C++ 算法主题系列之论递归回溯算法与原始穷举算法的一致性
迪丽瓦拉
2025-05-28 04:50:21
0

1. 前言

本文将和大家一起聊聊递归回溯算法原始穷举算法的内在关系,意在透过现象看本质,深入递归回溯算法的内在,真真切切看清楚其真面貌,而不仅限于套用算法模板。

回溯算法适用于如下的问题域:

  • 问题可以分解成多个步聚。
  • 每一步可有多个选择。
  • 求解问题域的多种方案或其中的最优方案。

假设求解某问题共需要 n个步骤、而每一步可有的选择为m,根据乘法原理,则符合问题的方案应该有 n*m。如下图所示:

24.png

数学上的排列组合问题便是典型的此类问题。

排列问题描述:如从 1,2,3,4,55 个数字中选择 n(n<=5) 个数字进行重新排列,请问共有多少排列方案,当n=5时称为全排列。

本文将从求解全排列的原始穷举算法开始,一路讨索原始穷举算法和回溯算法之间的一致性。这里的一致性指回溯算法本质就是原始穷举算法,当然,两者之间会有思想层面的差异性。

2. 原始穷举算法

什么是原始穷举算法?

理论上讲,算法只有一种,便是穷举,但是,人是智慧性的,可通过发现待处理数据之间的逻辑关系,从而改进算法,减少穷举的次数,提升算法性能。

原始穷举算法也是一种静态处理算法。

静态做何解释?

不急,且先看如何使用原始穷举算法求解 1,2,3,4,5 这几个数字的全排列。

穷举的基本思想是先确定数据范围,然后对其进行筛选。5个数字的全排列穷举算法实现流程如下:

  • 无论是哪一种排列方案,都需要找齐 5 个数字,说明每一种方案都需要分 5 步。如下图所示,每一次需要填满如下 5 个格间方算找出一种方案。

25.png

  • 第一步可以从1,2,3,4,5 中选择出一个数字。如下图先选择数字 1

26.png

  • 因全排列数字不能重复使用,故第二步只能从没有选择的数字中再选择一个。

27.png

  • 如此重复,至到完成后续步聚的填充。

28.png

如何编码实现?

必然是循环和条件分支语句上场,循环用来确定能查找的数字范围,条件分支进行筛选。

#include 
using namespace std;
int main(int argc, char** argv) {//统计总方案数量int count=0;//第一步可供选择数字范围为 1,2,3,4,5for(int i=1; i<=5; i++) {//确定第二步数字,可供选择数字范围为 1,2,3,4,5for(int j=1; j<=5; j++) {//因不能重复使用,需要排除if( j== i )continue;//第三步可供选择数字范围为 1,2,3,4,5,需要排除前面使用的for(int k=1; k<=5; k++) {if(k==j || k==i )continue;//第四步可供选择数字范围为 1,2,3,4,5,需要排除前面使用的for(int m=1; m<=5; m++) {if(m==j || m==i || m==k )continue;//第五步可供选择数字范围为 1,2,3,4,5,需要排除前面使用的for(int n=1; n<=5; n++) {if(n==j || n==i || n==k || n==m )continue;//计数count++;//到此,可以输出cout<

输出结果:

29.png

至此,解决了全排列问题,但是,有 2 个问题值得思考。

你是否看到了上述代码里有回溯?

Tips: 所谓回溯,指结束当前步聚,回到上一步,再次开始。

可认为代码中的每一个循环语句对应了问题中的每一步聚。如下图当第 5 次循环语句(第 5 步)结束后,会回溯到上一步,即第四步(第四个循环语句)。

Tips: 循环嵌套的特性:内层循环结束,重新进入外层循环判断,如果外层循环满足条件可以继续,新一轮内层循环也将开始。

30.png

31.png

同理,如果第四步(第 4 个循环语句)结束,会回溯到第 3 步(第 3 个循环语句)。一旦第三步确定好新数字,第四步和第五步会重新开始。如此反复,一步一步向上回溯再向下重新开始,便能找出所有方案。

32.png

思考后的结论:

  • 循环嵌套自带有回溯能力。
  • 根据循环嵌套的特点,所有内外循环次数的乘积便是最终求解的次数,可知第一步可供选择数字有 5 个,第二步有 4个,第三步有 3个,第四步有 2,第 5 步有 1 个。共计方案数为 5*4*3*2*1=120种,和代码输出结论一样。

**再思考第二个问题:**如果求 1,2,3,4,5,66个数字的全排列,或更多个数字的全排列又该如何?

显然,无法让上述代码在运行时,同时实现求 1~5数字和1~6数字的全排列,除非写 2 份代码。上文说过,原始穷举算法是静态的,所谓的静态性就在此,代码仅局限于一个具体问题的求解,而不是一类问题的求解。

33.png

3. 递归回溯算法

自然的想法,代码必须能根据问题需求动态扩展循环语句块,这时应该想到递归。

递归实现全排列动态化的流程:

  • 先求解在 1~55个数字中选择 1 个数字的排列方案。 这是一个简单的子问题,一个循环语句块就能解决。这次,循环语句块被封装在一个函数中。
#include 
using namespace std;
/*
* pos:描述需要排列的数字个数
* 此函数本质就是封装了循环语句结构
*/
void fillNumber(int pos){for(int i=1;i<=5;i++){cout<
  • 如果要在1~5个数字中选择 2 个数字进行排列。根据前面的理论,可以使用循环嵌套。

    fillNumber函数封装了循环语句,如果让此函数自己调用自己,不仅实现了循环嵌套,而且还是动态嵌套,甚至可以无限制的嵌套(一切由函数特性决定)。

    至此,便实现了动态扩展循环语法结构,想来,真是一件让人兴奋的事情。

void fillNumber(int pos){for(int i=1;i<=5;i++){cout<
  • 递归是需要有出口的,这里的限制很简单。如果5 个数字中选择 2 个数字,只需要 2 层循环嵌套便可。

    递归函数仅实现了动态循环嵌套,依然要遵循排列逻辑,仅当第一个数字和第二个数字确定后方能输出。

    因递归是函数间调用,上一级函数中的循环语句块中选择的数字要在下一级函数的循坏中使用,要么使用参数传递,要么使用全局变量。

如下使用参数传递方案:

#include 
using namespace std;
/*
* pos:描述需要排列的数字个数
* num:已经在上一轮中选择好的数字
*/
void fillNumber(int pos,int num) {for(int i=1; i<=5; i++) {if(pos==2)//选择好了 2 个数字,递归出口,输出cout<

输出结果:

34.png

代码中还是有几个问题需要解决:

  • 结果中出现了数字重复方案。这个问题容易解决,只需要添加如下一行代码便可,是不是很熟悉。
void fillNumber_(int pos,int num) {for(int i=1; i<=5; i++) {//本次选择不能和上次选择相同if(i==num)continue;//省略……}
}
  • 但是,当动态嵌套的循环(递归)层次很多时,通过参数传递选择好的数字是一件很繁琐的事情。如下图所示,类似于山顶的石块向山下滚动一下,越滚越多。会让函数的参数数量变得臃肿。

35.png

要解决这 2 个问题,可以使用全局变量:

  • 添加一个全局变量存储数字的使用状态,避免重复使用。
  • 添加一个全局变量存储选择的结果。

修改后代码如下:

#include 
using namespace std;
//数字的使用状态,初始为 0
int isUse[6]= {0};
//存储结果
int res[6]= {0};
//需要排列的数字个数
int num=2;
/*
*排列 
*/
void fillNumber(int pos) {for(int i=1; i<=5; i++) {if(isUse[i]==0) {//数字没有使用,填入结果res[pos]=i;//置为已用isUse[i]=1;if(pos==num) {//选择好了数字,递归出口,输出for(int j=1; j<=num; j++) {cout<

输出结果:

36.png

为什么输出的结果不完整?

如下图所示:

37.png

如果外层循环选择了数字 1 ,则内层循坏可以选择 2,3,4,5,且会对这几个数字做全局已经使用标记,当重新进入外层循坏时,已经没有数字可以。

所以代码中还差最后一环节,无论任何时候,当重回外层循坏时,需要把标记为已经使用的数字重新标记为初始未使用状态。

void fillNumber(int pos) {for(int i=1; i<=5; i++) {if(isUse[i]==0) {//数字没有使用,填入结果res[pos]=i;//置为已用isUse[i]=1;if(pos==num) {//选择好了所有数字,输出for(int j=1; j<=num; j++) {cout<

如此,使用递归实现的回溯算法便大功告成。

输出最终代码的测试结果:

38.png

小结:

  • 递归回溯算法中,递归充当了动态添加循环语句块的作用。
  • 当使用参数向下一级函数传递上一级函数结果时,不需要标记信息,但会增加代码的复杂度。
  • 递归回溯算法也不是什么新艳事物,不过是换了一个马甲做穷举,还是循坏嵌套这一套。
  • 原始穷举算法的性能本身就够寒碜,递归回溯算法的性能更寒碜。所以,递归回溯算法并不性能优化方案,而是静转动的实现方案,是类型解决方案。
  • 如需提升递归回溯算法的性能,可使用剪技。

4. 静态和动态

继续通过一个案例理解静态和动态的差异性,便能明白递归回溯算法为什么可以在算法界里独成一派。

问题描述:素数分解。

素数,又称质数,是指除 1和其自身之外,没有其他约数的正整数。例如 2、3、5、13 都是质 数,而 4、9、12、18 则不是。 虽然素数不能分解成除 1和其自身之外整数的乘积,但却可以分解成更多素数的和。你需要编程 求出一个正整数最多能分解成多少个互不相同的素数的和。 例如,21 = 2 + 1921的合法分解方法。21 = 2 + 3 + 5 + 11 则是分解为最多素数的方法。

问题分析:

为了简化问题,这里仅以分解 21的质数之和为例。

问题本质还是排列性质,只是排列的数字换成了质数。

问题所要的结论不是纯数字排列,而是要求排列后的数字相加之和为 21

如果使用静态穷举算法,将会面对 2 个问题:

  • 21小的质数有{2,3,5,7,11,13,17,19}。因不知道哪几个数字相加等于 21。最少可以是 2 个数字相加,而理论上最多可能是所有数字相加。如下书写静态代码,循环嵌套可以达到 8 层,且从第二层开始就要检查是否能得到所要的结果。
#include 
using namespace std;
int main(int argc, char** argv) {int target=21;int nums[8]= {2,3,5,7,11,13,17,19};//第一层循坏for(int i=0; i<8; i++) {//第二层循环for(int j=0; j<8; j++ ) {if( nums[j]==nums[i] )continue;if( nums[j]+nums[i]==21 ) {cout<
  • 如果要分解比 21大的数字,如此编写代码将会让人崩溃,甚至疯癫。

如果使用递归回溯方案,则可以根据需要动态且正确的进行循环次数的嵌套。如下代码,代码中的细节由读者自行参悟。

#include 
using namespace std;
int target=21;
//原始数字 1 2  3 4 5
int nums[8]= {2,3,5,7,11,13,17,19};
//状态数组:数字是否已经使用
int isUse[8]= {0};
//存储结果
int res[8];
//显示结果
int showAll() {int count=0;for(int i=0; i<8; i++) {if(res[i]!=0) {count++;cout<maxCount)maxCount=count;} else {//下一次循环(动态)fillNum(i+1,pos+1,target-nums[i]);}//回溯res[pos]=0;isUse[i]=0;}}
}
//测试
int main(int argc, char** argv) {fillNum(0,0,target);cout<<"有"<

输出结果:

39.png

5. 总结

通过上述的讲解,以及编码之间的比较,想必已经看出了递归回溯算法的本质。

归纳一句话便是:动态次数的循环嵌套而已。

本文想传递一种学习认知,世间万物的规律都是万变不离其宗,当你看破事物的本质,神秘感将会荡然无存。

相关内容

热门资讯

linux入门---制作进度条 了解缓冲区 我们首先来看看下面的操作: 我们首先创建了一个文件并在这个文件里面添加了...
C++ 机房预约系统(六):学... 8、 学生模块 8.1 学生子菜单、登录和注销 实现步骤: 在Student.cpp的...
A.机器学习入门算法(三):基... 机器学习算法(三):K近邻(k-nearest neigh...
数字温湿度传感器DHT11模块... 模块实例https://blog.csdn.net/qq_38393591/article/deta...
有限元三角形单元的等效节点力 文章目录前言一、重新复习一下有限元三角形单元的理论1、三角形单元的形函数(Nÿ...
Redis 所有支持的数据结构... Redis 是一种开源的基于键值对存储的 NoSQL 数据库,支持多种数据结构。以下是...
win下pytorch安装—c... 安装目录一、cuda安装1.1、cuda版本选择1.2、下载安装二、cudnn安装三、pytorch...
MySQL基础-多表查询 文章目录MySQL基础-多表查询一、案例及引入1、基础概念2、笛卡尔积的理解二、多表查询的分类1、等...
keil调试专题篇 调试的前提是需要连接调试器比如STLINK。 然后点击菜单或者快捷图标均可进入调试模式。 如果前面...
MATLAB | 全网最详细网... 一篇超超超长,超超超全面网络图绘制教程,本篇基本能讲清楚所有绘制要点&#...
IHome主页 - 让你的浏览... 随着互联网的发展,人们越来越离不开浏览器了。每天上班、学习、娱乐,浏览器...
TCP 协议 一、TCP 协议概念 TCP即传输控制协议(Transmission Control ...
营业执照的经营范围有哪些 营业执照的经营范围有哪些 经营范围是指企业可以从事的生产经营与服务项目,是进行公司注册...
C++ 可变体(variant... 一、可变体(variant) 基础用法 Union的问题: 无法知道当前使用的类型是什...
血压计语音芯片,电子医疗设备声... 语音电子血压计是带有语音提示功能的电子血压计,测量前至测量结果全程语音播报࿰...
MySQL OCP888题解0... 文章目录1、原题1.1、英文原题1.2、答案2、题目解析2.1、题干解析2.2、选项解析3、知识点3...
【2023-Pytorch-检... (肆十二想说的一些话)Yolo这个系列我们已经更新了大概一年的时间,现在基本的流程也走走通了,包含数...
实战项目:保险行业用户分类 这里写目录标题1、项目介绍1.1 行业背景1.2 数据介绍2、代码实现导入数据探索数据处理列标签名异...
记录--我在前端干工地(thr... 这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 前段时间接触了Th...
43 openEuler搭建A... 文章目录43 openEuler搭建Apache服务器-配置文件说明和管理模块43.1 配置文件说明...