我是好文章的搬運工,原文來自博客園,博主July_,地址:http://www.cnblogs.com/v-July-v/archive/2011/10/22/2316412.htmlhtml
常關注本blog的讀者朋友想必看過此篇文章:從B樹、B+樹、B*樹談到R 樹,此次,我們來說另外兩種樹:Tire樹與後綴樹。不過,在此以前,先來看兩個問題。
第一個問題: 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間複雜度分析。node
以前在此文:海量數據處理面試題集錦與Bit-map詳解中給出的參考答案:用trie樹統計每一個詞出現的次數,時間複雜度是O(n*le)(le表示單詞的平均長度),而後是找出出現最頻繁的前10個詞。也能夠用堆來實現(具體的操做可參考第三章、尋找最小的k個數),時間複雜度是O(n*lg10)。因此總的時間複雜度,是O(n*le)與O(n*lg10)中較大的哪個。ios
第二個問題:找出給定字符串裏的最長迴文。例子:輸入XMADAMYX。則輸出MADAM。這道題的流行解法是用後綴樹(Suffix Tree),但其用途遠不止如此,它能高效解決一大票複雜的字符串編程問題(固然,它有它的弱點,如算法實現複雜以及空間開銷大),歸納以下:
c++
本文第一部分,我們就來了解這個Trie樹,而後天然而然過渡到第二部分、後綴樹,接着進入第三部分、詳細闡述後綴樹的構造方法-Ukkonen,最後第四部分、對自動機,KMP算法,Extend-KMP,後綴樹,後綴數組,trie樹,trie圖及其應用作個全文歸納性總結。權做此番闡述,以備不時之需,在須要的時候即可手到擒來。ok,有任何問題,歡迎不吝指正或賜教。謝謝。程序員
Trie樹,即字典樹,又稱單詞查找樹或鍵樹,是一種樹形結構,是一種哈希樹的變種。典型應用是用於統計和排序大量的字符串(但不只限於字符串),因此常常被搜索引擎系統用於文本詞頻統計。它的優勢是:最大限度地減小無謂的字符串比較,查詢效率比哈希表高。面試
Trie的核心思想是空間換時間。利用字符串的公共前綴來下降查詢時間的開銷以達到提升效率的目的。
它有3個基本性質:算法
當時第一次看到這幅圖的時候,便立馬感到此樹之不凡構造了。單單從上幅圖即可窺知一二,比如大海搜人,立馬就能肯定東南西北中的到底哪一個方位,如此迅速縮小查找的範圍和提升查找的針對性,不失爲一創舉。
能夠看出:
數據庫
查詢操縱很是簡單。好比要查找int,順着路徑i -> in -> int就找到了。
搭建Trie的基本算法也很簡單,無非是逐一把每則單詞的每一個字母插入Trie。插入前先看前綴是否存在。若是存在,就共享,不然建立對應的節點和邊。好比要插入單詞add,就有下面幾步:
編程
Trie樹的單詞查詢實現 數組
如下是trie樹的簡單實現。下圖所示的測試只是作了一個很是簡單的檢測而已,先插入j然後查找j,再刪除再查找,目的主要是看刪除函數是否有效。往後再好好寫下trie樹用於單詞頻率統計的實現(單詞統計hash表固然也能夠實現,只不過若是用trie樹統計單詞出現頻率,想象一下,當樹中已有某個單詞,再次遍歷到一樣的單詞,即可以迅速高效的查詢找到某個單詞,爲其出現計數+1,這得益於查找高效的所帶來的好處)。
//copyright@singmelody //updated@2011 July #include <stdio.h> #include <malloc.h> #include <string.h> #define true 1 #define false 0 struct trieNode { trieNode():isword(false) { memset(next, 0, sizeof(next)); } trieNode *next[26]; bool isword; }Root; void insert(char *tar) { trieNode *p =&Root; int id; while(*tar) { id = *tar-'a'; if(p->next[id] == NULL) { p->next[id] =(trieNode *)malloc(sizeof(trieNode)); } p = p->next[id]; tar++; } p->isword = true; } //找到返回 true 不然返回false int search(char *tar) { trieNode *p = &Root; int id; while(*tar) { id = *tar - 'a'; if (p->next[id] == NULL) { return false; } p = p->next[id]; tar++; } //判斷結點是否標記 if (p->isword == true) return true; else return false; } void remove(char *tar) { trieNode *p =&Root; int id; while(*tar) { id = *tar-'a'; p = p->next[id]; tar++; } p->isword = false; } void searchprocess() { char searchstr[20]; printf("Please search:\n"); scanf("%s",searchstr); printf("Now searching %s:\n",searchstr); if (search(searchstr)==true) { printf("Success\n"); } else { printf("Fail\n"); } } int main() { //..... return 0; }
Trie樹單詞頻率統計實現
如下是用Trie樹統計單詞頻率的實現,程序尚不完善,有不少地方還需改進。
// trie tree.cpp : 定義控制檯應用程序的入口點。 // #include "stdafx.h" //功能:統計一段英文的單詞頻率(文章以空格分隔,沒有標點) //思路:trie節點保存單詞頻率,而後經過DFS按字典序輸出詞頻 //時空複雜度: O(n*len)(len爲單詞平均長度) //copyright@yansha 2011.10.25 //updated@July 2011.10.26 //程序尚不完善,有不少地方還需改進。 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #include <assert.h> #define num_of_letters 26 #define max_word_length 20 // 定義trie樹節點 struct Trie { int count; Trie *next[num_of_letters]; }; // 定義根節點 Trie *root = NULL; /** * 創建trie樹,同時保存單詞頻率 */ void create_trie(char *word) { int len = strlen(word); Trie *cur = root, *node; int pos = 0; // 深度爲單詞長度 for(int i = 0; i < len; ++i) { // 將字母範圍映射到0-25之間 pos = word[i] - 'a'; // 若是當前字母沒有對應的trie樹節點則創建,不然處理下一個字母 if(cur->next[pos] == NULL) //一、這裏應該有個查找過程 { node = (Trie *)malloc(sizeof(Trie)); node->count = 0; // 初始化next節點 for(int j = 0; j < num_of_letters; ++j) node->next[j] = NULL; // 開始處理下一個字母 cur->next[pos] = node; } cur = cur->next[pos]; } // 單詞頻率加1 cur->count++; } /** * 大寫字母轉化成小寫字母 */ void upper_to_lower(char *word, int len) { for (int i = 0; i < len; ++i) { if(word[i] >= 'A' && word[i] <= 'Z') word[i] += 32; } } /** * 處理輸入 */ void process_input() { char word[max_word_length]; // 打開統計文件(注意保持文件名一致) FILE *fp_passage = fopen("passage.txt", "r"); assert(fp_passage); // 循環處理單詞 while (fscanf(fp_passage, "%s", word) != EOF) { int len = strlen(word); if (len > 0) upper_to_lower(word, len); create_trie(word); } fclose(fp_passage); } /** * 深度優先遍歷 */ void trie_dfs(Trie *p, char *queue) { for(int i = 0; i < num_of_letters; ++i) { if(p->next[i] != NULL) { // 定義隊列頭結點 char *head = queue; // 在末尾增長一個字母 while (*queue != '\0') queue++; *queue = (char)(i + 'a'); queue = head; // 在控制檯打印單詞及其頻率 if (p->next[i]->count > 0) printf("%s\t%d\n", queue, p->next[i]->count); trie_dfs(p->next[i], queue); // 在末尾去掉一個字母 head = queue; while (*(queue+1) != '\0') queue++; *queue = '\0'; queue = head; } } } int main() { // 初始化trie樹根節點 root = (Trie *)malloc(sizeof(Trie)); for(int j = 0; j < num_of_letters; ++j) root->next[j] = NULL; // 處理輸入 process_input(); // 分配一個保存單詞中間結果的隊列 char *queue = (char*) calloc(max_word_length, sizeof(char)); // 經過DFS打印結果 trie_dfs(root, queue); system("pause"); return 0; }
後綴樹(Suffix tree)是一種數據結構,能快速解決不少關於字符串的問題。後綴樹的概念最先由Weiner 於1973年提出,既而由McCreight 在1976年和Ukkonen在1992年和1995年加以改進完善。
後綴,顧名思義,甚至通俗點來講,就是所謂後綴就是後面尾巴的意思。好比說給定一長度爲n的字符串S=S1S2..Si..Sn,和整數i,1 <= i <= n,子串SiSi+1...Sn便都是字符串S的後綴。
以字符串S=XMADAMYX爲例,它的長度爲8,因此S[1..8], S[2..8], ... , S[8..8]都算S的後綴,咱們通常還把空字串也算成後綴。這樣,咱們一共有以下後綴。對於後綴S[i..n],咱們說這項後綴起始於i。
S[1..8], XMADAMYX, 也就是字符串自己,起始位置爲1
S[2..8], MADAMYX,起始位置爲2
S[3..8], ADAMYX,起始位置爲3
S[4..8], DAMYX,起始位置爲4
S[5..8], AMYX,起始位置爲5
S[6..8], MYX,起始位置爲6
S[7..8], YX,起始位置爲7
S[8..8], X,起始位置爲8
空字串,記爲$。
然後綴樹,就是包含一則字符串全部後綴的壓縮Trie。把上面的後綴加入Trie後,咱們獲得下面的結構:
仔細觀察上圖,咱們能夠看到很多值得壓縮的地方。好比藍框標註的分支都是獨苗,沒有必要用單獨的節點同邊表示。若是咱們容許任意一條邊裏包含多個字 母,就能夠把這種沒有分叉的路徑壓縮到一條邊。另外每條邊已經包含了足夠的後綴信息,咱們就不用再給節點標註字符串信息了。咱們只須要在葉節點上標註上每 項後綴的起始位置。因而咱們獲得下圖:
這樣的結構丟失了某些後綴。好比後綴X在上圖中消失了,由於它正好是字符串XMADAMYX的前綴。爲了不這種狀況,咱們也規定每項後綴不能是其它後綴的前綴。要解決這個問題其實挺簡單,在待處理的子串後加一個空字串就好了。例如咱們處理XMADAMYX前,先把XMADAMYX變爲 XMADAMYX$,因而就獲得suffix tree--後綴樹了,以下圖所示:
那後綴樹同最長迴文有什麼關係呢?咱們得先知道兩個簡單概念:
有了上面的概念,本文引言中提出的查找最長迴文問題就相對簡單了。我們來回顧下引言中提出的迴文問題的具體描述:找出給定字符串裏的最長迴文。例如輸入XMADAMYX,則輸出MADAM。
思惟的突破點在於考察迴文的半徑,而不是迴文自己。所謂半徑,就是迴文對摺後的字串。好比迴文MADAM 的半徑爲MAD,半徑長度爲3,半徑的中心是字母D。顯然,最長迴文必有最長半徑,且兩條半徑相等。仍是以MADAM爲例,以D爲中心往左,咱們獲得半徑 DAM;以D爲中心向右,咱們獲得半徑DAM。兩者確定相等。由於MADAM已是單詞XMADAMYX裏的最長迴文,咱們能夠確定從D往左數的字串 DAMX與從D往右數的子串DAMYX共享最長前綴DAM。而這,正是解決迴文問題的關鍵。如今咱們有後綴樹,怎麼把從D向左數的字串DAMX變成後綴 呢?
到這個地步,答案應該明顯:把單詞XMADAMYX翻轉(XMADAMYX=>XYMADAMX,DAMX就變成後綴了)就好了。因而咱們把尋找回文的問題轉換成了尋找兩坨後綴的LCA的問題。固然,咱們還須要知道 到底查詢那些後綴間的LCA。很簡單,給定字符串S,若是最長迴文的中心在i,那從位置i向右數的後綴恰好是S(i),而向左數的字符串恰好是翻轉S後獲得的字符串S‘的後綴S'(n-i+1)。這裏的n是字符串S的長度。
可能上面的闡述還不夠直觀,我再細細說明下:
一、首先,還記得本第二部分開頭關於後綴樹的定義麼: 「先說說後綴的定義,顧名思義,甚至通俗點來講,就是所謂後綴就是後面尾巴的意思。好比說給定一長度爲n的字符串S=S1S2..Si..Sn,和整數i,1 <= i <= n,子串SiSi+1...Sn便都是字符串S的後綴。」
以字符串S=XMADAMYX爲例,它的長度爲8,因此S[1..8], S[2..8], ... , S[8..8]都算S的後綴,咱們通常還把空字串也算成後綴。這樣,咱們一共有以下後綴。對於後綴S[i..n],咱們說這項後綴起始於i。
S[1..8], XMADAMYX, 也就是字符串自己,起始位置爲1
S[2..8], MADAMYX,起始位置爲2
S[3..8], ADAMYX,起始位置爲3
S[4..8], DAMYX,起始位置爲4
S[5..8], AMYX,起始位置爲5
S[6..8], MYX,起始位置爲6
S[7..8], YX,起始位置爲7
S[8..8], X,起始位置爲8
空字串,記爲$。
二、對單詞XMADAMYX而言,迴文中心爲D,那麼D向右的後綴DAMYX假設是S(i)(當N=8,i從1開始計數,i=4時,即是S(4..8));而對於翻轉後的單詞XYMADAMX而言,迴文中心D向右對應的後綴爲DAMX,也就是S'(N-i+1)((N=8,i=4,即是S‘(5..8)) 。此刻已經能夠得出,它們共享最長前綴,即LCA(DAMYX,DAMX)=DAM。有了這套直觀解釋,算法天然呼之欲出:
用上圖作例子,i爲4時,LCA(4$, 5#)爲DAM,正好是最長半徑。固然,這只是直觀的敘述。
上面大體描述了後綴樹的基本思路。要想寫出實用代碼,至少還得知道下面的知識:
後綴樹的用途,總結起來大概有以下幾種
後綴樹的代碼實現,下期再續。第二部分、後綴樹完。
接下來,我們來了解後綴樹的構造方法-Ukkomen。爲了兼顧上文內容,以及加深印象,本部分打算從Trie樹從頭到位從新開始闡述一切。
Ukkonen的構造法O(n), 它比Sartaj Sahni的構造法O(nr), r爲字母表大小 在時間上更有優點. 但咱們不能說Sartaj Sahni的算法慢, 由於r每每會很小, 所以實際效率也接近線性, 兩種構造法在思想上均有可取之處.
字符串匹配問題是程序員常常要面對的問題. 字符串匹配算法的改進能夠使許多工程受益良多, 好比數據壓縮和DNA排列。你能夠把本身想象成一名工做於DNA排列工程的程序員. 那些基因研究者們每天忙着分切病毒的基因材料, 製造出一段一段的核苷酸序列. 他們把這些序列發到你的服務器裏, 期望你在基因數據庫中定位. 要知道, 你的數據庫裏有數百種病毒的數據, 而一個特定的病毒能夠有成千上萬的鹼基. 你的程序必須像C/S工程那樣實時向博士們反饋信息, 這須要一個很好的方案。
很明顯, 在這個問題上採起暴力算法是極其低效的. 這種方法須要你在基因數據庫裏對比每個核苷酸, 測試一個較長的基因段基本會把你的C/S系統變成一臺古老的批處理機。
因爲基因數據庫通常是不變的, 經過預處理來把搜索簡化或許是個好主意. 一種預處理的方法是創建一棵Trie. 咱們經過Trie引伸出一種東西叫做後綴Trie. (後綴Trie離後綴樹僅一步之遙.) 首先, Trie是一種n叉樹, n爲字母表大小, 每一個節點表示從根節點到此節點所通過的全部字符組成的字符串. 然後綴Trie的 「後綴」 說明這棵Trie包含了所給字段的全部後綴 (也許正是一個病毒基因).
圖1 BANANAS的後綴Trie
上展現了文本BANANAS的後綴Trie. 關於這棵Trie有兩個地方須要注意. 第一, 從根節點開始, BANANAS的每個後綴都插入到Trie中, 包括BANANAS, ANANAS, NANAS, ANAS, NAS, AS, S. 第二, 鑑於這種結構, 你能夠經過從根節點往下匹配的方式搜索到單詞的任何一個子串.
這裏所說的第二點正是咱們認爲後綴Trie優秀的緣由. 若是你輸入一個長度爲N的文本並想在其中搜索一個長度爲M的串, 傳統的暴力匹配須要進行N*M次字符對比, 而一些改進過的匹配技術, 好比像Boyer-Moore算法, 能夠在O(N+M)的時間開銷內解決問題, 平均效率更是使人滿意. 然而, 後綴Trie亮出了O(M)的牌子, 完全鄙視了其餘算法的成績, 後綴Trie對比的次數僅僅至關於被搜索串的長度!
這確實是可圈可點的威力, 這意味着你能經過僅僅7次對比便在莎士比亞全部做品中找出BANANAS. 但有一點咱們可不能忘了, 構造後綴Trie也是須要時間的.
後綴Trie之因此沒有家喻戶曉正是由於構造它須要O(n2)的時間和空間. 平方級的開銷使它在最須要它的領域 --- 長串搜索 中被拒之門外.
直到1976年, Edward McCreigh發表了一篇論文, 我們的後綴樹問世了. 後綴Trie的困境被完全打破.
後綴樹跟後綴Trie有着同樣的佈局, 但它把只有一個兒子的節點給剔除了. 這個過程被稱爲路徑壓縮, 這意味着樹上的某些邊將表示一個序列而不是單獨的字符.
圖2 BANANAS的後綴樹
圖2是由圖1的後綴Trie轉化而來的後綴樹. 你會發現這樹基本仍是那個形狀, 只是節點變少了. 在剔除了只有一個兒子的節點以後, 總節點數由23降爲11. 通過證實, 在最壞狀況下, 後綴樹的節點數也不會超過2N (N爲文本的長度). 這使構造後綴樹的線性時空開銷成爲可能.
然而, McCreight最初的構造法是有些缺陷的, 原則上它要按逆序構造, 也就是說字符要從末端開始插入. 如此一來, 便不能做爲在線算法, 它變得更加難以應用於實際問題, 如數據壓縮.
20年後, 來自赫爾辛基理工大學的Esko Ukkonen把原算法做了一些改動, 把它變成了從左往右. 本文接下來的全部描述和代碼都是基於Esko Ukkonen的成果.
對於所給的文本T, Esko Ukkonen的算法是由一棵空樹開始, 逐步構造T的每一個前綴的後綴樹. 好比咱們構造BANANAS的後綴樹, 先由B開始, 接着是BA, 而後BAN, … . 不斷更新直到構造出BANANAS的後綴樹.
圖3 逐步構造後綴樹
加入一個新的前綴須要訪問樹中已有的後綴. 咱們從最長的一個後綴開始(圖3中的BAN), 一直訪問到最短的後綴(空後綴). 每一個後綴會在如下三種節點的其中一種結束.
圖4 加入BOOK以後的BOOKKEEPER
(也就是BOOK的後綴樹)
如圖4, 在加入BOOK以後, 樹中有5個後綴(包括空後綴). 那麼要構造下一個前綴BOOKK的後綴樹的話, 只須要訪問樹中已存在的每個後綴, 而後在它們的末尾加上K.
前4個後綴BOOK, OOK, OK和K都在葉節點上結束. 因爲咱們要路徑壓縮, 只須要在通往葉節點的邊上直接加一個字符, 而不須要建立一個新節點.
在全部葉節點更新以後, 咱們還須要在空後綴後面加上K. 這時候咱們發現已經存在一條從0節點出發的邊的首字符爲K, 不必多此一舉了. 換句話說, 新加入的後綴K能夠在0節點和2節點之間的隱式節點中找到. 最終形態見圖5.
圖5 加入BOOKK以後的BOOKKEEPER
相比圖4, 樹的結構沒有發生變化
若是你是一位敏感的讀者, 可能要發問了, 若是加入K咱們什麼都不作的話, 在查找的時候如何知道它究竟是一個後綴呢仍是某個後綴的一截? 若是你同時又是一位熟悉字符串算法的朋友, 內心可能立刻就有答案了 --- 咱們只須要在文本後面加個字母表之外的字符, 好比$或者#. 那咱們查找到K$或K#的話就說明這是一個後綴了.
從圖4到圖5這個更新過程是相對簡單的, 其中咱們執行了兩種更新: 一種是將某條邊延長, 另外一種是啥都不作. 但接下來往圖5繼續加入BOOKKE, 咱們則會遇到另外兩種更新:
圖6先分割, 再添加
當咱們往圖5的樹中加入BOOKKE的時候, 咱們是從已存在的最長後綴BOOKK開始, 一直操做到最短的後綴空後綴. 更新最長的後綴必然是更新葉節點, 以前提到了, 很是簡單. 除此以外, 圖5中結束在葉節點上的後綴還有OOKK, OKK, KK. 圖6的第一棵樹展現了這一類節點的更新.
圖5中首個不是結束在葉節點上的後綴是K. 這裏咱們先引入一個定義:
在每次更新後綴樹的過程當中, 第一個非葉節點稱爲激活節點. 它有如下性質:
後綴K在邊KKE上的隱式節點結束. 在後綴樹中咱們要判斷一個節點是否是非葉節點須要看它是否有跟待加入字符相同的兒子, 即本例中的E.
一眼能夠看出, KKE中的第一個K只有一個兒子: K. 因此它是非葉節點(這裏同時也是激活節點), 咱們要給他加一個兒子來表示E. 這個過程有兩個步驟:
後綴K更新以後, 別忘了還有空後綴. 空後綴在根節點(節點0)結束, 顯然此時根節點是一個顯式節點. 咱們看一下它後面有沒有以E開頭的邊---沒有, 那麼加入一個新的葉節點(若是存在以E開頭的邊, 則不用任何操做). 最終如圖7.
圖7
藉助後綴樹的特性, 咱們能夠作出一個至關有效的算法. 首先一個重要的特性是: 一朝爲葉, 終生爲葉. 一個葉節點自誕生之後毫不會有子孫. 更重要的是, 每當咱們往樹上加入一個新的前綴, 每一條通往葉節點的邊都會延長一個字符(新前綴的最後一個字符). 這使得處理通往葉節點的邊變得異常簡單, 咱們徹底能夠在建立葉節點的時候就把當前字符到文本末的全部字符一股腦塞進去. 是的, 咱們不須要知道後面的字符是啥, 但咱們知道它們最終都要被加進去. 所以, 一個葉節點誕生的時候, 也正是它能夠被咱們遺忘的時候. 你可能會擔憂通往葉節點的邊被分割了怎麼辦, 那也沒關係, 分割以後只是起點變了, 尾部該怎麼着仍是怎麼着.
如此一來, 咱們只須要關心顯式節點和隱式節點上的更新.
還要提到一個節約時間的方法. 當咱們遍歷全部後綴時, 若是某個後綴的某個兒子跟待加字符(新前綴最後一個字符)相同, 那麼咱們當前前綴的全部更新就能夠中止了. 若是你理解了後綴樹的本質, 你會知道一旦待加字符跟某個後綴的某個兒子相同, 那麼更短的後綴必然也有這個兒子. 咱們不妨把首個這樣的節點定義爲結束節點. 比結束節點長的後綴必然是葉節點, 這一點很好解釋, 要麼原本就是葉節點, 要麼就是新建立的節點(新建立的必然是葉節點). 這意味着, 每個前綴更新完以後, 當前的結束節點將成爲下一輪更新的激活節點.
好了, 如今咱們能夠把後綴樹的更新限制在激活節點和結束節點之間, 效率有了很大的改善. 整理成僞代碼以下:
Update( 新前綴 ) { 當先後綴 = 激活節點 待加字符 = 新前綴最後一個字符 done = false; while ( !done ) { if ( 當先後綴在顯式節點結束 ) { if ( 當前節點後沒有以待加字符開始的邊 ) 在當前節點後建立一個新的葉節點 else done = true; } else { if ( 當前隱式節點的下一個字符不是待加字符 ) { 從隱式節點後分割此邊 在分割處建立一個新的葉節點 } else done = true; if ( 當先後綴是空後綴 ) done = true; else 當先後綴 = 下一個更短的後綴 } 激活節點 = 當先後綴 }
上面的僞代碼看上去很完美, 但它掩蓋了一個問題. 注意到第21行, 「下一個更短的後綴」, 若是呆板地沿着樹枝去搜索咱們想要的後綴, 那這種算法就不是線性的了. 要解決此問題, 咱們得附加一種指針: 後綴指針. 後綴指針存在於每一個結束在非葉節點的後綴上, 它指向「下一個更短的後綴」. 即, 若是一個後綴表示文本的第0到第N個字符, 那麼它的後綴指針指向的節點表示文本的第1到第N個字符.
圖8是文本ABABABC的後綴樹. 第一個後綴指針在表示ABAB的節點上. ABAB的後綴指針指向表示BAB的節點. 一樣地, BAB也有它的後綴指針, 指向AB. 如此這般.
圖8 加上後綴指針(虛線)的ABABABC的後綴樹
介紹一下如何建立後綴指針. 後綴指針的建立是跟後綴樹的更新同步的. 隨着咱們從激活節點移動到結束節點, 我把每一個新的葉節點的父親的路徑保存下來. 每當建立一條新邊, 我同時也在上一個葉節點的父親那兒建立一個後綴指針來指向當前新邊開始的節點. (顯然, 咱們不能在第一條新邊上作這樣的操做, 但除此以外均可以這麼作.)
有了後綴指針, 就能夠方便地一個後綴跳到另外一個後綴. 這個關鍵性的附加品使得算法的時間上限成功降爲O(N)。
涉及到字符串的問題,無外乎這樣一些算法和數據結構:自動機,KMP算法,Extend-KMP,後綴樹,後綴數組,trie樹,trie圖及其應用。固然這些都是比較高級的數據結構和算法,而這裏面最經常使用和最熟悉的大概是kmp,即便如此仍是有至關一部分人也不理解kmp,更別說其餘的了。固然通常的字符串問題中,咱們只要用簡單的暴力算法就能夠解決了,而後若是暴力效率過低,就用個hash。固然hash也是一個面試中常常被用到的方法。這樣看來,這樣的一些算法和數據結構實際上不多會被問到,不過若是使用它們通常能夠獲得很好的線性複雜度的算法。
老實說,字符串問題的確挺複雜的,出來一個若是用暴力,hash搞不定,就很難再想其餘的方法,固然有些能夠用動態規劃。下圖主要說明下這些算法數據結構之間的關係。圖中黃色部分主要寫明瞭這些算法和數據結構的一些關鍵點。
圖中能夠看到這樣一些關係:extend-kmp 是kmp的擴展;ac自動機是kmp的多串形式;它是一個有限自動機;而trie圖其實是一個肯定性有限自動機;ac自動機,trie圖,後綴樹實際上都是一種trie;後綴數組和後綴樹都是與字符串的後綴集合有關的數據結構;trie圖中的後綴指針和後綴樹中的後綴連接這兩個概念及其一致。
KMP算法請參考本博客內的這兩篇文章:6、教你從頭至尾完全理解KMP算法、updated,六(續)、從KMP算法一步一步談到BM算法。
後綴樹的構造能夠用Ukkonen算法在線性時間內完成[,可是不只構造算法實現至關複雜,並且後綴樹存在着致命弱點:空間開銷大且對大字母表時間效率不理想。至於後綴數組下次闡述,這裏簡單介紹下extend-kmp。而在介紹extend-kmp以前,我們先要回顧下KMP算法。
void next_comp(char * str){ int next[N+1]; int k = 0; next[1] = 0; //循環不變性,每次循環的開始,k = next[i-1] for(int i = 2 ; i <= N ; i++){ //若是當前位置不匹配,或者還推動到字符串開始,則繼續推動 while(A[k+1] != A[i] && k != 0){ k = next[k]; } if(A[k+1] == A[i]) k++; next[i] = k; } }
圖1 虛擬單一繼承
圖2 虛擬多重繼承
// virtual.cpp : 定義控制檯應用程序的入口點。 //#include "stdafx.h" #include <iostream> using namespace std; class Base1 { public: Base1(){} virtual ~Base1(){} virtual void speakClearly(){} virtual Base1* clone() const{ // cout<<"it is Base1"<<endl; // return ; } protected: float data_Base1; }; class Base2 { public: Base2(){} virtual ~Base2(){} virtual void mumble(){} virtual Base2* clone() const{ // cout<<"it is Base2"<<endl; // return; } protected: float data_Base2; }; class Derived:public Base1,public Base2 { public: Derived(){} virtual ~Derived(){} virtual Derived* clone() const { // cout<<"it is Derived"<<endl; // return; } protected: float data_Dervied; }; int main() { Base1* p1=new Derived(); p1->clone(); delete p1; return 0; }