算法学习:Rabin-Karp 算法

 文章目录

  • 一、认识Rabin-Karp 算法
  • 二、算法运用
    • 1.回文串
    • 2.重复的DNA序列
    • 3.找出字符串中第一个匹配的下标
  • 总结

一、认识Rabin-Karp 算法

        本文讲的Rabin-Karp 算法也属于字符串匹配算法的一种,相较于KMP算法,Rabin-Karp 算法更简单优雅,且能解决绝大部分的问题,更容易上手。基于前面学习的滑动窗口算法,这里本人打算主要从Rabin-Karp 算法先入手学习,其特点如下:

  1. 使用了哈希函数,将字符串的比较转化为数字的比较,从而加快了比较速度。
  2. 如果两个字符串的哈希值相同,则可以通过进一步比较字符来确认是否存在真正的匹配。
  3. 在向右滑动窗口的过程中,能够以O(1)的时间复杂度计算出下一位置的哈希值。
  4. 通过除余运算来解决整数溢出的问题。

       

二、算法运用

1.回文数

        下面选用力扣的第 9 题 回文数 练习,详情请读者自动跳转至原题。

       注意该题是对回文数的判断而不是回文串,这时需要用到RK算法。如果是回文串,那将可以用字符串拆分为独立的数字用左右指针的技巧解决,但这里要求的数字,显然是行不通的。本人刚开始接触这道题时很疑惑为什么不一开始就使用字符串的形式,对于这样的回文判断,没必要搞这么复杂。后来经过了解,相较于定义为字符串,定义为数字有以下优点:

  1. 计算效率:在某些情况下,将数字定义为数字而不是字符串可以更高效地进行计算。例如,如果使用字符串来表示数字,每次比较两个字符都需要进行字符转换和内存访问,这可能会增加计算时间。而将数字定义为数字可以直接进行数字比较,减少了转换和访问的开销。
  2. 内存占用:使用字符串表示数字需要更多的内存空间。每个字符都需要一个字节,而数字只需要一个或多个字节来表示。在处理大量数据时,使用数字可以节省内存空间。
  3. 数学运算:将数字定义为数字可以更方便地进行数学运算。例如,可以使用加法、减法、乘法等运算符来对数字进行操作,而使用字符串表示数字则需要额外的转换和解析。

        以上几点也差不多就是RK算法的特点,但也许是本人认识过浅,依然认为在这道题目上没必要这么写,或许此题用到RK算法的意义可能是为了易于让初学者了解RK算法的原理。

代码如下(示例):

class Solution {
    public boolean isPalindrome(int x) {
        //负数一定不满足回文数
        if(x<0){
            return false;
        }   
        //利用temp来分解x的各个位数,y记录x翻转的整个过程和最终结果
        int temp=x;
        int y=0;
        //当temp还有数字还可以被分解时
        while(temp>0){
            int z = temp % 10;//将数字的末尾数字提取出来
            temp = temp / 10;
             y = y*10 + z;//将提取出的末尾数字作为y的最高位数字,依此类推插入提取出来的数字
        }
        //判断是否是回文数
        return y==x;
    }
}

2.重复的DNA序列

       下面选用力扣的第 187 题「重复的 DNA 序列」练习,详情请读者自动跳转至原题。

       该题的解题思路在于运用RK算法实现边滑动窗口,边更新哈希值。这里我先用字符串的方式实现。

代码如下(示例):

class Solution {
    public List<String> findRepeatedDnaSequences(String s) {
        int n = s.length();
        //记录所有结果集
        HashSet<String> window = new HashSet<>();
        //记录重复出现的序列
        HashSet<String> result = new HashSet<>();

        for(int i = 0; i+10 <= n;i++){
            String res = s.substring(i,i+10);
            if(window.contains(res)){
                //找到目标
                result.add(res);
            }
            //实时监视
            window.add(res);
        }
        return new LinkedList<>(result);
    }
}

         在上一道回文数中,我们利用了数字的形式实现了RK算法。在此我们进行深度思考,同样也利用将字母转换成数字的技巧,仅针对本题给出的样例进行转换。

代码如下:

class Solution {
    public List<String> findRepeatedDnaSequences(String s) {
      //将字母转换成数字
    int[] nums = new int[s.length()];
    for(int i = 0;i<nums.length;i++){
        switch(s.charAt(i)){
            case 'A':
                nums[i]=0;
                break;
            case 'G':
                nums[i]=1;
                break;
            case 'C':
                nums[i]=2;
                break;
            case 'T':
                nums[i]=3;
                break;
            }
        }
        //guard作为哨兵,监视哈希值的所有变化
        HashSet<Integer> guard = new HashSet<>();
        //rse记录重复序列
        HashSet<String> res = new HashSet<>();

        int L=10;//数字位数
        int R=4;//进制
        int RL=(int) Math.pow(R,L-1);// 存储 R^(L - 1) 的结果,减少哈希冲突
        int valid=0;//记录哈希值

        //滑动哈希(一边滑动窗口,一边更新哈希值)
        int left=0,right=0;
        //右滑窗口,直到右边界
        while(right<nums.length){
            valid= valid*R+nums[right];//更新哈希值(在最低位添加数字)
            right++;
        //找到相似序列
        if(right-left==10){
            if(guard.contains(valid)){
                res.add(s.substring(left,right));//找到一样的哈希值,即找到重复序列,将子串添加到res集合中
            }
            else{
                guard.add(valid);//未满足要求,将当前哈希值记录到guard
            }
            valid= valid - nums[left]*RL;//更新哈希值(删除最高位的数字)
            left++;//左滑窗口,使右窗口继续移动
        }
    }
    //返回集合
     return new LinkedList<>(res);
  }
}

        以上我们列举出了A,C,T,G四种。难道我们要手动列举出以后碰到的所有情况吗?在此我们再进行深度优化,我们以 ASCII 码为例,用256个数字分别对应所有英文字符和英文符号,符合了我们绝大部分需求 。

        在这里我们出现了新的问题,那就是即便运用了long类型,但面对如此庞大的进制数据,也还是会出现整形溢出问题。这里我们就需要通过模运算使其余数落在 [0, Q-1] 的范围内,有效得避免整形溢出。在这里我们把这个余数作为该字符串的哈希值,那么也会产生哈希冲突的可能。这里我们就要考虑Q的重要性。Q作为除数,我们该怎么设置,才能减少哈希冲突?首先我们先从哈希值的空间考虑,刚才说了余数会落在 [0, Q-1] 的范围内,所以当这个空间越大,冲突的概率越小,也就说Q要足够大;我们再从公约数考虑,要想让余数不冲突,我们就应该让它尽量少发生变化。如果一个数有公约数,那么它就会出现多次的除数运算,到后面余数会越来越小,加大了重复的概率。所以在考虑Q够大的情况下,我们可以将其设置为素数,这样两者之间就不会产生多次运算。以上两个方法,着重考虑的是Q的大小,毕竟只要Q足够大,余数就是其本身,而考虑素数是为了更加精确,其实可以看需求是否要考虑。这里我们需要了解模运算的两个运算法则:

X % Q == (X + Q) % Q
(X + Y) % Q == (X % Q + Y % Q) % Q

       遇到乘法和加法运算就需要运用模运算来减少溢出。

代码如下:
 

class Solution {
    public List<String> findRepeatedDnaSequences(String s) {
      
        //guard作为哨兵,监视哈希值的所有变化
        HashSet<Long> guard = new HashSet<>();
        //rse记录重复序列
        HashSet<String> res = new HashSet<>();

        int L=10;//数字位数
        int R=256;//进制
        // 取一个比较大的素数作为求模的除数
        long Q = 1999999999;
        // R^(L - 1) 的结果
        long RL = 1;
        // 计算过程中不断求模,避免溢出
        for (int i = 1; i <= L - 1; i++) {
        RL = (RL * R) % Q;
        }
        long valid=0;//记录哈希值

        //滑动哈希(一边滑动窗口,一边更新哈希值)
        int left=0,right=0;
        //右滑窗口,直到右边界
        while(right<s.length()){
            valid = ((R * valid) % Q + s.charAt(right)) % Q;//更新哈希值(在最低位添加数字)
            right++;
        //找到相似序列
        if(right-left==10){
            if(guard.contains(valid)){
                 res.add(s.substring(left, right));//找到一样的哈希值,即找到重复序列,将子串添加到res集合中
            }
            else{
                guard.add(valid);//未满足要求,将当前哈希值记录到guard
            }
           valid = (valid - (s.charAt(left) * RL) % Q + Q) % Q;//更新哈希值(删除最高位的数字)
            left++;//左滑窗口,使右窗口继续移动
        }
    }
    //返回集合
     return new LinkedList<>(res);
  }
}

        这里我们要注意删除最高位数字的时候,可能会出现负值的情况。所以通过加上Q,我们可以将结果的范围调整为02Q-1,从而避免负数的出现。最后再次使用%Q对结果取模,确保结果在0Q-1的范围内。

3.找出字符串中第一个匹配的下标

       下面选用力扣的第 28 题 找出字符串中第一个匹配项的下标 练习,详情请读者自动跳转至原题。

        该题的解题思路在第二题中已经详细描述了。这里本题的要求是找到下标,改一下返回值即可。对于给出的目标值,这里不必使用容器,直接记录窗口内的哈希值和目标的哈希值,两者比较即可。

代码如下(示例):

class Solution {
    public int strStr(String s, String t) {
    // 位数
    int L = t.length();
    // 进制
    int R = 256;
    // 取一个比较大的素数作为求模的除数
    long Q = 1999999999;
    // R^(L - 1) 的结果
    long RL = 1;
    for (int i = 1; i <= L - 1; i++) {
        // 计算过程中不断求模,避免溢出
        RL = (RL * R) % Q;
    }

    // 计算模式串的哈希值,时间 O(L)
    long patHash = 0;
    for (int i = 0; i < t.length(); i++) {
        patHash = (R * patHash + t.charAt(i)) % Q;
    }
    
    // 记录滑动窗口中子字符串的哈希值
    long windowHash = 0;
    
   //滑动哈希(一边滑动窗口,一边更新哈希值)
    int left = 0, right = 0;
    while (right < s.length()) {
        //右滑窗口,直到右边界
        windowHash = ((R * windowHash) % Q + s.charAt(right)) % Q;//更新哈希值(在最低位添加数字)
        right++;

        //找到相似序列
        if (right - left == L) {
            // 根据哈希值判断是否匹配模式串
            if (windowHash == patHash) {
                // 再次确认,避免哈希冲突
                if (t.equals(s.substring(left, right))) {
                    return left;
                }
            }
            // 缩小窗口,移出字符
            windowHash = (windowHash - (s.charAt(left) * RL) % Q + Q) % Q;//更新哈希值(删除最高位的数字)
            left++;
        }
    }
    // 没有找到模式串
    return -1;
    }
}

        通过求模运算,我们已经将哈希算法的随机性尽可能得增大了,也许面对巨大数字的运算还有冲突的风险,所以我们确认余数相等时,我们这里调用一下暴力匹配算法,来检测是否真的发生了意外。因为之前已经将风险降到了最低,所以,这里的暴力匹配算法并不会出现反复的调用而对总体的时间复杂度产生巨大的影响,所以这里用暴力匹配算法是最简单明了的。 


总结

        

        RK算法的核心在于边滑动窗口,边更新哈希值,从而把滑动窗口内的匹配算法的时间复杂度降到O(n)。这里需要注意的是,仅用滑动窗口只能针对无序列的查找,因为涉及的计数器只记录了数值,并没有记录顺序。比如在‘sdfghj’中找‘fh’,那么结果会是‘fgh’,因为其中包含了‘f‘和’h’。所以当要求寻找特定目标时,我们需要用到滚动哈希,这样在记录的时候可以确保两者排列是一致的。

        拿第二题来说,对于样例比较小时,第一种解法是可行的,但一旦样例过大,效率就远不如第三种。第一种解法的时间复杂度为O(NL),我们知道滑动窗口算法的时间复杂度为O(N),不断截取子字符串的时间复杂度是O(L),所以一旦样例过大,时间复杂度会变大。但在RK算法中,用滚动哈希的方法使我们不用每次都去截取子串。在哈希冲突很小的概率下,O(L)是很小的,最后平均时间复杂度可以考虑为O(N)。

        之前不理解string换成integer的意义,但通过练习,明白了一旦数据过大,就会出现整形溢出,而求模可以很好的规避这一问题。同时通过对哈希值的计算,可以规避不断截取子字符串来降低时间复杂度。

  • 16
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
串匹配是指在一个文本串中查找另一个模式串的过程。常用的串匹配算法有Naïve算法、Rabin-Karp算法和Knuth-Morris-Pratt算法。 1. Naïve算法 Naïve算法是最简单的串匹配算法,也称为暴力匹配算法。它的思路是从文本串的第一个字符开始,依次比较文本串中的每个字符是否与模式串中的字符相等。若不相等,则继续向后比较;若相等,则比较下一个字符,直到找到完全匹配的子串或文本串被匹配完为止。 Naïve算法的时间复杂度是O(mn),其中m和n分别是模式串和文本串的长度。当模式串和文本串长度相等时,最坏情况下时间复杂度达到O(n^2)。 2. Rabin-Karp算法 Rabin-Karp算法是一种基于哈希值的串匹配算法。它的思路是先将模式串和文本串都转换为哈希值,然后比较它们的哈希值是否相等。如果哈希值相等,则再逐个比较模式串和文本串中的字符是否相等。这种方法可以有效地减少比较次数,提高匹配效率。 Rabin-Karp算法的时间复杂度是O(m+n),其中m和n分别是模式串和文本串的长度。但是,由于哈希函数的不完全性和哈希冲突的存在,Rabin-Karp算法在某些情况下可能会出现误判。 3. Knuth-Morris-Pratt算法 Knuth-Morris-Pratt算法是一种基于前缀函数的串匹配算法。它的思路是先计算出模式串的前缀函数,然后利用前缀函数的信息来跳过已经匹配过的部分,减少比较次数。 具体来说,KMP算法在匹配过程中维护一个指针i和一个指针j,其中i指向文本串中当前匹配的位置,j指向模式串中当前匹配的位置。如果当前字符匹配成功,则i和j同时向后移动一位;如果匹配失败,则通过前缀函数计算出j需要跳转到的位置,使得前j-1个字符与文本串中的对应字符已经匹配成功,然后将j指向这个位置,i不变,继续比较下一个字符。 KMP算法的时间复杂度是O(m+n),其中m和n分别是模式串和文本串的长度。由于利用了前缀函数的信息,KMP算法可以在最坏情况下达到O(n)的时间复杂度,比Naïve算法和Rabin-Karp算法更加高效。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值