1.2 过程与它们所产生的计算
一个过程也就是一种模式,它描述了一个计算过程的局部演化方式,描述了一个计算过程中的每一个步骤如何基于前面的步骤建立起来。这一节中,我们要考察一些简单过程所产生计算过程的“形状”,还将研究这些计算过程消耗各种重要计算资源(时间和空间)的速率。
1.2.1 线性的递归和迭代
考虑如何计算一个阶乘函数,有一个很简单的方法:
(define (factorial n)
(if (= 1 n)
1
(* n (factorial (- n 1)))))
我们可以利用代换模型来观察这个过程在计算,例如6!,时表现出的行为。
我们还有另一种不同的观点可以用来计算阶乘。 我们可以通过维护一个变动的乘积product,以及一个从1到n的计数器counter来帮助我们进行计算。代码如下:
(define (factorial n)
(fact-iter 1 1 n))
(define (fact-iter product counter max-count)
(if (> counter max-count)
product
(fact-iter (* counter product)
(+ counter 1)
max-count)))
与前面一样,我们也可以用替换模型来查看6!的计算过程。
现在比较一下这两种计算。从一个角度看,它们并没有多大差异。它们计算的都是阶乘函数,都需要与n正比的步骤数目,甚至采用了同样的乘运算序列。但是如果我们考虑这两个计算的“形状”,就会发现它们的进展方式大不相同。
对于第一个计算过程,代换模型揭示出一种先展开后收缩的形状。在展开阶段里,这一计算过程构造起一个推迟进行的操作形成的链条,收缩阶段表现为这些运算的实际执行。这种类型的计算过程由一个推迟执行的运算链条刻画,我们称为一个递归计算过程。为了执行这种计算过程,解释器需要维护好那些以后要执行的操作的轨迹。在计算阶乘n!时,我们需要保存的信息量随n线性增长,这样的一个递归计算过程称为一个线性递归过程。
与之对应,第二个计算过程里并没有增长和收缩,我们需要保存的所有东西就只有product、counter、max-count,我们称这种过程为一个迭代计算过程。一般来说,迭代过程是状态可以用固定数目的状态变量描述的过程,与此同时,存在着一套固定的规则来描述状态转移时变量的更新方式。还有一个结束检测,来描述这个计算过程的终止条件。计算n!时,所需步骤随n线性增长,我们称这种过程为线性迭代过程。
我们需要注意不要搞混了递归计算过程和递归过程这两个概念。后者描述的是一个语法形式上的事,前者则是在描述计算过程的模式。
练习 1.9
对于第一个过程
(+ 4 5)
(inc (+ 3 5))
(inc (inc (+ 2 5)))
(inc (inc (inc (+ 1 5))))
(inc (inc (inc (inc (+ 0 5)))))
(inc (inc (inc (inc 5))))
(inc (inc (inc 6)))
(inc (inc 7))
(inc 8)
9
对于第二个过程
(+ 4 5)
(+ 3 6)
(+ 2 7)
(+ 1 8)
(+ 0 9)
9
显然第一个计算过程是递归的,第二个计算过程是迭代的。
练习 1.10
显然,A(1,10)=1024,A(2,4)=2^16=65535,A(3,3)=65536
f(n)=2n
g(n)=2^n
h(n)=2^h(n-1), h(1)=2
1.2.2 树形递归
树形递归是另一种常见的计算模式。 考虑一个计算斐波那契数的递归过程:
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1)) (fib (- n 2))))))
考虑这一计算的模式, 容易想象,它将会展开为一棵树。 这个过程作为树形递归有教育一一四,但是它是一种糟糕的计算斐波那契数的方法,因为它作了太多次冗余计算。事实上,它的计算步骤将随n指数增长。
我们也可以规划出一种计算斐波那契数的迭代过程:
(define (fib n)
(fib-iter 1 0 n))
(define (fib-iter a b count)
(if (= count 0)
b
(fib-iter (+ a b) a (- count 1))))
显而易见,这个方法是一个线性迭代,它的效率比前一种过程高的多。但是我们也不该说,树形递归计算过程没有用。因为当我们考虑在层次结构的数据上操作时,树形递归计算过程会成为自然而威力强大的工具。即使是对于数的计算,树形递归计算过程也能帮助我们理解和设计程序。
实例:换零钱方式的统计
考虑这样一个问题:给了半美元,四分之一美元,10美分,5美分和1美分的硬币,将1美元换成零钱,一共有几种方式?
采用递归过程,我们能很容易地解决这个问题。我们首先将可用的硬币类型按某种顺序排列,那么将总数为a的现金换成n种不同的硬币的不同方式的数目等于:
- 将现金a换成除第一种硬币外的硬币,有多少种可能,加上
- 将a-d换成这些硬币有多少种可能,其中d为第一种硬币的币值
我们很容易便能写出一个递归过程:
(define (count-change amount)
(cc amount 5))
(define (cc amount kinds-of-coins)
(cond ((= amount 0) 1)
((or (< amount 0) (= kinds-of-coins 0)) 0)
(else (+ (cc amount
(- kinds-of-coins 1))
(cc (- amount
(first-denomination kinds-of-coins))
kinds-of-coins)))))
(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)))
count-change也会产生一个树形的递归计算过程,其中也有不少冗余的计算,不过我们也可以利用迭代的方法对这种冗余计算进行优化。
练习 1.11
递归过程:
(define (f n)
(if (< n 3)
n
(+ (f (- n 1))
(* 2
(f (- n 2)))
(* 3
(f (- n 3))))))
迭代过程:
(define (f n)
(f-iter 2 1 0 n))
(define (f-iter a b c count)
(if (= count 0)
c
(f-iter (+ a
(* 2 b)
(* 3 c))
a
b
(- count 1))))
练习 1.12
很容易,故略
练习 1.13
很容易,故略去。
1.2.3 增长的阶
本节的内容在诸多算法教材中皆有阐述,故在此不多赘述。
练习 1.14
不难得到,空间的阶为O(n),步骤的阶为O(n5)
练习 1.15
(a)5次
(b)空间和步数均为O(log(a))
1.2.4 求幂
这一节介绍了一个简单的求快速幂的方法,不多赘述。
练习 1.16
代码如下:
(define (fast-expt b n)
(fe-iter 1 b n))
(define (fe-iter a b n)
(cond ((= n 0) a)
((= n 1) (* a b))
((even? n) (fe-iter a (* b b) (/ n 2)))
(else (fe-iter (* a b) b (- n 1)))))
(define (even? n)
(= (remainder n 2) 0))
练习 1.17
(define (fast-mul a b)
(cond ((= b 0) 0)
((= b 1) a)
((even? b) (fast-mul (double a) (halve b)))
(else (+ a
(fast-mul a (- b 1))))))
练习 1.18
(define (fast-mul a b)
(fm-iter 0 a b))
(define (fm-iter m a b)
(cond ((= b 0) m)
((= b 1) (+ m a))
((even? b) (fm-iter m (double a) (halve b)))
(else (fm-iter (+ m a) a (- b 1)))))
练习 1.19
这是一道利用矩阵快速幂求斐波那契数列的题,题目本身十分容易,故略去。
1.2.5 最大公约数
找出两个数的最大公约数(GCD)的方法是著名的欧几里得算法,它基于这样的观察:如果r是a除以b的余数,则a和b的公约数与b和r的公约数相同。
容易将欧几里得算法写成一个过程:
(define (gcd a b)
(if (= b 0)
a
(gcd b (remainder a b))))
欧几里得算法所需的步数是对数增长的,这件事由下面的定理得出:
如果欧几里得算法用k步算出一对整数的gcd,那么这对数中较小的那个数必然大于或等于第k个斐波那契数。
练习 1.20
如果利用正则序我们计算remainder函数的次数将会是应用序的两倍,分别在检测b是否为0时以及递归触底之后各需求值一次,而应用序则只需在递归触底时求值。
1.2.6 实例:素数检测
本节将描述两种检查一个整数是否为素数的方法。
寻找因子
一种直接的方法是,从2测试到√n为止,这里不多赘述
费马检查
费马检查基于著名的费马小定理,我们通过随机取a,并计算a的n次方模n的值,如果与a模n同余,我们就认为n有较大的几率为素数。这里计算n次方并取模可以使用前面介绍的快速求幂的方法。
概率方法
费马检查和我们前面已经熟悉的算法不同,它的结果只有概率上的正确性,一个数通过了费马检查只能作为它是素数的一个很强的证据,并不保证n一定是素数。我们希望,对于任何一个数,如果执行检查的次数足够多,且n通过了检查,那么就能是这个检查出错的概率减小到所需要的任意程度。
不幸的是,这一断言并不完全正确。确实存在着一些能够骗过费马检查的n,它对于任意a<n都能通过费马检查。由于这种数极其罕见,费马检查在实践中还是很可靠的。
练习 1.21
最小因子分别为 199, 1999, 7。
这一节的练习感觉不想写,所以就算了。