DP broken profile
DP broken profile (quy hoạch động theo "đường biên gãy") là kỹ thuật DP dùng bitmask để lấp đầy một lưới ô vuông theo từng ô một (thay vì từng cột nguyên khối). Nó giải các bài toán đếm/tối ưu cách lát gạch, tô màu lưới, đặt vật trên lưới với độ phức tạp — nhanh hơn nhiều so với liệt kê toàn bộ cấu hình một cách ngây thơ.
Ý tưởng cốt lõi: khi duyệt lưới theo thứ tự ô-một, tại mỗi thời điểm ta chỉ cần biết trạng thái của ô nằm trên "đường biên" giữa phần đã xử lý và phần chưa xử lý. ô đó được mã hoá bằng một bitmask bit, nên số trạng thái chỉ là .
Ý tưởng / Trực giác
Hãy hình dung ta lấp lưới theo thứ tự đọc sách: hàng 0 từ trái sang phải, rồi hàng 1, ... Tại một thời điểm bất kỳ, "biên giới" giữa vùng đã quyết định và vùng chưa quyết định không phải một đường thẳng, mà là một đường gấp khúc (broken profile): vài ô của hàng hiện tại đã xử lý, vài ô của hàng trên vẫn còn ảnh hưởng xuống.
Câu hỏi mấu chốt: để điền tiếp các ô còn lại, ta cần nhớ gì về quá khứ? Câu trả lời: chỉ cần biết, trong số ô nằm ngay trên đường biên, ô nào đã bị một viên gạch trước đó chiếm chỗ (sẽ "thò" xuống), ô nào còn trống. Mọi chi tiết khác của quá khứ (gạch đặt thế nào ở các hàng phía trên) không ảnh hưởng tới cách lấp phần còn lại. Đây chính là tính chất Markov khiến DP áp dụng được.
Vì đường biên đúng bằng ô liên tiếp nên ta mã hoá nó bằng bitmask bit:
- bit : ô tương ứng đã bị chiếm (bởi gạch đặt từ ô trước/hàng trên).
- bit : ô tương ứng còn trống, cần được lấp.
Khi xử lý ô , ta đọc bit ứng với ô đó: nếu đã bị chiếm thì chỉ việc "trượt" biên qua; nếu còn trống thì thử mọi cách đặt gạch phủ ô này. Mỗi lựa chọn sinh ra một bitmask mới cho bước kế tiếp. Tổng số cách = tổng các đường đi hợp lệ trong đồ thị trạng thái này.
So với DP bitmask theo cột (chuyển cả cột một lúc, phải liệt kê mọi cặp mask hợp lệ giữa hai cột), broken profile chuyển từng ô, nên logic chuyển trạng thái đơn giản hơn (mỗi bước chỉ vài lựa chọn), dễ mở rộng cho gạch hình dạng lạ, và tránh phải tiền tính bảng tương thích cột.
Ví dụ chạy tay
Bài toán: đếm số cách lát kín lưới bằng domino và (đáp số đúng là 2: hai domino ngang, hoặc hai domino dọc).
Quy ước: ta duyệt ô theo thứ tự . Bitmask có bit; bit ứng với cột của "đường biên hiện tại". Khi đứng ở ô :
- bit = trạng thái của chính ô (đã bị gạch dọc từ hàng trên phủ chưa).
- các bit ứng với các ô còn lại của hàng ; các bit ứng với các ô của hàng (đã được gạch dọc "đặt trước").
Khởi tạo: dp[00] = 1 (chưa điền gì, biên trống trơn).
Lưới 2x2, ô đang xét đánh dấu [ ]:
Bước (0,0): Bước (0,1): Bước (1,0): Bước (1,1):
[.] . x [.] [?] . x [?]
. . . . ? . ? .
Xét ô , mask 00 (bit0=ô(0,0), bit1=ô(0,1)):
- ô trống. Đặt gạch dọc phủ : đánh dấu ô sẽ bị chiếm. Sang ô kế, mask thành
01(bit ô (0,0) tắt khi trượt qua, nhưng ô(1,0) được ghi). Để gọn, ta dùng quy ước mask đại diện "các ô của biên kế tiếp": kết quảdp'[10] += 1. - Đặt gạch ngang phủ : cả hai ô hàng 0 bị chiếm:
dp'[01] += 1(ô (0,1) đánh dấu chiếm).
Sau bước :
| mask (sau khi qua (0,0)) | nghĩa | giá trị |
|---|---|---|
10 |
ô (1,0) bị gạch dọc chiếm, ô (0,1) trống | 1 |
01 |
ô (0,1) bị gạch ngang chiếm | 1 |
Xét ô :
- Từ mask
10(ô (0,1) trống): chỉ còn cách đặt gạch dọc phủ → đánh dấu ô (1,1). Sang hàng 1, mask11(cả (1,0),(1,1) bị chiếm). - Từ mask
01(ô (0,1) đã bị chiếm): chỉ trượt qua, hàng 1 hoàn toàn trống → mask00.
Sang hàng 1:
| mask | nghĩa | giá trị |
|---|---|---|
11 |
(1,0),(1,1) đều bị chiếm sẵn | 1 |
00 |
hàng 1 trống hoàn toàn | 1 |
Xét ô rồi :
- Nhánh
11: cả hai ô hàng 1 đã bị chiếm sẵn → chỉ trượt qua, không đặt gì → về00. Hợp lệ (đây là phương án "hai gạch dọc"). - Nhánh
00: ô (1,0) trống, đặt gạch ngang phủ → cả hai bị chiếm → kết thúc về00. Hợp lệ (phương án "hai gạch ngang"). (Đặt gạch dọc ở (1,0) sẽ thò ra ngoài lưới nên bị loại.)
Kết quả cuối: dp[00] = 2. Đúng bằng 2 cách lát mong đợi. Bảng DP đã "thấy" rõ hai đường đi tương ứng hai cấu hình.
Cài đặt
Cài đặt chuẩn cho bài đếm cách lát domino / lưới (lấy là chiều nhỏ), kết quả modulo :
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
int n, m;
int main() {
cin >> n >> m;
if (m > n) swap(n, m); // luôn để m là chiều nhỏ -> 2^m nhỏ nhất
// dp[mask]: số cách điền tới ô hiện tại, mask = trạng thái m ô của biên gãy.
// bit = 1 nghĩa ô tương ứng đã bị một viên gạch chiếm trước đó.
vector<long long> dp(1 << m, 0);
dp[0] = 1; // biên hoàn toàn trống
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
vector<long long> ndp(1 << m, 0);
for (int mask = 0; mask < (1 << m); mask++) {
if (!dp[mask]) continue;
if (mask & (1 << j)) {
// Ô (i,j) đã bị chiếm (bởi gạch dọc từ hàng trên):
// chỉ trượt biên qua, xoá bit j cho bước kế tiếp.
int nm = mask ^ (1 << j);
ndp[nm] = (ndp[nm] + dp[mask]) % MOD;
} else {
// Ô (i,j) trống, bắt buộc phải phủ nó.
// 1) Gạch dọc 2x1: phủ (i,j) và (i+1,j).
// Đánh dấu bit j (ô (i+1,j) sẽ bị chiếm ở hàng sau).
if (i + 1 < n) {
int nm = mask | (1 << j);
ndp[nm] = (ndp[nm] + dp[mask]) % MOD;
}
// 2) Gạch ngang 1x2: phủ (i,j) và (i,j+1).
// Yêu cầu ô (i,j+1) còn trống (bit j+1 = 0).
if (j + 1 < m && !(mask & (1 << (j + 1)))) {
int nm = mask | (1 << (j + 1)); // ô (i,j+1) bị chiếm
ndp[nm] = (ndp[nm] + dp[mask]) % MOD;
}
}
}
dp = move(ndp);
}
}
// Sau khi đi hết lưới, đáp án là dp[0]:
// không ô nào "thò" ra ngoài, mọi ô đã được lấp khít.
cout << dp[0] << "\n";
return 0;
}
Mở rộng thêm loại gạch chỉ cần thêm nhánh trong khối "ô trống". Ví dụ gạch phủ :
// (đặt cùng chỗ với các nhánh đặt gạch, khi ô (i,j) trống)
if (i + 1 < n && j + 1 < m && !(mask & (1 << (j + 1)))) {
int nm = mask | (1 << j) | (1 << (j + 1)); // hai ô hàng dưới bị chiếm
ndp[nm] = (ndp[nm] + dp[mask]) % MOD;
}
Với lưới có ô cấm blocked[i][j]: nếu ô bị cấm thì nó không được đặt gạch và cũng không được bị gạch khác phủ — chỉ cho phép chuyển khi bit j == 0 rồi trượt qua (giữ bit j = 0).
Độ phức tạp
| Thời gian | có ô; mỗi ô duyệt qua tất cả mask, mỗi mask làm việc | |
| Bộ nhớ | chỉ giữ hai mảng dp và ndp kích thước |
Lý giải thời gian: vòng ngoài chạy đúng số ô ; với mỗi ô, vòng mask quét trạng thái và mỗi trạng thái sinh ra nhiều nhất 2–3 chuyển trạng thái hằng số. Nhân lại đúng bằng .
Về bộ nhớ: ta luôn chỉ cần trạng thái biên hiện tại và kế tiếp, nên không lưu toàn bộ lịch sử — chỉ . Giới hạn thực tế thường là vì (khi đó vẫn vừa thời gian); luôn xoay lưới để là chiều nhỏ hơn.
Khi cực lớn nhưng rất nhỏ (ví dụ ), có thể gộp mỗi hàng thành một ma trận chuyển rồi dùng lũy thừa ma trận để đạt .
⚠️ Lỗi thường gặp
- Quên modulo / tràn số. Khi đếm cách lát, kết quả tăng theo cấp số nhân; phải cộng dồn vào
long longvà lấy% MODngay sau mỗi phép cộng. Đểintsẽ tràn âm thầm và cho đáp án sai mà không lỗi runtime. - Sai định nghĩa bit tại ô đang xét. Phải nhất quán: bit luôn ứng với chính ô đang xét. Nếu lúc thì coi bit là ô hàng trên, lúc thì ô hàng hiện tại, toàn bộ chuyển trạng thái sẽ lệch. Hãy chốt một quy ước (như trong code) và bám sát.
- Đặt gạch thò ra ngoài lưới (off-by-one biên). Gạch dọc chỉ hợp lệ khi
i + 1 < n; gạch ngang chỉ khij + 1 < m. Bỏ kiểm tra này sẽ đếm cả cấu hình vượt biên → đáp án lớn hơn thực tế. - Quên điều kiện ô kế còn trống khi đặt gạch ngang. Đặt gạch ngang phủ chỉ hợp lệ khi bit đang là 0. Nếu ô đã bị gạch dọc từ hàng trên chiếm, đặt thêm gạch ngang là chồng gạch — phải loại bằng
!(mask & (1 << (j+1))). - Lấy sai trạng thái kết thúc. Đáp án là
dp[0]sau khi đi hết toàn bộ ô, nghĩa là không ô nào còn "thò" sang ngoài. Lấydp[mask]vớimask != 0, hoặc đọc đáp án giữa chừng, đều sai. - Không xoay lưới khi . là yếu tố quyết định tốc độ/bộ nhớ; quên
swapđể là chiều nhỏ có thể khiến nổ (ví dụ lưới mà lấy ). - Sửa
dpngay trong khi đang duyệt. Phải ghi kết quả vào mảng mớindprồi mới gán lại; cập nhật tại chỗ sẽ trộn lẫn trạng thái cũ và mới của cùng một bước.
Biến thể / Mở rộng
- Tô màu / gán nhãn lưới với ràng buộc lân cận (số màu nhỏ): broken profile mang theo màu của các ô biên thay vì 1 bit, trạng thái thành (ví dụ bài tối thiểu hoá độ bất hoà âm khi và nhỏ).
- Profile nhiều hàng cho gạch hình L, T, hoặc gạch cao hơn 2 ô — biên cần nhớ 2+ hàng.
- Broken profile + lũy thừa ma trận khi rất lớn, rất nhỏ.
- Liên hệ với DP bitmask (chuyển theo cột) và DP trên cây khi cấu trúc không phải lưới.
Bài tập luyện
- Domino trên lưới (gridomi) — (Advanced) Lưới có ô cấm
#; làm quen mô hình "đặt domino trên lưới ô vuông có ô bị chặn" trước khi đi vào đếm cấu hình bằng broken profile. - Đếm Cách Lát Gạch (cnttiling) — (Veteran) Bài kinh điển đúng chuẩn broken profile: đếm số cách lát kín lưới — áp dụng trực tiếp cài đặt ở trên.
- Đội Hợp Xướng Hogwarts (choirform) — (Veteran) Profile DP dạng tối ưu: gán mỗi ô một trong loại giọng để tối thiểu hoá tổng "bất hoà âm" giữa các ô kề; biên mang theo màu của hàng, mở rộng broken profile sang bài tô màu lưới.