第十五章 动态规划
15.1 钢条切割
这里要算的是切割的段数,和从切头切还是从尾切没有关系,只要计算
切前1米后,后面的米数的各种可能性
切前2米后,后面的米数的各种可能性
切 N 米后,后面的米数的各种可能性
后面各种可能性如何算呢?还是“切前1米后,后面的米数的各种可能性。切前2米后,后面的米数的各种可能性。。。”。切到 0 米时返回。
自顶向下

自底向上

自底向上方法过程如下:
先算 r[1] 的各种组合中的最小值。r[1] 各种组合只有一种,就是 p[1] + 0
再算 r[2] 的各种组合中的最小值。r[2] 时,组合有两种 :p[1] + r[1] 和 p[2] + 0。在算 p[1] + r[1] 时,因为已经有 r[1] 的值,所以就可以直接算出来。p[2] + 0 也可以直接算出来。这两个结果进行比较大小,然后设置到 r[2] 上。
再算 r[3] 的各种组合中的最小值。r[3] 时,组合有三种 :p[1] + r[2] 和 p[2] + r[1] 和 p[3] + 0。r[1] 和 r[2] 在前面都计算过了,都可以直接使用。
15.2 矩阵链乘法
这章主要是解决,矩阵链乘法的动态规划算法。那什么是矩阵链乘法的动态规划算法呢?
简单的说,就是有A1,A2...An个矩阵,如何利用标准矩阵乘法运算的话,如何相乘,进行相乘的运算次数最小。
如下性质的矩阵乘职链为“完全括号化的”。例如,矩阵链为<A1, A2, A3, A4, A5>

关于矩阵乘法
两个矩阵 A 和 B,如果 A 是 p(row) x q(column) 的矩阵,B 是 q(row) x r(column) 的矩阵,那么乘职 C 是 p(row) x r(column) 的矩阵。如果 A 的列 和 B 的行的值不一致的话,是无法相乘的。关于如何进行相乘,请看:理解矩阵乘法
3个矩阵相乘的话,相乘的顺序不同的话,会导致不同的计算代价。例如,假设 3 个矩阵规模为 A1(10x100)、A2(100x5)、A2(5x50)。下面展示一下顺序不同的结果:
注意:两个矩阵相乘次数计算方法为:A1.row x A1.cloumn x A2.cloumn
((A1A2)A3):A1A2 要做 10x100x5=5000 次乘法。再与 A3 相乘,又要做 10x5x50=2500 次乘法,总结 7500 次乘法。
(A1(A2A3)):A2A3 老板娘 100x5x50=25000 次乘法,再与 A1 相乘又需要 10x100x50=50000 次乘法,总结需要 75000 次乘法。
所以,第一种顺序计算矩阵链乘职要比第二种顺序快10倍。
计算括号化方案的数量
下面讲的方案和“卡特兰数”有关,可以先了解一下“卡特兰数”再了解这个,也可以直接看。书上说“穷举所有可能的括号化方案不会产生一个高效的算法”,但下面的公式就是穷举所有方案(卡特兰数也是)。个人理解的是,书上是想说,“把穷举的方案都计算一回”不是高效的算法,因为很多方案都已经计算过了,没有必要再进行计算。
当 n=1 时,只有一种方案。当 n>=2时,完全括号化方案可以写成“两个完全括号化的部分积相乘的形式”,而两个部分划分点在“第k个矩阵”和“第k+1个矩阵之间”,k 为 1,2,3…n-1 中任意一个值。因此,递归公式如下:

应用动态规划方法
步骤1:最优括号化方案的结构特征
为了构造一个最优解,我们可以把问题分成两个子问题(Ai Ai+1…Ak 和 Ak+1 Ak+2…Aj 的最优括号化问题),求出子问题的最优解(递归),然后把子问题的最优解组合起来。我们必须确保在确定分割点时,已经考察了所有可能的划分点,这样就可以保证不会遗漏最优解。
步骤2:一个递归求解方程
我们可以将所有 1<=i<=j<=n 确定 Ai Ai+1…Aj 的最小代价括号化方案作为子问题,令 m[i, j] 表示计算矩阵 Ai,j 所需乘法次数的最小值。那么,原问题最优解就是示计算 A1,n 所需的最低代价就是 m[1, n] 的值。
第一步
我们可以递归定义m[i, j]如下。对于i=j时的平凡问题,矩阵链只包含唯一的矩阵A(i..j)=A(i),因此不需要做任何标量乘法运算。所以,对所有i=1,2,…,n,m[i,i]=0。若i<j,我们利用步骤1中得到的最优子结构来计算 m[i, j]。我们假设 A(i)A(i+1)…A(j) 的最优括号化方案的分割点在矩阵 A(k) 和A (k+1) 之间,其中i<=k<j。那么,m[i, j]就等于计算A(i..k)和A(k+1..j)的代价加上两者相乘的代价的最小值。由于矩阵Ai的大小为p(i-1) x pi,易知A(i..k)和A(k+1..j)相乘的代价为 p(i-1) x p(k) x p(j) 次标量乘法运算。因此,我们得到
m[i, j] = m[i, k] + m[k+1, j] + p(i-1)p(k)p(j)
这里解释一下上面的公式。
1,关于 p,p 是一个数组,假设我们有 6 个矩阵如下,如果要用一个数组来保存矩阵的规模的话,用一个 7 个元素的数组就可保存。p[0] 保存第一个元素的 row,p[1] 第一个元素的 column,p[2] 保存第二个元素的 column,p[3] 保存第三个元素的 column … 之后都是保存后面元素的 column,这样 7 个元素的数组就可以保存 6 个矩阵的规则。

p[0]=30、p[1]=[35]、p[2]=[15]、p[3]=[5]、p[4]=[10]、p[5]=[20]、p[6]=[25]
2,为什么 m[i, j] = m[i, k] + m[k+1, j] + p(i-1)p(k)p(j)
就像 3 个矩阵相乘的话, A1(30x35)、A2(35x15)、A2(15x5),假如是 ((A1A2)A3) 进行乘法运算,i=1、k=2、j=3 的话,
m[i, k] 就相当于 A1乘A2的次数
m[k+1, j] 就相当于 A3 相乘的次数(0次)
而 p(i-1)p(k)p(j) 就相当于 A1A2 的结果矩阵 x A3 的次数。注意:p(i-1) 也就是 p[0],保存的是 A1 的 row(30);p(k) 也就是 p[2],保存的是 A2 的 column(15);p(j) 也就是 p[3],保存的是 A3 的 column(5)
第二步
A1 A2 A3 的乘法就像上面说的有两种选择:((A1A2)A3)、(A1(A2A3))。哪种选择乘法次数最小呢?也就是看哪个方法乘完,次数更小:min{((A1A2)A3), (A1(A2A3))}。所以完整的公式如下:

m[i, j] 的值给出了最优解的代价(就是相乘次数最小的值),但它并未提供足够的信息来构造最优解。为此,我们用 s[i, j] 保存 Ai Ai+1 … Aj 最优括号化方案的分割点位置 k。
步骤3:计算最优代价
我们可以很容易地根据上面的递推公式写一个递归算法,来计算最小代价 m[1, n]。注意到,我们需要求解的问题的数目是相对较少的:每对满足 1 <= i <= j <= n 的 i 和 j 对应一个唯一的子问题,共有 个。递归算***在递归调用树的不同分支中,多次遇到同一个子问题。遇到这种子问题重叠的问题,就应该使用“动态规划”
为什么会有 个子问题?其实就相当于求:在 n 里取两个数( i 和 j ),看有多少种组合。因为 i 和 j 是有顺序要求的,所以不能使用排序,要使用组合(一般有顺序的情况要使用组合)。最后加的那个 n 就是 Pi-1 * Pk * Pj。
下面的程序使用了“自底向上”表格法,代替递归算法来计算最优代价。此过程中,假设矩阵 Ai 的规模为 Pi-1 * Pi (i=1,2…n)。它的输入是一个序列 P = (P0, P1…Pn),其长度为 P.length = n + 1。过程中用一个辅助表 m[1..n, 1..n] 来保存代价 m[i, j],用另一个辅助表 s[1..n-1, 2..n] 记录最优值 m[i, j] 对应的分割点 k。我们就可以利用表 s 构造最优解。
为了使用“自底向上”方法,我们必须确定计算 m[i, j] 时需要访问哪些其它项(也就是它的子项,没有子项的结果无法计算 m[i, j])。
程序:

图 1:

图 2:

这部分不容易理解,要把程序和图一起来说明才行。
图片里的三角形看起来有点怪,是因为 i 的坐标 1.2…6 位置应该在 A1,A2…A6 的位置。因为要把三角形转 45 度显示,所以把 i 坐标的位置,放到对面边后面显示。而 j 坐标的位置没有变,所以和我们平时看二维数组的感觉的不一样。
(1) 关于图
首先说一下图,m[n, n] 二维数组应该是 图2 的形状。但 图2 和正常的二维数组相比也少了“左下”部分,相当于正常二维数组的 1/2。为什么要只表示出 1/2 呢?因为根据上面的规定 i <= j,所以只能使用 i <= j 的部分,i <= j部分就是“右上”部分。为了看起来方便,把 图2 的二维数组向左旋转了 45 度,变成了 图1 的形状。s[1..n-1, 2..n] 也是一样。
(2) 求解思想
求解的话,首先从 图1 的下层向上求因为塔尖的 m[1, 6] 是我们要求的最终结果。如果要求这个结果的话,要先下塔底的先求出来,所以要先求最下层的值。
首先求 m[1, 2]、m[2, 3] … m[5, 6]。而这些是很好求的,需要求 m[1, 1] 和 m[2, 2] 和 Pi-1 * Pk * Pj 就可以了,而m[1, 1] 和 m[2, 2] 在最开始已经设置好了0,所以只需要计算 Pi-1 * Pk * Pj 就可以了。
接着计算倒数第二层的值。m[1, 3] 的结果是 7875,要计算 m[1, 3] 的话,就要计算下面的组合。这里要 4 个元素的值,m[1, 1]、m[2, 3]、m[1, 2]、m[3, 3]。m[1, 1] 和 m[3, 3] 在最开始就求出来了,而 m[1, 2] 和 m[2, 3] 正是倒数第二层求出来的值
m[1, 1] + m[2, 3] + Pi-1PjPk
m[1, 2] + m[3, 3] + Pi-1PjPk
(3) 关于程序
为什么把 m[i, i] = 0 (i = 1 to n)?
因为 m[i, i] 相当于只有一个矩阵,一个矩阵不需要相乘,所以最小相乘次数为 0。
为什么 l = 2 to n?
l 是用来控制循环层数。为什么从 2 开始呢?因为第 1 层(也就是最下面那层),在程序的第 3~5 行已经设置完了,所以再循环 5 次就可以了。
这个是原来的记录,可能是错的:矩阵链的长度为 n (假设是 6 ),我们需要求 m[i, i]、m[i, i+1]…m[i, n] (也是就 Ai,i、Ai,i+1…Ai,n) ,n 个链(6个链)的相乘次数。因为上面有了把 m[i, i] = 0 的设置,所以我们只需要求 m[i, i+1]、m[i, i+2]…m[i, n],n-1 个链(5个链)的相乘次数就可以了。l 是用来控制循环的总次数
为什么 i = 1 to n-l+1
i 的循环次数,是和 图1 的那个塔的每层的元素个数相对应。因为最底层的 6 个元素的值已经在最开始的时候设置完了,所以再接下来就是计算“倒数第二层”及上面的值了。所以 i 的循环次数为 5次、4次…1次 递减的。
j 的作用是什么
j 的作用是是和 i 相配合。我们知道,i 是和每一行要计算元素的个数相关的,也是元素的其中一个坐标。至于 j,是为了产生元素的另一个坐标。例如:对于倒数第三行(7875开头的行),每个元素的坐标为 7875(1, 3)、4375(2,4)、2500(3,5)、3500(4、6),j 的作用就是用来表示 3,4,5,6 这几个坐标的。
k 的作用是什么?
k 的循环,是为了计算 Aij 中每一种情况的组合。例如,i=1, j=4 ,那么 k = i(1) to j-1(3),如果要计算 m[1, 4] 的话,就要计算:A11 * A24、A12 * A24、A13 * A44 这 3 种组合的值,看哪个相乘次数更小。
(4) 时间复杂度
因为是 3 层循环嵌套,所以复杂度为
步骤4:构造最优解
s[1..n-1, 2..n] 虽然保存了最优括号化方案的“分割点 k”的信息,但我们还要很好的输出出来。如何输出呢?(假设我们还是使用上面的例子,有 6 个矩阵)
- 首先,我们知道了 A(1..n) 的分割点是 s[1, n]=3。例如,s[1, 6]=3
- 然后,我们找 A(1..s[1, n]) 和 A(s[1, n] + 1, n) 的分割点。例如:A(1..s[1, n])=A(1,3) 的分割点是 s[1, s[1,n]]=s[1,3]=1; A(s[1, n] + 1, n)=A(4, 6) 的分割点 s[s[1,n]+1, n]=s[4,6]=5
- 按照 2 的步骤类推,直到最小矩阵
程序如下:

参考:
动态规划之矩阵链乘法
动态规划概论
矩阵链乘-算法导论
矩阵链乘法问题
动态规划之矩阵链乘法(第15章)
15.1 和 15.2 总结:钢条和矩阵链不同点
钢条每一米特性都是一样的,所以前面两米的组合 和 后面两米的组合 结果是一样的。
矩阵链每个矩阵可能都是不一样的,所以前面两个矩阵的乘积 和 后面两个矩阵的组合 的结果是不一样的。
也就是说在某一段(长度大于1)的最优值时(例如 3个元素 A、B、C),钢条只需要计算一个两个元素的组合,即 AB 或是 BC;而矩阵链而需要计算所有的两个元素的组合,即 AB 并且 BC。
例如下面的图中,钢条图中的 (2) 是对 3 个元素的求最大,但只求(3)(6)两个组合后,就计算出 3 个元素的最值,并没有计算 3 个元素中“前两个元素的组合 + 最后一个元素”这个种组合。但在矩阵链中,注意第一和第二行中的“A2A3A4”的组合,两个元素的两两组合都计算了。
钢条图:

矩阵链图:

所以,两者的递归公式也不相同:
钢条:
矩阵链:
如果 n = 1
如果 n >= 2
公式的不同点就在于矩阵链多出来那个P(k)。在没有P(k)的时候,总是前面“前面一个元素的组合 + 后面多个元素的组合”的形式,不存在“前面多个元素组合 + 后面多个元素组合”的形式。这个P(k)让每个子问题中前面元素也出现了组合的情况,这就形成了“各种两两组合”的形式,而不是“前面元素只是一种组合”的钢条的情况。所以,矩阵链有两个下标(i, j)在移动。
15.3 动态规则原理
虽然我们已经用动态规则方法解决了两个问题,但你可能还是弄不清应该在何时使用动态规则。在什么情况下应该使用动态规划方法求解问题呢?两个要素:
最优子结构
子问题重叠
1,最优子结构
如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构性质。因此,某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个好线索(当然,具有最优子结构性质也可能意味着适合应用信心策略)。
使用动态规则方法时,我们用子问题的最优解来构造原问题的最优解。因此,我们必须小心确保考察了最优解中用到的所有子问题。
一个刻画子问题空间的好经验是:保持子问题空间尽可能简单,只在必要时才扩展它。例如,在求钢条问题时,子问题递归方案是:。矩阵链的方案是:,它也能解决钢条问题,但有一些计算是没有意义的(也就是说,不计算那些也能得到钢条问题的解)。
一些微妙之处
在尝试使用动态规划算法时要小心,要注意“问题是否有具有最优子结构性质”。
考虑下面两个问题,其中都是给定一个有向图 和两个顶点 。
- 无树最短路径:找到一条从 u 到 v 的边数最小的路径。
- 无权最长路径:找到一条从 u 到 v 边数最多的简单路径。这里必须加上“简单路径”的要求,因为我们可以不停地沿着环走,将环去掉显然会减少边的数量。
图如下:

证明1:最短路径有最优子结构性质
省略(没看明白)
证明2:最长路径有最优子结构性质
考虑路径 q->r->t,它是从 q 到 t 的最长路径。q->r 是从 q 到 r 的最长路径吗?不是的,q->s->t->r 是一条更长的路径。r->t 是 r 到 t 的最长路径吗?不是的,r->q->s->t 是比它更长的。而“q->s->t->r”和“r->q->s->t”这两条 q->r 和 r->t 最长路径,加起来并不是简单路径,所以最长路径没有最优子结构性质。
为什么最长简单路径问题的子结构与最短路径有这知大的差别?
原因在于,虽然最长路径问题和最短路径问题的解都用到了两个子问题,但两个最长简单路径子问题是相关的,而两个最短路径的子问题是无关的(independent)。这里,子问题无关的含义是,同一个原问题的一个子问题的解不影响另一个子问题的解。
在无权最长路径的问题中,找出从q到t的最长简单路径的问题有两个问题:找出从q到r及从r到t的最长简单路径。对第一个子问题,我们选择路径q->s->t-r,因此使用了顶点s和t。在第二个子问题中就不能再使用这些顶点了,因为合并两个子问题的解会得到一个非简单的路径。然后求第二个问题又不能不用到顶点t。
那为什么最短路径的子问题是无关的呢?根本原因在于,最短路径子问题间是不共享资源的。首先我们假设最短路径的子问题是相关的,那么他们之间就一定会有公共的点,我们再这里设这个点为p,于是uw,wv两个子问题我们可以写成upw,wpv,可见pw与wp这一段是重复的,那么我们现在可以找到一个比原来最短路径e更短的路径e-2(p到w之间的距离),产生矛盾!
2,重叠子问题
适合用动态规划方法的第二个性质是子问题空间必须足够“小”,即问题的递归算***反复地求解相同的子问题,而不是一直生成“新的子问题”(分治方法求解的问题通常会在每一步都生成全新的子问题)。动态规划算法通常这样利用重叠子问题性质:对每个子问题求解一次,将解存入一个表中,当每次需要这个子问题时,直接查表。
下面是矩阵链的递归程序,和重复计算的部分(阴影部分):

3,重构最优
下面是一个带“备忘机制”的“自顶向下”的程序。重要的是 LOOKUP-CHAIN 的 第1,2行,先判断有没有值,有就直接取。

15.4 最长公共子序列
什么是子序列?
例如:Z = (B,C,D,B) 是 X = (A,B,C,B,D,A,B) 的子序列。对应下标为 (2,3,5,7)。子序列和包含它的串的元素顺序必须是相同的,但不必须是连接的。
什么是最长公共子序列?
公共子序列(common subsequence): 给定序列X和Y,序列Z是X的子序列,也是Y的子序列,则Z是X和Y的公共子序列。例如 X=<A,B,C,B,D,A,B>,Y=<B,D,C,A,B,A>,那么序列 Z=<B,C,A> 为 X 和 Y 的公共子序列,其长度为3。但 Z 不是 X 和 Y 的最长公共子序列,而序列<B,C,B,A>和<B,D,A,B>也均为X和Y的最长公共子序列,长度为4,而 X 和 Y 不存在长度大于等于5的公共子序列。
最长公共子序列问题
给定两个序列:X=<x1,x2,...,xm> 和 Y=<y1,y2,...,yn>,求 X 和 Y 长度最长的公共子序列。下面使用动态规划的方法来求解这个问题。
步骤1:刻画最长公共子序列的特征
省略
步骤2:一个递归解
在求X=<x1,x2,...,xm> 和 Y=<y1,y2,...,yn>的一人 LCS 时,我们一定要求一个或两个子问题。
当xm=yn时,找出Xm-1和Yn-1的最长公共子序列,然后在其尾部加上xm(=yn)即可得X和Y的一个最长公共子序列。
当xm≠yn时,必须解两个子问题,即找出Xm-1和Y的一个最长公共子序列及X和Yn-1的一个最长公共子序列。这两个公共子序列中较长者即为X和Y的一个最长公共子序列。
由此递归结构容易看到最长公共子序列问题具有子问题重叠性质。例如,在计算X和Y的最长公共子序列时,可能要计算出X和Yn-1及Xm-1和Y的最长公共子序列。而这两个子问题都包含一个公共子问题,即计算Xm-1和Yn-1的最长公共子序列。
与矩阵连乘积最优计算次序问题类似,我们来建立子问题的最优值的递归关系。用c[i,j]记录序列 Xi 和 Yj 的最长公共子序列的长度。其中Xi=< x1, x2, …, xi >,Yj=< y1, y2, …, yj >。当 i=0 或 j=0 时,空序列是 Xi 和 Yj 的最长公共子序列,故c[i, j]=0。其他情况下,由定理可建立递归关系如下:

上面的限制条件限定了需要求解的哪些子问题呢?
当 xi = yi 时,我们可以而且应该求解子问题 Xi-1 和 Yi-1 的一个 LCS。否则,应该求解两个子问题:Xi 和 Yj-1 的一个 LCS,及 Xi-1 和 Yj 的一个 LCS。
步骤3:计算 LCS 的长度
由于 LCS 问题只有 个子问题,我们使用自底向上地计算。
计算最长公共子序列长度的动态规划算法LCS_LENGTH(X,Y)以序列 X=< x1, x2, …, xm > 和 Y=< y1, y2, …, yn > 作为输入。输出两个数组 c[0..m ,0..n] 和 b[1..m ,1..n] 。其中 c[i, j] 存储 Xi 与 Yj 的最长公共子序列的长度,b[i, j] 记录指示 c[i, j] 的值是由哪一个子问题的解达到的,这在构造最长公共子序列时要用到。最后,X 和 Y 的最长公共子序列的长度记录于 c[m, n] 中。在计算时,按“行主次序(row-major order)”方式计算,即首先“从左到右”计算第一行,然后再计算第二行,依次类推。
程序如下:


下面结合上面的公式,加上下面的图,说明一下公式和程序为什么要这么做。
我们拿 c[3, 3] 进行举例说明。
对于当 xi = yj 时,c[i, j] = c[i-1, j-1] + 1。因为找出Xi-1和Yj-1的最长公共子序列,然后在其尾部加上xi(=yj)即可得X和Y的一个最长公共子序列。所以 c[3, 3] 的最长公共子序列,就等于 c[2, 2] + 1 = 2。而且,在 c[2, 2] 后,比 c[2, 2] 再长的公共子序列,只能在 c[2, 2] 后面,即 c[2, 3] 或 c[3, 2] 或 c[3, 3],不可能在 c[2, 2] 前面了,对吧。
对于 xi ≠ yj 时,要不最长公共子序列在 C[i-1, j] 中,要不在 C[i, j-1]中,所以我们要比较 C[i-1, j] 和 C[i, j-1] 的大小。相同的话,我们就随便找一个。例如:在计算C[3, 4]时,要比较 C[2, 4]=1 和 C[3, 3]=2,因为 C[3, 3] 大,所以取了 C[3, 3],而且箭头指向了 C[3, 3],也就是相当于记录一下前一个最大公共子序列在什么位置。
所以,这里箭头的意思是,当前这个单元格的前一个最大公共子序列在什么位置。
步骤4:构造 LCS
b 里保存了“箭头”(也就是前一个 LCS元素的位置),我们只需要从 b[m, n] 开始,并按箭头方向追踪下去即可。当遇到“↖︎”时,意味着 xi = yi 是 LCS 的一个元素。按照这种方法,我们可以逆序地依次构造出 LCS 的所有元素。
“←”和“↑”作用是,在 Xi ≠ Yj 时,比较 c[i, j-1] 和 c[i-1, j] 哪个大,哪个大就指向哪个。(c[i, j-1] 在 c[i, j] 的左面,所以使用“←”;c[i-1, j] 在 c[i, j] 的上面,所以使用“↑”)

算法的改进
对于 LCS 算法,我们完全可以去掉表 b。每个 c[i, j]项只依赖于表 c 中的其他三项:c[i-1, j]、c[i, j-1] 和 c[i-1, j-1]。给定 c[i, j] 的值,我们可以在 O(1) 时间内判断出在计算 c[i, j] 时使用了哪一项。但这样就需要把计算的过程再运行一回,计算过的值,就不用再计算了。相当于用时间换空间。
总结
一个长度为 n 的字符串的子序列的个数为 ,另一个字符串长度是 m 的话,两个就是 这么多个子串。如果在这么多个子串中进行比较的话,就是 这么多个串的组合,计算规则是非常恐怖的。
使用动态规划的 LCS 算法,时间复杂度只是 这么多,和 相比,小的太多了。如果不利用重复计算的结果的话,那么每个 C[i, j] 要计算的值的次数()也是非常恐怖的。由此可见在动态规划算法中,利用重复计算结果的好处。
15.5 最优二叉搜索树
todo
第十六章 贪心算法
对于许多最优化问题,使用动态规划算法有些杀鸡用牛刀了,可以使用更简单、更高效的算法,贪心算法(greedy algorithm)就是这样的算法。
贪心算法并不保证得到最优解,但对很多问题确实可以得到最优解。首先在 16.1 介绍一个简单但非平凡的问题–活动选择问题,这是一个可以用贪心算法求得最优解的问题。
16.1 活动选择问题
问题
假定一个有n个活动(activity)的集合S={a1,a2,….,an},这些活动使用同一个资源(例如同一个阶梯教室),而这个资源在某个时刻只能供一个活动使用。每个活动 ai 都有一个开始时间 si 和一个结束时间 fi,其中0<=si<fi< 正无穷。如果被选中国,任务 ai 发生在半开时间区间 [si,fi )期间。如果两个活动 ai 和 aj 满足 [si,fi) 和 [sj,fj) 不重叠,则称它们是兼容的。也就说,若 si>=fj 或 sj>=fi ,则 ai 和 aj 是兼容的。在活动选择问题中,我们希望选出一个最大兼容活动集。
前提:假定活动已按结束时间fi的单调递增顺序排序,即:
f1<=f2<=f3<=f4<=…<=fn-1<=fn

活动选择问题的最优子结构
活动选择问题是有最优子结构的,证明省略。意味着我们可以使用动态规划方法求解活动选择问题。如果用 c[i, j] 表示集合 Sij 的最优解的大小,则可递归式为
c[i, j] = c[i, k] + c[k, j] + 1
如果不知道 Sij 的最优解包含活动 ak,就需要考查 Sij 中所有活动,寻找哪个活动可获得最优解,于是:

递归公式中,注意几点:
1,递归结束的 Sij = 空,是用程序如何表达?
如果 i+1 >= j 的话,那么就是空。因为 ai 和 aj 之间没有任何元素,所以 c[i, j] 应该是 0。
2,Sij != 空,用程序如何表达?
如果 i+1 < j 的话,那么就是非空。因为 ai 和 aj 之间有大于1个的元素。
那么 ai.finish > aj.start 算不算作Sij = 空呢?
感觉至少在递归里不应该算。因为递归里最开始的时候,i 和 j 分别是数组的第一个和最后一个元素。如果第一个元素.finish > 最后一个元素.start 的话,就返回空,递归就在刚开始就结束了。但第一个和最后一个元素之间,还有很多其它元素,不能保证 第一个元素.finish > 其它元素的.start,所以在“递归结构”中,不应该算作空。如果在非递归结构里,例如使用自底向上的方式的话,在特定的条件下,ai.finish > aj.start 算不算作Sij = 空 可能还是可以使用的。
动态规划的图
活动选择的图如下,不像钢条和矩阵链问题,活动选择问题的 C[i,i] {i=1…n} 的元素是没有用的。因为活动选择问题最小单位是 2 (Sij)。而最小单位 2 其实也不用,因为根据上面的规则,Sij 中间没有元素的话,就是空,所以所有 Sij 是 2 的单元格都是 0。最后,在 c[1,3] 和 c[2,4] 中选择一个大的,放到 c[1,4]中。
k(x)计算,是指看 x 是否是i.finish <= x.start and x.start <= j.finish。有的答案里,是如果 k(x) 为 false 的话,就不计算 c[i,k] + c[k,j] 了。但个人感觉还是应该计算 c[i,k] + c[k,j] 的,这里还没有想清楚。

(这里,c[i,k] 如果计算应该还没有指出,需要自己想一下,这个 算法导论 16.1-1活动选择问题的动态规划算法 答案 是一个答案,不是知道对不对)
贪心选择
对于活动选择问题,什么是贪心选择?
直观上,我们应该选择这样一个活动,选出它后剩下的资源应该能被尽量多的其它任务使用。现在考虑可选的活动中,其中必然有一个最先结束。所以,活动选择问题的贪心选择算法如下:
前提:活动已经按结束时间递增顺序排序
判断方法:直觉告诉我们应该选择 S 中最早结束的活动,因为它剩下的资源可以供它之后尽量多的活动使用。(如果 S 中最早结束的活动有多个,我们可以选择其中一个,因为我们只关心最大资源使用,并不关心到底哪个活动使用)。
因为我们已经证明活动选择问题最有最优子结构性质,所以当我们做出贪心选择,选择了 a1 后,筛的 S1 是唯一需要求解的子问题。最优子结构性质告诉我们,如果 a1 在最优解中,那么原问题的最优解由活动 a1 及子问题 S1 中的所有活动组成。
关于“最早结束的活动的贪心选择,总是最优解的一部分”的证明省略。
递归贪心算法
我们假定输入的 n 个活动已经按结束时间的单调递增顺序排序完成(如果没有排序,我们可以在 O(nlgn) 时间内对它们排序)。我们尖圆一个虚拟活动 a0,其结束时间 f0=0,这样子问题 S0 就是完整的活动集 S。求解原问题即可调用 RECURSIVE-ACTIVITY-SELECTOR(s, f, 0, n)。

注意:
第2~3行的循环,是看哪个活动时间的“结束时间”在 k+1 这个活动的“开始时间”之后。
主要思想是,找到在第0个活动的“结束时间”之后的活动(假如是第3个活动)后,再使用递归找到在“刚刚找到的活动(第3个活动)”的“结束时间”之后的活动,以此类推。
过程图如下:

迭代贪心算法
不使用递归,我们使用迭代的方法,也可以实现贪心算法。

16.2 贪心算法原理
设计贪心算法经过下面几个步骤:
确定问题的最优子结构
基于问题的最优子结构设计一个递归算法
证明我们做出的贪心选择,只剩下一个子问题
证明贪心选择总是安全的
设计一个递归算法实现贪心策略
将贪心算法转化为迭代算法
比如在活动选择问题里面,我们就是确定了互动最优子结构的性质,我们在子问题Sj里面选出一个基于上次选择aj的最早结束活动am,使得Sj的最优解是由am和Sm的最优解组成的。
更一般的来说,我们可以讲贪心算法的设计步骤简述为下面几部:
将最优化问题简化为这样的形式:最初一个选择以后,只剩下一个子问题需要求解!
证明在做出贪心选择以后,原问题总是存在最优解,即贪心选择总是安全的!
证明在做出贪心选择以后,剩下的子问题满足性质:其最优解与做出选择的组合在一起得到原问题的最优解,即最优子结构
贪心算法的两大性质
贪心算法有两个重要的性质:
贪心选择性质
最优子结构性质
如果我们能证明有这两个性质,我们就向贪心算法迈出一大步。下面我们来详细讨论这两个性质
贪心选择性质
第一个关键要素就是贪心选择性质:我们可以做出局部最优选择来构造最优解。也就是说,我们在做出选择时,总是以当前的情况为基础做出最优选择的,而不用考虑子问题的解!
上面的话,我们利用活动选择的例子来解释一下:
“我们可以做出局部最优选择来构造最优解”,在活动选择中,我们已经有了第一个活动,然后我们要做的就是:“在剩下的活动中,找到一个开始时间 大于 前一个活动的结束时间 这样的活动”。找到后,我们再次“在剩下的活动中,找到一个开始时间 大于 前一个活动的结束时间这样的活动”。。。这样,我们在剩下的活动中找到我们想要的活动,就是做出局部最优选择来构造最优解。
“总是以当前的情况为基础做出最优选择的,而不用考虑子问题的解”,就是我们在局部选择最优时(也就是求解了问题时),不需要先求子问题的子问题的解,而是直接就可以在子问题中求出解。而动态规划则是,在求子问题解时,要先求子问题的子问题的子问题的子问题。。。一直到最小子问题才结束。
贪心算法与动态规划不同之处在于:
在动态规划算法中,每个步骤都要进行一次选择,但选择通常依赖于子问题。因此,我们通常使用“自底向上”的方法,先解小问题,再解大问题。
在贪心算法中,我们总是做当时看来最佳的选择,然后求解唯一的子问题。贪心算法可能依赖前面做的选择,但不依赖任何“将来的选择”或“子问题的解”。动态规划先求解子问题,才能进行第一次选择,而贪心算法在第一次选择前不求解任何子问题。
一个动态规划算法是“自底向上”的,而一个贪心算法通常是“自顶向下”的,进行一次次的选择,将问题变得更小。
最优子结构
如果一个问题的最优解包含其子问题的最优解,那么就称这个问题具有最优子结构性质!我们知道最优子结构这个性质是动态规划和贪心算法都必须具备的关键性质。
如果一个子问题 Sij 的最优解包括活动 ak,那么他必然也包含子问题 Sik 和 Skj 的最优解。给定这样的最优子结构,我们就可以得出结论:如果知道 Sij 的最优解应该包含哪个活动 ak,就可以组合 ak 以及 Sik 和 Skj 的最优解中的所有活动,来构造 Sij 的最优解。基于对最优子结构的这种观察结果,我们就可以设计出的递归式。
上面的这段,是针对动态规划的方式来说的
比起动态规划而言,贪心算法通常使用更为直接的最优子结构。假定通过对原问题进行贪心选择就可以得到子问题。我们真正要做的全部工作就是论证:将子问题的最优解与贪心选择组合在一起能生成原问题的最优解。这种方法隐含地对子问题使用了数学归纳法,证明了在每个步骤进行贪心选择会生成原问题的最优解。
而这段,是针对贪心算法来说的。贪心算法就是把“贪心性质(选择最早结束的活动会规划的更好)”和“最优解(在剩下的活动中选择最早结束的)”组合起来生成原问题的最优解。
贪心 VS 动态规划
由于贪心算法和动态规划都使用了最优子结构性质,你可能会对一个可用贪心算法求解的问题使用动态规划,或者相反。为了说明这两种方法之间的微妙差别,我们研究一个经典最优化问题的两个变形。
0-1背包问题:一个正在抢劫商店的小偷发现了n个商品,第i个商品价值vi元,重wi磅,vi和wi都是整数。小偷希望拿走价值尽可能高的商品,但是背包最做只能容纳W磅。他应该拿走哪些商品呢?
分数背包问题:设定与0-1背包一样,但是对每个商品,小偷可以只拿走其一部分,而不是只能做出二元(0-1)选择。可以把0-1背包问题中的商品看做品质不一大小不同的金块,而分数背包问题中的商品更像是金砂。
两个问题都具有最优子结构性质,虽然两个问题相似,但是我们可以用贪心算法求解分数背包,而不能求解0-1背包。为了求解分数背包,我们先计算每个商品的价重比即vi/wi。遵循贪心选择,先尽可能的拿走价重比最高的商品,如果此商品全部都拿走了,再继续拿价重比第二高的商品,依次类推,直到背包装满为止。
为了说明贪心策略对0-1背包问题无效,我们使用下图中实例。商品的价格在最下,而用长度来表示商品的重量和背包的最大容量W,即50。按照贪心算法,小偷应先拿走商品1,但是如下图(b)所示,最优解应该是拿走商品2和商品3。
但是对于分数背包问题,如(c)所示先拿走商品1是可以生成最优解的。拿走商品1对0-1背包问题无效是因为小偷无法装满背包,空闲空间降低了方案的有效价重比。在0-1背包问题中,当考虑是否将一个商品装入背包时,必须比较包含此商品的子问题的解与不包含它的子问题的解,然后才能做出选择。这会导致大量的重叠子问题——动态规划的标识。

16.3 赫夫曼编码
赫夫曼编码编码,简单地说就是把字符变成变长的,而不是定长的(现在的 utf-8 编码就是变长的)。
赫夫曼为什么要做这个编码方式呢?因为字符出现的频率不同,如果都使用定长编码的话,会多用很多空间。赫夫曼想了一种编码方式,把字符按照出现的频率,构建成一棵二叉树。树构建完后,每个字符所代表的编码也就确定下来了。所以,赫夫曼编码的最终是根据字符出现频率,编排出一种“最优”的字符编码方案。
这里的“最优”,是指代价最小,代价计算下面有说明。
在使用编码的时候,可能是使用 hash 的方式,直接取得编码对应的字符。

前缀码
前缀码,就是分隔每个字符的一种字符。因为是变长编码,所以每个字符的长度都是不一样的,如果没有种分隔字符,你不知道下个字符是多长。例如:二进制串 001011101 可以唯一地解析为 0-0-101-1101,解码为 aabe。
我们需要一种每方便的方式进行解码,一种二叉树表示可以满足这种需求,其叶结点为给定的字符。用从根结点到某个字符的简单路径,来表示该字符的二进制码。0 表示转向左孩子,1 表示转向右孩子。如下图

文件的最优编码方案总是对应一棵满(full)二叉树,即每个非叶结点都有两个孩子结点。上图的定长编码不是最优的,因为它是非满二叉树,它不包含 11 开头的字符。现在我们只关注满二叉树。若C为字母表且所有字符的出现频率均为正数,则最优前缀码对应的树恰有|C|个叶结点,每个叶结点对应字母表中的一个字符,且恰有|C|-1个叶结点。
定一棵对应前缀码的树T,我们可以容易地计算出编码一个文件需要多少个二进制位。对于字母表C中的每个字符c,令属性c.freq表示c在文件中出现的频率,令dT(c)表示c的叶结点在树中的深度,同时dT(c)也是字符c的码字的长度,我们记B(T)定义为T的代价,它表示编码文件需要多少个二进制位,则

构造赫夫曼编码
哈夫曼设计了一个贪心算法来构造最优前缀码,被称为哈夫曼编码。
假定C是一个n个字符的集合,而其中每个字符c∈C都是一个对象,其属性c.freq给出了字符的出现频率,算法自底向上地构造出对应最优编码的二叉树T。它从|C|个叶结点开始,执行|C|-1个“合并”操作创建出最终的二叉树,算法使用一个以属性freq为关键字最小优先队列Q,以识别两个最低频率的对象将其合并,当合并两个对象时,得到的新对象的频率设置为原来两个对象的频率之和。


为了分析赫夫曼算法的运行时间,我们假定 Q 是使用最小二叉堆实现的。
对一个 n 个字符集合 C,我们在第 2 行用 BUILD-MIN-HEAP 过程将 Q 初始化,花费时间为 O(n)。
第3~8行的 for 循环执行了 n-1 次,且每个堆操作都需要 O(lgn) 的时间,所以循环对总时间的贡献为 O(nlgn)。因此,处理一个 n 个字符的集合,赫夫曼算法的总运行时间为 O(nlgn)。如果将最小二叉堆换为 van Emde boas 树,我们可以将运行时间减少为 O(nlglgn)。
总结
赫夫曼算法看起来很简单,就是取 freq 最小的,慢慢组成树,形成一个最优前缀码。但证明贪心性质,和最优子结构还是不容易的。
如果不我们不使用贪心算法的话,可能我们就需要使用动态规划来计算了。