Chương 0·Bài 06

Two Pointers

Cho dãy số dương và một ngưỡng TT, tìm đoạn liên tiếp dài nhất có tổng không vượt TT. Thử mọi đoạn thì có tới cỡ n22\frac{n^2}{2} đoạn. Nhưng hãy hình dung một khung cửa sổ trượt trên dãy: mép phải tiến từng bước để nhận thêm số, tổng vượt ngưỡng thì mép trái tiến theo để bỏ bớt. Không mép nào phải lùi, nên cả quá trình chỉ khoảng 2n2n bước.

Đó là kỹ thuật hai con trỏ (two pointers). Dạng cùng chiều, tức cửa sổ trượt, trả lời câu hỏi về đoạn liên tiếp. Dạng ngược chiều xuất phát từ hai đầu một dãy đã sắp rồi tiến vào giữa, trả lời câu hỏi về cặp phần tử. Cả hai dựa trên cùng một ý: mỗi bước loại vĩnh viễn một vị trí chắc chắn không thuộc đáp án.

Cửa sổ trượt

Đánh số từ 1, đoạn đóng [l,r][l, r]; đoạn rỗng viết là l=r+1l = r + 1. Cố định đầu phải rr. Mọi aia_i dương nên khi ll tăng, tổng đoạn [l,r][l, r] giảm, vì thế các đầu trái cho tổng không vượt TT nằm liền nhau ở phía phải. Gọi L(r)L(r) là đầu trái nhỏ nhất thỏa; đoạn dài nhất kết thúc tại rr là [L(r),r][L(r), r]. Điều làm nên kỹ thuật là L(r)L(r) không bao giờ lùi:

L(1)≤L(2)≤⋯≤L(n),L(1) \le L(2) \le \dots \le L(n),

vì bỏ ar+1>0a_{r+1} > 0 khỏi đoạn [L(r+1),r+1][L(r+1), r+1] cho đoạn [L(r+1),r][L(r+1), r] có tổng nhỏ hơn, vẫn thỏa. Vậy chỉ cần một biến ll: sang bước rr, xuất phát từ ll cũ rồi đẩy sang phải cho tới khi đoạn thỏa; mọi đầu trái đã vượt qua bị loại vĩnh viễn. Giữ thêm sum là tổng cửa sổ: thêm ara_r thì cộng, bỏ ala_l thì trừ.

Khung xanh là cửa sổ; mép phải thêm một số, mép trái bỏ dần tới khi tổng không vượt T.
C++
ll l = 1, sum = 0, ans = 0;
for (ll r = 1; r <= n; r++) {
    sum += a[r];
    while (sum > t) sum -= a[l++];
    ans = max(ans, r - l + 1);
}

Vòng while nằm trong vòng for làm ta ngờ là O(n2)O(n^2), nhưng phải đếm tổng thể. Mỗi lần thân while chạy thì l tăng 1, mà l chỉ tăng và không vượt n+1n + 1, nên qua cả chương trình thân while chạy không quá nn lần. Mỗi phần tử vào cửa sổ một lần và ra không quá một lần: tổng cộng không quá 2n2n thao tác. Cách đếm này nhìn tổng số bước của con trỏ trên cả chương trình thay vì từng vòng, và sẽ dùng lại ở nhiều bài sau.

Lập luận chỉ dùng một tính chất của đề: bỏ bớt phần tử khỏi một đoạn thỏa thì đoạn còn lại vẫn thỏa. Gọi đoạn thỏa là tốt. Cửa sổ trượt dùng được khi “tốt” đơn điệu theo một trong hai chiều:

  • Loại A: đoạn con của đoạn tốt vẫn tốt. Dùng cho “dài nhất” và “đếm”. Ví dụ: tổng ≤T\le T với số dương; các phần tử đôi một khác nhau; không quá kk giá trị khác nhau; max⁡−min⁡≤d\max - \min \le d.
  • Loại B: đoạn chứa một đoạn tốt vẫn tốt. Dùng cho “ngắn nhất”. Ví dụ: tổng ≥S\ge S với số dương; chứa đủ mọi loại ký tự.

Số âm là chỗ dễ bị lừa. Với a=(5,−4,1)a = (5, -4, 1) và T=2T = 2, cả dãy có tổng 2 nên đáp án là 3. Chương trình trên bỏ a1a_1 ở r=1r = 1 vì 5>25 > 2; sau đó a2=−4a_2 = -4 kéo tổng xuống, đoạn chứa a1a_1 lại tốt, nhưng a1a_1 đã bị loại. Nó in 2. Các điều kiện “tổng đúng bằng xx” khi có số âm, “tổng chia hết cho mm” cũng không đơn điệu; chúng giải bằng tổng tiền tố và bảng đếm (bài 02).

Hỏi nhanh

Chương trình cửa sổ trượt ở trên có vòng while nằm trong vòng for. Với n=105n = 10^5 số dương, trong trường hợp xấu nhất, thân vòng while chạy tổng cộng bao nhiêu lần qua toàn bộ chương trình?

Ví dụĐoạn Việc Trong Ngân Sách

Đề. n≤105n \le 10^5 công việc, việc ii cần aia_i đơn vị thời gian. Chọn một đoạn liên tiếp (có thể rỗng) có tổng không vượt TT, dài nhất.

Lời giải. Đúng mẫu ở trên: tổng ≤T\le T với số dương là điều kiện loại A. Khi một mình ar>Ta_r > T, vòng while bỏ cả ara_r tới khi l=r+1l = r + 1 và sum bằng 0, ghi nhận độ dài 0 đúng với đoạn rỗng. sum không vượt T+104T + 10^4 nhưng cứ dùng long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, t, a[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> t;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    ll l = 1, sum = 0, ans = 0;
    for (ll r = 1; r <= n; r++) {
        sum += a[r];
        while (sum > t) sum -= a[l++];
        ans = max(ans, r - l + 1);
    }
    cout << ans << '\n';
}
7 10
6 2 3 5 1 1 4

Dài nhất, ngắn nhất, đếm

Dài nhất (loại A) là bài vừa làm: co mép trái cho tới khi cửa sổ tốt, rồi ghi nhận r−l+1r - l + 1 sau vòng co.

Ngắn nhất (loại B), ví dụ đoạn ngắn nhất có tổng ≥S\ge S trên dãy dương. Với rr cố định, đoạn ngắn nhất kết thúc tại rr dùng đầu trái lớn nhất còn tốt. Ta vẫn co từ trái, nhưng trong khi đoạn còn tốt: ghi nhận độ dài rồi mới bỏ ala_l. Khi thoát vòng, đoạn đã hỏng vì vừa bỏ quá tay một phần tử, nên không được ghi nhận.

C++
for (ll r = 1; r <= n; r++) {
    sum += a[r];
    while (sum >= s) {
        ans = min(ans, r - l + 1);
        sum -= a[l++];
    }
}

Đếm đoạn tốt. Với loại A, mọi đầu trái từ ll (sau vòng co) đến rr đều cho đoạn tốt, nên cộng r−l+1r - l + 1. Với loại B, mọi đầu trái đã bị bỏ đều tốt còn ll thì không, nên cộng l−1l - 1. Đáp án tới n(n+1)2\frac{n(n+1)}{2}, đếm bằng long long. Đếm đoạn có tổng đúng bằng xx trên dãy dương thì với mỗi rr chỉ có tối đa một đầu trái, hoặc lấy hiệu: số đoạn có tổng ≤x\le x trừ số đoạn có tổng ≤x−1\le x - 1.

Hỏi nhanh

Bài đoạn ngắn nhất có tổng ≥S\ge S trên dãy dương. Giả sử ta viết vòng co giống bài đoạn dài nhất, while (sum >= s) sum -= a[l++];, rồi ghi nhận ans = min(ans, r - l + 1) ngay sau vòng. Với a=(2,3,1,2,4,3)a = (2, 3, 1, 2, 4, 3) và S=7S = 7 chương trình in 11, trong khi đáp án đúng là 22. Vì sao sai?

Trạng thái cửa sổ

Cửa sổ trượt thật ra cần một cấu trúc nhớ nội dung cửa sổ, với ba thao tác: thêm phần tử bên phải, bỏ phần tử bên trái, và trả lời “đoạn hiện tại còn tốt không”. Với tổng, đó là một biến. Các trạng thái hay gặp khác:

  • Tích của số nguyên dương: một biến prod, bỏ phần tử bằng prod /= a[l]. Có số 0 thì phép chia hỏng, phải xử lý riêng.
  • Số giá trị khác nhau: mảng đếm cnt[v] và biến diff. Thêm vv: if (cnt[v]++ == 0) diff++. Bỏ vv: if (--cnt[v] == 0) diff--. Giá trị lớn thì nén tọa độ (bài 03).
  • Hiệu lớn nhất trừ nhỏ nhất: cần cực trị của cửa sổ, dùng multiset (bài 09, O(log⁡n)O(\log n) mỗi thao tác) hoặc hàng đợi đơn điệu.

Hàng đợi đơn điệu cho giá trị lớn nhất giữ một deque (hàng đợi hai đầu, thêm bớt được ở cả đầu lẫn đuôi; bài 09) các chỉ số trong cửa sổ với giá trị giảm ngặt từ đầu về đuôi. Thêm rr: bỏ mọi chỉ số ở đuôi có giá trị ≤ar\le a_r, vì chúng đứng trước rr mà không lớn hơn, cửa sổ nào còn chứa chúng cũng chứa rr. Rồi rr vào đuôi. Giá trị lớn nhất của cửa sổ là phần tử đầu; khi mép trái vượt qua chỉ số ở đầu thì bỏ đầu. Mỗi chỉ số vào một lần, ra một lần: O(n)O(n).

Deque giữ chỉ số có giá trị giảm dần; ô cam ở đầu là max của cửa sổ.
C++
while (!mx.empty() && a[mx.back()] <= a[r]) mx.pop_back();
mx.push_back(r);
while (mx.front() < l) mx.pop_front();

multiset cũng cho giá trị lớn nhất của cửa sổ, viết nhanh hơn, nhưng mỗi bước tốn O(log⁡k)O(\log k) cùng một lần cấp phát nút:

Lớn nhất của mọi cửa sổ độ dài 1000 trên một triệu số, trung vị 5 lần chạy, i7-10850H, g++ -O2.multiset192 ms · gấp 18 lầndeque11 ms
Lớn nhất của mọi cửa sổ độ dài 1000 trên một triệu số, bằng multiset và bằng hàng đợi đơn điệu, đo trên i7-10850H, g++ -O2.

Hàng đợi đơn điệu nhanh hơn khoảng 20 lần. Với n=2⋅105n = 2 \cdot 10^5 thì multiset vẫn qua; khi nn tới 10610^6 hoặc phải chạy nhiều lần, hãy dùng deque.

Ví dụĐoạn Dài Nhất Có Không Quá K Giá Trị

Đề. Dãy n≤5⋅105n \le 5 \cdot 10^5 số, 0≤ai≤1060 \le a_i \le 10^6, và số kk. In hai đầu mút của một đoạn dài nhất có không quá kk giá trị khác nhau.

Lời giải. Đoạn con của đoạn có không quá kk giá trị cũng vậy, nên điều kiện thuộc loại A. Giữ cnt và diff; với mỗi rr, thêm ara_r, trong khi diff > k thì bỏ ala_l. Sau vòng co [l,r][l, r] là đoạn tốt dài nhất kết thúc tại rr; cập nhật đáp án khi dài hơn hẳn, nên in ra đoạn dài nhất xuất hiện đầu tiên. Mỗi phần tử vào ra một lần, O(n)O(n); mảng đếm 106+110^6 + 1 ô.

Hàng dưới là mảng đếm của cửa sổ; khi số giá trị khác nhau vượt K, mép trái tiến.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 5e5 + 5;
ll n, k, a[N], cnt[1000005];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> k;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    ll l = 1, diff = 0, bl = 1, br = 1;
    for (ll r = 1; r <= n; r++) {
        if (cnt[a[r]]++ == 0) diff++;
        while (diff > k) {
            if (--cnt[a[l]] == 0) diff--;
            l++;
        }
        if (r - l > br - bl) bl = l, br = r;
    }
    cout << bl << ' ' << br << '\n';
}
10 2
1 2 1 3 4 3 3 4 5 4

Cặp có tổng cho trước

Bài toán: cho b1≤b2≤⋯≤bmb_1 \le b_2 \le \dots \le b_m đã sắp và số xx; tìm i<ji < j với bi+bj=xb_i + b_j = x. Thử mọi cặp là O(m2)O(m^2), chặt nhị phân cho từng bib_i là O(mlog⁡m)O(m \log m); hai con trỏ làm trong O(m)O(m). Đặt l=1l = 1, r=mr = m, xét s=bl+brs = b_l + b_r:

  • s=xs = x: xong.
  • s<xs < x: brb_r là số lớn nhất còn lại mà ghép với blb_l vẫn thiếu, nên blb_l ghép với số nào còn lại cũng nhỏ hơn xx. Loại blb_l.
  • s>xs > x: blb_l là số nhỏ nhất còn lại mà ghép với brb_r vẫn thừa, nên loại brb_r.

Bất biến: nếu có cặp (i,j)(i, j) với tổng xx thì l≤i<j≤rl \le i < j \le r. Mỗi bước chỉ loại một phần tử đã được chứng minh không thuộc cặp nào còn lại, và r−lr - l giảm 1, nên vòng dừng sau không quá m−1m - 1 bước.

Có thể nhìn bằng bảng các cặp: ô (i,j)(i, j) chứa bi+bjb_i + b_j, tăng khi đi sang phải và đi lên. Mỗi bước gạch bỏ cả một cột hoặc một hàng, và đường đi bám sát ranh giới giữa vùng “nhỏ hơn xx” và vùng “lớn hơn xx”.

Bên trái là hai con trỏ, bên phải là bảng tổng b(i) + b(j); mỗi bước gạch một hàng hoặc một cột.

Điều kiện vòng lặp phải là l < r chứ không phải l <= r: để <= thì khi l=rl = r ta ghép một phần tử với chính nó, và dãy (3,5,8)(3, 5, 8) với x=10x = 10 sẽ báo có cặp 5+55 + 5.

Hỏi nhanh

Dãy đã sắp tăng b1≤⋯≤bmb_1 \le \dots \le b_m, ta tìm cặp có tổng xx bằng hai con trỏ l=1l = 1, r=mr = m. Khi bl+br<xb_l + b_r < x ta tăng ll chứ không giảm rr. Vì sao bỏ blb_l là an toàn?

Ví dụHai Vị Trí Có Tổng

Đề. Mảng n≤2⋅105n \le 2 \cdot 10^5 số và số xx. In hai chỉ số khác nhau i,ji, j với ai+aj=xa_i + a_j = x, hoặc IMPOSSIBLE.

Lời giải. Mảng chưa sắp mà đề đòi vị trí ban đầu, nên sắp các cặp (giá trị, vị trí) theo giá trị, chạy hai con trỏ trên giá trị rồi in vị trí gốc. Vì l<rl < r nên hai vị trí in ra khác nhau, kể cả khi hai giá trị trùng nhau. Cả bài O(nlog⁡n)O(n \log n).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, x;
pair<ll, ll> a[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> x;
    for (ll i = 1; i <= n; i++) {
        cin >> a[i].first;
        a[i].second = i;
    }
    sort(a + 1, a + n + 1);
    ll l = 1, r = n;
    while (l < r) {
        ll s = a[l].first + a[r].first;
        if (s == x) {
            cout << a[l].second << ' ' << a[r].second << '\n';
            return 0;
        }
        if (s < x) l++;
        else r--;
    }
    cout << "IMPOSSIBLE\n";
}
6 13
8 2 11 5 7 1

Đếm cặp

Đếm số cặp i<ji < j có ai+aj≤Xa_i + a_j \le X. Cặp là tập hai vị trí nên thứ tự ban đầu không quan trọng, ta sắp tăng trước. Với l=1l = 1, r=nr = n:

  • al+ar≤Xa_l + a_r \le X: với mọi jj có l<j≤rl < j \le r ta có al+aj≤al+ar≤Xa_l + a_j \le a_l + a_r \le X, nên có đúng r−lr - l cặp (l,j)(l, j). Cộng r−lr - l rồi tăng ll.
  • al+ar>Xa_l + a_r > X: ara_r ghép với mọi phần tử còn lại đều vượt XX. Giảm rr.

Gọi F(X)F(X) là số cặp có tổng ≤X\le X. Với số nguyên, số cặp có tổng trong [L,R][L, R] là F(R)−F(L−1)F(R) - F(L - 1): cùng mẹo hiệu hai lần đếm ở mục đếm đoạn.

Hỏi nhanh

Dãy đã sắp (1,2,3,4)(1, 2, 3, 4), X=5X = 5, đang đếm cặp i<ji < j có tổng ≤X\le X. Ở bước đầu l=1l = 1, r=4r = 4 (giá trị 11 và 44), tổng bằng 5≤X5 \le X. Chương trình cộng r−l=3r - l = 3 vào đáp án rồi tăng ll. Vì sao đúng?

Ví dụĐếm Cặp Có Tổng Trong Khoảng

Đề. Dãy nn số nguyên dương và hai số L≤RL \le R; đếm cặp i<ji < j với L≤ai+aj≤RL \le a_i + a_j \le R. Tổng nn qua các test không quá 2⋅1052 \cdot 10^5.

Lời giải. Sắp dãy, đáp án là F(R)−F(L−1)F(R) - F(L - 1) với FF tính bằng hai con trỏ như trên. Số cặp tới 2⋅10102 \cdot 10^{10}, dùng long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll t, n, lo, hi, a[N];

ll f(ll x) {
    ll l = 1, r = n, cnt = 0;
    while (l < r) {
        if (a[l] + a[r] <= x) {
            cnt += r - l;
            l++;
        } else r--;
    }
    return cnt;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> t;
    while (t--) {
        cin >> n >> lo >> hi;
        for (ll i = 1; i <= n; i++) cin >> a[i];
        sort(a + 1, a + n + 1);
        cout << f(hi) - f(lo - 1) << '\n';
    }
}
3
5 6 10
1 1 5 6 9
4 8 8
4 4 4 4
6 2 20
1 2 3 4 5 6

Hai dãy, hai con trỏ

Với hai dãy cùng sắp tăng ta dùng một con trỏ cho mỗi dãy và chỉ đẩy con trỏ cần tiến: trộn hai dãy (như bước trộn của sắp xếp trộn, bài 03), tìm giá trị chung, hợp, hiệu. Cũng dạng đó là khi các câu hỏi cho theo thứ tự tăng: thay cho một lần chặt nhị phân mỗi câu, con trỏ trên dãy kia chỉ tiến. Ví dụ đếm, với mỗi aia_i, số phần tử của bb nhỏ hơn nó:

C++
ll j = 1;
for (ll i = 1; i <= n; i++) {
    while (j <= m && b[j] < a[i]) j++;
    cnt[i] = j - 1;
}

Khớp dãy con. Kiểm chuỗi tt có là dãy con của chuỗi ss không: duyệt ss, mỗi khi ký tự của ss bằng ký tự đang cần của tt thì tiến con trỏ trên tt. Tham lam này đúng vì dời mỗi ký tự được ghép về lần xuất hiện sớm nhất còn dùng được chỉ để lại nhiều chỗ hơn cho các ký tự sau.

Còn một dạng nữa là hai con trỏ cùng chiều nhưng khác tốc độ: trên dãy xi+1=f(xi)x_{i+1} = f(x_i), con trỏ chậm đi một bước, con trỏ nhanh đi hai bước; nếu có chu kỳ thì hai con trỏ gặp nhau trong chu kỳ, chỉ tốn O(1)O(1) bộ nhớ. Đó là thuật toán thỏ và rùa của Floyd.

Ví dụGhép Kích Thước

Đề. nn yêu cầu kích thước aia_i và mm tài nguyên kích thước bjb_j (n,m≤2⋅105n, m \le 2 \cdot 10^5). Ghép yêu cầu xx với tài nguyên yy được khi ∣x−y∣≤k|x - y| \le k, mỗi bên dùng nhiều nhất một lần. Tạo nhiều cặp nhất.

Lời giải. Sắp cả hai dãy, con trỏ ii trên yêu cầu, jj trên tài nguyên. Tài nguyên quá nhỏ (bj<ai−kb_j < a_i - k) thì không ghép được với yêu cầu nào từ ii trở đi (các yêu cầu sau chỉ lớn hơn), bỏ nó. Tài nguyên quá lớn (bj>ai+kb_j > a_i + k) thì yêu cầu aia_i không ghép được với tài nguyên nào từ jj trở đi, bỏ yêu cầu. Vừa thì ghép ngay: lập luận đổi chỗ cho thấy ghép yêu cầu nhỏ nhất với tài nguyên nhỏ nhất còn dùng được không bao giờ làm mất cặp. Cả bài O(nlog⁡n+mlog⁡m)O(n \log n + m \log m).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, m, k, a[N], b[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m >> k;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll j = 1; j <= m; j++) cin >> b[j];
    sort(a + 1, a + n + 1);
    sort(b + 1, b + m + 1);
    ll i = 1, j = 1, ans = 0;
    while (i <= n && j <= m) {
        if (b[j] < a[i] - k) j++;
        else if (b[j] > a[i] + k) i++;
        else ans++, i++, j++;
    }
    cout << ans << '\n';
}
6 5 3
30 41 44 50 65 70
28 39 46 67 80

Khi đi thi

  • Thấy “đoạn liên tiếp” cùng một điều kiện mà thêm phần tử chỉ có thể làm đoạn xấu đi (hoặc chỉ có thể làm nó tốt lên) thì nghĩ tới cửa sổ; gặp số âm, hay điều kiện “đúng bằng”, “chia hết”, thì chuyển sang tổng tiền tố và bảng đếm (bài 02).
  • Viết khung for r { thêm a[r]; while (vi phạm) bớt a[l++]; ghi nhận }; với đoạn ngắn nhất thì ghi nhận nằm trong vòng co. Thử ba test nhỏ: n=1n = 1, mọi phần tử đều lớn hơn ngưỡng, mọi phần tử bằng nhau.
  • Thấy “cặp phần tử” mà thứ tự ban đầu không quan trọng thì sắp xếp rồi cho hai đầu tiến vào: nhớ l < r, dùng long long cho tổng và số cặp, và gói (giá trị, vị trí gốc) khi phải in vị trí.

Tóm tắt

  • Hai con trỏ chỉ đi một chiều nên mỗi con trỏ đi tối đa nn bước và cả quá trình là O(n)O(n), kể cả khi vòng while nằm trong vòng for: đếm tổng số bước của con trỏ trên cả chương trình, không đếm từng vòng.
  • Cửa sổ trượt cần tính đơn điệu: đoạn con của đoạn tốt vẫn tốt (dài nhất, đếm) hoặc đoạn chứa đoạn tốt vẫn tốt (ngắn nhất). Số âm phá tính đó.
  • Dài nhất và đếm ghi nhận sau khi co; ngắn nhất ghi nhận trong vòng co, trước khi bỏ ala_l.
  • Trạng thái cửa sổ: một biến cho tổng, mảng đếm cho giá trị khác nhau, hàng đợi đơn điệu cho lớn nhất và nhỏ nhất.
  • Ngược chiều trên dãy đã sắp: tổng nhỏ hơn mục tiêu thì loại ala_l, lớn hơn thì loại ara_r; đếm cặp có tổng ≤X\le X thì mỗi lần al+ar≤Xa_l + a_r \le X cộng r−lr - l.

Bài tập

Đọc thêm