1 概述
單模式匹配是處理字符串的經典問題,指在給定字符串中尋找是否含有某一給定的字串。比較形象的是CPP中的strStr()
函數,Java的String類下的indexOf()
函數都實現了這個功能,本文討論幾種實現單模式匹配的方法,包括暴力匹配方法、KMP方法、以及Rabin-Karp方法(雖然Rabin-Karp方法在單模式匹配中性能一般,單其多模式匹配效率較高,且采取非直接比較的方法也值得借鑒)。
算法 | 預處理時間 | 匹配時間 |
---|---|---|
暴力匹配法 | O(mn) | |
KMP | O(m) | O(n) |
Rabin-Karp | O(m) | O(mn) |
2 暴力匹配
模式匹配類的問題做法都是類似使用一個匹配的滑動窗口,失配時改變移動匹配窗口,具體的暴力的做法是,兩個指針分別指向長串的開始、短串的開始,依次比較字符是否相等,當不相等時,指向短串的指針移動,當短串指針已經指向末尾時,完成匹配返回結果。
以leetcode28. 實現 strStr()為例給出實現代碼(下同)
class Solution {
public int strStr(String haystack, String needle) {
int m = haystack.length(), n = needle.length();
if (needle.length() == 0) return 0;
for (int i = 0; i <= m - n; i++) {
for (int j = 0; j < n; j++) {
if (haystack.charAt(i + j) != needle.charAt(j))
break;
if (j == n - 1)
return i;
}
}
return -1;
}
}
值得注意的是,Java中的indexO()
方法即采用了暴力匹配方法,盡管其算法復雜度比起下面要談到的KMP方法要高上許多。
一個可能的解釋是,日常使用此方法過程中串的長度都比較短,而KMP方法預處理要生成next數組浪費時間。而一般規模較大的字符串可以由開發人員自行決定使用哪種匹配方法。
3 KMP算法
這個算法由高德納和沃恩·普拉特在1974年構思,同年詹姆斯·H·莫里斯也獨立地設計出該算法,最終三人於1977年聯合發表。
大體想法是,在暴力匹配的前提下,每次失配時,不再從待匹配串開頭開始重新匹配,而是充分利用已經匹配到的部分,具體的就是使用一個部分匹配表(即在程序中經常講的next數組),利用這一特性以避免重新檢查先前匹配的字符。
比如對於待匹配串abcabce
當我匹配到末尾最后一個e字母時,發現失配,一般的做法是,對於長串指針往后移動一位,然后從待匹配串開始重新匹配,但事實上,我們發現對於待匹配串失配位置以前的字符串abcabc
來講,存在着一個長度為3的相同的字串abc
,我們可以把第一個叫做前綴,第二個叫做后綴,所以對於當在后綴下一個字符失配時,我們只需要回溯到前綴的下一個字符繼續匹配即可,對於此串即待匹配串移動到第四個字符(數組下標為3)開始匹配。
所以對於KMP算法,核心就是構建待匹配串的部分匹配表。其作用是當模式串第i個位置失配,我不必從模式串開始再重新匹配,而是移動到前i個字符的某個位置,具體這個位置是前i個字符的最長公共前后綴的長度。
依舊以abcabce
為例,假如匹配到第i = 5
也就是第六個字母(第二個c)時,失配,那么我只需要退回到i = 2
開始匹配即可,因為匹配到第六個字母時,我們已經確定abcab
匹配成功,很明顯發現abcab
中出現了兩次ab
且分別是前后綴,那么此時只需要從i = 2
接着匹配即可。所以計算部分匹配表本質上就是對模式串本身做了多次匹配,或者可以理解為模式串構建了一個失配的自動機。
所以對於abcabce
很容易計算出部分失配表,特別的i = 0
時令next[0] = -1
。
i |
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
模式串 | a | b | c | a | b | c | e |
next[i] | -1 | 0 | 0 | 0 | 1 | 2 | 3 |
給出算法Java實現
class Solution {
public int strStr(String haystack, String needle) {
int i = 0, j = 0;
int sLen = haystack.length();
int pLen = needle.length();
if (pLen == 0) {
return 0;
}
int[] next = getNext(needle);
while (i < sLen && j < pLen) {
if (j == -1 || haystack.charAt(i) == needle.charAt(j)) {
i++;
j++;
} else {
j = next[j];
}
}
return j == pLen ? (i - j) : -1;
}
public int[] getNext(String p) {
int pLen = p.length();
int[] next = new int[pLen];
int k = -1;
int j = 0;
next[0] = -1;
while (j < pLen -1) {
if (k == -1 || p.charAt(j) == p.charAt(k)) {
k++;
j++;
next[j] = k;
} else {
k = next[k];
}
}
return next;
}
}
4 Rabin-Karp算法
Rabin–Karp算法由 Richard M. Karp 和 Michael O. Rabin 在 1987 年發表,用來解決模式匹配問題,在多模式匹配中其效率很高,常見的應用就是論文查重。
Rabin–Karp算法采用了計算字符串hash值是否相等的方法來比較字符串是否相等,當然hash算法肯定會出現沖突的可能,所以對於計算出hash相等后還需用朴素方法對比是否字符串真的相等。
但是即使計算哈希,也需要每次都計算一個長度為模式串的哈希值,真正巧妙的地方在於,RK算法采取了滾動哈希的方法,我們假設需要匹配的字符只有26個小寫字母來展開討論。
我們采取常見的多項式哈希算法來計算,底數取一個經驗值31。(JDK對於String的hashCode()方法也是如此)
假設主串為abcdefg
,模式串為bcde
,首先計算模式串的hash值,基於上述假設的前提下,為了簡化,我們將字母進一步做一個映射轉換成整型(統一減去'a'),那么只需要計算[0,1,2,3]
的哈希值即可,得到
維護一個大小為模式串長度的滑動窗口,開始從主串開頭計算窗口內的hash值,比如最開始窗口內字符串為abcd
,此時有
然后此時發現h0與模式串哈希值並不相等,則將窗口往后移動一個單位,此時窗口內的字符串是bcde
,我們計算它的hash值
但此時顯而易見的是,\(h_1\)可以由\(h_0\)計算得來,具體的
所以此時我們能夠由前一個窗口的哈希值以O(1)的時間復雜度計算出下一個窗口的哈希值,以方便比較。
當然顯然字符串過長時會存儲hash值的變量會溢出,所以需要每次累加時進行一次取模運算,具體的可以選取一個大素數,素數的選擇可以參考這里。
下面給出java實現
class Solution {
public static int strStr(String haystack, String needle) {
int sLen = haystack.length(), pLen = needle.length();
if (pLen == 0) return 0;
if (sLen == 0) return -1;
int MOD = 997;
int power = 1;
for (int i = 0; i < pLen; i++) {
power = (power * 31) % MOD;
}
int hash = 0;
for (int i = 0; i < pLen; i++) {
hash = (hash * 31 + (needle.charAt(i) -'a')) % MOD;
}
int h = 0;
for (int i = 0; i < sLen; i++) {
h = (h * 31 + (haystack.charAt(i) - 'a')) % MOD;
if (i < pLen - 1) {
continue;
}
if (i >= pLen) {
h = (h - (haystack.charAt(i - pLen)-'a') * power) % MOD;
if (h < 0) {
h += MOD;
}
}
if (hash == h) {
int start = i - pLen + 1;
boolean equal = true;
for(int j = start, k = 0; j <= i; j++,k++) {
if (haystack.charAt(j) != needle.charAt(k))
equal = false;
}
if (equal) return start;
}
}
return -1;
}
}