講義 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 名是第幾個後綴
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)
字串的問題千奇百怪,其中最樸實(?!) 的就是匹配問題,因為作法的種類多的太誇張了,
所以我就挑幾個比較實用的作法來講解。
問題定義 對兩個字串A 和 B,從 A 裡找出所有的B。n=len(A), m=len(B)
1.2.1 天真算法
萬物的根源,枚舉 A 中所有的起點,一位一位跟 B 比對。時間複雜度:O(nm)
...
String A
String B
...
Start
1.2.2 RK 算法 (Robin-Karp Algorithm)
Hanhan 的最愛,他是天真算法的改進,把 A 每 m 個用 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。也就是先預處理出『自己的『每個前綴』的『後綴與自己的前綴』最長是哪個前綴』
的一個神祕函數,稱為 π 函數(如下圖所示)
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。
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(應該說論文
裡稱的,簡稱 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. 要比的後綴根本不在以前比過的範圍內 → 就去比吧!
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];
} }
字串匹配章,終。
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)
LCP Lemma LCP Theorem LCP Corollary h[i] >= h[i-1]-1
Pf: 反證法
1. 設 x=min(LCP(i, k),LCP(k, j)),則 i=x k 且 k =x j, 所以 LCP(i, j)≥x 2. 設 LCP(i, j)> x, 則 i[x+ 1] =j[x+ 1]
因 i≤k ≤j 且 i=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)
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 數組!
任兩個後綴的LCP 了!
總體複雜度O(nlgn),但是常數非常大就是了。
2.3 後記
如果有興趣的話,可以參考 2004 年許智磊寫的後綴數組,我是從那裡學會的,不過我寫 的份量跟他差不多。他後面有附上兩個例題的題解,但是第一個用前面教的Aho-Corasick 算 法更快,而第二題則可以參考參考。
最後再套許智磊說的話作為完結:『算法和數據結構都是死的,而運用他們的人才是真正 的主角』。XD