18.回溯问题去重的另一种写法
在同一父节点下去重的话,子集问题一定要排序。
可以用set来对本层去重。如果是在本层中的父节点下去重,则used数组要写在backtracking中,回溯前置true,回溯后不置false。在for循环中判断该元素是否已经用过,就在used的set中找,类似于哈希表。
这时候如果把used写在全局变量(类成员位置)中,同时在回溯后擦去就是错误的。这种写在全局变量中,会把树枝的情况都记录了,而不是单纯的控制某一节点下的同一层了。
一旦把used放在全局变量中,控制的就是整棵树,包括树枝。
组合和全排列问题也可以使用set来去重,但是没有数组效率高,因为会不断地做哈希映射,相对费时间。
数组去重组合、子集、排列问题的空间复杂度都是O(n),如果是set就变成O(n^2)。
java中可以使用hashSet来做去重,判断哈希表中是否有某个元素用hashSet.contain(元素)。
19.重新安排行程
例题332:**给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。
所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。
例如,行程 [“JFK”, “LGA”] 与 [“JFK”, “LGB”] 相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。**
深度优先遍历中的回溯
难点:
- 处理不当容易让所有航班变成死循环;
- 多种解法取字典序最小的,如何记录映射关系;
- 使用回溯的终止条件?
- 搜索的过程中,如何遍历一个机场对应的所有机场?
记录映射关系
一个机场映射多个机场,机场之间靠字母排序,可以用std::unordered_map,如果要让机场之间有顺序的话,可以用std::map或者std::multimap或者std::multiset。
映射关系可以定义为unordered_map<String,multiset<String>> targets
或者 unordered_map<String,map<String,int>> targets
含义如下:
unordered_map<string, multiset> targets:unordered_map<出发机场, 到达机场的集合> targets
unordered_map<string, map<string, int>> targets:unordered_map<出发机场, map<到达机场, 航班次数>> targets
这两个结构,我选择了后者,因为如果使用unordered_map<string, multiset> targets 遍历multiset的时候,不能删除元素,一旦删除元素,迭代器就失效了。
再说一下为什么一定要增删元素呢,正如开篇我给出的图中所示,出发机场和到达机场是会重复的,搜索的过程没及时删除目的机场就会死循环。
所以搜索的过程中就是要不断的删multiset里的元素,那么推荐使用unordered_map<string, map<string, int>> targets。
在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets的过程中,可以使用"航班次数"这个字段的数字做相应的增减,来标记到达机场是否使用过了。
如果“航班次数”大于零,说明目的地还可以飞,如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。
回溯法
将下例子抽象为树形结构:
回溯三部曲:
- 确定函数返回值与参数
用unordered_map<String,map<String,int>> targets
来记录航班的映射关系,定义为全局变量。
参数里还需要ticketNum表示一个机场对应机场的个数。
// unordered_map<出发机场, map<到达机场, 航班次数>> targets
unordered_map<string, map<string, int>> targets;
bool backtracking(int ticketNum, vector<string>& result) {
注意这里返回值是bool
回溯算法返回值一般都是void,为什么这里是bool?
因为只需要找到一个行程,即树形结构中唯一的一条通向叶子节点的路线,如图:
当找到这个叶子节点就返回。
- 确定终止条件
如果回溯过程中,遇到的机场个数达到了(航班数+1),那么就找到了一个行程,把所有的航班串在一起。
- 单层回溯逻辑
回溯的过程中,如何遍历一个机场所对应的所有机场呢?
这里刚刚说过,在选择映射函数的时候,不能选择unordered_map<string, multiset> targets, 因为一旦有元素增删multiset的迭代器就会失效,当然可能有牛逼的容器删除元素迭代器不会失效,这里就不在讨论了。
可以说本题既要找到一个对数据进行排序的容器,而且还要容易增删元素,迭代器还不能失效。
所以我选择了unordered_map<string, map<string, int>> targets 来做机场之间的映射。
class Solution{
List<String> res=new ArrayList<>();
LinkedList<String> path=new LinkedList();
public List<String> findItinerary(List<List<String>> tickets){
//将行程按照首字母顺序排序
Collections.sort(tickets, (a, b) -> a.get(1).compareTo(b.get(1)));
path.add("JFK");
boolean[] used=new boolean[tickets.size()];
backtracking((ArrayList) tickets,used);
return res;
}
public boolean backtracking(ArrayList<List<String>> tickets,boolean[] used){
if(path.size()==tickets.size()+1){
res=new LinkedList(path);
return true;
}
for(int i=0;i<tickets.size();i++){
if(used[i]==false && tickets.get(i).get(0).equals(path.getLast())){
path.add(tickets.get(i).get(1));
used[i]=true;
if(backtracking(tickets,used)){
return true;
}
used[i]=false;
path.removeLast();
}
}
return false;
}
}
java中List<List> 的初始化是先创建List,然后往内层添加List,再通过下标往每一层添加元素。如
public static void main(String[] args) {
List<List<String>> tickets = new ArrayList<>();
for (int i = 0; i < 5; i++) {
tickets.add(new LinkedList<>());
}
tickets.get(0).add("JFK");
tickets.get(0).add("SFO");
tickets.get(1).add("JFK");
tickets.get(1).add("ATL");
tickets.get(2).add("SFO");
tickets.get(2).add("ATL");
tickets.get(3).add("ATL");
tickets.get(3).add("JFK");
tickets.get(4).add("ATL");
tickets.get(4).add("SFO");
System.out.println(tickets);
}
20.N皇后
例题51:按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
N皇后是用回溯法解决的经典例题,将搜索过程抽象为一棵树,如图:
从图中,可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。
那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了。
class Solution{
List<List<String>> res=new LinkedList<>();
public List<List<String>> solveNQueens(int n){
//棋盘的宽度就是for循环的长度,棋盘的高度就是回溯的深度
char[][] qp=new char[n][n];
for(char[] a:qp){
Arrays.fill(a,'.');
}
backtracking(n,0,qp);
return res;
}
public void backtracking(int n,int row,char[][] qp){
if(row==n){
res.add(Array2List(qp));
return;
}
for(int i=0;i<n;i++){
if(isValid(i,row,n,qp)){
qp[row][i]='Q';
backtracking(n,row+1,qp);
qp[row][i]='.';
}
}
}
public List Array2List(char[][] qp){
List<String> list=new ArrayList<>();
for(char[] a:qp){
list.add(String.copyValueOf(a));
}
return list;
}
public boolean isValid(int col,int row,int n,char[][] qp){
for(int i=0;i<n;i++){//排除同列重复
if(qp[i][col]=='Q'){
return false;
}
}
//排除45度对角线
for (int i = row-1,j=col+1;i>=0 && j<n;i--,j++) {
if (qp[i][j] == 'Q') {
return false;
}
}
//排除135度对角线
for(int i=row-1,j=col-1;i>=0 && j>=0;i--,j--){
if( qp[i][j]=='Q'){
return false;
}
}
return true;
}
}
这个题在搜索过程中使用的是二维字符数组char[][],而返回类型是List<List>,因此需要将二维字符数组转换为字符集合加入结果集。需要用到list.add(String.copyValueOf(一行字符数组))这个函数将字符数组的值复制到集合中。
同时,判断斜线上是否有相同的皇后,就是在二维数组中判断左右的斜上方是否有重复。
21.解数独
例题37:编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
1.数字 1-9 在每一行只能出现一次。
2.数字 1-9 在每一列只能出现一次。
3.数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 ‘.’ 表示。
class Solution{
public void solveSudoku(char[][] board){
backtarcking(board);
}
public boolean backtarcking(char[][] board){
for(int i=0;i<9;i++){//控制每行下的列
for(int j=0;j<9;j++){
if(board[i][j]!='.'){
continue;
}
for(char num='1';num<='9';num++){//控制选的数
if(isValid(i,j,board,num)){
board[i][j]=num;
if(backtarcking(board)){
return true;
}
board[i][j]='.';
}
}
return false;
}
}
return true;
}
public boolean isValid(int row,int col,char[][] board,char num){
//排除同行
for(int i=0;i<9;i++){
if(board[row][i]==num){
return false;
}
}
//排除同列
for(int i=0;i<9;i++){
if(board[i][col]==num){
return false;
}
}
//排除3*3的方块
int startRow=(row/3)*3;
int startCol=(col/3)*3;
for(int i=startRow;i<startRow+3;i++){
for(int j=startCol;j<startCol+3;j++){
if(board[i][j]==num){
return false;
}
}
}
return true;
}
}
数独与N皇后不同,需要遍历每行每列的所有数字组合,而N皇后只用遍历每行一列放置一个皇后。因此可以用两层for循环遍历每个棋盘格,然后判断每个棋盘格填数字的合法性。
数独对3*3的小棋盘去重,不能对行列判断有9种情况会超时,需要算出该行该列是从哪个小棋盘的左上角开始,用int startRow=(row/3)*3; int startCol=(col/3)*3;
可以解决。
22.总结
理论基础
回溯是递归的副产品,只要有递归就有回溯,所有回溯法经常和二叉树遍历、深度优先遍历混在一起,因为这两种方式都用了递归。
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
回溯算法能解决如下问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
回溯法代码模板:
oid backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
组合问题
回溯法中用递归控制for循环嵌套的数量,把搜索过程抽象为树形结构,如题:
for循环横向遍历,递归纵向遍历,回溯不断调整结果集。
剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了。
组合总和
在组合问题中加了一个元素总和的限制。
剪枝就是如果当前和已经大于target,后续直接不进入遍历。
组合总和||
与上题的区别在于本题没有数量要求,可以无限重复,但是有总和的限制,所以间接也是个数的限制。
使用startIndex来控制for循环起始位置,可以避免重复取数。
组合总和|||
集合元素有重复,求解集不能包含重复组合。
**难点就在于去重。**去重包括树枝去重与树层去重。
我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
多个集合求组合
如果在现场面试的时候,一定要注意各种输入异常的情况,例如本题输入1 * #按键。
切割问题
难点:
- 切割问题其实类似组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
子集问题
数组无重复。
在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
这是一道子集问题的模板题,收集所有节点的值。
子集问题||
数组可重复。
针对子集问题去重。
递增子序列
使用set对同一父节点本层去重,子集要提前排序,才方便跳过重复的元素。
排列问题
数组无重复。
排列是有序的,{1,2}和{2,1}是两个集合。
排列可以不使用startIndex。
从上图可以看出,排列每层都是从0开始而不是startIndex,需要使用used数组记录path放了哪些元素。
排列问题||
数组有重复。
需要去重。
这个题可以发现used[i-1]=false 或者used[i-1]=true都可以去重,但前者效率更高。
去重问题
可以使用used数组去重,也可以使用set去重,set由于要哈希映射,效率更低。
重新安排行程
棋盘问题
N皇后问题
矩阵的宽度就是for循环的长度,矩阵的长度就是树形结构的高度。
解数独
和N皇后不同的是,需要对每一行空格的地方判断填入数字是否正确。
用两层for循环遍历每个格子,也就是二维递归。
性能分析
以下在计算空间复杂度的时候我都把系统栈(不是数据结构里的栈)所占空间算进去。
子集问题分析:
- 时间复杂度:O(2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2n)
- 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)
排列问题分析:
- 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
- 空间复杂度:O(n),和子集问题同理。
组合问题分析:
- 时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度:O(n),和子集问题同理。
N皇后问题分析:
- 时间复杂度:O(n!) ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * … * 1。
- 空间复杂度:O(n),和子集问题同理。
解数独问题分析:
- 时间复杂度:O(9^m) , m是’.'的数目。
- 空间复杂度:O(n2),递归的深度是n2
一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!