Khác với nhiều blog khác, mọi số thứ tự của blog này được đếm từ 1.
Tưởng tượng như ta phải xử lý một vấn đề sau:
Cho một mảng A. Thực hiện một số lượng truy vấn như sau:
Mỗi truy vấn gồm 2 số (l, r) (1 <= l, r <= A.length). Hãy tìm:
- Tổng các phần tử trong đoạn [l, r]
- Giá trị (hoặc chỉ số) của phần tử nhỏ nhất trong đoạn [l, r]
- Giá trị (hoặc chỉ số) của phần tử lớn nhất trong đoạn [l, r]
Với ý đầu tiên, ta có thể dễ dàng xử lý bằng mảng cộng dồn. Nhưng với các truy vấn tìm max/min lại không hề đơn giản như vậy.
Để khắc phục những nhược điểm của việc tổ chức dữ liệu tuyến tính thông thường, Segment Tree (cây phân đoạn / ST) là một cấu trúc dữ liệu được lựa chọn.
Ưu điểm của ST là có tính tổng quát, có thể giải quyết nhiều dạng truy vấn khác nhau liên quan đến mảng – tuy nhiên, nhược điểm chính của nó là dữ liệu cần sử dụng lớn hơn một số cấu trúc dữ liệu khác (như Interval Tree, Fenwick tree, etc.), và đôi khi việc cài đặt ST cũng không đơn giản.
Xây dựng ST từ array
Cách phổ biến nhất để xây dựng ST từ array dựa trên cảm hứng của cây nhị phân đầy đủ.
Hay nói một cách khác, với một khoảng [x, y] cho trước (x < y), ta luôn có thể chia nó thành 2 khoảng con: [x, floor((x+y)/2)] và [floor((x+y)/2)+1, y] (floor(z) là hàm chỉ phần nguyên của số thực z).
Từ đó, với một mảng A có kích thước N, ta có phương hướng cài đặt ST như sau:
Nút đầu tiên của cây, có số thứ tự 1, lưu trữ thông tin cho khoảng [1, N].
Nút thứ hai của cây, có số thứ tự 2, lưu trữ thông tin cho khoảng [1, floor((1+N)/2)] (khoảng con phía bên trái của khoảng [1, N]).
Nút thứ ba của cây, có số thứ tự 3, lưu trữ thông tin cho khoảng [floor((1+N)/2)+1, N] (khoảng con phía bên phải của khoảng [1, N]).
…
và cứ tiếp tục như vậy.
Tất cả thỏa mãn một số điều kiện:
- Nút thứ nhất (số thứ tự 1) của cây luôn lưu trữ thông tin của toàn bộ mảng.
- Nút lá sẽ luôn lưu trữ thông tin của một phần tử (hay khoảng [x, x]).
- Với mỗi nút khác nút lá có số thứ tự a, lưu trữ thông tin khoảng [x, y]:
- Nút (2*a) lưu trữ thông tin của khoảng [x, floor((x+y)/2)], tức khoảng con bên trái của khoảng [x, y].
- Nút (2*a+1) lưu trữ thông tin của khoảng [floor((x+y)/2)+1, y], tức khoảng con bên phải của khoảng [x, y].
Ví dụ với mảng sau:
, thì cây phân đoạn tương ứng sẽ như sau, giả sử như ta đang lưu thông tin về tổng các phần tử trong khoảng:

Với phương pháp cài đặt này, có thể khẳng định chắc chắn là kích thước của mảng biểu diễn các nút của cây phân đoạn sẽ không vượt quá 4N.
Nên nhớ rằng, cây phân đoạn ta đang cài đặt dựa trên cảm hứng của cây nhị phân đầy đủ – một cây như vậy luôn có số lượng nút lá ở dạng 2^x. (với x là một số nguyên không âm bất kỳ). Từ đó, dễ dàng thấy được, toàn bộ cây sẽ có 2^(x+1) – 1 nút.
Nhưng để mọi khoảng-1-phần-tử đều được lưu trữ, số nút lá bắt buộc phải không nhỏ hơn số phần tử trong mảng.
Nói cách khác, với phương thức cài đặt tối ưu nhất, x là số nguyên không âm nhỏ nhất thỏa mãn 2^x >= N.
Nếu N có dạng 2^a + b (với a >= 0 và b > 0, nhưng b rất nhỏ), thì tỷ lệ (2^x)/N sẽ rất lớn, tiến dần bằng 2 khi a tiến tới vô cùng và b tiến tới 1.
Hay nói cách khác, khi tiến tới vô cùng: số nút lá sẽ tiến dần tới 2N.
Và tương ứng, số nút trên toàn bộ cây sẽ tiến dần tới 4N.
Tham khảo thêm các cách chứng minh khác tại đây: Codeforces – Blog entry 49939.
Phương pháp xây dựng đã có, bây giờ viết code cài đặt segment tree thế nào?
Ta có thể dễ dàng thực hiện điều này bằng 1 hàm đệ quy: ta sẽ đi từ gốc của cây (nút 1), và truyền dẫn dần xuống theo độ sâu của cây. Hàm đệ quy sẽ chỉ dừng lại khi gặp nút lá: lúc này ta sẽ gán giá trị của phần tử trong mảng tương ứng với khoảng mà nút lá đó được quy ước.
Sau khi đã tìm tới nút lá, ta bắt đầu quay trở về và tính toán các giá trị cho nút cao hơn. Việc tính toán phụ thuộc vào mục đích của cây: nếu tính tổng thì lấy tổng 2 nút con, nếu tìm min/max thì lấy min/max 2 nút con.
Tóm lại, hàm đệ quy này dựa theo tư tưởng chia để trị, xuất phát từ gốc (nút 1), và gồm các quá trình sau:
- Nếu nút là lá (tức khoảng [x, y] được quy ước thỏa mãn x = y):
- Gán giá trị của nút đúng bằng giá trị của phần tử thứ x trong mảng A.
- Nếu điều kiện không thỏa mãn thì ta bỏ qua nút này, ngược lại kết thúc lần chạy hàm tại nút này ngay lập tức.
- Nếu hàm chưa thoát ra (không phải lá), thực hiện gọi hàm đệ quy:
- Lần gọi 1 với nút con bên trái (2*node).
- Lần gọi 2 với nút con bên phải (2*node+1).
- Sau 2 lần gọi, tổng hợp kết quả vào nút hiện tại (lấy tổng, min, max, etc.)
Do ST được tạo theo kiến trúc cây nhị phân đầy đủ, nên đường đi từ nút gốc tới mỗi nút lá sẽ chỉ tiêu tốn log2(N) bước gọi hàm mới. Do đó, độ phức tạp cho quá trình khởi tạo cây phân đoạn là O(N*logN).
Trả về giá trị truy vấn
Tới đây là bước quan trọng nhất của bài toán: trả lời các truy vấn tìm min/max/tổng như thế nào?
Chúng ta quay trở lại mảng ở trên: A[] = {5, 8, 6, 3, 2, 7, 2, 6}
Giả sử truy vấn của chúng ta yêu cầu tổng các phần tử trong khoảng [3, 8].
Dễ dàng tính được bằng Số học đơn thuần: sumq(3,8) = 6 + 3 + 2 + 7 + 2 + 6 = 26.
Trong trường hợp xử lý bằng cây nhị phân, ta thực hiện việc tính toán bằng cách: tìm tập các khoảng trong mảng thỏa mãn 2 điều kiện sau:
- Các khoảng trong tập không trùng khớp nhau và liên tục nhau (hay nói cách khác, nếu sắp xếp các khoảng theo thứ tự từ trước đến sau, sẽ không có bất kỳ vùng nào chồng lên nhau, và tất cả các khoảng nối liền lại tạo thành 1 khoảng liền mạch lớn).
- Khoảng liền mạch lớn thu được khi xếp các khoảng nhỏ lại (như đã mô tả ở trên) đúng bằng khoảng mà truy vấn yêu cầu.
Từ đây, ta nhận thấy, để thực hiện việc tính toán, ta có thể sử dụng một hàm đệ quy, gần tương tự như hàm đệ quy ở phần cài đặt ST.
Ý tưởng của hàm đệ quy lần này như sau:
- Lần gọi gốc bắt đầu từ nút gốc (nút 1).
- Nếu khoảng [L, R] lưu trữ trong nút nằm gọn trong khoảng [x, y] của truy vấn (tức là, x <= L <= R <= y), ta trả về giá trị đúng bằng giá trị lưu trên nút và kết thúc lần chạy hàm.
- Nếu không, ta thực hiện gọi xuống 2 khoảng con:
- Lần gọi 1 với nút con bên trái (2*node).
- Lần gọi 2 với nút con bên phải (2*node+1).
- Sau 2 lần gọi. Tổng hợp kết quả và trả về giá trị (tổng/min/max thu được sau 2 lượt gọi khoảng con, phép xử lý tùy thuộc đề bài).
Hãy áp dụng thử với truy vấn tổng phần tử khoảng [3, 8] của mảng A trên.

Thứ tự các bước thực hiện như sau:
- Gọi hàm ở nút 1 (khoảng [1, 8]).
- Do khoảng này không nằm gọn trong khoảng truy vấn, ta duyệt 2 khoảng con:
- Gọi hàm ở nút 2 (khoảng [1, 4]).
- Khoảng này cũng không nằm gọn trong truy vấn.
- Gọi hàm ở nút 4 (khoảng [1, 2]).
- Khoảng này không thuộc truy vấn nên dừng duyệt, kết thúc hàm và trả về giá trị 0.
- Gọi hàm ở nút 5 (khoảng [3, 4]).
- Khoảng này nằm gọn trong truy vấn, trả về giá trị 9, kết thúc hàm.
- Tổng hợp kết quả: trả về giá trị (0 + 9 = ) 9.
- Gọi hàm ở nút 3 (khoảng [5, 8]).
- Khoảng này nằm gọn trong truy vấn, trả về giá trị 17, kết thúc hàm.
- Tổng hợp kết quả: trả về giá trị (9 + 17 = ) 26.
- Kết thúc hàm.
Như vậy, ta cũng thu được kết quả là 26.
Ta thấy rằng, mỗi khoảng được yêu cầu trên truy vấn là mảng liên tục, cộng thêm việc ta duyệt đệ quy từ đỉnh gốc (chứa khoảng lớn nhất) xuống, nên với mỗi tầng của cây phân đoạn, ta chỉ sử dụng tối đa 2 nút ở tầng đó. Do đó, độ phức tạp cho 1 truy vấn là O(logN).
Cập nhật ST (cơ bản)
Nếu giả sử có sự thay đổi ở một phần tử nào đó của cây, ta cũng phải cập nhật lại giá trị đó.
Ở mức độ cơ bản (và có thể coi là khờ khạo), mỗi thao tác được thực hiện tương tự như các thao tác ở các phần trước:
- Gọi hàm đệ quy từ nút gốc (nút 1).
- Truy vấn xuống cho tới khi nào tới nút lá đại diện cho vị trí phần tử cần cập nhật.
- Cập nhật giá trị nút lá và kết thúc hàm đệ quy tại nút lá.
- Trở về tổng hợp giá trị của các khoảng cha. Hay nói cách khác, cập nhật lại giá trị của các nút đại diện cho các khoảng này (tính lại tổng, so sánh giữa 2 khoảng con để cập nhật giá trị max/min mới, etc.).
Mỗi thao tác cập nhật này sẽ có độ phức tạp O(logN). Dĩ nhiên vấn đề sẽ trở nên rắc rối khi ta phải cập nhật một khoảng phần tử cùng lúc…
To be continued…
Xem thêm series về các cải tiến / dạng nâng cao của Segment Tree:
- Lazy Propagation
- Cập nhật dạng đa thức (polynomial updates) (chưa cập nhật)
- Cây động (Dynamic tree) (chưa cập nhật)
Bài toán tham khảo 1: VNSPOJ-QMAX
Ta nhận thấy rằng: tất cả các truy vấn làm thay đổi mảng đều diễn ra trước khi các truy vấn yêu cầu tìm giá trị lớn nhất của các khoảng, cho nên dù có thể không phải tối ưu nhất, nhưng với mục đích luyện tập thì ta cũng có thể cài đặt cây phân đoạn ở bài này. Quá trình làm bài này sẽ gồm 2 bước:
- Xây dựng mảng A ban đầu. Độ phức tạp của quá trình này sẽ là O(n+m).
- Để đạt được độ phức tạp này, ta sử dụng phương pháp mảng cộng dồn.
- Với phương pháp này, ở mỗi truy vấn yêu cầu tăng tất cả các phần tử ở đoạn [u, v] lên k đơn vị, ta chỉ cần tăng A[u] lên k đơn vị và giảm A[v+1] (nếu có) đi k đơn vị.
- Sau khi xử lý các truy vấn, ta thực hiện cộng dồn thông qua duyệt tuyến tính từ phần tử thứ 2 trở đi: A[i] += A[i-1] (i > 1).
- Cài đặt cây phân đoạn và trả lời các truy vấn tìm max.
- Độ phức tạp cho quá trình cài đặt cây là O(n*log n).
- Độ phức tạp cho việc trả lời các truy vấn là O(q*log n).
Lời giải: Ideone.com
Bài toán tham khảo 2: Codeforces 920F – SUM and REPLACE
Tóm tắt bài toán:
Cho một mảng a gồm n (n <= 3*10^5) số nguyên dương, giá trị không vượt quá 10^6. Thực hiện m truy vấn (m <= 3*10^5) thuộc 2 loại:
- Loại 1: REPLACE l r – thay tất cả các phần tử trong đoạn [l, r] từ giá trị x trở thành giá trị D(x), với D(z) là số ước nguyên dương của một số nguyên z.
- Loại 2: SUM l r – tính tổng các phần tử trong đoạn [l, r].
Với mỗi truy vấn loại 2, in ra tổng tìm được.
Phương pháp: Ta dễ dàng rút ra một số nhận xét sau:
- Cây phân đoạn là một cấu trúc thích hợp để giải bài toán này.
- Ta có thể hoàn toàn khởi tạo trước mảng tính D(x) (với 1 <= x <= 10^6) thông qua kỹ thuật sàng nguyên tố Eratosthenes.
- Mỗi số nguyên x (1 <= x <= 10^6) có thể được biến đổi theo hệ thức x := D(x) tối đa (log x) lần cho tới khi x và D(x) bằng nhau (ta tạm gọi một số x thỏa mãn x = D(x) là một số thuộc trạng thái Z).
Với nhận xét thứ 3, làm thế nào để ta tận dụng nó và rút ngắn thời gian thực hiện bài toán? (nhớ rằng, mỗi truy vấn loại 1 có độ phức tạp tối đa là O(N*logN))
Cách 1 (của D. Bách):
Ngoài cây phân đoạn ban đầu dùng để lưu tổng các phần tử của mảng, ta sử dụng thêm 1 cây phân đoạn lưu tổng nữa, nhưng là tổng số lượng phần tử trong khoảng thuộc trạng thái Z.
Mỗi khi cập nhật một phần tử bất kỳ, ta kiểm tra lại phần tử đó ngay lập tức. Nếu sau khi cập nhật nó đã thỏa mãn D(x) = x, tức là phần tử đó thuộc trạng thái Z, và giá trị của các nút đại diện cho khoảng chứa phần tử này sẽ đều tăng thêm 1.
Sau này, khi duyệt các truy vấn cập nhật, ta sẽ thấy: có những lần duyệt sẽ xảy ra ở các khoảng mà toàn bộ các phần tử trong đó đã thuộc trạng thái Z. Lúc này, ta có thể quyết định dừng duyệt tiếp hay không, thông qua một bước kiểm tra đơn giản: tính tổng số phần tử thuộc trạng thái Z thông qua ST thứ hai, nếu giá trị thu được đúng bằng kích thước khoảng thì ta ngừng duyệt tiếp và kết thúc hàm đệ quy ở điểm đó.
Truy vấn tính tổng ta thực hiện như bình thường.
Độ phức tạp của thuật toán là O(m*(log n)^2).
Lời giải: Submission 35221937
Cách 2 (của liv1n9):
Ta nhận thấy rằng: D(x) = x khi và chỉ khi 1 <= x <= 2.
Do đó, ta có thể lập cây phân đoạn thứ hai: cây này lưu giá trị max của các khoảng.
Ở đây, điều kiện dừng cập nhật sẽ dễ hiểu hơn nhiều: nếu giá trị max của khoảng đó không vượt quá 2 thì ta ngừng duyệt (vì chắc chắn mọi phần tử x lúc đó đều thỏa mãn hệ thức D(x) = x, theo nhận xét ở trên).
Truy vấn tính tổng ta thực hiện như bình thường.
Độ phức tạp của thuật toán là O(m*(log n)^2).
Lời giải: Submission 34870782
Một số nội dung được dịch sang tiếng Việt từ sách Competitive Programming’s Handbook của Antti Laaksonen, CSES, Phần Lan.
#ThuyTrang_12A2