计算机算法
-设计与分析导论
Sara Baase
Allen Van Gelder
译者:sttony.dark
前言
第一章 分析算法和问题:原理与例子
1.11.11.1
概述概述概述
说一个问题是算法可解的(非正式的),意味着可以编写一个计算机程序,如果我们允
许这个程序有足够的运行时间和存储空间,这个程序可以为任何输入处理出一个正确的结
果。在 20 世纪 30 年代,计算机出现之前,数学家们就已经积极的定型和研究算法的概念。
算法被解释成(非正式的)一套清楚的简单指令,按照这套指令可以解决某个问题和计算某
个函数。多种形式的计算模型是设计和研究出来的。早期这个领域工作的重点叫可计算理论,
其主要是描述或总结那些可算法解决问题的特征,以及指出那些问题是不能算法解决的。由
阿兰. 图灵建立的一个重要的否定结论是:证明“停机问题”(halting problem)是不可解决
的。停机问题是说:能否判断一个任意给出的算法(或计算机程序)在给定输入的情况下是
否最终停止(也就是说是否进入死循环)。这个问题不能用计算机程序解决。
尽管可计算理论对于计算机科学是明显和基础的本质,但是判断一个问题是否在理论上
可以在计算机上解决的知识还不足以告诉在实际中是否也可以。例如,可以写出一个完美的
国际象棋程序。这并不是一件很困难的事情;棋子在棋盘上的摆放方式是有限的,在一定的
规则下棋局总会在有限步之后结束。程序可以考虑计算机可能走的每一步,对手对这一步所
有可能的响应,再考虑如何应对对手这一步…… 一直到每一种可能的走法到达结束。既然
知道了每一步的最后结果,计算机就可以选择一个最好的。考虑棋子在棋盘上合理的排列(这
比步数要少的多),估计就超过 1050。检查所有可能结果的程序需要运行几千年,如此程度
的程序不能运行。
实际应用中大量的问题都是可解决的——就是说可以为他们写出程序——但是对一个
实用的程序来说时间和存储空间的要求是十分重要的。一个实际程序明确的时间空间要求是
很重要的。因此,他们变成了计算机科学中一个分支的主题,叫计算复杂性。本书并不包含
这一分支,这一分支关心一套正式、有些抽象的关于可计算函数复杂性的理论。(解决一个
问题等价于根据一套输入计算出函数的输出。)度量复杂性的公理已经公式化了;他们是如
此的基础和一般以致一个程序执行指令的条数和需要存储空间的 bit 数都可以作为 复杂性
度量。使用这些公理,我们可以证明任意一个复杂问题的存在,以及问题没有最好的程序。
- 1 -
(Using these axioms, we can prove the existence of arbitrarily complex problems and of problems
for which there is no best program.)
本书中学习的计算复杂性分支只关心分析特殊问题和特殊算法。本书打算帮助读者建立
一份解决通用问题的经典算法的清单,分析算法、问题的一些一般性的设计技术、工具和指
导方针,以及证明正确性的方法。我们将呈现、学习和分析计算机程序中常用的解决各种问
题的算法。我们将分析算法执行所需的时间,我们也分析算法执行所需要的空间。在描述各
种问题的算法的时候,我们将看到几种经常被证明很有用的算法技术。因此我们暂停一下谈
一谈一些一般性的技术,比如分而治之(divide-and-conquer)、贪 婪 算 法( greedy algorithms)、
深度优先搜索(depth-first search)和动态编程(dynamic programming)。我们也将学习问题
本生的复杂性,就是不管用什么算法解决问题所需固有的时间和空间。我们将学习分类 NP
完全问题——目前还没有找到这类问题的有效算法——并试图用一些试探的方法找到有用
的结果。我们也将说明用 DNA 代替电子计算机来向解决这类问题接近。最后,我们将介绍
并行计算机算法的主题。
在下面的一节中我们将给出算法描述语言的大概,回顾一些本书用到的背景知识和工
具,并展示在分析算法中涉及的主要概念。
1.21.21.2
JavaJavaJava
作为算法描述语言
作为算法描述语言
作为算法描述语言
通过平衡多种条件之后,我们选择 Java 作为算法描述语言。算法必须易读。我们想将
注意力放在一个算法的策略和技术上,而不是编译器关心的申明和语法细节。语言必须支持
数据抽象和问题分解,这样才能够简单清楚的表示一个算法的思想。语言必须提供一种实际
的 实 现 ( 即 已 经 了 编 译 器 , 原 文 The language should provide a practical pathway to
implementation.)。他必须在大部分平台上可用而且提供程序开发的支持。实际的实现和运行
一个算法能增强学生的理解,不能陷入与编译器、调试器失败的战斗中。最后因为本书是教
算法的,不是程序设计,能轻易的将算法转换成读者使用的各种语言必须是合理的要求,特
殊语言特性必须减到最少。
在我们要求的许多方面 Java 表现的很好,尽管我们没有宣称他是理想的。他支持自然
的数据抽象。Java 是类型安全的,这意味着一种类型的对象不能在需要另一种对象的操作中
boolean
使用;不允许任意类型之间的转换(叫"cast")。他有显式的 boolean
boolean
类型,所以如果程序员
混淆了"="(赋值运算符)和"= =" (比较运算符),编译器可以发现这个错误。
Java 不允许指针操作,这些要求通常是含糊不清错误的源头;事实上编译器向程序员隐
藏了指针,而在幕后自动处理。在运行期,Java 检查越界的数组下标,检查其他由含糊不清
错误引起的矛盾。他执行垃圾收集器,这意味着自动回收不在引用的对象;这大大减轻了程
序员管理空间的负担。
Java 的缺点是:他有许多和 C 一样的简洁、含糊的语法特征。对象结构可能导致时间
和空间上的低效。许多 Java 构造需要比其他语言大的多的冗余,比如 C。
尽管 Java 有许多特殊的特性,本书展示的算法避免使用他们,只对语言独立的部分感
兴趣。事实上,算法中许多步骤都是易读的伪代码。这一节描述了我们在这本书中使用的
Java 的一个小子集,以及用于增加算法可读性的伪代码约定。附录 A 中给出了运行 Java 程
序需要的其他的实现细节,但是这些细节对理解主要内容没有关系。
- 2 -
1.2.1
1.2.1
1.2.1
Java
一个可用的 Java
Java
子集
完全熟悉 Java 对于理解本书中的算法并不重要。本节给出了本书出现的 Java 特性的一
个大概,主要是为那些想亲自实现算法的读者准备的。这里,我们指出使用的 Java 的 OO
特性,但是尽量避免以使文字得到完全的语言独立性;这主要对熟悉其他 OO 语言(比如
C++)而不是完全熟悉 Java 的读者有好处。附录 A 中有一个简单的 Java 程序。许多书深入
讲解 Java 语言。
有丰富 Java 经验的读者肯定会注意到许多例子中都没有使用 Java 的优秀特性。但是,
算法后面的概念不需要任何特殊的特性,而且我们希望这些概念可以容易的被领悟,并使用
各种语言,所以我们将他留给读者,一旦读者领悟了这些概念,就可以用他们喜欢的语言实
现。
熟悉 C 语法的读者将会意识到 Java 的语法有许多相似的地方:块由大括号分隔,"{"和
" }";数组的索引在方括号"[" 和"]"中。和 C 和 C++一样,二维数组实际上是一个元素是一
维数组的一维数组,所以存取二维数组需要两重 [ ] 就像" matrix[i][j]" 。运算符 "= ="
" !
="
"< =" 和"> =" 是数学关系运算符的关键字。在文中使用的伪代码中使用的是数学符
号。文中使用 "++" 和"--" 运算符来增加和减少,但是决不会将他们嵌入到其他表达式中。
这里也有从 C 借用的运算符 "+=" "- =" "* =" "/ =" 。例如
p+=q ; /*p=p+q */
y-=x ; // y=y-x
就像在例子中一样,注释从 "// " 到行结尾,或是从 " /* " 到" */ ",和 C++一样。
Java 的函数头也和 C 一样。函数头在函数名字后的括号中指定了参数的类型特征;在
函数名字之前指定了返回类型。返回类型和参数类型的组合叫函数的完全类型特征也叫原
型。因此
intintint
getMin(PriorityQ pq)
告诉我们 getMin 接收一个类型(或类)为 PriorityQ 的参数,返回类型 int。
char
、char
char
short
、short
short
、intintint
long
、long
long
boolean
Java 只有很少的原始类型,其他所有类型都叫 classes。原始类型是逻辑(boolean
boolean
)和
数字类型(bytebytebyte
)类型。所有的 Java 类(非原始
类型)都是引用类。在底层,在类中申明的变量都是“指针”;他们的值都是地址。类的实例
叫对象。申明一个变量不会创建对象。通常用"newnewnew
"运算符来创建对象,"new"返回新对象的
一个引用。
double
和 double
double
float
、float
float
对象的数据域按 OO 术语叫实例域。二元的点运算符用来存取对象的实例域。
例 1.1 创建和存取一个 Java 对象
在这个例子,让我们假设数据信息遵循下面的嵌套逻辑结构:
year
number
isLeap
month
day
- 3 -
这里使用非正式的术语,year 是由布尔属性 isLeap 和整数属性 number
构成的组合属性,而 month 和 day 只是简单的整数属性。为了反映这个嵌
套结构,我们必须在 Java 中定义两个类,一个表示整个数据,另一个表
示 year 域。假设我们为这两个类分别取名 Date 和 Year。接下来我们要申
明 number 和 isLeap 作为 Year 类的实例域,申明 year、month、day 为 Date
类的实例域。除此之外,我们最好将 Year 定义为 Date 的内部类。语法如
图 1.1。
Date
class
class
class
{
public
public
public
public
public
public
public
public
public
public
public
public
Year year;
intintint
intintint
static
static
static
month;
day;
class
class
class
{
Year
public
public
public
public
public
public
number;
intintint
boolean
boolean
boolean
isLeap;
}
}
图 1.1 带一个内部类 Year 的类 Date 的 Java 语法
public
不带 public
public
关键字,实例域就不能在 Date 和 Year 外面访问;为了
public
简单,我们在这里使用 public
public
。申明内部类,Year 带 static 修饰符的原因
是,因为我们可以单独创建 Year 的实例而不与任何特定 Date 对象相关联 。
本书中所有的内部类都将是 static。
假定我们创建了一个由 dueDate 变量引用的 Date 对象。为了存取对
象中的 year 实例域,使用.运算符,如"dueDate . year"。如果一个实例域
在类中(与之对应的是在原始类型中),要再接一个. 来存取他的实例域 ,
如"dueDate .year. isLeap" 。
赋值语句仅仅拷贝类对象的引用或地址;不拷贝实例域。例如,"
noticDate = dueDate" 导致变量 noticDate 指向了变量 dueDate 指向的同一
对象。因此下面的代码很可能是逻辑错误:
noticDate = dueDate;
noticDate .day = dueData .day -7 ;
参见 1.2.2 节,对这个问题有另外的讨论。
Java 中的控制语句 ififif
, elseelseelse
, whilewhilewhile
, forforfor
和 break 和他们在 C(和 C++)中的意义相同,
本书中将用到。存在许多其他的控制语句,但不用他们。while 和 for 的语法是
whilewhilewhile
forforfor
(继续条件)body
(初始化;继续条件;增量)body
ananan
boole
这里“初始化”和“增量”都是简单语句(不带{ }),“body”是任意语句, “继续条件”是 boole
boole
break
表达式。break
break
,但本
switch
循环中跳出(当然也包括 switch
switch
语句导致立即从最近的 forforfor
或 whilewhilewhile
- 4 -
switch
书不使用 switch
switch
)。
Object
Java 是单根继承,其根是 Object
Object
extends
。当申明一个新类时,他可以从原来的类中 extends
extends
,
新类就成了继承树种以前定义类的派生类。文中我们将不建立这样的体系,为了尽可能的保
持语言独立性;但是在附录 A 中给出了一些例子。如果新类没有申明为从任何类 extend,
Object
默认的他从 Object
Object
extend。学习算法不需要复杂的类体系。
在 OO 术语里对象的操作叫方法;但是我们将限制自己只使用静态方法,他们都是简单
的过程和函数。在我们的术语中,过程是一个可以取名字、可以被调用的计算步骤序列(带
参 数 的 );函数则是一个可以向调用者返回值的过程。在 Java 中不返回值的过程申明返回类
型为 voidvoidvoid
;这一点和 C 和 C++一样。Java 术语 static意味着这个方法可以被任何对象和合适
类型的对象(对象类型是它的类)所调用,只要依照方法的类型特征就行(经常被称作原型 )。
一个静态方法与任何特定对象都无关。静态方法的行为就像其他程序设计语言中的函数和过
程一样。但是,他们的名字必须跟在类名字的后面,例如"List . first(x) " 指以参数 x 调用在
类 List 中定义的 first 方法。
默认情况下 Java 中的实例域是私有的,这意味着他们仅能被定义在类中的方法(函数
和过程)所存取。这和抽象数据类型(ADT)的设计主题是一致的,即对象只能由定义在
ADT 中的方法所存取。实现这些 ADT 操作(或是静态方法,或是函数、过程)的代码存在
与类中,知道这些私有的实例域和其类型。 默认情况下方法也是私有的,但一般指定为
"public" ,所以定义在其他类中的方法可以调用他们。但是,一些只能由类中其他方法调用
的底层方法可能也是私有的。
ADT 的客户(client)(调用 ADT 的函数、过程)在 ADT“生存”以外的类中实现,所以
他们只能存取 ADT 类的 public 部分。私有数据的维护叫做封装或信息隐藏。
实例域在对象的生存期之内保存赋给他的值,直到后来又赋给他别的值。这里我们可以
看到将实例域指定为私有的优点。一个共有的实例域可以被整个程序的任意一段代码赋成任
意一个值。一个私有的实例域就只能通过 ADT 类中为此目的而设计的方法所赋值。这个方
法可以进行其他计算,并测试赋给他的值是否和 ADT 要求的一致,是否和其他实例域一致 。
一个新对象通过语句"newnewnew
classname( )"创建,例如:
Date dueDate = newnewnew
Date
这条语句导致 Java 调用 Date 类的默认构造函数。构造函数保留一个新对象所占的存储
空间,返回一个新对象的引用(很可能是地址)用于存取对象。新对象的实例域没有初始化 。
Java语言提示 :程序员可以为类定义一个额外的构造函数,构造函
数可以初始化各种实例域,执行其他计算。我们感兴趣的是语言独立性,
文中将不使用这样的构造函数,所以忽略其细节。
Java 中的数组申明和 C/C++中不太一样,他们的特征也明显不同。Java 语法申明一个整
x
型数组(非常明确,申明了一个类型为“整型数组”的 变 量 )是 " intintint
[ ]"。这条语句不初始化 x ,下面这条语句才初始化
[ ] x ",而 C 使用 " iii
ntntnt
x = newnewnew
intintint
[howMany] ;
这里 howMany 可以是一个常量也可以是一个变量,他的值指示了数组的长度。申明一个类
的数组是一样的。申明和初始化可以,通常都是合到一条语句中:
[ ] x = newnewnew
intintint
intintint
Date [ ] dates = newnewnew
[howMany];
Date [howMany ];
- 5 -
当这些语句初始化 x 和 dates ,保留数组的存储空间时,只能以默认值初始化元素,这未
必有用。因为在单个元素使用之前可能需要单独的值(很可能使用 newnewnew
运 算 符 )。在 Date 类
外初始化的语法
Date( );
dates[0] = newnewnew
dates[0]. month = 1 ;
dates[0]. day =1 ;
dates[0]. year = newnewnew
dates[0]. year. number =2000;
true
dates[0]. year. isLeap =true
true
,
Data.Year( );
注意,域名字跟在指定数组元素的索引之后。在第二条 newnewnew
语句中,内部类名字,Year,
被外部类 Date 所限定,因为这条语句在类 Date 的外面。就像前面提到的,Java 程序员可以
写一个带参数的构造函数以初始化一个构造好的新对象,但是本文为了语言独立性不使用这
样的构造函数。
一旦用 newnewnew
的方法,x . length。实例域length 是 newnewnew
语句初始化了 x 数组,x 引用的数组长度就不能改变了。Java 提供查询长度
语句自动附加到数组对象上的,可以被 x 所存取。
有效的元素索引是从 0 到(x.length -1)。如果程序试图存取一个越界的元素,Java 将
[ n+1]
停止程序(抛出异常)。我们经常希望索引从 1 到 n,因此常常初始化数组为 " newnewnew
" 。
intintint
Java 允许重载 和覆盖方法。一个重载方法是说,有多个带不同参数的定义,但是有相
同的返回值。许多算术操作是重载的。覆盖则是在类体系中多次定义了有同样参数类型的同
一方法,Java 采用在类体系中最接近的定义。(还是为了和其他语言兼容,而且这个特性对
于理解算法也不是决定性的,我们避免使用这个特性,读者可以去阅读 Java 语 言的 书。)在
不同类中可能使用相同名字的方法,但是这不是覆盖,因为在类外面方法时,必须使用类名
(对象名)来限制。在后面的例子将看的更清楚 。
对于熟悉 C++的读者,必须指出 Java 不允许程序员重载运算符。本文在伪代码中使用
String
这样的运算符来增加可读性(例如,x < y 这里 x 和 y 都是非数值类,比如 String
String
)。 但 是 ,
如果你定义一个类,开发一个实际的 Java 程序,你就必须写一个有名字的函数(比如 less
()),调用他来比较你的类。
1.2.2
1.2.2
1.2.2
组织者类
我们创造术语组织者类,这不是标准的 Java 术语,他指一种只是为了将多个实例域组
织到一起的简单的类。组织者类实现类似 C 的结构、Pascal 或 Modula 记录的规则;类似的
东西存在于 Lisp、ML 和其他大部分程序设计语言中。组织者类和 ADT 的目的正好相反;
他们只是简单的组织数据,不限制数据的存取,也不为数据提供任何定制的方法。习惯于在
其他类中定义组织者类;由于这个原因,在 Java 术语中组织者类叫内部类。
一个组织者类仅有一个方法,叫 copy。既然 Java 中的变量都是对象的引用,赋值语句
仅拷贝引用,而不是对象的实例域,就像我们在例 1.1 中看到的。如果这些变量在一个叫
Date 的组织者类中定义,我们可以使用语句
noticDate = Date. copy( dueDate );
noticDate .day = dueDate .day -7 ;
- 6 -
来拷贝 dueDate 对象的实例域到 noticDate 所引用的新对象中,之后修改的只是 noticDate 的
day 域。
定义 1.1 组织者类的拷贝函数
组织者类将实例域赋值到一个新对象的 copy 函数(方法)的一般规
则如下(例子中假设对象 d 被拷贝到新对象 d2):
1. 如果实例域(year)是其他的组织者类,copy 方法将调用该
类的 copy 方法,如 d2.year = Year. copy(d . year )。
2. 如果实例域(day)不是其他组织者类,使用一般的赋值,
如同 d2 .day = d.day。
完整的例子在图 1.2 中给出。
Date
class
class
class
{
public
public
public
public
public
public
public
public
public
public
public
public
{
Year year;
intintint
month;
intintint
day;
static
static
static
class
class
class
Year
number;
intintint
boolean
boolean
boolean
static
static
static
isLeap;
Year copy(Year y)
public
public
public
public
public
public
public
public
public
{
Year y2 =newnewnew
Year( );
y2. number = y. number;
y2. isLeap = y. isLeap;
return
return
return
y2;
}
}
public
public
public
{
static
static
static
Date
copy(Date d)
Date d2 =new Date( );
d2. year = Year. copy ( d . year);
d2. month = d.month;
d2. day = d. day;
return
return
return
d2;
}
public
public
public
static
static
static
intintint
defaultCentury;
}
图 1.21.21.2
带内部组织者类 Year 的组织者类 Date
程序员必须保证在定义组织者类时不能发生循环引用,否则 copy 函数将陷入递归不能
结束。当然,组织者类中新对象可以由一般方法创建:
Date
someDate = newnewnew
Date ( )
- 7 -
Java语言提示:Java 提供一种一层对象的机制,不需要写出每一条
赋值语句,clone 方法,但是他不能自动处理像 Date 这种嵌套结构;你仍
然要写出处理这种情况的代码。附录 A 给出了一个 copy1level 函数的一
般性代码。
public
一个组织者类包含唯一一个 public
public
static
实例域。如果 static
static
关键字也出现在域的申明中,
该域不与任何实际对象相关,关键在于他不是一个全局变量了。
典型组织者类
例 1.21.21.2
在图 1.2 中,为例 1.1 的类增加了 copy 函数,所以他们有成为组织者类的资
格。就像我们看到的,copy 的定义是机械的,而且单调的。在后面的例子中将省
略实现的细节。为了更加完整,我们包含了全局变量 defaultCentury,而一般情况
组织者类是不包含全局变量的。
总结,我们创造了术语组织者类,他指那些只是简单将实例域组织起来,为他们定义一
个拷贝函数的类
1.2.3
1.2.3
1.2.3
Java
基于 Java
Java
的伪代码约定
本书中的大多数算法使用了基于 Java 的更易读的伪代码,而不是严格的 Java。使用了
下面的约定(除了在附录 A 中的以外)。
1. 省略了块分隔符("{" 和"}")。块边界用缩进指出。
static
2. 方法(函数或过程)申明中省略了 static
static
tictictic
(偶尔会有 Java 内建的非静态方法;特别是 s. length( ) 用来获得字符串的长度。)
关键字 static 会出现在需要实例域和内部类的地方。
关键字。本文中申明的所有方法都是 stastasta
3. 在调用方法(函数或过程)前面省略了类名字。例如 x = cons( z, x) ,用完整的 Java
语法需要写成 x= IntList. cons ( z, x) (IntList 类在 2.3.2 节中描述)。无论什么时候
静态方法在其定义的类之外调用都必须加上类名字前缀。
public
4. 省略存取控制的关键字 public
public
protecte
和 protecte
protecte
关的文件都放在一个目录下,省去了处理可见的麻烦。
private
、 private
private
。将所有与一个 Java 程序相
5. 经常使用使用数学关系运算符
,
,
来代替他们的关键字版本。关系运算符用在意
6.
String
义明确的地方,比如 String
String
Java 保留的和 Java 标准部分的关键字都以黑体: intintint
码语句和程序变量字体不变。伪代码语句的字体也不变。
,即使在 Java 中是非法的。
String
、String
String
。注释用斜体。代
当我们特别指出这是 Java 语言时会偶尔离开这些约定。
1.31.31.3
数学知识
数学知识
数学知识
本书中我们使用各种数学概念、工具和技术。大多数你应该熟悉了,可能会有少部分是
新的。本节一一列举他们,为你提供一个参考,也是一个回顾。设计较深的证明在第三章。
- 8 -