BFE.devAI解答 - JavaScript 代码题
111. 回文子字符串数量
解法一:暴力枚举
我们可以枚举所有的子串,然后判断其是否为回文串。
具体地,我们枚举子串的左边界和右边界,确定了这些以后,就可以判断该子串是否为回文串了。
时间复杂度分析:一共有 $n^2$ 个子串,对于每个子串,需要 $O(n)$ 的时间来判断它是否为回文串,因此总时间复杂度为 $O(n^3)$。空间复杂度为 $O(1)$。
代码如下:
/**
* @param {string} s
* @return {number}
*/
function countSubstrings(s) {
let ans = 0;
const n = s.length;
for (let i = 0; i < n; i++) {
for (let j = i; j < n; j++) {
if (isPalindrome(s, i, j)) {
ans++;
}
}
}
return ans;
}
function isPalindrome(s, i, j) {
while (i < j) {
if (s[i] !== s[j]) {
return false;
}
i++;
j--;
}
return true;
}
解法二:中心扩展法
注意到回文串一定是中心对称的,因此我们可以枚举每个回文串的中心位置,然后向两边扩展,判断左右是否相同。
注意到中心位置一共有 $2n-1$ 个,因此总时间复杂度为 $O(n^2)$,空间复杂度为 $O(1)$。
代码如下:
/**
* @param {string} s
* @return {number}
*/
function countSubstrings(s) {
let ans = 0;
const n = s.length;
for (let i = 0; i < 2 * n - 1; i++) {
let left = Math.floor(i / 2);
let right = left + i % 2;
while (left >= 0 && right < n && s[left] === s[right]) {
ans++;
left--;
right++;
}
}
return ans;
}
解法三:动态规划
我们使用动态规划的方法,定义 $dp(i, j)$ 表示区间 $[i, j]$ 是否为回文子串,转移方程为:
$$ dp(i, j) = \begin{cases} true, & \text{if } i \ge j \ s_i = s_j, & \text{if } i < j \ false, & \text{if } i < j \text{ and } s_i \neq s_j \end{cases} $$
即如果区间 $[i, j]$ 为空串,那么它一定是回文子串;如果区间 $[i, j]$ 不为空且 $s_i = s_j$,那么它是回文子串当且仅当区间 $[i+1, j-1]$ 是回文子串;否则它不是回文子串。
注意到 $i \le j$,因此 $dp(i, j)$ 中 $i$ 不大于 $j$,我们只需要枚举 $i$ 和 $j$ 之间的距离(即 $j-i$),依次计算 $dp(i, j)$ 即可。
时间复杂度为 $O(n^2)$,空间复杂度为 $O(n^2)$。
代码如下:
/**
* @param {string} s
* @return {number}
*/
function countSubstrings(s) {
let ans = 0;
const n = s.length;
const dp = Array.from({ length: n }, () => new Array(n).fill(false));
for (let len = 1; len <= n; len++) {
for (let i = 0; i < n; i++) {
let j = i + len - 1;
if (j >= n) {
break;
}
if (len === 1) {
dp[i][j] = true;
} else if (len === 2) {
dp[i][j] = (s[i] === s[j]);
} else {
dp[i][j] = (s[i] === s[j] && dp[i+1][j-1]);
}
if (dp[i][j]) {
ans++;
}
}
}
return ans;
}
解法四:Manacher 算法
这是一个比较巧妙的算法,可以在 $O(n)$ 的时间内求出所有回文子串。
Manacher 算法与解法二类似,也是中心扩展法,但是它通过预处理来避免了重复的计算。
具体地,我们可以先在字符串 $s$ 的每个相邻两个字符之间(包括开头和结尾)插入一个特殊字符(比如 #),这样得到的字符串长度一定是奇数。
然后从左向右遍历字符串,用 $P_i$ 表示以字符 $s_i$ 为中心向两边扩展的最长回文串长度,我们用 $R$ 表示当前能够扩展的最右边界,$C$ 表示当前能够扩展的最右边界对应的回文串的中心位置。每次遍历到一个新的字符 $s_i$ 时,分为两种情况:
- 如果 $i \le R$,则有可能通过之前的信息,更新 $P_i$ 的值;
- 如果 $i > R$,则需要从 $s_i$ 为中心向两边扩展,同时更新 $R$ 和 $C$。
注意到如果 $P_i$ 表示以字符 $s_i$ 为中心的回文串长度,那么实际上 $P_i-1$ 就是以字符 $s_i$ 左边那个特殊字符为中心的回文串长度,$P_i+1$ 就是以字符 $s_i$ 右边那个特殊字符为中心的回文串长度,因此我们有如下转移公式:
$$ \begin{cases} P_i = 1 & \text{if } i > R \ P_i = \min(P_{2C-i}, R-i+1) & \text{otherwise} \end{cases} $$
其中第一种情况表示当前位置在 $R$ 的右侧,无法继续利用已经得到的信息,只能从 $s_i$ 为中心扩展,因此长度为 $1$;第二种情况表示当前位置在 $[L, R]$ 内部,可以根据它对称的位置更新 $P_i$ 的值。
时间复杂度为 $O(n)$,空间复杂度为 $O(n)$。
代码如下:
/**
* @param {string} s
* @return {number}
*/
function countSubstrings(s) {
const t = `#${s.split('').join('#')}#`;
const n = t.length;
const p = new Array(n).fill(0);
let R = 0, C = 0, ans = 0;
for (let i = 0; i < n; i++) {
if (i <= R) {
p[i] = Math.min(p[2*C-i], R-i+1);
} else {
p[i] = 1;
}
while (i - p[i] >= 0 && i + p[i] < n && t[i-p[i]] === t[i+p[i]]) {
p[i]++;
}
if (i + p[i] - 1 > R) {
R = i + p[i] - 1;
C = i;
}
ans += Math.floor((p[i] - 1) / 2);
}
return ans;
}
参考文献: