Segment Tree (Cây phân đoạn)
Segment Tree (cây phân đoạn) là cấu trúc dữ liệu cho phép vừa truy vấn thông tin trên một đoạn của mảng (tổng, min, max, gcd...) vừa cập nhật phần tử — cả hai chỉ trong .
Bài toán điển hình: cho mảng phần tử, xử lý truy vấn xen kẽ kiểu "tổng đoạn " và "gán ". Cách ngây thơ duyệt lại đoạn mỗi truy vấn mất mỗi lần, tổng — quá chậm khi . Tiền tố cộng dồn (prefix sum) trả lời tổng đoạn trong nhưng cập nhật một phần tử lại tốn . Segment Tree cân bằng cả hai về , và còn hỗ trợ những phép mà prefix sum chiều được (min/max/gcd).
Ý tưởng / Trực giác
Ý tưởng cốt lõi: chia để trị trên đoạn. Ta đệ quy chia đoạn làm đôi, mỗi nửa lại chia đôi, cho tới các đoạn đơn lẻ . Mỗi đoạn ứng với một node của cây nhị phân; node cha lưu kết quả tổng hợp của hai node con.
Tại sao truy vấn nhanh? Mấu chốt là: bất kỳ đoạn nào cũng phân rã được thành đoạn chuẩn (các node đã có sẵn trong cây). Khi truy vấn, ở mỗi node ta xét 3 khả năng:
- Đoạn node nằm hoàn toàn trong → trả luôn giá trị đã lưu, không đi sâu thêm.
- Đoạn node không giao với → trả phần tử trung tính (0 cho tổng, cho min) rồi dừng.
- Đoạn node giao một phần → đệ quy xuống cả hai con và gộp kết quả.
Vì chỉ có khoảng 2 node "giao một phần" trên mỗi tầng (một ở biên trái, một ở biên phải), số node thực sự được ghé thăm là .
Tại sao cập nhật nhanh? Một phần tử chỉ thuộc đúng một node ở mỗi tầng — đường đi từ gốc xuống lá . Sửa lá rồi đi ngược lên cập nhật lại tổng các tổ tiên: chỉ node.
Điều kiện để Segment Tree áp dụng được: phép gộp phải kết hợp (associative). Tức là . Tổng, min, max, gcd, "tổng đoạn con lớn nhất"... đều thỏa; nhờ vậy việc ghép kết quả từ nhiều đoạn con cho ra đáp án đúng bất kể thứ tự ghép.
Ví dụ chạy tay
Xét mảng (chỉ số 0..3), phép gộp là tổng. Cây phân đoạn (số trong ngoặc là tổng của node):
[0,3]=(10)
/ \
[0,1]=(4) [2,3]=(6)
/ \ / \
[0,0]=3 [1,1]=1 [2,2]=2 [3,3]=4
Truy vấn tổng đoạn (tức ). Đi từ gốc:
[0,3]: [1,3] giao một phần [0,3] -> xuống 2 con
├─ [0,1]: [1,3] giao một phần -> xuống 2 con
│ ├─ [0,0]: không giao [1,3] -> trả 0 <-- dừng
│ └─ [1,1]: nằm trong [1,3] -> trả 1 <-- dừng
└─ [2,3]: NẰM TRỌN trong [1,3] -> trả 6 <-- dừng, không đi sâu!
Cộng lại: . Đoạn được phân rã thành đúng 2 node chuẩn — không phải duyệt từng phần tử.
Cập nhật (trước là 2). Đi xuống lá , sửa rồi cập nhật ngược lên:
[0,3]=(10->13) (chỉ các node trên đường đi đổi)
\
[2,3]=(6->9)
/
[2,2]=(2->5) <-- sửa lá trước
Chỉ 3 node trên đường gốc→lá được cập nhật: lá thành 5, rồi thành , rồi gốc thành . Các node không đổi.
Cài đặt
Cập nhật điểm, truy vấn đoạn
Mảng tree lưu cây theo cách đánh số đống (heap): node là gốc, hai con của node là và . Cấp phát kích thước là an toàn (xem mục Độ phức tạp).
struct SegTree {
int n;
vector<long long> tree;
SegTree(int n) : n(n), tree(4 * n, 0) {}
// Xây cây từ mảng a, node phụ trách đoạn [l, r]
void build(vector<int>& a, int node, int l, int r) {
if (l == r) { tree[node] = a[l]; return; } // lá: chứa đúng a[l]
int mid = (l + r) / 2;
build(a, 2 * node, l, mid);
build(a, 2 * node + 1, mid + 1, r);
tree[node] = tree[2 * node] + tree[2 * node + 1]; // gộp từ 2 con
}
// Gán a[pos] = val
void update(int node, int l, int r, int pos, long long val) {
if (l == r) { tree[node] = val; return; } // tới lá thì gán
int mid = (l + r) / 2;
if (pos <= mid) update(2 * node, l, mid, pos, val);
else update(2 * node + 1, mid + 1, r, pos, val);
tree[node] = tree[2 * node] + tree[2 * node + 1]; // cập nhật ngược lên
}
// Tổng đoạn [ql, qr]
long long query(int node, int l, int r, int ql, int qr) {
if (qr < l || r < ql) return 0; // không giao -> phần tử trung tính
if (ql <= l && r <= qr) return tree[node]; // node nằm trọn trong [ql,qr]
int mid = (l + r) / 2;
return query(2 * node, l, mid, ql, qr)
+ query(2 * node + 1, mid + 1, r, ql, qr);
}
};
// Cách dùng (chỉ số 0-based, gốc là node 1, đoạn toàn cục [0, n-1]):
// SegTree seg(n);
// seg.build(a, 1, 0, n - 1);
// seg.update(1, 0, n - 1, i, val);
// long long s = seg.query(1, 0, n - 1, l, r);
Đổi phép gộp
Chỉ cần thay phép cộng và phần tử trung tính (giá trị trả về khi "không giao"):
// Min: tree[node] = min(tree[2*node], tree[2*node+1]);
// không giao -> trả LLONG_MAX
// Max: tree[node] = max(tree[2*node], tree[2*node+1]);
// không giao -> trả LLONG_MIN
// GCD: tree[node] = __gcd(tree[2*node], tree[2*node+1]);
// không giao -> trả 0
Lazy Propagation (cập nhật cả đoạn)
Khi cần cập nhật cả đoạn (cộng thêm cho mọi phần tử, hay gán...), nếu đi xuống từng lá thì một cập nhật đoạn tốn . Lazy propagation giữ lại độ phức tạp bằng mẹo: khi một node nằm trọn trong đoạn cập nhật, ta cập nhật giá trị tổng hợp của node đó ngay và ghi nợ (lazy) phần thay đổi cho các con — chỉ "đẩy nợ" xuống (push down) khi thực sự cần đi qua node đó.
struct LazySegTree {
int n;
vector<long long> tree, lazy;
LazySegTree(int n) : n(n), tree(4 * n, 0), lazy(4 * n, 0) {}
// Đẩy "nợ" của node xuống 2 con trước khi đi tiếp
void push_down(int node, int l, int r) {
if (lazy[node] == 0) return;
int mid = (l + r) / 2;
// mỗi con cộng lazy * (số phần tử của con)
tree[2 * node] += lazy[node] * (mid - l + 1);
tree[2 * node + 1] += lazy[node] * (r - mid);
lazy[2 * node] += lazy[node]; // dồn nợ tiếp cho cháu
lazy[2 * node + 1] += lazy[node];
lazy[node] = 0; // node đã trả hết nợ
}
// Cộng val cho mọi phần tử trong [ql, qr]
void update(int node, int l, int r, int ql, int qr, long long val) {
if (qr < l || r < ql) return;
if (ql <= l && r <= qr) { // node nằm trọn -> ghi nợ, dừng
tree[node] += val * (r - l + 1);
lazy[node] += val;
return;
}
push_down(node, l, r); // phải đi sâu -> trả nợ trước
int mid = (l + r) / 2;
update(2 * node, l, mid, ql, qr, val);
update(2 * node + 1, mid + 1, r, ql, qr, val);
tree[node] = tree[2 * node] + tree[2 * node + 1];
}
long long query(int node, int l, int r, int ql, int qr) {
if (qr < l || r < ql) return 0;
if (ql <= l && r <= qr) return tree[node];
push_down(node, l, r); // trả nợ trước khi đọc con
int mid = (l + r) / 2;
return query(2 * node, l, mid, ql, qr)
+ query(2 * node + 1, mid + 1, r, ql, qr);
}
};
Độ phức tạp
| Thao tác | Thời gian | Vì sao |
|---|---|---|
| Xây cây | mỗi node được tính đúng một lần; cây có node | |
| Truy vấn đoạn | đoạn phân rã thành node chuẩn; mỗi tầng chỉ ~2 node giao một phần | |
| Cập nhật điểm | chỉ sửa các node trên đường gốc→lá, dài | |
| Cập nhật đoạn (lazy) | nhờ ghi nợ, một cập nhật đoạn cũng chỉ chạm node |
Bộ nhớ: — cụ thể ta cấp tree (và lazy) kích thước . Lý do hệ số 4: khi không phải lũy thừa của 2, đánh số kiểu heap có thể nhảy tới chỉ số lớn gần ở tầng lá sâu nhất. Cấp luôn đủ chỗ; cấp có thể truy cập ngoài mảng.
⚠️ Lỗi thường gặp
- Tràn số (overflow): tổng của phần tử cỡ vượt xa
int(). Dùnglong longcho mảngtree. Với lazy, biểu thứclazy * (r - l + 1)còn dễ tràn hơn — cảlazylẫn kết quả phải làlong long. - Cấp phát thiếu bộ nhớ: dùng
2*nthay vì4*nchotreegây truy cập ngoài mảng (UB, có thể RE/WA ngẫu nhiên với không là lũy thừa 2). Luôn cấp4*n. - Quên
push_downtrong lazy: đọc/ghi node con khi node cha còn "nợ" chưa đẩy xuống → kết quả sai. Phải gọipush_downtrước khi đệ quy xuống con ở cảupdatelẫnquery. - Sai phần tử trung tính khi đổi phép gộp: với min phải trả
LLONG_MAX(không phải 0) cho nhánh không giao; trả 0 sẽ làm min luôn bằng 0. Với max trảLLONG_MIN, với gcd trả 0. - Nhầm phép gộp trong lazy không tương thích: lazy cho "cộng đoạn + truy vấn tổng" khác hẳn lazy cho "gán đoạn" hay "cộng đoạn + truy vấn min". Khi đổi loại cập nhật phải sửa cả công thức cập nhật
tree[node]lẫn cách dồn nợ trongpush_down; sao chép máy móc dễ sai. - Off-by-one ở biên đoạn: trộn lẫn 0-based và 1-based giữa input và cây, hoặc gọi
queryvới[l, r]mà input là[l, r). Cố định một quy ước (ở đây: 0-based, đoạn toàn cục[0, n-1]) và dùng nhất quán.
Biến thể / Mở rộng
- So với Fenwick Tree (BIT): Fenwick gọn và nhanh hơn hằng số cho tổng/đếm tiền tố, nhưng Segment Tree linh hoạt hơn: làm được min/max (Fenwick rất khó), lazy propagation logic phức tạp (gán đoạn, kết hợp nhiều loại cập nhật), và lưu nhiều thông tin trên mỗi node. Xem thêm Fenwick Tree.
- Tìm kiếm trên cây (descend): đi từ gốc xuống để trả lời "vị trí trái nhất có chỗ trống liên tiếp" hoặc "phần tử thứ " trong , thay vì binary search bọc ngoài thành .
- Segment Tree động / nén toạ độ: khi miền chỉ số rất lớn () nhưng số điểm ít, nén toạ độ hoặc tạo node theo nhu cầu (dynamic / sparse segment tree).
- Persistent Segment Tree: giữ lại mọi phiên bản sau cập nhật, trả lời truy vấn trên trạng thái quá khứ.
Bài tập luyện
- Đếm Kiện Cỏ (haybales15) — (Advanced) lazy propagation kinh điển: cộng cả đoạn, truy vấn min và tổng đoạn — bài nhập môn để luyện push_down.
- Marathon (marathong) — (Veteran) cập nhật điểm + truy vấn đoạn với phép gộp tuỳ biến (giữ tổng khoảng cách và mức tiết kiệm lớn nhất khi bỏ một điểm).
- Sắp Xếp Chỗ Ngồi (seating) — (Veteran) mỗi node lưu độ dài đoạn trống dài nhất (kèm tiền tố/hậu tố) rồi đi xuống cây tìm vị trí trái nhất đủ chỗ.
- Phá Mã (codebreak) — (Veteran) kết hợp Segment Tree với quy hoạch động trên cây — bài tổng hợp nâng cao.