浅谈动态规划(一)

前言:

猿爸爸把 1+1+1+1+1+1+1+1 = 写在纸上,问小猿(咦):
「它们加起来是多少哇?」

(数了一会…)「8 !」

猿爸爸在左边又加了个 1+,再问一次小猿:
「现在呢?」

(迅速地)「9 !」

「为什么小猿这么快就知道了呢?」

「因为你刚刚加了 1 啊~」

「所以只要记得之前的结果,就不用再计一次数啦。」

嗯,动态规划就是一种「先记住些事,方便后面节约时间」的神奇方法

正文

一、定义

原问题的解如何由子问题的解组合而成。

二、概念

1)动态规划是运筹学中用于求解决策过程中的最优化数学方法。 当然,我们在这里关注的是作为一种算法设计技术,作为一种使用多阶段决策过程最优的通用方法。

它是应用数学中用于解决某类最优化问题的重要工具。

 动态规划(dynamic programming)是解决问题的一种方法。

是的,它只是问题的一种解决方法。一个问题完全可能有多种解法。

2)如果问题是由交叠的子问题所构成,我们就可以用动态规划技术来解决它,一般来说,这样的子问题出现在对给定问题求解的递推关系中,这个递推关系包含了相同问题的更小子问题的解。动态规划法建议,与其对交叠子问题一次又一次的求解,不如把每个较小子问题只求解一次并把结果记录在表中(动态规划也是空间换时间的),这样就可以从表中得到原始问题的解。

对于有经验的人来说,摸索出原问题的解如何由子问题组合而成,就能把状态转移方程写出来,该问题就已经解决了。
因为动态规划的实现有很成熟的两类方法:

(1)“自顶向下”(top-down dynamic programming) 的实现方式;

(2)“自底向上” (bottom-up dynamic programming)的实现方式。

有经验的人由状态转移方程随便选一种实现方式,就跟大部分高中试题一样遵从套路即可。
所以,解动态规划问题本质还是找出“原问题的解如何由子问题组合而成”,这是动态规划的本质

而这种本质,实现的真正方法来自两种:

对问题状态的定义;

对状态转移方程的定义。

动态规划是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。而拆分问题,靠的就是状态的定义状态转移方程的定义

三、求解的基本步骤

动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。

初始状态→│决策1│→│决策2│→…→│决策n│→结束状态

动态规划决策过程示意图

(1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解

(2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性

(3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。

(4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件边界条件
一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。

实际应用中可以按以下几个简化的步骤进行设计:

(1)分析最优解的性质,并刻画其结构特征;

(2)递归的定义最优解;

(3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值;

(4)根据计算最优值时得到的信息,构造问题的最优解。

四、谈谈动态规划与其他算法之间的联系与区别

下面截取一段来自知乎王勐对动态规划的理解:(递推、贪心、搜索、动态规划四者之间的关系)

动态规划的本质不在于是递推或是递归,也不需要纠结是不是内存换时间。

首先需要明白哪些问题不是动态规划可以解决的,才能明白为神马需要动态规划。不过好处时顺便也就搞明白了递推贪心搜索和动规之间有什么关系,以及帮助那些总是把动规当成搜索解的同学建立动规的思路。当然熟悉了之后可以直接根据问题的描述得到思路。

动态规划是对于 某一类问题 的解决方法!!重点在于如何鉴定“某一类问题”是动态规划可解的而不是纠结解决方法是递归还是递推!

怎么鉴定dp可解的一类问题需要从计算机是怎么工作的说起…计算机的本质是一个状态机,内存里存储的所有数据构成了当前的状态,CPU只能利用当前的状态计算出下一个状态(不要纠结硬盘之类的外部存储,就算考虑他们也只是扩大了状态的存储容量而已,并不能改变下一个状态只能从当前状态计算出来这一条铁律)

当你企图使用计算机解决一个问题是,其实就是在思考如何将这个问题表达成状态(用哪些变量存储哪些数据)以及如何在状态中转移(怎样根据一些变量计算出另一些变量)。所以所谓的空间复杂度就是为了支持你的计算所必需存储的状态最多有多少,所谓时间复杂度就是从初始状态到达最终状态中间需要多少步!

太抽象了还是举个例子吧:

比如说我想计算第100个非波那契数,每一个非波那契数就是这个问题的一个状态,每求一个新数字只需要之前的两个状态。所以同一个时刻,最多只需要保存两个状态,空间复杂度就是常数;每计算一个新状态所需要的时间也是常数且状态是线性递增的,所以时间复杂度也是线性的。

上面这种状态计算很直接,只需要依照固定的模式从旧状态计算出新状态就行(a[i]=a[i-1]+a[i-2]),不需要考虑是不是需要更多的状态,也不需要选择哪些旧状态来计算新状态。对于这样的解法,我们叫递推

非波那契那个例子过于简单,以至于让人忽视了阶段的概念,所谓阶段是指随着问题的解决,在同一个时刻可能会得到的不同状态的集合。非波那契数列中,每一步会计算得到一个新数字,所以每个阶段只有一个状态。想象另外一个问题情景,假如把你放在一个围棋棋盘上的某一点,你每一步只能走一格,因为你可以东南西北随便走,所以你当你同样走四步可能会处于很多个不同的位置。从头开始走了几步就是第几个阶段,走了n步可能处于的位置称为一个状态,走了这n步所有可能到达的位置的集合就是这个阶段下所有可能的状态。

现在问题来了,有了阶段之后,计算新状态可能会遇到各种奇葩的情况,针对不同的情况,就需要不同的算法,下面就分情况来说明一下:

假如问题有n个阶段,每个阶段都有多个状态,不同阶段的状态数不必相同,一个阶段的一个状态可以得到下个阶段的所有状态中的几个。那我们要计算出最终阶段的状态数自然要经历之前每个阶段的某些状态。

好消息是,有时候我们并不需要真的计算所有状态,比如这样一个弱智的棋盘问题:从棋盘的左上角到达右下角最短需要几步。答案很显然,用这样一个弱智的问题是为了帮助我们理解阶段和状态。某个阶段确实可以有多个状态,正如这个问题中走n步可以走到很多位置一样。但是同样n步中,有哪些位置可以让我们在第n+1步中走的最远呢?没错,正是第n步中走的最远的位置。换成一句熟悉话叫做“下一步最优是从当前最优得到的”。所以为了计算最终的最优值,只需要存储每一步的最优值即可,解决符合这种性质的问题的算法就叫贪心。如果只看最优状态之间的计算过程是不是和非波那契数列的计算很像?所以计算的方法是递推。

既然问题都是可以划分成阶段和状态的。这样一来我们一下子解决了一大类问题:一个阶段的最优可以由前一个阶段的最优得到

如果一个阶段的最优无法用前一个阶段的最优得到呢?

什么你说只需要之前两个阶段就可以得到当前最优?那跟只用之前一个阶段并没有本质区别。最麻烦的情况在于你需要之前所有的情况才行。

再来一个迷宫的例子。在计算从起点到终点的最短路线时,你不能只保存当前阶段的状态,因为题目要求你最短,所以你必须知道之前走过的所有位置。因为即便你当前再的位置不变,之前的路线不同会影响你的之后走的路线。这时你需要保存的是之前每个阶段所经历的那个状态,根据这些信息才能计算出下一个状态!

每个阶段的状态或许不多,但是每个状态都可以转移到下一阶段的多个状态,所以解的复杂度就是指数的,因此时间复杂度也是指数的。哦哦,刚刚提到的之前的路线会影响到下一步的选择,这个令人不开心的情况就叫做有后效性

刚刚的情况实在太普遍,解决方法实在太暴力,有没有哪些情况可以避免如此的暴力呢?

契机就在于后效性

有一类问题,看似需要之前所有的状态,其实不用。不妨也是拿最长上升子序列的例子来说明为什么他不必需要暴力搜索,进而引出动态规划的思路。

假装我们年幼无知想用搜索去寻找最长上升子序列。怎么搜索呢?需要从头到尾依次枚举是否选择当前的数字,每选定一个数字就要去看看是不是满足“上升”的性质,这里第i个阶段就是去思考是否要选择第i个数,第i个阶段有两个状态,分别是选和不选。哈哈,依稀出现了刚刚迷宫找路的影子!咦慢着,每次当我决定要选择当前数字的时候,只需要和之前选定的一个数字比较就行了!这是和之前迷宫问题的本质不同!这就可以纵容我们不需要记录之前所有的状态啊!既然我们的选择已经不受之前状态的组合的影响了,那时间复杂度自然也不是指数的了啊!虽然我们不在乎某序列之前都是什么元素,但我们还是需要这个序列的长度的。所以我们只需要记录以某个元素结尾的LIS长度就好!因此第i个阶段的最优解只是由前i-1个阶段的最优解得到的,然后就得到了DP方程(感谢 @韩曦 指正)

LIS(i)=max{LIS(j)+1}       j<i and a[j]<a[i]

所以一个问题是该用递推、贪心、搜索还是动态规划,完全是由这个问题本身阶段间状态的转移方式决定的!

每个阶段只有一个状态->递推

每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心

每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到这个性质叫做最优子结构;

而不管之前这个状态是如何得到这个性质叫做无后效性。

另:其实动态规划中的最优状态的说法容易产生误导,以为只需要计算最优状态就好,LIS问题确实如此,转移时只用到了每个阶段“选”的状态。但实际上有的问题往往需要对每个阶段的所有状态都算出一个最优值,然后根据这些最优值再来找最优状态。比如背包问题就需要对前i个包(阶段)容量为j时(状态)计算出最大价值。然后在最后一个阶段中的所有状态种找到最优值。

五、动态规划迷思

a. “缓存”,“重叠子问题”,“记忆化”:

这三个名词,都是在阐述递推式求解的技巧。以Fibonacci数列为例,计算第100项的时候,需要计算第99项和98项;在计算第101项的时候,需要第100项和第99项,这时候你还需要重新计算第99项吗?不需要,你只需要在第一次计算的时候把它记下来就可以了。
上述的需要再次计算的“第99项”,就叫“重叠子问题”。如果没有计算过,就按照递推式计算,如果计算过,直接使用,就像“缓存”一样,这种方法,叫做“记忆化”,这是递推式求解的技巧。这种技巧,通俗的说叫“花费空间来节省时间”。都不是动态规划的本质,不是动态规划的核心。

b. “递归”:

递归是递推式求解的方法,连技巧都算不上。(自顶向下的方式)

c. “无后效性“,“最优子结构”:

上述的状态转移方程中,等式右边不会用到下标大于左边i或者k的值,这是”无后效性“的通俗上的数学定义,符合这种定义的状态定义,我们可以说它具有“最优子结构”的性质,在动态规划中我们要做的,就是找到这种“最优子结构”。
在对状态和状态转移方程的定义过程中,满足“最优子结构”是一个隐含的条件(否则根本定义不出来)。

六、动态规划解决方法辨析

“自顶向下”的实现方式 和 “自低向上”的实现方式各有什么优缺点,我的理解如下。

两种方法的取舍我个人的喜好是——优先选择Top-Down dynamic programming,除非不容易得到递归公式或空间复杂度无法接受。

“自顶向下”(top-down dynamic programming):

1.能方便的得到递归公式,并用递归函数实现;

2.保持了递归实现的代码结构,逻辑上容易理解;

3.过程中只计算需要计算的子结果;

4.当采用了caching技术时多次被调用时天然的复用之前被调用时的子结果。(比如连续两次计算fibonacci数F(4), F(5),则计算F(5)时已知F(3)和F(4),直接相加即可);

“自底向上”(bottom-up dynamic programming):

1.需要设计数据结构来完成自底向上的计算过程。逻辑上相对不那么直观;

2.常常可以进行空间复杂度的优化。比如Fibonacci数列可以优化为只使用两个变量的额外存储空间,0-1背包问题可以优化为O(n)的空间复杂度;

七、对动态规划的总结性理解

(1)“动态规划是什么”?

动态规划是递归(很多时候这种说法并不对,但是对于入门,可以暂时这么理解),是缓存,是用空间来换取时间;

但是,如果仅仅知道这些,你还是发现无法设计动态规划的算法。

因为你慢慢会发现,有些问题用动态规划和递归都能求解,但是动态规划的速度会更慢。

于是有人说了,动态规划题目的特征在于最优子结构重叠子问题——这就涉及到下一个问题(其实对于初学者来书,判断重叠子问题和最优子结构是不容易的,这部分就不是用文字能够使人明白的了。需要结合实例,分析才行)

(2)为什么需要使用动态规划?

在初等算法中,算法设计的思路一般如下,首先尝试穷举法;然而如何穷举?

此时往往要用到分治法——而归递,在绝大多数时候仅仅是分治法的一种表现形式而已;

在递归和分治法的基础上,往往会用动态规划来优化——动态规划,实际上是一种升级版的分治法。

当然,不是所有的穷举都能使用分治法;不是所有的分治法都能优化成动态规划。此时,就是上文提到的:只有一个问题是可分的,才可以使用分治法;只有分治出来的子问题有重叠,才可以使用DP;只有子问题具有最优子结构,DP才具有意义

另外dp主要解决重叠子问题问题,遇到重叠的子问题不需要再次计算。分治是把一个问题分解成若干个互不干扰的子问题,解决了子问题就解决了知道大的问题。所以才有了dp比较消耗空间的说法,因为要记录子问题的解。所以才出现了各种压缩空间的做法。如果不记录子问题的话dp就变成了搜索。 (在我的理解里,搜索其实就是一种特殊的动态规划,虽然效率很怎么样。但是暴搜一定能得到解,并且思路来的要比动态规划痛快一些。)

八、刷题之路

1、有一段楼梯有10级台阶,规定每一步只能跨一级或两级,要登上第10级台阶有几种不同的走法?

分析:很显然,这道题的对应的数学表达式是

F(n)=F(n-1) + F(n-2);      其中F(1)=1, F(2)=2

很自然的状况是,采用递归函数来求解:

  int  solution(int n)
  {  
     if(n>0 && n<2) 
       return n;  
     return solution(n-1) + solution(n-2);  
  }  

如果我们计算F(10), 先需要计算F(9) F(8); 但是我们计算F(9)的时候,又需要计算F(8),很明显,F(8)被计算了多次,存在重复计算;同理F(3)被重复计算的次数就更多了。算法分析与设计的核心在于 根据题目特点,减少重复计算。 在不改变算法结构的情况下,我们可以做如下改进:


  int dp[11];  
  int  solution(int n)
  {  
      if(n>0 && n<2) 
        return n;  
      if(dp[n]!=0) 
        return dp[n];  
      dp[n] = solution(n-1) + solution(n-2);  
      return  dp[n];  
  }  

这是一种递归形似的写法,进一步,我们可以将递归去掉:

  int  solution(int n)
  {  
      int dp[n+1];  
      dp[1]=1;dp[2]=2;  
      for (i = 3; i <= n; ++i)
        dp[n] = dp[n-1] + dp[n-2];   
      return  dp[n];  
  }  

当然,我们还可以进一步精简,仅仅用两个变量来保存前两次的计算结果; 这个算法留待读者自己去实现

2、如下图(图片来自百度图片)是一个数塔,从顶部出发在每一个节点可以选择向左或者向右走,一直走到底层,要求找出一条路径,使得路径上的数字之和最大.

这里写图片描述

数塔问题

思路分析:
这道题目如果使用贪婪算法不能保证找到真正的最大和。
在用动态规划考虑数塔问题时可以自顶向下的分析,自底向上的计算。
从顶点出发时到底向左走还是向右走应取决于是从左走能取到最大值还是从右走能取到最大值,只要左右两道路径上的最大值求出来了才能作出决策。同样的道理下一层的走向又要取决于再下一层上的最大值是否已经求出才能决策。这样一层一层推下去,直到倒数第二层时就非常明了。
所以第一步对第五层的8个数据,做如下四次决策:

如果经过第四层2,则在第五层的19和7中肯定是19;

如果经过第四层18,则在第五层的7和10中肯定是10;

如果经过第四层9,则在第五层的10和4中肯定是10;

如果经过第四层5,则在第五层的4和16中肯定是16;

经过一次决策,问题降了一阶。5层数塔问题转换成4层数塔问题,如此循环决策…… 最后得到1阶的数塔问题。

算法实现:首先利用一个二维数组data存储数塔的原始数据(其实我们只使用数组data一半的空间,一个下三角矩阵),然后利用一个中间数组dp存储每一次决策过程中的结果(也是一个下三角矩阵)。初始化dp,将data的最后一层拷贝到dp中。dp[n][j] = data[n][j] (j = 1, 2, …, n) 其中,n为数塔的层数。再动态规划过程汇总,我们有

      dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + data[i][j])

最后的结果保存在dp[0][0]中。
对于上面的数塔,我们的data数组如下:

9
12    15
10    6     8
2     18    9     5
19    7     10    4   16

而我们的dp数组如下:

 59
 50   49
 38   34    29
 21   28    19    21
 19   7     10    4    16

。。。未完待续

相关推荐
©️2020 CSDN 皮肤主题: 精致技术 设计师:CSDN官方博客 返回首页