虽然,Carl的《代码随想录》和labuladong的《算法小抄》对“动态规划”类问题有着详尽且优质的解答,但仍然想在这里简单啰嗦一下动态规划。在这里,并不想以《算法导论》中较为晦涩且难懂的「矩阵连乘」
、「最优二叉搜索树」
等为样例进行讲解。
定义
动态规划,英文名:Dynamic Programming,简称DP。其算法的基本思想是:将待求解的问题分解成若干个子问题,先求解子问题,再由子问题的解得到原问题的解。换句话说,将一个复杂的问题,分解为多个相互联系的较为简单的子问题,对每个子问题进行求解,复杂问题的答案蕴含在子问题中(具备「最优子结构」)。
通常,可以用动态规划求解的问题也可以用穷举来解决问题。但在穷举的过程中会存在「重叠子问题」。即,在暴力穷举求解时,一些中间值会进行多次的重复计算,严重影响效率。以斐波那契数列为例:
int Fib(int n)
{
return (n < 2) ? n: Fib(n - 1) + Fib(n - 2);
}
递归树如下:
我们知道,这些进行重复计算的值如果只计算一次的话,求解问题的效率会有一定的提高。
为解决该问题,引入了「备忘录方法」。即,其在自顶向下递归过程中用一个表来保存已解决问题的答案,在求解时先查表,表中有的直接用,没有的再递归。上述斐波那契数列在每次递归求解 F i b ( n − 1 ) + F i b ( n − 2 ) Fib(n-1)+Fib(n-2) Fib(n−1)+Fib(n−2)时, F i b ( n − 2 ) Fib(n-2) Fib(n−2)的值在求解 F i b ( n − 1 ) Fib(n-1) Fib(n−1)时已经求过,直接从表中引用。
我们发现,在利用「备忘录方法」进行求解时,我们是每求解一个问题记录一次,当并非所有子问题都需要求解时,「备忘录方法」可以严格的只求解需要的问题,而当所有子问题都需要求解一次或多次时,考虑使用动态规划。
int fib(int N) {
if (N <= 1) return N;
vector<int> dp(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];
}
基本要素
动态规划三个基本要素分别为:最优子结构、重叠子问题、状态转移方程。
- 最优子结构:当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。分析最优子结构性质时,一般假设由问题的最优解导出的子问题不是最优的,然后再设法说明在这个假设下可以构造出一个比原问题更优解更好的解,从而导出矛盾。
- 重叠子问题:在用递归算法自顶向下解此问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法对每个问题只解一次,而后将其解保存在一个表格中,当再次需要解此问题时,用常数时间查看一下结果。因此,用动态规划算法通常只需要多项式时间。
- 状态转移方程:动态规划中,一个状态向另一个状态转变时的规则。实际的算法问题中,列出状态转移方程往往较为困难。在列相应的状态转移方程时,我们需要注意:确定状态数组
dp
中角标的含义、初始化时已确定的状态及递归公式。
求解步骤
- 找出最优解的性质,并刻划最优解的结构特征。
- 递归地定义最优值。
- 以自底向上的方式计算出最优值。
- 根据计算最优值时得到的信息,构造最优解。
对比
首先,动态规划和分治法都是分解成子问题,由子问题的解得到原问题的解。但需要将二者进行区分:动态规划中除了有诸多的重叠子问题外,各子问题之间还存在着一定的关联(通过状态转移方程递推其他状态)。而分治法中,各子问题相对独立。
再者,贪心算法和动态规划也同样的都需要分解子问题来求解。二者所不同的是:动态规划在求解问题时需要遍历所有的可能情况。而贪心算法在每个子问题求解时只考虑局部最优解的情况。语言介绍较为苍白无力,接下来用「拼凑面额问题」来对比二者的区别。在此,我们将会引入一些面额的纸币以方便举例。
能否凑出面额
假设,我们去商店买东西。已知,口袋里有如下几种面额的纸币各一张:1
,2
,5
,10
,13
,我们需要支付的金额为17
。而由于种种原因,商店并没有零钱。即,我们需要用上述五个数字中的部分进行加和,让值恰好为17。
-
贪心算法
- 首先拿出面值为
13
的纸币。由于13<17
,面值13
的纸币一定要使用。接下来,我们需要用1
,2
,5
,10
四张纸币支付4
。 - 面值为
10
的纸币超过了要支付的金额,不予使用。 - 面值为
5
的纸币超过了要支付的金额,不予使用。 - 由于
2 < 4
,面值为2
的纸币也一定使用。 - 最后,剩下一张面值为
1
的纸币,不足以支付剩余的金额2
。则贪心算法的结果是不能支付。
- 首先拿出面值为
-
动态规划
- 我们假设在此按照自顶向下(最大面值开始)求解该问题时,同样的也会经历上述步骤。但,上述步骤执行完时并不直接返回
false
,而是继续考虑。 - 这一次,我们将考虑不需要面值为
13
的纸币。而是使用面值为10
的纸币。这一次,我们需要利用剩下的面值为1
,2
,5
面值的纸币凑出7
。 - 由于
5 < 7
,使用面值为5
的纸币,我们需要利用剩下的面值为1
和2
的纸币凑够金额2。显然这是可以的。因此利用动态规划得到的结果是可以支付。
- 我们假设在此按照自顶向下(最大面值开始)求解该问题时,同样的也会经历上述步骤。但,上述步骤执行完时并不直接返回
最少纸币数量
这一次,我们有面值为2
,8
,10
的纸币,假设每种纸币的数量足够多。我们需要做的是拼凑出金额16
。我们所做的是怎么用较少的纸币数量拼凑出相应的金额。
-
贪心算法
- 首先,我们拿出面值为
10
的纸币,一张即可(10 < 16 < 2 * 10
)。接下来,需要利用面值为2
和8
的纸币凑够金额6
- 面值为
8
的纸币超过了要支付的金额,不予使用。 -
3 * 2 = 6
。故需要4张纸币。
- 首先,我们拿出面值为
-
动态规划
- 在上述基础上,动态规划还会去考虑不使用面值为
10
时候的其他情况,分别2 * 8
,8 + 4 * 2
,8 * 2
三种方案,最终确定为使用2张面值8
的纸币所用纸币数量最少。
- 在上述基础上,动态规划还会去考虑不使用面值为
最后,需要声明的是,以上样例中动态规划的解法是为方便理解,效率上有着改进的空间。