字符串處理相關算法

這裏寫圖片描述

圖中可以看到這樣一些關係:extend-kmp 是kmp的擴展;ac自動機是kmp的多串形式;它是一個有限自動機;而trie圖實際上是一個確定性有限自動機;ac自動機,trie圖,後綴樹實際上都是一種trie;後綴數組和後綴樹都是與字符串的後綴集合有關的數據結構;trie圖中的後綴指針和後綴樹中的後綴鏈接這兩個概念及其一致。

kmp

首先這個匹配算法,主要思想就是要充分利用上一次的匹配結果,找到匹配失敗時,模式串可以向前移動的最大距離。這個最大距離,必須要保證不會錯過可能的匹配位置,因此這個最大距離實際上就是模式串當前匹配位置的next數組值。也就是max{Aj 是 Pi 的後綴 j < i},pi表示字符串A[1…i],Aj表示A[1…j]。模式串的next數組計算則是一個自匹配的過程。也是利用已有值next[1…i-1]計算next[i]的過程。我們可以看到,如果A[i] = A[next[i-1]+1] 那麼next[i] = next[i-1],否則,就可以將模式串繼續前移了。
整個過程是這樣的:

void next_comp(char * str){
   int next[N+1];
   int k = 0;
   next[1] = 0;
   //循環不變性,每次循環的開始,k = next[i-1] 
   for(int i = 2 ; i <= N ; i++){
      //如果當前位置不匹配,或者還推進到字符串開始,則繼續推進
      while(A[k+1] != A[i] && k != 0){
           k = next[k];
      }    
      if(A[k+1] == A[i]) k++;
      next[i] = k;
   } 
}

複雜度分析:

從上面的過程可以看出,內部循環再不斷的執行k = next[k],而這個值必然是在縮小,也就是是沒執行一次k至少減少1;

另一方面k的初值是0,而最多++N次,而k始終保持非負,很明顯減少的不可能大於增加的那些,所以整個過程的複雜度是O(N)。

上面是next數組的計算過程,而整個kmp的匹配過程與此類似。

extend-kmp

爲什麼叫做擴展-kmp呢,首先我們看它計算的內容,它是要求出字符串B的後綴與字符串A的最長公共前綴。extend[i]表示B[i…B_len] 與A的最長公共前綴長度,也就是要計算這個數組。

觀察這個數組可以知道,kmp可以判斷A是否是B的一個子串,並且找到第一個匹配位置?而對於extend[]數組來說,則可以利用它直接解決匹配問題,只要看extend[]數組元素是否有一個等於len_A即可。顯然這個數組保存了更多更豐富的信息,即B的每個位置與A的匹配長度。

計算這個數組extend也採用了於kmp類似的過程。首先也是需要計算字符串A與自身後綴的最長公共前綴長度。我們設爲next[]數組。當然這裏next數組的含義與kmp裏的有所過程。但它的計算,也是利用了已經計算出來的next[1…i-1]來找到next[i]的大小,整體的思路是一樣的。

具體是這樣的:觀察下圖可以發現
這裏寫圖片描述
先在1…i-1,要找到一個k,使得它滿足k+next[k]-1最大,也就是說,讓k加上next[k]長度儘量長。

實際上下面的證明過程中就是利用了每次計算後k+next[k]始終只增不減,而它很明顯有個上界,來證明整個計算過程複雜度是線性的。如下圖所示,假設我們已經找到這樣的k,然後看怎麼計算next[i]的值。設len = k+next[k]-1(圖中我們用Ak代表next[k]),分情況討論:

如果len < i 也就是說,len的長度還未覆蓋到Ai,這樣我們只要從頭開始比較A[i…n]與A的最長公共前綴即可,這種情況下很明顯的,每比較一次,必然就會讓i+next[i]-1增加一.
如果len >= i,就是我們在圖中表達的情形,這時我們可以看到i這個位置現在等於i-k+1這個位置的元素,這樣又分兩種情況
如果 L = next[i-k+1] >= len-i+1,也就是說L處在第二條虛線的位置,這樣我們可以看到next[i]的大小,至少是len-i+1,然後我們再從此處開始比較後面的還能否匹配,顯然如果多比較一次,也會讓i+A[i]-1多增加1.
如果 L < len-i+1 也就是說L處在第一條虛線位置,我們知道A與Ak在這個位置匹配,但Ak與Ai-k+1在這個位置不匹配,顯然A與與Ai-k+1在這個位置也不會匹配,故next[i]的值就是L。

這樣next[i]的值就被計算出來了,從上面的過程中我們可以看到,next[i]要麼可以直接由k這個位置計算出來,要麼需要在逐個比較,但是如果需要比較,則每次比較會讓k+next[k]-1的最大值加1.而整個過程中這個值只增不減,而且它有一個很明顯的上界k+next[k]-1 < 2*len_A,可見比較的次數要被限制到這個數值之內,因此總的複雜度將是O(N)的。

trie樹

首先trie樹實際上就是一些字符串組成的一個字符查找樹,邊由代表組成字符串的字符代表,這樣我們就可以在O(len(str))時間裏判斷某個字符串是否屬於該集合。trie樹的節點內分支可以用鏈表也可以用數組實現,各有優劣。

簡單的trie樹每條邊由一個字符代表,但是爲了節省空間,可以讓邊代表一段字符,這就是trie的壓縮表示。通過壓縮表示可以使得trie的空間複雜度與單詞節點數目成正比。

AC自動機

ac自動機,可以看成是kmp在多字符串情況下擴展形式,可以用來處理多模式串匹配。只要爲這些模式串建立一個trie樹,然後再爲每個節點建立一個失敗指針,也就是類似與kmp的next函數,讓我們知道如果匹配失敗,可以再從哪個位置重新開始匹配。ac實際上兩個人的名字的首字母,Aho-Corasick。

應該還記得,在kmp構造next數組時,我們是從前往後構造,即先構造1…i-1,然後再利用它們計算next[i],這裏也是類似。不過這個先後,是通過bfs的順序來體現的。AC自動機的失敗指針具有同樣的功能,也就是說當我們的模式串在Tire上進行匹配時,如果與當前節點的關鍵字不能繼續匹配的時候,就應該去當前節點的失敗指針所指向的節點繼續進行匹配。而從根到這個失敗指針指向的節點組成的字符串,實際上就是跟當前節點的後綴的匹配最長的字符串。

這裏寫圖片描述

trie圖

trie圖實際上一個確定性自動機,比ac增加了確定性這個屬性,對於ac自動機來說,當碰到一個不匹配的節點後可能要進行好幾次回溯才能進行下一次匹配。但是對於trie圖來說,可以每一步進行一次匹配,每碰到一個輸入字符都有一個確定的狀態節點。

從上面的圖中我們也可以看到trie圖的後綴節點跟ac自動機的後綴指針基本一致,區別在於trie圖的根添加了了所有字符集的邊。另外trie圖還會爲每個節點補上所有字符集中的字符的邊,而這個補邊的過程實際上也是一個求節點的後綴節點的過程,不過這些節點都是虛的,我們不把它們加到圖中,而是找到它們的等價節點即它們的後綴節點,從而讓這些邊指向後綴節點就可以了。(比如上圖中的黑節點c,它實際上並未出現在我們的初始tire裏,但我們可以把它作爲一個虛節點處理,把指向它的邊指向它的後綴節點)

trie圖主要利用兩個概念實現這種目的。一個是後綴節點,也就是每個節點的路徑字符串去掉第一個字符後的字符串對應的節點。計算這個節點的方法,是通過它父親節點的後綴節點,很明顯它父親的後綴節點與它的後綴節點的區別就是還少一個尾字符,設爲c。所以節點的父節點的指針的c孩子就是該節點的後綴節點。但是因爲有時候它父親不一定有c孩子,所以還得找一個與父親的c孩子等價的節點。於是就碰到一個尋找等價節點的問題。

而trie圖還有一個補邊的操作,不存在的那個字符對應的邊指向的節點實際上可以看成一個虛節點,我們要找一個現有的並且與它等價的節點,將這個邊指向它。這樣也實際上是要尋找等價節點。

我們看怎麼找到一個節點的等價節點,我們所謂的等價是指它們的危險性一致。那我們再看一個節點是危險節點的充要條件是:它的路徑字符串本身就是一個危險單詞,或者它的路徑字符串的後綴對應的節點是一個危險節點。因此我們可以看到,如果這個節點對應的路徑字符串本身不是一個危險單詞,那它就與它的後綴節點是等價的。所以我們補邊的時候,實際指向的是節點的後綴節點就可以了。

trie圖實際上對trie樹進行了改進,添加了額外的信息。使得可以利用它方便的解決多模式串的匹配問題。跟kmp的思想一樣,trie圖也是希望利用現在已經匹配的信息,對未來的匹配提出指導。提出了一些新的概念。定義trie樹上,從根到某個節點的路徑上所有邊上的字符連起來形成的字符串稱爲這個節點的路徑字符串。如果某個節點的路徑字符串以一個危險字符串結尾,那麼這個節點就是危險節點:也就是說如果到達這個點代表是匹配的狀態;否則就是安全節點。 那麼如何判斷某個節點是否危險呢?

根節點顯然是安全節點。一個節點是危險節點的充要條件是:它的路徑字符串本身就是一個危險單詞,或者它的路徑字符串的後綴(這裏特指一個字符串去掉第一個字符後剩餘的部分)對應的節點(一個字符串對應的節點,是指從trie圖中的根節點開始,依次沿某個字符指定的邊到達的節點)是一個危險節點。

那麼如何求每一個節點的後綴節點呢?這裏就可以裏利用以前的計算信息,得到了。具體來說就是利用父親節點的後綴節點,我們只要記住當前節點的最後一個字符設爲C,那麼父親節點的後綴節點的C分支節點就是要求的後綴節點了。首先我們限定,根節點的後綴節點是根本身,第一層節點的後綴節點是根節點。這樣我們可以逐層求出所有節點的後綴節點。但是這個過程中,可能出現一個問題:父親節點的後綴節點可能沒有c分支。這時候該怎麼辦呢?

如下圖所示如果設當前節點的父親節點的後綴節點爲w,我們假設w具有c孩子爲,我們可以看到對於w的整個c子樹來說,因爲根本不存在通向它們的邊c,它們也就不可能是不良字符串,這樣這些節點的危險性也就等價與它們的後綴節點的危險性了,而它們的後綴節點,實際上就是w的後綴節點的c孩子,如此回溯下去,最後就能找到。

這裏寫圖片描述

其實Trie圖所起到的作用就是建立一個確定性有限自動機DFA,圖中的每點都是一個狀態,狀態之間的轉換用有向邊來表示。Trie圖是在Tire的基礎上補邊過來的,其實他應該算是AC自動機的衍生,AC自動機只保存其後綴節點,在使用時再利用後綴節點進行跳轉,並一直迭代到找到相應的狀態轉移爲止,這個應該算是KMP的思想。這篇文章可以參考。

而Trie圖直接將AC自動機在狀態轉移計算後的值保存在當前節點,使得不必再對後綴節點進行迭代。所以Trie圖的每個節點都會有|∑|個狀態轉移(∑指字符集)。構造具體方法可見WC2006《Trie圖的構建、活用與改進》。我簡單敘述下流程:
(1)構建Trie,並保證根節點一定有|∑|個兒子。 (2)層次遍歷Trie,計算後綴節點,節點標記,沒有|∑|個兒子的對其進行補邊。
後綴節點的計算: (1)根結點的後綴節點是它本身。 (2)處於Trie樹第二層的節點的後綴結點也是根結點。
(3)其餘節點的後綴節點,是其父節點的後綴節點中有相應狀態轉移的節點(這裏類似AC自動機的迭代過程)。 節點標記: (1)本身就有標記。
(2)其後綴節點有標記。 補邊: 用其後綴節點相應的狀態轉移來填補當前節點的空白。
最後Trie圖中任意一個節點均有相應的狀態轉移,我們就用這個狀態轉移做動態規劃。
設dp[i][j]表示第i個狀態產生j個字符時,與DNA序列最小的改變值。 假設Tire圖中根節點是0,則初始化dp[0][0]=1。
其後,對圖進行BFS遍歷,可知處於第j層時,就說明以產生了j長度的字符串。 dp[0][0] = 1;for i = 1 to m do
for 圖中每條邊(s1,ch,s2) do dp[s2][i] = min{dp[s1][i-1] + (txt[i-1] !=
ch)}; for 圖中每個結點x do ans = min{dp[x][m]};

後綴樹

後綴樹,實際上就是字符串的所有後綴組成的字符串集合構成的trie樹。如果採用不壓縮方式的trie存儲,這樣整個內部節點和外部節點的總和就可能達到O(n^2).所以不能利用這種存儲方式,因爲如果採用它那麼構建的複雜度下界就是O(n^2),不會再低了。所以必須使用壓縮方式,纔有可能降到O(n)。

構建之前,我們首先給字符串加上一個未在字符串中出現過的單詞,比如”"," ”,很明顯就不會有後綴出現在內部了,可以用反證法證明:假設出現了一個這樣的後綴是內部節點,那麼意味着這條字符串路徑上會有兩個”"," ”只在結尾出現,之前沒有出現過。

構建過程中,我們看如果採用普通的構建過程是怎樣的?普通的構建,假設字符串爲A[1….N],我們從以A[1]開頭的後綴開始插入trie樹,插入的時候,逐步比對,直到找到不匹配的分支,在這個節點將原來的節點分裂,並加入這個新的節點。可以這個過程關鍵是尋找,之前sufix[1]…sufix[i-1]這些已經插入的字符串與sufix[i]的最長公共前綴。之後插入的時間O(1)就可以完成,因此主要的時間花在這個最長公共前綴(稱爲head[i])的尋找上。Headi是W(i,n)和W(j,n)的最長公共前綴,其中j是小於i的任意正整數,Taili使得Headi + Taili = W(i,n)。

那我們看到現在關鍵是這個最長公共前綴head[i]的計算了。我們再次考慮如何利用head[1]…head[i-1]來計算head[i],爲加快尋找hi的速度我們需要使用輔助結構——後綴鏈接。

後綴鏈接的定義(McCreight Arithmetic):
令Head[i-1] = az,其中a是字符串W的第i-1位字符。由於z在範圍i內出現過至少兩次(因爲az也是A[i-1…N]與之前某後綴的最長公共前綴,也就是說另外的那個後綴也是一az開頭的一個串,這樣就意味着它的後繼者,就比然是以z爲前綴的,這樣A[i…N]與它的公共前綴就是z。{實際上這個性質在我們計算後綴數組的lcp時也會利用到}),所以一定有|Head[i]| >= |z|,z是Head[i]的前綴。所謂hi-1的後綴鏈接(Suffix Link)實際是由hi-1指向z對應節點d的指針Link h[i-1]。當然,z有可能是空串,此時Link hi-1由hi-1指向根節點Root。

和前面 ac自動機的失敗指針 trie樹的後綴指針比較,我們可以發現這裏的z它剛好就是head[i-1]去掉第一個字符後的那個後綴,所謂的後綴鏈接,實際上是指向head[i]自身的後綴的鏈接,這個定義也就跟我們trie樹裏的後綴指針所指向的那個位置一致了。這樣這個head[i]的後綴鏈接怎樣建立就很清楚了。

創建方法:
1)根節點Root的後綴鏈接指向它自身
2)任何一個非葉節點的後綴鏈接都在該節點出現以後被立即創建

算法主框架如下:
For i = 1 -> n do
步驟1、函數Find從Link hi-1開始向下搜索找到節點hi
步驟2、增添葉子節點Leafi
步驟3、函數Down創建hi的後綴鏈接Link hi
End for

後綴樹性能分析:
接着剛纔文本框內的僞代碼來談論。對於給定的i,步驟2的複雜度爲O(1),但由於無法確定Link
hi-1到hi之間的節點個數,所以不能保證步驟1總是線性的。局部估算失敗,不妨從整體入手。有一點是肯定的,那就是i +
|Headi|總隨着i的遞增而遞增。因此,W中的每個字符只會被Find函數遍歷1次,總體複雜度是O(n)的。

這個分析就與extend-kmp的複雜度分析很類似了。

後綴數組

後綴數組實際上就是對字符串的後綴按照字典序進行排序,然後把排好序後的順序放到一個數組sa[]裏保存,數組元素代表了後綴在原串裏的起始索引。通過這個我們可以很容易得到另一個數組rank[],rank[i]代表了原來的後綴A[i…N]在sa數組裏的排名。

這個數據結構,主要涉及兩個方面的內容,一個是如何快速的對這些後綴排序,有很多方法,這裏只說明倍增算法,這個方法比較好理解,思路也比較巧妙。

還有就是後綴數組求出來後,如果要發揮比較強的作用,還需要求出各個後綴的最長公共前綴lcs。所以lcs的計算也是一個重點。

首先看排序,如果我們採用普通的排序算法,那麼需要nlogn次比較,但是每次比較需要O(n),這樣總的複雜度將是O(n*nlogn).

倍增算法是這樣的,主要是第i次排序,比較時的大小時利用了第i-1次的排序結果,這樣可以讓比較在O(1)時間裏完成:
我們首先對所有從原字符串各個位置開始的長度爲1的字符進行排序,然後再對從這些位置開始的長度爲2的排序,之後是長度爲2^i的排序,直到2^i >= N.可以看到這中間,總共需要log N次排序。然後我們看第i次排序,比較大小時怎樣利用了第i-1次的排序結果。

比如在第i次排序時,我們需要比較A[j]和A[k]開始的長度爲2^i的串,那麼我們可以將它們分成兩塊:
A[j]開始的長度爲2^i的串 = A[j]開始的2^(i-1)長 + A[j+2^(i-1)]開始的2^(i-1)長
A[k]開始的長度爲2^i的串 = A[k]開始的2^(i-1)長 + A[k+2^(i-1)]開始的2^(i-1)長
要比較A[j]開始的長度爲2^i的串 和 A[k]開始的長度爲2^i的串,我們只要先比較第一部分,如相等再比較第2部分,而這兩部分大小因爲之前已經排好序了,我們完全可以給它們一個rank值,只比較它們的rank值就可以得到大小關係,這樣比較就可以在O(1)時間內完成了。另外如果我們的排序算法是O(n)的,這樣整個算法的複雜度就是O(nlogn)的了。

再看lcs的計算,如果要計算任意兩個後綴的lcs[i][j],我們有一個結論:

設 i

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