本文由Yue_Qiu原创于2023.4.18,转载请标明出处。
递归转非递归方法论
有些算法/数据结构使用递归来描述非常清晰易懂,但有时候(有作业要求用非递归写,但使用递归写简单的多。),这时把递归转化成非递归就有必要了。
首先明确关键一点,我们要用循环模拟递归过程,接下来的所有讨论都是围绕这一点展开。能用循环模拟递归的前提是这两个东西有相似性,递归过程中每次调用执行的都是同一套代码,循环也是每次进循环都是走同一套代码,递归调用可以很容易的用循环的continue模拟。于是我们只要解决他们的不同点就好了。
以下提出一种完全不需要关注递归函数进行了什么,只需要进行代换的一套机械化公式方法论。
1. 变量独立
第一个要解决的问题是,递归是栈调用,栈中的每个函数都有独立的变量,而循环内的变量同名,在下一次循环中他们依旧是原来的变量(内存地址不变),不同次循环之间会相互干扰,这不符合参数的独立性。
所以,我们使用栈来实现各个元素的独立性,这里使用手写栈,使用idx(或者其他变量名,这无关紧要)模拟不同的函数调用(这一点很重要,实质是模拟了栈调用的过程),进而来模拟栈内不同层递归的独立变量。只需要用name[idx]便可以实现不同次循环(实质是这些循环模拟的递归层数不同)的变量相互独立。
2.调用与回溯
第二个要解决的问题是栈调用的调用与回溯,这倒是很容易想到,由前面说的idx以及这二者的相似性可知,continue使得循环进入下一次,我们可以使用continue来模拟调用与回溯,在下文会详细分析怎么办。
另一点,用循环模拟递归的话,我们要清楚一点就是循环的出口是什么,在下面的程序里,出口是回到main里面,也就是调用它的函数里面,这实际上是第一次递归的调用被栈弹出了,回到了上一个栈内函数里。那么用idx模拟栈层数,idx=0就代表递归出口了,也就是整个循环的出口。(为了方便,可以把循环封装到一个函数里)
#include <iostream>
using namespace std;
int fib(int n)
{
if (n == 0 || n == 1)
return 1;
return fib(n - 1) + fib(n - 2);
}
int main()
{
for(int i = 0; i < 10; i++)
cout << fib(i) << endl;
return 0;
}
第三个要解决的问题是,递归是栈调用,出栈之后,执行的函数不一定是从第一行开始执行,而循环只能从第一行老老实实向下执行。我们先来分析一下这个过程。
执行递归函数内语句只有两种情况:
第一种,新的调用,这时从第一行开始执行直到遇到递归出口回溯或者进行新的递归调用。
第二种,旧的回溯,这时候从回溯回来的那一行继续执行直到遇到递归出口或新的调用。
void dfs(int n, int m, int *arr)
{
if (m == N + 1)
{
++sum;
for (int i = 1; i <= N; ++i)
printf("%d ", arr[i]);
printf("\n");
return;//递归出口,回溯
}
for (int i = n + 1; i <= n * 2 + 1; ++i)
{
if (find(arr, fa(i)))
{
arr[m] = i;
dfs(i, m + 1, arr);//新调用
//回溯回来的位置是这里(或者第一层的话是递归结束回到原来的函数,比如main)
}
}
return; //另一个出口,回溯
}
第一种情况,新的调用,这与循环相同,都是从第一行往后执行,这很好,但第二种情况,回溯回来很可能是从中间开始执行,这时候直接循环就不行了,需要想办法解决这个问题。我们发现,跳过代码有很多种实现方式,而最直接的一种是goto(goto本职工作就是跳过代码),尽管我很不喜欢goto,但这里为了方便,还是先用goto来演示。
这个递归函数很简单,只有一个新调用位置跟固定的回溯位置,显而易见的,每次continue的情况不一样,需要分开讨论,这时候可以引入一个flag(变量名无所谓)来代表每次进入循环第一行是如何进入的,是从下一层调用返回的还是从上一层调用新调用的。如果是新调用的,那么就是从第一行进行,而返回的则是从上文标出的回溯的位置向后进行。
现在我们来详细分析一下这要怎么编码
void buildtree(int N, int *treecount)
{
// 手写栈
int idx = 1;
int sum = 0;
int *arr = (int *)malloc((N + 1) * sizeof(int));//动态开数组,节省内存
int *n = (int *)malloc((1 << N) * sizeof(int));
int *m = (int *)malloc((1 << N) * sizeof(int));
int *i = (int *)malloc((1 << N) * sizeof(int));
for (int j = 1; j <= (1 << N); ++j)
i[j] = 0;
arr[1] = 1;
n[1] = 1;
m[1] = 2;
bool flag = true;//这里只有两种情况,使用bool即可代表全部情况
//true代表新调用,false代表回溯
while (idx > 0)
{
if (flag == false)//回溯回来的话,直接去回溯回来的位置,不要执行前面的代码
goto lable;
if (m[idx] == N + 1)
{
++sum;
for (int i = 1; i <= N; ++i)
printf("%d ", arr[i]);
printf("\n");
//标准回溯代码
--idx;
flag = false;//递归出口,回溯
continue;
}
i[idx] = n[idx] + 1;
for (i[idx]; i[idx] <= n[idx] * 2 + 1; ++i[idx])
{
if (find(arr, fa(i[idx]), m[idx] - 1))
{
arr[m[idx]] = i[idx];
//标准调用代码
++idx;//栈层数加一,代表新调用
flag = true;//新调用
n[idx] = i[idx - 1];//新调用的参数
m[idx] = m[idx - 1] + 1;
break;//这里在一个for循环内,直接continue不行
lable:;//回溯回来的位置是这里,接下来要执行 ++i[idx] 操作,注意那个;
//标签后面不能直接是}所以加个空语句;
}
}
//到这里有两种情况,一种是for循环正常执行结束,一种是break出来的,判断一下就行
if (i[idx] == n[idx] * 2 + 2)
{
--idx;
flag = false;//回溯
}
//另一个递归出口
//到这里相当于return了
}
free(arr);
free(n);
free(m);
free(i);
*treecount = sum;
return;
}
如上面的代码所示,±idx然后再从头开始(使用continue或者执行到底)便可以很好的模拟整个递归的栈调用过程。
由于这个递归比较简单,也可以不用goto。
如果新调用位置不止一个,那么就让flag多几个取值代表全部情况即可。
训练
虽然递归转迭代这个东西用的不多,而且对递归过程理解深刻的话可以直接写,套这个方法论其实有些跳过思考偷懒的意味。但考虑到考试或者作业可能会涉及,这里还是给出几个训练,不用去关注这个dfs到底干了什么,只需要套公式即可:
1.单词 Play on Words - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
注:本题dfs最大有25层,只需要修改dfs函数。
#include<iostream>
#include<string>
#include<cstring>
using namespace std;
bool edges[30][30];int in[30],out[30];
bool B[30];
void dfs(int u)
{
B[u]=0;
for(int i=0;i<=25;++i)
{
if(B[i]&&edges[u][i]) dfs(i);
}
return;
}
bool solve()
{
int n;
cin>>n;
string s[100000+10];
for(int i=1;i<=n;++i)
{
cin>>s[i];
int c0=s[i][0]-'a',c1=s[i][s[i].size()-1]-'a';
B[c0]=B[c1]=1;
edges[c0][c1]=edges[c1][c0]=1;
++in[c1];
++out[c0];
}
int j;
for(int i=0;i<=25;++i) if(B[i])
{
j=i;
break;
}
dfs(j);
for(int i=0;i<=25;++i) if(B[i]) return 0;
int cntin=0,cntout=0;
for(int i=0;i<=25;++i)
{
if(in[i]-out[i]==1) ++cntout;
else if(out[i]-in[i]==1) ++cntin;
else if(abs(in[i]-out[i])>1) return 0;
}
if((cntin==1&&cntout==1)||(cntin==0&&cntout==0)) return 1;
return 0;
}
int main()
{
int n;
cin>>n;
while(n--)
{
memset(edges,0,sizeof(edges));
memset(B,0,sizeof(B));
memset(in,0,sizeof(in));
memset(out,0,sizeof(out));
if(solve()) cout<<"Ordering is possible.\n";
else cout<<"The door cannot be opened.\n";
}
return 0;
}
2.斐波那契数列
本题练习多种回溯位置与带有返回值的递归。最多调用20层。
int fib(int n)
{
if (n == 0||n==1)
return 0;
return fib(n - 1) + fib(n - 2);
}
答案:
void dfs(int oriu)
{
int *u = (int *) malloc(30 * sizeof(int));
int *i = (int *) malloc(30 * sizeof(int));
int idx = 1;
bool f = true;
u[idx] = oriu;
while (idx > 0)
{
B[u[idx]] = 0;
if (f == 0)
goto lab;
for (i[idx] = 0; i[idx] <= 25; ++i[idx])
{
if (B[i[idx]] && edges[u[idx]][i[idx]])
{
f = 1;
++idx;
u[idx] = i[idx-1];
break;
lab:;
}
}
if (i[idx] > 25)
{
f = 0;
--idx;
}
}
free(u);
free(i);
}
可以看到,多种回溯位置时这样做很麻烦,这是递归与循环的特性决定的。
(可以使用循环数组来简单的实现使用循环求fib但我们这里主要目的是递归转迭代)
这进一步说明了,递归转迭代很别扭,只有比较简单的时候好转。
int fib(int orin)
{
int *n = (int *) malloc(25 * sizeof(int));
int *ret = (int *) malloc(25 * sizeof(int));//存储return值
int idx = 1;
n[idx] = orin;
int *flag = (int *) malloc(25*sizeof(int));
//0表示初始调用,-1,-2表示两种调用,1表示回溯到lab1,2表示回溯到lab2
for(int i=1;i<=25;i++)
flag[i]=0;
while (idx > 0)
{
flag[idx+2] = 0;
if (flag[idx+1] == 1)
goto lab1;
if (flag[idx+1] == 2)
goto lab2;
if (n[idx] == 0 || n[idx] == 1)
{
flag[idx] = -flag[idx];
ret[idx] = 1;
idx--;
continue;
}
++idx;
n[idx] = n[idx - 1] - 1;
flag[idx] = -1;
continue;
lab1:
ret[idx] = ret[idx + 1];
++idx;
n[idx] = n[idx - 1] - 2;
flag[idx] = -2;
continue;
lab2:
ret[idx] += ret[idx + 1];
flag[idx] = -flag[idx];
idx--;
}
free(n);
int rret = ret[1];
free(ret);
return rret;
}