遍历列表时反转列表与非尾递归

Posted

技术标签:

【中文标题】遍历列表时反转列表与非尾递归【英文标题】:Reversing list vs non tail recursion when traversing lists 【发布时间】:2021-04-24 06:17:56 【问题描述】:

我想知道经验丰富的 lispers / 函数式程序员通常如何决定使用什么。比较:

(define (my-map1 f lst)
  (reverse
   (let loop ([lst lst] [acc '()])
     (if (empty? lst)
         acc
         (loop (cdr lst) (cons (f (car lst)) acc))))))

(define (my-map2 f lst)
  (if (empty? lst)
      '()
      (cons (f (car lst)) (my-map2 f (cdr lst)))))

问题可以这样描述:每当我们必须遍历一个列表时,我们是否应该将结果收集到累加器中,累加器保留尾递归,但最终需要列表反转?或者我们应该使用未优化的递归,但我们不必反转任何东西?

在我看来,第一个解决方案总是更好。确实,那里有额外的复杂性(O(n))。但是,它使用的内存要少得多,更不用说调用不是立即完成的函数了。

然而,我已经看到了使用第二种方法的不同示例。要么我遗漏了一些东西,要么这些例子只是教育性的。是否存在未优化递归更好的情况?

【问题讨论】:

【参考方案1】:

如果可能,我会使用像 map 这样的高阶函数,它会在底层构建一个列表。在 Common Lisp 中,我也经常使用 loop,它有一个 collect 关键字,用于以向前的方式构建列表(我还使用 series 库,它也透明地实现了它)。

我有时会使用非尾递归的递归函数,因为它们能更好地表达我想要的,而且列表的大小会相对较小;特别是在编写宏时,被操作的代码通常不会很大。

对于我不收集到列表中的更复杂的问题,我通常接受为每个解决方案调用的回调函数。这样可以确保更清楚地区分数据的产生方式和使用方式。

这种方法对我来说是最灵活的,因为没有假设应该如何处理或收集数据。但这也意味着回调函数可能会执行副作用或非本地返回(参见下面的示例)。只要副作用的范围很小(函数局部),我认为这不是一个特别的问题。

例如,如果我想要一个函数生成 0 到 N-1 之间的所有自然数,我会写:

(defun range (n f)
  (dotimes (i n)
    (funcall f i)))

这里的实现会遍历从 0 到 N 的所有值,并用值 I 调用 F。

如果我想将它们收集到一个列表中,我会写:

(defun range-list (N)
  (let ((list nil))
    (range N (lambda (v) (push v list)))
    (nreverse list)))

但是,我也可以通过使用队列来避免整个 push/nreverse 成语。 Lisp 中的队列可以实现为一对 (first . last),它跟踪底层链表集合的第一个和最后一个 cons 单元。这允许以恒定的时间将元素附加到末尾,因为不需要遍历列表(参见 P. Norvig 的 在 Lisp 中实现队列,1991)。

(defun queue ()
  (let ((list (list nil)))
    (cons list list)))

(defun qpush (queue element)
  (setf (cdr queue)
        (setf (cddr queue)
              (list element))))

(defun qlist (queue)
  (cdar queue))

因此,该函数的替代版本是:

(defun range-list (n)
  (let ((q (queue)))
    (range N (lambda (v) (qpush q v)))
    (qlist q)))

当您不想构建所有元素时,生成器/回调方法也很有用;这有点像惰性评估模型(例如 Haskell),您只使用您需要的项目。

假设您想使用range 查找vector 中的第一个空槽,您可以这样做:

(defun empty-index (vector)
  (block nil
    (range (length vector)
           (lambda (d) 
             (when (null (aref vector d))
               (return d))))))

这里,词法名称nilblock允许匿名函数调用return以返回值退出块。

在其他语言中,相同的行为通常由内而外颠倒过来:我们使用带有游标和下一个操作的迭代器对象。我倾向于认为简单地编写迭代并调用回调函数会更简单,但这也是另一种有趣的方法。

【讨论】:

阅读您的帖子总是很有启发性。我喜欢这个简单的queue 实现。 @谢谢谢谢!队列函数是从内存中编写的,我猜是基于 Norvig 的“在 Lisp 中实现队列” 正确,PAIP也是开始编程的最佳方式之一。 经过一番思考后,我终于理解了这个队列是如何工作的)虽然在球拍中实现起来会更难,因为它严重不鼓励使用可变结构。好吧,有人可能会争辩说我们应该坚持功能风格。另一方面,我认为只要我们知道自己在做什么,就可以做任何我们想做的事)无论如何,谢谢您的回复! @heinwol 请参阅Mutable Pairs and Lists 的球拍手册。在大多数情况下,不可变结构是有利的,但 mcons 肯定有可行的用例。【参考方案2】:

错误的二分法

您还有其他选择。在这里,我们可以通过单次遍历在列表上保留尾递归和map。这里使用的技术称为 continuation-passing style -

(define (map f lst (return identity))
  (if (null? lst)
      (return null)
      (map f
           (cdr lst)
           (lambda (r) (return (cons (f (car lst)) r))))))

(define (square x)
  (* x x))

(map square '(1 2 3 4))
'(1 4 9 16)

这个问题被标记为racket,它内置了对delimited continuations 的支持。我们可以使用单次遍历完成map,但这次不使用递归。享受 -

(require racket/control)

(define (yield x)
  (shift return (cons x (return (void)))))

(define (map f lst)
  (reset (begin
           (for ((x lst))
             (yield (f x)))
           null)))

(define (square x)
  (* x x))

(map square '(1 2 3 4))
'(1 4 9 16)

我的目的是让这篇文章向您展示将您的思想归类为特定结构的不利之处。我来学习 Scheme/Racket 的美妙之处在于,任何您可以梦想的实现都可供您使用。

我强烈推荐 Matthew Butterick 的 Beautiful Racket。这本易于使用且免费提供的电子书打破了您脑海中的玻璃天花板,向您展示了如何以面向语言的方式思考您的解决方案。

【讨论】:

哇,现在我知道如何使用这种“连续传递风格”了!我认为它只对线程或类似的东西有用。它似乎也比天真的递归更快!但它仍然比累加器慢。也可能是它使用的内存比累加器多几倍(~3?):每个捕获都必须存储对 f 和 lst 的附加引用。或者也许它以某种方式得到了优化,我不知道它是如何工作的) 不过,你的方法真的很漂亮!你的回答真的很鼓舞人心!谢谢谢谢! @heinwol 带有累加器和反向的线性递归可能是专门处理map 的最有效和最快的方法。但是延续传递风格和定界延续使得单独使用线性递归无法完成的事情成为可能。这是了解它们的好方法,因为您现在已经看到map 以多种方式实现。下次你遇到 continuation-passing style 或 delimited continuations 时,它们就不会那么陌生了:D【参考方案3】:

有很多方法可以使递归保持迭代过程。

我通常直接做延续传球风格。这是我的“自然”方式。

一个考虑到函数的类型。有时您需要将函数与其周围的函数连接起来,根据它们的类型,您可以选择另一种递归方式。

您应该从解决“小阴谋家”开始,为它打下坚实的基础。在“小打字机”中,您可以发现另一种类型的递归,它建立在其他计算哲学的基础上,用于 agda、coq 等语言。

在scheme中,有时您可以编写实际上是haskell的代码(您可以编写由haskell编译器生成的单子代码作为中间语言)。在这种情况下,递归的方式也不同于“通常”的方式,等等。

【讨论】:

很高兴在这里看到另一个 cps 推荐。并向小阴谋家致敬:D【参考方案4】:

带累加器的尾递归

遍历列表两次 构造两个列表 恒定的堆栈空间 会因 malloc 错误而崩溃

朴素递归

遍历列表两次(一次建立堆栈,一次拆除堆栈)。 构造一个列表 线性堆栈空间 可能因堆栈溢出(不太可能在球拍中)或 malloc 错误而崩溃

在我看来,第一个解决方案总是更好

分配通常比额外的堆栈帧更耗时,因此我认为后一种会更快(不过,您必须对其进行基准测试才能确定)。

是否存在未优化递归更好的情况?

是的,如果你正在创建一个惰性求值结构,在 haskell 中,你需要 cons-cell 作为求值边界,你不能惰性求值尾递归调用。


基准测试是确定的唯一方法,球拍具有深堆栈帧,因此您应该能够摆脱这两个版本。

stdlib 版本是quite horrific,这表明如果你愿意牺牲可读性,通常可以挤出一些性能。

给定相同函数的两个实现,使用相同的 O 表示法,我会在 95% 的情况下选择更简单的版本。

【讨论】:

哪种方案实现在高度依赖于实现的方面表现最佳。例如。拥有一个低于一百万个元素的中等大小的列表,只需制作一个需要 trmc、深堆栈、反向或深嵌套延续的副本。 Best of 10 表明,虽然 trmc 在大多数情况下表现最好,因为它没有随着时间和内存增长,但第二好的是不可能提前猜到的。 看来我的电脑上的累加器版本还是比较快的。给定一个该死的慢速 win32 机器和一个简单的 add1 函数,累加器快 2-4 倍(没有 gc 的 cpu 时间),仅 gc 时间(在 1-4 百万个整数列表上)快 4-8 倍。令人困惑的是,普通地图比累加器快 1.2-1.5 倍。但是,正如您所指出的,它是使用简单的递归实现的。也许这是 let 表单如何与递归一起工作的问题?谢谢你的回答!

以上是关于遍历列表时反转列表与非尾递归的主要内容,如果未能解决你的问题,请参考以下文章

Koltin 递归尾递归和记忆化

递归与非递归的转换

JavaScript 深度遍历对象的两种方式,递归与非递归

递归优化的斐波那契数列

方程 Tn=n∑k=1 =k 的尾递归函数

C++实现二叉树 前中后序遍历(递归与非递归)非递归实现过程最简洁版本