算法笔记·其一:从递归到回溯——以全排列与N皇后问题为例

一、递归与回溯的引入

1.1 递归算法概述

递归是一种常见的算法设计方法,简单来说就是函数内部直接或间接调用自身 。例如,求 n! 时,可以通过调用自身求出 (n−1)!,再乘以 n 得到结果。当然,如果只有自我调用而没有停止条件,递归就会无限进行下去,因此必须有一个终止条件 。比如阶乘的例子中,当 n=0 时,我们直接返回 1(因为 0!=1)。也就是说,递归程序通常包含两个核心要素:递归边界 (终止条件)和递归式(递推关系),它们支撑起了递归的核心逻辑。

1.2 什么是回溯

回溯,听起来与递归似乎没有直接联系,但实际上回溯算法是一种在递归基础上加入了"状态重置"的暴力搜索方法 。它并没有一个特定的库函数,而是一种算法思想:我们先试探某一种可能性,在试探过程中递归地深入搜索,当递归返回后,立即撤销刚才的选择(即回溯),表示当前分支已经探索完毕,转而尝试下一个分支,直到穷举所有可能。这种"试探-递归-撤销"的过程,使得回溯能够系统地遍历所有候选解。

值得一提的是,回溯本质上仍属于暴力枚举,时间复杂度通常较高(往往是指数级),但对于许多组合优化问题(如排列组合、子集、棋盘问题等),它是最直接且易于实现的求解方法,因此学习回溯算法依然十分必要。

二、全排列问题的回溯求解

2.1 例题重现

给定一个整数 n(1 ≤ n ≤ 8),输出 1 到 n 的所有全排列,要求按字典序输出 -1

输入格式

输入只有一行,包含一个整数 n

输出格式

输出所有排列,每行一个排列,每个排列中的数字之间用空格隔开 -1

样例输入

3

样例输出

1 2 3

1 3 2

2 1 3

2 3 1

3 1 2

3 2 1

样例解释

当n = 3时,所有可能的排列如上所示。可以理解为:先固定第一位为1,递归处理剩余数字 [2,3] 得到 [1,2,3] 和 [1,3,2];再固定第一位为2,递归处理 [1,3] 得到 [2,1,3] 和 [2,3,1];最后固定第一位为3,递归处理 [1,2] 得到 [3,1,2] 和 [3,2,1] 。

2.2 为什么要使用回溯算法

全排列问题要求我们逐个位置选择数字 ,每个位置的选择都会影响后续可能性,这形成了一个多阶段决策树

回溯算法正是用递归来深度优先遍历这棵树 :每层递归负责一个位置的选择,选完后继续深入,当一条路走到尽头(得到一个排列)时,自动退回上一层并撤销刚才的选择,从而尝试其他分支。如果没有这种"试探-撤销"机制,就无法系统地穷举所有排列。因此,回溯是解决全排列最自然、最通用的方法。

2.3 基本思路

以n=3为例,假设我们最开始选的是1,之后我们就会面临继续选择的问题,因为不能选择的相同的元素,此时就需要一个bool型的uesd数组,标记我们已经使用过的数字。

按照回溯的思路,我们编写回溯函数的时候应该先写递归边界,此题的边界即为寻找完了所有元素,假设有一个长度为n的数组nums表示1-n的数,以及一个数组path,表示回溯过程中的路径,那么递归边界为path的大小为n,即两数组的大小相等,当满足这个条件时,我们设一个二维数组result来存放排列的组合,把path存到result里,然后返回。当然,如果我们在主函数里定义四个数组,需要将其全部当成参数传入。

其次便是"试探-递归-撤销 "的流程,我们先进行循环,表示顺序试探1-n,然后,我们需要判断当前该数字是否使用过(根据used数组)若未使用,则将该数字放入path后,并将used数组的对应的值改为true(试探),之后进行递归调用(递归),最后则将该数字从path中移除,并将uesd数组的对应值改为false(撤销),同时,正是通过这个撤销操作,我们才能让循环继续尝试下一个数字,从而穷举所有排列。此外,主函数在这里不作赘述。

2.4 示例代码

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
/**
 * 回溯算法生成全排列
 * @param nums   包含所有待排列数字的数组(原数据,不会修改)
 * @param used   标记每个数字是否已被使用(会修改)
 * @param path   当前正在构建的排列路径(会修改)
 * @param result 存储所有找到的排列(最终结果)
 */
void backtrace(vector<int>& nums, vector<bool>& used, vector<int>& path, vector<vector<int>>& result) {
    // 终止条件:当前路径长度等于数字总数,说明找到了一个完整排列
    if (path.size() == nums.size()) {
        result.push_back(path);   // 将当前排列存入结果集
        return;
    }

    // 遍历所有数字,尝试将每个未使用的数字放入当前位置
    for (int i = 0; i < nums.size(); i++) {
        if (!used[i]) {               // 如果当前数字尚未被使用
            used[i] = true;            // 做选择:标记为已使用
            path.push_back(nums[i]);   // 将该数字加入路径

            // 递归进入下一层,继续构造剩余位置的数字
            backtrace(nums, used, path, result);

            // 回溯:撤销刚才的选择,以便尝试其他数字
            path.pop_back();           // 从路径中移除该数字
            used[i] = false;            // 恢复未使用状态
        }
    }
}

int main() {
    int n;
    cin >> n;                           // 输入需要排列的数字个数

    vector<int> nums(n);                 // 存放1~n的数字
    vector<int> path;                    // 当前路径
    vector<vector<int>> result;           // 存放所有排列结果
    vector<bool> used(n + 1);             // 标记数组,大小为n+1,但实际只用索引0~n-1

    // 初始化nums为1,2,...,n
    for (int i = 0; i < n; i++) {
        nums[i] = i + 1;
    }

    // 调用回溯函数生成全排列
    backtrace(nums, used, path, result);

    // 输出所有排列结果,每个排列内数字之间用空格隔开
    for (const auto& perm : result) {
        for (int i = 0; i < perm.size(); i++) {
            cout << perm[i];
            // 如果不是最后一个数字,则输出空格
            if (i != perm.size() - 1) cout << " ";
        }
        cout << endl;   // 每个排列占一行
    }

    return 0;
}

2.5 总结

全排列是回溯算法的经典入门题。它通过递归 实现深度优先搜索,在每一层递归中尝试所有可选数字,并在递归返回后撤销选择 (即回溯),从而穷举所有可能的排列。整个过程遵循"试探-递归-撤销"的模式。值得注意的是,该做法的时间复杂度:O(n!),因为 n 个数的全排列共有 n! 种,每种排列都需要 O(n) 时间来构建,所以,当n较大时,生成速度会明显变慢。

三、n皇后问题的回溯求解

3.1 例题重现

题目描述

N×N 的棋盘上放置 N 个皇后,使得它们彼此之间不能相互攻击。即:任意两个皇后都不在同一行、同一列或同一条对角线上。输出所有可能的放置方案。

输入格式

一个整数 n(通常 1 ≤ n ≤ 9,具体范围可根据题目要求调整),表示棋盘的大小和皇后的数量。

输出格式

输出所有可行的棋盘布局,每个布局由 n 行字符组成,其中 'Q' 表示皇后,'.' 表示空格。不同布局之间用空行隔开。

样例输入

4

样例输出

复制代码
.Q..
...Q
Q...
..Q.

..Q.
Q...
...Q
.Q..

说明

  • 上述样例展示了 4 皇后问题的两个解。

  • 每个解中,任意两个皇后都不在同一行、同一列或对角线上。

  • 输出顺序可以任意,但通常按字典序或任意顺序均可。

3.2 为什么要用回溯算法

与全排列问题相似,n皇后要求我们逐个位置选择是否放置皇后 ,每个位置的选择都会影响后续可能性,这也形成了一个多阶段决策树。回溯算法的每一次递归都决定了在某一行皇后应该放置在哪一列,同时撤销操作保证了我们能够探索不同分支,从而保证能够走遍全部分支。

3.3 基本思路

以 n=4 为例,假设我们从第 0 行开始放置皇后。每一行我们只能放一个皇后,并且要保证这个皇后与之前所有行已放置的皇后都不冲突(不同列、不同对角线)。此时我们就需要借助一个isVaild 函数来判断在当前行的某一列放皇后是否合法。

按照回溯的思路,编写回溯函数时先写递归边界。此题的边界就是我们已经放置完了所有行,即当前行号row 等于n。此时棋盘上已经有了一个完整的合法布局,我们就把当前棋盘chessboard存入结果集result 中,然后返回。

其次便是"试探-递归-撤销 "的流程。我们从当前行row 开始,循环遍历每一列col(从 0 到 n-1),表示尝试在这一行的每一列放置皇后。对于每一列,先用isVaild判断该位置是否合法(即检查同一列、两条对角线上方是否已有皇后)。如果合法,则在chessboard放上 'Q'(试探)。调用backtrack函数,行号加 1,继续处理下一行(递归)。当递归返回后,将chessboard重新置为 '.',表示撤销刚才放置的皇后,以便循环尝试当前行的下一列(撤销)。正是通过这个撤销操作,我们才能让循环继续尝试当前行的其他列,从而穷举所有可能的放置方案。当所有列都尝试完毕后,函数自然结束,返回上一层。

主函数负责读入n,初始化棋盘(全部为 '.'),调用backtrack从第 0 行开始搜索,最后输出所有结果。这里也不再赘述。

3.4 示例代码

cpp 复制代码
#include <bits/stdc++.h>  // 包含常用头文件(竞赛常用,建议实际开发中改用具体头文件)
using namespace std;

/**
 * 判断在棋盘 chessboard 的第 row 行、第 col 列放置皇后是否合法
 * @param chessboard 当前棋盘(二维字符数组)
 * @param n          棋盘大小
 * @param row        当前行
 * @param col        当前列
 * @return true 表示可以放置,false 表示冲突
 */
bool isValid(vector<vector<char>>& chessboard, int n, int row, int col) {
    // 1. 检查同一列的上方是否有皇后
    for (int i = 0; i < row; i++) {
        if (chessboard[i][col] == 'Q')
            return false;
    }
    // 2. 检查右上对角线(行减、列加)的上方是否有皇后
    for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
        if (chessboard[i][j] == 'Q')
            return false;
    }
    // 3. 检查左上对角线(行减、列减)的上方是否有皇后
    for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
        if (chessboard[i][j] == 'Q')
            return false;
    }
    // 所有检查通过,可以放置
    return true;
}

/**
 * 回溯算法核心函数
 * @param chessboard 当前棋盘(引用,会随递归修改)
 * @param n          棋盘大小
 * @param result     存储所有可行解的容器(三维字符数组)
 * @param row        当前要放置皇后的行号
 */
void backtrack(vector<vector<char>>& chessboard, int n,
               vector<vector<vector<char>>>& result, int row) {
    // 递归边界:已经成功放置完所有行(row 从 0 到 n-1)
    if (row == n) {
        result.push_back(chessboard);  // 将当前棋盘(一个完整解)存入结果集
        return;
    }
    // 尝试当前行的每一列
    for (int col = 0; col < n; col++) {
        // 剪枝:只有当前位置合法才继续深入
        if (isValid(chessboard, n, row, col)) {
            chessboard[row][col] = 'Q';   // 试探:放置皇后
            backtrack(chessboard, n, result, row + 1); // 递归:处理下一行
            chessboard[row][col] = '.';   // 回溯:撤销皇后,尝试下一列
        }
    }
}

int main() {
    int n;
    cin >> n;  // 输入棋盘大小

    // 初始化棋盘,全部填为 '.'
    vector<vector<char>> chessboard(n, vector<char>(n, '.'));

    // 存储所有解的容器,每个解是一个 n×n 的字符矩阵
    vector<vector<vector<char>>> result;

    // 从第 0 行开始回溯搜索
    backtrack(chessboard, n, result, 0);

    // 输出所有解
    for (auto& solution : result) {        // 遍历每一个解
        for (int i = 0; i < n; i++) {      // 遍历每一行
            for (int j = 0; j < n; j++) {  // 遍历每一列
                cout << solution[i][j];    // 输出字符('Q' 或 '.')
            }
            cout << endl;                   // 换行
        }
        cout << endl;                        // 两个解之间空一行
    }

    return 0;
}

3.5 总结

N皇后是回溯算法的经典应用,通过逐行放置皇后 ,在每一行尝试所有可能的列,利用剪枝函数提前排除冲突的分支,最终找出所有合法布局。整个过程遵循"试探-递归-撤销"的模式。

通过N皇后问题,我们进一步巩固了回溯算法的框架,并学习了如何在搜索过程中利用约束条件进行剪枝,这是回溯算法高效的关键。

四、回溯总结

回溯算法是一种通过递归系统地搜索所有可能解的暴力枚举方法。它的核心思想可以概括为三个步骤:

  1. 做选择:在当前步骤尝试一种可能性(例如在全排列中选一个数字,在 N 皇后中放一个皇后)。

  2. 递归:基于当前选择,继续深入下一步,探索后续的可能性。

  3. 撤销选择 :当递归返回后,立刻取消刚才的选择(即回溯),以便循环尝试当前步骤的其他选项。

正是通过这种"试探-递归-撤销"的循环,回溯能够穷举所有路径,找到所有可行解。同时,在搜索过程中可以利用约束条件提前剪枝(如 N 皇后中的isVaild判断),跳过明显无效的分支,从而保证结果正确,也提升了速度。

回溯算法通常适用于组合、排列、子集、棋盘搜索等问题,虽然时间复杂度往往较高(如 O(n!) 或 O(2ⁿ)),但对于许多问题,它是最直观且易于实现的解法。掌握回溯的通用模板,就能灵活应对各种类似的搜索问题。

相关推荐
森G2 小时前
CMake二、带文件多文件编译
c++
图图的点云库2 小时前
随机采样一致性算法实现
人工智能·算法·机器学习
Yupureki2 小时前
《MySQL数据库基础》4. 数据类型
c语言·开发语言·数据结构·数据库·c++·mysql
Oll Correct2 小时前
实验六:以太网交换机自学习和转发帧的过程
网络·笔记·学习
C++ 老炮儿的技术栈2 小时前
C++、C#常用语法对比
c语言·开发语言·c++·qt·c#·visual studio
_饭团2 小时前
指针核心知识:5篇系统梳理4
c语言·开发语言·c++·笔记·深度学习·算法·面试
AuroBreeze2 小时前
RISC-V: Minimal U-mode implementation
linux·c语言·c++·risc-v
YXXY3132 小时前
二分查找算法
算法
爱玩亚索的程序员2 小时前
算法入门(一)Python基础(list、dict、set、tuple、for、enumerate、lambda、sorted)
python·算法·list