演算法圖鑑
Tree · 05 / 07

Trie字典樹

每層一個字元,共用前綴

用在:自動補全、拼字檢查、IP 路由表

時間複雜度O(L)
空間複雜度O(ΣL)
難度進階
前置知識Hash Table、Traversal

01為什麼需要它

搜尋列的自動補全

使用者打了「alg」,要立刻列出所有以 alg 開頭的詞。字典有幾十萬個詞,每次都掃一遍太慢;雜湊表又只能查完整的鍵。

為什麼用它字典樹把共用前綴的詞疊在同一條路徑上。走 3 步到達「alg」,它底下的所有葉就是答案,成本和字典大小無關。

拼字檢查與敏感詞過濾

一篇文章的每個字都要查「在不在字典裡」,或掃一段文字看有沒有出現任何一個敏感詞。

為什麼用它查一個長度 L 的字只要 L 步。多個模式一起比對時,把所有模式建成一棵樹,掃文字時一次對照全部,這是 Aho-Corasick 的基礎。

路由器的 IP 查表

路由表有幾十萬條規則,每個封包要找「最長前綴匹配」的那一條,而且每秒要處理百萬個封包。

為什麼用它把 IP 當成位元字串放進字典樹,沿著封包的位元往下走,走到最深的有效節點就是最長前綴。這是二元字典樹(radix tree)的經典用途。

看到這些關鍵字就想到它:前綴、開頭是、自動補全、多個字串共用前綴、最長前綴匹配、字典。

02核心概念

字典樹(Trie,來自 retrieval)是一棵邊上有字元的樹。從根出發,沿著邊把字元串起來,走到任何一個節點,路徑就是一個前綴。共用前綴的字串共用路徑:car、cat、cart 只需要一條 c-a 的路,之後才分岔。每個節點另外有一個結尾標記,表示「有一個字串剛好在這裡結束」,這樣才能區分「ca 只是前綴」和「car 是一個字」。

插入、查詢、判斷前綴,都是沿著字串的每個字元往下走一步,成本是字串長度 O(L),和樹裡有多少字串無關。這是它和雜湊表的差別:雜湊表也能 O(L) 查一個完整的字,但它對前綴一無所知;字典樹走到前綴那個節點之後,底下的整棵子樹都是答案。

代價在空間。每個節點要存子節點表:字元集小(26 個小寫字母)就用固定陣列,查一步是 O(1) 但每個節點 26 個指標;字元集大(Unicode)就用雜湊表,省空間但慢一點。實務上還會做壓縮,把只有一個子節點的鏈合併成一段字串,那就是 radix tree,路由表和許多檔案系統用的就是它。

03演算法步驟

  1. 1節點結構:一個子節點表(dict 或長度 26 的陣列)加一個 is_end 布林。根對應空字串。
  2. 2插入:從根開始,對每個字元,沒有對應的子節點就建一個,然後走過去。最後一個節點標 is_end = True
  3. 3查單字:沿字元走,任何一步走不下去就是不存在;走完還要檢查 is_end查前綴:只要走得完就算有。
  4. 4自動補全:先走到前綴的節點,再對那棵子樹做 DFS,遇到 is_end 就收集一個字。
  5. 5字元集固定且小時用陣列存子節點,查得快;否則用雜湊表。字串非常多時考慮壓縮成 radix tree。

04互動示範

插入 car、cat、cart、dog,看共用的 c-a 路徑怎麼被重複利用。接著查前綴 ca 做自動補全,再分別查 ca 與 cart 是不是完整的字。綠色節點是有結尾標記的。

開始插入 car, cat, cart, dog → 前綴 ca → 查單字 ca、cart
·
步驟 0/29字典樹的根是空字串。每往下一層就多一個字元,一條從根到某節點的路徑就是一個前綴。

05程式碼

插入、查單字、查前綴與自動補全。Python 版用 dict 存子節點,C++ 版示範小寫字母用固定陣列的寫法。

class TrieNode:
    def __init__(self):
        self.children = {}        # 字元 -> TrieNode
        self.is_end = False       # 有沒有單字在這裡結束


class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for ch in word:
            if ch not in node.children:          # 沒有這條邊就開一條
                node.children[ch] = TrieNode()
            node = node.children[ch]
        node.is_end = True

    def _walk(self, s):
        """沿著 s 走到底,走不下去回傳 None"""
        node = self.root
        for ch in s:
            node = node.children.get(ch)
            if node is None:
                return None
        return node

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.is_end

    def starts_with(self, prefix):
        return self._walk(prefix) is not None

    def autocomplete(self, prefix):
        """列出所有以 prefix 開頭的單字:先走到前綴,再 DFS 收集"""
        node = self._walk(prefix)
        out = []
        def dfs(n, path):
            if n.is_end:
                out.append(path)
            for ch, child in sorted(n.children.items()):
                dfs(child, path + ch)
        if node:
            dfs(node, prefix)
        return out


t = Trie()
for w in ["car", "cat", "cart", "dog"]:
    t.insert(w)
print(t.search("ca"), t.starts_with("ca"))   # False True
print(t.autocomplete("ca"))                  # ['car', 'cart', 'cat']

06練習題

  • LeetCode 208Implement Trie (Prefix Tree)Medium
  • LeetCode 211Design Add and Search Words(含萬用字元的 DFS)Medium
  • LeetCode 1268Search Suggestions System(自動補全)Medium
  • LeetCode 212Word Search II(字典樹 + 網格回溯)Hard
  • LeetCode 648Replace Words(最短前綴)Medium