【代码随想录】【LeetCode】自学笔记09 - 回溯(DFS)

回溯概述

回溯是递归的副产品,只要有递归就会有回溯。所以,回溯函数也就是递归函数。
所有回溯法的问题都可以抽象为树形结构——一棵高度有限的N叉树。

回溯算法模板框架:
for循环横向遍历,递归纵向遍历,回溯不断调整结果集。

回溯三部曲:
1.回溯函数:
返回值——一般为void。(因为要遍历整棵树)
参数——一般是先写逻辑,然后需要什么参数,就填什么参数。
2.终止条件:
搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
3.遍历过程:
回溯法一般是在集合递归搜索:集合的大小构成了树的宽度,递归的深度构成了树的深度。

回溯模板:

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

可以看到:
终止条件在void里面,for的后面
剪枝:在终止条件之后的if里,或者在for()里

一些心得:
部分题,主函数里用sort
for的开始和结束一般都不固定,例如必需的STARTINDEX和剪枝里的n - ( k - path.size() ) + 1
和二叉树模板不同的是需要回溯撤销处理结果(类似层序遍历)

性能分析:

  • 组合:
    时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)
    组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
    空间复杂度: O ( n ) O(n) O(n)
    和子集问题同理。

  • 子集:
    时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)
    因为每一个元素的状态无外乎取与不取,所以时间复杂度为 O ( 2 n ) O(2^n) O(2n),构造每一组子集都需要填进数组,又有需要 O ( n ) O(n) O(n),最终时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)
    空间复杂度: O ( n ) O(n) O(n)
    递归深度为n,所以系统栈所用空间为 O ( n ) O(n) O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为 O ( n ) O(n) O(n)

  • 排列:
    时间复杂度: O ( n ! ) O(n!) O(n!)
    这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
    空间复杂度: O ( n ) O(n) O(n)
    和子集问题同理。

  • 时间复杂度:一般回溯算法的复杂度,都是指数级别的。

  • 空间复杂度:把系统栈(不是数据结构里的栈)所占空间算进去。

篇末总结

回溯法经常和二叉树遍历、深度优先搜索混在一起,因为这两种方式都是用了递归。

优化回溯算法只有剪枝一种方法。

回溯算法能解决如下问题:

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

每一道回溯法的题目都可以将遍历过程抽象为树形结构。

组合问题:
如果是一个集合来求组合的话,就需要startIndex;
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,从0开始。

排列问题:
1- 每层都是从0开始搜索而不是startIndex
2- 需要used数组记录path里都放了哪些元素了

子集问题:
一定要排序

组合问题

77.组合

其实不定义这两个全局遍历也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以我定义全局变量了。
return必须有!!
剪枝优化的地方,这里的n其实相当于遍历的终点;
// 是不是可以对标二叉树的层序遍历?

class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking( int startindex, int n, int k){
        if(path.size()==k) {
            result.push_back(path);
            return;
        }
        for(int i = startindex; i<=n-(k-path.size())+1; i++){注意是 ≤ !
            path.push_back(i);
            backtracking(i+1, n, k);注意是i+1, ++i和i++都不行!(why++i不行?)///WRONG k-i(尽量在for里面)
            path.pop_back();
        }
        return;
    }
    vector<vector<int>> combine(int n, int k) {
        result.clear();
        path.clear();
        backtracking(1, n, k);
        return result;
    }
};

216. 组合总和 III

之前(3月底)的疑惑解决了。主要是终止条件是size= =k且sum= =n,漏了sum<n的情况,但也罪不至于在远处的for报错int溢出还是什么的。。。
for(int i = startIndex; i <= 9-(k-path.size())+1; i++){

class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    int sum;// = 0;
    ///sum也可以不加到参数里(就像path 一样,在所有函数外面设置成全局变量!反之如果设置成参数那就不需要设置全局变量了。)
    void backtracking(int k, int n, int startindex){, int sum){
        if(sum>n) return;// 剪枝操作
        if(sum == n && path.size()==k ){
            result.push_back(path);
            return;
        }
        for(int i = startindex; i<=9-(k-path.size())+1; i++){///9,注意背会这一句
            sum+=i;
            path.push_back(i);
            backtracking(k, n, i+1);, sum);
            path.pop_back();
            sum-=i;
        }
        return;
    }
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear();
        path.clear();
        backtracking(k, n, 1);, 0);
        return result;
    }
};

17. 电话号码的字母组合

横向遍历单个按键对应的字母集合,纵向遍历按键;
注意本题的前处理步骤 —— 数字和字母如何映射;

二刷感想:
和前面讲解过的77. 组合和216.组合总和的区别:本题是多个集合求组合
(本质是一样的,只不过本题非共用数据,体现地更直观。)
这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
注意这里for循环,可不像是前两题,从startIndex开始遍历的。(因为这里的横向遍历不会受纵向的影响(不是共用一套系统),所以是固定的for的起始和结束,但之前两道题实际上也是创造了横向纵向两套遍历体系

三刷感想:
回溯参数就是边写边加进去的;
一定要加clear(),以及特殊情况判断:
res.clear();必须有
if(digits.size()==0) return res;///必须有,而且是和上一句一起有

class Solution {
public:
    vector<string>res;
    string path;
    vector<string> anjian = {
        "",
        "",
        "abc",
        "def",
        "ghi",
        "jkl",
        "mno",
        "pqrs",
        "tuv",
        "wxyz"
    };
    void dfs(string digits, int index){
        if(path.size()==digits.size()){
            res.push_back(path);
            return;
        }
        string str = anjian[digits[index]-'0'];//index = 0, digits[index] = '2', anjian[digits[index]-'0'] = "abc"
        for(int i = 0; i<str.size(); i++){
                char ch = str[i];
                path.push_back(ch);
                dfs(digits, index+1);
                path.pop_back();
        }        
    }
    vector<string> letterCombinations(string digits) {
        res.clear();必须有
        if(digits.size()==0) return res;///必须有,而且是和上一句一起有
        dfs(digits, 0);
        return res;
    }
};

39.组合总和

画图对理解很重要

分析:
1.
// 本题和我们之前讲过的77.组合、216.组合总和III有两点不同:
// 组合没有数量要求
// 元素可无限重复选取
2.
// **在求和问题中,sort加剪枝是常见的套路!**本题的剪枝优化,这个优化如果是初学者的话并不容易想到

sort函数默认为升序,也可进行降序排序。
sort函数的时间复杂度为n*log2n,比冒泡之类的排序算法效率要高。
sort函数包含在头文件为#include< algorithm >中。

还有一种剪枝,在for里面判断,这样避免了dfs再多运行一次之后再判断sum。
注意必须是>=,不能是>!
4.
答案

class Solution{
    private:
    vector<int> path;
    vector<vector<int>> result;
    void backtracking( vector<int>& candidates, int target, int sum, int startIndex){
        if (sum == target) {
        result.push_back(path);
        return;
        }
        for(int i = startIndex; i < candidates.size() && sum + candidates[i]  <= target; i++){
       / i 不是从0开始,是从starti开始的!/ + candidates[i] (上一轮for的i+1)这样避免了再次进入for,直接剪枝
            path.push_back(candidates[i]);
            sum += candidates[i];
            backtracking( candidates, target, sum, i);/!!!是i,WRONG startindex OR i+1
            sum -= candidates[i];
            path.pop_back();
        }
    }
    public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target){
        sort(candidates.begin(), candidates.end()); 
        backtracking(candidates, target, 0, 0);
        return result;
    }
};

40.组合总和II

分析:
本题同样是求组合总和,但就是因为其数组candidates有重复元素,而要求不能有重复的组合,所以相对于39.组合总和难度提升了不少。
强调一下,树层去重的话,需要对数组排序!
// 这里直接用startIndex来去重也是可以的, 就不用used数组了。
同上一题一样的技巧:在for里回溯,这样不会更新sum!if (i>startIndex && candidates[i] == candidates[i-1]) continue;在横向遍历阶段,要对同一树层使用过的元素进行跳过。

知识点:
求和——sort;
同一数组——startindex;
去重——数组内有重复数值的元素,但输出结果不能重复——used数组+sort排序
used数组——定义及初始化在主函数里;用过又还原的才需要跳过;for内跳过,用的是continue;
for内剪枝——注意是>=;
其他——用target减到0代替sum;i>0才能判断used[i-1];dfs用i++;path输入的是candidates[i]不是i。

不懂:
为什么是continue不能是break?用break会只输出部分结果

class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;
    void dfs(vector<int>& candidates, int target, int startindex, vector<int>& used) {
        if(target==0){
            res.push_back(path);
            return;
        }
        for(int i = startindex; i<candidates.size() && target-candidates[i]>=0; i++){//这句话必须有,不然会超时
            if(i>0&& used[i-1]==0 &&candidates[i-1]==candidates[i])continue; // break;
            path.push_back(candidates[i]);
            used[i]=1;
            dfs(candidates, target-candidates[i], i+1,used);
            path.pop_back();
            used[i]=0;
        }
    }
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(),candidates.end());
        vector<int> used(candidates.size(), 0);
        dfs(candidates, target, 0, used);
        return res;
    }
};

131. 分割回文串(need again)

// /*切割问题类似组合问题。
// 那么在代码里什么是切割线: startIndex ,表示下一轮递归遍历的起始位置,这个 startIndex 就是切割线
// 判断一个字符串是否是回文: 可以使用双指针法,一个指针从前向后,一个指针从后先前,如果前后指针所指向的元素是相等的,就是回文字符串了。

// 几个难点:
// 切割问题可以抽象为组合问题
// 如何模拟那些切割线
// 切割问题中递归如何终止
// 在递归循环中如何截取子串
// 如何判断回文

// 关于模拟切割线,其实就是index是上一层已经确定了的分割线,i是这一层试图寻找的新分割线
// 除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1。*/

只需一个额外参数,因为末项就是i!

class Solution {
private:
    vector<vector<string>> result;
    vector<string> path; // 放已经回文的子串
    void backtracking (const string& s, int startIndex) {
        // 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
        if (startIndex >= s.size()) {// startindex 是闭
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < s.size(); i++) {
            if (isPalindrome(s, startIndex, i)) {   // !!! i 是末项,startindex 是首项!!
                string str = s.substr(startIndex, i - startIndex + 1);// 获取[startIndex,i](双闭区间)在s中的子串
                path.push_back(str);
            } else {                                // 不是回文,跳过
                continue;
            }
            backtracking(s, i + 1); // 寻找i+1为起始位置的子串
            path.pop_back(); // 回溯过程,弹出本次已经填了的子串
        }
    }
    bool isPalindrome(const string& s, int start, int end) {//注意这里只判断s的一部分是不是子串,这样更方便!
        for (int i = start, j = end; i < j; i++, j--) {
            if (s[i] != s[j]) {
                return false;
            }
        }
        return true;
    }
public:
    vector<vector<string>> partition(string s) {
        result.clear();
        path.clear();
        backtracking(s, 0);
        return result;
    }
};

93.复原IP地址(need again)

在这里插入图片描述

string作参数要不要加&

试过了两个&加和不加都能通过。最好加上?

std::string str,str可以被修改,而且会调用拷贝构造函数。
std::sring& str,str可以被修改,但不会调用拷贝构造函数。
const::string str ,str不能被修改,但会调用拷贝构造函数。
const::string& str,str不能被修改,而且也不会调用拷贝构造函数。

// /*startIndex一定是需要的,因为不能重复分割,记录下一层递归分割的起始位置。
// 本题我们还需要一个变量pointNum,记录添加逗点的数量。
// */
class Solution{
    private:
    vector<string> result;

    void backtracking (string& s, int startIndex, int pointNum){
        if ( pointNum == 3) {// 逗点数量为3时,分隔结束
            if (isValid(s, startIndex, s.size()-1)){// 判断第四段子字符串是否合法,如果合法就放进result中
                result.push_back(s);
            }
            return;
        }

        for (int i = startIndex; i < s.size(); i++){
            if (isValid(s, startIndex, i)){先加入一步判断,在此前提下进行递归

                s.insert(s.begin()+ i + 1, '.');
                ///直接在指定位置插入元素,后面的元素都后移,更新原字符串
                ///insert(pos , elem): "在迭代器 pos 指定的位置之前插入一个新元素elem,并返回表示新插入元素位置的迭代器。"
                ///ctrl+1出现第一个标签页!!!

                pointNum ++;
                backtracking(s, i +2, pointNum);【多次指出】startindex 只是最初的标记点,之后主场是i!!!
                pointNum --;
                s.erase( s.begin() + i + 1);
            }else break;
        }
    }

    bool isValid( const string& s, int start, int end ){
    // 主要是这个函数比较长:判断字符串s在闭区间[start, end]所组成的数字是否合法
        if (start> end) return false;
        if (s[start] == '0' && start != end) return false;  
        //2.  0开头的多位数不合法
        int num = 0;
        for(int i = start; i <= end; i++){闭区间所以是<=end
            if (s[i] > '9' || s[i] < '0')return false;      
            // 3. 遇到非数字字符不合法【【注意char也是可以比较大小的!】】
            num = num* 10 + (s[i] - '0');
            ///【【注意string转多位数字的经典写法!】】string '225' -> int 2 2 5 -> int 255!!!!!!
            if(num > 255)return false;                      
            // 4. 如果大于255了不合法
        }
        return true;
    }

    public:
    vector<string> restoreIpAddresses (string& s){
        result.clear();
        if (s.size() > 12) return result; // 剪枝
        backtracking(s, 0 ,0);
        return result;
    }
};

子集问题

组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点
其实子集也是一种组合,集合是无序的,那么取过的元素不会重复取,for就要从startIndex开始,而不是从0开始!

78. 子集

注意此时 if 和 res 更新的写法和上一章的组合问题不同。

class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;
    void dfs(vector<int>& nums, int startindex){

        res.push_back(path);// 收集子集,要放在终止添加的上面,否则会漏掉自己;收集每一轮的第一个空集[]
        if(startindex==nums.size()) return;
        
        for(int i = startindex;i<nums.size(); i++ ){
            path.push_back(nums[i]);
            dfs(nums, i+1);
            path.pop_back();   
        }
    }
    vector<vector<int>> subsets(vector<int>& nums) {
        res.clear();
        if(nums.size()==0) return res;
        dfs(nums, 0);
        return res;
    }
};

90. 子集II

  • 本题和上一题的区别:“其中可能包含重复元素”。那么需要去重。

  • 去重,有两个要点:
    1- (sort):注意去重之前,需要先对集合【排序】
    2-(used数组):理解“树层去重”和“树枝去重”非常重要。

  • used数组:
    当used[i - 1] == false,说明同一树层candidates[i - 1]使用过(F -> T ->F),我们要对同一树层使用过的元素进行跳过

  • 以下还没理解当时怎么写的:
    在 public 里定义并初始化变量,在 private 里使用!!
    (猜是因为 used 初始化的参数是在 public 里定义的 nums ,如果在 private 里直接定义缺省值有点麻烦。总之要记住,不要觉得有点变扭)
    【代文18. :】如果把unordered_set uset放在类成员的位置(相当于全局变量),就把树枝的情况都记录了,不是单纯的控制某一节点下的同一层了,它控制的就是整棵树,包括树枝。所以这么写不行!
    本题也可以不使用used数组来去重,因为递归的时候下一个startIndex是i+1而不是0,从而可以用startIndex操作。
    如果要是全排列的话,每次要从0开始遍历,为了跳过已入栈的元素,需要使用used。

class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;
    void dfs(vector<int>& nums, int startindex,vector<bool>& used){

        res.push_back(path);// 收集子集,要放在终止添加的上面,否则会漏掉自己;收集每一轮的第一个空集[]
        if(startindex==nums.size()) return;
        
        for(int i = startindex;i<nums.size(); i++){

            if(i>0 && nums[i]==nums[i-1] && used[i-1]==0) continue;

            path.push_back(nums[i]);
            used[i] = 1;
            dfs(nums, i+1, used);
            used[i] = 0;
            path.pop_back();   
        }
    }
    vector<vector<int>>  subsetsWithDup(vector<int>& nums) {
        res.clear();
        if(nums.size()==0) return res;

        sort(nums.begin(), nums.end());
        vector<bool> used(nums.size(), 0);

        dfs(nums, 0,used);
        return res;
    }
};

491. 递增子序列

(need again:二刷没做三刷做了)
// 相信大家在本题中处处都能看到是回溯算法:求子集问题(二)的身影,但处处又都是陷阱。

分析:

  • 本题不能对原数组排序,排完序的数组都是自增子序列了
  • used去重:因为不连续(4675可能选4和最后一个5)
    所以这个uesd是模仿哈希表的,是以元素值作为导向的;
    题目说数值范围[-100, 100],对应i+100范围[0,200],判断的是 if ( used[nums[i]+100]==1 || (!path.empty() && nums[i]<path.back()) ) continue;,对应(是非空且不递增)或(同层上一个用过了)就跳过;
    对同一个 startindex ,对 used 清零。要知道used只负责本层!
    另外,在本层,记录这个元素【的值】用过了,后面不能再用了,一层之内不需要再回溯清零,所以后续used不用回溯为初值了。
    如果数值范围小的话,尽量用数组,不要用哈希表。
  • 其他:本题不能重复使用元素,所以需要startIndex;题目要求递增子序列大小至少为2,影响到 dfs函数和主函数开头的判断;
class Solution {
public:
    vector<vector<int>> res;
    vector<int> path;
    void dfs(vector<int>& nums, int startindex){

        if(path.size()>=2){
            res.push_back(path);
            //return;
        } 
        bool used[201] = {0};
        for(int i = startindex;i<nums.size(); i++){

            if(used[nums[i]+100]==1 
                || (!path.empty() && nums[i]<path.back()) ) continue;

            path.push_back(nums[i]);
            used[nums[i]+100] = 1;
            dfs(nums, i+1);
            path.pop_back();   
        }
    }

    vector<vector<int>> findSubsequences(vector<int>& nums) {
        res.clear();
        if(nums.size()<2) return res;

        dfs(nums, 0);
        return res;
    }
};

全排列问题

46.全排列

排列问题的不同:

  1. 每层都是从0开始搜索而不是startIndex
  2. 需要used数组记录path里都放了哪些元素了(标记path 里已经选择的元素,一个排列里一个元素只能使用一次。)
    组合里面在元素值一样但下标不同的时候去重,排列都需要去重,因为每次 for 都从0开始:如果元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1,就涉及到去重的used了。
class Solution {
public:
    vector<vector<int>> res;
    vector<int>path;
    void dfs(vector<int>& nums,vector<bool>& used){
        if(path.size()==nums.size()){
            res.push_back(path);
            return;
        }
        for(int i = 0; i<nums.size(); i++){
            if(used[i]==1)continue;///此处和组合不同,反而有点类似491(不连续有条件的子集)问题,但查找的值是nums的下标。
            path.push_back(nums[i]);
            used[i]=1;
            dfs(nums,used);
            path.pop_back();
            used[i]=0;
        }
    }
    vector<vector<int>> permute(vector<int>& nums) {
        res.clear();
        vector<bool>used(nums.size(), 0);
        dfs(nums, used);
        return res;
    }
};

47. 全排列II

融合了491,不希望值重复+排列不能重复,写了两个used:

class Solution {
public:
    vector<vector<int>>res;
    vector<int>path;
    void dfs(vector<int>& nums,vector<bool>& used1){
        if(path.size()==nums.size()){
            res.push_back(path);
            return;
        }
        vector<bool> used2(201,0);///类似491,对值查找;小范围用数组不用哈希
        for(int i = 0; i<nums.size(); i++){
            if(used1[i]==1 || used2[nums[i]+10]==1)continue;//类似491,对下标查找
            used2[nums[i]+10]=1;
            path.push_back(nums[i]);
            used1[i]=1;
            dfs(nums, used1);
            used1[i]=0;
            path.pop_back();
        }
    }
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        res.clear();
        vector<bool> used1(nums.size(), 0);
        dfs(nums, used1);
        return res;
    }
};

【代】答案,sort了一下,这样就可以把相邻的值都挨在一起,从而知道下标关联关系,从而可以只用一个used:

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& nums, vector<bool>& used) {
        // 此时说明找到了一组
        if (path.size() == nums.size()) {
            result.push_back(path);
            return;
        }
        for (int i = 0; i < nums.size(); i++) {
            // used[i - 1] == true,说明同一树枝nums[i - 1]使用过
            // used[i - 1] == false,说明同一树层nums[i - 1]使用过
            // 如果同一树层nums[i - 1]使用过则直接跳过
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
                continue;
            }
            if (used[i] == false) {
                used[i] = true;
                path.push_back(nums[i]);
                backtracking(nums, used);
                path.pop_back();
                used[i] = false;
            }
        }
    }
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        result.clear();
        path.clear();
        sort(nums.begin(), nums.end()); // 排序
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    }
};
332.重新安排行程(图论额外拓展题)(未)
51.N皇后(未)
37.数独(未)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值