递归的编译优化(2)

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

上一节:

递归的编译优化(1)

相关推荐
狼爷13 天前
吃透 Java Function 接口,搞定 99% 的 Stream 场景
java·函数式编程
文盲老顾25 天前
sqlserver 根据IP和数量,计算应该使用的掩码IP地址段
sqlserver·递归·自定义函数·cte·ip掩码·表值函数
码上有光1 个月前
c++:二叉搜索树(map和set的底层结构)
开发语言·c++·递归·二叉搜索树
8Qi81 个月前
LeetCode 235. 二叉搜索树的最近公共祖先(LCA)
算法·leetcode·二叉树·递归·二叉搜索树·lca·迭代
8Qi81 个月前
LeetCode 236. 二叉树的最近公共祖先(LCA)
算法·leetcode·二叉树·递归·lca·后序遍历
8Qi81 个月前
LeetCode 124. 二叉树中的最大路径和(Hard)
算法·leetcode·二叉树·递归
8Qi81 个月前
LeetCode 148. 排序链表 —— 解法一:自顶向下递归(分治 + 归并)
数据结构·算法·leetcode·链表·递归·分治·归并
浅念-2 个月前
递归解题指南:LeetCode经典题全解析
数据结构·算法·leetcode·职场和发展·排序算法·深度优先·递归
nuowenyadelunwen2 个月前
CS 61A Lab 2 笔记:短路求值、高阶函数与 Lambda 表达式
python·函数式编程·cs61a·berkeley