在蓝桥杯以及各类算法竞赛中,有一句广为流传的格言:"万物皆可搜"。搜索算法是打破思维僵局、拿到基础分数乃至斩获满分的绝对核心武器。当我们面对一个毫无头绪的复杂问题时,将所有可能的情况穷举出来并加以验证,往往是最直接有效的策略。
然而,暴力的搜索常常伴随着指数级的时间复杂度。为了在比赛规定的有限时间内跑出结果,我们就必须在基础的深度优先搜索(DFS)之上,引入"回溯"、"剪枝"以及"记忆化"等高级技巧。今天,我们就来深度拆解这三大搜索绝技。
目录
1.DFS基础回溯法
1.1回溯法概念
深度优先搜索(DFS)的核心思想是"不撞南墙不回头"。它会顺着一条路径一直往下探索,直到发现这条路走不通(到达边界或不满足条件),然后再退回到上一个分叉口,尝试另一条路径。
而这个"退回"并撤销之前操作的过程,就是回溯(Backtracking)。回溯法的本质是遍历一棵状态空间树,为了保证在遍历其他分支时不受当前分支的影响,我们必须在递归返回时"恢复现场"。
1.2模板
// 求1~n的全排列
int a[N];
bool vis[N];
void dfs(int dep)
{
if(dep == n + 1)
{
for(int i = 1; i <= n; i++)
cout << a[i] << " ";
cout << endl;
return;
}
for(int i = 1; i <= n; i++)
{
if(!vis[i]) continue; // 排除不合法的路径
// 修改状态
vis[i] = true;
a[dep] = i;
// 下一层
dfs(dep + 1);
// 恢复状态
vis[i] = false;
// a[dep] = 0; // 可选,恢复状态
}
}
1.3例题
1.3.1N皇后问题
https://www.lanqiao.cn/problems/1508/learning/?page=1&first_category_id=1&problem_id=1508
深度解析: N皇后是回溯法的教科书级应用。此题最精妙的地方在于如何用 O(1) 的时间判断对角线冲突。代码中利用了二维坐标的数学规律:同一条主对角线上的点,其"行号减去列号"是一个常数(加上n是为了防止负数下标);同一条副对角线上的点,其"行号加上列号"是一个常数。这大大降低了冲突检测的时间复杂度。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 15;
int ans = 0; // 统计合法方案数
bool col[MAXN]; // 列冲突标记
bool dg[2 * MAXN]; // 主对角线标记(row - col + n)
bool udg[2 * MAXN]; // 副对角线标记(row + col)
// row: 当前处理行,n:皇后总数
void dfs(int row, int n)
{
if(row == n)
{
ans++;
return;
}
// 遍历当前行的每一列
for(int j = 0; j < n; j++)
{
// 只有列,主副对角线都无冲突,才能放皇后
if(!col[j] && !dg[row - j + n] && !udg[row + j])
{
// 标记占用
col[j] = dg[row - j + n] = udg[row + j] = true;
// 递归下一行
dfs(row + 1, n);
// 回溯:恢复现场
col[j] = dg[row - j + n] = udg[row + j] = false;
}
}
}
int main()
{
int n; cin >> n;
dfs(0, n);
cout << ans << '\n';
return 0;
}
1.3.2小朋友崇拜圈
https://www.lanqiao.cn/problems/182/learning/?page=1&first_category_id=1&problem_id=182
深度解析: 这是一个在有向图中寻找最大环的问题。代码通过三个状态(0:未访问, 1:正在访问的路径中, 2:已彻底结束访问)巧妙地完成了找环任务。当 DFS 遇到一个状态为 1 的节点时,说明我们回到了当前正在走的路径上,一个环就此诞生!利用 pos 数组记录步数,可以直接用当前步数减去相遇点的步数算出环长。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e5 + 9;
int a[MAXN]; // i号小朋友崇拜的人
int vis[MAXN]; // 0:未访问, 1:正在访问, 2:已访问完毕
int pos[MAXN]; // 记录节点在当前递归路径中的位置
int max_len = 0; // 全局最大环长
// u:当前访问的节点,idx:当前节点在路径中的位置索引
void dfs(int u, int idx)
{
vis[u] = 1; // 标记"正在访问"
pos[u] = idx; // 记录当前节点在路径中的位置
int v = a[u]; // 找到u的下一个节点
if(vis[v] == 0)
{
// 未访问,继续递归
dfs(v, idx + 1);
}
else if(vis[v] == 1)
{
// 正在访问,发现环,计算环长并更新最大值
max_len = max(max_len, idx - pos[v] + 1);
}
vis[v] = 2; // 回溯:标记为"已访问完毕"
}
int main()
{
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
int N; cin >> N;
for(int i = 1; i <= N; i++) cin >> a[i];
// 遍历所有未访问的节点,执行DFS
for(int i = 1; i <= N; i++)
{
if(vis[i] == 0)
{
dfs(i, 1); // 从位置1开始计数
}
}
cout << max_len << '\n';
return 0;
}
1.3.3全球变暖
https://www.lanqiao.cn/problems/178/learning/?page=1&first_category_id=1&problem_id=178
深度解析: 典型的网格图连通块搜索(Flood Fill)。此题不需要回溯,因为我们要找出整个岛屿的轮廓。核心思路是:在用 DFS 遍历一个岛屿(连通块)的所有陆地时,顺便检查每块陆地的上下左右。如果某块陆地四个方向都不是海洋,那它就是安全的。只要一个岛屿中存在哪怕一个安全陆地,这个岛屿就不会被完全淹没。
#include <bits/stdc++.h>
using namespace std;
const int N = 1010;
char a[N][N];
bool vis[N][N];
int dx[] = {-1, 1, 0, 0};
int dy[] = {0, 0, -1, 1};
void dfs(int x, int y, bool &has_safe)
{
vis[x][y] = true; // 标记当前格子已访问
// 检查当前格子是否是安全格子
bool is_safe = true;
for(int i = 0; i < 4; i++)
{
int nx = x + dx[i];
int ny = y + dy[i];
if(a[nx][ny] == '.')
{
// 相邻为海洋
is_safe = false;
}
}
if(is_safe)
{
has_safe = true; // 岛屿存在安全格子,不会被完全淹没
}
// 遍历四个方向,递归访问相邻的未访问陆地
for(int i = 0; i < 4; i++)
{
int nx = x + dx[i];
int ny = y + dy[i];
if(nx >= 0 && nx < N && ny >= 0 && ny < N && a[nx][ny] == '#' && !vis[nx][ny])
{
dfs(nx, ny, has_safe);
}
}
}
int main()
{
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
int n; cin >> n;
for(int i = 0; i < n; i++) cin >> a[i];
int ans = 0; // 记录被淹没的岛屿数量
for(int i = 0; i < n; i++)
{
for(int j = 0; j < n; j++)
{
if(a[i][j] == '#' && !vis[i][j])
{
bool has_safe = false;
dfs(i, j, has_safe);
// 没有安全格子,会被淹没
if(!has_safe)
{
ans++;
}
}
}
}
cout << ans << '\n';
return 0;
}
2.剪枝
2.1剪枝的概念
如果把搜索过程看作是在一棵巨大的"状态树"上攀爬,那么很多时候我们能提前预知某些树枝上绝对不可能长出我们要的果实。此时,直接把这根树枝砍掉,不再深入遍历,这就是剪枝。 常见的剪枝分为两类:
-
可行性剪枝:当前状态已经不满足题目的限制条件,继续搜下去也是非法的,直接返回。
-
最优性剪枝:当前花费的代价已经超过了我们之前找到的最佳答案,再搜下去只会更差,立刻停止。
2.2例题
2.2.1数字王国之军训排队
https://www.lanqiao.cn/problems/2942/learning/?page=1&first_category_id=1&problem_id=2942
深度解析: 这是一个集合划分问题。我们在分配小队时,使用了一个经典的最优性剪枝:if(k >= ans) return;。这短短一行代码是灵魂所在。它意味着:如果在当前的尝试中,我们新开的队伍数量 k 已经大于或等于之前探索到的最好方案 ans,那么即便后续全部成功,得到的结果也不会比现在更好,于是直接砍掉这颗搜索树的子树,大幅提升速度。
#include <bits/stdc++.h>
using namespace std;
const int N = 15;
int n, a[N];
vector<int> group[N]; // 存储第i个队伍里所有学生姓名
int ans = N; // 全局最小队伍数
// 检查元素val能否加入第idx个队伍
bool check(int val, int idx)
{
// 遍历队伍中已有元素
for (int i = 0; i < group[idx].size(); i++)
{
int x = group[idx][i];
if (val % x == 0 || x % val == 0)
{
return false;
}
}
return true;
}
// U:当前正在处理第几个学生,k:目前已经分配了k个小队
void dfs(int u, int k)
{
if(k >= ans) return;
if (u == n)
{
ans = min(ans, k);
return;
}
// 将第u个学生加入已经存在的k个队伍中
for (int i = 0; i < k; i++)
{
if (check(a[u], i))
{
group[i].push_back(a[u]);
dfs(u + 1, k);
group[i].pop_back();
}
}
// 自己新开一个队伍
group[k].push_back(a[u]);
dfs(u + 1, k + 1);
group[k].pop_back();
}
int main()
{
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin >> n;
for (int i = 0; i < n; i++) cin >>a[i];
dfs(0, 0);
cout << ans << '\n';
return 0;
}#include <bits/stdc++.h>
using namespace std;
const int N = 15;
int n, a[N];
vector<int> group[N]; // 存储第i个队伍里所有学生姓名
int ans = N; // 全局最小队伍数
// 检查元素val能否加入第idx个队伍
bool check(int val, int idx)
{
// 遍历队伍中已有元素
for (int i = 0; i < group[idx].size(); i++)
{
int x = group[idx][i];
if (val % x == 0 || x % val == 0)
{
return false;
}
}
return true;
}
// U:当前正在处理第几个学生,k:目前已经分配了k个小队
void dfs(int u, int k)
{
if(k >= ans) return;
if (u == n)
{
ans = min(ans, k);
return;
}
// 将第u个学生加入已经存在的k个队伍中
for (int i = 0; i < k; i++)
{
if (check(a[u], i))
{
group[i].push_back(a[u]);
dfs(u + 1, k);
group[i].pop_back();
}
}
// 自己新开一个队伍
group[k].push_back(a[u]);
dfs(u + 1, k + 1);
group[k].pop_back();
}
int main()
{
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin >> n;
for (int i = 0; i < n; i++) cin >>a[i];
dfs(0, 0);
cout << ans << '\n';
return 0;
}
2.2.2特殊的三角形
https://www.lanqiao.cn/problems/3008/learning/?page=1&first_category_id=1&problem_id=3008
接下来这两道题完美展示了如何利用数学不等式进行可行性剪枝。在《特殊的三角形》中,通过提前用数学循环枚举,避开了复杂的图搜索;而在《特殊的多边形》中,搜索的剪枝被发挥到了极致。
多边形代码的剪枝亮点:
-
边界剪枝 :
if (next_prod > MAXV) break;既然边的长度在递增,当前乘积如果已经超出上限,后续乘上更大的边一定会超出上限,可以直接终止当前层的循环。 -
前瞻性剪枝(展望未来):预测未来需要选的边,如果假设接下来的边是以当前边的最小增量(+1, +2, +3...)去取,乘积依然超限,那么说明当前的边已经选得太大了,无需再试!这种预判操作是解决指数级组合爆炸的最强杀招。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;const int MAXV = 1e6;
int cnt[MAXV + 5]; // cnt[i]表示值为i的合法三角形个数
int sum[MAXV + 5]; // sum[i]表示值<=i的合法三角形总数(前缀和)int main()
{
ios::sync_with_stdio(0), cin.tie(0);
for (ll a = 2; a * (a + 1) * (a + 2) <= MAXV; ++a)
{
for (ll b = a + 1; a * b * (b + 1) <= MAXV; ++b)
{
ll max_c = min(a + b - 1, (ll)(MAXV / (a * b)));
for (ll c = b + 1; c <= max_c; ++c)
{
ll v = a * b * c;
if (v <= MAXV)
{
cnt[v]++;
}
}
}
}for (int i = 1; i <= MAXV; ++i) { sum[i] = sum[i - 1] + cnt[i]; } int t; if (cin >> t) { while (t--) { int l, r; cin >> l >> r; if (l > MAXV) { cout << 0 << '\n'; continue; } if(r > MAXV) r = MAXV; cout << sum[r] - sum[l - 1] << "\n"; } } return 0;}
2.2.3特殊的多边形
https://www.lanqiao.cn/problems/3075/learning/?page=1&first_category_id=1&problem_id=3075
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXV = 1e5;
int cnt[MAXV + 5]; // cnt[i] 记录值为 i 的合法多边形个数
int prefix_sum[MAXV + 5]; // 前缀和数组
int t, n;
// depth: 当前正在选第几条边 (0 到 n-1)
// last_val: 上一条选出的边的长度 (保证递增)
// current_prod: 当前已选边的乘积
// current_sum: 当前已选边的和
void dfs(int depth, int last_val, ll current_prod, ll current_sum)
{
if(depth == n)
{
ll sum_others = current_sum - last_val;
if (sum_others > last_val)
{
if (current_prod <= MAXV)
{
cnt[current_prod]++; // 找到一个合法的,计数 +1
}
}
return;
}
// 从 last_val + 1 开始枚举当前边,保证单调递增互不相同
for (int v = last_val + 1; ; ++v) {
ll next_prod = current_prod * v;
// 剪枝 1:当前的乘积都超过 MAXV 了,后面的更不可能,直接退出循环
if (next_prod > MAXV) break;
// 剪枝 2:前瞻性剪枝
ll min_future_prod = next_prod;
bool over_limit = false;
// 还需要选 n - 1 - depth 条边
for (int i = 1; i <= n - 1 - depth; ++i)
{
min_future_prod *= (v + i);
if (min_future_prod > MAXV)
{
over_limit = true;
break;
}
}
// 如果连取最小可能值都会超过 MAXV,那v太大了,直接结束
if (over_limit) break;
// 当前边 v 合格,继续往深处搜下一条边
dfs(depth + 1, v, next_prod, current_sum + v);
}
}
int main()
{
ios::sync_with_stdio(0), cin.tie(0);
if(!(cin >> t >> n)) return 0;
// 预处理:从第 0 条边开始搜索,上一条边长度视为 0,初始乘积 1,初始和 0
dfs(0, 0, 1, 0);
// 构建前缀和数组
for (int i = 1; i <= MAXV; ++i)
{
prefix_sum[i] = prefix_sum[i - 1] + cnt[i];
}
// O(1) 处理所有查询
while (t--)
{
int l, r;
cin >> l >> r;
// 防御性编程:处理越界区间
if (l > MAXV)
{
cout << 0 << "\n";
}
else
{
// 如果 r 超过了上限,截断到 MAXV 即可
r = min(r, MAXV);
cout << prefix_sum[r] - prefix_sum[l - 1] << "\n";
}
}
return 0;
}
3.记忆化搜索
3.1记忆化搜索概念
当我们使用普通的 DFS 时,很多时候会重复遇到"一模一样的状态",从而导致对同一棵子树进行成千上万次的重复计算。记忆化搜索(Memoization)就像是给 DFS 配备了一个备忘录:我们在第一次计算出某个状态的答案时,把它存在一个数组里。下次再遇到这个相同的状态,直接从数组里把答案拿出来用。
记忆化搜索在本质上等价于动态规划(DP),它是"自顶向下"解决重复子问题的高级战术。
3.2混境之地5
https://www.lanqiao.cn/problems/3820/learning/?page=1&first_category_id=1&problem_id=3820
深度解析: 虽说这道题使用了队列(Queue)进行广度优先搜索(BFS),但它蕴含的"状态记忆"思想与记忆化搜索同宗同源。普通的二维访问数组 vis[x][y] 只能记录坐标是否到达过,但在此题中,由于有了"背包"这个道具,到达同一个格子的状态发生了裂变。因此,我们将访问数组升维成 vis[x][y][used]。这告诉我们:在高级搜索中,"坐标"只是状态的一部分,所有能影响后续决策的参数,都应该被纳入记忆化数组的维度中。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
// 地形高度
int h[MAXN][MAXN];
// vis[x][y][0] 表示未使用背包到达该点,vis[x][y][1] 表示使用了背包到达该点
bool vis[MAXN][MAXN][2];
int n, m, k;
int A, B, C, D;
int dx[] = {-1, 1, 0, 0};
int dy[] = {0, 0, -1, 1};
struct Node {
int x;
int y;
int used; // 0 或 1
};
int main()
{
ios_base::sync_with_stdio(0), cin.tie(0), cout.tie(0);
if (!(cin >> n >> m >> k)) return 0;
cin >> A >> B >> C >> D;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> h[i][j];
}
}
// 特判:如果起点和终点重合,直接输出 Yes
if (A == C && B == D) {
cout << "Yes\n";
return 0;
}
queue<Node> q;
q.push({A, B, 0});
vis[A][B][0] = true;
while (!q.empty()) {
Node curr = q.front();
q.pop();
// 已经到达终点
if (curr.x == C && curr.y == D) {
cout << "Yes\n";
return 0;
}
// 探索四个方向
for (int i = 0; i < 4; ++i) {
int nx = curr.x + dx[i];
int ny = curr.y + dy[i];
// 检查边界
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (h[nx][ny] < h[curr.x][curr.y]) {
if (!vis[nx][ny][curr.used]) {
vis[nx][ny][curr.used] = true;
q.push({nx, ny, curr.used});
}
}
}
}
// 队列清空还没到达终点,说明无路可走
cout << "No\n";
return 0;
}
3.3地宫取宝
https://www.lanqiao.cn/problems/216/learning/?page=1&first_category_id=1&problem_id=216
深度解析: 这是蓝桥杯中最经典的记忆化 DFS 题型。小明在地宫中行走,走到格子 (x, y) 时,接下来的选择只与他当前拿了多少件宝贝 cnt,以及手中宝贝的最大价值 max_val 有关,而与他之前是怎么走到这个格子的完全无关。 这就是典型的无后效性 。我们利用一个四维数组 memo[x][y][cnt][max_val] 将这个状态记录下来。这行极其简单的 if (memo[x][y][cnt][max_val] != -1) return memo[...]; 可以将原本指数级飙升的时间复杂度,瞬间压缩到多项式级别。
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9 + 7;
int n, m, k;
int grid[55][55];
// memo[x][y][cnt][max_val]
long long memo[55][55][15][15];
long long dfs(int x, int y, int cnt, int max_val)
{
// 记忆化:如果该状态已经被计算过,直接返回答案,极其关键的剪枝!
if (memo[x][y][cnt][max_val] != -1)
{
return memo[x][y][cnt][max_val];
}
// 获取当前格子偏移后的价值 (1 到 13)
int val = grid[x][y] + 1;
long long ans = 0;
// 边界条件:到达右下角终点
if (x == n - 1 && y == m - 1) {
// 不拿当前宝贝,正好凑齐 k 个
if (cnt == k) ans = (ans + 1) % MOD;
// 拿当前宝贝,拿完后正好凑齐k个
if (cnt == k - 1 && val > max_val) ans = (ans + 1) % MOD;
return memo[x][y][cnt][max_val] = ans;
}
// 向右走
if (y + 1 < m) {
// 不拿当前格子的宝贝
ans = (ans + dfs(x, y + 1, cnt, max_val)) % MOD;
// 拿当前格子的宝贝
if (val > max_val && cnt < k) {
ans = (ans + dfs(x, y + 1, cnt + 1, val)) % MOD;
}
}
// 向下走
if (x + 1 < n) {
// 不拿
ans = (ans + dfs(x + 1, y, cnt, max_val)) % MOD;
// 拿
if (val > max_val && cnt < k) {
ans = (ans + dfs(x + 1, y, cnt + 1, val)) % MOD;
}
}
// 记录并返回结果
return memo[x][y][cnt][max_val] = ans;
}
int main()
{
ios_base::sync_with_stdio(0), cin.tie(0), cout.tie(0);
if (!(cin >> n >> m >> k)) return 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
cin >> grid[i][j];
}
}
// 初始化记忆化数组为 -1
memset(memo, -1, sizeof(memo));
// 从 (0, 0) 开始搜,初始宝贝数为 0,最大价值为 0 (代表空手)
cout << dfs(0, 0, 0, 0) << "\n";
return 0;
}
结语
从最粗暴的穷举(基础 DFS 回溯),到拥有预见能力的决策(剪枝),再到化繁为简的艺术(记忆化搜索),搜索算法在蓝桥杯的舞台上拥有着无可比拟的统治力。希望通过对这三大阶梯的梳理,能帮助你在算法竞赛的搜索迷宫中,找到属于你的破局之路。
本章完。