【问题标题】:Trying to get my head around recursion in Haskell?试图让我了解 Haskell 中的递归?
【发布时间】:2011-05-14 01:59:17
【问题描述】:

我现在使用了许多递归函数,但仍然无法理解这样一个函数的确切工作原理(我熟悉第二行(即| n==0 = 1)但对最后一行不太熟悉(即| n>0 = fac (n-1) * n))。

fac :: Int -> Int
fac n
    | n==0 = 1
    | n>0  = fac (n-1) * n

【问题讨论】:

标签: haskell recursion factorial


【解决方案1】:

Recursive algorithmsmathematical induction 密切相关。也许研究其中一个会帮助您更好地理解另一个。

在使用递归时需要牢记两个关键原则:

  • 基本案例
  • 感应步骤

归纳步骤通常是最困难的部分,因为它假设它所依赖的所有内容都已正确计算。实现这一信念的飞跃可能很困难(至少我花了一段时间才掌握它),但这只是因为我们的函数有先决条件;必须指定这些先决条件(在这种情况下,n 是一个非负整数),以便归纳步骤和基本情况始终为真

Base Case 有时也很困难:比如说,你知道阶乘 N!N * (N-1)!,但是你如何处理阶梯上的第一步呢? (在这种情况下,很容易定义0! := 1。这个明确的定义为您提供了一种终止归纳步骤的递归应用的方法。)

您可以在此函数中看到您的类型规范和保护模式提供了保证 Inductive Step 可以一遍又一遍地使用的前提条件,直到它到达基本案例 n == 0。如果不能满足先决条件,归纳步骤的递归应用将无法达到基本案例,并且您的计算将永远不会终止。 (嗯,它会在内存用完时。:)

一个复杂的因素,特别是对于函数式编程语言,是非常强烈的希望重写所有“简单”递归函数,就像你在这里一样,使用Tail Calls 或尾递归的变体。

因为这个函数调用自己,然后对结果执行另一个操作,你可以像这样构建一个调用链:

fac 3        3 * fac 2
  fac 2      2 * fac 1
    fac 1    1 * fac 0
      fac 0  1
    fac 1    1
  fac 2      2
fac 3        6

那个深调用栈占用内存;但是,如果编译器注意到函数在进行递归调用后没有改变任何状态,则可以优化递归调用。这些类型的函数通常会传递一个 accumulator 参数。一个堆垛机朋友有一个很好的例子:Tail Recursion in Haskell

factorial 1 c = c
factorial k c = factorial (k-1) (c*k)

这个非常复杂的变化:)意味着之前的调用链变成了这样:

fac 3 1       fac 2 3
  fac 2 3     fac 1 6
    fac 1 6   6

(嵌套只是为了展示;运行时系统实际上不会将执行的详细信息存储在堆栈上。)

无论n 的值如何,它都在常量内存中运行,因此这种优化可以将“不可能”算法转换为“可能”算法。您会看到这种技术在函数式编程中广泛使用,就像您在 C 编程中经常看到 char * 或在 Ruby 编程中经常看到 yield 一样。

【讨论】:

  • 很好的解释。这个问题是行人的,但我想确保我理解为什么根据定义递归案例是归纳的。你介意分享一些见解来推动这一点吗?关于输入基本模式的参数和结果值的性质的假设是否将递归情况限定为归纳?
  • @DavidShaked,递归情况通常会在进行递归调用时尝试以某种方式“减少”参数——使用阶乘,通过减法,但这不是唯一的选择——使用最终进行不需要进一步递归的调用的目标(基本情况)。每一步都试图解决一个更简单的问题,直到它达到一个再简单不过的定义。我希望这会有所帮助。
【解决方案2】:

当你写| condition = expression 时,它引入了一个守卫。守卫从上到下依次尝试,直到找到真正的条件,对应的表达式就是你的函数的结果。

这意味着如果n 为零,则结果为1,否则如果n > 0,则结果为fac (n-1) * n。如果n 为负数,则会出现不完整的模式匹配错误。

一旦确定了要使用的表达式,只需代入递归调用即可查看发生了什么。

fac 4
(fac 3) * 4
((fac 2) * 3) * 4
(((fac 1) * 2) * 3) * 4
((((fac 0) * 1) * 2) * 3) * 4
(((1 * 1) * 2) * 3) * 4
((1 * 2) * 3) * 4
(2 * 3) * 4
6 * 4
24

【讨论】:

    【解决方案3】:

    特别是对于更复杂的递归案例,拯救心理健康的诀窍是遵循递归调用,而只是假设他们“做正确的事”。例如。在您的 fac 示例中,您要计算 fac n。假设您已经拥有结果fac (n-1)。然后计算fac n 是微不足道的:只需将它乘以n。但归纳法的神奇之处在于,这种推理实际上有效(只要您提供适当的基本情况以终止递归)。所以例如对于斐波那契数,只需看看基本情况是什么,假设您能够计算所有小于 n 的数的函数:

    fib 0 = 0
    fib 1 = 1
    fib n = fib (n-1) + fib (n-2)
    

    看到了吗?你想计算fib n。如果您知道fib (n-1)fib (n-2),这很容易。但是您可以简单地假设您能够计算它们,并且递归的“更深层次”会做“正确的事情”。所以只要使用它们,它就会起作用。

    请注意,编写此函数有很多更好的方法,因为目前很多值都需要重新计算。

    顺便说一句:写fac 的“最佳”方式是fac n = product [1..n]

    【讨论】:

      【解决方案4】:

      你怎么了?也许警卫(|)是令人困惑的事情。

      您可以将守卫松散地认为是 if 链或 switch 语句(差异只有一个可以运行,并且它直接评估结果。不执行一系列任务,当然也没有副作用. 只是计算一个值)

      平移命令式的伪代码......

      Fac n:
         if n == 0: return 1
         if n > 0: return n * (result of calling fac w/ n decreased by one)
      

      其他海报的调用树看起来可能会有所帮助。帮自己一个忙,真正地完成它

      【讨论】:

        猜你喜欢
        • 2016-04-09
        • 1970-01-01
        • 2018-06-16
        • 2015-11-17
        • 1970-01-01
        • 2019-12-03
        • 2021-03-22
        • 1970-01-01
        • 2021-12-04
        相关资源
        最近更新 更多