DAY27:回溯算法(二)组合问题及其优化

news2024/12/27 11:17:10

文章目录

    • 77.组合(一定要注意逻辑问题)
      • 思路
        • for循环嵌套的情况
        • 回溯算法模拟for循环K层嵌套
      • 回溯法步骤
      • 伪代码
      • 完整版
      • debug测试
        • 逻辑问题:没有输出
        • 逻辑问题:为什么是递归传入i+1而不是startIndex+1?
          • 重要:为什么会输出[2,2],[3,2],[4,2]?
      • 注意startIndex这种用法
        • 特别注意:
    • 77.组合(剪枝优化)
      • 剪枝思路:
      • 伪代码
        • 原本的单层递归
        • 列式计算循环终止条件
        • 优化之后的for循环:
      • 优化后的完整版
      • 这种剪枝操作节省了多少时间复杂度?
      • 剪枝操作终止条件计算思路

77.组合(一定要注意逻辑问题)

  • 本题控制起点的搜索方式比较重要,同时一定要注意每层递归中参数的值,可能和我们认为的累加是不一样的!有的参数递归中累加,但是返回到第一层进行回溯的时候,参数值仍然是初始值,这时候就有可能出现重复的逻辑错误!
  • 不容易出错的累加方式是在传入参数的时候直接传入经历了for循环的i,用于新的搜索起点

给定两个整数 nk,返回范围 [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]]

提示:

  • 1 <= n <= 20
  • 1 <= k <= n

思路

for循环嵌套的情况

如果我们只考虑n=size大小的数组中取k=2个元素的组合情况,可以用两个for循环来写:

for(int i=0;i<=nums.size();i++){
    for(j=i+1;j<nums.size();i++){
        cout<<nums[i]<<","<<nums[j]<<endl;
    }
}

如果k=2,我们确实可以用两层for循环来解决。

如果k=3,我们可以再加一层for循环,比如:

for(int i=0;i<=nums.size();i++){
    for(j=i+1;j<nums.size();i++){
        for(int k=j+1;k<nums.size();k++){
            cout<<nums[i]<<","<<nums[j]<<","<<nums[k]<<endl;
        }  
    }
}

但是,如果集合更大,k=50的时候,我们不能写50个for循环来寻找这个子集。

也就是说,此时直接的for循环嵌套已经无法实现了。

回溯算法模拟for循环K层嵌套

我们需要使用回溯算法来模拟,实际上回溯算法也是模拟了这样的过程

回溯算法通过递归来控制有多少层for循环,每一层递归都是一个for循环

我们画出k=2情况下的树形结构,如图:

在这里插入图片描述

可以看出,我们所有的结果都放在叶子节点上

在2的分支里,我们如果不删掉1,就会多出来2 1这个分支,但是2 1和前面的1 2重复了,因为本题是组合而不是排列如果是排列的话,子树节点需要留下1

同时组合的元素不可以有重复,所以子节点的2本身也不能有。

在这里插入图片描述
也就是说,我们取2,剩余集合就是2后面的。取3,剩余集合就是3后面的。

为了达到这个效果,我们需要通过每次递归传入startIndex参数,来控制每次搜索的起始位置

回溯法步骤

  • 回溯的第一步,仍然是先确定递归函数的参数和返回值
  • 第二步是确定递归的终止条件
  • 确定单层搜索的逻辑,单层搜索的逻辑其实就是单层递归的逻辑

伪代码

  • 依照树形结构来写,本质上我们求的组合,其实就是一个个路径!求组合的过程就是求路径的过程。
  • startIndex控制搜索起点
//参数:一个组合就是一个一维数组path,还需要一个二维数组result来存放组合结果
//这两个参数可以放全局变量也可以不放,参数过多影响可读性可以放全局
//除此之外还需要n和k,还需要startIndex,来控制搜索起点
void backtracking(vector<int>&path,vector<vector<int>>&result,int n,int k,int startIndex){
    //终止条件,找到了大小为K的组合终止
    if(path.size()==k){
        result.push_back(path);
        return;
    }
    //单层递归逻辑,也就是单层搜索逻辑
    //树形结构中每一个节点都是一个for循环,从startIndex开始遍历剩余元素
    //n就是传入集合的大小
    for(i=startIndex;i<=n;i++){
        //path搜索路径上的元素,先把第一个放进来
        path.push_back(i);
        //下一层递归,i+1和startIndex+1?
        backtracking(path,result,n,k,startIndex+1);
        //回溯,把之前的元素pop出去
        //也就是说,取到[1,2]之后就把2弹出!才能继续取3和4!
        path.pop();
    }
    return;
}

完整版

  • 因为还需要传入控制搜索起点的参数startIndex,因此我们需要单独写函数来进行回溯。
  • 注意传入的数组就是闭区间[1,n]里的所有数字所以不需要考虑下标问题,因为传入的并不是数组,而是闭区间[1,n]内的所有整数。
  • 递归的过程中,重点是需要让for循环不再遍历之前已经遍历过的数字!而当把1pop出去,开始遍历2开头元素的时候,我们一定要注意,startIndex在第一层递归的时候,它的值一直都是1!所以后面遍历每一个不以1开头的都会出问题,因为这一层的startIndex一直都是从2开始找的
//这里如果用值传递就会没有输出,因为改的是副本,没输出一定要考虑是不是传递错了
void backtracking(vector<int>&path,vector<vector<int>>&result,int n,int k,int startIndex){
    //终止
    if(path.size()==k){
        result.push_back(path);
        return;
    }
    //单层递归
    for(int i = startIndex;i<=n;i++){
        //加入路径
        path.push_back(i);
        //找到i开头的所有组合 12 13 14
        backtracking(path,result,n,k,i+1);
        //回溯,去掉1开始找2开头的,如果传入startIndex+1那么找2开头的就会出问题了
        path.pop();
    }
    return;
}
vector<vector<int>> combine(int n, int k) {
	vector<int>path;
    vector<vector<int>>result;
    int startIndex = 1;
    backtracking(path,result,n,k,startIndex);
    return result;
}

debug测试

  • 注意结果集一定要用引用传递

逻辑问题:没有输出

传入数组的时候用了值传递,值传递只会修改副本!

如果使用了值传递,当 backtracking 函数返回时,任何对 result 的修改都不会影响到函数外部的 result,也就不会影响到最终的结果。也就是说主函数的result仍然是空的,所以输出为空

应将result改为引用传递。

在这里插入图片描述

逻辑问题:为什么是递归传入i+1而不是startIndex+1?

如果递归参数引用写成了startIndex+1,那么会导致以下输出:

在这里插入图片描述
我们会发现多输出了几个结果,包括[2,2],[3,2],[3,3],[4,2],[4,3],[4,4]。

重要:为什么会输出[2,2],[3,2],[4,2]?

因为递归的过程中,重点是需要让for循环不再遍历之前已经遍历过的数字!而当回到第一层递归,把1pop出去,开始遍历2开头元素的时候,我们一定要注意,startIndex在第一层递归的时候,它的值一直都是1!所以后面遍历每一个不以1开头的都会出问题,因为这一层的startIndex一直都是从2开始找的

所以,才会出现[2,2],[3,2],[4,2]这样的结果,因为返回第一层递归开始确认下一个元素的时候,startIndex的值是这一层递归的值!也就是第一层递归startIndex一直=1!!

注意startIndex这种用法

startIndex并不是通过本身的值改变来控制搜索起点!startIndex是一个为了改变for循环初始值设定的参数!每一次递归都会更新startIndex,为了让for循环能够跳过已经包含过的元素,从新的位置开始

startIndex这个参数被赋值为i+1的时候,我们就相当于控制了for(i=startIndex,i<n;i++)这个循环的起点!只要我们每次令startIndex=i+1再传入递归之中,我们就可以保证结果中不再存在已经在前面的for循环中处理过的数字,比如输第一位是2的时候2的下一位不会是2本身!

特别注意:

虽然 直接传入startIndex+1看似也能完成这个效果,但是只能在递归第一个元素的时候完成 !只有当我们传入第一个元素的时候,才能保证第一个元素后面跟着的元素和第一个本身不同。但是,当我们把第一个元素pop出去,开始搜索第二个元素为首位的组合时,startIndex还保留了递归第一层的时候的那个初始值,也就是1,那么此时还是会遍历第二个元素!因为第一层的startIndex的初值并没有变化!

每一层递归都有它自己的 startIndex,而这个值在这一层递归中是不会改变的。因此,当我们在递归调用中使用 startIndex+1 时,实际上是在使用当前递归级别的起始索引加一,而不是 path 中最后一个元素的下一个值

所以,当我们想要调用path 中最后一个元素的下一个值的时候,我们一定要用i+1,而不是startIndex+1。这就是我的逻辑错误,从第二个元素开始输出重复组合的原因。

77.组合(剪枝优化)

回溯法虽然是暴力搜索,但是有时候也是可以剪枝优化的。

  • 剪枝优化的重点就在于,如果剩下的元素小于还需要被拿出的元素,那么就可以结束循环了。
  • 当我们无法一下子列出关于i的结束条件,我们可以考虑先列式再左右移项

剪枝思路:

例子:n=4, k=4 时候的情况

在这里插入图片描述

当我们取2的时候,剩余的元素是3和4,此时一共就只有3个元素怎么搜也不可能搜到path.size()==k的情况了

这个情况就可以做剪枝,也就是如下图所示的情况

在这里插入图片描述
因为剪枝剪掉的是有深度的分支,所以说节省了一部分时间开销。递归深度的示例如下图所示:

在这里插入图片描述
如果没有做剪枝的话,回溯算法会搜索整个树形结构

完整的树形结构 via代码随想录:

在这里插入图片描述

伪代码

  • 剪枝优化,优化的是单层搜索的逻辑,只修改单层搜索代码即可
  • 树形图的每一个节点都是for循环的一个过程

原本的单层递归

void backtracking(int startIndex,int n,int k){
    //直接开始写单层递归
    for(i=startIndex;i<=n;i++){
        path.push_back(i);
        backtracking(i+1,n,k);
        path.pop_back();
    }
}

在原来的递归中,遍历了所有的子孩子,但是实际上可以不遍历这么多的子节点,比如上图的情况,234都不需要遍历。

因此如果要做剪枝,我们就需要在循环结束条件i<=n这里做文章。

剪枝的情况是n=4, k=4,取出第一个元素1之后,再取2,后面剩下的需要被选取的元素就不够了。path是已经被选取的元素个数,还剩下需要被选取的元素个数是k-path.size(),得到我们还需要选取的元素个数

之后,我们需要计算,元素选取至多从哪个位置开始。至多是指,例如n=4, k=3,那么至多从2开始选取元素,再往后就不可能再有符合要求的了。也就是说,我们需要计算剩余可选的元素的上限索引

列式计算循环终止条件

n是总的元素数量,k是目标组合的长度,path.size()是当前已经选择的元素数量(k - path.size()) 就是还需要选择的元素数量。如果剩余的元素数量,从i到n的元素,也就是n-i+1<(k - path.size()),也就是小于还需要选择的元素数量,那么就不可能再构建出长度为k的组合了。

因此,我们得到的终止条件就是n-i+1<(k - path.size()),也就是i>n-(k-path.size())+1是不满足条件的。所以终止条件应该改成:

i<=n-(k-path.size())+1

当我们很难一下子得到i的循环终止条件的时候,可以尝试先列式子再左右移项

优化之后的for循环:

for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置

优化后的完整版

void backtracking(vector<int>&path,vector<vector<int>>result,int n,int k,int startIndex){
    //终止
    if(path.size()==k){
        result.push_back(path);
        return;
    }
    //单层递归
    for(int i = startIndex;i<=n - (k - path.size()) + 1;i++){
        //加入路径
        path.push_back(i);
        //找到i开头的所有组合 12 13 14
        backtracking(path,result,n,k,i+1);
        //回溯,去掉1开始找2开头的,如果传入startIndex+1那么找2开头的就会出问题了
        path.pop();
    }
    return;
}
vector<vector<int>> combine(int n, int k) {
	vector<int>path;
    vector<vector<int>>result;
    int startIndex = 1;
    backtracking(path,result,n,k,startIndex);
    return result;
}

这种剪枝操作节省了多少时间复杂度?

在没有剪枝的版本中,递归会遍历所有可能的路径。在有剪枝的版本中,当知道某条路径不可能达到目标时,就会停止沿这条路径的搜索,转而搜索其他可能的路径。也就是说,如果当前的路径长度已经达到k,就不必再继续搜索。如果还需要选择的元素数量超过剩余元素数量,也不必继续搜索。这就是剪枝。

从时间复杂度的角度来看,剪枝并不能降低算法的时间复杂度!回溯法的时间复杂度是O(n!),这是因为需要遍历所有可能的路径。尽管剪枝可以减少搜索的路径数量,但不会改变时间复杂度的数量级,所以时间复杂度仍然是O(n!)

但是,剪枝可以显著减少实际运行时间和空间开销。因为剪枝可以避免搜索那些明显无法达到目标的路径,因此可以大大减少搜索的路径数量,从而减少计算量。具体节省了多少开销,要根据输入的具体情况来判断,比如n和k的具体值,以及剪枝策略的有效性等。

剪枝操作终止条件计算思路

如果我们不能立即得出循环终止条件,可以通过列出所有相关因素的数学关系来解决。

比如在这个例子中,需要确保剩余的元素足够多,以便构建长度为k的组合。然后,可以将这个逻辑关系式表示为数学式子:

需要的元素数量 <= 剩余的元素数量

即可得到:(k - path.size()) <= n - i + 1

然后解这个式子,再得到i的范围。通过这种方式,我们可以从问题的逻辑关系中得出终止条件剪枝的具体实现。这是一个非常有效的问题解决方法。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/668697.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Java-API简析_java.lang.CharSequence接口(基于 Latest JDK)(浅析源码)

【版权声明】未经博主同意&#xff0c;谢绝转载&#xff01;&#xff08;请尊重原创&#xff0c;博主保留追究权&#xff09; https://blog.csdn.net/m0_69908381/article/details/131318474 出自【进步*于辰的博客】 其实我的【Java-API】专栏内的博文对大家来说意义是不大的。…

Unreal 5 实现场景

如果你拿到了一个新的场景&#xff0c;想将此场景应用到游戏当中&#xff0c;首先需要给敌人增加ai移动路径&#xff0c;需要添加导航体积 添加导航模型包围体积 添加了体积以后&#xff0c;设置包围盒的大小&#xff0c;将敌人可以行进的区域给区分出来&#xff0c;然后按键盘…

PyCharm2023开发工具activice教程(包含工具link)

PyCharm2023 前言1. 下载工具2. 选择安装方法33. 填入active code4. 效果如下 前言 PyCharm是一款由JetBrains开发的强大的Python集成开发环境&#xff08;IDE&#xff09;。它提供了丰富的功能和工具&#xff0c;旨在提高Python开发者的生产力和效率。 以下是PyCharm的一些主…

Linux操作系统体系结构 ( 3 ) -【Linux通信架构系列 】

系列文章目录 C技能系列 Linux通信架构系列 C高性能优化编程系列 深入理解软件架构设计系列 高级C并发线程编程 期待你的关注哦&#xff01;&#xff01;&#xff01; 现在的一切都是为将来的梦想编织翅膀&#xff0c;让梦想在现实中展翅高飞。 Now everything is for the…

FTP服务器

文章目录 FTP服务器FTP的数据传输原理FTP的功能简介不同等级的用户身份命令记录与日志文件记录限制用户活动的目录 FTP的工作流程与使用到的端口FTP主动式连接FTP被动式连接 vsftpd服务器基础设置为什么使用vsftpd所需要的软件以及软件结构vsftpd.conf 配置值说明与服务器环境比…

【入门向】CV 小白如何入门?人脸识别教程带你学习计算机视觉

导言 计算机视觉作为人工智能领域的一个重要分支&#xff0c;旨在让计算机能够理解和解释图像和视频数据。而OpenCV作为一款开源的计算机视觉库&#xff0c;为开发者提供了丰富的工具和函数&#xff0c;用于处理图像、视频、对象检测、特征提取等任务。对于初学者来说&#xf…

chatgpt赋能python:如何在Python中捕获kill信号

如何在Python中捕获kill信号 在编写Python代码时&#xff0c;我们可能需要处理一些长时间运行的进程。有时候&#xff0c;我们会在运行这些进程时使用kill命令杀死它们。然而&#xff0c;Python进程是否可以捕获kill信号呢&#xff1f;答案是肯定的。 在本文中&#xff0c;我…

基于pyqt5、mysql、yolov7、chatgpt的小麦病害检测系统v1.0

基于pyqt5、mysql、yolov7、chatgpt的小麦病害检测系统设计与实现 一、界面设计1.1安装pyqt51.2创建用户子窗体1.3创建管理员主窗体1.4创建管理员子窗体1.5创建系统登陆界面 二、环境搭建2.1pyqt5工具配置2.2mysql5.7安装 三、编程实现3.1初始化数据库3.2创建用户数据库sdk文件…

chatgpt赋能python:Python如何快速提取指定行和列的数据?

Python如何快速提取指定行和列的数据&#xff1f; 在进行数据分析和处理时&#xff0c;常常需要从海量数据中筛选出所需的数据。这时&#xff0c;Python是一款非常强大的工具&#xff0c;可以方便地进行大规模数据清洗和筛选。本文将介绍如何使用Python快速提取指定行和列的数…

【JVM篇】手撸上万字带你吃透“垃圾回收”

前言&#xff1a;大家好&#xff0c;我是TwosJel&#xff0c;一名21级的本科生(*^▽^*)&#xff0c;最近二刷了《深入理解Java虚拟机》&#xff0c;因此想写一篇关于垃圾回收的随笔&#xff0c;于是便有了这篇文章❥(^_-)。 个人主页&#xff1a;TwosJel 个人介绍&#xff1a…

JWT --- 入门学习

1.常见的认证机制 basic auth &#xff1a; 每次请求都会携带用户的username&#xff0c;password&#xff0c;易被黑客拦截。 Cookie auth : 我们请求服务器&#xff0c;创建一个session对象,客户端创建cookie对象。客户端每次访问&#xff0c;携带cookie对象。 (在当今&…

chatgpt赋能python:Python排队:提高效率、优化流程的神器

Python排队&#xff1a;提高效率、优化流程的神器 随着科技的不断进步&#xff0c;排队已经成为了现代生活中不可避免的一部分。在各个行业中&#xff0c;排队都是必须考虑的问题&#xff0c;包括餐馆、医院、机场和银行等等。针对排队问题&#xff0c;我们可以使用Python编程…

使用Vue + FormData + axios实现图片上传功能实战

前言 上节回顾 上一小节中&#xff0c;我们添加了Vue-router的路有数据&#xff0c;这些数据都将是后续实战课程中的真实路由数据了。同时引入了ElementUI的el-menu做为左侧菜单的组件&#xff0c;但本专栏的特点就是遇到第三方功能和组件&#xff0c;自己尽量也要实现一遍&a…

蓝牙ATT协议介绍

介绍 ATT&#xff0c;Attribute Protocol&#xff0c;用于发现、读、写对端设备的协议(针对BLE设备) ATT允许蓝牙远程设备&#xff08;比如遥控器&#xff09;作为服务端提供拥有关联值的属性集&#xff0c;让作为客户端的设备&#xff08;比如手机、电视&#xff09;来发现、…

【软件工程】软件工程期末考试试卷

瀑布模型把软件生命周期划分为八个阶段&#xff1a;问题的定义、可行性研究、软件需求分析、系统总体设计、详细设计、编码、测试和运行、维护。八个阶段又可归纳为三个大的阶段&#xff1a;计划阶段、开发阶段和( C)。 A、详细计划 B、可行性分析 C、 运行阶段 D、 测试与排…

JavaScript中的CRUD操作指南示例 - 用DHTMLX创建医院管理系统!

创建、读取、更新和删除(CRUD)是现代web和移动应用程序执行的四个基本功能。然而这些函数是如何产生的&#xff0c;它们到底是做什么的&#xff1f; 在本文中&#xff0c;我们将简要介绍CRUD的含义以及它何时被引入编程的。文中我们还将使用用于医院管理的JavaScript演示应用程…

图文并茂spring-boot3 热部署配置(IntelliJ IDEA 2023.1)

文章目录 &#x1f95a; 版本情况&#x1f9c2; 前言&#xff08;踩坑&#xff09;&#x1f357; 四步完成spring-boot热部署&#x1f957; 1、下载热部署模块&#x1f957; 2、application.yml 或者application.properties添加dev-tools配置&#x1f957; 3、settings中勾选条…

设计服务要考虑的7个维度

我在《软件设计的核心方法及实例解析》里提到软件设计的核心方法是分解和组合。分解粒度上&#xff0c;不同的架构师想法不一样&#xff0c;但是却有一点共性&#xff1a;设计一定要把不稳定的部分做封装&#xff0c;对外暴露稳定的部分&#xff0c;这也是有接口隔离这一原则的…

VS code 可以做什么?

编写 markdown VS code 真的是非常好用的Markdown编写工具&#xff0c;我用他来编写Markdown的时间甚至比写代码还要多。比如&#xff0c;我每周写的公众号文章。 相关插件&#xff1a; MarkdownMarkdown Preview EnhancedMarkdown All in One 编写python 大多数同学写pyth…

LLM - 基于 ChatGLM-6B 的工程配置搭建私有 ChatGPT 中文在线聊天

欢迎关注我的CSDN&#xff1a;https://spike.blog.csdn.net/ 本文地址&#xff1a;https://blog.csdn.net/caroline_wendy/article/details/131104546 Paper&#xff1a;GLM: General Language Model Pretraining with Autoregressive Blank Infilling 一篇于2022年发表在ACL会…