开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第 2 天,点击查看活动详情
背包定义
给定一个背包容量 target,再给定一个数组 nums(物品),能否按一定方式选取 nums 中的元素得到 target
注意:Target 可能非显式,需要计算
背包问题 (Knapsack problem) 是一种组合优化的 NP (NP-Complete) 完全问题。NP 完全问题:多项式复杂程度的非确定性问题。通常我们认为计算机可解决的问题只限于多项式时间内,而O(2(N))、O(N!)这类非多项式级别的问题,其复杂度往往已经到了计算机都接受不了的程度。
背包分类
常见的背包类型主要有以下几种:
- 0-1 背包:对于每个元素来说,只有两个选:选和不选;也就是说,每个元素只能选取一次
- 完全背包:每个元素可以重复选择
- 组合背包:背包中的物品要考虑顺序
- 分组背包:不止一个背包,需要遍历每个背包
而每个背包要求的问题也是不同的,按照问题分类,又可以分为:
- 最值问题:最大值/最小值
- 存在问题:是否存在...,满足...
-
计数问题:求所有满足...的排列组合,有的题目只要求计算总数,有的要求列举详情
解题模板
背包问题大体的解题模板是两层循环,分别遍历物品 nums 和背包容量 target,然后写状态转移方程。
根据背包的分类我们确定物品和容量遍历的先后顺序,根据问题的分类确定状态转移方程的写法。
首先是背包分类的模板:
- 0-1 背包:外循环 nums,内循环 target,target 倒序
- 完全背包:外循环 nums,内循环 target,target 正序
- 组合背包:外循环 target,内循环 nums,target 正序
-
分组背包:需要三重循环,外循环背包 bags,内部循环根据题目的要求转化为 1,2,3 三种背包类型的模板
Q1:为什么遍历 target 时, 0-1 背包必须倒序,而完全背包必须正序?
递推公式为 dp[j] *= dp[j - num] (*号表示 |、+、min、max等操作),也就是说,dp[j] 依赖于 dp[j - num] 的结果。那么希望 dp[j - num] 是处理过当前物品还是没有处理过的呢?
很显然,0-1 背包对于每个物品,只允许选一次。也就是说,如果 dp[j - num] 中包含了 nums[i],那么 dp[j] 则不能取该物品,递推公式也就不能成立。所以为了防止 dp[j - num] 被当前物品污染,需要从后往前遍历 target,确保 dp[j] 所依赖的 dp[j - num] 没有接触过当前物品。
而完全背包中,每个物品允许多次选择。而且为了达成更优解,必须尝试多次获取同一物品。在处理 dp[j] 时,希望 dp[j - num] 已经处理过 num[i],得到更优的 dp[j - num] ,而 dp[j] 将基于这个更优的结果进行计算。所以完全背包这里,正序也是必须的。
Q2:为什么有时候 nums 为外循环,有时候 target 为外循环?如何区分使用哪个?
思考这样一个问题:循环处理 nums 时,假设当前处理的数为 num,对于 dp[i - num],是否希望其中包含了对 num 本身的处理结果呢?
0-1 背包:nums 为外循环
在 0-1 背包中,每个物品只允许选取一次,不希望 dp[i - num] 包含 num 处理结果。所以将 nums 放在外层,与 Q1 希望达成的效果是一致的:dp[i - num] 不会被当前物品污染。
完全背包:nums 为外循环 VS 组合背包: target 为外循环
完全背包和组合背包的主要区别是,是否关注结果的顺序。比如完全背包中认为 [1,2,1] 和 [1,1,2] 是同一种选取方式,而组合背包则视作不同。
在完全背包中,为了避免将 [1,2,1] 和 [1,1,2] 视作不同,先集中处理一个 num 可能达成的结果,再处理下一个... 因此将 nums 放在外层。
在组合背包中,使用 dp[i - num] 时,是允许其中包含 num 的处理结果的,因为最新的 num 与之前的 num 处于不同的位置,不会产生重复结果。
然后是问题分类的模板:有没有?有几种?最值是?
- 存在问题:dp[i] |= dp[i - num]
- 计数问题:dp[i] += dp[i - num]
-
最值问题:dp[i] = max/min(dp[i], dp[i - num] + 1)
典例剖析 [ 🔥 : HOT 100]
0-1 背包
🔥 416 分割等和子集
0-1 背包存在性问题:是否存在一个子集,其和为 target = sum / 2,外循环 nums,内循环 target 倒序,应用状态方程 2
var canPartition = function(nums) {
const sum = nums.reduce((num, acc) => num + acc, 0)
if(sum % 2 !== 0) return false // 不是偶数
const target = sum / 2
const n = nums.length
// dp 含义:能否恰好满足容量为 j 的背包
const dp = new Array(target + 1).fill(false)
dp[0] = true // 容量为 0,啥都不选
for(let i = 0; i < n; i += 1) {
for(let j = target; j > 0; j -= 1) {
dp[j] |= dp[j - nums[i]]
}
}
return dp[target]
};
🔥 494 目标和
0-1 背包不考虑元素顺序的计数问题:选 nums 里的数得到 target 的种数,外循环 nums,内循环 target 倒序,应用状态方程 3
var findTargetSumWays = function(nums, target) {
const n = nums.length
const sum = nums.reduce((num, acc) => num + acc, 0)
if((sum + target) % 2 !== 0 || Math.abs(sum) < Math.abs(target)) return 0
const t = (target + sum) / 2
const dp = new Array(t + 1).fill(0)
// 递推公式:dp[i] += dp[i - num]
dp[0] = 1
for(let i = 0; i < n; i += 1) {
for(let j = t; j >= nums[i]; j -= 1) {
dp[j] += dp[j - nums[i]]
}
}
return dp[t]
};
474 一和零
0-1 背包最值问题:外循环 nums,内循环 target 倒序,应用状态方程。target:有两个维度,m 和 n,注意同样是倒序的。
var findMaxForm = function(strs, m, n) {
// 背包容量:m 个 0 和 n 个 1
// strs 就是物品,选还是不选
// 最值问题
// 先计算得到每个 str对应的 0 和 1 的个数
let count = {}
const set = new Set(strs)
for(const str of set) {
let arr = [0, 0]
for(const c of str) {
arr[c] += 1
}
count[str] = arr
}
const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0))
for(let i = 0; i < strs.length; i += 1) {
const str = strs[i]
const [zero, one] = count[str]
for(let j = m; j >= zero; j -= 1) {
for(let k = n; k >= one; k -= 1) {
dp[j][k] = Math.max(dp[j][k], dp[j - zero][k - one] + 1)
}
}
}
return dp[m][n]
};
完全背包
🔥 279 完全平方数
完全背包的最值问题:完全平方数最小为1,最大为 sqrt(n),题目转换为在 nums = [ 1, 2, 3 ... sqrt(n)] 中选任意数平方和为 target = n。外循环 nums,内循环 target,转移方程 1
var numSquares = function(n) {
// target 就是 n, nums 则是 1 至小于 根号n 的数字
// 外循环 nums 内循环 target
// 状态转移方程:dp[j] = Math.min(dp[j], dp[j - i * i] + 1)
const m = Math.floor(Math.sqrt(n))
const dp = new Array(n + 1).fill(n)
dp[0] = 0
for( let i = 1; i <= m; i += 1) {
const num = i * i
for(let j = num; j <= n; j += 1) {
dp[j] = Math.min(dp[j - num] + 1, dp[j])
}
}
// console.log(dp)
return dp[n]
};
🔥 322 零钱兑换
完全背包最值问题:物品为 coins,背包容量为 amount,计算最少硬币数。外循环 nums,内循环 target 正序,应用最值方程
var coinChange = function(coins, amount) {
// 最少硬币
const dp = new Array(amount + 1).fill(Infinity)
dp[0] = 0
for(let i = 0; i < coins.length; i += 1) {
const coin = coins[i]
for(let j = coin; j <= amount; j += 1) {
dp[j] = Math.min(dp[j], dp[j - coin] + 1)
}
}
return isFinite(dp[amount]) ? dp[amount]: -1
};
518 零钱兑换 II
完全背包不考虑顺序的计数问题:外循环 nums,内循环 target 正序,应用计数方程
var change = function(amount, coins) {
const dp = new Array(amount + 1).fill(0)
dp[0] = 1
for(let i = 0; i < coins.length; i += 1) {
const coin = coins[i]
for(let j = coin; j <= amount; j += 1) {
dp[j] += dp[j - coin]
}
}
return dp[amount]
};
🔥 39 组合总数
完全背包计数问题,需要详情的:外循环 nums,内循环 target,正序
var combinationSum = function(candidates, target) {
const dp = new Array(target + 1).fill(0).map(() => [])
//console.log(dp)
dp[0] = [[]]
for(let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i]
for(let j = candidate; j <= target; j += 1) {
const arr = dp[j - candidate]
for(const item of arr) {
const tmp = [...item, candidate]
dp[j].push(tmp)
}
}
}
return dp[target]
};
组合背包
377 组合总数 IV
组合背包的计数问题
var combinationSum4 = function(nums, target) {
const dp = new Array(target + 1).fill(0)
dp[0] = 1
for(let i = 1; i <= target; i += 1) {
for(let j = 0; j < nums.length; j += 1) {
const num = nums[j]
if(i >= num) dp[i] += dp[i - num]
}
}
return dp[target]
};
PS:前端知识系列思维导图火热更新中,快来点个收藏吧~~ ☞ 前端知识体系思维导图 - 掘金 (juejin.cn)