Quy hoạch động Bitmask
Để hiểu được những kiến thức được đề cập trong bài viết này, bạn đọc cần nắm vững các kiến thức liên quan tới Thao tác xử lý bit (Bit manipulation). Các bạn có thể tìm đọc bài viết về kiến thức này tại đây.
I. Mở đầu
Các phép toán thao tác bit là những phép toán rất thú vị của những ngôn ngữ lập trình được hỗ trợ riêng như C/C++ và Java. Như chúng ta đều biết, các số nguyên trong máy tính được biểu diễn bằng một chuỗi hoặc nhiều chuỗi bit (dãy nhị phân), nhờ thế, ta có thể tận dụng các số nguyên để biểu diễn những dãy bit - hay còn gọi là các bitmask. Những phép toán thao tác bit trên các bitmask là một lựa chọn hiệu quả hơn so với các CTDL như vector
, set
hay bitset
,...nhờ vậy sẽ giúp thí sinh đạt được ưu thế trong các cuộc thi lập trình có tính cạnh tranh cao.
Một ứng dụng rất tuyệt vời của bitmask là biểu diễn các bài toán con trong các bài toán quy hoạch động. Bằng việc biểu diễn các bài toán con thông qua các dãy bit (gọi là các trạng thái) và sử dụng trạng thái nhỏ hơn để cập nhật kết quả cho các trạng thái lớn hơn, ta có một kĩ thuật rất hiệu quả để thay thế quay lui - nhánh cận, hoặc giải các bài toán quy hoạch động đếm cấu hình với số phần tử nhỏ. Trong bài viết này, tôi sẽ giới thiệu tới các bạn kĩ thuật sử dụng bitmask kết hợp với các bài toán quy hoạch động - Quy hoạch động bitmask.
II. Ý tưởng
1. Nhắc lại về "Trạng thái quy hoạch động"
Trạng thái (state) là một khái niệm cơ sở của quy hoạch động. Khi giải quyết một bài toán quy hoạch động, chúng ta cần đi tìm mục tiêu của bài toán lớn, sau đó xác định ra những bài toán con (hay những trường hợp nhỏ hơn) của bài toán lớn - đó gọi là các trạng thái.
Xét một ví dụ đơn giản:
Cho đồng xu và giá tiền của mỗi đồng cùng với một số nguyên . Tìm số đồng xu nhỏ nhất để tổng giá trị của chúng bằng (biết rằng số lượng đồng xu không giới hạn).
Trong bài toán trên, mục tiêu là đi tìm số đồng xu nhỏ nhất sao cho tổng giá trị của chúng bằng . Vậy các trạng thái sẽ là số lượng đồng xu nhỏ nhất để tổng bằng với mà . Muốn tìm ra trạng thái ta phải tìm ra các trạng thái . Và một khi đã tìm được trạng thái ta có thể dễ dàng tìm ra trạng thái của .
Để lưu trữ các trạng thái, ta sẽ sử dụng mảng để lưu kết quả tính toán được mỗi bước (gọi là bảng phương án). Trong bài toán Quy hoạch động bitmask, một hoặc một số trạng thái sẽ có thể biểu diễn bởi các bitmask (thường là các dãy con, tập con), và như đã nói, bitmask cũng có thể được duyệt bằng các số nguyên. Thông qua các thao tác xử lý bit, ta có thể tìm ra các trạng thái con của một trạng thái lớn (ví dụ như là trạng thái trước của tương ứng với hai số nguyên là và ).
2. Dấu hiệu nhận biết bài toán
Dấu hiệu đầu tiên để nhận biết những bài toán có khả năng sử dụng quy hoạch động bitmask, đó là ràng buộc dữ liệu. Thông thường, những bài toán này có độ dài của dãy số, của xâu kí tự, của tập hợp,...đều không vượt quá (có thể là những con số như ). Lí do là vì với ràng buộc thì số lượng dãy con của tập hợp sinh ra sẽ là đây là ràng buộc phù hợp để sử dụng các số nguyên duyệt trạng thái, rồi kết hợp với các kĩ thuật tính toán khác mà không bị Time Limit Exceeded.
Do việc sử dụng các bitmask để biểu diễn bài toán con, nên những bài toán quy hoạch động bitmask thường liên quan tới việc lựa chọn dãy con, tập con. Bảng phương án trong dạng bài này sẽ lưu trữ kết quả của các trạng thái (biểu diễn thông qua những số nguyên), chẳng hạn như thì ta có thể ngầm hiểu là (vì ).
3. Phân tích ý tưởng cụ thể
Cùng xét bài toán dưới đây (ASSIGN - SPOJ) để phân tích cách lựa chọn trạng thái trong quy hoạch động bitmask:
Đề bài
Một lớp học có học sinh, cô giáo phân công cho học sinh trong lớp nghiên cứu chủ đề học tập. Mỗi học sinh có sở thích học tập khác nhau, và không có học sinh nào muốn mình phải học một chủ đề không yêu thích.
Yêu cầu: Đếm số cách phân chia chủ đề học tập cho học sinh (mỗi học sinh một chủ đề), sao cho mỗi học sinh đều được học đúng chủ đề mình yêu thích?
Input:
- Dòng đầu tiên chứa số nguyên dương - số lượng test cases.
- Trên nhóm dòng tiếp theo, mỗi nhóm dòng mô tả một test case có cấu trúc:
- Dòng đầu tiên chứa số nguyên dương .
- Trên dòng tiếp theo, mỗi dòng chứa số nguyên mô tả sở thích của học sinh thứ : Nếu nghĩa là học sinh thứ không thích chủ đề ngược lại nghĩa là học sinh thứ yêu thích chủ đề .
Ràng buộc:
- .
- .
Output:
- In ra số cách phân chia thỏa mãn. Đáp án đảm bảo là một số nguyên bit.
Sample Input:
3
3
1 1 1
1 1 1
1 1 1
11
1 0 0 1 0 0 0 0 0 1 1
1 1 1 1 1 0 1 0 1 0 0
1 0 0 1 0 0 1 1 0 1 0
1 0 1 1 1 0 1 1 0 1 1
0 1 1 1 0 1 0 0 1 1 1
1 1 1 0 0 1 0 0 0 0 0
0 0 0 0 1 0 1 0 0 0 1
1 0 1 1 0 0 0 0 0 0 1
0 0 1 0 1 1 0 0 0 1 1
1 1 1 0 0 0 1 0 1 0 1
1 0 0 0 1 1 1 1 0 0 0
11
0 1 1 1 0 1 0 0 0 1 0
0 0 1 1 1 1 1 1 1 1 1
1 1 0 1 0 0 0 0 0 1 0
0 1 0 1 0 1 0 1 0 1 1
1 0 0 1 0 0 0 0 1 0 1
0 0 1 0 1 1 0 0 0 0 1
1 0 1 0 1 1 1 0 1 1 0
1 0 1 1 0 1 1 0 0 1 0
0 0 1 1 0 1 1 1 1 1 1
0 1 0 0 0 0 0 0 0 1 1
0 1 1 0 0 0 0 0 1 0 1
Sample Output:
6
7588
7426
Ý tưởng
Ở bài toán này, mục tiêu cần đi tìm số cách phân chia chủ đề hợp lí cho học sinh. Vậy các trạng thái nhỏ hơn sẽ là số cách phân chia chủ đề hợp lý cho học sinh ().
Ta đánh số các học sinh và các chủ đề từ tới .
Sử dụng bitmask, ta sẽ biểu diễn việc một học sinh thứ đã được phân chia chủ đề yêu thích của mình hay chưa. Gọi là trạng thái thể hiện học sinh thứ đã được phân chia chủ đề hay chưa, tương ứng với chưa và tương ứng với đã phân chia rồi. Ta sẽ lần lượt tìm cách phân chia cho từng chủ đề, vậy gọi là chỉ số của chủ đề đang được phân chia. Như vậy bảng phương án sẽ có dạng .
Ban đầu, coi như chưa có học sinh nào được phân chia chủ đề, tức là và mọi . Xét chủ đề thứ ta cần tìm một học sinh sao cho và bit thứ của đang tắt, khi đó ta có thể phân chia chủ đề này cho học sinh thứ rồi tiếp tục chuyển qua chủ đề thứ và tìm học sinh để phân chia như đã làm ở bước trước. Nếu như có thể tiến tới tức là đã phân chia được hết chủ đề cho học sinh, thì ta sẽ có được cộng thêm . Như vậy, ta có công thức truy hồi:
với là giá trị sau khi đã bật bit ở vị trí thứ lên và
Do trạng thái nhỏ hơn sẽ cập nhật sang trạng thái lớn hơn ở mỗi bước, nên quy hoạch động top-down sẽ là một phương pháp cài đặt thuận lợi, có thể mô tả như sau:
vector < vector < long long > > dp(1 << n, vector < long long >(n, -1));
int calc(int mask, int k)
{
if (k == n)
return 1;
if (dp[mask][k] != -1)
return dp[mask][k];
long long ans = 0;
for (int i = 0; i < n; ++i)
if (a[i][k] && !(mask & (1 << i)))
ans += calc(mask | (1 << i), k + 1);
return dp[mask][k] = ans;
}
main()
{
// Xuất phát từ trạng thái 0 - chưa có học sinh nào được phân chia chủ đề.
// và k = 0 - chủ đề đầu tiên cần phân chia là chủ đề số 0.
calc(0, 0);
// Kết quả sẽ nằm ở dp[0][0].
cout << dp[0][0];
return 0;
}
Độ phức tạp của cách làm này là vẫn có thể gây ra tràn bộ nhớ hoặc quá thời gian chạy (do bài toán có multi-testcase), vì thế ta cần cải tiến thêm. Nhận xét rằng, nếu hiện tại đang có bit thì tương ứng với việc đã có chủ đề được phân chia. Do ta đánh số các chủ đề từ nên vô tình sẽ chính là số hiệu của chủ đề tiếp theo cần được phân chia.
Vậy thay vì sử dụng một chiều để lưu trong bảng phương án, ta có thể tính ở mỗi lần gọi đệ quy thông qua câu lệnh: int k = __builtin_popcount(mask)
, ở đây __builtin_popcount()
là hàm dựng sẵn của C++ dùng để đếm số bit của một số nguyên.
Lúc này, độ phức tạp sẽ giảm xuống còn .
Cài đặt
#pragma GCC optimize("O3","unroll-loops")
#pragma GCC target("avx2")
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int maxn = 20;
int dp[1 << maxn], a[maxn][maxn];
int calc(int n, int mask)
{
int k = __builtin_popcount(mask);
if (k == n)
return 1;
if (dp[mask] != -1)
return dp[mask];
int ans = 0;
for (int i = 0; i < n; ++i)
if (a[i][k] && !(mask & (1 << i)))
ans += calc(n, mask | (1 << i));
return dp[mask] = ans;
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int t;
cin >> t;
while (t--)
{
int n;
cin >> n;
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
cin >> a[i][j];
// Xuất phát từ trạng thái 0 - chưa có học sinh nào được phân chia chủ đề.
memset(dp, -1, sizeof(dp));
calc(n, 0);
// Kết quả sẽ nằm ở dp[0] do cách thực hiện đệ quy có nhớ.
cout << dp[0] << '\n';
}
return 0;
}
Kết luận
Từ việc phân tích ví dụ trên, ta có thể rút ra một số nhận định chung về kĩ thuật Quy hoạch động bitmask như sau:
- Bảng phương án sẽ luôn luôn có một chiều - đại diện cho trạng thái có thể biểu diễn bằng bitmask.
- Trong nhiều trường hợp, bảng phương án sẽ lưu thêm một trạng thái, trở thành dạng với là vị trí cuối cùng được thêm vào tập con/dãy con. Bài toán lúc này thường sẽ có kết quả bị ảnh hưởng bởi thứ tự sắp xếp các phần tử trong một cấu hình biểu diễn bởi - ví dụ như ở bài toán Assign bên trên. Còn nếu như kết quả của bài toán không phụ thuộc vào cách sắp xếp của các phần tử trong một cấu hình, thì thường bảng phương án chỉ có dạng - tức là với mỗi cấu hình, cách lựa chọn đó là cố định.
Bây giờ, hãy cùng đến với một số bài tập nâng cao để hiểu rõ hơn về kĩ thuật Quy hoạch động bitmask!
III. Bài tập minh họa
1. Gói cước bạn bè
Đề bài
Một công ty có nhân viên đánh số từ tới . Ông chủ công ty trang bị cho mỗi nhân viên một điện thoại di động để tiện liên lạc khi làm việc. Cước phí sử dụng điện thoại sẽ do công ty chi trả.
Sau một vài tháng hoạt động, do chi phí thanh toán các hóa đơn điện thoại khá lớn nên ông chủ quyết định tìm cách giảm chi phí bằng cách yêu cầu các nhân viên của mình đăng ký sử dụng gói cước "bạn bè" của công ty viễn thông L4T (Looking for Troubles). Gói cước "bạn bè" có đặc điểm sau:
- Mỗi gói cước chỉ cho đúng hai người đăng ký tham gia, khi hai người đăng ký sử dụng gói cước "bạn bè" thì giá cước hai người đó gọi cho nhau sẽ chỉ là đồng/phút thay vì đồng/phút theo cước phí thông thường.
- Mỗi người chỉ được tham gia không quá một gói cước "bạn bè".
Dựa vào nhu cầu công việc, ông chủ ước lượng rằng trong mỗi tháng sẽ có cuộc gọi đánh số từ tới cuộc gọi thứ do nhân viên gọi nhân viên trong phút.
Yêu cầu: Hãy giúp ông chủ công ty yêu cầu các nhân viên của mình đăng ký sử dụng các gói cước “bạn bè” sao cho tổng số tiền điện thoại phải thanh toán hàng tháng cho các nhân viên là nhỏ nhất có thể?
Input:
- Dòng đầu tiên chứa hai số nguyên dương và .
- Dòng thứ hai chứa một số nguyên dương chẵn .
- Dòng thứ ba chứa số nguyên dương .
- Trên dòng tiếp theo, dòng thứ chứa ba số nguyên dương .
Constraints:
- .
- .
- .
- .
Output:
- Dòng đầu tin ghi số tiền phải trả hàng tháng theo phương án tìm được.
- Trên các dòng tiếp theo, mỗi dòng ghi số hiệu hai nhân viên được yêu cầu đăng ký sử dụng gói cước “bạn bè”.
Sample Input:
1 2
4
5
2 3 18
2 4 20
3 2 2
4 1 12
2 4 6
Sample Output:
84
1 4
2 3
Ý tưởng
Ta xây dựng các mảng sau:
- có nghĩa là tổng số phút gọi của người tới người .
- có nghĩa là tổng số phút gọi của người tới tất cả mọi người.
Hai mảng trên có thể tính dễ dàng khi đọc vào cuộc gọi.
Gọi là số cước phí ít nhất của những người . Ở đây, biến là số nguyên thể hiện trạng thái lựa chọn của nhân viên.
Ta tiến hành quy hoạch động bitmask như sau:
- Duyệt trong đoạn .
- Với mỗi ta duyệt cặp . Nếu khác và cả hai bit đều chưa được bật trong tập ta tiến hành chuyển trạng thái:
nw = mask | (1 << i) | (1 << j)
- tức là thêm người và vào tập bằng cách bật bit ở vị trí và lên.- Cập nhật: với là chi phí của người và gọi tới các người khác. Do ta ghép cặp người với người chi phí các cuộc gọi giữa họ chính là còn chi phí của họ gọi tới các người khác chính là .
- Để truy vết, ta sử dụng một mảng kiểu
pair < int, int >
là - với là để truy vết được cập nhật tối ưu nhờ thêm hai người nào. - Nếu ta có:
- .
- .
Đáp án của bài toán chính là bởi đoạn khi cập nhật ta đã tính trùng lần.
Để truy vết kết quả, ta sẽ dùng mảng đi ngược lại như sau:
int mask = mx - 1;
while (mask)
{
pair < int, int > t = tv[mask];
cout << t.first + 1 << ' ' << t.second + 1 << '\n';
// Mỗi bước truy vết thì tắt 2 bit đã bật trong bước hiện tại.
mask ^= (1 << t.first);
mask ^= (1 << t.second);
}
Độ phức tạp: .
Code mẫu
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int maxn = 16;
int f, r, n, m;
int val[maxn][maxn], all[maxn];
int dp[1 << maxn];
pair < int, int > tv[1 << maxn];
bool mini(int &a, int b)
{
if (a > b)
{
a = b;
return true;
}
return false;
}
void solution()
{
memset(dp, 0x3f3f3f, sizeof(dp));
int inf = dp[0];
dp[0] = 0;
int mx = 1 << n;
for (int mask = 0; mask < mx; ++mask)
{
if (dp[mask] == inf)
continue;
for (int i = 0; i < n; ++i)
{
if (mask & (1 << i))
continue;
for (int j = 0; j < n; ++j)
{
if (i == j || (mask & (1 << j)))
continue;
int nw = mask | (1 << i) | (1 << j);
if (mini(dp[nw], dp[mask] + 2 * val[i][j] * f + (all[i] - val[i][j]) * r + (all[j] - val[j][i]) * r))
tv[nw] = {i,j};
}
}
}
cout << dp[mx - 1] / 2 << '\n';
// Truy vết.
int mask = mx - 1;
while (mask)
{
pair < int, int > t = tv[mask];
cout << t.first + 1 << ' ' << t.second + 1 << '\n';
mask ^= (1 << t.first);
mask ^= (1 << t.second);
}
}
main()
{
cin >> f >> r >> n >> m;
for (int i = 1; i <= m; i++)
{
int u,v,c;
cin >> u >> v >> c;
u--;
v--;
if (u > v)
swap(u,v);
val[u][v] += c;
val[v][u] += c;
all[u] += c;
all[v] += c;
}
solution();
return 0;
}
2. Phép tính cổ
Đề bài
Một đoàn khảo cổ đang thám hiểm một hang động, và tình cờ tìm thấy một căn hầm từ thời cổ đại. Điều đặc biệt là căn hầm này mặc dù đã có từ xa xưa, nhưng lại có hệ thống mật mã giống như các hệ thống hiện đại. Trên cửa hầm có ghi một bài toán cổ, đáp án của bài toán đó chính là mật mã để vào trong căn hầm. Bài toán như sau:
Cho trước hai số nguyên dương và . Mật mã của căn hầm là số lượng số gần với . Biết rằng, một số nguyên dương được gọi là gần với nếu như nó thỏa mãn các điều sau:
- được tạo thành bằng cách hoán vị các chữ số của .
- không bắt đầu bằng chữ số .
- chia hết cho .
Yêu cầu: Hãy giúp đoàn khảo cổ tìm ra mật mã của căn hầm?
Input:
- Một dòng duy nhất chứa hai số nguyên dương và phân tách nhau bởi dấu cách.
Ràng buộc:
- .
- .
Subtasks:
- Subtask ( số điểm): .
- Subtask ( số điểm): Không có ràng buộc gì thêm.
Output:
- Số nguyên duy nhất là mật mã tìm được.
Sample Input 1:
104 2
Sample Output 1:
3
Sample Input 2:
223 4
Sample Output 2:
1
Sample Input 3:
7067678 8
Sample Output 3:
47
Ý tưởng
Subtask 1
Với subtask này, vì nên ta có thể sử dụng quay lui để sinh mọi hoán vị của và đếm số hoán vị chia hết cho . Tuy nhiên, cần lưu ý các trường hợp hoán vị sinh ra bị trùng nhau, cũng như các hoán vị bắt đầu bằng chữ số thì không đếm.
Độ phức tạp: với là số chữ số của .
Subtask 2
Với subtask này, ta sử dụng quy hoạch động trạng thái.
Đặt là số hoán vị của với các chữ số được chọn theo trạng thái và chia cho dư .
Giả sử trạng thái hiện tại là và chúng ta muốn thêm chữ số ở vị trí thứ vào trạng thái này (đồng nghĩa với việc phải bật bit thứ trong lên). Ta sẽ cập nhật từ trạng thái lên trạng thái tiếp theo dựa trên hai yếu tố:
- Trạng thái mới là: .
- Thêm chữ số thứ vào vị trí cuối cùng của các số được tạo ra bởi trạng thái mà chia cho dư từ đó tạo ra số mới có số dư khi chia cho là: .
Từ đây rút ra công thức:
với là toán tử |
xử lý bit
Tuy nhiên, có một vấn đề xảy ra là đối với những chữ số giống nhau ở trong thì việc hoán vị chúng sẽ tạo ra các số giống nhau. Lấy ví dụ, nếu có chữ số được chọn cùng là chữ số thì những hoán vị được tạo ra mà có chứa ba chữ số này sẽ bị lặp lại tới lần. Vì thế, ta cần phải loại bỏ những trường hợp trùng lặp này. Có cách để làm điều đó:
- Cách : Sau khi tính toán xong toàn bộ mảng chia kết quả cuối cùng cho các giá trị với là các chữ số trong và là số lần xuất hiện của chúng.
- Cách : Sử dụng một mảng đánh dấu trong quá trình tính toán là để thể hiện rằng chữ số đã được chọn hay chưa. Nếu đã chọn rồi thì không cập nhật trạng thái hiện tại lên kết quả nữa.
Độ phức tạp: .
Code mẫu
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int maxn = (1 << 18) + 5, maxr = 105;
int m;
int dp[maxn][maxr], visited[20];
string n;
void enter()
{
cin >> n >> m;
}
void solution()
{
dp[0][0] = 1;
for (int mask = 0; mask < (1 << n.size()); ++mask)
for (int r = 0; r < m; ++r)
{
memset(visited, 0, sizeof(visited));
for (int i = 0; i < n.size(); ++i)
{
int digit = n[i] - '0';
if (mask & (1 << i)) // If digit is was chosen, then consider next digit.
continue;
if (mask == 0 && digit == 0) // Do not choose the first digit is zero.
continue;
if (visited[digit])
continue;
visited[digit] = 1;
dp[mask | (1 << i)][(r * 10 + digit) % m] += dp[mask][r];
}
}
cout << dp[(1 << n.size()) - 1][0];
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
enter();
solution();
return 0;
}
Các bạn có thể luyện tập thêm về các bài tập quy hoạch động bitmask tại một số nguồn dưới đây:
IV. Tài liệu tham khảo
- https://vnoi.info/wiki/translate/topcoder/dynamic-programming.md
- https://www.hackerearth.com/practice/algorithms/dynamic-programming/bit-masking/tutorial/?fbclid=IwAR2RG8fGRbrn7FH2Ri_MAijUoFN7XCvQjBnskUMsFunlN69CeXDA3zRdcAw
- https://www.geeksforgeeks.org/bitmasking-and-dynamic-programming-set-1-count-ways-to-assign-unique-cap-to-every-person/
- https://cp.cyberlabs.club/docs/roadmap/advanced/dp-with-bitmasks/
©️ Tác giả: Vũ Quế Lâm từ Viblo
All rights reserved