資料結構系列——字尾樹(附Java實現程式碼)
字尾樹,說的通俗點就是將一個字串所有的字尾按照字首樹(Trie樹,可參考此篇文章)的形式組織成一棵樹。
什麼是字尾樹
舉例:“banana\0”,其中 “\0” 作為文字結束符號,該字串所有的字尾如下:
banana\0
anana\0
nana\0
ana\0
na\0
a\0
\0
將所有的字尾構建成一個字首樹,如下:
圖1:粗陋的字尾樹:
從圖中可以看出大量的重複子串,如存在三個“a\0”,浪費太多的空間,我們需要將其進行壓縮,得到如下的字尾樹:
圖2:真正的字尾樹:
瞬間感覺看不懂了是吧,其實就是把每個節點放一個字元,改成放多個字元,比如圖1最左邊的一條分支,每個節點一個字元,太浪費,到圖2就成了一個節點,儲存了全部的”banana\0”字元,大大節省了空間。當然也增加了查詢的複雜度。
字尾樹的應用
在看如何構建這樣一顆字尾樹之前,先了解下字尾樹的應用,如果沒有很好地應用場景,那麼我們就沒必要浪費時間去了解這麼一顆複雜的資料結構了。
- 查詢字串o是否在字串S中。
方案:用S構造字尾樹,按在trie中搜索字串的方法搜尋o即可。
原理:若o在S中,則o必然是S的某個字尾的字首。
例如S: leconte,查詢o: con是否在S中,則o(con)必然是S(leconte)的字尾之一conte的字首.有了這個前提,採用trie搜尋的方法就不難理解了。 - 指定字串T在字串S中的重複次數。
方案:用S+”$”構造字尾樹,搜尋T節點下的葉節點數目即為重複次數
原理:如果T在S中重複了兩次,則S應有兩個字尾以T為字首,重複次數就自然統計出來了。 - 字串S中的最長重複子串
方案:原理同2,具體做法就是找到最深的非葉節點。
這個深是指從root所經歷過的字元個數,最深非葉節點所經歷的字串起來就是最長重複子串。
為什麼要非葉節點呢?因為既然是要重複,當然葉節點個數要>=2。 - 兩個字串S1,S2的最長公共部分
方案:將S1#S2$作為字串壓入字尾樹,找到最深的非葉節點,且該節點的葉節點既有#也有$(無#)。
後面會用程式碼來描述如何應用字尾樹進行字串子串的查詢,即應用1。下面先來看看字尾樹的構建演算法。
字尾樹的構建
在 1995 年,Esko Ukkonen 發表了論文《On-line construction of suffix trees》
Ukkonen演算法
本文試圖描述Ukkonen演算法,首先顯示當字串是簡單的(即不包含任何重複的字元)時候它做什麼,然後擴充套件到完整的演算法。
首先來看一些前言:
我們正在建設的,基本上像一個搜尋特里結構(單詞查詢樹)。所以有一個根節點,從根節點引出新的節點,以及進一步從新節點引出其它節點,依次類推。
但是:與搜尋單詞查詢樹中不同,邊標籤不是單個字元。相反,每個邊的標籤是使用一對整數[從哪,到哪]。這些都是指向文字的指標。從這個意義上說,每個邊有任意長度的字串標籤,但只需要O(1)的空間(兩個指標)。
基本原理
我想首先展示如何建立一個特別簡單的字串的字尾樹,這個字串沒有重複的字元,如:
abc
這個演算法工作的步驟,從左到右。字串的每個字元都有一個步驟。每一步都可能涉及到多個個體的操作,但是我們將會看到(見結尾最後的觀察)的總數量操作是O(n)。
所以我們從左開始,第一次插入單個字元“a“建立一個從根節點(在左邊)到一個葉節點的邊,和作為[0,#)的標籤,這意味著邊代表了子串在位置0開始,結束在當前的結尾。我使用符號#來表示當前末尾,這是在位置1(a之後的右邊)。
因此,我們擁有一棵起始樹,這棵樹看起來如下:
而其意義如下:
現在我們前進到位置2(b的右邊)。 我們每個步驟的目標是插入至當前位置的所有後綴。
我們通過以下動作完成目標:
- 擴充套件已存在邊a為ab
- 插入一條新邊b
在我們的圖示裡,它看起來如下:
而其意義如下:
我們看到了兩點:
- ab邊的圖示與它在起始樹:[0,#]邊的圖示是相同的。它的意義卻已經自動更改了,因為我們把當前的位置#從1更改到2。
- 每條邊使用的空間複雜度為O(1),因為無論邊代表多少個字元 ,它都是由指向文本里的兩個指標組成。
接著我們再次增加位置,並且修改樹:給每個已經存在的邊增加c,插入一條表示新字尾c的邊。
在我們的圖示裡,它看起來如下:
而其意義如下:
我們看到:
- 這棵樹是經過上面的每個步驟後至當前位置的正確的字尾樹。
- 步驟數目與文字中包含的字元一樣多。
- 每個步驟的工作量是O(1),因為所有已經存在的邊都是增加#來自動更改的,而且為最後一個字元插入一條新邊的時間複雜度為 O(1)。因此對一個長度為n的字串來說,只需要O(n)時間複雜度。
註釋1:到這裡論文中構建了一顆“abc”的字尾樹,按照步驟一步一步很容易理解,唯一的地方就是那個#,不過我們可以忽略這個#,在使用程式實現的時候我們會有一個很好的優化的方式讓我們很輕鬆的處理掉這個#。不過,從下面開始,事情似乎變得複雜了很多
第一次擴充套件:簡單的重複
當然,字尾樹表示的如此良好只是因為我們的字串沒有包含任何重複。現在我們看一個更真實的字串:
abcabxabcd
這個字串像前面例子裡一樣是abc開始的,接著重複ab ,緊跟著x,再接著重複abc,緊跟著d。
步驟1到3:經過了前三個步驟後,我們擁有了前面例子的那棵樹:
步驟4:我們移動#到位置4。這隱含地更改所有已經存在的邊為如下:
而且我們需要在根節點插入當前步驟的最末的字尾a。
我們做這些之前,我們引入除#之外的 兩個或者更多變數,當然這些變數一直都存在,只是我們迄今為止沒有使用它們:
- 活動點(active point),它是一個三元組(活動節點,活動邊,活動長度)
- 剩餘字尾數(reminder),它是一個整數,來說明我們需要插入多少個新的字尾。
這兩個圖示的確切含義不久就會清晰,不過現在我們只能說:
- 在簡單的abc例子裡,活動點總是(root,’0x’,0),也就是說,活動節點是根節點,活動邊是由空字元’0x’指定的邊,活動長度是0。這麼做的結果是我們在每一步驟裡插入的一條新邊是作為新建立的邊插入到根節點。不久我們就會明白為什麼需要三元組表示這條資訊。
- 在每個步驟開始時剩餘字尾數總是設定為1。它的意義是我們主動插入到每一步驟末尾的字尾數目是1(總是最後一個字元)。
註釋2:關於這裡的活動點和剩餘字尾數簡單解釋下。活動點中的活動節點:是用於查詢一個字尾是否已經存在這棵樹裡,即查詢的時候從活動節點的子節點開始查詢,同時當需要插入邊的時候也是插入到該節點下;而活動邊則是每次需要進行分割的邊,即成為活動邊就意味著需要被分割;而活動長度則是指明從活動邊的哪個位置開始分割。剩餘字尾數是我們需要插入的字尾的數量,說明程式設計師點就是快取的數量,因為每次如果要插入的字尾存在,則快取起來。
現在將有變化了,當我們給根節點插入當前最後一個字元a的時候,我們特別注意到已經存在一條以a開始的邊:abca。在這種情況下我們做如下工作:
- 我們不向根節點插入一條新邊[4,#]。相反,我們只是注意到字尾a已經在我們的樹裡。它終止在更長的邊的中間位置,不過這麼做我們並不疑惑,我們還是保留它們原來的樣子。
- 我們設定活動點為(root,’a’,1)。這意味著活動點現在是在根節點的以a開始的向外的邊的中間某個位置,具體地指這條邊的位置1之後。我們注意到這條邊只是由它的首個字元a來宣告的。這就足夠了,因為以一個特定的字元開始的只有一條邊(通讀整個文件之後可以確定這是真的)。
- 我們還增加了剩餘字尾數, 那麼在下一步驟開始的時候,剩餘字尾數為2。
注意:當發現我們需要插入的最終字尾已經存在在這棵樹裡的時候,這棵樹本身根本就沒有改變(我們只是修改了活動節點和剩餘字尾數)。那麼這棵樹就不再是能準確的表示至當前位置的字尾樹了,不過它包含了所有的字尾(因為最終的字尾a隱含地包含了)。因此,除了修改變數外(所有這些變數都是定長的,因此空間複雜度是 O(1)),在這一步裡沒有做其他工作。
註釋3:這裡原文用了我們特別注意到,可惜,程式可不會注意到,所以在實現的時候這裡會涉及到對活動節點的子節點的一個遍歷操作。這裡還更新了活動點,活動節點是root,而活動邊是’a’,對於程式來說活動邊則是一個邊的物件,只是這個邊包含的字串時以’a’開頭,同時活動長度是從0增加到了1。剩餘字尾數是1,因為我們快取了一個字尾’a’。
步驟5:我們修改當前的位置#為5。這將自動地如下更新這棵樹:
而且由於剩餘字尾數為2,我們需要插入目前位置的兩個最終字尾:ab和b。這主要是因為:
- 前一步驟的a字尾從來都沒有真正地插入。因此它保留下來,然而由於我們已經向前走了一步,它現在由a延長為ab。
- 還有,我們需要插入新的最終邊b。
實際上,這意味著我們要修改活動點(它現在指向的是abcab邊裡的a之後),而且插入當前的最後一個字元b, 不過:同時它也證明b也已經出現在同一條邊裡。
因此,我們再次不修改這棵樹,我們只是:
- 修改活動點為(root,’a’,2)(是與前面相同的節點和邊,只不過現在我們指向到b之後)
- 增加剩餘字尾數為3 ,因為我們仍然不能插入前一個步驟的最終邊,同時我們也不能插入當前的最終邊
為了清晰地說明:我們需要在當前的步驟裡插入ab和b,不過由於ab已經找到,所以我們修改了活動點,而且甚至不試圖插入b。為什麼?因為如果ab處於這棵樹裡,那麼它的每個字尾(包括b)也一定在這棵樹裡。也許僅僅是隱含性的,不過它一定在這棵樹裡,因為這是我們迄今為止建立這棵樹所採用的方法。
註釋4:同時它也證明b也已經出現在同一條邊裡,看到這句話時思考了半天,真的無法證明,只是我們用肉眼看出來而已,所以程式需要做的是:比較活動邊在活動長度指定的字元與’b’是否相同,相同則證明了’b’也出現過在同一條邊。此時活動長度的作用就出來了,其實就是標識活動邊比較到哪裡了。
我們增加#而前進到步驟6。這棵樹自動修改為:
由於剩餘字尾數是3 ,我們不得不增加abx,bx和x。活動點告訴我們ab結束在哪兒,因此我們僅僅需要跳過這兒,然後插入x。x確實還不在這棵樹裡,因此我們分割abcabx邊,插入一個內部節點:
這條邊表示的仍然是指向文字內部的指標,因此分割和插入內部節點的時間複雜度為O(1)。
註釋5:這裡為什麼突然不證明x也出現過在同一條邊呢?因為被肉眼識破了,好吧,程式還是需要按照註釋4所說的方法進行一次比較,最終得出x不存在活動邊,所以如果需要插入abx,為了充分利用空間,所以不會單獨建一個分支存放abx,而是將原來的abcabx分割成ab和cabx兩段,然後再為ab增加一個分支存放x。而分割的邊就是活動邊,分割的長度就是活動長度。
這時我們處理了abx,並且把剩餘字尾數減為2。現在我們需要插入下一個剩餘字尾bx。但是在我們做這些之前,我們需要修改活動節點。分割並插入一條邊遵循的規則稱作規則1,如下,而且它適用於活動節點是根節點的情況(針對下面後續的其他情況,我們將要了解規則3)。規則1如下:
向根節點插入遵循:
- 活動節點保留為根節點
- 設定活動邊為我們需要插入的新字尾的第一個字元,也就是b。
- 活動長度減1
因此,新的活動節點三元組(root,’b’,1)表明要做的下一個插入在bcabx邊,第一個字元之後,即b之後。我們可以確定插入點的時間複雜度為 O(1),並且檢查x是否已經出現在樹裡。如果它出現在這條邊裡,我們將結束當前的步驟,保持一切為原樣。然而如果x沒有出現在這條邊裡,那麼我們分割這條邊而插入它:
註釋6:上面進行了一次分割,所以引入了規則1,規則1的前提條件是向根節點插入,但是我們插入的是在a這個分支,其實我覺得應該是這麼理解:進行分割時如果活動節點是根節點,則依舊保留為根節點;至於第二個 設定活動邊為b,這個可不是這麼一句話就可以認定的,需要從活動節點進行一次查詢,不過肯定是存在的,因為存在ab,則必然存在b。如果剩餘的是bcx,則找到b之後還需要繼續找c,找x。最終找不到就分割,然後重複以上步驟即可。
再此說明,它的時間複雜度為 O(1),而且我們按照規則1所示把剩餘字尾數修改為1,活動節點修改為(root,’x’,0)。
不過還有一件事情我們必須做。我們稱它為規則2:
如果我們分割一條邊並插入新的節點,而且如果它不是在當前步驟裡建立的第一個節點的話,我們通過特殊的指標,即字尾連線,把 以前插入的節點和新增的節點連線起來。後面我們將明白為什麼這麼做是有用的。這兒我們要明白:字尾連線表示為虛線邊
註釋7:字尾連線的目的是為了方便後面進行查詢,不過需要注意的是:是將前一個分割的節點通過後綴節點指向後一個分割的節點,而且這兩次分割必須是出現在一次插入中,即這裡是出現在插入x的情況下發生的兩次分割,所以可以增加字尾連線。
我們仍然需要插入當前步驟的最終字尾x。因為活動節點的活動長度部分已經減少到0,最終直接插入到根節點上。由於根節點上沒有以x開始的邊,所以我們插入了新邊:
正如你所能看到的,在當前的步驟裡插入了所有剩餘的字尾。
我們設定#=7而前進到步驟7,這將像往常一樣自動新增下一個字元a到所有的葉子邊上。然後我們試圖插入新的最終字元到活動節點(根節點),然後發現它已經存在在這棵樹裡了。因此我們結束當前的步驟,不插入任何邊,並且修改活動點位(root,’a’,1)。
設定#=8進入步驟8,我們新增b,像以前所看到的,這僅僅意味著我們修改活動點位(root,’a’,2) ,而且不需要做其他事情就增加剩餘字尾數。因為b已經出現在這棵樹裡。然而我們(在 O(1)時間複雜度裡)注意到活動節點現在是一條邊的結尾。我們通過重置活動節點位(node1,’\0x’,0)來體現這個。這兒,我們用node1來指ab邊結束的哪個內部節點。
註釋8:這裡出現了一個修改活動節點的規則:即如果活動邊上的所有字元全部都被匹配完了(級活動邊上的字元數==活動長度),則將活動邊晉升為活動節點,同時重置活動長度為0。所以下次查詢時就得從該節點開始了,而不是根節點了。
接著設定#=9進入步驟9,我們需要插入’c’,這將有助於我們理解的最後一條技巧。
第二次擴充套件:使用字尾連線
像往常一樣,#的修改自動給每條是葉子的邊添加了c,而且我們轉到活動點看是否可以插入’c’。活動點顯示’c’已經存在在那條邊裡,因此我們設定活動點為(node1,’c’,1),且增加剩餘字尾數,不做任何其他事情。
現在設定#=10進入步驟10,剩餘字尾數是4 ,因此我們首先需要在活動點插入d而實現插入abcd(這條邊從第三步驟開始就一直保留著)。
試圖在活動點插入d將引起時間複雜度為O(1)的邊分割:
分割起始的活動點在上圖中標記為紅色。 最後一條規則即規則3如下:
分割從不是根節點的活動點開始的邊之後,我們應當緊跟著從活動點開始的字尾連線,如果存在一條這樣的連線,那麼重置活動節點使它指向這個節點。如果不存在這樣字尾連線,那麼我們設定活動節點為根節點,活動邊和活動長度保持不變。
因此活動節點現在是(node2,’c’,1),這裡node2如下圖所示標記為紅色:
註釋9:這裡使用到了規則2,因為分割c的時候,活動節點是ab,而非根節點,而ab正好存在後綴節點,所以分割完活動節點就跑到了ab的字尾節點b。其實添加了abcd之後,要插入的就是bcd了,如果不使用字尾節點,則需要從根節點開始找b,但是b就一個字元,所以活動節點還是會變成b,而後綴節點就是省了這一步,所以後綴節點只是一個優化手段而已。可以看到b分割之後就需要從根節點找c了,因為b沒有後綴連線快速找到c。
由於abcd的插入已經完成,我們把剩餘字尾數減為3,而且考慮當前步驟的下一個剩餘字尾bcd。規則3已經設定活動點為右邊的節點和邊,因此插入bcd可以簡單地向活動點插入剩餘字尾的最後一個字元d來完成。
要做到這個將引起另一個邊分割,根據規則2 ,我們必須建立一條從以前已插入的節點開始的到新建節點的字尾連線:
我們注意到:字尾連線使我們重置了活動點,因為我們能在O(1)複雜度下插入下一個剩餘字尾。看看上面的圖就可確定標籤為ab的真正節點連線到節點b(它的字尾),而節點abc則連線到bc節點。
當前步驟仍然沒有結束。現在剩餘字尾數是2,我們需要遵循規則3再次重置活動節點。由於當前的活動節點(上圖中紅色標記的)已經沒有後綴連線,我們重置活動節點位根節點。活動節點現在是(root,’c’,1)。
因此下一個插入發生在根節點的一條邊上,以c開始的這條邊的標籤為:cabxabcd,位於第一個字元之後,即c之後。這將產生另一個分割:
另外,由於這涉及到新的內部節點的建立,我們遵循規則2,設定一條新的從前面已建立的內部節點開始的字尾連線:
(為了製作這些小圖,我使用了Graphviz Dot軟體。新的字尾連線使得Dot軟體愛你重新佈局了已經存在的邊,因此仔細地檢查並確定上圖中插入的唯一的東西就是一條新的字尾連線。)
建立了這條連線,剩餘字尾樹可設定為1 ,另外由於活動節點是根節點,我們根據規則1修改活動點位(root,’d’,0)。這意味著這一步的最後一個插入是向根節點插入單獨的d:
註釋10:到這裡全部的步驟就結束了,後面的一些需要主要的地方都是這個演算法需要注意的地方,所以需要先了解這個演算法的運作原理才能看懂後面的注意。
這是最後一步,至此我們已經完成了字尾樹的建立。雖然工作已經完成,但還有許多最後要注意的地方:
- 在每一步裡,我們向前移動#一個位置。這自動在時間複雜度O(1)內修改了所有的葉子結點。
- 不過,字尾樹沒有處理 a) 前一步驟保留下來的任何字尾 b)和當前步驟的最後一個字元。
- 剩餘字尾樹告訴我們我們需要做多少個後續的插入。這些插入把一對一對應為在當前位置#結束的字串的最後的字尾。我們認為是一個接著一個,然後再對它們進行插入。重要的是:每條插入都在O(1)的時間複雜度內完成,因為活動點告訴我們確切的位置,然後我們只需要在活動點增加一個單獨的字元。為什 麼?因為其他字元都隱含地包含了(否則活動點將是其他地方)。
- 在做了每個這樣的插入之後,我們把剩餘字尾數減少,並且如果存在後綴的邊,就新增一條字尾連線。如果不存在,(根據規則3)我們把活動節點設定為根節點。如果我們已經處在根節點,那麼我們根據規則1修改活動節點。在任何情況下,它只花費O(1)的時間複雜度。
- 在任意插入期間,我們發現我們需要插入的字元已經存在,那麼我們不作任何事情而結束當前步驟,甚至在剩餘字尾樹大於0的情況下。理由是保留的任何插入都是我們試圖插入的邊的字尾。因此它們所有都隱藏在當前的樹裡。事實是剩餘字尾樹大於0確保我們後續對剩餘字尾的處理。
- 如果在演算法結束時剩餘字尾數大於0意味著什麼呢?將是這中情況:結束的文字是以前出現在某個地方的這個文字的子字串。在這種 情況下,我們必須給這個字串結尾新增一個額外以前沒有出現過的字元。在這樣的文件裡,通常使用美元符號$作為解決這個問題的符號。為什麼會發生這種事情呢?—>如果後來我們使用完整的字尾樹搜尋字尾,那麼我們只有在字尾結束於葉子時才接受搜尋匹配。否則我們會得到許多假的匹配,因為字尾樹立簡單地包含了不是豬字串的真正後綴的許多這樣的字串。在結束的時候強制剩餘後 綴數為0是確保所有的字尾都結束在葉子節點的重要方法。然而,如果玩麼想用這棵樹來尋找通常的子字串,而不僅僅是主字串的字尾,那麼根據下面OP的評論的建議,最後一步確實不是必需的。
- 那麼,整個演算法的複雜性如何呢?如果文字是長度為n的字元組成,那麼顯然需要n步(或者如果我們增加了沒有符號,那麼就是n+1 步)。在每個步驟裡,我們要麼(除了修改變數外)什麼都不做,要門我們插入剩餘的字尾,每一步都花費O(1)時間複雜度。由於剩餘字尾數表明了我們在以前的步驟裡不做任何事情的次數,而且現在我們每做一次插入就對剩餘字尾數遞減,我們做這樣的事情 總的次數準確地說是n(或者n+1)。因此,整體的複雜度是O(n)。
然而,有一處小的地方我沒有正確地說明: 可能發生這樣的情況,我們添加了一條字尾連線,修改活動點,然後發現活動點的活動長度與新的活動節點不能一起正常工作。例如,看看下面這種情況:
(短劃線指的是這棵樹的剩餘部分,虛線指的是字尾連線。)
現在,假設活動節點是(red,’d’,3),因此它指向def邊的f之後的位置。現在假設我們做了必須的修改,而且現在依據規則3續接了字尾連線並修改了活動節點。新的活動節點是(green,’d’,3)。然而從綠色節點出發的d邊是de,因此這條邊只有2個字元。為了找到正確的活動點,很明顯我們需要新增一個到藍色節點的邊,然後重置活動節點為(blue,’f’,1)。
在特別糟的情況下,活動長度可以是剩餘字尾數那麼大,它甚至可以與n一樣大。再在找正確的活動節點的時候,這種情況可能剛好發生,我們不僅僅需要跳過一個內部節點長度,不過也許很長,最壞的情況是高達n。由於在每一步裡 剩餘字尾的插入通常是O(n),續接了字尾之後的對活動節點的後續調整也是O(n)的複雜度 ,這是否意味著這個演算法具有隱藏的O(n 2)的複雜度?
不是這樣的,理由是如果我們確實需要調整活動節點(例如,如上圖所示從綠色節點調整到藍色節點),那麼這就給我們引入了一個擁有自己的字尾連線的新節點,而且活動長度將縮減。當我們沿著字尾連線這個鏈向下走,我們就要插入剩餘的字尾,且只是縮減活動長度,使用這種方法我們可以調整的活動點的數目不可能超過任何給定時刻的活動長度。由於活動長度從來不會超過剩餘字尾數,而後綴剩餘數不僅僅在每個單一步驟裡是O(n),而且對整個處理過程進行的剩餘字尾遞增的總數也是O(n),因此調整活動節點的數目也是以O(n)為界的。
註釋11:最後一點註釋,這裡的注意其實主要證明為什麼演算法時O(n),以及為了某種特殊目的需要在字串後面加一個$。
上面就是Ukkonen演算法的全部內容,下面就是將其使用程式進行實現了,太長了,見下一篇吧~~~