我们在学数据结构的时候必然会接触栈(Stack),而栈有一个重要的应用是在程序设计语言中实现递归。递归用途十分广泛,比如我们常见的阶乘,如下代码:

1
2
3
4
public static int (int n) {
if (n == 1) return 1;
return n * func1(n - 1);
}

就可以用递归实现,而且实现相当简洁。如果要计算n的阶乘,那么只需知道n-1的阶乘再乘以n,同理依次类推,直到当我们计算2的阶乘的时候,只需知道1的阶乘,显然这是递归终止条件,再层层向上返回,直至计算出n的阶乘即可。

从上面的分析可以看出,如果我们要进行递归求解某一问题,需要满足以下两个条件:

  • 能将一个问题转变为另一个新问题,而新问题的解法与原问题相同或者类同,并且新问题的数据规模更小,问题简化。

使用递归的情景是当前数据规模较大,直接计算比较困难,那么可以将该问题进行分解,数据规模越来越小,计算也越来越容易,其实这是“分治法”的体现。

  • 存在递归终止条件,或者说递归的边界。

递归的终止条件是必须的,既然当前问题可以分解,那么就必须存在一个“极限”,分解到什么程度?到哪里停止?

“分治法”求解递归问题算法有一个一般形式:

1
2
3
4
void p(参数列表) {
if (递归终止条件成立) 直接求解; // 递归终止条件
else p(较小的参数) // 递归步骤
}

写递归代码要考虑两点:问题的递推公式以及终止条件。比如阶乘问题,它的递推公式为:

1
f(n) = n * f(n - 1)

这个自然很明显,如果遇到复杂的递归问题,那么推导问题的递推公式是十分重要的,同时推导出问题的终止条件,问题就好解决了。

递归代码要警惕堆栈溢出

就拿Java来说,栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里入栈与出栈的过程。

在Java虚拟机规范的描述中,我们可以查到这样的描述:

1
If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a StackOverflowError.

上面的描述大意是:如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常,如下所示:

1
Exception in thread "main" java.lang.StackOverflowError

所以我们在写递归代码时,时刻警惕堆栈溢出异常。当递归代码调用层次很深,超过了虚拟机所允许的最大深度,就会出现上面的异常了。

递归代码要注意重复计算问题

还有一个比较容易被忽略的问题是,我们在写递归代码时,有可能进行重复计算。就拿我们最熟悉的Fibonacci数列来说,下面是我们常见的Fibonacci数列递归代码:

1
2
3
4
5
6
7
public static int fib(int n) {
if (n == 1 || n == 2) {
return 1;
} return fib(n - 1) + fib(n - 2);
}

观察上面的代码我们可以发现,每计算一个数,都要计算两个数的值,其中就会出现计算重复的情况,比如计算fib(10), 需要计算fib(9) 和 fib(8),计算fib(9),需要计算fib(8) 和 fib(7),发现fib(8)被计算了两遍,如下所示:

1
2
3
4
5
6
    10
/
9 8
/ /
8 7 7 6
...

所以我们能不能将计算的结果缓存起来呢?这样当计算某一个数的时候,都去缓存里面查一下,来避免重复计算。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static int fib2(int n) {
if (n == 1 || n == 2) {
return 1;
} if (cache.containsKey(n)) {
return cache.get(n);
} int res = fib(n - 1) + fib(n - 2);
cache.put(n, res);
return res;
}

尾调用(tail call)与尾递归(tail recursion)

在了解尾递归之前,我们先了解下什么是尾调用?在阮一峰的尾调用优化这篇文章中详细描述了尾调用,尾调用就是指某个函数的最后一步是调用另一个函数,如下所示:

1
2
3
function f(x){
return g(x);
}

就是函数最后执行一步时直接调用了另一个函数,没有当前方法的局部变量再参与运算。

上面我们说到递归,因为递归是自己调用自己,如果尾调用自身,就称为尾递归。
这是什么意思呢?看如下代码:

1
2
3
4
public int func2(int n, int total) {
if (n == 1) return total;
return func2(n - 1, n * total);
}

我们将上面提到的阶乘用尾递归来改写下,发现多了一个参数,比如我们计算5的阶乘,计算情况如下:

1
2
3
4
5
6
7
8
9
func2(5, 1)

func2(4, 5)

func2(3, 20)

func2(2, 60)

func2(1, 120)

那么普通的阶乘递归和尾递归计算阶乘啥区别呢?

在普通递归中,比如计算func(n), func(n) 是依赖于 func(n-1) 的,func(n) 只有在得到 func(n-1) 的结果之后,才能计算它自己的返回值,因此理论上,在 func(n-1) 返回之前,func(n)不能结束返回。因此func(n)就必须保留它在栈上的数据,直到func(n-1)先返回,这样就可能出现栈溢出的异常。

对于尾递归来说,我们从上面的尾递归程序可以看出将上一次的计算结果放到调用参数里,func2(n, total)必须要像以前那样,必须等到 func2(n - 1, n total) 返回再计算自己,它的值其实就是 func2(n - 1, n total) 的返回值。所以在func2(n, total) 等待 func2(n - 1, n * total) 调用之前,就可以把自己栈上的数据销毁了。形象地说,只有向下计算,不需要再向上返回了。理论上说,尾递归不会出现栈溢出的异常。具体的尾递归在编译器的实现方式,需要编译器来支持。

现在有一个问题,Java现在支持尾递归吗?我们可以来运行一下上面的程序,发现没什么卵用。。

使用Stream延迟计算优化尾递归

如果我们对JDK8新加入的Stream有所了解的话,可以得知Stream支持延迟计算,比如Stream的Laziness-seeking特点是这样介绍的:

1
Many stream operations, such as filtering, mapping, or duplicate removal, can be implemented lazily, exposing opportunities for optimization

所以我们可以利用Stream将递归调用延迟化来避免栈帧的创建。Stream可以利用iterate方法生成无限长度的Stream,它有两个参数,第一个初始值,第二个是指定的生成函数,UnaryOperator类型,下面是利用iterate打印正整数的例子:

1
Stream.iterate(1, item -> item + 1).limit(10).forEach(System.out::println);

此时我们需要考虑以下几点:

  1. 递归之间的关系表示
  2. 判断递归结束标志
  3. 获取递归计算结果
  4. 触发递归函数

所以现在可以设计如下函数接口,用@FunctionalInterface注解声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
大专栏  递归、尾递归和使用Stream延迟计算优化尾递归="line">26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public interface TailRecursion<T> {

     * 用于递归栈帧之间的连接,惰性求值
*
* @return 下一个递归栈帧
*/
TailRecursion<T> apply(); * 判断当前递归是否结束
*
* @return 默认为false
*/
default boolean isFinished() {
return false;
} * 获得递归结果,只有在递归结束才能调用,这里默认给出异常,通过工具类的重写来获得值
*
* @return 递归最终结果
*/
default T getResult() {
throw new Error("递归还没有结束,调用获得结果异常!");
} * 执行一系列的递归,因为栈帧只有一个,所以使用findFirst获得最终的栈帧,接着调用getResult方法获得最终递归值
*
* @return 及早求值,获得最终递归结果
*/
default T invoke() {
return Stream.iterate(this, TailRecursion::apply)
.filter(TailRecursion::isFinished)
.findFirst()
.get()
.getResult();
}
}

上面的函数接口中,apply()方法和iterate的第二个参数UnaryOperator作用一致,用于将所有的递归计算放入到Stream里面。isFinished()用于判断是否计算结束,对于递归的中间步骤,该方法返回false,对于最后一步,该方法返回true。getResult()用于获取递归的最终结果(仅当递归结束)。

invoke方法则是最重要的一个方法,它会将所有的递归操作通过apply方法连接起来,通过没有栈帧的尾调用得到最后的结果。利用Stream类型提供的iterate方法,在所有的递归调用中,只有最后一个递归调用会在isFinished中返回true,当它被调用时,也就意味着整个递归调用链的结束。最后,通过findFirst来返回这个值。

对尾递归调用的统一包装

前面说到尾递归,用尾递归的一般形式来表示:

1
2
3
4
void p(参数列表) {
if (递归终止条件成立) 直接求解; // 递归终止条件
else p(较小的参数) // 此处必须为尾调用
}

所以我们需要设计对上面尾递归函数式接口包装一下,使其能够符合用尾递归进行计算的一般形式,主要有两点:

  • 调用下次递归
  • 用来结束一系列的递归操作,得到最终的结果

下面是具体包装实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class TailInvoke {

     * 获得当前递归的下一个递归
*
* @param nextFrame 下一个递归
* @param <T> T
* @return 下一个递归
*/
public static <T> TailRecursion<T> call(final TailRecursion<T> nextFrame) {
return nextFrame;
} * 结束当前递归,重写对应的默认方法的值,完成状态改为true,设置最终返回结果,设置非法递归调用
*
* @param value 最终计算结果
* @param <T> T
* @return 一个isFinished状态true的尾递归, 外部通过调用接口的invoke方法及早求值, 启动递归求值。
*/
public static <T> TailRecursion<T> done(T value) {
return new TailRecursion<T>() {
@Override
public TailRecursion<T> apply() {
throw new Error("递归已经结束,非法调用apply方法");
} @Override
public boolean isFinished() {
return true;
} @Override
public T getResult() {
return value;
}
};
}
}

我们用上面封装的包装类来求解阶乘问题,代码如下:

1
2
3
4
5
6
public static TailRecursion<Long> factorialTailRecursion(final long n, final long total) {
if (n == 1)
return TailInvoke.done(total);
else
return TailInvoke.call(() -> factorialTailRecursion(n - 1, n * total));
}

函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。

1
2
3
public static long factorial(final long n) {
return factorialTailRecursion(n, 1).invoke();
}

References:

向本文提出修改或勘误建议

递归、尾递归和使用Stream延迟计算优化尾递归的更多相关文章

  1. 驳 GarbageMan 的《一个超复杂的简介递归》——对延迟计算的实验和思考

    这是一篇因骂战而起的博文,GarbageMan 在该文章回复中不仅对我进行了侮辱,还涉及了我的母校,特写此文用理性的分析和实验予以回击. 在此也劝告 GarbageMan,没什么本事就别在那叫嚣了,还 ...

  2. Python类属性的延迟计算

    所谓类属性的延迟计算就是将类的属性定义成一个property,只在访问的时候才会计算,而且一旦被访问后,结果将会被缓存起来,不用每次都计算. 优点 构造一个延迟计算属性的主要目的是为了提升性能 实现 ...

  3. 【MySQL】查询优化实例解析-延迟关联优化

    [提出问题] 从数据表t通过分页查询的方式读取数据,读取时要根据a1排序.t有80万行记录,当OFFSET很大时,读取速度很慢.优化后查询速度提升很快. 下图是表的定义,一共有几十个字段,RowLen ...

  4. EasyPlayer Android RTSP播放器延迟再优化策略

    EasyPlayer延迟再优化策略 EasyPlayer是一款专门针对RTSP协议进行过优化的播放器.其中两个我们引以为傲的的优点就是起播快和低延迟.最近我们遇到一些需求,其对延迟要求非常苛刻,于是我 ...

  5. 利用类装饰器自定制property实现延迟计算

    class LazyProperty: ''' hello,我是非数据描述符(没有定义__set__,不然是大哥数据描述符了--!) ''' def __init__(self, func): pri ...

  6. MySQL 分页查询优化——延迟关联优化

    目录 1.   InnoDB表的索引的几个概念 2.   覆盖索引和回表 3.   分页查询 4.   延迟关联优化 写在前面 下面的介绍均是在选用MySQL数据库和Innodb引擎的基础开展.我们先 ...

  7. python 了解一点属性的延迟计算

    写在前面 本以为百度搜索这类知识的文章应该有很多, 然后我看了前面几篇后,基本上都是类似的内容,我想找些与众不同的博客看下,来拖宽这方面的广度,我就随机点到了第10页,结果第10页的内容基本跟属性的延 ...

  8. 2019牛客多校第二场F Partition problem 暴力+复杂度计算+优化

    Partition problem 暴力+复杂度计算+优化 题意 2n个人分成两组.给出一个矩阵,如果ab两个在同一个阵营,那么就可以得到值\(v_{ab}\)求如何分可以取得最大值 (n<14 ...

  9. 【Java】斐波那契数列(Fibonacci Sequence、兔子数列)的3种计算方法(递归实现、递归值缓存实现、循环实现、尾递归实现)

    斐波那契数列:0.1.1.2.3.5.8.13………… 他的规律是,第一项是0,第二项是1,第三项开始(含第三项)等于前两项之和. > 递归实现 看到这个规则,第一个想起当然是递归算法去实现了, ...

随机推荐

  1. 磁力搜索导航,MagnetW将搜索结果格式化统一显示

    简介 magnetW基于magnetX的规则原理,将各个磁力站的搜索结果统一格式化 安装 从Github Releases或者Github Wiki下载对应平台 3.1.1 更新了一批规则 支持Soc ...

  2. javascript获取数组最后一个元素(三种方法)

    JavaScript 获取Array末尾元素 一.JavaScript pop() 方法 pop() 方法用于删除并返回数组的最后一个元素. 注意:pop() 方法将删除 arrayObject 的最 ...

  3. Downton Abbey

    1. 当女儿以为泰坦尼克号不会沉的时候,父亲用了一个有意思的比喻: - I thought it was supposed to be unsinkable. - Every mountain is ...

  4. chromosome interaction mapping|cis- and trans-regulation|de novo|SRS|LRS|Haplotype blocks|linkage disequilibrium

    Dissecting evolution and disease using comparative vertebrate genomics-The sequencing revolution   s ...

  5. 算法笔记4.3递归 问题 B: 数列

    题目描述 编写一个求斐波那契数列的递归函数,输入n 值,使用该递归函数,输出如下图形(参见样例). 输入 输入第一行为样例数m,接下来有m行每行一个整数n,n不超过10. 输出 对应每个样例输出要求的 ...

  6. if necessary

  7. ROS中的日志(log)消息

    学会使用日志(log)系统,做ROS大型项目的主治医生 通过显示进程的运行状态是好的习惯,但需要确定这样做不会影响到软件的运行效率和输出的清晰度.ROS 日志 (log) 系统的功能就是让进程生成一些 ...

  8. ionic2踩坑之订阅发布模式的实现

    原文地址:http://www.cnblogs.com/eccainiao/p/6429536.html 转载请说明. 在ionic2中实现订阅发布模式,需要用到Events. Events下面有三个 ...

  9. 76)PHP,session数据区的使用

    (1)session数据区: 在我们的session会话结束之前,会有一个seeeion的文件,存储着session序列化的数据,在会话没有结束之前,我们都是操作$_SESSION的,但是在会话结束后 ...

  10. JS代码,从一个数组中得到连号的数并显示

    JavaScript code function m() { var k = [1, 2, 7, 8, 9, 11, 22, 35, 36]; ) return; ; ; ; ; i < k.l ...