【问题标题】:Tail call optimization in RacketRacket 中的尾调用优化
【发布时间】:2012-07-16 16:58:45
【问题描述】:

我在做 SICP exercise 2.28 并偶然发现了以下代码的奇怪行为:

(define (fringe tree)
  (cond
    ((null? tree) '())
    ((not (pair? tree)) (list tree))
    (else (append (fringe (car tree)) (fringe (cdr tree))))))

(define (fringe-tail tree)
  (define (fringe-iter tree result)
    (cond
      ((null? tree) result)
      ((not (pair? tree)) (list tree))
      (else (fringe-iter (cdr tree) (append result (fringe-tail (car tree)))))))
  (fringe-iter tree '()))

(define x (make-list (expt 10 4) 4))
(time (fringe x))
(time (fringe-tail x))

普通的fringe 运行速度比它的迭代版本fringe-tail 快​​得多:

cpu time: 4 real time: 2 gc time: 0

对比

cpu time: 1063 real time: 1071 gc time: 191

看起来fringe 被优化为循环并避免了任何分配,而fringe-tail 运行速度要慢得多,并且花费时间创建和销毁对象。

谁能给我解释一下? (以防万一我使用的是球拍 5.2.1)

【问题讨论】:

    标签: garbage-collection scheme racket tail-call-optimization


    【解决方案1】:

    如果将最后一个子句替换为:

    (else (fringe-iter (cdr tree) (append (fringe-tail (car tree)) result)))
    

    然后它们以相同的速度运行该输入,并且尾递归版本对于更大的输入更快。

    问题是您将append 放在前面的cdr 列表要长得多,它遍历和分配的内容比简单版本要多得多,后者将car 的边缘附加到前面。

    【讨论】:

      【解决方案2】:

      给定的代码在非尾部位置有应用程序,因此该函数不是迭代的,尽管它的名称。 :)

      试试这个:

      (define (fringe-tail tree)
        (define (iter tree k)
          (cond
            [(null? tree)
             (k '())]
            [(not (pair? tree)) 
             (k (list tree))]
            [else
             (iter (car tree)
                   (lambda (v1)
                     (iter (cdr tree)
                           (lambda (v2)
                             (k (append v1 v2))))))]))
        (iter tree (lambda (a-fringe) a-fringe)))
      

      但是,它仍然使用 append,这与它的第一个参数的长度一样昂贵。 fringefringe-tail 的某些退化输入会导致大量计算问题。

      让我们举一个这种退化输入的例子:

      (define (build-evil-struct n)
        (if (= n 0)
            (list 0)
            (list (list (build-evil-struct (sub1 n)))
                  (build-evil-struct (sub1 n))
                  (list n))))
      
      (define evil-struct (build-evil-struct 20))
      

      当同时应用于 fringefringe-iter 时,您会看到非常糟糕的性能:我在自己的系统上观察到 fringe 几秒钟的计算时间 边缘尾巴。这些测试是在禁用调试的 DrRacket 下运行的。如果您启用调试,您的数字将有很大不同。

      > (time (void (fringe evil-struct)))
      cpu time: 2600 real time: 2602 gc time: 1212
      
      > (time (void (fringe-tail evil-struct)))
      cpu time: 4156 real time: 4155 gc time: 2740
      

      对于这两种情况,append 的使用使它们容易受到某些退化输入的影响。如果我们编写 fringe 的累积版本,我们可以消除该成本,因为我们可以使用恒定时间 cons 操作:

      (define (fringe/acc tree)
        (define (iter tree acc)
          (cond [(null? tree)
                 acc]
                [(not (pair? tree))
                 (cons tree acc)]
                [else
                 (iter (car tree) (iter (cdr tree) acc))]))
        (iter tree '()))
      

      我们看一下 fringe/acc 在这个结构上的表现:

      > (time (void (fringe/acc evil-struct)))
      cpu time: 272 real time: 274 gc time: 92
      

      好多了!将这里的所有调用都变成尾调用是一件简单的事情。

      (define (fringe/acc/tail tree)
        (define (iter tree acc k)
          (cond [(null? tree)
                 (k acc)]
                [(not (pair? tree))
                 (k (cons tree acc))]
                [else
                 (iter (cdr tree) acc
                       (lambda (v1)
                         (iter (car tree) v1 k)))]))
        (iter tree '() (lambda (v) v)))
      
      > (time (void (fringe/acc/tail evil-struct)))
      cpu time: 488 real time: 488 gc time: 280
      

      在这种特殊情况下,Racket 的堆栈实现比我们在延续中表示的具体堆栈快一点,因此 fringe/accfringe/acc /尾巴。尽管如此,这两者都明显优于 fringe,因为它们避免了 append

      话虽这么说:这个函数已经作为flatten 函数内置在Racket 中!因此,如果您不想重新发明轮子,不妨使用它。 :)

      【讨论】:

      • 你试过你的全尾递归版本了吗?问题给出的例子更快吗?
      • 在稍微复杂的树上添加了一些性能数据。
      猜你喜欢
      • 2012-08-19
      • 2011-05-27
      • 2019-04-20
      • 1970-01-01
      • 1970-01-01
      • 2011-03-31
      • 2014-04-06
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多