算法思想篇(下) | 带你把 动态规划 吃的透透的

Posted 书伟认视界

tags:

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



人就是三维空间的生物,所以处理三维及以下的事物都得心应手,一旦遇到三维以上的东西,脑子就不灵光了。

This browser does not support music or audio playback. Please play it in WeChat or another browser.

作者:书伟
时间:2020/7/31




上一篇文章里讲了算法的三种思想“贪心、分治、回溯”入门,还有一种比较难掌握的算法思想叫“动态规划”,所以需要用一整篇文章来带你入门动态规划。
官方解释动态规划(Dynamic programming,简称DP)只有一句话:
动态规划,就是通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
从概念上看,基本思想很简单,熟话说“大事化小,小事化了”就是这般了。然鹅,任何大道至简的东西,有哪个又是容易的呢。
首先,如果你不太了解回溯算法的话,最好可以花几分钟先去看一下上篇文章( )最后写的回溯算法部分,预热一下,不然后面东西看的可能会有点懵。
回溯算法解决问题的方式其实就是“穷举”,时间复杂度是指数级别。继续用上篇那个“0-1背包”问题来讲。
问题:有一个背包,总承载重量是Wkg。现有n个物品,物品重量不全相等,且不可分割。问在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
前面讲了,背包问题的解空间是树的形式。假设W=9kg,4个物品重量表示为 items=[2,3,2,4] 下面具体把这棵递归树画出来看看,其中递归树中的每个节点f(i,cw)表示一种状态,i表示第几个物品,cw表示当前背包中的物品重量。如,f(1,2)表示第一个物品装入背包,总重量是2kg;f(1,0)表示第一个物品不装入背包,背包中此时的重量是0kg。左子树表示装入背包,右子树表示不装入背包。
算法思想篇(下) | 带你把 动态规划 吃的透透的
。观察上面这棵递归树,可以发现需要把所有情况都穷举完之后才能知道背包中总重量最大时候的情况,显然时间复杂度是 。然而这些情况当中有很多状态是被计算了多次的(同一颜色标注的那些)。
那如何解决这种冗余计算问题并减小时间复杂度呢?就是接下来说的动态规划了。当然,利用哈希表记录每个结果也是可以的,感兴趣的朋友自己琢磨下。
n个物品是否要放入背包,看成是n个不同阶段的决策过程(如同递归树中的每一层)。每个阶段中,只记录不同的状态,然后合并重复的状态。下一层的状态只需要根据当前层的状态来确定,与前面阶段的状态无关。这样的话,每一层/阶段的状态数最多是就是W。
具体做法如下:
利用一个二维数组 states[n][w+1]  来记录每层可以达到的不同状态。其中行表示背包中的重量,列表示物品个数,如果某个物品装入背包就标记为1(True),不装入背包就标记为0(默认状态就是False)。(注:数组下标从0开始算,所以states[0][]表示第一个物品)
算法思想篇(下) | 带你把 动态规划 吃的透透的
第一个物品放入或不放入背包,背包重量的状态有两种,要么是0要么是2。也就是说 states[0][0] 和 states[0][2] 都等于True。
算法思想篇(下) | 带你把 动态规划 吃的透透的
第二个物品在基于第一个物品的状态之上决策,有4种不同的状态(0+0、0+3、2+0、2+3),分别标记为1。
算法思想篇(下) | 带你把 动态规划 吃的透透的
第三个物品在基于第二个物品的状态之上决策,有6种不同的状态(所有状态:0+0、0+2、2+0、2+2、3+0、3+2、5+0、5+2,有两种状态重复),分别标记为1。
算法思想篇(下) | 带你把 动态规划 吃的透透的
第四个物品在基于第三个物品的状态之上决策,有9种不同的状态(所有状态:0+0、0+4、2+0、2+4、3+0、3+4、4+0、4+4、5+0、5+4、7+0、7+4(>9 不标记),有两种状态重复),分别标记为1。
算法思想篇(下) | 带你把 动态规划 吃的透透的
其中被标记的最大不超过9的数就是9。
看一下python的代码实现,或许更明白,重点看动态决策的部分。另外,输出最大不超过W的值要从最后一列开始,逐列往前查找。
 
   
   
 
def bag_dp(items, n, w):
   """
  items:物品重量列表
  n:物品个数
  w:背包允许的最大重量
  """
   states = [[0 for i in range(n)] for j in range(w+1)]   # 初始化一个状态表
   
   # 后一层状态要根据前一层状态来决策,所以还要初始化第一行
   states[0][0] = True
   states[0][items[0]] = True
   
   # 依次动态的对每一层进行决策
   for i in range(1,n):
       # 第i个物品不放入背包
       for j in range(w+1):
           if states[i-1][j] = True  states[i][j]=states[i-1][j]
       # 第i个物品放入背包
       for j in range(w+1):
           if states[i-1][j] = True  states[i-1][j] + items[i] = True
   # 输出最大不超过背包允许重量的值
   for i in range(w,-1,-1):
       if states[n-1][i] = True  return i
分析完代码你就应该都这道题是怎么操作的了,这就是动态规划的思想。记录所有可能的状态,把大问题分阶段/层,利用当前层的状态来推导下一层的状态,动态地实现。这就是“动态规划”。
另外,因为下一层状态的推导只需要用到当前层的状态,所以其实只需要一个一维数组就可以实现,这样可以大大减少空间复杂度,这里用二维数组是为了更好地说明问题。理解了之后,后面就不这么写了。毕竟一口吃不成一个胖子,但是胖子一定是一口一口吃出来的。
用一维数组实现的过程如下,不需要另外处理第i个物品不放入背包的情况,因为上一层的状态就相当于当前层的第i个物品不放入背包的情况。
 
   
   
 
def bag_DP(items, n, w):
   states = [0]*(w+1)
   states[0] = True
   states[items[0]] = True
   for i in range(1,n):
       for j in range(w-items[i]):
       # 若改成(range(w-items[i],-1,-1)),从后往前先计算大数,可避免重复计算问题
           if states[j] = True states[j+items[i]] = True
   for i in range(w,-1,-1) if states[i] = True retuen 1


到这里,动态规划并不是已经讲完了,而是才进入正轨,才准备进入高潮阶段。
我们要解决以下两个问题,才能算入门:
什么样的问题可以用动态规划来解?动态规划解题的套路/步骤又是什么?
先解决第一个问题。能用动态规划来解的题,要符合多阶段决策模型,也就是可以分成多阶段来解。首先该问题要有“边界”,这个很好理解,否则就会无穷递推下去。
其次必须要满足“最优子结构”,这个在概念在前面几个算法思想中也都有提到。通俗来讲,就是大问题的最优解包含子问题的最优解,利用子问题的最优解可以推导出大问题的最优解。在动态规划问题里,就是后面阶段的状态可以由前面阶段的状态找到。
回到前面的背包问题,具体来讲,背包最大承重W=9kg,4个物品的分别是【2,3,2,4】时,最优子结构有两个。一个是第1个物品不装入背包时,W=9kg针对对于剩余3个物品时的最优选择;另一个是第1个物品装入背包,W=9-2=7kg针对剩余3个物品时的最优选择。必须要考虑全这两种最优子结构的情况。
这两种最优子结构的解有重叠,结合起来才能得到整个问题的解。
然后,该问题本身有“重复子问题”,如前面画的那棵递归树,就是为了解决重复问题我们采用动态规划,否则就无需用动态规划了,这样浪费更多的空间(动态规划本质上还是以空间换时间的思想)。
接下来聊聊动态规划的解题套路。
解决动态规划问题的思路/技巧有两种:“状态转移表法”和“状态转移方程法”
  • 状态转移表法

再看下上面那段代码,看上去是不是感觉很简洁。首先要初始化状态,这其实就是定义初始边界。然后中间那两个for循环才是核心,如果直接没有前面的分析,直接看这个代码一般人也不太能看的懂,因为这两句代码是从前面那个表里“翻译”过来的。
在前面我们利用二维数组做的分析就是“状态转移表法”。画状态表的目的,其一是分析这个问题是否有重复子问题,需不需要用动态规划来解,另外就是寻找最优子结构之间的关系。这里把这个过程再总结一下。
画状态转移表之前最好画一下问题的递归树(直接利用递归树的实现方式其实就是回溯算法,这也是回溯算法的实现思想),可以方便写状态转移表。前面用的状态转移表是二维的,因为这里的背包问题只有两个自变量。如果自变量有很多,状态转移表可能是三维或者更高维,高维状态转移表不好画也不好分析,这时用状态转移方程来分析更好。
画状态转移表首先要定义边界或者初始条件,然后分析每个阶段的状态(最优子结构)之间的关系,填充状态转移表,并把该实现过程翻译成代码就完成了。
  • 状态转移方程法

所谓的状态转移方程其实就是找递推关系,这个递推关系就代表最优子结构直接的关系。对于那些容易用递推公式来表示的问题,用状态转移方程法更容易解决。但是动态规划问题就不好定义,所以递推关系本身就也就不容易得到。这可能需要做大量的题,慢慢找感觉。
这种方法是很容易理解的,接下里我用一个非常典型的例子来说明下。
台阶问题:有一座高度是 10 级台阶的楼梯,从下往上走,每跨一步只能向上 1 级或者 2 级台阶。要求用程序来求出一共有多少种走法。比如,每次走 1 级台阶,一共走 10 步,这是其中一种走法。我们可以简写成 1,1,1,1,1,1,1,1,1,1。再比如,每次走 2 级台阶,一共走 5 步,这是另一种走法。我们可以简写成 2,2,2,2,2。
算法思想篇(下) | 带你把 动态规划 吃的透透的
这个问题有多种解法,这里就说用动态规划怎么解。
由题意可知,要想走到第10级台阶上,要么从第8级台阶走要么从第9级台阶走。所以,假设我从下往上走到第8级台阶的走法有M种,走到第9级台阶的走法有N种,那么容易知道走到第10级台阶的走法就是M+N种。(注意,走法的数量不是走的步数)
由这个分析就容易知道,走到第10级台阶的最优子结构有两种,且两个最优子结构的关系是:F(10)=F(8)+F(9),这个方程在这里就是“状态转移方程”。那么就可以推导出如下递推关系:
F(1)=1;   F(2)=2;   F(n)=F(n-1)+F(n-2), (n>=3)
上面这个递推公式在这里就是“状态转移方程”,F(1)和F(2)就是边界条件。根据状态转移方程来看,当前状态只和前两个状态有关。
如果用递归树来分析的话,一定是有重复子问题的,不过这里直接用状态转移方程来写代码。如下所示:
 
   
   
 
def floor(n):
   if n<1 return 0
   if n==1 return 1
   if n==2 return 2
   
   #动态规划过程
   a,b,temp = 1,2,0
   for i in range(3,n+1):
       temp = a + b
       a = b
       temp = b
   return temp

上述代码中的for循环部分就是利用状态转移方程翻译过来的代码。
到这里,相信聪明的你已经掌握了这两种解决动态规划问题的思路了。


接下来,带大家再做一道0-1背包问题的升级版题目,重新回顾整个动态规划解题方法,最后大家一定会完全掌握动态规划这个鬼玩意儿。
背包问题升级版:
一组重量不全相同的物品,不同价值,且不可分割,满足背包最大重量限制的前提下,背包中物品的总价值最大是多少?
这个问题比开始说的那个0-1背包问题多考虑了一个价值因素。假设 items=[2,3,2,4]value=[3,6,4,2] 最大背包重量不超过9kg。首先来画递归树来分析一下重复子问题。其中的节点f(i,cw,cv)表示具体的某个状态,i表示第几个物品装还是不装如背包,cw表示当前背包中的总重量,cv表示当前背包中的物品总价值。
算法思想篇(下) | 带你把 动态规划 吃的透透的
可以看到,有很多重复子问题,比如第三层中的 f(3,5,9) 和 f(3,5,10) 这两个是重复计算的,所不同的是同一个物品同样的重量肯定只有一个价值,我们要求最大价值,说明 f(3,5,9) 才是最优的计算结果,所以处理这种重复子问题的时候,最后保留的是价值最大的那个。(重复指的是,同一个物品同样的重量)
再说一次,递归树的实现方式就是回溯算法的实现方法。
如果写状态转移表的画,和前面不同的是,不再是在二维数组中标记0或1,而是在二维表格中填写具体的价值。状态转移表如下:
算法思想篇(下) | 带你把 动态规划 吃的透透的
算法思想篇(下) | 带你把 动态规划 吃的透透的
算法思想篇(下) | 带你把 动态规划 吃的透透的
算法思想篇(下) | 带你把 动态规划 吃的透透的
算法思想篇(下) | 带你把 动态规划 吃的透透的
接下来把状态转移表的实现过程翻译成代码即可。
 
   
   
 
def bag_GP2(items, values, n, w):
   """
  items:物品重量列表
  values:物品价值列表
  n:物品个数
  w:背包允许的最大重量
  """
   # 初始化状态转移表
   states = [[-1 for i in range(n)] for j in range(w+1)]
   states[0][0] = 0
   states[0][items[0]] = values[0]
   
   # 动态规划过程
   for i in range(1,n):
       # 不选择第i个物品
       for j in range(w+1):
           if states[i-1][j] >= 0 states[i][j] = states[i-1][j]
       # 选择第i个物品
       for j in range(W+1):
           if states[i-1][j] >= 0:
               v = states[i-1][j] + values[i]
               if v > states[i-1][j]:
                   states[i][j+items[i]] = v
   # 从最后一行中找最大值
   maxValue = -1
   for i in range(w+1):
       if states[n-1][i] > maxValue maxValue=states[n-1][i]
   # pythonic一点的写法:return max(states[n-1])

上面还是用了一个二维数组来存储的,这样比较浪费空间。 因为同一重量的情况下,不同物品最后得到的价值可能不一样,后一阶段只需通过当前阶段来决策,所以可以直接申请两个一维数组就能解决这个问题(一个一维数组其实也可以,只不过看上去不是很好理解)。 而前面讲的那个背包问题不存在值变化的情况,所以用一个一维数组就可以解决。
下面代码我用节省空间的方式再实现一遍:
只有对同一个问题反复打磨,才能真正理解掌握。
 
   
   
 
def bag_DP2(items, values, n, w):
   preResult = [-1] * (w+1)
   result = [-1] * (w+1)
   preResult[0] = 0
   preResult[items[0]] = values[0]
   
   for i in range(1,n):
       # 不选择第i个物品
       for j in range(w+1):
           if result[j] < preResult[j]:
               result[j] = preResult[j]
       # 选择第i个物品
       for j in range(w+1):
           if preResult[j] >= 0:
               result[j+items[i]] = preResult[j]+values[i]
   return max(result)

到这里,动态规划的时间复杂度和空间复杂度应该大家能能看出来了,我再多啰嗦一句,时间复杂度是就主要是那两个嵌套for循环,所以是O(n*w),空间上就申请了一两个数组,所以是常数级别(w)。
以上就是全部的动态规划入门内容了,相信聪明的你一定学懂了,当然不刷题是不可能熟练的。
这也是本人《数据结构与算法》专栏的最后一篇文章,后续可能会写其他方面内容,欢迎大家来看。(“阅读原文”可获取代码)
最后祝大家学习愉快!





算法思想篇(下) | 带你把 动态规划 吃的透透的


欢迎加微信交流
算法思想篇(下) | 带你把 动态规划 吃的透透的

原创不易,您的 在看
就是支持我最大的动力!
算法思想篇(下) | 带你把 动态规划 吃的透透的算法思想篇(下) | 带你把 动态规划 吃的透透的算法思想篇(下) | 带你把 动态规划 吃的透透的算法思想篇(下) | 带你把 动态规划 吃的透透的

以上是关于算法思想篇(下) | 带你把 动态规划 吃的透透的的主要内容,如果未能解决你的问题,请参考以下文章

C++ 不知算法系列之初识动态规划算法思想

动态规划思想篇

算法之动态规划(递推求解一)

bzoj 3670 动物园 - kmp - 动态规划

从动态规划谈起

python_分治算法贪心算法动态规划算法