Cấu trúc cây nâng caoAdvanced Tree Structures
Mục lục
Table of contents
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Các loại tree đã học từ trước đến giờ đều xoay quanh thứ tự: binary search tree giữ key được sắp xếp để bạn tìm được một key trong O(log n), và balanced tree (AVL, red-black, B-tree — xem ./11-balanced-and-multiway-trees.md) giữ đảm bảo đó khi có insert. Các cấu trúc trong bài này thì khác. Chúng là những tree được xây để trả lời những câu hỏi mà một container đã sắp xếp đơn giản là không trả lời hiệu quả được:
- Trie trả lời “những key nào đã lưu bắt đầu bằng prefix này?” — câu hỏi mà hash table hoàn toàn không trả lời được và BST trả lời một cách vụng về. Insight then chốt là đường đi từ root đánh vần ra key, nên không node nào cần lưu chính key đó.
- Segment tree trả lời “tổng/min/max/gcd trên đoạn
a..blà bao nhiêu?” trong khi mảng bên dưới vẫn đang bị sửa đổi — việc chia đôi đệ quy biến một range thànhO(log n)mảnh đã tính sẵn. - Fenwick tree (Binary Indexed Tree) trả lời phiên bản prefix-sum của câu hỏi đó với một phần tư bộ nhớ và khoảng một phần ba lượng code, bằng cách khai thác biểu diễn nhị phân của chỉ số.
- Suffix array và suffix tree trả lời “pattern này có xuất hiện trong text này không, và ở đâu?” cho một text mà bạn sẽ tìm kiếm nhiều lần — bằng cách tiền xử lý mọi suffix một lần.
Chủ đề chung là tiền tính toán kèm đánh đổi. Mỗi cấu trúc bỏ ra bộ nhớ và thời gian dựng ban đầu để biến một lần quét O(n) thành một lần tra cứu O(log n) hoặc O(1). Việc chọn giữa chúng chủ yếu phụ thuộc vào bạn cần những phép toán nào và, quan trọng nhất, dữ liệu có thay đổi hay không: một mảng prefix sum tĩnh đánh bại mọi cấu trúc ở đây cho các truy vấn prefix chỉ đọc, và biết điều đó giúp bạn khỏi phải dựng một segment tree mà bạn không cần.
Kiến thức nền tảng
Trie (prefix tree)
Trie — lấy từ retrieval, thường đọc là “try” để phân biệt với “tree” — là một search tree cho key dạng chuỗi trong đó không node nào lưu key. Thay vào đó, vị trí của node trong tree định nghĩa chuỗi mà nó biểu diễn: nối các nhãn edge từ root xuống. Mọi hậu duệ của một node đều chia sẻ chuỗi của node đó làm prefix, và root biểu diễn chuỗi rỗng.
Trie chứa {"car", "cart", "cat", "do", "dog"}
(root)
/ \
c d
| |
a o*
/ \ |
r* t* g*
|
t*
* = một word đã lưu kết thúc ở đây
"ca" là một prefix hợp lệ (node tồn tại) nhưng không phải word đã lưu (không có *)
Chính cấu trúc đó khiến truy vấn prefix trở nên đơn giản: để tìm mọi word bắt đầu bằng "ca", đi hai edge rồi liệt kê subtree. Hash table không làm được điều này — việc hash phá hủy mối liên hệ giữa "car" và "cart" — còn mảng đã sắp xếp thì làm được (binary search ra khoảng prefix) nhưng lại không hỗ trợ insert rẻ.
class TrieNode:
__slots__ = ("children", "is_word")
def __init__(self):
self.children = {} # ký tự -> TrieNode
self.is_word = False # True nếu một key đã lưu kết thúc đúng tại đây
class Trie:
"""Prefix tree trên chuỗi. Mọi phép toán là O(L) theo độ dài key L,
không phụ thuộc vào số lượng key đang lưu."""
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
# setdefault chỉ tạo child nếu ký tự này là mới
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
def _walk(self, prefix):
"""Trả về node đi tới khi đánh vần prefix, hoặc None nếu đi vào ngõ cụt."""
node = self.root
for ch in prefix:
node = node.children.get(ch)
if node is None:
return None
return node
def search(self, word):
"""word có phải key đã lưu không? Tới được node là chưa đủ — phải có đánh dấu."""
node = self._walk(word)
return node is not None and node.is_word
def starts_with(self, prefix):
"""Có key nào đã lưu bắt đầu bằng chuỗi này không?"""
return self._walk(prefix) is not None
def autocomplete(self, prefix, limit=10):
"""Tối đa `limit` key đã lưu có prefix cho trước, theo thứ tự từ điển."""
node = self._walk(prefix)
results = []
if node is None:
return results
stack = [(node, prefix)] # stack tường minh thay cho đệ quy
while stack and len(results) < limit:
cur, word = stack.pop()
if cur.is_word:
results.append(word)
# push theo thứ tự ngược để ký tự nhỏ nhất được pop ra trước
for ch in sorted(cur.children, reverse=True):
stack.append((cur.children[ch], word + ch))
return results
t = Trie()
for w in ["car", "cart", "cat", "do", "dog", "dodge"]:
t.insert(w)
print(t.search("car"), t.search("ca")) # True False
print(t.starts_with("ca")) # True
print(t.autocomplete("ca")) # ['car', 'cart', 'cat']
print(t.autocomplete("do")) # ['do', 'dodge', 'dog']
Việc xóa cần cẩn thận: chỉ bỏ đánh dấu is_word là không đủ, vì các node giờ không dùng tới sẽ rò rỉ bộ nhớ và làm chậm việc duyệt. Bạn phải cắt tỉa ngược lên theo đường đi, dừng ngay khi gặp một node còn child khác hoặc còn kết thúc một key khác.
def delete(self, word):
"""Xóa word nếu có, cắt tỉa các node trở nên vô dụng. O(L)."""
path = [self.root]
node = self.root
for ch in word:
node = node.children.get(ch)
if node is None:
return False # word chưa từng được lưu
path.append(node)
if not node.is_word:
return False
node.is_word = False
for i in range(len(word) - 1, -1, -1): # đi ngược về phía root
child, parent = path[i + 1], path[i]
if child.children or child.is_word:
break # vẫn còn cần cho key khác
del parent.children[word[i]]
return True
Trie.delete = delete
Độ phức tạp. Gọi L là độ dài key, n là số key, Σ là kích thước alphabet, và N là tổng số ký tự đã lưu.
| Phép toán | Thời gian | Ghi chú |
|---|---|---|
insert | O(L) | Không phụ thuộc n — đây là tính chất nổi bật nhất của trie |
search | O(L) | Tương tự |
starts_with | O(L) | Tương tự |
delete | O(L) | Đi xuống, rồi cắt tỉa ngược lên |
autocomplete(p, k) | O(|p| + k·L) | Đi tới node, rồi liệt kê k kết quả |
| Space | O(N · Σ) worst | O(N) node; chi phí mỗi node tùy cách biểu diễn child |
Hãy chú ý cái không có trong bảng đó: n không bao giờ xuất hiện. Chi phí tra cứu của trie chỉ phụ thuộc vào độ dài key bạn đang tìm. Hash table cũng cho O(L) với key chuỗi (bạn phải hash toàn bộ chuỗi), nên lợi thế thực sự của trie không phải tốc độ tra cứu thô — mà là việc liệt kê theo prefix, duyệt theo thứ tự, và không có collision hay resize.
Chi phí bộ nhớ của trie — nói thẳng
Trie ngốn bộ nhớ, và đây là lý do phổ biến nhất để từ chối dùng nó trong production.
Cách biểu diễn con trỏ tới child quyết định tất cả:
| Cách biểu diễn | Chi phí mỗi node | Tra cứu mỗi ký tự | Khi nào phù hợp |
|---|---|---|---|
Mảng cố định [None] * Σ | Σ pointer (208 B cho ASCII thường trên 64-bit) | O(1), một lần load theo chỉ số | Trie dày đặc, alphabet nhỏ |
Hash map (dict) | ~64 B khi rỗng + các entry | O(1) qua hash | Trie thưa, Unicode; mặc định trong Python |
| List cặp đã sắp xếp | O(số children) | O(log Σ) binary search | Rất thưa, cực kỳ tiết kiệm bộ nhớ |
| Bitmap + mảng nén | Σ bit + các pointer đang dùng | O(1) với popcount | Trie production (kiểu HAMT) |
Trong CPython, TrieNode ở trên tốn khoảng 56 byte cho object cộng ít nhất 64 byte cho dict, nên một trie chứa 100k từ tiếng Anh (khoảng 900k ký tự, có lẽ 250k node phân biệt sau khi chia sẻ prefix) rơi vào cỡ vài chục megabyte — cho dữ liệu vốn chỉ chưa tới 1 MB dưới dạng một list chuỗi thuần túy. Nếu bạn chỉ cần kiểm tra thành viên, một set chuỗi nhỏ hơn, nhanh hơn, và gọn trong một dòng code. Hãy dựng trie khi cấu trúc prefix chính là mục đích, chứ không phải như một container chuỗi đa dụng.
Trie nén (radix trie / PATRICIA)
Phần lớn bộ nhớ đó đi vào các chuỗi node chỉ có một child — phần "dodg" trong "dodge" là bốn node không phân nhánh gì cả. Một compressed trie, còn gọi là radix trie hay PATRICIA trie, gộp mỗi chuỗi như vậy thành một edge duy nhất được gán nhãn bằng cả substring:
trie thường cho {"romane", "romanus", "rubens"} radix trie cho cùng tập đó
r r
| / \
o - m - a - n - e* "om" / \ "ubens"*
| \ / \
u - b - e - n - s* (và: n - u - s*) "an" o
/ \
"e"* "us"*
Số node giảm từ O(N) (tổng số ký tự) xuống O(n) (số key) — một radix trie với n key có tối đa n leaf và n−1 node phân nhánh nội bộ, bất kể key dài đến đâu. Tra cứu vẫn là O(L) nhưng với ít lần dereference pointer hơn nhiều và cache behaviour tốt hơn hẳn, đổi lại là logic so sánh substring và việc tách node khi insert.
Đây không phải chuyện học thuật suông. Radix trie là thứ các hệ thống thật dùng: page cache và bảng định tuyến IP của Linux kernel, cách lưu key của etcd và Redis (rax của Redis), các router trong net/http của Go, và Merkle Patricia trie của Ethereum đều dùng hình dạng này. Một biến thể liên quan, ternary search trie, lưu ba child mỗi node (<, =, >) và là điểm cân bằng tốt giữa trie mảng-Σ và BST.
Khái niệm chính
Segment tree
Segment tree trả lời range query trên một mảng có thể thay đổi. Cho data[0..n−1] và một phép toán kết hợp (sum, min, max, gcd, “đếm số 0”, gần như mọi thứ), nó hỗ trợ:
query(l, r)— phép toán gộp trêndata[l..r], trongO(log n)update(i, v)— gándata[i] = v, trongO(log n)
Riêng lẻ thì không có gì ấn tượng. Một mảng thường làm update trong O(1) và query trong O(n); một mảng prefix sum làm query trong O(1) và update trong O(n). Đóng góp của segment tree là làm cho cả hai đều logarit, đúng thứ bạn cần khi đọc và ghi xen kẽ nhau.
Ý tưởng là chia đôi đệ quy. Root bao [0, n−1]; mỗi node nội bộ chia đôi range của mình và lưu phép toán áp lên hai child; các leaf là từng phần tử riêng lẻ.
data = [1, 3, 5, 7, 9, 11] (tổng được ghi trong mỗi node)
[0..5] 36
/ \
[0..2] 9 [3..5] 27
/ \ / \
[0..1] 4 [2..2] 5 [3..4] 16 [5..5] 11
/ \ / \
[0] 1 [1] 3 [3] 7 [4] 9
query(1, 3) = 3 + 5 + 7 = 15
→ [1..1] nằm trọn bên trong → 3
→ [2..2] nằm trọn bên trong → 5
→ [3..3] nằm trọn bên trong → 7
Bất kỳ range [l, r] nào cũng phân tách thành tối đa 2 log n node được phủ trọn vẹn, vì ở mỗi tầng biên của truy vấn chỉ có thể “cắt” hai node (mỗi đầu một node). Đó là toàn bộ lập luận về độ phức tạp.
class SegmentTree:
"""Segment tree tính range sum với point update trên mảng kích thước cố định.
Lưu trong một mảng phẳng: node 1 là root; hai child của node v là
2v và 2v+1. Kích thước 4n là cận trên an toàn cho mọi n.
"""
def __init__(self, data):
self.n = len(data)
self.tree = [0] * (4 * self.n)
if self.n:
self._build(data, 1, 0, self.n - 1)
def _build(self, data, v, lo, hi):
if lo == hi: # leaf: chứa một phần tử
self.tree[v] = data[lo]
return
mid = (lo + hi) // 2
self._build(data, 2 * v, lo, mid)
self._build(data, 2 * v + 1, mid + 1, hi)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def update(self, idx, value):
"""data[idx] = value, trong O(log n) — một đường root-tới-leaf được ghi lại."""
self._update(1, 0, self.n - 1, idx, value)
def _update(self, v, lo, hi, idx, value):
if lo == hi:
self.tree[v] = value
return
mid = (lo + hi) // 2
if idx <= mid:
self._update(2 * v, lo, mid, idx, value)
else:
self._update(2 * v + 1, mid + 1, hi, idx, value)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def query(self, left, right):
"""Tổng của data[left..right], bao gồm hai đầu, trong O(log n)."""
return self._query(1, 0, self.n - 1, left, right)
def _query(self, v, lo, hi, left, right):
if right < lo or hi < left: # rời nhau: đóng góp phần tử đơn vị
return 0
if left <= lo and hi <= right: # phủ trọn: dùng giá trị đã lưu
return self.tree[v]
mid = (lo + hi) // 2 # phủ một phần: đệ quy vào cả hai nửa
return (self._query(2 * v, lo, mid, left, right)
+ self._query(2 * v + 1, mid + 1, hi, left, right))
st = SegmentTree([1, 3, 5, 7, 9, 11])
print(st.query(1, 3)) # 15
st.update(1, 10)
print(st.query(1, 3)) # 22
Để đổi phép toán, hãy đổi ba thứ: bước gộp (+), phần tử đơn vị trả về cho node rời nhau (0 cho sum, float('inf') cho min, -inf cho max, 0 cho gcd), và cách khởi tạo leaf. Mọi thứ khác giữ nguyên — segment tree tổng quát cho mọi monoid (một phép toán kết hợp có phần tử đơn vị). Đây là tính chất cần kiểm tra trước khi dùng nó: “sum” được, “min” được, “phần tử lớn thứ k trong range” thì không, vì bạn không thể gộp câu trả lời của hai child thành câu trả lời của parent.
Độ phức tạp: build O(n), query O(log n), point update O(log n), space O(4n). Con số 4n là cách cấp phát dư tiện lợi cho layout đệ quy; một cài đặt lặp bottom-up trên mảng đã pad thành lũy thừa của 2 chỉ cần 2n và nhanh hơn đáng kể, đổi lại khó mở rộng với lazy propagation hơn.
Lazy propagation
Point update là trường hợp dễ. Còn nếu bạn cần cộng 5 vào mọi phần tử trong data[l..r] thì sao? Làm từng phần tử một là O(r − l), vứt bỏ toàn bộ ý nghĩa của cấu trúc.
Lazy propagation giải quyết chuyện này: khi một update phủ trọn range của một node, áp nó vào giá trị tổng hợp của node đó và ghi lại một dấu “đang chờ” vào mảng lazy[] song song — đừng đi xuống. Chỉ đẩy dấu đó xuống các child khi một truy vấn hay update sau này thực sự cần nhìn vào bên trong node đó. Mỗi phép toán vẫn chỉ chạm O(log n) node.
class LazySegmentTree:
"""Segment tree range-add, range-sum. Cả hai phép toán O(log n).
lazy[v] là một update đã được phản ánh vào tree[v] nhưng chưa vào các child
của v; nó chỉ được đẩy xuống khi ta buộc phải đi qua v.
"""
def __init__(self, data):
self.n = len(data)
self.tree = [0] * (4 * self.n)
self.lazy = [0] * (4 * self.n)
self._build(data, 1, 0, self.n - 1)
def _build(self, data, v, lo, hi):
if lo == hi:
self.tree[v] = data[lo]
return
mid = (lo + hi) // 2
self._build(data, 2 * v, lo, mid)
self._build(data, 2 * v + 1, mid + 1, hi)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def _apply(self, v, lo, hi, delta):
self.tree[v] += delta * (hi - lo + 1) # mọi phần tử trong range đều tăng delta
self.lazy[v] += delta
def _push(self, v, lo, hi):
if self.lazy[v]:
mid = (lo + hi) // 2
self._apply(2 * v, lo, mid, self.lazy[v])
self._apply(2 * v + 1, mid + 1, hi, self.lazy[v])
self.lazy[v] = 0
def add(self, left, right, delta, v=1, lo=0, hi=None):
if hi is None:
hi = self.n - 1
if right < lo or hi < left:
return
if left <= lo and hi <= right: # phủ trọn: dừng ở đây, đánh dấu lazy
self._apply(v, lo, hi, delta)
return
self._push(v, lo, hi)
mid = (lo + hi) // 2
self.add(left, right, delta, 2 * v, lo, mid)
self.add(left, right, delta, 2 * v + 1, mid + 1, hi)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def query(self, left, right, v=1, lo=0, hi=None):
if hi is None:
hi = self.n - 1
if right < lo or hi < left:
return 0
if left <= lo and hi <= right:
return self.tree[v]
self._push(v, lo, hi) # buộc phải đi xuống, nên trả nợ trước
mid = (lo + hi) // 2
return (self.query(left, right, 2 * v, lo, mid)
+ self.query(left, right, 2 * v + 1, mid + 1, hi))
lst = LazySegmentTree([1, 3, 5, 7, 9, 11])
lst.add(1, 4, 10) # cộng 10 vào data[1..4]
print(lst.query(0, 5)) # 36 + 40 = 76
print(lst.query(2, 3)) # (5 + 10) + (7 + 10) = 32
Lazy propagation là chỗ segment tree trở nên thực sự khó nhằn. Hai quy tắc giúp tránh phần lớn rắc rối: giá trị lazy phải hợp thành được (hai update “add” đang chờ gộp thành một; hai update “assign” đang chờ thì giữ cái sau — còn trộn add với assign thì cần một quy tắc hợp thành tường minh và thứ tự cẩn thận), và _apply phải biết độ dài range cho các phép tổng hợp như sum. Sai một trong hai và bug sẽ rất tinh vi, phụ thuộc vào dữ liệu.
Segment tree cũng tổng quát hóa vượt ra ngoài mảng: persistent segment tree giữ mọi phiên bản lịch sử bằng path copying, merge-sort tree lưu một list đã sắp xếp ở mỗi node để trả lời truy vấn rank trên range, và segment tree beats xử lý được range-min-assignment. Những thứ đó đáng biết là chúng tồn tại, không đáng học thuộc.
Fenwick tree / Binary Indexed Tree
Fenwick tree (Peter Fenwick, 1994), hay Binary Indexed Tree (BIT), làm công việc phổ biến nhất của segment tree — prefix sum với point update — trong một phần tư bộ nhớ và khoảng mười lăm dòng code. Nó hoạt động bằng cách đọc biểu diễn nhị phân của chỉ số.
Cấu trúc: dùng chỉ số 1-based, và cho tree[i] lưu tổng của i & -i phần tử kết thúc tại vị trí i. Biểu thức i & -i tách ra bit thấp nhất được bật của i, vì trong bù hai -i chính là ~i + 1, phép này lật mọi bit phía trên bit thấp nhất được bật và giữ nguyên bit đó.
i nhị phân i & -i tree[i] bao
1 0001 1 data[1..1]
2 0010 2 data[1..2]
3 0011 1 data[3..3]
4 0100 4 data[1..4]
5 0101 1 data[5..5]
6 0110 2 data[5..6]
7 0111 1 data[7..7]
8 1000 8 data[1..8]
prefix_sum(7) = tree[7] + tree[6] + tree[4]
= data[7] + data[5..6] + data[1..4]
đường đi: 7 (0111) → 6 (0110) → 4 (0100) → 0 — gỡ bit thấp nhất được bật
một node cho mỗi bit được bật trong 7, nên tối đa log2(n) bước
add(5, delta) chạm tree[5], tree[6], tree[8]
đường đi: 5 (0101) → 6 (0110) → 8 (1000) → vượt n — cộng bit thấp nhất được bật
Prefix sum gỡ bit (i -= i & -i); update cộng bit (i += i & -i). Mỗi lần đi qua tối đa log₂ n chỉ số, và hai đường đi là hình phản chiếu chính xác của nhau — chính mẹo đó khiến toàn bộ cấu trúc gói gọn trong mười lăm dòng.
class FenwickTree:
"""Binary Indexed Tree trên các vị trí 1..n.
Point update và prefix sum, cả hai O(log n). Space: n+1 số nguyên.
"""
def __init__(self, n):
self.n = n
self.tree = [0] * (n + 1) # chỉ số 0 không dùng; cấu trúc là 1-based
def add(self, i, delta):
"""data[i] += delta, với 1 <= i <= n."""
while i <= self.n:
self.tree[i] += delta
i += i & -i # nhảy tới node kế tiếp có range bao i
def prefix_sum(self, i):
"""Tổng của data[1..i]. prefix_sum(0) == 0."""
total = 0
while i > 0:
total += self.tree[i]
i -= i & -i # gỡ bit thấp nhất được bật
return total
def range_sum(self, left, right):
"""Tổng của data[left..right] — phép trừ là lý do sum dùng được ở đây."""
return self.prefix_sum(right) - self.prefix_sum(left - 1)
@classmethod
def from_list(cls, data):
"""Dựng trong O(n) thay vì n lời gọi add() riêng lẻ tốn O(log n) mỗi lần."""
bit = cls(len(data))
bit.tree[1:] = list(data)
for i in range(1, bit.n + 1):
parent = i + (i & -i)
if parent <= bit.n:
bit.tree[parent] += bit.tree[i]
return bit
bit = FenwickTree.from_list([1, 3, 5, 7, 9, 11])
print(bit.prefix_sum(4)) # 16
print(bit.range_sum(2, 4)) # 15
bit.add(2, 10) # data[2] += 10
print(bit.range_sum(2, 4)) # 25
Fenwick so với segment tree. Fenwick tree kém tổng quát hơn hẳn và, khi áp dụng được, tốt hơn hẳn:
| Fenwick tree | Segment tree | |
|---|---|---|
| Space | n + 1 số nguyên | 4n (đệ quy) hoặc 2n (lặp) |
| Lượng code | ~15 dòng | ~50 dòng, còn nhiều hơn với lazy |
| Hằng số nhân | Rất thấp — vòng lặp gọn, không đệ quy, bộ nhớ gần như tuần tự | Cao hơn — đệ quy, rẽ nhánh, không pointer nhưng truy cập rải rác |
| Phép toán hỗ trợ | Chỉ loại khả nghịch: sum, xor, count | Mọi monoid kết hợp: min, max, gcd, merge tùy ý |
| Range update | Làm được (mẹo hai BIT) nhưng rườm rà | Tự nhiên, với lazy propagation |
| Truy vấn suffix / range bất kỳ | Qua phép trừ hai prefix | Trực tiếp |
Hạn chế then chốt nằm ở dòng in đậm: range_sum(l, r) = prefix_sum(r) − prefix_sum(l−1) đòi hỏi có phần tử nghịch đảo. min không có nghịch đảo, nên bạn không thể dựng Fenwick tree tính range minimum theo cách này. Đó, và chỉ đó, là điều buộc bạn phải chuyển sang segment tree.
Hai mở rộng đáng biết:
- Range update, point query — giữ một BIT của hiệu.
add(l, r, delta)trở thànhbit.add(l, delta); bit.add(r + 1, -delta), vàpoint_query(i)chính làbit.prefix_sum(i). - Range update, range sum — hai BIT, dùng đẳng thức rằng prefix-của-prefix của một mảng hiệu tái tạo được tổng lũy tiến. Vẫn
O(log n)với một nửa bộ nhớ của lazy segment tree. - Tìm
inhỏ nhất sao choprefix_sum(i) ≥ k— một lần binary lifting trên BIT chỉ với một lượt đi xuốngO(log n)(không phảiO(log² n)), khiến Fenwick tree trên tần suất giá trị trở thành một cấu trúc order-statistic.
Fenwick tree cũng chính là cách đếm số inversion trong O(n log n): quét từ trái sang phải, và với mỗi phần tử đếm xem đã chèn bao nhiêu giá trị lớn hơn.
Suffix array và suffix tree
Họ cấu trúc cuối cùng trả lời truy vấn substring trên một text mà bạn sẽ tìm kiếm lặp đi lặp lại. Tìm một pattern độ dài m trong text độ dài n một lần thì tốt nhất là dùng KMP hoặc Boyer–Moore trong O(n + m) — đừng dựng cấu trúc suffix cho một lần tìm kiếm duy nhất. Nhưng nếu text cố định và truy vấn cứ liên tục đến, việc tiền xử lý mọi suffix có lãi ngay lập tức.
Suffix array đơn giản là thứ tự đã sắp xếp của toàn bộ n suffix của text, lưu dưới dạng chỉ số bắt đầu của chúng:
s = "banana"
suffix chỉ số đã sắp xếp suffix array
"banana" 0 "a" (5) sa = [5, 3, 1, 0, 4, 2]
"anana" 1 "ana" (3)
"nana" 2 "anana" (1) lcp = [0, 1, 3, 0, 0, 2]
"ana" 3 "banana" (0) (LCP với suffix liền trước)
"na" 4 "na" (4)
"a" 5 "nana" (2)
Vì các suffix đã được sắp xếp, mọi lần xuất hiện của một pattern p đều nằm thành một khối liền kề trong suffix array — mọi suffix bắt đầu bằng p được xếp cạnh nhau. Nên tìm kiếm substring chỉ là hai lần binary search.
def build_suffix_array(s):
"""Suffix array của s bằng prefix doubling: O(n log^2 n).
Bất biến: sau vòng lặp với bước k, `rank` xếp hạng mọi suffix theo
2k ký tự đầu tiên của nó. Nhân đôi k cho tới khi vượt n là xếp hạng đầy đủ.
"""
n = len(s)
sa = list(range(n))
rank = [ord(c) for c in s] # xếp hạng theo ký tự đầu tiên
k = 1
while k < n:
# sort theo (rank của k ký tự đầu, rank của k ký tự tiếp theo)
key = lambda i: (rank[i], rank[i + k] if i + k < n else -1)
sa.sort(key=key) # O(n log n) mỗi vòng, log n vòng
new_rank = [0] * n
for i in range(1, n):
new_rank[sa[i]] = new_rank[sa[i - 1]] + (key(sa[i]) > key(sa[i - 1]))
rank = new_rank
k *= 2
return sa
def find_range(s, sa, pattern):
"""(lo, hi) là khoảng nửa mở các ô suffix-array có suffix bắt đầu bằng
pattern. O(|pattern| * log |s|): log n lần so sánh, mỗi lần O(|pattern|)."""
m = len(pattern)
lo, hi = 0, len(sa)
while lo < hi: # lower bound
mid = (lo + hi) // 2
if s[sa[mid]:sa[mid] + m] < pattern:
lo = mid + 1
else:
hi = mid
start, hi = lo, len(sa)
while lo < hi: # upper bound
mid = (lo + hi) // 2
if s[sa[mid]:sa[mid] + m] <= pattern:
lo = mid + 1
else:
hi = mid
return start, lo
s = "banana"
sa = build_suffix_array(s)
print(sa) # [5, 3, 1, 0, 4, 2]
lo, hi = find_range(s, sa, "ana")
print(hi - lo, sorted(sa[lo:hi])) # 2 [1, 3] — "ana" xuất hiện tại 1 và 3
Cách dựng bằng doubling ở trên là O(n log² n) vì nó sort lại ở mỗi vòng; thay sort bằng radix sort cho ra O(n log n), còn thuật toán SA-IS dựng trong O(n) và là thứ các thư viện thật dùng. Với một ghi chú như thế này, bản doubling là bản đáng để viết ra được từ trí nhớ.
Một LCP array đi kèm — lcp[i] = độ dài prefix chung dài nhất của sa[i] và sa[i−1] — được dựng trong O(n) bằng thuật toán Kasai và mở khóa hầu hết các truy vấn thú vị:
- Substring lặp lại dài nhất = giá trị lớn nhất trong LCP array.
- Số substring phân biệt =
n(n+1)/2 − sum(lcp). - Substring chung dài nhất của hai chuỗi = nối chúng bằng một ký tự phân cách không xuất hiện trong cả hai, dựng suffix array, rồi tìm LCP lớn nhất giữa hai suffix liền kề đến từ hai nửa khác nhau.
Suffix tree là trie nén của toàn bộ suffix của text (kèm một ký tự kết thúc để không suffix nào là prefix của suffix khác). Nó có đúng n leaf — mỗi suffix một leaf — và tối đa n−1 node nội bộ, nên tổng cộng O(n) node mặc dù biểu diễn lượng ký tự cỡ O(n²), vì các edge lưu cặp chỉ số (start, end) vào text gốc thay vì lưu bản sao.
suffix tree cho "banana$" (nhãn edge là substring của text)
(root)
/ | \ \
"a" "banana$" "na" "$"
/ \ / \
"na" \"$" "na$" "$"
/ \
"na$" "$"
Thuật toán Ukkonen dựng suffix tree trong O(n), và nó trả lời “p có xuất hiện không?” trong O(m) — tối ưu, và tốt hơn O(m log n) của suffix array. Trong thực tế, suffix array vẫn thắng gần như mọi lần:
| Suffix array (+ LCP) | Suffix tree | |
|---|---|---|
| Space | n số nguyên (~4–8 byte/ký tự) | 10–20× kích thước text, toàn pointer |
| Dựng | O(n) với SA-IS, bản O(n log² n) dễ viết | O(n) với Ukkonen, nổi tiếng khó cài đặt |
| Tìm substring | O(m log n), hoặc O(m) với tìm kiếm có LCP hỗ trợ | O(m) |
| Cache behaviour | Xuất sắc — một mảng số nguyên phẳng | Kém — chạy theo pointer |
| Dùng trong thực tế | Aligner tin sinh học, bzip2/BWT, FM-index | Chủ yếu trong sách giáo khoa và công cụ chuyên biệt |
Suffix array là lựa chọn thực dụng: cùng sức mạnh cho gần như mọi truy vấn, một phần nhỏ bộ nhớ, code đơn giản hơn hẳn, và hằng số nhân tốt hơn. Suffix array kết hợp với Burrows–Wheeler transform là nền tảng của FM-index, cách mà các genome aligner (Bowtie, BWA) tìm kiếm trên một reference 3 tỷ base dưới dạng nén.
Nên chọn cấu trúc range-query nào?
Đây là phần đúc kết thực dụng của cả bài. Chọn dựa trên hai câu hỏi: dữ liệu có thay đổi không? và phép toán có khả nghịch không?
| Bạn cần gì | Cấu trúc | Query | Update | Space | Ghi chú |
|---|---|---|---|---|---|
| Prefix/range sum, dữ liệu không bao giờ đổi | Mảng prefix sum | O(1) | O(n) dựng lại | n | Không gì đánh bại được. Hãy thử cái này trước. |
| Range min/max, dữ liệu không bao giờ đổi | Sparse table | O(1) | O(n log n) dựng lại | n log n | Chỉ với phép toán lũy đẳng (min, max, gcd) |
| Prefix sum + point update | Fenwick tree | O(log n) | O(log n) | n | Nhỏ nhất và nhanh nhất khi áp dụng được |
| Range sum + range add | Hai Fenwick | O(log n) | O(log n) | 2n | Một nửa bộ nhớ của lazy segment tree |
| Range query kết hợp bất kỳ + point update | Segment tree | O(log n) | O(log n) | 4n | min/max/gcd/merge tùy ý |
| Range query + range update | Lazy segment tree | O(log n) | O(log n) | 8n | Cây búa vạn năng; khó viết đúng nhất |
| Range query trên các phiên bản lịch sử | Persistent segment tree | O(log n) | O(log n) | O(n log n) | Path copying |
| Chèn/xóa phần tử, không chỉ sửa | Balanced BST / order-statistic tree | O(log n) | O(log n) | n | Xem ./11-balanced-and-multiway-trees.md |
| Truy vấn prefix trên chuỗi | Trie / radix trie | O(L) | O(L) | O(N)–O(N·Σ) | Autocomplete, routing, tra cứu IP |
| Tìm substring trong một text cố định | Suffix array + LCP | O(m log n) | dựng lại | n ints | Nhiều truy vấn trên cùng một text |
| Tìm substring, chỉ một truy vấn | KMP / Boyer–Moore | O(n + m) | — | O(m) | Đừng tiền xử lý cho một lần tìm |
”x có khả năng nằm trong set không?” | Bloom filter | O(1) | O(1) | bit | Xác suất; xem ./07-hash-tables.md |
Sai lầm phổ biến nhất là bỏ qua hai dòng đầu tiên. Một tỷ lệ rất lớn các bài “tôi cần segment tree” thực ra là tĩnh, và một mảng prefix sum giải quyết chúng trong ba dòng với truy vấn O(1).
Best Practices
- Đừng dựng trie chỉ để kiểm tra thành viên. Một
setchuỗi nhỏ hơn, nhanh hơn, và gọn một dòng. Hãy dựng trie khi bạn cần ngữ nghĩa prefix — autocomplete, longest-prefix-match routing, duyệt keyspace theo thứ tự — vì đó là điều duy nhất nó làm được mà không gì khác làm được. - Nén trie lại nếu nó ra production. Trie thường trên key dài chủ yếu là các chuỗi node một child. Radix trie có
O(n)node thay vìO(N)ký tự và là thứ mọi hệ thống thật (bảng định tuyến,etcd,raxcủa Redis) thực sự triển khai. - Chọn cách biểu diễn child của trie một cách có chủ đích. Mảng-Σ cho alphabet nhỏ và dày, hash map cho key thưa hoặc Unicode, bitmap cho code khắt khe về bộ nhớ. Chỉ một quyết định này đã chi phối toàn bộ dung lượng bộ nhớ của trie.
- Kiểm tra xem có lời giải tĩnh không trước khi dựng segment tree. Nếu mảng không bao giờ đổi, mảng prefix sum cho truy vấn
O(1), còn sparse table cho range min/maxO(1). Cả hai đều đơn giản và nhanh hơn bất cứ thứ gì logarit. - Xác nhận phép toán của bạn là monoid trước khi dùng segment tree. Bạn cần tính kết hợp và phần tử đơn vị. “Sum”, “min”, “gcd”, “max subarray sum” đều được. “Median của range” và “phần tử lớn thứ k” thì không, vì câu trả lời của hai child không hợp thành câu trả lời của parent.
- Ưu tiên Fenwick tree bất cứ khi nào phép toán khả nghịch. Một phần tư bộ nhớ, một phần ba lượng code, hằng số nhân tốt hơn nhiều. Chỉ chuyển sang segment tree khi bạn gặp
min/max/merge không khả nghịch hoặc cần range update với lazy propagation. - Làm đúng chỉ số của Fenwick một lần rồi thôi. Nó là 1-based.
prefix_sum(0)phải trả về0.range_sum(l, r) = prefix_sum(r) − prefix_sum(l−1). Lỗi lệch một đơn vị ở đây là bug BIT kinh điển. - Dựng Fenwick tree trong
O(n), không phảiO(n log n). Cách dựng tại chỗfrom_listở trên chỉ ba dòng và tốt hơn hẳn việc gọiaddnlần. - Khi viết lazy propagation, hãy định nghĩa phép hợp thành tường minh. Viết ra hai update đang chờ gộp với nhau như thế nào, và thứ tự có quan trọng không, trước khi viết code. Trộn “range assign” với “range add” mà không có quy tắc hợp thành rõ ràng là cách chắc chắn nhất để tạo ra một segment tree chỉ sai trên input lớn.
- Dùng suffix array, đừng dùng suffix tree, trừ khi bạn thực sự cần tìm kiếm
O(m)và đã đo được rằngO(m log n)là quá chậm. Cùng sức mạnh cho gần như mọi truy vấn, ít hơn một bậc độ lớn về bộ nhớ, và code bạn thực sự debug được. - Đừng tiền xử lý một text mà bạn chỉ tìm một lần. Dựng suffix array tốt nhất cũng là
O(n)với hằng số lớn; KMP tìm một pattern trongO(n + m)mà không cần tiền xử lý text chút nào. - Biết ngôn ngữ của bạn đã cho sẵn những gì. Python không có sẵn segment tree hay trie, nhưng
bisectbao quát việc tìm kiếm trên mảng đã sắp xếp,collections.Countervàdictbao quát hầu hết việc đếm tần suất, và với range query số học cỡ lớn thìnumpy.cumsumchính là mảng prefix sum với hằng số nhân của C. Chỉ dùng cấu trúc tự viết khi các thứ có sẵn thực sự không diễn đạt được truy vấn.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Trie — Wikipedia
- Radix tree (PATRICIA trie) — Wikipedia
- cp-algorithms — Segment Tree
- Segment tree — Wikipedia
- cp-algorithms — Fenwick Tree
- Fenwick tree — Wikipedia
- cp-algorithms — Suffix Array
- Suffix array — Wikipedia
- Suffix tree — Wikipedia
- Burrows–Wheeler transform — Wikipedia
- CLRS — Introduction to Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- VisuAlgo — Fenwick Tree
- Python Documentation —
bisect
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
The trees covered so far are all about ordering: a binary search tree keeps keys sorted so you can find one in O(log n), and a balanced tree (AVL, red-black, B-tree — see ./11-balanced-and-multiway-trees.md) keeps that guarantee under insertion. The structures in this note are different. They are trees built to answer questions that a sorted container simply cannot answer efficiently:
- Tries answer “which stored keys start with this prefix?” — a question a hash table cannot answer at all and a BST answers awkwardly. The key insight is that the path from the root spells the key, so no node needs to store the key itself.
- Segment trees answer “what is the sum/min/max/gcd over
a..b?” while the underlying array is still being modified — recursive halving turns a range intoO(log n)precomputed pieces. - Fenwick trees (Binary Indexed Trees) answer the prefix-sum version of that question with a quarter of the memory and about a third of the code, by exploiting the binary representation of the index.
- Suffix arrays and suffix trees answer “does this pattern occur in this text, and where?” for a text you will search many times — by preprocessing every suffix once.
The common theme is precomputation with a trade-off. Each structure spends memory and build time up front to convert an O(n) scan into an O(log n) or O(1) lookup. Choosing between them is mostly about which operations you need and, crucially, whether the data changes: a static array of prefix sums beats every structure here for read-only prefix queries, and knowing that saves you from building a segment tree you did not need.
Fundamentals
Tries (prefix trees)
A trie — from retrieval, usually pronounced “try” to distinguish it from “tree” — is a search tree for string keys where no node stores a key. Instead, a node’s position in the tree defines the string it represents: the concatenation of the edge labels from the root. All descendants of a node share that node’s string as a prefix, and the root represents the empty string.
Trie holding {"car", "cart", "cat", "do", "dog"}
(root)
/ \
c d
| |
a o*
/ \ |
r* t* g*
|
t*
* = a stored word ends here
"ca" is a valid prefix (the node exists) but not a stored word (no *)
That structure is what makes prefix queries trivial: to find every word starting with "ca", walk two edges and enumerate the subtree. A hash table cannot do this — hashing destroys the relationship between "car" and "cart" — and a sorted array can (binary search for the prefix range) but cannot then support cheap insertion.
class TrieNode:
__slots__ = ("children", "is_word")
def __init__(self):
self.children = {} # character -> TrieNode
self.is_word = False # True if a stored key ends exactly here
class Trie:
"""Prefix tree over strings. All operations are O(L) in the key length L,
independent of how many keys are stored."""
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
# setdefault creates the child only if this character is new
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
def _walk(self, prefix):
"""Return the node reached by spelling out prefix, or None if it dead-ends."""
node = self.root
for ch in prefix:
node = node.children.get(ch)
if node is None:
return None
return node
def search(self, word):
"""Is word a stored key? Reaching the node is not enough — it must be marked."""
node = self._walk(word)
return node is not None and node.is_word
def starts_with(self, prefix):
"""Is any stored key prefixed by this string?"""
return self._walk(prefix) is not None
def autocomplete(self, prefix, limit=10):
"""Up to `limit` stored keys with the given prefix, in lexicographic order."""
node = self._walk(prefix)
results = []
if node is None:
return results
stack = [(node, prefix)] # explicit stack instead of recursion
while stack and len(results) < limit:
cur, word = stack.pop()
if cur.is_word:
results.append(word)
# push in reverse so the smallest character is popped first
for ch in sorted(cur.children, reverse=True):
stack.append((cur.children[ch], word + ch))
return results
t = Trie()
for w in ["car", "cart", "cat", "do", "dog", "dodge"]:
t.insert(w)
print(t.search("car"), t.search("ca")) # True False
print(t.starts_with("ca")) # True
print(t.autocomplete("ca")) # ['car', 'cart', 'cat']
print(t.autocomplete("do")) # ['do', 'dodge', 'dog']
Deletion needs care: unmarking is_word is not enough, because the now-unused nodes leak memory and slow down traversals. You have to prune back up the path, stopping as soon as you hit a node that has other children or ends another word.
def delete(self, word):
"""Remove word if present, pruning nodes that become useless. O(L)."""
path = [self.root]
node = self.root
for ch in word:
node = node.children.get(ch)
if node is None:
return False # word was never stored
path.append(node)
if not node.is_word:
return False
node.is_word = False
for i in range(len(word) - 1, -1, -1): # walk back towards the root
child, parent = path[i + 1], path[i]
if child.children or child.is_word:
break # still needed by another key
del parent.children[word[i]]
return True
Trie.delete = delete
Complexity. Let L be the key length, n the number of keys, Σ the alphabet size, and N the total number of characters stored.
| Operation | Time | Note |
|---|---|---|
insert | O(L) | Independent of n — this is the trie’s headline property |
search | O(L) | Same |
starts_with | O(L) | Same |
delete | O(L) | Walk down, then prune back up |
autocomplete(p, k) | O(|p| + k·L) | Walk to the node, then enumerate k results |
| Space | O(N · Σ) worst | O(N) nodes; per-node cost depends on the child representation |
Note what is not in that table: n never appears. A trie’s lookup cost depends only on the length of the key you are looking up. A hash table also gives O(L) for string keys (you must hash the whole string), so the trie’s real advantage is not raw lookup speed — it is prefix enumeration, ordered iteration, and the absence of collisions or resizing.
The memory cost of a trie — the honest version
Tries are memory-hungry, and this is the single most common reason to reject one in production.
The child pointer representation is the whole story:
| Representation | Per-node cost | Lookup per char | When it fits |
|---|---|---|---|
Fixed array [None] * Σ | Σ pointers (208 B for lowercase ASCII on 64-bit) | O(1), one indexed load | Dense tries, small alphabet |
Hash map (dict) | ~64 B empty + entries | O(1) hashed | Sparse tries, Unicode; the Python default |
| Sorted list of pairs | O(children) | O(log Σ) binary search | Very sparse, memory-critical |
| Bitmap + packed array | Σ bits + used pointers | O(1) with popcount | Production tries (HAMT-style) |
In CPython, the TrieNode above costs roughly 56 bytes for the object plus at least 64 bytes for the dict, so a trie over 100k English words (about 900k characters, maybe 250k distinct nodes after prefix sharing) lands in the tens of megabytes — for data that fits in under 1 MB as a plain list of strings. If you only need membership testing, a set of strings is smaller, faster, and one line of code. Build a trie when prefix structure is the point, not as a general-purpose string container.
Compressed tries (radix tries / PATRICIA)
Most of that memory goes to chains of single-child nodes — the "dodg" in "dodge" is four nodes doing no branching. A compressed trie, also called a radix trie or PATRICIA trie, collapses each such chain into a single edge labelled with the whole substring:
plain trie for {"romane", "romanus", "rubens"} radix trie for the same
r r
| / \
o - m - a - n - e* "om" / \ "ubens"*
| \ / \
u - b - e - n - s* (and: n - u - s*) "an" o
/ \
"e"* "us"*
The number of nodes drops from O(N) (total characters) to O(n) (number of keys) — a radix trie with n keys has at most n leaves and n−1 internal branching nodes, regardless of how long the keys are. Lookup is still O(L) but with far fewer pointer dereferences and much better cache behaviour, at the cost of substring comparison logic and node-splitting on insert.
This is not an academic curiosity. Radix tries are what real systems use: the Linux kernel’s page cache and IP routing tables, etcd’s and Redis’s key storage (Redis’s rax), Go’s net/http routers, and Ethereum’s Merkle Patricia trie all use this shape. A related variant, the ternary search trie, stores three children per node (<, =, >) and is a good middle ground between an array-of-Σ trie and a BST.
Key Concepts
Segment trees
A segment tree answers range queries over a mutable array. Given data[0..n−1] and an associative operation (sum, min, max, gcd, “count of zeros”, almost anything), it supports:
query(l, r)— the operation folded overdata[l..r], inO(log n)update(i, v)— setdata[i] = v, inO(log n)
Neither is impressive alone. A plain array does update in O(1) and query in O(n); a prefix-sum array does query in O(1) and update in O(n). The segment tree’s contribution is making both logarithmic, which is what you need when reads and writes interleave.
The idea is recursive halving. The root covers [0, n−1]; each internal node splits its range in half and stores the operation applied to its two children; the leaves are the individual elements.
data = [1, 3, 5, 7, 9, 11] (sums shown in each node)
[0..5] 36
/ \
[0..2] 9 [3..5] 27
/ \ / \
[0..1] 4 [2..2] 5 [3..4] 16 [5..5] 11
/ \ / \
[0] 1 [1] 3 [3] 7 [4] 9
query(1, 3) = 3 + 5 + 7 = 15
→ [1..1] fully inside → 3
→ [2..2] fully inside → 5
→ [3..3] fully inside → 7
Any range [l, r] decomposes into at most 2 log n fully-covered nodes, because at each level the query boundary can only “cut” two nodes (one at each end). That is the entire complexity argument.
class SegmentTree:
"""Range-sum segment tree with point update over a fixed-size array.
Stored in a flat array: node 1 is the root; the children of node v are
2v and 2v+1. Size 4n is a safe upper bound for any n.
"""
def __init__(self, data):
self.n = len(data)
self.tree = [0] * (4 * self.n)
if self.n:
self._build(data, 1, 0, self.n - 1)
def _build(self, data, v, lo, hi):
if lo == hi: # leaf: holds one element
self.tree[v] = data[lo]
return
mid = (lo + hi) // 2
self._build(data, 2 * v, lo, mid)
self._build(data, 2 * v + 1, mid + 1, hi)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def update(self, idx, value):
"""data[idx] = value, in O(log n) — one root-to-leaf path is rewritten."""
self._update(1, 0, self.n - 1, idx, value)
def _update(self, v, lo, hi, idx, value):
if lo == hi:
self.tree[v] = value
return
mid = (lo + hi) // 2
if idx <= mid:
self._update(2 * v, lo, mid, idx, value)
else:
self._update(2 * v + 1, mid + 1, hi, idx, value)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def query(self, left, right):
"""Sum of data[left..right], inclusive, in O(log n)."""
return self._query(1, 0, self.n - 1, left, right)
def _query(self, v, lo, hi, left, right):
if right < lo or hi < left: # disjoint: contributes the identity
return 0
if left <= lo and hi <= right: # fully covered: use the stored value
return self.tree[v]
mid = (lo + hi) // 2 # partial: recurse into both halves
return (self._query(2 * v, lo, mid, left, right)
+ self._query(2 * v + 1, mid + 1, hi, left, right))
st = SegmentTree([1, 3, 5, 7, 9, 11])
print(st.query(1, 3)) # 15
st.update(1, 10)
print(st.query(1, 3)) # 22
To change the operation, change three things: the combine step (+), the identity returned for disjoint nodes (0 for sum, float('inf') for min, -inf for max, 0 for gcd), and the leaf initialization. Everything else is untouched — segment trees are generic over any monoid (an associative operation with an identity element). This is the property to check before reaching for one: “sum” works, “min” works, “the k-th largest in the range” does not, because you cannot combine two children’s answers into the parent’s.
Complexity: build O(n), query O(log n), point update O(log n), space O(4n). The 4n is a convenient over-allocation for the recursive layout; an iterative bottom-up implementation over a padded power-of-two array needs only 2n and is meaningfully faster, at the cost of being harder to extend with lazy propagation.
Lazy propagation
Point updates are the easy case. What if you need to add 5 to every element in data[l..r]? Doing it element by element is O(r − l), which throws away the whole point of the structure.
Lazy propagation fixes this: when an update fully covers a node’s range, apply it to that node’s aggregate and record a “pending” marker in a parallel lazy[] array — do not descend. Push the marker down to the children only when a later query or update actually needs to look inside that node. Each operation still touches O(log n) nodes.
class LazySegmentTree:
"""Range-add, range-sum segment tree. Both operations O(log n).
lazy[v] is an update already reflected in tree[v] but not yet in v's
children; it is pushed down only when we must descend past v.
"""
def __init__(self, data):
self.n = len(data)
self.tree = [0] * (4 * self.n)
self.lazy = [0] * (4 * self.n)
self._build(data, 1, 0, self.n - 1)
def _build(self, data, v, lo, hi):
if lo == hi:
self.tree[v] = data[lo]
return
mid = (lo + hi) // 2
self._build(data, 2 * v, lo, mid)
self._build(data, 2 * v + 1, mid + 1, hi)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def _apply(self, v, lo, hi, delta):
self.tree[v] += delta * (hi - lo + 1) # every element in the range grew by delta
self.lazy[v] += delta
def _push(self, v, lo, hi):
if self.lazy[v]:
mid = (lo + hi) // 2
self._apply(2 * v, lo, mid, self.lazy[v])
self._apply(2 * v + 1, mid + 1, hi, self.lazy[v])
self.lazy[v] = 0
def add(self, left, right, delta, v=1, lo=0, hi=None):
if hi is None:
hi = self.n - 1
if right < lo or hi < left:
return
if left <= lo and hi <= right: # fully covered: stop here, mark lazily
self._apply(v, lo, hi, delta)
return
self._push(v, lo, hi)
mid = (lo + hi) // 2
self.add(left, right, delta, 2 * v, lo, mid)
self.add(left, right, delta, 2 * v + 1, mid + 1, hi)
self.tree[v] = self.tree[2 * v] + self.tree[2 * v + 1]
def query(self, left, right, v=1, lo=0, hi=None):
if hi is None:
hi = self.n - 1
if right < lo or hi < left:
return 0
if left <= lo and hi <= right:
return self.tree[v]
self._push(v, lo, hi) # must descend, so settle the debt first
mid = (lo + hi) // 2
return (self.query(left, right, 2 * v, lo, mid)
+ self.query(left, right, 2 * v + 1, mid + 1, hi))
lst = LazySegmentTree([1, 3, 5, 7, 9, 11])
lst.add(1, 4, 10) # add 10 to data[1..4]
print(lst.query(0, 5)) # 36 + 40 = 76
print(lst.query(2, 3)) # (5 + 10) + (7 + 10) = 32
Lazy propagation is where segment trees get genuinely tricky. Two rules save most of the pain: the lazy value must compose (two pending “add” updates combine into one; two pending “assign” updates keep the later one — and mixing add and assign requires an explicit composition rule and careful ordering), and _apply must know the range length for aggregate operations like sum. Get either wrong and the bugs are subtle and data-dependent.
Segment trees also generalize beyond arrays: persistent segment trees keep every historical version by path copying, merge-sort trees store a sorted list at each node for range-rank queries, and segment tree beats handles range-min-assignment. Those are worth knowing exist, not worth memorizing.
Fenwick trees / Binary Indexed Trees
A Fenwick tree (Peter Fenwick, 1994), or Binary Indexed Tree (BIT), does the most common segment-tree job — prefix sums with point updates — in a quarter of the memory and about fifteen lines of code. It works by reading the binary representation of the index.
The structure: use 1-based indexing, and let tree[i] store the sum of the i & -i elements ending at position i. The expression i & -i isolates the lowest set bit of i, because in two’s complement -i is ~i + 1, which flips every bit above the lowest set bit and leaves that bit intact.
i binary i & -i tree[i] covers
1 0001 1 data[1..1]
2 0010 2 data[1..2]
3 0011 1 data[3..3]
4 0100 4 data[1..4]
5 0101 1 data[5..5]
6 0110 2 data[5..6]
7 0111 1 data[7..7]
8 1000 8 data[1..8]
prefix_sum(7) = tree[7] + tree[6] + tree[4]
= data[7] + data[5..6] + data[1..4]
index walk: 7 (0111) → 6 (0110) → 4 (0100) → 0 — strip the lowest set bit
one node per set bit in 7, so at most log2(n) steps
add(5, delta) touches tree[5], tree[6], tree[8]
index walk: 5 (0101) → 6 (0110) → 8 (1000) → past n — add the lowest set bit
Prefix sum strips bits (i -= i & -i); update adds them (i += i & -i). Each walk visits at most log₂ n indices, and the two walks are exact mirror images — which is the trick that makes the whole thing fit in fifteen lines.
class FenwickTree:
"""Binary Indexed Tree over positions 1..n.
Point update and prefix sum, both O(log n). Space: n+1 integers.
"""
def __init__(self, n):
self.n = n
self.tree = [0] * (n + 1) # index 0 is unused; the structure is 1-based
def add(self, i, delta):
"""data[i] += delta, for 1 <= i <= n."""
while i <= self.n:
self.tree[i] += delta
i += i & -i # jump to the next node whose range covers i
def prefix_sum(self, i):
"""Sum of data[1..i]. prefix_sum(0) == 0."""
total = 0
while i > 0:
total += self.tree[i]
i -= i & -i # strip the lowest set bit
return total
def range_sum(self, left, right):
"""Sum of data[left..right], inclusive — subtraction is why sums work here."""
return self.prefix_sum(right) - self.prefix_sum(left - 1)
@classmethod
def from_list(cls, data):
"""Build in O(n) instead of n separate O(log n) add() calls."""
bit = cls(len(data))
bit.tree[1:] = list(data)
for i in range(1, bit.n + 1):
parent = i + (i & -i)
if parent <= bit.n:
bit.tree[parent] += bit.tree[i]
return bit
bit = FenwickTree.from_list([1, 3, 5, 7, 9, 11])
print(bit.prefix_sum(4)) # 16
print(bit.range_sum(2, 4)) # 15
bit.add(2, 10) # data[2] += 10
print(bit.range_sum(2, 4)) # 25
Fenwick versus segment tree. The Fenwick tree is strictly less general and, when it applies, strictly better:
| Fenwick tree | Segment tree | |
|---|---|---|
| Space | n + 1 integers | 4n (recursive) or 2n (iterative) |
| Code size | ~15 lines | ~50 lines, more with lazy |
| Constant factor | Very low — tight loop, no recursion, sequential-ish memory | Higher — recursion, branching, pointer-free but scattered |
| Operations supported | Invertible only: sum, xor, count | Any associative monoid: min, max, gcd, custom merges |
| Range update | Possible (two-BIT trick) but fiddly | Natural, with lazy propagation |
| Query a suffix / arbitrary range | Via subtraction of prefixes | Directly |
The critical limitation is in bold: range_sum(l, r) = prefix_sum(r) − prefix_sum(l−1) requires an inverse. There is no inverse for min, so you cannot build a range-minimum Fenwick tree the same way. That, and nothing else, is what forces you to a segment tree.
Two extensions worth knowing:
- Range update, point query — keep a BIT of differences.
add(l, r, delta)becomesbit.add(l, delta); bit.add(r + 1, -delta), andpoint_query(i)isbit.prefix_sum(i). - Range update, range sum — two BITs, using the identity that a difference array’s prefix-of-prefix reconstructs the running total. Still
O(log n)with half the memory of a lazy segment tree. - Find the smallest
iwithprefix_sum(i) ≥ k— a binary lift over the BIT in a singleO(log n)descent (notO(log² n)), which makes a Fenwick tree over value-frequencies an order-statistic structure.
Fenwick trees are also how you count inversions in O(n log n): sweep left to right, and for each element count how many larger values you have already inserted.
Suffix arrays and suffix trees
The last family answers substring queries on a text you will search repeatedly. Searching a text of length n for a pattern of length m once is best done with KMP or Boyer–Moore in O(n + m) — do not build a suffix structure for a single search. But if the text is fixed and the queries keep coming, preprocessing every suffix pays off immediately.
A suffix array is simply the sorted order of all n suffixes of the text, stored as their starting indices:
s = "banana"
suffix index sorted suffix array
"banana" 0 "a" (5) sa = [5, 3, 1, 0, 4, 2]
"anana" 1 "ana" (3)
"nana" 2 "anana" (1) lcp = [0, 1, 3, 0, 0, 2]
"ana" 3 "banana" (0) (LCP with the previous suffix)
"na" 4 "na" (4)
"a" 5 "nana" (2)
Because the suffixes are sorted, every occurrence of a pattern p appears as a contiguous block in the suffix array — all suffixes starting with p sort together. So substring search is two binary searches.
def build_suffix_array(s):
"""Suffix array of s by prefix doubling: O(n log^2 n).
Invariant: after the iteration with step k, `rank` ranks every suffix by
its first 2k characters. Doubling k until it exceeds n ranks them fully.
"""
n = len(s)
sa = list(range(n))
rank = [ord(c) for c in s] # rank by the first character
k = 1
while k < n:
# sort by (rank of first k chars, rank of the next k chars)
key = lambda i: (rank[i], rank[i + k] if i + k < n else -1)
sa.sort(key=key) # O(n log n) per round, log n rounds
new_rank = [0] * n
for i in range(1, n):
new_rank[sa[i]] = new_rank[sa[i - 1]] + (key(sa[i]) > key(sa[i - 1]))
rank = new_rank
k *= 2
return sa
def find_range(s, sa, pattern):
"""(lo, hi) half-open range of suffix-array slots whose suffix starts with
pattern. O(|pattern| * log |s|): log n comparisons, each O(|pattern|)."""
m = len(pattern)
lo, hi = 0, len(sa)
while lo < hi: # lower bound
mid = (lo + hi) // 2
if s[sa[mid]:sa[mid] + m] < pattern:
lo = mid + 1
else:
hi = mid
start, hi = lo, len(sa)
while lo < hi: # upper bound
mid = (lo + hi) // 2
if s[sa[mid]:sa[mid] + m] <= pattern:
lo = mid + 1
else:
hi = mid
return start, lo
s = "banana"
sa = build_suffix_array(s)
print(sa) # [5, 3, 1, 0, 4, 2]
lo, hi = find_range(s, sa, "ana")
print(hi - lo, sorted(sa[lo:hi])) # 2 [1, 3] — "ana" occurs at 1 and 3
The doubling construction above is O(n log² n) because it re-sorts each round; replacing the sort with a radix sort gives O(n log n), and the SA-IS algorithm builds it in O(n) and is what real libraries use. For a note like this, the doubling version is the one worth being able to write from memory.
A companion LCP array — lcp[i] = length of the longest common prefix of sa[i] and sa[i−1] — is built in O(n) by Kasai’s algorithm and unlocks most of the interesting queries:
- Longest repeated substring = the largest value in the LCP array.
- Number of distinct substrings =
n(n+1)/2 − sum(lcp). - Longest common substring of two strings = concatenate them with a separator that appears in neither, build the suffix array, and find the largest LCP between adjacent suffixes coming from different halves.
A suffix tree is the compressed trie of all suffixes of the text (with a terminator appended so no suffix is a prefix of another). It has exactly n leaves — one per suffix — and at most n−1 internal nodes, so O(n) nodes total despite representing O(n²) characters worth of suffixes, because edges store (start, end) index pairs into the original text rather than copies.
suffix tree for "banana$" (edge labels are substrings of the text)
(root)
/ | \ \
"a" "banana$" "na" "$"
/ \ / \
"na" \"$" "na$" "$"
/ \
"na$" "$"
Ukkonen’s algorithm builds a suffix tree in O(n), and it answers “does p occur?” in O(m) — optimal, and better than the suffix array’s O(m log n). In practice, suffix arrays still win almost every time:
| Suffix array (+ LCP) | Suffix tree | |
|---|---|---|
| Space | n integers (~4–8 bytes/char) | 10–20× the text size in pointers |
| Construction | O(n) with SA-IS, easy O(n log² n) version | O(n) with Ukkonen, notoriously hard to implement |
| Substring search | O(m log n), or O(m) with an LCP-augmented search | O(m) |
| Cache behaviour | Excellent — a flat integer array | Poor — pointer chasing |
| Real-world use | Bioinformatics aligners, bzip2/BWT, FM-index | Mostly textbooks and specialized tooling |
The suffix array is the pragmatic choice: same power for nearly all queries, a fraction of the memory, dramatically simpler code, and better constants. Suffix arrays plus the Burrows–Wheeler transform are the basis of the FM-index, which is how genome aligners (Bowtie, BWA) search a 3-gigabase reference in compressed form.
Which range-query structure should I use?
This is the practical payoff of the whole note. Pick by two questions: does the data change? and is the operation invertible?
| What you need | Structure | Query | Update | Space | Notes |
|---|---|---|---|---|---|
| Prefix/range sums, data never changes | Prefix-sum array | O(1) | O(n) rebuild | n | Nothing beats this. Try it first. |
| Range min/max, data never changes | Sparse table | O(1) | O(n log n) rebuild | n log n | Idempotent operations only (min, max, gcd) |
| Prefix sums + point update | Fenwick tree | O(log n) | O(log n) | n | Smallest and fastest when it applies |
| Range sum + range add | Two Fenwicks | O(log n) | O(log n) | 2n | Half the memory of a lazy segment tree |
| Any associative range query + point update | Segment tree | O(log n) | O(log n) | 4n | min/max/gcd/custom merge |
| Range query + range update | Lazy segment tree | O(log n) | O(log n) | 8n | The general hammer; hardest to get right |
| Range query over historical versions | Persistent segment tree | O(log n) | O(log n) | O(n log n) | Path copying |
| Insert/delete elements, not just modify | Balanced BST / order-statistic tree | O(log n) | O(log n) | n | See ./11-balanced-and-multiway-trees.md |
| Prefix queries over strings | Trie / radix trie | O(L) | O(L) | O(N)–O(N·Σ) | Autocomplete, routing, IP lookup |
| Substring search in a fixed text | Suffix array + LCP | O(m log n) | rebuild | n ints | Many queries against one text |
| Substring search, one query only | KMP / Boyer–Moore | O(n + m) | — | O(m) | Do not preprocess for a single search |
”Is x probably in the set?” | Bloom filter | O(1) | O(1) | bits | Probabilistic; see ./07-hash-tables.md |
The most common mistake is skipping the first two rows. An enormous fraction of “I need a segment tree” problems are static, and a prefix-sum array solves them in three lines with O(1) queries.
Best Practices
- Do not build a trie for membership testing. A
setof strings is smaller, faster, and one line. Build a trie when you need prefix semantics — autocomplete, longest-prefix-match routing, ordered iteration over a keyspace — because that is the only thing it does that nothing else does. - Compress your trie if it goes to production. A plain trie over long keys is mostly single-child chains. A radix trie has
O(n)nodes instead ofO(N)characters and is what every real system (routing tables,etcd, Redisrax) actually ships. - Choose the trie’s child representation deliberately. Array-of-Σ for a small dense alphabet, a hash map for sparse or Unicode keys, a bitmap for memory-critical code. This one decision dominates the trie’s memory footprint.
- Check for a static solution before building a segment tree. If the array never changes, a prefix-sum array gives
O(1)queries, and a sparse table givesO(1)range min/max. Both are simpler and faster than anything logarithmic. - Verify your operation is a monoid before using a segment tree. You need associativity and an identity. “Sum”, “min”, “gcd”, “max subarray sum” all work. “Median of the range” and “k-th largest” do not, because two children’s answers do not compose into the parent’s.
- Prefer a Fenwick tree whenever the operation is invertible. Quarter the memory, a third of the code, a much better constant factor. Move to a segment tree only when you hit
min/max/non-invertible merges or need range updates with lazy propagation. - Get the Fenwick indexing right once and never touch it again. It is 1-based.
prefix_sum(0)must return0.range_sum(l, r) = prefix_sum(r) − prefix_sum(l−1). Off-by-one errors here are the standard BIT bug. - Build a Fenwick tree in
O(n), notO(n log n). The in-placefrom_listconstruction above is three lines and strictly better than callingaddntimes. - When writing lazy propagation, define composition explicitly. Write down how two pending updates combine, and whether order matters, before writing code. Mixing “range assign” with “range add” without a stated composition rule is the single most reliable way to produce a segment tree that is wrong only on large inputs.
- Use a suffix array, not a suffix tree, unless you specifically need
O(m)search and have measured thatO(m log n)is too slow. Same power for almost every query, an order of magnitude less memory, and code you can actually debug. - Do not preprocess a text you will search once. Building a suffix array is
O(n)at best with a large constant; KMP finds a single pattern inO(n + m)with no preprocessing of the text at all. - Know what your language already gives you. Python has no built-in segment tree or trie, but
bisectcovers sorted-array searches,collections.Counteranddictcover most frequency work, and for large numeric range queriesnumpy.cumsumis a prefix-sum array with a C constant factor. Reach for the custom structure only when the built-ins genuinely cannot express the query.
References
- roadmap.sh — Data Structures & Algorithms
- Trie — Wikipedia
- Radix tree (PATRICIA trie) — Wikipedia
- cp-algorithms — Segment Tree
- Segment tree — Wikipedia
- cp-algorithms — Fenwick Tree
- Fenwick tree — Wikipedia
- cp-algorithms — Suffix Array
- Suffix array — Wikipedia
- Suffix tree — Wikipedia
- Burrows–Wheeler transform — Wikipedia
- CLRS — Introduction to Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- VisuAlgo — Fenwick Tree
- Python Documentation —
bisect