贪心算法(2)——算法导论(22)
1. 写在前面
在上一篇博客中,我们通过选择问题了解了贪心算法。这一篇博客将继续介绍贪心算法,主要谈谈贪心算法的原理,并简单分析一下背包问题。
2. 贪心算法原理
通过上一篇博客中的选择问题,我们看到,贪心算法可以由如下几个步骤来实现:
- 确定问题的最优子结构;
- 设计一个递归算法;
- 证明如果我们做出一个贪心选择,则只剩下一个子问题;
- 证明贪心选择是安全的;
- 设计并实现贪心算法。
对比动态规划,我们发现贪心算法和它十分相似,首先它们都必须具备最优子结构性质,然后通常都是将原问题分解为子问题,根据最优子结构性质与问题的分解,设计一个递归算法。不同之处在于,动态规划算法在对问题进行分解时,由于无法确定哪一种分解能够得到原问题的最优解,因此需要考察所有的分解情况,并且正是由于这种分解问题的不确定性,通常会导致子问题重叠,为了提高效率,通常会用一个“备忘录”去备忘子问题的解;而在贪心算法中,我们很明确的知道如何分解问题能产生最优解(或者说是很明确的知道哪种选择的结果在最优解中),因此通常贪心算法没有子问题重叠重叠问题,但我们必须要确保贪心选择的正确性。
和动态规划一样,我们也总结出贪心算法的两个特点(必要条件)。
2.1. 最优子结构性质
这个性质和动态规划一样,因此不再赘述:
如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构性质。
2.2. 贪心选择性质
对于某个问题,如果我们可以通过做出局部最优(贪心)选择来构造全局最优解,那么我们就称该问题具有贪心选择性质。
3. 背包问题
下面通过两种不同形式的背包问题,来进一步说明动态规划算法与贪心算法的区别之处,进而加深对贪心算法的理解。
首先说明一下背包问题:
给定一组物品和一个限定重量的背包,每种物品都有自己的重量和价格。在限定的总重量内,我们如何选择,才能使得背包内的物品总价格最高。
在0-1背包问题中,对于每一件物品,你只能做出二元(0-1)选择,即只能选择 选择该物品或不选择该物品,而不能只选择该物品的一部分;分数背包问题相反。
我们先做如下说明:
设共有\(n\)件物品,记为\(t_1,t_2,...,t_n\),其中物品\(t_i\)重量为\(w_i\),价值为\(p_i\);限重为\(W\)。
3.1. 0-1背包
首先,我们可证明,0-1背包问题具有最优子结构性质:
因为,对于某种最优选择方案,假设\(t_i\)属于其中,如果我们拿走\(t_i\),那么原问题则变为子问题:从商品\(t_1, ...,t_{i-1},t_{i+1}, ...,t_n\)中选择某些物品,限重为\(W-p_i\)。在子问题的所有选择方案中,要想让其中的某种方案与\(t_i\)组合成原问题的一个最优方案,该方案必须是子问题的一个最优方案。用剪切-粘贴法可以很容易证明,不再赘述。
根据上面的分析,我们先这么考虑,设\(P_{T, w}\)为物品集合为\(T\),限重\(w\)时,选择的物品的最高总价值,我们可以很容易用一个递归式去表示最优解:
下面给出此递归式的Python实现:
def knapsack_0_1(T, w):
if len(T) == 1:
if T[0][2] <= w:
return T[0][1]
return 0
maxValue = 0
for i, t in enumerate(T):
if t[2] <= w:
value = t[1] + knapsack_0_1(T[:i] + T[i + 1:], w - T[i][2])
if maxValue < value:
maxValue = value
return maxValue
我们作如下测试:
if __name__ == '__main__':
'''
1号商品:$60 - 10kg
2号商品:$100 - 20kg
3号商品:$120 - 30kg
限重 50kg
'''
T = [(1, 60 , 10), (2, 100, 20), (3, 120, 30)]
W = 50
print(knapsack_0_1(T, W))
打印结果:220
重新审视上述递归式和以上的实现代码,我们发现其压根就不是动态规划算法,而只是简单的递归算法。因为它不满足动态规划问题的一个必要条件:子问题重叠。实际上它也没有充分利用最优子结构性质,而导致“重复”求解了许多问题。其时间复杂度为\(O(n!)\)。
要用上动态规划算法,我们可以这么去考虑,设\(P[i, w]\)表示物品\(t_1, ...,t_i\)在限重为\(w\)时,能够选择的物品的最高价值。我们可以用如下递归式去表示\(P[i, w]\):
简单解释一下上述递归式:当\(w = 0\)或\(i = 0\)时,显然\(P[i, w] = 0\);当\(w_i > w\)时,因为无法将物品\(t_i\)放入背包(即不能选择\(t_i\)),因此\(P[i, w] = P[i-1, w]\);第三种情况,既可以选择\(t_i\)也可以不选择\(t_i\),因此需要在这二者之间找出最大值。我们的目标是求出\(P[n, W]\)。
下面给出一个自底向上的Python实现代码:
def knapsack_0_1(T, w):
P = [[0] * (w + 1)for i in range(len(T) + 1)]
for i, t in enumerate(T):
i = i + 1 # i从0开始迭代,因此必须加上1
for j in range(1, w + 1):
if t[2] > j:
P[i][j] = P[i - 1][j]
else:
P[i][j] = max(P[i - 1][j], t[1] + P[i-1][j - t[2]])
return P
我们做同样的测试:
if __name__ == '__main__':
'''
1号商品:$60 - 10kg
2号商品:$100 - 20kg
3号商品:$120 - 30kg
限重 50kg
'''
# 注意:下面我们将重量都同步缩减为原来的0.1倍,不影响结果。
T = [(1, 60 , 1), (2, 100, 2), (3, 120, 3)]
W = 5
print(knapsack_0_1(T, W)[len(T)][W])
打印结果为:220
分析上述动态规划算法,我们发现其时间复杂度为:\(O(n \times W)\),比一开始的递归算法要好。
3.2. 分数背包
分数背包同0-1背包一样,也具有最优子结构,其证明和0-1背包差不多,这里不再赘述。
在分数背包问题中,直觉告诉我们,这样做能够总价值最高的商品:首先用平均价值最高的商品去填充背包。若背包限重还有剩余,则用平均价值第二高的商品去填充背包……之后的情况以此类推,直到背包被“填满”。先提前声明,背包一定是能被“填满”的,即所给的商品的总重量是大于背包的限重的;对于背包限重大于或等于商品总重量的“平凡”情况,没有考虑的必要。
以上的选择策略便是该问题的贪心选择。接下来我们必须证明贪心选择是安全的。
考虑在某种最优选择方案中,在第\(k\)次选择时,\(t_i\)是当前所剩的商品中平均价值最高的商品。假设在该最优方案的该次选择中,选择的商品\(t_j\)不是平均价值最高的商品,即\(j \neq i\)。我们可以采用剪切-粘贴法,即考虑用\(t_i\)去替代\(t_j\)(若\(w_i \geq w_j\),则取重量为\(w_j\)的部分\(t_i\)去替代;若\(w_i < w_j\),则取全部的\(t_i\)去替代 ),很明显,替代后的方案比之前的方案更优。因此假设不成立,即\(j = i\),即在第\(k\)次选择时,选择的商品\(t_j\)是剩余商品中平均价值最高的商品,由于\(k\)具有任意性,因此贪心选择是安全的。
有了上述的分析,我们可以很容易设计出一个贪心算法,首先将所有商品按平均价值递减的顺序排序,然后再采用如上贪心选择策略做出选择,这样可以在\(O(n\lg n)\)的时间内解决分数背包问题。
下面给出Python实现代码:
def knapsack_fraction(T, w):
T.sort(key = lambda t: t[1] / t[2], reverse= True)
p = 0
for t in T:
if w <= t[2]:
p += w * t[1] / t[2]
return p
else:
p += t[1]
w -= t[2]
作如下测试:
if __name__ == '__main__':
'''
1号商品:$60 - 10kg
2号商品:$100 - 20kg
3号商品:$120 - 30kg
限重 50kg
'''
T = [(1, 60 , 10), (2, 100, 20), (3, 120, 30)]
W = 50
print(knapsack_fraction(T, W))
打印:240.0
3.3 0-1背包 VS 分数背包
你可能会想,为什么不把用于分数背包问题的贪心算法也用于0-1呢?事实上,是不行的。从我们测试的例子中,就能看出问题。
在分数背包中,最优方案背包里的物品是:10kg的1号商品,20kg的2号商品和20kg的3号商品。注意此时3号商品只取了20kg,它被“分割”了,而在0-1背包问题中,这种“分割”是不允许的。
换个角度说,如果我们对0-1背包问题同样采用如上贪心算法,并且还要保证不能“分割”物品,那么最终我们只能将10kg的1号商品,20kg的2号商品,其总价值为$160,背包还有20kg的载重空间被白白浪费了。
再换个角度,如果把上面的对分数背包贪心选择安全性的证明套用到0-1背包问题中,我们就会发现,在证明中用\(t_i\)去替代\(t_j\)的做法不一定能够成功,原因还是因为0-1背包问题中,商品是不允许"切割"的,其重量总是一个固定值。