返回首页返回书籍《Hello 算法》
目录

目录 · CONTENTS

  1. 01序
  2. 02前言
  3. 03关于本书
  4. 04如何使用本书
  5. 05小结
  6. 06初识算法
  7. 07算法无处不在
  8. 08算法是什么
  9. 09小结
  10. 10复杂度分析
  11. 11算法效率评估
  12. 12迭代与递归
  13. 13时间复杂度
  14. 14空间复杂度
  15. 15小结
  16. 16练习
  17. 17数据结构
  18. 18数据结构分类
  19. 19基本数据类型
  20. 20数字编码
  21. 21字符编码
  22. 22小结
  23. 23练习
  24. 24数组与链表
  25. 25数组
  26. 26链表
  27. 27列表
  28. 28内存与缓存
  29. 29小结
  30. 30练习
  31. 31栈与队列
  32. 32栈
  33. 33队列
  34. 34双向队列
  35. 35小结
  36. 36练习
  37. 37哈希表
  38. 38哈希表
  39. 39哈希冲突
  40. 40哈希算法
  41. 41小结
  42. 42练习
  43. 43树
  44. 44二叉树
  45. 45二叉树遍历
  46. 46二叉树数组表示
  47. 47二叉搜索树
  48. 48AVL 树
  49. 49小结
  50. 50练习
  51. 51堆
  52. 52堆
  53. 53建堆操作
  54. 54Top-k 问题
  55. 55小结
  56. 56练习
  57. 57图
  58. 58图
  59. 59图的基础操作
  60. 60图的遍历
  61. 61小结
  62. 62练习
  63. 63搜索
  64. 64二分查找
  65. 65二分查找插入点
  66. 66二分查找边界
  67. 67哈希优化策略
  68. 68重识搜索算法
  69. 69小结
  70. 70练习
  71. 71排序
  72. 72排序算法
  73. 73选择排序
  74. 74冒泡排序
  75. 75插入排序
  76. 76快速排序
  77. 77归并排序
  78. 78堆排序
  79. 79桶排序
  80. 80计数排序
  81. 81基数排序
  82. 82小结
  83. 83练习
  84. 84分治
  85. 85分治算法
  86. 86分治搜索策略
  87. 87构建二叉树问题
  88. 88汉诺塔问题
  89. 89小结
  90. 90练习
  91. 91回溯
  92. 92回溯算法
  93. 93全排列问题
  94. 94子集和问题
  95. 95n 皇后问题
  96. 96小结
  97. 97练习
  98. 98动态规划
  99. 99初探动态规划
  100. 100动态规划问题特性
  101. 101动态规划解题思路
  102. 1020-1 背包问题
  103. 103完全背包问题
  104. 104编辑距离问题
  105. 105小结
  106. 106练习
  107. 107贪心
  108. 108贪心算法
  109. 109分数背包问题
  110. 110最大容量问题
  111. 111最大切分乘积问题
  112. 112小结
  113. 113练习
  114. 114附录
  115. 115编程环境安装
  116. 116一起参与创作
  117. 117术语表
  118. 118参考文献
  119. 119纸质书
第 103 / 119 节

第 103 节 · SECTION 103

完全背包问题

在本节中,我们先求解另一个常见的背包问题:完全背包,再了解它的一种特例:零钱兑换。

完全背包问题

问题

给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品可以重复选取,问在限定背包容量下能放入物品的最大价值。示例如下图所示。

完全背包问题的示例数据

动态规划思路

完全背包问题和 0-1 背包问题非常相似,区别仅在于不限制物品的选择次数。

  • 在 0-1 背包问题中,每种物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。
  • 在完全背包问题中,每种物品的数量是无限的,因此将物品 $i$ 放入背包后,仍可以从前 $i$ 个物品中选择。

在完全背包问题的规定下,状态 $[i, c]$ 的变化分为两种情况。

  • 不放入物品 $i$ :与 0-1 背包问题相同,转移至 $[i-1, c]$ 。
  • 放入物品 $i$ :与 0-1 背包问题不同,转移至 $[i, c-wgt[i-1]]$ 。

从而状态转移方程变为:

$$ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1]) $$

代码实现

对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致:

[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp}

空间优化

由于当前状态是从左边和上边的状态转移而来的,因此空间优化后应该对 $dp$ 表中的每一行进行正序遍历。

这个遍历顺序与 0-1 背包正好相反。请借助下图来理解两者的区别。

=== "<1>" 完全背包问题在空间优化后的动态规划过程

=== "<2>" unbounded_knapsack_dp_comp_step2

=== "<3>" unbounded_knapsack_dp_comp_step3

=== "<4>" unbounded_knapsack_dp_comp_step4

=== "<5>" unbounded_knapsack_dp_comp_step5

=== "<6>" unbounded_knapsack_dp_comp_step6

代码实现比较简单,仅需将数组 dp 的第一维删除:

[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp_comp}

零钱兑换问题

背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。

!!! question

给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,**每种硬币可以重复选取**,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 $-1$ 。示例如下图所示。

零钱兑换问题的示例数据

动态规划思路

零钱兑换可以看作完全背包问题的一种特殊情况,两者具有以下联系与不同点。

  • 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
  • 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
  • 完全背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。

第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表

状态 $[i, a]$ 对应的子问题为:前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币数量,记为 $dp[i, a]$ 。

二维 $dp$ 表的尺寸为 $(n+1) \times (amt+1)$ 。

第二步:找出最优子结构,进而推导出状态转移方程

本题与完全背包问题的状态转移方程存在以下两点差异。

  • 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。
  • 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。

$$ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1) $$

第三步:确定边界条件和状态转移顺序

当目标金额为 $0$ 时,凑出它的最少硬币数量为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。

当无硬币时,无法凑出任意 $> 0$ 的目标金额,即是无效解。为使状态转移方程中的 $\min()$ 函数能够识别并过滤无效解,我们考虑使用 $+ \infty$ 来表示它们,即令首行所有 $dp[0, a]$ 都等于 $+ \infty$ 。

代码实现

大多数编程语言并未提供 $+ \infty$ 变量,只能使用整型 int 的最大值来代替。而这又会导致大数越界:状态转移方程中的 $+ 1$ 操作可能发生溢出。

为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币数量最多为 $amt$ 。最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。代码如下所示:

[file]{coin_change}-[class]{}-[func]{coin_change_dp}

下图展示了零钱兑换的动态规划过程,和完全背包问题非常相似。

=== "<1>" 零钱兑换问题的动态规划过程

=== "<2>" coin_change_dp_step2

=== "<3>" coin_change_dp_step3

=== "<4>" coin_change_dp_step4

=== "<5>" coin_change_dp_step5

=== "<6>" coin_change_dp_step6

=== "<7>" coin_change_dp_step7

=== "<8>" coin_change_dp_step8

=== "<9>" coin_change_dp_step9

=== "<10>" coin_change_dp_step10

=== "<11>" coin_change_dp_step11

=== "<12>" coin_change_dp_step12

=== "<13>" coin_change_dp_step13

=== "<14>" coin_change_dp_step14

=== "<15>" coin_change_dp_step15

空间优化

零钱兑换的空间优化的处理方式和完全背包问题一致:

[file]{coin_change}-[class]{}-[func]{coin_change_dp_comp}

零钱兑换问题 II

!!! question

给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问凑出目标金额的硬币组合数量**。示例如下图所示。

零钱兑换问题 II 的示例数据

动态规划思路

相比于上一题,本题目标是求组合数量,因此子问题变为:前 $i$ 种硬币能够凑出金额 $a$ 的组合数量。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。

当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为:

$$ dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]] $$

当目标金额为 $0$ 时,无须选择任何硬币即可凑出目标金额,因此应将首列所有 $dp[i, 0]$ 都初始化为 $1$ 。当无硬币时,无法凑出任何 $>0$ 的目标金额,因此首行所有 $dp[0, a]$ 都等于 $0$ 。

代码实现

[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp}

空间优化

空间优化处理方式相同,删除硬币维度即可:

[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp_comp}