Trie — Autocomplete, IP routing, và prefix-friendly dictionary
HashMap không thể tìm prefix hiệu quả. Trie giải quyết bằng cách biến mỗi ký tự thành một node, biến prefix query thành đường đi O(L) độc lập với số từ đã lưu. Bài này dạy trie cơ bản, Patricia/Radix tree, và ứng dụng thực tế từ autocomplete đến IP routing.
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át | Root node |
| Ngã rẽ dán nhãn "c", "a", "r" | Node với character key |
| Đường đi từ root đến đích | Path = 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 |
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.
class TrieNode {
// Map from character to child node.
// Alternative: TrieNode[26] for a-z only (faster but wastes memory).
Map<Character, TrieNode> children = new HashMap<>();
// True if this node marks the end of a valid word.
boolean isEnd;
}
class Trie {
private final TrieNode root = new TrieNode();
}
Trade-off Map<Character, TrieNode> vs TrieNode[26]:
| Cách lưu children | Lookup | Memory | Phù hợp khi |
|---|---|---|---|
TrieNode[26] (array) | O(1), rất nhanh | 26 pointer mỗi node, lãng phí nếu sparse | Chỉ a-z, trie dày đặc |
HashMap<Character, TrieNode> | O(1) amortized | Chỉ cấp phát khi có child thực sự | Alphabet rộng, trie thưa |
TreeMap<Character, TrieNode> | O(log k), k = số children | Như HashMap nhưng ordered | Cần duyệt children theo thứ tự |
Với Java 21, HashMap là lựa chọn mặc định an toàn cho ASCII alphabet. Với Unicode (65k+ character), TrieNode[65536] sẽ tốn khoảng 256KB mỗi node — hoàn toàn không khả thi.
3. Insert, Search, và startsWith
public void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
// Create child node if it does not exist yet.
node = node.children.computeIfAbsent(c, k -> new TrieNode());
}
node.isEnd = true; // mark end of word
}
public boolean search(String word) {
TrieNode node = walk(word);
// Must reach a node AND that node must be a word-end.
return node != null && node.isEnd;
}
public boolean startsWith(String prefix) {
// Only need to reach the prefix node -- no isEnd check needed.
return walk(prefix) != null;
}
// Walk down the trie following each character of s.
// Returns the node reached, or null if path does not exist.
private TrieNode walk(String s) {
TrieNode node = root;
for (char c : s.toCharArray()) {
node = node.children.get(c);
if (node == null) return null;
}
return node;
}
ASCII diagram — trie sau khi insert ["car", "cart", "cat", "can", "dog"]:
root
├── c
│ └── a
│ ├── r [isEnd=true] ("car")
│ │ └── t [isEnd=true] ("cart")
│ ├── t [isEnd=true] ("cat")
│ └── n [isEnd=true] ("can")
└── d
└── o
└── g [isEnd=true] ("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ác | Time | Space |
|---|---|---|
| Insert | O(L) — L là độ dài từ | O(L) cho các node mới trên path |
| Search | O(L) | — |
| startsWith | O(L) | — |
| Delete | O(L) — set isEnd = false, cleanup tùy chọn | — |
| List all words with prefix | O(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 khong co nhanh: i-n-t-e-r
├── e
│ └── s
│ └── t [isEnd=true] ("interest")
│ └── i
│ └── n
│ └── g [isEnd=true] ("interesting")
└── n
└── a
└── l [isEnd=true] ("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=true] ("interest")
│ └── "ing" [isEnd=true] ("interesting")
└── "nal" [isEnd=true] ("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 (LeetCode 642):
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.
class AutocompleteNode {
Map<Character, AutocompleteNode> children = new HashMap<>();
boolean isEnd;
// Top-K words reachable from this node, sorted by frequency desc.
List<String> topK = new ArrayList<>();
}
- Lookup: O(L) — walk xuống prefix node, trả về
node.topKngay 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.
public List<String> topKWithPrefix(String prefix, int k) {
TrieNode node = walk(prefix);
if (node == null) return List.of();
// Min-heap: evict least frequent when size exceeds k.
PriorityQueue<String> heap = new PriorityQueue<>(
Comparator.comparingInt(w -> frequencyOf(w))
);
dfsCollect(node, new StringBuilder(prefix), heap, k);
return new ArrayList<>(heap);
}
- 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à TrieNode array
Dùng TrieNode[26] 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:
// WRONG -- explodes for non-ASCII input like "cà phê" or "東京"
class TrieNode {
TrieNode[] children = new TrieNode[26]; // assumes a-z only!
boolean isEnd;
}
// CORRECT -- works for any Unicode character
class TrieNode {
Map<Character, TrieNode> children = new HashMap<>();
boolean isEnd;
}
TrieNode[65536] mỗi node = 65.536 pointer × 8 bytes = 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.
// WRONG -- returns true even if "car" was never inserted
public boolean search(String word) {
return walk(word) != null; // only checks path existence!
}
// CORRECT
public boolean search(String word) {
TrieNode node = walk(word);
return node != null && node.isEnd; // must be marked as word-end
}
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:
// INCOMPLETE delete -- leaks empty nodes
public void delete(String word) {
TrieNode node = walk(word);
if (node != null) node.isEnd = false; // node and its parents stay in 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: com → google → mail. 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 (Module 7) 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
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.cvàinclude/linux/radix-tree.h— Patricia trie implementation cho IP routing và memory management. - OpenJDK không có Trie trong standard library — nhưng Apache Commons Collections có
org.apache.commons.collections4.trie.PatriciaTrie.
Cross-link trong khóa học:
- Module 3 Lesson 07 (B-tree): tree variant tối ưu cho disk I/O — bổ sung cho trie tối ưu cho prefix string.
- Module 7 (String Matching): Aho-Corasick dùng trie làm cơ sở cho multi-pattern search.
- Module 9 (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:
TrieNode[26](nhanh, lãng phí memory cho sparse alphabet) vàHashMap(linh hoạt, phù hợp Unicode). isEndflag 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
Q1Trie 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.
Q2Chọn HashMap children hay TrieNode[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, à, á, â, ấ, ầ, ...). TrieNode[26] 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 TrieNode[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<Character, TrieNode> xử lý đúng Unicode, tốn memory khi có child thực sự, không lãng phí cho node thưa.
Q3Patricia/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.
Q4Trie 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.
Q5Memory 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ể.
Q6IP 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?