【力扣hot100】回溯专题|全排列、子集、字母组合、组合总和、括号生成、单词搜索、分割回文串、N皇后

回溯

文章目录

    • 回溯
      • [46. 全排列](#46. 全排列)
      • [78. 子集](#78. 子集)
      • [17. 电话号码的字母组合](#17. 电话号码的字母组合)
      • [39. 组合总和](#39. 组合总和)
      • [22. 括号生成](#22. 括号生成)
      • [79. 单词搜索](#79. 单词搜索)
      • [131. 分割回文串](#131. 分割回文串)
      • [51. N 皇后](#51. N 皇后)

回溯三问:

  1. 当前操作?
  2. 子问题?
  3. 下一个子问题?

dfs(i) ---> dfs(i+1)

46. 全排列

46. 全排列

核心流程

  1. 定义状态

    • res:存放最终所有的排列结果。
    • tmp:存放当前正在构建的一个排列(路径)。
    • nums:原始数组。
  2. 终止条件 (if (tmp.size() == nums.length))

    • tmp 里的数字个数等于 nums 的长度时,说明我们已经把每个数字都用了一次,凑齐了一个完整的排列。
    • 此时将 tmp 的副本存入 res,并返回上一层。
  3. 选择与剪枝 (for 循环与内层检查)

    • 外层循环遍历 nums 中的每一个数字。

    • 关键点:代码中有一段

      复制代码
      for (j = 0; ...)

      的逻辑。这是在检查这个数字是不是已经被用过了?

      • 全排列要求每个数字只能用一次。
  • 如果 nums[i] 已经在 tmp 里了,就跳过它(break)。
    • 只有当 nums[i] 不在 tmp 里时(即 j == tmp.size()),才允许将其加入。
  1. 回溯操作

    • tmp.add(nums[i])前进。把当前数字放入路径。
    • dfs(...)递归。去填下一个位置。
    • tmp.remove(...)后退(回溯)。当递归回来时,说明以当前数字开头的某种排列已经找完了,需要把它拿出来,尝试下一个数字
java 复制代码
class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> tmp = new ArrayList<>();

        dfs(res, nums, tmp);
        return res;
    }

    private void dfs(List<List<Integer>> res,int[] nums,List<Integer> tmp){
        //终止条件:临时列表的大小等于nums的,证明得到了一个完整排列
        if(tmp.size() == nums.length){
            res.add(new ArrayList<>(tmp));
            return;
        }

        //遍历nums里的每一个数字
        for(int i =0;i<nums.length;i++){
            int j;
            //检查当前数字是否已存在于tmp中
            for(j =0 ;j<tmp.size();j++){
                if(nums[i] == tmp.get(j)){
                    break;
                }
            }
            //如果j == tmp.size(),证明内层循环正常结束,tmp中不存在nums[i]
            if(j == tmp.size()){
                tmp.add(nums[i]);
                dfs(res,nums,tmp);//继续递归找下一个数字
                tmp.remove(tmp.size() - 1);//回溯,移除最后一个数字
            }
        }
    }
}

78. 子集

78. 子集

这道题使用的是 递归 + 回溯 的思想。我们可以把整个过程想象成在做一系列的二选一决策。

假设输入数组是 [1, 2, 3],我们的决策树如下:

  1. 面对数字 1:
    • 不选 1 :进入左分支,继续处理后面的 [2, 3]
    • 选 1 :把 1 加入临时列表 tmp,进入右分支,继续处理后面的 [2, 3]
  2. 面对数字 2:
    • 无论刚才选了没选 1,现在都要对 2 做同样的"选/不选"决策
  3. 面对数字 3:
    • 同理,做决策
  4. 结束:
    • 当所有数字都处理完(索引越界)时,tmp 里剩下的就是一个合法的子集,把它加入结果集 res
java 复制代码
class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> tmp = new ArrayList<>();

        dfs(nums, 0, tmp, res);
        return res;
    }

    private void dfs(int[] nums, int pathLen, List<Integer> tmp, List<List<Integer>> res) {
        //终止条件:索引等于数组长度时,所有元素都处理完了
        if (pathLen == nums.length) {
            res.add(new ArrayList<>(tmp));
            return;
        }

        // 分支1:不选当前元素的情况
        // 直接递归处理下一个元素,tmp 不做任何改变
        dfs(nums, pathLen + 1, tmp, res);

        // 分支2:选当前元素的情况
        // 1. 做选择:把当前元素加入 tmp
        tmp.add(nums[pathLen]);
        // 2. 递归处理下一个元素
        dfs(nums, pathLen + 1, tmp, res);
        // 3. 撤销选择(回溯):把刚才加进去的元素拿出来,恢复现场
        tmp.remove(tmp.size() - 1);
    }
}

17. 电话号码的字母组合

17. 电话号码的字母组合

有一张固定大小的填空卷(path),按顺序每一格尝试填入所有可能的字母。填完最后一格就保存结果,然后回溯回来把刚才写的字涂改一下(覆盖),试下一个字母

java 复制代码
import java.util.ArrayList;
import java.util.List;

class Solution {
    // 定义电话按键的数字到字母的映射关系
    // 索引 0 和 1 为空字符串,索引 2 对应 "abc",以此类推
    private static final String[] MAPPING = new String[]{"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};

    public List<String> letterCombinations(String digits) {
        int n = digits.length();
        // 边界处理:如果输入为空字符串,直接返回空列表
        if (n == 0) return List.of();

        // 结果集:用于存储所有生成的字母组合
        List<String> ans = new ArrayList<>();

        // 【核心优化】:创建一个固定长度的字符数组作为"路径容器"
        // 它的作用类似于一个只有 n 个格子的草稿纸。
        // 在递归过程中,我们不需要每次都创建新的字符串或列表,而是直接修改这个数组对应位置的值。
        char[] path = new char[n];

        // 启动深度优先搜索 (DFS)
        // i: 当前正在处理的数字下标(也是 path 数组中要填入的位置)
        dfs(0, ans, path, digits.toCharArray());
        return ans;
    }

    /**
     * @param i      当前递归的深度(对应第几个数字)
     * @param ans    最终结果集
     * @param path   共享的字符数组(当前路径)
     * @param digits 输入的数字字符数组
     */
    private void dfs(int i, List<String> ans, char[] path, char[] digits) {
        // 1. 终止条件(Base Case)
        // 当 i 等于数字长度时,说明所有数字对应的字母都选完了,path 数组已填满
        if (i == digits.length) {
            // 将当前的字符数组转换为字符串并加入结果集
            // 注意:new String(path) 会复制数组内容,所以是安全的
            ans.add(new String(path));
            return;
        }

        // 2. 获取当前数字对应的字母字符串
        // 例如 digits[i] 是 '2',减去 '0' 得到整数 2,MAPPING[2] 就是 "abc"
        String letters = MAPPING[digits[i] - '0'];

        // 3. 遍历当前数字对应的所有可能字母
        for (char c : letters.toCharArray()) {
            // 【做选择】:将当前字母填入 path 的第 i 个位置
            // 这一步非常关键:它不是 append(追加),而是 overwrite(覆盖)。
            // 比如 path[0] 先被赋值为 'a',回溯回来后,下一次循环会被赋值为 'b',直接覆盖了 'a'。
            path[i] = c;

            // 【递归】:处理下一个数字(i + 1)
            dfs(i + 1, ans, path, digits);

            // 【撤销选择】:
            // 在这段特定的代码逻辑中,不需要显式的"撤销"操作(如 path[i] = 0)。
            // 因为当函数返回上一层时,下一次循环执行 path[i] = nextChar 会直接覆盖掉旧值。
        }
    }
}

39. 组合总和

39. 组合总和

这道题的核心思路是回溯 + 剪枝 :从数组中依次尝试选数字,每选一个就累加到 sum 中,如果 sum == target 就收集当前组合,如果 sum > target 就剪枝返回;通过引入 start 索引保证每次只往后选、不回头,从而避免 [2,3][3,2] 这种重复组合;递归时传 i(而非 i+1)则允许同一个数字被重复选取;每次递归返回后通过 tmp.remove(tmp.size() - 1) 撤销选择、恢复现场,继续尝试下一个数字

java 复制代码
class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> tmp = new ArrayList<>();
        dfs(0,0,candidates,target,tmp,res);

        return res;
    }

    private void dfs(int start,int sum,int[] candidates,int target,List<Integer> tmp,List<List<Integer>> res){
        //剪枝,防止无限递归
        if(sum > target){
            return;
        }

        if(sum == target){
            res.add(new ArrayList<>(tmp));
            return;
        }
        //从索引i开始遍历,避免回头选之前的数
        for(int i = start;i<candidates.length;i++){
            int num = candidates[i];
            tmp.add(num);
            sum += num;
            dfs(i,sum,candidates,target,tmp,res);//传 i 而不是 i+1,因为同一个数字可以重复选
            tmp.remove(tmp.size()-1);//删除最后一个添加的元素
            sum -= num;
        }
    }
}

易错

for(int num : candidates) 每次都从数组第一个元素开始遍历,会得到重复组合:2,33,2

引入一个索引start,保证每次只从当前位置或之后选择数字

22. 括号生成

22. 括号生成

这道题用回溯 + 剪枝 来做:用一个长度为 2nchar[] path 数组,从第 0 位开始逐位尝试填 ();填的时候有两个剪枝条件:左括号已用数量不能超过 n右括号已用数量不能超过左括号数量 (否则会出现 )( 这种不合法的情况);每次赋值 path[i] = '('path[i] = ')' 都是直接覆盖,不需要手动 remove;当填满 2n 个位置时,说明得到了一个合法的括号组合,将其转为字符串加入结果集

java 复制代码
class Solution {
    public List<String> generateParenthesis(int n) {
        List<String> res = new ArrayList<>();
        char[] tmp = new char[2*n];
        dfs(n,0,0,0,tmp,res);

        return res;
    }

    /**
     * @param i     当前填到第几个位置
     * @param left  已使用的左括号数量
     * @param right 已使用的右括号数量
     */
    private void dfs(int n,int i,int left,int right,char[] tmp,List<String> res){
        if(i == tmp.length){
            res.add(new String(tmp));
            return;
        }

        //左括号没用完可以继续加左括号
        if(left < n){
            tmp[i] = '(';
            dfs(n,i+1,left+1,right,tmp,res);
        }

        //左括号>右括号时才能加右括号,否则会出现")("
        if(left > right){
            tmp[i] = ')';
            dfs(n,i+1,left,right+1,tmp,res);
        }
    }
}

法二 :在初始进入dfs前先设置开头和结尾是()

然后 DFS 只需要填中间 1 ~ 2n-2 这些位置,剪枝条件不变:

  • 左括号总数不能超过 n
  • 右括号数量不能超过左括号数量
java 复制代码
class Solution {
    public List<String> generateParenthesis(int n) {
        List<String> res = new ArrayList<>();
        if (n == 0) return res;

        char[] path = new char[n * 2];
        // 固定首尾
        path[0] = '(';
        path[2 * n - 1] = ')';

        // 从第 1 位开始填,初始 left=1(已经放了一个左括号),right=0
        dfs(1, 1, 0, n, path, res);
        return res;
    }

    private void dfs(int i, int left, int right, int n, char[] path, List<String> res) {
        // 终止条件:填到了倒数第二个位置(最后一个已经被固定为 ')')
        if (i == 2 * n - 1) {
            res.add(new String(path));
            return;
        }

        // 剪枝1:左括号还没用完,可以继续加左括号
        if (left < n) {
            path[i] = '(';
            dfs(i + 1, left + 1, right, n, path, res);
        }

        // 剪枝2:右括号数量 < 左括号数量,才能加右括号
        if (right < left) {
            path[i] = ')';
            dfs(i + 1, left, right + 1, n, path, res);
        }
    }
}

79. 单词搜索

79. 单词搜索

遍历网格的每一个格子作为起点 ,从该起点出发,向上下左右四个方向递归搜索,尝试拼出目标单词。只要有一个起点能成功,就返回 true

java 复制代码
class Solution {
    public boolean exist(char[][] board, String word) {
        int r = board.length,c = board[0].length;
        boolean[][] visited = new boolean[r][c];
        for(int i=0;i<r;i++){
            for(int j=0;j<c;j++){
                boolean flag = dfs(board,visited,i,j,word,0);
                if(flag)return true;
            }
        }
        return false;
    }

    public boolean dfs(char[][] board, boolean[][] visited, int i, int j, String word, int k) {
        if (board[i][j] != word.charAt(k)) {
            return false;//字符不匹配直接失败
        } else if (k == word.length() - 1) {
            return true;//整个单词都匹配到了
        }

        visited[i][j] = true;//标记已访问过的
        int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
        boolean result = false;
        for (int[] dir : directions) {
            int newi = i + dir[0], newj = j + dir[1];
            if (newi >= 0 && newi < board.length && newj >= 0 && newj < board[0].length) {
                if (!visited[newi][newj]) {
                    boolean flag = dfs(board, visited, newi, newj, word, k + 1);
                    if (flag) {
                        result = true;
                        break;
                    }
                }
            }
        }
        visited[i][j] = false;//回溯恢复 如果某一步走不通,就回溯:把 visited 恢复,退回到上一步,换另一个方向继续试
        return result;
    }


}

131. 分割回文串

131. 分割回文串

回溯三问:

  1. 当前操作?选择回文子串si...j,加入path
  2. 子问题?从下标>=i的后缀构造回文分割
  3. 下一个子问题?从下标>=j+1的后缀中构造回文分割

判断回文串:用相向双指针判断,或者把字符串反转一下看是否相等

这道题的回溯不是"选哪个字符",而是对每个位置做一个二选一决策

在位置 ii+1 之间,切一刀 还是不切

  • 不切 :当前段继续往后延伸,start 不变,i 往后移
  • :把 [start, i] 这段截下来,判断是不是回文;如果是,加入 path,然后从 i+1 开始新的段
参数 含义
i 当前扫描到的位置,讨论在 ii+1 之间是否切一刀
start 当前这一段的起始位置(上一刀切完后的下一个位置)
s 原始字符串
path 当前已经切出来的回文子串列表
ans 最终结果集

为什么 i < s.length() - 1

因为当 i 是最后一个字符时(i == n-1),后面没有字符了,必须切

java 复制代码
class Solution {
    public List<List<String>> partition(String s) {
        List<List<String>> ans = new ArrayList<>();
        List<String> path = new ArrayList<>();
        dfs(0, 0, s, path, ans);
        return ans;
    }

    // 现在 s 未被分割的部分为 [start, n-1]
    // 当前位于下标 i,讨论是否在 i 和 i+1 之间切一刀
    private void dfs(int i, int start, String s, List<String> path, List<List<String>> ans) {
        if (i == s.length()) { // s 分割完毕
            ans.add(new ArrayList<>(path)); // 复制 path
            return;
        }

        // 不分割
        if (i < s.length() - 1) { // i=n-1 时必须分割(这是最后一段),i<n-1 时才可以不分割
            dfs(i + 1, start, s, path, ans);
        }

        // 分割 得到子串 [start, i]
        if (isPalindrome(s, start, i)) { // 判断子串 [start, i] 是不是回文串
            path.add(s.substring(start, i + 1));
            // 现在 s 未被分割的部分为 [i+1, n-1]
            dfs(i + 1, i + 1, s, path, ans);
            path.removeLast(); // path.remove(path.size() - 1);
        }
    }

    private boolean isPalindrome(String s, int left, int right) {
        while (left < right) {
            if (s.charAt(left++) != s.charAt(right--)) {
                return false;
            }
        }
        return true;
    }
}

51. N 皇后

51. N 皇后

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

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

**总结:**逐行放置皇后,对每一行尝试每一列,用三个布尔数组记录列和两条对角线的占用状态,能放就标记占用并递归到下一行,递归返回后恢复标记(回溯),放满 n 行就收集结果

java 复制代码
class Solution {
    public List<List<String>> solveNQueens(int n) {
        List<List<String>> ans = new ArrayList<>();
        int[] queens = new int[n]; //queens[r] = c 表示第 r 行的皇后放在第 c 列
        boolean[] col = new boolean[n];//col[c]=true表示第c列已被占用
        boolean[] diag1 = new boolean[n * 2 - 1];//主对角线
        boolean[] diag2 = new boolean[n * 2 - 1];//副对角线
        dfs(0, queens, col, diag1, diag2, ans);
        return ans;
    }

    private void dfs(int r, int[] queens, boolean[] col, boolean[] diag1, boolean[] diag2, List<List<String>> ans) {
        int n = col.length;
        if (r == n) {//每行都放了皇后
            List<String> board = new ArrayList<>(n); // 预分配空间
            for (int c : queens) {
                char[] row = new char[n];
                Arrays.fill(row, '.');
                row[c] = 'Q';
                board.add(new String(row));
            }
            ans.add(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, queens, col, diag1, diag2, ans);
                col[c] = diag1[r + c] = diag2[rc] = false; // 恢复现场
            }
        }
    }
}
相关推荐
旖旎夜光1 小时前
LCR 173:在点名(二分查找) —— 题解
数据结构·c++·算法·leetcode·二分查找
重生之后端学习1 小时前
438. 找到字符串中所有字母异位词[中等]✅
开发语言·数据结构·算法·leetcode·职场和发展
渡之1 小时前
ArduPilot LowPassFilter 深度解析
算法·无人机
nxb5561 小时前
云原生:控制器
java·开发语言·云原生
咖啡八杯1 小时前
统一返回对象设计:AjaxResult 与 R 的取舍
java·spring boot·框架·若依·返回值
小手cool2 小时前
JSON.parseObject 返回 List 时不能强制转换的解决方案
java·json·list
凯酱3 小时前
MyBatis 中 OGNL 的使用:从入门到实战
java·tomcat·mybatis
ting94520009 小时前
Humalike X Hermes 深度技术剖析:单指令注入群聊社交智能的底层架构、算法与跨 IM 平台实现
人工智能·算法·架构
吴声子夜歌9 小时前
Java面试——算法
java·算法·面试