【动态规划】背包问题的应用

0-1背包问题的应用

应用1:Leetcode.416

题目

416.分割等和子集

分析

\(dp[i][j]\) 表示取数组 \(nums\) 中前 \(i\) 个元素,可以凑成和为 \(j\) 的种类是否大于 \(0\)。同时假设,设数组 \(nums\) 中所有元素的和为 \(S\) ,长度为 \(n\)

如果 \(S\) 不能被 \(2\) 整除,那么,所有的元素肯定不能分割为等和子集。

如果 \(S\) 可以被 \(2\) 整除,那么,我们就可以将数组中的元素看作物品,背包容量为 \(S/2\),题目就可以转换为0-1背包问题,最后答案就是 \(dp[i][S/2]\)

边界条件

边界条件就是凑成和为零的方案,即对于任何数字都不选,它也对应一种方法,所以

\[dp[i][0] = true, \quad 0 \le i \lt n \]

状态转移

对于数组中的第 \(i\) 个元素 \(nums[i - 1]\) ,它有两种选择:

  • 如果不选择当前元素,则当前状态与 \(dp[i-1][j]\) 相同;

  • 如果选择当前元素,则当前状态与 \(dp[i - 1][j-nums[i - 1]]\) 相同。

也就是说,我们只需要从上述两种方案中,选择一个结果为 \(true\) 的方案即可,所以,状态转移方程:

\[dp[i][j] = dp[i - 1][j] \ | \ dp[i - 1][j - nums[i - 1]], \quad nums[i - 1] \le j \le S/2 \]

代码实现

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        n = len(nums)
        _sum = sum(nums)
        if _sum % 2 != 0:
            return False
        _sum = _sum // 2
        dp = [[False for _ in range(_sum + 1)] for _ in range(n + 1)]
        for i in range(n + 1):
            dp[i][0] = True

        for i in range(1, n + 1):
            for j in range(1, _sum + 1):
                # 背包容量不足,不能装入nums[i-1]
                if j < nums[i-1]:
                    dp[i][j] = dp[i - 1][j]
                else:
                    dp[i][j] = dp[i - 1][j] | dp[i - 1][j - nums[i - 1]]
        return dp[n][_sum]

对其压缩状态,优化后的实现:

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        n = len(nums)
        _sum = sum(nums)
        if _sum % 2 != 0:
            return False
        _sum = _sum // 2

        dp = [False] * (_sum + 1)
        dp[0] = True
        for i in range(1, n + 1):
            for j in range(_sum, 0, -1):
                if j >= nums[i - 1]:
                    dp[j] = dp[j] | dp[j - nums[i - 1]]
        return dp[_sum]

完全背包问题的应用

应用1:Leetcode.518

题目

518. 零钱兑换 II

分析

\(dp[i][j]\) 表示使用前 \(i\) 枚金币能凑出金额 \(j\) 的凑法数量。

边界条件

使用任何面额的金币,凑出金额 \(0\) 的数量都是 \(1\),也就是说不使用金币,它对应的凑法数量为 \(1\),即:

\[dp[i][0] = 1, \quad 0 \le i \le amount \]

状态转移

对于数组 \(coins\) 中的第 \(i\) 枚金币 \(coins[i - 1]\) ,都有两种选择:

  • 当前金额 \(j\) 小于当前的金币的面额,凑法数量就是不选择当前面额的金币的种数,即:

    \[dp[i][j] = dp[i - 1][j], \quad j \lt coins[i - 1] \]

  • 当前金额 \(j\) 大于等于当前的金币面额,此时,可以选择也可以不选择当前金币:

    • 如果不选择,那么,凑法数量就是与前一个状态相同,即:

      \[dp[i][j] = dp[i - 1][j], \quad j \ge coins[i - 1] \]

    • 如果选择,那么,凑法数量就是在当前状态下,多次选择当前面额的金币种数,即:

      \[dp[i][j] = dp[i][j - coins[i - 1]], \quad j \ge coins[i - 1] \]

    因此,当面额总数 \(j\) 大于当前金币面额时,凑法数量就是上述两种选择之和:

    \[dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]], \quad j \ge coins[i - 1] \]

综上,结合上述两种场景,状态转移方程为:

\[dp[i][j] = \begin{cases} dp[i - 1][j] , & j \lt coins[i - 1]\\ dp[i - 1][j] \ + \ dp[i][j - coins[i - 1]], & j \ge coins[i - 1] \end{cases} \]

代码实现

from typing import List

class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        n = len(coins)
        dp = [[0 for _ in range(amount + 1)] for _ in range(n + 1)]
        for i in range(0, n + 1):
            dp[i][0] = 1

        for i in range(1, n + 1):
            for j in range(1, amount + 1):
                if j - coins[i - 1] >= 0:
                    dp[i][j] = dp[i][j - coins[i - 1]] + dp[i - 1][j]
                else:
                    dp[i][j] = dp[i - 1][j]
        return dp[n][amount]

if __name__ == "__main__":
    s = Solution()
    print(s.change(amount=5, coins=[1, 2, 5]))

对其压缩状态,优化后的实现:

from typing import List

class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        dp = [0 for _ in range(amount + 1)]
        dp[0] = 1

        for i in range(1, len(coins) + 1):
            for j in range(1, amount + 1):
                if j - coins[i - 1] >= 0:
                    dp[j] = dp[j - coins[i - 1]] + dp[j]
        return dp[amount]

应用2:Leetcode.322

题目

322. 零钱兑换

方法一:自底向上动态规划

分析

简而言之,题目等价于:

\(N\) 种物品和一个容量为 \(W\) 的背包。第 \(i\) 种物品的重量是 \(w_i\),每种物品的数量都有无限个。求解将哪些物品装入背包可使这些物品的重量刚好等于背包容量,且物品数量最少。

假设 \(dp[i]\) 表示金额 \(i\) 最少可以兑换的硬币数量。

边界条件

如果金额为零,显然最少的硬币数量为零,即:

\[dp[0] = 0 \]

状态转移

这里,我们直根据完全背包问题的模板接给出状态转移方程:

\[dp[i] = \min^{n - 1}_{j = 0}\{dp[i - coins[j]] + 1\}, \quad i \ge coins[j] \]

代码实现

from typing import List

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        n = len(coins)
        dp = [float("INF")] * (amount + 1)

        dp[0] = 0
        for i in range(1, n + 1):
            for j in range(1, amount + 1):
                if j >= coins[i - 1]:
                    dp[j] = min(dp[j], dp[j - coins[i - 1]] + 1)

        result = dp[amount]
        if dp[amount] == float("INF"):
            result = -1

        return result

方法二:自顶向下动态规划

分析

这里我们利用分治的思想:通过定义一个带返回值的递归函数,将问题分解为子问题(子树),通过递归推导出答案。

我们定义一个函数 traverse(coins: List[int], amount: int, memory: List[int]) 表示:当硬币为 \(coins\) 时,凑齐金额 \(amount\) 的最小数量,其中,\(memory\) 记录已经搜索过的结果,避免重复计算。

代码实现

from typing import List

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        memory = [0] * amount
        return self.traverse(coins, amount, memory)

    def traverse(self, coins: List[int], amount: int, memory: List[int]) -> int:
        if amount == 0:
            return 0
        if amount < 0:
            return -1
        # 避免重复搜索
        if memory[amount - 1] != 0:
            return memory[amount - 1]

        answer = float("INF")
        for coin in coins:
            result = self.traverse(coins, amount - coin, memory)
            if result == -1:
                continue
            answer = min(answer, result + 1)

        if answer == float("INF"):
            answer = -1
        # 记录已经计算过的结果
        memory[amount - 1] = answer
        return answer

应用3:Leetcode.337

题目

377. 组合总和 Ⅳ

分析

这道题如果直接使用回溯暴力搜索组合数,会导致内存超出限制。

这里,我们考虑使用动态规划求解,即在给定数组 nums 中,选择若干元素的组合,使它们的和等于 target,其中,元素可以多次重复选择。

这道题与完全背包问题的模型类似,但是,这道题的差异是:选择的不同物品的顺序代表不同的方案,因此,我们需要求出满足条件的所有物品的排列数。

假设数组的长度为 \(n\),我们定义 \(dp[i][j]\) 表示选择 \(i\) 个元素,凑成和为 \(j\) 的方案数,那么,最后要求的答案就是

\[answer = \sum_{i = 0}^{target}dp[i][target] \]

把所有的个数组合都选择一遍,然后将每个数字对应的所有方案数求和,就是最后的答案。

边界条件

当不选择任何元素时,只有一种方案,因此,有

\[dp[0][0] = 1 \]

状态转移

对于任意的 \(dp[i][j]\) 而言,组合中的最后一个数可以选择 \(nums\) 中的任意一个值,即:

  • 如果最后一个数选择 \(nums[0]\),则方案数为:\(dp[i-1][j - nums[0]]\);

  • 如果最后一个数选择 \(nums[1]\),则方案数为:\(dp[i-1][j - nums[1]]\);

  • 如果最后一个数选择 \(nums[2]\),则方案数为:\(dp[i-1][j - nums[2]]\);

  • \(\cdots\)

因此,当选择 \(i\) 个元素时,可以凑成和为 \(j\) 的组合数应该为上述所有方案的总和,即

\[dp[i][j] = \sum_{k=0}^{n - 1} f[i - 1][j - nums[k]], \ j \ge nums[n] \text{, and } 0 \le i \le target \]

那么,我们只需要遍历所有的组合,并累加组合,即可得到答案。

这里需要注意,物品种类物品个数的区别:

  • 完全背包是从背包中选择 \(i\) 类物品,每类物品可以无限制重复地选择,因此,状态转移可以从当前状态 \(i\) 继续转移;

  • 这道题是从背包中选择 \(i\) 个物品,它们可以是同一类物品,也可以是多类物品,选择的物品总数固定(能选择的物品总数不会超过 \(target\)),因此,状态转移需要从上一个状态 \(i - 1\) 转移。

代码实现

class Solution:
    def combinationSum4(self, nums: List[int], target: int) -> int:
        result = 0
        dp = [[0 for _ in range(target + 1)] for _ in range(target + 1)]
        dp[0][0] = 1
        length = target
        # 遍历结果集中的物品数量,所有凑成和为target时,结果集中最多可以选择target个物品
        for i in range(1, length + 1):
            # 遍历可以凑成的和
            for j in range(target + 1):
                # 尝试选择每一个物品
                for num in nums:
                    if num <= j:
                        # 累加可以选的物品的总数之和
                        dp[i][j] += dp[i - 1][j - num]
            result += dp[i][j]
        return result

优化后

class Solution:
    def combinationSum4(self, nums: List[int], target: int) -> int:
        dp = [0] * (target + 1)
        dp[0] = 1
        for i in range(1, target + 1):
            for num in nums:
                if num <= i:
                    dp[i] += dp[i - num]

        return dp[target]

参考:

posted @ 2023-01-09 21:26  LARRY1024  阅读(37)  评论(0编辑  收藏  举报