算法学习随笔 7_回溯算法整理总结

本章记录一些有关回溯算法的一些较为经典或者自己第一次做印象比较深刻的算法以及题型,包含自己作为初学者第一次碰到题目时想到的思路以及网上其他更优秀的思路,本章持续更新中......

回溯算法:回溯算法其实本质上是一种暴力穷举的算法,一听到暴力穷举,第一感觉就是效率不高,那为什么还是要使用回溯算法呢?因为有一些问题的规模是非常大的,一个 for 循环可以写,2个 for循环嵌套也还行,3个 for循环嵌套也忍了,那有的问题需要10个、100个 for循环嵌套呢,总不能手撸 N 个 for 循环嵌套吧,这时候回溯算法就排上了用场。回溯就是一个递归函数,也就是自己调用自己,我们设置终止条件,当满足终止条件的时候就结束。回溯和递归总是一起出现的,要理解回溯和递归,其实光靠想象有点难度,最好可以画一个图来把递归回溯的过程形式化展现出来,其实可以用树状图来表示一个递归回溯的过程。

递归过程可以用N叉树表示,每一层表示当前可以做的选择,比如对一个数组 [1,2,3,4] 进行递归,那第一层就是 1,2,3,4;然后在分别在排除这些元素的剩余集合中继续递归,比如,此时节点 1 下面的一层可能是2 3 4,节点 2 下面的一层是 1 3 4。

递归的要素:1、终止条件:递归必须要有终止条件或者能够自动返回,否则很容易导致无限递归从而导致栈溢出。2、for循环:利用for循环是来横向遍历的,而递归是纵向遍历,可以理解为利用for循环实现广度优先遍历,利用递归实现深度优先遍历,同时进行。3、递归参数:递归的参数在递归的时候需要用什么就写什么就好。

目录

No 77. 组合(中等)

No ​​​​​40. 组合总和 II(中等)

No 47. 全排列 II(中等)

No 17. 电话号码的字母组合(中等)

No 131. 分割回文串(中等)

No 93. 复原 IP 地址(中等)

No 491. 递增子序列(中等)

No 332.重新安排行程(困难)

No 51.N皇后(困难)

No 37. 解数独(困难)


No 77. 组合(中等)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/combinations/

题目描述:

给定两个整数 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]]

思路:用这个题来整理一下回溯问题的模板。题目的意思其实就是找组合,组合的元素个数是 K。组合无序,所以使用过的数字不能再使用,否则就重复了,所以越往后找其实需要遍历的越少。

递归函数参数:需要三个,分别是起始位置、要求的区间末尾数字、组合的大小。凡是组合类的问题,都需要一个起始位置作为参数,因为下一个递归需要从下一个位置开始。递归函数一般不需要返回值,但也有例外情况,有的情况加上一个返回值会提高搜索效率。

递归的逻辑:把当前的数字添加到结果中,然后在递归下一个数字。当递归返回时再弹出。

递归终止条件:只要当前的结果大小等于 K ,那这就是一个符合条件的结果,添加到结果集中。

class Solution {
public:  
    vector> res;
    vector temp;
    void backTracking(int sign, int n, int k) {
        //终止条件
        if( temp.size() == k){
            res.push_back(temp);
            return;
        }
        //递归
        for(int i = sign; i <= n; i++) {
            temp.push_back(i);
            //因为不重复,所以下次递归就是从下一个位置开始的
            backTracking(i + 1, n, k);
            temp.pop_back();
        } 
    }
    vector> combine(int n, int k) {
        backTracking(1, n, k);
        return res;
    }
};

No ​​​​​40. 组合总和 II(中等)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/combination-sum-ii/

题目描述:

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用 一次 。注意:解集不能包含重复的组合。

示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

示例 2:
输入: candidates = [2,5,2,1,2], target = 5,
输出:
[
[1,2,2],
[5]
]

思路:这个题有一个特殊情况,那就是给定的数组中可能会有重复的元素,那么如果这个元素使用过了,后面再使用,那就会导致重复,但是我们又不能在一开始就删除掉重复的元素,否则就和给定的数组不一样了,结果肯定会不正确。所以我们只能在遍历的时候,一边遍历,一边进行去重的操作。

我的去重思路是这样:我们按照正常的递归回溯先操作,当递归返回的时候,将要进行下一次 FOR 循环 的时候,进行去重,如果下一个数字和这个使用过的数字是一样的,那就跳过,注意要是用while循环跳过,因为可能不止一个重复的。要想进行这样的操作必须首先对原数组进行排序,让相同的元素挨在一起。可是排序对原数组进行了修改,不会导致其它问题吗?这里我们找的是组合,和顺序没有关系,只要是这些数字,找到的就是这些组合,不会因为数组的顺序变化而导致组合的变化。

class Solution {
public:
    //标准递归回溯
    vector> res;
    vector res_temp;
    void backTracking(vector& nums, int target, int sum, int startIndex) {
        //符合条件
        if(sum == target) {
            res.push_back(res_temp);
            return;
        }
        //不符合条件
        if(sum > target) {
            return;
        }
        //递归
        for(int i = startIndex; i < nums.size(); i++) {
            sum += nums[i];
            res_temp.push_back(nums[i]);
            backTracking(nums, target, sum, i + 1);
            res_temp.pop_back();
            sum -= nums[i];
            //关键步骤:当操作结束递归返回,准备开始下一轮时,如果发现当前数字和后面数字相同,那就跳过,否则会重复
            //要用while,因为可能不止一个相同,但是这个要求nums有序; 
            while(i < nums.size() - 1 && nums[i] == nums[i + 1]) {
                i++; 
            }
        }
    }
    vector> combinationSum2(vector& candidates, int target) {
        //这里排序是为了后面排除重复集合,让相同的元素放在一起
        sort(candidates.begin(), candidates.end());
        backTracking(candidates, target, 0, 0);
        return res;
    }
};

No 47. 全排列 II(中等)

来源:力扣(LeetCode)
链接:​https://leetcode-cn.com/problems/permutations-ii/

题目描述:

给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。

示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
 [1,2,1],
 [2,1,1]]

示例 2:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

思路:这个题目求的是全排列,所以这时候就不在需要传递起始位置了,因为求全排列,数字是可以重复使用的,不同的顺序是不同的结果。但是题目给定的数组有重复的元素,应该如何去重?其实这里的去重思路和上面的题目一样,也是在使用过这个数字,将要进行下一次 for 循环的时候,看一下下一个数字是不是一样,一样的话就跳过,同样也需要对原数组进行排序。这里对原数组进行排序也没有影响,因为求的是全排列,只要是这些数字,那么最后得到的全排列一定是一样的。好了,原数组中重复的数字问题解决了,也就是同层有重复数字的问题解决了。(树层去重)

但是还有一个问题需要考虑。我们在递归的时候,先把当前拿到的数字添加到结果中,然后进行下一次递归,但是因为求全排列,下一次递归的起始位置也是 0 。那么可以想一下在第一次递归的时候,拿到的是下标为 0  的数字,下一次递归又是这个数字,而我们希望不要在使用现在使用过的这个数字了,这时候怎么办?这一点其实是在 纵深 方向的去重,解决方法是定义一个数组用来标定已经使用过的数字,如果这个数字使用过了,那就进行标记,下次递归的时候只有没有标记的数字才可以使用,当递归返回的时候,再取消标记。其实就是回溯。这样就可以避免下次递归的时候又用到了之前的递归使用过的数字。对树层去重还有一个方法就是在每次递归的 for 循环之前定义一个数组,用于标定同层使用过的数字。注意是要每次递归的 for 循环之前都要重新定义,这样才能保证和每一层对应。(树枝去重)

所以总结一下,在求排列的时候,不仅要对树层去重,还要对同一树枝去重

class Solution {
public:
    //优化:也可以对nums先排序,因为是求全排列,所以顺序无所谓,之后利用while循环来代替记录同层重复的容器即可
    vector> res;
    vector res_temp;
    void backTracking(vector& nums, vector& usedNum) {
        if(res_temp.size() == nums.size()) {
            res.push_back(res_temp);
            return;
        }
        //usedNum用来控之前用过的数字不能重复使用,递归中用,深度
        for(int i = 0; i < nums.size(); i++) {
            if(usedNum[i] == 0){
                //用过的数字置1
                usedNum[i] = 1;
                res_temp.push_back(nums[i]);
                backTracking(nums, usedNum);
                //递归返回时弹出并置0
                res_temp.pop_back();
                usedNum[i] = 0;
            }
            // 当前数字使用过且下一个数字和当前数字相同
            while(i < nums.size() - 1 && nums[i] == nums[i + 1] && usedNum[i] == 1){
                i++;
            }
        }
    }

    //本题维护了两个容器,来记录同层和递归的时候使用过的数字
    // vector> res;
    // vector res_temp;
    // void backTracking(vector& nums, vector& usedNum) {
    //     if(res_temp.size() == nums.size()) {
    //         res.push_back(res_temp);
    //         return;
    //     }
    //     //numSet用来控制同层不能重复使用,for循环中的,层间
    //     //usedNum用来控之前用过的数字不能重复使用,递归中用,深度
    //     unordered_set numSet;
    //     for(int i = 0; i < nums.size(); i++) {
    //         if(numSet.find(nums[i]) == numSet.end() && usedNum[i] == 0){
    //             //同层使用过的数字
    //             numSet.insert(nums[i]);
    //             //用过的数字置1
    //             usedNum[i] = 1;
    //             res_temp.push_back(nums[i]);
    //             backTracking(nums, usedNum);
    //             //递归返回时弹出并置0
    //             res_temp.pop_back();
    //             usedNum[i] = 0;
    //         }
    //     }
    // }
    vector> permuteUnique(vector& nums) {
        vector usedNum(nums.size(), 0);
        sort(nums.begin(), nums.end());
        backTracking(nums, usedNum);
        return res;
    }
};

No 17. 电话号码的字母组合(中等)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/n-ary-tree-level-order-traversal/

题目描述:

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

算法学习随笔 7_回溯算法整理总结_第1张图片

示例 1:
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:
输入:digits = ""
输出:[]

示例 3:
输入:digits = "2"
输出:["a","b","c"]

思路:这一题刚拿到肯定会有点懵,感觉好像知道要怎么做,但是具体的细节又不是很清楚,其实这是因为对水回溯还没有完全理解。拿到这样的题,首先第一步要建立映射关系,也就是把按键和字母对应起来。这道题目肯定是建立一个字符串数组了,因为每一个按键上有多个字母,到时候肯定要用到其中一个或几个,需要遍历。 

其次要考虑一下,如何对输入的数字进行拆分。例如输入了23,我们要分别对2和3对应的字符串进行梳理,如果输入234,那就分别对2,3和4对应的字符串进行处理。其实就是在 N 个数组中找所有的组合。之前做过在一个数组中找组合的,现在变成了 N 个。

关键点:用一个 index 表示现在用到的是输入数字的第几个数字。例如输入 23 ,那么 index = 0 表示现在使用的是2,index = 1表示现在使用的是3。而用这个 index 可以拿到这个数字对应的数组。拿到这个数组就可以进行递归遍历了。

class Solution {
public:
    const string map[10] = { {""},{""},{"abc"},{"def"},{"ghi"},{"jkl"},{"mno"},{"pqrs"},{"tuv"},{"wxyz"} };
    vector res;
    string str;
    //关键在于用一个index就可以实现对digits的数字逐个获取同时实现获取不同数字对应的字母数组
    void backTracking(string digits, int index) {
        if(index == digits.size()) {
            res.push_back(str);
            return;
        }
        //拿到digits的一个数字,index其实可以表示这是第几层,也就是深度
        int numOfDigits = digits[index] - '0';
        //拿到这个数字代表的字母
        string lettersOfNum = map[numOfDigits];
        //不同的index对应不同的字母集,并且能够在递归的时候切换
        for(int i = 0; i < lettersOfNum.size(); i++) {
            str.push_back(lettersOfNum[i]);
            //这里的index + 1 自带回溯,当递归结束的时候index还是index,也可以在递归前后+1-1
            backTracking(digits, index + 1);
            str.pop_back();
        }
    }
    vector letterCombinations(string digits) {
        if(digits.size() == 0){
            return res;
        }
        backTracking(digits, 0);
        return res;
    }
};

No 131. 分割回文串(中等)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/palindrome-partitioning/

题目描述:

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。回文串 是正着读和反着读都一样的字符串。​

示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]

示例 2:
输入:s = "a"
输出:[["a"]]

思路:这个题目其实本质上就是一个回溯递归,只不过是在每次递归的时候要多进行一个条件判断,这个条件判断就是判断是不是回文串。判断回文串这里就不说了,双指针搞定。

整体思路就是利用递归,当前子串是回文串,那就递归,从下一个字符继续开始找是不是回文串。如果当前子串不是回文串,那就直接进行下一轮循环,看看再加进来一个字符是不是回文串。最后当开始位置大于等于数组的末尾的时候,是一个符合的结果,就把当前结果添加到结果集中。

这里有一个陷阱:要记住每次拿到的是一个子串,不是一个字符。例如第一次 for 循环拿到了 字符串 a ,第二次拿到的是 aa,第三次拿到的是 aab。那么当我们在一层进行遍历的时候,如果当前的子串不是回文串,我们是直接进行下一次循环还是直接中断循环呢?显然我们应该直接进行下一次循环,不进行递归(因为这个串不是回文串),而直接进行下一次循环是因为 当前字符串不是回文串,但是如果再加进来一个字符,可能就是回文串了。比如 当前是 ab ,但是下一个字符是 a,加进来就变成了 aba ,这就是一个回文串了。如果是回文串那就进行下一次递归,递归返回时记得回溯。

class Solution {
public:
    vector> res;
    vector res_temp;
    //判断是不是回文串
    bool judgeStr(const string& s, int leftIndex, int rightIndex){
        for(int i = leftIndex, j = rightIndex; i < j; i++, j--){
            if(s[i] != s[j]){
                return false;
            }
        }
        return true;
    }
    void backTracking(const string& s, int stratIndex) {
        //如果分割完以后发现都是回文串,那就把这个分割方案写到分割方案集合中
        if(stratIndex >= s.size()){
            res.push_back(res_temp);
            return;
        }
        //判断从startIndex 到 i 的字符串是不是回文串,startIndex = 0的这一层,子串的长度依次是1,2,3...s.size()
        for(int i = stratIndex; i < s.size(); i++) {
            //如果不是回文串那就再添加一个字符进来看看是不是回文串,所以直接下一轮循环添加一个字符
            //当前子串不是回文串就不用递归了,因为题目要求所有子串都是回文串
            if(judgeStr(s, stratIndex, i ) == false){ 
                continue;
            }
            //如果是回文串,就把当前这个字符串,写到分割方案中
            if(judgeStr(s, stratIndex, i ) == true) {
                string str = s.substr(stratIndex, i - stratIndex + 1);
                res_temp.push_back(str);
            }
            //并递归下一个字符,从下一个字符开始,看看有没有回文串,也就是深度+1
            //这层也是像第一层一样,子串长度是1,2,3,...s.size(),只不过这里的s是去掉第一个字符的s

            backTracking(s, i + 1);
            //递归返回要把之前记录进来的弹出
            res_temp.pop_back();
        }
    }
    vector> partition(string s) {
        backTracking(s, 0);
        return res;
    }
};

No 93. 复原 IP 地址(中等)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/restore-ip-addresses/

题目描述:

有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。

例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "[email protected]" 是 无效 IP 地址。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 '.' 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。

示例 1:
输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]

示例 2:
输入:s = "0000"
输出:["0.0.0.0"]

示例 3:
输入:s = "101023"
输出:["1.0.10.23","1.0.102.3","10.1.0.23","10.10.2.3","101.0.2.3"]

思路:这一题和上面一题的思路是一样的,只不过判断条件变了,然后多了一个插入 字符 的操作,并且是在原字符数组上进行操作。那么如何保证在原字符数字上操作,不会影响之后的遍历呢?答案就是回溯,当递归返回的时候,一切又会恢复原样。那终止条件是什么呢?根据题目,这些字符串一定会被分为 4 段, 也就是会添加 3 个点,我们可以用一个变量记录添加点的个数,如果这个添加点的个数等于3,那就终止。注意这里的变量是从0开始的,其实变量值等于2的时候就是插入3个点了,但是还会进行下次一递归,这时候就变成3了。

这里需要注意的点:在终止的时候,要对最后这一段子串进行合法判断,之后最后这一段也合法,这才是一个合理的划分。还有一个就是如果当前子串不合法,应该进行下一轮循环还是直接终止循环?答案是终止,因为这个题目和上一个题目不一样,这个题目如果当前子串不合法,那么不管在加多少字符,这个子串都不合法,所以直接终止。

class Solution {
public:   
    vector res;
    void backTracking(string& s, int stratIndex, int pointNum) {
        //如果已经添加了三个点,那就说明已经分好了4段,不能用开始位置来作为终止条件了
        if(pointNum == 3) {
            //判断最后一个子串是否符合规定
            if(judgeSubStr(s, stratIndex, s.size() - 1)) {
                res.push_back(s);
            }
            return;
        }
        for(int i = stratIndex; i < s.size(); i++) {
            if(judgeSubStr(s, stratIndex, i) == true) {
                //在这个字符后面添加一个 点
                s.insert(s.begin() + i + 1, '.'); 
                pointNum++;
                backTracking(s, i + 2, pointNum);
                pointNum--;
                //删除之前加的 点,参数是迭代器,只删除指定位置的字符
                s.erase(s.begin() + i + 1);
            }
            else{
                //这里用break,因为如果当前子串不合规,那么就算再添加一个字符进来也是不合规
                //131.分割回文串中,如果当前子串不合规,但是再添加一个字符进来,有可能合规,所以用continue
                break;
            }
        }
    }
    //判断子串是否合法
    bool judgeSubStr(string& s, int leftIndex, int rightIndex) {
        if(leftIndex > rightIndex){
            return false;
        }
        // 开头是 0 并且不止一个字符
        if(s[leftIndex] - '0' == 0 && leftIndex != rightIndex){
            return false;
        }
        int num = 0;//用于计算子串和是否在规定区间内
        for(int i = leftIndex; i < rightIndex + 1; i++) {
            //遇到非法字符
            if(s[i] > '9' || s[i] < '0') {
                return false;
            }
            num = num * 10 + (s[i] - '0');
            if(num > 255) {
                return false;
            }
        }
        return true;
    }
    vector restoreIpAddresses(string s) {
        backTracking(s, 0, 0);
        return res;
    }
};

No 491. 递增子序列(中等)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/increasing-subsequences/

题目描述:

给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。

示例 1:
输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]

示例 2:
输入:nums = [4,4,3,2,1]
输出:[[4,4]]

思路:这一题其实也是换汤不换药,但是这是一个求组合的题目,需要起始位置。不同点是加入的条件判断变成了只有比当前保存的结果数组最后一个数字大的才能加入这个结果数组,这样可以保证这个结果数组是升序。但是这个题目给定的数组是有重复的数字的,如果重复使用就会导致出现重复的序列。而且我们不能对这个原数组进行排序,然后按照之前总结过的当时去重,因为这个题目涉及到求子序列,改变原数组的顺序会改变子序列的顺序。那么我们只能用之前总结过的 树层 去重,在递归的 for 循环之前定义一个数组标记 当前树层 使用过的数字,后面在加入对是否使用过的判断条件即可。

小陷阱:在终止条件这里,每当结果数组大小大于2 的时候,就可以作为一个升序子序列了,但是这里一定不能直接返回,因为这个题目不是求最终的结果,而是相当于从根节点到叶子节点的每一个节点,只要符合条件都是一个升序子序列。那什么时候返回呢?起始位置超过数组最后一个位置的下标的时候就终止了。

class Solution {
public:
    vector> res;
    vector res_temp;
    void backTracking(vector& nums, int startIndex) {
        if(res_temp.size() >= 2) {
            res.push_back(res_temp);
        }
        if(startIndex == nums.size()){
            return;
        }
        // 使用set来对本层元素进行去重,每一层递归都定义了一个新的,所以是记录每一层用过的数字
        // 以[4,7,6,7]为例,第一层的元素是4,7,6,7;然后7重复,只使用4,7,6; 
        // 然后4,7,6又作为根节点,节点4下面有孩子7,6,7,其中7重复了,只使用一个;节点7下面有孩子6,7;节点6下面有孩子7;
        // 所以所谓层,其实就是根节点下所有的孩子,同一父节点下的同层上使用过的孩子就不能在使用了
        // 所以所谓深,其实就是根节点下到叶子节点的深度,在利用递归一边往下走一边记录节点的时候,每次记录后就把符合条件的写到临时结果集中
        //     直到所有以4开头的子集都遍历完,也就是以4为根节点的所有路径都遍历完,把这个临时结果集添加到总结果集中。同层其他节点和孩子节点也一样。
        // 这里不能使用之前的去重方法,因为之前的去重方法要求相同的数字在一起,本题不能排序,所以只能用一个不能有重复元素的unordered_set。
        unordered_set deduplication; 
        // 也可以使用一个数组代替,因为题目给定了数字的范围,可以用一个大小为200的数组来记录 
        // int usedNum[201] = {0};       
        for(int i = startIndex; i < nums.size(); i++) {
            // 不仅要满足nums[i]要比res_temp末尾的值大,还要满足nums[i]在本层中没有使用过
            // 如果使用数组 usedNum[nums[i] + 100] == 0;如果使用set:deduplication.find(nums[i]) == deduplication.end()
            if((res_temp.empty() == true || nums[i] >= res_temp.back()) &&  deduplication.find(nums[i]) == deduplication.end()) {
                //把当前层使用过的数字记录下来,同一父节点下的同层上使用过的元素就不能在使用了
                deduplication.insert(nums[i]);
                // usedNum[nums[i] + 100] = 1;
                res_temp.push_back(nums[i]);
                backTracking(nums, i + 1);
                res_temp.pop_back();
            }
            else{
                //因为是无序数组,虽然当前数字不符合要求,但是不知道下一个是不是符合
                continue;
            }
        }
    }
    //要深刻理解回溯的层和深的概念
    vector> findSubsequences(vector& nums) {
        if(nums.size() < 2){
            return res;
        }
        backTracking(nums, 0);
        return res;
    }   
};

No 332.重新安排行程(困难)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reconstruct-itinerary/

题目描述:

给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。

例如,行程 ["JFK", "LGA"] 与 ["JFK", "LGB"] 相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。

算法学习随笔 7_回溯算法整理总结_第2张图片

示例 1:
输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]

算法学习随笔 7_回溯算法整理总结_第3张图片

输入:tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
输出:["JFK","ATL","JFK","SFO","ATL","SFO"]
解释:另一种有效的行程是 ["JFK","SFO","ATL","JFK","ATL","SFO"] ,但是它字典排序更大更靠后。

思路:这一题挺难的,因为会有这几种情况,完全相同的机票,出发地相同但目的地不同的几篇,出发地不同但目的地相同的机票,出发地和目的地都不同的机票。之前没有考虑到会有完全相同的机票,所以我选择使用一个 字符串数组 来保存已经使用过的机票,发现行不通。后来我又想到使用 pair  这个数据结构,来构建一个机票和下标共同构成的 map 的 KEY,map的值为bool型,用来标记每一张机票是否使用。但是map的key不支持复杂数据类型,这条路也行不通。

typedef pair ticketPair;
typedef pair ticketKeyPair;
unordered_map ticketsSign;
void getTicketsSign(vector>& tickets) {
    for (int i = 0; i < tickets.size(); i++) {
        string ticketFrom = tickets[i][0];
        string tickesDes = tickets[i][1];
        ticketsSign[make_pair(make_pair(ticketFrom, tickesDes), i)] = false;
    }
}

最后参考了一下题解,发现自己还是对unordered_map和map这两个数据结构的理解不够深刻。题解并不是对机票来做 unordered_map,而是使用出发机场作为 key ,把这个机场能到达的机场以及次数构建一个map并作为 value 来构建 unordered_map。这一点是我没有想到的,而且构建这个map的方式也是我没有想到的。使用map来构建到达机场以及次数,可以保证题目要求的字典升序,因为map的key是有序的。

有了这个unordered_map,那么就可以进行递归回溯了。这里的终止条件是想到了,N 张机票,最优经过的机场总数一定是 N + 1 ,因为题目要求每张票都是用。如果遍历完了不是 N+1,那就没有符合要求的路径。

有一个注意点:这里的递归函数是有返回值的,只要符合终止条件了,那就返回true,不用再继续寻找了,因为我们构建unordered_map的时候,对value是使用的map构建的,map的key也就是目的地默认是升序,此时找到的第一个符合条件的路径就是题目要求的路径。

class Solution {
public:
    // unordered_map<出发机场, map<到达机场, 可使用次数>> targets
    // 目的机场集合使用map是因为map的key是有序的,题目要求字典升序
    unordered_map> targets;
    map des;
    vector res;
    bool backTracking(int ticketsNum) {
        if(res.size() == ticketsNum + 1){
            return true;
        }
        //targets[res[res.size() - 1]] 表示res[res.size() - 1]这个机场可以访问的机场map表,res的最后一个总是下一个的出发机场
        //pair& target 表示从这个map中挨个拿数据
        for(pair& target : targets[res[res.size() - 1]]){
            //目的机场还可以访问
            if(target.second > 0){
                //把目的机场添加到结果集中
                res.push_back(target.first);
                target.second--;
                //因为找到一个符合的路径就返回,所以递归有返回值
                if(backTracking(ticketsNum) == true) {
                    return true;
                }
                target.second++;
                res.pop_back();
            }
            
        }
        return false;
    }
    vector findItinerary(vector>& tickets) {
        int ticketsNum = tickets.size();
        //建立映射关系
        for(int i = 0; i < ticketsNum; i++) {
            targets[tickets[i][0]][tickets[i][1]]++;
        }
        res.push_back("JFK");
        backTracking(ticketsNum);
        return res;
    }
};

No 51.N皇后(困难)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/n-queens/

题目描述:

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

算法学习随笔 7_回溯算法整理总结_第4张图片

示例 1:
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:
输入:n = 1
输出:[["Q"]]

思路:这一题和前面的也是类似,这一次需要判断的是当前要放置棋子的位置是否合法。这里的棋子是皇后,这里普及一点点国际象棋小知识,皇后可以横着、竖着、斜着走。所以这里的合法判断就变成了判断当前位置的同行、同列、斜线是否存在皇后。这里一定要想清楚斜着走的话,行和列是怎么加减的,而且只需要向上判断,因为下面的棋盘还没处理。

首先进行棋盘的初始化,定义一个字符串数组,并全部初始化为题目要求的 点 。然后就开始进行递归,递归的是需要期盼的大小,棋盘和当前所在的行数。因为一行肯定只能放一个皇后,所以以行为单位进行for循环。在递归之前进行条件判断即可,是合法的位置就变为皇后,然后进行递归,如果不是合法的位置,那就进行下一轮循环,看看这一行的下一个位置合不合法。递归返回时记得要回溯,撤销之前的操作。

class Solution {
public:
    vector> res;
    //不能同行,不能同列,不能同斜线
    bool judgePosition(int row, int col, int n, vector& res_temp){
        //同行,不用处理,单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,下一次就去下一行了,所以不用去重了
        // for(int col_i = 0; col_i < n; col_i++){
        //     if(res_temp[row][col_i] == 'Q'){
        //         return false;
        //     }
        // }

        //同列
        for(int row_i = 0; row_i < n; row_i++){
            if(res_temp[row_i][col] == 'Q'){
                return false;
            }
        }  

        //同斜线,注意斜线有2个方向,只需要往上查找就行,因为下面的行还没有进行处理
        //45度,左上,row控制上下,col控制左右
        for(int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--,j--){
            if(res_temp[i][j] == 'Q'){
                return false;
            }
        }
        //135度, 右上
        for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--,j++){
            if(res_temp[i][j] == 'Q'){
                return false;
            }
        }
        return true;
    }
    void backTracking(int n, int row, vector& res_temp) {
        if(row == n) {
            res.push_back(res_temp);
            return;
        }
        for(int col = 0; col < n; col++) {
            if( judgePosition(row, col, n, res_temp) == true) {
                res_temp[row][col] = 'Q';
                backTracking(n, row + 1, res_temp);
                res_temp[row][col] = '.';
            }
        }
    }
    vector> solveNQueens(int n) {
        vector res_temp(n, string(n, '.'));
        backTracking(n, 0, res_temp);
        return res;
    }
};

No 37. 解数独(困难)

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/sudoku-solver/

题目描述:

编写一个程序,通过填充空格来解决数独问题。数独的解法需 遵循如下规则:

数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.' 表示。

算法学习随笔 7_回溯算法整理总结_第5张图片

示例 1:
输入:board = [
["5","3",".",".","7",".",".",".","."],
["6",".",".","1","9","5",".",".","."],
[".","9","8",".",".",".",".","6","."],
["8",".",".",".","6",".",".",".","3"],
["4",".",".","8",".","3",".",".","1"],
["7",".",".",".","2",".",".",".","6"],
[".","6",".",".",".",".","2","8","."],
[".",".",".","4","1","9",".",".","5"],
[".",".",".",".","8",".",".","7","9"]]

输出:[
["5","3","4","6","7","8","9","1","2"],
["6","7","2","1","9","5","3","4","8"],
["1","9","8","3","4","2","5","6","7"],
["8","5","9","7","6","1","4","2","3"],
["4","2","6","8","5","3","7","9","1"],
["7","1","3","9","2","4","8","5","6"],
["9","6","1","5","3","7","2","8","4"],
["2","8","7","4","1","9","6","3","5"],
["3","4","5","2","8","6","1","7","9"]]

解释:输入的数独如上图所示,唯一有效的解决方案如下所示:

算法学习随笔 7_回溯算法整理总结_第6张图片

思路:看到这个题,回想起了之前上中学被数独游戏支配的恐惧,要是中学的时候会这个那多好,哈哈。这个题其实本质上还是换汤不换药,只不过递归时要进行的条件判断复杂了一些。

合法判断:同行、同列不能有重复数字,当前这个3*3 的格子内不能有重复数字。确定每一个小格子的起始位置想了挺久,后来发现是自己想复杂了,直接用 当前行 除以 3然后在 * 3,那就是起始位置,其实是利用了 整形除法的取整特性。本质上还是映射,就是把这个格子内的行列数都映射在这个格子的起始位置。

整体思路:利用两个 for 循环,遍历每一个位置,如果这个位置是数字,那就跳过,直接遍历下一个数字,如果是空格,那就对这个位置以及要放的数字进行合法判断,如果合法那就添加一个数字,如果不合法那就换一个数字再判断。如果这9个数字都用完了还不合法,那就无解。

这里的递归函数也有返回值,因为如果找到了合法的数独解,后面的就不用再遍历了。

class Solution {
public:
    // 需要返回值但不需要终止条件
    //返回值的作用是解数独找到一个符合的条件立刻就返回,相当于找从根节点到叶子节点一条唯一路径
    //不需要终止条件是因为要遍历整个棋盘
    bool backTracking(vector>& board){
        for(int i = 0; i < board.size(); i++){
            for(int j = 0; j < board[i].size(); j++){
                //这个位置上有数字
                if(board[i][j] != '.'){
                    continue;
                }
                for(char c = '1'; c <= '9'; c++){
                    if(judgeValid(i, j, c, board) == true){
                        //设置数字
                        board[i][j] = c;
                        if(backTracking(board) == true){
                            return true;
                        }
                        //回溯撤销
                        board[i][j] = '.';
                    }
                }
                //这9个数字都试完了都不行
                return false;
            }
        }
        return true;
    }

    bool judgeValid(int row, int col, char val, vector>& board){
        //同行
        for(int i = 0; i < 9; i++) {
            if(board[row][i] == val){
                return  false;
            }
        }
        //同列
        for(int j = 0; j < 9; j++) {
            if(board[j][col] == val){
                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] == val){
                    return false;
                }
            }
        }
        return true;
    }
    void solveSudoku(vector>& board) {
        backTracking(board);
    }
};

你可能感兴趣的:(算法学习随笔,算法,学习,c++)