写在前面:
事实上,如今真正能够实现尾递归的编译器少之又少。在这里读者不必过分追求从普通递归转化到尾递归,可以仅仅将其作为知识的拓展,或者是为解题提供新的思路。
之前一直觉得自己对递归算法思考深度不足,现在用Java做了一些经典的递归练习题,并尽量将代码优化成尾递归。
回顾一下函数调用的大概过程:
1)调用开始前,调用方(或函数本身)会往栈上压相关的数据,参数,返回地址,局部变量等。
2)执行函数。
3)清理栈上相关的数据,返回。
为了更好的理解尾递归,我们先来看一下什么是尾调用
1.尾调用
在计算机科学里,尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下该调用位置为尾位置。
注意以下三个函数都不是尾调用
function foo1(data) {//最后一个动作是+1操作,并非是直接函数调用
return a(data) + 1;
}
function foo2(data) {
var ret = a(data);
return ret;
}
function foo3(data) {//是经过计算返回的结果,也不是尾调用
var ret = a(data);
return (ret === 0) ? 1 : ret;
}
接下来重点阐述什么是尾递归
2.尾递归
若一个函数在尾位置调用本身(或是一个尾调用本身的其他函数等),则称这种情况为尾递归,是递归的一种特殊情形。而形式上只要是最后一个return语句返回的是一个完整函数,它就是尾递归。这里注意:尾递归是尾调用的子集。
下面拿求阶乘作为例子来具体的说明
int fun(int n)
{
if (n<=1)
return 1;
else
return n*fun(n-1);
}
我们可以看到,fun(n) 是依赖于 fun(n-1) 的,fun(n) 只有在得到 fun(n-1) 的结果之后,才能计算它自己的返回值,因此理论上,在 fun(n-1) 返回之前,fun(n) 不能结束返回。因此fun(n) 就必须保留它在栈上的数据,直到fun(n-1) 先返回,而尾递归的实现则可以在编译器的帮助下,消除这个限制:
int tail_fun(int n,int res)
{
if (n<=1)
return res;
else
return tail_fun(n-1,n*res);
}
// 像下面这样调用
tail_fun(10000000000,1);
从上可以看到尾递归把返回结果放到了调用的参数里。这个细小的变化导致,tail_fun(n, res) 不必像以前一样,非要等到拿到了tail_fun(n-1, n*res) 的返回值,才能计算它自己的返回结果 -- 它完全就等于tail_fun(n-1, n*res) 的返回值。因此理论上:tail_fun(n) 在调用tail_fun(n-1) 前,完全就可以先销毁自己放在栈上的东西。这就是为什么尾递归如果在得到编译器的帮助下,是完全可以避免爆栈的原因:每一个函数在调用下一个函数之前,都能做到先把当前自己占用的栈给先释放了,尾递归的调用链上可以做到只有一个函数在使用栈,因此可以无限地调用!
有必要说明的一点,尾递归的写法只是具备了使当前函数在调用下一个函数前把当前占有的栈销毁的能力,但是会不会真的这样做,要具体看编译器最终的决定,如果在语言层面上,没有规定要优化这种尾调用,那编译器就可以有自己的选择来做不同的实现,在这种情况下,尾递归就不一定能解决一般递归的问题。另外,不是所有语言都支持尾递归,比如说 python。
对比优化前和优化后的递归函数,容易发现尾递归的判断条件并没有变,改变的只是返回值。这为优化提供了便利。
3.一些练习
1.斐波那契数列(计算第10位斐波那契数列的值)
import java.util.*;
public class Main {
public static int solve(int n){
if(n<=2)
return 1;
else
return solve(n-1)+solve(n-2);
}
public static void main(String[] args) {
Scanner in=new Scanner(System.in);
System.out.println(solve(10));
}
}
这种递归计算最终的return操作是加法操作,所以不是尾递归。经过试验,当n的值稍大时很容易爆栈,当n=50时短时间内就已经运行不出结果;另外,不知道为什么,运行上述代码的CPU占用率也相当之高,在我的笔记本上CPU占用率一度接近80%,因此不建议在配置较低的笔记本上运行此代码。下面将它优化成尾递归:
import java.util.*;
public class Main {
public static int solve(int n,int a,int b){
if(n<=2)
return a;
else
return solve(n-1,b,a+b);//n-2+1=n-1,反映了a+b的次数与n的关系
}
public static void main(String[] args) {
Scanner in=new Scanner(System.in);
System.out.println(solve(10,1,1));
}
}
2.请编写一段代码,要求计算1+2+3+100 并输出结果。
在这段代码中不能出现for,while关键字。
import java.util.*;
public class Main {
public static int solve(int n){//从n加到100
if(n==100)
return n;
else
return n+solve(n+1);
}
public static void main(String[] args) {
Scanner in=new Scanner(System.in);
System.out.println(solve(1));
}
}
尾递归:
import java.util.Scanner;
public class Main {
public static int solve(int n,int a){
if(n==100)
return a;
else
return solve(n+1,n+a);
}
public static void main(String[] args) {
Scanner in=new Scanner(System.in);
System.out.println(solve(1,100));
}
}
通过这两个例题,我们已经可以大致总结出从一般递归到尾递归的改进步骤:
- 如果把所有递归值串行展开,其中的任意一个值若只受相邻一个值的影响,那么它的尾递归一般有两个形参(形参1,形参2)。
形参1:即为判断条件中的变量,到形参2的差值能反映出递归次数。
形参2:递归的结束条件,并作为最终的结果返回,调用时的实参往往等于判断条件中的常量。
- 如果该值受相邻两个值(都在左边或都在右边,如斐波那契数列都在左边)的影响,那么它的尾递归一般有三个形参(形参1,形参2,形参3)。
形参1:同上。
形参2:较远值。
形参3:同1中的形参2,较近值。
下面给出模版:
elemtype fun(elemtype n,elemtype res)
{
if(结束条件)
return res;
else
return fun(n±1,res执行相关运算);
}
elemtype tail_fun(elemtype n,elemtype a,elemtype b)
{
if(结束条件)
return a;
else
return tail_fun(n±1,b,b与a执行相关运算);
}
有了模版,对递归的优化就更容易一些了。下面我将再通过一些练习进行巩固加深。为使篇幅不过于冗长,我把它放在了后续文章中,并命名为《递归算法(二)--巩固加深》。