大家好,我是程序员小熊,来自大厂的程序猿。最长回文子串是面试中常考的题目,尤其是一些互联网大厂,像亚马逊、微软、脸书、字节和腾讯等都考过这道题。
本文提供四种解题思路和三种解法,将本题解法的时间复杂度由 O(n^3) 一步步降为 O(n) ,供大家参考,希望对大家有所帮助。
给你一个字符串 s,找到 s 中最长的回文子串。
本题要求的是最长回文子串,必须先明确两个概念。
回文串:从左向右读跟从右向左读都一样的字符串。例如 “level”或者“noon”等等。
子串:原始字符串中任意个连续的字符组成的子序列。
由于题目提示 1 <= s.length <= 1000,因此设计一个 O(n^2) 的算法是合理的。因为 O(n^2) 的算法可以在 1s 内处理大约 10^4 级别的数据;并且从示例1中可以知道,如果字符串存在多个最长回文子串,只需要输出一个即可。
以字符串 "abba" 为例子,如下动图所示。
暴力法动图
要求字符串的最长回文子串,只需要先找出最长回文子串的起始位置,再求出最长回文子串的长度即可。
因此可以通过两层遍历枚举长度大于等于2且超过当前得到的最长回文子串长度的子串,再加一层判断子串是否是回文串的遍历,就可求出给定字符串的最长回文子串。
java
/* 验证子串 s[left...right]是否为回文子串 */
boolean isPalindrome(char[] charArray, int left, int right) {
while (left < right) {
if (charArray[left] != charArray[right]) {
return false;
}
left++;
right--;
}
return true;
}
String longestPalindrome(String s) {
int len = s.length();
/* 长度少于 2 的字符串的最长回文子串是其自身 */
if (len < 2) {
return s;
}
int maxLen = 1; // 记录最长回文子串的长度
int start = 0; // 记录最长回文子串的起始位置
char[] charArray = s.toCharArray();
for (int i = 0; i < len - 1; i++) {
for (int j = i + 1; j < len; j++) {
/* 回文子串 s[i...j] 的长度超过当前最长回文子串长度 */
if (j - i + 1 > maxLen && isPalindrome(charArray, i, j)) {
/* 更新最长回文子串的长度 */
start = i;
maxLen = j - i + 1;
}
}
}
return s.substring(start, start + maxLen);
}
复杂度分析
时间复杂度:三层遍历 O(n^3)。
空间复杂度:没有开辟额外空间 O(1)。
回文串具有天然状态转移性,一个长度大于 2 的回文串,去掉首尾两头之后,剩余的部分依然是回文串。
情况一:如果一个子串首尾两头的字符不相同,则该子串不是回文串。如下图示。
回文串判断1
情况二:如果一个子串首尾两头的字符相同,则去掉首尾两头的字符,继续判断去掉后的子串,直至子串的首尾两头的字符不相同或子串为空。如下动图示。
回文串判断2
也就是说一个子串首尾两头的字符相同,去掉首尾两头的字符后剩余的子串是否是回文串决定了原子串是否是回文串。
状态:dp[i][j] 表示子串 s[i...j] 是否为回文子串。状态转移方程:
边界条件:[i + 1...j - 1]不成立(构成区间)
整理得:
即当 len(s[i...j])= 2 or 3 时,不用检查子串是否是回文串,不需要状态转移。
初始化:单个字符一定是字符串 dp[i][j] = true。
输出:状态为 true 时,记录当前最长回文子串的起始位置和长度,填完表后截取字符串。
举例以字符串 "abcba" 为例子,如下动图示,表格中的 dp[i][j] 也可参考它左下方的值填写。
状态转移表
java
String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
int start = 0;
int maxLen = 1;
/* dp[i][j]:s[i...j] 是否是回文串 */
boolean[][] dp = new boolean[len][len];
for (int i = 0; i < len; i++) {
dp[i][i] = true;
}
char[] charArray = s.toCharArray();
for (int j = 1; j < len; j++) {
for (int i = 0; i < j; i++) {
if (charArray[i] != charArray[j]) {
dp[i][j] = false;
} else {
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
/* s[i...j] 是回文串,记录当前最长回文串长度和起始位置 */
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j - i + 1;
start = i;
}
}
}
return s.substring(start, start + maxLen);
}
相较于暴力法,动态规划利用状态转移方程,快速判断一个子串是否是回文串,每一步计算都尽可能地利用之前计算的结果(保存的),空间换时间。
复杂度分析
时间复杂度:O(n^2)。
空间复杂度:O(n^2)。
回文串的枚举可以从两边开始,也可以从中间开始,所以可以枚举所有可能的回文子串的中心位置。
由于奇/偶数长度的回文子串的中心位置不一样,所以枚举时需要考虑回文子串长度是奇数还是偶数,如下图示。
偶数回文串
奇数回文串
举例
以奇数长度的字符串 "abcba" 为例子,中心扩散如下动图示,
C++
/* s[left] 和 s[right] 想两断扩散, 求以 s[left] 和 s[right] 为中心的最长回文串*/
string longestPalindromeHelper(string s, int left, int right) {
while (left >= 0 && right < s.length() && s[left] == s[right]) {
left--;
right++;
}
return s.substr(left + 1, right - left - 1);
}
string longestPalindrome(string s) {
int len = s.length();
if (len < 2) {
return s;
}
string res;
for (int i = 0; i < len; i++) {
/* 以s[i]作奇数回文子串的的回文中心向两边扩散,得到的最长回文子串*/
string s1 = longestPalindromeHelper(s, i, i);
/* 以s[i]、s[i + 1] 分别作偶数回文子串的的回文中心向两边扩散,得到的最长回文子串*/
string s2 = longestPalindromeHelper(s, i, i + 1);
res = res.size() > s1.size() ? res : s1;
res = res.size() > s2.size() ? res : s2;
}
return res;
}
复杂度分析
时间复杂度:O(n^2),枚举中心位置的个数是 2(n - 1),每次向两边扩散检测试服是回文子串。
空间复杂度:O(1)。
通过将原始字符串进行预处理(用不在输入字符串中的字符隔开),使得奇/偶数长度的回文串的性质统一表示。该算法专门用于查找最长回文子串,其时间复杂度为 O(n)。
由于绝大多数笔/面试不要求掌握次算法,所以这里就不再介绍了,感兴趣的童鞋,可以查阅相关资料进一步了解。