版权申明:本文为博主窗户(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...如果有不存在反函数,需要存下来的地方,也可以自己写一写,此处不再实现。
上一节:
浙公网安备 33010602011771号