先说结论,仅追求用栈模拟递归是有套路的(文章就是介绍我自己琢磨的一个套路),但是写出高质量的非递归代码是有难度的,还需要考虑问题本身的特征。
用栈模拟递归的可行性
首先要明白函数调用的汇编层面的过程。
详情参考浅析函数的调用过程
所以一次函数调用本质上就是操作系统自动的帮我们建立、维护并回收一个内存栈,并在内存栈中保存了一些函数执行的状态信息。
递归是一种特殊的函数调用,即函数自己调用自己。函数调用是有开销的,如果递归调用的层次太深,还会有爆栈的风险。
因此,通常我们希望可以把递归程序转换为非递归程序。如前所述,用栈模拟递归是可行的。
举例说明栈模拟递归的一般套路
二叉树的后序遍历是我们数据结构课都学过的例子,它的非递归写法难度是很大的。我们以此为例,供读者体会我们的套路。
递归形式的代码长这样子:
typedef struct Tnode* BinTree;
typedef int elementType;
struct Tnode
{
elementType x;
BinTree left, right;
};
void postOrderTravel(BinTree p)
{
if (p)
{
postOrderTravel(p->left);
postOrderTravel(p->right);
cout << p->x << endl;
}
}
用我们的套路写出来的非递归代码是这样的:
struct Node{
BinTree t;
int flag;
};
void myPostOrderTravel(BinTree p)
{
stack<Node> stk;
if (!p)
return;
stk.push({p, 0});
while (!stk.empty())
{
Node& tmp = stk.top();
switch (tmp.flag)
{
case 0:
tmp.flag = 1;
if (tmp.t->left)
{
stk.push({tmp.t->left, 0});
break;
}
case 1:
tmp.flag = 2;
if (tmp.t->right)
{
stk.push({tmp.t->right, 0});
break;
}
default:
cout << tmp.t->x << endl;
stk.pop();
}
}
}
栈中存放哪些内容
栈中的一个节点代表一个函数体。
栈的数据结构是我们自定义的结构体,结构体 = 递归调用函数的参数 + flag
为什么要有函数的参数很好理解,我们是模拟递归,递归有的我们一定要有。
flag就有些意思了。
先看这张图(作者是 BNUbeginner):
模拟栈实现手写递归
函数执行过程分为以下五步:
- 执行代码块0
- 执行递归调用,进入新的函数体
- 从调用的函数体返回,接着执行代码块1
我们可以看到,递归调用类似于一个中断的过程,我们现有的函数体被中断了,转去执行新的函数体,然后从新的函数体返回,继续执行现有的函数体。
flag的作用就是标志着当前的函数体是从代码块0开始执行还是从代码块1开始执行
对于二叉树的后序遍历:
flag == 0: 对左儿子访问
flag == 1: 对右儿子访问
flag == 2: 输出自己的节点值
所有我们用flag配合switch语句(我甚至怀疑最早的switch就是针对模拟递归调用提出的,它们的功能实在是太吻合了),switch捕捉flag对应的语句块,如果执行完一个语句块要相应的增加flag序号(作者将flag的修改放在每个语句块的最前面,功能是一样的)。
switch (tmp.flag)
{
case 0:
tmp.flag = 1;
if (tmp.t->left)
{
stk.push({tmp.t->left, 0});
break;
}
case 1:
tmp.flag = 2;
if (tmp.t->right)
{
stk.push({tmp.t->right, 0});
break;
}
default:
cout << tmp.t->x << endl;
stk.pop();
}
因此,针对任何一个递归函数,只要我们能够将它划分成几个语句块,就可以套用模板来实现非递归方法。
效率如何
我们用栈模拟递归,减少了函数调用的开销,但是我们的代码执行速度变快了吗?答案是否定的!
现代CPU都是流水线作业的,伴有大量的指令集并行。而流水线最怕的就是分支语句,分支语句往往会造成流水线的停顿或者使得一些代码的执行浪费掉了。
而对于非分支语句,无论是编译器的代码优化还是硬件的动态调度,都可以通过乱序执行很大程度上提高代码执行速度。
我们的模板伴有一个巨大的while循环,还有大量的switch-break分支跳转语句,执行速度并不比递归方式快。但是在节省内存空间方面,我们用栈模拟递归显然是十分优秀的。
回溯问题的非递归写法
洛谷P1049 装箱问题,一个类似01背包的问题。可用回溯轻松完成。
对于回溯问题,我们一般不需要使用flag成员。因为回溯就是深度优先搜索,按照一颗书的形式完成搜索问题。
我们的一个函数体对应树上的一个节点。对于一个节点来说,只要它能够将它的孩子节点都扩展上,那么它的任务就完成了。
换句话说,我们不需要从子节点返回时对父节点做任何操作。
首先给出递归写法,判断边界+如果当前物品取+如果当前物品不取:
void dfs(int u, int left)
{
if (u == n)
{
ans = min(ans, left);
return;
}
if (left >= weight[u])
dfs(u + 1, left - weight[u]);
dfs(u + 1, left);
}
模板写法,没什么可说的,直接套用模板即可:
struct Node{
int u, left;
int flag;
};
const int maxn = 7e5;
Node stk[maxn];
int top = -1;
void solve()
{
stk[++top] = {0, V, 0};
while (top != -1)
{
Node& tmp = stk[top];
switch (tmp.flag)
{
case 0:
tmp.flag = 1;
if (tmp.u == n)
{
ans = min(tmp.left, ans);
top--;
break;
}
case 1:
tmp.flag = 2;
if (tmp.left >= weight[tmp.u])
{
stk[++top] = {tmp.u + 1, tmp.left - weight[tmp.u], 0};
break;
}
case 2:
top--;
stk[++top] = {tmp.u + 1, tmp.left, 0};
break;
default:
break;
}
}
}
更优秀的非递归写法:
在这里我们不使用flag成员。
const int maxn = 7e5;
struct Node_t{
int u, left;
};
int top = -1;
Node_t stk[maxn];
void solve()
{
stk[++top] = {0, V};
while (top != -1)
{
Node_t tmp = stk[top];
top--;
if (tmp.u == n)
{
ans = min(tmp.left, ans);
continue;
}
if (tmp.left >= weight[tmp.u])
stk[++top] = {tmp.u + 1, tmp.left - weight[tmp.u]};
stk[++top] = {tmp.u + 1, tmp.left};
}
}
时间效率:
递归 | 模板写法 | 更优秀的非递归 |
---|---|---|
36ms | 54ms | 50ms |
二叉树后序遍历的非递归算法
void PostOrderTravel(BinTree t)
{
stack<BinTree> stk;
BinTree pLastVisit = 0;
while (t)
{
stk.push(t);
t = t->left;
}
while (!stk.empty())
{
t = stk.top();
stk.pop();
if (!t->right || t->right == pLastVisit)
{
cout << t->x << endl;
pLastVisit = t;
}
else
{
stk.push(t);
t = t->right;
while (t)
{
stk.push(t);
t = t->left;
}
}
}
}