LeetCode - 5. Longest Palindromic Substring - 最长回文子串 - 中心扩展、DP、Manacher's Algorithm

Given a string s, find the longest palindromic substring in s. You may assume that the maximum length of s is 1000.

Example 1:                                                        Example 2:

Input: "babad"                            Input: "cbbd"
Output: "bab"                             Output: "bb"
Note: "aba" is also a valid answer.

方法一:中心扩展  (beats 84.72%)

    这道题如果暴力求解的话太麻烦了,这种回文问题,用从中心逐渐向左右两边扩展的方式进行计算会快很多,值得注意的是,回文问题中,“aba”和“abba”不是一种类型,前一种是以一个字符为中心的对称,后一种是以两个相同字符为中心的对称,两种中心方式都要考虑。我的代码如下:

string longestPalindrome(string s)
{
    int len = s.length();
    if (len == 1 || len == 0)
        return s;
    int max_len = 1, start = -1;    // 最长回文string的起始位置,max_len是最长回文长度
    for (int i = 0; i < len; i++)
    {
        int l = i - 1, r = i + 1;       // left, right
        // 把一个字符作为中心
        while (l >= 0 && r <= len - 1 && s[l] == s[r]){ --l; ++r; }
        if (r - l - 1 > max_len)
        {
            max_len = r - l - 1;
            start = l + 1;
        }
        
        if (i != len - 1 && s[i] == s[i + 1])           
        {
            l = i - 1, r = i + 2;
            // 两个相同字符作为中心
            while (l >= 0 && r <= len - 1 && s[l] == s[r]){ --l; ++r; }
            if (r - l - 1 > max_len)
            {
                max_len = r - l - 1;
                start = l + 1;
            }
        }
    }
    if(start == -1)     // 当没有回文子串的时候,随便返回一个就行,为保证不会越界,返回第一个就行
        return string() + s[0];
    return s.substr(start, max_len);
}

    就是从左往右,以所有位置为中心进行扩展尝试,如果有两个连着一样的,以这两个并列为中心,向左右进行扩展尝试,找出最长的长度,以及最长回文子串的开头,这样利用substr就可以得到最长回文子串了(如果有两个以上连着相同的情况并不用考虑,因为三个可以看成1个扩展了一层,4个可以看成2个扩展了一层,and so on)。

方法二:DP  (beats 44.34%)

    一个简单的改进暴力算法的想法就是,比如“cabac”这种情况下,我们已经知道“aba”是回文的了,只需要判断左右两边是不是,就可以知道整个string是不是了(和中心扩展思想一样,但是需要O(N ^ 2)的空间)。也就是说比如我们设 dp[i][j] 为 true 表示 s 从 i 到 j 是回文的,那么要求就是 dp[i + 1][j - 1] 为 true,且 s[i] == s[j]。

    最开始我们知道 dp[i][i] 对所有 i 来说都成立,然后如果 s[i] == s[i + 1],那么 dp[i][i + 1] 就是true。

    比如 s 长度为 len,基于这两点就可以创建一个 len * len 大小的矩阵,并且对矩阵进行初始化,然后一步一步计算矩阵每个位置上的值,最后找出 j - i 最大的位置。直接照着网上的solution写的:

string longestPalindrome(string s)
{
    int n = s.length();
    int start = 0, max_len = 1;
    bool table[1000][1000] = {false};   // 这样初始化其实不行(只能给table[0][0]赋值),不过默认是false
    for (int i = 0; i < n; i++)
        table[i][i] = true;
    for (int i = 0; i < n - 1; i++)
    {
        if (s[i] == s[i+1])
        {
            table[i][i+1] = true;
            start = i;
            max_len = 2;
        }
    }
    for (int len = 3; len <= n; len++)
    {
        for (int i = 0; i < n - len + 1; i++)
        {
            int j = i + len - 1;
            if (s[i] == s[j] && table[i + 1][j - 1])    // 谁先谁后感觉区别不大
            {
                table[i][j] = true;
                start = i;
                max_len = len;
            }
        }
    }
    return s.substr(start, max_len);
}

方法三:Manacher's Algorithm  (beats 99.23%)

    做着题的时候感觉特别像字符串匹配的KMP算法,感觉这道题肯定有更加高效的算法,只不过是我不知道而已= =。

    果然,LeetCode这道题给出的Solution里提到了只需要O(N)时间(Linear Time)即可完成的算法,叫作“Manacher's Algorithm”。

    这个算法和我最开始的想法一样,就是在每个字符中插入一个特定字符,我想的是“-”,比如“aba”编程 “a-b-a”,这里就是用的“#”,没区别,但是跟我想的不一样的是,字符串的左右两端也扩充了,而且在两个又各加了一个控制符,为了控制便捷,比如“aba”,就变成了“^$#a#b#a#$”。这样做的好处就是,不需要再考虑是单独一个字符为中心,还是两个相同字符为中心,而是在插入“#”后,只需要考虑以单个字符为中心的情况。比如说第一方法中,如果“bb”是中心,那么在这个算法中是,“b#b” 中间的 “#” 为中心,如果是“b”为中心,那这个算法里也就是“b”为中心。

    这个不是这个算法的核心,这个算法的核心思想就是要跳过不必要的重复判断。

   算法的思想是,先对字符串进行上述处理,然后创建一个和新的字符串同样大小的数组,比如说叫P,P[i] 表示以第i位为中心的最长回文串的一半的长度,也可以理解为以这个圆为圆心的半径。直接用Solution里的原图看看数组的值:

比如说,“habcbap”,转换之后是“^$#h#a#b#c#b#a#p#$”

    算法的核心思想就是,可以看到中间“abacaba”是最长回文,当我们已经算到了 “c” 的位置,知道p[10] = 5(如果我没算错的话- -),那当我们算 “p” 这个位置的时候,发现“p”位置的下标减去“f”的下标,大于“c”位置(center)的值,也就是在以“c”为中心的最长回文串的外边,那么我们肯定是不知道什么对“p”位置的值有贡献的东西,只能用第一种方法,中心扩充,来计算这个位置的值。

    但是!如果我们要算 “c” 后边的 “b” 的值的时候,我们知道了这个 “b” 是处在 “c” 的回文子串之中的,所以如果它的下标是 i,那么它对应过去的下标 mirror_i 位置上一定也是b,这时候需要判断对应位置上的回文值,因为对应位置一定已经计算过了,所以通过镜像过去位置上的回文值计算当前位置的回文值,能够避免掉很多运算

    如果对应位置的回文长度不如 “c” 的回文长度长,那么当前位置的一定也就是那么长,因为是镜像的!下图就是实例:

    但是如果镜像位置上的回文长度超出了 “c” 辐射的范围,那么当前位置上的就是至少能够达到碰到 “c” 辐射范围的边缘。如下图所示。

    总结来说,就是在从左往右遍历整个字符串的过程中,通过已有信息,跳过不必要的判断,共有三种情况:

    1、要计算的位置在已知最大的回文串覆盖范围外 —— 只能通过中心扩展一点一点算,没有跳过计算;

    2、要计算的位置在已知最大回文串覆盖范围内,且镜像位置上的回文串被最大回文串包含 —— 不需要计算,直接 p[i] = p[mirror_i] 即可,跳过这个位置所有计算;

    3、要计算的位置在已知最大回文串覆盖范围内,但镜像位置上的回文串没有完全被最大回文串包含 —— 可以从最大回文串的边缘开始中心扩充,能跳过一部分计算。

    看懂了算法思想,代码就比较简单了:

// Transform S into T.
// For example, S = "abba", T = "^#a#b#b#a#$".
// ^ and $ signs are sentinels appended to each end to avoid bounds checking
string preProcess(string s)
{
    int n = s.length();
    if (n == 0) return "^$";
    string ret = "^";
    for (int i = 0; i < n; i++)
        ret += "#" + s.substr(i, 1);
    ret += "#$";
    return ret;
}
 
string longestPalindrome(string s)
{
    string T = preProcess(s);
    int n = T.length();
    int *P = new int[n];
    int C = 0, R = 0;                 // center, right
    for (int i = 1; i < n-1; i++)
    {
        int i_mirror = 2 * C - i;     // equals to i' = C - (i-C)
        P[i] = (R > i) ? min(R-i, P[i_mirror]) : 0;
        // Attempt to expand palindrome centered at i
        while (T[i + 1 + P[i]] == T[i - 1 - P[i]])
            P[i]++;
        // If palindrome centered at i expand past R, adjust center based on expanded palindrome.
        if (i + P[i] > R)
        {
            C = i;
            R = i + P[i];
        }
    }
    // Find the maximum element in P.
    int maxLen = 0;
    int centerIndex = 0;
    for (int i = 1; i < n-1; i++)
    {
        if (P[i] > maxLen)
        {
            maxLen = P[i];
            centerIndex = i;
        }
    }
    delete[] P;
    return s.substr((centerIndex - 1 - maxLen) / 2, maxLen);
}

     由于这个算法其实最关心的就是 right 边界,每次就是移动 R, 算法可以保证 finish in 2 * n steps(具体为什么是2*n还没有很明白),所以是 O(N) 的时间富足度。

方法四:后缀树(Suffix Trees)

    并没有看

猜你喜欢

转载自blog.csdn.net/Bob__yuan/article/details/81431484