2 算法入门
本章将介绍贯穿全书的用来思考算法设计和分析的一个框架。这部分内容基本是独立的,但是它也包括了对第3和第4章中一些内容的引用。(它也包含了几个求和运算的例子,在附录A中会说明如何解决。)
我们首先分析第1章介绍的用来解决排序问题的插入排序算法。我们定义“伪码”,如果你曾做过计算机编程,你应该熟悉它。我们使用“伪码”来说明我们如何描述我们的算法。描述了插入排序算法后,再证明它完成正确的排序,以及分析它的运行时间。算法分析中我们介绍一种记号,它主要关注运行时间如何随着待排序元素数的增加而增加。讨论了插入排序后,我们介绍算法设计中的分治法,并用它实现称为归并排序的算法。最后我们分析归并排序的运行时间。
2.1 插入排序
我们分析的第一个算法,即插入排序,解决的是第1章介绍的排序问题:
待排序的数字也称为关键字。尽管在概念上是对一个序列排序,然而输入是一个含有n个元素的数组形式。
本书中,我们通常使用伪码编写的程序来描述算法,这与C、C++、Java、Python或Pascal等语言的很多方面类似。如果你熟悉上面任何一种语言,不会在阅读我们的算法上有太大问题。伪码和真实代码的区别是,在伪码中,我们可以使用任何简洁明了的表现方法来描述一个给定算法。有时候,最明了的方法是自然语言,因此如果你遇到在一段真实代码中嵌入了自然语言的单词或者句子时不要惊讶。伪码和真实代码的另一个区别是,伪码通常不涉及软件工程方面的问题。为了更简明地表达算法的本质,数据抽象、模块化以及错误处理等问题往往省略掉。
我们先从插入算法开始,它是在对少量元素进行排序时的一个高效算法。插入排序如同许多人打牌时对手中的扑克牌进行排序一样。一开始,我们的左手是空的,扑克牌面朝下的放在桌子上。然后,每一次我们从桌子上取一张扑克牌,并把它插入到左手的正确位置。为了找到这张扑克牌的正确位置,我们将这张扑克牌从右向左依次与已在手中的扑克牌进行比较,如图2.1所示。在任何时候,左手中的扑克牌都是排好序的,并且这些扑克牌都是原来桌子上牌堆中最上面的那些。
图2.1使用插入排序对扑克牌进行排序
我们使用称为INSERTION-SORT的过程给出插入排序的伪码,该过程以一个待排序的长度为n的数组作为参数。(在代码中,
的元素数目n记为
。)该算法对输入数字进行原地排序:它在数组
中重新排列数字,并且任何时间最多有常量个元素被存在数组的外面。当INSERTION-SORT过程执行完毕后数组
中包含排好序的输出序列。
INSERTION-SORT(A)
1 for j = 2 to A.length
2 key = A[j]
3 //将A[j]插入到已排好序的序列A[1…j-1]中。
4 i = j – 1
5 while i > 0 and A[i] > key
6 A[i+1]= A[i]
7 i = i – 1
8 A[i+1]= key
循环不变式和插入排序的正确性
![](https://img-my.csdn.net/uploads/201405/15/1400117078_7168.png)
图2.2 插入排序对数组A=〈5,2,4,6,1,3〉的操作。数组用一些方格标识,数组中的数的位置关系显示在相关方格中。(a)-(e) 第1到8行的for循环的迭代。每次迭代时,黑色方格保存的关键字,该关键字在第5行条件检查时与它左侧阴影方格中的值进行比较。阴影箭头表示在第6行中数组元素向右移动一个位置,黑色箭头标识第8行中关键字移动的位置。(f)最终已排序的数组。
图2.2描述了插入排序如何对A=〈5,2,4,6,1,3〉实现的。索引j标识将要插入到手中的“当前扑克牌”。在for循环的每次迭代之前,这个for循环以为索引,由
这些元素组成的子数组构成了手中当前已排好序的扑克牌,剩下的子数组
对应于仍在桌子上的那堆扑克牌。事实上,
中元素是原来在位置1到
的元素,只是现在它们已排好序。我们使用循环不变式来形式的描述
的这些性质:
在第1到8行的for循环的每次迭代之前,子数组由原来在
中的元素组成,只是已经排好序。
循环不变式主要用来帮助我们理解一个算法的正确性。我们需证明循环不变式的三个性质:
初始:在循环的第一次迭代之前算法是正确的。
保持:如果算法在循环的一次迭代前是正确的,那么在下次迭代前它仍是正确的。
终止:在循环结束时,这个不变性提供了我们一个证明该算法正确的有用的属性。
如果满足前两个性质,那么在循环的每次迭代前,循环不变式为真。(当然,我们可以自由的使用既定事实,而不是用循环不变式自己去证明循环不变式在每次迭代前保持为真。)注意这个数学归纳法类似,要证明一个属性满足,你需要证明一个基本情况和一步归纳。在这里,可以将第一次迭代前不变满足对应于基本情况,将每次迭代中不变满足对应用归纳步骤。
第三个性质或许可能是最重要的一个,因为我们使用循环不变式行来证明正确性。通常,我们将循环不变式和导致循环结束的条件一起使用。终止性质与我们在数学归纳法中使用的不同,在数学归纳法中我们无限的使用归纳步骤,而在这里当循环结束时,我们终止“归纳”。
让我们来看看插入排序如何满足这些性质的。
初始:我们从证明在第一次迭代前循环不变式是满足的开始,即。子数组
只有一个元素
组成,即事实上是
原始的元素。此外,这个子数组是排好序的(显而易见的),这即证明在循环的第一个迭代前,循环不变式是满足的。
保持:接下来,我们证明第二个性质:证明每次迭代保持着这个循环不变式。通俗地说,for循环的循环体依次将向右移动一个位置,直到它找到
的合适位置(第4-7行),
的值会被插入到该位置。这时子数组
由原来在
的元素组成,只不过现在已排好序。为for循环的下一次迭代递增j,这时仍保持循环不变式。
如果第二条性质要有一个较正式的证明,这需要陈述并证明while循环(第5-7行)也有一个循环不变式成立。但是,在这一点上,我们不愿陷入这样的过于形式化的细节,而是依靠非形式化的分析来证明外层循环满足第二个性质。
终止:最后,我们检查当循环终止时会发生什么。导致循环终止的条件是。因为每次循环迭代将
增1,终止时一定是
。将循环不变式中的
替换成
,我们得出子数组
由原来在
中的元素组成,但现在已排好序。可以看出子数组
就是整个数组,我们得出结论是整个数组已排序。因此,算法是正确的。
在本章后续部分以及其他章节中,我们将使用循环不变式的这个方法来证明算法的正确性。
伪码约定
我们在伪码中使用如下约定:
.缩进表示块结构。例如,第1行的for循环体由第2-8行组成,第5行的while循环体由第6-7行组成,不包括第8行。这种缩进样式同样应用于if-else表达式。使用缩进来替代传统的块结构标识符,如begin和end语句,极大的减少了混乱,同时保留,甚至增强了简明性。
.while、for和repeat-until循环结构和if-else条件结构同在C、C++、Java、Python和Pascal中类似。本书中,循环计数变量在离开循环后仍保持着其值,这不像在C++、Java和Pascal中的一些情况。这样,在for循环刚结束时,循环变量的值就是第一次超出for循环边界时的值。我们在对插入排序的正确性的讨论中应用这个性质。第1行的for循环头是for j = 2 to A.length,这样在循环结束时,(或者等价于
,因为
)。当一个for循环中在每次迭代中递增其循环计数变量时,我们使用to关键字,当一个for循环递减其循环计数变量时,我们使用downto关键字。当循环计数变量的变化大于1时,变化量跟在可选关键字by后面。
.”//”符号标识该行的剩余部份为注释。
.这种形式的复合赋值,同时将变量
和
赋值为表达式
的值。这等同于赋值
,然后紧跟着
。
.给定的过程中的变量(像,
和
)是本地变量,除非特别说明,我们不会使用全局变量。
.我们通过使用在数组名后方括号中的索引来访问数组元素。例如,表示数组中的第
个元素。”...”记号表示数组中的一个数据的范围。因此,
表示
的一个子数组,它由
、
、…、
这
个元素组成。
.我们通常将复合数据组织成对象,对象由属性组成。我们使用在许多面向对象编程语言中遇到的语法来访问特定的属性:对象名称,后面紧跟一个点,然后是属性名称。例如,我们将数组看作一个对象,用length属性标识它包含有多少个元素。为了表示数组的元素个数,我们可以写成
。
我们将表示数组或对象的变量视为一个指针,它指向这个数组或对象表示的数据。对于对象的任何属性
,若使
则
。此外,如果这是我们设置
,那么这是不但
等于3,
同样等于3。换句话说,在
赋值语句后,
和
指向同一个对象。
我们的属性记号可以是“串联的”。例如,假设属性它本身是一个指向一个包含属性
的类型的对象。那么
隐含的用括号表示为
。换言之,如果我们使
,则
与
相同。
有时候,一个指针不指向任何对象。这样,我们给指针一个特殊的值NIL。
.我们通过传值的方式传参数给过程:被调用过程接收了参数的属于过程自身的拷贝,若它给参数分配一个值,这个改变不会被调用过程看到。传递对象时,指向对象表示的数据的指针会被复制,但是对象的属性不会。例如,若是被调用过程的一个参数,那么在被调用过程中赋值
对调用过程来说是不可见的,但赋值
是可见的。类似的,数组是指针传递的,因此,一个数组的指针会被传入,而不是整个数组,并且对单独的数组元素进行的改变会对调用过程可见。
.return语句会立即把控制权返回给调用过程中调用的地方。大多数return语句同时将一个值返回给调用者。我们的伪码不同于许多编程语言的地方在于我们允许在一个return语句中返回多个值。
.布尔运算符“and”和“or”都是短路求值的。即,当我们计算表达式“ and
”时,我们先计算
。如果
是FALSE,那么整个表达式不会为TRUE,所有我们不必再计算
。若另一方面,
是TRUE,我们必须计算
来确定整个表达式的值。类似的,在表达式“
or
”中我们只有在
为FALSE时才去计算
。短路求值的运算符允许我们把布尔表达式写成像“
!= NIL and
”的形式,而不必担心若
是NIL而当我们试着去计算
时会发生什么。
.error关键字标识一个错误发生,因为条件对调用的过程来说是错误的导致该错误发生了。调用过程负责处理错误,因此我们不必采取什么措施。