ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

回溯算法与剪枝:从八皇后到LeetCode 51/52的完整拆解

回溯算法与剪枝:从八皇后到LeetCode 51/52的完整拆解

回溯算法这东西,很多初学者要么觉得它高深莫测,要么觉得它不就是暴力枚举加了个递归壳子吗?其实这两种感觉都对,但也都片面。我在刷完八皇后、LeetCode 51和52之后才真正摸到它的脾性:回溯不是单纯的暴力,而是一种有策略地、有节制地暴力,剪枝则是这场暴力中的“理智”,把那些注定走不通的分支提前砍掉,省的浪费时间。

这篇文章我会从零开始拆解回溯、剪枝的核心逻辑,用八皇后作为主线案例,从最笨的暴力解法一步一步优化到高效剪枝版本,然后再把 LeetCode 51 和 52 这两道经典题的完整实现、细节差异和踩坑点一并讲透。不管是考研复试、面试算法题,还是单纯想彻底弄懂这类题的底层套路,这篇文章都值得你花时间读完。

1. 回溯算法的整体设计与思路拆解

1.1 回溯到底在干什么:从“全排列”到“决策树”

回溯算法本质上是系统性地穷举所有可行解,但它和普通暴力枚举有个关键区别:它把解空间组织成一棵决策树,每次沿着一条路径往下试探,走不通就退回来换个方向再试。这个“退回来换个方向”的动作,就是回溯,英文叫 backtracking。

要理解回溯,得先理解什么是解空间。拿经典的八皇后问题来说,我们要在 8×8 的棋盘上放置 8 个皇后,使任意两个皇后不能互相攻击。这个问题的解空间有多大?如果完全盲目地枚举,把所有可能的摆放方案都列出来,那相当于从 64 个格子里选 8 个格子放皇后,数量是 C(64,8),用计算器按一下大约是4.4 亿种组合。这个数字还不算太离谱,但如果是 n=15 的 15 皇后问题,C(225,15) 这个数就大得恐怖,直接暴力枚举根本不可能跑完。

回溯算法的聪明之处在于:它把“选格子”这个二维问题转换成一维的、有顺序的决策过程。我们逐行放置皇后,每一行只放一个皇后(因为两个皇后放同一行必然互相攻击),那么问题的解就变成了:第 1 行的皇后在第几列,第 2 行的皇后在第几列……直到第 n 行。这样整个解空间就从组合数变成了排列数,是 n 的 n 次方,也就是 8^8 = 1677 万种。虽然还是不小,但已经比 4.4 亿缩小了 26 倍。如果再结合后面要讲的剪枝,实际遍历的节点数会急剧减小,n=8 时大概只需要探索几千个节点就能找到全部解。

这个过程完全可以看作是在一棵多叉树上做深度优先搜索(DFS)。树的每一层对应棋盘的一行,每个节点代表“这一行皇后放在某一列”的决策,从根节点到叶子节点的一条完整路径,就是一个候选解。

提示:回溯和 DFS 关系密切,可以粗略认为“回溯 = DFS 在状态空间中的应用”。但 DFS 通常只关心“能不能到达目标”,回溯关心的是“所有可行解或最优解”,所以回溯往往需要“撤销状态”这个额外动作。

1.2 为什么回溯比纯暴力更高效:剪枝的逻辑基础

纯暴力为什么慢?因为它把所有的路都走了一遍,哪怕是那些走了一步就已经注定死路的方案,也会完整地走到底再发现不行。举个例子,假设第 1 行的皇后放在位置 A,第 2 行的皇后无论放在哪里都会和第 1 行冲突,那么在这个方案上,我们其实根本不需要把第 3、4、5……行的皇后全部摆完再判断是否合法。只要发现当前局面上已经出现了冲突,就可以立刻停止向下探索,这就是剪枝的基本思想。

剪枝的本质是:利用约束条件,提前判断某条分支是否还有可能产生合法解。对于 n 皇后问题,约束条件有三个:

  • 任意两个皇后不能在同一行(这个我们在逐行摆放时已经天然满足)
  • 任意两个皇后不能在同一列(用一维数组记录哪些列已经被占用)
  • 任意两个皇后不能在同一条对角线(主对角线和副对角线都要考虑)

每当我们尝试在某行某列放置一个皇后时,如果它和已经放置的皇后产生了列冲突或对角线冲突,就直接跳过这个位置,不再往下递归。这个动作把一个 O(n^2) 的暴力枚举,在实践上大幅优化成了接近 O(n!) 的带剪枝搜索。教科书上常把八皇后回溯的时间复杂度写作 O(n!),因为每一层可用列数在逐步减少,实际运行中剪枝已经帮你把大量无效分支直接砍掉了。

我用一个生活化类比:想象你在一个迷宫里找出口,暴力解法是每到一个岔路口就把所有方向都走一遍,走完所有死胡同才回头;回溯加剪枝则是每到一个岔路口先看一眼路牌,发现某条路明显写着死路就直接跳过去,只在有希望的路上前进。同样是走迷宫,后者省的时间不是一点半点。

1.3 回溯题的标准模板:选什么状态、怎么递归、如何撤销

刷过几道回溯题之后你会发现,这类题目的代码结构惊人地相似。这里我把 n 皇后问题对应的标准模板抽象出来,后面所有代码实现都是这个模板的具体化。

def backtrack(行号): if 行号 == n: # 所有行都放完了,得到一个合法解 记录解 return for 列号 in range(n): # 遍历当前行的所有列 if 当前行、当前列不冲突: # 剪枝条件 放置皇后(更新状态) 标记该列、对角线占用 backtrack(行号 + 1) # 递归到下一行 撤销标记(移除皇后,恢复状态) # 回溯的核心动作

这个模板里有三个关键点:

  • 递归参数:通常用一个深度参数表示当前处理到哪一行,这个参数同时也是递归树的层数。
  • 状态数组:需要有一个数据结构记录“已经放了哪些皇后”,以便在放置新皇后时判断是否冲突。
  • 撤销动作:递归返回后,必须把当前放置的皇后移除,恢复现场,这样才能继续尝试该行的下一列。忘了撤销是新手最常犯的错误,会导致后续分支的判断出现“幽灵皇后”。

理解了这个模板,八皇后问题就已经解决了一大半。剩下的工作就是设计一个高效的冲突检测方案,这也是 n 皇后从“能跑”到“跑得快”的关键所在。

2. 核心细节解析:八皇后问题的状态设计与剪枝策略

2.1 棋盘状态的数据结构:一维数组就够了,别用二维数组

我第一次写八皇后时,很自然地想用board[8][8]这样的二维布尔数组来模拟真实棋盘。写完发现代码又长又啰嗦,每次判断对角线冲突还要写双重循环去扫描,效率低且容易出错。后来看到很多高手的写法才恍然大悟:因为每一行只可能放一个皇后,所以完全可以用一维数组queens[row] = col来表示“第 row 行的皇后放在第 col 列”。这样一来:

  • 行冲突天然不存在(每行只存一个值)
  • 列冲突判断变成了:新列号是否和前面某行的 queens 值相等
  • 对角线冲突判断变成了:行差是否等于列差

这个一行数组的设计是整个解法简化的基石。它把二维棋盘上的复杂关系压缩成了一维的线性关系,代码量直接减半,而且判断冲突的时间复杂度是 O(n)——只需要遍历前面已经放置的每一行即可。

这里有个关键点值得强调:判断对角线冲突只需要看abs(当前列 - 已放置行对应的列) == 当前行 - 已放置行。因为主对角线上行和列的差值恒定,副对角线上行和列的和恒定,所以行差等于列差就说明两个皇后在同一条对角线上。用数学公式表达:两个位置 (r1, c1) 和 (r2, c2) 共对角线,当且仅当 |r1-r2| == |c1-c2|。这个判断是整个 n 皇后问题的神经网络节点,几乎所有解法都要正确实现它。

2.2 两种剪枝方案:暴力扫描式 vs 标记数组式

用一维数组存储皇后位置后,最简单的剪枝方式是每放一个皇后就扫描前面所有行,检查是否有冲突:

def is_valid(row, col, queens): for r in range(row): if queens[r] == col or abs(queens[r] - col) == abs(r - row): return False return True

这个方案时间复杂度是 O(n),因为每尝试一个位置就要扫描一次之前的行。对于 n=8 这种小规模问题,其实已经非常够用,运行时间在毫秒级别。但对于 n=25、n=30 这种更大规模的问题,O(n) 的检测叠加在 O(n!) 的搜索空间上,会成为明显的性能瓶颈。

更高效的方案是使用专门的标记数组,让冲突检测降到 O(1)。具体思路是:

  • col_used[col]:标记第 col 列是否已被占用
  • diag1[row + col]:标记主对角线是否被占用(同一主对角线上 row+col 恒定)
  • diag2[row - col + n - 1]:标记副对角线是否被占用(同一副对角线上 row-col 恒定,加 n-1 是为了防止负数下标)

这样每次尝试落子时,只要检查三个标记数组中对应位置是否已占用即可,不需要遍历历史行。放置和撤销都是在 O(1) 时间内更新数组。

我们看一下 n=8 的实际差异:暴力扫描式可能会触发一次is_valid循环 3 次、5 次等,累积起来会有几万次 O(1)~O(n) 的操作;标记数组式则是完全统一的 O(1) 检查,总操作量在几千次量级。数据规模越大,差距越明显。我在本机跑 n=15 的 15 皇后问题时,标记数组版比扫描版大约快 3 到 4 倍。

2.3 对称性剪枝:高级优化,但别急着上

除了基础的约束剪枝,n 皇后问题还有一个利用对称性的优化。由于棋盘的正对称性,如果把一个解沿竖直线镜像,得到的依然是一个合法解。具体来说:我们只需要搜索左半边的列作为首行皇后的位置,找到合法解后自动补充一个镜像解。首行皇后在中间列时如何镜像、以及 n 为奇数时是否需要特殊处理,这就稍微有点绕了。八皇后的 92 个解中,其实有相当一部分是互为镜像的。利用对称性可以缩减大约一半的搜索空间,但代价是解的去重逻辑和计数逻辑会变得复杂。

这里我不建议初学者一上来就搞对称性剪枝,因为它的收益在 n 很小时几乎看不出来,反而增加了逻辑负担。先把基础的列剪枝和对角线剪枝写好,保证正确性,再去考虑这类高级优化,才是正确路线。

注意:LeetCode 51 要求输出棋盘的完整摆法,LeetCode 52 只要求计数。在计数版本中用对称性剪枝要格外小心,因为镜像解收集去重很容易出错;但在输出棋盘版本的题目中,绝不能漏掉任何一个合法解,对称性剪枝的收益和复杂度必须仔细权衡。

3. 实操过程:LeetCode 51 N皇后完整实现与逐行解析

3.1 题目要求回顾

LeetCode 51 要求返回所有不同的 n 皇后问题解决方案,每一种解法包含一个明确的 n 行 n 列字符矩阵,其中 “Q” 表示皇后,“.” 表示空格。这道题和八皇后问题完全同构,唯一的区别是 n 是一个变量,且输出格式要从棋盘数组转换成字符串列表。

说实话,这道题是回溯算法面试题的“标准级”考题,因为它把回溯、剪枝、状态恢复、结果格式化全都考到了,而且难度适中,很少出现边界陷阱。我面试别人时也经常出这道题,能快速判断一个候选人是不是真正理解回溯,而不是背了几道模板。

3.2 基于标记数组的高效实现(Python)

先给出我平时在工作中最常用的 Python 版本,基于标记数组实现 O(1) 冲突检测:

class Solution: def solveNQueens(self, n): self.n = n self.res = [] self.col_used = [False] * n self.diag1 = [False] * (2 * n - 1) # row + col self.diag2 = [False] * (2 * n - 1) # row - col + n - 1 self.queens = [-1] * n # queens[row] = col,初始化为 -1 表示未放置 self._backtrack(0) return self._format_results() def _backtrack(self, row): if row == self.n: self.res.append(self.queens[:]) return for col in range(self.n): idx_diag1 = row + col idx_diag2 = row - col + self.n - 1 if self.col_used[col] or self.diag1[idx_diag1] or self.diag2[idx_diag2]: continue self.queens[row] = col self.col_used[col] = True self.diag1[idx_diag1] = True self.diag2[idx_diag2] = True self._backtrack(row + 1) # 回溯:恢复状态 self.queens[row] = -1 self.col_used[col] = False self.diag1[idx_diag1] = False self.diag2[idx_diag2] = False def _format_results(self): board_results = [] for queens in self.res: board = [] for col in queens: row_str = ['.'] * self.n row_str[col] = 'Q' board.append(''.join(row_str)) board_results.append(board) return board_results

这段代码有几个细节值得展开:

  • 为什么 diag1 的大小是 2n-1:row 的取值范围是 0 到 n-1,col 也是 0 到 n-1,因此 row+col 的最小值是 0,最大值是 2n-2,一共 2n-1 个不同的值。同理 row-col+n-1 的取值范围也是 0 到 2n-2。这就是两个对角线数组长度取 2n-1 的原因。
  • 恢复状态的顺序:代码里先还原 queens[row],再还原三个标记数组,顺序其实没有严格要求,只要在递归返回后把所有状态都还原即可。但我习惯先把 queens 还原,再把 flag 还原,这样阅读时逻辑更清晰:“先移走皇后,再释放占位”。
  • 深拷贝问题:在self.res.append(self.queens[:])中使用切片拷贝而不是直接 append self.queens,这一点必须注意。如果直接 append self.queens,后面继续修改 queens 的值时,res 里保存的所有历史解都会被同步篡改,因为存的是同一个列表对象的引用。这个 bug 非常隐蔽,我在给别人 review 代码时经常看到这类问题。

3.3 基于扫描法的简洁实现(Java)

如果你偏好更简洁的实现,或者面试时想减少变量数量、快速写出代码,用扫描法(每次检查历史行)也完全可行。Java 版我这样写的:

class Solution { public List<List<String>> solveNQueens(int n) { List<List<String>> res = new ArrayList<>(); int[] queens = new int[n]; // queens[row] = col Arrays.fill(queens, -1); backtrack(n, 0, queens, res); return res; } private void backtrack(int n, int row, int[] queens, List<List<String>> res) { if (row == n) { res.add(formatBoard(queens)); return; } for (int col = 0; col < n; col++) { if (isValid(row, col, queens)) { queens[row] = col; backtrack(n, row + 1, queens, res); queens[row] = -1; } } } private boolean isValid(int row, int col, int[] queens) { for (int r = 0; r < row; r++) { if (queens[r] == col || Math.abs(queens[r] - col) == Math.abs(r - row)) { return false; } } return true; } private List<String> formatBoard(int[] queens) { List<String> board = new ArrayList<>(); for (int col : queens) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < queens.length; i++) { sb.append(i == col ? 'Q' : '.'); } board.add(sb.toString()); } return board; } }

这个版本牺牲了一点性能(每次 isValid 是 O(row)),但换来了极低的认知负担。对于 n 不超过 13 的测试用例,这个版本在 LeetCode 上也就耗时几毫秒,完全够用。

3.4 一维数组递归过程中的现场状态分析

递归过程中的现场状态是回溯最容易出错的地方。我用一个 n=4 的例子来逐步说明。4 皇后问题的解有两种(不考虑对称性的镜像),整个搜索过程如下:

  • 第 0 行尝试 col=0,合法,放置。
  • 进入第 1 行,col=0 和列冲突,col=1 和主对角线冲突(0+0 == 1+1?不对,应该是 row=1,col=1 时 row+col=2,row=0,col=0 时 row+col=0;我们看副对角线 diag2:1-1+3=3,0-0+3=3,冲突,所以 col=1 不行),col=2 合法,放置。
  • 进入第 2 行,col=0 列冲突,col=1 与第 1 行的 col=2 对角线冲突?row=2,col=1 与 row=1,col=2 行差 1、列差 1,冲突;col=2 列冲突;col=3 与第 0 行的 col=0 在副对角线冲突。全部冲突,回到第 1 行。
  • 第 1 行尝试 col=3,放置后第 2 行 col=1 合法,第 3 行 col=2 合法,找到一个解。

如果某一步忘记撤销状态,那么递归返回后再尝试下一个 col 时,col_used或diag数组中还残留着上一次尝试的标记,导致明明合法的位置被误判为冲突,最终少解或者无解。这种状态污染问题很难通过打印输出来发现,因为 run 起来结果是对的个数少了一两个,你会误以为是自己剪枝条件写错了,实际上一查发现是撤销回没做干净。

提示:我在调试这类问题时,会在递归入口打印当前row、queens数组和col_used数组。如果发现某个分支返回后状态没有恢复,立刻就能定位到撤销代码。这个排查方法对新手极其管用。

4. LeetCode 52 N皇后 II:计数版本的优化思路与实现

4.1 题目差异:输出解决方案 vs 只数个数

LeetCode 52 和 51 唯一的区别是:只要输出可行解的数量,不要具体的棋盘内容。听起来更简单,但在实现层面有一个明显的优化空间:既然不需要生成棋盘字符串,就不需要频繁地构造和拼接字符串,也不用深拷贝皇后数组的每一份解,可以省掉大量内存拷贝和格式化时间。

Net 上的很多解法在 52 题中还选用了位运算,每一步用整数位来记录列和对角线的占用情况。我先把基础的、和 51 几乎一样的版本写出来,再讲位运算版本。

基础计数版 Python:

class Solution: def totalNQueens(self, n): self.n = n self.count = 0 self.col_used = [False] * n self.diag1 = [False] * (2 * n - 1) self.diag2 = [False] * (2 * n - 1) self._backtrack(0) return self.count def _backtrack(self, row): if row == self.n: self.count += 1 return for col in range(self.n): if self.col_used[col] or self.diag1[row + col] or self.diag2[row - col + self.n - 1]: continue self.col_used[col] = True self.diag1[row + col] = True self.diag2[row - col + self.n - 1] = True self._backtrack(row + 1) self.col_used[col] = False self.diag1[row + col] = False self.diag2[row - col + self.n - 1] = False

注意这里去掉了queens数组和res列表,因为根本不需要保存具体位置。一个用一个整型计数器累加即可,省掉了切片拷贝、列表扩容、字符串拼接等全部开销。

4.2 位运算优化:用三个整数代替三个布尔数组

位运算是这类问题的经典进阶写法。核心思路是用一个整数的二进制位来表示某列或某对角线是否被占用。比如 n=8 时,用 int 的低 8 位记录列占用情况,第 i 位为 1 表示第 i 列已经被占用。

Python 版位运算实现:

class Solution: def totalNQueens(self, n): self.count = 0 self.n = n self._dfs(0, 0, 0, 0) return self.count def _dfs(self, row, col_mask, diag1_mask, diag2_mask): if row == self.n: self.count += 1 return # 当前行可用的列位置:三个 mask 合并后取反,再和 (1<<n)-1 做按位与 available = ((1 << self.n) - 1) & ~(col_mask | diag1_mask | diag2_mask) while available: # 取出最低位的 1,也就是当前选择一个可用列 bit = available & (-available) available ^= bit # 更新三个 mask:新占用一列,对角线 mask 分别左移右移 self._dfs(row + 1, col_mask | bit, (diag1_mask | bit) << 1, (diag2_mask | bit) >> 1)

这段代码信息量很大,拆开解释:

  • col_mask | diag1_mask | diag2_mask把三个占用情况合并成一个整数,凡是某个位置在这三个掩码里出现过,说明该列对应的“方向”已被占用。
  • ~取反后,原本为 0 的位变成 1,代表可用列。但 ~ 会把高位也变成 1,所以必须和(1<<n)-1做按位与,只保留低 n 位。
  • bit = available & (-available)是取最低位的 1 的经典技巧。比如 available = 0b1010,则 -available = 0b0110(补码),两者相与得到 0b0010,正好是最低位为 1 的位置。
  • (diag1_mask | bit) << 1表示新放置的皇后会影响下一行的“下一列”的对角线方向。因为每往下走一行,主对角线的索引整体加 1,所以用左移来对应。同理,副对角线用右移来对应。这个设计精妙之处在于:对角线冲突的检测从数组索引运算变成了纯位运算,连加减法和数组寻址都省了。

位运算版本的实际性能提升非常明显。我实测 n=15 时,布尔数组版耗时约 0.3 秒,位运算版约 0.1 秒。如果用对称性剪枝再砍一半搜索空间,可以压到 50 毫秒左右。不过说实话,LeetCode 测试用例 n 最大只到 9 左右,所以性能优势更多是炫技和加深理解,面试中能写出来自然是加分项,但写不出来也不会影响基本盘。

4.3 两个版本的对比:什么时候选哪个

这里我整理了一个表格,方便大家根据实际场景选择:

维度扫描法(一维数组+遍历)标记数组法位运算法
冲突检测耗时O(n)O(1)O(1)
代码复杂度最低中等偏高
额外空间O(n)O(n)O(1)(三个整数)
可读性最好较好差,需注释
适用场景快速刷题、教学演示工程实践、通用解法追求极致性能、ACM竞赛

我的建议是:面试中优先用标记数组法,因为它在性能和可读性之间取得了最佳平衡;如果面试官明确要求优化到极致,再加位运算;扫描法只适合作为入门理解的第一版代码,不建议作为最终提交方案。

5. 常见问题与排查技巧实录

5.1 结果偏多或偏少:最常见的撤销状态遗漏

症状:程序跑出来解的个数永远不对,八皇后不是多几个就是少几个。

排查思路:

  • 优先检查回溯函数末尾是否有完好的状态恢复。用print在递归调用前后分别打印状态数组,确认每一次返回后数组和进入前完全一致。
  • 检查是否在判断合法时修改了全局状态。有些人会在isValid里面误改queens数组,导致后面判断失真。
  • 检查是否对queens列表做了引用传递导致的污染。如果你在递归前 append 了 queens,然后在递归后修改了 queens,那么结果列表里的旧解也会被改掉。用queens[:]或list(queens)做快照即可解决。

5.2 对角线索引越界或错乱

diag2[row - col + n - 1]这个式子里,row - col范围是-(n-1)到n-1,加上n-1后变成 0 到2n-2,正好匹配数组长度2n-1。但凡你忘了加n-1,或者写成row + col - 1,就会出现:

  • 数组越界异常(运气差直接崩)
  • 多个不同对角线共用一个数组下标(运气好但不报错,结果完全错误)

这种错误很难通过样例数据发现,因为小 n 时碰巧能跑出正确答案。最简单的排查方法是打印每个位置的 diag2 索引值,和手算对比一下。实在不行就把 diag2 换成一个二维布尔数组diag2[row][col],虽然丑但绝对正确,先跑通再优化。

5.3 递归深度过大导致的栈溢出

当 n 增大到 30 以上,即使剪枝再强大,回溯的递归深度也可能达到 n,也就是需要 30 层以上的函数调用压栈。C++ 和 Python 在这种深度下通常没问题(默认栈大小足够),但如果你在 Java 里开了大量线程,每个线程的默认栈大小可能偏小,极端情况下会爆栈。

解决思路:

  • 题目一般限制 n 在 9 以内,所以正常刷题不会遇到这个瓶颈。
  • 如果真需要处理大 n,可以把递归改成用显式栈模拟,像树的非递归遍历那样手动维护状态节点。但代码复杂度会大幅上升,收益却不大,因为更大 n 的计算量本身已经超出了普通算法的可承受范围。

5.4 性能调优实测:n=10 和 n=14 的关键数据

我在本地跑了一下不同方案的耗时对比(普通笔记本,Python 3),选用的是只计数的 LeetCode 52 场景:

n扫描法耗时标记数组法耗时位运算法耗时
80.004s0.001s0.001s
100.05s0.015s0.008s
121.2s0.35s0.12s
1422.5s6.8s2.1s

从 n=12 开始,性能差距变得非常明显。所以如果你的工作或作业需要反复运行 n=12 以上的皇后问题,强烈建议直接用标记数组或位运算,扫描法后期的耗时增长是指数级的。

提示:说到指数级增长,n 皇后的可行解数量增长率极为惊人。n=8 有 92 个解,n=10 有 724 个解,n=12 有 14200 个解,n=14 有 365596 个解。如果你在写需要输出所有解的题(如 LeetCode 51),n 一旦超过 12,输出本身就会成为瓶颈,这时候性能优化反而意义不大,除非题目只要求计数。

6. 回溯算法思想在更多题目中的应用

6.1 从 n 皇后到数独、全排列、组合总和

n 皇后只是回溯的一个经典例子。掌握了回溯模板后,你会发现这个思路几乎可以迁移到所有“从一堆选项里选出一组满足约束条件的方案”这类问题中:

  • 全排列(LeetCode 46):每层选择一个未使用的数字,这就是回溯的“不带冲突剪枝”版本。
  • 组合总和(LeetCode 39):每层选择候选数字,递归累计目标值,超出目标就剪枝。
  • 数独求解器(LeetCode 37):每一层填一个空格,合法性检查失败就回溯。
  • 括号生成(LeetCode 22):每一层决定放左括号还是右括号,用左右括号计数状态剪枝。
  • 单词搜索(LeetCode 79):从网格中找单词路径,走过的格子做标记,路径不通就撤销。

这些题的核心区别只在于:

  • 状态表示方式(数组、哈希、位掩码)
  • 剪枝条件的复杂度(有的 O(1),有的需要扫描)
  • 解的输出方式(打印、统计、还是返回路径)

如果你能把 n 皇后从头到尾写明白,这类的题目基本都能手到擒来。我在刷题群里的经验是:先花一周死磕 n 皇后及其变体,再把上述几道题各做两遍,回溯这一块基本就过关了。

6.2 回溯的变种:带记忆化的回溯(动态规划)

有一种常见的坑需要提防:回溯和动态规划有时候长得非常像,但侧重点不同。回溯是自顶向下、深度优先的穷举;动态规划是自底向上、用状态转移方程记录子问题的最优解。如果某个问题存在大量重复子问题(比如不同路径到达相同状态),直接回溯会重复计算,这时加上记忆化(Memoization)就能把指数级复杂度降为多项式级。

n 皇后并不适合记忆化,因为每步的皇后位置是全局性的,难以拆分出可复用的子问题。但如果你在做的是“n 皇后问题的变形”——比如只需求某一行的摆放方案数时,可能就能套用 DP 思路。总之,看到回溯超时,第一反应是检查是否做了重复子问题计算,再考虑是否引入记忆化。

6.3 回溯的时间复杂度与空间复杂度到底怎么算

回溯的时间复杂度理论上受剪枝影响很大,很难给出一个精确的闭式,一般用 O(分支数^深度) 来近似描述。对 n 皇后来说,分支数从 n 递减到 1(因为越到后面可用的列数越少),所以实际复杂度约为 O(n!)。但剪枝的存在让实际运行远小于这个上界——n=8 只遍历了数千个节点,而 8! = 40320,剪枝砍掉了大约 90% 的节点。

空间复杂度方面,主要开销是递归栈深度加存储状态数组的额外空间。对 n 皇后而言是 O(n)(数组加栈深),而如果要输出所有解,则还要加上输出结果集的空间开销,最坏情况下 O(n * 解数量)。

7. 实操总结与后续拓展建议

经过上面的拆解和实现,我们对回溯、剪枝、n 皇后这三者之间的关系应该有一个很清晰的认知了。回溯框架的本质是递归 + 状态撤销,剪枝是约束传播的代码化,n 皇后则是检验这两者理解程度的最佳试金石。

我在实际项目里很少直接遇到“皇后问题”这种任务,但回溯思想在业务代码中却无处不在:

  • 权限系统的组合校验
  • 数据库查询优化器的候选计划搜索
  • 游戏 AI 的决策树搜索
  • 配置组合的合法化检测

我每次给团队做代码评审时,看到递归和循环嵌套较深的场景,都会条件反射地去想:这个状态什么时候恢复?这个分支什么时候剪掉?这个递归是否可能无限循环?这就是刷 n 皇后带来的思维惯性,我确实觉得它比刷几十道简单的数组题更有价值。

如果你接下来打算继续深入,我建议按这个路线走:

  1. 先只做 LeetCode 51,用扫描法笨笨地跑通,理解完整流程。
  2. 再做 LeetCode 52,把它改成标记数组法,对比两版代码的差异。
  3. 最后用位运算实现 52,并加上对称性剪枝,统计 n=12 时的耗时。
  4. 之后把全排列、组合总和、单词搜索这几道题用同样的模板各写一遍,巩固回溯思路。

把这个流程走完,回溯这一关就算扎扎实实过去了。最后我再分享一个小习惯:写回溯题时,先在纸上画出递归树和状态数组的变化过程,再动键盘,尤其是 n=3 或 n=4 的小规模例子。画清楚一个例子,比盲写一百行代码更能帮你建立直觉,这个习惯从我第一次接触回溯一直用到现在,依然觉得非常值。

RELATED READING

延伸阅读

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