1. 程式人生 > >Trie 樹——搜尋關鍵詞提示

Trie 樹——搜尋關鍵詞提示

當你在搜尋引擎中輸入想要搜尋的一部分內容時,搜尋引擎就會自動彈出下拉框,裡面是各種關鍵詞提示,這個功能是怎麼實現的呢?其實底層最基本的就是 Trie 樹這種資料結構。

1. 什麼是 “Trie” 樹

Trie 樹也叫 “字典樹”。顧名思義,它是一個樹形結構,專門用來處理在一組字串集合中快速查詢某個字串的問題。

假設我們有 6 個字串,它們分別是:how,hi,her,hello,so,see。我們希望在這裡面多次查詢某個字串是否存在,如果每次都拿要查詢的字串和這六個字串依次進行匹配,那效率就會比較低。

如果我們可以對這六個字串做一下預處理,組織成 Trie 樹的結構,那之後每次查詢,都只要在 Trie 樹中進行匹配即可。Trie 樹的本質,就是利用字串之間的公共字首,將重複的前綴合並在一起

其中,根節點不包含任何資訊,每個節點代表字串中的一個字元,從根節點到紅色節點的一條路徑表示一個字串。注意紅色節點並不都是葉子節點,比如有兩個詞 how 和 however,那麼 w 和 r 都是紅色節點。一個 Trie 樹的構造過程如下所示。


當我們要在構建好的 Trie 樹中查詢一個字串的時候,那就要將查詢的字串分割成單個的字元,然後從根節點開始匹配。如下面的例子所示,綠色路徑就是 “her” 的匹配路徑,而 “he” 的最後一個匹配節點並不是紅色節點,所以其並不能完全匹配任何字串。


2. 如何實現一棵 Trie 樹

從上面我們可以看到,Trie 樹主要有兩個操作:一個是將字串集合構建成 Trie 樹,另一個是在 Trie 樹中查詢一個字串

Trie 樹是一個多叉樹結構,其子節點個數事先未知,但我們可以藉助散列表的思想,在下標與字元之間建立一個一一對映,來儲存子節點的指標。

假設我們的字串只有 a 到 z 這 26 個字母,那麼陣列下標為 0 的元素就儲存指向子節點 a 的指標,下標為 1 的元素就儲存指向子節點 b 的指標,以此類推,下標為 25 的元素就儲存指向子節點 z 的指標。如果某個字元的子節點不存在,那對應該下標位置的元素就為 NULL。當我們在 Trie 樹中進行查詢的時候,就可以拿字元的 ASCII 碼減去 'a' 的 ASCII 碼來獲取其子節點的指標。

#include <iostream>
#include <cstring>

using namespace std;

class TrieNode
{
public:

    char data;
    bool is_ending_char;
    TrieNode *children[26];

    TrieNode(char ch)
    {
        data = ch;
        is_ending_char = false;
        for (int i = 0; i < 26; i++)
            children[i] = NULL;
    }
};

class Trie
{
private:

    TrieNode *root;

public:

    // 建構函式,根節點儲存無意義字元 '/'
    Trie()
    {
        root = new TrieNode('/');
    }

    // 向 Trie 樹中新增一個字串
    void insert_string(const char str[])
    {
        TrieNode *cur = root;
        for (unsigned int i = 0; i < strlen(str); i++)
        {
            int index = int(str[i] - 'a');
            if (cur->children[index] == NULL)
            {
                TrieNode *temp = new TrieNode(str[i]);
                cur->children[index] = temp;
            }
            cur = cur->children[index];
        }
        cur->is_ending_char = true;
    }

    // 在 Trie 樹中查詢一個字串
    bool search_string(const char str[])
    {
        TrieNode *cur = root;
        for (unsigned int i = 0; i < strlen(str); i++)
        {
            int index = int(str[i] - 'a');
            if (cur->children[index] == NULL)
            {
                return false;
            }
            cur = cur->children[index];
        }
        if (cur->is_ending_char == true) return true;
        else return false;
    }
};

int main()
{
    char str[][8] = {"how", "hi", "her", "hello", "so", "see", "however"};

    Trie test;
    for (int i = 0; i < 7; i++)
    {
        test.insert_string(str[i]);
    }

    cout << "Finding \'her\': " << test.search_string("her") << endl;
    cout << "Finding \'he\': " << test.search_string("he") << endl;
    cout << "Finding \'how\': " << test.search_string("how") << endl;
    cout << "Finding \'however\': " << test.search_string("however") << endl;

    return 0;
}

在構建 Trie 樹的過程中,需要掃描所有的字串,時間複雜度為 O(n),其中 n 表示所有字串的長度之和。而在 Trie 樹中進行查詢的話,如果待查詢字串的長度為 k 的話,那最多隻需要對比 k 個節點即可,時間複雜度為 O(k)。

3. Trie 樹的記憶體消耗

在上面的例子中,Trie 樹的每個節點都要儲存 26 個指標,儘管某些節點的子節點很少,我們依然要維護這麼一個長度的陣列。另外,如果字串中不僅包含小寫字母,而且包含大寫字母、數字甚至是中文等,那就會需要更多的儲存空間。也就是說,在某些情況下,Trie 樹並不一定會節省記憶體空間,尤其是在重複字首不多的時候。

當然,儘管 Trie 樹可能會很浪費記憶體,但是確實非常高效,這也是一種空間換時間的折中。如果我們可以稍微犧牲一點查詢的效率,那就可以選用陣列、散列表、紅黑樹等其他資料結構來儲存一個節點的子節點指標。

假設我們使用陣列,陣列中的指標按照所指向子節點的字元大小順序排列。這樣,在查詢的時候,我們可以通過二分演算法來快速找到指向子節點的指標。但是,在往 Trie 樹中插入字串的話,為了維護陣列的有序性,就會稍微慢了點。

另外,還可以採用縮點優化,將只有一個子節點而且不是結束節點的節點與其子節點進行合併,來節省空間,但這也增加了編碼難度。

4. Trie 樹與散列表、紅黑樹的比較

在字串匹配或者說查詢問題上,Trie 樹對要處理的字串有極其嚴格的要求。

  • 字串中包含的字符集不能太大;

  • 字串的字首重合比較多;

  • 從零開始實現一個 Trie 樹,比較複雜,不便於維護;

  • Trie 樹中利用指標來儲存資料,不利用利用快取。

因此,在工程中,我們更傾向於使用散列表或者紅黑樹,它們都不需要自己去實現,直接利用程式語言中提供的執行緒類庫就行。實際上,Trie 樹不適合這種精確查詢,更適合的是查詢字首匹配的字串,也就是搜尋時的關鍵詞提示功能。

5. 搜尋關鍵詞提示功能的實現

假設關鍵詞庫由使用者的熱門搜尋關鍵片語成,我們將這個詞庫構建成一個 Trie 樹。當用戶輸入其中某個單詞的時候,把這個詞作為一個字首子串在 Trie 樹中匹配。還以上面為例,當用戶輸入 'h' 時,我們就可以將以 'h' 為字首的單詞 hello,her,hi,how 展示在搜尋提示框,當用戶輸入 'he' 時,我們就可以將以 'h' 為字首的單詞 hello,her 展示在搜尋提示框。這就是搜尋關鍵詞提示的最基本的演算法原理。

另外,Trie 樹還可以擴充套件到更加廣泛的應用上,比如輸入法、程式碼編輯器和瀏覽器的自動輸入補全功能。

參考資料-極客時間專欄《資料結構與演算法之美》

獲取更多精彩,請關注「seniusen」!