动态规划的理解

仅作为查找整合资料来看,以便工作时复习该专栏,参考的链接放在了最后,代码均基于python实现(原文是基于java实现的,有需要的直接点“参考自”)。

一、算法理论

动态规划算法的核心

理解一个算法就要理解一个算法的核心,动态规划算法的核心是下面的一张图片和一个小故事。
在这里插入图片描述

A * "1+1+1+1+1+1+1+1 =?" *

A : "上面等式的值是多少"
B : *计算* "8!"

A *在上面等式的左边写上 "1+" *
A : "此时等式的值为多少"
B : *quickly* "9!"
A : "你怎么这么快就知道答案了"
A : "只要在8的基础上加1就行了"
A : "所以你不用重新计算因为你记住了第一个等式的值为8!动态规划算法也可以说是 '记住求过的解来节省时间'"

由上面的图片和小故事可以知道动态规划算法的核心就是记住已经解决过的子问题的解。依次解决子问题,最后一个子问题就是初始问题的最优解。动态规划应用于子问题重叠的情况,子问题的划分是通过递归实现。为了避免子问题的重复计算,保证每个子问题只求解一次,会将解保存在数组中。
动态规划的问题一般有两个特征:(1)最优子结构。如果一个问题的最优解包含子问题的最优解,那么该问题就具有最优子结构;(2)重叠子问题。如果递归算法反复计算相同的子问题,那么该问题具有重叠子问题。如果在递归的每一步都生成新的子问题,那么要用分治法解决。

所以经常在刷题弄完了所有解的情况,或者递归实现发现超时了,多半都是动态规划求解。

动态规划的基本步骤如下:

1.刻画一个最优解的结构特征;

2.递归定义最优解的值;

3.计算最优解的值,通常采用自底向上的方法。

动态规划算法的两种形式

上面已经知道动态规划算法的核心是记住已经求过的解,记住求解的方式有两种:①自顶向下的备忘录法 ②自底向上
为了说明动态规划的这两种方法,举一个最简单的例子:求斐波拉契数列**Fibonacci **。先看一下这个问题:

Fibonacci (n) = 1;   n = 0

Fibonacci (n) = 1;   n = 1

Fibonacci (n) = Fibonacci(n-1) + Fibonacci(n-2)

先使用递归版本来实现这个算法:

def fib(n):
	if(n<=0)
		return 0;
	if(n==1)
		return 1;
	return fib(n-1)+fib(n-2);

# 输入6
# 输出:8

先来分析一下递归算法的执行流程,假如输入6,那么执行的递归树如下:
在这里插入图片描述
上面的递归树中的每一个子节点都会执行一次,很多重复的节点被执行,fib(2)被重复执行了5次。由于调用每一个函数的时候都要保留上下文,所以空间上开销也不小。这么多的子节点被重复执行,如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间。

下面就看看动态规划两种方法怎样来解决斐波拉契数列Fibonacci数列问题。

1.自顶向下的备忘录法

import numpy as np
def Fibonacci(n):
    if(n <= 0):
        return n
    Memo = np.zeros((n+1,),dtype=int) # 创建一个n+1长度的一维数组,下标即为第几个斐波拉契数,0位置是0
    return fib(n, Memo)
def fib(n, Memo):
    if(Memo[n] != 0):  # 前面的已经计算出来了的(重点部分,可以断点调试走一下)
        return Memo[n]
    # 如果已经求出了fib(n)的值直接返回,否则将求出的值保存在Memo备忘录中。
    if(n<=2):
        Memo[n]=1
        return 1
    else:
        Memo[n] = fib(n-1, Memo) + fib(n-2, Memo)
        return Memo[n]
res = Fibonacci(6) # Memo[n]=[0,1,1,2,...] # 下标为3的是2
print(res)

备忘录法:创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fib(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在Memo数组中,下次在调用fib(n)的时候就不会重新递归了。比如上面的递归树中在计算fib(6)的时候先计算fib(5),调用fib(5)算出了fib(4)后,fib(6)再调用fib(4)就不会在递归fib(4)的子树了,因为fib(4)的值已经保存在Memo[4]中。

2.自底向上的动态规划

备忘录法还是利用了递归,上面算法不管怎样,计算fib(6)的时候最后还是要计算出fib(1),fib(2),fib(3),只不过算其他子树的时候已经递归过的不用重复算了…,那么何不先计算出fib(1),fib(2),fib(3)…,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。

def fib(n):
	if(n <= 0):
		return 0
	Memo = np.zeros((n+1,))
	Memo[0] = 0
	Memo[1] = 1
	for i in range(2,n+1):
		Memo[i] = Memo[i-1] + Memo[i-2]
	return Memo[n]

自底向上方法也是利用数组保存了先计算的值,为后面的调用服务。观察参与循环的只有 i,i-1 , i-2三项,因此该方法的空间可以进一步的压缩如下。

def fib(n):
	if(n<=1):
		return n;
		int Memo_i_2 = 0
		int Memo_i_1 = 1
		int Memo_i = 1
		for i in range(2,n+1): # 2~n
			Memo_i=Memo_i_2+Memo_i_1
			Memo_i_2=Memo_i_1
			Memo_i_1=Memo_i
		return Memo_i


一般来说由于备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销,使用自底向上的动态规划方法要比备忘录方法好。

例子——钢条切割

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述在这里插入图片描述

  • 法1,递归
    先把分成i节,和剩下的n-i结
def cut(p, n):
	if n == 0:
		return 0
	q = float("-inf")
	for i in range(1, n+1):
		q = Math.max(q, p[i-1]+cut(p, n-i));
	return q

递归很好理解,如果不懂可以看上面的讲解,递归的思路其实和回溯法是一样的,遍历所有解空间但这里和上面斐波拉契数列的不同之处在于,在每一层上都进行了一次最优解的选择,q=Math.max(q, p[i-1]+cut(p, n-i));这个段语句就是最优解选择,这里上一层的最优解与下一层的最优解相关。

  • 法2,备忘录版本
import numpy as np
def cutMemo(p):
	r = np.zeros((len(p)+1,))
	return cut(p, len(p), r)
def cut(p, n, r):
	q = 1
	if(r[n] >= 0):
		return r[n]
	if(n == 0):
		q = 0
	else:
		for i in range(1, n+1):
			q = max(q, cut(p, n-i, r) + p[i-1])
	r[n] = q
	return q

有了上面求斐波拉契数列的基础,理解备忘录方法也就不难了。备忘录方法无非是在递归的时候记录下已经调用过的子函数的值。这道钢条切割问题的经典之处在于自底向上的动态规划问题的处理,理解了这个也就理解了动态规划的精髓。

  • 法3,自底向上的动态规划
def buttom_up_cut(p):
	r = np.zeros((len(p)+1,))
	for i in range(1, len(p)+1):
		q = -1
		for j in range(1, i+1):
			q = max(q, p[j-1] + r[i-j])
		r[i] = q
	return r[p.length]

自底向上的动态规划问题中最重要的是理解注释①处的循环,这里外面的循环是求r[1],r[2]…,里面的循环是求出r[1],r[2]…的最优解,也就是说r[i]中保存的是钢条长度为i时划分的最优解,这里面涉及到了最优子结构问题,也就是一个问题取最优解的时候,它的子问题也一定要取得最优解。下面是长度为4的钢条划分的结构图。
在这里插入图片描述
①最优子结构

用动态规划求解最优化问题的第一步就是刻画最优解的结构,如果一个问题的解结构包含其子问题的最优解,就称此问题具有最优子结构性质。因此,某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个很好的线索。使用动态规划算法时,用子问题的最优解来构造原问题的最优解。因此必须考查最优解中用到的所有子问题。

②重叠子问题

在斐波拉契数列和钢条切割结构图中,可以看到大量的重叠子问题,比如说在求fib(6)的时候,fib(2)被调用了5次,在求cut(4)的时候cut(0)被调用了4次。如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。

动态规划的经典模型

线性模型

线性模型的是动态规划中最常用的模型,上文讲到的钢条切割问题就是经典的线性模型,这里的线性指的是状态的排布是呈线性的。【例题1】是一个经典的面试题,我们将它作为线性模型的敲门砖。

【例题1】在一个夜黑风高的晚上,有n(n <= 50)个小朋友在桥的这边,现在他们需要过桥,但是由于桥很窄,每次只允许不大于两人通过,他们只有一个手电筒,所以每次过桥的两个人需要把手电筒带回来,i号小朋友过桥的时间为T[i],两个人过桥的总时间为二者中时间长者。问所有小朋友过桥的总时间最短是多少。
每次过桥的时候最多两个人,如果桥这边还有人,那么还得回来一个人(送手电筒),也就是说N个人过桥的次数为2*N-3(倒推,当桥这边只剩两个人时只需要一次,三个人的情况为来回一次后加上两个人的情况…)。有一个人需要来回跑,将手电筒送回来(也许不是同一个人,realy?!)这个回来的时间是没办法省去的,并且回来的次数也是确定的,为N-2,如果是我,我会选择让跑的最快的人来干这件事情,但是我错了…如果总是跑得最快的人跑回来的话,那么他在每次别人过桥的时候一定得跟过去,于是就变成就是很简单的问题了,花费的总时间:

T = minPTime * (N-2) + (totalSum-minPTime)

来看一组数据 四个人过桥花费的时间分别为 1 2 5 10,按照上面的公式答案是19,但是实际答案应该是17。

具体步骤是这样的:

第一步:1和2过去,花费时间2,然后1回来(花费时间1);
第二歩:3和4过去,花费时间10,然后2回来(花费时间2);
第三部:1和2过去,花费时间2,总耗时17。

所以之前的贪心想法是不对的。我们先将所有人按花费时间递增进行排序,假设前i个人过河花费的最少时间为opt[i],那么考虑前i-1个人过河的情况,即河这边还有1个人,河那边有i-1个人,并且这时候手电筒肯定在对岸,所以opt[i] = opt[i-1] + a[1] + a[i] (让花费时间最少的人把手电筒送过来,然后和第i个人一起过河)如果河这边还有两个人,一个是第i号,另外一个无所谓,河那边有i-2个人,并且手电筒肯定在对岸,所以opt[i] = opt[i-2] + a[1] + a[i] + 2a[2] (让花费时间最少的人把电筒送过来,然后第i个人和另外一个人一起过河,由于花费时间最少的人在这边,所以下一次送手电筒过来的一定是花费次少的,送过来后花费最少的和花费次少的一起过河,解决问题)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2a[2] }

解释:(上式的opt[i-1]表示已经过河的i-1个人的最优时间合,a[1]表示过河时间最少的那个人的时间,a[2]表示过河时间第二少的,a[i]表示未过河的那个任意人的过河时间,2a[2]是过河时间第二的那个人要和i过河再回来和a[1]的那个人过河)

区间模型

区间模型的状态表示一般为d[i][j],表示区间[i, j]上的最优解,然后通过状态转移计算出[i+1, j]或者[i, j+1]上的最优解,逐步扩大区间的范围,最终求得[1, len]的最优解。

【例题2】给定一个长度为n(n <= 1000)的字符串A,求插入最少多少个字符使得它变成一个回文串。
典型的区间模型,回文串拥有很明显的子结构特征,即当字符串X是一个回文串时,在X两边各添加一个字符’a’后,aXa仍然是一个回文串,我们用d[i][j]来表示A[i…j]这个子串变成回文串所需要添加的最少的字符数,那么对于A[i] == A[j]的情况,很明显有 d[i][j] = d[i+1][j-1] (这里需要明确一点,当i+1 > j-1时也是有意义的,它代表的是空串,空串也是一个回文串,所以这种情况下d[i+1][j-1] = 0);当A[i] != A[j]时,我们将它变成更小的子问题求解,我们有两种决策:

1、在A[j]后面添加一个字符A[i];

2、在A[i]前面添加一个字符A[j];

根据两种决策列出状态转移方程为:

d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1; (每次状态转移,因为不相等就始终会插入一个,区间长度增加1)

空间复杂度O(n2),时间复杂度O(n2), 下文会提到将空间复杂度降为O(n)的优化算法。

解释:d[i][j]表示的是在字符串A[i…j]从字符i到字符j构成的这个子串变成回文串所需要添加的最少的字符数。A[i] == A[j](也就是这个字符串首尾相同),那就往里剥,也就是i+1,j-1的这个子串的 d[i][j] = d[i+1][j-1] (即i变成i+1,j变成j-1),当这个子串首尾不等的时候,有两种插入方法,即首插或者尾插。

背包模型

背包问题是动态规划中一个最典型的问题之一。由于网上有非常详尽的背包讲解,这里只将常用部分抽出来。

【例题3】01背包问题:有N种物品(每种物品1件)和一个容量为V的背包。放入第 i 种物品耗费的空间是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。f[i][v]表示前i种物品恰好放入一个容量为v的背包可以获得的最大价值。决策为第i个物品在前i-1个物品放置完毕后,是选择放还是不放,状态转移方程为:

f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +w[i] }

时间复杂度O(VN),空间复杂度O(VN) (空间复杂度可利用滚动数组进行优化达到O(V) )。

解释

  • 对于前f[i][v]表示从前i个物品中选,选出的总体积小于j的最优解。可以从f[0][0]=0开始遍历,有N个物品,需要N次遍历,f[i][v]不断由之前的状态更新。
  • 对于某件物品,可以将所有状态分为两类,第一类不含i,第二类含i:
  • 第一类不含i,那就是在前i-1物品中选不超过v的最优价值为f[i-1][v],因为不含i,所以截止到第i件也是最优价值为f[i-1][v]。
  • 第二类为含该件物品i的,也就是含第i件物品体积也不能超v,加入第i件物品的体积是Ci,那么前i-1件物品的体积则不能超过v-Ci了,此时i-1件物品的价值约束是f[i-1][v-Ci],实际再加上第i件物品的价值w[i],最优选为f[i-1][v-Ci]+w[i];
    注意第二类只有在第i件物品的体积要不大于背包总体积v[i]<=v时才存在。

二、动态规划的题leecode

自己酌情按照能力由简单到复杂练吧
leecode动态规划

参考自

参考1

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值