Có 40 món đồ, giá mỗi món tới ; có bao nhiêu cách chọn một nhóm món có tổng giá đúng ? Thử mọi nhóm là cách, mất hàng nghìn giây. Quy hoạch động theo tổng cũng không được, vì tổng tới .
Mẹo là chia đôi: 20 món đầu cho tổng, 20 món sau cũng vậy, và mỗi nhóm món của cả 40 món là một tổng bên trái cộng một tổng bên phải. Việc còn lại là tìm cặp có tổng giữa hai danh sách, việc mà sắp xếp và hai con trỏ làm rất nhanh. Kỹ thuật này gọi là gặp nhau ở giữa (meet in the middle): chi phí từ xuống cỡ .
Chia đôi rồi ghép
Bài toán: cho và , đếm số tập con có tổng bằng . Hai phần tử cùng giá trị ở hai vị trí khác nhau cho hai tập con khác nhau. Duyệt thẳng là tập con: mất vài mili giây, sát một giây, hết hy vọng. Quy hoạch động theo tổng (bài 12) tốn , rất tốt khi cỡ nhưng vô nghĩa khi tới .
Cắt dãy ở giữa. Đặt : nửa trái gồm , nửa phải gồm . Mỗi tập con của cả dãy tách được thành đúng một cặp , phần của nằm ở nửa trái và phần nằm ở nửa phải; ngược lại mỗi cặp ghép lại thành đúng một , và . Gọi là danh sách tổng của mọi tập con nửa trái (đủ phần tử, kể cả 0 của tập rỗng, tổng trùng thì xuất hiện nhiều lần), tương tự cho nửa phải. Khi đó số tập con có tổng bằng đúng bằng số cặp với lấy trong , lấy trong và , trong đó là cặp vị trí trong hai danh sách. Thử mọi cặp vẫn là , nhưng bài đã đổi bản chất: không còn lục trong không gian tập con, mà chỉ tìm cặp có tổng giữa hai danh sách số, việc làm được bằng sắp xếp.
Ví dụ với dãy và , ta có . Nửa trái cho , số 5 xuất hiện hai lần, từ và từ . Nửa phải cho . Các cặp có tổng 12 là , hai lần , và : năm cặp, nên có 5 tập con.
Sinh tổng của một nửa. Bắt đầu với . Thêm phần tử : mỗi tập con cũ hoặc không lấy (giữ tổng ), hoặc lấy (tổng ), nên danh sách mới là nối với , gấp đôi. Số phép cộng của cả nửa là . Muốn danh sách đã sắp mà không tốn sort: nếu đã sắp thì cũng đã sắp, vì cộng cùng một hằng số không đổi thứ tự (kể cả khi âm), và hai danh sách đã sắp trộn thành một trong thời gian tuyến tính bằng merge. Cả nửa tốn , không có thừa số .
Thừa số mà phép trộn tiết kiệm không hề nhỏ:
Trộn nhanh gấp khoảng 4 lần, chỉ nhờ tận dụng việc và đã sắp sẵn.
Khi mọi dương và chỉ cần các tổng không vượt , có thể bỏ ngay các tổng vượt lúc sinh. Mẹo này chỉ đúng với số không âm.
Hỏi nhanh
Mọi đều dương và ta chỉ cần các tập con có tổng không vượt , nên khi sinh danh sách của một nửa ta bỏ ngay mọi tổng đã vượt . Nếu đề cho phép âm, cách bỏ này còn dùng được không?
Với số dương, tổng chỉ tăng khi thêm phần tử, nên tổng đã vượt là hết đường quay lại. Số âm phá đúng điều đó: dãy với có tổng sau phần tử đầu, vậy mà sau phần tử thứ hai ta được . Bỏ đi là mất đáp án.
Đáp án đầu lặp lại giả thiết chỉ đúng cho số dương. Đáp án thứ ba nói ngược: vấn đề không phải danh sách dài thêm mà là ta đã bỏ nhầm một phần tử cần thiết. Đáp án cuối quá hẹp: chỉ cần một phần tử âm xuất hiện sau là đủ hỏng.
Ghép bằng hai con trỏ. Đặt ở đầu (nhỏ nhất), ở cuối (lớn nhất), xét . Nếu thì không ghép được với phần tử còn lại nào của , vì mọi với đều không lớn hơn ; bỏ . Nếu thì tương tự bỏ . Mỗi bước dịch một con trỏ, nên sau tối đa bước mọi cặp đã được xét. Khi phải cẩn thận với giá trị lặp: gọi là số phần tử liên tiếp bằng và là số phần tử liên tiếp bằng ; mọi cặp giữa hai nhóm đều có tổng , nên cộng rồi nhảy qua cả hai nhóm. Chỉ dịch mỗi con trỏ một bước thì ví dụ trên ra 4 thay vì 5.
Hỏi nhanh
Cho , và . Hai danh sách có bao nhiêu cặp vị trí với ?
Số 1 ghép với số 4 cho 1 cặp vị trí. Hai số 3 trong cùng ghép với hai số 2 trong cho cặp. Tổng cộng .
Đáp án 2 đếm các cặp giá trị khác nhau, bỏ qua việc cùng giá trị xuất hiện nhiều lần. Đáp án 3 là kết quả của hai con trỏ chỉ dịch một bước sau mỗi lần gặp. Đáp án 6 cộng kích thước hai nhóm ( rồi ) thay vì nhân.
Cắt ở đâu. Cắt ở vị trí thì chi phí cỡ , dấu bằng khi . Cắt lệch làm mất hết lợi ích: tách 10 và 30 phần tử cho nửa phải tổng, chỉ riêng việc lưu đã cỡ 8 GB. Vài con số để ước lượng, với long long 8 byte: mỗi nửa tổng, nhẹ như không; mỗi nửa tổng, 8 MB một danh sách; mỗi nửa tổng, 256 MB, bộ nhớ chặn đường trước thời gian. Vì vậy kỹ thuật dùng cho tới khoảng 40 đến 45. Nếu mỗi phần tử có lựa chọn thay vì hai, mỗi nửa có trạng thái: với , chỉ tới cỡ 24 đến 26.
Hỏi nhanh
Với , ta cắt dãy thành nửa trái 10 phần tử và nửa phải 30 phần tử, rồi làm đúng các bước ở trên. Điều gì chi phối thời gian và bộ nhớ của chương trình?
Nửa có phần tử cho danh sách tổng, nên chênh lệch số phần tử bị khuếch đại thành chênh lệch lũy thừa: nửa 30 phần tử gấp lần nửa 10 phần tử. Hai con trỏ chỉ đi qua vị trí, không phải cặp (đó là lý do của cả kỹ thuật); và các nửa không cân bằng, vì khác xa .
Ví dụĐếm Tổng Mục Tiêu
Đề. Cho số nguyên dương và . Đếm số tập con (theo vị trí) có tổng đúng .
Lời giải. Đúng khung ở trên: chia dãy làm hai nửa, sinh danh sách đã sắp các tổng tập con của từng nửa, rồi đếm các cặp với . Mỗi tập con của cả dãy tách duy nhất thành phần thuộc nửa trái và phần thuộc nửa phải, nên mỗi tập con có tổng ứng với đúng một cặp vị trí trong hai danh sách. Tổng một nửa lên tới và đáp án tới , nên mọi biến là ll.
Bước 1: sinh tổng tập con đã sắp. Bắt đầu với danh sách (tập rỗng). Thêm phần tử thì mỗi tập con cũ cho hai tập con mới: không lấy (tổng giữ nguyên) và lấy (tổng cộng ). Danh sách cũ s đã sắp, nên t = s + a[i] cũng đã sắp, và merge trộn hai danh sách đã sắp thành một danh sách đã sắp trong thời gian tuyến tính. Sau phần tử, danh sách có số.
vector<ll> gen(ll lo, ll hi) {
vector<ll> s = {0};
for (ll i = lo; i < hi; i++) {
vector<ll> t(s.size()), c(2 * s.size());
for (ll k = 0; k < (ll)s.size(); k++) t[k] = s[k] + a[i];
merge(s.begin(), s.end(), t.begin(), t.end(), c.begin());
s = c;
}
return s;
}
Bước 2: ghép bằng hai con trỏ. đi từ đầu lên, đi từ cuối xuống. Tổng nhỏ hơn thì phải tăng ; lớn hơn thì phải giảm . Gặp tổng bằng , hai danh sách có thể có nhiều giá trị bằng nhau liền nhau: đếm khối (dài cu) và khối (dài cv), mọi cặp giữa hai khối đều hợp lệ nên cộng cu * cv, rồi bước qua cả hai khối.
ll i = 0, j = R.size() - 1, ans = 0;
while (i < (ll)L.size() && j >= 0) {
if (L[i] + R[j] < x) i++;
else if (L[i] + R[j] > x) j--;
else {
ll u = L[i], v = R[j], cu = 0, cv = 0;
while (i < (ll)L.size() && L[i] == u) i++, cu++;
while (j >= 0 && R[j] == v) j--, cv++;
ans += cu * cv;
}
}
Với test ví dụ và : , ; các cặp là một lần và hai lần, tổng 3.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, x, a[45];
vector<ll> L, R;
vector<ll> gen(ll lo, ll hi) {
vector<ll> s = {0};
for (ll i = lo; i < hi; i++) {
vector<ll> t(s.size()), c(2 * s.size());
for (ll k = 0; k < (ll)s.size(); k++) t[k] = s[k] + a[i];
merge(s.begin(), s.end(), t.begin(), t.end(), c.begin());
s = c;
}
return s;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> x;
for (ll i = 0; i < n; i++) cin >> a[i];
L = gen(0, n / 2);
R = gen(n / 2, n);
ll i = 0, j = R.size() - 1, ans = 0;
while (i < (ll)L.size() && j >= 0) {
if (L[i] + R[j] < x) i++;
else if (L[i] + R[j] > x) j--;
else {
ll u = L[i], v = R[j], cu = 0, cv = 0;
while (i < (ll)L.size() && L[i] == u) i++, cu++;
while (j >= 0 && R[j] == v) j--, cv++;
ans += cu * cv;
}
}
cout << ans << '\n';
}
4 6
1 2 3 3
3
Độ phức tạp: mỗi nửa sinh trong nhờ trộn thay vì sắp, ghép cũng ; bộ nhớ hai danh sách số.
Các câu hỏi ghép
Cùng khung hai con trỏ trả lời được nhiều câu hỏi khác về cặp ; đề thi thường chỉ đổi câu hỏi chứ không đổi khung.
- Tổng lớn nhất không vượt . Với tăng dần, ngưỡng giảm, nên con trỏ trên chỉ lùi: lùi tới khi rồi cập nhật đáp án.
- Đếm cặp có tổng không vượt . Cùng con trỏ đó, mỗi cộng thêm , số phần tử . Số tập con có tổng trong là số cặp tổng trừ số cặp tổng .
- Tổng gần mục tiêu nhất. Phần tử của gần nhất nằm cạnh vị trí chèn của : phần tử đầu tiên không nhỏ hơn , hoặc phần tử ngay trước nó. Thử cả hai ứng viên rồi lấy tốt nhất.
Ta cũng có thể bỏ vào bảng băm và tra trong thời gian kỳ vọng . Cách này chỉ trả lời được câu hỏi “bằng”, không trả lời được “không vượt” hay “gần nhất”, nên với tổng số ta nghiêng về sắp xếp cộng hai con trỏ. Bảng băm chỉ cần khi tóm tắt của một nửa không có thứ tự tự nhiên, như ở phần đường đi bên dưới.
Tóm tắt của một nửa
Mỗi nửa thực chất cho ra một danh sách các tóm tắt. Một tóm tắt hợp lệ khi hai tóm tắt (một của nửa trái, một của nửa phải) đủ để làm hai việc: quyết định hai nửa có ghép được không, và tính giá trị của phương án ghép. Với bài đếm tổng, tóm tắt là một số. Nhiều bài cần nhiều hơn.
- Đúng phần tử. Tóm tắt là (tổng, số phần tử). Nửa trái dùng phần tử thì nửa phải bắt buộc dùng , nên chia mỗi danh sách thành các nhóm theo số phần tử và chỉ ghép nhóm với nhóm .
- Phần dư. Muốn tổng theo modulo lớn nhất, tóm tắt là phần dư của nửa; điều cần xét thêm là phép cộng có thể “cuộn vòng” qua .
- Ba lô trọng lượng lớn. Tóm tắt là (trọng lượng , giá trị ), ghép được khi . Sắp nửa phải theo trọng lượng rồi thay giá trị bằng max tiền tố (bài 02), mỗi trạng thái trái hỏi bằng một lần chặt nhị phân.
- Hai túi cân bằng. Mỗi vật có ba lựa chọn (túi 1, túi 2, bỏ lại), tóm tắt là (hiệu hai túi, tổng đã dùng), hai nửa ghép được khi hai hiệu đối nhau. Với cùng một hiệu chỉ cần giữ tổng đã dùng lớn nhất.
Điều kiện “đủ” thường bị bỏ sót ở những ràng buộc đi xuyên qua chỗ cắt. Ví dụ đếm tập con có tổng mà không chọn hai phần tử liền kề: hai tập con hợp lệ ở hai nửa vẫn có thể ghép thành tập chứa cả và , hai phần tử sát chỗ cắt. Muốn đúng, tóm tắt phải ghi thêm “có chọn phần tử sát chỗ cắt hay không”, rồi ghép chỉ khi không đồng thời chọn cả hai.
Hỏi nhanh
Đếm tập con của có tổng và không chứa hai phần tử có chỉ số liền kề. Ta cắt ở giữa như thường lệ, mỗi nửa chỉ sinh các tập con không có hai phần tử liền kề, rồi ghép theo tổng bằng hai con trỏ. Cách làm này sai ở đâu?
Điều kiện “không liền kề” là ràng buộc giữa và , hai phần tử nằm ở hai phía của chỗ cắt, nên không nửa nào tự kiểm tra được. Tóm tắt phải kèm cờ “có chọn phần tử sát chỗ cắt không” và phép ghép phải loại cặp có cả hai cờ bật.
Tổng vẫn cộng được bình thường (đáp án cuối sai), mỗi phần tử vẫn chỉ có hai lựa chọn chọn/bỏ (đáp án hai sai), còn đáp án đầu chính là chỗ dễ nhầm: độc lập trong từng nửa không có nghĩa là độc lập qua chỗ cắt.
Ví dụPhần Dư Tập Con Lớn Nhất
Đề. Cho số nguyên dương và . Chọn một tập con bất kỳ, kể cả rỗng, để tổng chia dư lớn nhất; in phần dư đó.
Lời giải. Quy hoạch động theo phần dư tốn , duyệt tập con tốn : cả hai quá chậm. Thay bằng , sinh phần dư tổng của mỗi nửa, ta được hai danh sách , gồm các số trong , mỗi danh sách tối đa phần tử. Một tập con cho phần dư với , , và có hai trường hợp:
- : phần dư là .
- : phần dư là , vì . Mà đạt được khi để nửa phải rỗng, nên cặp này không bao giờ tốt hơn.
Vậy đáp án là , đúng câu hỏi “lớn nhất không vượt ngưỡng” với ngưỡng .
Bước 1: sinh phần dư rồi sắp. Mỗi phần tử nhân đôi danh sách: giữ các phần dư cũ và thêm (s[k] + a[i]) % m. Ở đây phải sort chứ không trộn được như ví dụ trước: sau phép lấy dư, các giá trị lớn cuộn về đầu, nên danh sách mới không còn đã sắp.
vector<ll> gen(ll lo, ll hi) {
vector<ll> s = {0};
for (ll i = lo; i < hi; i++) {
ll sz = s.size();
for (ll k = 0; k < sz; k++) s.push_back((s[k] + a[i]) % m);
}
sort(s.begin(), s.end());
return s;
}
Bước 2: ghép với ngưỡng . Đây là câu hỏi “lớn nhất không vượt ngưỡng” ở mục trước. Duyệt tăng dần, con trỏ trên chỉ lùi tới khi . Nó dừng trước khi âm vì và .
ll j = R.size() - 1, ans = 0;
for (ll x : L) {
while (x + R[j] >= m) j--;
ans = max(ans, x + R[j]);
}
Với test ví dụ, từ , từ ; cặp tốt nhất là .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m, a[40];
vector<ll> L, R;
vector<ll> gen(ll lo, ll hi) {
vector<ll> s = {0};
for (ll i = lo; i < hi; i++) {
ll sz = s.size();
for (ll k = 0; k < sz; k++) s.push_back((s[k] + a[i]) % m);
}
sort(s.begin(), s.end());
return s;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 0; i < n; i++) {
cin >> a[i];
a[i] %= m;
}
L = gen(0, n / 2);
R = gen(n / 2, n);
ll j = R.size() - 1, ans = 0;
for (ll x : L) {
while (x + R[j] >= m) j--;
ans = max(ans, x + R[j]);
}
cout << ans << '\n';
}
4 10
6 7 4 2
9
Độ phức tạp: sinh mỗi nửa , sắp , ghép tuyến tính.
Ví dụChọn Đúng K Món
Đề. Có món, món giá . Đếm số cách chọn đúng món có tổng giá không vượt .
Lời giải. Hai điều kiện, đúng món và tổng , nên tóm tắt của mỗi tập con một nửa là cặp (tổng, số món). Mỗi cách chọn hợp lệ tách duy nhất thành với , nên ta xếp các tổng của mỗi nửa vào nhóm theo số món, và chỉ ghép nhóm của nửa trái với nhóm của nửa phải. Mỗi cách được đếm đúng một lần ở cặp nhóm .
Bước 1: sinh tổng kèm số món, chia theo số món. Sinh như ví dụ đầu, nhân đôi danh sách sau mỗi món, nhưng mỗi phần tử giờ là cặp (tổng, số món): lấy thêm món thì tổng cộng và số món cộng 1. Xong thì bỏ từng tổng vào nhóm theo số món của nó, rồi sắp từng nhóm.
void gen(ll lo, ll hi, vector<ll> b[]) {
vector<pair<ll, ll>> s = {{0, 0}};
for (ll i = lo; i < hi; i++) {
ll m = s.size();
for (ll k = 0; k < m; k++) s.push_back({s[k].first + a[i], s[k].second + 1});
}
for (auto x : s) b[x.second].push_back(x.first);
for (ll c = 0; c <= hi - lo; c++) sort(b[c].begin(), b[c].end());
}
Bước 2: ghép từng cặp nhóm. Với mỗi và hợp lệ, quét tăng dần trên , giữ là số phần tử của không vượt . giảm nên chỉ giảm, và mỗi cộng thêm cách. có thể âm, khi đó về 0.
for (ll c = 0; c <= h; c++) {
ll d = K - c;
if (d < 0 || d > n - h) continue;
ll j = R[d].size();
for (ll x : L[c]) {
while (j > 0 && R[d][j - 1] > P - x) j--;
ans += j;
}
}
Tổng giá tối đa , còn dưới giới hạn ll. Với test ví dụ, : nhóm ghép được hai cách, ghép được hai cách, tổng 4.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, K, P, a[45];
vector<ll> L[25], R[25];
void gen(ll lo, ll hi, vector<ll> b[]) {
vector<pair<ll, ll>> s = {{0, 0}};
for (ll i = lo; i < hi; i++) {
ll m = s.size();
for (ll k = 0; k < m; k++) s.push_back({s[k].first + a[i], s[k].second + 1});
}
for (auto x : s) b[x.second].push_back(x.first);
for (ll c = 0; c <= hi - lo; c++) sort(b[c].begin(), b[c].end());
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> K >> P;
for (ll i = 0; i < n; i++) cin >> a[i];
ll h = n / 2;
gen(0, h, L);
gen(h, n, R);
ll ans = 0;
for (ll c = 0; c <= h; c++) {
ll d = K - c;
if (d < 0 || d > n - h) continue;
ll j = R[d].size();
for (ll x : L[c]) {
while (j > 0 && R[d][j - 1] > P - x) j--;
ans += j;
}
}
cout << ans << '\n';
}
5 3 12
2 3 4 5 8
4
Độ phức tạp: sinh , sắp , ghép tuyến tính theo tổng kích thước các nhóm.
Tách phương trình
Kỹ thuật không đòi hỏi phải có tập con. Xét bài: cho dãy gồm số, đếm các bộ bất kỳ sao cho , với cỡ 2000. Duyệt bốn chỉ số tốn . Nhưng phương trình đã tách sẵn hai vế: vế trái chỉ phụ thuộc cặp , vế phải chỉ phụ thuộc cặp . Gọi là số cặp có thứ tự với tổng ; mỗi cặp bên trái ghép với mỗi cặp bên phải cùng tổng, nên đáp án là tổng các qua mọi giá trị . Với các tổng là , các nhóm có kích thước và đáp án là .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, a[2005];
vector<ll> s;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) cin >> a[i];
for (ll i = 0; i < n; i++)
for (ll j = 0; j < n; j++) s.push_back(a[i] + a[j]);
sort(s.begin(), s.end());
ll ans = 0;
for (ll l = 0, r = 0; l < (ll)s.size(); l = r) {
while (r < (ll)s.size() && s[r] == s[l]) r++;
ans += (r - l) * (r - l);
}
cout << ans << '\n';
}
3
1 2 3
19
Thời gian cho việc sắp xếp, so với ban đầu. Bộ nhớ là cái giá: giá trị, với là số, tức 64 MB nếu vừa int và 128 MB nếu phải long long. Khi hai vế là hai danh sách khác nhau (chẳng hạn ), sắp cả hai và dùng khung hai con trỏ ở mục đầu, nhân kích thước nhóm hai bên.
Mẹo là chuyển biến sang vế bên kia cho đến khi mỗi vế chỉ chứa một nhóm biến:
- viết lại thành : vế trái giá trị, rồi duyệt và .
- thành : hai vế mỗi vế giá trị.
- với : mỗi vế là một bộ ba, giá trị mỗi vế, chấp nhận được khi .
Ví dụGiá Trị Lớn Nhất Từ Ba Số
Đề. Cho tập gồm số nguyên dương đôi một khác nhau, mỗi số . Tìm phần tử lớn nhất sao cho có (được trùng nhau) với ; đề bảo đảm có đáp án.
Lời giải. Bốn biến, duyệt thẳng . Viết lại : vế trái là một cặp, chỉ cần lưu mọi tổng với (cặp không thứ tự là đủ vì chỉ hỏi có hay không), khoảng số, rồi sắp. Vế phải là cặp : thử từ lớn xuống nhỏ, với mỗi thử mọi và tra trong danh sách bằng chặt nhị phân; gặp lần đầu là in và dừng, vì đang thử là lớn nhất còn lại. Tổng cộng . Với test ví dụ, và cho .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, u[1005];
vector<ll> s;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) cin >> u[i];
sort(u, u + n);
for (ll i = 0; i < n; i++)
for (ll j = i; j < n; j++) s.push_back(u[i] + u[j]);
sort(s.begin(), s.end());
for (ll d = n - 1; d >= 0; d--) {
for (ll c = 0; c < n; c++) {
if (binary_search(s.begin(), s.end(), u[d] - u[c])) {
cout << u[d] << '\n';
return 0;
}
}
}
}
5
1
2
4
7
13
13
Gặp nhau trên đường đi
Cùng ý tưởng dùng được khi lời giải là một chuỗi bước. Cho bảng chứa số nguyên không âm, ta đi từ góc trên trái xuống góc dưới phải, mỗi bước sang phải hoặc xuống dưới, và muốn đếm đường đi có XOR các số trên đường bằng , với .
Mỗi đường đi có đúng bước, nên số đường đi là (chọn bước đi xuống trong bước), với bảng là : quá nhiều để duyệt. Mỗi bước làm tổng chỉ số hàng cộng cột tăng đúng 1, nên mọi đường đi sau đúng bước đều rơi vào một ô trên đường chéo (đánh số từ 0), và cắt đường chéo đó đúng một lần. Ta chia đường đi tại ô này: nửa đầu đi từ góc trên trái tới đường chéo, nửa sau đi ngược từ góc dưới phải lên đường chéo. Mỗi nửa có không quá hướng đi.
Tóm tắt một nửa đường là (ô gặp nhau, XOR các số đã đi qua). Hai nửa chỉ ghép được khi cùng ô gặp nhau, nên ta gom các nửa đường theo ô: mỗi ô trên đường chéo có một bảng đếm từ giá trị XOR sang số nửa đường. Đây là chỗ cần bảng băm hoặc map, vì XOR không có thứ tự để chạy hai con trỏ như với tổng. Chỉ còn một chỗ tinh tế: ô gặp nhau thuộc cả hai nửa nếu ta tính nó ở cả hai phía.
Hỏi nhanh
Gọi là XOR các số trên nửa đầu của một đường đi, tính từ ô xuất phát tới ô gặp nhau (gồm cả hai ô này); là XOR các số trên nửa sau, từ ô gặp nhau tới ô đích. XOR của cả đường đi là gì?
XOR của cả đường là XOR của mọi ô đúng một lần. Ô xuất hiện trong và trong , mà , nên thiếu ; XOR thêm một lần là đủ. Cách khác là quy ước chỉ thuộc một nửa.
Giao hoán không cứu được vì vấn đề là số lần xuất hiện chứ không phải thứ tự. Đáp án thứ hai áp quy tắc của phép cộng cho XOR: không bị “thừa” mà bị mất. Đáp án thứ ba lẫn mục tiêu với kết quả cần tính; chỉ xuất hiện khi so sánh.
Cùng cách nghĩ đó áp dụng cho hoán vị, xâu, đồ thị. Tìm đường đi ngắn nhất bằng BFS hai chiều là một dạng của nó: nếu mỗi trạng thái có trạng thái kề và đáp án cách bước, BFS từ hai đầu chỉ sinh cỡ trạng thái mỗi phía thay vì từ một phía.
Ví dụĐường Đi XOR Mục Tiêu
Đề. Bảng (), ô chứa số nguyên không âm tới . Đếm đường đi từ tới , mỗi bước sang phải hoặc xuống dưới, có XOR các ô trên đường (kể cả hai đầu) bằng .
Lời giải. Như vừa phân tích, có tới đường nên không duyệt hết được, nhưng mọi đường cắt đường chéo tại đúng một ô. Chia mỗi đường thành nửa đầu từ tới ô trên đường chéo và nửa sau từ ô đó tới ; hai nửa ghép được khi cùng ô gặp nhau và XOR của chúng bằng .
Bước 1: nửa đầu. go1 đi từ , chỉ sang phải hoặc xuống, XOR dần các ô, kể cả ô gặp nhau. Tới ô có thì tăng cnt[i][v]: bảng đếm của ô gặp nhau ở hàng (hàng xác định luôn ô trên đường chéo).
void go1(ll i, ll j, ll v) {
v ^= a[i][j];
if (i + j == h) {
cnt[i][v]++;
return;
}
if (i + 1 < n) go1(i + 1, j, v);
if (j + 1 < m) go1(i, j + 1, v);
}
Bước 2: nửa sau, đi ngược. go2 đi từ lên hoặc sang trái, XOR các ô trừ ô gặp nhau. Tới đường chéo với XOR , nửa đầu cần XOR để cả đường ra , nên cộng cnt[i][v ^ K]. Quy ước ô gặp nhau chỉ thuộc nửa đầu là để ô ấy được XOR đúng một lần.
void go2(ll i, ll j, ll v) {
if (i + j == h) {
if (cnt[i].count(v ^ K)) ans += cnt[i][v ^ K];
return;
}
v ^= a[i][j];
if (i > 0) go2(i - 1, j, v);
if (j > 0) go2(i, j - 1, v);
}
Với test ví dụ, hai đường đi qua (theo hai cách) cho XOR 23.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m, K, h, ans, a[25][25];
map<ll, ll> cnt[25];
void go1(ll i, ll j, ll v) {
v ^= a[i][j];
if (i + j == h) {
cnt[i][v]++;
return;
}
if (i + 1 < n) go1(i + 1, j, v);
if (j + 1 < m) go1(i, j + 1, v);
}
void go2(ll i, ll j, ll v) {
if (i + j == h) {
if (cnt[i].count(v ^ K)) ans += cnt[i][v ^ K];
return;
}
v ^= a[i][j];
if (i > 0) go2(i - 1, j, v);
if (j > 0) go2(i, j - 1, v);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m >> K;
for (ll i = 0; i < n; i++)
for (ll j = 0; j < m; j++) cin >> a[i][j];
h = (n + m - 2) / 2;
go1(0, 0, 0);
go2(n - 1, m - 1, 0);
cout << ans << '\n';
}
2 3 23
1 2 4
8 4 16
2
Mỗi nửa đi tối đa 19 bước, tức cỡ nhánh, mỗi nhánh một thao tác map: khoảng .
Khi đi thi
- Giới hạn cỡ 30 đến 45 kèm giá trị quá lớn cho quy hoạch động theo tổng là dấu hiệu rõ nhất; ước lượng ngay mỗi nửa có (hoặc ) trạng thái và nhân với 8 byte để chắc bộ nhớ chứa nổi.
- Trước khi gõ code, viết ra hai điều: tóm tắt của một nửa gồm những gì, và điều kiện ghép hai tóm tắt là gì. Ràng buộc nào đi xuyên qua chỗ cắt phải nằm trong tóm tắt.
- Mọi tổng dùng
long long, tập rỗng có mặt ở cả hai nửa, ghép theo nhóm giá trị bằng nhau (nhân số lượng) chứ không theo từng phần tử, và ưu tiên sắp xếp cộng hai con trỏ hơn bảng băm khi khóa có thứ tự.
Tóm tắt
- Tập con của cả dãy tương ứng một đối một với cặp (tập con trái, tập con phải) và tổng cộng được, nên bài thành hai danh sách cỡ cần ghép.
- Danh sách tổng đã sắp sinh bằng cách nhân đôi và trộn trong ; hai con trỏ ghép trong ; hai nửa phải cân nhau.
- Câu hỏi ghép thường gặp: đếm tổng bằng (nhóm giá trị bằng nhau, nhân số lượng), lớn nhất không vượt , gần mục tiêu nhất (hai ứng viên kề vị trí chèn).
- Tóm tắt của nửa có thể là (tổng, số phần tử), phần dư, (trọng lượng, giá trị), (hiệu, tổng) hay (ô, XOR); ràng buộc xuyên qua chỗ cắt phải nằm trong tóm tắt.
- Cùng ý tưởng dùng cho phương trình (chuyển biến để mỗi vế có hoặc giá trị) và cho đường đi (gặp nhau trên đường chéo giữa).
Bài tập
Đọc thêm
- Meet in the MiddleUSACO Guide
- Competitive Programmer’s Handbook, mục 5.5
- Subset sum problemWikipedia
- 折半搜索 (meet in the middle)OI Wiki
- Meet In The Middlevelog (BOJ 1208)