引入
在知乎看到了一篇名为Robin-Karp的算法,说是比KMP算法更加容易理解,并且也更好实现。我们都知道KMP算法在计算子串的那个数组的时候,理解起来和实现起来感觉上是两个不同的算法,十分的绕,很久不看就忘了。
这个算法则更加简单,直接按照原理,就可以写出算法来,其实细说起来,它就是根据暴力算法来改良的,我们知道,暴力算法的复杂度为O(MN),每次移动一个窗口,就要重新依次比较pattern和text的每一位,假设patter长为M,text长为N,那么其算法复杂度最坏情况下就要比较M*N次,就比如下图,就需要比较到最后一个c!=a的时候,才算结束。
KMP的思想就是记忆化之前比较的结果,直接移动到能够开始匹配的位置。
那么Robin-Karp算法又是怎么实现的呢,前面说了它是基于暴力算法作出的改良,暴力算法最麻烦的一段在于逐一比较,那么有没有办法能够一次性比较所有的字符呢?我们很容易就会想到Hash,只要将一段字符串进行Hash,那么只要比较两段字符串的Hash是否相同,就能知道两个字符串有没有可能相同。
这里说的是Hash相同,字符串可能相同,其余情况则不同。为什么是可能呢?因为Hash存在冲突,如果Hash相同,并不意味着一定相同。
计算滚动Hash
我这里先举例,先从例子中学习如何计算滚动Hash,然后我在抛出公式:
以"bba"为例子,计算bba的Hash,我们可以使用:
b
∗
12
8
2
+
b
∗
128
+
a
b*128^2+b*128+a
b∗1282+b∗128+a,其中b和a的值是其ascii码的值。
为什么要乘以28呢,是因为ascii码的总个数是128,所以我们可以以128为进位。
相信你看了上面的例子,它实际上就是一个求进制而已,比如求二进制“110”的十进制是多少,就是求: 1 ∗ 2 2 + 1 ∗ 2 + 0 1*2^2+1*2+0 1∗22+1∗2+0
但是,如果字符串实际特别长,比如真实的字符串由十万个bba组成,那么我们必须对求出来的int数字取余,取余的部分就是Hash的精髓了,一般就是找一个合适的质数,这里可以取101,因为如果取的比较小,比如37,那么产生Hash冲突的概率也会比较高,这里可以选取接近128的质数。
为什么要使用int类型呢?其实,只要是整型都可以。使用int类型,可以方便的取余,另外在计算滑动窗口的时候,也比字符串类型方便加减。
所以,我们求字符串的hash值的公式就呼之欲出了:
其中,
a
a
a表示字符的定义域范围,比如ascii码的范围是128。
S
S
S表示字符串,那么
∣
S
∣
|S|
∣S∣表示字符串的长度,
s
i
s_i
si就是第i个字符。
好了,现在求Hash的方法已经完成了,那么我们考虑,对于滑动窗口大小是3,字符串是“abbc”,如何快速的计算出两个字符串"abb"和"bbc"的Hash呢?
根据之前的经验,我们只需要先计算出“abb”的值,然后减去最高位"a"的Hash,加上一个最低位"c"的Hash,也就是:
(
(
a
∗
12
8
2
+
b
∗
128
+
b
)
−
a
∗
12
8
2
)
∗
128
+
c
((a*128^2+b*128+b)-a*128^2)*128+c
((a∗1282+b∗128+b)−a∗1282)∗128+c
如果我们之前有取余的运算,也不会影响结果,但可能让结果成为负数,这里可以再加上一个101,让结果为正:
(
(
(
a
∗
12
8
2
+
b
∗
128
+
b
)
%
101
−
(
a
∗
12
8
2
)
%
101
)
∗
128
+
c
)
%
101
+
101
(((a*128^2+b*128+b)\%101-(a*128^2)\%101)*128+c)\%101+101
(((a∗1282+b∗128+b)%101−(a∗1282)%101)∗128+c)%101+101
所以轮流计算Hash的公式也呼之欲出了:
看不懂不要紧,在程序里写人话就行了。
子串查找题解
Rabin-Karp算法对模式串和文本中的子串分别进行哈希运算,以便对它们进行快速比对。
import java.util.Arrays;
import java.util.List;
public class Solution {
public void search(String pattern,String text){
int m=pattern.length();
int n=text.length();
int patternHash=0;
int textHash=0;
/**
* 初始工作
*/
int hash=1;
int primeNumber=101;//使用一个比较大的质数来取余
int domain=256;//所有字符的定义域是0-255
//计算最高位要乘以的Hash值,之后用来计算下一个滑动窗口的值
for (int i=0;i<m-1;i++){
hash=(hash*domain)%primeNumber;
}
//计算第一个窗口内pattern和text的Hash
for (int i=0;i<m;i++){
patternHash=(domain*patternHash+pattern.charAt(i))%primeNumber;
textHash=(domain*textHash+text.charAt(i))%primeNumber;
}
for (int i=m;i<=n;i++){//注意:这里是取等于
if (patternHash==textHash){
//要验证一遍,因为可能是Hash冲突导致的相等
int countDiff=0;
for (int j =0; j < m; j++) {
if (countDiff!=0) break;
if (pattern.charAt(j)!=text.charAt(i-m+j)){
countDiff++;
}
}
if (countDiff==0) {
System.out.println("position found,beginAt:"+(i-m));
return;
}
}
//不相等,那么计算下一个滑动窗口
if (i<n){
//也就是减去最前面那个字符的HASH,加上新加入的字符的Hash
textHash=(domain*(textHash-text.charAt(i-m)*hash)+text.charAt(i))%primeNumber;
//新算出来的Hash值可能是负数,直接加上质数转成正数即可
if (textHash<0) textHash+=primeNumber;
}
}
}
public static void main(String[] args) {
Solution solution=new Solution();
solution.search("aabba","caabba");
}
}
题解写的很清楚了,我就不再赘述了,这里最后分析一下复杂度:
有人说Robin-Karp复杂度也是O(MN),其实不然,一共有(n-m+1)个窗口,复杂度为O(n),计算p的哈希值O(m),计算第一个窗口的复杂度,O(m),此后计算每一个窗口的复杂度O(1),要计算m-n次。
这几个复杂度之间采用加法,所以实际它的复杂度是O(M+N),并且没有额外空间。
在Golang官方库中,字符串子串查找就是用的该算法。而Java的String查找子串则是用的暴力方法。