Ukkonen's Algorithm構(gòu)造后綴樹實(shí)錄

聲明

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

前言

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

什么是后綴樹

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


參考的一篇論文中對(duì)后綴樹的介紹

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

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

什么是字典樹(Trie)

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


從根節(jié)點(diǎn)到葉節(jié)點(diǎn)就能表示一個(gè)唯一的字符串

再插入ABBAC


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

Trie的基本性質(zhì)可以歸納為:

  1. 根節(jié)點(diǎn)不包含字符,除根節(jié)點(diǎn)意外每個(gè)節(jié)點(diǎn)只包含一個(gè)字符。
  2. 從根節(jié)點(diǎn)到某一個(gè)節(jié)點(diǎn),路徑上經(jīng)過的字符連接起來,為該節(jié)點(diǎn)對(duì)應(yīng)的字符串。
  3. 每個(gè)節(jié)點(diǎn)的所有子節(jié)點(diǎn)包含的字符串不相同。

Trie的結(jié)構(gòu)就是這么簡單直接。如果我們在節(jié)點(diǎn)的實(shí)現(xiàn)類上加一個(gè)計(jì)數(shù)屬性,然后
在每次新字符串插入完成時(shí)將所在節(jié)點(diǎn)的計(jì)數(shù)加一,我們就可以實(shí)現(xiàn)詞頻統(tǒng)計(jì)了。

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

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

Suffix Trie
壓縮我們的Suffix Trie

通過觀察上圖,我們可以發(fā)現(xiàn)一個(gè)問題。
(樹太長了一屏都放不下?逃。。。。。
樹確實(shí)太長了,對(duì)于那些沒有分叉的一連串節(jié)點(diǎn),完全可以壓縮成一個(gè)單獨(dú)的節(jié)點(diǎn)。
像這樣:


Suffix Tree

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

Ukkonen's Algorithm的流程

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


字符串a(chǎn)aaabbbbaaaabbbb$的后綴樹

聯(lián)系上文我們知道有一部分后綴因?yàn)橹貜?fù)的原因被隱式的包含了,而我們在實(shí)踐中并不希望這樣的情況出現(xiàn),所以我們用一個(gè)唯一的標(biāo)識(shí)符$來表示字符串的結(jié)尾,這樣每一個(gè)后綴都有一個(gè)唯一的結(jié)尾標(biāo)識(shí)符,就只能被顯示的分叉出來??。

Let's begin

代碼實(shí)現(xiàn)的上下文:

程序的輸入是需要構(gòu)建后綴樹的字符串text。

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


    空的后綴樹
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

神奇的事情出現(xiàn)了。因?yàn)槲覀兪鞘褂米笥抑羔榿泶砉?jié)點(diǎn)中保存的字符串,所以有一件事情我們要注意----所有的葉節(jié)點(diǎn)的右指針跟Index指針保持一致。
當(dāng)Index變成1的時(shí)候,我們需要插入的后綴是:aa, a。
節(jié)點(diǎn)一的右指針隨著Index自動(dòng)加一,所以aa已經(jīng)在里面了,我們還需要插入a。
這個(gè)時(shí)候我們發(fā)現(xiàn)a已經(jīng)被隱式的包含了。
就這么算了?
當(dāng)然不,我們必須保證所有的后綴都被表示出來,所以我們需要remainder來記錄我們還需要插入多少個(gè)后綴,并用ActivePoint這個(gè)標(biāo)記,用來表示被隱式包含的后綴在哪。所以現(xiàn)在,remainder變成了1, ActivePoint變成了:root的一個(gè)叫aa的子節(jié)點(diǎn)的0位置。即a。

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

現(xiàn)在remainder變成2了,因?yàn)閍a, 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

這一步發(fā)生了什么:
我們前進(jìn)到位置4時(shí),新的字符b出現(xiàn)了,現(xiàn)在待插入的后綴是aaab, aab, ab, b。
如果我們在ActivePoint繼續(xù)往下走,我們會(huì)發(fā)現(xiàn)下一個(gè)是a, 跟b不一樣,當(dāng)前節(jié)點(diǎn)是aaaab, 所以aaab并沒有被隱式包含。所以樹要分叉了。在 aaaab中插一個(gè)節(jié)點(diǎn)進(jìn)來,把a(bǔ)aaab分裂成aaa和ab, 這樣我們就插入了aaab。
現(xiàn)在還剩下aab, ab, b。

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

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

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

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

在一次插入剩余后綴的流程中后面分裂的節(jié)點(diǎn)都應(yīng)該被前面分裂的節(jié)點(diǎn)用SuffixLink鏈接起來。

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


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

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


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

插入了bbba, bba, ba


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

新的問題出現(xiàn)了,a這個(gè)后綴被隱式包含了,所以我們退出這次插入,把活動(dòng)點(diǎn)改成(root, a, 0)。但我們發(fā)現(xiàn),這個(gè)時(shí)候我們的活動(dòng)點(diǎn)指向了Node6的結(jié)尾,所以我們需要將ActivePoint更新位(6, null, -1)。這樣我們才能繼續(xù)隱式包含的查找。

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

通過觀察圖像我們可以預(yù)料到,接下來的aaabbbb全是已經(jīng)在樹中的,所以我們會(huì)得到:

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

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

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

這一步發(fā)生了什么?
首先,我們照例分裂了activePoint指定的節(jié)點(diǎn),插入$, 完成了aaaabbbb$的插入。

然后發(fā)現(xiàn),這里沒有SuffixLink, 但是,既然aaaabbbb都被包含了,那么aaabbbb一定也已經(jīng)被包含了,所以我們把a(bǔ)ctive_node設(shè)置成了root。
由于接下來需要插入aaabbbb$, 所以active_length是6(注意對(duì)長度的計(jì)數(shù)為了實(shí)現(xiàn)的方便也從0開始)。
從root開始,我們順著aaabbbb在樹中的路徑一路前進(jìn),就能發(fā)現(xiàn)aaabbbb在樹中的結(jié)尾在2的子節(jié)點(diǎn)bbbbaaaabbbb$的位置3,于是,新的ActivePoint就被確定了:
(2,bbbbaaaabbbb$, 3)。

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

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

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

當(dāng)我們分裂了一個(gè)節(jié)點(diǎn)需要更新active_node的時(shí)候,如果當(dāng)前的active_node有suffixLink, 我們直接把a(bǔ)ctive_node更新成被指向的節(jié)點(diǎn),activePoint的其他數(shù)據(jù)不變。

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


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

Java實(shí)現(xiàn)

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容