• 沒有找到結果。

講義08 - 字串

N/A
N/A
Protected

Academic year: 2023

Share "講義08 - 字串"

Copied!
11
0
0

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

全文

(1)

講義 08 - 字串

momohuang 2012/12/03

1 字串

1.1 名詞解釋

觀看前人的編排,他們都先做了一些名詞的解釋,我也仿效一下:

1. 字元 (character):

孤單的一個符號。’7’,’1’, ’阿’, ’2’, ’ 局’, ’ 漬’

2. 字元集 (Alphabet):

由字元組成的集合,據說用 Σ 表示 3. 字串 (String):

由字元集中的字元構成的序列。”7122”

4. 子字串 (Substring):

字串中的一段連續字元。”71” in ”7122”

5. 子序列 (Subsequence):

字串中不需連續的一斷字元。”72” in ”7122”

6. 前綴 (Prefix):

一個子字串包含第一個字元。”7”, ”71”, ”712”, ”7122” in ”7122”

7. 後綴 (Suffix):

一個子字串包含最後一個字元。”2”, ”22”, ”122”, ”7122” in ”7122”

8. 字典序 (Alphabetical Order):

定義字串間的大小,先定義字元間的大小:’ ’ < ’a’ < ’b’ < ’c’ < ’d’ < …< ’z’

通常就是照著 ASCII碼,比較要注意的是空字元比其他都還要小 接下來從第一個位置一位一位比對,先比對方小的就是比較小的字串 9. 後綴數組 (Suffix Array):

將一個字串的所有後綴,照字典序排序後,所得的名次陣列。

Sa[i]: 第 i 名是第幾個後綴

(2)

10. 排名數組 (Rank Array):

為後綴數組的逆數組。

Ra[i]: 第i 個後綴是第幾名

11. 最長共同前綴 (Longest Common Prefix):

兩個字串,從第一位一位一位比對,直到不一樣就停止 EX: ’712221212’ 和’712222222’ 的最長共同前綴:’71222’

12. lcp(I, J):

對於一個字串,他的第 I 個後綴和第J 個後綴的 LCP有多長 13. LCP(I, J):

對於一個字串,他的第 I 名後綴與第J 名後綴的 LCP有多長 14. height[i]:

對於一個字串,LCP(i−1, i) 15. h[i]:

對於一個字串,LCP(Ra[i]1,Ra[i])

1.2 匹配問題 (String Matching)

字串的問題千奇百怪,其中最樸實(?!) 的就是匹配問題,因為作法的種類多的太誇張了,

所以我就挑幾個比較實用的作法來講解。

問題定義 對兩個字串AB,從 A 裡找出所有的Bn=len(A), m=len(B)

1.2.1 天真算法

萬物的根源,枚舉 A 中所有的起點,一位一位跟 B 比對。時間複雜度:O(nm)

...

String A

String B

...

Start

(3)

1.2.2 RK 算法 (Robin-Karp Algorithm)

Hanhan 的最愛,他是天真算法的改進,把 Am 個用 rolling hash 做一個 key 值。如 果跟 B 的 key值相同,才要一位一位比對:

H =s[0]∗Xm−1+s[1]∗Xm−2+· · ·+s[m−2]∗X1+s[m−1]∗X0

但是有些情況下,會發生跟天真算法一樣悲劇的情形,所以可以改成計算多個 Key 值,

然後就不再做一位一位的比對了,雖然有些許的危險性在。(所以人稱『RP 算法』) 時間複 雜度:O(n+m)

1.2.3 KMP 算法 (Knuth-Morris-Pratt Algorithm)

人總是不斷追求極致,於是發展出神奇的 KMP 算法。接下來讓大家對他有多一分的了 解吧!他的複雜度 O(n+m)且 100% 安全。

String A

A B A A A

String B

Start

A B A A A A B A A A

B

A B A A A ...

A B A A B A ... A B A A B A ...

這是一個充分利用已有資訊去做出判斷的好算法。如果依照天真算法,你就會做出如上 圖左邊的動作,但其實這是沒有必要的,因為你既然已經知道前四格都跟自己相同,你可 以『直接』把 String B shift 到一個『確定可以的地方』再比對其下一個字元,又不行則再 shift。也就是先預處理出『自己的『每個前綴』的『後綴與自己的前綴』最長是哪個前綴』

的一個神祕函數,稱為 π 函數(如下圖所示)

(4)

B A B A B B A B A B

B A B A B A B A

B A B B A B

B A B A

B B

假如你有了這個函數,你就可以做出如同上面那張圖右邊的異能動作,不斷的比對直到 你配對成功,成功後再一次做出位移的動作,然後繼續比對。你可以知道你要比對的次數就 只會有 n 次,但每次比對都會配上 B 字串位移的次數,乍聽之下,好像是 O(nm),但仔細 想想後,會發現 B 字串位移的總次數最多只會是 n 次,所以比對複雜度 O(n)。最後的問題 在於這個 π 函數是要如何求得,其實求他的方式就跟比對的方式幾乎一樣,增加出一個後,

不斷的位移,直到他的下一個跟自己的下一個相同為止,因為是相同的手法,複雜度 O(m)。

這就是有名的複雜度 O(n+m)的KMP算法,不清楚可以去Momo’s Weird Code搜尋KMP 有 code跟彩色圖解。

字典樹 (Trie) 就是用來保存多個字串的資料結構,放個圖相信大家就會了解了,

因為著實不難,只是一棵樹而已,不具巧妙之處。

用 Pointer 阿,array 阿都可以實踐,唯一缺點是記憶體用量大,因為他的記憶體用量為

輸入字串的總字元數量 ×26或 ×52 之類的,因為對於所有的節點都無可避免的開出無謂的 大小 26 陣列。一般來說他可以高效的性能判斷某個字串是否出現過,也可以藉由 LCA 算 法,快速的求出兩個字串的 LCP。

(5)

root

A B

P

Insert "APP"

Insert "BPP"

Insert "APA"

Insert "APC"

P C

A

P P

1.2.4 Aho-Corasick 算法

這時你碰到一個需要匹配多個字串的問題,就是給你一個字串A 和多個字串Bi,要你看 有沒有匹配之類的問題。先給出大致的算法構想。定義 n =len(A), m=∑

len(Bi)。

Trie

KMP BFS

算法步驟

1. 把它給你的多個字串,做成一棵可愛的字典樹,可以做 pointer 版或是 array 版,我比 較喜歡的是 array 版,因為我不太會指標,array 版的話就是把節點離散化 (?) 的感覺,

pointer 就是記錄記憶體位置那樣。

2. 接下來就是要做 KMP 的 π 函數了,也就是我們這裡稱的 failure function(應該說論文

(6)

裡稱的,簡稱 f 函數),其實概念就跟 KMP 一樣啦,只是有一個重點:那就是一定要 透過 BFS 進行,為什麼呢?不能用 DFS 嘛?原因是你求得的那個前綴的 f 函數亦必 須是已知的。各位還記得 KMP嘛?如果沒試成,那就得再前往更前面的 f 函數,而且 可以知道自己 f 函數的那一層一定比自己那一層淺,所以從 root 開始 BFS是萬無一失 的,時間複雜度 O(m)。

3. 與 failure function 同步進行的是backtrack function(之後簡稱 b 函數),其實他就是代表 你的後綴且『同樣是輸入進來的字串』的函數,因為並不是每一個節點都是輸進來的 字串的結尾,其實 b 函數就等於 (『若 f 函數是某字串結尾』? f 函數: f 函數的 b 函 數)。所以對於不是某字串的結尾也要做一個 b 函數,這樣編程時將較方便,時間複雜 度 O(m)。

4. 終於到了最後匹配的步驟了,其實就是 KMP,一模一樣,時間複雜度 O(n)。只差在當 你發現某個節點是某個字串的結尾時 (也就是匹配成功),你必須透過 b 函數遞迴一下,

因為那個字串匹配成功則他的所有後綴都會匹配成功,可是這樣就會在時間複雜度裡增 加了一個 O(k) 代表匹配成功的數量,這是很可怕的,因為如果匹配了太多次,電腦就 跑不動了。所以你可以 DP 一下,就是把匹配成功的節點做記錄,到全部都跑完再一次 整理。不過還是仍得端看題目,有時候他根本就不在乎有哪些人被匹配到,那麼 b 函數 就只是沒有用的渣渣了。

5. 接下來就等著 AC 吧!(或陷入無限debug 迴圈,code比上面的多 = =)

1.2.5 Gusfield Algorithm(Z algorithm)

各位是否覺得 KMP 太過於困難,太過於複雜,可以試試看簡單易懂的 Z算法,這是獻 給如我一般每次都記不住 KMP 到底在幹嘛的大家,也是字串匹配的最終章。(code 12 行) 算法用 回傳一個 Z 陣列,Z[x] 代表從此字串 x 開始的後綴與此字串的 LCP,O(n)。(把

字串做成 BA,用一下 Z算法就可以知道有沒有字串匹配了)

其實這個也是充分利用先前資訊的一個演算法,在字串演算法中,最重要的概念就是要

『充分利用』,想想看這題可以怎麼做吧!

Hint 1: 朝讓上面的比對指針只會前進不必後退之類思考,這樣才能 O(n)。

Hint 2: 說不定要記錄兩個指針!譬如說一個是比對到哪,一個是是哪個後綴比的

Hint 3: 如果一個位置 X 位於之前比過的那段 [L, R] 當中,他是否跟X−L 相同呢?

Hint 4: 接續 Hint 3,搞不好可以用討論的,就可以解決這個困難

可以知道 Z[0]是 n,所以從第一位開始比對。其實只要分成三種情形:

1. 要比的後綴根本不在以前比過的範圍內 就去比吧!

(7)

B A B A A

A B A

1 b c d e f

0

A B ...

2 3 a

A

4

B

5 Z[1] == Z[b]

Z[2] == Z[c] ? L R

void Z_maker( int z[], char s[], int n ){

z[0] = n;

int L = 0, R = 0, i, x;

for( i = 1 ; i < n ; i++ ){

if( R < i || z[i-L] >= R-i+1 ){

R < i ? x = i : x = R+1;

while( x < n && s[x] == s[x-i] ) x++;

z[i] = x-i; if( i < x ){ L = i; R = x-1; } }

else z[i] = z[i-L];

} }

字串匹配章,終。

(8)

2 SA 數組、LCP 定理

不知道到這個時候,還有沒有人記得前面的名詞介紹,不記得請翻一翻前面。這裡要介 紹一個可以求出SA 數組世界第二快的算法。(想知道最快的是什麼嘛?依據蝴蝶的說法:有 興趣自虐的人可以咕狗一下 DC3 算法)

2.1 Doubling Algorithm( 增算法)

SA[i] = 第 i 名是此字串的哪一個後綴,RA[i] = 第 i 個後綴是此字串的第幾名 (依據字 典序),大家想到的方法很可能都脫離不了O(n2lgn)之類的。像先把所有後綴都拿出來,然

後再做個 sort,因為有 n 個後綴所以 O(nlgn)?不,因為任兩個比較的平均時間 O(n),所

以仍是 O(n2lgn)。

雖然不知道發明這個算法的人是誰,不過他真的突破天際,想到了一個 O(nlgn) 的算 法。像前面所說的,字串題就是要充分利用所擁有的資訊,創造最大的利益 (?) 接下來就來 看看他想到的作法究竟是什麼:

前面提到的樸素作法,顯然沒有利用到他們都是同一個字串的這個性質,其實這是很棒 的性質,假如你知道每一個一個字的 SA 數組了,是否可以 O(n) 得到每兩個兩個字的 SA 數組呢?如果第一個字一樣就比第二個字,所以其實你可以用兩個一個字組成兩個字,很直 覺的性質吧(?!) 不過還記得Counting Sort 嘛?用剛剛的性質,你先對第二個字做CS,接著 再對第一個字做CS,就得到每兩個兩個字的 SA 數組了。透過這個步驟一直合併合併,只需 要 O(lgn)次就得到最終的 SA 數組了。

這時不知道有沒有人發現,最後一組兩個字並不具有兩個字,這並不是 bug,不過你要 記得先預處理掉那些不具有第二個字的字,並把他們都排到第一順位 (因為是依照字典序,

而且務必要記得co 這段),當然這也是為什麼到最後每一個字串都會是不同長度的原因。

至於想要看實際的 code,可以去看momo’s weird code 4 月 20日那篇。

2.2 Longest Common Prefix (LCP) 定理

為了要將 SA 數組發揮到極致,我們還要能在很快時間找出任兩個 Suffix 的 LCP。我覺 得這段非常有趣但也挺複雜的,要多多複習名詞,並請細細品味 XD

(P.S. 多加一個表示法:=x 代表兩個字串前 x 位相同)

2.2.1 LCP Lemma

LCP(i, j) = min(LCP(i, k),LCP(k, j)) (i≤j ≤k)

(9)

LCP Lemma LCP Theorem LCP Corollary h[i] >= h[i-1]-1

Pf: 反證法

1. 設 x=min(LCP(i, k),LCP(k, j)),則 i=x kk =x j, 所以 LCP(i, j)≥x 2. 設 LCP(i, j)> x, 則 i[x+ 1] =j[x+ 1]

i≤k ≤ji=x j =x k,故 i[x+ 1]≤k[x+ 1]≤j[x+ 1]

因此 i[x+ 1] =k[x+ 1] =j[x+ 1],那怎麼 LCP 才x, 矛盾!

3. 由 1. 和2. 可知 LCP(i, j) = x=min(LCP(i, k),LCP(k, j))

2.2.2 LCP Theorem

LCP(i, k)LCP(j, k) (i+ 1≤k ≤j)

Pf: 數學歸納法

1. j −i= 0 和 j−i= 1 皆成立

2. 若 j−i=m 成立,則 LCP(i, j) = min(LCP(k−1, k)) (i+ 1≤k ≤j) 由 LCP Lemma,

LCP(i−1, j) =min(LCP(i−1, i),LCP(i, j))

LCP(i−1, j) =min(LCP(k−1, k)) (i≤k ≤j)

⇒j −i=m+ 1 成立

而 height 數組可以透過 h 數組求得,這也是我們為什麼要弄出一個 h 數組去折磨自己,

全部都是為了可以用 RMQ ( 預處理 O(nlgn) / 詢問O(1) )求出任兩個後綴的 LCP阿!

2.2.3 LCP Corollary

LCP(i, k)LCP(j, k) (i≤j ≤k)

Pf: 遞推

LCP(i, k) =min(LCP(i, j),LCP(j, k))LCP(j, k)

(10)

2.2.4 h 數組的性質

h[i]h[i−1]1

h[i-1] <= 1 h[i] >= 0 >= h[i-1]-1

QED

h[i] >= h[i-1]-1

h[i-1] > 1 ra[i-1] > ra[i-1]-1

lcp(i, j) > 1 ra[i] < ra[j], ra[i+1] < ra[j+1]

lcp(i+1, j+1)

= lcp(i, j)-1

ra[i-1] > ra[sa[ra[i-1]-1]]

ra[i] > ra[sa[ra[i-1]-1]+1]

ra[i]-1 >= ra[sa[ra[i-1]-1]+1]

LCP(ra[i-1], ra[i])

>=

LCP(ra[sa[ra[i-1]-1]+1], ra[i]) LCP Corollary

lcp(sa[ra[i]-1], i)

>=

lcp(sa[ra[i-1]-1]+1, i) lcp(sa[ra[i]-1], i)

>=

lcp(sa[ra[i-1]-1], i-1)-1

LCP(ra[i]-1, ra[i])

>=

LCP(ra[i-1]-1, ra[i-1])-1 QED

(?!)

因為你只需要從 h[i−1]1 開始比對,小小的估一下複雜度,便會發現他是線性的,之 後再透過 height[i] =h[SA[i]],我們便可以以 O(n) 的時間求出height 數組!

(11)

任兩個後綴的LCP 了!

總體複雜度O(nlgn),但是常數非常大就是了。

2.3 後記

如果有興趣的話,可以參考 2004 年許智磊寫的後綴數組,我是從那裡學會的,不過我寫 的份量跟他差不多。他後面有附上兩個例題的題解,但是第一個用前面教的Aho-Corasick 算 法更快,而第二題則可以參考參考。

最後再套許智磊說的話作為完結:『算法和數據結構都是死的,而運用他們的人才是真正 的主角』。XD

參考文獻

相關文件

6 去的,那不知道,其實我真的不知道以現在臺灣的疫情來講,會不會到最後政 府有可以先放行啦…等等這一些的狀況,我不曉得,也就是目前暫時還是定在 六月中,沒有取消,這個完全沒有取消,可是萬一在 7/14 以前,我們講說因 為疫情已經不能夠再動這個部份的時侯,那麼我們可能會挪到寒假辦理,請大 家相信挪到寒假辦理那也是很難去湊到這樣子的時間,你真的不能夠一直講說

}; 或是假如你知道了這棵樹是完全二元樹(complete binary tree),或是它的深度不深,沒有很歪斜, 那麼可以較簡單的用陣列來儲存一棵樹。令A[1]為根節點,對於一個節點A[n],它的子節點是 A[2n]和A[2n+1],母節點是A[n/2]。 用同樣的方法,假如我們知道了一個樹是k元樹,也可以用一個指標陣列來指向它的所有子節點。 Struct