【问题标题】:Rewrite apply function to use recursion instead重写应用函数以使用递归代替
【发布时间】:2013-11-25 08:41:10
【问题描述】:

学习 lisp 最难的部分可能是以“lisp 方式”进行思考,这种方式优雅而令人印象深刻,但并不总是那么容易。我知道递归被用来解决很多问题,我正在研究一本书,而不是使用apply 来解决很多问题,我知道这不像lispy,也不那么便携。

一个有经验的 lisper 应该能够在不知道 describe-path locationedges 所指的具体内容的情况下帮助解决这个逻辑。这是我正在阅读的书中的一个示例:

(defun describe-paths (location edges)
  (apply (function append) (mapcar #'describe-path
               (cdr (assoc location edges)))))

我已成功重写此代码以避免apply 并改用递归。它似乎正在工作:

(defun describe-paths-recursive (location edges)
  (labels ((processx-edge (edge)
         (if (null edge)
         nil
         (append (describe-path (first edge))
             (processx-edge (rest edge))))))
    (processx-edge (cdr (assoc location edges)))))

我希望有更多经验丰富的人来建议是否有更优雅的方式将apply 转换为递归,或者我是否做了一些不明智的事情。这段代码看起来不错,但会有更“口齿不清”的东西吗?

【问题讨论】:

  • 如果你想要代码审查,有codereview.stackexchange.com。 Stackoverflow 是针对实际实际问题的。
  • 更多“Lispy”是为了避免堆栈溢出。
  • @RainerJoswig 谢谢,虽然我认为这很实用。诚然,我是 lisp 新手,stackoverflow 的这个 lisp 领域的文化似乎与我在这里工作过的其他语言非常不同。这将是一个受欢迎的问题,在 C++ 等领域得到了很多答案
  • 原始版本是递归的。它在对 MAPCAR 的调用中调用自己。
  • @ThomasBartscher 不,它没有。仔细看,功能不一样。

标签: lisp common-lisp


【解决方案1】:

(apply (function append) (mapcar #'g ...)) 只是mapcan更新: with usual caveats 关于破坏性更新和引用列表,另请参阅this):

(defun describe-paths (location edges)
  (mapcan #'describe-path
               (cdr (assoc location edges))))

递归有助于思考和理解。但实际上在你的代码中使用它是有代价的。

你的递归重写是tail recursive modulo cons;没有 Lisp 有这种优化 AFAIK,即使 it was first described in 1974在 Lisp 中

所以你写的作为一个可执行规范是很好的。

但是 Common Lisp 是一门实用的语言。特别是,它有许多编码迭代的方法。请记住,迭代过程是我们的目标;递归过程在效率方面很糟糕。因此,当我们编写语法递归的代码时,我们仍然希望它描述一个迭代过程(例如在恒定堆栈空间中运行)。

Common Lisp 作为一种实用语言,我们只需直接写出循环即可。一方面,

(defun describe-paths-loop (location edges &aux (res (list 1)) (p res))
  (dolist (x (cdr (assoc location edges)) 
             (cdr res))                   ; the return form
    (setf (cdr p) (describe-path x))
    (setf p (last p))))

保证在恒定的堆栈空间中工作。

更新: 这会破坏性地连接 describe-path 返回的列表,因此应注意不要返回具有相同 last cons 单元格的列表单独调用,或者这可能会创建循环结构。或者,对describe-path 的调用可以包含在copy-list 调用中。当然,如果describe-path返回一个已经循环的列表,这里的last也会进入循环。

【讨论】:

  • 如果不是这样,我会感到非常惊讶,因为它是一个迭代构造,并且表单在标记体中执行,但我看不到 the documentation 的哪个位置 dolist 保证在恒定的堆栈空间中工作。例如,this 可能是dolist 的实现(我认为),但它仍然是递归的,可能不会使用常量堆栈空间。
  • 抛开可能的警告,+1 因为这是唯一提到名副其实的mapcan的答案!
  • 也就是说,如果我发现一个没有使它成为适当的迭代构造的实现,我会提交一份错误报告。 (如果它有 TCO,它可能没问题。)
  • @JoshuaTaylor “名副其实”mapcan?还是“尊贵”? :)
  • @Kaz hm... 两者都是真的,我想 :)
【解决方案2】:

我看到了一些关于使用apply 的意见是一种不好的风格。但实际上,如果有人能向我解释为什么 apply 被认为是不好的,那就太好了。

你用“lispy”这个词是什么意思。 Common lisp 允许以您想要的任何风格进行编程。

如果“lispy”意味着函数式编程风格,那么第一个代码是用更函数式编程风格编写的。一个函数被传递给函数mapcar,另一个函数被传递给apply,所有的工作都是通过将一个函数的结果传递给另一个函数来完成的。在您的代码中,您不会将函数作为参数传递给其他函数。但是递归可以被认为是函数式编程风格的标志。而且书中的代码比你的短。

如果你不喜欢apply,因为apply决定了运行时的参数计数,你可以在这种情况下使用reduce(如果我理解数据结构正确的话): (感谢 Joshua Taylor 在没有 :from-end t 关键参数的情况下指出巨大的资源开销)

(defun describe-paths (location edges)
  (reduce #'append (mapcar #'describe-path
            (rest (assoc location edges))) :from-end t))

无论如何,我很确定书中代码的目的是教育原因。这是mapcarapply 的示例,显示了在lisp 中如何将列表视为数据和代码。

附言实际上我想知道为什么apply 可能不好(堆栈用于函数调用)。

> (apply #'+ (make-list 500000 :initial-element 1))
*** - Lisp stack overflow. RESET

正如 Rainer Joswig 所说,避免堆栈溢出是 lispy。减少修复问题。

> (reduce #'+ (make-list 50000000 :initial-element 1))
50000000

【讨论】:

  • 这也是一个不错的方法,谢谢提及。基本上 apply 的问题是你最终会达到调用参数的限制。递归和使用 reduce 解决了这个问题。
  • @OpenLearner,只有在尾调用递归优化(或其他类似优化)可用时,递归才能解决此问题。否则你会溢出你的堆栈。您的示例没有尾递归优化,因此您将在大数据上实现堆栈溢出。
  • 另外,apply 可能对参数数量有限制。在某些实现中,我认为它可能低至数百。因此,对于包含 1000 个或更多元素的列表,在这样的实现中会失败。
  • 哎呀! (reduce 'append ...),如果您不使用 :from-end t,则效率非常,因为它分配和丢弃 很多 不必要的内存。 append 复制除第一个参数之外的所有参数。 (append (append (append x '()) y) z) 复制x 三次,复制y 两次!如果您需要(reduce 'append ...),请务必(reduce 'append ... :from-end),但也要考虑寻找替代方案(但在某些情况下(reduce 'append ... :from-end t) 可能是合适的解决方案)。
  • @JoshuaTaylor 你是 100% 正确的。如果有人不阅读您的评论,我会编辑我的答案。
【解决方案3】:

Lisp 方法是使用函数式、命令式或面向对象的编程(带有或不带有可变状态)来解决问题,或者发明一些其他您认为合适的编程并用宏来表达。在忽略其他方法的同时寻找递归不是 Lisp 的方式;这是任性的 Lisp 学院派的做法。

重写函数最直接的方法:

(defun describe-paths (location edges)
  (apply (function append) (mapcar #'describe-path
               (cdr (assoc location edges)))))

是使用loop。消除 apply 的正确动机是我们期望有许多路径,这可能会超过函数参数数量的限制。

您对apply 所做的只是为append 函数创建一个大参数列表。我们可以使用loop 将任意数量的列表附加到一个大列表中,如下所示:

(defun describe-paths (location edges)
  (loop for path in (cdr (assoc location edges))
        appending (describe-path path))

大概,describe-path 返回一个列表,而您想将它们连接在一起。

loopappending 子句,也可以拼写为append,将is 参数形式的值附加到匿名列表中。当循环终止时,该列表将成为返回值。

如果我们有理由相信described-path 返回的列表在每次调用时都是新分配的,我们可以使用nconcing 来提高性能。

【讨论】:

  • 我听说loop 不是“lisp 方式”,因为许多lisp 纯粹主义者远离loop,因为它的结构与其他语言的风格大相径庭。但很高兴在这里看到它作为一个例子。
  • 我们能确定loop ... appending 在时间上不是二次的吗? (不像nconcing)。
  • @WillNess 循环规范只说,“append 关键字导致其列表值被连接到一个列表中,就好像它们是函数 append 的参数一样。”您可以macroexpand 循环查看您的实现中发生了什么。顺便说一下,nconcappend 之间的区别不在于时间复杂度。使用nconc 的简单累积仍然是二次的,但会减少内存分配。降低复杂性需要维护一个尾指针,以避免重复在累积列表中搜索尾。这与输入是复制还是修改无关。
  • @Kaz 对,如果我们复制,我们就无法避免二次时间,如果我们不这样做,那么我们可以,正如您指出的那样。谢谢。 :) ... 或者 append 的实现在底层并不简单。
  • @WillNess 复制输入不需要二次时间。这是线性时间。请注意,append 本身,当一次给定所有片段作为参数时,可以在线性时间内工作,并且它可以在内部使用破坏性操作来构建列表。 IE。它不会仅仅通过append 的二进制版本调用reducenconcappend 之间的区别在于我们是将输入列表复制到具有相同 car 字段的新列表结构中,还是我们只是重写它们的尾部 cdr-s。
【解决方案4】:

这个问题没有错;例如,在 python 类别中会提出很多类似的问题。

但是对于您的问题:您所做的是好的。事实上,它与 Peter Norvig 在他的 Lisp 书籍之一中展示的更通用的技术非常相似,所以要么你读过那本书,要么你自己偶然发现了一个好的实践。无论哪种方式,这是一个完全可以接受的递归实现。

【讨论】:

    猜你喜欢
    • 2011-07-16
    • 2021-10-24
    • 2022-01-16
    • 2019-08-24
    • 2013-09-10
    • 2020-09-16
    • 2020-08-02
    • 2019-02-06
    • 1970-01-01
    相关资源
    最近更新 更多