5. 最长回文子串
问题
给你一个字符串 s
,找到 s
中最长的回文子串。
示例 1:
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。
示例 2:
输入:s = "cbbd"
输出:"bb"
提示:
1 <= s.length <= 1000
s
仅由数字和英文字母组成
My Answer
public static String longestPalindrome(String s){
// 定义空串
String longestPalindrome = "";
// 定义游标
int front, rear;
// 1. 从后往前寻找和第一个字符相同的字符位置
// 2. 找到后,若下标平均数>0,开始对比第一个字符向后与相同字符向前的字符
// 3. 若一直到下标平均数<1,对比字符都相等,则返回字符串
// 若字符串长度不为1
if (1 != s.length()){
// 从第一个字符开始检测
for(int i=0; i<s.length()-1; i++){
for(int j=s.length()-1; j>=i; j--){
// 从后向前对比
if(s.charAt(i) == s.charAt(j)){
front = i;
rear = j;
// 如果下标差值为偶数,说明可能有奇数长度的回文字符
if ((rear-front)%2 == 0){
while(rear-front > 0){
front++;
rear--;
// 如果遇见不满足回文字符,退出循环
if(s.charAt(front) != s.charAt(rear)){
break;
}
}
// 如果循环完整执行,且之前最长回文子串长度小于新的,则更新
if(rear-front<1 && longestPalindrome.length()<s.substring(i, j+1).length()){
longestPalindrome = s.substring(i, j+1);
}
}else{
// 如果下标差值为奇数,说明可能有偶数长度的回文字符
while(rear-front > 1){
front++;
rear--;
// 如果遇见不满足回文字符,退出循环
if(s.charAt(front) != s.charAt(rear)){
break;
}
}
// 如果循环完整执行,且之前最长回文子串长度小于新的,则更新
if(s.charAt(front) == s.charAt(rear) && longestPalindrome.length()<s.substring(i, j+1).length()){
longestPalindrome = s.substring(i, j+1);
}
}
}
}
}
// 若不存在回文子串,则返回第一个字符
if(0 == longestPalindrome.length()){
longestPalindrome = s.substring(0, 1);
}
}else{
// 若字符串s长度为1,则直接赋值
longestPalindrome = s;
}
return longestPalindrome;
}
官方Answer
方法一:动态规划
思路与算法
对于一个子串而言,如果它是回文串,并且长度大于 222,那么将它首尾的两个字母去除之后,它仍然是个回文串。例如对于字符串 "ababa"\textrm{"ababa"}"ababa",如果我们已经知道 "bab"\textrm{"bab"}"bab"是回文串,那么 "ababa"\textrm{"ababa"}"ababa"一定是回文串,这是因为它的首尾两个字母都是 "a"\textrm{"a"}"a"。
根据这样的思路,我们就可以用动态规划的方法解决本题。我们用 P(i,j)P(i,j)P(i,j) 表示字符串 sss 的第 iii 到 jjj 个字母组成的串(下文表示成 s[i:j]s[i:j]s[i:j])是否为回文串:
这里的「其它情况」包含两种可能性:
- s[i,j]s[i,j]s[i,j] 本身不是一个回文串;
- i>ji > ji>j,此时 s[i,j]s[i, j]s[i,j] 本身不合法。
那么我们就可以写出动态规划的状态转移方程:
也就是说,只有 s[i+1:j−1]s[i+1:j-1]s[i+1:j−1] 是回文串,并且 sss 的第 iii 和 jjj 个字母相同时,s[i:j]s[i:j]s[i:j] 才会是回文串。
上文的所有讨论是建立在子串长度大于 2 的前提之上的,我们还需要考虑动态规划中的边界条件,即子串的长度为 1或 2。对于长度为 1 的子串,它显然是个回文串;对于长度为 2 的子串,只要它的两个字母相同,它就是一个回文串。因此我们就可以写出动态规划的边界条件:
根据这个思路,我们就可以完成动态规划了,最终的答案即为所有 P(i,j)=trueP(i, j) = \text{true}P(i,j)=true 中 j−i+1j-i+1j−i+1(即子串长度)的最大值。注意:在状态转移方程中,我们是从长度较短的字符串向长度较长的字符串进行转移的,因此一定要注意动态规划的循环顺序。
public String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
int maxLen = 1;
int begin = 0;
// dp[i][j] 表示 s[i..j] 是否是回文串
boolean[][] dp = new boolean[len][len];
// 初始化:所有长度为 1 的子串都是回文串
for (int i = 0; i < len; i++) {
dp[i][i] = true;
}
char[] charArray = s.toCharArray();
// 递推开始
// 先枚举子串长度
for (int L = 2; L <= len; L++) {
// 枚举左边界,左边界的上限设置可以宽松一些
for (int i = 0; i < len; i++) {
// 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得
int j = L + i - 1;
// 如果右边界越界,就可以退出当前循环
if (j >= len) {
break;
}
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];
}
}
// 只要 dp[i][L] == true 成立,就表示子串 s[i..L] 是回文,此时记录回文长度和起始位置
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j - i + 1;
begin = i;
}
}
}
return s.substring(begin, begin + maxLen);
}
复杂度分析
- 时间复杂度:O(n2)O(n^2)O(n2),其中 n 是字符串的长度。动态规划的状态总数为 O(n2)O(n^2)O(n2),对于每个状态,我们需要转移的时间为 O(1)O(1)O(1)。
- 空间复杂度:O(n2)O(n^2)O(n2),即存储动态规划状态需要的空间。
方法二:中心扩展算法
我们仔细观察一下方法一中的状态转移方程:
找出其中的状态转移链:
可以发现,所有的状态在转移的时候的可能性都是唯一的。也就是说,我们可以从每一种边界情况开始「扩展」,也可以得出所有的状态对应的答案。
边界情况即为子串长度为 11 或 22 的情况。我们枚举每一种边界情况,并从对应的子串开始不断地向两边扩展。如果两边的字母相同,我们就可以继续扩展,例如从 P(i+1,j−1)P(i+1,j-1)P(i+1,j−1) 扩展到 P(i,j)P(i,j)P(i,j);如果两边的字母不同,我们就可以停止扩展,因为在这之后的子串都不能是回文串了。
聪明的读者此时应该可以发现,「边界情况」对应的子串实际上就是我们「扩展」出的回文串的「回文中心」。方法二的本质即为:我们枚举所有的「回文中心」并尝试「扩展」,直到无法扩展为止,此时的回文串长度即为此「回文中心」下的最长回文串长度。我们对所有的长度求出最大值,即可得到最终的答案。
public String longestPalindrome(String s) {
if (s == null || s.length() < 1) {
return "";
}
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i);
int len2 = expandAroundCenter(s, i, i + 1);
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
public int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
--left;
++right;
}
return right - left - 1;
}
复杂度分析
- 时间复杂度:O(n2)O(n^2)O(n2),其中 nnn 是字符串的长度。长度为 1 和 2 的回文中心分别有 nnn 和 n−1n-1n−1 个,每个回文中心最多会向外扩展 O(n)O(n)O(n) 次。
- 空间复杂度:O(1)O(1)O(1)。
方法三:Manacher\text{Manacher}Manacher算法
还有一个复杂度为 O(n) 的 Manacher\text{Manacher}Manacher 算法。然而本算法十分复杂,一般不作为面试内容。这里给出,仅供有兴趣的同学挑战自己。
为了表述方便,我们定义一个新概念臂长,表示中心扩展算法向外扩展的长度。如果一个位置的最大回文字符串长度为 2 * length + 1
,其臂长为 length
。
下面的讨论只涉及长度为奇数的回文字符串。长度为偶数的回文字符串我们将会在最后与长度为奇数的情况统一起来。
思路与算法
在中心扩展算法的过程中,我们能够得出每个位置的臂长。那么当我们要得出以下一个位置 i
的臂长时,能不能利用之前得到的信息呢?
答案是肯定的。具体来说,如果位置 j
的臂长为 length
,并且有 j + length > i
,如下图所示:
当在位置 i 开始进行中心拓展时,我们可以先找到 i 关于 j 的对称点 2 * j - i
。那么如果点 2 * j - i
的臂长等于 n,我们就可以知道,点 i 的臂长至少为 min(j + length - i, n)
。那么我们就可以直接跳过 i 到 i + min(j + length - i, n)
这部分,从 i + min(j + length - i, n) + 1
开始拓展。
我们只需要在中心扩展法的过程中记录右臂在最右边的回文字符串,将其中心作为 j
,在计算过程中就能最大限度地避免重复计算。
那么现在还有一个问题:如何处理长度为偶数的回文字符串呢?
我们可以通过一个特别的操作将奇偶数的情况统一起来:我们向字符串的头尾以及每两个字符中间添加一个特殊字符 #,比如字符串 aaba
处理后会变成 #a#a#b#a#
。那么原先长度为偶数的回文字符串 aa
会变成长度为奇数的回文字符串 #a#a#
,而长度为奇数的回文字符串 aba
会变成长度仍然为奇数的回文字符串 #a#b#a#
,我们就不需要再考虑长度为偶数的回文字符串了。
注意这里的特殊字符不需要是没有出现过的字母,我们可以使用任何一个字符来作为这个特殊字符。这是因为,当我们只考虑长度为奇数的回文字符串时,每次我们比较的两个字符奇偶性一定是相同的,所以原来字符串中的字符不会与插入的特殊字符互相比较,不会因此产生问题。
public String longestPalindrome(String s) {
int start = 0, end = -1;
StringBuffer t = new StringBuffer("#");
for (int i = 0; i < s.length(); ++i) {
t.append(s.charAt(i));
t.append('#');
}
t.append('#');
s = t.toString();
List<Integer> arm_len = new ArrayList<Integer>();
int right = -1, j = -1;
for (int i = 0; i < s.length(); ++i) {
int cur_arm_len;
if (right >= i) {
int i_sym = j * 2 - i;
int min_arm_len = Math.min(arm_len.get(i_sym), right - i);
cur_arm_len = expand(s, i - min_arm_len, i + min_arm_len);
} else {
cur_arm_len = expand(s, i, i);
}
arm_len.add(cur_arm_len);
if (i + cur_arm_len > right) {
j = i;
right = i + cur_arm_len;
}
if (cur_arm_len * 2 + 1 > end - start) {
start = i - cur_arm_len;
end = i + cur_arm_len;
}
}
StringBuffer ans = new StringBuffer();
for (int i = start; i <= end; ++i) {
if (s.charAt(i) != '#') {
ans.append(s.charAt(i));
}
}
return ans.toString();
}
public int expand(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
--left;
++right;
}
return (right - left - 2) / 2;
}
- 时间复杂度:O(n)O(n)O(n),其中 n 是字符串的长度。由于对于每个位置,扩展要么从当前的最右侧臂长
right
开始,要么只会进行一步,而right
最多向前走 O(n)O(n)O(n) 步,因此算法的复杂度为 O(n)O(n)O(n)。 - 空间复杂度:O(n)O(n)O(n),我们需要 O(n)O(n)O(n) 的空间记录每个位置的臂长。
作者:LeetCode-Solution
链接:https://leetcode.cn/problems/longest-palindromic-substring/solution/zui-chang-hui-wen-zi-chuan-by-leetcode-solution/
来源:力扣(LeetCode)
6. Z 字形变换
题目
将一个给定字符串 s
根据给定的行数 numRows
,以从上往下、从左到右进行 Z 字形排列。
比如输入字符串为 "PAYPALISHIRING"
行数为 3 时,排列如下:
P A H N
A P L S I I G
Y I R
之后,你的输出需要从左往右逐行读取,产生出一个新的字符串,比如:"PAHNAPLSIIGYIR"
。
请你实现这个将字符串进行指定行数变换的函数:
string convert(string s, int numRows);
示例 1:
输入:s = "PAYPALISHIRING", numRows = 3
输出:"PAHNAPLSIIGYIR"
示例 2:
输入:s = "PAYPALISHIRING", numRows = 4
输出:"PINALSIGYAHRPI"
解释:
P I N
A L S I G
Y A H R
P I
示例 3:
输入:s = "A", numRows = 1
输出:"A"
提示:
1 <= s.length <= 1000
s
由英文字母(小写和大写)、','
和'.'
组成1 <= numRows <= 1000
My Answer
public static String zConvert(String s, int numRows){
String result = "";
// 若行数为1或者超过字符串s长度
if(1 >= numRows || numRows >= s.length()) {
return s;
}
// 此外,新建二维字符数组存储Z字变形排列
// 计算两组首字母下标差
int subDifference = 2*numRows - 2;
// 计算二维数组行列数
int col = ((s.length()+subDifference-1)/subDifference)*(numRows-1);
// 创建二维字符数组
char[][] storage = new char[numRows][col];
// 按照Z字排列
for(int i=0, j=0, k=0; i<s.length(); i++){
storage[j][k] = s.charAt(i);
// 如果第一组字符下标没有超过竖列,则下移;若超过,则向右上移动
if(i%subDifference < numRows-1){
j++;
}else{
j--;
k++;
}
}
// 将二维数组内非空字符放入结果字符串
for(int j=0; j<numRows; j++){
for(int k=0; k<col; k++){
if('\0' != storage[j][k]){
result += storage[j][k];
}
System.out.print(storage[j][k] + "\t");
}
System.out.println();
}
return result;
}
官方Answer
方法一:利用二维矩阵模拟
设 n 为字符串 s 的长度,r=numRowsr=\textit{numRows}r=numRows。对于 r=1r=1r=1(只有一行)或者 r≥nr\ge nr≥n(只有一列)的情况,答案与 s 相同,我们可以直接返回 s。对于其余情况,考虑创建一个二维矩阵,然后在矩阵上按 Z 字形填写字符串 s,最后逐行扫描矩阵中的非空字符,组成答案。
根据题意,当我们在矩阵上填写字符时,会向下填写 rr 个字符,然后向右上继续填写 r−2r-2r−2 个字符,最后回到第一行,因此 Z 字形变换的周期 t=r+r−2=2r−2t=r+r-2=2r-2t=r+r−2=2r−2,每个周期会占用矩阵上的 1+r−2=r−11+r-2=r-11+r−2=r−1 列。
因此我们有 ⌈nt⌉\Big\lceil\dfrac{n}{t}\Big\rceil⌈tn⌉ 个周期(最后一个周期视作完整周期),乘上每个周期的列数,得到矩阵的列数 c=⌈nt⌉⋅(r−1)c=\Big\lceil\dfrac{n}{t}\Big\rceil\cdot(r-1)c=⌈tn⌉⋅(r−1)。
创建一个 r 行 c 列的矩阵,然后遍历字符串 s 并按 Z 字形填写。具体来说,设当前填写的位置为 (x,y)(x,y)(x,y),即矩阵的 x 行 y 列。初始 (x,y)=(0,0)(x,y)=(0,0)(x,y)=(0,0),即矩阵左上角。若当前字符下标 i 满足 i mod t<r−1i\bmod t<r-1imodt<r−1,则向下移动,否则向右上移动。
填写完成后,逐行扫描矩阵中的非空字符,组成答案。
public String convert(String s, int numRows) {
int n = s.length(), r = numRows;
if (r == 1 || r >= n) {
return s;
}
int t = r * 2 - 2;
int c = (n + t - 1) / t * (r - 1);
char[][] mat = new char[r][c];
for (int i = 0, x = 0, y = 0; i < n; ++i) {
mat[x][y] = s.charAt(i);
if (i % t < r - 1) {
++x; // 向下移动
} else {
--x;
++y; // 向右上移动
}
}
StringBuffer ans = new StringBuffer();
for (char[] row : mat) {
for (char ch : row) {
if (ch != 0) {
ans.append(ch);
}
}
}
return ans.toString();
}
复杂度分析
-
时间复杂度:O(r⋅n)O(r\cdot n)O(r⋅n),其中 r=numRowsr=\textit{numRows}r=numRows,n 为字符串 s 的长度。时间主要消耗在矩阵的创建和遍历上,矩阵的行数为 r,列数可以视为 O(n)O(n)O(n)。
-
空间复杂度:O(r⋅n)O(r\cdot n)O(r⋅n)。矩阵需要 O(r⋅n)O(r\cdot n)O(r⋅n) 的空间。
方法二:压缩矩阵空间
方法一中的矩阵有大量的空间没有被使用,能否优化呢?
注意到每次往矩阵的某一行添加字符时,都会添加到该行上一个字符的右侧,且最后组成答案时只会用到每行的非空字符。因此我们可以将矩阵的每行初始化为一个空列表,每次向某一行添加字符时,添加到该行的列表末尾即可。
public String convert(String s, int numRows) {
int n = s.length(), r = numRows;
if (r == 1 || r >= n) {
return s;
}
StringBuffer[] mat = new StringBuffer[r];
for (int i = 0; i < r; ++i) {
mat[i] = new StringBuffer();
}
for (int i = 0, x = 0, t = r * 2 - 2; i < n; ++i) {
mat[x].append(s.charAt(i));
if (i % t < r - 1) {
++x;
} else {
--x;
}
}
StringBuffer ans = new StringBuffer();
for (StringBuffer row : mat) {
ans.append(row);
}
return ans.toString();
}
复杂度分析
- 时间复杂度:O(n)O(n)O(n)。
- 空间复杂度:O(n)O(n)O(n)。压缩后的矩阵需要 O(n)O(n)O(n) 的空间。
方法三:直接构造
我们来研究方法一中矩阵的每个非空字符会对应到 s 的哪个下标(记作 idx\textit{idx}idx),从而直接构造出答案。
由于 Z 字形变换的周期为 t=2r−2t=2r-2t=2r−2,因此对于矩阵第一行的非空字符,其对应的 idx\textit{idx}idx 均为 t 的倍数,即 idx≡0(modt)\textit{idx}\equiv 0\pmod tidx≡0(modt);同理,对于矩阵最后一行的非空字符,应满足 idx≡r−1(modt)\textit{idx}\equiv r-1\pmod tidx≡r−1(modt)。
对于矩阵的其余行(行号设为 i),每个周期内有两个字符,第一个字符满足 idx≡i(modt)\textit{idx}\equiv i\pmod tidx≡i(modt),第二个字符满足 idx≡t−i(modt)\textit{idx}\equiv t-i\pmod tidx≡t−i(modt)。
// C语言
char * convert(char * s, int numRows){
int n = strlen(s), r = numRows;
if (r == 1 || r >= n) {
return s;
}
int t = r * 2 - 2;
char * ans = (char *)malloc(sizeof(char) * (n + 1));
int pos = 0;
for (int i = 0; i < r; ++i) { // 枚举矩阵的行
for (int j = 0; j + i < n; j += t) { // 枚举每个周期的起始下标
ans[pos++] = s[j + i]; // 当前周期的第一个字符
if (0 < i && i < r - 1 && j + t - i < n) {
ans[pos++] = s[j + t - i]; // 当前周期的第二个字符
}
}
}
ans[pos] = '\0';
return ans;
}
复杂度分析
- 时间复杂度:O(n)O(n)O(n),其中 n 为字符串 s 的长度。s 中的每个字符仅会被访问一次,因此时间复杂度为 O(n)O(n)O(n)。
- 空间复杂度:O(1)O(1)O(1),返回值不计入空间复杂度。
作者:LeetCode-Solution
链接:https://leetcode.cn/problems/zigzag-conversion/solution/z-zi-xing-bian-huan-by-leetcode-solution-4n3u/
来源:力扣(LeetCode)