Kotlin编程语言(十)——递归和尾递归
系列文章目录
Kotlin编程语言(一)——Java到kotlin的演变过程
Kotlin编程语言(二)——Kotlin详解
Kotlin编程语言(三)——Kotlin和Java共同点
Kotlin编程语言(四)——Kotlin和Java差异点
Kotlin编程语言(五)——基础语法定义
Kotlin编程语言(六)——不同数量的引号定义字符串
Kotlin编程语言(七)——区间(ranges)
Kotlin编程语言(八)——Array、List和Map
Kotlin编程语言(九)——不同字符类型相互转换
Kotlin编程语言(十)——递归和尾递归
前言
在 Kotlin 中,递归和尾递归是函数式编程的重要概念。下面将分别介绍这两个概念以及它们在 Kotlin 中的使用方式。
一、递归(Recursion)
递归是指在一个函数或子程序中直接或间接调用自身的一种方法。每次递归调用都会创建一个新的栈帧,这可能会导致栈溢出问题。在编程中,递归可以用来解决那些可以通过更小规模的相同问题来表达的问题。
示例:计算累加
fun sum(n: Int): Int {
return if (n == 1) 1 else n + sum(n - 1)
}
fun main() {
val result = sum(10)
println("The sum from 1 to 10 is: $result") // 输出: The sum from 1 to 10 is: 55
}
解释:
- 当
n
不等于1时,函数会返回n + sum(n - 1)
。 - 每次递归调用都会创建一个新的栈帧来保存当前的
n
值和部分计算结果。 - 最终,当
n==1
时,递归停止,所有栈帧开始回溯并计算最终结果。
栈帧的创建和销毁
-
初始调用:
sum(10)
- 创建一个栈帧,保存
n = 10
。 - 计算
10 + sum(9)
,但sum(9)
还未计算完成,所以不能立即返回结果。
- 创建一个栈帧,保存
-
递归调用:
sum(9)
- 创建一个新的栈帧,保存
n = 9
。 - 计算
9 + sum(8)
,但sum(8)
还未计算完成,所以不能立即返回结果。
- 创建一个新的栈帧,保存
-
继续递归调用:
sum(8)
,sum(7)
, …,sum(1)
- 每次递归调用都会创建一个新的栈帧,保存当前的
n
值。 - 每次调用都会计算
n + sum(n - 1)
,但sum(n - 1)
还未计算完成,所以不能立即返回结果。
- 每次递归调用都会创建一个新的栈帧,保存当前的
-
递归终止:
sum(1)
- 创建一个新的栈帧,保存
n = 1
。 - 返回
1
。
- 创建一个新的栈帧,保存
-
回溯计算
- 控制权返回到
sum(2)
,计算2 + 1 = 3
,返回3
。 - 控制权返回到
sum(3)
,计算3 + 3 = 6
,返回6
。 - 继续回溯,直到
sum(10)
,计算10 + 45 = 55
,返回55
。
- 控制权返回到
二、尾递归(Tail Recursion)
尾递归是一种特殊的递归形式,其中函数的最后一步操作是调用自身。由于尾递归函数的最后一个操作是递归调用,所以编译器可以优化这个过程,使其不会增加额外的栈帧,从而避免了因为过多的递归调用而导致的栈溢出错误。
Kotlin 编译器支持尾递归优化,但是必须显式地使用 tailrec
关键字来标记一个函数是尾递归的。如果一个被标记为 tailrec
的函数不是尾递归的形式,编译器将会报错。
示例:尾递归计算累加:
tailrec fun sum(n: Int, accumulator: Int = 0): Int {
return if (n == 0) accumulator else sum(n - 1, n + accumulator)
}
fun main() {
val result = sum(10)
println("The sum from 1 to 10 is: $result") // 输出: The sum from 1 to 10 is: 55
}
解释:
- 当
n≠0
时,函数会返回sum(n - 1, n + accumulator)
。 accumulator
参数用于累积中间结果。- 每次递归调用都是函数的最后一步操作,编译器可以优化这种调用,使其重用当前的栈帧。
- 最终,当
n==0
时,递归停止,返回累积的结果accumulator
。
栈帧的创建和销毁
-
初始调用:
sum(10, 0)
- 创建一个栈帧,保存
n = 10
和accumulator = 0
。 - 计算
sum(9, 10 + 0)
,即sum(9, 10)
。
- 创建一个栈帧,保存
-
递归调用:
sum(9, 10)
- 更新当前栈帧中的
n
和accumulator
,而不是创建新的栈帧。 - 计算
sum(8, 19)
,即sum(8, 19)
。
- 更新当前栈帧中的
-
继续递归调用:
sum(8, 19)
,sum(7, 27)
, …,sum(0, 55)
- 每次递归调用都会更新当前栈帧中的
n
和accumulator
。 - 没有创建新的栈帧,只是修改现有的栈帧。
- 每次递归调用都会更新当前栈帧中的
-
递归终止:
sum(0, 55)
- 当
n == 0
时,返回accumulator
,即55
。
- 当
三、区别
递归:
- 每次递归调用都会创建一个新的栈帧。
- 所有的栈帧在递归调用完成后才会逐个销毁。
- 如果递归深度很大,可能会导致栈溢出。
尾递归:
- 编译器优化后,每次递归调用不会创建新的栈帧,而是重用当前的栈帧。
- 只有一个栈帧被反复更新,直到递归终止。
- 避免了栈溢出的风险,更高效。
四、图解
普通递归的栈帧图
sum(10) -> sum(9) -> sum(8) -> ... -> sum(1)
| | | |
v v v v
10 9 8 1
尾递归的栈帧图
sum(10, 0) -> sum(9, 10) -> sum(8, 19) -> ... -> sum(0, 55)
| | | |
v v v v
(10, 0) (9, 10) (8, 19) (0, 55)
总结
- 性能考虑:虽然尾递归可以提高效率并防止栈溢出,但它并不总是最佳选择。有时候,迭代算法可能更加直观且易于理解。
- 编译器优化:只有当递归调用是函数体中的最后一个操作,并且没有其他后续的操作需要执行时,Kotlin 编译器才能对递归进行优化。
- 内存消耗:非尾递归可能会导致大量的内存消耗,特别是对于深度较大的递归调用,因为每个调用都需要保存一个栈帧。