Leetcode(647)——回文子字符串
题目
给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。
回文字符串:是正着读和倒过来读一样的字符串。
子字符串:是字符串中的由连续字符组成的一个序列。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
示例 1:
输入:s = “abc”
输出:3
解释:三个回文子串: “a”, “b”, “c”
示例 2:
输入:s = “aaa”
输出:6
解释:6个回文子串: “a”, “a”, “a”, “aa”, “aa”, “aaa”
提示:
- 1 <= s.length <= 1000
- s 由小写英文字母组成
题解
方法一:枚举出所有的子串,然后再判断这些子串是否是回文
思路
长度为
l
e
n
g
t
h
length
length 的字符串,会有长度为
l
e
n
g
t
h
,
l
e
n
g
t
h
−
1...
,
3
,
2
,
1
length,length-1...,3,2,1
length,length−1...,3,2,1 的子字符串,其中长度为
1
1
1 的子字符串一定是回文字符串。从长度为
2
2
2 的子字符串开始,依次查找以
s
[
0
]
,
s
[
1
]
.
.
.
s
[
l
−
1
]
s[0],s[1]...s[l-1]
s[0],s[1]...s[l−1] 为起点的从长度为
2
2
2 的子字符串,然后再查找以
s
[
0
]
,
s
[
1
]
.
.
.
s
[
l
−
1
]
s[0],s[1]...s[l-1]
s[0],s[1]...s[l−1] 为起点的从长度为
3
3
3 的子字符串,以此类推。
具体方法如下:长度为
l
l
l 的子字符串,从起点
s
[
i
]
s[i]
s[i] 开始,查询
s
[
i
]
=
=
s
[
i
+
l
−
1
]
s[i]==s[i+l-1]
s[i]==s[i+l−1],如果为
f
a
l
s
e
false
false 则退出查询。否则为
t
r
u
e
true
true 则继续查询
s
[
i
+
1
]
=
=
s
[
i
+
l
−
2
]
s[i+1]==s[i+l-2]
s[i+1]==s[i+l−2],以此类推,如果全为
t
r
u
e
true
true 则该子字符串是回文字符串,即
c
o
u
n
t
+
+
count++
count++。
代码实现
class Solution {
public:
int countSubstrings(string s) {
int count = s.length(); // 长度为 1 的字符串都是回文字符串
int sl = s.length()-1, a, b;
bool isit = false;
for(int n = 0; n < sl; n++){ // 遍历每个点,除了最后一个点,因为以它为起点的子字符串的长度最多为1
// 遍历以 s[n] 为起点的,结尾字符为 s[l] 的子字符串
for(int l = sl; n < l; l--){
a = n;
b = l;
while(a <= b){
if(s[a] != s[b]){
isit = false;
break;
}else{
isit = true;
a++;
b--;
}
}
if(isit)
count++;
}
}
return count;
}
};
复杂度分析
时间复杂度:
O
(
n
3
)
O(n^3)
O(n3) ,其中
n
n
n 是字符串的长度。本算法会用
O
(
n
2
)
O(n^2)
O(n2) 的时间枚举出所有的子串
s
[
l
i
⋯
r
i
]
s[l_i \cdots r_i]
s[li⋯ri],然后再用
O
(
r
i
−
l
i
+
1
)
O(r_i - l_i + 1)
O(ri−li+1) 的时间检测当前的子串是否是回文,整个算法的时间复杂度是
O
(
n
3
)
O(n^3)
O(n3)。
空间复杂度:
O
(
1
)
O(1)
O(1)
方法二:中心向外拓展
思路
这是一个比较巧妙的方法,实质的思路和动态规划的思路类似。
比如对一个字符串 ababa,选择最中间的 a 作为中心点,往两边扩散,第一次扩散发现 left 指向的是 b,right 指向的也是 b,所以是回文串,继续扩散,同理 ababa 也是回文串。
这个是确定了一个中心点后的寻找的路径,然后我们只要寻找到所有的中心点,问题就解决了。
中心点一共有多少个呢?看起来像是和字符串长度相等,但你会发现,如果是这样,上面的例子永远也搜不到 abab,想象一下单个字符的哪个中心点扩展可以得到这个子串?似乎不可能。所以中心点不会只有单个字符,有时还要包括两个相邻的字符。比如上面这个子串 abab,就可以有中心点 ba 扩展一次得到,所以最终的中心点由 2 * len - 1 个,分别是 len 个单字符和 len - 1 个双字符。
如果上面看不太懂的话,还可以看看下面几个问题:
为什么有 2 * len - 1 个中心点?
aba 有5个中心点,分别是 a、b、c、ab、ba
abba 有7个中心点,分别是 a、b、b、a、ab、bb、ba
什么是中心点?
中心点即 left 指针和 right 指针初始化指向的地方,可能是一个也可能是两个
为什么不可能是三个或者更多?
因为 3 个可以由 1 个扩展一次得到,4 个可以由两个扩展一次得到
中心拓展算法只会遍历并判断一次某个中心的最小长度的非回文字符串(比如 “abccab” 中以 “cc” 为中心的 “bcca”,而 “abccab” 就不会再次判断了),再向外拓展的子字符串就不会再次去判断了,相比方法一和方法二,它节约的时间主要是在这里。
代码实现
自己写的:
class Solution {
public:
int countSubstrings(string s) {
int count = 0; // 长度为 1 的字符串都是回文字符串
int next;
// 遍历一遍所有字符,除了第一个和最后一个
for(int n = 0; n < s.length(); n++){
// 以当前字符为中心向两侧拓展直到到达最大长度或出现不是回文字符串为止
next = 0;
while(0 <= n-next && n+next <= s.length()-1){
if(s[n-next] != s[n+next])
break;
else{
count++;
next++;
}
}
}
for(int left = 0, right = left + 1; right < s.length(); left++, right++){
// 以当前字符及其相邻字符为中心向两侧拓展直到到达最大长度或出现不是回文字符串为止
next = 0;
while(0 <= left-next && right+next <= s.length()-1){
if(s[left-next] != s[right+next])
break;
else{
count++;
next++;
}
}
}
return count;
}
};
Leetcode 官方题解:(将我自己写的两个循环合为一个)
class Solution {
public:
int countSubstrings(string s) {
int n = s.size(), ans = 0;
for (int i = 0; i < 2 * n - 1; ++i) {
int left = i / 2, right = i / 2 + i % 2;
while (left >= 0 && right < n && s[left] == s[right])
--left, ++right, ++ans;
}
return ans;
}
};
复杂度分析
时间复杂度:
O
(
N
2
)
O(N^2)
O(N2) ,其中
N
N
N 是字符串的字符个数。而枚举回文中心的是
O
(
n
)
O(n)
O(n) 的,对于每个回文中心拓展的次数也是
O
(
n
)
O(n)
O(n) 的,所以时间复杂度是
O
(
N
2
)
O(N^2)
O(N2)。
空间复杂度:
O
(
1
)
O(1)
O(1)
方法三:思路动态规划(DP)
思路
这一题还可以使用动态规划(DP)来进行解决。首先我们先确定原问题:给你一个字符串 s ,请你统计并返回这个字符串中回文子串的数目。
对原问题重定义:判断 s 的某个子串 s[a, b] 是不是回文字符串?然后统计并返回全部的回文子字符串。
- 划分子问题:而解决重定义后的问题,需要解决两个子问题。
- 子问题①:其子串 s[a+1, b-1] 是不是回文字符串(这个子问题又要用到其子子问题——即其子串 s[a+2, b-2] 是不是回文字符串)
- 子问题②:字符串 s[a, b] 的两边字符是否相等,即
s[a] == s[b]
- 状态(即解决每个子问题时的前提条件):dp[i][j] 表示字符串 s[i, j] 是否是一个回文串。
- 状态转移方程:
if( s[i] == s[j] && (j - i < 2 || dp[i + 1][j - 1]) )
dp[i][j]=true;
else dp[i][j]=false;
s[i] == s[j] && j - i < 2
表示当只有一个字符时,它就是一个回文串。当有两个字符时,如果是它们相等,比如 aa,也是一个回文串。s[i] == s[j] && dp[i + 1][j - 1]
表示当有三个及以上字符时,比如 ababa 这个字符记作串 1,把两边的 a 去掉,也就是 bab 记作串 2,可以看出只要串2是一个回文串,那么左右各多了一个 a 的串 1 必定也是回文串。所以当s[i]==s[j]
时,自然要看dp[i+1][j-1]
是不是一个回文串。
算法实现:
为什么从右下角开始遍历?这是根据状态转移方程决定的:
因为在填 dp 数组时,dp(i, j) 的值依赖于 dp(i+1,j-1),也就是当前位置的左下方。显然如果从上往下遍历,左下方的值就完全没有初始化,当然当前位置也会是错误的。但是从右下角遍历就保证了左下方的所有值都已经计算好了。
如下图所示,红色箭头表示的是右上角的值依赖左下角的值(即 dp(i, j) 的值依赖于 dp(i+1,j-1) ),这里只画了部分。黄色箭头表示的是计算的顺序,它是 从右下角开始,从下往上,从左往右 开始计算的,所以当计算 dp[i][j] 的时候,dp[i+1][j-1] 已经计算过了(图中灰色部分是
i
>
j
i>j
i>j,属于无效的)
除了上面这种方式以外,我们还可以 从左上角开始,从左往右,从上往下 开始计算,这样也能保证在计算 dp[i][j] 的时候,dp[i+1][j-1] 已经计算过了,如下图所示:
代码实现
从右下角开始,从上往下,从左往右,求出 dp[i][j]
class Solution {
public:
int countSubstrings(string s) {
int n = s.length();
bool dp[n][n];
int count = 0;
for(int i=n-1; 0<=i; i--){
for(int j=i; j<n; j++)
if(s[i] == s[j] && (j-i<2 || dp[i+1][j-1])){
count++;
dp[i][j] = true;
}else dp[i][j] = false;
}
return count;
}
};
从左上角开始,从左往右,从上往下,求出 dp[i][j]
class Solution {
public:
int countSubstrings(string s) {
int n = s.length();
bool dp[n][n];
int count = 0;
for(int j=0; j<n; j++){
for(int i=0; i<=j; i++)
if(s[i] == s[j] && (j-i<2 || dp[i+1][j-1])){
count++;
dp[i][j] = true;
}else dp[i][j] = false;
}
return count;
}
};
复杂度分析
时间复杂度:
O
(
N
2
)
O(N^2)
O(N2) ,其中
N
N
N 是字符串的字符个数。动态规划相比方法一减少了不同字符串在判断中重复判断相同的子串是否为回文字符串的步骤,但是相比方法二的中心拓展还是遍历判断了一些非回文子串。
空间复杂度:
O
(
N
2
)
O(N^2)
O(N2)