前言
什么是递归?什么是回溯?它们有什么关系?
简单来说,递归是一种算法的结构,它表现为函数间接或直接地调用自己。而回溯是一种算法思想,它可以用递归这种算法结构来实现。回溯法类似与穷举法,与之不同的是回溯法中有“剪枝”操作,可以将它理解为优化后的穷举。
为什么难写?
对于大多数刚学习算法的同学来说,涉及到递归、回溯的代码常常令我们感到害怕。造成这种恐惧心理的原因主要有以下几点:
- 不知道回溯法代码长什么样。
- 递归控制不好,容易写死循环。
- 递归代码不好调试,靠“人脑调试”比较困难。 可以发现这几个原因是紧密相关的,你不知道回溯代码怎么写 -> 你容易写死循环 -> 你不好调试。那有没有克服恐惧的办法呢? 有的。
套路
什么?写算法还有套路?在查询了N篇文章以后,我终于找到了回溯算法的套路。
回溯算法解题套路框架 <- 想深入学习的同学可以看看原文章,直接上干货:
回溯代码的框架:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
很多同学上来就看蒙了,它说这是乱写的,这可不是乱写啊。路径、选择列表、结束条件,显然是有bear而来。
那么接下来就用一道实际的题目来教大家怎么套这个模板。
问题
n位逐位整除数(简称整除数):从其高位开始,高1位能被整数1整除(显然),高2位能被整数2整除,…,整个n位能被整数n整除。例如,整数102450就是一个6位整除数。给定整数n,求所有的n位整除数的个数。
思路
让我求有多少个n位整除数,那么最直接的方法就是暴力穷举,我把所有n位数全表示出来,一个个判断不就行了嘛?这好吗?这不好。不妨试想我们在穷举过程中举到了以13开头的N数,那么根据题目所说13它本身肯定不是一个2位的整除数,那你说131,132,133......13XXXX,这样的数字我还要考虑嘛?肯定不用了。所以说,穷举它不能乱举,小伙子你不能走弯路。
那就像本文一开始提到的,回溯法是优化的穷举,它是个聪明的方法,不像穷举那么暴力鲁莽rude,它是有“剪枝”操作的,那么我就用它试试呗。
#include <iostream>
using namespace std;
void backtrack(int a,int t,int n,int &sum){
//补全代码
}
int main(){
int a=0;//用来穷举的工具人
int n;//要求n位整除数
cin>>n;
int sum;//计算有多少个n位整除数
backtrack(a,0,n,sum);
cout<<sum;
return 0;
}
看到这里很多人蒙了,这个backtrack函数怎么有4个传入参数呢?跟模板不一样啊!那个t是什么东西啊?为什么一开始要传t=0呢?别慌我们慢慢分析。
看到这这棵树,大师我悟了,原来t是指当前数字的位数也就是树的层数呗。那么为什么一开始要传0,相信你也懂了,因为我们要从根节点开始。上面的这棵树也就是回溯法的决策树。从树中我们可以很清楚地发现,如果我们要找一个3位数,那么我们只需要从根节点走到t=3也就是树的第三层。
我们就用这棵树来生动地展现回溯法中的“剪枝”操作。
我们之前提到过任何以13开头的数字都肯定不是整除数,所以我们将1-13这个边直接“掐死”,它后续所有的节点全部丢弃。这就叫“剪枝”。
记得我们上文提到三个关键词:路径、选择列表、结束条件。
首先,这题中什么是路径?路径顾名思义是一种记录,它记录了我们从起点到达终点,在这道题里,起点一定是“万恶之源”,也就是决策树的根节点,那么终点就是找到了n位的整除数,那么有没有一个东西记录了我们从根节点开始每一次的选择呢,显然,就是节点所对应的值。以139为例,它记录了我们第一次走了1然后选择了3最后选择了9。
那么很容易想到结束条件就是找到了n位数,便是到达了第n层,即t=n。
最后选择列表是什么呢?这也是本题的一个难点:t=1时,选择列表是1-9,t>1时,选择列表则是0-9。
做完了所有准备工作,接下来进入正题。
解决问题
上来照着模板就是一顿填写
void backtrack(int a,int t,int n,int &sum){
if(t==n){
sum++;//代表找到了一条“路径”
return;
}
for(int i=1;i<=9;i++){
a=a*10+i;//做选择
backtrack(a,t+1,n,sum);
a=(int)a/10;//撤销选择
}
}
然后答案出错,有这么简单吗?当然没有。 我们上面说到本题的一个难点就是当t=1时和t>1时选择列表不同,因此这里要分情况处理。
void backtrack(int a,int t,int n,int &sum){
if(t==0){ //t=0时选择列表为1-9
for(int i=1;i<=9;i++){
a=a*10+i;
backtrack(a,t+1,n,sum);
a=(int)a/10;
}
}
else{ //t>0时选择列表为0-9
if(t==n){
if(a%n==0){
sum++;
}
return;
}
for(int i=0;i<=9;i++){
a=a*10+i;
backtrack(a,t+1,n,sum);
a=(int)a/10;
}
}
}
这下没错了吧,都考虑这么周全了。运行一下:
n=1,输出9,嗯,没错
n=2,输出45,嗯,也没错
n=3,输出300,嗯?(正确为150)
......
n=6,输出150000,(正确为1200)越来越夸张,是不是哪里忘了?
分析为什么n=3开始就出错了,还是以13开头的数字为例,132走一遍我们的判断程序,sum会+1嘛?我们整个代码唯一能让sum++的只有这句话:
if(a%n==0){
sum++;
}
那么 132 % 3 = 0,糟了,sum会加1。
究竟漏了哪里呢???往上看,我们刚刚才拿13开刀过,我们用它展示了“剪枝”。那么我们的代码中有没有“剪枝”操作呢?很遗憾,没有。这也就是为什么会多出那么多假的整除数。好吧,让我们加上“剪枝”功能得到的就是完整的代码了。
void backtrack(int a,int t,int n,int &sum){
if(t==0){
for(int i=1;i<=9;i++){
a=a*10+i;
backtrack(a,t+1,n,sum);
a=(int)a/10;
}
}
else{
if(t==n){
if(a%n==0){
sum++;
}
return;
}
if(a%t!=0){ // *********** 剪枝操作 ***********
return;
}
for(int i=0;i<=9;i++){
a=a*10+i;
backtrack(a,t+1,n,sum);
a=(int)a/10;
}
}
}
再次运行,n=6,输出1200。大功告成!!! ^-^
备注
在位数较大时无法用int储存,因此可以使用数组的方式储存a,不过解题思路是一样的,使用数组存储a的递归函数如下:
void backtrack(int *a, int t, int n, int &sum)
{
int r,j;
for(r=0,j=1;j<=t;j++) {
r=r*10+a[j];
r=r%t;
} //r=0代表整除
if(t==0){
for(int i=1;i<=9;i++){
a[t+1]=i;
backtrack(a,t+1,n,sum);
a[t+1]=0;
}
}
else{
if(t==n){
if(r==0){
sum++;
}
return;
}
if(r!=0){
return;
}
for(int i=0;i<=9;i++){
a[t+1]=i;
backtrack(a,t+1,n,sum);
a[t+1]=0;
}
}
}
后话
这篇文章记录了我与朋友做这道题时思考的过程,对于算法我们俩都是新手中的新手。像很多学习算法的同学一样,之前看到递归、看到回溯都是害怕,觉得那太复杂了,自己肯定写不出来,只能照着别人的copy。
但是真正去尝试写后才发现原来也是有规律可循,真实的过程远比文章所记录的坎坷,我们写了很多次死循环,每一次都是通过下断点单步调试最终发现问题所在。写代码的能力很重要,但是调试代码的能力同样重要。写完代码后不禁感叹递归的精妙,短短几行代码便能解决问题,优雅。
写代码还是要勇于尝试,要敢写,错了就调试,然后修改,直到解决问题,这算是提升代码能力的必经之路。
好了,这是我写的第一篇博客,初次使用md,排版难免不够美观,作为一个算法初学者,文中的代码没那么优雅,干脆。所以还请各路大神多多指教,最后,谢谢,谢谢朋友们。