[字符串匹配]214. 最短回文串 (暴力解 → RK算法 → KMP算法)

214. 最短回文串

题目链接


分类

  • 字符串(寻找以第0个字符为开头的最长回文子串)
  • 字符串匹配算法(equals()、RK算法、KMP算法、马拉车算法(待补充))

在这里插入图片描述

题目分析

本题是向字符串前面添加字符,将其转换为最短回文串,希望最终构成的回文串最短,就是尽可能减少添加的字符数量,所以问题转化为在字符串s中寻找最大的回文子串,且这个回文串是以s[0]为开头的,s去掉这个回文串的剩余部分反转加到s前面,就构成题目要求的最短回文串,所以要寻找的这个回文子串越大,最终构成的回文串就越小。

思路1-1:暴力解(自己的最初想法)

算法设计:(有很多漏洞,debug见“实现遇到的问题”)
在s中寻找一个 “左右字符相等”的字符,假设找到这样的字符ch,那么就可以以ch为中心点做中心扩散寻找以它为中心的最大回文串,记录下它所能构成的最大回文串[left,right]。

  • 如果构成的回文串的左边界left能够到达字符串s[0]处,且right还未到达s[n-1],则说明只需要在字符串前面倒序加上s[right+1~n-1]的字符即可构成最短回文串,返回。
  • 如果构成的回文串的左边界left不能到达s[0],则重新在s上寻找下一个 “左右字符相等”的字符,如果能够找到,就继续重复上面的步骤,如果直到字符串末尾都找不到这样的字符,则在字符串前面加上整个字符串的倒序。
实现遇到的问题:各种查缺补漏

1、思路1的方法没有考虑长度为偶数的回文串
用例不通过

输入:"abbacd"
输出:"dcabbabbacd"
预期结果:"dcabbacd"

原因分析:没有考虑长度为偶数的回文串,其实存在两种回文串形式:有中心点的例如aba,和没有中心点的例如aabbaa,这两种情况都有可能,所以要设置两个if分支,分别判断按这两种形式能构成的最大回文串。

输入: "babbbabbaba"
输出:"ababbabbbabbbabbaba"
预期结果:"ababbabbbabbaba"

原因分析:不能只判断上述两种回文形式的其中一种,两个分支并不是互斥关系,所以不要使用else if。

2、如果整个字符串本身就是回文的,就返回字符串本身。
findPal如果返回空的sb,说明找不到回文串;如果返回和s相等的字符串,说明整个字符串都是回文串。

在算法最开始先对整个字符串做判断,看本身是不是回文串,如果是,返回s本身,如果不是才继续下面的工作。

实现代码:

class Solution {
    public String shortestPalindrome(String s) {
        if(s == null || s.length() < 2) return s;

        //将字符串转成字符数组
        char[] sToChar = s.toCharArray();
        int len = sToChar.length;
        //判断字符串本身是不是回文串
        StringBuilder test = new StringBuilder();
        if(len % 2 == 0) test = findPal(s, sToChar, len / 2 - 1, len / 2);
        else test = findPal(s, sToChar, len / 2 - 1, len / 2 + 1);
        if(test.toString().equals(s)) return s;

        StringBuilder res = new StringBuilder(s.substring(1, len));
        res.reverse();

        //寻找所有“左右字符相等”的字符
        for(int i = 0; i < len; i++){
            //回文串没有中心点的情况
            if(i > 0 && sToChar[i] == sToChar[i - 1]){//用例:s=aabbc
                //以i为中心点寻找最大回文串
                StringBuilder sb = findPal(s, sToChar, i - 1, i);
                // if(sb.toString().equals(s)) return s;
                if(sb.length() > 0 && sb.length() < res.length()){
                    res = sb;
                }                
            }
            //找到一个“左右字符相等”的字符s[i](回文串有中心点的情况)
            if(i > 0 && i < len - 1 && sToChar[i - 1] == sToChar[i + 1]){
                //以i为中心点寻找最大回文串
                StringBuilder sb = findPal(s, sToChar, i - 1, i + 1);
                // if(sb.toString().equals(s)) return s;//用例:s="aba"
                if(sb.length() > 0 && sb.length() < res.length()){
                    res = sb;
                }
                //如果左边界!=0,则退出,寻找下一个“左右字符相等”的字符
            }
        }
        return res.append(s).toString();
    }
    //以[left,right]为起点寻找最大回文串:left==right是奇数情况、left+1=right是偶数情况
    public StringBuilder findPal(String s, char[] sToChar, int left, int right){
        StringBuilder sb = new StringBuilder();//寻找到的最长回文串
        while(left >= 0 && right < s.length()){
            if(sToChar[left] == sToChar[right]){
                left--;
                right++;
            }
            else break;
        }
        //如果回文串的左边界==0,此时的left==-1,说明要使整个s构成回文串,只需要将s[right+1,n-1]逆序加到s前面即可
        if(left == -1 && right < s.length()) sb.append(s.substring(right, s.length())).reverse();
        else if(left == -1 && right == s.length()) sb.append(s);
        return sb;
    }
}

思路1-2 思路简化的暴力解

思路1-1使用的暴力解是第一次做时的想法,虽然是暴力解,但也比较繁琐,实现起来较麻烦。其实这题的暴力解可以有更简单的形式:

从思路1-1也可以发现,我们要找的是s中从s[0]开始的最大回文子串,所以每次可以删除s末尾的一个字符,然后判断剩余字符串是否回文,这样找到的第一个有效回文串就是s中以s[0]开头的最大回文串。

算法流程:

1、先判断s本身是不是回文串,如果是则直接返回该字符串,如果不是则继续下面的处理;

2、去掉s最后末尾的字符,判断剩下的子串是否回文,如果是,则将删除的字符反转加到该子串前面,构成最终返回的结果。如果不是,则继续;

3、去掉s最后末尾的两个字符,判断剩下的子串是否回文,处理同上。

实现代码:

class Solution {
    public String shortestPalindrome(String s) {
        if(s == null || s.length() < 2) return s;
        StringBuilder sb = new StringBuilder();
        int left = 0, right = s.length();
        
        while(right > 0){
            //如果当前子串是回文串,则得到的必然是最大的回文子串,将该子串赋给sb后break
            if(isPal(s.substring(0, right))){
                sb.append(s);
                break;
            } 
            //如果当前子串不是回文串,则删除最后一个字符后重新判断
            else right--;
        }
        //将被删除的部分反转后加到sb前面
        StringBuilder res = new StringBuilder(s.substring(right, s.length()));
        return res.reverse().append(sb).toString();
    }
    //判断字符串是不是回文串
    public boolean isPal(String s){
        int left = 0, right = s.length() - 1;
        while(left < right){
            if(s.charAt(left) != s.charAt(right)) return false;
            left++;
            right--;
        }
        return true;
    }
}
  • 时间复杂度:在最差情况下,s只剩下一个字符时,该字符必然回文,再将删除的字符串反转加到该字符前面,得到最终结果。因此时间复杂度为O(N),而得到的每个子串都要做回文判断,使用中心扩散,需要O(N/2)的时间,所以整体时间复杂度为O(N^2).

  • 存在的问题:超时。

思路2:s和反转s做equals寻找最大回文子串(思路3\4优化的基础)

将s转成StringBuilder sb,然后sb反转,得到s反转后的字符串rS,两个字符串长度相等,分别取sb[0,right-1]和rS[left, n-1]进行比较,寻找s上以s[0]开头的最大回文子串。

比较sb[0,right-1]和rS[left, n-1]:

  • 如果相等,说明s[0,right-1]是回文串,退出循环。
  • 如果不相等,则将right–,left++,继续比较sb[0,right-1]和rS[left, n-1],直到两字符串相等 或 right <= 0,则退出循环。

退出循环时,sb[0,right-1] == rS[left,n-1]就是满足条件的最长回文串,将sb[right,n-1]这部分子串反转,加到s前面就得到最终的结果。因为sb[right,n-1]反转就是rS[0,left-1],所以直接取rS[0,left-1]加到s前面即可。

实现遇到的问题:reverse会修改原sb
  • sb.reverse() 会返回反转后的stringbuilder,但同时sb自身也会发生反转。在使用时容易忽略。(卡了很久才发现是这里出问题)
  • s.substring()则不会修改自身。

实现代码:

class Solution {
    public String shortestPalindrome(String s) {
        if(s == null || s.length() < 2) return s;
        StringBuilder sb = new StringBuilder(s);
        int left = 0, right = sb.length();
        StringBuilder rS = sb.reverse();//sb的反转字符串
        sb = new StringBuilder(s);//重新赋值,因为前面的reverse将sb自身也反转了。
        
        while(right > 0){
            if(sb.substring(0, right).equals(rS.substring(left,rS.length()))) break;
            left++;
            right--;
           
        }
        //退出循环时,sb[0,right]==rS[left,n-1]就是最长回文串,而rS[0,left-1]就是剩余部分的反转,将该部分加到原字符串s前面即可。
        
        return rS.substring(0,left) + s;
    }
}
  • 可以AC,但效率较低。

思路3:RK算法(字符串哈希法)

思路3其实是对思路2的优化,思路2使用的字符串匹配函数equals内部仍然是一个个字符比对,时间复杂度为O(N),对思路2的优化,其实就是对字符串匹配的优化。

这里使用RK算法进行优化,计算字符串的hash值,使用单向hash方法:

hash[i] = (hash[i-1] * p + s[i]-'a') % mod

简单来说,就是将字符串看做p进制的式子,现在要将其转化成十进制好比较(可以举二进制转换的例子对比理解)

其中,s[i]-'a’表示取s[i]的ASCII值,p和mod都是素数,且p<mod。这样一个字符串就能根据这个算法得到自己对应的哈希值,如果两个字符串的哈希值不相等就一定不是相同的字符串,但哈希值相等也不一定就是相同的字符串,只要哈希就有碰撞的危险,所以p和mod的选择很重要,p通常选择大于可选字符数量的质数,这里可选的字符并没有明确规定,所以可选字符的数量认为是128,>128附近的质数可以取131,mod取字符串长度平方值附近的质数,官方题解取1000000007,不知道依据在哪(猜测是基于用例,如果是这样那这个值参考意义就不大了)

我们寻找s最长回文子串的方法是取s和反转s比较,所以就可以计算s和反转s(用~s表示)的哈希值来判断是否相等。为了可以迭代计算哈希值,我们选择从s[0]和~s[0]开始计算,后面哈希值的计算可以基于前一个哈希值,这样可以实现一次遍历完成整个字符串的哈希值计算。

如何计算s和~s的哈希值?
s和~s是同步计算+比较的,所以两个哈希值要同时计算,前面说过计算哈希值其实就是p进制转换十进制的过程,进制转换就要约定好高低位,例如:s=“abcd”,我们认为最右端的d是最低位,最左端的a是最高位,这样的设置和十进制数1234的高低位是一样的。

所以从后往前计算哈希值就是:

('d'-'a')*p^0 + ('c'-'a')*p^1 + ('b'-'a')*p^2 + ('a'-'a')*p^3

从前往后计算哈希值就是:

(((('a'-'a') * p + 'b'-'a') * p + ('c'-'a')) * p + 'd'-'a')

从前往后计算的就是"abcd"的哈希值,从后往前计算的就是"dcba"的哈希值,所以可以总结为:

s: hash[i] = (hash[i-1] * p + s[i]-'a') % mod
~s:hash[i] = (hash[i-1] + (s[i]-'a') * p^i) % mod。
  • 使用上面的公式计算s和~s的哈希值时,字符串对象始终是s,不需要反转s。当然也可以反转s后只选用一个公式计算(选哪个公式在这题无所谓)。

在计算过程中维护一个max,如果出现两个s和~s的哈希值相等,就拿当前下标i更新max,后计算的i必然大于先计算的i。在i到达字符串末尾时,max就是s中的最大回文串右边界所在下标。

后续的操作和前面的其他思路一样。

实现遇到的问题:字面量溢出问题

用例出错:

输入:"aabababababaababaa" 
输出:"aababaabababababaabababababaababaa" 
预期结果:"aababaabababababaababaa"

原因分析:
1、可能计算过程中一些字面量在mod之前就发生了溢出,所以要在mod之前先将可能溢出的部分转换为long型,mod之后再转回int。保险起见可以每一个字面量都转换。
2、mul的计算也要做模运算,防止溢出。

实现代码:

class Solution {
    public String shortestPalindrome(String s) {
        if(s == null || s.length() < 2) return s;

        int hashS = 0, hashRs = 0, max = -1;//正向s和反向s的哈希值,max指示最长回文子串的右边界下标
        int p = 131, mod = 1000000007, mul = 1;//mul用于迭代计算p^i
        int i = 0;//工作指针
        while(i < s.length()){
            //按上面分析的公式迭代计算正向s和反向s的哈希值(s不需要反转)
            hashS = (int)(((long)hashS * p + (s.charAt(i) - 'a')) % mod);
            hashRs = (int)((hashRs + (long)(s.charAt(i) - 'a') * mul) % mod);
            //如果哈希值相等,说明[0,i]是回文串,记下该回文串的右边界下标
            if(hashRs == hashS){
                max = i;
            }
            //迭代更新p^i(记得取模)
            mul = (int) ((long) mul * p % mod);
            i++;
        }
        //max表示s的最长回文子串[0,max],所以取[max+1,n-1]反转加入到s前面
        StringBuilder toBeReverse = new StringBuilder(s.substring(max + 1, s.length()));

        return toBeReverse.reverse() + s;
    }
}
  • 存在的问题:这个解法可以AC,且效率挺高,但是如果测试用例的量非常庞大,仍然是有出现哈希碰撞的可能的,通常工程上不可以使用这样方法。

  • 时间复杂度:O(|s|),|s|表示字符串的长度。

  • 空间复杂度:O(1)。

思路4:KMP算法

同样是对思路2中字符串匹配的优化。和思路3相比,kmp不会有哈希碰撞的风险,是可靠的解法。

如何将问题转化为kmp可解问题?
基于思路2可以发现,s和反转~s两个做匹配可以找到s上最大的回文子串,这其实就是一个字符串匹配问题,又因为目标回文串要以s[0]为起点,所以我们把s当做模式串,把~s当做查询串,拿模式串利用kmp与~s做匹配,当匹配到~s的最后一个字符i时,从s[0]~s[i]就是s上的最大回文串,如图:

在这里插入图片描述

如何构造next数组 + 应用next数组?
https://blog.csdn.net/m0_38142029/article/details/107339482

实现代码:

class Solution {
    public String shortestPalindrome(String s) {
        if(s == null || s.length() < 2) return s;
        int[] next = getNext(s);//获取模式串s的next数组
        String _s = new StringBuilder(s).reverse().toString();//获取反转s作为查询串
        int j = 0;//模式串工作指针,运行结束时记录的s[0~j-1]就是最大回文串范围
        //kmp核心代码
        for(int i = 0; i < _s.length(); i++){
            while(j > 0 && s.charAt(j) != _s.charAt(i)){
                j = next[j];
            }

            if(s.charAt(j) == _s.charAt(i)){
                j++;
            }
        }
        //取s[j~n-1]反转添加到s前面
        String add = new StringBuilder(s.substring(j)).reverse().toString();
        return add + s;
        
    }
    //对s构造next数组(不理解过程的可以举例代入)
    public int[] getNext(String s){
        int j = 0;//指示已匹配部分最长回文前缀的最后一个字符下标
        int[] next = new int[s.length()];
        char[] ch = s.toCharArray();
        //i指示已匹配部分最长回文后缀的下一个字符下标
        for(int i = 2; i < ch.length; i++){//i从2开始是因为next[0],next[1]默认都是0
            //如果第i-1个字符合第j个字符不匹配,则j回溯,直到两位置字符匹配或j回溯到0
            while(j > 0 && ch[i - 1] != ch[j]){
                j = next[j];
            }
            //如果第i-1个字符合第j个字符匹配,则最长匹配前后缀长度+1,next[i]存放最长回文前缀的下一个字符下标
            if(ch[i - 1] == ch[j]){
                next[i] = j+1;
                j++;
            }
            //如果回溯后仍不相等,则next[i]就取当前回溯到的j对应的next[j],实际上就是取0
            else{
                next[i] = 0;
            }
        }
        return next;
    }
}
  • 时间复杂度:O(|S|)
  • 空间复杂度:O(|S|),next数组的大小。
  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值