Chương 1·Bài 17

Segment Tree

Cho dãy a1,a2,…,ana_1, a_2, \dots, a_n và qq 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 giá trị nhỏ nhất trên một đoạn [l,r][l, r]. Quét cả đoạn để trả lời mỗi câu hỏi tốn O(n)O(n); với n=q=2⋅105n = q = 2 \cdot 10^5 đó là 4⋅10104 \cdot 10^{10} phép so sánh, xa giới hạn một giây. 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 nó.

Cây phân đoạn (segment tree) trả lời cả hai loại thao tác trong O(log⁡n)O(\log n). 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 như vậy thành đáp án cho đoạn bất kỳ. Thông tin lưu không nhất thiết là min: có thể là tổng, ước chung lớn nhất, hay đoạn con liên tiếp 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 O(log⁡n)O(\log 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, mỗi đoạn một nút

Ý 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 [l,r][l, r] với l<rl < r tách tại mid=⌊(l+r)/2⌋mid = \lfloor (l + r)/2 \rfloor thành [l,mid][l, mid] và [mid+1,r][mid + 1, r], 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ý [1,n][1, n], 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ó 2n−12n - 1 nút. Hình dưới là cây của a=(5,2,7,3,8,1,6,4)a = (5, 2, 7, 3, 8, 1, 6, 4).

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 [2,7][2, 7]. Ta lấy các nút lớn nhất nằm trọn trong đoạn hỏi: [2,2][2,2], [3,4][3,4], [5,6][5,6] và [7,7][7,7]. Bốn đoạn này rời nhau và ghép lại đúng bằng [2,7][2, 7], nên đáp án là min⁡(2,3,1,6)=1\min(2, 3, 1, 6) = 1. Sửa một phần tử. Gán a6←9a_6 \gets 9 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: [6,6][6,6], [5,6][5,6], [5,8][5,8], [1,8][1,8]. 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 [l,r][l, r], 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ó 4n4n ô. Ba hàm build, update, get nhận số hiệu nút cùng đoạn [l,r][l, r] mà nút ấy quản lý. Hàm get(id, l, r, u, v) hỏi đoạn [u,v][u, v] 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 (101810^{18} 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 ⌈log⁡2n⌉+1\lceil \log_2 n \rceil + 1 tầng, với n=2⋅105n = 2 \cdot 10^5 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 4⋅19=764 \cdot 19 = 76 nút.

Với n = 16, 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 dd có số hiệu trong [2d,2d+1)[2^d, 2^{d+1}) và tầng cuối thường còn khoảng trống. Số hiệu lớn nhất không vượt 2h+1−12^{h+1} - 1 với h=⌈log⁡2n⌉h = \lceil \log_2 n \rceil, mà 2h−1<n2^{h-1} < n nên 2h+1<4n2^{h+1} < 4n. Mảng 4n4n ô luôn đủ, còn 2n2n ô có thể thủng.

Hỏi nhanh

Ta dùng cách đánh số: gốc là 1, hai con của nút vv là 2v2v và 2v+12v + 1, đoạn [l,r][l, r] chia tại mid=⌊(l+r)/2⌋mid = \lfloor (l + r)/2 \rfloor. Với n=6n = 6 cây có 2n−1=112n - 1 = 11 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?

Ví dụGiá Trị Thấp Nhất

Đề. Dãy n≤2⋅105n \le 2 \cdot 10^5 ô, q≤2⋅105q \le 2 \cdot 10^5 yêu cầu: 1 k u gán ô kk bằng uu; 2 l r in min của các ô từ ll đến rr.

Lời giải. Đúng cây min ở trên: nút [l,r][l, r] 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.

C++
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ỉ O(log⁡n)O(\log n) nút bị đổi.

C++
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 [u,v][u, v] trả về phần tử trung hòa 101810^{18}, không ảnh hưởng phép min; nút nằm trọn trong [u,v][u, v] trả về giá trị đã lưu; còn lại hỏi hai con.

C++
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 (7,2,9,2,6)(7, 2, 9, 2, 6) có min 2; sau khi gán a2=8a_2 = 8, đoạn [1,3][1, 3] có min 7.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Thời gian O((n+q)log⁡n)O((n + q) \log n), khoảng 1,5⋅1071{,}5 \cdot 10^7 lần ghé nút; mảng st cần 4n4n ô.

Nút lưu gì cũng được, miễn gộp được

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 00 và phép cộng thì ta có cây cho tổng; bằng 00 và gcd⁡\gcd 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 đó aa là nửa trái và bb 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.)

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ụMức Nhỏ Nhất Và Số Lần

Đề. Mảng n≤105n \le 10^5 phần tử đánh số từ 0, m≤105m \le 10^5 thao tác: 1 i v gán ai=va_i = v; 2 l r in min trên đoạn nửa mở [l,r)[l, r) cùng số vị trí đạt min đó.

Lời giải. Mỗi nút lưu cặp (min, số lần đạt min). Ba hàm build, update, get giữ nguyên khung của ví dụ trước; chỉ thay phép min bằng hàm gộp cặp và phần tử trung hòa.

Bước 1: gộp hai cặp. Nếu min của hai nửa khác nhau, mọi vị trí đạt min của cả đoạn nằm ở nửa có min nhỏ hơn, nên giữ nguyên cặp của nửa ấy. Nếu bằng nhau, cả hai nửa đều đóng góp: cộng hai số lần.

C++
pair<ll, ll> combine(pair<ll, ll> x, pair<ll, ll> y) {
    if (x.first < y.first) return x;
    if (y.first < x.first) return y;
    return {x.first, x.second + y.second};
}

Bước 2: lá và phần tử trung hòa. Lá của ô giá trị vv là (v,1)(v, 1), ở cả build lẫn update. Nút ngoài đoạn hỏi trả về (1018,0)(10^{18}, 0): min lớn hơn mọi giá trị thật nên combine luôn chọn bên kia, và số lần 0 không làm sai tổng.

C++
pair<ll, ll> get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return {1e18, 0};
    if (u <= l && r <= v) return st[id];
    ll mid = (l + r) >> 1;
    return combine(get(id * 2, l, mid, u, v), get(id * 2 + 1, mid + 1, r, u, v));
}

Bước 3: đổi chỉ số của đề. Cây đánh số từ 1, đề đánh số từ 0 với đoạn nửa mở: phần tử aia_i là vị trí i+1i + 1, và đoạn [l,r)[l, r) là các vị trí l+1..rl + 1..r.

C++
if (t == 1) update(1, 1, n, x + 1, y);
else {
    pair<ll, ll> res = get(1, 1, n, x + 1, y);
    cout << res.first << ' ' << res.second << '\n';
}

Với ví dụ, (4,1,1,3,2)(4, 1, 1, 3, 2) có min 1 xuất hiện 2 lần.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1e5 + 5;
ll n, m, a[N];
pair<ll, ll> st[4 * N];

pair<ll, ll> combine(pair<ll, ll> x, pair<ll, ll> y) {
    if (x.first < y.first) return x;
    if (y.first < x.first) return y;
    return {x.first, x.second + y.second};
}

void build(ll id, ll l, ll r) {
    if (l == r) {
        st[id] = {a[l], 1};
        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] = {val, 1};
        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]);
}

pair<ll, ll> get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return {1e18, 0};
    if (u <= l && r <= v) return st[id];
    ll mid = (l + r) >> 1;
    return combine(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 >> m;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (m--) {
        ll t, x, y;
        cin >> t >> x >> y;
        if (t == 1) update(1, 1, n, x + 1, y);
        else {
            pair<ll, ll> res = get(1, 1, n, x + 1, y);
            cout << res.first << ' ' << res.second << '\n';
        }
    }
}
5 5
4 1 1 3 2
2 0 5
1 2 5
2 0 3
1 0 1
2 0 4

Mỗi thao tác O(log⁡n)O(\log n).

Ví dụTổng Đoạn Liên Tiếp

Đề. Dãy n≤2⋅105n \le 2 \cdot 10^5 số, ∣ai∣≤109|a_i| \le 10^9, và m≤2⋅105m \le 2 \cdot 10^5 lần gán ak←xa_k \gets x. 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 aa với nửa phải bb:

pre=max⁡(a.pre, a.sum+b.pre),suf=max⁡(b.suf, b.sum+a.suf),best=max⁡(a.best, b.best, a.suf+b.pre).\mathrm{pre} = \max(a.\mathrm{pre},\ a.\mathrm{sum} + b.\mathrm{pre}), \quad \mathrm{suf} = \max(b.\mathrm{suf},\ b.\mathrm{sum} + a.\mathrm{suf}), \quad \mathrm{best} = \max(a.\mathrm{best},\ b.\mathrm{best},\ a.\mathrm{suf} + b.\mathrm{pre}).
Mỗi nút giữ bốn số; đoạn tốt nhất của L ghép R nằm trong L, trong R, hoặc là hậu tố của L nối tiền tố của R.

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: (2,−1)(2, -1) ghép (4,−5)(4, -5) cho best 5, đổi chỗ thì ra 4.

Bước 1: nút và lá. Lá giá trị xx có tổng xx, còn ba số kia là max⁡(0,x)\max(0, x): hoặc lấy chính ô đó, hoặc lấy đoạn rỗng.

C++
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.

C++
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.

C++
update(1, 1, n, k, x);
cout << st[1].best << '\n';

Tổng tới 2⋅10142 \cdot 10^{14}, vừa ll.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Mỗi lần gán O(log⁡n)O(\log n); đọc đáp án O(1)O(1).

Đi xuống cây để tìm vị trí

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 b1,…,bnb_1, \dots, b_n, thao tác đảo một bit, và câu hỏi “bit 1 thứ kk 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 O(log⁡n)O(\log n) cho mỗi bước chặt, tổng O(log⁡n⋅log⁡n)O(\log n \cdot \log n). 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ứ kk 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 kk bit 1 thì đi trái, giữ nguyên kk; ngược lại bỏ qua mọi bit 1 của con trái, trừ chúng khỏi kk 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 kk bit 1.

Mỗi nút ghi số bit 1 trong đoạn của nó; đường cam là đường đi xuống.
C++
#include <bits/stdc++.h>
using namespace std;
using 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

Các bit 1 ban đầu nằm ở vị trí 2,3,6,8,92, 3, 6, 8, 9, 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 đó kk 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ó ai≥xa_i \ge x” trên cây lưu max: sang trái nếu max của con trái ≥x\ge x, 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ứ kk của một tập đang thay đổi.

Ví dụPhân Bổ Phòng

Đề. Có n≤2⋅105n \le 2 \cdot 10^5 khách sạn, khách sạn ii còn hih_i phòng. Lần lượt mm đoàn đến, đoàn jj cần rjr_j phòng và được xếp vào khách sạn có chỉ số nhỏ nhất còn ít nhất rjr_j phòng (rồi khách sạn đó bớt rjr_j 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 rjr_j 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 ≥rj\ge r_j. 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.

C++
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ừ xx 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 đủ.

C++
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.

C++
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.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Mỗi đoàn đi một đường từ gốc tới lá: O(log⁡n)O(\log n).

Cập nhật đoạn và thẻ lười

Thêm một loại thao tác: cộng xx vào mọi aia_i với l≤i≤rl \le i \le r, và hỏi tổng đoạn. Cập nhật điểm cho từng vị trí tốn O(nlog⁡n)O(n \log n) một thao tác. Ta nhớ rằng truy vấn chỉ ghép O(log⁡n)O(\log n) 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 [l,r][l, r] biết ngay tổng mới của mình: tổng cũ cộng xx 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 xx, 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ày st[id] đã chứa rồi.

Hàm apply(id, len, x) đặt thẻ xx lên một nút dài lenlen: 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.

Nhãn +k 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 a=(5,2,7,3,8,1,6,4)a = (5, 2, 7, 3, 8, 1, 6, 4) với thao tác “cộng 3 vào [2,7][2, 7]”: hai nút [3,4][3, 4] và [5,6][5, 6] nằm trọn nên nhận thẻ +3+3, tổng thành 1616 và 1515; lá [2,2][2,2] và [7,7][7,7] cũng nằm trọn và thành 55, 99; các nút cắt tính lại từ con, gốc thành 54=36+6⋅354 = 36 + 6 \cdot 3. Bốn lá [3,3][3,3] tới [6,6][6,6] chưa hề được chạm tới. Nếu sau đó hỏi tổng [3,3][3, 3], đường đi qua [3,4][3, 4] là nút cắt, nên thẻ +3+3 được đẩy xuống và lá [3,3][3,3] trả về 7+3=107 + 3 = 10.

C++
#include <bits/stdc++.h>
using namespace std;
using 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

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 O(1)O(1), nên cập nhật đoạn và truy vấn đều O(log⁡n)O(\log n). 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 vv nằm trọn trong đoạn cần cộng xx. Trong cài đặt thẻ lười ở trên, tổng st của vv được cập nhật vào lúc nào?

Ví dụDãy Công Tắc

Đề. Có N≤105N \le 10^5 đèn, ban đầu tắt, và M≤105M \le 10^5 thao tác: 0 S E đảo trạng thái mọi đèn từ SS tới EE; 1 S E đếm số đèn đang bật trong đoạn đó.

Lời giải. Nút lưu số đèn bật trong đoạn. Thẻ là một cờ “đảo”: đảo cả một đoạn dài lenlen làm số đèn bật thành lenlen trừ số cũ, và hai lần đảo triệt tiêu nhau, nên thẻ chỉ cần một bit, hợp hai thẻ là XOR. Phần còn lại giống hệt cây cộng đoạn ở trên.

Bước 1: áp thẻ lên một nút. Biết độ dài đoạn của nút là đủ để cập nhật giá trị ngay, không cần xuống con.

C++
void apply(ll id, ll len) {
    st[id] = len - st[id];
    lz[id] ^= 1;
}

Bước 2: đẩy thẻ xuống. Trước khi đi vào con, nếu nút còn thẻ thì áp nó cho hai con (độ dài mid−l+1mid - l + 1 và r−midr - mid) rồi xóa thẻ ở nút.

C++
void down(ll id, ll l, ll r) {
    if (!lz[id]) return;
    ll mid = (l + r) >> 1;
    apply(id * 2, mid - l + 1);
    apply(id * 2 + 1, r - mid);
    lz[id] = 0;
}

Bước 3: đảo đoạn và đếm đoạn. Nút nằm trọn trong đoạn thì áp thẻ và dừng; nút cắt ngang thì đẩy thẻ, đi xuống hai con, rồi cộng lại. get cũng phải đẩy thẻ trước khi xuống con, vì giá trị ở con có thể chưa được cập nhật.

C++
void update(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return;
    if (u <= l && r <= v) {
        apply(id, r - l + 1);
        return;
    }
    down(id, l, r);
    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];
}

Ban đầu mọi đèn tắt, mọi ô bằng 0, nên không cần build. Với ví dụ, sau hai lần đảo [2,5][2, 5] rồi [3,6][3, 6], chỉ đèn 2 và 6 sáng.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1e5 + 5;
ll n, m, st[4 * N], lz[4 * N];

void apply(ll id, ll len) {
    st[id] = len - st[id];
    lz[id] ^= 1;
}

void down(ll id, ll l, ll r) {
    if (!lz[id]) return;
    ll mid = (l + r) >> 1;
    apply(id * 2, mid - l + 1);
    apply(id * 2 + 1, r - mid);
    lz[id] = 0;
}

void update(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return;
    if (u <= l && r <= v) {
        apply(id, r - l + 1);
        return;
    }
    down(id, l, r);
    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];
}

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 >> m;
    while (m--) {
        ll t, s, e;
        cin >> t >> s >> e;
        if (t == 0) update(1, 1, n, s, e);
        else cout << get(1, 1, n, s, e) << '\n';
    }
}
6 7
1 1 6
0 2 5
1 1 3
0 3 6
1 2 6
0 1 6
1 1 6

Mỗi thao tác O(log⁡N)O(\log N).

Thẻ là một hàm: gán, cộng, nhân

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 f(x)=b⋅x+cf(x) = b \cdot x + c áp lên từng phần tử. Cộng yy là thẻ (b,c)=(1,y)(b, c) = (1, y); gán yy là (0,y)(0, y); nhân yy là (y,0)(y, 0); thẻ “không làm gì” là (1,0)(1, 0). Hai điều cần tính:

  • Áp thẻ lên một nút dài lenlen với tổng ss: tổng của bxi+cb x_i + c trên lenlen phần tử là b⋅s+c⋅lenb \cdot s + c \cdot len.
  • Hợp thẻ. Thẻ g=(bg,cg)g = (b_g, c_g) chạy sau thẻ f=(bf,cf)f = (b_f, c_f) cho g(f(x))=bg(bfx+cf)+cgg(f(x)) = b_g (b_f x + c_f) + c_g, tức thẻ (bgbf,  bgcf+cg)(b_g b_f,\; b_g c_f + c_g).

Phép gán tự xóa mọi thứ trước nó: hợp (0,y)(0, y) sau bất kỳ (bf,cf)(b_f, c_f) cho (0,y)(0, y). Còn “cộng 5 rồi gán 2” là (0,2)(0, 2) và “gán 2 rồi cộng 5” là (1,5)(1, 5) hợp sau (0,2)(0, 2), cho (0,7)(0, 7), 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 cc 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 cc. Thao tác ai←min⁡(ai,c)a_i \gets \min(a_i, 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ì bb 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 vv đang giữ thẻ “cộng 3” (tức x↦x+3x \mapsto x + 3). Sau đó có thao tác “nhân 2” phủ trọn nút vv. Thẻ mới của vv, viết dưới dạng x↦bx+cx \mapsto b x + c, là gì?

Ví dụCộng, Gán Và Lấy Tổng

Đề. Mảng N≤2⋅105N \le 2 \cdot 10^5 số, Q≤2⋅105Q \le 2 \cdot 10^5 thao tác trên đoạn [L,R][L, R]: 1 L R X cộng XX; 2 L R X gán bằng XX; 3 L R in tổng. 1≤Ai,X≤1061 \le A_i, X \le 10^6.

Lời giải. Thẻ là cặp (mul, add) như trên, nghĩa là “mỗi ô xx của đoạn thành mul⋅x+add\mathrm{mul} \cdot x + \mathrm{add}”, ban đầu (1,0)(1, 0). Cộng XX là thẻ (1,X)(1, X), gán XX là (0,X)(0, X). 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ẻ (b,c)(b, c) lên nút. Tổng của đoạn dài lenlen thành b⋅st+c⋅lenb \cdot \mathrm{st} + c \cdot len. Thẻ mới đến sau thẻ cũ, nên ô xx đi qua cả hai thành b(mul⋅x+add)+cb(\mathrm{mul} \cdot x + \mathrm{add}) + c: thẻ hợp là (b⋅mul, b⋅add+c)(b \cdot \mathrm{mul},\ b \cdot \mathrm{add} + c).

C++
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 (1,0)(1, 0) thì bỏ qua; còn lại áp (mul, add) của cha lên hai con rồi đặt thẻ cha về (1,0)(1, 0). 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ẻ (0,0)(0, 0) nghĩa là “gán bằng 0”.

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;
}

Bước 3: hai loại sửa, một hàm. update nhận thẻ (b,c)(b, c) và theo đúng khung cây lười; ở main, cộng gọi với (1,X)(1, X), gán gọi với (0,X)(0, X).

C++
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ỡ 2⋅10112 \cdot 10^{11}, tổng cả mảng tới 4⋅10164 \cdot 10^{16}, vẫn vừa ll. Với ví dụ, sau khi cộng 3 vào [2,4][2, 4] rồi gán 2 cho [3,5][3, 5], mảng là (1,5,2,2,2)(1, 5, 2, 2, 2), tổng 12.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Mỗi thao tác O(log⁡N)O(\log N).

Cài đặt không đệ quy và các biến thể

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 2n2n ô. Đặt lá của vị trí ii (đánh số từ 0) ở st[n + i]; khi đó cha của nút vv là v/2v/2. Dựng cây là một vòng lặp lùi từ n−1n - 1 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ở [l,r)[l, r), đ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.

C++
#include <bits/stdc++.h>
using namespace std;
using 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

Với phép gộp giao hoán (min, max, tổng, gcd) mã này đúng với mọi nn, kể cả khi nn không phải lũy thừa của 2, như ví dụ có n=6n = 6. 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.

Vài biến thể thường gặp khác:

  • Vị trí rất lớn. Nếu tọa độ tới 10910^9 nhưng chỉ có qq 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á log⁡2C+1\log_2 C + 1 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ứ kk đều O(log⁡C)O(\log C).
  • 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 2n2^n số (n≤17n \le 17). 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ó m≤105m \le 10^5 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 2n2^n lá, chỉ khác phép gộp đổi theo tầng: nút cách lá hh tầng gộp bằng OR nếu hh lẻ, XOR nếu hh chẵn. Nút vv ở độ sâu ⌊log⁡2v⌋\lfloor \log_2 v \rfloor nên cách lá h=n−⌊log⁡2v⌋h = n - \lfloor \log_2 v \rfloor tầng. Cài kiểu không đệ quy: lá ii ở st[2^n + i - 1], dựng lùi từ 2n−12^n - 1 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 O(n)O(n). Với ví dụ, (4∣6)⊕(3∣5)=6⊕7=1(4 \mid 6) \oplus (3 \mid 5) = 6 \oplus 7 = 1.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Khi đi thi

  • Thấy dãy vừa bị sửa vừa bị hỏi theo đoạn, với 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, nghĩ tới cây phân đoạn; nếu chỉ cần tổng hay đếm với sửa điểm thì cây Fenwick ngắn hơn, còn dãy cố định thì tổng tiền tố hoặc bảng thưa.
  • Trước khi gõ code, viết ra trên giấy: nút lưu gì, hàm gộp (đúng thứ tự trái phải), phần tử trung hòa; với cập nhật đoạn thêm: thẻ là gì, hợp thẻ mới vào thẻ cũ thế nào, áp thẻ lên nút ra sao (nhớ độ dài đoạn).
  • Cấp mảng 4n4n ô, dùng long long cho tổng và thẻ, để 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 là 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,r][l, r] lưu tóm tắt của đoạn, có 2n−12n - 1 nút, mảng 4n4n ô, dựng O(n)O(n).
  • 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 O(log⁡n)O(\log n).
  • 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ứ kk, phần tử đầu tiên ≥x\ge x) mất O(log⁡n)O(\log n), 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

1
Những Nhà Vô Địch Có Thểcanuc80k1721Với mỗi nút, đếm số đội ở dưới có thể thắng tới nút đó: tổng hai con nếu ký tự là ?, chỉ con trái nếu 1, chỉ con phải nếu 0; ký tự thứ pp ứng với nút 2K−p2^K - p, đổi nó thì chỉ tính lại đường lên gốc.
LQDOJ
2
Đoạn Đầu Tốt Nhấtcanuc80k1705Mỗi nút lưu (tổng, tiền tố lớn nhất) kể cả tiền tố rỗng; gộp đúng thứ tự trái rồi phải.
LQDOJ
3
Phí Giao Hàngcanuc80k1708Tách ∣i−k∣|i - k| làm hai nửa: với i≤ki \le k cần min của pi−ip_i - i cộng kk, với i≥ki \ge k cần min của pi+ip_i + i trừ kk; dùng hai cây min.
LQDOJ
4
Hai Giá Trị Dẫn Đầucanuc80k1702Mỗi nút giữ hai giá trị lớn nhất ở hai vị trí khác nhau; gộp bằng cách chọn hai số lớn nhất trong bốn ứng viên.
LQDOJ
5
Hạng Nhì Theo Giá Trịcanuc80k1709Mỗi nút giữ (lớn nhất, số lần, lớn nhì, số lần); gộp bằng cách xét bốn giá trị của hai con, giá trị bằng nhau thì cộng số lần.
LQDOJ
6
Đoạn Ngoặc Sau Hoán Đổicanuc80k1722Mã hóa ngoặc mở là +1+1, ngoặc đóng là −1-1; đoạn đúng khi tổng bằng 0 và tiền tố nhỏ nhất (kể cả tiền tố rỗng) không âm; nút lưu (tổng, tiền tố nhỏ nhất), hoán đổi là hai cập nhật điểm.
LQDOJ
7
Đếm Bội Số Bacanuc80k1711Mỗi nút đếm số phần tử có dư 0, 1, 2 khi chia 3; một lần tăng 1 xoay vòng ba số đếm; thẻ là số lần xoay theo modulo 3.
LQDOJ
8
Cộng Cấp Sốcanuc80k1716Thẻ gồm hai số (a,b)(a, b) nghĩa là cộng a⋅i+ba \cdot i + b vào vị trí ii; tổng đoạn tăng a⋅(l+⋯+r)+b⋅lena \cdot (l + \dots + r) + b \cdot len.
LQDOJ
9
Tổng Bình Phương Độngcanuc80k1717Thẻ là hàm bậc nhất như ví dụ Cộng, Gán Và Lấy Tổng; nút lưu thêm tổng bình phương, và (x+c)2(x + c)^2 khai triển ra cần cả tổng thường.
LQDOJ
10
Cực Tiểu Trên Vòngcanuc80k1713Cộng đoạn và min với thẻ lười; cung quấn vòng (L>RL > R) tách thành hai đoạn; đọc từng dòng vì thao tác có hai hoặc ba số.
LQDOJ
11
Chia Hết Với Một Ngoại Lệcanuc80k1725Cây gcd: bỏ qua nút có gcd chia hết cho XX, đi xuống các nút còn lại và dừng ngay khi gặp hai phần tử không chia hết.
LQDOJ
12
Biến Đổi Afin Trên Đoạncanuc80k1719Thẻ (b,c)(b, c) như ví dụ Cộng, Gán Và Lấy Tổng nhưng mọi phép tính theo modulo 998244353; chú ý đoạn nửa mở.
LQDOJ
13
Đoạn Một Liên Tiếp Dài Nhấtcanuc80k1729Nút lưu tiền tố, hậu tố, đoạn dài nhất cho cả bit 1 lẫn bit 0; đảo đoạn là đổi chỗ hai bộ số.
LQDOJ
14
Đội Hình Luân Phiêncanuc80k1730Mỗi nút lưu bảng 2×22 \times 2: giá trị tốt nhất khi đi vào đoạn với dấu kế tiếp là ++ hay −- và ra với dấu kế tiếp là ++ hay −-; gộp là nhân ma trận (max⁡,+)(\max, +); hoán đổi là hai cập nhật điểm.
LQDOJ
15
Khoảng Cách Lặp Nhỏ Nhấtcanuc80k1727Xử lý offline theo đầu phải tăng dần: tới vị trí pp, ghi khoảng cách tới lần xuất hiện trước cùng giá trị vào vị trí trước đó; hỏi min trên [L,R][L, R].
LQDOJ

Đọc thêm

  • Tất tần tật về Cây Phân ĐoạnVNOI WikiBài tiếng Việt đầy đủ: bản đệ quy, chứng minh O(log⁡n)O(\log n) và 4n4n ô, thẻ lười, và các mở rộng như lưu mảng trong mỗi nút.
  • Cài đặt cây phân đoạn hiệu quảVNOI WikiBản dịch blog Codeforces của Al.Cash về cách cài từ dưới lên với 2n2n ô, ngắn và nhanh, kể cả phép gộp không giao hoán.
  • Segment Treecp-algorithmsChứng minh bốn nút mỗi tầng, đi xuống cây, thẻ lười gán và cộng, cây động và cây bền vững.
  • 线段树OI WikiĐiều kiện toán học cho tóm tắt và thẻ, thứ tự hợp thẻ, thẻ vĩnh viễn, cây động, tìm kiếm nhị phân trên cây.
  • セグメント木を徹底解説algo-logicBài tiếng Nhật có hình từng bước cho cập nhật điểm và thẻ lười, rồi trừu tượng hóa bằng khái niệm monoid.
  • Lazy SegtreeAtCoder LibraryĐặc tả chuẩn của cây thẻ lười: các điều kiện cho phép gộp, phép áp thẻ và phép hợp thẻ, kèm tìm kiếm nhị phân trên cây.