設計搜尋自動補全 — Trie、Top-K 排序與分散式即時建議系統 | 資料結構與演算法

2026/07/25
設計搜尋自動補全 — Trie、Top-K 排序與分散式即時建議系統 | 資料結構與演算法

搜尋自動補全(Search Autocomplete) 是現代搜尋體驗的核心功能——使用者每輸入一個字元,系統即時回傳最相關的候選建議。本文將從需求分析出發,深入設計以 Trie(前綴樹) 結合 Top-K 排序 為核心的自動補全系統,涵蓋 頻率追蹤個人化建議模糊搜尋(BK-Tree)分散式架構多層快取策略,搭配 JavaScript/TypeScriptC++ 雙語言完整實作,帶你徹底掌握搜尋建議系統的設計與實戰。

前言

在上一篇文章中,我們實作了

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 搜尋規模為參考:

指標數值說明
DAU5 億每日活躍搜尋用戶
每用戶日均搜尋10 次每次搜尋觸發多次補全請求
每次搜尋補全請求15 次平均查詢長度 15 字元,每字元一次
每日總請求750 億5 億 x 10 x 15
QPS~870,000750 億 / 86,400
峰值 QPS(x3)~2,600,000尖峰流量
唯一搜尋字串數~100 億歷史累積
Trie 記憶體~300 GB100 億 x 30B(字串 + 頻率 + 索引)
熱門查詢快取~6 GB2,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-KO(P)O(L x K)讀多寫少、生產環境
純雜湊表O(1)O(N)極端讀取、離線更新
Elasticsearch SuggestO(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;
}

兩種實作策略比較

特性ArrayTrieHashTrie
字母表限制固定(英文 26 字元)任意字符
記憶體布局連續陣列(cache-friendly)分散(pointer chasing)
Cache 命中率
每節點記憶體26 x 4B = 104B(固定)動態分配(平均更少)
空節點浪費高(26 slots 固定)無(按需分配)
查詢速度O(L),常數極小O(L),常數較大
Unicode 支援需特殊處理原生支援
適用場景英文搜尋、效能敏感多語言、字元集未知

5. 效能分析

時間複雜度

操作純 TrieTrie + 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-TreeO(M)M = 詞彙數量
Redis 快取~6 GB2,000 萬熱門前綴

延遲基準(參考值)

路徑延遲說明
瀏覽器本地快取命中< 1msService 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 Worker5 分鐘30~50%完全不發送請求
CDN 邊緣快取Cloudflare / Fastly60 秒50~70%地理就近回應
Redis 快取Redis Cluster5 分鐘80~90%伺服器端記憶體快取
Trie Service記憶體內 Trie永久100%最後防線

經過多層快取後,實際到達 Trie Service 的請求量僅為原始 QPS 的 1~5%

6.5 即時趨勢更新

當某個突發事件(如大地震、體育賽事)導致搜尋趨勢驟變時,離線每小時更新的頻率可能不夠即時。解決方案是 雙路合併

  1. 離線路徑(Offline):每小時重建 Trie,涵蓋長期穩定的搜尋頻率
  2. 即時路徑(Real-time):透過 Kafka + Flink 串流計算最近 10 分鐘的熱門搜尋,結果存入 Redis
  3. 合併:Suggest Service 查詢時同時讀取 Trie 和 Redis 的即時熱門列表,合併排序後回傳

6.6 CJK(中日韓)支援

中文自動補全面臨三個特殊挑戰:

  1. 字元集龐大:中文常用字約 6,000 個,不能用固定大小陣列。解法:使用 HashMap 作為子節點容器
  2. 輸入法映射:使用者輸入拼音 “bei”,系統應建議 “北京”、“背包”。解法:同時建立漢字和拼音的 Trie,查詢時合併
  3. 分詞問題:中文沒有空格分隔詞彙。解法:使用分詞工具(如 jieba)預處理後再建索引

6.7 優化要點總覽

優化維度具體手段預期效益
前端 Debounce150ms 防抖QPS 降低 70~80%
CDN 邊緣快取熱門前綴快取延遲降低 50~80%
Redis 快取層前綴→Top-K,TTL=5 分鐘DB 壓力降低 90%
Trie 節點 Top-K避免子樹遍歷,O(P) 查詢速度提升 10~100 倍
離線建索引頻率離線更新讀寫完全解耦
分片路由按首字母分片線性水平擴展
模糊搜尋降級精確匹配無結果時才啟動 BK-Tree90% 走快速路徑

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. 總結

在這篇文章中,我們從需求分析出發,完整設計並實作了一套搜尋自動補全系統:

  1. Trie + Top-K 快取:這是自動補全的核心引擎。透過在每個 Trie 節點預先快取 Top-K 結果,將查詢時間從 O(P + N log K) 降至 O(P),P 通常只有幾個字元,幾乎是常數時間。

  2. 模糊搜尋(BK-Tree):當精確前綴匹配找不到結果時,BK-Tree 利用三角不等式剪枝,高效查詢 Edit Distance 在容忍範圍內的候選字串,剪枝率達 75~90%。

  3. 個人化建議:透過維護使用者的最近搜尋記錄,並在合併排序時給予 boost 加成,讓每個使用者看到個人化的建議列表。

  4. 生產環境架構:多層快取(瀏覽器 → 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 分析器,能較好地處理這些問題。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →