[leetcode] 5.最长回文子串

[leetcode] 5.最长回文子串

DATE: 2018-12-27

  • 题目描述

给定一个字符串 s,找到 s 中最长的回文子串。可以假设 s 的最大长度为 1000.
示例1:

输入:"babad"
输出:"bad"
注意:"abd" 也是一个有效答案。

示例2:

输入:"cbbd"
输出:"bb"

回文

回文是一种文字现象,一个句子或单词,正着看和反着看是完全一致的,比如中文有“上海自来水来自海上”、“黄山落叶松叶落山黄”,英语有 'level', 'dad', 'mom', 'bob', 'noon' 等。

这道题的目的就是找出一个单词中最长的回文部分,如 banana 中的 anana 就是它的最长回文子串,如果认为一个单独的字母也算是回文的话,那么每个单词都有长度至少为 1 的回文子串。所以只要输入的字符串 s 不为空,函数一定会返回长度大于等于 1 的字符串,但是知道这点好像也没有对于解题没有什么帮助哦。。。

解法一:暴力求解法

初见这个问题,就会想到使用暴力法求解:遍历整个字符串,对于每个位置,执行一次 palindrome() 方法获取以该位置为中心的最长的回文字,不断迭代最长回文字串,则该字符串便是所求了。

public class BruteForce_origin {
    public String longsetPalindrome(String s) {
        if (s == null || s.length() == 0) {
        return "";
        }

        String res = "";

        for (int i = 0; i < s.length; i++) {
        String tmp = Palindrome(s, i);
            res = tmp.length() > res.length() ? tmp : res;
        }
        return res;
    }

    private static String Palindrome(String s, int center) {
        int i = center - 1;
        int j = center + 1;
        String res = "";
        while (i >= 0 && j < s.length() && s.charAt(i) == s.charAt(j)) {
            res = s.substring(i, j+1);
            i--;
            j++;
        }

        i = center;
        j = center + 1;
        while (i >= 0 && j < s.length() && s.charAt(i) == s.charAt(j)) {
            String tmp = s.substring(i, j+1);
            i--;
            j++;
            res = tmp.length() > res.length() ? tmp : res;
        }

        return res;
    }
}

这个方法理解起来没什么难度,相应地效率也非常低。在 LeetCode上提交的结果是:

在此方法中,我们不断创建新的 String 对象,这时非常耗时的,代码执行的三百毫秒中,至少有二百多毫秒在操作字符串。所以我们不难想出改进方案——使用数组,传递数组的引用,palindrom() 计算回文字开始与结束的下标,两个int类型的维护成本比操作字符串不知道低到哪里去了。

解法二:改进的暴力求解法

public class BruteForce {
    public String longestPalindrome(String s) {
        if (s == null || s.length() == 0) {
            return "";
        }

        char[] str = s.toCharArray();
        int[] res = {0, 0};
        for (int i = 0; i < str.length; i++) {
            int[] tmp = parlindrome(str, i); 
            res = (tmp[1] - tmp[0]) > (res[1] - res[0]) ? tmp : res;
        }

        return s.substring(res[0], res[1]+1);
    }

    // 获取回文子串的区间端点下标
    private static int[] parlindrome(char[] str, int center) {
        int i = center;
        int j = center;
        while (i >= 0 && j < str.length && str[i] == str[j]) {
            i--;
            j++;
        }
        i++;
        j--;
        int m = center, n = center + 1;
        while (m >= 0 && n < str.length && str[m] == str[n]) {
            i = m < i ? m : i;
            j = n > j ? n : j;
            m--;
            n++;
        }

        return new int[] {i, j};
    }
}

不出所料,这段代码的效率提高的不是一星半点儿:

执行时间缩短了一个数量级,与方法一的代码对比就不难看出对象的操作和直接操作基本数据类型在时间上的差别了。

解法三:马拉车算法

这个“马拉车”算法名字很奇怪吼,是哪位马车夫在工作之余喝茶时一拍脑袋想出的吗?并不是这样的,只是因为它的发明者名叫 Manacher,算法便被命名为 Manachers algorithm,音译成中文后就变成了奇怪的“马拉车”了。

你若有兴趣,可以看看最大回文子串的维基页面,里面就有讲到这个马拉车算法,我也是从这里学来的。要是得我解释得太不清楚,你也可以读一读这个页面。

奇数还是偶数

回文字有一个比较恼人的特点,就是当从中点开始向两边遍历时,偶数和奇数长度回文的情况是不同的,必须得为两种情况分别编程,去扣边界。比如,对于 s = "bob" 中点的下标是 1,从中间向两边扩展就是 s[0] == s[1];而对于 s = "noon",中点下标是 1 和 2,两个都是中点,向两边扩展就变成了 s[0] == s[3]。解法一和二中 palindrome() 方法执行两轮迭代就是处于这个原因——我们无法知道在 i
处的回文字长度到底是偶数还是奇数,所以必须得假设两种情况分别成立,计算出两个长度,取最大值作为返回值。

那么有没有什么可以统一两种情况的方法呢?答案自然是有的,给出答案的人就是我们这位“马拉车”大佬了,他给出的方法是扩张字符串s,在每个字符两侧加上占位符,在这里我们使用 + 作为占位符,这样 s 就发生了一点微妙的变化——当 s = bob(奇数长度)时,加上占位符后 s = +b+o+b+,它是关于 s[3] => o 对称的;而当 s = noon(偶数长度)时,加上占位符 s = +n+o+o+n+,它是关于 s[4] => +对称的。就是这样,加上占位符之后,s 的长度不论奇偶,都有了唯一确定的中点,也就是说, s 的长度变成了奇数。这样,我们就可以合并两种情况。

镜像 -> 算一半

回文最重要的性质就是——对称,利用这一点,我们不难推出回文中一个位置 i 上以 i 为中心的回文子串的长度和以回文中心 c 为轴 i 的对称点 2*c-i 处相同。

我们可以利用此特性减少代码的工作量,只需要借助一个辅助数组记录 s 中对应下标的回文长度。

有这两个工具,我们的代码就没什么想不通的了。

开始撸代码

public class ManachersAlgorithm {
    public static String longestPalindrome(String s) {
        if (s == null || s.length() == 0) {
            return "";
        }
        char[] schar = addBoundaries(s.toCharArray());
        int[] p = new int[schar.length];
        int c = 0;
        int r = 0;
        int i = 0, j = 0;
        for (int n = 1; n < schar.length; n++) {
            // 下面这段判断语句是整条代码最核心的部分,
            // 能理解它,整个算法就不是问题。
            if (n > r) {
                p[n] = 0;
                i = n - 1;
                j = n + 1;
            } else {    // n <= r
                int n1 = 2 * c - n;
                if (p[n1] < (r - i - 1)) {
                    p[n] = p[n1];
                    i = -1;
                } else {    // p[n1] >= (r - i - 1)
                    p[n] = r - n;
                    // r + 1 关于 i 的对称点
                    i = 2 * n - r - 1;
                    j = r + 1;
                }
            }

            while (i >= 0 && j < p.length && schar[i] == schar[j]) {
                p[n]++;
                i--;
                j++;
            }

            if (i + p[n] > r) {
                r = n + p[n];
                c = n;
            }
        }

        int len = 0;
        c = 0;
        for (int n = 0; n < p.length; n++) {
            if (p[n] > len) {
                len = p[n];
                c = n;
            }
        }

        char[] res = Arrays.copyOfRange(schar, c - len, c + len + 1);

        return new String(removeBoundaries(res));    
    }

    private static char[] addBoundaries(char[] str) {
        if (str.length == 0) {
            return new char[]{'+', '+'};
        }

        char[] res = new char[str.length*2 + 1];
        int p = 0;
        res[p++] = '+';
        for (int i = 0; i < str.length; i++) {
            res[p++] = str[i];
            res[p++] = '+';
        }
        return res;
    }

    private static char[] removeBoundaries(char[] str) {
        if (str == null || str.length < 3) {
            return new char[] {};
        }

        char[] res = new char[(str.length - 1) / 2];
        for (int i = 0; i < res.length; i++) {
            res[i] = str[i*2 + 1];
        }

        return res;
    }
}

如果对于说这段代码不太好理解,你可以使用 print 大法。

提交 LeetCode 运行情况为

虽然是 18 ms,看起来比解法二没快多少,但是要知道,这个算法的复杂度是 O(n) 的,和之前两个算法不是一个量级的。

参考信息:

posted @ 2018-12-27 21:27  Cheehool  阅读(330)  评论(0编辑  收藏  举报