1. 动态规划入门:从斐波那契到背包问题
动态规划(Dynamic Programming)是算法设计中最重要的思想之一,它通过将复杂问题分解为相互重叠的子问题,并存储子问题的解来避免重复计算。很多初学者第一次接触动态规划都是在学习斐波那契数列时,但实际上它的应用远不止于此。
我在准备算法面试时,花了整整两周时间专门研究动态规划问题。最初看到状态转移方程时完全摸不着头脑,直到通过大量练习才逐渐理解其精髓。本文将分享我在LeetCode上刷题的实战经验,重点解析五个经典动态规划问题。
2. 爬楼梯问题:斐波那契数列的经典应用
2.1 问题描述与初步分析
爬楼梯问题是动态规划最经典的入门题目。题目要求计算爬到n阶楼梯的不同方法数,每次可以爬1或2个台阶。乍看之下这像是一个排列组合问题,但实际上它完美符合动态规划的两个关键特性:
- 最优子结构:第n阶的解依赖于第n-1阶和第n-2阶的解
- 重叠子问题:计算f(n)时需要重复计算f(n-1)和f(n-2)
我第一次尝试时使用了递归解法:
int climbStairs(int n) { if (n <= 2) return n; return climbStairs(n-1) + climbStairs(n-2); }这种解法虽然直观,但时间复杂度高达O(2^n),当n=45时就需要数秒才能计算出结果。
2.2 动态规划解法优化
2.2.1 基础DP解法
改进方案是使用记忆化存储,将中间结果保存下来:
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]; }这样时间复杂度降为O(n),空间复杂度也是O(n)。
2.2.2 空间优化技巧
观察发现我们只需要前两个状态,因此可以进一步优化空间:
int climbStairs(int n) { if (n <= 2) return n; int a = 1, b = 2; for (int i = 3; i <= n; i++) { int c = a + b; a = b; b = c; } return b; }这种"滚动数组"技术将空间复杂度优化到O(1)。
提示:很多线性DP问题都可以用类似方法优化空间,这是面试中的加分项。
3. 杨辉三角:二维动态规划的代表
3.1 问题特点分析
杨辉三角要求生成前numRows行,其中每个数等于它上方两数之和。这实际上是一个二维动态规划问题:
- 状态定义:dp[i][j]表示第i行第j列的值
- 边界条件:每行首尾元素为1
- 状态转移:dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
3.2 实现细节与优化
基础实现如下:
public List<List<Integer>> generate(int numRows) { List<List<Integer>> res = new ArrayList<>(); for (int i = 0; i < numRows; i++) { List<Integer> row = new ArrayList<>(); for (int j = 0; j <= i; j++) { if (j == 0 || j == i) { row.add(1); } else { row.add(res.get(i-1).get(j-1) + res.get(i-1).get(j)); } } res.add(row); } return res; }虽然这个问题不需要特别优化,但它很好地展示了二维DP的实现模式。我在第一次实现时犯了一个错误:没有正确处理j的边界条件,导致数组越界。这提醒我们在处理二维DP时要特别注意边界条件。
4. 打家劫舍:线性DP的经典问题
4.1 问题建模与状态定义
打家劫舍问题要求在不触动警报的情况下获得最大收益。这需要定义合适的状态:
- dp[i]:考虑前i个房屋时能获得的最大金额
- 状态转移:dp[i] = max(dp[i-1], nums[i] + dp[i-2])
4.2 实现与空间优化
基础实现:
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], nums[i] + dp[i-2]); } return dp[nums.length-1]; }同样可以优化空间:
public int rob(int[] nums) { int prev2 = 0, prev1 = 0; for (int num : nums) { int curr = Math.max(prev1, prev2 + num); prev2 = prev1; prev1 = curr; } return prev1; }注意:这类问题变种很多,比如房屋排成环形的情况,需要特殊处理首尾相连的限制条件。
5. 完全平方数:背包问题的变种
5.1 问题转化思路
这道题要求用最少的完全平方数凑出n,可以看作是一个完全背包问题:
- 物品:完全平方数1,4,9,...
- 背包容量:n
- 目标:恰好装满背包且物品数量最少
5.2 动态规划解法
状态转移方程: dp[i] = min(dp[i], dp[i - jj] + 1) 对所有jj <= i
实现代码:
public int numSquares(int n) { int[] dp = new int[n+1]; Arrays.fill(dp, Integer.MAX_VALUE); dp[0] = 0; for (int i = 1; i <= n; i++) { for (int j = 1; j*j <= i; j++) { dp[i] = Math.min(dp[i], dp[i - j*j] + 1); } } return dp[n]; }这个问题的关键在于理解它如何转化为背包问题。我在第一次做时没有意识到这一点,走了很多弯路。后来发现很多看似不同的问题其实都有相似的DP结构。
6. 零钱兑换:完全背包的典型应用
6.1 问题分析与状态定义
零钱兑换是更标准的完全背包问题:
- dp[i]:凑出金额i所需的最少硬币数
- 初始化:dp[0]=0,其他为无穷大
- 状态转移:dp[i] = min(dp[i], dp[i-coin] + 1)
6.2 实现细节
public int coinChange(int[] coins, int amount) { int[] dp = new int[amount+1]; Arrays.fill(dp, amount+1); dp[0] = 0; for (int i = 1; i <= amount; i++) { for (int coin : coins) { if (coin <= i) { dp[i] = Math.min(dp[i], dp[i - coin] + 1); } } } return dp[amount] > amount ? -1 : dp[amount]; }常见错误:忘记初始化dp[0]=0,或者没有处理无法兑换的情况。我在第一次提交时就因为没有处理无解情况而WA。
7. 动态规划的解题框架
通过这五个问题,我总结出解决DP问题的通用步骤:
- 定义状态:明确dp数组的含义
- 确定状态转移方程:找出dp[i]与之前状态的关系
- 初始化:设置边界条件
- 确定计算顺序:确保计算当前状态时所需的状态已计算
- 考虑空间优化:看是否能减少空间复杂度
8. 常见错误与调试技巧
在练习过程中,我遇到了各种错误,总结出以下调试方法:
- 打印DP表:对于小规模输入,打印整个DP数组检查是否正确
- 检查边界条件:特别是n=0,1等特殊情况
- 验证状态转移:手动计算几个步骤看是否符合预期
- 空间优化后验证:确保优化没有改变算法逻辑
9. 动态规划的进阶学习路径
掌握基础DP后,可以继续学习:
- 区间DP(如矩阵链乘法)
- 树形DP(如二叉树中的最大路径和)
- 状态压缩DP(如旅行商问题)
- 数位DP(如数字1的个数统计)
每类问题都有其独特的状态定义和转移方式,需要大量练习才能熟练掌握。