回溯的剪枝

234 阅读2分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第20天,点击查看活动详情

回溯不能没有剪枝,正如西方不能没有耶路撒冷(逃

即使是最简单的子集问题,使用回溯算法时复杂度都达到了比指数阶还要高的阶。而且是实实在在的达到了,或者说,对于先前的子集算法,其时间复杂度是 Θ(n2n)\Theta (n2^n)

这是一件恐怖的事,当数据过大时,我们等不起。

然而,在实际的问题中,它的实际背景给了我们一些优化的空间。让我们可以把真正要计算的结果集合或者说结果空间缩小。

例如经典的0,1背包问题。

现在的我们已经会了回溯方法,其实也就是任意层的暴力for循环枚举。对于01背包回溯要做的就是决定每个物品选或者不选。

这就和我们的子集问题是思路了。仅仅是这个思路的话,那没什么好说的,记得在最后一层计算当前的最大值并及时的调整最大值,同时可以保留最大值的解决方案。

在这里,我们重点考虑下优化,避免太多不必要的搜索。还记得我们先前说的对于子集类问题而言,选和不选是不完全一样的吗?

在这个问题里,选择意味着总价值增大的同时,重量也可能增大。重量如果大于给定的限制时,那么就没有必要继续搜索了,可以直接退出。

而如果不选,铁定是不会超过给定的限制的。这就是不一样的情况了。

但是从这方面来讲如果一直不选,到最后那怕选了可能也不会比结果更好了,但是要检测剩余可能的最大收益,也并没有那么简单,所以我们做一个额外的处理,把它当成可以分割的物品,采用贪心方法收集。

具体到代码如下:

def knapsack(pairs, M):
    # pairs: [(v, w), (v, w), ...]
    n = len(pairs)
    # sort pairs by v/w
    pairs.sort(key=lambda x: x[0] / x[1], reverse=True)
    X, BX, fv = [0] * n, [], -1

    def Rest(t, rc): # 剩余部分效益
        n, rv = len(pairs), 0
        for i in range(t, n):
            if pairs[i][1] <= rc:
                rv += pairs[i][0]
                rc -= pairs[i][1]
        if t < n:
            rv += rc * pairs[t][0] / pairs[t][1]
        return rv

    def Knap(t, cv, cw):
        nonlocal X, BX, fv
        if t >= n and cv > fv:
            fv, BX = cv, X[:]
        elif t < n:
            X[t] = 1
            if cw + pairs[t][1] <= M:
                Knap(t + 1, cv + pairs[t][0], cw + pairs[t][1])
            X[t] = 0
            if cv + Rest(t + 1, M - cw) > fv:
                Knap(t + 1, cv, cw)

    Knap(0, 0, 0)
    return fv,[pairs[i] for i in range(len(pairs)) if BX[i]]

我们通过排序让贪心算法可以使用,但是这回改变物品原来的顺序,因此我们最终返回的结果是具体的物品。这样我们把选和不选做了不同的判断、剪枝。让复杂度一定程度上降低了,尽管上界仍然是相当高的。但是实际上的输入会有一定程度的优化。