「力扣」第 5 题:最长回文子串(暴力解法、中心扩散、动态规划)

「力扣」第 5 题:最长回文子串(暴力解法、中心扩散、动态规划)

解释题意

大家好,这里是「力扣」视频题解第 5 题:最长回文子串。

这道题给我们一个字符串 s,让我们找出这个字符串 s 的最长回文子串,并且告诉我们,s 的最大长度为 1000

看到这个提示,我们简单计算一下, 1000 1000 1000 的平方是 10 10 10 6 6 6 次方,经验告诉我们,可以设计一个大欧 N 方的算法。(这里需要查相关资料说清楚。)

我们看一下示例 1:

给出的字符串是:babad,输出 bab,它是原始字符串的子串,并且 aba 也是一个有效的答案。

示例 2:

输入 cbbd,输出 bb

由示例,我们可以归纳出回文子串的特点:

1、子串(substring):在原始字符串中必须是连续的字符,这一点是区别于子序列的,子序列()只需要保证字符的相对顺序不变,但不要求连续;

2、回文性质(palindromic):简单说就是从左向右读和从右向左读都是一样的。我们中国人是把回文玩得很 6 的,在古时候,我们就有回文对联和回文诗,有兴趣的朋友可以在网上搜索一下,「力扣」上也有很多关于回文串、回文子序列的问题,大家也不妨做一下。

回文性质从形象上说,就是在回文串的中心位置画一条直线,回文串关于这条直线中心对称。

关于回文,我们知道:

1、单个字符串一定是回文;

回文串可以根据其长度的奇偶性分类:

2、奇数长度的回文串:它的中心正好落在回文串的中间位置;

3、偶数长度的回文串:它的中心是两个相等的字符,也可以认为它的中心是这两个字符中间的空隙。

4、判定是否是回文字符串,可以从两边向中间同步遍历,即使用双指针成对地比较位于字符串前面和后面的字符,只要有一对字符不匹配,这个字符串就不是回文,直到双指针相遇,当且仅当全部匹配的时候,字符串才是回文。

另外一种方法是从回文串的中心开始向两边扩散去匹配,这个方法也很简单,要注意的一点是数组下标不能越界。

方法 1:暴力解法

最直接能够想到的方法是:

  • 枚举 s 的所有子串;
  • 然后逐个判断每个子串的回文性质;
  • 同时记录最长子串;
  • 细节:记录最长子串需要截取,截取有一定性能消耗。替代方式:记录最长回文子串的起始位置 start 和最长回文子串的长度 maxLen,到遍历完成以后,再做截取。

这一版代码交给读者完成。

Java 代码:

public class Solution {

    // 暴力解法

    public String longestPalindrome(String s) {
        int len = s.length();
        if (len < 2) {
            return s;
        }

        int maxLen = 1;
        String res = s.substring(0, 1);

        // 枚举所有长度大于等于 2 的子串
        for (int i = 0; i < len - 1; i++) {
            for (int j = i + 1; j < len; j++) {
                if (j - i + 1 > maxLen && valid(s, i, j)) {
                    maxLen = j - i + 1;
                    res = s.substring(i, j + 1);
                }
            }
        }
        return res;
    }

    private boolean valid(String s, int left, int right) {
        // 验证子串 s[left, right] 是否为回文串
        while (left < right) {
            if (s.charAt(left) != s.charAt(right)) {
                return false;
            }
            left++;
            right--;
        }
        return true;
    }
}

复杂度分析

  • 时间复杂度: O ( n 3 ) O(n^3) O(n3),这里 n n n 是字符串的长度,枚举字符串的左边界、右边界是 O ( n 2 ) O(n^2) O(n2),然后继续验证子串是否是回文子串,这一步操作是 O ( n ) O(n) O(n)
  • 空间复杂度: O ( 1 ) O(1) O(1),只使用到常数个临时变量,与字符串长度无关。

方法 2:中心扩散法

  • 刚刚我们提到了判定回文子串的两种方法,其中一种就是从“中心位置”开始判断。

  • 为此,我们还可以枚举所有可能的回文子串所在的中心位置,需要枚举的可能的中心位置一共有 2 × n − 1 2 \times n - 1 2×n1 个,在时间复杂度上降低了一个级别。

  • 中心位置可能是一个字符(奇数长度的时候),也可能是两个字符(偶数长度的时候)。

  • 中心位置不论是一个字符,还是两个字符的,其实我们都是用两个指针从中间向两边成对地去判断字符是否相等。可以认为中心是一个字符的时候,是两个指针的重合。

  • 为此可以设计一个统一的方法,兼容这两种情况。

具体的方法是:

1、如果传入重合的索引编码,进行中心扩散,此时得到的回文子串的长度是奇数;

2、如果传入相邻的索引编码,进行中心扩散,此时得到的回文子串的长度是偶数。

下面我们看一下代码:

Java 代码:

public class Solution2 {

    // 中心扩散法

    public String longestPalindrome(String s) {
        int len = s.length();
        if (len < 2) {
            return s;
        }
        int maxLen = 1;
        int start = 0;
        // 中心位置枚举到 len - 2 即可
        for (int i = 0; i < len - 1; i++) {
            int oddLen = expandAroundCenter(s, i, i);
            // System.out.println("oddLen:" + oddLen);
            int evenLen = expandAroundCenter(s, i, i + 1);
            // System.out.println("evenLen:" + evenLen);

            int curMaxLen = Math.max(oddLen, evenLen);
            if (curMaxLen > maxLen) {
                maxLen = curMaxLen;
                start = i - (maxLen - 1) / 2;
            }
        }
        return s.substring(start, start + maxLen);
    }

    /**
     * 回文串的长度
     * @param s
     * @param left
     * @param right
     * @return
     */
    private int expandAroundCenter(String s, int left, int right) {
        // left = right 的时候,此时回文中心是一个字符,回文串的长度是奇数
        // right = left + 1 的时候,此时回文中心两个字符,回文串的长度是偶数
        int len = s.length();
        int i = left;
        int j = right;
        while (i >= 0 && j < len) {
            if (s.charAt(i) == s.charAt(j)) {
                i--;
                j++;
            } else {
                break;
            }
        }
        // 这里要小心,跳出 while 循环时,恰好满足 s.charAt(i) != s.charAt(j),
        // 此时回文串的长度是 j - i
        return j - i - 1;
    }
}

复杂度分析:

  • 时间复杂度: O ( n 2 ) O(n^{2}) O(n2),枚举中心位置有 2 N 2N 2N 个(这里我们没有分析得特别细致),每一次向两边扩散检测是否回文,时间复杂度都是 O ( n ) O(n) O(n)
  • 空间复杂度: O ( 1 ) O(1) O(1),只使用到常数个临时变量,与字符串长度无关。

方法 3:动态规划

能想到“动态规划”解法,是因为“回文串”是天然具有“状态转移”性质的:

一个回文去掉两头以后,剩下的部分依然是回文(这里暂不讨论边界)。

因此,我们可以使用“动态规划”的方法快速判断一个子串是否是回文子串。“动态规划”的方法,在判断子串的过程中使用,参考子串的子串是否是回文的结果。

“动态规划”最关键的步骤是想清楚“状态如何转移”,我们依然从回文串的定义展开讨论:

1、如果一个字符串的头尾两个字符都不相等,那么这个字符串一定不是回文串;

2、如果一个字符串的头尾两个字符相等,才有必要继续判断下去:

(1)如果里面的子串是回文,整体就是回文串;

(2)如果里面的子串不是回文串,整体就不是回文串。

即在头尾字符相等的情况下,里面子串的回文性质据定了整个子串的回文性质,这就是状态转移。于是我们把“状态”定义为原字符串的一个子串是否为回文子串。

第 1 步:定义状态

dp[i][j] :子串 s[i, j] 是否为回文子串,这里 ij 分别表示字符串 s 的左右边界,并且是可以取到的。

第 2 步:思考状态转移方程

根据上面的分析,不难得到状态转移方程:

dp[i][j] = (s[i] == s[j]) and dp[i + 1][j - 1]

先判断左右边界所指向的字符是否相等,如果不相等,就直接下结论,子串不是回文串。如果相等,我们就看去掉了头和尾的那个子串是否是回文子串。注意,这里的逻辑运算符,我们用的是 and,它有短路的功能。

对于我们这个问题,“动态规划”实际上是在填一张二维表格,ij 的关系是 i <= j ,因此,只需要填这张表的上半部分;

看到 dp[i + 1][j - 1] 就得考虑边界情况。

边界条件是:以 i + 1 为左边界和以 j - 1 为右边界的子串,长度小于等于 1,因为我们之前说过,单个字符一定是回文串。即在严格小于 2的情况下,计算不等式 j - 1 - (i + 1) + 1 < 2 ,整理得 j - i < 3

这个结论很显然:当子串 s[i, j] 的长度等于 2 或者等于 3 的时候,我其实只需要判断一下头尾两个字符是否相等就可以直接下结论了。

  • 如果子串 s[i + 1, j - 1] 只有 1 个字符,即去掉两头,剩下中间部分只有 1 1 1 个字符,当然是回文;
  • 如果子串 s[i + 1, j - 1] 为空串,那么子串 s[i, j] 一定是回文子串。

因此,在 s[i] == s[j] 成立和 j - i < 3 的前提下,直接可以下结论,dp[i][j] = true,否则才执行状态转移。

(这一段看晕的朋友,直接看代码吧。我写晕了,车轱辘话来回说。)

第 3 步:考虑初始化

初始化的时候,单个字符一定是回文串,因此把对角线先初始化为 1,即 dp[i][i] = 1

事实上,初始化的部分都可以省去。因为只有一个字符的时候一定是回文,dp[i][i] 根本不会被其它状态值所参考。

第 4 步:考虑输出

只要一得到 dp[i][j] = true,就记录子串的长度和起始位置,没有必要截取,因为截取字符串也要消耗性能,记录此时的回文子串的“起始位置”和“回文长度”即可。

第 5 步:考虑状态是否可以压缩

因为在填表的过程中,只参考了左下方的数值。事实上可以压缩,但会增加一些判断语句,增加代码编写和理解的难度,丢失可读性。在这里不做状态压缩。

下面是编码的时候要注意的事项:总是先得到小子串的回文判定,然后大子串才能参考小子串的判断结果。

思路是:

1、在子串右边界 j 逐渐扩大的过程中,枚举左边界可能出现的位置;

2、左边界枚举的时候可以从小到大,也可以从大到小。

这两版代码的差别仅在内层循环,希望大家能够自己动手,画一下表格,思考为什么这两种代码都是可行的,相信会对“动态规划”作为一种“表格法”有一个更好的理解。

Java 代码:

public class Solution {

    // 动态规划

    public String longestPalindrome(String s) {
        int len = s.length();
        if (len < 2) {
            return s;
        }

        boolean[][] dp = new boolean[len][len];

        for (int i = 0; i < len; i++) {
            dp[i][i] = true;
        }

        int maxLen = 1;
        int start = 0;

        for (int right = 1; right < len; right++) {
            for (int left = 0; left < right; left++) {
                if (s.charAt(left) != s.charAt(right)) {
                    dp[left][right] = false;
                } else {
                    if (right - left <= 2) {
                        dp[left][right] = true;
                    } else {
                        dp[left][right] = dp[left + 1][right - 1];
                    }
                }

                if (dp[left][right]) {
                    int curLen = right - left + 1;
                    if (curLen > maxLen) {
                        maxLen = curLen;
                        start = left;
                    }
                }
            }
        }
        return s.substring(start, start + maxLen);
    }
}

复杂度分析:

  • 时间复杂度: O ( n 2 ) O(n^{2}) O(n2)
  • 空间复杂度: O ( n 2 ) O(n^{2}) O(n2),二维 dp 问题,一个状态得用二维有序数对表示,因此空间复杂度是 O ( n 2 ) O(n^{2}) O(n2)

这个方法在实际运行起来,会比中心扩展法要慢,这是因为:

  • “动态规划”方法,实际上是“暴力解法”的优化,在判断是否回文这一步,我们“用空间换时间”,把时间复杂度降低了一个数量级;
  • 而“中心扩散法”在枚举的中心数量上,比“暴力解法”要少一个数量级;
  • 因此,中心扩散法在执行时间上较快于动态规划方法,但它们本质上都是 O ( n 2 ) O(n^{2}) O(n2) 时间复杂度的算法,执行时间不一样是因为 n 2 n^{2} n2 前面那个系数不一样。

最后要向大家提及的是,“最长回文子串”还有线性时间复杂度的算法,这是由著名计算机科学家 Manacher 发明的,这个方法是专门用于解决“最长回文子串”问题的算法,有兴趣的朋友可以在网络上或者是本题的题解区搜索 Manacher 算法的解释,这个算法充分利用回文子串的对称性,也是采用“以空间换时间”的思路,在 O ( n ) O(n) O(n) 的时间复杂度内完成了最长回文子串的搜索。

大家重点还是掌握“中心扩散法”和“动态规划法”。

方法 4:Manacher 算法

(省略)

你可能感兴趣的:(力扣)