1 前言
本文主要解决的问题如下:
- 回溯算法是什么?
- 解决回溯算法相关问题有什么技巧?
- 如何学习回溯算法?
- 回溯算法是否有规律可循?
1.1 回溯算法概述
其实,回溯算法和DFS算法可以认为是同一种算法。
抽象的说,解决一个回溯问题,实际上就是遍历一遍决策树的过程,树的每一个叶子节点存放着一个合法答案。将整棵树遍历一遍,就将叶子节点的答案收集起来,就能得到所有的合法答案。
站在回溯树上的一个节点,需要思考三个问题:
- 路径:已经做出的选择
- 选择列表:当前可以做的选择
- 结束条件:到达树的底层,无法再做选择的条件
回溯算法的基本框架为:
List<E> result = new ArrayList<>();
void backtrack(路径, 选择列表) {
if (end condition) {
result.add(路径);
return;
}
for (选择 in 选择列表) {
do choose;
backtrack(路径, 选择列表);
undo choose;
}
}
核心在于
for循环里面的递归,在递归调用之前做选择,在递归调用之后撤销选择。
下面通过几个例题来实战一下,探究其中的奥妙。
1.2 全排列问题
见LeetCode第46题T46-全排列问题
1.2.1 思路分析
这道题就是穷举出所有可能的排列。根据我们的经验,若要穷举出全排列:
- 固定一个数字,将这个数字记录下来
- 选择需要固定的第二个数字,记录下来
- ...
如何固定一个数字?
- 使用
boolean[] visited数组来记录某个元素是否被访问过
如何记录数字?
- 需要有一个容器,来记录历史访问到的数据
如何返回?
- 当容器的元素个数等于数组的个数,也就是我们用完了所有的数字,可以返回
返回后要做什么?
- 将加入路径的元素清除
- 将该元素的访问标记清除
1.2.2 实现代码
boolean[] visited; // 访问标记
List<List<Integer>> res;
/**
* 全排列问题
* @param nums
* @return
*/
public List<List<Integer>> permute(int[] nums) {
res = new ArrayList<>();
int n = nums.length;
visited = new boolean[n];
List<Integer> path = new ArrayList<>(); // 记录当前路径
dfs(nums, path);
return res;
}
private void dfs(int[] nums, List<Integer> path) {
// 结束条件,nums元素全部在path中出现
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (!visited[i]) { // 当前数字没有被访问过
path.add(nums[i]); // 加入路径
visited[i] = true; // 标记访问
dfs(nums, path); // 进入下一层决策树
int lastIndex = path.size() - 1;
path.remove(lastIndex); // 取消选择
visited[i] = false; // 取消标记
}
}
}
该算法的时间复杂度为,穷举了整颗决策树。回溯算法并不像动态规划问题一样存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般比较高。
1.3 N皇后问题
见LeetCode第51题N皇后。
1.4 总结
回溯算法就是一个多叉树遍历的过程,写backtrack函数的时候,一定要维护走过的[路径]和当前可以做选择的列表,当触发结束条件的时候,将路径计入结果集中。
2 排列|组合|子集问题
2.1 问题概述
排列|组合|子集问题,就是从给定nums中取出若干元素,主要有下面几种变体:
-
元素无重复不可重复选取:
nums中的元素都是唯一的,每个元素只能使用一次 -
重复元素不可重复选取:
nums中的元素存在重复,但是每个元素只能选取一次 -
无重复元素可以重复选取:
nums中的元素唯一,每个元素可以被重复使用 -
重复元素重复选取:重复元素就代表着可以重复选取
无论问题的形式如何变化,本质就是穷举出所有的解,这些解呈现树形结构,所以合理运用回溯算法框架,便可以将这些问题一网打尽。
- 组合|子集树
- 排列树
组合问题和子集问题其实是等价的,问题的三种形式,无非就是在组合树和排列树剪掉或者增加一点树枝罢了。
2.2 子集-去重不可复选
见LeetCode第78题,78、子集
思路分析
- 第一个元素:
nums[0] ~ nums[n - 1] - 第一个节点的子节点:
nums[1] ~ nums[n - 1] - 【选择】对于某一层的某个节点,他的孩子节点的为
nums[i + 1] ~ nums[n - 1] - 【路径】
List<Integer> tempList
2.3 组合-去重不可重复选
见力扣第77题,[77-组合]。
思路见T77-组合
2.4 排列-去重不可重复选
全排列问题在[1.2 全排列问题](#1.2 全排列问题)中讲过。
2.5 子集|组合-元素可重不可重复选
标准的子集问题输入的nums是没有重复元素的,如果出现了重复元素,应当如何处理呢?
对于这样的问题,可以先对nums中的元素进行排序,如果nums[i - 1] == nums[i],就可以跳过深度搜索,实现剪枝的效果。
LeetCode中第T90 子集II描述了这样的问题。
2.6 排列-元素可重不可重复选
排列问题存在重复,比子集问题复杂,见LeetCode第47题全排列II
核心思路依旧是:
- 排序
- 剪枝
2.7 子集|组合-元素无重复可以重复选
见LeetCode第39题[组合总数]
2.8 排列-元素无重复可以重复选
LeetCode上没有类似的题,但是可以想想一下nums中的元素没有重复,可以可以重复选,总共有哪些排列?
若输入为nums = {1, 2, 3},总有有种排列方式。
标准的全排列问题需要使用剪枝数组boolean[] isVisited来进行剪枝,避免使用相同的元素。如果允许使用同一个元素,则去掉剪枝逻辑即可。
class Solution {
List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> track = new LinkedList<>();
public List<List<Integer>> permuteRepeat(int[] nums) {
backtrack(nums);
return res;
}
// 回溯算法核心函数
void backtrack(int[] nums) {
// base case,到达叶子节点
if (track.size() == nums.length) {
// 收集叶子节点上的值
res.add(new LinkedList(track));
return;
}
// 回溯算法标准框架
for (int i = 0; i < nums.length; i++) {
// 做选择
track.add(nums[i]);
// 进入下一层回溯树
backtrack(nums);
// 取消选择
track.removeLast();
}
}
}
2.9 总结
- 元素无重复不可重复选:在于记录已经选择过的元素,需要使用
boolean[] isvisted数组来判断当前元素是否合法 - 元素有重复不可重复选:在于排序和剪枝,如果两个相邻的子节点的元素相同,则执行剪枝
- 元素无重复可重复选:在于去重逻辑,需要删除重复的东西
3 回溯算法两种穷举视角-球盒模型
在上一节中通过列举9种不同的排列组合问题,详细探究了回溯算法是如何解决这些问题的。
本节,将深入研究递归穷举算法的原理,为读者解释为什么要那样写。
先说重要结论:
- 回溯的本质思维模式是【球盒模型】,一些回溯算法,皆从此出,别无二法
- 【球盒模型】,必然有两种穷举视角,分别为【球】和【盒】
- 从理论上分析,两种穷举视角本质上是一样的,但涉及到代码的实现,复杂度可能有优劣之分
3.1 穷举思维方式-球盒模型
首先回顾我们学过的排列组合知识:
- 全排列表示从个球中有序地拿出个球,共有几种排列方式
- 组合表示从个球中一把抓出个球,结果可能为几种
3.1.1 全排列
盒子视角
第一个盒子从n个盒子中选择一个球,有n种选择方法,剩余的k - 1个盒子从n - 1个球中选择自己的球,即
球视角
当前球可以选择入盒子,也可以选择不入盒子
- 不入盒子:将剩下的个球装进个盒子
- 入盒子:将剩下的个球装进个盒子
即:
3.1.2 组合
由于组合不在乎球的顺序,所以盒子是1个容量为的盒子。
盒子视角
第一个球可以选择个球的任意一个,然后剩余的容量从个球中选择,即
球视角
每个球有两种选择:
- 装进盒子:将剩下的个球装进容量为盒子
- 不进盒子:将剩下的个球装进盒子
TODO
4 回溯算法实践
4.1 解数独
见LeetCode第37题[解数独]。
算法的核心思路非常简单,就是对每个空着的格子穷举1-9,如果遇到不合法的数字,即在同一行或者同一列或者3*3的格子里头有相同的数字,则跳过,如果遇到一个合法的数字,则继续穷举下一个空格。
我的具体思路见T37-解数独
4.2 集合划分
回溯算法是一种暴力求解方法,其过程就是穷举一棵决策树的过程,只要在递归之前做出选择,递归之后撤销选择即可。
本小节分析LeetCode第698题[划分为K个相等的子集]
本题我是没有一点思路的。
这道算法题就是让我们求集合的划分,可以借鉴球盒模型的抽象,从两种不同的角度来解决这道集合划分问题。
将装有n个数字的数组nums分成k个和相同的集合,类似于从一堆数字球中选择一个球,放入到第k个桶中,最后这k个桶数字之和要相同。
回溯算法的关键在于:如何正确地做选择,这样才能利用递归进行穷举。
4.2.1 从数字出发
从某个数字nums[i]来看,该数字要选择进入到bucket[j]中
如何使用递归的方式,实现这种思路呢?
对数字递归遍历,而对桶使用遍历的方式。
int[] bucket = new int[k];
// 穷举 nums 中的每个数字
void backtrace(int[] nums, index) {
// 返回条件
if (index == nums.length) {
return;
}
// 穷举每一个桶
for (int i = 0; i< bucket.length; i++) {
// 选择装进第 i 个桶
bucket[i] += nums[index];
// 递归下一个数字的选择
backtrace(nums, index + 1);
// 撤销选择
bucket[i] -= nums[index];
}
}
对上面的进行修改,就可以得到从数字角度出发的解:
/**
* 划分为 K 个相等的子集
* @param nums
* @param k
* @return
*/
public boolean canPartitionKSubsets(int[] nums, int k) {
// 排除一些基本情况
if (k > nums.length) return false;
int sum = 0;
for(int num : nums) {
sum += num;
}
if (sum % k != 0) return false;
// 桶集合,记录每个桶的和
int[] buckets = new int[k];
// 理论上每个桶集合中的数字和
int target = sum / k;
// 穷举,判断是否能够划分为 target 子集
return backtrace(nums, 0, buckets, target);
}
/**
* 递归判断是否可以拆分
* @param nums
* @param i
* @param buckets
* @param target 每个桶的目标和
* @return
*/
private boolean backtrace(int[] nums, int i, int[] buckets, int target) {
if (i == nums.length) {
// 遍历检查所有桶中的数字之和是否为 target
for (int j = 0; j < buckets.length; j++) {
if (buckets[j] != target) {
return false;
}
}
return true;
}
// 穷举 nums[i] 可能放到的桶中
for (int j = 0; j < buckets.length; j++) {
if (buckets[j] + nums[i] > target) { // 剪枝逻辑
// 这个桶桶不能放这个数字
continue;
}
buckets[j] += nums[i];
// 穷举递归下一个数字的选择
if (backtrace(nums, i + 1, buckets, target)) {
return true;
}
// 撤销数字的选择
buckets[j] -= nums[i];
}
// 放到哪个桶中都是不可以的
return false;
}
上述代码是可以进行优化的,为了降低算法的递归深度,我们尽量让算法走剪枝逻辑,也即是说用少量的数字就填满桶,因此我们可以预先对数组从大到小进行排序。
计算复杂度分析
- 对于每个数字,都有
k个桶可以选择,所以组合出来的结果个数为,即时间复杂度为。
4.2.2 从桶的视角出发
从桶的视角出发,每个桶需要遍历nums数组中的所有数字,决定是否将当前数字装进桶中,当装满一个桶的时候,还要装下一个桶,直到所有的桶都装满。
/**
* 带有备忘录的解法:从桶的视角出发
* @param nums
* @param k
* @return
*/
public boolean canPartitionKSubsetsI(int[] nums, int k) {
if (k > nums.length) return false;
int sum = 0;
for (int num : nums) sum += num;
if (sum % k != 0) return false;
// 每个桶的目标数字
int target = sum / k;
int used = 0; // 使用位图的方式,来判读某个元素是否被使用过了
return dfs(k, 0, nums, 0, used, target);
}
HashMap<Integer, Boolean> memo = new HashMap<>();
private boolean dfs(int k, int curSum, int[] nums, int start, int used, int target) {
if (k == 0) {
return true;
}
if (curSum == target) {
// 递归下一个桶
boolean res = dfs(k - 1, 0, nums, 0, used, target);
memo.put(used, res); // 记录 used 状态以及其 搜索结果
return res;
}
if (memo.containsKey(used)) {
return memo.get(used); // 如果备忘录中含有当前状态的结果,直接返回,避免冗余计算
}
for (int i = start; i < nums.length; i++) {
if (((used >> i) & 1) == 1) {
// 判断当前数字nums[i]是否被使用过
continue;
}
if (nums[i] + curSum > target) continue;
// 做选择
used |= 1 << i;
curSum += nums[i];
if (dfs(k, curSum, nums, i + 1, used, target)) {
return true;
}
// 撤销选择
used ^= 1 << i;
curSum -= nums[i];
}
return false;
}
在上面的方法中,使用到了位图的方式记录某个数字是否已经被访问过,并且使用了
memo来记录当前状态下是否有正确的结果,降低计算的复杂度。
时间复杂度
- 每个桶要遍历
n个数字,对于每个数字有装和不装两种状态,所以对于每个桶,有中选择,总共k个桶,时间复杂度为
回溯算法经典例题
T46-全排列问题
题目描述
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
我的思路
- 定义一个访问标记
int[n] visited - 结果集
List<List<Integer>>
思路凌乱
T51-N皇后问题
题目描述
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
皇后可以攻击同一行、同一列、左上、左下、右上、右下四个方向上的任意单位
我的思路
- 棋盘初始化
char[][] board = new char[n][n],初始化为. List<int[]> position用来存储皇后的位置,后续根据这个判断是否合法- 从第
row = 0行开始递归 - 递归函数
dfs(row, col, postion)- 递归截止条件:
row >= n || position.size() == 8
- 对于
row行,每一列进行遍历,通过available(row, col, posiziotn)判断是否合法 - 将当前棋子的位置放进
position - 在棋盘
board的对应位置置为Q - 递归遍历找到下一行
- 将当前棋子从集合中移出
- 棋盘对应位置为
.
- 递归截止条件:
- 判断是否合法:同一行|同一列|斜率为的位置都是不合法的,单独一个方法去判断
T78-子集
题目描述
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的
子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
我的思路
- DFS深度优先遍历
T77-组合
题目描述
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
我的思路
k限定了数的深度,所以递归结束条件由k决定,或者是临时列表的size- DFS深度优先遍历
- [选择]对于某个固定的节点
nums[i],其孩子节点可能为nums[i + 1] ~ nums[n - 1] - 返回条件:
size == k
- [选择]对于某个固定的节点
- 要先添加元素,再判断返回条件,防止出现重复组合
T90-子集II
问题描述
给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的 子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列
我的思路
- 子集问题+去重
- 如何去重?可以使用
HashSet,值是排列内容字符串,如果已经存在,就不往结果集合里头插入了
优化思路
剪枝,排序之后的数组,如果当前数字和前一个数字相同,就不再进行遍历了,只遍历 第一条。
class Solution {
List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> track = new LinkedList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
// 先排序,让相同的元素靠在一起
Arrays.sort(nums);
backtrack(nums, 0);
return res;
}
void backtrack(int[] nums, int start) {
// 前序位置,每个节点的值都是一个子集
res.add(new LinkedList<>(track));
for (int i = start; i < nums.length; i++) {
// 剪枝逻辑,值相同的相邻树枝,只遍历第一条
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
track.addLast(nums[i]);
backtrack(nums, i + 1);
track.removeLast();
}
}
}
}
T40-组合总和II
题目描述
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次 。
**注意:**解集不能包含重复的组合
我的思路
- 组合中元素的个数是不是固定的,所以不能够使用多数之和
- 深度优先遍历
- 结束条件为
start == nums.length,或者是curSum == target - 先排序,这样在搜索子节点的时候,可以及时跳出
- 也需要进行剪枝
- 结束条件为
T47-全排列II
题目描述
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
我的思路
- 不重复的全排列,就需要剪枝
- 剪枝之前先排序,保证相同的节点位置相邻
- 如何剪枝?如果相邻的子节点相同,就跳过这个子节点
- 增加一个变量,记录访问的前一个子节点,如果当前节点和之前的节点相同,就跳过
优化
剪枝条件可以为
if (i > 0 && nums[i] == nums[i - 1] && !vis[i - 1]) {
continue;
}
T39-组合总数
题目描述
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
我的思路
- 首先排序
- 回溯算法判断是否满足
target - 递归结束条件为
target == 0
注意的点
- 保证组合的不重复,遍历子节点的时候,起始索引为
i + 1 - 本体可以重复使用元素,因此递归搜索子节点的时候,起始索引为
i
T37-解数独
题目描述
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字
1-9在每一行只能出现一次。 - 数字
1-9在每一列只能出现一次。 - 数字
1-9在每一个以粗实线分隔的3x3宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.' 表示。
我的思路
- 遍历寻找下一个需要填数字坐标
(i, j) - 根据函数
isValid(i,j, board)判断合适的数字 - 将合法的数字填入到
board中 - 进入递归函数
backtrace(i, j, board)判断下一个位置 - 将当前写入的数字擦除
- 返回条件所有空位都被填上了数字
需要注意的是,
backtrace()函数的返回值是boolean类型的,这样可以在找到最优解的时候,直接返回
T698-划分为k个相等的子集
题目描述
给定一个整数数组 nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。
示例 1:
输入: nums = [4, 3, 2, 3, 5, 2, 1], k = 4
输出: True
说明: 有可能将其分成 4 个子集(5),(1,4),(2,3),(2,3)等于总和。
我的思路
完全没有思路