【回溯Ⅰ】子集问题

什么是回溯?

回溯是一种算法,通常用于解决搜索问题、游戏问题、布局问题等。在回溯算法中,系统尝试在所有可能的选择中逐步构建解决方案,当发现当前的选择并不是有效的解决方案时,便回溯到之前的步骤,尝试其他的选择。这种方法通过深度优先的方式搜索所有可能的情况,直到找到解决方案或者确定不存在解决方案为止。

回溯算法通常包括以下步骤:

  • 选择:做出一个选择,尝试向前推进。
  • 约束:检查当前选择是否满足问题的限制条件。
  • 目标:检查当前选择是否是期望的解决方案。
  • 回溯:如果选择不满足约束条件或者不能达到期望的解决方案,就回溯到之前的步骤,尝试其他的选择。

这种算法在许多领域都有应用,如解决数独、八皇后问题、图的着色问题等。回溯算法的实现通常使用递归的方式,因为问题的解决方案往往可以表示为一棵树,通过递归的方式遍历这棵树,从而找到解决方案。
深度优先搜索(DFS)在回溯问题中用得很多,是一种常见的解决问题。深度优先搜索可以用递归方式实现,也可以使用栈而避免函数递归调用。
注意:递归函数有几个要点:

  • 函数终止条件:递归函数必须定义清晰的终止条件,也称为基本情况。这些条件应该能够直接返回结果,而不再进行递归调用。缺乏终止条件或者不正确的终止条件可能导致无限递归,最终耗尽系统资源。

  • 递归关系:递归函数根据递归关系,去递归调用自身。递归关系使得函数朝终止条件逐步推进,以确保递归最终结束。

组合问题题集

回溯 and 二进制枚举

下面的几个题目,是一个类型,可以用经典的回溯递归解决,也可以用二进制枚举。
什么是二进制枚举,最经典易懂的例子,一个集合假设有n个元素,其幂集有2^n个,这是怎么得到的?——n个元素,每个元素有两种可能(不存在子集中 OR 存在子集中),每个位置2*2*2*…*2,就是2^n。 二进制枚举法,对于78.子集就是完美适配的解决方案。 用一个mask:从00000逐一增加到11111即可表示所有的可能性。 在其他约束情况下,需增加一步判断:判断这些子集是否满足约束条件。

78. 子集

78. 子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的
子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
在这里插入图片描述

二进制枚举

数组nums有n个元素,那么其幂集就有2^n个,1左移n位,就是2^n。下面是二进制运算的一些细节:

  • 细节1:用mask来表示这个二进制枚举的过程,mask就从0逐一增加到(1<<n)。
  • 细节2:根据mask的二进制表示的每一位i,判断nums[i]是否在某子集中,mask的第i位通过mask & (1<<i),或者(mas>>i)&1得到,再判断第i位为0还是1。

java代码如下:

class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        List<Integer> temp = new ArrayList<Integer>();
        List<List<Integer>> ans = new ArrayList<List<Integer>>();

        int n = nums.length;
        // mask表示所有可能的二进制掩码
        for(int mask = 0; mask < (1 << n); mask++){
            temp.clear();
            // 判断mask的每一位
            for(int i = 0; i < n ; i ++){
                //if(((mask >> i) & 1)!= 0)
                if((mask & (1 << i)) != 0)
                    temp.add(nums[i]);
               
            }
            // 注意java是面向对象的编程语言,这里的ArrayList是引用型
            //如果直接ans.add(temp),后续temp还会更改,ans中这个子集也会更改,结果就不正确了
            //因此需要new一个新的add到ans中
            ans.add(new ArrayList<Integer>(temp));
        }
        return ans;        
    }
}
递归求解

整体是思路是遍历数组nums,每一个元素有两种可能:选择OR不选择。根据前面提到的递归函数的两个要点,分别分析:

  • 首先是递归结束条件,我们是在遍历数组nums,因此数组遍历结束即递归结束。用cur表示遍历到元素下标,cur从0开始,如果cur == n,即遍历结束
  • 其次是递推关系,每一轮针对下标cur的元素操作(选择OR不选择),就去判断cur++的元素,直到cur == n。
    这里有一个大致模板(C++版本),借鉴的力扣官方题解:
vector<int> t;
void dfs(int cur, int n) {
    if (cur == n) {
        // 记录答案...
        return;
    }
    // 考虑选择当前位置
    t.push_back(cur);
    dfs(cur + 1, n, k);
    t.pop_back();
    // 考虑不选择当前位置
    dfs(cur + 1, n, k);
}

下面是Java版本的完整解决代码:

class Solution {
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    List<Integer> temp = new ArrayList<Integer>();
    public List<List<Integer>> subsets(int[] nums) {
        dfs(0,nums);
        return ans;
    }
    public void dfs(int cur, int[] nums){
        if( cur == nums.length){
            ans.add(new ArrayList<Integer>(temp));
            return ;
        }
        temp.add(nums[cur]); //考虑下标cur元素在该子集中的情况
        dfs(cur + 1, nums);
        temp.remove(temp.size() -1); // 考虑下标cur元素不在该子集中的情况
        dfs(cur + 1, nums);
    }
}

77. 组合

77. 组合

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数组合
你可以按 任何顺序 返回答案。
在这里插入图片描述

递归

这里的数组nums就是[1~n],我们的目标是找到只有k个元素的集合。我们用之前找所有子集的代码模板,但是递归结束有两种可能:①已经有k个元素了,找到答案直接返回;②遍历完了整个数组了。 其实①是一种剪枝,即前面1~cur 的元素中已经包括k个元素了,cur后面元素涉及到的搜索空间就不探索了。

// 找到只包含k个元素的子集
if (temp.size() == k) {
        ans.push_back(temp);
        return;
    }
// 遍历完数组[1,2,3,...,n]
if (cur == n + 1) {
        return;
    }

还有一种可能:1~cur中只选择了m个元素,cur之后元素只有s个,而m+s< k,那么也可以直接忽略这种情况,因为这种情况下即便遍历完数组也找不到k个元素的集合了。

// 就算cur~n所有元素都选上,这个子集的元素也少于k个
if(temp.size() + (n - cur + 1) < k){
	return
}

有了这种情况 cur == n+1(遍历结束)这个约束条件可以删除。 因为1~cur中被选择的元素m个,可以明确m<=k。当m ==k时,已经被第一个约束条件结束递归了;如果cur == n+1的时候,m<k,那么早在cur=n+1之前就被第三个约束条件剪枝了。

Java代码如下:

class Solution {
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    List<Integer> temp = new ArrayList<Integer>();

    public List<List<Integer>> combine(int n, int k) {
        dfs(1, n, k);
        return ans;
    }
    public void dfs(int cur, int n , int k){
        if(temp.size() + (n - cur + 1) < k)
            return ;
        if(temp.size() == k){
            ans.add(new ArrayList<Integer>(temp));
            return ;
        }
        temp.add(cur); // 选择当前元素的情况
        dfs(cur + 1, n, k);
        temp.remove(temp.size() - 1); // 不选择当前元素的情况
        dfs(cur + 1, n, k);
    }
}
二进制枚举

用二进制枚举找所有子集,增加一个子集元素数量为k的判断即可。

class Solution {
    List<Integer> temp = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> combine(int n, int k) {
        // 利用mask找到所有的子集(幂集)
        for(int mask = 0; mask < (1<<n); mask++ ){
            temp.clear();
            for(int i = 0; i < n; i++)
                if( (mask & (1<<i)) != 0)
                    temp.add(i+1);
            // 判断该子集元素个数是否满足要求(k个)
            if(temp.size()==k)
                ans.add(new ArrayList<Integer>(temp));
        }
        return ans;
    }
}
⭐字典序法枚举

上面用二进制枚举法,遍历了所有的子集,这是没有必要的,因为我们只需要仅包括k个元素的子集。
那么只包括k个元素,即n位的mask,只有k个位置为1。我们要怎么得到这些mask呢?——用字典序列。 比如6位的mask只有3位为1,从000111开始:000111→001011→001101→001110→010011→010101→010110→011001→011010→011100→100011……→111000
初始时二进制掩码的最低k位全为1,最终结束的时候是mask最高k位全为1。代码如何实现这个字典序列变化的过程呢? 首先我们用一个长度为k+1的数组temp表示结果数组,初始赋值为[1,2,3,…,k,n+1],最后一位是哨兵位,用于最后判断遍历结束。 如果temp[j] + 1 ≠ temp[ j + 1],说明temp[j]和temp[j+1]不连续,即二进制掩码的第temp[j]和temp[j+1]位为1,且不连续。那么二进制掩码中temp[j]位的1可以向高位移动一维,对应temp数组中temp[j] = temp[j] +1;而mask中temp[j]位之后的1全部移动到最低位,对应temp数组中下标0~j-1的数变成[1,2,3,…,j-1]。
完整Java代码如下:

class Solution {
    List<Integer> temp = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    public List<List<Integer>> combine(int n, int k) {
        // 将 temp 中 [0, k - 1] 每个位置 i 设置为 i + 1,即 [0, k - 1] 存 [1, k]
        for (int i = 1; i <= k; ++i) {
            temp.add(i);
        }
        temp.add(n + 1); // 末尾加一位 n + 1 作为哨兵
        
        int j = 0;
        while (j < k) {
            ans.add(new ArrayList<Integer>(temp.subList(0, k)));
            j = 0;
            // 寻找第一个 temp[j] + 1 != temp[j + 1] 的位置 t
            // 我们需要把 [0, j- 1] 区间内的每个位置重置成 [1, j]
            while (j < k && temp.get(j) + 1 == temp.get(j + 1)) {
                temp.set(j, j + 1);
                ++j;
            }
            // j 是第一个 temp[j] + 1 != temp[j + 1] 的位置
            temp.set(j, temp.get(j) + 1);
        }
        return ans;
    }
}

216. 组合总和 III

216. 组合总和 III

找出所有相加之和为 nk 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
在这里插入图片描述
这里的数组nums就是[1,2,3,4,5,6,7,8,9],要求是找包括k个元素且元素和为n的子集。

二进制枚举

我们可以枚举[1~9]所有子集,但是增加两个判断条件:①只包括k个元素;②元素和为n。

class Solution {
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    List<Integer> temp = new ArrayList<Integer>();
    public List<List<Integer>> combinationSum3(int k, int n) {  
        for(int mask = 0 ; mask < (1 << 9); mask++){
            if(check(mask,k,n))
                ans.add(new ArrayList<Integer>(temp));
        }
        return ans;
    }
    public boolean check(int mask,int k,int n){
        temp.clear();
        for(int i = 0; i < 9 ; i++){
            if(((mask>>i) & 1) != 0 )
                temp.add(i+1);
        }
        // 先判断元素数量是否为k
        if(temp.size() != k)
            return false;
        
        int sum = 0;
        for(int num : temp)
            sum += num;
        return n == sum; // 再判断和是否为sum
    }
}
字典序法枚举

这里遍历判断所有的子集,其实是有浪费时间的,因为我们只需要k个元素的子集,再去判断其和即可。 因此可以用上述77题的方法,只找k个元素的子集,再增加一个sum == n的判断:

class Solution {
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    List<Integer> temp = new ArrayList<Integer>();
    public List<List<Integer>> combinationSum3(int k, int n) { 
        int sum =0; // 记录包含k个元素的子集的和
        for(int i = 0;i < k; i++){
            temp.add(i+1);
            sum += i+1;
        }
        temp.add(10);
        int j = 0;
        while(j < k){
            if(sum==n){ // 新增判断条件
                ans.add(new ArrayList<Integer>(temp.subList(0,k)));
            }
            j = 0;
            while(j < k && temp.get(j)+1 == temp.get(j+1)){   
                sum -=  temp.get(j);          
                temp.set(j,j+1);
                sum += j+1;
                j++;                
            }
            temp.set(j,temp.get(j)+1);
            sum += 1;           
        }
        return ans;
    }
}
递归

这里要遍历的数是1~9,依旧是上面的模板,dfs遍历每个数的时候都有两种情况:选择OR不选择。 递归结束条件是:找到了包括k个元素的子集,如果子集元素和为n就添加这个temp到结果数组中,否则什么也不做:

class Solution {
    List<Integer> temp = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> combinationSum3(int k, int n) {
        dfs(1, 9, k, n);
        return ans;
    }
    public void dfs(int cur, int n, int k, int sum) {
    	// 剪枝
        if (temp.size() + (n - cur + 1) < k) {
            return;
        }
        // 递归结束
        if (temp.size() == k) {
            int tempSum = 0;
            for (int num : temp) {
                tempSum += num;
            }
            // 判断和是否满足条件
            if (tempSum == sum) {
                ans.add(new ArrayList<Integer>(temp));   
            }
            return;
        }
        temp.add(cur); //子集中添加cur
        dfs(cur + 1, n, k, sum);
        temp.remove(temp.size() - 1); // 子集中不添加cur
        dfs(cur + 1, n, k, sum);
    }
}

👇另一种递归思路
之前是遍历数组,以数组遍历结束为递归结束条件。现在递归结束条件是找到包括k个元素的子集,同时满足和为n。 递推关系:前一轮temp[j] = a,那么temp[j+1]可以在[a+1~9]之间选择。实际上temp[0]可以在[1~9]中任意选择,假设temp[0] = 3,就默认1、2不会出现在这个子集中。完整代码如下:

class Solution {
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    List<Integer> output = new ArrayList<Integer>();
    public List<List<Integer>> combinationSum3(int k, int n) {       
        backstrack(1,0,k,n,0);
        return  ans;  
    }
    public void backstrack(int loca, int num,  int k ,int n, int sum){
        if(num + (9 - loca +1) < k)
            return ;
        // k个数且和为n,加入该组合并返回
        if(num == k && sum == n){
            ans.add(new ArrayList<Integer>(output));
            return ;
        }
        // k个数,但是和不会n,直接返回
        if( num == k)
            return ;
        // 和为n但是不够k个数,直接返回
        if(sum == n)
            return ;

        // 既不够k个数,和也不够n,继续递归寻找
        for(int i = loca; i <= 9; i++){
            if(sum + i <= n){
                output.add(i);
                backstrack(i + 1, num + 1, k, n, sum + i);
                output.remove(num);
            }
        }
    }
}

实际这两种递归思路的区别在于,之前一种是在遍历数组[1~9],判断nums[i]选择or不选择;现在是递归k次,确定找长度为k的子集,temp[i]可以在[temp[i-1] + 1~9]之间任意选择。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值