代码随想录算法训练营 Day22 | 回溯算法 part04

491. 非递减子序列

给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。

数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。

cpp 复制代码
class Solution {
public:
    vector<int> path;
    vector<vector<int>> ans;
    void backtracking(vector<int>& nums, int startIndex) {
        // 1. 终止条件/收集结果
        // 题目要求子序列长度至少为2
        if (path.size() >= 2) {
            ans.push_back(path);
            // 注意:这里不要 return,因为还要继续向下递归寻找更长的递增子序列
        }
        // 2. 核心去重逻辑:使用 set 对本层元素进行去重
        // 关键点:st 定义在函数内部,属于局部变量
        // 这意味着每一层递归都会创建一个新的 st,只负责当前这一层的去重(树层去重)
        unordered_set<int> st; 
        for (int i = startIndex; i < nums.size(); i++) {
            // 去重:如果 st 中已经存在该值,说明本层已经选过这个值了,跳过
            if (st.find(nums[i]) != st.end()) continue;
            // 3. 递增判断
            // 如果路径为空,或者当前元素 >= 路径最后一个元素,则满足递增条件
            if (path.empty() || nums[i] >= path.back()) {
                path.push_back(nums[i]);
                st.insert(nums[i]); // 记录本层使用过该值
                backtracking(nums, i + 1); // 递归下一层
                path.pop_back(); // 回溯
                // ❓ 疑问点:这里需要 st.erase(nums[i]) 吗?
                // 答案:不需要。
                // 因为 st 是负责"本层"去重的。当你执行 pop_back 回溯时,
                // 说明在当前层,选 nums[i] 的所有分支已经探索完毕。
                // 接下来 for 循环会 i++,进入下一个分支。
                // 我们不希望下一个分支再选同样的 nums[i](如果有重复值的话)。
                // 所以 st.insert 的记录应该保留直到本层函数结束。
            }
        }
    }
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        path.clear();
        ans.clear();
        backtracking(nums, 0);
        return ans;      
    }
};

总结

1. 为什么不能用 sort + nums[i]==nums[i-1] 去重?

在 子集 II 中,我们通过排序,让相同的元素相邻,然后判断 nums[i] == nums[i-1] 来去重。

但在 本题 中,绝对不能排序!

  • 题目求的是递增子序列,必须保持原数组的相对顺序。
  • 如果排序了,原本 [4, 6, 7, 7] 变成 [4, 6, 7, 7](虽然这个例子排序后不变,但如果是 [4, 3, 2, 1] 排序后变成 [1, 2, 3, 4],原数组的顺序就丢了,找出的子序列就不是原数组的子序列了)。
  • 因为不能排序,相同的元素不一定相邻,所以 nums[i] == nums[i-1] 这种判断方法失效。
2. 为什么用 unordered_set 且不需要 erase
  • 树层去重 vs 树枝去重:
    • unordered_set<int> st; 定义在 backtracking 函数内部。
    • 这意味着每次进入新一层递归,都会创建一个新的空 st
    • 这个 st 只负责当前层的元素选择去重。
  • 为什么不需要 erase
    • 当你执行 st.insert(nums[i]) 后,进入递归,然后回溯 pop_back 回来。
    • 此时,当前层对 nums[i] 的探索已经结束。
    • for 循环继续 i++。如果后面的 nums[i+1]nums[i] 值相同,我们希望跳过它(因为它是当前层的重复选择)。
    • 因此,st 中的记录需要保留,直到当前层整个 for 循环结束,函数返回,st 自动销毁。
3. 对比总结
特性 90. 子集 II (Subsets II) 491. 递增子序列
能否排序 能 (求所有子集,顺序不重要) 不能 (求子序列,必须保持原序)
去重方法 sort + if(nums[i]==nums[i-1]) unordered_set 哈希表
去重范围 排序后利用数组下标判断 利用 set 记录本层已选数值
代码特点 空间 O(1) 空间 O(N) (每层递归都有 set 开销)

46. 全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

cpp 复制代码
class Solution {
public:
    vector<int> path;
    vector<vector<int>> ans;
    // used 数组:记录当前路径中哪些下标的元素已经被使用过
    // 注意:这里 used 是值传递(拷贝),每一层递归都有独立的副本
    void backtracking(vector<int>& nums, vector<bool>& used) {
        // 1. 终止条件
        // 当路径长度等于数组长度时,说明所有元素都已排列完毕
        if (path.size() == nums.size()) {
            ans.push_back(path);
            return;
        }
        // 2. 单层搜索逻辑
        // 排列问题:每次都要从头开始搜索
        for (int i = 0; i < nums.size(); i++) {
            // 剪枝/去重:如果该元素已经在路径中,跳过
            if (!used[i]) {
                path.push_back(nums[i]);
                used[i] = true;             // 标记为已使用
                backtracking(nums, used);   // 递归
                path.pop_back();            // 回溯
                used[i] = false;            // 撤销标记
            }
        }
    }
    vector<vector<int>> permute(vector<int>& nums) {
        path.clear();
        ans.clear();
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return ans;
    }
};

总结

1. 排列 vs 组合关键区别点
特性 组合/子集问题 排列问题
循环起始 for (int i = startIndex; ...) for (int i = 0; ...)
递归参数 backtracking(..., i + 1) backtracking(..., used)
核心逻辑 通过 startIndex 控制只能向后找,避免顺序不同导致的重复(如 [1,2] 和 [2,1])。 每次都从头遍历,通过 used 数组排除已选元素,从而允许顺序不同。
2. used 数组的作用

在排列问题中,used 数组是核心,它相当于一个"占位符":

  • used[i] == true 时,表示 nums[i] 已经在当前路径的前面某个位置出现过,本轮不能再选。
  • used[i] == false 时,表示 nums[i] 还没被选过,可以选择放入 path 的当前位置。

47. 全排列 II

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

cpp 复制代码
// 哈希集合去重
class Solution {
public:
    vector<int> path;
    vector<vector<int>> ans;
    // used 采用引用传递,优化性能
    void backtracking(vector<int>& nums, vector<bool>& used) {
        if (path.size() == nums.size()) {
            ans.push_back(path);
            return;
        }
        // 核心点:定义在函数体内部
        // 每一层递归都会创建一个新的 set,负责本层的去重
        unordered_set<int> st; 
        for (int i = 0; i < nums.size(); i++) {
            // 去重逻辑:如果 set 中已经有该值,说明本层已经选过这个值的元素了,跳过
            // 注意:这里判断的是 值
            if (st.find(nums[i]) != st.end()) continue;
            // 排列逻辑:只有未被使用的元素才能选
            if (!used[i]) {
                path.push_back(nums[i]);
                used[i] = true;
                st.insert(nums[i]); // 记录本层使用过这个值
                backtracking(nums, used);
                path.pop_back();
                used[i] = false;
                // 注意:这里不需要从 st 中 erase
                // 因为 st 是局部的,函数结束自动销毁
                // 我们希望保留记录,防止本层后续循环再选相同的值
            }
        }
    }
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        path.clear(); ans.clear();
        vector<bool> used(nums.size(), false);
        // 此方法不需要 sort,因为 set 会自动处理乱序的重复值
        backtracking(nums, used);
        return ans;
    }
};

// 排序 + used 数组去重
class Solution {
public:
    vector<int> path;
    vector<vector<int>> ans;
    void backtracking(vector<int>& nums, vector<bool>& used) { // 建议加上引用 &
        if (path.size() == nums.size()) {
            ans.push_back(path);
            return;
        }
        for (int i = 0; i < nums.size(); i++) {
            // 核心去重逻辑(树层去重)
            // 1. nums[i] == nums[i-1]:当前元素与前一个相同
            // 2. used[i-1] == false:前一个相同元素处于"未使用"状态
            //    (说明是回溯回来的,即同一树层的前一个分支已经处理完了)
            // 此时如果再用 nums[i],就会产生重复排列
            if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
            if (!used[i]) {
                path.push_back(nums[i]);
                used[i] = true;
                backtracking(nums, used);
                path.pop_back();
                used[i] = false;
            }
        }
    }
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        path.clear(); ans.clear();
        vector<bool> used(nums.size(), false);
        sort(nums.begin(), nums.end()); // 排序是这种方法的前提!
        backtracking(nums, used);
        return ans;
    }
};

总结

1. 核心机制对比
特性 版本一 (unordered_set) 版本二 (sort + 判断)
前置条件 不需要排序。 必须排序。
去重原理 数值去重。用集合记录本层已选的数值,遇到重复值直接跳过。 结构去重。利用排序后相邻元素相等的特性,判断是否为"树层重复"。
空间开销 较高。每一层递归都要创建一个 set 对象,有哈希表的开销。 极低。不需要额外空间,仅利用已有的 used 数组。
时间效率 中等。涉及哈希计算,且无法利用排序剪枝。 最高。纯逻辑判断,且排序后有时能更早剪枝。
2. 原理深入解析

版本一(数值去重):

  • 它的逻辑非常直观:在当前层,如果我选了一个 2,那么后面再遇到 2 我就不选了。
  • 它不关心数组是否有序,只关心"值"是否出现过。这是一种"无视位置,只看数值"的策略。
  • 适用场景:当你无法修改原数组顺序,或者不想处理复杂的下标逻辑时。

版本二(树层去重):

  • 这里的判断条件 nums[i] == nums[i-1] && !used[i-1] 是回溯算法中的经典。
  • !used[i-1] 的含义:
    • 如果 used[i-1] == true,说明 nums[i-1] 在当前路径中正在被使用(是在树枝上),此时 nums[i] 是可以选的(比如 [1, 1, 2],第一个 1 在路径里,第二个 1 可以接着用)。
    • 如果 used[i-1] == false,说明 nums[i-1] 刚刚被回溯弹出来了(回到了树层),此时 nums[i] 和它值相同,如果再选就会产生重复分支。
  • 优势:这种方法深刻理解了递归树的结构,用极低的成本完成了去重。

51. N 皇后

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q''.' 分别代表了皇后和空位。

cpp 复制代码
class Solution {
public:
    vector<vector<string>> ans;
    // 检查在 放置皇后是否合法
    // 只需要检查上方、左上方、右上方,因为下方和右下方还没有放皇后
    bool check(vector<string>& mp, int line, int col, int n) {
        // 1. 检查正上方 (同列)
        // 遍历当前行 line 之前的所有行
        for (int i = 0; i < line; i++) {
            if (mp[i][col] == 'Q') return false;
        }
        // 2. 检查左上方对角线
        // i 向上走,j 向左走
        for (int i = line - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
            if (mp[i][j] == 'Q') return false;
        }
        // 3. 检查右上方对角线
        // i 向上走,j 向右走
        for (int i = line - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
            if (mp[i][j] == 'Q') return false;
        }

        return true;
    }
    // 回溯函数
    // line: 当前正在处理第几行
    void backtracking(vector<string>& mp, int n, int line) {
        // 终止条件:如果行号 line 等于 n,说明 0 到 n-1 行都放好了
        if (line >= n) {
            ans.push_back(mp); // 收集结果
            return;
        }
        // 单层搜索逻辑:遍历当前行的每一列
        for (int i = 0; i < n; i++) {
            // 检查当前位置 是否可以放皇后
            if (check(mp, line, i, n)) {
                mp[line][i] = 'Q';          // 放置皇后
                backtracking(mp, n, line + 1); // 递归处理下一行
                mp[line][i] = '.';          // 回溯:撤销放置
            }
        }
    }
    vector<vector<string>> solveNQueens(int n) {
        ans.clear();
        vector<string> mp(n, string(n, '.')); // 初始化棋盘,全部置为 '.'
        backtracking(mp, n, 0);
        return ans;
    }
};

总结

1. 树形结构的特殊性
  • 组合/排列问题:树的深度由 path 的长度控制,每一层的宽度是数组的选择范围。
  • N 皇后问题:
    • 树的深度 = 棋盘的行数 n。每一层递归对应棋盘的一行。
    • 树的宽度 = 棋盘的列数 nfor 循环遍历的是当前行的每一列。
    • 隐含约束:每一行只能放一个皇后,所以一旦在当前行放置了皇后,直接递归进入下一行(line + 1),不需要像组合问题那样继续在当前行向后遍历。
2. check 函数(剪枝)

check 函数,利用了"自上而下填充"的特性,检查了三个方向:

  1. 正上方:防止同列冲突。
  2. 左上方对角线:防止对角冲突。
  3. 右上方对角线:防止对角冲突。
3. 性能分析

目前的 check 函数每次调用的时间复杂度是 O(N)。

  • 复杂度:总时间复杂度约为 O(N!)。因为第一行有 N 种选法,第二行有 N-1 种... check 操作本身也是 O(N),综合起来是 O(N * N!)。

37. 解数独

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则:

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。

cpp 复制代码
class Solution {
public:
    // 检查在 位置放置数字 是否合法
    bool check(vector<vector<char>>& board, int line, int col, char k) {
        // 1. 检查 3x3 宫内是否重复
        // 巧妙的坐标计算:line/3*3 定位到当前宫的起始行,col/3*3 定位到当前宫的起始列
        int l = line / 3 * 3;
        int c = col / 3 * 3;
        for (int i = l; i < l + 3; i++) {
            for (int j = c; j < c + 3; j++) {
                if (board[i][j] == k) return false;
            }
        }
        // 2. 检查当前列是否重复
        for (int i = 0; i < board.size(); i++) {
            if (board[i][col] == k) return false;
        }
        // 3. 检查当前行是否重复
        for (int j = 0; j < board[0].size(); j++) {
            if (board[line][j] == k) return false;
        }
        return true;
    }
    // 核心回溯函数
    // 返回值 bool:因为解数独只需要找到一个解即可,找到后需立即停止后续搜索
    bool backtracking(vector<vector<char>>& board) {
        // 二维遍历棋盘,寻找空位 '.'
        for (int i = 0; i < board.size(); i++) {
            for (int j = 0; j < board[0].size(); j++) {
                if (board[i][j] == '.') { // 发现空位
                    // 尝试填入 '1' 到 '9'
                    for (char k = '1'; k <= '9'; k++) {
                        if (check(board, i, j, k)) { // 检查合法性
                            board[i][j] = k;         // 填入
                            if (backtracking(board)) return true; // 递归,如果下一层返回 true,说明找到了解,直接向上传递 true
                            board[i][j] = '.';        // 回溯,撤销填入
                        }
                    }
                    // 关键逻辑:
                    // 如果 1-9 都试完了,程序还在运行,说明当前这个空位无解。
                    // 这意味着之前某一步填错了,返回 false 告诉上一层需要回溯。
                    return false;
                }
            }
        }
        // 遍历完整个棋盘没有发现 '.',说明棋盘填满了,找到了解
        return true;
    }
    void solveSudoku(vector<vector<char>>& board) {
        backtracking(board);
    }
};

总结

1. N皇后 vs 解数独 树形结构的差异
  • N皇后:是一维递归。树的深度 = 行数。每一层递归只负责处理一行。当处理到最后一行结束时,自然就结束了。
  • 解数独:是二维递归。树的深度 = 棋盘中空位的数量。
    • backtracking 函数并没有明确的"终止行"参数。
    • 它通过双重 for 循环遍历整个棋盘,找到一个空位 . 就进去填,填完后递归进入下一层。
    • 下一层递归接着往后找空位。
2. 返回值的妙用(找到即停)
cpp 复制代码
if (backtracking(board)) return true;
  • N皇后:需要找出所有解,所以不需要返回值,找到一个解存起来,继续找。
  • 解数独:只需要找出一个解。
    • 当递归函数返回 true 时,代表"后面的空位都填满了,问题解决了"。
    • 这个 true 会一层层向上传递,所有层级立即 return true,整个递归栈迅速结束。
    • 如果返回 false,说明这条路走不通,回溯 board[i][j] = '.',尝试下一个数字。

回溯算法章节总结

回溯算法本质就是 "暴力搜索 + 剪枝优化"。它可以被抽象为一棵 N 叉树。

通用模板公式:

cpp 复制代码
void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }
    for (选择 : 本层集合) { // 横向遍历
        处理节点;
        backtracking(路径, 选择列表); // 纵向递归
        回溯,撤销处理结果;
    }
}

1. 三大核心场景对比

这是做题中最容易混淆的部分,通过对比 startIndexused 的使用来区分:

1. 组合/子集问题

代表题目:39.组合总和、40.组合总和II、78.子集、90.子集II、491.递增子序列

  • 核心特征 :顺序不同视为同一个集合([1,2][2,1] 是一样的)。
  • 关键手段 :使用 startIndex
    • 每次递归都传入 i + 1(或 i),保证只能往后选,不能回头。
    • 这就在结构上天然避免了 [2,1] 这种逆序组合的出现。
  • 子集特殊点 :每个节点都要收集结果(ans.push_back(path)),而不仅仅是在叶子节点。
2. 排列问题

代表题目:46.全排列、47.全排列II

  • 核心特征 :顺序不同视为不同集合([1,2][2,1] 是不一样的)。
  • 关键手段 :使用 used 数组
    • 没有 startIndex,每次 for 循环都从 0 开始。
    • 通过 if(used[i]) continue; 来跳过已经选过的元素。
  • 排列特殊点:叶子节点才收集结果。
3. 棋盘/二维问题

代表题目:51.N皇后、37.解数独

  • 核心特征:二维矩阵搜索,约束条件极其复杂(不能同行、同列、同斜线)。
  • 关键手段
    • N皇后 :一维递归(每层递归处理一行),通过 check 函数检查列和对角线。
    • 解数独 :二维递归(每一层递归只填一个格子),通过 bool 返回值实现"找到一个解就立即停止"。

2. 去重策略:回溯最难的一关

方法 代码特征 适用场景 代表题目 原理
排序+跳过 sort + if(i>0 && nums[i]==nums[i-1] && !used[i-1]) 组合、排列 (可以打乱原数组顺序) 40.组合总和II、90.子集II、47.全排列II 利用排序让相同元素相邻。判断 !used[i-1] 说明是树层重复(回溯回来的),直接跳过。
哈希集合 unordered_set<int> st; (定义在递归函数内) 无法排序的场景 491.递增子序列 利用 set 记录本层用过哪些数值。如果是乱序且不能排序,这是必杀技。

3. 性能优化细节

  1. 参数引用传递 vs 值传递

    • void backtracking(vector<int>& path, ...):推荐使用引用 &
    • 引用传递直接操作原数据,节省了拷贝的开销。配合及时的 pop_back() 撤销操作,效率最高。
  2. 返回值 bool vs void

    • void:用于需要找出所有解 的场景(N皇后、组合、子集)。找到解后存入 ans,继续找下一个。
    • bool:用于只需要找出一个解 的场景(解数独)。一旦找到解,层层返回 true,迅速结束递归。
相关推荐
tankeven2 小时前
HJ154 kotori和素因子
c++·算法
Shirley~~2 小时前
力扣hot100:相交链表
前端·算法
会编程的土豆2 小时前
【leetcode hot 100】二叉树
算法·leetcode
罗湖老棍子3 小时前
花神游历各国(信息学奥赛一本通- P1550)(洛谷-P4145)
数据结构·算法·线段树·势能数·区间开平方根 区间查询
Mr_Xuhhh3 小时前
LeetCode 热题 100 刷题笔记:数组与排列的经典解法(续)
算法·leetcode·职场和发展
炽烈小老头3 小时前
【每天学习一点算法 2026/03/29】搜索二维矩阵 II
学习·算法·矩阵
靴子学长3 小时前
Qwen3.5 架构手撕源码
算法·架构·大模型
寒月小酒3 小时前
3.28 OJ
算法
AI成长日志3 小时前
【笔面试算法学习专栏】堆与优先队列专题:数组中的第K个最大元素与前K个高频元素
学习·算法·面试