算法精讲--动态规划(一):基础与核心思想
动态规划的本质
动态规划(DP)通过将复杂问题分解为重叠子问题,利用最优子结构特性,以空间换时间实现高效求解。其核心在于避免重复计算,典型特征为:
- 备忘录模式(自顶向下):递归+缓存
- DP 表模式(自底向上):迭代填表
适用场景
特征 | 示例问题 | 判断要点 |
---|---|---|
重叠子问题 | 斐波那契数列 | 递归树存在大量重复计算节点 |
最优子结构 | 背包问题 | 全局最优包含局部最优解 |
解题四部曲(以力扣 70 题为例)
阶段衔接控制流
graph TD
A[审题分析] -->|建立问题模型| B[方案设计]
B -->|推导状态方程| C[执行验证]
C -->|测试边界案例| D[总结优化]
D -->|反哺知识体系| A
1. 审题分析
// 审题三问:
// Q1: 题目核心约束是什么? → 每次只能爬1或2阶
// Q2: 所求目标是什么? → 到达n阶的总方案数
// Q3: 是否存在特殊边界? → n=1时只能1种方案
2. 方案设计
// 动态规划三要素:
// 状态定义 → dp[i]表示到达i阶的方案数
// 转移方程 → dp[i] = dp[i-1] + dp[i-2]
// 边界条件 → dp[0]=1, dp[1]=1
3. 执行验证
// Java 版本
public int climbStairs(int n) {
if(n <= 2) return n;
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];
}
4. 总结优化
空间优化技巧
动态规划中的空间优化可以通过滚动变量方法来实现,减少内存使用。
public int m(int[] nums) {
int prev = 0, curr = 0;
for (int num : nums) {
int temp = Math.max(curr, prev + num);
prev = curr;
curr = temp;
}
return curr;
}
我们只需要保留前两步的结果,而不是整个数组,从而节省了空间。
public int climbStairs(int n) {
if (n <= 1) return n;
int dp0 = 1,dp1 = 2;
for(int i = 2; i < n; i++) {
int new_dp = dp0 + dp1;
dp0 = dp1;
dp1 = new_dp;
}
return dp1;
}
易错点提示
- 错误初始化:未正确处理边界条件,特别是在小规模输入(如
n=0
或n=1
)时。 - 错误推导方向:在进行状态转移时,需要确保遍历顺序与状态依赖关系匹配,以防止遗漏或错误的计算。
- 状态定义模糊:应准确捕捉问题本质特征,确保定义的状态能够涵盖问题的所有情况。
优化维度 | 原始方案 | 滚动变量法 |
---|---|---|
时间复杂度 | O(n) | O(n) |
空间复杂度 | O(n) | O(1) |
扩展性 | 难以应对 n 极大 | 支持大数取模 |
其他典型案例解析
案例 1:最长递增子序列(力扣 300)
1. 审题分析
// 审题三问:
// Q1: 题目核心约束是什么? → 子序列必须是递增的
// Q2: 所求目标是什么? → 最长递增子序列的长度
// Q3: 是否存在特殊边界? → 数组长度为 0 时,结果为 0
2. 方案设计
// 动态规划三要素:
// 状态定义 → dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度
// 转移方程 → dp[i] = max(dp[j] + 1) ∀ j < i 且 nums[j] < nums[i]
// 边界条件 → dp 数组初始值都为 1,因为每个元素自身可以构成一个长度为 1 的子序列
3. 执行验证
public class LongestIncreasingSubsequence {
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) return 0;
int[] dp = new int[nums.length];
// 初始化 dp 数组,每个元素初始值为 1
for (int i = 0; i < nums.length; i++) {
dp[i] = 1;
}
int maxLength = 1;
for (int i = 1; i < nums.length; 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;
}
}
4. 总结优化
class Solution {
public int lengthOfLIS(int[] nums) {
int[] tails = new int[nums.length];
int size = 0; // 当前记录的最大长度
for (int x : nums) {
// 二分查找插入位置
int left = 0, right = size;
while (left < right) {
int mid = left + (right - left)/2;
if (tails[mid] < x) {
left = mid + 1; // x应该放在右侧
} else {
right = mid; // x应该放在左侧或当前位置
}
}
// 替换或追加
tails[left] = x;
if (left == size) size++; // 如果插入位置是末尾,说明长度增加
}
return size;
}
}
优化维度 | 原始方案 | 二分查找优化法 |
---|---|---|
时间复杂度 | ||
空间复杂度 | ||
扩展性 | 数据量增大时性能下降 | 能处理大规模数据 |
案例 2:打家劫舍(力扣 198)
1. 审题分析
// 审题三问:
// Q1: 题目核心约束是什么? → 相邻房屋不能同时偷取
// Q2: 所求目标是什么? → 前 i 间房能偷取的最大金额
// Q3: 是否存在特殊边界? → 房屋数量为 0 时,结果为 0;房屋数量为 1 时,结果为该房屋的金额
2. 方案设计
// 动态规划三要素:
// 状态定义 → dp[i] 表示前 i 间房能偷取的最大金额
// 转移方程 → dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
// 边界条件 → dp[0] = nums[0], dp[1] = max(nums[0], nums[1])
3. 执行验证
public class HouseRobber {
public int rob(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[nums.length - 1];
}
}
4. 总结优化
class Solution {
public int rob(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
// dp0表示不偷前一个房屋的最优解,dp1表示偷前一个房屋的最优解
int dp0 = 0, dp1 = nums[0];
for (int i = 1; i < nums.length; i++) {
int newDp = Math.max(dp1, dp0 + nums[i]); // 当前最大 = max(不偷当前,偷当前)
dp0 = dp1; // 前一个"不偷"状态继承之前的最大值
dp1 = newDp; // 更新当前最大值
}
return dp1;
}
}
优化维度 | 原始方案 | 滚动变量法 |
---|---|---|
时间复杂度 | ||
空间复杂度 | ||
扩展性 | 占用较多内存 | 节省内存,适合大规模数据 |
下期预告
在下一期中,我们将探讨多维 DP 与状态压缩技巧,结合真实案例,如编辑距离(力扣 72)和不同路径(力扣 62),探讨如何扩展状态定义维度,提升解决问题的不同策略。建议读者提前尝试这两个题目,思考如何扩展状态定义和优化算法策略。
附录 常见的动态规划转移方程类型及其适用场景:
1. 线性递推型
- 形式:
dp[i] = dp[i-1] + dp[i-2]
- 应用场景:
- 爬楼梯问题: 在每一阶的到达方式中,考虑前一阶和前两阶。
- 斐波那契数列: 计算第
n
项是前两项之和。
2. 选择型(最优选择)
- 形式:
dp[i] = max(dp[i-1], dp[i-2] + value[i])
或dp[i] = min(dp[i-1], dp[i-2] + value[i])
- 应用场景:
- 打家劫舍问题: 选择抢或不抢相邻房子的最大收益。
- 0/1 背包问题: 在一定容量下,选择物品以最大化总价值。
3. 状态转移型
- 形式:
dp[i][j] = dp[i-1][j] || dp[i-1][j-cost[i]]
- 应用场景:
- 背包问题: 确定是否选择某个物品,是否能够装满背包而使价值最大化。
- 子集和问题: 判断能否选择特定子集的和达到某个目标。
4. 区间型
- 形式:
dp[i][j] = dp[i][k] + dp[k+1][j]
- 应用场景:
- 矩阵链乘法问题: 计算不同顺序乘法所需的最小运算次数。
- 区间DP: 如求解最长回文子串。
5. 多维状态型
- 形式:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 应用场景:
- 最长公共子序列问题: 根据字符在两个序列中的位置决定子序列的长度。
- 编辑距离问题: 用于计算两个字符串之间的最少操作步骤。
6. 图论相关型
- 形式:
dp[node] = min(dp[parent] + cost)
- 应用场景:
- 最短路径问题: 如 Dijkstra 算法中的动态规划形式。
- 旅行商问题: 维护状态与路径来计算最优路线。
7. 分治型
- 形式:
dp[i][j] = min(dp[i][k] + dp[k+1][j])
- 应用场景:
- 分治方法中的动态规划: 将问题分解为子问题,并寻找合并方式。