Cho một dãy số và các thao tác xen kẽ nhau: gán lại một phần tử, hoặc hỏi giá trị nhỏ nhất trên một đoạn . Quét cả đoạn để trả lời mỗi câu hỏi tốn ; với đó là phép so sánh. Tổng tiền tố (bài 02) không cứu được: giá trị nhỏ nhất không có phép ngược để “gỡ” phần đầu, và mỗi lần sửa một phần tử làm sai mọi tiền tố đứng sau.
Cây phân đoạn (segment tree) trả lời cả hai loại thao tác trong . Nó chia dãy thành các đoạn bằng cách chia đôi liên tiếp, lưu thông tin của từng đoạn, rồi ghép vài đoạn thành đáp án cho đoạn bất kỳ. Thông tin lưu có thể là min, tổng, ước chung lớn nhất, hay đoạn con có tổng lớn nhất; và với thẻ lười, cả thao tác trên một đoạn dài cũng tốn . Đây là cấu trúc quen thuộc nhất trong các đề “sửa rồi hỏi”, và là nền của cây bền vững (bài 59), phân rã nặng nhẹ (bài 58).
Chia đôi dãy
Ý tưởng là tính trước đáp án cho một số đoạn được chọn khéo: đủ ít để sửa nhanh khi một phần tử đổi, đủ nhiều để ghép thành mọi đoạn hỏi. Cách chọn là chia đôi. Đoạn với tách tại thành và , cứ thế tới khi đoạn chỉ còn một phần tử. Các đoạn ấy xếp thành một cây nhị phân: nút quản lý một đoạn, gốc quản lý , hai con của một nút quản lý hai nửa của nó, lá quản lý đúng một phần tử. Mỗi nút lưu min của đoạn mình quản lý. Cây có nút. Hình dưới là cây của .
Trong hình, nút cam nằm trọn trong đoạn hỏi, nút xanh cắt ngang biên, nút mờ rời đoạn hỏi. Đổi đoạn hoặc gán một phần tử rồi chạy.
Hỏi đoạn . Ta lấy các nút lớn nhất nằm trọn trong đoạn hỏi: , , và . Bốn đoạn này rời nhau và ghép lại đúng bằng , nên đáp án là . Sửa một phần tử. Gán chỉ làm đổi các nút có đoạn chứa vị trí 6, tức một đường từ lá lên gốc: , , , . Ta tính lại từng nút từ dưới lên bằng min của hai con; mọi nút khác giữ nguyên.
Cài đặt. Quy ước cả bài: đánh số từ 1, đoạn đóng , gốc có số hiệu 1, hai con của nút id là id * 2 và id * 2 + 1, mảng st có ô. Ba hàm build, update, get nhận số hiệu nút cùng đoạn mà nút ấy quản lý. Hàm get(id, l, r, u, v) hỏi đoạn và chỉ có ba trường hợp: đoạn của nút rời đoạn hỏi thì trả một giá trị trung hòa ( với min, lớn hơn mọi giá trị thật); nằm trọn thì trả st[id] và dừng; còn lại thì hỏi hai con rồi gộp. Hàm update đi xuống đúng một con chứa vị trí cần sửa, rồi tính lại st[id] từ hai con khi quay lên; dòng tính lại đó giữ cho mọi nút trên đường đi đúng.
Vì sao mỗi thao tác chỉ tốn logarit. Mỗi lần chia đôi, độ dài đoạn giảm còn nhiều nhất một nửa làm tròn lên, nên cây có tối đa tầng, với là 19. Cập nhật ghé đúng một nút mỗi tầng. Với truy vấn, gọi nút là cắt nếu đoạn của nó giao đoạn hỏi nhưng không nằm trọn; chỉ nút cắt mới gọi xuống hai con. Một nút cắt có cả phần tử trong và ngoài đoạn hỏi, mà đoạn của nó liền mạch, nên nó phải chứa biên trái hoặc biên phải của đoạn hỏi. Các nút cùng tầng rời nhau, nên mỗi tầng có không quá hai nút cắt, sinh không quá bốn con ở tầng kế tiếp: truy vấn ghé tối đa nút.
Với trong hình dưới, mỗi tầng có không quá hai nút xanh cắt ngang biên.
Bộ nhớ. Số hiệu các nút không liền nhau: nút ở tầng có số hiệu trong và tầng cuối thường còn khoảng trống. Số hiệu lớn nhất không vượt với , mà nên . Mảng ô luôn đủ, còn ô có thể thủng.
Hỏi nhanh
Ta dùng cách đánh số: gốc là 1, hai con của nút là và , đoạn chia tại . Với cây có nút. Số hiệu lớn nhất mà một nút của cây này dùng là bao nhiêu?
Gốc 1 quản lý , hai con là 2 quản lý và 3 quản lý . Nút 3 chia thành 6 quản lý và 7 quản lý . Nút 6 chia tiếp thành 12 quản lý và 13 quản lý . Vậy lá của vị trí 5 mang số hiệu 13.
Cây vẫn chỉ có 11 nút, nhưng số hiệu có khoảng trống, nên mảng cỡ (chỉ số từ 0 đến 11) không chứa nổi nút 13. Đáp án 11 là số nút chứ không phải số hiệu lớn nhất, còn 24 là kích thước mảng ta cấp.
Ví dụGiá Trị Thấp Nhất
Đề. Dãy ô, yêu cầu: 1 k u gán ô bằng ; 2 l r in min của các ô từ đến .
Lời giải. Đúng cây min ở trên: nút lưu min của các ô trong đoạn, gộp hai con bằng min. Ba hàm theo đúng khung đã học.
Bước 1: dựng cây. Lá lưu chính ô đó; nút trong lưu min của hai con, tính sau khi đã dựng hai con.
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = a[l];
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = min(st[id * 2], st[id * 2 + 1]);
}
Bước 2: gán một ô. Đi xuống đúng một nhánh chứa pos, gán ở lá, rồi tính lại min trên đường đi lên. Chỉ nút bị đổi.
void update(ll id, ll l, ll r, ll pos, ll val) {
if (l == r) {
st[id] = val;
return;
}
ll mid = (l + r) >> 1;
if (pos <= mid) update(id * 2, l, mid, pos, val);
else update(id * 2 + 1, mid + 1, r, pos, val);
st[id] = min(st[id * 2], st[id * 2 + 1]);
}
Bước 3: hỏi min đoạn. Nút nằm ngoài trả về phần tử trung hòa , không ảnh hưởng phép min; nút nằm trọn trong trả về giá trị đã lưu; còn lại hỏi hai con.
ll get(ll id, ll l, ll r, ll u, ll v) {
if (v < l || r < u) return 1e18;
if (u <= l && r <= v) return st[id];
ll mid = (l + r) >> 1;
return min(get(id * 2, l, mid, u, v), get(id * 2 + 1, mid + 1, r, u, v));
}
Với ví dụ, cả dãy có min 2; sau khi gán , đoạn có min 7.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], st[4 * N];
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = a[l];
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = min(st[id * 2], st[id * 2 + 1]);
}
void update(ll id, ll l, ll r, ll pos, ll val) {
if (l == r) {
st[id] = val;
return;
}
ll mid = (l + r) >> 1;
if (pos <= mid) update(id * 2, l, mid, pos, val);
else update(id * 2 + 1, mid + 1, r, pos, val);
st[id] = min(st[id * 2], st[id * 2 + 1]);
}
ll get(ll id, ll l, ll r, ll u, ll v) {
if (v < l || r < u) return 1e18;
if (u <= l && r <= v) return st[id];
ll mid = (l + r) >> 1;
return min(get(id * 2, l, mid, u, v), get(id * 2 + 1, mid + 1, r, u, v));
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
while (q--) {
ll t, x, y;
cin >> t >> x >> y;
if (t == 1) update(1, 1, n, x, y);
else cout << get(1, 1, n, x, y) << '\n';
}
}
5 6
7 2 9 2 6
2 1 5
1 2 8
2 1 3
1 4 10
2 3 5
2 4 4
2
7
6
10
Thời gian , khoảng lần ghé nút; mảng st cần ô.
Nút và phép gộp
Nhìn lại ba hàm trên: chữ min xuất hiện đúng ba chỗ, là giá trị của lá, cách tính nút từ hai con và giá trị trả về cho đoạn rời. Mọi thứ còn lại chỉ nói về hình dạng cây. Thay ba chỗ ấy bằng và phép cộng thì ta có cây cho tổng; bằng và thì có cây cho ước chung lớn nhất; tương tự cho max, XOR, hay đếm số phần tử thỏa một tính chất.
Tổng quát, mỗi nút lưu một tóm tắt (summary) của đoạn nó quản lý, với hai điều kiện. Thứ nhất, tóm tắt của một đoạn tính được từ tóm tắt của hai nửa bằng một hàm combine(a, b), trong đó là nửa trái và là nửa phải. Thứ hai, có một tóm tắt của đoạn rỗng (phần tử trung hòa) mà gộp với nó không đổi gì. Khi đó cả ba hàm giữ nguyên, chỉ đổi min thành combine. Truy vấn ghép các nút nằm trọn trong đoạn hỏi theo thứ tự từ trái sang phải.
Không phải đại lượng nào cũng làm tóm tắt được. Điều kiện thứ nhất đòi tóm tắt của đoạn cha suy ra được từ tóm tắt của hai con chỉ bằng thông tin đã lưu. Với tổng, min, gcd, điều đó đúng. Với trung vị thì sai. Khi một đại lượng không gộp được một mình, cách chữa thường là lưu kèm vài đại lượng phụ cho đủ.
Hỏi nhanh
Mỗi nút cây phân đoạn cần lưu một tóm tắt của đoạn sao cho gộp được từ hai nút con. Nếu mỗi đoạn chỉ lưu đúng một con số, con số nào dưới đây không đủ để tính ra con số của nút cha? (Trung vị của một đoạn là phần tử đứng giữa sau khi sắp xếp; số phần tử chẵn thì lấy phần tử thấp hơn trong hai phần tử giữa.)
Tổng bình phương và số phần tử chẵn cộng được từ hai con; ước chung lớn nhất của đoạn là gcd của hai gcd con. Trung vị thì không: lấy nửa trái có trung vị 5. Hai nửa phải khác nhau và cùng có trung vị 6. Ghép với cho có trung vị 5; ghép với cho có trung vị 6. Hai nút cha có cùng cặp trung vị con mà trung vị khác nhau, nên không tính được chỉ từ con số đã lưu.
Một điều nữa dễ bỏ sót: combine không cần giao hoán, nhưng thứ tự đối số có nghĩa. Trong get, lời gọi combine(get(trái), get(phải)) luôn đặt nửa trái trước, nên thứ tự vị trí được giữ nguyên. Với min, tổng, gcd chuyện này không đáng bận tâm. Với phép gộp như “đoạn con có tổng lớn nhất” ở ví dụ thứ hai dưới đây, đảo thứ tự cho kết quả sai.
Ví dụTổng Đoạn Liên Tiếp
Đề. Dãy số, , và lần gán . Sau mỗi lần gán, in tổng lớn nhất của một đoạn con liên tiếp của cả dãy (được chọn đoạn rỗng, tổng 0).
Lời giải. Chỉ lưu đoạn con tốt nhất của mỗi nút thì chưa gộp được: đoạn tốt nhất của hai nửa ghép lại có thể vắt qua ranh giới, khi đó nó là một hậu tố của nửa trái nối với một tiền tố của nửa phải. Vậy mỗi nút lưu bốn số: tổng cả đoạn sum, tiền tố lớn nhất pre, hậu tố lớn nhất suf, đoạn con lớn nhất best; ba số cuối cho phép rỗng nên không âm. Gộp nửa trái với nửa phải :
Trong hình, mỗi nút giữ bốn số. Đoạn tốt nhất của ghép nằm trong , trong , hoặc là hậu tố của nối tiền tố của .
Một tiền tố của đoạn ghép hoặc nằm gọn trong nửa trái, hoặc là cả nửa trái rồi tới một tiền tố của nửa phải; hậu tố đối xứng; đoạn con thì nằm gọn một bên hoặc cắt ranh giới. Phép gộp không giao hoán: ghép cho best 5, đổi chỗ thì ra 4.
Bước 1: nút và lá. Lá giá trị có tổng , còn ba số kia là : hoặc lấy chính ô đó, hoặc lấy đoạn rỗng.
struct Node {
ll sum, pre, suf, best;
};
Node leaf(ll x) {
return {x, max(0LL, x), max(0LL, x), max(0LL, x)};
}
Bước 2: gộp. Viết đúng ba công thức trên, tổng thì cộng hai nửa. Thứ tự tham số quan trọng: a là nửa trái, b là nửa phải.
Node combine(Node a, Node b) {
return {a.sum + b.sum, max(a.pre, a.sum + b.pre), max(b.suf, b.sum + a.suf),
max({a.best, b.best, a.suf + b.pre})};
}
Bước 3: sửa rồi đọc gốc. build và update giữ khung quen thuộc, lá dùng leaf, nút trong dùng combine. Bài chỉ hỏi cả dãy, nên đáp án sau mỗi lần gán là best ở gốc, không cần hàm get.
update(1, 1, n, k, x);
cout << st[1].best << '\n';
Tổng tới , vừa ll.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
struct Node {
ll sum, pre, suf, best;
};
const ll N = 2e5 + 5;
ll n, m, a[N];
Node st[4 * N];
Node leaf(ll x) {
return {x, max(0LL, x), max(0LL, x), max(0LL, x)};
}
Node combine(Node a, Node b) {
return {a.sum + b.sum, max(a.pre, a.sum + b.pre), max(b.suf, b.sum + a.suf),
max({a.best, b.best, a.suf + b.pre})};
}
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = leaf(a[l]);
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = combine(st[id * 2], st[id * 2 + 1]);
}
void update(ll id, ll l, ll r, ll pos, ll val) {
if (l == r) {
st[id] = leaf(val);
return;
}
ll mid = (l + r) >> 1;
if (pos <= mid) update(id * 2, l, mid, pos, val);
else update(id * 2 + 1, mid + 1, r, pos, val);
st[id] = combine(st[id * 2], st[id * 2 + 1]);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
while (m--) {
ll k, x;
cin >> k >> x;
update(1, 1, n, k, x);
cout << st[1].best << '\n';
}
}
5 4
-3 2 -1 4 -5
1 -2
5 3
2 -6
4 -8
5
8
7
3
Mỗi lần gán ; đọc đáp án .
Đi xuống cây
Có bài không hỏi giá trị của một đoạn mà hỏi vị trí. Ví dụ: dãy bit , thao tác đảo một bit, và câu hỏi “bit 1 thứ tính từ trái nằm ở vị trí nào”. Chặt nhị phân trên vị trí cần một truy vấn cho mỗi bước chặt, tổng . Cây cho phép bỏ lớp chặt bên ngoài.
Mỗi nút lưu số bit 1 trong đoạn của nó. Đi từ gốc, giữ bất biến “đáp án là bit 1 thứ bên trong đoạn của nút hiện tại”. Tại một nút: nếu con trái có ít nhất bit 1 thì đi trái, giữ nguyên ; ngược lại bỏ qua mọi bit 1 của con trái, trừ chúng khỏi rồi đi phải. Mỗi bước xuống một tầng; tới lá thì đó là đáp án. Điều kiện bắt đầu là gốc có ít nhất bit 1.
Trong hình, mỗi nút ghi số bit 1 trong đoạn của nó; đường cam là đường đi xuống.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, q, b[N], st[4 * N];
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = b[l];
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = st[id * 2] + st[id * 2 + 1];
}
void flip(ll id, ll l, ll r, ll pos) {
if (l == r) {
st[id] ^= 1;
return;
}
ll mid = (l + r) >> 1;
if (pos <= mid) flip(id * 2, l, mid, pos);
else flip(id * 2 + 1, mid + 1, r, pos);
st[id] = st[id * 2] + st[id * 2 + 1];
}
ll kth(ll id, ll l, ll r, ll k) {
if (l == r) return l;
ll mid = (l + r) >> 1;
if (st[id * 2] >= k) return kth(id * 2, l, mid, k);
return kth(id * 2 + 1, mid + 1, r, k - st[id * 2]);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) cin >> b[i];
build(1, 1, n);
while (q--) {
ll t, x;
cin >> t >> x;
if (t == 1) flip(1, 1, n, x);
else cout << (st[1] < x ? -1 : kth(1, 1, n, x)) << '\n';
}
}
10 7
0 1 1 0 0 1 0 1 1 0
2 1
2 4
2 6
1 2
2 2
1 10
2 5
2
8
-1
6
10
Các bit 1 ban đầu nằm ở vị trí , nên bit thứ 1 ở vị trí 2, bit thứ 4 ở vị trí 8, và không có bit thứ 6. Lỗi hay gặp nhất là quên trừ số bit 1 của con trái khi sang phải: khi đó vẫn tính cả những bit 1 đã bỏ qua và ta lạc sang nhánh sai. Cùng khuôn đó dùng cho “vị trí đầu tiên có ” trên cây lưu max: sang trái nếu max của con trái , ngược lại sang phải. Nếu chỉ số của cây là giá trị và nút đếm số lần xuất hiện, cùng cách đi xuống ấy tìm được phần tử nhỏ thứ của một tập đang thay đổi.
Ví dụPhân Bổ Phòng
Đề. Có khách sạn, khách sạn còn phòng. Lần lượt đoàn đến, đoàn cần phòng và được xếp vào khách sạn có chỉ số nhỏ nhất còn ít nhất phòng (rồi khách sạn đó bớt phòng); không có thì in 0.
Lời giải. Cây lưu max số phòng còn trống của mỗi đoạn khách sạn. Nếu max ở gốc nhỏ hơn thì không khách sạn nào đủ chỗ, in 0. Ngược lại đi xuống từ gốc: khách sạn đầu tiên đủ chỗ nằm bên trái khi và chỉ khi bên trái có ít nhất một khách sạn đủ chỗ, tức max của con trái . Tới lá là đúng khách sạn cần tìm.
Bước 1: dựng cây max. Như cây min, chỉ đổi min thành max.
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = h[l];
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = max(st[id * 2], st[id * 2 + 1]);
}
Bước 2: đi xuống tìm và trừ luôn. walk vừa tìm vừa sửa trong một lượt: rẽ trái nếu con trái đủ chỗ, ngược lại rẽ phải; tới lá thì trừ phòng và trả về chỉ số khách sạn; trên đường quay lên tính lại max. Lời gọi chỉ diễn ra khi gốc đủ chỗ, nên nhánh phải luôn đủ chỗ mỗi khi nhánh trái không đủ.
ll walk(ll id, ll l, ll r, ll x) {
if (l == r) {
st[id] -= x;
return l;
}
ll mid = (l + r) >> 1, res;
if (st[id * 2] >= x) res = walk(id * 2, l, mid, x);
else res = walk(id * 2 + 1, mid + 1, r, x);
st[id] = max(st[id * 2], st[id * 2 + 1]);
return res;
}
Bước 3: từng đoàn. Kiểm max ở gốc trước, đủ chỗ mới gọi walk.
cout << (st[1] < x ? 0 : walk(1, 1, n, x)) << ' ';
Với ví dụ, đoàn cần 3 phòng đi tới khách sạn 3 vì khách sạn 1 lúc đó chỉ còn 1 phòng.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, m, h[N], st[4 * N];
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = h[l];
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = max(st[id * 2], st[id * 2 + 1]);
}
ll walk(ll id, ll l, ll r, ll x) {
if (l == r) {
st[id] -= x;
return l;
}
ll mid = (l + r) >> 1, res;
if (st[id * 2] >= x) res = walk(id * 2, l, mid, x);
else res = walk(id * 2 + 1, mid + 1, r, x);
st[id] = max(st[id * 2], st[id * 2 + 1]);
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= n; i++) cin >> h[i];
build(1, 1, n);
while (m--) {
ll x;
cin >> x;
cout << (st[1] < x ? 0 : walk(1, 1, n, x)) << ' ';
}
cout << '\n';
}
4 5
3 1 4 2
2 3 1 4 2
1 3 1 0 4
Mỗi đoàn đi một đường từ gốc tới lá: .
Thẻ lười
Thêm một loại thao tác: cộng vào mọi với , và hỏi tổng đoạn. Cập nhật điểm cho từng vị trí tốn một thao tác. Ta nhớ rằng truy vấn chỉ ghép nút nằm trọn trong đoạn; cập nhật cũng chỉ nên chạm tới đúng những nút đó. Một nút nằm trọn trong biết ngay tổng mới của mình: tổng cũ cộng nhân độ dài đoạn. Còn các nút bên dưới nó thì ta chưa sửa, và ta ghi nợ ở nút: “mọi phần tử ở dưới bị cộng thêm , hai con chưa biết”. Khoản nợ ấy là một thẻ (tag). Chỉ khi một thao tác sau cần chui xuống con của nút này, ta mới trả nợ: chuyển thẻ cho hai con rồi xóa thẻ ở nút. Kỹ thuật này gọi là cập nhật lười (lazy propagation).
Hai mảng có ý nghĩa chính xác:
st[id]là tổng đúng của đoạn, với điều kiện thẻ của mọi nút tổ tiên đã được đẩy xuống;lz[id]là số cần cộng vào mỗi phần tử của đoạn mà hai con chưa biết; phần cộng nàyst[id]đã chứa rồi.
Hàm apply(id, len, x) đặt thẻ lên một nút dài : st[id] += x * len, lz[id] += x. Hàm down(id, l, r) đẩy thẻ của nút xuống hai con rồi xóa nó. Hai quy tắc: gặp nút nằm trọn thì đặt thẻ và dừng; gặp nút cắt thì đẩy thẻ trước khi xuống hai con, rồi tính lại st[id] từ hai con khi quay lên. Cả hai quy tắc áp dụng cho cập nhật lẫn truy vấn.
Trong hình, nhãn xanh lá là thẻ chưa đẩy xuống. Thử cộng thêm hoặc hỏi tổng một đoạn nhỏ để thấy thẻ được đẩy.
Chạy tay trên với thao tác “cộng 3 vào ”: hai nút và nằm trọn nên nhận thẻ , tổng thành và ; lá và cũng nằm trọn và thành , ; các nút cắt tính lại từ con, gốc thành . Bốn lá tới chưa hề được chạm tới. Nếu sau đó hỏi tổng , đường đi qua là nút cắt, nên thẻ được đẩy xuống và lá trả về .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], st[4 * N], lz[4 * N];
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = a[l];
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = st[id * 2] + st[id * 2 + 1];
}
void apply(ll id, ll len, ll x) {
st[id] += x * len;
lz[id] += x;
}
void down(ll id, ll l, ll r) {
if (!lz[id]) return;
ll mid = (l + r) >> 1;
apply(id * 2, mid - l + 1, lz[id]);
apply(id * 2 + 1, r - mid, lz[id]);
lz[id] = 0;
}
void update(ll id, ll l, ll r, ll u, ll v, ll x) {
if (v < l || r < u) return;
if (u <= l && r <= v) {
apply(id, r - l + 1, x);
return;
}
down(id, l, r);
ll mid = (l + r) >> 1;
update(id * 2, l, mid, u, v, x);
update(id * 2 + 1, mid + 1, r, u, v, x);
st[id] = st[id * 2] + st[id * 2 + 1];
}
ll get(ll id, ll l, ll r, ll u, ll v) {
if (v < l || r < u) return 0;
if (u <= l && r <= v) return st[id];
down(id, l, r);
ll mid = (l + r) >> 1;
return get(id * 2, l, mid, u, v) + get(id * 2 + 1, mid + 1, r, u, v);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
while (q--) {
ll t, l, r;
cin >> t >> l >> r;
if (t == 1) {
ll x;
cin >> x;
update(1, 1, n, l, r, x);
} else cout << get(1, 1, n, l, r) << '\n';
}
}
8 5
5 2 7 3 8 1 6 4
2 1 8
1 2 7 3
2 1 8
2 3 4
2 5 8
36
54
16
28
Vì sao đúng. Mỗi lần đi từ một nút cắt xuống con, ta đã đẩy thẻ của nút cắt ấy, nên khi hàm chạm tới một nút bất kỳ thì mọi tổ tiên của nó đều đang có thẻ bằng 0, và st của nút đó là tổng đúng. Các nút được ghé giống hệt phần chứng minh trước, down chỉ tốn , nên cập nhật đoạn và truy vấn đều . Ba lỗi hay gặp: quên down trong hàm truy vấn (chạy đúng trên ví dụ ngắn nhưng sai khi một thẻ còn treo ở nút cắt), quên nhân với độ dài khi áp thẻ lên tổng, và quên tính lại st[id] sau hai lời gọi đệ quy ở update.
Hỏi nhanh
Một nút nằm trọn trong đoạn cần cộng . Trong cài đặt thẻ lười ở trên, tổng st của được cập nhật vào lúc nào?
Chỉ hai con là “lười”; bản thân nút nhận thẻ phải đúng ngay. Nếu sau khi cộng vào cả đoạn của ta hỏi đúng đoạn đó, truy vấn thấy nằm trọn và trả về tổng của ngay, không đi xuống, nên tổng ấy đã phải chứa phần cộng.
Ba phương án còn lại đều để tổng cũ cho tới khi có ai đó đi xuống con của , và một truy vấn nằm trọn sẽ đọc trúng giá trị cũ. Lúc đẩy thẻ xuống, tổng của không cần đổi; thẻ chỉ đổi tổng của hai con.
Thẻ là một hàm
Khi có hai loại cập nhật đoạn trở lên, thứ tự bắt đầu quan trọng. “Cộng 5 rồi gán 2” cho mọi phần tử bằng 2; “gán 2 rồi cộng 5” cho 7. Thẻ của một nút phải nhớ đúng hiệu ứng gộp của cả chuỗi thao tác chưa đẩy xuống, theo đúng thứ tự thời gian.
Cách gọn nhất là coi thẻ là một hàm bậc nhất áp lên từng phần tử. Cộng là thẻ ; gán là ; nhân là ; thẻ “không làm gì” là . Hai điều cần tính:
- Áp thẻ lên một nút dài với tổng : tổng của trên phần tử là .
- Hợp thẻ. Thẻ chạy sau thẻ cho , tức thẻ .
Phép gán tự xóa mọi thứ trước nó: hợp sau bất kỳ cho . Còn “cộng 5 rồi gán 2” là và “gán 2 rồi cộng 5” là hợp sau , cho , khớp với phép tính tay.
Thẻ ở cha luôn mới hơn thẻ ở con. Thẻ chỉ được đặt lên một nút khi thao tác đi tới nút đó, mà muốn tới nút đó thì mọi tổ tiên phải đã đẩy thẻ xuống và đang trống; vì vậy mọi thẻ được đặt sau này lên tổ tiên đều đến sau những thẻ đang nằm ở con. Do đó khi đẩy xuống, thẻ mới của con là thẻ cha hợp sau thẻ con.
Để cách làm này đúng, một loại thẻ cần ba điều kiện. Một, thẻ đóng kín với phép hợp và có thẻ “không làm gì”. Hai, áp thẻ lên một nút tính được chỉ từ tóm tắt của nút đó và độ dài đoạn. Ba, áp thẻ lên tóm tắt của đoạn ghép cho cùng kết quả với việc áp thẻ lên hai tóm tắt thành phần rồi ghép lại. Với tổng (và cả min), phép gán và phép cộng thỏa đủ ba điều kiện. Nhưng “cộng vào mọi phần tử” lên đoạn con có tổng lớn nhất thì không: số phần tử của đoạn tối ưu đổi theo . Thao tác trên cả đoạn cũng vậy; loại này cần kỹ thuật riêng (bài 66). Nếu thẻ có phép nhân thì nhân dồn và dễ tràn; đề thường cho modulo, và ta lấy modulo mọi phép nhân, cộng.
Hỏi nhanh
Nút đang giữ thẻ “cộng 3” (tức ). Sau đó có thao tác “nhân 2” phủ trọn nút . Thẻ mới của , viết dưới dạng , là gì?
Mỗi phần tử bị cộng 3 trước, sau đó nhân 2, nên giá trị cuối là . Công thức hợp thẻ cho đúng kết quả này: , .
là thứ tự ngược (nhân 2 trước rồi cộng 3). làm mất thẻ cũ. lấy làm hằng số mới, một công thức không có cơ sở. Điểm cần nhớ: thẻ mới phải nhân vào cả hằng số của thẻ cũ.
Ví dụCộng, Gán Và Lấy Tổng
Đề. Mảng số, thao tác trên đoạn : 1 L R X cộng ; 2 L R X gán bằng ; 3 L R in tổng. .
Lời giải. Thẻ là cặp (mul, add) như trên, nghĩa là “mỗi ô của đoạn thành ”, ban đầu . Cộng là thẻ , gán là . Không cần xét riêng “nút này đang có thẻ gán hay thẻ cộng”: mọi trường hợp đã nằm trong công thức hợp.
Bước 1: áp một thẻ lên nút. Tổng của đoạn dài thành . Thẻ mới đến sau thẻ cũ, nên ô đi qua cả hai thành : thẻ hợp là .
void apply(ll id, ll len, ll b, ll c) {
st[id] = b * st[id] + c * len;
mul[id] = b * mul[id];
ad[id] = b * ad[id] + c;
}
Bước 2: đẩy thẻ xuống. Thẻ trung hòa thì bỏ qua; còn lại áp (mul, add) của cha lên hai con rồi đặt thẻ cha về . build phải khởi tạo mul = 1 cho mọi nút, vì mảng toàn cục bắt đầu bằng 0 và thẻ nghĩa là “gán bằng 0”.
void down(ll id, ll l, ll r) {
if (mul[id] == 1 && ad[id] == 0) return;
ll mid = (l + r) >> 1;
apply(id * 2, mid - l + 1, mul[id], ad[id]);
apply(id * 2 + 1, r - mid, mul[id], ad[id]);
mul[id] = 1;
ad[id] = 0;
}
Bước 3: hai loại sửa, một hàm. update nhận thẻ và theo đúng khung cây lười; ở main, cộng gọi với , gán gọi với .
if (t == 1) update(1, 1, n, l, r, 1, x);
else update(1, 1, n, l, r, 0, x);
Một phần tử có thể tới cỡ , tổng cả mảng tới , vẫn vừa ll. Với ví dụ, sau khi cộng 3 vào rồi gán 2 cho , mảng là , tổng 12.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], st[4 * N], mul[4 * N], ad[4 * N];
void build(ll id, ll l, ll r) {
mul[id] = 1;
if (l == r) {
st[id] = a[l];
return;
}
ll mid = (l + r) >> 1;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
st[id] = st[id * 2] + st[id * 2 + 1];
}
void apply(ll id, ll len, ll b, ll c) {
st[id] = b * st[id] + c * len;
mul[id] = b * mul[id];
ad[id] = b * ad[id] + c;
}
void down(ll id, ll l, ll r) {
if (mul[id] == 1 && ad[id] == 0) return;
ll mid = (l + r) >> 1;
apply(id * 2, mid - l + 1, mul[id], ad[id]);
apply(id * 2 + 1, r - mid, mul[id], ad[id]);
mul[id] = 1;
ad[id] = 0;
}
void update(ll id, ll l, ll r, ll u, ll v, ll b, ll c) {
if (v < l || r < u) return;
if (u <= l && r <= v) {
apply(id, r - l + 1, b, c);
return;
}
down(id, l, r);
ll mid = (l + r) >> 1;
update(id * 2, l, mid, u, v, b, c);
update(id * 2 + 1, mid + 1, r, u, v, b, c);
st[id] = st[id * 2] + st[id * 2 + 1];
}
ll get(ll id, ll l, ll r, ll u, ll v) {
if (v < l || r < u) return 0;
if (u <= l && r <= v) return st[id];
down(id, l, r);
ll mid = (l + r) >> 1;
return get(id * 2, l, mid, u, v) + get(id * 2 + 1, mid + 1, r, u, v);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
while (q--) {
ll t, l, r;
cin >> t >> l >> r;
if (t == 3) {
cout << get(1, 1, n, l, r) << '\n';
continue;
}
ll x;
cin >> x;
if (t == 1) update(1, 1, n, l, r, 1, x);
else update(1, 1, n, l, r, 0, x);
}
}
5 7
1 2 3 4 5
3 1 5
1 2 4 3
3 2 5
2 3 5 2
3 1 5
1 1 3 4
3 2 4
15
23
12
17
Mỗi thao tác .
Không đệ quy
Khi chỉ cần cập nhật điểm và truy vấn đoạn, có cách cài đặt ngắn hơn, chạy nhanh hơn và dùng ô. Đặt lá của vị trí (đánh số từ 0) ở st[n + i]; khi đó cha của nút là . Dựng cây là một vòng lặp lùi từ về 1, cập nhật điểm là đi từ lá lên gốc. Với truy vấn trên đoạn nửa mở , đi từ hai biên lên: nếu l lẻ, tức là con phải, thì cha của nó chứa phần tử bên trái l, nằm ngoài đoạn hỏi, nên phải lấy riêng st[l] rồi dịch l sang bên phải; nếu r lẻ thì lấy riêng st[r - 1]. Hai biên gặp nhau thì dừng.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, q, st[2 * N];
void update(ll pos, ll val) {
for (st[pos += n] = val; pos > 1; pos >>= 1) st[pos >> 1] = min(st[pos], st[pos ^ 1]);
}
ll get(ll l, ll r) {
ll res = 1e18;
for (l += n, r += n; l < r; l >>= 1, r >>= 1) {
if (l & 1) res = min(res, st[l++]);
if (r & 1) res = min(res, st[--r]);
}
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 0; i < n; i++) cin >> st[n + i];
for (ll i = n - 1; i >= 1; i--) st[i] = min(st[i * 2], st[i * 2 + 1]);
while (q--) {
ll t, x, y;
cin >> t >> x >> y;
if (t == 1) update(x, y);
else cout << get(x, y) << '\n';
}
}
6 6
4 9 2 7 5 6
2 0 6
2 1 4
1 2 8
2 1 4
2 3 6
1 0 1
2
2
7
5
Với phép gộp giao hoán (min, max, tổng, gcd) mã này đúng với mọi , kể cả khi không phải lũy thừa của 2, như ví dụ có . Với phép không giao hoán ta đệm số lá lên lũy thừa của 2 bằng phần tử trung hòa rồi giữ hai biến tích lũy, một cho phía trái (nối thêm vào cuối) và một cho phía phải (nối thêm vào đầu), rồi gộp hai biến ở cuối. Bản không đệ quy có thẻ lười cũng tồn tại nhưng phức tạp hơn nhiều; khi cần thẻ lười, bản đệ quy dễ viết đúng hơn.
Bản không đệ quy ngắn hơn và nhanh hơn rõ rệt:
Bản không đệ quy nhanh hơn bản đệ quy gần 4 lần, vì không tốn lời gọi hàm và mảng nhỏ hơn một nửa. Quy tắc: không cần thẻ lười thì dùng bản không đệ quy.
Vài biến thể thường gặp khác:
- Vị trí rất lớn. Nếu tọa độ tới nhưng chỉ có thao tác thì nén tọa độ (nếu biết trước tất cả thao tác), hoặc chỉ tạo nút khi cần, gọi là cây động: mỗi cập nhật tạo thêm không quá nút.
- Cây theo giá trị. Chỉ số của cây là giá trị, nút lưu số lần xuất hiện. Thêm, xóa, đếm số phần tử trong khoảng giá trị và tìm phần tử nhỏ thứ đều .
- Chọn công cụ. Dãy không đổi: tổng tiền tố cho tổng (bài 02), bảng thưa cho min/max (bài 22). Có sửa điểm và chỉ cần tổng hay đếm: cây Fenwick (bài 16) ngắn hơn. Còn lại, tức phép gộp không có phép ngược, tóm tắt nhiều thành phần, hoặc cập nhật đoạn: cây phân đoạn.
Ví dụPhép Gộp Xen Kẽ
Đề. Dãy đúng số (). Rút gọn theo tầng: tầng đầu thay từng cặp kề bằng OR, tầng kế bằng XOR, rồi OR, XOR xen kẽ tới khi còn một số. Có lần gán một phần tử; sau mỗi lần in số cuối cùng.
Lời giải. Quá trình rút gọn chính là một cây phân đoạn đầy đủ với lá, chỉ khác phép gộp đổi theo tầng: nút cách lá tầng gộp bằng OR nếu lẻ, XOR nếu chẵn. Nút ở độ sâu nên cách lá tầng. Cài kiểu không đệ quy: lá ở st[2^n + i - 1], dựng lùi từ về 1, mỗi lần gán đi từ lá lên gốc và gộp đúng phép của tầng. Đáp án là st[1]. Mỗi lần gán . Với ví dụ, .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m, sz, st[1 << 18];
void pull(ll v) {
if ((n - __lg(v)) & 1) st[v] = st[v * 2] | st[v * 2 + 1];
else st[v] = st[v * 2] ^ st[v * 2 + 1];
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
sz = 1 << n;
for (ll i = 0; i < sz; i++) cin >> st[sz + i];
for (ll v = sz - 1; v >= 1; v--) pull(v);
while (m--) {
ll p, b;
cin >> p >> b;
ll v = sz + p - 1;
st[v] = b;
for (v >>= 1; v >= 1; v >>= 1) pull(v);
cout << st[1] << '\n';
}
}
2 3
1 6 3 5
1 4
3 4
4 2
1
3
0
Khi đi thi
- Dãy vừa bị sửa vừa bị hỏi theo đoạn, phép gộp không có phép ngược (min, max, gcd, đoạn con tốt nhất) hoặc có cập nhật cả đoạn: cây phân đoạn. Chỉ cần tổng hay đếm với sửa điểm thì Fenwick ngắn hơn; dãy cố định thì tổng tiền tố hoặc bảng thưa.
- Trước khi gõ code, ghi ra giấy: nút lưu gì, hàm gộp (đúng thứ tự trái phải), phần tử trung hòa. Cập nhật đoạn thì thêm: thẻ là gì, hợp thẻ thế nào, áp thẻ lên nút ra sao (nhớ độ dài đoạn).
- Cấp mảng ô, dùng
long long, giá trị trung hòa của min lớn hơn mọi giá trị thật. Lỗi hay gặp nhất: quên đẩy thẻ trong hàm truy vấn, hoặc quên tính lại nút sau khi cập nhật hai con.
Tóm tắt
- Cây phân đoạn là cây nhị phân chia đôi dãy: nút quản lý lưu tóm tắt của đoạn, có nút, mảng ô, dựng .
- Cập nhật điểm tính lại một đường từ lá lên gốc; truy vấn ghép các nút nằm trọn trong đoạn hỏi. Mỗi tầng chỉ có tối đa hai nút cắt nên chỉ ghé tối đa bốn nút, tổng .
- Tóm tắt cần gộp được từ hai nửa bằng một phép kết hợp có phần tử trung hòa; phép gộp không cần giao hoán nhưng phải gộp đúng thứ tự trái phải.
- Đi xuống cây tìm vị trí (bit 1 thứ , phần tử đầu tiên ) mất , không cần chặt nhị phân ngoài.
- Cập nhật đoạn dùng thẻ lười: nút nằm trọn nhận thẻ và sửa tổng của mình ngay, thẻ chỉ đẩy xuống khi cần đi qua nút cắt, cả trong truy vấn. Thẻ là một hàm, hợp theo thứ tự thời gian, thẻ cha luôn mới hơn thẻ con.
Bài tập
Đọc thêm
- Tất tần tật về Cây Phân ĐoạnVNOI Wiki
- Cài đặt cây phân đoạn hiệu quảVNOI Wiki
- Segment Treecp-algorithms
- 线段树OI Wiki
- セグメント木を徹底解説algo-logic
- Lazy SegtreeAtCoder Library