後綴樹的構造方法-Suffix tree

原文:http://blog.csdn.net/zhongjiekangping/article/details/6981329

原來是打算翻譯Sartaj Sahni的Suffix tree, 並專注地進行了一週, 連複習備考的時間也不惜佔去. 我希望給國產的同好者提供更通俗易懂的資料, 在翻譯的同時對原文進行了刪改, 並加入了許多自己的心得. 然而後來發現了Mark Nelson的這篇文章, 相比之下更有親和力, 於是老實地盡棄前功來翻譯這篇. 更重要一個原因是, Mark Nelson介紹的是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包含了所給字段的所有後綴 (也許正是一個病毒基因).

 後綴trie

圖1

BANANAS的後綴Trie

 

圖1展示了文本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有着一樣的佈局, 但它把只有一個兒子的節點給剔除了. 這個過程被稱爲路徑壓縮, 這意味着樹上的某些邊將表示一個序列而不是單獨的字符.

 

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的後綴樹.

 

trie

圖3

逐步構造後綴樹


初窺門徑

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

 

l         一個葉節點. 這個是常識了, 圖4中標號爲1, 2, 4, 5的就是葉節點.

 

l         一個顯式節點. 圖4中標號爲0, 3的是顯式節點, 它表示該節點之後至少有兩條邊.

 

l         一個隱式節點. 圖4中, 前綴BO, BOO, 或者非前綴OO, 它們都在某條表示序列的邊上結束, 這些位置就叫作隱式節點. 它表示後綴Trie中存在的由於路徑壓縮而剔除的節點. 在後綴樹的構造過程中, 有時要把一些隱式節點轉化爲顯式節點.

 trie


圖4

加入BOOK之後的BOOKKEEPER

(也就是BOOK的後綴樹)

 

如圖4, 在加入BOOK之後, 樹中有5個後綴(包括空後綴). 那麼要構造下一個前綴BOOKK的後綴樹的話, 只需要訪問樹中已存在的每一個後綴, 然後在它們的末尾加上K.

 

前4個後綴BOOK, OOK, OK和K都在葉節點上結束. 由於我們要路徑壓縮, 只需要在通往葉節點的邊上直接加一個字符, 而不需要創建一個新節點.

 

在所有葉節點更新之後, 我們還需要在空後綴後面加上K. 這時候我們發現已經存在一條從0節點出發的邊的首字符爲K, 沒必要畫蛇添足了. 換句話說, 新加入的後綴K可以在0節點和2節點之間的隱式節點中找到. 最終形態見圖5.

 

trie

圖5加入BOOKK之後的BOOKKEEPER

相比圖4, 樹的結構沒有發生變化

 

如果你是一位敏感的讀者, 可能要發問了, 如果加入K我們什麼都不做的話, 在查找的時候如何知道它到底是一個後綴呢還是某個後綴的一截? 如果你同時又是一位熟悉字符串算法的朋友, 心裏可能馬上就有答案了 --- 我們只需要在文本後面加個字母表以外的字符, 比如$或者#. 那我們查找到K$或K#的話就說明這是一個後綴了.


稍微麻煩一點的事情

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

 

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

 

2.    在顯式節點後加一條新邊.

 trie

 

圖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.

 

 trie

圖7

 

 

 


歸納, 反思, 優化

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

 

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

 

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

 

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

 

 

PLAIN TEXT

C:

1.     Update( 新前綴 )

2.     {

3.         當前後綴 = 激活節點

4.         待加字符 = 新前綴最後一個字符

5.         done = false;

6.         while ( !done ) {

7.             if ( 當前後綴在顯式節點結束 ) {

8.                 if ( 當前節點後沒有以待加字符開始的邊 )

9.                     在當前節點後創建一個新的葉節點

10.              else

11.                  done = true;

12.          } else {

13.              if ( 當前隱式節點的下一個字符不是待加字符 ) {

14.                  從隱式節點後分割此邊

15.                  在分割處創建一個新的葉節點

16.              } else

17.                  done = true;

18.          if ( 當前後綴是空後綴 )

19.              done = true;

20.          else

21.              當前後綴 = 下一個更短的後綴

22.      }

23.      激活節點 = 當前後綴

24.  }

 

 

 


後綴指針

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

 

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

 http://img166.ph.126.net/_G1wwOmH9eeAiH1WhW3LNQ==/2189593843833692898.jpg

 

圖8

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

 

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

 

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


圖片看不了的,可以看Mark Nelson的原文:http://facweb.cs.depaul.edu/mobasher/classes/csc575/Suffix_Trees/index.html


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章