LeetCode hot100 回溯专题总结:Java 通用模板、决策树模型

一句话说明核心方法

回溯就是"走迷宫":沿着一条路一直走,走不通就退回上一个路口,换条路再走。

写成代码只有三步:做选择 → 递归 → 撤销选择

hot100 里的八道回溯题,全部由这一个框架变形而来。


什么是回溯

很多人背模板背得很熟,但一直没搞懂回溯到底在干什么。其实它就是在遍历一棵看不见的树------决策树。

[1,2,3] 求子集为例,递归展开后就是这棵树:

复制代码
                 []
        ┌─────────┼─────────┐
       [1]       [2]       [3]
     ┌───┴───┐      │
  [1,2]   [1,3]   [2,3]
     │
 [1,2,3]

树上每个节点代表"当前已做的选择",往下走一层就是"再做一个选择",往回退就是"撤销刚才的选择"。

所以写任何回溯题之前,先回答三个问题,答案出来了,代码也就出来了:

  • 路径 :已经做了哪些选择?→ 用一个 list 维护
  • 选择列表 :现在还能选什么?→ 只有两种控制方式:startused[]
  • 结束条件:什么时候把路径收进结果集?

万能框架

复制代码
void backtrack(参数, List<Integer> path, List<List<Integer>> res) {
    if (结束条件成立) {
        res.add(new ArrayList<>(path));   // 注意:必须拷贝!
        return;
    }
    for (选择 : 当前可选择的东西) {
        做选择;                    // path.add(x)
        backtrack(下一层);
        撤销选择;                  // path.remove最后一个
    }
}

八道题的差异,只有三个地方:

  1. 选择列表怎么控制 ------start 还是 used[]
  2. 什么时候收集答案------每个节点都收,还是只在叶子收
  3. 怎么剪枝------什么情况直接跳过这个选择

八道题全景表

题号 题目 控制选择列表 收集时机 关键剪枝
78 子集 start 每个节点
39 组合总和 starti 叶子(sum = target) sum 超了就停
17 电话号码 index 前进 叶子(拼完)
22 括号生成 左右括号计数 叶子(长度 2n) 右括号数 < 左括号数
131 分割回文串 starti+1 叶子(切完) 切出来不是回文就跳过
46 全排列 used[] 叶子(选满 n 个)
51 N皇后 row 逐行 + 冲突集合 叶子(放满 n 行) 列/对角线冲突
79 单词搜索 棋盘置 # 标记 匹配完 word 越界/字符不等

看着有八道,其实只有三套模板


模板一:start 型(子集、组合、分割)

核心思想:选过的不再回头选 。递归传一个 start,下一层只能从 start 往后挑,天然避免了 [1,2][2,1] 这种重复。

78 子集(最纯粹的模板题)

复制代码
class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        backtrack(nums, 0, new ArrayList<>(), res);
        return res;
    }

    private void backtrack(int[] nums, int start,
                           List<Integer> path, List<List<Integer>> res) {
        res.add(new ArrayList<>(path));          // 每个节点都是答案
        for (int i = start; i < nums.length; i++) {
            path.add(nums[i]);                   // 做选择
            backtrack(nums, i + 1, path, res);   // 只能往后选
            path.remove(path.size() - 1);        // 撤销选择
        }
    }
}

注意:子集型不需要终止条件 ,for 循环走完自然结束,if (start == n) return; 反而会漏答案。

39 组合总和(改一行 + 加剪枝)

和 78 只有两处不同:允许重复选同一个数 (递归传 i 不是 i+1),以及收集条件从"每个节点"变成"和恰好等于 target"。

复制代码
private void backtrack(int[] candidates, int start, int target,
                       List<Integer> path, List<List<Integer>> res) {
    if (target == 0) {
        res.add(new ArrayList<>(path));
        return;
    }
    for (int i = start; i < candidates.length; i++) {
        if (candidates[i] > target) continue;     // 剪枝:选这个数肯定超了
        path.add(candidates[i]);
        backtrack(candidates, i, target - candidates[i], path, res); // 传 i,可重复选
        path.remove(path.size() - 1);
    }
}

131 分割回文串(换个角度看 start)

start 变成"从哪里开始切",for 循环枚举"这一刀切在哪里",切出来的段是回文才有资格往下递归:

复制代码
private void backtrack(String s, int start, List<String> path,
                       List<List<String>> res) {
    if (start == s.length()) {                   // 切完了
        res.add(new ArrayList<>(path));
        return;
    }
    for (int i = start; i < s.length(); i++) {
        String seg = s.substring(start, i + 1);
        if (!isPalindrome(seg)) continue;        // 不是回文,这刀白切
        path.add(seg);                           // 做选择:留下这一段
        backtrack(s, i + 1, path, res);          // 下一刀从 i+1 开始
        path.remove(path.size() - 1);
    }
}

17 电话号码 / 22 括号生成(选择列表换个来源)

  • 17 :选择列表不再是数组下标,而是 digits[index] 这个数字对应的 3~4 个字母,index 走到头就收集

  • 22 :选择列表永远只有两个------放 ( 或放 )。剪枝规则:左括号数 < n 才能放左;右括号数 < 左括号数才能放右

    private void backtrack(int n, int open, StringBuilder sb, List res) {
    if (sb.length() == 2 * n) {
    res.add(sb.toString());
    return;
    }
    if (open < n) { // 左括号还没用完
    sb.append('(');
    backtrack(n, open + 1, sb, res);
    sb.deleteCharAt(sb.length() - 1);
    }
    if (sb.length() - open < open) { // 右括号比左括号少,才能放右
    sb.append(')');
    backtrack(n, open, sb, res);
    sb.deleteCharAt(sb.length() - 1);
    }
    }


模板二:used 型(排列、棋盘)

核心思想:每层都从头扫,但用过的标记掉 。排列和组合的本质区别就在这:[1,2][2,1] 是两个不同的排列,所以不能只往后选,必须能回头。

46 全排列

复制代码
class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        backtrack(nums, new boolean[nums.length], new ArrayList<>(), res);
        return res;
    }

    private void backtrack(int[] nums, boolean[] used,
                           List<Integer> path, List<List<Integer>> res) {
        if (path.size() == nums.length) {        // 选满了,收集
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = 0; i < nums.length; i++) {  // 每层从头扫
            if (used[i]) continue;               // 用过的跳过
            used[i] = true;                      // 做选择
            path.add(nums[i]);
            backtrack(nums, used, path, res);
            path.remove(path.size() - 1);        // 撤销选择
            used[i] = false;                     // 撤销要完全对称!
        }
    }
}

51 N皇后(used 数组的进阶版)

一行只放一个皇后,所以递归参数就是 row,每层只决定"这一行放哪一列"。冲突检查就是把 used[] 换成三个集合:列、主对角线、副对角线。

复制代码
class Solution {
    public List<List<String>> solveNQueens(int n) {
        List<List<String>> res = new ArrayList<>();
        backtrack(n, 0, new HashSet<>(), new HashSet<>(), new HashSet<>(),
                  new int[n], res);
        return res;
    }

    private void backtrack(int n, int row, Set<Integer> cols,
                           Set<Integer> diag1, Set<Integer> diag2,
                           int[] queens, List<List<String>> res) {
        if (row == n) {
            res.add(toBoard(queens, n));
            return;
        }
        for (int col = 0; col < n; col++) {
            if (cols.contains(col)
                    || diag1.contains(row - col)     // 主对角线:同一条线上 row-col 固定
                    || diag2.contains(row + col)) {  // 副对角线:row+col 固定
                continue;                            // 剪枝:会打架,跳过
            }
            queens[row] = col;                       // 做选择
            cols.add(col); diag1.add(row - col); diag2.add(row + col);
            backtrack(n, row + 1, cols, diag1, diag2, queens, res);
            cols.remove(col); diag1.remove(row - col); diag2.remove(row + col); // 撤销
        }
    }

    private List<String> toBoard(int[] queens, int n) {
        List<String> board = new ArrayList<>();
        for (int r = 0; r < n; r++) {
            char[] line = new char[n];
            java.util.Arrays.fill(line, '.');
            line[queens[r]] = 'Q';
            board.add(new String(line));
        }
        return board;
    }
}

对角线技巧记一下:同一条主对角线上,row - col 是定值;同一条副对角线上,row + col 是定值


模板三:网格型(单词搜索)

前两套模板都是在"一维"上选,79 题把战场搬到了二维网格。套路不变,只是:

  • 做选择 = 走进一个格子,把原字符临时改成 # 当 visited 标记

  • 撤销选择 = 把字符改回来

    class Solution {
    public boolean exist(char[][] board, String word) {
    for (int i = 0; i < board.length; i++)
    for (int j = 0; j < board[0].length; j++)
    if (dfs(board, word, i, j, 0)) return true;
    return false;
    }

    复制代码
      private boolean dfs(char[][] board, String word, int i, int j, int k) {
          if (i < 0 || i >= board.length || j < 0 || j >= board[0].length
                  || board[i][j] != word.charAt(k)) return false;  // 剪枝:越界/不匹配
          if (k == word.length() - 1) return true;                 // 匹配完了
          char tmp = board[i][j];
          board[i][j] = '#';                        // 做选择:标记走过
          boolean found = dfs(board, word, i + 1, j, k + 1)
                       || dfs(board, word, i - 1, j, k + 1)
                       || dfs(board, word, i, j + 1, k + 1)
                       || dfs(board, word, i, j - 1, k + 1);
          board[i][j] = tmp;                        // 撤销选择:恢复现场
          return found;
      }

    }

这题和前三套最大的不同:返回值是 boolean,找到了就一路短路返回,不再继续搜。


复杂度怎么估

不用精确算,记结论就够面试用:

  • 子集型 :树有 2^n 个节点,每个节点拷贝路径 O(n) → 时间 O(n · 2^n)
  • 排列型 :树有 n! 个叶子,路径长 O(n) → 时间 O(n · n!)
  • 网格型 (79):每个格子出发,每步最多 3 个方向(不走回头路)→ O(mn · 3^L),L 是单词长度

空间都是递归栈深度:子集 O(n)、排列 O(n)、N皇后 O(n)、网格 O(L)。


易错点

  • 收集时必须拷贝 :res.add(new ArrayList<>(path))。直接 add path,最后所有结果共享同一个 list,答案全是空
  • 做选择和撤销必须完全对称 :addremove,used[i]=trueused[i]=false,board# 配恢复原字符。漏一处,整棵树的搜索全乱
  • 子集型不要写终止条件 :78 题写了 if (start == n) return; 会漏掉 [1,2,3] 这种答案
  • 39 题传 i 不传 i+1:一个字符的差别,决定能不能重复选同一个数
  • 剪枝写在 for 循环里,不是写在递归开头:continue 跳过的是"这一个选择",return 结束的是"整棵子树",两者含义完全不同

面试追问:数组里有重复元素怎么办

hot100 这八道题的输入都没有重复元素,但面试官很爱追问这一句。答案两行:

  • 先排序,让相同的数相邻
  • 同层去重 :子集/组合型用 if (i > start && nums[i] == nums[i-1]) continue;,排列型用 if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;

思路都一样:保证相同的数在同一层只被选一次。对应练习题:90 子集 II、40 组合总和 II、47 全排列 II。


总结:刷题顺序建议

按模板分组二刷,比按题号刷效果好得多:

  1. 78 → 39 → 131(start 型,从裸模板到变形)
  2. 17 → 22(选择列表换来源)
  3. 46 → 51(used 型,从一维到棋盘)
  4. 79(网格型收尾)

每道题动手前只问三个问题:路径是什么?选择列表靠什么控制?什么时候收集? 答得上来,回溯就算学透了。

相关推荐
2601_962218611 小时前
万象生鲜系统订单全生命周期状态同步技术实现业务可视
大数据·数据库·人工智能·python·算法
Wang's Blog1 小时前
Java框架快速入门:Spring Security+OAuth2之用户注册与唯一性校验实现
java·数据库·spring
那年窗外下的雪.1 小时前
AIDC 学习日志|第 22 天|All-Active/Single-Active 故障演练设计
服务器·网络·学习·算法·哈希算法
Wang's Blog2 小时前
Java框架快速入门: Spring Security+OAuth2之多因子认证与TOTP实战
java·spring·ui
滕州市燕猫虎计算机科技工作室个体工商户2 小时前
Java面试题汇总
java·开发语言
wzdark2 小时前
基于启发式搜索的最优路径规划算法研究4
算法
l1t3 小时前
用superpi、tinypi、tpi等工具计算圆周率的比较
c语言·算法
a187927218313 小时前
【算法】链表(二):链表上的双指针——变速、异链与定距,和一份路程账本
数据结构·算法·leetcode·链表·go·指针·环形链表
2301_32241428043 小时前
活力孕康复APP 47737- 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·mysql·微信小程序·idea·微信开发者工具