【資料結構與演算法】字串匹配
文章目錄
20201023 更新:
之前發新的部落格把原來的覆蓋掉了,重新發一遍,把連結部分補上去了。
字串匹配
text:需要檢索子串的長字串
pattern:作為被檢索物件的短字串
應用情景:
-
檢索操作:特別是text很長,pattern很短的情況。
-
網際網路上的資訊監視
-
爬蟲時的資訊檢索
0 Brute-force substring search (naïve)
讓pattern和text的每個位置進行比對,直到某一次比對完全對應。可以用一個二重迴圈完成。或者讓i直接減j(i在這裡是回溯的,但在KSP當中不是),然後j清零即可,避免了二重迴圈。
時間複雜度:
O
(
m
n
)
O(mn)
O(mn)。最壞情況例如:aaaaaab
和aab
。
問題:
- linear-time algorithm?
- avoid backup(本地的備份)?
1 KMP演算法
DFA版本
如果當前的匹配失敗了,並且pattern已經匹配上 n + 1 n+1 n+1個字元,我們考慮藉助pattern的特徵,減少匹配的次數。
對於KMP演算法,我們考慮其DFA,其中state的編號是當前匹配的pattern的長度。我們可以用DFA的矩陣來描述這個pattern,通過一定的程式求解,用求得的DFA來檢查text即可。
求解DFA的過程:
- 如果在
state[j]
(前面有j個字元匹配),並且當前字元恰好為p[j]
,我們前往state[j+1]
- 否則,我們給已經求解的DFA輸入
p
1
⋯
p
j
p_1 \cdots p_j
p1⋯pj,少輸入一個開頭的
p
0
p_0
p0。這個序列最多跳轉到當前節點的前一個,正好可以放入我們已經求好的DFA,就可以遞迴地求出在
state[j]
根據 p j p_j pj跳轉的方式。
虛擬碼:
string pat;
int M = pat.length();
int dfa[R][M]; // pattern長度為M,字符集大小為R
dfa[0][0] = 1;
for (int X = 0, j = 1; j < M; j++) { // X表示輸入p1...pX到當前DFA
for (int c = 0; c < R; c++)
dfa[c][j] = dfa[c][X]; // 所有不匹配的情況都可以複製
dfa[pat[j]][j] = j + 1; // 匹配的情況轉移到下一個結點
X = dfa[pat[j]][X]; // 根據當前DFA更新X
} // 雖然內部有個迴圈,但R是給定常數!
預處理時間和空間複雜度: O ( R M ) O(RM) O(RM)
用DFA處理,如果R很大,比如Unicode,其實也不合適。不過在檢索過程中複雜度是線性的,就還行。
特徵向量版本
改進版本是用一維陣列處理,就是課上講的。
如果有n位匹配,而第n+1位不匹配,我們有:
s
m
⋯
s
m
+
n
=
p
0
⋯
p
n
−
1
s_m \cdots s_{m+n} = p_0 \cdots p_{n-1}
sm⋯sm+n=p0⋯pn−1。這時候我們考慮最小的
j
j
j,使得
p
0
⋯
p
n
−
1
−
j
=
p
j
⋯
p
n
−
1
p_0 \cdots p_{n-1-j} = p_j \cdots p_{n-1}
p0⋯pn−1−j=pj⋯pn−1,也就是找到
p
0
⋯
p
n
−
1
p_0 \cdots p_{n-1}
p0⋯pn−1的一個最長的相同prefix和suffix。為什麼呢?我考慮左手和右手都拿著一個pattern字串,它倆一開始是上下對齊的,這時候我左手不動,右手往右移動,此時兩個字串重合的地方就是相同長度的prefix和suffix。直到重合的部分相等,這時候我就停下來。在停下來之前,重合的部分都不相等,也就是說,當前這段prefix和
s
m
⋯
s
m
+
n
s_m \cdots s_{m+n}
sm⋯sm+n的等長suffix是不相等的,我沒有必要做這個檢驗!我只需要等到停下來之後,prefix和suffix相等了,這時候我再開始做檢驗。這樣,i
根本不會回退,雖然j
有時回退,但是j
和i
總是同時++,因此檢驗的時間只取決於text的長度,是線性的。
注意,最長相等字首字尾必須是真子串!否則會原地不動。
全體j = N[n]
構成了pattern字串的特徵向量N
。特別地,令N[0]=-1
,它的優點會在程式碼中看到。
整理成程式碼長這樣:
int KMPStringMatching(string text, string pattern)
int n = text.length();
int m = pattern.length();
int next[m]; // pattern的特徵向量,假設已知
int i = 0, j = 0; // 兩個“指標”
while (i < n && j < m) {
if (j == -1 || text[i] == pattern[j]) { // 匹配的情況,或者j=-1時跳過這個i
i++;
j++;
}
else { // 失配的情況
j = next[j]; // 讓pattern向右移動,表現為指標即這種跳轉
}
}
if (j == m)
return i - j; // 找到一開始的匹配位置
else
return -1; // 失配
}
求特徵向量的思想是動態規劃:
- 對於一個匹配的最長字首字尾
p
0
⋯
p
k
−
1
p_0 \cdots p_{k-1}
p0⋯pk−1和
p
j
−
k
⋯
p
j
−
1
p_{j-k} \cdots p_{j-1}
pj−k⋯pj−1,也就是
next[j] == k
,如果 p k = p j p_k = p_j pk=pj,那麼next[j+1] = k+1
。 - 否則,如果
p
k
≠
p
j
p_k \neq p_j
pk=pj,令
k = next[j]
,則 p 0 ⋯ p k − 1 p_0 \cdots p_{k-1} p0⋯pk−1和 p j − k ⋯ p j − 1 p_{j-k} \cdots p_{j-1} pj−k⋯pj−1仍然相等,這時候繼續討論,直到最小的k都不行,那麼next[j] = 0
,表示這樣的k不存在。
int* findNext(string P) {
int j, k;
int m = P.length();
assert (m > 0);
int *next = new int[m];
assert (next != 0);
next[0] = -1; // 注意賦初值!這個時候就是相容的了
j = 0; k = -1;
while (j < m - 1) {
while (k >= 0 && P[k] != P[j])
k = next[k];
j++;
k++;
next[j] = k; // next[j+1] = k+1
}
return next;
}
在KMP匹配過程中,i
永遠不會回退,但是j
會根據N[j]
回退。
KMP演算法的效率分析:
預處理是 O ( m ) O(m) O(m)的,匹配時間效率是 O ( n ) O(n) O(n)的。
j = N[j]
最多執行N次:j增加只有j++,而j = N[j]
至少讓j減1。如果執行超過N次,那麼j會得到負數,這肯定不可能。同樣,求特徵向量同理。
特徵向量有兩種求法:一個是求N[j]
時不包含p[j]
,也就是上面的那種方法;一個是包含的。這個不多講了,比較繁瑣
KMP演算法優化
在原來的KMP演算法中,當
t
i
≠
p
j
t_i \neq p_j
ti=pj,我們就讓j = next[j]
,使得pattern串向右移動。令k = next[j]
,那麼此時和
t
i
t_i
ti對應的是
p
k
p_k
pk。如果本來
p
j
=
p
k
p_j = p_k
pj=pk,那麼
t
i
=
p
k
t_i = p_k
ti=pk,這一步判斷是冗餘的。因此在求next
的時候,我們加一行:
while (j < m - 1) {
while (k >= 0 && P[k] != P[j])
k = next[k];
j++;
k++;
if (P[j] == P[k]) // 加入的行,也就壓榨了一點效能
next[j] = next[k];
else // 無論條件是否成立,不影響下一步的k的值
next[j] = k;
}
事實上這就保證了優化之後對於任何j
,都有p[j] != p[next[j]]
。用歸納法就可以了。設優化之前,k = next[j] < j, l = next[k] < k
,p[l] != p[k] == p[j]
。如果k
已經是0了,那next[j] = l = -1
,上面那個表示式都會報錯……
靈活的字串運算
求最長重複字串的下標位置,暴力列舉是 O ( n 4 ) O(n^4) O(n4)。KMP可以在這裡優化嗎?
遍歷所有的子串s[i]~s[n]
,求每一個子串的特徵向量,然後最好的值是所有特徵向量在這一位的值的最大值。這個是
O
(
n
2
)
O(n^2)
O(n2)
2 Boyer-Moore演算法
【參考博文】
http://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html
https://blog.csdn.net/sealyao/article/details/4568167
與KMP演算法不同,BM演算法中j
是反著走的。i
不保證一直往右走,每次pattern右移後,必須從右向左檢查pattern和text的對齊情況。BM演算法不走回頭路是指:pattern始終在往右移動,而不是text的i
始終往右移動。
壞字元規則:
對於失配的s[i]
,我們稱其為壞字元。
- 如果該字元不存在於p,則pattern右移m位。
- 否則,
s[i]
與p中從後往前最近的一個s[i]
對齊匹配(也就是s[i]
在P
中最後一次出現的座標;如果沒出現就是-1,也就是上面的情況)。
這個演算法效率的分析其實不太穩定,取決於pattern向右跳多少。最好的情況下,每次都跳pattern的長度,是 O ( n / m ) O(n/m) O(n/m)的;最壞的情況下,每次跳一步,每跳一步都要遍歷整個pattern,是 O ( m n ) O(mn) O(mn)的。
Skip的規則可以預處理,得到一個table。只需要遍歷一遍p即可,時間複雜度 O ( m ) O(m) O(m)。
好字尾規則:
我們稱檢查過程中能夠與text匹配的pattern字尾為好字尾。好字尾的位置以最後一個為準。如果好字尾在pattern中只出現一次,那麼上一次出現的位置為-1
失配時,pattern後移至上一個好字尾在pattern中出現的位置。
- 如果最長的好字尾在pattern中存在,則將pattern向右移動到好字尾的位置。
- 否則,則從好字尾中找到pattern的最長相等字首字尾。(如果這樣的字首字尾存在,那麼直接將pattern右移m位是不安全的,可能漏解)
兩個規則放在一起,取最大的跳。
我們來簡單地分析一下這個演算法。當遇到壞字元的時候,我們根據壞字元規則右移到上一個壞字元與之匹配,因為僅憑一個字元我們就可以斷言:任何更短步長的位移都會失配。而這兩個字元匹配上之後,我們還是要從後往前重新檢查,因此BM演算法只是單純地減少了不必要的操作。當遇到好字尾的時候,如果上一個好字尾在pattern串內部是比較好討論的,和壞字元類似;如果不在,採用了移動最長相等字首字尾的情形,仍然是安全的。
補充: Sunday演算法
【參考部落格】
https://blog.csdn.net/q547550831/article/details/51860017
和BM演算法類似,Sunday演算法考慮text和pattern對齊之後,text參加匹配的最後一個字元的後面一個字元。把它作為壞字元進行移位。不同的地方在於,Sunday演算法從前往後遍歷pattern串。
求偏移表的方式和BM演算法一樣。
3 shift-or algorithm
【參考部落格】
https://blog.csdn.net/weixin_30443813/article/details/99766066
演算法的原理很簡單:維持這樣一個字串集合 S S S,集合中的元素是text的字尾,又是pattern的字首。我們讓pattern不斷右移,更新 S S S,直到pattern ∈ S \in S ∈S。
集合可以通過bitset表示:
bitset D[m]
, D[j] = 1
當且僅當
p
0
⋯
p
j
=
t
j
−
i
⋯
t
i
p_0 \cdots p_j = t_{j-i} \cdots t_i
p0⋯pj=tj−i⋯ti
初始條件:d[0] = (S[m-1] == T[0])
終止條件:d[m-1] == 1
更新操作:d[j] = 1
,當且僅當d[j-1] == 1 && s[i]==p[j]
s[i]==p[j]
可以提前求出來,因為它只依賴於pattern中的字元。對於pattern中每一個字元c
,都對應一個特徵bitset B[m]
,其中B[i] == (c == p[i])
。因此,更新操作可以寫作D = (D << 1) | 1 & B
,其中B
是s[i]
的特徵bitset。或一個1,保證最低位不恆為0。
上面這種寫法是shift-and,shift-or就是把所有的0和1互換,與和或互換。這樣更新操作不用或那個1,因為右移出來的總是0;但是,需要記得提前把D
初始化為1。
4 Karp-Rabin演算法
【參考部落格】
https://blog.csdn.net/Shine__Wong/article/details/102095474
假如有很多模式,和一個需要匹配的串。用一個window來檢索需要匹配的串,對它做hash。因為hash是對輸入敏感的,如果window裡面的東西的hash值和某個pattern的hash值一樣,那我們就匹配上了。這個更方便。
計算hash:設字符集大小為R,那麼每個長度為m的字串都可以看做一個R進位制數(在判斷過程中不考慮終止字元),它有一個多項式表示。由於R是有限大小的,它有唯一確定的指紋fingerprint。
問題在於:
- 求指紋的時間是 O ( m ) O(m) O(m)的
- R很大時不好表示
解決方案:
- 相鄰兩個字串的hash值是高度關聯的,體現在多項式表示中。因此,
hash(i+1) = (hash(i) * m + p[i+1]
, - 對上述結果再mod
M
,其中M
是個常數,它充分大使得發生衝突的概率很小,但又保證每個串的指紋是唯一的。解決了儲存空間的問題。
5 有窮自動機
它可以定義一套字串的pattern。
用矩陣表示,矩陣中每個元素表示:結點state遇到字元時轉移到哪個結點。用圖表示,是一個有窮個結點的單向圖,每個結點都有編號。從同一個節點出發的邊都有字元表中的字元作為編號,且字元相同時邊相同。
如果一個字串符合有窮自動機deterministic finite state automation (DFA),那麼這個字串匹配當前模式。
在網課當中講到了用DFA定義的KMP演算法,可以參考普林斯頓的演算法網課。
相關文章
- 【資料結構與演算法】字串匹配(字尾陣列)資料結構演算法字串匹配陣列
- 資料結構與演算法——字串資料結構演算法字串
- 【資料結構與演算法】字串匹配(Rabin-Karp 演算法和KMP 演算法)資料結構演算法字串匹配KMP
- 重學資料結構和演算法(三)之遞迴、二分、字串匹配資料結構演算法遞迴字串匹配
- 資料結構與演算法-資料結構(棧)資料結構演算法
- 資料結構 - 字串資料結構字串
- [資料結構拾遺]字串排序演算法總結資料結構字串排序演算法
- 資料結構與演算法資料結構演算法
- 資料結構:初識(資料結構、演算法與演算法分析)資料結構演算法
- 資料結構與演算法:圖形結構資料結構演算法
- python演算法與資料結構-什麼是資料結構Python演算法資料結構
- (python)資料結構—字串Python資料結構字串
- 資料結構與演算法02資料結構演算法
- 資料結構與演算法-堆資料結構演算法
- 資料結構與演算法03資料結構演算法
- 【JavaScript 演算法與資料結構】JavaScript演算法資料結構
- 資料結構與演算法(java)資料結構演算法Java
- python資料結構與演算法Python資料結構演算法
- 資料結構與演算法——排序資料結構演算法排序
- 演算法與資料結構——序演算法資料結構
- 資料結構與演算法——概述資料結構演算法
- 【資料結構與演算法】bitmap資料結構演算法
- 資料結構與演算法 - 串資料結構演算法
- 資料結構與演算法(1)資料結構演算法
- python演算法與資料結構-演算法和資料結構介紹(31)Python演算法資料結構
- 資料結構與演算法之線性結構資料結構演算法
- 資料結構與演算法-連結串列資料結構演算法
- 資料結構和演算法面試題系列—字串資料結構演算法面試題字串
- [資料結構與演算法] 排序演算法資料結構演算法排序
- javascript資料結構與演算法-棧JavaScript資料結構演算法
- 資料結構與演算法之美資料結構演算法
- 演算法與資料結構1800題演算法資料結構
- JavaScript資料結構與演算法(串)JavaScript資料結構演算法
- [資料結構與演算法] 邂逅棧資料結構演算法
- 資料結構與演算法分析——棧資料結構演算法
- 資料結構與演算法(八):排序資料結構演算法排序
- 演算法與資料結構之集合演算法資料結構
- 資料結構與演算法:遞迴資料結構演算法遞迴