以下内容参照了https://zhuanlan.zhihu.com/p/78220312。
动态规划遵循一套固定的流程:
递归的暴力解法 -> 带备忘录的递归解法 -> 非递归的动态规划解法
下面以斐波那契数列为例,揭开动态规划的面纱:
斐波那契数列
一、暴力递归法
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
但是使用递归算法求解该问题往往是十分低效的,不仅存在占用内存大,且存在大量重复计算。如下图,当我们计算
f
(
20
)
f(20)
f(20),可看到每个分支都存在大量重复计算,如果我们能够把这些计算过的值先保存下来,是不是就可以避免了重复计算,这就是带有备忘录的递归解法,这也是动态规划的第一个性质:重叠子问题。在讲这个方法前,我们先来分析一下递归算法的时间复杂度,子问题的个数乘以解决一个子问题的时间。图中的结点即是一个子问题,解决一个子问题的时间,即
f
(
n
−
1
)
+
f
(
n
−
2
)
f(n-1)+f(n-2)
f(n−1)+f(n−2)显然为
O
(
1
)
O(1)
O(1),所以递归算法的时间复杂度与结点数相同为
O
(
2
n
)
O(2^n)
O(2n)。
二、带备忘录的递归解法
在上一个问题中,我们得出结论,如果能够保留此前计算出的值,即增加一个备忘录,就能提高递归解法的效率。下面的代码实际上做到了,如果备忘录中无值则进行相应计算,有值则直接返回,大大减少了计算所需要的的时间。
int fib(int N) {
if (N < 1) return 0;
// 备忘录全初始化为 0
vector<int> memo(N + 1, 0);
// 初始化最简情况
memo[1] = memo[2] = 1;
return helper(memo, N);
}
int helper(vector<int>& memo, int n) {
// 未被计算过
if (n > 0 && memo[n] == 0)
memo[n] = helper(memo, n - 1) +
helper(memo, n - 2);
return memo[n];
}
下面这个图可以更好地说明备忘录的作用。
通过将冗余的计算剪枝,极大的减少了子问题的个数。此时的时间复杂度降为
O
(
n
)
O(n)
O(n)。
至此,带备忘录的递归解法的效率已经和动态规划一样了。实际上,这种解法和动态规划的思想已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。
啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」。
啥叫「自底向上」?反过来,我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
三、动态规划
动态规划的难点在于找到一个状态转移方程。对于斐波那契数列来说,这个很好找,即 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n)=f(n-1)+f(n-2) f(n)=f(n−1)+f(n−2)。而且,有了上一步备忘录的启发,我们可以将备忘录独立成一张表,即DP Table。
int fib(int N) {
vector<int> dp(N + 1, 0);
dp[1] = dp[2] = 1;
for (int i = 3; i <= N; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[N];
}
但对于上面的代码,我们还可以继续优化,我们需要保存的只有当前状态和之前的两个状态,所以可以将时间复杂度降为
O
(
1
)
O(1)
O(1)。
int fib(int n) {
if (n < 2) return n;
int prev = 0, curr = 1;
for (int i = 0; i < n - 1; i++) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}
斐波那契数列的例子严格来说不算动态规划,以上旨在演示算法设计螺旋上升的过程。当问题中要求求一个最优解或在代码中看到循环和 max、min 等函数时,十有八九,需要动态规划大显身手。
凑零钱
题目:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,再给一个总金额 n,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,则回答 -1 。
比如说,k = 3,面值分别为 1,2,5,总金额 n = 11,那么最少需要 3 枚硬币,即 11 = 5 + 5 + 1 。下面走流程。
一、暴力解法
第一步,写出状态转移方程:
f
(
n
)
f(n)
f(n)是对于凑出零钱
n
n
n,所需要的最小次数。当
n
=
0
n=0
n=0,返回0,其他情况时,返回
1
+
m
i
n
(
f
(
n
−
c
1
)
)
1+min(f(n-c_1))
1+min(f(n−c1)),此处加1是最后一次凑零钱的操作。
其实,这个方程就用到了最优子结构性质:原问题的解由子问题的最优解构成。即 f(11) 由 f(10), f(9), f(6) 的最优解转移而来。
记住,要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。
比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高… 当然,最终就是你每门课都是满分,这就是最高的总成绩。
得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,“每门科目考到最高”这些子问题是互相独立,互不干扰的。
但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为子问题并不独立,语文数学成绩无法同时最优,所以最优子结构被破坏。
回到凑零钱问题,显然子问题之间没有相互制约,而是互相独立的。所以这个状态转移方程是可以得到正确答案的。
然后按照方差写出暴力递归算法即可:
int coinChange(vector<int>& coins, int amount) {
if (amount == 0) return 0;
int ans = INT_MAX;
for (int coin : coins) {
// 金额不可达
if (amount - coin < 0) continue;
int subProb = coinChange(coins, amount - coin);
// 子问题无解
if (subProb == -1) continue;
ans = min(ans, subProb + 1);
}
return ans == INT_MAX ? -1 : ans;
}
时间复杂度分析:子问题总数 x 每个子问题的时间。子问题总数为递归树节点个数,这个比较难看出来,是
O
(
n
k
)
O(n^k)
O(nk),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为
O
(
k
)
O(k)
O(k)。所以总时间复杂度为
O
(
k
∗
n
k
)
O(k*n^k)
O(k∗nk),指数级别。
接下来,我们引入备忘录机制:
二、带备忘录的递归算法
int coinChange(vector<int>& coins, int amount) {
// 备忘录初始化为 -2
vector<int> memo(amount + 1, -2);
return helper(coins, amount, memo);
}
int helper(vector<int>& coins, int amount, vector<int>& memo) {
if (amount == 0) return 0;
if (memo[amount] != -2) return memo[amount];
int ans = INT_MAX;
for (int coin : coins) {
// 金额不可达
if (amount - coin < 0) continue;
int subProb = helper(coins, amount - coin, memo);
// 子问题无解
if (subProb == -1) continue;
ans = min(ans, subProb + 1);
}
// 记录本轮答案
//*********************************************
memo[amount] = (ans == INT_MAX) ? -1 : ans;
//*********************************************
return memo[amount];
}
三、动态规划
有了状态转移方程之后,该问题的代码就不难写了,构建动态规划表:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
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];
}