动态规划常见问题类型:
一、线性DP:
斐波那契数列
爬楼梯问题
最大子数组和
二、区间DP(这个其实可以直接放到字符串DP中的...):
1.石子合并
-
转移表达式:
dp[i][j] = min{dp[i][k] + dp[k+1][j] + cost(i, j)} (i ≤ k < j) -
具体转移表达式
javafor (int k = i; k < j; k++) { int cost = dp[i][k] + dp[k+1][j] + (prefixSum[j+1] - prefixSum[i]); dp[i][j] = Math.min(dp[i][j], cost); } -
详细解释:
- 分割策略
在区间[i, j]中选择分割点k
将区间分为[i, k]和[k+1, j]两部分 - 代价构成
dp[i][k]: 合并左半部分[i, k]的代价
dp[k+1][j]: 合并右半部分[k+1, j]的代价
prefixSum[j+1] - prefixSum[i]: 合并完成后两堆石子合并的代价(即区间[i, j]的总和)
- 分割策略
-
详细代码
java
public class StoneMerge {
public static int mergeStones(int[] stones) {
int n = stones.length;
if (n <= 1) return 0;
// 预处理:计算前缀和,用于快速计算区间和
int[] prefixSum = new int[n + 1];
for (int i = 0; i < n; i++) {
prefixSum[i + 1] = prefixSum[i] + stones[i];
}
// dp[i][j] = 合并区间 [i, j] 的最小代价
int[][] dp = new int[n][n];
// 枚举区间长度
for (int len = 2; len <= n; len++) { // 长度从2开始
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = Integer.MAX_VALUE;
// 枚举分割点 k
for (int k = i; k < j; k++) {
// 合并 [i, k] + 合并 [k+1, j] + 合并后的代价
int cost = dp[i][k] + dp[k + 1][j] +
(prefixSum[j + 1] - prefixSum[i]);
dp[i][j] = Math.min(dp[i][j], cost);
}
}
}
return dp[0][n - 1];
}
public static void main(String[] args) {
int[] stones = {3, 2, 4, 1};
System.out.println("最小合并代价: " + mergeStones(stones)); // 20
}
}
2.回文串分割
三、树形DP:
树的最大独立集
树的重心
四、背包问题:资源优化问题,关注约束下的最值
0-1背包 - 像买限量商品
- 每种物品只有一个
- 解法:二维DP,外循环物品,内循环背包容量(倒序)
完全背包 - 像自助餐厅打菜
- 每种食物可以无限取
- 解法:一维DP,外循环物品,内循环背包容量(正序)
多重背包 - 像超市购物有数量限制
- 每种商品有固定数量
- 解法:二进制优化或转为0-1背包
- 例题:

解法:
java
import java.util.*;
//结果可以用TreeSet来存放,
//
class Solution {
public int getDifferentWeights (int[] weights, int[] counts, int maxWeights) {
boolean[] allPosibility = new boolean[maxWeights + 1];
allPosibility[0] = true;
//处理每个砝码
for (int i = 0; i < weights.length; i++) {
int weight = weights[i];
int count = counts[i];
//处理每种重量可能,从大往小判断可以避免再重复判断
for (int j = maxWeights; j >= 0; j--) {
//先判断该种可能是否已经可以走
if(allPosibility[j]) {
//可以走再使用当前砝码不同数量去与最大重量比较,可以就可行
for (int k = 1; k <= count; k++) {
if ((j + weight * k) <= maxWeights) {
allPosibility[j + weight * k] = true;
} else {break;}
}
}
}
}
int result = 0;
for (boolean b : allPosibility) {
if (b) {
result ++;
}
}
return result;
}
}
// 注意类名必须为 Main, 不要有任何 package xxx 信息
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
// 注意 hasNext 和 hasNextLine 的区别
int n = in.nextInt();
int[] weights = new int[n];
int[] counts = new int[n];
int maxWeights = 0;
//存储好砝码数据,同时得到最大重量
for(int i = 0; i < n; i++) {
weights[i] = in.nextInt();
}
for(int i = 0; i < n; i++) {
counts[i] = in.nextInt();
}
for(int i = 0; i < n; i++) {
maxWeights += weights[i] * counts[i];
}
Solution solution = new Solution();
System.out.println(solution.getDifferentWeights(weights, counts, maxWeights));
in.close();
}
}
分组背包 - 像套餐选择
- 每组物品中只能选一个
- 解法:外循环组,中循环背包容量(倒序),内循环组内物品
- 例题:

解题:
java
import java.util.*;
class Solution {
public void getMax(ArrayList<ArrayList<int[]>> list, int[][] parents, int budget, int m) {
ArrayList<ArrayList<int[]>> groups = new ArrayList<>();//存放每个主件的可能性list
//ArrayList<int[]> options = new ArrayList<>();//存放每一个主件的可能性int数组
for(int j = 1; j <= m; j ++) {
ArrayList<int[]> options = new ArrayList<>();//存放每一个主件的可能性int数组,不能放外面否则所有主件的选项都被累积到了同一个列表中
if(parents[j][0] != 0) {//如果是主件
// 选项0:只买主件
options.add(new int[] {parents[j][0],parents[j][0] * parents[j][1]});
}
ArrayList<int[]> aAttachment = list.get(j);
// 选项1:主件+附件1
if(aAttachment.size() >= 1){
int[] aArray1 = aAttachment.get(0);
options.add(new int[] {
parents[j][0] + aArray1[0],
parents[j][0] * parents[j][1] + aArray1[0] * aArray1[1]
});
}
// 选项2:主件+附件2
if (aAttachment.size() >= 2) {
int[] aArray2 = aAttachment.get(1);
options.add(new int[] {
parents[j][0] + aArray2[0],
parents[j][0] * parents[j][1] + aArray2[0] * aArray2[1]
});
}
// 选项3:主件+附件1+附件2
if (aAttachment.size() >= 2) {
int[] aArray1 = aAttachment.get(0);
int[] aArray2 = aAttachment.get(1);
options.add(new int[] {
parents[j][0] + aArray2[0] + aArray1[0],
parents[j][0] * parents[j][1] + aArray2[0] * aArray2[1] + aArray1[0] * aArray1[1]
});
}
groups.add(options);
}
int[] dp = new int[budget + 1];
for(ArrayList<int[]> group : groups) {
if(group.isEmpty()) continue;
for(int w = budget; w >= 0; w--) {
for(int[] option : group){
int price = option[0];
int satisfaction = option[1];
if(price <= w) {
dp[w] = Math.max(dp[w], dp[w - price] + satisfaction);
}
}
}
}
System.out.println(dp[budget]);
}
}
// 注意类名必须为 Main, 不要有任何 package xxx 信息
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
// 注意 hasNext 和 hasNextLine 的区别
int budget = in.nextInt();//预算
int count = in.nextInt();//物品件数
ArrayList<ArrayList<int[]>> attachment = new ArrayList<>();
int[][] mainGoods = new int[count+1][2];
for(int i = 0; i <= count; i++) {
attachment.add(new ArrayList<>());
}
for (int i = 1; i <= count; i++) { // 注意 while 处理多个 case
int price = in.nextInt();
int content = in.nextInt();
int parent = in.nextInt();
//添加主件
if(parent == 0) {
mainGoods[i][0] = price;
mainGoods[i][1] = content;
}else { //添加附件,不用存parent 直接看它所属的list的index就代表是哪一个主件的
attachment.get(parent).add(new int[]{price, content});
}
//需要max来记录,需要target来判断价格是否超出,需要判断主附是否满足
}
Solution solution = new Solution();
solution.getMax(attachment, mainGoods, budget, count);
in.close();
}
}
解法套路总结:
状态定义:dp[i][w] 表示前i个物品在容量w下的最优解
状态转移:考虑拿或不拿当前物品的决策
初始化:根据题目要求初始化边界条件
结果:通常是dp[n][capacity]
关键区别在于对物品数量的约束不同,导致遍历顺序和状态转移略有差异。
五、字符串DP:序列分析问题,关注字符串的结构性质
解法步骤:
看到题目时:
先分析题目类型(匹配/子序列/分割/转换)
再选择合适的DP模型:
涉及两个字符串 → 优先考虑双序列型
涉及一个字符串的性质 → 单序列型或区间型
涉及区间操作 → 区间型
具体说明:
- 典型问题分类(先判断题目类型):
- A.匹配类:KMP、字符串匹配、正则表达式
- B.子序列类:LCS(最长公共子序列也可以是匹配类)、LIS(最长递增子序列)、不同路径变形
- C.分割类:单词拆分、回文分割、有效IP、解码方法数、石子合并
- D.转换类:编辑距离、最小窗口、字符串重构、字符串变换、模式转换(
只能用双序列模型来解决)- 识别标志
- 题目关键词:"最少...次数"、"最小...代价"、"...转换为..."、"编辑距离"
- 问题结构:有明确的源和目标、有可列举的操作集、求最优转换路径
- 识别标志
- 字符串DP模型(选什么模型来解决这个题):
- 双序列型:用两个维度 的状态解决两个字符串 的问题
- A. 匹配类: 最长公共子序列、字符串相似度、正则表达式
- D. 转换类: 编辑距离、字符串变换
- 单序列型:用一个维度 的状态解决一个字符串 的问题
- C. 分割类: 单词拆分、数字解码
- A. 匹配类: KMP部分状态、单字符串模式识别
- B. 子序列类: 最长回文子串、(最长)递增子序列
- 区间型:用二维状态 解决字符串区间 的问题
- B. 子序列类: (最长)回文子串(序列)、括号匹配
- C. 分割类: 区间分割、矩阵链乘变形、 回文串分割
- A. 匹配类: 区间内匹配统计
- 通用模型思路:
确定状态定义: 明确dp数组的含义
初始化边界: 处理长度为0或1的情况
状态转移: 根据问题特点写出转移方程
返回结果:根据dp数组返回最终答案
- 具体示例
-
编辑距离:是转换类问题,用双序列型
状态:dp[i][j] 表示s1前i个到s2前j个的最小编辑距离
转移: 考虑s1[i-1]和s2[j-1]的匹配情况javaif(s1[i-1]==s2[j-1]) dp[i][j]=dp[i-1][j-1] else dp[i][j] = min(删除, 插入, 替换) + 1 -
解码方法数: 是分割类问题,用单序列模型
状态:dp[i] 表示字符串前i个字符的解码方法数
转移: 考虑当前字符单独解码或与前一个字符组合解码javadp[i] = dp[i-1] (当 s[i-1] 可以单独解码时) + dp[i-2] (当 s[i-2:i] 可以组合解码时) -
最长回文子串:子序列问题,考虑用区间模型
状态:dp[i][j] 表示区间 [i, j] 的某种性质
转移: 通常枚举区间长度,然后枚举起点,从小区间推导大区间javadp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]
- 注意要点:
- 注意索引对应关系:
dp[i][j]对应s1[i-1]和s2[j-1] - 边界处理要仔细:
空字符串的情况 - 转移条件要完整:
相等、不相等等各种情况 - 结果返回要准确: 通常是
dp[m][n]或max/min值
具体试题:
1.最长公共子序列(LCS)
2.编辑距离
-
状态表达式:
javaif (s1[i-1] == s2[j-1]) dp[i][j] = dp[i-1][j-1] // 字符相同,无需操作 else dp[i][j] = min( dp[i-1][j] + 1, // 删除 s1[i-1] dp[i][j-1] + 1, // 插入 s2[j-1] dp[i-1][j-1] + 1 // 替换 s1[i-1] 为 s2[j-1] ) -
详细表达式
java// 初始化边界条件 for (int i = 0; i <= m; i++) dp[i][0] = i; // s1前i个变为空串需i次删除 for (int j = 0; j <= n; j++) dp[0][j] = j; // 空串变为s2前j个需j次插入 // 状态转移 for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (s1.charAt(i-1) == s2.charAt(j-1)) { dp[i][j] = dp[i-1][j-1]; // 字符相同,无操作 } else { dp[i][j] = Math.min( Math.min(dp[i-1][j] + 1, // 删除:s1删除当前字符 dp[i][j-1] + 1), // 插入:s1插入s2当前字符 dp[i-1][j-1] + 1); // 替换:替换s1当前字符为s2当前字符 } } } -
详细代码
java
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String s = scanner.nextLine();
String t = scanner.nextLine();
scanner.close();
int result = minEditDistance(s, t);
System.out.println(result);
}
public static int minEditDistance(String s, String t) {
int m = s.length();
int n = t.length();
// dp[i][j] 表示 s 的前 i 个字符转换为 t 的前 j 个字符所需的最小编辑距离
int[][] dp = new int[m + 1][n + 1];
// 初始化边界条件
// s 的前 0 个字符转换为 t 的前 j 个字符,需要 j 次插入操作
for (int j = 0; j <= n; j++) {
dp[0][j] = j;
}
// s 的前 i 个字符转换为 t 的前 0 个字符,需要 i 次删除操作
for (int i = 0; i <= m; i++) {
dp[i][0] = i;
}
// 填充 dp 表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s.charAt(i - 1) == t.charAt(j - 1)) {
// 字符相同,不需要操作
dp[i][j] = dp[i - 1][j - 1];
} else {
// 字符不同,需要操作
// 三种操作的最小值:插入、删除、替换
dp[i][j] = Math.min(
Math.min(dp[i - 1][j] + 1, // 删除 s[i-1]
dp[i][j - 1] + 1), // 插入 t[j-1]
dp[i - 1][j - 1] + 1 // 替换 s[i-1] 为 t[j-1]
);
}
}
}
return dp[m][n];
}
}
3.正则表达式匹配
4.最长回文字串
-
转移条件说明
边界相等:s[i] == s[j](首尾字符必须相等)
内部回文:dp[i+1][j-1] == true(去掉首尾后仍是回文) -
详细表达式:
java// 初始化 for (int i = 0; i < n; i++) { dp[i][i] = true; // 单个字符是回文 if (i < n-1) { dp[i][i+1] = (s[i] == s[i+1]); // 两个相邻字符相等是回文 } } // 状态转移(按长度从小到大) for (int len = 3; len <= n; len++) { // 枚举长度 for (int i = 0; i + len - 1 < n; i++) { // 枚举起始位置 int j = i + len - 1; // 结束位置 dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]; // 状态转移 } }
六、计数DP:
1.不同路径数
2.解码方法数
dp[i]表示字符串前i个字符的解码方法数- 转移表达式:
dp[i] = dp[i-1] (当 s[i-1] 可以单独解码时) + dp[i-2] (当 s[i-2:i] 可以组合解码时) - 转移条件说明
单字符解码条件:s[i-1] != '0'(字符不能为'0')
双字符解码条件:10 ≤ parseInt(s[i-2:i]) ≤ 26 - 核心代码:
java
public int numDecodings(String s) {
int n = s.length();
if (n == 0 || s.charAt(0) == '0') return 0;
// dp[i] 表示 s 前 i 个字符的解码方法数
int[] dp = new int[n + 1];
dp[0] = 1; // 空字符串有一种解码方式
dp[1] = 1; // 第一个字符不为'0'时有一种解码方式
for (int i = 2; i <= n; i++) {
// 单个字符解码
if (s.charAt(i-1) != '0') {
dp[i] += dp[i-1];
}
// 两个字符解码
int twoDigit = Integer.parseInt(s.substring(i-2, i));
if (twoDigit >= 10 && twoDigit <= 26) {
dp[i] += dp[i-2];
}
}
return dp[n];
}
七、优化问题:
1.最长递增子序列 (看不懂的话可以去看看LIS思维训练哈哈哈
java
import java.util.*;
class Solution {
public void redraiment(int[] woods, int num) {
int[] dp = new int[num];
if(num == 0) System.out.print(0);
if(woods == null) System.out.print(0);
for(int i = 0; i < num; i++){
dp[i] = 1;
}
for(int i = 0; i < num; i++){
for(int j = 0; j < i; j++){
if(woods[i] > woods[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);//到底是原来的路更长还是走新的那条路更长
}
}
}
int max = 0;
for(int a : dp){
max = Math.max(max,a);
}
System.out.print(max);
}
}
// 注意类名必须为 Main, 不要有任何 package xxx 信息
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
// 注意 hasNext 和 hasNextLine 的区别
int num = in.nextInt();
int[] woods = new int[num];
for(int i = 0; i < num; i++){
woods[i] = in.nextInt();
}
Solution solution = new Solution();
solution.redraiment(woods, num);
in.close();
}
}