Cây phân đoạn (segment tree) là cấu trúc dữ liệu chuyên trị bài toán vừa cập nhật vừa truy vấn trên đoạn bất kỳ, mỗi thao tác trong . Bài toán quen thuộc nhất của nó: cho dãy số và thao tác xen kẽ nhau, mỗi thao tác hoặc gán lại một phần tử, hoặc hỏi số nhỏ nhất trong một đoạn; trả lời mọi câu hỏi trong .
Ý tưởng của cây phân đoạn là chia đôi dãy liên tiếp thành những đoạn nhỏ dần, tính sẵn đáp án cho từng đoạn, rồi ghép vài đoạn lại thành đáp án cho đoạn được hỏi. Mỗi phần tử chỉ nằm trong khoảng đoạn như vậy, nên khi một phần tử thay đổi, ta chỉ cần tính lại chừng ấy đoạn. Thông tin tính sẵn có thể là số nhỏ nhất, tổng, ước chung lớn nhất, hay phức tạp hơn; và với kỹ thuật thẻ lười, ta còn cộng hay gán được cả một đoạn trong .
IHỏi min đoạn trên dãy bị sửa
Bài toán: cho dãy và thao tác. Mỗi thao tác thuộc một trong hai loại: gán bằng một giá trị mới, hoặc hỏi số nhỏ nhất trong đoạn . Từ đây ta viết tắt số nhỏ nhất là min.
Cách dễ nghĩ ra nhất là gán trực tiếp, còn mỗi câu hỏi thì duyệt cả đoạn:
if (t == 1) a[k] = x;
else {
ll res = 1e18;
for (ll i = l; i <= r; i++) res = min(res, a[i]);
cout << res << '\n';
}
Độ phức tạp: gán , mỗi câu hỏi ; với cần khoảng phép tính.
Tổng tiền tố (bài 0.3) không giúp được ở đây vì hai lý do: phép min không có phép ngược để gỡ phần đầu ra, và mỗi lần gán một phần tử làm sai mọi tiền tố đứng sau nó. Ta có thể thấy được điểm lãng phí của cách duyệt: các câu hỏi khác nhau thường chồng lên nhau, nhưng câu hỏi nào cũng duyệt lại từ đầu. Ta muốn tính sẵn min của một số đoạn, đủ ít để khi một phần tử đổi chỉ phải sửa vài đoạn, nhưng cũng đủ nhiều để ghép được thành mọi đoạn được hỏi.
Cách chọn các đoạn là chia đôi. Đoạn với được tách tại thành hai nửa và ; mỗi nửa lại được tách tiếp như vậy, cho tới khi đoạn chỉ còn một phần tử. Các đoạn này xếp thành một cây:
- mỗi đoạn là một nút của cây, và ta nói nút đó quản lý đoạn đó;
- nút quản lý cả dãy là gốc;
- hai nửa của một đoạn là hai nút con của nút quản lý đoạn đó, gọi là con trái và con phải;
- nút quản lý đúng một phần tử là lá.
Mỗi nút lưu min của đoạn nó quản lý. Cây như vậy được gọi là cây phân đoạn. Hình dưới là cây của dãy . Nút cam nằm trọn trong đoạn hỏi, nút xanh cắt ngang biên của đoạn hỏi, nút mờ nằm ngoài đoạn hỏi. Bạn có thể đổi đoạn hỏi, hoặc gán một phần tử rồi bấm chạy:
Hỏi min đ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à .
Gán một phần tử. Gán chỉ làm thay đổi các nút có đoạn chứa vị trí . Đó là các nút trên đường đi từ lá lên gốc: , , , . Ta tính lại các nút này từ dưới lên, mỗi nút bằng min của hai con; mọi nút khác giữ nguyên.
Để cài đặt, ta đánh số các nút: gốc là nút , hai con của nút là nút và nút . Min của nút lưu ở ô thứ của một mảng. Chương trình gồm ba hàm; cả ba đều đi từ gốc xuống, và nhận vào số hiệu của nút cùng đoạn mà nút đó quản lý:
- Dựng cây: ở lá, min là chính phần tử đó; ở nút khác, dựng hai con trước rồi lấy min của hai con.
- Gán một phần tử: đi xuống đúng con chứa vị trí cần gán, gán ở lá, rồi trên đường quay lên tính lại min của mỗi nút từ hai con.
- Hỏi đoạn: có ba trường hợp. Nút nằm ngoài đoạn hỏi thì trả về một số rất lớn, , để không ảnh hưởng tới phép min. Nút nằm trọn trong đoạn hỏi thì trả về min đã lưu và dừng. Còn lại thì hỏi cả hai con rồi lấy min.
Với , cây chỉ có khoảng tầng, nên gán một phần tử chỉ ghé khoảng nút. Với câu hỏi, chỉ những nút cắt ngang biên của đoạn hỏi mới đi tiếp xuống con, mà mỗi tầng có không quá hai nút như vậy; vì thế một câu hỏi ghé không quá bốn nút mỗi tầng (xem ô Chứng minh).
Chứng minh
Số tầng. Mỗi lần chia đôi, độ dài đoạn giảm còn không quá một nửa (làm tròn lên), nên cây có không quá tầng; với là tầng.
Mỗi tầng có không quá hai nút cắt. Gọi một nút là nút cắt nếu đoạn của nó có cả phần tử nằm trong lẫn phần tử nằm ngoài đoạn hỏi. Đoạn của nút là một đoạn liên tiếp, nên nút cắt 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 một tầng quản lý các đoạn rời nhau, nên mỗi biên nằm trong không quá một nút của tầng đó. Vậy mỗi tầng có không quá hai nút cắt.
Mỗi tầng được ghé không quá bốn nút. Chỉ nút cắt mới gọi xuống hai con; nút nằm trọn hay nằm ngoài đoạn hỏi đều dừng ngay. Hai nút cắt của một tầng sinh ra không quá bốn nút được ghé ở tầng dưới. Vậy một câu hỏi ghé không quá nút khi .
Còn một chi tiết: mảng lưu cây cần bao nhiêu ô? Cây có đúng nút, nhưng số hiệu của các nút không liền nhau.
Hỏi nhanh
Đánh số các nút như trên: gốc là , hai con của nút là và , đoạn tách tại . Với , cây có nút.
Số hiệu lớn nhất của một nút trong cây này là bao nhiêu?
Gốc quản lý , hai con là nút quản lý và nút quản lý . Nút tách thành nút quản lý và nút quản lý . Nút tách tiếp thành nút quản lý và nút quản lý . Vậy lá của vị trí mang số hiệu .
Cây vẫn chỉ có nút, nhưng các số hiệu có khoảng trống. Đáp án là số nút chứ không phải số hiệu lớn nhất; còn là , kích thước mảng ta sẽ dùng.
Vì vậy mảng ô có thể không đủ chỗ. Mảng ô thì luôn đủ (xem ô Chứng minh).
Chứng minh
Gọi . Cây có không quá tầng, và nút ở tầng thứ (gốc ở tầng ) có số hiệu nhỏ hơn . Vậy mọi số hiệu đều nhỏ hơn . Vì nên .
Ví dụGiá Trị Thấp Nhất
Đề. Cho dãy số và yêu cầu, mỗi yêu cầu có một trong hai dạng: 1 k u gán ô thứ bằng ; 2 l r in số nhỏ nhất trong các ô từ đến .
Lời giải. Đây đúng là bài toán của mục này. Trong code, mảng st lưu min của các nút; ba hàm build, update, get lần lượt là dựng cây, gán một phần tử và hỏi đoạn; mỗi hàm nhận số hiệu nút id cùng đoạn mà nút đó quản lý. Với ví dụ của đề, cả dãy có min là ; sau khi gán , đoạn có min là .
#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
Độ phức tạp: dựng cây , mỗi yêu cầu ; với cần khoảng lần ghé nút.
IILưu thông tin khác ở mỗi nút
Nhìn lại chương trình ở trên: phép min chỉ xuất hiện ở ba chỗ, là cách tính một nút từ hai con, giá trị của lá, và giá trị trả về cho nút nằm ngoài đoạn hỏi. Mọi phần còn lại chỉ nói về hình dạng của cây. Thay phép min bằng phép cộng và thay bằng , ta có cây cho tổng đoạn; thay bằng phép lấy ước chung lớn nhất và , ta 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 điều kiện.
Tổng quát, mỗi nút lưu một thông tin về đoạn nó quản lý, và cần có hai điều:
- Thông tin của một đoạn tính được từ thông tin của hai nửa, bằng một hàm mà ta gọi là phép gộp.
- Có một giá trị dành cho “đoạn rỗng”, gộp với nó thì không thay đổi gì, gọi là phần tử trung hòa. Hàm hỏi đoạn trả về phần tử trung hòa cho nút nằm ngoài đoạn hỏi.
Khi có đủ hai điều này, ba hàm của cây giữ nguyên, chỉ cần thay phép min bằng phép gộp.
Không phải thông tin nào cũng gộp được. Điều kiện thứ nhất đòi thông tin của nút cha phải tính được chỉ từ thông tin đã lưu ở hai nút con. Khi một đại lượng không gộp được một mình, cách thường dùng là lưu kèm vài đại lượng phụ, như ví dụ cuối mục này.
Hỏi nhanh
Mỗi đoạn chỉ được lưu đúng một con số, và con số của nút cha phải tính được chỉ từ con số của hai nút con. Trung vị của một đoạn là phần tử đứng giữa sau khi sắp xếp đoạn; nếu đoạn có số phần tử chẵn thì lấy phần tử nhỏ hơn trong hai phần tử đứng giữa.
Ba trong bốn con số dưới đây gộp được như vậy. Con số nào là ngoại lệ?
Tổng bình phương và số phần tử chẵn của đoạn cha là tổng của hai con; ước chung lớn nhất của đoạn cha là ước chung lớn nhất của hai số đã lưu ở hai con.
Trung vị thì không gộp được. Lấy nửa trái có trung vị , và hai nửa phải khác nhau là và , cùng có trung vị . Ghép với ta được , có trung vị ; ghép với ta được , có trung vị . Hai nút cha có cùng cặp trung vị con là và , nhưng trung vị khác nhau, nên không thể tính trung vị của cha chỉ từ hai số đã lưu.
Cần chú ý thêm về thứ tự: phép gộp không nhất thiết phải giao hoán, tức là gộp nửa trái với nửa phải có thể cho kết quả khác gộp nửa phải với nửa trái. Vì vậy ta luôn gộp theo đúng thứ tự vị trí: nửa trái trước, nửa phải sau. Với min, tổng hay ước chung lớn nhất, thứ tự không ảnh hưởng; nhưng với ví dụ ngay dưới đây, đảo thứ tự sẽ cho kết quả sai.
Ví dụTổng Đoạn Liên Tiếp
Đề. Cho dãy số với , 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, có tổng .
Lời giải. Nếu mỗi nút chỉ lưu tổng lớn nhất của một đoạn con, ta chưa gộp được: đoạn con tốt nhất của đoạn ghép có thể vắt qua chỗ nối hai nửa, và khi đó nó gồm một đoạn ở cuối nửa trái nối với một đoạn ở đầu nửa phải. Vì vậy mỗi nút lưu bốn số:
- : tổng cả đoạn;
- : tổng lớn nhất của một đoạn ở đầu (tiền tố), được chọn đoạn rỗng;
- : tổng lớn nhất của một đoạn ở cuối (hậu tố), được chọn đoạn rỗng;
- : tổng lớn nhất của một đoạn con, được chọn đoạn rỗng.
Gộp nửa trái với nửa phải :
Lá có giá trị thì có tổng , còn ba số kia bằng : hoặc lấy chính phần tử đó, hoặc lấy đoạn rỗng. Bài chỉ hỏi cả dãy, nên sau mỗi lần gán, đáp án là của gốc, không cần hàm hỏi đoạn.
Trong hình, mỗi nút giữ bốn số; bạn có thể đổi hai nửa để xem cách gộp:
Chứng minh
Một tiền tố của đoạn ghép hoặc nằm gọn trong nửa trái, hoặc gồm cả nửa trái nối với một tiền tố của nửa phải; lấy giá trị lớn hơn của hai trường hợp, ta được công thức của . Hậu tố làm đối xứng.
Một đoạn con của đoạn ghép hoặc nằm gọn trong nửa trái, hoặc nằm gọn trong nửa phải, hoặc vắt qua chỗ nối. Trường hợp cuối gồm một hậu tố của nửa trái và một tiền tố của nửa phải, nên tổng lớn nhất của nó là .
Phép gộp này không giao hoán: gộp với theo đúng thứ tự cho (đoạn ), còn đổi chỗ thì ra . Tổng có thể tới , nên các số dùng kiểu long long.
#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
Độ phức tạp: dựng cây , mỗi lần gán ; với cần khoảng lần gộp nút.
IIITìm vị trí bằng cách đi xuống cây
Có những bài không hỏi giá trị trên một đoạn mà hỏi một vị trí. Bài toán: cho dãy bit , mỗi là hoặc , và các thao tác xen kẽ nhau: đảo một bit, hoặc hỏi bit thứ tính từ trái nằm ở vị trí nào.
Ta có thể dùng cây lưu tổng, tức là số bit của mỗi đoạn, rồi chặt nhị phân (bài 0.6) trên vị trí: tìm vị trí nhỏ nhất mà đoạn có ít nhất bit . Mỗi bước chặt cần một câu hỏi tốn , nên một lần tìm tốn . Cây phân đoạn cho phép làm nhanh hơn, bằng cách đi thẳng từ gốc xuống một lá.
Mỗi nút lưu số bit trong đoạn của nó. Ta đi từ gốc xuống và luôn giữ điều sau: đáp án là bit thứ nằm trong đoạn của nút đang đứng. Tại một nút, nếu con trái có ít nhất bit thì đáp án nằm trong con trái; ta đi sang trái và giữ nguyên . Ngược lại, đáp án nằm trong con phải; ta bỏ qua mọi bit của con trái, nên trừ số bit của con trái khỏi rồi đi sang phải. Mỗi bước xuống một tầng, và lá cuối cùng chính là đáp án. Trước khi đi, cần kiểm tra gốc có ít nhất bit ; nếu không thì không có đáp án.
Trong hình, mỗi nút ghi số bit trong đoạn của nó; đường cam là đường đi xuống:
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]);
}
Độ phức tạp: , với cần khoảng bước.
Lỗi hay gặp là quên trừ số bit của con trái khi đi sang phải: khi đó vẫn tính cả những bit đã bỏ qua, và đường đi lạc sang nhánh sai.
Cùng cách đi xuống đó dùng được cho câu hỏi “vị trí đầu tiên có ” trên cây lưu max: nếu max của con trái ít nhất thì đi sang trái, ngược lại đi sang phải. Ví dụ dưới đây làm đúng như vậy.
Sử dụng kiến thức này cũng có thể giải bài: tìm phần tử nhỏ thứ của một tập số đang được thêm và xóa. Cho chỉ số của cây là giá trị, mỗi lá đếm số lần giá trị đó có trong tập; phần tử nhỏ thứ chính là vị trí của “bit thứ ” theo cách đi xuống ở trên.
Ví dụPhân Bổ Phòng
Đề. Có khách sạn, khách sạn thứ còn phòng trống. Lần lượt đoàn khách đến, đoàn thứ 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 trống; khách sạn đó bớt đi phòng. Nếu không có khách sạn nào đủ chỗ, in .
Lời giải. Cây lưu max số phòng trống của mỗi đoạn khách sạn. Nếu max ở gốc nhỏ hơn , không khách sạn nào đủ chỗ, ta in . Ngược lại, ta đ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 là max của con trái ít nhất . Tới lá là đúng khách sạn cần tìm. Hàm walk trong code vừa tìm vừa sửa trong một lượt: tới lá thì trừ đi phòng, và trên đường quay lên tính lại max của mỗi nút. Với ví dụ của đề, đoàn thứ hai cần phòng được xếp vào khách sạn , vì lúc đó khách sạn chỉ còn 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
Độ phức tạp: dựng cây , mỗi đoàn đi một đường từ gốc xuống lá tốn ; với cần khoảng lần ghé nút.
IVCộng cả đoạn: thẻ lười
Bài toán: cho dãy và các thao tác xen kẽ nhau: cộng vào mọi phần tử của đoạn , hoặc hỏi tổng của đoạn .
Nếu cộng bằng cách gán lại từng vị trí trong đoạn, một thao tác có thể tốn . Ta có thể thấy được điểm lãng phí: câu hỏi tổng đoạn chỉ đọc các nút lớn nhất nằm trọn trong đoạn hỏi, chứ không cần biết từng lá. Vì vậy thao tác cộng cũng chỉ nên sửa đúng những nút nằm trọn trong đoạn cần cộng, giống cách câu hỏi tìm các nút đó.
Với một nút nằm trọn trong đoạn cần cộng, tổng mới của nút tính được ngay: tổng cũ cộng nhân với độ dài đoạn. Còn các nút bên dưới thì ta chưa sửa vội, mà chỉ ghi lại ở nút đó: “mọi phần tử trong đoạn này cần cộng thêm , nhưng hai nút con chưa được cộng”. Ghi chú này được gọi là thẻ lười (lazy tag). Chỉ khi một thao tác sau cần đi xuống các con của nút này, ta mới đẩy thẻ xuống: sửa tổng của hai con, chuyển thẻ thành thẻ của hai con, rồi xóa thẻ ở nút. Kỹ thuật này được gọi là cập nhật lười (lazy propagation).
Hai quy tắc dưới đây dùng cho cả thao tác cộng lẫn câu hỏi:
- Gặp nút nằm trọn trong đoạn: với thao tác cộng, sửa tổng của nút và cộng vào thẻ của nút; rồi dừng.
- Gặp nút cắt ngang biên: đẩy thẻ của nút xuống hai con trước khi đi xuống. Với thao tác cộng, khi quay lên thì tính lại tổng của nút từ hai con.
Trong hình, nhãn màu xanh lá là thẻ chưa được đẩy xuống. Bạn có thể cộng thêm vào một đoạn, hoặc hỏi tổng một đoạn nhỏ để xem thẻ được đẩy:
Ta chạy tay trên dãy với thao tác cộng vào đoạn . Hai nút và nằm trọn trong đoạn nên nhận thẻ , tổng thành và . Hai lá và cũng nằm trọn trong đoạn, giá trị thành và . Các nút cắt ngang biên được tính lại từ hai con, và gốc thành . Bốn lá từ tới chưa hề bị sửa. Nếu sau đó hỏi tổng đoạn , nút cắt ngang biên nên thẻ được đẩy xuống, và lá trả về .
Trong code, mảng st lưu tổng của các nút, mảng lz lưu thẻ. Hàm apply đặt thẻ lên một nút có độ dài : tổng tăng , thẻ tăng . Hàm down đẩy thẻ của một nút xuống hai con rồi xóa thẻ đó.
#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
Độ phức tạp: dựng cây , mỗi thao tác ; với cần khoảng lần ghé nút.
Chứng minh
Khi một hàm đi tới một nút, mọi nút tổ tiên trên đường đi đều đã được đẩy thẻ xuống, vì hàm chỉ đi từ một nút xuống con sau khi gọi down ở nút đó. Vậy mọi phần cộng của các thao tác trước đều đã được tính vào tổng của nút này, và tổng lưu ở nút là tổng đúng. Ngoài ra, khi đặt thẻ lên một nút, tổng của chính nút đó được sửa ngay, nên một câu hỏi dừng ở nút đó cũng đọc được tổng đúng.
Các nút được ghé giống hệt câu hỏi ở mục đầu, và mỗi lần đẩy thẻ chỉ tốn , nên mỗi thao tác vẫn tốn .
Hỏi nhanh
Một bạn cài thẻ lười như trên, nhưng quên gọi down trong hàm hỏi đoạn. Bạn có thể bấm Chạy để xem chương trình in gì với dãy , thao tác cộng vào đoạn , rồi hỏi tổng đoạn :
#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];
ll mid = (l + r) >> 1;
return get(id * 2, l, mid, u, v) + get(id * 2 + 1, mid + 1, r, u, v);
}
signed main() {
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';
}
}
3 2
1 2 3
1 1 2 5
2 1 1
1
Đáp án đúng là , nhưng chương trình in ra . Vì sao?
Nút nằm trọn trong đoạn cần cộng, nên nhận thẻ và tổng của nó thành ; hai lá bên dưới chưa được sửa. Câu hỏi đi qua nút , một nút cắt ngang biên, nhưng vì không gọi down, nó đi thẳng xuống lá vẫn còn giá trị cũ là . Cách sửa là gọi down trước khi đi xuống, như code ở trên.
Lỗi này khó phát hiện vì chương trình vẫn đúng khi câu hỏi chỉ đọc các nút nằm trọn, chẳng hạn hỏi đoạn vẫn ra ; nó chỉ sai khi một thẻ còn nằm ở nút cắt. Các lựa chọn còn lại không đúng: hàm apply có sửa tổng của nút và có nhân với độ dài đoạn, còn hàm hỏi đoạn chỉ đọc mảng st, không đọc mảng a.
VCộng và gán cùng lúc: hợp thẻ
Khi có từ hai loại cập nhật đoạn trở lên, thứ tự của chúng bắt đầu quan trọng. “Cộng rồi gán ” cho mọi phần tử bằng , còn “gán rồi cộng ” cho mọi phần tử bằng . Thẻ của một nút phải nhớ đúng tác dụng của cả chuỗi thao tác chưa được đẩ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ủa đoạn. Cộng là thẻ ; gán là thẻ ; nhân là thẻ ; thẻ “không làm gì” là . Ta cần hai phép tính:
- Áp thẻ lên một nút có độ dài và tổng : tổng của các giá trị trên phần tử là .
- Hợp hai thẻ. Thẻ đến sau thẻ cho , tức là thẻ .
Kiểm tra lại với ví dụ trên: “cộng rồi gán ” là thẻ hợp sau thẻ , cho ; “gán rồi cộng ” là thẻ hợp sau thẻ , cho . Cả hai đều khớp với phép tính tay.
Khi đẩy thẻ từ cha xuống con, thẻ của cha luôn đến sau thẻ đang có ở con (xem ô Chứng minh), nên thẻ mới của con là thẻ của cha hợp sau thẻ của con.
Chứng minh
Một thẻ chỉ được đặt lên một nút khi thao tác đi tới nút đó. Muốn đi tới nút đó, mọi tổ tiên của nó đã phải đẩy thẻ xuống, nên lúc đó các tổ tiên không còn thẻ. Vì vậy mọi thẻ đang nằm ở tổ tiên đều được đặt sau thẻ đang nằm ở con, tức là đến sau theo thời gian.
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProCần cẩn thận: không phải loại thẻ nào cũng dùng được với mọi thông tin lưu ở nút. Thẻ phải áp được lên một nút chỉ từ thông tin đã lưu ở nút đó và độ dài đoạn. Chẳng hạn, cộng vào mọi phần tử thì tổng mới tính được ngay; nhưng tổng lớn nhất của một đoạn con thì không, vì đoạn con tốt nhất có thể thay đổi tùy theo .
Sử dụng kiến thức này cũng có thể giải bài: nhân cả đoạn với một số, cộng một số vào cả đoạn, và hỏi tổng đoạn theo modulo. Thẻ vẫn là cặp như trên; chỉ cần lấy modulo sau mọi phép nhân và phép cộng.
Ví dụCộng, Gán Và Lấy Tổng
Đề. Cho mảng số và thao tác trên đoạn : 1 L R X cộng vào mọi phần tử; 2 L R X gán mọi phần tử bằng ; 3 L R in tổng. Mọi và nằm trong khoảng từ tới .
Lời giải. Thẻ là cặp như trên, nghĩa là “mỗi phần tử của đoạn thành ”, ban đầu là . Cộng là thẻ , gán là thẻ . Ta không cần xét riêng nút đang có thẻ gán hay thẻ cộng, vì mọi trường hợp đã nằm trong công thức hợp thẻ. Trong code, hai mảng mul và ad lưu hai thành phần và của thẻ. Cần chú ý: hàm dựng cây phải đặt mul bằng cho mọi nút, vì mảng toàn cục ban đầu toàn số , mà thẻ lại có nghĩa là “gán bằng ”.
Một phần tử có thể tới khoảng (được cộng tới lần), tổng cả mảng tới khoảng , vẫn vừa kiểu long long. Với ví dụ của đề, sau khi cộng vào đoạn rồi gán cho đoạn , mảng là , có tổng .
#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
Độ phức tạp: dựng cây , mỗi thao tác ; với cần khoảng lần ghé nút.
VICài đặt không đệ quy, nhanh hơn
Khi chỉ cần gán một phần tử và hỏi đoạn, không cần thẻ lười, ta có một cách cài đặt ngắn hơn, chạy nhanh hơn và chỉ dùng ô. Cách này đánh số vị trí từ và dùng đoạn nửa mở , tức là đoạn gồm các vị trí .
Lá của vị trí đặt ở ô , và cha của nút là nút . Dựng cây là một vòng lặp lùi từ về ; gán một phần tử là đi từ lá lên gốc, tính lại từng nút trên đường. Để hỏi đoạn , ta đưa hai biên lên dần từng tầng:
- nếu là số lẻ thì nút là con phải, và cha của nó chứa cả nút nằm bên trái , ngoài đoạn hỏi; vì vậy ta lấy riêng nút rồi dịch sang phải một ô;
- nếu là số lẻ thì nút là con trái, và cha của nó chứa cả nút , ngoài đoạn hỏi; vì vậy ta lấy riêng nút .
Sau đó cả hai biên cùng lên tầng trên, và ta dừng khi hai biên gặp nhau.
#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
Độ phức tạp: dựng cây , mỗi thao tác ; với cần khoảng phép tính.
Chương trình này đúng với mọi , kể cả khi không phải lũy thừa của như ví dụ có , miễn là phép gộp giao hoán (min, max, tổng, ước chung lớn nhất). Với phép gộp không giao hoán, cần đệm số lá lên một lũy thừa của bằng phần tử trung hòa, rồi gộp riêng phần bên trái, phần bên phải và ghép hai phần lại ở cuối.
So với bản đệ quy, bản không đệ quy chạy nhanh hơn rõ rệt:
Bản không đệ quy nhanh hơn bản đệ quy khoảng lần, vì không tốn lời gọi hàm và mảng chỉ bằng một nửa. Vì vậy khi không cần thẻ lười và giới hạn thời gian chặt, nên cân nhắc dùng bản không đệ quy.
Bài nâng cao
Xem 2 bài nâng cao
Ví dụThay Bằng Số Ước
Đề. Cho dãy số nguyên dương và thao tác: 1 l r thay mỗi trong đoạn bằng , số ước dương của ; 2 l r in tổng của đoạn .
Lời giải. Thao tác loại không dùng được thẻ lười: biết tổng của một đoạn không cho biết tổng số ước của các phần tử trong đoạn. Tuy nhiên, số ước giảm rất nhanh. Với thì , và với , chỉ sau nhiều nhất lần thay, đã thành hoặc . Hai số này không đổi nữa, vì và .
Vì vậy ta cứ thay từng phần tử ở lá, nhưng bỏ qua những đoạn mà mọi phần tử đều đã là hoặc . Mỗi nút lưu tổng và max của đoạn; khi thay, gặp nút có max không quá thì dừng ngay, không đi xuống. Mỗi phần tử chỉ thực sự bị thay không quá lần, nên tổng số nút được ghé vẫn nhỏ (xem ô Chứng minh).
Số ước của mọi số tới được tính trước bằng một vòng lặp kiểu sàng: với mỗi , cộng vào số ước của mọi bội của . Vòng lặp này tốn khoảng phép tính.
Chứng minh
khi . Các ước của ghép được thành từng cặp và với , nên , nhỏ hơn khi ; còn và . Thử hết các số tới thì số lần thay nhiều nhất trước khi thành hoặc là , chẳng hạn .
Số nút được ghé. Xét một thao tác loại trên đoạn . Các nút được ghé gồm các nút cắt ngang biên, không quá hai nút mỗi tầng như ở mục đầu, cùng con của chúng; và các nút nằm trọn trong đoạn mà có max lớn hơn , cùng con của chúng. Mỗi nút nằm trọn có max lớn hơn chứa ít nhất một phần tử sẽ bị thay thật sự trong thao tác này, nên ta tính nó vào lần thay của phần tử đó; mỗi lần thay chỉ được tính cho các tổ tiên của lá đó, không quá nút. Cả dãy có không quá lần thay thật sự, nên tổng số nút được ghé qua mọi thao tác là .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 3e5 + 5, C = 1e6 + 5;
ll n, m, a[N], d[C], st[4 * N], mx[4 * N];
void build(ll id, ll l, ll r) {
if (l == r) {
st[id] = mx[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];
mx[id] = max(mx[id * 2], mx[id * 2 + 1]);
}
void update(ll id, ll l, ll r, ll u, ll v) {
if (v < l || r < u || mx[id] <= 2) return;
if (l == r) {
st[id] = mx[id] = d[st[id]];
return;
}
ll mid = (l + r) >> 1;
update(id * 2, l, mid, u, v);
update(id * 2 + 1, mid + 1, r, u, v);
st[id] = st[id * 2] + st[id * 2 + 1];
mx[id] = max(mx[id * 2], mx[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];
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);
for (ll i = 1; i < C; i++)
for (ll j = i; j < C; j += i) d[j]++;
cin >> n >> m;
for (ll i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
while (m--) {
ll t, l, r;
cin >> t >> l >> r;
if (t == 1) update(1, 1, n, l, r);
else cout << get(1, 1, n, l, r) << '\n';
}
}
5 6
1 2 6 10 12
2 1 5
1 3 5
2 1 5
1 1 5
2 2 4
2 1 1
31
17
8
1
Độ phức tạp: sàng số ước , dựng cây , các thao tác tổng cộng ; với cần khoảng phép tính cho sàng và không quá khoảng lần ghé nút.
Ví dụDãy Cột Chênh Đủ Xa
Đề. Có cột xếp từ trái sang phải, cột thứ cao . Chọn một dãy cột sao cho hai cột liên tiếp được chọn chênh nhau ít nhất , tức là . In độ dài lớn nhất của dãy và một dãy chỉ số đạt độ dài đó ().
Lời giải. Đây là một bài quy hoạch động (bài 1.3). Gọi là độ dài lớn nhất của một dãy hợp lệ kết thúc tại cột . Cột đứng ngay trước trong dãy là một cột có hoặc , nên
với quy ước max của tập rỗng bằng . Tính thẳng công thức này tốn , khoảng phép tính.
Ta có thể thấy rằng điều kiện của chỉ phụ thuộc vào giá trị : nó phải nằm trong một khoảng ở dưới hoặc một khoảng ở trên. Vì vậy ta dựng cây phân đoạn lưu max, có chỉ số là giá trị độ cao: sau khi tính xong , ta ghi vào lá ứng với độ cao , giữ số lớn hơn nếu lá đó đã có giá trị. Khi tính , các cột đã được ghi đều đứng trước , nên chỉ cần hỏi max trên hai khoảng độ cao.
Độ cao tới , không dùng trực tiếp làm chỉ số được. Ta nén giá trị: sắp xếp các độ cao khác nhau tăng dần, rồi thay mỗi độ cao bằng thứ tự của nó trong dãy đã sắp xếp. Hai khoảng độ cao khi đó thành hai đoạn chỉ số, tìm bằng chặt nhị phân (bài 0.6): các độ cao không quá là một đoạn ở đầu, các độ cao ít nhất là một đoạn ở cuối.
Để in dãy, mỗi lá lưu cặp (độ dài, chỉ số cột), và ta nhớ cột đứng trước của mỗi cột. Đi ngược từ cột có độ dài lớn nhất theo các cột đứng trước, ta được dãy cần in theo thứ tự ngược lại.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, d, k, a[N], b[N], f[N], par[N];
pair<ll, ll> st[4 * N];
void update(ll id, ll l, ll r, ll pos, pair<ll, ll> val) {
if (l == r) {
st[id] = max(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] = max(st[id * 2], st[id * 2 + 1]);
}
pair<ll, ll> get(ll id, ll l, ll r, ll u, ll v) {
if (v < l || r < u) return make_pair(0LL, 0LL);
if (u <= l && r <= v) return st[id];
ll mid = (l + r) >> 1;
return max(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 >> d;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
b[i] = a[i];
}
sort(b + 1, b + n + 1);
k = unique(b + 1, b + n + 1) - b - 1;
ll best = 0;
for (ll i = 1; i <= n; i++) {
ll p = upper_bound(b + 1, b + k + 1, a[i] - d) - b - 1;
ll q = lower_bound(b + 1, b + k + 1, a[i] + d) - b;
pair<ll, ll> t = max(get(1, 1, k, 1, p), get(1, 1, k, q, k));
f[i] = t.first + 1;
par[i] = t.second;
update(1, 1, k, lower_bound(b + 1, b + k + 1, a[i]) - b, make_pair(f[i], i));
if (f[i] > f[best]) best = i;
}
vector<ll> res;
for (ll i = best; i; i = par[i]) res.push_back(i);
reverse(res.begin(), res.end());
cout << res.size() << '\n';
for (ll i = 0; i < (ll)res.size(); i++) cout << res[i] << (i + 1 == (ll)res.size() ? '\n' : ' ');
}
6 4
2 8 4 12 7 15
6
1 2 3 4 5 6
Độ phức tạp: cho bước sắp xếp, các lần chặt nhị phân, và hai câu hỏi cùng một lần ghi cho mỗi cột; với cần khoảng lần ghé nút.
Khi đi thi
- Dãy vừa bị sửa vừa bị hỏi theo đoạn, mà phép gộp không có phép ngược (min, max, ước chung lớn nhất, đoạn con tốt nhất) hoặc có cập nhật cả đoạn, thì nghĩ tới cây phân đoạn. Chỉ cần tổng đoạn với gán một phần tử thì cây Fenwick (bài 1.7) ngắn hơn; dãy không đổi thì cân nhắc tổng tiền tố.
- Trước khi gõ code, nên viết ra giấy: nút lưu gì, phép gộp (đúng thứ tự trái phải), phần tử trung hòa. Với 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ớ nhân với độ dài đoạn).
- Cấp mảng ô và dùng kiểu
long long. Lỗi hay gặp: quên đẩy thẻ trong hàm hỏi đoạn, quên tính lại nút sau khi cập nhật hai con, và với cây min, phần tử trung hòa chưa đủ lớn.
Tóm tắt
- Cây phân đoạn chia đôi dãy liên tiếp: mỗi nút quản lý một đoạn và lưu thông tin của đoạn đó. Cây có nút, lưu trong mảng ô, dựng trong .
- Gán một phần tử thì tính lại một đường từ lá lên gốc; hỏi đoạn thì ghép các nút lớn nhất nằm trọn trong đoạn hỏi. Mỗi tầng có không quá hai nút cắt, nên mỗi thao tác tốn .
- Thông tin ở nút phải gộp được từ hai nửa bằng một phép gộ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 thứ , phần tử đầu tiên ít nhất ) tốn , không cần chặt nhị phân bê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ỉ được đẩy xuống khi cần đi qua nút cắt, trong cả thao tác cập nhật lẫn câu hỏi. Nhiều loại cập nhật thì coi thẻ là một hàm và hợp theo thứ tự thời gian.
Bài tập
Bài tập ngoài
Đi xuống cây
IIISegment TreeAtCoderĐọ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