老铁们,阿扩又回来啦!
上一章,我们一起学习了Dijkstra算法,看它如何在复杂的地图上,像一位精明的规划师一样,为我们计算出最优路径。Dijkstra的每一步都是基于当前最优的“贪心”选择,坚定不移地向前。
但今天,我们要认识一种风格截然不同的算法。它更像一位谨慎的棋手,在棋盘上落子前,会反复“试探”。它会先走一步看看,如果发现这条路可能会导致失败,它会毫不犹豫地收回这步棋,退回到上一个路口,尝试其他的可能性。这种“走一步,看一步,不行就退回来”的智慧,就是回溯算法 (Backtracking) 的精髓。
而要体验回溯的魅力,没有比经典的N皇后问题更合适的舞台了。准备好在棋盘上,来一场展现递归与试探之美的“宫斗大戏”了吗?
章节十一:棋盘上的“试探”:回溯解N皇后 - (回溯算法 / Backtracking)
🎯 回溯:优雅的暴力,智慧的穷举
很多老铁一听“回溯”,可能会觉得它和“暴力穷举”差不多。没错,回溯的本质确实是在“搜”,但它是一种带有剪枝(Pruning)的深度优先搜索(DFS)。它不是盲目地把所有可能性都试一遍,而是在探索的过程中,一旦发现当前的选择已经不满足条件(或者说,这条路已经走不通了),就立刻停止深入,“回溯”到上一步,从而避免了大量无效的搜索。
你可以把回溯想象成走迷宫:
- 选择一条路: 在一个路口,选择一个方向前进。
- 遇到死胡同: 如果发现这条路是死胡同(不满足约束条件),你不会继续往前撞墙。
- 原路返回: 你会退回到上一个路口(撤销选择),然后尝试那个路口的其他方向。
这个过程通常用递归来实现,其解题框架可以总结为“回溯三部曲”:
- 路径 (Path): 你已经做出的选择的集合。
- 选择列表 (Choices): 你当前可以做的选择。
- 结束条件 (End Condition): 当你满足了某个条件(比如路径长度达标),就找到了一个解。
一个通用的回溯算法伪代码模板如下:
backtrack(路径, 选择列表):
if 满足结束条件:
保存结果(路径)
return
for 选择 in 选择列表:
// 剪枝:如果选择不合法,就跳过
if not is_valid(选择):
continue
做选择 // 将选择加入路径
backtrack(路径, 新的选择列表)
撤销选择 // 将选择从路径中移除,这步是回溯的关键!
👑 N皇后:棋盘上的“宫斗大戏”
N皇后问题是一个经典的回溯应用场景。问题描述如下:
在一个 N×N 的棋盘上,放置 N 个皇后,使得她们互相之间不能攻击。
国际象棋的规则是,皇后可以攻击同一行、同一列、以及同一斜线上的任何棋子。

(一个8皇后问题的解)
这个问题要求我们找出所有可能的布局方案。如果用纯暴力的方法,从 N×N 个格子中选 N 个来放皇后,组合数将是天文数字。而回溯算法,则能巧妙地解决这个问题。
💡 解题思路:一行一行地“试探”
我们可以把问题简化一下:每一行必须且只能放置一个皇后。这样,我们的任务就变成了:从第0行开始,为每一行选择一个合适的列来放置皇后。
现在,我们把N皇后问题套入回溯三部曲:
- 路径: 已经成功放置了皇后的那些行,以及她们分别在哪一列。我们可以用一个数组
cols来记录,cols[row] = col表示第row行的皇后放在了第col列。 - 选择列表: 对于当前正在考虑的第
row行,它的选择列表就是第0到N-1列。 - 结束条件: 当我们成功放置到第
N行时(即row == N),说明我们已经找到了一个完整的、合法的布局方案。
最关键的一步:如何“剪枝”?
当我们准备在 (row, col) 位置放一个皇后时,如何判断这个位置是否合法?我们需要检查它是否会被之前已经放置的皇后攻击:
- 列检查: 检查
col这一列是否已经被其他皇后占用了。 - 主对角线检查 (左上到右下): 在这条对角线上的所有点,它们的
行号 - 列号的值是恒定的。 - 副对角线检查 (右上到左下): 在这条对角线上的所有点,它们的
行号 + 列号的值是恒定的。
我们可以用三个集合(Set)来记录已经被占用的列和对角线,从而实现 O(1) 的合法性检查,大大提高效率!
图解回溯:皇后的“步步为营”
下面以4皇后问题为例,展示回溯的决策过程:
这个图清晰地展示了算法是如何深入、碰壁、回退、再尝试其他路径的。
💻 Show Me The Code! (Java & Python 双语教学)
Java 版本:
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class NQueens {
private List<List<String>> solutions;
private char[][] board;
private int n;
private Set<Integer> usedColumns;
private Set<Integer> usedDiagonals1; // 主对角线 (row - col)
private Set<Integer> usedDiagonals2; // 副对角线 (row + col)
public List<List<String>> solveNQueens(int n) {
this.solutions = new ArrayList<>();
this.n = n;
this.board = new char[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
board[i][j] = '.';
}
}
this.usedColumns = new HashSet<>();
this.usedDiagonals1 = new HashSet<>();
this.usedDiagonals2 = new HashSet<>();
backtrack(0); // 从第 0 行开始
return solutions;
}
private void backtrack(int row) {
// 结束条件:成功放完 N 行
if (row == n) {
solutions.add(boardToList());
return;
}
// 遍历当前行的所有列
for (int col = 0; col < n; col++) {
int diag1 = row - col;
int diag2 = row + col;
// 剪枝:检查是否合法
if (usedColumns.contains(col) || usedDiagonals1.contains(diag1) || usedDiagonals2.contains(diag2)) {
continue;
}
// 做选择
board[row][col] = 'Q';
usedColumns.add(col);
usedDiagonals1.add(diag1);
usedDiagonals2.add(diag2);
// 递归到下一行
backtrack(row + 1);
// 撤销选择 (回溯)
board[row][col] = '.';
usedColumns.remove(col);
usedDiagonals1.remove(diag1);
usedDiagonals2.remove(diag2);
}
}
private List<String> boardToList() {
List<String> list = new ArrayList<>();
for (char[] row : board) {
list.add(new String(row));
}
return list;
}
public static void main(String[] args) {
NQueens solver = new NQueens();
int n = 4;
List<List<String>> solutions = solver.solveNQueens(n);
System.out.println("Found " + solutions.size() + " solutions for " + n + "-Queens:");
for (List<String> solution : solutions) {
System.out.println("--- Solution ---");
for (String row : solution) {
System.out.println(row);
}
}
}
}
Python 版本:
from typing import List
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
solutions = []
# 使用一个列表记录每行皇后的列位置,cols[row] = col
# 这种表示法更简洁,但为了输出格式,我们仍需构建棋盘
cols = [-1] * n
used_cols = set()
used_diagonals1 = set() # 主对角线: row - col
used_diagonals2 = set() # 副对角线: row + col
def backtrack(row: int):
# 结束条件
if row == n:
# 构建棋盘输出
board = []
for r in range(n):
line = ['.'] * n
line[cols[r]] = 'Q'
board.append("".join(line))
solutions.append(board)
return
# 遍历当前行的所有列
for col in range(n):
diag1 = row - col
diag2 = row + col
# 剪枝
if col in used_cols or diag1 in used_diagonals1 or diag2 in used_diagonals2:
continue
# 做选择
cols[row] = col
used_cols.add(col)
used_diagonals1.add(diag1)
used_diagonals2.add(diag2)
# 递归
backtrack(row + 1)
# 撤销选择 (回溯)
# cols[row] = -1 # 这行可以省略,因为下一轮循环会覆盖它
used_cols.remove(col)
used_diagonals1.remove(diag1)
used_diagonals2.remove(diag2)
backtrack(0)
return solutions
if __name__ == "__main__":
solver = Solution()
n = 4
solutions = solver.solveNQueens(n)
print(f"Found {len(solutions)} solutions for {n}-Queens:")
for i, solution in enumerate(solutions):
print(f"--- Solution {i+1} ---")
for row_str in solution:
print(row_str)
💡 阿扩小结:划重点!
回溯算法是解决一类特定问题的强大框架,尤其是那些需要找出所有解的组合、排列、子集问题:
- 核心思想: 带剪枝的深度优先搜索。它不是蛮力,而是“走一步,看一步,不行就退回”的智能搜索。
- 回溯三部曲: 牢记路径、选择列表、结束条件这三个要素,它们是构建回溯函数的骨架。
- 关键操作: “做选择” 和 “撤销选择” 必须成对出现,这是实现回溯的根本。
- 应用场景:
- 组合问题: N皇后、组合总和。
- 排列问题: 全排列。
- 子集问题: 子集。
- 棋盘/网格问题: 单词搜索、数独。
掌握了回溯,你就拥有了一把能打开许多组合优化和搜索问题大门的钥匙。
老铁们,今天这场棋盘上的“头脑风暴”还过瘾吗?回溯的魅力就在于它将复杂的搜索问题,用一套优雅的递归框架清晰地表达了出来。如果觉得阿扩讲明白了,别忘了点赞、收藏、转发!
下一章,我们将揭秘一个在字符串处理和搜索引擎中大放异彩的数据结构——Trie树(字典树)。想知道搜索框里的“联想词”是如何秒速实现的吗?我们下期见分晓!🔍
2万+

被折叠的 条评论
为什么被折叠?



