递归更加利于理解,因此某些情况下我们更喜欢使用递归(比如归并排序、或者树的操作),但是随着递归深度不断加深,就可能出现栈溢出,很多大神可能都已经知道如何通过堆栈和循环来避免栈溢出,但是,我觉得提供一种简单、通用的方法来替换递归,对于新手来说是非常棒的想法。
递归和循环的优缺点
递归:
- 优点:便于阅读和理解算法;
- 缺点:可能引起栈溢出;
循环:
- 优点:能够避免栈溢出;效率通常要比递归更好一些;
- 缺点:不利于理解代码,不易维护;
替换递归的 10 个规则
下面介绍的几种规则,是针对通用的递归转换方法,而在实际的使用中,对于简单的递归来说,可能只需要其中一部分就可以解决问题,所以在阅读过程中,需要着重理解每个原则解决哪些问题。
规则 01
递归和循环根本的差异在于,递归调用借助线程调用栈,来存储参数、局部变量等信息,因此转换时需要我们完成这部分工作,通过定义一个结构体SnapShot
,来存储递归过程中的所有数据,以及阶段信息,总结结构体包括内容如下:
- 当递归调用时值发生变化的参数,引用类型参数时不需要添加到结构体中,比如下面例子中,参数
n
需要添加,而引用类型retVal
则不需要; - 阶段标记(主要用来解决递归中的分支问题,后面会详细介绍);
- 一些局部变量(递归调用返回后需要使用的局部变量);
1 | int SomeFunc(int n,int &retIdx) |
规则 02
在循环模拟方法最开始,创建一个局部变量,该变量用于存储递归调用的返回值。
- 该值等同于递归中的返回值,如果需要用到返回值,直接使用该变量替代;
- 如果递归函数返回值是 void 类型,则不需要使用局部变量;
- 使用递归函数默认返回值,来初始化该局部变量;
1 | // 将递归转换为循环 |
规则 03
创建一个SnapShot
类型的堆栈,通过这个堆栈来模拟线程调用栈,继续上面的例子。
1 | // 将递归转换为循环 |
规则 04
- 创建一个
SnapShot
实例对象,并且根据循环的输入参数以及状态其实值,初始化该实例; - 然后将该实例添加到创建的堆栈中。
1 | // 将递归转换为循环 |
规则 05
- 创建一个
while
循环,循环条件为snapshotStack
不为空; - 每次循环时,从
snapshotStack
取出栈顶元素。
1 | // 将递归转换为循环 |
规则 06
前面提到过 SnapShot
结构中有一个 stage
变量,用来存储递归的各个阶段,那么如何划分阶段,每个阶段内容是什么,后续将会一一解释,下面这一规则说明了,如何对递归进行阶段划分。
- 以递归调用为分界点,将整个过程分为两部分(针对只有一个递归调用的情况),第一部分为调用前,第二部分表示递归调用后的代码;
- 如果一个函数中有两个递归调用,则必须有三个阶段:
- 第一阶段 -> 递归调用 -> (上次递归调用返回)第二阶段(第二次递归调用) -> (第二次递归调用返回) -> 第三阶段
- 如果一个函数中有三个递归调用,则至少有四个阶段;
- 依次类推。
1 | // 将递归转换为循环 |
规则 07
- 在规则 6 中确定阶段划分之后,需要根据
SnapShot.stage
变量来决定进入哪个case
; - 执行相关的阶段代码;
1 | // 将递归转换为循环 |
阶段 2 中我们给 test 进行了赋值,规则 1 中提到,凡是在递归调用之后,需要用到的局部变量,都需要记录到
SnapShot
中,因此这里需要记录到相应结构。
规则 2 中提到,凡是用到返回值的地方,都用声明的局部变量来替代,因此将retVal
赋值给 test。
规则 08
- 如果递归调用存在返回值,将每次循环的结果,记录到之前声明的局部变量中,比如这里的
retVal
; - 当
while
循环结束时,该局部变量存储着递归函数的最终结果;
1 | switch (currentSnapStack.stage) |
规则 09
如果递归函数中,包含关键字return
,在循环中,需要将之转换为 continue
:
- 当递归中返回一个值时,首先根据规则 8,将返回值存储到临时变量(retVal),然后调用
continue
; - 大多数情况下,规则 9 是可选的,但是为了避免逻辑错误,最好加上;
1 | switch (currentSnapStack.stage) |
规则 10
- 对于递归调用,需要再循环中,创建一个新的
Snapshot
实例对象,初始化该对象的stage
变量,以及输入参数、局部变量,然后将该实例添加到堆栈中,并且调用continue
; - 如果递归调用之后,还有代码需要执行,则需要将
currentSnapStack.stage
设置为相应阶段,并在新的Snapshot
实例添加到堆栈之前,添加到堆栈中;
1 | // 阶段 1,SomeFunc(n-1,retIdx) 之前的内容 |
各类递归示例
示例代码地址
如果你看过这些源码,你会发现,里面包含递归和循环两个版本,其原因简单来说,循环版本是为了避免栈溢出,而递归版本则是为了阅读源码的人理解算法的意思。
总结
我的观点是,当使用 C/C++ 或者 Java 时,必须非常小心的使用递归,避免栈的溢出异常,但是,在许多情况下,递归代码更容易理解,并且更加容易写出来,但缺点是容易造成栈溢出。所以,通过上述方法,将递归转换为循环,并不是为了提高代码可读性,也不是为了提高代码执行效率,而是通过一种简单的方式避免栈溢出。
如前面所说,我更倾向于保存递归和循环两个版本代码,递归版本给人阅读,循环版本给计算机阅读,当你了解了这两种方式的利弊之后,可以选择使用哪种方式编写代码。
最后附上英文原文地址:英文版
以及原作者的个人 GitHub 库,其中包含部分示例:地址