LeetCode題集-5 - 最長迴文子串之馬拉車(二)

IT规划师發表於2024-12-10

書接上回,我們今天繼續來聊聊最長迴文子串的馬拉車解法。

題目:給你一個字串 s,找到 s 中最長的迴文子串。

01、中心擴充套件法最佳化-合併奇偶處理

俗話說沒有最好只有更好,看著O(n^2)的時間複雜度,想想應該還有更優的方案吧,答案是肯定的,馬拉車法就可以做到時間和空間複雜度都是O(n)。

其實無論什麼演算法都是為了解決某種問題而產生的,因此我們還是循序漸進的來最佳化中心擴充套件法最終得到馬拉車法。

我們知道在中心擴充套件法中有一個很明顯的問題:迴文串長度奇偶性需要不同的處理方式,並且在計算之前我們也不知道這個迴文串到底是奇還是偶,導致每次我們都需要同時求出奇偶兩種情況下的迴文串並取最大的那個,因此我們需要首先解決奇偶性導致的差異化處理問題。

如果要解決這個問題可能要從兩個方面入手:其一從外部下手找到一個公式統一奇偶性,其二從內部下手修改自身使其變的統一。

關於第一點,因為本題的特點是根據奇偶性做不同的處理,而統一的公式更偏向一個值可以是奇或偶最後得到統一的結果,比如7和8,(7-1)/2=3,(8-1)/2=3,因此第一點並不適合本題。

關於第二點,奇數個字元之間有偶數個空格,偶數個字元之間有奇數個空格,而奇數加偶數還是奇數,也就是如果我們把這些空格考慮進來,奇偶性就統一了,那麼接下來還有分析一下這個空格引入後是否會對原迴文串特性產生影響,其實並沒有影響,對於奇數串相當於中心元素兩邊各多了一個空格還是對稱的,對於偶數相當於中心的兩個元素之間加了一個空格結果也還是對稱的,因為空格比較特殊,我們選擇一個其他特殊符號比如[#],因此這個方案具有可行。

這個方案還有最後一個小問題,就是怎麼計算迴文串長度,即實現透過處理後的字串計算出實際的字元長度。

比如上圖中原奇數迴文串長度是5,處理後長度是11,原偶數迴文串長度是6,出來後是13,如果我們不考慮中心元素i,則迴文串實際長度正好等於處理後的一半,即原迴文串長度=(處理後迴文串長度 - 1)/2,我們先給這個值取個名字方便後面交流,叫回文串半徑。

到這裡最佳化方案就是確定了,解決了奇偶性問題,解決了字串處理後轉換問題,下面我們看看具體實現程式碼。

//中心擴散法-合併奇偶
public static string CenterExpandMergeOddEven(string s)
{
    //如果字串為空或只有一個字元,直接返回該字串
    if (s == null || s.Length < 1)
    {
        return "";
    }
    // 預處理字串,插入#字元以統一處理奇偶迴文串
    //比如:s="cabac"轉化為s="#c#a#b#a#c#"
    var tmp = $"#{string.Join("#", s.ToCharArray())}#";
    //記錄最長迴文子串的起始位置和最大長度
    var startIndex = 0;
    var maxLength = 0;
    //從左到右遍歷處理過的字串,求每個字元的迴文串半徑
    for (var i = 0; i < tmp.Length; ++i)
    {
        //計算當前以i為中心的迴文串半徑
        var radius = PalindromicRadius(tmp, i, i);
        //如果當前計算的半徑大於maxLength,就更新startIndex
        if (radius > maxLength)
        {
            startIndex = (i - radius) / 2;
            maxLength = radius;
        }
    }
    //根據startIndex和maxLength,從原始字串中擷取返回
    return s.Substring(startIndex, maxLength);
}
//迴文串半徑
public static int PalindromicRadius(string s, int leftIndex, int rightIndex)
{
    //左邊界大於等於首字元,右邊界小於等於尾字元,並且左右字元相等
    while (leftIndex >= 0 && rightIndex < s.Length && s[leftIndex] == s[rightIndex])
    {
        //從中心往兩端擴充套件一位
        //向左擴充套件
        --leftIndex;
        //向右擴充套件
        ++rightIndex;
    }
    //返回迴文串半徑(注意本來應該是(rightIndex - leftIndex + 1)/2,
    //但是滿足條件後leftIndex、rightIndex又分別向左和右又各擴充套件了一位,
    //因此需要把這兩位減掉,因為中心元素不在計算返回還需要減掉一位,
    //因此(rightIndex - leftIndex + 1 - 2 - 1)/2
    //所以最後公式為(rightIndex - leftIndex - 1)/2)  
    return (rightIndex - leftIndex - 2) / 2;
}

時間複雜度:O(n2),由於每遍到一個字元,都需要往左/右進行探測,所以是O(n2)。

空間複雜度:O(1)。

因此這次最佳化效能上並沒有任何提升,只是處理方式上做了最佳化,這一步並不是白做,而是為了下面馬拉車法做前期準備。

02、中心擴充套件法再最佳化(馬拉車法)

再暴力破解法最佳化中我們用到了經典的以空間換時間的做法,把已經計算過的子字串結果儲存下來減少了不必要的計算,同樣的我也可以用類似的思想來最佳化中心擴充套件法。

暴力破解法用了迴文串對稱性由外向內判斷是否是迴文串,中心擴充套件法用了迴文串對稱性由內向外判斷是否是迴文串,而這兩種方法都是應用了對稱性去算,那麼我們能否直接用迴文串的對稱性直接判斷是否為迴文串呢?答案是肯定的,下面我們用圖例來說明其中原理。

在上面的合併奇偶處理中,我們知道處理後迴文串半徑長度就是我們最終迴文串長度。如果我們把已經計算過的迴文串半徑儲存下來,後面需要的時候就可以直接拿過來用了。

如上圖,在迴文串半徑行中,綠色背景表示已經計算完迴文串半徑的字元,並中心擴充套件是從左往右計算的,此時計算完了s[9]即字元c處,那麼我們想象一下此時如何計算索引為10即s[10]元素的迴文串半徑呢?s[11]、s[12]呢?

回想一下上文提到的對稱性,如果我們以當前位置s[9]為中心點,那麼其右半徑和其左半徑是對稱的,也就是說相對應的元素應該是性質相同的,可以合理猜測s[10]應該和s[8]的迴文串半徑一致也為0,同理猜測s[11]=s[7]=1,s[12]=s[6]=2。

當然這並不絕對,我們只是想拿來說明我們可以利用對稱性,把其對稱點已經計算過的結果利用上,雖然上面的s[10],s[11],s[12]三個值是對的,但描述並不準確,因為存在s[10]>s[8]的情況。如下圖:

如上圖,當上一次處理完s[7],可得其迴文串半徑為3,此時開始計算s[8],使用對稱性可得s[8]>=s[6],因此我們在計算以s[8]為中心向兩邊擴充套件查詢回文串時不需要再以s[8]為起點了,而是根據s[6]=2,我們直接跳躍2位,左邊從s[6],右邊從s[10],開始向兩邊擴充套件查詢以s[8]為中心的實際迴文串長度,最後計算可能為4,這一步正是整個演算法的核心所在。

因此我們可以把這個演算法分為兩種情況:

(1)當前位置大於當前已探測右側最遠位置,則直接使用中心擴充套件方法查詢回文串;

(2)當前位置小於等於當前已探測右側最遠位置,則根據其對稱位置已計算結果,進行直接選擇跳過部分位再開始使用中心擴充套件方法查詢回文串;

當然起始位置、已探測最右側位置、中心點、最大長度怎麼選,什麼時候更新,跳過多少位後再計算,這些都是細節部分了,我們下面直接看程式碼。

//馬拉車法
public static string Manacher(string s)
{
    if (s == null || s.Length == 0)
    {
        return "";
    }
    var tmp = $"#{string.Join("#", s.ToCharArray())}#";
    //rightIndex表示目前計算出的最右端範圍,rightIndex和左邊都是已探測過的
    var rightIndex = 0;
    //centerIndex最右端位置的中心對稱點
    var centerIndex = 0;
    //記錄最長迴文子串的起始位置和最大長度
    var startIndex = 0;
    var maxLength = 0;
    //radiusPoints陣列記錄所有已探測過的迴文串半徑,後面我們再計算i時,根據radiusPoints[iMirror]計算i
    var radiusPoints = new int[tmp.Length];
    //從左到右遍歷處理過的字串,求每個字元的迴文串半徑
    for (var i = 0; i < tmp.Length; i++)
    {
        //根據i和right的位置分為兩種情況:
        //1、i<=right利用已知的資訊來計算i
        //2、i>right,說明i的位置時未探測過的,只能用中心探測法
        if (i <= rightIndex)
        {
            // 找出i關於前面中心的對稱
            var iMirror = 2 * centerIndex - i;
            //這句是關鍵,不用再像中心探測那樣,一點點的往左/右擴散,
            //根據已知資訊減少不必要的探測,必須選擇兩者中的較小者作為左右探測起點
            var minRadiusLength = Math.Min(rightIndex - i, radiusPoints[iMirror]);
            //這裡左右-1和+1是因為對稱性可以直接跳過相應的迴文串半徑
            radiusPoints[i] = PalindromicRadius(tmp, i - minRadiusLength - 1, i + minRadiusLength + 1);
        }
        else
        {
            //i落在rightIndex右邊,是沒被探測過的,只能用中心探測法
            //這裡左右-1和+1,是因為中心點字元肯定是迴文串,可以直接跳過
            radiusPoints[i] = PalindromicRadius(tmp, i - 1, i + 1);
        }
        //大於rightIndex,說明可以更新最右端範圍了,同時更新centerIndex
        if (i + radiusPoints[i] > rightIndex)
        {
            centerIndex = i;
            rightIndex = i + radiusPoints[i];
        }
        //找到了一個更長的迴文半徑,更新原始字串的startIndex位置
        if (radiusPoints[i] > maxLength)
        {
            startIndex = (i - radiusPoints[i]) / 2;
            maxLength = radiusPoints[i];
        }
    }
    //根據start和maxLen,從原始字串中擷取一段返回
    return s.Substring(startIndex, maxLength);
}
//迴文串半徑
public static int PalindromicRadius(string s, int leftIndex, int rightIndex)
{
    //左邊界大於等於首字元,右邊界小於等於尾字元,並且左右字元相等
    while (leftIndex >= 0 && rightIndex < s.Length && s[leftIndex] == s[rightIndex])
    {
        //從中心往兩端擴充套件一位
        //向左擴充套件
        --leftIndex;
        //向右擴充套件
        ++rightIndex;
    }
    //返回迴文串半徑(注意本來應該是(rightIndex - leftIndex + 1)/2,
    //但是滿足條件後leftIndex、rightIndex又分別向左和右又各擴充套件了一位,
    //因此需要把這兩位減掉,因為中心元素不在計算返回還需要減掉一位,
    //因此(rightIndex - leftIndex + 1 - 2 - 1)/2
    //所以最後公式為(rightIndex - leftIndex - 1)/2)  
    return (rightIndex - leftIndex - 2) / 2;
}

時間複雜度:O(n),可能比較難以理解的是為什麼for巢狀了while還是O(n)的時間複雜度?因為首先每個字元最多隻會進行一次擴充套件,其次當進行一次新的擴充套件後,當前位置到最新的有邊界這部分可以透過對稱性得到而不需要再次擴充套件,因此while總的操作次數不會大於n,再加上預處理字串n,以及初始化的輔助陣列2n+1,因此整體還是O(n)。

空間複雜度:O(n),我們需要 O(n) 的空間記錄每個位置的半徑。

這就是馬拉車演算法的整個過程,我們再來總結一下,馬拉車演算法是對中心擴充套件法的最佳化,解決了其兩個問題:其一奇偶統一處理,其二利用對稱性減少重複計算。其中第二點的重點是透過對稱性不用每次擴充套件都從中心點出發,而是直接跳過已計算的部分位。

:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner

相關文章