☰
深度优先搜索DFS从入门到进阶:递归、回溯与剪枝实战解析
2026/10/5 11:44:53 网站建设 项目流程

1. DFS到底是什么:不撞南墙不回头的遍历思维

很多刚接触算法竞赛的朋友,第一次在题解里看到DFS(Depth First Search,深度优先搜索)时,脑子里其实是懵的。搜索?搜什么?是从一堆数据里找一个目标吗?还是像搜索引擎那样去查资料?都不是。

我一直觉得,DFS与其说是一种算法,不如说是一套"穷举的思维框架"。它的核心逻辑只有一句话:沿着一条路走到黑,走不通了再退回来,换一条路继续走,直到把所有可能的路都走完。

打个最朴素的比方。你在一个迷宫里找出口,DFS的走法是这样的:随便挑一面墙贴着走,一直走到死胡同,然后退回到刚才的分岔路口,换另一个方向继续走。这跟BFS(宽度优先搜索)那种"一层一层往外扩散"的策略完全不同,DFS的性格更"莽",但它能保证:只要迷宫有出口,你最终一定能找到。

在算法竞赛里,DFS最常用的场景是解决"决策型"问题。什么叫决策型问题?就是你在每一步都要做一个选择,每个选择都会产生新的分支,最后需要在这些分支组成的"选择树"里找到满足条件的答案。比如:

  • 从一个数字集合里选几个数,使它们的和等于某个目标值;
  • 在有障碍物的网格里,从起点走到终点,有多少条不同路径;
  • 给N个物品,每个选或不选,求最大价值。

这类问题用暴力循环写,要么嵌套层数不确定,要么代码写到你怀疑人生。但用DFS,一套递归代码就能把整棵"选择树"完整地走一遍,无非就是"选这个分支走到底,不行再回来选另一个"。

算法竞赛里的DFS,本质上就是用递归的方式实现一场系统性的、不重不漏的路径遍历。"不重"是指同一条路径不会走两遍,"不漏"是指所有可能的路径都被考虑过。这四个字是整套DFS代码正确性的根基,后文所有代码都会围绕这一点展开。

提示:如果你完全没接触过递归,先别慌。DFS的代码骨架里递归占比很高,但它的思路比代码本身简单得多。我建议你先理解"走到尽头就回头"这句话,再看代码,会轻松很多。

2. 递归:DFS的代码载体与执行真相

2.1 从"调自己"开始理解递归调用栈

DFS通常用递归实现,因为递归函数天然适合描述"深入下一步"的过程。很多初学者在看递归代码时,脑子里会想象"函数不停复制自己、自我嵌套",其实这个理解是反的——真正发生的是同一个函数被反复调用,每次调用都有自己的独立变量空间,它们在内存里按顺序压入调用栈。

我们来看一个最经典的入门例子:输出1到N的全排列。这个题几乎每本算法书都有,蓝桥杯、PAT、力扣的Top题库里也都有它的变体,值得彻底吃透。

#include <bits/stdc++.h> using namespace std; int n; // 要排列的数字个数 int path[10]; // 记录当前排列 bool used[10]; // used[x] = true 表示数字x已经被用过 void dfs(int step) { // step 表示当前要填第几个位置 if (step > n) { // 1. 所有位置都填完了,输出结果 for (int i = 1; i <= n; i++) { cout << path[i] << " "; } cout << endl; return; // 2. 回到上一层调用 } for (int x = 1; x <= n; x++) { // 3. 枚举所有数字 if (!used[x]) { // 4. 如果这个数字还没用过 path[step] = x; // 5. 在当前层直接赋值 used[x] = true; // 6. 标记为已用 dfs(step + 1); // 7. 递归填下一个位置 used[x] = false; // 8. 回溯:撤销标记,尝试下一个数字 } } } int main() { cin >> n; dfs(1); // 从第一个位置开始 return 0; }

这段代码只有两个关键动作:标记和撤销标记。每次进入一个新的dfs(step),就是在"填第step个位置"这个节点上做选择;递归调用dfs(step + 1)则是往下一层推进;当step > n时说明一条完整的排列已经构造完,输出并return。

2.2 调用栈的可视化:排列3个数时到底发生了什么

很多人看代码能看懂,但一追问"计算机是怎么一层层往上返回的"就卡住了。我建议你亲手画一张调用栈图。以n=3为例,当程序执行到dfs(3)内部的dfs(4)时,调用栈从上到下大致是这样的:

dfs(4) -> step=4,满足step>3,输出path[1..3],return dfs(3) -> 正在执行 for 循环,等着 dfs(4) 返回 dfs(2) -> 正在等待 dfs(3) 返回 dfs(1) -> 正在等待 dfs(2) 返回 main -> 正在等待 dfs(1) 返回

每个dfs(step)调用在执行完自己的for循环后会自然结束,然后控制权交还给上一层调用,继续执行上一层自己还没跑完的for循环。这个过程就是"回溯"的底层机制——不是代码跳来跳去,而是栈出栈的天然行为。

我见过太多新手在if (step > n)的return之后,忘记处理used[x]的复位。结果就是:排列数量骤减,或者出现1 1 1这种明显错误的输出。因为数字1一旦被标记,在整个递归深入过程中都不会被再次使用,等到回溯回来时,如果你不撤销,后面的位置就几乎无数字可用了。

这套"标记-递归-撤销"的模式,掌握之后会发现是高度套路的。你甚至不需要背代码,只需要理解每一步在做什么,然后对着题目套骨架。我自己的习惯是:凡是碰到"枚举所有选择、每种选择还要影响后续选择"的题目,第一反应就是DFS。

3. 回溯的本质:状态撤销与现场还原

3.1 什么叫"回溯"?为什么必须撤销状态?

上一节的全排列代码里,used[x] = false那一行就是回溯。为什么要撤销?因为同一套标记状态需要被不同分支复用。

举个例子。你已经选了1、2作为前两个数字,递归调用进入第三层,填了3,输出了1 2 3,然后返回。此刻如果你不把3的标记撤销,那么回到第二层、尝试下一轮循环时,数字3还是"已用"状态,但第二层此时明明还没有选3,这是个错误的现场。只有撤销标记,第二层才能正确地在"选了1、2"这个现场下,接着尝试把3放到别的可选位置。

回溯这个概念如果抽象成一句话就是:在递归返回后,要把本次选择造成的影响全部消除,让程序回到"还没做这个选择"的状态。

很多初学者一开始对"撤销"不太重视,觉得反正答案对了就行。但一旦遇到N皇后、数独、图的路径枚举这类问题,状态种类变多(棋盘标记、位置标记、剩余数字表、方向数组访问标记),漏掉一次还原就会产生灾难性的连锁错误,而且错误往往是隐蔽的——可能在某个分支上才出现,其余分支一切正常。这种bug在竞赛中是出了名的难debug。

3.2 迷宫寻路:用DFS走通带障碍的地图

全排列是DFS最简单的载体,因为选择空间是一维的(选哪个数字)。实际竞赛里更常见的是二维甚至多维空间搜索,比如迷宫。来看一个经典迷宫问题:给定一个n*m的地图,1表示障碍,0表示空地,从(1,1)出发,问能否走到(n,m),如果能,最少步数是多少(BFS求步数,DFS求可达性和路径)。

DFS负责"找路"的核心代码长这样:

const int dx[] = {-1, 1, 0, 0}; const int dy[] = {0, 0, -1, 1}; int n, m; int maze[105][105]; bool visited[105][105]; bool dfsMaze(int x, int y) { if (x == n && y == m) return true; // 到达终点 visited[x][y] = true; // 标记当前点已访问 for (int k = 0; k < 4; k++) { // 枚举四个方向 int nx = x + dx[k]; int ny = y + dy[k]; if (nx < 1 || nx > n || ny < 1 || ny > m) continue; // 越界跳过 if (maze[nx][ny] == 1) continue; // 撞墙跳过 if (visited[nx][ny]) continue; // 已经走过跳过 if (dfsMaze(nx, ny)) return true; // 深入,找到就直接返回 } return false; // 四个方向都走不通 }

这段代码里有个很重要的细节需要强调:递归调用之前检查visited,递归内部立刻设置visited[x][y] = true,而不是在for循环里临时判断+标记。为什么?因为visited的作用是防止在一条路径上绕圈子,我们是用"全局标记"配合"递归"实现的。如果把标记放在for循环内且不还原,你可能会错过从其他路径合法地访问同一个点。

对于"只问是否存在路径"的问题,visited标记可以不撤销,因为一旦某个点在当前搜索路径中被访问过,再通过别的路径走到它,接下来的所有可能结果都已经在第一次访问时探索过了,重复访问只会浪费时间。但如果你要的是"所有路径"或者"所有排列",visited/used就必须撤销,因为每个分支需要看到不同的历史状态。

这里有一个非常容易混淆的点,我建议你在做题时先问自己一句:这题是求"第一组解"、"所有解的数量",还是"最优解"?不同目标决定visited撤销与否、DFS要不要剪枝、甚至该不该用DFS。先想清楚求解目标,再动键盘,比闷头写代码高效得多。

4. DFS的经典应用场景:从排列组合到图与连通块

4.1 排列组合:竞赛里最频繁出现的DFS模板

全排列讲过了,排列衍生出的组合问题在竞赛里几乎每周都见。比如:从{1, 2, 3, ..., n}里选k个数,打印所有组合。组合与排列的关键区别是:组合不在乎顺序,1 2 3和3 2 1是同一种。

DFS写组合时有一个经典优化:强制让选择顺序递增。也就是在递归参数里加一个startIndex,每次只从startIndex之后选数。这个设计直接把搜索空间砍掉一半以上,还天然避免了重复集合的产生。

int n, k; int comb[25]; void dfsCombine(int step, int start) { if (step > k) { // 选够了k个数 for (int i = 1; i <= k; i++) cout << comb[i] << " "; cout << endl; return; } for (int i = start; i <= n; i++) { // 只从start开始枚举 comb[step] = i; dfsCombine(step + 1, i + 1); // 下一个数必须比当前大 } }

这段代码的变化只有一个参数start,但效果立竿见影。没有start时你要额外判断"当前组合是否已经出现过",有了start之后,1 2 3只会在第一个数是1、第二个数是2、第三个数是3时被构造一次,绝不会出现3 2 1。这种通过参数约束搜索顺序的思路,在DFS题目里是一种通用优化手段,值得反复体会。

4.2 图的DFS遍历:邻接表与连通块计数

DFS另一个高频场景是图论。判断一个无向图有多少个连通块、判断两个点是否连通、拓扑排序的DFS实现、找桥找割点,全都绕不开DFS。

以连通块计数为例,核心代码只有十几行:

vector<int> g[1005]; bool vis[1005]; void dfsGraph(int u) { vis[u] = true; for (int v : g[u]) { if (!vis[v]) dfsGraph(v); } } int countComponents(int n) { int cnt = 0; for (int i = 1; i <= n; i++) { if (!vis[i]) { cnt++; // 发现一个新的连通块 dfsGraph(i); // 把这个块内所有点都标记 } } return cnt; }

为什么dfsGraph(i)一次能标记一个连通块的全部点?因为DFS的递归特性是"一旦进入一个点,就会不回头地把所有能到达的点走完"。这种从某个起点出发、"尽力扩散"的行为,正好和一个连通块的定义完全吻合:块内任意两点之间都有路径,块与块之间没有路径。

vis数组在这里同样不需要撤销。理由跟上文说的一样:图的遍历目标是把每个点至少访问一次,不需要回退去枚举"不同路径的排列"。掌握这个"DFS遍历图+visited只加不减"的模型,后面学tarjan、割点、缩点会顺很多。

4.3 网格类问题:从岛屿数量到洪水填充

刷过力扣或者蓝桥杯的朋友,一定见过"岛屿数量"这道题:一个二维网格,1是陆地,0是海水,问有多少个岛屿。解法思路跟图连通块几乎一模一样,差别只是把vector邻接表换成了四个方向的网格移动。

void dfsGrid(int x, int y, vector<vector<char>>& grid) { if (x < 0 || x >= grid.size() || y < 0 || y >= grid[0].size()) return; if (grid[x][y] == '0') return; grid[x][y] = '0'; // 直接在原地图上"沉没"岛屿 dfsGrid(x + 1, y, grid); dfsGrid(x - 1, y, grid); dfsGrid(x, y + 1, grid); dfsGrid(x, y - 1, grid); }

这段代码有一个非常实用的小技巧:直接把访问过的陆地改成海水(grid[x][y] = '0'),省掉了一个额外的visited数组。这在竞赛和面试里都能用,能少维护一个数据结构,代码也更简洁。前提是确定可以原地修改地图,如果地图后续还要用,就不能这么干,得另开visited数组。

如果题目从"数岛屿"升级成"最大岛屿面积",思路一模一样,只是把dfsGrid的返回值变成int,每次扩展时累加面积即可。这类"在一个网格上沿着四方向/八方向搜索连通区域"的模型,就是洪水填充算法(Flood Fill),很多和图像处理、地图模拟相关的题目都会在它上面做文章。

5. 剪枝的艺术:DFS性能差距的关键

5.1 什么情况需要剪枝?无脑DFS的代价

DFS的问题也很明显:如果搜索树又大又深,纯靠递归硬跑,状态数量会爆炸。你可能会遇到一个n=25的排列题,暴力枚举25!种情况,计算机跑一年也跑不完。这时候就必须引入剪枝——在递归开始深入之前,提前判断这条分支是否还有希望,如果注定无解或不是最优,立刻放弃这条路径,不再往下走。

剪枝不是优化,是唯救命稻草。竞赛里常见的剪枝策略有这么几类:

  • 可行性剪枝:当前状态已经违反约束,比如拿到的数已经超过目标和,再往下加只会更大,直接return;
  • 最优性剪枝:当前累计的值已经比已找到的最优解差了,不适合用来做"是否超过当前最优"判断的,直接return;
  • 顺序优化:先搜索更容易产生解的分支,比如按数字从大到小搜索,往往能更快逼近最优解,从而给最优性剪枝提供更紧的上界。
  • 对称性剪枝:在某些组合问题中,交换两个相同物品无意义,可以强制搜索顺序减少重复。

5.2 一个经典的剪枝案例:N皇后问题

N皇后问题应该算是DFS+剪枝最经典的考题了。在n*n的棋盘上放n个皇后,要求任意两个皇后不在同一行、同一列、同一对角线。这题如果用纯暴力枚举所有摆放方式,复杂度是O(n^2的n次方)级别的,n稍大就崩。

DFS解法是逐行摆放,每一行尝试放一列,用三个布尔数组记录哪些列、哪些主对角线、哪些副对角线已经被占用,然后递归到下一行。当我发现某一行所有列都被占用时,这层递归的for循环自动结束,相当于天然剪枝。核心代码如下:

bool col[15], diag1[30], diag2[30]; int ans = 0, n; void dfsQueen(int row) { if (row == n) { ans++; return; } for (int c = 0; c < n; c++) { if (col[c] || diag1[row + c] || diag2[row - c + n]) continue; col[c] = diag1[row + c] = diag2[row - c + n] = true; dfsQueen(row + 1); col[c] = diag1[row + c] = diag2[row - c + n] = false; } }

这里用了一个数组下标技巧:主对角线上所有点的row - c值相等,副对角线上所有点的row + c值相等。为了避免row - c出现负数,统一加n偏移量。这样判断对角线是否冲突,时间复杂度直接O(1)。类似的索引压缩技巧在棋盘类和矩阵类DFS中非常常见,熟练掌握能省不少事。

当n=8时,这个代码在普通家用电脑上运行的时间接近瞬间完成;但如果你不使用任何剪枝,纯暴力枚举所有棋盘摆放,复杂度是不可想象的。剪枝的本质,就是利用题目约束条件把搜索空间从"全部可能"压缩到"真正有意义的那一小部分"。

6. DFS实测经验:常见坑与排查思路

6.1 递归深度:栈溢出是初学者最常见的翻车原因

DFS依赖系统调用栈,每一层递归都要占用栈空间。默认8MB的栈,大概能支持的递归层数在几千到几万层之间(跟栈帧大小有关)。但有些题目地图很大,比如1000x1000的网格洪水填充,递归深度可能达到十万层以上,直接Stack Overflow。

遇到这种情况,要么把递归DFS改成显式栈模拟:

stack<pair<int, int>> st; st.push({sx, sy}); while (!st.empty()) { auto [x, y] = st.top(); st.pop(); // 处理当前节点,把相邻未访问节点压栈 }

要么在比赛前用编译器指令加大栈空间(GCC在Linux下可加-Wl,--stack,268435456)。我还见过有人把显式栈当DFS用,结果因为pop顺序跟递归不一致,导致输出路径和预期不同——那是因为栈先入后出,往栈里push邻居的顺序,决定了实际搜索的遍历顺序。如果你想严格复现递归DFS的访问顺序,可以考虑用vector模拟栈并手动控制栈顶指针,这样顺序就完全一致了。

6.2 忘写visited导致死循环

第二个高频坑是漏掉visited标记,或者标记写错位置。尤其在网格搜索和图搜索里,如果你不标记"这个点已经访问过",DFS会在这条路径上反复横跳:从A走到B,B又走回A,A又走到B……直到栈溢出或者TLE。

正确的做法是:在进入某个点的那一刻就标记,而不是在准备进入时才标记。如果你在if判断里才标记,可能出现多个分支同时尝试进入同一个点的问题。如果题解要求所有路径,那visited标记要在递归返回时撤销;如果要求的是连通性、是否存在路径,那visited标记永不撤销,省时省力。

6.3 参数传递顺序与全局变量污染

很多人在写DFS时习惯把所有变量都设成全局,方便省事。但全局变量有个致命问题:整个递归过程中所有分支共享同一份状态。一旦某个分支忘还原状态,其他分支就会拿到被污染的数据。我自己早期写DFS时,经常因为少还原一个cnt计数器,排错排一个晚上。

一个比较稳妥的做法是:把状态变量作为递归参数传递,或者严格遵循"谁修改、谁还原"的原则,在每一层递归前和后成对地修改/还原。针对"计数"这类状态,我建议用返回值累加的方式代替全局计数器,能少很多麻烦。

还有一个小技巧:如果题目分多个测试数据,记得在每组数据开始前重置所有全局状态数组。这个看似基础的操作,在蓝桥杯的填空题里坑过很多人——因为前一组测试的DFS已经把标记数组改了,忘了重置,第二组数据从第一分钟起就是错的。

7. DFS下一步学什么:从入门到国奖的进阶路线

DFS本身是一个极其基础又极其百搭的算法。掌握了最朴素的"递归+回溯"框架后,我建议你按这个顺序继续往下走,每一步都以"能做出一类题"为目标,而不是读完就算:

  1. BFS宽度优先搜索:和DFS刚好相对。DFS走一条路走到黑,BFS一层一层扩散。最短路径、最少步数、状态转移等问题基本都是BFS的主场。推荐做力扣的"二叉树层序遍历""打开转盘锁"。
  2. 回溯法在排列组合中的应用:把全排列和组合的代码吃透后,做力扣的"组合总和""子集""复原IP地址"这些题。它们都是同一个模板的不同变体。
  3. 记忆化搜索:当DFS过程中存在大量重复子问题时,用dp数组缓存中间结果,就是记忆化搜索。比如斐波那契数列、滑雪问题、数位DP的许多题都用这个技巧。
  4. 剪枝策略的系统学习:从N皇后开始,逐步理解可行性剪枝、最优性剪枝、排序剪枝的应用场景。竞赛里很多DFS题卡的其实不是思路,而是剪枝优化,这一步跨过去,才能从"会写DFS"变成"会用DFS"。
  5. DFS在图论算法中的延伸:连通分量、割点、桥、强连通分量、拓扑排序的DFS版本,都是DFS和图论的深度结合。蓝桥杯国赛里经常出现这类题。

如果你正在准备蓝桥杯或ACM,我个人强烈建议把DFS当成第一个认真吃透的算法。因为它能覆盖大批基础题,同时又是后续图论、树形DP、状态压缩的预修课。算法竞赛里很多高分选手,都是把DFS写到了条件反射的程度——看见"枚举所有情况"就能立刻手写DFS骨架,看见"求最短步数"立刻切BFS思维。这种肌肉记忆的建立,没有捷径,就是靠刷题量的堆积。刚开始哪怕一天只做一两道DFS题也没关系,坚持一个月,你会明显感觉到递归思路顺畅了一大截。

最后分享一个我自己的习惯:每次写DFS题之前,先在纸上画出搜索树的形态,标清楚哪些节点需要回溯,哪些不需要;哪些分支可以剪掉,剪的依据是什么。这个习惯十分钟就能养成,但它能省下大量debug时间,也让你的DFS代码从一开始就是结构清晰的,而不是写到哪想到哪。算法竞赛这条路没有太多神秘的东西,就是一遍遍地把基础动作练到极致,DFS就是那个值得一开始就投入时间的基本功。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询