章节十一:棋盘上的“试探”:回溯解N皇后 - (回溯算法 / Backtracking)

老铁们,阿扩又回来啦!

上一章,我们一起学习了Dijkstra算法,看它如何在复杂的地图上,像一位精明的规划师一样,为我们计算出最优路径。Dijkstra的每一步都是基于当前最优的“贪心”选择,坚定不移地向前。

但今天,我们要认识一种风格截然不同的算法。它更像一位谨慎的棋手,在棋盘上落子前,会反复“试探”。它会先走一步看看,如果发现这条路可能会导致失败,它会毫不犹豫地收回这步棋,退回到上一个路口,尝试其他的可能性。这种“走一步,看一步,不行就退回来”的智慧,就是回溯算法 (Backtracking) 的精髓。

而要体验回溯的魅力,没有比经典的N皇后问题更合适的舞台了。准备好在棋盘上,来一场展现递归与试探之美的“宫斗大戏”了吗?


章节十一:棋盘上的“试探”:回溯解N皇后 - (回溯算法 / Backtracking)

🎯 回溯:优雅的暴力,智慧的穷举

很多老铁一听“回溯”,可能会觉得它和“暴力穷举”差不多。没错,回溯的本质确实是在“搜”,但它是一种带有剪枝(Pruning)的深度优先搜索(DFS)。它不是盲目地把所有可能性都试一遍,而是在探索的过程中,一旦发现当前的选择已经不满足条件(或者说,这条路已经走不通了),就立刻停止深入,“回溯”到上一步,从而避免了大量无效的搜索。

你可以把回溯想象成走迷宫:

  1. 选择一条路: 在一个路口,选择一个方向前进。
  2. 遇到死胡同: 如果发现这条路是死胡同(不满足约束条件),你不会继续往前撞墙。
  3. 原路返回: 你会退回到上一个路口(撤销选择),然后尝试那个路口的其他方向。

这个过程通常用递归来实现,其解题框架可以总结为“回溯三部曲”:

  • 路径 (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 行,它的选择列表就是第 0N-1 列。
  • 结束条件: 当我们成功放置到第 N 行时(即 row == N),说明我们已经找到了一个完整的、合法的布局方案。

最关键的一步:如何“剪枝”?
当我们准备在 (row, col) 位置放一个皇后时,如何判断这个位置是否合法?我们需要检查它是否会被之前已经放置的皇后攻击:

  1. 列检查: 检查 col 这一列是否已经被其他皇后占用了。
  2. 主对角线检查 (左上到右下): 在这条对角线上的所有点,它们的 行号 - 列号 的值是恒定的。
  3. 副对角线检查 (右上到左下): 在这条对角线上的所有点,它们的 行号 + 列号 的值是恒定的。

我们可以用三个集合(Set)来记录已经被占用的列和对角线,从而实现 O(1) 的合法性检查,大大提高效率!

图解回溯:皇后的“步步为营”

下面以4皇后问题为例,展示回溯的决策过程:

开始
Row 0: 尝试Col 0
(0,0) OK
Row 1: 尝试Col 0 (冲突)
尝试Col 1 (冲突)
尝试Col 2 (OK)
Row 2: 尝试所有Col (全冲突)
死胡同!
回溯!
撤销(1,2)
Row 1: 继续尝试Col 3 (OK)
Row 2: 尝试Col 0 (冲突)
尝试Col 1 (OK)
Row 3: 尝试所有Col (全冲突)
死胡同!
回溯!
撤销(2,1)
Row 2: 继续尝试 (无)
回溯!
撤销(1,3)
Row 1: 继续尝试 (无)
回溯!
撤销(0,0)
Row 0: 尝试Col 1
(0,1) OK
...继续探索...
最终找到解
(0,1), (1,3), (2,0), (3,2)

这个图清晰地展示了算法是如何深入、碰壁、回退、再尝试其他路径的。

💻 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)
💡 阿扩小结:划重点!

回溯算法是解决一类特定问题的强大框架,尤其是那些需要找出所有解的组合、排列、子集问题:

  1. 核心思想: 带剪枝的深度优先搜索。它不是蛮力,而是“走一步,看一步,不行就退回”的智能搜索。
  2. 回溯三部曲: 牢记路径、选择列表、结束条件这三个要素,它们是构建回溯函数的骨架。
  3. 关键操作: “做选择”“撤销选择” 必须成对出现,这是实现回溯的根本。
  4. 应用场景:
    • 组合问题: N皇后、组合总和。
    • 排列问题: 全排列。
    • 子集问题: 子集。
    • 棋盘/网格问题: 单词搜索、数独。

掌握了回溯,你就拥有了一把能打开许多组合优化和搜索问题大门的钥匙。


老铁们,今天这场棋盘上的“头脑风暴”还过瘾吗?回溯的魅力就在于它将复杂的搜索问题,用一套优雅的递归框架清晰地表达了出来。如果觉得阿扩讲明白了,别忘了点赞、收藏、转发

下一章,我们将揭秘一个在字符串处理和搜索引擎中大放异彩的数据结构——Trie树(字典树)。想知道搜索框里的“联想词”是如何秒速实现的吗?我们下期见分晓!🔍

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杨小扩

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值