1. 项目概述从“计蒜客-蓝桥杯国赛训练营”说起如果你正在准备蓝桥杯国赛或者任何需要考察算法深度和思维严谨性的编程竞赛那么“深度优先搜索”这个知识点你绝对绕不过去。我当年备赛时也是从一堆DFSDepth-First Search的练习题里摸爬滚打过来的深知其中的门道。今天要聊的就是“计蒜客-蓝桥杯国赛训练营”里那些经典的深度优先搜索练习题。这些题目不是简单的模板套用它们往往融合了回溯、剪枝、状态压缩甚至是动态规划的思想是检验你是否真正理解DFS精髓的试金石。很多新手觉得DFS就是递归往下走但一到具体问题比如排列组合、图的连通性、棋盘覆盖、数独求解就不知道如何下手或者写出来的代码又慢又容易出错。这篇文章我就结合训练营里常见的几类题型把DFS从“是什么”到“怎么用”再到“怎么用得好”掰开揉碎了讲清楚。无论你是刚开始接触算法的小白还是想在国赛前查漏补缺的选手相信都能从这里找到可以直接“抄作业”的思路和避坑指南。2. 深度优先搜索的核心思想与竞赛应用场景2.1 不只是“一条路走到黑”很多人对DFS的第一印象是“递归”、“栈”、“一条路走到黑”。这没错但只对了一半。在竞赛中DFS更核心的价值在于其系统性遍历所有可能状态的能力。你可以把它想象成一个执着且有条理的探险家面对一个迷宫问题空间他选择一条路一直走到尽头递归深入如果发现是死胡同不满足条件就退回上一个岔路口回溯尝试另一条没走过的路。这个过程会遍历迷宫里的每一条可能的路径。在蓝桥杯等国赛级别的题目中DFS很少单独出现。它通常是作为解决一个搜索问题的骨架。这个“搜索问题”的定义非常广可能是寻找所有符合条件的排列全排列问题、在迷宫中找到一条从起点到终点的路径路径搜索、判断一个图是否是连通的连通块问题、或者在一个约束条件下填充数字如数独、N皇后。理解DFS关键是要建立“状态”的概念。每一个递归调用都对应问题的一个“状态”。DFS的任务就是生成所有可能的状态并检查哪些是我们要的答案。2.2 竞赛中的典型DFS应用模式根据我在刷题和比赛中的经验国赛难度的DFS题大致可以分为以下几类理解这些模式能让你快速定位解题方向排列组合与子集枚举这是DFS最经典的应用。例如给定一组不重复的数字求出所有可能的全排列。这里的“状态”就是当前已经排好的部分序列。DFS递归树的分支数就是剩余可选的数字个数。蓝桥杯真题中常有类似“数字排列”、“代表团出访”等变体。网格/矩阵中的搜索通常用一个二维数组表示地图如迷宫、棋盘。从某个点出发向上下左右四个有时八个方向探索寻找路径或统计连通区域。这类题目需要处理好边界条件、访问标记防止重复访问导致死循环和可行性判断如遇到障碍物不能走。这是训练营里最常见的题型之一。约束满足问题例如N皇后、数独。这类问题的特点是存在很强的约束条件皇后不能互相攻击、数独每行每列每宫格数字不重复。DFS需要一边尝试填充一边实时检查约束是否被破坏一旦破坏立即回溯这被称为“可行性剪枝”是优化搜索效率的关键。树或图的遍历虽然竞赛中常用邻接表或邻接矩阵来显式表示图但很多问题可以抽象成树或图的遍历。例如计算二叉树的最大深度、查找图中两个节点间的所有路径。此时DFS的“访问标记”尤为重要对于无向图要防止走回头路。组合优化问题的暴力搜索在一些数据范围较小通常n 20的问题中可能需要枚举所有物品的选择方案选或不选来求最大价值或最小代价这本质上是子集枚举。如果选择之间存在依赖或顺序则需要更复杂的DFS状态设计。注意区分“遍历”和“搜索”。遍历强调访问所有节点而搜索强调寻找特定目标或所有解。在竞赛语境下DFS多数时候承担的是“搜索”任务。3. DFS练习题的精讲与框架拆解下面我将选取“计蒜客-蓝桥杯国赛训练营”中极具代表性的几类题目给出详细的解题框架和代码实现。我会用C和Python两种语言作为示例因为它们是蓝桥杯的主流语言。关键在于理解思路语言只是工具。3.1 案例一全排列问题排列组合类题目简述给定一个不含重复数字的数组nums返回其所有可能的全排列。核心思路拆解 我们定义一个递归函数dfs(path, used)。path当前已经构成的排列状态。used一个布尔数组记录nums中每个数字是否已被使用。 递归过程递归终止条件当path的长度等于nums的长度时说明一个排列已经完成将其加入答案列表。在当前状态下遍历nums中的所有数字。如果某个数字nums[i]未被使用 (used[i] false)则做出选择将nums[i]加入path并标记used[i] true。递归进入下一层决策树即调用dfs(path, used)。撤销选择回溯这是DFS的精髓递归返回后要将刚才的选择撤销即从path末尾移除nums[i]并标记used[i] false。这样状态才能恢复到上一层以便尝试其他分支。C实现示例#include vector using namespace std; class Solution { public: vectorvectorint permute(vectorint nums) { vectorvectorint res; vectorint path; vectorbool used(nums.size(), false); // 访问标记数组 dfs(nums, used, path, res); return res; } private: void dfs(vectorint nums, vectorbool used, vectorint path, vectorvectorint res) { // 终止条件路径长度等于原数组长度 if (path.size() nums.size()) { res.push_back(path); return; } for (int i 0; i nums.size(); i) { if (!used[i]) { // 如果数字未被使用 // 做出选择 used[i] true; path.push_back(nums[i]); // 递归进入下一层 dfs(nums, used, path, res); // 撤销选择回溯 path.pop_back(); used[i] false; } } } };Python实现示例class Solution: def permute(self, nums: List[int]) - List[List[int]]: def backtrack(path, used): # 终止条件 if len(path) len(nums): # 注意这里要添加path的副本因为path之后会被修改 res.append(path[:]) return for i in range(len(nums)): if not used[i]: # 做出选择 used[i] True path.append(nums[i]) # 递归 backtrack(path, used) # 撤销选择 path.pop() used[i] False res [] used [False] * len(nums) backtrack([], used) return res实操心得path必须使用引用或全局变量以避免在递归过程中频繁拷贝带来的性能开销。但在保存结果时res.push_back(path)C中直接添加即可path会被拷贝Python中必须添加副本path[:]否则res中保存的都是对同一个path列表的引用最终内容全会一样。used数组是处理“不含重复数字”情况的标准做法。如果题目中nums包含重复数字则需要先排序然后在循环中添加判断if (used[i] || (i 0 nums[i] nums[i-1] !used[i-1])) continue;。这是去重的关键技巧理解起来需要画一下递归树。3.2 案例二岛屿数量网格搜索类题目简述给你一个由1陆地和0水组成的二维网格计算网格中岛屿的数量。岛屿由水平或垂直方向上相邻的陆地连接形成。核心思路拆解 我们可以把整个网格遍历一遍。当遇到一个1就说明发现了一个岛屿的起点此时将岛屿计数加1并从这个点开始进行一次DFS“淹没”操作。DFS“淹没”函数dfs(i, j)的任务是将当前陆地 (grid[i][j]) 标记为已访问比如改成0或2然后向其四个方向上、下、左、右进行递归探索。递归终止条件坐标越界或者当前格子不是陆地 (1)。通过这次DFS所有与起点相连的陆地都会被标记为已访问从而在主循环中不会被重复计数。C实现示例class Solution { public: int numIslands(vectorvectorchar grid) { if (grid.empty()) return 0; int m grid.size(), n grid[0].size(); int count 0; for (int i 0; i m; i) { for (int j 0; j n; j) { if (grid[i][j] 1) { count; dfs(grid, i, j, m, n); } } } return count; } private: // 方向数组表示上下左右四个方向的坐标偏移 vectorvectorint dirs {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; void dfs(vectorvectorchar grid, int x, int y, int m, int n) { // 终止条件越界或不是陆地 if (x 0 || x m || y 0 || y n || grid[x][y] ! 1) { return; } // 标记为已访问 grid[x][y] 0; // 向四个方向递归探索 for (auto dir : dirs) { int nx x dir[0]; int ny y dir[1]; dfs(grid, nx, ny, m, n); } } };Python实现示例class Solution: def numIslands(self, grid: List[List[str]]) - int: if not grid: return 0 m, n len(grid), len(grid[0]) count 0 def dfs(i, j): # 终止条件 if i 0 or i m or j 0 or j n or grid[i][j] ! 1: return # 标记为已访问 grid[i][j] 0 # 四方向递归 dfs(i1, j) dfs(i-1, j) dfs(i, j1) dfs(i, j-1) for i in range(m): for j in range(n): if grid[i][j] 1: count 1 dfs(i, j) return count避坑技巧访问标记这里我们直接修改了原数组grid将访问过的陆地1改成0。这是一种节省空间的常用技巧。如果题目不允许修改原数组则需要额外创建一个visited[m][n]的布尔数组来记录访问状态。方向数组使用方向数组dirs可以使代码更简洁避免写四行相似的递归调用。这在搜索类题目中是标准写法。递归深度网格很大时递归DFS可能导致栈溢出。虽然蓝桥杯评测环境栈空间通常足够但这是一个需要考虑的点。遇到极端情况可以使用栈Stack来模拟递归过程实现迭代式的DFS或者使用BFS广度优先搜索。3.3 案例三N皇后问题约束满足类题目简述将 N 个皇后放在 N × N 的棋盘上使得它们不能互相攻击即任意两个皇后不能在同一行、同一列或同一斜线上。返回所有不同的解。核心思路拆解 这是DFS回溯的标杆性问题。我们一行一行地放置皇后。状态board表示当前棋盘布局row表示当前正在放置第几行从0开始。选择在第row行尝试将皇后放在每一列colfrom 0 to N-1的位置上。约束检查在放置前必须检查这个位置(row, col)是否和之前已经放置的所有皇后冲突同一列、同一主对角线、同一副对角线。递归与回溯如果位置合法放置皇后然后递归处理row1行。递归返回后撤销当前位置的皇后尝试下一列。关键优化——快速冲突检查直接遍历所有已放置的皇后检查冲突是 O(N) 的。我们可以用三个布尔数组或集合将检查优化到 O(1)cols[col]记录第col列是否已有皇后。diag1[row - col N]记录主对角线左上到右下是否被占用。同一条主对角线上row - col为定值。加N是为了避免负索引。diag2[row col]记录副对角线右上到左下是否被占用。同一条副对角线上row col为定值。C实现示例class Solution { public: vectorvectorstring solveNQueens(int n) { vectorvectorstring res; // 初始化棋盘全部为. vectorstring board(n, string(n, .)); // 三个用于快速检查的数组 vectorbool cols(n, false); vectorbool diag1(2 * n - 1, false); // 主对角线有 2n-1 条 vectorbool diag2(2 * n - 1, false); // 副对角线有 2n-1 条 dfs(0, board, res, cols, diag1, diag2, n); return res; } private: void dfs(int row, vectorstring board, vectorvectorstring res, vectorbool cols, vectorbool diag1, vectorbool diag2, int n) { // 终止条件所有行都处理完毕 if (row n) { res.push_back(board); return; } // 尝试在当前行的每一列放置皇后 for (int col 0; col n; col) { int d1 row - col n; // 主对角线索引 int d2 row col; // 副对角线索引 // 检查冲突 if (cols[col] || diag1[d1] || diag2[d2]) { continue; // 冲突跳过该位置 } // 做出选择 board[row][col] Q; cols[col] diag1[d1] diag2[d2] true; // 递归到下一行 dfs(row 1, board, res, cols, diag1, diag2, n); // 撤销选择 board[row][col] .; cols[col] diag1[d1] diag2[d2] false; } } };Python实现示例class Solution: def solveNQueens(self, n: int) - List[List[str]]: def dfs(row, board): # 终止条件 if row n: # 将棋盘转换为要求的字符串列表格式 res.append([.join(row) for row in board]) return for col in range(n): d1, d2 row - col, row col if cols[col] or diag1[d1] or diag2[d2]: continue # 做出选择 board[row][col] Q cols[col], diag1[d1], diag2[d2] True, True, True # 递归 dfs(row 1, board) # 撤销选择 board[row][col] . cols[col], diag1[d1], diag2[d2] False, False, False res [] # 初始化棋盘和检查数组 board [[. for _ in range(n)] for _ in range(n)] cols [False] * n # 对角线条数为 2n-1用字典或偏移数组 diag1 collections.defaultdict(bool) # 主对角线 row-col diag2 collections.defaultdict(bool) # 副对角线 rowcol dfs(0, board) return res经验之谈状态压缩对于N皇后我们只关心列和对角线的占用情况不需要存储整个棋盘状态来检查冲突。这就是状态压缩思想的体现极大提升了效率。输出格式蓝桥杯等竞赛对输出格式要求严格。务必按照题目要求将解转换成字符串列表。像上面Python代码中[.join(row) for row in board]就是标准的转换方式。复杂度尽管有剪枝N皇后的解空间仍然很大。当 N 较大时如 N12寻找所有解会非常耗时。有时题目可能只要求解的数量或一个解这时算法是可行的。4. DFS的优化核心剪枝的艺术在国赛题目中纯暴力DFS往往无法通过所有测试用例因为时间复杂度是指数级的。这时“剪枝”就成了救命稻草。剪枝顾名思义就是在DFS的递归树上提前砍掉那些“明显不可能得到正确解”的分支。下面介绍几种最实用的剪枝策略。4.1 可行性剪枝在做出选择向下递归之前先判断当前选择是否“可行”。如果不可行直接跳过不进入递归。这是最常用、最有效的剪枝。例子组合总和问题给定无重复元素数组candidates和目标数target找出所有和为target的组合数字可重复使用。在递归函数中我们维护当前和sum。在遍历candidates选择数字加入组合时如果sum candidates[i] target那么即使继续加下去和也只会更大永远不可能等于target。因此我们可以提前终止循环不再尝试candidates[i]及之后更大的数字前提是数组已排序。这就是可行性剪枝。void dfs(vectorint candidates, int target, int start, int sum, vectorint path, vectorvectorint res) { if (sum target) { res.push_back(path); return; } for (int i start; i candidates.size(); i) { // 可行性剪枝如果加上当前数已经超过target由于数组已排序后面的数更大直接break if (sum candidates[i] target) { break; } path.push_back(candidates[i]); // 注意数字可重复使用所以下一层递归的start仍然是i dfs(candidates, target, i, sum candidates[i], path, res); path.pop_back(); } }4.2 最优性剪枝常用于求最优解如最小步数、最短路径的问题。我们维护一个全局变量best记录当前找到的最优值如最小步数。在DFS过程中如果当前路径的“代价”已经大于等于best那么即使继续走下去也不可能得到比best更好的解可以直接返回。例子迷宫最短路径假设steps记录当前已走步数best是当前找到的最短路径步数。void dfs(int x, int y, int steps) { // 最优性剪枝如果当前步数已经不小于已知最优解没必要继续 if (steps best) { return; } if (到达终点) { best min(best, steps); return; } // ... 继续搜索 }4.3 记忆化搜索Memoization这其实是DFS与动态规划的结合严格说不算“剪枝”但它是优化重复子问题搜索的神器。当DFS函数的状态可以用少数参数唯一确定并且这个状态会被重复计算多次时使用记忆化。例子爬楼梯问题求方案数dfs(i)表示爬到第i阶楼梯的方案数。dfs(i) dfs(i-1) dfs(i-2)。如果不加记忆化计算dfs(5)会重复计算很多次dfs(3)、dfs(2)等。vectorint memo; // 记忆化数组初始化为-1表示未计算 int dfs(int i) { if (i 1) return 1; // 边界条件 if (memo[i] ! -1) return memo[i]; // 已经计算过直接返回 memo[i] dfs(i-1) dfs(i-2); // 计算并保存 return memo[i]; }在蓝桥杯的某些DFS题中状态可能是(位置, 剩余资源)等如果直接DFS会超时尝试设计记忆化数组往往是突破口。4.4 顺序剪枝与去重对于排列组合问题如果结果集要求不能有重复的组合而非排列那么顺序剪枝至关重要。例子组合问题从n个数中选k个为了避免[1,2]和[2,1]被算作两个组合我们可以在DFS时传入一个start参数保证每次选择的数字索引是递增的。这样递归树就只会生成[1,2]而不会生成[2,1]。void dfs(int start, int k, vectorint path, ...) { if (path.size() k) { // 得到一个组合 return; } for (int i start; i n; i) { path.push_back(nums[i]); dfs(i 1, k, path, ...); // 下一层从 i1 开始保证不回头选 path.pop_back(); } }如果数组中有重复元素还需要配合排序和跳过重复值的操作如之前全排列去重提到的技巧。5. 从训练营到考场DFS实战调试与排错指南理论懂了框架会写了但自己动手时还是漏洞百出这太正常了。下面是我在大量练习和比赛中总结出的DFS代码调试清单和常见“坑点”。5.1 DFS调试核心检查清单当你写的DFS代码结果不对、死循环或者超时时请按顺序检查以下问题递归终止条件是否正确且完整是否考虑了所有成功找到解的情况是否考虑了所有失败/无解的情况比如越界、不满足约束忘记失败返回是导致栈溢出的常见原因。访问标记used/visited是否正确管理标记时机是在进入递归前标记还是在递归函数开头标记通常是在做出选择后、递归调用前标记。恢复时机必须在递归调用结束后、尝试下一个选择前恢复标记。这是回溯的关键忘记恢复会导致状态污染漏掉很多解。标记介质是用独立数组还是修改原数据修改原数据要确保题目允许。状态参数传递是否正确哪些状态是全局的如结果集res哪些状态是随着递归深度变化的如当前路径path、当前和sum这些是作为函数参数传递还是作为类的成员变量如果是通过引用传递如C的vectorint path在回溯时一定要记得“恢复现场”pop_back。剪枝条件是否严密且高效可行性剪枝的条件是否写对了特别是边界条件还是。去重剪枝的逻辑在数组有重复元素时是否正确通常需要先对数组排序。结果保存是否有问题保存的是当前状态的副本还是引用在Python中res.append(path)和res.append(path[:])天差地别。5.2 常见错误与修正案例错误案例1忘记恢复访问标记// 错误写法 void dfs(int x, int y) { visited[x][y] true; // ... 处理逻辑 for (每个方向) { int nx, ny; if (可访问) { dfs(nx, ny); // 进入下一层 // 忘记写 visited[x][y] false; !!! } } } // 这样会导致从起点出发后所有格子都被标记无法回溯探索其他路径。修正如果visited是用于记录单条路径上的访问如走迷宫不重复走格子那么应该在递归返回后恢复visited[x][y] false;。如果visited是用于标记整个连通分量如岛屿问题则不需要恢复。错误案例2结果集中全是空列表或相同列表# 错误写法 (Python) res [] path [] def backtrack(...): if 终止条件: res.append(path) # 错误添加的是path的引用 return for ...: path.append(x) backtrack(...) path.pop()修正res.append(path[:])或res.append(list(path))。错误案例3递归层数过深导致栈溢出蓝桥杯评测环境对递归深度有一定限制。对于极端深度的搜索如网格非常大且路径很长递归DFS可能爆栈。对策首先检查算法逻辑是否有误是否缺少终止条件导致无限递归。如果逻辑正确但数据量大考虑改用显式栈Stack实现迭代DFS或BFS。迭代DFS不会产生函数调用开销但代码稍复杂。部分语言可以设置递归深度限制如Python的sys.setrecursionlimit但这只是权宜之计。5.3 性能分析与测试数据构造在训练时不要只满足于样例通过。要学会自己构造测试数据来验证程序的正确性和效率。小数据测试用最小的、能涵盖所有边界情况的输入测试。比如N皇后问题测试N1, 2, 3, 4。检查输出数量和格式。中等数据测试测试算法在典型规模下的表现。例如排列问题测试n7或8检查运行时间是否在预期内。最大数据边界测试根据题目给出的数据范围上限如n20构造最大的合法输入测试程序是否会超时或内存溢出。这能帮你判断剪枝是否足够有效。随机数据对拍如果你有一个保证正确但较慢的暴力程序或已知的其他正确代码可以写一个脚本生成大量随机数据分别用你的优化程序和暴力程序跑对比结果是否一致。这是发现隐蔽逻辑错误的最强手段。6. 训练营进阶当DFS遇见其他知识点国赛题目 rarely 考察单一算法。DFS常常作为基础框架与其他知识点结合构成更复杂的问题。6.1 DFS 回溯这就是我们一直在讨论的经典模式。回溯是DFS在求解所有可行解或一个解时的特定应用其核心就是“尝试-递归-撤销”。6.2 DFS 记忆化 - 动态规划如前所述记忆化搜索是自顶向下的DP。很多DP问题尤其是路径、方案数问题都可以用DFS记忆化来思考和实现这有时比直接想状态转移方程更直观。6.3 DFS 剪枝 - 启发式搜索如IDA*当问题搜索空间巨大时单纯的剪枝可能不够。可以引入“启发函数”来估算从当前状态到目标状态至少还需要多少代价。如果当前代价启发估值 已知最优解则剪枝。这就是IDA*算法的思想在求解“八数码”、“骑士巡游”等问题时非常有效。在蓝桥杯国赛中这属于较高难度的考点。6.4 DFS 遍历树与图虽然竞赛中图的遍历更常用BFS求最短路径但DFS在求所有路径、判断环、拓扑排序、连通分量等方面也有应用。关键是要区分递归遍历树和图的区别树没有环而图可能有。因此遍历图时visited数组的含义需要仔细斟酌是在整个遍历过程中永久标记求连通块还是在单条路径探索中临时标记求简单路径6.5 实战融合案例单词搜索LeetCode 79这是一个典型的DFS回溯网格搜索的综合题。 题目给定一个二维网格和一个单词找出该单词是否存在于网格中。单词必须按照字母顺序通过相邻的单元格上下左右构成。思路遍历网格每个点作为起点。从起点开始DFS参数为当前坐标(i, j)和单词匹配到的索引k。终止条件k word.length()匹配成功坐标越界或当前字符不匹配则失败。为了避免重复使用同一个单元格需要临时修改网格当前字符如改为#作为访问标记。向四个方向递归搜索。递归返回后恢复网格字符回溯。任何一条路径成功即返回true。这个题目完美融合了网格DFS、回溯、访问标记和剪枝提前发现字符不匹配则剪枝。7. 个人备赛心得与资源推荐刷完“计蒜客-蓝桥杯国赛训练营”的DFS专题只是第一步。根据我的经验想在国内顶尖的编程竞赛中取得好成绩还需要做到以下几点1. 专题化训练与总结不要东一题西一题。像DFS就集中一段时间刷20-30道不同难度的题目。从模板题全排列、组合开始到经典题N皇后、岛屿数量再到变种题单词搜索、解数独。每刷完一道花时间写解题报告记录思路、核心代码和易错点。这个本子或笔记软件就是你最宝贵的财富。2. 刻意练习“翻译”能力竞赛题目的描述往往包裹着现实场景。你的首要能力是把文字描述抽象成DFS模型。看到“不同的派出方案”要想到组合看到“地图探索”要想到网格DFS看到“填充方案”要想到约束满足。这种能力只能通过大量读题和练习来培养。3. 重视时间复杂度的估算在动手写代码前先估算最坏情况下的时间复杂度。例如全排列是O(n!)n10时是360万n12时是4.79亿后者就很可能超时。如果估算后发现复杂度过高就要思考如何剪枝优化或者换用其他算法如状态压缩DP。4. 调试能力是练出来的单步调试对于理解递归过程非常有帮助。初期可以多用IDE的调试功能观察path、used等变量的变化。后期要锻炼自己“脑补”递归栈和状态变化的能力。遇到死循环第一时间检查终止条件和访问标记。5. 推荐的扩展练习平台与资源LeetCode搜索“Backtracking”和“Depth-first Search”标签题目质量高讨论区有丰富题解。AcWing有很多蓝桥杯真题和详细的视频讲解非常适合备赛。《算法竞赛入门经典》刘汝佳经典教材其中的搜索章节讲解得非常系统。蓝桥杯官网练习系统直接做历年真题感受出题风格和难度。最后DFS乃至所有算法学习都是一个“模仿-理解-创造”的过程。初期多抄写、背诵经典代码的框架没有错但一定要在理解的基础上进行。当你拿到一道新的搜索题能迅速在脑海里构建出状态树并设计出剪枝策略时你就真正掌握了它。在国赛的考场上这种扎实的内功会让你在面对任何变种题时都心中有底。