Skip to content

回溯算法

回溯算法的本质是对一棵决策树的深度优先遍历。树上每个节点代表一次"选择",从根到叶的一条路径就是一个完整方案。算法通过"做选择 → 递归 → 撤销选择"三步穷举所有路径,再用剪枝砍掉不合法的分支。只要理解了这棵决策树的形状,所有回溯题都是同一个框架的变体。

与网格 DFS 的关系:两者底层都是 DFS,但目的不同——回溯在隐式决策树上穷举方案(选择→递归→撤销选择),网格 DFS 在显式图/网格上搜索连通区域(访问→标记→扩展,不撤销标记)。网格搜索类问题见深度优先搜索专题

前置知识

回溯框架

所有回溯题共享同一个骨架:

ts
function backtrack(path, 选择列表) {
  if (满足结束条件) {
    收集结果;
    return;
  }

  for (const 选择 of 选择列表) {
    if (不合法) continue;  // 剪枝

    path.push(选择);          // 1. 做选择
    backtrack(path, 新选择列表); // 2. 递归进入下一层
    path.pop();               // 3. 撤销选择(回溯)
  }
}

框架是固定的,题与题之间的差异全部体现在以下三个维度的填充方式上:

维度变化点影响
选择列表的起点for 从 start 开始 vs 从 0 开始区分组合/子集与排列
递归时传入的起点i + 1 vs i区分元素能否重复选取
剪枝条件跳过重复元素 / 跳过已访问元素处理输入重复 / 排列去重

理解了这三个维度,后面所有题型的代码差异都只是在这三处做微调。

子集、组合、排列的本质区别

回溯最经典的三类问题——子集、组合、排列——直接对应上面三个维度的不同组合:

概念定义是否关注顺序for 循环起点递归传参
子集选任意个元素不关注i = starti + 1
组合选固定个元素不关注i = starti + 1
排列选固定个元素关注i = 0无需 start,用 visited

子集和组合本质相同(子集 = 不限长度的组合),它们与排列的核心区别是是否关注元素的排列顺序。不关注顺序就用 start 限制"只往后选",关注顺序就从头遍历并用 visited 排除已选元素。

去重的统一方法论

当输入集合包含重复元素时,不论子集/组合还是排列,去重方法是一致的:

  1. 先排序——让相同元素相邻。
  2. 同层跳过——在同一层 for 循环中,如果当前元素与前一个相同,且前一个在本层已被回溯过,则跳过。

对于组合/子集,条件写作 i > start && nums[i] === nums[i - 1]——i > start 意味着前一个同值元素在本层已经展开并回溯完毕。

对于排列,条件写作 nums[i] === nums[i - 1] && !visited[i - 1]——!visited[i - 1] 说明前一个同值元素不是在"上层被选中",而是在"本层被跳过",从而避免重复。

两种写法形式不同但语义一致:保证相同值在同一个决策层只展开一次


解题决策树

拿到一道回溯题

├─ 求子集 / 组合(不关注顺序)?
│  │
│  ├─ 元素唯一,每个只选一次?
│  │  └─ 基础框架 .......................... → 见「一」
│  │
│  ├─ 元素有重复,每个只选一次?
│  │  └─ 排序 + 同层去重 ................... → 见「二」
│  │
│  └─ 元素可重复选取?
│     └─ 递归时 start 不前进 ................ → 见「三」

├─ 求排列(关注顺序)?
│  │
│  ├─ 元素唯一?
│  │  └─ visited 数组标记 ................... → 见「四」
│  │
│  └─ 元素有重复?
│     └─ visited + 排序去重 ................. → 见「四」

├─ 切割 / 分割问题?
│  └─ 本质是组合:在切割点做选择 ............ → 见「五」

└─ 棋盘 / 网格放置问题?
   └─ 逐行/逐格放置 + 合法性剪枝 ........... → 见「六」

一、子集/组合:元素唯一,不可重复选

判断关键词:子集、组合、从 N 个中选 K 个、元素互不相同。

适用场景:集合中没有重复元素,每个元素在一个方案中最多出现一次。

这是回溯最基础的形态。for 循环从 start 开始保证不回头选,递归传 i + 1 保证每个元素只选一次。子集与组合唯一的差别是收集结果的时机——子集在每个节点都收集,组合在满足长度时才收集。

ts
function backtrack(path: number[], start: number) {
  // 子集:每个节点都是答案
  // 组合:path.length === k 时才收集并 return
  res.push([...path]);

  for (let i = start; i < nums.length; i++) {
    path.push(nums[i]);
    backtrack(path, i + 1);
    path.pop();
  }
}

题型参考(框架微调):

题目在基础框架上的微调点
[78] 子集标准框架,每次递归入口都收集当前 path
[77] 组合增加长度判断:path.length === k 时才收集并 return。

二、子集/组合:元素有重复,不可重复选

判断关键词:子集 II、组合总和 II、candidates 含重复值、结果不能重复。

适用场景:集合中有重复元素,每个元素按下标只能用一次,但要求结果集无重复方案。

在「一」的基础上只需加两步:排序 + 同层去重i > start && nums[i] === nums[i - 1] 确保同一层 for 循环中不会对相同的值重复展开分支。

ts
nums.sort((a, b) => a - b);

function backtrack(path: number[], start: number) {
  res.push([...path]);

  for (let i = start; i < nums.length; i++) {
    // 同层去重:前一个相同值已经展开过,跳过
    if (i > start && nums[i] === nums[i - 1]) continue;

    path.push(nums[i]);
    backtrack(path, i + 1);
    path.pop();
  }
}

题型参考(框架微调):

题目在去重框架上的微调点
[90] 子集 II标准去重框架,每次递归都收集。
[40] 组合总和 II增加 remain 参数;remain < 0 时剪枝,=== 0 时收集。

三、子集/组合:元素可重复选取

判断关键词:可无限选取、同一元素可多次使用、组合总和。

适用场景:集合中元素不重复,但每个元素可以被选取无限次。

与「一」相比,唯一的变化是递归时传入 i 而非 i + 1——允许下一层继续选同一个元素。

ts
function backtrack(path: number[], start: number, remain: number) {
  if (remain === 0) { res.push([...path]); return; }
  if (remain < 0) return;

  for (let i = start; i < nums.length; i++) {
    path.push(nums[i]);
    backtrack(path, i, remain - nums[i]); // i 不变,允许重复选
    path.pop();
  }
}

题型参考(框架微调):

题目在可重复选框架上的微调点
[39] 组合总和标准可重复选框架,remain === 0 时收集。

四、排列

判断关键词:全排列、排列顺序、所有排列方式。

适用场景:需要考虑元素的排列顺序,即 [1,2][2,1] 是不同方案。

排列与组合/子集的核心区别:for 循环每次都从 0 开始(任何位置都可以放任何元素),用 visited 数组标记哪些元素已被选过。

ts
function backtrack(path: number[]) {
  if (path.length === nums.length) {
    res.push([...path]);
    return;
  }

  for (let i = 0; i < nums.length; i++) {
    if (visited[i]) continue;

    path.push(nums[i]);
    visited[i] = true;
    backtrack(path);
    path.pop();
    visited[i] = false;
  }
}

含重复元素的排列在此基础上加排序 + 同层去重。去重条件是 nums[i] === nums[i - 1] && !visited[i - 1]——前一个相同元素未被选中,说明它在本层已经展开并回溯过了,当前是重复分支。

ts
nums.sort((a, b) => a - b);

function backtrack(path: number[]) {
  if (path.length === nums.length) {
    res.push([...path]);
    return;
  }

  for (let i = 0; i < nums.length; i++) {
    if (visited[i]) continue;
    if (i > 0 && nums[i] === nums[i - 1] && !visited[i - 1]) continue;

    path.push(nums[i]);
    visited[i] = true;
    backtrack(path);
    path.pop();
    visited[i] = false;
  }
}

题型参考(框架微调):

题目在排列框架上的微调点
[46] 全排列标准排列框架,visited 标记已选元素。
[47] 全排列 II增加排序 + !visited[i-1] 同层去重。

五、切割/分割问题

判断关键词:分割字符串、所有切割方案、回文分割、IP 地址复原。

适用场景:将字符串/数组切割成若干段,每段满足某个条件,求所有合法切法。

切割问题本质是组合问题的变形:在每个可能的切割点做选择。starti 的子串是当前这一刀切出来的部分,如果这一段合法就递归切剩余部分,不合法就跳过——和组合框架的 start + i + 1 结构完全一致。

ts
function backtrack(path: string[], start: number) {
  if (start === s.length) {
    res.push([...path]);
    return;
  }

  for (let i = start; i < s.length; i++) {
    const segment = s.substring(start, i + 1);
    if (!isValid(segment)) continue; // 剪枝:当前段不合法

    path.push(segment);
    backtrack(path, i + 1);
    path.pop();
  }
}

题型参考(框架微调):

题目在切割框架上的微调点
[131] 分割回文串isValid 实现为回文判断。
[93] 复原 IP 地址isValid 改为 IP 段合法性(0-255、无前导零),段数限制为 4。

六、棋盘/网格放置问题

判断关键词:N 皇后、数独、棋盘、逐行放置、约束满足。

适用场景:在二维网格上按规则放置元素,穷举所有合法布局或判断是否存在合法布局。

棋盘问题的回溯结构是"逐行/逐格决策":每一行(或每一个空格)做一次选择,选择前检查合法性,不合法则剪枝。框架与前面完全一致,只是"选择列表"变成了列号/数字,"剪枝条件"变成了棋盘规则约束。

以 N 皇后为例,逐行放置,每行选一个列位置:

ts
function backtrack(row: number) {
  if (row === n) {
    res.push(formatBoard(board));
    return;
  }

  for (let col = 0; col < n; col++) {
    if (!isValid(board, row, col)) continue; // 列、两条对角线冲突检测

    board[row][col] = 'Q';
    backtrack(row + 1);
    board[row][col] = '.';
  }
}

数独问题类似,但决策粒度更细——逐格而非逐行,每格尝试 1-9,合法性检查包含行、列、3×3 宫格三重约束。

题型参考(框架微调):

题目在棋盘框架上的微调点
[51] N 皇后逐行放置,三方向冲突检测(列、两条对角线),收集所有方案。
[52] N 皇后 II51 相同逻辑,只计数不收集方案。
[37] 解数独逐格放置,行/列/宫格三重约束,找到一个解即返回。

七、其他经典回溯

部分题目不严格属于上面的分类,但依然遵循"选择—递归—撤销"框架,差异在于选择列表的构成和约束条件。

题目回溯思路
[17] 电话号码的字母组合每一位数字对应一组候选字母,逐位选择;类似组合但选择列表随层变化。
[22] 括号生成每步选放 (),通过剩余左右括号数量做剪枝。
[79] 单词搜索网格上四方向 DFS + visited 标记,匹配到单词末位即收集。

总结

框架三维度速查

所有回溯题的差异可以归结为三个维度的选择:

维度子集/组合排列
for 循环起点i = starti = 0
递归传入的起点i + 1(不可重复)/ i(可重复)无需传 start
去重方式i > start && nums[i] === nums[i-1]visited + !visited[i-1]

解题速查表

题型核心策略典型题目
子集/组合(元素唯一)基础框架,start + i+1[78] [77]
子集/组合(含重复)排序 + 同层跳过[90] [40]
子集/组合(可重复选)递归传 i 不传 i+1[39]
排列(元素唯一)visited 数组[46]
排列(含重复)visited + 排序去重[47]
切割/分割组合变形,切割点做选择[131] [93]
棋盘放置逐行/逐格 + 合法性剪枝[51] [52] [37]

MIT LICENSE