CHAPTER 22 / 数据结构与算法
动态规划与解题表达:状态必须回答一句话
最少硬币和背包为什么能复用旧答案,却不能乱改循环顺序?
这一章要弄清楚
- 用清晰语句定义状态
- 推导转移、基础值和计算顺序
- 区分可重复选择与只能选择一次
先备知识:测试与调试:独立定位失败 / 数组、哈希与双指针:重复工作从哪里删掉 / 二分、排序与堆:保留哪些候选 / 递归、树与图:沿着依赖和边访问
ISO C++20;macOS 或 Linux;仅标准库;示例是独立教学程序,非学习者验收。
先发现重复子问题
求凑出金额六的最少硬币,币值为一、三、四。如果枚举最后一枚硬币,就分别需要知道金额五、三、二的最优答案,再加一。递归展开时许多金额会反复出现。保存已经解决的子问题,就是动态规划的起点。它不是“见到最优就开一个数组”,而是确认大问题能由适当的较小状态组合,并且相同状态以后不需要重新求解。
给状态写一句完整定义:dp[s] 表示恰好凑出金额 s 的最少硬币数,允许每种币重复使用。这个定义同时固定了目标是恰好、优化的是数量、以及可重复使用。若改成不超过金额、统计方案数或每枚只能一次,转移和循环方向都会变化。很多 DP bug 来自代码写完后才想清 dp 究竟代表什么。
转移来自最后一步,而不是背公式
考虑最后选币 c,前一状态是 s-c,因此候选为 dp[s-c]+1。对所有可用正币值取最小。金额零用零枚,是基础值;不可达状态用一个明显大于任何可行答案的哨兵,且不要对最大整数直接加一导致溢出。因为币值正,依赖状态金额更小,可按金额递增计算。
对于币值一、三、四,dp[1]=1,dp[2]=2,dp[3]=1,dp[4]=1,dp[5]=2,dp[6]=2。贪心先拿四再拿一、一需要三枚,但三加三只要两枚,这说明局部选最大不总有全局最优保证。只有满足特定结构时才能用贪心替代完整 DP。
背包说明循环方向的含义
零一背包中,每件物品最多使用一次,dp[capacity] 表示在当前已处理物品范围内、不超过该容量能取得的最大价值。处理新物品时从大容量往小容量更新,这样读取 dp[capacity-weight] 时仍是没有使用当前物品的旧阶段结果。若从小到大,刚更新的小容量会被本轮再次读取,等于允许同一物品反复使用。
本章例子重量二和三,价值三和四,总容量四。合法最优值四,只能拿重量三那件;错误的正向零一更新可能把重量二拿两次得到六。用这个短反例,比在大表中寻找问题更快。空间压缩后阶段维度虽然不再显式储存,却仍然存在于循环顺序的语义中。
从答案扩展到方案
只保留最优数值不一定足以恢复选择。如果要解释用了哪些硬币,可保存每个金额最后选的币,再从目标反向减去;背包恢复可保存二维决策表或采用谨慎的重建策略。遇到多个相同最优方案时,提前定义任意一个、字典序最小或全部方案,不要让测试错误地要求唯一答案。
复杂度按状态数量乘每个状态的转移成本分析。金额为 A、币种 m 的硬币 DP 通常 O(A×m),依赖数值大小,因此是伪多项式风格的复杂度,不能仅把输入硬币个数当 n。若金额可达十亿,这个数组方案即使推导正确也可能不可用,需重新利用问题结构。
用六句话完成面试说明
先复述输入约束和答案语义;给出朴素思路及重复工作;定义状态;解释转移和基础值;说明为什么计算顺序保证依赖已就绪;最后给复杂度和边界测试。先用二维或明确阶段版本得到可信解,再做滚动数组等优化。一次只压缩一种维度,保留小规模参考解进行差分测试,避免把省空间变成难以验证的状态混用。
币值 [1,3,4] 的最少枚数
金额 0 不需要硬币;1 和 2 只能用币 1。
金额 3、4 各用一枚。
从金额 4 加币 1,或金额 1 加币 4,都是两枚。
从金额 3 加币 3 得两枚,优于 4+1+1。
阅读完整推演文字
- 基础金额
dp[0]:0;dp[1]:1;dp[2]:2
金额 0 不需要硬币;1 和 2 只能用币 1。
- 可直接使用大币
dp[3]:1 via 3;dp[4]:1 via 4
金额 3、4 各用一枚。
- 金额 5
dp[4]:1;last coin:1;dp[5]:2
从金额 4 加币 1,或金额 1 加币 4,都是两枚。
- 金额 6
dp[3]:1;last coin:3;dp[6]:2
从金额 3 加币 3 得两枚,优于 4+1+1。
跟着例子,走完一遍
最少硬币击破贪心
币值 [1,3,4],金额 6;同时测试 [2] 无法凑 3 和金额零。
- dp[0]=0。
- 每个金额枚举最后一枚币。
- dp[6] 由 dp[3]+1 得到 2。
#include <algorithm>
#include <iostream>
#include <span>
#include <stdexcept>
#include <vector>
int min_coins(std::span<const int> coins, int amount) {
if (amount < 0 || amount > 10000) throw std::invalid_argument("amount range");
for (const int c : coins) if (c <= 0) throw std::invalid_argument("positive coins required");
const int impossible = amount + 1;
std::vector<int> dp(static_cast<std::size_t>(amount) + 1, impossible);
dp[0] = 0;
for (int s = 1; s <= amount; ++s)
for (const int c : coins)
if (c <= s) dp[static_cast<std::size_t>(s)] = std::min(dp[static_cast<std::size_t>(s)], dp[static_cast<std::size_t>(s-c)] + 1);
return dp[static_cast<std::size_t>(amount)] == impossible ? -1 : dp[static_cast<std::size_t>(amount)];
}
int main() {
const std::vector<int> coins{1,3,4}, even{2};
if (min_coins(coins, 6) != 2 || min_coins(even, 3) != -1 || min_coins(coins, 0) != 0) return 1;
std::cout << "coins=" << min_coins(coins, 6) << " impossible=" << min_coins(even, 3)
<< " zero=" << min_coins(coins, 0) << '\n';
}coins=2 impossible=-1 zero=0
此题允许重复选择;只使用正币值,示例限制小金额避免无限内存要求。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 18-a.cpp -o example && ./example预期标准输出:
coins=2 impossible=-1 zero=0
零一背包逆序避免重复用物品
重量 [2,3],价值 [3,4],容量 4,每件最多一次。
- 处理第一件逆序更新容量 4、3、2,最多价值 3。
- 处理第二件逆序更新容量 4、3,得到价值 4。
- 不能用两次重量 2 的物品。
#include <algorithm>
#include <array>
#include <iostream>
#include <vector>
struct Item { int weight; int value; };
int main() {
constexpr int capacity = 4;
const std::array<Item,2> items{{{2,3},{3,4}}};
std::vector<int> dp(static_cast<std::size_t>(capacity)+1, 0);
for (const auto item : items)
for (int c = capacity; c >= item.weight; --c)
dp[static_cast<std::size_t>(c)] = std::max(dp[static_cast<std::size_t>(c)],
dp[static_cast<std::size_t>(c-item.weight)] + item.value);
if (dp[4] != 4 || dp[1] != 0 || dp[2] != 3) return 1;
std::cout << "best=" << dp[4] << '\n';
}best=4
逆序使本轮读取的较小容量仍属于上一物品阶段。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 18-b.cpp -o example && ./example预期标准输出:
best=4
压缩数组后忘记阶段
零一背包按容量从小到大更新,本轮已用当前物品的状态又被读取,暗中改成可重复使用。
修正思路:保持二维阶段推理;压缩后逆序容量,使用单件重量 2、容量 4 的反例检查。
轮到你动手
先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。
练习 1
只有一件重量 2、价值 3 的物品,容量 4。正向与逆向更新各得到什么?
给我一点提示
- 观察 dp[2] 是否本轮刚更新。
- 零一语义最多选一次。
查看答案与推理
正向更新先使 dp[2]=3,再用它更新 dp[4]=6,错误地重复选同一件;逆向先算 dp[4]=3,再更新 dp[2]=3,正确答案 3。
练习 2
在 min_coins 中记录最后一枚硬币,如何恢复金额 6 的一个最优方案?
给我一点提示
- 每次严格改进 dp[s] 时记录 choice[s]。
- 从目标往零倒推。
查看答案与推理
choice[6]=3,先记录币 3,将金额降到 3;choice[3]=3,再降到 0,得到 [3,3]。无解时先返回失败,不能在未设置 choice 的状态循环。
把理解说出来
先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。
写 DP 的第一步是什么?
参考回答 / English answer
完整定义状态含义,包括处理范围、容量是恰好还是至多、元素能否重复;然后推导转移。
Define exactly what each state means before writing the recurrence. Include the processed items, the capacity interpretation, and whether reuse is allowed.币值 1、3、4 凑 6,贪心和最优分别多少枚?
参考回答 / English answer
贪心 4+1+1 是 3 枚;最优 3+3 是 2 枚,说明该币系不保证贪心正确。
Greedy takes four plus one plus one, using three coins. The optimal solution uses two threes, so this coin system does not support that greedy rule.零一背包容量正序更新为什么错?
参考回答 / English answer
读到本轮已使用当前物品的状态,重复使用同一件,改变问题语义。
Ascending updates can read a state already improved by the current item. That allows the same item to be used again and changes the problem.dp[0] 为什么是零,而不是不可达?
参考回答 / English answer
金额零有合法空选择,成本为零;它是所有可达转移的起点。
The empty selection makes amount zero reachable with zero cost. That base state seeds every valid construction.O(amount×coin_count) 为什么可能仍太大?
参考回答 / English answer
依赖金额数值而非其编码长度;大金额会产生巨大状态数组,需根据约束重新设计。
The bound depends on the numeric amount, not just the length of its representation. A very large amount can make the state space impractical.从最优值恢复方案需要什么?
参考回答 / English answer
保留决策或足够状态供回溯,说明多个最优方案时的选择规则;空间压缩可能丢掉重建信息。
Store decisions or enough earlier states to reconstruct the choices. Space compression can discard information needed for reconstruction, so plan that requirement first.继续查证
- MIT 6.006 · Dynamic Programming I ↗
Lecture 19:子问题、记忆化与自底向上
- MIT 6.006 · Dynamic Programming II ↗
Lecture 20:状态选择与转移推导
公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。
接着看已有的图解
- 原有 C/C++ 编程题库 ↗
按当前缺口选择一题;基础练习仍使用标准 C++20,不按旧站点完成标记解锁 G0。
这些资料按主题补充本章内容。