Cho dãy số dương và một ngưỡng , tìm đoạn liên tiếp dài nhất có tổng không vượt . Thử mọi đoạn thì có tới cỡ đ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 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 ; đoạn rỗng viết là . Cố định đầu phải . Mọi dương nên khi tăng, tổng đoạn giảm, vì thế các đầu trái cho tổng không vượt nằm liền nhau ở phía phải. Gọi là đầu trái nhỏ nhất thỏa; đoạn dài nhất kết thúc tại là . Điều làm nên kỹ thuật là không bao giờ lùi:
vì bỏ khỏi đoạn cho đoạn có tổng nhỏ hơn, vẫn thỏa. Vậy chỉ cần một biến : sang bước , xuất phát từ 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 thì cộng, bỏ thì trừ.
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à , 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ên qua cả chương trình thân while chạy không quá 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á 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 với số dương; các phần tử đôi một khác nhau; không quá giá trị khác nhau; .
- 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 với số dương; chứa đủ mọi loại ký tự.
Số âm là chỗ dễ bị lừa. Với và , cả dãy có tổng 2 nên đáp án là 3. Chương trình trên bỏ ở vì ; sau đó kéo tổng xuống, đoạn chứa lại tốt, nhưng đã bị loại. Nó in 2. Các điều kiện “tổng đúng bằng ” khi có số âm, “tổng chia hết cho ” 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 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?
Mỗi lần thân while chạy thì l tăng đúng 1; l bắt đầu từ 1, không giảm và không vượt . Ở một bước while có thể chạy rất nhiều lần, nhưng khi đó các bước khác phải chạy ít đi. Đáp án quên rằng l không quay về đầu; “đúng lần” nhầm số lần chạy của while với số bước của for.
Ví dụĐoạn Việc Trong Ngân Sách
Đề. công việc, việc cần đơ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 , dài nhất.
Lời giải. Đúng mẫu ở trên: tổng với số dương là điều kiện loại A. Khi một mình , vòng while bỏ cả tới khi và sum bằng 0, ghi nhận độ dài 0 đúng với đoạn rỗng. sum không vượt nhưng cứ dùng long long.
#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
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 sau vòng co.
Ngắn nhất (loại B), ví dụ đoạn ngắn nhất có tổng trên dãy dương. Với cố định, đoạn ngắn nhất kết thúc tại 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ỏ . 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.
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ừ (sau vòng co) đến đều cho đoạn tốt, nên cộng . Với loại B, mọi đầu trái đã bị bỏ đều tốt còn thì không, nên cộng . Đáp án tới , đếm bằng long long. Đếm đoạn có tổng đúng bằng trên dãy dương thì với mỗi chỉ có tối đa một đầu trái, hoặc lấy hiệu: số đoạn có tổng trừ số đoạn có tổng .
Hỏi nhanh
Bài đoạn ngắn nhất có tổng 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 và chương trình in , trong khi đáp án đúng là . Vì sao sai?
Ghi nhận phải nằm trong vòng, lúc đoạn còn tốt, trước khi bỏ . Ở tổng là 9, vòng bỏ 2 rồi bỏ 4 và dừng khi với tổng 3. Độ dài là của đoạn hỏng. Dãy chỉ có một đoạn ngắn nhất dài 2, vòng while có chạy, và lỗi xảy ra cả khi nhỏ hơn tổng cả dãy.
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ằngprod /= 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ếndiff. Thêm :if (cnt[v]++ == 0) diff++. Bỏ :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, 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 : bỏ mọi chỉ số ở đuôi có giá trị , vì chúng đứng trước mà không lớn hơn, cửa sổ nào còn chứa chúng cũng chứa . Rồi 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: .
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 cùng một lần cấp phát nút:
Hàng đợi đơn điệu nhanh hơn khoảng 20 lần. Với thì multiset vẫn qua; khi tới 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 số, , và số . In hai đầu mút của một đoạn dài nhất có không quá giá trị khác nhau.
Lời giải. Đoạn con của đoạn có không quá 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 , thêm , trong khi diff > k thì bỏ . Sau vòng co là đoạn tốt dài nhất kết thúc tại ; 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, ; mảng đếm ô.
#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
4 8
Cặp có tổng cho trước
Bài toán: cho đã sắp và số ; tìm với . Thử mọi cặp là , chặt nhị phân cho từng là ; hai con trỏ làm trong . Đặt , , xét :
- : xong.
- : là số lớn nhất còn lại mà ghép với vẫn thiếu, nên ghép với số nào còn lại cũng nhỏ hơn . Loại .
- : là số nhỏ nhất còn lại mà ghép với vẫn thừa, nên loại .
Bất biến: nếu có cặp với tổng thì . 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à giảm 1, nên vòng dừng sau không quá bước.
Có thể nhìn bằng bảng các cặp: ô chứa , 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 ” và vùng “lớn hơn ”.
Điều kiện vòng lặp phải là l < r chứ không phải l <= r: để <= thì khi ta ghép một phần tử với chính nó, và dãy với sẽ báo có cặp .
Hỏi nhanh
Dãy đã sắp tăng , ta tìm cặp có tổng bằng hai con trỏ , . Khi ta tăng chứ không giảm . Vì sao bỏ là an toàn?
Với mọi ta có , nên loại không làm mất cặp nào. “ nhỏ nhất cả dãy” sai vì ta chỉ biết nó nhỏ nhất trong phần còn lại. Giảm làm tổng nhỏ đi, vô ích khi tổng đang thiếu. Và chưa hề được thử với mọi số bên trái: hai con trỏ thử ít cặp hơn thế rất nhiều, đó là lý do nó nhanh.
Ví dụHai Vị Trí Có Tổng
Đề. Mảng số và số . In hai chỉ số khác nhau với , 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ì nên hai vị trí in ra khác nhau, kể cả khi hai giá trị trùng nhau. Cả bài .
#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
2 3
Đếm cặp
Đếm số cặp có . 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 , :
- : với mọi có ta có , nên có đúng cặp . Cộng rồi tăng .
- : ghép với mọi phần tử còn lại đều vượt . Giảm .
Gọi là số cặp có tổng . Với số nguyên, số cặp có tổng trong là : cùng mẹo hiệu hai lần đếm ở mục đếm đoạn.
Hỏi nhanh
Dãy đã sắp , , đang đếm cặp có tổng . Ở bước đầu , (giá trị và ), tổng bằng . Chương trình cộng vào đáp án rồi tăng . Vì sao đúng?
Dãy tăng nên với mọi : tổng thỏa ở đầu mút xa nhất thì thỏa với cả khúc. Đếm xong mọi cặp chứa thì không cần nữa. Ba cặp không đều hợp lệ: . Cộng đếm cả cặp .
Ví dụĐếm Cặp Có Tổng Trong Khoảng
Đề. Dãy số nguyên dương và hai số ; đếm cặp với . Tổng qua các test không quá .
Lời giải. Sắp dãy, đáp án là với tính bằng hai con trỏ như trên. Số cặp tới , dùng long long.
#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
6
6
15
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 , số phần tử của nhỏ hơn nó:
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 có là dãy con của chuỗi không: duyệt , mỗi khi ký tự của bằng ký tự đang cần của thì tiến con trỏ trên . 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 , 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 bộ nhớ. Đó là thuật toán thỏ và rùa của Floyd.
Ví dụGhép Kích Thước
Đề. yêu cầu kích thước và tài nguyên kích thước (). Ghép yêu cầu với tài nguyên được khi , 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ỏ trên yêu cầu, trên tài nguyên. Tài nguyên quá nhỏ () thì không ghép được với yêu cầu nào từ trở đi (các yêu cầu sau chỉ lớn hơn), bỏ nó. Tài nguyên quá lớn () thì yêu cầu không ghép được với tài nguyên nào từ 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 .
#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
4
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ỏ: , 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ùnglong longcho 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 bước và cả quá trình là , kể cả khi vòng
whilenằm trong vòngfor: đế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ỏ .
- 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 , lớn hơn thì loại ; đếm cặp có tổng thì mỗi lần cộng .
Bài tập
Đọc thêm
- Kĩ thuật hai con trỏVNOI Wiki
- Two PointersUSACO Guide
- Sliding WindowUSACO Guide
- 双指针OI Wiki
- 尺取り法drken (Qiita)