我是F#的新手,正在阅读有关尾递归函数的内容,希望有人可以给我两个不同的函数foo实现 - 一个是尾递归的,一个不是这样我可以更好地理解这个原理。
答案 0 :(得分:43)
从一个简单的任务开始,比如将列表中的项目从a映射到b。我们想写一个有签名的函数
val map: ('a -> 'b) -> 'a list -> 'b list
其中
map (fun x -> x * 2) [1;2;3;4;5] == [2;4;6;8;10]
从非尾递归版本开始:
let rec map f = function
| [] -> []
| x::xs -> f x::map f xs
这不是尾递归,因为函数在进行递归调用后仍有工作要做。 ::
是List.Cons(f x, map f xs)
的语法糖。
如果我将最后一行重写为| x::xs -> let temp = map f xs; f x::temp
,那么函数的非递归性可能会更加明显 - 显然它在递归调用之后正在工作。
使用累加器变量使尾部递归:
let map f l =
let rec loop acc = function
| [] -> List.rev acc
| x::xs -> loop (f x::acc) xs
loop [] l
这是我们在变量acc
中建立一个新列表。由于列表是反向构建的,我们需要在将输出列表反馈给用户之前将其反转。
如果你正处于一个小小的思维扭曲中,你可以使用继续传递来更简洁地编写代码:
let map f l =
let rec loop cont = function
| [] -> cont []
| x::xs -> loop ( fun acc -> cont (f x::acc) ) xs
loop id l
由于对loop
和cont
的调用是最后调用的函数而没有额外的工作,因此它们是尾递归的。
这是有效的,因为延续cont
是由一个新的延续捕获的,而这个延续又被另一个延续,导致一种类似树的数据结构,如下所示:
(fun acc -> (f 1)::acc)
((fun acc -> (f 2)::acc)
((fun acc -> (f 3)::acc)
((fun acc -> (f 4)::acc)
((fun acc -> (f 5)::acc)
(id [])))))
按顺序建立一个列表而不要求你反转它。
为了它的价值,开始以非尾递归的方式编写函数,它们更容易阅读和使用。
如果您有一个大清单,请使用累加器变量。
如果您无法以方便的方式找到使用累加器的方法,并且您没有任何其他选项可供使用,请使用延续。我个人认为难以阅读的非常重要,大量使用延续。
答案 1 :(得分:21)
尝试比其他示例更简短的解释:
let rec foo n =
match n with
| 0 -> 0
| _ -> 2 + foo (n-1)
let rec bar acc n =
match n with
| 0 -> acc
| _ -> bar (acc+2) (n-1)
foo不是尾递归,因为foo必须递归调用foo才能评估“2 + foo(n-1)”并返回它。
bar是尾递归的,因为bar不必使用递归调用的返回值来返回值。它可以让递归调用的bar立即返回其值(不通过调用堆栈返回所有方式)。编译器通过将递归重写为循环来看到这个和'作弊'。
将bar中的最后一行更改为“| _ - > 2+(bar(acc + 2)(n-1))”会破坏尾部递归。
答案 2 :(得分:8)
这是一个更明显的例子,将它与通常为因子做的事情进行比较。
let factorial n =
let rec fact n acc =
match n with
| 0 -> acc
| _ -> fact (n-1) (acc*n)
fact n 1
这个有点复杂,但想法是你有一个累加器来保持运行的计数,而不是修改返回值。
此外,这种包装方式通常是一个好主意,这样你的调用者就不必担心为累加器播种(注意事实是函数的本地)
答案 3 :(得分:3)
我也在学习F#。 以下是非尾递归和尾递归函数来计算斐波那契数。
非尾递归版
let rec fib = function
| n when n < 2 -> 1
| n -> fib(n-1) + fib(n-2);;
尾递归版
let fib n =
let rec tfib n1 n2 = function
| 0 -> n1
| n -> tfib n2 (n2 + n1) (n - 1)
tfib 0 1 n;;
注意:由于fibanacci数字可以快速增长,您可以将最后一行tfib 0 1 n
替换为
tfib 0I 1I n
利用F#中的Numerics.BigInteger结构
答案 4 :(得分:2)
此外,在测试时,不要忘记在调试模式下编译时默认关闭间接尾递归(tailcall)。这可能导致尾调用递归在调试模式下溢出堆栈,但不会在释放模式下溢出。