本文首发于我的公众号 Linux云计算网络(id: cloud_dev) ,专注于干货分享,号内有 10T 书籍和视频资源,后台回复 「1024」 即可领取,欢迎大家关注,二维码文末可以扫。
写在前面:从本章开始,算法导论章节进入第四部分:高级设计和分析技术。在读的过程中,可以明显感觉到本章内容跟之前章节的内容要复杂得多。这么来说,之前章节的内容更多的是在教我们使用一些在算法设计过程中常用的工具(即数据结构),而本章以后的内容是在述说更上层的方法论(如何根据不同的问题精确地设计不同的算法)。这就好比建房子时,有了一切所需的工具之后,如何根据不同的地段或房主的要求,设计出切实可行的房子结构,这取决于建筑设计师的思想。因此,本章以后的内容在某种程度上更为复杂,尤其是动态规划这章。曾经听搞过ACM的同学说过,他认为ACM题型中最难的部分就是动态规划,他觉得如何根据问题的类型写出递推关系式是很难的。我在平时的做题中也发现,写递推式确实是很难,基本上所有关于动态规划的题都是参考别人写的递推式,然后自己去实现。所以,本章之所以迟迟未动手写,就是我觉得太过于复杂,很难用自己的语言描述清楚。但是我又想把它写好,就一直拖着,拖了一个月了,中间做了一些相关的题目,但是仍然不能自己独立完成一个全新的题目。现在,我觉得不能在拖了,我觉得想要真正掌握动态规划,只能通过多做题,多总结,才能融会贯通,这注定是一个长期的过程。
一、动态规划概要
我认为动态规划也是体现了一种分治的思想,看归并排序就知道,传统意义上的分治法是将原问题划分成一些个子问题(可以任意分:对半或1/3等),然后通过递归方法进行求解。动态规划也是基于同样的思想:原问题规模太大,太复杂,不好解,就分解成子问题进行求解(子问题都是形式相同,但规模更小)。只不过区别在于:分治在于分而治之,不在乎子问题的形式;而动态规划还隐含着一层优化的含义,子问题可以有很多个,但要找到具有问题最优解的子问题,同时,这些子问题之间也是有联系,有重合的:上一个问题的最优解可以推出下一个问题的最优解。以上这句话说得有点啰嗦,总结下来就是:动态规划求解的问题应该具备两个要素:最优子结构和子问题重叠。从问题形式上看,分治法和动态规划法可能是:分治法:某某问题怎么解?动态规划法:某某问题怎么解能达到最优?常见的题型如:最长公共子序列、最长子串、最大字段和等等。从与分治法的对比中,我们基本上对动态规划就有一定的认识和理解了。有一个问题:求连续如子数组的最大和,这个问题既可以用分治法,也可以用动态规划法,可以参见我的另一篇博文来融会这两种方法:算法导论第四章分治策略实例解析(一)。如果看了这些仍然对动态规划难以理解,可以看知乎大神们的回答:什么是动态规划?动态规划的意义是什么?
二、动态规划解题思路
大概了解了动态规划算法思想,接下来就是重中之重:怎么运用这种思想解实际的问题?教科书上给出了四个步骤:
1)刻画一个最优解的结构特征。
2)递归定义最优解的值。
3)计算最优解的值,通常采用自底向上方法。
4)利用计算出的信息构造一个最优解。
别看简单的四句话,蕴含的含义要多得多,我们一条一条地来分析。
1)刻画一个最优解的结构特征
动态规划的思想在于对原问题进行拆分,如何拆分,使子问题具有最优子结构,且满足各子问题之间重叠的性质。拆分的关键在于对原问题的形式化定义,专业的词汇叫“问题的状态”,通过定义问题的状态,找出状态之间的转移关系,进而就可以求出问题的最优解。动态规划的难点就在于:建立问题状态之间的转移关系式。前面说过,不同的状态定义,决定了子问题的形式,而使原问题的形式达到最优解的形式只有一种,所以,正确建立状态转移方程是解动态规划的关键所在。此步骤说白了就是确定问题状态的。我们举几个例子具体分析下。
a、求连续子数组的最大和
如(2 -3 2 -1 3),结果为(2 -1 3):4。
我们定义子问题状态为:Fi为以第i个数为结尾的字数组的最大和,当然也可以定义成其他的,如二维的Fi,j为以第i个数开头,第j个数结尾的子数组的最大和。但具体是哪一个是对的,我们只能通过下一个步骤来进行验证。可见,问题的状态定义是多样的,动态规划难就难在:如何定义状态,并建立状态之间的转移关系。如果你做题多了,就能一眼看出(可惜我没到这个境界)。我的一个感觉就是顺着题目来,然后如果问题涉及到一个对象,就定义一维,涉及两个对象,定义二维,但也不一定(如矩阵链乘法,一个对象定义二维)。就拿这个问题来说,定义一维的Fi为某个数结尾的字数组的最大和,大抵上是不会错的。当然我的这个方法有点投机取巧的味道,还是得对问题的本质进行分析,得到的结果才能快速且准确。
b、求最长递增子序列的长度(LIS)
如(1 7 2 8 3 4),结果为(1 2 3 4):4。
根据上面的方法,顺着题目的意思,只涉及到一个序列,因此定义一维的Fi为以第i个数为结尾的LIS的长度。
c、求最长公共子序列(LCS)
如序列X=abababa和Y=abcacbca的最长公共子序列,由于涉及到两个序列,我们定义C[i,j]为Xi和Yi的LCS的公共部分的长度。
2)递归定义最优解的值
由于各子问题的形式是一样的,因此我们可以通过递归的方式来求解子问题,但各子问题之间并不是相互独立(满足重叠子问题的性质),而是相互依存的,一个子问题可以推导出下一个子问题,因此,通过上一个步骤定义的子问题状态之间是可以相互转移的,状态与状态之间的转移关系式,称之为状态转移方程。在上一个步骤中说过,问题的状态可能存在多种形式,那怎么确定哪一种状态是对的,一个重要的原则是:通过建立状态转移方程,看是否合理,然后推导是否子问题中每一种情况都考虑到了。同样以上一个步骤中的三个例子进行说明。
a、求连续子数组的最大和
如果定义Fi为以第i个数为结尾的字数组的最大和。则可轻松写出状态转移方程:F(i) = max(F(i-1), F(i) + A[i]),以第i个数结尾的字数组的最大和,要么是以第i-1个数结尾的 字数组的最大和F(i-1),要么是前i-1个数加第i个数F(i-1)+A[i]。而如果定义Fi,j为问题状态,显然原问题没有得到化简,也写不出状态转移方程。
b、求最长递增子序列的长度(LIS)
如果定义Fi为以第i个数为结尾的LIS的长度。则可以写出状态转移方程为:
,即保证第i项比第k项小的情况下,以第i项结尾的LIS长度加一的最大值,取遍i的所有值(i小于k)。
c、求最长公共子序列(LCS)
通过分析,同样,可以写出C[i,j]的状态转移方程,如果xi=yj,则C[i,j] = C[i-1,j-1]+1;如果xi != yj,则C[i,j]=max(C[i-1,j], C[i, j-1])。
3)计算最优解的值,通常采用自底向上方法
通过以上两个步骤,动态规划最难的地方已经攻克了,接下来就是具体求解的过程。在这个步骤中,特别强调了一句话:通常采用自底向上的方法。隐含的意思就是说还有其他的方法。到此可见,贯穿动态规划始终的是递归两个字,既然用到递归,肯定存在递归的解法,进一步,为了改善递归的效率,又存在带备忘录的递归解法。总而言之,递归的解法采用的是自顶向下的方法,层层深入,这样的方式缺点就在于重复计算了子问题。所以,自然可以想到采用自底向上的方法,把一个个子问题解出来,自然就可以得到原问题。
4)利用计算出的信息构造一个最优解
这一步就是利用前三步计算出的解,构造出原问题的最优解所包含的所有元素,比如上面三个例子,我们得到的是一个表示最大最小的整数值,我们需要定义一个额外的数组来保存得到这个数值的所有包含的元素。
三、相关题目
本节通过几个题目来体会上面所说的内容,相关的题目会在之后遇到的时候进行补充。
未完待续:
动态规划与贪心的联系与区别
我的公众号 「Linux云计算网络」(id: cloud_dev),号内有 10T 书籍和视频资源,后台回复 「1024」 即可领取,分享的内容包括但不限于 Linux、网络、云计算虚拟化、容器Docker、OpenStack、Kubernetes、工具、SDN、OVS、DPDK、Go、Python、C/C++编程技术等内容,欢迎大家关注。