算法思维进阶 力扣 375.猜数字大小 II 暴力递归 记忆化搜索 DFS C++详细算法解析 每日一题
目录
- 零、题目描述
- 一、为什么这道题值得深入理解?
- 二、题目拆解:用“决策树”理解核心矛盾
- 三、思路演进:从暴力递归到记忆化搜索
- 四、代码详解:逐行理解记忆化搜索的实现
- 五、示例推演:用n=3理解记忆化过程
- 六、复杂度分析与优势
- 时间复杂度
- 空间复杂度
- 与暴力递归的对比
- 七、坑点总结:实现时的注意事项
- 八、举一反三:为下一篇做铺垫
- 九、总结
零、题目描述
题目链接:力扣 375. 猜数字大小 II
如果感觉没明白题目的意思,我们可以换个角度,把这个问题理解成“提前准备一笔钱,确保无论对方选1到n中的哪个数字,你都能通过最优的猜测顺序把它猜出来,这笔钱最少需要多少”。
为什么要强调“无论对方选哪个数字”?因为你没法预知对方的选择,所以必须考虑最极端的情况——比如你猜了A,对方说“小了”;你再猜B,对方说“大了”……这个过程中花的钱可能有多有少,但你必须保证:哪怕遇到花钱最多的那条猜测路径,你手里的钱也足够用。而我们要找的,就是能覆盖所有可能情况的“最小启动资金”。
用两个例子直观感受:
举个直观的例子:
当 n=2 时,花的最少的钱是 1。
- 如果你先猜 1:猜对了不花钱;猜错了(实际是 2),花 1 元就能再猜到 2。最坏花费 1 元。
- 如果你先猜 2:猜错了(实际是 1),要花 2 元再猜 1。最坏花费 2 元。
- 所以最小确保金额是 1。
当 n=3 时,答案是 2。
- 先猜 2:猜错了(无论是 1 还是 3),只需再猜一次即可,总花费 2 元。
- 先猜 1 或 3:最坏情况要花 4 元(比如猜 1 错了,实际是 3,总花费 1+3=4)。
- 所以最优选择是先猜 2,最小确保金额为 2。
简单说就是:你要站在“最倒霉”的角度,选一条“花钱最少”的万能策略——我们不应管对方选的是什么数字,你都能靠这笔钱稳稳赢下游戏,而这笔钱还不能多花一分。
代码框架:
class Solution {
public:int getMoneyAmount(int n) {}
};
一、为什么这道题值得深入理解?
猜数字大小 II 是区间类记忆化搜索的经典代表,其价值体现在三个方面:
- “最坏情况最优解”的思维训练:题目要求“确保能猜到的最小金额”,本质是在“最坏情况”(即每次都遇到最不利的数字)下寻找“最小成本”,这种“最大化最小值”的决策逻辑是很多博弈问题、风险控制问题的核心;
- 区间子问题的拆解艺术:问题天然可拆解为“左区间”和“右区间”的子问题,理解这种“区间分割”的递归逻辑,能直接迁移到矩阵链乘法、石子游戏等区间DP问题;
- 记忆化搜索的典型应用:暴力递归的时间复杂度高达O(n!),而通过记忆化搜索可将复杂度降至O(n³),这种“用空间换时间”的优化思路在此题中体现得淋漓尽致,能帮你掌握“识别重复子问题”的关键能力。
下一篇我们将讲解的「329. 矩阵中最长递增路径」同样依赖记忆化搜索解决“重复子问题”,提前吃透这道题的思路,能为后续学习打下坚实基础。
二、题目拆解:用“决策树”理解核心矛盾
要解决这道题,首先需明确“确保能猜到的最小金额”的含义。我们可以用决策树来可视化过程:
- 每个节点代表一次猜测(值为所猜数字);
- 左子树表示“实际数字比猜测值小”,右子树表示“实际数字比猜测值大”;
- 从根节点到叶子节点(正确数字)的路径之和,就是此次猜测的总花费;
- 我们需要找到一棵“根节点的最大路径和最小”的决策树——即“最坏情况下花费最小”的策略。
以 n=3 为例,决策树如下:
方案1:根节点猜2├─ 小于于目标数,左子树(那么答案是1):无需再猜,路径和=2└─ 大于目标数,右子树(那么答案是3):无需再猜,路径和=2最坏情况花费=2(取最大值)方案2:根节点猜1├─ 小于于目标数,左子树:无(1是最小)└─ 大于目标数,右子树(实际是2或3)├─需猜2(就可以知道答案了,是3),花费和是=1+2=3└─需猜3(就可以知道答案了,是2),花费和是=1+3=4最坏情况花费=4(取最大值)方案3:根节点猜3├─ 小于目标数,左子树(实际是2或1)| ├─需猜2(就可以知道答案了,是1),花费和是=3+2=5| └─需猜1(就可以知道答案了,是2),花费和是=3+1=4└─ 大于目标数,右子树:无(3是最大)最坏情况花费=5(取最大值)在根节点提供的情况中取花费最小的情况,就是根节点为2花费2为最终结果
核心矛盾:暴力枚举所有可能的猜测顺序会导致指数级复杂度(每个区间有O(n)种猜测选择,子区间又有O(n)种,递归深度O(n)),必须通过“存储子区间的计算结果”来避免重复计算。
三、思路演进:从暴力递归到记忆化搜索
1. 暴力递归:枚举所有可能的猜测点
核心思路:对于区间[left, right],尝试每一个可能的猜测点i(left ≤ i ≤ right),计算“猜i的花费 + 左右子区间的最坏情况花费”,取所有i中的最小值。
- 子问题定义:
dfs(left, right)
表示“猜中[left, right]之间的数字,确保能猜到的最小金额”; - 递归逻辑:
dfs(left, right) = min( i + max(dfs(left, i-1), dfs(i+1, right)) )
(i从left到right遍历);i
是当前猜的数字(需支付i元);max(dfs(left, i-1), dfs(i+1, right))
是“最坏情况”(左右子区间中花费更大的那个);
- 边界条件:当left ≥ right时(区间内只有0或1个数字),无需猜测,花费为0。
暴力递归代码(超时但思路关键):
class Solution {
public:int getMoneyAmount(int n) {return dfs(1, n);}// 计算区间[left, right]的最小确保金额int dfs(int left, int right) {// 区间为空或只有一个数字,无需猜测if (left >= right) return 0;int minCost = INT_MAX;// 尝试每个可能的猜测点ifor (int i = left; i <= right; i++) {// 猜i的花费 = i + 左右子区间的最坏情况(取较大者)int cost = i + max(dfs(left, i-1), dfs(i+1, right));// 取所有猜测点中的最小花费minCost = min(minCost, cost);}return minCost;}
};
为什么超时?
以n=4为例,dfs(1,4)
会计算 dfs(1,0)
、dfs(2,4)
、dfs(1,1)
、dfs(3,4)
等子问题,而 dfs(2,4)
又会计算 dfs(2,1)
、dfs(3,4)
——其中 dfs(3,4)
被重复计算。随着n增大,重复子问题呈指数级增长,时间复杂度高达O(n!),n=20时就已无法承受。
2. 记忆化搜索:用备忘录存储重复子问题
优化核心:区间[left, right]的最小花费是固定的,无论它是从哪个父区间拆分而来,结果都相同。因此,我们可以用一个二维数组 memo[left][right]
存储计算过的结果,避免重复递归。
步骤升级:
- 定义备忘录
memo[left][right]
,初始值为0(表示未计算); - 计算
dfs(left, right)
前,先检查memo[left][right]
:- 若不为0,直接返回(已计算);
- 若为0,计算后存入
memo[left][right]
再返回。
记忆化搜索代码:
class Solution {
public:int memo[201][201]; // 备忘录:存储区间[left][right]的最小确保金额int getMoneyAmount(int n) {// 初始化备忘录(全局变量默认0,无需额外初始化)return dfs(1, n);}int dfs(int left, int right) {// 边界条件:区间为空或只有一个数字,花费0if (left >= right) return 0;// 若已计算,直接返回备忘录中的结果if (memo[left][right] != 0) return memo[left][right];int minCost = INT_MAX;// 尝试所有可能的猜测点ifor (int i = left; i <= right; i++) {// 计算左右子区间的花费int leftCost = dfs(left, i-1);int rightCost = dfs(i+1, right);// 当前猜测i的总花费 = i + 左右子区间的最坏情况(取较大者)int currentCost = i + max(leftCost, rightCost);// 取所有猜测点中的最小花费minCost = min(minCost, currentCost);}// 存入备忘录memo[left][right] = minCost;return minCost;}
};
四、代码详解:逐行理解记忆化搜索的实现
1. 备忘录设计
int memo[201][201];
- 尺寸选择:题目中n最大为200,区间left和right的范围是1~200,因此201×201的数组足够覆盖所有可能的区间;
- 初始值:全局数组默认初始化为0,而有效结果(除边界条件)均为正数,因此0可安全表示“未计算”。
2. 递归函数 dfs(left, right)
int dfs(int left, int right) {if (left >= right) return 0; // 边界:无需猜测if (memo[left][right] != 0) return memo[left][right]; // 读取备忘录
- 边界条件:当left ≥ right时,区间内最多只有一个数字,无需猜测即可确定,花费0;
- 备忘录检查:若已计算过该区间,直接返回结果,避免重复递归。
3. 核心计算逻辑
int minCost = INT_MAX;
for (int i = left; i <= right; i++) {int leftCost = dfs(left, i-1); // 左子区间花费int rightCost = dfs(i+1, right); // 右子区间花费int currentCost = i + max(leftCost, rightCost); // 最坏情况花费minCost = min(minCost, currentCost); // 取最小的最坏情况
}
- 遍历所有可能的猜测点i:每个i将区间拆分为[left, i-1]和[i+1, right];
- 计算最坏情况:因为我们需要“确保能猜到”,必须考虑左右子区间中花费更大的那个(即最坏情况),再加上当前猜测i的花费;
- 取最小值:在所有可能的i中,选择“最坏情况花费最小”的那个,即为当前区间的最优解。
4. 存入备忘录并返回
memo[left][right] = minCost;
return minCost;
- 将计算结果存入备忘录,供后续调用直接使用,完成“空间换时间”的优化。
五、示例推演:用n=3理解记忆化过程
以n=3为例,演示记忆化搜索的执行流程:
- 调用
dfs(1,3)
,memo[1][3]
为0,进入计算; - 遍历i=1,2,3:
- i=1:
- 左子区间
dfs(1,0)
→ 0(边界); - 右子区间
dfs(2,3)
→ 首次计算:- i=2:左
dfs(2,1)=0
,右dfs(3,3)=0
→ 花费2+0=2; - i=3:左
dfs(2,2)=0
,右dfs(4,3)=0
→ 花费3+0=3; dfs(2,3)
取min(2,3)=2,存入memo[2][3]=2
;
- i=2:左
- 当前花费:1 + max(0, 2) = 3;
- 左子区间
- i=2:
- 左子区间
dfs(1,1)=0
,右子区间dfs(3,3)=0
; - 当前花费:2 + max(0, 0) = 2;
- 左子区间
- i=3:
- 左子区间
dfs(1,2)
→ 首次计算(过程类似,结果为1); - 当前花费:3 + max(1, 0) = 4;
- 左子区间
- i=1:
minCost
取min(3,2,4)=2,存入memo[1][3]=2
,返回2。
可见,memo[2][3]
和 memo[1][2]
等子区间的结果被存储后,若后续其他父区间需要调用,可直接读取,大幅减少计算量。
六、复杂度分析与优势
时间复杂度
- 记忆化搜索中,每个区间[left, right]只计算一次,区间总数为O(n²)(left和right各有n种可能);
- 每个区间的计算需要遍历O(n)个猜测点i;
- 总时间复杂度:O(n³),n=200时为200³=8,000,000,完全可接受。
空间复杂度
- 备忘录数组大小为O(n²)(201×201≈40,000);
- 递归栈深度为O(n)(最坏情况区间从1到n,递归深度n);
- 总空间复杂度:O(n²)。
与暴力递归的对比
维度 | 暴力递归 | 记忆化搜索 |
---|---|---|
时间复杂度 | O(n!) | O(n³) |
空间复杂度 | O(n)(递归栈) | O(n²)(备忘录+栈) |
核心优化点 | 无 | 存储重复子问题结果 |
七、坑点总结:实现时的注意事项
-
备忘录的尺寸与初始化
- 若n最大为200,备忘录需至少为201×201(因为数字从1开始),避免数组越界;
- 初始值需与“有效结果”区分:若用0表示未计算,需确保有效结果不为0(此题中只有边界条件为0,其他情况均为正数,因此安全)。
-
循环遍历的范围
- 猜测点i必须遍历[left, right]的所有可能值,不能遗漏。例如n=3时若漏掉i=2,会错误地得到4作为结果。
-
“最坏情况”的取法
- 必须用
max(leftCost, rightCost)
而非min
,因为我们需要确保“无论实际数字在左还是右,都有足够的钱完成猜测”。取max才能覆盖最坏情况。
- 必须用
-
递归边界的判断
left >= right
时返回0,涵盖了left > right(空区间)和left = right(单数字区间)两种情况,无需额外拆分。
八、举一反三:为下一篇做铺垫
这道题的记忆化搜索思路可直接迁移到「329. 矩阵中最长递增路径」:
- 两者都存在大量重复子问题(矩阵中每个单元格的最长路径 vs 区间[left, right]的最小花费);
- 都需要用备忘录存储子问题结果(二维数组 memo);
- 递归逻辑都依赖“当前状态的解 = 子状态的解的组合”(此题是min(i + max(…)),329题是max(上下左右路径+1))。
提前掌握“识别重复子问题→设计备忘录→递归计算+存储”的流程,能帮你快速解决下一篇的问题。
九、总结
「猜数字大小 II」的核心是通过记忆化搜索解决“区间拆分导致的重复子问题”:
- 暴力递归因重复计算子问题,复杂度高达O(n!),无法实用;
- 记忆化搜索用二维备忘录存储区间[left, right]的结果,将复杂度降至O(n³),实现高效求解;
- 关键思路是“对每个区间尝试所有可能的猜测点,取最坏情况下的最小花费”,体现了“最大化最小值”的决策逻辑。
下一篇,我们将运用同样的记忆化搜索思想,解决力扣 329. 矩阵中最长递增路径,进一步巩固“用备忘录优化递归”的核心能力。
最后欢迎大家在评论区分享你的代码或思路,咱们一起交流探讨~ 🌟 要是有大佬有更精妙的思路或想法,恳请在评论区多多指点批评,我一定会虚心学习,并且第一时间回复交流哒!
这是封面原图~ 喜欢的话先点个赞鼓励一下呗~ 再顺手关注一波,后续更新不迷路,保证让你看得过瘾!😉