版权申明:本文为博主窗户(Colin Cai)原创,欢迎转帖。如要转贴,必须注明原文网址
http://www.cnblogs.com/Colin-Cai/p/13752031.html
作者:窗户
QQ/微信:6679072
E-mail:6679072@qq.com
本章接着上次的话题,继续讨论递归的编译优化问题。
注: 里面只是用Python来描述,以方便说明,内容是通用的。
先从尾递归开始讲起,然后讲到尾调用,再讲到更一般的情况。当然,本节的函数都是不带副作用的,也就是不影响外部状态,是数学意义上的函数。
尾递归
所谓尾递归,就是虽然是递归的形式,自身调用自身,并且和其他递归形式不一样,尾递归最终是完全转换成了自身的函数调用。
比如,我们计算\\sum_{i=1}\^{n}i
def sum(r, n):
if n == 0:
return r
return sum(r+n, n-1)
然后我们用sum(0, n)来计算求和,这里求sum(r, n)要么归结为返回最终结果,要么归结于另外一组参数的sum调用,所以为尾递归。
递归的计算
先从Fibonacci数列说起
def fib(n):
if n < 3:
return 1
return fib(n-1) + fib(n-2)
运行程序,解释器解释的时候需要事先给定一个运行栈,栈里每一层放置当前函数调用所运行位置、局部变量、过程中结果,用来作函数调用、返回时的上下文切换。
改写一下上面的代码,以便于统一上面的局部变量和过程中结果,从而便于说明。
def fib(n):
if n < 3:
return 1
a = fib(n-1)
b = fib(n-2)
ret = a + b
return ret
简单起见,fib的运行位置涉及到函数退出或函数调用,从而切换函数执行的地方有如下几个:
start(刚进入函数)
return_1(return 1的位置,准备返回函数)
cal_fib_n_1(正在计算fib(n-1))
cal_fib_n_2(正在计算fib(n-2))
return_ret(计算完fib(n-1)+fib(n-2),准备返回)
我们现在来分析解释器去执行fib(5)的时候,整体的计算过程。
我们的运行栈中每一个元素是一个(函数,运行位置,变量列表)的三元组,栈顶是当前正在做的计算
最开始栈是
{(fib, start, n=5)}
假设栈的增长方向往右,那么接着的计算顺序是
{(fib, start, n=5)}
{(fib, call_fib_n_1, n=5), (fib, start, n=4)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_1, n=4), (fib, start, n=3)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_1, n=4), (fib, call_fib_n_1, n=3), (fib, start, n=2)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_1, n=4), (fib, call_fib_n_1, n=3), (fib, return_1, n=2)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_1, n=4), (fib, call_fib_n_2, n=3, a=1)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_1, n=4), (fib, call_fib_n_2, n=3, a=1), (fib, start, n=1)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_1, n=4), (fib, call_fib_n_2, n=3, a=1), (fib, return_1, n=1)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_1, n=4), (fib, return_ret, n=3, a=1, b=1, ret=2)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_2, n=4, a=2)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_2, n=4, a=2), (fib, start, n=2)}
{(fib, call_fib_n_1, n=5), (fib, call_fib_n_2, n=4, a=2), (fib, return_1, n=2)}
{(fib, call_fib_n_1, n=5), (fib, return_ret, n=4, a=2, b=1, ret=3)}
{(fib, call_fib_n_2, n=5, a=3)}
{(fib, call_fib_n_2, n=5, a=3), (fib, start, n=3)}
{(fib, call_fib_n_2, n=5, a=3), (fib, call_fib_n_1, n=3)}
{(fib, call_fib_n_2, n=5, a=3), (fib, call_fib_n_1, n=3), (fib, start, n=2)}
{(fib, call_fib_n_2, n=5, a=3), (fib, call_fib_n_1, n=3), (fib, return_1, n=2)}
{(fib, call_fib_n_2, n=5, a=3), (fib, call_fib_n_2, n=3, a=1)}
{(fib, call_fib_n_2, n=5, a=3), (fib, call_fib_n_2, n=3, a=1), (fib, start, n=1)}
{(fib, call_fib_n_2, n=5, a=3), (fib, call_fib_n_2, n=3, a=1), (fib, return_1, n=1)}
{(fib, call_fib_n_2, n=5, a=3), (fib, return_ret, n=3, a=1, b=1, ret=2)}
{(fib, return_ret, n=5, a=3, b=2, ret=5)}
综上,我们可以叫它树递归,因为它的递归最终像一棵树一样,以上还只是对于n=5的时候,如果n=10,规模则庞大的多。上一节我们已经说到了。
我们本节不考虑树递归的优化。
尾递归优化
回头来,我们对于刚才那个sum函数来看看递归栈的变化,我们在这里重新写一下该函数
def sum(r, n):
if n == 0:
return r
return sum(r+n, n-1)
它一共可以有四个切换的地方:
start(进入函数)
return_r(执行return r)
call_sum(调用sum(r+n, n-1))
return_sum(执行第二个return)
我们来看看sum(0,5)的运行栈,最开始的时候是
{(sum, start, r=0, n=5)}
接下来
{(sum, call_sum, r=0, n=5)}
{(sum, call_sum, r=0, n=5), (sum, start, r=5, n=4)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, start, r=9, n=3)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, start, r=12, n=2)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, call_sum, r=12, n=2)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, call_sum, r=12, n=2), (sum, start, r=14, n=1)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, call_sum, r=12, n=2), (sum, call_sum, r=14, n=1)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, call_sum, r=12, n=2), (sum, call_sum, r=14, n=1), (sum, start, r=15, n=0)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, call_sum, r=12, n=2), (sum, call_sum, r=14, n=1), (sum, return_r, r=15, n=0)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, call_sum, r=12, n=2), (sum, return_sum, r=14, n=1, return_sum_value=15)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, call_sum, r=9, n=3), (sum, return_sum, r=12, n=2, return_sum_value=15)}
{(sum, call_sum, r=0, n=5), (sum, call_sum, r=5, n=4), (sum, return_sum, r=9, n=3, return_sum_value=15)}
{(sum, call_sum, r=0, n=5), (sum, return_sum, r=5, n=4, return_sum_value=15)}
{(sum, return_sum, r=0, n=5, return_sum_value=15)}
它的特点是在计算过程中,栈的长度先是一直增长,等到第一个返回出现的时候,一路都是返回,栈的长度是一直减少。
用过程的步数(也就是上面过程的高度)来代表计算复杂度,那么,也就是说它的计算复杂度与递归深度是一样的,这和树递归非常不一样,之前的斐波那契数列的递归,对于fib(n)的计算,栈深度是n的线性级别,可计算复杂度却是n的指数级别。
栈的长度关系到空间复杂度,我们其实没有必要维护这样一个栈,
于是我们灵机一动,换个思路,尾递归不就是不断在改变函数的自变量直到符合条件退出吗?
r=0, n=5
r=5, n=4
r=9, n=3
r=12, n=2
r=14, n=1
r=15, n=0
n=0符合退出条件,返回15
于是这就可以是循环要做的事情啊
一般性的考虑尾递归转换为循环,可以如下:
所有尾递归发生的地方都当成是修改自变量,然后整个函数体前面加上循环。
比如如下这样的函数是个尾递归,为了问题简单化一点,s只有一个参数,实际上多参数函数只需要传入参数改为多参数的tuple,就可以改为单参数函数。
例子中所有的函数cond1、cond2、cond3、t、t2都是随便可以替代的,分支个数什么的都可以更改。
def func(s):
if cond1(s):
retrurn f(s)
elif cond2(s):
return func(t(s))
elif cond3(s):
retrurn f2(s)
else:
return func(t2(s))
变形为:
def func_circle(s):
while True:
if cond1(s):
retrurn f(s)
elif cond2(s):
return func(t(s))
elif cond3(s):
retrurn f2(s)
else:
return func(t2(s))
再将所有的尾递归发生的地方改为对参数的改变(也是是看成是状态改变)
def func_circle(s):
while True:
if cond1(s):
retrurn f(s)
elif cond2(s):
#return func(t(s))
s = t(s)
elif cond3(s):
retrurn f2(s)
else:
#return func(t2(s))
s = t2(s)
以上就改完了,
对于之前sum函数,我们也来这样变动一下:
def sum(r, n):
if n == 0:
return r
return sum(r+n, n-1)
套上死循环,注意最后改成else
def sum_circle(r, n):
while True:
if n == 0:
return r
else:
return sum(r+n, n-1)
再把尾递归的地方改为状态转换
def sum_circle(r, n):
while True:
if n == 0:
return r
else:
r, n = (r+n, n-1)
上面就转换成功了。
尾调用优化
我们来看一个C语言的例子
//even.c
#include <stdint.h>
int is_odd(uint64_t n);
int is_even(uint64_t n) {
if (n == (uint64_t)0) {
return 1;
}
return is_odd(n - (uint64_t)1);
}
//odd.c
#include <stdint.h>
int is_even(uint64_t n);
int is_odd(uint64_t n) {
if (n == (uint64_t)0) {
return 0;
}
return is_even(n - (uint64_t)1);
}
//test.c
#include <stdint.h>
#include <stdio.h>
int is_even(uint64_t n);
int main() {
uint64_t x = (uint64_t)10000000000ull;
if (is_even(x)) {
printf("%llu is even\n", (unsigned long long)x);
} else {
printf("%llu is not even\n", (unsigned long long)x);
}
return 0;
}
以上是三个C文件,其实就是判断偶数的is_even和判断奇数的is_odd互相递归,先单独编译成o文件,再链接为可执行文件。
gcc -c even.c -o even.o
gcc -c odd.c -o odd.o
gcc -c test.c -o test.o
gcc even.o odd.o test.o -o a.out
执行a.out发现崩溃,符合我们的逾期,它有非常长的运行栈,甚至超过我电脑内存,必然会爆掉。
但我们加上-O2优化编译再链接
gcc -O2 -c even.c -o even.o
gcc -O2 -c odd.c -o odd.o
gcc -O2 -c test.c -o test.o
gcc even.o odd.o test.o -o a.out
此时执行a.out发现过了几秒之后,打印出
10000000000 is even
可能会有点疑惑,因为三个文件是单独编译的,之后链接并不存在整体优化,也就是一直无法串通。而is_even和is_odd互相不知道对方是干嘛的。
脑洞大开一下,是不是编译器通过名字猜出功能?
好吧,虽然想法离谱,但还是试试,把is_even和is_odd名字改成谁也猜不明白的样子,把printf里的内容写成乱码,再做一遍,依然如此,没有崩溃。
其实,我们观察一下这里is_even和is_odd的实现,发现它和尾递归很像,它除了彻底退出之外,会全部转化为调用另外一个函数,而尾递归是全部转化为调用自身。
我们可以这样想,当转化为另一个调用的时候,原来函数的任何自变量已经没有存在的意义,那么直接覆盖掉就行了。
is_even(100)
is_odd(99)
is_even(98)
is_odd(97)
...
这样一路算下去即可,所以我们只需要保留一组函数调用的状态就可以了,完全不需要压栈。
这样调用方式叫尾调用,是比尾递归更一般的形式。
当函数判断到自己存在尾调用的地方,直接从运行栈上把自己的那部分弹出,修改成新的函数然后压入即可。如此,就不会在反复调用的时候导致栈不断增长。
更一般的情况
我们来考虑阶乘,定义
n!=\\prod_{i=1}\^{n}{i}
用尾递归实现:
def factorial(n):
def factorial_iter(r, n):
if n == 0:
return r
return factorial_iter(r * n, n - 1)
return factorial_iter(1, n)
但很容易就写成以下的写法,
def factorial(n):
if n == 0:
return 1
return factorial(n - 1) * n
以上这一段不是尾递归/尾调用,因为它不是完全转化为一个函数调用,这样的写法一般情况下执行会拖着一个运行栈。
这一段代码符合以下:
def func(s):
if cond(s):
return f(s)
else:
return g(func(t(s)), s)
其中cond、f、g、t都是函数,实际上,上面最后一段阶乘写法,换成这样写可以是
def factorial(n):
if (lambda n : n == 0)(n):
return (lambda n : 1)(n)
else:
return (lambda a, b : a * b)(factorial((lambda n : n - 1)(n)), n)
也就是,这里
cond = lambda n : n == 0
f = lambda n : 1
g = lambda a, b : a * b
t = lambda n : n - 1
我们看看这样的写法能不能有机会优化
计算过程也是一开始一直计算func的参数
func(s)
g(func(t(s)), s)
g(g(func(t(t(s))), t(s)), s)
g(g(g(func(t(t(t(s)))), t(t(s))), t(s)), s)
g(g(g(g(func(t(t(t(t(s))))), t(t(t(s)))), t(t(s))), t(s)), s)
...
直到发现一个t\^n(s)满足cond,然后从最里层一层层的计算出来
但此时最里一层是g(f(t\^n(s)), t\^(n-1)(s))
虽然t\^n(s)我们现在是知道了,但通过t\^n(s)推出t\^(n-1)(s)需要t函数存在反函数才行
如果t有反函数T
那么 t\^(n-1)(s) = T(t\^n(s))
于是一切就可以推回去了,从里往外一层层的上去就可以求值了。
我们仔细来写一下,
对于
def func(s):
if cond(s):
return f(s)
else:
return g(func(t(s)), s)
如果函数t有反函数T,那么以下优化是存在的
def func_opt(s):
rec_depth = 0
arg = s
while not cond(arg):
rec_depth += 1
arg = t(arg) #每一次都通过t转化一下
ret = f(arg)
for i in range(rec_depth):
#一层层的往外算
arg = T(arg)
ret = g(ret, arg)
return ret
如果函数t不存在反函数,那我们只能把
t(s)、t(t(s))...
这些全都记下来,才能推出来了
def func_opt_t_no_inv(s):
arg = s
arg_list = []
arg_list.append(arg) #过程中全部压进栈
while not cond(arg):
arg = t(arg)
arg_list.append(arg) #过程中全部压进栈
ret = f(arg_list.pop())
while arg_list:
arg = arg_list.pop()
ret = g(ret, arg)
return ret
我们测试一下,就以阶乘的例子
cond = lambda n : n == 0
f = lambda n : 1
g = lambda a, b : a * b
t = lambda n : n - 1
def func(s):
if cond(s):
return f(s)
else:
return g(func(t(s)), s)
#t的反函数
T = lambda n : n + 1
def func_opt(s):
rec_depth = 0
arg = s
while not cond(arg):
rec_depth += 1
arg = t(arg) #每一次都通过t转化一下
ret = f(arg)
for i in range(rec_depth):
#一层层的往外算
arg = T(arg)
ret = g(ret, arg)
return ret
def func_opt_t_no_inv(s):
arg = s
arg_list = []
arg_list.append(arg) #过程中全部压进栈
while not cond(arg):
arg = t(arg)
arg_list.append(arg) #过程中全部压进栈
ret = f(arg_list.pop())
while arg_list:
arg = arg_list.pop()
ret = g(ret, arg)
return ret
for i in range(100):
a = func(i)
b = func_opt(i)
c = func_opt_t_no_inv(i)
assert a == b == c
print(i, a)
我们发现对于0~99,三者计算结果一样,并没有触发assert异常。
多个条件分支的更一般场合
如果分支很多,超过两个,比如如下这样
def func(s):
if cond1(s):
return f1(s)
elif cond2(s):
return g1(func(t1(s)), s)
elif cond3(s):
return f2(s)
elif cond4(s):
return g2(func(t2(s)), s)
...
怎么办呢?
其实,只是比刚才那个复杂了一点点而已,我们可以考虑如下变形方式:
我们还是假设t1/t2...有反函数T1/T2...
第一步,先填写成这样:
def func_opt(s):
rec_depth = 0
while True:
## BEGIN func函数体##
if cond1(s):
return f1(s)
elif cond2(s):
return g1(func(t1(s)), s)
elif cond3(s):
return f2(s)
elif cond4(s):
return g2(func(t2(s)), s)
...
## END func函数体##
rec_depth += 1
for i in range(rec_depth):
## BEGIN func函数体##
if cond1(s):
return f1(s)
elif cond2(s):
return g1(func(t1(s)), s)
elif cond3(s):
return f2(s)
elif cond4(s):
return g2(func(t2(s)), s)
...
## END func函数体##
return ret
上下两个循环中都填入func的函数体
第二步,对上面那个func函数体做以下改造:
遇到结束的分支,改为ret = 返回值,然后break跳出循环
而递归下去的分支,改成s = t(s), t是这里s转换下去的那个函数
而对下面的那个func函数体,
遇到结束的分支,改为pass
而递归下去的分支,改为s = T(s)之后再接上ret = g(ret, s)
比如上面经过这样的修改则是
def func_opt(s):
rec_depth = 0
while True:
## BEGIN func函数体##
if cond1(s):
ret = f1(s)
elif cond2(s):
s = t1(s)
elif cond3(s):
ret = f2(s)
elif cond4(s):
s = t2(s)
...
## END func函数体##
rec_depth += 1
for i in range(rec_depth):
## BEGIN func函数体##
if cond1(s):
pass
elif cond2(s):
s = T1(s)
ret = g1(ret, s)
elif cond3(s):
pass
elif cond4(s):
s = T2(s)
ret = g2(ret, s)
...
## END func函数体##
return ret
这样,就改造好了,这样递归就改成了循环,可以去构造实例来验证。
至于t1/t2...如果有不存在反函数,需要存下来的地方,也可以自己写一写,此处不再实现。
上一节: