动态规划入门到熟悉,看不懂来打我啊
动态规划入门到熟悉,看不懂来打我啊
2019-09-06 19:05:22 兔子hebtu666 阅读数 9594 收藏 更多
分类专栏: 动态规划
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/hebtu666/article/details/100585136
2.1斐波那契系列问题
2.2矩阵系列问题
2.3跳跃系列问题
3.1 01背包
3.2 完全背包
3.3多重背包
3.4 一些变形选讲
2.1斐波那契系列问题
在数学上,斐波纳契数列以如下被以递归的方法定义:F(0)=0,F(1)=1, F(n)=F(n-1)+F(n-2)(n>=2,n∈N*)根据定义,前十项为1, 1, 2, 3, 5, 8, 13, 21, 34, 55
例1:给定一个正整数n,求出斐波那契数列第n项(这时n较小)
解法一:完全抄定义
-
def f(n):
-
if n==1 or n==2:
-
return 1
-
return f(n-1)+f(n-2)
分析一下,为什么说递归效率很低呢?咱们来试着运行一下就知道了:
比如想求f(10),计算机里怎么运行的?
想算出f(10),就要先算出F(9),
想算出f(9),就要先算出F(8),
想算出f(8),就要先算出F(7),
想算出f(7),就要先算出F(6)……
兜了一圈,我们发现,有相当多的计算都重复了,比如红框部分:
那如何解决这个问题呢?问题的原因就在于,我们算出来某个结果,并没有记录下来,导致了重复计算。那很容易想到如果我们把计算的结果全都保存下来,按照一定的顺序推出n项,就可以提升效率
解法2:
-
def f1(n):
-
if n==1 or n==2:
-
return 1
-
l=[0]*n #保存结果
-
l[0],l[1]=1,1 #赋初值
-
for i in range(2,n):
-
l[i]=l[i-1]+l[i-2] #直接利用之前结果
-
return l[-1]
可以看出,时间o(n),空间o(n)。
继续思考,既然只求第n项,而斐波那契又严格依赖前两项,那我们何必记录那么多值浪费空间呢?只记录前两项就可以了。
解法3:
-
def f2(n):
-
a,b=1,1
-
for i in range(n-1):
-
a,b=b,a+b
-
return a
补充:
- pat、蓝桥杯等比赛原题:求的n很大,F(N)模一个数。应每个结果都对这个数取模,否则:第一,计算量巨大,浪费时间;第二,数据太大,爆内存,
- 对于有多组输入并且所求结果类似的题,可以先求出所有结果存起来,然后直接接受每一个元素,在表中查找相应答案
- 此题有快速幂算法,但是碍于篇幅和同学们水平有限,不再叙述,可以自行学习。
例2:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
依旧是找递推关系:
1)跳一阶,就一种方法
2)跳两阶,它可以一次跳两个,也可以一个一个跳,所以有两种
3)三个及三个以上,假设为n阶,青蛙可以是跳一阶来到这里,或者跳两阶来到这里,只有这两种方法。
它跳一阶来到这里,说明它上一次跳到n-1阶,
同理,它也可以从n-2跳过来
f(n)为跳到n的方法数,所以,f(n)=f(n-1)+f(n-2)
优化思路与例1类似,请自行思考。
例3:我们可以用2*1的小矩形横着或者竖着去覆盖更大的矩形。请问用n个2*1的小矩形无重叠地覆盖一个2*n的大矩形,总共有多少种方法?
N=1: 只有一种
N=2,两种:
N=3:
读到这里,你们应该能很快想到,依旧是斐波那契式递归啊。
对于n>=3:怎么能覆盖到三?
只有两种办法,从n-1的地方竖着放了一块,或者从n-2的位置横着放了两块
例4:给定一个由0-9组成的字符串,1可以转化成A,2可以转化成B。依此类推。。25可以转化成Y,26可以转化成z,给一个字符串,返回能转化的字母串的有几种?
比如:123,可以转化成
1 、2 、3变成ABC,
12 、3变成LC,
1 、23变成AW
三种,返回三,
比如99999,就一种:iiiii,返回一。
分析:求i位置及之前字符能转化多少种。
两种转化方法
1)字符i自己转换成自己对应的字母
2)和前面那个数组成两位数,然后转换成对应的字母
假设遍历到i位置,判断i-1位置和i位置组成的两位数是否大于26,大于就没有第二种方法,f(i)=f(i-1),如果小于26, f(i)=f(i-1)+f(i-2)
2.2矩阵系列问题
例5:给一个由数字组成的矩阵,初始在左上角,要求每次只能向下或向右移动,路径和就是经过的数字全部加起来,求可能的最小路径和。
1 3 5 9
8 1 3 4
5 0 6 1
8 8 4 0
路径:1 3 1 0 6 1 0路径和最小,返回12
分析:我们可以像之前一样,暴力的把每一种情况都试一次,但是依旧会造成过多的重复计算,以本题为例子最后解释一下暴力慢在哪里,以后不再叙述了。
比如本题来讲,我们尝试如下路径:
有很多路是重复走过的一遍。
再进一步说:
从1到6位置,有很多路可以走,直观感受一下:
所有路中,一定会有和最小的,但是我们并不知道,每次尝试一次1->6->终点的路线时,我们把所有的情况都算了一遍,这过程中我们浪费了相当多的有效信息。
这就是暴力的结果。
优化做法:生成和矩阵相同大小的二维表,用来记录到起点每个位置的最小路径和
接下来带着大家真正进入动态规划;
第一步:初始化(对于本题来说,第一列和第一行,我们别无选择,就一条路,因此,我们可以直接确定答案)
第二步:确定其余位置如何推出(我们称为状态转移方程)
直观来说,每个位置只可能是从上面,或者左边走来的:
对于普遍的位置i,j,只有i-1,j和i,j-1这两个位置可以一步走到这里,所以
DP[i,j]=min(DP[i,j-1],DP[i-1,j])+L[i,j](之前的最优解加上本位置的数字)
继续优化:和之前一样,这个式子实际上也是严格依赖两个值,一个是左边的值,一个是上面的值,所以,我们按之前的思路,应该可以想到可以压缩空间。
我们尝试用一维的空间来解题:
想象这是我们的第一行答案:
我们如何利用仅有的一维空间来更新出下一行呢?
我们要想:
- 我们需要左面的数字,所以,本位置的左边必须是更新过的数字(否则就是左上的位置了),所以应该从左往右更新。
- 我们需要上面的数字,这个不需要更新,本来就需要本位置的旧数字。
本题第二行为:8,1,3,4
第一行答案为
依次更新:
更新A:
(只能向下走)
更新B:
(比较从左边来和从上面来哪里比较小)
更新C:
更新D:
最后我们可以发现,伪代码是这样的:
For i 0 -> 高度:
For j 0 -> 宽度
DP[j]=min(DP[j-1],DP[j])+L[i,j]
时间不变,空间优化到o(min(高,宽))
例6:给一个由数字组成的矩阵,初始在左上角,要求每次只能向下或向右移动,路径和就是经过的数字全部加起来,求可能的最大路径和。
和例5只差一个“大”字,请自己思考
例7:一个矩阵,初始在左上角,要求每次只能向下或向右移动,求到终点的方法数。
和例5,6类似,只是方法数应该等于,左边的方法数加上上面的方法数
第二章末练习
1
一个只包含'A'、'B'和'C'的字符串,如果存在某一段长度为3的连续子串中恰好'A'、'B'和'C'各有一个,那么这个字符串就是纯净的,否则这个字符串就是暗黑的。例如:
BAACAACCBAAA 连续子串"CBA"中包含了'A','B','C'各一个,所以是纯净的字符串
AABBCCAABB 不存在一个长度为3的连续子串包含'A','B','C',所以是暗黑的字符串
你的任务就是计算出长度为n的字符串(只包含'A'、'B'和'C'),有多少个是暗黑的字符串。(网易17校招原题)
2、X国的一段古城墙的顶端可以看成 2*N个格子组成的矩形(如下图所示),现需要把这些格子刷上保护漆。
你可以从任意一个格子刷起,刷完一格,可以移动到和它相邻的格子(对角相邻也算数),但不能移动到较远的格子(因为油漆未干不能踩!)
比如:a d b c e f 就是合格的刷漆顺序。
c e f d a b 是另一种合适的方案。
当已知 N 时,求总的方案数。当N较大时,结果会迅速增大,请把结果对 1000000007 (十亿零七) 取模。
3.1 01背包
入门了动态规划之后,我们来看一个经典系列问题:背包问题
这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。
用子问题定义状态:
f[i][j]表示前i件物品恰放入一个容量为j的背包可以获得的最大价值。则其状态转移方程为:
“将前i件物品放入容量为j的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只牵扯前i−1件物品的问题。
如果不放第i件物品,那么问题就转化为“前i−1件物品放入容量为j的背包中”,价值为f[i−1][j];
如果放第i件物品,那么问题就转化为“前i−1件物品放入剩下的容量为j−c[i]的背包中”,此时能获得的最大价值就是f[i−1][j−w[i]],再加上通过放入第i件物品获得的价值v[i]。
因此得出上面的式子。
继续优化空间(利用之前提到的知识):
如果我们压缩到一维空间解题,这次我们需要的是上面的位置和左上的位置,也就是说,我们需要左边的位置是没被更新过的,得出更新顺序应该从右往左:
for i in range(1,n+1):
for j in range(v,-1,-1)
f[j] = max(f[j], f[j - w[i]] + v[i]);
3.2 完全背包
这个问题非常类似于01背包问题,所不同的是每种物品有无限件。也就是从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取0件、取1件、取2件……等很多种。如果仍然按照解01背包时的思路,很容易得出:
这跟01背包问题一样有O(VN)个状态需要求解,但求解每个状态的时间已经不是常数了
而是,总的复杂度可以认为是,将01背包问题的基本思路加以改进,得到了这样一个清晰的方法。这说明01背包问题的方程的确是很重要,可以推及其它类型的背包问题。但我们还是试图改进这个复杂度。
我们可以知道,对于一个普遍位置w,当前物品代价为2的话,下图中红色区域就是和位置w的取值相关的一些数值:
对当前物品的决策就依次是:不拿、拿一个、拿两个、拿三个(对应上面式子中的k)
我们算法优化的思路就是不断去除重复计算,显然我们可以继续优化这个式子。
请思考:我们的E3位置是如何得出的?其实是根据三个红色区域得出的,但是我们算位置w时又算了一遍,显然是重复了。而E3其实包含了不拿、拿一个、拿两个这些情况中的最优解,我们算w时直接用就可以了。
给出模板代码:
for (int i = 1; i <= n; i++)
for (int j = w[i]; j <= V; j++)
f[j] = max(f[j], f[j - w[i]] + v[i]);
对比两种背包:
这个代码与01背包的代码只有j的循环次序不同而已。为什么这样一改就可行呢?
首先想想为什么01背包中要按照j=V...0 j=V...0j=V...0的逆序来循环。这是因为要保证第i次循环中的状态f[i][j]是由状态f[i−1][j−w[i]]递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第i件物品”这件策略时,依据的是一个绝无已经选入第i件物品的子结果f[i−1][j−w[i]]。
而现在完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第i ii种物品”这种策略时,却正需要一个可能已选入第i种物品的子结果f[i][j−w[i]],所以就可以并且必须采用j=0...V j=0...Vj=0...V的顺序循环。这就是这个简单的程序为何成立的道理。
最终给出状态转移方程给不明白的同学看:
(也可以通过数学导出此式)
3.3多重背包
和之前的背包不同,每种物品不是只有一件,也不是有无限件,这次的每种物品的数量都是有限制的,我们对于每种物品,可以选择拿一件、两件……p[i]件。
我们借用上一种问题的图:
看起来是类似的,位置w依旧和红色区域相关,但是我们可以直接根据E3来求出位置w吗?是不能的,因为条件变了,每种物品不是无限的,可能在w位置,图中椭圆圈出的位置代表着需要拿三个,但是如果规定最多拿两个,我们这种算法就出问题了。
一种做题思路:把每个物品都按01背包做:比如第i种物品,我们就按有p[i]件相同的物品。每一种物品都是如此,按01背包做就可以了。(但是显然很蠢)
改进:
我们平时买东西时,难道带的全是一元的硬币吗?当然不是,只要手中的钱可以凑出商品的价格即可,比如9元的东西,我不一定用九个硬币(背包问题的物品)来付钱,可以5元+4个1元。
背包问题也一样,我们不一定要全部拆成1的物品,只要我们的物品可以代表0——>p[i]的所有情况,我们就认为这种策略是正确的。
那如何拆p[i]个物品可以保证我们的物品可以代表0——>p[i]的所有情况呢?这里要借助2进制思想。
一个n位的二进制数可以取0到2的n次方-1,第i位代表的是2的i-1次方。
对应到物品:
我们的p[i]=15,我们怎样拆呢?
1+2+4+8即可,这四个数一定可以组合出0-15的任何一个数。
二进制拆分代码如下:
for (int i = 1; i <= n; i++) {
int num = min(p[i], V / w[i]);
for (int k = 1; num > 0; k <<= 1) {
if (k > num) k = num;
num -= k;
for (int j = V; j >= w[i] * k; j--)
f[j] = max(f[j], f[j - w[i] * k] + v[i] * k);
}
}
3.4 一些变形选讲
1)最常见的一些变形,甚至不能说是变形,上面也提到过,但是怕同学们不知道:
我们常见的问题中,一般是问最优解,可能是最大,或者最小,但是,问题也可能是方法的数量,这个时候,一般把状态转移方程中的max(min)改为sum(求和)即可,当然,压缩空间后的样子还是需要自己写。
2)初始化的细节问题
我们看到的求最优解的背包问题题目中,事实上有两种不太相同的问法。有的题目要求"恰好装满背包"时的最优解,有的题目则并没有要求必须把背包装满。这两种问法的区别是在初始化的时候有所不同。
如果是第一种问法,要求恰好装满背包,那么在初始化时除了f[0]为0其它f[1...V]均设为−∞,这样就可以保证最终得到的f[N]是一种恰好装满背包的最优解。
如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将f[0...V]全部设为0。
为什么呢?可以这样理解:初始化的f数组事实上就是在没有任何物品可以放入背包时的合法状态。如果要求背包恰好装满,那么此时只有容量为0的背包可能被价值为0的nothing “恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,它们的值就都应该是
−∞了。如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。
3)常数优化
前面的代码中有for(j=V...w[i]),还可以将这个循环的下限进行改进。
由于只需要最后f[j]的值,倒推前一个物品,其实只要知道f[j−w[n]]即可。以此类推,对以第j个背包,其实只需要知道到f[j−sumw[j...n]]即可,代码自行修改。
4)其实拆解二进制物品并不是多重背包的最优解,但是最优的单调队列思想写起来有些繁琐,可能以后会写。
可以刷的题
鉴于有一些同学说简单,我把去年写的一些题解放在这里:
皇后问题(位运算)
旅行商问题(认识状态压缩)
HDU1029 HDU1087 HDU1176 HDU1257 POJ1458
POJ2533 HDU1114 HDU1260 HDU1160
POJ1189 UVA12511 HDU2845 HBCPC2018 K