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。每一层递归对应棋盘的一行。 - 树的宽度 = 棋盘的列数
n。for循环遍历的是当前行的每一列。 - 隐含约束:每一行只能放一个皇后,所以一旦在当前行放置了皇后,直接递归进入下一行(
line + 1),不需要像组合问题那样继续在当前行向后遍历。
- 树的深度 = 棋盘的行数
2. check 函数(剪枝)
check 函数,利用了"自上而下填充"的特性,检查了三个方向:
- 正上方:防止同列冲突。
- 左上方对角线:防止对角冲突。
- 右上方对角线:防止对角冲突。
3. 性能分析
目前的 check 函数每次调用的时间复杂度是 O(N)。
- 复杂度:总时间复杂度约为 O(N!)。因为第一行有 N 种选法,第二行有 N-1 种... check 操作本身也是 O(N),综合起来是 O(N * N!)。
37. 解数独
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字
1-9在每一行只能出现一次。- 数字
1-9在每一列只能出现一次。- 数字
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. 三大核心场景对比
这是做题中最容易混淆的部分,通过对比 startIndex 和 used 的使用来区分:
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返回值实现"找到一个解就立即停止"。
- N皇后 :一维递归(每层递归处理一行),通过
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. 性能优化细节
-
参数引用传递 vs 值传递
void backtracking(vector<int>& path, ...):推荐使用引用&。- 引用传递直接操作原数据,节省了拷贝的开销。配合及时的
pop_back()撤销操作,效率最高。
-
返回值 bool vs void
void:用于需要找出所有解 的场景(N皇后、组合、子集)。找到解后存入ans,继续找下一个。bool:用于只需要找出一个解 的场景(解数独)。一旦找到解,层层返回true,迅速结束递归。