《算法导论》第15章专门介绍动态规划算法,本文结合装配线调度和矩阵链乘法理解动态规划算法的一些基本问题(15.1 – 15.3)。

适合采用动态规划方法的最优化问题包含两个要素:最优子结构和重叠子问题。

最优子结构

如果问题的一个最优解中包含子问题的最优解,则该问题具有最优子结构。要判断问题是否具有最优子结构,那么就需要描述问题的最优解。对这两个例子而言:

1. 调度线问题中,我们要求得通过装配站j的最快路线,需要知道通过装配站j-1的最快路线,然后基于j-1最快路线的时间决定是否应该换装配线;

2. 矩阵链的乘法中,AiAi+1. . . Aj,的一个最优加全部括号把乘积在Ak和Ak+1之间分开,那只有AiAi+1. . . Ak和Ak+1. . . Aj都是最优的,AiAi+1. . . Aj才是最优的。

在寻找最优子结构时,可以遵循一些共同的模式:

1. 问题的一个解是做一个选择。 比如装配线例子中需要选择一条装配线;矩阵链乘法中选择一个下标以在该位置分裂矩阵链。这个选择做完后,就会得到一个或者多个待解决的子问题。

2. 假定对一个给定的问题,已知的是一个可以导致最优解的选择。不必关心如何确定这个选择,也不用关心这个选择将会如何被实现,尽管假定它是已知的。比如在矩阵乘法的问题中,我们就假设在k出分开,k可能会是什么,现在不用管,也不用担心说:从1到n都是可能的啊,是的,的确是可能的,但这并不妨碍你做这样的假定。

3. 在已知这个选择后,要确定哪些子问题会随之发生。比如装配线中的一个子问题,假定通过Sj的最快路线是已知的,那么随之而来的问题是通过Sj-1的最快路线。

4. 证明在问题的一个最优解中,使用的子问题的解本身也必须是最优的。通过假设每一个子问题的解都不知最优解,然后到处矛盾,就可以做到这一点。比如通过Sj的最快路线中通过Sj-1的部分也是最快路线,否则就会有另外一个路线是通过Sj-1和Sj的最快路线。

在描述子问题空间时,经验规则是尽量保持这个空间简单,然后在需要的时候再扩充它。比如,在装配线调度问题中,子问题空间就是从工厂入口通过装配站S1,j和S2,j的最快路线。在矩阵链乘法中,假设我们试图将子问题空间约束为形如A1A2. . . Aj的矩阵乘积,一个最优的全加括号必定把乘积在Ak和Ak+1之间分开,就会导出形如A1A2. . . Ak和AkAk+1. . . Aj的子问题, 如果k不等于j-1,那么形如A1A2. . . Aj的自问题就无法描述所有的子问题。所以子问题空间就需要扩展,将两段都能变化,所以应该使用AiAi+1. . . Aj的加全括号作为子问题。

 

上面介绍了如何描述问题的最优解,如何寻找最优子结构,和子问题,验证你是不是真的找到了合适的子结构,就用递归方式来定义你的解。

比如对装配线问题:

子问题是:底盘从起点到装配站Si,j的最快时间,i可能为1或者2,j就更多,那

f1[j] = min(f1[j-1] + a1, f2[j-1]+t2,j-1+a1,j) j>=2

f2[j] = min(f2[j-1] + a2, f1[j-1]+t1,j-1+a2,j) j>=2

比如对矩阵乘问题:矩阵Ai...Aj 的最优加括号方法

f(i,j) = f(i,k-1)  +  f(k,j) + pi-1pkpj

装配线你可以明确看到j是依赖于j-1的,矩阵链乘法跟装配线不一样,他依赖于k,可以k是什么呢,跟i和j有什么关系呢?没有确定的关系,k在i和j之间,所有就导致我们需要计算k的每个可能值。

 

在用递归方式描述一个解后呢,就可以根据上面的递归算法来实现了。实现的时候,根据子问题的重叠性质,最基本的优化方式有:

用备忘录将解决过的子问题记录下来,后面使用时直接查找,避免重复解决;

 

最优子结构在问题空间中以两种方式变化:

1. 有多少个子问题被使用在原问题的一个最优解中,以及

2. 在决定一个最优解中使用哪些子问题时有多少个选择。

比如在调度线问题中,一个最优解只使用一个子问题,但为确定一个最优解,我们需要考虑两种选择。

又比如在矩阵链乘法中,一个最优解使用两个问题,但这两个问题却有j-i个选择(也就是k的选择可以是i到j的值)。

非正式的,一个动态规划算法的运行时间依赖于两个因素的乘积:子问题的总个数和每一个子问题有多少中选择。比如,在调度线问题中,子问题个数为2n个,每个问题有2个选择,其复杂度仍然是O(n)的;对矩阵链乘法,子问题个数为O(n2),每个子问题最多有n-1个选择,因此执行时间为O(n3)。

动态规划是从底向上的方式使用最优子结构,而贪心算法是以自顶向下的方式使用最优子结构。

 

重叠子问题

适合采用动态规划方法的最优化问题的第二个要素是子问题的空间要“小”,也就是用来解原问题的递归算法可以反复地解同样的子问题。典型的,不同的子问题数是输入规模的一个多项式。当一个递归算法不断调用同一个问题时,可以说该最优问题包含重叠子问题。动态规划要去子问题既要独立,又要重叠。如果同一个问题的两个子问题不共享资源,则它们是独立的;对于两个相同的子问题,只是作为不同问题的子问题出现的话,则它们是重叠的。比如,在矩阵链乘法中,子问题A3A4在计算A2A3A4、A1A2A3A4 时都会被用到,也就是作为不同问题的子问题出现,所以这两个子问题是重叠的。在矩阵链乘法中,我们还可以使用自顶向下的递归算法来实现,伪码如下:

 

RECURSIVE-MATRIX-CHAIN(p,i,j) (called with(p,1,n))

if i=j then return 0 

m[i,j]=NaN

for k=i to j-1 

do q= RECURSIVE-MATRIX-CHAIN(p,i,k)+ RECURSIVE-MATRIX-CHAIN(p,k+1,j)+pi-1pkpj

if q< m[i,j] then m[i,j] =q

return m[i,j]

 

设T(n)为计算n个矩阵的一个最优加全括号的时间,T(n) >= 1 + (T(k)+T(n-k)+1)k=1, to n-1,可以求得其复杂度是O(2n)。

比较动态规划算法的实现如下(先计算重叠子问题,把重叠子问题的结果保存下来,再计算更大一点的子问题):

 

n=length(p) –1;

for (i =1, I <n , i++)


m[I,i] = 0 //长度为1时
 
for l=2, to n       //l 是子问题的长度,从2开始,最大为n


do i=1, to n-l+1   // i是子问题长度为l时,可选的起始点。

do j=i+l-1;

m[I,j] = NAN;

for k=I to j-1  //遍历所有可能的k,计算出m[I,j]

do q = m[I,k] + m[k+1,j]+pi-1pkpj

if q<m[I,j]

then m[I,j] = q;

s[I,j] = k;

 

自顶向下的递归算法无法充分利用重叠子问题,减少计算量,而动态规划算法就可以将重叠子问题的结果保存下来,下次使用时直接使用,而不是将子问题再计算一次。

做备忘

动态规划的一个变形是将原问题的低效的递归算法得到的结果。 这样就是效率没有变化,但是采用了自顶向下的方法解决问题。

 

使用动态规划算法的难点在于定义子问题并找到最优子结构。下面再以《编程之美》中买书折扣问题为例,介绍选择、子问题和子结构。

先说选择,假设5本书分别买的数量是Y1,Y2,Y3,Y4,Y5,假设Yi>Yj(i<j)。我们为了折扣数量低,可以选择先结算(1,1,1,1,1)或者(1,1,1,1,0)或者(1,1,1,0,0)或者(1,1,0,0,0)或者(1,0,0,0,0)。一旦这样选择,就会产生不同的子问题,比如,如果你选择(1,1,1,1,1),子问题就变成Y1-1,Y2-1,Y3-1,Y4-1,Y5-1,依次类推可以得到子结构。