CSP-J/CSP-S 深度优先搜索(DFS)完全讲义

CSP-J/CSP-S 深度优先搜索(DFS)完全讲义

本文约7500字,系统讲解DFS的核心思想、三种枚举模式、回溯与剪枝技巧、记忆化搜索、迭代加深等进阶内容,并附有经典例题解析与代码模板。

第一章 深度优先搜索概述

1.1 什么是深度优先搜索

深度优先搜索(Depth First Search,简称DFS),顾名思义就是按照深度优先 的顺序对"问题状态空间"进行搜索的算法。它的核心思想可以用一句话概括:一条路走到黑,走不通再回头

具体来说,DFS从某个状态出发,沿着一条路径一直深入探索,直到无法继续前进(到达叶子节点或遇到不合法状态),然后回溯到上一个状态,选择另一条路径继续探索,直到遍历完所有可能的状态。

我们可以把"问题空间"类比为一张 ,其中的状态类比为结点,状态之间的联系就用图中的边来表示。那么使用DFS求解问题,就相当于在一张图上进行深度优先遍历。

1.2 DFS与递归、栈的关系

DFS通常通过递归来实现。"递"的过程就是往下搜索,对应着深度的增加;"归"的过程就是回溯,退回到上一级。

递归函数在执行时,系统会使用调用栈 来保存每一层函数的状态(局部变量、返回地址等)。因此,DFS本质上就是在利用系统栈来实现"深度优先"的遍历顺序。我们也可以手动使用栈(stack)来模拟递归过程,但递归写法更加简洁直观。

注意 :"递归"是一种单纯的遍历/计算方法,而"深搜"是一类包括遍历形式、状态记录与检索、剪枝优化等算法整体设计的统称。

1.3 搜索树

在对问题进行DFS时,所有的状态和状态之间的转移会构成一棵搜索树

  • 根节点:初始状态

  • 内部节点:搜索过程中的中间状态

  • 叶子节点:终止状态(找到了答案或无法继续)

  • :从一个状态到另一个状态的转移

整个DFS算法就是基于这棵搜索树完成的------我们沿着树的边向下深入,到达叶子后回溯。

1.4 什么时候用DFS

搜索本质上就是枚举 。但普通的for循环只能处理一层枚举,当问题需要分多步决策时,就需要用到搜索。

DFS适合解决以下几类问题:

  1. 枚举类问题:全排列、子集、组合等

  2. 路径/连通性问题:迷宫寻路、图的遍历、连通块计数

  3. 棋盘/布局问题:N皇后、数独、八数码

  4. 最优解问题:在搜索空间中寻找最优方案(配合剪枝)

在CSP竞赛中,DFS是一个极其重要 的算法,因为它不仅本身是高频考点,还可以用来骗部分分 ,有时还能写成记忆化搜索来代替DP。

第二章 DFS的三种基本枚举模式

在CSP-J/S中,DFS最常见的应用是解决三种基本的枚举问题:指数型枚举排列型枚举组合型枚举。掌握这三种模式是写好DFS的基础。

2.1 指数型枚举(子集枚举)

问题描述:给定n个数,枚举所有子集(每个数选或不选)。

每个数都有不选 两种状态,因此总共有2^n种方案,时间复杂度为指数级。

核心思路:递归到第x个数时,分别尝试"选"和"不选"两个分支。

代码模板

cpp

复制代码
#include <iostream>
using namespace std;

int n;
int vis[20];  // 1表示选,2表示不选

void dfs(int x) {
    // 已经处理完所有n个数,输出一种方案
    if (x > n) {
        for (int i = 1; i <= n; i++) {
            if (vis[i] == 1) cout << i << " ";
        }
        cout << endl;
        return;
    }
    
    // 分支1:不选第x个数
    vis[x] = 2;
    dfs(x + 1);
    vis[x] = 0;  // 回溯
    
    // 分支2:选第x个数
    vis[x] = 1;
    dfs(x + 1);
    vis[x] = 0;  // 回溯
}

int main() {
    cin >> n;
    dfs(1);
    return 0;
}

关键点 :每次递归返回后要恢复状态(回溯),否则会影响后续分支的搜索。

2.2 排列型枚举(全排列)

问题描述:给定n个不同的数,输出所有排列方案。

排列是区分顺序 的,1 2 31 3 2是两种不同的方案。总共有n!种方案。

核心思路 :用一个vis数组标记哪些数已经被选过。每层递归从所有未被选的数中选一个放入当前位置。

代码模板

cpp

复制代码
#include <iostream>
#include <iomanip>
using namespace std;

int n;
int vis[15];  // vis[i]=1表示数字i已被选
int a[15];    // 存储当前排列

void dfs(int x) {
    // 已经选完了n个数
    if (x > n) {
        for (int i = 1; i <= n; i++) {
            cout << setw(5) << a[i];
        }
        cout << endl;
        return;
    }
    
    // 枚举所有可选的数字
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            vis[i] = 1;     // 标记已选
            a[x] = i;       // 放入当前位置
            dfs(x + 1);     // 继续选下一个
            vis[i] = 0;     // 回溯:撤销标记
            // a[x] = 0;    // 可以不写,因为下次会被覆盖
        }
    }
}

int main() {
    cin >> n;
    dfs(1);
    return 0;
}

关键点 :排列型枚举的每一层都要遍历所有未被选过的数 ,因此需要使用vis数组来避免重复选择。

2.3 组合型枚举

问题描述:从n个不同元素中取出m个元素的所有组合。

组合不区分顺序1 2 31 3 2是同一组方案。

核心思路 :为了避免重复(顺序不同的组合被重复计数),我们规定后选的数必须比先选的大 。因此递归函数需要两个参数:当前选了几个数(x),以及从哪个数开始选(start)。

代码模板

cpp

复制代码
#include <iostream>
using namespace std;

int n, m;
int a[15];

void dfs(int x, int start) {
    // 已经选了m个数
    if (x > m) {
        for (int i = 1; i <= m; i++) {
            cout << a[i] << " ";
        }
        cout << endl;
        return;
    }
    
    // 从start开始选,保证后面的数比前面的大
    for (int i = start; i <= n; i++) {
        a[x] = i;
        dfs(x + 1, i + 1);  // 下一个数从i+1开始选
        // 这里不需要显式回溯,因为a[x]会被覆盖
    }
}

int main() {
    cin >> n >> m;
    dfs(1, 1);
    return 0;
}

2.4 三种枚举模式总结

类型 状态数 是否区分顺序 关键特征
指数型枚举 2^n --- 每个元素选/不选
排列型枚举 n! 用vis标记已选
组合型枚举 C(n,m) 用start控制起始位置

第三章 回溯(Backtracking)

3.1 什么是回溯

回溯是DFS中最重要的概念之一。当搜索到某一步发现走不通(不合法),或者已经走到路径的尽头时,就退回上一步重新选择。

回溯在代码层面的体现就是:在递归调用返回后,恢复之前修改的状态

3.2 为什么需要回溯

考虑全排列的例子:当我们已经选择了1 2 3这个排列后,需要回到选择1 2的状态,然后尝试选择4。如果不恢复vis[3]的状态,那么3就永远被标记为"已选",导致无法生成1 2 4这个排列。

回溯就是为了解决搜索时路径之间的信息互相干扰的问题。

3.3 回溯三部曲

回溯算法可以总结为三个步骤:

  1. 确定终止条件:什么时候得到一个完整的答案?

  2. 确定递归逻辑:在当前层要做什么选择?

  3. 进行回溯(撤销):递归返回后,撤销当前层所做的修改

3.4 通用DFS/回溯模板

cpp

复制代码
void dfs(int x) {  // x表示当前进行到第几步/第几层
    // 1. 终止条件
    if (x > n) {
        // 处理或输出答案
        return;
    }
    
    // 2. 枚举当前层的所有可能选择
    for (int i = 0; i < choices; i++) {
        // 做选择(修改状态)
        state = new_state;
        
        // 递归进入下一层
        dfs(x + 1);
        
        // 3. 回溯(恢复状态)
        state = old_state;
    }
}

这个模板来源于洛谷官方DFS教程。

第四章 剪枝优化(Pruning)

DFS的本质是暴力枚举 ,在最坏情况下会遍历整个搜索树。当搜索空间很大时,直接搜索会超时。剪枝就是通过提前排除不可能产生答案的分支,从而减少搜索量。

剪枝的核心思想是:在搜索过程中,如果能够证明当前分支不可能得到合法答案(或不可能比已有答案更优),就立即停止搜索这个分支

4.1 优化搜索顺序

原则 :优先搜索分支较少的节点,这样可以尽早排除不可能的分支。

示例 :在"小猫爬山"问题中,将小猫按重量从大到小排序后再搜索。因为重量大的猫选择更少(能装它的缆车更少),先处理它们可以减少搜索树的分支。

示例 :在数独问题中,每次选择可选数字最少的空格进行填充。

4.2 可行性剪枝

定义 :如果当前状态已经不可能达到合法答案,就提前终止。

常见形式

  • 边界检查:当前位置越界 → 剪枝

  • 约束检查:当前累计值已经超过限制 → 剪枝

  • 剩余判断:即使把剩下的所有资源都用上也无法达到目标 → 剪枝

示例:在背包问题中,如果当前物品总重量已经超过背包容量,则剪枝。

4.3 最优性剪枝

定义 :对于求最优解 的问题,如果当前方案的代价已经不小于当前已知的最优解,那么继续搜索不可能得到更优答案,应该剪枝。

代码形式

cpp

复制代码
int ans = INF;  // 全局最优解

void dfs(int u, int cost) {
    // 最优性剪枝
    if (cost >= ans) return;
    
    if (u == n) {
        ans = min(ans, cost);
        return;
    }
    // ... 继续搜索
}

4.4 排除等效冗余

定义 :如果多个搜索分支会到达等效的状态(即后续的搜索完全一样),那么只需要搜索其中一个。

示例 :在组合枚举中,1 2 31 3 2是同一组方案。通过规定start参数强制数字递增,就排除了顺序不同造成的等效冗余。

4.5 记忆化搜索(Memoization)

记忆化搜索是一种特殊的剪枝------通过记录已经计算过的状态,避免重复计算。

核心思想:把递归理解为"带有备忘录的递归"------每次递归返回时,将结果存入备忘录;每次进入递归时,先查备忘录里有没有当前状态的结果。

适用条件

  1. 重叠子问题:不同的递归路径会重复计算同一个子问题

  2. 最优子结构:一个问题的最优解可以由子问题的最优解推导而来

实现框架

cpp

复制代码
#include <bits/stdc++.h>
using namespace std;

// 1. 定义备忘录数组
int f[N][M];  // 存储已计算的状态结果,初始化为-1表示未计算

// 2. DFS递归函数
int dfs(int i, int j) {
    // 边界条件
    if (边界条件) return 边界值;
    
    // 3. 查备忘录
    if (f[i][j] != -1) return f[i][j];
    
    // 4. 状态转移:搜索所有可能的选择
    int ans = 初始值;
    for (所有可能的选择) {
        ans = max(ans, 子问题的结果);
    }
    
    // 5. 存备忘录
    return f[i][j] = ans;
}

int main() {
    memset(f, -1, sizeof(f));  // 初始化备忘录
    cout << dfs(起始状态) << endl;
    return 0;
}

实现记忆化搜索的关键是:按照 "可变参数 → 返回值" 的格式创建备忘录。

记忆化搜索 vs 传统DP

维度 记忆化搜索 传统DP(迭代)
实现方式 递归,代码简洁 迭代,需手动规划顺序
适用场景 状态转移复杂 状态转移简单
空间开销 额外递归栈 无递归栈
计算方式 按需计算 可能计算无用状态

4.6 剪枝策略总结

剪枝类型 核心思想 典型应用
优化搜索顺序 先搜分支少的 小猫爬山、数独
可行性剪枝 提前识别死胡同 迷宫、约束满足问题
最优性剪枝 不比最优解更好就停 最优化问题
排除等效冗余 避免重复搜索相同状态 组合枚举
记忆化搜索 用空间换时间 有重叠子问题的递归

第五章 进阶搜索技巧

5.1 迭代加深搜索(IDDFS)

问题背景 :普通的DFS会一头搜到底,当搜索树的深度未知可能很深时,DFS可能会在一条很深的路径上浪费大量时间。而BFS虽然能保证找到最优解,但需要存储大量状态。

核心思想 :迭代加深搜索(Iterative Deepening DFS)结合了DFS和BFS的优点------每次限制搜索的最大深度,从1开始逐渐增加深度限制,直到找到答案。

实现方式

cpp

复制代码
bool dfs(int u, int depth, int max_depth) {
    if (u > max_depth) return false;  // 超过限制深度,剪枝
    if (找到答案) return true;
    
    for (所有可能的选择) {
        if (dfs(u + 1, depth + 1, max_depth)) return true;
    }
    return false;
}

int main() {
    for (int max_depth = 1; ; max_depth++) {
        if (dfs(0, 0, max_depth)) {
            cout << max_depth << endl;
            break;
        }
    }
    return 0;
}

适用场景

  • 搜索树很深但答案深度不大

  • 需要求最小步数的问题

  • 状态空间太大,BFS会爆内存

经典例题:UVA529 Addition Chains、POJ3134 Power Calculus

5.2 双向搜索(Meet-in-the-Middle)

核心思想 :将搜索空间对半分割 ,分别从两端搜索,然后在中间"相遇"。这可以将指数级复杂度从O(2^n)降低到O(2^(n/2))

适用场景:搜索空间巨大,但可以将问题分成两个独立的部分。

5.3 DFS序

定义:DFS序(DFS Order)是指对一棵树进行深度优先搜索时,访问节点的顺序序列。

DFS序在树形问题中有广泛应用:

  • 求LCA(最近公共祖先)

  • 求k级祖先

  • 子树区间查询(将子树转化为连续区间)

第六章 经典例题解析

6.1 小猫爬山(AcWing 165)

题目描述:有N只小猫要坐缆车下山,每辆缆车的最大承重为W。给出每只小猫的重量,求最少需要多少辆缆车。

数据范围:N ≤ 18

解题思路

  1. 用DFS依次处理每只小猫

  2. 对于每只小猫,有两种选择:放入已有的某辆缆车,或新开一辆缆车

  3. 状态表示:dfs(u, k)表示处理到第u只小猫,已经用了k辆缆车

剪枝策略

  1. 优化搜索顺序:将小猫按重量从大到小排序

  2. 最优性剪枝 :如果k >= ans,直接返回(不可能更优)

核心代码

cpp

复制代码
#include <bits/stdc++.h>
using namespace std;
const int N = 20;
int w[N], sum[N];  // sum[i]表示第i辆缆车当前载重
int ans = 1e9;
int n, W;

bool cmp(int a, int b) { return a > b; }

void dfs(int u, int k) {
    // 最优性剪枝
    if (k >= ans) return;
    
    // 所有猫都处理完了
    if (u == n) {
        ans = k;
        return;
    }
    
    // 尝试放入已有的缆车
    for (int i = 0; i < k; i++) {
        if (sum[i] + w[u] <= W) {  // 可行性剪枝
            sum[i] += w[u];
            dfs(u + 1, k);
            sum[i] -= w[u];  // 回溯
        }
    }
    
    // 新开一辆缆车
    sum[k] += w[u];
    dfs(u + 1, k + 1);
    sum[k] -= w[u];  // 回溯
}

int main() {
    cin >> n >> W;
    for (int i = 0; i < n; i++) cin >> w[i];
    sort(w, w + n, cmp);  // 优化搜索顺序
    dfs(0, 0);
    cout << ans << endl;
    return 0;
}

6.2 数独(AcWing 166)

题目描述:填充一个9×9的数独,使得每行、每列、每个3×3宫格内数字1-9各出现一次。

解题思路

  1. 用DFS依次填充空格

  2. 每个空格尝试填入1-9中可行的数字

  3. 填完所有空格即得到一个解

剪枝策略

  1. 优化搜索顺序 :每次选择可选数字最少的空格进行填充

  2. 位运算优化:用9位二进制数表示每行/列/宫格中哪些数字可用

6.3 N皇后问题

题目描述:在N×N的棋盘上放置N个皇后,使得它们互不攻击(不在同一行、同一列、同一对角线)。

解题思路

  1. 逐行放置皇后(每行恰好一个)

  2. 用三个数组分别标记主对角线副对角线是否已被占用

  3. 主对角线:row - col为常数;副对角线:row + col为常数

核心代码

cpp

复制代码
int n, ans;
bool col[20], diag1[40], diag2[40];  // 列、主对角线、副对角线

void dfs(int row) {
    if (row == n) {
        ans++;
        return;
    }
    for (int i = 0; i < n; i++) {
        if (!col[i] && !diag1[row - i + n] && !diag2[row + i]) {
            col[i] = diag1[row - i + n] = diag2[row + i] = true;
            dfs(row + 1);
            col[i] = diag1[row - i + n] = diag2[row + i] = false;  // 回溯
        }
    }
}

6.4 迷宫寻路

题目描述:在n×m的迷宫中,从起点(S)走到终点(E),求最短路径步数。

解题思路

  1. 用DFS搜索所有从起点到终点的路径

  2. 维护当前步数,更新最短路径

  3. 可用最优性剪枝:当前步数 ≥ 已知最短步数时剪枝

6.5 连通块(Flood Fill)

题目描述:在二维网格中,统计有多少个连通的同色区域。

解题思路

  1. 遍历所有格子,遇到未访问的目标格子就启动DFS

  2. DFS向四个(或八个)方向扩展,标记所有连通的格子

  3. 每次DFS完成,连通块计数+1

这是DFS在图论中最基础的应用之一。

第七章 竞赛实战建议

7.1 如何判断一道题该用DFS

  1. 数据范围小:n ≤ 20左右时,可以考虑指数级搜索

  2. 需要枚举所有方案:求方案数、求所有解

  3. 分多步决策:每一步的选择会影响后面的状态

  4. 图/树的遍历:连通性、路径查找

7.2 DFS的调试技巧

  1. 打印搜索树:在递归入口打印当前深度和状态,观察搜索过程

  2. 检查回溯:确保每次递归返回后状态都被正确恢复

  3. 先写暴力再优化:先写出无剪枝的版本确保正确性,再逐步加入剪枝

7.3 复杂度分析

  • 无剪枝的DFS:时间复杂度等于搜索树的节点数

  • 指数型枚举:O(2^n)

  • 排列型枚举:O(n!)

  • 组合型枚举:O(C(n,m))

  • 剪枝后:实际运行时间远小于理论最坏情况

7.4 常见错误

  1. 忘记回溯:导致状态污染,搜索结果错误

  2. 回溯不彻底:只恢复了部分状态

  3. 剪枝条件写错:剪掉了可能产生答案的分支

  4. 递归深度过大:导致栈溢出(注意n的范围)

  5. 全局变量使用不当:多组数据时忘记初始化

第八章 总结

深度优先搜索是CSP-J/S竞赛中最核心的算法之一。掌握DFS需要理解以下几个层次:

第一层(入门)

  • 理解DFS的"一条路走到黑"思想

  • 掌握三种基本枚举模式(指数型、排列型、组合型)

  • 能够写出基本的递归DFS代码

第二层(进阶)

  • 理解回溯的本质和必要性

  • 掌握常见的剪枝策略(优化搜索顺序、可行性剪枝、最优性剪枝)

  • 能够分析搜索树的规模并进行优化

第三层(精通)

  • 掌握记忆化搜索及其与DP的关系

  • 掌握迭代加深搜索等进阶技巧

  • 能够在复杂问题中灵活运用DFS+多种剪枝策略

记住:DFS不仅仅是一个算法,更是一种解决问题的思维方式------将复杂问题分解为多步决策,在状态空间中进行系统性的搜索。通过大量的练习,你会逐渐培养出"搜索直觉",能够在竞赛中快速判断何时使用DFS、如何设计状态、怎样进行剪枝。


愿你在CSP-J/S的征程中,用DFS攻克一道道难题!

相关推荐
码匠许师傅1 小时前
【C++ 面试真题】27. 聊聊 C++ 的内存泄漏与内存布局
java·c++·面试
码流怪侠2 小时前
2026年8月GitHub热榜深度拆解:Agent Skills席卷开源圈,一个“技能包“收割5万星
算法·程序员·github
hold?fish:palm2 小时前
30 两两交换链表中的节点
数据结构·算法·链表
Nil2082 小时前
leetcode 98验证二叉搜索树
算法·leetcode·职场和发展
不正经学生2 小时前
C语言结构体:自定义数据类型,让变量打包出行
java·c语言·开发语言·数据结构·算法
加油码3 小时前
动静态库的链接底层原理(进阶版)
linux·c++
一木 之林3 小时前
三、内存管理、指针与堆栈
c语言·jvm·c++
cxr8283 小时前
数学的本质:关系、模式与不变量的结构世界
人工智能·算法·机器学习
初夏睡觉4 小时前
原创题目 - 空壳恋爱
c++·算法·dp·oi·信息学·原创题目·值得挑战的题目