寻找最长回文子串算法优化Java版

134 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

引言

回文串就是正读和反读都一样的字符串。判断一个字符串是否为回文串也很简单,只要把字符串反序,再和原来的字符串比较一下就行了。

问题来了,反序后再比较,需要申请一个新的字符串空间。能否再优化。可以,只要先对比第1个字符和倒数第1个字符,在对比第2个与倒数第2个,以此类推。如果都相等,那就是回文串了。

本期讲的题目是:给你一个字符串,找出里面最长的回文子串。例如,输入abcded,那么输出应该是ded;输入afsnthtn,输出应该是nthtn。

方法1

找出该字符串的所有字串,然后依次判断该字串是否为回文。

该算法的时间复杂度和空间复杂度就是:字串渐进为O(n2)O(n^2),加上判断回文的O(n)O(n),一共是O(n3)O(n^3),空间可以为O(1)O(1)

方法2

我们这样思考,举个例子:abcdefg这个串,中间的cde已经不是回文了,那么以d为中心的串就不可能是回文了。

根据这个思路,我们可以遍历整个字符串,把每个字符和字符间的空隙当作回文的中心,然后向两边扩展来找到最长回文串。

image.png

这样一来,时间复杂度降为O(n2)O(n^2),空间复杂度还是O(1)O(1)

我们进一步优化。举个例子:字符串“cabadabae”用中心扩展的算法,已经知道了第3位为中心的aba和第5位为中心的abadaba是回文,那么在判断第7位为中心的回文串的时候,是否可以利用已知信息?

image.png

已知第5位为中心的abadaba是回文,由回文的特性,就能够知道2-4位和6-8位对称,而又知道第3位为中心的aba是回文,所以2-4位是回文。这样的话,6-8位肯定是回文。这样我们在判断以第7为为中心时,就不需要从头开始扩展。

你可能觉得这种情况很特殊,不具备一般性。但我们看如果要判断以第6位为中心的回文,依然不需要从头开始扩展。由于之前的计算,已经知道了第5位为中心的abadaba是回文,而第4位为中心的a的回文长度是1,所以第6位为中心的回文长度只能是1,不用再去扩展判断了。

image.png

但这还不够,我们再看:以第7位为中心的回文串的计算,由之前分析已经知道最小长度是3了,但是还是需要进行扩展,因为第9位是什么,根据之前的信息无法得知,需要扩展进行探索。而以第6位为中心的回文串的计算,并不需要进行探索了,因为根据之前第5位为回文中心串的信息、和第4位为回文中心串的信息,已经可以推断,第6位为回文中心串的长度只能为1。

注意,判断第6位时,我们直接可以得出结论为1,但是第7位依然需要扩展判断。是因为第5位为中心的回文串给到的信息到第8位为止,第9位之后的信息是未知的。也就是说,整个算法最核心的就是已知回文的右边界。只要在这个边界内,我们都有已知信息可以利用。

因此,我们只需找到右边界所对应的回文中心,然后以它作对称就能获得很多已知信息。

方法3

由上述推导,我们可以得出以下具体步骤:

1、首先,我们要记录下目前已知的回文串能够覆盖到的最右边的地方,就像案例中的第8位

2、同时,覆盖到最右边的回文串所对应的回文中心也要记录,就像案例中的第5位

3、以每一位为中心的回文串的长度也要记录,后面进行推断的时候能用到,就像案例中用到的以第3位为中心的回文和第4位为中心的回文

4、对于新的中心,我们判断它是否在右边界内,若在,就计算它相对右边界回文中心的对称位置,从而得到一些信息,同时,如果该中心需要进行扩展,则继续扩展就行。


不过该步骤还是有缺陷。因为回文串可能是偶数。回文的中心就变成了两个字符中间。这时我们只需要再两个字符之间加一个特殊符合,比如-。这样两种情况就能统一了。改进后的步骤如下:

1、先对字符串进行预处理,两个字符之间加上特殊符号#;

2、然后遍历整个字符串,用一个数组来记录以该字符为中心的回文长度,为了方便计算右边界,我在数组中记录长度的一半(向下取整);

3、每一次遍历的时候,如果该字符在已知回文串最右边界的覆盖下,那么就计算其相对最右边界回文串中心对称的位置,得出已知回文串的长度;

4、判断该长度和右边界,如果达到了右边界,那么需要进行中心扩展探索。当然,如果第3步该字符没有在最右边界的“羽翼”下,则直接进行中心扩展探索。进行中心扩展探索的时候,同时又更新右边界;

5、最后得到最长回文之后,去掉其中的特殊符号即可。

Java代码如下:

// 预处理字符串,在两个字符之间加上#
private static String preHandleString(String s) {
    StringBuffer sb = new StringBuffer();
    int len = s.length();
    sb.append('-');
    for(int i = 0; i < len; i++) {
        sb.append(s.charAt(i));
        sb.append('-');
    }
    return sb.toString();
}
// 寻找最长回文字串
public static String findLongestPalindromeSubString(String s) {
    // 先预处理字符串
    String str = preHandleString(s);
    // 处理后的字串长度
    int len = str.length();
    // 右边界
    int rightSide = 0;
    // 右边界对应的回文串中心
    int rightSideCenter = 0;
    // 保存以每个字符为中心的回文长度一半(向下取整)
    int[] halfLenArr = new int[len];
    // 记录回文中心
    int center = 0;
    // 记录最长回文长度
    int longestHalf = 0;
    for(int i = 0; i < len; i++) {
        // 是否需要中心扩展
        boolean needCalc = true;
        // 如果在右边界的覆盖之内
        if(rightSide > i) {
            // 计算相对rightSideCenter的对称位置
            int leftCenter = 2 * rightSideCenter - i;
            // 根据回文性质得到的结论
            halfLenArr[i] = halfLenArr[leftCenter];
            // 如果超过了右边界,进行调整
            if(i + halfLenArr[i] > rightSide) {
                halfLenArr[i] = rightSide - i;
            }
            // 如果根据已知条件计算得出的最长回文小于右边界,则不需要扩展了
            if(i + halfLenArr[leftCenter] < rightSide) {
                // 直接推出结论
                needCalc = false;
            }
        }
        // 中心扩展
        if(needCalc) {
            while(i - 1 - halfLenArr[i] >= 0 && i + 1 + halfLenArr[i] < len) {
                if(str.charAt(i + 1 + halfLenArr[i]) == str.charAt(i - 1 - halfLenArr[i])) {
                    halfLenArr[i]++;
                } else {
                    break;
                }
            }
            // 更新右边界及中心
            rightSide = i + halfLenArr[i];
            rightSideCenter = i;
            // 记录最长回文串
            if(halfLenArr[i] > longestHalf) {
                center = i;
                longestHalf = halfLenArr[i];
            }
        }
    }
    // 去掉之前添加的#
    StringBuffer sb = new StringBuffer();
    for(int i = center - longestHalf + 1; i <= center + longestHalf; i += 2) {
        sb.append(str.charAt(i));
    }
    return sb.toString();
}

public static void main(String[] args) {
    String[] testStrArr = new String[] {
            "aqcdecdcsdehnfisni",
            "adaelele",
            "cabadabae",
            "aaaabcdefgfedcbaa",
            "aaba",
            "aaaaaaaaa"
    };
    for(String str : testStrArr) {
        System.out.println(String.format("原字串 : %s", str));
        System.out.println(String.format("最长回文串 : %s", findLongestPalindromeSubString(str)));
        System.out.println();
    }
}

算法中,halfLenArr与预处理后的字符串对应结果。我们分析时间复杂度,两层循环很容易让人以为是O(n2)O(n^2)。其实并非两层循环就是O(n2)O(n^2),这个算法在循环的时候,要么在扩展右边界,要么直接的出结论,时间复杂度为O(n)O(n)。空间复杂度为O(n)O(n)