本文介绍一种近似算法的设计技巧: Rounding. 具体来说, 它有两种实现思路:
- Rounding solution. 以整数规划为例, 可以先求其线性规划松弛解, 然后对解进行四舍五入(同时保证可行性), 从而得到整数解.
- Rounding instance. 修改原问题实例然后求解, 同时保证解的可行性(对原问题而言). 修改实例的目的是使得问题更容易求解.
背包问题
考虑 n n n个商品, 它们的大小为 w 1 , w 2 , … , w n ∈ N + w_1, w_2, \ldots, w_n \in \mathbb{N}^+ w1,w2,…,wn∈N+, 价值为 p 1 , p 2 , … , p n ∈ N + p_1, p_2, \ldots, p_n \in \mathbb{N}^+ p1,p2,…,pn∈N+. 给定一个大小为 W W W的背包, 我们需要找到一组商品装入背包中(商品总大小不超过 W W W)使得装入背包的商品总价值达到最大.
设决策变量
x
i
∈
{
0
,
1
}
x_i\in \{0,1\}
xi∈{0,1}代表商品
i
i
i是否被装入背包中. 背包问题可以写成如下整数规划:
max
∑
i
p
i
x
i
s.t.
∑
i
=
1
n
w
i
x
i
≤
W
x
i
∈
{
0
,
1
}
.
\begin{aligned} \max & \sum_i p_ix_i \\ \text{s.t. } & \sum_{i=1}^n w_i x_i \leq W \\ & x_i \in \{0,1\}. \end{aligned}
maxs.t. i∑pixii=1∑nwixi≤Wxi∈{0,1}.
虽然背包问题可以用动态规划求得最优解, 但从时间复杂度上来看它是指数时间的算法. 当问题规模(encoding length of input)非常大时, 我们可以考虑对它进行近似地求解. 下面从Rounding的角度介绍几种近似求解的思路.
LP-Rounding
考虑上述规划的松弛问题(Linear programming relaxation):
max
∑
i
p
i
x
i
s.t.
∑
i
=
1
n
w
i
x
i
≤
W
0
≤
x
i
≤
1.
\begin{aligned} \max & \sum_i p_ix_i \\ \text{s.t. } & \sum_{i=1}^n w_i x_i \leq W \\ & 0 \leq x_i \leq 1. \end{aligned}
maxs.t. i∑pixii=1∑nwixi≤W0≤xi≤1.
令 x = ( x 1 , x 2 , … , x n ) T x = (x_1, x_2, \ldots, x_n)^T x=(x1,x2,…,xn)T代表松弛问题的最优解. 如果 x x x是整数解则直接输出, 否则需要把它Rounding成整数解.
Lemma. 存在松弛问题的最优解 x x x,它最多包含一个非整数的分量.
证明. 假设存在两个变量 x i , x j ∈ ( 0 , 1 ) x_i, x_j\in (0,1) xi,xj∈(0,1). 不失一般性, 不妨假设 p i / w i ≥ p j / w j p_i/w_i \geq p_j/w_j pi/wi≥pj/wj. 增大 x i x_i xi的同时减小 x j x_j xj直到 x i = 1 x_i = 1 xi=1或 x j x_j xj等于0.
因此, 我们得到如下算法:
- 计算松弛问题的最优解 x x x;
- 保证 x x x最多包含一个非整数分量 x k x_k xk(单纯形算法直接保证), 然后令 x k = 0 x_k=0 xk=0;
- 令 W ′ = W − ∑ i w i W' = W - \sum_{i}w_i W′=W−∑iwi代表背包剩余的空间, 从剩下的商品中挑选一个价值最大且大小不超过 W ′ W' W′的商品加入背包.
Python实现
from ortools.linear_solver import pywraplp
class KnapsackLPRounding(object):
""" 背包问题LP Rounding(近似)解法.
"""
def __init__(self, w, p, W):
"""
:param w: 物品大小, list
:param p: 物品价值, list
:param W: 背包大小, int
"""
self._w = w
self._p = p
self._W = W
self._n = len(self._w)
self._result = None
def _solve_lp(self):
solver = pywraplp.Solver('MasterModel', pywraplp.Solver.GLOP_LINEAR_PROGRAMMING)
n = len(self._w)
# 决策变量
x = [solver.NumVar(0, 1, 'x[%d]' % i) for i in range(n)]
# 约束
ct = solver.Constraint(0, self._W)
for i in range(n):
ct.SetCoefficient(x[i], self._w[i])
# 目标
obj = solver.Objective()
for i in range(n):
obj.SetCoefficient(x[i], self._p[i])
obj.SetMaximization()
# 求解
solver.Solve()
# 得到计算结果
return [x[i].solution_value() for i in range(n)]
def _rounding(self, x):
""" 对分数解x取整, 然后从剩余商品中挑一个价值最大的商品装入背包(如果可行)
"""
sol = [i for i in range(len(x)) if abs(x[i] - 1) < 1e-6]
# 背包剩余的空间
available_space = self._W - sum([self._w[i] for i in sol])
# 剩下的物品
left_over = set(range(self._n)) - set(sol)
# 剩下物品的价值
left_over_profits = [self._p[i] for i in left_over]
# 按从大到小排序
left_over_items = list(sorted(zip(left_over, left_over_profits), key=lambda item: item[1], reverse=True))
left_over = [item[0] for item in left_over_items if self._w[item[0]] <= available_space]
return sol + [left_over[0]] if left_over else sol
def solve(self):
# 求解背包问题的松弛解
x = self._solve_lp()
# Rounding solution
self._result = self._rounding(x)
return self
PTAS
PTAS的全称是Polynomial Time Approximation Scheme. 给定参数 ϵ > 0 \epsilon>0 ϵ>0, 它是多项式时间的 ( 1 + ϵ ) (1+\epsilon) (1+ϵ)近似算法, 即算法对应的目标函数值与最优目标函数值之差不超过最优值的 ϵ \epsilon ϵ倍.
算法-k
- 枚举所有不超过 k k k个商品的集合 S \mathcal{S} S, 共有 O ( k n k ) O(kn^k) O(knk)种情况.
- 从 S \mathcal{S} S中选择价值最大的集合 S ∗ ∈ S S^*\in \mathcal{S} S∗∈S.
- 用贪心算法填充剩下的空间.
评估
设OPT代表最优解, ALG代表算法解. 可以证明1: OPT / ALG ≤ 1 + 1 / k \text{OPT} / \text{ALG} \leq 1+1/k OPT/ALG≤1+1/k. 时间复杂度是 O ( k n k + 1 ) O(kn^{k+1}) O(knk+1). 由于 k k k是常数, 该算法是多项式时间. 当参数 k k k越大, 算法解与最优解越相近, 但时间复杂度越高. 时间复杂度的增长是关于 k k k的指数函数.
Python实现
枚举算法的实现. 分成两个函数考虑
choose_exact(n, k)
: 从 n n n个物品里选 k k k个, 枚举所有情况.choose_at_most(n, k)
: 从 n n n个物品里选至少 1 1 1个至多 k k k个, 枚举所有情况.
def choose_exact(n, k):
"""
从n个物品[0, 1, ... n-1]里选择k个, 枚举所有情况. 例如:
>>> choose_exact(4, 2)
[[0, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]]
>>> choose_exact(3, 3)
[[0, 1, 2]]
"""
if k == 1:
return [[i] for i in range(n)]
res_mid = choose_exact(n, k - 1)
result = []
for p in res_mid:
for i in range(max(p)+1, n):
result.append(p + [i])
return result
def choose_at_most(n, k):
""" 从n个物品[0, 1, ..., n-1]里选择至少1个至多k个, 枚举所有情况.
"""
result = []
for i in range(1, k+1):
result += choose_exact(n, i)
return result
背包问题的PTAS.
class KnapsackPTAS(object):
"""
背包问题的PTAS.
近似比 = (1+1/k), 计算时间复杂度 = O(kn^{k+1}).
"""
def __init__(self, w, p, W):
"""
:param w: 物品大小, list
:param p: 物品价值, list
:param W: 背包大小, int
"""
self._w = w
self._p = p
self._W = W
self._n = len(self._w)
self._result = None
def solve(self, k):
# step1. 枚举所有不超过k个物品的解, 取价值最大的可行解
solutions = choose_at_most(self._n, k)
max_sol = None
max_p = 0
for sol in solutions:
if sum([self._w[i] for i in sol]) > self._W:
continue
p = sum([self._p[i] for i in sol])
if p > max_p:
max_p = p
max_sol = sol
# step2. 背包的剩余空间用贪心算法填充
self._result = self._greedy(max_sol)
return self
def _greedy(self, sol):
"""
给定可行解sol, 把背包剩余的空间用贪心算法填充.
"""
# 背包剩余的空间
available_space = self._W - sum([self._w[i] for i in sol])
new_items = []
# 剩下的物品
left_over = set(range(self._n)) - set(sol)
# 计算剩下物品的性价比
left_over_values = [self._p[i] / self._w[i] for i in left_over]
# 按性价比从大到小排序
left_over_items = sorted(zip(left_over, left_over_values), key=lambda x: x[1], reverse=True)
left_over = [item[0] for item in left_over_items]
# 把剩余的物品依次装入背包(如果能装下)
for i in left_over:
if self._w[i] <= available_space:
new_items.append(i)
available_space -= self._w[i]
return sol + new_items
FPTAS
FPTAS的全称是Fully Polynomial Time Approximation Scheme. 给定 ϵ > 0 \epsilon>0 ϵ>0, 算法解ALG与最优解OPT的关系也满足 OPT / ALG ≤ 1 + ϵ \text{OPT}/\text{ALG} \leq 1+\epsilon OPT/ALG≤1+ϵ. 它与PTAS区别的地方在于算法的时间复杂度是关于 1 / ϵ 1/\epsilon 1/ϵ的多项式函数(PTAS是关于 1 / ϵ 1/\epsilon 1/ϵ的指数函数).
该算法基于动态规划: 设计原问题的一个动态规划算法,然后修改问题实例, 通过动态规划求解从而得到原问题的解. 修改之后的实例可以保证算法的计算复杂度是多项式时间的.
动态规划
设 f ( i , j ) f(i, j) f(i,j)代表背包中物品价值 等于 j j j所需要的最小的总体积(total weight), 其中物品来自集合 { 1 , 2 , … , i } \{1, 2, \ldots, i\} {1,2,…,i}. 令 P = max ( p i ) P=\max(p_i) P=max(pi), 因此 n P nP nP是 j j j的上限.
考虑 i = 1 , 2 , … , m i=1, 2, \ldots, m i=1,2,…,m, j = 1 , 2 , … , n P j=1, 2, \ldots, nP j=1,2,…,nP.
递归式
f ( i + 1 , j ) = { min { f ( i , j ) , f ( i , j − p i + 1 ) + w i + 1 } , if p i + 1 ≤ j f ( i , j ) otherwise f(i+1,j) = \begin{cases} \min\{f(i,j), f(i, j - p_{i+1}) + w_{i+1}\}, & \text{ if } p_{i+1}\leq j \\ f(i, j) & \text{ otherwise} \end{cases} f(i+1,j)={min{f(i,j),f(i,j−pi+1)+wi+1},f(i,j) if pi+1≤j otherwise
初始条件
f
(
1
,
p
1
)
=
w
1
f
(
1
,
0
)
=
0
f
(
i
,
j
)
=
∞
,
for the rest
i
,
j
\begin{aligned} & f(1, p_1) = w_1 \\ & f(1, 0) = 0 \\ & f(i, j) = \infty, \quad \text{for the rest } i, j \end{aligned}
f(1,p1)=w1f(1,0)=0f(i,j)=∞,for the rest i,j
Python实现
下面是上述动态规划的实现. 更多关于动态规划的标准实践可以参考 算法设计技巧: 动态规划 (Dynamic Programming).
import math
class KnapsackDP(object):
""" 背包问题的动态规划算法.
"""
def __init__(self, w, p, W):
"""
:param w: 物品大小, list
:param p: 物品价值, list
:param W: 背包大小, int
"""
self._w = w
self._p = p
self._W = W
self._n = len(self._w)
self._f = self._init_recurrence_formula()
self._result = None
def _init_recurrence_formula(self):
n = len(self._w)
f = [[]] * n
max_p = max(self._p)
for i in range(n):
f[i] = [math.inf] * n * max_p
f[0][0] = 0 # !
f[0][self._p[0]] = self._w[0]
return f
def solve(self):
n = len(self._w)
max_p = max(self._p)
# result_items保存计算的中间结果
# key = profit, value = 达到此profit所包含的一个item
result_items = {self._p[0]: 0} # 初始化
for i in range(n-1):
for j in range(n * max_p):
if self._p[i+1] <= j:
self._f[i+1][j] = min(self._f[i][j],
self._f[i][j-self._p[i+1]] + self._w[i+1])
if self._f[i][j-self._p[i+1]] + self._w[i+1] < self._f[i][j]:
result_items[j] = i+1
else:
self._f[i+1][j] = self._f[i][j]
self._result = self._get_result(result_items, self._get_profit())
return self
FPTAS
- 给定 ϵ > 0 \epsilon > 0 ϵ>0, 令 K = ϵ P / n K = \epsilon P / n K=ϵP/n.
- 令 p i ′ = ⌊ p i / K ⌋ p'_i = \lfloor p_i/K\rfloor pi′=⌊pi/K⌋, ∀ i = 1 , 2 , … , n \forall i=1,2,\ldots,n ∀i=1,2,…,n.
- 用上述动态规划求解新的实例(对应价值 p i ′ p'_i pi′), 然后输出结果.
可以证明1: ALG ≥ ( 1 − ϵ ) OPT \text{ALG} \geq (1-\epsilon) \text{OPT} ALG≥(1−ϵ)OPT, 且算法的时间复杂度为 O ( n 2 ⌊ n / ϵ ⌋ ) O(n^2\lfloor n/\epsilon\rfloor) O(n2⌊n/ϵ⌋).
Python实现
class KnapsackFPTAS(object):
""" 动态规划FPTAS.
近似比: ALG >= (1-epsilon)OPT, 时间复杂度 = O(n^2 * floor(n/epsilon))
"""
def __init__(self, w, p, W):
"""
:param w: 物品大小, list
:param p: 物品价值, list
:param W: 背包大小, int
"""
self._w = w
self._p = p
self._W = W
self._n = len(self._w)
self._result = None
def solve(self, epsilon):
k = epsilon * max(self._p) / len(self._w)
p1 = [int(x/k) for x in self._p]
dp = KnapsackDP(self._w, p1, self._W).solve()
self._result = dp.get_result()
return self
参考文献
K. Lai and M. X. Goemans. The Knapsack Problem and Fully Polynomial Time Approximation Schemes (FPTAS), lecture notes, 2006. ↩︎ ↩︎