海量數據處理之Tire樹(字典樹)

參考博文:http://blog.csdn.net/v_july_v/article/details/6897097

第一部分、Trie樹

1.1、什麼是Trie樹

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

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

它有3個基本性質:

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

1.2、樹的構建

舉個在網上流傳頗廣的例子,如下:
    題目:給你100000個長度不超過10的單詞。對於每一個單詞,我們要判斷他出沒出現過,如果出現了,求第一次出現在第幾個位置。
    分析:這題當然可以用hash來解決,但是本文重點介紹的是trie樹,因爲在某些方面它的用途更大。比如說對於某一個單詞,我們要詢問它的前綴是否出現過。這樣hash就不好搞了,而用trie還是很簡單。
    現在回到例子中,如果我們用最傻的方法,對於每一個單詞,我們都要去查找它前面的單詞中是否有它。那麼這個算法的複雜度就是O(n^2)。顯然對於100000的範圍難以接受。現在我們換個思路想。假設我要查詢的單詞是abcd,那麼在他前面的單詞中,以b,c,d,f之類開頭的我顯然不必考慮。而只要找以a開頭的中是否存在abcd就可以了。同樣的,在以a開頭中的單詞中,我們只要考慮以b作爲第二個字母的,一次次縮小範圍和提高針對性,這樣一個樹的模型就漸漸清晰了。
    好比假設有b,abc,abd,bcd,abcd,efg,hii 這6個單詞,我們構建的樹就是如下圖這樣的:
  當時第一次看到這幅圖的時候,便立馬感到此樹之不凡構造了。單單從上幅圖便可窺知一二,好比大海搜人,立馬就能確定東南西北中的到底哪個方位,如此迅速縮小查找的範圍和提高查找的針對性,不失爲一創舉。
    ok,如上圖所示,對於每一個節點,從根遍歷到他的過程就是一個單詞,如果這個節點被標記爲色,就表示這個單詞存在,否則不存在。
    那麼,對於一個單詞,我只要順着他從根走到對應的節點,再看這個節點是否被標記爲紅色就可以知道它是否出現過了。把這個節點標記爲紅色,就相當於插入了這個單詞。
    這樣一來我們查詢和插入可以一起完成(重點體會這個查詢和插入是如何一起完成的,稍後,下文具體解釋),所用時間僅僅爲單詞長度,在這一個樣例,便是10。
    我們可以看到,trie樹每一層的節點數是26^i級別的。所以爲了節省空間。我們用動態鏈表,或者用數組來模擬動態。空間的花費,不會超過單詞數×單詞長度。

1.3、前綴查詢

    上文中提到”比如說對於某一個單詞,我們要詢問它的前綴是否出現過。這樣hash就不好搞了,而用trie還是很簡單“。下面,咱們來看看這個前綴查詢問題:
    已知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樹的平均高度h爲len,所以Trie樹的查詢複雜度爲O(h)=O(len)。好比一棵二叉平衡樹的高度爲logN,則其查詢,插入的平均時間複雜度亦爲O(logN))。
    下面解釋下上述方法3中所說的爲什麼hash不能將建立與查詢同時執行,而Trie樹卻可以:
  • 在hash中,例如現在要輸入兩個串911,911456,如果要同時查詢這兩個串,且查詢串的同時若hash中沒有則存入。那麼,這個查詢與建立的過程就是先查詢其中一個串911,沒有,然後存入9、91、911;而後查詢第二個串911456,沒有然後存入9、91、9119114、91145、911456。因爲程序沒有記憶功能,所以並不知道911在輸入數據中出現過,只是照常以例行事,存入9、91、911、9114、911...。也就是說用hash必須先存入所有子串,然後for循環查詢。
  • 而trie樹中,存入911後,已經記錄911爲出現的字符串,在存入911456的過程中就能發現而輸出答案;倒過來亦可以,先存入911456,在存入911時,當指針指向最後一個1時,程序會發現這個1已經存在,說明911必定是某個字符串的前綴。
    讀者反饋@悠悠長風:關於這點,我有不同的看法。hash也是可以實現邊建立邊查詢的啊。當插入911時,需要一個額外的標誌位,表示它是一個完整的單詞。在處理911456時,也是按照前面的查詢9,91,911,當查詢911時,是可以找到前面插入的911,且通過標誌位知道911爲一個完整單詞。那麼就可以判斷出911爲911456的前綴啊。雖然trie樹更適合這個問題,但是我認爲hash也是可以實現邊建立,邊查找。
   吾答曰:但若反過來呢?比如說是先查詢911456,而後查詢911呢?你的在hash中做一個完整單詞的標誌就行不通了。因爲,你查詢911456時,並不知道後來911會是一個完整的單詞。
    至於,有關Trie樹的查找,插入等操作的實現代碼,網上遍地開花且千篇一律,諸君儘可參考,想必不用我再做多餘費神。

1.4、查詢

    Trie樹是簡單但實用的數據結構,通常用於實現字典查詢。我們做即時響應用戶輸入的AJAX搜索框時,就是Trie開始。本質上,Trie是一顆存儲多個字符串的樹。相鄰節點間的邊代表一個字符,這樣樹的每條分支代表一則子串,而樹的葉節點則代表完整的字符串。和普通樹不同的地方是,相同的字符串前綴共享同一條分支。下面,再舉一個例子。給出一組單詞,inn, int, at, age, adv, ant, 我們可以得到下面的Trie:
  可以看出:
  • 每條邊對應一個字母。
  • 每個節點對應一項前綴。葉節點對應最長前綴,即單詞本身。
  • 單詞inn與單詞int有共同的前綴“in”, 因此他們共享左邊的一條分支,root->i->in。同理,ate, age, adv, 和ant共享前綴"a",所以他們共享從根節點到節點"a"的邊。
    查詢操縱非常簡單。比如要查找int,順着路徑i -> in -> int就找到了。

    搭建Trie的基本算法也很簡單,無非是逐一把每則單詞的每個字母插入Trie。插入前先看前綴是否存在。如果存在,就共享,否則創建對應的節點和邊。比如要插入單詞add,就有下面幾步:
  1. 考察前綴"a",發現邊a已經存在。於是順着邊a走到節點a。
  2. 考察剩下的字符串"dd"的前綴"d",發現從節點a出發,已經有邊d存在。於是順着邊d走到節點ad
  3. 考察最後一個字符"d",這下從節點ad出發沒有邊d了,於是創建節點ad的子節點add,並把邊ad->add標記爲d。

1.5、Trie樹的應用

    除了本文引言處所述的問題能應用Trie樹解決之外,Trie樹還能解決下述問題(節選自此文:海量數據處理面試題集錦):
  • 3、有一個1G大小的一個文件,裏面每一行是一個詞,詞的大小不超過16字節,內存限制大小是1M。返回頻數最高的100個詞。
  • 9、1000萬字符串,其中有些是重複的,需要把重複的全部去掉,保留沒有重複的字符串。請怎麼設計和實現?
  • 10、 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間複雜度分析。
  • 13、尋找熱門查詢:搜索引擎會通過日誌文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度爲1-255字節。假設目前有一千萬個記錄,這些查詢串的重複讀比較高,雖然總數是1千萬,但是如果去除重複和,不超過3百萬個。一個查詢串的重複度越高,說明查詢它的用戶越多,也就越熱門。請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。
    (1) 請描述你解決這個問題的思路;
    (2) 請給出主要的處理流程,算法,以及算法的複雜度。
1.6、Tire樹的實現
 
/*Trie樹(字典樹) 2011.10.10*/ 
  
#include <iostream> 
#include<cstdlib> 
#define MAX 26 
using namespace std; 
  
typedef struct TrieNode                     //Trie結點聲明  
{ 
    bool isStr;                            //標記該結點處是否構成單詞  
    struct TrieNode *next[MAX];            //兒子分支  
}Trie; 
  
void insert(Trie *root,const char *s)     //將單詞s插入到字典樹中  
{ 
    if(root==NULL||*s=='\0') 
        return; 
    int i; 
    Trie *p=root; 
    while(*s!='\0') 
    { 
        if(p->next[*s-'a']==NULL)        //如果不存在,則建立結點  
        { 
            Trie *temp=(Trie *)malloc(sizeof(Trie)); 
            for(i=0;i<MAX;i++) 
            { 
                temp->next[i]=NULL; 
            } 
            temp->isStr=false; 
            p->next[*s-'a']=temp; 
            p=p->next[*s-'a'];    
        }    
        else
        { 
            p=p->next[*s-'a']; 
        } 
        s++; 
    } 
    p->isStr=true;                       //單詞結束的地方標記此處可以構成一個單詞  
} 
  
int search(Trie *root,const char *s)  //查找某個單詞是否已經存在  
{ 
    Trie *p=root; 
    while(p!=NULL&&*s!='\0') 
    { 
        p=p->next[*s-'a']; 
        s++; 
    } 
    return (p!=NULL&&p->isStr==true);      //在單詞結束處的標記爲true時,單詞才存在  
} 
  
void del(Trie *root)                      //釋放整個字典樹佔的堆區空間  
{ 
    int i; 
    for(i=0;i<MAX;i++) 
    { 
        if(root->next[i]!=NULL) 
        { 
            del(root->next[i]); 
        } 
    } 
    free(root); 
} 
  
int main(int argc, char *argv[]) 
{ 
    int i; 
    int n,m;                              //n爲建立Trie樹輸入的單詞數,m爲要查找的單詞數  
    char s[100]; 
    Trie *root= (Trie *)malloc(sizeof(Trie)); 
    for(i=0;i<MAX;i++) 
    { 
        root->next[i]=NULL; 
    } 
    root->isStr=false; 
    scanf("%d",&n); 
    getchar(); 
    for(i=0;i<n;i++)                 //先建立字典樹  
    { 
        scanf("%s",s); 
        insert(root,s); 
    } 
    while(scanf("%d",&m)!=EOF) 
    { 
        for(i=0;i<m;i++)                 //查找  
        { 
            scanf("%s",s); 
            if(search(root,s)==1) 
                printf("YES\n"); 
            else
                printf("NO\n"); 
        } 
        printf("\n");    
    } 
    del(root);                         //釋放空間很重要  
    return 0; 
}

發佈了36 篇原創文章 · 獲贊 17 · 訪問量 16萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章