力扣刷题Day21

文章详细介绍了回溯算法的概念,它作为递归的副产品,通常用于解决组合、切割、子集和排列等搜索问题。回溯法通过构建树形结构来解决问题,具有穷举性质但可通过剪枝优化提高效率。文中以组合问题为例,展示了回溯法的模板和剪枝优化策略,并提供了具体的代码实现。
摘要由CSDN通过智能技术生成

回溯算法理论基础

回溯算法 - 

一种搜索方式。

回溯是递归的副产品,只要有递归就会有回溯。 

递归函数的下面就是回溯的过程。

一般说回溯函数就是递归函数。

回溯搜索法的效率:纯暴力,穷举「并不高效」。 --- 因为有些问题很复杂,只能穷举,最多剪枝。

一般可以解决如下几种问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合

  • 切割问题:一个字符串按一定规则有几种切割方式

  • 子集问题:一个N个数的集合里有多少符合条件的子集

  • 排列问题:N个数按一定规则全排列,有几种排列方式

  • 棋盘问题:N皇后,解数独等等

如何理解?抽象为树形结构

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

递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

回溯法的模板:

回溯三部曲

  • 回溯函数模板返回值以及参数(回溯算法中函数返回值一般为void。回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。)

void backtracking(参数)
  • 回溯函数终止条件(什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来- 收集结果,并结束本层递归。)

if (终止条件) {
    存放结果;
    return;
}
  • 回溯搜索的遍历过程

在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。

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

参考资料:https://programmercarl.com/回溯算法理论基础.html#题目分类大纲如下

77. 组合

力扣链接:https://leetcode.cn/problems/combinations/

这道题用暴力的方式:如果是k=50的情况,那么就需要50个for循环嵌套,那么是很难实现的。

因此只能考虑回溯。

组合问题抽象为如下树形结构:

n相当于树的宽度,k相当于树的深度

可以看出这棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不再重复取。

第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。

每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围

图中可以发现n相当于树的宽度,k相当于树的深度

图中每次搜索到了叶子节点,我们就找到了一个结果

相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。

依照回溯三部曲「for循环中嵌套一个递归思路」:

  • 回溯返回值以及参数:

需要两个全局变量 - 一个存放所有的结果集;一个存放单个集合的结果集「可以将这两个变量放入回溯方法中,但是影响代码可读性 - 适合作为全局变量

List<List<Integer>> result;
List<Integer> list;

返回值:void 

参数:int k 「组合的大小」,int n「右边的位置」,int left「左边的位置 - 确定从那个地方开始取数(搜索的起始位置)」

  • 回溯的终止条件:如果list.size = k,那就将这个结果储存进集合,然后返回。

  • 回溯的单层逻辑:使用for循环(int left; left <=n; left++) - 将i放入list,然后递归backtraking(k, n, i+1);回溯:删除list中最后一个元素 - list.remove(list.size()-1)。

具体代码实现:

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        result = new ArrayList<>();
        list = new ArrayList<>();
        backtraking(n, k, 1);
        return result;
        
    }
    
    List<List<Integer>> result;
    List<Integer> list;

    public void backtraking(int n, int k, int left){
        //终止条件
        if(list.size() == k){
            result.add(new ArrayList<>(list));//需要注意 - 不能添加list,这是一个引用地址;需要新建一个ArrayList存放list中的数据;list是会改变的
            return;
        }
        
        //单层处理逻辑
        for(int i=left; i<=n; i++){
            list.add(i);
            //递归
            backtraking(n, k, i+1);
            //回溯
            list.remove(list.size()-1);
        }
    }
    
}

注意在存放结果集的时候不要存放引用地址。

参考资料:https://programmercarl.com/0077.组合.html#回溯法三部曲

剪枝优化

这里也有一个可以考虑的剪枝优化:

可以看出来,在[1234]集合中,当取4的时候,这个集合就是空的。

因此是可以剪枝的 - 对于搜索效率的提升是很大的。

那么怎么确定这个剪枝的范围呢?

所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置

如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了

优化过程如下:

  1. 已经选择的元素个数:list.size();

  1. 所需需要的元素个数为: k - list.size();

  1. 列表中剩余元素(n-i) >= 所需需要的元素个数(k - path.size())

  1. 在集合n中至多要从该起始位置 : i <= n - (k - path.size()) + 1,开始遍历

所以优化之后的for循环是:

for(int i = startIndex; i <= n -(k - path.size())+1; i++)

具体代码:

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        result = new ArrayList<>();
        list = new ArrayList<>();
        backtraking(n, k, 1);
        return result;
        
    }
    
    List<List<Integer>> result;
    List<Integer> list;

    public void backtraking(int n, int k, int left){
        //终止条件
        if(list.size() == k){
            result.add(new ArrayList<>(list));//需要注意 - 不能添加list,这是一个引用地址;需要新建一个ArrayList存放list中的数据;list是会改变的
            return;
        }
        
        //单层处理逻辑
        for(int i=left; i<=n-(k-list.size())+1; i++){//剪枝优化
            list.add(i);
            //递归
            backtraking(n, k, i+1);
            //回溯
            list.remove(list.size()-1);
        }
    }
    
}

216.组合总和III

力扣链接:https://leetcode.cn/problems/combination-sum-iii/

不能使用暴力的原因:不能控制要嵌套几层for循环 - 考虑回溯

这里的条件是:集合是固定的 - 从1到9;求和为n,size为k

本题k相当于树的深度,9(因为整个集合就是9个数)就是树的宽度。

回溯三部曲:

  • 确定回溯返回值以及输入参数:

两个全局变量:

List<List<Integer>> result;
List<Integer> list;

输入:backtraking(int n「求和值」, int k「集合要求大小」, int left「取值起始位置」, int sum「此时list求和大小」);

输出:void

  • 回溯终止条件:当list.size() == k时:如果sum == n,那么将list放入result,然后return;否则直接return;

  • 回溯单层逻辑:for(int i= left; i<=9; i++){list.add(i); sum+= i}; 递归 - backtraking(n, k, i+1; sum); 回溯:sum-= list.(list.size()-1); list.remove(list.size()-1);

这里也可以进行剪枝优化:

  1. 对遍历的树宽度进行优化:

深度为k,因此还需要的数是:k - list.size(); 

这里的元素总共为9,起始点为i --- 因此 9-i >= k - list.size(), 才需要往后遍历: i<=9-(k-list.size())+1

  1. 如果sum已经比n大了,那么就不用再往下进行回溯搜索了,因为后面都比n要大。 -- 注意在return之前需要进行回溯操作|或者放在终止条件中,就不用再回溯

代码具体实现1:

class Solution {
    public List<List<Integer>> combinationSum3(int k, int n) {
        result = new ArrayList<>();
        list = new ArrayList<>();
        backtraking(k, n, 0, 1);

        return result;
    }

    List<List<Integer>> result;
    List<Integer> list;
    public void backtraking(int k, int n, int sum, int left){
        //终止条件
        if(list.size() == k){
            if(sum == n){
                result.add(new ArrayList<>(list));
            }

            return;
        }

        for(int i=left; i<= 9-(k-list.size())+1; i++){//单层循环+剪枝优化1
            list.add(i);
            sum += i;

            //剪枝优化2
            if(sum > n){
                //先回溯
                sum -= list.get(list.size()-1);
                list.remove(list.size()-1);
                //再return

                return;
            }

            //递归
            backtraking(k, n, sum, i+1);
            //回溯
            sum -= list.get(list.size()-1);
            list.remove(list.size()-1);
        }
    }

}

代码具体实现2:

class Solution {
    public List<List<Integer>> combinationSum3(int k, int n) {
        result = new ArrayList<>();
        list = new ArrayList<>();
        backtraking(k, n, 0, 1);

        return result;
    }

    List<List<Integer>> result;
    List<Integer> list;
    public void backtraking(int k, int n, int sum, int left){
        //终止条件
        if(sum > n){ //剪枝优化2
                return;
            }

        if(list.size() == k){
            if(sum == n){
                result.add(new ArrayList<>(list));
            }

            return;
        }

        for(int i=left; i<= 9-(k-list.size())+1; i++){//单层循环+剪枝优化1
            list.add(i);
            sum += i;
            
            //递归
            backtraking(k, n, sum, i+1);
            //回溯
            sum -= list.get(list.size()-1);
            list.remove(list.size()-1);
        }
    }

}

参考资料:https://programmercarl.com/0216.组合总和III.html

17.电话号码的字母组合

题目链接:https://leetcode.cn/problems/letter-combinations-of-a-phone-number/

首先这道题是输入电话号码 - 然后给出可能的字母组合。

因此需要对数字 - 字母做一个映射

可以考虑的方式是 - 采用map;或者用数组。

有一个巧妙的方式是使用一维数组:对应下标储存对应的字符串

String[] map = {"", "", abc, def....}

这样直接使用mep[i]就能找到对应的字母。

同样,因为不能确定需要使用多少层的for循环进行嵌套 ---这道题需要用回溯算法。

树的深度是数字的个数;树的宽度是第一个集合中的字母数量

回溯三部曲:

  • 输出以及输入的参数:

两个全局变量:List<String>  result - 所有字符串组合; String s - 当前字符串

输出:void

输入:backtraking(String digits  - 输入字符串, int index - 当前遍历到数字的下标) - 这道题和之前77.组合不一样的是:之前是在一个集合里面遍历,因此需要确定第二层起始的遍历的位置;而这里是在不同的集合中进行比那里,因此需要确定的是第二层需要遍历的集合是哪一个。

  • 回溯的终止条件:if(index = digits.length()) - 当index等于字符串的长度时,s中的元素已经满了「这里index代表的是当前遍历到的数字的下标,因此当index = digits.length()的时候,已经把数字都遍历完了,此时index已经不在digits这个字符中了,也就结束了」。「也可以用其他的参数控制,比如说」

  • 回溯的单层逻辑:先找到数字的映射 letters,然后for(int i = 0; i<letter.length();i++) - 将letters.chatAt(i) 放入s中;递归 —— backtraking(digits, index+1); 回溯:删除s最后一个字符

具体代码实现:

class Solution {
    public List<String> letterCombinations(String digits) {
        result = new ArrayList<>();
        s = new StringBuffer();

        //判断一下空的情况
        if(digits == null || digits.length() ==0){
            return result;
        }

        backtraking(digits, 0);

        return result;
    }

    String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "qprs", "tuv", "wxyz"};
    List<String> result;
    StringBuffer s;
    public void backtraking(String digits, int index){
        //终止条件
        if(index == digits.length()){
            result.add(s.toString());
            return;
        }

        //获得映射字母串
        int d = digits.charAt(index) -'0';
        String m = map[d];

        for(int i=0; i< m.length(); i++){
            s.append(m.substring(i,i+1));
            
            //递归
            backtraking(digits, index+1);

            //回溯
            s.deleteCharAt(s.length()-1);
        }
    }
}

参考资料:https://programmercarl.com/0017.电话号码的字母组合.html#java

好的,关于力扣刷题C++常用操作,我可以给你一些常见的操作和技巧: 1. 使用 STL 容器和算法库:STL(Standard Template Library)是 C++ 标准库中的一个重要组成部分,包含了许多常用的容器和算法。在力扣刷题中,使用 STL 可以大大提高代码的效率和可读性。例如,vector 可以用来存储动态数组,sort 可以用来排序等等。 2. 使用 auto 关键字:auto 关键字可以自动推导变量类型,可以减少代码量和提高可读性。例如,auto x = 1; 可以自动推导出 x 的类型为 int。 3. 使用 lambda 表达式:lambda 表达式是 C++11 中引入的一种匿名函数,可以方便地定义一些简单的函数对象。在力扣刷题中,使用 lambda 表达式可以简化代码,例如在 sort 函数中自定义比较函数。 4. 使用位运算:位运算是一种高效的运算方式,在力扣刷题中经常会用到。例如,左移运算符 << 可以用来计算 2 的幂次方,右移运算符 >> 可以用来除以 2 等等。 5. 使用递归:递归是一种常见的算法思想,在力扣刷题中也经常会用到。例如,二叉树的遍历、链表的反转等等。 6. 使用 STL 中的 priority_queue:priority_queue 是 STL 中的一个容器,可以用来实现堆。在力扣刷题中,使用 priority_queue 可以方便地实现一些需要维护最大值或最小值的算法。 7. 使用 STL 中的 unordered_map:unordered_map 是 STL 中的一个容器,可以用来实现哈希表。在力扣刷题中,使用 unordered_map 可以方便地实现一些需要快速查找和插入的算法。 8. 使用 STL 中的 string:string 是 STL 中的一个容器,可以用来存储字符串。在力扣刷题中,使用 string 可以方便地处理字符串相关的问题。 9. 注意边界条件:在力扣刷题中,边界条件往往是解决问题的关键。需要仔细分析题目,考虑各种边界情况,避免出现错误。 10. 注意时间复杂度:在力扣刷题中,时间复杂度往往是评判代码优劣的重要指标。需要仔细分析算法的时间复杂度,并尽可能优化代码。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值