贪心算法(2)——算法导论(22)

1. 写在前面

上一篇博客中,我们通过选择问题了解了贪心算法。这一篇博客将继续介绍贪心算法,主要谈谈贪心算法的原理,并简单分析一下揹包问题

2. 贪心算法原理

通过上一篇博客中的选择问题,我们看到,贪心算法可以由如下几个步骤来实现:

  1. 确定问题的最优子结构;
  2. 设计一个递归算法;
  3. 证明如果我们做出一个贪心选择,则只剩下一个子问题;
  4. 证明贪心选择是安全的;
  5. 设计并实现贪心算法。

对比动态规划,我们发现贪心算法和它十分相似,首先它们都必须具备最优子结构性质,然后通常都是将原问题分解为子问题,根据最优子结构性质与问题的分解,设计一个递归算法。不同之处在于,动态规划算法在对问题进行分解时,由于无法确定哪一种分解能够得到原问题的最优解,因此需要考察所有的分解情况,并且正是由于这种分解问题的不确定性,通常会导致子问题重叠,为了提高效率,通常会用一个“备忘录”去备忘子问题的解;而在贪心算法中,我们很明确的知道如何分解问题能产生最优解(或者说是很明确的知道哪种选择的结果在最优解中),因此通常贪心算法没有子问题重叠重叠问题,但我们必须要确保贪心选择的正确性。

动态规划一样,我们也总结出贪心算法的两个特点(必要条件)。

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\)时,选择的物品的最高总价值,我们可以很容易用一个递归式去表示最优解:

\[P_{T, w} = \begin {cases} 0 & \text{若$T = \{t_i\}, w_i > w$ }\\ p_i&\text{若$T = \{t_i\}, w_i \leq w$}\\ \max\limits_{t_i \in T, w_i \leqslant w}(p_i + P_{T-t_i, w-w_i})&\text{其他} \end{cases} \]

下面给出此递归式的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]\)

\[P[i,w] = \begin {cases} 0 & \text{$w=0$或$i=0$}\\ P[i-1, w] & \text{$w_i > w, i = 1, 2,..., n $}\\ \max\limits_{w_i \leq w} \{P[i-1, w], p_i + P[i-1, w-w_i]\} & \text{$i = 1, 2,..., n$} \end{cases} \]

简单解释一下上述递归式:当\(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揹包问题中,商品是不允许"切割"的,其重量总是一个固定值。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章