Chương 0·Bài 0.7

Two Pointers

Cho thời gian làm của nn công việc xếp thành một hàng và quỹ thời gian TT. Tìm đoạn việc liên tiếp dài nhất có tổng thời gian không quá TT, trong O(n)O(n).

Đâ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ố ll và rr để đánh dấu việc đầu và việc cuối của đoạn đang xét. Mỗi bước, rr tiến thêm một việc; khi tổng vượt quá TT, ll 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ừ 11 tới nn, việc thứ ii cần aia_i đơn vị thời gian. Đoạn gồm các việc từ ll tới rr được viết là [l,r][l, r]; khi l=r+1l = r + 1, đoạn không có việc nào và có độ dài 00.

Với mỗi vị trí rr, ta tìm đoạn dài nhất kết thúc tại rr: bắt đầu từ cả đoạn [1,r][1, r], 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á TT. 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 rr có tổng không quá TT. Đáp án là đoạn dài nhất trong tất cả các đoạn tìm được.

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: O(n2)O(n^2), với n=105n = 10^5 cần khoảng 101010^{10} 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 rr, 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 10810^8 phép tính mỗi giây, nên 101010^{10} 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 rr, ta đã phải bỏ các việc 1,2,…,l−11, 2, \dots, l - 1 ở đầu đoạn, tức là mọi đoạn bắt đầu trước ll và kết thúc tại rr đều có tổng vượt TT. Khi chuyển sang r+1r + 1, mỗi đoạn như vậy có thêm việc r+1r + 1, tổng chỉ có thể lớn hơn, nên vẫn vượt TT. Vì vậy các việc 1,2,…,l−11, 2, \dots, l - 1 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 [l,r][l, r]), chỉ cộng thêm ar+1a_{r+1}, rồi tiếp tục bỏ việc từ vị trí ll hiện tại nếu tổng vẫn vượt TT.

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.

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);
}

Độ phức tạp: O(n)O(n), với n=105n = 10^5 cần khoảng 2⋅1052 \cdot 10^5 phép tính.

Đoạn [l,r][l, r] trong cách làm này giống như một khung nhìn lướt trên dãy: rr đưa thêm việc vào khung, ll đưa bớt việc ra khỏi khung, và cả khung chỉ di chuyển sang phải. Vì vậy, đoạn [l,r][l, r] 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 ll là đầu trái và rr là đầu phải của cửa sổ.

Ta thử chạy tay với dãy 2,3,1,2,4,3,1,52, 3, 1, 2, 4, 3, 1, 5 và T=10T = 10. Đầu phải lần lượt đưa 2,3,1,22, 3, 1, 2 vào cửa sổ: tổng là 88, chưa vượt TT, cửa sổ dài 44. Đưa thêm 44 vào thì tổng thành 1212, vượt TT. Đầu trái tiến lên, bỏ số 22 ở đầu cửa sổ ra, tổng còn 1010 và không còn vượt TT. Tiếp tục như vậy tới hết dãy, độ dài lớn nhất gặp được là 44, 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à TT để thử:

Khung xanh là cửa sổ; đầu phải thêm một số, đầu trái bỏ bớt số cho tới khi tổng không vượt T.

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.

Cửa sổ sau mỗi bước của đầu phải r, với T = 10: hai đầu chỉ đi sang phải; hàng cam (r = 4) là đoạn dài nhất.a2312431512345678r = 12tổng 2r = 223tổng 5r = 3231tổng 6r = 42312tổng 8r = 53124tổng 10 · bỏ 2r = 61243tổng 10 · bỏ 3r = 72431tổng 10 · bỏ 1r = 8315tổng 9 · bỏ 2, 4
Cửa sổ sau mỗi bước của đầu phải, với T = 10: hai đầu chỉ đi sang phải; hàng cam là một đoạn dài nhất.
Chứng minh

Gọi L(r)L(r) là đầu trái nhỏ nhất sao cho đoạn [L(r),r][L(r), r] có tổng không quá TT; nếu chính ara_r đã lớn hơn TT thì L(r)=r+1L(r) = r + 1, đoạn rỗng.

Đầu trái không lùi, tức là L(r+1)≥L(r)L(r + 1) \ge L(r). Với mọi l<L(r)l < L(r), đoạn [l,r][l, r] có tổng lớn hơn TT; thêm ar+1>0a_{r+1} > 0 vào thì đoạn [l,r+1][l, r + 1] càng có tổng lớn hơn TT. Vậy L(r+1)L(r + 1) không thể nhỏ hơn L(r)L(r).

Vòng while dừng đúng tại L(r)L(r). Trước vòng while của bước rr, ta có l=L(r−1)≤L(r)l = L(r - 1) \le L(r). Khi l<L(r)l < L(r) thì tổng còn vượt TT nên ll tiếp tục tiến; khi l=L(r)l = L(r) thì tổng không vượt TT 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í rr nào đó, và trong các đoạn có tổng không quá TT kết thúc tại rr, đoạn bắt đầu tại L(r)L(r) là dài nhất. Chương trình xét mọi rr, 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 O(n2)O(n^2). 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 nn bước, nên cả chương trình chỉ có khoảng 2n2n 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 n=105n = 10^5 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?

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

Đề. Có n≤105n \le 10^5 công việc, việc ii cần aia_i đơn vị thời gian (1≤ai≤1041 \le a_i \le 10^4), và ngân sách T≤109T \le 10^9. 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 TT; đượ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ả TT. 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 l=r+1l = r + 1 và sum bằng 00. Cửa sổ lúc này rỗng, có độ dài 00, đú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 T+ar≤109+104T + a_r \le 10^9 + 10^4, vẫn vừa kiểu int; dùng long long thì không phải lo chuyện tràn số.

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

Độ phức tạp: O(n)O(n), với n=105n = 10^5 cần khoảng 2⋅1052 \cdot 10^5 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á TT; ở 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 SS.

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 rr tiến lên, đầu trái ll 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 O(n)O(n).

Hỏi nhanh

Với T=7T = 7, đoạn 1,2,41, 2, 4 trong dãy 2,3,1,2,4,32, 3, 1, 2, 4, 3 có tổng 77, 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?

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á TT (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á kk 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á dd.
  • 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 SS (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á dd. 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 5,−4,15, -4, 1 và T=2T = 2: cả dãy có tổng 22, nên đáp án là 33. Nhưng ở bước đầu tiên, riêng số 55 đã vượt TT, nên thuật toán bỏ số 55 ra khỏi cửa sổ. Đến khi gặp −4-4 thì số 55 đã bị bỏ, không thể đưa trở lại, và thuật toán in ra 22.

Tương tự, các điều kiện như “tổng đúng bằng xx” hay “tổng chia hết cho mm” 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 aa 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?

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 a1,a2,…,ana_1, a_2, \dots, a_n:

  1. Đoạn dài nhất: tìm đoạn liên tiếp dài nhất có tổng không quá TT. Đây là bài toán ở đầu bài học.
  2. Đoạn ngắn nhất: tìm đoạn liên tiếp ngắn nhất có tổng ít nhất SS.
  3. Đếm đoạn: đếm số đoạn liên tiếp có tổng không quá TT.

Ví dụ với dãy 2,3,1,2,4,32, 3, 1, 2, 4, 3 và T=S=7T = S = 7: đoạn dài nhất có tổng không quá 77 dài 33, chẳng hạn 2,3,12, 3, 1; đoạn ngắn nhất có tổng ít nhất 77 dài 22, đó là 4,34, 3; và có 1414 đoạn có tổng không quá 77.

Cả ba bài đều dùng chung một vòng lặp: đầu phải rr đi từ trái sang phải, mỗi bước đưa ara_r 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ổ [l,r][l, r] có tổng không quá TT, và đó là đoạn tốt dài nhất kết thúc tại rr. 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 SS, và ta muốn đoạn đó càng ngắn càng tốt. Khi cửa sổ [l,r][l, r] đã có tổng ít nhất SS, ta ghi nhận độ dài của nó, rồi thử bỏ ala_l để 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ỏ ala_l. Khi vòng while dừng, tổng của cửa sổ đã nhỏ hơn SS, 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ừ 101810^{18}, 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 101810^{18} thì dãy không có đoạn nào có tổng ít nhất SS.

C++
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: O(n)O(n), với n=105n = 10^5 cần khoảng 2⋅1052 \cdot 10^5 phép tính.

Mỗi lần cửa sổ còn đủ tổng S thì ghi độ dài trước, rồi mới bỏ số ở đầu trái.

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í rr. Sau khi vòng while dừng, đoạn [l,r][l, r] có tổng không quá TT. Các đoạn kết thúc tại rr và bắt đầu từ ll trở về sau, tức là [l,r],[l+1,r],…,[r,r][l, r], [l + 1, r], \dots, [r, r], đều nằm trong đoạn [l,r][l, r]; các số đều dương nên tổng của chúng cũng không quá TT. Còn các đoạn kết thúc tại rr mà bắt đầu trước ll thì có tổng vượt TT, vì ll là đầu trái nhỏ nhất thỏa điều kiện (ô Chứng minh ở mục đầu). Như vậy, có đúng r−l+1r - l + 1 đoạn thỏa điều kiện kết thúc tại rr. Cộng con số này với mọi rr, 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ó.

C++
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: O(n)O(n), với n=105n = 10^5 cần khoảng 2⋅1052 \cdot 10^5 phép tính.

Với dãy 2,3,1,2,4,32, 3, 1, 2, 4, 3 và T=7T = 7, giá trị r−l+1r - l + 1 ở các bước lần lượt là 1,2,3,3,3,21, 2, 3, 3, 3, 2, cộng lại được 1414 đoạn.

Dãy 2, 3, 1, 2, 4, 3 với T = 7: ở bước r = 5, cửa sổ gồm các vị trí 3 tới 5, nên có 5 − 3 + 1 = 3 đoạn kết thúc tại 5 có tổng không quá 7.213213244536lrT = 73 đoạn
Dãy 2, 3, 1, 2, 4, 3 với T = 7: ở bước r = 5, cửa sổ gồm các vị trí 3 tới 5, nên có 5 − 3 + 1 = 3 đoạn kết thúc tại 5 có tổng không quá 7.

Số đoạn có thể lên tới n(n+1)2\frac{n(n+1)}{2}, khoảng 5⋅1095 \cdot 10^9 khi n=105n = 10^5, 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 SS: 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 rr và bắt đầu trước ll đều đủ tổng, còn các đoạn bắt đầu từ ll trở đi thì không, nên ta cộng l−1l - 1 vào kết quả.
  • Đếm đoạn có tổng đúng bằng xx: lấy số đoạn có tổng không quá xx trừ đi số đoạn có tổng không quá x−1x - 1.

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 nn số a1,a2,…,ana_1, a_2, \dots, a_n (0≤ai≤1060 \le a_i \le 10^6) và số kk (1≤k≤n≤5⋅1051 \le k \le n \le 5 \cdot 10^5). 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; 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 rr, ta thêm ara_r vào cửa sổ; khi số giá trị khác nhau vượt kk, ta bỏ ala_l ra và tăng ll. Sau vòng while, [l,r][l, r] là đoạn tốt dài nhất kết thúc tại rr. Ở đâ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ì 0≤ai≤1060 \le a_i \le 10^6, mảng đếm cần 106+110^6 + 1 ô, chỉ số từ 00 tới 10610^6.

Hàng dưới là mảng đếm của cửa sổ; khi số giá trị khác nhau vượt K, đầu 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

Độ phức tạp: O(n)O(n), với n=5⋅105n = 5 \cdot 10^5 cần khoảng 10610^6 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 b1≤b2≤⋯≤bmb_1 \le b_2 \le \dots \le b_m và một số xx; tìm hai phần tử ở hai vị trí khác nhau có tổng bằng xx.

Cách ngây thơ là thử mọi cặp phần tử. Có khoảng m22\frac{m^2}{2} cặp, nên với m=2⋅105m = 2 \cdot 10^5 ta phải thử khoảng 2⋅10102 \cdot 10^{10} 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 1,3,4,6,8,11,141, 3, 4, 6, 8, 11, 14 và x=19x = 19. Đặt con trỏ ll ở số nhỏ nhất và con trỏ rr ở số lớn nhất:

  • 1+14=151 + 14 = 15, nhỏ hơn 1919. Số 11 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ố 11.
  • 3+14=173 + 14 = 17, rồi 4+14=184 + 14 = 18, vẫn nhỏ hơn 1919. Ta bỏ tiếp 33 và 44.
  • 6+14=206 + 14 = 20, lớn hơn 1919. Lần này số 1414 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ố 1414.
  • 6+11=176 + 11 = 17, nhỏ hơn 1919, bỏ số 66. Còn lại 8+11=198 + 11 = 19: đã 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 xx thì bỏ số bên trái (tăng ll); nếu tổng lớn hơn xx thì bỏ số bên phải (giảm rr); nếu tổng bằng xx 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 mm phần tử, ta cần không quá m−1m - 1 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 xx và vùng có tổ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.
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 xx thì có một cặp như vậy nằm trọn trong đoạn [l,r][l, r]. Lúc đầu [l,r][l, r] là cả dãy nên điều này đúng.

Khi tổng nhỏ hơn xx, với mọi jj trong [l,r][l, r] ta có bl+bj≤bl+br<xb_l + b_j \le b_l + b_r < x, nên blb_l không ghép được với số nào trong đoạn; bỏ blb_l đi thì điều trên vẫn đúng. Khi tổng lớn hơn xx, lập luận tương tự cho brb_r.

Vòng lặp dừng khi tìm được cặp, hoặc khi l=rl = r. 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 xx.

Đ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 l=rl = r ta sẽ ghép một phần tử với chính nó: dãy 3,5,83, 5, 8 với x=10x = 10 sẽ báo có cặp 5+55 + 5.

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

Đề. Cho mảng nn số a1,a2,…,ana_1, a_2, \dots, a_n và số xx (n≤2⋅105n \le 2 \cdot 10^5, 1≤x,ai≤1091 \le x, a_i \le 10^9). In hai chỉ số khác nhau i,ji, j với ai+aj=xa_i + a_j = x (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ó l<rl < r, 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 2⋅1092 \cdot 10^9, vượt giới hạn của int, nên ta dùng long long.

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

Độ phức tạp: O(nlog⁡n)O(n \log n) cho bước sắp xếp, phần hai con trỏ chỉ O(n)O(n); với n=2⋅105n = 2 \cdot 10^5 cần khoảng 4⋅1064 \cdot 10^6 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 xx. 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 O(n2)O(n^2).

VIĐếm cặp có tổng không quá X

Bài toán: cho dãy nn số, đếm số cặp vị trí i<ji < j có ai+aj≤Xa_i + a_j \le X. 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 l=1l = 1, r=nr = n:

  • Nếu al+ar≤Xa_l + a_r \le X: số ala_l ghép với số lớn nhất còn lại là ara_r mà tổng vẫn không vượt XX, nên ghép với mọi số al+1,…,ar−1a_{l+1}, \dots, a_{r-1} đứng giữa (đều nhỏ hơn hoặc bằng ara_r) cũng không vượt. Có tất cả r−lr - l cặp như vậy, đều chứa ala_l. Ta cộng r−lr - l vào kết quả rồi bỏ ala_l (tăng ll), vì mọi cặp chứa ala_l đã được đếm.
  • Nếu al+ar>Xa_l + a_r > X: số ara_r ghép với số nhỏ nhất còn lại là ala_l mà tổng đã vượt XX, nên ghép với số nào khác cũng vượt; ara_r không thuộc cặp nào thỏa điều kiện. Ta bỏ ara_r (giảm rr).

Muốn đếm số cặp có tổng nằm trong khoảng [L,R][L, R], ta lấy số cặp có tổng không quá RR trừ đi số cặp có tổng không quá L−1L - 1, giống cách đếm đoạn có tổng đúng bằng xx ở 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 Pro

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

Đề. Có nhiều bộ dữ liệu. Mỗi bộ gồm dãy nn số nguyên dương và hai số L≤RL \le R; đếm số cặp chỉ số i<ji < j với L≤ai+aj≤RL \le a_i + a_j \le R. Tổng nn qua các bộ dữ liệu không quá 2⋅1052 \cdot 10^5.

Lời giải. Gọi F(X)F(X) là số cặp có tổng không quá XX, tính bằng hai con trỏ như trên. Sắp xếp dãy, đáp án là F(R)−F(L−1)F(R) - F(L - 1). Số cặp có thể tới 2⋅10102 \cdot 10^{10}, nên cần dùng kiểu long long cho kết quả.

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

Độ phức tạp: O(nlog⁡n)O(n \log n) cho mỗi test vì phải sắp xếp, hai lần đếm chỉ thêm O(n)O(n); tổng nn không quá 2⋅1052 \cdot 10^5 nên cần khoảng 4⋅1064 \cdot 10^6 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 aa có nn số và dãy bb có mm số; với mỗi aia_i, đếm số phần tử của bb nhỏ hơn aia_i. Con trỏ jj dừng tại phần tử đầu tiên của bb không nhỏ hơn aia_i, nên có j−1j - 1 phần tử nhỏ hơn. Vì aa tăng dần, vị trí dừng cho ai+1a_{i+1} không thể đứng trước vị trí dừng cho aia_i, nên jj chỉ cần tiến tiếp:

C++
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: O(n+m)O(n + m), với n=m=2⋅105n = m = 2 \cdot 10^5 cần khoảng 4⋅1054 \cdot 10^5 phép tính.

Kiểm tra dãy con. Cho hai chuỗi ss và tt. Hỏi tt có phải dãy con của ss không, tức là có thể xóa bớt một số ký tự của ss (giữ nguyên thứ tự những ký tự còn lại) để được tt hay không. Ta duyệt ss từ trái sang phải, đồng thời giữ con trỏ jj trên tt (chỉ số ký tự của tt đang cần tìm); mỗi khi gặp ký tự bằng tjt_j thì tiến jj. Nếu duyệt hết ss mà jj đã đi hết tt thì tt là dãy con của ss.

C++
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: O(∣s∣)O(|s|), với chuỗi ss dài 10610^6 cần khoảng 10610^6 phép tính.

Chứng minh

Cách làm trên ghép mỗi ký tự của tt với lần xuất hiện sớm nhất còn dùng được trong ss, và cách ghép này không bao giờ thiệt. Giả sử có một cách ghép đúng mà tjt_j được ghép với vị trí pp, muộn hơn vị trí sớm nhất qq còn dùng được. Đổi tjt_j sang vị trí qq: vì q<pq < p, các ký tự sau của tt vẫn được ghép ở các vị trí sau pp, tức là cũng sau qq, 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ó nn yêu cầu với kích thước a1,…,ana_1, \dots, a_n và mm tài nguyên với kích thước b1,…,bmb_1, \dots, b_m (n,m≤2⋅105n, m \le 2 \cdot 10^5, các kích thước và kk không quá 10910^9). Yêu cầu kích thước xx ghép được với tài nguyên kích thước yy khi ∣x−y∣≤k|x - y| \le k; 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ỏ ii chạy trên dãy yêu cầu, con trỏ jj 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ỏ (bj<ai−kb_j < a_i - k): 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 (bj>ai+kb_j > a_i + k): 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 ii với tài nguyên jj 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 aia_i với bjb_j thì không cần làm gì. Nếu không:

  • aia_i được ghép với bqb_q, còn bjb_j được ghép với apa_p: đổi thành hai cặp (ai,bj)(a_i, b_j) và (ap,bq)(a_p, b_q). Cặp đầu hợp lệ vì ta đang ở trường hợp ghép được. Cặp sau cũng hợp lệ: bq≤ai+k≤ap+kb_q \le a_i + k \le a_p + k, và bq≥bj≥ap−kb_q \ge b_j \ge a_p - k.
  • Chỉ một trong hai đang được ghép: bỏ cặp đó, thay bằng cặp (ai,bj)(a_i, b_j), số cặp không đổi.
  • Cả hai đều chưa được ghép: thêm cặp (ai,bj)(a_i, b_j) 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 (ai,bj)(a_i, b_j), nên ghép ngay là an toàn.

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

Độ phức tạp: O(nlog⁡n+mlog⁡m)O(n \log n + m \log m) cho bước sắp xếp, phần ghép chỉ O(n+m)O(n + m) vì mỗi bước tiến ít nhất một con trỏ; với n=m=2⋅105n = m = 2 \cdot 10^5 cần khoảng 7⋅1067 \cdot 10^6 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ó nn tháng (n≤2⋅105n \le 2 \cdot 10^5), tháng thứ ii có did_i ngày (di≤106d_i \le 10^6), và các năm nối tiếp nhau liên tục. Nghỉ vào ngày thứ jj của một tháng thì được jj điểm vui. Chọn đúng xx 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 nn sang tháng 11 của năm sau, và xx 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 xx ngày kết thúc ở ngày thứ jj của một tháng có dd ngày, với j<dj < d. Gọi vv là điểm của ngày đầu tiên trong đoạn.

Trường hợp v≤jv \le j. Dời đoạn sang phải một ngày: thêm ngày có điểm j+1j + 1, bỏ ngày có điểm vv, tổng tăng j+1−v≥1j + 1 - v \ge 1. Dời tiếp lần thứ kk, ngày được thêm có điểm j+kj + k; ngày bị bỏ có điểm v+k−1v + k - 1 nếu còn trong tháng cũ, hoặc không quá kk nếu đã sang tháng mới. Cả hai đều nhỏ hơn j+kj + k, nên tổng tiếp tục tăng cho tới khi đoạn kết thúc ở ngày dd.

Trường hợp v>jv > j. Ngày đầu và ngày cuối nằm ở hai tháng khác nhau, vì nếu cùng tháng thì v≤jv \le j. Dời đoạn sang trái lần thứ kk (với k=1,2,…,jk = 1, 2, \dots, j): thêm ngày có điểm v−k≥1v - k \ge 1, bỏ ngày có điểm j−k+1j - k + 1, tổng thay đổi đúng v−j−1≥0v - j - 1 \ge 0. Sau jj 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: d1,…,dn,d1,…,dnd_1, \dots, d_n, d_1, \dots, d_n, tức là tháng n+in + i giống tháng ii. Mọi đoạn xx 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 2n2n này, kết thúc ở cuối một tháng rr với n≤r≤2nn \le r \le 2n. 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 rr, ta giữ cửa sổ gồm các tháng trọn vẹn [l,r][l, r] có tổng số ngày không quá xx, càng nhiều tháng càng tốt. Như bài toán mở đầu, khi rr tăng thì ll chỉ tăng. Số ngày còn thiếu là miss=x−(dl+⋯+dr)miss = x - (d_l + \dots + d_r), lấy ở cuối tháng l−1l - 1: các ngày dl−1−miss+1,…,dl−1d_{l-1} - miss + 1, \dots, d_{l-1}. Vì tháng l−1l - 1 đã bị bỏ ra khỏi cửa sổ, tức là thêm cả tháng đó thì vượt xx ngày, nên miss<dl−1miss < d_{l-1}.

Tổng điểm của một tháng trọn dd ngày là 1+2+⋯+d=d(d+1)21 + 2 + \dots + d = \frac{d(d+1)}{2}, tổng của một cấp số cộng. Phần lấy ở cuối tháng l−1l - 1 có tổng điểm d(d+1)2−(d−miss)(d−miss+1)2\frac{d(d+1)}{2} - \frac{(d - miss)(d - miss + 1)}{2} với d=dl−1d = d_{l-1}. Khi l=1l = 1, cửa sổ đã gồm ít nhất cả một năm nên đủ xx ngày, không cần lấy thêm ngày nào.

Tổng điểm có thể tới khoảng 2⋅105⋅5⋅1011=10172 \cdot 10^5 \cdot 5 \cdot 10^{11} = 10^{17}, nên mọi biến dùng long long.

Code đầy đủ
#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

Độ phức tạp: O(n)O(n), với n=2⋅105n = 2 \cdot 10^5 cần khoảng 8⋅1058 \cdot 10^5 phép tính.

Ví dụĐẩy Hộp Trên Trục Số

Đề. Trên trục số có nn hộp và mm ô đích, đều ở các tọa độ nguyên khác 00 (n,m≤2⋅105n, m \le 2 \cdot 10^5, tọa độ không quá 10910^9 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 nn và tổng mm đều không quá 2⋅1052 \cdot 10^5.

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 a1<a2<⋯<apa_1 < a_2 < \dots < a_p và các ô đích b1<b2<⋯<bqb_1 < b_2 < \dots < b_q.

Khi đẩy, các hộp bị đẩy luôn dồn thành một khối liền nhau. Gọi EE là tọa độ cuối của khối và cc là số hộp có tọa độ ban đầu không vượt EE: cả cc hộp này nằm trong khối, chiếm đúng các tọa độ E−c+1,…,EE - c + 1, \dots, E. Các hộp ở sau EE 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 [E−c+1,E][E - c + 1, E], cộng số ô đích sau EE vốn đã có hộp.

Nhận xét 1: chỉ cần xét EE là một ô đích. Nếu ô EE không phải ô đích, đẩy ít đi một bước (giảm EE đi 11) 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 bib_i, 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 E=biE = b_i với i=1,…,qi = 1, \dots, q.

Chứng minh

Giả sử ô EE không phải ô đích, và khối gồm cc hộp. Nếu không có hộp nào nằm ở EE từ đầu, giảm EE đi 11 thì khối trượt sang trái một ô: mất tọa độ EE (không phải ô đích), thêm tọa độ E−cE - c, nên số ô đích được phủ không giảm. Nếu có một hộp nằm ở EE từ đầu, giảm EE đi 11 thì hộp đó đứng yên ở EE, khối còn c−1c - 1 hộp ở E−c+1,…,E−1E - c + 1, \dots, E - 1; 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 E=biE = b_i, gọi cic_i là số hộp có tọa độ không vượt bib_i; khối chiếm [bi−ci+1,bi][b_i - c_i + 1, b_i]. Khi ii tăng, cic_i chỉ tăng, nên cic_i tính được bằng một con trỏ jj 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 bi−ci+1b_i - c_i + 1 cũng không bao giờ giảm. Giữa bib_i và bi+1b_{i+1} có bi+1−bib_{i+1} - b_i 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 ci+1−cic_{i+1} - c_i không vượt quá bi+1−bib_{i+1} - b_i. Chuyển vế ta được bi+1−ci+1≥bi−cib_{i+1} - c_{i+1} \ge b_i - c_i. Vì vậy vị trí ô đích đầu tiên nằm trong khối, gọi là pip_i, cũng tính được bằng một con trỏ chỉ tiến.

Phần sau khối. Gọi sis_i là số ô đích từ bib_i trở đi vốn đã có hộp. Ta tính sis_i 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 ii, số ô đích được phủ là (i−pi+1)+si+1(i - p_i + 1) + s_{i+1}, và cả ba con trỏ đều chỉ đi một chiều. Trong code, pip_i là biến lo và sis_i là mảng suf.

Code đầy đủ
#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

Độ phức tạp: O(n+m)O(n + m) cho mỗi bộ dữ liệu vì ba con trỏ chỉ tiến; tổng nn và tổng mm không quá 2⋅1052 \cdot 10^5 nên cần khoảng 10610^6 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òng while. Nên thử ba test nhỏ: n=1n = 1, mọi phần tử đều lớn hơn TT, 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ùng long long cho 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 nn bước, nên cả quá trình là O(n)O(n) dù vòng while nằm trong vòng for. 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òng while, trước khi bỏ ala_l.
  • 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 xx thì tăng ll, lớn hơn thì giảm rr; khi đếm cặp có tổng không quá XX, mỗi lần al+ar≤Xa_l + a_r \le X thì cộng r−lr - l.

Bài tập

Bài tập ngoài

Nhớ thêm thông tin

IVVasya and StringCodeforces

Đọc thêm