聲明
歡迎提出反例來證明代碼有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的基本性質可以歸納為:
- 根節點不包含字符,除根節點意外每個節點只包含一個字符。
- 從根節點到某一個節點,路徑上經過的字符連接起來,為該節點對應的字符串。
- 每個節點的所有子節點包含的字符串不相同。
Trie的結構就是這么簡單直接。如果我們在節點的實現類上加一個計數屬性,然后
在每次新字符串插入完成時將所在節點的計數加一,我們就可以實現詞頻統計了。
什么是后綴字典樹(Suffix Trie)
把一個字符串的所有后綴都插入到Trie中,就得到了Suffix Trie。
壓縮我們的Suffix Trie
通過觀察上圖,我們可以發現一個問題。
(樹太長了一屏都放不下?逃。。。。。
樹確實太長了,對于那些沒有分叉的一連串節點,完全可以壓縮成一個單獨的節點。
像這樣:
abab的后綴有:
abab, bab, ab, b.
其中ab, b都被隱式的包含了,所以在圖中要好好找一找才能找到。
我們還發現圖中有兩條虛線箭頭,這玩意叫Suffix Link, 是后綴樹中一個很重要的概念,下文會詳述。
現在我們對后綴樹已經有了一個直觀感受了,對其的一些應用想必也很容易理解。比如模式匹配。在KMP算法中,我們對模式串進行處理,這種方式在模式串數量巨大而文本有限時就會顯得低效。這個時候對文本進行處理的后綴樹的優勢就體現出來了。
在使用Ukkonen的算法進行后綴樹的構造時,設文本長度是n, 則時間和空間復雜度都是O(n), 匹配某個特定長度為k的模式時,時間復雜度是O(k)。
Ukkonen's Algorithm的流程
接下來我們以aaaabbbbaaaabbbb這個字符串為例,描述一遍這個算法的流程。
我們最后構造出來的后綴樹長這樣:
聯系上文我們知道有一部分后綴因為重復的原因被隱式的包含了,而我們在實踐中并不希望這樣的情況出現,所以我們用一個唯一的標識符$來表示字符串的結尾,這樣每一個后綴都有一個唯一的結尾標識符,就只能被顯示的分叉出來??。
Let's begin
代碼實現的上下文:
程序的輸入是需要構建后綴樹的字符串text。
- Index指針,指向字符串中具體的某一個字符。
- ActivePoint(active_node, active_edg, active_length), 它是一個三元組,里面記錄了當前活動節點,活動邊,及活動長度。對于這個概念先不要慌,看下去就能明白是干什么的,初始值是(root, null, -1)。
- remainder, 表示我們還需要插入多少個后綴,初始值是0。
-
節點:在這里使用節點來保存信息,保存的信息有該節點中保存的字符串在text中的開始和結束位置,它的子節點們,以及它的SuffixLink鏈接的節點。
初始化我們的后綴樹,讓它有一個根節點
空的后綴樹
Index = 0,ActivePoint(root, null, -1), remainder = 0
我們需要插入到位置0為止該字符串的所有后綴,即:a。
Index = 1, ActivePoint(root, aa, 0), remainder = 1
神奇的事情出現了。因為我們是使用左右指針來代表節點中保存的字符串,所以有一件事情我們要注意----所有的葉節點的右指針跟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
現在remainder變成2了,因為aa, a被隱式包含了。
Index = 3, ActivePoint(root, aaaa, 2), remainder = 3
Index = 4, ActivePoint(root, aaa, 1), remainder = 3;
這一步發生了什么:
我們前進到位置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被隱式包含了嗎?沒有,所以我們繼續分叉
并更新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
b沒有被隱式包含,此時ActivePoint是(root, null, -1);
直接插入b
因為隱式包含的原因,往前走了三步
Index = 7, ActivePoint(root, bbbb, 2), remainder = 3;
Index = 8, ActivePoint(root, null, -1), remainder = 1
重復上面的分叉流程
插入了bbba, bba, ba
新的問題出現了,a這個后綴被隱式包含了,所以我們退出這次插入,把活動點改成(root, a, 0)。但我們發現,這個時候我們的活動點指向了Node6的結尾,所以我們需要將ActivePoint更新位(6, null, -1)。這樣我們才能繼續隱式包含的查找。
Index = 8, ActivePoint(6, null, -1), remainder = 1
通過觀察圖像我們可以預料到,接下來的aaabbbb全是已經在樹中的,所以我們會得到:
Index = 15, ActivePoint(2, abbbbaaaabbbb, 4), remainder = 8
接下來就是我們的$大顯神威的時候了,它會把所有的隱式包含后綴都變成顯式的。
而且現在我們面臨了新的情況,active_node不是根節點,所以我們會探討這個時候發生節點分裂后怎么更新active_node。
我們也發現了圖中有大量的SuffixLink, 所以我們也會探討SuffixLink的使用。
我們現在的情況,簡單一點來說,就是在Index指向$時,插入aaaabbbb$的所有后綴。
Index = 16, ActivePoint(2, bbbbaaaabbbb$, 3), remainder = 8
這一步發生了什么?
首先,我們照例分裂了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
觀察: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的其他數據不變。
于是我們按照上述流程繼續分裂或插入后綴,就能得到我們的最終結果。
再放一遍圖:
以上是對整個算法流程的描述,如果覺得筆者沒有講清楚,可以到Visualization of Ukkonen's Algorithm上跟一遍完整的流程。喜歡英文資料的童鞋們也可以到stackoveflow上看一下外國某大佬的解釋??。不過最好的學習方式當然還是自己實現一遍啦??。
Java實現
當前還只是嘗試性的實現,并沒有翻譯成項目用的語言并加入到項目中,有興趣的同學可以讀一讀測一測,如果能幫忙找出Bug那就真是太感謝了(畢竟整合進項目要改就比較爆炸??)
GitHub