(2010-03-13)
1、背景
詞彙搜尋、詞頻統計等字串操作,是搜尋引擎、文本處理系統等經常使用的業務,現在假設有這麼一個簡單的文本處理例子:有一篇10000個詞的文章,要查出單詞“was”在這篇文章中出現的次數。那麼一般來說,沒學過資料結構課程的讀者可能會採用最簡單但是最尋找效率最低的窮舉遍曆法:讀入整篇文章的詞到一個字串大數組中,然後一個一個地與“was”比較匹配。對於學習過資料結構課程的讀者而言,不難想到資料結構的教材中介紹的2種經典的便於尋找資料的結構——平衡尋找二叉樹和雜湊表:對於平衡尋找二叉樹(或者是它的增強版-紅/黑樹狀結構),以詞彙作為關鍵字,其出現的次數作為索引值,按平衡規則存入二叉樹;對於雜湊表,採用某種字串雜湊演算法將散列出相同值(相同散列地址)的詞彙存放在同一個雜湊表項(或稱為散列桶)中,以便尋找。接著,簡要分析一下三者的尋找效率(假設詞彙數為n,詞彙平均長度為d):
(1)窮舉法,很顯然,要遍曆n個詞,每個詞耗費的字串比較時間是O(d),所以總時間複雜度為O(dn);
(2)平衡尋找二叉樹,尋找一個詞的時間為O(logn),則總尋找時間複雜度為O(dlogn);
(3)雜湊表,計算一個詞的雜湊值花時為O(d),假設所有詞雜湊出來的散列桶個數為m,詞彙都平均地分布在這m個桶裡,則可得總耗費的時間為O(d+n/m*d)=O(n/m*d)。
現在,介紹一種新的資料結構-字典樹,它是專門用來進行文本的查存處理,且在文本的儲存空間和尋找效率上都優於平衡二叉樹和雜湊表。
2、概念
假設有一本英文字典,我們要尋找某個單詞,一般先在索引目錄中尋找該詞的首字母構成的單詞集合,然後在首字母的單詞集合中尋找第二個字母的子集合,以此類推,直到尋找到整個單詞,而字典樹的構成和尋找方式則與上述實體字典類似。
字典樹(Tire),又稱單詞尋找樹,是一種樹形結構,用於儲存大量的字串,它的優點是:利用字串的公用首碼來節約儲存空間,從而也能有效地提高尋找效率。其基本性質有:
(1)根節點不包含字元,除根節點外每一個節點都只包含一個字元;
(2)從根節點到某一節點,路徑上經過的字元串連起來,為該節點對應的字串;
(3)每個節點的所有子節點包含的字元都不相同。
3、字典樹的構建
從第二節的概念來理解,不難想到字典樹的構建方法。比如有b,abc,abd,bcd,abcd,efg,hii 這6個單詞,則所構建的字典樹如下圖:
從上圖來看,對每一個節點而言,從根節點到它的路徑就是一個單詞,如果該節點被標記為紅色則說明該單詞存在。以第一節定義的時間單位為準,從尋找效率來看,假設將文章讀入字典樹後,在每一個單詞的末字母節點上標記上它出現的次數,則後續尋找一個單詞出現的總次數所花費的時間僅是O(d)。從儲存空間上來看,第一節的三種結構都需要儲存所有的單詞,比如ab、abc、abde三個詞需要儲存所有單詞的所有字母共9個字母,而字典樹則可以利用相同首碼的單詞共用首碼空間的特性,只需要儲存5個字母。所以無論從時間效率還是空間容量來說,字典樹對於大量字串資料的處理都是優於一般的資料結構的。
字典樹的基本操作有尋找、插入和刪除,當然刪除操作比較少見。本文只是實現了對整個樹的刪除操作,至於單個詞的刪除操作也很簡單。在字典樹中搜尋關鍵詞的步驟為:(1) 從根結點開始一次搜尋;(2) 取得要尋找關鍵詞的第一個字母,並根據該字母選擇對應的子樹並轉到該子樹繼續進行檢索;(3) 在相應的子樹上,取得要尋找關鍵詞的第二個字母,並進一步選擇對應的子樹進行檢索。(4) 迭代過程…… (5) 在某個結點處,關鍵詞的所有字母已被取出,則讀取附在該結點上的資訊,即完成尋找,其他動作類似處理。一個簡易字典樹結構的操作簡要代碼如下:
/***************************************************Name: Trie樹的基本實現 Description: Trie樹的基本實現,包括尋找、插入和刪除操作***************************************************/#include<algorithm>#include<iostream>using namespace std;const int sonnum=26,base='a';struct Trie{ int num;//記錄多少單詞途徑該節點,即多少單詞擁有以該節點為末尾的首碼 bool terminal;//若terminal==true,該節點沒有後續節點 int count;//記錄單詞的出現次數,此節點即一個完整單詞的末尾字母 struct Trie *son[sonnum];//後續節點};/*********************************建立一個新節點*********************************/Trie *NewTrie(){ Trie *temp=new Trie; temp->num=1; temp->terminal=false; temp->count=0; for(int i=0;i<sonnum;++i)temp->son[i]=NULL; return temp;}/*********************************插入一個新詞到字典樹pnt:樹根s :新詞len:新詞長度*********************************/void Insert(Trie *pnt,char *s,int len){ Trie *temp=pnt; for(int i=0;i<len;++i) { if(temp->son[s[i]-base]==NULL)temp->son[s[i]-base]=NewTrie(); else {temp->son[s[i]-base]->num++;temp->terminal=false;} temp=temp->son[s[i]-base]; } temp->terminal=true; temp->count++;}/*********************************刪除整棵樹pnt:樹根*********************************/void Delete(Trie *pnt){ if(pnt!=NULL) { for(int i=0;i<sonnum;++i)if(pnt->son[i]!=NULL)Delete(pnt->son[i]); delete pnt; pnt=NULL; }}/*********************************尋找單詞在字典樹中的末尾節點pnt:樹根s :單詞len:單詞長度*********************************/Trie* Find(Trie *pnt,char *s,int len){ Trie *temp=pnt; for(int i=0;i<len;++i) if(temp->son[s[i]-base]!=NULL)temp=temp->son[s[i]-base]; else return NULL; return temp;}
上述字典樹節點結構Tire裡的成員除了後續節點群組son之外,都是節點儲存的資訊,設計者可以根據需求進行修改。
4、應用
從以上幾節可以看出,字典樹的應用主要用于海量字串資料的統計分析,以下列舉了兩個簡單的字典樹應用例子:
(1)有一個簡單的搜尋引擎,會將使用者每天輸入的檢索詞進行儲存,並定期進行統計,查出頻率最高的幾個檢索詞。其實這就是將第一節的例子稍微執行個體化了一點,在搜尋引擎後台建立一個字典樹,在使用者輸入檢索詞的時候,如果該詞不在字典樹中將該詞插入字典樹,否則在該詞的末節點的次數儲存標記加1,則在定期統計時,計算各個節點的儲存標記次數即可得檢索頻率最高的詞了。
(2)給定N個單詞,能以較小的時間複雜度將這N個單詞按字典序排序。如果將這N個單詞讀入普通的字串數組,則排序的時候使用普通排序或快速排序所耗費的時間為O(N^2)或O(NlogN);如果將這N個單詞讀入字典樹(節點的子節點字元值從左至右為字典序),則採用對字典樹的前序走訪就能輸出排好序的單詞表,時間複雜度為O(Nd)。