java算法母题30道-第5篇-动态规划
算法母题30道(第5篇):动态规划(21-25)
系列说明:本系列共6篇文章,涵盖30道经典算法母题,使用Java实现
适用场景:算法学习、面试准备、编程能力提升
30道算法母题总览
第1篇:数组与字符串基础(1-5)
- 两数之和
- 三数之和
- 盛最多水的容器
- 接雨水
- 最长无重复字符子串
第2篇:链表操作(6-10)
- 反转链表
- 合并两个有序链表
- 链表中倒数第k个节点
- 判断链表是否有环
- 合并K个有序链表
第3篇:栈与队列(11-15)
- 有效的括号
- 用栈实现队列
- 最小栈
- 滑动窗口最大值
- 每日温度
第4篇:二叉树遍历(16-20)
- 二叉树的前序遍历
- 二叉树的中序遍历
- 二叉树的后序遍历
- 二叉树的层序遍历
- 二叉树的最大深度
第5篇:动态规划(21-25)⭐ 本篇
- 爬楼梯
- 最长递增子序列
- 最长公共子序列
- 背包问题(0-1背包)
- 编辑距离
第6篇:搜索与排序(26-30)
- 二分查找
- 快速排序
- 归并排序
- 岛屿数量(DFS/BFS)
- 全排列(回溯)
动态规划核心思想
动态规划(Dynamic Programming,DP) 是一种通过将复杂问题分解为简单子问题来求解的方法。
核心特征
- 重叠子问题:子问题会被重复计算
- 最优子结构:全局最优解包含局部最优解
- 状态转移方程:子问题之间的关系
解题步骤
- 确定状态(dp 数组的含义)
- 确定状态转移方程
- 确定初始条件(base case)
- 确定计算顺序
- 空间优化(可选)
第21题:爬楼梯
📝 题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
💡 解题思路
动态规划:
- 状态定义:
dp[i]表示爬到第 i 阶的方法数 - 状态转移方程:
dp[i] = dp[i-1] + dp[i-2]- 到达第 i 阶,可以从第 i-1 阶爬 1 步,或从第 i-2 阶爬 2 步
- 初始条件:
dp[0] = 1, dp[1] = 1 - 计算顺序:从底向上(0 → n)
本质:这就是斐波那契数列
🎯 日常应用
- 路径规划:计算不同路径的数量
- 组合优化:资源分配的方案数
- 游戏设计:角色移动的可能性
- 金融模型:多步决策问题
🔧 代码解决的问题
- 计数问题
- 递推关系的应用
- 空间优化的技巧
💻 Java实现代码
public class ClimbingStairs {
/**
* 爬楼梯 - 动态规划
* @param n 楼梯阶数
* @return 不同的方法数
*/
public int climbStairs(int n) {
if (n <= 2) {
return n;
}
// dp 数组
int[] dp = new int[n + 1];
dp[1] = 1;
dp[2] = 2;
// 状态转移
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
/**
* 爬楼梯 - 空间优化(只保留前两个状态)
*/
public int climbStairsOptimized(int n) {
if (n <= 2) {
return n;
}
int prev2 = 1; // dp[i-2]
int prev1 = 2; // dp[i-1]
int current = 0;
for (int i = 3; i <= n; i++) {
current = prev1 + prev2;
prev2 = prev1;
prev1 = current;
}
return current;
}
// 测试方法
public static void main(String[] args) {
ClimbingStairs solution = new ClimbingStairs();
for (int n = 1; n <= 10; n++) {
System.out.println("n = " + n + ": " +
solution.climbStairs(n) + " 种方法");
}
}
}
️ 复杂度分析
基础 DP:
- 时间复杂度:O(n) - 一次遍历
- 空间复杂度:O(n) - dp 数组
空间优化版:
- 时间复杂度:O(n)
- 空间复杂度:O(1) - 只使用常数个变量
第22题:最长递增子序列
📝 题目描述
给你一个整数数组 nums,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。
示例:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4。
输入:nums = [0,1,0,3,2,3]
输出:4
输入:nums = [7,7,7,7,7,7,7]
输出:1
💡 解题思路
方法1:动态规划 O(n²)
- 状态定义:
dp[i]表示以nums[i]结尾的最长递增子序列长度 - 状态转移方程:
dp[i] = max(dp[j]) + 1, 其中 0 ≤ j < i 且 nums[j] < nums[i] - 初始条件:
dp[i] = 1(每个元素本身就是一个递增子序列)
方法2:贪心 + 二分查找 O(n log n)
- 维护一个数组
tails,其中tails[i]表示长度为 i+1 的递增子序列的最小尾部元素 - 遍历数组,对每个元素:
- 如果大于
tails的所有元素,直接追加 - 否则,用二分查找找到第一个 ≥ 当前元素的位置并替换
- 如果大于
日常应用
- 股票交易:最长上涨趋势
- 数据分析:趋势识别
- 生物信息学:基因序列比对
- 性能监控:指标增长趋势
🔧 代码解决的问题
- 子序列问题
- 最优子结构
- 贪心策略的应用
Java实现代码
import java.util.Arrays;
public class LongestIncreasingSubsequence {
/**
* 最长递增子序列 - 动态规划 O(n²)
* @param nums 输入数组
* @return 最长递增子序列的长度
*/
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1);
int maxLength = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLength = Math.max(maxLength, dp[i]);
}
return maxLength;
}
/**
* 最长递增子序列 - 贪心 + 二分查找 O(n log n)
*/
public int lengthOfLISOptimized(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
// tails[i] 表示长度为 i+1 的递增子序列的最小尾部元素
int[] tails = new int[nums.length];
int size = 0;
for (int num : nums) {
// 二分查找第一个 >= num 的位置
int left = 0, right = size;
while (left < right) {
int mid = left + (right - left) / 2;
if (tails[mid] < num) {
left = mid + 1;
} else {
right = mid;
}
}
// 更新或追加
tails[left] = num;
if (left == size) {
size++;
}
}
return size;
}
// 测试方法
public static void main(String[] args) {
LongestIncreasingSubsequence solution = new LongestIncreasingSubsequence();
int[] nums = {10, 9, 2, 5, 3, 7, 101, 18};
System.out.println("输入数组: " + Arrays.toString(nums));
System.out.println("最长递增子序列长度(DP): " + solution.lengthOfLIS(nums));
System.out.println("最长递增子序列长度(优化): " + solution.lengthOfLISOptimized(nums));
}
}
⏱️ 复杂度分析
动态规划:
- 时间复杂度:O(n²) - 双重循环
- 空间复杂度:O(n) - dp 数组
贪心 + 二分:
- 时间复杂度:O(n log n) - 遍历一次,每次二分查找 O(log n)
- 空间复杂度:O(n) - tails 数组
第23题:最长公共子序列
📝 题目描述
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
示例:
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace",它的长度为 3。
输入:text1 = "abc", text2 = "abc"
输出:3
输入:text1 = "abc", text2 = "def"
输出:0
解题思路
二维动态规划:
- 状态定义:
dp[i][j]表示text1[0..i-1]和text2[0..j-1]的最长公共子序列长度 - 状态转移方程:
如果 text1[i-1] == text2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 否则: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) - 初始条件:
dp[0][j] = 0, dp[i][0] = 0 - 计算顺序:从左到右,从上到下
🎯 日常应用
- 版本控制:Git 的差异比较(diff)
- 文本比对:文档相似度检测
- 生物信息学:DNA 序列比对
- 抄袭检测:代码或文章相似度
🔧 代码解决的问题
- 字符串匹配问题
- 二维 DP 的应用
- 序列比对
💻 Java实现代码
public class LongestCommonSubsequence {
/**
* 最长公共子序列 - 二维动态规划
* @param text1 第一个字符串
* @param text2 第二个字符串
* @return 最长公共子序列的长度
*/
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
// dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的 LCS 长度
int[][] dp = new int[m + 1][n + 1];
// 状态转移
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
// 字符相同,LCS 长度 +1
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
// 字符不同,取较大值
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
/**
* 获取最长公共子序列(扩展)
*/
public String getLCS(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int[][] dp = new int[m + 1][n + 1];
// 填充 dp 表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 回溯找到 LCS
StringBuilder lcs = new StringBuilder();
int i = m, j = n;
while (i > 0 && j > 0) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
lcs.append(text1.charAt(i - 1));
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return lcs.reverse().toString();
}
// 测试方法
public static void main(String[] args) {
LongestCommonSubsequence solution = new LongestCommonSubsequence();
String text1 = "abcde";
String text2 = "ace";
System.out.println("text1: " + text1);
System.out.println("text2: " + text2);
System.out.println("LCS 长度: " + solution.longestCommonSubsequence(text1, text2));
System.out.println("LCS: " + solution.getLCS(text1, text2));
}
}
⏱️ 复杂度分析
-
时间复杂度:O(m × n)
- m 和 n 分别是两个字符串的长度
- 需要填充 m × n 的 DP 表
-
空间复杂度:O(m × n)
- DP 表的大小为 (m+1) × (n+1)
- 可优化到 O(min(m, n))
第24题:背包问题(0-1背包)
📝 题目描述
有 n 个物品和一个容量为 W 的背包。每个物品只能使用一次。
第 i 个物品的重量是 weight[i],价值是 value[i]。
求解将哪些物品装入背包可使这些物品的总重量不超过背包容量,且总价值最大。
示例:
输入:weight = [1, 3, 4], value = [15, 20, 30], W = 4
输出:35
解释:选择物品1(重量1,价值15)和物品3(重量3,价值20),
总重量4,总价值35
💡 解题思路
动态规划:
- 状态定义:
dp[i][j]表示前 i 个物品,背包容量为 j 时的最大价值 - 状态转移方程:
如果不选第 i 个物品: dp[i][j] = dp[i-1][j] 如果选第 i 个物品(j >= weight[i-1]): dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i-1]] + value[i-1]) - 初始条件:
dp[0][j] = 0, dp[i][0] = 0
空间优化:
- 使用一维数组
dp[j] - 注意:内层循环必须从后向前遍历(避免重复使用物品)
🎯 日常应用
- 资源分配:预算最优分配
- 投资组合:选择最优投资组合
- 物流装载:货物装载优化
- 项目管理:任务优先级排序
代码解决的问题
- 最优化问题
- 资源约束下的最优选择
- 0-1 决策问题
💻 Java实现代码
public class Knapsack01 {
/**
* 0-1 背包问题 - 二维 DP
* @param weight 物品重量数组
* @param value 物品价值数组
* @param W 背包容量
* @return 最大价值
*/
public int knapsack2D(int[] weight, int[] value, int W) {
int n = weight.length;
int[][] dp = new int[n + 1][W + 1];
// 状态转移
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= W; j++) {
// 不选第 i 个物品
dp[i][j] = dp[i - 1][j];
// 选第 i 个物品(如果容量允许)
if (j >= weight[i - 1]) {
dp[i][j] = Math.max(dp[i][j],
dp[i - 1][j - weight[i - 1]] + value[i - 1]);
}
}
}
return dp[n][W];
}
/**
* 0-1 背包问题 - 一维 DP(空间优化)
*/
public int knapsack1D(int[] weight, int[] value, int W) {
int n = weight.length;
int[] dp = new int[W + 1];
// 遍历物品
for (int i = 0; i < n; i++) {
// 从后向前遍历背包容量(关键!)
for (int j = W; j >= weight[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
return dp[W];
}
/**
* 获取选择的物品(扩展)
*/
public int[] getSelectedItems(int[] weight, int[] value, int W) {
int n = weight.length;
int[][] dp = new int[n + 1][W + 1];
// 填充 DP 表
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= W; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= weight[i - 1]) {
dp[i][j] = Math.max(dp[i][j],
dp[i - 1][j - weight[i - 1]] + value[i - 1]);
}
}
}
// 回溯找到选择的物品
int[] selected = new int[n];
int j = W;
for (int i = n; i > 0 && j > 0; i--) {
if (dp[i][j] != dp[i - 1][j]) {
selected[i - 1] = 1; // 选择了第 i 个物品
j -= weight[i - 1];
}
}
return selected;
}
// 测试方法
public static void main(String[] args) {
Knapsack01 solution = new Knapsack01();
int[] weight = {1, 3, 4, 5};
int[] value = {15, 20, 30, 35};
int W = 7;
System.out.println("物品重量: " + java.util.Arrays.toString(weight));
System.out.println("物品价值: " + java.util.Arrays.toString(value));
System.out.println("背包容量: " + W);
System.out.println("最大价值(二维DP): " + solution.knapsack2D(weight, value, W));
System.out.println("最大价值(一维DP): " + solution.knapsack1D(weight, value, W));
int[] selected = solution.getSelectedItems(weight, value, W);
System.out.println("选择的物品: " + java.util.Arrays.toString(selected));
}
}
⏱️ 复杂度分析
二维 DP:
- 时间复杂度:O(n × W)
- 空间复杂度:O(n × W)
一维 DP:
- 时间复杂度:O(n × W)
- 空间复杂度:O(W) - 大幅优化
第25题:编辑距离
题目描述
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
示例:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
输入:word1 = "intention", word2 = "execution"
输出:5
💡 解题思路
二维动态规划:
- 状态定义:
dp[i][j]表示word1[0..i-1]转换成word2[0..j-1]的最少操作数 - 状态转移方程:
如果 word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1] # 不需要操作 否则: dp[i][j] = min( dp[i-1][j] + 1, # 删除 dp[i][j-1] + 1, # 插入 dp[i-1][j-1] + 1 # 替换 ) - 初始条件:
dp[i][0] = i(删除 i 个字符)dp[0][j] = j(插入 j 个字符)
🎯 日常应用
- 拼写检查:自动纠错
- 搜索引擎:查询建议(“你是不是要找…”)
- DNA 比对:基因序列差异
- 版本控制:文件差异计算
🔧 代码解决的问题
- 字符串编辑问题
- 最短编辑路径
- 字符串相似度计算
💻 Java实现代码
public class EditDistance {
/**
* 编辑距离 - 二维动态规划
* @param word1 源字符串
* @param word2 目标字符串
* @return 最少操作数
*/
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
// dp[i][j] 表示 word1[0..i-1] 转换成 word2[0..j-1] 的最少操作数
int[][] dp = new int[m + 1][n + 1];
// 初始条件
for (int i = 0; i <= m; i++) {
dp[i][0] = i; // 删除 i 个字符
}
for (int j = 0; j <= n; j++) {
dp[0][j] = j; // 插入 j 个字符
}
// 状态转移
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
// 字符相同,不需要操作
dp[i][j] = dp[i - 1][j - 1];
} else {
// 取三种操作的最小值
dp[i][j] = Math.min(
Math.min(dp[i - 1][j] + 1, // 删除
dp[i][j - 1] + 1), // 插入
dp[i - 1][j - 1] + 1 // 替换
);
}
}
}
return dp[m][n];
}
/**
* 获取编辑路径(扩展)
*/
public String getEditPath(String word1, String word2) {
int m = word1.length();
int n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// 填充 DP 表
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(
Math.min(dp[i - 1][j], dp[i][j - 1]),
dp[i - 1][j - 1]
) + 1;
}
}
}
// 回溯找到编辑路径
StringBuilder path = new StringBuilder();
int i = m, j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && word1.charAt(i - 1) == word2.charAt(j - 1)) {
path.append("保持 '").append(word1.charAt(i - 1)).append("'\n");
i--;
j--;
} else if (i > 0 && dp[i][j] == dp[i - 1][j] + 1) {
path.append("删除 '").append(word1.charAt(i - 1)).append("'\n");
i--;
} else if (j > 0 && dp[i][j] == dp[i][j - 1] + 1) {
path.append("插入 '").append(word2.charAt(j - 1)).append("'\n");
j--;
} else {
path.append("替换 '").append(word1.charAt(i - 1))
.append("' 为 '").append(word2.charAt(j - 1)).append("'\n");
i--;
j--;
}
}
return path.reverse().toString();
}
// 测试方法
public static void main(String[] args) {
EditDistance solution = new EditDistance();
String word1 = "horse";
String word2 = "ros";
System.out.println("word1: " + word1);
System.out.println("word2: " + word2);
System.out.println("编辑距离: " + solution.minDistance(word1, word2));
System.out.println("\n编辑路径:");
System.out.println(solution.getEditPath(word1, word2));
}
}
️ 复杂度分析
-
时间复杂度:O(m × n)
- m 和 n 分别是两个字符串的长度
- 需要填充 m × n 的 DP 表
-
空间复杂度:O(m × n)
- DP 表的大小为 (m+1) × (n+1)
- 可优化到 O(min(m, n))
📊 总结与对比
动态规划技巧总结
| 题目 | 状态定义 | 状态转移方程 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 爬楼梯 | dp[i]: 到第i阶的方法数 | dp[i] = dp[i-1] + dp[i-2] | O(n) | O(1) |
| 最长递增子序列 | dp[i]: 以nums[i]结尾的LIS | dp[i] = max(dp[j]) + 1 | O(n²)/O(n log n) | O(n) |
| 最长公共子序列 | dp[i][j]: text1[0…i]和text2[0…j]的LCS | 见上方 | O(mn) | O(mn) |
| 0-1背包 | dp[i][j]: 前i个物品容量j的最大价值 | 见上方 | O(nW) | O(W) |
| 编辑距离 | dp[i][j]: word1[0…i]到word2[0…j]的最少操作 | 见上方 | O(mn) | O(mn) |
动态规划解题模板
1. 一维 DP 模板
int[] dp = new int[n + 1];
dp[0] = base_case;
for (int i = 1; i <= n; i++) {
dp[i] = 状态转移方程;
}
return dp[n];
2. 二维 DP 模板
int[][] dp = new int[m + 1][n + 1];
// 初始化边界
for (int i = 0; i <= m; i++) dp[i][0] = base_case;
for (int j = 0; j <= n; j++) dp[0][j] = base_case;
// 状态转移
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
dp[i][j] = 状态转移方程;
}
}
return dp[m][n];
3. 背包问题模板
int[] dp = new int[W + 1];
for (int item : items) {
for (int j = W; j >= weight; j--) { // 从后向前
dp[j] = Math.max(dp[j], dp[j - weight] + value);
}
}
return dp[W];
动态规划优化技巧
-
空间优化:
- 一维 DP → 常数空间(只保留必要状态)
- 二维 DP → 一维 DP(滚动数组)
-
状态压缩:
- 使用位运算表示状态
- 减少状态维度
-
记忆化搜索:
- 递归 + 缓存
- 自顶向下的 DP
📚 下一篇预告
第6篇:搜索与排序(26-30)
- 二分查找
- 快速排序
- 归并排序
- 岛屿数量(DFS/BFS)
- 全排列(回溯)
如果觉得这篇文章对您有帮助,欢迎:
- 👍 点赞支持
- 💬 留言讨论
- ⭐ 收藏备用
有任何算法问题,欢迎在评论区交流!
更多推荐


所有评论(0)