如何制作尾递归函数并进行测试呢? OCaml的

时间:2016-11-03 10:37:29

标签: recursion ocaml tail-recursion

我是OCaml的新手,我正在学习尾递归函数。我希望这个函数递归,但我不知道从哪里开始。

let rec rlist r n =
    if n < 1 then []
    else Random.int r :: rlist r (n-1);;

let rec divide = function
    h1::h2::t -> let t1,t2 = divide t in
        h1::t1, h2::t2
    | l -> l,[];;

let rec merge ord (l1,l2) = match l1,l2 with
    [],l | l,[] -> l
    | h1::t1,h2::t2 -> if ord h1 h2
        then h1::merge ord (t1,l2)
        else h2::merge ord (l1,t2);;

有没有办法测试函数是否递归?

2 个答案:

答案 0 :(得分:4)

  

如果你给一个人一条鱼,你可以喂他一天。但是,如果你给他一根钓竿,你可以喂他一辈子。

因此,我最好教你如何自己解决问题,而不是给你解决方案。

尾递归函数是递归函数,其中所有递归调用都处于尾部位置。如果呼叫位置是函数中的最后一个调用,即,如果被调用函数的结果将成为调用者的结果,则调用位置称为尾部位置。

让我们将以下简单函数作为我们的工作示例:

let rec sum n = if n = 0 then 0 else n + sum (n-1)

它不是尾递归函数,因为调用sum (n-1)不在尾部位置,因为其结果随后增加1。以尾递归形式转换一般递归函数并不总是容易的。有时,在效率,可读性和尾递归之间存在权衡。

一般技术是:

  1. 使用累加器
  2. 使用延续传递方式
  3. 使用累加器

    有时函数确实需要存储中间结果,因为递归的结果必须以非平凡的方式组合。递归函数为我们提供了一个空闲容器来存储任意数据,称为堆栈。不幸的是,堆栈容器是有界的,其大小是不可预测的。因此,有时候,最好从堆栈切换到堆。后者稍慢(因为它为垃圾收集器引入了更多的工作),但更大,更可控。在我们的例子中,我们只需要一个词来存储运行总和,所以我们有一个明确的胜利。我们使用的空间更少,而且我们不会引入任何内存垃圾:

    let sum n = 
      let rec loop n acc = if n = 0 then acc else loop (n-1) (acc+n) in
      loop n 0
    

    然而,正如您所看到的,这是一个权衡 - 实施变得稍微大一些,不太容易理解。

    我们在这里使用了一般模式。由于我们需要引入累加器,我们需要一个额外的参数。由于我们不想或不能改变我们函数的接口,我们引入了一个新的辅助函数,它是递归的并且将携带额外的参数。这里的诀窍是我们在进行递归调用之前应用求和,而不是之后。

    使用延续传递样式

    当您使用累加器重写递归算法时并非总是如此。在这种情况下,可以使用更通用的技术 - 继续传递风格。基本上,它接近于先前的技术,但我们将使用继续代替累加器。延续是一个函数,它实际上将在递归之后需要完成的工作推迟到以后的时间。通常,我们将此函数称为return或简称为k(对于延续)。在精神上,延续是一种将计算结果抛回到未来的方法。 &#34;返回&#34;是因为您将来将结果返回给调用者,因为,结果将不会立即使用,但一旦所有内容都准备就绪。但是,让我们来看看实现:

    let sum n = 
      let rec loop n k = if n = 0 then k 0 else loop (n-1) (fun x -> k (x+n)) in
      loop n (fun x -> x)
    

    你可能会看到,我们采用了相同的策略,除了我们使用函数int代替k累加器作为第二个参数。如果是基本情况,如果n为零,我们将返回0,(您可以将k 0视为return 0)。在一般情况下,我们在尾部位置递归,并且定期递归归纳变量n,但是,我们打包工作,应该将递归函数的结果放入函数中:{{ 1}}。基本上,这个函数说,一旦fun x -> k (x+n) - 递归调用的结果准备就绪,将它添加到数字x并返回。 (同样,如果我们使用名称n而不是return,它可能更具可读性:k)。

    这里没有魔法,我们仍然有与累加器相同的权衡,因为我们在每次递归调用时都会创建一个新的闭包(函数对象)。每个新创建的闭包都包含对前一个闭包的引用(通过参数传递)。例如,fun x -> return (x+n)是一个函数,它捕获两个自由变量,值fun x -> k (x+n)和函数n,这是前一个延续。基本上,这些延续形成一个链表,其中每个节点都有计算和除一个之外的所有参数。因此,计算被延迟,直到最后一个已知。

    当然,对于我们的简单示例,不需要使用CPS,因为它会产生不必要的垃圾并且速度要慢得多。这仅用于演示。然而,对于更复杂的算法,特别是对于那些在非平凡的情况下组合两个或更多个递归调用的结果的算法,例如折叠图形数据结构。

    所以现在,凭借新知识,我希望你能够像馅饼一样轻松解决你的问题。

    测试尾递归

    尾调用是一个非常明确的语法概念,因此调用是否处于尾部位置应该非常明显。但是,仍然很少有方法可以检查呼叫是否处于尾部位置。事实上,还有其他情况,尾部调用优化可能会发挥作用。例如,对shortciruit逻辑运算符正确的调用也是尾调用。因此,当呼叫使用堆栈或尾部呼叫时,并不总是很明显。 OCaml的新版本允许人们在呼叫场所放置注释,例如,

    k

    如果调用实际上不是尾调用,则编译器会发出警告:

    let rec sum n = if n = 0 then 0 else n + (sum [@tailcall]) (n-1)
    

    另一种方法是使用Warning 51: expected tailcall 选项进行编译。注释文件将包含每个调用的注释,例如,如果我们将上述函数放入文件-annot并使用sum.ml进行编译,那么我们可以打开ocaml -annot sum.ml文件并查看所有电话:

    sum.annot

    但是,如果我们进行第三次实现,那么看到所有调用都是尾调用,例如"sum.ml" 1 0 41 "sum.ml" 1 0 64 call( stack )

    grep call -A1 sum.annot

    最后,您可以使用一些大输入来测试您的程序,并查看您的程序是否会因stackoverflow而失败。您甚至可以减小堆栈的大小,这可以通过环境变量call( tail -- call( tail -- call( tail -- call( tail 来控制,例如将堆栈限制为一千个单词:

    OCAMLRUNPARAM

答案 1 :(得分:3)

您可以执行以下操作:

let rlist r n =
  let aux acc n =
    if n < 1 then acc
    else aux (Random.int r :: acc) (n-1)
  in aux [] n;;

let divide l =
  let aux acc1 acc2 = function
    | h1::h2::t -> 
        aux (h1::acc1) (h2::acc2) t
    | [e] -> e::acc1, acc2
    | [] -> acc1, acc2
  in aux [] [] l;;

但是对于分歧,我更喜欢这个解决方案:

 let divide l =
   let aux acc1 acc2 = function
     | [] -> acc1, acc2
     | hd::tl -> aux acc2 (hd :: acc1) tl
   in aux [] [] l;;

let merge ord (l1,l2) = 
  let rec aux acc l1 l2 = 
    match l1,l2 with
      | [],l | l,[] -> List.rev_append acc l
      | h1::t1,h2::t2 -> if ord h1 h2
        then aux (h1 :: acc) t1 l2
        else aux (h2 :: acc) l1 t2
  in aux [] l1 l2;;

关于测试一个函数是否是尾递归的问题,通过稍微查看它你会找到它here