1. 程式人生 > >[leetcode] 5.最長迴文子串

[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)$ 的,和之前兩個演算法不是一個量級的。

參考資訊:
* leetcode
* Wikipedia