Quy hoạch động Bitmask Phần 2
Trước khi bắt đầu, các bạn độc giả cần nắm được cách biểu diễn tập hợp nhỏ bằng bitmask và kỹ thuật Quy hoạch động bitmask cơ bản được trình bày ở bài viết này.
Trong bài viết này, các kí hiệu của phép toán trên tập hợp sẽ được áp dụng cho cả ngôn ngữ bitmask.
Giới thiệu
DP SOS (Dynamic Programming on Subsets) là một kỹ thuật trong lập trình động, được sử dụng để giải quyết các bài toán liên quan đến tập hợp và tổ hợp. Kỹ thuật này cho phép ta tính toán các giá trị của các tập hợp con một cách hiệu quả, thường là bằng cách sử dụng bitmask để biểu diễn các tập hợp.
DP SOS thường áp dụng cho các bài toán như tìm kiếm tập hợp con tối ưu, hoặc phân chia một tập hợp thành các phần tử sao cho thỏa mãn một điều kiện nhất định. Thay vì tính toán lại từ đầu cho mỗi tập hợp con, DP SOS lưu trữ kết quả đã tính toán trước đó, giúp giảm thiểu thời gian tính toán và tăng tốc độ giải quyết bài toán. Kỹ thuật này rất hữu ích trong các bài toán tối ưu hoá kết hợp, đặc biệt là khi số lượng phần tử không quá lớn.
Bài toán kinh điển
Trước tiên, chúng ta hãy nghiên cứu cách sử dụng DP SoS để giải quyết bài toán sau: Xét các tập là tập con của \({0,1,2,..,n-1}\) , với mỗi tập như vậy, ta gán cho nó một giá trị \(a[u]\). Ta cần tính giá trị hàm \(f\) cho mọi tập con \(S\) của tập \({0,1,2,..,n-1}\) theo công thức: \(f[U]=a[v_{0}]+a[v_{1}]+…\) với \(v_{i}\) là các tập con của \(U\).
Cách sử dụng thuật toán vét cạn
Đối với phương pháp vét cạn, chúng ta có thể duyệt từng tập rồi từng tập và kiểm tra xem có phải tập con của hay không, cách làm này tốn độ phức tạp .
Ngoài ra, có thể tối ưu một chút bằng cách với mỗi tập , ta chỉ duyệt đúng các tập là tập con của với phương pháp duyệt tập con. Lúc này, mỗi bit trong bitmask có trạng thái: bit tắt ở tập cha, bit bật ở tập cha nhưng không được bật trong tập con, bit bật trong cả tập cha và tập con. Do đó, độ phức tạp thời gian là .
Code tham khảo
fill(f, f + (1 << n), a[0]); // do phương pháp duyệt tập con bỏ qua tập rỗng
for (int mask = 0; mask < (1 << n); mask++) {
for (int sub = mask; sub; sub = (sub - 1) & mask) f[mask] += a[sub];
}
Ý tưởng & Thuật toán dp sos
Chúng ta định nghĩa tập con \( S(x, i) \) như sau: \[ S(x, i) = \{ y \in X \; | \; y \oplus x \in 2^i \} \]
Nói một cách đơn giản, \( S(x, i) \) chứa tất cả các mặt phụ của \( x \) mà các bit bên trái của bit \( i \)-th khớp với các bit của \( x \).
Chúng ta có thể phân tích \( S(x, i) \) như sau: \[ S(x, i) = \{ 11000000, 11000001, 11000100, 11000101 \} \]
1. Bit \( i \)-th của \( x \) là 0: \[ S(x, i) = S(x, i-1) \]
2. Bit \( i \)-th của \( x \) là 1: \[ S(x, i) = S(x, i-1) \cup \{ y | y \text{ có bit } i\text{-th được đặt} \} \]
Sử dụng phân chia trên, chúng ta suy ra một bảng DP \( dp[x][i] \) như sau: \[ dp[x][i] = \sum_{y \in S(x, i)} |A[y]| \]
{Độ phức tạp thời gian:} \( O(N – 2^N) \)
Cài đặt
Với công thức truy hồi như trên, khi cài đặt, việc duyệt theo biến hay trước đều đúng. Tuy nhiên, mỗi cách làm có ưu/nhược điểm riêng nên ta sẽ tìm hiểu cả hai.
Cách 1
Ta sẽ duyệt theo biến trước:
vector sos(1 << n);
vector<vector> dp(1 << n, vector(n + 1));
for (int x = 0; x < (1 << n); x++) {
dp[x][0] = a[x];
for (int i = 0; i < n; i++) {
dp[x][i + 1] = dp[x][i];
if (x & (1 << i)) { dp[x][i + 1] += dp[x ^ (1 << i)][i]; }
}
sos[x] = dp[x][n];
}
Ưu điểm của cách cài đặt này là khi bắt đầu tính cho một bất kì, ta đã có kết quả của mọi trạng thái liên quan đến sub-masks của , do đó, cách này còn được gọi là “online” DP SoS. Điều này vô cùng hữu ích đối với những bài DP SoS có công thức tự gọi lại chính nó.
Tuy nhiên, cách làm này không thể tối ưu bộ nhớ xuống được.
Cách 2 (Tối ưu bộ nhớ)
Ta có thể đưa biến lên trước trong cách đặt trạng thái và chỉ duy trì dòng của bảng Quy hoạch động, hay thậm chí là dòng nếu ta duyệt các mask từ lớn đến nhỏ (ý tưởng tương tự DP Knapsack).
for (int k = 1; k <= n; k++)
for (int mask = (1 << n) - 1; mask >= 0; mask--)
if (mask & (1 << (k - 1))) sos[mask] += sos[mask ^ (1 << (k - 1))];
Để cài đặt ngắn gọn hơn nữa, ta còn có thể đưa ra nhận xét rằng đối với DP SoS, việc duyệt các mask từ nhỏ đến lớn cũng không làm sai kết quả. Hơn nữa, vì không còn cần phải đánh số dòng trên bảng QHĐ, ta có thể duyệt từ đến và coi trường hợp cơ sở là , sẽ thuật tiện hơn cho việc xử lý bitmask. Đến đây, ta đã có code DP SoS rất ngắn, dễ cài đặt, tối ưu bộ nhớ và hằng số thấp. Nhìn chung, cách làm này phổ biến hơn cách 1.
for (int k = 0; k < n; k++)
for (int mask = 0; mask < (1 << n); mask++)
if (mask & (1 << k)) sos[mask] += sos[mask ^ (1 << k)];
SOS DP as N-Dimensional Prefix Sum
Trước khi tiếp tục, hãy xem lại tổng tiền tố 2D. Cho mảng \( A \) có kích thước \( n \times m \), mảng tổng tiền tố \( S \) được định nghĩa như sau: \[ S[i][j] = \sum_{a \leq i} \sum_{b \leq j} A[a][b] \] Phương pháp chuẩn sử dụng bao gồm loại trừ: \[ S[i][j] = S[i-1][j-1] + S[i-1][j] + S[i][j-1] – S[i-1][j-1] + A[i][j] \] Mặc dù phương pháp này hoạt động cho 2D, nó có một hạn chế đáng kể: khi số chiều tăng lên, số lượng các phép toán cần thêm hoặc bớt cũng tăng, làm cho việc tính toán trở nên không hiệu quả cho nhiều chiều.
// Initialize
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) { S[i][j] = A[i][j]; }
}
// Sweep along x-axis
for (int i = 1; i < n; i++) {
for (int j = 0; j < m; j++) { S[i][j] += S[i - 1][j]; }
}
// Sweep along y-axis
for (int i = 0; i < n; i++) {
for (int j = 1; j < m; j++) { S[i][j] += S[i][j - 1]; }
}
Phương pháp này tổng quát hóa cho các chiều lớn hơn.
Giả sử chúng ta muốn tính tổng tiền tố cho 4D. Chúng ta có thể tính toán điều này bằng cách quét dọc theo từng trục của 4D, tại một thời điểm:
Đầu tiên, quét dọc theo trục \( a \): \[ S[i][j][k][l] = \sum_{0 \leq a \leq i} A[a][j][k][l] \]
Sau khi quét dọc theo trục \( x \): \[ S[i][j][k][l] = \sum_{0 \leq a \leq i} S[a][j][k][l] \]
Tiếp theo, quét dọc theo trục \( b \): \[ S[i][j][k][l] = \sum_{0 \leq b \leq j} S[i][b][k][l] \]
Cuối cùng, sau khi quét dọc theo trục \( c \): \[ S[i][j][k][l] = \sum_{0 \leq c \leq k} S[i][j][c][l] \]
Nếu chúng ta mở rộng điều này sang các chiều, đây là những gì xảy ra: khi quét theo chỉ số \( i \)-th, \( S[i] \) chứa các giá trị mà \( A \) đã tính toán ở đó. Hơn nữa, các chỉ số đầu tiên là tọa độ mà chúng ta đang xem xét trong bài toán tìm kiếm.
Điều này cho thấy, khi chúng ta thêm một bit vào một lưới, thì bit đó là một tọa độ \( n \)-chiều, \( F(x) \) tương ứng với định nghĩa của một tổng tiền tố \( n \)-chiều.
Bằng cách quét theo từng chiều, chúng ta có thể tối ưu hóa DP mà đã được đề cập, chứng minh rằng SOS DP thực sự là một tổng tiền tố \( n \)-chiều.
F = A;
for (int i = 0; i < n; i++) { // Sweep along the i-th axis
for (int x = 0; x < (1 << n); x++) {
if (x & (1 << i)) // If the i-th bit is set, accumulate
F[x] += F[x ^ (1 << i)];
}
}
¶ Tham khảo







