经典算法题之(九)------ 尾递归

尾递归是啥

简之,尾递归 = 尾调用+递归,对于这二者的认识可以先看这篇:浅谈尾递归
一个简单的尾递归例子如下:

public int f(int n){
    if(0 == n)
        return 0;
    else
        return f(n-1);     // 直接调用f(),没有赋值,运算
}

使用尾递归要注意以下几点:

尾调用一定要是return f(x)的形式,不能有任何其他的“运算”

比如:

  • 赋值变量后返回
public int f(int n){
    if(0 == n)
        return 0;
    else{
        int result = f(n-1);   // 赋值
        return result;
    }   
}
  • 加减乘除
public int f(int n){
    if(0 == n)
        return 0;
    else
        return 1+f(n-1);   // 进行运算
}

即使是f()之间的运算也不行:

public int f(int n){
    if(n < 2)
        return n;
    else
        return f(n-1)+f(n-2);   // f()之间进行运算
}

原因
这样的严格要求的原因在于:编译器对于尾调用优化的基础,就是尾调用在函数的“最后一步”,可以认为当前函数已经执行完毕,本层函数的返回结果由下层函数直接返回。故当前函数对应的栈帧可以直接pop掉,push进下一层函数的栈帧。

故对于尾递归(函数最后一句自己调用自己),函数栈的深度可以一直维持1的深度

而对于:

public int f(int n){
    if(0 == n)
        return 0;
    else{
        int result = f(n-1);   // 赋值
        return result;
    }   
}

在倒数第二句即“下”到下一层函数中去了,即本层函数的栈帧没有pop掉,就要push进f(n-1)的栈帧,相应地,f(n-1)的倒数第二行又会再次push f(n-2)的栈帧,这就没有达到尾递归的优化目的。
而对于:

public int f(int n){
    if(0 == n)
        return 0;
    else
        return 1+f(n-1);   // 进行运算
}

编译器分析到1+f(n-1)时,由于执行1+(即加法操作)之前需要先计算出f(n-1)的结果,于是编译器“认为”当前函数没有执行完毕,只能“中途”调用f(n-1),即先保存当然函数现场,然后push f(n-1)的栈帧。联想一下CPU中断的实现——保护现场,从这个角度来说,在最后一行还是第一行进行1+f(n-1),空间上的开销是一样的——保存原函数栈帧,再push进下一层函数栈帧,函数栈深度+1。

关于return f(n-1)+f(n-2)的分析是一样的,不再赘述。

如何优化最后一步有运算的情况

显然,如果能写成尾递归的形式,无疑能大大节省空间上的开销,避免爆栈的风险,那么对于上面最后一步有运算的情况,有什么方法可以优化为尾递归呢。如之前提到的:

public int f(int n){
    if(0 == n)
        return 0;
    else
        return 1+f(n-1);   // 进行运算
}

可以先从原因入手:编译器为什么不能对上面的代码进行尾递归优化?—— 因为编译器认为执行到f(n-1)时,当前的函数(f(n))还没执行结束,换言之,进入下层递归之前需要记录f(n)的PC(Program Counter)的位置,需要记录本层函数各个变量的值,在下层函数调用完毕返回时,恢复现场,继续从PC位置继续进行运算。

所以问题就变成——我们怎么样让编译器认为当前函数在f(n-1)就执行完毕了呢?

答案是:把加数放入形参中,交给下一层函数进行

太过抽象,看上面的例子的改版:

public int f(int n, int last){
    if(0 == n)
        return last;
    else
        return f(n-1, 1+last);   // 引入形参进行计算
}

可以看到,将在f()的形参中引入一个用于存储上层计算结果的int last变量,将原来的1+f(n-1)换为f(n-1, last+1),这样就成功转化为尾递归的形式了。但是要特别注意的是,f()的递归出口同样需要修改,并且第一次调用时需要last的实参应该为0:f(n,0)。

那么对于之前斐波那契数列该怎么改呢?

public int f(int n){
    if(n < 2)
        return n;
    else
        return f(n-1)+f(n-2);   // f()之间进行运算
}

很明显,我们需要引入两个形参存储f(n-1)和f(n-2),看下面代码:

public static int f(int n){
    if(n < 2)
        return 1;
    else
        return Fibonacci(n, 1, 1);   // 调用时用1,1进行初始化
}
// 工具函数
public static int Fibonacci(int n, int f1, int f2){
    if(1 == n)   // 尾递归只会一步步向下,1就是递归出口
        return f2;
    else
        return Fibonacci(n-1, f2, f1+f2);
}

public static int f(int n){
    if(n < 2)
        return 1;
    else
        return Fibonacci(n, 0, 1);   // 调用时用0,1进行初始化
}
// 工具函数
public static int Fibonacci(int n, int f1, int f2){
    if(1 == n)   // 尾递归只会一步步向下,1就是递归出口
        return f1+f2;   // 第一次调用Fibonacci时f1,f2的初始值不同,此处返回表达式也不同
    else
        return Fibonacci(n-1, f2, f1+f2);
}

代码参考:浅谈尾递归

关于尾递归的思考

有这么一个通识:所有的递归都可以改写为迭代。

概念上的证明很容易:递归的实际执行就是通过栈这个顺序结构来实现的,单个CPU也只能执行顺序的指令流,所以所有的递归都可改为串行的指令,而递归中的“自己调用自己”意味着对于每一层的栈帧,其实代码都是一样的,只是形参不同,这提示我们:递归不仅可以写成顺序的指令流(单个CPU只能执行顺序的指令流),而且这个指令流还是个“周期函数”,可以用循环(while, do while或者for)来实现它,只需要在每次循环开始之前初始化好局部变量的值即可,当然具体实现时这部分(初始化)可以放在循环体前面也可以放在循环体后面。

这个只是在概念上证明时可行的,具体到各个形形色色的递归方法,转换为迭代还是有难度的,不过对于二叉树的递归转迭代还是需要掌握,具体实现可以见下面链接:

144. 二叉树的前序遍历

94. 二叉树的中序遍历

145. 二叉树的后序遍历

但是对于尾递归,是很容易转换成迭代的,其实从上面 1+f(n-1)和 f(n-1)+f(n-2)转化为尾递归的例子也可以体会到,观察尾递归的参数变化规律即可。

如下面两个尾递归转化为迭代:

public int f(int n, int last){
    if(0 == n)
        return last;
    else
        return f(n-1, 1+last);   // 引入形参进行计算
}

迭代形式:

public int f(int n){
    int sum = 0
    while(n-- > 0)
        sum++;
    return sum; 
}

其实掌握了数学规律以后,可以直接推导出”通项公式“——result = n,迭代都不需要:

public int f(int n){
    return n; 
}

斐波那契:

public int f(int n){
    if(n < 2)
        return n;
    else
        return f(n-1)+f(n-2);   // f()之间进行运算
}

迭代形式:

public static int f(int n){
    if(n < 2)
        return n;
    int f1 = 1;
    int f2 = 1;
    int count = 2;
    while(count++ < n){
        int temp = f1+f2;     // 完成之前的尾递归的参数变换
        f1 = f2;
        f2 = temp;
    }
    return f2;
}

但是,一个几乎都知道的结论是,迭代的效率要比递归好得多,无论是空间还是时间上都好得多,即使是优化了的尾递归也比不上迭代——就算空间上是常数,pop,push栈帧(肯定涉及变量的空间申请,代码的重复执行)的开销也比迭代差得多,毕竟后者只使用一个栈帧,并且是常数变量——并不需要重复申请销毁的常数变量。

发布了149 篇原创文章 · 获赞 25 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/ProLayman/article/details/104026035
今日推荐