Haskell/递归
递归函数在 Haskell 中起着核心作用,并且通常在计算机科学和数学中被广泛使用。递归 本质上是一种重复形式,我们可以通过区分函数含义是递归的,以及它表现的方式来理解它。
递归函数意味着:一个函数可以调用自身。
它的行为是这样的:它仅在满足条件时调用自身,就像 if/else/then 表达式一样,或者像一个模式匹配,其中包含至少一个基本情况来终止递归,以及一个递归情况,导致函数调用自身,从而创建一个循环。
如果没有终止条件,递归函数可能会永远循环,导致无限循环。
数学(特别是组合学)有一个叫做阶乘的函数。[1] 它以一个非负整数作为参数,找到所有小于或等于“n”的正整数,并将它们全部相乘。例如,6 的阶乘(表示为 )是 。我们可以使用递归风格在 Haskell 中定义它
让我们看看两个相邻数字的阶乘
示例:连续数字的阶乘
Factorial of 6 = 6 × 5 × 4 × 3 × 2 × 1 Factorial of 5 = 5 × 4 × 3 × 2 × 1
注意我们是如何对齐的。你可以在这里看到 包含 。事实上, 仅仅是 。让我们继续
示例:连续数字的阶乘
Factorial of 4 = 4 × 3 × 2 × 1 Factorial of 3 = 3 × 2 × 1 Factorial of 2 = 2 × 1 Factorial of 1 = 1
任何数字的阶乘就是该数字乘以比它小 1 的数字的阶乘。有一个例外:如果我们要求 0 的阶乘,我们不希望将 0 乘以 -1 的阶乘(阶乘只适用于正数)。事实上,我们只是说 0 的阶乘是 1(我们定义它是这样的。只需相信我们,这是正确的。[2])。因此,0 是递归的基本情况:当我们到达 0 时,我们可以立即说答案是 1,不需要递归。我们可以总结阶乘函数的定义如下
- 0 的阶乘是 1。
- 任何其他数字的阶乘就是该数字乘以比它小 1 的数字的阶乘。
我们可以直接将它翻译成 Haskell
示例:阶乘函数
factorial 0 = 1
factorial n = n * factorial (n - 1)
这定义了一个名为 factorial
的新函数。第一行说 0 的阶乘是 1,第二行说任何其他数字 n
的阶乘等于 n
乘以 n - 1
的阶乘。注意 n - 1
周围的括号;如果没有它们,这将被解析为 (factorial n) - 1
;请记住,函数应用(将函数应用于值)在没有显式分组的情况下优先于其他任何操作(我们说函数应用比其他任何操作绑定更紧密)。
注意
上面的 factorial
函数最好定义在一个文件中,但由于它是一个小函数,在 GHCi 中将其写成单行是可行的。为此,我们需要添加一个分号来分隔行
> let factorial 0 = 1; factorial n = n * factorial (n - 1)
Haskell 实际上使用行分隔和其他空格来代替分号和括号等分隔符。Haskell 程序员通常更喜欢单独的行和适当缩进的简洁外观;尽管如此,显式使用分号和其他标记始终是一个替代方案。
上面的示例演示了数字 n 的阶乘与比它略小的数字 n - 1 的阶乘之间的简单关系。
将函数调用视为委托。递归函数的指令委托了一个子任务。碰巧的是,委托函数使用与委托者相同的指令;只是输入数据发生了变化。递归函数唯一真正令人困惑的地方是每个函数调用都使用相同的参数名称,因此跟踪多个委托可能很棘手。
让我们看看执行 factorial 3
时会发生什么
- 3 不等于 0,因此我们计算 2 的阶乘
- 2 不等于 0,因此我们计算 1 的阶乘
- 1 不等于 0,因此我们计算 0 的阶乘
- 0 等于 0,因此我们返回 1。
- 为了完成对 1 的阶乘的计算,我们将当前数字 1 乘以 0 的阶乘,即 1,得到 1(1 × 1)。
- 1 不等于 0,因此我们计算 0 的阶乘
- 为了完成对 2 的阶乘的计算,我们将当前数字 2 乘以 1 的阶乘,即 1,得到 2(2 × 1 × 1)。
- 2 不等于 0,因此我们计算 1 的阶乘
- 为了完成对 3 的阶乘的计算,我们将当前数字 3 乘以 2 的阶乘,即 2,得到 6(3 × 2 × 1 × 1)。
(注意我们最终得到了两次出现的 1,因为基本情况是 0 而不是 1;但这没关系,因为乘以 1 不会有任何影响。如果我们想的话,我们可以设计 factorial
在 1 处停止,但约定(通常很有用)是定义 0 的阶乘。)
在阅读或编写递归函数时,你很少需要逐个“展开”递归——我们将此留给编译器。
关于我们对 factorial
的递归定义,还有一点需要注意:两个声明(一个是 factorial 0
,一个是 factorial n
)的顺序很重要。Haskell 通过从顶部开始并选择第一个匹配的函数定义来决定使用哪个函数定义。如果我们在“基本情况”(factorial 0
)之前放置了通用情况(factorial n
),那么通用的 n
将匹配传递给它的任何内容——包括 0。然后编译器将得出结论,factorial 0
等于 0 * factorial (-1)
,依此类推,直到负无穷大(显然这不是我们想要的)。因此,始终从最具体的开始,依次到最通用的列出多个函数定义。
练习 |
---|
|
命令式语言在 Haskell 程序使用递归的相同上下文中使用循环。例如,在 C(一种典型的命令式语言)中编写阶乘函数的一种惯用方法是使用for循环,如下所示
示例:命令式语言中的阶乘函数
int factorial(int n) {
int res = 1;
for ( ; n > 1; n--)
res *= n;
return res;
}
在这里,for 循环导致res
反复乘以n
。每次重复后,n
会减去1
(这就是n--
的作用)。当n
不再大于1
时,重复停止。
将这样的函数直接翻译成 Haskell 是不可能的,因为不允许更改变量res
和n
的值(破坏性更新)。但是,您可以始终通过将每个循环变量作为递归函数的参数来将循环转换为等效的递归形式。例如,以下是将上述循环递归“翻译”成 Haskell 的示例
示例:使用递归模拟循环
factorial n = go n 1
where
go n res
| n > 1 = go (n - 1) (res * n)
| otherwise = res
go
是一个辅助函数,它实际执行阶乘计算。它接受一个额外的参数res
,该参数用作累积参数来构建最终结果。
注意
根据您熟悉的语言,您可能对递归引起的性能问题感到担忧。但是,Haskell 和其他函数式编程语言的编译器包含许多针对递归的优化(鉴于递归的频繁使用,这并不奇怪)。此外,Haskell 是惰性的——计算仅在其他计算需要其结果时执行,这有助于避免某些性能问题。我们将在后面的章节中进一步讨论这些问题以及它们涉及的一些微妙之处。
事实证明,factorial
函数并没有什么特别之处;许多数值函数都可以用自然的方式递归定义。例如,让我们考虑一下乘法。当您第一次学习乘法时(还记得那个时刻吗?:)), 它可能是通过“重复加法”的过程实现的。也就是说,5 × 4 与将 5 相加四次的结果相同。当然,将 5 相加四次与将 5 相加三次然后再加上一次是相同的,即 5 × 4 = 5 × 3 + 5。这导致了乘法的自然递归定义
示例:递归定义的乘法
mult _ 0 = 0 -- anything times 0 is zero
mult n m = (mult n (m - 1)) + n -- recurse: multiply by one less, and add an extra copy
退一步,我们可以看到数值递归如何适应一般的递归模式。数值递归的基线通常由一个或多个特定数字(通常为 0 或 1)组成,这些数字可以立即给出答案。递归情况通过递归调用该函数并使用较小的参数,并使用结果以某种方式生成最终答案来计算结果。使用的“较小参数”通常比当前参数少 1,导致递归“沿着数轴向下行走”(如上面factorial
和mult
的示例)。但是,原型模式不是唯一的可能性;较小的参数也可以通过其他方式生成。
练习 |
---|
|
Haskell 有很多递归函数,特别是与列表相关的函数。[4] 考虑一下length
函数,它用于查找列表的长度
示例:length
的递归定义
length :: [a] -> Int
length [] = 0
length (x:xs) = 1 + length xs
注意
如果您尝试从源文件加载上面的定义,当您尝试使用它时,GHCi 会抱怨“歧义出现”,因为 Prelude 已经提供了length
。在这种情况下,只需将您正在定义的函数的名称更改为其他名称,例如length'
或myLength
。
因此,length
的类型签名告诉我们它接受任何类型的列表并生成一个Int
。下一行说空列表的长度为 0(这是基线)。最后一行是递归情况:如果一个列表不为空,那么它可以分解为第一个元素(这里称为x
)和列表的其余部分(如果不再有元素,它将只是空列表),按照惯例,它将被称作xs
(即x
的复数)。列表的长度是 1(考虑x
)加上xs
的长度(如下一步中的tail
示例,当参数列表与 (:) 模式匹配时,xs
被设置)。
考虑一下连接函数(++)
,它将两个列表连接在一起
示例:递归的(++)
Prelude> [1,2,3] ++ [4,5,6] [1,2,3,4,5,6] Prelude> "Hello " ++ "world" -- Strings are lists of Chars "Hello world"
(++) :: [a] -> [a] -> [a]
[] ++ ys = ys
(x:xs) ++ ys = x : xs ++ ys
这比length
复杂一些。类型说(++)
接受两个相同类型的列表并生成另一个相同类型的列表。基线说将空列表与列表ys
连接与ys
本身相同。最后,递归情况将第一个列表分解为其头部(x
)和尾部(xs
),并说要连接两个列表,将第一个列表的尾部与第二个列表连接起来,然后将头部x
连接到前面。
这里有一个模式:对于基于列表的函数,基线通常涉及一个空列表,递归情况涉及将列表的尾部再次传递给我们的函数,以便列表逐渐变小。
练习 |
---|
为以下基于列表的函数给出递归定义。在每种情况下,想想基线是什么,然后思考一般情况是什么,以所有比它小的东西为例。(请注意,所有这些函数都可以在 Prelude 中使用,因此在 GHCi 中测试您的定义时,您需要为它们取不同的名称。)
|
递归用于定义几乎所有与列表和数字相关的函数。下次您需要基于列表的算法时,从空列表情况和非空列表情况开始,看看您的算法是否递归。
尽管递归在 Haskell 中无处不在,但人们很少需要编写显式递归的函数。相反,标准库函数以各种方式为我们执行递归。例如,实现factorial
函数的一种更简单的方法是
示例:使用标准库函数实现阶乘
factorial n = product [1..n]
这几乎像是作弊,不是吗?:) 这是大多数经验丰富的 Haskell 程序员会编写的 factorial
版本,而不是我们最初使用的显式递归版本。当然,product
函数在幕后使用了一些列表递归,[6] 但以这种方式编写 factorial
意味着你,程序员,不必担心它。