目录
动态规划问题一般是求最值问题。
动态规划三要素:重叠子问题、最优子结构、状态转移方程。
状态转移方程最困难,作者提供思维框架:
明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。
开始举例说明。
一、斐波那契数列
求f(n).
1. 暴力递归
int fib(int N) {
if (N == 1 || N == 2) {
return 1;
}
return fib(N - 1) + fib(N - 2);
}
递归算法时间复杂度计算:
- 子问题个数:二叉树节点数,O(2^n).
- 每个子问题时间:f(n-1) + f(n-2)这样一个加法运算,O(1).
因此,这个算法时间复杂度为O(2^n).
但是,这样计算会存在重叠子问题,耗费大量时间。比如计算f(20)要计算f(19)和f(18),计算f(19)又会计算f(18)和f(17),这样f(18)被计算了两次。重叠子问题即为动态规划问题的第一个性质。
2. 带备忘录的递归解法
建立个备忘录,存所有计算过的子问题,就可避免重复计算。
inf fib(int N) {
if (N < 1) {
return 0;
}
// 备忘录全初始化为0
vector<int> memo(N + 1, 0);
return helper(memo, N);
}
int helper(vector<int>& memo, int n) {
// base case
if (n == 1 || n == 2) {
return 1;
}
// 已经计算过
if (memo[n] != 0) {
return memo[n];
}
memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
return memo[n];
}
时间复杂度:
- 子问题个数:无冗余计算,O(n).
- 每个子问题时间:O(1).
因此,该算法时间复杂度为O(n).
该算法是自顶向下,动态规划是自底向上。
3. 动态规划解法
将备忘录独立为一张表,叫dp table。自底向上计算。
int fib(int N) {
vector<int> dp(N + 1, 0);
// base case
dp[1] = 1;
dp[2] = 1;
for (int i = 3; i <= N; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[N];
}
再优化:由于当前状态仅与之前两个状态有关,因此不需要那么长的dp table。代码如下:
int fib(int n) {
if (n == 1 || n == 2) {
return 1;
}
int pre = 1;
int cur = 1;
for (int i = 3; i <= n; i++) {
int sum = pre + cur;
pre = cur;
cur = sum;
}
return sum;
}
引入概念:状态转移方程
f(n)作为一个状态,状态f(n)是由f(n-1)和f(n-2)相加转移来的,仅此而已。状态转移方程即暴力解法,动态规划是使用了备忘录或dp table优化。
二、凑零钱问题
k
种面值的硬币,面值分别为 c1, c2 ... ck
,每种硬币的数量无限,再给一个总金额 amount
,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。比如说 k = 3
,面值分别为 1,2,5,总金额 amount = 11
。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。
- 明确状态:原问题和子问题中变化的量。总金额amout。
- 确定dp函数的定义:当前的目标金额是n,至少需要dp(n)个硬币凑出。
- 确定选择并择优:可以做出什么选择改变状态。
- 确定base case:dp[0] = 0
int coinChange(vector<int>& coins, int amount) {
// 数组大小为 amount + 1,初始值也为 amount + 1
vector<int> dp(amount + 1, amount + 1);
// base case
dp[0] = 0;
for (int i = 0; i < dp.size(); i++) {
// 内层 for 在求所有子问题 + 1 的最小值
for (int coin : coins) {
// 子问题无解,跳过
if (i - coin < 0) continue;
dp[i] = min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}