【labuladong的算法小抄】1. 动态规划详解

动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用较多,比如说求最长递增子序列,最小编辑距离等。

既然是求最值,核心问题就是穷举。

动态规划的穷举有点特别,因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或「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 memo = new ArrayList<>(N+1);

    // 初始化最简情况

    return helper(memo , N);

}

int helper(List 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];

}

画出递归树,你就知道「备忘录」到底做了什么。

带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题的个数。

【labuladong的算法小抄】1. 动态规划详解_第1张图片
【labuladong的算法小抄】1. 动态规划详解_第2张图片

递归算法的时间复杂度计算:子问题个数乘以解决一个子问题需要的时间。这里子问题就是f(1), f(2), f(3), ……, 数量和输入规模 n = 20 成正比,解决一个子问题没循环,时间为O(1),所以时间复杂度被降到了O(n)。

至此,带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。动态规划一般都脱离了递归,而是由循环迭代完成计算,从问题规模最小的 f(1) 和 f(2) 开始往上推, 直到推到我们想要的答案 f(20)。

3. dp数组的迭代解法

有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!

【labuladong的算法小抄】1. 动态规划详解_第3张图片


【labuladong的算法小抄】1. 动态规划详解_第4张图片

这里引出「状态转移方程」,实际上就是描述问题结构的数据形式。把 f(n) 想做一个状态 n,这个状态 n 是由状态 n-1 和状态 n-2 相加转移而来,这就叫 状态转移。

暴力解就是状态转移方程的直接循环操作,「备忘录」和 DP table只是暴力解的剪枝优化。所以千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即暴力解。优化方法无非使用备忘录或者DP table,再无奥妙可言。

4.细节优化

根据斐波那契数列的状态转移方程,当前状态只和之前两个状态相关,其实并不需要那么长的一个DP table来存储所有的状态,只要想办法存储之前的两个状态就行了。所以可以进一步优化,把空间复杂度降为O(1):

【labuladong的算法小抄】1. 动态规划详解_第5张图片

动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在演示算法设计螺旋上升的过程。


二、凑零钱问题(如何列出状态转移方程)

 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 中选择一个硬币,然后目标金额就会减少。

【labuladong的算法小抄】1. 动态规划详解_第6张图片

最后明确base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:

【labuladong的算法小抄】1. 动态规划详解_第7张图片

至此,状态转移方程已经完成了,以上算法是暴力解法,以上代码的数学形式就是状态转移方程:

【labuladong的算法小抄】1. 动态规划详解_第8张图片

暴力解法这个问题就已经解决了,接下来需要消除重叠子问题:比如 amount = 11,coins = {1,2,5},递归树如下:

【labuladong的算法小抄】1. 动态规划详解_第9张图片

时间复杂度分析:子问题总数 x 每个子问题的时间。

子问题总数为递归树节点个数。总共 n 个子问题,每个子问题可以拆解为 k 条分支,每条分支为 当前总数 + 路径硬币面值,而当前总数如果不是最小子问题,会被继续往下计算 ,直到拆解为最小的子问题(n = 0 或 -1)。考虑最坏的情况,这是一棵满 k 叉树(当然实际并不是这样),总共 n +1 层。

【labuladong的算法小抄】1. 动态规划详解_第10张图片

这里是 (k  ^ n) / (k-1) 个节点,可以大致看作节点数为 k^(n-1),另外每个子问题中含有一个遍历不同种类硬币的 for 循环,所以再乘上一个 k,时间复杂度大致为  O( k ^n),指数级别。

(注:这里推理结果与原文不同,原文较简略。若有不当,请不吝指出!原文如下)

【labuladong的算法小抄】1. 动态规划详解_第11张图片

2. 带备忘录的递归

只要和前面斐波那契数列问题一样,用备忘录把子问题的结果记下来,在计算子问题之前,先查找是否有现成答案,就可以消除子问题:

【labuladong的算法小抄】1. 动态规划详解_第12张图片

这样子问题总树不会超过 amount ,子问题数目最多为 n,处理每个子问题的循环 k 不变,所以时间复杂度下降为了 O (kn)。

3. dp数组的迭代解法

递归是自顶向下的,dp table 是自底向上的。

【labuladong的算法小抄】1. 动态规划详解_第13张图片
【labuladong的算法小抄】1. 动态规划详解_第14张图片

三、总结

斐波那契数列问题,解释了如何通过「备忘录」或者「dp table」的方法来优化递归树,并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。

凑零钱的问题,展示了如何流程化确定「状态转移方程」,只要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消除重叠子问题而已。

计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有的可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。

列出状态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不容易穷举完整。

备忘录、DP table就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门。

你可能感兴趣的:(【labuladong的算法小抄】1. 动态规划详解)