创造性的编程活动-区别于编码-通常通过例子来展示某些技术。本文认为编程是一系列的关于分解任务到子任务,分解数据到数据结构的设计决定构成。对功能规格的细化过程将会通过简短但是不平凡的例子证明出来。
关键字:编程教育 编程技术 逐步编程构建。
编程这门技术通常通过例子来教授。编程课程是否成功往往取决于选择例子是否恰当。不幸的是,大部分人选择例子去说明计算机可 以做什么。与此相反,选择例子的主要标准应该是通过这些例子可以表达技术的应用方法。事实上,现在很多例子是首先被完成,成为一个成品,然后去解释它的目 的,以及语言的细节。但是活跃的编程应该包括设计新程序,而不是对这老程序去思考。这样的教学方法的结果会给学生获得一个印象:编程主要就是掌握一门语言 (所有的特殊性和复杂性,然后依赖自己的直觉,把自己的想法变成一个程序。非常清晰的是,编程的课程,应该去教授设计和构造的方法,例子的选择也可以演示 这个循序渐进的过程。
这个论文通过一个例子去表达我的两个目的。一些著名的技术将会被演示和传达,包括策略的预选,逐步求精的试探方案,引入辅助数据,递归, 而程序通过一系列的细化步骤被阶段的开发出来。
在每一步中,程序的一个或几个指令被分解成更详细的指令。这接二连三的分解或细化规格一直进行,直到所有的指令都被表达到计算 机语言或编程语言;执行此一程序的结果通过数据体现出来,这些数据,可能有必要引入进一步的分解,以便在子任务和指令之间实施数据沟通;这样,由于任务逐 步的细化,因此数据可能要加以细化,分解,结构化去实现设计决策。这样我们就可以得出非常自然的结论:程序的细化和数据的细化是同步进行的 。
每一次细化求精都意味着一些设计的决策。至关重要的是:这些决策需要明确制定,并且程序员知道做出这个决策下面的选择 标准和可选的其他解决方法。给定问题的可能解决方法就是一个树的叶子,每一个叶子都表达了审议和决定的一个结果。一个子树可以被认为是一组共同特征和结构 的解决方案集合。引入解决方案树的概念,是为了在一个程序的目标和环境发生改变的时候,程序需要调整时是有意义的。
逐步求精的一个指导原则是,尽可能的分解决策,分开表面上看起来有相关性的,对于涉及细节比较多的表达,应该尽可能的 推迟决策。不同的环境(计算机或者语言),需要不同的表达。(译注1:比如11,在16进制为0xB,在二进制是..,,这些都太具体了,和解决问题本身 关系不大,就尽可能的把设计决策向后推)。
选择的例子将会在第三节开始确定,在正式的阅读论文前,我强烈建议读者自己先去寻求问题的解决,论文的方案仅仅是众多的解法之一。
2. 符号
为了描述程序,这篇论文使用一个略微增强的Algol 60 符号体系。为了更好的表达语句的重复执行,而不必通过标签和跳转那么麻烦,采用这样的格式的记号:
repeat (statement sequence)
until (boolean expression)
含义是一直执行 statement sequence,直到boolean expression为true。
给定一个8X8的棋盘和8个彼此敌对的王后。为每个王后找到一个位置,以便任何一个王后都不会被另一个王后干掉(这意味着, 在棋盘上的每一行,每一列,每个对角线都只能有一个王后)。这个问题比较典型,典型在于这类问题预先并不知道解决方法 ,而必须通过试错去寻求方法。
通常,存在一个可能解的集合A,它满足条件P而被选择出来。解可以表达为X,这样就有一个方程为 "从集合A中找到X,X满足 p(x)"(译注2:这不是前文提及的Algol60的记号体系的内容,而是集合里面的符号)找出解的程序是:
repeat Generate the next element of A and call it x
until p (x ) V (no more elements in A );
if p (x ) then x = solution
这一类问题的困难程度往往和集合A的大小有关,因为问题需要对可能符合条件的解进行穷举,因此必须考虑效率。本例内, 棋盘布局共有 64!/(56! X 8!) = 2^32 个,假设每个解的产生和验证需要100ms,那么粗略估计找到解的时间需要7个小时。因此找到一个捷径是必要的,通过这个捷径清除明显不符合的解。策略的预选可以表达为 把条件P分解为条件Q 和条件R,让B是X的集合,并且X属于A,且满足条件R(x)。(译注3:都是集合代数内的符号,好不容易看懂了)。 . 显然B是A的子集 。
现在不必考虑从集合A中选择,而只要考虑集合B内的元素被生成和测试,测试的条件是Q,而不是P,这样就满足如下的需求:
1.集合B比集合A小
2。集合B的元素容易生成
3.条件Q比条件P更容易验证
相应的问题变成:
repeat Generate the next element of B and call it x
until q (x ) V (no more elements in B );
if q (x ) then x = solution
在八皇后问题中,一个比较恰当的条件R是“棋盘上每一列必然只能有一个王后”。条件Q仅仅限定每一行每一个对角线最多 只有一个王后。和条件P相比,条件Q更加容易验证。集合B(要求每列一个王后)仅仅有8^8 = 2^24 个元素。集合B可以通过王后在限定列上的移动来产生。这样以上所有条件都可以达到满足。
在此假定验证一个潜在的解需要100ms,那么找到一个解将会花费100ms。如果有专用的强大的计算机,那么做完这 次优化就足够去提交程序运行了。如果运气不好,需要排队和他人共享计算机(按每秒一个解验证来计算,需要280小时),就需要继续花费时间去寻找更进一步 的捷径。
另外一个解决方法如下:把试验解x表达为 {x 1 , x 2 , ··· , x n }的形式,这样每一个试验解都可以通过 [x 1 ], [x 1 , x 2 ], [x 1 , x 2 , ··· , x n ] 的相应步骤而得到。分解是这样的:
1. 每一个步骤[x 1 ], [x 1 , x 2 ], [x 1 , x 2 , ··· , x n ] 都要比得到整个X([x 1 , x 2 , ··· , x n ] )更加简单
2. 在j<n的情况下 ,那么条件Q(Xj)是Q(X ) 的子集
如果前面的步骤就不再满足条件,那么这个步骤的后续步骤就不必再继续了。(译注4:比如验证到第三个王后的时候,就已经不能满 足条件Q的化,那么继续试探第四个王后的位置就不在必要了),反过来说,一个部分符合条件Q的试验解也不一定可以推演出完整的解来。(译注4:比如验证到 第三个王后的时候,还是满足条件Q的,这不意味着继续推演到第8个王后也一定可以放到棋盘上)。 试验解的逐步构造方法因此需求当试验解在第j步失败时, 就停止继续试探,然后重新尝试其他可能。这个技术被称为回溯, 程序代码如下:
j := 1;
repeat trystep j ;
if successful then advance else regress
until (j < 1) V (j > n )
在8王后的例子中, 通过从第一列开始,逐列的放置王后的方法可以找到一个解。显然,如果部分配置都不能满足王后间互不侵犯的条件,那么由这个部分配置继续向下搜索就不可能找 到一个解。因此,在第j步,只要考虑验证第j个王后是否满足互不侵犯的条件。仅仅判断第j步是否安全,和判断整个棋盘上的王后是否安全相比更加容易进行, 因此整个条件可以分解为第j步,在第j列为王后找到一个安全的位置。
程序将随后根据这个方法开发出来。通过验证876次棋盘布局找到一个完整的解。再次假设每次验证需要1秒,这个解法将会需要15分钟,如果每一步需要100ms,那么将会耗时0.9s。
通过逐步求精的方法,我们产生问题的第一个版本的解法:
variable board, pointer, safe ;
considerfirstcolumn ;
repeat trycolunm ;
if safe then
begin setqueen; considernextcolumn
end else regress
until lastcoldone V regressouttofirstcol
这个程序由一系列更基本的指令集合(或者说procedures)构成,描述如下:
considerthiscolumn。问题就是去检查安全方格。变量pointer用来指示当前被检查的方格。这个方格所在的列被称为当前检查列 。这个过程初始化变量pointer 到第一个列。
trycolumun。 开始在当前列的当前方格内检查,在列里面向下移动直到找到安全的方格, 此时布尔变量safe 设置为true,或者直到最后一个方格完成并且还不安全,这个情况下,布尔变量safe 被设置为false。
setqueen .王后被放置到最后一个被检查的方格。
considernextcolumn。 继续下一个列,并且初始化变量pointer
regress .回溯到可以进一步放置王后的列,移开在回溯列之后的列上放置的王后。(注意,我们可能不得不至少回溯两列【todo】,为什么?)。
接着我们继续细化指令trycolumn ,regress
procedure trycolumn ;
repeat advancepointer ; testsquare until safe V lastsquare
procedure regress ;
begin reconsiderpriorcolumn if - regressouttofirstcol then begin removequeen ;
if lastsquare then begin reconsiderpriorcolumn ;
if not regressouttofirstcol then removequeen end end end
程序现在由指令和谓词构成,指令包括:
considerfirstcolumn ,considernextcolumn ,reconsiderpriorcolumn ,advancepointer
testsquare (sets the variable ) ,setqueen ,removequeen
谓词包括: lastsquare lastcoldone regressouttofirstcol
为了让这些指令和谓词可以在通用编程语言中可以被表达出来,有必要用这些编程语言来表达它们。如何通过数据来表达这些相关的事实的决策不能再被推迟了。首先要表达的是皇后的位置,以及当前正在检测的方格。
最直接的方法( 为了便于理解,可以想象在木棋盘上放置大理石块)是引入一个方格矩阵b[i,j],当b[i,j]=true表明方格的i,j位置已经被占用。
一个算法的成功,极度的依赖是否选择了一个良好的的数据结构表达,好的数据结构可以让在其上的操作更容易被表达出来。 除了这个,关于存储需求,可放在首要地位(尽管难以在这种情况下) 。程序设计上的一个公共的难题是--在对数据结构的决定必须做出的时候,经常很难预见到这个数据结构上将会发生的操作的细节,也无法估计一个数据结构相对 于另外一个的优点。通常,因此建议对数据结构的决策尽可能的推迟。
(but not until it becomes obvious that no realizable solution will suit the chosen algorithm).
(但是不能推迟到对于选定的算法显然没有人任何可实现的解的时候[译注5:不明白])
对于当前问题,技术在这个阶段,下面的选择比起布尔矩阵来说,也更加简单,存储也更加经济。
j是当前被检查列的索引,(xj,j)是前一个被检查方格的坐标;王后在k<j的位置表达为(xk,k)。现在pointer 和 board的定义为:
integer j (0 <= j <= 9)
integer array x [1:8] (0 <= xj <= 8)
条件和谓词表达为:
procedure considerfirstcolumn ;
begin j := 1; x [1] := 0 endprocedure considernextcolumn ;
begin j := j + 1; x [j ] = 0 endprocedure reconsiderpriorcolumn ;
j := j - 1
procedure advancepointer ;
x [j ] := x [j ] + 1
Boolean procedure lastsquare;
lastsquare := x [j ] = 8;
Boolean procedure lastcoldone ;
lastcoldone := j > 8
Boolean procedure regressouttofirstcol
regressouttofirstcol := j < 1
于是,程序为如下的指令表达:
testsquare
setqueen
removequeen
事实上,指令setqueen,removequeen可以当成空洞,如果我们决定testsquare来确定x1 ...xj-1(todo)
不过,不幸的是testsquare是最经常执行的指令,一个指令不能仅仅考虑合理还要考虑效率,即使如此,这不失为是一个好的解法 。显然testsquare 以x1...xj-1方式表示是低效的。testsquare明显比setqueen,removequeen执行的次数更多。后两个给偶出只有在列变化的时候才会执行(共有n次) 前一个指令只要方格变化就会被执行(EM>xm ),当然,setqueen,removequeen是唯一影响棋盘布局的过程。通过引入辅助变量V(x1...xj)来获取效率的提升。
1. 方格是否安全的验证,从V(x)来获得比起直接从x获得更加容易(通过v单元计算,而不是Ku单元计算todo)
2. 从x计算V(x)并不复杂。
The introduction of V is advantageous (apart from considerations of storage economy), if
n(k - 1)u > mu or (n/m)(k - 1) > (v/u) ,
i.e. if the gain is greater than the loss in computation units.
如果n(k - 1)u > mu 或者 (n/m)(k - 1) > (v/u) , 引入V是有利的(不考虑存储经济性),这样获得的效率提升比起计算单元的损失要大些。
A most straightforward solution to obtain a simple version of testsquare is to introduce a Boolean matrix B such that B [i ,j ] = true signifies that square (i ,j ) recomputation whenever a new queen is removed is prohibitive (why?) and will more than outweigh the gain.
(todo)
方格安全条件的实现必须要求不能占用已经被另一个王后占用的行列和对角线,这导致更加经济的对V的选择。我们引入布尔数组a,b,c
a k = true : 行k没有王后
bk = true : 45度对角线k没有王后
c k = true : -45度对角线k没有王后
基于同在一个45度对角线的方格的坐标等和,同在一个-45度的对角线的方格的坐标等差的事实,并且行列的索引从1到8,我们得到三个数组的下标范围: a [1:8],b [2:16],c [-7:7] (译注6:数组b用坐标的和做下标就可以确定唯一的棋盘上的方格的位置,相应的c用两个坐标的差做索引,也是同样的道理,紧接着的第四行中的testsquare的定义可以看到:b下标就是j+x[j]坐标和,c的下标是j-x[j]也就是坐标差,从而验证了我的猜想是正确的 )
以上引入的辅助数据必须注意要正确的初始化。我们的算法从空的棋盘开始,因此必须通过对a,b,c的所有元素赋给true来表现这个事实。我们现在可以:
procedure testsquare;
safe := a[x[j]] and b[j + x[j]] and c[j - x[j]]
procedure setqueen;
a[x[j]] := b[j + x[j]] := x[j - x[j]] := false procedure removequeen;
a[x[j]] := b[j + x[j]] := c[j - x[j]]
后面的过程的正确性依赖于这样的一个事实:当前每个王后都必然放置到安全的方格上,在一个被移除的王后后面放置的王后也必然已经移除完毕。因此被腾出来的方格再度变得安全。
对当前程序的严格检查发现变量x[j](译注7:表示第j列的当前方格)是经常要使用的,这些地方的程序也执行的最频 繁。检查x[j]也比给j赋值更加频繁,因此,引入新的辅助数据可以进一步的提高效率。以整数i代表x[j],在j被增加前,x[j] = i要被执行,在j被减少之后 ,i := x[j] 也要被执行 ,然后对以上过程的重新清理,改为如下代码:
procedure testsquare ;
safe := a [i ] and b [i + j ] and c [i - j ]
procedure setqueen ;
a [i ] := b [i + j ] := c [i - j ] := false procedure removequeen ;
a [i ] := b [i + j ] := c [i - j ] := true procedure considerfirstcolumn ;
begin j := 1; i := 0 end procedure advancepointer ;
i := i + 1;
procedure considernextcolumn ;
begin x [j ] := i; j := j + 1; i := 0 end Boolean procedure lastsquare lastsquare := i - 8
最终的程序使用了如下的过程:testsquare setqueen regress removequeen ,其他的过程被直接替换,现在的程序是这样的:
j := 1; i := 0;
repeat repeat i := i + 1; testsquare until safe or (i = 8);
if safe then begin setqueen ; x [j ] := i ; j := j + 1; i := O
end else regress until (j > 8) or (i < 1);
if j > 8 then PRlNT(x ) else FAILURE
值得注意的是,程序的结构依然和第一步设计的版本相同。自然的,等效的有效解法可以通过逐步求精的方法得出和开发出 来。这个事实应该给学生明确的表达出来。一个可更换的方法是EWD提出的。解法基于这样的想法:棋盘上每一个列包含一个安全的位置,从空的棋盘开始到8列 填满结束。填写棋盘列的方法是一个过程,自然的办法是通过对这个过程进行递归获得完整的棋盘布局。它可轻易组成的同一套更为原始的指示,这些全部用于第一 个解决办法。
procedure trycolumn begin integer i ; i := 0;
repeat i := i + 1; testsquare ;
if safe then begin setqueen ; x [j ] := i ;
if j < 8 then trycolumn (j +1);
if not safe then removequeen end until safe or (i = 8)
end
使用这个过程的程序是:
Trycolumn (1);
if safe then PRINT(x ) else FAILURE
(注意,因为递归过程中引入了一个变量I,每个列有自己的i,因此,每个过程也有自己的I,testsquare setqueen removequeen 必须在Trycolumn 内本地声明,因为他们引用了I去扫描当前列的方格。)
5. 一般化的8王后问题。
这个程序一旦执行正确和令人满意,就保持不再改变,这样的情况在现实计算世界内也比较罕见的。现实情况是,用户迟早会发现程序 不再能够给出希望的结果,或者更差的是,给出不是用户真正期望的结果。 或者需求或者扩展就是必要的了,此时,逐步细化的程序设计和系统结构就更加有价值 和好处。如果程序组件和结构都是比较自洽的,哪么很多指令都不必修改即可通过。对再设计和再验证就不必花费太多的努力。可维护性对程序的结构化程度提出了 挑战。
以下的章节的目的是:演示以一般化的看待8王后问题的好处。通过扩展前面提及的程序组件,来扩展原始的8王后问题和解法。
找出8个敌对的王后再8X8的棋盘上的全部位置,以便没有王后会被其他的王后干掉。
新的程序基本上有两个部分构成:
1. 找到生成进一步解法的方法
2. 决定是否全部解都已经被生成
显然,通过一些系统的方法去生成和验证候选的解是必要的。 一个办法是确定候选解的次序,并且找到最后一个解的验证条件。如果一个次序被找到了的话,那么解就可以映射到整数上面去。如果算法严格的按照递增的次序去 生成解,那么和解相关的这个数字限定条件实际上就是算法终止的标准。(译者:这一段我看了很多次,才慢慢的明白他的意思,最初的看法可以说是根本翻译不下 去了)
可能解的上界是M (x max ) = 88888888
上面的生成了一个解的程序,生成了一个最小数量的解,但是可以作为找到更多解的一个开始。新的测试方格的方法现在要严 格的按照M(x)递增的次序来进行,并且从00000000开始。生成进一步解的方法必须可以“从一个当前解给出的棋盘布局开始,按照M(x)的递增次序 继续扫描更多的解,直到下一个解被找到,或者超出解的上界”。
6. 扩展程序
通过修改全局的数据结构,并且共同相同的代码块的想法,可以将简单的8王后问题的程序扩展到可以解决一般化的8王后问题。全局的数据结构必须修改,这样同样的算法可以在找到第一个解后,能够继续找下一个解,直到解全部被找出来。
当第一个王后已经移动到第8行之外,并且在这个回归程序已经回归出了第一列,此时就可以说明,全部可能解都已经遍历完毕,不必再继续找了;这样就得到了一个非递归版本的程序:
considerfirstcolumn ;
repeat trycolumn ;
if safe then begin setqueen ; considernextcolumn ;
if lastcoldone then begin PRlNT(x ); regress end end else regress regressouttofirstcol
指示解被找到的打印语句现在就在检查层执行,也即是说,在离开循环语句之前执行。算法继续寻找下一个解,并且使用一个捷径--直接回归到前一个列,毕竟每个成功的解在每个行都有一个王后,在8列内的移动最后一个王后也就不再必要。
遵循相同的考虑,以递归程序编写,就可以更加简单的做出扩展:
procedure Trycolumn(j) ;
begin integer i ;
(declaralions of procedures testsquare, advancequeen,
setqueen, removequeen, lastsquare )
i := 0;
repeat advancequeen; testsquare ;
if safe then begin setqueen ; x [j ] := i ;
if not lastcoldone then Trycolumn (i + l) else PRlNl'(x );
removequeen end until lastsquare end
主程序只有一个语句,就是Trycolumn (1) .
总结下,两个程序代表了一个相同的算法。通过对方格测试了15720次后获得了92个解。每个解平均花费了171次验证;找到下一个解验证的次数最多的是876次(就是第一个解),最小8个。(两个程序都是在cdc 6400上的pascel语言通过)
这个带着一个例子的课程可以总结为以下要点:
1. 程序构造可以有一系列的逐步求精的过程构成。给定任务的每一个步骤可以分解为许多的子任务。对任务描述的细化可以通过对子任务之间通讯的数据的细化获得。对程序的细化和对数据结构的细化应该并行进行。
2. 未来对程序进行功能上的修改或者扩充,或者需要相应调整以便适应新的执行环境(语言,计算机),这些工作的困难程度,就是由这种方式得到的模块化程度来决定的。
3. 在逐步求精的过程中,应该尽可能的使用可以自然贴切的表达问题的符号体系。在逐步求精的过程中,符号发展的方向将会由最终采用的计算机语言决定,并且最终 和计算机语言一致。这些符号语言应该尽可能自然和清晰的表达在设计过程中逐步出现的程序结构和数据;同时也能够在逐步求精的过程中,通过表达基本的特征和 结构规律,从而对程序将要执行的计算机上也是自然的这一点给出指导。值得注意的是,很难找出一门很好的符合这些需求的语言,现在广泛使用的教学语言 Fortran只能说在很低程度上可以满足。
4. 每一次细化都隐含着给予一组设计条件的设计决策。这些标准是效率,存储的经济,清晰,结构化。教学中学生应该自觉参与到设计决策中来,批判的检查解法,甚 至是拒绝解法,即使这个解法的结果是正确的;他们应该学会如何参照以上标准去权衡可选的方案。尤其是,如果有必要,他们应该被教到回到较早的设计决策,即 使已经回到了决策树的顶端。一个短程序样本就可以说明这个重要道理,仅仅考虑这个目的而去建立一个操作系统是根本没有必要的
5. 甚至一个短程序的开发,要详细交代也需要有一个长的故事;这就说明谨慎的编程并不是一项无足轻重的议题。如果通过这个论文,有利于消除人们的普遍的错误认 识--认为编程是容易的,只要编程语言是足够强大的和现有的电脑速度够快的话,那么它已经取得了它的一个目的
致谢: 作者感谢C.A.R. Hoare 和 E. W. Dijkstra的帮助,以及和他们在讨论中得到的启发。
列出以下文章作为参考和进一步阅读
1. Dijkstra, E. W. A constructive approach lo the problem of program correctness. BIT 8 (1968), 174-186.
2. Dijkstra, E. W. Notes on structured programming. EWD 249 Technical U. Eindhoven, The Netherlands, 1969.
3. Naur, P. Programming by action clusters. BIT 9 (1969) 250 - 258.
4. Wirth, N. Programming and programming languages. Proc. Internat. Comput. Symp., Bonn, Germany, May 1970.