回忆了xdoj的青春,做了那么多cf和atcoder,也该实践实践了,作为现在ai的时代,作为阿里系一员,首当其冲就是去试试通义实验室。
通义实验室给了一道最长回文子序列题,一下干懵逼了。看起来是道很典型的题,但满脑子想着优化,想了好几种方法,左试试右试试,拿不定主意。再加上已经很久没人盯着写代码了,导致脑子一片浆糊,最终被迫降级写了一种最low的方法。。。现在想想其实每一种思路都是正确的,只是不同的复杂度罢了。
方法1:暴力枚举
这就是最low的方法,平方复杂度遍历字符串,再用线性复杂度判断回文,总计 的时间复杂度。这个没什么可说的,由于平常工作用的java,直接上java。
class Solution {
public String longestPalindrome(String s) {
int len = s.length();
int maxLength = 0;
int startPos = 0;
int endPos = 0;
for (int start = 0; start < len; start++) {
for (int end = start; end < len; end++) {
if (checkMirror(s, start, end)) {
int currentLen = end - start + 1;
if (currentLen > maxLength) {
maxLength = currentLen;
startPos = start;
endPos = end;
}
}
}
}
return s.substring(startPos, endPos + 1);
}
/**
* 校验是否是回文串
* @param s 待校验串
* @param start 校验起始位置
* @param end 校验终点位置
* @return 是否是回文串
*/
private static boolean checkMirror(String s, Integer start, Integer end){
for (; start <= end; start++, end--) {
if (s.charAt(start) != s.charAt(end)) {
break;
}
}
return start > end;
}
}
方法2:中心辐射
由于回文串具有中心对称的特点,因此暴力枚举的时候没必要枚举始末节点,只需要枚举中心节点,即可将 的算法降为
,单独考虑一下当串长为奇数和偶数时,中心点分别为一个和两个即可
class Solution {
public String longestPalindrome(String s) {
int len = s.length();
int maxLen = 1;
int startPos = 0;
for (int c=1; c<len; c++) {
int oddLen = findMaxLenOdd(s, c);
if (oddLen > maxLen){
maxLen = oddLen;
startPos = c - oddLen / 2;
}
int evenLen = findMaxLenEven(s, c);
if (evenLen > maxLen){
maxLen = evenLen;
startPos = c - evenLen / 2;
}
}
return s.substring(startPos, startPos + maxLen);
}
/**
* 判断以c为中心的最长奇数回文子串
* @param s 模板串
* @param c 中心点位
* @return 最长奇数回文串长度
*/
private static int findMaxLenOdd(String s, int c) {
int curLen = 1;
while (c - curLen >= 0 && c + curLen < s.length() && s.charAt(c - curLen) == s.charAt(c + curLen)) {
curLen++;
}
return curLen * 2 - 1;
}
/**
* 判断以c为中心的最长偶数回文子串
* @param s 模板串
* @param c 中心点位
* @return 最长偶数回文串长度
*/
private static int findMaxLenEven(String s, int c) {
int curLen = 0;
while (c - curLen -1 >= 0 && c + curLen < s.length() && s.charAt(c - curLen - 1) == s.charAt(c + curLen)) {
curLen++;
}
return curLen * 2;
}
}
方法3:动态规划
偶然间发现,leetcode这道题居然是原题,看来要做面试还是得刷leetcode,老做竞赛题,只是保持手感的一种方式(虽然现在已经没什么手感了),而正在要去面试还得踏踏实实做leetcode去,起码难度题型什么的都有参考,免得自己乱想没有方向。
dp其实就是也类似于两头扩散了, 表示
这个区间内的子串是否为回文串。不难发现若某个子串为回文串,并且其两侧的元素相同,则加上两侧的元素则仍为回文串,即
,最后枚举所有回文串取最大值即可。这种算法枚举了所有可能的子串,因此时间复杂度也是
,不过由于要记录每个子串是否为回文串,因此空间复杂度也为
class Solution {
public String longestPalindrome(String s) {
int len = s.length();
boolean[][] dp = new boolean[len][len];
int maxLen = 1;
int startPos = len - 1;
dp[len - 1][len - 1] = true;
for (int i = len - 2; i >= 0; i--) {
dp[i][i] = true;
dp[i][i + 1] = s.charAt(i) == s.charAt(i + 1);
if (dp[i][i + 1] && maxLen < 2) {
maxLen = 2;
startPos = i;
}
for (int j = i + 2; j < len; j++) {
dp[i][j] = s.charAt(i) == s.charAt(j) && dp[i + 1][j - 1];
if (dp[i][j]) {
int curLen = j - i + 1;
if (curLen > maxLen) {
maxLen = curLen;
startPos = i;
}
}
}
}
//for(int i=0;i<len;i++){
// for (int j=i;j<len;j++){
// System.out.print(dp[i][j] + " ");
// }
// System.out.print("\n");
//}
return s.substring(startPos, startPos + maxLen);
}
}
方法4:Manacher算法
最后进阶一下,学习一下最高端的Manacher算法,居然可以将回文串操作的复杂度降到 ,太神奇了,赶紧去看看
Manacher算法主要分为两个部分,补串和计算回文长度
第一个部分是补串。由上述方法2可知,回文串针对奇偶长度,其实对称中心是不一样的。奇串的回文中心是一个字母,而偶串的回文中心则是两个字母。于是如果在原串的每个字母中间都加一个特殊字符,就可以稳定将奇串和偶串全都统一为奇串。
原串 | 原串长度 | 补串 | 补串长度 | 原串与补串长度关系 | |
奇串 | ABA | 3 | #A#B#A# | 7 | 原串长度=(补串长度-1)/2 |
偶串 | ABBA | 4 | #A#B#B#A# | 9 | 原串长度=(补串长度-1)/2 |
第二部分就是最核心的就是线性复杂度求回文长度。说到线性复杂度,那么肯定需要有一个变量来遍历每个中心位置所对应的最长回文子串长度,假设该变量为 ,回文半径为
,那么最终最长回文子串就是可以通过数组最大值求得,即为
。恰巧
即为最终所求的原串的回文长度,那么回文半径为
,而回文串的中心点位于
,计算可知,最终回文串的起点位于
。
接下来说 数组的每个值如何在常数复杂度来求。这其实就利用了已知回文串的对称性。首先,我们需要记录一下,我们已经得到的最远回文串,最远回文串就是当前所有回文串中右边界最远的回文串,因为这是回文串给我们提供的信息最多。假设该回文串中心为
,长度为
。接下来,我们要利用这个回文串来计算我们要求的最长回文子串。
一共分为四种情况。
第1.1种情况,我们当前的枚举变量 刚好在这个最远回文串的外侧,即
。那么其实该回文串无法给我们提供任何信息,我们只能回到方法2的中心辐射法去求最长回文串。求完后,当前求到的回文串即可以更新为我们已知的最远回文串
接下来三种情况都是枚举变量 在这个最远回文串的内侧的场景,即
这三种情况都需要找到当前变量 关于回文串中心
的对称点
。然后利用因为
已知,我们就可以利用此信息来求
。接下来我们根据
的大小来分三种情况
第2.1种情况,对称点的回文串在最右回文串内部,此时 ,那么要求的以
为中心的回文串一定与对称点对应回文串关于
点对称,所以其子串长度也与对称点一样,即
第2.2种情况,对称点的回文串在最右回文串外部,此时 ,由于我们可以知道超出最右回文串长度外部的边界一定不关于回文串中心对称。因此超出的部分一定不回文,那么回文串的长度为当前点到回文边界的距离,即
第2.3种情况,对称点的回文串刚好在最右回文串的边界上,此时 ,那么超出最右回文串长度外部边界的点是否回文是并不知道的,此时我们即可从当前点开始继续向外用中心辐射法去求最长子串,并更新最右回文串。
总结一下,其实我们可以用一个统一的方案,首先找到当前 点关于中心
点的对称点
。若对称点在回文串内,则
,再继续向后用中心辐射法枚举,确认最终的值
经过上述研究发现,最右回文串一定是线性向后移动的,这保证了整个算法的复杂度在线性 内完成。
最后,上代码
class Solution {
public String longestPalindrome(String s) {
int len = s.length();
String newS = addDivider(s);
//System.out.println(newS);
int newLen = 2 * len + 1;
int[] p = new int[newLen];
p[0] = 1;
int maxLen = 1;
int startPos = 0;
int c = 0;
int d = 1;
for (int i = 1; i < newLen; i++) {
int newP = 1;
if (c + d - 1 >= i) {
newP = Math.min(c + d - i, p[2 * c - i]);
}
while (i - newP >= 0 && i + newP < newLen && newS.charAt(i - newP) == newS.charAt(i + newP)) {
newP++;
}
if (newP - 1 > maxLen) {
maxLen = newP - 1;
//int orgC = (i - 1) / 2;
//int orgP = newP / 2 - 1;
//int orgC = i / 2;
//int orgP = (newP - 1) / 2;
//startPos = orgC - orgP;
startPos = (i - newP + 1) / 2;
}
if (c + d < i + newP) {
c = i;
d = newP;
}
p[i] = newP;
//System.out.println(i + "*" + p[i] + "*" + maxLen + "*" + startPos + "*" + c + "*" + d);
}
return s.substring(startPos, startPos + maxLen);
}
/**
* 为模板串添加分隔符,将模板串统一为奇串
*
* @param s 模板串
* @return 填补后的补串
*/
private static String addDivider(String s) {
int len = s.length();
char divider = '#';
StringBuilder newS = new StringBuilder().append(divider);
for (int i = 0; i < len; i++) {
newS.append(s.charAt(i)).append(divider);
}
return newS.toString();
}
}
附录:leetcode题目链接