回溯
文章目录
-
- 回溯
-
- [46. 全排列](#46. 全排列)
- [78. 子集](#78. 子集)
- [17. 电话号码的字母组合](#17. 电话号码的字母组合)
- [39. 组合总和](#39. 组合总和)
- [22. 括号生成](#22. 括号生成)
- [79. 单词搜索](#79. 单词搜索)
- [131. 分割回文串](#131. 分割回文串)
- [51. N 皇后](#51. N 皇后)
回溯三问:
- 当前操作?
- 子问题?
- 下一个子问题?
dfs(i) ---> dfs(i+1)
46. 全排列
核心流程
-
定义状态:
res:存放最终所有的排列结果。tmp:存放当前正在构建的一个排列(路径)。nums:原始数组。
-
终止条件 (
if (tmp.size() == nums.length)):- 当
tmp里的数字个数等于nums的长度时,说明我们已经把每个数字都用了一次,凑齐了一个完整的排列。 - 此时将
tmp的副本存入res,并返回上一层。
- 当
-
选择与剪枝 (
for循环与内层检查):-
外层循环遍历
nums中的每一个数字。 -
关键点:代码中有一段
for (j = 0; ...)的逻辑。这是在检查这个数字是不是已经被用过了?
- 全排列要求每个数字只能用一次。
-
- 如果
nums[i]已经在tmp里了,就跳过它(break)。- 只有当
nums[i]不在tmp里时(即j == tmp.size()),才允许将其加入。
- 只有当
-
回溯操作:
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. 子集
这道题使用的是 递归 + 回溯 的思想。我们可以把整个过程想象成在做一系列的二选一决策。
假设输入数组是 [1, 2, 3],我们的决策树如下:
- 面对数字 1:
- 不选 1 :进入左分支,继续处理后面的
[2, 3] - 选 1 :把 1 加入临时列表
tmp,进入右分支,继续处理后面的[2, 3]
- 不选 1 :进入左分支,继续处理后面的
- 面对数字 2:
- 无论刚才选了没选 1,现在都要对 2 做同样的"选/不选"决策
- 面对数字 3:
- 同理,做决策
- 结束:
- 当所有数字都处理完(索引越界)时,
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. 电话号码的字母组合
有一张固定大小的填空卷(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. 组合总和
这道题的核心思路是回溯 + 剪枝 :从数组中依次尝试选数字,每选一个就累加到 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,3和3,2
引入一个索引start,保证每次只从当前位置或之后选择数字
22. 括号生成
这道题用回溯 + 剪枝 来做:用一个长度为 2n 的 char[] 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. 单词搜索
遍历网格的每一个格子作为起点 ,从该起点出发,向上下左右四个方向递归搜索,尝试拼出目标单词。只要有一个起点能成功,就返回 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. 分割回文串

回溯三问:
- 当前操作?选择回文子串si...j,加入path
- 子问题?从下标>=i的后缀构造回文分割
- 下一个子问题?从下标>=j+1的后缀中构造回文分割
判断回文串:用相向双指针判断,或者把字符串反转一下看是否相等
这道题的回溯不是"选哪个字符",而是对每个位置做一个二选一决策:
在位置
i和i+1之间,切一刀 还是不切?
- 不切 :当前段继续往后延伸,
start不变,i往后移 - 切 :把
[start, i]这段截下来,判断是不是回文;如果是,加入path,然后从i+1开始新的段
| 参数 | 含义 |
|---|---|
i |
当前扫描到的位置,讨论在 i 和 i+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 皇后


如何 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; // 恢复现场
}
}
}
}