References
- 《算法导论》
最近在回顾算法的知识,特将一些动态规划的重点记录下来,好让以后自己不要忘记。
基本概念
动态规划(Dynamic Programming,简称为DP,下面都将使用DP来代替)是一种将大问题分解成小问题,然后将小问题的解决方案合并成大问题的解决方案的一种算法。他的思想有点类似于分而治之(Divide-and-Conquer,在递归解法里用的最多的一种思想)。
与分而治之所不同的是,DP使用的是一种自底向上、使用表来记录计算过程,每个子问题都只计算一次,从而避免了多余重复的计算子问题的复杂度。
DP一般被用来解决优化问题(Optimization Problem)。在优化问题里,一般对于问题都会有多个解法,而其中会有一些最优解,我们需要从这些解里面去找出最优解。
DP和其他的一些算法所不同的是,他并没有固定的复杂度,最核心的主要是问题的特征和对于问题的建模过程。对于不同的问题,DP解法会有不同的算法复杂度。
问题特征
能够使用DP解决的问题都具有下面的特征:
- 问题能够分解成子问题,而这些子问题具有和原问题相同的结构。通常来说,因为DP都是解决最优解问题,所以一般来说这个最优解都可以分解成一些更小的最优解。一直分解下去,直到最基本的结构,这些结构通常都是我们问题里面本身已经明确说明了的。
- 子问题之间有重叠。这个是我们利用DP来解决这种问题的最关键特征,因为DP相对于传统的穷举或者递归解法,对于每个子问题会只解一次,所以在子问题有重复的时候,利用DP可以极大的减少复杂度。
举个比较典型的例子(不过不是优化问题),就是求第n个Fibonacci数。用fib(n)来表示第n个Fibonacci数,那么如果用递归的方式求的话,就是下面的代码(C++):
如果用自底向上的迭代思路解决,那就是下面的样子(C++):
而如果使用DP的思路来解决,就是下面的样子(C++):
可以看到这里DP具有一定的空间换时间的味道,不过当然我们可以通过动态内存分配解决这个问题。
当然Fibonacci数列这个例子本身不是很典型,不过他展现了一些自底向上的解决方案对于重叠的子问题有很大的优势。
经典问题
在《算法导论》里面,讲到了几个DP的经典问题:
- 生产线调度问题
- 矩阵链相乘问题
- 两个字符串的最长子串问题(Longest Common Subsequence, LCS)
下面主要说明一下各个问题的主要描述和DP的思路
生产线调度问题
生产一个部件需要N个步骤,而在车间里面共有两条生产线生产这个部件,每个部件都可以再两个条线上进行生产。
当部件进行第k道工序的时候,需要T(i, k)的时间,其中i取1或2,表示是第一还是第二条生产线。
同时,当进行完第k道工序之后,可以切换到另一条生产线进行下一道工序,不过需要切换时间,是C(i, k),其中当i等于2的时候,表示从1切换到2;当i等于1的时候,表示从2切换到1。
当工序开始的时候,放在1线或2线也需要时间,用e1 和e2 表示。
当工序完成的时候,从1线或2线取下来也是需要时间的,用x1 和x2 表示。
有了上面的问题定义,我们考虑一下怎么解这个优化问题。
首先定义f1 (j) ,他表示在1线上完成第j道工序的最快时间。
当j = 1的时候,f1 (j) 只有一个取值就是e1 + T(1, 1)。
当j = 2, 3, ..., n的时候,f1 (j) 什么的取值分为下面两种情况:
- f1 (j) = f1 (j - 1) + T(1, j - 1) ,这个时候完成第j道工序的时间就是在同一条生产线里面完成上一道工序的最快时间 加上当前这个工序的所需用时。
- f1 (j) = f2 (j - 1) + C(1, j - 1) + T(1, j - 1), 这个时候最快时间就是从第2道工序完成的最快时间 加上转换时间,再加上当前工序的所需用时。
注意上面的两个子问题,都是最快时间,因为如果f1 (j - 1) 不是最快用时的话,那么就存在一个更快的完成j - 1工序的时间,和f的定义矛盾。
这里可以看到最快时间问题可以分解成一个和自身结构相同的子问题,这样就满足了DP的第一个条件。
由此我们定义问题的解为f* = min(f1 (n), f2 (n))
而f1 (j) =
- e1 + T(1, 1), 当j = 1时。
- min(f1 (j - 1) + T(1, j - 1), f2 (j - 1) + C(1, j - 1) + T(1, j - 1) ), 当j = 2, 3, ..., n时。
由此我们如果尝试用递归来写这个程序的话(自顶向下),可以看到如果用r1 (j)来表示f1 (j)的计算次数的话,那么我们从上面的分析得到下面的等式:
r1 (j) = r2 (j) = r1 (j + 1) + r2 (j + 1)
可以证明r1 (1) = 2n - 1 ,也就是所有f的计算时间需要O(2n )。这也同时表明子问题的重叠计算,也就满足了DP的第二个性质。
所以是用递归计算的话过程将会随着n的增大而变得非常慢。
如果我们利用DP的自底向上的思想的话,也就是从f1 (1), f2 (1) 开始计算,迭代到f*来完成的话,那么算法的复杂度则是O(n)。
算法的伪代码如下:
2 f1[1] = e1 + T(1, 1)
3 f2[1] = e2 + T(2, 1)
4 for j in 2 to n
5 do if f1[j - 1] + T(1, j) <= f2[j - 1] + C(1, j - 1) + T(1, j)
6 then f1[j] = f1[j - 1] + T(1, j)
7 l1[j] = 1
8 else f1[j] = f2[j - 1] + C(1, j - 1) + T(1, j)
9 l1[j] = 2
10 if f2[j - 1] + T(2, j) <= f1[j - 1] + C(2, j - 1) + T(2, j)
11 then f2[j] = f2[j - 1] + T(2, j)
12 l2[j] = 2
13 else f2[j] = f1[j - 1] + C(2, j - 1) + T(2, j)
14 l2[j] = 1
15
16 if f1[n] + x1 <= f2[n] + x2
17 then f* = f1[n] + x1
18 l* = 1
19 else f* = f2[n] + x2
20 l* = 2
矩阵链相乘问题
假设有N个矩阵相乘,由矩阵的结合律我们知道A B C = (A B) C = A (B C),所以这一序列的矩阵相乘我们知道有很多种不同的相乘顺序。
如N1 N2 N3 N4 可以是((N1 N2 )(N3 N4 )),或者是((N1 (N2 N3 )) N4 )这样的顺序。
假设A和B是两个可以相乘的矩阵,那么A就是m x n的一个矩阵,而B就是n x p的一个矩阵,那么计算矩阵相乘就需要mnp那么多个乘法运算。
因为这个原因,如果矩阵运算的时候使用不同的顺序来进行矩阵相乘会有不同的乘法次数要求。
举例来说,现在假设有一个矩阵乘法序列A B C,其中A、B和C分别是10 x 100、100 x 5、5 x 50的矩阵。那么((A B) C)需要的乘法次数是(10 x 100 x 5) + (10 x 5 x 50) = 7500;(A (B C))需要的乘法次数则是(100 x 5 x 50) + (10 x 100 x 50) = 75000。所以是用第一个顺序会比是用第二个顺序快10倍。
有了上面,我们可以计算一下如果给定一个有n个矩阵相乘的矩阵序列,那么我们用P(n)来表示这n个矩阵的括号组合(也就是相乘的顺序)。
P(n)的值有下面两种可能:
- P(n) = 1,当n = 1的时候
- P(n) = Sum[1, n-1] P(k) P(n-k),当n >= 2时
由证明可以知道P(n)是以2n 作为下限的,所以如果是用枚举来计算组合的话,时间开销会非常大。
下面我们来考虑一下如果对这个优化问题进行DP解。
我们首先用A(i, j),其中i < j,来表示矩阵序列Ai Ai+1 ... Aj 相乘之后得到的矩阵。
计算A(i, j),我们需要首先将Ai Ai+1 ... Aj 从第k个矩阵处分开(k >= i && k < j)。所以求A(i, j)所需要的乘法次数就是求A(i, k)的次数 + A(k+1, j)的次数 + 这两个矩阵相乘的次数。
假设Ai 是一个pi-1 x pi 的矩阵,那么我们用m[i, j]来表示求A(i, j)需要的最少乘法次数。
有上面的分析可以知道,加入最少的乘法次数的实现是在我们将这个矩阵序列从第k个矩阵处分开的话,那么有
m[i, j] = m[i, k] + m[k+1, j] + pi-1 pk pj
这里可以看出原来的问题可以分解成一个和自身性质相同的子问题,所以这符合DP的第一个性质。
从而我们可以有下面的公式:
- m[i, j] = 0,如果i = j
- m[i, j] = min[i<= k < j] {m[i, k] + m[k+1, j] + pi-1 pk pj },如果i < j
根据这个公式,我们在计算的开始输入一个数组p,里面的元素表示矩阵的维数。那么DP解就有下面的伪代码:
2 n = length[p] - 1
3 for i in 1 to n
4 do m[i, i] = 0
5 for l in 2 to n
6 do for i in 1 to (n - l + 1)
7 do j = i + l - 1
8 m[i, j] = INFINITY
9 for k in i to j - 1
10 do q = m[i, k] + m[k+1, j] + p(i-1)p(k)p(j)
11 if q < m[i, j]
12 then m[i, j] = q
13 s[i, j] = k
14 return m and s
其中s记录的是矩阵划分的顺序。
LCS问题
这个问题在我之前的一篇研究diff算法的博文里面曾经提到过,所以在此就不重复了。