Cho dãy số và câu hỏi, mỗi câu hỏi tổng các số từ vị trí đến vị trí . Trả lời mọi câu hỏi trong .
Đâ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 số đầu tiên, số đầu tiên, và cứ thế tới 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ừ : . Đoạn gồm các vị trí từ tới được viết là .
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ừ tới :
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: , với cần khoảng phép tính.
Mỗi câu hỏi có thể phải cộng tới số, nên cả bài vượt xa khoảng 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 và cùng chứa , 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 số đầu tiên của dãy là tiền tố dài , và gọi tổng của chúng là :
Mảng được gọi là mảng tiền tố (hay mảng cộng dồn). Nhờ công thức , ta dựng được cả mảng trong một lượt duyệt. Tiền tố dài gồm tiền tố dài và đoạn đứng nối tiếp nhau, nên tổng của đoạn bằng hiệu hai tổng tiền tố:
Ô 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ừ .
Ví dụ với dãy , mảng tiền tố là . Tổng đoạn là , đúng bằng . Bạn có thể kéo hai đầu , để thử với các đoạn khác:
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 có thể vừa kiểu int, nhưng tổng của số cỡ lên tới , 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 , 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ố ô và luôn hiểu là tổng của 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ừ , và dựng pre[i] = a[0] + ... + a[i - 1] với pre[0] = 0. Câu hỏi cũng đánh số từ : tính tổng a[l] + ... + a[r].
Biểu thức nào cho kết quả đúng?
Với cách dựng này, pre[k] là tổng của k số đầu tiên, tức là từ a[0] tới a[k - 1]. Đoạn từ a[l] tới a[r] là r + 1 số đầu tiên bỏ đi l số đầu tiên, nên tổng là pre[r + 1] - pre[l].
Biểu thức pre[r] - pre[l] thiếu mất a[r]. Biểu thức pre[r] - pre[l - 1] là công thức của cách đánh số từ ; đem sang đây thì lệch một vị trí, và còn đọc pre[-1] khi l = 0. Biểu thức pre[r + 1] - pre[l - 1] thừa a[l - 1]. Chọn một cách đánh số rồi giữ nguyên trong cả chương trình là cách tốt nhất để tránh lỗi này.
Ví dụTổng Trên Đoạn Cố Định
Đề. Cho dãy số nguyên dương () và câu hỏi; mỗi câu hỏi cho và hỏi tổng các số từ vị trí đến vị trí ().
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 . Tổng lớn nhất có thể tới , nên cần dùng kiểu 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
Độ phức tạp: , với cần khoảng 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 số và nhiều câu hỏi, mỗi câu hỏi đoạn 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 nếu là số chẵn và nếu là số lẻ. Khi đó số số chẵn trong đoạn chính là tổng , nên ta chỉ cần dựng mảng tiền tố của dãy .
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 mảng tiền tố, mỗi mảng cho một chữ cái.
Ví dụĐếm Cặp AC Trên Đoạn
Đề. Cho chuỗi dài chỉ gồm các chữ A, C, G, T, và câu hỏi. Mỗi câu hỏi cho và hỏi có bao nhiêu vị trí với mà hai ký tự 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: nếu là AC. Một cặp nằm trọn trong đoạn khi chữ C của nó nằm ở vị trí từ tới , nên đáp án là chứ không phải . Nếu viết , ta sẽ đếm thừa cặp có chữ A ở vị trí , nằm ngoài đoạn.
Trên chuỗi AACACGACCT, chữ C của các cặp AC nằm ở vị trí . Câu hỏi đếm được hai cặp, ứng với vị trí và . Câu hỏi không đếm cặp nào: cặp có chữ C ở vị trí có chữ A ở vị trí , 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
Độ phức tạp: , với cần khoảng phép tính.
IIIPhép toán nào dùng được tiền tố
Công thức đúng nhờ hai điều. Thứ nhất, tổng của tiền tố dài ghép được từ tổng của tiền tố dài và tổng của đoạn . 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 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à và trong C++ là x ^ y, so hai số theo từng chữ số nhị phân: hai chữ số khác nhau cho , giống nhau cho . Ví dụ . Vì , phép XOR là phép ngược của chính nó. Đặt và . Trong , mỗi số đứng trước vị trí xuất hiện hai lần và tự triệt tiêu, nên XOR của đoạn bằng .
Phép min, max. Biết số nhỏ nhất của và số nhỏ nhất của không cho ta biết số nhỏ nhất của đoạn , vì số nhỏ nhất của tiền tố dài 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í , chẳng hạn “số nhỏ nhất trong 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 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ệ?
Đếm số chẵn và đếm chữ c là tính tổng đoạn của một dãy đánh dấu , . Phép XOR có phép ngược là chính nó. Phép max thì không có phép ngược: số lớn nhất của tiền tố dài có thể nằm ngay trong phần cần bỏ đi, và khi đó nó không cho biết gì về đoạn .
Sử dụng kiến thức này cũng có thể giải bài: với mỗi vị trí , tính ước chung lớn nhất của các số còn lại khi bỏ đi riêng . 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í là ước chung lớn nhất của hai số: một số của tiền tố , một số của hậu tố .
IVĐếm đoạn có tổng bằng k
Bài toán: cho dãy số nguyên, có thể âm, và một số . Đếm số đoạn có tổng bằng .
Dãy có đ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 , khoảng phép tính khi .
Viết điều kiện bằng mảng tiền tố: đoạn có tổng bằng khi , tức là . Đặt , mỗi đoạn ứng với đúng một cặp chỉ số (với có thể bằng ) thỏa , 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 từ tới và giữ một bảng đếm: với mỗi giá trị , bảng ghi có bao nhiêu chỉ số mà . Tại mỗi , số đoạn kết thúc tại có tổng bằng chính là số mà bảng ghi cho giá trị . Sau đó ta thêm vào bảng để dùng cho các phía sau. Lúc bắt đầu, bảng đã có sẵn giá trị một lần, ứng với các đoạn bắt đầu từ .
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:
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: vì mỗi lần dùng map tốn ; với cần khoảng 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 , 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 và :
#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
6
Dãy chỉ có đoạn có tổng bằng : đoạn , đoạn chỉ gồm số , và cả dãy. Vì sao chương trình in ra ?
Với , chương trình tra đúng ô vừa được tăng ở dòng ngay trên. Vì vậy ở mỗi bước, nó đếm thêm cặp , tức là đoạn không có số nào. Dãy có vị trí nên chương trình đếm thừa đoạn, in ra . Cách sửa là tra bảng trước, rồi mới thêm tiền tố của vị trí đang xét, như code ở trên.
Lỗi này chỉ lộ ra khi : với , ô được tra khác ô vừa tăng, nên chương trình vẫn in đúng. Vì vậy khi kiểm một bài đếm đoạn, nên thử thêm một test có . Các lựa chọn còn lại không đúng: dòng cnt[0] = 1 là cần thiết cho các đoạn bắt đầu từ vị trí , còn map đếm đúng với mọi chỉ số, kể cả số âm và số .
Khi tiền tố chỉ nhận ít giá trị, chẳng hạn phần dư khi chia cho một số 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:
Cùng một vòng lặp, chỉ khác bảng đếm, mà map chậm hơn mảng khoảng lần, còn unordered_map chậm hơn khoảng lần. Với , 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 số nguyên với , có thể âm. Đếm số đoạn có tổng chia hết cho .
Lời giải. Đoạn có tổng chia hết cho khi chia hết cho , tức là hai tiền tố và có cùng số dư khi chia cho . 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 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 , cộng số tiền tố có cùng số dư với vào đáp án, rồi mới thêm vào mảng đếm. Số dư chỉ có giá trị nên dùng mảng được, và cả bài tố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ừ tới 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 cần dùng kiểu long long cho kết quả.
Trên ví dụ của đề, các tiền tố là , và số dư của chúng khi chia cho là . Ba tiền tố có số dư , ở các vị trí , tạo thành ba cặp, ứng với ba đoạn , và . 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 :
#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
Độ phức tạp: , với cần khoảng 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í , vế còn lại chỉ chứa những gì của vị trí . 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 ProSử 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 , đoạn có tổng lớn nhất kết thúc tại có tổng bằng trừ đi tiền tố nhỏ nhất trong . 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 ban đầu toàn số . Có thao tác, mỗi thao tác cộng vào mọi số từ vị trí đến vị trí . 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 phép cộng, nên cả bài tốn . Ta có thể làm mỗi thao tác chỉ bằng hai phép sửa. Giữ một mảng , ban đầu toàn số , gọi là mảng hiệu (difference array). Mỗi thao tác cộng vào đoạn chỉ sửa hai ô của mảng này:
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: . Tên gọi mảng hiệu đến từ việc : 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 vào đoạn và một vị trí . Tổng chứa ô khi , và chứa ô khi . Vì vậy thao tác này góp vào tổng đó:
- khi ;
- khi ;
- khi .
Như vậy đúng các vị trí trong đoạn được cộng thêm . 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.
#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
Độ phức tạp: , với cần khoảng phép tính.
Mảng hiệu cần có ô thứ , vì thao tác có 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 ProMả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à một thao tác cộng vào các điểm từ tới , 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ó đoạn với hai đầu mút là số nguyên từ tới . Một điểm nguyên gọi là đạt ngưỡng nếu nó nằm trong ít nhất đoạn. Có câu hỏi, mỗi câu hỏi cho và hỏi đoạn chứa bao nhiêu điểm đạt ngưỡng ().
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 , 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 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 được trả lời bằng một phép trừ.
Gọi 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 phép tính.
#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
Độ phức tạp: , với cần khoảng 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ó hàng, 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 tới hàng và từ cột tới cột .
Gọi là tổng các số trong hình chữ nhật từ ô tới ô , và quy ước hàng , cột đều bằng . Mỗi ô của tính được từ ba ô đã có:
Hai hình chữ nhật ứng với và cùng chứa phần ứng với , 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ừ ô tới ô , 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:
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:
Dựng bảng tốn , sau đó mỗi câu hỏi chỉ tốn .
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à . 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 ở vòng trong là đọc các ô nằm sát nhau trong bộ nhớ, còn để hàng ở vòng trong thì mỗi bước phải nhảy qua cả một hàng.
Cùng 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 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 tại và , trừ tại và . 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 (), ô * có cây, ô . trống. Có 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ố , ô trống là số , dựng bảng 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.
#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
Độ phức tạp: , với và cần khoảng 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 và thao tác; thao tác thứ cộng vào mọi số từ vị trí đến vị trí . Sau đó có lệnh, lệnh yêu cầu thực hiện mỗi thao tác có chỉ số từ đến một lần. In dãy sau khi thực hiện mọi lệnh (, ).
Lời giải. Làm thẳng thì mỗi lệnh gọi tới thao tác, mỗi thao tác cộng vào tới số, nên cả bài có thể tốn tới 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 làm số lần thực hiện của các thao tác từ đến tăng thêm . Đây đúng là bài “cộng 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 . 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ứ được thực hiện bao nhiêu lần, gọi là . Trên ví dụ của đề, ba lệnh , , cho số lần thực hiện của bốn thao tác là .
Bước 2: cộng vào dãy. Thao tác thứ được thực hiện lần, nên tổng cộng nó cộng vào đoạn . Đây lại là lần cộng một đoạn, nhưng lần này trên dãy , 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 thao tác, mỗi thao tác cộng tới , nên kết quả có thể tới và cần kiểu long long.
#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
4 4 11 16 12
Độ phức tạp: , với cần khoảng phép tính.
Ví dụKho Trung Tâm Trên Tuyến Đường
Đề. Trên một con đường có ruộng lúa ở các tọa độ nguyên (không quá ; 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 . 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 ruộng và đặt kho ở tọa độ . Thay các ruộng đó bằng ruộng gần 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 giảm dần rồi tăng dần, nên ruộng gần nhất là 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 .
Nhận xét 2: với một đoạn ruộng , kho nên đặt ở ruộng đứng giữa. Gọi . Đặt kho tại tọa độ cho tổng chi phí nhỏ nhất (xem ô Chứng minh). Khi đó các ruộng từ tới nằm bên trái kho, các ruộng từ tới 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 độ:
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 .
Chứng minh
Xét các ruộng của đoạn và một vị trí đặt kho . Dời kho sang phải một đơn vị thì mọi ruộng ở tọa độ không quá xa thêm , còn mọi ruộng ở tọa độ lớn hơn gần thêm . Vậy tổng chi phí thay đổi đúng bằng (số ruộng ở tọa độ không quá ) trừ (số ruộng ở tọa độ lớn hơn ). Khi 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 : dời sang phải, có ít nhất ruộng ở tọa độ không quá và nhiều nhất ruộng ở tọa độ lớn hơn , 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 ruộng ở tọa độ từ trở lên và nhiều nhất ruộng ở tọa độ nhỏ hơn , mà . Vậy 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á . Nếu đoạn 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 , tiến cho tới khi đoạn là đoạn tốt, rồi cập nhật đáp án bằng . Một đoạn chỉ có một ruộng luôn có chi phí , nên vòng while luôn dừng trước khi vượt .
Tổng tọa độ có thể tới và ngân sách tới , nên mọi biến dùng kiểu long long.
#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
3
Độ phức tạp: vì hai con trỏ chỉ tiến và mỗi lần tính chi phí tốn ; với cần khoảng 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ố ô với , dùng kiểu
long long, và đưa số dư âm về khoảng từ tới 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 , vế kia chỉ chứa những gì của , rồi đếm bằng bảng đếm khi duyệt . Nhớ tra bảng trước rồi mới thêm, và thử một test có .
Tóm tắt
- với ; tổng đoạn là . Dựng mảng tốn , mỗi câu hỏi tốn .
- Đếm phần tử thỏa điều kiện trên đoạn: đánh dấu , 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 ứng với một cặp tiền tố . Duyệt , tra bảng đếm trước rồi mới thêm , và nhớ có sẵn 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
Tổng đoạn
IKuriyama Mirai's StonesCodeforcesTiền tố XOR
IIIXOR WorldAtCoderMảng hiệu
VRange Update QueriesCSESĐọ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