1. 三要素和框架
动态规划问题的一般形式就是求最值。比如说让你求最长递增子序列呀,最小编辑距离呀等等。求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。
首先,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出正确的「状态转移方程」,才能正确地穷举。而且,你需要判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在「重叠子问题」,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。
以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素。具体什么意思等会会举例详解,但是在实际的算法问题中,写出状态转移方程是最困难的,这也就是为什么很多朋友觉得动态规划问题困难的原因,我来提供我总结的一个思维框架,辅助你思考状态转移方程:
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义
dp 数组/函数的含义。框架
# 自顶向下递归的动态规划 def dp(状态1, 状态2, ...): for 选择 in 所有可能的选择: # 此时的状态已经因为做了选择而改变 result = 求最值(result, dp(状态1, 状态2, ...)) return result # 自底向上迭代的动态规划 # 初始化 base case dp[0][0][...] = base case # 进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...)
自顶向下的时候,常用到备忘录。
而从低向上的时候,更多使用dp[],已经记录了子问题的最优解。
2. 经典例题
下面通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者主要是让你明白什么是重叠子问题(斐波那契数列没有求最值,所以严格来说不是动态规划问题),后者主要举集中于如何列出状态转移方程。
斐波那契数列
class Solution { Map<Integer, Integer> memo = new HashMap<>(); public int fib(int n) { return fibDB(n); } // 方法一 递归 public int getFib(int n) { if (n == 0 || n == 1) { return n; } return getFib(n - 1) + getFib(n - 2); } // 方法二 带备忘录的递归 public int getMemoFib(int n) { if (n == 0 || n == 1) { return n; } if (memo.containsKey(n)) { return memo.get(n); } int res = getMemoFib(n - 1) + getMemoFib(n - 2); memo.put(n, res); return res; } public int fibDB(int n) { if (n == 0) { return 0; } int[] dp = new int[n + 1]; dp[0] = 0; dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } }
凑零钱问题
首先,这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立。
那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程?
1、确定 base case,这个很简单,显然目标金额
amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。2、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额
amount。3、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。
4、明确
dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。所以我们可以这样定义
dp 函数:dp(n) 表示,输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量。// 伪码框架 int coinChange(int[] coins, int amount) { // 题目要求的最终结果是 dp(amount) return dp(coins, amount) } // 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币 int dp(int[] coins, int n) { // 做选择,选择需要硬币最少的那个结果 for (int coin : coins) { res = min(res, 1 + dp(coins, n - coin)) } return res }
递归方法,自顶向下
class Solution { int[] memo; int coinChange(int[] coins, int amount) { memo = new int[amount + 1]; // 备忘录初始化为一个不会被取到的特殊值,代表还未被计算 Arrays.fill(memo, -666); return dp(coins, amount); } int dp(int[] coins, int amount) { if (amount == 0) return 0; if (amount < 0) return -1; // 查备忘录,防止重复计算 if (memo[amount] != -666) return memo[amount]; int res = Integer.MAX_VALUE; for (int coin : coins) { // 计算子问题的结果 int subProblem = dp(coins, amount - coin); // 子问题无解则跳过 if (subProblem == -1) continue; // 在子问题中选择最优解,然后加一 res = Math.min(res, subProblem + 1); } // 把计算结果存入备忘录 memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res; return memo[amount]; } }
迭代方法,自底向上
int coinChange(int[] coins, int amount) { int[] dp = new int[amount + 1]; // 数组大小为 amount + 1,初始值也为 amount + 1 Arrays.fill(dp, amount + 1); // base case dp[0] = 0; // 外层 for 循环在遍历所有状态的所有取值 for (int i = 0; i < dp.length; i++) { // 内层 for 循环在求所有选择的最小值 for (int coin : coins) { // 子问题无解,跳过 if (i - coin < 0) { continue; } dp[i] = Math.min(dp[i], 1 + dp[i - coin]); } } return (dp[amount] == amount + 1) ? -1 : dp[amount]; }
