Koltin 递归尾递归和记忆化
Posted RikkaTheWorld
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Koltin 递归尾递归和记忆化相关的知识,希望对你有一定的参考价值。
文章目录
学习秒表:
- 了解 Kotlin 中的尾递归函数
- 从 尾递归 了解 fold、foldRight、 reverse、unfold 的实现
- 从 记忆化 了解 map、 iterate 的实现
1. 递归与尾递归
Kotlin 中很好的支持递归函数,使得递归可以被广泛使用, 但是稍微了解算法的人都知道, 递归函数一不小心就会爆栈,即随着递归次数的增加,内存不足以存储中间的计算步骤和中间结果,导致内存溢出。
所以我们需要了解递归的利与弊, 并了解哪种递归是可用的,哪种递归是不能用的。
1.1 尾递归(tail call)
尾递归就是函数的末尾是调用函数本身。
递归函数有很多种写法,尾递归是递归的其中一个版本。
请看下面代码,我们使用一个尾递归函数, 参数是一个 List<Char>
,我们通过递归的方式,将 List 中的每一个 Char 相连接,得到一个 String:
fun append(s: String, c: Char): String = "$s$c"
fun toString(list: List<Char>): String
fun toString(list: List<Char>, s: String): String =
if (list.isEmpty())
s
else
// toString(list.subList(1, list.size), append(s, list[0]))
// 上面的注释也可以写成下面这样
toString(list.drop(1), append(s, list.first()))
return toString(list, "")
这里写了一个局部函数, 来更好的展现了递归函数的用法。 这里局部函数 toString(list: List<Char>, s: String)
就是尾递归函数。
1.2 递归
同样是上面的例子, 但是 append
不再是在字符添加到字符末尾了,而是相反,变成了:
fun prepend(c: Char, s: String): String = "$c$s"
那其实可以从列表的最后一个字符开始:
fun toString(list: List<Char>): String
fun toString(list: List<Char>, s: String): String =
if (list.isEmpty())
s
else
toString(list.subList(0, list.size - 1), prepend(list[list.size - 1], s))
return toString(list, "")
但是,这只适合于这种访问类型的列表,如果遇到了索引列表或者双链表,就只能反转链表,这样做的话就很低效。
假设我们遇到了,最坏的情况就是, 现在这个 List 是一个无限列表,为了更加高效,我们在遇到终止条件之前,不进行任何计算,中间步骤必须存储在某个地方,直到它被用到进行计算。 这就是递归的方案, 而不是尾递归,请看下面代码:
fun toString(list: List<Char>): String =
if (list.isEmpty())
""
else
prepend(list[0], toString(list.subList(1, list.size)))
因为函数的末尾是调用 prepend
函数对字符串和字符进行相连, 所以它是一个递归函数,但不是一个尾递归函数
1.3 递归和尾递归的区别
- 尾递归函数中,我们写的和迭代一样,就是把当前计算的值做为初始值,进入到下一个循环去计算,循环往复
- 递归函数更像是搜索,先是搜到终止条件, 在回溯所有的计算
两者的计算步骤和内存消耗如下图所示,可以看出来哪边是递归哪边是尾递归么?
根据语言的限制,Kotlin 堆栈最多存储 20000 个中间步骤,而 Java 大约是 3000 个,如果我们为了写递归函数而修改堆栈空间,这会造成浪费,因为每个线程都会初始化相同的堆栈大小,一些非递归的线程,很容易产生浪费。 且这样做也不太优雅。
左边是使用尾递归的做法,可以看到,在每次计算时,使用的内存就是 一个数 + 一个计算步骤
, 它使用的内存是不边的,随着计算量的增加, 内存是不会增加,增加的只有计算时间。
右边是使用递归的做法, 可以看到,在随着计算次数的增加, 使用的内存会越来越多, 呈线性增长(但有些时候比线性还要可怕), 它使用的内存是 存储步骤1 + 存储步骤2 + ... + 存储步骤 n
, 在找到终止条件时,占用的内存达到了顶峰。
结论:
如果我们要使用递归函数,我们应该确保步骤在极低的数量下, 否则,我们应该避免使用递归函数,而是使用尾递归函数。
2. 尾递归消除
上面把尾递归函数的有点说的很绝对哈,但其实是不严谨的,因为函数本身也是对象,所以函数自身调用肯定也会消耗内存空间,无限次尾递归,也会造成爆栈的。
解决方案就是把尾递归替换成循环:
fun toStringCorec(list: List<Char>): String
var s = ""
for (c in list) s = append(s, c)
return s
这样就可以解决尾递归的问题
2.1 尾调用消除
tailrec
关键字可以用来修饰一个尾调用函数,帮这个函数优化, 例如我们上面所说的,它会帮忙把函数优化成循环的方式,并且在编译期帮我们做检查,或许还有别的有点,如下所示
fun toString(list: List<Char>): String
tailrec fun toString(list: List<Char>, s: String): String =
if (list.isEmpty())
s
else
toString(list.drop(1), append(s, list.first()))
return toString(list, "")
2.2 从循环切到尾递归
假设我们要做累加,传统的想法很容易就想到使用一个 for 循环去做累加,我们脑子里可能会有这么一个流程图:
不过 Kotlin 中没有这样的for循环(for循环的条件不能是一个 Bool 值),但是用 while 循环可以做到:
fun sum(n: Int): Int
var sum = 0
var idx = 0
while (idx <= n)
sum += idx
idx ++
return sum
这写起来很简单,但是这段代码包含了一些容易出错的地方。
- while 的判断语句,是 <= 还是 <,这个得明确
- idx 的自增是 sum增加前,还是 sum 增加后
- 这个函数写了两个变量,根据编程之美的原则,局部变量是代码坏味道,我们应该要剔除它
解决这个问题, 我们应该抛弃 while 循环,可以改用使用 递归的方式,而且用 辅助函数,来代替变量,这是因为函数的参数是不可变。
// 辅助函数, 使用尾递归,去掉了变量
fun sum(n: Int, s: Int, i: Int): Int =
if (i > n) s
else sum(n, s + i, i + 1)
fun sum(n: Int): Int
return sum(n,0, 0)
因为 n 是恒定的, 所以这个函数可以优化成局部函数,消除掉第一个参数:
fun sum(n: Int): Int
fun sum(s: Int, i: Int): Int =
if (i > n) s
else sum(s + i, i + 1)
return sum(0, 0)
接下来告诉 Kotlin, 我们写了一个尾递归函数,需要 Kotlin 帮忙优化,所以我们加上一个 tailrec 关键字:
fun sum(n: Int): Int
tailrec fun sum(s: Int, i: Int): Int =
if (i > n) s
else sum(s + i, i + 1)
return sum(0, 0)
这样子做很好, 但是在实际生产中,我们会把大量的时间消耗在将 非尾递归函数 实现成 递归函数,这是不太现实的。
2.3 使用递归值函数
递归fun函数也是可以写成值函数的,但是因为 值函数不能通过 TCE(尾递归消除) 进行优化,所以调用时很可能会爆栈,所以需要尾递归时,还是使用 fun 函数,加上 tailrec 关键字。
3. 递归函数和列表
递归函数最常见用于处理列表, 这一章来看下递归是如何解决列表问题的
3.1 对列表进行抽象
考虑下面递归函数,它计算整数列表中元素的总和:
fun sum(list: List<Int>): Int =
if (list.isEmpty()) 0
else list[0] + sum(list.drop(1))
如果列表为空,则返回0,否则返回 第一个元素的值 + 将 sum 函数应用到列表其余部分的结果。如果定义辅助函数来返回列表的头部和尾部,可能会更清楚,我们可以用扩展函数来表示:
fun <T> List<T>.head(): T =
if (this.isEmpty()) throw Exception("head called on empty list")
else this[0]
fun <T> List<T>.tail(): List<T> =
if (this.isEmpty()) throw Exception("tail called on empty list")
else this.drop(1)
fun sum(list: List<Int>): Int =
if (list.isEmpty()) 0
else list.head() + sum(list.tail())
使用扩展函数的好处就是, 外部调用时无需关心其实现细节,做了一层封装其实就是一次抽象。最后将其优化尾递归函数:
fun sum(list: List<Int>): Int
tailrec fun sumTail(list: List<Int>, acc: Int): Int =
if (list.isEmpty()) acc
else sumTail(list.tail(), acc + list.head())
return sumTail(list, 0)
同样的,我们可以将这辅助函数应用到下面的函数来分割字符串:
fun makeString(list: List<String>, delim: String): String =
when
list.isEmpty() -> ""
list.tail().isEmpty() -> "$list.head() $makeString(list.tail(), delim)"
else -> "$list.head() $delim $makeString(list.tail(), delim)"
这段函数的作用是, 输入一个字符串列表,然后凭借里面每个字符串,然后在中间加上对应的值。例如输入:
输入:print(makeString(listOf("a","b","c","d","e","f","g"), "123"))
输出:a 123 b 123 c 123 d 123 e 123 f 123 g
这个函数是非尾递归函数,为了优化,我们可以写成尾递归的版本,并且使用泛型:
fun <T> makeString(list: List<T>, delim: String): String
tailrec fun makeString_(list: List<T>, acc: String): String = when
list.isEmpty() -> acc
acc.isEmpty() -> makeString_(list.tail(), "$list.head()")
else -> makeString_(list.tail(), "$acc $delim $list.head()")
return makeString_(list, "")
这比较简单,但是为每个递归函数重复这个过程会很繁琐,我们可以再退后一步,将函数继续抽象。 我们先来看看这个函数的整个过程,做了什么?
- 处理给定类型的元素列表,返回另一个类型的单个值。 在上例中, 给定类型是
List<String>
,返回值是 String , 那我们可以将这两个类型抽象成 T 和 U - 利用 T 型元素和 U型元素产生 U 型元素的一种操作, 这种操作是一对元素 (U, T) 到 U 的函数
这其实和本章一开始的例子 sumTail
函数是一样的, 也就是说, sumTail
函数 和 makeString_
函数本质上是相同的,只是他们应用了不同的类型。 sumTail 是 (Int, Int) 到 Int, makeString 则是 (List, String) 到 String。
如果我们可以实现一个通用版本的尾递归函数,那么我们其实就不用再去写 makeString_、sumTail这种尾递归函数了。 为了达到目标,我们实现一个通用的版本, 该函数可用于 sum、string、makeString,假定函数名为 foldLeft
,然后编写函数,如下:
fun <T, U> foldLeft(list: List<T>, z: U, f: (T, U) -> U): U
tailrec fun foldLeft(list: List<T>, acc: U): U =
if (list.isEmpty()) acc
else foldLeft(list.tail(), f(list.head(), acc))
return foldLeft(list, z)
// 将其应用到 sum、string、makeString
fun sum(list: List<Int>) = foldLeft(list, 0, Int::plus)
fun string(list: List<Char>) = foldLeft(list, "") t, acc -> acc + t
fun <T> makeString(list: List<T>, delim: String): String = foldLeft(list, "") t, acc ->
if (acc.isEmpty()) "$t"
else "$acc $delim $t"
上面创建的这个函数是无循环编程时最重要的函数之一, 这个函数允许以一种安全的堆栈方式抽象尾递归,这样使用者就不用考虑使用函数尾递归。
但是有时候需要用相反的方式来做事情,使用递归而不是尾递归,例如有字符串列表 [a, b, c] 希望只是用 head 和tail以及 prepend 函数来构建字符串 “abc”,假设不能按元素的索引访问列表元素,但可以编写下面的递归实现:
fun string(list: List<Char>): String =
if (list.isEmpty()) ""
else prepend(list.head(), string(list.tail()))
我们得以 foldLeft 的反向思维,去写一个 foldRight, 以 Char 为 T类型, 以 String 为 U类型:,那么可以编写 foldRight:
fun <T, U> foldRight(list: List<T>, identity: U, f: (T, U) -> U): U =
if (list.isEmpty()) identity
else f(list.head(), foldRight(list.tail(), identity, f))
// 同时应用到 string函数
fun string(list: List<Char>): String = foldRight(list, "") char, acc ->
prepend(char, acc)
这里 foldRight
不是尾递归函数,所以不能使用 TCE 优化,不能创建 foldRight 的真正尾递归版本。
至此,我们实现了 Kotlin 列表的两个极为重要的操作符,我们在实际开发中不用去创建 foldLeft 和 foldRight。 因为 Kotlin 已经帮我们实现了 , 只是 foldLeft 函数被简称成 fold
。
3.2 反转列表
翻转列表有时是有用的,可能性能上不是最好的,但是也具备一定可行性。基于循环的方式定义一个reverse函数很容易,在列表上向后迭代。在 Kotlin中可以使用:
fun <T> reverse(list: List<T>): List<T>
val result: MutableList<T> = mutableListOf()
(list.size downTo 1).forEach
result.add(list[it - 1])
return result
但这并不是在 Kotlin 中应该使用的方式, kotlin 是不建议写循环的
之前写了一个 foldRight,但是基于它不是尾递归实现,所以列表很大时,容易爆栈,我们应该更多的使用 foldLeft。 下面我们使用 foldLeft 来写一个 reverse
// 向左折叠列表
fun <T> prepend(elem: T, list: List<T>): List<T> =
foldLeft(list, listOf(elem)) elm, acc ->
acc + elm
fun <T> reverseFold(list: List<T>): List<T> = foldLeft(list, listOf(), ::prepend)
实际开发时不要手写 prepend 和 reverse ,因为 Kotlin 已经定义了标准的 reverse 函数
3.3 构建共递归列表
我们一次又一次做的事情就是构建尾递归列表, 其中大部分都是 Int列表,在 Java 考虑下面的示例:
for(int i = 0; i <= limit; i++)
...
这段代码包含了两个抽象,一个是尾递归列表, 一个是列表的处理(在代码块中)。
对于递归的优化,就是将抽象推至极限,这样可以最大限度的重用代码, 使得程序更加安全。
例如下面代码:
for(int i = 0; i < 5; i++)
System.out.println(i)
等同于
listOf(0,1,2,3,4).forEach(::println)
列表和结果都被抽象出来了,但是还可以进一步进行抽象, 比如,这个列表有100个元素,我们不可能手写 listOf(0,1,2 …99)吧?
下面我们编写一个 range 函数来实现段起始值到终止值的函数:
fun range(start: Int, end: Int): List<Int>
val result = mutableListOf<Int>()
var index = start
while (index < end)
result.add(index)
index++
return result
下面来编写一个更加通用的版本,该函数适用于任何类型和任何条件, 因为范围的概念只是用于数字,所以将这个函数命名为 unfold,并用下面的函数签名:
fun <T> unfold(seed: T, f:(T) -> T, p:(T) -> Boolean): List<T>
val result = mutableListOf<T>()
var elem = seed
while (p(elem)结合记忆和尾递归