我正在通过“现在学习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).
答案 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]递归。 这是真的?总是使用尾递归被认为是“好习惯”吗?会吗 值得努力使用累加器来养成一个好习惯吗?
将尾递归构造转换为迭代(循环)是一种相当简单的优化。由于尾部(递归)调用是最后完成的事情,因此可以在递归调用中重用堆栈帧,通过简单地跳转到谓词/函数/方法的开头,使所有意图和目的的递归成为循环。 /子程序。因此,尾递归谓词不会溢出堆栈。应用优化的尾递归构造具有以下好处:
可能的缺点?
当语言标准要求尾递归优化时,这当然不是问题。
引用维基百科:
尾调用很重要,因为它们可以在不添加的情况下实现 调用堆栈的新堆栈帧。当前程序的大部分框架 不再需要它,它可以被尾调用的帧替换, 适当修改(类似于进程的覆盖,但功能 调用)。然后程序可以跳转到被调用的子程序。生成这样的代码 而不是标准的调用序列称为尾调用消除或尾部 呼叫优化。
另见:
我从未理解为什么更多语言不实现尾递归优化
答案 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}}早期已经记录了环境修整。
但是,是的,调试可能是个问题。