Cho dãy số và thao tác xen kẽ nhau: đổi một phần tử, hoặc hỏi tổng của một đoạn. Nếu dãy đứng yên thì tổng tiền tố (bài 02) trả lời mỗi câu hỏi trong . Nhưng chỉ cần một phần tử bị sửa, mọi tiền tố đứng sau nó đều sai và dựng lại mất ; với , đó là cỡ phép tính. Cây Fenwick (Fenwick tree, còn gọi là cây chỉ số nhị phân, Binary Indexed Tree, BIT) làm cả hai việc, sửa một phần tử và hỏi một tiền tố, trong , tức khoảng 18 bước.
Cấu trúc chỉ là một mảng số cùng hai vòng lặp ba dòng, nhưng cách nó chia dãy thành các khúc dựa hoàn toàn vào cách viết nhị phân của chỉ số. Cây này cũng là công cụ đếm quen thuộc của các bài nghịch thế, đếm cặp và xếp hạng, nên bài dạy cả phần đếm đó.
Mỗi ô giữ một khúc dài lowbit
Ta cần một mảng hỗ trợ hai thao tác: update(pos, val) tăng thêm (có thể âm), và get(pos) trả về , bằng 0 khi . Tổng đoạn là ; phép gán là update(k, u - a[k]). Có hai cách làm hiển nhiên, mỗi cách chậm một đầu: giữ nguyên mảng thì sửa nhưng hỏi tiền tố ; giữ mảng tổng tiền tố thì hỏi nhưng sửa một phần tử làm mọi tiền tố phía sau đổi theo, . Cây Fenwick chọn lối ở giữa: mỗi ô giữ tổng của một khúc, độ dài các khúc là , sao cho mỗi tiền tố ghép được từ ít khúc và mỗi phần tử nằm trong ít khúc, cả hai con số đều .
Với số nguyên dương , là giá trị của bit 1 thấp nhất trong cách viết nhị phân của : có , có , có . Trong C++ đó là i & -i (bài 14). Cây Fenwick là mảng (trong code là bit[1..n]), với
tức tổng của phần tử kết thúc tại . Chỉ số lẻ có . Còn , , , .
Hỏi nhanh
Cây Fenwick có . Ô giữ tổng của những phần tử nào? (Biết .)
Bit 1 thấp nhất của có giá trị 8, nên và gồm 8 phần tử kết thúc tại 40, tức .
Đáp án đầu nhầm với tổng tiền tố; trong cây chỉ những ô có chỉ số là lũy thừa của 2 mới giữ cả tiền tố. Đáp án có 9 phần tử vì tính khúc là , trong khi khúc đúng là , mở ở đầu trái. Đáp án cuối lấy vị trí của bit (thứ 3) thay cho giá trị của nó (8).
Truy vấn: ghép tiền tố từ các bit của . Để tính , bắt đầu từ ô : nó giữ phần tử cuối của tiền tố. Phần còn lại là tiền tố độ dài , xử lý đúng cách ấy. Mỗi bước xóa bit 1 thấp nhất, cho tới khi về 0. Với : , ba khúc dài nối khít nhau. Số khúc bằng số bit 1, không quá .
Hỏi nhanh
Gọi trên cây Fenwick (biết ). Chương trình cộng các ô nào?
Mỗi bước trừ : . Ba ô , và ghép đủ tiền tố 22, đúng ba bit 1 của .
Dãy là đường đi của vòng cập nhật, cộng chứ không trừ. Đi lùi từng đơn vị là cách cộng tiền tố ngây thơ, . Chia đôi chỉ số thì không liên quan tới các khúc của cây.
Cập nhật: sửa mọi ô phủ vị trí . Khi tăng, ô phải tăng nếu khúc của nó chứa . Ô đầu tiên là ; các ô còn lại nằm bên phải, và ta đi từ ô này sang ô phủ kế tiếp bằng cách cộng . Với và , dãy ô là .
Vì sao đúng? Khúc của với là một khối căn lề , nên hai khúc bất kỳ hoặc rời nhau hoặc lồng nhau. Đặt và . Không ô nào với phủ , vì nên khúc của bắt đầu sau . Ô phủ , vì chia hết cho nên khúc của nó dài ít nhất . Và với , phủ khi và chỉ khi phủ , do tính lồng nhau. Vậy các ô phủ đúng là , , rồi các ô phủ . Mỗi lần nhảy, ít nhất gấp đôi, nên có không quá ô.
void update(ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) bit[pos] += val;
}
ll get(ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res += bit[pos];
return res;
}
Hỏi nhanh
Đề cho chỉ số phần tử từ 0 đến . Một bạn gọi update(0, val) thẳng vào cây cài như trên, quên cộng 1 vào chỉ số. Điều gì xảy ra?
Với thì 0 & -0 bằng 0. Bước pos += pos & -pos không làm thay đổi, điều kiện luôn đúng và chương trình lặp vô hạn (thực tế là quá thời gian).
Ba đáp án kia đều giả định vòng lặp thoát được. Cách tránh: đổi sang chỉ số từ 1 ngay khi đọc dữ liệu, tức update(i + 1, val); còn với câu hỏi “tổng phần tử đầu” thì gọi thẳng get(i).
Ví dụCộng Điểm, Hỏi Tiền Tố
Đề. Mảng phần tử đánh số từ 0, ban đầu toàn 0. Xử lý thao tác: + i delta cộng delta vào ; ? i in (tổng rỗng là 0).
Lời giải. Đúng hai hàm trên, chỉ lệch chỉ số: phần tử của đề là ô của cây, nên + i delta là update(i + 1, delta); còn “ phần tử đầu” ứng với các ô , nên ? i là get(i), và get(0) tự trả 0. Mảng bit số ll là 40 MB. Dữ liệu cỡ số nên hai dòng tăng tốc là bắt buộc.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5e6 + 5;
ll n, q, bit[N];
void update(ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) bit[pos] += val;
}
ll get(ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res += bit[pos];
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
while (q--) {
char op;
ll i;
cin >> op >> i;
if (op == '+') {
ll d;
cin >> d;
update(i + 1, d);
} else cout << get(i) << '\n';
}
}
5 8
? 0
+ 1 4
+ 3 -2
? 2
+ 0 7
? 1
? 4
? 5
0
4
7
9
9
Gán giá trị và dựng cây trong O(n)
Ba chỗ hay sai khi cài trọn vẹn. Một là chỉ số: cây chạy trên . Hai là kiểu dữ liệu: tổng của số cỡ lên tới , nên bit phải là long long. Ba là phép gán: cây chỉ biết cộng, nên muốn đặt ta giữ thêm mảng giá trị hiện tại a và gọi update(k, u - a[k]); a[k] = u;.
Dựng cây bằng lần update tốn . Có cách dựng . Gọi ; các ô với có khúc , nối khít thành , tức toàn bộ khúc của trừ . Vậy
chẳng hạn . Mỗi ô con thỏa , đúng là ô kế tiếp trong vòng cập nhật. Do đó gán bit[i] = a[i], rồi duyệt tăng dần và đẩy bit[i] lên ô : khi tới thì mọi ô con của nó, đều nhỏ hơn , đã đẩy giá trị lên. Mỗi ô đẩy đúng một lần.
Ví dụTổng Đoạn Động
Đề. Cho và thao tác (, ): 1 k u gán ; 2 l r in .
Lời giải. Dựng cây trong như trên. Gán là cộng phần chênh lệch u - a[k] (có thể âm) rồi cập nhật a[k]; tổng đoạn là get(r) - get(l - 1). Bất biến: sau mỗi thao tác, get(i) bằng tổng tiền tố của mảng hiện tại. Mỗi thao tác không quá hai lần duyệt cây, mỗi lần 18 bước: cả bài cỡ phép, so với của cách ngây thơ.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, q, a[N], bit[N];
void update(ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) bit[pos] += val;
}
ll get(ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res += bit[pos];
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
bit[i] = a[i];
}
for (ll i = 1; i <= n; i++)
if (i + (i & -i) <= n) bit[i + (i & -i)] += bit[i];
while (q--) {
ll t, x, y;
cin >> t >> x >> y;
if (t == 1) {
update(x, y - a[x]);
a[x] = y;
} else cout << get(y) - get(x - 1) << '\n';
}
}
5 6
2 1 4 3 5
2 2 4
1 3 9
2 1 3
1 1 10
2 1 5
2 4 4
8
12
28
3
Cộng đoạn: mảng hiệu đặt vào cây
Đổi bài toán: thao tác cộng vào mọi , và câu hỏi là giá trị một phần tử . Dùng mảng hiệu của bài 02, với . Cộng đoạn chỉ đổi hai ô, và , còn là một get. Ở bài 02 ta phải đọc hết thao tác rồi cộng dồn một lần; đặt vào cây Fenwick thì hỏi lúc nào cũng được: update(l, v); update(r + 1, -v); rồi = get(k). Khi , ô nằm ngoài cây và vòng lặp tự không chạy.
Hỏi nhanh
Cây Fenwick giữ mảng hiệu và được tính bằng get(k). Thao tác “cộng vào ” cần hai lệnh update nào?
get(k) phải tăng đúng với mọi và không đổi với . update(l, v) làm mọi get(k) với tăng ; để hủy tác dụng từ trở đi ta phải trừ tại .
Với update(r, -v), get(r) tăng nên không được cộng. Đáp án thứ ba dịch cả đoạn sang trái một vị trí, còn đáp án cuối không hủy gì cả mà cộng thêm nữa.
Còn khi cần tổng đoạn sau những lần cộng đoạn thì sao? Thay vào tổng :
Đổi thứ tự lấy tổng vì góp mặt trong , tức lần. Chẳng hạn với : , đúng bằng . Hai tổng cuối là hai tiền tố: một trên cây giữ , một trên cây giữ . Mỗi lần cộng đoạn sửa cả hai cây tại và , và tổng đoạn là .
Ví dụCộng Đoạn, Tính Tổng
Đề. Có test, mỗi test một mảng phần tử ban đầu toàn 0 và thao tác: 0 l r v cộng vào ; 1 l r in .
Lời giải. Dùng hai cây như ở trên: cộng vào đoạn thành bốn lần cập nhật điểm, và tổng tiền tố bằng .
Bước 1: một hàm cập nhật cho cả hai cây. Hai cây là hai mảng b1, b2; truyền mảng làm tham số để khỏi viết hai bản update, get giống hệt nhau.
void update(ll b[], ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) b[pos] += val;
}
ll get(ll b[], ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res += b[pos];
return res;
}
Bước 2: cộng đoạn và tổng tiền tố. add(l, r, v) ghi tại và tại vào cây hiệu , đồng thời ghi và vào . pre(x) ghép hai cây theo công thức trên; tổng đoạn là pre(r) - pre(l - 1).
void add(ll l, ll r, ll v) {
update(b1, l, v);
update(b2, l, v * l);
update(b1, r + 1, -v);
update(b2, r + 1, -v * (r + 1));
}
ll pre(ll x) {
return (x + 1) * get(b1, x) - get(b2, x);
}
Bước 3: xóa cây giữa các test. Hai cây là mảng toàn cục, nên dữ liệu của test trước còn nằm đó. Xóa ô đầu là đủ, vì test này chỉ đụng tới các ô (ô nằm ngoài cây và vòng update không ghi vào).
for (ll i = 0; i <= n; i++) b1[i] = b2[i] = 0;
Tổng tới cỡ , vừa ll. Với test đầu, sau hai lần cộng mảng là và tổng bằng 11.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5;
ll t, n, q, b1[N], b2[N];
void update(ll b[], ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) b[pos] += val;
}
ll get(ll b[], ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res += b[pos];
return res;
}
void add(ll l, ll r, ll v) {
update(b1, l, v);
update(b2, l, v * l);
update(b1, r + 1, -v);
update(b2, r + 1, -v * (r + 1));
}
ll pre(ll x) {
return (x + 1) * get(b1, x) - get(b2, x);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n >> q;
for (ll i = 0; i <= n; i++) b1[i] = b2[i] = 0;
while (q--) {
ll type, l, r;
cin >> type >> l >> r;
if (type == 0) {
ll v;
cin >> v;
add(l, r, v);
} else cout << pre(r) - pre(l - 1) << '\n';
}
}
}
2
5 5
1 1 5
0 2 4 3
1 1 3
0 1 2 2
1 2 5
4 4
0 1 4 1
0 2 3 4
1 1 4
1 2 2
0
6
11
12
5
Mỗi thao tác tốn bốn lần cập nhật hoặc bốn lần truy vấn, mỗi lần .
Fenwick đếm: chỉ số là giá trị
Từ đầu bài, chỉ số là vị trí. Ta cũng có thể lấy chỉ số là giá trị: ô của giá trị đếm số phần tử bằng đang có trong một đa tập. Chèn là update(v, 1), xóa là update(v, -1), và get(v) là số phần tử . Mọi câu hỏi kiểu “đã thấy bao nhiêu phần tử nhỏ hơn, lớn hơn, hay nằm trong khoảng nào” đều thành một hay hai lần get. Đây là cách dùng phổ biến nhất của cây trong đề thi.
Giá trị tới thì không thể dành ô. Nếu biết trước mọi giá trị có thể xuất hiện (đọc hết input trước, kể cả giá trị trong các lệnh đổi), ta nén tọa độ như ở bài 03: sắp xếp, bỏ trùng thành mảng vals, rồi thay mỗi giá trị bằng hạng của nó, lower_bound(vals.begin(), vals.end(), x) - vals.begin() + 1. Nén giữ nguyên thứ tự nên mọi phép so sánh và vẫn đúng.
Phần tử thứ . Bài toán ngược: biết , tìm giá trị nhỏ thứ của đa tập, tức chỉ số nhỏ nhất có . Chặt nhị phân trên rồi gọi get tốn ; đi thẳng xuống trong cây chỉ . Cho chạy từ 0; với lần lượt là lũy thừa của 2 lớn nhất không vượt , rồi giảm dần tới 1: nếu và thì nhảy và trừ khỏi . Cuối cùng đáp án là . Khi xét bước , là bội của nên khúc của ô đúng là , và đây là chặt nhị phân trên từng bit của đáp án. Cách này cần giá trị trong cây không âm.
Chương trình dưới giữ một đa tập các số trong : + x chèn , ? x in phần tử nhỏ thứ , - x xóa phần tử nhỏ thứ và in giá trị bị xóa.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5;
ll n, q, bit[N];
void update(ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) bit[pos] += val;
}
ll kth(ll k) {
ll p = 0;
for (ll e = __lg(n); e >= 0; e--)
if (p + (1LL << e) <= n && bit[p + (1LL << e)] < k) {
p += 1LL << e;
k -= bit[p];
}
return p + 1;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
while (q--) {
char op;
ll x;
cin >> op >> x;
if (op == '+') update(x, 1);
else if (op == '?') cout << kth(x) << '\n';
else {
ll v = kth(x);
cout << v << '\n';
update(v, -1);
}
}
}
10 9
+ 7
+ 3
+ 7
+ 5
? 2
? 3
- 2
? 2
? 3
5
7
5
7
7
Đa tập có nên phần tử nhỏ thứ 2 và thứ 3 là 5 và 7. Lệnh - 2 xóa số 5, còn nên hai câu hỏi sau cùng cho 7.
Ví dụNhững Cặp Đảo Thứ Tự
Đề. Đếm số cặp có trong dãy số nguyên ; hai số bằng nhau không tạo nghịch thế.
Lời giải. Mỗi nghịch thế có đúng một đầu phải, nên đếm theo đầu phải. Quét từ trái sang và giữ trong cây Fenwick, theo giá trị, các phần tử đã quét. Khi tới phần tử thứ (đếm từ 0), cây chứa đúng phần tử đứng trước, trong đó get(a[i]) phần tử không lớn hơn , nên phần tử lớn hơn hẳn. Phải hỏi trước khi thêm chính , nếu không tự đếm mình. Giá trị chỉ tới nên dùng thẳng làm chỉ số; giá trị tới thì nén trước. Với ví dụ : các lượt cộng , tổng 7; số 2 cuối không đếm số 2 đứng trước vì bằng nó.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll M = 6e4;
ll n, bit[M + 5];
void update(ll pos, ll val) {
for (; pos <= M; pos += pos & -pos) bit[pos] += val;
}
ll get(ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res += bit[pos];
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
ll ans = 0;
for (ll i = 0; i < n; i++) {
ll x;
cin >> x;
ans += i - get(x);
update(x, 1);
}
cout << ans << '\n';
}
6
4
1
3
2
5
2
7
Hỏi nhanh
Một bạn viết ans += i - get(x - 1) thay cho i - get(x). Với dãy , chương trình in ra bao nhiêu, và đáp án đúng của đề là bao nhiêu?
get(x - 1) chỉ đếm các phần tử nhỏ hơn hẳn , nên đếm các phần tử đứng trước mà lớn hơn hoặc bằng . Dãy cho , trong khi các cặp bằng nhau không phải nghịch thế và đáp án là 0.
Hai điều kiện “” và “” chỉ khác nhau ở các giá trị trùng, nên test ngẫu nhiên nhỏ có nhiều số khác nhau không bắt được lỗi này. Với đề cho giá trị trùng, luôn thử tay một dãy toàn số bằng nhau.
Khi nào Fenwick không đủ
Công thức cần phép toán có phép ngược, nên cây hợp với cộng, XOR và đếm, giống điều kiện của tổng tiền tố ở bài 02. Với min và max thì không: không cho biết .
Có một ngoại lệ hữu ích. Nếu mọi cập nhật chỉ làm giá trị tăng lên và ta chỉ hỏi tiền tố, thay phép cộng bằng max: bit[pos] = max(bit[pos], val) trong vòng cập nhật, và lấy max trong vòng truy vấn. Đây là cách giải nhiều bài quy hoạch động theo giá trị. Nhưng nếu giá trị có thể giảm đi, hoặc cần max trên đoạn bất kỳ, hoặc gán cả một đoạn, thì phải dùng cây phân đoạn (bài 17).
Cây có bản hai chiều bằng hai vòng lặp lồng nhau, mỗi thao tác (bài 45). Với nhiều test, đặt lại ô cho mỗi test, nên chỉ ổn khi tổng qua các test bị chặn. So với cây phân đoạn, Fenwick ngắn hơn và thường chạy nhanh hơn, đổi lại nó chỉ làm được các việc kể trên.
Ví dụDãy Tăng Có Trọng Số
Đề. Có phần tử, phần tử có chiều cao (một hoán vị của ) và trọng số . Chọn một dãy con có chiều cao tăng nghiêm ngặt với tổng trọng số lớn nhất.
Lời giải. Quy hoạch động như dãy con tăng của bài 12: trên các có , nhưng tìm max kiểu duyệt mọi là . Quét tăng dần và giữ một cây Fenwick max theo chiều cao: ô của chiều cao ghi của phần tử đã quét có chiều cao đó. Khi tới , các phần tử đã quét đều đứng trước , nên cần tìm là max tiền tố trên chiều cao . Mỗi ô chỉ được ghi một lần và giá trị trong cây chỉ tăng, đúng ngoại lệ cho phép dùng max. Cả bài ; tổng tới .
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, h[N], b[N], bit[N];
void update(ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) bit[pos] = max(bit[pos], val);
}
ll get(ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res = max(res, bit[pos]);
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) cin >> h[i];
for (ll i = 0; i < n; i++) cin >> b[i];
ll ans = 0;
for (ll i = 0; i < n; i++) {
ll dp = get(h[i] - 1) + b[i];
update(h[i], dp);
ans = max(ans, dp);
}
cout << ans << '\n';
}
6
4 2 6 1 5 3
7 4 8 5 6 10
15
Khi đi thi
- Dãy bị sửa xen kẽ với các câu hỏi tổng hay đếm trên tiền tố, đoạn là dấu hiệu của Fenwick; dãy đứng yên chỉ cần tổng tiền tố (bài 02); cần min, max trên đoạn bất kỳ hay gán cả đoạn thì chuyển sang cây phân đoạn (bài 17).
- Cài bằng chỉ số từ 1: đề cho từ 0 thì cộng 1 trước khi gọi
update(vìupdate(0, ·)lặp vô hạn), dùnglong longcho tổng, đổi phép gán thành cộng phần chênh lệch, và lấy tổng đoạn bằngget(r) - get(l - 1). - Bài đếm cặp (nghịch thế, “bao nhiêu phần tử trước đó nhỏ hơn hay lớn hơn”): quét theo một khóa, dùng Fenwick theo giá trị đã nén của khóa kia, hỏi trước khi thêm phần tử hiện tại và kiểm kỹ chỗ nào là , chỗ nào là khi có giá trị trùng.
Tóm tắt
- là tổng của phần tử kết thúc tại , với =
i & -i; hai khúc bất kỳ rời nhau hoặc lồng nhau. get(pos): cộng rồi ;update(pos, val): sửa rồi . Mỗi vòng , tổng đoạn là hiệu haiget.- Phép gán đổi thành cộng phần chênh lệch; dựng cây trong bằng cách đẩy mỗi lên .
- Cộng đoạn dùng mảng hiệu trên cây; cộng đoạn kèm hỏi tổng đoạn cần hai cây, với .
- Lấy chỉ số là giá trị thì cây đếm được nghịch thế và xếp hạng, nén tọa độ khi giá trị lớn, và nhảy nhị phân xuống cây tìm phần tử thứ trong .
- Cây cần phép ngược: min và max chỉ dùng được cho tiền tố khi giá trị không giảm.
Bài tập
get.update(-1) rồi một update(+1).Đọc thêm
- Cây chỉ số nhị phân (Binary Indexed Tree)VNOI WikiBài tiếng Việt đi từ cách cộng dồn ngây thơ tới cây Fenwick và nêu rõ giới hạn: phép toán phải có phép ngược.
- Fenwick Treecp-algorithmsCách đánh số từ 0 bằng , dựng , cộng đoạn kèm hỏi đoạn bằng hai cây và bản hai chiều.
- 树状数组OI WikiTrình bày đầy đủ nhất: cây quản lý, độ phức tạp, cây trọng số tìm phần tử thứ , đếm nghịch thế, mở rộng cho max.
- Point Update Range SumUSACO GuideBộ bài tập chọn lọc từ CSES, USACO, Kattis, SPOJ kèm mức độ khó, dùng được cho cả Fenwick lẫn cây phân đoạn.
- Fenwick TreesSOI wikiLưu ý về chỉ số 0, cộng đoạn bằng hàm tuyến tính, tìm kiếm nhị phân trên cây và vì sao max chỉ dùng được khi giá trị không giảm.