大家好,我是 方圆,本篇我们来讲回溯。回溯相当于穷举搜索,它会尝试各种可能的情况直到找到一个满足约束条件的解,寻找解的手段一般通过 DFS 实现,是一个 增量构造答案 的过程。回溯法适用于解决能够将原问题拆分成子问题的题目,以构造长为 n 的字符串为例进行讲解:
在构造长为 n 的字符串时,从可选择的字符中选取一个字符,这样就构造出了长为 1 的字符串,那么接下来便需要构造长 n - 1 的字符串,再选取一个字符,便构造除了长为 2 的字符串,那么接下来需要构造长为 n - 2 的字符串,以此类推,过程如下图所示:
这样不断地解决子问题,直到满足条件,得到问题的解的过程,便是对回溯法的应用。在这个过程中,我们需要考虑如下三个要点:
- 当前问题:即例子中的构造长为 n 的字符串
- 每一步的操作 :即例子中在每一步中的“枚举字母”
- 子问题:即例子中的构造长为 n - 1 的字符串
根据这三个要点,将其写成 Java 的回溯代码,如下:
// 定义全局变量记录结果值
List<String> res;
int n;
/**
* 回溯法构造长为 n 的字符串
*
* @param selected 选择列表:路径元素的取值范围
* @param path 走过的路径
*/
private void backtrack(char[] selected, StringBuilder path) {
// 结束条件(构造长为 n 的字符串)
if (n == path.length()) {
res.add(path.toString());
return;
}
// 每一步的操作:在选择列表中,枚举字母,构造字符串
for (char c : selected) {
path.append(c);
// 子问题:构造长为 n - 1 的字符串
backtrack(selected, path);
// 恢复现场
path.deleteCharAt(path.length() - 1);
}
}
在 void backtrack(char[] selected, StringBuilder path)
方法中,定义 char[] selected
为 “选择列表”,表示的是构造“路径”时的 决策范围,路径中的元素需要从该对象中选取;定义 StringBuilder path
为 “路径” ,表示递归过程中已经做过的选择;每次递归完成时,通常都需要 “恢复现场” 的操作,即将走过的路径恢复到递归之前;定义边界条件,当路径满足该条件时,即可记录该路径为答案(之一)。
在解决回溯问题时,需要先考虑当前问题、每一步的操作和子问题,再根据这三个要点定义回溯方法。下面我们通过对子集型回溯、组合型回溯和排列型回溯对回溯问题进行学习和总结。
子集型回溯
子集型回溯的问题,对于 每个元素都可以选或不选,我们以 78. 子集 中等 为例,看看它该如何求解:
给你一个整数数组 nums ,数组中的元素 互不相同。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums 中的所有元素 互不相同
根据题意,可知构造子集时每个元素都可以选或不选,接下来便要考虑解题时的三个要点:
- 当前问题:从下标大于等于 i 的子数组中构造子集
- 每一步的操作 :将下标大于等于 i 的元素加入到路径中
- 子问题:题目要求不能有重复的子集,所以子问题要从下标大于等于 i + 1 的子数组中构造子集
题解如下,注意关注其中的注释信息:
public class Solution78 {
// 定义全局变量
List<List<Integer>> res;
public List<List<Integer>> subsets(int[] nums) {
res = new LinkedList<>();
backtrack(nums, 0, new LinkedList<>());
return res;
}
/**
* 回溯
*
* @param nums 选择列表
* @param begin 构造子集开始的子数组索引
* @param path 路径
*/
private void backtrack(int[] nums, int begin, LinkedList<Integer> path) {
// 走过的所有路径均为答案之一
res.add((List<Integer>) path.clone());
for (int i = begin; i < nums.length; i++) {
// 每一步的操作:将下边大于等于 begin 的元素加入到路径中
path.add(nums[i]);
// 子问题:
backtrack(nums, i + 1, path);
// 恢复现场:即将递归前加入路径的元素移除
path.removeLast();
}
}
}
接下来我们再看一道 131. 分割回文串 中等:
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串。返回 s 所有可能的分割方案。
示例 1:
输入:s = “aab”
输出:[[“a”,“a”,“b”],[“aa”,“b”]]
示例 2:
输入:s = “a”
输出:[[“a”]]
提示:
1 <= s.length <= 16
s 仅由小写英文字母组成
根据题意,字符串所有子串必须都是回文串,而对于字符串中的每个字符元素,我们需要根据它是否能构成子串来判断它的选或不选,所以依然是子集型回溯。接下来,需要考虑下三个要点:
- 当前问题:题目要求分割方案中的所有子串都为回文串,那么当前问题便是从大于等于下标 i 的子数组中判断并构造回文串集合
- 每一步的操作:判断是否为回文串,为回文串的话加入到路径中
- 子问题:从大于等于下标 i + 1 的子数组中判断并构造回文串集合
public class Solution131 {
// 定义全局变量
List<List<String>> res;
public List<List<String>> partition(String s) {
res = new LinkedList<>();
backtrack(s, 0, new LinkedList<>());
return res;
}
private void backtrack(String s, int begin, LinkedList<String> path) {
// 所有子串均为回文串,添加答案并结束
if (begin == s.length()) {
res.add((List<String>) path.clone());
return;
}
for (int i = begin; i < s.length(); i++) {
String cur = s.substring(begin, i + 1);
// 每一步的操作:判断是否为回文串,是的话加入路径中,并继续处理子问题
if (isReverse(cur)) {
path.add(cur);
// 子问题:从大于小于等于下标 i + 1 的子数组中判断并构造回文串集合
backtrack(s, i + 1, path);
// 恢复现场
path.removeLast();
}
}
}
private boolean isReverse(String s) {
int left = 0, right = s.length() - 1;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
}
相关练习
- 257. 二叉树的所有路径 简单
- 17. 电话号码的字母组合 中等
- 784. 字母大小写全排列 中等
- 22. 括号生成 中等
组合型回溯
组合型回溯与子集型回溯相似,它同样也会涉及元素的选与不选,不同的是组合型回溯需要 增加判断条件 来满足题意,达到 剪枝优化 的目的,以 77. 组合 中等 为例,看一下该如何解决:
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
提示:
1 <= n <= 20
1 <= k <= n
它限定了路径长度为 n,而在子集型回溯中是不会包含这个限制的,除了要考虑解决回溯问题的三个要点外,组合型回溯还需要考虑 剪枝优化 条件:
- 当前问题:从小于等于 n 的范围内,在路径中添加第 i 个元素
- 每一步的操作:加入当前元素或不加入当前元素
- 子问题:从小于等于 n - 1 的范围内,在路径中添加第 i + 1 个元素
- 剪枝优化:路径中的元素为 k 个时;路径中的元素加上可选择列表中的剩余的元素不足 k 个时;n 取值小于等于 0 时
题解如下:
public class Solution77 {
List<List<Integer>> res;
int k;
public List<List<Integer>> combine(int n, int k) {
res = new LinkedList<>();
this.k = k;
backtrack(n, new LinkedList<>());
return res;
}
// 1. 当前问题:从小于等于 n 的范围内,在路径中添加第 i 个元素
// 2. 每一步的操作:加入当前元素或不加入当前元素
// 3. 子问题:从小于等于 n - 1 的范围内,在路径中添加第 i + 1 个元素
// 4. 剪枝优化:路径中的元素为 k 个时;路径中的元素加上可选择列表中的剩余的元素不足 k 个时;n 取值小于等于 0 时
private void backtrack(int n, LinkedList<Integer> path) {
// 剪枝优化
if (path.size() == k) {
res.add((List<Integer>) path.clone());
return;
}
if (n + path.size() < k) {
return;
}
if (n <= 0) {
return;
}
// 加
path.add(n);
backtrack(n - 1, path);
// 加入过元素后需要恢复现场
path.removeLast();
// 不加
backtrack(n - 1, path);
}
}
接下来,再看一题 216. 组合总和 III 中等,同样地,它限制了组合长度为 k:
找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:
只使用数字1到9
每个数字 最多使用 一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
示例 1:
输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。
示例 2:
输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]
解释:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
没有其他符合的组合了。
示例 3:
输入: k = 4, n = 1
输出: []
解释: 不存在有效的组合。
在[1,9]范围内使用4个不同的数字,我们可以得到的最小和是1+2+3+4 = 10,因为10 > 1,没有有效的组合。
提示:
2 <= k <= 9
1 <= n <= 60
考虑组合型回溯的四个要点:
- 当前问题:在小于等于 n 的条件下,在路径中添加第 i 个元素
- 每一步的操作:添加当前值或不添加当前值
- 子问题:在小于等于 n - 1 的条件下,在路径中添加第 i + 1 个元素
- 剪枝优化:元素大于等于 k 个;元素和大于等于 n;num 取值小于等于 0
class Solution216 {
List<List<Integer>> res;
int n;
int k;
public List<List<Integer>> combinationSum3(int k, int n) {
this.res = new LinkedList<>();
this.k = k;
this.n = n;
backtrack(9, new LinkedList<>(), 0);
return res;
}
// 1. 当前问题:在小于等于 n 的条件下,在路径中添加第 i 个元素
// 2. 每一步的操作:添加当前值或不添加当前值
// 3. 子问题:在小于等于 n - 1 的条件下,在路径中添加第 i + 1 个元素
// 4. 剪枝优化:元素大于等于 k 个;元素和大于等于 n;num 取值小于等于 0
private void backtrack(int num, LinkedList<Integer> path, int sum) {
if (path.size() == k && sum == n) {
res.add((List<Integer>) path.clone());
return;
}
if (path.size() >= k) {
return;
}
if (sum > n) {
return;
}
if (num <= 0) {
return;
}
// 添加
path.add(num);
backtrack(num - 1, path, sum + num);
path.removeLast();
// 不添加
backtrack(num - 1, path, sum);
}
}
相关练习
- 39. 组合总和 中等
- 40. 组合总和 II 中等
排列型回溯
排列型相比于组合型,对于不同元素的排列顺序是有区别的,比如在排列型回溯中,[1, 2]
和 [2, 1]
是两种 不同的 排列,而在组合中,它们是相同的组合。求解排列型回溯问题时,一般会使用 boolean visited[]
数组来标记对应下标处的元素有没有被选择过,以此来判断哪些元素是能选的,哪些元素是不能选的。下面我们以 46. 全排列 中等 为例,看看该如何求解:
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整数 互不相同
首先我们需要考虑下解决回溯问题的三个要点:
- 当前问题:向路径中添加第 i 个元素
- 每一步的操作:在路径中添加未被选择过的元素
- 子问题:向路径中添加第 i + 1 个元素
public class Solution46 {
List<List<Integer>> res;
public List<List<Integer>> permute(int[] nums) {
res = new LinkedList<>();
backtrack(nums, new boolean[nums.length], new LinkedList<>());
return res;
}
// 1. 当前问题:向路径中添加第 i 个元素
// 2. 每一步的操作:在路径中添加未被选择过的元素
// 3. 子问题:向路径中添加第 i + 1 个元素
private void backtrack(int[] nums, boolean[] visited, LinkedList<Integer> path) {
// 结束条件
if (path.size() == nums.length) {
res.add((List<Integer>) path.clone());
return;
}
for (int i = 0; i < nums.length; i++) {
if (visited[i]) {
continue;
}
path.add(nums[i]);
visited[i] = true;
backtrack(nums, visited, path);
// 恢复现场
path.removeLast();
visited[i] = false;
}
}
}
相关练习
- 47. 全排列 II 中等
- LCR 157. 套餐内商品的排列顺序 中等
- 面试题 08.12. 八皇后 困难
回溯算法使用的注意点
注意使用回溯法时需要关注题目中是否要求返回所有路径(组合),如果不需要的话,可以考虑使用动态规划或其他方法,如题目 377. 组合总和 Ⅳ 中等,该题并不要求返回所有组合,而是组合数目。
巨人的肩膀
- Bilibili - 回溯算法套路①子集型回溯【基础算法精讲 14】
- Bilibili - 回溯算法套路②组合型回溯+剪枝【基础算法精讲 15】
- Bilibili - 回溯算法套路③排列型回溯+N皇后【基础算法精讲 16】