本博客所有内容均整理自《算法图解》,欢迎讨论交流~
动态规划是一种很优雅的算法,它可以认为是使用了分治法的思想,即将大问题划分成小问题,逐一击破这些小问题,从而总体上解决大问题。
对于动态规划的定义,百度百科是这样给出的:动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。
动态规划一般用于解决非常棘手的问题,因为如果不棘手,我们就直接解决了,就不需要划分成小问题了。
实话实说,动态规划学起来很难,因为非常抽象。这里我引用《算法图解》书中的一个贯穿始终的例子来解析动态规划。
1、背包问题
假设你是一个小偷,背着一个可装4磅重量东西的背包去偷东西。你可盗窃的商品有如下三件:
为了让盗窃的商品价值最高,你该选择盗窃哪些商品呢?
第一种思路:简单暴力
对于上面这个问题,我们很容易就能想到一种解决办法,那就是穷举法。我们尝试各种可能的组合,判断是否能够装得下背包,然后在能够装得下的所有组合中选择价值最高的那一种,搞定!
所有可能的组合如下图所示:
不难看出,选择偷吉他+笔记本电脑的组合是最优的。这种办法可行,但是速度非常慢,而且不具有普适性。
在有3件商品的情况下,你需要计算8个不同的集合,而有4件商品时,你需要计算16种组合,当有n个商品时,你需要计算种组合。所以这种办法的运行时间为O(
)。
很明显,这不是一个可以让人信服的办法。只要商品数量多到一定程度,这种算法虽然一定有解,但行不通,因为世上没有长生不老药。
所以,我们希望通过一种近似算法快速获取该问题的近似解,虽然不一定是最优解,但很接近,而且速度快。
于是,动态规划的优越性就显现了出来。
第二种思路:近似算法——动态规划
上面说过,动态规划先解决子问题,再逐步解决大问题。
对于背包问题,你先解决小背包(子背包)问题,再逐步解决原来的问题。就像下面这张图表现的那样:
我们需要解决一个4磅背包的问题,那我们就首先解决3磅背包和1磅背包的问题,二者解决完之后合起来,就相当于解决了整个的4磅背包问题。
对于具体的解决步骤,我们一步一步地来。
每个动态规划算法都从一个网格开始,背包问题的网格如下:
网格的各行为商品,各列为不同容量的背包,1~4磅。所有这些列你都需要,因为它们将帮助你计算子背包的价值。
网格最初是空的。你将填充其中的每个单元格,网格填满后,就找到了问题的答案!我们接下来就一步一步地填写该网格。
1.吉他行
首先来看第一行,这是吉他行,意味着你将尝试把吉他装入背包。
在每个单元格中,都需要做一个简单的决定:偷不偷吉他?做决定的依据就是,你要找出一个价值最高的商品集合。
第一个单元格表示背包的容量为1磅。吉他的重量也是1磅,这意味着它能够装入背包。因此最高单元格可以包含吉他,价值为1500美元。所以单元格填写如下:
与这个单元格一样,每个单元格都将包含当前可装入背包的所有商品。
来看下一个单元格,这个单元格表示背包的容量为2磅,很明显,1磅的时候都足以装下吉他,2磅的时候就更可以了!
所以,单元格填写如下:
依此类推,这一行的其他单元格也一样填写。别忘了,这才是第一行,只有吉他可供选择,也就是说此时假设你还没法盗窃其他商品。所以第一行结束时,网格填写如下:
此时你可能心存疑惑,原来的问题说的是4磅的背包,我们为什么在这里要考虑容量为1磅、2磅等的背包呢?其实这个疑惑的答案很简单,因为我们现在在使用动态规划!动态规划从小问题着手,逐步解决大问题!这里解决的1磅、2磅的小背包问题,是为了解决4磅的大背包问题。
所以我们回顾第一行结束之后网格,此时,这行表示的是当前的最大价值,即只能偷吉他时,不同容量的背包可偷的商品的最大价值。很明显,此时1磅~4磅的背包可偷的商品的最大价值都是1500美元,方案就是偷走吉他。
2.音响行
我们来接着填充下一行——音响行。
你现在处于第二行了,可偷的商品有吉他和音响。在每一行,可偷的商品都是当前行的商品以及之前各行的商品。因此,当前你还不能偷笔记本电脑,而只能偷音响和吉他。
我们首先来看第一个单元格,它表示容量为1磅的背包。在第一行,可装入1磅背包的商品最大价值为1500美元。但是现在我们到了第二行,可以有改进吗?
首先,1磅的背包是可以装下吉他的,第一行也就是这么做的,所以价值为1500美元;其次,1磅的背包可以装下音响吗?答案是不能,所以,我们无法在此处偷音响或者偷音响+吉他的组合。
因而,此时最大价值依然是1500美元。网格填写如下:
很显然,接下来的两个单元格与第一个单元格类似,都是无法装下音响,只能装下吉他,所以网格更新如下:
当背包容量来到4磅时,情况就不同了。因为此时,背包可以装得下音响了!原来第一行的最大价值为1500美元,但此时由于我们多一个音响的选择,而且4磅的音响可以装得下,所以我们如果在背包中装入音响而不是吉他,价值将为3000美元!所以还是偷音响吧!因此网格更新如下:
当网格更新到上面这样时,很明显我们更新了整体的最大价值!所以第二行结束之后,最大价值为3000美元。
3.笔记本电脑行
下面以同样的方式处理笔记本电脑。笔记本电脑重3磅,没法将其装入容量为1磅或2磅的背包,所以前两个单元格的最大价值还是1500美元。
对于容量为3磅的背包,此前的最大价值为1500美元,但是此时我们多了一个笔记本电脑的选择,所以我们可以选择偷笔记本电脑,这样新的最大价值将为2000美元!
接下来,对于容量为4磅的背包,情况将很有趣。当前的最大价值为3000美元,你可不偷音响,而选择偷笔记本电脑,但它的价值只有2000美元。
价值虽然没有之前高,但是很关键的是,笔记本电脑的重量只有3磅,背包还有1磅的容量没有用!
在1磅的容量中,可装入的商品的最大价值是多少呢?是的,我们之前计算过,那就是可装入吉他,价值1500美元。因此,我们需要做如下比较:
此时我们就可以很清楚地知道,为什么我们之前要计算小背包可装入的商品的最大价值了,因为余下了空间时,我们可根据这些子问题的答案来确定余下的空间可装入哪些商品。
根据计算,笔记本电脑和吉他的总价值为3500美元,因此偷这个组合将是更好的选择。
所以最终的网格如下所示:
抛开之前那么多复杂的网格填充过程,其实我们每一次计算网格填充元素的时候,使用的计算公式是相同的,如下所示:
解释一下,就是说对于第i行第j列的元素cell[i][j],我们从以下的两个值中取较大的那一个:1.上一个单元格的值,即cell[i-1][j]的值;2.当前商品的价值+剩余空间的最大价值,其中剩余空间的最大价值为cell[i-1][j-当前商品的重量]。
我们可以使用这个公式来计算每一个单元格的价值,如此计算得出的最终网格将与前一个网格完全相同。
2、增加商品的背包问题
前面的背包问题其实是非常非常简单的,或许你不使用填充网格的方式也可以获得最优组合,甚至比画网格来的更快。但是,当我们发现还有更多商品可偷时情况就截然不同了。
2.1再增加一件商品
假设你发现还有第四件商品可偷——一个iPhone!1磅,2000美元。
此时需要重新执行前面所做的计算吗?不需要!不要忘记,动态规划是逐步计算小问题从而获得最大价值,所以,前面解决的任何小问题,都是整体大问题的很有意义的步骤,不需要重复进行。所以做动态规划网格时,新添加商品将非常方便!
到目前为止,计算出的最大价值如下:
这意味着当背包容量为4磅时,你最多可偷价值为3500美元的商品。但这是以前的情况,下面再添加表示iPhone的行。
我们现在来尝试填充这个新增的行。
我们从第一个单元格开始。iPhone可装入容量为1磅的背包。之前1磅背包的最大价值为1500美元,但iPhone价值2000美元,因此偷iPhone将是更好的选择,而不是吉他。
在下一个单元格中,我们可以装入吉他+iPhone的组合,该组合价值为3500美元,所以网格更新如下:
对于第三个单元格,也没有比装入iPhone+吉他更好的选择了,因为笔记本电脑重3磅,而iPhone+吉他重2磅,都可以装进去,但是笔记本电脑的价值只有2000美元。
对于最后一个单元格,情况就非常有趣了。当前的最大价值为3500美元,但现在你可偷iPhone,这将余下3磅的容量。
根据之前的网格,3磅容量的最大价值为2000美元!再加上iPhone价值2000美元,总价值为4000美元。所以新的最大价值诞生了!
最终的网格如下:
3、八个问题
当我们考虑了上面两种情况并获得最终网格之后,我们来思考八个问题:
第一个问题:沿着一列往下走时,最大价值有可能降低吗?
其实这个问题的答案很简单:不可能!
因为每次迭代时,你都存储了当前的最大价值,所以当可选择的商品增加时,也就是对应的一列往下走时,我们只可能找到更优的解,不可能找到更差的解!最大价值不可能比以前低!
从这个问题引申一下,如果说你发现在上面的基础上,你还可以偷一件商品——MP3播放器,它重1磅,价值1000美元,你要偷吗?
其实回答这个问题很简单,因为新来的商品重1磅,而我们之前的最优解的重量就是4磅,所以首先不可能把它直接加入之前的最优解中;其次,当前网格中,1磅背包的最大价值是2000美元,这大于MP3播放器的价值。
所以,我们可以很快回答关于MP3播放器的问题,那就是不偷!
第二个问题:行的排列顺序发生变化时结果将如何?
假设你按如下顺序填充各行:音响、笔记本电脑、吉他。网格将是什么样的呢?
网格将类似于下面这样:
虽然网格中有很多地方和之前的网格不同,但是最终的结果依然还是3500美元。
所以说,行的排列顺序发生变化时将不会对最终结果有任何影响,也就是说,各行的排列顺序无关紧要。
第三个问题:可以逐列而不是逐行填充网格吗?
这个问题比较复杂,不动手试试是没办法知道的,在这里我就直接引用书中给出的结论了。
就当前的问题而言,这没有任何影响,但是对于其他问题,可能有影响。
第四个问题:增加一件更小的商品将如何呢?
假设你还可以偷一条项链,它重0.5磅,价值1000美元。
前面的网格都假设所有商品的重量为整数,但现在你决定把项链偷了,因此余下的容量是3.5磅。那么在3.5磅的容量中,可装入的最大价值是多少呢?不知道!因为我们此前只计算了容量为1磅、2磅、3磅和4磅的背包可装入的商品的最大价值。现在,我们需要知道容量为3.5磅的背包可装入想商品的最大价值。
由于项链的引入,我们需要考虑的粒度更细,因此必须调整网格:
关于该问题的最终结果这里就不详细给出了,其实画出了这个粒度更细的网格之后,按照之前一模一样的思路去填充即可。
第五个问题:可以偷商品的一部分吗?
假设你在杂货店行窃,可偷成袋的扁豆和大米,但如果整袋装不下,可打开包装,再将背包倒满。在这情况下,不再是要么偷要么不偷,而变成了可偷商品的一部分。如何使用动态规划来处理这种情形呢?
答案是没法处理!
可能你会没想到,但事实的确如此。因为使用动态规划时,要么考虑拿走整件商品,要么考虑不拿,而没法判断该不该拿走商品的一部分。
但使用贪婪算法可轻松解决该问题。首先,尽可能多地拿价值最高的商品,如果拿光了,再尽可能拿剩下商品中价值最高的商品,依此类推。
例如,假设有如下商品可供选择:
看图说话,很明显藜麦比其他商品值钱,因此要尽量往背包中多装藜麦!如果能够在背包中装满藜麦,结果就是最佳的。
如果藜麦装完之后背包还没满,就接着装下一种最值钱的商品,依此类推。
第六个问题:计算最终的解时会涉及两个以上的子背包吗?
为获得前述背包问题的最优解,可能需要偷两件以上的商品。但根据动态规划算法的设计,最多只需合并两个子背包,即根本不会涉及两个以上的子背包!因为这些被涉及的子背包可能还包含更小的子背包!
第七个问题:最优解可能导致背包没装满吗?
完全可能!
假设你发现还可以偷一颗大钻石,它重达3.5磅,价值100万美元!比其他商品都值钱得多。所以你绝对应该把它给偷了!
但当你这么做时,余下的容量只剩下0.5磅了,别的什么东西都装不下了。
第八个问题:如何处理相互依赖的问题呢?
对于上面的偷东西问题,很明显偷哪一件商品都是一个独立的过程,不受偷其他商品的影响。但是假设有影响呢?
我们来看另一个例子。假设你要去伦敦度假,假期两天,但你想去游览的地方很多,两天根本游览不完,所以你无法前往所有心仪的地方,这必然涉及一个取舍问题。
为此,我们列出如下的清单:
对于最后第三列的评分,这表明了你有多想去游览这个景点。
其实这也是一个背包问题!但约束条件不是背包的容量,而是有限的时间;不是决定该装入哪些商品,而是决定该去游览哪些名胜。
我们根据前面的经验来填写网格:
但是很多时候,事情并没有这么简单!假设你还想去巴黎,因此在前述清单中又添加了几项:
去这些地方游览需要很长时间,因为你先得从伦敦前往巴黎,这需要半天时间;但是如果这三个地方都去游玩,是不是需要4.5天呢?很明显答案不是,因为不是去每个地方都得先从伦敦到巴黎,所以伦敦到巴黎这个半天时间,可以被共享!
换句话说,将埃菲尔铁塔装入“背包”后,卢浮宫将更“便宜”!在已去埃菲尔铁塔的情况下,再去卢浮宫将只需要1天时间,而不是1.5天。如何使用动态规划来对这种情况建模呢?
没办法建模。
是的,答案就是这么无情。动态规划功能强大,它能够解决子问题并使用这些子问题的答案来解决大问题。但仅当每个子问题都是离散的,即不依赖于其他子问题时,动态规划才管用!这意味着使用动态规划算法无法解决去巴黎游玩的问题。
4、最长公共子串和最长公共子序列
通过前面的动态规划问题,我们可以获得以下启示:
- 动态规划可帮助你在给定约束条件下找到最优解。在背包问题中,你必须在背包容量给定的情况下,偷到价值最高的商品。
- 在问题可分解为彼此独立且离散的子问题时,就可以使用动态规划。
要设计出动态规划解决方案可能很难,下面是一些通用的思路:
- 每种动态规划解决方案都涉及网格。
- 单元格中的值通常就是你要优化的值。
- 每个单元格都是一个子问题,因此我们考虑如何将问题分成子问题,这有助于我们找出网格的坐标轴。
下面再来看一个例子,学习一下最长公共子串问题。
假设你管理一个网站,用户在该网站输入单词时,你需要给出其定义。
但如果用户拼错了,你必须猜测他原本要输入的是什么单词。例如,某人本来想查单词fish,但是却输入了hish,在你的字典里根本没有hish这样的单词,所以你就要猜测他原本想要输入的是什么了。
用于解决这个问题的网格是什么样的呢?要想画出这个网格,我们必须考虑以下三个问题:
- 单元格中的值是什么?
- 如何将这个问题划分成子问题?
- 网格的坐标轴是什么?
在网格中,单元格中的值通常就是你要优化的值,所以在这里,单元格中的值就应该是输入字符和猜测字符这两个字符串都包含的最长子串的长度。
如何划分成子问题呢?我们可能需要比较子串:不是比较hish和fish,而是先比较his和fis,依此类推。
每个单元格都将包含两个子串的最长公共子串的长度,所以坐标轴就是这两个单词,即输入字符和猜测字符。
所以解决了上面三个问题,我们画出的最初网格如下所示:
接下来,我们需要来填充网格了。在填充该网格的每个单元格时,该使用什么样的计算公式呢?
实话实说,我也不知道……
因为根本没有找出计算公式的简单办法,必须通过尝试才可以。对于计算最长公共子串,这里直接给出网格填充结果,我们从结果来反推计算公式:
上面是通过不断寻找计算公式而最终获得的网格。仔细观察,我们不难发现,计算方法如下图所示:
解释一下,就是说,对于任何一个单元格,如果该列和该行的两个字母不同,则值为0;如果该列和该行的字母相同,则值为其左上角邻居的值加1。
虽然这个计算方法听起来让人一头雾水,但其实很好理解。也就是说,如果当前行和当前列的字母不同,那么意味着当前的输入字符和猜测字符不同,所以当前状态的最长公共子串的长度为0;如果当前行和当前列的字母相同,那么当前的输入字符和猜测字符相同,所以当前状态的最长公共子串长度就取决于当前状态的上一状态的最长公共子串长度了,也就是说,假设在当前状态的上一状态时的最长公共子串长度为x,由于当前状态的输入字符和猜测字符又相同,所以最长公共子串长度就又要增长1,变为x+1,所以当前单元格的值等于其左上角邻居的值加1,因为其左上角的邻居的值就是当前状态的上一状态的最长公共子串长度!
上面这一段解释可能写得很复杂,但是事实确实如此。仔细想想就应该明白了。
在此需要注意的是,对于最长公共子串问题,其实最终答案不一定是在最后一个网格中。
考虑下面的问题,查找单词hish和vista的最长公共子串时,网格如下:
很明显,该问题的最终答案为2,。所以说,对于前面的背包问题,最终答案总是在最后一个单元格中;但是对于最长公共子串问题,答案为网格中最大的数字,它可能位于网格的其他位置。
回到之前的问题,某人输入了hish,我们根据网格可知,hish和fish的最长公共子串长度为3,而hish和vista的最长公共子串长度为2,所以我们认为该人更可能是想要输入fish。
接下来我们深入类似的另一个问题,学习一下最长公共子序列问题。
假设某人不小心输入了fosh,那么他原本想输入的是fish还是fort呢?
我们使用最长公共子串的公式来画出两个网格:
很遗憾,这两个最长公共子串长度相同,都是2!
在这种情况下,应该怎么办呢?
其实从我们的经验来看,肯定觉得fish更可能,因为直观上感觉fosh和fish更像!
上图比较了最长公共子序列的长度:即两个单词中都有的序列包含的字母数!
比较最长公共子序列的最终网格如下所示:
下面是填写各个单元格时使用的公式:
解释一下,对于每个单元格,如果该行和该列的字母不同,就选择上边和左边邻居中较大的那个;如果该行和该列的字母相同,就将当前单元格的值设置为其左上角单元格的值加1,与计算最长公共子串时一样。
5、总结
最后,我们来总结一下有关于动态规划的特征:
- 需要在给定约束条件下优化某种指标时,动态规划很有用。
- 问题可分解为离散子问题时,可使用动态规划来解决。
- 每种动态规划解决方案都涉及网格。
- 单元格中的值通常就是你要优化的值。
- 每个单元格都是一个子问题,因此你需要考虑的最主要的就是如何将问题分解为子问题。
- 没有一种通用的单元格计算公式,必须多次尝试。