設計搜尋自動補全 — Trie、Top-K 排序與分散式即時建議系統 | 資料結構與演算法
搜尋自動補全(Search Autocomplete) 是現代搜尋體驗的核心功能——使用者每輸入一個字元,系統即時回傳最相關的候選建議。本文將從需求分析出發,深入設計以 Trie(前綴樹) 結合 Top-K 排序 為核心的自動補全系統,涵蓋 頻率追蹤、個人化建議、模糊搜尋(BK-Tree)、分散式架構 與 多層快取策略,搭配 JavaScript/TypeScript 與 C++ 雙語言完整實作,帶你徹底掌握搜尋建議系統的設計與實戰。
前言
在上一篇文章中,我們實作了
LRU/LFU Cache,學會如何用資料結構設計高效的快取淘汰策略。今天我們要解決另一個每天都在使用、卻很少深入思考的系統設計問題——搜尋自動補全(Autocomplete)。
打開 Google,輸入 “how to”,在你打完第二個空格的瞬間,搜尋框下方就出現了十條建議:“how to tie a tie”、“how to screenshot”、“how to cook rice”… 這些建議如何在 不到 100 毫秒 內從數十億條歷史搜尋中篩選出來?Amazon 的商品搜尋、YouTube 的影片搜尋、IDE 的程式碼補全——背後都是同一套核心技術。
這個問題的關鍵挑戰在於 前綴匹配的效率 和 排序的即時性。暴力掃描所有候選字串顯然不可行,我們需要一種能在 O(P) 時間內(P 為前綴長度)直接取得 Top-K 結果的資料結構。答案正是我們在
第 011 篇中學過的 Trie(字典樹),但需要加上精心設計的 Top-K 快取、頻率追蹤,以及多層分散式架構。
本文你將學到:
- 搜尋自動補全的完整需求分析與規模估算
- 三種方案比較:純 Trie、Trie + 節點 Top-K 快取、純雜湊表
- 完整可運行的 JavaScript/TypeScript 與 C++ 雙語言實作
- 模糊搜尋(Fuzzy Search)的 BK-Tree 演算法
- 分散式 Trie 分片、多層快取、即時趨勢更新與 CJK 支援
- 4 道 LeetCode 經典自動補全題目
1. 需求分析
功能性需求(Functional Requirements)
- 使用者每輸入一個字元,回傳前 K 個(通常 K = 5 ~ 10)最相關的補全建議
- 建議按照 熱度(搜尋頻率) 排序
- 支援 最近搜尋(Recent Searches) 個人化結果
- 支援 模糊匹配(Fuzzy Match)——容忍拼寫錯誤,例如 “goolge” 應建議 “google”
- 空白輸入時回傳使用者的最近搜尋記錄
非功能性需求(Non-Functional Requirements)
- 延遲(Latency):P99 < 100ms,理想目標 < 50ms
- 可用性(Availability):99.99%(每年停機不超過 52 分鐘)
- 一致性(Consistency):最終一致性可接受——熱度更新延遲幾小時無妨
- 可擴展性:支援數十億級別的歷史搜尋字串
- 記憶體可控:在有限的伺服器記憶體中高效索引
排除範圍
- 不處理語意搜尋(Semantic Search)
- 不處理多語言混排補全
- 不設計使用者認證系統
規模估算
以 Google 搜尋規模為參考:
| 指標 | 數值 | 說明 |
|---|---|---|
| DAU | 5 億 | 每日活躍搜尋用戶 |
| 每用戶日均搜尋 | 10 次 | 每次搜尋觸發多次補全請求 |
| 每次搜尋補全請求 | 15 次 | 平均查詢長度 15 字元,每字元一次 |
| 每日總請求 | 750 億 | 5 億 x 10 x 15 |
| QPS | ~870,000 | 750 億 / 86,400 |
| 峰值 QPS(x3) | ~2,600,000 | 尖峰流量 |
| 唯一搜尋字串數 | ~100 億 | 歷史累積 |
| Trie 記憶體 | ~300 GB | 100 億 x 30B(字串 + 頻率 + 索引) |
| 熱門查詢快取 | ~6 GB | 2,000 萬條 x 300B,可放入 Redis |
結論:核心挑戰是 ~100 萬 QPS 的讀取路徑,而非資料量。必須依賴 多層快取(瀏覽器 → CDN → Redis → Trie Service)來分散壓力。
2. 方案設計
2.1 方案一:純 Trie + 即時遍歷
每次查詢前綴時,在 Trie 中定位到前綴節點,然後 遍歷整個子樹 收集所有完整字串,再用 Min-Heap 篩選 Top-K。
查詢 "app" 的流程:
1. 在 Trie 中走 a → p → p,到達 "app" 節點
2. DFS 遍歷子樹,找到 "apple"(1000), "application"(600), "apply"(450)...
3. 用 Min-Heap(大小 K)篩選出 Top-K 結果
時間複雜度:O(P + N log K),N = 子樹節點數
優點:記憶體最省,更新即時。缺點:熱門前綴(如 “a”)子樹巨大,N 可達數百萬,延遲不可控。
2.2 方案二:Trie + 節點 Top-K 快取(推薦)
核心優化——在每個 Trie 節點上預先快取 Top-K 結果。查詢時只需走到前綴節點,直接讀取已排序的 Top-K 列表。
帶 Top-K 快取的 Trie:
root
├── top_k: ["apple"(1000), "amazon"(900), "ant"(800)...]
│
└── a (節點)
├── top_k: ["apple"(1000), "amazon"(900), "app"(750)...]
│
└── p (節點)
├── top_k: ["apple"(1000), "app"(750), "apply"(600)...]
│
├── p → ...
└── t → ...
查詢 "app" → 走到 "app" 節點 → 直接回傳 top_k 陣列
時間複雜度:O(P) ← 前綴長度,通常只有幾個字元
優點:查詢極快 O(P),適合讀多寫少場景。缺點:插入/更新時需沿路更新所有節點的 Top-K,時間 O(L x K log K)。
2.3 方案三:純雜湊表(前綴 → Top-K)
預先計算所有可能前綴(長度 1 ~ maxLen)對應的 Top-K 結果,存入 HashMap。
HashMap:
"a" → ["apple"(1000), "amazon"(900), ...]
"ap" → ["apple"(1000), "app"(750), ...]
"app" → ["apple"(1000), "app"(750), "apply"(600), ...]
...
優點:查詢 O(1)。缺點:空間爆炸——每條字串產生 L 個前綴鍵,100 億字串 x 平均 15 前綴 = 1,500 億條目。
方案比較
| 策略 | 查詢時間 | 更新時間 | 空間 | 適用場景 |
|---|---|---|---|---|
| 純 Trie + 即時遍歷 | O(P + N log K) | O(L) | 低 | 資料量小、更新頻繁 |
| Trie + 節點快取 Top-K | O(P) | O(L x K) | 中 | 讀多寫少、生產環境 |
| 純雜湊表 | O(1) | O(N) | 高 | 極端讀取、離線更新 |
| Elasticsearch Suggest | O(1) | 非同步 | 高 | 大規模生產環境 |
結論:本文選擇 方案二(Trie + 節點 Top-K 快取),在查詢效能和記憶體之間取得最佳平衡。
3. 核心實作——JavaScript/TypeScript
3.1 型別定義與 MinHeap
// ============================================================
// Autocomplete Service — TypeScript 完整實作
// 包含:Trie + Top-K 快取 + 熱度排序 + 最近搜尋 + 模糊搜尋
// ============================================================
// ─── 型別定義 ───────────────────────────────────────────────
interface SearchSuggestion {
query: string;
score: number; // 熱度分數(搜尋頻率)
source: "trie" | "recent" | "personalized";
}
interface TrieNode {
children: Map<string, TrieNode>;
isEnd: boolean;
topK: SearchSuggestion[]; // 快取此節點前綴的 Top-K 結果
}
// ─── MinHeap(用於 Top-K 選取)───────────────────────────────
class MinHeap<T> {
private heap: T[] = [];
constructor(private comparator: (a: T, b: T) => number) {}
push(item: T): void {
this.heap.push(item);
this.bubbleUp(this.heap.length - 1);
}
pop(): T | undefined {
if (this.heap.length === 0) return undefined;
const top = this.heap[0];
const last = this.heap.pop()!;
if (this.heap.length > 0) {
this.heap[0] = last;
this.sinkDown(0);
}
return top;
}
peek(): T | undefined { return this.heap[0]; }
get size(): number { return this.heap.length; }
private bubbleUp(i: number): void {
while (i > 0) {
const parent = Math.floor((i - 1) / 2);
if (this.comparator(this.heap[i], this.heap[parent]) < 0) {
[this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]];
i = parent;
} else break;
}
}
private sinkDown(i: number): void {
const n = this.heap.length;
while (true) {
let smallest = i;
const left = 2 * i + 1;
const right = 2 * i + 2;
if (left < n && this.comparator(this.heap[left], this.heap[smallest]) < 0)
smallest = left;
if (right < n && this.comparator(this.heap[right], this.heap[smallest]) < 0)
smallest = right;
if (smallest !== i) {
[this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]];
i = smallest;
} else break;
}
}
}
3.2 Trie 核心——前綴樹 + Top-K 快取
// ─── AutocompleteTrie 核心 ──────────────────────────────────
class AutocompleteTrie {
private root: TrieNode;
private readonly K: number;
constructor(k: number = 10) {
this.K = k;
this.root = this.createNode();
}
private createNode(): TrieNode {
return { children: new Map(), isEnd: false, topK: [] };
}
/**
* 插入查詢字串並更新沿路所有節點的 Top-K 快取
* 時間複雜度:O(L × K log K),L = 字串長度
*/
insert(query: string, score: number): void {
const suggestion: SearchSuggestion = {
query,
score,
source: "trie",
};
let node = this.root;
this.updateTopK(node, suggestion); // 更新根節點
for (const char of query.toLowerCase()) {
if (!node.children.has(char)) {
node.children.set(char, this.createNode());
}
node = node.children.get(char)!;
this.updateTopK(node, suggestion); // 更新沿路每個節點
}
node.isEnd = true;
}
/**
* 維護節點的 Top-K 列表
* 若新項目 score 超過當前最小值則替換
*/
private updateTopK(node: TrieNode, suggestion: SearchSuggestion): void {
// 去重:若同查詢已存在則更新分數
const existingIdx = node.topK.findIndex(s => s.query === suggestion.query);
if (existingIdx >= 0) {
node.topK[existingIdx].score = Math.max(
node.topK[existingIdx].score,
suggestion.score
);
node.topK.sort((a, b) => b.score - a.score);
return;
}
// 未滿 K 個則直接加入
if (node.topK.length < this.K) {
node.topK.push({ ...suggestion });
node.topK.sort((a, b) => b.score - a.score);
}
// 分數高於當前最低者則替換
else if (suggestion.score > node.topK[node.topK.length - 1].score) {
node.topK[node.topK.length - 1] = { ...suggestion };
node.topK.sort((a, b) => b.score - a.score);
}
}
/**
* 查詢前綴對應的 Top-K 建議
* 時間複雜度:O(P)——P = 前綴長度(因為節點已快取 Top-K)
*/
search(prefix: string): SearchSuggestion[] {
let node = this.root;
for (const char of prefix.toLowerCase()) {
if (!node.children.has(char)) return [];
node = node.children.get(char)!;
}
return [...node.topK];
}
/**
* 序列化 Trie 以便持久化存儲至 S3/GCS
*/
serialize(): string {
const serializeNode = (node: TrieNode): object => ({
isEnd: node.isEnd,
topK: node.topK,
children: Object.fromEntries(
[...node.children.entries()].map(([k, v]) => [k, serializeNode(v)])
),
});
return JSON.stringify(serializeNode(this.root));
}
}
3.3 AutocompleteService——對外 API 層
// ─── AutocompleteService(對外 API 層)────────────────────────
interface AutocompleteConfig {
maxSuggestions: number; // 最終回傳數量(通常 5~10)
maxRecentSearches: number; // 保存最近搜尋數量
recentSearchBoost: number; // 最近搜尋的分數加成
debounceMs: number; // 前端 Debounce 毫秒數
}
class AutocompleteService {
private trie: AutocompleteTrie;
private recentSearches: Map<string, string[]>; // userId → 最近搜尋
private searchFrequency: Map<string, number>; // 全域搜尋頻率計數
private config: AutocompleteConfig;
// 簡易記憶體快取(生產環境應替換為 Redis)
private cache: Map<string, { results: SearchSuggestion[]; timestamp: number }>;
private readonly CACHE_TTL = 300_000; // 5 分鐘
constructor(config: Partial<AutocompleteConfig> = {}) {
this.config = {
maxSuggestions: 10,
maxRecentSearches: 20,
recentSearchBoost: 500,
debounceMs: 150,
...config,
};
this.trie = new AutocompleteTrie(this.config.maxSuggestions * 2);
this.recentSearches = new Map();
this.searchFrequency = new Map();
this.cache = new Map();
}
/**
* 建立索引——批量插入查詢字串與頻率
* 通常離線執行,每小時更新一次
*/
buildIndex(queries: Array<{ query: string; frequency: number }>): void {
console.log(`[Autocomplete] 建立索引,共 ${queries.length} 筆...`);
const start = Date.now();
for (const { query, frequency } of queries) {
this.trie.insert(query, frequency);
this.searchFrequency.set(query, frequency);
}
console.log(`[Autocomplete] 索引完成,耗時 ${Date.now() - start}ms`);
}
/**
* 查詢補全建議(主要 API)
* 合併 Trie 結果、最近搜尋、個人化排序
*/
getSuggestions(prefix: string, userId?: string): SearchSuggestion[] {
// 空白輸入回傳最近搜尋
if (!prefix || prefix.length === 0) {
return userId ? this.getRecentSearches(userId) : [];
}
const cacheKey = `${userId ?? "anon"}:${prefix.toLowerCase()}`;
// 1. 檢查快取
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.results;
}
// 2. Trie 查詢——O(P)
const trieSuggestions = this.trie.search(prefix);
// 3. 混合最近搜尋(個人化)
let results = this.mergeWithRecentSearches(trieSuggestions, prefix, userId);
// 4. 截取 Top-K
results = results.slice(0, this.config.maxSuggestions);
// 5. 寫入快取
this.cache.set(cacheKey, { results, timestamp: Date.now() });
return results;
}
/**
* 記錄使用者搜尋(更新頻率與最近搜尋)
*/
recordSearch(query: string, userId?: string): void {
// 更新全域頻率(生產環境應發送至 Kafka 非同步處理)
const currentFreq = this.searchFrequency.get(query) ?? 0;
this.searchFrequency.set(query, currentFreq + 1);
// 更新使用者最近搜尋
if (userId) {
const recent = this.recentSearches.get(userId) ?? [];
const filtered = recent.filter(q => q !== query);
this.recentSearches.set(
userId,
[query, ...filtered].slice(0, this.config.maxRecentSearches)
);
}
}
private getRecentSearches(userId: string): SearchSuggestion[] {
const recent = this.recentSearches.get(userId) ?? [];
return recent.map(query => ({
query,
score: this.config.recentSearchBoost,
source: "recent" as const,
}));
}
private mergeWithRecentSearches(
trieSuggestions: SearchSuggestion[],
prefix: string,
userId?: string
): SearchSuggestion[] {
if (!userId) return trieSuggestions;
// 篩選出匹配前綴的最近搜尋
const recentMatches = this.getRecentSearches(userId)
.filter(s => s.query.toLowerCase().startsWith(prefix.toLowerCase()));
// 合併並去重——最近搜尋因 boost 加成會排在前面
const seen = new Set<string>();
const merged: SearchSuggestion[] = [];
for (const suggestion of [...recentMatches, ...trieSuggestions]) {
if (!seen.has(suggestion.query)) {
seen.add(suggestion.query);
merged.push(suggestion);
}
}
return merged.sort((a, b) => b.score - a.score);
}
}
3.4 模糊搜尋——BK-Tree 實作
當使用者輸入 “aple” 時,精確前綴匹配找不到結果,此時需要 模糊搜尋 容忍拼寫錯誤。BK-Tree(Burkhard-Keller Tree) 是基於 Edit Distance 建立的度量樹,能高效查詢「所有與目標字串 Edit Distance <= d 的字串」。
// ─── BK-Tree — 模糊搜尋 ────────────────────────────────────
class BKTree {
private root: { word: string; children: Map<number, any> } | null = null;
/**
* 計算 Levenshtein Edit Distance(動態規劃)
* 時間複雜度:O(m × n)
*/
static editDistance(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(
dp[i - 1][j], // 刪除
dp[i][j - 1], // 插入
dp[i - 1][j - 1] // 替換
);
}
}
}
return dp[m][n];
}
insert(word: string): void {
if (!this.root) {
this.root = { word, children: new Map() };
return;
}
let current = this.root;
while (true) {
const dist = BKTree.editDistance(word, current.word);
if (dist === 0) return; // 已存在
if (!current.children.has(dist)) {
current.children.set(dist, { word, children: new Map() });
return;
}
current = current.children.get(dist);
}
}
/**
* 查詢所有與 target Edit Distance ≤ maxDist 的字串
* BK-Tree 剪枝率通常達 75~90%,遠優於線性掃描
*/
search(target: string, maxDist: number): string[] {
if (!this.root) return [];
const results: string[] = [];
const stack = [this.root];
while (stack.length > 0) {
const node = stack.pop()!;
const dist = BKTree.editDistance(target, node.word);
if (dist <= maxDist) results.push(node.word);
// 剪枝:只探索距離在 [dist-maxDist, dist+maxDist] 範圍的子節點
for (const [childDist, childNode] of node.children) {
if (Math.abs(childDist - dist) <= maxDist) {
stack.push(childNode);
}
}
}
return results.sort();
}
}
3.5 完整使用示範
// ─── 使用示範 ──────────────────────────────────────────────
function demo(): void {
console.log("=== 搜尋自動補全系統 Demo ===\n");
const service = new AutocompleteService({
maxSuggestions: 5,
recentSearchBoost: 800,
});
// 1. 建立索引
service.buildIndex([
{ query: "apple", frequency: 1000 },
{ query: "apple watch", frequency: 850 },
{ query: "apple store", frequency: 720 },
{ query: "apple iphone", frequency: 680 },
{ query: "application", frequency: 600 },
{ query: "apply for job", frequency: 450 },
{ query: "apt-get install", frequency: 300 },
{ query: "apartment", frequency: 280 },
{ query: "amazon", frequency: 950 },
{ query: "amazon prime", frequency: 800 },
{ query: "appetizer", frequency: 200 },
]);
// 2. 記錄使用者最近搜尋
service.recordSearch("apple store", "user_001");
service.recordSearch("apple watch", "user_001");
// 3. 匿名用戶查詢
console.log('\n--- 前綴 "app" 的建議(匿名用戶)---');
const anonResults = service.getSuggestions("app");
anonResults.forEach((s, i) =>
console.log(` ${i + 1}. ${s.query} (score: ${s.score}, source: ${s.source})`)
);
// 輸出:
// 1. apple (score: 1000, source: trie)
// 2. apple watch (score: 850, source: trie)
// 3. apple store (score: 720, source: trie)
// 4. apple iphone (score: 680, source: trie)
// 5. application (score: 600, source: trie)
// 4. 個人化查詢——最近搜尋有 boost 加成
console.log('\n--- 前綴 "app" 的建議(user_001,有個人化)---');
const personalResults = service.getSuggestions("app", "user_001");
personalResults.forEach((s, i) =>
console.log(` ${i + 1}. ${s.query} (score: ${s.score}, source: ${s.source})`)
);
// 輸出:
// 1. apple (score: 1000, source: trie)
// 2. apple watch (score: 800, source: recent) ← boost 加成
// 3. apple store (score: 800, source: recent) ← boost 加成
// 4. apple iphone (score: 680, source: trie)
// 5. application (score: 600, source: trie)
// 5. 模糊搜尋 Demo
console.log("\n--- 模糊搜尋 BK-Tree Demo ---");
const bkTree = new BKTree();
["apple", "application", "apply", "appetizer", "apartment"].forEach(w =>
bkTree.insert(w)
);
const typo = "aple";
const fuzzyResults = bkTree.search(typo, 1);
console.log(` 輸入 "${typo}"(容忍距離=1):${fuzzyResults.join(", ")}`);
// 輸出:輸入 "aple"(容忍距離=1):apple
const typo2 = "appel";
const fuzzyResults2 = bkTree.search(typo2, 2);
console.log(` 輸入 "${typo2}"(容忍距離=2):${fuzzyResults2.join(", ")}`);
// 輸出:輸入 "appel"(容忍距離=2):apple, apply
}
demo();
4. 核心實作——C++
C++ 版本提供兩種 Trie 實作策略的比較:陣列版(cache-friendly,適合英文)與 HashMap 版(支援 Unicode,彈性高)。
4.1 陣列版 Trie(固定字母表)
// ============================================================
// 搜尋自動補全 — C++ 高效實作
// 策略一:陣列版 Trie(固定字母表,cache-friendly)
// ============================================================
#include <array>
#include <string>
#include <vector>
#include <algorithm>
#include <iostream>
#include <chrono>
constexpr int ALPHABET_SIZE = 26;
struct ArrayTrieNode {
std::array<int, ALPHABET_SIZE> children; // 索引代替指標
bool isEnd = false;
int score = 0;
std::vector<std::pair<int, std::string>> topK; // {score, query}
ArrayTrieNode() { children.fill(-1); }
};
class ArrayTrie {
public:
explicit ArrayTrie(int k = 10) : K_(k) {
nodes_.reserve(1'000'000); // 預分配節點池
nodes_.emplace_back(); // root = 節點 0
}
void insert(const std::string& query, int score) {
int cur = 0;
updateTopK(0, query, score);
for (char ch : query) {
if (ch < 'a' || ch > 'z') continue;
int idx = ch - 'a';
if (nodes_[cur].children[idx] == -1) {
nodes_[cur].children[idx] = static_cast<int>(nodes_.size());
nodes_.emplace_back();
}
cur = nodes_[cur].children[idx];
updateTopK(cur, query, score);
}
nodes_[cur].isEnd = true;
nodes_[cur].score = score;
}
std::vector<std::pair<int, std::string>> search(
const std::string& prefix
) const {
int cur = 0;
for (char ch : prefix) {
if (ch < 'a' || ch > 'z') return {};
int idx = ch - 'a';
if (nodes_[cur].children[idx] == -1) return {};
cur = nodes_[cur].children[idx];
}
return nodes_[cur].topK;
}
size_t nodeCount() const { return nodes_.size(); }
private:
void updateTopK(
int nodeIdx, const std::string& query, int score
) {
auto& topK = nodes_[nodeIdx].topK;
// 去重
for (auto& [s, q] : topK) {
if (q == query) {
s = std::max(s, score);
std::sort(topK.begin(), topK.end(),
[](const auto& a, const auto& b) {
return a.first > b.first;
});
return;
}
}
if (static_cast<int>(topK.size()) < K_) {
topK.emplace_back(score, query);
} else if (score > topK.back().first) {
topK.back() = {score, query};
} else {
return;
}
std::sort(topK.begin(), topK.end(),
[](const auto& a, const auto& b) {
return a.first > b.first;
});
}
std::vector<ArrayTrieNode> nodes_;
int K_;
};
4.2 HashMap 版 Trie(支援 Unicode)
// 策略二:HashMap 版 Trie(支援任意字元集)
#include <unordered_map>
#include <memory>
struct HashTrieNode {
std::unordered_map<char, std::unique_ptr<HashTrieNode>> children;
bool isEnd = false;
int score = 0;
std::vector<std::pair<int, std::string>> topK;
};
class HashTrie {
public:
explicit HashTrie(int k = 10)
: K_(k), root_(std::make_unique<HashTrieNode>()) {}
void insert(const std::string& query, int score) {
HashTrieNode* cur = root_.get();
updateTopK(cur, query, score);
for (char ch : query) {
if (!cur->children.count(ch)) {
cur->children[ch] = std::make_unique<HashTrieNode>();
}
cur = cur->children[ch].get();
updateTopK(cur, query, score);
}
cur->isEnd = true;
cur->score = score;
}
std::vector<std::pair<int, std::string>> search(
const std::string& prefix
) const {
const HashTrieNode* cur = root_.get();
for (char ch : prefix) {
auto it = cur->children.find(ch);
if (it == cur->children.end()) return {};
cur = it->second.get();
}
return cur->topK;
}
private:
void updateTopK(
HashTrieNode* node, const std::string& query, int score
) {
auto& topK = node->topK;
for (auto& [s, q] : topK) {
if (q == query) {
s = std::max(s, score);
std::sort(topK.begin(), topK.end(),
[](const auto& a, const auto& b) {
return a.first > b.first;
});
return;
}
}
if (static_cast<int>(topK.size()) < K_) {
topK.emplace_back(score, query);
} else if (score > topK.back().first) {
topK.back() = {score, query};
} else {
return;
}
std::sort(topK.begin(), topK.end(),
[](const auto& a, const auto& b) {
return a.first > b.first;
});
}
std::unique_ptr<HashTrieNode> root_;
int K_;
};
4.3 Edit Distance + 效能測試
// ─── Edit Distance(空間優化版本)──────────────────────────
int editDistance(const std::string& a, const std::string& b) {
int m = static_cast<int>(a.size());
int n = static_cast<int>(b.size());
std::vector<int> prev(n + 1), curr(n + 1);
for (int j = 0; j <= n; ++j) prev[j] = j;
for (int i = 1; i <= m; ++i) {
curr[0] = i;
for (int j = 1; j <= n; ++j) {
if (a[i-1] == b[j-1]) {
curr[j] = prev[j-1];
} else {
curr[j] = 1 + std::min({prev[j], curr[j-1], prev[j-1]});
}
}
std::swap(prev, curr);
}
return prev[n];
}
// ─── 主程式與效能測試 ─────────────────────────────────────
int main() {
std::cout << "=== 搜尋自動補全 C++ 效能測試 ===\n\n";
// 功能測試
ArrayTrie trie;
std::vector<std::pair<std::string, int>> data = {
{"apple", 1000}, {"apple watch", 850},
{"application", 600}, {"apply", 450},
{"apt", 300}, {"amazon", 950}
};
for (const auto& [q, s] : data) trie.insert(q, s);
std::cout << "前綴 \"app\" 的 Top-5 建議:\n";
auto results = trie.search("app");
for (int i = 0; i < static_cast<int>(results.size()); ++i) {
std::cout << " " << (i+1) << ". " << results[i].second
<< " (score: " << results[i].first << ")\n";
}
// 輸出:
// 1. apple (score: 1000)
// 2. apple watch (score: 850)
// 3. application (score: 600)
// 4. apply (score: 450)
// 5. apt (score: 300)
std::cout << "\nEdit Distance(\"aple\", \"apple\") = "
<< editDistance("aple", "apple") << "\n";
// 輸出:Edit Distance("aple", "apple") = 1
// 效能基準測試——10 萬筆資料
using Clock = std::chrono::high_resolution_clock;
std::vector<std::pair<std::string, int>> testData;
const std::string chars = "abcdefghijklmnopqrstuvwxyz";
for (int i = 0; i < 100'000; ++i) {
std::string query;
int len = 5 + rand() % 10;
for (int j = 0; j < len; ++j) query += chars[rand() % 26];
testData.emplace_back(query, rand() % 10000);
}
ArrayTrie benchTrie;
auto t0 = Clock::now();
for (const auto& [q, s] : testData) benchTrie.insert(q, s);
auto t1 = Clock::now();
int queries = 10000;
for (int i = 0; i < queries; ++i) {
auto prefix = testData[i % testData.size()].first.substr(0, 3);
auto r = benchTrie.search(prefix);
}
auto t2 = Clock::now();
auto insertMs = std::chrono::duration_cast<
std::chrono::milliseconds>(t1 - t0).count();
auto searchMs = std::chrono::duration_cast<
std::chrono::milliseconds>(t2 - t1).count();
std::cout << "\n--- 效能基準測試(ArrayTrie)---\n";
std::cout << "插入 100,000 筆: " << insertMs << "ms\n";
std::cout << "查詢 10,000 次: " << searchMs << "ms\n";
std::cout << "節點數: " << benchTrie.nodeCount() << "\n";
return 0;
}
兩種實作策略比較
| 特性 | ArrayTrie | HashTrie |
|---|---|---|
| 字母表限制 | 固定(英文 26 字元) | 任意字符 |
| 記憶體布局 | 連續陣列(cache-friendly) | 分散(pointer chasing) |
| Cache 命中率 | 高 | 低 |
| 每節點記憶體 | 26 x 4B = 104B(固定) | 動態分配(平均更少) |
| 空節點浪費 | 高(26 slots 固定) | 無(按需分配) |
| 查詢速度 | O(L),常數極小 | O(L),常數較大 |
| Unicode 支援 | 需特殊處理 | 原生支援 |
| 適用場景 | 英文搜尋、效能敏感 | 多語言、字元集未知 |
5. 效能分析
時間複雜度
| 操作 | 純 Trie | Trie + Top-K 快取 | 說明 |
|---|---|---|---|
| 插入 | O(L) | O(L x K log K) | L = 字串長度,K = Top-K 大小 |
| 前綴查詢 | O(P + N log K) | O(P) | P = 前綴長度,N = 子樹節點數 |
| 模糊查詢(BK-Tree) | — | O(N^(1-d/Sigma)) | 遠優於線性掃描 O(N) |
空間複雜度
| 結構 | 空間 | 說明 |
|---|---|---|
| Trie 節點 | O(N x Sigma) | N = 節點數,Sigma = 字母表大小 |
| Top-K 快取 | O(N x K) | 每節點存 K 個建議 |
| BK-Tree | O(M) | M = 詞彙數量 |
| Redis 快取 | ~6 GB | 2,000 萬熱門前綴 |
延遲基準(參考值)
| 路徑 | 延遲 | 說明 |
|---|---|---|
| 瀏覽器本地快取命中 | < 1ms | Service Worker / localStorage |
| CDN 邊緣快取命中 | 5 ~ 20ms | 地理就近節點 |
| Redis 快取命中 | 1 ~ 5ms | 記憶體讀取 |
| Trie Service 查詢 | 1 ~ 10ms | 記憶體中 O(P) 查詢 |
| 端到端(含網路) | 30 ~ 100ms | 全鏈路含序列化 |
6. 生產環境考量
6.1 整體架構
┌─────────────────────────────────────────────────────────────┐
│ Client(瀏覽器/App) │
│ [input onChange] ── Debounce(150ms) ──→ GET /suggest?q=XXX │
└──────────────────────────┬──────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ CDN / Edge Cache │
│ (Cloudflare / Fastly — 快取熱門前綴 TTL=60s) │
└──────────────────────────┬──────────────────────────────────┘
│ Cache Miss
▼
┌─────────────────────────────────────────────────────────────┐
│ API Gateway / Load Balancer │
│ (Nginx / AWS ALB — 路由、限流、SSL 終止) │
└──────────┬──────────────────────────────────────────────────┘
│
┌─────┴──────┐
▼ ▼
┌─────────┐ ┌─────────┐ ┌──────────────────────────┐
│Suggest │ │Suggest │ │ Redis Cluster │
│Service │ │Service │ ←──→ │ 前綴→Top-K 映射 │
│(TS/Go)│ │(TS/Go)│ │ TTL=300s,記憶體 6GB │
└─────────┘ └─────────┘ └──────────────────────────┘
│
│ Cache Miss
▼
┌─────────────────────────────────────────────────────────────┐
│ Trie Service │
│ (讀取只讀 Trie 索引,部署在高記憶體機器) │
│ 每小時從離線管線重建索引(Atomic Swap) │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────┐
│ 離線資料管線(Offline Pipeline) │
│ │
│ 搜尋日誌 → Kafka → Flink/Spark │
│ (收集事件) (串流聚合頻率) │
│ ↓ │
│ 頻率資料庫(PostgreSQL / BigQuery) │
│ ↓ │
│ Trie Builder(每小時執行) │
│ ↓ │
│ 序列化 Trie 存儲(S3 / GCS) │
│ ↓ │
│ 通知 Trie Service 熱重載 │
└──────────────────────────────────────┘
6.2 前端 Debounce 策略
前端不應每輸入一個字元就發送請求。使用 Debounce 可以將 QPS 降低 70~80%。
使用者輸入 "apple":
無 Debounce(5 次請求):
a → ap → app → appl → apple 共 5 次 HTTP 請求
有 Debounce 150ms(1~2 次請求):
使用者停止輸入 150ms 後才發送 → 大幅減少伺服器壓力
6.3 分散式 Trie 分片
當單機記憶體無法容納完整 Trie 時(例如 300GB),需要按首字母分片:
Trie Cluster(按首字母分片):
a-f: Node 1(~50GB RAM)
g-l: Node 2(~50GB RAM)
m-r: Node 3(~50GB RAM)
s-z: Node 4(~50GB RAM)
路由邏輯:
prefix = "apple" → 首字母 'a' → 路由到 Node 1
prefix = "google" → 首字母 'g' → 路由到 Node 2
使用 一致性雜湊(Consistent Hashing) 進行路由,當節點擴縮時只需重新分配少量分片。
6.4 多層快取策略
| 層級 | 技術 | TTL | 命中率 | 作用 |
|---|---|---|---|---|
| 瀏覽器快取 | localStorage / Service Worker | 5 分鐘 | 30~50% | 完全不發送請求 |
| CDN 邊緣快取 | Cloudflare / Fastly | 60 秒 | 50~70% | 地理就近回應 |
| Redis 快取 | Redis Cluster | 5 分鐘 | 80~90% | 伺服器端記憶體快取 |
| Trie Service | 記憶體內 Trie | 永久 | 100% | 最後防線 |
經過多層快取後,實際到達 Trie Service 的請求量僅為原始 QPS 的 1~5%。
6.5 即時趨勢更新
當某個突發事件(如大地震、體育賽事)導致搜尋趨勢驟變時,離線每小時更新的頻率可能不夠即時。解決方案是 雙路合併:
- 離線路徑(Offline):每小時重建 Trie,涵蓋長期穩定的搜尋頻率
- 即時路徑(Real-time):透過 Kafka + Flink 串流計算最近 10 分鐘的熱門搜尋,結果存入 Redis
- 合併:Suggest Service 查詢時同時讀取 Trie 和 Redis 的即時熱門列表,合併排序後回傳
6.6 CJK(中日韓)支援
中文自動補全面臨三個特殊挑戰:
- 字元集龐大:中文常用字約 6,000 個,不能用固定大小陣列。解法:使用 HashMap 作為子節點容器
- 輸入法映射:使用者輸入拼音 “bei”,系統應建議 “北京”、“背包”。解法:同時建立漢字和拼音的 Trie,查詢時合併
- 分詞問題:中文沒有空格分隔詞彙。解法:使用分詞工具(如 jieba)預處理後再建索引
6.7 優化要點總覽
| 優化維度 | 具體手段 | 預期效益 |
|---|---|---|
| 前端 Debounce | 150ms 防抖 | QPS 降低 70~80% |
| CDN 邊緣快取 | 熱門前綴快取 | 延遲降低 50~80% |
| Redis 快取層 | 前綴→Top-K,TTL=5 分鐘 | DB 壓力降低 90% |
| Trie 節點 Top-K | 避免子樹遍歷,O(P) 查詢 | 速度提升 10~100 倍 |
| 離線建索引 | 頻率離線更新 | 讀寫完全解耦 |
| 分片路由 | 按首字母分片 | 線性水平擴展 |
| 模糊搜尋降級 | 精確匹配無結果時才啟動 BK-Tree | 90% 走快速路徑 |
7. LeetCode 練習
以下是與搜尋自動補全相關的經典題目,建議按順序練習:
題目一:LeetCode 642 — Design Search Autocomplete System
| 項目 | 內容 |
|---|---|
| 難度 | Hard |
| 連結 | LeetCode 642 |
| 核心 | Trie + 頻率排序,核心即本文實作 |
| 提示 | 注意特殊字元 ‘#’ 作為搜尋結束信號,觸發 recordSearch |
題目二:LeetCode 208 — Implement Trie (Prefix Tree)
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 208 |
| 核心 | Trie 基礎實作——insert、search、startsWith |
| 提示 | 本題是 642 的前置練習,先確保 Trie 基礎紮實 |
題目三:LeetCode 1268 — Search Suggestions System
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 1268 |
| 核心 | 對每個前綴回傳字典序最小的 3 個建議 |
| 提示 | 可用 Trie + DFS,也可用排序 + Binary Search |
題目四:LeetCode 72 — Edit Distance
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 72 |
| 核心 | Levenshtein Distance 的 DP 實作 |
| 提示 | 這是模糊搜尋的理論基石——BK-Tree 的核心度量函數 |
練習建議:先完成 208(Trie 基礎),再挑戰 642(完整 Autocomplete System)。1268 是簡化版的好練習。72 則幫助理解模糊搜尋的 Edit Distance 原理。在面試中,642 是系統設計類題目的高頻考題,建議在白板上先畫出 Trie 結構圖和 Top-K 更新流程,再開始寫程式碼。
8. 總結
在這篇文章中,我們從需求分析出發,完整設計並實作了一套搜尋自動補全系統:
Trie + Top-K 快取:這是自動補全的核心引擎。透過在每個 Trie 節點預先快取 Top-K 結果,將查詢時間從 O(P + N log K) 降至 O(P),P 通常只有幾個字元,幾乎是常數時間。
模糊搜尋(BK-Tree):當精確前綴匹配找不到結果時,BK-Tree 利用三角不等式剪枝,高效查詢 Edit Distance 在容忍範圍內的候選字串,剪枝率達 75~90%。
個人化建議:透過維護使用者的最近搜尋記錄,並在合併排序時給予 boost 加成,讓每個使用者看到個人化的建議列表。
生產環境架構:多層快取(瀏覽器 → CDN → Redis → Trie Service)將 260 萬 QPS 的壓力逐層消化;離線管線與即時管線雙路合併,兼顧穩定性與即時性;按首字母分片實現水平擴展。
搜尋自動補全是 Trie、Heap、快取策略、分散式系統設計的綜合應用。理解了這個系統的設計原理後,你不僅能在面試中從容應對 LeetCode 642 等題目,還能在實際工作中為搜尋體驗帶來質的提升。
在下一篇文章中,我們將探討另一個經典的系統設計問題——
設計任務排程器(Task Scheduler),學習如何用優先佇列和排程策略實現高效的任務管理系統,敬請期待!
FAQ
Q: Autocomplete 和全文搜尋(Full-Text Search)有什麼區別?
兩者解決的問題不同。Autocomplete 是前綴匹配——使用者輸入 “app” 時,系統回傳以 “app” 開頭的建議,核心資料結構是 Trie,回傳速度要求極高(< 100ms)。全文搜尋 則是關鍵字匹配——使用者輸入 “how to cook pasta” 時,系統回傳內容中包含這些關鍵字的文件,核心技術是 倒排索引(Inverted Index),如 Elasticsearch。在實際產品中,兩者通常協作:使用者打字時 Autocomplete 提供即時建議,按下 Enter 後全文搜尋回傳完整結果。
Q: 為什麼要在 Trie 每個節點快取 Top-K 結果?這不是浪費記憶體嗎?
這是一個典型的 空間換時間 的權衡。不快取 Top-K 時,每次查詢需遍歷整個子樹,時間為 O(P + N log K),熱門前綴如 “a” 可能有數百萬個結果。快取後查詢降為 O(P),對 < 100ms 的即時建議至關重要。記憶體方面,假設每節點存 10 個建議(約 300 bytes),100 萬個節點的額外開銷約 300MB,伺服器端完全可接受。而且 Trie 通常離線建構、批量更新,寫入效能不是瓶頸。
Q: 如何處理中日韓(CJK)等多位元組字元的自動補全?
CJK 的挑戰主要有三:(1) 字元集龐大——中文常用字約 6,000 個,不能用固定陣列 children[26],應使用 HashMap 作為子節點容器。(2) 輸入法映射——使用者輸入拼音 “bei”,系統應建議 “北京”,做法是同時建立漢字和拼音的 Trie 索引。(3) 分詞問題——中文沒有空格分隔詞彙,需先用分詞工具(如 jieba)處理。生產環境推薦使用 Elasticsearch 的 Completion Suggester,它內建 CJK 分析器,能較好地處理這些問題。