【基础算法】(06)五大常用算法之二:动态规划
Auther: Thomas Shen
E-mail: Thomas.shen3904@qq.com
Date: 2017/10/22
All Copyrights reserved !
1. 简述:
本系列介绍了五大常用算法,其中本文是第二篇,介绍了 ‘动态规划’ 的细节内容。
动态规划(Dynamic Programming,所以我们简称动态规划为DP)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。
动态规划算法通常基于一个递推公式及一个或多个初始状态。当前子问题的解将由上一次子问题的解推出。使用动态规划来解题只需要多项式时间复杂度,因此它比回溯法、暴力法等要快许多。
2. 算法原理:
2.1 基本概念:
转载自:http://blog.csdn.net/yake827/article/details/52119469
动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
2.2 基本思想与策略:
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。
与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。
2.3 适用的情况:
能采用动态规划求解的问题的一般要具有3个性质:
- 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
- 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
总体来说,动态规划算法就是一系列以空间换取时间的算法。
2.4 求解的基本步骤:
动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。
动态规划决策过程:
初始状态→│决策1│→│决策2│→…→│决策n│→结束状态
- 划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。
- 确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。
- 确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。
- 寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。
一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。
实际应用中可以按以下几个简化的步骤进行设计:
- 分析最优解的性质,并刻画其结构特征。
- 递归的定义最优解。
- 以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值。
- 根据计算最优值时得到的信息,构造问题的最优解。
2.5 算法实现的说明:
动态规划的主要难点在于理论上的设计,也就是上面4个步骤的确定,一旦设计完成,实现部分就会非常简单。
使用动态规划求解问题,最重要的就是确定动态规划三要素:
- 问题的阶段;
- 每个阶段的状态;
- 从前一个阶段转化到后一个阶段之间的递推关系。
递推关系必须是从次小的问题开始到较大的问题之间的转化,从这个角度来说,动态规划往往可以用递归程序来实现,不过因为递推可以充分利用前面保存的子问题的解来减少重复计算,所以对于大规模问题来说,有递归不可比拟的优势,这也是动态规划算法的核心之处。
确定了动态规划的这三要素,整个求解过程就可以用一个最优决策表来描述,最优决策表是一个二维表,其中行表示决策的阶段,列表示问题状态,表格需要填写的数据一般对应此问题的在某个阶段某个状态下的最优值(如最短路径,最长公共子序列,最大价值等),填表的过程就是根据递推关系,从1行1列开始,以行或者列优先的顺序,依次填写表格,最后根据整个表格的数据通过简单的取舍或者运算求得问题的最优解。
f(n,m)=max{f(n-1,m), f(n-1,m-w[n])+P(n,m)}
3. 动态规划算法基本框架:
for(j=1; j<=m; j=j+1) // 第一个阶段
xn[j] = 初始值;
for(i=n-1; i>=1; i=i-1)// 其他n-1个阶段
for(j=1; j>=f(i); j=j+1)//f(i)与i有关的表达式
xi[j]=j=max(或min){g(xi-1[j1:j2]), ......, g(xi-1[jk:jk+1])};
t = g(x1[j1:j2]); // 由子问题的最优解求解整个问题的最优解的方案
print(x1[j1]);
for(i=2; i<=n-1; i=i+1)
{
t = t-xi-1[ji];
for(j=1; j>=f(i); j=j+1)
if(t=xi[ji])
break;
}
4. 应用案例:
4.1 案例一:
有n级台阶,一个人每次上一级或者两级,问有多少种走完n级台阶的方法。
分析:动态规划的实现的关键在于能不能准确合理的用动态规划表来抽象出 实际问题。在这个问题上,我们让f(n)表示走上n级台阶的方法数。
那么当n为1时,f(n) = 1,n为2时,f(n) =2,就是说当台阶只有一级的时候,方法数是一种,台阶有两级的时候,方法数为2。那么当我们要走上n级台阶,必然是从n-1级台阶迈一步或者是从n-2级台阶迈两步,所以到达n级台阶的方法数必然是到达n-1级台阶的方法数加上到达n-2级台阶的方法数之和。即f(n) = f(n-1)+f(n-2),我们用dp[n]来表示动态规划表,dp[i],i>0,i<=n,表示到达i级台阶的方法数。
/*dp是全局数组,大小为n,全部初始化为0,是题目中的动态规划表*/
int fun(int n){
if (n==1||n==2)
return n;
/*判断n-1的状态有没有被计算过*/
if (!dp[n-1])
dp[n-1] = fun(n-1);
if(!dp[n-2])
dp[n-2]=fun(n-2);
return dp[n-1]+dp[n-2];
}
4.2 案例二:
给定一个矩阵m,从左上角开始每次只能向右走或者向下走,最后达到右下角的位置,路径中所有数字累加起来就是路径和,返回所有路径的最小路径和,如果给定的m如下,那么路径1,3,1,0,6,1,0就是最小路径和,返回12.
1 3 5 9
8 1 3 4
5 0 6 1
8 8 4 0
分析:对于这个题目,假设m是m行n列的矩阵,那么我们用dp[m][n]来抽象这个问题,dp[i][j]表示的是从原点到i,j位置的最短路径和。我们首先计算第一行和第一列,直接累加即可,那么对于其他位置,要么是从它左边的位置达到,要么是从上边的位置达到,我们取左边和上边的较小值,然后加上当前的路径值,就是达到当前点的最短路径。然后从左到右,从上到下依次计算即可。
#include <iostream>
#include <algorithm>
using namespace std;
int dp[4][4] = {};
int main(){
int arr[4][4] = {1,3,5,9,8,1,3,4,5,0,6,1,8,8,4,0};
//cout << fun(arr,4,4) << endl;
const int oo = ~0U>>2;
for (int i = 0;i<4;i++)
for (int j = 0; j < 4;j++)
dp[i][j] = oo;
//dp[0][0] = oo;
for (int i = 0; i < 4;i++){
for (int j = 0; j<4;j++){
if (dp[i][j] == oo){
if (i==0&&j==0)
dp[i][j] = arr[i][j];
else if (i==0&&j!=0)
dp[i][j] = arr[i][j] + dp[i][j-1];
else if(i!=0&&j==0)
dp[i][j] = arr[i][j] + dp[i-1][j];
else{
dp[i][j] = arr[i][j]+min(dp[i-1][j],dp[i][j-1]);
}
}
}
}
// cout << dp[3][3] << endl;
for (int i = 0; i< 4;i++){
for (int j = 0; j<4;j++){
cout << dp[i][j] << " ";
}
cout << endl;
}
}
4.3 案例三:
给定数组arr,返回arr的最长递增子序列的长度,比如arr=[2,1,5,3,6,4,8,9,7],最长递增子序列为[1,3,4,8,9]返回其长度为5.
分析:首先生成dp[n]的数组,dp[i]表示以必须arr[i]这个数结束的情况下产生的最大递增子序列的长度。对于第一个数来说,很明显dp[0]为1,当我们计算dp[i]的时候,我们去考察i位置之前的所有位置,找到i位置之前的最大的dp值,记为dpj,dp[j]代表以arr[j]结尾的最长递增序列,而dp[j]又是之前计算过的最大的那个值,我们在来判断arr[i]是否大于arr[j],如果大于dp[i]=dp[j]+1.计算完dp之后,我们找出dp中的最大值,即为这个串的最长递增序列。
#include <iostream>
#include <algorithm>
using namespace std;
/*动态规划表*/
int dp[5] = {};
int main(){
int arr[5] = {2,4,5,3,1};
dp[0] = 1;
const int oo = 0;
for (int i = 1;i<5;i++){
int _max = oo;
for (int j=0;j<i;j++)
if(dp[j]>_max&&arr[i]>arr[j])
_max = dp[j];
dp[i] = _max+1;
}
int maxlist=0;
for (int i = 0; i < 5;i++)
if (dp[i] > maxlist)
maxlist=dp[i];
cout << maxlist << endl;
}
4.4 案例四:
给定两个字符串str1和str2,返回两个字符串的最长公共子序列,例如:str1=”1A2C3D4B56”,str2=”B1D23CA45B6A”,”123456”和”12C4B6”都是最长公共子序列,返回哪一个都行。
分析:本题是非常经典的动态规划问题,假设str1的长度为M,str2的长度为N,则生成M*N的二维数组dp,dp[i][j]的含义是str1[0..i]与str2[0..j]的最长公共子序列的长度。
dp值的求法如下:
dp[i][j]的值必然和dp[i-1][j],dp[i][j-1],dp[i-1][j-1]相关,结合下面的代码来看,我们实际上是从第1行和第1列开始计算的,而把第0行和第0列都初始化为0,这是为了后面的取最大值在代码实现上的方便,dp[i][j]取三者之间的最大值。
int findLCS(string A, int n, string B, int m) {
// n表示字符串A的长度,m表示字符串B的长度
int dp[500][500] = {};
for (int i = 0;i < n;i++)
{
for (int j = 0; j<m;j++)
{
if (A[i]==B[j])
dp[i+1][j+1] = dp[i][j]+1;
else
dp[i+1][j+1] = max(dp[i+1][j],dp[i][j+1]);
}
}
return dp[n][m];
}
4.5 案例五:
背包问题,动态规划经典问题,一个背包有滴定的承重W,有N件物品,每件物品都有自己的价值,记录在数组V中,也都有自己的重量,记录在数组W中,每件物品只能选择要装入还是不装入背包,要求在不超过背包承重的前提下,选出的物品总价值最大。
分析:假设物品编号从1到n,一件一件的考虑是否加入背包,假设dp[x][y]表示前x件物品,不超过重量y的时候的最大价值,枚举一下第x件物品的情况:
- 情况1:如果选择了第x件物品,则前x-1件物品得到的重量不能超过y-w[x]。
- 情况2:如果不选择第x件物品,则前x-1件物品得到的重量不超过y。
所以dp[x][y]可能等于dp[x-1][y],也就是不取第x件物品的时候,价值和之前一样,也可能是dp[x-1][y-w[x]]+v[x],也就是拿第x件物品的时候,当然会获得第x件物品的价值。两种可能的选择中,应该选择价值较大的那个,也就是:
dp[x][y] = max{dp[x-1][y],dp[x-1][y-w[x]]+v[x]}
因此,对于dp矩阵来说,行数是物品的数量n,列数是背包的重量w,从左到右,从上到下,依次计算出dp值即可。
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
int main(){
/*物品数量*/
int n = 4;
/*背包承重*/
int cap = 10;
int v[4] = {42,12,40,25};
int w[4] = {7,3,4,5};
/*二维动态规划表*/
vector<int> p(cap+1,0);
vector<vector<int>> dp(n+1,p);
for (int i = 1;i <=n;i++){/*枚举物品*/
for (int j = 1;j<cap+1;j++){/*枚举重量*/
/*判断枚举的重量和当前选择的物品重量的关系 如果枚举的和总量大于等于选择物品,则需要判断是否选择当前物品*/
if (j-w[i-1]>=0)
dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i-1]]+v[i-1]);
else
/*如果枚举的重量还没有当前选择物品的重量大,那就只能是不取当前物品*/
dp[i][j] = dp[i-1][j];
}
}
cout << dp[n][cap] << endl;
}
4.6 案例六:
给定两个字符串str1,str2,在给定三个整数ic,dc,rc,分别代表插入,删除和替换一个字符的代价。返回将str1编辑成str2的代价,比如,str1=”abc”,str2=”adc”,ic=5,dc=3,rc=2,从str1到str2,将’b’换成’d’代价最小,所以返回2.
分析:在构建出动态规划表的时候,关键是搞清楚每个位置上数值的来源。首先我们生成dp[M+1][N+1]的动态规划表,M代表str1的长度,N代表str2的长度,那么dp[i][j]就是str1[0..i-1]变成str2[0…j-1]的最小代价,则dp[i][j]的来源分别来自以下四种情况:
- 首先将str1[i-1]删除,变成str1[0…i-2],然后将str1[0…i-2]变成str2[0…j-1],那么dp[i-1][j]就代表从str1[0..i-2]到str2[0…j-1]的最小代价,所以:dp[i][j] = dp[i-1][j]+dc;
- 同理也可以是从str1[0…i-1]变成str2[0…j-2],然后在插入str2[j-1],dp[i][j-1]就代表从str1[0…i-1]变成str2[0…j-2]的最小大家,所以:dp[i][j] = dp[i][j-1]+ic;
- 如果str[i-1] == str2[j-1],则只需要将str1[0…i-2]变成str2[0…j-2],此时dp[i][j] = dp[i-1][j-1];
- 如果str1[i-1]!=str2[j-1],则我们只需要将str1[i-1]替换成str2[j-1],此时dp[i][j] = dp[i-1][j-1]+rc;
在这四种情况当中,我们选取最小的一个,即为最小代价。
#include <iostream>
#include <string>
#include <algorithm>
#include <vector>
using namespace std;
int main(){
string str1 = "ab12cd3";
string str2 = "abcdf";
//cin>>str1;
//cin>>str2;
const int M = str1.length();
const int N = str2.length();
//vector<int> p(M+1,0);
//vector<vector<int>> dp(N+1,p);
int dp[10][10] = {};
int ic=5,dc=3,rc=2;
//int ic = 1,dc=1,rc=1;
dp[0][0] = 0;
for (int i = 1;i<N+1;i++)
dp[0][i] = ic*i;
for (int i = 1;i<M+1;i++)
dp[i][0] = dc*i;
for (int i=0;i<M;i++){
for (int j = 0;j<N;j++){
int x = min(dc+dp[i+1][j],dp[i][j+1]+ic);
if (str1[i]!=str2[j])
dp[i+1][j+1] = min(dp[i][j] + rc,x);
else
dp[i+1][j+1] = min(dp[i][j],x);
}
}
cout << dp[M][N] << endl;
}