什么是动态规划
基本概念
动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。动态规划是一种被广泛用于求解组合最优化问题的算法。
算法思想
算法思想与分治法类似,也是 将待求解的问题分解为若干个子问题 (阶段),按顺序求解子阶段,
前一子问题的解,为后一子问题的求解提供了有用的信息
。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。
与分治法的差别
适合于用动态规划法求解的问题,经分解后得到的子问题往往 不是互相独立 的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。
适用的情况
能采用动态规划求解的问题的一般要具有3个性质:
-
最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
-
无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
-
有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。( 该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势 )
最长公共子序列问题
问题描述
给定两个长度为n和m的字符串A和B,确定A和B中最长公共子序列的长度。
解决思路
-
传统算法 一种传统的方式是使用蛮力搜索的方法,列举A所有的2n个子序列对于每一个子序列子在senta(m)时间内来确定它是否也是B的子序列。该算法的时间复杂性是senta(m2n),是指数复杂性的。
-
动态规划算法 寻找一个求最长公共子序列的递推公式,令A=a_1a_2a_3….a_n和B=b_1b_2b_3…b_m,令L[i,j]表示a_1a_2_3…a_i和b_1b_2b_3…b_j的最长公共子序列的长度,则就有当i和j都大于0的时候,如果a_i=b_j,则L[i,j]=L[i-1,j-1]+1,反之,如果a_i!=b_j,则L[i,j]=max(L[i-1,j],L[i,j-1])所以就有以下递推公式:
L [i,j]=0 i0||j0
L[i,j]=L[i-1,j-1]+1 i,j>0&&a_i==b_j
L[i,j]=max(L[i-1,j],L[i,j-1]) i,j>0&&a_i=b_j
代码实现
输入A和B字符串,返回二者的最长子序列长度
int Lcs(char *A, int n, char *B, int m) {//A[0...n] B[0...m]
int L[n + 1][m + 1];
for (int i = 0; i <= n; ++i) {
L[i][0] = 0;
}
for (int j = 0; j <= m; ++j) {
L[0][j] = 0;
}
for (int k = 1; k <= n; ++k) {
for (int i = 1; i <= m; ++i) {
if (A[k] == B[i])L[k][i] = L[k - 1][i - 1] + 1;
else L[k][i] = L[k][i - 1] > L[k - 1][i] ? L[k][i - 1] : L[k - 1][i];
}
}
return L[n][m];
}
注意,以上算法需要的空间复杂度是senta(mn),但是因为计算表中每一项的计算仅仅需要其上一行和上一列的元素,所以对算法进行改进可以使得空间复杂度降为senta(min(m,n))
(准确来说是需要2min(m,n)的空间,仅仅将前一行和当前行存储下来即可)。
结论
最长公共子序列问题的最优解能够在senta(mn)时间和senta(min(m,n))空间内计算得到。
矩阵链相乘
问题描述
给定一个n个矩阵的序列⟨A1,A2,A3…An⟩,我们要计算他们的乘积:A1A2A3…AnA1A2A3…An,由于矩阵乘法满足结合律,加括号不会影响结果,但是不同的加括号方法,算法复杂度有很大的差别:
考虑矩阵链⟨A1,A2,A3⟩,三个矩阵规模分别为10×100、100×5、5×50:
- 按((A1A2)A3)方式,需要做10∗100∗5=5000次,再与A3相乘,又需要10∗5∗50=2500,共需要7500次运算;
- 按(A1(A2A3))方式计算,共需要100∗5∗50+10∗100∗50=75000次标量乘法
以上两种不同的加括号方式具有10倍的差别,可见一个好的加括号方式,对计算效率有很大影响。
解决思路
使用一个长度为n+1的一维数组p来记录每个矩阵的规模,其中n为矩阵下标,i的范围1~n,例如对于矩阵Ai而言,它的规模应该是p[i-1]×p[i]。由于i是从1到n取值,所以数组p的下标是从0到n。
用于存储最少乘法执行次数和最佳分段方式的结构是两个二维数组m和s,都是从1~n取值。m[i][j]记录矩阵链< Ai,Ai+1,…,Aj>的
最少乘法执行次数 ,而s[i][j]则记录 最优质m[i][j]的分割点k。
需要注意的一点是当i=j时,m[i][j]=m[i][i]=0,因为一个矩阵不需要任何乘法。
假设矩阵链从Ai到Aj,有j-i+1个矩阵,我们从k处分开,将矩阵链分为Ai~Ak和Ak+1到Aj两块,那么我们可以比较容易的给出m[i][j]从k处分隔的公式:
m[i][j]=m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
在一组确定的i和j值的情况下,要使m[i][j]的值最小,我们只要在所有的k取值中(i <=k< j),寻找一个让m[i][j]最小的值即可。
假设L为矩阵链的长度,那么L=j-i+1。当L=1时,只有一个矩阵,不需要计算。那么我们可以从L=2到n进行循环,对每个合理的i和j值的组合,遍历所有k值对应的m[i][j]值,将最小的一个记录下来,存储到m[i][j]中,并将对应的k存储到s[i][j]中,就得到了我们想要的结果。
代码
/*
* 输入:ms[1...n+1],ms[i]表示第i个矩阵的行数,ms[i+1]表示第i个矩阵的列数
* 输出:n个矩阵的数量乘法的最小次数
*/
int dp[1024][1024] = { 0 };
struct Matrix {
int row;
int column;
};
int matrixChainCost(Matrix *ms, int n) {
for (int scale = 2; scale <= n; scale++) {
for (int i = 0; i <= n - scale; i++) {
int j = i + scale - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; k++) {
dp[i][j] = std::min(dp[i][j], dp[i][k] + dp[k+1][j] + (ms[i].row*ms[k].column*ms[j].column));
}
}
}
return dp[0][n - 1];
}
复杂度分析
- 时间复杂度:senta(n^3)
- 空间复杂度:senta(n^2)
所有点对的最短路径问题
问题描述
设G是一个有向图,其中每条边(i, j)都有一个非负的长度L[i, j],若点i 到点j 没有边相连,则设L[i, j] = ∞.
找出每个顶点到其他所有顶点的最短路径所对应的长度。
例如:
则 L: 0 2 9
8 0 6
1 ∞ 0
解决思路(Floyd算法)
Floyd算法(所有点对最短路径)就是每对可以联通的顶点之间总存在一个借助于其他顶点作为媒介而达到路径最短的最短路径值(这个值通过不断增添的媒介顶点而得到更新,也可能不更新——通过媒介的路径并不比其原路径更短),所有的值存储于邻接矩阵中,这是典型的动态规划思想。
值得注意的是,Floyd算法本次的状态的获取 只用到了上个阶段的状态 ,而没有用到其他阶段的状态,这就为 压缩空间 奠定了条件。
Floyd算法能够成功的关键之一就是D0(初始矩阵,即权重矩阵)的初始化,凡是不相连接的边必须其dij必须等于正无穷且dii=0(矩阵对角线上的元素!)
代码实现
/*
* 输入:n×n维矩阵l[1…n,1…n],对于有向图G=({1,2,…n},E)中的边(i,j)的长度为l[i,j]
* 输出:矩阵D,使得D[i,j]等于i到j的距离
* l矩阵需要满足:l[i,i]=0,对于m–>n没有直接连接的有向边(因为是有向图,只考虑单边),应有l[m,n]=INT.MAX(即无穷)
*
*/
void Floyd(int **l,int n){
int **d= reinterpret_cast<int **>(new int[n + 1][n + 1]);
for (int i = 1; i <=n ; ++i) {
for (int j = 1; j <=n ; ++j) {
d[i][j]=l[i][j];
}
}
for (int k = 1; k <=n ; ++k) {
for (int i = 1; i <=n ; ++i) {
for (int j = 1; j <=n ; ++j) {
d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
}
}
}
}
复杂度分析
算法的运行时间是senta(n^3)
算法的空间复杂性是senta(n^2)
背包问题
问题描述
设U={u1,u2,u3…un}是一个准备放入容量为C的背包中的n项物品的集合。我们要做的是从U中拿出若干物品装入背包C,要求这些物品的总体积不超过C,但是要求装入背包的物品总价值最大。
解决思路
有 n 种物品,物品 i 的体积为 v[i], 价值为 p[i]. 假定所有物品的体积和价格都大于 0, 以及背包的体积为 V.
mp[x][y] 表示体积不超过 y 且可选前 x 种物品的情况下的最大总价值
那么原问题可表示为 mp[n][V]。
递归关系:
递归式 | 解释 |
---|---|
mp[0][y] = 0 | 表示体积不超过 y 且可选前 0 种物品的情况下的最大总价值,没有物品可选,所以总价值为 0 |
mp[x][0] = 0 | 表示体积不超过 0 且可选前 x 种物品的情况下的最大总价值,没有物品可选,所以总价值为 0 |
当 v[x] > y 时,mp[x][y] = mp[x-1][y] | 因为 x 这件物品的体积已经超过所能允许的最大体积了,所以肯定不能放这件物品, |
那么只能在前 x-1 件物品里选了
当 v[x] <= y 时,mp[x][y] = max{ mp[x-1][y], p[x] + mp[x-1][y-v[x]] } | x
这件物品可能放入背包也可能不放入背包,所以取前两者的最大值就好了, 这样就将前两种情况都包括进来了
代码
/*
* 输入:物品集合U={u1,u2,u3...un},体积为s1,s2,s3...sn,价值为v1,v2,v3...vn,容量为C的背包
* 输出:满足条件的最大价值
*
*/
int Knapsack(int *s,int *v,int C,int n){
int V[n+1][C+1];//V[i][j]表示从前i项找出的装入体积为j背包的最大值
for (int i = 0; i <=n ; ++i) {
V[i][0]=0;
}
for (int j = 0; j <=C ; ++j) {
V[0][j]=0;
}
for (int k = 1; k <=n ; ++k) {
for (int i = 1; i <=C ; ++i) {
if(s[k]<=i){
V[k][i]=max(V[k-1][i],V[k-1][i-s[k]]+v[k]);
}
}
}
return V[n][C];
}
算法复杂度
背包问题的最优解可以在senta(nC)时间内和senta©空间内解得。
注意,上述算法的时间复杂性对输入不是多项式的,但是它的运行时间关于输入值是多项式的(时间复杂性+其他耗费时间),故认为它是伪多项式时间算法。