这一部分介绍了设计和分析高效算法的三种重要技术:动态规划、贪心算法、和摊还分析。
动态规划通常用来解决最优化问题,在这类问题中,通过做出一组选择来达到最优解。在做出每个选择的同时,通常会生成与原问题形式相同的子问题。当多于一个选择子集都生成相同的子问题时,动态规划技术通常通常就会有效,其关键技术就是对每个这样的子问题都保存其解,当其重复出现时即可避免重复求解。
与动态规划类似,贪心算法通常用于最优化问题,,做出一组选择来达到最优解。贪心算法的思想是每步选择都追求局部最优。速度比动态规划方法快的多,但是,并不总能简单地判断出贪心算法是否有效。
使用摊还分析方法分析一类特定的算法,这类算法执行一组相似的操作组成的序列。摊还分析并不是通过分别分析每个操作的实际代价的界来分析操作序列的代价结果,而是直接分析序列整体的实际代价的界。这种方法的好处,虽然某些操作的代价可能很高,但其他很多操作的代价可能很低。
第 15 章 动态规划
动态规划与分治方法相似,都是通过组合子问题的解来求解原问题。分治方法将问题划分为互不相交的子问题,递归地求解子问题,再将它们的解组合起来,求出原问题的解。与之相反,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题。在这种情况下,分治算法会做许多不必要的工作,它会反复地求解那些公共子问题。而动态规划算法对每个子问题只求解一次,将其解保存在一个表格中,从而无需每次求解一个子子问题时都重新计算,避免了这种不必要的操作。
通常按如下4个步骤设计一个动态规划算法:
- 刻画一个最优解的结构特征。
- 递归地定义最优解的值。
- 计算最优解的值,通常采用自底向上的方法。
- 利用计算出的信息构造一个最优解。
15.1 钢条切割
钢条切割问题时这样的:给定一段长度为n英寸的钢条和一个价格表
pi
,求切割钢条方案,使得销售收入
rn
最大。注意,如果长度为n英寸钢条价格
pn
足够大,最优解可能就是完全不需要切割。
长度为n英寸的钢条共有
2n−1
种切割方案。
对于
rn(n>=1)
,可以用更短的钢条的最优收益来描述它:
除了上述求解方法外,钢条切割问题还存在一种相似的但更为简单的递归求解方法:将钢条从左边割下长度为i的一段,只对右边剩下的长度为n-i的一段继续进行切割(递归求解),对左边的一段则不再进行切割。即问题分解的形式为: 将长度为n的钢条分解为左边开始一段以及剩余部分继续分解的结果。得到公式简化版:
自顶向下递归实现
下面的过程实现了公式简化板的实现,它采用的是一种直接的自顶向下的递归方法:
CUT-ROD(p,n)
if n== 0
return 0
q = -∞
for i = 1 to n
q = max(q, p[i] + CUT-ROD(p, n-i))
return q
过程CUT-ROD以价格数数组p[1..n]和整数n为输入,返回长度为n的钢条最大收益。CUT-ROD的运行时间为 n 的指数函数。
使用动态规划方法求解最优钢条切割问题
朴素递归算法之所以效率很低,是因为它反复求解相同的子问题。因此,动态规划方法仔细安排求解顺序,对每个子问题只求解一次,并将结果保存下来。如果随后再次需要此子问题的解,只需查找保存的结果,而不必重新计算。因此,动态规划方法时付出额外的内存空间来节省时间,时典型的时空权衡的例子。而时间上的节省可能是非常巨大的:可能将一个指数时间的解转化为一个多项式时间的解。
动态规划有两种等价的实现方法:
第一种方法称为带备忘的自顶向下。此方法仍按自然的递归形式编写过程,但过程或保存每个子问题的解(通常在一个数组或散列表)。当需要一个子问题的解时,过程首先检查是否已经保存过此解。如果是,则直接返回保存的值,从而节省了计算时间;否则,按通常方式计算这个子问题。称这个递归过程时带备忘的。
第二种方法称为自底向上法。这种方法一般需要恰当定义子问题“规模”的概念,使得任何子问题的求解都只依赖与“更小的”子问题的求解。因此可以将子问题按规模排序,按由小到大顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它时,它的所有前提子问题都已求解完成。
两种算法得到的算法具有相同的渐近运行时间,仅有的差异在某些特殊情况下,自顶向下方法并未真正递归地考察所有可能的子问题。由于没有频繁的递归函数调用的开销,自底向上方法的时间复杂性函数通常具有更小的系数。
下面给出的时自顶向下CUT-ROD过程的伪代码,加入了备忘机制:
MEMOIZED-CUT-ROD(p,n)
let r[0..n] be a new array
for i = 0 to n
r[i] = -∞
return MEMOIZED-CUT-ROD-AUX(p, n, r)
MEMOIZED-CUT-ROD-AUX(p, n, r)
if r[n] >= 0
return r[n]
if n == 0
q = 0
else q = -∞
for i = 1 to n
q = max(q, p[i] + MEMOIZED-CUT-ROD-AUX(p, n-i, r))
r[n] = q
return q
自底向上版本更为简单
BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0] = 0
for j = 1 to n
q = -∞
for i = 1 to j
q = max(q, p[i] + r[j - i])
r[j] = q
return r[n]
过程依次求解规模为j=0,1,……,n的子问题。
两个算法的运行时间都为
θ(n2)
子问题图
当思考一个动态规划问题时,应该弄清楚所涉及的子问题及子问题之间的依赖关系。子问题图G=(V,{E})的规模可以帮助确定动态规划算法的运行时间。动态规划算法的运行时间与顶点和边的数量呈线性关系。
重构解
前文给出的钢条切割问题的动态规划算法返回最优解的收益值,但并未返回解本身(一个长度列表,给出切割后每段钢条的长度)。下面给出BOTTOM-UP-CUT-ROD(p,n)扩展版本,它对长度为j的钢条不仅计算最大收益值
rj
,还保存最优解对应的第一段钢条的切割长度
sj
:
EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] and s[0..n]be a new array
r[0] = 0
for j = 1 to n
q = -∞
for i = 1 to j
if q < p[i] + r[j - i]
q = p[i] + r[j-i]
s[j] = i
r[j] = q
return r and s
下面过程接受两个参数:价格表p 和钢条长度n,然后调用EXTENDED-BOTTOM-UP-CUT-ROD来计算切割下来的每段钢条的长度s[1..n]。最后输出长度为n的钢条的完整的最优切割方案:
PRINT-CUT-ROD-SOLUTION(p,n)
(r, s) = EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
while n > 0
print s[n]
n = n - s[n]
c++ 代码实现
#include <iostream>
using namespace std;
#define MIN -1
#define MAX(a,b) (((a) > (b)) ? (a) : (b))
int CUT_ROD(int p[], int n);
int memoized_cut_rod(int p[], int n);
int memoized_cut_rod_aux(int p[], int n, int r[]);
int bottom_up_cut_rod(int p[], int n);
void extended_bottom_up_cut_rod(int p[], int n, int r[], int s[]);
void print_cut_rod_solution(int p[], int n, int r[], int s[]);
int main()
{
int value[10] = {1,5,8,9,10,17,17,20,24,30};
// cout << CUT_ROD(value, 7) << endl;
// cout << memoized_cut_rod(value, 7) << endl;
// cout << bottom_up_cut_rod(value, 7) << endl;
int r[11], s[11];
extended_bottom_up_cut_rod(value, 10, r, s);
for (auto i : r)
cout << i << ends;
cout << endl;
for (auto i : s)
cout << i << ends;
cout << endl;
print_cut_rod_solution(value, 7, r, s);
return 0;
}
//朴素递归方法实现钢条切割
//以价格数组p[1..n]和整数n为输入
int CUT_ROD(int p[], int n)
{
int q;
if (n == 0)
return 0;
q = MIN;
for (int i = 0; i < n; ++i)
q = MAX(q, p[i] + CUT_ROD(p , n - 1 - i));
return q;
}
//自顶向上的CUT_ROD加入了备忘录
int memoized_cut_rod(int p[], int n)
{
int r[n+1];
for (int i = 0; i < n + 1; ++i)
{
r[i] = MIN;
}
return memoized_cut_rod_aux(p, n, r);
}
int memoized_cut_rod_aux(int p[], int n, int r[])
{
int q;
if (r[n] >= 0)
return r[n];
if (n == 0)
q = 0;
else
{
q = MIN;
for (int i = 0; i < n; ++i)
q = MAX(q, p[i] + memoized_cut_rod_aux(p, n - i - 1, r));
}
r[n] = q;
return q;
}
//自底向上版本
//函数值返回不对, 将i<j改成<=对了
int bottom_up_cut_rod(int p[], int n)
{
int r[n+1];
int q;
r[0] = 0;
for(int j = 0; j < n; ++j)
{
q = MIN;
for (int i = 0; i <= j; ++i)
q = MAX(q, p[i] + r[j - i]);
r[j + 1] = q;
}
return r[n];
}
//重构解
//下面是BOTTOM-UP-CUT-ROD的扩展版本,它对它对长度为j的钢条不仅计算最大收益值$r_j$,还保存最优解对应的第一段钢条的切割长度$s_j$
void extended_bottom_up_cut_rod(int p[], int n, int r[], int s[])
{
int q ;
r[0] = 0;
s[0] = 0;
for (int j = 0; j < n; ++j)
{
q = MIN;
for (int i = 0; i <= j; ++i)
{
if (q < p[i] + r[j-i])
{
q = p[i] + r[j-i];
s[j+1] = i + 1;
}
}
r[j+1] = q;
}
}
//下面过程接受两个参数:价格表p 和钢条长度n,
//然后调用EXTENDED-BOTTOM-UP-CUT-ROD来计算切割下来的每段钢条的长度s[1..n]。最后输出长度为n的钢条的完整的最优切割方案:
void print_cut_rod_solution(int p[], int n, int r[], int s[])
{
while (n > 0)
{
cout << s[n] << ends;
n = n - s[n];
}
}
15.2 矩阵链乘法
给定一个 n 个矩阵的序列(矩阵链) <A1,A2,...,An> <script type="math/tex" id="MathJax-Element-277"> </script>,计算它们的乘积
可以先用括号明确计算次序,然后利用标准的矩阵相乘算法进行计算。由于矩阵乘法满足结合律,因此任何加括号的方法都会得到相同的计算结果。称有如下性质的矩阵乘积链为 完全括号化的:它是单一矩阵,或者是两个完全括号化的矩阵乘积链的积,且已外加括号。
对矩阵加括号的方式会对乘积运算的代价产生巨大影响。先来分析两个矩阵相乘的代价。下面的伪代码给出了两个矩阵相乘的标准算法。属性rows和columns时矩阵的行数和列数。
MATRIX-MULTIPLY(A,B)
if A.columns != B.rows
error "incompatible dimensions"
else let C be a new A.rows * B.columns matrix
for i = 1 to A.rows
for j = 1 to B.columns
cij = 0
for k = 1 to A.columns
cij = cij + aik * bkj
return C
两个矩阵只有相容,即A的列数等于B的行数时,才能相乘。
矩阵链乘法问题:给定 n 个矩阵的链
<A1,A2,...,An>
<script type="math/tex" id="MathJax-Element-2043">
</script>,矩阵
Ai
的规模为
pi−1∗pi(1≤i≤n)
,求完全括号化方案,使得计算乘积
A1A2.......An
所需标量乘法次数最少。
注意:求解矩阵链乘法问题并不是要真正进行矩阵相乘运算,我们的目标
只是确定代价最低的计算顺序。
计算括号化方案的数量
括号方案的数量与 n 呈指数关系。
应用动态规划方法
按照本章开头提出的4个步骤进行:
步骤 1 :最优括号化方案的结构特征
任何最优解都是有子问题实例的最优解构成的,因此,为了构造一个矩阵乘法问题实例的最优解,可以将问题划分为两个子问题,求出问题时的最优解,然后将子问题的最优解组合起来。必须保证在确定分割点时,已经考察了所有可能的划分点,这样就可以保证不会遗漏最优解。
步骤2:一个递归求解方案
令m[i..j]表示计算矩阵
Ai..j
所需标量乘法次数的最小值。那么,原问题的最优解——计算
A1..n
所需的最低代价就是m[1,n]。
步骤 3 : 计算最有代价
自底向上方法
MATRIX-CHAIN-ORDER(p)
n = p.length - 1
let m[1..n, 1..n] and s[1..n-1, 2..n] be new tables
for i = 1 to n
m[i,i] = 0
for l = 2 to n
for i = 1 to n-l+1
j = i+l-1
m[i,j] = ∞
for k = i to j - 1
q = m[i,k] + m[k+1, j] + pi-1pkpj
if q < m[i,j]
m[i,j] = q
s[i,j] = k
return m and s
步骤4: 构造最优解
调用PRINT-OPTIMAL-PARENS(s,1,n)即可输出
<A1,A2,...,An>
<script type="math/tex" id="MathJax-Element-2066">
</script>的最优括号方案。
PRINT-OPTIMAL-PARENS(s,i,j)
if i==j
print "A"
else print"("
PRINT-OPTIMAL-PARENS(s, i, s[i,j])
PRINT-OPTIMAL-PARENS(s, s[i,j]+1,j)
print")"
15.3 动态规划原理
在本节中,我们关注适合应用动态规划方法求解的最优化问题应该具备的两个要素:最优子结构和子问题重叠。
最优子结构
如果一个问题的最优解包含其子问题的最优解,就称此问题具有最优子结构性质。
一些微妙之处
重叠子问题
重构最优解
备忘
15. 4 最长公共子序列
一个给定的子序列,就是将给定序列中零个或多个元素去掉之后得到结果。其形式化定义如下:给定一个序列X=
<x1,x2,...,xn>
,另一个序列Z=
<z1,z2,...,zk>
<script type="math/tex" id="MathJax-Element-5141">
</script>满足如下条件时称为X的
子序列,即存在一个严格递增的X的下标序列
<i1,i2,...,in>
,对所有j=1,2, … , k,满足
xi=zj
.
给定两个序列X和Y,如果Z即是X的子序列,也是Y的子序列,称它时X和Y的
公共子序列。
最长公共子序列问题(LCS)给定两个序列X=
<x1,x2,...,xm>
和Y=
<y1,y2,...,yn>
,求X和Y长度最长的公共子序列。
步骤 1: 刻画最长公共子序列的特征
给定一个序列X=
<x1,x2,...,xm>
,定义X的第 i 前缀为
Xi=<x1,x2,...,xi>
.
步骤2:一个递归解
定义c[i,j]表示
Xi
和
Yj
的LCS长度。得到如下公式:
步骤3:计算LCS的长度
过程LCS-LENGTH接受两个序列X和Y为输入,它将c[i, j]的值保存在表c[0..m, 0..n]中,并按 行主次序计算表项(即首先由左至右计算c的第一行,然后计算第二行,以此类推)。过程返回表b和表c,c[m,n]保存了X和Y的LCS的长度。
LCS-LENGTH(X,Y)
m = X.length
n = Y.length
let b[1..m, 1..n] and c[0..m, 0..n] be new tables
for i = 1 to m
c[i, 0] = 0
for j = 0 to n
c[0,j] = 0
for i = 1 to m
for j = 1 to n
if xi == yi
c[i,j] = c[i-1,j-1]+1
b[i,j] = "↖"
else if c[i-1, j] >= c[i, j-1]
c[i,j] = c[i-1,j]
b[i,j] = "↑"
else c[i,j] = c[i, j-1]
b[i,j] = "←"
return c and b
步骤4:构造LCS
调用PRINT-LCS(b, X, Y, X.length, Y.length).
PRINT-LCS(b, X, i, j)
if i==0 or j == 0
return
if b[i,j] == ↖"
PRINT-LCS(b,X,i-1,j-1)
print x
else if b[i,j] == "↑"
PRINT-LCS(b,X,i-1,j)
else PRINT-LCS(b,X,i,j-1)
算法改进
15. 5 最优二叉搜索树
下面伪代码接受概率列表
p1,...,pn
和
q0,...,,qn
及规模 n 作为输入,返回表e和root。
OPTIMAL-BST(p,q,n)
let e[1..n+1, 0..n], w[1..n+1, 0..n], and root[1..n, 1..n]be new tables
for i = 1 to n+1
e[i, i-1] = q(i-1)
w[i,i-1] = q(i-1)
for l = 1 to n
for i = 1 to n-l+1
j = i+l-1
e[i,j] = ∞
w[i,j] = w[i,j-1] + pi + qj
for r = i to j
t = e[i,r-1] + e[r+1, j] + w[i,j]
if t < e[i,j]
e[i,j] = t
root[i,j] = r
return e and root