【資料結構與演算法】字串匹配(Rabin-Karp 演算法和KMP 演算法)
Rabin-Karp 演算法
概念
用於在 一個字串 中查詢 另外一個字串 出現的位置。
與暴力法不同,基本原理就是比較字串的 雜湊碼 ( HashCode ) , 快速的確定子字串是否等於被查詢的字串
比較雜湊值採用的是滾動雜湊法
- 如何計算雜湊值:
如 : “abcde” 的雜湊碼值為
-
滾動雜湊法:
\[a×31^2+b×31^1+c×31^0 \]
母串是"abcde",子串是"cde"
則母串先計算"abc"的雜湊值:而子串"cde"的雜湊值是:
\[c×31^2+d×31^1+e×31^0 \]與母串雜湊值不匹配,於是母串向後繼續計算雜湊值,下標i=3指向字母d,
前n個字元的hash * 31-前n字元的第一字元 * 31的n次方(n是子串長度)
可以計算出母串中"bcd"的雜湊值,再與子串雜湊值進行比較
程式碼實現
public static void main(String[] args) { String s = "ABABABA"; String p = "ABA"; match(p, s); } //p是母串,s是子串 private static void match(String p, String s) { long hash_p = hash(p);//p的hash值 long[] hashOfS = hash(s, p.length()); match(hash_p, hashOfS); } private static void match(long hash_p, long[] hash_s) { for (int i = 0; i < hash_s.length; i++) { if (hash_s[i] == hash_p) { System.out.println(i); } } } final static long seed = 31; /** * n是子串的長度 * 用滾動方法求出s中長度為n的每個子串的hash,組成一個hash陣列 */ static long[] hash(final String s, final int n) { long[] res = new long[s.length() - n + 1]; //前m個字元的hash res[0] = hash(s.substring(0, n)); for (int i = n; i < s.length(); i++) { char newChar = s.charAt(i); char ochar = s.charAt(i - n); //前n個字元的hash*seed-前n字元的第一字元*seed的n次方 long v = (res[i - n] * seed + newChar - pow(seed, n) * ochar) % Long.MAX_VALUE; //防止溢位 res[i - n + 1] = v; } return res; } static long pow(long a,int b){ long ans = 1; while(b>0){ ans*=a; b--; } return ans; } /** * 使用100000個不同字串產生的衝突數,大概在0~3波動,使用100百萬不同的字串,衝突數大概110+範圍波動。 * 如果資料量非常大,可以在子串和母串雜湊值匹配成功的時候多進行一步樸素的字串比較,以防萬一。 */ static long hash(String str) { long h = 0; for (int i = 0; i != str.length(); ++i) { h = seed * h + str.charAt(i); } return h % Long.MAX_VALUE; }
時間複雜度分析
設母串長度為m,子串長度為n。
則滾動計算母串雜湊值複雜度是O(m)
計運算元串雜湊值複雜度是O(n)
遍歷母串進行雜湊值匹配的複雜度是O(m)
綜上,Rabin-Karp演算法的時間複雜度是O(m+n)
KMP 演算法
概念
KMP演算法是一種改進的字串匹配演算法,由D.E.Knuth,J.H.Morris和V.R.Pratt同時發現,因此人們稱它為克努特——莫里斯——普拉特操作(簡稱KMP演算法)。
主要用於在文字串S中查詢模式串P出現的位置。
-
KMP和暴力匹配的不同
-
如何求解next陣列
程式碼實現
public static void main(String[] args) { String src = "babababcbabababb"; String p = "bababb"; int index = kmp(src, p); System.out.println(index); } //s是文字串,p是模式串 private static int kmp(String s, String p) { if (s.length() == 0 || p.length() == 0) return -1; if (p.length() > s.length()) return -1; int[] next = next(p); int i = 0; //文字串的下標 int j = 0; //模式串的下標 int slength = s.length(); int plength = p.length(); while (i < slength) { //①如果j = -1,或者當前字元匹配成功(即S[i] == P[j]),都令i++,j++ //j=-1,因為next[0]=-1,說明p的第一位和i這個位置無法匹配,這時i,j都增加1,i移位,j從0開始 if (j == -1 || s.charAt(i) == p.charAt(j)) { i++; j++; } else { //②如果j != -1,且當前字元匹配失敗(即S[i] != P[j]),則令 i 不變,j = next[j]回退 //next[j]即為j所對應的next值 j = next[j]; } if (j == plength) { //匹配成功了 return i - j; } } return -1; } private static int[] next(String p) { int[] next = new int[p.length() + 1]; int left = -1; int right = 0; next[0] = -1; while (right < p.length()) { if (left == -1 || p.charAt(left) == p.charAt(right)) { next[++right] = ++left; //最長匹配位置加一 } else { left = next[left]; //字首回退到上一個最長匹配位置 } } return next; }
KMP演算法改進(nextval陣列)
可以把next陣列改造成nextval陣列
下標(j) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
模式串(P) | a | b | c | d | a | b | d |
next | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
nextval | -1 | 0 | 0 | 0 | -1 | 0 | 2 |
當 j 處模式串字元不等於next[j]處模式串字元時,nextval[j]=next[j]
當 j 處模式串字元等於next[j]處模式串字元時,nextval[j]=nextval[next[j]]
比如:
下標為j=4處的模式串字元是a,而下標為next[j]處的模式串字元也是a,則nextval[4]
拷貝nextval[next[4]]
處的值,也就是-1
解釋一下,按照next陣列回退的話,下標為4處next[4]=0
,會回退到下標為0處,而下標為0處next[0]
=-1,會回退到下標為-1處,回退了兩次。
但是如果應用改進的nextval陣列,下標為4處next[4]=-1
,直接回退到下標為-1處,只需要回退一次。
當遇到有大量連續重複元素的陣列時,效能提升最為明顯。
比如:
當 j=3 時,通過next陣列回退需要先退到下標為2,再退到下標為1,在退到下標為0,最後退到下標為-1。
而通過nextval陣列回退,一次就可以回退到下標為-1處。
//求nextval陣列
private static int[] nextval(String p, int[] nextval) {
int right = 0, left = -1; //left是字首,right是字尾
nextval[0] = -1;
while (right < p.length()) {
if (left == -1 || p.charAt(right) == p.charAt(left)) {
left++;
right++; //多加了一次判斷比較 nextval[right] 和 nextval[left]
if (nextval[right] != nextval[left]) {
nextval[right] = left;
} else {
nextval[right] = nextval[left]; //注意
}
} else {
left = nextval[left]; //回退
}
}
return nextval;
}