动态规划
讲之前先举一个经典贪心例题
此题可以用贪心从面值高的硬币遍历,但只适合一些差距大的面值,若存在更多面值,例如
动态规划(DP)基础
type = [1, 5, 10, 25, 50] #5种面值
定义数组Min[ ] 记录最少硬币数量:
对输入的某个金额i,Min[i]是最少的硬币数量。
第一步:只考虑1元面值的硬币
i=1元时,等价于:i = i-1 = 0元需要的硬币数量,加上1个1元硬币。
其中把Min[]叫做“状态”,把Min[]的变化叫做“状态转移”
继续,所有金额仍然都只用1元硬币
- i=2元时,等价于:i = i-1 = 1元需要的硬币数量,加上1个1元硬币。
- i=3元时…
- i=4元时…
在1元硬币的计算结果基础上,再考虑加上5元硬币的情况。从i=5开始就行了:
i=5元时,等价于:
(1)i = i-5 = 0元需要的硬币数量,加上1个5元硬币。Min[5]=1。
(2)原来的Min[5]=5。
取(1)(2)的最小值,所以Min[5]=1。
- i=6元时,等价于:
(1)i = i - 5 = 1元需要的硬币数量,加上1个5元硬币。 Min[6] = 2
(2)原来的Min[6] = 6
取(1)(2)的最小值,所以Min[6] = 2 - i=7元时,…
- i=8元时,…
那么递推关系就可以得出:
Min[i] = min(Min[i],Min[i-5] + 1)
def solve(s):
Min = [int(1e12)]*(s+1) #初始化为无穷大
Min[0] = 0
for j in range(cnt): #5种硬币
for i in range(type[j],s+1):
Min[i] = min(Min[i], Min[i - type[j]] + 1)
print(Min[s])
cnt = 5 #5种硬币
type = [1, 5, 10, 25, 50] #5种面值
s = int(input())
solve(s)
一般的动态规划题状态数组名为dp[]
DP的两个特征
(1)重叠子问题。子问题是原大问题的小版本,计算步骤完全一样;计算大问题的时候,需要多次重复计算小问题。
一个子问题的多次计算,耗费了大量时间。用DP处理重叠子问题,每个子问题只需要计算一次,从而避免了重复计算,这就是DP效率高的原因。
(2)最优子结构。首先,大问题的最优解包含小问题的最优解;其次,可以通过小问题的最优解推导出大问题的最优解。
因为题解的状态树用数组来表示了,就可以很有效率的减小了复杂程度
DP:记忆化
- 如果各个子问题不是独立的,如果能够保存已经解决的子问题的答案,在需要的时候再找出已求得的答案,可以避免大量的重复计算。
- 基本思路:用一个表记录所有已解决的子问题的答案,不管该问题以后是否被用到,只要它被计算过,就将其结果填入表中。
- 记忆化
DP的求解过程
经典DP问题:0/1背包
- 给定n种物品和一个背包,物品i的重量是wi,其价值为vi,背包的容量为C。
- 背包问题:选择装入背包的物品,使得装入背包中物品的总价值最大
- 如果在选择装入背包的物品时,对每种物品i只有两种选择:装入背包或不装入背包,称为0/1背包问题。
例:有5个物品,重量分别是{2, 2, 6, 5, 4},价值分别为{6, 3, 5, 4, 6},背包的容量为10。
定义一个(n+1)×(C+1)的二维表dp[][]
dp[i][j]表示把前i个物品装入容量为j的背包中获得的最大价值。
填表:按只放第1个物品、只放前2个、只放前3个…一直到放完,这样的顺序考虑。(从小问题扩展到大问题)
1、只装第1个物品。(横向是递增的背包容量)
这里就只举一个装物品流程的例子了
2、**只装前3个物品。**如果第3个物品重量比背包大,那么不能装第3个物品,情况和只装第1、2个一样。如果第3个物品重量小于背包,那么:(1)如果把物品3装进去(重量是6),那么相当于只把1、2装到(容量-6)的背包中。(2)如果不装3,那么相当于只把1、2装到背包中。 -取(1)和(2)的最大值。
2、只装前3个物品。如果第3个物品重量比背包大,那么不能装第3个物品,情况和只装第1、2个一样。如果第3个物品重量小于背包,那么:(1)如果把物品3装进去(重量是6),那么相当于只把1、2装到(容量-6)的背包中。(2)如果不装3,那么相当于只把1、2装到背包中。 -取(1)和(2)的最大值。
不装就代表直接继承了上一层装的东西了
上正规例题
DP状态设计
DP状态:定义二维数组dp[][],大小为N×C。
dp[i][j]:把前i个物品(从第1个到第i个)装入容量为j的背包中获得的最大价值。
把每个dp[i][j]]看成一个背包:背包容量为j,装1~i这些物品。最后得到的dp[N][C]就是问题的答案:把N个物品装进容量C的背包的最大价值。
DP状态转移方程
递推计算到dp[i][j],分2种情况:
(1)第i个物品的体积比容量j还大,不能装进容量j的背包。那么直接继承前i-1个物品装进容量j的背包的情况即可:dp[i][j] = dp[i-1][j]。
(2)第i个物品的体积比容量j小,能装进背包。又可以分为2种情况:装或者不装第i个。
1)装第i个。从前i-1个物品的情况下推广而来,前i-1个物品是dp[i-1][j]。第i个物品装进背包后,背包容量减少c[i],价值增加w[i]。有:
dp[i][j] = dp[i-1][j-c[i]] + w[i]。
2)不装第i个。那么:dp[i][j] = dp[i-1][j]。
取1)和2)的最大值,状态转移方程:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - c[i]] + w[i])
整体代码:
def solve(n,C):
for i in range(1,n+1):
for j in range (0,C+1):
if c[i]>j: dp[i][j] = dp[i-1][j]
else: dp[i][j] = max(dp[i-1][j], dp[i-1][j-c[i]]+w[i])
return dp[n][C]
N=3011
dp = [[0 for i in range(N)] for j in range(N)]
#或者这样写:dp = [[0]*N for j in range(N)]
w = [0]*N
c = [0]*N
n, C = map(int, input().split())
for i in range(1, n+1):
c[i], w[i] = map(int, input().split())
print(solve(n, C))
空间优化:滚动数组
把dp[][]优化成一维的dp[],以节省空间。
Dp[i][]是从上面一行dp[i-1]算出来的,第i行只跟第i-1行有关系,跟更前面的行没有关系:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - c[i]] + w[i])
优化:只需要两行dp[0][]、dp[1][],用新的一行覆盖原来的一行,交替滚动。
经过优化,空间复杂度从O(N×C)减少为O©。
两种滚动方式分别为:交替滚动和自我滚动。
交替滚动
定义dp[2][j]:用dp[0][]和dp[1][]交替滚动。
优点:逻辑清晰、编码不易出错,建议初学者采用这个方法。
用now和old做索引:now始终指向正在计算的最新的一行,old指向已计算过的旧的一行。
对照原递推代码,now相当于i,old相当于i - 1。
def solve(n,C):
now = 0
old = 1
for i in range(1,n+1):
old,now = now,old #交换
for j in range (0,C+1):
if c[i] > j: dp[now][j] = dp[old][j]
else:
dp[now][j] = max(dp[old][j], dp[old][j-c[i]]+w[i])
return dp[now][C]
N = 3011
dp = [[0 for i in range(N)] for j in range(2)] #注意先后
w = [0]*N
c = [0]*N
n, C = map(int, input().split())
for i in range(1, n+1): c[i], w[i] = map(int, input().split())
print(solve(n, C))
省去了多层矩阵只需要两层即可,空间利用率大大增加
自我滚动
继续精简:用一个一维的dp[]就够了,自己滚动自己。
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - c[i]] + w[i])
def solve(n,C):
for i in range(1,n+1):
for j in range (C,c[i]-1,-1):
dp[j] = max(dp[j], dp[j-c[i]]+w[i])
return dp[C]
N = 3011
dp = [0]*N
w = [0]*N
c = [0]*N
n, C = map(int, input().split())
for i in range(1, n+1):
c[i], w[i] = map(int, input().split())
print(solve(n, C))
这里第二层循环从小到大循环是错的:
例如i = 2时,左图的dp[5]经计算得到dp[5] = 9,把dp[5]更新为9。
右图中继续往后计算,当计算dp[8]时,得dp[8] = dp[5]’ + 3 = 9+3 = 12,这个答案是错的。
错误的产生是滚动数组重复使用同一个空间引起的(计算dp时我需要调用上一层而且是小于j的位置,如果正向的话就会调用计算过之后的)。
从大到小循环是对的:
例如i = 2时,首先计算最后的dp[9] = 9,它不影响前面状态的计算。
深化背包问题
0/1背包简化版 (装箱问题)
0/1背包的简化版,不管物品的价格。把体积(不是价格)看成最优化目标:最大化体积(就是剩的最少最后输出V-dp[V])。
dp = [0]*20010
V = int(input())
n = int(input())
c = [0]*40
for i in range(1, n+1): c[i]=int(input())
for i in range(1,n+1):
for j in range (V,c[i]-1,-1):
dp[j] = max(dp[j], dp[j-c[i]]+c[i])
print(V-dp[V])
完全背包
- 思路和0/1背包类似。0/1背包的每种物品只有1件,完全背包的每种物品有无穷多件,第i种可以装0件、1件、2件、C/ci件。
- 定义dp[i][j]:把前i种物品(从第1种到第i种)装入容量为j的背包中获得的最大价值。
- 把每个dp[i][j]都看成一个背包:背包容量为j,装1~i这些物品。最后得到的dp[N][C]就是问题的答案:把N种物品装进容量C的背包的最大价值。
- 在0/1背包问题中,每个物品只有拿与不拿两种;而完全背包问题,需要考虑拿几个。
代码:
完全背包的代码和0/1背包的代码相似,只多了一个k循环,用来遍历每种物品拿几个。
def solve(n,C):
for i in range(1,n+1):
for j in range (0,C+1):
if i==1: dp[i][j] = 0
else: dp[i][j] = dp[i - 1][j]
for k in range(0,j//c[i]+1): #k*c[i]<=j #在容量为j的背包中放k个
dp[i][j] = max(dp[i][j], dp[i - 1][j - k * c[i]] + k * w[i])
return dp[n][C]
N = 3011
dp = [[0]*N for j in range(N)]
w = [0]*N; c = [0]*N; n, C = map(int, input().split())
for i in range(1, n+1): c[i], w[i] = map(int, input().split())
print(solve(n, C))
分组背包
分组背包问题:
- 有一些物品,把物品分为n组,其中第i组第k个物品体积是c[i][k],价值是w[i][k];
- 每组内的物品冲突,每组内最多只能选出一个物品;
- 给定一个容量为C的背包,问如何选物品,使得装进背包的物品的总价值最大。
解题思路:
与0/1背包相似。
- 0/1背包dp[i][j]:把前i个物品(从第1个到第i个)装入容量为j的背包中获得的最大价值。
- 分组背包dp[i][j]:把前i组物品装进容量j的背包(每组最多选一个物品),可获得的最大价值。
- 状态转移方程:
dp[i][j] = max{dp[i-1][j], dp[i-1][j-c[i][k]] + w[i][k]}
dp[i-1][j]表示第i组不选物品,dp[i-1][j-c[i][k]]表示第i组选第k个物品。
求解方程需要做i、j、k的三重循环。
状态转移方程: dp[i][j] = max{dp[i-1][j], dp[i-1][j-c[i][k]] + w[i][k]}
用滚动数组,变为: dp[j] = max{dp[j], dp[j-c[i][k]] + w[i][k]}
dp = [0]*N
for i in range(1,n+1): #遍历每个组
for j in range (C,-1,-1): #枚举容量
for k in range(1,C+1): #用k枚举第i组的所有物品
if(j >= c[i][k]): #第k个物品能装进容量j的背包
dp[j] = max(dp[j], dp[j-c[i][k]] + w[i][k]) #第i组第k个
print(dp[C])
多重背包
多重背包问题:
- 给定n种物品和一个背包,第i种物品的体积是ci,价值为wi,并且有mi个,背包的总容量为C。
- 如何选择装入背包的物品,使得装入背包中的物品的总价值最大?
- 对比完全背包:一个容量为 C的背包,有 N 种物品,第i种物品的体积为 ci,价值为wi,每种物品都有无限多个。
- 两者非常相似。
思路1:转化为0/1背包
思路2:直接DP
- 定义状态dp[i][j]:表示把前i个物品装进容量j的背包,能装进背包的最大价值。
- 第i个物品分为装或不装两种情况,状态转移方程:
dp[i][j] = max{dp[i-1][j], dp[i-1][j-kc[i]] + kw[i]}
1 ≤ k ≤ min{m[i], j/c[i]} - 直接写i、j、k三重循环,复杂度和第一种思路的复杂度一样,同样会超时。
- 对比完全背包:1 ≤ k ≤ j/c[i]
状态转移方程: dp[i][j] = max{dp[i-1][j], dp[i-1][j-kc[i]] + kw[i]}
用滚动数组,变为: dp[j] = max{dp[j], dp[j-kc[i]] + kw[i] }
dp = [0]*N
for i in range(1,n+1): #枚举物品
for j in range (C,c[i]-1,-1): #枚举背包容量
for k in range(1,m[i]+1): #用k遍历第i组的所有物品
if(j >= k*c[i]): #第k个物品能装进容量j的背包
dp[j] = max(dp[j],dp[j-k*c[i]]+k*w[i])
print(dp[C])
思路3:二进制拆分优化
一种极简单而有效的技巧。
- 注意拆分的具体实现,不能全部拆成2的倍数,而是先按2的倍数从小到大拆,最后是一个小于等于最大倍数的余数。
- 保证拆出的数相加在[1, mi]范围内,不会大于mi。
- 例如mi = 25,把它拆成1、2、4、8、10,最后是余数10,10 < 16 = 24,这5个数能组合成1~25内的所有数字,不会超过25。
- 如果把25拆成1、2、4、8、16,相加的范围就是[1, 31]了。
多重背包例题:
N = 100010
w = [0]*N; c = [0]*N; m = [0]*N
xw = [0]*N; xc = [0]*N; xm = [0]*N #新的
n, C = map(int, input().split())
for i in range(1, n+1): w[i], c[i],m[i] = map(int, input().split())
#以下是二进制拆分
xn = 0 #二进制拆分后的新物品总数量
for i in range(1,n+1):
j = 1
while j <= m[i]: # 例:m[i]=2
m[i] -= j #减去已拆分的
xn+=1
xc[xn] = j*c[i] #新物品的体积
xw[xn] = j*w[i]
j <<= 1 #二进制枚举:1,2,4...
if(m[i]>0): #最后一个是余数
xn += 1
xc[xn] = m[i]*c[i]
xw[xn] = m[i]*w[i]
#以下是滚动数组版本的0/1背包
dp = [0]*N
for i in range(1,xn+1): #枚举物品
for j in range (C,xc[i]-1,-1): #枚举背包容量
dp[j] = max(dp[j],dp[j-xc[i]]+xw[i])
print(dp[C])