Thuật toán Cốt lõi — Tìm kiếm, Sắp xếp & Đồ thị
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.

Plain Text
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

Plain Text
-- 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
Plain Text
-- 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)
Plain Text
-- 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)
Plain Text
-- Đ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.

Thử ngẫmtime của trie độc lập với n, nhưng memory vẫn O(N × L) worst case. Một tập toàn UUID ngẫu nhiên, không share prefix, có được lợi gì từ trie so với HashMap không?

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

Plain Text
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":

Plain Text
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

Thử ngẫmPatricia tree nén 14 node xuống còn 5 cho ba từ "interest/interesting/internal". Với một bộ từ toàn từ ngắn 2-3 ký tự không share prefix, phép nén này còn tiết kiệm được gì không?

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.

Plain Text
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.

Plain Text
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.

Thử ngẫmapproach 1 tốn O(L×K) mỗi insert để cập nhật top-K toàn path; approach 2 chỉ tốn O(L). Một search engine có traffic đọc gấp 1000 lần traffic ghi nên nghiêng về approach nào?

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:

Plain Text
-- 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.

Plain Text
-- 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:

Plain Text
-- 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.

Đọc thêm: Aho-Corasick — khoá Thuật toán ứng dụng dùng đúng trie này làm khung, gắn thêm fail link để tìm nhiều pattern cùng lúc trong một lần quét — bài toán mà trie đơn thuần không giải được.

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
0/6 câu đã trả lời
  1. 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?
  2. Q2
    Chọn HashMap children hay mảng [26] cho trie xử lý tên sản phẩm tiếng Việt?
  3. Q3
    Patricia/Radix tree giải quyết vấn đề gì của trie cơ bản? Trade-off là gì?
  4. 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ả?
  5. 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?
  6. Q6
    IP routing dùng Patricia trie — vì sao tốt hơn hash table cho longest prefix match?

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

Bloom filter — Probabilistic membership với 99% RAM tiết kiệm