我应该避免Prolog中的尾递归吗?

时间:2012-12-31 02:00:06

标签: prolog tail-recursion accumulator tailrecursion-modulo-cons

我正在通过“现在学习Prolog”在线书籍来寻找乐趣。

我正在尝试编写一个遍历列表中每个成员的谓词,并使用累加器向其中添加一个谓词。我已经很容易做到了,没有尾递归。

addone([],[]).
addone([X|Xs],[Y|Ys]) :- Y is X+1, addone(Xs,Ys).

但我已经读过,出于性能原因,最好避免这种类型的递归。这是真的?总是使用尾递归被认为是“好习惯”吗?是否值得努力使用累加器来养成良好的习惯?

我试图将此示例更改为使用累加器,但它会反转列表。我怎么能避免这个?

accAddOne([X|Xs],Acc,Result) :- Xnew is X+1, accAddOne(Xs,[Xnew|Acc],Result).
accAddOne([],A,A).
addone(List,Result) :- accAddOne(List,[],Result).

5 个答案:

答案 0 :(得分:11)

简短回答:尾递归是可取的,但不要过分强调它。

您的原始程序与Prolog中的尾部递归一样。但还有更重要的问题:正确性和终止。

事实上,许多实现都非常愿意为他们认为更重要的其他属性牺牲尾递归。例如steadfastness

但是您尝试的优化有一定的意义。至少从历史的角度来看。

回到20世纪70年代,主要的AI语言是LISP。相应的定义应该是

(defun addone (xs)
  (cond ((null xs) nil)
    (t (cons (+ 1 (car xs))
         (addone (cdr xs))))))

这不是直接尾递归的:原因是cons:在那个时候的实现中,首先评估它的参数,然后才能执行cons。因此,如您所指出的那样重写(并反转结果列表)是一种可能的优化技术。

然而,在Prolog中,由于逻辑变量,您可以在了解实际值之前创建缺点。如此多的程序在LISP中不是尾递归的,转换为Prolog中的尾递归程序。

在许多Prolog教科书中仍然可以找到这种影响。

答案 1 :(得分:7)

你的addOne程序已经 尾递归。

头部和最后一次递归调用之间没有选择点,因为/ 2是确定性的。

有时会添加累加器以允许尾递归,我能想到的更简单的例子是reverse / 2。这是一个天真的反向(nreverse / 2),非尾递归

nreverse([], []).
nreverse([X|Xs], R) :- nreverse(Xs, Rs), append(Rs, [X], R).

如果我们添加累加器

reverse(L, R) :- reverse(L, [], R).
reverse([], R, R).
reverse([X|Xs], A, R) :- reverse(Xs, [X|A], R).

现在reverse / 3是尾递归的:递归调用是最后一个,没有选择点。

答案 2 :(得分:4)

O.P。表示:

  

但我已经读过,出于性能原因,最好避免[tail]递归。   这是真的?总是使用尾递归被认为是“好习惯”吗?会吗   值得努力使用累加器来养成一个好习惯吗?

将尾递归构造转换为迭代(循环)是一种相当简单的优化。由于尾部(递归)调用是最后完成的事情,因此可以在递归调用中重用堆栈帧,通过简单地跳转到谓词/函数/方法的开头,使所有意图和目的的递归成为循环。 /子程序。因此,尾递归谓词不会溢出堆栈。应用优化的尾递归构造具有以下好处:

  • 执行速度稍快,因为不需要分配/释放新的堆栈帧;此外,你可以获得更好的参考位置,因此可以说更少的分页。
  • 递归深度没有上限。
  • 没有堆栈溢出。

可能的缺点?

  • 丢失有用的堆栈跟踪。如果TRO仅应用于发布/优化版本而不是调试版本中,则不是问题,但是......
  • 开发人员将编写依赖于TRO的代码,这意味着在未应用TRO的情况下,应用TRO的代码将运行良好。这意味着在上述情况下(TRO仅在发布/优化版本中),发布版本和调试版本之间存在功能变化,这实际上意味着编译器选项的选择会从相同的源代码生成两个不同的程序。

当语言标准要求尾递归优化时,这当然不是问题。

引用维基百科:

  

尾调用很重要,因为它们可以在不添加的情况下实现   调用堆栈的新堆栈帧。当前程序的大部分框架   不再需要它,它可以被尾调用的帧替换,   适当修改(类似于进程的覆盖,但功能   调用)。然后程序可以跳转到被调用的子程序。生成这样的代码   而不是标准的调用序列称为尾调用消除或尾部   呼叫优化。

另见:

我从未理解为什么更多语言不实现尾递归优化

答案 3 :(得分:2)

我认为addone的第一个版本不会导致代码效率降低。它也更具可读性,所以我认为没有理由为什么避免它应该是好的做法。

在更复杂的示例中,编译器可能无法自动将代码传输到尾递归。然后将其重写为优化可能是合理的,但前提是它确实是必要的。

那么,如何实现addone的工作尾递归版?这可能是作弊但假设reverse是通过尾递归实现的(例如,请参阅here),那么它可用于解决您的问题:

accAddOne([X|Xs],Acc,Result) :- Xnew is X+1, accAddOne(Xs,[Xnew|Acc],Result).
accAddOne([],Acc,Result) :- reverse(Acc, Result).
addone(List,Result) :- accAddOne(List,[],Result).
但是,这是极其笨拙的。 : - )

顺便说一句,我找不到更简单的解决方案。可能由于与Haskell中的foldr相同的原因通常没有使用尾递归定义。

答案 4 :(得分:0)

与其他一些编程语言相比,某些Prolog实现非常适合尾部递归程序。尾递归可以作为上次呼叫优化(LCO)的特例处理。例如,这在Java中不起作用:

public static boolean count(int n) {
    if (n == 0) {
        return true;
    } else {
        return count(n-1);
    }
}

public static void main(String[] args) {
    System.out.println("count(1000)="+count(1000));
    System.out.println("count(1000000)="+count(1000000));
}

结果将是:

count(1000)=true
Exception in thread "main" java.lang.StackOverflowError
    at protect.Count.count(Count.java:9)
    at protect.Count.count(Count.java:9)

另一方面,主要的Prolog实现没有任何问题:

 ?- [user].
 count(0) :- !.
 count(N) :- M is N-1, count(M).
 ^D

结果将是:

?- count(1000).
true.
?- count(1000000).
true.

Prolog系统之所以能够做到这一点,是因为无论如何它们的执行通常都是trapolpolin风格,而最后调用的优化则是选择点消除和环境修整的问题。 {@ {3}}早期已经记录了环境修整。

但是,是的,调试可能是个问题。