其他分享
首页 > 其他分享> > 86版 MIT SICP lecture 流(一)视频笔记

86版 MIT SICP lecture 流(一)视频笔记

作者:互联网

一、IBM科学计算包中60%的程序本质上内部都会执行的几个流程

视频中,老师提到自己的研究生曾分析过IBM公司的科学计算包的程序代码,其中高达60%的部分其实本质上都在执行以下四个流程:enumerate(枚举)、filter(筛选)、 map (映射)、accumulate(累积)。

课程中老师举了两个过去的课中曾出现过的程序,将树中的奇数叶子结点求平方后求和以及获取斐波那契数列中的奇数项,但按原来的习惯写出的两个程序,虽然我们能分析出代码块中包含上述的四个过程,但这四个过程其实是相互混合,彼此不分离地体现在代码中的,因此老师讲:“我们需要创建一种新的编程语言。”在这种新的编程语言下,基本的数据结构由cons“序对”变为了stream“流”。

二、流模式编程

嗯……其实课堂上老师管这种新的数据结构命的名叫“cons-stream”因为表面上来看,它和原来的cons太像了(但其实本质上有着极为关键的不同),下面是它的构造函数和选择函数:

(CONS-STREAM x y)
(HEAD s)
(TAIL s)

其中CONS-STREAM我们现在可以将它看做cons,HEAD可以看做car,TAIL可以看做cdr(只是暂时看做,本质上有关键的不同)。

那在有了stream“流”之后,我们要定义出map-stream、filter、accumulate三个标准过程:

(define (map-stream proc s)
  (if (empty-stream? s)
      the-empty-stream
      (cons-stream
       (proc (head s))
       (map-stream proc (tail s)))))
(define (filter pred s)
  (cond 
   ((empty-stream? s) the-empty-stream)
   ((pred (head s))
    (cons-stream (head s)
                 (filter pred
                         (tail s))))
   (else (filter pred (tail s)))))
(define (accumulate combiner init-val s)
  (if (empty-stream? s)
      init-val
      (combiner (head s)
                (accumulate combiner
                            init-val
                            (tail s)))))

以及枚举函数,枚举一棵树的叶子结点:

(define (enumerate-tree tree)
  (if (leaf-node? tree)
       (cons-stream tree
                    the-empty-stream)
      (append-streams
       (enumerate-tree
        (left-branch tree))
       (enumerate-tree
        (right-branch tree)))))

(define (append-streams s1 s2)
  (if (empty-stream? s1)
      s2
      (cons-stream
       (head s1)
       (append-streams (tail s1)
                       s2))))

枚举一串数:

(define (enum-interval low high)
  (if (> low high)
       the-empty-stream
       (cons-stream
        low
        (enum-interval (+ 1 low) high))))

有了上述过程后,获取奇树叶平方和可以这么写:

(define (sum-odd-squares tree)
  (accumulate
   +
   0
   (map
    square
    (filter odd
            (enumerate-tree tree)))))

获取斐波那契数列奇数项可以这么写:

(define (odd-fibs n)
  (accumulate
   cons
   '()
   (filter
    odd
    (map fib (enum-interval 1 n)))))

可以看到两个过程形式上已经高度统一,四种基本过程在之中非常清晰地写明了。

三、用回溯算法解决会更高效的问题如果用全部枚举的暴力解法解决会更容易

老师课上举了经典的“八皇后”问题,这常常是回溯算法中会举的经典例子。回溯算法简单来描述就是:

1.从一结点出发,查找其下属分支结点中的第一个结点,如果新结点不满足条件,则删除此路径并回溯回上一节点,查找上一节点所属的另一分支上的结点:
在这里插入图片描述

在这里插入图片描述
2.如果新的结点满足条件,则依次判断新结点的下属分支结点:
在这里插入图片描述
3.如果都不满足条件,则一路回溯到上上个结点,检查上上个结点下属的还未被检查过的结点:
在这里插入图片描述
在这里插入图片描述
4.直到找到符合第一层到最后一层每一层上的结点都满足我们的给定条件的能走通的一条路径。

(是不是还是应该把每一层的结点都先画好地来讲比较好?上面这个讲法应该还是体现了回溯算法的基本思路的吧,下面放一张网上找到的别人的图吧)

在这里插入图片描述
之所以要采用回溯算法,是因为从代码逻辑层面分析,使用这种方法这样会更高效。但这种高效的办法提高了对编程技巧的要求,降低了开发效率。如果我们不用这种回溯算法,用某种更为暴力的方法解决“八皇后”问题呢?以下是老师课中给出的解法:

(define (queens size)
  (define (fill-cols k)
    (if
     (= k 0)
      (singleton empty-board)    ;如果是第0行,返回一个空棋盘
      (collect ...)))            ;不是第0行,执行判断过程
  (fill-cols size)

(collect  ;收集定义的某一元素空间中所有满足给定条件的元素的语法糖,是一个filter和map的结合体。
 (adjoin-position try-row
                  k
                  rest-queens)    ;把行数k和对第k行所尝试的列加入到对第k-1行的八皇后问题的解法表中。
 ((rest-queens (fill-cols (- 1 k)))  
  (try-row (enum-interval 1 size)))  ;枚举所有列下标,以获取一行中的所有元素的列下标。(枚举)

  ;在对第k-1行成立的每一种解法,我们以如下条件筛选新的第k行中的元素:
  ;“第一,第k行的元素不能和该种对k-1行成立的解法中出现过的任何元素的列下标相同”
  ;“第二,假设该种解法中出现过一元素行下标为m,列下标为n,第k行的元素的列下标不能等于n+(k-m)或n-(k-m),即第k行中的元素不能同该种第k-1行成立的解法中的任何元素位于同一斜线上。”
  ;若第k行的某一元素满足了以上两个条件,我们就能生成出一种对第k行成立的解法表。
  ;所有新的解法表结合到一起就是对k行“八皇后”问题的解
 (safe? try-row k rest-queens))  ;(筛选)

简单地说,假如我们已有了对n-1行问题的所有解法,我们可以将这对于n-1行的每一种解法和第n行种的每个元素先做匹配,枚举出所有在对n-1行成立的解法棋盘上再在第n行放新棋子的可能情况,然后将所有情况通过筛选器筛选出符合题目条件的所有对n行问题的解法:
在这里插入图片描述
相比较回溯算法,上面这种解法相对更容易想到与更容易实现。但是,我们也很容易发现这种方法的问题:对存储空间的浪费,效率低下。那接下来,着堂课上最精彩的部分就要来了,如何通过设计我们语言中的数据结构,让形式上为递归枚举的代码在机器中实际上是正向迭代逐数据进行的。

四、最精妙部分:如何通过设计数据结构让形式上递归枚举的代码在机器中实际上是正向迭代执行的

为了方便讲解,老师课上举了一个简单的例子,对从10000到1000000的这么多数据,我们如何获取其中的第二个质数。其实按照我以前的习惯,我想我可能很容易写出这样的代码:从头开始逐个判断每一个数是否为质数,设置一个标志位用来记录质数的个数,如果某个数是质数,标志位加一,当标志位为2时,拿出对应的那个质数,程序终止。事实上这样的代码虽然能够实现功能,但它其实是“丑陋”的,我们如何用map,accumulate,enum,filter等基本结构写出简洁的,每次对数据进行的处理都很明晰的、“优雅的”代码?如下便是老师课上所给出的:

(head
  (tail (filter
         prime?
         (enum-interval 10000 1000000))))

但依然还是那个问题,这样写虽然简洁优雅,这样编程似乎也更为容易,但这样的算法岂不是无故浪费了许多存储空间,且执行效率低下吗?

如果真的如代码形式所展现的这样运行,那么确实存在这样的问题。

但实际上这个代码在我们的语言中,运行在机器内部时却并非先枚举出从一万到一百万的所有数->筛选出质数表->tail->head这样进行的,而是对每一个数字来依次判断。

那这件事是怎么实现的呢?还记得咱们之前强调过CONS-STREAM,HEAD,TAIL只是形式上分别像cons,car,cdr,但其实本质上有很大的不同吗?其实这三者的本质如下:

(CONS-STREAM x y) --> (cons x (DELAY Y))
(HEAD s).--> (car s)
(TAIL s) --> (FORCE (CDR s))

其中DELAY包含有其所在过程的过程名,FORCE包含有一个“promise”信号,执行到DELAY时,程序会暂停直到获取到promise信号,DELAY获取到promise信号后,会启动执行其内部包含的过程名。结合这两点特性,上述获取10000到1000000的所有数据中的第二个质数在机器中实际的执行过程其实像下面这样:
在这里插入图片描述

先是一个枚举10000到1000000的过程,那枚举内部会包含一个CONS-STREAM,而CONS-STREAM内部包含一个DELAY,因此程序到此会暂停,并向外部去到filter过程,因为CONS-STREAM中的HEAD是10000,而10000不是质数,因此程序会执行filter(TAIL s),那TAIL中包含有FORCE,因而会启动CONS-STREAM过程中的DELAY所包含的枚举过程……重复上述过程,程序就实现了虽然代码形式上是要先枚举出所有10000到1000000的所有数,再筛选出其中的所有质数,但在机器中却是让所有数据排着队流过一遍枚举器和筛选器的。

DELAY和FORCE是如何实现的?

其实很简单:

DELAY 即 (lambda () <exp>) ;<exp>也就是所在过程的过程名
(FORCE P) 即 (P)

(lambda () <‘exp>)这个无参过程在没有接受到参数前不会执行,直到它接受到TAIL中传过来的参数,才会执行exp。

五、添加MEMO-RROC

这部分我感觉我多少还有点似懂非懂,先把过程实现记下,回头再好好看看。

老师讲,现在的代码版本,若是跟踪过程执行结果,会发现有很多嵌套的TAIL,需要给DELAY的实现(lambda () <‘exp>)外面包上一层MEMO-PROC对已执行过程直接返回结果,下面是MEMO-PROC的实现:

 (define (memo-proc proc)
   (let ((already-run? nil) (result nil))
     (lambda ()
       (if (not already-run?)
           (sequence
            (set! result (proc))
            (set! already-run? (not nil))
            result)
           result))))

[唯一编程神课SICP]计算机程序的构造和解释(1986版) MIT 6.001

标签:结点,cons,stream,SICP,filter,枚举,lecture,86,解法
来源: https://blog.csdn.net/qq_43071318/article/details/117093486