Chương 0·Bài 0.3

Prefix Sum

Cho dãy nn số và qq câu hỏi, mỗi câu hỏi tổng các số từ vị trí ll đến vị trí rr. Trả lời mọi câu hỏi trong O(n+q)O(n + q).

Đây là bài toán quen thuộc nhất của kỹ thuật tổng tiền tố (prefix sum). Trước khi đọc câu hỏi, ta tính sẵn tổng của 11 số đầu tiên, 22 số đầu tiên, và cứ thế tới nn số đầu tiên của dãy. Tổng của một đoạn bất kỳ khi đó bằng hiệu của hai tổng đã tính sẵn, nên mỗi câu hỏi chỉ cần một phép trừ. Cùng ý tưởng này còn giúp đếm số phần tử thỏa một điều kiện trong đoạn, đếm số đoạn có tổng cho trước, cộng một giá trị vào nhiều đoạn cùng lúc, và tính tổng các số trong một hình chữ nhật của bảng số.

ITổng đoạn bằng một phép trừ

Đánh số dãy từ 11: a1,a2,…,ana_1, a_2, \dots, a_n. Đoạn gồm các vị trí từ ll tới rr được viết là [l,r][l, r].

Cách dễ nghĩ ra nhất là với mỗi câu hỏi, cộng lần lượt các số từ ala_l tới ara_r:

C++
while (q--) {
    ll l, r, sum = 0;
    cin >> l >> r;
    for (ll i = l; i <= r; i++) sum += a[i];
    cout << sum << '\n';
}

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

Mỗi câu hỏi có thể phải cộng tới nn số, nên cả bài vượt xa khoảng 10810^8 phép tính mà máy chấm làm được trong một giây. Ta có thể thấy được điểm lãng phí của cách làm trên: các câu hỏi khác nhau thường có phần chung, chẳng hạn hai câu hỏi [2,7][2, 7] và [3,9][3, 9] cùng chứa a3+a4+⋯+a7a_3 + a_4 + \dots + a_7, nhưng câu hỏi nào cũng cộng lại phần đó từ đầu.

Thay vào đó, ta cộng một lần trước khi đọc câu hỏi. Gọi ii số đầu tiên của dãy là tiền tố dài ii, và gọi tổng của chúng là prei\mathrm{pre}_i:

pre0=0,prei=a1+a2+⋯+ai=prei−1+ai.\mathrm{pre}_0 = 0, \qquad \mathrm{pre}_i = a_1 + a_2 + \dots + a_i = \mathrm{pre}_{i-1} + a_i .

Mảng pre\mathrm{pre} được gọi là mảng tiền tố (hay mảng cộng dồn). Nhờ công thức prei=prei−1+ai\mathrm{pre}_i = \mathrm{pre}_{i-1} + a_i, ta dựng được cả mảng trong một lượt duyệt. Tiền tố dài rr gồm tiền tố dài l−1l - 1 và đoạn [l,r][l, r] đứng nối tiếp nhau, nên tổng của đoạn bằng hiệu hai tổng tiền tố:

al+al+1+⋯+ar=prer−prel−1.a_l + a_{l+1} + \dots + a_r = \mathrm{pre}_r - \mathrm{pre}_{l-1}.

Ô pre0=0\mathrm{pre}_0 = 0 là tổng của tiền tố không có số nào. Nhờ ô này, công thức vẫn đúng với các đoạn bắt đầu từ l=1l = 1.

Ví dụ với dãy 3,1,4,1,5,9,2,63, 1, 4, 1, 5, 9, 2, 6, mảng tiền tố là 0,3,4,8,9,14,23,25,310, 3, 4, 8, 9, 14, 23, 25, 31. Tổng đoạn [3,6][3, 6] là 4+1+5+9=194 + 1 + 5 + 9 = 19, đúng bằng pre6−pre2=23−4\mathrm{pre}_6 - \mathrm{pre}_2 = 23 - 4. Bạn có thể kéo hai đầu ll, rr để thử với các đoạn khác:

Kéo l, r: tổng đoạn luôn bằng hiệu hai ô của mảng tiền tố.

Công thức ngắn, nhưng có hai chỗ hay viết sai. Chỗ thứ nhất là kiểu dữ liệu: mỗi aia_i có thể vừa kiểu int, nhưng tổng của 2⋅1052 \cdot 10^5 số cỡ 10910^9 lên tới 2⋅10142 \cdot 10^{14}, nên mảng tiền tố cần kiểu long long. Chỗ thứ hai là chỉ số: nếu đọc dãy vào a[0] tới a[n - 1] mà vẫn dùng công thức prer−prel−1\mathrm{pre}_r - \mathrm{pre}_{l-1}, mọi câu trả lời đều lệch một vị trí. Cách an toàn là cho mảng tiền tố n+1n + 1 ô và luôn hiểu prei\mathrm{pre}_i là tổng của ii số đầu tiên.

Hỏi nhanh

Một bạn đọc dãy vào a[0], a[1], ..., a[n - 1], tức là đánh số từ 00, và dựng pre[i] = a[0] + ... + a[i - 1] với pre[0] = 0. Câu hỏi cũng đánh số từ 00: tính tổng a[l] + ... + a[r].

Biểu thức nào cho kết quả đúng?

Ví dụTổng Trên Đoạn Cố Định

Đề. Cho dãy nn số nguyên dương x1,x2,…,xnx_1, x_2, \dots, x_n (xi≤109x_i \le 10^9) và qq câu hỏi; mỗi câu hỏi cho l,rl, r và hỏi tổng các số từ vị trí ll đến vị trí rr (n,q≤2⋅105n, q \le 2 \cdot 10^5).

Lời giải. Đây đúng là bài toán của mục này. Ta dựng mảng tiền tố ngay khi đọc dãy, rồi với mỗi câu hỏi in prer−prel−1\mathrm{pre}_r - \mathrm{pre}_{l-1}. Tổng lớn nhất có thể tới 2⋅10142 \cdot 10^{14}, nên cần dùng kiểu long long.

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

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

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

IIĐếm phần tử thỏa điều kiện trên đoạn

Bài toán: cho dãy nn số và nhiều câu hỏi, mỗi câu hỏi đoạn [l,r][l, r] có bao nhiêu số chẵn.

Ta đổi bài toán này về bài tính tổng đoạn. Đánh dấu bi=1b_i = 1 nếu aia_i là số chẵn và bi=0b_i = 0 nếu aia_i là số lẻ. Khi đó số số chẵn trong đoạn [l,r][l, r] chính là tổng bl+bl+1+⋯+brb_l + b_{l+1} + \dots + b_r, nên ta chỉ cần dựng mảng tiền tố của dãy bb.

Cách làm này dùng được cho mọi điều kiện xét riêng trên từng phần tử: số chẵn, số dương, ô có cây, ký tự là chữ a. Với một chuỗi chữ cái thường mà câu hỏi có thể hỏi về chữ cái bất kỳ, ta dựng 2626 mảng tiền tố, mỗi mảng cho một chữ cái.

Đổi chuỗi, chữ cần đếm, hoặc kéo l, r: số lần xuất hiện luôn là hiệu hai ô của mảng tiền tố.

Ví dụĐếm Cặp AC Trên Đoạn

Đề. Cho chuỗi SS dài n≤105n \le 10^5 chỉ gồm các chữ A, C, G, T, và q≤105q \le 10^5 câu hỏi. Mỗi câu hỏi cho l,rl, r và hỏi có bao nhiêu vị trí ii với l≤i<rl \le i < r mà hai ký tự SiSi+1S_i S_{i+1} là AC.

Lời giải. Mỗi cặp AC chiếm hai vị trí liền nhau, nên ta cần chọn đánh dấu cặp vào một trong hai vị trí đó. Ta đánh dấu vào vị trí của chữ C, tức là vị trí bên phải: bi=1b_i = 1 nếu Si−1SiS_{i-1} S_i là AC. Một cặp nằm trọn trong đoạn [l,r][l, r] khi chữ C của nó nằm ở vị trí từ l+1l + 1 tới rr, nên đáp án là prer−prel\mathrm{pre}_r - \mathrm{pre}_l chứ không phải prer−prel−1\mathrm{pre}_r - \mathrm{pre}_{l-1}. Nếu viết prel−1\mathrm{pre}_{l-1}, ta sẽ đếm thừa cặp có chữ A ở vị trí l−1l - 1, nằm ngoài đoạn.

Trên chuỗi AACACGACCT, chữ C của các cặp AC nằm ở vị trí 3,5,83, 5, 8. Câu hỏi [2,5][2, 5] đếm được hai cặp, ứng với vị trí 33 và 55. Câu hỏi [8,10][8, 10] không đếm cặp nào: cặp có chữ C ở vị trí 88 có chữ A ở vị trí 77, nằm ngoài đoạn.

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

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> q >> s;
    s = " " + s;
    for (ll i = 1; i <= n; i++) pre[i] = pre[i - 1] + (i > 1 && s[i - 1] == 'A' && s[i] == 'C');
    while (q--) {
        ll l, r;
        cin >> l >> r;
        cout << pre[r] - pre[l] << '\n';
    }
}
10 5
AACACGACCT
1 10
2 5
3 7
5 8
8 10

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

IIIPhép toán nào dùng được tiền tố

Công thức prer−prel−1\mathrm{pre}_r - \mathrm{pre}_{l-1} đúng nhờ hai điều. Thứ nhất, tổng của tiền tố dài rr ghép được từ tổng của tiền tố dài l−1l - 1 và tổng của đoạn [l,r][l, r]. Thứ hai, phép trừ là phép ngược của phép cộng, nên gỡ được phần tiền tố dài l−1l - 1 ra. Một phép toán khác cũng dùng được tiền tố nếu nó có đủ hai điều đó, nghĩa là có một phép ngược để gỡ phần đầu ra.

Phép XOR. Phép XOR của hai số, viết là x⊕yx \oplus y và trong C++ là x ^ y, so hai số theo từng chữ số nhị phân: hai chữ số khác nhau cho 11, giống nhau cho 00. Ví dụ 5⊕3=1012⊕0112=1102=65 \oplus 3 = 101_2 \oplus 011_2 = 110_2 = 6. Vì x⊕x=0x \oplus x = 0, phép XOR là phép ngược của chính nó. Đặt X0=0X_0 = 0 và Xi=a1⊕a2⊕⋯⊕aiX_i = a_1 \oplus a_2 \oplus \dots \oplus a_i. Trong Xr⊕Xl−1X_r \oplus X_{l-1}, mỗi số đứng trước vị trí ll xuất hiện hai lần và tự triệt tiêu, nên XOR của đoạn [l,r][l, r] bằng Xr⊕Xl−1X_r \oplus X_{l-1}.

Phép min, max. Biết số nhỏ nhất của a1,…,ara_1, \dots, a_r và số nhỏ nhất của a1,…,al−1a_1, \dots, a_{l-1} không cho ta biết số nhỏ nhất của đoạn [l,r][l, r], vì số nhỏ nhất của tiền tố dài rr có thể nằm ngay trong phần đầu cần bỏ đi. Vì vậy tiền tố không trả lời được câu hỏi về số nhỏ nhất hay lớn nhất trên một đoạn bất kỳ. Tiền tố min vẫn có ích khi đoạn luôn bắt đầu từ vị trí 11, chẳng hạn “số nhỏ nhất trong ii số đầu tiên”.

Hỏi nhanh

Dãy không thay đổi giữa các câu hỏi. Ta muốn dựng sẵn một mảng tiền tố, rồi trả lời mỗi câu hỏi về đoạn [l,r][l, r] chỉ bằng một phép tính trên hai ô của mảng đó.

Ba trong bốn câu hỏi dưới đây làm được theo cách này. Câu hỏi nào là ngoại lệ?

Sử dụng kiến thức này cũng có thể giải bài: với mỗi vị trí ii, tính ước chung lớn nhất của các số còn lại khi bỏ đi riêng aia_i. Phép lấy ước chung lớn nhất cũng không có phép ngược, nhưng ở đây phần còn lại gồm một tiền tố và một hậu tố (các số ở cuối dãy). Ta tính ước chung lớn nhất của mọi tiền tố từ trái sang và của mọi hậu tố từ phải sang. Đáp án tại vị trí ii là ước chung lớn nhất của hai số: một số của tiền tố a1,…,ai−1a_1, \dots, a_{i-1}, một số của hậu tố ai+1,…,ana_{i+1}, \dots, a_n.

IVĐếm đoạn có tổng bằng k

Bài toán: cho dãy nn số nguyên, có thể âm, và một số kk. Đếm số đoạn [l,r][l, r] có tổng bằng kk.

Dãy có n(n+1)2\frac{n(n+1)}{2} đoạn. Có mảng tiền tố, ta tính được tổng mỗi đoạn bằng một phép trừ, nhưng thử hết mọi đoạn vẫn tốn O(n2)O(n^2), khoảng 2⋅10102 \cdot 10^{10} phép tính khi n=2⋅105n = 2 \cdot 10^5.

Viết điều kiện bằng mảng tiền tố: đoạn [l,r][l, r] có tổng bằng kk khi prer−prel−1=k\mathrm{pre}_r - \mathrm{pre}_{l-1} = k, tức là prel−1=prer−k\mathrm{pre}_{l-1} = \mathrm{pre}_r - k. Đặt j=l−1j = l - 1, mỗi đoạn [l,r][l, r] ứng với đúng một cặp chỉ số j<rj < r (với jj có thể bằng 00) thỏa prej=prer−k\mathrm{pre}_j = \mathrm{pre}_r - k, và ngược lại. Vì vậy thay vì đếm đoạn, ta đếm các cặp chỉ số như vậy.

Ta duyệt rr từ 11 tới nn và giữ một bảng đếm: với mỗi giá trị vv, bảng ghi có bao nhiêu chỉ số j<rj < r mà prej=v\mathrm{pre}_j = v. Tại mỗi rr, số đoạn kết thúc tại rr có tổng bằng kk chính là số mà bảng ghi cho giá trị prer−k\mathrm{pre}_r - k. Sau đó ta thêm prer\mathrm{pre}_r vào bảng để dùng cho các rr phía sau. Lúc bắt đầu, bảng đã có sẵn giá trị pre0=0\mathrm{pre}_0 = 0 một lần, ứng với các đoạn bắt đầu từ l=1l = 1.

Tiền tố có thể âm hoặc rất lớn, nên không dùng trực tiếp làm chỉ số của mảng được. Ta dùng map<ll, ll>, có thể hiểu như một mảng đếm mà chỉ số là số nguyên bất kỳ. Trong code, ta không cần lưu cả mảng tiền tố, vì ở mỗi bước chỉ dùng tiền tố của vị trí đang xét:

C++
map<ll, ll> cnt;
cnt[0] = 1;
ll cur = 0, ans = 0;
for (ll i = 1; i <= n; i++) {
    cur += a[i];
    ans += cnt[cur - k];
    cnt[cur]++;
}

Độ phức tạp: O(nlog⁡n)O(n \log n) vì mỗi lần dùng map tốn O(log⁡n)O(\log n); với n=2⋅105n = 2 \cdot 10^5 cần khoảng 4⋅1064 \cdot 10^6 phép tính.

Hỏi nhanh

Một bạn viết chương trình đếm số đoạn có tổng bằng kk, nhưng đổi chỗ hai dòng cuối trong vòng lặp so với code ở trên. Bạn có thể bấm Chạy để xem chương trình in gì với dãy 1,−1,01, -1, 0 và k=0k = 0:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, k, a[N];

signed main() {
    cin >> n >> k;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    map<ll, ll> cnt;
    cnt[0] = 1;
    ll cur = 0, ans = 0;
    for (ll i = 1; i <= n; i++) {
        cur += a[i];
        cnt[cur]++;
        ans += cnt[cur - k];
    }
    cout << ans << '\n';
}
3 0
1 -1 0

Dãy chỉ có 33 đoạn có tổng bằng 00: đoạn 1,−11, -1, đoạn chỉ gồm số 00, và cả dãy. Vì sao chương trình in ra 66?

Khi tiền tố chỉ nhận ít giá trị, chẳng hạn phần dư khi chia cho một số mm nhỏ, ta nên dùng mảng đếm thay cho map. Chênh lệch tốc độ lớn hơn ta tưởng:

Đếm một triệu tiền tố theo phần dư.mảng đếm29 msunordered_map238 ms · gấp 8,2 lầnmap769 ms · gấp 27 lần
Đếm một triệu tiền tố theo phần dư bằng ba loại bảng đếm.

Cùng một vòng lặp, chỉ khác bảng đếm, mà map chậm hơn mảng khoảng 2727 lần, còn unordered_map chậm hơn khoảng 88 lần. Với n=2⋅105n = 2 \cdot 10^5, cả ba cách đều kịp thời gian, nhưng khi giới hạn chặt, đó có thể là khác biệt giữa AC và TLE.

Ví dụĐếm Đoạn Có Tổng Chia Hết

Đề. Cho dãy N≤2⋅105N \le 2 \cdot 10^5 số nguyên A1,…,ANA_1, \dots, A_N với ∣Ai∣≤109|A_i| \le 10^9, có thể âm. Đếm số đoạn có tổng chia hết cho NN.

Lời giải. Đoạn [l,r][l, r] có tổng chia hết cho NN khi prer−prel−1\mathrm{pre}_r - \mathrm{pre}_{l-1} chia hết cho NN, tức là hai tiền tố prer\mathrm{pre}_r và prel−1\mathrm{pre}_{l-1} có cùng số dư khi chia cho NN. Như vậy mỗi cặp tiền tố có cùng số dư ứng với đúng một đoạn cần đếm. Ta duyệt rr và giữ một mảng đếm: với mỗi số dư, có bao nhiêu tiền tố đứng trước có số dư đó. Tại mỗi rr, cộng số tiền tố có cùng số dư với prer\mathrm{pre}_r vào đáp án, rồi mới thêm prer\mathrm{pre}_r vào mảng đếm. Số dư chỉ có NN giá trị nên dùng mảng được, và cả bài tốn O(N)O(N).

Cần cẩn thận với số âm: trong C++, -7 % 6 bằng -1 chứ không phải 5. Vì vậy ta đưa số dư về khoảng từ 00 tới N−1N - 1 bằng ((x % n) + n) % n; nếu không, hai tiền tố có cùng số dư có thể rơi vào hai ô khác nhau của mảng đếm. Đáp án có thể tới N(N+1)2≈2⋅1010\frac{N(N+1)}{2} \approx 2 \cdot 10^{10}, nên cần dùng kiểu long long cho kết quả.

Trên ví dụ của đề, các tiền tố là 0,2,−1,3,4,0,60, 2, -1, 3, 4, 0, 6, và số dư của chúng khi chia cho 66 là 0,2,5,3,4,0,00, 2, 5, 3, 4, 0, 0. Ba tiền tố có số dư 00, ở các vị trí 0,5,60, 5, 6, tạo thành ba cặp, ứng với ba đoạn [1,5][1, 5], [1,6][1, 6] và [6,6][6, 6]. Mỗi cung trong hình dưới nối hai tiền tố có cùng số dư, tức là một đoạn có tổng chia hết cho 66:

Mỗi cung là một đoạn có tổng chia hết cho 6.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, cnt[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    cnt[0] = 1;
    ll cur = 0, ans = 0;
    for (ll i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        cur = ((cur + x) % n + n) % n;
        ans += cnt[cur];
        cnt[cur]++;
    }
    cout << ans << '\n';
}
6
2 -3 4 1 -4 6

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

Bước khó nhất của các bài đếm đoạn thường là biến đổi điều kiện, sao cho một vế chỉ chứa những gì của vị trí rr, vế còn lại chỉ chứa những gì của vị trí l−1l - 1. Câu hỏi dưới đây là một ví dụ.

Câu hỏi nhanh dành cho tài khoản Pro

Mở khóa bằng tài khoản Pro

Sử dụng kiến thức này cũng có thể giải bài: tìm đoạn có tổng lớn nhất. Với mỗi rr, đoạn có tổng lớn nhất kết thúc tại rr có tổng bằng prer\mathrm{pre}_r trừ đi tiền tố nhỏ nhất trong pre0,…,prer−1\mathrm{pre}_0, \dots, \mathrm{pre}_{r-1}. Thay cho bảng đếm, ta chỉ cần giữ một biến lưu tiền tố nhỏ nhất đã gặp.

VCộng cả đoạn: mảng hiệu

Bài toán: dãy a1,a2,…,ana_1, a_2, \dots, a_n ban đầu toàn số 00. Có mm thao tác, mỗi thao tác cộng xx vào mọi số từ vị trí ll đến vị trí rr. In dãy sau khi làm hết các thao tác.

Làm thẳng mỗi thao tác tốn tới nn phép cộng, nên cả bài tốn O(nm)O(nm). Ta có thể làm mỗi thao tác chỉ bằng hai phép sửa. Giữ một mảng diff\mathrm{diff}, ban đầu toàn số 00, gọi là mảng hiệu (difference array). Mỗi thao tác cộng xx vào đoạn [l,r][l, r] chỉ sửa hai ô của mảng này:

diffl+=x,diffr+1−=x.\mathrm{diff}_l \mathrel{+}= x, \qquad \mathrm{diff}_{r+1} \mathrel{-}= x .

Sau khi làm hết các thao tác, ta lấy tổng tiền tố của mảng hiệu, và đó chính là dãy cần in: ai=diff1+diff2+⋯+diffia_i = \mathrm{diff}_1 + \mathrm{diff}_2 + \dots + \mathrm{diff}_i. Tên gọi mảng hiệu đến từ việc diffi=ai−ai−1\mathrm{diff}_i = a_i - a_{i-1}: mảng hiệu và mảng tiền tố là hai phép ngược của nhau.

Chứng minh

Xét một thao tác cộng xx vào đoạn [l,r][l, r] và một vị trí ii. Tổng diff1+⋯+diffi\mathrm{diff}_1 + \dots + \mathrm{diff}_i chứa ô diffl\mathrm{diff}_l khi i≥li \ge l, và chứa ô diffr+1\mathrm{diff}_{r+1} khi i≥r+1i \ge r + 1. Vì vậy thao tác này góp vào tổng đó:

  • 00 khi i<li < l;
  • xx khi l≤i≤rl \le i \le r;
  • x−x=0x - x = 0 khi i>ri > r.

Như vậy đúng các vị trí trong đoạn [l,r][l, r] được cộng thêm xx. Mỗi thao tác góp vào tổng một cách độc lập với các thao tác khác, nên sau mọi thao tác, tổng tiền tố của mảng hiệu bằng đúng dãy cần tìm.

Hình dưới chạy ba thao tác của chương trình ngay sau đó: mỗi thao tác chỉ sửa hai ô, và một lượt cộng dồn ở cuối cho lại cả dãy.

Ba thao tác của chương trình dưới: mỗi thao tác sửa hai ô, một lượt cộng dồn cho lại dãy.
C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, m, diff[N];

signed main() {
    cin >> n >> m;
    while (m--) {
        ll l, r, x;
        cin >> l >> r >> x;
        diff[l] += x;
        diff[r + 1] -= x;
    }
    ll cur = 0;
    for (ll i = 1; i <= n; i++) {
        cur += diff[i];
        cout << cur << (i == n ? '\n' : ' ');
    }
}
8 3
2 5 2
4 7 3
1 3 -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.

Mảng hiệu cần có ô thứ n+1n + 1, vì thao tác có r=nr = n sẽ sửa tới ô đó. Ngoài ra, cách làm này chỉ dùng được khi mọi thao tác cộng đều đến trước mọi câu hỏi. Nếu thao tác cộng và câu hỏi xen kẽ nhau, mỗi lần hỏi ta lại phải cộng dồn cả mảng, và cần một cách làm khác.

Câu hỏi nhanh dành cho tài khoản Pro

Mở khóa bằng tài khoản Pro

Mảng hiệu hay xuất hiện ở dạng độ phủ: trên trục số có nhiều đoạn, hỏi mỗi điểm nằm trong bao nhiêu đoạn. Mỗi đoạn [l,r][l, r] là một thao tác cộng 11 vào các điểm từ ll tới rr, nên một lượt cộng dồn ở cuối cho độ phủ của mọi điểm.

Ví dụĐiểm Đạt Ngưỡng

Đề. Trên trục số có NN đoạn với hai đầu mút là số nguyên từ 11 tới 2⋅1052 \cdot 10^5. Một điểm nguyên gọi là đạt ngưỡng nếu nó nằm trong ít nhất KK đoạn. Có QQ câu hỏi, mỗi câu hỏi cho a,ba, b và hỏi đoạn [a,b][a, b] chứa bao nhiêu điểm đạt ngưỡng (N,Q≤2⋅105N, Q \le 2 \cdot 10^5).

Lời giải. Bài này dùng tiền tố hai lần. Lần thứ nhất là mảng hiệu để tính độ phủ: mỗi đoạn trong đề là một thao tác cộng 11, và một lượt cộng dồn cho biết mỗi điểm nằm trong bao nhiêu đoạn. Lần thứ hai giống mục Đếm phần tử thỏa điều kiện trên đoạn: đánh dấu 11 cho các điểm đạt ngưỡng, dựng mảng tiền tố của dãy đánh dấu đó, rồi mỗi câu hỏi [a,b][a, b] được trả lời bằng một phép trừ.

Gọi M=2⋅105M = 2 \cdot 10^5 là độ dài trục số. Nếu đếm lại từng điểm cho mỗi câu hỏi, cả bài có thể tốn tới Q⋅M=4⋅1010Q \cdot M = 4 \cdot 10^{10} phép tính.

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

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> k >> q;
    for (ll i = 1; i <= n; i++) {
        ll l, r;
        cin >> l >> r;
        diff[l]++;
        diff[r + 1]--;
    }
    ll cover = 0;
    for (ll x = 1; x <= M; x++) {
        cover += diff[x];
        good[x] = good[x - 1] + (cover >= k);
    }
    while (q--) {
        ll a, b;
        cin >> a >> b;
        cout << good[b] - good[a - 1] << '\n';
    }
}
4 3 5
1 5
3 7
4 9
6 10
1 10
3 6
5 8
1 3
7 10

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

VITổng hình chữ nhật: tiền tố hai chiều

Bài toán: cho một bảng số có RR hàng, CC cột, và nhiều câu hỏi. Mỗi câu hỏi tổng các số trong một hình chữ nhật con, từ hàng r1r_1 tới hàng r2r_2 và từ cột c1c_1 tới cột c2c_2.

Gọi P(i,j)P(i, j) là tổng các số trong hình chữ nhật từ ô (1,1)(1, 1) tới ô (i,j)(i, j), và quy ước hàng 00, cột 00 đều bằng 00. Mỗi ô của PP tính được từ ba ô đã có:

P(i,j)=ai,j+P(i−1,j)+P(i,j−1)−P(i−1,j−1).P(i, j) = a_{i,j} + P(i-1, j) + P(i, j-1) - P(i-1, j-1).

Hai hình chữ nhật ứng với P(i−1,j)P(i-1, j) và P(i,j−1)P(i, j-1) cùng chứa phần ứng với P(i−1,j−1)P(i-1, j-1), nên phần đó bị cộng hai lần và phải trừ đi một lần. Tổng của hình chữ nhật cần hỏi cũng tính theo cùng cách nghĩ: lấy hình lớn từ ô (1,1)(1, 1) tới ô (r2,c2)(r_2, c_2), trừ đi hai dải thừa ở phía trên và bên trái, rồi cộng lại phần góc trên bên trái bị trừ hai lần:

S=P(r2,c2)−P(r1−1,c2)−P(r2,c1−1)+P(r1−1,c1−1).S = P(r_2, c_2) - P(r_1 - 1, c_2) - P(r_2, c_1 - 1) + P(r_1 - 1, c_1 - 1).

Cách cộng trừ như vậy được gọi là bao hàm loại trừ (inclusion-exclusion). Bạn có thể kéo chuột trên bảng để chọn hình chữ nhật khác:

Kéo chuột trên bảng để chọn hình chữ nhật.

Dựng bảng PP tốn O(RC)O(RC), sau đó mỗi câu hỏi chỉ tốn O(1)O(1).

Hai vòng lặp dựng bảng đổi chỗ cho nhau thì công thức vẫn đúng, và độ phức tạp vẫn là O(RC)O(RC). Tuy nhiên tốc độ thật thì khác: mảng hai chiều trong C++ được lưu liền nhau theo từng hàng, nên để cột jj ở vòng trong là đọc các ô nằm sát nhau trong bộ nhớ, còn để hàng ii ở vòng trong thì mỗi bước phải nhảy qua cả một hàng.

Dựng tiền tố hai chiều 3000 × 3000.theo hàng (i ngoài, j trong)39 mstheo cột (j ngoài, i trong)107 ms · gấp 2,8 lần
Dựng tiền tố hai chiều trên bảng 3000 × 3000 theo hai thứ tự vòng lặp.

Cùng 9⋅1069 \cdot 10^6 phép tính, nhưng cách để hàng ở vòng trong chậm hơn gần ba lần. Quy tắc dễ nhớ: chỉ số cuối cùng của mảng nên nằm ở vòng lặp trong cùng.

Sử dụng kiến thức này cũng có thể giải bài: cộng xx vào nhiều hình chữ nhật rồi in cả bảng, bằng mảng hiệu hai chiều. Mỗi hình chữ nhật chỉ sửa bốn ô: cộng xx tại (r1,c1)(r_1, c_1) và (r2+1,c2+1)(r_2 + 1, c_2 + 1), trừ xx tại (r1,c2+1)(r_1, c_2 + 1) và (r2+1,c1)(r_2 + 1, c_1). Cuối cùng, ta lấy tiền tố hai chiều của cả bảng một lần.

Ví dụĐếm Cây Trong Hình Chữ Nhật

Đề. Cho lưới n×nn \times n (n≤1000n \le 1000), ô * có cây, ô . trống. Có q≤2⋅105q \le 2 \cdot 10^5 câu hỏi, mỗi câu hỏi cho góc trên bên trái và góc dưới bên phải của một hình chữ nhật, hỏi có bao nhiêu cây bên trong.

Lời giải. Coi ô có cây là số 11, ô trống là số 00, dựng bảng PP theo công thức trên rồi trả lời mỗi câu hỏi bằng bốn góc. Cần chú ý khi đặt tên biến cho tọa độ góc: không nên dùng tên y1, vì thư viện <cmath> đã có một hàm trùng tên, khai báo biến toàn cục tên y1 sẽ gây lỗi biên dịch.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1005;
ll n, q, p[N][N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> q;
    for (ll i = 1; i <= n; i++) {
        string s;
        cin >> s;
        for (ll j = 1; j <= n; j++) p[i][j] = (s[j - 1] == '*') + p[i - 1][j] + p[i][j - 1] - p[i - 1][j - 1];
    }
    while (q--) {
        ll r1, c1, r2, c2;
        cin >> r1 >> c1 >> r2 >> c2;
        cout << p[r2][c2] - p[r1 - 1][c2] - p[r2][c1 - 1] + p[r1 - 1][c1 - 1] << '\n';
    }
}
5 5
.*..*
**...
..*..
*..**
...*.
1 1 1 5
1 1 5 5
2 2 4 4
3 3 3 3
4 4 5 5

Độ phức tạp: O(n2+q)O(n^2 + q), với n=1000n = 1000 và q=2⋅105q = 2 \cdot 10^5 cần khoảng 1,2⋅1061{,}2 \cdot 10^6 phép tính.

Bài nâng cao

Xem 2 bài nâng cao

Ví dụCác Lệnh Theo Nhóm

Đề. Cho dãy a1,a2,…,ana_1, a_2, \dots, a_n và mm thao tác; thao tác thứ ii cộng did_i vào mọi số từ vị trí lil_i đến vị trí rir_i. Sau đó có kk lệnh, lệnh (x,y)(x, y) yêu cầu thực hiện mỗi thao tác có chỉ số từ xx đến yy một lần. In dãy sau khi thực hiện mọi lệnh (n,m,k≤105n, m, k \le 10^5, 0≤ai,di≤1050 \le a_i, d_i \le 10^5).

Lời giải. Làm thẳng thì mỗi lệnh gọi tới mm thao tác, mỗi thao tác cộng vào tới nn số, nên cả bài có thể tốn tới 101510^{15} phép tính. Ta tách bài thành hai bước, và mỗi bước là một bài mảng hiệu.

Bước 1: đếm số lần thực hiện mỗi thao tác. Mỗi lệnh (x,y)(x, y) làm số lần thực hiện của các thao tác từ xx đến yy tăng thêm 11. Đây đúng là bài “cộng 11 vào một đoạn”, chỉ khác là đoạn nằm trên dãy các thao tác chứ không nằm trên dãy aa. Dùng một mảng hiệu trên dãy các thao tác rồi cộng dồn, ta biết thao tác thứ ii được thực hiện bao nhiêu lần, gọi là cic_i. Trên ví dụ của đề, ba lệnh (1,2)(1, 2), (2,4)(2, 4), (4,4)(4, 4) cho số lần thực hiện của bốn thao tác là 1,2,1,21, 2, 1, 2.

Bước 2: cộng vào dãy. Thao tác thứ ii được thực hiện cic_i lần, nên tổng cộng nó cộng ci⋅dic_i \cdot d_i vào đoạn [li,ri][l_i, r_i]. Đây lại là mm lần cộng một đoạn, nhưng lần này trên dãy aa, nên ta dùng thêm một mảng hiệu nữa rồi cộng dồn.

Một số có thể được cộng bởi tới 10510^5 thao tác, mỗi thao tác cộng tới 105⋅10510^5 \cdot 10^5, nên kết quả có thể tới 101510^{15} và cần kiểu long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, m, k, a[N], l[N], r[N], d[N], cnt[N], add[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 i = 1; i <= m; i++) cin >> l[i] >> r[i] >> d[i];
    while (k--) {
        ll x, y;
        cin >> x >> y;
        cnt[x]++;
        cnt[y + 1]--;
    }
    ll times = 0;
    for (ll i = 1; i <= m; i++) {
        times += cnt[i];
        add[l[i]] += times * d[i];
        add[r[i] + 1] -= times * d[i];
    }
    ll cur = 0;
    for (ll i = 1; i <= n; i++) {
        cur += add[i];
        cout << a[i] + cur << (i == n ? '\n' : ' ');
    }
}
5 4 3
2 0 1 3 4
1 3 2
2 5 1
4 4 5
3 5 3
1 2
2 4
4 4

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

Ví dụKho Trung Tâm Trên Tuyến Đường

Đề. Trên một con đường có R≤105R \le 10^5 ruộng lúa ở các tọa độ nguyên X1≤X2≤⋯≤XRX_1 \le X_2 \le \dots \le X_R (không quá 10910^9; nhiều ruộng có thể chung một tọa độ). Ta xây một kho ở một tọa độ nguyên trên đường, rồi chọn một số ruộng gửi lúa về kho; mỗi ruộng được chọn tốn chi phí bằng khoảng cách từ ruộng đó tới kho. Tổng chi phí không được vượt quá ngân sách B≤2⋅1015B \le 2 \cdot 10^{15}. Tìm số ruộng nhiều nhất có thể chọn.

Lời giải. Bài này cần ghép ba nhận xét.

Nhận xét 1: các ruộng được chọn nên đứng liền nhau. Giả sử ta đã chọn cc ruộng và đặt kho ở tọa độ hh. Thay các ruộng đó bằng cc ruộng gần hh nhất thì tổng chi phí không tăng. Đi dọc dãy tọa độ đã sắp xếp, khoảng cách tới hh giảm dần rồi tăng dần, nên cc ruộng gần hh nhất là cc ruộng đứng liền nhau trong dãy. Vì vậy ta chỉ cần xét các đoạn ruộng liên tiếp [l,r][l, r].

Nhận xét 2: với một đoạn ruộng [l,r][l, r], kho nên đặt ở ruộng đứng giữa. Gọi m=⌊l+r2⌋m = \lfloor \frac{l + r}{2} \rfloor. Đặt kho tại tọa độ XmX_m cho tổng chi phí nhỏ nhất (xem ô Chứng minh). Khi đó các ruộng từ ll tới m−1m - 1 nằm bên trái kho, các ruộng từ m+1m + 1 tới rr nằm bên phải kho, và tổng chi phí tính được bằng mảng tiền tố của dãy tọa độ:

Xm(m−l)−(prem−1−prel−1)+(prer−prem)−Xm(r−m).X_m (m - l) - (\mathrm{pre}_{m-1} - \mathrm{pre}_{l-1}) + (\mathrm{pre}_r - \mathrm{pre}_m) - X_m (r - m).

Hai số hạng đầu là tổng khoảng cách của các ruộng bên trái, hai số hạng sau là tổng khoảng cách của các ruộng bên phải. Như vậy chi phí nhỏ nhất của một đoạn bất kỳ tính được trong O(1)O(1).

Chứng minh

Xét các ruộng của đoạn [l,r][l, r] và một vị trí đặt kho hh. Dời kho sang phải một đơn vị thì mọi ruộng ở tọa độ không quá hh xa thêm 11, còn mọi ruộng ở tọa độ lớn hơn hh gần thêm 11. Vậy tổng chi phí thay đổi đúng bằng (số ruộng ở tọa độ không quá hh) trừ (số ruộng ở tọa độ lớn hơn hh). Khi hh tăng, lượng thay đổi này chỉ tăng, nên tổng chi phí giảm dần rồi tăng dần, và chỗ nào dời sang hai bên đều không giảm được chi phí là chỗ chi phí nhỏ nhất.

Tại h=Xmh = X_m: dời sang phải, có ít nhất m−l+1m - l + 1 ruộng ở tọa độ không quá hh và nhiều nhất r−mr - m ruộng ở tọa độ lớn hơn hh, mà m−l+1≥r−mm - l + 1 \ge r - m, nên chi phí không giảm. Dời sang trái, lập luận tương tự với ít nhất r−m+1r - m + 1 ruộng ở tọa độ từ hh trở lên và nhiều nhất m−lm - l ruộng ở tọa độ nhỏ hơn hh, mà r−m+1≥m−lr - m + 1 \ge m - l. Vậy XmX_m là một chỗ đặt kho tốt nhất.

Nhận xét 3: cửa sổ trượt. Gọi một đoạn ruộng là đoạn tốt nếu chi phí nhỏ nhất của nó không vượt quá BB. Nếu đoạn [l,r][l, r] là đoạn tốt thì mọi đoạn nằm bên trong nó cũng là đoạn tốt: giữ nguyên chỗ đặt kho mà bớt ruộng thì chi phí không tăng. Đây đúng là tính chất “bớt đi vẫn tốt” của bài 0.7, nên ta dùng cửa sổ trượt: với mỗi rr, tiến ll cho tới khi đoạn [l,r][l, r] là đoạn tốt, rồi cập nhật đáp án bằng r−l+1r - l + 1. Một đoạn chỉ có một ruộng luôn có chi phí 00, nên vòng while luôn dừng trước khi ll vượt rr.

Tổng tọa độ có thể tới 101410^{14} và ngân sách tới 2⋅10152 \cdot 10^{15}, nên mọi biến dùng kiểu long long.

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

ll cost(ll l, ll r) {
    ll m = (l + r) / 2;
    return x[m] * (m - l) - (pre[m - 1] - pre[l - 1]) + (pre[r] - pre[m]) - x[m] * (r - m);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> len >> b;
    for (ll i = 1; i <= n; i++) {
        cin >> x[i];
        pre[i] = pre[i - 1] + x[i];
    }
    ll l = 1, ans = 0;
    for (ll r = 1; r <= n; r++) {
        while (cost(l, r) > b) l++;
        ans = max(ans, r - l + 1);
    }
    cout << ans << '\n';
}
6 30 7
1 3 10 11 14 20

Độ phức tạp: O(R)O(R) vì hai con trỏ chỉ tiến và mỗi lần tính chi phí tốn O(1)O(1); với R=105R = 10^5 cần khoảng 2⋅1052 \cdot 10^5 lần tính chi phí.

Khi đi thi

  • Gặp nhiều câu hỏi về tổng hay số lượng trên các đoạn của một dãy không đổi thì nghĩ ngay tới tổng tiền tố. Nếu dãy bị sửa xen giữa các câu hỏi, mảng tiền tố không còn dùng được.
  • Dựng mảng tiền tố n+1n + 1 ô với pre0=0\mathrm{pre}_0 = 0, dùng kiểu long long, và đưa số dư âm về khoảng từ 00 tới m−1m - 1 trước khi dùng làm chỉ số.
  • Bài đếm đoạn: biến đổi điều kiện để một vế chỉ chứa những gì của rr, vế kia chỉ chứa những gì của l−1l - 1, rồi đếm bằng bảng đếm khi duyệt rr. Nhớ tra bảng trước rồi mới thêm, và thử một test có k=0k = 0.

Tóm tắt

  • prei=prei−1+ai\mathrm{pre}_i = \mathrm{pre}_{i-1} + a_i với pre0=0\mathrm{pre}_0 = 0; tổng đoạn [l,r][l, r] là prer−prel−1\mathrm{pre}_r - \mathrm{pre}_{l-1}. Dựng mảng tốn O(n)O(n), mỗi câu hỏi tốn O(1)O(1).
  • Đếm phần tử thỏa điều kiện trên đoạn: đánh dấu 11, 00 rồi lấy tiền tố. Phép toán có phép ngược (cộng, XOR) dùng được tiền tố; min, max thì không.
  • Đếm đoạn thỏa điều kiện: mỗi đoạn [l,r][l, r] ứng với một cặp tiền tố (l−1,r)(l - 1, r). Duyệt rr, tra bảng đếm trước rồi mới thêm prer\mathrm{pre}_r, và nhớ có sẵn pre0\mathrm{pre}_0 trong bảng.
  • Mảng hiệu là phép ngược của tiền tố: cộng một đoạn bằng hai phép sửa, cộng dồn một lần ở cuối, khi mọi thao tác cộng đến trước mọi câu hỏi.
  • Tiền tố hai chiều dựng và hỏi bằng bao hàm loại trừ trên bốn góc.

Bài tập

Bài tập ngoài

Tiền tố XOR

IIIXOR WorldAtCoder

Đọc thêm