vlambda博客
学习文章列表

动态规划入门——多重背包与单调优化,从此登堂入室




今天是算法与数据结构的第14篇文章,也是动态规划专题的第三篇。

在之前的文章当中,我们介绍了多重背包的二进制拆分的解法。在大多数情况下,这种解法已经足够了,但是如果碰到极端的出题人可能还是会被卡时间。这个时候只能用更加快速的方法,也就是今天我们要一起来看的单调优化。

单调优化是单调队列优化的简称,单调栈我们在之前的LeetCode专题已经介绍过了。它的本质只是一个简单的栈,通过在插入元素时候对栈顶的部分元素进行弹出,从而保证了栈内元素的有序。而单调队列也类似,只是插入元素的位置不同而已。栈是只能从栈顶插入,队列则是从队尾插入。

比如我们看下下图,下图就是一个典型的单调队列,队列中的元素是[10, 6, 3]。队首是10,队列当中的元素从队首开始往队尾递减。

动态规划入门——多重背包与单调优化,从此登堂入室

如果这个时候我们从队尾插入9,由于9大于队尾的3,所以3出队列,我们继续判断,发现9依然大于6,所以6再次出队列。最后得到的结果是[10, 9]。准确的说,由于我们进出队列的操作可以同时在队首或者队尾进行,所以严格说起来这并不是普通的队列,而是一个双端队列。

单调队列或者说单调栈的最大用处是由于容器内元素递增或者递减,所以栈顶或者是队首的元素就是最值。我们通过使用单调栈可以在常数时间内获得某一个区间内的若干个最值,在一些问题当中只获得一次最值还是不够的。因为种种条件的限制,所以可能使得最值不一定能够成立,这个时候需要求第二最值或者是第三最值,在这种问题下, 使用单调栈或者是单调队列就是非常有必要的了。

比如今天要讨论的多重背包问题,就是这样的情况。


基础分析


我们先把单调队列的事情先放在一边,先来仔细分析一下题目。

在之前的文章当中,我们曾经讨论过动态规划算法的复杂度问题。对于动态规划算法而言,我们要做的是遍历所有的决策,以及决策可以应用的状态,找到每个状态最佳的转移,记录这些最好的转移结果。在所有的结果当中的最值,就是我们要找的整个问题的答案。那么,我们可以很方便地推导出动态规划的复杂度,等于状态数乘上决策数。

我们记住这个简单的结论,它可以帮助我们很方便地分析动态规划算法的复杂度,尤其在一些通过传统方法不方便分析的时候。

我们把复杂度结论带入多重背包问题,对于多重背包问题来说,我们的决策是由两个条件决定的。其中一个是物品,另一个是这个物品的数量。所以决策的数量等于物品数乘上物品的个数,状态是背包的容量。我们假设物品的数量是N,物品的个数为M,背包的容量是V,那么它的复杂度就是 。显然,在绝大多数情况下,这个复杂度是我们不能接受的,也是我们需要引入种种优化的原因。
在之前的文章当中,我们通过二进制表示法,将物品的数量拆分成了若干个2次幂的和。所以对于二进制表示法而言,它的复杂度是 。我们通过二进制表示将M这一维降到了 ,那么有没有办法将它继续简化呢?
当然是有的,我们继续来分析。在NVM这三个维度当中,N是无论如何不能减少的。我们有N种物品再怎么样也得比较一下这N个物品的好坏优劣,不可能说还有策略都不考虑就得到最优结果,这是不可能的。既然N这一维度不能动,我们只能从VM这两个维度入手了。
这个M比较讨厌,我们能不能想一个办法来解决掉它呢?
在朴素的想法当中,我们需要遍历拿取的个数来找到最优的子结构。比如当前的状态是i,我们需要遍历0-M中所有的j,看看究竟dp[i]的最优解是通过哪一个j转移得到的。那么有没有办法,我们不用枚举,自动可以获取呢?
当然是有的,这个也是单调队列优化的精髓。

单调队列


下面,我们来一点一点推导单调优化的过程。
首先,我们假设当前遍历到的状态是i,也就是背包容量是i,当前的物品是item,它的体积是v,价值是p,数量是n。我们先来看第一个洞见,对于状态i而言,它只能从i-kv状态转移得到。这里的k是一个[0, min(i / v, n)]范围内的整数,min(i / v, n)这个式子我想应该大家都能看懂,就是当前状态i最多能够包含多少个物品item。这个数量是物品数量的上限n和i这个状态最多装得下的数量i / v中较小的那个,我们令min(i / v, n)这个值叫做cnt。
也就是说对于状态i而言,它最多包含cnt个item,最少包含0个。那么它的转移可能性一共只有cnt种,而不可能从i-1以及i-2等其他状态转移到。我们写出这个状态转移方程,可以得到:
也就是说在当前item也就是当前决策下,状态i的最好结果一定是由一系列确定的子状态其中之一转移得到的,并且这些子状态和一个整数k挂钩,k的取值范围是[0, cnt]。我们先暂时忽略这个范围,简化问题。考虑最极端的情况,最极端的情况这个物品数量管够,在这种情况下,我们可以列一下可以通过item转移到i的所有状态,它是一个序列:[i % v, i % v + v, i % v + 2v, ..., i - v]。
在之前裸的做法当中,我们是通过一重循环来遍历了这个序列,从其中找到了最佳的转移。我们现在希望可以不用遍历,通过某种方法快速找到其中最优的状态进行转移。这个逻辑应该不难理解,到这里,我们没有引入任何花哨的操作。
我们下面来做一点简单的分析,我们已经列举出了对于状态i所有可能转移到的上游状态。我们不希望通过遍历来找到其中最佳的转移,顺着这个思路,我们大概可以猜测一下,应该通过某种方法找到这个序列当中的某个最值。只有这样,我们才可以不需要遍历,快速找到答案。但是通过什么方法,寻找什么最值我们现在还不知道。到这里,我们又往前进了一步,大概知道了接下来的策略,但是具体的细节我们还不知道,没关系,我们先放一放,继续进行分析。
为了简化书写,我们令 m = i % v,也就是当前状态对物品item体积的余数。那么上面的那个序列可以写成[m, m+v, m+2v, ... i - v]。由于在本问题当中,我们希望背包里的价值越大越好,所以显然对于dp[m], dp[m+v], dp[m+2v]... 这个序列而言,它是递增的。原因也很简单,对于每一个状态而言,dp数组当中都存储的它对应的最优结果。所以不可能出现我们用了更多的空间,但是背包里的价值却减少的情况。
我们当然希望可以简单地从dp[m], dp[m+v], dp[m+2v]...dp[i-v]这个序列当中选取最大的那个,但是由于上面这个结论,所以我们并不能这么做。不能这么做的原因有两个,一个刚才说了,因为dp[i]是一个随着i递增而递增的序列,背包装的东西越多,装的价值只会越大,不会减少。还有一个原因是后效性,这个问题和零一背包的情况有些相似。举个例子,比如说dp[m] = x,如果dp[m+v]=x+p,也就是说dp[m+v]由dp[m]转移得到,代表它已经装了一个物品item。如果我们再从dp[m+v]进行转移,我们则无法判断到底一共拿取了多少个物品,也就无法判断是否违法。
这两个问题,我们一个一个来解决,先说第二个问题。这个问题解决的方法很多,最简单的就是将这个序列的结果单独存储一份,使得当我们更新dp的时候不会影响。在零一背包当中我们通过倒叙遍历来解决了这个问题,但是在多重背包当中,这种方法不太适用。接着我们来看第一个问题,我们直接找到序列最值不可行的原因是因为背包容量引起了不公,为了解决问题,我们需要想办法消除这种不公。消除的办法也简单,我们可以通过某种方法,将这些值放到同一个基准,消除因为容量变化引起的不公。
实际上这个基准很好找,就是m。我们假设dp[m], dp[m+v], dp[m+2v]...dp[i-v]这个序列当中的值都是通过dp[m]转移得到的。比如dp[m+v],如果是从dp[m]转移得到的,那么dp[m+v]应该等于dp[m]+p。同理,dp[m+2v]应该等于dp[m]+2p。这里需要注意,这里的数据都是没有经过item物品更新过的结果,是上一个物品更新之后得到的值。所以这里的dp[m+v]一定不是通过dp[m]转移得到的,如果dp[m+v] - p > dp[m],那么显然可以说明dp[m+v]的潜力要比dp[m]更大,因为同样的体积v,它创造了更多价值。同理,如果dp[m+2v] - 2p > dp[m+v] - p,则说明dp[m+2v]的价值更大。以此类推, 我们可以得到一个全新的序列:
[dp[m], dp[m+v] - p, dp[m+2v] - 2p, ... dp[i-v] - (i div v - 1)p]
这个时候,我们已经消除了背包容量变化带来的偏差,我们可以放心地从其中选择最值作为最佳的状态转移了。但是还有一个小问题,有可能最值是不成立的,举个例子,如果说我们发现dp[m+2v] - 2p的值是最大的,但是由于item这个物品最多获取cnt个,如果从m+2v这个状态转移到i,需要的数量超过cnt,那么这也是一个无效的转移。我们需要抛开它,继续往下查找次优的结果。
对于区间内最值的维护,单调队列非常合适,我们可以保证队首的元素是最优的,如果队首的元素不合法,那么我们可以很方便地弹出获取次优解。也就是说我们通过单调队列维护了[dp[m], dp[m+v] - p, dp[m+2v] - 2p, ... dp[i-v] - (i div v - 1)p]这个区间的最值,这也是单调队列最常用的场景。

算法流程


我们整理一下上面的思路,可以整理出整个算法运行的流程。
首先我们需要一重循环来遍历物品,这个是肯定跑不了的。无论用什么算法用什么优化,我们都需要遍历物品,物品是决策的基础。在01背包和二进制表示法当中,第二重循环就是直接遍历背包容量了。但是显然,在当前算法当中,我们不能这么做。因为前文当中说到,我们需要用单调队列来维护[dp[m], dp[m+v] - p, dp[m+2v] - 2p, ... dp[i-v] - (i div v - 1)p]这样一个序列,所以我们需要按照这个序列的顺序来遍历背包容量。我们关注到起始状态是dp[m],这个m代表分组,也就是物品体积的余数。
举个例子,如果物品i的体积是5,那么m有0,1,2,3,4这5种取值,其实这也是5的余数。相当于我们通过余数将背包容量进行分组,这样我们维护不同分组下的序列。这些分组拼装在一起就是整个背包容量。
下面我们来看下代码,结合上面的叙述会更直观一些:

from collections import deque
items = [(10, 3, 5), (5, 6, 3), (2, 2, 4)]

if __name__ == '__main__':
volume = 20
dp = [0 for _ in range(volume+1)]
# 单调队列
deq = deque()
for item in items:
cnt, v, p = item
for i in range(v):
# 每个i代表一组新的序列
# 所以队列需要清空重新开始
deq.clear()
m = (volume - i) // v
for j in range(m+1):
val = dp[j * v + i] - j * p
while len(deq) > 0 and val >= deq[-1][0]:
deq.pop()
deq.append((val, j))
if deq[0][1] < j - cnt:
deq.popleft()
dp[j * v + i] = deq[0][0] + j * p
print(dp[20])

由于我们要实现单调队列,并且从左右两端都会进行读取操作,所以我们使用双端队列来实现。在之前的Python专题的文章当中我们介绍过Python中deque的用法。除此之外,上面这段代码当中还有很多细节,我们一点一点来看。
首先我们来看循环变量,最外层循环的是item,这个已经确定了,i循环的是v的余数。即商品i的重量的余数,也就是对于商品i来说整个被背包容量分组的数量。然后我们针对每一个分组单独计算最优值。m表示这个数列的数量,就是背包容量减去余数之后除以商品的体积。所以我们要维护的序列就是[i, i+v, i+2v,..., i+mv],j循环的是这个序列。
为了公平起见,我们要用dp[i]作为标准来衡量整个序列当中的价值。对于状态j * v + i来说,我们把它减去j个item的价值,也就是放到dp[i]一个起跑线来衡量价值。所以val = dp[j * v + i] - j * p。
针对单调队列deq来说,这是一个递减的队列。也就是说队列头部的元素是最大值,末尾是最小值。我们每次从尾端进行插入,弹出所有比当前小的元素,之后我们插入当前的候选值。根据我们之前的推论,由于dp数组当中的值在一轮遍历之后会更新,所以我们需要把当前的值和状态一起存储起来,不能只存储下标,否则当更新过dp[j * v+ i]之后,就找不回来更新之前的值了。
这些边界条件应该还好,问题不是很大,问题比较大的是if deq[0][1] < j - cnt这个判断。这个判断当中隐藏了两个要点,我们一个一个来说。
首先,deq这个队列当中每个节点有两个值,一个是val就是dp数组当中存储的价值,另一个是j,是序列的位置或者说状态,注意,它不等于物品拿了的个数。由于题目当中有物品拿取数量的限制,也就是说并不是所有的转移都是合法的。我们需要保证从队列的状态转移到当前状态需要的物品个数小于等于最大数量cnt,如果大于就是非法。当前状态是j,队列头部元素状态是deq[0][1],也就是说 j - deq[0][1] > cnt的话就非法。
因为deq队列当中头部元素值是最大的,所以我们优先考虑从头部进行转移到当前状态。转移是通过拿取物品进行的,所以我们需要进行物品数量的判断,如果不满足需要进行弹出,这个是第一个要点。
第二个点是,为什么这里用if判断而不是while呢?
因为对于j来说,j-1的状态已经更新过了,也就是说队列头部的元素必然对j-1是合法的。也就是说j-1的数量距离j-1最多也就是cnt,那么对于j而言最多也就增加了1,我们最多只需要一次弹出就好了。当然也可以用while循环,只不过没有必要。所以如果看不懂的话,这里写成while循环也是一样的。
最后,我们来分析一下它的复杂度。对于物品i来说,它如果它的体积是v,那么我们一共可以分成v组。每组当中的数量是volume / v,所以这两者相乘就刚好是背包体积V的状态数。你可能还会觉得我们使用了单调队列会有开销,的确,但每一个元素最多进出单调队列一次,也就是说,对于每一组序列,单调队列是 的复杂度,和遍历的复杂度一致,所以使用单调队列并不会整体增加复杂度的规模,只会增加常数。

总结


到这里,多重背包的单调优化解法就介绍完了。单调优化是动态规划算法当中非常常用的一种优化方法,并不只是可以应用在多重背包问题上,在许多场景下都适用。不过前提是需要我们对于状态之间的关系,以及转移前后带来的影响全部思考清楚。
从代码上来说,动态规划实在是非常简单,一般不会超过几十行,我们今天的算法主体也才12行,但是这短短的代码中间藏了大量的信息和思考。对于初学者而言,第一次学习的时候会一脸懵逼是正常的,但是仔细冷静下来,少关注一些术语,多思考一些本质的原理,其实没那么难,一遍看不懂多看几遍,总会有那么一个时刻,让你有灵光一闪的感觉。
今天的文章就是这些,如果觉得有所收获,请顺手点个在看或者转发吧,你们的举手之劳对我来说很重要。