3.6-3.11回溯
46. 全排列 - 力扣(LeetCode)
class Solution {
public:
vector<vector<int>> permute(vector<int> &nums) {
int n = nums.size();
vector<vector<int>> ans;
vector<int> path(n), on_path(n); // 所有排列的长度都是一样的 n
//on_path为bool类型的数组
auto dfs = [&](this auto&& dfs, int i) {
if (i == n) {
ans.emplace_back(path);
return;
}
for (int j = 0; j < n; j++) {
if (!on_path[j]) {
path[i] = nums[j]; // 从没有选的数字中选一个
on_path[j] = true; // 已选上
dfs(i + 1);
on_path[j] = false; // 恢复现场
// 注意 path 无需恢复现场,因为排列长度固定,直接覆盖就行
}
}
};
dfs(0);
return ans;
}
};
复杂度分析
- 时间复杂度:\(O(n⋅n!)\),其中 n 为 nums 的长度。视频中提到,搜索树中的节点个数低于
3⋅n!。实际上,精确值为⌊e⋅n!⌋,其中 e=2.718⋯ 为自然常数。每个非叶节点要花费 O(n) 的时间遍历 onPath 数组,每个叶结点也要花费 O(n) 的时间复制 path 数组,因此时间复杂度为O(n⋅n!)。 - 空间复杂度:O(n)。返回值的空间不计入。
78. 子集 - 力扣(LeetCode)
方法一:输入的视角(选或不选)
对于输入的 nums,考虑每个 nums[i] 是选还是不选,由此组合出 \(2^n\)个不同的子集。
dfs 中的 i 表示当前考虑到 nums[i] 选或不选。
class Solution { public: vector<vector<int>> subsets(vector<int>& nums) { vector<vector<int>> ans; vector<int> path; int n = nums.size(); auto dfs = [&](this auto&& dfs, int i) -> void { if (i == n) { // 子集构造完毕 ans.emplace_back(path); return; } // 不选 nums[i] dfs(i + 1); // 选 nums[i] path.push_back(nums[i]); dfs(i + 1); path.pop_back(); // 恢复现场 }; dfs(0); return ans; } };方法二:答案的视角(枚举选哪个)
枚举子集(答案)的第一个数选谁,第二个数选谁,第三个数选谁,依此类推。
dfs 中的 i 表示现在要枚举选
nums[i]到nums[n−1]中的一个数,添加到 path 末尾。如果选 nums[j] 添加到 path 末尾,那么下一个要添加到 path 末尾的数,就要在
nums[j+1]到nums[n−1]中枚举了。注意:不需要在回溯中判断 i=n 的边界情况,因为此时不会进入循环,
if i == n: return这句话写不写都一样。class Solution { public: vector<vector<int>> subsets(vector<int>& nums) { int n = nums.size(); vector<vector<int>> res; vector<int> path; auto dfs = [&](this auto&& dfs , int startIndex)-> void{ res.push_back(path); for (int i = startIndex; i < n; i++) { path.push_back(nums[i]); dfs(i + 1); path.pop_back(); } }; // res.clear(); // path.clear(); dfs(0); return res; } };复杂度分析
- 时间复杂度:\(O(n2^n )\),其中 n 为 nums 的长度。答案的长度为子集的个数,即$ 2^n$ ,同时每次递归都把一个数组放入答案,因此会递归 2^n 次,再算上加入答案时复制 path 需要 O(n) 的时间,所以时间复杂度为 \(O(n2^n )\)。
- 空间复杂度:O(n)。返回值的空间不计。
方法三:二进制枚举
根据 从集合论到位运算,常见位运算技巧分类总结 中的「枚举子集」的技巧,可以只用简单的循环枚举所有子集。
class Solution { public: vector<vector<int>> subsets(vector<int>& nums) { int n = nums.size(); vector<vector<int>> ans(1 << n); for (int i = 0; i < (1 << n); i++) { // 枚举全集 U 的所有子集 i for (int j = 0; j < n; j++) { if (i >> j & 1) { // j 在集合 i 中 ans[i].push_back(nums[j]); } } } return ans; } };这个解法利用位运算高效生成所有子集,具体思路如下:
步骤解析:
- 子集总数:一个包含n个元素的集合有2ⁿ个子集。每个元素有选或不选两种状态,对应二进制位的0或1。
- 枚举所有子集:通过遍历从0到2ⁿ-1的所有整数,每个整数i的二进制表示对应一个子集的选择情况。
- 位判断元素选择:对于每个i,检查其二进制每一位j。若第j位为1,则将nums[j]加入当前子集。
示例说明:
以nums = [1,2,3]为例:
- i=3(二进制011):j=0和j=1的位为1,子集为[1,2]。
- i=5(二进制101):j=0和j=2的位为1,子集为[1,3]。
复杂度分析:
- 时间:O(n×2ⁿ),外层循环2ⁿ次,内层循环n次。
- 空间:O(n×2ⁿ),存储所有子集。
适用场景:
- 数组元素唯一,需生成所有可能的子集。
- 适用于n较小的情况,因时间和空间随n指数增长。
此方法直观且代码简洁,有效利用位运算特性,避免了递归栈的开销,是生成子集的经典解法。
17. 电话号码的字母组合 - 力扣(LeetCode)
class Solution {
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
public:
vector<string> letterCombinations(string digits) {
vector<string> res;
string s;
auto dfs = [&](this auto&& dfs , int index)->void{
if(index == digits.size()){
res.push_back(s);
return;
}
int digit = digits[index] - '0';
string letters = letterMap[digit];
for (int i = 0; i < letters.size(); i++) {
s.push_back(letters[i]);
dfs(index + 1);
s.pop_back();
}
};
if(digits.size() == 0) return res;
dfs(0);
return res;
}
};
39. 组合总和 - 力扣(LeetCode)
class Solution {
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<vector<int>> res;
vector<int> path;
auto dfs = [&](this auto&& dfs , int s , int startIndex)->void{
if(s == target) {
res.push_back(path);
return;
}
//剪枝过程,s+candidates[k] > target的都被排除了
for (int k = startIndex; k < candidates.size() && s + candidates[k] <= target; k++) {
path.push_back(candidates[k]);
dfs(s + candidates[k], k);
path.pop_back();
}
};
sort(candidates.begin() , candidates.end());//注意需要排序
dfs(0 , 0);
return res;
}
};
22. 括号生成 - 力扣(LeetCode)
分析
本质上来说,我们需要在 0,1,2,…,2n−1 中选 n 个数(位置),填入左括号。其余 n 位置填入右括号。
所以本题其实就是 77. 组合,但会有一些约束,比如
())(这种就是不合法的。对于括号字符串的任意前缀,右括号的个数不能超过左括号的个数。方法一:枚举当前位置填左括号还是右括号
本质上是
「选或不选」,把填左括号视作「选」,填右括号视作「不选」。(也可以反过来)答疑
问:代码如何保证左右括号都恰好填了 n 个?答:代码中的 open < n 限制左括号至多填 n 个,
i - open < open限制右括号至多填 open 个(不能超过左括号的个数)。由于一共要填 2n 个括号,那么当我们递归到终点时:
- 如果左括号少于 n 个,那么右括号也会少于 n 个,与 i == m 矛盾,因为每填一个括号 i 都会增加 1。
- 如果左括号超过 n 个,与 open < n 矛盾,这句话限制了左括号至多填 n 个。
- 所以递归到终点时,左括号恰好填了 n 个,此时右括号也恰好填了 2n−n=n 个。
class Solution {
public:
vector<string> generateParenthesis(int n) {
int m = n * 2; // 括号长度
vector<string> ans;
string path(m, 0); // 所有括号长度都是一样的 m
// i = 目前填了多少个括号
// open = 左括号个数,i-open = 右括号个数
auto dfs = [&](this auto&& dfs, int i, int open) {
if (i == m) { // 括号构造完毕
ans.emplace_back(path); // 加入答案
return;
}
if (open < n) { // 可以填左括号,选
path[i] = '('; // 直接覆盖
dfs(i + 1, open + 1); // 多了一个左括号
}
if (i - open < open) { // 可以填右括号,不选
path[i] = ')'; // 直接覆盖
dfs(i + 1, open);
}
};
dfs(0, 0);
return ans;
}
};
- 复杂度分析
时间复杂度:分析回溯问题的时间复杂度,有一个通用公式:路径长度×搜索树的叶子数。对于本题,它等于 \(O(n⋅C(2n,n))\)。但由于左右括号的约束,实际上没有这么多叶子,根据 Catalan 数,只有 \(\frac{C(2n,n)}{(n+1)}\)个叶子节点,所以实际的时间复杂度为 \(O(C(2n,n))\)。此外,根据阶乘的 Stirling 公式,时间复杂度也可以表示为 \(O(\frac{4^n}{\sqrt{n}})\)。 - 空间复杂度:O(n)。返回值的空间不计入。
方法二:枚举下一个左括号的位置
整体思路:path中存放左括号的下标,for循环中枚举当前可以放置的右括号个数。
同 77 题,用「枚举选哪个」的思路。
在从左往右填的过程中,要时刻保证右括号的个数不能超过左括号的个数。
如果前面填了 5 个左括号,2 个右括号,那么还能填几个右括号?
至多填 5−2=3 个。
所以枚举(在填下一个左括号之前)填入了 0,1,2,3 个右括号,这样就能得到下一个左括号的位置。
为了方便,代码直接用
balance表示左右括号之差。这样我们枚举的范围就是[0,balance]。注意最后一个左括号的右边还可以填右括号,但无需考虑。填入所有左括号后,剩余的位置我们会自动填入右括号。
class Solution {
public:
vector<string> generateParenthesis(int n) {
vector<string> ans;
vector<int> path; // 记录左括号的下标
// i = 目前填了多少个括号
// balance = 左括号个数 - 右括号个数
auto dfs = [&](this auto&& dfs, int i, int balance) {
if (path.size() == n) {//左括号一旦填完,所有的右括号位置都确定了
string s(n * 2, ')');
for (int j : path) {
s[j] = '(';
}
ans.emplace_back(s);
return;
}
// 枚举填 close=0,1,2,...,balance 个右括号
for (int close = 0; close <= balance; close++) {
// 先填 close 个右括号,然后填 1 个左括号,记录左括号的下标 i+close
path.push_back(i + close);
//目前已经填了i + close + 1个括号
dfs(i + close + 1, balance - close + 1);
path.pop_back();
}
};
dfs(0, 0);
return ans;
}
};
79. 单词搜索 - 力扣(LeetCode)
关于网格图 DFS,可以做做 200. 岛屿数量。
基本思路(优化前)
枚举
i=0,1,2,…,m−1和j=0,1,2,…,n−1,以 (i,j) 为起点开始搜索。同时,我们还需要知道当前匹配到了 word 的第几个字母,所以还需要一个参数
k。定义
dfs(i,j,k)表示当前在board[i][j]这个格子,要匹配word[k],返回在这个状态下最终能否匹配成功(搜索成功)。分类讨论:
如果
board[i][j]!=word[k],匹配失败,返回 false。否则(
board[i][j]==word[k])
- 如果
k=len(word)−1,匹配成功,返回 true。- 否则,枚举 (i,j) 周围的四个相邻格子 (x,y),如果 (x,y) 没有出界,则递归 dfs(x,y,k+1),如果其返回 true,则 dfs(i,j,k) 也返回 true。
- 如果递归周围的四个相邻格子都没有返回 true,则最后返回 false,表示没有搜到。
细节:
递归过程中,为了避免重复访问同一个格子,可以用 vis 数组标记。更简单的做法是,直接修改
board[i][j],将其置为空(或者 0),返回 false 前再恢复成原来的值(恢复现场)。注意返回 true 的时候就不用恢复现场了,因为已经成功搜到 word 了。
class Solution {
static constexpr int DIRS[4][2] = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
public:
bool exist(vector<vector<char>>& board, string word) {
int m = board.size(), n = board[0].size();
auto dfs = [&](this auto&& dfs, int i, int j, int k) -> bool {
if (board[i][j] != word[k]) { // 匹配失败
return false;
}
if (k + 1 == word.length()) { // 匹配成功!
return true;
}
board[i][j] = 0; // 标记访问过
for (auto& [dx, dy] : DIRS) {
int x = i + dx, y = j + dy; // 相邻格子
if (0 <= x && x < m && 0 <= y && y < n && dfs(x, y, k + 1)) { // 没超过边界,并且后续字母都成功匹配
return true;
}
}
board[i][j] = word[k]; // 恢复现场
return false; // 没搜到
};
// 每个格子都可以作为起点
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (dfs(i, j, 0)) {
return true; // 搜到了!
}
}
}
return false; // 没搜到
}
};
第一个优化
比如示例 3,word=ABCB,其中字母 B 出现了 2 次,但 board 中只有 1 个字母 B,所以肯定搜不到 word,直接返回 false。
一般地,如果 word 的某个字母的出现次数,比 board 中的这个字母的出现次数还要多,可以直接返回 false。
第二个优化
启发:如果 word=abcd 但 board 中的 a 很多,d 很少(比如只有一个),那么从 d 开始搜索,能更快地找到答案。(即使我们肉眼去找,这种方法也是更快的)
设 word 的第一个字母在 board 中出现了 x 次,word 的最后一个字母在 board 中出现了 y 次。
如果 y<x,我们可以把 word 反转,相当于从 word 的最后一个字母开始搜索,这样更容易在一开始就满足
board[i][j] != word[k],不会往下递归,递归的总次数更少。加上这两个优化,就可以击败接近 100% 了!其中 Java、C++、Go 和 Rust 都可以跑到 0ms。
class Solution {
static constexpr int DIRS[4][2] = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
public:
bool exist(vector<vector<char>>& board, string word) {
unordered_map<char, int> cnt;
for (auto& row : board) {
for (char c : row) {
cnt[c]++;
}
}
// 优化一
unordered_map<char, int> word_cnt;
for (char c : word) {
if (++word_cnt[c] > cnt[c]) {
return false;
}
}
// 优化二
if (cnt[word.back()] < cnt[word[0]]) {
ranges::reverse(word);
}
int m = board.size(), n = board[0].size();
auto dfs = [&](this auto&& dfs, int i, int j, int k) -> bool {
if (board[i][j] != word[k]) { // 匹配失败
return false;
}
if (k + 1 == word.length()) { // 匹配成功!
return true;
}
board[i][j] = 0; // 标记访问过
for (auto& [dx, dy] : DIRS) {
int x = i + dx, y = j + dy; // 相邻格子
if (0 <= x && x < m && 0 <= y && y < n && dfs(x, y, k + 1)) {
return true; // 搜到了!
}
}
board[i][j] = word[k]; // 恢复现场
return false; // 没搜到
};
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (dfs(i, j, 0)) {
return true; // 搜到了!
}
}
}
return false; // 没搜到
}
};
复杂度分析
- 时间复杂度:\(O(mn3^k)\),其中 m 和 n 分别为 grid 的行数和列数,k 是 word 的长度。除了递归入口,其余递归至多有 3 个分支(因为至少有一个方向是之前走过的),所以每次递归(回溯)的时间复杂度为 \(O(3^k)\),一共回溯 O(mn) 次,所以时间复杂度为 \(O(mn3^k)\)。
- 空间复杂度:\(O(|Σ|+k)\)。其中 |Σ|=52 是字符集合的大小。递归需要 O(k) 的栈空间。部分语言用的数组代替哈希表,可以视作 ∣Σ∣=128。
131. 分割回文串 - 力扣(LeetCode)
方法一:输入的视角(逗号选或不选)
假设每对相邻字符之间有个逗号,那么就看每个逗号是选还是不选。
也可以理解成:是否要把 s[i] 当成分割出的子串的最后一个字符。注意 s[n−1] 一定是最后一个字符,一定要选。
auto dfs = [&](this auto&& dfs, int i, int start)
- 递归参数设计:
i:表示当前探索的字符位置,用于确定子串的右边界s[start...i]start:当前子串的起始位置,分割后的新子串从start开始- 递归树的两个分支:
- 不分割分支:当
i < n-1时,尝试继续扩展右边界i+1,保持start不变- 分割分支:若
s[start...i]是回文,则分割该子串,并递归处理剩余字符串s[i+1...]- 回溯机制:
- 在记录回文子串后 (
push_back),通过pop_back()撤销选择,确保不同路径的状态隔离示例执行流程
以输入
"aab"为例:
- 初始调用:
dfs(0, 0)
- 检查
s[0]是回文- 分割为
"a",递归dfs(1,1)
- 检查
s[1]是回文- 分割为
"a",递归dfs(2,2)
- 检查
s[2]是回文- 分割为
"b",存入结果["a","a","b"]- 回溯后尝试扩展
s[1..2],发现"ab"不是回文- 回溯到初始状态,尝试扩展
s[0..1]
- 发现
"aa"是回文- 分割后递归
dfs(2,2),得到["aa","b"]最终结果:
[["a","a","b"], ["aa","b"]]时间复杂度分析
- 最坏情况:当所有子串都是回文时(如全相同字符),时间复杂度为 O(n*2ⁿ)
- 剪枝优化:通过及时终止非回文路径,实际运行效率优于暴力枚举
该算法通过 DFS 回溯穷举所有可能的分割方式,并利用回文判断进行剪枝,最终得到所有合法的分割方案。
class Solution {
private:
// 判断子串 s[l...r] 是否为回文
bool isPalindrome(string& s, int l, int r) {
while (l < r) {
if (s[l++] != s[r--]) // 双指针向中间逼近,逐字符比较
return false; // 发现不匹配字符立即返回 false
}
return true; // 全部字符匹配,是回文
}
public:
vector<vector<string>> partition(string s) {
int n = s.length();
vector<vector<string>> ans;// 存储所有合法的分割方案
vector<string> path;// 记录当前递归路径中的回文子串
// 考虑 i 后面的逗号怎么选
// start 表示当前这段回文子串的开始位置
auto dfs = [&](this auto&& dfs, int i, int start) {
if (i == n) { // 扫描到字符串末尾,s 分割完毕
ans.emplace_back(path);// 将当前路径中的子串组合存入结果
return;
}
// 不选 i 和 i+1 之间的逗号(i=n-1 时一定要选)
// 分支1:不在此处分割,继续向右扩展子串范围(前提是还能扩展),在dfs(i + 1)体现出来
if (i < n - 1) {
// 考虑 i+1 后面的逗号怎么选
dfs(i + 1, start);
}
// 选 i 和 i+1 之间的逗号(把 s[i] 作为子串的最后一个字符)
// 分支2:若 s[start...i] 是回文,尝试在此处分割
if (isPalindrome(s, start, i)) {
path.push_back(s.substr(start, i - start + 1));
// 考虑 i+1 后面的逗号怎么选
// start=i+1 表示下一个子串从 i+1 开始
// 递归处理剩余部分,从下一个位置开始新的分割
dfs(i + 1, i + 1);
// 回溯:移除当前子串,尝试其他分割可能性
path.pop_back(); // 恢复现场
}
};
dfs(0, 0);
return ans;
}
};
复杂度分析
- 时间复杂度:\(O(n2^n )\),其中 n 为 s 的长度。每次都是选或不选,递归次数为一个满二叉树的节点个数,那么一共会递归 $O(2^ n ) $次(等比数列和),再算上判断回文和加入答案时需要 O(n) 的时间,所以时间复杂度为 \(O(n2 ^n )\)。
- 空间复杂度:O(n)。返回值的空间不计。
方法二:答案的视角(枚举子串结束位置)
🛠 算法流程示例
以输入
s = "aab"为例:
- 初始调用
dfs(0):
i=0,遍历j=0,1,2:
j=0:"a"是回文 → 存入path→ 递归dfs(1)
- 在
dfs(1)中,遍历j=1,2:
j=1:"a"是回文 → 存入 → 递归dfs(2)
j=2:"b"是回文 → 存入 → 递归dfs(3)→ 触发终止条件 → 结果["a","a","b"]j=2:"ab"非回文 → 跳过j=1:"aa"是回文 → 存入 → 递归dfs(2)
j=2:"b"是回文 → 存入 → 递归dfs(3)→ 结果["aa","b"]j=2:"aab"非回文 → 跳过
⏱ 时间复杂度分析
- 最坏情况:所有子串均为回文(如全相同字符),时间复杂度为 O(n·2ⁿ)。
- 实际优化:通过及时跳过非回文子串,避免无效递归,提升效率。
💡 代码亮点
- 简洁的回溯结构:通过单层循环枚举所有可能的分割点,逻辑清晰。
- 原地修改避免拷贝:直接操作引用
string& s,减少内存开销。- 明确的递归语义:
i始终表示当前待处理区间的起点,符合直觉。
该算法通过深度优先搜索和回溯,高效枚举所有可能的分割方案,并通过回文剪枝优化性能,最终返回所有合法的回文分割结果。
class Solution {
bool isPalindrome(string& s, int left, int right) {
while (left < right) {
if (s[left++] != s[right--]) {
return false;
}
}
return true;
}
public:
vector<vector<string>> partition(string s) {
int n = s.length();
vector<vector<string>> ans;
vector<string> path;
// 考虑 s[i] ~ s[n-1] 怎么分割
//i 表示当前待分割区间的起始位置
auto dfs = [&](this auto&& dfs, int i) {
if (i == n) { // s 分割完毕
ans.emplace_back(path);
return;
}
for (int j = i; j < n; j++) { // // 枚举所有可能的结束位置 j(从 i 到字符串末尾)
if (isPalindrome(s, i, j)) {// 检查子串 s[i...j] 是否为回文
path.push_back(s.substr(i, j - i + 1)); // 分割!
// 考虑剩余的 s[j+1] ~ s[n-1] 怎么分割
dfs(j + 1);
path.pop_back(); // 恢复现场
}
}
};
dfs(0);
return ans;
}
};
复杂度分析
- 时间复杂度:\(O(n2^n )\),其中 n 为 s 的长度。答案的长度至多为逗号子集的个数,即 \(O(2^ n )\),因此会递归 \(O(2 ^n )\) 次,再算上判断回文和加入答案时需要 O(n) 的时间,所以时间复杂度为 \(O(n2^ n )\)。
- 空间复杂度:O(n)。返回值的空间不计。
51. N 皇后 - 力扣(LeetCode)
排列型回溯,简洁高效!
问:本题和 46. 全排列 的关系是什么?
答:由于每行恰好放一个皇后,记录每行的皇后放在哪一列,可以得到一个 [0,n−1] 的排列 queens。示例 1 的两个图,分别对应排列 [1,3,0,2] 和 [2,0,3,1]。所以我们本质上是在枚举列号的全排列。
问:如何 O(1) 判断两个皇后互相攻击?
答:由于我们保证了每行每列恰好放一个皇后,所以只需检查斜方向。对于 ↗ 方向的格子,行号加列号是不变的。对于 ↖ 方向的格子,行号减列号是不变的。如果两个皇后,行号加列号相同,或者行号减列号相同,那么这两个皇后互相攻击。
问:如何 O(1) 判断当前位置被之前放置的某个皇后攻击到?
答:额外用两个数组 diag1和 diag2分别标记之前放置的皇后的行号加列号,以及行号减列号。如果当前位置的行号加列号在 diag1中(标记为 true),或者当前位置的行号减列号在 diag 2中(标记为 true),那么当前位置被之前放置的皇后攻击到,不能放皇后。
关键点解释:
数据结构选择
queens数组:记录每行皇后所在的列,用于最终生成棋盘字符串。col、diag1、diag2:快速判断冲突的标记数组,时间复杂度从暴力法的O(n!)优化到O(n!),但常数项大幅降低。- 使用
vector<int>而非vector<bool>:因C++标准库对vector<bool>有特殊优化(压缩存储),访问性能较差。对角线索引计算
- 主对角线(左上到右下):同一主对角线上所有点的
r + c值相同,范围为0到2n-2。- 副对角线(右上到左下):同一副对角线上所有点的
r - c值相同,为避免负数,偏移n-1,即索引为r - c + n - 1,范围0到2n-2。DFS与回溯逻辑
- 递归终止条件:当
r == n时,说明所有行已合法放置皇后,生成棋盘字符串并保存。- 循环遍历列:对每一列尝试放置皇后,若当前列和两个对角线均未被占用,则标记状态并递归处理下一行。
- 状态恢复:递归返回后需撤销当前列的占用标记,以便尝试其他分支。
Lambda表达式特性
- 使用
this auto&& dfs语法(C++23特性)允许lambda递归调用自身,等效于显式传递函数对象。在C++中,布尔值
true和false可以隐式转换为整数1和0,而整数也能隐式转换为布尔值(非零为true,零为false)。代码中使用vector<int>存储标记状态,其本质是通过整数0和1表示布尔逻辑。以下是具体解释:diag1[r + c] = true; // 等价于 diag1[r + c] = 1
diag2[rc] = false; // 等价于 diag2[rc] = 0为什么使用
vector<int>而非vector<bool>?**
- 性能考量:
vector<bool>是C++标准库的一个特化版本,内部使用位压缩存储(每个元素占1位)。这种优化导致:
- 访问效率低:每次读写需位操作(掩码、移位),影响性能。
- 内存局部性差:位操作可能引发缓存未命中。
vector<int>的优势:
- 直接存储整数(如
0和1),访问无需位操作,速度更快。- 内存对齐更好,适合高频访问场景(如DFS回溯)。
- 内存权衡:
虽然vector<int>占用更多内存(例如4字节/intvs1位/bool),但对于n较小的问题(如N皇后问题),内存消耗可忽略不计。
class Solution {
public:
vector<vector<string>> solveNQueens(int n) {
vector<vector<string>> ans;
vector<int> queens(n); // 皇后放在 (r,queens[r])
vector<int> col(n, 0); // 标记列是否被占用
vector<int> diag1(2 * n - 1, 0); // 主对角线(左上到右下)标记,公式:r + c
vector<int> diag2(2 * n - 1, 0); // 副对角线(右上到左下)标记,公式:r - c + n - 1
// vector<int> 效率比 vector<bool> 高
auto dfs = [&](this auto&& dfs, int r) {//每层递归枚举行
if (r == n) {// 递归终止条件:已成功放置n个皇后
vector<string> board(n);
// 生成当前棋盘配置
for (int i = 0; i < n; i++) {
// queens[i]是当前行皇后所在的列,构造类似"..Q.."的字符串
board[i] = string(queens[i], '.') + 'Q' + string(n - 1 - queens[i], '.');//左侧填'.' + 放皇后 + 右侧填'.'
}
ans.push_back(board);
return;
}
// 在 (r,c) 放皇后
for (int c = 0; c < n; c++) {
int rc = r - c + n - 1;// 计算副对角线的唯一索引
if (!col[c] && !diag1[r + c] && !diag2[rc]) { // 判断能否放皇后
queens[r] = c; // 直接覆盖,无需恢复现场
col[c] = diag1[r + c] = diag2[rc] = true; // 皇后占用了 c 列和两条斜线
dfs(r + 1);
col[c] = diag1[r + c] = diag2[rc] = false; // 恢复现场
}
}
};
dfs(0);
return ans;
}
};
复杂度分析
-
时间复杂度:\(O(n^2⋅n!)\)。搜索树中至多有 O(n!) 个叶子,每个叶子生成答案每次需要 \(O(n^2 )\) 的时间,所以时间复杂度为 \(O(n^2 ⋅n!)\)。实际上搜索树中远没有这么多叶子,n=9 时只有 352 种放置方案,远远小于 9!=362880。更加准确的方案数可以参考 OEIS A000170,为 \(O( \frac{n!}{2.54 ^ n})\)。
-
空间复杂度:O(n)。返回值的空间不计入。







浙公网安备 33010602011771号