目录
第一讲 01背包问题
第二讲 完全背包问题
第三讲 多重背包问题
第四讲 混合三种背包问题
第五讲 LIS问题
第一讲:01背包
问题描述:有N件物品和一个容量为V的背包。第i件物品的费用是c[i],价值是w[i]。求解将哪些物品装入背包可使价值总和最大。
基本解法:
类似此类问题我们称之为01背包问题,其基本特点为:每种类型的物品有且仅有一件,对于每一个物品的操作仅有选取和不选取两种,如果使用深度优先搜寻,检查N个物品的选取情况的组合,那么时间复杂度为O(2^n),若是物品费用,以及背包大小均为整数,就可以已动态规划的方法高效求解。
我们用二维数组dp[i][j],表示前i件物品放入一个容量为j的背包内可获得的最大价值。我们将:N件物品放入一个容量为V的背包这个问题,分解为将i个物品放入一个容量为j的背包这个结构相同,但范围更小的子问题,并通过状态转移方程式由自小向大逐步求解。
01背包问题的状态转移方程式为:
dp[i][j] = max(dp[i-1][j],dp[i-1][j-c[i]]+w[i])
这个方程式非常重要,所有背包问题均由这个方程式衍生,所以我们着重解释下这个方程式:
将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只牵扯前i-1件物品的问题。
若我们不放入第i件物品,那么问题就转化为:“前i-1件物品放入容量为j的背包” ,其最优价值为dp[i-1][j]。
若我们放入第i件物品,那么问题就转化为:“将前i-1件物品放入容量为(j-c[i])的背包内(因为放入第i件物品时背包体积为j,所以未放入第i件物品时,前i-1件物品占有的空间为(j-c[i]))”,此时最优价值为dp[i-1][j-c[i]]加上放入第i件物品获得的价值w[i]。
对于这两种操作,我们选取最优解,即为dp[i][j]的值。
列出伪代码如下:
for i=1...N
for j=1...V
dp[i][j] = max(dp[i-1][j],dp[i-1][j-c[i]]+w[i])
空间复杂度的优化:
上述状态转移方程式的时间复杂度为:N*V,空间复杂度为亦是N*V。对于时间复杂度的优化是很艰难的,我们着重讨论对于空间复杂度的优化。
我们再次分析上述状态转移方程式:
dp[i][j] = max(dp[i-1][j],dp[i-1][j-c[i]]+w[i])
我们可以看出对于第i件物品的存取策略仅直接依赖与第(i-1)件物品的存取策略。假设我们将dp[0...n][0...v]压缩为dp[0...v],在进行第i次循环时,若还未进行更新,那么此时的dp[j]和dp[j-c[i]]存放的值分别对应:dp[i-1][j]和dp[i-1][j-c[i]]。但需要注意的是,这要求我们在每次主循环的时候已 j = (V->0)的顺序来推dp[j],这样做的目的为:确保无后效性,确保dp[j],dp[j-c[i]]的值均来自上一轮,同时确保每件物品仅能选入一次。
列出伪代码如下:
for i=1...N
for j=V...c[i] (很明显 j-c[i]的值应不小于0)
dp[j] = max(dp[j],dp[j-c[i]]+w[i])
问题细分:
01背包问题可以继续向下细分为2个类型:
1.求恰好装满背包时的最优解
2.未要求恰好装满背包时的最优解
事实上,这两个问题在解决时,仅在初始化时有所不同
对于第一类问题,除dp[0]=0外,其他dp[]值均初始化为负无穷。
对于第二类问题,全部初始化为0。
我们这样理解这两个问题:对于dp的初始化,我们将其理解为在未放入任何值时,背包的最优解(合法状态)。对于第一类问题,除容量为0的背包可以被价值为0的物品“恰好装满”外,其他容量的背包均无合法解,因此我们赋予其一个极小值,以便未来可以被任何一个合法值所更新。对于第二类问题,因为未要求“恰好装满”,所以对于任何体积的背包均由一个合法解即:“不装入任何物品”,此时背包的价值为0,所以我们将dp初始化为0。几乎所有背包问题,都可细分出这两类问题,因此大家因掌握这个技巧。
第二讲:完全背包
题目描述:有N种物品和一个容量为V的背包,每种物品都有无限件可用。第i种物品的费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
基本解法:
这个问题是01问题的衍生问题,与01背包的区别在于:对于每种物品可以无限取。所以对于任意一件物品,它的存取策略包括但不局限与取或不取,而是有取0件,1件,2件……n件……等。
类似的,我们可以模仿01背包的状态转移方程式,写出完全背包的状态转移方程式:
dp[i][j] = max(dp[i-1][j-k*c[i]]+k*w[i])
接下来我们来解释这个方程式:dp[i][j]代表前i种物品放入容量为j的背包时的最优解。k代表第i件物品的选取数量,很明显 k*w[i]的值应大于等于0且小于等于j,通过枚举k的值我们可以求解方程式。
复杂度的优化:
时间复杂度的优化:完全背包问题依然有N*V个状态需要我们逐一求解,但是对于每个状态的求解已经无法在常数时间内完成。因为要枚举k值,所以对于每个状态的求解耗时为:j/c[i]。总的时间复杂度则为 P*N*V(P为非一的系数)。
相较于01背包,完全背包额外的时间消耗发生在枚举k时,所以我们思考能否在此进行优化?事实上我们可以利用二进制对其枚举过程进行优化,将(j/c[i])件物品拆分为价值为 w[i]*2^p ,重量为c[i]*2^p的若干件物品。p值不相同,那么(j/c[i])件物品将会被分为log2(j/c[i])件,从而大大节省了时间。那么为什么可以通过二进制进行优化呢?
我们分析:
2^0 = 1
2^1 = 10
2^2 = 100
2^3 = 1000
……
对于2^p-1内的数字,每一位上都有1的存在,所以我们可以通过组合相加得到2^p-1内的所有整数。
通过二进制可以将P*N*V的P降低至log级别,是非常巨大的优化,但是依然有更好的优化方法,使空间复杂与时间复杂度均降低至N*V级别的,伪代码如下:
for i=1...N
for j=0...V
dp[j] = max(dp[j],dp[j-c[i]]+w[i])
我们对比01背包问题状态压缩后的伪代码发现,两者不同的仅为:j的遍历顺序不同,前者逆序,后者正序。为什么仅改变遍历顺便可以解决完全背包问题呢?我们再次分析01背包逆序枚举的原因:“确保无后效性,确保dp[j],dp[j-c[i]]的值均来自上一轮,同时确保每件物品仅能选入一次”即:保证在考虑第i件物品的选取策略时,依据的是绝无第i件物品入选的的子结果dp[i-1][j-c[i]]。 然而完全背包对于一件物品可多次入选,所以在考虑“加选一件第i种物品”这种策略时,我们所依赖的正是一个已经入选若干i物品的子结果dp[i][j-c[i]],所以我们应当正序枚举j值。
第三讲:多重背包
题目描述:有N种物品和一个容量为V的背包。第i种物品最多有n[i]件可用,每件费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
基本解法:
多重背包和完全背包十分相似,状态转移方程式也类似:
Dp[i][j] = max(dp[i][j-k*c[i]]+k*w[i])
不同的是k的取值范围为:0<=k<=n[i]。
方程式的含义与完全背包相似,不再解释
复杂度的优化:
与完全背包相似,额外的时间支出发生在枚举k值上面,所以我们考虑能否在此处进行优化。同样的,我们考虑二进制的思想:将第i种物品分成若干件物品,其中每件物品有一个系数,这件物品的费用和价值均是原来的费用和价值乘以这个系数。使这些系数分别为1,2,4,...,2^(k-1),n[i]-2^k+1,且k是满足n[i]-2^k+1>0的最大整数。例如,如果n[i]为13,就将这种物品分成系数分别为1,2,4,6的四件物品。要注意的是拆分得到的物品的系数和为n[i],确保不可能取多于n[i]件的第i件物品。
附:二进制的优化方法已经可以解决绝大部分的多重背包问题,但对于极个别的难题,还有更优解法:应用单调队列的方法使每个状态的值可以以均摊O(1)的时间求解,最终使得时间复杂度压缩至O(N*V)详情见楼天成《男人八题》。但因为过于复杂,这里不再讨论。
第四讲:混合背包
混合背包问题一般分为以下几类:
1.01背后和完全背包的混合:部分物品只可选入一次,部分物品可无限次选入。对于此类问题我们再设计状态转移方程式是分开考虑即可:
For i 1...n
If(第i件物品仅可以选入一次)
For j V...0
Dp[j] = max(dp[j],dp[j-c[i]]+w[i])
If(第i件物品可无限次选入)
For j 0...V
Dp[j] = max(dp[j],dp[j-c[i]]+w[i])
2.01背包,完全背包,多重背包混合:部分物品只可入选一次,部分物品可入选无限次,部分物品可以入选n[i]次。对于此类问题,我们可以将符合完全背包和多重背包的物品通过二进制拆分成若干个物品,将拆分而来的物品视为仅可入选一次独立的物品,从而将混合背包问题转化为01背包问题。
3.完全背包,多重背包混合。解法与2相同。
第五讲:最长上升子序列问题(LIS Longest Increasing Subsequence)
题目描述:给定N个数,求这N个数的最长上升子序列。
基本解法:
长度为N的序列A[],其子序列有2^n个,因而采用穷举的方法耗费巨大,故而这里采用动态规划的方法。
A[i]代表数组中的第i个数字,dp[i]代表以A[i]为结尾的最长上升子序列的长度。那么在更新dp[i]时,dp[i]仅依赖与dp[1...(j-1)],很容易想出:对于a[j],若a[i]>a[j],那么dp[i]的值应当从{dp[i],dp[j]+1}中选取最大值。换而言之,dp[i]=max{dp[j]+1}(1<=j<i)。
列出伪代码:
For i 1...n
For j 1..i-1
If a[i] > a[j]
dp[i] = max(dp[i],dp[j]+1)
初始化问题:数组中的任何一个元素都可以看做长度为1的子序列,所以我们将dp初始化为1.
复杂度的优化:
上述的解法的时间复杂度为为:O(n^2),事实上,我们可以将动态规划和二分搜索结合起来,从而将时间复杂度进一步压缩至:O(n*log2(n))。
我们试图用贪心的思想去寻找最长上升子序列本身,并用数组L[]存储,那么length(L)就是最长上升子序列的长度。
例如A = {4,1,6,2,8,5,7,3}
1.L = [4] 将4加入L
2.L = [1] 1小于4,为了将来能加入更多的数,使LIS尽可能长,故而我们用a[i],替换第一个大于a[i]的数
3.L = [1,6] 6大于1,直接将6加入L
4.L = [1,2]
5.L = [1,2,8]
6.L = [1,2,5]
7.L = [1,2,5,7]
8.L = [1,2,3,7]
最终length(L) = 4 即最长上升自序列的长度为4。
因为L内的元素始终为单调不下降,所以我们在查找第一个大于a[i]的数字时,可以用二分查找的方式进行优化。
伪代码如下:
For i 1...N
If(a[i] > L[length])
L[length+1] = a[i]
Else
pos = lower_abound(L,L+length,a[i])-L
L[pos] = a[i]
问题细分:
LIS可以细分为两大类:
1.最长严格上升子序列
2.最长非严格上升自序列。
两者的区别在于:前者的LIS中 L[i] > L[i-1],后者LIS中L[i] >= L[i-1]。两个问题的解法一致,但在状态转移时要注意。
问题变形:
LIS问题有很多的变形,常见的有已下几种:
1.最长下降子序列
解法:逆序处理A[]即可!
2.环状最长上升子序列
解法:将首尾拼接!
3.最长连续上升子序列
解法:dp[i]仅依赖于dp[i-1],而非更早的状态!
附:
本讲稿部分内容借鉴了或引用了以下博客或书籍内容:
崔添翼:https://github.com/tianyicui/pack
《计算机科学及编程导论》
《挑战程序设计》
作者博客ID及地址:声声醉如兰,http://www.cnblogs.com/alan-W/转载请注明出处