回文串就是一個正讀和反讀都一樣的字符串,比如“level”或者“noon”等等就是回文串。回文子串,顧名思義,即字符串中滿足回文性質的子串。比如輸入字符串 "google”,由於該字符串里最長的對稱子字符串是 "goog”,因此輸出4。
1.問題解決的基本方法
分析:可能很多人都寫過判斷一個字符串是不是對稱的函數,這個題目可以看成是該函數的加強版。
要判斷一個字符串是不是對稱的,不是一件很難的事情。我們可以先得到字符串首尾兩個字符,判斷是不是相等。如果不相等,那該字符串肯定不是對稱的。否則我們接着判斷里面的兩個字符是不是相等,以此類推。
#include<iostream> using namespace std; //字符串是否對稱 bool isAym(char *cbegin, char *cend) { if(cbegin == NULL || cend ==NULL || cbegin > cend) { return false; } while(cbegin<cend) { if(*cbegin!=*cend) { return false; } cbegin++; cend--; } return true; }
現在我們試着來得到對稱子字符串的最大長度。最直觀的做法就是得到輸入字符串的所有子字符串,並逐個判斷是不是對稱的。如果一個子字符串是對稱的,我們就得到它的長度,最后經過比較,就能得到最長的對稱子字符串的長度了。
//O(n*n*n)復雜度的子字符串 int getMaxSym(char * str) { if(str == NULL) return 0; int maxlength = 0, strlength = 0; char *pFirst = str; char *strEnd = str + strlen(str); while(pFirst < strEnd) { char *pLast = strEnd; while(pLast > pFirst) { if(isAym(pFirst, pLast)) { strlength = pLast - pFirst + 1; if(strlength > maxlength) { maxlength = strlength; } } pLast --; } pFirst ++; } return maxlength; }
上述方法的時間效率:由於需要兩重while循環,每重循環需要O(n)的時間。另外,我們在循環中調用了IsSym,每次調用也需要O(n)的時間。因此整個函數的時間效率是O(n^3)。
假設輸入:abcddcba,按照上述程序,要分割成 'abcddcba’, 'bcddcb’, 'cddc’, 'dd’…等字符串,並對這些字符串分別進行判斷。不難發現,很多短子字符串在長些的子字符串中比較過,這導致了大量的冗余判斷,根本原因是:對字符串對稱的判斷是由外向里進行的。
換一種思路,從里向外來判斷。也就是先判斷子字符串(如dd)是不是對稱的。如果它(dd)不是對稱的,那么向該子字符串兩端各延長一個字符得到的字符串肯定不是對稱的。如果它(dd)對稱,那么只需要判斷它(dd)兩端延長的一個字符是不是相等的,如果相等,則延長后的字符串是對稱的。
2.改進的解決方案
根據從里向外比較的思路寫出如下代碼:
//改進后的程序 int getMaxSym2(char * str) { if(str == NULL) return 0; int maxlength = 0; char *ptag = str; while(*ptag !='\0') { //奇數子字符串 char *left = ptag - 1; char *right = ptag + 1; int oddlenght = 1; while(left >= str && *right != '\0' && *left == *right) { left--; right++; oddlenght += 2; } if(oddlenght > maxlength) { maxlength = oddlenght; } //偶數子字符串 left = ptag; right = ptag + 1; int evenlength = 0; while(left >= str && *right != '\0' && *left == *right) { left--; right++; evenlength += 2; } if(evenlength > maxlength) { maxlength = evenlength; } ptag++; } return maxlength; }
由於子字符串的長度可能是奇數也可能是偶數。長度是奇數的字符串是從只有一個字符的中心向兩端延長出來,而長度為偶數的字符串是從一個有兩個字符的中心向兩端延長出來。因此程序中要把這兩種情況都考慮進去。
由於總共有O(n)個字符,每個字符可能延長O(n)次,每次延長時只需要O(1)就能判斷出是不是對稱的,因此整個函數的時間效率是O(n^2)。
上述方法稱為朴素算法,關於字符串的題目常用的算法有KMP、后綴數組、AC自動機,這道題目利用擴展KMP可以解答,其時間復雜度也很快O(N*logN)。但是,這里介紹一個專門針對回文子串的算法,其時間復雜度為O(n),這就是manacher算法。
3.manacher算法
算法的基本思路是這樣的:把原串每個字符中間用一個串中沒出現過的字符分隔#開來(統一奇偶),同時為了防止越界,在字符串的首部也加入一個特殊符$,但是與分隔符不同。同時字符串的末尾也加入'\0'。算法的核心:用輔助數組p記錄以每個字符為核心的最長回文字符串半徑。也就是p[i]記錄了以str[i]為中心的最長回文字符串半徑。p[i]最小為1,此時回文字符串就是字符串本身。
示例:原字符串 'abba’,處理后的新串 ' $#a#b#b#a#\0’,得到對應的輔助數組p=[0,1,1,2,1,2,5,2,2,1]。
程序如下,對應的變量解釋在后面
//預處理,將str:abba轉換為: $#a#b#b#a#\0(從1開始) char * pre(char *str) { int length = strlen(str); char *prestr = new char[2*length + 4]; prestr[1] = '$'; for(int i=0;i<length;i++) { prestr[2*(i+1)] = '#'; prestr[2*(i+1)+1] = str[i]; } prestr[2*length+2]='#'; prestr[2*length+3]='\0'; return prestr; }
以下是manacher算法的具體實現,包括:輔助數組的構建、最大字符串長度的獲取。
//manacher算法 int getMaxSym3(char *str) { char *prestr = pre(str); int mx =0, pi=1;//邊界和對稱中心 int len = strlen(prestr); //輔助數組 int *p = new int[len]; p[0] = 0; for(int i=1;i<len;i++) { if(mx>i) { p[i]=min(mx-i,p[2*pi-i]);//核心 } else { p[i]=1; } while(prestr[i-p[i]]==prestr[i+p[i]]&&i-p[i]>0&&i+p[i]<len) { p[i]++; } if(i+p[i] > mx) { mx = p[i] + i; pi = i; } } //最大回文字符串長度 int maxlen = 0; for(int i=0;i<len;i++) { if(p[i]>maxlen) { maxlen = p[i]; } } delete []prestr; delete []p; return maxlen - 1; }
上面幾個變量說明:pi記錄具有遍歷過程中最長半徑的回文字符串中心字符串。mx記錄了具有最長回文字符串的右邊界。
pi是最長回文字符串(淡藍色)的中心,如果以j為中心的最大回文串如上如所示,那么i處的情況與j處相同(關於pi的兩側是對稱的)。這樣便減少了運算量,i的對稱位置是2*pi-i。
但是有另外一種情況,就是j的一部分超出藍色部分,這時p[i]=p[j]就不一定對了,如下圖
這就為什么有取最小值這個操作:
if(mx>i) { p[i]=min(mx-i,p[2*pi-i]);//核心 }
剩下的代碼就很容易看懂了。
最后遍歷一邊p數組,找出最大的p[i]-1就是所求的最長回文字符串長度,說明如下:
(1)因為p[i]記錄插入分隔符之后的回文字符串半徑,所以以i為中心的回文字符串長度為2*p[i]-1。例如:bb=>#b#b#,中間#的半徑為3,回文字符串長度為2*3-1;
(2)注意上面兩個串的關系。 #b#b#減去一個#號的長度就是原來的2倍。即((2*p[i]-1)-1)/2 = p[i]-1,得證。