动态规划是一种常见的「算法设计技巧」。
动态规划遵循一套固定的流程:递归的暴力解法 -> 带备忘录的递归解法 -> 非递归的动态规划解法。这个过程是层层递进的解决问题的过程,你如果没有前面的铺垫,直接看最终的非递归动态规划解法,当然会觉得牛逼而不可及了。
本文将举两个栗子。
斐波那契数列指的是这样一个数列 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377,610,987,1597,2584,4181,6765,10946,17711,28657,46368…
这个数列从第3项开始,每一项都等于前两项之和。
针对这个数列,可以用一个递归的函数去计算第n项 数值
function recurFib(n) {//n为正整数
if(n < 2){
return n ;
}else {
return recurFib(n-1)+recurFib(n-2)
}
}
代码简洁易懂但是十分低效。如下图的递归树
PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
可以看到,当n=5的时候,递归树已经长的很大了,那么当n=20,甚至n=100的时候就更多了。
递归算法的时间复杂度怎么计算?
子问题个数乘以解决一个子问题需要的时间。
子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 2^n。
解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 1。
所以,这个算法的时间复杂度为 2^n,指数级别,n越大越爆炸。
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 recurFib(3) 被计算了两次,而且你可以看到,以recurFib(3) 为根的这个递归树体如果量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 recurFib(3) 这一个节点被重复计算,所以这个算法及其低效。
这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题。
明确了耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表。
function recurFib(n) {//n为正整数
var memo = [];
// 初始化最简情况
memo[1] = memo[2] = 1;
return judge(memo, n);
}
function judge(memo,n) {
// 未被计算过
if (memo[n]) {
memo[n] = judge(memo, n - 1) + judge(memo, n - 2);
}
return memo[n];
}
递归树图变化如下
可以看到带备忘录的递归算法,能把一棵存在巨量冗余的递归树通过剪枝,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。
那么递归算法的时间复杂度怎么算?
子问题个数乘以解决一个子问题需要的时间。
子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题个数就是n个
解决一个子问题需要的时间为1,依旧不变。
所以该算法的时间复杂度是 n。
而这种解法和动态规划的思想差不多。只不过这种方法叫做自顶向下,动态规划叫做自底向上。
就像前面的递归树图,是从上向下延伸,向下逐渐分解规模,直到 recurFib(1) 和 recurFib(2) 触底,然后逐层返回答案。
反过来,我们直接从最底下,最简单,问题规模最小的 recurFib(1) 和 recurFib(2) 开始往上推,直到推到我们想要的答案 recurFib(n),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
有了上一步带备忘录的递归解法的启发,我们可以把这个备忘录独立出来成为一张表,名为 dynamic_plan(额还是简写dp吧),在这张表上完成自底向上的推算。
function recurFib(n) {//n为正整数
var dp = [];
dp[0] = 0;
dp[1] = dp[2] = 1;
for (var i = 3; i <= n; i++){
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
由此可以看出动态规划只是带备忘录的递归解法的反向运算,所以效率也基本相同。
这里,引出「动态转移方程」这个名词,实际上就是描述问题结构的数学形式(裴波那契数列):
为啥叫「状态转移方程」?为了听起来高端。你把 f(n) 想做一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移,仅此而已。
你会发现,上面的几种解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。可见列出「状态转移方程」的重要性,它是解决问题的核心。很容易发现,其实状态转移方程直接代表着暴力解法。
千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即这个暴力解。优化方法无非是用备忘录或者 DP table,再无奥妙可言。
这个例子的最后,讲一个细节优化。细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 dp 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 1:
function recurFib(n) {//n为正整数
if(n < 2){
return n;
}
var prev = 0, curr = 1;
for (var i = 0; i <= n - 1; i++){
var 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(11) 由 F(10), F(9), F(6) 的最优解转移而来。
若要符合最优子结构,子问题间必须互相独立。何为相互独立?下面举个例子来讲解。
例子:这次高考你需要考出最高分。那么你的子问题就是需要把对应的语文、数学、英语…等科目考到最高分。而每个科目的最高分的子问题则是需要把每个科目试卷中对应的各个模块的分数考到最高。
因而子问题每个科目考到最高分是相互独立、互不干扰的。
子问题每个科目试卷对应的各个模块的分数考到最高是相互独立、互不干扰的。
如果加一个条件使数学成绩会受到语文成绩的影响,那么其子问题则不独立,最优子结构被破坏。
回归凑零钱问题,显然子问题之间是互相独立的。所以这个状态转移方程是可以得到正确答案的。
var coins = [10, 5, 1];
function coinChange(amount) {
if(amount == 0){
return 0;
}
var ans = amount;//由于最小面值是1,所以ans为初始最大零钱数(amount / 1)
for (var i = 0; i < coins.length; i ++) {
if(amount - coins[i] < 0){
continue;
}
var subProb = coinChange(amount - coins[i]);
if(subProb == -1){
continue;
}
ans = Math.min(ans, subProb + 1);
}
return ans == amount ? -1 : ans;
}
console.log(coinChange(21))
下图为递归树图:
时间复杂度分析:子问题总数 x 每个子问题的时间。子问题总数为递归树节点个数,这个比较难看出来,是 n^k,总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 k。所以总时间复杂度为 k*n^k,指数级别。
var coins = [10, 5, 1];
function coinChange(amount) {
// 备忘录初始化为-1
var memo = [];
return judge(amount, memo);
}
function judge(amount, memo) {
if (amount == 0){
return 0;
}
if (memo[amount]){
return memo[amount];
}
var ans = amount;
for (var i = 0; i < coins.length; i ++) {
// 金额不可达
if(amount - coins[i] < 0){
continue;
}
var subProb = judge(amount - coins[i], memo);
// 子问题无解
if(subProb == -1){
continue;
}
ans = Math.min(ans, subProb + 1);
}
// 记录本轮答案
memo[amount] = (ans == amount) ? -1 : ans;
return memo[amount];
}
console.log(coinChange(21))
总的时间复杂度是 kn
var coins = [10, 5, 1];
function coinChange(amount) {
var dp = [0];
for (var i = 1; i < amount + 1; i++) {
// 内层 for 在求所有子问题 + 1 的最小值
for (var j = 0; j < coins.length; j ++) {//便利所有面值
if (i - coins[j] < 0){
continue;
}
dp[i] = dp[i] ? Math.min(dp[i], 1 + dp[i - coins[j]]) : (1 + dp[i - coins[j]]);
}
}
return dp[amount] == amount ? -1 : dp[amount];
}
console.log(coinChange(21))