算法设计与分析——动态规划(一)矩阵连乘

动态规划——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} pi1×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 A11×2,A22×3,A33×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} A1A2AkAk+1An
选择一个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}) (A1A2Ak)(Ak+1An)
两部分乘积得到。
这下子好了,左边的连乘就是 A 1 A 2 … A k A_{1}A_{2} \dots A_{k} A1A2Ak,这个问题和原问题很像啊,就是规模改了一下,内容不一样而已。右边也是类似的。
然后,我们先解决 A 1 A 2 … A k A_{1}A_{2} \dots A_{k} A1A2Ak,显然这个问题,也可以看成最后由两个矩阵乘积得到,也就是要选择一个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}) (A1A2Aj)(Aj+1Ak)

等等···
经过若干次的分析以后,我们就开始看到我们所要解决问题的通用形式了:寻找某个运行顺序,使得 A i A i + 1 … A j A_{i}A_{i+1} \dots A_{j} AiAi+1Aj的乘法次数最少。定义 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+1Aj所需的最少的乘法次数。原问题其实就是在求 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} A1A2Ak得到一个 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+2An是一个 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}) (A1A2Ak)(Ak+1An)
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)+p0pkpnk=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)!我们可以看到,不同的实现时间复杂度会很有很大的不同!

    原文作者:动态规划
    原文地址: https://blog.csdn.net/jmh1996/article/details/83050996
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞