Bài 8c: Segment Tree (Cây Phân Đoạn) - Truy Vấn Đoạn¶
Tác giả: FPTOJ Team
Nội dung tham khảo từ: VNOI Wiki - Cây Phân Đoạn, CP-Algorithms
1. Bản chất vấn đề¶
Bài toán thực tế¶
Giả sử ta quản lý một lớp học gồm \(N\) học sinh, mỗi học sinh có một điểm số tương ứng. Ta cần thực hiện liên tục hai loại yêu cầu sau:
- Truy vấn tổng: Tính tổng điểm của các học sinh từ số \(L\) đến số \(R\).
- Cập nhật điểm: Thay đổi điểm số của học sinh tại vị trí \(pos\) thành giá trị mới \(val\).
So sánh các hướng tiếp cận¶
Nếu sử dụng các cấu trúc dữ liệu thông thường:
- Mảng tĩnh (Array):
- Mỗi thao tác cập nhật điểm chỉ tốn \(O(1)\).
- Tuy nhiên, để tính tổng điểm đoạn \([L, R]\), ta cần duyệt qua các phần tử từ \(L\) đến \(R\), tốn \(O(N)\) trong trường hợp xấu nhất. Với \(Q\) câu hỏi, tổng thời gian sẽ là \(O(Q \times N)\), gây ra hiện tượng quá thời gian giới hạn (TLE) khi \(N, Q \approx 10^5\).
- Mảng cộng dồn (Prefix Sum):
- Cho phép tính tổng đoạn trong \(O(1)\) bằng công thức \(S[R] - S[L-1]\).
- Tuy nhiên, khi có một phần tử tại vị trí \(pos\) thay đổi, ta buộc phải cập nhật lại toàn bộ mảng cộng dồn từ vị trí \(pos\) đến cuối mảng, tốn \(O(N)\). Với nhiều thao tác cập nhật, độ phức tạp vẫn là \(O(Q \times N)\).
Segment Tree (Cây phân đoạn) ra đời để cân bằng cả hai thao tác trên: mỗi thao tác truy vấn đoạn hoặc cập nhật điểm đều chỉ mất thời gian \(O(\log N)\), sử dụng không gian bộ nhớ là \(O(N)\) (cụ thể là cấp phát mảng kích thước \(4N\)).

2. Tư duy cốt lõi: Chia để trị trên cây nhị phân¶
Ý tưởng chủ đạo của Segment Tree là tính toán trước và lưu trữ kết quả của các đoạn con trong mảng gốc, sau đó kết hợp các kết quả này lại khi có yêu cầu truy vấn.
Với mảng gốc \(a\) có kích thước \(N\): * Nút gốc của cây quản lý toàn bộ mảng, tương ứng với đoạn \([0, N-1]\). * Tại mỗi nút quản lý đoạn \([start, end]\) (với \(start < end\)), ta chia đôi đoạn này thành hai nửa: * Con trái quản lý nửa đầu: \([start, mid]\) * Con phải quản lý nửa sau: \([mid+1, end]\) * Với \(mid = \lfloor \frac{start + end}{2} \rfloor\). * Quá trình chia đôi này diễn ra liên tục cho đến khi \(start = end\). Lúc này, đoạn chỉ còn lại \(1\) phần tử duy nhất, tương ứng với một nút lá trên cây quản lý phần tử \(a[start]\).
Ví dụ với mảng \(a = [2, 5, 3, 7, 1, 4, 6, 3]\) gồm \(8\) phần tử:
3. Cấu trúc cây và Biểu diễn mảng¶
Segment Tree là một cây nhị phân đầy đủ (mọi nút trong đều có đúng \(2\) con). Ta có thể biểu diễn cấu trúc cây này dưới dạng một mảng tĩnh \(tree\) một chiều (1-indexed):
- Nút gốc nằm ở chỉ số \(1\) (\(tree[1]\)).
- Nếu nút cha nằm ở chỉ số \(i\), thì:
- Nút con trái nằm ở chỉ số \(2i\).
- Nút con phải nằm ở chỉ số \(2i + 1\).
- If mảng gốc có kích thước \(N\), ta sẽ sử dụng mảng \(tree\) có kích thước tối đa là \(4N\) phần tử để lưu trữ toàn bộ các nút của cây.
Chứng minh toán học về kích thước mảng \(4N\)¶
Một câu hỏi phổ biến là tại sao chúng ta cần cấp phát mảng có kích thước \(4N\) để lưu trữ Segment Tree của mảng có \(N\) phần tử. Dưới đây là chứng minh toán học chi tiết:
-
Trường hợp lý tưởng (\(N\) là lũy thừa của \(2\)): Nếu \(N = 2^h\) với \(h \in \mathbb{N}\), Segment Tree sẽ là một cây nhị phân đầy đủ hoàn hảo (perfect binary tree) có chiều cao \(h\).
- Số nút lá ở tầng cuối cùng là \(N = 2^h\).
- Số nút trong là \(N - 1 = 2^h - 1\).
- Tổng số nút trên cây là \(2N - 1 = 2^{h+1} - 1\). Nếu sử dụng chỉ số mảng bắt đầu từ \(1\), chỉ số của nút lớn nhất là \(2N - 1\). Trong trường hợp này, kích thước mảng chỉ cần \(2N\) là đủ.
-
Trường hợp tổng quát (\(N\) không phải là lũy thừa của \(2\)): Gọi \(h\) là chiều cao nhỏ nhất của cây sao cho \(2^h \geq N\). Theo định nghĩa, ta có: $\(2^{h-1} < N \leq 2^h\)$ Khi đó, ta dựng một cây nhị phân đầy đủ hoàn hảo có chiều cao \(h\) để bao phủ toàn bộ \(N\) phần tử. Cây này sẽ có số lá là \(2^h\). Các phần tử thực tế của mảng sẽ được điền vào các lá bên trái, còn các lá dư thừa ở bên phải sẽ được gán giá trị mặc định (\(0\) đối với phép tính tổng).
- Chỉ số lớn nhất của nút lá trên cây nhị phân đầy đủ hoàn hảo này nằm ở tầng \(h\), tương ứng với chỉ số lớn nhất là: $\(2^{h+1} - 1\)$
- Từ bất đẳng thức \(N > 2^{h-1}\), nhân cả hai vế với \(4\), ta được: $\(4N > 4 \cdot 2^{h-1} = 2 \cdot 2^h = 2^{h+1}\)$
- Do đó: $\(\text{Chỉ số lớn nhất} = 2^{h+1} - 1 < 2^{h+1} < 4N\)$
Như vậy, chỉ số lớn nhất của bất kỳ nút nào trên cây Segment Tree luôn nhỏ hơn \(4N\). Việc cấp phát mảng kích thước \(4N\) đảm bảo an toàn tuyệt đối và tránh lỗi vượt quá chỉ số mảng (out of bounds) trong mọi trường hợp.
4. Thao tác Truy vấn tổng đoạn \([L, R]\)¶
Để tính tổng các phần tử trong khoảng \([L, R]\), ta thực hiện duyệt đệ quy từ nút gốc đi xuống. Tại mỗi nút quản lý đoạn \([start, end]\), có \(3\) trường hợp xảy ra:
- Đoạn quản lý nằm ngoài hoàn toàn đoạn truy vấn: $\(end < L \quad \text{hoặc} \quad R < start\)$ Trường hợp này, nút hiện tại không đóng góp gì vào tổng cần tìm. Ta lập tức trả về giá trị trung hòa (đối với phép cộng là \(0\), đối với phép tìm cực tiểu là \(+\infty\)).
- Đoạn quản lý nằm trong hoàn toàn đoạn truy vấn: $\(L \leq start \quad \text{và} \quad end \leq R\)$ Trường hợp này, toàn bộ các phần tử thuộc đoạn \([start, end]\) đều thuộc đoạn truy vấn. Ta trả về ngay giá trị \(tree[node]\) mà không cần đi sâu xuống các con.
- Đoạn quản lý giao một phần với đoạn truy vấn: Ta chia đôi đoạn quản lý hiện tại bằng cách tính \(mid = \lfloor \frac{start + end}{2} \rfloor\), sau đó gọi đệ quy tìm kiếm trên con trái (đoạn \([start, mid]\)) và con phải (đoạn \([mid+1, end]\)). Kết quả trả về là tổng của cả hai lượt đệ quy này.
Chứng minh độ phức tạp truy vấn \(O(\log N)\)¶
Khi thực hiện truy vấn tổng đoạn trong khoảng \([L, R]\), thuật toán duyệt cây từ gốc xuống theo đệ quy. Chúng ta cần chứng minh rằng tại mỗi tầng (level) của cây, số lượng nút được duyệt qua không vượt quá \(4\).
Độ phức tạp thời gian phụ thuộc vào số lượng nút giao một phần, vì chỉ các nút này mới sinh ra các lượt gọi đệ quy xuống tầng tiếp theo. 1. Xét một tầng bất kỳ của cây Segment Tree. Các đoạn quản lý của các nút cùng một tầng là các đoạn rời nhau (disjoint) và phủ liên tiếp nhau. 2. Vì đoạn truy vấn \([L, R]\) là một đoạn liên tục, nó chỉ có tối đa hai điểm biên là điểm đầu \(L\) và điểm cuối \(R\). 3. Một nút quản lý \([start, end]\) giao một phần với \([L, R]\) khi và chỉ khi đoạn đó chứa điểm biên \(L\) hoặc điểm biên \(R\) (nhưng không bao phủ hoàn toàn \([L, R]\)). 4. Do các đoạn ở cùng một tầng rời nhau, tại mỗi tầng chỉ có tối đa \(1\) nút chứa \(L\) và tối đa \(1\) nút chứa \(R\). 5. Như vậy, ở mỗi tầng, số lượng nút giao một phần tối đa là \(2\). 6. Từ mỗi nút giao một phần ở tầng \(d\), chúng ta gọi đệ quy xuống tối đa \(2\) con ở tầng \(d+1\). Do đó, số lượng nút được ghé thăm ở tầng $d+1 tối đa là \(2 \times 2 = 4\). 7. Vì chiều cao của cây Segment Tree là \(h = \lceil \log_2 N \rceil\), tổng số nút được ghé thăm trên toàn bộ cây là: $\(\text{Số nút ghé thăm} \leq 4h = O(\log N)\)$ Do đó, độ phức tạp thời gian của hàm truy vấn luôn là \(O(\log N)\).
Minh họa Truy vấn đoạn \([2, 5]\) trên mảng \([2, 5, 3, 7, 1, 4, 6, 3]\)¶
Sơ đồ dưới đây biểu thị trạng thái duyệt của hàm truy vấn:
(Giao một phần -> Chia đôi)"] n2["[0, 3] : 17
(Giao một phần -> Chia đôi)"] n3["[4, 7] : 14
(Giao một phần -> Chia đôi)"] n4["[0, 1] : 7
(Nằm ngoài -> Trả về 0)"] n5["[2, 3] : 10
(Nằm trong -> Trả về 10)"] n6["[4, 5] : 5
(Nằm trong -> Trả về 5)"] n7["[6, 7] : 9
(Nằm ngoài -> Trả về 0)"] n1 --> n2 n1 --> n3 n2 --> n4 n2 --> n5 n3 --> n6 n3 --> n7 classDef partial fill:#fff9c4,stroke:#fbc02d,stroke-width:2px; classDef inside fill:#e8f5e9,stroke:#4caf50,stroke-width:2px; classDef outside fill:#ffebee,stroke:#ef5350,stroke-width:2px; class n1,n2,n3 partial; class n5,n6 inside; class n4,n7 outside;
Trace chi tiết từng bước đệ quy của hàm query(node, start, end, L, R) với \(L = 2, R = 5\):¶
| Bước | Lời gọi hàm | Trạng thái đoạn so với \([2, 5]\) | Kết quả trả về |
|---|---|---|---|
| 1 | query(node=1, 0, 7, 2, 5) |
Giao một phần (\(0 < 2\) và \(7 > 5\)) | query(2, 0, 3, 2, 5) + query(3, 4, 7, 2, 5) = \(10 + 5 = 15\) |
| 2a | query(node=2, 0, 3, 2, 5) |
Giao một phần (\(0 < 2\)) | query(4, 0, 1, 2, 5) + query(5, 2, 3, 2, 5) = \(0 + 10 = 10\) |
| 3a | query(node=4, 0, 1, 2, 5) |
Nằm ngoài hoàn toàn (\(1 < 2\)) | \(0\) |
| 3b | query(node=5, 2, 3, 2, 5) |
Nằm trong hoàn toàn (\(2 \geq 2\) và \(3 \leq 5\)) | \(tree[5] = 10\) |
| 2b | query(node=3, 4, 7, 2, 5) |
Giao một phần (\(7 > 5\)) | query(6, 4, 5, 2, 5) + query(7, 6, 7, 2, 5) = \(5 + 0 = 5\) |
| 3c | query(node=6, 4, 5, 2, 5) |
Nằm trong hoàn toàn (\(4 \geq 2\) và \(5 \leq 5\)) | \(tree[6] = 5\) |
| 3d | query(node=7, 6, 7, 2, 5) |
Nằm ngoài hoàn toàn (\(6 > 5\)) | \(0\) |
5. Thao tác Cập nhật điểm \(a[pos] = val\)¶
Khi điểm số tại vị trí \(pos\) thay đổi, ta cần cập nhật lại giá trị lưu trữ của toàn bộ các nút trên cây có chứa vị trí \(pos\) này (tổng cộng \(O(\log N)\) nút trên đường đi từ lá lên gốc).
Minh họa cập nhật \(a[3] = 10\) (thay đổi giá trị tại \(pos = 3\) từ \(7\) thành \(10\))¶
Đường đi của quá trình cập nhật dọc theo cây được làm nổi bật như sau:
6. Cài đặt Cây Segment Tree cơ bản¶
Dưới đây là mã nguồn cài đặt đầy đủ của Segment Tree hỗ trợ cập nhật điểm và truy vấn tổng đoạn:
7. Biến thể: Segment Tree áp dụng cho Min/Max¶
Segment Tree cực kỳ linh hoạt nhờ khả năng thay đổi phép toán gộp. Nếu bài toán yêu cầu tìm giá trị nhỏ nhất (hoặc lớn nhất) trong đoạn thay vì tính tổng:
- Phép gộp: Thay phép tính cộng
+thành phép lấy cực trịmin()hoặcmax(). $\(tree[node] = \min(tree[2 \cdot node], tree[2 \cdot node + 1])\)$ - Giá trị trung hòa: Thay giá trị trung hòa \(0\) của tổng bằng \(+\infty\) đối với phép tìm min, hoặc \(-\infty\) đối với phép tìm max.
8. Kỹ thuật Lazy Propagation (Trì hoãn Cập nhật đoạn)¶
Bài toán thực tế¶
Giả sử ta cần thực hiện yêu cầu sau: "Cộng thêm giá trị \(val\) vào toàn bộ các phần tử từ vị trí \(L\) đến vị trí \(R\)".
Nếu cập nhật điểm thông thường, ta phải thực hiện cập nhật \((R - L + 1)\) lần, tốn \(O(N \log N)\) thời gian cho mỗi truy vấn. Để giải quyết, ta sử dụng kỹ thuật Lazy Propagation (Lan truyền trễ) giúp đưa thao tác cập nhật đoạn về độ phức tạp \(O(\log N)\).
Ý tưởng cốt lõi: "Sự lười biếng thông minh"¶
Khi thực hiện cập nhật cộng thêm \(val\) trên đoạn \([L, R]\): * Nếu đoạn quản lý của nút \([start, end]\) nằm hoàn toàn trong \([L, R]\), ta cập nhật ngay giá trị của nút này và ghi lại một nhãn lười (lazy tag) tại nút đó với nội dung: "Các nút con của nút này sẽ cần cộng thêm \(val\) khi được duyệt tới". Ta không lập tức cập nhật các nút con. * Khi có bất kỳ thao tác truy vấn hay cập nhật nào khác đi qua nút hiện tại, ta thực hiện thao tác đẩy xuống (pushDown): lan truyền giá trị nhãn lười từ cha xuống hai con, cập nhật giá trị của hai con rồi xóa nhãn lười tại nút cha.
Phân tích toán học về tính đúng đắn của phép đẩy trễ¶
Với một nút quản lý đoạn \([start, end]\) có độ dài đoạn là \(length = end - start + 1\): * Nếu tất cả các phần tử trong đoạn được cộng thêm một lượng \(V\), thì giá trị tổng lưu tại nút đó sẽ tăng thêm một lượng là: $\(\Delta = V \times (end - start + 1)\)$ * Phép toán này đảm bảo tính phân phối của phép nhân đối với phép cộng: $\(\sum_{i=start}^{end} (a[i] + V) = \left( \sum_{i=start}^{end} a[i] \right) + V \times (end - start + 1)\)$ * Khi đẩy nhãn lười \(V\) xuống các con, con trái quản lý đoạn \([start, mid]\) và con phải quản lý đoạn \([mid+1, end]\) sẽ lần lượt nhận giá trị nhãn lười cộng dồn \(lazy[2i] \leftarrow lazy[2i] + V\) và \(lazy[2i+1] \leftarrow lazy[2i+1] + V\).
Minh họa Lan truyền trễ khi cộng \(5\) vào đoạn \([2, 5]\)¶
Sơ đồ dưới đây minh họa các nút nhận giá trị và các nút được đánh dấu nhãn lười:
(Đẩy xuống)"] n2["[0, 3] : 17 -> 27
(Đẩy xuống)"] n3["[4, 7] : 14 -> 24
(Đẩy xuống)"] n4["[0, 1] : 7
(Không đổi)"] n5["[2, 3] : 10 -> 20
(Đánh dấu Lazy +5)"] n6["[4, 5] : 5 -> 15
(Đánh dấu Lazy +5)"] n7["[6, 7] : 9
(Không đổi)"] n1 --> n2 n1 --> n3 n2 --> n4 n2 --> n5 n3 --> n6 n3 --> n7 classDef lazy fill:#f3e5f5,stroke:#ab47bc,stroke-width:2px; classDef merged fill:#e3f2fd,stroke:#2196f3,stroke-width:2px; class n5,n6 lazy; class n1,n2,n3 merged;
Cài đặt Lazy Propagation¶
9. Cấu trúc Cây Phân Đoạn Bền Vững (Persistent Segment Tree)¶
Bài toán thực tế¶
Ta cần thực hiện cập nhật mảng gốc và trả lời các truy vấn đoạn giống như Segment Tree cơ bản, nhưng cần hỗ trợ truy vấn trên các phiên bản lịch sử khác nhau của mảng.
Ý tưởng cốt lõi: Sao chép đường đi¶
Thay vì tạo mới một Segment Tree hoàn toàn mới cho mỗi phiên bản (tốn \(O(N)\) bộ nhớ và thời gian), ta nhận thấy một thao tác cập nhật điểm chỉ làm thay đổi tối đa \(O(\log N)\) nút trên đường đi từ gốc đến lá.
- Đối với mỗi thao tác cập nhật, ta tạo ra các nút mới dọc theo đường đi từ gốc đến lá để ghi nhận thông tin mới.
- Các nhánh không nằm trên đường đi này sẽ được trỏ trực tiếp (chia sẻ) sang các nút tương ứng của cây phiên bản trước đó.
- Do đó, mỗi lần cập nhật ta chỉ tạo thêm \(O(\log N)\) nút mới. Bộ nhớ tăng thêm cho mỗi phiên bản chỉ là \(O(\log N)\).
Sơ đồ cấu trúc chia sẻ nút giữa Phiên bản 0 và Phiên bản 1 (cập nhật \(a[0] = 10\))¶
Cài đặt Persistent Segment Tree¶
10. Segment Tree 2 Chiều (2D Segment Tree)¶
Bài toán thực tế¶
Cho một ma trận kích thước \(N \times M\). Ta cần thực hiện: * Truy vấn tổng các phần tử trong hình chữ nhật con giới hạn bởi góc trái trên \((x_1, y_1)\) và góc phải dưới \((x_2, y_2)\). * Cập nhật phần tử tại một điểm \((x, y) = val\).
Ý tưởng cốt lõi: "Cây lồng cây"¶
Ta xây dựng một Segment Tree theo chiều dọc (quản lý các hàng từ \(0\) đến \(N-1\)). Tại mỗi nút của cây dọc này, thay vì lưu một giá trị số, ta lưu trữ một cây Segment Tree theo chiều ngang quản lý các cột từ \(0\) đến \(M-1\).
- Để cập nhật điểm \((x, y) = val\), ta duyệt tìm các nút quản lý hàng \(x\) trên Segment Tree hàng, tại mỗi nút đi qua ta tiến hành cập nhật giá trị cột \(y\) tương ứng trong cây con cột.
- Độ phức tạp cho mỗi lần cập nhật hoặc truy vấn là \(O(\log N \times \log M)\).
11. Tóm tắt Độ phức tạp¶
Dưới đây là bảng tổng kết độ phức tạp về thời gian và không gian của các biến thể Segment Tree:
| Cấu trúc dữ liệu | Cập nhật điểm | Cập nhật đoạn | Truy vấn đoạn | Không gian bộ nhớ |
|---|---|---|---|---|
| Segment Tree Cơ bản | \(O(\log N)\) | \(O(N \log N)\) | \(O(\log N)\) | \(O(N)\) (cần mảng \(4N\)) |
| Lazy Segment Tree | \(O(\log N)\) | \(O(\log N)\) | \(O(\log N)\) | \(O(N)\) (cần \(2\) mảng \(4N\)) |
| Persistent Segment Tree | \(O(\log N)\) | — | \(O(\log N)\) | \(O(N + Q \log N)\) |
| 2D Segment Tree | \(O(\log N \log M)\) | — | \(O(\log N \log M)\) | \(O(N \times M)\) (cần mảng \(16NM\)) |
12. Khi nào dùng Segment Tree so với Fenwick Tree (BIT)?¶
Cả hai cấu trúc dữ liệu đều có độ phức tạp thời gian cực kỳ tối ưu, tuy nhiên ta cần cân nhắc sử dụng tùy theo yêu cầu cụ thể:
- Nên dùng Fenwick Tree (BIT) khi:
- Bài toán chỉ yêu cầu tính tổng/tích đoạn (các phép toán có tính chất nghịch đảo).
- Yêu cầu tiết kiệm bộ nhớ tối đa (\(O(N)\) tuyệt đối).
- Cần tốc độ thực thi nhanh và code ngắn gọn dễ cài đặt.
- Nên dùng Segment Tree khi:
- Phép toán cần gộp không có tính chất nghịch đảo (ví dụ: tìm giá trị nhỏ nhất/lớn nhất đoạn, tìm ước chung lớn nhất đoạn).
- Yêu cầu cập nhật một khoảng đoạn giá trị (Lazy Propagation).
- Yêu cầu lưu trữ các phiên bản lịch sử của dữ liệu (Persistent Segment Tree).
13. Bài tập luyện tập¶
| Tên bài tập | Nền tảng | Độ khó | Hướng dẫn sơ lược |
|---|---|---|---|
| CSES - Dynamic Range Sum Queries | CSES | ⭐⭐ | Cây Segment Tree cơ bản tính tổng |
| CSES - Dynamic Range Min Queries | CSES | ⭐⭐ | Thay phép gộp thành lấy giá trị nhỏ nhất |
| CSES - Range Update Queries | CSES | ⭐⭐⭐ | Sử dụng Lazy Propagation cập nhật đoạn |
| CSES - Distinct Values Queries | CSES | ⭐⭐⭐ | Kết hợp Offline Query và Segment Tree |
| SPOJ - MKTHNUM | SPOJ | ⭐⭐⭐⭐ | Áp dụng Persistent Segment Tree để tìm số nhỏ thứ K |
14. Tài liệu tham khảo¶
- CP-Algorithms - Segment Tree
- VNOI Wiki - Cây Phân Đoạn
- Codeforces - Efficient Segment Trees
- YouTube - Segment Tree Playlist (takeuforward)
Bài liên quan: * Bài 8a: Heap (Hàng đợi ưu tiên) * Bài 8b: DSU (Gộp tập hợp) * Bài 8d: Fenwick Tree (Cây chỉ số nhị phân)