什么是前綴樹?
前綴樹是N叉樹的一種特殊形式。通常來說,一個前綴樹是用來存儲字符串的。前綴樹的每一個節點代表一個字符串(前綴)。每一個節點會有多個子節點,通往不同子節點的路徑上有着不同的字符。子節點代表的字符串是由節點本身的原始字符串,以及通往該子節點路徑上所有的字符組成的。
下面是前綴樹的一個例子:

在上圖示例中,我們在節點中標記的值是該節點對應表示的字符串。例如,我們從根節點開始,選擇第二條路徑 ‘b’,然后選擇它的第一個子節點 ‘a’,接下來繼續選擇子節點 ‘d’,我們最終會到達葉節點 “bad”。節點的值是由從根節點開始,與其經過的路徑中的字符按順序形成的。
值得注意的是,根節點表示空字符串。
前綴樹的一個重要的特性是,節點所有的后代都與該節點相關的字符串有着共同的前綴。這就是前綴樹名稱的由來。
我們再來看這個例子。例如,以節點 “b” 為根的子樹中的節點表示的字符串,都具有共同的前綴 “b”。反之亦然,具有公共前綴 “b” 的字符串,全部位於以 “b” 為根的子樹中,並且具有不同前綴的字符串來自不同的分支。
前綴樹有着廣泛的應用,例如自動補全,拼寫檢查等等。我將在后面的章節中介紹實際應用場景。
如何表示一個前綴樹?
在前面的文章中,我們介紹了前綴樹的概念。在這篇文章中,我們將討論如何用代碼表示這個數據結構。
在閱讀一下內容前,請簡要回顧N叉樹的節點結構。
//Definition for a Node.
class Node {
public int val;
public List<Node> children;
public Node() {}
public Node(int _val) {
val = _val;
}
public Node(int _val, List<Node> _children) {
val = _val;
children = _children;
}
};
前綴樹的特別之處在於字符和子節點之間的對應關系。有許多不同的表示前綴樹節點的方法,這里我們只介紹其中的兩種方法。
方法一 - 數組
第一種方法是用數組存儲子節點。
例如,如果我們只存儲含有字母 a 到 z 的字符串,我們可以在每個節點中聲明一個大小為26的數組來存儲其子節點。對於特定字符 c,我們可以使用 c - 'a' 作為索引來查找數組中相應的子節點。
class TrieNode {
// change this value to adapt to different cases
public static final int N = 26;
public TrieNode[] children = new TrieNode[N];
// you might need some extra values according to different cases
};
/** Usage: * Initialization: TrieNode root = new TrieNode(); * Return a specific child node with char c: root.children[c - 'a'] */
訪問子節點十分快捷。訪問一個特定的子節點比較容易,因為在大多數情況下,我們很容易將一個字符轉換為索引。但並非所有的子節點都需要這樣的操作,所以這可能會導致空間的浪費。
方法二 - Map
第二種方法是使用 Hashmap 來存儲子節點。
我們可以在每個節點中聲明一個Hashmap。Hashmap的鍵是字符,值是相對應的子節點。
class TrieNode {
public Map<Character, TrieNode> children = new HashMap<>();
// you might need some extra values according to different cases
};
/** Usage: * Initialization: TrieNode root = new TrieNode(); * Return a specific child node with char c: root.children.get(c) */
通過相應的字符來訪問特定的子節點更為容易。但它可能比使用數組稍慢一些。但是,由於我們只存儲我們需要的子節點,因此節省了空間。這個方法也更加靈活,因為我們不受到固定長度和固定范圍的限制。
補充
我們已經提到過如何表示前綴樹中的子節點。除此之外,我們也需要用到一些其他的值。
例如,我們知道,前綴樹的每個節點表示一個字符串,但並不是所有由前綴樹表示的字符串都是有意義的。如果我們只想在前綴樹中存儲單詞,那么我們可能需要在每個節點中聲明一個布爾值(Boolean)作為標志,來表明該節點所表示的字符串是否為一個單詞。
前綴樹插入
我們已經在另一篇文章中討論了 (如何在二叉搜索樹中實現插入操作)。
提問:
你還記得如何在二叉搜索樹中插入一個新的節點嗎?
當我們在二叉搜索樹中插入目標值時,在每個節點中,我們都需要根據 節點值 和 目標值 之間的關系,來確定目標值需要去往哪個子節點。同樣地,當我們向前綴樹中插入一個目標值時,我們也需要根據插入的 目標值 來決定我們的路徑。
更具體地說,如果我們在前綴樹中插入一個字符串 S,我們要從根節點開始。 我們將根據 S[0](S中的第一個字符),選擇一個子節點或添加一個新的子節點。然后到達第二個節點,並根據 S[1] 做出選擇。 再到第三個節點,以此類推。 最后,我們依次遍歷 S 中的所有字符並到達末尾。 末端節點將是表示字符串 S 的節點。
下面是一個例子:





我們來用偽代碼總結一下以上策略:
1. Initialize: cur = root
2. for each char c in target string S:
3. if cur does not have a child c:
4. cur.children[c] = new Trie node
5. cur = cur.children[c]
6. cur is the node which represents the string S
通常情況情況下,你需要自己構建前綴樹。構建前綴樹實際上就是多次調用插入函數。但請記住在插入字符串之前要 初始化根節點 。
前綴樹搜索
搜索前綴
正如我們在前綴樹的簡介中提到的,所有節點的后代都與該節點相對應字符串的有着共同前綴。因此,很容易搜索以特定前綴開頭的任何單詞。
同樣地,我們可以根據給定的前綴沿着樹形結構搜索下去。一旦我們找不到我們想要的子節點,搜索就以失敗終止。否則,搜索成功。
我們來用偽代碼總結一下以上策略:
1. Initialize: cur = root
2. for each char c in target string S:
3. if cur does not have a child c:
4. search fails
5. cur = cur.children[c]
6. search successes
搜索單詞
你可能還想知道如何搜索特定的單詞,而不是前綴。我們可以將這個詞作為前綴,並同樣按照上述同樣的方法進行搜索。
- 如果搜索失敗,那么意味着沒有單詞以目標單詞開頭,那么目標單詞絕對不會存在於前綴樹中。
- 如果搜索成功,我們需要檢查目標單詞是否是前綴樹中單詞的前綴,或者它本身就是一個單詞。為了進一步解決這個問題,你可能需要稍對節點的結構做出修改。
提示:往每個節點中加入布爾值可能會有效地幫助你解決這個問題。
應用
Trie (發音為 “try”) 或前綴樹是一種樹數據結構,用於檢索字符串數據集中的鍵。這一高效的數據結構有多種應用:
1. 自動補全

圖 1. 谷歌的搜索建議
2. 拼寫檢查

圖2. 文字處理軟件中的拼寫檢查
3. IP 路由 (最長前綴匹配)

圖 3. 使用Trie樹的最長前綴匹配算法,Internet 協議(IP)路由中利用轉發表選擇路徑。
4. T9 (九宮格) 打字預測

圖 4. T9(九宮格輸入),在 20 世紀 90 年代常用於手機輸入
5. 單詞游戲

圖 5. Trie 樹可通過剪枝搜索空間來高效解決 Boggle 單詞游戲
還有其他的數據結構,如平衡樹和哈希表,使我們能夠在字符串數據集中搜索單詞。為什么我們還需要 Trie 樹呢?盡管哈希表可以在 O(1)時間內尋找鍵值,卻無法高效的完成以下操作:
- 找到具有同一前綴的全部鍵值。
- 按詞典序枚舉字符串的數據集。
Trie 樹優於哈希表的另一個理由是,隨着哈希表大小增加,會出現大量的沖突,時間復雜度可能增加到 O(n),其中 n 是插入的鍵的數量。與哈希表相比,Trie 樹在存儲多個具有相同前綴的鍵時可以使用較少的空間。此時 Trie 樹只需要 O(m) 的時間復雜度,其中 mm 為鍵長。而在平衡樹中查找鍵值需要 O*(mlog*n) 時間復雜度。
完整實現
Trie 樹的結點結構
Trie 樹是一個有根的樹,其結點具有以下字段:。
- 最多 R 個指向子結點的鏈接,其中每個鏈接對應字母表數據集中的一個字母。
本文中假定 R 為 26,小寫拉丁字母的數量。 - 布爾字段,以指定節點是對應鍵的結尾還是只是鍵前綴。

圖 6. 單詞 “leet” 在 Trie 樹中的表示
class TrieNode {
// R links to node children
private TrieNode[] links;
private final int R = 26;
private boolean isEnd;
public TrieNode() {
links = new TrieNode[R];
}
public boolean containsKey(char ch) {
return links[ch -'a'] != null;
}
public TrieNode get(char ch) {
return links[ch -'a'];
}
public void put(char ch, TrieNode node) {
links[ch -'a'] = node;
}
public void setEnd() {
isEnd = true;
}
public boolean isEnd() {
return isEnd;
}
}
向 Trie 樹中插入鍵
我們通過搜索 Trie 樹來插入一個鍵。我們從根開始搜索它對應於第一個鍵字符的鏈接。有兩種情況:
- 鏈接存在。沿着鏈接移動到樹的下一個子層。算法繼續搜索下一個鍵字符。
- 鏈接不存在。創建一個新的節點,並將它與父節點的鏈接相連,該鏈接與當前的鍵字符相匹配。
重復以上步驟,直到到達鍵的最后一個字符,然后將當前節點標記為結束節點,算法完成。

圖 7. 向 Trie 樹中插入鍵
class Trie {
private TrieNode root;
public Trie() {
root = new TrieNode();
}
// Inserts a word into the trie.
public void insert(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char currentChar = word.charAt(i);
if (!node.containsKey(currentChar)) {
node.put(currentChar, new TrieNode());
}
node = node.get(currentChar);
}
node.setEnd();
}
}
復雜度分析
- 時間復雜度:O(m),其中 mm 為鍵長。在算法的每次迭代中,我們要么檢查要么創建一個節點,直到到達鍵尾。只需要 mm 次操作。
- 空間復雜度:O(m)。最壞的情況下,新插入的鍵和 Trie 樹中已有的鍵沒有公共前綴。此時需要添加 m個結點,使用 O(m)空間。
在 Trie 樹中查找鍵
每個鍵在 trie 中表示為從根到內部節點或葉的路徑。我們用第一個鍵字符從根開始,。檢查當前節點中與鍵字符對應的鏈接。有兩種情況:
-
存在鏈接。我們移動到該鏈接后面路徑中的下一個節點,並繼續搜索下一個鍵字符。
-
不存在鏈接。若已無鍵字符,且當前結點標記為
isEnd,則返回 true。否則有兩種可能,均返回 false :
- 還有鍵字符剩余,但無法跟隨 Trie 樹的鍵路徑,找不到鍵。
- 沒有鍵字符剩余,但當前結點沒有標記為
isEnd。也就是說,待查找鍵只是Trie樹中另一個鍵的前綴。

圖 8. 在 Trie 樹中查找鍵
class Trie {
...
// search a prefix or whole key in trie and
// returns the node where search ends
private TrieNode searchPrefix(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char curLetter = word.charAt(i);
if (node.containsKey(curLetter)) {
node = node.get(curLetter);
} else {
return null;
}
}
return node;
}
// Returns if the word is in the trie.
public boolean search(String word) {
TrieNode node = searchPrefix(word);
return node != null && node.isEnd();
}
}
復雜度分析
- 時間復雜度 : O(m)。算法的每一步均搜索下一個鍵字符。最壞的情況下需要 m 次操作。
- 空間復雜度 : O(1)。
查找 Trie 樹中的鍵前綴
該方法與在 Trie 樹中搜索鍵時使用的方法非常相似。我們從根遍歷 Trie 樹,直到鍵前綴中沒有字符,或者無法用當前的鍵字符繼續 Trie 中的路徑。與上面提到的“搜索鍵”算法唯一的區別是,到達鍵前綴的末尾時,總是返回 true。我們不需要考慮當前 Trie 節點是否用 “isend” 標記,因為我們搜索的是鍵的前綴,而不是整個鍵。

圖 9. 查找 Trie 樹中的鍵前綴
class Trie {
...
// Returns if there is any word in the trie
// that starts with the given prefix.
public boolean startsWith(String prefix) {
TrieNode node = searchPrefix(prefix);
return node != null;
}
}
復雜度分析
- 時間復雜度 : O(m)。
- 空間復雜度 : O(1)。
完整代碼
class Trie {
private final int ALPHABET_SIZE = 26;
private Trie[] children = new Trie[ALPHABET_SIZE];
boolean isEndOfWord = false;
public Trie() {}
public void insert(String word) {
Trie tmp = this;
for (char i : word.toCharArray()) {
if (tmp.children[i-'a'] == null) {
tmp.children[i-'a'] = new Trie();
}
tmp = tmp.children[i-'a'];
}
tmp.isEndOfWord = true;
}
public boolean search(String word) {
Trie tmp = this;
for (char i : word.toCharArray()) {
if (tmp.children[i-'a'] == null) {
return false;
}
tmp = tmp.children[i-'a'];
}
return tmp.isEndOfWord ? true : false;
}
public boolean startsWith(String prefix) {
Trie tmp = this;
for (char i : prefix.toCharArray()) {
if (tmp.children[i-'a'] == null) {
return false;
}
tmp = tmp.children[i-'a'];
}
return true;
}
}
