Ukkonen's Algorithm構造后綴樹實錄

聲明

歡迎提出反例來證明代碼有bug, 雖然我自己測試了一段時間,但畢竟測試不能證明一段代碼沒有bug??

前言

最近項目中的一個關鍵算法使用了后綴樹(Suffix Tree)來優化匹配速度,所以花時間去研究了一下。
后綴樹是一種數據結構,能夠幫助我們快速解決很多關于字符串的問題。后綴樹的概念最早由Weiner在1973年提出,后來 McCreight 和Ukkonen又對其做了改進和完善,本文的主角就是Ukkonen在論文On–line construction of suffix trees中提出的后綴樹構造算法。

什么是后綴樹

先給你們看一幅恐怖的后綴樹表示圖:


參考的一篇論文中對后綴樹的介紹

是不是頓時感覺很頭疼???
其實還沒到頭疼的地方。

筆者理解后綴樹的過程是: 字典樹(Trie) ->后綴字典樹(Suffix Trie) -> 壓縮之后的后綴字典樹,即后綴樹。

什么是字典樹(Trie)

Trie常用于詞頻統計及大量字符串的排序,核心思想是空間換時間。
Trie長這樣:
插入ABABA, ABABC


從根節點到葉節點就能表示一個唯一的字符串

再插入ABBAC


分叉之前的部分表示字符串的公共前綴

Trie的基本性質可以歸納為:

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

Trie的結構就是這么簡單直接。如果我們在節點的實現類上加一個計數屬性,然后
在每次新字符串插入完成時將所在節點的計數加一,我們就可以實現詞頻統計了。

什么是后綴字典樹(Suffix Trie)

把一個字符串的所有后綴都插入到Trie中,就得到了Suffix Trie。

Suffix Trie
壓縮我們的Suffix Trie

通過觀察上圖,我們可以發現一個問題。
(樹太長了一屏都放不下?逃。。。。。
樹確實太長了,對于那些沒有分叉的一連串節點,完全可以壓縮成一個單獨的節點。
像這樣:


Suffix Tree

abab的后綴有:
abab, bab, ab, b.
其中ab, b都被隱式的包含了,所以在圖中要好好找一找才能找到。
我們還發現圖中有兩條虛線箭頭,這玩意叫Suffix Link, 是后綴樹中一個很重要的概念,下文會詳述。
現在我們對后綴樹已經有了一個直觀感受了,對其的一些應用想必也很容易理解。比如模式匹配。在KMP算法中,我們對模式串進行處理,這種方式在模式串數量巨大而文本有限時就會顯得低效。這個時候對文本進行處理的后綴樹的優勢就體現出來了。
在使用Ukkonen的算法進行后綴樹的構造時,設文本長度是n, 則時間和空間復雜度都是O(n), 匹配某個特定長度為k的模式時,時間復雜度是O(k)。

Ukkonen's Algorithm的流程

接下來我們以aaaabbbbaaaabbbb這個字符串為例,描述一遍這個算法的流程。
我們最后構造出來的后綴樹長這樣:


字符串aaaabbbbaaaabbbb$的后綴樹

聯系上文我們知道有一部分后綴因為重復的原因被隱式的包含了,而我們在實踐中并不希望這樣的情況出現,所以我們用一個唯一的標識符$來表示字符串的結尾,這樣每一個后綴都有一個唯一的結尾標識符,就只能被顯示的分叉出來??。

Let's begin

代碼實現的上下文:

程序的輸入是需要構建后綴樹的字符串text。

  1. Index指針,指向字符串中具體的某一個字符。
  2. ActivePoint(active_node, active_edg, active_length), 它是一個三元組,里面記錄了當前活動節點,活動邊,及活動長度。對于這個概念先不要慌,看下去就能明白是干什么的,初始值是(root, null, -1)。
  3. remainder, 表示我們還需要插入多少個后綴,初始值是0。
  4. 節點:在這里使用節點來保存信息,保存的信息有該節點中保存的字符串在text中的開始和結束位置,它的子節點們,以及它的SuffixLink鏈接的節點。
    初始化我們的后綴樹,讓它有一個根節點


    空的后綴樹
Index = 0,ActivePoint(root, null, -1), remainder = 0

我們需要插入到位置0為止該字符串的所有后綴,即:a。


屏幕快照 2018-04-04 下午3.10.20 (2).png
Index = 1, ActivePoint(root, aa, 0), remainder = 1
屏幕快照 2018-04-04 下午3.19.34 (2).png

神奇的事情出現了。因為我們是使用左右指針來代表節點中保存的字符串,所以有一件事情我們要注意----所有的葉節點的右指針跟Index指針保持一致。
當Index變成1的時候,我們需要插入的后綴是:aa, a。
節點一的右指針隨著Index自動加一,所以aa已經在里面了,我們還需要插入a。
這個時候我們發現a已經被隱式的包含了。
就這么算了?
當然不,我們必須保證所有的后綴都被表示出來,所以我們需要remainder來記錄我們還需要插入多少個后綴,并用ActivePoint這個標記,用來表示被隱式包含的后綴在哪。所以現在,remainder變成了1, ActivePoint變成了:root的一個叫aa的子節點的0位置。即a。

Index = 2, ActivePoint(root, aaa, 1), remainder = 2
屏幕快照 2018-04-04 下午3.30.23 (2).png

現在remainder變成2了,因為aa, a被隱式包含了。

Index = 3, ActivePoint(root, aaaa, 2), remainder = 3
屏幕快照 2018-04-04 下午3.32.28 (2).png
Index = 4, ActivePoint(root, aaa, 1), remainder = 3;
屏幕快照 2018-04-04 下午3.34.16 (2).png

這一步發生了什么:
我們前進到位置4時,新的字符b出現了,現在待插入的后綴是aaab, aab, ab, b。
如果我們在ActivePoint繼續往下走,我們會發現下一個是a, 跟b不一樣,當前節點是aaaab, 所以aaab并沒有被隱式包含。所以樹要分叉了。在 aaaab中插一個節點進來,把aaaab分裂成aaa和ab, 這樣我們就插入了aaab。
現在還剩下aab, ab, b。

這個分叉給我們帶來了一個問題,在active_node是根節點的時候,分叉發生之后,我們怎么更新ActivePoint?
我們前面說過,ActivePoint是用來表示被隱式包含的待插入后綴的,所以,當前位置的隱式包含后綴被插入了,當然是當前插入的后綴aaab往前進一個位置,刪掉第一個字符成aab,即active_length - 1。
由于后綴樹的特性,當更長aaa的后綴都被隱式包含的時候,短一個字符的后綴aaa肯定也被包含了,而且既然前一個是從root節點的子節點,那后一個肯定也一樣,這個特性很容易驗證,所以active_node依然是root。
那active_edg又怎么更新呢?我們此時就要開始尋找active_node的子節點中以新后綴的開頭開頭的子節點了,這個時候還是以a開頭的,所以不變(如果不是以a開頭的,就需要更換active_edg了),此時ActivePoint變成了(root, aaa, 1)。

aab被隱式包含了嗎?沒有,所以我們繼續分叉

屏幕快照 2018-04-04 下午3.47.08 (2).png

并更新ActivePoint為(root, aa, 0); remainder減去1變成2。
此時我們注意到a和aa被一個虛線箭頭鏈接了起來,這個箭頭叫SuffixLink, 意義在于比如當我們的ActivePoint指向Node1的位置0時, 即隱式包含了aaaa時,我們遇到新的字符串$,于是通過分叉把aaaa$插進去,接下來插需要插aaa$,我們只需要跟著suffixLink走就能確定新的ActivePoint的Active_node的位置,而活動長度只需要保持不變。(那我們怎么確認是active_node跟哪個個子節點的邊是active_edg的呢?不好意思,我們只能遍歷一下找一找,看看哪個子節點是a開頭的)。關于SuffixLink的使用后面會有體現。
如果看不懂前面的描述,只需要先記住:

在一次插入剩余后綴的流程中后面分裂的節點都應該被前面分裂的節點用SuffixLink鏈接起來。

接下來我們再度分叉插入ab


屏幕快照 2018-04-04 下午4.06.18 (2).png

b沒有被隱式包含,此時ActivePoint是(root, null, -1);
直接插入b


屏幕快照 2018-04-04 下午4.07.53 (2).png
因為隱式包含的原因,往前走了三步
Index = 7, ActivePoint(root, bbbb, 2), remainder = 3;
屏幕快照 2018-04-04 下午4.16.26 (2).png
Index = 8, ActivePoint(root, null, -1), remainder = 1
重復上面的分叉流程

插入了bbba, bba, ba


屏幕快照 2018-04-04 下午4.19.46 (2).png

新的問題出現了,a這個后綴被隱式包含了,所以我們退出這次插入,把活動點改成(root, a, 0)。但我們發現,這個時候我們的活動點指向了Node6的結尾,所以我們需要將ActivePoint更新位(6, null, -1)。這樣我們才能繼續隱式包含的查找。

Index = 8, ActivePoint(6, null, -1), remainder = 1
屏幕快照 2018-04-04 下午4.24.42 (2).png

通過觀察圖像我們可以預料到,接下來的aaabbbb全是已經在樹中的,所以我們會得到:

Index = 15, ActivePoint(2, abbbbaaaabbbb, 4), remainder = 8
屏幕快照 2018-04-04 下午4.28.20 (2).png

接下來就是我們的$大顯神威的時候了,它會把所有的隱式包含后綴都變成顯式的。
而且現在我們面臨了新的情況,active_node不是根節點,所以我們會探討這個時候發生節點分裂后怎么更新active_node。
我們也發現了圖中有大量的SuffixLink, 所以我們也會探討SuffixLink的使用。
我們現在的情況,簡單一點來說,就是在Index指向$時,插入aaaabbbb$的所有后綴。

Index = 16, ActivePoint(2, bbbbaaaabbbb$, 3), remainder = 8
屏幕快照 2018-04-04 下午5.00.34 (2).png

這一步發生了什么?
首先,我們照例分裂了activePoint指定的節點,插入$, 完成了aaaabbbb$的插入。

然后發現,這里沒有SuffixLink, 但是,既然aaaabbbb都被包含了,那么aaabbbb一定也已經被包含了,所以我們把active_node設置成了root。
由于接下來需要插入aaabbbb$, 所以active_length是6(注意對長度的計數為了實現的方便也從0開始)。
從root開始,我們順著aaabbbb在樹中的路徑一路前進,就能發現aaabbbb在樹中的結尾在2的子節點bbbbaaaabbbb$的位置3,于是,新的ActivePoint就被確定了:
(2,bbbbaaaabbbb$, 3)。

這是比較繁瑣的一步,有了suffixLink的話會簡單很多。

Index = 16, ActivePoint(4, bbbbaaaabbbb$, 3), remainder = 7
屏幕快照 2018-04-04 下午5.11.16 (2).png
觀察:SuffixLink的妙用:

我們通過分裂節點3(bbbbaaaabbbb$)可以完成aaabbbb$的插入。
然后跟著SuffixLink把active_node設置成節點4,active_length不變,active_edg也不變。
我們可以驗證,通過suffixLink來更新 activePoint和通過把active_node設置為root然后一步一步往前走得到的結果是一樣的。

當我們分裂了一個節點需要更新active_node的時候,如果當前的active_node有suffixLink, 我們直接把active_node更新成被指向的節點,activePoint的其他數據不變。

于是我們按照上述流程繼續分裂或插入后綴,就能得到我們的最終結果。
再放一遍圖:


屏幕快照 2018-04-04 下午5.20.59 (2).png
以上是對整個算法流程的描述,如果覺得筆者沒有講清楚,可以到Visualization of Ukkonen's Algorithm上跟一遍完整的流程。喜歡英文資料的童鞋們也可以到stackoveflow上看一下外國某大佬的解釋??。不過最好的學習方式當然還是自己實現一遍啦??。

Java實現

當前還只是嘗試性的實現,并沒有翻譯成項目用的語言并加入到項目中,有興趣的同學可以讀一讀測一測,如果能幫忙找出Bug那就真是太感謝了(畢竟整合進項目要改就比較爆炸??)
GitHub

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容