Chapter 8 字串演算法(String Algorithm)
●Section 0 字元與字串(Alphabets and Strings)
字串是一種很特別的結構,他不只是一個多個字元所組成的序列而已,因為我們會把字 串看成一個整體,當我們要對這些字串進行操作時,就會有很多問題出現。因此這章要講的 便是和字串相關的資料結構或演算法。以下就來說一下基本定義:
1. 字元(Character):
字元代表的是一個象徵符號(Symbols),例如:A,B,C,a,b,c,1,2,3……。
2. 字元集(Alphabet):
字元集是由有限個字元組成的非空集合,通常以符號∑表示。
3. 字串(String):
(1) 一個由同個字元集中的字元所構成的序列,稱為一個字串或單字(Word),而通常 以符號S表示一個字串。
(2) n個字元所表示出的字串S我們通常寫成S=a1a2a3…an(其中aiא∑ , i=1…n),且 我們說字串S的長度(表示成|S|)為n,第i個字元ai也可以表示成Si或S[i]。
(3) 所有由∑中字元構成的字串,表示成∑*,而任一∑*的子集我們都可以稱作一種語言
(Language)。
(4) 字串S=a1a2a3…an中的一段aiai+1…aj(1 ≦ i ≦ j ≦ n)我們稱為S的子字串
(Substring),且我們可以表示成Si…j,或S[i…j]。
(5) 字串S刪掉一些元素之後所剩下來的序列我們稱為S的子序列(Subsequence), 其與子字串有點相近,但子字串一定是子序列,子序列卻不一定是子字串。
(6) S1…k我們稱為S的k前綴(k‐prefix),且可表示成S1…k ٌ S。
(7) Sn‐k+1…n我們稱為S的k後綴(k‐suffix),且可表示成Sn‐k+1…n ⊐ S。
(8) 字串大小的比較:假設兩個字串S[1..n]與T[1..m],且l是使S[1.. l]=T[1..
l]最大的數且l ≤ n,m,則
1. 若l = n且l = m,則S = T。
2. 若l = n且l < m,則S < T。
3. 若l < n且l < m,則S > T。
4. 若l < n 且l < m,則S[l+1]與T[l+1]的大小比較即為S與T的大小比 較。
ㄧ般我們所說的字典順序即是字串大小順序。
4. 其他專有名詞定義(Other):
(1)最長公共前綴(Longest Common Prefix, LCP):兩個字串u,v的LCP我們通常 表示成 LCP(u,v),意思就是 Max{ l | u[1..l] = v[1..l] 且 l ≤ u 與 v 的長度 },
也就是從前面開始比對,看最長可以比到哪裡才不同。
●Section 1 模式匹配(Pattern Matching)
模式匹配是字串相關問題中很常見的一個而基本問題,基本問題敘述如下:
給定一個主字串(Text)S[1…n],和模板字串(Pattern)T[1…m],問你說T是否為 S的一個子字串。
§3‐1 樸素算法(Naïve String‐Matching algorithm)
對於這類問題,我們能夠有一個很明顯很直觀的想法就是說,對於所有可能匹配到的地 方去匹配看看,簡單來說對於所有的k(k=[1,n‐m+1])檢查S[k…k+m‐1]=T,當然這種作 法複雜度是O(m(n‐m+1)),對於競賽這種很容易出現刁鑽字串比對的情形,很容易就會花 費很多時間,因此較不實用。但是對於字串字元出現情形非常隨機散亂時卻也不失為一種好 方法。
NAIVE‐STRING‐MATCHER(S, T) 1 n ← length[S]
2 m ← length[T]
3 for k ← 1 to n – m + 1
4 do if T[1 ... m] = S[k ... k + m ‐1]
5 then print "Pattern occurs with shift"
§3‐2 RK算法(Robin‐Karp algorithm)
Robin‐Karp算法是對於樸素算法的一種改進,利用類似rolling hash的想法,但是 卻不必真的去寫一個hash table,而是利用rolling hash能夠滾動的特性,將S中長度 為m的子字串轉成一個key,並且與T的key比較,如果key相同才進行比較,這樣子其 實能夠減少許多不必要比較的時間。
而其預處理複雜度是Θ(m),主程序的複雜度是O((n‐m+1)+cm)(其中c為key值相同 的數量),或者更進一步分析O(c) = O(v+n/p)(v為匹配成功的次數,n/p為錯誤匹配的 次數),則我們可以得到複雜度為O(n + m(v+n/p)),假設匹配次數不多且p>n時,幾乎會 是線性的!!
RABIN‐KARP‐MATCHER(S, T, |Σ|, p) 1 n ← length[S]
2 m ← length[T]
3 h ← |Σ|m‐1 mod p 4 q ← 0
5 t1 ← 0
6 for i ← 1 to m ▹ Preprocessing.
7 do q ← ( q×|Σ| + T[i] ) mod p 8 t1 ← ( t1×|Σ| + S[i] ) mod p
9 for k ← 1 to n – m + 1 ▹ Matching.
10 do if q = tk
11 then if T[1 ... m] = S[k ... k + m ‐ 1 ]
12 then print "Pattern occurs with shift" s 13 if k < n – m + 1
14 then tk+1 ← (( tk – S[i]×h )|Σ|+ S[k + m]) mod p
或許更進一步我們可以利用多個p值來降低key值相同的可能性,或許到p的個數夠多 時,碰撞機率非常低,甚至不需要檢查是否匹配成功,利用所有key值相等即可判斷匹配成 功。當然這樣做還是有某種程度的危險性就是。
§3‐3 KMP算法(Knuth‐Morris‐Paratt algorithm)
即便RK在理想狀況時能夠是線性的,但是最差複雜度仍然不能盡如人意,因此下面就 要介紹一個有名的KMP算法,即使最差狀況預處理複雜度為Θ(m),主程序複雜度仍只有 Θ(n)。
我們可以觀察一下上面兩種算法,速度會慢有一個主要原因就是在於對於S之中的某些 地方,T對他去檢查次數太多次了,更白話的說法是:我們沒有將之前匹配的訊息「充分利
用」,我們沒有從匹配失敗的地方記取教訓,以致於某些地方不斷的與 T 進行比較,而造成 時間緩慢。
(a) (b)
我們可以先觀察上面兩張圖,假設我們現在從S中的k開始與T做匹配,比對到S[k+5]
時發現匹配失敗了。根據樸素算法,我們應該會把T向前移動一格,並且重新從k+1開始做 匹配。
然而我們其實可以發現到,因為在 k 的地方匹配了 5 個字,那接下來的四個字一定是 T[2..5]呀@@!假如我們有辦法先知道T[2..5]根本沒辦法匹配T,那我們幹嘛還傻傻的只 位移一位繼續比對。又如果我們能知道哪個l能使得T[l..5]能與T匹配成功的話,那直接 位移到l的位置不就好了嗎(意思就是位移l‐1位)?
因此我們可以把問題改成說,如果在某個地方k,S與T匹配到了i位,那下次我可以 直接位移多少位,就不用再檢查S[k..k+i‐1]的任何一個地方了?
我們可以知道匹配了i位等價於知道接下來出現的字是T[2..i]。我們現在要做的就是 找一個最小的l使得T[l..i]=T[1..i‐l+1]且2≤l。其實你可以發現到,這個問題就是問 你說,從T中的i位置往前延伸,最多可以往前幾位(< i)使得往前的這個位數是T的前綴。
我們引入一個函數π,使上面所說的從i最多可以往前的位數為π[i] ,則意思就是T[i‐
π[i]+1..i] = T[1..π[i]]且π[i]<i。換句話說就是原本的位置i,下次要匹配時,代表 在T中的哪個位置?現在我們就是要求出所有的π[i]。
我們可以很明顯的知道π[1] = 0,假設我們現在知道π[1]...π[i],那我們要如何從 這些資訊去推出π[i+1]呢?(這個過程叫遞推,有點類似數學歸納法的過程)
很明顯的可以出現兩種狀況:
(1)T[i+1]=T[π[i]+1]:這時候很明顯可以多匹配一個,因此π[i+1]= π[i]+1。
(2) T[i+1]≠T[π[i]+1]:這時候就較麻煩一點,但你仔細觀察後會發現,既然T[i+1]
≠T[π[i]+1],那下一個可能的匹配位置一定是T[π[π[i]+1],因此我們可以不斷迭代下 去,直到π[π[..π[i]]]=0或T[i+1]=T[π[π[..π[i]]]+1]。
COMPUTE‐PREFIX‐FUNCTION(T)
1 m ← length[T]
2 π[1] ← 0 3 k ← 0
4 for i ← 2 to m
5 do while k > 0 and T[k + 1] ≠ T[i]
6 do k ← π[k]
7 if T[k + 1] = T[i]
8 then k ← k + 1 9 π[i] ← k
10 return π
有了π函數,與S匹配就易如反掌了!
假設在某個時刻S[i‐k+1..i]=T[1..k](即是從S的i往前與T匹配了k位),那我 們要檢查S[i+1]是否=T[k+1]:
(1)若S[i+1] = T[k+1],則k=k+1繼續匹配。
(2)若S[i+1] ≠ T[k+1],則k利用π[k]迭代下去,直到π[π[..π[k]]]=0或 T[i+1]=T[π[π[..π[k]]]+1]。
一但在某個時候發現k=m,即匹配成功(S[i‐k+1..i]=T),如果要繼續進行匹配,之 後可以先對k做一次迭代(k=π[k])再繼續匹配下去。
KMP‐MATCHER(S, T) 1 n ← length[S]
2 m ← length[T]
3 π ← COMPUTE‐PREFIX‐FUNCTION(T)
4 k ← 0 ▹Number of characters matched.
5 for i ← 1 to n ▹Scan the text from left to right.
6 do while k > 0 and T[k + 1] ≠ S[i]
7 do k ← π[k] ▹Next character does not match.
8 if T[k + 1] = S[i]
9 then k ← k + 1 ▹Next character matches.
10 if k = m ▹Is all of T matched?
11 then print "Pattern occurs with shift" i ‐ m 12 k ← π[k] ▹Look for the next match.
會匹配之後我們來分析一下他的複雜度,很明顯的如果是考慮每個地方最多迭代幾次的 話,那有可能迭代O(m)次,想當然爾,這樣會變成O(nm)。然而這是從部分來看,從整體 來看的話,你會發現到每次k只可能+1,因此k最多只可能減少n次,第7行的while操 作也只可能執行n次,因此最差就是O(n)。預處理的估計也類似,所以只有O(m),因而KMP 的整個複雜度為O(n+m)。
§3‐4 KMP擴展算法(Knuth‐Morris‐Paratt Extend algorithm)
我們再看一下一般的KMP算法,發現到他是看S中的每個位置往前最多可以與T匹配多 少,你會覺得這非常不符合人體工學呀(?),因此我們決定要將KMP擴展成KMPlayer(誤)。
總之有人研究出了另外一個東西,我們把它稱為擴展KMP(KMP‐Extend)。
擴展 KMP主要與 KMP不同的就是他很符合人體工學,即是從 S 的每個位置往後能匹配 多少?
我們定義一個 α 函數,為 α[i] = LCP(S[i..n],T) = Max{ l | S[i...i+l‐1] = T[1...l] },意思就是T從S[i]開始比較最大可以匹配到的長度。
如果我們能求出α函數,那就能求出所有T在S中匹配的位置了。
現在我們想嘗試利用與 KMP 類似的遞推法,藉由目前的α[1]..α[i‐1],推出 α[i]。
要推出 α[i],其實可以使用之前的資訊來幫助求出之。首先我們可以分成幾種狀況(其中
的k是介於1到i‐1且能使k+α[k]‐1最大的值。白話的說,就是能使我們對後面字串了解 最多的位置):
(1) (2) (3)
(1)k+α[k]‐1 < i:
如上圖(1),很明顯的,因為先前的資訊不能提供我們任何關於S[i]之後的訊息,
因此要求出α[i]就必須從S[i]開始慢慢向後比對直到求出與T的LCP。
(2)i ≤ k+α[k]‐1:
這又可以分成兩種狀況,如上圖(2)(3)。可以很明顯看出,因為我們有的訊息已
經超過 i,這代表說在 k+α[i]‐1 之前的字串我們其實都已經知道會長什麼樣子
了,即使要比對也不需要從S[i]慢慢往後比。
但是我們這樣做有個前提,我們必須要知道LCP(T[i‐k+1..m],T),有這個我們才 能知道要比較直接從哪裡開始比較就好了。
因此我們在又要引入一個函數 β函數,使β[i] = LCP(T[i..m],T),可以發現到 β的 形式與α極像,因此我們可以先假設β已知,利用β求出α。如果會從β推出α,那從β推 出β也不是難事。
以剛才的第二個條件下又能有兩種狀況:
1. k+α[k]‐1 > i+β[i‐k+1]‐1:
因為可能 LCP 長度在已知訊息範圍內,所以很明顯的,α[i]只可能是 β[i‐k+1]。(因為如果 α[i]可能更大的話,β[i‐k+1]也一定會更大,所以 最大一定是β[i‐k+1])
2. k+α[k]‐1 ≤ i+β[i‐k+1]‐1:
因為可能LCP長度超過目前已知訊息,但卻不能保證超過已知訊息後會與LCP 相符合,所以必須要從S[k+α[k]‐1]開始往後匹配,直到求出LCP。
因此利用上述這樣遞推的過程就能推出α,而上述的β雖然沒有說明,但其實與α一模 一樣,因此就不再贅述。
KMP‐MATCHER‐EXTEND(S, T) 01 n ← length[S]
02 m ← length[T]
03 β ← COMPUTE‐PREFIX‐FUNCTION‐EXTEND(T) 04 j ← 0
05 while T[1 + j] = S[1 + j]
06 do j ← j + 1 07 α[1] ← j
08 k ← 1
09 for i ← 2 to n
10 do if i + β[i – k + 1] ‐ 1 < k + α[k] – 1 ▹ Case 2‐1 11 then α[i] ← β[i – k + 1]
12 else
13 j ← Max( 0 , k + α[k] ‐ i ) ▹ j = 0 ‐> Case 1 14 while S[i + j] = T[1 + j] ▹ j ≠ 0 ‐> Case 2‐2 15 do j ← j + 1
16 α[i] ← j 17 k ← i
COMPUTE‐PREFIX‐FUNCTION‐EXTEND(T) 01 m ← length[T]
02 j ← 0
03 while T[1 + j] = T[2 + j]
04 do j ← j + 1 05 β[2] ← j
06 k ← 2
07 for i ← 3 to m
08 do if i + β[i – k + 1] ‐ 1 < k + β[k] – 1 ▹ Case 2‐1 09 then β[i] ← β[i – k + 1]
10 else
11 j ← Max( 0 , k + β[k] ‐ i ) ▹ j = 0 ‐> Case 1 12 while T[i + j] = T[1 + j] ▹ j ≠ 0 ‐> Case 2‐2 13 do j ← j + 1
14 β[i] ← j 15 k ← i 16 return β
同樣的如果只看單體,那有可能每次回圈都跑O(m)次,不過整體來看的話,可以明顯 發現,k一定是不斷遞增的,要碼是在第一個if就判斷掉且k不變,要碼就是k越來越大,
且每次要比對時的起點一定是不斷往後的,也就是對於每個S中的任一個位置頂多只會比對 一次,因此While總共只會執行n次,是線性的。因此複雜度當然也就是O(n+m)了。
§3‐5 其他算法(Others algorithm)
其實匹配算法也有很多人研究過,也有著不同的結果,例如說BM(Boyer‐Moore algorithm)、z‐value...等等,都是著名的算法,但是對於競賽來說,學一個KMP就 已經很足夠,如果有興趣的話可以自己去專研。
這裡提供一個網址,說明許多字串匹配的算法,可以參考看看:
http://www‐igm.univ‐mlv.fr/~lecroq/string/
接下來將介紹一些特殊的字串問題或資料結構,對於處理許多字串的問題往往都能有高 效的算法,當然不僅限於基本的模式匹配而已。
●Section 2 字典樹(Trie, Prefix Tree)
Trie是一種特別的樹狀結構,就像我們平常查字典時一樣,對於查找一個字串的時候能 夠依照前到後的順序查找。這種結構的好處是,因為他是依照字串的prefix來進行分支的,
因此能夠迅速的查找與插入字串或支持一些對於prefix的操作(例如求兩個字串的LCP)。
當然如果字符集Σ不是英文字母而是數字或其他序列也沒關係,只是為了方便以下都使 用字母來說明。
§2‐1 定義(Definition)
1. Trie是一個|Σ|元樹,且有分成兩種節點:
(1)分支點(Branch Node):全都是非葉節點,不儲存字串,但是有|Σ|個孩子,
照順序是’A’,‘B’,‘C’...’Z’孩子,與一個Φ孩子
(代表「空」)。
(2)資料點(Data Node):全都是葉節點,且會儲存字串。
2. 對於一個第i層的節點,其α子樹中所有字串的第i個字都是α(α = ’A’...’Z’,Φ)
下圖即是一個Trie的範例。
可以很明顯發現,樹最大的高度h只可能是最大字串的長度+2。
§2‐2 實作(Implementation)
在實作上我們會用一個結構來存取Trie,並且每個Trie有|Σ|+1個兒子指標與一個 Count值和Data指標,兒子指標指向Trie或NULL或一個字串;Count則是紀錄說這個節 點以下有多少字串,如果Count = 1則其為資料節點,而這時候的Data指標就會指向一個 字串。
a. 查找字串(Search):
很明顯的,如果我們要查找字串S,則只要在第 i 層的時候往子節點S[i]走即可。
TRIE‐SEARCH(S)
1 p ← root of Trie 2 for i ← 1 to length[S]
3 do if p‐>Count = 1 ▹ p is a data node 4 then return p‐>Data
5 else if p‐>child[S[i]] = NULL ▹ p is a branch node 6 then return NULL
7 else p ← p‐>child[S[i]]
不用說,因為只可能是往下走所以複雜度是O(h)。
b. 插入字串(Insertion):
同樣也是很明顯,如果我們要插入一個字串S,我們一定是先查到他應該插入的位 置。假如他可以插入的位置是空的,那當然就直接插入就好了。但若假如不是空的話,
那就代表有一個字串T在那個位置上,因此這時候就必須往下擴張節點,直到找到這兩 個節點的LCP。
TRIE‐INSERT(S)
01 p ← root of Trie 02 i ← 1
03 while i ≤ length[S] and q = NULL 04 do p‐>Count ← p‐>Count + 1
05 if p‐>Count = 2 ▹ p is a data node
06 then T ← p‐>Data ▹ T is the collision string
07 p‐>Data ← NULL 08 while S[i] = T[i]
09 do p‐>child[S[i]] ← new Node 10 p ← p‐>child[S[i]]
11 p‐>Count ← 2 12 i ← i + 1
13 p‐>child[T[i]] ← new Node 14 p‐>child[T[i]]‐>Data ← T 15 p‐>child[T[i]]‐>Count ← 1 16 p‐>child[S[i]] ← new Node 17 q ← p‐>child[S[i]]
18 else if p‐>child[S[i]] = NULL ▹ p is a branch node 19 then q ← p
20 else p ← p‐>child[S[i]]
21 i ← i + 1
22 q‐>Data ← S ▹ q is the inserted node 23 q‐>Count ← 1
明顯的複雜度是O(h)。
c. 刪除字串(Deletion):
刪除操作其實一般來說不太會用到,當然也是可以每次作標記之後再刪除,不過這 裡要說的是動態刪除的方式。明顯的,首先我們會先找到要刪除的字串,而且在查找過 程要順便紀錄所有會經過的結點(如果是遞回的話就不用紀錄了XD)。
接下來沿著Path往上爬,每個結點的Count值都‐1。因為一定存在至少一個T跟 S的LCP最大,所以沿著原路回去的話,可能會發現離S最近的幾個分支點Count=1,
這樣會發生問題,因為照理來說分支點的Count一定大於1,因此在回去的時候要一邊 把下面結點Release掉,並把分支點變成資料點。
TRIE‐DELETE(S)
01 p ← root of Trie 02 Stack ← EMPTY
02 for i ← 1 to length[S]
03 do push p onto Stack
03 if p‐>Count = 1 ▹ p is a data node 04 then break
05 else if p‐>child[S[i]] = NULL ▹ p is a branch node 06 then break
07 else p ← p‐>child[S[i]]
08 if Stack[top]‐>Count != 1 then EXIT ▹ S does not find
09 i ← i – 1 ▹ i is depth of the pointer
10 T ← NULL ▹ T is the string which has
11 p ← pop Stack maximal LCP with S
12 while Stack ≠ EMPTY 13 do p ← pop Stack
14 p‐>Count ← p‐>Count ‐ 1 15 if T = NULL
16 then for each character j ∈ {Φ}∪Σ 17 do if j ≠ S[i]
18 then T ← p‐>child[j]‐>Data
19 RELEASE(p‐>child[S[i]]) 20 if p‐>Count = 1
21 then p‐>Data ← T
22 RELEASE(p‐>child[T[i]]) 23 i ← i ‐ 1
查找T的時間是O(|Σ|),而遞回上去刪點的複雜度是O(h),所以總的來說是 O(h+|Σ|)。
§2‐3 應用與擴展(Application and Extension)
a. 排序(Sorting):
因為他就像字典一樣,所以當我們在作DFS的時候其實就會照著字母的順序,所以 當然可以輕易的做到字串排序了,複雜度則是O(結點個數)=O(hn)=O(讀入字串的時 間)。雖然複雜度還蠻理想的,不過一般來說我們不會為了排序就建一棵Trie吧XD”。
b. 求LCP:
任兩個存在於Trie中的LCP,很明顯的就是最近共同祖先(Lowest Common Ancestor, LCA)嘛,之後的課程將會講到,如果這棵樹是靜態的,那麼就利用O(結點 數量)的預處理的時間,回答每個LCA詢問只花O(1)的時間。
c. 壓縮字典樹(Compressed Trie):
因為我們會發現到,如果任兩個字串的LCP很長,那中間就必須會多出很多只有一 個child的branch node,因此我們可以把這些branch node壓縮起來,也就是給每 個branch node標記ㄧ個level值,代表要往下查詢時直接看字串的第level個字就 好了。當然這樣很多操作會更複雜些,因此就不在贅述,有興趣者就請自己參考相關書 籍囉。
下左圖是一個binary trie,右圖則是Compressed binary trie。
是說還有一個叫做PATRICIA(Practical Algorithm To Retrieve Information Coded In Alphanumeric)的東西,不過講師沒什麼研究,大概就是把Trie的空間與時間 優化到極致的一種結構,有興趣者就自行參考相關書籍囉。
§2‐4 小結(Conclusion)
Trie的優點在於各項操作蠻快的,而且並不會說很難寫,如果對於Prefix相關的問題 也能有一些好的複雜度解法,不過有一個很大的缺點是,因為結點上會有很多空指標,造成 空間的負擔,因此使用時還是要謹慎估計與考慮才是。
●Section 3 後綴數組(Suffix Array)
了解了Trie之後我們可以知道,Trie主要是對於不同字串之間尋求一種相關的結構,
然而對於只有單一個字串時,字串與其子字串的相關性問題卻不能有些很好的做法,而KMP 所求的序列雖然對匹配很有用,但是卻不易擴展。因此我們現在就必須介紹一個叫做後綴數 組的東西,他是一個關係到字串本身與其Suffix的序列,對於處理對特定字串相關的操作 來說非常的有用,以下就來慢慢介紹:
§3‐1 定義(Definition)
我們先假設對於一個特定的字串S[1..n],其後綴S[i..n]定義為Suffix(i)。很明顯的 因為兩個字串要相等一定長度一樣,所以對於一個S的所有Suffix都不可能相等。
由此我們可以定義後綴數組與名次數組。
(1)後綴數組:假設所有SA[i]是S的所有Suffix的一個排列,且使得Suffix(SA[1])
< Suffix(SA[2]) < ... < Suffix(SA[n]),則我們可以稱SA是S的後綴數組。
其簡單的說,即是SA[i]為所有Suffix中第i大Suffix的位置。
(2)名次數組:名次數組Rank[i]其實就是SA的一個反元素,使得SA[Rand[i]] = i,
也就是Suffix(i)在所有Suffix中的排名。
為了敘述方便我們再定義一些相關符號:
假設兩字串u, v,則我們可以定義三種比較關係<k、>k、=k: ◎ u <k v ⇔ u[1..k] < v[1..k]。
◎ u =k v ⇔ u[1..k] = v[1..k]。
◎ u >k v ⇔ u[1..k] > v[1..k]。
簡單的說就是對於一個字串的前 k 個字做比較,特別要注意的是,如果沒有 k 個字元也沒關 係,只要在那之前有出現不一樣就可以了。
§3‐2 建立後綴數組(Implementation)
可以很簡單的想到一個方法,那就是直接把所有suffix列出來再排序,不過當然這樣 很明顯的會是O(n^2lgn)之類的,非常的沒有效率。因為我們沒有利用到Suffix之間的關 係,因此下面就來介紹一個Suffix array的倍增算法:
● 倍增算法(Doubling Algorithm):
首先我們可以先在S後面加一個字元Φ,並假設這個字元Φ < 字元集Σ中的所有字元。
這樣做便可以使如果比較字串時可能出現未定義的情形變成有定義。
根據定義,我們可以得到下面三個性質:
性質1 對k ≥ n,有Suffix(i) <k Suffix(j) ⇔ Suffix(i) < Suffix(j) 性質2 Suffix(i) =2k Suffix(j)
⇔ Suffix(i) =k Suffix(j) 且 Suffix(i+k) =k Suffix(j+k)
性質3 Suffix(i) <2k Suffix(j) ⇔ Suffix(i) <k Suffix(j) 或
(Suffix(i) =k Suffix(j) 且Suffix(i+k) <k Suffix(j+k))
有了這三個性質,我們可以先定義k‐後綴數組SAk與k‐名次數組Rankk:
k‐後綴數組SAk:假設所有SAk[i]是S的所有Suffix的一個排列,且使得Suffix(SA[1])
≤k Suffix(SA[2]) ≤k ... ≤k Suffix(SA[n]),則我們可以稱SAk是S的k‐後綴數 組。其簡單的說,即是SA[i]為所有Suffix中,以只比前k個為前提時,第i大Suffix 的位置。要注意的一點是,k‐後綴數組有可能是會出現相等的情形的,所以其實排 列並不唯一,不過不影響我們繼續以下的操作。
k‐名次數組Rankk:名次數組Rankk[i]其實就是在只比較前k個的前提下,Suffix(i) 的排名。要注意的一點是,因為Suffix(i)只基於比較前k個字元的關係,所以是 有可能出現等於的情形的,如果對於i, j有Suffix(i)=kSuffix(i),那便有 Rankk[i]=Rankk[j]。
其更好一點的說法是Rankk[i] = 1+ |{ j | Suffix(j) <k Suffix(i) }|。
而且很明顯的有了SAk我們很容易就能在O(n)時間內求出Rankk。
有了上述的性質與定義,我們便可以嘗試使用類似遞推法的方法求出SA與Rank。假設 我們已經知道SAk與Rankk了,那我們便可以把所有的Suffix2k(i)想成有兩個數的有序數 對(Rankk[i], Rankk[i+k]),那我們要求SA2k與Rank2k,其實就是要將Suffix2k(i),
做排序的動作,排完之後便可以在O(n)的時間求出SA2k與Rank2k。但重點是要如何排序呢,
如果是Quicksort,則需要花 O(lgn)的時間,其實就一般情況來說算是不錯了,而且 Quicksort 也算好寫。不過通常必須要有 O(n)才比較足夠,因此這時候我們就會利用 Radix Sort,複雜 度為 O(n)。而 SA1跟 Rank1要怎麼求呢?很簡單,就把字元排序就好了,因為這只會進行一 次,所以 O(n)或 O(nlgn)都無妨。
會遞推之後我們可以知道,一但出現SAm與Rankm時(m = 2k ≥ n),我們便求出了SA 與Rank,因此總的複雜度是O(nlgn),如果是遞推過程是採用Quicksort則是O(nlg2n)。
DOUBLING‐ALGORITHM(S) 01 sort suffix1 by S 02 cnt ← 1
03 for i ← 2 to n
04 do if S[ suffix1[i‐1] ] ≠ S[ suffix1[i] ] 05 then cnt ← cnt + 1
06 rank1[suffix1[i]] ← cnt;
07 for k ← 1 to n step k
08 do sort suffix2k by suffixk and rankk 09 rank2k[ suffix2k[1] ] = 1;
10 cnt ← 1
11 for i ← 2 to n
12 do if rankk[ suffix2k[i‐1] ] ≠ rankk[ suffix2k[i] ] or 13 rankk[ suffix2k[i‐1]+k ] ≠ rankk[ suffix2k[i]+k ] 14 then cnt ← cnt + 1
15 rank2k[suffix2k[i]] ← cnt;
嘿嘿看起來很簡單對吧,其實真正寫的時候會遇到許多問題呢!
而且其實倍增算法有個特別的優化,在使用Radix sort時,我們會需要做兩次Counting sort,而且Counting Sort一次大約要花4*n的時間(初始化、計算Count值、迭加Count 值、計算Suffix2k),因此總共會需要8*n的時間。
但是其實不需要這麼麻煩,因為我們可以發現Rankk其實有一個特性,就是Rankk[SAk[i]]
≤ i,且對於i < j一定會有Rankk[SAk[i]] ≤ Rankk[SAk[j]]。
又Count[p] = |{ i | Rankk[i] ≤ p }|,所以對於所有可能的i,i+1,...,j能使 Rankk[SAk[i]] = ... = Rankk[SAk[j]] = p,可以發現j = Count[p]。
求出Count值後,要依序填入Suffix2k。我們可以發現到,Rankk[i]只要i ≥ n‐k+1,
則Rank2k[i]中的相對順序不可能改變。因此i ≥ n‐k+1的Rankk[i]是不可能重複的。
又會受到改變順序的影響的suffixk[i],只可能是suffixk[i] ≤ n‐k+1的元素,因 為這些元素要便成suffix2k[i]都需要看suffixk[i+k]的臉色。所以我們可以依i大到小的 順序把suffixk[i+k] ≥ k元素填入suffixk[i]的位置,因為suffixk[i+k]小的一定先填,
所以一定會在後面。填完之後剩下的就是Rankk[i]中i ≥ n‐k+1的元素了,只要照順序填 上去就好,因為他們的值都不會重複的,而且因為不需要看i+k項的臉色,所以優先度一定 是最高的。
此優化就是把上面的code的第八行改成以下幾行:
01 for i ← 1 to n
02 do Count[rankk[suffixk[i]]] ← i 03 for i ← n downto 1
04 do if suffixk[i]‐k ≥ 1
05 then suffix2k[Count[Rankk[Suffixk[i]‐k]]] ← Suffixk[i]‐k]
06 Count[Rankk[Suffixk[i]‐k]] ← Count[Rankk[Suffixk[i]‐k]] ‐ 1 07 for i ← n downto n‐k+1
08 do if suffixk[i]‐k ≥ 1
09 then suffix2k[Count[Rankk[i]] ← i
因為每次只需要做3*n次,且第七行的回圈到最後總共只會做n次而不是nlgn次。因 此每次能比原本做法快很多。而實際上這個優化當遇到可能需要用O(n)建SA法,且n不大 時,還能比O(n)得更有效率許多。
● 三倍增算法(Skew Algorithm , Difference Cover modulo 3 , DC3):
DC3算法較倍增算法複雜很多,但他有O(n)的複雜度,雖然當n不大時實行狀況不理想,
或許是常數太大的原因。而且其不容易寫,對於競賽實用度來說不是很高。不過有興趣者還 是可以自行觀看,在此就不說明。
下面有篇論文應該即是DC3的出處:
http://www.cs.helsinki.fi/u/tpkarkka/publications/jacm05‐revised.pdf
§3‐4 應用(Application)
如果想要充分發揮Suffix array的效果,我們還必須要有一個輔助工具,就是能在很 快的時間內查出任兩個Suffix的LCP。
我們先定義LCP(i,j) = lcp(Suffix(SA[i]),Suffix(SA[j])),意思就是第i大的 Suffix跟第j大的Suffix的LCP長度。
而我們很明顯的會有兩個性質:
性質1 LCP(i,j) = LCP(j,i)
性質2 LCP(i,i) = Length(SA[i])
由這兩個性質我們可以得出一個LCP Lemma:
對任意1≤i<j<k≤n,LCP(i,j)=min{LCP(i,j)=LCP(j,k)}
且由這個Lemma我們可以得出LCP Theorem:
對任意1≤i<j≤n,LCP(i,j)=min{ LCP(k‐1,k) | i<k≤j }
具體證明就不說明了,直觀來看其實是蠻顯然的,因為任兩個相鄰的Suffix(SA[k])跟 Suffix(SA[k+1])是變異最少的,所以i與j的LCP就會是i~j之間最小的LCP。
因此我們可以這裡定義一個height數列,另height[i]=LCP(i‐1,i),1<i≤j。且令 height[1]=0。
且為了方便我們再設另一個height的反數列,h[i]=height[Rank[i]] ⇔
height[i]=h[SA[i]]。
我們可以明顯看出當要求LCP(i,j)時就是要求height[i+1]~height[j]之間的最小 值,這我們可以利用RMQ(Range Minimum Query)或線段樹來做,詳細做法之後課程才會 說到。
然而現在最重要的就是如何求出h或height數列呢?(只要求出一個另一個都能容易 求出)
研究發現我們能有第三個性質:
性質3 對i>1且Rank[i]>1 必有h[i] ≥ h[i‐1]‐1。
這個性質其實還蠻不直觀,不過證明同樣不在此說明。我們可以根據性質三,並依照下 列方法求出h[i]:
1. Rank[i] = 1:h[i] = 0。
2. i = 1 或 h[i‐1] ≤ 1:直接將Suffix(i)跟Suffix(Rank[i]‐1)從第一個字開 始比較直到有兩個字不同。
3. i>1, Rank[i]>1, h[i‐1]>1:Suffix(i)跟Suffix(Rank[i]‐1)至少有前 h[i‐1]‐1個字是相同的,於是比較從h[i‐1]開始,直到某個地方不相同就可以計 算出h[i]了。
看起來好像就只是暴力比對而已,但是這樣的複雜度卻只有O(n),是個很有效率的做 法。因此便能在O(n)時間內求出h跟height數列了。
§3‐3 小結(Conclusion)
後綴數組在字串相關問題中是非常有利的應用,結合height數列的力量,發揮效力更 加強大,例如說模式批配可以達到O(mlgn)、多模式字串匹配可以達O(m+lgn)、求最長回 文子字串為O(mlgm)...,總之其功能強大,在此就不說明。
事實上還有一種較做後綴樹的東西,對於所有後綴數組做得到的後綴樹都做得到,但是 後綴樹實行難度較高,因此在此不做說明,如果有興趣者則可以自行研究。
另外推薦一篇對於後綴數組寫得很好的文章:
※ 2004 年 国家集训队论文许智磊-《后缀数组》
●Section 4 自動機(Automaton)
自動機在計算理論(Theory of Computation)中是很重要的一個應用,在此要說明的 便是對字串問題很有利的工具—有限狀態自動機(Deterministic Finite Automata, DFA)。
§4‐1 定義(Definition)
有限狀態自動機M是一個5元組(Q, q0, F, Σ, δ),其中:
● Q是狀態的一個有限集合 ● q0∈Q是一個初始狀態
● F⊆Q是一個可接受的狀態集合 ● Σ是輸入的字元集
● δ是一個Q×Σ→ Q的函數,稱為M的狀態轉移函數
下圖即顯示了一個簡單的自動機M({0,1},0,{1},{a,b},δ):
其中δ是轉移函數,δ(0,a)=1, δ(0,b)=0, δ(1,a)=0, δ(1,b)=0。
而我們還可以推導出一個函數δ*,其為一個Q×Σ*→ Q的函數,如果 S是ㄧ個字串,q0
是起始狀態,則δ*有下列定義。
δ*(q0,0) = q0 (0代表ㄧ個空字串)
δ*(q0,uc) = δ(δ*(q0,u),c) (c代表ㄧ個字元,uc 就是把 c 接在 u 末端)
特別的如果我們說δ*(q0,u) ∈ F,則u是對M來說可接受的(accepted),否則他就是被 拒絕的(rejected)。
所有被M接受的字串所組成的集合我們寫做L(M),其為一個Σ*的子集。
如果一個語言(Language)是規律(Regular)的,代表存在ㄧ個M能夠接受他的所有 元素。
§4‐2 實作與應用(Implementation and Application)
我們可以發現到,自動機在做的事其實跟KMP很像,每次往後匹配,匹配失敗就跳到另 一個位置繼續匹配,ㄧ但出現狀態是被接受的,就代表字串匹配成功了。因此我們只要求出 對模式字串T的自動機M,字串匹配就是小意思了。
FINITE‐AUTOMATON‐MATCHER(S, δ, m) 01 n ← length[S]
02 q ← 0
03 for i ← 1 to n
04 do q ← δ(q, S[i]) 05 if q = m
06 then print "Pattern occurs with shift" i ‐ m 下面就是一個自動機的說明:
但問題來了我們要怎麼求出對應模式字串T的自動機M呢?
假設我們的狀態指的就是已匹配成功的個數的話Q = {0, 1, ... ,m}。那麼當對某個 狀態q與字元a,若q = m或者T[q+1]≠a,則δ(q,a) = δ(π[q],a)(其中π是KMP算法 中所求的前綴函數)。
因此很簡單我們可以得到下列算法:
COMPUTE‐TRANSITION‐FUNCTION(T, Σ) 01 m ← length[T]
02 for q ← 0 to m
03 do for each character a ∈ Σ 04 do if q = m or T[q+1] ≠ a
05 then δ(q, a) ← δ(π[q], a) 06 else
07 δ(q, a) ← q + 1 08 return δ
複雜度很明顯是Θ(m + m|Σ|),算很有效率了。
§4‐3 小結(Conclusion)
當然自動機的應用沒那麼少,像多模式字串匹配也能夠用得上,它可以應用的地方還多 著。大學還有專門位自動機開的一門計算理論 Theory of Computation的課程,不過那不 在我們今天討論的範圍,而且對競賽來說就也不是幫助那麼大了,有興趣者就自行參考相關 書籍囉。