目录
[题目一------647. 回文子串 - 力扣(LeetCode)](#题目一——647. 回文子串 - 力扣(LeetCode))
[题目二------5. 最长回文子串 - 力扣(LeetCode)](#题目二——5. 最长回文子串 - 力扣(LeetCode))
[题目三------1745. 分割回文串 IV - 力扣(LeetCode)](#题目三——1745. 分割回文串 IV - 力扣(LeetCode))
[题目四------132. 分割回文串 II - 力扣(LeetCode)](#题目四——132. 分割回文串 II - 力扣(LeetCode))
[题目五------ 516. 最长回文子序列 - 力扣(LeetCode)](#题目五—— 516. 最长回文子序列 - 力扣(LeetCode))
[题目六------1312. 让字符串成为回文串的最少插入次数 - 力扣(LeetCode)](#题目六——1312. 让字符串成为回文串的最少插入次数 - 力扣(LeetCode))
题目一------647. 回文子串 - 力扣(LeetCode)
子字符串其实就是子数组!!!它们两者都是等价的!!
注意:具有不同开始位置的子串,即使是由相同的字符组成,也被视作不同的子串。
事实上,这种回文串问题,动态规划不是最优解。
中心拓展算法,马拉车算法解决回文串问题的效率可比动态规划高。但是我们还是要讲动态规划版本的解法,但是动态规划的思想可以直接让回文串的困难题全部变成简单题。
我们可以将所有子串是否是回文的信息保存在dp表里面。
1.状态表示
对于线性dp,我们可以用「经验+题目要求」来定义状态表示:
- 以某个位置为结尾,巴拉巴拉;
- 以某个位置为起点,巴拉巴拉。
这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
- dp[i] 表示:以i位置元素为结尾的「所有子串」中,回文串的个数。
但是这里有一个非常致命的问题,那就是我们无法确定i结尾的回文子串的样子。这样就会导致我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定一个回文子串。
上面的那种思路其实我是不赞同的。
**我们可以先预处理一番,将所有子串是否是回文子串的信息存储在dp表里面,然后在表里面统计true的个数即可。**现在思路是不是变的简洁明了了!!
为了能表示出所有的子串,我们可以创建一个n*n大小的二维dp表
- dp[i][j] 表示:原字符串里面[i,j]区间的字符子串是否是回文子串
我们使用一个二维数组 dp
来记录字符串 s
的所有子串是否是回文字符串。dp
是一个 n * n
的二维数组,但实际上我们只用到它的"上三角部分",因为回文串的对称性决定了 dp[i][j]
(其中 i > j
)是多余的。
2.推导状态转移方程
对于回文字符串,我们一般分析一个"区间两头"的元素。
- 当
s[i] != s[j]
的时候(最左边的字符不等于最右边的字符):- 子串
s[i...j]
不可能是回文字符串,因此dp[i][j] = false
。
- 子串
- 当
s[i] == s[j]
(最左边的字符等于最右边的字符)的时候:- 根据长度分三种情况讨论:
- 长度为 1,也就是
i == j
:此时一定是回文字符串,因此dp[i][j] = true
。 - 长度为 2,也就是
i + 1 == j
:此时也一定是回文字符串,因为两个字符相等,所以dp[i][j] = true
。 - 长度大于 2:此时需要看看去掉两头字符的子串
s[i+1...j-1]
是否是回文字符串。如果s[i+1...j-1]
是回文字符串,那么s[i...j]
也是回文字符串,即dp[i][j] = dp[i + 1][j - 1]
。
- 长度为 1,也就是
- 根据长度分三种情况讨论:
综上,状态转移方程可以根据上述情况分别讨论。
注意:s[i...j]表示字符串 s
从索引 i
到索引 j
(包括两端)的子串。
3. 初始化:
因为我们的状态转移⽅程分析的很细致,因此⽆需初始化。
4.填表顺序:
我不知道大家有没有看到上面这个dp[i][j] = dp[i + 1][j - 1],也就是说dp[i+1][j-1]一定要比dp[i][j]先算出来!!!那就i只能从右往左遍历,j只能从前往后遍历但是我们又要满足这个i<j,所以我们应该按下面这个来填充
cpp
for(int i=n-1;i>=0;i--)
{
for(int j=i;j<n;j++)
{
}
}
5.返回值:
根据「状态表⽰和题⽬要求」,我们需要返回 dp 表中 true 的个数。
cpp
class Solution {
public:
int countSubstrings(string s) {
int n = s.size(); // 获取字符串s的长度
vector<vector<bool>> dp(n, vector<bool>(n)); // 初始化一个n*n的二维布尔数组dp,用于记录子串是否为回文
int ret = 0; // 初始化计数器ret,用于记录回文子串的总数
// 从字符串的末尾开始向前遍历,确保在计算dp[i][j]时,dp[i+1][j-1]已经被计算过
for (int i = n - 1; i >= 0; i--) {
for (int j = i; j < n; j++) {
// 如果子串的两端字符不相等,则该子串不是回文
if (s[i] != s[j]) {
dp[i][j] = false;
} else {
// 如果子串的两端字符相等,则根据子串的长度进行分类讨论
if (i == j) {
// 长度为1的子串一定是回文
dp[i][j] = true;
} else if (i + 1 == j) {
// 长度为2的子串,如果两端字符相等,则也是回文
dp[i][j] = true;
} else {
// 长度大于2的子串,如果去掉两端字符的子串是回文,则该子串也是回文
dp[i][j] = dp[i + 1][j - 1];
}
}
// 如果当前子串是回文,则计数器ret加1
if (dp[i][j] == true) {
ret++;
}
}
}
return ret; // 返回回文子串的总数
}
};
题目二------5. 最长回文子串 - 力扣(LeetCode)
其实这题和上面那题的思路是一模一样的。只不过上面那个是想要回文子串的数量,我们这题是要最长回文子串。
事实上,这种回文串问题,动态规划不是最优解。
中心拓展算法,马拉车算法解决回文串问题的效率可比动态规划高。但是我们还是要讲动态规划版本的解法,但是动态规划的思想可以直接让回文串的困难题全部变成简单题。
我们可以将所有子串是否是回文的信息保存在dp表里面。
1.状态表示
对于线性dp,我们可以用「经验+题目要求」来定义状态表示:
- 以某个位置为结尾,巴拉巴拉;
- 以某个位置为起点,巴拉巴拉。
这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
- dp[i] 表示:以i位置元素为结尾的「所有子串」中,回文串的个数。
但是这里有一个非常致命的问题,那就是我们无法确定i结尾的回文子串的样子。这样就会导致我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定一个回文子串。
上面的那种思路其实我是不赞同的。
**我们可以先预处理一番,将所有子串是否是回文子串的信息存储在dp表里面,然后在表里面统计true的个数即可。**现在思路是不是变的简洁明了了!!
为了能表示出所有的子串,我们可以创建一个n*n大小的二维dp表
- dp[i][j] 表示:原字符串里面[i,j]区间的字符子串是否是回文子串
我们使用一个二维数组 dp
来记录字符串 s
的所有子串是否是回文字符串。dp
是一个 n * n
的二维数组,但实际上我们只用到它的"上三角部分",因为回文串的对称性决定了 dp[i][j]
(其中 i > j
)是多余的。
2.推导状态转移方程
对于回文字符串,我们一般分析一个"区间两头"的元素。
- 当
s[i] != s[j]
的时候(最左边的字符不等于最右边的字符):- 子串
s[i...j]
不可能是回文字符串,因此dp[i][j] = false
。
- 子串
- 当
s[i] == s[j]
(最左边的字符等于最右边的字符)的时候:- 根据长度分三种情况讨论:
- 长度为 1,也就是
i == j
:此时一定是回文字符串,因此dp[i][j] = true
。 - 长度为 2,也就是
i + 1 == j
:此时也一定是回文字符串,因为两个字符相等,所以dp[i][j] = true
。 - 长度大于 2:此时需要看看去掉两头字符的子串
s[i+1...j-1]
是否是回文字符串。如果s[i+1...j-1]
是回文字符串,那么s[i...j]
也是回文字符串,即dp[i][j] = dp[i + 1][j - 1]
。
- 长度为 1,也就是
- 根据长度分三种情况讨论:
综上,状态转移方程可以根据上述情况分别讨论。
注意:s[i...j]表示字符串 s
从索引 i
到索引 j
(包括两端)的子串。
3. 初始化:
因为我们的状态转移⽅程分析的很细致,因此⽆需初始化。
4.填表顺序:
我不知道大家有没有看到上面这个dp[i][j] = dp[i + 1][j - 1],也就是说dp[i+1][j-1]一定要比dp[i][j]先算出来!!!那就i只能从右往左遍历,j只能从前往后遍历但是我们又要满足这个i<j,所以我们应该按下面这个来填充
cpp
for(int i=n-1;i>=0;i--)
{
for(int j=i;j<n;j++)
{
}
}
5.返回值:
我们需要返回最长的回文子串!!我们完全可以定义一个len和beign,这样子我们可以通过s.substr函数返回我们的回文子串
cpp
class Solution {
public:
// 定义一个函数,用于寻找并返回字符串s中的最长回文子串
std::string longestPalindrome(std::string s) {
int n = s.size(); // 获取字符串s的长度
// 初始化一个二维布尔数组dp,大小为n*n,用于记录字符串s的所有子串是否为回文
vector<vector<bool>> dp(n, vector<bool>(n, false));
// 初始化变量len,用于记录当前找到的最长回文子串的长度
// 初始化为1,因为至少有一个字符的回文子串存在
int len = 1;
// 初始化变量begin,用于记录当前找到的最长回文子串的起始位置
int begin = 0;
// 采用动态规划的思想,从字符串的末尾开始向前遍历
for (int i = n - 1; i >= 0; i--) {
// j表示子串的结束位置,从i开始遍历到字符串的末尾
for (int j = i; j < n; j++) {
// 如果子串的两端字符不相等,则dp[i][j]为false,表示该子串不是回文
if (s[i] != s[j]) {
dp[i][j] = false;
} else {
// 如果子串的两端字符相等,则需要根据子串的长度进行分类讨论
if (i == j) {
// 当子串长度为1时,它一定是回文,因此dp[i][j]为true
dp[i][j] = true;
} else if (i + 1 == j) {
// 当子串长度为2时,如果两端字符相等,则它也是回文,因此dp[i][j]为true
dp[i][j] = true;
} else {
// 当子串长度大于2时,如果去掉两端字符的子串是回文,则当前子串也是回文
// 因此,dp[i][j]的值等于dp[i + 1][j - 1]的值
dp[i][j] = dp[i + 1][j - 1];
}
}
// 如果当前子串是回文,并且其长度大于之前记录的最长回文子串长度
// 则更新len和begin的值
if (dp[i][j] && j - i + 1 > len) {
len = j - i + 1;
begin = i;
}
}
}
// 使用substr函数从字符串s中提取最长回文子串,并返回
return s.substr(begin, len);
}
};
题目三------1745. 分割回文串 IV - 力扣(LeetCode)
虽然说这题是困难题,但是实际上真的简单的一批。
首先处理一下数组即可。过程和上面一模一样的。
接着我们看题目要求。
只要这3个区间的子串都是回文串,那是不是就可以返回true了呢?
cpp
class Solution {
public:
bool checkPartitioning(string s) {
int n = s.size();
// 1.⽤ dp 把所有的⼦串是否是回⽂预处理⼀下
vector<vector<bool>>
dp(n, vector<bool>(n));
for (int i = n - 1; i >= 0; i--)
{
for (int j = i; j < n; j++)
{
if (s[i] != s[j])
{
dp[i][j] = false;
}
else
{
if (i == j || i + 1 == j) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
}
}
// 2. 枚举所有的第⼆个字符串的起始位置以及结束位置
for (int i = 1; i < n - 1; i++)
for (int j = i; j < n - 1; j++)
if (dp[0][i - 1] && dp[i][j] && dp[j + 1][n - 1])
return true;
return false;
}
};
题目四------132. 分割回文串 II - 力扣(LeetCode)
其实这题和139. 单词拆分 - 力扣(LeetCode) 的思路是差不多的,所以我希望大家学习完这题之后,可以去看看这题!!
这题看起来很困难,但是实际上很简单!!!
1.状态表示:
根据「经验」,继续尝试用i
位置为结尾,定义状态表示,看看能否解决问题。
dp[i]表示:字符串s中[0, i]区间上的字符串,最少分割的次数。
2.状态转移方程:
状态转移方程一般都是根据「最后一个位置」的信息来分析。
【0,i】是回文串,则dp[i]=0
【0,i】不是回文串,我们要考虑最后一刀切在哪里?
则我们需要设置另外一个变量j(注意0<j<=i),我们让j从1开始往右边遍历,如果发现【j,i】是回文串,则我们只需要在【0,i-1】区间的基础上加上一刀即可,我们就让dp[i]=dp[j-1]+1。
至于为什么设置0<j<=i,这是因为我们考虑的是最后一刀,j代表的是最后一个回文串的起始位置,当j=0的时候我们已经考虑过了。所以j必须从1开始。
由于我们要的是最小值,因此应该循环遍历一遍j
的取值,拿到里面的最小值即可。
所以状态转移方程是dp[i]=min(dp[i],dp[j-1]+1);
优化:
我们在状态转移方程里面分析到,要能够快速判断字符串里面的子串是否回文。因此,我们可以先处理一个isPalindrome
表(或类似的二维数组),里面保存所有子串是否回文的信息。
这个优化策略和上面几题的简直一模一样
3.初始化:
观察「状态转移方程」,我们会用到dp[j-1]
的值来表示[0, j-1]
区间上的最少分割次数。我们可以思考一下当i=0
的时候,[0, i]
区间上的字符串已经是空串或者一个字符(自然是回文串),最小的回文串就是1(不需要分割),j
往后的值就不用考虑了(因为dp[-1]
是没有意义的)。
因此,我们可以在循环遍历j
的值之前处理i=0
的情况,然后j
从1
开始循环。但是,为了防止求min
操作时,0
干扰结果(实际上在这个问题里不会,因为dp[i]
会初始化为一个较大的数),我们先把表里面的值初始化为「无穷大」(在C++中可以用INT_MAX
表示)。
4.填表顺序:
毫无疑问是「从左往右」。
5.返回值:
根据「状态表示」,应该返回dp[n-1]
,其中n
是字符串s
的长度。
cpp
class Solution {
public:
int minCut(string s) {
int n = s.size(); // 获取字符串s的长度
// 初始化一个二维数组isPalindrome,用于记录s的所有子串是否是回文串
vector<vector<bool>> isPalindrome(n, vector<bool>(n));
// 从字符串末尾开始向前遍历,填充isPalindrome数组
for (int i = n - 1; i >= 0; i--) { // 外层循环,确定子串的起始位置
for (int j = i; j < n; j++) { // 内层循环,确定子串的结束位置
// 如果子串的首尾字符不相等,则该子串不是回文串
if (s[i] != s[j]) {
isPalindrome[i][j] = false;
} else {
// 如果首尾字符相等,则需要判断去掉首尾字符后的子串是否是回文串
// 如果是单个字符或相邻字符,则一定是回文串
if (i == j || i + 1 == j) {
isPalindrome[i][j] = true;
} else {
// 否则,依赖于去掉首尾字符后的子串的回文状态
isPalindrome[i][j] = isPalindrome[i + 1][j - 1];
}
}
}
}
// 初始化dp数组,dp[i]表示s[0:i]的最少分割次数
vector<int> dp(n, INT_MAX);
// 遍历字符串s的每个位置,计算最少分割次数
for (int i = 0; i < n; i++) {
// 如果s[0:i]是回文串,则不需要分割,分割次数为0
if (isPalindrome[0][i]) {
dp[i] = 0;
} else {
// 否则,遍历所有可能的分割点j,找到最少的分割次数
for (int j = 1; j < n; j++) {
// 如果s[j:i]是回文串,则可以考虑在j-1处分割,此时分割次数为dp[j-1]+1
if (isPalindrome[j][i]) {
dp[i] = min(dp[i], dp[j - 1] + 1);
}
}
}
}
// 返回整个字符串s的最少分割次数
return dp[n-1];
}
};
题目五------ 516. 最长回文子序列 - 力扣(LeetCode)
这题看起来有点难对不对?
按照之前的想法,我们很容易就想到状态表示:dp[i]表示:以i位置元素为结尾的所有子序列中,最长的回文子序列的长度。如果这么定义,我们一定会用到dp[i-1],dp[i-2]等等。但是我们根本推导不出来。
1.状态表示:
关于「单个字符串」问题中的「回⽂⼦序列」,或者「回⽂⼦串」,我们的状态表示研究的对象一般都是选取原字符串中的⼀段区域[i, j]内部的情况。这⾥我们继续选取字符串中的⼀段区域来研究:
- dp[i][j] 表⽰:s字符串中从索引i到索引j的子序列中的最长回⽂⼦序列的⻓度。
2.状态转移⽅程:
[i, j]区间内的所有的⼦序列中,最⻓的回⽂⼦序列的⻓度。
关于「回⽂⼦序列」和「回⽂⼦串」的分析⽅式,⼀般都是⽐较固定的,都是选择这段区域的「左右端点」的字符情况来分析。**因为如果⼀个序列是回⽂串的话,「去掉⾸尾两个元素之后依旧是回⽂串」,「⾸尾加上两个相同的元素之后也依旧是回⽂串」。**因为,根据「⾸尾元素」的不同,可以分为下⾯两种情况:
i. 当⾸尾两个元素「相同」的时候,也就是s[i] == s[j]:这个时候,我们要考虑三种情况
- 当i == j的时候,此时区间内只有⼀个字符。这个⽐较好分析,dp[i][j]表⽰⼀个字符的最⻓回⽂序列,⼀个字符能够⾃⼰组成回⽂串,因此此时dp[i][j] = 1。
- 当i + 1 == j的时候,此时区间内有两个字符。当这两个字符相同的时候,dp[i][j] = 2;
- 剩下的情况就是:那么s[i]和s[j]可以组成回⽂⼦序列的⼀部分,并且此时的最长回⽂⼦序列,应该是[i + 1, j - 1]区间内的那个最⻓回⽂⼦序列⾸尾填上s[i]和s[j],此时dp[i][j] = dp[i + 1][j - 1] + 2。
ii. 当⾸尾两个元素不「相同」的时候,也就是s[i] != s[j]:此时这两个元素就不能同时添加在⼀个回⽂串的左右,那么我们就应该让s[i]单独加在⼀个序列的左边,或者让s[j]单独放在⼀个序列的右边,看看这两种情况下的最⼤值:
- 单独加⼊s[i]后的区间在[i, j - 1];:dp[i][j]=dp[i][j-1];
- 单独加⼊s[j]后的区间在[i + 1, j];:dp[i][j]=dp[i+1][j];
- 取两者的最⼤值,于是dp[i][j] = max(dp[i][j - 1], dp[i + 1][j])。
3.初始化:
初始化其实就是防止越界。
其实我们可以画图理解一下。
对角线是可能越界的,但是我们已经处理了i==j的情况,所以我们无需另外做防越界处理
4.填表顺序:
根据「状态转移」,我们发现,在dp表所表示的矩阵中,dp[i + 1]表⽰下⼀⾏的位置,dp[j - 1]表⽰前⼀列的位置。这些都要比dp[i]和dp[j]先出现。
cpp
// 从字符串的末尾开始向前遍历,逐步构建 dp 数组
for (int i = n - 1; i >= 0; i--) {
// 对于每个起始索引 i,遍历所有可能的结束索引 j(从 i 到 n-1)
for (int j = i; j < n; j++) {
5.返回值:
根据「状态表示」,我们需要返回dp[0][n - 1],它表示整个字符串s的最长回⽂⼦序列的⻓度。
cpp
class Solution {
public:
// 计算字符串 s 的最长回文子序列的长度
int longestPalindromeSubseq(string s) {
int n = s.size(); // 获取字符串的长度
// 创建一个二维动态规划数组 dp,用于存储子问题的解
// dp[i][j] 表示字符串 s 从索引 i 到索引 j 的子串的最长回文子序列的长度
vector<vector<int>> dp(n, vector<int>(n, 0));
// 从字符串的末尾开始向前遍历,逐步构建 dp 数组
for (int i = n - 1; i >= 0; i--) {
// 对于每个起始索引 i,遍历所有可能的结束索引 j(从 i 到 n-1)
for (int j = i; j < n; j++) {
// 如果起始和结束字符相同
if (s[i] == s[j]) {
// 如果起始和结束索引相同,即子串只有一个字符,那么它是回文,长度为 1
if (i == j) {
dp[i][j] = 1;
}
// 如果起始和结束索引相邻,即子串有两个字符且相同,那么它是回文,长度为 2
else if (i + 1 == j) {
dp[i][j] = 2;
}
// 如果起始和结束索引不相邻,那么我们需要考虑去掉首尾字符后的子串的最长回文子序列长度
// 并加上当前的首尾字符(因为它们相同,可以构成回文的一部分)
else {
dp[i][j] = dp[i + 1][j - 1] + 2;
}
}
// 如果起始和结束字符不同
else {
// 那么最长回文子序列要么不包含起始字符,要么不包含结束字符
// 我们取这两种情况中的最大值
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
// 返回整个字符串 s 的最长回文子序列的长度
return dp[0][n - 1];
}
};
题目六------1312. 让字符串成为回文串的最少插入次数 - 力扣(LeetCode)
在「单个字符串」问题中,我们经常关注字符串中的某一段区域 [i, j]
来研究回文子串或子序列。这里,我们选取字符串中的一段区域 [i, j]
来分析,并定义一个状态 dp[i][j]
来表示该区域成为回文子串(或子序列,根据具体问题定义)所需的最少插入次数。
1.状态表示:
dp[i][j]
:表示字符串s
的子串s[i...j]
成为回文子串所需的最少插入次数。
2.状态转移方程:
根据回文串的性质,我们可以根据子串的首尾字符是否相同来分类讨论:
- 当首尾字符相同(
s[i] == s[j]
)时 :- 当
i == j
(单个字符,肯定是回文),则此时不需要任何插入,即 dp[i][j] = 0。 - 当
i == j - 1
(两个相邻且相同的字符,也是回文),则此时不需要任何插入,即dp[i][j] = 0。 - 否则,
[i, j]
区间内成为回文子串的最少插入次数取决于去掉首尾字符后的子串[i+1, j-1]
的情况,即 dp[i][j] = dp[i+1][j-1]。
- 当
- 当首尾字符不相同(
s[i] != s[j]
)时 :- 此时,我们可以在区间最右边补上一个
s[i]
,或者在最左边补上一个s[j]
,来尝试构造回文子串。 - 需要的最少插入次数分别是
dp[i+1][j] + 1
(右边补s[i]
)和dp[i][j+1] + 1
(左边补s[j]
)。 - 我们取这两种情况中的最小值作为
dp[i][j]
的值,即 dp[i][j] = min(dp[i+1][j], dp[i][j+1]) + 1。
- 此时,我们可以在区间最右边补上一个
3.初始化
初始化其实就是防止越界。
其实我们可以画图理解一下。
对角线是可能越界的,但是我们已经处理了i==j的情况,所以我们无需另外做防越界处理
4.填表顺序:
根据「状态转移」,我们发现,在dp表所表示的矩阵中,dp[i + 1]表⽰下⼀⾏的位置,dp[j - 1]表⽰前⼀列的位置。这些都要比dp[i]和dp[j]先出现。
cpp
// 从字符串的末尾开始向前遍历,逐步构建 dp 数组
for (int i = n - 1; i >= 0; i--) {
// 对于每个起始索引 i,遍历所有可能的结束索引 j(从 i 到 n-1)
for (int j = i; j < n; j++) {
5.返回值:
根据「状态表示」,我们需要返回dp[0][n - 1],它表示整个字符串s的最长回⽂⼦序列的⻓度。
cpp
class Solution {
public:
// 函数功能:计算将字符串s转变为回文所需的最少插入次数
int minInsertions(string s) {
int n = s.size(); // 获取字符串s的长度
// 初始化一个二维动态规划数组dp,dp[i][j]代表将s[i...j]转变为回文所需的最少插入次数
vector<vector<int>> dp(n, vector<int>(n));
// 从字符串的末尾开始,逆向遍历每一个字符作为子串的起始点
for (int i = n - 1; i >= 0; i--) {
// 遍历从i开始的每一个可能的子串终点j
for (int j = i; j < n; j++) {
// 当子串的首尾字符相同时
if (s[i] == s[j]) {
// 如果子串仅包含一个或两个相同的字符,则无需插入任何字符
if (i == j || i + 1 == j) {
dp[i][j] = 0;
}
// 否则,子串转变为回文所需的最少插入次数等于去掉首尾字符后的子串所需的最少插入次数
else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 当子串的首尾字符不相同时
else {
// 子串转变为回文所需的最少插入次数等于以下两种情况中的最小值加1:
// 1. 在j之后插入s[i],然后计算剩余子串s[i+1...j]所需的最少插入次数
// 2. 在i之前插入s[j],然后计算剩余子串s[i...j-1]所需的最少插入次数
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
}
}
// 返回整个字符串s转变为回文所需的最少插入次数
return dp[0][n - 1];
}
};