浅谈我对动态规划的一点理解---大家准备好小板凳,我要开始吹牛皮了~~~

Posted Angel_Kitty

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈我对动态规划的一点理解---大家准备好小板凳,我要开始吹牛皮了~~~相关的知识,希望对你有一定的参考价值。

前言

作为一个退役狗跟大家扯这些东西,感觉确实有点。。。但是,针对网上没有一篇文章能够很详细的把动态规划问题说明的很清楚,我决定还是拿出我的全部家当,来跟大家分享我对动态规划的理解,我会尽可能的把所遇到的动态规划的问题都涵盖进去,博主退役多年,可能有些地方会讲的不完善,还望大家多多贡献出自己的宝贵建议,共同进步~~~

概念

首先我们得知道动态规划是什么东东,百度百科上是这么说的,动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。1957年出版了他的名著《Dynamic Programming》,这是该领域的第一本著作。

小伙伴们估计看到这段话都已经蒙圈了吧,那么动态规划到底是什么呢?这么说吧,动态规划就是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。

举个例子,我们都是从学生时代过来的人,学生时代我们最喜欢的一件事是什么呢?就是等待寒暑假的到来,因为可以放假了啊^-^,嘻嘻,谁会不喜欢玩呢~~可是呢,放假之前我们必须经历的一个过程就是期末考试,期末没考好,回家肯定是要挨板子的,所以我们就需要去复习啦,而在复习过程中我们是不是要去熟记书中的每一个知识点呢,书是一个主体,考试都是围绕着书本出题,所以我们很容易知道书本不是核心,书本中的若干个知识点才是核心,然后那个若干个知识点又可以拆解成无数个小知识点,是不是发现有点像一棵倒立的树呢,但是呢,当我们要运用这些知识点去解题时,每一道题所涉及的知识点,其实就是这些知识点的一个排列组合的所有可能结果的其中一种组合方式,这个能理解的了嘛?

对这个排列组合,我们举个例子,比如小明爸爸叫小明去买一包5元钱的香烟,他给了一张5元的,三张2元的,五张1元的纸币,问小明有几种付钱的方式?这个选择方式我们很容易就知道,我们可以对这些可能结果进行一个枚举。

这个组合方式有很多种,我们可以对其进行一个分类操作:

当我们只用一张纸币的时候:一张5元纸币

当我们需要用两张纸币的时候:结果不存在

当我们需要用三张纸币的时候:两张2元纸币和一张1元纸币

当我们需要用四张纸币的时候:一张2元纸币,三张一元纸币

当我们需要用五张纸币的时候:五张一元纸币

从上面的分类分析来看,我们知道,排列组合的方式共有四种,而我如果问你,我现在需要花费的纸币张数要最小,我们应该选取哪种方式呢,很显然我们直接选取第一种方法,使用一张5元的纸币就好了啊,这个就是求最优解的问题啦,也就是我们今天需要研究的问题,动态规划问题

相信大家到了这里,对动态规划应该有了初步的认识吧,我也很高兴带大家一起畅游算法的美妙,那么请继续听我吹牛皮吧,啦啦啦~~~

基本思想

若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。

简单来说,还是拿上面的例子来讲,比如说你做一道数学难题,难题,无非就是很难嘛,但是我们需要做的就是把这道难题解出来,对于一个数学水平很菜的选手来讲,做出一道难题是不是会感觉非常困难呢?其实换个角度来看待这个问题,一道难题其实是由若干个子问题构成,而每一个子问题也许会是一些很基础的问题,一个入门级的问题,类似于像1+1=2这样的问题,相信大家只要有所接触都能熟练掌握,而针对这些难题,我们也应该去考虑把它进行一个分解,我现在脑边还能回忆起中学老师说过的话,做不来的题目你可以把一些解题过程先写出来,把最基本的思路写出来,写着写着说不定答案就出来了呢?相信看完我这篇文章的人水平都能再上一个台阶,不仅如此,对于当前的全球热潮Artificial Intelligence也是如此,看似非常复杂繁琐的算法,把它进行一个拆解,其实就是若干个数学公式的组合,最根本的来源还是基础数学,所以啊,学好数学,未来是光明的~~~

分治与动态规划

共同点:二者都要求原问题具有最优子结构性质,都是将原问题分而治之,分解成若干个规模较小(小到很容易解决的程序)的子问题.然后将子问题的解合并,形成原问题的解.

不同点:分治法将分解后的子问题看成相互独立的,通过用递归来做。

     动态规划将分解后的子问题理解为相互间有联系,有重叠部分,需要记忆,通常用迭代来做。

问题特征

最优子结构:当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。

重叠子问题:在用递归算法自顶向下解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,在以后尽可能多地利用这些子问题的解。

我认为可能会和回溯的部分问题有点类似,有兴趣的同学可以自行阅读一下我曾经写过的文章回溯算法入门及经典案例剖析(初学者必备宝典)

解题步骤

1.找出最优解的性质,刻画其结构特征和最优子结构特征,将原问题分解成若干个子问题;

  把原问题分解为若干个子问题,子问题和原问题形式相同或类似,只不过规模变小了。子问题都解决,原问题即解决,子问题的解一旦求出就会被保存,所以每个子问题只需求解一次。

2.递归地定义最优值,刻画原问题解与子问题解间的关系,确定状态;

  在用动态规划解题时,我们往往将和子问题相关的各个变量的一组取值,称之为一个“状态”。一个“状态”对应于一个或多个子问题, 所谓某个“状态”下的“值”,就是这个“状 态”所对应的子问题的解。所有“状态”的集合,构成问题的“状态空间”。“状态空间”的大小,与用动态规划解决问题的时间复杂度直接相关。

3.以自底向上的方式计算出各个子问题、原问题的最优值,并避免子问题的重复计算;

  定义出什么是“状态”,以及在该“状态”下的“值”后,就要找出不同的状态之间如何迁移――即如何从一个或多个“值”已知的 “状态”,求出另一个“状态”的“值”(递推型)。

4.根据计算最优值时得到的信息,构造最优解,确定转移方程;

  状态的迁移可以用递推公式表示,此递推公式也可被称作“状态转移方程”。

实例分析

1.01背包问题

有N件物品和一个容量为V的背包。第i件物品的费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。 

f[i][v]表示前i件物品恰放入一个容量为v的背包可以获得的最大价值。则其状态转移方程便是:f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}。

将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只牵扯前i-1件物品的问题。如果不放第i件物品,那么问题就转化为“前i-1件物品放入容量为v的背包中”;如果放第i件物品,那么问题就转化为“前i-1件物品放入剩下的容量为v-c[i]的背包中”,此时能获得的最大价值就是f [i-1][v-c[i]]再加上通过放入第i件物品获得的价值w[i]。

对01背包不清楚的或者有兴趣阅读的同学请移步至这里

下面贴下01背包的模板

void backtrack(int i,int cp,int cw)
{
    if(i>n)
    {
        if(cp>bestp)
        {
            bestp=cp;
            for(i=1;i<=n;i++) bestx[i]=x[i];
        }
    }
    else
    {
        for(int j=0;j<=1;j++)  
        {
            x[i]=j;
            if(cw+x[i]*w[i]<=c)  
            {
                cw+=w[i]*x[i];
                cp+=p[i]*x[i];
                backtrack(i+1,cp,cw);
                cw-=w[i]*x[i];
                cp-=p[i]*x[i];
            }
        }
    }
}

最终我们可以去得到答案:

int n,c,bestp;//物品个数,背包容量,最大价值
int p[10000],w[10000],x[10000],bestx[10000];//物品的价值,物品的重量,物品的选中情况
int main()
{
    bestp=0; 
    cin>>c>>n;
    for(int i=1;i<=n;i++) cin>>w[i];
    for(int i=1;i<=n;i++) cin>>p[i];
    backtrack(1,0,0);
    cout<<bestp<<endl;
}

2.矩阵连乘

给定n个可连乘的矩阵{A1, A2, …,An},根据矩阵乘法结合律,可有多种不同计算次序,每种次序有不同的计算代价,也就是数乘次数。例如给定2个矩阵A[pi,pj]和B[pj,pk],A×B为[pi,pk]矩阵,数乘次数为pi×pj×pk。将矩阵连乘积Ai…Aj简记为A[i:j] ,这里i≤j。考察计算A[i:j]的最优计算次序,设这个计算次序在矩阵Ak和Ak+1之间将矩阵链断开,i≤k<j,则A[i:j]的计算量=A[i:k]的计算量+A[k+1:j]的计算量+A[i:k]和A[k+1:j]相乘的计算量。计算A[i:j]的最优次序所包含的计算矩阵子链A[i:k]和A[k+1:j]的次序也是最优的。即矩阵连乘计算次序问题的最优解包含着其子问题的最优解,这种性质称为最优子结构性质,问题具有最优子结构性质是该问题可用动态规划算法求解的显著特征。

举个例子:

给出N个数,每次从中抽出一个数(第一和最后一个不能抽),该次的得分即为抽出的数与相邻两个数的乘积。一直这样将每次的得分累加直到只剩下首尾两个数为止,问最小得分。

实现过程如下:

#define maxn 105
int dp[maxn][maxn],a[maxn]; 
int main()
{
    int n;
    cin>>n;
    int i,j,k,len;
    memset(dp,0,sizeof(dp)); 
    //len是设置步长,也就是j减i的值 
    for(i=0;i<n;i++) cin>>a[i];
    for(i=0;i<n-2;i++) dp[i][i+2]=a[i]*a[i+1]*a[i+2];
    //如果只有三个数就直接乘起来 
    for(len=3;len<n;len++)
    {
        for(i=0;i+len<n;i++)
        {    
            j=i+len;
            for(k=i+1;k<j;k++)
            {
                if(dp[i][j]==0) dp[i][j]=dp[i][k]+dp[k][j]+a[i]*a[k]*a[j];
                 else dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]+a[i]*a[k]*a[j]);
            }
        }
    }
    cout<<dp[0][n-1]<<endl;
    return 0;
}

3.最长公共子序列与最长公共子串

子串应该比较好理解,至于什么是子序列,这里给出一个例子:有两个母串

  • cnblogs
  • belong

比如序列bo, bg, lg在母串cnblogs与belong中都出现过并且出现顺序与母串保持一致,我们将其称为公共子序列。最长公共子序列(Longest Common Subsequence, LCS),顾名思义,是指在所有的子序列中最长的那一个。子串是要求更严格的一种子序列,要求在母串中连续地出现。在上述例子的中,最长公共子序列为blog(cnblogs, belong),最长公共子串为lo(cnblogs, belong)。

对于母串X=<x1,x2,,xm>X=<x1,x2,⋯,xm>, Y=<y1,y2,,yn>Y=<y1,y2,⋯,yn>,求LCS与最长公共子串。

暴力解法:

假设 m<nm<n, 对于母串XX,我们可以暴力找出2m2m个子序列,然后依次在母串YY中匹配,算法的时间复杂度会达到指数级O(n2m)O(n∗2m)。显然,暴力求解不太适用于此类问题。

动态规划:

假设Z=<z1,z2,,zk>Z=<z1,z2,⋯,zk> 是XX 与YY 的LCS, 我们观察到

  • 如果xm=ynxm=yn ,则zk=xm=ynzk=xm=yn ,有Zk1Zk−1 是Xm1Xm−1 与Yn1Yn−1 的LCS;
  • 如果xmynxm≠yn ,则ZkZk 是XmXm 与Yn1Yn−1 的LCS,或者是Xm1Xm−1 与YnYn 的LCS。

因此,求解LCS的问题则变成递归求解的两个子问题。但是,上述的递归求解的办法中,重复的子问题多,效率低下。改进的办法——用空间换时间,用数组保存中间状态,方便后面的计算。这就是动态规划(DP)的核心思想了。

DP求解LCS

用二维数组c[i][j]记录串x1x2xix1x2⋯xi与y1y2yjy1y2⋯yj的LCS长度,

用i,j遍历两个子串x,y,如果两个元素相等就+1 ,不等就用上一个状态最大的元素

实现过程如下:

int lcs(string str1, string str2, vector<vector<int>>& vec) {
    int len1 = str1.size();
    int len2 = str2.size();
    vector<vector<int>> c(len1 + 1, vector<int>(len2 + 1, 0));
    for (int i = 0; i <= len1; i++) {
        for (int j = 0; j <= len2; j++) {
            if (i == 0 || j == 0) {
                c[i][j] = 0;
            }
            else if (str1[i - 1] == str2[j - 1]) {
                c[i][j] = c[i - 1][j - 1] + 1;
                vec[i][j] = 0;
            }
            else if (c[i - 1][j] >= c[i][j - 1]){
                c[i][j] = c[i - 1][j];
                vec[i][j] = 1;
            }
            else{
                c[i][j] = c[i][j - 1];
                vec[i][j] = 2;
            }
        }
    }

    return c[len1][len2];
}

void print_lcs(vector<vector<int>>& vec, string str, int i, int j)
{
    if (i == 0 || j == 0)
    {
        return;
    }
    if (vec[i][j] == 0)
    {
        print_lcs(vec, str, i - 1, j - 1);
        printf("%c", str[i - 1]);
    }
    else if (vec[i][j] == 1)
    {
        print_lcs(vec, str, i - 1, j);
    }
    else
    {
        print_lcs(vec, str, i, j - 1);
    }
}

int _tmain(int argc, _TCHAR* argv[])
{
    string str1 = "123456";
    string str2 = "2456";
    vector<vector<int>> vec(str1.size() + 1, vector<int>(str2.size() + 1, -1));
    int result = lcs(str1, str2, vec);

    cout << "result = " << result << endl;

    print_lcs(vec, str1, str1.size(), str2.size());

    getchar();
    return 0;
}

DP求解最长公共子串

前面提到了子串是一种特殊的子序列,因此同样可以用DP来解决。定义数组的存储含义对于后面推导转移方程显得尤为重要,糟糕的数组定义会导致异常繁杂的转移方程。考虑到子串的连续性,将二维数组c[i,j]c[i,j]用来记录具有这样特点的子串——结尾为母串x1x2xix1x2⋯xi与y1y2yjy1y2⋯yj的结尾——的长度。

区别就是因为是连续的,如果两个元素不等,那么就要=0了而不能用之前一个状态的最大元素

最长公共子串的长度为 max(c[i,j]), i{1,,m},j{1,,n}max(c[i,j]), i∈{1,⋯,m},j∈{1,⋯,n}。

实现过程如下:

int lcs_2(string str1, string str2, vector<vector<int>>& vec) {
    int len1 = str1.size();
    int len2 = str2.size();
    int result = 0;     //记录最长公共子串长度
    vector<vector<int>> c(len1 + 1, vector<int>(len2 + 1, 0));
    for (int i = 0; i <= len1; i++) {
        for (int j = 0; j <= len2; j++) {
            if (i == 0 || j == 0) {
                c[i][j] = 0;
            }
            else if (str1[i - 1] == str2[j - 1]) {
                c[i][j] = c[i - 1][j - 1] + 1;
                vec[i][j] = 0;
                result = c[i][j] > result ? c[i][j] : result;
            }
            else {
                c[i][j] = 0;
            }
        }
    }
    return result;
}

void print_lcs(vector<vector<int>>& vec, string str, int i, int j)
{
    if (i == 0 || j == 0)
    {
        return;
    }
    if (vec[i][j] == 0)
    {
        print_lcs(vec, str, i - 1, j - 1);
        printf("%c", str[i - 1]);
    }
    else if (vec[i][j] == 1)
    {
        print_lcs(vec, str, i - 1, j);
    }
    else
    {
        print_lcs(vec, str, i, j - 1);
    }
}
int _tmain(int argc, _TCHAR* argv[])
{
    string str1 = "123456";
    string str2 = "14568";
    vector<vector<int>> vec(str1.size() + 1, vector<int>(str2.size() + 1, -1));
    int result = lcs_2(str1, str2, vec);

    cout << "result = " << result << endl;

    print_lcs(vec, str1, str1.size(), str2.size());

    getchar();
    return 0;
}

4.走金字塔

给定一个由n行数字组成的数字三角型,如图所示。设计一个算法,计算从三角形的顶至底的一条路径,使该路径经过的数字总和最大。路径上的每一步都只能往左下或右下走,给出这个最大和。
        7 
      3  8 
    8  1  0 
  2  7  4  4 
4  5  2  6  5

对于这种问题,我们可以有正向和反向两种思考方式。正向思考这个问题,dp[i][j]表示从第一行第一列到第i行第j列最大的数字总和;反向思考这个问题,dp[i][j]表示从第i行第j列到最后一行最大的数字总和。反向思考的代码要简洁一些

正向思考:

int triangle[110][110],dp[110][110];
int main()
{
    int N;
    cin>>N;
    memset(dp,0,sizeof(dp));
    memset(triangle,0,sizeof(triangle));
    for(int i=1;i<=N;i++)
    {
        for(int j=1;j<=i;j++)
        {
            cin>>triangle[i][j];
        }
    }
    dp[1][1]=triangle[1][1];
    for(int i=2;i<=N;i++)
    {
        for(int j=1;j<=i;j++)
        {
            if(j!=1) dp[i][j]=max(dp[i][j],dp[i-1][j-1]+triangle[i][j]);
            浅谈我对DDD领域驱动设计的理解

浅谈我对DDD领域驱动设计的理解

浅谈我对DDD领域驱动设计的理解

浅谈我对DDD领域驱动设计的理解

(转载)浅谈我对DDD领域驱动设计的理解

动态规划详解_2