Meet in the Middle
Meet in the Middle (MITM — "gặp nhau ở giữa") là kỹ thuật chia tập đối tượng thành hai nửa, liệt kê vét cạn từng nửa một cách độc lập, rồi ghép hai nửa lại để được lời giải toàn cục. Nhờ đó, một bài toán brute force được hạ xuống — đủ để xử lý thay vì chỉ như vét cạn thông thường.
Bài toán điển hình: cho mảng số, đếm số tập con có tổng đúng bằng . Vét cạn là quá chậm, nhưng thì chạy thoải mái. MITM khai thác chính khoảng cách đó.
Ý tưởng / Trực giác
Tại sao lại đắt đến vậy? Vì mỗi phần tử có 2 lựa chọn (chọn / không chọn), và ta xét mọi tổ hợp của cả phần tử cùng lúc. Số tổ hợp nhân lên theo cấp số nhân.
Mấu chốt là: một tập con bất kỳ của toàn mảng luôn tách được thành "phần nằm ở nửa trái" cộng "phần nằm ở nửa phải". Hai phần này độc lập — chọn gì ở nửa trái không ràng buộc chọn gì ở nửa phải. Do đó:
Thay vì sinh tổ hợp đầy đủ, ta:
- Sinh mọi tổng con của nửa trái → tập , kích thước .
- Sinh mọi tổng con của nửa phải → tập , kích thước .
- Với mỗi tổng , ta cần phần phải có tổng đúng bằng . Đếm xem trong có bao nhiêu giá trị bằng .
Bước 3 nếu duyệt thô là — vẫn chậm! Bí quyết để "gặp nhau ở giữa" thật sự nhanh là sắp xếp (hoặc bỏ vào bảng băm). Khi đó mỗi truy vấn chỉ tốn bằng tìm kiếm nhị phân. Tổng cộng .
Tóm lại: ta đổi một bài toán "nhân" () thành "cộng rồi tra cứu" (). Đó chính là cú hạ bậc thần kỳ.
Ví dụ chạy tay
Mảng , cần đếm số tập con có tổng . Chia thành nửa trái {a[0]=3, a[1]=1} và nửa phải {a[2]=4, a[3]=2}.
Bước 1 — sinh mọi tổng nửa trái (4 mặt nạ bit ):
mask chọn tổng
00 {} 0
01 {3} 3
10 {1} 1
11 {3,1} 4
L = [0, 3, 1, 4]
Bước 2 — sinh mọi tổng nửa phải, rồi sắp xếp:
mask chọn tổng
00 {} 0
01 {4} 4
10 {2} 2
11 {4,2} 6
R (đã sort) = [0, 2, 4, 6]
Bước 3 — với mỗi , đếm số phần tử trong bằng :
ℓ=0 → cần 5 trong R=[0,2,4,6] → có 0
ℓ=3 → cần 2 trong R=[0,2,4,6] → có 1 (cặp: trái{3} + phải{2} = 5) ✓
^
ℓ=1 → cần 4 trong R=[0,2,4,6] → có 1 (cặp: trái{1} + phải{4} = 5) ✓
^
ℓ=4 → cần 1 trong R=[0,2,4,6] → có 0
Tổng số tập con có tổng là . Kiểm chứng tay: và — đúng 2 tập.
Cài đặt
Đếm số tập con có tổng bằng (đáp án cho bài meetmiddle):
#include <bits/stdc++.h>
using namespace std;
// Sinh mọi tổng con của a[from..to-1]
vector<long long> gen(const vector<long long>& a, int from, int to) {
int len = to - from;
vector<long long> sums;
sums.reserve(1 << len);
for (int mask = 0; mask < (1 << len); mask++) {
long long s = 0;
for (int i = 0; i < len; i++)
if (mask >> i & 1) s += a[from + i]; // bit i bật => lấy phần tử
sums.push_back(s);
}
return sums;
}
long long count_subsets(const vector<long long>& a, long long K) {
int n = a.size();
int half = n / 2; // chia đôi: trái [0,half), phải [half,n)
vector<long long> L = gen(a, 0, half);
vector<long long> R = gen(a, half, n);
sort(R.begin(), R.end()); // sort để tra cứu O(log) bằng nhị phân
long long cnt = 0;
for (long long ls : L) {
long long need = K - ls; // phần phải phải bù đúng K - ls
// số phần tử của R đúng bằng need = upper_bound - lower_bound
cnt += upper_bound(R.begin(), R.end(), need)
- lower_bound(R.begin(), R.end(), need);
}
return cnt;
}
int main() {
int n; long long K;
scanf("%d %lld", &n, &K);
vector<long long> a(n);
for (auto& x : a) scanf("%lld", &x);
printf("%lld\n", count_subsets(a, K));
}
Biến thể "tổng gần nhất" — thay vì đếm bằng nhau, ta tìm tổng hoặc gần nhất bằng lower_bound:
long long closest_subset_sum(const vector<long long>& a, long long K) {
int n = a.size(), half = n / 2;
auto L = gen(a, 0, half);
auto R = gen(a, half, n);
sort(R.begin(), R.end());
long long best = LLONG_MAX; // |tổng - K| nhỏ nhất
for (long long ls : L) {
long long need = K - ls;
auto it = lower_bound(R.begin(), R.end(), need);
if (it != R.end()) // ứng viên >= need
best = min(best, llabs(ls + *it - K));
if (it != R.begin()) // ứng viên < need (sát bên trái)
best = min(best, llabs(ls + *prev(it) - K));
}
return best;
}
Độ phức tạp
- Thời gian: Sinh mỗi nửa tốn (mỗi trong mặt nạ phải cộng tới phần tử). Sắp xếp tốn . Vòng ghép cũng . Tổng cộng . Với thì , nhân hằng số nhỏ — chạy trong vài trăm mili-giây.
- Bộ nhớ: để lưu hai mảng tổng. Với , mỗi mảng có số
long long(~8 MB) — chấp nhận được. Đây chính là cái giá phải trả: brute force tốn bộ nhớ nhưng thời gian, còn MITM "mua" tốc độ bằng bộ nhớ.
So sánh nhanh:
| Brute Force | Meet in the Middle | |
|---|---|---|
| Giới hạn thực tế | ||
| Thời gian | ||
| Bộ nhớ |
⚠️ Lỗi thường gặp
- Tràn số (overflow). Tổng của tới phần tử, mỗi phần tử cỡ , có thể lên — vượt
int(). Phải dùnglong longcho mọi biến tổng và cho . Triệu chứng: đáp án đúng với test nhỏ nhưng sai/âm với test lớn. - Đếm "bằng nhau" sai bằng
lower_boundđơn lẻ. Khi đếm số phần tử bằng đúngneed, phải lấyupper_bound - lower_bound. Nếu chỉ dùngbinary_search(trả về true/false) sẽ bỏ sót các giá trị trùng lặp — và trong MITM, tổng trùng lặp xuất hiện rất nhiều. Test có phần tử lặp (ví dụ[1,2,3,2]) sẽ lộ lỗi này. - Quên rằng tập con rỗng cũng được tính. Mặt nạ
mask = 0sinh ra tổng và nên nằm trong , . Nếu đề tính cả tập rỗng (nhưhalfblood) mà ta khởi tạo vòng từmask = 1, đáp án sẽ thiếu. Ngược lại nếu đề yêu cầu tập khác rỗng (nhưcowsubsets), nhớ trừ đi trường hợp cả hai nửa đều rỗng. - Chia đôi lệch khi lẻ.
half = n/2làm tròn xuống; nửa phải cón - halfphần tử (nhiều hơn 1 khi lẻ). Cài đặt phải dùng(1 << (n - half))cho nửa phải, không tái dùng(1 << half). Sai chỗ này sẽ bỏ sót hoặc duyệt thừa phần tử cuối. - Sắp xếp nhầm nửa rồi tra cứu nửa kia. Phải sort đúng mảng mà ta sẽ tìm kiếm nhị phân trên đó (), không phải mảng đang duyệt (). Tra
lower_boundtrên mảng chưa sort cho kết quả vô nghĩa mà không báo lỗi. - Dùng vét cạn ở bước ghép. Nếu quên sort + tìm kiếm và lồng hai vòng
for, độ phức tạp trở lại — đúng kết quả nhưng TLE. Triệu chứng kinh điển: "thuật toán đúng mà vẫn quá thời gian".
Biến thể / Mở rộng
- MITM hai chiều (2 ràng buộc). Khi cần khớp đồng thời hai đại lượng (ví dụ
halfblood: tổng độ chua và tổng độ đắng ), không sort theo một khóa được nữa. Dùngmap<pair<long long,long long>, long long>(bảng băm theo cặp ) cho nửa phải, rồi với mỗi cặp ở nửa trái tra . - Chia ba lựa chọn / . Bài "chia tập con thành hai nhóm bằng nhau" (
cowsubsets) cho mỗi phần tử 3 trạng thái (nhóm 1 / nhóm 2 / không chọn). Liệt kê nửa trái bằng tổ hợp, lưu hiệu (nhóm1 − nhóm2), rồi ghép sao cho hiệu hai nửa triệt tiêu. - MITM trên đồ thị/lời giải (4-Sum, birthday attack). Bài tìm 4 phần tử tổng trong : sinh mọi tổng đôi (cỡ ), sort, rồi ghép — chính là MITM trên "nửa cặp".
Bài tập luyện
- Lệnh Robot (robotinst) — (Trung cấp) Với , đếm số cách chọn lệnh để robot tới đích; chia đôi tập lệnh rồi ghép theo tọa độ — bước nhập môn MITM hai chiều.
- Đếm tập con theo tổng (meetmiddle) — (Kỳ cựu) Đúng bài toán kinh điển: đếm tập con có tổng bằng với , áp dụng trực tiếp khung sinh–sort–tra cứu ở trên.
- Hoàng Tử Lai (halfblood) — (Kỳ cựu) Đếm tập con thỏa đồng thời hai ràng buộc tổng với ; luyện MITM hai chiều bằng bảng băm theo cặp.
- Tập hợp cân bằng (cowsubsets) — (Kỳ cựu) Đếm tập con chia được thành hai nhóm tổng bằng nhau; mỗi phần tử có 3 trạng thái nên cần MITM dạng ghép theo hiệu — biến thể nâng cao.