Cho thời gian làm của công việc xếp thành một hàng và quỹ thời gian . Tìm đoạn việc liên tiếp dài nhất có tổng thời gian không quá , trong .
Đây là bài toán quen thuộc nhất của kỹ thuật hai con trỏ (two pointers). Ta dùng hai chỉ số và để đánh dấu việc đầu và việc cuối của đoạn đang xét. Mỗi bước, tiến thêm một việc; khi tổng vượt quá , tiến lên để bỏ bớt các việc ở đầu đoạn. Cả hai chỉ số chỉ đi về phía trước, không bao giờ lùi lại, nên chỉ cần đi hết dãy một lượt là tìm được đáp án. Kỹ thuật này còn một dạng phổ biến nữa: trên một dãy đã sắp xếp, hai chỉ số bắt đầu từ hai đầu dãy rồi tiến dần vào giữa, dùng để tìm cặp số có tổng cho trước.
IĐoạn dài nhất: cửa sổ trượt
Trước hết, ta xét cách làm dễ nghĩ ra nhất. Đánh số các việc từ tới , việc thứ cần đơn vị thời gian. Đoạn gồm các việc từ tới được viết là ; khi , đoạn không có việc nào và có độ dài .
Với mỗi vị trí , ta tìm đoạn dài nhất kết thúc tại : bắt đầu từ cả đoạn , rồi lần lượt bỏ các việc ở đầu đoạn cho tới khi tổng không còn vượt quá . Vì ta chỉ bỏ đúng số việc cần thiết, đoạn còn lại là đoạn dài nhất kết thúc tại có tổng không quá . Đáp án là đoạn dài nhất trong tất cả các đoạn tìm được.
ll ans = 0;
for (ll r = 1; r <= n; r++) {
ll l = 1, sum = 0;
for (ll i = 1; i <= r; i++) sum += a[i];
while (sum > t) sum -= a[l++];
ans = max(ans, r - l + 1);
}
Độ phức tạp: , với cần khoảng phép tính.
Cách làm này cho kết quả đúng nhưng chạy chậm, vì với mỗi , chương trình lại tính tổng từ đầu và bỏ các việc từ đầu. Máy chấm thường chỉ làm được khoảng phép tính mỗi giây, nên phép tính vượt xa giới hạn thời gian.
Ta có thể thấy được điểm lãng phí của cách làm trên. Giả sử khi xét , ta đã phải bỏ các việc ở đầu đoạn, tức là mọi đoạn bắt đầu trước và kết thúc tại đều có tổng vượt . Khi chuyển sang , mỗi đoạn như vậy có thêm việc , tổng chỉ có thể lớn hơn, nên vẫn vượt . Vì vậy các việc vẫn sẽ phải bỏ, và cách làm trên đang bỏ lại chúng ở mỗi bước. Ta có thể giữ lại kết quả của bước trước: giữ nguyên l và sum (tổng của đoạn ), chỉ cộng thêm , rồi tiếp tục bỏ việc từ vị trí hiện tại nếu tổng vẫn vượt .
Trong code, ta chỉ cần sửa hai chỗ: đưa l và sum ra ngoài vòng for, và bỏ vòng tính lại tổng từ đầu.
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);
}
Độ phức tạp: , với cần khoảng phép tính.
Đoạn trong cách làm này giống như một khung nhìn lướt trên dãy: đưa thêm việc vào khung, đưa bớt việc ra khỏi khung, và cả khung chỉ di chuyển sang phải. Vì vậy, đoạn thường được gọi là cửa sổ (window), còn cách làm này được gọi là cửa sổ trượt (sliding window). Từ đây về sau, ta gọi là đầu trái và là đầu phải của cửa sổ.
Ta thử chạy tay với dãy và . Đầu phải lần lượt đưa vào cửa sổ: tổng là , chưa vượt , cửa sổ dài . Đưa thêm vào thì tổng thành , vượt . Đầu trái tiến lên, bỏ số ở đầu cửa sổ ra, tổng còn và không còn vượt . Tiếp tục như vậy tới hết dãy, độ dài lớn nhất gặp được là , cũng là đáp án của bài. Bạn có thể bấm chạy để xem từng bước, hoặc đổi dãy số và để thử:
Nếu xếp các cửa sổ sau mỗi bước chồng lên nhau, ta sẽ thấy cả hai đầu của cửa sổ đều chỉ đi sang phải.
Chứng minh
Gọi là đầu trái nhỏ nhất sao cho đoạn có tổng không quá ; nếu chính đã lớn hơn thì , đoạn rỗng.
Đầu trái không lùi, tức là . Với mọi , đoạn có tổng lớn hơn ; thêm vào thì đoạn càng có tổng lớn hơn . Vậy không thể nhỏ hơn .
Vòng while dừng đúng tại . Trước vòng while của bước , ta có . Khi thì tổng còn vượt nên tiếp tục tiến; khi thì tổng không vượt nên vòng dừng.
Không bỏ sót đáp án. Đoạn dài nhất cần tìm kết thúc tại một vị trí nào đó, và trong các đoạn có tổng không quá kết thúc tại , đoạn bắt đầu tại là dài nhất. Chương trình xét mọi , nên tìm được đáp án.
Nhìn vòng while nằm trong vòng for, ta dễ nghĩ chương trình vẫn chạy . Tuy nhiên, nếu đếm theo từng phần tử thay vì theo từng vòng lặp, ta sẽ thấy mỗi phần tử được đưa vào cửa sổ đúng một lần, khi đầu phải đi qua, và bị bỏ ra nhiều nhất một lần, khi đầu trái đi qua. Mỗi đầu đi tối đa bước, nên cả chương trình chỉ có khoảng bước. Cách đếm tổng số bước di chuyển của các con trỏ trên toàn chương trình như thế này còn được dùng ở nhiều bài sau.
Hỏi nhanh
Xét chương trình cửa sổ trượt cho bài đoạn dài nhất ở trên, với số dương. Vòng while nằm bên trong vòng for, nên dễ tưởng nó chạy rất nhiều lần.
Cộng dồn trên toàn bộ chương trình, dòng sum -= a[l++] trong vòng while chạy nhiều nhất bao nhiêu lần?
Mỗi lần dòng sum -= a[l++] chạy thì l tăng đúng 1. Biến l bắt đầu từ 1, không bao giờ giảm và không vượt , nên dòng đó chạy không quá lần trên cả chương trình. Ở một bước, vòng while có thể chạy nhiều lần, nhưng khi đó các bước khác phải chạy ít đi.
Lựa chọn quên rằng l không bao giờ quay về đầu dãy. Lựa chọn “đúng lần” nhầm số lần chạy của while với số bước của for. Còn l không hề tiến xa dần: mỗi lần chỉ tiến đúng 1.
Ví dụĐoạn Việc Trong Ngân Sách
Đề. Có công việc, việc cần đơn vị thời gian (), và ngân sách . Tìm độ dài lớn nhất của một đoạn việc liên tiếp có tổng thời gian không vượt ; được chọn đoạn rỗng.
Lời giải. Đây là bài toán vừa giải ở trên. Có một trường hợp cần chú ý: một việc có thời gian lớn hơn cả . Khi đó vòng while bỏ hết các việc trong cửa sổ, kể cả chính việc đó, cho tới khi và sum bằng . Cửa sổ lúc này rỗng, có độ dài , đúng với đoạn rỗng mà đề cho phép. Trước khi bỏ bớt việc, sum có thể lên tới , vẫn vừa kiểu int; dùng long long thì không phải lo chuyện tràn số.
#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
Độ phức tạp: , với cần khoảng phép tính.
IIKhi nào dùng được cửa sổ trượt
Trước khi đi tiếp, ta thống nhất một cách gọi. Một đoạn được gọi là đoạn tốt nếu nó thỏa điều kiện mà đề bài yêu cầu. Ở bài toán mở đầu, đoạn tốt là đoạn có tổng thời gian không quá ; ở một bài khác, đoạn tốt có thể là đoạn có các phần tử đôi một khác nhau, hay đoạn có tổng ít nhất .
Thuật toán cửa sổ trượt ở mục trước chạy đúng nhờ một tính chất của đoạn tốt: nếu bỏ bớt một số phần tử ở hai đầu của một đoạn tốt, đoạn còn lại vẫn là đoạn tốt. Ở bài mở đầu, tính chất này đúng vì mọi thời gian đều dương, bỏ bớt việc thì tổng chỉ giảm. Nhờ tính chất này, khi đầu phải tiến lên, đầu trái không bao giờ cần lùi lại (ô Chứng minh ở mục trước), và cả thuật toán chỉ tốn .
Hỏi nhanh
Với , đoạn trong dãy có tổng , nên là đoạn tốt. Không cần cộng lại, đoạn nào dưới đây chắc chắn cũng là đoạn tốt?
Đoạn nằm bên trong đoạn tốt , nên nó cũng là đoạn tốt: bỏ bớt số thì tổng chỉ giảm. Ba đoạn còn lại đều có thêm số nằm ngoài đoạn , nên tính chất trên không nói được gì về chúng; cộng thử thì cả ba đều vượt .
Vì vậy, trước khi dùng cửa sổ trượt cho một bài mới, ta cần kiểm tra xem đoạn tốt của bài đó có một trong hai tính chất sau hay không:
- Bớt đi vẫn tốt: mọi đoạn liên tiếp nằm bên trong một đoạn tốt cũng là đoạn tốt. Với tính chất này, khi cửa sổ không còn tốt, ta bỏ bớt phần tử ở đầu trái cho tới khi nó tốt trở lại; vì vậy tính chất này phù hợp với bài tìm đoạn dài nhất và bài đếm đoạn. Ví dụ: đoạn có tổng không quá (khi mọi số đều dương), đoạn có các phần tử đôi một khác nhau, đoạn có không quá giá trị khác nhau, đoạn có hiệu giữa số lớn nhất và số nhỏ nhất không quá .
- Thêm vào vẫn tốt: mọi đoạn chứa một đoạn tốt cũng là đoạn tốt. Với tính chất này, khi cửa sổ đã tốt, ta thử bỏ bớt phần tử ở đầu trái để tìm đoạn ngắn hơn mà vẫn tốt; vì vậy tính chất này phù hợp với bài tìm đoạn ngắn nhất. Ví dụ: đoạn có tổng ít nhất (khi mọi số đều dương), đoạn chứa đủ mọi loại ký tự cho trước.
Sử dụng kiến thức này cũng có thể giải bài: chọn nhiều số nhất trong dãy, không cần đứng liền nhau, sao cho số lớn nhất trừ số nhỏ nhất không quá . Chỉ cần sắp xếp dãy tăng dần rồi dùng cửa sổ trượt.
Cần cẩn thận khi dãy có số âm. Khi đó bỏ bớt phần tử có thể làm tổng tăng lên, nên tính chất “bớt đi vẫn tốt” thường không còn đúng, và cửa sổ trượt có thể cho kết quả sai. Chẳng hạn với dãy và : cả dãy có tổng , nên đáp án là . Nhưng ở bước đầu tiên, riêng số đã vượt , nên thuật toán bỏ số ra khỏi cửa sổ. Đến khi gặp thì số đã bị bỏ, không thể đưa trở lại, và thuật toán in ra .
Tương tự, các điều kiện như “tổng đúng bằng ” hay “tổng chia hết cho ” thường không có hai tính chất trên. Những bài như vậy thường được giải bằng tổng tiền tố kết hợp mảng đếm (bài 0.3).
Hỏi nhanh
Dãy gồm các số nguyên, có thể âm. Ta muốn tìm đoạn dài nhất thỏa một điều kiện nào đó bằng cửa sổ trượt, đúng như cách làm ở mục đầu.
Với điều kiện nào dưới đây, cửa sổ trượt vẫn cho kết quả đúng?
Điều kiện “không quá số âm” có tính chất bớt đi vẫn tốt, bất kể các số mang dấu gì: bỏ bớt phần tử thì số số âm chỉ giữ nguyên hoặc giảm.
Điều kiện tổng không quá hỏng khi có số âm, như ví dụ ở trên. Hai điều kiện “chia hết” và “đúng bằng” không có tính chất bớt đi vẫn tốt, kể cả khi mọi số đều dương; những bài đó dùng tổng tiền tố và mảng đếm (bài 0.3).
IIIBa dạng bài: dài nhất, ngắn nhất, đếm đoạn
Cửa sổ trượt thường được dùng cho ba dạng câu hỏi. Ta xét cả ba trên cùng một dãy số dương :
- Đoạn dài nhất: tìm đoạn liên tiếp dài nhất có tổng không quá . Đây là bài toán ở đầu bài học.
- Đoạn ngắn nhất: tìm đoạn liên tiếp ngắn nhất có tổng ít nhất .
- Đếm đoạn: đếm số đoạn liên tiếp có tổng không quá .
Ví dụ với dãy và : đoạn dài nhất có tổng không quá dài , chẳng hạn ; đoạn ngắn nhất có tổng ít nhất dài , đó là ; và có đoạn có tổng không quá .
Cả ba bài đều dùng chung một vòng lặp: đầu phải đi từ trái sang phải, mỗi bước đưa vào cửa sổ, rồi vòng while bỏ bớt phần tử ở đầu trái khi cần. Điểm khác nhau nằm ở chỗ cập nhật đáp án: cập nhật ở đâu và cập nhật bằng giá trị nào. Đây cũng là chỗ dễ viết sai, nên ta xem lần lượt từng bài.
Đoạn dài nhất. Đây là bài đã giải ở mục đầu. Sau khi vòng while dừng, cửa sổ có tổng không quá , và đó là đoạn tốt dài nhất kết thúc tại . Vì vậy ta cập nhật đáp án ngay sau vòng while.
Đoạn ngắn nhất. Lần này đoạn tốt là đoạn có tổng ít nhất , và ta muốn đoạn đó càng ngắn càng tốt. Khi cửa sổ đã có tổng ít nhất , ta ghi nhận độ dài của nó, rồi thử bỏ để xem đoạn ngắn hơn có còn đủ tổng hay không. Cần chú ý thứ tự: ghi nhận độ dài trước, rồi mới bỏ . Khi vòng while dừng, tổng của cửa sổ đã nhỏ hơn , nên độ dài lúc này không còn là một đáp án hợp lệ.
Biến ans bắt đầu từ , một số rất lớn, để lần ghi nhận đầu tiên luôn nhỏ hơn nó. Nếu hết vòng lặp mà ans vẫn bằng thì dãy không có đoạn nào có tổng ít nhất .
ll l = 1, sum = 0, ans = 1e18;
for (ll r = 1; r <= n; r++) {
sum += a[r];
while (sum >= s) {
ans = min(ans, r - l + 1);
sum -= a[l++];
}
}
Độ phức tạp: , với cần khoảng phép tính.
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProĐếm đoạn. Xét một vị trí . Sau khi vòng while dừng, đoạn có tổng không quá . Các đoạn kết thúc tại và bắt đầu từ trở về sau, tức là , đều nằm trong đoạn ; các số đều dương nên tổng của chúng cũng không quá . Còn các đoạn kết thúc tại mà bắt đầu trước thì có tổng vượt , vì là đầu trái nhỏ nhất thỏa điều kiện (ô Chứng minh ở mục đầu). Như vậy, có đúng đoạn thỏa điều kiện kết thúc tại . Cộng con số này với mọi , ta được số đoạn cần đếm; mỗi đoạn được đếm đúng một lần, tại vị trí kết thúc của nó.
ll l = 1, sum = 0, cnt = 0;
for (ll r = 1; r <= n; r++) {
sum += a[r];
while (sum > t) sum -= a[l++];
cnt += r - l + 1;
}
Độ phức tạp: , với cần khoảng phép tính.
Với dãy và , giá trị ở các bước lần lượt là , cộng lại được đoạn.
Số đoạn có thể lên tới , khoảng khi , vượt giới hạn của kiểu int. Vì vậy cần dùng kiểu long long cho kết quả.
Bài đếm còn hai biến thể hay gặp, chỉ cần sửa một chút:
- Đếm đoạn có tổng ít nhất : dùng vòng lặp của bài đoạn ngắn nhất. Sau vòng
while, mọi đoạn kết thúc tại và bắt đầu trước đều đủ tổng, còn các đoạn bắt đầu từ trở đi thì không, nên ta cộng vào kết quả. - Đếm đoạn có tổng đúng bằng : lấy số đoạn có tổng không quá trừ đi số đoạn có tổng không quá .
IVCửa sổ nhớ thêm thông tin
Ở các bài trên, cửa sổ chỉ cần nhớ tổng các số bên trong. Bài khác có thể cần nhớ nhiều hơn, nhưng vòng lặp vẫn giữ nguyên. Ta chỉ cần cập nhật nhanh được khi thêm một số ở bên phải, khi bỏ một số ở bên trái, và biết được cửa sổ hiện tại có còn tốt hay không. Hai trường hợp hay gặp:
- Số giá trị khác nhau: đếm xem mỗi giá trị xuất hiện bao nhiêu lần trong cửa sổ. Thêm một số mà trước đó chưa có trong cửa sổ thì số giá trị khác nhau tăng 1; bỏ một số mà sau đó không còn trong cửa sổ thì giảm 1. Ví dụ ngay dưới đây làm đúng như vậy.
- Tích các số nguyên dương: thêm một số thì nhân tích với số đó, bỏ một số thì chia tích cho số đó.
Ví dụĐoạn Dài Nhất Có Không Quá K Giá Trị
Đề. Cho 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; nếu có nhiều đoạn dài nhất, in đoạn nào cũng được.
Lời giải. Bỏ bớt phần tử thì số giá trị khác nhau chỉ có thể giảm, nên đoạn tốt ở bài này có tính chất “bớt đi vẫn tốt”. Cửa sổ cần nhớ số lần xuất hiện của từng giá trị và số giá trị khác nhau. Với mỗi , ta thêm vào cửa sổ; khi số giá trị khác nhau vượt , ta bỏ ra và tăng . Sau vòng while, là đoạn tốt dài nhất kết thúc tại . Ở đây ta chỉ cập nhật đáp án khi gặp đoạn dài hơn hẳn, nên chương trình in đoạn dài nhất xuất hiện đầu tiên. Vì , mảng đếm cần ô, chỉ số từ tới .
#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
Độ phức tạp: , với cần khoảng phép tính.
VTìm cặp có tổng bằng x
Phần này trình bày dạng thứ hai của hai con trỏ. Bài toán: cho một dãy đã sắp xếp tăng dần và một số ; tìm hai phần tử ở hai vị trí khác nhau có tổng bằng .
Cách ngây thơ là thử mọi cặp phần tử. Có khoảng cặp, nên với ta phải thử khoảng cặp, quá chậm. Hai con trỏ tận dụng việc dãy đã được sắp xếp để mỗi bước loại được một phần tử không thể thuộc cặp nào.
Ta chạy tay với dãy và . Đặt con trỏ ở số nhỏ nhất và con trỏ ở số lớn nhất:
- , nhỏ hơn . Số ghép với số lớn nhất còn lại mà vẫn chưa đủ, nên ghép với số nào khác cũng không đủ. Ta bỏ số .
- , rồi , vẫn nhỏ hơn . Ta bỏ tiếp và .
- , lớn hơn . Lần này số ghép với số nhỏ nhất còn lại mà đã vượt, nên ghép với số nào khác cũng vượt. Ta bỏ số .
- , nhỏ hơn , bỏ số . Còn lại : đã tìm được cặp.
Có thể tóm tắt cách làm thành ba quy tắc: nếu tổng nhỏ hơn thì bỏ số bên trái (tăng ); nếu tổng lớn hơn thì bỏ số bên phải (giảm ); nếu tổng bằng thì đã tìm được cặp. Mỗi bước loại được một số không thể thuộc cặp nào, nên với phần tử, ta cần không quá bước.
Bảng bên phải trong hình ghi tổng của mọi cặp. Mỗi bước loại đi cả một hàng hoặc một cột của bảng, và đường đi của hai con trỏ bám theo ranh giới giữa vùng có tổng nhỏ hơn và vùng có tổng lớn hơn :
Chứng minh
Ta giữ một điều luôn đúng trong suốt quá trình: nếu dãy có cặp tổng bằng thì có một cặp như vậy nằm trọn trong đoạn . Lúc đầu là cả dãy nên điều này đúng.
Khi tổng nhỏ hơn , với mọi trong ta có , nên không ghép được với số nào trong đoạn; bỏ đi thì điều trên vẫn đúng. Khi tổng lớn hơn , lập luận tương tự cho .
Vòng lặp dừng khi tìm được cặp, hoặc khi . Lúc đó đoạn chỉ còn một số, không chứa cặp nào, nên dãy không có cặp nào có tổng bằng .
Điều kiện của vòng lặp phải là l < r, không phải l <= r. Nếu viết <=, khi ta sẽ ghép một phần tử với chính nó: dãy với sẽ báo có cặp .
Ví dụHai Vị Trí Có Tổng
Đề. Cho mảng số và số (, ). In hai chỉ số khác nhau với (nhiều cặp thì in cặp nào cũng được), hoặc in IMPOSSIBLE nếu không có.
Lời giải. Mảng chưa được sắp xếp, nhưng đề lại yêu cầu in vị trí ban đầu. Vì vậy ta lưu mỗi số cùng vị trí của nó thành một cặp (giá trị, vị trí), sắp xếp các cặp theo giá trị, chạy hai con trỏ trên giá trị rồi in ra vị trí ban đầu. Vì luôn có , hai vị trí in ra luôn khác nhau, kể cả khi hai giá trị bằng nhau. Tổng hai số có thể lên tới , vượt giới hạn của int, nên ta dùng long long.
#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
Độ phức tạp: cho bước sắp xếp, phần hai con trỏ chỉ ; với cần khoảng phép tính.
Sử dụng kiến thức này cũng có thể giải bài: tìm ba số có tổng bằng . Sau khi sắp xếp, cố định số nhỏ nhất của bộ ba rồi tìm hai số còn lại bằng hai con trỏ như trên; cả bài tốn .
VIĐếm cặp có tổng không quá X
Bài toán: cho dãy số, đếm số cặp vị trí có . Thứ tự ban đầu của dãy không ảnh hưởng tới số cặp, nên ta có thể sắp xếp dãy tăng dần, rồi đặt , :
- Nếu : số ghép với số lớn nhất còn lại là mà tổng vẫn không vượt , nên ghép với mọi số đứng giữa (đều nhỏ hơn hoặc bằng ) cũng không vượt. Có tất cả cặp như vậy, đều chứa . Ta cộng vào kết quả rồi bỏ (tăng ), vì mọi cặp chứa đã được đếm.
- Nếu : số ghép với số nhỏ nhất còn lại là mà tổng đã vượt , nên ghép với số nào khác cũng vượt; không thuộc cặp nào thỏa điều kiện. Ta bỏ (giảm ).
Muốn đếm số cặp có tổng nằm trong khoảng , ta lấy số cặp có tổng không quá trừ đi số cặp có tổng không quá , giống cách đếm đoạn có tổng đúng bằng ở mục trước.
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProVí dụĐếm Cặp Có Tổng Trong Khoảng
Đề. Có nhiều bộ dữ liệu. Mỗi bộ gồm dãy số nguyên dương và hai số ; đếm số cặp chỉ số với . Tổng qua các bộ dữ liệu không quá .
Lời giải. Gọi là số cặp có tổng không quá , tính bằng hai con trỏ như trên. Sắp xếp dãy, đáp án là . Số cặp có thể tới , nên cần dùng kiểu long long cho kết quả.
#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
Độ phức tạp: cho mỗi test vì phải sắp xếp, hai lần đếm chỉ thêm ; tổng không quá nên cần khoảng phép tính.
VIIHai con trỏ trên hai dãy
Hai con trỏ cũng dùng được khi có hai dãy cùng được sắp xếp tăng dần: mỗi dãy có một con trỏ riêng, và ở mỗi bước ta chỉ tiến con trỏ của dãy cần tiến. Ví dụ quen thuộc nhất là bước trộn của sắp xếp trộn (bài 0.4): so sánh hai số đang đứng đầu ở hai dãy, lấy số nhỏ hơn đưa vào kết quả, rồi tiến con trỏ của dãy chứa số đó. Các bài tìm phần tử chung, lấy hợp hay hiệu của hai dãy cũng làm theo cách tương tự.
Một cách dùng khác: ta cần trả lời nhiều câu hỏi trên một dãy, và các câu hỏi được xét theo thứ tự tăng dần. Khi đó, thay vì tìm kiếm nhị phân (bài 0.6) riêng cho từng câu hỏi, con trỏ chỉ cần tiến tiếp từ vị trí của câu hỏi trước. Ví dụ: cho hai dãy đã sắp xếp tăng dần, dãy có số và dãy có số; với mỗi , đếm số phần tử của nhỏ hơn . Con trỏ dừng tại phần tử đầu tiên của không nhỏ hơn , nên có phần tử nhỏ hơn. Vì tăng dần, vị trí dừng cho không thể đứng trước vị trí dừng cho , nên chỉ cần tiến tiếp:
ll j = 1;
for (ll i = 1; i <= n; i++) {
while (j <= m && b[j] < a[i]) j++;
cnt[i] = j - 1;
}
Độ phức tạp: , với cần khoảng phép tính.
Kiểm tra dãy con. Cho hai chuỗi và . Hỏi có phải dãy con của không, tức là có thể xóa bớt một số ký tự của (giữ nguyên thứ tự những ký tự còn lại) để được hay không. Ta duyệt từ trái sang phải, đồng thời giữ con trỏ trên (chỉ số ký tự của đang cần tìm); mỗi khi gặp ký tự bằng thì tiến . Nếu duyệt hết mà đã đi hết thì là dãy con của .
ll n = s.size(), m = t.size(), j = 0;
for (ll i = 0; i < n && j < m; i++)
if (s[i] == t[j]) j++;
cout << (j == m ? "YES" : "NO") << '\n';
Độ phức tạp: , với chuỗi dài cần khoảng phép tính.
Chứng minh
Cách làm trên ghép mỗi ký tự của với lần xuất hiện sớm nhất còn dùng được trong , và cách ghép này không bao giờ thiệt. Giả sử có một cách ghép đúng mà được ghép với vị trí , muộn hơn vị trí sớm nhất còn dùng được. Đổi sang vị trí : vì , các ký tự sau của vẫn được ghép ở các vị trí sau , tức là cũng sau , nên cách ghép mới vẫn đúng. Đổi lần lượt như vậy từ ký tự đầu tiên, ta được đúng cách ghép của hai con trỏ.
Ví dụGhép Kích Thước
Đề. Có yêu cầu với kích thước và tài nguyên với kích thước (, các kích thước và không quá ). Yêu cầu kích thước ghép được với tài nguyên kích thước khi ; mỗi yêu cầu và mỗi tài nguyên dùng trong nhiều nhất một cặp. Tìm số cặp lớn nhất có thể tạo.
Lời giải. Sắp xếp cả hai dãy, con trỏ chạy trên dãy yêu cầu, con trỏ chạy trên dãy tài nguyên. Ở mỗi bước có ba khả năng:
- Tài nguyên quá nhỏ (): các yêu cầu sau chỉ lớn hơn, nên tài nguyên này không ghép được với yêu cầu nào nữa. Ta bỏ tài nguyên này.
- Tài nguyên quá lớn (): các tài nguyên sau chỉ lớn hơn, nên yêu cầu này không ghép được với tài nguyên nào nữa. Ta bỏ yêu cầu này.
- Ghép được: ta ghép yêu cầu với tài nguyên ngay, rồi tiến cả hai con trỏ.
Chứng minh
Hai trường hợp đầu chỉ bỏ những phần tử không thể ghép được với ai. Với trường hợp ghép được, xét một cách ghép nhiều cặp nhất cho phần còn lại. Nếu cách ghép đó đã ghép với thì không cần làm gì. Nếu không:
- được ghép với , còn được ghép với : đổi thành hai cặp và . Cặp đầu hợp lệ vì ta đang ở trường hợp ghép được. Cặp sau cũng hợp lệ: , và .
- Chỉ một trong hai đang được ghép: bỏ cặp đó, thay bằng cặp , số cặp không đổi.
- Cả hai đều chưa được ghép: thêm cặp thì được thêm một cặp, trái với giả thiết là cách ghép nhiều cặp nhất.
Vậy luôn có một cách ghép tốt nhất chứa cặp , nên ghép ngay là an toàn.
#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
Độ phức tạp: cho bước sắp xếp, phần ghép chỉ vì mỗi bước tiến ít nhất một con trỏ; với cần khoảng phép tính.
Bài nâng cao
Xem 2 bài nâng cao
Ví dụKỳ Nghỉ Trọn X Ngày
Đề. Một năm có tháng (), tháng thứ có ngày (), và các năm nối tiếp nhau liên tục. Nghỉ vào ngày thứ của một tháng thì được điểm vui. Chọn đúng ngày liên tiếp để tổng điểm vui lớn nhất; đoạn ngày được phép đi qua chỗ nối từ tháng sang tháng của năm sau, và không vượt quá số ngày của một năm.
Lời giải. Bài này cần ghép ba ý.
Ý 1: có một đoạn tốt nhất kết thúc đúng vào ngày cuối của một tháng. Nếu đoạn kết thúc ở giữa tháng, ta luôn có thể dời cả đoạn sang phải hoặc sang trái từng ngày mà tổng điểm không giảm, cho tới khi nó kết thúc ở ngày cuối một tháng (xem ô Chứng minh). Như vậy ta chỉ cần xét các đoạn kết thúc ở cuối một tháng.
Chứng minh
Xét một đoạn ngày kết thúc ở ngày thứ của một tháng có ngày, với . Gọi là điểm của ngày đầu tiên trong đoạn.
Trường hợp . Dời đoạn sang phải một ngày: thêm ngày có điểm , bỏ ngày có điểm , tổng tăng . Dời tiếp lần thứ , ngày được thêm có điểm ; ngày bị bỏ có điểm nếu còn trong tháng cũ, hoặc không quá nếu đã sang tháng mới. Cả hai đều nhỏ hơn , nên tổng tiếp tục tăng cho tới khi đoạn kết thúc ở ngày .
Trường hợp . Ngày đầu và ngày cuối nằm ở hai tháng khác nhau, vì nếu cùng tháng thì . Dời đoạn sang trái lần thứ (với ): thêm ngày có điểm , bỏ ngày có điểm , tổng thay đổi đúng . Sau lần dời, đoạn kết thúc ở ngày cuối của tháng trước.
Trong cả hai trường hợp, ta được một đoạn kết thúc ở ngày cuối một tháng mà tổng điểm không nhỏ hơn.
Ý 2: lịch lặp lại thành một vòng tròn. Để xử lý đoạn đi qua chỗ nối giữa hai năm, ta viết danh sách tháng hai lần liên tiếp: , tức là tháng giống tháng . Mọi đoạn ngày trên lịch vòng kết thúc ở cuối một tháng đều trở thành một đoạn liên tiếp trên danh sách dài này, kết thúc ở cuối một tháng với . Viết mảng hai lần như vậy là cách quen thuộc để xử lý mảng vòng.
Ý 3: hai con trỏ trên các tháng. Với mỗi tháng cuối , ta giữ cửa sổ gồm các tháng trọn vẹn có tổng số ngày không quá , càng nhiều tháng càng tốt. Như bài toán mở đầu, khi tăng thì chỉ tăng. Số ngày còn thiếu là , lấy ở cuối tháng : các ngày . Vì tháng đã bị bỏ ra khỏi cửa sổ, tức là thêm cả tháng đó thì vượt ngày, nên .
Tổng điểm của một tháng trọn ngày là , tổng của một cấp số cộng. Phần lấy ở cuối tháng có tổng điểm với . Khi , cửa sổ đã gồm ít nhất cả một năm nên đủ ngày, không cần lấy thêm ngày nào.
Tổng điểm có thể tới khoảng , nên mọi biến dùng long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, x, d[2 * N];
ll tri(ll k) {
return k * (k + 1) / 2;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> x;
for (ll i = 1; i <= n; i++) {
cin >> d[i];
d[i + n] = d[i];
}
ll l = 1, days = 0, val = 0, ans = 0;
for (ll r = 1; r <= 2 * n; r++) {
days += d[r];
val += tri(d[r]);
while (days > x) {
days -= d[l];
val -= tri(d[l]);
l++;
}
if (r >= n) {
ll miss = x - days, p = d[l - 1];
ans = max(ans, val + tri(p) - tri(p - miss));
}
}
cout << ans << '\n';
}
3 5
3 1 4
13
Độ phức tạp: , với cần khoảng phép tính.
Ví dụĐẩy Hộp Trên Trục Số
Đề. Trên trục số có hộp và ô đích, đều ở các tọa độ nguyên khác (, tọa độ không quá về trị tuyệt đối, mỗi dãy tọa độ tăng nghiêm ngặt). Hộp ở nửa trục dương chỉ có thể bị đẩy sang phải, hộp ở nửa trục âm chỉ có thể bị đẩy sang trái; đẩy một hộp thì nó đẩy luôn các hộp đứng sát phía trước, và các hộp luôn ở những tọa độ khác nhau. Tìm số ô đích nhiều nhất có hộp nằm đúng trên đó. Có nhiều bộ dữ liệu, tổng và tổng đều không quá .
Lời giải. Hai nửa trục không ảnh hưởng nhau. Nửa âm lấy trị tuyệt đối của tọa độ rồi làm y như nửa dương, đáp án là tổng của hai nửa. Xét nửa dương với các hộp và các ô đích .
Khi đẩy, các hộp bị đẩy luôn dồn thành một khối liền nhau. Gọi là tọa độ cuối của khối và là số hộp có tọa độ ban đầu không vượt : cả hộp này nằm trong khối, chiếm đúng các tọa độ . Các hộp ở sau vẫn nằm nguyên chỗ cũ, nên chúng chỉ phủ những ô đích vốn đã có hộp. Vậy số ô đích được phủ là: số ô đích trong đoạn , cộng số ô đích sau vốn đã có hộp.
Nhận xét 1: chỉ cần xét là một ô đích. Nếu ô không phải ô đích, đẩy ít đi một bước (giảm đi ) không làm mất ô đích nào đang được phủ (xem ô Chứng minh). Giảm dần như vậy, ta tới một khối kết thúc tại một ô đích , hoặc tới lúc không đẩy hộp nào. Như vậy đáp án là giá trị lớn nhất giữa “không đẩy gì” và các trường hợp với .
Chứng minh
Giả sử ô không phải ô đích, và khối gồm hộp. Nếu không có hộp nào nằm ở từ đầu, giảm đi thì khối trượt sang trái một ô: mất tọa độ (không phải ô đích), thêm tọa độ , nên số ô đích được phủ không giảm. Nếu có một hộp nằm ở từ đầu, giảm đi thì hộp đó đứng yên ở , khối còn hộp ở ; các tọa độ có hộp không đổi, nên số ô đích được phủ cũng không đổi.
Nhận xét 2: biên trái của khối không giảm. Với , gọi là số hộp có tọa độ không vượt ; khối chiếm . Khi tăng, chỉ tăng, nên tính được bằng một con trỏ trên dãy hộp, như ví dụ đếm ở mục Hai con trỏ trên hai dãy. Điều bất ngờ là biên trái cũng không bao giờ giảm. Giữa và có tọa độ nguyên, mà các hộp nằm ở các tọa độ khác nhau, nên số hộp mới thêm không vượt quá . Chuyển vế ta được . Vì vậy vị trí ô đích đầu tiên nằm trong khối, gọi là , cũng tính được bằng một con trỏ chỉ tiến.
Phần sau khối. Gọi là số ô đích từ trở đi vốn đã có hộp. Ta tính từ cuối về đầu, so dãy ô đích với dãy hộp bằng thêm một con trỏ nữa, giống tổng tiền tố nhưng cộng từ phải sang (bài 0.3).
Tóm lại, với mỗi , số ô đích được phủ là , và cả ba con trỏ đều chỉ đi một chiều. Trong code, là biến lo và là mảng suf.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll t, n, m, a[N], b[N], pa[N], pb[N], suf[N];
ll solve(ll p, ll q) {
suf[q + 1] = 0;
for (ll i = q, j = p; i >= 1; i--) {
while (j >= 1 && pa[j] > pb[i]) j--;
suf[i] = suf[i + 1] + (j >= 1 && pa[j] == pb[i]);
}
ll ans = suf[1], j = 0, lo = 1;
for (ll i = 1; i <= q; i++) {
while (j < p && pa[j + 1] <= pb[i]) j++;
while (lo <= i && pb[lo] < pb[i] - j + 1) lo++;
ans = max(ans, i - lo + 1 + suf[i + 1]);
}
return ans;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n >> m;
for (ll i = 1; i <= n; i++) cin >> a[i];
for (ll i = 1; i <= m; i++) cin >> b[i];
ll p = 0, q = 0, ans = 0;
for (ll i = 1; i <= n; i++) if (a[i] > 0) pa[++p] = a[i];
for (ll i = 1; i <= m; i++) if (b[i] > 0) pb[++q] = b[i];
ans += solve(p, q);
p = q = 0;
for (ll i = n; i >= 1; i--) if (a[i] < 0) pa[++p] = -a[i];
for (ll i = m; i >= 1; i--) if (b[i] < 0) pb[++q] = -b[i];
ans += solve(p, q);
cout << ans << '\n';
}
}
1
4 5
-4 -2 1 3
-5 -2 2 3 4
3
Độ phức tạp: cho mỗi bộ dữ liệu vì ba con trỏ chỉ tiến; tổng và tổng không quá nên cần khoảng phép tính.
Khi đi thi
- Gặp bài hỏi về “đoạn liên tiếp” mà đoạn tốt có tính chất “bớt đi vẫn tốt” hoặc “thêm vào vẫn tốt” thì nghĩ tới cửa sổ trượt. Nếu dãy có số âm, hay điều kiện là “đúng bằng”, “chia hết”, hãy cân nhắc tổng tiền tố và mảng đếm (bài 0.3).
- Khuôn chung của cửa sổ trượt là
for r { thêm a[r]; while (cửa sổ không tốt) bỏ a[l++]; cập nhật đáp án }; riêng bài đoạn ngắn nhất thì cập nhật đáp án bên trong vòngwhile. Nên thử ba test nhỏ: , mọi phần tử đều lớn hơn , mọi phần tử bằng nhau. - Gặp bài hỏi về “cặp phần tử” mà thứ tự ban đầu không quan trọng thì sắp xếp dãy rồi cho hai con trỏ đi từ hai đầu vào giữa. Nhớ dùng điều kiện
l < r, dùnglong longcho tổng và số cặp, và lưu cặp (giá trị, vị trí ban đầu) khi phải in vị trí.
Tóm tắt
- Hai con trỏ chỉ đi một chiều, mỗi con trỏ đi tối đa bước, nên cả quá trình là dù vòng
whilenằm trong vòngfor. Khi tính độ phức tạp, hãy đếm tổng số bước các con trỏ đi trên cả chương trình, thay vì đếm theo từng vòng lặp. - Cửa sổ trượt dùng được khi đoạn tốt có tính chất “bớt đi vẫn tốt” (đoạn dài nhất, đếm đoạn) hoặc “thêm vào vẫn tốt” (đoạn ngắn nhất). Cần cẩn thận với dãy có số âm.
- Bài đoạn dài nhất và bài đếm đoạn cập nhật đáp án sau vòng
while; bài đoạn ngắn nhất cập nhật đáp án bên trong vòngwhile, trước khi bỏ . - Ngoài tổng, cửa sổ có thể nhớ những thông tin khác, như số lần xuất hiện của từng giá trị để biết số giá trị khác nhau.
- Trên dãy đã sắp xếp, hai con trỏ đi từ hai đầu vào giữa: tổng nhỏ hơn thì tăng , lớn hơn thì giảm ; khi đếm cặp có tổng không quá , mỗi lần thì cộng .
Bài tập
Bài tập ngoài
Nhớ thêm thông tin
IVVasya and StringCodeforcesĐọc thêm
- Kĩ thuật hai con trỏVNOI Wiki
- Two PointersUSACO Guide
- Sliding WindowUSACO Guide
- 双指针OI Wiki
- 尺取り法drken (Qiita)