題目:給你一個字串 s,找到 s 中最長的迴文子串。
這一題作為中等難度,常規解法對於大多數人應該都沒有難度。但是其中也有超難的解決辦法,下面我們就一起由易到難,循序漸進地來解這道題。
01、暴力破解法
對於大多數題目來說,在不考慮效能的情況下,暴力破解法常常是最符合人的思維習慣的。
比如這道題,求一個字串中最長的迴文子串,那麼我們只需要把字串中所有可能的子字串都判斷一下是不是迴文串,並找出長度最長的不就行了嘛。
這裡需要三層迴圈,第一層和第二層迴圈組織出所有可能的子字串,第三層迴圈判斷是否為迴文串。
而判斷一個字串是否為迴文串,也很簡單,只需要從字串兩端開始判斷首尾字元是否相等,如果相等繼續向字串中心方向前進繼續比較下一個首尾字元是否相等,直到比較完所有字元,如果都相等則為迴文串。其核心思想是由外向內,逐一比較。
具體程式碼如下:
//暴力破解法
public static string BruteForce(string s)
{
var result = string.Empty;
var max = 0;
var len = s.Length;
//從第一個字元開始遍歷,作為起始字元
for (var i = 0; i < len; i++)
{
//從第二個字元來開始遍歷,作為結束字元
for (var j = i + 1; j <= len; j++)
{
//取出[i,j)字串,即包含i,不好含j,為臨時字串
var temp = s[i..j];
//如果臨時字串是迴文字串,且臨時字串長度大於目標字串長度
if (IsPalindromic(temp) && temp.Length > result.Length)
{
//則更新目標字串為當前臨時字串
result = temp;
max = Math.Max(max, j - i - 1);
}
}
}
return result;
}
//判斷字串是否為迴文串
public static bool IsPalindromic(string s)
{
var len = s.Length;
//遍歷字串的一半長度
for (var i = 0; i < len / 2; i++)
{
//如果對稱位置不同,則不為迴文串
if (s[i] != s[len - i - 1])
{
return false;
}
}
return true;
}
時間複雜度:兩層for迴圈O(n^2),for 迴圈裡邊判斷是否為迴文串O(n),所以時間複雜度為O(n^3)。
空間複雜度:O(1),常數個變數。
02、暴力破解法最佳化(動態規劃法)
要想最佳化暴力破解法,我們要先找到它到底有什麼問題。它的時間複雜度之所以這麼高,是因為有大量的重複計算,可能文字描述不夠直觀,下面我們先用二維表展示一個字串的所有子字串的組合情況,然後再在這個表中看判斷是否為迴文串時哪些子字串被重複判斷。
如上圖在字串abcde中,在判斷其子字串bcd是否是迴文串時,作為字串bcd已經計算過了,同樣的其子字串c,作為字串也已經計算過了,其他的沿著箭頭方向都是表示存在重複計算的地方。
到這裡我們的最佳化方案就有了,我們可以把已經計算過的存下來,這樣下次用到的時候直接拿過來用而不用再計算了。
既然我們透過圖就發現了一些規律,我們不妨再深入思考一下,為什麼會這樣?
如果我們基於暴力破解法中判斷是否為迴文串的演算法定義迴文串,那麼可得:
P(i,j)=P(i+1,j-1)&&S[i]==S[j]
可以理解為如果一個字串是迴文串,那麼去掉首尾字元後子字串依然是迴文串。反過來如果子字串是迴文串並且其首尾一個字元相等,那麼這個字串整體也是迴文串。
我們再對上圖斜對角上加些輔助線,如果我們按所有子字串的長度分類,則會發現長度為3的依賴長度為1的,長度為5的依賴長度為3的,長度為4的依賴長度為2的。如下圖:
長度為1本身就是迴文串,長度為2的如果兩個字元相等則為迴文串,那麼所有長度大於等於3的都可以透過長度為1和2的計算出來。
到這裡整個演算法思路就出來了:先計算長度為1和2的子字串並存入二維陣列,然後基於此二維陣列繼續計算長度為3、4、5……。
具體程式碼如下:
//動態規劃
public static string DynamicProgramming(string s)
{
var length = s.Length;
//判斷該組合是否是迴文字串,行為起始點,列為結尾點
var dp = new bool[length, length];
//最長迴文字串,初始為0
var result = string.Empty;
//從迴文長度為1開始判斷,到字元長度n為止
for (var len = 1; len <= length; len++)
{
for (var startIndex = 0; startIndex < length; startIndex++)
{
//結束索引 = 起始索引 + 間隔(len - 1)
var endIndex = startIndex + len - 1;
//結束索引超出字串長度,結束本次迴圈
if (endIndex >= length)
{
break;
}
//迴文字串的公式就是子字串也是迴文,並且當前起始字元和結束字元相等,
//所以得出公式 dp[startIndex+1,endIndex-1] && s[startIndex] == s[endIndex]
//其中迴文長度為1和2兩種特殊情況需要單獨處理,其特殊性在於他們不存在子字串
//迴文長度為1時,自身當然等於自身
//迴文長度為2時,起始字元和結束字元是相鄰的,只要相鄰的字元相等就可以
dp[startIndex, endIndex] = (len == 1 || len == 2 || dp[startIndex + 1, endIndex - 1]) && s[startIndex] == s[endIndex];
//當前字串是迴文,並且當前迴文長度大於最長迴文長度時,修改result
if (dp[startIndex, endIndex] && len > result.Length)
{
result = s.Substring(startIndex, len);
}
}
}
return result;
}
時間複雜度:O(n^2),即為兩層for迴圈組成的所有子字串情況。
空間複雜度:O(n^2),即儲存已計運算元字串結果需要的空間。
這個演算法還有一個專有名稱:動態規劃,我們這裡之所以沒有突出去講,是想把整個解題思路展現出來,掌握好了基礎解題能力,我們才能做總結,而這個解法的總結就可以概括為動態規劃,這是一種通用的思想,後面會經常用到。
03、中心擴充套件法
上面的最佳化雖然時間複雜度降下來了,但是空間複雜度上升了,我們繼續想想有什麼其他方法呢?
上面的方法判斷是否為迴文串的核心思想是由外向內,那我們是否可以換個思路——從內向外呢?這就是中心擴充套件法的核心思想。
在暴力破解法中,我們需要兩層迴圈表示任何一種子字串排列,而且中心擴充套件法核心思想是透過一箇中心點向兩邊擴充套件也就天然只需要一層迴圈即可表示探測所有字元的情況。
由中心往兩邊擴充套件是對稱的,而回文串長度可以是奇數也可以是偶數,因此我們在中心擴充套件時就需要分奇偶兩種情況來處理。
到這裡我們可以大致梳理一下整體邏輯了:
(1)依次迴圈處理字串的每個字元,向兩邊擴充套件;
(2)每次擴充套件分奇偶兩種情況處理;
(3)計算出最大長度並保留;
(4)重複(2)、(3)直至所有字元處理完成;
具體實現程式碼如下:
//中心擴散法
public static string CenterExpand(string s)
{
//如果字串為空或只有一個字元,直接返回該字串
if (s == null || s.Length < 1)
{
return "";
}
//記錄最長迴文子串的起始位置和結束位置
var startIndex = 0;
var endIndex = 0;
//遍歷每個字元,同時處理迴文字串長度為奇偶的情況,
//即以該字元或該字元與其下一個字元之間為中心的迴文
for (var i = 0; i < s.Length; i++)
{
//獲取迴文字串長度為奇數的情況,
//即以當前字元為中心的迴文長度
var oddLength = PalindromicLength(s, i, i);
//獲取迴文字串長度為偶數的情況,
//即以當前字元和下一個字元之間的空隙為中心的迴文長度
var evenLength = PalindromicLength(s, i, i + 1);
//取兩種情況下的最長長度
var maxLength = Math.Max(oddLength, evenLength);
//如果找到更長的迴文子串,更新起始位置和長度
if (maxLength > endIndex - startIndex)
{
//重新計算起始位置
startIndex = i - (maxLength - 1) / 2;
//重新計算結束位置
endIndex = i + maxLength / 2;
}
}
//返回最長迴文子串
return s[startIndex..(endIndex + 1)];
}
//從中心向外擴充套件,檢查並返回迴文串的長度
public static int PalindromicLength(string s, int leftIndex, int rightIndex)
{
//左邊界大於等於首字元,右邊界小於等於尾字元,並且左右字元相等
while (leftIndex >= 0 && rightIndex < s.Length && s[leftIndex] == s[rightIndex])
{
//從中心往兩端擴充套件一位
//向左擴充套件
--leftIndex;
//向右擴充套件
++rightIndex;
}
//返回迴文串的長度(注意本來應該是rightIndex - leftIndex + 1,
//但是滿足條件後leftIndex、rightIndex又分別向左和右又各擴充套件了一位,
//因此需要把這兩位減掉,所以最後公式為rightIndex - leftIndex - 1)
return rightIndex - leftIndex - 1;
}
時間複雜度:O(n^2)。
空間複雜度:O(1)。
此方法雖然時間複雜度沒變,但是空間複雜度大大降低。這也給了我們繼續最佳化的動力。
下一章節我們將詳細講解次題的馬拉車解法。
注:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner