Chương 1·Bài 10

Meet in the Middle

Cho n=40n = 40 số nguyên dương, mỗi số tới 10910^9, và một số xx. Hãy đếm các tập con có tổng đúng xx. Duyệt mọi tập con tốn 240≈1,1⋅10122^{40} \approx 1{,}1 \cdot 10^{12} phép, tức hàng nghìn giây. Quy hoạch động kiểu ba lô cần một mảng theo tổng, mà tổng tới 10910^9 thì mảng đó quá lớn. Con số 40 chính là dấu hiệu của dạng bài này: đủ nhỏ để vét cạn nếu biết cách, nhưng vét cạn thẳng thì không qua nổi.

Gặp nhau ở giữa (meet in the middle) chia bài thành hai nửa độc lập, vét cạn từng nửa (mỗi nửa chỉ còn 220≈1062^{20} \approx 10^6 trường hợp), rồi ghép kết quả hai nửa bằng sắp xếp và hai con trỏ. Chi phí từ 2n2^n xuống cỡ 2n/22^{n/2}. Kỹ thuật dùng được mỗi khi lời giải gồm “một lựa chọn bên trái” cùng “một lựa chọn bên phải”, và việc kiểm hai lựa chọn có hợp nhau hay không là việc nhanh.

Chia đôi dãy rồi ghép hai nửa

Bài toán: cho a1,…,ana_1, \dots, a_n và xx, đếm số tập con có tổng bằng xx. 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à 2n2^n tập con: n=20n = 20 mất vài mili giây, n=30n = 30 sát một giây, n=40n = 40 hết hy vọng. Quy hoạch động theo tổng (bài 12) tốn O(nx)O(nx), rất tốt khi xx cỡ 10610^6 nhưng vô nghĩa khi xx tới 10910^9.

Cắt dãy ở giữa. Đặt h=⌊n/2⌋h = \lfloor n/2 \rfloor: nửa trái gồm a1,…,aha_1, \dots, a_h, nửa phải gồm ah+1,…,ana_{h+1}, \dots, a_n. Mỗi tập con TT của cả dãy tách được thành đúng một cặp (TL,TR)(T_L, T_R), phần của TT 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 TT, và sum(T)=sum(TL)+sum(TR)\mathrm{sum}(T) = \mathrm{sum}(T_L) + \mathrm{sum}(T_R). Gọi LL là danh sách tổng của mọi tập con nửa trái (đủ 2h2^h 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), RR tương tự cho nửa phải. Khi đó số tập con có tổng bằng xx đúng bằng số cặp (u,v)(u, v) với uu lấy trong LL, vv lấy trong RR và u+v=xu + v = x, trong đó (u,v)(u, v) là cặp vị trí trong hai danh sách. Thử mọi cặp vẫn là 2n2^n, 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 xx giữa hai danh sách số, việc làm được bằng sắp xếp.

Ví dụ với dãy (2,3,5,1,4,6)(2, 3, 5, 1, 4, 6) và x=12x = 12, ta có h=3h = 3. Nửa trái (2,3,5)(2, 3, 5) cho L=(0,2,3,5,5,7,8,10)L = (0, 2, 3, 5, 5, 7, 8, 10), số 5 xuất hiện hai lần, từ {5}\{5\} và từ {2,3}\{2, 3\}. Nửa phải (1,4,6)(1, 4, 6) cho R=(0,1,4,5,6,7,10,11)R = (0, 1, 4, 5, 6, 7, 10, 11). Các cặp có tổng 12 là 2+102 + 10, hai lần 5+75 + 7, 7+57 + 5 và 8+48 + 4: năm cặp, nên có 5 tập con.

L đi tăng dần, R đi giảm dần; mỗi đường nối là một cặp có tổng x.

Sinh tổng của một nửa. Bắt đầu với S=(0)S = (0). Thêm phần tử aa: mỗi tập con cũ hoặc không lấy aa (giữ tổng ss), hoặc lấy aa (tổng s+as + a), nên danh sách mới là SS nối với S+aS + a, gấp đôi. Số phép cộng của cả nửa là 1+2+⋯+2k−1<2k1 + 2 + \dots + 2^{k-1} < 2^k. Muốn danh sách đã sắp mà không tốn sort: nếu SS đã sắp thì S+aS + a cũng đã sắp, vì cộng cùng một hằng số không đổi thứ tự (kể cả khi aa â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 O(2k)O(2^k), không có thừa số log⁡\log.

Mỗi phần tử mới nhân đôi danh sách: S và S + a đều đã sắp nên chỉ cần trộn.

Khi mọi aia_i dương và chỉ cần các tổng không vượt xx, có thể bỏ ngay các tổng vượt xx lúc sinh. Mẹo này chỉ đúng với số không âm.

Hỏi nhanh

Mọi aia_i đều dương và ta chỉ cần các tập con có tổng không vượt xx, nên khi sinh danh sách của một nửa ta bỏ ngay mọi tổng đã vượt xx. Nếu đề cho phép aia_i âm, cách bỏ này còn dùng được không?

Ghép bằng hai con trỏ. Đặt ii ở đầu LL (nhỏ nhất), jj ở cuối RR (lớn nhất), xét s=Li+Rjs = L_i + R_j. Nếu s<xs < x thì LiL_i không ghép được với phần tử còn lại nào của RR, vì mọi Rj′R_{j'} với j′≤jj' \le j đều không lớn hơn RjR_j; bỏ LiL_i. Nếu s>xs > x thì tương tự bỏ RjR_j. Mỗi bước dịch một con trỏ, nên sau tối đa ∣L∣+∣R∣|L| + |R| bước mọi cặp đã được xét. Khi s=xs = x phải cẩn thận với giá trị lặp: gọi cLc_L là số phần tử liên tiếp bằng LiL_i và cRc_R là số phần tử liên tiếp bằng RjR_j; mọi cặp giữa hai nhóm đều có tổng xx, nên cộng cL⋅cRc_L \cdot c_R 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 L=(1,3,3)L = (1, 3, 3), R=(2,2,4)R = (2, 2, 4) và x=5x = 5. Hai danh sách có bao nhiêu cặp vị trí (u,v)(u, v) với u+v=5u + v = 5?

Cắt ở đâu. Cắt ở vị trí kk thì chi phí cỡ 2k+2n−k≥2⋅2n/22^k + 2^{n-k} \ge 2 \cdot 2^{n/2}, dấu bằng khi k=n/2k = n/2. 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 230≈1092^{30} \approx 10^9 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: n=30n = 30 mỗi nửa 3276832768 tổng, nhẹ như không; n=40n = 40 mỗi nửa 220≈1062^{20} \approx 10^6 tổng, 8 MB một danh sách; n=50n = 50 mỗi nửa 3,4⋅1073{,}4 \cdot 10^7 tổng, 256 MB, bộ nhớ chặn đường trước thời gian. Vì vậy kỹ thuật dùng cho nn tới khoảng 40 đến 45. Nếu mỗi phần tử có cc lựa chọn thay vì hai, mỗi nửa có cn/2c^{n/2} trạng thái: với c=3c = 3, nn chỉ tới cỡ 24 đến 26.

Hỏi nhanh

Với n=40n = 40, 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?

Ví dụĐếm Tổng Mục Tiêu

Đề. Cho n≤40n \le 40 số nguyên dương ai≤109a_i \le 10^9 và x≤109x \le 10^9. Đếm số tập con (theo vị trí) có tổng đúng xx.

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 (u,v)(u, v) với u+v=xu + v = x. 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 xx ứng với đúng một cặp vị trí (u,v)(u, v) trong hai danh sách. Tổng một nửa lên tới 2⋅10102 \cdot 10^{10} và đáp án tới 2402^{40}, 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 {0}\{0\} (tập rỗng). Thêm phần tử aia_i thì mỗi tập con cũ cho hai tập con mới: không lấy aia_i (tổng giữ nguyên) và lấy aia_i (tổng cộng aia_i). 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 kk phần tử, danh sách có 2k2^k số.

C++
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ỏ. ii đi từ đầu LL lên, jj đi từ cuối RR xuống. Tổng nhỏ hơn xx thì phải tăng LiL_i; lớn hơn thì phải giảm RjR_j. Gặp tổng bằng xx, hai danh sách có thể có nhiều giá trị bằng nhau liền nhau: đếm khối Li=uL_i = u (dài cu) và khối Rj=vR_j = v (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.

C++
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ụ (1,2,3,3)(1, 2, 3, 3) và x=6x = 6: L=(0,1,2,3)L = (0, 1, 2, 3), R=(0,3,3,6)R = (0, 3, 3, 6); các cặp là 0+60 + 6 một lần và 3+33 + 3 hai lần, tổng 3.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Mỗi nửa sinh trong O(2n/2)O(2^{n/2}) nhờ trộn thay vì sắp, ghép cũng O(2n/2)O(2^{n/2}); bộ nhớ hai danh sách 2202^{20} số.

Những câu hỏi ghép khác

Cùng khung hai con trỏ trả lời được nhiều câu hỏi khác về cặp (u,v)(u, v); đề thi thường chỉ đổi câu hỏi chứ không đổi khung.

  • Tổng lớn nhất không vượt TT. Với uu tăng dần, ngưỡng T−uT - u giảm, nên con trỏ jj trên RR chỉ lùi: lùi tới khi u+Rj≤Tu + R_j \le T rồi cập nhật đáp án.
  • Đếm cặp có tổng không vượt TT. Cùng con trỏ đó, mỗi uu cộng thêm j+1j + 1, số phần tử v≤T−uv \le T - u. Số tập con có tổng trong [A,B][A, B] là số cặp tổng ≤B\le B trừ số cặp tổng ≤A−1\le A - 1.
  • Tổng gần mục tiêu gg nhất. Phần tử của RR gần g−ug - u nhất nằm cạnh vị trí chèn của g−ug - u: phần tử đầu tiên không nhỏ hơn g−ug - u, 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ỏ RR vào bảng băm và tra x−ux - u trong thời gian kỳ vọng O(1)O(1). 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.

Ví dụNgân Sách Tối Ưu

Đề. Có n≤40n \le 40 công việc, việc ii tốn ai≤109a_i \le 10^9 thời gian. Chọn một tập việc bất kỳ, kể cả rỗng, có tổng thời gian không vượt T≤109T \le 10^9; in tổng lớn nhất đạt được.

Lời giải. Câu hỏi “tổng lớn nhất không vượt TT” trên cặp (u,v)(u, v), u∈Lu \in L, v∈Rv \in R. Với uu tăng dần, ngưỡng T−uT - u giảm, nên phần tử lớn nhất của RR còn dùng được chỉ lùi dần: một con trỏ jj đi từ cuối RR là đủ.

Bước 1: sinh và bỏ sớm tổng vượt TT. Như ví dụ trước, nhưng mọi aia_i dương nên tổng nào đã vượt TT thì thêm phần tử chỉ làm nó lớn hơn; bỏ ngay. Danh sách s đã sắp nên các tổng s[k] + a[i] còn ≤T\le T là một đoạn đầu: vòng for dừng ở tổng đầu tiên vượt TT. Trộn đoạn đó với s.

C++
vector<ll> gen(ll lo, ll hi) {
    vector<ll> s = {0};
    for (ll i = lo; i < hi; i++) {
        vector<ll> t;
        for (ll k = 0; k < (ll)s.size() && s[k] + a[i] <= T; k++) t.push_back(s[k] + a[i]);
        vector<ll> c(s.size() + t.size());
        merge(s.begin(), s.end(), t.begin(), t.end(), c.begin());
        s = c;
    }
    return s;
}

Bước 2: ghép. Với mỗi uu của LL theo thứ tự tăng, lùi jj tới khi u+Rj≤Tu + R_j \le T rồi cập nhật đáp án. Vòng lùi không bao giờ đưa jj xuống âm, vì R0=0R_0 = 0 và mọi uu đã ≤T\le T sau bước 1.

C++
ll j = R.size() - 1, ans = 0;
for (ll u : L) {
    while (u + R[j] > T) j--;
    ans = max(ans, u + R[j]);
}

Với test ví dụ, nửa trái (3,5)(3, 5) cho L=(0,3,5,8)L = (0, 3, 5, 8), nửa phải (8,9,12)(8, 9, 12) cho R=(0,8,9,12)R = (0, 8, 9, 12) sau khi bỏ các tổng vượt 14; cặp 5+9=145 + 9 = 14 là tốt nhất.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll n, T, 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;
        for (ll k = 0; k < (ll)s.size() && s[k] + a[i] <= T; k++) t.push_back(s[k] + a[i]);
        vector<ll> c(s.size() + t.size());
        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 >> T;
    for (ll i = 0; i < n; i++) cin >> a[i];
    L = gen(0, n / 2);
    R = gen(n / 2, n);
    ll j = R.size() - 1, ans = 0;
    for (ll u : L) {
        while (u + R[j] > T) j--;
        ans = max(ans, u + R[j]);
    }
    cout << ans << '\n';
}
5 14
3 5 8 9 12

jj chỉ lùi nên cả vòng ghép là O(∣L∣+∣R∣)O(|L| + |R|); tổng cộng O(2n/2)O(2^{n/2}).

Tóm tắt của một nửa không chỉ là một con số

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 KK phần tử. Tóm tắt là (tổng, số phần tử). Nửa trái dùng cc phần tử thì nửa phải bắt buộc dùng K−cK - c, 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 cc với nhóm K−cK - c.
  • Phần dư. Muốn tổng theo modulo mm 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 mm.
  • Ba lô trọng lượng lớn. Tóm tắt là (trọng lượng ww, giá trị pp), ghép được khi wL+wR≤Mw_L + w_R \le M. 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 xx 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ả aha_h và ah+1a_{h+1}, 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 a1,…,ana_1, \dots, a_n có tổng xx 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?

Ví dụPhần Dư Tập Con Lớn Nhất

Đề. Cho n≤35n \le 35 số nguyên dương ai≤109a_i \le 10^9 và m≤109m \le 10^9. Chọn một tập con bất kỳ, kể cả rỗng, để tổng chia mm dư lớn nhất; in phần dư đó.

Lời giải. Quy hoạch động theo phần dư tốn O(nm)O(nm), duyệt tập con tốn 2352^{35}: cả hai quá chậm. Thay aia_i bằng ai mod ma_i \bmod m, sinh phần dư tổng của mỗi nửa, ta được hai danh sách LL, RR gồm các số trong [0,m)[0, m), mỗi danh sách tối đa 2182^{18} phần tử. Một tập con cho phần dư (x+y) mod m(x + y) \bmod m với x∈Lx \in L, y∈Ry \in R, và có hai trường hợp:

  • x+y<mx + y < m: phần dư là x+yx + y.
  • x+y≥mx + y \ge m: phần dư là x+y−m<xx + y - m < x, vì y<my < m. Mà xx đạ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à max⁡{x+y≤m−1}\max\{x + y \le m - 1\}, đúng câu hỏi “lớn nhất không vượt ngưỡng” với ngưỡng m−1m - 1.

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ư hai 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.

C++
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 m−1m - 1. Duyệt xx tăng dần, con trỏ jj trên RR chỉ lùi tới khi x+Rj<mx + R_j < m. Nó dừng trước khi âm vì R0=0R_0 = 0 và x<mx < m.

C++
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ụ, L=(0,3,6,7)L = (0, 3, 6, 7) từ (6,7)(6, 7), R=(0,2,4,6)R = (0, 2, 4, 6) từ (4,2)(4, 2); cặp tốt nhất là 3+6=93 + 6 = 9.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Sinh mỗi nửa O(2n/2)O(2^{n/2}), sắp O(2n/2⋅n)O(2^{n/2} \cdot n), ghép tuyến tính.

Ví dụChọn Đúng K Món

Đề. Có N≤40N \le 40 món, món ii giá ai≤1016a_i \le 10^{16}. Đếm số cách chọn đúng KK món có tổng giá không vượt P≤1018P \le 10^{18}.

Lời giải. Hai điều kiện, đúng KK món và tổng ≤P\le P, 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 (TL,TR)(T_L, T_R) với ∣TL∣+∣TR∣=K|T_L| + |T_R| = K, 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 cc của nửa trái với nhóm K−cK - c của nửa phải. Mỗi cách được đếm đúng một lần ở cặp nhóm (∣TL∣,K−∣TL∣)(|T_L|, K - |T_L|).

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 aia_i thì tổng cộng aia_i 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.

C++
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 [sum, c] : s) b[c].push_back(sum);
    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 cc và d=K−cd = K - c hợp lệ, quét xx tăng dần trên LcL_c, giữ jj là số phần tử của RdR_d không vượt P−xP - x. P−xP - x giảm nên jj chỉ giảm, và mỗi xx cộng thêm jj cách. P−xP - x có thể âm, khi đó jj về 0.

C++
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 40⋅1016=4⋅101740 \cdot 10^{16} = 4 \cdot 10^{17}, còn dưới giới hạn ll. Với test ví dụ, K=3K = 3: nhóm L1=(2,3)L_1 = (2, 3) ghép R2=(9,12,13)R_2 = (9, 12, 13) được hai cách, L2=(5)L_2 = (5) ghép R1=(4,5,8)R_1 = (4, 5, 8) được hai cách, tổng 4.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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 [sum, c] : s) b[c].push_back(sum);
    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

Sinh O(2n/2)O(2^{n/2}), sắp O(2n/2⋅n)O(2^{n/2} \cdot n), ghép tuyến tính theo tổng kích thước các nhóm.

Gặp nhau ở giữa một 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 aa gồm nn số, đếm các bộ (i,j,k,l)(i, j, k, l) bất kỳ sao cho ai+aj=ak+ala_i + a_j = a_k + a_l, với nn cỡ 2000. Duyệt bốn chỉ số tốn n4≈1,6⋅1013n^4 \approx 1{,}6 \cdot 10^{13}. Nhưng phương trình đã tách sẵn hai vế: vế trái chỉ phụ thuộc cặp (i,j)(i, j), vế phải chỉ phụ thuộc cặp (k,l)(k, l). Gọi csc_s là số cặp có thứ tự với tổng ss; 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 cs2c_s^2 qua mọi giá trị ss. Với a=(1,2,3)a = (1, 2, 3) các tổng là 2,3,3,4,4,4,5,5,62, 3, 3, 4, 4, 4, 5, 5, 6, các nhóm có kích thước 1,2,3,2,11, 2, 3, 2, 1 và đáp án là 1+4+9+4+1=191 + 4 + 9 + 4 + 1 = 19.

C++
#include <bits/stdc++.h>
using namespace std;
using 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

Thời gian O(n2log⁡n)O(n^2 \log n) cho việc sắp xếp, so với O(n4)O(n^4) ban đầu. Bộ nhớ là cái giá: n2n^2 giá trị, với n=4000n = 4000 là 1,6⋅1071{,}6 \cdot 10^7 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 Ai+Bj=−(Ck+Dl)A_i + B_j = -(C_k + D_l)), 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:

  • a+b+c=da + b + c = d viết lại thành a+b=d−ca + b = d - c: vế trái n2n^2 giá trị, rồi duyệt dd và cc.
  • ai+bj+ck+dl=0a_i + b_j + c_k + d_l = 0 thành ai+bj=−(ck+dl)a_i + b_j = -(c_k + d_l): hai vế mỗi vế n2n^2 giá trị.
  • ab+c=d(e+f)ab + c = d(e + f) với d≠0d \ne 0: mỗi vế là một bộ ba, n3n^3 giá trị mỗi vế, chấp nhận được khi n≤100n \le 100.

Ví dụGiá Trị Lớn Nhất Từ Ba Số

Đề. Cho tập UU gồm n≤1000n \le 1000 số nguyên dương đôi một khác nhau, mỗi số ≤2⋅108\le 2 \cdot 10^8. Tìm phần tử lớn nhất d∈Ud \in U sao cho có a,b,c∈Ua, b, c \in U (được trùng nhau) với a+b+c=da + b + c = d; đề bảo đảm có đáp án.

Lời giải. Bốn biến, duyệt thẳng n4n^4. Viết lại a+b=d−ca + b = d - c: vế trái là một cặp, chỉ cần lưu mọi tổng ui+uju_i + u_j với i≤ji \le j (cặp không thứ tự là đủ vì chỉ hỏi có hay không), khoảng 5⋅1055 \cdot 10^5 số, rồi sắp. Vế phải là cặp (d,c)(d, c): thử dd từ lớn xuống nhỏ, với mỗi dd thử mọi cc và tra d−cd - c trong danh sách bằng chặt nhị phân; gặp lần đầu là in dd và dừng, vì dd đang thử là lớn nhất còn lại. Tổng cộng O(n2log⁡n)O(n^2 \log n). Với test ví dụ, d=13d = 13 và c=7c = 7 cho 13−7=6=2+413 - 7 = 6 = 2 + 4.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

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 n×mn \times m 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 KK, với n,m≤20n, m \le 20.

Mỗi đường đi có đúng D=n+m−2D = n + m - 2 bước, nên số đường đi là (Dn−1)\binom{D}{n-1} (chọn n−1n - 1 bước đi xuống trong DD bước), với bảng 20×2020 \times 20 là (3819)≈3,5⋅1010\binom{38}{19} \approx 3{,}5 \cdot 10^{10}: 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 h=⌊D/2⌋h = \lfloor D/2 \rfloor bước đều rơi vào một ô trên đường chéo i+j=hi + j = h (đá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á 219≈5⋅1052^{19} \approx 5 \cdot 10^5 hướng đi.

Ô xanh dương là đường chéo gặp nhau; bấm một ô để xem XOR của hai nửa đường qua nó.

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 xx 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 vv (gồm cả hai ô này); yy là XOR các số trên nửa sau, từ ô gặp nhau vv tới ô đích. XOR của cả đường đi là gì?

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ó bb trạng thái kề và đáp án cách dd bước, BFS từ hai đầu chỉ sinh cỡ bd/2b^{d/2} trạng thái mỗi phía thay vì bdb^d từ một phía.

Ví dụĐường Đi XOR Mục Tiêu

Đề. Bảng n×mn \times m (n,m≤20n, m \le 20), ô chứa số nguyên không âm tới 101810^{18}. Đếm đường đi từ (1,1)(1, 1) tới (n,m)(n, m), 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 KK.

Lời giải. Như vừa phân tích, có tới 3,5⋅10103{,}5 \cdot 10^{10} đường nên không duyệt hết được, nhưng mọi đường cắt đường chéo i+j=hi + j = h tại đúng một ô. Chia mỗi đường thành nửa đầu từ (0,0)(0, 0) tới ô trên đường chéo và nửa sau từ ô đó tới (n−1,m−1)(n - 1, m - 1); hai nửa ghép được khi cùng ô gặp nhau và XOR của chúng bằng KK.

Bước 1: nửa đầu. go1 đi từ (0,0)(0, 0), chỉ sang phải hoặc xuống, XOR dần các ô, kể cả ô gặp nhau. Tới ô có i+j=hi + j = h thì tăng cnt[i][v]: bảng đếm của ô gặp nhau ở hàng ii (hàng xác định luôn ô trên đường chéo).

C++
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ừ (n−1,m−1)(n - 1, m - 1) lên hoặc sang trái, XOR các ô trừ ô gặp nhau. Tới đường chéo với XOR vv, nửa đầu cần XOR v⊕Kv \oplus K để cả đường ra KK, 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.

C++
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 1,2,4,161, 2, 4, 16 (theo hai cách) cho XOR 23.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Mỗi nửa đi tối đa 19 bước, tức cỡ 2192^{19} nhánh, mỗi nhánh một thao tác map: khoảng 219log⁡2192^{19} \log 2^{19}.

Khi đi thi

  • Giới hạn nn 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ó 2n/22^{n/2} (hoặc 3n/23^{n/2}) 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 2n2^n thành hai danh sách cỡ 2n/22^{n/2} cần ghép.
  • Danh sách tổng đã sắp sinh bằng cách nhân đôi và trộn trong O(2k)O(2^k); hai con trỏ ghép trong O(2n/2)O(2^{n/2}); hai nửa phải cân nhau.
  • Câu hỏi ghép thường gặp: đếm tổng bằng xx (nhóm giá trị bằng nhau, nhân số lượng), lớn nhất không vượt TT, 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ó n2n^2 hoặc n3n^3 giá trị) và cho đường đi (gặp nhau trên đường chéo giữa).

Bài tập

1
Giới Hạn Hành Lýcanuc80k1002Đếm tập con có tổng không vượt WW: mỗi uu cộng số tổng phải không vượt W−uW - u; có nhiều bộ dữ liệu và khối lượng có thể bằng 0.
LQDOJ
2
Tổng Trong Khoảngcanuc80k1003Đếm tổng trong [A,B][A, B] bằng hiệu hai lần đếm “không vượt”; số âm nên không cắt nhánh theo tổng tạm thời được.
LQDOJ
3
Tổng Tập Con Sát Mục Tiêucanuc80k1030Với mỗi uu thử hai ứng viên kề vị trí chèn của goal−ugoal - u trong danh sách phải; tập rỗng cũng hợp lệ.
LQDOJ
4
Chia Hai Nhóm Cân Bằngcanuc80k1013Chia không nhãn: cố định phần tử đầu ở một nhóm để khỏi đếm đôi; tóm tắt mỗi nửa là hiệu hai nhóm, ghép hai hiệu sao cho tổng của chúng gần 0 nhất và đếm số cặp đạt.
LQDOJ
5
Các Bộ Lệnh Đến Đíchcanuc80k1018Tóm tắt là (tổng vector, số lệnh); ghép nửa trái (v,c)(v, c) với nửa phải ((X,Y)−v,k−c)((X, Y) - v, k - c) cho mọi kk cùng lúc.
LQDOJ
6
Bốn Lần Némcanuc80k1006Thêm mức điểm 0 để “nhiều nhất bốn lần ném” thành đúng bốn lần, rồi ghép hai cặp, mỗi bên N2N^2 tổng.
LQDOJ
7
Bốn Chỉ Số Đạt Tổngcanuc80k1005Tổng bốn số là tổng của hai cặp; duyệt cặp thứ hai theo chỉ số tăng dần và chỉ đưa vào bảng những cặp nằm hẳn bên trái nó, bốn chỉ số tự khác nhau.
LQDOJ
8
Bốn Dãy Tổng Bằng Khôngcanuc80k1007Hai danh sách tổng cặp Ai+BjA_i + B_j và Ck+DlC_k + D_l; tổng trùng phải đếm nhiều lần, để ý bộ nhớ cỡ n2n^2.
LQDOJ
9
Ba Lô Sức Chứa Lớncanuc80k1012Tóm tắt là (trọng lượng, giá trị); sắp nửa phải theo trọng lượng, lấy max tiền tố giá trị, mỗi trạng thái trái hỏi bằng chặt nhị phân.
LQDOJ
10
Tập Con Gần Số Khôngcanuc80k1022Tập con phải khác rỗng và hòa thì chọn ít phần tử nhất: lưu (tổng, số phần tử) và cẩn thận cặp mà cả hai nửa cùng rỗng.
LQDOJ
11
Sáu Vị Trí Cân Bằngcanuc80k1017Viết ab+c=d(e+f)ab + c = d(e + f) với d≠0d \ne 0: mỗi vế là một bộ ba, n3n^3 giá trị; các bộ có thứ tự nên đếm số lần xuất hiện.
LQDOJ
12
Hai Túi Vàng Cân Bằngcanuc80k1019Mỗi thỏi có ba lựa chọn; với một nửa chỉ cần (hiệu hai túi, tổng đã dùng), và cùng hiệu thì giữ tổng đã dùng lớn nhất.
LQDOJ
13
Ba Nhóm Cùng Điểmcanuc80k1034Ba tổng bằng nhau tương đương hai hiệu bằng 0; khóa là cặp hiệu, giá trị là tổng nhóm A, cần truy vết cách chọn.
LQDOJ
14
Hai Dãy Màu Đối Chiềucanuc80k1026Cắt chuỗi thành hai nửa dài NN, đảo nửa sau; mỗi cách tô một nửa cho một cặp (chuỗi đỏ, chuỗi xanh), hai nửa ghép được khi các cặp “khớp chéo”.
LQDOJ

Đọc thêm

  • Meet in the MiddleUSACO GuideMở bằng bài CSES 1628 (ví dụ đầu tiên ở trên), kèm danh sách bài luyện từ dễ tới khó.
  • Competitive Programmer’s Handbook, mục 5.5Hai trang ngắn nêu đúng hướng làm của bài này, kể cả việc sinh danh sách đã sắp bằng phép trộn để đạt O(2n/2)O(2^{n/2}).
  • Subset sum problemWikipediaNguồn gốc của kỹ thuật (Horowitz-Sahni, 1974) và cách Schroeppel-Shamir hạ bộ nhớ xuống 2n/42^{n/4}.
  • 折半搜索 (meet in the middle)OI WikiBài toán đèn USACO 2009 với n≤35n \le 35 và BFS hai chiều, cách nhìn của cộng đồng Trung Quốc.
  • Meet In The Middlevelog (BOJ 1208)Bài viết tiếng Hàn đếm tập con có tổng SS bằng sắp xếp và tìm kiếm nhị phân, có lưu ý về tập rỗng khi S=0S = 0.