动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用较多,比如说求最长递增子序列,最小编辑距离等。
既然是求最值,核心问题就是穷举。
动态规划的穷举有点特别,因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或「DP table」来优化穷举过程,避免不必要的计算。
而且,动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。
另外,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解并不容易,只有列出正确的「状态转移方程」才能正确地穷举。
以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素。但是在实际的算法问题中,写出状态转移方程是最困难的。辅助思考状态转移方程的思维框架:
明确「状态」-> 定义dp数组/函数的含义->明确「选择」->明确 base case
一、斐波那契数列(理解重叠子问题)
1. 暴力递归
斐波那契数列的数学形式就是递归的,写成代码如下:
int fib(int N) {
if(N==1 | N==2) return 1;
return fib(N-1) + fib(N-2);
}
这样写代码虽然简洁易懂,但是十分低效。假设N=20,画出递归树(但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助):
递归算法的时间复杂度怎么计算?子问题的个数乘以解决一个子问题需要的时间。
子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为O(2^n)。
解决一个子问题的时间,在本算法中,没有循环,只有一个加法操作,时间为O(1)。
所以这个算法的时间复杂度为O(2^N),指数级别,爆炸。
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如f(18)被计算了两次,而且你可以看到,以f(18)为根的这个递归树体量巨大,多算一遍,就会耗费巨大的时间。更何况,还不止f(18)这一个节点被重复计算,所以这个算法极其低效。
这就是动态规划问题的第一个性质:重叠子问题。
2. 带备忘录的递归解法
既然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。
int fib(int N){
if(N<1) return 0;
// 备忘录全初始化为0
List
// 初始化最简情况
return helper(memo , N);
}
int helper(List
// 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];
}
画出递归树,你就知道「备忘录」到底做了什么。
带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题的个数。
递归算法的时间复杂度计算:子问题个数乘以解决一个子问题需要的时间。这里子问题就是f(1), f(2), f(3), ……, 数量和输入规模 n = 20 成正比,解决一个子问题没循环,时间为O(1),所以时间复杂度被降到了O(n)。
至此,带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。动态规划一般都脱离了递归,而是由循环迭代完成计算,从问题规模最小的 f(1) 和 f(2) 开始往上推, 直到推到我们想要的答案 f(20)。
3. dp数组的迭代解法
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!
这里引出「状态转移方程」,实际上就是描述问题结构的数据形式。把 f(n) 想做一个状态 n,这个状态 n 是由状态 n-1 和状态 n-2 相加转移而来,这就叫 状态转移。
暴力解就是状态转移方程的直接循环操作,「备忘录」和 DP table只是暴力解的剪枝优化。所以千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即暴力解。优化方法无非使用备忘录或者DP table,再无奥妙可言。
4.细节优化
根据斐波那契数列的状态转移方程,当前状态只和之前两个状态相关,其实并不需要那么长的一个DP table来存储所有的状态,只要想办法存储之前的两个状态就行了。所以可以进一步优化,把空间复杂度降为O(1):
动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在演示算法设计螺旋上升的过程。
二、凑零钱问题(如何列出状态转移方程)
k 种面值的硬币,例如 k = 3,面值分别为1,2,5,每种硬币的数量无限,给定一个总金额 amount = 11,问最少需要多少枚硬币能够凑出11元。这里最少需要三枚硬币:11 = 5 + 5 + 1。如果不能凑出,返回 -1。
1. 暴力递归
这是一个动态规划问题,因为它具有「最优子结构」。要符合「最优子结构」,子问题间必须相互独立。比如:想要考出最高的成绩,那么你的 子问题就是要把语文考到最高,数学考到最高,英语……为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高,简答题……当然,最终就是你每门课都是满分,这就是最高的总成绩。这个过程符合最优子结构,“每门科目考到最高” 这些子问题是互相独立,互不干扰的。
但是如果加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,子问题并不独立,语文数学成绩无法同时最优,最优子结构被破坏。
那么凑零钱的问题为什么符合最优子结构呢?比如你想求 amount = 11 时的最少硬币数(原问题),只要你知道凑出 amount = 10 (10+1=11),或amount = 8 (8+3=11), 或amount=6(6+5=11)这些 子问题的最少硬币数,再凑一个对应的硬币,就能得到原问题的答案。因为硬币的数量是没有限制的,子问题之间没有相互制约,是相互独立的。
既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程。
先确定「状态」,也就是原问题和子问题中的变量。由于硬币数量无限,所以唯一的状态就是目标金额 amount。
然后确定 dp 函数的定义:当前的目标金额是 n ,至少需要 dp(n) 个硬币凑出该金额。
然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,选择就是从面额列表 coins 中选择一个硬币,然后目标金额就会减少。
最后明确base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:
至此,状态转移方程已经完成了,以上算法是暴力解法,以上代码的数学形式就是状态转移方程:
暴力解法这个问题就已经解决了,接下来需要消除重叠子问题:比如 amount = 11,coins = {1,2,5},递归树如下:
时间复杂度分析:子问题总数 x 每个子问题的时间。
子问题总数为递归树节点个数。总共 n 个子问题,每个子问题可以拆解为 k 条分支,每条分支为 当前总数 + 路径硬币面值,而当前总数如果不是最小子问题,会被继续往下计算 ,直到拆解为最小的子问题(n = 0 或 -1)。考虑最坏的情况,这是一棵满 k 叉树(当然实际并不是这样),总共 n +1 层。
这里是 (k ^ n) / (k-1) 个节点,可以大致看作节点数为 k^(n-1),另外每个子问题中含有一个遍历不同种类硬币的 for 循环,所以再乘上一个 k,时间复杂度大致为 O( k ^n),指数级别。
(注:这里推理结果与原文不同,原文较简略。若有不当,请不吝指出!原文如下)
2. 带备忘录的递归
只要和前面斐波那契数列问题一样,用备忘录把子问题的结果记下来,在计算子问题之前,先查找是否有现成答案,就可以消除子问题:
这样子问题总树不会超过 amount ,子问题数目最多为 n,处理每个子问题的循环 k 不变,所以时间复杂度下降为了 O (kn)。
3. dp数组的迭代解法
递归是自顶向下的,dp table 是自底向上的。
三、总结
斐波那契数列问题,解释了如何通过「备忘录」或者「dp table」的方法来优化递归树,并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。
凑零钱的问题,展示了如何流程化确定「状态转移方程」,只要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消除重叠子问题而已。
计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有的可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。
列出状态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不容易穷举完整。
备忘录、DP table就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门。