【算法】动态规划

本文介绍了动态规划的基础,通过一个经典的硬币找零问题说明了动态规划与贪心算法的区别。接着详细阐述了0/1背包问题的动态规划解决方案,包括状态设计、状态转移方程以及空间优化的滚动数组技巧。最后讨论了完全背包和分组背包问题,以及多重背包问题的几种解题思路。
摘要由CSDN通过智能技术生成

动态规划

讲之前先举一个经典贪心例题

image-20230325142244035

此题可以用贪心从面值高的硬币遍历,但只适合一些差距大的面值,若存在更多面值,例如

image-20230325142426100

动态规划(DP)基础

type = [1, 5, 10, 25, 50] #5种面值

定义数组Min[ ] 记录最少硬币数量:
对输入的某个金额i,Min[i]是最少的硬币数量。
第一步:只考虑1元面值的硬币

image-20230325142631537

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开始就行了:

image-20230325143011005

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的求解过程

image-20230325144301985

经典DP问题:0/1背包

  • 给定n种物品和一个背包,物品i的重量是wi,其价值为vi,背包的容量为C。
  • 背包问题:选择装入背包的物品,使得装入背包中物品的总价值最大
  • 如果在选择装入背包的物品时,对每种物品i只有两种选择:装入背包或不装入背包,称为0/1背包问题。

image-20230325144453729

例:有5个物品,重量分别是{2, 2, 6, 5, 4},价值分别为{6, 3, 5, 4, 6},背包的容量为10。
定义一个(n+1)×(C+1)的二维表dp[][]
dp[i][j]表示把前i个物品装入容量为j的背包中获得的最大价值。

image-20230325144518983

填表:按只放第1个物品、只放前2个、只放前3个…一直到放完,这样的顺序考虑。(从小问题扩展到大问题)
1、只装第1个物品。(横向是递增的背包容量)

image-20230325144602800

这里就只举一个装物品流程的例子了

2、**只装前3个物品。**如果第3个物品重量比背包大,那么不能装第3个物品,情况和只装第1、2个一样。如果第3个物品重量小于背包,那么:(1)如果把物品3装进去(重量是6),那么相当于只把1、2装到(容量-6)的背包中。(2)如果不装3,那么相当于只把1、2装到背包中。 -取(1)和(2)的最大值。

image-20230325144757386

2、只装前3个物品。如果第3个物品重量比背包大,那么不能装第3个物品,情况和只装第1、2个一样。如果第3个物品重量小于背包,那么:(1)如果把物品3装进去(重量是6),那么相当于只把1、2装到(容量-6)的背包中。(2)如果不装3,那么相当于只把1、2装到背包中。 -取(1)和(2)的最大值。

image-20230325144832353

不装就代表直接继承了上一层装的东西了

上正规例题

image-20230325145045338

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))

这里第二层循环从小到大循环是错的:

image-20230325153723757

例如i = 2时,左图的dp[5]经计算得到dp[5] = 9,把dp[5]更新为9。
右图中继续往后计算,当计算dp[8]时,得dp[8] = dp[5]’ + 3 = 9+3 = 12,这个答案是错的。
错误的产生是滚动数组重复使用同一个空间引起的(计算dp时我需要调用上一层而且是小于j的位置,如果正向的话就会调用计算过之后的)。

从大到小循环是对的:

image-20230325153927771

例如i = 2时,首先计算最后的dp[9] = 9,它不影响前面状态的计算。

深化背包问题

0/1背包简化版 (装箱问题)

image-20230325154052023

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])

完全背包

image-20230325160512849

  • 思路和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))

image-20230325161028442

分组背包

分组背包问题:

  • 有一些物品,把物品分为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背包

image-20230325181906244

思路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:二进制拆分优化

一种极简单而有效的技巧。

image-20230325182149024

  • 注意拆分的具体实现,不能全部拆成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]了。

多重背包例题:

image-20230325182801673

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])
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菜小田

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值