palindrome是指一個字串的 string = reverse(string)
而longest palindromic substring則是指找出一個字串中,substring是palindrome的最長substring
這個問題brute force不難想,就是把所有的substring 列出來,檢查是不是palindrome,但是這樣worse case會是O(n^3),因為substring有O(n^2)個,檢查palindrom要O(n)
比較常見的做法是中心點展開法,也就是選取一個點,然後展開看最大可以展到多少長度

這樣的time complexity為O(n^2),共n個字元做展開,每個字元展開worst case O(n), total O(n^2)
事實上,有更好的方法Manacher’s algorithm time complexity是O(n),可以參考wiki上的說明 https://en.wikipedia.org/wiki/Longest_palindromic_substring#Manacher’s_algorithm
但是Wiki上的說明實在是太難理解了,所以這邊做個整理說明,並給出一些可能的實現
Manacher’s algorithm中文又有人叫馬拉車算法,本質上還是中心點展開法,但是差異在Manacher算法中會把之前走過的展開的資訊做個紀錄,在後面的展開中直接略去從頭開始展開的步驟,直接將整個演算法步數降到O(n)
在處理palindrome時,為了要避免分開處理奇數長度palindrome 或是偶數長度palindrome,一個常見的處理方式是在字串中插入delimeter

用什麼delimiter都可以,只要不要跟原始string的char衝突就好,加了delimiter後,整個字串的長度一定是奇數,且palindrome也一定是奇數,所以不用擔心要特別偵測偶數的palindrome(偶數palidrome是偵測s[i] == s[i+1],再以i, i+1往兩側展開)
為什麼加了delimiter就保證奇數呢?可以這樣想: N個字元的字串,有N+1個空間,插入delimeter就是N+N+1 = 2N+1 保證奇數,並且觀察以上abba, aba兩個字串的palindrome,可以發現delimeter本身是對稱的,所以也是符合把palindrome空隙插滿,亦即2m+1的情況,因此也是奇數
這邊先看一下其中一個實現方式,此方法參考了 https://www.geeksforgeeks.org/manachers-algorithm-linear-time-longest-palindromic-substring-part-4/ 的思路來實現
std::string lps(const std::string& s)
{
int len = s.size();
int N = 2 * len + 1; //with delimiter
std::vector<int> dp(N);
int C = 1; //current center
int R = 1; //palindrome right index of current center
int maxLen = 1; // max palindrome radius (inclusive)
int maxI = 0; //center index of max palindrome length
for(int i = 1; i < N; i++)
{
int mirrorI = C - (i-C);
int delta = R - i;
if(delta>0)
{
//based on previous information, calculate current palindromic string radius
//also need to take account of information which is out of bound
dp[i] = std::min(dp[mirrorI], delta);
}
while(( i + dp[i] < N - 1) &&
( i - dp[i] > 0) &&
( ((i + dp[i] + 1) % 2 == 0) ||
(s[(i+dp[i]+1)/2] == s[(i-dp[i]-1) / 2])))
{
dp[i]++;
}
//update max radius
if(dp[i] > maxLen)
{
maxLen = dp[i];
maxI = i;
}
//update center
if(i + dp[i] > R)
{
C = i;
R = i + dp[i];
}
}
int index = (maxI - maxLen)/2;
return s.substr(index, maxLen);
}
先理解一下dp內代表的意義

dp中存了走訪的資訊,代表該index [i-j, i+j] 是以該index為中心,最長的palindrome,可以看到圖中的 a dp = 9,代表 該字元 a 往右邊9個與往左邊9個皆相同,也可以發現這個數字對應原來沒有delimiter的字串,剛好以a為中心整個palindrome長度就是9
如果我們可以得到整個dp的值,基本上答案就出來了
首先看一個範例,以下圖的箭頭處為例,該處字元a 對應的dp值應該是多少呢?

最簡單的想法就是往右走往左走,看看能比對到最長的長度,這樣就要O(n),n個字元就要O(n^2),Manacher的算法說,可以省略一些步驟,方框處是以c為中心點找到最長的palindrome,左右7 chars都對稱,這裡面隱含了一些可利用的資訊

如何找出隱含的資訊,仔細觀察在虛線c的位置,已經找到左右7個對稱,代表在某種程度,左邊看到的世界和右邊看到的世界是相同的, 圖中藍色的數字是已經算好的dp值,下排紅色的數字是從mirror看到的數字,因為對稱,代表在c的方圓內,可以保證兩方在碰到藍色框之內是相同的,藍色框外的世界不知道,這裡我們可以看到至少? 地方的longest match = 3(因為3仍然在方框內)
方框外的資訊就不知道,但是至少我們可以從方框外開始比

也就是直接跳了3步,直接從j = 4開始比,透過比較完可以知道方框往右可以延伸的地方,必要時更新new center,和new radius

看起來很繁瑣,但是只要掌握住基本的概念,利用可利用的資訊,超出邊界的不能用,邊界外的資訊再另外算的原則,就可以比較好理解

我們再來看圖中?處的值應該是多少,看mirror,應該是7,但是如果仔細看,其實對應的位置走七步是超出藍框邊界的(下圖中綠色框處),因此必須做調整,只能保證到藍框處,也就是5,接著直接從5開始比,比失敗就設定dp = 5

關於time complexity,乍看之下,上面的作法是省略了一些比較,但真的能把n^2降到n嗎?關鍵在於藍框處,會需要做超過一步的比較就代表以新的點為中心,沒有藍框以內的資訊可以用,因此新的比較是從藍框外開始比,如果有比到代表會擴展藍色框的右邊,而藍色框是一路往右的,因此整體的time complexity是O(n)
test case: 這個test case長度約一千多,如果使用一般的做法n^2約做1 million steps

實測step數 Manacher算法對上面的字串 len =1450, steps = 2897
順便附上中心點展開法的一個參考解答(來自於leetcode其他人的submission)
std::string lps(const std::string& s)
{
if (s.size() <= 1) return s;
int maxIdx = 0;
int maxLen = 1;
int i = 0;
while (i < s.size()) {
int start = i;
int end = i;
// expand the window from the end if it's an even palindrome
while (end + 1 < s.size() && s[end] == s[end + 1]) {
end++;
}
i = end + 1;
// expand the window from both sides until it's not longer a palindrome
while (start - 1 >= 0 && end + 1 < s.size() && s[start - 1] == s[end + 1]) {
start--, end++;
}
int currLen = end - start + 1;
if (currLen > maxLen) {
maxIdx = start;
maxLen = currLen;
}
}
return s.substr(maxIdx, maxLen);
}
以上面test case測試,len = 1450,steps = 524902
另外也放上根據wiki 參考,直接使用delimiter作法的實現,和最一開始的實作差別是直接使用加工過的string做計算,不用額外處理index
std::string lps(const std::string& s)
{
int len = s.size();
int N = 2 * len + 1;
std::vector<int> dp(N);
std::string s2(1, '|');
for(auto c: s)
{
s2.push_back(c);
s2.push_back('|');
}
int C = 0;
int R = 0;
for(int i = 1; i < N; i++)
{
if(i > R)
{
C = i;
R = i;
}
else
{
int mirrorI = C - (i - C);
dp[i] = std::min(dp[mirrorI], R - i);
}
int j = dp[i] + 1;
while((i-j >= 0) && (i+j < N) && (s2[i-j] == s2[i+j]))
{
j++;
}
dp[i] = j - 1;
if(i + dp[i] > R)
{
C = i;
R = i + dp[i];
}
}
auto it = std::max_element(dp.begin(), dp.end());
int maxLen = *it;
int index = it - dp.begin();
return s.substr((index - maxLen) / 2, maxLen);
}
總結來說,Manacher算法的核心在如何重複利用先前發掘的dp資訊,並且需要標定藍框的位置,藍框是使得Manacher可以將步數降到O(n)的關鍵,對應程式碼就是C, R變數(center, radius)
Manacher算法其實理解了後並不難,不過理解本身需要沉下心來花一點時間,不能抱著速成心態看。