1、经典0-1背包问题
问题描述:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内选择物品使得物品的总价值最高。
回顾对于二维的0-1背包问题递推关系式:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
−
1
]
[
j
−
w
e
i
g
h
t
[
i
]
]
+
v
a
l
u
e
[
i
]
)
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
dp[i][j]=max(dp[i−1][j],dp[i−1][j−weight[i]]+value[i])
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]表示的是从0到i标号的物品中放入承重为j的背包中最大的价值总和。
首先,对于此公式的解释:每次选择只有两种情况,在物品
0
,
1
,
.
.
.
,
i
{0,1,...,i}
0,1,...,i中不选择和选择物品
i
i
i。对于边界初始化:
d
p
[
0
]
[
0
]
=
d
p
[
i
]
[
0
]
=
0
,
d
p
[
0
]
[
j
]
=
v
a
l
u
e
[
j
]
dp[0][0]=dp[i][0]=0, dp[0][j]=value[j]
dp[0][0]=dp[i][0]=0,dp[0][j]=value[j]其他数组中的值全部置为0,这是因为每次更新都取最大值,且所以这些值都会被覆盖,所以初始化全0是没问题的。同时,遍历次序物品先还是背包先,顺序还是逆序都是可以得到正确结果的。
2、滚动数组
回顾我们在做斐波那契数列的时候,空间复杂度可以从O(N)降到O(1)(用两个位置去储存即可),这也是滚动数组的基本思想,做空间压缩。
从上面的递推关系我们可以发现,
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]只和第一维为
i
−
1
i-1
i−1的dp数组有关,所以可以将递推公式中的
i
−
1
i-1
i−1替换为
i
i
i,将第
i
−
1
i-1
i−1层的结果写入
i
i
i层中,即
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
]
[
j
]
,
d
p
[
i
]
[
j
−
w
e
i
g
h
t
[
i
]
+
v
a
l
u
e
[
i
]
)
dp[i][j] = max(dp[i][j], dp[i][j-weight[i] + value[i])
dp[i][j]=max(dp[i][j],dp[i][j−weight[i]+value[i])这说明第一个维度只用存储一个数据,这就是可以二维压缩到一维的原因。
d
p
[
j
]
dp[j]
dp[j]表示的是容量大小为
j
j
j的背包能达到的最大价值和。对比二维的递推表达,我们有如下一维递推:
d
p
[
j
]
=
m
a
x
(
d
p
[
j
]
,
d
p
[
j
−
w
e
i
g
h
t
[
i
]
]
+
v
a
l
u
e
[
i
]
)
dp[j] = max(dp[j], dp[j-weight[i]] + value[i])
dp[j]=max(dp[j],dp[j−weight[i]]+value[i])不放物品
i
i
i为
d
p
[
j
]
dp[j]
dp[j],这是因为
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
]
dp[i][j]=dp[i-1][j]
dp[i][j]=dp[i−1][j]只是将上一层的结果进行了拷贝;放了物品
i
i
i的结果和二维的类似可得。初始化,根据一维
d
p
dp
dp数组的含义,显然
d
p
[
0
]
=
0
dp[0]=0
dp[0]=0,同样这里的
d
p
[
i
]
dp[i]
dp[i]可以置为0。关于遍历次序的理解,这里以一个例子来说明:假设有标号0,1,2的三个物品weight分别为1,3,4;value分别为15,20,30。遍历的顺序这里采用的是先遍历物品再逆序遍历背包。首先说明这样遍历的可行性。
for i in range(3):
for j in range(4, weight[i] - 1, -1):
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
注意到
i
=
0
,
j
=
4
,
d
p
[
4
]
=
v
a
l
u
e
[
0
]
=
15
=
d
p
[
0
]
[
4
]
i
=
0
,
j
=
3
,
d
p
[
3
]
=
v
a
l
u
e
[
0
]
=
15
=
d
p
[
0
]
[
3
]
.
.
.
i
=
1
,
j
=
4
,
d
p
[
4
]
=
m
a
x
(
v
a
l
u
e
[
1
]
+
d
p
[
4
−
w
e
i
g
h
t
[
1
]
]
,
d
p
[
4
]
)
=
35
=
d
p
[
1
]
[
4
]
.
.
i=0,j=4,dp[4]=value[0]=15=dp[0][4]\\ i=0,j=3,dp[3]=value[0]=15=dp[0][3]\\...\\ i=1,j=4,dp[4]=max(value[1]+dp[4-weight[1]],dp[4])=35=dp[1][4]\\..
i=0,j=4,dp[4]=value[0]=15=dp[0][4]i=0,j=3,dp[3]=value[0]=15=dp[0][3]...i=1,j=4,dp[4]=max(value[1]+dp[4−weight[1]],dp[4])=35=dp[1][4]..
最后得到的每个
d
p
[
j
]
=
d
p
[
2
]
[
j
]
dp[j]=dp[2][j]
dp[j]=dp[2][j]。而如果将背包承重遍历改变为顺序:
i
=
0
,
j
=
0
,
d
p
[
0
]
=
0
=
d
p
[
0
]
[
0
]
i
=
0
,
j
=
1
,
d
p
[
1
]
=
v
a
l
u
e
[
0
]
=
15
=
d
p
[
0
]
[
1
]
i
=
0
,
j
=
2
,
d
p
[
2
]
=
m
a
x
(
d
p
[
1
]
+
v
a
l
u
e
[
0
]
,
d
p
[
2
]
)
=
15
+
15
=
30
≠
d
p
[
0
]
[
2
]
=
15
.
.
.
i=0,j=0,dp[0]=0=dp[0][0]\\ i=0,j=1,dp[1]=value[0]=15=dp[0][1]\\ i=0,j=2,dp[2]=max(dp[1]+value[0],dp[2])=15+15=30\neq dp[0][2]=15\\...
i=0,j=0,dp[0]=0=dp[0][0]i=0,j=1,dp[1]=value[0]=15=dp[0][1]i=0,j=2,dp[2]=max(dp[1]+value[0],dp[2])=15+15=30=dp[0][2]=15...
可以看到
d
p
[
2
]
dp[2]
dp[2]的结果显然是不对的,物品0被放入了两次。这是因为计算dp[2]的时候又用到了更新之后的dp[1]。可以对照二维dp的情况,i=0的情况都进行了初始化且没有相互影响。说的更明白一点,比如
i
=
1
,
d
p
[
3
]
i=1,dp[3]
i=1,dp[3]的结果会用到
d
p
[
2
]
=
d
p
[
0
]
[
2
]
dp[2]=dp[0][2]
dp[2]=dp[0][2],而
d
p
[
2
]
dp[2]
dp[2]在此之前已经更新为
d
p
[
1
]
[
2
]
dp[1][2]
dp[1][2],这样计算的
d
p
[
3
]
dp[3]
dp[3]肯定不对了。同理,交换物品和背包承重的循环顺序仍然会计算错误。这一块最好还是实际画一遍dp数组遍历过程才会更清楚。
下面以一个实际的例子来看。
3、实例
力扣分隔等和子集: 给你一个只包含正整数的非空数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
https://leetcode.cn/problems/partition-equal-subset-sum/
1、问题分析:从数组中选出若干数字,使得数字总价值为sum/2。对比01背包问题,nums的元素相当于物品重量,sum相等于背包承重。
2、设置dp数组和下标含义:
d
[
i
]
[
j
]
d[i][j]
d[i][j]表示否存在从下标
[
0
,
i
]
[0,i]
[0,i]中选取元素,使得和为恰好为
j
j
j的情况,存在则返回True。
3、递推关系式:
A、不选下标为i的元素:
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
]
dp[i][j]=dp[i-1][j]
dp[i][j]=dp[i−1][j]
B、选择下标为i的元素,因为数组中元素都是正整数,其中还要分两类来看:1)如果
n
u
m
s
[
i
]
=
=
j
nums[i]==j
nums[i]==j,
d
p
[
i
]
[
j
]
=
T
r
u
e
dp[i][j]=True
dp[i][j]=True
2)如果
n
u
m
s
[
i
]
<
j
nums[i]<j
nums[i]<j,
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
−
n
u
m
s
[
i
]
]
dp[i][j]=dp[i-1][j-nums[i]]
dp[i][j]=dp[i−1][j−nums[i]]
3)如果
n
u
m
s
[
i
]
>
j
nums[i]>j
nums[i]>j,归为第一类情况。
4、初始化:
d
p
[
0
]
[
0
]
=
F
a
l
s
e
dp[0][0]=False
dp[0][0]=False
class Solution:
def canPartition(self, nums: List[int]) -> bool:
# 判断特殊条件
sum1 = sum(nums)
if sum1%2 != 0 or len(nums) < 2 or sum1 < 2 * max(nums):
return False
sum1 = sum1 // 2
n = len(nums)
# 初始化dp
dp = [[False for x in range(sum1+1)] for x in range(n)]
dp[0][nums[0]] = True
# 递推:
for i in range(1,n):
for j in range(1,sum1+1):
if nums[i] == j:
dp[i][j] = True
elif nums[i] < j:
dp[i][j] = dp[i-1][j-nums[i]] or dp[i-1][j]
else:
dp[i][j] = dp[i-1][j]
return dp[n-1][sum1]
注意到这个问题中, d p [ i ] dp[i] dp[i]层的结果只和 d p [ i − 1 ] dp[i-1] dp[i−1]的相关,所以依然可以压缩为一维dp数组。
class Solution:
def canPartition(self, nums: List[int]) -> bool:
# 判断特殊条件
sum1 = sum(nums)
if sum1%2 != 0 or len(nums) < 2 or sum1 < 2 * max(nums):
return False
sum1 = sum1 // 2
n = len(nums)
dp = [False for x in range(sum1+1)]
for i in range(1,n):
for j in range(sum1,0,-1):
if nums[i] == j:
dp[j] = True
elif nums[i] < j:
dp[j] = dp[j-nums[i]] or dp[j]
else:
dp[j] = dp[j]
return dp[sum1]