写在前面
- 这是我对动态规划一些入门题的笔记,主要便于随时随地的回顾这些基础内容。
- 基本都是些简单题目,因为复杂的题目代码太长,不便于作为笔记进行重温。
- 前续Ⅰ:[算法][动态规划]动态转移过程与Python实现小样两例(切绳子与跳台阶)
- 前续Ⅱ:[算法][动态规划][背包问题①]0-1背包问题的优化及约束变形[python实现]
- 背包问题讲义
如果无法访问码云的代码库,请访问此处:本文源码@Gist
有一个重量上限容量为 t o t a l total total 的背包和 i t e m _ n u m item\_num item_num 种物品,第 i i i 种物品的重量是 w i w_i wi 、价值为 v i v_i vi ,求问选择将哪些物品放入背包(每种物品的数量无限)可以得到最大的价值。
附带约束:背包恰好装满,解决方案参见 [算法][动态规划][背包问题①]0-1背包问题的优化及约束变形[python实现]:算法约束-恰好放满背包
- [算法][动态规划][背包问题①]0-1背包问题的优化及约束变形[python实现]
- 实现代码:01-package.py
在01背包问题中我们推断了01背包算法的动态转移方程:
d p [ i + 1 ] [ j ] = m a x { d p [ i ] [ j − w i ] + v i , d p [ i ] [ j ] } dp[i+1][j]=max\{dp[i][j-w_i]+v_i,dp[i][j]\} dp[i+1][j]=max{dp[i][j−wi]+vi,dp[i][j]}在逆序推断的情况下,该动态转移矩阵可以略去一个维度变为: d p [ j ] = m a x { d p [ j − w i ] + v i , d p [ j ] } dp[j]=max\{dp[j-w_i]+v_i, dp[j]\} dp[j]=max{dp[j−wi]+vi,dp[j]}修正逆序遍历的下限 l o w e r = m a x { t o t a l − ∑ t = i i t e m _ n u m w t ) , w [ i ] } lower=max\{total-\sum_{t=i}^{item\_num}w_t), w[i]\} lower=max{total−∑t=iitem_numwt),w[i]} 之后,算法的伪码为:
def zero_one_solution(...):
for(int i = 0; t < item_num; i++): # 物品循环
for(int j = total; j >= lower; j--): # 价值循环(采用逆循环的方式)
dp[j]=max(dp[j - w[i]] + v[i], dp[j])
w
和v
数组中,将问题转化为0-1背包问题。因此算法框架就变为:
def complete_solution(..., w, v): # 将完全背包问题转化为0-1背包问题的算法结构
w, v = encoder(w, v)
result = zero_one_solution(..., w, v)
result = decoder(result) # 如果不需要对package_trace解码,可以跳过这一步骤,详见前续Ⅱ
在完全背包问题中,由于每种物品的数量无限,我们很容易观察到两个常数级别的优化:
给定一个掩码数组mask[item_num]
,当其等于 1 时直接进行跳过该物品,即:
for(int i = 0; t < item_num; i++): # 物品循环
if mask[i] == 1:
continue
# else do other thing like :
# {for(int j = total; j >= lower; j--): dp[j]=max(...)}
# or {w, v = encode(...)}(will be introduced next section)
产生掩码的代码:(完整代码@码云)
# def get_mask(...):
# input total:Scale, w:List, v:List
item_num= len(w)
mask = [0 for _1 in range(item_num + 1)]
for i in range(item_num): # 产生掩码 复杂度为 O(item_num^2)
if w[i] > total: # w_x>total直接跳过
mask[i] = 1
continue
for j in range(i + 1, item_num, 1): # 基于冒泡的形式进行比较
if mask[i] == 1: # 稍微进行加速
break
a, b = w[i] <= w[j], v[i] >= v[j]
c, d = w[i] >= w[j], v[i] <= v[j]
picked = j if a and b else (i if c and d else -1)
if picked > 0:
mask[picked] = 1
break
# output mask:List
编码和解码,即通过编码w
和v
把完全背包问题转化为0-1背包问题。
w
和v
数组中,将问题转化为0-1背包问题。这种编码方式被称为full
编码方式。编码方式很简单:(完整代码@码云)
def encoder(total, w_o, v_o):
"""
采用全量编码的方式重组物品序列
:param total: 背包容量上限
:param w_o: 原物品序列重量列表
:param v_o: 原物品序列价值列表
:return: 编码后的物品序列价值列表
"""
item_num, w, v, mask = len(w_o), [], [], get_mask(total, w_o, v_o)
for i in range(item_num):
if mask[i] == 1:
continue
size = max(total // w_o[i], 1) # 至少应该保留一件防止程序bug
# 全量输出模式 : 添加 ⌊total/v[i]⌋ 件相同的物品
w += [w_o[i] for _ in range(size)]
v += [v_o[i] for _ in range(size)]
return w, v, mask
解码方式:
def decoder(w_o, trace_o, mask):
"""
采用全量编码的方式重组物品序列
:param w_o: 原物品序列重量列表
:param trace_o: 原物品序列选择列表
:param mask: 过滤掩码,当其等于 1 时直接进行跳过该物品
:return: 编码后的物品选择列表
"""
item_num, trace, start = len(w_o), [], 0
for i in range(item_num):
if mask[i] == 1:
continue
size = max(total // w_o[i], 1) # 为了trace可追踪,至少为1件
num = len(list(filter(lambda x: start <= x < start + size, trace_o)))
if num > 0:
trace += [(i, num)] # (i, j) : 序号为 i 的物品放入 j 个
start += size
return trace
compress
编码方式或binary
编码方式。例如,对于物品 x x x有 w x = 2 w_x=2 wx=2, v x = 1 v_x=1 vx=1,背包容量为9,将该种物品重构为序号从0起至 s m a x = log ⌊ t o t a l w i ⌋ = 2 s_{max}=\log ⌊\frac{total}{w_i}⌋=2 smax=log⌊witotal⌋=2 即 k = s m a x + 1 k=s_{max}+1 k=smax+1 个的新物品序列:
物品编号(s) | 物品重量 | 物品价值 | 选中该编码后的物品代表的原物品数 |
---|---|---|---|
0 | 2 | 1 | 2 0 2^0 20=1 |
1 | 4 | 2 | 2 1 2^1 21=2 |
2 | 8 | 4 | 2 2 2^2 22=4 |
Tip:在完全编码中需要编码为 4 4 4 个 w x = 2 w_x=2 wx=2, v x = 1 v_x=1 vx=1的物品,转为0-1背包问题。显然压缩编码模式。
- 在二进制编码后物品序列中,选择编号为 s s s 的二进制物品时,即代表了选择了 2 s 2^s 2s 个原物品。例如已知原本可以选择0~4个原物品,若选择3个原物品,在二进制物品列表中即为选择中 s = { 0 , 1 } s =\{0,1\} s={0,1}的两个物品。
编码方式:(完整代码@码云)
def encoder(total, w_o, v_oe):
"""
采用全量编码的方式重组物品序列
:param total: 背包容量上限
:param w_o: 原物品序列重量列表
:param v_o: 原物品序列价值列表
:return: 编码后的物品序列价值列表
"""
item_num, w, v, mask = len(w_o), [], [], get_mask(total, w_o, v_o)
for i in range(item_num):
if mask[i] == 1:
continue
size = size_counter(w_o[i])
# 压缩模式 : 添加 ⌊log(total/v[i])⌋ 件相同的物品
w += [w_o[i] * (2 ** _) for _ in range(size)]
v += [v_o[i] * (2 ** _) for _ in range(size)]
return w, v, mask
解码方式:
def decoder(w_o, trace_o, mask, w):
"""
采用全量编码的方式重组物品序列
:param w_o: 原物品序列重量列表
:param trace_o: 原物品序列选择列表
:param mask: 过滤掩码,当其等于 1 时直接进行跳过该物品
:param w: 编码后的物品序列列表,用于解码
:return: 编码后的物品选择列表
"""
import math
item_num, trace, start = len(w_o), [], 0
for i in range(item_num):
if mask[i] == 1:
continue
size = max(math.floor(math.log(total / w_o[i], 2)) + 1, 1) # 为了trace可追踪,至少为1件
num, codes = 0, list(filter(lambda x: start <= x < start + size, trace_o))
for v in codes:
num += w[v] // w_o[i]
if num > 0:
trace += [(i, num)]# (i, j) : 序号为 i 的物品放入 j 个
start += size
return trace
我们发现,哪怕是基于二进制编码,算法的时间复杂度还是 O ( t o t a l ∗ i t e m _ n u m ∗ ∑ log t o t a l w i ) O(total*item\_num*\sum \log\frac{total}{w_i}) O(total∗item_num∗∑logwitotal),跟0-1背包问题的 O ( t o t a l ∗ i t e m _ n u m ) O(total*item\_num) O(total∗item_num)仍有差距。
我们再次写出0-1背包问题在逆序情况下的状态转移方程 d p [ j ] = m a x { d p [ j − w i ] + v i , d p [ j ] } dp[j]=max\{dp[j-w_i]+v_i, dp[j]\} dp[j]=max{dp[j−wi]+vi,dp[j]}
for i in range(item_num):
for j in range(total, w[i]-1, -1): # 倒序遍历背包剩余容量,即 for(inr j = total; j >= w[i]; j--)
# dp[i+1][j] = max(dp[i][j-w[i]]+v[i], dp[i][j]) , also works
dp[j] = dp[j - w[i]] + v[i]
稍微修改一下遍历方向:
for i in range(item_num):
for j in range(w[i], total+1, 1): # 正序遍历背包剩余容量,即 for(inr j = w[i]; j <= total; j++)
# dp[i+1][j] = max(dp[i+1][j-w[i]]+v[i], dp[i][j]) , also works
dp[j] = dp[j - w[i]] + v[i]
会发现完全背包问题居然得解了,且算法的时间复杂度为: O ( t o t a l ∗ i t e m _ n u m ) O(total*item\_num) O(total∗item_num)。
为什么这么做会有作用呢?回忆0-1背包问题中我们倒序遍历背包容量之所以能减少一个dp
维度的原因:
当我们从后往前遍历时,确保了后一个状态i
是由前一个状态i-1
递推而来。后一个状态经过更新之后前一个状态的更新并不会造成后一个状态的改变,即前一个状态i-1
永远不会选到状态i
所对应的物品i
,也就是为了保证一种物品只选一次,即完全背包的状态转移方程应当是(正序遍历和倒序遍历时下式应当都成立):
d p [ i + 1 ] [ j ] = m a x { d p [ i + 1 ] [ j − w i ] + v i , d p [ i ] [ j ] } dp[i+1][j]=max\{dp[i+1][j-w_i]+v_i, dp[i][j]\} dp[i+1][j]=max{dp[i+1][j−wi]+vi,dp[i][j]}
基于状态转移的速度优化方案,我们给出以下代码:(完整代码@码云)
def solution_speedup(data, restrict=True):
"""
完全背包问题(兼容约束解决方案)
空间复杂度 O(total),时间复杂度 O(total*item_num)
:param data: 数据集
:param restrict: 是否进行装满约束 @see https://blog.csdn.net/Shenpibaipao/article/details/90961776#_182
:return: 最优价值和放入背包的物品序号
"""
total, w, v, answer = data.values()
item_num, mask = len(w), get_mask(total, w, v)
specific = float("-inf") if restrict else 0 # 兼容满背包约束方案
dp = [specific for _1 in range(total + 1)]
dp[0] = 0
trace = [[] for _1 in range(total + 1)]
for i in range(item_num):
if mask[i] == 1:
continue
for j in range(w[i], total+1, 1): # 修改此处以实现完全背包 -> for(i=w[i];i<=total;i++)
trace[j], dp[j] = (trace[int(j - w[i])] + [i], dp[j - w[i]] + v[i]) \
if dp[j - w[i]] + v[i] > dp[j] else (trace[j], dp[j])
# 重新编码轨迹,如果不需要输出轨迹或重编译轨迹可直接注释下面这一段代码以加速算法运行
temp_trace, trace[total] = trace[total], []
for i in range(len(temp_trace)):
v = temp_trace[i]
if i > 0 and temp_trace[i-1] == v: # 依赖逻辑短路保证数组不越界
continue
trace[total] += [(v, temp_trace.count(v))]
return {
"max_value": dp[total],
"package_trace": trace[total]
}
这里给出以上所有代码和测试:(完整代码@码云)
01-package.py
测试结果:
function[solution_full] {'max_value': 30, 'package_trace': [(0, 5)]} while repeat 5000 times, using time: 56.75(s)
function[solution_compress] {'max_value': 30, 'package_trace': [(0, 5)]} while repeat 5000 times, using time: 31.78(s)
function[solution_compress] {'max_value': 10, 'package_trace': [(0, 1), (1, 2)]} while repeat 1 times, using time: 0.42(s)
function[solution_speedup] {'max_value': 17, 'package_trace': [(2, 1), (5, 2)]} while repeat 5000 times, using time: 12.58(s)
可以看到solution_speedup
的速度得到显著的提升。