3.6-3.11回溯

46. 全排列 - 力扣(LeetCode)

image-20250310200148245

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;
    }
};

这个解法利用位运算高效生成所有子集,具体思路如下:

步骤解析:

  1. 子集总数:一个包含n个元素的集合有2ⁿ个子集。每个元素有选或不选两种状态,对应二进制位的0或1。
  2. 枚举所有子集:通过遍历从0到2ⁿ-1的所有整数,每个整数i的二进制表示对应一个子集的选择情况。
  3. 位判断元素选择:对于每个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)

image-20250310235534450

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−1j=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)

  1. 递归参数设计
    • i:表示当前探索的字符位置,用于确定子串的右边界 s[start...i]
    • start:当前子串的起始位置,分割后的新子串从 start 开始
  2. 递归树的两个分支
    • 不分割分支:当 i < n-1 时,尝试继续扩展右边界 i+1,保持 start 不变
    • 分割分支:若 s[start...i] 是回文,则分割该子串,并递归处理剩余字符串 s[i+1...]
  3. 回溯机制
    • 在记录回文子串后 (push_back),通过 pop_back() 撤销选择,确保不同路径的状态隔离

示例执行流程

以输入 "aab" 为例:

  1. 初始调用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" 为例:

  1. 初始调用 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ⁿ)
  • 实际优化:通过及时跳过非回文子串,避免无效递归,提升效率。

💡 代码亮点

  1. 简洁的回溯结构:通过单层循环枚举所有可能的分割点,逻辑清晰。
  2. 原地修改避免拷贝:直接操作引用 string& s,减少内存开销。
  3. 明确的递归语义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]。所以我们本质上是在枚举列号的全排列。

img

问:如何 O(1) 判断两个皇后互相攻击?

答:由于我们保证了每行每列恰好放一个皇后,所以只需检查斜方向对于 ↗ 方向的格子,行号加列号是不变的。对于 ↖ 方向的格子,行号减列号是不变的。如果两个皇后,行号加列号相同,或者行号减列号相同,那么这两个皇后互相攻击。

image-20250313162928973

问:如何 O(1) 判断当前位置被之前放置的某个皇后攻击到?

答:额外用两个数组 diag1和 diag2分别标记之前放置的皇后的行号加列号,以及行号减列号。如果当前位置的行号加列号在 diag1中(标记为 true),或者当前位置的行号减列号在 diag 2中(标记为 true),那么当前位置被之前放置的皇后攻击到,不能放皇后。

image-20250313163419477

关键点解释:

  1. 数据结构选择

    • queens数组:记录每行皇后所在的列,用于最终生成棋盘字符串。
    • coldiag1diag2:快速判断冲突的标记数组,时间复杂度从暴力法的O(n!)优化到O(n!),但常数项大幅降低。
    • 使用vector<int>而非vector<bool>:因C++标准库对vector<bool>有特殊优化(压缩存储),访问性能较差。
  2. 对角线索引计算

    • 主对角线(左上到右下):同一主对角线上所有点的r + c值相同,范围为02n-2
    • 副对角线(右上到左下):同一副对角线上所有点的r - c值相同,为避免负数,偏移n-1,即索引为r - c + n - 1,范围02n-2
  3. DFS与回溯逻辑

    • 递归终止条件:当r == n时,说明所有行已合法放置皇后,生成棋盘字符串并保存。
    • 循环遍历列:对每一列尝试放置皇后,若当前列和两个对角线均未被占用,则标记状态并递归处理下一行。
    • 状态恢复:递归返回后需撤销当前列的占用标记,以便尝试其他分支。
  4. Lambda表达式特性

    • 使用this auto&& dfs语法(C++23特性)允许lambda递归调用自身,等效于显式传递函数对象。
  5. 在C++中,布尔值truefalse可以隐式转换为整数10,而整数也能隐式转换为布尔值(非零为true,零为false)。代码中使用vector<int>存储标记状态,其本质是通过整数01表示布尔逻辑。以下是具体解释:

    diag1[r + c] = true; // 等价于 diag1[r + c] = 1
    diag2[rc] = false; // 等价于 diag2[rc] = 0

  6. 为什么使用vector<int>而非vector<bool>?**

    • 性能考量
      vector<bool>是C++标准库的一个特化版本,内部使用位压缩存储(每个元素占1位)。这种优化导致:
      • 访问效率低:每次读写需位操作(掩码、移位),影响性能。
      • 内存局部性差:位操作可能引发缓存未命中。
    • vector<int>的优势
      • 直接存储整数(如01),访问无需位操作,速度更快。
      • 内存对齐更好,适合高频访问场景(如DFS回溯)。
    • 内存权衡
      虽然vector<int>占用更多内存(例如4字节/int vs 1位/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)。返回值的空间不计入。


    Weixin Photo Editor_20250313165426

posted @ 2025-03-13 23:35  七龙猪  阅读(4)  评论(0)    收藏  举报
-->