【问题标题】:F# Tail Recursive Function ExampleF# 尾递归函数示例
【发布时间】:2011-03-15 22:54:32
【问题描述】:

我是 F# 的新手,正在阅读关于尾递归函数的文章,希望有人能给我函数 foo 的两种不同实现——一种是尾递归的,另一种不是,这样我可以更好地理解原理。

【问题讨论】:

标签: f# tail-recursion


【解决方案1】:

从一个简单的任务开始,例如将列表中的项目从 '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

由于对 loopcont 的调用是最后调用的函数,无需额外工作,因此它们是尾递归的。

之所以有效,是因为延续 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 [])))))

它按顺序建立一个列表,而不需要你反转它。


不管它的价值如何,开始以非尾递归方式编写函数,它们更易于阅读和使用。

如果您有一个很大的列表要查看,请使用累加器变量。

如果您找不到以方便的方式使用累加器的方法,并且您没有任何其他选项可供使用,请使用延续。我个人认为不平凡、大量使用难以阅读的延续。

【讨论】:

  • 在“Continuation Passing”下面的代码中,您使用 id 函数而不定义它(“loop id l”行。我假设它是正确的(有趣的 x->x) ?
  • @Onorio Catenacci: id 是 F# 自带的内置函数之一,它的定义是 let id x = x
  • @Juliet--我早该知道你会错过这么明显的东西 :-) 我只是以为你忽略了复制所有的演示代码。
  • 对不起,如果我很密集,但我正试图围绕你的第一个累加器示例而我一点也不明白:你通过 loop (f x::acc) xs 调用递归函数但是递归函数只接收一个参数,所以这不会编译?也许我对 function 关键字有些不理解,而我实际上还没有使用过(可以使用 fun 关键字代替吗?)
  • @knocte function 关键字为函数添加了一个参数。见markhneedham.com/blog/2010/02/07/f-function-keyword
【解决方案2】:

尝试比其他示例更简短的解释:

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 + 会导致需要在递归之后执行的操作通话结束。

【讨论】:

  • 感谢 Batibix 提供的简洁示例
【解决方案3】:

这是一个更明显的例子,将其与您通常对阶乘所做的比较。

let factorial n =
    let rec fact n acc =
        match n with
        | 0 -> acc
        | _ -> fact (n-1) (acc*n)
    fact n 1

这个有点复杂,但想法是你有一个累加器来保持运行计数,而不是修改返回值。

此外,这种包装方式通常是个好主意,这样您的调用者就不必担心为累加器播种(请注意,事实是函数本地的)

【讨论】:

    【解决方案4】:

    我也在学习 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;;  
    

    注意:由于斐波那契数可能增长得非常快,您可以将最后一行 tfib 0 1 n 替换为
    tfib 0I 1I n 以利用 F# 中的 Numerics.BigInteger 结构

    【讨论】:

      【解决方案5】:

      另外,在测试的时候,别忘了在Debug模式下编译时,间接尾递归(tailcall)默认是关闭的。这会导致尾调用递归在 Debug 模式下溢出堆栈,但在 Release 模式下不会。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2019-09-14
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多