经验大分享:SICP学习笔记(1.2.1~1.2.2)

简介: 经验大分享:SICP学习笔记(1.2.1~1.2.2)

SICP学习笔记(1.2.1 ~ 1.2.2)

周银辉

1, 递归过程 和 递归计算过程

在学习SICP前我还没注意过有这样的一个区分,因为我们始终停留在语法表面上的“递归过程(recursive procedure)”,而没有去理解其实质是否是“递归计算过程(recursive process)”。

递归过程:从语法书写层面上而言的,在过程的定义中,其直接或间接地引用该过程本身。 比如 F{F}或者 F{G{F}}

递归计算过程:从实际运算层面上而言的,一个在语法上按照递归过程书写的函数其在运算时可能是按照递归的方式也可能是按照迭代的方式进行的。这取决于解释器或编译器本身是否有对“递归”进行优化,比如Scheme解释器是“严格尾递归”的, 而C#之类的,即便在语法形式上是"尾递归"的,但其仍然不能被编译成迭代计算过程,当然,你可以使用for,while等.

要检测出一个递归过程在计算时到底是按照递归方式还是按照迭代方式进行的,非常容易,只要将其进行深度递归或无限次递归,如果Stack Overflow了,那么是按照递归方式计算的,仅仅是一个死循环似的不停计算但没有栈溢出,那么是按照迭代方式计算的。一般而言函数式编程语言(Scheme,Haskell等), 会按照迭代方式进行计算;命令式编程语言(C++,C#,Java等)会按照递归方式进行计算。

2,迭代计算过程 和 递归计算过程

根据SICP中所言,迭代计算过程就是那种其状态数目可以用固定数目的状态变量来描述的过程。要理解这个非常简单,我们可以想象一下一个 for 循环 for (int i=0; i<10; i++) {} 伴随做计算的进行(不停的迭代),这个循环的状态在不停地发生变化,但这个变化仅仅需要常量数目个状态变量就可以描述和记录了,这里是一个状态变量 i ;另外,如果这个迭代计算被中断了,要从中断中恢复运算,仅需提供 i 值就可以了。这同时也说明,该迭代过程所需要的空间是一定的,其并不会随着迭代的进行而增加。

相反的,递归计算过程,其在空间的需求上会随着递归的深入而不断增加,因为我们需要在进入地N+1次递归前保存第N次迭代的相关数据,以便最后一次递归结束后能回退到先前的递归上来,如果这样的数据保存在栈上,当栈快被“使用完毕”时还没有结束递归的话,便会导致“栈溢出”。

3,树形递归

如果我们将一次“函数调用”看做是树的一个节点的话(注意,如果要对应到语言上的话,我这里说的是函数式编程语言,在这类语言里面不关心所谓的操作数,操作符,标识符等等,它们都是函数),那么我们就可以将整个表达式描述成一棵树的形式。对于递归函数而言,如果每个节点顶多有一颗子树,很明显,该递归是线性的;同理,如果树是按照“二叉树”的形式展开的,那么我们说这是“双递归”,如果有多颗子树则是普通的树形递归。(我猜想,这同样可以在语法层面和实际计算层面来划分,所以可以分别看待“树形递归过程” 和“树形计算过程”

4, 尾递归

比较这两个递归函数:

F()

{

// do something here

A;

F();

}

G()

{

// do something here

G();

A;

}

我们说函数 F 是尾递归的,而函数 G 不是。(为表示方便,我将跳出递归的条件省略了,不要关心是否会无穷地递归)

所谓尾递归,就是将“递归”调用这件事放到函数体的最后来执行。函数 F 相比于 G 一个很大的优势在于,当 F 即将进入下一次递归时,其不需要分配空间其保存本次计算的相关数据(轨迹),因为它不需要回退了(没事可做,还回来干嘛);而函数 G 则不同,其在进入下一次递归时,必须保存本次计算的相关数据,因为当它执行完下一次递归计算后,还得回来计算表达式 A 。(这类似于后序遍历,当遍历完子节点后还得回来完成对根节点的遍历)

尾递归的好处很明显,其可以节省空间(并防止栈溢出),因为我们可以将其计算过程转化成线性的。正如上面在讲“迭代计算过程 和 递归计算过程 “ 时所言,”迭代计算过程就是那种其状态数目可以用固定数目的状态变量来描述的过程“。由于尾递归的函数不需要保存本次递归的相关轨迹,所以我们可以在进入新的一次迭代时将上一次迭代的堆栈空间重新利用起来,而不必占用新的堆栈空间,这也就是为什么尾递归不会堆栈溢出了。说出来比较抽象,看看下面的代码或许能有所提示:

myLabel : F()

{

// do something here

A;

goto myLabel;

}

很不幸的是,C# 编译器并没支持尾递归,虽然 MSIL 中有一个所谓的 tail. 前缀(用于修饰call, callvirt, calli),但我们平时使用的C#编译器并不会在编译而成的代码中使用这个前缀,原因好像是说其”效率超低“, 感兴趣的看看这里: http://connect.microsoft.com/VisualStudio/feedback/ViewFeedback.aspx?FeedbackID=98236 以及 http://blogs.msdn.com/shrib/archive/2005/01/25/360370.aspx

将 “递归” 转换 为“尾递归” :

对于普通的递归转尾递归,需要引入一个概念:accumulator parameter (如何翻译?累加器参数/累积参数? 学过 F# 的同学可以指导下, F#中貌似有这个概念),该类参数负责累积每次递归产生的结果值并以参数的形式传入到下次递归中。

我们看阶乘的递归形式:

(define (F n)

(if (= n 1)

1

( n (F (- n 1)))))

转换成尾递归形式:

(define (F n)

(G 1 1 n))

(define (G a i n)

(if (> i n)

a

(G ( i a) (+ i 1) n)))

其中的参数 a 就是我们这里所谓的accumulator parameter, 它累积了每次迭代的结果值并将其作为参数传入到下一次迭代中去。如何累积的呢:新的累计值 = i 旧的累计值

5,练习 1.9

比较下面两个过程有啥不同:

(define (+ a b)

(if (= a 0)

b

(inc (+ (dec a) b))))

(define (+ a b)

(if (= a 0)

b

(+ (dec a) (inc b))))

很简单,对于定义的 过程 ”+“ 而言,第一个是普通的线性递归过程,其计算过程也是递归的; 第二个是尾递归,其计算过程会被Scheme解释器解释成迭代的。

6, 练习1.10

对Ackermann函数求值

在班车上用草稿纸加铅笔计算的,所以这里就没给出程序了

(A 1 10) = 2^10 (A 2 4) = 2^16 (A 3 3) = 2^16

(define (f n) (A 0 n)) 表示 2n

(define (g n) (A 1 n)) 表示 2^n

(define (h n) (A 2 n)) 表示 2^2^2^... (n 个2)

7,换零钱问题

这个题目非常有意思。注意到下面这句话:

those that do not use any of the first kind of coin, and those that do. Therefore, the total number of ways to make change for some amount is equal to the number of ways to make change for the amount without using any of the first kind of coin, plus the number of ways to make change assuming that we do use the first kind of coin. But the latter number is equal to the number of ways to make change for the amount that remains after using a coin of the first kind。

换零钱的全部方式数,等于完全不使用第一种钱币的方式数目,加上使用了第一种钱币的方式数 (这比较容易理解,就相当于在说,这个世界上的人口数目,等于我不认识的人的总数,加上我认识的人的总数), 而后一个数目等于去掉一个第一种硬币值后剩下的现金数的换钱方式数(这很明显,因为使用了第一种钱币(假设为a)的换钱方式中,每种方式都包含a,所以可以将a减去)

将其翻译成 C# 代码如下:

private static readonly int【】 FirstDenomination = new【】 { 50, 10, 1, 5, 25 };

public static int CountChange(int amount)

{

return CountChange(amount, 5);

}

public static int CountChange(int amount, int kindsOfCoins)

{

if (amount == 0)

{

return 1;

}

if (amount [/span> 0 || kindsOfCoins == 0)

{

return 0;

}

//代码效果参考:http://www.zidongmutanji.com/bxxx/45106.html

return

CountChange(amount, kindsOfCoins - 1) +

CountChange(amount - FirstDenomination【kindsOfCoins - 1】, kindsOfCoins);

}

注意,SICP上的:

(define (first-denomination kinds-of-coins)

(cond ((= kinds-of-coins 1) 1)

((= kinds-of-coins 2) 5)

((= kinds-of-coins 3) 10)

((= kinds-of-coins 4) 25)

((= kinds-of-coins 5) 50)))

实际上相当于定义一个枚举或者数组,所以我在 C# 代码中将其翻译成了 int【】 FirstDenomination = new【】 { 50, 10, 1, 5, 25 }; 其中的50,25 等代表的是每种钱币的面值(统一成了美分)。

如果我们计算1美元(100美分)的话,将得到如下结果:

这是一个树形递归,计算步骤多么恐怖呀,递归调用了32459次, 这是一个非常痛苦的过程, 并且非常可惜的是没有简单并统一的方法将这样的树形递归转换成迭代形式。但对于不同的问题已有一些特定的解决方案来进行优化,比如对于大多数的树形递归问题,我们可以使用“动态规划”的方式来进行运算以减少运算量。关于动态规划可以参考这里: 或 这里 , 或者以后我会写点东西专门来说说动态规划与树形递归。

8,练习1.11

A function f is defined by the rule that f(n) = n if n 3. Write a procedure that computes f by means of a recursive process. Write a procedure that computes f by means of an iterative process.

递归的方式:

(define (F n)

(if (< n 3)

n

(+ (F (- n 1)) ( 2 (F(- n 2))) ( 3 (F (- n 3))))))

尾递归的方式:

(define (G a b c count)

(cond ((= count 0) c)

((= count 1) b)

(else (G (+ a ( 2 b) ( 3 c)) a b (- count 1)))))

(define (F n)

(G 2 1 0 n))

根据上文讲过的 accumulator parameter 的概念 : 从这里+ (F (- n 1)) ( 2 (F(- n 2))) ( 3 (F (- n 3))))) 可以看出 , 新的累积值 = 旧的累计值a + 2 另外一个旧的累积值b + 3 * 另外一个更旧的累积值 , 所以 累积值 = a + 2b + 3c

9,练习 1.12

帕斯卡三角(杨辉三角),这个问题上中学时老师带着我们找个规律,蛮简单的:对于第 n 层的第 i 个数F(n, i),如果 n<=1 或者 i=0 或者 i=n 那么为 1,否则为 F(n-1, i-1)+F(n-1, i)

10, 练习1.13

利用数学归纳法比较容易证明的,另外原文没有说清楚的是: 其中, γ=1-? ,更多的,看看 这里 :

相关文章
|
2月前
|
敏捷开发 程序员 测试技术
代码之禅:技术感悟与实践之路
【5月更文挑战第29天】在编程世界里,每一行代码都如同禅宗中的一句偈语,蕴含着深邃的智慧与哲思。本文旨在通过个人的技术实践和感悟,探讨如何在日复一日的代码编写中,寻找到提升效率和质量的路径。从对编程语言的深入理解,到开发流程的优化,再到团队合作与沟通的艺术,文章尝试描绘出一幅程序员修行的蓝图,为追求卓越的技术人员提供灵感与指导。
|
2月前
|
机器学习/深度学习 IDE 数据挖掘
如何系统地自学python?
如何系统地自学python?
30 1
|
3天前
|
算法 开发者
代码之美:技术感悟与编程艺术
【6月更文挑战第28天】在数字世界的构建中,代码不仅仅是冷冰冰的指令集合,更是开发者智慧与情感的结晶。本文将深入探讨编程背后的艺术性,揭示如何通过技术感悟提升代码质量,以及在日复一日的编码实践中如何保持创新与热情。
|
8天前
|
算法 开发者
代码之美:我的编程之旅与技术感悟
【6月更文挑战第23天】编程不仅是技术的实践,更是艺术的创造。本文将通过个人经历,探讨如何从初学者成长为一名有洞察力的开发者,并分享在编程旅途中的技术感悟。我们将一起探索编程的本质、学习过程中的挑战与乐趣,以及如何培养解决问题的能力,最终达到技术与创造力的融合。
|
5天前
|
机器学习/深度学习 人工智能 Java
经验大分享:SICP学习笔记(1.2.1~1.2.2)
经验大分享:SICP学习笔记(1.2.1~1.2.2)
|
21天前
|
算法 程序员
探索代码之美:技术感悟与实践
【6月更文挑战第10天】在编程的海洋中,我们都是探险者。本文将分享我在编程旅程中的一些技术感悟,包括如何理解代码之美、如何提高编程效率以及如何保持对技术的热爱。通过这些感悟,我们可以更好地理解编程的本质,提高我们的技术水平,并享受编程带来的乐趣。
13 3
|
2月前
|
算法
技术感悟:代码之美
在当今数字化时代,技术的发展日新月异,而程序设计作为其中的重要一环,更是呈现出无限的魅力。本文通过对代码之美的深入思考,探讨了程序设计背后的艺术和哲学,以及技术在人类生活中的重要性和影响。
23 0
|
敏捷开发 架构师 数据建模
|
存储 算法 搜索推荐
程序员学Python算法编程中常见的问题和算法
  一些著名问题与算法   如果您的飞船破了一个洞,我只能深表同情,因为我所解决的99个问题里唯独没有这个问题。   ——匿名者1   本文提到的所有问题与算法,因为有一些算法仅仅是为了试图说明某个原理,而有一些问题仅仅是为了某个算法而创造的。然而,作为索引,这里会列举出学习中最重要的那些问题与算法。   在本文大多数描述中,n代表的是问题规模,如一个序列中的元素数量。而在图论问题中,n表示的是节点的数量,m则表示边的数量。
184 0
|
存储 Linux 程序员
《程序员的自我修养》第三章学习笔记
1,  编译器编译源代码生成的文件叫做目标文件。 从结构上说,是编译后的可执行文件,只不过还没有经过链接   3.1 目标文件的格式 1,可执行文件的格式: Windows下的PE  和   Linux下的ELF 2,从广义上说,目标文件与可执行文件的格式几乎是一样的,所以广义上可以将目标文件与可执行文件看成是一种类型的文件。
1095 0