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

这写起来很简单,但是这段代码包含了一些容易出错的地方。

  1. while 的判断语句,是 <= 还是 <,这个得明确
  2. idx 的自增是 sum增加前,还是 sum 增加后
  3. 这个函数写了两个变量,根据编程之美的原则,局部变量是代码坏味道,我们应该要剔除它

解决这个问题, 我们应该抛弃 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, "")

这比较简单,但是为每个递归函数重复这个过程会很繁琐,我们可以再退后一步,将函数继续抽象。 我们先来看看这个函数的整个过程,做了什么?

  1. 处理给定类型的元素列表,返回另一个类型的单个值。 在上例中, 给定类型是 List<String>,返回值是 String , 那我们可以将这两个类型抽象成 T 和 U
  2. 利用 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)结合记忆和尾递归

尾递归

尾递归

C语言 尾递归

搜索分析(DFSBFS递归记忆化搜索)

Swift 编程中的尾递归和蹦床译