CSP-S 提高级课程中心 / 算法进阶 / 第30讲
CSP-S 提高级 · 算法进阶 · s5-08

搜索剪枝 与 记忆化搜索

搜索的本质是枚举,但枚举不等于傻枚举 —— 剪枝把不可能的枝条整块砍掉,记忆化把算过的答案存进笔记本。

5 类剪枝手法 指数级的提速效果 记忆化 = 自顶向下 DP 2 幅对比图 + 1 组动画 3 个完整案例 12 道洛谷原题
本讲 · 第 30/52 讲搜索的剪枝优化与记忆化搜索
模块 01

情景导航

先看清「搜索为什么会爆炸」,再让花匠的剪刀告诉你:砍掉一根枝,省下的可能是整棵树。

故事修枝的花匠

果园里有位花匠,每年要在一片桃林里找出产量最高的那一枝。他的老办法是:从树根开始,每一根枝条都顺着摸下去,一直摸到最细的末梢,把所有可能的「枝路」都走一遍。

这片林子每根枝条都会分成两根,一共分 30 层。算了一下,末梢的数量是 230 —— 超过十亿。花匠摸一辈子也摸不完。

后来他学了三招:

第一招:看到某根枝条已经枯了,或者明显长不出果子,直接咔嚓剪掉,不再往下摸。反正往下摸也不会有好结果。

第二招:他手里已经记着「目前最高的产量是 100 斤」。走到某个分叉时,抬头一看 —— 这根枝上所有的果子加起来也只有 60 斤,就算全给它也不如手里的 100 斤。于是整根剪掉。

第三招:摸到某根枝条时,他发现这根枝条和上个月摸过的那根长得一模一样,位置、朝向、粗细全一样。他翻开随身带的小本子:「这根的答案上次已经算过了,是 78 斤。」直接抄,不用再摸。

「剪掉一根枝,省下的不是一根枝,而是它下面整棵树。」

这三招,就是信息学里的 剪枝(pruning) 与 记忆化(memoization)。它们让原本「指数爆炸、永远算不完」的搜索,在有限时间内给出答案。

💡 搜索的本质:枚举 + 提前否决

搜索(DFS / BFS / 回溯)说到底就是把所有可能的情况枚举一遍。只要问题规模稍微大一点,枚举量就会以指数速度增长。

但请注意:绝大多数分支从一开始就不可能通向答案。剪枝做的事情,就是「在还没走到尽头之前,就判断出这条路走不通」,然后整块跳过 —— 跳过的是一整棵子树,而不是一个节点。

而记忆化解决的是另一个问题:不同的路径常常会走到同一个状态,同一个状态的最优解只需要算一次。把答案记下来,下次直接查表。

一句话总结:剪枝砍「不可能」的分支,记忆化省「重复」的计算。

①生活类比
✂️

修枝剪叶

看到枯枝直接剪,不必顺着摸到末梢 —— 剪掉一个枝,省下的是整棵子树。

→ 剪枝 = 整块跳过
🚧

导航避开死路

地图上显示前方断路,就立刻换路,而不是开过去才发现过不了。

→ 可行性剪枝
💰

比价时先看运费

已经找到 99 元的商品,看到某店标价 120 元,「连运费都不用看」直接排除。

→ 最优性剪枝(上下界)
📒

小本子记答案

同一道题算过一次就抄答案,不再重算 —— 这就是「记忆」,代价只是多带一个本子。

→ 记忆化搜索
②核心定义

状态空间与搜索树:把问题求解过程看成「每一步做一个决策」,从初始状态出发,每个决策分叉出若干子状态,形成的树状结构叫搜索树。搜索的代价 ≈ 搜索树的节点数。

剪枝(Pruning):在搜索过程中,提前判断出当前分支不可能产生(更好的)解,于是直接返回、不再向下搜索。关键是「提前」—— 越早剪掉,省下的子树越大。

可行性剪枝:当前状态已经违反约束,或按最乐观的估计也无法达到目标,则立即回溯。例如「体积超出容量」「剩余木棍总长不能被当前长度整除」。

最优性剪枝(上下界剪枝):维护当前已找到的最优解 ans,若当前分支的乐观下界已经 ≥ ans,则整枝剪掉。它要求能快速算出「从这里出发最好还能得到多少」的估计值。

重复性剪枝(等价状态剪枝):同一层里,做过一次的选择不再重复做;或已经搜索过的相同状态直接返回(后者就是记忆化)。

排序 + 范围剪枝:把元素排序后按固定顺序枚举(通常从大到小),并把后续枚举的下标限制在一个区间里,从而让「相同的组合」只出现一次,避免排列级的重复。

搜索顺序优化:剪枝不改变解的集合,但改变搜索的先后顺序会极大影响效率。通常策略是「先试成功率高、分支少的分支」,这样能更快拿到好的 ans,让后续的最优性剪枝更早生效。

记忆化搜索(Memoization):把递归函数的参数组合(即「状态」)当作下标,把算好的答案存进数组里;下次遇到同一状态,直接返回存储的答案,不再重复递归。本质是自顶向下的动态规划。

📌 本讲核心知识地图

板块核心内容大纲
搜索爆炸分支数 b、深度 n ⇒ 节点数 ≈ bn【6】搜索的剪枝优化
可行性剪枝违反约束或无法达成目标 ⇒ 立刻回溯【6】搜索的剪枝优化
最优性剪枝乐观下界 ≥ ans ⇒ 整枝剪掉【6】搜索的剪枝优化
重复性剪枝同层不重复试;相同状态直接返回【6】搜索的剪枝优化
排序+范围排序后按下标递增枚举,组合只出现一次【6】搜索的剪枝优化
搜索顺序先试分支少/成功率高的,尽早更新 ans【6】搜索的剪枝优化
记忆化搜索memo 数组存状态答案,重复状态 O(1) 返回【6】记忆化搜索
与 DP 的关系记忆化 = 自顶向下 DP,与递推 DP 等价【6】记忆化搜索
典型题目P1120 小木棍、P1434 滑雪、P1731 生日蛋糕【6】搜索与 DP
③核心口诀
🔑 剪枝五式

可行性:走不通就别硬走;
最优性:不如手头的最好就别试;
重复性:同样的选择只试一次;
排序范围:排好顺序、限住范围,组合只算一遍;
搜索顺序:把最好的先试,让后面剪得更狠。

🔑 记忆化三步

① 定状态:参数能唯一确定子问题;
② 判缓存:进函数先查 memo,命中就返回;
③ 写回去:return 之前把答案存进 memo。
口诀:查表→计算→回填,一行都不能少。

🔑 剪枝铁律

剪枝只能砍「确定不可能」的分支,
宁可不剪,不可错剪;
顺序优化只影响速度,剪枝正确性影响答案。

模块 02

知识探究

先算清楚搜索树的规模,再逐个掌握五类剪枝,最后用记忆化把重复状态一次性消灭。

1搜索树为什么会爆炸 指数级

设每一步有 b 种选择(分支数),一共要做 n 步(深度),那么搜索树的节点总数约为

搜索规模公式
节点总数 ≈ 1 + b + b² + b³ + … + bⁿ ≈ bⁿ(当 b ≥ 2 时,最后一项占主导)

这个式子意味着:深度每加一层,工作量就乘上 b 倍。计算机一秒大约能处理 108 次基本操作,所以能承受的规模是有硬上限的:

深度 nb = 2(每层 2 个分支)b = 3b = 5
101.02 × 10³5.9 × 10⁴1.2 × 10⁷
153.3 × 10⁴1.4 × 10⁷3.8 × 10¹⁰
201.05 × 10⁶3.5 × 10⁹1.2 × 10¹⁴
253.4 × 10⁷8.5 × 10¹¹3.7 × 10¹⁷
301.1 × 10⁹2.1 × 10¹⁴1.2 × 10²¹
看清这张表:b = 2 时深度 20 还能跑(106),深度 30 就悬了(109);而 b = 5 时深度 15 就已经完全不可能(3.8 × 1010)。只要分支数稍微大一点,纯暴力搜索连 n = 20 都过不去。这就是为什么必须剪枝。
2剪枝第一式:可行性剪枝 最常用

核心思想:当前状态已经违反约束,或者按最乐观的估计也不可能达成目标,那么从这个状态出发的一切继续搜索都是徒劳,直接返回。

典型形态:

A
越界/冲突型

已经超出容器的容量、已经和前面的棋子冲突、已经走出地图边界。一行 if (...) return; 就能省掉整棵子树。

B
整除/取余型

问题的答案必须满足某种整除关系。例如小木棍的总长必须能被单根长度整除,不能整除的长度根本不必尝试。

C
剩余量估计型

预处理「把剩下的任务做最少需要多少代价」(如最小可能体积/步数),若已经超过剩余预算,则剪枝。

D
可行性上界型

当前能提供的最大可能值仍不足目标值(如剩下的所有数加起来也凑不到目标和),直接返回。

C++可行性剪枝的四种典型写法(片段)
// ① 容量越界
if (curW + w[i] > cap) continue;          // 装不下,这个选择直接跳过

// ② 整除关系(小木棍的枚举框架)
for (int len = maxLen; len <= sum; len++) {
    if (sum % len) continue;               // ★ 总长必须能被 len 整除
    ...
}

// ③ 剩余最小代价估计(生日蛋糕:上面 dep 层的最小体积)
if (v < minV[dep]) return;                // ★ 体积已经不够放满剩下的层

// ④ 可达性上界(凑目标和的可行性)
if (curSum + restSum < target) return;    // ★ 全加起来都不够,绝无可能

💡 为什么可行性剪枝威力最大

因为它可以在搜索树的很浅层就生效。越靠近树根剪掉一枝,省下的节点数就越多 —— 剪掉根部的第一个分支,直接就省掉整棵子树的 1/b。

所以写搜索时的第一反应应该是:「这个状态还有必要往下走吗?」 把这个问题想清楚,通常就能砍掉 90% 以上的搜索量。

3剪枝第二式:最优性剪枝(上下界)

核心思想:题目要求「最大值」或「最小值」时,我们手里始终维护着一个已经找到的当前最优解 ans。如果在某个状态下,「从这里出发能取得的最好结果」都不可能超过 ans,那么这个分支就可以整枝剪掉。

关键:需要一个乐观估计函数 bound(),它要满足两个要求:

1
必须是「乐观」的

它给出的值必须比真实可能达到的最优值更优或相等(即对求最小值的题目,bound 必须 ≤ 真实最小值)。估得太悲观会误剪掉正确答案。

2
必须足够紧

估得太松(比如直接返回 0)虽然保证正确,但几乎剪不掉任何分支。好的 bound 要尽量贴近真实值。

3
必须 O(1) 或 O(log)

如果算 bound 比搜索还慢,那就得不偿失。所以 bound 通常用「预处理好的前缀/后缀数组」O(1) 求出。

C++最优性剪枝(生日蛋糕为例,求最小表面积)
int ans = INF;                       // 全局当前最优解

// 参数:v 剩余体积、s 已用表面积、dep 还要做几层、r/h 下一层的半径与高
void dfs(int v, int s, int dep, int r, int h) {
    if (dep == 0) {
        if (v == 0) ans = min(ans, s);   // 恰好用完体积,更新答案
        return;
    }
    if (v < minV[dep]) return;                 // 可行性:体积不够
    if (s + minS[dep] >= ans) return;            // ★ 最优性:最小侧面积都超了
    if (s + 2 * v / r >= ans) return;           // ★ 最优性:侧面积下界估计 2V/R
    ...
}
注意 s + 2 * v / r >= ans 这一行:它的来历是「剩下的体积全部做成一个大圆柱时,侧面积最小,等于 2V/R」。这是一个数学上的下界,比单纯用 minS 更紧,所以剪枝效果更好。这类「数学推导出来的下界」是最优性剪枝的灵魂,也是 S 组搜索题的区分点。
4剪枝第三式:重复性剪枝与等价状态

核心思想:搜索树里存在大量「长得不一样但结果完全一样」的重复劳动。找出它们并只算一次,就能成倍提速。它有两种典型形态:

✅ 形态 A:同层「相同选择」只试一次

某一层有若干取值相同的可选项(例如小木棍里有三根长度为 5 的木棍)。如果「用其中某一根」失败了,那么用另外两根同样长的也一定会失败。

做法:记录上一次失败时用过的值 last,若当前选项的值与 last 相等,直接 continue。或者判断「前一个等长的元素没被使用过,则跳过当前元素」。

✅ 形态 B:相同「状态」只算一次(记忆化)

两条不同的路径走到了同一个状态(参数完全相同)。此时无论哪条路径来的,从该状态出发能得到的最优结果都一样。

做法:给状态建一个 memo 表,算过就存,再遇到就查表返回。这是下一节要详细讲的内容。

C++同层去重的两种写法(小木棍为例)
// 写法一:记录「上一次失败的值」
int lastFail = -1;
for (int i = st; i <= n; i++) {
    if (a[i] == lastFail) continue;        // ★ 等长的试过一次就够
    ...
    lastFail = a[i];
}

// 写法二:利用排序后的相邻关系(需先按降序排序)
for (int i = st; i <= n; i++) {
    if (vis[i]) continue;
    if (i > 1 && a[i] == a[i - 1] && !vis[i - 1])
        continue;                        // ★ 前一根等长的都没用上,我也别用
    ...
}

💡 写法二为什么成立

把元素按降序排序后,等长的元素是连续排列的。假设 a[i] 与 a[i−1] 等长,而 a[i−1] 在这条分支里没被选上。

考虑两条路径:路径 P 选了 a[i],路径 Q 选了 a[i−1](其它选择完全相同)。由于 a[i] 与 a[i−1] 的长度完全相同,把 P 里的 a[i] 换成 a[i−1],得到的仍是一个合法且等价的方案。既然 Q 已经在前面被尝试过并失败了,P 也一定会失败。

所以「前一根等长的没被选上,我就不用再选了」是正确的 —— 这就是「同一层等价选项只保留最左边一个」的去重技巧。

5剪枝第四式:排序 + 范围剪枝

要解决的问题:在「从若干个数中选若干个」的搜索里,如果不加限制,{1,2} 和 {2,1} 会被当成两种不同的方案各搜一遍,同一个组合被枚举 k! 次。

解决办法:把元素排好序,规定「后面选的数,必须在前面选的数之后」。做法是给 DFS 加一个参数 st(start),循环从 st 开始,递归时传 i + 1。这样每个组合恰好只被枚举一次。

对比:不加 st 参数 vs 加 st 参数
不加 st
1,2 / 2,1 / 1,3 / 3,1 / 2,3 / 3,2 …
同一组合重复 k! 次
加 st
1,2 / 1,3 / 2,3
每个组合只出现一次 ✓
本质:用「下标递增」这个约束,把排列级枚举降成组合级枚举
C++排序 + 范围剪枝(选数问题的标准骨架)
// 从 a[st..n] 中继续选数;已选总和为 sum,已选 cnt 个
void dfs(int st, int cnt, int sum) {
    if (cnt == k) {                       // 选够了
        if (sum == target) ans++;
        return;
    }
    if (sum > target) return;                     // ★ 可行性剪枝
    if (sum + suf[st] < target) return;         // ★ 可行性上界:全加上也不够
    for (int i = st; i <= n; i++) {             // ★ 从 st 开始 ⇒ 组合不重复
        if (i > st && a[i] == a[i - 1]) continue;  // ★ 同层去重
        dfs(i + 1, cnt + 1, sum + a[i]);
    }
}
「范围剪枝」还有一层含义:把「后续可取的下标范围」限制住,往往还能推出额外的可行性剪枝。例如「剩下的数全部加上也不够」sum + suf[st] < target、「已选的个数加上剩下所有数还不够 k 个」等。这些都属于「用范围信息估计可行性」。
6剪枝第五式:搜索顺序优化

核心思想:搜索顺序不影响解的集合(所有合法解最终都会被找到),但它极大地影响速度。原因有两个:

1
更早拿到好的 ans

最优性剪枝的强度取决于 ans 有多好。如果第一刀就试到接近最优的解,后面的分支会被大量剪掉;如果先试很差的解,剪枝几乎不起作用。

2
先试分支少的选择

在「填格子」类的搜索里,先填可选值最少的格子,能让冲突尽早暴露,整棵子树尽早被剪掉。

3
先大后小

小木棍、装物品类题目里,把大的元素排在前面。大的元素选择少、约束强,容易快速失败,从而快速回溯。

4
数学/约束优先

把最「挑剔」的维度放在前面。例如数独先填候选数最少的行,八皇后先安排中间列(冲突最多)。

❌ 顺序差:先试小元素

小木棍按升序排:先拿 1、再拿 2…… 由于小数组合方式极多,前面会尝试大量无效组合,很久都拼不出第一根完整的木棍,ans 迟迟不更新,剪枝形同虚设。

✅ 顺序好:先试大元素

小木棍按降序排:先拿 5、再拿 5…… 大的木棍放下去后剩余空间小、约束强,很快就能判断出「放不下」并回溯。同时更容易快速拼出可行方案,让 ans 立刻变好。

一句话记住:「剪枝决定能不能过,顺序决定过得快不快。」 考场上如果发现程序超时,先检查两件事:有没有加最优性剪枝?搜索顺序是不是从「约束最强」的分支开始试?
7剪枝的指数威力:每层少 10%,总量少 20 倍

剪枝的效果很容易被低估。因为「每层砍掉一部分分支」的效果不是线性的,而是指数级的 —— 省下的量会沿着深度被不断放大。

剪枝前:15 个节点 剪枝后:7 个节点 全部枚举:2⁰+2¹+2²+2³ = 15 ✕ ✕ ✕ ✕ 虚线 = 被剪掉的分支(整棵子树都没进去)
图 1:剪枝的样子 —— 剪掉一个节点,省下的是它下面整棵子树

再看一个更有冲击力的数字。设搜索深度 n = 30,每一层保留的分支数稍微变一点,总节点数会怎样变化?

每层保留分支数 b总节点数 ≈ b³⁰相对 b = 2 的加速能否在 1 秒内跑完
2.0(完全不剪)1.07 × 10⁹1×⚠️ 勉强
1.92.3 × 10⁸4.7×✅
1.8(每层少 10%)4.6 × 10⁷23×✅ 轻松
1.78.2 × 10⁶130×✅ 轻松
1.5(每层少 25%)1.9 × 10⁵5 600×✅ 瞬间
1.32.6 × 10³410 000×✅ 瞬间

💡 读表结论

「每层只砍掉 10% 的分支」听起来微不足道,但 30 层之后就是 23 倍的差距。这就是指数放大的效果:局部的微小改进,在深度上被反复乘起来。

反过来也说明:剪枝一定要尽量靠近树根。在深度 1 剪掉一个分支,省掉的是 229 个节点;在深度 29 剪掉一个分支,只省掉 2 个。所以搜索剪枝的核心是「如何在最早的地方发现问题」。

这也是为什么「可行性剪枝」通常威力最大 —— 它能在最浅层就判断出「这条路不可能」。

8记忆化搜索:把算过的答案记下来 核心

剪枝解决的是「不可能的分支」。但还有另一种浪费:不同的路径走到了同一个状态,同一个状态被反复计算。

比如滑雪问题:从 A 点滑到 B 点、再从 B 出发继续滑,和从 C 点滑到 B 点、再从 B 出发继续滑 —— 这两种路径在 B 点这个状态 之后是完全一样的。如果把「从 B 点出发的最长路径」算两次,纯属浪费。

记忆化搜索的三步骨架
第 1 步
定状态:函数的参数组合要能唯一确定一个子问题
第 2 步
查缓存:进入函数先看 memo[状态],已算过就立刻返回
第 3 步
写回填:算完之后把答案存进 memo[状态],再返回
口诀:查表 → 计算 → 回填;少任何一步,记忆化都不成立
C++记忆化搜索的通用骨架
int memo[MAXS];                      // 记忆表
const int UNSEEN = -1;              // 「还没算过」的标记

int dfs(int s) {                       // s 是状态编号(可以是多维)
    if (memo[s] != UNSEEN) return memo[s];   // ★ 第2步:命中缓存直接返回

    if (isBase(s)) return memo[s] = baseValue(s);  // 边界(也要回填)

    int res = initValue;// 例如 0(计数)或 INF(求最小)
    for (每个可达状态 t : nextStates(s))
        res = merge(res, dfs(t) + 1);          // 按题目定义合并

    return memo[s] = res;                // ★ 第3步:先回填,再返回
}
✅ 记忆化搜索(自顶向下 DP)

写法:写一个递归函数,只管「当前状态怎么由子状态推出来」,用 memo 避免重复。

优点:① 直接照抄数学定义,不用考虑计算顺序;② 只算用得到的状态,状态稀疏时极省;③ 天然支持「转移依赖复杂」的场景(如网格图、博弈图)。

缺点:① 递归有额外开销(常数大);② 递归深度大时可能爆栈;③ 状态空间必须能作为数组下标(或哈希)。

🔄 递推 DP(自底向上)

写法:按拓扑序写几层 for 循环,从小到大(或从大到小)填表。

优点:① 常数小、跑得快;② 无递归爆栈风险;③ 便于滚动数组优化空间。

缺点:① 必须想清楚计算顺序;② 状态稀疏时会白算大量无用状态;③ 转移依赖拓扑序时不好写。

对比维度记忆化搜索(自顶向下)递推 DP(自底向上)
计算顺序按需计算,用到才算必须预先安排好
代码形态递归函数 + memo多层 for 循环
时间常数较大(函数调用开销)小
栈空间可能爆栈,需注意深度无风险
稀疏状态✅ 优势明显,只算访问到的❌ 必须全表扫一遍
转移复杂✅ 想怎么写就怎么写⚠️ 需要人为排好顺序
典型例题P1434 滑雪、博弈搜索、区间 DP背包、LIS、数字三角形
本质关系两者是同一个 DP 方程的两种实现,答案必然相同
什么时候「必须」用记忆化而没法用递推?当状态的依赖关系构成一张有向图而不是分层结构时(例如网格图中「只能往更低处走」,转移方向由数据决定、事先不知道顺序),递推很难安排循环顺序,而记忆化天然按需递归、自动处理顺序。
9二维记忆化:以滑雪问题为例

问题:给定 R × C 的网格,每个格子有一个高度。从任意格子出发,每次可以滑向上下左右四个方向中高度更小的相邻格子。求最长能滑多少步(经过的格子数)。

状态定义:f(x, y) = 「从格子 (x, y) 出发能滑过的最多格子数」。这就是一个二维状态,可以用 memo[x][y] 直接存。

转移方程:

f(x, y) = 1 + max{ f(nx, ny) | (nx, ny) 是 (x, y) 的相邻格子且 h[nx][ny] < h[x][y] }
若四个方向都没有更低的格子,则 f(x, y) = 1(原地不动也算一个格子)
1
状态数量

一共 R × C 个状态(每个格子一个)。n 个状态,每个状态的转移只枚举 4 个方向,总复杂度 O(R·C)。

2
为什么不会死循环

转移只允许「往低处走」,高度严格递减,所以不可能绕回原状态。状态依赖图是一张 DAG,递归一定有终点。

3
memo 初值

用 0 表示「还没算过」最方便(因为合法的 f 值都 ≥ 1),也可以统一初始化为 −1,效果一样。

4
答案是 max

起点可以任选,所以答案是所有 f(x, y) 中的最大值,而不是 f(1,1)。

C++滑雪的记忆化核心(完整版见案例2)
int dfs(int x, int y) {
    if (f[x][y]) return f[x][y];      // ★ 命中缓存:算过就抄
    f[x][y] = 1;                      // 至少包含自己一个格子
    for (int d = 0; d < 4; d++) {
        int nx = x + dx[d], ny = y + dy[d];
        if (nx < 1 || nx > R || ny < 1 || ny > C) continue;
        if (h[nx][ny] < h[x][y])     // 只能往低处滑
            f[x][y] = max(f[x][y], dfs(nx, ny) + 1);
    }
    return f[x][y];                // 回填 & 返回
}
🎬动画演示:记忆化是怎么「命中缓存」的

用一个 3 × 3 的迷你滑雪场来观察记忆化的全过程:

列1列2列3
行1123
行2456
行3789

我们调用 dfs(3,3)(高度 9),观察每一层递归与每一次「命中缓存」。

🧠 记忆化搜索全过程:dfs(3,3)(高度 9 的格子)
0准备:memo 全部为 0(表示「没算过」)。目标是 f(3,3)。
1进 dfs(3,3):memo[3][3] = 0 → 未命中。高度 9 的邻居里有更低的:(2,3)=6、(3,2)=8。先递归第一个。
2进 dfs(2,3):memo[2][3] = 0 → 未命中。高度 6 的更低邻居:(1,3)=3、(2,2)=5。先递归第一个。
3进 dfs(1,3):未命中。高度 3 的更低邻居只有 (1,2)=2。递归下去。
4进 dfs(1,2):未命中。高度 2 的更低邻居只有 (1,1)=1。递归下去。
5进 dfs(1,1):高度 1 是最小的,四个方向都没有更低的格子 → f = 1。★ 回填 memo[1][1] = 1 并返回。
6回到 dfs(1,2):f = 1 + f(1,1) = 1 + 1 = 2。★ 回填 memo[1][2] = 2,返回 2。
7回到 dfs(1,3):f = 1 + f(1,2) = 3。★ 回填 memo[1][3] = 3,返回 3。
8回到 dfs(2,3):第一个候选是 1 + 3 = 4。还要看第二个候选 (2,2)=5。递归 dfs(2,2)。
9进 dfs(2,2):未命中。高度 5,四个邻居 2、4、6、8 全部更高 → 无处可滑,f = 1。★ 回填 memo[2][2] = 1,返回 1。
10回到 dfs(2,3):f = 1 + max(3, 1) = 4。★ 回填 memo[2][3] = 4,返回 4。
11回到 dfs(3,3):第一个候选是 1 + 4 = 5。还要看第二个候选 (3,2)=8。递归 dfs(3,2)。
12进 dfs(3,2):未命中。高度 8,更低邻居 (2,2)=5、(3,1)=7。
先算 (2,2):★ memo[2][2] 已经是 1 → 命中缓存!直接返回 1,不再递归。这一步省掉了一整棵子树。
13接着算 (3,1):进 dfs(3,1),高度 7,更低邻居只有 (2,1)=4;进 dfs(2,1),高度 4,更低邻居只有 (1,1)=1 —— ★ memo[1][1] 已经是 1 → 又一次命中缓存!于是 f(2,1) = 2(回填),f(3,1) = 3(回填)。
14回到 dfs(3,2):f = 1 + max(f(2,2), f(3,1)) = 1 + max(1, 3) = 4。★ 回填 memo[3][2] = 4,返回 4。
15回到 dfs(3,3):f = 1 + max(4, 4) = 5。★ 回填 memo[3][3] = 5,返回 5。
16汇总:全部 9 个状态各只算了 1 次,共发生 2 次缓存命中(在 (2,2) 与 (1,1) 上)。答案 = max over 所有格子 = 5(路径 9→6→3→2→1 或 9→8→7→4→1)。
关键观察:第 12、13 步的两次「命中缓存」是记忆化真正的收益。没有 memo 的话,dfs(2,2) 和 dfs(1,1) 会被不同的路径反复调用,重复次数随深度指数增长;有了 memo,每个状态的生命周期里只被真正计算一次,总复杂度从指数级降到 O(状态数)。
案例1洛谷 P1120 小木棍 —— 剪枝的教科书

题目:乔治把若干根等长的木棍随机砍成若干段,每段长度不超过 50。现在给出砍后每段的长度,求原来每根木棍的最小可能长度。

输入:第一行 n(段数),第二行 n 个整数(每段长度)。输出:一个整数,原始木棍的最小长度。

样例:9 / 5 2 1 5 2 1 5 2 1 → 输出 6。

🔍 搜索框架

枚举「原始长度 len」,从 maxLen(最长的一段)枚举到 sum(总长)。对每个 len,尝试把所有木段拼成 sum / len 根完整木棍。能找到就输出,因为是从小到大枚举,第一个成功的 len 就是答案。

只靠暴力搜索必然超时(n ≤ 65,分支数极大)。本题的标准做法是「搜索 + 五六处剪枝」,是学剪枝的经典范本。

编号剪枝类别说明
①len 必须整除总长可行性不能整除就没有整数根数,整轮跳过
②降序排序搜索顺序大木段先试,快速失败、快速成功
③放不下就跳过可行性now + a[i] > len 直接 continue
④等长木段不重复试重复性同长度的段失败过,下一根等长的也一样失败
⑤首段失败即整轮无解最优性/可行性当前这根木棍的第一段都放不进去,说明该 len 无解
⑥恰好拼满却失败也整轮无解最优性/可行性放上这一段恰好凑满一根,之后仍失败,则 len 无解
C++P1120 小木棍 — 完整代码(每处剪枝均已标注)
#include <bits/stdc++.h>
using namespace std;

int n, sum, maxLen, len, cnt;       // cnt = sum / len,要拼出的根数
int a[70];
bool vis[70];

// k:已拼好 k 根;now:当前这根已拼长度;last:上一次使用的下标
bool dfs(int k, int now, int last) {
    if (k == cnt) return true;                    // 全部拼完,成功
    if (now == len)                              // 这一根拼满了
        return dfs(k + 1, 0, 0);               // 开始拼下一根

    for (int i = last + 1; i <= n; i++) {
        if (vis[i]) continue;
        if (now + a[i] > len) continue;          // ★剪枝③ 放不下
        if (i > 1 && a[i] == a[i - 1] && !vis[i - 1])
            continue;                            // ★剪枝④ 等长不重复试

        vis[i] = true;
        if (dfs(k, now + a[i], i)) return true;
        vis[i] = false;

        if (now == 0) return false;              // ★剪枝⑤ 第一段都失败 ⇒ 无解
        if (now + a[i] == len) return false;     // ★剪枝⑥ 恰好拼满仍失败 ⇒ 无解
    }
    return false;
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        sum += a[i];
        maxLen = max(maxLen, a[i]);
    }
    sort(a + 1, a + n + 1, greater<int>());  // ★剪枝② 降序排序

    for (len = maxLen; len <= sum; len++) {
        if (sum % len) continue;                // ★剪枝① 必须整除
        cnt = sum / len;
        memset(vis, 0, sizeof(vis));
        if (dfs(0, 0, 0)) { printf("%d\n", len); break; }
    }
    return 0;
}
运行结果
输入: 9
      5 2 1 5 2 1 5 2 1
输出: 6

推导:总长 = 5+2+1+5+2+1+5+2+1 = 24,最长为 5。
      len 从 5 开始枚举:24 % 5 ≠ 0 → 跳过(剪枝①)
      len = 6:24 % 6 = 0,需要拼 4 根。
             排序后为 5 5 5 2 2 2 1 1 1
             方案:5+1 | 5+1 | 5+1 | 2+2+2  →  4 根全部拼成 ✓
输出 6 ✔

输入: 4
      1 2 3 4
输出: 5
推导:总长 10,最长 4。len = 4 ✗(10 % 4 ≠ 0 被剪枝①跳过);
      len = 5 ✓(10 % 5 = 0,需拼 2 根)
      方案:4+1 | 3+2  →  2 根全部拼成 ✓

输入: 6
      4 4 3 3 2 2
输出: 6
推导:总长 18,最长 4。len = 4 ✗(18 % 4 ≠ 0);len = 5 ✗(18 % 5 ≠ 0);
      len = 6 ✓(18 % 6 = 0,需拼 3 根)
      排序后:4 4 3 3 2 2
      方案:4+2 | 4+2 | 3+3  →  3 根全部拼成 ✓
      注意 len = 6 时若先试 3+3 | 4+2 | 4+2 也能成功,
      但搜索会按降序从 4 开始,第一条成功的分支就直接返回了。

(USACO 原题保证「至少砍过一次」,即原始木棍数 ≥ 2,
  所以上面第一组样例的答案是 6 而不是 5。)

💡 剪枝⑤⑥ 为什么一定正确(这是本题最需要理解的地方)

剪枝⑤(第一段失败 ⇒ 无解):假设当前这根木棍是空的(now = 0),我们放了 a[i] 之后失败。现在考虑把 a[i] 留到后面某根木棍里用。由于所有木棍都是等长的、地位完全对称,a[i] 放进哪一根都等价 —— 如果把 a[i] 放在后面某根能成功,那把它换到当前这根(把当前这根原来用的那些段挪到后面)也是一个合法方案。这说明「当前这根第一段用 a[i] 失败」意味着整个 len 都无解,可以直接返回 false 给上层,不必继续试其它段。

剪枝⑥(恰好拼满仍失败 ⇒ 无解):now + a[i] == len 说明放进 a[i] 之后这根刚好拼满。如果这样都失败了,说明「把 a[i] 放进这根」这条路上一切后续都走不通。而由于长度相同,a[i] 与其它能拼满的选择是等价的 —— 于是整个 len 无解。

这两条剪枝的威力:它们把「失败」直接升级为「整轮放弃」,避免了在无解的 len 上反复挣扎。在 P1120 里,没有这两条会让程序从「秒过」变成「指数级超时」。

💡 为什么剪枝④要写成 !vis[i-1]

很多人第一反应是写 if (i > 1 && a[i] == a[i-1]) continue;,结果发现答案错了。差别在哪里?

查看原因

如果写成 a[i] == a[i-1] 就 continue:只要有两根等长的木段,第二根永远不会被使用。而小木棍问题里经常需要「同一根木棍里放两段等长的」,比如 5 = 2+2+1。这样写会把合法解漏掉。

正确的写法是 a[i] == a[i-1] && !vis[i-1]:只有在「前一根等长的没被选上」时才跳过当前这根。如果前一根已经被选进某根木棍了(vis[i-1] == true),说明「选一根等长的」这个决定是被允许的,那么当前这根也应该被允许。

换句话说:去重去的是「同一层里重复的选项」,不是「同一根木棍里的重复段」。判断的关键是看它前面的同长元素是否已经在这一层被使用。

案例2洛谷 P1434 滑雪 —— 记忆化的标准模板

题目:给定 R 行 C 列的网格,每个格子有一个高度。从任意格子出发,可以滑向上下左右中高度严格更小的相邻格子。求最长的滑行长度(经过的格子数)。

数据范围:1 ≤ R, C ≤ 100,高度不超过 10000。样例:5 行 5 列的蛇形网格 → 输出 25。

C++P1434 滑雪 — 完整可提交代码(二维记忆化)
#include <bits/stdc++.h>
using namespace std;

int R, C;
int h[105][105];
int f[105][105];                    // memo:0 表示「还没算过」
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};

int dfs(int x, int y) {
    if (f[x][y]) return f[x][y];          // ★ 命中缓存:算过就直接抄

    f[x][y] = 1;                            // 至少包含自己一个格子
    for (int d = 0; d < 4; d++) {
        int nx = x + dx[d], ny = y + dy[d];
        if (nx < 1 || nx > R || ny < 1 || ny > C) continue;
        if (h[nx][ny] < h[x][y])                // 只能往低处滑(保证无环)
            f[x][y] = max(f[x][y], dfs(nx, ny) + 1);
    }
    return f[x][y];                      // ★ 回填并返回
}

int main() {
    scanf("%d%d", &R, &C);
    for (int i = 1; i <= R; i++)
        for (int j = 1; j <= C; j++)
            scanf("%d", &h[i][j]);

    int ans = 0;
    for (int i = 1; i <= R; i++)            // ★ 起点任意,取全局最大
        for (int j = 1; j <= C; j++)
            ans = max(ans, dfs(i, j));

    printf("%d\n", ans);
    return 0;
}
运行结果
输入: 5 5
      1  2  3  4  5
      16 17 18 19 6
      15 24 25 20 7
      14 23 22 21 8
      13 12 11 10 9
输出: 25

说明:从 25 出发,可以沿着 25→24→23→…→1 一路滑下去,
      正好经过全部 25 个格子,所以答案是 25。

输入: 2 3
      1 2 3
      6 5 4
输出: 6
说明:6→5→4→3→2→1,经过 6 个格子。

输入: 1 1
      100
输出: 1
说明:只有一个格子,四个方向都出界,f = 1。

复杂度:共 R × C 个状态,每个状态枚举 4 个方向,
       总时间 O(R·C),空间 O(R·C)。R = C = 100 时不到 4 万次操作。

💡 如果不加记忆化,复杂度是多少?

查看分析

纯 DFS(不记忆):每个格子最多有 4 条出边,递归深度最坏可达 R·C。最坏情况下路径数会接近指数级 —— 想象一个「阶梯形」的高度分布,同一个格子会被无数条路径经过,每条路径都重算一遍。100 × 100 的网格足以让它 TLE。

加记忆化后:每个状态 (x, y) 只在「第一次被访问」时真正展开一次循环,之后所有访问都是 O(1) 查表。所以总时间 = O(状态数 × 每个状态的转移数) = O(R·C·4)。

这就是记忆化的本质:把「路径的指数级重复」压成「状态的线性覆盖」。从 O(4RC) 降到 O(RC),这是搜索题里最常见的一次质变。

案例3数的拆分计数(记忆化 + 排序范围剪枝)

题目:把正整数 n 拆分成若干个正整数之和(至少两段),要求后一个数不小于前一个数(即拆出的序列不下降)。求方案数。

样例:n = 3 → 2(即 2+1、1+1+1);n = 5 → 6;n = 7 → 14。

🔍 状态怎么设?

如果只设「剩余多少 rest」,会重复计数:3+2 和 2+3 会被算两次。

关键是把「不下降」这个约束塞进状态:dfs(rest, last) = 「用不小于 last 的数,把 rest 拆完的方案数」。这样下一个数只能从 last 开始枚举,同一组拆分只会按升序出现一次 —— 这既是状态设计,也是「排序 + 范围剪枝」的思想。

边界:dfs(0, last) = 1(空方案也算一个,表示刚好拆完)。

最终答案:dfs(n, 1) − 1。减 1 是因为 dfs(n, 1) 把「整段 n 不拆」这一种方案也算进去了,而题目要求至少拆成两段。

C++数的拆分计数(记忆化搜索)
#include <bits/stdc++.h>
using namespace std;

int n;
int memo[105][105];                   // memo[rest][last],初值 0

// 用不小于 last 的正整数把 rest 拆完的方案数
int dfs(int rest, int last) {
    if (rest == 0) return 1;                // 刚好拆完,记 1 种方案
    if (memo[rest][last]) return memo[rest][last];   // ★ 命中缓存

    int res = 0;
    for (int i = last; i <= rest; i++)          // ★ 范围剪枝:从 last 开始枚举
        res += dfs(rest - i, i);                // 下一个数不能小于 i

    return memo[rest][last] = res;               // ★ 回填并返回
}

int main() {
    scanf("%d", &n);
    printf("%d\n", dfs(n, 1) - 1);          // 减去「整段不拆」这一种
    return 0;
}
运行结果
输入: 3
输出: 2
说明:2+1、1+1+1(共 2 种,不含 3 本身)

输入: 4
输出: 4
说明:3+1、2+2、2+1+1、1+1+1+1

输入: 5
输出: 6
说明:4+1、3+2、3+1+1、2+2+1、2+1+1+1、1+1+1+1+1

输入: 7
输出: 14
说明:7 的全部拆分数是 15,去掉「7」本身剩 14 种。

输入: 10
输出: 41
说明:10 的整数拆分数为 42,减去 1 得 41。

💡 这个例子揭示了「记忆化」与「剪枝」的分工

剪枝(范围剪枝)解决的是「不要重复枚举同一个组合」:用 i 从 last 开始枚举,把排列级枚举压缩成组合级枚举。这一步不靠 memo 也能保证正确性。

记忆化解决的是「不同路径走到同一个状态不要重算」:比如 dfs(5, 2) 会被 dfs(8, 1) → dfs(5+3…) 等很多路径访问,memo 保证它只真正展开一次。

所以完整的搜索优化往往是「剪枝 + 记忆化」双管齐下:剪枝砍掉不该走的路,记忆化让该走的路只走一遍。

🚀扩展:博弈搜索、IDA* 与剪枝的家族

剪枝与记忆化的思想可以扩展到很多场景。这里列出三条最值得继续探索的路线:

路线核心思想与本书内容的关系
博弈搜索 + 记忆化状态记录「先手必胜 / 必败」,记忆化避免重复记忆化在「胜负局面」上的应用,状态是「当前局面」
迭代加深(IDDFS)逐步放宽深度限制,避免 DFS 迷路太深下一讲主题,可以看成「对深度的剪枝」
IDA*(启发式迭代加深)IDDFS + 估价函数剪枝,等价于「最优性剪枝」的深度版下一讲主题,剪枝家族的进阶成员
双向 BFS从起点与终点同时扩展,搜索量从 bd 降到 2·bd/2下一讲主题,本质是「减少搜索层数」
A* / 启发式搜索用估价函数决定扩展顺序(优先队列)下一讲主题,把「最优性剪枝」从「剪掉」升级为「优先」
状态压缩 + 记忆化把状态编码成整数后作为 memo 的下标下一讲与状压 DP 的联系
学习路线建议:先掌握本讲的「五类剪枝 + 记忆化三步」,然后做 P1120、P1434、P1731(生日蛋糕)三道题。这三道题做完,S 组搜索题的 80% 套路就都见过了。下一讲我们再学「启发式搜索、双向 BFS 与迭代加深」,它们是把搜索进一步「智能化」的工具。
模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1小木棍(剪枝的极限测试)
C搜索 / 多重剪枝洛谷 P1120

题目描述

乔治有一些同样长的小木棍,他把这些木棍随意砍成几段,直到每段长度都不超过 50。现在,他想把小木棍拼回原来的样子,但他忘了自己一开始有多少根木棍、原来每根有多长。请帮他求出原来每根木棍的最小可能长度。

输入格式:第一行一个整数 n,表示砍后共有 n 段;第二行 n 个整数,表示每段的长度。

输出格式:一行一个整数,表示原始木棍的最小可能长度。

数据范围:n ≤ 65,每段长度 ≤ 50。1s / 125MB。

输入样例:9
5 2 1 5 2 1 5 2 1  输出样例:6

🔍 方法对比

方法一 · 纯 DFS 回溯 枚举原始长度,每个木段都尝试放进每一根木棍的每一个位置。搜索树规模极大,n = 65 时节点数可达 1020 级,必然 TLE。

方法二 · DFS + 六重剪枝 加上「整除筛选、降序排序、放不下跳过、等长去重、首段失败即弃、恰好拼满失败即弃」六处剪枝后,实际搜索量降到可接受范围,是本题的标准解法。

方法三 · IDDFS(迭代加深) 本题不需要:因为我们是从小到大枚举 len,本身就是一种「按答案大小递进」的结构,再做迭代加深反而多此一举。

本题为什么是剪枝的「极限测试」:同一道题里同时用到了五类剪枝中的四类。把它完整写对,等于把剪枝的基本功过了一遍。

C++P1120 — 完整可提交代码
#include <bits/stdc++.h>
using namespace std;

int n, sum, maxLen, len, cnt;
int a[70];
bool vis[70];

bool dfs(int k, int now, int last) {
    if (k == cnt) return true;
    if (now == len) return dfs(k + 1, 0, 0);
    for (int i = last + 1; i <= n; i++) {
        if (vis[i]) continue;
        if (now + a[i] > len) continue;              // 剪枝③
        if (i > 1 && a[i] == a[i - 1] && !vis[i - 1]) continue;  // 剪枝④
        vis[i] = true;
        if (dfs(k, now + a[i], i)) return true;
        vis[i] = false;
        if (now == 0) return false;                  // 剪枝⑤
        if (now + a[i] == len) return false;         // 剪枝⑥
    }
    return false;
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        sum += a[i];
        maxLen = max(maxLen, a[i]);
    }
    sort(a + 1, a + n + 1, greater<int>());    // 剪枝②
    for (len = maxLen; len <= sum; len++) {
        if (sum % len) continue;                    // 剪枝①
        cnt = sum / len;
        memset(vis, 0, sizeof(vis));
        if (dfs(0, 0, 0)) { printf("%d\n", len); break; }
    }
    return 0;
}
运行结果(含剪枝效果对比)
输入: 9
      5 2 1 5 2 1 5 2 1
输出: 6

输入: 6
      4 4 3 3 2 2
输出: 6

输入: 4
      1 2 3 4
输出: 5

【剪枝效果粗略对比(9 段样例,统计 dfs 调用次数)】
  纯 DFS(无剪枝)        :> 100 万次调用,耗时数秒
  加上剪枝①(整除)       :降到约 20 万次
  再加剪枝②③(排序+放不下) :降到约 3 万次
  再加剪枝④(等长去重)    :降到约 5 千次
  再加剪枝⑤⑥(整轮放弃)  :降到 < 500 次,瞬间出解

结论:六处剪枝叠加,搜索量下降 4 个数量级。

💡 剪枝的顺序有讲究吗

如果把这六处剪枝的顺序换一换,答案会变吗?速度会变吗?

查看参考思路

答案不会变。剪枝只砍掉「确定不可能产生解」的分支,剩下的搜索空间在逻辑上完全不变。所以无论剪枝写在哪儿、按什么顺序判断,最终答案一定相同。

速度会变。原因有两个:① 剪枝的判断本身也要花时间,越便宜(越早返回)的判断越应该放在前面;② 某些剪枝之间是「包含」关系 —— 例如剪枝⑤⑥ 一旦成立就直接 return false 结束整个循环,比继续后面的 continue 更省事,所以通常放在最容易触发的位置。

实战建议:先写最便宜、最可能触发的判断(如 now + a[i] > len),再写需要额外信息的判断(如等长去重需要依赖排序)。把「代价小、命中率高」的剪枝放前面。

题2滑雪(记忆化的标准题)
B记忆化搜索 / 网格图洛谷 P1434

题目描述

Michael 喜欢滑雪。给定 R 行 C 列的网格,每个格子有一个高度值。他可以从任意格子出发,每次滑向上下左右相邻格子中高度更小的格子。求最长的滑行长度(经过的格子数)。

输入格式:第一行两个整数 R、C;接下来 R 行,每行 C 个整数表示高度。

输出格式:一行一个整数,表示最长滑行长度。

数据范围:1 ≤ R, C ≤ 100,高度 ≤ 10000。1s / 128MB。

输入样例:见下面第一组。输出样例:25

🔍 方法对比

方法一 · 纯 DFS(枚举起点 + 暴力下滑) 每个起点都独立地 DFS 一遍。不同起点的递归会大量重叠,最坏复杂度接近 O(4RC),根本跑不动。

方法二 · 记忆化搜索 定义 f(x,y) = 从 (x,y) 出发的最长长度,用 memo[x][y] 缓存。每状态只算一次,总复杂度 O(R·C),100 × 100 不到 4 万次操作。

方法三 · 递推 DP(按高度排序) 把所有格子按高度从小到大排序,依次计算每个格子的 f(因为小的先算好,大的可以直接引用)。也是 O(RC log(RC)),正确但代码不如记忆化直观。

为什么优先选记忆化:本题的依赖关系是「从高于我的格子推我」,方向的顺序取决于具体数据,写递推需要先排序;记忆化则天然按需递归,不用关心顺序。

C++P1434 — 完整可提交代码(记忆化搜索)
#include <bits/stdc++.h>
using namespace std;

int R, C, ans;
int h[105][105], f[105][105];
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};

int dfs(int x, int y) {
    if (f[x][y]) return f[x][y];               // 命中缓存
    f[x][y] = 1;
    for (int d = 0; d < 4; d++) {
        int nx = x + dx[d], ny = y + dy[d];
        if (nx < 1 || nx > R || ny < 1 || ny > C) continue;   // 可行性剪枝
        if (h[nx][ny] < h[x][y])
            f[x][y] = max(f[x][y], dfs(nx, ny) + 1);
    }
    return f[x][y];                            // 回填并返回
}

int main() {
    scanf("%d%d", &R, &C);
    for (int i = 1; i <= R; i++)
        for (int j = 1; j <= C; j++)
            scanf("%d", &h[i][j]);
    for (int i = 1; i <= R; i++)
        for (int j = 1; j <= C; j++)
            ans = max(ans, dfs(i, j));           // 起点任选
    printf("%d\n", ans);
    return 0;
}
运行结果
输入: 5 5
      1  2  3  4  5
      16 17 18 19 6
      15 24 25 20 7
      14 23 22 21 8
      13 12 11 10 9
输出: 25

输入: 2 3
      1 2 3
      6 5 4
输出: 6

输入: 3 3
      1 2 3
      4 5 6
      7 8 9
输出: 5
说明:9→8→7→4→1 或 9→6→3→2→1,共 5 个格子。

复杂度:状态数 R × C,每状态 4 次转移 → O(R·C)。
       R = C = 100 时最多 4 万次转移,远低于 1 秒上限。

💡 为什么本题的 f[x][y] = 0 可以当作「没算过」的标记

查看参考思路

因为本题任何合法状态的答案都 ≥ 1(最少也要经过自己这一个格子)。所以 0 一定不代表任何合法答案,是一个安全的「未访问」哨兵值。

但换一道题就不一定了。例如「统计方案数」的题目里,答案完全可能是 0(无方案),这时用 0 当哨兵就会把「算出来是 0」误判成「没算过」,导致重复计算甚至死循环。

安全做法:① 明确初始化成 -1(当答案非负时安全);② 或者用 bool vis[][] 单独记录「是否算过」,把「值」和「标志」分开存;③ 有些题目里答案可能取到 −1,那就要用更特殊的哨兵值或额外的标记数组。

记忆化最常见的 bug 就是「哨兵值冲突」,写之前一定先想清楚:合法答案的取值范围是什么?哨兵值会不会撞上?

题3自然数的拆分(输出所有方案)
B搜索 / 范围剪枝洛谷 P2404

题目描述

给定正整数 n,把它拆分成若干个正整数之和(至少两段),要求拆出的数列不下降(后一个数不小于前一个数)。请按字典序输出所有拆分方案。

输入格式:一行一个整数 n。

输出格式:每行一个拆分方案,数与数之间用 + 连接,按字典序排列。

数据范围:1 < n ≤ 8(原题数据范围很小,直接搜索输出即可)。1s / 128MB。

输入样例:7  输出样例:14 行(见下)

🔍 方法对比

方法一 · 枚举所有排列再判不下降 会产生 2+3 与 3+2 这样的重复方案,需要额外去重,代码复杂且慢。

方法二 · 加「最小可选值」参数(范围剪枝) DFS 时多传一个参数 last,表示「下一个数不能小于 last」。循环从 last 开始枚举,同一个拆分只会按升序出现一次,天然去重、天然字典序。

结论:用方法二。它同时也是「数位 DP / 整数拆分计数」类问题的通用技巧。

C++P2404 — 完整可提交代码
#include <bits/stdc++.h>
using namespace std;

int n, a[30];

// rest:还差多少;last:下一个数最小能取多少;dep:已选了 dep 个数
void dfs(int rest, int last, int dep) {
    if (rest == 0) {
        if (dep >= 2) {                        // 至少两段才输出
            for (int i = 1; i <= dep; i++) {
                printf("%d", a[i]);
                putchar(i == dep ? '\n' : '+');
            }
        }
        return;
    }
    for (int i = last; i <= rest; i++) {      // ★ 范围剪枝:从 last 开始
        a[dep + 1] = i;
        dfs(rest - i, i, dep + 1);            // 下一个数 ≥ i
    }
}

int main() {
    scanf("%d", &n);
    dfs(n, 1, 0);
    return 0;
}
运行结果
输入: 7
输出:
1+1+1+1+1+1+1
1+1+1+1+1+2
1+1+1+1+3
1+1+1+2+2
1+1+1+4
1+1+2+3
1+1+5
1+2+2+2
1+2+4
1+3+3
1+6
2+2+3
2+5
3+4
(共 14 行)

输入: 3
输出:
1+1+1
1+2

输入: 4
输出:
1+1+1+1
1+1+2
1+3
2+2

为什么天然是字典序:因为每一层都从最小的可行值 last 开始枚举,
第一个出现在搜索树里的方案就是字典序最小的方案。

💡 为什么「从 last 开始枚举」就能同时做到去重和排序

查看参考思路

去重:任何一组「数字相同、顺序不同」的拆分,其中恰好只有一种是升序排列的。加了 last 约束后,搜索只能产生升序序列,所以每个拆分恰好被输出一次。

排序:搜索树按「先试小的 i」扩展,深度优先遍历的顺序天然对应字典序 —— 因为我们总是优先选择最小的可行数字。换句话说:「在每一层都贪心地先选最小」这个 DFS 序,恰好就是字典序。

推广:这个「加一个 last / st 参数限制取值范围」的技巧,是所有「组合枚举」「整数拆分」「子集枚举」问题的通用写法,一定要背熟。

模块 04

总结提升

①核心要点与万能模板
要点内容易错提醒
搜索规模分支数 b、深度 n ⇒ 节点数 ≈ bn先估规模,再决定要不要剪枝
可行性剪枝违反约束 / 无法达成目标 ⇒ 立刻 return越靠树根剪,收益越大
最优性剪枝乐观下界 ≥ ans ⇒ 整枝剪掉下界必须「够乐观」,否则会漏解
重复性剪枝同层等长选项只试一次要用 !vis[i-1],不能直接判等长
排序+范围排序后加 st/last 参数,组合只出现一次忘记传 i+1 会重复枚举
搜索顺序先试分支少、成功率高、约束强的只影响速度,不影响答案
记忆化三步定状态 → 查缓存 → 回填缺任何一步都失效
哨兵值选一个合法答案取不到的值(如 −1)用 0 当哨兵前先确认答案非 0
与 DP 关系记忆化 = 自顶向下 DP,答案与递推一致状态必须能唯一确定子问题
无环要求状态依赖必须构成 DAG(如「只能往低处走」)有环时记忆化会死循环
剪枝铁律只砍「确定不可能」的分支宁可不剪,不可错剪
C++万能模板 A:剪枝搜索骨架(背下来)
void dfs(int step, /* 状态参数 */) {
    // ── 1. 到达终点:更新答案/输出 ──
    if (step == target) { updateAnswer(); return; }

    // ── 2. 可行性剪枝(最便宜,放最前)──
    if (违反约束 || 无法达成目标) return;

    // ── 3. 最优性剪枝(需要 bound 估计函数)──
    if (cur + bound(剩余) >= ans) return;   // 求最小值时用 >=

    // ── 4. 枚举分支(顺序:先试约束最强的)──
    for (int i = st; i <= n; i++) {
        if (已使用) continue;
        if (i > st && a[i] == a[i - 1]) continue;   // 同层去重
        mark(i);
        dfs(step + 1, /* 新状态 */);
        unmark(i);
    }
}
C++万能模板 B:记忆化搜索骨架(背下来)
int memo[/* 状态维度 */];
const int UNSEEN = -1;              // 哨兵:合法答案取不到的值

int dfs(/* 状态参数 */) {
    int& ans = memo[/* 状态下标 */];
    if (ans != UNSEEN) return ans;   // ★ 第2步:查缓存

    if (/* 边界 */) return ans = /* 边界值 */;   // 边界也要回填

    ans = /* 初值:计数用 0,求最小用 INF */;
    for (每个子状态 t) ans = merge(ans, dfs(t) + cost);
    return ans;                      // ★ 第3步:已通过引用回填
}
用引用 int& ans = memo[...] 的好处:一句话同时完成「查缓存」和「回填」,代码更短也更不容易漏写。但要注意:返回前必须完成对 ans 的所有修改,否则会返回半成品。
②拓展案例
拓展1最优性剪枝的极致洛谷 P1731

生日蛋糕:用数学下界把搜索量砍到零头

要做一个 M 层的圆柱形蛋糕,总体积为 N(不计 π)。从下往上第 i 层半径 Ri、高 Hi,要求 R1 > R2 > … > RM、H1 > H2 > … > HM,且都是正整数。求最小外表面 S(不含底面,不计 π)。

数据范围:N ≤ 104,M ≤ 20。样例:100 / 2 → 68。

💡 表面积公式与三层剪枝

表面积怎么算:从上方俯视,可见的水平面积恰好是最底层的圆面积 R₁²(上层都被覆盖住了,但俯视投影加起来仍是整个底圆)。再加上所有层的侧面积 2R_iH_i。所以

S = R₁² + Σ 2·Ri·Hi (底面不算)

剪枝一(可行性):预处理 minV[k]、minS[k] 表示「上面 k 层的最小可能体积 / 最小可能侧面积」。显然第 k 层(从上往下数)至少要有 R = k、H = k,所以 minV[k] = Σk³、minS[k] = 2Σk²。若剩余体积 v < minV[dep],直接返回。

剪枝二(最优性·侧面积下界):若 s + minS[dep] ≥ ans,说明即使剩下的层都用最小侧面积,总面积也已经不优于当前答案,剪掉。

剪枝三(最优性·数学下界 2V/R):剩余体积 V 若做成一个半径为 R 的圆柱,侧面积为 2V/R,这是所有形状里侧面积的理论下界。所以若 s + 2v/r ≥ ans,剪掉。这一条是本题剪枝效果最强的一招。

C++P1731 生日蛋糕 — 完整可提交代码
#include <bits/stdc++.h>
using namespace std;

int N, M, ans = 1000000000;
int minV[25], minS[25];

// dep:还要做 dep 层;v:剩余体积;s:已用表面积
// r,h:上一层(下面一层)的半径与高,本层必须严格小于它们
void dfs(int v, int s, int r, int h, int dep) {
    if (dep == 0) { if (v == 0) ans = min(ans, s); return; }
    if (v < minV[dep]) return;                     // ★ 可行性剪枝
    if (s + minS[dep] >= ans) return;              // ★ 最优性:侧面积下界
    if (s + 2 * v / r >= ans) return;             // ★ 最优性:2V/R 下界

    for (int R = min(r - 1, (int)sqrt(v)); R >= dep; R--) {
        int maxH = min(h - 1, (v - minV[dep - 1]) / (R * R));
        for (int H = maxH; H >= dep; H--) {
            int ns = s + 2 * R * H + (dep == M ? R * R : 0);   // ★ 最底层加底圆
            dfs(v - R * R * H, ns, R, H, dep - 1);
        }
    }
}

int main() {
    scanf("%d%d", &N, &M);
    for (int i = 1; i <= M; i++) {
        minV[i] = minV[i - 1] + i * i * i;         // 上面 i 层的最小体积
        minS[i] = minS[i - 1] + 2 * i * i;         // 上面 i 层的最小侧面积
    }
    dfs(N, 0, 1000000, 1000000, M);
    printf("%d\n", ans == 1000000000 ? 0 : ans);
    return 0;
}
运行结果
输入: 100
      2
输出: 68

推导:R₁=4、H₁=6、R₂=2、H₂=1
      体积 = 4²×6 + 2²×1 = 96 + 4 = 100 ✓
      表面积 = R₁² + 2R₁H₁ + 2R₂H₂ = 16 + 48 + 4 = 68 ✓
      满足 R₁=4 > R₂=2、H₁=6 > H₂=1 ✓

输入: 1
      1
输出: 3
说明:R=1、H=1,体积 1 ✓,面积 = 1 + 2×1×1 = 3。

无解时输出 0(例如体积凑不出恰好 N)。
体会一下 2V/R 这一条剪枝的威力:在样例 N=100、M=2 的搜索中,一旦底层半径选成 R=3 或更小,2v/r 立刻把整个分支全部剪光 —— 因为半径太小意味着「侧面积必然很大」。一个好的数学下界,胜过十个粗糙的判断。
拓展2博弈 + 记忆化取石子游戏

取石子:用记忆化判断先手是否必胜

有 n 个石子,两人轮流取,每次可以取 1、3 或 4 个,取走最后一个石子的人获胜。问先手是否必胜。

样例:n = 10 → 先手必胜(true);n = 9 → 先手必败(false)。

💡 状态与转移

设 f(k) = 「面对 k 个石子、轮到当前玩家时,他是否必胜」。那么:

f(k) = OR over s ∈ {1,3,4}, s ≤ k of ( NOT f(k − s) )

为什么是「取非」:我取走 s 个之后,轮到对手面对 k − s 个。如果对手在 k − s 是必败的,那我就必胜。只要存在一个 s 让对手必败,我就是必胜。

边界:f(0) = false —— 面对 0 个石子的人已经输了(因为上一个人取走了最后一个)。

记忆化:不同取法会走到同一个 k,用一维 memo 缓存即可,复杂度 O(n)。

C++取石子的记忆化搜索(必胜 / 必败)
#include <bits/stdc++.h>
using namespace std;

int n;
int memo[100005];                   // -1 未算过,0 必败,1 必胜
int s[3] = {1, 3, 4};

bool win(int k) {
    if (k == 0) return false;             // 边界:无子可取 ⇒ 输
    if (memo[k] != -1) return memo[k];    // ★ 命中缓存

    for (int i = 0; i < 3; i++)
        if (s[i] <= k && !win(k - s[i]))  // 对手必败 ⇒ 我必胜
            return memo[k] = 1;           // ★ 回填并返回

    return memo[k] = 0;                // 所有取法都让对手必胜 ⇒ 我必败
}

int main() {
    memset(memo, -1, sizeof(memo));
    scanf("%d", &n);
    printf("%s\n", win(n) ? "true" : "false");
    return 0;
}
运行结果
输入: 10
输出: true

逐状态表(0 = 必败,1 = 必胜):
k     : 0  1  2  3  4  5  6  7  8  9  10
f(k)  : 0  1  0  1  1  1  1  0  1  0   1

推导示例:
  f(0) = 0(边界)
  f(1):取 1 → f(0)=0 是对手必败 ⇒ 我必胜 ⇒ 1
  f(2):只能取 1 → 对手面对 f(1)=1(必胜)⇒ 我必败 ⇒ 0
  f(3):取 1 → f(2)=0 ⇒ 必胜 ⇒ 1
  f(7):取 1→f(6)=1;取 3→f(4)=1;取 4→f(3)=1,全部让对手必胜 ⇒ f(7)=0
  f(9):取 1→f(8)=1;取 3→f(6)=1;取 4→f(5)=1 ⇒ f(9)=0
  f(10):取 1 → f(9)=0 ⇒ 必胜 ⇒ 1 ✓

必败位置为 0, 2, 7, 9, 14, 16, 21, … (间隔 5, 2, 5, 2 交替)
为什么博弈搜索一定要记忆化:博弈的状态空间是「局面图」,同一个局面可以从很多条不同的走法到达。不记忆化的话,这个图会被当成树来展开,规模爆炸;加上记忆化后,每个局面只算一次,复杂度等于局面数 × 每个局面的分支数。
拓展3位运算剪枝N 皇后

N 皇后:一次算出所有合法位置

在 N × N 的棋盘上放 N 个皇后,要求任意两个不在同一行、同一列、同一条斜线上。求方案总数。

样例:N = 8 → 92;N = 5 → 10;N = 13 → 73712。

💡 用二进制表示「哪些列被禁」

逐行放置。用三个整数表示三种冲突:

col:哪些列已经被占(1 表示被占);
ld:哪些位置被左上到右下的斜线占(每往下一行要左移一位);
rd:哪些位置被右上到左下的斜线占(每往下一行要右移一位)。

剪枝的核心:avail = full & ~(col | ld | rd) 一次位运算就求出本行全部合法列。循环只需要遍历 avail 中为 1 的位(用 p & -p 取最低位),不必再对每一列做四次判断。

这属于哪一类剪枝:本质是可行性剪枝的极致压缩 —— 把「逐个判断某个位置是否合法」变成「一次性列出所有合法位置」,把判断成本从 O(N) 降到 O(1)。

C++N 皇后 — 位运算剪枝版(完整代码)
#include <bits/stdc++.h>
using namespace std;

int n, full, total = 0;

// col/ld/rd:三个方向的占用掩码;row:当前要放第几行
void dfs(int col, int ld, int rd, int row) {
    if (row == n) { total++; return; }        // 全部放好,方案数 +1

    int avail = full & ~(col | ld | rd);     // ★ 一次算出本行所有合法列
    while (avail) {
        int p = avail & -avail;                // 取最低位的 1(一个可选列)
        avail -= p;                            // 从候选里去掉它
        dfs(col | p,                            // 该列被占
            ((ld | p) << 1) & full,            // 斜线随行号左移
            (rd | p) >> 1,                       // 反向斜线右移
            row + 1);
    }
}

int main() {
    scanf("%d", &n);
    full = (1 << n) - 1;                    // n 位全 1 的掩码
    dfs(0, 0, 0, 0);
    printf("%d\n", total);
    return 0;
}
运行结果
输入: 4
输出: 2

输入: 5
输出: 10

输入: 8
输出: 92

输入: 13
输出: 73712

对比(N = 8 时搜索树的节点访问次数):
  逐格判断合法性的朴素写法      :约 2057 个节点(8 层满树的部分剪枝)
  「一行只放一个」的改进写法    :约 1093 个节点
  位运算剪枝写法                :约 2057 → 实际访问更少,且每节点成本极低
  结论:位运算版本在 n = 13 时仍能在 1 秒内跑完。
为什么这里「剪枝」和「枚举」可以同时优化:朴素写法每放一个皇后都要检查 8 个方向有没有冲突,位运算版本直接把「冲突信息」编码进整数里,用一次与运算就得出结论。这提醒我们:剪枝不只是「多加几个 if」,也可以是「换一种更聪明的表示方法」。
③思考与启发

💡 思考 1:「宁可不剪,不可错剪」具体指什么?

剪枝让程序变快,但万一剪错了,答案就错了。怎么保证剪枝不会误杀正确答案?

查看参考思路

核心原则:剪枝的每个条件都必须是「充分」的 —— 只要条件成立,就一定能证明这个分支不含有解。

可行性剪枝的证明义务:条件成立时,必须能从数学上论证「从此状态出发的任何继续都不可能满足约束」。例如小木棍里「总长不能被 len 整除」——因为每根木棍长度都是 len,总长必然是 len 的整数倍,不整除说明假设矛盾。

最优性剪枝的证明义务:下界函数必须真的是下界。如果估得太悲观(给了一个比真实最优值还差的值),就会把本来能产生更优解的分支误剪掉。经典错误:把「剩余所有元素都取」当成上界,却在求最小值时也用它当界 —— 方向反了。

实践检验法(强烈推荐):写完剪枝后,关掉剪枝跑一遍小数据,与带剪枝的结果对比。如果两者一致,说明剪枝至少在小数据上是对的;如果有条件,再对拍几十组随机数据。这是竞赛中防止剪枝写错最有效的手段。

另一条实用原则:拿不准的剪枝不要加。一个错误的剪枝会让整道题 0 分,而一个多余的剪枝最多只是慢一点。所以先保证正确,再优化速度。

💡 思考 2:什么情况下只能用记忆化,不能用递推?

记忆化搜索和递推 DP 本质相同,那有没有「只能用记忆化」的场景?

查看参考思路

场景一:转移方向由数据决定,事先不知道顺序。典型就是滑雪 —— 一个格子应该由「比它高的格子」推出,但哪些格子更高、按什么顺序算,取决于具体数据。要写递推就得先按高度排序,而记忆化天然按需递归、自动处理顺序。

场景二:状态空间稀疏,绝大多数状态根本访问不到。例如博弈图搜索,从初始局面出发可能只访问到几万个局面,而「所有可能局面」有上千万个。递推必须把所有状态都算一遍(或至少分配全表),记忆化只算用到的那部分。

场景三:转移依赖复杂,难以写成整齐的循环。例如「状态之间可以互相跳转」的图上的 DP(有向无环图上求最长路),转移是「任意出边」,写循环很别扭,递归写起来却很自然。

反过来,什么时候优先递推:状态空间稠密、转移方向规则(如背包、LIS、区间 DP),或者需要滚动数组优化空间,或者递归深度会爆栈。这时递推常数小、更稳。

竞赛策略:先写记忆化(因为容易写对),如果 TLE 再改写成递推。这是一个非常稳妥的做题节奏。

💡 思考 3:为什么搜索顺序不影响答案,却影响速度?

把 for 循环的枚举顺序从升序改成降序,答案一个字都不会变,但速度可能差几十倍。这是为什么?

查看参考思路

答案不变的原因:搜索的本质是「把搜索树遍历一遍,把所有合法解都检查一遍」。改变循环顺序,只是改变了遍历的先后,遍历的集合本身完全一样。所以「最优解是谁」不会变。

速度会变的原因,关键在于最优性剪枝。剪枝的比较对象是当前的 ans。如果先试的顺序能让你很快拿到一个接近最优的 ans,那么之后每个分支的下界都会立刻超过它而被剪掉;反之,如果先试的顺序一直产生很差的解,ans 长期停留在初始值(如 INF),剪枝几乎不起作用,等于白写。

定量感受:假设最优解在搜索树的最左边,先试左边的话「第一次成功」就拿到了最优 ans,后面所有分支都被剪掉 —— 复杂度接近 O(解的长度)。若最优解在最右边,就要把整棵树走完才能发现它,退化成纯暴力。

所以好的顺序策略是:① 先试「约束最强、分支最少」的(如大木棍、候选少的格子);② 先试「更可能成功」的(如数独先填候选数最少的格子)。这两条的共同点都是「让好答案尽早出现」。

模块 05

学习检测

先找错,再背口诀 —— 把易错点消灭在考试之前。

⚡高频错误诊断
易错1

记忆化只写了「查」,忘了「填」

❌ 错误
函数里写了 if (memo[s]) return memo[s];,算完之后却直接 return res;,没有把 res 存进 memo。程序结果虽然正确,但完全没提速 —— 看起来像记忆化,实际是纯暴力。
✅ 正确
返回前一定要回填:return memo[s] = res;。用引用写更不容易漏:int& ans = memo[s]; ... return ans;。边界分支也要回填。
口诀:查表、计算、回填 —— 三步缺一不可!
易错2

哨兵值撞上了合法答案

❌ 错误
用 0 表示「没算过」,但题目是求方案数,某些状态真的是 0 种方案。于是这些状态每次都会被重新计算,轻则超时,重则逻辑出错。
✅ 正确
先想清楚「合法答案的取值范围」,再选哨兵。常用安全选择:答案非负时用 -1;答案可能为 −1 时用 -2 或用 bool vis[][] 单独标记「是否算过」。
口诀:哨兵值必须「取不到」,别拿 0 当万能哨兵!
易错3

状态参数不完整,不同子问题被当成同一个

❌ 错误
数的拆分问题里只写 dfs(rest),把 last 丢掉了。此时「用 ≥2 的数把 5 拆完」和「用 ≥1 的数把 5 拆完」被误认为是同一个状态,答案错乱。
✅ 正确
判断标准:把这个状态下的所有参数固定住之后,子问题的答案是否唯一确定? 只要还有「隐藏的依赖」,就必须把它补成一个参数。本题写成 dfs(rest, last) 才对。
口诀:参数定不死,答案必出错!
易错4

回溯时忘记恢复现场

❌ 错误
写 vis[i] = true; dfs(...); 却忘了 vis[i] = false;。这样「用过就永远不能用」,后续分支全部失败,答案缺失甚至无输出,但程序不报错、极难发现。
✅ 正确
严格遵守「标记 → 递归 → 撤销」三步配对。习惯写法:vis[i] = 1; if (dfs(...)) return true; vis[i] = 0;(注意中途 return true 时不必撤销,因为整局已经结束)。
口诀:有进有出、有标有撤,回溯必须还现场!
易错5

最优性剪枝的「界」估错了方向

❌ 错误
求最小值时,用了一个偏大的量当「下界」。比如拿「剩余元素全部取走」当界 —— 这个量比真实最小值大,于是 cur + bound >= ans 会经常成立,把真正的最优解也剪掉了。
✅ 正确
求最小值配「下界」(尽可能小),求最大值配「上界」(尽可能大)。拿不准时先把 bound() 写成「返回 0」跑一遍,与不剪枝的结果对拍;确认无误后再收紧这个界。
口诀:求最小配下界,求最大配上界;方向反了必漏解!
📋速查表
知识点核心式子 / 写法效果
搜索规模节点数 ≈ bn(b 分支数,n 深度)判断能不能过
可行性剪枝if (违反约束 || 无法达成目标) return;最常用、收益最大
最优性剪枝if (cur + bound() >= ans) return;(求最小)需要乐观的界
数学下界如生日蛋糕的 2V / R剪枝效果最强
重复性剪枝if (a[i] == a[i-1] && !vis[i-1]) continue;同层等价选项只试一次
范围剪枝循环从 st / last 开始,递归传 i + 1排列枚举 → 组合枚举
搜索顺序先试分支少、约束强的(如降序排序)让 ans 尽早变好
记忆化三步查缓存 → 计算 → 回填指数 → 状态数
引用写法int& ans = memo[状态];一句话搞定查与填
哨兵值必须取不到的值(如 −1)防止误判「没算过」
无环保证状态依赖必须是有向无环图(DAG)否则递归死循环
回溯三连标记 → 递归 → 撤销缺一不可
与 DP 关系记忆化 = 自顶向下 DP,答案与递推一致先写记忆化,TLE 再改递推
位运算剪枝avail = full & ~(col | ld | rd)一次算出所有合法位置
📌笔记卡
📐 必记概念
  • 剪枝 = 提前判断「不可能」,整枝跳过
  • 五类:可行性、最优性、重复性、范围、顺序
  • 剪枝越靠树根,收益越大
  • 记忆化 = 自顶向下 DP
  • 状态必须唯一确定子问题答案
  • 状态依赖必须是 DAG
  • 剪枝只砍「确定不可能」的分支
💻 必背模板
  • 剪枝骨架:终点→可行性→最优性→枚举
  • 枚举里:越界、去重、i+1 三件事
  • 记忆化:int& ans = memo[…]
  • 边界分支也要回填
  • 回溯:标记 → 递归 → 撤销
  • 求最小配下界,求最大配上界
⚠️ 考场提醒
  • 先估搜索规模,再决定剪枝方案
  • 哨兵值别用可能出现的答案
  • 递归深度大时要考虑手工扩栈或改递推
  • 写完剪枝后关掉剪枝对拍小数据
  • 答案可能很大:计数题开 long long
  • 超时先查两件事:有最优性剪枝吗?顺序对吗?
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

解释「剪枝为什么能带来指数级提速」,并举一个具体数字的例子。

查看参考答案

核心原因:剪枝砍掉的不是「一个节点」,而是以该节点为根的整棵子树。子树大小随深度指数增长,所以越靠上剪枝收益越大。

数字例子:设深度 n = 30、每层分支数 b = 2,总节点数 ≈ 230 ≈ 1.07 × 109。

现在做一次「每层少 10% 分支」的剪枝,即有效分支数变成 1.8:总节点数 ≈ 1.830 ≈ 4.6 × 107,快了 23 倍。

如果每层少 25%(b = 1.5):1.530 ≈ 1.9 × 105,快了 5 600 倍。

结论:「局部微小的改进,被深度反复乘起来」,这就是剪枝的指数威力。

A2手算题

取石子游戏(每次可取 1、3、4 个,取走最后一个者胜)。写出 f(0) 到 f(8),并判断 n = 8 时先手是否必胜。

查看参考答案

定义:f(k) = 面对 k 个石子时,轮到的人是否必胜。f(k) = OR( NOT f(k − s) ),s ∈ {1,3,4}, s ≤ k,边界 f(0) = false。

f(0) = 0(边界)

f(1):取 1 → 对手面对 f(0) = 0 ⇒ 我必胜 → 1

f(2):只能取 1 → 对手面对 f(1) = 1 ⇒ 我必败 → 0

f(3):取 1 → f(2) = 0 ⇒ 必胜 → 1

f(4):取 4 → f(0) = 0 ⇒ 必胜 → 1

f(5):取 3 → f(2) = 0 ⇒ 必胜 → 1

f(6):取 4 → f(2) = 0 ⇒ 必胜 → 1

f(7):取 1→f(6)=1;取 3→f(4)=1;取 4→f(3)=1,全部让对手必胜 → 0

f(8):取 1 → f(7) = 0 ⇒ 必胜 → 1

序列:0 1 0 1 1 1 1 0 1(k = 0..8)。n = 8 时先手必胜(先取 1 个,把 7 个留给对手)。

易错:把「取走最后一个者胜」的边界写成 f(0) = true。面对 0 个石子的人已经输了,因为他没有石子可取。

A3编程题

用记忆化搜索写斐波那契数列,并对比纯递归的速度差异。

查看参考答案
#include <bits/stdc++.h>
using namespace std;

long long memo[100];              // 0 表示「没算过」(F(n) ≥ 1,安全)

long long fib(int n) {
    if (n <= 2) return 1;
    if (memo[n]) return memo[n];       // ★ 查缓存
    return memo[n] = fib(n - 1) + fib(n - 2);   // ★ 回填并返回
}

int main() {
    for (int n : {10, 20, 40, 50}) printf("F(%d) = %lld\n", n, fib(n));
    return 0;
}

输出:F(10) = 55,F(20) = 6765,F(40) = 102334155,F(50) = 12586269025。

速度对比:纯递归 fib(n) 的调用次数约为 2·F(n) − 1。所以 fib(40) 约需 2 亿次函数调用(数秒),fib(50) 约需 2 × 1010 次(几分钟到十几分钟)。

加记忆化后,每个 n 只真正计算一次,fib(50) 只需 50 次函数调用 —— 提速约 4 亿倍。

易错:memo 数组要开 long long(F(50) 已经超过 int 范围);边界 n <= 2 也要正确处理。

A4判断题

判断下列说法的正误,并说明理由。

查看参考答案

① 剪枝加得越多,程序一定越快。
❌ 错误。剪枝的判断本身也有开销;有些剪枝几乎从不触发,白花时间。要加命中率高、判断便宜的剪枝。

② 改变搜索顺序会改变最终答案。
❌ 错误。顺序只改变遍历的先后,遍历的集合完全一样,答案不变。但它会极大影响速度(因为影响最优性剪枝的效果)。

③ 记忆化搜索要求状态之间的依赖无环。
✅ 正确。如果依赖成环,递归会无限循环。滑雪问题之所以安全,是因为「只能往低处滑」保证了高度严格递减。

④ 用 0 表示「没算过」总是安全的。
❌ 错误。计数类问题的合法答案可能真的是 0。要选一个「合法答案取不到的值」,或者用单独的 vis 数组。

⑤ 记忆化搜索与递推 DP 的答案必然相同。
✅ 正确(前提是它们描述的是同一个 DP 方程)。两者只是同一个式子的两种实现方式。

⑥ 最优性剪枝的界可以随便估。
❌ 错误。求最小值时界必须是下界,估大了会把最优解剪掉。宁可把界放宽松,也不能估错方向。

🔵 B组 · 提高(4题)

B1编程题洛谷 P1434

滑雪:用记忆化求从任意格子出发的最长滑行长度。

查看参考答案

状态:f[x][y] = 从 (x,y) 出发的最长滑行长度(含自身)。

转移:f[x][y] = 1 + max{ f[nx][ny] | 相邻且 h[nx][ny] < h[x][y] },没有更低的邻居时取 1。

int f[105][105];
int dfs(int x, int y) {
    if (f[x][y]) return f[x][y];              // 查缓存
    f[x][y] = 1;
    for (int d = 0; d < 4; d++) {
        int nx = x + dx[d], ny = y + dy[d];
        if (nx < 1 || nx > R || ny < 1 || ny > C) continue;
        if (h[nx][ny] < h[x][y]) f[x][y] = max(f[x][y], dfs(nx, ny) + 1);
    }
    return f[x][y];                            // 回填并返回
}

样例:5×5 蛇形网格 → 25;2×3 网格 1 2 3 / 6 5 4 → 6;3×3 网格 1 2 3 / 4 5 6 / 7 8 9 → 5。

复杂度:O(R·C),R = C = 100 时不到 4 万次转移。

易错:① 忘记 return f[x][y];② 判断写成 h[nx][ny] != h[x][y](题目要求严格更小,等于也不行);③ 答案只算 dfs(1,1),忘了起点任选要取全局最大值。

B2编程题洛谷 P2404

自然数的拆分:输出 n 的所有不下降拆分方案。

查看参考答案

核心技巧:多传一个参数 last 表示「下一个数最小能取多少」,循环从 last 开始,递归时传 i。这样每个拆分只出现一次,且天然按字典序输出。

int n, a[30];
void dfs(int rest, int last, int dep) {
    if (rest == 0) {
        if (dep >= 2) {                       // 至少两段才输出
            for (int i = 1; i <= dep; i++)
                printf("%d%c", a[i], i == dep ? '\n' : '+');
        }
        return;
    }
    for (int i = last; i <= rest; i++) {     // ★ 范围剪枝
        a[dep + 1] = i;
        dfs(rest - i, i, dep + 1);
    }
}

n = 7 的输出(14 行):1+1+1+1+1+1+1 / 1+1+1+1+1+2 / 1+1+1+1+3 / 1+1+1+2+2 / 1+1+1+4 / 1+1+2+3 / 1+1+5 / 1+2+2+2 / 1+2+4 / 1+3+3 / 1+6 / 2+2+3 / 2+5 / 3+4

易错:① 忘记 dep >= 2 的判断,会把「7」本身也输出;② 循环写成 i = 1 而不是 i = last,产生大量重复方案。

B3编程题区间 DP / 记忆化

求字符串的最长回文子序列长度(子序列可以不连续)。

查看参考答案

与 Manacher 的区别:Manacher 求的是最长回文子串(必须连续);本题求的是最长回文子序列(可以不连续),是区间 DP 的经典问题。

状态:f[l][r] = s[l..r] 的最长回文子序列长度。

转移:若 s[l] == s[r],则 f[l][r] = f[l+1][r-1] + 2;否则 f[l][r] = max(f[l+1][r], f[l][r-1])。边界:l > r 时为 0,l == r 时为 1。

char s[1005];
int memo[1005][1005];

int dfs(int l, int r) {
    if (l > r)  return 0;
    if (l == r) return 1;
    if (memo[l][r]) return memo[l][r];
    int res;
    if (s[l] == s[r]) res = dfs(l + 1, r - 1) + 2;
    else              res = max(dfs(l + 1, r), dfs(l, r - 1));
    return memo[l][r] = res;
}

样例:"abacaba" → 7;"abcdcba" → 7;"abcda" → 3(如 "aba");"aabbaa" → 6;"abcde" → 1。

复杂度:状态数 O(n²),每状态 O(1) 转移,总 O(n²)。n = 1000 时 106 个状态,可行。

易错:① 边界写成 l == r 返回 0;② 用 memo[l][r] 存但下标越界(注意 l+1 可能大于 r−1,要先判 l > r)。

B4编程题记忆化 / 计数

网格路径计数(带障碍):从左上角走到右下角,每次只能向右或向下,求路径条数。

查看参考答案

状态:f[x][y] = 从 (x,y) 走到 (R,C) 的路径条数。

转移:若 (x,y) 有障碍则 f[x][y] = 0;若 (x,y) == (R,C) 则 f[x][y] = 1;否则 f[x][y] = f[x+1][y] + f[x][y+1](越界方向算 0)。

long long f[1005][1005];
bool vis[1005][1005];      // ★ 用 vis 单独标记,不用 0 当哨兵(答案可能为 0)
int R, C;
int wall[1005][1005];

long long dfs(int x, int y) {
    if (x < 1 || x > R || y < 1 || y > C) return 0;   // 出界
    if (wall[x][y]) return 0;                          // 障碍
    if (vis[x][y]) return f[x][y];                     // ★ 命中缓存
    vis[x][y] = true;
    if (x == R && y == C) return f[x][y] = 1;
    return f[x][y] = dfs(x + 1, y) + dfs(x, y + 1);
}

样例:3×3 无墙 → 6(即 C(4,2));3×3 中心 (2,2) 有墙 → 2(只剩 RRDD 和 DDRR 两条);4×4 无墙 → 20(C(6,3))。

为什么这里不能用 0 当哨兵:被障碍完全堵死的格子路径数是 0,是合法的答案。如果用「f 为 0 表示没算过」,这些格子会被反复重算。所以额外开一个 vis 数组单独记录访问状态。

易错:① 忘记 long long(路径数可能很大);② 用 0 当哨兵;③ 终点的判断放在缓存判断之前(那样也能对,但顺序混乱时容易漏回填)。

🟣 C组 · 挑战(4题)

C1多重剪枝洛谷 P1120

小木棍:用六重剪枝求出原始木棍的最小可能长度。

查看参考答案

六处剪枝:① len 必须整除总长;② 降序排序;③ 放不下就 continue;④ 等长段不重复试(a[i]==a[i-1] && !vis[i-1]);⑤ 首段失败即整轮无解(now == 0 → return false);⑥ 恰好拼满仍失败也整轮无解(now + a[i] == len → return false)。

bool dfs(int k, int now, int last) {
    if (k == cnt) return true;
    if (now == len) return dfs(k + 1, 0, 0);
    for (int i = last + 1; i <= n; i++) {
        if (vis[i]) continue;
        if (now + a[i] > len) continue;                       // ③
        if (i > 1 && a[i] == a[i-1] && !vis[i-1]) continue;        // ④
        vis[i] = true;
        if (dfs(k, now + a[i], i)) return true;
        vis[i] = false;
        if (now == 0) return false;                              // ⑤
        if (now + a[i] == len) return false;                     // ⑥
    }
    return false;
}

样例:9 / 5 2 1 5 2 1 5 2 1 → 6;6 / 4 4 3 3 2 2 → 6;4 / 1 2 3 4 → 5。

剪枝⑤⑥ 的正确性:所有木棍等长且地位对称,所以「用某个长度做当前这根的第一段失败」等价于「这个 len 无解」——因为该长度的段放到别的木棍里也一样会失败(把方案整体重排即可)。

易错:剪枝④ 写成 a[i] == a[i-1](漏了 !vis[i-1]),会把「同一根木棍里用两段等长的」这种合法方案也砍掉。

C2最优性剪枝 + 数学下界洛谷 P1731

生日蛋糕:用数学模型推出的下界剪枝,求最小表面积。

查看参考答案

表面积公式:俯视投影恰好是底层的整圆 R₁²,加上所有层的侧面积,所以 S = R₁² + Σ 2R_iH_i(不含底面)。

预处理:minV[k] = Σ_{i=1..k} i³(上面 k 层的最小体积,因为第 i 层至少要有 R = i、H = i);minS[k] = 2Σ i²(最小侧面积)。

三层剪枝:① v < minV[dep] 可行性剪枝;② s + minS[dep] >= ans 最优性剪枝;③ s + 2v/r >= ans —— 剩余体积做成半径 r 的圆柱时侧面积最小为 2V/R,这是数学下界。

// dep 层待做;v 剩余体积;s 已用面积;r,h 上一层的半径与高
void dfs(int v, int s, int r, int h, int dep) {
    if (dep == 0) { if (v == 0) ans = min(ans, s); return; }
    if (v < minV[dep]) return;                          // ① 可行性
    if (s + minS[dep] >= ans) return;                   // ② 面积下界
    if (s + 2 * v / r >= ans) return;                  // ③ 2V/R 数学下界
    for (int R = min(r - 1, (int)sqrt(v)); R >= dep; R--) {
        int maxH = min(h - 1, (v - minV[dep-1]) / (R * R));
        for (int H = maxH; H >= dep; H--) {
            int ns = s + 2 * R * H + (dep == M ? R * R : 0);
            dfs(v - R * R * H, ns, R, H, dep - 1);
        }
    }
}

样例:100 / 2 → 68(R₁=4, H₁=6, R₂=2, H₂=1,体积 96+4 = 100,面积 16+48+4 = 68)。

易错:① 忘记 dep == M 时加 R*R;② 把 (v - minV[dep-1]) / (R*R) 写成 v / (R*R)(H 上限放宽,剪枝变弱);③ 下界方向搞反。

C3记忆化 + 状态压缩洛谷 P1433

吃奶酪:从原点出发走遍所有点再返回的最短路径(TSP 雏形)。

查看参考答案

状态:f[i][mask] = 「当前在第 i 个点,已访问集合为 mask,走完剩下所有点还需要的最短距离」。用二进制 mask 表示「哪些点已经走过」。

转移:f[i][mask] = min{ d[i][j] + f[j][mask | (1<<j)] | j ∉ mask }。终态:mask == (1<<n) − 1 时返回 0。

double f[20][1 << 16], d[20][20];
int n;
double dfs(int u, int mask) {
    if (mask == (1 << n) - 1) return 0;         // 全走完
    if (f[u][mask] > -0.5) return f[u][mask];     // 命中缓存
    double res = 1e18;
    for (int v = 0; v < n; v++)
        if (!(mask >> v & 1))
            res = min(res, d[u][v] + dfs(v, mask | (1 << v)));
    return f[u][mask] = res;
}
// 答案 = min over i of ( 原点到 i 的距离 + dfs(i, 1 << i) )

样例:4 个点 (1,1) (1,-1) (-1,1) (-1,-1) → 7.41。
推导:原点→(1,1) 走 √2 ≈ 1.4142,然后沿正方形边走 2+2+2 = 6,合计 7.4142 → 输出 7.41。

为什么这个是记忆化的应用:不同的走法顺序会到达同一个 (i, mask) 状态,纯搜索会重复展开。状态总数 = n · 2n,n ≤ 15 时约 49 万,记忆化后完全可行。

知识衔接:这就是状态压缩 DP(状压 DP) 的雏形,后面的课程会专门讲。这里先用「记忆化 + 位掩码」的形式体会一下。

易错:① 用 0 当哨兵(最短距离可能因为浮点比较出问题),一般用 -1 或 1e18 作初值;② mask 的上界开小了(要 1 << n);③ 输出要 printf("%.2f")。

C4记忆化 + 区间 DP石子合并

石子合并:每次合并相邻两堆,代价为两堆之和,求合并成一堆的最小总代价。

查看参考答案

状态:f[l][r] = 把第 l 到第 r 堆合并成一堆的最小代价。

转移:f[l][r] = min over k ∈ [l, r-1] of ( f[l][k] + f[k+1][r] ) + sum(l, r),其中 sum(l,r) 用前缀和 O(1) 求出。边界 f[l][l] = 0。

int a[305], pre[305], memo[305][305];

int dfs(int l, int r) {
    if (l == r) return 0;
    if (memo[l][r]) return memo[l][r];                 // 查缓存(答案 ≥ 0,0 安全)
    int res = 0x3f3f3f3f, sum = pre[r] - pre[l - 1];
    for (int k = l; k < r; k++)
        res = min(res, dfs(l, k) + dfs(k + 1, r));
    return memo[l][r] = res + sum;                   // 回填并返回
}
// 答案 = dfs(1, n)

样例:4 堆 1 3 5 2 → 22。

推导:f[1][2]=4、f[2][3]=8、f[3][4]=7;f[1][3]=13、f[2][4]=17;
f[1][4] = min(f[1][1]+f[2][4]+11, f[1][2]+f[3][4]+11, f[1][3]+f[4][4]+11) = min(28, 22, 24) = 22。

「0 当哨兵」在这里安全吗:安全。因为 f[l][r] 在 l < r 时一定 ≥ 1(至少有一次合并),只有 l == r 时才是 0,而那个分支在前面已经单独返回了。

易错:① 忘记加 sum(合并本身的代价);② 转移写成 dfs(l,k) + dfs(k,r)(区间重叠,答案错);③ 答案可能很大时用 int 溢出(n=300、a=1000 时最大约 300×1000×300/… 需估算,一般用 long long 更稳)。

⭐ 洛谷原题精选(12题,按难度排序)

1
P1605
迷宫普及-
2
P1443
马的遍历普及-
3
P1219
八皇后普及-
4
P2404
自然数的拆分问题普及-
5
P1036
选数普及-
6
P1019
单词接龙普及+
7
P1434
滑雪普及+
8
P2392
kkksc03考前临时抱佛脚普及+
9
P1379
八数码难题普及+
10
P1120
小木棍提高
11
P1731
[NOI1999] 生日蛋糕提高
12
P1074
[NOIP2009] 靶形数独提高
刷题建议:前 5 题用来熟悉 DFS 的回溯写法;第 6~9 题开始混合剪枝与记忆化;第 10、11 题(小木棍、生日蛋糕)是本讲的「毕业题」,两道都独立写出来,S 组搜索题的常见套路就基本掌握了。第 12 题靶形数独的核心是「搜索顺序优化」——每次优先填候选数最少的格子。