C++学习笔记-回溯算法(1)组合问题

资料来源:代码随想录

1.回溯算法理论基础

回溯法,一般可以解决如下几种问题:

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

回溯法解决的问题都可以抽象为树形结构

因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,构成了树的深度

回溯模板:

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

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

2.组合问题 77(先看下面的4)

对应于递归三部曲,回溯也有三部曲。

1)函数参数及返回值:

回溯算法中的递归函数返回值一般都是void类型,而参数的话不好在开头直接确定,需要分析一下用到哪些参数,再加进来。

本题中,需要用到的参数是给定的两个整数n和k,还有用来防止把同一个元素取了两次的startIndex,保证下一层的递归从上一层递归已经用过的元素后面的元素开始。

2)终止条件

本题中,当路径的大小为要求的k的时候,就说明已经找到一个符合要求的组合了,就可以把这个存起来了。

3)单层递归逻辑

for循环用来遍历本层的元素,进行横向遍历,注意是从startIndex开始,避免遍历已经遍历过的元素。递归用来向下一层深入,同样是从当前遍历元素的下一个元素开始。递归之后要回溯,退回到上一层,才能把新的元素加入,从而访问到别的分支。

class Solution {
private:
    //先定义两个全局变量用来存放结果
    vector<int> path;  //单条路径
    vector<vector<int>> result;  //最后的结果集
    
    //递归函数(即回溯函数)
    void backtracking(int n, int k, int startIndex)
    {
        //终止条件:路径大小为k,说明收集到了一条,就可以存进结果集了
        if(path.size()==k)
        {
            result.push_back(path);
            return;//存起来之后还要用return来终止本层递归
        }

        //单层递归逻辑
        for(int i=startIndex; i<=n; i++)
        {
            path.push_back(i);  //把节点放进path里
            backtracking(n,k,i+1);  //下一层要从当前节点i的下一个节点开始
            path.pop_back();  //回溯,返回上一层
        }
    }


public:
    vector<vector<int>> combine(int n, int k) {
        result.clear();
        path.clear();
        backtracking(n,k,1);  //初始递归从第一个元素开始
        return result;
    }
};

剪枝:

要满足组合里元素个数=k,所以可以把必然不满足=k的分支都剪掉,令其不参与递归。方法同下题中的方法2:缩短for循环的范围

//单层递归逻辑
        for (int i = startIndex; i <= n-(k-path.size())+1 ; i++)
        {
            path.push_back(i);
            backtracking(n,k,i+1);
            path.pop_back();
        }

4.组合总和III 216

 把整个过程视为构造一棵N叉树,可选择的每个元素都要过一遍(宽度),要求由几个元素构成组合,就要把N叉树往下构造多少层。每个组合里可以有k个数,所以最多下到k层,那么树的深度就是k;可选的一共9个数,都要过一遍,所以宽度是9.

把找到的单个符合条件的结果命名为path是因为每个组合结果都对应一条从根节点到叶子节点的路径

class Solution {
private:
    //先定义两个全局变量
    vector<vector<int>> result;
    vector<int> path;

    //函数返回值:因为递归的结果会直接放在全局变量里,所以不需要返回值了
    //参数:目标相加之和targetSum,也就是n;数目k;当前path统计的节点数值之和sum;用来控制循环起始量的startIndex
    void backtracking(int targetSum, int k, int sum, int startIndex)
    {
        //终止条件:最多k个数,所以当path里元素个数=k时,就可以返回了,返回也有两种情况
        if(path.size()==k)
        {
            if(sum==targetSum) result.push_back(path);  //当前path里元素总和=目标值,说明收集到了一个符合要求的,放进结果集
            return;  //元素个数够了但sum!=targetSum  直接返回
        }

        //单层递归逻辑
        for(int i=startIndex; i<=9; i++)  //第一次for循环时是从1开始的,但因为每一层递归都要用这个循环,所以起始不能写1,要写成会变的startIndex
                                          //9是因为只能使用元素1-9,所以会把1-9遍历一遍
        {
            sum=sum+i;   //累加
            path.push_back(i);   //把元素放进path里
            backtracking(targetSum,k,sum,i+1);  //向下一层递归,注意下一层递归是要从当前元素i之后开始的,所以把下一层的satrtIndex赋为 i+1
            sum=sum-i;  //回溯
            path.pop_back();  //回溯
        }
    }


public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear();
        path.clear();
        backtracking(n,k,0,1);  //第一轮循环的startIndex是从1开始的
        return result;  //上面那个函数走完之后结果已经放在result里了
    }
};

里面有很多支是不符合条件的,比如不满足和为n,或者无法满足组合内元素个数为k,这些都可以提前处理一下,不要再进行没有意义的递归。

剪枝方法1:去掉元素之和已经>n的,这部分必不符合要求,就不用再递归了。剪枝部分放在递归函数的开头,这样一进递归就可以判断需不需要继续下面的处理了,如果不满足,就直接返回。

class Solution {
private:
    //先定义两个全局变量
    vector<vector<int>> result;
    vector<int> path;

    void backtracking(int targetSum, int k, int sum, int startIndex)
    {
        //剪枝
        if(sum>targetSum)  //当前和已经大于目标值了,直接返回
        {
            return; 
        }
        
        if(path.size()==k)
        {
            if(sum==targetSum) result.push_back(path);  
            return; 
        }

        //单层递归逻辑
        for(int i=startIndex; i<=9; i++)  
        {
            sum=sum+i;   //累加
            path.push_back(i);   //把元素放进path里
            backtracking(targetSum,k,sum,i+1);  //向下一层递归
            sum=sum-i;  //回溯
            path.pop_back();  //回溯
        }
    }


public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear();
        path.clear();
        backtracking(n,k,0,1);  
        return result;  
    }
};

剪枝方法2:缩短for循环的范围。题目中要求组合里的元素必须是k个,当循环到一定程度时,剩下未被选择的元素构成的组合元素个数必小于k,不符合要求,这部分就不用再递归了。

如何确定i的范围?当前path中已有的元素个数为path.size(),为了达到k个,还需要k-path.size个,因此此时初始的元素集中必须还剩下k-path.size个元素。那么,i必须走到还剩下k-path.size个元素时就停止,不然继续向后走的话,剩余的元素就不够了。要余下这么多元素的话,i如何定位呢?i<=9-(k-path.size)+1.例如,k=2,刚开始的时候path为空,k-path.size=2-0=2,9-2+1=8,也就是说i最多最多走到8这里必须停下,才能保证组合里元素个数为2.

class Solution {
private:
    vector<int> path;
    vector<vector<int>> result;

    void backtracking(int targetSum, int k, int sum, int startIndex)
    {
        //终止条件
        if(path.size()==k)
        {
            if(sum==targetSum) result.push_back(path);
            return;
        }

        //单层递归逻辑
        for(int i=startIndex; i<=9-(k-path.size())+1; i++)
        {
            sum+=i;
            path.push_back(i);
            backtracking(targetSum,k,sum,i+1);
            sum-=i;
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear();
        path.clear();
        backtracking(n,k,0,1);
        return result;
    }
};

5.电话号码的字母组合 17

class Solution {
private:
    //先给出数字和字母对应表,字符串类型
    const string letterMap[10]={
        "",  //0
        "",  //1
        "abc",  //2
        "def",  //3
        "ghi",  //4
        "jkl",  //5
        "mno",  //6
        "pqrs",  //7
        "tuv",  //8
        "wxyz",  //9
    };

public:
    //定义两个全局变量存放结果,因为题目中要求返回的是vector<string>,所以最终结果是字符串类型数组,单个结果就是字符串
    vector<string> result;
    string s;

    void backtracking(const string& digits, int index) //index是用来表明输入的组合里遍历到了哪个元素
    {
        //终止条件:遍历到digits要求的个数,就可以返回了
        if(index==digits.size())  //如果在size-1就返回,那么最后一个数字就被漏掉了,所以必须到了size才能返回
        {
            result.push_back(s);  //保存结果
            return;  //结束当前递归
        }

        //对数字和字母的映射进行处理
        int digit=digits[index]-'0';  //digits[index]取出来的是字符,减掉‘0’将其转换为int类型的整数
        string letter=letterMap[digit];  //取index对应的字母字符串
        for(int i=0; i<letter.size(); i++)  //可以从0开始是因为每一层的元素对应的字母都不在同一个组合里,所以每一轮都可以从头开始
        {
            s.push_back(letter[i]);
            backtracking(digits,index+1);  //以输入"23"举例,上一层从2对应的组合里取,index=0,下一层就要从3对应的组合里取,index=1,所以每往下一层,index+1
            s.pop_back();  //回溯
        }
    }
    
    vector<string> letterCombinations(string digits) {
        s.clear();
        result.clear();
        if(digits.size()==0)  //示例中出现了,输入可能为空
        {
            return result; 
        }
        backtracking(digits,0);  //刚开始从index=0开始
        return result;
    }
};

隐藏回溯:

public:
    vector<string> result;
    string s;

    void backtracking(const string& digits, int index, const string& s) //回溯隐藏时这里多一个参数
    {
        if(index==digits.size())  
        {
            result.push_back(s);  
            return;  
        }

        //对数字和字母的映射进行处理
        int digit=digits[index]-'0';  
        string letter=letterMap[digit];  
        for(int i=0; i<letter.size(); i++)  
        {
            backtracking(digits,index+1, s+letter[i]);  //这里隐藏了回溯,因为传入的参数就是s+letter[i],说明s本身并没有被改变
        }
    }
    
    vector<string> letterCombinations(string digits) {
        s.clear();
        result.clear();
        if(digits.size()==0)  
        {
            return result; 
        }
        backtracking(digits,0,"");  //起始传入的是一个空字符串
        return result;
    }
};

7.组合总和 39

本题只有组和总和的限制,而没有组合内元素个数的限制,所以递归层数没有限制,当组合总和≥target时,就返回。

何时需要startIndex?

在同一个集合中求组合时,就需要;在不同集合中求组合时,就不需要

startIndex是for循环里的循环起始量,是管同一层的,用来避免同一层之间取了相同的元素、从而出现相同的组合。但不同层之间是可以取相同元素的,所以向下一层递归的时候不需要i+1,直接从当前元素i开始也是可以的。

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;

    void backtracking(vector<int>& candidates, int target, int sum, int startIndex)  //参数包括单条路径的总和,还有每层循环的循环起始量,以保证不会出现相同的组合
    {
        //终止条件:总和>=target
        if(sum>target) return;
        if(sum==target) 
        {
            result.push_back(path);
            return;
        }

        //单层递归逻辑
        for(int i=startIndex; i<candidates.size(); i++)
        {
            sum=sum+candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates,target,sum,i);  //下一层选取的元素可以和上一层相同,所以直接传入i即可,不需要用i+1来避免取到相同的元素
            sum-=candidates[i];  //回溯
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        result.clear();
        path.clear();
        backtracking(candidates,target,0,0);  //刚开始的时候,总和和循环起始量都为0
        return result;
    }
};

剪枝:

以上的代码中,即便下一层的sum>target了,还是会先完成下一层的递归,然后再判断出sum>target,再结束掉递归。可以提前判断一下,如果下一层的sum>target,也就是本层的sum+candidates[i]>target,就可以提前结束掉for循环,不要再往下了。改一下for循环的循环条件。

当然,前提是必须先给集合排个序,这样一旦出现>target的,后面的就全部是>target了,可以放心提前结束循环。

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;

    void backtracking(vector<int>& candidates, int target, int sum, int startIndex)  
    {
        if(sum>target) return;
        if(sum==target) 
        {
            result.push_back(path);
            return;
        }

        for(int i=startIndex; i<candidates.size() && sum+candidates[i]<=target; i++)  //加一个循环条件,sum+candidates[i]<=target
        {
            sum=sum+candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates,target,sum,i); 
            sum-=candidates[i];  
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        result.clear();
        path.clear();
        sort(candidates.begin(),candidates.end()); //先排序!!!
        backtracking(candidates,target,0,0);  
        return result;
    }
};

8.组合总和II 40

难点:数组中有重复的元素,但解集中不可以出现相同的组合。

所以需要进行去重,“树层去重”。

所谓去重,其实就是使用过的元素不能重复选取。 这么一说好像很简单!

都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。

那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?

回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。

所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重

强调一下,树层去重的话,需要对数组排序!

选择过程树形结构如图所示:

Q:为什么同一树层上的两个重复元素不可以重复选取?

A:因为第一个重复元素组成的集合里必然已经包含了第二个重复元素能组成的所有可能集合,如果第二个再取的话,必然会重复。

去重逻辑

如果candidates[i]==candidates[i-1],说明出现了两个重复元素,这时需要用数组used来看两个重复元素是在同一树枝上使用了还是在同一树层上使用了

如果used[i-1]= 0(false),说明在这一树枝上没有用到candidates[i-1],那么就是在candidates[i]相同层的前一个元素出现了相同的,used[i-1]= 0是因为是从前一个树枝回溯到这的,所以就是树层重复了,那么candidates[i]就不能再被选取了。出现这种情况的话,直接跳过,不要执行。

如果used[i-1]= 1(true),说明在这一树枝上用到了candidates[i-1],那么就是在同一树枝上出现了两个相同元素,这种情况是合法的。

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;

    void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used)
    {
        //终止条件:
        //if(sum>target) return;  这个终止条件放到剪枝里去处理
        if(sum==target)
        {
            result.push_back(path);
            return;
        }

        for(int i=startIndex; i<candidates.size() && candidates[i]+sum <=target; i++)  //同一个集合里进行组合,所以需要startIndex;for循环的范围有剪枝
        {
            //去重:对同一树层中出现的相同元素进行处理,第二次开始出现的相同元素就不能再使用了
            if(i>0 && candidates[i]==candidates[i-1] && used[i-1]==false)
            {
                continue;
            }
            
            sum=sum+candidates[i];
            path.push_back(candidates[i]);
            used[i]=true;  //用过的就在used数组里赋值1
            backtracking(candidates,target,sum,i+1,used);  //下一层元素不能和当前层重复,所以startIndex要从i+1开始
            sum-=candidates[i];
            path.pop_back();
            used[i]=false;  //回溯
        }
    }

public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        vector<bool> used(candidates.size(),false);  //刚开始都没用过,所以used初值全为0
        result.clear();
        path.clear();
        sort(candidates.begin(),candidates.end());  //树层去重必须要排序
        backtracking(candidates,target,0,0,used);
        return result;
    }
};

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值