Mini-challenge — Autocomplete top-K với Trie + frequency
Implement Autocomplete class: insert word với frequency, suggest top-K theo prefix dùng Trie + DFS + min-heap. Pattern cốt lõi của Google search box và e-commerce search bar.
Bạn đang build search box cho sàn e-commerce có 1 triệu sản phẩm. Người dùng gõ "lap" — hệ thống cần trả về ngay top 5 sản phẩm phù hợp prefix đó, sắp xếp theo lượt tìm gần đây.
Giải pháp: kết hợp Trie (bài 08) để tìm prefix trong O(L) với frequency lưu tại mỗi node — sau đó DFS subtree từ prefix node và dùng min-heap để extract top-K hiệu quả.
Sau bài này bạn implement được autocomplete pattern Google search box, e-commerce search bar dùng — backbone của bộ gợi ý.
🎯 Yêu cầu bài toán
Viết class Autocomplete với API sau:
void insert(String word, int frequency)— insert hoặc cộng dồn frequency cho word. Gọi nhiều lần cùng word thì cộng dồn frequency.List<String> suggest(String prefix, int k)— trả về top K word có prefix matching, sorted desc theo frequency. Tie-break: alphabetical ascending.
Yêu cầu Big-O:
| Operation | Complexity |
|---|---|
insert(word, frequency) | O(L) — L là độ dài word |
suggest(prefix, k) | O(L + matches × log K) — matches là số word match prefix |
Constraint: 1 triệu word, alphabet a-z, độ dài tối đa 20.
📦 Starter code
import java.util.*;
public class Autocomplete {
// TODO: design data structure (Trie + frequency)
public void insert(String word, int frequency) {
// TODO
throw new UnsupportedOperationException();
}
public List<String> suggest(String prefix, int k) {
// TODO
throw new UnsupportedOperationException();
}
}
Dành 20–25 phút tự làm trước khi xem gợi ý.
🧪 Test cases
Autocomplete a = new Autocomplete();
a.insert("laptop", 50);
a.insert("lap", 10);
a.insert("lapis", 30);
a.insert("legend", 5);
assertEquals(List.of("laptop", "lapis", "lap"), a.suggest("la", 3));
assertEquals(List.of("laptop"), a.suggest("lap", 1));
assertEquals(List.of(), a.suggest("xyz", 5));
// tie-break: same frequency -> alphabetical asc
a.insert("apple", 20);
a.insert("apply", 20);
assertEquals(List.of("apple", "apply"), a.suggest("ap", 2));
💡 Gợi ý
Bài 08 dùng isEnd boolean để đánh dấu cuối từ. Thay isEnd bằng int frequency: giá trị 0 nghĩa là node trung gian, giá trị lớn hơn 0 nghĩa là node cuối từ với frequency đó.
Thêm một field nữa vào node: String word lưu toàn bộ chuỗi tại node cuối từ. Khi DFS sau này, bạn sẽ cần từ đầy đủ để trả về — nếu không có field này, phải maintain prefix string dọc theo đường đi, phức tạp hơn.
insert walk down theo từng ký tự (tạo node nếu chưa có), rồi tại node cuối: node.frequency += frequency và node.word = word.
suggest(prefix, k) cần hai bước:
- Walk xuống trie theo từng ký tự của prefix. Nếu gặp node null — prefix không tồn tại, trả về
List.of(). - Từ node đến được (prefix node), DFS toàn bộ subtree để collect tất cả word có
frequency > 0.
DFS: nếu node.word != null thì node này là end-of-word — thêm vào kết quả. Sau đó đệ quy với từng child.
Sau khi collect xong, sort theo frequency desc, tie-break alphabetical asc, lấy K đầu.
Sort toàn bộ matches tốn O(matches × log matches). Khi K nhỏ (K=5, matches=10.000), lãng phí. Dùng min-heap size K thay thế:
- Min-heap comparator: frequency ascending (phần tử nhỏ nhất nằm trên đỉnh — sẽ bị evict đầu tiên khi heap đầy).
- Với mỗi word collect được: push vào heap, nếu
heap.size() > kthì poll (loại bỏ frequency thấp nhất). - Cuối cùng heap chứa đúng K word frequency cao nhất.
Tie-break cho comparator: nếu frequency bằng nhau, word nào alphabetically lớn hơn thì ở đỉnh heap (bị evict trước) — như vậy heap giữ lại word alphabetically nhỏ hơn. b.word.compareTo(a.word) thay vì a.word.compareTo(b.word).
Sau khi collect xong, poll từng phần tử và reverse để ra thứ tự frequency desc.
✅ Lời giải
import java.util.*;
public class Autocomplete {
private static class Node {
Map<Character, Node> children = new HashMap<>();
int frequency; // 0 if not end of word
String word; // null if not end of word
}
private final Node root = new Node();
public void insert(String word, int frequency) {
Node node = root;
for (char c : word.toCharArray()) {
node = node.children.computeIfAbsent(c, k -> new Node());
}
node.frequency += frequency; // accumulate on repeated insert
node.word = word;
}
public List<String> suggest(String prefix, int k) {
Node node = root;
for (char c : prefix.toCharArray()) {
node = node.children.get(c);
if (node == null) return List.of();
}
// Min-heap of size k: comparator evicts least valuable first.
// "Least valuable" = lowest frequency; on tie = alphabetically largest.
PriorityQueue<Node> heap = new PriorityQueue<>(
(a, b) -> a.frequency != b.frequency
? Integer.compare(a.frequency, b.frequency)
: b.word.compareTo(a.word)
);
collect(node, heap, k);
List<String> result = new ArrayList<>();
while (!heap.isEmpty()) result.add(heap.poll().word);
Collections.reverse(result); // heap pops smallest first; flip for desc
return result;
}
private void collect(Node node, PriorityQueue<Node> heap, int k) {
if (node.word != null) {
heap.offer(node);
if (heap.size() > k) heap.poll(); // evict least valuable
}
for (Node child : node.children.values()) {
collect(child, heap, k);
}
}
}
Trace ví dụ — insert("laptop", 50); insert("lap", 10); insert("lapis", 30); suggest("la", 3):
| Bước | Trạng thái |
|---|---|
| insert "laptop", 50 | walk l→a→p→t→o→p; node "p" (cuối): frequency=50, word="laptop" |
| insert "lap", 10 | walk l→a→p; node "p" (cuối): frequency=10, word="lap" |
| insert "lapis", 30 | walk l→a→p→i→s; node "s": frequency=30, word="lapis" |
| suggest("la", 3) | walk l→a; prefix node = "a" |
| DFS subtree | thu thập: node "p" (lap, f=10), node "p/top" (laptop, f=50), node "s" (lapis, f=30) |
| Min-heap K=3 | push lap(10) → heap:[10]; push laptop(50) → heap:[10,50]; push lapis(30) → heap:[10,30,50]; size=3, không evict |
| poll+reverse | poll→10(lap), poll→30(lapis), poll→50(laptop); reverse → ["laptop","lapis","lap"] |
⚠️ Pitfall phổ biến
Pitfall 1 — Quên node.word field, phải maintain prefix string trong DFS.
// AWKWARD: no word field -- must carry prefix string through recursion
private void collect(Node node, String current,
PriorityQueue<String> heap, int k) {
if (node.frequency > 0) {
heap.offer(current); // string allocation every end-of-word
}
for (Map.Entry<Character, Node> e : node.children.entrySet()) {
collect(e.getValue(), current + e.getKey(), heap, k); // O(L) allocation per step
}
}
Lưu word tại end node: đánh đổi memory lấy code sạch hơn, tránh O(L) string allocation mỗi bước DFS. Với 1 triệu word, mỗi string tối đa 20 ký tự — tổng memory là manageable.
Pitfall 2 — Sort full subtree thay min-heap top-K.
// SLOW: sort all matches, then take first k
List<Node> all = new ArrayList<>();
collectAll(node, all);
all.sort((a, b) -> b.frequency - a.frequency);
return all.stream().limit(k).map(n -> n.word).toList();
O(matches × log matches) thay vì O(matches × log K). Với K=5 và matches=10.000: sort tốn khoảng 140k so sánh; min-heap top-5 tốn khoảng 30k so sánh — nhanh hơn gần 5 lần. Khi matches=100.000, gap càng lớn hơn.
Pitfall 3 — Comparator ngược chiều cho tie-break.
Min-heap giữ lại phần tử lớn hơn theo comparator (phần tử nhỏ hơn bị evict). Để giữ word alphabetically nhỏ hơn (ưu tiên "apple" hơn "apply" khi frequency bằng nhau), comparator phải xem "apply" là nhỏ hơn:
// WRONG: keeps "apply" over "apple" on tie -- inverted alphabetical priority
(a, b) -> a.word.compareTo(b.word)
// CORRECT: "apply" is "smaller" in the heap => evicted first => "apple" kept
(a, b) -> b.word.compareTo(a.word)
Test kỹ với tie-break case trước khi tin vào comparator logic.
📊 Benchmark tham khảo
1M words, 100k suggest calls, K=10:
Naive HashMap full scan: ~120s
Trie + DFS + sort: ~2s
Trie + DFS + min-heap: ~1.5s
Trie + pre-cached top-K/node: ~0.1s
Min-heap thắng sort rõ khi K nhỏ. Pre-cached top-K thắng tất cả cho query-heavy workload — nhưng insert chậm hơn và tốn memory hơn (trade-off section dưới).
🔀 Variant để tự thử
Variant 1 — Pre-cache top-K mỗi node (optimize suggest về O(L)):
Thay vì DFS mỗi lần suggest, lưu sẵn top-K list trong mỗi node. Khi insert, walk xuống từng node trên path và cập nhật top-K list tại mỗi node. suggest chỉ cần walk tới prefix node và trả về node.topK — O(L).
Trade-off: insert tốn O(L × K log K) thay vì O(L). Phù hợp khi suggest call rate cao hơn nhiều so với insert — điển hình cho production search box (vocabulary ổn định, query liên tục).
Variant 2 — Fuzzy matching với edit distance 1:
Prefix hiện tại yêu cầu exact match. Nếu user gõ "latop" thay vì "lapto" — bài này trả về rỗng. Thêm "tolerate 1 typo": DFS với counter mismatch cho phép, dừng nhánh khi counter vượt 1. Phức tạp hơn đáng kể. Cross-link: Module 7 (Levenshtein automaton — sẽ cover).
Variant 3 — Personalized frequency:
Thêm dimension user-specific: mỗi user có lịch sử search riêng. Approach 1: global Trie + per-user frequency map — suggest lấy global matches rồi re-rank theo user preference. Approach 2: per-user Trie — memory tốn nhưng query sạch hơn. Với 1 triệu user và 1 triệu word, per-user Trie hoàn toàn không khả thi — approach 1 (global trie + re-rank) là hướng sản phẩm thực tế.
🏭 Applied note
Elasticsearch completion field type — dùng Finite State Transducer (FST), là variant nén của trie. FST encode trie thành minimal acyclic finite automaton, tiết kiệm memory hơn trie thường với alphabet lớn. Suggest O(prefix length), không cần DFS subtree.
Lucene — engine dưới Elasticsearch, dùng FST cho term dictionary. Mỗi segment có FST riêng, không phải single global trie — cho phép merge và delete segment hiệu quả.
Google Search box — multi-tier: tier 1 là trie + pre-cached top-K per prefix (bài này, Variant 1), tier 2 là ML ranker personalize dựa trên user history, tier 3 là spell corrector (Levenshtein automaton + language model). Bài này là tier 1 — foundation của toàn bộ pipeline.
Cross-link: Module 7 (String Matching — Aho-Corasick, Levenshtein), Module 11 (Search Engine — full-text indexing, ranking).
✨ Điều bạn vừa làm được
- Extend Trie cơ bản (bài 08) với
frequencyfield để support top-K ranked autocomplete. - Implement DFS subtree collect + min-heap size K: O(matches × log K) thay vì O(matches × log matches).
- Nắm tie-break comparator logic cho min-heap: cần invert hướng để heap giữ đúng phần tử.
- Hiểu trade-off lazy DFS vs pre-cached top-K: insert speed vs query speed.
- Biết pattern này là tier 1 của Google Search, Elasticsearch completion suggester, và e-commerce search bar.
Bài tiếp theo: Case Study: MySQL InnoDB B+tree + Redis dict rehash
Bài này có giúp bạn hiểu bản chất không?