图论专题(下)

图论专题(下)------ 回溯算法

本篇覆盖图论专题后 8 题:全排列、子集、电话号码的字母组合、组合总和、括号生成、单词搜索、分割回文串、N 皇后。这 8 道题全部基于回溯算法,是竞赛中出现频率极高的搜索技巧,也是 Hot100 里主观难度最高的专题之一。


一、前置知识:回溯算法框架

回溯的本质是带撤销的暴力枚举,在一棵决策树上做 DFS,每次做一个选择,递归处理后撤销这个选择,恢复现场,再尝试下一个选择。

复制代码
回溯框架:

void backtrack(参数) {
    if (满足结束条件) {
        收集结果;
        return;
    }
    for (选择 in 当前所有可选项) {
        做选择;          // 将选择加入路径
        backtrack(...);  // 递归,进入下一层
        撤销选择;        // 恢复现场,回到上一层
    }
}

回溯三要素

  1. 路径:已经做出的选择序列
  2. 选择列表:当前可以做的选择
  3. 结束条件:到达决策树的叶节点,收集结果

理解回溯的核心是理解决策树的形状,每道题的区别在于树的结构(深度、宽度、剪枝条件)不同。


二、全排列(#46)

题意

给一个不含重复数字的整数数组 nums,返回其所有可能的全排列。

复制代码
输入:[1, 2, 3]
输出:
[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

思路

决策树:每层从剩余未选的数字中选一个放到当前位置,直到所有数字都被选完。

复制代码
                    []
          /          |          \
        [1]         [2]         [3]
       /   \       /   \       /   \
    [1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
      |     |     |     |     |     |
  [1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]

used 数组标记每个数字是否已被选择,避免重复选同一个数字。

逐步演示(以 [1,2,3] 为例)

复制代码
backtrack(path=[], used=[F,F,F])
  选1:path=[1], used=[T,F,F]
    选2:path=[1,2], used=[T,T,F]
      选3:path=[1,2,3],收集结果
      撤销3:path=[1,2], used=[T,T,F]
    撤销2:path=[1], used=[T,F,F]
    选3:path=[1,3], used=[T,F,T]
      选2:path=[1,3,2],收集结果
      撤销2:path=[1,3], used=[T,F,T]
    撤销3:path=[1], used=[T,F,F]
  撤销1:path=[], used=[F,F,F]
  选2:... (类似)
  选3:... (类似)

代码

cpp 复制代码
class Solution {
    vector<vector<int>> res;
    vector<int> path;
    vector<bool> used;

    void backtrack(vector<int>& nums) {
        // 结束条件:路径长度等于数组长度,收集结果
        if (path.size() == nums.size()) {
            res.push_back(path);
            return;
        }
        for (int i = 0; i < nums.size(); i++) {
            if (used[i]) continue;  // 已选过,跳过
            // 做选择
            used[i] = true;
            path.push_back(nums[i]);
            // 递归
            backtrack(nums);
            // 撤销选择
            path.pop_back();
            used[i] = false;
        }
    }

public:
    vector<vector<int>> permute(vector<int>& nums) {
        used.assign(nums.size(), false);
        backtrack(nums);
        return res;
    }
};

复杂度

  • 时间 :O(n⋅n!)O(n \cdot n!)O(n⋅n!),共 n!n!n! 个排列,每个排列长度 nnn
  • 空间 :O(n)O(n)O(n),递归栈深度为 nnn

三、子集(#78)

题意

给一个不含重复元素的整数数组 nums,返回其所有可能的子集(幂集)。

复制代码
输入:[1, 2, 3]
输出:
[[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]

思路

子集问题的决策树:每层从当前位置之后的元素中选,保证不回头,避免重复子集。

和全排列的区别:

  • 全排列每层都从全部元素里选(用 used 防重)

  • 子集每层从 start 往后选(保证递增顺序,天然去重)

  • 子集每进入一层就收集一次结果(不只在叶节点收集)

    决策树(每个节点都收集为一个子集):

    [] ← 收集 []
    ├─ [1] ← 收集 [1]
    │ ├─ [1,2] ← 收集 [1,2]
    │ │ └─ [1,2,3] ← 收集 [1,2,3]
    │ └─ [1,3] ← 收集 [1,3]
    ├─ [2] ← 收集 [2]
    │ └─ [2,3] ← 收集 [2,3]
    └─ [3] ← 收集 [3]

start 参数控制每层的起始位置,每次递归时传入 i+1,确保不会选到已选元素左边的元素。

代码

cpp 复制代码
class Solution {
    vector<vector<int>> res;
    vector<int> path;

    void backtrack(vector<int>& nums, int start) {
        res.push_back(path); // 每进入一层就收集当前路径
        for (int i = start; i < nums.size(); i++) {
            path.push_back(nums[i]);      // 做选择
            backtrack(nums, i + 1);       // 下一层从 i+1 开始,不回头
            path.pop_back();              // 撤销选择
        }
    }

public:
    vector<vector<int>> subsets(vector<int>& nums) {
        backtrack(nums, 0);
        return res;
    }
};

复杂度

  • 时间 :O(n⋅2n)O(n \cdot 2^n)O(n⋅2n),共 2n2^n2n 个子集,每个子集平均长度 n/2n/2n/2
  • 空间 :O(n)O(n)O(n),递归栈深度

四、电话号码的字母组合(#17)

题意

给一个包含数字 2-9 的字符串 digits,返回数字能表示的所有可能的字母组合(手机九宫格)。

复制代码
输入:"23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

2→abc,3→def

思路

每个数字对应一组字母,依次对每个数字做选择,选完所有数字后收集结果。

这是多叉树的 DFS ,树的深度等于 digits 的长度,每层的宽度等于当前数字对应的字母数(2或3或4)。

复制代码
digits = "23"

第0层:选数字'2'对应的字母
  选'a'→
    第1层:选数字'3'对应的字母
      选'd'→ 收集"ad"
      选'e'→ 收集"ae"
      选'f'→ 收集"af"
  选'b'→
    第1层:...收集"bd","be","bf"
  选'c'→
    第1层:...收集"cd","ce","cf"

idx 参数表示当前处理到 digits 的第几个字符,当 idx == digits.size() 时收集结果。

代码

cpp 复制代码
class Solution {
    vector<string> res;
    string path;
    unordered_map<char, string> phoneMap = {
        {'2', "abc"}, {'3', "def"}, {'4', "ghi"}, {'5', "jkl"},
        {'6', "mno"}, {'7', "pqrs"}, {'8', "tuv"}, {'9', "wxyz"}
    };

    void backtrack(string& digits, int idx) {
        if (idx == digits.size()) {
            res.push_back(path); // 所有数字都处理完,收集结果
            return;
        }
        string letters = phoneMap[digits[idx]]; // 当前数字对应的字母
        for (char c : letters) {
            path.push_back(c);           // 做选择
            backtrack(digits, idx + 1);  // 处理下一个数字
            path.pop_back();             // 撤销选择
        }
    }

public:
    vector<string> letterCombinations(string digits) {
        if (digits.empty()) return {};
        backtrack(digits, 0);
        return res;
    }
};

复杂度

  • 时间 :O(4n⋅n)O(4^n \cdot n)O(4n⋅n),nnn 为 digits 长度,每个数字最多对应 4 个字母
  • 空间 :O(n)O(n)O(n),递归栈深度

五、组合总和(#39)

题意

给一个无重复元素的整数数组 candidates 和目标值 target,找出所有可以使数字之和等于 target 的组合,每个数字可以重复选取。

复制代码
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]

思路

和子集问题类似,用 start 控制不回头,但允许重复选同一个数(递归时传 i 而不是 i+1)。

remain(剩余目标值)做剪枝:

  • 如果 remain == 0,收集结果
  • 如果 remain < 0,当前路径已经超过目标,直接返回

先对 candidates 排序,这样当 candidates[i] > remain 时,后面的数更大,可以直接 break 剪枝,减少无效搜索。

复制代码
candidates=[2,3,6,7], target=7

backtrack(start=0, remain=7)
  选2:remain=5
    选2:remain=3
      选2:remain=1
        选2:remain=-1 < 0,剪枝返回
        选3:remain=-2 < 0,剪枝返回
      撤销2
      选3:remain=0,收集[2,2,3] ✓
      撤销3
      选6:remain=-3 < 0,剪枝返回(后面更大,直接break)
    撤销2
    选3:remain=2
      选3:remain=-1 < 0,剪枝
    撤销3
    选6:remain=-1 < 0,剪枝,break
  撤销2
  选3:remain=4
    选3:remain=1
      选3:remain=-2,剪枝,break
    撤销3
    选6:remain=-2,剪枝,break
  撤销3
  选6:remain=1
    选6:remain=-5,剪枝,break
  撤销6
  选7:remain=0,收集[7] ✓
  撤销7

代码

cpp 复制代码
class Solution {
    vector<vector<int>> res;
    vector<int> path;

    void backtrack(vector<int>& candidates, int start, int remain) {
        if (remain == 0) {
            res.push_back(path); // 找到一个合法组合
            return;
        }
        for (int i = start; i < candidates.size(); i++) {
            if (candidates[i] > remain) break; // 已排序,后面更大,直接剪枝
            path.push_back(candidates[i]);
            backtrack(candidates, i, remain - candidates[i]); // i 不是 i+1,允许重复选
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end()); // 排序,方便剪枝
        backtrack(candidates, 0, target);
        return res;
    }
};

复杂度

  • 时间 :O(nT/M)O(n^{T/M})O(nT/M),TTT 为 target,MMM 为最小元素,指数级但剪枝后实际很快
  • 空间 :O(T/M)O(T/M)O(T/M),递归最深层数

六、括号生成(#22)

题意

给整数 n,生成所有由 n 对括号组成的有效括号组合。

复制代码
输入:n=3
输出:["((()))","(()())","(())()","()(())","()()()"]

思路

回溯,但有明确的剪枝条件,使决策树大幅缩小。

两个关键变量:

  • left:已放置的左括号数量
  • right:已放置的右括号数量

合法性约束(剪枝条件):

  • 任何时候 left < right,说明右括号多于左括号,已经非法,直接返回
  • left > nright > n,超出数量限制,返回
  • 结束条件:left == right == n,收集结果

每次选择只有两种:放左括号或放右括号。

复制代码
n=2 的决策树:

""
├─ "(" (left=1,right=0)
│   ├─ "((" (left=2,right=0)
│   │   └─ "(()" (left=2,right=1)
│   │       └─ "(())" ✓
│   └─ "()" (left=1,right=1)
│       └─ "()(" (left=2,right=1)
│           └─ "()()" ✓
└─ ")" → right>left,非法,剪枝

代码

cpp 复制代码
class Solution {
    vector<string> res;
    string path;

    void backtrack(int n, int left, int right) {
        if (path.size() == 2 * n) {
            res.push_back(path); // 长度达到 2n,收集结果
            return;
        }
        // 放左括号:左括号数量还没达到 n
        if (left < n) {
            path.push_back('(');
            backtrack(n, left + 1, right);
            path.pop_back();
        }
        // 放右括号:右括号数量必须小于左括号数量(保证合法性)
        if (right < left) {
            path.push_back(')');
            backtrack(n, left, right + 1);
            path.pop_back();
        }
    }

public:
    vector<string> generateParenthesis(int n) {
        backtrack(n, 0, 0);
        return res;
    }
};

复杂度

  • 时间 :O(4nn)O(\dfrac{4^n}{\sqrt{n}})O(n 4n),这是第 nnn 个卡特兰数,有效括号序列的数量
  • 空间 :O(n)O(n)O(n),递归深度为 2n2n2n

七、单词搜索(#79)

题意

给一个字符矩阵 board 和字符串 word,判断 word 是否存在于矩阵中。单词必须按上下左右相邻顺序构成,且同一个格子不能重复使用。

复制代码
输入:
board =
A B C E
S F C S
A D E E
word = "ABCCED"

输出:true(路径:A→B→C→C→E→D)

思路

对矩阵中每个格子作为起点做 DFS,尝试匹配 word

idx 表示当前需要匹配 word[idx],每次匹配成功就向四个方向递归尝试匹配下一个字符。

防止重复使用同一格子 :进入格子时把它临时标记(比如改成 '#'),回溯时还原,避免开辟额外的 visited 数组。

剪枝

  • 越界或当前格子不等于 word[idx],直接返回 false

  • idx == word.size(),所有字符匹配完成,返回 true

    board: word = "ABCCED"
    A B C E
    S F C S
    A D E E

    从(0,0)='A'出发:
    匹配A(idx=0) ✓,标记(0,0)='#'
    向右到(0,1)='B':
    匹配B(idx=1) ✓,标记(0,1)='#'
    向右到(0,2)='C':
    匹配C(idx=2) ✓,标记(0,2)='#'
    向下到(1,2)='C':
    匹配C(idx=3) ✓,标记(1,2)='#'
    向下到(2,2)='E':
    匹配E(idx=4) ✓,标记(2,2)='#'
    向左到(2,1)='D':
    匹配D(idx=5) ✓,idx==6==word.size(),返回true ✓

代码

cpp 复制代码
class Solution {
public:
    bool dfs(vector<vector<char>>& board, string& word, int i, int j, int idx) {
        if (idx == word.size()) return true; // 所有字符匹配完成
        int m = board.size(), n = board[0].size();
        if (i < 0 || i >= m || j < 0 || j >= n) return false; // 越界
        if (board[i][j] != word[idx]) return false;            // 字符不匹配

        char tmp = board[i][j];
        board[i][j] = '#'; // 临时标记,防止重复使用

        bool found = dfs(board, word, i+1, j, idx+1) ||
                     dfs(board, word, i-1, j, idx+1) ||
                     dfs(board, word, i, j+1, idx+1) ||
                     dfs(board, word, i, j-1, idx+1);

        board[i][j] = tmp; // 回溯,还原格子
        return found;
    }

    bool exist(vector<vector<char>>& board, string word) {
        int m = board.size(), n = board[0].size();
        for (int i = 0; i < m; i++)
            for (int j = 0; j < n; j++)
                if (dfs(board, word, i, j, 0)) return true;
        return false;
    }
};

复杂度

  • 时间 :O(mn⋅4L)O(mn \cdot 4^L)O(mn⋅4L),LLL 为 word 长度,每个格子作为起点,DFS 最多扩展 4L4^L4L 条路径
  • 空间 :O(L)O(L)O(L),递归栈深度为 LLL

八、分割回文串(#131)

题意

给字符串 s,将 s 分割成若干子串,使得每个子串都是回文串,返回所有可能的分割方案。

复制代码
输入:"aab"
输出:[["a","a","b"],["aa","b"]]

思路

回溯,start 表示当前从 s[start] 开始分割。枚举每个可能的切割点 end,如果 s[start..end] 是回文串,就把它加入路径,递归处理 s[end+1..]

判断回文串:双指针从两端向中间对比。

剪枝s[start..end] 不是回文串就不递归,直接跳过。

复制代码
s = "aab"

backtrack(start=0)
  end=0,s[0..0]="a",是回文 ✓,path=["a"]
    backtrack(start=1)
      end=1,s[1..1]="a",是回文 ✓,path=["a","a"]
        backtrack(start=2)
          end=2,s[2..2]="b",是回文 ✓,path=["a","a","b"]
            start==3==s.size(),收集["a","a","b"] ✓
          撤销"b"
        撤销"a"
      end=2,s[1..2]="ab",不是回文,跳过
    撤销"a"
  end=1,s[0..1]="aa",是回文 ✓,path=["aa"]
    backtrack(start=2)
      end=2,s[2..2]="b",是回文 ✓,path=["aa","b"]
        收集["aa","b"] ✓
      撤销"b"
    撤销"aa"
  end=2,s[0..2]="aab",不是回文,跳过

代码

cpp 复制代码
class Solution {
    vector<vector<string>> res;
    vector<string> path;

    bool isPalindrome(string& s, int left, int right) {
        while (left < right) {
            if (s[left] != s[right]) return false;
            left++; right--;
        }
        return true;
    }

    void backtrack(string& s, int start) {
        if (start == s.size()) {
            res.push_back(path); // 整个字符串都被切完了,收集结果
            return;
        }
        for (int end = start; end < s.size(); end++) {
            if (!isPalindrome(s, start, end)) continue; // 不是回文,跳过
            path.push_back(s.substr(start, end - start + 1)); // 做选择
            backtrack(s, end + 1);                             // 递归处理剩余部分
            path.pop_back();                                   // 撤销选择
        }
    }

public:
    vector<vector<string>> partition(string s) {
        backtrack(s, 0);
        return res;
    }
};

复杂度

  • 时间 :O(n⋅2n)O(n \cdot 2^n)O(n⋅2n),最多 2n−12^{n-1}2n−1 种分割方式,每次判断回文 O(n)O(n)O(n)
  • 空间 :O(n)O(n)O(n),递归栈深度

九、N 皇后(#51)

题意

给整数 n,返回 n 皇后问题的所有不同解法。皇后不能互相攻击,即同行、同列、同对角线上不能有两个皇后。

复制代码
输入:n=4
输出:
[".Q..",  ["..Q.",
 "...Q",   "Q...",
 "Q...",   "...Q",
 "..Q."]   ".Q.."]

思路

逐行放置皇后,每行只放一个皇后,因此同行冲突天然不存在,只需检查:

  • 列冲突:该列是否已有皇后
  • 主对角线冲突 :同一主对角线的格子满足 行 - 列 相同
  • 副对角线冲突 :同一副对角线的格子满足 行 + 列 相同

用三个集合分别记录已被占用的列、主对角线、副对角线,检查冲突 O(1)O(1)O(1)。

复制代码
n=4,逐行放置:

第0行:尝试列0,1,2,3
  列0:col={0},diag1={0-0=0},diag2={0+0=0},放置
    第1行:尝试列0,1,2,3
      列0:col有0,冲突
      列1:diag2有0+0=0,现在1行1列 1+1=2 不冲突;diag1有0,现在1-1=0冲突
      列2:检查:col无2,diag1: 1-2=-1 无,diag2: 1+2=3 无 → 放置
        第2行:尝试列0,1,2,3
          列0:diag2: 2+0=2,col有0冲突
          列1:diag2: 2+1=3 冲突(第1行放了2列,3在diag2中)?
               实际diag2={0,3},2+1=3 冲突
          列2:col有2冲突
          列3:检查全部无冲突 → 放置
            第3行:全部列都冲突 → 回溯
          撤销列3
        第2行无解,回溯
      撤销列2
      列3:diag1: 1-3=-2 无;diag2: 1+3=4 无;col无3 → 放置
        第2行:
          列0:col无0,diag1: 2-0=2 无,diag2: 2+0=2 无 → 放置
            第3行:
              列0:col有0
              列1:diag1: 3-1=2,col有?diag2:3+1=4 冲突
              列2:col无2,diag1: 3-2=1 无,diag2: 3+2=5 无 → 放置
                第4行已出界,收集结果:
                  .Q..
                  ...Q
                  Q...
                  ..Q. ✓

代码

cpp 复制代码
class Solution {
    vector<vector<string>> res;
    vector<string> board;
    unordered_set<int> cols, diag1, diag2; // 列、主对角线(行-列)、副对角线(行+列)

    void backtrack(int row, int n) {
        if (row == n) {
            res.push_back(board); // 所有行放完,收集结果
            return;
        }
        for (int col = 0; col < n; col++) {
            // 检查冲突
            if (cols.count(col)) continue;
            if (diag1.count(row - col)) continue;
            if (diag2.count(row + col)) continue;

            // 放置皇后
            board[row][col] = 'Q';
            cols.insert(col);
            diag1.insert(row - col);
            diag2.insert(row + col);

            backtrack(row + 1, n);

            // 撤销皇后
            board[row][col] = '.';
            cols.erase(col);
            diag1.erase(row - col);
            diag2.erase(row + col);
        }
    }

public:
    vector<vector<string>> solveNQueens(int n) {
        board.assign(n, string(n, '.')); // 初始化棋盘
        backtrack(0, n);
        return res;
    }
};

复杂度

  • 时间 :O(n!)O(n!)O(n!),第 0 行有 nnn 种选择,第 1 行最多 n−1n-1n−1 种,以此类推
  • 空间 :O(n)O(n)O(n),递归栈深度 + 三个集合各最多 nnn 个元素

十、本篇小结

题目 树的结构 收集时机 关键剪枝 时间
全排列 每层从全部元素选,used防重 叶节点(路径满) used标记 O(n⋅n!)O(n \cdot n!)O(n⋅n!)
子集 每层从start往后选,不回头 每个节点 start递增 O(n⋅2n)O(n \cdot 2^n)O(n⋅2n)
电话号码字母组合 每层处理一个数字的所有字母 叶节点(处理完所有数字) O(4n⋅n)O(4^n \cdot n)O(4n⋅n)
组合总和 每层从start往后选,可重复 remain==0 排序+remain<0剪枝 指数级
括号生成 每层只有放左/右括号两种选择 路径长度==2n right<left才能放右括号 O(4n/n)O(4^n/\sqrt{n})O(4n/n )
单词搜索 矩阵上的DFS,四个方向 idx==word.size() 越界或字符不匹配 O(mn⋅4L)O(mn \cdot 4^L)O(mn⋅4L)
分割回文串 每层枚举切割点 start==s.size() 非回文串不递归 O(n⋅2n)O(n \cdot 2^n)O(n⋅2n)
N 皇后 逐行放置,每列一个皇后 row==n 列/对角线冲突检查 O(n!)O(n!)O(n!)

回溯的本质是暴力搜索加剪枝。所有回溯题的代码结构都一样,区别只在于:

  1. 选择列表:每层可以做哪些选择
  2. 路径约束:什么时候某个选择非法(直接跳过或剪枝)
  3. 结束条件:什么时候收集结果

把这三个问题想清楚,回溯题就能套框架写出来。

相关推荐
懒惰的coder1 小时前
MPC算法
算法
余俊晖1 小时前
图文混合文档的轻量级多模态listwise重排框架:Rank-Nexus
人工智能·算法·机器学习
小许同学记录成长1 小时前
三维编辑功能实现
qt·算法·无人机
平行侠1 小时前
026FFT快速乘法 - 从信号处理到大数计算的革命
数据结构·算法·信号处理
Controller-Inversion1 小时前
240. 搜索二维矩阵 II
线性代数·算法·矩阵
计算机安禾1 小时前
【c++面向对象编程】第4篇:类与对象(三):拷贝构造函数与深浅拷贝问题
开发语言·c++·算法
C雨后彩虹1 小时前
猴子爬山问题
java·数据结构·算法·华为·面试
y = xⁿ1 小时前
20天速通LeetCodeday13:关于回溯
算法
计算机安禾1 小时前
【c++面向对象编程】第1篇:从C到C++:面向对象编程思想入门
c语言·c++·算法