搜索杂谈

写在前面

UPD 2025/10/15:因不可抗力因素加上鸽太久重写了。

主要参考李煜东《算法竞赛进阶指南》,少量 oi-wiki 和 deepseek。

0x20 搜索

搜索其实就是高级的枚举,很多题目都可以用搜索完成。就算不能,搜索也是骗分神器。

  • 树与图的遍历
  • DFS
  • BFS
  • 启发式搜索

树与图的遍历

  • 树与图的深度优先遍历,树的 DFS 序、深度和重心
  • 树与图的广度优先遍历,拓扑排序

树与图的深度优先遍历,树的 DFS 序、深度和重心

DFS:在面对每个点的多条分支时,任选一个分支进行遍历,直至遍历完后返回当前点。

一般为递归实现,也可以用栈实现。

时间复杂度:\(O(n + m)\),其中 \(m\) 为边数。

栈实现 dfs 代码(和队列实现的 bfs 其实很像):

vector<int> mp;
bool vis[MN];

void dfs(int st) {
    stack<int> s;
    s.push(st);
    vis[st] = 1;
    while (!s.empty()) {
        int x = s.top();
        s.pop();
        for (int y : mp[x]) {
            if (vis[y]) continue;
            vis[y] = 1;
            s.push(y);
        }
    }
}

关于爆栈

在 NOI 系列比赛中,如果你的写法是正确的,官方评测机是不会爆栈的。

本地开大栈空间:

  • Windows:编译时加上 -Wl,--stack=size,单位为字节;
  • Linux:终端运行 ulimit -s size,单位为 KiB。

Linux 设置栈空间为无限:ulimit -s unlimited

参考了这个帖子:https://www.luogu.com.cn/discuss/1172098
怎么刚要准备写就有人问,感谢万能的谷民


时间戳:每个点被第一次访问的顺序。

DFS 序:在对树进行 dfs 时节点的遍历顺序。
如果使用打标记——遍历分支——打标记的方法进行 dfs,输出的 dfs 序里面每个节点都会出现两次,且它们中间的部分就是以当前节点为根的子树的 dfs 序。

例如下图,节点对应的时间戳为 1 2 5 6 3 4,dfs 序为:1 2 5 5 6 6 2 3 3 4 4 1

谁会闲的没事自己画图啊

我们平时也在很多算法中用到它们,比如 tarjan 和树剖。

二叉树的先序、中序与后序遍历

蓝书上并没有讲,但还是说一下。

先序(前序)遍历:根 -> 左子 -> 右子;
中序遍历:左 -> 根 -> 右;
后序遍历:左 -> 右 -> 根。

如下图,先序:1 2 4 5 3 6 7,中序 4 2 5 1 6 3 7,后序:4 5 2 6 7 3 1

oi-wiki 搬运工上线

同时给出先序和中序遍历,或者中序和后序可以构造出唯一的二叉树。

P1030 [NOIP 2001 普及组] 求先序排列

树的深度

节点到根的距离,是一种自上而下的统计。

一个节点的深度为其父节点的深度加一。

树的重心

对于一个节点 \(x\),如果我们把它从树中删除,在剩余的子树中最大的子树我们定义其为 max_part(x)。如果一个节点的 max_part(x) 值最小,那么他就是树的重心。

我们自下而上求出 \(x\) 的子树大小,用其中最大的子树和整棵树的大小与 \(x\) 所有子树的差取最大值求重心,代码实现一般为两个 dfs。

代码

带权的树的重心:P1364 医院设置

图的连通块划分

枚举所有的点,找到没被标记的点进行 dfs。

树与图的广度优先遍历,拓扑排序

我们用一个队列实现。最初时里面只有一个节点,bfs 时不断取出队头并将其分支加入队尾,直至队列为空。

性质:

  • 按层遍历;
  • “单调性”,“两段性”。

时间复杂度:\(O(n + m)\),其中 \(m\) 为边数,和 dfs 一样。

拓扑排序

对一张有向无环图,若有一个由图中所有点构成的序列 \(A\) 满足:对于每条边 \((x, y)\)\(x\) 出现在 \(y\) 之前,则 \(A\) 为该图的一个拓扑序。

实现:对每一个点统计其入度。bfs 前将所有入度为 0 的节点加入队列;bfs 时,每到一个点就将其入度减一,若该点入度减为 0 后将其加入队列,重复直至队列为空。

拓扑排序可以检验一张图是否存在环,判断求出的拓扑序长度是否小于节点数即可。

代码

DFS

指数型、组合型、排列型。

可以求解子集和问题、全排列问题、N 皇后问题。

  • 剪枝
  • 迭代加深 & 双向搜索(主要是折半搜索)

剪枝

我们通过一些方法来排除不必要分支以减小搜索树的大小的手段,这就是剪枝。(赛博园丁

蓝书上给出了常用的 5 种剪枝方法:

  1. 优化搜索顺序

    搜索树的各个层次、分支间的顺序并不是固定的,不同的搜索顺序会使得搜索树的形态不同,规模大小也相差甚远。
    例如 0x22 中的例题“小猫爬山”就是将小猫的重量进行了从大到小的排序。

  2. 排除等效冗余

    若搜索树中有些分支是等效的,相当于重复搜索同一个状态,那么我们就可以只对其中的一条进行搜索。

  3. 可行性剪枝

    对当前状态进行检查,如果发现该分支无法到达递归边界就直接返回。

  4. 最优性剪枝

    如果当前统计的代价已经超过了之前搜索出的最小代价,此时再继续往下搜索也没有意义。

  5. 记忆化

    有些题目需要我们对同一个搜索树进行反复搜索,每次都要重新搜索一边早就搜索过的状态是很浪费时间的。我们可以记录当前状态搜索到边界的返回值,下次搜索到了就直接返回。
    但是有些题目的搜索状态空间是树形的,即每个状态只会被搜索到一次,那记忆化就没有意义了。

下面结合题目来具体感受一下上面几种剪枝的运用。

例题 1:P1120 小木棍

  1. 优化搜索顺序

    把木棍长度从小到大排序,优先尝试较长的木棍。

  2. 排除等效冗余

    1. 先拼 \(x\) 再拼 \(y\) 和先拼 \(y\) 再拼 \(x\) 显然是等效的,故可以按照长度递减的顺序进行尝试。
    2. 拼接一根木棒时如果尝试某个长度的木棍分支返回错误那就不再尝试其他长度和它一样的木棒。
    3. 对空木棒尝试第一根木棍时如果最后分支返回失败,那么不在其它空木棒尝试该长度的木棍。
    4. 在对一个木棒拼入一个木棍时,若该木棒恰好被拼接完整,搜索下一根木棒时返回失败,直接判定当前分支失败。

这道题显然还有其它剪枝,大家都写过这道题就不在此赘述,主要是理解这两种剪枝方式。

例题 2:P1731 [NOI1999] 生日蛋糕

从下往上搜,分支为每层蛋糕的深度和半径。容易发现表面积为最下层的上表面加每一层的侧面积。

  1. 上下界剪枝

    我们发现对于每一层都有高度和半径的上界,还有剩余体积可以决定上界,就直接在范围内枚举。

  2. 优化搜索顺序

    在上面的范围内倒叙枚举。

  3. 可行性剪枝

    我们知道当 \(r = h\) 时有圆柱体的最小体积和侧面积,也就是从上到下每层的最小体积和表面积都可以预处理出来。当搜索到某一层时发现当前使用的体积加上剩余层数的体积最小值大于题目给的最大体积时,进行剪枝。

  4. 最优性剪枝

    1. 当前搜到的表面积大于之前搜出的答案,剪枝。
    2. 推式子,式子的大致意思就是当前位往上的最小表面积加当前表面积大于搜出的答案时,剪枝。(蓝书上写太多了呜呜呜,写数学公式又麻烦就放过我吧 QAQ)

搜索面对的状态就是一个多元组,每一元都是问题状态空间的维度,这些是我们从题目中得到的。而剪枝就是给这些维度加上边界以减少搜索树分支的扩张。
除去对当前已有状态的分析外,我们也可以对未来的状态进行估算,这样提前除去一些分支,这就涉及到一会要讲的 A* 和 IDA* 了。

迭代加深

有的时候答案在搜索树上较浅的位置上,但由于 dfs 的特性,可能会搜出一些深度很大但是对答案没有贡献的子树。

对于一些题目,我们能够确保答案在较浅的节点时,我们就可以限制搜索层数进行搜索。如果搜索失败我们就将限制放宽继续搜索,直至找到答案。这就是迭代加深思想。

想要使用的话要先估算最大深度,不过有些题目限制了这一点(类似于“10 步之内找不到答案判定为无解”之类的)。

UVA529 Addition Chains 估算深度(蓝书例题)
题目给出限制直接搜的题一般要加各种剪枝吧,比如P2324 [SCOI2005] 骑士精神,我的评价是不如直接写 IDA*。

双向搜索

对于一些题目里明确给出了起始状态和最终状态,且从初态或终态开始搜索产生的搜索树都能覆盖到所有状态。这时我们就可以同时从初态和终态开始搜索,产生两棵深度减半的搜索树,在搜索树中间交会或组合成最终答案。

如下图,第一张是朴素 dfs 搜索树,第二张是双向搜索的搜索树, 明显双向搜索少搜索了许多分支。

图一

图二


折半搜索

双向搜索主要还是写 bfs 的多,比如一张图给出起点终点。折半搜索就是 dfs 枚举,其本质是双向搜索。

一般都会给出一个集合,让你选出其中的几个计算价值等。假设每个物品有选与不选两种状态,直接 dfs 的复杂度是 \(O(2^n)\) 的,可能会超时。我们将集合分为两半进行搜索,再用一些方法将结果合并,这样时间复杂度降为 \(O(2^{n / 2})\)

例题:P4799 [CEOI 2015] 世界冰球锦标赛 (Day2)

BFS

一般在地图上搜索的题多吧。。。

  • 广搜变形
    • 双端队列 bfs
    • 优先队列 bfs
    • 双向 bfs(双向搜索)

广搜变形

双端队列 BFS

在最基本的 bfs 中,每一次拓展都是一步,逐层搜索出起始状态到最终状态的最小步数,这相当于在一张边权均为 1 的图上搜索。之前我们提到过 bfs 具有“单调性”和“两段性”的性质,由此得出,每个点第一次入队时的步数即为所求。

如果边权不全为 1,每次拓展的代价就不同了。例如蓝书上的例子就是当边权为 0 或 1 的情况。

例题:P4667 [BalticOI 2011] Switch the Lamp On (Day1)

我们将每个格子的对角线连边,若对角线上的两个点之间本来是通路,通过它的代价就是 0,我们让这条边的边权为 0,若为断路则边权为 1,再用 bfs 跑最短路。

但是这样就不满足 bfs 的“单调性”和“两段性”了。于是我们想到双端队列。
若当前的边权为 0 就放入队头,反之放入队尾。这样就能在任意时刻满足“单调性”和“两段性”,一个节点可能会被入队多次,但是它第一次出队时一定是我们想要的最终答案,之后再取出时忽略即可。
时间复杂度 \(O(R * C)\)

优先队列 BFS

我们上面讨论的边权只有两种情况但实际不会有那么好心的出题人,我们还需要掌握更加普世的情况,即解决在一张带权图上的最短路问题。有两种解决方案。

方案一

使用最朴素的 bfs,即使用普通队列。这样就不能保证每次出队都是最小代价,所以只好允许一个节点的答案被多次更新、多次进出队列,直至搜索队列为空。其中状态的多次更新也就是迭代思想。

我们将它对应在最短路问题上,恭喜你发明了 SPFA 算法!

经常被卡的人都知道,SPFA 已经()了。它的最优的时间复杂度是正常 bfs 的 \(O(n)\),但是由于我们允许反复入队出队并对每个取出的状态都拓展,最坏时间复杂度会达到 \(O(n^2)\)

方案二

选择保证每次出队的代价都是最小代价。我们先考虑之前边权只有两种情况的时候是怎么搜索的,其实就是把代价更大的放在队列后面。如果我们能把代价按大小放入队列,就能满足出队的代价就是最小代价。

于是改用二叉堆。我们每次取出队头即取出当前代价最小进行拓展,再把新状态丢进堆里,重复执行直至队列为空。

虽然这样同一个节点也会多次入队多次更新,甚至同一个状态的不同代价同时出现在堆里,但是他能保证一个状态被第一次取出时一定是当前状态到起点的最小代价,当这个状态再次被取出时直接忽略不拓展。

所以,堆里的每个状态最多被拓展一次,算上维护堆,时间复杂度为 \(O(n \log n)\)

你知道我要说什么:对应在最短路问题中,恭喜你发明了堆优化的 Dijkstra 算法!

总结

  1. 在一张边权为 1 的图上求最短路:

    普通的 bfs:入队一次出队一次,每个状态入队时就是当前状态最小代价,每个状态拓展一次,时间复杂度 \(O(n)\)

  2. 边权为 0 或 1:

    双端队列 dfs:入队多次,拓展一次,第一次出队的代价为当前状态最小代价,时间复杂度 \(O(n)\)

  3. 一般的最短路:

    迭代思想+普通 bfs:入队(更新)多次出队(拓展)多次,数组储存最小代价,最坏时间复杂度 \(O(n^2)\)

    优先队列 bfs:入队(更新)多次出队(拓展)一次,第一次出队的代价为当前状态最小代价,时间复杂度 \(O(n \log n)\)

双向 bfs(双向搜索)

前面我们介绍过双向搜索的思想,不再赘述。

双向 bfs 写起来更顺,因为 bfs 本身就是逐层进行拓展的。我们从起点和终点同时搜索,每次拓展一层,当搜索状态重复时说明相遇了,合并计算答案即可。

P1379 八数码难题

启发式搜索

带有估价函数的搜索。

  • A*
  • IDA*

A*

注意到我们在使用优先队列 bfs 时采用的是类似于贪心的做法,只能保证局部代价最小,但是不能保证全局最小。

假设我们设计一个函数,能大致的计算该状态未来产生的估计最小代价,当这个值过大时及时的跳过那些看似更优实则更劣的状态,从而更早的找到全局最小代价。

于是可以想到设计一个“估价函数”,以任意状态为输入,计算出该状态到目标状态的代价估计值。我们将优先队列 bfs 改造一下,将判断条件改为“当前代价+未来估价最小的进行拓展。

为了保证每个状态第一次被取出的状态就是最优解,估计函数要满足一个基本准则:

设当前状态 \(state\) 到目标状态的所需的代价的估价值为 \(f(state)\)

设在未来的搜索中,实际求出的从当前状态 \(state\) 到目标状态的最小代价为 \(g(state)\)

对于任意的 \(state\),应该有 \(f(state) \le g(state)\)

省流一下就是说,估价函数的估值不能大于实际代价,估价比实际更优。

如果我们不遵循准则的话,可能会出现一个最优解由于其估价过大导致被压在堆里,导致错误的路径被反复拓展直至产生错误的答案。

那么遵循上面的规则为什么是对的呢?
即使我们估价不太准确导致非最优解 \(x\) 先被拓展,但是随着代价的不断增加,由于 \(x\) 并非最优,就一定会出现某时刻的 \(x\) 的代价小于从起点到最终状态的最小代价;
而有最优解 \(y\),其当前代价加上 \(f(y)\) 必定小于等于 \(y + g(y)\),后者为从起点到终点的最小代价。
结合 \(x\) 就会发现 \(y + g(y)\) 小于 \(x\) 的当前代价,所以此时 \(y\) 就会从堆里取出拓展从而找到最优解。

我们将上面的信息使用优先队列进行维护,就是带有估价函数的优先队列 bfs,即 A*算法。只要满足 \(f(state) \le g(state)\) 就能保证一个状态第一次被取出时就一定是起点到当前状态的最小代价,且每个状态只会被拓展一次。

估价越准确、越接近实际代价,A* 算法的效率就越高。如果估价为 0 的话就是普通的 bfs 了。

P2901 [USACO08MAR] Cow Jogging G(第 k 短路)

P1379 八数码难题

IDA*

省流:dfs+估价函数+迭代加深(ID)=IDA*

主播主播,虽然你的 A* 算法效率很高,但是写 bfs 还是太需要手法了,并且堆太吃时间了,有没有什么效率又高又不需要手法的算法呢?

A* 本质上还是优先队列 bfs,那么在计算复杂度时就绕不开 \(\log n\)。不过其关键还是在于估价函数,很容易想到是否能给 dfs 也套上估价。

但是与 bfs 不同,dfs 是深度优先的算法,如果我们的估价函数并不准确,就会在错误的分支上向下搜索出一个深度很大的子树,浪费大量时间。

有没有感觉这句话在哪听过?我们之前讲过的迭代加深就是解决搜索深度过大的问题的思想,容易想到将其与估价函数结合,这样就不怕在错误的分支浪费时间,这就是更适合 dfs 宝宝体质的 IDA*。

具体地,我们设计一个估值函数,估算从当前状态到达最终状态所需的步数,将迭代加深的限制设为若当前深度+未来估计步数>深度限制,则从当前分支回溯

复杂度我不知道怎么算了,但是实际测试 IDA* 的效率很高,而且更好写。

P2324 [SCOI2005] 骑士精神


完结撒花~

posted @ 2025-06-09 21:23  StarLinkOvO  阅读(36)  评论(0)    收藏  举报