最长回文子串——马拉车算法详解

本文详细介绍了马拉车算法,一种用于寻找字符串最长回文子串的高效算法,通过特殊字符处理、半径数组计算和利用回文特性优化,将复杂度降低到线性。文中还提供了Python实现和三种方法的对比:中心扩展法和动态规划。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、马拉车算法(Manacher‘s Algorithm)是用来解决求取一个字符串的最长回文子串问题的。此算法充分利用了回文字符串的性质,将算法复杂度降到了线性,非常值得一学。

我将网上所有讲解马拉车算法的文章基本看了一遍,总结出了最通俗易懂的介绍,同时用 python 进行了实现。

题目
给定一个字符串s,找到s中最长的回文子字符串。

所谓回文字符串,指的是无论从左往右读还是从右往左读,结果都是一样的,也叫做对称字符串。

比如 “google” 的最长回文子串为 “goog”。

马拉车算法
这个算法的总框架是,遍历所有的中心点,寻找每个中心点对应的最长回文子串,然后找到所有中心点对应的最长回文子串,与求取一个字符串的最长回文子串中的第4个方法思想类似。

但是,第4个方法的复杂度为 O(n2),而马拉车算法对其进行了改进,将复杂度变为了线性。

1.1、字符之间插入特殊字符
回文串的中心点有两种,如果长度为奇数,则回文串中心为最中间的那个字符,如 “aba” 的 “b”;如果长度为偶数,则回文串中心为最中间的两个字符的分界,如 “abba” 的 “bb”。为了统一,马拉车算法首先将字符串的每个字符之间(包括首尾两端)插入一个特殊符号,如#,这个符号必须是原字符串中所没有的。

比如我们的原字符串为

s = "google"

那么插入#号之后,变为了

ss = "#g#o#o#g#l#e#"

这样做之后,字符串的长度肯定是奇数,因为插入的#号的个数一定等于字符个数+1,因此总长度是偶数+奇数=奇数。这样,循环时便不用考虑原字符串长度的奇偶性了。

1.2、计算半径数组 p
接下来,我们需要想办法计算出一个数组 p,这个数组的长度与处理后的字符串 ss 等长,其中 p[i] 表示以 ss[i] 为中心的最长回文子串的半径(不包括 p[i] 本身),暂且把它成为半径数组。如果 p[i] = 0,则说明回文子串就是 ss[i] 本身。

比如 “#a#b#” 的半径数组为 [0, 1, 0, 1, 0]。

数组 p 的最大半径,就是我们要寻找的最长回文子串的半径。因此只要计算出了数组 p,最后答案就呼之欲出了。

如何计算数组 p
一般的方法,是以中心点为中心,挨个将半径逐步扩张,直至字符串不再是回文字符串。但是这样做,整体的算法复杂度为 O(n2)。马拉车算法的关键之处,就在于巧妙的应用了回文字符串的性质,来计算数组 p。

马拉车算法在计算数组 p 的整个流程中,一直在更新两个变量:

i:回文子串的中心位置
mx:回文子串的最后位置
使用这两个变量,便可以用一次扫描来计算出整个数组 p,关键公式为:

p[i] = min(mx-i, p[2 * id - i])

我们用图示来理解这个公式,如下图:

当前,我们已经得到了 p[0…i-1],想要计算出 p[i] 来。红1为以 j 为中心的回文子串,红2为以 i 为中心的回文子串,红3为以 id 为中心的回文子串(首尾两端分别为mx的对称点和mx)。

那么,如果 mx 在 i 的右边,则我们可以通过已经计算出的 p[j] 来计算 p[i],其中 j 与 i 的中心点为 id。这里分两种情况:

先直接令 p[i] 的回文子串就等于 p[j] 的回文子串,即红2长度等于红1,然后判断红2的末尾是否超过了 mx,如果没有超过,则说明 p[i] 就等于 p[j]。
为什么呢?
因为以 id 为中心的回文子串为红3,包含了红1和红2,而且红1和红2以 id 为中心,那么一定有红2=红1。并且已经知道,红1是以 j 为中心的最长子串,那么红2也肯定是以 i 为中心的最长子串。
如果红2的末尾超过了 mx,那么就只能让 p[i] = mx - i了,即我可以保证至少半径到 mx 这个位置,是可以回文的,但是一旦往右超出了 mx,就不能保证了,剩下的只能用笨方法慢慢扩张来得到最长回文子串。
那如果红2的左边超出了mx的对称点,怎么办?不会出现这种情况的,因为红1的右边不会超过mx。如果超过了mx,那么在上一次迭代中,id应该更新为j,mx应该更新为 j+p[j]。在迭代中,会始终保证 mx 是所有已经得到的回文子串末端最靠右的位置。

另外,如果 mx 不在 i 的右边呢?那就利用不了红3的对称性了,只能使用笨方法慢慢扩张了。

1.3、数组 p 中的最大值,即为最长回文子串的半径
c++代码实现

class Solution {
public:
    string longestPalindrome(string s) {
        int len=s.size();
        string str;
        for(int i=0;i<len;i++){
            str+='#';
            str+=s[i];
        }
        str+='#';
        int l=str.size();
        vector<int>temp(l,0);
        int max_right=0,pos=0,res=0,res_pos=0;
        for(int i=0;i<l;i++){
            if(i<max_right){
                temp[i]=min(max_right-i,temp[2*pos-i]);
            }else{
                temp[i]=1;
            }
            while(i-temp[i]>=0&&i+temp[i]<l&&str[i-temp[i]]==str[i+temp[i]])
                temp[i]++;
            if(i+temp[i]-1>max_right){
                max_right=i+temp[i]-1;
                pos=i;
            }
            if(res<temp[i]){
                res=temp[i];
                res_pos=i;
            }
        }
        return s.substr((res_pos-res+1)/2,res-1);
    }
};

二、中心扩展法,就是依次将每个字符串中的字符作为一个可能存在的回文子串的中心字符,那么中心有两种中心方式:

2.1,回文子串长度是奇数,那么就是往两边扩展的,假设中心字符下标为i,那么只要有下标为i-1和i+1的两个字符相等,那么回文子串的长度加2,并且继续一个往前,一个往后遍历,也就是说继续比较下标为i-2和i+2的两个字符,直到找到不相等的或者说到达字符串边界,那么代表以s[i]为中心的最长回文子串找到了。

2.2,回文子串的长度是偶数,那么就先对比s[i]以及s[i+1],如果两者相等,那么继续比较s[i-1]和s[i+2],直到超出字符串的边界或者不相等,这样就能找到最长的回文子串,s[i]可能是中中心,也可能是右中心,但是这个不用分别计算,因为s[i]如果是这个子串的右中心,那么就一定是下一个字符的子串的左中心,所以不会漏掉。

c++代码实现

class Solution {
    public:
    int f(string &s,int i,int len)
    {
        int l1=1;
        int l2=1;
        int l=i-1;
        int r=i+1;
        while(l>=0&&r<len)
        {
            if(s[l]==s[r])
                l1+=2;
            else
                break;
            l--;
            r++;
        }
        for (l = i, r = i+1; l >= 0 && r < s.size() && s[l] == s[r]; l--, r++);
        l2 = r - l - 1;
        return max(l1, l2);
    }
    string longestPalindrome(string s) {
        //中心扩展法
        int len=s.size();
        if(len<=1)
            return s;
        int max_len=0,start=0;
        for(int i=0;i<len;++i)
        {
            int temp=f(s,i,len);
            if(temp>max_len)
            {
                max_len=temp;
                start=i-(temp-1)/2;
            }
        }
        return s.substr(start,max_len);     
    }
    };

三、直接利用动态规划,主要是写好状态转移方程 。定义一个长宽等于字符串长度的二维数组,其中若二维数组dp [i][j]的值为1,代表在s中从下标i开始到下标j这段长度的字符子串是回文字符串。

首先,一个字符肯定是回文子串,所以将dp[i][i](i从0到字符串长度)都设置为1,然后开始遍历,遍历过程中主要就看转移方程了,假设有s[i]==s[j],那表明它满足了新增的两个字符相等的条件,接下来就是看这两个字符往内层的子串是否也是回文的,所以只要dp[i+1] [j-1]为1,代表从i+1到j-1都是满足回文的,那么s[i]如果等于s[j],那么回文子串就可以加上两个新成员,还有一种情况就是它们两个字符是相邻的,那么只要有j-1==1,也可以代表这是一个新增的长度为2的新回文子串。

最后再找出长度最长的回文子串输出就可以了,但是这个的时间复杂度为n*2,因为有两重嵌套循环,空间复杂度也是n**2,状态转移方程为:
dp[l] [r] = (s[l]==s[r] && (r-l==1 || dp[l+1] [r-1])) ? true : false

c++代码实现

class Solution {
    public:
    string longestPalindrome(string s) {
        //动态规划做
        int l=s.size();
        if(l==0)
            return "";
        int max_l=0,max_r=0;
        vector<vector<int>>dp(l,vector<int>(l,0));
        for(int i=0;i<l;++i)
            dp[i][i]=1;
        for(int right=1;right<l;++right)
        {
            for(int left=0;left<right;++left)
            {
                if(s[left]==s[right]&&(right-left==1||dp[left+1][right-1]))
                {
                    dp[left][right]=1;
                    if(right-left>max_r-max_l)
                    {
                        max_r=right;
                        max_l=left;
                    }
                }
            }
        }
        return s.substr(max_l,max_r-max_l+1);    
    }
    };

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值