算法 | 一篇文章吃透背包问题!

425 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 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 分割等和子集

leetcode.cn/problems/pa…

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 目标和

leetcode.cn/problems/ta…

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 一和零

leetcode.cn/problems/on…

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 完全平方数

leetcode.cn/problems/pe…

完全背包的最值问题:完全平方数最小为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 零钱兑换

leetcode.cn/problems/co…

完全背包最值问题:物品为 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

leetcode.cn/problems/co…

完全背包不考虑顺序的计数问题:外循环 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 组合总数

leetcode.cn/problems/co…

完全背包计数问题,需要详情的:外循环 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

leetcode.cn/problems/co…

组合背包的计数问题

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)

参考资料

题解:一篇文章吃透背包问题!(细致引入+解题模板+例题分析+代码呈现) - 力扣(LeetCode)

comzyh.com/upload/PDF/…