1. 程式人生 > >Trie原理以及應用於搜尋提示

Trie原理以及應用於搜尋提示

背景

有一段如下內容:

舉個例子來說吧,搜尋提示功能大家都知道吧,就是下面這個圖的東西。

搜尋

如果是google,baidu這種大型搜尋系統,或者京東淘寶這種電商系統,搜尋提示的呼叫量是搜尋服務本身呼叫量的幾倍,因為你每輸入一個鍵盤,就要呼叫一次搜尋提示服務,這算得上是個標準的高併發系統吧?那麼它是怎麼實現的呢?

可能很多人腦子裡立刻出現了快取+雜湊的系統,把搜尋的搜尋提示詞存在redis叢集中,每次來了請求直接redis叢集中查詢key,然後返回相應的value值就行了,完美解決,雖然耗費點記憶體,但是空間換時間嘛,也能接受,這麼做行不行?恩,我覺得是可以的,但有人這麼做嗎?沒有。

瞭解的人應該知道,沒有人會這麼來實現,這種搜尋提示的功能一般用trie樹來做,耗費的記憶體不多,查詢速度為O(k),其中k為字串的長度,雖然看上去沒有雜湊表的O(1)好,但是少了網路開銷,節約了很多記憶體,並且實際查詢時間還要不比快取+雜湊慢多少,一種合適當前場景的核心資料結構才是高併發系統的關鍵,快取+雜湊如果也看成一種資料結構,但這種資料結構並不適用於所有的高併發場景,所以高併發系統的設計,關鍵在合理的資料結構的設計,而不在架構的套用。

所以就來溫習一下trie樹,並來做個記錄。

Trie

知識簡介

字典樹(Trie)可以儲存一些字串->值的對應關係。基本上,它跟 Java 的 HashMap 功能相同,都是 key-value 對映,只不過 Trie 的 key 只能是字串。

Trie 的強大之處就在於它的時間複雜度。它的插入和查詢時間複雜度都為 O(k) ,其中 k 為 key 的長度,與 Trie 中儲存了多少個元素無關。Hash 表號稱是 O(1) 的,但在計算 hash 的時候就肯定會是 O(k) ,而且還有碰撞之類的問題;Trie 的缺點是空間消耗很高。

至於Trie樹的實現,可以用陣列,也可以用指標動態分配。

Trie樹,又稱單詞查詢樹或鍵樹,是一種樹形結構,是一種雜湊樹的變種。典型應用是用於統計和排序大量的字串(但不僅限於字串),所以經常被搜尋引擎系統用於文字詞頻統計。它的優點是:最大限度地減少無謂的字串比較,查詢效率比雜湊表高。

Trie的核心思想是空間換時間。利用字串的公共字首來降低查詢時間的開銷以達到提高效率的目的。

Trie樹的基本性質可以歸納為:

  1. 根節點不包含字元,除根節點意外每個節點只包含一個字元。
  2. 從根節點到某一個節點,路徑上經過的字元連線起來,為該節點對應的字串。
  3. 每個節點的所有子節點包含的字串不相同。

Trie樹有一些特性:

  1. 根節點不包含字元,除根節點外每一個節點都只包含一個字元。
  2. 從根節點到某一節點,路徑上經過的字元連線起來,為該節點對應的字串。
  3. 每個節點的所有子節點包含的字元都不相同。
  4. 如果字元的種數為n,則每個結點的出度為n,這也是空間換時間的體現,浪費了很多的空間。
  5. 插入查詢的複雜度為O(n),n為字串長度。

基本思想(以字母樹為例):

  1. 插入過程

    對於一個單詞,從根開始,沿著單詞的各個字母所對應的樹中的節點分支向下走,直到單詞遍歷完,將最後的節點標記為紅色,表示該單詞已插入Trie樹。

  2. 查詢過程

    同樣的,從根開始按照單詞的字母順序向下遍歷trie樹,一旦發現某個節點標記不存在或者單詞遍歷完成而最後的節點未標記為紅色,則表示該單詞不存在,若最後的節點標記為紅色,表示該單詞存在。

字典樹的資料結構:

利用串構建一個字典樹,這個字典樹儲存了串的公共字首資訊,因此可以降低查詢操作的複雜度。

下面以英文單詞構建的字典樹為例,這棵Trie樹中每個結點包括26個孩子結點,因為總共有26個英文字母(假設單詞都是小寫字母組成)。則可宣告包含Trie樹的結點資訊的結構體:

typedef struct Trie_node  
{  
    int count;                    // 統計單詞前綴出現的次數  
    struct Trie_node* next[26];   // 指向各個子樹的指標  
    bool exist;                   // 標記該結點處是否構成單詞    
}TrieNode , *Trie;  

其中next是一個指標陣列,存放著指向各個孩子結點的指標。

已知n個由小寫字母構成的平均長度為10的單詞,判斷其中是否存在某個串為另一個串的字首子串。下面對比3種方法:

  1. 最容易想到的:即從字串集中從頭往後搜,看每個字串是否為字串集中某個字串的字首,複雜度為O(n^2)。

  2. 使用hash:我們用hash存下所有字串的所有的字首子串。建立存有子串hash的複雜度為O(n*len)。查詢的複雜度為O(n)* O(1)= O(n)

  3. 使用Trie:因為當查詢如字串abc是否為某個字串的字首時,顯然以b、c、d….等不是以a開頭的字串就不用查找了,這樣迅速縮小查詢的範圍和提高查詢的針對性。所以建立Trie的複雜度為O(n*len),而建立+查詢在trie中是可以同時執行的,建立的過程也就可以成為查詢的過程,hash就不能實現這個功能。所以總的複雜度為O(n*len),實際查詢的複雜度只是O(len)。

Trie樹的操作

在Trie樹中主要有3個操作,插入、查詢和刪除。一般情況下Trie樹中很少存在刪除單獨某個結點的情況,因此只考慮刪除整棵樹。

  1. 插入

    假設存在字串str,Trie樹的根結點為root。i=0,p=root。

    1. 取str[i],判斷p->next[str[i]-97]是否為空,若為空,則建立結點temp,並將p->next[str[i]-97]指向temp,然後p指向temp;
      若不為空,則p=p->next[str[i]-97];
    2. i++,繼續取str[i],迴圈1)中的操作,直到遇到結束符’\0’,此時將當前結點p中的 exist置為true。
  2. 查詢

    假設要查詢的字串為str,Trie樹的根結點為root,i=0,p=root

    1. 取str[i],判斷判斷p->next[str[i]-97]是否為空,若為空,則返回false;若不為空,則p=p->next[str[i]-97],繼續取字元。
    2. 重複1)中的操作直到遇到結束符’\0’,若當前結點p不為空並且 exist 為true,則返回true,否則返回false。
  3. 刪除(二選其一)

    1. 刪除可以以遞迴的形式進行刪除。
    2. 先搜尋後刪除

lintcode

Java:

package cc.wsyw126.java.lintCode.implement_trie;

public class Trie {
    private TrieNode root;

    public Trie() {
        root = new TrieNode();
    }

    // Inserts a word into the trie.
    public void insert(String word) {
        TrieNode node = root;
        int length = word.length();
        int position ;
        char c;
        for (int i = 0; i < length; i++) {
            c = word.charAt(i);
            position = c-'a';
            if (node.trieNodes[position] == null) {
                node.trieNodes[position] = new TrieNode();
            }
            node = node.trieNodes[position];
            node.setCount(node.getCount()+1);
        }
        node.setExist(true);
    }

    // Returns if the word is in the trie.
    public boolean search(String word) {
        boolean result = false;
        TrieNode node = root;
        int length = word.length();
        int position ;
        char c;
        for (int i = 0; i < length; i++) {
            c = word.charAt(i);
            position = c - 'a';
            node = node.trieNodes[position];
            if (node == null) {
                break;
            }
        }
        if (node != null && node.getExist()) {
            result = true;
        }
        return result;
    }

    // Returns if there is any word in the trie
    // that starts with the given prefix.
    public boolean startsWith(String prefix) {
        TrieNode node = root;
        int length = prefix.length();
        int position ;
        char c;
        for (int i = 0; i < length; i++) {
            c = prefix.charAt(i);
            position = c - 'a';
            node = node.trieNodes[position];
            if (node == null) {
                return false;
            }
        }
        return true;
    }

    // delete if the word is in the trie.
    public boolean doDelete(String word, TrieNode node) {
        //樹中已匹配的字串比傳入字串短
        if (node == null) {
            return false;
        }

        //樹中已匹配的字串比傳入字串不短
        if (word.length()  > 1){
            char c = word.charAt(0);
            int position = c - 'a';
            TrieNode trieNode = node.trieNodes[position];
            boolean b = doDelete(word.substring(1), trieNode);
            if (b) {
                node.setCount(node.getCount() - 1);
                if (trieNode.getCount() == 0) {
                    node.trieNodes[position] = null;
                }
                return true;
            }
        }

        if (word.length() == 1) {
            char c = word.charAt(0);
            int position = c - 'a';
            TrieNode trieNode = node.trieNodes[position];
            //只刪除單詞 如果是字首不刪除
            if (trieNode != null && trieNode.getExist()) {
                return true;
            }
        }
        return false;
    }

    // delete if the word is in the trie.
    public boolean delete(String word) {
        return this.doDelete(word,root);
    }

    class TrieNode {
        // Initialize your data structure here.
        int count = 0;
        TrieNode[] trieNodes = new TrieNode[26];
        Boolean exist = false;
        public TrieNode() {
        }

        public TrieNode(int count, Boolean exist) {
            this.count = count;
            this.exist = exist;
        }

        public int getCount() {
            return count;
        }

        public void setCount(int count) {
            this.count = count;
        }

        public TrieNode[] getTrieNodes() {
            return trieNodes;
        }

        public void setTrieNodes(TrieNode[] trieNodes) {
            this.trieNodes = trieNodes;
        }

        public Boolean getExist() {
            return exist;
        }

        public void setExist(Boolean exist) {
            this.exist = exist;
        }
    }

    public static void main(String[] args) {
        Trie trie = new Trie();
        trie.search("lintcode");
        trie.startsWith("lint");
        trie.insert("lint");
        trie.startsWith("lint");

        boolean lint = trie.delete("lin");
        System.out.println("lint = " + lint);
        lint = trie.delete("lint");
        System.out.println("lint = " + lint);
    }
}

C:

#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include "stdio.h"

typedef struct Trie_node {
    int count;                    // 統計單詞前綴出現的次數
    struct Trie_node *next[26];   // 指向各個子樹的指標
    bool exist;                   // 標記該結點處是否構成單詞
} TrieNode, *Trie;


TrieNode *createTrieNode() {
    TrieNode *node = (TrieNode *) malloc(sizeof(TrieNode));
    node->count = 0;
    node->exist = false;
    memset(node->next, 0, sizeof(node->next));    // 初始化為空指標
    return node;
}


void Trie_insert(Trie root, char *word) {
    Trie node = root;
    char *p = word;
    int id;
    while (*p) {
        id = *p - 'a';
        if (node->next[id] == NULL) {
            node->next[id] = createTrieNode();
        }
        node = node->next[id];  // 每插入一步,相當於有一個新串經過,指標向下移動
        ++p;
        node->count += 1;      // 這行程式碼用於統計每個單詞前綴出現的次數(也包括統計每個單詞出現的次數)
    }
    node->exist = true;        // 單詞結束的地方標記此處可以構成一個單詞
}

int Trie_search(Trie root, char *word) {
    Trie node = root;
    char *p = word;
    int id;
    while (*p) {
        id = *p - 'a';
        node = node->next[id];
        ++p;
        if (node == NULL)
            return 0;
    }
    return node->count;
}


int main() {
    Trie root = createTrieNode();     // 初始化字典樹的根節點
    char str[12];
    bool flag = false;
    while (gets(str)) {
        if (flag)
            printf("%d\n", Trie_search(root, str));
        else {
            if (strlen(str) != 0) {
                Trie_insert(root, str);
            } else
                flag = true;
        }
    }

    return 0;
}