在开始讲动态规划之前,我们先来讲一个算法问题的实例,由此引出动态规划的一系列概念。
最长公共子序列问题 (LCS)
给定两个序列
x[1…m]
y[1…n]
求他们最长公共子序列
比如:
X
:
A
B
C
B
D
A
B
X: A B C B D A B
X:ABCBDAB
Y
:
B
D
C
A
B
A
Y: B D C A B A
Y:BDCABA
那么他们的最长子序列有
L
C
S
(
X
,
Y
)
=
B
D
A
B
,
B
C
A
B
,
B
C
B
A
LCS(X,Y)={BDAB, BCAB, BCBA}
LCS(X,Y)=BDAB,BCAB,BCBA
此处注意,最长子序列不要求是连续的,只要元素前后序列关系一致即可
对于这个问题,如果我们用暴力解法,我们需要检查在X[1…m]中的所有子序列是否也出现在Y[1…n]的子序列中,分析该算法的复杂度:
- 检查一个子序列是否出现在Y[1…n]中的算法复杂度是 O ( n ) O(n) O(n)
- X[1…m]的所有子序列有 2 m 2^m 2m个(可以讲1…m的每个元素都作为一个标识位,出现在子序列中则记作1,不出现则记作0,由此可以得到可能的子序列总数是 2 m 2^m 2m)
在最坏情况下,算法耗时代价为 O ( n . 2 m ) O(n. 2^m) O(n.2m),大家可以看到这个算法复杂度时指数级的,非常的慢。
那有什么办法可已改进这个算法呢?下面我们来看一下一个改进版的算法。
LCS算法改进
为了引出这个改进算法,我们要分几部走,才能更好的理解这个算法。
第一步 我们先放下求子序列的问题,而关注于LCS(X, Y)子序列的长度问题
第二步 通过求子序列长度的算分,衍生得到求子序列本身的算法。
子序列长度问题
首先,我们定义
∣
S
∣
|S|
∣S∣表示序列
S
S
S的长度。
然后,我们的策略是使用递归的方法,先研究X,Y序列的前部子序列
X
[
1...
i
]
,
Y
[
1...
j
]
X[1...i],Y[1...j]
X[1...i],Y[1...j],此处
i
<
m
,
j
<
n
i<m, j<n
i<m,j<n。
定义数组
C
[
i
,
j
]
=
∣
L
C
S
(
X
[
1...
i
]
,
Y
[
1...
j
]
)
∣
C[i,j] = |LCS(X[1...i],Y[1...j])|
C[i,j]=∣LCS(X[1...i],Y[1...j])∣即
C
[
i
,
j
]
C[i,j]
C[i,j]等于
X
[
1...
i
]
,
Y
[
1...
j
]
X[1...i],Y[1...j]
X[1...i],Y[1...j]的最长子序列的长度, 那么
C
[
i
,
j
]
=
L
C
S
(
X
,
Y
)
C[i,j] =LCS(X,Y)
C[i,j]=LCS(X,Y)
那么我们就会可以发现以下的规律,
C
[
i
,
j
]
=
{
C
[
i
−
1
,
j
−
1
]
+
1
当
X
[
i
]
=
Y
[
j
]
m
a
x
{
C
[
i
,
j
−
1
]
,
C
[
i
−
1
,
j
]
}
其他
C[i,j] = { \begin{cases} C[i-1, j-1]+1 &\text{当} X[i]=Y[j]\\ max\lbrace C[i,j-1], C[i-1,j]\rbrace&\text{其他} \end{cases} }
C[i,j]={C[i−1,j−1]+1max{C[i,j−1],C[i−1,j]}当X[i]=Y[j]其他
上面这个式子怎么来的?接下来我就给大家推导以下。
-
先来看 X [ i ] = Y [ j ] X[i]=Y[j] X[i]=Y[j]的情况
我们定义 Z [ 1... k ] Z[1...k] Z[1...k]为 X [ 1... i ] , Y [ 1... j ] X[1...i], Y[1...j] X[1...i],Y[1...j]的最长公共子序列,那么 C [ i , j ] = k C[i,j]=k C[i,j]=k,而且 Z [ k ] = X [ i ] = Y [ j ] Z[k]=X[i]=Y[j] Z[k]=X[i]=Y[j]。
毫无疑问 Z [ 1... k − 1 ] Z[1...k-1] Z[1...k−1]是 X [ 1... i − 1 ] , Y [ 1... j − 1 ] X[1...i-1], Y[1...j-1] X[1...i−1],Y[1...j−1]的公共子序列,但我们还可知道 Z [ 1... k − 1 ] Z[1...k-1] Z[1...k−1]是最长公共子序列。
为什么呢?我们可以用反证法简单推得。
假设 W W W是 X [ 1... i − 1 ] , Y [ 1... j − 1 ] X[1...i-1], Y[1...j-1] X[1...i−1],Y[1...j−1]最长公共子序列,
那么 ∣ W ∣ > k − 1 |W|>k-1 ∣W∣>k−1,
由此可得 W ∣ ∣ Z [ k ] W||Z[k] W∣∣Z[k]必然是 X [ 1... i ] , Y [ 1... j ] X[1...i], Y[1...j] X[1...i],Y[1...j]的公共子序列,且它的长度大于k,这与题设矛盾。
由此可得 Z [ 1... k − 1 ] Z[1...k-1] Z[1...k−1]是 X [ 1... i − 1 ] , Y [ 1... j − 1 ] X[1...i-1], Y[1...j-1] X[1...i−1],Y[1...j−1]的最长公共子序列。
由 C [ i − 1 , j − 1 ] = k − 1 C[i-1,j-1]=k-1 C[i−1,j−1]=k−1可以得到 C [ i , j ] = C [ i − 1 , j − 1 ] + 1 C[i,j]=C[i-1,j-1]+1 C[i,j]=C[i−1,j−1]+1可以得到 -
再来看一下 X [ i ] < > Y [ j ] X[i]<>Y[j] X[i]<>Y[j]的情况
这时, C [ i , j ] C[i,j] C[i,j]就等于 X [ 1... i − 1 ] , Y [ 1... j ] X[1...i-1], Y[1...j] X[1...i−1],Y[1...j]的最长公共子序列和 X [ 1... i ] , Y [ 1... j − 1 ] X[1...i], Y[1...j-1] X[1...i],Y[1...j−1]的最长公共子序列中较大的一个。
因为 X [ i ] < > Y [ j ] X[i]<>Y[j] X[i]<>Y[j],所以 X [ 1... i − 1 ] , Y [ 1... j ] X[1...i-1], Y[1...j] X[1...i−1],Y[1...j]的最长公共子序列 Z ∣ ∣ X [ i ] Z||X[i] Z∣∣X[i]不可能是 Y [ 1... j ] Y[1...j] Y[1...j]的子序列,反之亦然,这种情况下就要比较哪一个子序列更长。
这个其实很好理解吧,在此就不再证明了。
讲到这里,我们就可以引出动态规划的第一个标志
动态规划#1标志
优化的子结构: 对于一个问题或实例的优化的解决方案要包括对其子问题的优化解决方案。
对于最长子序列问题(LCS)而言就是如果Z是X,Y的最长公共子序列,则Z的前缀子序列也是X的某一个前缀子序列和Y的某一个前缀子序列的最长公共子序列。
前缀子序列具体来说就是这个子序列必须从其父序列的第一个元素开始,其形式只能是 Z [ 1... l ] , l < ∣ Z ∣ Z[1...l],l<|Z| Z[1...l],l<∣Z∣,
那么对于最长公共子序列(LCS)问题的递归算法如下:
LCS(x,y,i,j)
if x[i] = y[j]
then c[i,j] <- LCS(x,y,i-1,j-1)+1
else c[i,j] <- max{LCS(x,y,i,j-1),LCS(x,y,i-1,j)}
return c[i,j]
如果使用上面算法,在最坏情况下算法复杂对会是什么呢?
最坏情况,应该是每次我们都走else分支,因为else分支每次都要递归两次分别是LCS(x,y,i,j-1)和LCS(x,y,i-1,j),进入else的条件是x[i] 始终都不等于y[j]。我们来看一下,这棵最坏情况下的递归树是什么样的。
假设m=7, n=6:
这棵递归树的总高度是m+n, 因而在最坏情况下递归的代价是指数级的,这显然不是一个令人满意的结果。如何改善呢?
我们仔细观察这棵递归树就会发现他其实包含了很多一样的子树,如图中红色框内的两棵子树就是完全一模一样的,这也意味着在递归过程中存在很多重复劳动。
至此,我们引导词动态规划的另外一个重要标志。
动态规划#2标志
** 重叠的子问题:**一个递归算法包含很少数量的不同的子问题,并这些子问题重复出现很多次。
在最长公共子序列(LCS)的子问题空间(把这些子问题看成一个集合)中包含了m-n个不同的子问题,我们可以采用备忘录算法来避免重复运算,即把已经解决的子问题的结果存储下来,下次再遇到时直接把结果拿出来即可。
LCS(x,y,i,j)
if c[i,j] = nil
then if x[i] = y[j]
then c[i,j] <- LCS(x,y,i-1,j-1)+1
else c[i,j] <- max{LCS(x,y,i,j-1),LCS(x,y,i-1,j)}
return c[i,j]
可以看到,这个备忘录算法其实仅比原来的算法多了一行代码,即判断c[i,j]是否已存在,如果已存在则直接返回值,无需运算。
这个算法的时间复杂度是
Θ
(
m
n
)
\Theta(mn)
Θ(mn),可以看作一个静态的工作量,而其需要的空间复杂度也是
Θ
(
m
n
)
\Theta(mn)
Θ(mn),即c[m,n]的数组。
此次,关于最长公共子序列的几个关键问题都已经解决了,接下来我们通过一个实例来展示整个算法如果运作。
-
第一步,先用下式来填写c[m,n]表,得到每个单元格的结果
C [ i , j ] = { C [ i − 1 , j − 1 ] + 1 当 X [ i ] = Y [ j ] m a x { C [ i , j − 1 ] , C [ i − 1 , j ] } 其他 C[i,j] = { \begin{cases} C[i-1, j-1]+1 &\text{当} X[i]=Y[j]\\ max\lbrace C[i,j-1], C[i-1,j]\rbrace&\text{其他} \end{cases} } C[i,j]={C[i−1,j−1]+1max{C[i,j−1],C[i−1,j]}当X[i]=Y[j]其他
实际计算时我们不用递归直接顺序计算每一个单元格的结果,首先将c[0,j]和c[i,0](即第一行和第一列)全部填0。
计算c[1,1]时X[1]=A, Y[1]=B, 不相等,则c[1,1]=max{c[1,0],c[0,1]} = 0。
计算c[2,1]时X[2]=B, Y[1]=B, 相等,则c[2,1]=c[0,0]+1 = 1,且每次遇到相等时,添加一个蓝色箭头标识 c[i-1, j-1]的位置。
以此类推,全部填完层c[i,j]矩阵。
-
第二步,填完后我们通过一个反向重构算法来推得该子序列,具体的直观做法如下。
从最后一行中找到最后一个有蓝色箭头的值层c[6,6]=4,用红色圈圈标识。我们反向追踪它的源头,即蓝色箭头的另一边的值c[5,5]=3,也用红色圈圈标识。继续最终我们发现这个3的来源是在比较c[5,4]=3和c[4,5]=2时取了较大值c[5,4],故我们将c[5,4]也用红色圈圈标识。以此类推,圈出所有可以追踪到的值,然后在这一系列被圈出来的值中,如果有蓝色箭头指向的值,将其所在行列的元素都用()标识,这些被标识的元素就是所求的最长公共子序列。
最后,这个算法可以进一步的优化,即只计算蓝色箭头标识的那一部分数值,具体实现法留给大家去思考。