算法精讲–动态规划(一):基础与核心思想

89 阅读7分钟

算法精讲--动态规划(一):基础与核心思想

动态规划的本质

动态规划(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;
}
易错点提示
  1. 错误初始化:未正确处理边界条件,特别是在小规模输入(如 n=0n=1)时。
  2. 错误推导方向:在进行状态转移时,需要确保遍历顺序与状态依赖关系匹配,以防止遗漏或错误的计算。
  3. 状态定义模糊:应准确捕捉问题本质特征,确保定义的状态能够涵盖问题的所有情况。
优化维度原始方案滚动变量法
时间复杂度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;
    }  
}
优化维度原始方案二分查找优化法
时间复杂度O(n2)O(n^2)O(nlogn)O(nlogn)
空间复杂度O(n)O(n)O(n)O(n)
扩展性数据量增大时性能下降能处理大规模数据

案例 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;
    }
}
优化维度原始方案滚动变量法
时间复杂度O(n)O(n)O(n)O(n)
空间复杂度O(n)O(n)O(1)O(1)
扩展性占用较多内存节省内存,适合大规模数据

下期预告

在下一期中,我们将探讨多维 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])
  • 应用场景:
    • 分治方法中的动态规划: 将问题分解为子问题,并寻找合并方式。