Cho một dãy số và câu hỏi, mỗi câu hỏi tổng các phần tử từ vị trí đến vị trí . Cộng lại từng đoạn mất tới phép cộng cho một câu, cả bài là ; với con số này là , quá xa giới hạn một giây. Tổng tiền tố (prefix sum) trả lời mỗi câu bằng đúng một phép trừ, sau một lượt chuẩn bị . Ý tưởng chỉ một dòng, nhưng nó là nền của mảng hiệu, tổng hai chiều, đếm đoạn con, và về sau là cây Fenwick, cây phân đoạn.
Tổng của một đoạn bằng hiệu hai tiền tố
Đánh số dãy từ 1: . Đị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 :
Đẳng thức đúng vì gồm và ; phép trừ xóa đúng phần đầu. Ô không thừa: nhờ nó, đoạn bắt đầu từ vẫn dùng chung công thức .
Với , mảng là . Tổng đoạn là , đúng bằng .
Hai chỗ hay sai nằm ngay trong công thức ngắn này. Thứ nhất là kiểu dữ liệu: mỗi vừa int, nhưng tổng của số cỡ lên tới , nên phải là long long. Thứ 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 là để 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;
using 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
Không chỉ là phép cộng
Công thức chỉ dùng hai tính chất của 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 tính chất đó 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, ký tự
a, ô có cây), dựng tiền tố của dãy đánh dấu , bằng 1 nếu phần tử thứ thỏa và 0 nếu không. Với chuỗi chữ thường, 26 mảng tiền tố cho tần suất 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;
using 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: chuyển về cặp tiền tố
Rất 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ó.
Ví dụ 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) là bảng đếm 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 ) thì dùng mảng đếm.
Cùng khuôn đó giải được nhiều điều kiện khác. Tổng đoạn chia hết cho khi và có 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 khi : đế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ó, rồi lấy lớn nhất qua mọi ; thay bảng đếm 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ỏ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;
using 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: cộng cả đoạn chỉ bằng hai phép sửa
Bài toán ngược: 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 trong :
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;
using 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;
using 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
Tổng 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.
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;
using 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 WikiBài tiếng Việt đầy đủ, nhiều ví dụ thi trong nước.
- Prefix SumsUSACO GuideLộ trình bài tập từ dễ tới khó, có lời giải từng bài.
- More on Prefix SumsUSACO GuideTiền tố hai chiều, mảng hiệu, đếm đoạn theo phần dư.
- 前缀和 & 差分OI WikiTrình bày gọn, có cả tiền tố trên cây và tiền tố nhiều chiều.
- Competitive Programmer’s Handbook, chương 9Truy vấn đoạn từ tiền tố tới bảng thưa và cây Fenwick, viết rất ngắn gọn.