Leetcode字符串子串查找算法——比KMP更容易理解的Robin-Karp算法

引入

在知乎看到了一篇名为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 b1282+b128+a,其中b和a的值是其ascii码的值。
为什么要乘以28呢,是因为ascii码的总个数是128,所以我们可以以128为进位。

相信你看了上面的例子,它实际上就是一个求进制而已,比如求二进制“110”的十进制是多少,就是求: 1 ∗ 2 2 + 1 ∗ 2 + 0 1*2^2+1*2+0 122+12+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 ((a1282+b128+b)a1282)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 (((a1282+b128+b)%101(a1282)%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查找子串则是用的暴力方法。

KMP算法是一种字符串匹配算法,用于在一个文本串S内查找一个模式串P的出现位置。它的时间复杂度为O(n+m),其中n为文本串的长度,m为模式串的长度。 KMP算法的核心思想是利用已知信息来避免不必要的字符比较。具体来说,它维护一个next数组,其中next[i]表示当第i个字符匹配失败时,下一次匹配应该从模式串的第next[i]个字符开始。 我们可以通过一个简单的例子来理解KMP算法的思想。假设文本串为S="ababababca",模式串为P="abababca",我们想要在S中查找P的出现位置。 首先,我们可以将P的每个前缀和后缀进行比较,得到next数组: | i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | --- | - | - | - | - | - | - | - | - | | P | a | b | a | b | a | b | c | a | | next| 0 | 0 | 1 | 2 | 3 | 4 | 0 | 1 | 接下来,我们从S的第一个字符开始匹配P。当S的第七个字符和P的第七个字符匹配失败时,我们可以利用next[6]=4,将P向右移动4个字符,使得P的第五个字符与S的第七个字符对齐。此时,我们可以发现P的前五个字符和S的前五个字符已经匹配成功了。因此,我们可以继续从S的第六个字符开始匹配P。 当S的第十个字符和P的第八个字符匹配失败时,我们可以利用next[7]=1,将P向右移动一个字符,使得P的第一个字符和S的第十个字符对齐。此时,我们可以发现P的前一个字符和S的第十个字符已经匹配成功了。因此,我们可以继续从S的第十一个字符开始匹配P。 最终,我们可以发现P出现在S的第二个位置。 下面是KMP算法的C++代码实现:
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值