海量數據處理---從Trie樹(字典樹)談到後綴樹

 文章轉載博客:https://blog.csdn.net/v_july_v/article/details/6897097

第一個問題: 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間複雜度分析。

    之前在此文:海量數據處理面試題集錦與Bit-map詳解中給出的參考答案:用trie樹統計每個詞出現的次數,時間複雜度是O(n*le)(le表示單詞的平均長度),然後是找出出現最頻繁的前10個詞。也可以用堆來實現(具體的操作可參考第三章、尋找最小的k個數),時間複雜度是O(n*lg10)。所以總的時間複雜度,是O(n*le)與O(n*lg10)中較大的哪一個。

    第二個問題:找出給定字符串裏的最長迴文。例子:輸入XMADAMYX。則輸出MADAM。這道題的流行解法是用後綴樹(Suffix Tree),但其用途遠不止如此,它能高效解決一大票複雜的字符串編程問題(當然,它有它的弱點,如算法實現複雜以及空間開銷大),概括如下: 

  • 查詢字符串S是否包含子串S1。主要思想是:如果S包含S1,那麼S1必定是S的某個後綴的前綴;又因爲S的後綴樹包含了所有的後綴,所以只需對S的後綴樹使用和Trie相同的查找方法查找S1即可(使用後綴樹實現的複雜度同流行的KMP算法的複雜度相當)。 
  • 找出字符串S的最長重複子串S1。比如abcdabcefda裏abc同da都重複出現,而最長重複子串是abc。 
  • 找出字符串S1同S2的最長公共子串。注意最長公共子串(Longest CommonSubstring)和最長公共子序列(LongestCommon Subsequence, LCS)的區別:子串(Substring)是串的一個連續的部分,子序列(Subsequence)則是從不改變序列的順序,而從序列中去掉任意的元素而獲得的新序列;更簡略地說,前者(子串)的字符的位置必須連續,後者(子序列LCS)則不必。比如字符串acdfg同akdfc的最長公共子串爲df,而他們的最長公共子序列是adf。LCS可以使用動態規劃法解決。
  • Ziv-Lampel無損壓縮算法。 LZW算法的基本原理是利用編碼數據本身存在字符串重複特性來實現數據壓縮,所以一個很好的選擇是使用後綴樹的形式來組織存儲字符串及其對應壓縮碼值的字典。
  • 找出字符串S的最長迴文子串S1。例如:XMADAMYX的最長迴文子串是MADAM(此即爲上面所說的第二個問題:最長迴文問題,本文第二部分將詳細闡述此問題)。
  • 多模式串的模式匹配問題(suffix_array + 二分)。

   本文第一部分,咱們就來了解這個Trie樹,然後自然而然過渡到第二部分、後綴樹,接着進入第三部分、詳細闡述後綴樹的構造方法-Ukkonen,最後第四部分、對自動機,KMP算法,Extend-KMP,後綴樹,後綴數組,trie樹,trie圖及其應用做個全文概括性總結。權作此番闡述,以備不時之需,在需要的時候便可手到擒來。ok,有任何問題,歡迎不吝指正或賜教。謝謝。

第一部分、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、911、9114、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也是可以實現邊建立,邊查找。

    至於,有關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樹還能解決下述問題(節選自此文:海量數據處理面試題集錦與Bit-map詳解):

  • 3、有一個1G大小的一個文件,裏面每一行是一個詞,詞的大小不超過16字節,內存限制大小是1M。返回頻數最高的100個詞。
  • 9、1000萬字符串,其中有些是重複的,需要把重複的全部去掉,保留沒有重複的字符串。請怎麼設計和實現?
  • 10、 一個文本文件,大約有一萬行,每行一個詞,要求統計出其中最頻繁出現的前10個詞,請給出思想,給出時間複雜度分析。
  • 13、尋找熱門查詢:搜索引擎會通過日誌文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度爲1-255字節。假設目前有一千萬個記錄,這些查詢串的重複讀比較高,雖然總數是1千萬,但是如果去除重複和,不超過3百萬個。一個查詢串的重複度越高,說明查詢它的用戶越多,也就越熱門。請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。
    (1) 請描述你解決這個問題的思路;
    (2) 請給出主要的處理流程,算法,以及算法的複雜度。

    有了Trie,後綴樹就容易理解了。本文接下來的第二部分,介紹後綴樹。

 

第二部分、後綴樹

2.1、後綴樹的定義    

    後綴樹(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--後綴樹了,如下圖所示:

2.2、後綴樹與迴文問題的關聯

    那後綴樹同最長迴文有什麼關係呢?我們得先知道兩個簡單概念:

  • 最低共有祖先,LCA(Lowest Common Ancestor),也就是任意兩節點(多個也行)最長的共有前綴。比如下圖中,節點7同節點1的共同祖先是節點5與節點10,但最低共同祖先是5。 查找LCA的算法是O(1)的複雜度,當然,代價是需要對後綴樹做複雜度爲O(n)的預處理。 

  • 廣義後綴樹(Generalized Suffix Tree)。傳統的後綴樹處理一坨單詞的所有後綴。廣義後綴樹存儲任意多個單詞的所有後綴。例如下圖是單詞XMADAMYX與XYMADAMX的廣義後綴 樹。注意我們需要區分不同單詞的後綴,所以葉節點用不同的特殊符號與後綴位置配對。 

 

 

2.3、最長迴文問題的解決

    有了上面的概念,本文引言中提出的查找最長迴文問題就相對簡單了。咱們來回顧下引言中提出的迴文問題的具體描述:找出給定字符串裏的最長迴文。例如輸入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的長度。

    可能上面的闡述還不夠直觀,我再細細說明下:

    1、首先,還記得本第二部分開頭關於後綴樹的定義麼: “先說說後綴的定義,顧名思義,甚至通俗點來說,就是所謂後綴就是後面尾巴的意思。比如說給定一長度爲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
                                  空字串,記爲$。

    2、對單詞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。有了這套直觀解釋,算法自然呼之欲出:

  1. 預處理後綴樹,使得查詢LCA的複雜度爲O(1)。這步的開銷是O(N),N是單詞S的長度 ;
  2. 對單詞的每一位置i(也就是從0到N-1),獲取LCA(S(i), S‘(N-i+1)) 以及LCA(S(i+1), S’(n-i+1))。查找兩次的原因是我們需要考慮奇數迴文和偶數迴文的情況。這步要考察每坨i,所以複雜度是O(N) ;
  3. 找到最大的LCA,我們也就得到了迴文的中心i以及迴文的半徑長度,自然也就得到了最長迴文。總的複雜度O(n)。 

    用上圖做例子,i爲4時,LCA(4$, 5#)爲DAM,正好是最長半徑。當然,這只是直觀的敘述。
    上面大致描述了後綴樹的基本思路。要想寫出實用代碼,至少還得知道下面的知識:

  • 創建後綴樹的O(n)算法。此算法有很多種,無論Peter Weiner的73年年度最佳算法,還是Edward McCreight1976的改進算法,還是1995年E. Ukkonen大幅簡化的算法(本文第4部分將重點闡述這種方法),還是Juha Kärkkäinen 和 Peter Sanders2003年進一步簡化的線性算法,都是O(n)的時間複雜度。至於實際中具體選擇哪一種算法,可依實際情況而定。 
  • 實現後綴樹用的數據結構。比如常用的子結點加兄弟節點列表,Directed 優化後綴樹空間的辦法。比如不存儲子串,而存儲讀取子串必需的位置。以及Directed Acyclic Word Graph,常縮寫爲黑哥哥們掛在嘴邊的DAWG。 

2.4、後綴樹的應用

    後綴樹的用途,總結起來大概有如下幾種 

 

  1. 查找字符串o是否在字符串S中。 
      方案:用S構造後綴樹,按在trie中搜索字串的方法搜索o即可。 
      原理:若o在S中,則o必然是S的某個後綴的前綴。 
    例如S: leconte,查找o: con是否在S中,則o(con)必然是S(leconte)的後綴之一conte的前綴.有了這個前提,採用trie搜索的方法就不難理解了。
  2. 指定字符串T在字符串S中的重複次數。 
      方案:用S+’$'構造後綴樹,搜索T節點下的葉節點數目即爲重複次數 
      原理:如果T在S中重複了兩次,則S應有兩個後綴以T爲前綴,重複次數就自然統計出來了。
  3. 字符串S中的最長重複子串 
      方案:原理同2,具體做法就是找到最深的非葉節點。 
      這個深是指從root所經歷過的字符個數,最深非葉節點所經歷的字符串起來就是最長重複子串。 
    爲什麼要非葉節點呢?因爲既然是要重複,當然葉節點個數要>=2。 
  4. 兩個字符串S1,S2的最長公共部分 
      方案:將S1#S2$作爲字符串壓入後綴樹,找到最深的非葉節點,且該節點的葉節點既有#也有$(無#)。 

    後綴樹的代碼實現,下期再續。第二部分、後綴樹完。

第三部分、後綴樹的構造方法-Ukkonen

    接下來,咱們來了解後綴樹的構造方法-Ukkomen。爲了兼顧上文內容,以及加深印象,本部分打算從Trie樹從頭到位重新開始闡述一切。

    Ukkonen的構造法O(n), 它比Sartaj Sahni的構造法O(nr), r爲字母表大小 在時間上更有優勢. 但我們不能說Sartaj Sahni的算法慢, 因爲r往往會很小, 因此實際效率也接近線性, 兩種構造法在思想上均有可取之處.

 

3.1、問題的起源

 

字符串匹配問題是程序員經常要面對的問題. 字符串匹配算法的改進可以使許多工程受益良多, 比如數據壓縮和DNA排列。你可以把自己想象成一名工作於DNA排列工程的程序員. 那些基因研究者們天天忙着分切病毒的基因材料, 製造出一段一段的核苷酸序列. 他們把這些序列發到你的服務器裏, 指望你在基因數據庫中定位. 要知道, 你的數據庫裏有數百種病毒的數據, 而一個特定的病毒可以有成千上萬的鹼基. 你的程序必須像C/S工程那樣實時向博士們反饋信息, 這需要一個很好的方案。

 很明顯, 在這個問題上採取暴力算法是極其低效的. 這種方法需要你在基因數據庫裏對比每一個核苷酸, 測試一個較長的基因段基本會把你的C/S系統變成一臺古老的批處理機。

3.2、直覺上的解決方法

由於基因數據庫一般是不變的, 通過預處理來把搜索簡化或許是個好主意. 一種預處理的方法是建立一棵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)的時間和空間. 平方級的開銷使它在最需要它的領域 --- 長串搜索 中被拒之門外.

3.3、橫空出世

直到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.4、初窺門徑

加入一個新的前綴需要訪問樹中已有的後綴. 我們從最長的一個後綴開始(圖3中的BAN), 一直訪問到最短的後綴(空後綴). 每個後綴會在以下三種節點的其中一種結束.

 

  • 一個葉節點. 這個是常識了, 圖4中標號爲1, 2, 4, 5的就是葉節點.
  • 一個顯式節點. 圖4中標號爲0, 3的是顯式節點, 它表示該節點之後至少有兩條邊.
  • 一個隱式節點. 圖4中, 前綴BO, BOO, 或者非前綴OO, 它們都在某條表示序列的邊上結束, 這些位置就叫作隱式節點. 它表示後綴Trie中存在的由於路徑壓縮而剔除的節點. 在後綴樹的構造過程中, 有時要把一些隱式節點轉化爲顯式節點。

 

 

        圖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#的話就說明這是一個後綴了.

3.5、稍微麻煩一點的事情

從圖4到圖5這個更新過程是相對簡單的, 其中我們執行了兩種更新: 一種是將某條邊延長, 另一種是啥都不做. 但接下來往圖5繼續加入BOOKKE, 我們則會遇到另外兩種更新:

  1. 創建一個新節點來割開某一隱式節點所處的邊, 並在其後加一條新邊.
  2. 在顯式節點後加一條新邊.

 

圖6先分割, 再添加

當我們往圖5的樹中加入BOOKKE的時候, 我們是從已存在的最長後綴BOOKK開始, 一直操作到最短的後綴空後綴. 更新最長的後綴必然是更新葉節點, 之前提到了, 非常簡單. 除此之外, 圖5中結束在葉節點上的後綴還有OOKK, OKK, KK. 圖6的第一棵樹展示了這一類節點的更新.

圖5中首個不是結束在葉節點上的後綴是K. 這裏我們先引入一個定義:

在每次更新後綴樹的過程中, 第一個非葉節點稱爲激活節點. 它有以下性質:

 

  1. 所有比激活節點長的後綴都在葉節點上結束.
  2. 所有在激活節點之後加入的後綴都不在葉節點上結束.

 

後綴K在邊KKE上的隱式節點結束. 在後綴樹中我們要判斷一個節點是不是非葉節點需要看它是否有跟待加入字符相同的兒子, 即本例中的E.

一眼可以看出, KKE中的第一個K只有一個兒子: K. 所以它是非葉節點(這裏同時也是激活節點), 我們要給他加一個兒子來表示E. 這個過程有兩個步驟:

 

  1. 在第一個K和第二個K之間把邊分割開, 於是第一個K(隱式節點)成了一個顯式節點, 如圖6第二棵樹.
  2. 在剛剛變身而來的顯式節點後加一個新節點表示E, 如圖6第三棵樹. 由此我們又多了一個葉節點。

 

後綴K更新之後, 別忘了還有空後綴. 空後綴在根節點(節點0)結束, 顯然此時根節點是一個顯式節點. 我們看一下它後面有沒有以E開頭的邊---沒有, 那麼加入一個新的葉節點(如果存在以E開頭的邊, 則不用任何操作). 最終如圖7.

圖7

3.6、歸納, 反思, 優化

藉助後綴樹的特性, 我們可以做出一個相當有效的算法. 首先一個重要的特性是: 一朝爲葉, 終生爲葉. 一個葉節點自誕生以後絕不會有子孫. 更重要的是, 每當我們往樹上加入一個新的前綴, 每一條通往葉節點的邊都會延長一個字符(新前綴的最後一個字符). 這使得處理通往葉節點的邊變得異常簡單, 我們完全可以在創建葉節點的時候就把當前字符到文本末的所有字符一股腦塞進去. 是的, 我們不需要知道後面的字符是啥, 但我們知道它們最終都要被加進去. 因此, 一個葉節點誕生的時候, 也正是它可以被我們遺忘的時候. 你可能會擔心通往葉節點的邊被分割了怎麼辦, 那也不要緊, 分割之後只是起點變了, 尾部該怎麼着還是怎麼着.

如此一來, 我們只需要關心顯式節點和隱式節點上的更新.

還要提到一個節約時間的方法. 當我們遍歷所有後綴時, 如果某個後綴的某個兒子跟待加字符(新前綴最後一個字符)相同, 那麼我們當前前綴的所有更新就可以停止了. 如果你理解了後綴樹的本質, 你會知道一旦待加字符跟某個後綴的某個兒子相同, 那麼更短的後綴必然也有這個兒子. 我們不妨把首個這樣的節點定義爲結束節點. 比結束節點長的後綴必然是葉節點, 這一點很好解釋, 要麼本來就是葉節點, 要麼就是新創建的節點(新創建的必然是葉節點). 這意味着, 每一個前綴更新完之後, 當前的結束節點將成爲下一輪更新的激活節點.

好了, 現在我們可以把後綴樹的更新限制在激活節點和結束節點之間, 效率有了很大的改善. 整理成僞代碼如下:

Update( 新前綴 )
{
  當前後綴 = 激活節點
  待加字符 = 新前綴最後一個字符
       done = false;
  while ( !done ) {
  if ( 當前後綴在顯式節點結束 ) 
  {
    if ( 當前節點後沒有以待加字符開始的邊 )
      在當前節點後創建一個新的葉節點
    else
      done = true;
  } else {
    if ( 當前隱式節點的下一個字符不是待加字符 ) 
    {
      從隱式節點後分割此邊
       在分割處創建一個新的葉節點
    } else
      done = true;
    if ( 當前後綴是空後綴 )
      done = true;
    else
      當前後綴 = 下一個更短的後綴
     }
  激活節點 = 當前後綴
}

3.7、後綴指針

上面的僞代碼看上去很完美, 但它掩蓋了一個問題. 注意到第21行, “下一個更短的後綴”, 如果呆板地沿着樹枝去搜索我們想要的後綴, 那這種算法就不是線性的了. 要解決此問題, 我們得附加一種指針: 後綴指針. 後綴指針存在於每個結束在非葉節點的後綴上, 它指向“下一個更短的後綴”. 即, 如果一個後綴表示文本的第0到第N個字符, 那麼它的後綴指針指向的節點表示文本的第1到第N個字符.

圖8是文本ABABABC的後綴樹. 第一個後綴指針在表示ABAB的節點上. ABAB的後綴指針指向表示BAB的節點. 同樣地, BAB也有它的後綴指針, 指向AB. 如此這般.

圖8 加上後綴指針(虛線)的ABABABC的後綴樹

介紹一下如何創建後綴指針. 後綴指針的創建是跟後綴樹的更新同步的. 隨着我們從激活節點移動到結束節點, 我把每個新的葉節點的父親的路徑保存下來. 每當創建一條新邊, 我同時也在上一個葉節點的父親那兒創建一個後綴指針來指向當前新邊開始的節點. (顯然, 我們不能在第一條新邊上做這樣的操作, 但除此之外都可以這麼做.)

有了後綴指針, 就可以方便地一個後綴跳到另一個後綴. 這個關鍵性的附加品使得算法的時間上限成功降爲O(N)。

第四部分、全文總結
自動機,KMP算法,Extend-KMP,後綴樹,後綴數組,trie樹,trie圖及其應用

    涉及到字符串的問題,無外乎這樣一些算法和數據結構:自動機,KMP算法,Extend-KMP,後綴樹,後綴數組,trie樹,trie圖及其應用。當然這些都是比較高級的數據結構和算法,而這裏面最常用和最熟悉的大概是kmp,即使如此還是有相當一部分人也不理解kmp,更別說其他的了。當然一般的字符串問題中,我們只要用簡單的暴力算法就可以解決了,然後如果暴力效率太低,就用個hash。當然hash也是一個面試中經常被用到的方法。這樣看來,這樣的一些算法和數據結構實際上很少會被問到,不過如果使用它們一般可以得到很好的線性複雜度的算法。

    老實說,字符串問題的確挺複雜的,出來一個如果用暴力,hash搞不定,就很難再想其他的方法,當然有些可以用動態規劃。下圖主要說明下這些算法數據結構之間的關係。圖中黃色部分主要寫明瞭這些算法和數據結構的一些關鍵點。

    圖中可以看到這樣一些關係:extend-kmp 是kmp的擴展;ac自動機是kmp的多串形式;它是一個有限自動機;而trie圖實際上是一個確定性有限自動機;ac自動機,trie圖,後綴樹實際上都是一種trie;後綴數組和後綴樹都是與字符串的後綴集合有關的數據結構;trie圖中的後綴指針和後綴樹中的後綴鏈接這兩個概念及其一致。

    後綴樹的構造可以用Ukkonen算法在線性時間內完成[,但是不僅構造算法實現相當複雜,而且後綴樹存在着致命弱點:空間開銷大且對大字母表時間效率不理想。至於後綴數組下次闡述,這裏簡單介紹下extend-kmp。而在介紹extend-kmp之前,咱們先要回顧下KMP算法。

 kmp

    首先這個匹配算法,主要思想就是要充分利用上一次的匹配結果,找到匹配失敗時,模式串可以向前移動的最大距離。這個最大距離,必須要保證不會錯過可能的匹配位置,因此這個最大距離實際上就是模式串當前匹配位置的next數組值。也就是max{Aj 是 Pi 的後綴  j < i},pi表示字符串A[1...i],Aj表示A[1...j]。模式串的next數組計算則是一個自匹配的過程。也是利用已有值next[1...i-1]計算next[i]的過程。我們可以看到,如果A[i] = A[next[i-1]+1] 那麼next[i] = next[i-1],否則,就可以將模式串繼續前移了。
整個過程是這樣的:
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;
   } 
}
    複雜度分析:從上面的過程可以看出,內部循環再不斷的執行k = next[k],而這個值必然是在縮小,也就是是沒執行一次k至少減少1;另一方面k的初值是0,而最多++ N次,而k始終保持非負,很明顯減少的不可能大於增加的那些,所以整個過程的複雜度是O(N)。
    上面是next數組的計算過程,而整個kmp的匹配過程與此類似。

extend-kmp

    爲什麼叫做擴展-kmp呢,首先我們看它計算的內容,它是要求出字符串B的後綴與字符串A的最長公共前綴。extend[i]表示B[i...B_len] 與A的最長公共前綴長度,也就是要計算這個數組。觀察這個數組可以知道,kmp可以判斷A是否是B的一個子串,並且找到第一個匹配位置?而對於extend[]數組來說,則可以利用它直接解決匹配問題,只要看extend[]數組元素是否有一個等於len_A即可。顯然這個數組保存了更多更豐富的信息,即B的每個位置與A的匹配長度。
    計算這個數組extend也採用了於kmp類似的過程。首先也是需要計算字符串A與自身後綴的最長公共前綴長度。我們設爲next[]數組。當然這裏next數組的含義與kmp裏的有所過程。但它的計算,也是利用了已經計算出來的next[1...i-1]來找到next[i]的大小,整體的思路是一樣的。
    具體是這樣的:觀察下圖可以發現

    首先在1...i-1,要找到一個k,使得它滿足k+next[k]-1最大,也就是說,讓k加上next[k]長度儘量長。實際上下面的證明過程中就是利用了每次計算後k+next[k]始終只增不減,而它很明顯有個上界,來證明整個計算過程複雜度是線性的。如下圖所示,假設我們已經找到這樣的k,然後看怎麼計算next[i]的值。設len = k+next[k]-1(圖中我們用Ak代表next[k]),分情況討論:

  • 如果len < i 也就是說,len的長度還未覆蓋到Ai,這樣我們只要從頭開始比較A[i...n]與A的最長公共前綴即可,這種情況下很明顯的,每比較一次,必然就會讓i+next[i]-1增加一.
  • 如果len >= i,就是我們在圖中表達的情形,這時我們可以看到i這個位置現在等於i-k+1這個位置的元素,這樣又分兩種情況:
    1. 如果 L = next[i-k+1] >= len-i+1,也就是說L處在第二條虛線的位置,這樣我們可以看到next[i]的大小,至少是len-i+1,然後我們再從此處開始比較後面的還能否匹配,顯然如果多比較一次,也會讓i+A[i]-1多增加1.
    2. 如果 L < len-i+1 也就是說L處在第一條虛線位置,我們知道A與Ak在這個位置匹配,但Ak與Ai-k+1在這個位置不匹配,顯然A與與Ai-k+1在這個位置也不會匹配,故next[i]的值就是L。這樣next[i]的值就被計算出來了,從上面的過程中我們可以看到,next[i]要麼可以直接由k這個位置計算出來,要麼需要在逐個比較,但是如果需要比較,則每次比較會讓k+next[k]-1的最大值加1.而整個過程中這個值只增不減,而且它有一個很明顯的上界k+next[k]-1 < 2*len_A,可見比較的次數要被限制到這個數值之內,因此總的複雜度將是O(N)的。 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章