Mini-challenge — Autocomplete top-K với Trie + frequency
Implement Autocomplete: insert word với frequency, suggest top-K theo prefix dùng Trie + DFS + min-heap. Pattern cốt lõi của search box.
Bạn đang làm ô tìm kiếm cho một sàn thương mại điện tử có 1 triệu sản phẩm. Người dùng gõ "lap", hệ thống phải trả về ngay top 5 sản phẩm khớp prefix đó, xếp theo lượt tìm gần đây.
Cách làm: dùng Trie (bài 08) để tìm prefix trong O(L), lưu frequency (tần suất tìm kiếm) tại mỗi node, rồi DFS subtree từ node prefix và dùng min-heap để lấy top-K nhanh.
Sau bài này bạn tự cài được lớp nền của các bộ gợi ý tìm kiếm.
Yêu cầu bài toán
Viết cấu trúc Autocomplete với API sau:
insert(word, frequency): insert word hoặc cộng dồn frequency cho word. Gọi nhiều lần với cùng word thì frequency được cộng dồn.suggest(prefix, k): trả về top K word khớp prefix, sắp xếp frequency giảm dần. Khi bằng frequency thì xếp theo alphabet tăng dần.
Yêu cầu Big-O:
| Thao tác | Độ phức tạp |
|---|---|
insert(word, frequency) | O(L), với L là độ dài word |
suggest(prefix, k) | O(L + matches × log K), với matches là số word khớp prefix |
Ràng buộc: 1 triệu word, alphabet a-z, độ dài tối đa 20.
Starter: cấu trúc dữ liệu và interface
-- Cấu trúc node của Trie
AutocompleteNode:
children: Map<ký_tự, AutocompleteNode>
frequency: int -- 0 nếu không phải cuối từ
word: String -- null nếu không phải cuối từ
-- Interface cần cài đặt
Autocomplete:
root: AutocompleteNode
insert(word, frequency):
-- TODO
suggest(prefix, k):
-- TODO: trả về List<String>
Hãy tự làm 20–25 phút trước khi xem gợi ý. Trước khi code, liệt kê các input biên (prefix không tồn tại, prefix rỗng, k lớn hơn số kết quả, insert cùng một word hai lần); cách nghĩ về chúng nằm ở Edge case.
Test case
a <- Autocomplete mới
a.insert("laptop", 50)
a.insert("lap", 10)
a.insert("lapis", 30)
a.insert("legend", 5)
assert a.suggest("la", 3) = ["laptop", "lapis", "lap"]
assert a.suggest("lap", 1) = ["laptop"]
assert a.suggest("xyz", 5) = []
-- bằng frequency thì xếp alphabet tăng dần
a.insert("apple", 20)
a.insert("apply", 20)
assert a.suggest("ap", 2) = ["apple", "apply"]
Gợi ý
Bài 08 dùng isEnd (kiểu boolean) để đánh dấu cuối từ. Hãy thay isEnd bằng frequency: int: giá trị 0 nghĩa là node trung gian, giá trị lớn hơn 0 nghĩa là node cuối từ và cho biết frequency của từ đó.
Thêm một field nữa vào node: word lưu cả chuỗi tại node cuối từ. Khi DFS sau này, bạn cần từ đầy đủ để trả về. Không có field này thì phải mang theo chuỗi prefix dọc đường đi, phức tạp hơn.
insert đi xuống theo từng ký tự (tạo node nếu chưa có), rồi tại node cuối đặt node.frequency += frequency và node.word = word.
suggest(prefix, k) cần hai bước:
- Đi xuống trie theo từng ký tự của prefix. Nếu gặp node null thì prefix không tồn tại, trả về danh sách rỗng.
- Từ node đến được (node prefix), DFS toàn bộ subtree để gom mọi word có
frequency > 0.
DFS: nếu node.word != null thì node này là cuối từ, thêm vào kết quả. Sau đó đệ quy với từng child.
Gom xong thì sắp xếp theo frequency giảm dần (bằng nhau thì alphabet tăng dần), rồi lấy K phần tử đầu.
Sắp xếp toàn bộ matches tốn O(matches × log matches). Khi K nhỏ (K=5, matches=10.000) thì quá lãng phí. Hãy dùng min-heap kích thước K thay thế:
- Comparator của min-heap: frequency tăng dần (phần tử nhỏ nhất nằm ở đỉnh, bị loại đầu tiên khi heap đầy).
- Với mỗi word gom được: push vào heap, nếu
heap.size() > kthì poll (bỏ phần tử có frequency thấp nhất). - Cuối cùng heap giữ đúng K word có frequency cao nhất.
Khi frequency bằng nhau, word nào đứng sau theo alphabet thì phải nằm ở đỉnh heap (bị loại trước), nhờ vậy heap giữ lại word đứng trước. Nghĩa là comparator so sánh word theo thứ tự alphabet ngược.
Gom xong thì poll từng phần tử rồi đảo ngược để ra thứ tự frequency giảm dần.
Lời giải
-- Cấu trúc Node
Node:
children: Map<ký_tự, Node>
frequency: int -- 0 nếu không phải cuối từ
word: String -- null nếu không phải cuối từ
-- Cấu trúc chính
Autocomplete:
root: Node mới (rỗng)
-- Insert word với frequency (cộng dồn nếu gọi lại)
insert(word, frequency):
node <- root
for each ký_tự c trong word:
if c không trong node.children:
node.children[c] <- Node mới
node <- node.children[c]
node.frequency <- node.frequency + frequency -- cộng dồn khi insert lại
node.word <- word
Time: O(L) Space: O(L) cho node mới trên path
-- Suggest top K word có prefix matching
suggest(prefix, k):
-- Bước 1: đi xuống trie theo prefix
node <- root
for each ký_tự c trong prefix:
node <- node.children.get(c)
if node = null: return [] -- prefix không tồn tại
-- Bước 2: DFS subtree + min-heap kích thước k
-- Comparator: loại phần tử "kém giá trị nhất" trước
-- kém nhất = frequency thấp nhất; nếu bằng = alphabetically lớn nhất
heap <- MinHeap(key: (frequency tăng dần; tie: word giảm dần alphabetical))
collect(node, heap, k)
-- Bước 3: poll + đảo ngược (heap pop nhỏ nhất trước)
result <- []
while heap không rỗng:
result.append(heap.poll().word)
result.reverse()
return result
Time: O(L + matches × log K) Space: O(k)
-- DFS collect: thêm word vào min-heap, giữ size <= k
collect(node, heap, k):
if node.word != null:
heap.push(node)
if heap.size() > k:
heap.poll() -- evict phần tử kém nhất
for each child trong node.children.values():
collect(child, heap, k)
Trace ví dụ: insert("laptop", 50); insert("lap", 10); insert("lapis", 30); suggest("la", 3) (nếu chưa quen đọc bảng trace, ôn lại ở Bảng trace):
| 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 | gom: node "p" (lap, f=10), node "p" cuối của 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"] |
Các bẫy hay gặp
Bẫy 1: Quên field word, phải mang theo chuỗi prefix trong DFS.
-- LỘN XỘN: không có field word nên phải mang chuỗi prefix qua đệ quy
collect(node, currentPrefix, heap, k):
if node.frequency > 0:
heap.push(currentPrefix) -- cấp phát string mỗi lần
for each (ký_tự c, child) trong node.children:
collect(child, currentPrefix + c, heap, k) -- O(L) allocation mỗi bước
Lưu word tại node cuối từ là đánh đổi bộ nhớ để lấy code gọn hơn, và khỏi phải cấp phát chuỗi O(L) ở mỗi bước DFS. Với 1 triệu word, mỗi chuỗi tối đa 20 ký tự, tổng bộ nhớ vẫn chấp nhận được.
Bẫy 2: Sắp xếp cả subtree thay vì dùng min-heap top-K.
-- CHẬM: sắp xếp toàn bộ match rồi lấy K đầu
all <- []
collectAll(node, all)
all.sort(theo frequency giảm dần)
return all[0..k-1]
-- O(matches × log matches) thay vì O(matches × log K)
-- K=5, matches=10.000: sắp xếp tốn cỡ 130k phép so sánh; min-heap top-5 tốn cỡ 25k
Bẫy 3: Comparator đặt ngược chiều khi xử lý trường hợp bằng frequency.
Min-heap giữ lại phần tử lớn hơn theo comparator (phần tử nhỏ hơn bị loại). Để giữ word đứng trước theo alphabet (ưu tiên "apple" hơn "apply" khi frequency bằng nhau), comparator phải coi "apply" là nhỏ hơn trong heap:
-- SAI: khi bằng frequency lại giữ "apply" thay vì "apple", ngược thứ tự alphabet
comparator(a, b):
if a.frequency != b.frequency: return a.frequency - b.frequency
return a.word.compareTo(b.word) -- "apple" < "apply" => "apple" bị evict trước -- sai!
-- ĐÚNG: "apply" "nhỏ hơn" trong heap => bị evict trước => giữ "apple"
comparator(a, b):
if a.frequency != b.frequency: return a.frequency - b.frequency
return b.word.compareTo(a.word) -- đảo ngược: "apply" < "apple" trong heap
Hãy test kỹ trường hợp bằng frequency trước khi tin vào logic của comparator.
So sánh bốn cách
Từ chậm đến nhanh khi query: quét cả HashMap, Trie + DFS + sort, Trie + DFS + min-heap, rồi Trie với top-K tính sẵn ở mỗi node. Khoảng cách cụ thể tuỳ dữ liệu và K, hãy tự đo trên bộ dữ liệu của bạn. Khi K nhỏ, min-heap thắng sắp xếp rõ rệt; top-K tính sẵn thắng khi workload chủ yếu là query, nhưng insert chậm hơn và tốn bộ nhớ hơn (đánh đổi ở mục dưới).
Biến thể để tự thử
Biến thể 1: tính sẵn top-K ở mỗi node (đưa suggest về O(L)):
Thay vì DFS mỗi lần suggest, lưu sẵn danh sách top-K trong mỗi node. Khi insert, đi xuống từng node trên đường đi và cập nhật danh sách top-K tại mỗi node. suggest chỉ cần đi tới node prefix rồi trả về node.topK, O(L).
Đánh đổi: insert tốn O(L × K log K) thay vì O(L). Hợp khi số lần gọi suggest nhiều hơn insert rất nhiều, điển hình cho ô tìm kiếm production (bộ từ vựng ổn định, query liên tục).
Biến thể 2: tìm gần đúng với edit distance 1:
Prefix hiện tại yêu cầu khớp chính xác. Nếu người dùng gõ "latop" thay vì "laptop", bài này trả về rỗng. Hãy thêm khả năng chịu 1 lỗi gõ: DFS có một bộ đếm số ký tự sai, bỏ nhánh khi bộ đếm vượt 1. Phức tạp hơn đáng kể.
Biến thể 3: frequency theo từng người dùng:
Thêm yếu tố riêng cho từng người dùng: mỗi người có lịch sử tìm kiếm riêng. Cách 1: một Trie chung kèm map frequency cho từng người, suggest lấy kết quả chung rồi xếp hạng lại theo sở thích của người đó. Cách 2: mỗi người một Trie, tốn bộ nhớ nhưng query gọn hơn. Với 1 triệu người dùng và 1 triệu word, Trie riêng cho từng người là bất khả thi, nên cách 1 (Trie chung cộng xếp hạng lại) là hướng thực tế cho sản phẩm.
Thử ngẫmbiến thể 3 kết luận Trie riêng cho từng người dùng là bất khả thi với 1 triệu người dùng. Nếu chỉ 1% người dùng có lượng truy cập đủ lớn để cá nhân hoá đáng giá, kết luận đó còn đúng không?
Dùng ở đâu trong thực tế
Field kiểu completion của Elasticsearch: dùng Finite State Transducer (FST), một biến thể nén của trie. FST mã hoá trie thành một automaton hữu hạn không chu trình ở dạng tối giản, tiết kiệm bộ nhớ hơn trie thường, và lấy được top-N theo weight mà không phải duyệt hết subtree.
Lucene: engine nằm dưới Elasticsearch, dùng FST cho term dictionary. Mỗi segment có FST riêng chứ không phải một trie toàn cục duy nhất, nhờ vậy gộp (merge) và xoá segment hiệu quả.
Xem thêm case study Lucene và ClamAV ở khoá Thuật toán ứng dụng.
Điều bạn vừa làm được
- Mở rộng Trie cơ bản (bài 08) bằng field
frequencyđể gợi ý top-K có xếp hạng. - Cài DFS gom word trong subtree cùng min-heap kích thước K: O(matches × log K) thay vì O(matches × log matches).
- Nắm cách đặt comparator cho min-heap khi bằng frequency: phải đảo chiều thì heap mới giữ đúng phần tử.
- Hiểu đánh đổi giữa DFS lúc query và top-K tính sẵn: tốc độ insert so với tốc độ query.
- Biết cách làm này là họ hàng gần của completion suggester trong Elasticsearch (dùng FST, biến thể nén của trie).
Bài tiếp theo: Case Study: MySQL InnoDB B+tree + Redis dict rehash
Bloom filter — Probabilistic membership, tiết kiệm RAM
Bài tiếpCase Study: MySQL InnoDB B+tree index + Redis dict incremental rehash
Bài này có giúp bạn hiểu bản chất không?
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.
Thảo luận & góp ý
Hỏi đáp về bài này