Thuật toán Cốt lõi — Tìm kiếm, Sắp xếp & Đồ thị/Trie — Autocomplete, IP routing, và prefix-friendly dictionary
8/36
Bài 8 / 36~22 phútTìm kiếm nhanh — Hashing & TreeMiễn phí lượt xem

Trie — Autocomplete, IP routing, và prefix-friendly dictionary

Trie biến prefix query thành O(L) độc lập với số từ — HashMap phải scan O(n×L). Bài dạy trie, Patricia/Radix tree và ứng dụng từ autocomplete đến IP routing.

TL;DR: Trie (đọc như "try") là cây mà mỗi node đại diện cho một ký tự — path từ root đến một node là một prefix. Tìm prefix = đi theo path O(L) hoàn toàn độc lập với n (số từ đã lưu), trong khi HashMap phải scan toàn bộ O(n×L). Hai pitfall lớn nhất: dùng mảng [26] khi alphabet có dấu (Unicode), và quên kiểm tra isEnd sau walk nên nhầm "node trung gian" với "từ hợp lệ". Patricia/Radix tree nén single-child chain thành edge label — dùng trong Linux IP routing, Git, Ethereum.

Bạn cần build autocomplete cho 1 triệu tên sản phẩm. Người dùng gõ "ca", hệ thống phải trả về mọi từ bắt đầu bằng "ca" trong vòng vài millisecond.

Với HashMap: không thể tìm prefix — bạn phải scan toàn bộ 1 triệu key, kiểm tra từng cái xem có bắt đầu bằng "ca" không — O(n × L), tệ. Với TreeMap (Red-Black tree): có thể dùng subMap("ca", "cb") để lấy prefix range — nhưng mỗi lần so sánh key vẫn phải compare full string, và kết quả vẫn phải traverse nhiều node trên tree.

Câu trả lời là Trie (đọc như "try") — cây mà mỗi node đại diện cho một ký tự, và mỗi path từ root đến leaf là một từ hoàn chỉnh. Tìm prefix = đi theo path ký tự — O(L) với L là độ dài prefix, hoàn toàn độc lập với n (số từ đã lưu).

Bài này dạy trie cơ bản, variant nén (Patricia/Radix tree), các use case thực tế (autocomplete, IP routing, dictionary), và trade-off memory vs lookup speed.

1. Analogy — Bản đồ chữ

Hình dung một mê cung với các ngã rẽ được dán nhãn chữ cái. Từ điểm xuất phát (root), bạn đi theo từng ký tự của từ cần tìm: muốn tìm "car", rẽ theo "c" → "a" → "r". Đến nơi, bạn đã tìm thấy "car".

Điều đặc biệt: tất cả từ bắt đầu bằng "ca" đều đi qua cùng một ngã rẽ "c" rồi "a". Muốn liệt kê tất cả từ bắt đầu bằng "ca"? Đi đến giao lộ "ca", rồi khám phá tất cả nhánh con từ đó: rẽ "r" ra "car", rẽ "t" ra "cat", rẽ "n" ra "can". Không cần quét toàn bộ dictionary.

Bản đồ chữTrie
Điểm xuất phátRoot node
Ngã rẽ dán nhãn "c", "a", "r"Node với character key
Đường đi từ root đến đíchPath = một từ
Giao lộ chung "ca"Prefix node được chia sẻ
Khám phá nhánh từ giao lộ "ca"DFS subtree để liệt kê prefix matches
Dấu hiệu "điểm dừng — từ kết thúc ở đây"isEnd = true trên node
💡 Cách nhớ

Trie tối ưu cho prefix query vì các từ cùng prefix chia sẻ chung đường đi. Càng nhiều từ có chung prefix, tiết kiệm bộ nhớ càng nhiều — ngược với HashMap lưu mỗi key độc lập.

2. Cấu trúc trie cơ bản

Mỗi node trong trie cần hai thứ: map từ ký tự sang node con, và flag đánh dấu node này có phải điểm kết thúc từ không.

Cấu trúc node:
  TrieNode:
    children: Map<ký_tự, TrieNode>   -- map từ ký tự sang node con
    isEnd: boolean                   -- true nếu node này kết thúc một từ hợp lệ

  -- Khởi tạo: children rỗng, isEnd = false

Trade-off Map<ký_tự, Node> vs mảng [26]:

Cách lưu childrenLookupMemoryPhù hợp khi
Mảng [26]O(1), rất nhanh26 pointer mỗi node, lãng phí nếu sparseChỉ a-z, trie dày đặc
HashMap<ký_tự, Node>O(1) amortizedChỉ cấp phát khi có child thực sựAlphabet rộng, trie thưa
TreeMap<ký_tự, Node>O(log k), k = số childrenNhư HashMap nhưng orderedCần duyệt children theo thứ tự

Với Unicode (65k+ character), mảng [65536] sẽ tốn khoảng 256KB mỗi node — hoàn toàn không khả thi. Dùng HashMap cho alphabet rộng.

3. Insert, Search, và startsWith

-- Insert từ vào trie
insert(root, word):
    node <- root
    for each ký_tự c trong word:
        if c không trong node.children:
            node.children[c] <- TrieNode mới
        node <- node.children[c]
    node.isEnd <- true              -- đánh dấu cuối từ

Time: O(L)  Space: O(L) cho các node mới trên path
-- Tìm kiếm chính xác từ trong trie
search(root, word):
    node <- walk(root, word)
    -- phải đến được node VÀ node đó phải là điểm kết thúc từ
    return node != null và node.isEnd = true

Time: O(L)  Space: O(1)
-- Kiểm tra prefix có tồn tại không
startsWith(root, prefix):
    -- chỉ cần đến được node prefix -- không cần kiểm tra isEnd
    return walk(root, prefix) != null

Time: O(L)  Space: O(1)
-- Đi xuống trie theo từng ký tự của s
-- Trả về node đến được, hoặc null nếu path không tồn tại
walk(root, s):
    node <- root
    for each ký_tự c trong s:
        if c không trong node.children:
            return null
        node <- node.children[c]
    return node

Sơ đồ trie sau khi insert ["car", "cart", "cat", "can", "dog"]:

graph TD
    ROOT(("root"))
    C(("c"))
    D(("d"))
    CA(("a"))
    DO(("o"))
    CAR["r ✓"]
    CAT["t ✓"]
    CAN["n ✓"]
    CART["t ✓"]
    DOG["g ✓"]
    ROOT --> C
    ROOT --> D
    C --> CA
    D --> DO
    CA --> CAR
    CA --> CAT
    CA --> CAN
    CAR --> CART
    DO --> DOG

Node "c" và "a" được chia sẻ bởi "car", "cart", "cat", "can". Đây chính là lợi thế memory của trie — prefix chung không bị lưu nhiều lần.

4. Độ phức tạp

Thao tácTimeSpace
InsertO(L) — L là độ dài từO(L) cho các node mới trên path
SearchO(L)
startsWithO(L)
DeleteO(L) — set isEnd = false, cleanup tùy chọn
Liệt kê tất cả từ có prefixO(L + k) — k là số kết quả

Điểm quan trọng nhất: time complexity hoàn toàn độc lập với n (số từ đã insert). Dù trie có 10 hay 10 triệu từ, search "cart" luôn chỉ đi qua đúng 4 node. Đây là điểm mà HashMap và TreeMap không sánh được cho prefix query.

Memory cost: worst case O(N × L) với N là số từ và L là độ dài trung bình — khi không có prefix nào chung. Với từ điển thực tế (nhiều prefix chung), memory tiết kiệm hơn đáng kể. Trường hợp nhiều từ ngắn distinct là tệ nhất cho trie.

5. Memory optimization — Patricia / Radix tree

Trie cơ bản có điểm yếu: chuỗi node đơn con (single-child chain) lãng phí. Hãy xem trie của ["interest", "interesting", "internal"]:

root
└── i
    └── n
        └── t
            └── e
                └── r               <- chain không có nhánh: i-n-t-e-r
                    ├── e
                    │   └── s
                    │       └── t [isEnd]  ("interest")
                    │           └── i
                    │               └── n
                    │                   └── g [isEnd]  ("interesting")
                    └── n
                        └── a
                            └── l [isEnd]  ("internal")

Chain i→n→t→e→r không có nhánh nào — 5 node chỉ để lưu prefix "inter". Patricia tree (còn gọi là Radix tree) nén chain này thành một edge duy nhất có label "inter":

root
└── "inter"
    ├── "est" [isEnd]   ("interest")
    │   └── "ing" [isEnd]  ("interesting")
    └── "nal" [isEnd]   ("internal")

Thay vì 14 node, chỉ cần 5 node + edge labels. Lookup vẫn O(L) nhưng phức tạp hơn: khi đi theo một edge, phải match prefix của search string với edge label, xử lý trường hợp match một phần (partial match) để biết rẽ tiếp hay dừng.

Patricia/Radix tree được dùng bởi:

  • Linux kernel IP routing table — longest prefix match cho IPv4/IPv6
  • Git pack-refs index
  • Ethereum state trie (Patricia Merkle tree)
  • Nhiều router firmware cho routing table

6. Bài kinh điển — Top-K Autocomplete

Khi build search box, không chỉ cần prefix match mà còn cần trả về K từ phổ biến nhất theo prefix.

Approach 1 — Pre-computed top-K per node:

Mỗi node lưu sẵn list K từ phổ biến nhất trong subtree của nó. Khi insert từ với frequency, cập nhật list top-K từ root xuống đến node cuối cùng.

AutocompleteNode:
    children: Map<ký_tự, AutocompleteNode>
    isEnd: boolean
    topK: List<String>   -- K từ phổ biến nhất có thể reach từ node này
  • Lookup: O(L) — walk xuống prefix node, trả về node.topK ngay lập tức.
  • Insert: O(L × K) — cập nhật top-K tại mỗi node trên path (L node, mỗi node sort K element).
  • Trade-off: query nhanh, nhưng insert chậm hơn và tốn memory (mỗi node lưu K chuỗi).

Approach 2 — Lazy DFS + heap:

Chỉ lưu frequency tại node lá (isEnd = true). Khi query, DFS toàn bộ subtree từ prefix node, dùng min-heap size K để track top-K.

topKWithPrefix(root, prefix, k):
    node <- walk(root, prefix)
    if node = null: return []

    heap <- MinHeap(capacity=k, key=frequency)  -- loại bỏ freq thấp nhất khi đầy
    dfsCollect(node, heap, k)
    return heap.toList()

Time: O(L + matches × log K)  Space: O(k)
  • Lookup: O(L + matches × log K) — DFS qua subtree.
  • Insert: O(L) — đơn giản hơn approach 1.
  • Trade-off: query chậm hơn khi subtree lớn, nhưng insert và memory nhẹ hơn.

Chọn gì? Approach 1 phù hợp khi query frequency cao và vocabulary ổn định (search engine autocomplete). Approach 2 phù hợp khi vocabulary thay đổi thường xuyên hoặc K lớn.

7. Pitfall tổng hợp

Pitfall 1 — Unicode alphabet và mảng cố định kích thước

Dùng mảng 26 phần tử chỉ an toàn cho input ASCII a-z. Với Unicode (tiếng Việt, tiếng Nhật, emoji), có thể tới 65.536 code point trong BMP:

-- SAI: nổ khi gặp ký tự ngoài a-z (Latin có dấu, CJK, emoji)
TrieNode:
    children: mảng[26]    -- chỉ dành cho a-z!
    isEnd: boolean

-- ĐÚNG: xử lý được mọi ký tự Unicode
TrieNode:
    children: HashMap<ký_tự, TrieNode>
    isEnd: boolean

Mảng 65.536 phần tử mỗi node = 65.536 pointer × 8 byte = 512KB mỗi node. Với trie 10.000 node, đó là 5GB chỉ cho children array — hoàn toàn không dùng được.

Pitfall 2 — Quên kiểm tra isEnd

Trie chứa ["car", "card"]: khi search "car", walk("car") trả về node "r" không null — nhưng không có nghĩa "car" là một từ hợp lệ nếu không check isEnd.

-- SAI: trả về true dù "car" chưa được insert
search(root, word):
    return walk(root, word) != null    -- chỉ kiểm tra path tồn tại!

-- ĐÚNG: phải kiểm tra cả isEnd
search(root, word):
    node <- walk(root, word)
    return node != null và node.isEnd = true

Trie chứa ["card"]: walk("car") trả về node "r" với isEnd = false. Đây là "điểm trung gian" trên đường đến "card", không phải từ độc lập. Chỉ node.isEnd mới phân biệt được hai trường hợp.

Pitfall 3 — Delete leak (memory rò rỉ)

Nếu delete chỉ set isEnd = false, các node không dùng trên branch vẫn còn trong memory:

-- CHƯA ĐẦY ĐỦ: bị rò rỉ node rỗng
delete(root, word):
    node <- walk(root, word)
    if node != null:
        node.isEnd <- false    -- node và các node cha vẫn còn trong memory!

Xử lý đúng: sau khi set isEnd = false, cleanup bottom-up bằng cách xóa các node không còn children và không phải isEnd. Cần thêm logic đệ quy hoặc stack — phức tạp hơn và chỉ cần thiết khi delete thường xuyên. Trong nhiều ứng dụng (search index read-mostly), bỏ qua cleanup là chấp nhận được.

8. Ứng dụng trong các hệ thống thực tế

Linux kernel IP routing table dùng Patricia trie cho longest prefix match. Khi packet đến với destination IP, kernel cần tìm route khớp prefix dài nhất (ví dụ 192.168.1.0/24 cụ thể hơn 192.168.0.0/16). Hash table không hỗ trợ prefix query. Red-Black tree so sánh full key. Patricia trie walk theo từng bit của IP address → O(32) cho IPv4, O(128) cho IPv6 — cực nhanh và độc lập với kích thước routing table.

DNS resolver dùng trie theo cấu trúc domain hierarchy. Domain mail.google.com được resolve theo thứ tự ngược: comgooglemail. Mỗi level tương đương một node trong conceptual trie. Caching DNS khai thác cấu trúc này: cache tại node google.com phục vụ mọi subdomain.

Aho-Corasick — multi-pattern search (Thuật toán Ứng dụng — Module 2) build một trie của tất cả pattern cần tìm, sau đó thêm "fail links" (tương tự KMP failure function) để xử lý nhiều pattern cùng lúc trong O(text_length + matches). Đây là thuật toán đằng sau grep -r khi search nhiều pattern, và các anti-virus scanner.

Spell checker / autocorrect dùng trie kết hợp Levenshtein automaton. Thay vì check edit distance với từng từ trong dictionary (O(n × L)), traverse trie với state machine tracking edit distance — chỉ explore các nhánh có thể nằm trong threshold edit distance.

Browser URL bar và search suggest box thường dùng trie trên tập URL đã thăm hoặc top queries. Mỗi keystroke là một prefix walk, kết quả là subtree enumeration. Chrome và Firefox giữ trie trong memory để suggest hoạt động ngay lập tức không cần round trip.

9. Deep Dive

📚 Deep Dive — nguồn tham khảo

Sách kinh điển:

  • The Art of Computer Programming (Knuth), Vol. 3 — Section 6.3: Digital Searching. Phân tích gốc của trie, chứng minh complexity, so sánh với các structure tìm kiếm khác. Reference học thuật chuẩn nhất.
  • Introduction to Algorithms (CLRS), 4th Edition — không có chapter riêng cho trie, nhưng string matching chapter (Chapter 32) liên quan đến Aho-Corasick.

Paper gốc:

  • Morrison (1968) — "PATRICIA — Practical Algorithm To Retrieve Information Coded In Alphanumeric". Paper gốc định nghĩa Patricia tree, compressed trie, và ứng dụng cho binary string search.
  • Aho & Corasick (1975) — "Efficient String Matching: An Aid to Bibliographic Search". Multi-pattern matching qua trie + fail links.

Source thực tế:

  • Linux kernel lib/radix-tree.cinclude/linux/radix-tree.h — Patricia trie implementation cho IP routing và memory management.

Cross-link trong khóa học:

  • Module 1 Lesson 07 (B-tree): tree variant tối ưu cho disk I/O — bổ sung cho trie tối ưu cho prefix string.
  • Thuật toán Ứng dụng — Module 2 (String Matching): Aho-Corasick dùng trie làm cơ sở cho multi-pattern search.
  • Thuật toán Ứng dụng — Module 4 (Phân tán): Ethereum state trie — Patricia Merkle tree trong blockchain.

10. Tóm tắt

  • Trie lưu mỗi ký tự là một node; path từ root đến node lá là một từ. Search prefix = walk path → O(L), độc lập với n.
  • Hai cách lưu children: mảng [26] (nhanh, lãng phí memory cho sparse alphabet) và HashMap (linh hoạt, phù hợp Unicode).
  • isEnd flag bắt buộc: phân biệt "điểm trung gian trên path" với "từ kết thúc tại đây".
  • Memory cost worst case O(N × L) — tệ nhất khi nhiều từ ngắn không có prefix chung.
  • Patricia/Radix tree nén single-child chain thành edge label, giảm số node đáng kể — dùng trong Linux IP routing, Git, Ethereum.
  • Top-K autocomplete: pre-compute top-K per node cho query O(L); lazy DFS + heap cho insert O(L).
  • Ứng dụng rộng: autocomplete, IP longest prefix match, DNS, Aho-Corasick, spell checker.

11. Tự kiểm tra

Tự kiểm tra
Q1
Trie search O(L) độc lập với n — vì sao? Trong khi HashMap search O(1) cũng không phụ thuộc n, tại sao trie lại tốt hơn cho prefix query?

Trie search O(L) vì chỉ đi theo path ký tự từ root: mỗi bước là một children.get(c) — O(1). L bước tổng cộng, bất kể trie chứa 10 hay 10 triệu từ. Không có giai đoạn "quét tất cả từ".

HashMap search O(1) cho exact match, nhưng prefix query ("tất cả từ bắt đầu bằng ca") phải scan toàn bộ key — O(n × L). HashMap không lưu prefix structure. Trie lưu prefix chung dưới dạng shared path — từ prefix node, DFS subtree là đủ để liệt kê tất cả match mà không cần touch các nhánh không liên quan.

Q2
Chọn HashMap children hay mảng [26] cho trie xử lý tên sản phẩm tiếng Việt?

Dùng HashMap. Tiếng Việt có hàng trăm ký tự khi tính dấu (a, à, á, â, ấ, ầ, ...). Mảng 26 phần tử chỉ phù hợp cho ASCII a-z không dấu.

Nếu normalize input về ASCII (bỏ dấu: "cà phê" → "ca phe"), có thể dùng mảng 27 (thêm space). Nhưng normalize mất thông tin và có thể gây ambiguity ("lan" vs "lăn"). Dùng HashMap<ký_tự, TrieNode> xử lý đúng Unicode, tốn memory khi có child thực sự, không lãng phí cho node thưa.

Q3
Patricia/Radix tree giải quyết vấn đề gì của trie cơ bản? Trade-off là gì?

Trie cơ bản lãng phí memory và tạo nhiều node cho single-child chain — ví dụ prefix "inter" tạo 5 node liên tiếp không có nhánh. Patricia tree nén chain đó thành một edge duy nhất với label "inter", giảm số node đáng kể.

Trade-off: lookup phức tạp hơn. Khi đi theo một edge, phải match prefix của query string với edge label — cần xử lý partial match (query ngắn hơn edge label hoặc dài hơn). Insert cũng phức tạp hơn: đôi khi phải "split" một edge khi có từ mới chia sẻ chỉ một phần prefix. Code phức tạp hơn đáng kể so với trie cơ bản.

Q4
Trie chứa các từ trong list ["a", "ab", "abc"]. Gọi search("ab") — walk trả về node nào? isEnd ở node đó là gì? Kết quả?

walk("ab") đi root → node "a" → node "b". Trả về node "b".

Vì "ab" đã được insert (nằm trong list), node "b" có isEnd = true. search("ab") trả về node != null && node.isEnd = true.

Nếu trie chỉ chứa ["a", "abc"] (không có "ab"): walk("ab") vẫn trả về node "b" không null — vì "b" là node trung gian trên đường đến "abc". Nhưng node.isEnd = false, nên search("ab") trả về false. Đây là lý do phải check cả isEnd, không chỉ kiểm tra walk != null.

Q5
Memory cost của trie tệ nhất ở trường hợp nào? Tốt nhất ở trường hợp nào?

Tệ nhất: nhiều từ ngắn không có prefix chung — ví dụ tập UUID hoặc hash string. Mỗi từ tạo ra chuỗi node riêng, không chia sẻ gì với các từ khác. Memory gần O(N × L) — có thể tốn hơn HashMap.

Tốt nhất: nhiều từ có prefix chung dài — ví dụ dictionary từ điển thực tế, URL cùng domain, hay IP address trong một subnet. Prefix chung được lưu một lần dưới dạng shared path. Với dictionary tiếng Anh, hàng nghìn từ chia sẻ prefix "un-", "pre-", "inter-" — trie tiết kiệm hơn HashMap đáng kể.

Q6
IP routing dùng Patricia trie — vì sao tốt hơn hash table cho longest prefix match?

Longest prefix match yêu cầu: trong số tất cả route prefix khớp với destination IP, chọn cái dài nhất (cụ thể nhất). Ví dụ 192.168.1.0/24 thắng 192.168.0.0/16 cho IP 192.168.1.5.

Hash table chỉ làm exact match — cần thử tất cả 32 độ dài prefix có thể cho IPv4 (lookup /32, /31, ..., /0) và chọn dài nhất match → 32 hash lookup mỗi packet. Patricia trie đi theo từng bit của IP address, mỗi bước narrow xuống nhánh phù hợp và track route match gần nhất — O(32) bước cho IPv4, O(128) cho IPv6, một lần duyệt duy nhất, không cần retry. Đây là lý do Linux kernel routing table dùng Patricia trie.

Bài tiếp theo: Bloom filter — probabilistic, false positive math

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên