導言
我們肯定是天天都在用搜索引擎啦,例如我用百度查找資料,會發現當我輸入一段字符時,百度就自動跳出了一些熱搜關鍵詞,在推薦頁面也會想你推薦一些實時熱點,這是怎么實現的呢?可以使用類似 map 容器的對象,“鍵”是關鍵詞,“值”是被搜索的次數,每次需要更新數據時,先找到被搜索的熱詞,使其的值加 1,然后來個快速排序,但是這種方式需要頻繁的對數據進行操作,時間復雜度和空間復雜度都很大,對於一個優秀的搜索引擎來說是絕對不可取的。

字典樹
這里就要引入一種更厲害的結構啦——字典樹 (Trie),又稱單詞查找樹、前綴樹,是一種樹形結構,是一種哈希樹的變種。在統計、排序和保存大量的字符串(但不僅限於字符串)是具有更小的時間復雜度,因此可以應用於搜索引擎系統用於文本詞頻統計。它的優點是:利用字符串的公共前綴來減少查詢時間,最大限度地減少無謂的字符串比較,查詢效率比哈希樹高。
例如我有 "a"、"apple"、"appeal"、"appear"、"bee"、"beef"、"cat" 這 7 個單詞,那么就能夠組織成如圖所示字典樹,如果我們要獲取 "apple" 這個單詞的信息,那么就按順序訪問對應的結點就行啦。

字典樹的性質
- 根節點不包含字符,除根節點外每一個節點都只包含一個字符;
- 從根節點到某一節點,路徑上經過的字符連接起來,為該節點對應的字符串;
- 每個節點的所有子節點包含的字符都不相同。
字典樹的應用
| 應用 | 說明 |
|---|---|
| 字典 | 字符串集合對應一定的信息 |
| 計算熱詞 | 統計字符串在集合中出現的個數 |
| 串的快速檢索 | 給出 N 個單詞組成的熟詞表,以及一篇全用小寫英文書寫的文章,按最早出現的順序寫出所有不在熟詞表中的生詞。可以把熟詞建成字典樹,然后讀入文章進行比較,這種方法效率是比較高的。 |
| “串”排序 | 給定N個互不相同的僅由一個單詞構成的英文名,將他們按字典序從小到大輸出,采用數組的方式創建字典樹,這棵樹的每個結點的所有兒子很顯然地按照其字母大小排序。對這棵樹進行先序遍歷即可 |
| 最長公共前綴 | 對所有串建立字典樹,對於兩個串的最長公共前綴的長度即他們所在的結點的公共祖先個數,於是,問題就轉化為當時公共祖先問題。 |
結點結構體定義
為了更好地理解,這里使用順序存儲結構描述字典樹。鏈式存儲結構實現的字典樹,在另一篇博客——AC 自動機(Aho-Corasick automaton)有所介紹。
假如這個字典只包括 26 個小寫英文字母,雖然這個字典可能會容納賊多、賊長的單詞,但是一個結點會有多少種后繼是可以確定的,因為對於一個單詞而言,任何一位的字母一定是 26 個字母中的一個,我們可以根據需要選擇一個字母的后繼有幾個結點。例如剛才這棵字典樹,對於根結點而言,可能會有 26 種后繼,但是我的字典里只有 “a”、“b”、“c” 3 種開頭的單詞,因此我選擇這 3 個結點作為根結點的后繼。例如圖中被標綠色的結點,“appea” 的后繼可能是 “l”,“r”,分別表示 "appeal"、"appear" 兩個單詞。

我們的做法是使用順序存儲結構表示樹,因此需要先開辟一個足夠大的數組,使用靜態鏈表的思想,用游標表示結點的后繼。由於我們確定一個結點的后繼可能存在 26 個,因此選擇開辟一個數組來描述。定義一個包含 26 個后綴指針的結構體,其中再開一個 bool 類型的成語,用於判定是否是單詞的結尾。
#define MAXSIZE 26
struct node {
bool flag; //若結點是單詞的結尾,值為 true,否則為 false
int next[MAXSIZE]; //指向后繼的游標數組
}trie[1000001];
插入操作
插入操作,即向字典樹中保存一個單詞,初始化字典樹就是重復地插入已有的單詞啦。由於我們使用類靜態鏈表的做法,因此需要用一個游標來定向空閑的空間,在我們需要插入新結點的時候可以拿該游標的元素來使用。當然你可以另外搞一些代碼來描述閑置鏈表,但是字典樹很少涉及刪除操作,因此忽略。如果你不知道靜態鏈表是啥,可以參考我的另一篇博客靜態鏈表及思想應用。
偽代碼

代碼實現
void Insert(string str, int &space)
{
int order;
int idx = 1; //從第一層向下挖掘
for (int i = 0; i < str.length(); i++)
{
order = str[i] - 'a'; //將字符轉換為其在字母表的順序
if (trie[idx].next[order] == 0) //若 idx 沒有該字符的子結點
{
trie[idx].next[order] = space++; //啟用第 space 號結點,拷貝新結點的編號
idx = trie[idx].next[order]; //idx 結點的對應字母的后繼為 space
trie[idx].flag = false; //標記新結點不是單詞的結尾
}
else
idx = trie[idx].next[order]; //前綴已存在,繼續挖掘
}
trie[idx].flag = true; //flag 成員設置為 true,表示單詞結尾
}
查找操作
查找操作的結構設計與插入操作類似,按照字符串的字母字典序向下訪問結點,如果訪問到空結點即表示失配,返回 false,需要注意的是即使匹配成功,若字母不為單詞的結尾也算失配。
偽代碼

代碼實現
bool Find(string str)
{
int order;
int idx = 1; //從第一層向下挖掘
for (int i = 0; i < str.length(); i++)
{
order = str[i] - 'a'; //將字符轉換為其在字母表的順序
if (trie[idx].next[order] == 0) //若字母失配,匹配結束
{
return false;
}
idx = trie[idx].next[order]; //存在對應字母,匹配繼續
}
if (trie[idx].flag == false) //若成功匹配,但是不為單詞結尾
return false;
else
return true; //單詞匹配成功,返回 true
}
簡單應用
要求構造一個字典樹,先輸入單詞的數量,隨后按行輸入單詞並存入字典樹中。接着輸入需要匹配的單詞數量,隨后按行輸入需要匹配的單詞,匹配成功輸出 “YES”,否則輸出 “NO”。
代碼實現
把上述函數封裝好,寫個主函數組織一下結構即可:
int main()
{
int num1, num2;
int space = 1; //表示第一個空閑空間的下標
string str;
cout << "請輸入需要保存的單詞數量:";
cin >> num1;
cout << "按行輸入單詞" << endl;
for (int i = 1; i <= num1; i++)
{
cin >> str;
Insert(str, space);
}
cout << "\n請輸入需要匹配的單詞數量:";
cin >> num2;
cout << "按行輸入單詞" << endl;
for (int i = 0; i < num2; i++)
{
cin >> str;
if (Find(str))
cout << "YES" << endl;
else
cout << "NO" << endl;
}
return 0;
}
調試效果

情景應用
外地人

情景解析
因為考慮到多個字符串的保存於匹配,因此我們選擇字典樹來組織數據。由於情景需要我們把匹配好的方言再翻譯回英文,因此我們需要進行一些改裝,把判斷字符串結尾的 flag 修改成一個 int 類型的變量,然后開一個 string 類的數組,用該變量訪問該數組對應的字符串,即實現了翻譯功能。因此插入單詞之后需要保存好這個單詞對應了哪個字符串。
代碼實現

參考資料
字典樹
字典樹
字典樹(Trie)詳解
字典樹基礎進階全掌握
AC 自動機(Aho-Corasick automaton)
