
🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在算法学习中,动态规划一直是许多同学绕不开的重点与难点.而在动态规划的众多题型中,回文串问题 又是一个非常经典的分支,例如判断回文子串、寻找最长回文子串、统计回文子串数量、求最长回文子序列等.这类问题看似形式多样,但背后往往遵循着相似的状态设计思路和转移规律.回文串问题的核心在于字符串两端字符之间的关系:如果首尾字符相同,那么问题通常可以向内部子串收缩;如果首尾字符不同,则需要根据题目要求选择不同的转移方式.正因如此,回文串问题非常适合用动态规划来解决.通过合理定义
dp状态,我们可以把一个复杂的字符串问题拆解成更小的子问题,并逐步推导出最终答案.本文将围绕"回文串问题"的动态规划解题框架展开,先梳理常见的状态定义方式、遍历顺序和状态转移规律,再结合几个经典案例进行分析,帮助大家建立一套清晰、可复用的解题思路.希望读完本文后,你不仅能够理解单个题目的做法,也能在遇到类似回文串问题时,快速判断是否适合使用动态规划,并独立完成状态设计与代码实现.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!
目录
- 1.回文串问题背景介绍
- 2.回文子串(OJ题)
- 3.最长回文子串(OJ题)
- 4.分割回⽂串IV(OJ题)
- 5.分割回⽂串II(OJ题)
- 6.最长回文子序列(OJ题)
- 8.让字符串成为回⽂串的最少插⼊次数(OJ题)
1.回文串问题背景介绍
在字符串类算法题中,回文串问题 是一类非常经典且高频出现的题型.所谓回文串,是指一个字符串从左往右读和从右往左读完全相同,例如 "aba"、"abba"、"level"、"上海自来水来自海上" 都可以看作回文结构.它的最大特点是具有明显的对称性,也就是字符串的首尾字符相互对应,中间部分同样满足类似的结构.
正是因为这种"首尾呼应、中心对称"的特征,回文串问题天然适合从区间角度进行分析.对于一个字符串 s,如果我们想判断区间 s[i...j] 是否为回文串,通常需要先比较两端字符 s[i] 和 s[j] 是否相同.如果它们不同,那么当前区间一定不是回文;如果它们相同,则还需要继续判断内部区间 s[i+1...j-1] 是否为回文.也就是说,一个较长区间的结果,往往依赖于更短区间的结果.
这种"由小区间推导大区间"的关系,正是动态规划发挥作用的地方.很多回文串问题表面上形式不同,例如:
- 判断某个字符串是否为回文串;
- 求字符串中的最长回文子串;
- 统计字符串中回文子串的数量;
- 求最长回文子序列;
- 计算将字符串变成回文串所需的最少操作次数;
- 求回文串划分的最少切割次数.
这些问题虽然目标不同,但它们都围绕一个核心展开:如何利用已经求出的较小子问题结果,推导出更大区间的答案 .因此,在解决回文串问题时,我们经常会定义二维动态规划数组 dp[i][j],用来表示字符串从下标 i 到下标 j 这一段区间的状态.根据题目要求不同,dp[i][j] 的含义也会有所不同:它可以表示该区间是否为回文串,也可以表示该区间内最长回文子序列的长度,还可以表示该区间变成回文串所需的最小代价.
回文串问题之所以重要,不仅因为它在算法题中出现频率高,更因为它能够很好地训练我们对区间动态规划的理解.相比普通的一维动态规划,回文串问题通常需要同时考虑左右边界、区间长度、内部状态以及遍历顺序.如果遍历顺序不正确,即使状态转移方程写对了,也可能因为依赖的子问题还没有被计算出来而导致结果错误.
因此,学习回文串问题时,不能只记忆某一道题的代码模板,更应该理解它背后的分析方法.我们需要思考:当前问题是否具有区间依赖关系?dp[i][j] 应该表示什么?当前状态依赖哪些更小的状态?当 s[i] 与 s[j] 相同或不同时,状态应该如何转移?遍历时应该先计算短区间还是长区间?
掌握这些核心思路后,回文串问题就不再是一类零散的题目,而可以被归纳为一套清晰的解题框架.无论是最长回文子串、回文子序列,还是回文划分与最小编辑操作,都可以从"区间状态 + 首尾字符关系 + 子问题递推"这一思路出发,逐步推导出相应的动态规划解法.
2.回文子串(OJ题)

算法思路:解法(动态规划):
我们可以先预处理一下,将所有子串是否回文的信息统计在 dp 表里面,然后直接在表里面统计 true 的个数即可.
1.状态表示:
为了能表示出来所有的子串,我们可以创建一个 n * n 的二维 dp 表,只用到上三角部分即可.
其中,dp[i][j] 表示:s 字符串 [i, j] 的子串,是否是回文串.
2.状态转移方程:
对于回文串,我们一般分析一个区间两头的元素:
- i. 当
s[i] != s[j]的时候:不可能是回文串,dp[i][j] = 0; - ii. 当
s[i] == s[j]的时候:根据长度分三种情况讨论:- 长度为1,也就是
i == j:此时一定是回文串,dp[i][j] = true; - 长度为2,也就是
i + 1 == j:此时也一定是回文串,dp[i][j] = true; - 长度大于2,此时要去看看
[i + 1, j - 1]区间的子串是否回文:dp[i][j] = dp[i + 1][j - 1].
- 长度为1,也就是
综上,状态转移方程分情况谈论即可.
3.初始化:
因为我们的状态转移方程分析的很细致,因此无需初始化.
4.填表顺序:
根据状态转移方程,我们需要从下往上填写每一行,每一行的顺序无所谓.
5.返回值:
根据状态表示和题目要求,我们需要返回 dp 表中 true 的个数.

核心代码
cpp
//功能:计算一个字符串中回文子串的个数
class Solution
{
public:
//输入:字符串s
//输出:s中所有回文子串的数量
int countSubstrings(string s)
{
//动态规划解题四步走
//1.创建 dp 表
//2.初始化
//3.填表
//4.返回值
//获取字符串的长度
int n = s.size();
//dp[i][j]:布尔类型,表示字符串s中【下标i 到 下标j】的子串是否为回文子串
//初始化n行n列的二维bool数组,默认值为false
vector<vector<bool>> dp(n, vector<bool>(n));
//用于统计回文子串的总个数
int ret = 0;
//外层循环:i从最后一个字符倒序遍历到第一个字符
//倒序原因:状态转移需要依赖 dp[i+1][j-1],保证先计算下方的子问题
for(int i = n - 1; i >= 0; i--)
{
//内层循环:j从i开始向右遍历,保证子串是 s[i...j](j >= i,子串有效)
for(int j = i; j < n; j++)
{
//第一步:子串两端的字符必须相等,才有可能是回文
if(s[i] == s[j])
{
//状态转移方程:
//1.如果 i+1 < j:说明子串长度大于2,中间的子串 dp[i+1][j-1] 必须是回文
//2.否则(子串长度为1 或 2):直接判定为回文子串
dp[i][j] = i + 1 < j ? dp[i + 1][j - 1] : true;
}
//如果当前子串 s[i...j] 是回文子串,总数量+1
if(dp[i][j])
ret++;
}
}
//返回最终统计的回文子串总数
return ret;
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// 功能:计算一个字符串中回文子串的个数
class Solution
{
public:
// 输入:字符串s
// 输出:s中所有回文子串的数量
int countSubstrings(string s)
{
// 获取字符串的长度
int n = s.size();
// dp[i][j]:表示字符串s中【下标i 到 下标j】的子串是否为回文子串
vector<vector<bool>> dp(n, vector<bool>(n, false));
// 用于统计回文子串的总个数
int ret = 0;
// 外层循环:i从最后一个字符倒序遍历到第一个字符
for (int i = n - 1; i >= 0; i--)
{
// 内层循环:j从i开始向右遍历
for (int j = i; j < n; j++)
{
// 两端字符相同,才有可能构成回文子串
if (s[i] == s[j])
{
// 长度为1或2时,直接是回文
// 长度大于2时,需要判断内部子串是否为回文
dp[i][j] = i + 1 < j ? dp[i + 1][j - 1] : true;
}
// 如果当前子串是回文子串,计数加1
if (dp[i][j])
{
ret++;
}
}
}
return ret;
}
};
void testCountSubstrings()
{
Solution sol;
string s1 = "abc";
cout << "测试用例1:" << s1 << endl;
cout << "回文子串个数:" << sol.countSubstrings(s1) << endl;
cout << "预期结果:3" << endl;
cout << endl;
string s2 = "aaa";
cout << "测试用例2:" << s2 << endl;
cout << "回文子串个数:" << sol.countSubstrings(s2) << endl;
cout << "预期结果:6" << endl;
cout << endl;
string s3 = "aba";
cout << "测试用例3:" << s3 << endl;
cout << "回文子串个数:" << sol.countSubstrings(s3) << endl;
cout << "预期结果:4" << endl;
cout << endl;
string s4 = "abba";
cout << "测试用例4:" << s4 << endl;
cout << "回文子串个数:" << sol.countSubstrings(s4) << endl;
cout << "预期结果:6" << endl;
cout << endl;
string s5 = "racecar";
cout << "测试用例5:" << s5 << endl;
cout << "回文子串个数:" << sol.countSubstrings(s5) << endl;
cout << "预期结果:10" << endl;
cout << endl;
}
int main()
{
testCountSubstrings();
return 0;
}

3.最长回文子串(OJ题)

算法思路:解法(动态规划):
a. 我们可以先用 dp 表统计出所有子串是否回文的信息
b. 然后根据 dp 表示 true 的位置,得到回文串的起始位置和长度.
那么我们就可以在表中找出最长回文串.
关于预处理所有子串是否回文,已经在上一道题目里面讲过,这里就不再赘述啦~

核心代码
cpp
//功能:给定一个字符串 s,找到 s 中最长的回文子串,并返回该子串
class Solution
{
public:
//输入:字符串s
//输出:s中最长的回文子串
string longestPalindrome(string s)
{
//动态规划解题四步走
//1.创建 dp 表
//2.初始化
//3.填表
//4.返回值
//获取字符串的长度
int n = s.size();
//dp[i][j]:布尔类型,表示字符串s中【下标i 到 下标j】的子串是否为回文子串
//初始化n行n列的二维bool数组,默认值为false
vector<vector<bool>> dp(n, vector<bool>(n));
//记录最长回文子串的信息:
//len:最长回文子串的长度,初始值为1(单个字符一定是回文)
//begin:最长回文子串的起始下标,初始值为0
int len = 1, begin = 0;
//外层循环:i从最后一个字符倒序遍历到第一个字符
//倒序原因:状态转移需要依赖 dp[i+1][j-1],保证先计算下方的子问题
for(int i = n - 1; i >= 0; i--)
{
//内层循环:j从i开始向右遍历,保证子串是 s[i...j](j >= i,子串有效)
for(int j = i; j < n; j++)
{
//子串两端字符相等,才有可能是回文
if(s[i] == s[j])
{
//状态转移方程:
//1.若 i+1 < j:子串长度大于2,中间子串必须是回文
//2.否则(子串长度为1 或 2):直接判定为回文
dp[i][j] = i + 1 < j ? dp[i + 1][j - 1] : true;
}
//如果当前子串是回文,并且长度大于已记录的最长长度
//更新最长回文子串的 长度 和 起始位置
if(dp[i][j] && j - i + 1 > len)
{
len = j - i + 1;
begin = i;
}
}
}
//截取字符串:从起始位置begin开始,截取长度为len的子串,返回结果
return s.substr(begin, len);
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// 功能:给定一个字符串 s,找到 s 中最长的回文子串,并返回该子串
class Solution
{
public:
// 输入:字符串s
// 输出:s中最长的回文子串
string longestPalindrome(string s)
{
// 获取字符串的长度
int n = s.size();
// 如果字符串为空,直接返回空字符串
if (n == 0)
{
return "";
}
// dp[i][j]:表示字符串s中【下标i 到 下标j】的子串是否为回文子串
vector<vector<bool>> dp(n, vector<bool>(n, false));
// len:最长回文子串的长度
// begin:最长回文子串的起始下标
int len = 1, begin = 0;
// 外层循环:i从最后一个字符倒序遍历到第一个字符
for (int i = n - 1; i >= 0; i--)
{
// 内层循环:j从i开始向右遍历
for (int j = i; j < n; j++)
{
// 子串两端字符相等,才有可能是回文
if (s[i] == s[j])
{
// 长度为1或2时,直接是回文
// 长度大于2时,需要判断内部子串是否为回文
dp[i][j] = i + 1 < j ? dp[i + 1][j - 1] : true;
}
// 如果当前子串是回文,并且长度大于已记录的最长长度
if (dp[i][j] && j - i + 1 > len)
{
len = j - i + 1;
begin = i;
}
}
}
// 返回最长回文子串
return s.substr(begin, len);
}
};
void testLongestPalindrome()
{
Solution sol;
string s1 = "babad";
cout << "测试用例1:" << s1 << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s1) << endl;
cout << "预期结果:bab 或 aba" << endl;
cout << endl;
string s2 = "cbbd";
cout << "测试用例2:" << s2 << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s2) << endl;
cout << "预期结果:bb" << endl;
cout << endl;
string s3 = "a";
cout << "测试用例3:" << s3 << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s3) << endl;
cout << "预期结果:a" << endl;
cout << endl;
string s4 = "ac";
cout << "测试用例4:" << s4 << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s4) << endl;
cout << "预期结果:a 或 c" << endl;
cout << endl;
string s5 = "abba";
cout << "测试用例5:" << s5 << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s5) << endl;
cout << "预期结果:abba" << endl;
cout << endl;
string s6 = "racecar";
cout << "测试用例6:" << s6 << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s6) << endl;
cout << "预期结果:racecar" << endl;
cout << endl;
string s7 = "abcda";
cout << "测试用例7:" << s7 << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s7) << endl;
cout << "预期结果:任意单个字符" << endl;
cout << endl;
string s8 = "";
cout << "测试用例8:空字符串" << endl;
cout << "最长回文子串:" << sol.longestPalindrome(s8) << endl;
cout << "预期结果:空字符串" << endl;
cout << endl;
}
int main()
{
testLongestPalindrome();
return 0;
}

4.分割回⽂串IV(OJ题)

算法思路:解法(动态规划):
题目要求一个字符串被分成三个非空回文子串,乍一看,要表示的状态很多,有些无从下手.
其实,我们可以把它拆成两个小问题:
i. 动态规划求解字符串中的一段非空子串是否是回文串;
ii. 枚举三个子串除字符串端点外的起止点,查询这三段非空子串是否是回文串.
那么这道困难题就秒变为简单题啦,变成了一道枚举题.
关于预处理所有子串是否回文,已经在上一道题目里面讲过,这里就不再赘述啦~

核心代码
cpp
//功能:判断字符串 s 是否可以分割成 三个 非空的回文子串
class Solution
{
public:
//输入:字符串s
//输出:是否能分割为三个非空回文子串(true/false)
bool checkPartitioning(string s)
{
int n = s.size();
//1.预处理:用 dp 表存储所有子串是否为回文
//dp[i][j]:表示字符串 s 中 [i, j] 区间的子串是否为回文串
vector<vector<bool>> dp(n, vector<bool>(n));
//倒序遍历 i,保证计算 dp[i][j] 时,dp[i+1][j-1] 已计算完成
for(int i = n - 1; i >= 0; i--)
{
//j 从 i 开始遍历,保证子串有效(j >= i)
for(int j = i; j < n; j++)
{
//两端字符相等,才可能是回文
if(s[i] == s[j])
{
//状态转移:
//子串长度 >2 → 依赖中间子串的结果;长度 ≤2 → 直接是回文
dp[i][j] = i + 1 < j ? dp[i + 1][j - 1] : true;
}
}
}
//2.枚举分割点,将字符串分割为 三个非空子串
//第一个子串:[0, i-1]
//第二个子串:[i, j]
//第三个子串:[j+1, n-1]
//i 是第二个子串的起始位置,必须 ≥1(保证第一个子串非空)
//i 必须 ≤n-2(保证第三段有空间)
for(int i = 1; i < n - 1; i++)
{
//j 是第二个子串的结束位置,必须 ≥i,且 ≤n-2(保证第三段非空)
for(int j = i; j < n - 1; j++)
{
//三段子串 全部都是回文 → 满足条件,直接返回 true
if(dp[0][i - 1] && dp[i][j] && dp[j + 1][n - 1])
return true;
}
}
//遍历完所有分割点都不满足,返回 false
return false;
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// 功能:判断字符串 s 是否可以分割成 三个 非空的回文子串
class Solution
{
public:
// 输入:字符串s
// 输出:是否能分割为三个非空回文子串(true/false)
bool checkPartitioning(string s)
{
int n = s.size();
// 如果字符串长度小于3,不可能分割成三个非空子串
if (n < 3)
{
return false;
}
// 1.预处理:用 dp 表存储所有子串是否为回文
// dp[i][j]:表示字符串 s 中 [i, j] 区间的子串是否为回文串
vector<vector<bool>> dp(n, vector<bool>(n, false));
// 倒序遍历 i,保证计算 dp[i][j] 时,dp[i+1][j-1] 已计算完成
for (int i = n - 1; i >= 0; i--)
{
// j 从 i 开始遍历,保证子串有效(j >= i)
for (int j = i; j < n; j++)
{
// 两端字符相等,才可能是回文
if (s[i] == s[j])
{
// 状态转移:
// 子串长度 > 2 → 依赖中间子串的结果
// 子串长度 <= 2 → 直接是回文
dp[i][j] = i + 1 < j ? dp[i + 1][j - 1] : true;
}
}
}
// 2.枚举分割点,将字符串分割为三个非空子串
// 第一段:[0, i-1]
// 第二段:[i, j]
// 第三段:[j+1, n-1]
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;
}
};
// 将 bool 结果转成字符串,方便输出
string boolToString(bool value)
{
return value ? "true" : "false";
}
// 单个测试用例执行函数
void runTest(string s, bool expected)
{
Solution sol;
bool result = sol.checkPartitioning(s);
cout << "测试字符串:" << s << endl;
cout << "实际结果:" << boolToString(result) << endl;
cout << "预期结果:" << boolToString(expected) << endl;
if (result == expected)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
void testCheckPartitioning()
{
// 示例1:
// "abcbdd" 可以分割为 "a" + "bcb" + "dd"
runTest("abcbdd", true);
// 示例2:
// "bcbddxy" 无法分割成三个非空回文子串
runTest("bcbddxy", false);
// 三个字符,且每个字符都可以单独作为回文
// "a" + "b" + "c"
runTest("abc", true);
// 整体都相同,可以分割为 "a" + "a" + "a"
runTest("aaa", true);
// 可以分割为 "aa" + "b" + "aa"
runTest("aabaa", true);
// 可以分割为 "racecar" + "x" + "level"
runTest("racecarxlevel", true);
// 长度不足3,无法分割成三个非空子串
runTest("a", false);
runTest("aa", false);
// 空字符串,无法分割
runTest("", false);
}
int main()
{
testCheckPartitioning();
return 0;
}

5.分割回⽂串II(OJ题)

算法思路:解法(动态规划):
1.状态表示:
根据经验,继续尝试用 i 位置为结尾,定义状态表示,看看能否解决问题:
dp[i] 表示:s 中 [0, i] 区间上的字符串,最少分割的次数.
2.状态转移方程:
状态转移方程一般都是根据最后一个位置的信息来分析:设 0 <= j <= i,那么我们可以根据 j ~ i 位置上的子串是否是回文串分成下面两类:
i. 当 [j ,i] 位置上的子串能够构成一个回文串,那么 dp[i] 就等于 [0, j - 1] 区间上最少回文串的个数 + 1,即 dp[i] = dp[j - 1] + 1;
ii. 当 [j ,i] 位置上的子串不能构成一个回文串,此时 j 位置就不用考虑.
由于我们要的是最小值,因此应该循环遍历一遍 j 的取值,拿到里面的最小值即可.
优化: 我们在状态转移方程里面分析到,要能够快速判读字符串里面的子串是否回文.因此,我们可以先处理一个 dp 表,里面保存所有子串是否回文的信息.
3.初始化:
观察状态转移方程,我们会用到 j - 1 位置的值.我们可以思考一下当 j == 0 的时候,表示的区间就是 [0, i].如果 [0, i] 区间上的字符串已经是回文串了,最小的回文串就是 1 了,j 往后的值就不用遍历了.
因此,我们可以在循环遍历 j 的值之前处理 j == 0 的情况,然后 j 从 1 开始循环.
但是,为了防止求 min 操作时,0 干扰结果.我们先把表里面的值初始化为无穷大.
4.填表顺序:
毫无疑问是从左往右.
5.返回值:
根据状态表示,应该返回 dp[n - 1].

核心代码
cpp
//功能:给定字符串 s,将 s 分割成若干回文子串,计算最少需要的分割次数
class Solution
{
public:
//输入:字符串s
//输出:将s分割成全回文子串所需的最小分割次数
int minCut(string s)
{
int n = s.size();
//第一步:预处理二维数组 isPal
//isPal[i][j]:布尔值,表示字符串 s 中 [i, j] 区间的子串是否为回文串
vector<vector<bool>> isPal(n, vector<bool>(n));
//倒序遍历 i,保证计算 isPal[i][j] 时,子问题 isPal[i+1][j-1] 已求解
for(int i = n - 1; i >= 0; i--)
{
//j 从 i 开始遍历,保证子串有效(j >= i)
for(int j = i; j < n; j++)
{
//状态转移:
//1.两端字符不相等 → 不是回文
//2.两端字符相等:
//- 子串长度>2 → 依赖中间子串结果
//- 子串长度≤2 → 直接是回文
isPal[i][j] = s[i] == s[j] ? (i + 1 < j ? isPal[i + 1][j - 1] : true) : false;
}
}
//第二步:动态规划计算最小分割次数
//dp[i]:表示字符串 s 中 [0, i] 区间的子串,所需的最小分割次数
//初始化为 INT_MAX(无穷大),表示初始状态下不可达
vector<int> dp(n, INT_MAX);
//遍历每个位置 i,计算 dp[i]
for(int i = 0; i < n; i++)
{
//如果 [0, i] 本身就是回文串 → 无需分割,分割次数为 0
if(isPal[0][i])
dp[i] = 0;
//如果 [0, i] 不是回文串,需要枚举分割点 j
else
{
//枚举分割点 j:将字符串分为 [0, j-1] 和 [j, i] 两部分
for(int j = 1; j <= i; j++)
{
//若后半部分 [j, i] 是回文串,则可以分割
if(isPal[j][i])
{
//状态转移:最小分割次数 = 前半部分最小次数 + 1次分割
dp[i] = min(dp[i], dp[j - 1] + 1);
}
}
}
}
//dp[n-1] 存储了整个字符串 [0, n-1] 的最小分割次数
return dp[n - 1];
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
// 功能:给定字符串 s,将 s 分割成若干回文子串,计算最少需要的分割次数
class Solution
{
public:
// 输入:字符串s
// 输出:将s分割成全回文子串所需的最小分割次数
int minCut(string s)
{
int n = s.size();
// 如果字符串为空或只有一个字符,不需要分割
if (n <= 1)
{
return 0;
}
// 第一步:预处理二维数组 isPal
// isPal[i][j]:表示字符串 s 中 [i, j] 区间的子串是否为回文串
vector<vector<bool>> isPal(n, vector<bool>(n, false));
// 倒序遍历 i,保证计算 isPal[i][j] 时,子问题 isPal[i+1][j-1] 已求解
for (int i = n - 1; i >= 0; i--)
{
// j 从 i 开始遍历,保证子串有效
for (int j = i; j < n; j++)
{
isPal[i][j] = s[i] == s[j] ? (i + 1 < j ? isPal[i + 1][j - 1] : true) : false;
}
}
// 第二步:动态规划计算最小分割次数
// dp[i]:表示字符串 s 中 [0, i] 区间所需的最小分割次数
vector<int> dp(n, INT_MAX);
for (int i = 0; i < n; i++)
{
// 如果 [0, i] 本身就是回文串,不需要分割
if (isPal[0][i])
{
dp[i] = 0;
}
else
{
// 枚举分割点 j,将字符串分为 [0, j-1] 和 [j, i]
for (int j = 1; j <= i; j++)
{
// 如果后半部分 [j, i] 是回文串,则可以更新答案
if (isPal[j][i])
{
dp[i] = min(dp[i], dp[j - 1] + 1);
}
}
}
}
return dp[n - 1];
}
};
void runTest(string s, int expected)
{
Solution sol;
int result = sol.minCut(s);
cout << "测试字符串:" << s << endl;
cout << "实际结果:" << result << endl;
cout << "预期结果:" << expected << endl;
if (result == expected)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
void testMinCut()
{
// 示例1:
// "aab" 可以分割为 "aa" + "b"
// 只需要切 1 次
runTest("aab", 1);
// 单个字符本身就是回文,不需要分割
runTest("a", 0);
// "ab" 可以分割为 "a" + "b"
// 需要切 1 次
runTest("ab", 1);
// 整个字符串本身就是回文,不需要分割
runTest("aba", 0);
// 整个字符串本身就是回文,不需要分割
runTest("abba", 0);
// "aabaa" 本身就是回文,不需要分割
runTest("aabaa", 0);
// "abc" 只能分割为 "a" + "b" + "c"
// 需要切 2 次
runTest("abc", 2);
// "aabb" 可以分割为 "aa" + "bb"
// 需要切 1 次
runTest("aabb", 1);
// "banana" 可以分割为 "b" + "anana"
// 需要切 1 次
runTest("banana", 1);
// 空字符串,不需要分割
runTest("", 0);
}
int main()
{
testMinCut();
return 0;
}

6.最长回文子序列(OJ题)

算法思路:解法(动态规划):
1.状态表示:
关于单个字符串问题中的回文子序列,或者回文子串,我们的状态表示研究的对象一般都是选取原字符串中的一段区域 [i, j] 内部的情况.这里我们继续选取字符串中的一段区域来研究:
dp[i][j] 表示:s 字符串 [i, j] 区间内的所有的子序列中,最长的回文子序列的长度.
2.状态转移方程:
关于回文子序列和回文子串的分析方式,一般都是比较固定的,都是选择这段区域的左右端点的字符情况来分析.因为如果一个序列是回文串的话,去掉首尾两个元素之后依旧是回文串,首尾加上两个相同的元素之后也依旧是回文串.根据首尾元素的不同,可以分为下面两种情况:
i. 当首尾两个元素相同 的时候,也就是 s[i] == s[j]:
那么 [i, 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],对应区间[i, j - 1],此时最长回文序列长度为dp[i][j - 1]; -
单独加入
s[j],对应区间[i + 1, j],此时最长回文序列长度为dp[i + 1][j].
因此:
dp[i][j] = max(dp[i][j - 1], dp[i + 1][j])
综上所述,状态转移方程为:
- 当
s[i] == s[j]时:dp[i][j] = dp[i + 1][j - 1] + 2 - 当
s[i] != s[j]时:dp[i][j] = max(dp[i][j - 1], dp[i + 1][j])
3.初始化:
初始化的目的是处理状态转移过程中的边界情况,我们根据状态转移方程来分析需要初始化的位置:
根据 dp[i][j] = dp[i + 1][j - 1] + 2,区间 [i, j] 有两种边界情况:
i. 当 i == j 时:区间内只有一个字符,单个字符本身就是回文,因此 dp[i][j] = 1;
ii. 当 i + 1 == j 时:区间内有两个字符,若两字符相同则 dp[i][j] = 2,不同则 dp[i][j] = 1.
对于第一种边界情况,我们在填表时可以同步处理;
对于第二种边界情况,dp[i + 1][j - 1] 的值为0,不会影响最终结果,因此无需额外处理.
4.填表顺序:
根据状态转移方程,dp[i][j] 的值依赖于 dp[i + 1][j](下一行)和 dp[i][j - 1](前一列).因此填表顺序应为:
从下往上填写每一行,每一行从左往右填写.
5.返回值:
根据状态表示,我们需要返回 [0, n - 1] 区间(整个字符串)的最长回文子序列长度,因此返回 dp[0][n - 1].

核心代码
cpp
//功能:给定字符串 s,找出其中最长的回文子序列的长度
//注意:子序列 ≠ 子串,子序列不要求连续,子串要求连续
class Solution
{
public:
//输入:字符串s
//输出:s中最长回文子序列的长度
int longestPalindromeSubseq(string s)
{
int n = s.size();
//1.创建 dp 表
//dp[i][j]:表示字符串 s 中【下标i 到 下标j】的子串中,最长回文子序列的长度
vector<vector<int>> dp(n, vector<int>(n));
//2.填表 + 初始化
//外层循环:i从最后一个字符倒序遍历到第一个字符
//倒序原因:状态转移需要依赖 dp[i+1][j-1],保证先计算下方的子问题
for(int i = n - 1; i >= 0; i--)
{
//初始化:单个字符的最长回文子序列长度为 1(i == j 时)
dp[i][i] = 1;
//内层循环:j从 i+1 开始向右遍历,枚举子串的右端点
for(int j = i + 1; j < n; j++)
{
//3.状态转移方程:
//情况1:两端字符 s[i] 和 s[j] 相等
//最长回文子序列长度 = 中间子串的最长长度 + 2(加上当前两个字符)
if(s[i] == s[j])
{
dp[i][j] = dp[i + 1][j - 1] + 2;
}
//情况2:两端字符不相等
//最长回文子序列长度 = 去掉左端 或 去掉右端 后的最大值
else
{
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
//4.返回值:整个字符串 [0, n-1] 的最长回文子序列长度
return dp[0][n - 1];
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
// 功能:给定字符串 s,找出其中最长的回文子序列的长度
// 注意:子序列 ≠ 子串,子序列不要求连续,子串要求连续
class Solution
{
public:
// 输入:字符串s
// 输出:s中最长回文子序列的长度
int longestPalindromeSubseq(string s)
{
int n = s.size();
// 如果字符串为空,最长回文子序列长度为 0
if (n == 0)
{
return 0;
}
// 1.创建 dp 表
// dp[i][j]:表示字符串 s 中【下标i 到 下标j】的子串中,最长回文子序列的长度
vector<vector<int>> dp(n, vector<int>(n, 0));
// 2.填表 + 初始化
// 外层循环:i从最后一个字符倒序遍历到第一个字符
for (int i = n - 1; i >= 0; i--)
{
// 初始化:单个字符的最长回文子序列长度为 1
dp[i][i] = 1;
// 内层循环:j从 i+1 开始向右遍历
for (int j = i + 1; j < n; j++)
{
// 情况1:两端字符相等
if (s[i] == s[j])
{
dp[i][j] = dp[i + 1][j - 1] + 2;
}
// 情况2:两端字符不相等
else
{
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
// 4.返回整个字符串的最长回文子序列长度
return dp[0][n - 1];
}
};
void runTest(string s, int expected)
{
Solution sol;
int result = sol.longestPalindromeSubseq(s);
cout << "测试字符串:" << s << endl;
cout << "实际结果:" << result << endl;
cout << "预期结果:" << expected << endl;
if (result == expected)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
void testLongestPalindromeSubseq()
{
// 示例1:
// "bbbab" 的最长回文子序列可以是 "bbbb"
// 长度为 4
runTest("bbbab", 4);
// 示例2:
// "cbbd" 的最长回文子序列是 "bb"
// 长度为 2
runTest("cbbd", 2);
// 单个字符本身就是回文子序列
runTest("a", 1);
// 空字符串,最长回文子序列长度为 0
runTest("", 0);
// 所有字符相同,整个字符串都是回文子序列
runTest("aaaa", 4);
// 本身就是回文串,最长回文子序列就是整个字符串
runTest("racecar", 7);
// 没有重复字符时,最长回文子序列长度通常为 1
runTest("abc", 1);
// "agbdba" 的最长回文子序列可以是 "abdba"
// 长度为 5
runTest("agbdba", 5);
// "character" 的最长回文子序列可以是 "carac"
// 长度为 5
runTest("character", 5);
}
int main()
{
testLongestPalindromeSubseq();
return 0;
}

8.让字符串成为回⽂串的最少插⼊次数(OJ题)

算法思路:解法(动态规划):
1.状态表示:
关于单个字符串问题中的回文子序列,或者回文子串,我们的状态表示研究的对象一般都是选取原字符串中的一段区域 [i, j] 内部的情况.这里我们继续选取字符串中的一段区域来研究:
状态表示:dp[i][j] 表示字符串 [i, j] 区域成为回文子串的最少插入次数.
2.状态转移方程:
关于回文子序列和回文子串的分析方式,一般都是比较固定的,都是选择这段区域的左右端点的字符情况来分析.因为如果一个序列是回文串的话,去掉首尾两个元素之后依旧是回文串,首尾加上两个相同的元素之后也依旧是回文串.根据首尾元素的不同,可以分为下面两种情况:
i. 当首尾两个元素相同 的时候,也就是 s[i] == s[j]:
-
那么
[i, j]区间内成为回文子串的最少插入次数,取决于[i + 1, j - 1]区间内成为回文子串的最少插入次数; -
若
i == j或i == j - 1(此时[i + 1, j - 1]不构成合法区间),区间内只有 1~2 个字符,且首尾相同,本身就是回文子串,最少插入次数为0.
因此:dp[i][j] = i >= j - 1 ? 0 : dp[i + 1][j - 1]
ii. 当首尾两个元素不相同 的时候,也就是 s[i] != s[j]:
-
可以在区间最右边补上一个
s[i],最少插入次数为dp[i + 1][j] + 1(dp[i + 1][j]为[i+1, j]成为回文的最少次数,+1是本次插入); -
可以在区间最左边补上一个
s[j],最少插入次数为dp[i][j - 1] + 1(dp[i][j - 1]为[i, j-1]成为回文的最少次数,+1是本次插入).
因此:dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1
综上所述,状态转移方程为:
- 当
s[i] == s[j]时:dp[i][j] = i >= j - 1 ? 0 : dp[i + 1][j - 1] - 当
s[i] != s[j]时:dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1
3.初始化:
根据状态转移方程,所有状态都可以通过递推得到,无需额外初始化.
4.填表顺序:
根据状态转移的依赖关系,dp[i][j] 依赖于 dp[i + 1][j](下一行)和 dp[i][j - 1](前一列),因此填表顺序为:
从下往上填写每一行,每一行从左往右填写.
5.返回值:
根据状态表示,我们需要返回整个字符串 [0, n - 1] 区间成为回文子串的最少插入次数,因此返回 dp[0][n - 1].

核心代码
cpp
//功能:给定字符串 s,计算最少需要插入多少个字符,能让 s 变成回文串
class Solution
{
public:
//输入:字符串s
//输出:将s变为回文串所需的最少字符插入次数
int minInsertions(string s)
{
int n = s.size();
//1.创建 dp 表
//dp[i][j]:表示字符串 s 中【下标i 到 下标j】的子串,变成回文串所需的最少插入次数
vector<vector<int>> dp(n, vector<int>(n));
//2.填表
//外层循环:i从最后一个字符倒序遍历到第一个字符
//倒序原因:状态转移依赖 dp[i+1][j-1],保证子问题先被计算
for(int i = n - 1; i >= 0; i--)
{
//内层循环:j从 i+1 开始遍历
//当 i == j 时,单个字符已是回文,插入次数为 0,无需处理
for(int j = i + 1; j < n; j++)
{
//3.状态转移方程:
//情况1:两端字符 s[i] 和 s[j] 相等
//无需插入字符,最少插入次数 = 中间子串的最少插入次数
if(s[i] == s[j])
{
dp[i][j] = dp[i + 1][j - 1];
}
//情况2:两端字符不相等
//选择插入一个字符匹配左端/右端,取最小值后 +1(本次插入)
else
{
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
}
}
//4.返回值:整个字符串 [0, n-1] 变成回文串的最少插入次数
return dp[0][n - 1];
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
// 功能:给定字符串 s,计算最少需要插入多少个字符,能让 s 变成回文串
class Solution
{
public:
// 输入:字符串s
// 输出:将s变为回文串所需的最少字符插入次数
int minInsertions(string s)
{
int n = s.size();
// 如果字符串为空或只有一个字符,本身就是回文串,不需要插入
if (n <= 1)
{
return 0;
}
// 1.创建 dp 表
// dp[i][j]:表示字符串 s 中【下标i 到 下标j】的子串,
// 变成回文串所需的最少插入次数
vector<vector<int>> dp(n, vector<int>(n, 0));
// 2.填表
// 外层循环:i从最后一个字符倒序遍历到第一个字符
for (int i = n - 1; i >= 0; i--)
{
// 内层循环:j从 i+1 开始遍历
for (int j = i + 1; j < n; j++)
{
// 情况1:两端字符相等
// 不需要额外插入字符,结果取决于中间子串
if (s[i] == s[j])
{
dp[i][j] = dp[i + 1][j - 1];
}
// 情况2:两端字符不相等
// 可以选择匹配左端或匹配右端,取代价较小的一种
else
{
dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
}
}
// 4.返回整个字符串变成回文串的最少插入次数
return dp[0][n - 1];
}
};
void runTest(string s, int expected)
{
Solution sol;
int result = sol.minInsertions(s);
cout << "测试字符串:" << s << endl;
cout << "实际结果:" << result << endl;
cout << "预期结果:" << expected << endl;
if (result == expected)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
void testMinInsertions()
{
// 示例1:
// "zzazz" 本身就是回文串,不需要插入
runTest("zzazz", 0);
// 示例2:
// "mbadm" 可以插入 2 个字符变成回文串
// 例如 "mbdadbm" 或 "mdbabdm"
runTest("mbadm", 2);
// 示例3:
// "leetcode" 最少需要插入 5 个字符
runTest("leetcode", 5);
// 单个字符本身就是回文串
runTest("a", 0);
// 空字符串不需要插入
runTest("", 0);
// 两个相同字符,本身就是回文串
runTest("aa", 0);
// 两个不同字符,插入 1 个即可
// 例如 "ab" -> "aba" 或 "bab"
runTest("ab", 1);
// "abc" 最少插入 2 个
// 例如 "abc" -> "abcba"
runTest("abc", 2);
// "race" 最少插入 3 个
// 例如 "race" -> "ecarace"
runTest("race", 3);
// "google" 最少插入 2 个
// 例如可以围绕 "goog" 或 "ogo" 构造回文
runTest("google", 2);
}
int main()
{
testMinInsertions();
return 0;
}


🚀真正的勇者不是流泪的人,而是含泪奔跑的人!
敬请期待下一篇文章内容:【动态规划算法】(两个数组的DP问题深度剖析与求解方法)
每日心灵鸡汤:每一次努力都是幸运的伏笔
任何时候,都不要怀疑自己的价值,也不要怀疑努力的意义.与其焦虑未来,不如专注当下,把眼前的事做好.哪有那么多的天赋异禀,那些优秀的人都曾和你一样,默默地翻山越岭.很久之后回头再看,你终会发现,所有成功都没有捷径,唯有努力和坚持,才有赢的可能.不是每一次努力,都一定会成功,但每一次努力,都是幸运的伏笔.在一次又一次的努力和坚持中,你终将明白努力奋斗的意义,在最美好的时光里你真的成为了更好的你,没有辜负时光,也没有辜负自己.
