搜索

搜索算法是一种“优雅”的暴力算法,它的核心思想是枚举,按照一定的顺序,不重不漏地枚举每一种可能的答案,最终找到一个问题需要的解。搜索算法是一种比较通用的算法,几乎可以实现各类问题(但是不保证高效)。

前置知识:递归、栈、队列

主要有两种搜索方法:

  1. 深度优先搜索(DFS)
  2. 宽度优先搜索(BFS)

两者主要是搜索顺序不同

深度优先搜索(DFS)

深度优先搜索是搜索算法的一种实现方式,它的思想是先尽量尝试比较“深”的答案。

image

在迷宫游戏中有一个非常简洁的“右手策略”:从入口处开始,用右手摸着右边的墙。一直走,一定能走出去。为什么这个策略可行呢?

迷宫里有很多岔路口,对于每个路口,把它们看成一个点,岔路口和岔路口之间,有路相连,如果把点与点之间的相邻关系抽象出来,可以得到这样的结构:

image

从起点出发向下走,假设有两条路可以选,分别指向岔路 1 和岔路 2,假如根据右手策略,先走到了岔路 1,此时继续根据右手策略走,结果走到了死胡同。但是,因为右手一直贴着墙,最终能从这个死胡同绕出来,回到岔路 1,此时右手摸着墙绘尝试继续走下一个方向,于是走到了终点。

通过这个例子可以发现:(1)遇到死胡同会自动走回去,回到上一个路口;(2)对于每个路口,优先尝试某个方向,继续往深处走。如果走回来了,继续尝试第二个方向、第三个方向……直到枚举完所有选择,如果还是没有找到出口,又回回到上一个路口,尝试上一个路口的下一个方向……。这样看来,右手策略确实是有效的,它的思想就是深度优先搜索。

深度优先搜索是先把一个需要解决的问题看成一个多步决策问题。要解决一个大问题,先把这个问题分成几步,而每一步都有若干种不同的决策方式,把所有决策方式都枚举一遍,并且按照某个顺序依次枚举。

image

对于步骤 1,有 3 种不同的决策方式,先选择决策 1,走到步骤 2 的 2 号位置。这时候又有 3 种不同的决策方式,还是先尝试第 1 种,走到 5 号位置,发现不是答案,也没有下一个步骤了,于是退回 2 号位置,尝试第 2 种决策,走到 6 号位置,发现还不是答案,于是退回 2 号位置,继续尝试走到 7 号位置,发现也不是答案,退回 2 号位置。此时 2 号位置所有可能都尝试了一遍,继续后退回 1 号位置,尝试 1 号位置的下一种可能性,走到 3 号位置,继续尝试第 1 种决策,走到 8 号位置,再退回 3 号位置,再走到 9 号位置,这时发现找到了答案。如果只需要找到一个答案,那么现在就可以让程序结束了,如果需要找到所有的答案,再退回 3 号位置继续运行程序。

深度优先搜索的名字体现在,当走到某个位置时,总是先选择一种决策方式,然后继续往深处走,尝试下一步的决策,直到走到最深处走不下去或者没有下一种决策方式时再退回。

深度优先搜索通常利用递归实现,其本质是栈。


例题:P1605 迷宫

  1. 存储迷宫
    二维数组 b[x][y] 存储迷宫信息,兼职判重
    dx[] dy[] 数组存方向偏移量
  2. 深搜与回溯
    从起点开始,往四个方向尝试,能走就打上标记,锁定现场,然后走过去;到达目的地则更新答案;走投无路则返回,返回后要去除标记,恢复现场;继续尝试,直到尝遍所有可能,最终从入口退出。
  3. 深搜实际上对应一棵DFS树
    image
#include <cstdio>
int n, m, t, ans;
int sx, sy, fx, fy;
int b[10][10];
int dx[4] = {-1, 0, 1, 0};
int dy[4] = {0, 1, 0, -1};
void dfs(int x, int y) {
    if (x == fx && y == fy) { // 递归的边界条件
        ++ans;
        return;
    }
    // 还没到终点
    for (int i = 0; i < 4; ++i) {
        int xx = x + dx[i];
        int yy = y + dy[i];
        if (xx >= 1 && xx <= n && yy >= 1 && yy <= m && b[xx][yy] == 0) {
            b[xx][yy] = 1; // (xx,yy)被走过
            dfs(xx, yy); // (x,y)->(xx,yy)
            b[xx][yy] = 0; // 复原
        }
    }
}
int main()
{
    scanf("%d%d%d%d%d%d%d", &n, &m, &t, &sx, &sy, &fx, &fy);
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j) 
            b[i][j] = 0; // 能走
    for (int i = 0; i < t; ++i) {
        int x, y;
        scanf("%d%d", &x, &y);
        b[x][y] = 2; // 障碍物
    }
    b[sx][sy] = 1; // 暂时不能走(被当前路径走过)
    dfs(sx, sy);
    printf("%d\n", ans);
    return 0;
}

例题:P1644 跳马问题

  1. 用 dx[] dy[] 数组存方向偏移量
  2. DFS搜索方案
    从起点开始,向右边的四个点尝试,能走就走过去,一直到走投无路返回;到达目的地则更新答案;一直尝试,直到穷尽所有可能
    由于本题限制了跳转方向,每个点不会往回走,所以不需要判重
  3. 深搜会生成DFS树
    image
#include <cstdio>
const int MAXN = 20;
int n, m, ans;
int dx[4] = {2, 1, -1, -2};
int dy[4] = {1, 2, 2, 1};
void dfs(int x, int y) {
    if (x == n && y == m) {
        ans++;
        return;
    }
    for (int i = 0; i < 4; i++) {
        int xx = x + dx[i], yy = y + dy[i];
        if (xx >= 0 && xx <= n && yy <= m) dfs(xx, yy);
    }
}
int main()
{
    scanf("%d%d", &n, &m);
    dfs(0, 0);
    printf("%d\n", ans);
    return 0;
}

例题:P2404 自然数的拆分问题

#include <iostream>
#include <vector>
#include <cstdio> 

using namespace std;

// 这个递归函数是整个算法的核心,其任务是
// 对于一个给定的数字 r,尝试将其拆分成以数字 start 或更大数字开头的、非递减的序列
/**
 * @brief 深度优先搜索函数,用于寻找所有拆分方案
 * @param r      当前剩余待拆分的数值 (remaining)
 * @param start  当前步骤可选择的数的最小值,用于保证序列非递减和避免重复
 * @param nums   存储当前拆分方案,使用引用(&)传递可以避免每次递归都复制整个 vector,提高效率
 */
void dfs(int r, int start, vector<int>& nums) {
    // --- 递归终止条件 ---
    // 如果剩余值为0,说明已经找到了一个和为n的完整拆分方案
    if (r == 0) {
        // 题目要求拆分成“若干个”数,即数量必须大于1
        if (nums.size() > 1) {
            // 打印当前存储的方案
            cout << nums[0];
            for (size_t i = 1; i < nums.size(); i++) {
                cout << "+" << nums[i];
            }
            cout << "\n";
        }
        return; // 结束当前分支的搜索
    }

    // --- 核心循环:尝试拆分出下一个数 i ---
    // i 从下限 start 开始,确保序列非递减 (例如 1+1+2 可以,1+2+1不行)
    // i 最大不能超过 r,否则和就大于n了
    // 这个从小到大的遍历顺序也保证了最终输出结果是按字典序的
    for (int i = start; i <= r; i++) {
        // 1. 执行动作:将选择的数 i 添加到当前方案中
        nums.push_back(i);

        // 2. 递归深入:
        //    - 待拆分的值变为 r - i
        //    - 下一个数的最小值是 i,保证非递减
        //    - 传递更新后的方案
        dfs(r - i, i, nums);

        // 3. 撤销动作(回溯):当上一行递归返回后,将 i 移除,
        //    以便 for 循环下一次迭代能尝试其他的可能性。
        nums.pop_back();
    }
}

int main() {
    int n;
    cin >> n;

    vector<int> nums; // 用于存储一个拆分方案的动态数组

    // 开始DFS:
    // 初始要拆分n,第一个数最小可以是1,方案为空
    dfs(n, 1, nums);

    return 0;
}

例题:P1706 全排列问题

输入一个数字 \(n\),输出 \(1\)\(n\) 的全排列。

分析:有 \(n\) 个数字需要全排列,可以看成一个多步决策问题:有 \(n\) 个位置需要放数字,每个位置放一个,对每个位置放什么数字进行决策。

写一个递归函数,用一个数组记录每一层的决策结果,也就是排了哪些数,用 \(k\) 表示目前走到第几层,即现在正在枚举第 \(k\) 个位置要放的数字。当走到第 \(n+1\) 层时就可以数出结果了。

由于每个数字只能使用一次,在前面的决策中用过的数字,后面就不能再用了。一个简单的解决方法是在全局变量区域设置一个 used 标记数组,如果某个数字 i 在某一层用了,就把 used[i] 赋值为 true。这样在每一层枚举决策时,都先检查一下 used 数组,如果发现这个数字没用过,才能考虑这一层可以选择放这个数,并且如果决定使用就把它标记一下。

当递归回到上一层的时候,应该清除掉当时做的对应标记,因为回来的时候就意味着接下来该层要换下一种决策方式了,那么之前做的标记就失去了意义。每次递归进入更深一层前,进行标记;从深处返回后,清除相应标记。这被称为回溯。

image

#include <cstdio>
const int N = 10;
int a[N], n; // a数组记录每一个位置放的数
bool used[N]; // 记录每个数字是否用过的标记数组
void dfs(int k) { // k表示当前正在放第几个数
    if (k == n + 1) { // 如果走到了第n+1层,说明已经完成了一种全排列
        for (int i = 1; i <= n; i++) {
            // 输出时%5d表示保留5个场宽(右对齐占据5个宽度)
            // 如果长度不够5位,会在前面自动补空格
            printf("%5d", a[i]);
        }
        printf("\n");
        return;
    }
    for (int i = 1; i <= n; i++) { // 尝试在第k个位置放i
        if (!used[i]) { // 如果i没用过
            a[k] = i; used[i] = true; // 在当前位置放i,并将其标记已使用
            dfs(k + 1); // 去下一层
            used[i] = false; // 回溯,清除标记
        }
    }
}
int main()
{
    scanf("%d", &n);
    dfs(1);
    return 0;
}

在 C++ 标准库中有一个函数 std::next_permutation,它可以用于解决需要遍历所有排列组合的问题,比如全排列。

std::next_permutation 是 C++ 标准库 <algorithm> 中的一个函数,它的作用是将一个序列就地转换成它的下一个字典序排列

函数原型

  • 参数:要操作的序列范围 [first, last),对于 vector,通常是 vec.begin()vec.end()
  • 功能:它会原地修改这个序列,将其变为下一个字典序更大的排列。
  • 返回值
    • 如果成功生成了下一个排列,它返回 true
    • 如果当前序列已经是其所有可能排列中的最后一个(即字典序最大,通常是降序序列),函数会将其变回字典序最小的排列(即升序排列),并返回 false

最重要的使用前提:必须先排序!

如果想用 next_permutation 生成一个序列的所有排列,必须在开始循环之前,将序列按升序排序

原因next_permutation 只会寻找“下一个”更大的排列,如果从一个中间状态开始,比如 {2, 1, 3},就会错过所有在它之前的排列,如 {1, 2, 3}{1, 3, 2},只有从最小的排列开始,才能保证遍历所有情况。

标准使用模式:do-while 循环

生成所有排列最经典、最标准的模式是使用 do-while 循环。

// 1. 先对容器进行升序排序
sort(vec.begin(), vec.end());

// 2. 使用 do-while 循环
do {
	// 在这里处理当前的排列
	// ...
	
} while (next_permutation(vec.begin(), vec.end()));

为什么用 do-while 而不是 while?因为 do-while 循环会先执行一次循环体,然后再判断条件。这确保了能处理初始的、排好序的那个排列,如果用 while,它会先调用 next_permutation,跳过第一个排列。

处理重复元素

next_permutation 的一个巨大优势是它能正确地处理包含重复元素的序列,它只会生成唯一的排列。

例如,对于 {1, 1, 2},它只会生成 3 个唯一的排列:{1, 1, 2}{1, 2, 1}{2, 1, 1},而不会重复生成 {1, 1, 2}

本题如果用 next_permutation 来编写代码会非常简洁。

参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
int main()
{
    int n; scanf("%d", &n);
    vector<int> v(n);
    for (int i = 0; i < n; i++) v[i] = i + 1;
    do {
        for (int x : v) {
            printf("%5d", x);
        }
        printf("\n");
    } while (next_permutation(v.begin(), v.end())); 
    return 0;
}

习题:P1378 油滴扩展

解题思路

题目要求找到一个放置顺序,使得最终油滴总面积最大,由于点的数量 \(N\) 非常小(\(N \le 6\)),因此考虑直接尝试所有可能的放置顺序,\(N\) 个点的总排列是 \(N!\)\(6!=720\)),这个计算量不大。

遍历放置点的顺序的全排列,每次计算该排列顺序下的油滴总面积。对于一个放置顺序,依次处理每一个点,计算其能扩展的最大半径。

这个半径受限于两个因素:到边界的距离(油滴中心到矩形四条边的最短距离)和到已放置油滴的距离(对于所有在它之前已经被放置的油滴,新油滴的边界不能与它们重叠,因此,新油滴的半径不能超过两个圆心的距离减去之前那个油滴放置时的半径)。取这些所有限制条件下的最小值,就是每一个点在当前顺序下的最终半径。将每个油滴的面积累加到当前排列的总面积中。

每计算完一个排列的总面积,就将其与全局的最大面积进行比较,并随时更新保留最大值。

遍历完成后,用矩形的总面积减去最大油滴面积,就得到了题目要求的“剩余最小空间”。

参考代码
#include <cstdio>
#include <cmath>
#include <algorithm> // 包含 std::sort 和 std::next_permutation
#include <vector>
 
using namespace std;
 
// PI 的精确值
const double PI = acos(-1);
 
// 点的结构体
struct Point {
    int x, y;
};
 
/**
 * @brief 计算两点之间的欧几里得距离
 */
double dist(double x1, double y1, double x2, double y2) {
    double dx = x1 - x2, dy = y1 - y2;
    return sqrt(dx * dx + dy * dy);
}
 
int main() {
    int n;
    double x, y, xx, yy; // 使用 double 避免后续计算中的类型转换
    scanf("%d", &n);
    scanf("%lf%lf%lf%lf", &x, &y, &xx, &yy);
 
    // 存储所有油滴中心点
    vector<Point> v(n);
    for (int i = 0; i < n; i++) {
        scanf("%d%d", &v[i].x, &v[i].y);
    }
 
    vector<int> idx(n);
    for (int i = 0; i < n; i++) idx[i] = i;
 
    double max_area = 0; // 用于记录所有排列中能产生的最大总面积
 
    // 使用 do-while 和 next_permutation 遍历所有点的排列
    do {
    	
    	// 现在的 idx 是全排列顺序中的一种顺序 
    	
        vector<double> r(n); // 存储当前排列下,每个油滴的最终半径
        double current_area = 0; // 当前排列的总面积
 
        // 按当前排列的顺序 (v[idx[0]], v[idx[1]], ...) 依次放置油滴
        for (int idx_i = 0; idx_i < n; idx_i++) {
        	int i = idx[idx_i];
            // 1. 计算油滴到四条边界的最小距离,作为初始半径
            double min_r = min({abs(v[i].x - x), abs(v[i].x - xx), abs(v[i].y - y), abs(v[i].y - yy)});
 
            // 2. 检查与【之前已放置】的油滴的距离,更新半径限制
            for (int idx_j = 0; idx_j < idx_i; idx_j++) {
            	int j = idx[idx_j];
                double d = dist(v[i].x, v[i].y, v[j].x, v[j].y);
                // 如果新油滴的圆心已经落在了旧油滴的内部,则其半径为0
                if (d < r[j]) {
                    min_r = 0;
                    break; // 无需再与其他油滴比较
                }
                // 半径不能超过 (两圆心距离 - 已有油滴半径)
                min_r = min(min_r, d - r[j]);
            }
 
            r[i] = min_r; // 确定当前油滴的最终半径
            current_area += PI * r[i] * r[i]; // 累加面积
        }
 
        // 更新全局最大面积
        max_area = max(max_area, current_area);
 
    } while (next_permutation(idx.begin(), idx.end()));
 
    // 计算矩形总面积
    double rect_area = abs(x - xx) * abs(y - yy);
    // 输出剩余的最小面积,并四舍五入为整数
    printf("%.0f\n", rect_area - max_area);
 
    return 0;
}

例题:P1219 [USACO1.5] 八皇后

  1. 存储数据
    ans数组记录各行放的位置
    c数组标记某一列是否放置了棋子
    d1数组标记副对角线,用到的下标范围是 行+列 2,3,4,...,2n
    d2数组标记主对角线,用到的下标范围是 行-列+n(因为下标不能是负数,统一加n做偏移) 1,2,3,...,2n-1
  2. DFS搜索方案
    2.1 从第1行开始放,然后尝试放第2~n行
    2.2 对于某一行,依次枚举每一列,如果某一列能放下,则记住放置位置,宣布占领该位置的辐射区域,然后继续搜索下一行
    2.3 如果某一行的每一列都不能放下,则退回前一行,恢复现场,尝试前一行的下一列
    2.4 如果能放满n行,说明找到了一种合法方案,则方案数+1,打印方案,接着返回上一行,继续搜索其他的合法方案,直到搜完所有的可能方案
    2.5 因为是逐行逐列搜的,先搜到的方案字典序一定更小
    image
#include <cstdio>
const int MAXN = 30;
int n, cnt;
int ans[MAXN], c[MAXN], d1[MAXN], d2[MAXN];
void dfs(int i) {
    if (i > n) {
        cnt++;
        if (cnt <= 3) {
            for (int j = 1; j <= n; j++) printf("%d ", ans[j]);
            printf("\n");
        }
        return;
    }
    for (int j = 1; j <= n; j++) {
        if (!c[j] && !d1[i + j] && !d2[i - j + n]) {
            ans[i] = j; // 记录第i行的皇后放在了第j列
            c[j] = d1[i + j] = d2[i - j + n] = 1; // 标记
            dfs(i + 1);
            c[j] = d1[i + j] = d2[i - j + n] = 0; // 复原
        }
    }
}
int main()
{
    scanf("%d", &n);
    dfs(1);
    printf("%d\n", cnt);
    return 0;
}

习题:P1236 算24点

题意:输入四个整数,在每个数都只能用一次的情况下,利用加减乘除四则运算,使得最后的结果为 \(24\),给出一组解或输出无解

解题思路

定义状态:算法的每一步都面临一个状态,即“当前还剩下哪些数字可以用来计算”。初始状态就是输入的4个数字。

搜索过程:从当前可用的数字集合中,任意挑选出两个不同的数字。对这两个数字,依次尝试加、减、乘、除四种运算。其中,加法和乘法可以直接计算,而减法为了避免负数和重复(例如 \(5-3\)\(3-5\)),总是用较大的数减去较小的数,除法也是一样,而且还必须保证能整除(即余数为 \(0\)),因为题目中要求所有中间结果都为整数。将一次运算产生的结果,作为一个新数字放回到集合中,此时,原来的两个数被消耗,新增了一个数,所以集合中的数字总数会减少一个。基于这个新的数字集合,递归地进行下一步搜索。

总共需要进行 3 次运算,当完成 3 次运算后,数字集合中只会剩下一个数,此时检查这个数是否等于 24。如果结果是 24,说明找到了一条成功的路径,立即记录并输出计算过程,然后设置一个标志,快速结束整个搜索过程。如果一条路径走到底(3 次运算后)结果不是 24,或者在中间步骤无法继续,就需要回溯,回溯意味着撤销上一步的选择(比如,将用过的两个数状态恢复为“未使用”,将新产生的结果从集合中移除),然后尝试其他的数字组合或运算。

参考代码
#include <cstdio>
#include <algorithm>

using namespace std;

// 全局变量定义
int num[10];       // 存储数字的数组。初始存4个输入数,之后也存放中间计算结果。
int res_idx;       // num数组中可用数字的下一个索引位置,也代表当前可用数字的数量。
bool used[10];     // 标记num数组中的数字是否在当前递归层被使用,防止重复选取。
bool flag;         // 标记是否已找到解,找到后变为true以提前终止搜索。
char buf[4][100];  // 存储三步计算过程的字符串表示,如 "a+b=c"。

/**
 * @brief 深度优先搜索函数
 * @param idx 当前是第几步计算 (有效值为 1, 2, 3)
 * @param pre 上一步计算得到的结果。当idx=4时,pre代表最终结果。
 */
void dfs(int idx, int pre) {
    // 递归终止条件:完成了3步运算
    if (idx == 4) {
        // 检查第3步运算的结果是否为24
        if (pre == 24) {
            // 找到解,打印存储在buf中的三步运算过程
            for (int i = 1; i <= 3; i++) {
                printf("%s\n", buf[i]);
            }
            flag = true; // 设置找到解的标记
        }
        return; // 结束当前递归分支
    }

    // 核心循环:遍历所有可能的数字对 (num[i], num[j]) 进行运算
    for (int i = 1; i < res_idx; i++) {
        for (int j = 1; j < res_idx; j++) {
            // 必须是两个不同的、且在当前层未被使用的数字
            if (i == j || used[i] || used[j]) {
                continue;
            }

            // --- 状态变更:准备进行运算 ---
            used[i] = used[j] = true; // 标记这两个数字为“已使用”
            int big = max(num[i], num[j]);
            int small = min(num[i], num[j]); // 按题目要求,运算时大数在前

            // --- 尝试加法 ---
            num[res_idx] = big + small; // 将新结果存入num数组末尾
            sprintf(buf[idx], "%d+%d=%d", big, small, big + small); // 记录运算过程
            res_idx++; // 可用数字的总数增加1
            dfs(idx + 1, big + small); // 递归到下一步
            if (flag) return; // 如果已找到解,立即终止后续所有搜索
            res_idx--; // 回溯:恢复res_idx,撤销添加的新数字

            // --- 尝试减法 ---
            // 避免结果为0或负数,同时因为已排序,避免了重复计算(如5-3和3-5)
            if (big > small) {
                num[res_idx] = big - small;
                sprintf(buf[idx], "%d-%d=%d", big, small, big - small);
                res_idx++;
                dfs(idx + 1, big - small);
                if (flag) return;
                res_idx--;
            }

            // --- 尝试乘法 ---
            num[res_idx] = big * small;
            sprintf(buf[idx], "%d*%d=%d", big, small, big * small);
            res_idx++;
            dfs(idx + 1, big * small);
            if (flag) return;
            res_idx--;

            // --- 尝试除法 ---
            // 题目要求中间结果必须是整数,所以必须能整除
            if (small != 0 && big % small == 0) {
                num[res_idx] = big / small;
                sprintf(buf[idx], "%d/%d=%d", big, small, big / small);
                res_idx++;
                dfs(idx + 1, big / small);
                if (flag) return;
                res_idx--;
            }

            // --- 回溯 ---
            // 撤销标记,以便其他分支可以重新使用这两个数字
            used[i] = used[j] = false;
        }
    }
}

int main() {
    // 读入4个初始数字
    for (int i = 1; i <= 4; i++) {
        scanf("%d", &num[i]);
    }
    res_idx = 5; // 初始有4个数字(索引1-4),下一个可用索引是5

    dfs(1, 0); // 从第1步开始搜索,初始pre值无意义,设为0即可

    // 如果搜索完所有可能性后,flag仍然是false,说明无解
    if (!flag) {
        printf("No answer!\n");
    }

    return 0;
}


宽度优先搜索(BFS)

  1. 宽搜的过程
    从起点开始,向下逐层扩展,逐层访问
  2. 宽搜的实现
    宽搜是通过队列实现的,用 queue 创建一个队列,在搜索过程中通过队列来维护序列的状态空间,入队就排队等待,出队就扩展后续状态入队

例:P1588 [USACO07OPEN] Catch That Cow S

题意:给两个数 \(x, y\),每次可以把 \(x\) 变成 \(x-1, x+1, 2*x\),问经过多少次变化可以让 \(x\)\(y\) 相等
数据范围:\(x,y \le 10^5\)

5-1=4; 4*2=8; 8*2=16; 16+1=17

  1. 暴力搜索当前位置的三种移动方式
  2. 要求最小步数,应该用BFS
  3. 从起始位置x开始搜索,ans数组存储移动步数,当x==y时,ans[y]即答案

边界约束 \((1 \le x \le 10^5)\) 与去重
x*2,x-1,x-1 不如 x-1,x*2 更优,所以上界:\(x \le 10^5\)
减到 0 或负以后再加也不会更优,所以下界:\(x \ge 1\)
image

参考代码
#include <cstdio>
#include <queue>
using std::queue;
const int N = 1e5+5;
int ans[N]; // 表示从x到对应的数最少需要做几次变换
bool vis[N]; // 表示某个数有没有被搜到过
int main()
{
	int T; scanf("%d",&T);
	for (int i=1;i<=T;i++) {
		int x, y; scanf("%d%d",&x,&y);
		queue<int> q; q.push(x); 
		// 注意vis和ans的初始化
		for (int j=0;j<N;j++) {
			ans[j]=0; vis[j]=false;
		}
		// 以上是初始化(这对于多组数据问题非常关键)
		vis[x]=true; ans[x]=0;
		while (!q.empty()) {
			// 取出队列的头部元素
			int t = q.front();
			q.pop();
			if (t==y) {
				// 已经搜到y了,提前结束
				break;
			}
			// 引出新的转移 t-1,t+1,t*2
			int t1 = t-1;
			if (t1>=0 && t1<N && !vis[t1]) {
				q.push(t1); vis[t1]=true;
				ans[t1]=ans[t]+1;
			}
			int t2 = t+1;
			if (t2>=0 && t2<N && !vis[t2]) {
				q.push(t2); vis[t2]=true;
				ans[t2]=ans[t]+1;
			}
			int t3 = t*2;
			if (t3>=0 && t3<N && !vis[t3]) {
				q.push(t3); vis[t3]=true;
				ans[t3]=ans[t]+1;
			}
		}
		printf("%d\n",ans[y]);
	}
    return 0;
}

例:P1443 马的遍历

参考代码
#include <cstdio>
#include <queue>
using std::queue;
const int N = 405;
bool vis[N][N];
int ans[N][N];
int dx[8] = {-1, -2, -2, -1, 1, 2, 2, 1};
int dy[8] = {-2, -1, 1, 2, -2, -1, 1, 2};
struct S {
	int x,y;	
};
int main()
{
	int n, m, x, y; scanf("%d%d%d%d",&n,&m,&x,&y);
	for (int i=1;i<=n;i++)
		for (int j=1;j<=m;j++) {
			ans[i][j]=-1;
		}
	queue<S> q; q.push({x,y});
	vis[x][y]=true; ans[x][y]=0;
	while (!q.empty()) {
		S t = q.front(); q.pop();
		for (int i=0;i<8;i++) {
			int tx=t.x+dx[i];
			int ty=t.y+dy[i];
			// (t.x,t.y) -> (tx,ty)
			if (tx>=1 && tx<=n && ty>=1 && ty<=m && !vis[tx][ty]) {
				q.push({tx,ty});
				ans[tx][ty]=ans[t.x][t.y]+1;
				vis[tx][ty]=true;
			}
		}
	}
	for (int i=1;i<=n;i++) {
		for (int j=1;j<=m;j++) {
			printf("%-5d",ans[i][j]); // %-5d 是左对齐占满5个单位
		}
		printf("\n");
	}
    return 0;
}

例:UVA1189 Find The Multiple

题意:给定一个正整数 \(n\),寻找 \(n\) 的一个非零的倍数 \(m\),这个 \(m\) 在十进制表示下每一位只包含 \(0\) 或者 \(1\)

解题思路

仍然采用 BFS 的方法

\(x\) 后面加上 \(0\)\(n\) 取模就是 \(x * 10 \bmod n\),后面加上 \(1\)\(n\) 取模就是 \((x * 10 + 1) \bmod n\)
\(x\)\(y\)\(n\) 相等,那么在两数后面添加相同的数之后模 \(n\) 也是一样的
所以我们可以考虑根据模 \(n\) 的值进行 BFS,则时间复杂度为 \(O(n)\)

参考代码
#include <cstdio>
#include <queue>
using namespace std;
const int N = 205;
int pre[N], num[N];
// num[i]表示模n余i的数对应的位
// pre[i]表示该状态的前一个状态
void output(int x) {
    if (x == -1) return;
    output(pre[x]); // 利用递归实现倒序输出
    printf("%d", num[x]);
}
int main()
{
    int n;
    scanf("%d", &n);
    while (n != 0) {
        for (int i = 0; i < n; i++) {
            pre[i] = num[i] = -1;
        }
        queue<int> q;
        q.push(1 % n); num[1 % n] = 1;
        while (!q.empty()) {
            int cur = q.front(); q.pop();
            if (cur == 0) {
                output(cur); printf("\n");
                break;
            }
            // 在后面加一个0
            int to = cur * 10 % n;
            if (num[to] == -1) {
                q.push(to);
                num[to] = 0;
                pre[to] = cur;
            }
            // 在后面加一个1
            to = (cur * 10 + 1) % n;
            if (num[to] == -1) {
                q.push(to);
                num[to] = 1;
                pre[to] = cur;
            }
        }
        scanf("%d", &n);
    }
    return 0;
}

例:UVA12101 Prime Path

题意:把一个四位数质数变成另一个四位数质数,每次只能改变一个数位,每次改变后的四位数要求是质数,求最少修改次数

要求最少修改次数,显然应该使用 BFS

参考代码
#include <cstdio>
#include <queue>
using namespace std;
const int N = 10005;
int ans[N];
bool p[N], vis[N];
int calc(int d1, int d2, int d3, int d4) {
    return d1 * 1000 + d2 * 100 + d3 * 10 + d4;
}
int main()
{
    // 预处理出10000以内的素数,p[i]表示i是不是素数
    for (int i = 2; i < N; i++) p[i] = true;
    for (int i = 2; i < N / i; i++) {
        if (p[i]) {
            for (int j = i * i; j < N; j += i) p[j] = false;
        }
    }

    int t;
    scanf("%d", &t);
    while (t--) {
        int a, b;
        scanf("%d%d", &a, &b);
        for (int i = 1000; i < 10000; i++) {
            vis[i] = false; ans[i] = 0;
        }
        queue<int> q; q.push(a); vis[a] = true;
        while (!q.empty()) {
            int cur = q.front(); q.pop();
            if (cur == b) break;
            int d1 = cur / 1000;
            int d2 = cur / 100 % 10;
            int d3 = cur / 10 % 10;
            int d4 = cur % 10;
            // 改千位
            for (int i = 1; i <= 9; i++) {
                int num = calc(i, d2, d3, d4);
                // num还没有变过且是质数才继续搜索该状态
                if (!vis[num] && p[num]) {
                    q.push(num); vis[num] = true;
                    ans[num] = ans[cur] + 1;
                }
            }
            // 改百位
            for (int i = 0; i <= 9; i++) {
                int num = calc(d1, i, d3, d4);
                if (!vis[num] && p[num]) {
                    q.push(num); vis[num] = true;
                    ans[num] = ans[cur] + 1;
                }
            }
            // 改十位
            for (int i = 0; i <= 9; i++) {
                int num = calc(d1, d2, i, d4);
                if (!vis[num] && p[num]) {
                    q.push(num); vis[num] = true;
                    ans[num] = ans[cur] + 1;
                }
            }
            // 改个位
            for (int i = 0; i <= 9; i++) {
                int num = calc(d1, d2, d3, i);
                if (!vis[num] && p[num]) {
                    q.push(num); vis[num] = true;
                    ans[num] = ans[cur] + 1;
                }
            }
        }
        if (!vis[b]) printf("Impossible\n");
        else printf("%d\n", ans[b]);
    }
    return 0;
}

洪水填充(flood fill)

  • 判断连通性和统计连通块个数的问题

例:P1596 [USACO10OCT] Lake Counting S

  1. 存储网格图
    f[x][y] 存储网格图
    dx[8] dy[8] 存储方向偏移量
  2. 搜索
    枚举单元格,判断是否可以进入
    如果可以进入,则水坑数量+1,并且将该单元格所属水坑的其他单元格全都进入一遍(这里DFS和BFS都可实现)
    为避免重复搜索,对走过的单元格进行标记

DFS实现

#include <cstdio>
char f[105][105];
int n, m;
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
void dfs(int x, int y) {
    f[x][y] = '.';
    for (int i = 0; i < 8; i++) {
        int xx = x + dx[i];
        int yy = y + dy[i];
        if (xx >= 0 && xx < n && yy >= 0 && yy < m && f[xx][yy] == 'W') {
            dfs(xx, yy);
        }
    }
}
int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i++) scanf("%s", f[i]);
    int lake = 0;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++) 
            if (f[i][j] == 'W') {
                lake++;
                dfs(i, j);
            }
    printf("%d\n", lake);
    return 0;
}

BFS实现

#include <cstdio>
#include <queue>
using namespace std;
char f[105][105];
int n, m;
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
struct Node {
    int x, y;
};
void bfs(int x, int y) {
    f[x][y] = '.';
    queue<Node> q;
    q.push({x, y});
    while (!q.empty()) {
        Node t = q.front(); q.pop();
        for (int i = 0; i < 8; i++) {
            int xx = t.x + dx[i], yy = t.y + dy[i];
            if (xx >= 0 && xx < n && yy >= 0 && yy < m && f[xx][yy] == 'W') {
                f[xx][yy] = '.';
                q.push({xx, yy});
            }
        }
    }
}
int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i++) scanf("%s", f[i]);
    int lake = 0;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++) 
            if (f[i][j] == 'W') {
                lake++;
                bfs(i, j);
            }
    printf("%d\n", lake);
    return 0;
}
  • 时间复杂度:\(O(nm)\)

双向搜索

image

例:P1379 八数码难题

解题思路

既然是要求最少的步数,那么就可以考虑使用 BFS
这里的状态显然是当前八数码的局面,如何记录这种状态是否出现过呢?
最直接的想法就是不转化,开个 \(9\) 维的数组,但是这样会造成巨大的空间复杂度
所以我们应该使用哈希表记录状态

参考代码
#include <cstdio>
#include <iostream>
#include <string>
#include <queue>
#include <unordered_map>
#include <algorithm>
using namespace std;
unordered_map<string, int> ans; 
struct Status {
    string s;
    int idx;
};
int main()
{
    string s, target = "123804765";
    cin >> s;
    queue<Status> q;
    q.push({s, int(s.find('0'))}); ans[s] = 0;
    while (!q.empty()) {
        Status cur = q.front();
        q.pop();
        if (cur.s == target) break;
        int step = ans[cur.s];
        // 与上面交换
        if (cur.idx > 2) {
            string to = cur.s; 
            swap(to[cur.idx - 3], to[cur.idx]);
            if (ans.count(to) == 0) {
                q.push({to, cur.idx - 3});
                ans[to] = step + 1;
            }
        }
        // 与左边交换
        if (cur.idx % 3 > 0) {
            string to = cur.s;
            swap(to[cur.idx - 1], to[cur.idx]);
            if (ans.count(to) == 0) {
                q.push({to, cur.idx - 1});
                ans[to] = step + 1;
            } 
        }
        // 与右边交换
        if (cur.idx % 3 < 2) {
            string to = cur.s;
            swap(to[cur.idx + 1], to[cur.idx]);
            if (ans.count(to) == 0) {
                q.push({to, cur.idx + 1});
                ans[to] = step + 1;
            } 
        }
        // 与下面交换
        if (cur.idx < 6) {
            string to = cur.s;
            swap(to[cur.idx + 3], to[cur.idx]);
            if (ans.count(to) == 0) {
                q.push({to, cur.idx + 3});
                ans[to] = step + 1;
            }
        }
    }
    printf("%d\n", ans[target]);
    return 0;
}
康托展开

整个棋盘的任意一种状态可以看作是 \(0 \sim 8\) 的一组排列
康托展开是一个排列到一个自然数的双射(一一对应)
因为 \(n\) 个数的排列一共有 \(n!\) 种,所以可以将一个排列用它在所有排列中的排名来表示(可以理解为字典序),例如:

  • \(0 1 2 3 4 5 6 7 8\)\(0\)
  • \(8 7 6 5 4 3 2 1 0\)\(9!-1=362879\)
  • \(0 5 4 2 1 3 6 7 8\)\(0*8!+4*7!+3*6!+1*5!+0*4!+0*3!+0*2!+0*1!+0*0!=22440\)

正展开:对于第 \(i\) 个数,统计有几个排列前 \(i-1\) 个数和它相同,第 \(i\) 个数比它小

逆展开:对于第 \(i\) 个数,统计当前值可以减去几个 \((n-i)!\),就说明有几个未出现数比它小,即可求得第 \(i\) 个数的值

参考代码(康托展开)
#include <cstdio>
#include <iostream>
#include <string>
#include <queue>
#include <algorithm>
using namespace std;
const int FAC = 362880 + 5;
int ans[FAC], fac[10];
bool used[10];
struct Status {
    string s;
    int idx;
};
void init() {
    fac[0] = 1;
    for (int i = 1; i < 10; i++) fac[i] = fac[i - 1] * i;
    for (int i = 0; i < FAC; i++) ans[i] = -1;
}
int cantor(const string& s) {
    for (int i = 0; i < 10; i++) used[i] = false;
    int ret = 0, len = s.length();
    for (int i = 0; i < len; i++) {
        int cnt = 0, num = s[i] - '0';
        for (int j = 0; j < num; j++)
            if (!used[j]) cnt++;
        ret += cnt * fac[len - i - 1];
        used[num] = true;
    }
    return ret;
}
int main()
{
    init();
    string s, target = "123804765";
    cin >> s;
    queue<Status> q;
    q.push({s, int(s.find('0'))}); ans[cantor(s)] = 0;
    while (!q.empty()) {
        Status cur = q.front();
        q.pop();
        if (cur.s == target) break;
        int step = ans[cantor(cur.s)];
        // 与上面交换
        if (cur.idx > 2) {
            string to = cur.s; 
            swap(to[cur.idx - 3], to[cur.idx]);
            int perm = cantor(to);
            if (ans[perm] == -1) {
                q.push({to, cur.idx - 3});
                ans[perm] = step + 1;
            }
        }
        // 与左边交换
        if (cur.idx % 3 > 0) {
            string to = cur.s;
            swap(to[cur.idx - 1], to[cur.idx]);
            int perm = cantor(to);
            if (ans[perm] == -1) {
                q.push({to, cur.idx - 1});
                ans[perm] = step + 1;
            } 
        }
        // 与右边交换
        if (cur.idx % 3 < 2) {
            string to = cur.s;
            swap(to[cur.idx + 1], to[cur.idx]);
            int perm = cantor(to);
            if (ans[perm] == -1) {
                q.push({to, cur.idx + 1});
                ans[perm] = step + 1;
            } 
        }
        // 与下面交换
        if (cur.idx < 6) {
            string to = cur.s;
            swap(to[cur.idx + 3], to[cur.idx]);
            int perm = cantor(to);
            if (ans[perm] == -1) {
                q.push({to, cur.idx + 3});
                ans[perm] = step + 1;
            }
        }
    }
    printf("%d\n", ans[cantor(target)]);
    return 0;
}

还可以更快吗?

双向搜索

可以发现搜索的起点和终点都是已知的,是不是可以同时从终点开始 BFS,在中间点交汇呢?

为了减少搜索量,我们可以从起点和终点同时开始进行 BFS 或者 DFS,当两边的搜索结果相遇时,就可以认为是获得了可行解

显然经此优化,搜索量仅为原来的一半

参考代码(双向搜索)
#include <cstdio>
#include <iostream>
#include <string>
#include <queue>
#include <unordered_map>
#include <algorithm>
using namespace std;
unordered_map<string, int> ans[2]; 
struct Status {
    string s;
    int idx;
};
queue<Status> q[2];
int main()
{
    string s, target = "123804765";
    cin >> s;
    q[0].push({s, int(s.find('0'))}); ans[0][s] = 0;
    q[1].push({target, int(target.find('0'))}); ans[1][target] = 0;
    int now = 0;
    while (true) {
        int rev = 1 - now;
        Status cur = q[now].front(); q[now].pop();
        if (ans[rev].count(cur.s)) {
            printf("%d\n", ans[now][cur.s] + ans[rev][cur.s]);
            break;
        }
        int step = ans[now][cur.s];
        // 与上面交换
        if (cur.idx > 2) {
            string to = cur.s; 
            swap(to[cur.idx - 3], to[cur.idx]);
            if (ans[now].count(to) == 0) {
                q[now].push({to, cur.idx - 3});
                ans[now][to] = step + 1;
            }
        }
        // 与左边交换
        if (cur.idx % 3 > 0) {
            string to = cur.s;
            swap(to[cur.idx - 1], to[cur.idx]);
            if (ans[now].count(to) == 0) {
                q[now].push({to, cur.idx - 1});
                ans[now][to] = step + 1;
            } 
        }
        // 与右边交换
        if (cur.idx % 3 < 2) {
            string to = cur.s;
            swap(to[cur.idx + 1], to[cur.idx]);
            if (ans[now].count(to) == 0) {
                q[now].push({to, cur.idx + 1});
                ans[now][to] = step + 1;
            } 
        }
        // 与下面交换
        if (cur.idx < 6) {
            string to = cur.s;
            swap(to[cur.idx + 3], to[cur.idx]);
            if (ans[now].count(to) == 0) {
                q[now].push({to, cur.idx + 3});
                ans[now][to] = step + 1;
            }
        }
        now = rev;
    }
    return 0;
}

剪枝

剪枝就是排除搜索树中不必要的分支
比如,如果知道往某个支路走答案必定(或者已经)不如当前最优解,那么就可以跳过这个支路
同样,为了可以剪掉更多枝,可以优先往期望较优的分支走
可以对当前状态“估价”,例如当前状态到最终状态至少要 \(x\) 步,而当前已经走过的步数再加上 \(x\) 大于等于当前的最优解步数,则直接回溯

posted @ 2023-08-06 06:13  RonChen  阅读(278)  评论(0)    收藏  举报