回溯4:去重另一种写法、重新安排行程、N皇后、解数独

18.回溯问题去重的另一种写法

在同一父节点下去重的话,子集问题一定要排序。
子集问题去重要先排序
可以用set来对本层去重。如果是在本层中的父节点下去重,则used数组要写在backtracking中,回溯前置true,回溯后不置false。在for循环中判断该元素是否已经用过,就在used的set中找,类似于哈希表。

这时候如果把used写在全局变量(类成员位置)中,同时在回溯后擦去就是错误的。这种写在全局变量中,会把树枝的情况都记录了,而不是单纯的控制某一节点下的同一层了。
全局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
    一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!

总结

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值