动态规划

Posted -sushi

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动态规划相关的知识,希望对你有一定的参考价值。

2.阅读代码——动态规划

乔治·桑塔亚纳说过,“那些遗忘过去的人注定要重蹈覆辙。”这句话放在问题求解过程中也同样适用。不懂动态规划的人会在解决过的问题上再次浪费时间,懂的人则会事半功倍。那么什么是动态规划?这种算法有何神奇之处?

目的:为了避免解决重复性问题

斐波那契

1.递归算法

void fun(int n)
{
    if(n==0)
       return 0;
    if(n==1)
       return 1;
     reurn fun(n-2)+fun(n-1);
}

任何一个递归函数,都可以化成一棵树。

如图所示:

技术图片

从图片上可以看到,以粉色为底的第二层f(3)同样在第三层也重复算了一遍,第三层的f(2)重复算了两遍,第四层也有f(2),这样的问题,我们叫做重复子问题,由于重复计算同一个值的斐波那契值,致使递归的效率不高,所以我们引进动态规划。

2.动态规划(简称DP)

动态规划的核心思想:

把原问题分解成若干个子问题进行求解,先求解子问题,然后从这些子问题得到原问题的姐解,不同的是,动态规划保存已解决的子问题的答案,便于在后面需要时能够马上得到,就可以节省大量的重复计算。


在动态规划里,有一个特别重要的角色,就是数组。而数组有一维数组和二维数组,至于是一维数组的选定还是二维数组的选定,要根据具体问题具体分析,一般二维数组解决较难的题目,讲到这里,其实还没有对动态规划形成一个具体的概念,请看下面的典型应用,加深理解。

3.动态规划的典型应用

一维应用:

斐波那契数列:

int fun(int x)
{
   int dp[max];
   dp[0]=0;dp[1]=1;dp[2]=1;
   
   for(int i=3;i<=x;i++)
   {
       dp[i]=dp[i-1]+dp[i-2];
   }
   return dp[x];
}

从上面就可以发现,如果当数据量急剧增大时,递归和动态规划的的效率也会有明显差异。


这里有一个小技巧,运用dp时,我们想要的得到的结果一般是数组的最后一个,例如我们求斐波那契值时,就是一维数组的最后最后一位,如果是二维数组,那么结果则是位于最右下角的那一位

二维应用:

题目1:给你一个字符串,如19216801这一个字符串,通过在任意位置加入三个点,组成合理的ip地址,请问,它有几种合理的方案

题意分析:

  • 合理的ip地址 X.X.X.X,,并且每一位都必须小于255,拆成四位。
  • ip拆成四位,数字不能以0开头。

图解:

技术图片

具体部分:

图一:

技术图片

图二:

技术图片

图三:

技术图片


  • 由图一和图二的橙色圈圈和蓝色圈圈可知,则这道题画成一棵树,则会出现很多的重复子问题,那么用动态规划的图如下,这道题,要选择二维dp数组作为载体,来存放已解决的子问题。

  • 画图注意点:

    • 1.对于一个动态规划的问题,一定要理解dp[]或dp[] []对应的含义,例如在斐波那契数列里,dp[x]就表示斐波那契数列的值,而在这道题里,我们把题目进行分解,要求将字符串拆成四个255以内的值,那么dp[] [j]就可以前j个字符串,dp[i] []则表示可以表示i个255以内的值,合起来的意思,就是前j个字符串可以表示i个255以内的值
    • 2.在画图的时候,其实也要注意,为了让我们对题目更好的理解,我们一般会多申请一个空间,从1开始。
    • 3.我们题目所要的解一般在d[max] [max]这个地方,这个题目例题要我们求的就是d[4] [8],str前8个字符能组成几种255以内的数

    动态规划图解分解过程:

    第一步:对d[i] [j]数组进行初始化,对i和j等于0的地方,可赋值为0,因为没有意义;

    比如i=0时,j=1时,表示1可以表示0个255的方法有几种,显然没意义,所以直接赋值为0;

技术图片

第二步:对i=1开始分析。

技术图片

  • 从j=1开始看,将字符串1表示成1个255以内的数,有一种
  • j=2,将字符串19表示成1个255以内的数,有一种
  • j=3,将字符串192表示成1个255以内的数,有一种
  • j>=4开始,就会看到已经是四位数了,大于255,所以是0

第三步:从i=2开始分析

  • 由前面可知,j=1时,根据意义,1不能表示2个255以内的数,所以是0

  • j=1时,要注意,从i=2开始后,不是直接看19是255以内的数,那它就是一种,这种想法是错误的,对于递归问题转动态规划,我们要看它的起源,所以j=2,先看9是不是255以内的数,则应该返回i=1那一行看,如果9是,则d[2] [2]+=d[1] [1],然后再看19是不是255以内的数,如果是,则d[2] [2]+=d[1] [0],所以最终d[2] [2]=1.

  • 技术图片

  • 技术图片

    d[2] [3]+=dp[1] [1],看192是不是255以内的数,如果是d[2] [3]+=dp[1] [0]。最终,d[2] [3]=2。

    技术图片

  • 同理,后面的数,也是这样得出。经过多次运算,我们会发现一种规律,每一个格子的值都是当前格子左上角的三个格子之和组成,那为什么是三个格子,也不一定,看具体要求,因为这道题是不超过255,那肯定只能是三位数,所以就是三个格子。最后,组成的图如下:

    技术图片

  • 那么我们会有一个疑问,为什么最后的答案就是我们要求。这需要我们回归树形图。拿其中一个格子来说。

  • 技术图片

  • 为什么这个格子是5?

    技术图片

    技术图片

    技术图片

  • 仔细数数,图中蓝色的圈圈是否有5个,就说明6的起源有5个,再回归方格图,我们就会有一种感觉,如果看的是dp[3] [1]=3这个数,我们就可以去树形图里找1的起源,会有三个圈,而1所在的树的层次肯定会比6的层次高,因为1在前面,所以1会是6的子问题,所以,这棵树,与这个表结合,表从上到下的子问题的答案,到最后,我们要求的答案,而树,从上到下,每一个分支,也是一个子问题,到叶子节点,可能就是我们要找的答案。

动态规划代码:

接下来,我们来看代码实现。

int DP(string str)
{
    int len=str.length;
    int dp[5][len+1]={0};
     //进行初始化    
    for(int i=0;i<5;i++)//先对dp数组进行遍历赋值
    {
         for(int j=i;j<len;j++)
         {
             if(i==0&&j==0)//dp[0][0]置为1,后面比较好算
             {
                dp[i][j]=1;
                continue;
             }
             if(i==0)//第一行没有意义,所以为0
             {
                dp[i][j]=0;
                continue;
             }
               dp[i][j]=0;
             for(int x=1;x<=3;x++)//三位数
             {
                if(j-x>=0&&validate(str.substr(j-x+1,x)))
  //str.substr(a,b),表示从j-x+1这个地方开始截取x个字符
  //validate()这个函数是判断数字
                {
                    dp[i][j]+=dp[i-1][j-x];
                }
             }
             
         }
    }
         return dp[4][len]
};
int validate(string str)
{
   if(s==‘0‘)  return true;
   if(s.startWith(‘0‘)) return false;
   return parseInt(s)<=255;
}
/*
库函数
str.startWith(ch)    //判断字符串是否以‘0’开头
 parseInt(str)      //是将str字符串转化为数字的函数
*/

伪代码

int DP(string str)
{
   得到字符串的长度len
   申请多一个空间的dp数组,并初始化
   
   for i=0 to i=4//对dp数组进行遍历
   {
     for j=i to j=len-1
     {
         if(i==0&&j==0)//dp[0][0]置为1,后面比较好算,不影响之前的分析
         {
           dp[i][j]=1;
           continue;
         }
         if(如果是第一行)
         {
            没有意义,则dp[i][j]=0;
            continue;
         }
         dp[i][j]=0;
         for x=1 to x=3  //三位数
         {
           if(从j-x+1这个地方开始截取x字符,并且数字有意义)
           {
              dp[i][j]+=dp[i-1][j-x];
           }
         }
         
     }
   }
}

运行结果:

技术图片

题目解题优势及难点

对于上面这道题,其实也可以通过递归来实现,根据一些前辈的经验,得出了两个经验,一是只要遇到字符串的子序列或配准问题首先考虑动态规划DP, 二是只要遇到需要求出所有可能情况首先考虑用递归,但是上面这道题目,其实用动态规划也可以理解。

题目二:

技术图片
技术图片

  • 这道题,一开始看着题目有点像是哈夫曼树求最小权值,后来发现是不一样的,这道题要求是相邻的,哈夫曼树没有这个条件,所以代码放进去是错误的。
    技术图片

解题思路:

  • 步骤一,计算sum[]数组,表示第i堆石子到第j堆石子的总和

技术图片

  • 对sum[i]数组里,我们可以横着看每一行,i=1时,从j=1开始,代表的意义就是如果从第一个石子往右开始合并,先不计较到底要怎么合并花费比较小,从第二行开始,代表的意义就是从第二个石子开始从左往右合并,不考虑开销,这样,就可以罗列出所有合并的可能性,那么我们得出所有可能性后,要做的就是,让这些可能性进行关联,找出最小开销。
  • 那么对下面第二行,第三行也是同理的。

代码实现:

技术图片


上面的步骤,其实只是对这些石子所有可能性进行一个存储,所以,接下来,要进入核心代码的部分,真正进行dp[] []数组的操作。

技术图片

伪代码:

for i=1 to i=N
  for j=i to j=N
    计算把第i堆合并到第j堆需要的费用
     用sum数组存储
for j=2 to j=N
{
    for i=j to j=0
    {
        先对dp[i][j]初始化为无穷大
        for k=i to k=j-1
        {
           从i到j共有j-i个状态,取最优值
           dp[i][j]=min(dp[i][j],dp[i] [k]+dp[k+1][j]+sum[i][j]);
        }
    }
}

运行结果:

技术图片

DP过程:

以归并石子的长度为阶段,一共有n-1个阶段

状态:每个阶段有多少堆石子要归并

当归并长度为2时,有n-1个状态,分别为1和2合并,2和3合并.....n-1和n合并

当归并长度为3时,有n-2个状态,分别为1,2,3合并,2,3,4合并.....

当归并长度为n时,有1个状态

让我们看看上面的代码:

j=2,i=1

j=3,i=2

j=3,i=1

j=4,i=3

j=4,i=2

j=4,i=1

从上面的变化,其实我们可以看到,代码在算从i到j分别算出合并两个,三个,,这样算下去,含义就是从第i堆到第j堆,算出不同状态下的dp值

图解如下

  • 首先,对dp[i] [j]进行初始化。

  • 对于动态规划,我们要知道,最核心的部分,就是能够找到dp[i] [j]横坐标,纵坐标表示的含义。

  • 对于此题,dp[i] [j]表示的含义其中一种是就是从第i堆合并到第j堆的最小代价。

技术图片

  • j=i,表示自己合并自己,不用花费任何钱
  • 第二步,将两堆合并成一堆需要的费用。

技术图片

  • 第三步,将三堆合并成一堆,这里可能会感到疑惑的是为什么是三堆,我们已经解决了相邻两堆之间的问题,那么在三堆之间,我们则要对当前对进行分析,是要选右边的并成一堆,还是选左边的并成一堆,所以,肯定是选和比较小的,所以还记得我们之前做的准备sum数组嘛,它记录了每一堆从左往右要花费的钱钱,这时,它就起了作用。

技术图片

  • 对于i=1,j=3这个点来说,它的含义就是将第一堆(1),第二堆(2),第三堆(3)合并在一起,那么总的费用就是第一堆和第二队进行合并cost1=3,12堆和3进行合并,就是cost2=3+3=6,所以总的费用就是3+6=9。
  • 那么下面对于i=2,j=4也是同理

最后

技术图片

  • 我们来举个例子吧

  • 看表里的i=2,j=4,结合代码,含义是把第二堆第三堆第四堆进行合并

  • k=2时,dp[2] [2]+dp[3] [4]+sum[2] [4]

  • k=3时,dp[2] [3]+dp[4] [4]+sum[2] [4]

  • 从上面就可看到,dp[2] [4]有两种选择,第三堆和第四堆先合并,再和第二堆进行合并,或者第二堆和第三堆先合并,再合并第四堆,然后比较它们之间的大小,就可以求出正解

题目解题的优势及难点

这次我选的题目都是关于动态规划的,所有优势大都差不多,就不说了,动态规划的题目看多了,我觉得这道题给我的感觉就更多是枚举的感觉,通过二维数组来存储,其实,也是这道题让我写了这个全部都是动态规划的题目,因为一开始还不懂什么是动态规划,就碰上了这个题目,当时在那边死命的看这道题的题解,就是死命的看不懂,那在这之前,其实对动态规划有所耳闻,只是并没有非常认真地了解过,所以,就打算多找几道题,让我熟悉一下它

题目三:环形相邻两堆石子合并

技术图片

解题思路:

  • 环形结构,经常采用双倍长度线性化的手段,也就是说,把环形结构看成是长度为环的两倍的两倍的线性结构来处理,将环化成线性结构

  • 环的长度是N,所以题目相当于与有一排石子,1,,,,N,N+1,,,2N,然后就可以用线性的石子合并问题的方法做了

  • 有个地方需要注意,f(i,j)总是和f(N+i,N+j)相等,所以可以减少一些不必要的计算。

  • 将N结构的线性表,转换成双倍的2N结构的长度。然后在2N长度的表中,截取我们需要的长度N的部分就可以了

状态:

  • dp[i] [j]表示从第i堆合并到第j堆(合并成一堆)的最小代价

  • sum[i]表示前i堆石子的和

  • 状态转移方程:

  • dp[i] [j]=min(dp[i] [k]+dp[(i+k+1)%n] [j-k-1]+sum[i] [j])(0<k<=j-1)(j<=k<j)

dp过程:

  • len...1>n len表示归并长度
  • i...1>2n i表示起点
  • 那么j=i+len-1
  • k...i>j-1

根据上面,就可以知道说,转换成线性结构后,和题目二是差不多的

代码实现:

技术图片
技术图片

伪代码

for i=1 to i=N
  for j=i to j=N
    计算把第i堆合并到第j堆需要的费用
    用sum数组存储
for j=1 to j=n-1
  for i=0 to i=n-1
  {
      对mins[i][j]初始化为无穷大
      for k=0 k=j-1
      {
          计算从i到j共有j-i个状态,取最优值
      }
  }
   

图解如下:

技术图片

技术图片

技术图片

这个是用vs调试出来的结果

下面,对上面的数据进行分析

  • 从第二列开始看
  • i=0,j=1,表示第0堆与第一堆进行合并(由于数据存储的时候,是从下标为0开始,但是如果从下标为一 开始,可能更好理解)
  • 接下来的一列,都是这个意思
  • 从第三列开始看
  • i=0,j=2表示将第0堆,第一堆,第二堆进行合并,此时有两种状态,上面也已经说过了
  • 总的来说,这道题的思路和上面一道一样,唯一的不同就是处理时,要用%进行求余,可以减少一定的计算时间。

运行结果:

技术图片

题目解题的优势及难点

其实会发现,这题看多了,到最后一道题,再看的时候,就没那么难了,这最后一道题,我觉得最重要的是懂得环路处理起来的思路,其实想想我们上学期学过的数组左移右移这样,也是可以通过求余来算的,我上次看过一个视频,对于程序员,如何高效刷题,他说的是,对于同一类的题目应该给他刷个十几二十题的,并且可以有小本本记录下每一个超级经典,自己又容易错的,同样的题目看多了,遇到面试官的那些奇葩题目,都是万变不离其宗,也就不会慌了。




以上是关于动态规划的主要内容,如果未能解决你的问题,请参考以下文章

是否可以动态编译和执行 C# 代码片段?

动态规划_线性动态规划,区间动态规划

应对笔试手写代码,如何准备动态规划?

应对笔试手写代码,如何准备动态规划?

应对笔试手写代码,如何准备动态规划?

算法动态规划 ⑤ ( LeetCode 63.不同路径 II | 问题分析 | 动态规划算法设计 | 代码示例 )