分类目录:《算法设计与分析》总目录
相关文章:
· 动态规划(一):基础知识
· 动态规划(二):钢条切割
· 动态规划(三):矩阵链乘法
· 动态规划(四):动态规划详解
· 动态规划(五):最长公共子序列
我们第一个应用动态规划的例子是求解一个如何切割钢条的简单问题。公司购买长钢条,将其切割为短钢条出售。切割工序本身没有成本支出。公司管理层希望知道最佳的切割方案。
假定我们知道公司出售一段长度为
i
i
i英寸的钢条的价格为
p
p
p。钢条的长度均为整英寸。下图给出了一个价格表的样例。
每段长度为
i
i
i英寸的钢条为公司带来
p
p
p美元的收益钢条切割问题是这样的:给定一段长度为
n
n
n英寸的钢条和一个价格表
p
p
p,求切割钢条方案,使得销售收益
r
r
r最大。注意,如果长度为
n
n
n英寸的钢条的价格
p
p
p足够大,最优解可能就是完全不需要切割。
先考虑
n
=
4
n=4
n=4的情况,下图给出了4英寸钢条所有可能的切割方案,包括根本不切割的方案。我们发现,将一段长度为4英寸的钢条切割为两段各长2英寸的钢条,将产生的收益
p
2
+
p
2
=
5
+
5
=
10
p_2+p_2=5+5=10
p2+p2=5+5=10,为最优解。
上图给出了4英寸钢条的8种切割方案。根据价格表,在每段钢条之上标记了它的价格。最优策略为方案(c)——将钢条切割为两段长度均为2英寸的钢条——总价值为10。
长度为 n n n英寸的钢条共有 2 n − 1 2^{n-1} 2n−1种不同的切割方案,因为在距离钢条左端 i i i英寸处,我们总是可以选择切割或不切割。我们用普通的加法符号表示切割方案,因此7=2+2+3表示将长度为7英寸的钢条切割为三段——两段长度为2英寸、一段长度为3英寸。
如果一个最优解将钢条切割为 k k k段( 1 ≤ k ≤ n 1≤k≤n 1≤k≤n),那么最优切割方案 n = i 1 + i 2 + ⋯ + i k n=i_1+i_2+\cdots+i_k n=i1+i2+⋯+ik将钢条切割为长度分别为 i 1 i_1 i1、 i 2 i_2 i2、 ⋯ \cdots ⋯、 i k i_k ik的小段,得到最大收益 r n = p i 1 + p i 2 + ⋯ + p i k r_n=p_{i_1}+p_{i_2}+\cdots+p_{i_k} rn=pi1+pi2+⋯+pik。
对于上述价格表样例,我们可以观察所有最优收益值
r
i
r_i
ri及对应的最优切割方案:
更一般地,对于
r
i
r_i
ri,我们可以用更短的钢条的最优切割收益来描述它:
r
n
=
m
a
x
(
p
n
,
r
1
+
r
n
−
1
,
r
2
+
r
n
−
2
,
⋯
,
r
n
−
1
+
r
1
)
r_n=max(p_n,r_1+r_{n-1},r_2+r_{n-2},\cdots,r_{n-1}+r_1)
rn=max(pn,r1+rn−1,r2+rn−2,⋯,rn−1+r1)
第一个参数 p n p_n pn对应不切割,直接出售长度为 n n n英寸的钢条的方案。其他 n − 1 n-1 n−1个参数对应另外 n − 1 n-1 n−1种方案:对每个 i i i,首先将钢条切割为长度为 i i i和 n − i n-i n−i的两段,接着求解这两段的最优切割收益,由于无法预知哪种方案会获得最优收益,我们必须考察所有可能的 i i i,选取其中收益最大者。如果直接出售原钢条会获得最大收益,我们当然可以选择不做任何切割。
注意到,为了求解规模为 n n n的原问题,我们先求解形式完全一样,但规模更小的子问题。即当完成首次切割后,我们将两段钢条看成两个独立的钢条切割问题实例。我们通过组合两个相关子问题的最优解,并在所有可能的两段切割方案中选取组合收益最大者,构成原问题的最优解。我们称钢条切割问题满足最优子结构性质:问题的最优解由相关子问题的最优解组合而成,而这些子问题可以独立求解。
除了上述求解方法外,钢条切割问题还存在一种相似的但更为简单的递归求解方法:我们将钢条从左边切割下长度为
i
i
i的一段,只对右边剩下的长度为
n
−
i
n-i
n−i的一段继续进行切割(递归求解),对左边的一段则不再进行切割。即问题分解的方式为:将长度为n的钢条分解为左边开始一段,以及剩余部分继续分解的结果。这样,不做任何切割的方案就可以描述为:第一段的长度为
n
n
n,收益为
p
n
p_n
pn,剩余部分长度为0,对应的收益为
r
0
=
0
r_0=0
r0=0。于是我们可以得到钢条切割的简化版本:
r
n
=
max
1
≤
i
≤
n
(
p
i
+
r
n
−
1
)
r_n=\max \limits_{1 \leq i \leq n}(p_i+r_{n-1})
rn=1≤i≤nmax(pi+rn−1)
在此公式中,原问题的最优解只包含一个相关子问题,即右端剩余部分的解,而不是两个。
def cut_rod(p, n):
if n == 0:
return 0
r = -1
for i in range(n):
r = max(r, p[i] + cut_rod(p, n - i - 1))
return r
过程cut_rod(p, n)
以价格数组p[1..n]
和整数n
为输入,返回长度为n
的钢条的最大收益。若n=0
,不可能有任何收益,所以cut_rod(p, n)
的第2~3行返回0。第4行将最大收益r
初始化为
−
1
-1
−1,以便第5~6行的for
循环能正确计算,第7行返回计算结果。
在你运行cut_rod(p, n)
的时候你可能会发现,一旦输入规模稍微变大,程序运行时间会变得相当长。每当将
n
n
n增大1,程序运行时间差不多就会增加1倍。其原因在于,cut_rod(p, n)
反复地用相同的参数值对自身进行递归调用,即它反复求解相同的子问题。下图显示了
n
=
4
n=4
n=4时的调用过程:
为了分析cut_rod(p, n)
的运行时间,令
T
(
n
)
T(n)
T(n)表示第二个参数值为
n
n
n时cut_rod(p, n)
的调用次数。
此值等于递归调用树中根为 n n n的子树中的结点总数,注意,此值包含了根结点对应的最初的一次调用。因此 T ( 0 ) = 1 T(0)=1 T(0)=1,且
T ( n ) = 1 + ∑ i = 0 n − 1 T ( i ) = 2 n T(n)=1+\sum_{i=0}^{n-1}T(i)=2^n T(n)=1+i=0∑n−1T(i)=2n
回过头看,cut_rod(p, n)
的指数运行时间并不令人惊讶。对于长度为
n
n
n的钢条,cut_rod(p, n)
显然考察了所有
2
n
−
1
2^{n-1}
2n−1种可能的切割方案。递归调用树中共有
2
n
−
1
2^{n-1}
2n−1个叶结点,每个叶结点对应一种可能的钢条切割方案。对每条从根到叶的路径,路径上的标号给出了每次切割前右边剩余部分的长度(子问题的规模)。
使用动态规划方法求解最优钢条切割问题
我们现在展示如何将cut_rod(p, n)
转换为一个更高效的动态规划算法。
我们已经看到,朴素递归算法之所以效率很低,是因为它反复求解相同的子问题。因此,动态规划方法仔细安排求解顺序,对每个子问题只求解一次,并将结果保存下来。如果随后再次需要此子问题的解,只需查找保存的结果,而不必重新计算。因此,动态规划方法是付出额外的内存空间来节省计算时间,是典型的时空权衡的例子。而时间上的节省可能是非常巨大的:可能将一个指数时间的解转化为一个多项式时间的解。如果子问题的数量是输入规模的多项式函数,而我们可以在多项式时间内求解出每个子问题,那么动态规划方法的总运行时间就是多项式阶的。
动态规划有两种等价的实现方法,下面以钢条切割问题为例展示这两种方法。
第一种方法称为带备忘的自顶向下法。此方法仍按自然的递归形式编写过程,但过程会保存每个子问题的解(通常保存在一个数组或散列表中)。当需要一个子问题的解时,过程首先检查是否已经保存过此解。如果是,则直接返回保存的值,从而节省了计算时间;否则,按通常方式计算这个子问题。我们称这个递归过程是带备忘的,因为它“记住”了之前已经计算出的结果。
第二种方法称为自底向上法。这种方法一般需要恰当定义子问题“规模”的概念,使得任何子问题的求解都只依赖于“更小的”子问题的求解。因而我们可以将子问题按规模排序,按由小至大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它(也是第一次遇到它)时,它的所有前提子问题都已求解完成。
两种方法得到的算法具有相同的渐近运行时间,仅有的差异是在某些特殊情况下,自顶向下方法并未真正递归地考察所有可能的子问题。由于没有频繁的递归函数调用的开销,自底向上方法的时间复杂性函数通常具有更小的系数。
def cut_rod(p, n):
r = [-1] * n
return cut_rod_dp(p, n, r)
def cut_rod_dp(p, n, r):
if r[n - 1] >= 0:
return r[n - 1]
if n == 0:
q = 0
else:
q = -1
for i in range(1, n + 1):
q = max(q, p[i - 1] + cut_rod_dp(p, n - i, r))
r[n - 1] = q
return q
这里,主过程cut_rod(p, n)
将辅助数组r
的元素均初始化为-1,这是一种常见的表示“未知值”的方法(已知的收益总是非负值)。然后它会调用辅助过程cut_rod_dp(p, n, r)
。过程cut_rod_dp(p, n, r)
是最初的cut_rod(p, n)
引入备忘机制的版本。它首先检查所需值是否已知,如果是,则第2行直接返回保存的值;否则,第3~7行用通常方法计算所需值q
,第8行将q
存入r
,第9行将其返回。
自底向上版本更为简单:
def cut_rod(p, n):
r = [-1] * n
for i in range(n):
q = p[i]
for j in range(i):
q = max(q, p[j] + p[i - j - 1])
r[i] = q
return r[n - 1]
自底向上版本cut_rod(p, n)
采用子问题的自然顺序:若
i
<
j
i<j
i<j,则规模为
i
i
i的子问题比规模为
j
j
j的子问题“更小”。因此,过程依次求解规模为
j
=
0
,
1
,
⋯
,
n
j=0, 1, \cdots, n
j=0,1,⋯,n的子问题。
子问题图
当思考一个动态规划问题时,我们应该弄清所涉及的子问题及子问题之间的依赖关系。问题的子问题图准确地表达了这些信息。下图显示了
n
=
4
n=4
n=4时钢条切割问题的子问题图。它是一个有向图,每个顶点唯一地对应一个子问题。若求子问题
x
x
x的最优解时需要直接用到子问题
y
y
y的最优解,那么在子问题图中就会有一条从子问题
x
x
x的顶点到子问题
y
y
y的顶点的有向边。例如,如果自顶向下过程在求解
x
x
x时需要直接递归调用自身来求解
y
y
y,那么子问题图就包含从
x
x
x到
y
y
y的一条有向边。我们可以将子问题图看做自顶向下递归调用树的“简化版”或“收缩版”,因为树中所有对应相同子问题的结点合并为图中的单一顶点,相关的所有边都从父结点指向子结点。
自底向上的动态规划方法处理子问题图中顶点的顺序为:对于一个给定的子问题 x x x,在求解它之前求解邻接至它的子问题 y y y。自底向上动态规划算法是按“逆拓扑序”或“反序的拓扑序”来处理子问题图中的顶点。换句话说,对于任何子问题,直至它依赖的所有子问题均已求解完成,才会求解它。类似地,我们可以用“深度优先搜索”来描述自顶向下动态规划算法处理子问题图的顺序。
子问题图 G = ( V , E ) G=(V, E) G=(V,E)的规模可以帮助我们确定动态规划算法的运行时间。由于每个子问题只求解一次,因此算法运行时间等于每个子问题求解时间之和。通常,一个子问题的求解时间与子问题图中对应顶点的度(出射边的数目)成正比,而子问题的数目等于子问题图的顶点数。因此,通常情况下,动态规划算法的运行时间与顶点和边的数量呈线性关系。
重构解
前文给出的钢条切割问题的动态规划算法返回最优解的收益值,但并未返回解本身。我们可以扩展动态规划算法,使之对每个子问题不仅保存最优收益值,还保存对应的切割方案。利用这些信息,我们就能输出最优解:
def cut_rod(p, n):
r = [-1] * n
s = list(range(n))
for i in range(n):
q = p[i]
for j in range(i):
if q < p[j] + p[i - j - 1]:
q =p[j] + p[i - j - 1]
s[i] = j
r[i] = q
return r[n - 1], s[n - 1]
此过程与原过程很相似,差别只是创建了数组s
,并在求解规模为
i
i
i的子问题时将第一段钢条的最优切割长度
j
j
j保存在了s[i]
中。