算法学习之回溯算法

190 阅读26分钟

回溯算法的入门学习

本节我们来学习回溯算法,我们先来看看回溯算法能够解决哪些问题

  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 棋盘问题:N皇后,解数独等等

有一点我们要先提一下,任何递归算法都是非常低效率的算法,即使加了剪枝也是,所以能用for循环构建解决的算法,就不要用递归和回溯,这点要先搞清楚,不要看到一个题目就想着什么几把递归回溯,到时候给你整个超时你就知道什么是痛苦。另外有的同学可能搞不懂组合和排序的分别,他们的区别请看下图

我们如何理解回溯法呢?我们首先要记住一点,那就是回溯法解决的问题都可以抽象为树形结构,这一点我们会在后面的算法问题中反复提到,我们无论是构建回溯法的思路还是去理解回溯法都是将其抽象会树形结构来进行理解的。为什么其可以理解为树形结构呢?因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成的树的深度。现在可能还听不太懂,但是随着我们做题的深入,我们肯定是可以听懂的

接着我们来讲我们回溯法的代码模板,我们的模板就是我们以后做回溯题目都用的上的模板,非常重要,所以我们要认真学

我们第一步要确定的是我们的回溯法的结束条件,这个一般是我们左边界大于右边界或者是我们的集合存放的数量达到了我们的题目所要求的量。第二步我们要确定的是我们的递归逻辑,一般我们可以将其抽象为一个树型结构,如下图所示

一般这里是要使用一个for循环来进行递归,且进入递归之前我们要添加对应的值,而出去递归之后又要将加入的值给删去,不然就不是回溯了,他会一直往上加,这肯定不行的。这里的for循环的逻辑是随着我们的具体的题目需求的变化而变化的。

最后是我们的结束代码,不过一般我们的回溯代码的返回值都是void,所以这个其实也可以不加,这里我们先按下不表,要加的时候再加上吧

学习完了模板之后,我们就来正式做我们的回溯的题目

组合

对于本题,我们就可以使用我们之前的模板来进行解题了,注意我们这里要求的是组合,其对顺序的互异是不要求的,也就是两个元素相同但顺序不同的组合,其会被认为是一个元素。那么我们就需要在做的时候,同时做到去重。

那么我们要如何做到去重呢?其实很简单,我们只要让我的起始坐标在递归时不断前进,当集合的数量到达指定数量时就收集结果,按照这样的逻辑,我们在我们收集的过程中就能够完成去重,因为实际上这样做根本不会得到重复的元素,具体请看下路

最后我们可以写入我们的代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> combine(int n, int k) {
        dfs(n,k,1,new ArrayList<>());
        return list;
    }
​
    private void dfs(int n, int k, int start, List<Integer> list) {
        if(list.size()==k){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i <= n; i++) {
            list.add(i);
            dfs(n,k,i+1,list);
            list.remove(list.size()-1);
        }
    }
}

我们上面的这个代码的确是可行的,但是他的效率有些不尽人意,我们要怎么才能对其进行对应的优化呢?这当然就需要用到剪枝。那么我们的剪枝应该怎么做呢?其实我们不难分析出来,如果当前的指针到边界里所能拿到的数据量比我们当前的集合所存放的数据数量与到目标数量之差还要少,那么就可以执行剪枝。这样听着似乎很不好理解,我们直接看图就可以了

那么我们可以将我们的代码改造如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> combine(int n, int k) {
        dfs(n,k,1,new ArrayList<>());
        return list;
    }
​
    private void dfs(int n, int k, int start, List<Integer> list) {
        if(list.size()==k){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i <= n - (k - list.size()) + 1; i++) {
            list.add(i);
            dfs(n,k,i+1,list);
            list.remove(list.size()-1);
        }
    }
}

我们这里的n是边界,而k是我们的目标大小,那么k-list.size(),就是目标大小与集合当前数据总量的差,那么n-(k-list.size())就是所有集合数据与上一个结果的差值,我们的i必须要比这个所剩余的值还要小,否则就没必要进行继续递归,同时这里还进行了一个+1,这里由于边界值问题所以给的一个+1补偿。另外我们的原式应该是n-i<=k-list.size()-1,我们上面最后的结果只不过是进行了一个式子的转换而已

我们如果不想做这种转换行为,我们也可以将我们的代码写成如下的形式

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> combine(int n, int k) {
        dfs(n,k,1,new ArrayList<>());
        return list;
    }
​
    private void dfs(int n, int k, int start, List<Integer> list) {
        if(list.size()==k){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i <= n; i++) {
            if(i>n - (k - list.size()) + 1){
                break;
            }
            list.add(i);
            dfs(n,k,i+1,list);
            list.remove(list.size()-1);
        }
    }
}

我们这个代码的逻辑就更好理解了一些,不过更加繁琐了就是,而且本质还是一样的

组合总和III

这题直接套用我们的模板就可以了,其组合的最终情况是我们的总和为我们的目标和时,其剪枝条件时总和比我们的目标和还要大时或者是我们集合中的数量超越了我们想要的数量时,那么我们可以写入我们的代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> combinationSum3(int k, int n) {
        dfs(k,n,1,0,new ArrayList<>());
        return list;
    }
​
    private void dfs(int k, int n, int start, int sum, List<Integer> list) {
        if(sum>n || list.size()>k){
            return;
        }
        if(sum==n && list.size()==k){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i < 10; i++) {
            list.add(i);
            dfs(k,n,i+1,sum+i,list);
            list.remove(list.size()-1);
        }
    }
}

电话号码的字母组合

对于这一题,同样是套用模板,不过这里我们套用的模板有些不一样,首先,为了便于我们后续的递归,我们先创建一个符合条件的字符串数组,用其来模拟我们电话号码上的字符串

class Solution {
    List<String> list = new ArrayList<>();
    String[] arr = new String[]{"0","1","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
    public List<String> letterCombinations(String digits) {
        if(digits.equals("")){
            return list;
        }
        StringBuffer sb = new StringBuffer();
        dfs(digits,0,0,sb);
        return list;
    }
​
    private void dfs(String digits, int is, int js, StringBuffer sb) {
        if(sb.length()==digits.length()){
            list.add(sb.toString());
            return;
        }
        int index = digits.charAt(is) - '0';
        String s = arr[index];
        for (int j = js; j < s.length(); j++) {
            sb.append(s.charAt(j));
            dfs(digits,is+1,js,sb);
            sb.deleteCharAt(sb.length()-1);
        }
    }
}

这里我们注意我们的递归,我们这里递归用了两个参数来传递我们的指针,第一个指针是is,这个指针用于递归遍历我们的号码,第二个是js,这个是用于遍历递归我们的每一个数据能代表的具体值的指针,同样使用回溯模板,不过我们这里多加了一个指针而已

最后来看看我们的思路图

组合总和

这题也不难,没什么特别值得说的,我们可以直接写入我们的回溯代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    int target;
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        this.target=target;
        Arrays.sort(candidates);
        dfs(candidates,0,0,new ArrayList<>());
        return this.list;
    }
​
    private void dfs(int[] candidates, int all, int start, List<Integer> list) {
        if(all>target){
            return;
        }else if(all==target){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i < candidates.length; i++) {
            if(all+candidates[i]>target){
                break;
            }
            list.add(candidates[i]);
            dfs(candidates,all+candidates[i],i,list);
            list.remove(list.size()-1);
        }
    }
}

这里我们进行了剪枝,不过值得注意的是,我们的剪枝操作必须要事先对我们的数组进行排序才可以达到,否则是无法排序的。

回溯算法的进阶学习

之前我们已经做过了回溯算法的入门学习,但那都只是小试牛刀,接下来,我们就要进行进阶的学习,本节的内容就颇具难度了。

组合总和 II

这题,就很有难度了,这里难的主要部分在于去重,我们要怎么才能够保证我们最终得到的组合不是重复的呢?我们容易想到使用set集合来进行去重,但这个属实是太慢了,最后我们会在一个例子里直接进行一个时的超,寄了。

回到正题,那我们要如何进行去重呢?

其实我们的基本思路很简单,只要让我们的递归逻辑不会在同一层中选择相同的元素就可以了,这里正确性的证明就不做了,我懒得,反正原理的确就是如此。那么我们要如何完成这个逻辑呢?我们新创建一个布尔类型的数组来帮我们完成这个工作,我们的逻辑很简单,我们每次选择一个数的时候,就将该数对应的坐标代表的布尔数组的值更改为true,回到这一层的时候就将其更换为false。我们每次选择数之前,判断该数是否与前一个树相同,如果相同再进行其前一个布尔类型的值是否为真的判断,若为真则说明前一个数已经被我们选取,现在已经是我们的组合中的一员,我们就不用对其进行任何考虑,但如果没有选择,则说明我们前一个递归已经选择过该数了,此时我们为了去重就不应该重复选择相同的数,此时我们要跳过该值的选择

那么根据上面的思路,我们可以写入我们的代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        List<Integer> list = new ArrayList<>();
        boolean[] booleans = new boolean[candidates.length];
        Arrays.sort(candidates);
        dfs(candidates,target,list,booleans,0,0);
        return this.list;
    }
​
    private void dfs(int[] candidates, int target, List<Integer> list, boolean[] booleans,int sum,int start) {
        if(sum==target){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i < candidates.length; i++) {
            if(i>0 && candidates[i]==candidates[i-1] && !booleans[i-1]){
                continue;
            }
            if(sum+candidates[i]>target){
                break;
            }
            list.add(candidates[i]);
            booleans[i]=true;
            dfs(candidates,target,list,booleans,sum+candidates[i],i+1);
            list.remove(list.size()-1);
            booleans[i]=false;
        }
    }
}

我们这里先对我们的数组进行了排序,这是当然的,如果不进行排序,那我们后面的东西根本玩不转。然后我们的中间的递归逻辑是我们的第一个数不参与去重,这也很好理解,而当我们的总和超越了我们的目标和时,我们就直接停止for循环里的递归,不必再进行查找。

最后我们其实容易知道,我们的这个代码其实的本质逻辑其实就是不要令其选择同一层的重复元素而已,我们之前是用布尔类型的数组来帮助我们的判断的,但实际上,我们也可以不用该数组。我们可以使用最开始传入的坐标来完成我们的判断,请看代码

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        List<Integer> list = new ArrayList<>();
        Arrays.sort(candidates);
        dfs(candidates,target,list,0,0);
        return this.list;
    }
​
    private void dfs(int[] candidates, int target, List<Integer> list,int sum,int start) {
        if(sum==target){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i < candidates.length; i++) {
            if(i>0 && candidates[i]==candidates[i-1] && i>start){
                continue;
            }
            if(sum+candidates[i]>target){
                break;
            }
            list.add(candidates[i]);
            dfs(candidates,target,list,sum+candidates[i],i+1);
            list.remove(list.size()-1);
        }
    }
}

我们上面的代码原理在于,如何当我们递归进入新一层时,必然其start也会跟着变化,且最开始的i必然是等于start的,只要i大于start,此时就说明start代表的元素已经被选取,那么此时i和start必然在同一层且i指向的是大于start的元素,此时i还没有被选择,此时我们进行i是否与前一位相同的判断,若相同则跳过,不相同则选取。

这里我们要记住的模板是我们的去重的判断,几乎很多需要去重的题目,其去重过程都可以简化为不在同一层中选择相同的元素来解决,我们实现不同组合中的元素不相同的方法就是使用上面的方法实现的

分割回文串

本节要求我们要做的事情是分割回文串,我们要做的事情是对我们的该串进行分割,令其返回所有可能形成的回文子串,这题难就难在这里的递归逻辑其实和我们之前的不太一样,我们首先要确定我们的递归停止的逻辑,我们以前的递归停止逻辑都是当我们的集合收集到指定数量的值之后停止,但是我们的这里的集合根本没有指定的大小,那么我们应该怎么办呢?我们注意看可以发现,我们的返回的所有串,其必然都是由原来的串组成的,那么我们就可以设置我们的递归逻辑为我们的起始坐标到达我们的字符串的尾部时,我们就收集结果。接着是我们的递归逻辑,我们的递归逻辑是,每次递归我们寻找回文子串,若不是,则扩大搜索范围,若是,则以该回文子串的结尾在其后继续寻找回文子串。

class Solution {
    List<List<String>> list = new ArrayList<>();
    public List<List<String>> partition(String s) {
        List<String> list = new ArrayList<>();
        dfs(s,0,list);
        return this.list;
    }

    private void dfs(String s, int start, List<String> list) {
        if(start>=s.length()){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i < s.length(); i++) {
            String s1 = s.substring(start,i+1);
            if(judge(s1)){
                list.add(s1);
            }else {
                continue;
            }
            dfs(s,i+1,list);
            list.remove(list.size()-1);
        }
    }

    private boolean judge(String s1) {
        if(s1.length()==1){
            return true;
        }
        int len = 0,right = s1.length()-1;
        while (len<right){
            if(s1.charAt(len++)!=s1.charAt(right--)){
                return false;
            }
        }
        return true;
    }
}

这里我们需要记忆的就是我们这种for循环结合continue的递归模板,用于解决回文串或者是构造我们的一些目标串时很是受用

最后我们来看一下我们的标准答案的代码

class Solution {
    List<List<String>> lists = new ArrayList<>();
    Deque<String> deque = new LinkedList<>();

    public List<List<String>> partition(String s) {
        backTracking(s, 0);
        return lists;
    }

    private void backTracking(String s, int startIndex) {
        //如果起始位置大于s的大小,说明找到了一组分割方案
        if (startIndex >= s.length()) {
            lists.add(new ArrayList(deque));
            return;
        }
        for (int i = startIndex; i < s.length(); i++) {
            //如果是回文子串,则记录
            if (isPalindrome(s, startIndex, i)) {
                String str = s.substring(startIndex, i + 1);
                deque.addLast(str);
            } else {
                continue;
            }
            //起始位置后移,保证不重复
            backTracking(s, i + 1);
            deque.removeLast();
        }
    }
    //判断是否是回文串
    private boolean isPalindrome(String s, int startIndex, int end) {
        for (int i = startIndex, j = end; i < j; i++, j--) {
            if (s.charAt(i) != s.charAt(j)) {
                return false;
            }
        }
        return true;
    }
}

复原IP地址

然后我们来做复原IP地址的问题,这一题可以说是上一题的再练习,其模板是十分相似的,那么同样我们来做一下这一题,我们这题首先我们要明确的是,我们的字符串长度必须在4和12之间,否则就不可能有任何结果,所以我们首先进行一个正确性的校验。

然后我们的递归的结束条件时当我们的坐标到达我们的结尾时,但是我们就收集结果并结束递归,然后我们的for循环的条件也很简单,首先,如果我们的i和起始点位置相差大于4,那么我们就不用玩了,然后每次我们截取对应的结点作为我们的IP地址的子串,验证其正确性,若正确则将其加入到我们的可变长字符串中,否则就跳过,我们每次加入可变长字符串我们都往其中手动添加一个.,但是这里要注意的是,我们的.的数量不能超过四个,因此我们在开头设置了校验,一旦我们的.的数量超越了4,我们就结束这个方法,因为其必然不符合。最后我们还设置了一个回溯的方法,这里回溯使用的方法是可变长数组的删除方法

最后得到的结果总是会在结尾处带着.的,所以我们再加入我们的结果前还需要对我们的结果做相应的处理,首先我们要去除最后的.,然后我们的字符串是可能出现.有四个,但是内部包含的字符串只有三个的情况的,因此我们还要对字符串进行一个数量判断,只有字符串数量大于3的字符串,我们才会将其加入

class Solution {
    List<String> list = new ArrayList<>();
    public List<String> restoreIpAddresses(String s) {
        if(s.length()<4 || s.length()>12){
            return list;
        }
        dfs(s,0,new StringBuffer(),0);
        return list;
    }

    private void dfs(String s, int index, StringBuffer sb,int sum) {
        if(index>=s.length()){
            String s1 = sb.substring(0,sb.length()-1);
            String[] split = s1.split("\.");
            if(split.length<=3){
                return;
            }
            list.add(s1);
        }
        if(sum>3){
            return;
        }
        for (int i = index; i < s.length(); i++) {
            if(i-index>4){
                break;
            }
            String s1 = s.substring(index,i+1);
            int start = sb.length();
            if(judge(s1)){
                sb.append(s1);
                sb.append('.');
            }else {
                continue;
            }
            int right = sb.length();
            dfs(s,i+1,sb,sum+1);
            sb.delete(start,right);
        }
    }

    private boolean judge(String s) {
        if(s.length()==1){
            return true;
        }
        if(s.charAt(0)=='0'){
            return false;
        }
        int i = Integer.parseInt(s);
        return i >= 0 && i <= 255;
    }
}

接着我们来看看官方的回溯模板,其实差不多,不过我们的递归逻辑是加入四个点之后再进行对应的处理和判断,符合条件的我们就加入,而这里是加入三个点之后再对结果进行判断,如果结果符合,就将其加入到集合中。这里我们的递归条件是判断我们加入的逗号的数量,如果等于3就收集,否则就继续递归。我们的递归逻辑是每次判断我们的字符串是否是回文串,若是我们则往其中加入标点,并令我们的记录标点的数量+1然后进行递归,当然移除的时候要加入-1,这里增加和移除标点使用的方法都是字符串的截取函数,其中后者还使用了拼接,所以会导致效率不高。

最后我们可以看到一旦不符合就直接停止循环递归,这是因为一旦这个不符合,那么后续无论怎么增加都是不符合的,的确可以直接停止递归,我们前面用的是continue,其实是不够完美,也是对我们的题目理解不够深刻的体现。

最后我们也是可以确定我们什么时候用continue,什么时候用break,前者是当我们的结果不符合时继续搜寻可能符合的时候,而后者是无论怎么搜寻都不可能符合的时候

class Solution {
    List<String> result = new ArrayList<>();

    public List<String> restoreIpAddresses(String s) {
        if (s.length() > 12) return result; // 算是剪枝了
        backTrack(s, 0, 0);
        return result;
    }

    // startIndex: 搜索的起始位置, pointNum:添加逗点的数量
    private void backTrack(String s, int startIndex, int pointNum) {
        if (pointNum == 3) {// 逗点数量为3时,分隔结束
            // 判断第四段⼦字符串是否合法,如果合法就放进result中
            if (isValid(s,startIndex,s.length()-1)) {
                result.add(s);
            }
            return;
        }
        for (int i = startIndex; i < s.length(); i++) {
            if (isValid(s, startIndex, i)) {
                s = s.substring(0, i + 1) + "." + s.substring(i + 1);    //在str的后⾯插⼊⼀个逗点
                pointNum++;
                backTrack(s, i + 2, pointNum);// 插⼊逗点之后下⼀个⼦串的起始位置为i+2
                pointNum--;// 回溯
                s = s.substring(0, i + 1) + s.substring(i + 2);// 回溯删掉逗点
            } else {
                break;
            }
        }
    }

    // 判断字符串s在左闭⼜闭区间[start, end]所组成的数字是否合法
    private Boolean isValid(String s, int start, int end) {
        if (start > end) {
            return false;
        }
        if (s.charAt(start) == '0' && start != end) { // 0开头的数字不合法
            return false;
        }
        int num = 0;
        for (int i = start; i <= end; i++) {
            if (s.charAt(i) > '9' || s.charAt(i) < '0') { // 遇到⾮数字字符不合法
                return false;
            }
            num = num * 10 + (s.charAt(i) - '0');
            if (num > 255) { // 如果⼤于255了不合法
                return false;
            }
        }
        return true;
    }
}

子集

本题似乎和我们之前的画风不太一样,好像又变简单了些,其实不是的,这题我们是要提升我们的另外一个理解,到底是什么理解,我们接着看就知道了

首先这题怎么做呢?一个简单的想法就是可以递归嵌套for循环,让for循环的条件成为递归的结束条件来完成本体,请看代码

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> subsets(int[] nums) {
        for (int i = 0; i <= nums.length; i++) {
            List<Integer> list = new ArrayList<>();
            dfs(nums,0,i,list);
        }
        return this.list;
    }

    private void dfs(int[] nums, int start, int sum, List<Integer> list) {
        if(list.size()==sum){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i < nums.length; i++) {
            list.add(nums[i]);
            dfs(nums,i+1,sum,list);
            list.remove(list.size()-1);
        }
    }
}

上面就是我们的代码,我们利用for循环作为我们的终止条件,完美地实现了不重复的去重并收集结果。但是这个方法并不具有通用性,而且也不够符合我们的要求,因为一般来说我们是希望我们的方法是能够一次解决问题的,而不是通过一个for循环来实现解决问题。那我们应该要怎么做呢?其实奥秘就隐藏在我们的结束条件里

我们之前的结束条件总是我们的集合到达某一种情况就结束搜索并收集结果,而本题我们求所有的子集,其实在我们的循环递归过程中,就会自然地将所有的结果都遍历完,我们只需要将这些结果全部收集起来就能够得到答案了。

那么我们可以写入我们的代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> subsets(int[] nums) {
        List<Integer> list = new ArrayList<>();
        dfs(nums,0,list);
        return this.list;
    }

    private void dfs(int[] nums, int start, List<Integer> list) {
        this.list.add(new ArrayList<>(list));
        for (int i = start; i < nums.length; i++) {
            list.add(nums[i]);
            dfs(nums,i+1,list);
            list.remove(list.size()-1);
        }
    }
}

子集 II

接着我们这题就是对我们之前的题目的一个综合运用了,如果我们对之前的题目的知识点掌握地比较深的话,那么这题本质上是不难的。首先,我们看到本题要进行去重,那么我们就需要使用我们的之前的去重模板,我们的递归逻辑就设置成不能令我们的递归选择我们同层中的重复元素,同时别忘了要事先对我们的数组进行排序,不然我们根本就玩不转。然后我们这里要获得其所有的自己,那么我们对其做出的对应改动就是不设置任何的递归条件,每次递归都令其收集结果,这样其就能获得我们所需要的值了

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        List<Integer> list = new ArrayList<>();
        Arrays.sort(nums);
        dfs(nums,0,list);
        return this.list;
    }

    private void dfs(int[] nums, int start, List<Integer> list) {
        this.list.add(new ArrayList<>(list));
        for (int i = start; i < nums.length; i++) {
            if(i>start && nums[i]==nums[i-1]){
                continue;
            }else {
                list.add(nums[i]);
                dfs(nums,i+1,list);
                list.remove(list.size()-1);
            }
        }
    }
}

最后我们来看看我们的递归分析图

递增子序列

我们解开这题的经典想法会是利用树层去重但是,我们的树层去重有一个前提,那就是我们的数组一定要是排序过的,而我们这里是不允许对数组自身进行排序的,所以我们这里不可以使用树层的去重法,一用就错。这件事也提醒我们,不要套模板套爽了,就遇见什么类似的题目就二话不说直接套模板,一定要好好复习分析下题目本身是否可以套模板。

那么我们要如何解决这题呢?我们先用我们的递归做法来做,首先我们要确定我们的递归结束情况,我们希望当我们的集合里有两个及以上元素时,我们就收集结果。其次,我们希望我们的路径收集的集合总是比前面的大的,所以我们判断我们的路径中的最后一个元素是否有元素,如果没有则直接添加,如果有我们就取出其最后一个与当前的元素做比较,如果当前的比较小,那么就跳过当前的选取,反之则选取

那么我们可以构造出我们的没有进行去重的算法代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> findSubsequences(int[] nums) {
        List<Integer> list = new ArrayList<>();
        dfs(nums,0,list);
        return this.list;
    }

    private void dfs(int[] nums, int start, List<Integer> list) {
        if(list.size()>1){
            this.list.add(new ArrayList<>(list));
        }
        for (int i = start; i < nums.length; i++) {
            if(list.size()!=0 && nums[i]<list.get(list.size()-1)){
                continue;
            }
            list.add(nums[i]);
            dfs(nums,i+1,list);
            list.remove(list.size()-1);
        }
    }
}

最后我们要解决的问题是,我们要怎么去重才能保证我们不会选取重复的元素?这里就要使用到我们的哈希来帮助我们去重了,首先我们要确定我们要去除的是什么情况下的元素,我们可以通过一个样例来加深理解

可以看到,我们的实际结果比预期的结果多了两个1、1的集合,这两个集合产生的原因是一位我们的1在第一次循环时进行了选取,而后面我们的1又和最后一个1进行了选取,最终导致我们的答案里出现了三个1。那么我们去重时,我们的一个简单想法就是,我们希望我们一旦选择过一次1,我们后续的选择中就不要再次选择同样的元素了,注意,这里是同一层的逻辑,也就是说,只有在同一层中我们要遵从这样的逻辑,不同层的结果不会互相影响。因为实际上我们的集合中还有1、1、1这样的组合,因此我们不可以令其变成一旦选取了1,那么后续就不会再选取1的情况

要实现这种想法,我们可以利用我们的哈希表进行去重,每次选取元素时,我们就判断该元素是否已经被选取,若已经被选取,那么我们就跳过该元素的选取,反之则选取。同时,由于我们的逻辑只用于同一层中,因此我们在每层中都创建一个哈希表并进行去重,这样就能达到我们想要的每一层进行独立的逻辑去重的效果

最后我们可以构造我们的代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> findSubsequences(int[] nums) {
        List<Integer> list = new ArrayList<>();
        dfs(nums,0,list);
        return this.list;
    }

    private void dfs(int[] nums, int start, List<Integer> list) {
        if(list.size()>1){
            this.list.add(new ArrayList<>(list));
        }
        Set<Integer> set = new HashSet<>();
        for (int i = start; i < nums.length; i++) {
            if(!list.isEmpty() && nums[i]<list.get(list.size()-1) || set.contains(nums[i])){
                continue;
            }
            set.add(nums[i]);
            list.add(nums[i]);
            dfs(nums,i+1,list);
            list.remove(list.size()-1);
        }
    }
}

最后我们值得一提的是,本题中限定了数组的范围是从-100到100,因此我们可以手动构建一个数组作为哈希表进行去重,这样还能提高一层效率,因为java中提供的哈希表比较慢,不太好使。那么最后我们可以写入我们的代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> findSubsequences(int[] nums) {
        List<Integer> list = new ArrayList<>();
        dfs(nums,0,list);
        return this.list;
    }

    private void dfs(int[] nums, int start, List<Integer> list) {
        if(list.size()>1){
            this.list.add(new ArrayList<>(list));
        }
        int[] arr = new int[201];
        for (int i = start; i < nums.length; i++) { 
            if(list.size()!=0 && nums[i]<list.get(list.size()-1)){
                continue;
            }else if(arr[nums[i]+100]==1){
                continue;
            }
            arr[nums[i]+100]=1;
            list.add(nums[i]);
            dfs(nums,i+1,list);
            list.remove(list.size()-1);
        }
    }
}

最后我们值得一提的是,我们这里的每层使用哈希表去重的逻辑所完成的功用是可以防止在同一层中我们再次选取相同的元素,其具体的作用过程可以看图

最后,哈希表去重法我们只用过这么一回,而且我们也只用过哈希表,一般是哈希表组合每层递归达到固定层去重的目的,因此如果我们要使用这种逻辑,我们要注意的是,要用就只用哈希表来完成,别自己耍什么大聪明拿其他的来用,记住我们这套哈希去重的模板,每层创建哈希表,且不会刻意移除哈希表内的元素,其他情况下我们该用之前的递归模板还是用之前的递归模板就行了。

回溯算法的深入学习

最后一章就是深入环节了,这一章学完,起码我们的回溯入门是搞定了,以后就不用担心回溯类题目了,起码基本的回溯类题目到时候都会有个思路是吧

全排列

对于这一题,我们可以利用一个布尔类型的数组来辅助解题,同样是利用回溯,我们这里是要求求出其全排列,因此我们需要利用数组来帮助判断元素是否已经选取,若已经选取到我们的集合中,那么我们就跳过,若没有则选取,集合到了指定大小就收集并结束递归,这样就可以获得我们所需要的全排列了

注意这里由于我们需要的是全排列,所以我们的for循环总是从0开始的,而不是跟之前一样是传入一个参数并从那个参数为起点开始的,而我们的布尔类型数组就是为了防止重复选取相同元素而整出来的东西

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    public List<List<Integer>> permute(int[] nums) {
        List<Integer> list = new ArrayList<>();
        dfs(nums, list,new boolean[nums.length]);
        return this.list;
    }

    private void dfs(int[] nums, List<Integer> list,boolean[] booleans) {
        if(list.size()== nums.length){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            if(booleans[i]){
                continue;
            }
            list.add(nums[i]);
            booleans[i]=true;
            dfs(nums, list,booleans);
            booleans[i]=false;
            list.remove(list.size()-1);
        }
    }
}

该代码执行的过程如下图所示

全排列II

然后我们来学习进阶的题目,本题的数字是可能重复的,然而我们要收集所有不重复的全排列,那么我们要如何进行去重呢?无脑的方法当然是使用Set集合,但这样的话就直接进入人才库回家玩去了,所以我们断然不能使用这种投机取巧的方法。

其实我们这里的去重逻辑就跟之前的一样简单,我们只要避免我们递归例程选取我们已经选取过的值相同的元素就可以了,同时由于我们这里是求其全排列,因此我们还需要布尔类型的数组来帮助我们进行去重,防止我们选择我们之前的路径中已经选择过的元素

其执行过程如下

最终我们可以构造代码如下

class Solution {
    List<List<Integer>> list = new ArrayList<>();
    boolean[] booleans;
    public List<List<Integer>> permuteUnique(int[] nums) {
        booleans = new boolean[nums.length];
        Arrays.sort(nums);
        dfs(nums,new ArrayList<>());
        return list;
    }

    private void dfs(int[] nums,List<Integer> list) {
        if(list.size()== nums.length){
            this.list.add(new ArrayList<>(list));
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            if(i>0 && nums[i]==nums[i-1] && !booleans[i-1]){
                continue;
            }
            if(!booleans[i]){
                booleans[i]=true;
                list.add(nums[i]);
                dfs(nums,list);
                booleans[i]=false;
                list.remove(list.size()-1);
            }
        }
    }
}

这里我们有几点要记住,第一点,我们的同层去重法必须要排序之后才能够正确使用。第二点,使用布尔类型数组避免重复选择相同元素必须是一个回溯的过程。第三点,在求全排列的情况下,无法用之前的坐标法来代替布尔类型数组的作用达到去重作用,因为我们这里的坐标总是从0开始的

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

接着我们就必须来理一下我们所学习的两个在同一层上进行去重的方法了,我们目前一共学习了两种方法能够帮助我们进行树层上的去重,第一种是通过布尔类型数组,其有第二种局限性的形式,就是利用坐标进行去重。这种形式好用,但是其特点是一定要进行对原数组进行排序,如果原数组是不可以排序的,那么该方法就会直接失效。第二种去重的方法是使用哈希表来帮助我们去重,使用哈希表进行去重,我们需要在每一层上都创建一个哈希表并用于该层的判断,比较浪费内存,效率也不是很高说实话,所以一般来说,我们都是推荐使用前一种方式来进行树层上的去重

一般来说,我们能用第一种方式进行树层去重的题目,我们也可以用第二种方式来达成,但是在不可排序的情况下,是第一种情况所无法复现的,目前后一种去重法的含金量只体现在递增子序列这一题中,其他题目上完全都可以不用。另外如果我们在递归例程中使用了布尔数组,那么一般我们也是偏向使用第一种方式的,因为正好能一起用了是吧

简单来说,能用布尔类型进行树层去重,就不要用哈希表

重新安排行程

接着我们来看一道重量级的题目

这位更是重量级,要做这种重量级的题目,我们一定要深刻记住我们之前学习的知识,综合我们的知识来解题,

class Solution {
    List<List<String>> list = new ArrayList<>();
    boolean[] booleans;
    public List<String> findItinerary(List<List<String>> tickets) {
        booleans = new boolean[tickets.size()];
        dfs(tickets,"JFK",0,0,new ArrayList<>());
        return findMin(list);
    }

    private void dfs(List<List<String>> tickets,String s,int index,int deep,List<String> list) {
        if(deep==tickets.size()){
            list.add(tickets.get(index).get(1));
            this.list.add(new ArrayList<>(list));
            list.remove(list.size()-1);
            return;
        }
        for (int i = 0; i < tickets.size(); i++) {
            if(booleans[i]){
                continue;
            }
            List<String> stringList = tickets.get(i);
            if(stringList.get(0).equals(s)){
                booleans[i]=true;
                list.add(stringList.get(0));
                dfs(tickets,stringList.get(1),i,deep+1,list);
                list.remove(list.size()-1);
                booleans[i]=false;
            }
        }
    }

    public List<String> findMin(List<List<String>> list) {
        int min = 0;
        for (int i = 1; i < list.size(); i++) {
            List<String> stringList1 = list.get(min);
            List<String> stringList2 = list.get(i);

            boolean judge = false;
            for (int j = 0; j < stringList1.size(); j++) {
                String s1 = stringList1.get(j);
                String s2 = stringList2.get(j);
                if(s1.equals(s2)){
                    continue;
                }
                if(s1.compareTo(s2)>0){
                    judge=true;
                }
                break;
            }
            if(judge){
                min=i;
            }
        }
        return list.get(min);
    }
}

我们这个逻辑也是很简单,就是直接递归暴力搜索所有可能的结果,然后返回的结果里再进行排序,最后得到的结果就是我们所需要的结果,中间利用布尔类型的数组防止重复搜索,这个想法确实很不错,实际上也的确有用,但是,这题毕竟是个重量级题目,肯定是不可能这么简单的,由于我们的数组的长度能达到惊人的300,因此这题其实用这么低效的搜索方式根本搜索不到,最终会超时,所以这份算法是行不通的,我们必须要更换我们的方法,我们必须要使用效率更高的回溯法,否则这题就无解

我们来逐步解决下这一题的难点,先来看看本题一共有几个难点

首先我们第一个要避免的问题就是死循环的问题,这个很容易理解,我们之前的代码是使用布尔类型的数组来避免我们进入死循环,但是说实话这种方式的效率有点低,实际上,我们大可以使用次数这种特殊的值来防止其继续递归,我们构造一个从某个机场到另一个机场的情况,然后每种情况下我们都构造一个数量值,相同则加,不同则新增可到达的路径,最后只要我们判断该路径的到达次数是否还大于0即可

接着我们来解决第二个问题,那就是我们要如何记录我们的映射关系,同时我们能准确返回字母序排在前面的路径,我们之前采用的方式是记录所有的可能路径,然后找到字典序最小的路径并返回,这个操作由于还多了一个排除的过程,因此效率上属实是不尽人意。那么我们要怎么解决这个问题呢?我们可以先将我们的所有情况存在一个集合中,这个集合的路径本身就是有序的,这样我们首先获取到的第一个有效的路径一定是我们题目中所需要的字典序最小的路径。问题在于,我们要选取什么集合好?其实,我们这里可以选取两种集合,第一个中是Map<Stirng,List>和Map<String,Map<String,Integer>>这两种集合,前者的存储格式是Map<出发机场, 到达机场的集合>,后者的存储格式是Map<出发机场, Map<到达机场, 航班次数>>,我们选择后者,因为后者才能存放数字,才有我们的前面说的防止重复选择的应用。也就是说,我们每次进行递归前,都应该先将我们的集合中的内容存放到我们的所构造的新的集合中

那么我们要如何插入我们的集合呢?我们可以对我们的地址集合进行迭代,每次我们判断我们当前的Map结合是否存在这个起始地址,若不存在,我们就创建一个TreeMap的集合,然后往其中存入到达路径和1的次数,反之则取出该集合,然后存入该Map集合的到达路径并用getOrDeafult方法来进行可到达区域的累加,最后我们每次迭代都将Map集合和起始地点存储到我们的总的大的Map集合中,最后我们就可以得到我们所需要的集合对象了

然后是第三个问题,我们的终止条件是什么?这个其实很简单,因为我们已经假定我们的能够搜集到的第一个完整路径就是目标路径了,所以我们的递归结束条件就是我们的路径中的值与我们的最开始传入的数组大小还大于1的时候,其实分析题目也容易知道我们的结果路径中总是有最后一个路径的,这就导致我们的路径大小总是稳定大于我们的数组长度的一位

最后一个问题,我们要如何遍历一个机场对应的所有机场?我们当然是使用for循环了,由于路径中的第一位总是"JFK",且本题保证有答案,所以我们每次递归的时候可以先取出路径中的最后一位,然后判断Map集合中是否存在该路径,若存在,我们就取出其Map对应的Value值,对其进行递归查找,当然,每次递归时我们都要检查其次数是否大于0,大于0我们才进行递归,否则我们不进行递归,同时递归完毕之后我们需要进行回溯。这里有一点不同的是,由于我们的集合总是拿到了适合路径之后就可以停止了,所以我们每次递归都判断拿到的路径是否达到了长度,若达到则直接返回即可

我们可以来看看其递归的搜索过程

最后我们容易构造其代码如下

class Solution {
    //存储路径的集合,其存储格式为Map<出发机场, Map<到达机场, 航班次数>>
    Map<String,Map<String,Integer>> map = new HashMap<>();
    //存储路径的集合
    List<String> list = new ArrayList<>();
    public List<String> findItinerary(List<List<String>> tickets) {
        add(tickets);
        //先添加第一位起始路径
        list.add("JFK");
        dfs(tickets);
        return list;
    }

    //递归搜索路径的方法
    private void dfs(List<List<String>> tickets) {
        //如果路径集合的大于数组一位,说明已经收集到合适集合
        if(list.size()==tickets.size()+1){
            return;
        }
        //取出路径中的最后一位的路径
        String s = list.get(list.size()-1);
        //如果大集合中存在该起始路径,我们再进行对应的迭代搜索
        if(map.containsKey(s)){
            //取出大集合中对应的小集合,并进行迭代,entrySet可以理解为每一个小的map组件
            for (Map.Entry<String,Integer> entry:map.get(s).entrySet()) {
                //取出其对应的次数
                int val = entry.getValue();
                //若次数大于0则进行递归
                if(val > 0){
                    //添加路径
                    list.add(entry.getKey());
                    //指定次数-1
                    entry.setValue(val-1);
                    dfs(tickets);
                    //判断是否符合条件,若符合则直接退出递归
                    if(list.size()==tickets.size()+1){
                        return;
                    }
                    //回溯移除路径
                    list.remove(list.size()-1);
                    //回溯重置次数
                    entry.setValue(val);
                }
            }
        }
    }

    //将地址存储到集合中
    private void add(List<List<String>> tickets) {
        //对集合进行遍历
        for (List<String> list:tickets) {
            //先定义小Map集合
            Map<String,Integer> map;
            //判断起始地址是否存在于大Map集合中
            if(this.map.containsKey(list.get(0))){
                //取出大Map集合中的小Map集合
                map = this.map.get(list.get(0));
                //利用getOrDefault方法往小Map集合中实现数字的累加
                map.put(list.get(1), map.getOrDefault(list.get(1),0)+1);
            }else {
                //创建新的Map集合
                map = new TreeMap<>();
                //往Map集合中存入到达位置及能到达的次数,默认为1
                map.put(list.get(1),1);
            }
            //将小集合的情况存储到大集合中
            this.map.put(list.get(0),map);
        }
    }
}

但是我们上面的代码我们可以看到,我们似乎是重复进行了两次的判断,这样似乎不太美观,而且显得有些臃肿,我们有办法对我们的代码进行进一步的优化吗?当然可以。我们只要对我们的将我们的返回值改为布尔类型即可,终止条件改为如果路径长度大于集合的一位,那么就返回true,每次递归时判断返回的是否为真,若为真则再次返回真来结束递归,其他情况返回false,通过布尔类型我们可以达到我们所需要的搜索到第一个适合长度路径就立刻返回的效果,我们可以记住这种递归方式,这种递归方式可以让我们总是在收集到第一个适合条件的路径之后就立刻返回,而且代码上也更加美观

class Solution {
    //存储路径的集合,其存储格式为Map<出发机场, Map<到达机场, 航班次数>>
    Map<String,Map<String,Integer>> map = new HashMap<>();
    //存储路径的集合
    List<String> list = new ArrayList<>();
    public List<String> findItinerary(List<List<String>> tickets) {
        add(tickets);
        //先添加第一位起始路径
        list.add("JFK");
        dfs(tickets);
        return list;
    }

    //递归搜索路径的方法
    private boolean dfs(List<List<String>> tickets) {
        //如果路径集合的大于数组一位,说明已经收集到合适集合
        if(list.size()==tickets.size()+1){
            return true;
        }
        //取出路径中的最后一位的路径
        String s = list.get(list.size()-1);
        //如果大集合中存在该起始路径,我们再进行对应的迭代搜索
        if(map.containsKey(s)){
            //取出大集合中对应的小集合,并进行迭代,entrySet可以理解为每一个小的map组件
            for (Map.Entry<String,Integer> entry:map.get(s).entrySet()) {
                //取出其对应的次数
                int val = entry.getValue();
                //若次数大于0则进行递归
                if(val > 0){
                    //添加路径
                    list.add(entry.getKey());
                    //指定次数-1
                    entry.setValue(val-1);
                    if(dfs(tickets)){
                        return true;
                    }
                    //回溯移除路径
                    list.remove(list.size()-1);
                    //回溯重置次数
                    entry.setValue(val);
                }
            }
        }
        return false;
    }

    //将地址存储到集合中
    private void add(List<List<String>> tickets) {
        //对集合进行遍历
        for (List<String> list:tickets) {
            //先定义小Map集合
            Map<String,Integer> map;
            //判断起始地址是否存在于大Map集合中
            if(this.map.containsKey(list.get(0))){
                //取出大Map集合中的小Map集合
                map = this.map.get(list.get(0));
                //利用getOrDefault方法往小Map集合中实现数字的累加
                map.put(list.get(1), map.getOrDefault(list.get(1),0)+1);
            }else {
                //创建新的Map集合
                map = new TreeMap<>();
                //往Map集合中存入到达位置及能到达的次数,默认为1
                map.put(list.get(1),1);
            }
            //将小集合的情况存储到大集合中
            this.map.put(list.get(0),map);
        }
    }
}

N皇后

接着我们来学习一些经典的重量级题目,首先是我们早有耳闻的N皇后,这个题目也是老经典了啊

这一次我们很厉害的,把N皇后的题目一次就给做出来了,没有一次WA,这我是那个自豪啊,这么久的努力总算是没有白费,我们先来看看我们的代码

class Solution {
    boolean[] booleans;
    List<List<String>> list = new ArrayList<>();
    List<int[][]> ansList = new ArrayList<>();
    public List<List<String>> solveNQueens(int n) {
        int[][] ans = new int[n][n];
        booleans = new boolean[n];
        dfs(ans,0);
        for (int[][] arr:ansList) {
            List<String> list = new ArrayList<>();
            for (int i = 0; i < arr.length; i++) {
                StringBuffer sb = new StringBuffer();
                for (int j = 0; j < arr[i].length; j++) {
                    if(arr[i][j]==0){
                        sb.append('.');
                    }else {
                        sb.append('Q');
                    }
                }
                list.add(sb.toString());
            }
            this.list.add(list);
        }

        return list;
    }

    private void dfs(int[][] arr,int deep) {
        if(deep==arr.length){
            int[][] ans = new int[arr.length][arr.length];
            for (int i = 0; i < arr.length; i++) {
                System.arraycopy(arr[i], 0, ans[i], 0, arr.length);
            }
            ansList.add(ans);
        }
        for (int i = 0; i < arr.length; i++) {
            if(booleans[i]){
                continue;
            }
            if(judge(arr,deep,i)){
                arr[deep][i]=1;
                booleans[i]=true;
                dfs(arr,deep+1);
                booleans[i]=false;
                arr[deep][i]=0;
            }
        }
    }

    private boolean judge(int[][] arr, int deep, int j) {
        //左上角
        int x = deep,y = j;
        while (x>0 && y>0){
            x--;
            y--;
            if(arr[x][y]==1){
                return false;
            }
        }

        //左下角
        x = deep;y = j;
        while (x<arr.length-1 && y>0){
            x++;
            y--;
            if(arr[x][y]==1){
                return false;
            }
        }

        //右上角
        x = deep;y = j;
        while (x>0 && y<arr.length-1){
            x--;
            y++;
            if(arr[x][y]==1){
                return false;
            }
        }

        //右下角
        x = deep;y = j;
        while (x<arr.length-1 && y<arr.length-1){
            x++;
            y++;
            if(arr[x][y]==1){
                return false;
            }
        }

        return true;
    }
}

然后我们来讲解下我们的代码逻辑,首先我们的基本想法是,每次递归我们就确定一个位置放皇后,每次放置时我们必须进行皇后是否可以放置于该位置的正确性判断,若可以则放然后进入下一层的递归,若不行我们就跳出该选择下一个位置来放置皇后。

我们递归的结束条件是当我们的深度到达我们的矩阵底部的时候,到达则说明我们已经成功选取到了符合条件的皇后组合,此时我们就将符合条件的组合收集起来,递归结束之后我们将对应的集合一个个取出改造成题目要求的形式并返回即可

同时我们这里使用布尔类型的数组来辅助我们递归,放置我们选择同一列的位置放置皇后

然后来具体看一下我们的递归的执行过程

官方的解题思路和我们几乎是如出一辙,不同的是官方的解题将判断对角线的情况仅分为两种情况进行判断,而且其是每次到达符合条件的结束位置就收集该路径到目标集合中,这个其实大差不差,不是很有所谓说实话

class Solution {
    List<List<String>> res = new ArrayList<>();

    public List<List<String>> solveNQueens(int n) {
        char[][] chessboard = new char[n][n];
        for (char[] c : chessboard) {
            Arrays.fill(c, '.');
        }
        backTrack(n, 0, chessboard);
        return res;
    }


    public void backTrack(int n, int row, char[][] chessboard) {
        if (row == n) {
            res.add(Array2List(chessboard));
            return;
        }

        for (int col = 0;col < n; ++col) {
            if (isValid (row, col, n, chessboard)) {
                chessboard[row][col] = 'Q';
                backTrack(n, row+1, chessboard);
                chessboard[row][col] = '.';
            }
        }

    }


    public List Array2List(char[][] chessboard) {
        List<String> list = new ArrayList<>();

        for (char[] c : chessboard) {
            list.add(String.copyValueOf(c));
        }
        return list;
    }


    public boolean isValid(int row, int col, int n, char[][] chessboard) {
        // 检查列
        for (int i=0; i<row; ++i) { // 相当于剪枝
            if (chessboard[i][col] == 'Q') {
                return false;
            }
        }

        // 检查45度对角线
        for (int i=row-1, j=col-1; i>=0 && j>=0; i--, j--) {
            if (chessboard[i][j] == 'Q') {
                return false;
            }
        }

        // 检查135度对角线
        for (int i=row-1, j=col+1; i>=0 && j<=n-1; i--, j++) {
            if (chessboard[i][j] == 'Q') {
                return false;
            }
        }
        return true;
    }
}

解数独

最后我们再来讲一道经典的重量级题目,解数独,这题还真的就是重量级了,还贴近生活,先来看看题目

解开这题的思路其实非常简单粗暴,就是进行二维上的迭代递归,利用两个for循环递归判断数独的各种情况,若如何则继续,不符合就排除。与前面的题目不同的是,我们之前判断的时候,是用deep这样的参数来作为纵向的坐标来帮助我们完成递归,当时我们使用deep来协助完成递归的原因在于我们需要这个参数来进行停止递归的判断

而我们这里,我们要做的事情是,我们要递归判断所有的不含有数字的位置,并且遍历其所有的可能情况,每次递归前我们都要判断我们的当前位置的放置数字是否适合,若适合则递归,反之则跳过,这个和我们的N皇后的思路其实很像

接着我们要解决的问题是,我们的递归结束的条件是什么?其实,我们这里并不需要对我们的递归构造任何的结束条件,我们这里递归例程做的事情就是将我们对应位置的数值改为适合的数值,改完之后自动结束即可,由于是原地修改,我们等待其自动结束即可,不需要构造递归结束条件并收集

最后我们来简单看看其递归的过程

然后我们可以构造代码如下

class Solution {
    char[] chars = new char[]{'1','2','3','4','5','6','7','8','9'};
    public void solveSudoku(char[][] board) {
        dfs(board);
    }

    private boolean dfs(char[][] board) {
        for (int i = 0; i < chars.length; i++) {
            for (int j = 0; j < chars.length; j++) {
                if(board[i][j]=='.'){
                    for (int k = 0; k < chars.length; k++) {
                        if(judge(board,chars[k],i,j)){
                            board[i][j]=chars[k];
                            if(dfs(board)){
                                return true;
                            }
                            board[i][j]='.';
                        }
                    }
                    return false;
                }
            }
        }
        return true;
    }

    private boolean judge(char[][] board, char c, int x, int y) {
        //查找横
        for (int i = 0; i < chars.length; i++) {
            if(board[x][i]==c){
                return false;
            }
        }

        //查找纵
        for (int i = 0; i < chars.length; i++) {
            if(board[i][y]==c){
                return false;
            }
        }

        //查找九宫格
        int hengs,henge,zongs,zonge;
        int m = x / 3, n = y/3;
        hengs = m*3;zongs = n*3;
        henge = hengs+3;zonge = zongs+3;
        for (int i = hengs; i < henge; i++) {
            for (int j = zongs; j < zonge; j++) {
                if(board[i][j]==c){
                    return false;
                }
            }
        }

        return true;
    }
}

这里的这份代码最值得讲是我们的递归例程,首先我们的递归例程都是从i=0和j=0中开始的,我们每次递归都要从第一个起点开始来时进行暴力搜索,当然,由于我们的搜索并修改的特点,我们之前修改过的位置的值会被跳过

如果我们这里使用x和y的两个参数来执行我们的递归的话,最终我们会只能修改一部分的数独的值,后续的值会由于我们的边界问题导致无法被修改,因此我们这里每次递归我们都需要让我们的递归例程从0开始

最后是我们这里令其返回布尔类型的值,这招可以让我们收集到第一个符合条件的值就立刻停止递归,但是要注意的是,我们之前确实也用过这招,虽然说套路是这个套路,但模板并不是死的,我们这里不用简单的依葫芦画瓢单纯把前面的代码构造样式给拷贝过来,我们需要进行分析并构造一个适合的例程才能让我们的布尔返回值发挥作用,达到收集到第一个合适例程就结束递归的效果

我们这里分析我们的递归例程,我们每次递归都要判断该递归是否成功,若成功我们则返回真,每次我们递归时会执行一个for循环,如果我们的例程成功了,那么我们就找到了合适的值,此时后续的代码就不会执行,但是如果我们的代码最终执行到了选择数字1-9的后面,说明1-9都没有一个合适的值给他用,此时我们返回false,说明该例程不适合,最后我们的代码最终执行完,此时说明我们的递归例程已经完成,且没有出错,那么此时我们就要返回true。注意,我们这里一定要返回true,因为我们的递归例程中的结果同时也是我们的例程执行的条件,如果我们返回的是false,那么我们的代码就永远不会进入到最终的结束代码中,那就寄了,所以我们这里一定要返回true