动态规划——Dynamic programming,可以说是本人一直没有啃下的骨头,这次我就得好好来学学Dynamic programming.
OK,出发!
动态规划通常是分治算法的一种特殊情况,它一般用于最优化问题,如果这些问题能够:
1.能够分解为规模更小的子问题
2.递归的子问题具有最优子结构性质。也就是说,原问题的最优化解能够通过子问题的解计算得到。
动态规划的一个核心的步骤就是定义一个合适的问题形式,动态规划与分治算法不一样,动态规划算法通常需要枚举所有的可能的切分策略,这是因为需要得到最优解;除此之外,动态规划通过“记录已计算子问题的解”来避免重复计算。
为了使用动态规划的方法,那么我们首先得先看问题能够使用分治的方法来解决。给定一个问题,我们需要先看看他的核心输入数据结构是否可以分解。如果发现输入的核心数据结构为:
- 数组
- 矩阵
- 集合
- 树
- 图
那么这些输入是很容易分解的。
我们再看,子问题的输出能够构成原问题的输出。
如果上面两个条件都满足,那么这个问题当然是可以使用分治法来解决的。
下面,我们通过几个经典的问题来看看如何由分治过渡到动态规划。
矩阵连乘问题
INPUT:
给定: A 1 , A 2 , … , A n A_{1},A_{2},\dots,A_{n} A1,A2,…,Ann个矩阵,矩阵 A i A_{i} Ai的形状为 p i − 1 × p i p_{i-1}\times p_{i} pi−1×pi
OUTPUT:
给出一个 A 1 , A 2 , … , A n A_{1},A_{2},\dots,A_{n} A1,A2,…,An的运算顺序,使得乘法的次数最少。
先看看为啥不同运算顺序,会造成乘法的次数不一样呢?
假设有三个矩阵:
A 1 的 形 状 为 1 × 2 , A 2 的 形 状 为 2 × 3 , A 3 形 状 为 3 × 4 A_{1}的形状为1 \times 2,A_{2}的形状为2\times 3,A_{3}形状为3 \times 4 A1的形状为1×2,A2的形状为2×3,A3形状为3×4
那么第一种运算顺序: ( A 1 A 2 ) A 3 (A_{1}A_{2})A_{3} (A1A2)A3,需要的乘法次数为:1x2x3+1x3x4=18
第二种运算顺序: A 1 ( A 2 A 3 ) A_{1}(A_{2}A_{3}) A1(A2A3),需要的乘法次数为2x3x4+1x2x4=32。
那么第一种运算次数更少,因此第一种运算顺序更好。
当直接给n个矩阵的时候,我们是不好下手的,也没有啥头绪。那我们就想,
我们就先看n=1,2,3,4的时候嘛。
当n=1的时候,不要乘了。
当只有2个矩阵相乘的时候,没有什么最优的,就只有一种运算顺序,我们看到会做的;
当只有3个矩阵相乘的时候,我们也会做的,就是只要像上面的例子一样去比较就是了的。
当有四个矩阵的时候呢?能不能划为先三个矩阵相乘再两个矩阵相乘?或者左边两个矩阵,右边两个矩阵,两个中间结果再乘起来?因为,我们会做2个和3个矩阵相乘了。
当有5个矩阵呢?
我们看到,当n很小的时候,我们是会做的。那么给定一个很大的n,我们能不能把它转换为规模更小的n呢?
假设我们把这个问题看做一个多阶段决策问题,每次的决策就是选一个位置加一个括号。
OK,假设我们已经得到最优的解了。那么这个最优解的最后一次乘法,一定可以看成是两个部分的乘积。也就是说从:
A 1 A 2 … A k A k + 1 … A n A_{1}A_{2} \dots A_{k} A_{k+1} \dots A_{n} A1A2…AkAk+1…An
选择一个k,使得最终的结果是从:
( A 1 A 2 … A k ) ( A k + 1 … A n ) (A_{1}A_{2} \dots A_{k})( A_{k+1} \dots A_{n}) (A1A2…Ak)(Ak+1…An)
两部分乘积得到。
这下子好了,左边的连乘就是 A 1 A 2 … A k A_{1}A_{2} \dots A_{k} A1A2…Ak,这个问题和原问题很像啊,就是规模改了一下,内容不一样而已。右边也是类似的。
然后,我们先解决 A 1 A 2 … A k A_{1}A_{2} \dots A_{k} A1A2…Ak,显然这个问题,也可以看成最后由两个矩阵乘积得到,也就是要选择一个j使得: ( A 1 A 2 … A j ) ( A j + 1 … A k ) (A_{1}A_{2} \dots A_{j})( A_{j+1}\dots A_{k}) (A1A2…Aj)(Aj+1…Ak)
等等···
经过若干次的分析以后,我们就开始看到我们所要解决问题的通用形式了:寻找某个运行顺序,使得 A i A i + 1 … A j A_{i}A_{i+1} \dots A_{j} AiAi+1…Aj的乘法次数最少。定义 O P T ( i , j ) OPT(i,j) OPT(i,j)表示 A i A i + 1 … A j A_{i}A_{i+1} \dots A_{j} AiAi+1…Aj所需的最少的乘法次数。原问题其实就是在求 O P T ( 1 , n ) OPT(1,n) OPT(1,n)
而将原问题分解为两个子问题: O P T ( 1 , k ) OPT(1,k) OPT(1,k)和 O P T ( k , n ) OPT(k,n) OPT(k,n),那么原问题的解可以通过 O P T ( 1 , n ) = O P T ( 1 , k ) + O P T ( k , n ) + p 0 × p k × p n OPT(1,n)=OPT(1,k)+OPT(k,n)+p_{0}\times p_{k} \times p_{n} OPT(1,n)=OPT(1,k)+OPT(k,n)+p0×pk×pn得到。
这是因为左边 A 1 A 2 … A k A_{1}A_{2} \dots A_{k} A1A2…Ak得到一个 p 0 × p k p_{0}\times p_{k} p0×pk的矩阵,而右边 A k + 1 A k + 2 … A n A_{k+1}A_{k+2}\dots A_{n} Ak+1Ak+2…An是一个 p k × p n p_{k} \times p_{n} pk×pn的矩阵。左右两个矩阵相乘需要的乘法次数就是 p 0 × p k × p n p_{0}\times p_{k} \times p_{n} p0×pk×pn
到目前为止,so far so good! 然鹅!第一阶段里面的k
( A 1 A 2 … A k ) ( A k + 1 … A n ) (A_{1}A_{2} \dots A_{k})( A_{k+1} \dots A_{n}) (A1A2…Ak)(Ak+1…An)
k到底是谁啊?我们其实是不知道的,那怎么办呢?
那就没办法了,只有枚举所有的可能,即:k=1试一下,k=2试一下,k=3试一下,一共有n个可能。然后选其中使得乘法次数最少的k。
即:
O P T ( 1 , n ) = m i n ( O P T ( 1 , k ) + O P T ( k , n ) + p 0 p k p n ∣ k = 1 , 2 , 3 , . . . , n ) OPT(1,n)=min(OPT(1,k)+OPT(k,n)+p_{0}p_{k}p_{n}|k=1,2,3,…,n) OPT(1,n)=min(OPT(1,k)+OPT(k,n)+p0pkpn∣k=1,2,3,...,n)
于是,我们很快写出这个算法的伪代码:
find_min(i,j)
1. if i=j then
2. return 0 //只有一个矩阵
3. endif
4. if i=j-1 then
5. return p[i-1]p[i]p[i+1] //两个矩阵
6. endif
7. min=p[i-1]p[i]p[j] //随便设置一个初始的最小值
8. for k=i to j do
9. tmp = find_min(i,k)+find_min(k+1,j)+p[i-1] * p[k] *p[j]
10. if tmp < min then
11. min=tmp
12. endif
13. endfor
14. return min
显然,这段代码是可以工作的!但是这段代码的复杂度是很高的哇!这里复杂度是 O ( 2 n ) O(2^{n}) O(2n)
为什么呢?这主要因为里面有很多重复计算的过程。
例如,对于 A 1 A 2 A 3 A 4 A 5 A_{1}A_{2}A_{3}A_{4}A_{5} A1A2A3A4A5,当我们把k分别选择为2,3的时候。
K=2的情况:
左边: A 1 A 2 A_{1}A_{2} A1A2,右边: A 3 A 4 A 5 A_{3}A_{4}A_{5} A3A4A5。
假设,我们已经求解出来K=2的情况的乘法次数。
K=3的情况:
左边: A 1 A 2 A 3 A_{1}A_{2}A_{3} A1A2A3,右边: A 4 A 5 A_{4}A_{5} A4A5
K=3的时候,在解决左边的子问题的时候,必然会遇到求解 A 1 A 2 A_{1}A_{2} A1A2的问题,而我们的算法不管之前计算得到的结果,又会再计算一次。从而造成复杂度过高。如果我们能够有一个表来记录中间结果就好啦!
改进版本2:
find_min_beta(i,j)
1. if table[i,j] != null then
2. return table[i,j]
3. if i=j then
4. table[i,j]=0
5. return table[i,j]
6. end if
7. if i=j-1 then
8. table[i,j]=p[i-1]p[i]p[i+1]
9. return table[i,j]
10. end if
11. min=p[i-1]p[i]p[j]
12. for k=i to j do
13. tmp = find_min_beta(i,k)+find_min_beta(k+1,j)+p[i-1]p[k]p[j]
14. if tmp < min then
15. min=tmp
16. endif
17. endfor
18. return min
这样子就可以解决重复计算的问题。
但一个原因就是,每次分解问题的规模只减少1,可以预想find_min(1,n)至少到递归n层。当n比较大的时候,函数栈就会花很多空间来维护栈结构,这也是很浪费内存的事情。为此,我们可以将递归转换为非递归。
改进版本3:
find_min_alpha(1,n)
1. for i=1 to n do
2. table[i,i]=0
3. end for
4. for j=2 to n do
5. for i=1 to n-j do
6. min=p[i-1]p[i]p[i+1]
7. for k=i to i+j do
8. tmp =table[i,k]+table[k+1,i+j] +p[i-1]p[k]p[i+j]
9. if tmp<min then
10. min=tmp
11. end if
12. end for
13. table[i,i+j]=min
14. end for
15. end for
16. return table[1,n]
此时,该算法的复杂度就是 O ( n 3 ) O(n^{3}) O(n3)!我们可以看到,不同的实现时间复杂度会很有很大的不同!