【问题标题】:Space complexity of streams in SchemeScheme中流的空间复杂度
【发布时间】:2020-05-11 18:39:15
【问题描述】:

我正在阅读计算机程序的结构和解释 (SICP),并希望确保我的想法是正确的。

考虑以下使用递归定义的简单流:

(define (integers-starting-from n)
    (cons-stream n (integers-starting-from (+ n 1))))

(define ints (integers-starting-from 1))

(car (cdr-stream (cdr-stream (cdr-stream (cdr-stream ints)))))

如果我们采用 SICP 中的实现,每当我们 cons-stream 时,我们实际上是在使用一个变量和一个 lambda 函数(用于延迟评估)。因此,当我们沿着这个流cdr-stream 时,嵌套的 lambda 函数被创建并存储了一个帧链,用于评估 lambda 函数。这些框架是必要的,因为 lambda 函数评估表达式并在封闭框架中找到它们。因此,我认为为了评估流的第 n 个元素,您需要存储 n 个额外的占用线性空间的帧。

这与其他语言中迭代器的行为不同。如果您需要深入下游,将占用大量空间。当然,也可以只保留直接封闭的框架而丢弃所有其他的祖先框架。这是实际方案实现的作用吗?

【问题讨论】:

    标签: functional-programming scheme lisp sicp lazy-sequences


    【解决方案1】:

    “嵌套的 lambda 函数被创建”

    不。有 no 嵌套范围。在

    (define integers-starting-from 
      (lambda (n)
        (cons-stream n (integers-starting-from (+ n 1)))))
    

    (integers-starting-from (+ n 1)) 形式中对integers-starting-from 的嵌套调用的参数,表达式(+ n 1),是指在对(integers-starting-from n) 的原始调用中n 的绑定,但是(+ n 1) 在调用之前进行评估

    Scheme 是一种渴望的编程语言,而不是一种懒惰的

    因此,cons-stream 的结果中的 lambda 包含对调用框架的引用,是的,但是没有环境嵌套。在创建新的 lambda 之前已经获得了该值,并将其作为下一个 cons 单元格的一部分返回,该单元格表示流的下一个状态。

    (define ints (integers-starting-from 1))
    =
    (define ints (let ((n 1))
        (cons-stream n (integers-starting-from (+ n 1)))))
    =
    (define ints (let ((n 1))
        (cons n (lambda () (integers-starting-from (+ n 1))))))
    

    通话继续

    (car (cdr-stream (cdr-stream ints)))
    =
    (let* ((ints         (let ((n 1))
                           (cons n 
                              (lambda () (integers-starting-from (+ n 1))))))
           (cdr-ints     ((cdr ints)))
           (cdr-cdr-ints ((cdr cdr-ints)))
           (res          (car cdr-cdr-ints)))
      res)
    =
    (let* ((ints         (let ((n 1))
                           (cons n 
                              (lambda () (integers-starting-from (+ n 1))))))
           (cdr-ints     ((cdr ints))
                         =
                         ((let ((n 1))
                             (lambda () (integers-starting-from (+ n 1)))))
                         =
                         (integers-starting-from 2)   ;; args before calls!
                         =
                         (let ((n 2))
                           (cons n 
                              (lambda () (integers-starting-from (+ n 1)))))
              )
           (cdr-cdr-ints ((cdr cdr-ints)))
           (res          (car cdr-cdr-ints)))
      res)
    =
    (let* ((ints         (let ((n 1))
                           (cons n 
                              (lambda () (integers-starting-from (+ n 1))))))
           (cdr-ints     (let ((n 2))
                           (cons n 
                              (lambda () (integers-starting-from (+ n 1))))))
           (cdr-cdr-ints (let ((n 3))
                           (cons n 
                              (lambda () (integers-starting-from (+ n 1))))))
           (res          (car cdr-cdr-ints)))
      res)
    =
    3
    

    所以这里没有嵌套的 lambda。甚至连 lambda 链都没有,因为实现是非记忆的。 cdr-intscdr-cdr-ints 的值是短暂的,在计算第三个元素时很容易被垃圾收集。没有任何东西可以参考它们。

    因此获得第 n 个元素是在 在常数空间模垃圾中完成的,因为所有中间 O(n) 空间 实体都符合条件被垃圾收集。

    在 (one possible) 记忆实现中,每个 lambda 实际上将被其结果替换 cons 单元格,并且会有一个三个链 - 仍然没有-nested -- lambdas,与开放式列表一致

    (1 . (2 . (3 . <procedure-to-go-next>)))
    

    在不保留此类链顶部条目的程序中,所有临时 conses 也将有资格进行垃圾收集。

    the sieve of Eratosthenes 就是一个这样的例子,即使是非记忆 SICP 流。它的性能特征与其内部流的前缀部分没有内存保留是一致的。

    【讨论】:

    • cons-stream 是一种特殊形式。 (cons-stream a b) 等价于 (cons a (delay b))。
    • 是的,这就是我正在使用的。我还把(delay p) 等同于(lambda () p)。如果 delay 是一个特殊的形式,它会记住它的结果,我们将得到 lambda 链,但仍然没有任何帧的嵌套。
    • here 在 SICP 书中说," Delay 可以是一种特殊形式,因此 (delay ⟨exp⟩)(lambda () ⟨exp⟩)" 的语法糖,这正是我正在使用什么。
    • 谢谢。我想我知道这里发生了什么。之前以为函数不是尾递归的,现在看来确实是尾递归的。
    • tail or no tail,它计算结果值 - cons 单元格 - 并返回它。
    【解决方案2】:

    我认为值得指出的是,在这种情况下分析空间使用情况并不总是那么简单。

    例如,这里是 Racket 中 forcedelay 的一个完全幼稚的实现:

    (define-syntax-rule (delay form)
      (λ () form))
    
    (define (force p)
      (p))
    

    而且我们可以构建足够多的与 SICP 流有点兼容的东西,从而在这方面造成危险:

    (define-syntax-rule (cons-stream kar kdr)
      ;; Both car & cdr can be delayed: why not?  I think the normal thing is
      ;; just to delay the cdr
      (cons (delay kar) (delay kdr)))
    
    (define (stream-car s)
      (force (car s)))
    
    (define (stream-cdr s)
      (force (cdr s)))
    
    (define (stream-nth s n)
      (if (zero? n)
          (stream-car s)
          (stream-nth (stream-cdr s) (- n 1))))
    

    (请注意这里缺少很多,因为我很懒。)

    在此基础上,我们可以构建整数流:

    (define (integers-starting-from n)
      (cons-stream n (integers-starting-from (+ n 1))))
    

    现在我们可以试试这个:

    (define naturals (integers-starting-from 0))
    
    (stream-nth naturals 10000000)
    

    过了一会儿,最后这件事返回10000000。我们可以多次调用它,每次都得到相同的答案。

    但是我们对 Promise 的实现很糟糕:强制一个 Promise 让它工作每次我们强制它,我们想只做一次。相反,我们可以记住我们的承诺,这样就不会发生这种情况(这可能不是线程安全的:可以这样做):

    (define-syntax-rule (delay form)
      (let ([thunk/value (λ () form)]
            [forced? #f])
        (λ ()
          (if forced?
              thunk/value
              (let ([value (thunk/value)])
                (set! thunk/value value)
                (set! forced? #t)
                value)))))
    

    其余的代码都是一样的。

    现在,当您致电(stream-nth naturals 10000000) 时,您可能会遇到相当糟糕的情况:尤其是您可能会耗尽内存。

    你会过得不好的原因有两个:

    • naturals 的形式引用整个流;
    • 花哨的 Promise 正在记住它们的值,这是整个流的尾部。

    这意味着,当你沿着流向下走时,你会消耗越来越多的内存,直到你用完:程序的空间复杂度就像最后一行 stream-nth 的参数大小一样。

    这里的问题是delay 试图以一种在这种情况下无益的方式变得聪明。特别是如果您将流视为您通常会遍历一次的对象,那么记住它们是没有用的:您已经仔细记住了一个永远不会再次使用的值。

    Racket memoize 提供的delayforce 的版本,在这种情况下也会占用大量内存。

    您可以避免这种情况,方法是不记忆,或者确保永远不要保留流的开头,以便 GC 可以拾取它。特别是这个程序

    (define (silly-nth-natural n)
      (define naturals (integers-starting-from 0))
      (stream-nth naturals n))
    

    不会使用与n 成比例的空间,因为一旦对stream-nth 进行了第一次尾调用,就不再有任何东西保留流的开头。

    另一种方法是只弱保存记忆值,这样如果系统绝望,它可以丢弃它。这是一个 hacky 且大部分未经测试的实现(这是非常特定于 Racket 的):

    (define-syntax-rule (delay form)
      ;; a version of delay which memoizes weakly
      (let ([thunk (λ () form)]
            [value-box #f])
        (λ ()
          (if value-box
              ;; the promise has been forced
              (let ([value-maybe (weak-box-value value-box value-box)])
                ;; two things that can't be in the box are the thunk
                ;; or the box itself, since we made those ourselves
                (if (eq? value-maybe value-box)
                    ;; the value has been GCd
                    (let ([value (thunk)])
                      (set! value-box (make-weak-box value))
                      value)
                    ;; the value is good
                    value-maybe))
              ;; the promise has not yet been forced
              (let ((value (thunk)))
                (set! value-box (make-weak-box value))
                value)))))
    

    我怀疑大量的weak box可能会让GC做很多工作。

    【讨论】:

    • 记忆化自然会占用内存。就像在 Haskell 中一样,如果你命名它,它会保留(在内存中)。 (stream-nth naturals 10000000) 将在内存中实现整个前缀,但(stream-nth (integers-starting-from 0) 10000000) 不会——尽管必须收集大量垃圾。每个工具都有其权衡。 --- 我不太确定silly-nth-natural 的保证,这取决于编译器。这里有一些 recent Q/A 关于 Chez 代码 IIRC 正是这个问题。 (另见那里的 OP 的 cmets)。
    • 通常流必须被记忆。例如汉明数流的通常/经典定义以三种不同的速度消耗自身。 Python 必须引入一个特殊的复制设备tee(谷歌“python tee hamming”)来克服这个问题,正是因为它的生成器(或者是迭代器?)是非记忆的(所以,再次,权衡)。 (而且这不太对,当它可以只重用流的一个记忆部分时,它会创建三个副本 IIUIC。)(续)
    • 所以对于记忆流,定义必须在内部有头部引用(上面提到的问题)并且编译器可以正确处理,或者我们将不得不重组代码来实现它,如播种在回答我链接。所以,是的,这一切都是不平凡的,可能没有 one-size-fits-all 解决方案。 :)weak-boxes 解决方案依赖于系统首先撞墙,因此它已经无法发挥作用。 -- 重新延迟car,SRFI 重做流是这样进行的,但实际上他们所要做的就是修复take 代码,然后在这里和那里使用额外显式的delay。跨度>
    • 我在 this answer 讨论了在 Scheme 中使用 SICP 流实现汉明数序列。 (用低级语言“自己”做是一个很好的练习。我曾经尝试用手工滚动的“对象”来做它——比如生产者、分离器、乘法器等......很多隐藏的问题都暴露出来了方式。)这一切让我现在开始思考那个“经典”代码是否也存在隐藏的性能问题(在上面那个链接中的问题中看到)!
    • @WillNess:我不认为 SE cmets 是进行扩展讨论的地方,但是是的,我知道这些问题。我的回答只是为了让 OP 也意识到其中一些。我的silly-nth-natural 与您指出的问题并不完全等同,我认为:特别是我的问题是 lambda-lifted,因此编译器不必证明任何关于不可能引用的事情。但是,我认为这不是就此事进行大讨论的合适论坛。
    【解决方案3】:

    简短的回答,是的,在适当的情况下,直接封闭的环境被丢弃。

    我认为(car (cdr-stream (cdr-stream (cdr-stream (... 不会发生这种情况,但如果您改为查看stream-refin sect。 3.5.1:

    (define (stream-ref s n)
      (if (= n 0)
          (stream-car s)
          (stream-ref (stream-cdr s) (- n 1))))
    

    如果你暂时忘记了你对环境框架的了解,但回想第 1 章以及递归与迭代过程的讨论,那么这是一个迭代过程,因为正文的最后一行是对同一函数的回调.

    所以也许您的问题可以重述为:“鉴于我现在对评估环境模型的了解,迭代过程如何使用常量空间?

    正如你所说,这是因为祖先的框架被丢弃了。本书后面的第 5 章将详细介绍这是如何发生的,例如,第 5 节。 4.2 《序列求值与尾递归》,或者喜欢讲座视频,lecture 9b

    第 4 章和第 5 章的重要部分涵盖了明确回答此问题所需的详细信息。或者如作者所说,驱散魔法。

    【讨论】:

    • OP 似乎声称有一个 n 环境帧的嵌套以到达流的第 n 个元素。您的回答似乎与此主张一致。 (?)我在下面的回答中声称这里有 no 嵌套框架。看看能不能说服你。 :)
    • @WillNess 我们有一个形式为 (g (f (f (f (fa))))) 的表达式,不管 f 或 g 的实现,这不会导致嵌套申请和评估的一系列电话?如果是这样,是否每次调用 apply 都会扩展环境(第 4.1.1 节)?
    • 表达式为(car (cdr-stream (cdr-stream (cdr-stream (cdr-stream ints)))))cdr-stream 是一个函数,它被定义为(define (cdr-stream s) (force (cdr s)))。 Scheme 是急切的,在将参数的值传递给函数之前,在当前环境中对参数进行评估。 car 在返回其 car 字段之前也对其参数进行评估。所以表达式的评估是在堆栈的帮助下进行的,(续)
    • (eval expression) = (let* ((s1 (cdr-stream (cdr-stream (cdr-stream (cdr-stream ints)))))) (car s1)) = (let* ((s2 (cdr-stream (cdr-stream (cdr-stream ints)))) (s1 (force (cdr s2))) ) (car s1)) = (let* ((s3 (cdr-stream (cdr-stream ints))) (s2 (force (cdr s3))) (s1 (force (cdr s2))) ) (car s1)) = ...。这里没有环境,只有当前环境。嵌套的调用被分解成一连串的调用链(这里用let* 说明)。这种形式的解开代码称为static single assignment form
    • *(*stack 上的一系列调用)---对于您的具体问题,在评估参数需要函数调用时扩展环境;并且在找到它的值之后,从该函数返回时,不再需要扩展环境,并且不再需要,被销毁,从堆栈中弹出。新环境仅由 let 和朋友创建(包括函数调用),并且仅在创建 lambda 并从该嵌套范围内返回时才保留(作为闭包的一部分)。我曾经解决过提问者here 的类似误解。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2016-02-13
    • 2012-08-14
    • 2013-09-12
    • 2018-08-02
    • 2011-10-31
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多