• 沒有找到結果。

Chapter 8 字串演算法(String Algorithm)

N/A
N/A
Protected

Academic year: 2023

Share "Chapter 8 字串演算法(String Algorithm)"

Copied!
15
0
0

加載中.... (立即查看全文)

全文

(1)

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]最大的數且≤ n,m,則 

    1. 若= n且= 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) 

(2)

對於這類問題,我們能夠有一個很明顯很直觀的想法就是說,對於所有可能匹配到的地 方去匹配看看,簡單來說對於所有的k(k=[1,n‐m+1])檢查S[k…k+m‐1]=T,當然這種作 法複雜度是O(m(n‐m+1)),對於競賽這種很容易出現刁鑽字串比對的情形,很容易就會花 費很多時間,因此較不實用。但是對於字串字元出現情形非常隨機散亂時卻也不失為一種好 方法。 

NAIVESTRINGMATCHER(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 + 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時,幾乎會 是線性的!! 

RABINKARPMATCHER(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 + m ‐ 1 

12       then print "Pattern occurs with shift" s  13        if k < n – m + 1 

14       then tk+1 ← (( tk – S[ih )|Σ|S[k + m]) mod p 

  或許更進一步我們可以利用多個p值來降低key值相同的可能性,或許到p的個數夠多 時,碰撞機率非常低,甚至不需要檢查是否匹配成功,利用所有key值相等即可判斷匹配成 功。當然這樣做還是有某種程度的危險性就是。 

§3‐3 KMP算法(Knuth‐Morris‐Paratt algorithm) 

  即便RK在理想狀況時能夠是線性的,但是最差複雜度仍然不能盡如人意,因此下面就 要介紹一個有名的KMP算法,即使最差狀況預處理複雜度為Θ(m),主程序複雜度仍只有 Θ(n)。 

  我們可以觀察一下上面兩種算法,速度會慢有一個主要原因就是在於對於S之中的某些 地方,T對他去檢查次數太多次了,更白話的說法是:我們沒有將之前匹配的訊息「充分利

(3)

用」,我們沒有從匹配失敗的地方記取教訓,以致於某些地方不斷的與 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

(4)

 6       do k ← π[k

 7         if T[k + 1] = T[i

 8      then k ← k + 1   9         π[i] ← k 

10 return π 

有了π函數,與S匹配就易如反掌了! 

假設在某個時刻S[ik+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[ik+1..i]=T),如果要繼續進行匹配,之 後可以先對k做一次迭代(k=π[k])再繼續匹配下去。 

KMP‐MATCHER(ST  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) = Maxl | 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最大的值。白話的說,就是能使我們對後面字串了解 最多的位置): 

(5)

(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[ik+1..m],T),有這個我們才 能知道要比較直接從哪裡開始比較就好了。 

因此我們在又要引入一個函數 β函數,使β[i] = LCP(T[i..m],T),可以發現到 β的 形式與α極像,因此我們可以先假設β已知,利用β求出α。如果會從β推出α,那從β推 出β也不是難事。 

以剛才的第二個條件下又能有兩種狀況: 

1. k+α[k]‐1 > i+β[ik+1]‐1: 

因為可能 LCP 長度在已知訊息範圍內,所以很明顯的,α[i]只可能是 β[ik+1]。(因為如果 α[i]可能更大的話,β[ik+1]也一定會更大,所以 最大一定是β[ik+1]) 

2. k+α[k]‐1 ≤ i+β[ik+1]‐1: 

因為可能LCP長度超過目前已知訊息,但卻不能保證超過已知訊息後會與LCP 相符合,所以必須要從S[k+α[k]‐1]開始往後匹配,直到求出LCP。 

因此利用上述這樣遞推的過程就能推出α,而上述的β雖然沒有說明,但其實與α一模 一樣,因此就不再贅述。 

KMP‐MATCHER‐EXTEND(ST 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 + β[– + 1] ‐ 1 < k + α[k] – 1  ▹ Case 2‐1  11      then α[i] ← β[– + 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 

(6)

 

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 + β[– + 1] ‐ 1 < k + β[k] – 1  ▹ Case 2‐1  09      then β[i] ← β[– + 1] 

10       else 

11      j ← Max( 0 , k + β[k] ‐ i )  ▹ j = 0 ‐> Case 1  12      while T[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):全都是葉節點,且會儲存字串。 

(7)

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 

(8)

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 + 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 ←   

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 ∈ {Φ}∪Σ  17      do if  j ≠ S[i

18       then T ← p‐>child[j]‐>Data 

(9)

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主要是對於不同字串之間尋求一種相關的結構,

(10)

然而對於只有單一個字串時,字串與其子字串的相關性問題卻不能有些很好的做法,而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 <v  ⇔ u[1..k] < v[1..k]。 

  ◎ u =v  ⇔ u[1..k] = v[1..k]。 

  ◎ u >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) <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‐後綴數組有可能是會出現相等的情形的,所以其實排 列並不唯一,不過不影響我們繼續以下的操作。 

(11)

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) <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 = 2≥ 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 Ssuffix1[i‐1] ] ≠ Ssuffix1[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       rank2ksuffix2k[1] ] = 1; 

10       cnt ← 1 

11       for i ← 2 to n 

12         do if rankksuffix2k[i‐1] ] ≠ rankksuffix2k[i] ] or   13      rankksuffix2k[i‐1]+k ] ≠ rankksuffix2k[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]是不可能重複的。 

(12)

  又會受到改變順序的影響的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 nk+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]] ⇔

(13)

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},δ): 

(14)

其中δ是轉移函數,δ(0,a)=1, δ(0,b)=0, δ(1,a)=0, δ(1,b)=0。 

而我們還可以推導出一個函數δ*,其為一個Q×Σ*→ Q的函數,如果 S是ㄧ個字串,q0

是起始狀態,則δ*有下列定義。 

δ*(q0,0) = q(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  下面就是一個自動機的說明: 

 

(15)

  但問題來了我們要怎麼求出對應模式字串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 δ(qa) ← δ(π[q], a)  06       else 

07      δ(qa) ← + 1  08 return δ 

  複雜度很明顯是Θ(m + m|Σ|),算很有效率了。 

§4‐3 小結(Conclusion) 

當然自動機的應用沒那麼少,像多模式字串匹配也能夠用得上,它可以應用的地方還多 著。大學還有專門位自動機開的一門計算理論 Theory of Computation的課程,不過那不 在我們今天討論的範圍,而且對競賽來說就也不是幫助那麼大了,有興趣者就自行參考相關 書籍囉。

 

參考文獻

相關文件

結論與心得 ➢ 團隊合作:這堂課除了每週的團體進度外,也有個人進度,因此不能只 做完自己的事情就結束了,而是必須要與小組討論,必須要擬定大概的 方向才能做事情。因此時間管理與團隊合作是我覺得在這堂課上學到的 東西,如果沒有時間管理,不能有效的規範自己做好事情,如果沒有團 隊合作精神,有時候做的事情只是白費工夫。 ➢

二、非選擇:4 分 不論容貌好壞,帶給人的煩惱往往是一樣多的。 容貌美麗所帶來的煩惱,往往是容貌平平的人所體味不到的;容貌平平所帶來的煩惱,也是容貌美麗的人 所體味不到的。 美好的容貌,可能給你帶來幸運,卻不一定帶給你幸福。 人,大可不必為容貌平平而沮喪,如果你留意的話,就不難發現:在你周圍容貌不般配的戀人或夫妻,並