经典递归分析

在前面一篇中, 已经看过许多直观的递归的例子, 在这篇里, 将分析两个经典的递归问题, 阶乘与菲波那契数列数列, 在此过程中, 还将对比递归与循环(迭代)间的异同, 探讨递归与内存中的栈的关系, 以及递归的效率等问题.

如无特别说明, 示例使用的是 Java, IDE 则为 Eclipse.

阶乘(factorial)

阶乘大家应该都很熟悉了. 下面是一些简单例子:

2!= 2×1=2

3!= 3×2×1=6

用一个简单的循环就可以把它写出来. 不过我们现在打算用递归来写.

两种情形(case)

递归通常有两部分, 一是基本情形(base cases), 二是递归情形(recursive cases).

发现 case 这个词不好翻译~大家知道是怎么回事就好了, 跟 switch 语句中的 case 一个意思. (我也不想去查证标准的翻译究竟是怎样了~)

注: 上面的 cases 是复数, 即无论是基本情形还是递归情形都可能是一个或多个.

递归基本可表述成以下类似的结构:

递归调用(参数) {
if (基本情形) {
// …
// 完成, 收工
} else { // 递归情形
// …
递归调用(新参数);
}
}

因此, 写递归程序一是明确基本情形, 二是找出递归情形.

基本情形(base case)

对阶乘而言, 0!= 1, 这就是我们的基本情形.

当然你可能认为 1!= 1 才是 base case, 这样也 OK, 这里不打算去争论这些.

据此, 我们就可以写出如下代码:

static int factorial(int num) {if (num == 0) {return 1;} else {// TODO}
}

递归情形(recursive case)

好了, 现在看递归模式. 这与上一篇中那个捧着画框的蒙娜丽莎模式非常像:

观察 3! 与 2!=21 就不难看出, 3!=321=3(2!), 即 3! 建立在 2! 的基础上, 推广至更一般的情况, 我们有:

N! = N * (N – 1)!

据此, 就可以完成程序了:

static int factorial(int num) {if (num == 0) {return 1;} else {return num * factorial(num - 1);}
}

你可以把 num - 1 写成 --num, 这样看上去更紧凑.

不过, 如果你用代码质量检查工具 sonar 去检查的话, 它会抱怨你这种用法.

事实上, 如果你不小心写成了 num--, 就变成无限递归了, 你将收到一个 stack overflow 的错误. 所以, 老实写成 num - 1 也许才是最好的.

另: 阶乘增长是很快的, 用 int 的话很快就会溢出.

递归 vs 循环(迭代, iteration)

我们可能更加习惯比如以 for 或者 while 循环写就的代码, 但这两种方式其实区别不大.

兜圈子的游戏

飞去来器(boomerang)又名回旋镖, 自归器, 飞去飞来器等其它名称, 顾名思义就是飞出去以后兜个圈子后又会再飞回来. (图片来自百度百科)

回旋镖

你一次次反复地把它丢出去, 它也会一次次的回来.

递: 从递增, 递减, 递推等词语中, 它有反复地, 一次又一次地, 一个接一个的等意思;

归: 回来

从某种意义上说, 无论是迭代还是递归, 两者都是在兜圈子, 下图展示了这一点:

递归 vs 迭代, 兜圈子

迭代在 while 语句块中兜圈子, 递归则不过是在方法层面上兜圈子, 因此都能达到反复执行某些代码的目的.

所以, 递归也是一种循环, 我们一般意义上说的循环其实应该叫迭代.

递减与终止条件

此外, 两者都有类似的终止条件(上图蓝线部分)以及一个递减操作(上图黄线部分).

当然也可能是递增到某个终止条件, 总之是要往终止条件靠近.

如果无法终止, 一个出现死循环, 一个则是无限递归.

无限递归通常很快会以栈内存溢出(stack overflow)而终止.

死循环如果有在块中不断创建对象, 也会引发堆内存不足;另一情况则是只是修改本地同一个变量, 这样内存占用不会上升, 但 CPU 会一直处于高负荷, 甚至导致其它进程乃至整个系统假死.

总之, 不能终止的话都将导致很严重的后果. 前面说到, 如果不小心把自减–写在变量后面,

也即先传递值, 后自减(写在后面就是后自减), 这样就导致了传递的还是原来的值.

那么就会引发以下错误:

stack overflow error

stackoverflow.com 这个著名的问答网站我想很多人都很熟悉了.

经上分析, 迭代与递归很相似, 那么现在问题来了:

递归迭代哪家强?

递归在方法级别上循环, 所以迭代能做的事, 它也能做;

那么递归能做的事, 迭代是不是也能做呢?

  1. 如果是简单循环, 迭代自然也是能做了.

实际上它还更擅长, 它的效率要高于递归.

注: 有种尾递归优化技术据说能使得递归的效率不逊于迭代, 但另一方面, 似乎并非所有实现了递归机制的语言都有这种优化, 所以说迭代效率要高于递归基本上是没什么问题的.

  1. 如果是复杂的情况, 迭代虽然也能做, 但做起来可能就是困难重重了.

简单地断言迭代不能完成某些递归能完成的事, 这恐怕有点武断.

这个问题有点复杂, 我看到有些说法是: 如果迭代也能完成某些复杂递归才能做到的事, 那么, 在迭代体的内部, 它可能已经不知不觉引入了某种在递归机制中才有的结构, 比如类似于**栈(stack)**的结构.

那么, 这与递归又有什么实质区别呢? 在这些问题上, 递归的优势也许正是在于它无需引入这样的结构, 从而降低了复杂度.

<<黑客与画家>>一书中也提到一种说法: "任何 C 或 Fortran 程序复杂到一定程度之后, 都会包含一个临时开发的, 只有一半功能的, 不完全符合规格的, 到处都是 bug 的, 运行速度很慢的 Common Lisp 实现. "

这里要表达的大概也是这样的意思.

注: 这些问题比较深奥, 因个人才疏学浅, 无法对上述说法作为判断, 仅当参考, 不作为结论.

递归与栈(stack)

栈对递归是必不可少的结构.

准确地讲, 方法调用离不开栈, 递归不过是一种特殊的方法调用, 即所谓的"自己调用自己".

从阶乘程序中可以看到, else 块中又调用了它自己, 这也是我们通常对于递归的理解. 自己调用自己这常会让有些人觉得困惑, 或者觉得这种机制很奇特, 不好理解.

如果试图自己抓着自己的头发, 从而把自己提起来, 显然是办不到的.

可以在 base case 中设置一个断点, 以 debug 模式下跑一下, 如下, 求 factorial(2):

递归 调试 方法调用堆栈

层叠的方法其实就是一个形象的 stack, 从上图中最下面往上走, main 方法调用了 factorial, factorial 则又调用了两次它自己.

可以看到 26 行调用了 21 行处的代码, 21 行处的代码又再次调用了自身, 最终停在了 19 行我们设置的断点上.

main 方法在 26 行处调用了另一个方法 factorial, 这导致了 main 方法被压栈(push), 也即暂停了 main 方法的执行, 转而去执行 factorial 方法.

所谓压栈也即是要记录这种调用的关系, 以便当被调用方法返回时, 能回到之前调用它的方法处继续执行.

上图无法查看每次调用时 num 的值, 要进入到 debug 的 perspective,

如果你用过 eclipse 来 debug 程序, 你应该知道什么是 debug 的 perspective(我也不清楚这词要怎么翻译).

调戏程序是每个程序员的基本功!(如果你想带点广东式普通话的韵味, 这样发音就对了~)

每次调用参数其实对应不同的值, 比如第一次时 value 为 2:

递归调用堆栈 方法参数值

第二次调用时, value 已经是 1:

递归调用堆栈 方法参数值

到了第三次调用, value 为 0, 所以 if 条件才能满足, 最终被断点捕获:

递归调用堆栈 方法参数值

下面的一个图形像地展示了递归运行的情况, 总共 6 个过程: (图片来自阶乘递归调用示例)

阶乘递归调用示例

这个图实际就是个倒过来的栈.

方法的本地变量(local variable)

方法参数及其它在方法体内声明的变量都是方法的本地变量.

mainargs 以及 result 就是本地变量.

factorial 中的 num 也是本地变量.

前面说了, 方法执行中如果调用了其它方法会导致当前方法被压栈, 最终形成一个方法调用链:

所谓的栈也就是一个 FILO(first in, last out, 先进后出)的队列.

但这还不是问题的全部, 栈中不单维护了方法间的调用关系, 更重要的是保存了这些本地变量的值, 它们被一同压到了栈中, 以备在方法最终出栈时"恢复现场".

试想有一个长长的方法, 执行到一半时调用了其它方法, 那么之前可能已经算出很多中间运算结果, 保存在方法本地变量上, 那么压栈时自然也要把这些结果保存起来.

调用实例与类的实例的对比

假如有个类 Person, 可以视作是一个对象模板, new Person 可以产生许多彼此不同的实例(instance)

类的实例 堆内存

factorial 则是一个方法模板, 当每发生一次压栈时, 则可认为是生成了一个调用的实例放在那里:

调用实例 栈内存

如果把 factorial 想像成一个对象, 用不同的参数调用时就好比 new 了一个个对象一样:

new factorial(2), new factorial(1), new factorial(0), 所以, 即便递归调用了自己, 每次调用都是独立的.

更重要的是, 每个线程都会有自己的栈, 彼此之间互不干扰.

线程各自的栈内存

所以, 如果静态地去看代码, 自己调用自己确实不好理解, 但动态地去观察它的行为, 那么, 它调用自己与调用别人又有什么区别呢?

对电脑而言, 无论是哪个方法都不过是一堆的指令, 再次执行同一堆指令与转到另一堆指令上去执行其实没有任何区别.

综上, 有了栈这种结构, 方法每次被调用时传入的参数及本地变量都能被捕获并维持, 与 while 等循环反复修改一个变量不同, 递归调用压栈时, 每个变量都有自己的一份副本.

理解内存同样是程序员的基本功. 有人揶揄 Java 说, "如果说 Java 中没有指针, 那么空指针异常又是怎么来的呢? "

如果你听说过 javascript 中的闭包(closure), 那么你可能会发现那种机制与这里谈论的机制间有那么一点相似之处, 如果不能从内存层面去思考变量的捕获与维持, 那么去理解闭包这种抽象的概念将会是件困难的事.

菲波那契数列(Fibonacci sequence)

说完了阶乘, 再说说菲波那契数列. 定义相信大家都清楚了:

简单讲就是前两项是 0 和 1, 后面的每一项则都是它之前两项之和.

具体如下: 0 1 1 2 3 5 8 13 21 34 55 89 144…

由这些数字形成的螺线:

斐波那契 螺线

自然界的一些例子:

斐波那契数列 自然界 台风 螺

递归的表达优势

数学定义如下:

斐波那契数列的数学定义

这一定义直接就可以翻译成代码:

public static int fib(int n) {if (n == 0) {return 0;} else if (n == 1) {return 1;} else {return fib(n - 1) + fib(n - 2);}
}

从中可以看出递归在表达能力上的优势, 它没有 for 循环之类那种过于强烈的命令式, 过程式的痕迹, 整个表达显得简洁有力.

这里也体现了前面说的多个 recursive cases 的情况, 这里面有两个 case: fib(n-1)fib(n-2).

随着 case 的增多, 乃至于参数的增多, 用 for 之类的循环来表达将会越来越吃力.

递归的性能问题

当然, 递归也不是没有缺点. 就目前这个程序而言, 有两个 case, 以 fib(5) 为例, 执行的展开是一种类似于树形的结构:

斐波那契 树形递归

图片来自<<计算机程序的构造和解释>>(SICP: Structure and Interpretation of Computer Programs)书中截图.

有一点很严重的就是存在巨大的重复计算的问题, 正如上图的圈出的那样, 在计算 fib(5) 时, fib(3) 被重复计算了两遍.

如果不考虑 fib(4) 中 fib(2) 的计算量, 那么从 fib(4) 到 fib(5), 计算量翻了一番, 粗略估计它的计算量随着N的增长呈现指数式的增长.

按书中说法, fib(n) 就是最接近 φn/√5 的整数(φ 的 n 次方除以根号 5), 其中 φ(phi)即是所谓的黄金分割(golden ratio), 满足方程 φ2=φ+1, φ 的值约等于 1.618.

有一种称为 memoization 的优化技术能够缓存计算的值, 从而减少重复计算.

java 中应该没有这种优化, 如果我们自己手动处理, 大概就像是引入一个 hash map, 存放着已经计算过的值. 计算一个值之前先看看这里是否已经有结果了. 当然, 如果要自己显式去处理, 整个代码就无法做到那么简洁了.

总结

有一种说法是:

"计算机软件产业最为惊人的成就, 是其持续不断地放弃硬件产业的惊人成果和稳定性. "

“The most amazing achievement of the computer software industry is its continuing cancellation of the steady and staggering gains made by the computer hardware industry.”–Henry Petroski

撇去稳定性不谈, 这里是在抱怨软件性能拖累了硬件的性能提升表现, 用户电脑的硬件在不断升级, 但用户还是觉得电脑很慢.

当然, 软件人这么任性一方面与水平有关, 但另一方面, 也存在这样的说法:

应该优化人的时间而不是机器的时间.

其实语言的发展从早期的汇编语言到现在 javascript, python, ruby 等动态语言的兴盛, 人们越来越强调的语言的表达能力. 像汇编那样晦涩难懂的语言, 即便性能很好, 今天恐怕也没人愿意去用了. 我们今天有丰富的软件可用, 高级语言降低了编程的门槛自然也是功不可没.

最近有新闻说美国总统奥巴马都开始学编程了.

因此, 应该综合地去看待递归的优缺点.

关于经典的两个递归例子就分析到这里.

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/pingmian/40904.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

介绍一些好玩且实用的开源的AI工具

介绍一些好玩且实用的开源的AI工具 随着人工智能技术的迅猛发展&#xff0c;开源社区涌现出了许多关于AI的项目&#xff0c;这些项目不仅展示了技术的创新力&#xff0c;也为开发者提供了丰富的工具和资源。本文将介绍几个既有趣又实用的开源人工智能工具&#xff0c;它们不仅…

【电路笔记】-B类放大器

B类放大器 文章目录 B类放大器1、概述2、B类放大器介绍3、推挽式配置4、限制交叉失真5、B类放大器效率6、总结1、概述 我们在之前的文章中已经知道,A 类放大器的特点是导通角为 360,理论最大效率为 50%。 在本文中,我们将详细介绍另一类放大器,称为B类放大器,它是为解决A…

GD32单片机使用TIMER进行外部计数

网上关于使用GD32单片机的ETI引脚作为计数器输入引脚的资料少之又少&#xff0c;此文希望有需要的朋友避免弯路。 我们把ETI引脚作为方波输入引脚&#xff0c;其中ETR输入引脚为PA0。通过查资料得知&#xff1a; 因此将先将引脚进行映射&#xff0c;代码如下&#xff1a; voi…

Linux_进程池

目录 1、进程池基本逻辑 2、实现进程池框架 3、文件描述符的继承 4、分配任务给进程池 5、让进程池执行任务 6、回收子进程 7、进程池总结 结语 前言&#xff1a; 在Linux下&#xff0c;进程池表示把多个子进程用数据结构的方式进行统一管理&#xff0c;在任何时候…

18. JAVA 多线程锁介绍

1. 前言 本节内容主要是对 Java 多线程锁进行介绍&#xff0c;是对锁的一个全方位的概述&#xff0c;为我们对后续深入学习不同的锁的使用方法奠定一个良好的基础。本节内容的知识点如下&#xff1a; 乐观锁与悲观锁的概念&#xff0c;以及两种锁之间的区别&#xff0c;这是并…

【unity实战】使用unity的新输入系统InputSystem+有限状态机设计一个玩家状态机控制——实现玩家的待机 移动 闪避 连击 受击 死亡状态切换

最终效果 文章目录 最终效果前言人物素材新输入系统InputSystem的配置动画配置代码文件路径状态机脚本创建玩家不同的状态脚本玩家控制源码完结 前言 前面我们已经写过了使用有限状态机制作一个敌人AI&#xff1a;【unity实战】在Unity中使用有限状态机制作一个敌人AI 那么玩…

【苍穹外卖】Day1遇到的问题

1、lombok版本不兼容问题 java: java.lang.IllegalAccessError: class lombok.javac.apt.LombokProcessor (in unnamed module 0x3278991b) cannot access class com.sun.tools.javac.processing.JavacProcessingEnvironment (in module jdk.compiler) because module jdk.comp…

Java项目:基于SSM框架实现的毕业论文管理系统【ssm+B/S架构+源码+数据库+毕业论文】

一、项目简介 本项目是一套基于SSM框架实现的毕业论文管理系统 包含&#xff1a;项目源码、数据库脚本等&#xff0c;该项目附带全部源码可作为毕设使用。 项目都经过严格调试&#xff0c;eclipse或者idea 确保可以运行&#xff01; 该系统功能完善、界面美观、操作简单、功能…

javaScript(九) 数组

] console.log(af.pop()) console.log(af) 第一个输出&#xff1a;{id:2,name:“枷”,score:“98”} 第二个输出&#xff1a;[ {id:1,name:“My”,score:“90”}, {id:3,name:“123”,score:“80”} ] Array.prototype.shift() 删除数组中的第一个元素&#xff0c;该方法…

一个项目学习Vue3---Vue计算属性

观察下面一段代码&#xff0c;学习Vue计算属性 <template><div><span>用户大于10岁的数量&#xff1a;{{ userVue.filter(user>user.age>10).length}}</span><span>用户大于10岁的数量2&#xff1a;{{ userAgeltTen}}</span><sp…

基于轨迹信息的图像近距离可行驶区域方案验证

一 图像可行驶区域方案 1.1 标定场景 1.2 标定步骤 设计一定间距标定场&#xff0c;在标定场固定位置设置摄像头标定标识点。主车开到标定场固定位置录制主车在该位置各个摄像头数据&#xff0c;通过摄像头捕获图像获取图像上关键点坐标pts-2d基于标定场设计&#xff0c;计算…

vue实现左右拖动分屏

效果图如下&#xff1a; 封装组件 <template><div ref"container" class"container"><div class"left-content" :style"leftStyle">/**定义左侧插槽**/<slot name"left"></slot></div>…

Springboot+Vue3开发学习笔记《2》

SpringbootVue3开发学习笔记《2》 博主正在学习SpringbootVue3开发&#xff0c;希望记录自己学习过程同时与广大网友共同学习讨论。 总共涉及两部分&#xff0c;第一部分为基础部分学习&#xff0c;第二部分为实战部分。 一、学习路径 1.1 基础部分 配置文件整合MyBatisBea…

QQ聊天记录删除了怎么恢复?这4个方法让你秒找回!

在现代社会&#xff0c;QQ已经成为我们日常交流和工作中不可或缺的沟通工具。然而&#xff0c;有时我们可能会不小心删除了重要的聊天记录&#xff0c;这会带来诸多不便甚至困扰。那么&#xff0c;当你发现自己误删了数据&#xff0c;qq聊天记录删除了怎么恢复呢&#xff1f;有…

2024-07-04 base SAS programming学习笔记8(HTML)

当使用ODS来进行结果或数据集输出的时候&#xff0c;可以同时设置多个ODS 命令&#xff0c;同时输出到多个不同的文件。使用_ALL_ 表示关闭所有的ODS输出窗口&#xff0c;比如&#xff1a; ods html file(body)"html-file-pathname"; ods html file"pdf-file-pa…

中国东方资产管理25届秋招北森测评笔试如何高分通过?真题考点分析看完这篇就够了

一、东方资管校招测评题型分析 中国东方资产管理股份有限公司&#xff08;中国东方资管&#xff09;的校园招聘测评题型主要包括以下几个部分&#xff1a; 1. **计分题&#xff0c;行测知识**&#xff1a;这部分题量大约在56-57题左右&#xff0c;分为不同的模块进行计时测试。…

Spzhi知识付费社区主题免费下载

主题介绍 用typecho打造一款知识付费社区主题&#xff0c;带会员功能&#xff0c;为内容创业者提供知识变现一站式解决方案&#xff0c;让用户沉淀到自己的平台&#xff0c;形成自己的私域流量池&#xff0c;打造流量闭环&#xff0c;零门槛搭建你的移动网络课堂 主题功能 支…

SpringBoot Task 定时任务

springboot中使用Task定时任务非常简单 springboot 中自带的都有注解不需要引入依赖 第一步&#xff1a;在启动类上添加启用定时任务注解 EnableScheduling //开启任务调度 第二步&#xff1a;创建一个springboot组件用于定时任务管理 package cn.lsy.api.Task;import cn.ls…

论文解读——如何生成高分辨率图像PGGAN

论文&#xff1a;Progressive Growing of GANs for Improved Quality, Stability, and Variation&#xff08;2017.10&#xff09; 作者&#xff1a;Tero Karras, Timo Aila, Samuli Laine, Jaakko Lehtinen 链接&#xff1a;https://arxiv.org/abs/1710.10196 代码&#xff1a…