單模式匹配是處理字符串的經典問題,指在給定字符串中尋找是否含有某一給定的字串。比較形象的是CPP中的strStr()
函數,Java的String類下的indexOf()
函數都實現了這個功能,本文討論幾種實現單模式匹配的方法,包括暴力匹配方法、KMP方法、以及Rabin-Karp方法(雖然Rabin-Karp方法在單模式匹配中性能通常,單其多模式匹配效率較高,且採起非直接比較的方法也值得借鑑)。java
算法 | 預處理時間 | 匹配時間 |
---|---|---|
暴力匹配法 | O(mn) | |
KMP | O(m) | O(n) |
Rabin-Karp | O(m) | O(mn) |
模式匹配類的問題作法都是相似使用一個匹配的滑動窗口,失配時改變移動匹配窗口,具體的暴力的作法是,兩個指針分別指向長串的開始、短串的開始,依次比較字符是否相等,當不相等時,指向短串的指針移動,當短串指針已經指向末尾時,完成匹配返回結果。算法
以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方法要高上許多。ide
一個可能的解釋是,平常使用此方法過程當中串的長度都比較短,而KMP方法預處理要生成next數組浪費時間。而通常規模較大的字符串能夠由開發人員自行決定使用哪一種匹配方法。函數
這個算法由高德納和沃恩·普拉特在1974年構思,同年詹姆斯·H·莫里斯也獨立地設計出該算法,最終三人於1977年聯合發表。性能
大致想法是,在暴力匹配的前提下,每次失配時,再也不從待匹配串開頭開始從新匹配,而是充分利用已經匹配到的部分,具體的就是使用一個部分匹配表(即在程序中常常講的next數組),利用這一特性以免從新檢查先前匹配的字符。設計
好比對於待匹配串abcabce
當我匹配到末尾最後一個e字母時,發現失配,通常的作法是,對於長串指針日後移動一位,而後從待匹配串開始從新匹配,但事實上,咱們發現對於待匹配串失配位置之前的字符串abcabc
來說,存在着一個長度爲3的相同的字串abc
,咱們能夠把第一個叫作前綴,第二個叫作後綴,因此對於當在後綴下一個字符失配時,咱們只須要回溯到前綴的下一個字符繼續匹配便可,對於此串即待匹配串移動到第四個字符(數組下標爲3)開始匹配。指針
因此對於KMP算法,核心就是構建待匹配串的部分匹配表。其做用是當模式串第i個位置失配,我沒必要從模式串開始再從新匹配,而是移動到前i個字符的某個位置,具體這個位置是前i個字符的最長公共先後綴的長度。code
依舊以abcabce
爲例,假如匹配到第i = 5
也就是第六個字母(第二個c)時,失配,那麼我只須要退回到i = 2
開始匹配便可,由於匹配到第六個字母時,咱們已經肯定abcab
匹配成功,很明顯發現abcab
中出現了兩次ab
且分別是先後綴,那麼此時只須要從i = 2
接着匹配便可。因此計算部分匹配表本質上就是對模式串自己作了屢次匹配,或者能夠理解爲模式串構建了一個失配的自動機。blog
因此對於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; } }
Rabin–Karp算法由 Richard M. Karp 和 Michael O. Rabin 在 1987 年發表,用來解決模式匹配問題,在多模式匹配中其效率很高,常見的應用就是論文查重。
Rabin–Karp算法採用了計算字符串hash值是否相等的方法來比較字符串是否相等,固然hash算法確定會出現衝突的可能,因此對於計算出hash相等後還需用樸素方法對比是否字符串真的相等。
可是即便計算哈希,也須要每次都計算一個長度爲模式串的哈希值,真正巧妙的地方在於,RK算法採起了滾動哈希的方法,咱們假設須要匹配的字符只有26個小寫字母來展開討論。
咱們採起常見的多項式哈希算法來計算
假設主串爲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; } }