← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202632 phút đọc28 min read

Cấu trúc cây nâng caoAdvanced Tree Structures

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Trie (prefix tree)
  4. Chi phí bộ nhớ của trie — nói thẳng
  5. Trie nén (radix trie / PATRICIA)
  6. Khái niệm chính
  7. Segment tree
  8. Lazy propagation
  9. Fenwick tree / Binary Indexed Tree
  10. Suffix array và suffix tree
  11. Nên chọn cấu trúc range-query nào?
  12. Best Practices
  13. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Tries (prefix trees)
  4. The memory cost of a trie — the honest version
  5. Compressed tries (radix tries / PATRICIA)
  6. Key Concepts
  7. Segment trees
  8. Lazy propagation
  9. Fenwick trees / Binary Indexed Trees
  10. Suffix arrays and suffix trees
  11. Which range-query structure should I use?
  12. Best Practices
  13. References

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:

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""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ánThời gianGhi chú
insertO(L)Không phụ thuộc n — đây là tính chất nổi bật nhất của trie
searchO(L)Tương tự
starts_withO(L)Tương tự
deleteO(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ả
SpaceO(N · Σ) worstO(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ễnChi phí mỗi nodeTra 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 entryO(1) qua hashTrie thưa, Unicode; mặc định trong Python
List cặp đã sắp xếpO(số children)O(log Σ) binary searchRất thưa, cực kỳ tiết kiệm bộ nhớ
Bitmap + mảng nénΣ bit + các pointer đang dùngO(1) với popcountTrie 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ợ:

Riêng lẻ thì không có gì ấn tượng. Một mảng thường làm update trong O(1)query trong O(n); một mảng prefix sum làm query trong O(1)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 treeSegment tree
Spacen + 1 số nguyên4n (đệ 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ânRấ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, countMọi monoid kết hợp: min, max, gcd, merge tùy ý
Range updateLà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 prefixTrự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:

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]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ị:

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
Spacen số nguyên (~4–8 byte/ký tự)10–20× kích thước text, toàn pointer
DựngO(n) với SA-IS, bản O(n log² n) dễ viếtO(n) với Ukkonen, nổi tiếng khó cài đặt
Tìm substringO(m log n), hoặc O(m) với tìm kiếm có LCP hỗ trợO(m)
Cache behaviourXuất sắc — một mảng số nguyên phẳngKém — chạy theo pointer
Dùng trong thực tếAligner tin sinh học, bzip2/BWT, FM-indexChủ 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?phép toán có khả nghịch không?

Bạn cần gìCấu trúcQueryUpdateSpaceGhi chú
Prefix/range sum, dữ liệu không bao giờ đổiMảng prefix sumO(1)O(n) dựng lạinKhô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ờ đổiSparse tableO(1)O(n log n) dựng lạin log nChỉ với phép toán lũy đẳng (min, max, gcd)
Prefix sum + point updateFenwick treeO(log n)O(log n)nNhỏ nhất và nhanh nhất khi áp dụng được
Range sum + range addHai FenwickO(log n)O(log n)2nMột nửa bộ nhớ của lazy segment tree
Range query kết hợp bất kỳ + point updateSegment treeO(log n)O(log n)4nmin/max/gcd/merge tùy ý
Range query + range updateLazy segment treeO(log n)O(log n)8nCâ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 treeO(log n)O(log n)O(n log n)Path copying
Chèn/xóa phần tử, không chỉ sửaBalanced BST / order-statistic treeO(log n)O(log n)nXem ./11-balanced-and-multiway-trees.md
Truy vấn prefix trên chuỗiTrie / radix trieO(L)O(L)O(N)O(N·Σ)Autocomplete, routing, tra cứu IP
Tìm substring trong một text cố địnhSuffix array + LCPO(m log n)dựng lạin intsNhiều truy vấn trên cùng một text
Tìm substring, chỉ một truy vấnKMP / Boyer–MooreO(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 filterO(1)O(1)bitXá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

Tài liệu tham khảo

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:

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.

OperationTimeNote
insertO(L)Independent of n — this is the trie’s headline property
searchO(L)Same
starts_withO(L)Same
deleteO(L)Walk down, then prune back up
autocomplete(p, k)O(|p| + k·L)Walk to the node, then enumerate k results
SpaceO(N · Σ) worstO(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:

RepresentationPer-node costLookup per charWhen it fits
Fixed array [None] * ΣΣ pointers (208 B for lowercase ASCII on 64-bit)O(1), one indexed loadDense tries, small alphabet
Hash map (dict)~64 B empty + entriesO(1) hashedSparse tries, Unicode; the Python default
Sorted list of pairsO(children)O(log Σ) binary searchVery sparse, memory-critical
Bitmap + packed arrayΣ bits + used pointersO(1) with popcountProduction 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:

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 treeSegment tree
Spacen + 1 integers4n (recursive) or 2n (iterative)
Code size~15 lines~50 lines, more with lazy
Constant factorVery low — tight loop, no recursion, sequential-ish memoryHigher — recursion, branching, pointer-free but scattered
Operations supportedInvertible only: sum, xor, countAny associative monoid: min, max, gcd, custom merges
Range updatePossible (two-BIT trick) but fiddlyNatural, with lazy propagation
Query a suffix / arbitrary rangeVia subtraction of prefixesDirectly

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:

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 arraylcp[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:

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
Spacen integers (~4–8 bytes/char)10–20× the text size in pointers
ConstructionO(n) with SA-IS, easy O(n log² n) versionO(n) with Ukkonen, notoriously hard to implement
Substring searchO(m log n), or O(m) with an LCP-augmented searchO(m)
Cache behaviourExcellent — a flat integer arrayPoor — pointer chasing
Real-world useBioinformatics aligners, bzip2/BWT, FM-indexMostly 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 needStructureQueryUpdateSpaceNotes
Prefix/range sums, data never changesPrefix-sum arrayO(1)O(n) rebuildnNothing beats this. Try it first.
Range min/max, data never changesSparse tableO(1)O(n log n) rebuildn log nIdempotent operations only (min, max, gcd)
Prefix sums + point updateFenwick treeO(log n)O(log n)nSmallest and fastest when it applies
Range sum + range addTwo FenwicksO(log n)O(log n)2nHalf the memory of a lazy segment tree
Any associative range query + point updateSegment treeO(log n)O(log n)4nmin/max/gcd/custom merge
Range query + range updateLazy segment treeO(log n)O(log n)8nThe general hammer; hardest to get right
Range query over historical versionsPersistent segment treeO(log n)O(log n)O(n log n)Path copying
Insert/delete elements, not just modifyBalanced BST / order-statistic treeO(log n)O(log n)nSee ./11-balanced-and-multiway-trees.md
Prefix queries over stringsTrie / radix trieO(L)O(L)O(N)O(N·Σ)Autocomplete, routing, IP lookup
Substring search in a fixed textSuffix array + LCPO(m log n)rebuildn intsMany queries against one text
Substring search, one query onlyKMP / Boyer–MooreO(n + m)O(m)Do not preprocess for a single search
”Is x probably in the set?”Bloom filterO(1)O(1)bitsProbabilistic; 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

References