Che*_* Yo 1 recursion ocaml tail-recursion
我想让这个函数递归,但我不知道从哪里开始。
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);;
Run Code Online (Sandbox Code Playgroud)
有没有办法测试一个函数是否是递归的?
如果你给一个人一条鱼,你就可以养活他一天。但是如果你给他一根鱼竿,你就可以养活他一辈子。
所以,与其给你解决办法,不如教你自己怎么解决。
尾递归函数是递归函数,其中所有递归调用都在尾位置。如果调用位置是函数中的最后一次调用,即如果被调用函数的结果将成为调用者的结果,则调用位置称为尾位置。
让我们以下面的简单函数作为我们的工作示例:
let rec sum n = if n = 0 then 0 else n + sum (n-1)
Run Code Online (Sandbox Code Playgroud)
它不是尾递归函数,因为调用sum (n-1)
不在尾位置,因为它的结果随后增加了 1。将一般递归函数转换为尾递归形式并不总是那么容易。有时,在效率、可读性和尾递归之间需要权衡。
一般的技术是:
有时一个函数确实需要存储中间结果,因为递归的结果必须以一种非平凡的方式组合。递归函数为我们提供了一个免费的容器来存储任意数据——调用栈。语言运行时存储当前调用函数参数的地方。不幸的是,堆栈容器是有界的,其大小是不可预测的。因此,有时最好从堆栈切换到堆。后者稍慢(因为它为垃圾收集器引入了更多工作),但更大且更可控。在我们的例子中,我们只需要一个词来存储运行总和,所以我们显然赢了。我们使用的空间更少,而且我们不会引入任何内存垃圾:
let sum n =
let rec loop n acc = if n = 0 then acc else loop (n-1) (acc+n) in
loop n 0
Run Code Online (Sandbox Code Playgroud)
然而,正如您所看到的,这需要权衡——实现变得稍微大一些,并且不太容易理解。
我们在这里使用了一个通用模式。由于我们需要引入一个累加器,我们需要一个额外的参数。由于我们不想或不能改变我们函数的接口,我们引入了一个新的辅助函数,它是递归的并且会携带额外的参数。这里的技巧是我们在执行递归调用之前而不是之后应用求和。
当您可以使用累加器重写递归算法时,情况并非总是如此。在这种情况下,可以使用更通用的技术 - 继续传递风格。基本上,它与前面的技术很接近,但我们将使用延续来代替累加器。延续是一个函数,它实际上将需要在递归之后完成的工作推迟到以后的时间。按照惯例,我们称此函数return
或简称为k
(用于延续)。从心理上讲,延续是一种将计算结果扔回未来的方式。“返回”是因为你将结果返回给调用者,在未来,因为结果不会现在被使用,而是一旦一切准备就绪。但是让我们看看实现:
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)
Run Code Online (Sandbox Code Playgroud)
您可能会看到,我们采用了相同的策略,只是int
我们使用了一个函数k
作为第二个参数而不是累加器。如果基本情况,如果n
为零,我们将返回 0,(您可以读k 0
作return 0
)。在一般情况下,我们在尾部位置递归,并定期递减归纳变量n
,但是,我们将应该用递归函数的结果完成的工作打包成一个函数:fun x -> k (x+n)
。基本上,这个函数说,一旦x
- 递归调用的结果准备好,将其添加到数字n
并返回。(同样,如果我们使用 namereturn
而不是k
它可能更具可读性:)fun x -> return (x+n)
。
这里没有魔法,我们仍然有与累加器相同的权衡,因为我们在每次递归调用时创建一个新的闭包(功能对象)。每个新创建的闭包都包含对前一个闭包的引用(通过参数传递)。例如,fun x -> k (x+n)
是一个函数,它捕获两个自由变量 valuen
和 function k
,这是前一个延续。基本上,这些延续形成一个链表,其中每个节点承载一个计算和除一个之外的所有参数。因此,计算被延迟到知道最后一个。
当然,对于我们这个简单的例子,没有必要使用 CPS,因为它会产生不必要的垃圾并且速度会慢很多。这仅用于演示。然而,对于更复杂的算法,特别是那些在非平凡情况下组合两个或多个递归调用的结果的算法,例如折叠图数据结构。
所以现在,有了新知识,我希望你能像馅饼一样轻松地解决你的问题。
尾调用是一个非常明确的句法概念,因此调用是否在尾位置应该很明显。但是,仍然很少有方法可以检查调用是否处于尾部位置。事实上,在其他情况下,尾调用优化可能会发挥作用。例如,对短路逻辑运算符的调用也是尾调用。因此,当调用使用堆栈或尾调用时并不总是很明显。新版本的 OCaml 允许在调用位置放置注释,例如,
let rec sum n = if n = 0 then 0 else n + (sum [@tailcall]) (n-1)
Run Code Online (Sandbox Code Playgroud)
如果调用不是真正的尾调用,编译器会发出警告:
Warning 51: expected tailcall
Run Code Online (Sandbox Code Playgroud)
另一种方法是使用-annot
选项进行编译。注释文件将包含每个调用的注释,例如,如果我们将上述函数放入一个文件中sum.ml
并使用 编译ocamlc -annot sum.ml
,那么我们可以打开sum.annot
文件并查找所有调用:
"sum.ml" 1 0 41 "sum.ml" 1 0 64
call(
stack
)
Run Code Online (Sandbox Code Playgroud)
但是,如果我们放置第三个实现,那么看到所有调用都是尾调用,例如grep call -A1 sum.annot
:
call(
tail
--
call(
tail
--
call(
tail
--
call(
tail
Run Code Online (Sandbox Code Playgroud)
最后,您可以使用一些大输入来测试您的程序,看看您的程序是否会因堆栈溢出而失败。您甚至可以减小堆栈的大小,这可以通过环境变量来控制OCAMLRUNPARAM
,例如,将堆栈限制为一千字:
export OCAMLRUNPARAM='l=1000'
ocaml sum.ml
Run Code Online (Sandbox Code Playgroud)