vlambda博客
学习文章列表

常见编程模式之动态规划:0-1背包问题

16. 动态规划(DP)Part 1

动态规划是编程问题中最常见的一种模式。本质上来说,动态规划是一种对递归的优化,通过记忆化存储的方式减少重复计算的次数。在尝试用动态规划解决问题时,我们可以遵循如下的四个步骤:

  • 先思考题目中的递归关系
  • 观察递归过程中是否存在重复的运算
  • 尝试通过记忆化的方法消除重复运算(即 「记忆化搜索」)
  • 尝试调整计算顺序,自底向上通过 「查表」的方式顺序计算

本篇将介绍一种经典的动态规划类问题:「0-1 背包」问题。

0-1 背包问题(0/1 Knapsack)

0-1 背包问题是最基本的背包问题,其本质是一个优化问题。0-1 背包问题的通用形式为:给定 件物品和一个容量为 的背包,放入第 件物品耗费的「费用」是 (即背包容量),得到的「价值」是 ,求解将哪些物品装入背包可使得价值总和最大。其特点是:每种物品仅有「一件」,可以选择放或者不放。

表示前 件物品恰放入一个容量为 的背包可以获得的最大价值,则我们可以定义如下的状态转移方程:

对于“将前 件物品放入容量为 的背包中”这个子问题,如果只考虑第 件物品的策略(放或者不放),则可以将其转化为一个只和前 件物品相关的问题。如果不放第 件物品,则问题转化为“前 件物品放入容量为 的背包中”,价值为 ;如果放第 件物品,则问题转化为“前 件物品放入剩下的容量为 的背包中”,此时的价值为 加上第 件物品获得的价值 「注意」:如果当前的 小于 ,则 。伪代码如下:

上述代码省略了 的处理情况。对于上述代码,其时间和空间复杂度均为 。对于「空间复杂度」,我们可以将其优化为 。具体来说,由于我们只关心最终的结果 ,所以可以只记录当 时的结果,即使用一维数组 。这时我们需要保证在每一次循环时, 是由 递推而来。对于 ,由于 是从小到大遍历的,所以可以满足要求;而对于 ,我们需要确保当前循环下 之后更新,这样即保留了上一次循环时的值,满足条件。因此我们只需要对于 「从大到小遍历」即可,注意:此时上述 的处理不需要再执行,因为 变成了一维数组。伪代码如下:

在上述伪代码中,当物品数量为 0 时初始化的值均为 0,这可能需要基于题目的要求而进行变化。如果题目要求恰好装满背包,则除了 为 0 外,其它的 均应设为 ,因为此时没有合法解。

此外,我们还可以对第二重循环的「下限」进行进一步优化,减少循环数量:

这种优化主要适用于背包容量 很大的情况。考虑在最后一个循环时,我们最终需要计算 ,因此我们需要在上一个循环中至少计算出 。因此在第 次循环中只需要遍历 即可 ,同理,由于 ,因此在第 次循环中只需要遍历 即可。以此类推,在第 次循环时,内层循环的下限只需要设为 即可(注意上限始终为 )。

「注意」:在《背包问题九讲 2.0 beta1.2》中,作者给出的常数优化公式的下标有误,同时错误地使用了价值

416. 分割等和子集(Medium)

给定一个「只包含正整数」的「非空」数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

「示例」:

输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].

这道题看似和背包问题无关,但如果我们将元素和看做背包容量,则该问题可以转化为:

给定 N 个物品和一个容量为 sum/2 的背包,每个物品对应的容量为其元素大小,那么是否可以挑选出部分物品使得背包恰好装满?

这种背包问题与标准 0-1 背包的区别在于,不需要考虑物品价值,且要求背包恰好装满。因此我们需要对原问题的解法做两处修改,一是初始化的值,这里前面已经叙述过;二是状态转移方程的调整,数组返回的值为布尔值(能否恰好装满),且无需再最大化价值。基于这一解法,我们可以给出如下的 python 实现:

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        s = sum(nums)
        if s % 2 != 0:
            return False # 不能分为两份
        
        s = int(s / 2)
        dp = [[False for _ in range(s + 1)] for _ in range(len(nums))] # 创建二维数组,默认初始值为False,注意0表示放置一个元素(不需要考虑没有元素的情况)
        
        for i in range(len(nums)): # 容量为0,不放任何物品就完事,因此可以设为True
            dp[i][0] = True

        for j in range(1, s + 1):
            dp[0][j] = nums[0] == j # 只能放一个元素时,需要第一个元素和目标容量相等

        for i in range(1, len(nums)): # 这里从1开始即可,第一个元素已经初始化
            for j in range(1, s + 1):
                if nums[i] <= j: # 可以放第i个元素时
                    dp[i][j] = dp[i - 1][j - nums[i]] or dp[i - 1][j]
                else# 不能放第i个元素时
                    dp[i][j] = dp[i - 1][j]
        return dp[len(nums) - 1][s]

根据之前的空间复杂度优化方法,我们可以将二维数组优化为一维数组:

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        s = sum(nums)
        if s % 2 != 0:
            return False
        
        s = int(s / 2)
        dp = [False for _ in range(s + 1)] # 创建一维数组,默认初始值为False       
        
        dp[0] = True
        if nums[0] <= s # Leetcode下不写该初始化也通过(可能测试用例不全)
            dp[nums[0]] = True # 参考二维时的初始化设置(看做dp[0][j])
            
        for i in range(1, len(nums)):
            for j in range(s, nums[i] - 1-1): # 逆序遍历
                    dp[j] = dp[j] or dp[j - nums[i]]
        return dp[s]

474. 一和零(Medium)

在计算机界中,我们总是追求用有限的资源获取最大的收益。

现在,假设你分别支配着 「m」 个 0 和 「n」 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。

你的任务是使用给定的 「m」 个 0 和 「n」 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。

「示例」:

输入: Array = {"10""0001""111001""1""0"}, m = 5, n = 3
输出: 4

解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 "10","0001","1","0" 。

这道题实际上是一个「二维费用」的 0-1 背包问题,即对于每件物品,具有两种不同的费用,选择这件物品必须同时付出这两种费用。对于每种费用都有一个可付出的最大值(背包容量)。对于本题来说,将数组中的每个元素看做物品,选择该元素需要付出 0 和 1 两种费用,0 对应的背包容量为 m,1 对应的背包容量为 n,每件元素的价值均为 1,求可以放入背包的元素的最大价值(即数量)。

对于这种背包问题,我们需要添加一维状态,设 表示前 件物品付出两种费用分别为 时可获得的最大价值。则状态转移方程为:

基于前述优化空间复杂度的方法,可以只使用二维的数组,采用逆序循环。下面给出该方法的 python 实现:

class Solution:
    def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
        dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)] # 初始化二维数组
        for i in range(0, len(strs)): # 注意需要从0开始循环,遍历所有字符串
            c_i = strs[i].count('0')
            d_i = strs[i].count('1')
            for v in range(m, c_i - 1-1):
                for u in range(n, d_i - 1-1):
                    dp[v][u] = max(dp[v][u], dp[v - c_i][u - d_i] + 1)
        return dp[m][n]

关于背包问题的更多介绍可以参考著名的《背包问题九讲》。