递归的编译优化(2)

尾递归

所谓尾递归,就是虽然是递归的形式,自身调用自身,并且和其他递归形式不一样,尾递归最终是完全转换成了自身的函数调用。

比如,我们计算


n
i

1

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

!

n
i

1

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

复制代码

相关推荐
小李飞刀李寻欢3 小时前
DeepSeek V3 版本模型结构分析
算法·大模型·deepseek
某不知名網友4 小时前
C++ 七大排序算法完整讲解
java·算法·排序算法
得物技术4 小时前
得物推荐系统诊断 Agent:从 “调接口” 到 “会思考”|AICon 演讲整理
人工智能·算法·架构
Lugas4 小时前
为啥说男生找对象尽量在25岁前找到?
算法
MrZhao4004 小时前
从能跑到可用:一个 Agent Harness 还差哪些工程闭环?
算法
QN1幻化引擎5 小时前
Gravity-Anchored Cognitive Field Architecture: The DalinX V8/V10 Implementation
java·前端·算法
学计算机的计算基5 小时前
LeetCode 图论四题精讲:BFS、拓扑排序、Trie 树的模板与优化
java·笔记·算法
浩瀚地学5 小时前
【面试算法笔记】0202-链表-基本功能实现
java·经验分享·笔记·算法·面试
tkevinjd5 小时前
416分割等和子集
java·python·算法·leetcode·职场和发展
Keven_116 小时前
算法札记:Tarjan与拓扑序(Topo)的关系
算法·拓扑·tarjan