字符串匹配

1. 问题描述

给定一个目标字符串string,和一个模式字符串sub,求sub是否是string的子串的问题就叫做字符串匹配,也叫做模式匹配。

2. 暴力算法

枚举string的每一个长度为sub.length()的子串,然后检查其是否和sub相同。

//    判断a[i:j)与b[l:r)是否相同
public boolean isEquals(String a, int i, int j, String b, int l, int r){
    if((j - i) != (r - l)) return false;
    for (int k = 0; k < j-i; k++) {
        if(a.charAt(i+k) != b.charAt(l+k)) return false;
    }
    return true;
}
public boolean isMatch(String string, String sub){
    for (int i = 0; i < string.length() - sub.length(); i++) {
        if(isEquals(string, i, i+sub.length(), sub, 0, sub.length()))
            return true;
    }
    return false;
}

3. Rabin-Karp算法

Rabin-Karp算法是利用的hash算法,将一个字符串映射成一个唯一的hash值,如果两个字符串的hash值不同,那么这两个字符串一定不相同;如果hash值相同,因为可能存在冲突(当然这个冲突的可能很小),所以为了确定是否真的相同,还需要对两个字符串进行一次遍历,进行一一比对。
将字符串映射成hash的方法:
设字符串为S[0,1,2,…,N],则S[0,1,2,…,i]的hash值这么计算:
h a s h ( S [ 0 , 1 , 2 , . . . , i ] ) = ∑ j = 0 i b i − j ∗ S [ j ] ( M O D p ) , hash(S[0,1,2,...,i]) = \sum_{j = 0}^{i}b^{i-j}*S[j] (MOD\quad p), hash(S[0,1,2,...,i])=j=0ibijS[j](MODp),其中,b表示一个基底(base),一般选用一个大于S[]中元素表示范围的最小质数(对于纯字母组成的字符串,字母的表示范围为0-25共26个,因此本人选用的是31这个质数)。S[j]在参与运算时表示它的ASCII值,例如’a’=97,‘b’=98。p是一个较大的质数(本人用的16777619),之所以求模是因为计算机的基本数据结构能表示的数据范围有限,容易溢出。而选用质数为模的原因是可以减少冲突的发生。
有了S[0,1,2,…,i]的hash值,当我们要计算S[1,2,3,…,i,i+1]的hash值时,就不用重新逐个遍历它里面的每一个字符了,根据上面的式子,我们可以得到:
h a s h ( S [ 1 , 2 , 3 , . . . , i , i + 1 ] ) = ( h a s h ( S [ 0 , 1 , 2 , . . . , i ] ) − S [ 0 ] ∗ b i ) ∗ b + S [ i + 1 ] ( M O D p ) , hash(S[1,2,3,...,i,i+1]) = (hash(S[0,1,2,...,i]) - S[0] * b^i) * b + S[i+1] (MOD \quad p), hash(S[1,2,3,...,i,i+1])=(hash(S[0,1,2,...,i])S[0]bi)b+S[i+1]MODp因此可以在O(1)的时间复杂度内计算出hash值(除了第一次),这个算法快就快在这里。

public class StringDemo {
    final int prime = 16777619;//大质数
    
//    判断a[i:j)与b[l:r)是否相同
    public boolean isEquals(String a, int i, int j, String b, int l, int r){
        if((j - i) != (r - l)) return false;
        for (int k = 0; k < j-i; k++) {
            if(a.charAt(i+k) != b.charAt(l+k)) return false;
        }
        return true;
    }
    
    public boolean rabinKarp(String string, String sub){
        if(sub.length() == 0) return true;
        if(sub.length() > string.length()) return false;
        int hashSub = 0, hash = 0, pow=1;
        for (int i = 0; i < sub.length(); i++) {
            hashSub = (hashSub * 31 + sub.charAt(i)) % prime;
            hash = (hash * 31 + string.charAt(i)) % prime;
            pow = (pow * 31) % prime;
        }
//        System.out.println(hash+", "+hashSub+", "+pow);
        if(hash == hashSub &&
                isEquals(string, 0, sub.length(), sub, 0, sub.length())){
            return true;
        }
        for (int i = sub.length(); i < string.length(); i++) {
            hash = (hash * 31 + string.charAt(i)) % prime;
//            用%代替求模运算时要注意
//            因为%是带符号的,因此如果遇到负数应该将%结果+p,然后再%p
//            例如-12 MOD 10 = (-12 % 10 + 10)%10 = 8
//            此步有减法,可能出现负数,因此要这么处理
            hash = (((hash - string.charAt(i - sub.length()) * pow) % prime) + prime) % prime;
//            System.out.println(hash+", "+hashSub+", "+pow);
            if(hash == hashSub &&
                    isEquals(string, i-sub.length()+1, i+1, sub, 0, sub.length())){
                return true;
            }
        }
        return false;
    }
    public static void main(String[] args) {
//        字符串是一个不可变常量,一旦创建了一个对象,它的值将不可以再被改变
        StringDemo demo = new StringDemo();
        String a = "abcedefghijkeolmwnoseoavodocopaaodnciwoaaqowenvviahifhi" +
                "dnjgairfhrujtgiyhfivndjdqujiothjfncjkagufhiorfncjhabufioreb" +
                "jchbhnafvjksbvhjsehiurjwiohipwohginvsjkfjioejcfoijoiajvoicmfdkjnvci" +
                "uisjfiejioefjioesgjiosjoigvjiorfjiofcvjoierjifjcksnjihfier89u9iriuvnc";
        String b = "iosjoigvjiorfjiofcvjoierjifjcksnjihfier89u9";
        System.out.println(demo.rabinKarp(a, b));
    }
}

其中有一个细节需要注意一下,就是计算机的求余%运算和数学中的求模运算有一点区别:在对负数求余%时,得到的是一个负数,例如-12%10 = -2,但是-12 mod 10 = 8,为了消除负数的影响,当对负数求模时,这么算,-12 mod 10 =(-12%10 + 10)%10。
下午的时候看了不少博客都没有具体的实现,公式倒是讲的比我清楚,但是都没说溢出怎么处理,要是没有溢出的处理办法,那么只能计算长度比较小的子串了,稍微大一点就容易溢出。为了防止溢出,我们使用了求模运算。
求模运算有几个性质(四则运算的,即加减乘除的模等于模的加减乘除再求模),然后因为幂运算是由乘法变过来的,因此也是符合的,这些运算的混合也还是符合求模的性质。可能说的有点混乱,什么意思呢,就是对一个大式子求模时可以先将其单项求模然后运算再求模。为什么要步步求模,就是因为害怕溢出,等你求出了大式子的结果再求模,很可能中间已经发生了溢出,那么结果就不会正确了。
大质数p要选用的合适,也不能太大,最好是小于int型最大值的2次方根。为什么呢?例如a*b (mod p) = (a mod p) * (b mod p) (mod p),如果p大于int型最大值的2次方根,那么等式后面的(a mod p) * (b mod p)就面临着溢出的风险,造成求模结果错误。
那么为什么我还是选用了16777619这么大的一个数?那是因为经过我的计算,122 * 16777619 = 2046869518恰好不会发生溢出,122是’z’的ASCII值,这是整个算式中最有可能发生溢出的地方,这里都没有溢出的话,其他的地方也不会再发生溢出了。
我们来分析一下:

  1. 首先是这里
for (int i = 0; i < sub.length(); i++) {
	hashSub = (hashSub * 31 + sub.charAt(i)) % prime;
	hash = (hash * 31 + string.charAt(i)) % prime;
	pow = (pow * 31) % prime;
}

代码第2行hashSub最大值×31不会大于231-1,因为hashSub每一步都是模后的结果,因此其最大值为16777619-1,而sub.charAt(i)的最大值为’z’的ASCII值122,因此这个式子的最大结果为520106280,可以看到并没有溢出。
代码第3行同上。
代码第4行分析同上。

  1. 然后看这里,
hash = (hash * 31 + string.charAt(i)) % prime;
hash = (((hash - string.charAt(i - sub.length()) * pow) % prime) + prime) % prime;

第1行的分析同上面一样;
第2行最容易发生溢出的地方在于这个乘法运算:string.charAt(i - sub.length()) * pow,但是上面也已经分析过,这个式子并不会发生溢出。不过这一行和前面不一样的地方在于这里有一个减法运算,可能会出现负数,因此要按照前面提到的方法进行修正。
因此最终我们得到了这样的结论,我选用的这个质数并不会造成溢出。而且因为它足够的大,基本上不会发生碰撞。
有的博客里面提到可以直接使用231作为p,正好直接将模运算当成溢出运算,但是我是没想明白,这种情况a*b (mod p) = (a mod p) * (b mod p) (mod p)他们是怎么得出正确的结果的。

你可能感兴趣的:(字符串匹配)