ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

算法工程师必备:子集、全排列、DFS与BFS四大穷举范式详解

算法工程师必备:子集、全排列、DFS与BFS四大穷举范式详解 1. 从“暴力美学”到“高效穷举”算法工程师的必备思维在算法面试和日常开发中我们常常会遇到一类问题给定一个有限的集合我们需要找出所有满足特定条件的组合、排列或路径。比如从一堆数字里找出所有和为特定值的子集或者为一组任务安排所有可能的执行顺序。新手面对这类问题第一反应往往是“这该怎么下手”而有经验的开发者则会立刻想到一个词穷举。很多人对“穷举”有误解认为它等同于“暴力破解”是效率低下、缺乏技巧的代名词。但事实恰恰相反系统化、结构化的穷举是解决许多复杂问题的基石更是理解回溯、搜索等高级算法思想的必经之路。一个合格的算法工程师必须熟练掌握几种核心的穷举范式子集遍历、全排列、广度优先搜索BFS和深度优先搜索DFS。它们不是孤立的技巧而是一套完整的工具箱用于应对不同特征的解空间。今天我们就抛开理论教科书式的描述直接从Java代码实现和实战场景出发深入这四种穷举算法的骨髓。我会结合自己踩过的坑和优化心得带你理解每一种方法背后的“为什么”——为什么用递归而不用迭代为什么这里用BFS而那里用DFS状态回溯时到底该注意什么我们将用最“说人话”的方式把看似枯燥的算法变成你手中解决实际问题的利器。无论你是正在备战面试还是工作中遇到了需要枚举所有可能性的场景这篇文章都能给你提供可直接“抄作业”的代码模板和避坑指南。2. 穷举的核心理解状态空间树在深入具体算法之前我们必须建立一个核心心智模型状态空间树。这是理解所有穷举算法的钥匙。所谓“状态”就是在解决问题过程中的某个瞬间所有相关变量的一个快照。而“状态空间树”则形象地描绘了从初始状态开始经过所有可能的决策最终到达所有可能终态的一棵树。举个例子假设我们有数组[1, 2, 3]要找出所有子集。初始状态一个空列表[]表示尚未选择任何元素。决策对于数组中的每个元素我们有两个选择“选它”或者“不选它”。状态变化每做一个选择我们就从一个状态父节点转移到新的状态子节点。这棵状态空间树会长成这样省略部分分支[] / \ [1] [] / \ / \ [1,2] [1] [2] [] ... ... ... ...树的根节点是初始状态每一个叶子节点都代表一个完整的子集一个可能的解。穷举算法本质上就是系统地遍历这棵状态空间树上的所有节点或所有叶子节点的过程。不同的穷举算法对应了不同的遍历策略子集遍历/DFS通常沿着一条分支一路走到底得到一个完整子集再退回回溯探索其他分支。这对应了树的深度优先遍历。全排列可以看作是对一个“选择列表”进行深度优先遍历但每一步的选择会改变后续可选列表。BFS会先访问离根节点最近的所有节点即所有只包含一个元素的子集再访问下一层的节点。这对应了树的广度优先遍历。理解了你面对的问题可以建模成一棵树并且你需要在树上进行遍历那么选择哪种算法就清晰多了。接下来我们就带着这个模型逐一拆解四种经典实现。3. 子集遍历掌握回溯的“选与不选”范式子集问题是最经典的穷举场景之一。题目通常形如“给定一个不含重复元素的整数数组nums返回所有可能的子集幂集。” 解集不能包含重复的子集。3.1 递归回溯最直观的“决策树”模型我们直接上代码这是最核心的模板public ListListInteger subsets(int[] nums) { ListListInteger result new ArrayList(); ListInteger path new ArrayList(); // 记录当前路径当前子集 backtrack(nums, 0, path, result); return result; } private void backtrack(int[] nums, int startIndex, ListInteger path, ListListInteger result) { // 1. 收集结果树的每一个节点都是一个子集 result.add(new ArrayList(path)); // 注意必须新建一个列表 // 2. 遍历选择列表从startIndex开始防止重复 for (int i startIndex; i nums.length; i) { // 做出选择 path.add(nums[i]); // 递归进入下一层决策树注意i1表示下一个选择的起点 backtrack(nums, i 1, path, result); // 撤销选择回溯 path.remove(path.size() - 1); } }为什么这样写—— 核心逻辑拆解startIndex参数是关键它定义了本次递归中我们可以从原数组的哪个位置开始选择。为什么需要它为了避免生成重复的子集例如[1,2]和[2,1]在集合意义上被视为同一个。通过startIndex我们保证了元素的选择顺序总是从左到右的从而天然去重。在递归入口处收集结果result.add(new ArrayList(path));这行代码放在for循环之前。这意味着我们在进入每一个节点时都记录当前状态。对应到树上就是访问每个节点时都收集答案。因此我们收集了从根节点到所有节点的路径即所有子集。回溯操作path.remove(path.size() - 1)这是回溯算法的灵魂。在递归调用返回后我们必须将刚才加入path的元素移除以恢复状态这样才能在同层尝试下一个选择。忘记回溯是初学者最常见的错误会导致结果集混乱。实测中的坑与技巧深拷贝与浅拷贝的巨坑result.add(path)和result.add(new ArrayList(path))是天壤之别。前者添加的是path对象的引用而path在后续回溯中会被不断修改导致result中所有的列表最终都指向同一个空列表必须使用new ArrayList(path)创建当前路径的快照。时间复杂度与空间复杂度对于每个元素都有“选”或“不选”两种可能因此子集总数是2^nn为数组长度。递归栈深度为n。所以时间复杂度是O(n * 2^n)因为每个子集都需要O(n)时间复制到结果中空间复杂度是O(n)递归栈和路径存储。另一种思路位运算枚举对于小规模n比如n20可以用一个整数的二进制位来表示选择状态。从0到(1 n) - 1遍历每个数的二进制表示中为1的位就对应原数组中被选中的元素。这种方法没有递归开销代码简洁但可读性稍差且n较大时整数会溢出。// 位运算枚举子集 public ListListInteger subsetsBit(int[] nums) { ListListInteger result new ArrayList(); int n nums.length; int total 1 n; // 2^n for (int mask 0; mask total; mask) { ListInteger subset new ArrayList(); for (int i 0; i n; i) { if ((mask (1 i)) ! 0) { // 检查第i位是否为1 subset.add(nums[i]); } } result.add(subset); } return result; }4. 全排列体验“路径”与“选择列表”的舞蹈全排列问题要求我们列出所有可能的顺序。题目“给定一个不含重复数字的数组nums返回其所有可能的全排列。”4.1 回溯法使用used数组标记已选择元素全排列和子集的关键区别在于子集中[1,2]和[2,1]是同一个但排列中它们是两个不同的解。因此我们不能再用startIndex来限制选择顺序而是需要记录哪些元素已经被用过了在每一步从未使用的元素中挑选。public ListListInteger permute(int[] nums) { ListListInteger result new ArrayList(); ListInteger path new ArrayList(); boolean[] used new boolean[nums.length]; // 标记元素是否已在当前路径中 backtrack(nums, used, path, result); return result; } private void backtrack(int[] nums, boolean[] used, ListInteger path, ListListInteger result) { // 终止条件路径长度等于原数组长度说明一个排列已完成 if (path.size() nums.length) { result.add(new ArrayList(path)); // 深拷贝 return; } for (int i 0; i nums.length; i) { if (used[i]) { continue; // 跳过已使用的元素 } // 做出选择 used[i] true; path.add(nums[i]); // 递归 backtrack(nums, used, path, result); // 撤销选择回溯 path.remove(path.size() - 1); used[i] false; // 注意used数组也需要回溯 } }为什么这样写—— 与子集遍历的对比终止条件明确只有当path的长度等于输入数组长度时才构成一个完整的排列此时才将结果加入result。这与子集问题每个节点都是解不同。used数组替代startIndex因为每个元素在每个排列中只能使用一次我们需要一个额外的数据结构来记录元素的使用情况。used[i]为true表示nums[i]已经在当前路径中。used数组也必须回溯这是另一个容易遗漏的点。在递归返回后除了要将元素从path中移除还必须将对应的used[i]重置为false否则该元素在后续分支中就无法被使用了。处理含重复元素的全排列如果数组包含重复元素如[1,1,2]上面的代码会产生重复的排列如两个[1,1,2]。为了解决这个问题我们需要在递归树的同一层进行“剪枝”。核心思想是在遍历选择时对于重复的数字我们只允许它被“第一次”使用。实现上通常先对数组排序然后在循环中添加判断if (used[i]) continue; // 剪枝条件当前元素与前一个元素相同且前一个元素未被使用说明是同一层的重复 if (i 0 nums[i] nums[i - 1] !used[i - 1]) { continue; }这里的判断!used[i-1]是关键。它意味着当遇到重复元素时如果前面的相同元素还没有被使用即used[i-1] false那么当前这个重复元素就不能被使用。这样可以保证在树的同一层相同的数字只会被选取一次从而避免生成重复的排列。如果used[i-1] true说明这个重复元素是在更深的递归层被使用的这是允许的例如路径中已经选了第一个1现在选第二个1。5. 深度优先搜索一条道走到黑的探索者DFS通常用于在图或树这种结构中系统地探索所有可能的顶点或路径。它的名字就揭示了其策略尽可能深地搜索图的分支当一条路径被完全探索后再回溯到上一个分叉点。5.1 递归实现最符合思维直觉的方式我们以经典的“二叉树的所有路径”问题为例但将其思想推广到更一般的图。假设我们有一个无向图用邻接表表示从某个节点开始进行DFS遍历。// 图节点的简单定义 class GraphNode { int val; ListGraphNode neighbors; GraphNode(int x) { val x; neighbors new ArrayList(); } } public void dfsRecursive(GraphNode node, SetGraphNode visited) { if (node null || visited.contains(node)) { return; // 基线条件节点为空或已访问 } // 处理当前节点例如打印 System.out.println(node.val); visited.add(node); // 标记已访问 // 递归访问所有邻居 for (GraphNode neighbor : node.neighbors) { dfsRecursive(neighbor, visited); } // 注意对于简单的遍历这里没有显式的“撤销访问”操作 // 因为visited记录的是全局访问状态我们不需要回溯到重复访问节点的状态。 }为什么需要visited集合在图尤其是带环的图中进行DFS如果不记录已访问的节点递归会沿着环无限进行下去最终导致栈溢出。visited集合确保了每个节点只被处理一次。5.2 显式栈实现递归的等价转换递归的本质就是函数调用栈。我们可以用一个显式的Stack来模拟这个过程这对于理解DFS的运作机制和应对深度递归可能导致的栈溢出问题很有帮助。public void dfsIterative(GraphNode start) { if (start null) return; SetGraphNode visited new HashSet(); StackGraphNode stack new Stack(); stack.push(start); visited.add(start); while (!stack.isEmpty()) { GraphNode node stack.pop(); System.out.println(node.val); // 处理节点 // 将邻居压入栈中。注意顺序为了和递归顺序一致先处理第一个邻居 // 可能需要逆序压栈这取决于具体需求。 for (GraphNode neighbor : node.neighbors) { if (!visited.contains(neighbor)) { visited.add(neighbor); stack.push(neighbor); } } } }递归DFS vs 迭代DFS栈递归代码简洁思维直观符合“深度优先”的自然描述。但深度过大会导致栈溢出。迭代栈手动管理栈避免了递归的栈溢出风险并且有时能更灵活地控制流程。但代码稍显复杂。选择在算法竞赛或工程中如果问题深度可控用递归更省心。如果图非常深或者需要精确控制遍历过程则用迭代栈。5.3 DFS在回溯问题中的本质回过头看子集和排列的回溯算法其实就是一种特殊的DFS它遍历的是一棵隐式的状态空间树。path变量记录了从根节点到当前节点的路径backtrack函数中的递归调用就是在深入下一层而for循环和回溯操作则是在探索同一层的其他分支。所以当你写回溯代码时心里要有一棵清晰的树并且明白你正在对其进行深度优先遍历。6. 广度优先搜索层层递进的剥洋葱法BFS采用与DFS截然不同的策略它先访问起始节点的所有直接邻居然后再访问邻居的邻居以此类推。这种“由近及远”的探索方式天然适合求解最短路径、最小步数等问题。6.1 队列实现标准模板BFS几乎总是使用队列Queue来实现。我们以在二维网格中寻找从起点到终点的最短路径为例假设网格可通行每次可向上下左右四个方向移动一格。public int bfsShortestPath(int[][] grid, int[] start, int[] end) { int rows grid.length, cols grid[0].length; if (grid[start[0]][start[1]] 1 || grid[end[0]][end[1]] 1) return -1; // 起点或终点是障碍 int[][] directions {{1,0}, {-1,0}, {0,1}, {0,-1}}; boolean[][] visited new boolean[rows][cols]; Queueint[] queue new LinkedList(); queue.offer(start); visited[start[0]][start[1]] true; int steps 0; while (!queue.isEmpty()) { int size queue.size(); // 关键记录当前层的节点数 for (int i 0; i size; i) { // 处理当前层的所有节点 int[] current queue.poll(); if (current[0] end[0] current[1] end[1]) { return steps; // 找到终点返回步数 } // 探索四个方向 for (int[] dir : directions) { int newRow current[0] dir[0]; int newCol current[1] dir[1]; // 检查边界、是否可通行、是否已访问 if (newRow 0 newRow rows newCol 0 newCol cols grid[newRow][newCol] 0 !visited[newRow][newCol]) { visited[newRow][newCol] true; queue.offer(new int[]{newRow, newCol}); } } } steps; // 当前层所有节点处理完毕步数加一 } return -1; // 队列为空仍未找到终点 }为什么BFS能找到最短路径因为BFS是按“层”遍历的。当第一次访问到目标节点时所经历的层数即steps必然是从起点到该节点的最短距离假设边权为1。任何其他路径如果想更短它必须在更早的层就被访问到这与BFS“先访问距离近的节点”的性质矛盾。代码中的关键细节int size queue.size()这行至关重要它锁定了当前“层”的节点数量。内层for循环确保这些节点被全部处理完后steps才增加。如果不这样做steps就无法准确代表从起点到当前节点的距离。visited标记的时机在将邻居节点加入队列时立即标记为已访问。这是为了防止同一个节点被多次加入队列导致效率降低甚至死循环。想象一下A和B互为邻居A将B入队如果不标记B出队后又会将A入队。队列的选择使用LinkedList作为Queue的实现即可。6.2 BFS与DFS的应用场景对比选择BFS还是DFS往往取决于问题的需求特性广度优先搜索深度优先搜索数据结构队列 (Queue)栈 (Stack) / 递归解的特点找到的第一个解往往是最短路径边权相同。不一定能找到最短路径可能很快找到一个解也可能很久。空间复杂度最坏情况需存储一整层的节点对于分支因子为b的树空间复杂度为 O(b^d)d为深度。取决于递归深度或栈深空间复杂度为 O(d)。经典应用无权图最短路径、扩散问题如腐烂的橘子、层次遍历。拓扑排序、检测环、寻找所有解回溯、路径记录、解决迷宫只需找到一条路。思维模型“剥洋葱”一圈一圈扩大。“走迷宫”碰到死胡同就回头。简单来说求最短、最少、最近用BFS求所有解、判断连通性、记忆化搜索用DFS回溯。7. 实战融合从算法模板到解题思路掌握了这四种范式很多LeetCode上的中等难度题目就变成了模板题。关键在于如何将问题转化和建模。案例电话号码的字母组合题目给定一个仅包含数字 2-9 的字符串返回所有它能表示的字母组合数字到字母的映射如手机九宫格。分析建模每个数字对应3-4个字母。我们需要从每个数字对应的字母集合中选取一个然后将所有选取的字母连起来。这本质上是在多个集合上进行笛卡尔积运算。对应穷举范式这可以看作遍历一棵树。树的深度是输入字符串的长度每个节点的分支是对应数字的字母个数。我们需要收集所有从根到叶子的路径。这是典型的DFS/回溯问题。实现使用回溯框架。path记录当前组合index表示当前处理到输入字符串的第几位。class Solution { private static final String[] LETTER_MAP { , , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz }; public ListString letterCombinations(String digits) { ListString result new ArrayList(); if (digits null || digits.length() 0) return result; backtrack(digits, 0, new StringBuilder(), result); return result; } private void backtrack(String digits, int index, StringBuilder path, ListString result) { if (index digits.length()) { result.add(path.toString()); return; } char digit digits.charAt(index); String letters LETTER_MAP[digit - 0]; for (char ch : letters.toCharArray()) { path.append(ch); // 选择 backtrack(digits, index 1, path, result); // 递归 path.deleteCharAt(path.length() - 1); // 回溯 } } }案例岛屿数量题目给你一个由 1陆地和 0水组成的二维网格请你计算网格中岛屿的数量。岛屿被水包围并且通过水平或垂直方向相连。分析建模网格可以看作一个图每个单元格是一个节点上下左右相邻的陆地节点之间有边。问题转化为求图中连通分量的个数。对应穷举范式遍历所有单元格。当遇到一个未被访问的 1陆地就从这个点开始搜索并标记所有与其连通的陆地。这个搜索过程可以用DFS或BFS。每启动一次新的搜索就发现了一个新的岛屿连通分量。实现选择这里搜索的目的是标记不涉及路径记录且图不大用递归DFS代码更简洁。class Solution { public int numIslands(char[][] grid) { if (grid null || grid.length 0) return 0; int count 0; int rows grid.length, cols grid[0].length; for (int i 0; i rows; i) { for (int j 0; j cols; j) { if (grid[i][j] 1) { count; dfsMarking(grid, i, j); // 将整个岛屿标记掉 } } } return count; } private void dfsMarking(char[][] grid, int r, int c) { int rows grid.length, cols grid[0].length; if (r 0 || c 0 || r rows || c cols || grid[r][c] ! 1) { return; } grid[r][c] 0; // 标记为已访问相当于 visited 功能 // 递归搜索四个方向 dfsMarking(grid, r - 1, c); dfsMarking(grid, r 1, c); dfsMarking(grid, r, c - 1); dfsMarking(grid, r, c 1); } }个人踩坑心得在解决这类网格DFS/BFS问题时我习惯将“标记已访问”和“判断越界/合法性”写在递归函数或BFS循环的最开头作为一个统一的“守卫语句”。这样逻辑清晰避免在多个方向调用前重复写判断条件。另外对于只是标记连通性的问题直接修改原数组如将 1 改为 0比使用额外的visited数组更节省空间但前提是允许修改输入数据。8. 性能优化与常见陷阱即使理解了模板实际应用中仍会遇到性能瓶颈和隐蔽的Bug。陷阱一递归深度过大对于深度可能很大的树或图例如一条长长的链表递归DFS可能导致StackOverflowError。对策改用显式栈的迭代DFS。或者如果问题允许尝试使用BFS。Java虚拟机参数在生产环境中可以通过-Xss参数调整线程栈大小但这只是权宜之计根本在于算法选择。陷阱二重复计算与记忆化在一些搜索问题中如带权图的最短路径、某些动态规划问题单纯的DFS/BFS会重复访问大量相同状态导致指数级复杂度。对策引入记忆化搜索。用一个缓存如HashMap存储已经计算过的状态状态 - 结果。在递归开始时先查缓存如果存在则直接返回在递归结束时将结果存入缓存。这本质上是DFS动态规划的结合。陷阱三状态回溯的遗漏这是回溯算法中最常见的错误尤其是在处理复杂状态时不止一个path列表。检查清单对于递归函数中所有发生变化的共享状态如path,used, 修改的全局数组等在递归调用返回后必须恢复其原状。养成“对称”编程的习惯add之后必有removesetTrue之后必有setFalse。陷阱四BFS中丢失层次信息如果不使用size queue.size()来区分层就无法准确计算步数或距离。在需要按层处理结果的问题中如二叉树的层序遍历这个技巧是必须的。性能优化技巧剪枝在回溯或搜索中如果能在深入之前就判断出当前分支不可能产生有效解则立即返回。这能极大减少搜索空间。例如在组合总和问题中如果当前和已经超过目标值就可以提前结束递归。双向BFS当起点和终点都已知且搜索空间很大时可以从起点和终点同时开始BFS。当两个搜索相遇时路径即被找到。这通常能将搜索范围从 O(b^d) 降低到 O(b^(d/2))其中b是分支因子d是深度。迭代加深搜索结合了DFS空间占用小和BFS能找到最短路径的优点。它按深度限制进行DFS先深度为1搜索没有找到则深度为2搜索依次增加。适用于搜索树很深但答案深度较浅且状态空间巨大的情况如某些棋类游戏。写算法代码尤其是穷举类最忌讳的就是死记硬背模板。我自己的习惯是先在白纸上画出问题的状态空间树想清楚节点是什么、边是什么、终止条件是什么。然后问自己我需要所有解还是一个解需要最短路径吗答案大概在树的浅层还是深层回答完这些问题该用DFS回溯还是BFS甚至是否需要剪枝、记忆化思路就自然清晰了。把这些基础范式内化成自己的思考方式再遇到新问题你就能快速拆解组合出合适的解决方案了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进