【问题标题】:Should I avoid tail recursion in Prolog and in general?我应该避免在 Prolog 和一般情况下进行尾递归吗?
【发布时间】:2012-12-15 07:42:33
【问题描述】:

我正在阅读“Learn Prolog now”在线图书以获取乐趣。

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

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).

【问题讨论】:

  • addone 已经完全可尾调用优化。它是tail recursive "modulo cons",Prolog 确实有这种优化——新的 cons 单元 [Y|Ys] 被分配 first ,其中有两个“洞”(两个尚未实例化的 logvar,YYs),然后Y 在规则主体内被实例化(由is/2),然后然后递归调用实例化逻辑变量Ys。因此,无需从递归调用返回到此规则的主体。
  • LPN!如今有一整章是通过示例来展示差异的。据我所知,Prolog 是尾递归优化的,因此它是可取的,因为它通过不后退有效地将 N 步转换为 N/2。 LPN:learnprolognow.org/… 第 5.3 章,2018 年 1 月。
  • IOW 第一个 sn-p 是最好的。 Prolog 在以自上而下的方式生产/处理/构建其列表方面已经是最优秀的了。劣质语言迫使您反向构建列表,然后将其反向,但那是因为它们劣质。自上而下是最好的。

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


【解决方案1】:

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

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

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

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

早在 1970 年代,主要的 AI 语言是 LISP。相应的定义是

(defun 插件 (xs)
  (cond ((null xs) nil)
    (t (cons (+ 1 (car xs)))
         (添加(cdr xs))))))

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

然而,在 Prolog 中,您可以在知道实际值之前创建缺点,这要归功于逻辑变量。这么多在 LISP 中不是尾递归的程序,在 Prolog 中被翻译成尾递归程序。

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

【讨论】:

    【解决方案2】:

    您的 addOne 过程已经尾递归的。

    head 和最后一个递归调用之间没有选择点,因为 is/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 是尾递归:递归调用是最后一个,没有选择点了。

    【讨论】:

    • 您对剪切的建议不正确。最小的反例:freeze(Ys,(true;true)), addone([0],Ys) 应该给出两个答案,而不是像你的版本那样有 cut。
    • 我很欣赏你的例子,并删除了我的建议。说实话,我无法理解冻结的行为......
    • 简单的经验法则:在其防护中具有普遍统一性的切口几乎总是红色切口。所以大多数情况下,只有干净的只读测试是绿色的。
    • 由于 freeze/2 或 when/2 或类似结构,您的剪辑是红色的。很多时候(不是在这个例子中)这样的切割也用 clpfd 显示为红色。
    • @false:当然有道理(我看到了一些解释here)。我想(不太确定)我从 Clocksin Mellish 那里得到了建议……
    【解决方案3】:

    O.P.说:

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

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

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

    可能的缺点?

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

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

    引用维基百科:

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

    另见:

    我一直不明白为什么更多的语言不实现尾递归优化

    【讨论】:

    • 由于逻辑变量和非确定性,Prolog 中的情况明显比其他语言复杂。事实上,它是 Prolog 机器复杂性的主要来源:在 WAM 中,它导致了一个非常复杂(和巧妙)的变量分类方案,这通常导致实现遗漏了其中的某些部分。 ZIP 需要对相关变量进行昂贵的动态扫描,并且在 GC 期间需要一些开销。
    【解决方案4】:

    我不认为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 相同的原因,通常不定义尾递归。

    【讨论】:

    • +1 因为只有你注意到 OP 的 accAddOne 是错误的。
    • @day 没错,OP 已经说过它会产生相反的结果。
    • addone 的第一个版本应该是高效的代码。
    【解决方案5】:

    与其他一些编程语言相比,某些 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 系统可以做到这一点的原因是,它们的执行通常是蹦床式的,最后调用优化是选择点消除和环境修剪的问题。早期的WAM 已经记录了环境修剪。

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

    【讨论】:

      猜你喜欢
      • 2010-10-15
      • 2020-10-05
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2022-06-14
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多