本文将和大家一起聊聊递归回溯算法
和原始穷举算法
的内在关系,意在透过现象看本质,深入递归回溯算法的内在,真真切切看清楚其真面貌,而不仅限于套用算法模板。
回溯算法适用于如下的问题域:
假设求解某问题共需要 n
个步骤、而每一步可有的选择为m
,根据乘法原理,则符合问题的方案应该有 n*m
。如下图所示:
数学上的排列组合问题便是典型的此类问题。
排列问题描述:如从 1,2,3,4,5
这 5
个数字中选择 n(n<=5)
个数字进行重新排列,请问共有多少排列方案,当n=5
时称为全排列。
本文将从求解全排列的原始穷举算法开始,一路讨索原始穷举算法和回溯算法之间的一致性。这里的一致性指回溯算法本质就是原始穷举算法,当然,两者之间会有思想层面的差异性。
什么是原始穷举算法?
理论上讲,算法只有一种,便是穷举,但是,人是智慧性的,可通过发现待处理数据之间的逻辑关系,从而改进算法,减少穷举的次数,提升算法性能。
原始穷举算法也是一种静态处理算法。
静态做何解释?
不急,且先看如何使用原始穷举算法求解 1,2,3,4,5
这几个数字的全排列。
穷举的基本思想是先确定数据范围,然后对其进行筛选。5
个数字的全排列穷举算法实现流程如下:
5
个数字,说明每一种方案都需要分 5
步。如下图所示,每一次需要填满如下 5
个格间方算找出一种方案。1,2,3,4,5
中选择出一个数字。如下图先选择数字 1
。如何编码实现?
必然是循环和条件分支语句上场,循环用来确定能查找的数字范围,条件分支进行筛选。
#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<
输出结果:
至此,解决了全排列问题,但是,有 2
个问题值得思考。
你是否看到了上述代码里有回溯?
Tips: 所谓回溯,指结束当前步聚,回到上一步,再次开始。
可认为代码中的每一个循环语句对应了问题中的每一步聚。如下图当第 5
次循环语句(第 5
步)结束后,会回溯到上一步,即第四步(第四个循环语句)。
Tips: 循环嵌套的特性:内层循环结束,重新进入外层循环判断,如果外层循环满足条件可以继续,新一轮内层循环也将开始。
同理,如果第四步(第 4
个循环语句)结束,会回溯到第 3
步(第 3
个循环语句)。一旦第三步确定好新数字,第四步和第五步会重新开始。如此反复,一步一步向上回溯再向下重新开始,便能找出所有方案。
思考后的结论:
5
个,第二步有 4
个,第三步有 3
个,第四步有 2
,第 5
步有 1 个。共计方案数为 5*4*3*2*1=120
种,和代码输出结论一样。**再思考第二个问题:**如果求 1,2,3,4,5,6
此6
个数字的全排列,或更多个数字的全排列又该如何?
显然,无法让上述代码在运行时,同时实现求 1~5
数字和1~6
数字的全排列,除非写 2
份代码。上文说过,原始穷举算法是静态的,所谓的静态性就在此,代码仅局限于一个具体问题的求解,而不是一类问题的求解。
自然的想法,代码必须能根据问题需求动态扩展循环语句块,这时应该想到递归。
递归实现全排列动态化的流程:
1~5
这5
个数字中选择 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<
输出结果:
代码中还是有几个问题需要解决:
void fillNumber_(int pos,int num) {for(int i=1; i<=5; i++) {//本次选择不能和上次选择相同if(i==num)continue;//省略……}
}
要解决这 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<
输出结果:
为什么输出的结果不完整?
如下图所示:
如果外层循环选择了数字 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<
如此,使用递归实现的回溯算法便大功告成。
输出最终代码的测试结果:
小结:
继续通过一个案例理解静态和动态的差异性,便能明白递归回溯算法
为什么可以在算法界里独成一派。
问题描述:素数分解。
素数,又称质数,是指除 1
和其自身之外,没有其他约数的正整数。例如 2、3、5、13
都是质 数,而 4、9、12、18
则不是。 虽然素数不能分解成除 1
和其自身之外整数的乘积,但却可以分解成更多素数的和。你需要编程 求出一个正整数最多能分解成多少个互不相同的素数的和。 例如,21 = 2 + 19
是 21
的合法分解方法。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<<"有"<
输出结果:
通过上述的讲解,以及编码之间的比较,想必已经看出了递归回溯算法的本质。
归纳一句话便是:动态次数的循环嵌套而已。
本文想传递一种学习认知,世间万物的规律都是万变不离其宗,当你看破事物的本质,神秘感将会荡然无存。