动态规划(Dynamic Programming,简称DP),是大家都觉得比较难以掌握的算法。为了应付面试,我们经常会背诵一下DP问题的源码,其实,只要理解了思想,掌握基本的模型,然后再来点写代码的套路,动态规划并没有那么难。
先来回顾一下题型目录。
动态规划基本题型总结提纲目录
1.硬币找零,路径规划
2.字符串相似度/编辑距离(edit distance)
3.最长公共子序列(Longest Common Subsequence,lcs)
4.最长递增子序列(Longest Increasing Subsequence,lis)
5.最大子序列积
6.矩阵链乘法
7.0-1背包问题
8.有代价的最短路径
9.瓷砖覆盖(状态压缩DP)
10.工作量划分
11.三路取苹果
上篇文章讲到第五个题型,最大子序列积,这篇从第六个题型开始。
6.矩阵链乘法
先看矩阵乘法
从定义可以看出:只有当矩阵A的列数与矩阵B的行数相等时A×B才有意义。一个m×r的矩阵A左乘一个r×n的矩阵B,会得到一个m×n的矩阵C。在计算机中,一个矩阵说穿了就是一个二维数组。一个m行r列的矩阵可以乘以一个r行n列的矩阵,得到的结果是一个m行n列的矩阵,其中的第i行第j列位置上的数等于前一个矩阵第i行上的r个数与后一个矩阵第j列上的r个数对应相乘后所有r个乘积的和。
所谓矩阵链乘法是指当一些矩阵相乘时,如何加括号来改变乘法顺序从而来降低乘法次数。例如有三个矩阵连乘:A1*A2*A3,其维数分别为:10*100,100*5,5*50.如果按照((A1*A2)A3)来计算的话,求(A1*A2)要10*100*5=5000次乘法,再乘以A3需要10*5*50=2500次乘法,因此总共需要7500次乘法。如果按照(A1(A2*A3))来计算的话,求(A2*A3)要100*5*50=25000次乘法,再乘以A1需要10*100*50=50000次乘法,因此总共需要75000次乘法。可见,按不同的顺序计算,代价相差很大。
比如对于这个M₁M₂M₃的矩阵链,
我们可以先计算M₁M₂然后结果乘以M₃,也可以M₂M₃先算,然后乘以M₁,为了表达方便,可以用括号表示计算顺序。 矩阵链M₁M₂M₃有两种计算顺序:((M₁M₂)M₃)和(M₁(M₂M₃))。 那么不同计算顺序有什么区别? 对于((M₁M₂)M₃):
对于(M₁(M₂M₃)):
我们要做的就是找到让乘法运算最少的计算顺序,换言之就是找一种加括号方式,使得最后乘法运算最少。
矩阵链乘法问题可以表述如下:给定n个矩阵构成的一个链(A1*A2*A3……*An),其中i=1,2,……n,矩阵Ai的维数为p(i-1)*p(i),对于乘积A1*A2*A3……*An以一种最小化标量乘法次数的方式进行加括号。
这个问题太难理解了。
为了代入问题,先来朴素算法,这个总能看懂吧。
void mult(int a[MAXN][MAXN],int b[MAXN][MAXN],int c[MAXN][MAXN],int p,int q,int r)
{ int i,j,k; //先对c进行初始化 for(i=0;i<p;i++) { for(j=0;j<r;j++) { c[i][j] = 0; } } //计算矩阵乘法 for(i=0;i<p;i++) { for(j=0;j<r;j++) { for(k=0;k<q;k++) { c[i][j] += a[i][k] * b[k][j]; } } }
}
解决这个问题,我们可以用穷举法,但是n很大时,这不是个好方法,其时间复杂度为指数形式。拿上面的例子来说,加括号后把矩阵链分成了两部分,计算代价为两者代价的和。因此假设这种方法的代价最少,则两个部分的代价也是最小的,如果不是最小的,那么这种方法就不是最优的,因此矩阵链乘法具有最优子结构。因此我们可以利用子问题的最优解来构造原问题的一个最优解。所以,可以把问题分割为两个子问题(A1*A2*A3……*Ak和A(k+1)*A(k+2)*A(k+3)……*An),需找子问题的最优解,然后合并这些问题的最优解。从下面的程序可以看出,其时间复杂度为n*n*n.
题目分析:
递推关系:
设计算A[i:j],1≤i≤j≤n,所需要的最少数乘次数m[i,j],则原问题的最优值为m[1,n]。
当i=j时,A[i:j]=Ai,因此,m[i][i]=0,i=1,2,…,n
当i<j时,若A[i:j]的最优次序在Ak和Ak+1之间断开,i<=k<j,则:m[i][j]=m[i][k]+m[k+1][j]+pi-1pkpj。由于在计算是并不知道断开点k的位置,所以k还未定。不过k的位置只有j-i个可能。因此,k是这j-i个位置使计算量达到最小的那个位置。
综上,有递推关系如下:
构造最优解:
若将对应m[i][j]的断开位置k记为s[i][j],在计算出最优值m[i][j]后,可递归地由s[i][j]构造出相应的最优解。s[i][j]中的数表明,计算矩阵链A[i:j]的最佳方式应在矩阵Ak和Ak+1之间断开,即最优的加括号方式应为(A[i:k])(A[k+1:j)。因此,从s[1][n]记录的信息可知计算A[1:n]的最优加括号方式为(A[1:s[1][n]])(A[s[1][n]+1:n]),进一步递推,A[1:s[1][n]]的最优加括号方式为(A[1:s[1][s[1][n]]])(A[s[1][s[1][n]]+1:s[1][s[1][n]]])。同理可以确定A[s[1][n]+1:n]的最优加括号方式在s[s[1][n]+1][n]处断开…照此递推下去,最终可以确定A[1:n]的最优完全加括号方式,及构造出问题的一个最优解。
动态规划迭代实现
用动态规划迭代方式解决此问题,可依据其递归式自底向上的方式进行计算。在计算过程中,保存已解决的子问题的答案。每个子问题只计算一次,而在后面需要时只需简单检查一下,从而避免了大量的重复计算,最终得到多项式时间的算法。
代码如下:
//3d1-2 矩阵连乘 动态规划迭代实现
//A1 30*35 A2 35*15 A3 15*5 A4 5*10 A5 10*20 A6 20*25
//p[0-6]={30,35,15,5,10,20,25}
#include "stdafx.h"
#include <iostream>
using namespace std; const int L = 7; int MatrixChain(int n,int **m,int **s,int *p);
void Traceback(int i,int j,int **s);//构造最优解 int main()
{ int p[L]={30,35,15,5,10,20,25}; int **s = new int *[L]; int **m = new int *[L]; for(int i=0;i<L;i++) { s[i] = new int[L]; m[i] = new int[L]; } cout<<"矩阵的最少计算次数为:"<<MatrixChain(6,m,s,p)<<endl; cout<<"矩阵最优计算次序为:"<<endl; Traceback(1,6,s); return 0;
} int MatrixChain(int n,int **m,int **s,int *p)
{ for(int i=1; i<=n; i++) { m[i][i] = 0; } for(int r=2; r<=n; r++) //r为当前计算的链长(子问题规模) { for(int i=1; i<=n-r+1; i++)//n-r+1为最后一个r链的前边界 { int j = i+r-1;//计算前边界为r,链长为r的链的后边界 m[i][j] = m[i+1][j] + p[i-1]*p[i]*p[j];//将链ij划分为A(i) * ( A[i+1:j] ) s[i][j] = i; for(int k=i+1; k<j; k++) { //将链ij划分为( A[i:k] )* (A[k+1:j]) int t = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j]; if(t<m[i][j]) { m[i][j] = t; s[i][j] = k; } } } } return m[1][L-1];
} void Traceback(int i,int j,int **s)
{ if(i==j) return; Traceback(i,s[i][j],s); Traceback(s[i][j]+1,j,s); cout<<"Multiply A"<<i<<","<<s[i][j]; cout<<" and A"<<(s[i][j]+1)<<","<<j<<endl;
}
7.0-1背包问题
题目描述:
有n个物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?
为方便讲解和理解,下面讲述的例子均先用具体的数字代入,即:eg:number=4,capacity=8
有n个物品,每个物品有两个属性:一个是体积,一个是价值 ,可以表示为:(w1,v1),(w1,v1),…,(w1,vn) {(w_1,v_1 ),(w_1,v_1 ),…,(w_1,v_n )} )。同时我们还有一背包,背包的容量用W表示。现在我们将物品放入背包,放入的物品体积的总和不得超过背包的体积。
问:这个背包能装下的最大价值。
题目分析:
①、将原问题分解为子问题(子问题和原问题形式相同,且子问题解求出就会被保存);
②、确定状态:01背包中一个状态就是NN个物体中第ii个是否放入体积为VV背包中;
③、确定一些初始状态(边界状态)的值;
④、确定状态转移方程,如何从一个或多个已知状态求出另一个未知状态的值。(递推型)
思路:
①、确认子问题和状态
01背包问题需要求解的就是,为了体积V的背包中物体总价值最大化,NN件物品中第ii件应该放入背包中吗?(其中每个物品最多只能放一件)
为此,我们定义一个二维数组,其中每个元素代表一个状态,即前ii个物体中若干个放入体积为VV背包中最大价值。数组为:f[N][V]f[N][V],其中fijfij表示前ii件中若干个物品放入体积为jj的背包中的最大价值。
②、初始状态
初始状态为f[0][0−V]f[0][0−V]和f[0−N][0]f[0−N][0]都为0,前者表示前0个物品(也就是空物品)无论装入多大的包中总价值都为0,后者表示体积为0的背包啥价值的物品都装不进去。
③、转移函数
if (背包体积j小于物品i的体积)f[i][j] = f[i-1][j] //背包装不下第i个物体,目前只能靠前i-1个物体装包
elsef[i][j] = max(f[i-1][j], f[i-1][j-Vi] + Wi)
最后一句的意思就是根据“为了体积V的背包中物体总价值最大化,NN件物品中第ii件应该放入背包中吗?”转化而来的。ViVi表示第ii件物体的体积,WiWi表示第ii件物品的价值。这样f[i-1][j]代表的就是不将这件物品放入背包,而f[i-1][j-Vi] + Wi则是代表将第i件放入背包之后的总价值,比较两者的价值,得出最大的价值存入现在的背包之中。
01背包问题其实就可以化简为涂写下面的表格,其中每个数对应数组nArr中每个元素,初始化部分为0,然后从左上角按行求解,一直求解到右下角获取最终解nArr[5][12]。
代码如下:
#include<iostream>
using namespace std;int main()
{int nArr[6][13] = {{0}};int nCost[6] = {0 , 2 , 5 , 3 , 10 , 4}; //花费int nVol[6] = {0 , 1 , 3 , 2 , 6 , 2}; //物体体积int bagV = 12;for( int i = 1; i< sizeof(nCost)/sizeof(int); i++){for( int j = 1; j<=bagV; j++){if(j<nVol[i])nArr[i][j] = nArr[i-1][j];elsenArr[i][j] = max(nArr[i-1][j] , nArr[i-1][j-nVol[i]] + nCost[i]); cout<<nArr[i][j]<<' ';} cout<<endl;}cout<<nArr[5][12]<<endl;return 0;
}
再来看另外一个课件上的解释:
背包问题是动态规划里面比较出名的题型,其变种题目很多,由于篇幅有限就不一一展开了,下面只列出背包问题的题型目录。
一、01背包问题
这是最基本的背包问题,每个物品最多只能放一次。
二、完全背包问题
第二个基本的背包问题模型,每种物品可以放无限多次。
三、 多重背包问题
每种物品有一个固定的次数上限。
四、 混合三种背包问题
将前面三种简单的问题叠加成较复杂的问题。
五、二维费用的背包问题
一个简单的常见扩展。
六、分组的背包问题
一种题目类型,也是一个有用的模型。后两节的基础。
七、 有依赖的背包问题
另一种给物品的选取加上限制的方法。
八 、泛化物品
九、 背包问题问法的变化
试图触类旁通、举一反三。
8.有代价的最短路径
从某顶点出发,沿图的边到达另一顶点所经过的路径中,各边上权值之和最小的一条路径叫做最短路径。解决最短路的问题有以下算法,Dijkstra算法,Bellman-Ford算法,Floyd算法和SPFA算法等。
现在只讨论Dijkstra算法。
1。指定一个节点,例如我们要计算 'A' 到其他节点的最短路径
2.引入两个集合(S、U),S集合包含已求出的最短路径的点(以及相应的最短长度),U集合包含未求出最短路径的点(以及A到该点的路径,注意 如上图所示,A->C由于没有直接相连 初始时为∞)
3.初始化两个集合,S集合初始时 只有当前要计算的节点,A->A = 0,
U集合初始时为 A->B = 4, A->C = ∞, A->D = 2, A->E = ∞,敲黑板!!!接下来要进行核心两步骤了
4.从U集合中找出路径最短的点,加入S集合,例如 A->D = 2
5.更新U集合路径,if ( 'D 到 B,C,E 的距离' + 'AD 距离' < 'A 到 B,C,E 的距离' ) 则更新U
循环执行 4、5 两步骤,直至遍历结束,得到A 到其他节点的最短路径
比如现在求A到E的最短加权路径,路径上的数值为权重。
1.选定A节点并初始化,如上述步骤3所示
2.执行上述 4、5两步骤,找出U集合中路径最短的节点D 加入S集合,并根据条件if ( 'D 到 B,C,E 的距离' + 'AD 距离' < 'A 到 B,C,E 的距离' )
来更新U集合。
3.这时候A->B, A->C
都为3,没关系。其实这时候他俩都是最短距离,如果从算法逻辑来讲的话,会先取到B点。而这个时候 if 条件变成了if ( 'B 到 C,E 的距离' + 'AB 距离' < 'A 到 C,E 的距离' )
,如图所示这时候A->B
距离 其实为A->D->B
4.思路就是这样,往后就是大同小异了
5.算法结束
大致步骤:
清除所有点的标号
全部d[i] = INF
然后将图信息权值复制到d中
循环n次{在所有为标号的结点中,选出d值最小的结点x给x标记对于从x出发的所有边(x,y),更新d[y] = min{d[y],d[y]+w(x,y)}
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 500;
const int INF = 0x3f3f3f3f;
int n,m,dis[maxn],Map[maxn][maxn],v[maxn];
void dijkstra(int u)
{memset(v,0,sizeof v);memset(dis,INF,sizeof dis);v[u] = 1;for(int i = 1; i <= n; i++)dis[i] = min(dis[i],Map[u][i]);for(int i = 1; i <= n; i++){int x,m = INF;for(int y = 1; y <= n; y++)if(!v[y] && dis[y] <= m)m = dis[x = y];v[x] = 1;for(int y = 1; y <= n; y++)dis[y] = min(dis[y],dis[x]+Map[x][y] );}
}
int main()
{int a,u,v,w;scanf("%d%d%d",&n,&m,&a);memset(Map, INF, sizeof Map);for(int i = 0; i < m; i++){scanf("%d%d%d",&u,&v,&w);Map[u][v] = w;Map[v][u] = w; //该图为无向图,若为有向图则需要少建立一个边信息}for(int i = 1; i <= n; i++)Map[i][i] = 0;dijkstra(a);for(int i = 1; i <= n; i++)cout<<dis[i]<<" ";return 0;
}
动态规划还有最后几个题型,下篇会都说完。再后面我会分享贪心算法、回溯算法、分治算法,排序算法。欢迎一起学习。