322. 零钱兑换
给你一个整数数组
coins,表示不同面额的硬币;以及一个整数amount,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回
-1。你可以认为每种硬币的数量是无限的。
cpp
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
// 1. 定义 DP 数组
// dp[j] 表示:凑成金额 j 所需的 **最少** 硬币个数
vector<int> dp(amount + 1, INT_MAX);
// 2. 初始化
// 凑成金额 0 需要 0 个硬币
dp[0] = 0;
// 3. 状态转移(完全背包:求组合的最小值)
// 外层物品,内层背包正序遍历
for(int i = 0; i < n; i++){
for(int j = coins[i]; j <= amount; j++){
// 递推公式:求最小值
if(dp[j-coins[i]]==INT_MAX) continue;
// dp[j] = min(不用当前硬币, 使用当前硬币)
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
// 4. 判断结果
// 如果 dp[amount] 没被更新过,说明无法凑出,返回 -1
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
};
总结
1. 为什么是求组合,不是求排列?
因为我们只关心"最少几张纸币",[1, 1, 5] 和 [5, 1, 1] 凑出 7 块钱都是 3 张硬币。所以外层物品、内层背包是绝对正确的。
2. 为什么 dp[0] = 0?
这是所有"求个数/步数"题的通用初始化。凑出 0 元需要 0 个硬币。
279. 完全平方数
给你一个整数
n,返回 和为n的完全平方数的最少数量 。完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,
1、4、9和16都是完全平方数,而3和11不是。
cpp
class Solution {
public:
int numSquares(int n) {
// 1. 定义 DP 数组
// dp[j] 表示:凑成和为 j 的完全平方数所需的最少数量
vector<int> dp(n + 1, INT_MAX);
// 2. 初始化
dp[0] = 0; // 凑成和为 0 需要 0 个平方数
// 3. 遍历物品与背包
// 物品是什么?是所有的完全平方数 (1, 4, 9, 16...)
// 这里直接用 i*i 动态生成了物品,极其巧妙,省去了提前打表的步骤
for(int i = 1; i * i <= n; i++){
// 背包容量是 n,从当前物品重量 i*i 开始正序遍历(完全背包)
for(int j = i * i; j <= n; j++){
// 【高光细节】防止 INT_MAX + 1 导致负数溢出
if(dp[j - i * i] == INT_MAX) continue;
// 递推公式:求最少数量
// dp[j] = min(不用当前的平方数 i*i, 用当前的平方数 i*i)
dp[j] = min(dp[j], dp[j - i * i] + 1);
}
}
// 4. 返回结果
return dp[n];
}
};
总结
1. 完美的物品抽象
在传统的完全背包题中,题目会直接给你一个数组(比如硬币面额)。
而这道题只给了一个数字 n:
- 物品:所有的完全平方数 1^2,2^2,3^2...k^2(其中 k^2≤n)。
- 背包容量:目标和
n。 - 物品重量:i^2。
- 物品价值:数量
1。
2. 为什么外层是 i,内层是 j?
这里求的是"最少数量",12 = 4 + 4 + 4 和 12 = 4 + 4 + 4,顺序不重要,所以这是一个 求组合的最小值 问题。
按照我们总结的铁律,求组合必须外层物品,内层背包。
139. 单词拆分
给你一个字符串
s和一个字符串列表wordDict作为字典。如果可以利用字典中出现的一个或多个单词拼接出s则返回true。注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
cpp
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
// 1. 优化查询:将字典转化为哈希集合,将查找时间从 O(N) 降为 O(1)
unordered_set<string> st(wordDict.begin(), wordDict.end());
int n = s.size();
// 2. 定义 DP 数组
// dp[j] 表示:字符串 s 的前 j 个字符(即 s[0...j-1])能否被成功拆分
vector<bool> dp(n + 1, false);
// 3. 初始化
// 空字符串永远可以被拆分(什么都不拆),这是递推的基础
dp[0] = true;
// 4. 状态转移
// 外层遍历背包容量(字符串的结束位置 j)
for(int j = 1; j <= n; j++){
// 内层遍历物品(寻找分割点 i,i 的范围是 [0, j-1])
for(int i = 0; i < j; i++){
// 截取子串 s[i...j-1]
string str = s.substr(i, j - i);
// 递推逻辑:
// 如果截取的子串在字典中,并且它前面的部分 s[0...i-1] 也能被成功拆分
// 那么当前的前 j 个字符就能被成功拆分!
if(st.find(str) != st.end() && dp[i]){
dp[j] = true;
// 找到一种拆分方法即可,不需要继续找其他的 i 了,直接 break 提速
break;
}
}
}
return dp[n];
}
};
总结
1. 思路
遇到字符串能不能被分割的问题,最直觉的想法就是:在哪下刀?
假设我们要判断前 j 个字符是否合法,我们在 0 到 j-1 之间找一刀切下去:
- 左边:
s[0...i-1],它能不能拆开?看dp[i]。 - 右边:
s[i...j-1],它是不是个单词?去哈希表里查一下。
两边都成立,dp[j]就成立。这就是纯粹的 区间 DP 思想,远比强行套用"外层物品内层背包"的完全背包公式要清晰得多。
2. 两个细节
unordered_set的引入:在动态规划内部做字符串查找是非常致命的性能消耗。直接用哈希集合预处理。dp[0] = true的哨兵作用:如果没有这个初始化,当整个字符串就是一个单词时(比如i=0),dp[0]就会是 false,导致整体判断失败。
背包问题总结
类别一:0-1 背包(物品只能用一次)
这是所有背包问题的基础。
核心特征是:为了防止同一个物品在同一轮被反复累加,内层遍历背包容量时必须是倒序(从大到小)。
- 经典应用:分割等和子集、最后一块石头的重量 II、一和零(二维费用 0-1 背包)。
- 通用代码框架:
cpp
for(int i = 0; i < 物品数量; i++) { // 外层:物品
for(int j = 背包最大容量; j >= weight[i]; j--) { // 内层:容量【倒序】
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
类别二:完全背包(物品可以无限次使用)
与 0-1 背包的唯一区别在于:既然可以重复拿,那计算当前状态时就需要用到本层已经更新过的状态。因此,内层遍历背包容量必须是正序(从小到大)。
- 经典应用:零钱兑换、完全平方数、组合总和 Ⅳ。
- 通用代码框架:
cpp
for(int i = 0; i < 物品数量; i++) { // 外层:物品
for(int j = weight[i]; j <= 背包最大容量; j++) { // 内层:容量【正序】
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
类别三:两大灵魂追问 ------ 求最值 vs 求方案数
确定了正倒序之后,递推公式和初始化方式完全取决于题目在求什么。
1. 求"最值"(最大价值 / 最少硬币数)
- 递推公式:
dp[j] = max(dp[j], dp[j-w] + v)或min(dp[j],dp[j-w] + 1) - 初始化逻辑:
- 求最大值:全数组初始化为
0。 - 求最小值:
dp[0] = 0,其余全初始化为一个极大的数(如0x3f3f3f3f或INT_MAX)。
- 求最大值:全数组初始化为
- 易错点:求最小值时,执行
dp[j-w] + 1之前,必须加if判断(如if(dp[j-w] != INT_MAX)),否则必定发生INT_MAX + 1导致整数溢出变成负数!
2. 求"方案数"(有多少种凑法)
- 递推公式:累加操作
dp[j] += dp[j-w] - 初始化逻辑:死记硬背
dp[0] = 1,其余为0。 (凑成总和 0 的方法数是 1,即"什么都不选",这是所有方案数累加的基石)。 - 易错点:如果硬币面额多、金额大,方案数会呈指数级爆炸,普通的
int必定溢出。数组类型果断换成uint64_t或long long。
类别四:求方案数的终极细分 ------ 组合 vs 排列
这是完全背包中最喜欢考的弯弯绕绕。当题目要求求"方案数"时,外层和内层循环到底谁先谁后?
- 求组合数(不强调顺序,
[1,5]和[5,1]算同一种)- 铁律:外层物品,内层背包(正序)
- 原理:先固定放 1 块钱的,再固定放 5 块钱的,物品放入的相对顺序被锁死,不会产生重复。
- 代表题:零钱兑换 II
- 求排列数(强调顺序,
[1,5]和[5,1]算不同的两种)- 铁律:外层背包,内层物品(正序)
- 原理:对于每一个背包容量,都把所有硬币从头到尾试一遍,这就保留了先拿 5 还是先拿 1 的顺序差异。
- 代表题:组合总和 Ⅳ
附加:如何识别"伪装的背包题"?
有时候题目根本没提"背包"、"容量",需要抽象转化:
- 二维费用:题目出现两个并列的限制条件(如:最多有 m 个 0 和 n 个 1),立刻想到二维 0-1 背包,
dp变成二维数组,内层套两个倒序循环。 - 隐藏物品数组:题目只给了一个目标值
n,没给数组。自己用循环生成物品(如:完全平方数中,物品是i*i;爬楼梯中,物品是1~m)。 - 字符串拆分(如单词拆分):虽然常被归类为完全背包,但最清晰的解法是脱离背包公式,用区间 DP 切割法:外层右边界,内层左边界一刀切,判断左半边
dp[i]是否合法且右半边是否在字典中。