Cho một dãy số và một câu hỏi: tổng các số từ vị trí đến vị trí là bao nhiêu? Một câu thì cộng lại là xong. Nhưng đề thường hỏi hàng trăm nghìn câu trên cùng một dãy, và cộng lại từ đầu cho mỗi câu thì quá chậm.
Tổng tiền tố (prefix sum) chuẩn bị sẵn tổng của mọi đoạn bắt đầu từ đầu dãy. Có những tổng đó rồi, mỗi câu hỏi chỉ còn một phép trừ. Ý tưởng ngắn này là nền của mảng hiệu, tiền tố hai chiều, và về sau là cây Fenwick, cây phân đoạn.
Tổng đoạn bằng một phép trừ
Cộng từng số trong đoạn tốn tới phép cộng cho một câu hỏi. Với , cả bài cỡ phép cộng, quá xa giới hạn một giây.
Đánh số dãy từ 1: , và định nghĩa
Mỗi là tổng của tiền tố dài . Tổng đoạn là tiền tố dài bỏ đi tiền tố dài :
Hai tiền tố có chung phần , phép trừ xóa đúng phần đó. Ô không thừa: nhờ nó, đoạn bắt đầu từ vẫn dùng chung công thức.
Với thì . Tổng đoạn là , đúng bằng .
Hai lỗi hay gặp nằm ngay trong công thức ngắn này. Một là kiểu dữ liệu: mỗi vừa int, nhưng tổng số cỡ lên tới , nên phải là long long. Hai là chỉ số: đọc dãy vào a[0..n-1] mà vẫn viết pre[r] - pre[l - 1] thì mọi câu trả lời lệch một vị trí. Cách an toàn nhất: cho có ô và luôn hiểu là tổng phần tử đầu.
Hỏi nhanh
Dãy được đọc vào a[0], a[1], ..., a[n-1] (đánh số từ 0), và ta dựng pre[i] = a[0] + ... + a[i-1] với pre[0] = 0. Câu hỏi cũng dùng chỉ số từ 0: tổng a[l] + ... + a[r]. Biểu thức nào đúng?
Với cách dựng này, pre[k] là tổng của k phần tử đầu, tức a[0..k-1]. Đoạn a[l..r] là r + 1 phần tử đầu bỏ đi l phần tử đầu, nên tổng là pre[r + 1] - pre[l].
pre[r] - pre[l] bỏ mất a[r]; pre[r] - pre[l - 1] là công thức của cách đánh số từ 1, đem sang đây thì lệch một vị trí và còn đọc pre[-1] khi l = 0; pre[r + 1] - pre[l - 1] thừa a[l - 1]. Chọn một quy ước rồi giữ nó trong cả chương trình là cách tránh lỗi này tốt nhất.
Ví dụTổng Trên Đoạn Cố Định
Đề. Dãy số nguyên dương không đổi, truy vấn , in tổng đoạn. .
Lời giải. Đúng bài toán của mục này. Dựng khi đọc dãy, mỗi truy vấn in . Cả bài , tổng lớn nhất cỡ nên dùng long long.
#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
4
13
15
28
XOR và đếm
Công thức chỉ cần hai điều ở phép cộng: kết hợp được, và có phép ngược để gỡ phần đầu ra. Phép toán nào có đủ hai điều đó cũng dựng tiền tố được.
- XOR. Phép XOR, viết và trong C++ là
x ^ y, so hai số theo từng chữ số nhị phân: hai chữ số khác nhau cho 1, giống nhau cho 0. Chẳng hạn . Đặt . Vì , các phần tử trước xuất hiện hai lần trong và tự triệt tiêu, nên XOR đoạn là . - Đếm. Muốn biết đoạn có bao nhiêu phần tử thỏa một tính chất (số chẵn, chữ
a, ô có cây), đánh dấu nếu phần tử thứ thỏa, nếu không, rồi lấy tiền tố của . Với xâu chữ thường, 26 mảng tiền tố cho số lần xuất hiện của từng chữ trong mọi đoạn.
Phép min và max thì không gỡ được: biết số nhỏ nhất của và của không cho biết số nhỏ nhất của . Với các phép này cần công cụ khác: bảng thưa (bài 22) khi dãy không đổi, cây phân đoạn (bài 17) khi dãy bị sửa. Tiền tố của min vẫn có ích khi đoạn luôn bắt đầu từ 1, ví dụ “giá trị nhỏ nhất trong phần tử đầu”.
Hỏi nhanh
Bài nào không giải được bằng một mảng tiền tố dựng sẵn rồi trả lời mỗi truy vấn trong ? (Dãy không thay đổi giữa các truy vấn.)
Đếm số chẵn và đếm chữ c là tiền tố của dãy 0/1; XOR có phép ngược là chính nó. Phép max không có phép ngược: có thể nằm ngay trong phần mà ta cần bỏ đi, và khi đó nó không nói gì về đoạn . Truy vấn max trên dãy cố định dùng bảng thưa.
Ví dụĐếm Cặp AC Trên Đoạn
Đề. Chuỗi dài gồm A, C, G, T. Mỗi truy vấn hỏi có bao nhiêu chỉ số với mà là AC.
Lời giải. Một cặp AC chiếm hai ô nên phải chọn gắn nó với ô nào. Gắn với ô bên phải: đặt nếu là AC. Cặp nằm trọn trong khi ô phải của nó thuộc , vậy đáp án là , không phải . Viết nhầm thành sẽ đếm cả cặp vắt qua biên trái, có ô A ở vị trí .
Trên ví dụ AACACGACCT, các cặp có ô phải ở 3, 5, 8. Truy vấn lấy hai ô 3 và 5; truy vấn không lấy ô 8, vì A ở vị trí 7 nằm ngoài đoạn.
#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
3
2
1
1
0
Đếm đoạn con
Nhiều bài hỏi “có bao nhiêu đoạn con thỏa…”. Có đoạn, duyệt hết là . Mẹo là viết điều kiện của đoạn thành điều kiện trên hai số và , rồi cố định đầu phải và đếm xem có bao nhiêu chỉ số ghép được với nó.
Bài kinh điển: đếm đoạn có tổng bằng . Điều kiện tương đương . Duyệt từ trái sang phải, giữ bảng đếm cnt các giá trị đã gặp; mỗi đầu phải góp cnt[pre[r] - k] đoạn. Bảng đếm có sẵn giá trị với số lần 1, chính là tiền tố rỗng ứng với các đoạn bắt đầu từ .
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]++;
}
Thứ tự hai dòng cuối trong vòng lặp quan trọng: phải cộng cnt[cur - k] trước khi thêm cur của chính đầu phải này, nếu không với ta đếm cả đoạn rỗng .
map (bài 09) đếm được theo giá trị bất kỳ, kể cả số âm hay số rất lớn, đổi lại mỗi bước chậm hơn mảng. Khi tiền tố chỉ nhận ít giá trị, ví dụ phần dư khi chia cho , hãy dùng mảng đếm. Chênh lệch lớn hơn ta tưởng:
Cùng một vòng lặp, chỉ khác bảng đếm: map chậm hơn mảng gần 30 lần, unordered_map khoảng 8 lần. Với cả ba đều qua, nhưng khi giới hạn chặt, đó là khác biệt giữa AC và TLE.
Cùng khuôn đó giải được nhiều điều kiện khác:
- Tổng đoạn chia hết cho : và cùng số dư khi chia cho , đếm cặp cùng phần dư.
- Tổng đoạn bằng độ dài đoạn: , đếm cặp cùng giá trị .
- Tổng đoạn lớn nhất: với mỗi , lấy trừ tiền tố nhỏ nhất đứng trước nó; bảng đếm thay bằng một biến giữ tiền tố nhỏ nhất.
Bước khó nhất luôn là biến đổi để một vế chỉ chứa , vế kia chỉ chứa .
Hình dưới chạy trường hợp chia hết trên dữ liệu của ví dụ tiếp theo. Mỗi bước thêm một tiền tố và ghi phần dư của nó khi chia cho 6; cung nối nó với các tiền tố trước cùng dư.
Hỏi nhanh
Đếm các đoạn có tổng bằng độ dài . Đặt . Điều kiện của đoạn tương đương với điều gì?
Tổng đoạn bằng độ dài nghĩa là . Chuyển vế: , tức . Đáp án quên rằng cặp tiền tố của đoạn là chứ không phải ; hai đáp án còn lại là chuyển vế sai. Khi đã có dạng , bài toán thành đếm cặp bằng nhau, giải bằng bảng đếm như trên.
Ví dụĐếm Đoạn Có Tổng Chia Hết
Đề. Dãy số nguyên, , có thể âm. Đếm số đoạn con có tổng chia hết cho .
Lời giải. Đoạn thỏa khi và chỉ khi chia hết cho , tức hai tiền tố có cùng số dư khi chia cho , nên mỗi cặp tiền tố cùng phần dư ứng với đúng một đoạn. Duyệt , giữ cnt[d] là số tiền tố đứng trước có phần dư , cộng cnt của phần dư hiện tại vào đáp án rồi mới tăng nó. Phần dư chỉ có giá trị nên dùng mảng, cả bài .
Chỗ dễ sai là số âm: trong C++, -7 % 6 bằng -1 chứ không phải 5, nên phải đưa phần dư về khoảng từ tới bằng ((x % n) + n) % n, nếu không hai tiền tố cùng số dư rơi vào hai ô khác nhau. Đáp án có thể tới , cần long long.
Trên ví dụ, tiền tố là , phần dư theo 6 là . Ba tiền tố dư 0 (vị trí 0, 5, 6) cho ba cặp, ứng với các đoạn , , .
#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
3
Mảng hiệu
Bài toán ngược lại: dãy ban đầu toàn 0, có thao tác “cộng vào mọi phần tử từ đến ”, cuối cùng in dãy. Làm thẳng tốn mỗi thao tác. Mảng hiệu (difference array) làm mỗi thao tác chỉ bằng hai phép sửa:
Sau mọi thao tác, , tức là tổng tiền tố của . Một thao tác trên làm tổng tăng khi rồi giảm lại khi , nên đúng các vị trí được cộng . Mảng hiệu và tổng tiền tố là hai phép ngược của nhau.
#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
-1 1 1 5 5 3 3 0
Mảng diff phải có ô vì thao tác có sửa tới đó. Kỹ thuật này chỉ dùng được khi mọi thao tác đến trước mọi câu hỏi. Nếu cộng đoạn và hỏi xen kẽ, cần cây Fenwick hoặc cây phân đoạn có cập nhật đoạn.
Mảng hiệu hay xuất hiện ở dạng độ phủ: có nhiều đoạn trên trục số, hỏi mỗi điểm nằm trong bao nhiêu đoạn. Mỗi đoạn cộng 1 lên , một lượt cộng dồn cho độ phủ của mọi điểm.
Ví dụĐiểm Đạt Ngưỡng
Đề. Trên trục có đoạn. Một điểm nguyên đạt ngưỡng nếu nằm trong ít nhất đoạn. truy vấn hỏi đoạn đó chứa bao nhiêu điểm đạt ngưỡng. .
Lời giải. Hai tầng tiền tố. Tầng một là mảng hiệu cho độ phủ: mỗi đoạn tăng diff[l], giảm diff[r + 1], cộng dồn được số đoạn chứa từng điểm . Tầng hai là tiền tố của dãy đánh dấu “điểm đạt ngưỡng”, gọi là good. Truy vấn trả lời bằng good[b] - good[a - 1]. Gọi là độ dài trục, cả bài ; đếm lại từng điểm cho mỗi truy vấn thì tốn tới .
#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
4
3
3
0
1
Tiền tố hai chiều
Trên bảng , đặt là tổng hình chữ nhật từ ô tới ô , với hàng 0 và cột 0 bằng 0. Mỗi ô tính từ ba ô đã có:
Hai hình và cùng chứa phần , nên phần đó bị cộng hai lần và phải trừ đi một lần. Tổng hình chữ nhật từ hàng tới , cột tới ghép theo cùng cách nghĩ, gọi là bao hàm loại trừ: lấy hình lớn, trừ hai dải thừa, rồi cộng lại góc bị trừ hai lần:
Dựng bảng tốn , mỗi câu hỏi . Mảng hiệu cũng có bản hai chiều: cộng lên một hình chữ nhật bằng cách sửa bốn góc, tại và , tại và , rồi lấy tiền tố hai chiều một lần ở cuối.
Hai vòng lặp dựng bảng đổi chỗ cho nhau thì công thức vẫn đúng, độ phức tạp vẫn . Tốc độ thật thì khác: mảng hai chiều trong C++ nằm liền nhau theo từng hàng, nên để cột ở vòng trong là đọc các ô sát nhau, còn để hàng ở vòng trong là mỗi bước nhảy qua cả một hàng.
Cùng phép tính mà chậm hơn khoảng hai lần rưỡi. Quy tắc dễ nhớ: chỉ số cuối của mảng nằm ở vòng lặp trong cùng.
Ví dụĐếm Cây Trong Hình Chữ Nhật
Đề. Lưới (), ô * có cây. truy vấn, mỗi truy vấn cho góc trên-trái và góc dưới-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à 1, ô trống là 0, dựng theo công thức trên rồi trả lời bằng bốn góc. Dựng , mỗi truy vấn . Tên biến cho góc tránh y1: trong <cmath> đã có hàm tên đó, khai báo biến toàn cục trùng tên sẽ lỗi biên dịch.
#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
2
9
3
1
3
Khi đi thi
- Thấy nhiều câu hỏi về đoạn trên dãy không đổi là nghĩ ngay tới tiền tố; nếu dãy bị sửa xen giữa các câu hỏi thì chuyển sang cây Fenwick hoặc cây phân đoạn.
- Viết có ô với , dùng
long long, và đưa phần dư âm về khoảng từ tới trước khi dùng làm chỉ số. - Bài đếm đoạn con: biến đổi điều kiện để một vế chỉ chứa và , vế kia chỉ chứa và , rồi đếm bằng bảng đếm khi duyệt đầu phải.
Tóm tắt
- , ; tổng đoạn là , dựng , hỏi .
- Phép toán kết hợp có phép ngược (cộng, XOR, đếm) đều dựng tiền tố được; min, max thì không.
- Đếm đoạn con thỏa điều kiện: cố định đầu phải, đếm các tiền tố trước đó ghép được, nhớ đưa tiền tố rỗng vào từ đầu.
- 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.
- Bản 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
Đọc thêm
- Mảng cộng dồn và mảng hiệuVNOI Wiki
- Prefix SumsUSACO Guide
- More on Prefix SumsUSACO Guide
- 前缀和 & 差分OI Wiki
- Competitive Programmer’s Handbook, chương 9