0-数据结构与算法(java语言版)_中文版


目录 第一章 Java与面向对象程序设计........................................................................................1 1.1 Java语言基础知识....................................................................................................1 1.1.1 基本数据类型及运算.......................................................................................1 1.1.2 流程控制语句...................................................................................................3 1.1.3 字符串...............................................................................................................3 1.1.4 数组...................................................................................................................5 1.2 Java的面向对象特性................................................................................................7 1.2.1 类与对象...........................................................................................................7 1.2.2 继承...................................................................................................................9 1.2.3 接口.................................................................................................................10 1.3 异常.........................................................................................................................11 1.4 Java与指针..............................................................................................................12 第二章 数据结构与算法基础.............................................................................................15 2.1 数据结构.................................................................................................................15 2.1.1 基本概念.........................................................................................................15 2.1.2 抽象数据类型.................................................................................................17 2.1.3 小结.................................................................................................................19 2.2 算法及性能分析.....................................................................................................19 2.2.1 算法.................................................................................................................19 2.2.2 时间复杂性.....................................................................................................20 2.2.3 空间复杂性.....................................................................................................24 2.2.4 算法时间复杂度分析.....................................................................................25 2.2.5 最佳、最坏与平均情况分析.........................................................................27 2.2.6 均摊分析.........................................................................................................29 第三章 线性表.....................................................................................................................32 3.1 线性表及抽象数据类型.........................................................................................32 3.1.1 线性表定义.....................................................................................................32 3.1.2 线性表的抽象数据类型.................................................................................32 3.1.3 List接口 ..........................................................................................................34 3.1.4 Strategy接口 ...................................................................................................35 3.2 线性表的顺序存储与实现.....................................................................................36 3.3 线性表的链式存储与实现.....................................................................................42 3.3.1 单链表.............................................................................................................42 3.3.2 双向链表.........................................................................................................46 3.3.3 线性表的单链表实现.....................................................................................48 3.4 两种实现的对比.....................................................................................................53 3.4.1 基于时间的比较.............................................................................................53 3.4.2 基于空间的比较.............................................................................................53 3.5 链接表.....................................................................................................................54 3.5.1 基于结点的操作.............................................................................................54 3.5.2 链接表接口.....................................................................................................54 3.5.3 基于双向链表实现的链接表.........................................................................56 1 3.6 迭代器.....................................................................................................................59 第四章 栈与队列.................................................................................................................62 4.1 栈.............................................................................................................................62 4.1.1 栈的定义及抽象数据类型.............................................................................62 4.1.2 栈的顺序存储实现.........................................................................................63 4.1.3 栈的链式存储实现.........................................................................................65 4.2 队列.........................................................................................................................66 4.2.1 队列的定义及抽象数据类型.........................................................................66 4.2.2 队列的顺序存储实现.....................................................................................68 4.2.3 队列的链式存储实现.....................................................................................71 4.3 堆栈的应用.............................................................................................................72 4.3.1 进制转换.........................................................................................................72 4.3.2 括号匹配检测.................................................................................................73 4.3.3 迷宫求解.........................................................................................................74 第五章 递归.........................................................................................................................78 5.1 递归与堆栈.............................................................................................................78 5.1.1 递归的概念.....................................................................................................78 5.1.2 递归的实现与堆栈.........................................................................................80 5.2 基于归纳的递归.....................................................................................................81 5.3 递推关系求解.........................................................................................................83 5.3.1 求解递推关系的常用方法.............................................................................83 5.3.2 线性齐次递推式的求解.................................................................................85 5.3.3 非齐次递推关系的解.....................................................................................86 5.3.4 Master Method ................................................................................................87 5.4 分治法.....................................................................................................................89 5.4.1 分治法的基本思想.........................................................................................89 5.4.2 矩阵乘法.........................................................................................................91 5.4.3 选择问题.........................................................................................................93 第六章 树.............................................................................................................................96 6.1 树的定义及基本术语.............................................................................................96 6.2 二叉树.....................................................................................................................99 6.2.1 二叉树的定义.................................................................................................99 6.2.2 二叉树的性质.................................................................................................99 6.2.3 二叉树的存储结构.......................................................................................101 6.3 二叉树基本操作的实现.......................................................................................105 6.4 树、森林...............................................................................................................112 6.4.1 树的存储结构...............................................................................................112 6.4.2 树、森林与二叉树的相互转换...................................................................114 6.4.3 树与森林的遍历...........................................................................................115 6.4.4 由遍历序列还原树结构...............................................................................116 6.5 Huffman树 ............................................................................................................117 6.5.1 二叉编码树...................................................................................................117 6.5.2 Huffman树及Huffman编码 ..........................................................................118 第七章 图...........................................................................................................................123 2 4.4 图的定义...............................................................................................................123 4.4.1 图及基本术语...............................................................................................123 4.4.2 抽象数据类型...............................................................................................127 4.5 图的存储方法.......................................................................................................129 4.5.1 邻接矩阵.......................................................................................................129 4.5.2 邻接表...........................................................................................................131 4.5.3 双链式存储结构...........................................................................................132 4.6 图ADT实现设计 ..................................................................................................138 4.7 图的遍历...............................................................................................................139 4.7.1 深度优先搜索...............................................................................................139 4.7.2 广度优先搜索...............................................................................................142 4.8 图的连通性...........................................................................................................143 4.8.1 无向图的连通分量和生成树.......................................................................143 4.8.2 有向图的强连通分量...................................................................................144 4.8.3 最小生成树...................................................................................................145 4.9 最短距离...............................................................................................................151 4.9.1 单源最短路径...............................................................................................151 4.9.2 任意顶点间的最短路径...............................................................................155 4.10 有向无环图及其应用...........................................................................................157 4.10.1 拓扑排序.......................................................................................................157 4.10.2 关键路径.......................................................................................................159 第八章 查找.......................................................................................................................164 8.1 查找的定义...........................................................................................................164 8.1.1 基本概念.......................................................................................................164 8.1.2 查找表接口定义...........................................................................................165 8.2 顺序查找与折半查找...........................................................................................165 8.3 查找树...................................................................................................................168 8.3.1 二叉查找树...................................................................................................168 8.3.2 AV L树...........................................................................................................175 8.3.3 B-树...............................................................................................................183 8.4 哈希.......................................................................................................................188 8.4.1 哈希表...........................................................................................................189 8.4.2 哈希函数.......................................................................................................190 8.4.3 冲突解决.......................................................................................................191 第九章 排序.......................................................................................................................194 9.1 排序的基本概念...................................................................................................194 9.2 插入类排序...........................................................................................................195 9.2.1 直接插入排序...............................................................................................195 9.2.2 折半插入排序...............................................................................................196 9.2.3 希尔排序.......................................................................................................197 9.3 交换类排序...........................................................................................................199 9.3.1 起泡排序.......................................................................................................199 9.3.2 快速排序.......................................................................................................200 9.4 选择类排序...........................................................................................................202 3 9.4.1 简单选择排序...............................................................................................202 9.4.2 树型选择排序...............................................................................................203 9.4.3 堆排序...........................................................................................................204 9.5 归并排序...............................................................................................................208 9.6 基于比较的排序的对比.......................................................................................209 9.7 在线性时间内排序...............................................................................................211 9.7.1 计数排序.......................................................................................................211 9.7.2 基数排序.......................................................................................................212 4 第一章 Java 与面向对象程序设计 在这一章中向读者简要介绍有关 Java 的基本知识。Java 语言是一种广泛使用并且具有 许多良好的如面向对象、可移植性、健壮性等特性的计算机高级程序设计语言,在这里对 Java 的介绍不可能面面俱到,因此在第一章中只对理解书中 Java 代码的相关知识进行介绍。 对于熟悉 Java 的读者可以不阅读本章。 1.1 Java 语言基础知识 1.1.1 基本数据类型及运算 在 Java 中每个变量在使用前均必须声明它的类型。Java 共有八种基本数据类型:四种 是整型,两种浮点型,一种字符型以及用于表示真假的布尔类型。各种数据类型的细节如表 1-1 所示。 表 1-1 Java 数据类型 类型 存储空间 范围 int 32 bit [-2147483648,2147483647] short 16 bit [-32768,32767] long 64 bit [-9223372036854775808, 9223372036854775807] byte 8 bit [-128,127] float 32 bit [-3.4E38,3.4E38] double 64 bit [-1.7E308,1.7E308] char 16 bit Unicode 字符 boolean 1 bit True,false 在声明一个变量时,应先给出此变量的类型,随后写上变量名。在声明变量时一行中可 以有声明多个变量,并且可以在声明变量的同时对变量进行初始化。例如: int i; double x, y = 1.2; char c = ‘z’; boolean flag; 在程序设计中,常常需要在不同的数字数据类型之间进行转换。图 1-1 给出了数字类型 间的合法转换。 1 图 1-1 数字类型间的合法转换 char byte short int double long float 图 1-1 中六个实箭头表示无信息损失的转换,而三个虚箭头表示的转换则可能会丢失精 度。 有时在程序设计中也需要进行在图 1-1 中没有出现的转换,在 Java 中这种数字转换也 是可以进行的,不过信息可能会丢失。在可能丢失信息的情况下进行的转换是通过强制类型 转换来完成的。其语法是在需要进行强制类型转换的表达式前使用圆括号,圆括号中是需要 转换的目标类型。例如: double x = 7.8; int n = (int)x; //x 等于 7 Java 使用常见的算术运算符+-*/进行加、减、乘、除的运算。当除法运算符/作用 于两个整数时,是进行整数除法。整数的模(即求余)运算使用 % 运算符。对整型变量一 种最常见的操作就是递增与递减运算,与 C/C++ 一样 Java 也支持递增和递减运算符。例如: int n = 7, m = 2; double d = 7; n = n / m; //n 等于 3 d /= m; //d等于 3.5 n--; //n等于 2 int a = 2 * n++; //a 等于 4 int b = 2 * ++m; //b 等于 6 此外 Java 还具有完备的关系运算符,如==(是否相等),<(小于),>(大于),<= (小于等于),>=(大于等于),!=(不等于);并且 Java 使用&&表示逻辑与,||表示逻辑 或,!表示逻辑非;以及七种位运算符&(与)、|(或)、^(异或)、~(非)、 >>(右移)、 <<(左移)、>>>(高位填充 0 的右移)。 最后 Java 还支持一种三元运算符 ?: ,这个运算符有时很有用。它的形式为 condition ? e1 : e2 这是一个表达式,在 condition 为 true 时返回值为 e1,否则为 e2。例如: min = x < y ? x : y; 则 min 为 x 与 y 中的较小值。 2 1.1.2 流程控制语句 计算机高级语言程序设计中共有三种流程结构,分别是:顺序、分支、循环。其中分支 与循环流程结构需要使用固定语法的流程控制语句来完成。 Java 中有两种语句可用于分支结构,一种是 if 条件语句,另一种是 switch 多选择语句。 条件语句的形式如下: if (condition) statement1 else statement2 当 if 后的条件 condition 的值为 true 时执行 statement1 中的语句,否则执行 statement2 中的语句。 多选择语句的形式为: switch (integer expression) { case value1: block1; break; case value2: block2; break; … case valueN: blockN; break; default: default block; } switch 语句从与选择值相匹配的 case 标签处开始执行,一直执行到下一个 break 处或者 switch 的末尾。如果没有相匹配的 case 标签,而且存在 default 子句,那么执行 default 子句。 如果没有相匹配的 case 标签,并且没有 default 子句,则结束 switch 语句的执行,执行 switch 后面的语句。 Java 中的循环语句主要有三种,分别是 for 循环、while 循环、do…while 循环。 for 循环的形式为: for (initialization; condition; increment) statement; for 语句的循环控制的第一部分通常是对循环变量的初始化,第二部分给出进行循环的 测试条件,第三部分则是对循环变量的更新。 while 循环的形式为: while (condition) statement; while 循环首先对循环条件进行测试,只有在循环条件满足的情况下才执行循环体。 do…while 循环的形式为: do statement while (condition); 与 while 循环不同的是,do…while 循环首先执行一次循环体,当循环条件满足时则继 续进行下一次循环。 1.1.3 字符串 字符串是指一个字符序列。在 Java 中没有内置的字符串类型,而是在标准 Java 库中包 含一个名为 String 的预定义类。每个被一对双引号括起来的字符序列均是 String 类的一个实 例。字符串可以使用如下方式定义。 3 String s1 = null; //s1 指向 NULL String s2 = ""; //s2 是一个不包含字符的空字符串 String s3 = "Hello"; Java 允许使用符号 + 把两个字符串连接在一起。当连接一个字符串和一个非字符串 时,后者将被转换成字符串,然后进行连接。例如: s3 = s3 + "World!"; //s3 为"HelloWorld!" String s4 = "abc" + 123; //s4 为"abc123" Java 的 String 类包含许多方法,其中多数均非常有用,在表 1-2 中只给出最常用的一些 方法。 表 1-2 Java String 类常用方法 char charAt(int index) Returns the char value at the specified index. int compareTo(String anotherString) Compares two strings lexicographically. int compareToIgnoreCase(String str) Compares two strings lexicographically, ignoring case differences. boolean endsWith(String suffix) Tests if this string ends with the specified suffix. boolean equals(Object anObject) Compares this string to the specified object. boolean equalsIgnoreCase(String anotherString) Compares this String to another String, ignoring case considerations. int indexOf(String str) Returns the index within this string of the first occurrence of the specified substring. int lastIndexOf(String str) Returns the index within this string of the rightmost occurrence of the specified substring. int length() Returns the length of this string. boolean startsWith(String prefix) Tests if this string starts with the specified prefix. String substring(int beginIndex) Returns a new string that is a substring of this string. String substring(int beginIndex, int endIndex) Returns a new string that is a substring of this string. char[] toCharArray() Converts this string to a new character array. 4 String toLowerCase() Converts all of the characters in this String to lower case using the rules of the default locale. String toString() This object (which is already a string!) is itself returned. String toUpperCase() Converts all of the characters in this String to upper case using the rules of the default locale. String trim() Returns a copy of the string, with leading and trailing whitespace omitted. 如果读者需要进一步了解有关String提供的其他方法及方法完成的功能,可以通过在线 API(应用程序接口)文档了解相关信息,从中你可以查到标准库中所有的类及方法。API 文档是Java SDK 的一部分,以HTML 格式显示。JDK1.5.0 的 API 文档地址为: http://java.sun.com/j2se/1.5.0/docs/api/index.html。 1.1.4 数组 数组是用来存放一组具有相同类型数据的数据结构。可以通过整型下标来访问数组中的 每一个值。数组是可以通过在某种数据类型后面加上[]来定义,在此之后跟上变量名就可以 定义相应类型的数组变量了。例如: int[] a; 还可以使用另一种方法定义数组,例如: int a[]; 以上这两种方法的定义是等价的。在这里只定义了一个整型数组变量 a,但是还没有将 a 真正的初始化为一个数组。为将一个数组初始化可以使用 new 关键字,也可以使用赋值语 句进行初始化。数组一旦被创建,就不能改变它的大小。 例如: a = new int[10]; //将 a 初始化为大小为 10 的整型数组。 int[] b = {0,1,2,3} //将 b 初始化为大小为 4 的整型数组, //并且 4 个分量的值分别等于 0,1,2,3 数组的下标从 0 开始计数,到数组大小减 1 结束。在 Java 中不能越过数组下标的范围 去访问数组中的数据。例如: a[10] = 10; 如果越过数组的下标访问数据,则会产生一个名为 ArrayIndexOutOfBoundsException 的 运行时错误。为避免产生这种错误,可以通过在访问某个下标的数组元素前检查数组的大小 来避免。数组的大小可以通过数组的变量 length 返回。例如: for (int i=0;i,x 是它的第一元素,y 是它的第二元素。 当使用图形来表示数据结构时,是用图形中的点来表示数据元素,用图形中的弧来表示 数据元素之间的关系。如果数据元素 x 与 y 之间有关系,则在图形中有从表示 x 的点 出发到达表示 y 的点的一条弧。 例 2-1 一种数据结构的二元组表示为 set = (K,R),其中 K = {01, 02, 03, 04, 05} R = {} 可以看到在数据结构 set 中,只有数据元素的集合非空,而数据元素之间除了同属一个 集合之外不存在任何关系(关系集合为空)。这表明该结构只考虑数据元素而不考虑它们之 间的关系。我们把具有这种特点的数据结构称为集合结构。 数据结构 set 的图形表示方法见图 2-1。 例 2-2 一种数据结构的二元组表示为 linearity = (K,R),其中 K = {01, 02, 03, 04, 05} R = {<02,04>, <03,05>, <05,02>, <01,03>} 可以看到在数据结构 linearity 中,数据元素之间是有序的。在这些数据元素中有一个可 以被称为“第一个”(元素 01)的数据元素;还有一个可以被称为“最后一个”(元素 04) 的数据元素;除第一个元素以外每个数据元素有且仅有一个直接前驱元素,除最后一个元素 以外每个数据元素有且仅有一个直接后续元素。这种数据结构的特点是数据元素之间是 1 对 1 的联系,即线性关系,我们把具有此种特点的数据结构称为线性结构。 数据结构 linearity 的图形表示方法见图 2-1。 例 2-3 一种数据结构的二元组表示为 tree = (K,R),其中 K = {01, 02, 03, 04, 05, 06} R = {<01,02>, <01,03>, <02,04>, <02,05>, <03,06>} 可以看到在数据结构 tree 中,除了一个数据元素(元素 01)以外每个数据元素有且仅 有一个直接前驱元素,但是可以有多个直接后续元素。这种数据结构的特点是数据元素之间 是 1 对 N 的联系,我们把具有此种特点的数据结构称为树结构。 数据结构 tree 的图形表示方法见图 2-1。 例 2-4 一种数据结构的二元组表示为 graph = (K,R),其中 K = {01, 02, 03, 04, 05} R = {<01,02>, <01,05>, <02,01>, <02,03>, <02,04>, <03,02>, <04,02>, <04,05>, <05,01>, <05,04>} 可以看到在数据结构 graph 中,每个数据元素可以有多个直接前驱元素,也可以有多个 直接后续元素。这种数据结构的特点是数据元素之间是 M 对 N 的联系,我们把具有此种特 点的数据结构称为图结构。 16 数据结构 graph 的图形表示方法见图 2-1。 图 2-1 set、linearity、tree、graph 的图形表示 01 03 05 02 04 set linearity tree 01 03 05 02 04 01 03 05 02 04 01 03 05 02 0406 graph 数据的存储结构主要包括数据元素本身的存储以及数据元素之间关系表示。通过数据元 素的定义可以看出,我们可以很容易的使用 Java 中的一个类来实现它,数据元素的数据项 就是类的成员变量。 数据元素之间的关系在计算机中主要有两种不同的表示方法:顺序映像和非顺序映像, 并由此得到两种不同的存储结构:顺序存储结构和链式存储结构。顺序存储结构的特点是: 数据元素的存储对应于一块连续的存储空间,数据元素之间的前驱和后续关系通过数据元素 在存储器中的相对位置来反映。链式存储结构的特点是:数据元素的存储对应的是不连续的 存储空间,每个存储节点对应一个需要存储的数据元素。元素之间的逻辑关系通过存储节点 之间的链接关系反映出来。 由于我们是在 Java 这种计算机高级程序设计语言的基础上来讨论数据结构,因此,我 们在讨论数据的存储结构时不会在真正的物理地址的基础上去讨论顺序存储和链式存储,而 是在 Java 语言提供的一维数组以及对象的引用的基础上去讨论和实现数据的存储结构。关 于 Java 中的一维数组和对象的引用我们已经在第一章 1.1.4 和 1.4 中分别进行了介绍,在这 里不再赘述。 2.1.2 抽象数据类型 抽象数据类型是描述数据结构的一种理论工具。在介绍抽象数据类型之前我们先介绍一 下数据类型的基本概念。 数据类型(data type)是一组性质相同的数据元素的集合以及加在这个集合上的一组操 作。例如 Java 语言中就有许多不同的数据类型,包括数值型的数据类型、字符串、布尔型 等数据类型。以 Java 中的 int 型为例,int 型的数据元素的集合是[-2147483648,2147483647] 间的整数,定义在其上的操作有加、减、乘、除四则运算,还有模运算等。 定义数据类型的作用一个是隐藏计算机硬件及其特性和差别,使硬件对于用户而言是透 明的,即用户可以不关心数据类型是怎么实现的而可以使用它。定义数据类型的另一个作用 17 是,用户能够使用数据类型定义的操作,方便的实现问题的求解。例如,用户可以使用 Java 定义在 int 型的加法操作完成两个整数的加法运算,而不用关心两个整数的加法在计算机中 到底是如何实现的。这样不但加快了用户解决问题的速度,也使得用户可以在更高的层面上 考虑问题。 与机器语言、汇编语言相比,高级语言的出现大大地简便了程序设计。但是要将解答问 题的步骤从非形式的自然语言表达到形式化的高级语言表达,仍然是一个复杂的过程,仍然 要做很多繁杂琐碎的事情,因而仍然需要抽象。 对于一个明确的问题,要解答这个问题,总是先选用该问题的一个数据模型。接着,弄清 该问题所选用的数据模型在已知条件下的初始状态和要求的结果状态,以及隐含着的两个状 态之间的关系。然后探索从数据模型的已知初始状态出发到达要求的结果状态所必需的运算 步骤。 我们在探索运算步骤时,首先应该考虑顶层的运算步骤,然后再考虑底层的运算步骤。 所谓顶层的运算步骤是指定义在数据模型级上的运算步骤,或叫宏观运算。它们组成解答问 题步骤的主干部分。其中涉及的数据是数据模型中的一个变量,暂时不关心它的数据结构; 涉及的运算以数据模型中的数据变量作为运算对象,或作为运算结果,或二者兼而为之,简 称为定义在数据模型上的运算。由于暂时不关心变量的数据结构,这些运算都带有抽象性质, 不含运算的细节。所谓底层的运算步骤是指顶层抽象的运算的具体实现。它们依赖于数据模 型的结构,依赖于数据模型结构的具体表示。因此,底层的运算步骤包括两部分:一是数据 模型的具体表示;二是定义在该数据模型上的运算的具体实现。我们可以把它们理解为微观 运算。于是,底层运算是顶层运算的细化,底层运算为顶层运算服务。为了将顶层算法与底 层算法隔开,使二者在设计时不会互相牵制、互相影响,必须对二者的接口进行一次抽象。 让底层只通过这个接口为顶层服务,顶层也只通过这个接口调用底层的运算。这个接口就是 抽象数据类型。 抽象数据类型(abstract data type, 简称 ADT)由一种数据模型和在该数据模型上的一 组操作组成。 抽象数据类型包括定义和实现两个方面,其中定义是独立于实现的。抽象数据类型的定 义仅取决于它的逻辑特性,而与其在计算机内部的实现无关,即无论它的内部结构如何变化, 只要它的逻辑特性不变,都不会影响到它的使用。其内部的变化(抽象数据类型实现的变化) 只是可能会对外部在使用它解决问题时的效率上产生影响,因此我们的一个重要任务就是如 何简单、高效地实现抽象数据类型。很明显,对于不同的运算组,为使组中所有运算的效率 都尽可能地高,其相应的数据模型具体表示的选择将是不同的。在这个意义下,数据模型的 具体表示又依赖于数据模型上定义的那些运算。特别是,当不同运算的效率互相制约时,还 必须事先将所有的运算的相应使用频度排序,让所选择的数据模型的具体表示优先保证使用 频度较高的运算有较高的效率。 我们应该看到,抽象数据类型的概念并不是全新的概念。抽象数据类型和数据类型在实 质上是一个概念,只不过是对数据类型的进一步抽象,不仅限于各种不同的计算机处理器中 已经实现的数据类型,还包括为解决更为复杂的问题而由用户自定义的复杂数据类型。例如 高级语言都有的“整数”类型就是一种抽象数据类型,只不过高级语言中的整型引进实现了, 并且实现的细节可能不同而已。我们没有意识到抽象数据类型的概念已经孕育在基本数据类 型的概念之中,是因为我们已经习惯于在程序设计中使用基本数据类型和相关的运算,没有 进一步深究而已。 抽象数据类型一方面使得使用它的人可以只关心它的逻辑特征,不需要了解它的实现方 式。另一方面可以使我们更容易描述现实世界,使得我们可以在更高的层面上来考虑问题。 例如可以使用树来描述行政区划,使用图来描述通信网络。 18 根据抽象数据类型的概念,对抽象数据类型进行定义就是约定抽象数据类型的名字,同 时,约定在该类型上定义的一组运算的各个运算的名字,明确各个运算分别要 有多少个参 数,这些参数的含义和顺序,以及运算的功能。一旦定义清楚,人们在使用时就可以像引用 基本数据类型那样,十分简便地引用抽象数据类型;同时,抽象数据类型的实现就有了设计 的依据和目标。抽象数据类型的使用和实现都与抽象数据类型的定义打交道,这样使用与实 现没有直接的联系。因此,只要严格按照定义,抽象数据类型的使用和实现就可以互相独立, 互不影响,实现对它们的隔离,达到抽象的目的。 为此抽象数据类型可以使用一个三元组来表示: ADT = (D, S, P) 其中 D 是数据对象,S 是 D 上的关系集,P 是加在 D 上的一组操作。 在定义抽象数据类型时,我们使用以下格式: ADT 抽象数据类型名{ 数据对象:<数据对象的定义> 数据关系:<数据关系的定义> 基本操作:<基本操作的定义> } 2.1.3 小结 通过以上两小节的内容我们可以看到数据结构就是研究三个方面的主要问题的:数据的 逻辑结构、数据的存储结构以及定义在数据结构上的一组操作。即研究按照某种逻辑关系组 织起来的一批数据,并按一定的映像方式把它们存放在计算机的存储器中,最后分析在这些 数据上定义的一组操作。为此我们要考虑怎样合理的组织数据,建立合适的结构,提高实现 的效率。 在数据结构的实现中我们可以很好的将数据结构中的一些基本概念和 Java 语言中的一 些概念对应起来。数据元素可以对应到类,其数据项就是类的成员变量,某个具体的数据元 素就是某个类的一个实例;数据的顺序存储结构与链式存储结构可以通过一维数组以及对象 的引用来实现;抽象数据类型也可以对应到类,抽象数据类型的数据对象与数据之间的关系 可以通过类的成员变量来存储和表示,抽象数据类型的操作则使用类的方法来实现。 2.2 算法及性能分析 算法设计是最具创造性的工作之一,人们解决任何问题的思想、方法和步骤实际上都可 以认为是算法。人们解决问题的方法有好有坏,因此算法在性能上也就有高低之分。在这一 节中我们首先给出算法的定义,然后介绍分析算法性能的理论方法。 2.2.1 算法 算法(algorithm)是指令的集合,是为解决特定问题而规定的一系列操作。它是明确 定义的可计算过程,以一个数据集合作为输入,并产生一个数据集合作为输出。一个算法通 常来说具有以下五个特性: z 输入:一个算法应以待解决的问题的信息作为输入。 19 z 输出:输入对应指令集处理后得到的信息。 z 可行性:算法是可行的,即算法中的每一条指令都是可以实现的,均能在有限的时 间内完成。 z 有穷性:算法执行的指令个数是有限的,每个指令又是在有限时间内完成的,因此 整个算法也是在有限时间内可以结束的。 z 确定性:算法对于特定的合法输入,其对应的输出是唯一的。即当算法从一个特定 输入开始,多次执行同一指令集结果总是相同的。 对于随机算法,算法的第五个特性应当被放宽。在本书中所讨论的算法均是确定性算法。 简单的说,算法就是计算机解题的过程。在这个过程中,无论是形成解题思路还是编写 程序,都是在实施某种算法。前者是算法的逻辑形式,后者是算法的代码形式。 2.2.2 时间复杂性 在计算机资源中,最重要的就是时间与空间。评价一个算法性能的好坏,实际上就是评 价算法的资源占用问题。在这一小节中我们讨论算法运行时间的确定问题,这个问题被称之 为算法的时间复杂性。关于算法的空间性能评价我们将在 2.2.3 中介绍。 本节以一个例子开始,通过它来说明如何去分析算法的运行时间。 例 2-5 简单选择排序: 令 A[0,n-1]为有 n 个数据元素的数组,我们的目标是将数组 A 排序为一个非降序的有序 数组。使用简单选择排序来解决这个问题的算法是:首先在 n 个元素中找到最小元素,将其 放在 A[0]中,然后在剩下的 n-1 个元素中找到最小的放到 A[1]中,这个过程不断进行下去, 直到在最后 2 个元素中找到小的并将其放到 A[n-2]中。 图 2-2 说明了对具有 7 个整数的数组使用简单选择排序的过程。 初始序列 第一趟排序 a[0] a[1] a[2] a[3] a[4] a[5] a[6] 12 23 9 24 15 3 18 k = 0 min = 5 第二趟排序 3 23 9 24 15 12 18 k = 1 min = 2 第三趟排序 3 9 23 24 15 12 18 k = 2 min = 5 第四趟排序 3 9 12 24 15 23 18 k = 3 min = 4 20 第五趟排序 3 9 12 15 24 23 18 k = 4 min = 6 第六趟排序 3 9 12 15 18 23 24 k = 5 min = 5 图 2-2 对 7 个整数简单选择排序过程 排序结束 3 9 12 15 18 23 24 算法 2-1 selectSort 输入:整型数组 a[0,n-1] 输出:按非降序排列的数组 a[0,n-1] 代码: public void selectSort (int[] a) { int n = a.length; for (int k=0; k 0,运行时间最多为cn2。也就是说Ο符号提供 了一个运行时间的上界。 定义 2-1 令 T(n)和 f(n)是非负函数,如果存在一个非负整数 N 以及一个常数 c>0,使得: )n(cf)n(T,Nn ≤≥∀ 则 T(n) = Ο(f(n))。 即如果 存在,那么 )n(f/)n(Tlimn ∞→ 22 ⇒∞≠ ∞→ )n(f )n(Tlim n T(n) = Ο(f(n)) 例如,算法2-1的时间复杂度 )1n(32 )1n(n)n(T −+−≤ ,由于当 时, , 则有 。或令 ,因为 2n ≥ 22n)n(T ≤ )n()n(T 2Ο= 2n)n(f = ∞≠=−+−≤ ∞→∞→ 2 1 n )1n(32/)1n(nlim)n(f )n(Tlim 2nn , 则有 。 )n()n(T 2Ο= „ Ω符号 Ο符号给出了算法时间复杂度的上界,而Ω符号在运行时间的常数因子范围内给出了时 间复杂度的下界。 Ω符号可以解释为:如果输入大于等于某个阈值 N,算法的运行时间下限是 f(n)的 c 倍, 其中 c 是一个正常数,则称算法的时间复杂度是Ω(f(n))的。Ω的形式定义与Ο符号对称。 定义 2-2 令 T(n)和 f(n)是非负函数,如果存在一个非负整数 N 以及一个常数 c>0,使得: )n(cf)n(T,Nn ≥≥∀ 则 T(n) = Ω(f(n))。 即如果 存在,那么 )n(f/)n(Tlimn ∞→ ⇒≠ ∞→ 0)n(f )n(Tlim n T(n) = Ω(f(n)) 例如,算法 2-1 的时间复杂度 2 )1n(n)n(T −≥ ,由于当 时,2n ≥ 2n4 1)n(T ≥ ,则有 。或令 ,因为)n()n(T 2Ω= 2n)n(f = 02 1 n 2/)1n(nlim)n(f )n(Tlim 2nn ≠=−≥ ∞→∞→ ,因此则有 。 )n()n(T 2Ω= „ θ符号 Ο符号给出了算法时间复杂度的上界,Ω符号给出了时间复杂度的下界,而θ给出了算 法时间复杂度的精确阶。 Θ符号可以解释为:如果输入大于等于某个阈值N,算法的运行时间在下限c1f(n)和上限 c2f(n)之间(00 和c2>0,使 23 得: )n(fc)n(T)n(fc,Nn 21 ≤≤≥∀ 则 T(n) = Θ(f(n))。 即如果 存在,那么 )n(f/)n(Tlimn ∞→ ⇒>= ∞→ 0)(c c)n(f )n(Tlim n T(n) = Θ(f(n)) 例如,算法 2-1 的时间复杂度 )1n(32 )1n(n)n(T2 1)-n(n −+−≤≤ ,由于当 时,2n ≥ 22 2n)n(Tn4 1 ≤≤ ,则有 。或令 ,因为)n()n(T 2Θ= 2n)n(f = 2 1 )n(f )n(Tlim n = ∞→ ,则有 。 )n()n(T 2Θ= 定义 2-3 的一个重要推论是 ,当且仅当 并且 )n()n(T 2Θ= )n()n(T 2Ο= )n()n(T 2Ω= 通过以上的分析我们可以看出:我们评价算法的运行时间是通过分析在一定规模下算法 执行基本操作的次数来反映的,并且由于我们只对大规模问题的运行时间感兴趣,所以是使 用算法的渐进时间复杂度 T(n)来度量算法的时间性能的。Ο、Ω、Θ符号分别定义了时间复 杂度的上界、下界以及精确阶。 2.2.3 空间复杂性 在上一小节中我们讨论了算法的时间复杂性,下面我们来讨论算法的空间复杂性。 算法的空间复杂性同样是由算法运行时使用的空间来评价的。我们把算法使用的空间定 义为:为了求解问题的实例而执行的操作所需要的存储空间的数目,但是它不包括用来存储 输入实例的空间。 同样前面对于评价算法时间复杂性的讨论都可以用于对算法的空间复杂性的讨论。并且 在这里有这样一个观察结论:算法的空间复杂性是以时间复杂性为上界的。这是因为在算法 中每访问一次存储空间都是需要使用一定时间的,即使每次访问的都是不同的存储空间,空 间的大小也不会超过基本操作次数常数倍,因此算法的空间复杂性是以时间复杂性为上界 的。 如果使用 S(n)与 T(n)分别表示算法的空间复杂度和时间复杂度,则有 。 ))n(T()n(S Ο= 例如在算法 2-1 中,为了算法的执行,我们使用了常数个中间变量,每个变量的存储空 间都是常数大小,所以在算法 2-1 中: )1()1()1()n(S Θ=Ω=Ο= 24 2.2.4 算法时间复杂度分析 我们不但要了解什么是算法时间复杂度,还要学会分析算法的时间复杂度。最简单的方 法就是将算法执行的所有基本操作都计算出来,然后得出算法的时间复杂度。但是很多时候 这种方法是不可取的,因为它太麻烦而且可能计算不出所有基本操作的执行次数。 一般来说,不存在固定的方法,使用它就可以得到一个算法的时间复杂度。但是在分析 算法的时间复杂度时有一些常用技术是可以使用的。 „ 计算循环次数 运行时间往往都是集中在循环中,循环之外往往是一些简单的具有常数基本操作的运 算,而这些常数次的基本操作在渐进时间复杂度中是不起作用的。这就使得运行时间往往和 循环的次数具有相同的阶。 下面给出几个使用这种方法分析算法时间复杂度的例子。 算法 2-2 function1 输入:正整数 n 输出:循环执行的总次数 代码: public int function1 (int n) { int i = 1, count = 0; while (i <= n) {i = i * 2; count++; } return count; } 例 2-6 分析上面的算法function1 的时间复杂度。在这里只有一层循环,假设循环k次, 循环每进行一次会执行常数个基本操作,因此算法的时间复杂度T(n) = Θ(k)。因为循环只 执行了k次,i在循环执行过程中的变化趋势为 1,2,4,8…2k,在执行完第k次循环后i=2k,所以 有 ⎣⎦1n logk2n2 k1-k +=⇒<≤ 由此可知 ⎣⎦ )n log()1n long()k()n(T Θ=+Θ=Θ= 。 算法 2-3 function2 输入:正整数 n 输出:循环执行的总次数 代码: public int function2 (int n) { int count=0, s=0; while (s1,则执行赋值的次数要多于从 1 开始的情况,因为在数组 大小被扩展到 m 之前是没有元素的移动过程的。因此 m=1 时的运行时间是最多的,它是当 m 取其他值时运行时间的上界。 在算法中使用频度最高的是赋值操作,因此我们对元素赋值的次数进行计数,结果可以 反映算法的时间复杂度。 假设加入的数据元素的个数为 n。则每个元素最多被移动 n 次,所以 n 个元素加入数组 总的时间 。这是加入 n 个元素运行时间的上界,但是这个上界不够紧密,我 们可以通过均摊分析得到更紧密的上界。分析如下。 )n()n(T 2Ο= 当 m=1 时,算法的执行情况可用图 2-3 表示。 图 2-3 数组倍增过程 1 2 3 4 5 6 7 1 1 2 1 2 3 4 1 2 3 4 5 6 7 o.f o.f o.f o.f 指溢出 为计算加入n个元素需要元素赋值的次数,先定义变量Ci,其值为加入第i个元素时元素 赋值的次数。通过图 2-3 可以得出 29 Ci = 1 其他 i i–1 为 2 的整数次幂 表 2-1 反映了图 2-3 所示过程中数组大小及Ci变化的情况。 得到: ,因此 n 个元素加入数组总的时间 。这个时间均摊到每个元素,则存储和移动每个元素代价是Θ(1) 均摊时间。 ⎣⎦ 3n2nC )1n(log 0i i n 1j j ≤+= ∑∑ − == )n()n()n(T 2Ο<<Θ= i 1 2 3 4 5 6 7 8 9 10 size 1 2 4 4 8 8 8 8 16 16 Ci 1 2 3 1 5 1 1 1 9 1 1 1 1 1 1 1 1 1 1 1 Ci 1 2 4 8 表 2-1 Ci 随 i 变化情况 有时在对一个算法进行均摊分析时,不能像上面那样能够求出每次计算的时间,因此无 法使用求和的方法来得出 n 次计算的时间总和,然后再均摊到每次计算上。这时可以使用资 源预留的方法进行分析。 例 2-12 考虑下面的算法。有一个 n 个正整数的数组 a[0, n-1]作为输入,同时生成一个 大小与 a 相同的数组 array,然后依次处理 a 中每个元素:如果当前的 a[i]是奇数则直接添加 到 array 中最后一个元素后面;如果是偶数,则从 array 中最后一个元素开始,向前依次删 除所有的奇数。这个过程可以通过图 2-4 说明。 图 2-4 数组 a 元素处理过程 (a) (b) (d) (e) (c) (f) (g) 输入数组 a 2 3 5 8 4 7 3 2 数组 array 变化情况: 2 3 2 8 2 8 4 7 2 8 4 2 3 5 2 8 4 7 3 代码: 30 public void function4 (int[] a ) { int p = 0, n = a.length; int[] array = new int[n]; for (int i=0; i0 && array[p-1]%2!=0) array[p--] = 0; //删除前面的奇数 array[p++] = a[i]; } return ; } 现在来分析一下算法的时间复杂度。在某些情况下,例如n/2 个奇数后面跟着一个偶数, 那么while循环要执行Ο(n)次,这里for循环要执行n次,因此总的运行时间是Ο(n2)。同样如 果我们使用均摊分析,可以得出复杂性为Θ(n)。 这里共有这样一些基本操作:添加、删除元素。但是在每一次for循环执行过程中不知 道到底删除了多少个数据元素,因此无法如同例 2-11 那样计算出Ci,也就无法直接求出n次 计算执行基本操作的总次数。 此时我们可以采用资源预留的分析方法,在进行每一次计算时,假设我们都为该次计算 预留一定的时间。假设第 i 次计算执行 次基本操作,只要保证不等式 成立, 则 就是算法的时间上界。 ' iC ∑∑ == ≤ n 1i ' i n 1i i CC ∑ = n 1i ' iC 在本例中可以设 ,其中一次操作用于元素的添加操作,一次用于元素的删除操 作。由于偶数只执行添加操作,而每个奇数最多进行一次添加和一次删除操作,因此 , 。把这个时间均摊到每个元素上,则每个元素的操作 时间为Θ(1),即 while 语句的均摊时间是Θ(1)。 2C' i = ∑∑ == =≤ n 1i ' i n 1i i 2nCC )n()n(T Θ= 31 第三章 线性表 线性结构是最简单,也是最常用的数据结构之一。线性结构的特点是:在数据元素的有 限集中,除第一个元素无直接前驱,最后一个元素无直接后续以外,每个数据元素有且仅有 一个直接前驱元素和一个直接后续元素。在这一章中主要介绍线性表的基本概念、定义线性 表的抽象数据类型;在给出线性表的顺序存储结构和链式存储结构的基础上,分别给出线性 表抽象数据类型的实现。 3.1 线性表及抽象数据类型 3.1.1 线性表定义 线性表(linear list)是n个类型相同数据元素的有限序列,通常记作(a0, a1, …ai-1, ai, ai+1 …, an-1 )。 在线性表的定义中,我们看到从a0到an-1的n个数据元素是具有相同属性的元素。比如说 可以都是数字,例如(23, 14, 66, 5, 99);也可以是字符,例如(A, B, C, … Z);当然也可以是 具有更复杂结构的数据元素,例如每个数据元素可以是我们在前面定义过的学生这种类的一 个实例。 在线性表的相邻数据元素之间存在着序偶关系,即ai-1是ai的直接前驱,则ai是ai-1的直接 后续,同时ai又是ai+1的直接前驱,ai+1是ai的直接后续。唯一没有直接前驱的元素a0一端称为 表头,唯一没有后续的元素an-1一端称为表尾。 线性表中数据元素的个数n定义为线性表的长度,当n=0 时线性表为空表。在非空的线 性表中每个数据元素在线性表中都有唯一确定的序号,例如a0的序号是 0,ai的序号是i。在 一个具有n > 0 个数据元素的线性表中,数据元素序号的范围是[0, n-1]。 在这里特别需要注意的是线性表和数组的区别。从概念上来看,线性表是一种抽象数据 类型;数组是一种具体的数据结构。线性表与数组的逻辑结构是不一样的,线性表是元素之 间具有1对1的线性关系的数据元素的集合,而数组是一组数据元素到数组下标的一一映射。 并且从物理性质来看,数组中相邻的元素是连续地存储在内存中的;线性表只是一个抽象的 数学结构,并不具有具体的物理形式,线性表需要通过其它有具体物理形式的数据结构来实 现。在线性表的具体实现中,表中相邻的元素不一定存储在连续的内存空间中,除非表是用 数组来实现的。对于数组,可以利用其下标在一个操作内随机存取任意位置上的元素;对于 线性表,只能根据当前元素找到其前驱或后继,因此要存取序号为i的元素,一般不能在一 个操作内实现,除非表是用数组实现的。 线性表是一种非常灵活的数据结构,线性表可以完成对表中数据元素的访问、添加、删 除等操作,表的长度也可以随着数据元素的添加和删除而变化。 3.1.2 线性表的抽象数据类型 下面我们给出线性表的抽象数据类型定义。 ADT List { 32 数据对象:D = {ai | ai∈D0,i=0, 1, 2…n-1,D0为某一数据对象} 数据关系:R = { | ai, ai+1∈D,i=0, 1, 2 … n-2} 基本操作: 序号 方法 功能描述 ⑴ getSzie () 输入参数:无 返回参数:非负整数 功能:返回线性表的大小,即数据元素的个数。 ⑵ isEmpty () 输入参数:无 返回参数:boolean 功能:如果线性表为空返回 true,否则返回 false。 ⑶ contains ( e ) 输入参数:Object 对象 e 返回参数:boolean 功能:判断线性表是否包含数据元素 e,包含返回 true,否则返 回 false。 ⑷ indexOf ( e ) 输入参数:Object 对象 e 返回参数:整数 功能:返回数据元素 e 在线性表中的序号。如果 e 不存在则返 回-1。 ⑸ insert ( i, e ) 输入参数:非负整数 i(序号),Object 对象 e 返回参数:无 功能:将数据元素 e 插入到线性表中 i 号位置。若 i 越界,报错。 ⑹ insertBefore (p, e) 输入参数:Object 对象 p,Object 对象 e 返回参数:boolean 功能:将数据元素 e 插入到元素 p 之前。成功返回 true,否则 返回 false。 ⑺ insertAfter (p, e) 输入参数:Object 对象 p,Object 对象 e 返回参数:boolean 功能:将数据元素 e 插入到元素 p 之后。成功返回 true,否则 返回 false。 ⑻ remove ( i ) 输入参数:非负整数 i(序号) 返回参数:Object 对象 功能:删除线性表中序号为 i 的元素,并返回之。若 i 越界, 报错。 ⑼ remove (e) 输入参数:Object 对象 e 返回参数:boolean 功能:删除线性表中第一个与 e 相同的元素。成功返回 true, 否则返回 false。 ⑽ replace (i, e) 输入参数:非负整数 i(序号),Object 对象 e 返回参数:Object 对象 功能:替换线性表中序号为 i 的数据元素为 e,返回原数据元素。 若 i 越界,报错。 ⑾ get ( i ) 输入参数:非负整数 i(序号) 返回参数:Object 对象 功能:返回线性表中序号为 i 的数据元素。若 i 越界,报错。 33 } ADT List 在上述抽象数据类型的定义中,我们定义了 11 种操作,然而对于线性表的操作并不仅 限于上述的操作,根据实际情况的需要还可以定义更多更复杂的操作。例如,将两个线性表 合并为一个更大的线性表;把一个线性表分成两个线性表;对现有线性表进行复制等。 3.1.3 List 接口 通过 2.1.3 中的内容我们知道:抽象数据类型可以对应到 Java 中的类,抽象数据类型的 数据对象与数据之间的关系可以通过类的成员变量来存储和表示,抽象数据类型的操作则使 用类的方法来实现。 下面先不考虑如何完成数据对象以及数据之间关系的存储和表示,我们考虑如何将抽象 数据类型所提供的操作使用 Java 语言给出明确的定义。事实上对抽象数据类型提供的操作 使用高级语言进行定义,就是给出其应用程序接口,在 Java 中我们可以使用一个接口来进 行定义。如此在使用类来完成抽象数据类型的具体实现时,我们只要实现相应的接口就实现 了对抽象数据类型定义的操作的实现。 为此我们给出抽象数据类型 List 的 Java 接口。 代码 3-1 List 接口 public interface List { //返回线性表的大小,即数据元素的个数。 public int getSize(); //如果线性表为空返回 true,否则返回 false。 public boolean isEmpty(); //判断线性表是否包含数据元素 e public boolean contains(Object e); //返回数据元素 e 在线性表中的序号 public int indexOf(Object e); //将数据元素 e 插入到线性表中 i 号位置 public void insert(int i, Object e) throws OutOfBoundaryException; //将数据元素 e 插入到元素 obj 之前 public boolean insertBefore(Object obj, Object e); //将数据元素 e 插入到元素 obj 之后 public boolean insertAfter(Object obj, Object e); //删除线性表中序号为 i 的元素,并返回之 public Object remove(int i) throws OutOfBoundaryException; //删除线性表中第一个与 e 相同的元素 public boolean remove(Object e); //替换线性表中序号为 i 的数据元素为 e,返回原数据元素 public Object replace(int i, Object e) throws OutOfBoundaryException; //返回线性表中序号为 i 的数据元素 public Object get(int i) throws OutOfBoundaryException; } 在上述 List 接口的定义中我们将数据元素的类型定义为 Object 类型,做这样的定义的 原因是:在 Java 中 Object 类是其他所有类的父类,因此其他任何类的引用或者说任何类类 型的变量都可以赋给 Object 类型的变量,这样我们实现的抽象数据类型就可以对任何一种 34 数据元素都适用,而不用对每一种不同类型的数据元素给出不同的实现。也就是说我们将要 实现的线性表可以存放任何一种数据元素。 其次,在 List 接口的定义中使用了异常。这是因为在某些操作的实现过程中,会出现 各种错误的情况。这些错误可能是用户的要求无法实现,例如线性表已经为空,但是用户仍 然调用删除数据元素的方法,那么此时删除操作是无法实现的;又或者用户在使用这些操作 时出现了错误,例如线性表虽然不为空,但是用户在调用 get( int i )方法时指定的序号 i 超过 了范围,此时也会出错。因此在定义接口时还要对各种可能出现的错误条件,定义相应的异 常。异常的定义可以通过继承 java.lang.Exception 或其子类来实现。 代码 3-2 OutOfBoundaryException 异常 //线性表中出现序号越界时抛出该异常 public class OutOfBoundaryException extends RuntimeException{ public OutOfBoundaryException(String err){ super(err); } } 3.1.4 Strategy 接口 在 List 接口方法定义中,我们将所有数据元素的类型都使用 Object 来替代,这样做是 为了程序的通用性,即一种抽象数据类型的实现可以用于所有数据元素。但是这样做带来了 另一个需要解决的问题,即完成数据元素之间比较大小或是否相等的问题。在使用 Object 类型的变量指代了所有数据类型之后,那么所有种类的数据元素的比较就都需要使用 Object 类型的变量来完成,但是不同数据元素的比较方法或策略是不一样的。例如字符串的比较是 使用 java.lang.String 类的 compareTo 和 equals 方法;而基本的数值型数据是使用关系运算符 来完成的;其他各种不同的类的比较方法就更加千差万别多种多样了,即使同一个类的比较 方法在不同的情况下也会不同,例如两个学生之间的比较有时可以用学号的字典顺序来进 行,有时可能又需要使用成绩来比较。因此我们无法简单的在两个 Object 类型的变量之间 使用"= ="、"<"等关系运算符来完成各种不同数据元素之间的比较操作,同时 Java 也不提供 运算符的重载,为此我们引入 Strategy 接口。 使用 Strategy 接口可以实现各种不同数据元素相互之间独立的比较策略。在实现各种抽 象数据类型时,例如线性表,可以使用 Strategy 接口变量来完成形式上的比较,然后在创建 每个抽象数据类型的实例时,例如一个具体的用于学生的线性表时,可以引入一个实际的实 现了 Strategy 接口的策略类对象,例如实现了 Strategy 接口的学生比较策略类对象。使用这 一策略的另一优点在于,一旦不想继续使用原先的比较策略对象,随时可以使用另一个比较 策略对象将其替换,而不同修改抽象数据类型的具体实现。 按上述方案我们给出相应的代码。 代码 3-3 Strategy 接口 public interface Strategy { //判断两个数据元素是否相等 public boolean equal(Object obj1, Object obj2); /** * 比较两个数据元素的大小 * 如果 obj1 < obj2 返回-1 * 如果 obj1 = obj2 返回 0 35 * 如果 obj1 > obj2 返回 1 */ public int compare(Object obj1, Object obj2); } 例如对于学生可以给出以下比较策略。 代码 3-4 学生比较策略 public class StudentStrategy implements Strategy { public boolean equal(Object obj1, Object obj2) { if (obj1 instanceof Student && obj2 instanceof Student) { Student s1 = (Student)obj1; Student s2 = (Student)obj2; return s1.getSId().equals(s2.getSId()); } else return false; } public int compare(Object obj1, Object obj2) { if (obj1 instanceof Student && obj2 instanceof Student) { Student s1 = (Student)obj1; Student s2 = (Student)obj2; return s1.getSId().compareTo(s2.getSId()); } else return obj1.toString().compareTo(obj2.toString()); } } 3.2 线性表的顺序存储与实现 线性表的顺序存储是用一组地址连续的存储单元依次存储线性表的数据元素。假设线性 表的每个数据元素需占用K个存储单元,并以元素所占的第一个存储单元的地址作为数据元 素的存储地址。则线性表中序号为i的数据元素的存储地址LOC(ai)与序号为i+1 的数据元素 的存储地址LOC(ai+1)之间的关系为 LOC(ai+1) = LOC(ai) + K 通常来说,线性表的i号元素ai的存储地址为 LOC(ai) = LOC(a0) + i×K 其中LOC(a0)为 0 号元素a0的存储地址,通常称为线性表的起始地址。 线性表的这种机内表示称作线性表的顺序存储。它的特点是,以数据元素在机内存储地 址相邻来表示线性表中数据元素之间的逻辑关系。每一个数据元素的存储地址都和线性表的 起始地址相差一个与数据元素在线性表中的序号成正比的常数。由此,只要确定了线性表的 起始地址,线性表中的任何一个数据元素都可以随机存取,因此线性表的顺序存储结构是一 种随机的存储结构。线性表的顺序存储可用图 3-1 描述。 36 图 3-1 线性表的顺序存储 Loc(a0) Loc(a0) + K … … Loc(a0) + i×K Loc(a0) + (n-1)×K a0 a1 ai … an-1 … 内存状态 存储地址 数据元素的序号 0 1 … i … n-1 空闲 由于高级语言中的数组具也有随机存储的特性,因此在抽象数据类型的实现中都是使用 数组来描述数据结构的顺序存储结构。通过图 3-1,我们看到线性表中的数据元素在依次存 放到数组中的时候,线性表中序号为 i 的数据元素对应的数组下标也为 i,即数据元素在线 性表中的序号与数据元素在数组中的下标相同。 在这里需要注意的是,如果线性表中的数据元素是对象时,数组存放的是对象的引用, 即线性表中所有数据元素的对象引用是存放在一组连续的地址空间中。如图 3-2 所示。 图 3-2 数组存储对象引用 … 0 1 … i-1 i i+1 … …… … n-1 len-1 数组 下标 a1 a0 ai-1 ai ai+ 1an-1 … 由于线性表的长度可变,不同的问题所需的最大长度不同,那么在线性表的具体实现中 我们是使用动态扩展数组大小的方式来完成线性表长度的不同要求的。在第二章的例 2-11 中我们看到了使用动态扩展数组大小的方式来实现这一存储策略的方法,以及动态扩展数组 方法的时间复杂度,对于每一个元素而言其均摊时间复杂度为Θ(1)。因此在分析算法时间 复杂度时,使用动态扩展数组只会给算法增加常数运行时间。 在使用数组实现线性表的操作中,经常会碰到在数组中进行数据元素的查找、添加、删 除等操作,下面我们先讨论如何在数组中实现上述操作。 在数组中进行查找,最简单的方法就是算法 2-5 描述的顺序查找,其平均时间复杂度是 Θ(n)。这些在算法 2-5 及例 2-10 中已经详细分析,在这里不再赘述。 在数组中添加数据元素,通常是在数组中下标为 i (0 ≤ i ≤ n)的位置添加数据元素,而将 原来下标从 i 开始的数组中所有后续元素依次后移。整个操作过程可以通过图 3-3 说明。 37 图 3-3 在数组下标 i 处插入元素 e 0 1 … i-1 i i+1 … n len-1 i+2下标 a0 a1 … ai-1 ai ai+ 1 0 1 … i-1 i i+1 … an-1 …… … n-1 len-1 a0 a1 … ai-1 ai ai+ 1 … an-1 …… e 插入前 插入后 下标 使用 Java 语言实现整个操作过程的关键语句是 for (int j=n; j>i; j--) a[j] = a[j-1]; a[i] = e; 如果要对上述操作的执行时间进行分析,那么对于不同下标处的插入,情况会有所不同。 我们主要关心平均情况下的运行时间,并假设在数组下标[0, n]范围内任何一个位置 i 处插入 数据元素的概率是相等的。即 [] 1n 1kiP,n] [0,k +==∈∀ 假设Ci是在数组下标为i的地方插入数据元素时需要移动数据元素的次数,那么Ci = n - i, 0 ≤ i ≤ n。此时,在数组下标i处插入一个数据元素需要移动数据元素的平均次数是: []() )n(2 n 2 )1n(n n 1)i-n(1n 1iPCi)n(T n 0i n 0i Θ==+=+=⋅= ∑∑ == 因此,在一个具有 n 个数据元素的数组中插入一个数据元素的平均时间复杂度为Θ(n)。 与在数组中添加数据元素相对的是在数组中删除数据元素,与添加类似,删除操作也通 常是删除数组中下标为 i (0 ≤ i < n)的元素,然后将数组中下标从 i+1 开始的所有后续元素依 次前移。删除操作过程也可以通过图 3-4 说明。 图 3-4 在数组下标 i 处删除元素 0 1 … i-1 i n-1… n-2 len-1 下标 a0 a1 … ai-1 ai ai+ 1 0 1 … i-1 i i+1 … an-1 …… … n-1 len-1 a0 a1 … ai-1 ai+ 1 … an-1 …… 删除前 删除后 下标 使用 Java 语言实现整个操作过程的关键语句是 38 for (int j=i; jsize) throw new OutOfBoundaryException("错误,指定的插入序号越界。"); if (size >= elements.length) expandSpace(); for (int j=size; j>i; j--) elements[j] = elements[j-1]; elements[i] = e; size++; return; } private void expandSpace(){ Object[] a = new Object[elements.length*2]; for (int i=0; i=size) throw new OutOfBoundaryException("错误,指定的删除序号越界。"); Object obj = elements[i]; for (int j=i; j=size) throw new OutOfBoundaryException("错误,指定的序号越界。"); Object obj = elements[i]; elements[i] = e; return obj; } //返回线性表中序号为 i 的数据元素 public Object get(int i) throws OutOfBoundaryException { if (i<0||i>=size) throw new OutOfBoundaryException("错误,指定的序号越界。"); return elements[i]; } } 代码 3-5 说明:在 ArrayList 类中共有 4 个成员变量,其中 elements 数组以及 size 用于 存储线性表中的数据元素以及表明线性表中数据元素的个数;而 strategy 是用来完成线性表 中数据元素的比较操作的策略;LEN 是 elements 数组的初始默认大小,数组的大小在后续 的插入操作中可以会发生变化。 算法 getSize()、isEmpty()、replace(int i, Object e)、get(int i)的时间复杂度均为Θ(1)。通 过成员变量 size 可以直接判断出线性表中数据元素的个数以及线性表是否为空。这里使用 数组来实现线性表,由于数组具有随机存取的特性,因此获取线性表中序号为 i 的数据元素 或对其进行替换均可在常数时间内完成。 41 算法contains(Object e)、indexOf(Object e)主要是在线性表中查找某个数据元素,它们与 算法 2-5 linearSearch类似,只是存在查找可能会出现不成功的情况。此时可以假设在具有n 个数据元素的线性表中包含一个本不属于线性表的数据元素an+1,如果把查找不成功的情况 对应为查找本不属于线性表的数据元素an+1,则上述两个算法数组实现的平均时间复杂度可 以对应为在具有n+1 个数据元素的数组中查找成功的情况(这时查找不成功的机率为 1/(n+1)),算法运行时间T(n)= ((n+1)+1)/2 ≈ n/2。即完成上述操作需要比较数组中近一半的 元素。 算法 insert(int i, Object e)、remove(int i)主要是按照线性表中的序号来完成数据元素的插 入与删除。在使用数组实现时,算法的时间复杂度在对数组基本操作的分析中已经说明,要 完成这些操作平均要移动数组中大约一半的数据元素。并且如果在插入数据元素的过程中, 出现了数组空间的扩展,通过前面的均摊分析我们知道对于每个数据元素而言需要的时间是 常数,因此算法的运行时间 T(n)≈n/2。 算法 insertBefore(Object obj, Object e)、insertAfter(Object obj, Object e)、remove(Object e) 是按照线性表中的某个特定数据元素来完成数据元素的插入、删除操作。此时算法可以看成 由两个部分组成,首先需要在线性表中找到对应的数据元素,然后按照数据元素在线性表中 的位置来完成相应的插入和删除操作。在使用数组实现时,算法的运行时间 T(n) ≈ n。下面 以算法 insertBefore 为例来说明:如果 p 不存在于数组中,则整个算法需要进行 n 次比较 0 次移动;如果 p 存在于数组中,并且对应的数组下标为 i,则第一步需要进行 i+1 次比较才 能找到 p,第二步需要依次后移从 i 开始的 n-i 个数据元素,因此整个算法的运行时间 T(n) = (i+1) + (n-i) = n+1 ≈ n。同样在插入的过程中如果出现了数组空间的扩展,对于每个元素而言 只会增加常数时间,不会对算法的运行时间造成实质性的影响。 3.3 线性表的链式存储与实现 实现线性表的另一种方法是链式存储,即用指针将存储线性表中数据元素的那些单元依 次串联在一起。这种方法避免了在数组中用连续的单元存储元素的缺点,因而在执行插入或 删除运算时,不再需要移动元素来腾出空间或填补空缺。然而我们为此付出的代价是,需要 在每个单元中设置指针来表示表中元素之间的逻辑关系,因而增加了额外的存储空间的开 销。 上述实现方法实际上就是使用链表来实现线性表。而链表有许多不同的形式,在这一节 中首先介绍两种重要的链表及其操作特点,然后给出一种线性表的链表实现。 3.3.1 单链表 链表是一系列的存储数据元素的单元通过指针串接起来形成的,因此每个单元至少有两 个域,一个域用于数据元素的存储,另一个域是指向其他单元的指针。这里具有一个数据域 和多个指针域的存储单元通常称为结点(node)。 图 3-5 单链表结点结构 data next 数据域 指针域 42 一种最简单的结点结构如图 3-5 所示,它是构成单链表的基本结点结构。在结点中数据 域用来存储数据元素,指针域用于指向下一个具有相同结构的结点。 在 Java 中没有显式的指针类型,然而实际上对象的访问就是使用指针来实现的,即在 Java 中是使用对象的引用来替代指针的。因此在使用 Java 实现该结点结构时,一个结点本 身就是一个对象。结点的数据域 data 可以使用一个 Object 类型的对象来实现,用于存储任 何类型的数据元素,并通过对象的引用指向该元素;而指针域 next 可以通过节点对象的引 用来实现。 由于数据域存储的也是对象引用,因此数据实际上和图 3-2 中一样,是通过指向数据的 物理存储地址来完成存储的,但是在后面叙述的方便,我们在图示中都将数据元素直接画到 了数据域中,请读者注意实际的状态与之是有区别的。 上面的单链表结点结构是结点的一种最简单的形式,除此之外还有其他不同的结点结 构,但是这些结点结构都有一个数据域,并均能完成数据元素的存取。为此在使用 Java 定 义单链表结点结构之前先给出一个结点接口,在接口中定义了所有结点均支持的操作,即对 结点中存储数据的存取。代码 3-6 定义了结点接口。 代码 3-6 结点接口 public interface Node { //获取结点数据域 public Object getData(); //设置结点数据域 public void setData(Object obj); } 在给出结点接口定义之后,单链表的结点定义就可以通过实现结点接口来完成。代码 3-7 给出了单链表结点的定义。 代码 3-7 单链表结点定义 public class SLNode implements Node { private Object element; private SLNode next; public SLNode() { this(null,null); } public SLNode(Object ele, SLNode next){ this.element = ele; this.next = next; } public SLNode getNext(){ return next; } public void setNext(SLNode next){ this.next = next; } /**************** Methods of Node Interface **************/ public Object getData() { 43 return element; } public void setData(Object obj) { element = obj; } } 单链表是通过上述定义的结点使用 next 域依次串联在一起而形成的。一个单链表的结 构如图 3-6 所示。 图 3-6 单链表结构 a0 a1 a2 a3 a4 ∧ head tail 链表的第一个结点和最后一个结点,分别称为链表的首结点和尾结点。尾结点的特征是 其 next 引用为空(null)。链表中每个结点的 next 引用都相当于一个指针,指向另一个结点, 借助这些 next 引用,我们可以从链表的首结点移动到尾结点。如此定义的结点称为单链表 (single linked list)。在单链表中通常使用 head 引用来指向链表的首结点,由 head 引用可 以完成对整个链表中所有节点的访问。有时也可以根据需要使用指向尾结点的 tail 引用来方 便某些操作的实现。 在单链表结构中还需要注意的一点是,由于每个结点的数据域都是一个 Object 类的对 象,因此,每个数据元素并非真正如图 3-4 中那样,而是在结点中的数据域通过一个 Object 类的对象引用来指向数据元素的。 与数组类似,单链表中的结点也具有一个线性次序,即如果结点 P 的 next 引用指向结 点 S,则 P 就是 S 的直接前驱,S 是 P 的直接后续。单链表的一个重要特性就是只能通过前 驱结点找到后续结点,而无法从后续结点找到前驱结点。在单链表中通常需要完成数据元素 的查找、插入、删除等操作。下面我们逐一讨论这些操作的实现。 在单链表中进行查找操作,只能从链表的首结点开始,通过每个结点的 next 引用来一 次访问链表中的每个结点以完成相应的查找操作。例如需要在单链表中查找是否包含某个数 据元素 e,则方法是使用一个循环变量 p,起始时从单链表的头结点开始,每次循环判断 p 所指结点的数据域是否和 e 相同,如果相同则可以返回 true,否则继续循环直到链表中所有 结点均被访问,此时 p 为 null。该过程如图 3-7 所示。 图 3-7 在单链表中查找元素 a0 a1 a2 a3 a4 ∧ head p p p=p.getNext() … p p p=p.getNext() null p p=p.getNext() 查找失败 使用 Java 语言实现整个过程的关键语句是: 44 p=head; while (p!=null) if (strategy.equal( e , p.getData() )) return true; return false; 在单链表中查找操作的运行时间与在数组中的查找操作一样,在平均情况下需要比较大 约一般的数据元素,即 T(n) ≈ n/2。 在单链表中数据元素的插入,是通过在链表中插入数据元素所属的结点来完成的。对于 链表的不同位置,插入的过程会有细微的差别。图 3-8(a)、3-8(b)、3-8(c)分别说明了 在单链表的表头、表尾以及链表中间插入结点的过程。 ① head … s ② (a) ① … s ② (b) p … 图 3-8 在单链表中插入结点 ① ∧ tail … ∧ s ② (c) 从图 3-7 中可以看出,除了单链表的首结点由于没有直接前驱结点,所以可以直接在首 结点之前插入一个新的结点之外,在单链表中的其他任何位置插入一个新结点时,都只能是 在已知某个特定结点引用的基础上在其后面插入一个新结点。并且在已知单链表中某个结点 引用的基础上,完成结点的插入操作需要的时间是Θ(1)。由于在单链表中数据元素的插入 是通过节点的插入来完成的,因此在单链表中完成数据元素的插入操作要比在数组中完成数 据元素的插入操作所需Ο(n)的时间要快得多。 类似的,在单链表中数据元素的删除也是通过结点的删除来完成的。在链表的不同位置 删除结点,其操作过程也会有一些差别。图 3-9(a)、3-9(b)、3-9(c)分别说明了在单链 表的表头、表尾以及链表中间删除结点的过程。 head … (a) 45 … (b) p … 待删结点 图 3-9 在单链表中插入结点 ① tail … ∧ (c) p null ② 从图 3-8 中可以看出,在单链表中删除一个结点时,除首结点外都必须知道该结点的直 接前驱结点的引用。并且在已知单链表中某个结点引用的基础上,完成其后续结点的删除操 作需要的时间是Θ(1)。由于在单链表中数据元素的删除是通过节点的删除来完成的,因此 在单链表中完成数据元素的删除操作要比在数组中完成数据元素的删除操作所需Ο(n)的时 间要快得多。 通过以上分析,我们可以得出以下结论:在单链表中进行顺序查找与在数组中完成相同 操作具有相同的时间复杂度,而在单链表中在已知特定结点引用的前提下完成数据元素的插 入与删除操作要比在数组中完成相同操作快得多。 3.3.2 双向链表 单链表的一个优点是结构简单,但是它也有一个缺点,即在单链表中只能通过一个结点 的引用访问其后续结点,而无法直接访问其前驱结点,要在单链表中找到某个结点的前驱结 点,必须从链表的首结点出发依次向后寻找,但是需要Ο(n)时间。为此我们可以扩展单链 表的结点结构,使得通过一个结点的引用,不但能够访问其后续结点,也可以方便的访问其 前驱结点。扩展单链表结点结构的方法是,在单链表结点结构中新增加一个域,该域用于指 向结点的直接前驱结点。扩展后的结点结构是构成双向链表的结点结构,如图 3-10 所示。 图 3-10 双向链表结点结构 data next 数据域 后续指针域 pre 前驱指针域 与单链表节点定义类似,双向链表的结点定义也可以通过实现结点接口来完成。代码 3-8 给出了双向链表结点的定义。 代码 3-8 双向链表结点定义 public class DLNode implements Node { private Object element; private DLNode pre; private DLNode next; 46 public DLNode() { this(null,null,null); } public DLNode(Object ele, DLNode pre, DLNode next){ this.element = ele; this.pre = pre; this.next = next; } public DLNode getNext(){ return next; } public void setNext(DLNode next){ this.next = next; } public DLNode getPre(){ return pre; } public void setPre(DLNode pre){ this.pre = pre; } /****************Node Interface Method**************/ public Object getData() { return element; } public void setData(Object obj) { element = obj; } } 双向链表是通过上述定义的结点使用 pre 以及 next 域依次串联在一起而形成的。一个双 向链表的结构如图 3-11 所示。 图 3-11 双向链表结构 a0 head tail ∧ a1 a2 a3 ∧ 在双向链表中同样需要完成数据元素的查找、插入、删除等操作。在双向链表中进行查 找与在单链表中类似,只不过在双向链表中查找操作可以从链表的首结点开始,也可以从尾 结点开始,但是需要的时间和在单链表中一样,在平均情况下需要比较大约一般的数据元素, 即 T(n) ≈ n/2。 单链表的插入操作,除了首结点之外必须在某个已知结点后面进行,而在双向链表中插 入操作在一个已知的结点之前或之后都可以进行。例如在某个结点 p 之前插入一个新结点的 47 过程如图 3-12 所示。 图 3-12 在结点 p 之前插入 s ④ ③② ① p s … … 使用 Java 语言实现整个过程的关键语句是 s.setPre (p.getPre()); p.getPre().setNext(s); s.setNext(p); p.setPre(s); 在结点 p 之后插入一个新结点的操作与上述操作对称,这里不再赘述。插入操作除了上 述情况,还可以在双向链表的首结点之前、双向链表的尾结点之后进行,此时插入操作与上 述插入操作相比更为简单,请读者自己分析。 单链表的删除操作,除了首结点之外必须在知道待删结点的前驱结点的基础上才能进 行,而在双向链表中在已知某个结点引用的前提下,可以完成该结点自身的删除。图 3-13 表示了删除 p 的过程。 图 3-13 删除结点 p p … … 使用 Java 语言实现整个过程的关键语句是 p.getPre().setNext(p.getNext()); p.getNext().setPre(p.getPre()); 如果删除的结点是首结点或尾结点时,情况会更加简单,请读者自己分析。 3.3.3 线性表的单链表实现 在使用链表实现线性表时,既可以使用单链表,也可以使用双向链表。实现中链表的选 择主要是依据需要实现的ADT的基本操作来决定,在这里我们可以选择单链表来实现线性 表。在使用单链表实现线性表时,线性表中的每个数据元素对应单链表中的一个结点,而线 性表元素之间的逻辑关系是通过单链表中元素所在结点之间的指向来表示的:如果表是a0, a1, …, an-1 ,那么含有元素ai-1的结点的next域应指向含有元素ai的结点(i=1,2,…,n-1)。含有an-1 的那个结点的next域是null。 在使用单链表实现线性表的时候,为了使程序更加简洁,我们通常在单链表的最前面添 加一个哑元结点,也称为头结点。在头结点中不存储任何实质的数据对象,其 next 域指向 线性表中 0 号元素所在的结点,头结点的引入可以使线性表运算中的一些边界条件更容易处 48 理。一个带头结点的单链表实现线性表的结构图如图 3-14 所示。 图 3-14 带头结点的单链表 a0 a1 an-1 ∧ head … 通过图 3-12 我们发现,对于任何基于序号的插入、删除,以及任何基于数据元素所在 结点的前面或后面的插入、删除,在带头结点的单链表中均可转化为在某个特定结点之后完 成结点的插入、删除,而不用考虑插入、删除是在链表的首部、中间、还是尾部等不同情况。 代码 3-9 给出了基于单链表实现线性表的程序。 代码 3-9 线性表的单链表实现 public class ListSLinked implements List { private Strategy strategy; //数据元素比较策略 private SLNode head; //单链表首结点引用 private int size; //线性表中数据元素的个数 public ListSLinked () { this(new DefaultStrategy()); } public ListSLinked (Strategy strategy) { this.strategy = strategy; head = new SLNode(); size = 0; } //辅助方法:获取数据元素 e 所在结点的前驱结点 private SLNode getPreNode(Object e){ SLNode p = head; while (p.getNext()!=null) if (strategy.equal(p.getNext().getData(),e)) return p; else p = p.getNext(); return null; } //辅助方法:获取序号为 0<=i0; i--) p = p.getNext(); return p; } //获取序号为 0<=i0; i--) p = p.getNext(); return p; } //返回线性表的大小,即数据元素的个数。 public int getSize() { return size; } //如果线性表为空返回 true,否则返回 false。 public boolean isEmpty() { return size==0; } //判断线性表是否包含数据元素 e public boolean contains(Object e) { SLNode p = head.getNext(); while (p!=null) if (strategy.equal(p.getData(),e)) return true; else p = p.getNext(); return false; } //返回数据元素 e 在线性表中的序号 public int indexOf(Object e) { SLNode p = head.getNext(); int index = 0; while (p!=null) if (strategy.equal(p.getData(),e)) return index; else {index++; p = p.getNext();} return -1; } //将数据元素 e 插入到线性表中 i 号位置 public void insert(int i, Object e) throws OutOfBoundaryException { if (i<0||i>size) throw new OutOfBoundaryException("错误,指定的插入序号越界。"); SLNode p = getPreNode(i); SLNode q = new SLNode(e,p.getNext()); p.setNext(q); size++; return; } 50 //将数据元素 e 插入到元素 obj 之前 public boolean insertBefore(Object obj, Object e) { SLNode p = getPreNode(obj); if (p!=null){ SLNode q = new SLNode(e,p.getNext()); p.setNext(q); size++; return true; } return false; } //将数据元素 e 插入到元素 obj 之后 public boolean insertAfter(Object obj, Object e) { SLNode p = head.getNext(); while (p!=null) if (strategy.equal(p.getData(),obj)){ SLNode q = new SLNode(e,p.getNext()); p.setNext(q); size++; return true; } else p = p.getNext(); return false; } //删除线性表中序号为 i 的元素,并返回之 public Object remove(int i) throws OutOfBoundaryException { if (i<0||i>=size) throw new OutOfBoundaryException("错误,指定的删除序号越界。"); SLNode p = getPreNode(i); Object obj = p.getNext().getData(); p.setNext(p.getNext().getNext()); size--; return obj; } //删除线性表中第一个与 e 相同的元素 public boolean remove(Object e) { SLNode p = getPreNode(e); if (p!=null){ p.setNext(p.getNext().getNext()); size--; return true; 51 } return false; } //替换线性表中序号为 i 的数据元素为 e,返回原数据元素 public Object replace(int i, Object e) throws OutOfBoundaryException { if (i<0||i>=size) throw new OutOfBoundaryException("错误,指定的序号越界。"); SLNode p = getNode(i); Object obj = p.getData(); p.setData(e); return obj; } //返回线性表中序号为 i 的数据元素 public Object get(int i) throws OutOfBoundaryException { if (i<0||i>=size) throw new OutOfBoundaryException("错误,指定的序号越界。"); SLNode p = getNode(i); return p.getData(); } } 代码 3-9 说明:在 SLinkedList 类中共有 3 个成员变量,其中 size 用于表明线性表中数 据元素的个数;head 是带头结点的单链表的首结点引用;而 strategy 是用来完成线性表中数 据元素的比较操作的策略。 算法 getSize()、isEmpty()的时间复杂度均为Θ(1)。通过成员变量 size 可以直接判断出 线性表中数据元素的个数以及线性表是否为空。 在类中提供了两个私有方法 getPreNode(Object e)、getPreNode(int i),其功能是找到数据 元素 e 或线性表中 i 号数据元素所在结点的前驱结点。在带头结点的单链表中的插入、删除 操作均是在某个结点之后完成的,因此线性表中一些基于数据元素或序号的插入、删除操作 的实现依赖于对应元素在单链表中的前驱结点引用。这两个方法的平均运行时间 T(n)≈n/2。 算法 replace(int i, Object e)、get(int i)的平均时间复杂度均为Θ(n)。由于链表中每个结点 在内存中的地址并不是连续的,所以链表不具有随机存取的特性,这样要对线性表中 i 号元 素进行获取或替换的操作,不可能与使用数组实现线性表那样可以在常数时间内完成,而是 必须从链表的头结点开始沿着链表定位 i 号元素所在的结点,然后才能进行相应的操作,因 此算法的平均运行时间 T(n)≈n/2,比使用数组实现相应操作要慢得多。 算法 contains(Object e)、indexOf(Object e)主要是在线性表中查找某个数据元素。算法平 均运行时间与使用数组的实现一样,都需要从线性表中 0 号元素出发,依次向后查找,因此 算法运行时间 T(n) ≈ n/2。 算法 insert(int i, Object e)、remove(int i)在实现的过程中首先需要在链表中定位 i 号元素 所在结点的前驱结点,然后才能完成插入、删除操作,由于定位方法 getPreNode(Object e)、 getPreNode(int i)的平均运行时间约为 n/2,而真正的结点的插入与删除只需要常数时间,因 此算法的运行时间 T(n)≈n/2,与使用数组实现的运行时间相同。 52 算法 insertBefore(Object obj, Object e)、insertAfter(Object obj, Object e)、remove(Object e) 在实现的过程中 insertBefore、remove 需要找到对应元素的前驱结点,insertAfter 需要找到对 应元素本身,这个定位过程的平均运行时间约为 n/2,而剩下的插入与删除操作只需要常数 时间,因此整个算法的平均运行时间 T(n)≈n/2 < n,要优于使用数组实现的运行时间。 3.4 两种实现的对比 3.4.1 基于时间的比较 线性表的操作主要有查找、插入、删除三类操作。 对于查找操作有基于序号的查找,即存取线性表中 i 号数据元素。由于数组有随机存取 的特性,在线性表的顺序存储实现中可以在Θ(1)的时间内完成;而在链式存储中由于需要 从头结点开始顺着链表才能取得,无法在常数时间内完成,因此顺序存储优于链式存储。查 找操作还有基于元素的查找,即线性表是否包含某个元素、元素的序号是多少,这类操作线 性表的顺序存储与链式存储都需要从线性表中序号为 0 的元素开始依次查找,因此两种实现 的性能相同。综上所述,如果在线性表的使用中主要操作是查找,那么应当选用顺序存储实 现的线性表。 对于基于数据元素的插入、删除操作而言,当使用数组实现相应操作时,首先需要采用 顺序查找定位相应数据元素,然后才能插入、删除,并且在插入、删除过程又要移动大量元 素;相对而言链表的实现只需要在定位数据元素的基础上,简单的修改几个指针即可完成, 因此链式存储优于顺序存储。对于基于序号的插入、删除操作,因为在顺序存储中平均需要 移动一半元素;而在链式存储中不能直接定位,平均需要比较一半元素才能定位。因此顺序 存储与链式存储性能相当。综上所述,如果在线性表的使用中主要操作是插入、删除操作, 那么选用链式存储的线性表为佳。 3.4.2 基于空间的比较 线性表的顺序存储,其存储空间是预先静态分配的,虽然在实现的过程中可以动态扩展 数组空间,但是如果线性表的长度变化范围较大,空间在使用过程中由于会存在大量空闲空 间,使得存储空间的利用率不高。而线性表的链式存储,其结点空间是动态分配的,不会存 在存储空间没有完全利用的情况。因此当线性表长度变化较大时,宜采用链式存储结构。 当线性表的数据元素结构简单,并且线性表的长度变化不大时。由于链式存储结构使用 了额外的存储空间来表示数据元素之间的逻辑关系,因此针对数据域而言,指针域所占比重 较大;而在线性表的顺序存储结构中,没有使用额外的存储空间来表示数据元素之间的逻辑 关系,尽管有一定的空闲空间没有利用,但总体而言由于线性表长度变化不大,因此没有利 用的空间所占比例较小。所以当线性表数据元素结构简单,长度变化不大时可以考虑采用顺 序存储结构。 53 3.5 链接表 3.5.1 基于结点的操作 在 3.1.2 小节给出的线性表抽象数据类型中,其提供的操作主要是指对线性表中的数据 元素及其序号的。例如插入操作就是基于序号和元素进行的,insert(i, e)是在序号为 i 的地方 插入元素,insertBefore 、与 insertAfter 是在某个数据元素之前或之后插入新的元素。这种 基于序号的操作实际上并不适合采用(单向或双向)链表来实现,因为为了在链表中定位数 据元素或序号,我们不得不沿着结点间的 next(或 pre)引用,从链表前端(双向链表也可 以从后端)开始逐一扫描。 我们考察一种经常需要完成的操作:顺序的将线性表中每个数据元素都访问一遍。如果 使用链式存储实现的线性表ListSLinked所提供的get(i)操作来实现,则需要Ο(n2)时间。因为 在使用链表实现取i号数据元素的操作时,需要将结点的引用从链表前端向后移动i次,而取 i+1 号数据元素时不能在上一次操作——取i号数据元素——的过程中受益,而必须重新从链 表前端开始定位,则访问线性表中每个元素一次所需要的总时间为 0+1+2+…+n-1=Ο(n2)。 这一时间复杂度是难以接受的。 实际上,除了通过序号来访问线性结构中的元素,还可通过其他途径来得到线性结构中 的元素。例如我们能够直接通过结点来访问数据元素,通过 3.3.1 中定义的结点接口,我们 看到结点实际上可以看成是可以存取数据元素的容器,数据元素与存放它的容器是一一对应 的。如果能够取得结点的引用,则可以取得相应结点存储的数据元素,并且在实际应用中的 许多情况下更希望以结点作为参数来完成某些操作。 如果能够以结点作为参数,那么就可以在Ο(1)时间内定位结点的地址,进而可以在更 短的时间内完成相应的操作。例如如果能够直接定位在链表中进行插入和删除结点的前驱, 那么相应的插入和删除操作都可以在Ο(1)完成。 3.5.2 链接表接口 链接表可以看成是一组结点序列以及基于结点进行操作的线性结构的抽象,或者说是对 链表的抽象。 在链接表中提供基于结点的操作时,有一个问题需要考虑:需要将多少链接表的实现细 节暴露给使用它的程序员?如果将单链表或双向链表的细节,例如结点结构、首结点引用或 尾结点引用都提供给程序员。这样做可以使得程序员可以直接访问数据并修改内部链表结构 (例如通过 next 和 pre 引用),但是基于安全性和面向对象的封装原则,我们并不这样做。 那么如何在向用户提供相关链表结点引用的基础上,却可以保证用户不会通过该引用对链表 的内部结构直接进行访问或修改呢?这实际上可以通过 3.1.1 定义的 Node 接口来实现,因 为任何链表结点(单链表结点、双向链表结点都实现了 Node 接口)都可被 Node 类型的变 量引用,而 Node 接口中只有存取数据元素的方法,因此程序员即可以存取数据,又不能对 内部链表结构进行修改。 代码 3-10 给出链接表支持的操作接口定义。 代码 3-10 链接表接口 public interface LinkedList { //查询链接表当前的规模 54 public int getSize(); //判断列表是否为空 public boolean isEmpty(); //返回第一个结点 public Node first() throws OutOfBoundaryException; //返回最后一结点 public Node last() throws OutOfBoundaryException; //返回 p 之后的结点 public Node getNext(Node p) throws InvalidNodeException, OutOfBoundaryException; //返回 p 之前的结点 public Node getPre(Node p) throws InvalidNodeException, OutOfBoundaryException; //将 e 作为第一个元素插入链接表,并返回 e 所在结点 public Node insertFirst(Object e); //将 e 作为最后一个元素插入列表,并返回 e 所在结点 public Node insertLast(Object e); //将 e 插入至 p 之后的位置,并返回 e 所在结点 public Node insertAfter(Node p, Object e) throws InvalidNodeException; //将 e 插入至 p 之前的位置,并返回 e 所在结点 public Node insertBefore(Node p, Object e) throws InvalidNodeException; //删除给定位置处的元素,并返回之 public Object remove(Node p) throws InvalidNodeException; //删除首元素,并返回之 public Object removeFirst() throws OutOfBoundaryException; //删除末元素,并返回之 public Object removeLast() throws OutOfBoundaryException; //将处于给定位置的元素替换为新元素,并返回被替换的元素 public Object replace(Node p, Object e) throws InvalidNodeException; //元素迭代器 public Iterator elements(); } 其中最后一个方法 elements()在 3.6 中介绍,InvalidNodeException 是当作为参数的结点 不合法时抛出的异常。定义如下: 代码 3-11 InvalidNodeException 异常 public class InvalidNodeException extends RuntimeException { public InvalidNodeException(String err) { super(err); } } 结点 p 在以下情况下可以认为是不合法的: z p==null; z p 在链接表中不存在; z 在调用方法 getPre(p)时,p 已经是第一个存有数据的结点; z 在调用方法 getNext(p)时,p 已经是最后一个存有数据的结点。 55 3.5.3 基于双向链表实现的链接表 在 3.3.2 小节中,为了实现双向链表结构,曾经在代码 3-8 中定义了双向链表结点结构 DLNode。由于 DLNode 实现了 Node 接口,所以 DLNode 本身就是一个结点,对内部的链 表而言就是组成链表的一部份,而对于外部而言就是可以存取数据元素的容器。 在使用双向链表实现链接表时,为使编程更加简洁,我们使用带两个哑元结点的双向链 表来实现链接表。其中一个是头结点,另一个是尾结点,它们都不存放数据元素,头结点的 pre 为空,而尾结点的 Next 为空。如此构成的双向链表结构如图 3-15 所示。 图 3-15 带头尾结点的双向链表 head ∧ a0 a1 a2 tail ∧ 在具有头尾结点的双向链表中插入和删除结点,无论插入和删除的结点位置在何处,因 为首尾结点的存在,插入、删除操作都可以被归结为 3.3.2 小节中介绍的在双向链表某个中 间结点的插入和删除;并且因为首尾结点的存在,整个链表永远不会为空,因此在插入和删 除结点之后,也不用考虑链表由空变为非空或由非空变为空的情况下 head 和 tail 的指向问 题;从而简化了程序。 代码 3-12 基于双向链表实现的链接表 public class LinkedListDLNode implements LinkedList { private int size; //规模 private DLNode head;//头结点,哑元结点 private DLNode tail;//尾结点,哑元结点 public LinkedListDLNode() { size = 0; head = new DLNode();//构建只有头尾结点的链表 tail = new DLNode(); head.setNext(tail); tail.setPre(head); } //辅助方法,判断结点 p 是否合法,如合法转换为 DLNode protected DLNode checkPosition(Node p) throws InvalidNodeException { if (p==null) throw new InvalidNodeException("错误:p 为空。"); if (p==head) throw new InvalidNodeException("错误:p 指向头节点,非法。"); if (p==tail) throw new InvalidNodeException("错误:p 指向尾结点,非法。"); DLNode node = (DLNode)p; return node; } 56 //查询链接表当前的规模 public int getSize() { return size; } //判断链接表是否为空 public boolean isEmpty() { return size==0; } //返回第一个结点 public Node first() throws OutOfBoundaryException{ if (isEmpty()) throw new OutOfBoundaryException("错误:链接表为空。"); return head.getNext(); } //返回最后一结点 public Node last() throws OutOfBoundaryException{ if (isEmpty()) throw new OutOfBoundaryException("错误:链接表为空。"); return tail.getPre(); } //返回 p 之后的结点 public Node getNext(Node p)throws InvalidNodeException,OutOfBoundaryException { DLNode node = checkPosition(p); node = node.getNext(); if (node==tail) throw new OutOfBoundaryException("错误:已经是链接表尾端。"); return node; } //返回 p 之前的结点 public Node getPre(Node p) throws InvalidNodeException, OutOfBoundaryException { DLNode node = checkPosition(p); node = node.getPre(); if (node==head) throw new OutOfBoundaryException("错误:已经是链接表前端。"); return node; } //将 e 作为第一个元素插入链接表 public Node insertFirst(Object e) { 57 DLNode node = new DLNode(e,head,head.getNext()); head.getNext().setPre(node); head.setNext(node); size++; return node; } //将 e 作为最后一个元素插入列表,并返回 e 所在结点 public Node insertLast(Object e) { DLNode node = new DLNode(e,tail.getPre(),tail); tail.getPre().setNext(node); tail.setPre(node); size++; return node; } //将 e 插入至 p 之后的位置,并返回 e 所在结点 public Node insertAfter(Node p, Object e) throws InvalidNodeException { DLNode node = checkPosition(p); DLNode newNode = new DLNode(e,node,node.getNext()); node.getNext().setPre(newNode); node.setNext(newNode); size++; return newNode; } //将 e 插入至 p 之前的位置,并返回 e 所在结点 public Node insertBefore(Node p, Object e) throws InvalidNodeException { DLNode node = checkPosition(p); DLNode newNode = new DLNode(e,node.getPre(),node); node.getPre().setNext(newNode); node.setPre(newNode); size++; return newNode; } //删除给定位置处的元素,并返回之 public Object remove(Node p) throws InvalidNodeException { DLNode node = checkPosition(p); Object obj = node.getData(); node.getPre().setNext(node.getNext()); node.getNext().setPre(node.getPre()); size--; return obj; 58 } //删除首元素,并返回之 public Object removeFirst() throws OutOfBoundaryException{ return remove(head.getNext()); } //删除末元素,并返回之 public Object removeLast() throws OutOfBoundaryException{ return remove(tail.getPre()); } //将处于给定位置的元素替换为新元素,并返回被替换的元素 public Object replace(Node p, Object e) throws InvalidNodeException { DLNode node = checkPosition(p); Object obj = node.getData(); node.setData(e); return obj; } //元素迭代器 public Iterator elements() { return new LinkedListIterator(this); } } 代码 3-12 说明:LinkedListDLNode 中共有 3 个成员变量,其中 head 和 tail 分别指向双 向链表中空的头结点和尾结点,它们本身并不存储数据元素;size 用来标明当前链接表中数 据元素的个数,使用该成员变量可以在Ο(1)时间内返回链接表的规模,而不用从头至尾计 数元素的个数。除此之外,LinkedListDLNode 中其他各个方法的正确性不难理解,并且各 个方法的时间复杂度均为Ο(1)。通过上述代码可以看到在使用结点作为参数时,链表实现 插入、删除操作的优越性就明显的体现出来。 3.6 迭代器 迭代器(Iterator)是程序设计的一种模式,它属于设计模式中的行为模式,它的功能是 提供一种方法顺序访问一个聚集对象中各个元素,而又不需暴露该对象的内部表示。 多个对象聚在一起形成的总体称之为聚集(Aggregate),聚集对象是能够包容一组对象 的容器对象。聚集依赖于聚集结构的抽象化,具有复杂性和多样性。例如数组就是一种最基 本的聚集。 聚集对象需要提供一种方法,允许用户按照一定的顺序访问其中的所有元素。而迭代器 提供了一个访问聚集对象中各个元素的统一接口,简单的说迭代器就是对遍历操作的抽象。 在一个迭代器中一般需要提供以下操作: 59 表 3-1 迭代器支持的操作 序号 方法 功能描述 ⑴ first() 输入参数:无 返回参数:无 功能:将游标指向到第一个元素 ⑵ next() 输入参数:无 返回参数:无 功能:将游标指向下一个元素。 ⑶ isDone() 输入参数:无 返回参数:boolean 功能:判断迭代器中是否还有剩余的元素。 ⑷ currentItem() 输入参数:无 返回参数:Object 对象 功能:返回迭代器当前数据元素。 根据以上定义的操作,我们先给出迭代器的 Java 接口。 代码 3-13 迭代器接口 public interface Iterator { //移动到第一个元素 public void first(); //移动到下一个元素 public void next(); //检查迭代器中是否还有剩余的元素 public boolean isDone(); //返回当前元素 public Object currentItem(); } 迭代器的实现可以根据不同的聚集对象给出不同的实现,下面我们结合聚集对象 LinkedList 对象,来实现针对 LinkedList 的迭代器。代码 3-14 给出了完整的实现代码。 代码 3-14 LinkedListIterator,基于 LinkedList 聚集对象的迭代器实现 public class LinkedListIterator implements Iterator { private LinkedList list;//链接表 private Node current;//当前结点 //构造方法 public LinkedListIterator(LinkedList list) { this.list = list; if (list.isEmpty()) //若列表为空 current = null; //则当前元素置空 else current = list.first();//否则从第一个元素开始 } //移动到第一个元素 public void first(){ if (list.isEmpty()) //若列表为空 60 current = null; //则当前元素置空 else current = list.first();//否则从第一个元素开始 } //移动到下一个元素 public void next() throws OutOfBoundaryException{ if (isDone()) throw new OutOfBoundaryException("错误:已经没有元素。"); if (current==list.last()) current = null; //当前元素后面没有更多元素 else current = list.getNext(current); } //检查迭代器中是否还有剩余的元素 public boolean isDone() { return current==null; } //返回当前元素 public Object currentItem() throws OutOfBoundaryException{ if (isDone()) throw new OutOfBoundaryException("错误:已经没有元素。"); return current.getData(); } } 代码 3-14 说明:由于本迭代器是基于链接表聚集对象的,因此在类中有一个成员变量 为链接表对象引用;除此之外还有一个用于返回当前元素的结点对象引用。LinkedListIterator 代码中各方法的正确性不难理解,且各个方法均在Ο(1)时间内完成。 在有了基于链接表聚集对象的迭代器实现以后,就可以对链接表中的数据使用迭代器接 口提供的方法进行完整的遍历了。例如在代码 3-12 基于双向链表实现的链接表代码中可以 对外提供一个访问所有数据元素的迭代器,即代码 3-12 中最后一个 elements()方法实现的功 能。 61 第四章 栈与队列 栈和队列是两种重要的数据结构。从栈与队列的逻辑结构上来说,它们也是线性结构, 与线性表不同的是它们所支持的基本操作是受到限制的,它们是操作受限的线性表,是一种 限定性的数据结构。 4.1 栈 4.1.1 栈的定义及抽象数据类型 栈(stack)又称堆栈,它是运算受限的线性表,其限制是仅允许在表的一端进行插入 和删除操作,不允许在其他任何位置进行插入、查找、删除等操作。表中进行插入、删除操 作的一端称为栈顶(top),栈顶保存的元素称为栈顶元素。相对的,表的另一端称为栈底 (bottom)。 当栈中没有数据元素时称为空栈;向一个栈插入元素又称为进栈或入栈;从一个栈中删 除元素又称为出栈或退栈。由于栈的插入和删除操作仅在栈顶进行,后进栈的元素必定先出 栈,所以又把堆栈称为后进先出表(Last In First Out,简称 LIFO)。图 4-1 显示了一个堆栈 及数据元素插入和删除的过程。 图 4-1 堆栈及入栈和出栈 A D C B A C B A 栈顶/底 栈顶 栈底 栈顶 栈底 栈顶 栈底 空栈 A 入栈 BCD 入栈 D 出栈 在图 4-1 中当 ABCD 均已入栈之后,出栈时得到的序列为 DCBA,这就是“后进先出”。 在解决实际问题时,如果碰到了数据的使用具有“后进先出”的特性,就预示着可以使用堆 栈来存储和使用这些数据。 堆栈的基本操作除了进栈、出栈操作外,还有判空、取栈顶元素等操作。下面给出堆栈 的抽象数据类型定义。 ADT Stack { 数据对象:D = {ai | ai∈D0,i=0, 1, 2…n-1,D0为某一数据对象} 数据关系:R = { | ai, ai+1∈D,i=0, 1, 2 … n-2} 基本操作: 序号 方法 功能描述 ⑴ getSzie () 输入参数:无 返回参数:非负整数 功能:返回堆栈的大小,即数据元素的个数。 62 ⑵ isEmpty () 输入参数:无 返回参数:boolean 功能:如果堆栈为空返回 true,否则返回 false。 ⑶ push(e) 输入参数:Object 对象 e 返回参数:无 功能:数据元素 e 入栈。 ⑷ pop() 输入参数:无 返回参数:Object 对象 功能:栈顶元素出栈。 ⑸ peek() 输入参数:无 返回参数:Object 对象 功能:获取栈顶元素,但不出栈。 } ADT Stack 对应于堆栈的抽象数据类型,代码 4-1 给出了完整的 Java 接口。 代码 4-1 Stack 接口 public interface Stack { //返回堆栈的大小 public int getSize(); //判断堆栈是否为空 public boolean isEmpty(); //数据元素 e 入栈 public void push(Object e); //栈顶元素出栈 public Object pop() throws StackEmptyException; //取栈顶元素 public Object peek() throws StackEmptyException; } 其中涉及的异常类定义如下: 代码 4-2 StackEmptyException 堆栈为空时出栈或取栈顶元素抛出此异常 public class StackEmptyException extends RuntimeException{ public StackEmptyException(String err) { super(err); } } 4.1.2 栈的顺序存储实现 和线性表类似,堆栈也有两种基本的存储结构:顺序存储结构和链式存储结构。 顺序栈是使用顺序存储结构实现的堆栈,即利用一组地址连续的存储单元依次存放堆栈 中的数据元素。由于堆栈是一种特殊的线性表,因此在线性表的顺序存储结构的基础上,选 择线性表的一端作为栈顶即可。根据数组操作的特性,选择数组下标大的一端,即线性表顺 序存储的表尾来作为栈顶,此时入栈、出栈等操作可以在Ο(1)时间完成。 由于堆栈的操作都在栈顶完成,因此在顺序栈的实现中需要附设一个指针 top 来动态的 63 指示栈顶元素在数组中的位置。通常 top 可以用栈顶元素所在数组下标来表示,top= -1 时表 示空栈。图 4-1 就可以看成是一个顺序栈。 堆栈在使用过程中所需的最大空间很难估计,因此,一般来说在构造堆栈时不应设定堆 栈的最大容量。一种合理的做法和线性表的实现类似,先为堆栈分配一个基本容量,然后在 实际的使用过程中,当堆栈的空间不够时再倍增存储空间,这个过程所需的时间均摊到每个 数据元素时间为Θ(1),不会影响操作实现的时间复杂度。 代码 4-3 给出了基于以上思想实现的堆栈。 代码 4-3 Stack 的顺序存储实现 public class StackArray implements Stack { private final int LEN = 8; //数组的默认大小 private Object[] elements; //数据元素数组 private int top; //栈顶指针 public StackArray() { top = -1; elements = new Object[LEN]; } //返回堆栈的大小 public int getSize() { return top+1; } //判断堆栈是否为空 public boolean isEmpty() { return top<0; } //数据元素 e 入栈 public void push(Object e) { if (getSize()>=elements.length) expandSpace(); elements[++top] = e; } private void expandSpace(){ Object[] a = new Object[elements.length*2]; for (int i=0; i | ai, ai+1∈D,i=0, 1, 2 … n-2} 基本操作: 序号 方法 功能描述 ⑴ getSzie () 输入参数:无 返回参数:非负整数 功能:返回堆栈的大小,即数据元素的个数。 ⑵ isEmpty () 输入参数:无 返回参数:boolean 功能:如果堆栈为空返回 true,否则返回 false。 ⑶ enqueue (e) 输入参数:Object 对象 e 返回参数:无 功能:数据元素 e 入队。 ⑷ dequeue () 输入参数:无 返回参数:Object 对象 功能:栈顶元素出队。 ⑸ peek() 输入参数:无 返回参数:Object 对象 功能:获取队首元素,但不出队。 } ADT Queue 对应于队列的抽象数据类型,代码 4-5 给出了完整的 Java 接口。 代码 4-5 Queue 接口 public interface Queue { //返回队列的大小 public int getSize(); //判断队列是否为空 public boolean isEmpty(); //数据元素 e 入队 public void enqueue(Object e); //队首元素出队 public Object dequeue() throws QueueEmptyException; //取队首元素 public Object peek() throws QueueEmptyException; } 其中涉及的异常类定义如下: 67 代码 4-6 QueueEmptyException 队列为空时出队或取队首元素抛出此异常 public class QueueEmptyException extends RuntimeException { public QueueEmptyException(String err) { super(err); } } 4.2.2 队列的顺序存储实现 在队列的顺序存储实现中,我们可以将队列当作一般的表用数组加以实现,但这样做的 效果并不好。尽管我们可以用一个指针 last 来指示队尾,使得 enqueue 运算可在Ο(1)时间内 完成,但是在执行 dequeue 时,为了删除队首元素,必须将数组中其他所有元素都向前移动 一个位置。这样,当队列中有 n 个元素时,执行 dequeue 就需要Ο(n)时间。 为了提高运算的效率,我们用另一种方法来表达数组中各单元的位置关系。设想数组 A[0.. capacity-1]中的单元不是排成一行,而是围成一个圆环,即 A[0]接在 A[capacity-1]的后 面。这种意义下的数组称为循环数组,如图 4-3 所示。 图 4-3 循环数组 e1 e0 e2 1 2 0 34 5 6 7 front rear 队首 队尾 用循环数组实现的队列称为循环队列,我们将循环队列中从队首到队尾的元素按逆时针 方向存放在循环数组中一段连续的单元中。并且直接用队首指针 front 指向队首元素所在的 单元,用队尾指针 rear 指向队尾元素所在单元的后一个单元。如图 4-3 所示,队首元素存储 在数组下标为 0 的位置,front=0;队尾元素存储在数组下标为 2 的位置,rear=3。 当需要将新元素入队时,可在队尾指针指示的单元中存入新元素,并将队尾指针 rear 按 逆时针方向移一位。出队操作也很简单,只要将队首指针 front 依逆时针方向移一位即可。 容易看出,用循环数组来实现队列可以在Ο(1)时间内完成 enqueue 和 dequeue 运算。执行一 系列的入队与出队运算,将使整个队列在循环数组中按逆时针方向移动。 当然队首和队尾指针也可以有不同的指向,例如也可以用队首指针 front 指向队首元素 所在单元的前一个单元,或者用队尾指针 rear 指向队尾元素所在单元的方法来表示队列在 循环数组中的位置。但是不论使用哪一种方法来指示队首与队尾元素,我们都要解决一个细 节问题,即如何表示满队列和空队列。 下面以图 4-3 所示的表示方法来说明这个问题。在图 4-3 中用队首指针front指向队首元 素所在的单元,用队尾指针rear指向队尾元素所在单元的后一个单元。如此在图 4-4(b)中 所示循环队列中,队首元素为e0,队尾元素为e3。当e4、e5、e6、e7相继进入队列后,如图 4-4 (c)所示,队列空间被占满,此时队尾指针追上队首指针,有rear = front。反之,如果从图 4-4(b)所示的状态开始,e0、e1、e2、e3相继出队,则得到空队列,如图 4-4(a)所示,此 时队首指针追上队尾指针,所以也有front = rear。可见仅凭front与rear是否相等无法判断队 68 列的状态是“空”还是“满”。解决这个问题可以有两种处理方法:一种方法是少使用一个 存储空间,当队尾指针的下一个单元就是队首指针所指单元时,则停止入队。这样队尾指针 就不会追上队首指针,所以在队列满时就不会有front = rear。这样一来,队列满的条件就变 为(rear+1)% capacity = front,而队列判空的条件不变,仍然为front = rear。另外一种解决这 个问题的方法是增设一个标志,以区别队列是“空”还是“满”,例如增设size变量表明队 列中数据元素的个数,如果size = Max则队列满。 图 4-4 循环队列 e6 e5 e4 e7 1 2 0 3 4 5 6 7 front rear (a) e3 e1 e0 1 2 0 34 5 6 7 e2 front (b) e3 e1 e0 1 2 0 3 4 5 6 7 e2 front rear (c) rear 表 4-1 总结了上述分析的结果。 表 4-1 循环队列中各关键量 不使用 size 标记队列元素个数 使用 size 标记队列元素个数 队首元素 elements[front] elements[front] 队尾元素 elements[(rear-1) % capacity] elements[(rear-1) % capacity] 队空 rear=front size=0 队满 (rear+1)%capacity=front size=capacity 注:其中 elements 为存放队列元素的数组。 下面以少使用一个存储单元的方案实现循环队列。 代码 4-7 Queue 的顺序存储实现 public class QueueArray implements Queue { private static final int CAP = 7;//队列默认大小 private Object[] elements; //数据元素数组 private int capacity; //数组的大小 elements.length private int front; //队首指针,指向队首 private int rear; //队尾指针,指向队尾后一个位置 public QueueArray() { this(CAP); } public QueueArray(int cap){ capacity = cap + 1; elements = new Object[capacity]; front = rear = 0; } //返回队列的大小 69 public int getSize() { return (rear -front+ capacity)%capacity; } //判断队列是否为空 public boolean isEmpty() { return front==rear; } //数据元素 e 入队 public void enqueue(Object e) { if (getSize()==capacity-1) expandSpace(); elements[rear] = e; rear = (rear+1)%capacity; } private void expandSpace(){ Object[] a = new Object[elements.length*2]; int i = front; int j = 0; while (i!=rear){ //将从 front 开始到 rear 前一个存储单元的元素复制到新数组 a[j++] = elements[i]; i = (i+1)%capacity; } elements = a; capacity = elements.length; front = 0; rear = j; //设置新的队首、队尾指针 } //队首元素出队 public Object dequeue() throws QueueEmptyException { if (isEmpty()) throw new QueueEmptyException("错误:队列为空"); Object obj = elements[front]; elements[front] = null; front = (front+1)%capacity; return obj; } //取队首元素 public Object peek() throws QueueEmptyException { if (isEmpty()) throw new QueueEmptyException("错误:队列为空"); return elements[front]; } } 70 代码 4-7 说明:在 QueueArray 类中成员变量 CAP 是用来以默认大小生成队列,由于我 们采用损失一个存储单元来区分队列空与满的两种不同状态,因此实际的数组大小要比队列 最大容量大 1。为了代码的简洁,在 QueueArray 类中引入成员变量 capacity 表示数组的大小, 即 capacity = elements.length。除此之外各操作的实现不难理解,并且每个操作的实现方法其 时间复杂度 T(n)=Ο(1)。 4.2.3 队列的链式存储实现 队列的链式存储可以使用单链表来实现。为了操作实现方便,这里采用带头结点的单链 表结构。根据单链表的特点,选择链表的头部作为队首,链表的尾部作为队尾。除了链表头 结点需要通过一个引用来指向之外,还需要一个对链表尾结点的引用,以方便队列的入队操 作的实现。为此一共设置两个指针,一个队首指针和一个队尾指针,如图 4-5 所示。队首指 针指向队首元素的前一个结点,即始终指向链表空的头结点,队尾指针指向队列当前队尾元 素所在的结点。当队列为空时,队首指针与队尾指针均指向空的头结点。 图 4-5 队列的链式存储结构 a0 a1 an-1 ∧ front rear ∧front rear … (a)空队列 (b)非空队列 代码 4-8 给出了队列链式存储的操作实现。 代码 4-8 Queue 的链式存储实现 public class QueueSLinked implements Queue { private SLNode front; private SLNode rear; private int size; public QueueSLinked() { front = new SLNode(); rear = front; size = 0; } //返回队列的大小 public int getSize() { return size; } //判断队列是否为空 71 public boolean isEmpty() { return size==0; } //数据元素 e 入队 public void enqueue(Object e) { SLNode p = new SLNode(e,null); rear.setNext(p); rear = p; size++; } //队首元素出队 public Object dequeue() throws QueueEmptyException { if (size<1) throw new QueueEmptyException("错误:队列为空"); SLNode p = front.getNext(); front.setNext(p.getNext()); size--; if (size<1) rear = front; //如果队列为空,rear 指向头结点 return p.getData(); } //取队首元素 public Object peek() throws QueueEmptyException { if (size<1) throw new QueueEmptyException("错误:队列为空"); return front.getNext().getData(); } } 代码 4-8 的正确性不难理解,并且所有操作的实现算法,其时间复杂度 T(n)=Ο(1)。 4.3 堆栈的应用 堆栈所具有的后进先出特性,使得堆栈成为程序设计中非常有用的工具。本节将讨论堆 栈应用的典型例子。 4.3.1 进制转换 进制转换是一种常见的数值计算问题,例如将十进制数转换成八进制数。实现进制转换 的一种简单方法是重复以下两步,直到 N 等于 0。 X = N mod d //其中 mod 为求余运算 N = N div d //其中 div 为整除运算 72 最后得到的一系列余数就是转换后的结果。 例如:(2007)10 = (3727)8,其运算过程如下: 20078 250 7 8 31 2 8 3 7 8 0 3 余数 可以看到上述过程是从低位到高位产生 8 进制数的各个数位,而在输出时,一般来说都 是从高位到低位进行输出,这正好产生数位的顺序相反。换一个说法就是,越晚生成的数位 越早需要输出,结果数位的使用具有后出现先使用的特点,因此生成的结果数位可以使用一 个堆栈来存储,然后从栈顶开始依次输出即可得到相应的转换结果。 算法 4-1 实现了十进制数到八进制数的转换。 算法 4-1 baseConversion 输入:十进制正整数 i 输出:打印相应八进制数 代码: public void baseConversion(int i){ Stack s = new StackSLinked(); while (i>0){ s.push(i%8+""); i = i/8; } while (!s.isEmpty()) System.out.print((String)s.pop()); } 4.3.2 括号匹配检测 假设表达式中包含三种括号:圆括号、方括号和花括号,并且它们可以任意相互嵌套。 例如{[{}]([])}或[{()[]}]等为正确格式,而{[( ])}或({[()})等均为不正确的格式。 该问题可按“期待匹配消解”的思想来设计算法,对表达式中的每一个左括号都期待一个 相应的右括号与之匹配,表达式中越迟出现并且没有得到匹配的左括号期待匹配的程度越 高。不是期待出现的右括号则是非法的。它具有天然的后进先出的特点。 于是可以如下设计算法:算法需要一个堆栈,在读入字符的过程中,如果是左括号,则 直接入栈,等待相匹配的同类右括号;若读入的是右括号,且与当前栈顶左括号匹配,则将 栈顶左括号出栈,如果不匹配则属于不合法的情况。另外如果碰到一个右括号,而堆栈为空, 说明没有左括号与之匹配,属于非法情况;或者字符读完,而堆栈不为空,说明有左括号没 有得到匹配,也属于非法情况。当字符读完同时堆栈为空,并且在匹配过程中没有发现不匹 配的情况,说明所有的括号是匹配的。 算法 4-2 bracketMatch 输入:字符串 str 输出:boolean,匹配结果 代码: 73 public boolean bracketMatch(String str) { Stack s = new StackSLinked(); for (int i=0;i0 实现它的递归算法如下: 算法 5-1 factorial 输入:正整数 n 输出:n! 代码: public int factorial (int n) { // 1. if (n == 0) // 2. return 1; // 3. else // 4. return n* factorial(n-1); // 5. } // 6. 在算法 5-1 中第 2 行是判断是否满足递归终止条件,如果满足则执行第 3 行,否则进行 递归调用执行第 5 行。在这里可以看到递归调用与递归终止条件在递归算法中缺一不可,如 果没有递归终止条件那么递归将会无休止的进行下去;而没有递归调用,则递归算法就不成 其为递归算法。因此在编写递归算法时一定要注意这两个方面的内容。下面我们再看一个递 归算法的例子。 例 5-2 计算以 x 为底的 n 次幂,其中 n 为非负整数。计算整数次幂可以简单的使用一 个循环迭代 n 次,每让 x 乘以自身即可。但是这样算法的时间复杂度为 Θ(n),效率较低。 下面我们设计一个新的算法,可以使得时间复杂度为 Θ(log n)。 因为xn可以写成如下形式: 78 xn = 1 n=0 n>0, n 为奇数 ⎣⎦()2n/2x n>0, n 为偶数 ⎣⎦()xx 2n/2 ⋅ 这显然是一个递归定义,其中当 n 为 0 时是递归终止条件;否则如果 n 大于 0,则需要 进行递归调用,只不过在进行递归调用时需要分两种不同情况分别进行处理。算法 5-2 实现 了这一过程。 算法 5-2 power 输入:整数 x、非负整数 n 输出:xn 代码: public int power (int x, int n) { // 1. int y; // 2. if (n == 0) // 3. 递归终止条件 y = 1; // 4. else { // 5. y = power (x, n/2); // 6. 递归调用 y = y * y; // 7. if (n%2 == 1) y = y * x; // 8. } // 9. return y; //10. } // 11. 如果将乘法作为基本操作,则算法 5-2 的时间复杂度函数可以如下表示: T(1) = 1 T(n) = T(n/2) +1 n>1 这是一个非常简单的递推关系的求解问题,因为每进行一次调用,n 变为原来的一半, 因此总共的递归调用次数为 Θ(log n),因此 T(n) = T(n/2) + 1 = (T(n/4) + 1) + 1 = ((T(n/8) + 1)+1) +1 = (…(T(1) + 1) + … +1) + 1 = Θ(log n) 通过例 5-1 和例 5-2 可以看到,递归算法的结构清晰明了、易于阅读,并且算法的正确 性可以很容易的使用数学归纳法得到证明。这些都为算法设计和程序调试带来了很大方便, 它是算法设计中一种非常有用的技术。在实际应用中使用递归可以解决以下多方面的问题: ⑴ 问题本身的定义就是递归的,例如许多数学定义就是递归的。 ⑵ 问题本身虽然不是递归 定义的,但是它所用到的数据结构是递归的,例如链表、树就可以看成是递归定义的数据结 构。 ⑶ 问题的解法满足递归的性质,例如在本章后面将要介绍的一些问题。 79 5.1.2 递归的实现与堆栈 在第四章中介绍的堆栈还有一个非常重要的应用,即在程序设计语言中实现递归。我们 知道在递归算法中会递归调用自身,因此在递归算法的执行过程中会多次进行自我调用。那 么这个调用过程是如何实现的呢? 为了说明自身的递归调用,我们先看任意两个函数(不同程序设计语言对“函数”称谓不 同,在这里我们不妨都称之为函数)之间进行调用的情形。 通常在一个函数执行过程中需要调用另一个函数时,在运行被调用函数之前系统通常需 要完成如下工作: ⑴ 对调用函数的运行现场进行保护,主要是参数与返回地址等信息的保 存; ⑵ 创建被调用函数的运行环境; ⑶ 将程序控制转移到被调用函数的入口。在被调用函 数执行结束之后,返回调用函数之前,系统同样需要完成 3 件工作: ⑴ 保存被调函数的返 回结果; ⑵ 释放被调用函数的数据区; ⑶ 依照保存的调用函数的返回地址将程序控制转移 到调用函数。 如果上述函数调用的过程中发生了新的调用,即被调函数在执行完成之前又调用了其他 函数,此时构成了多个函数的嵌套调用。当发生嵌套调用时按照后调用先返回的原则处理, 如此则形成了一个保存函数运行时环境变量的“后进先出”的使用过程,因此整个函数调用期 间的相关信息的保存需要使用一个堆栈来实现。系统将整个程序运行时需要的数据空间安排 在一个堆栈中,每当调用一个函数时就为它在栈顶分配一个存储区,每当从一个函数返回时 就释放它的存储区。 一个递归算法的实现实际上就是多个相同函数的嵌套调用。 下面我们用算法 5-1:factorial 来说明递归的实现过程。假设 n=3,那么递归调用的过程 如图 5-1 所示。 图 5-1 递归调用示例 public int factorial (int n) { // 1. if (n == 0) // 2. return 1; // 3. else // 4. return n* factorial(n-1); // 5. } // 6. f(3) f(2) f(1) f(0) 返回址, n m, 3 调 f(3) 调 f(2) 调 f(1) 调 f(0) m, 3 5, 2 m, 3 5, 2 5, 1 m, 3 5, 2 5, 1 5, 0 m, 3 5, 2 5, 1 m, 3 5, 2 m, 3 f(0)返 f(1)返 f(2)返 f(3)返 1 1*1 2*1 3*2 通过上面的内容,我们介绍了递归算法的实现原理。但是同时我们看到递归方法在某些情 况下却并不一定是最高效的方法,主要原因在于递归方法过于频繁的函数调用和参数传递, 这会使系统有较大的开销。在某些情况下,若采用循环或递归算法的非递归实现,将会大大 提高算法的实际执行效率。当然这也并不意味着不建议使用递归方法解决问题,递归仍然是 非常有用和广泛使用的技术。 80 5.2 基于归纳的递归 基于归纳的递归是一种较为简单并且也是一种基本的递归算法设计方法。它的主要思想 是把数学归纳法应用于算法设计之中。 对于一个规模为 n 的问题 P(n),归纳法的步骤是: Ⅰ. 基本项:A1是问题P(1)的解 Ⅱ. 递归项:对于所有的k,11 hanoi算法在n取 1 到 5 时的运行时间为:1、3、7、15、31,我们发现T(n)满足如下关系: 81 T(n) = 2n - 1。对此我们使用数学归纳法进行证明: 当n=1 时,T(1) = 2n – 1 = 1,满足时间复杂度函数。 假设当n<=k时T(n) = 2n – 1 成立,则T(k) = 2k – 1。 当n=k+1 时,T(n) = T(k+1) = 2T(k) + 1 = 2(2k – 1) + 1 = 2k+1 – 1= 2n – 1 成立,因此: 当n>=1 时T(n) = 2n – 1。 最后我们得到算法hanoi的时间复杂度T(n) = Ο(2n)。 下面我们来看另外一个例子。 例 5-4 编写一个算法输出 n 个布尔量的所有可能组合。 每个布尔量有真和假两种取值,分别对应 1,0。对于n个布尔量有 2n种组合,每一种均 为n位。 基本项:如果 n 为 1,则只需要输出 0 和 1 即可。 递归项:n个布尔量的 2n种所有不同的组合可以看成是 2*2n-1种组合,其中 2n-1种组合是 n-1 个布尔量的所有组合,每种组合包含n-1 位。这样n个布尔变量的所有组合是在n-1 个布 尔变量的每种组合的基础上加上 1 或 0 而分别得到的结果。 在具体的实现中使用一个数组 b 来存放 n 位组合的每一个分量。 算法 5-4 coding 输入:正整数 n 输出:n 位布尔量的所有组合 代码:(数组下标从 0 开始,因此调用方法时参数中的 n 应当取数组 b 的下标上限。) public void coding (int[] b, int n) { if (n==0) { b[n] = 0;outBn(b); b[n] = 1;outBn(b); } else { b[n] = 0; coding(b,n-1); b[n] = 1; coding(b,n-1); } } private void outBn (int[] b) { for (int i=0;i1 同样可以使用数学归纳法证明T(n) = 2n+1-2。因此算法coding的时间复杂度T(n) =Ο(2n)。 82 5.3 递推关系求解 5.3.1 求解递推关系的常用方法 „ 数学归纳法 从上面介绍的算法可以看出,递归算法的时间复杂度是使用递推关系给出的。求解递推 关系的一种方法如同例 5-3、例 5-4 那样可以先观察前几项,猜测 T(n)的通项,然后用数学 归纳法证明,最后得出时间复杂度。使用这种方法的一个问题是要有足够的观察力猜出通项, 这使得使用这种方法存在一定难度。 „ 迭代法 求解递推关系的另外一种方法就是迭代法,用这个方法估计递归方程解的渐近阶不要求 推测解的渐近表达式,但要求较多的代数运算。方法的思想是迭代地展开递归方程的右端, 使之成为一个非递归的和式,然后通过对和式的估计来达到对方程左端即方程的解的估计。 例 5-5 求解递推关系 T(n) = 3T(n/4) + n,T(0) = 1。 T(n) = 3T(n/4) + n = n + 3(n/4 + 3T(n/42)) = n + 3(n/4 + 3(n/42 + 3T(n/43))) = n + 3(n/4 + 3(n/42 + 3(n/43 + … +3(n/4i + 3T(0))…))) = 2i i1 2i 33 3n n n ... n 3 T(0)44 4 +++ +++ < 4 i 1logn i0 3 n3 T(0)4 ∞ + = ⎛⎞+⎜⎟⎝⎠∑ = 3log43n4n + = Ο(n) 其中i = log4n,并且由于T(n) > n =Ω(n),因此T(n) = Θ(n)。 从这个例子可以看到迭代法导致繁杂的代数运算。 „ 递归树 在一棵递归树中,每个节点代表了一组递推表达式中函数符号所表示的子问题的代价。 我们求出树中每层节点的代价之和就得到一层的代价和,然后我们将树中每层的代价和求出 来就可以确定所有递归调用的代价。它对描述分治算法的递归方程特别有效。 下面我们用一个例子来说明使用递归树求解递推关系这种方法。 例 5-6 求解递推关系T (n) = 3T(n/4) + cn2 ,c为大于 0 的常数,T(1)= 1。 使用递归树求解该递推关系的过程如图 5-2。 83 图 5-2 递归树示例 T(n) (a) cn2 T(n/4) T(n/4) T(n/4) (b) cn2 c(n/4)2 c(n/4)2 c(n/4)2 T(n/16) T(n/16) T(n/16) T(n/16) T(n/16) T(n/16) T(n/16) T(n/16) T(n/16) (c) cn2 c(n/4)2 c(n/4)2 c(n/4)2 c(n/16)2 c(n/16)2 c(n/16)2 (d) c(n/16)2 c(n/16)2 c(n/16)2 c(n/16)2 c(n/16)2 c(n/16)2 T(1) T(1) T(1) … T(1) T(1) T(1) … T(1) T(1) log4n 3lognlog 44 n3 = 2cn16 3 cn2 2 2 cn16 3 ⎟ ⎠ ⎞⎜ ⎝ ⎛ 3log4n Total = Ο(n2) 通过图 5-1 我们看到: 首先每向下进行一次递归调用,问题的规模会变为原来的 1/4,最后问题的规模会变为 1,在这个过程中一共向下进行了多少层的递归调用呢?不妨设为i,则 n/4i = 1,即 i = log4n 。 其次,由于每个节点代表的问题规模是上一层节点的 1/4,不妨假设上层节点的问题规 模为k,那么本层每个节点的问题规模为k/4。在节点向下进行递归调用时,上层节点的代价 为ck2,而本层每个节点的代价为c(k/4)2,即上层节点的 1/16。由于本层节点数是上层的 3 倍,所以本层所有节点代价和是上层节点代价和的 3/16。 最后每向下一层进行递归调用,每层节点数是上层的 3 倍,而一共进行了log4n次,因 此最下一层的节点数是 。 3lognlog 44 n31 =∗ T(n) = 3log2 1-nlog 2 2 22 4 4 n)1(Tcn16 3...cn16 3cn16 3cn ⋅+⎟ ⎠ ⎞⎜ ⎝ ⎛++⎟ ⎠ ⎞⎜ ⎝ ⎛++ < 3log2 0i i 4ncn16 3 +⎟ ⎠ ⎞⎜ ⎝ ⎛∑ ∞ = = 3log2 4ncn13 16 + =Ο(n2) 并且由于T (n) > cn2 =Ω(n2),所以T (n) =Θ(n2) 84 除了上面介绍的基本方法,下面向读者介绍求解一些特定形式的递推关系的方法,这些 递推形式是在本书中会经常出现的。如果读者需要了解更多有关递推关系求解方法的内容, 可以参考组合数学中的相关内容。 5.3.2 线性齐次递推式的求解 我们把注意力主要放在一阶和二阶的线性齐次递推关系式的求解上。 一阶线性齐次递推关系式的解可以直接得到,令f (n) = a f (n-1),假定序列从f(0)开始, 容易得出f(n) = an f(0)。 假设二阶线性齐次递推关系式的形式为f (n) = a1 f(n-1) + a2 f(n-2),假定序列从f(0)以及f(1) 开始,那么对于该递推关系的求解方法为: ⑴ 求解特征方程 ,令其两个根为r0axax 21 2 =−− 1和r2 ⑵ 按如下公式求出 f(n) rrr nrcrc 21 n 2 n 1 ==+ ( ) =nf 21 n 22 n 11 rr rcrc ≠+ ⑶ 将f(0)和f(1)代入第二步求得的结果,计算出c1和c2 例 5-7 求解递推关系 f(n) = f(n-1) + 2 f(n-2) 且 f(0) = 1,f(1) = 2。 特征方程是x2﹣x﹣2 = 0,有 r1=-1,r2=2,所以递推解为f(n) = c1(-1)n + c2(2)n。为求出c1和 c2解以下两个方程: f(0) = c1 + c2 = 1,f(1) = -c1 + 2c2 = 2 得到:c1 = 0 和c2 = 1,所以f(n) = 2n。 例 5-8 考虑 Fibonacci 序列 1, 1, 2, 3, 5, 8, …它可以使使用递推关系表示为 f(n) = f(n-1) + f(n-2) 且 f(1) = 1,f(2) = 1。为简化讨论可引入 f(0) = 0。 特征方程是x2﹣x﹣1 = 0,有r1= 2/)51( + ,r2= 2/)51( − ,所以递推解为 f(n) = n 2 n 1 2 51c2 51c ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ −+⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ + 为求出c1和c2解以下两个方程: f(0) = c1 + c2 = 0,f(1) = ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ −+⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ + 2 51c2 51c 21 = 1 得到:c1 = 5/1 和c2 = 5/1− ,所以 f(n) = nn 2 51 5 1 2 51 5 1 ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ −−⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ + 当 n 足够大时,第二项趋近于 0,因此当 n 足够大时 85 f(n) n 2 51 5 1 ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ +≈ 5.3.3 非齐次递推关系的解 同样,在这里我们主要关注在算法分析中常用的两种非齐次递推关系上。 第一种也是最简单的非齐次递推关系是: f(n) = f(n-1) + g(n),n>0 容易得出递推式的解是:f(n) = f(0) + ∑ = n 1i )i(g 下面可虑另一个递推关系 f(n) = g(n) f(n-1) + h(n),n>0 为了解这个关系定义一个新函数 f’(n),令 0n)0(f ' = ()=nf 0n),n(g(1)f1)-g(n)g(n ' >⋅⋅⋅ 将 f(n) 与 f(n-1)代入原递推关系式,得到 g(n) g(n-1)…g(1) f’(n) = g(n) (g(n-1)…g(1) f’(n-1)) + h(n) 上式简化为 f’(n) = f’(n-1) + h(n)/ g(n) g(n-1)…g(1) 因此 f’(n) = ∑ = + n 1i ' 1)...g(1)-g(i g(i) )i(h)0(f 最后求出 f(n) = g(n) g(n-1)…g(1) ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ + ∑ = n 1i 1)...g(1)-g(i g(i) )i(h)0(f 例 5-9 考虑例 5-4 中的递推关系 T(n) = 2T(n﹣1) + 2,T(1) = 2。 为方便求解可以引入T(0) = 0,令T(n) = 2n T’(n),T(0)= T’(0)= 0。那么 2n T’(n) = 2 (2n-1 T’(n-1)) + 2 化简上式为 T’(n) = T’(n-1) + 2/2n 它的解是 T’(n) = T’(0) + ∑ = n 1i i2 2 = ∑ = n 1i i2 2 最后解出 86 T(n) = 2n T’(n) = 2n ∑ = n 1i i2 2 = 2n+1-2 这与在例 5-4 中得到的结果是一致的。 例 5-10 求解递推关系 f(n) = n f(n-1) + n!,f(0) = 0。 令 f(n) = n! f’(n),f(0) = f’(0)= 0。那么 n! f’(n) = n ((n-1)! f’(n-1)) + n! 化简上式为 f’(n) = f’(n-1) + 1 它的解是 f’(n) = f’(0) + n = n 最后解出 f(n) = n! f’(n) = nn! 5.3.4 Master Method Master Method 为求解如下形式的递推式提供了简单的方法。 T(n) = aT(n/b) + f (n) (5.1) 其中a、b为常数,并且a ≥ 1 , b > 1;f (n)是正的确定函数。 在 Master Method 中分 3 种不同的情况分别给出问题的解。(5.1)是一类分治法的时间复 杂性所满足的递归关系,即一个规模为 n 的问题被分成规模均为 n/b 的 a 个子间题,递归地 求解这 a 个子问题,然后通过对这 a 个子问题的解的综合,得到原问题的解。如果用 T(n) 表示规模为 n 的原问题的复杂性,用 f(n)表示把原问题分成 a 个子问题和将 a 个子问题的解 综合为原问题的解所需要的时间,我们便有递推关系式(5.1)。关于分治法的具体内容我们将 在 5.4 中作详细介绍。 Master Method 依赖于下面的定理 5.1 定理 5.1 设 a≥1 和 b>1 是常数,f (n)是定义在非负整数上的一个确定的非负函数。又设 T(n)也是定义在非负整数上的一个非负函数,且满足递推关系式(5.1)。递推关系式(5.1)中的 n/b 可以是 ,也可以是 。那么我们有如下的 T(n)渐近估计式: ⎣b/n ⎦ ⎤⎡b/n 1. 对于某常数 ε>0,如果 f (n) = ( )ε−Ο alogbn ,那么 T(n) = ( )alogbnΘ 2. 如果 f (n) = ( )alogbnΘ ,那么 T (n) = ( )n logn alogbΘ 3. 对于某常数 ε>0,如 果 f (n) = ( )ε+Ω alogbn ,且对于某常数 c<1 和所有充分大的正整 数 n 有 a f (n/b) ≤ c f (n),那么 T (n) = Θ( f (n) )。 证明:略 通过对定理 5.1 的分析,读者可能已经注意到,这里涉及的三类情况,都是拿 f (n)与 作比较。定理直观地告诉我们,递归关系式解的渐近阶由这两个函数中的较大者决定。在第 一类情况下,函数 较大,则 T(n) = alogbn alogbn ( )alogbnΘ ;在第三类情况下,函数 f(n)较大,则 87 T(n) = Θ( f (n) );在第二类情况下,两个函数一样大,则乘以 n 的对数作为因子,此时 T (n) = ( )n logn alogbΘ 。 在这个定理中需要特别注意的是在第一类情况下 f(n)不仅必须比 小,而且必须是 多项式地比 小;在第三类情况下 f(n)不仅必须比 大,而且必须是多项式地比 大,还要满足附加的条件:对于某常数 c<1 和所有充分大的正整数 n,有 a f (n/b) ≤ c f (n)。 这个附加条件的直观含义是 a 个子问题的再分解和再综合所需要的时间最多与原问题的分 解和综合所需要的时间同阶。我们在一般情况下将碰到的以多项式为界的函数基本上都满足 这个条件。 alogbn alogbn alogbn alogbn 例 5-11 求解递推关系 T(n) = 4T(n/2) + n a = 4,b = 2 = nalogbn⇒ 2 f (n) = n ( )ε−Ο 2n Case 1:f (n) = ,其中 ε=1>0 T∴ (n) =Θ(n2) 例 5-12 求解递推关系 T(n) = T(n/2) + 1 a = 1,b = 2 = nalogbn⇒ 0 = 1 f (n) = 1 Case 2:f (n) =Θ(1) ∴ T (n) =Θ(log n) 例 5-13 求解递推关系 T(n) = 2T(n/4) + n log n a = 2,b = 4 = n2logalog 4b nn =⇒ 0.5 f (n) = n log n Case 3:f (n) =Ω(n0.5+ε),其中ε=0.5>0 并且 a f (n/b) = 2 (n/4)log(n/4) = (1/2)n log n – n ≤(1/2)n log n = c f (n),其中 c=1/2<1 ∴ T (n) =Θ(n log n) 例 5-14 求解递推关系 T(n) = 2T(n/2) + n /log n a = 2,b = 2 = n alogbn⇒ f (n) = n /log n f (n) = ,但并不是多项式的比 小,因此 Master Method 并不适用于这种情 况。 )(n alogbΟ alogbn 从这个例子我们看到定理的 3 类情况并没有覆盖所有可能的 f(n)。在第一类情况和第二 类情况之间有一个间隙:f(n)小于但不是多项式地小于 ;类似地,在第二类情况和第三alogbn 88 类情况之间也有一个间隙:f(n)大于但不是多项式地大于 。如果函数 f(n)落在这两个间 隙之一中,或者虽有 f (n) = alogbn ( )ε+Ω alogbn ,但附加条件 a f (n/b) ≤ c f (n)不满足,那么定理 5.1 不能适用。 5.4 分治法 对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决, 否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地 解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。 5.4.1 分治法的基本思想 任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小, 越容易直接求解,解题所需的计算时间也越少。例如,对于 n 个元素的排序问题,当 n=1 时,不需任何计算。n=2 时,只要作一次比较即可排好序。n=3 时只要作 3 次比较即可,…。 而当 n 较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当 困难的。 分治法的设计思想是,将一个难以直接解决的大问题,分割成一些规模较小的相同问题, 以便各个击破,分而治之。如果原问题可分割成 k (1< k ≤ n)个子问题,并且这些子问题都是 可解的,进一步我们还可利用这些子问题的解求出原问题的解,那么此时使用分治法就是可 行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。 在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小, 最终使子问题缩小到很容易直接求出其解。 下面我们同一个例子进一步说明分治法的基本思想。 例 5-15 寻找具有 n 个元素的数组 a[0, n-1]中的最大与最小元素。为了简化讨论,不妨 设 n 为 2 的整数次幂。解答这个问题的一种直接算法是遍历数组,找出最大与最小元素,如 算法 5-5。 算法 5-5 simpleMinMax 输入:整数数组 a[0, n-1] 输出:a 中的最大与最小元素 代码: public IntPair simpleMinMax (int[] a){ IntPair pair = new IntPair(); pair.x = a[0]; pair.y = a[0]; for (int i=1; ia[i]) pair.y = a[i]; } return pair; } 89 private class IntPair{ int x; int y; } 算法 5-5 中一共要进行 2n-2 次比较。 然而另外一种解决这个问题的方法是使用分治法:可以把数组 a 分成大小相等的两个数 组 a1 和 a2,在 a1 和 a2 中分别找出最大和最小元素,然后比较 a1 和 a2 中的最大和最小元 素,两个最大元素中大的就是原数组中的最大元素,两个最小元素中小的就是原数组中的最 小元素。为了要在 a1 和 a2 中找到最大与最小元素,可以重复上述过程,分别将 a1 分为 a11、 a12 以及将 a2 分为 a21、a22,该过程可以一直进行下去,直到在某次分割后的数组中元素 的个数少于三个的时候,此时最多只需要一次比较就可以找出该数组中的最大和最小元素。 算法 5-6 表示了这个工作过程。 算法 5-6 min_max 输入:整数数组 a[0, n-1] 输出:a 中的最大与最小元素 代码: public IntPair min_max(int[] a, int low, int high){ // 1. IntPair pair = new IntPair(); // 2. 定义见算法 5-5 if (low>high-2){ // 3. if (a[low]p2.x ? p1.x : p2.x; // 13. pair.y = p1.y2 使用递归树对该递推关系式进行分析,如图 5-3 所示。 图 5-3 使用递归树分析算法 5-6 2 2 2 2 2 2 2 T(2) T(2) T(2) … T(2)T(2) log n-1 1-n log2 21 )2(T22Total 1-n log 1-n log 1i i∑ = += 1-n log2 22 23 )2(T2 1-n log 2-3n/2n/22n)2(T22T(n) 1-n log 1-n log 1i i =+−=+= ∑ = ,这比使用算法 5-5 要好上许多。 通过对算法 5-6 的分析与总结,我们给出分治算法的一般设计步骤: 1. 划分步骤:在算法的这个步骤中,把输入的问题实例划分为 k≥1 个子问题,每个实 例的规模严格小于问题的原始规模 n。一般来说,应尽量将 k 个子问题的规模大致 相同。k=2 是最通常的情况,例如算法 5-6 中就是这样。有时也有 k=1 的划分,例 如折半查找,但是这种情况等价于输入数据被分割为两部分,而其中一部分被舍弃 了。当然 k 也是可以取其他值。 2. 治理步骤:当子问题的规模大于某个预定的阈值时,这个步骤是由 k 个递归调用组 成的;如果子问题的规模小于阈值时则直接对问题进行求解。例如算法 5-6 的阈值 是 2。 3. 组合步骤:这个步骤是组合 k 个子问题的解来得到期望的原问题的解。组合步骤对 分治算法的性能起到非常关键的影响,算法的效率在很大程度上依赖于组合步骤地 实现。 在下面的两小节中我们再介绍两个使用分治法求解问题的例子。 5.4.2 矩阵乘法 令 A、B 是两个 n×n 矩阵,我们希望计算它们的乘积 C = AB。下面讨论如何将分治法 91 运用到这个问题的求解上。 „ 传统方法 在传统方法中,我们是使用两个矩阵乘积的定义来求解 C = AB 的。C 中每个元素由以 下公式计算 C(i, j) = ∑ = ⋅ n 1k )j,k(B)k,i(A 从公式中很容易看出,为计算矩阵C共需要n3次乘法运算和n3-n2次加法运算。因此算法 的时间复杂度T(n) = Θ(n3)。 „ 简单分治法 假设n=2k(k≥0),如果n>1,则A,B和C可以分成 4 个大小为n/2×n/2 的矩阵 , , ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛= 2221 1211 AA AAA ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛= 2221 1211 BB BBB ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛= 2221 1211 CC CCC 如果用分治法来计算矩阵 C,则可以进行如下计算 ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ ++ ++= 2222122121221121 2212121121121111 BABABABA BABABABAC 划分步骤:将矩阵 A、B 分成 4 个 n/2×n/2 的矩阵; 治理步骤:当 n>1 时,递归计算 8 个 n/2×n/2 的矩阵的乘积; 组合步骤:计算治理步骤得到 n/2×n/2 的矩阵的和。 在这里需要 8 次 n/2×n/2 矩阵乘法和 4 次 n/2×n/2 矩阵加法,由此算法的时间复杂度可 以由下面的递推关系式表示 T(1) = 1 T(n) = 8T(n/2) +4(n/2)2 n>1 由Master Method知T(n) = Θ(n3),可见分治法并没有产生更有效的算法,相反使用分治 法它所消耗的时间比传统方法还要多,这主要是由分治法的递归调用引起的系统开销造成 的。如果我们对这里分治法执行中所需的乘法与加法分开计算,得到的结果与传统方法中使 用的乘法与加法的次数是一样的,实际上这里的分治法不过是传统方法的递归形式罢了,只 不过是矩阵元素相乘的次序上不同而已。 下面我们将寻找更有效的分治法来解决这个问题。 „ STRASSEN 算法 STRASSEN 算法与简单的分治法之间的区别在于它使用了 7 次 n/2×n/2 矩阵乘法和 18 次 n/2×n/2 矩阵加法。 我们仍然在当 n>1 时,将 A,B 和 C 可以分成 4 个大小为 n/2×n/2 的矩阵 , , ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛= 2221 1211 AA AAA ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛= 2221 1211 BB BBB ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ ++ ++=⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛= 2222122121221121 2212121121121111 2221 1211 BABABABA BABABABA CC CCC 92 为了计算 C,我们先计算以下 7 个 n/2×n/2 矩阵的乘积 d1 = (A11 + A22) (B11 + B22) d2 = (A21 + A22) B11 d3 = A11 (B12 – BB22) d4 = A22 (B21 – B11) d5 = (A11 + A12) B22 d6 = (A21 – A11) (B11 + B12) d7 = (A12 – A22) (B21 + B22) 然后可以通过下面公式求出 C ⎟⎟ ⎠ ⎞ ⎜⎜ ⎝ ⎛ +++ +++= d6d2-d3d1d4d2 d5d3d7d5-d4d1C 此时算法的时间复杂度可以由下面的递推关系式表示 T(1) = 1 T(n) = 7T(n/2) +18(n/2)2 n>1 由Master Method知T(n) = Θ(nlog7),在时间上的要优于传统方法。 5.4.3 选择问题 在具有 n 个元素的有序数组 a[0, n-1]中的中项是其中间元素。即如果 n 为奇数,则中间 元素是第(n+1)/2 个元素;如果 n 为偶数则选择第 n/2 这个中间元素作为中项。那么综合两种 情况,中项是第⎣(n+1)/2⎦个元素。 寻找中项或任意的第 k 小元素的一个直接方法是对所有的元素排序,并取出相应元素, 然而使用这种方法需要的时间较多,至少需要 Ω(n log n)时间,这是因为任何一种基于比较 的排序方法在最坏的情况下都需要这么多时间。 下面我们介绍一种使用分治法在 Θ(n)时间内就可以找到中项或第 k 小项的算法。在使 用分治法找出中项或第 k 小元素的基本思想是:在分治法递归调用的每一个划分步骤中都舍 弃一定比例的元素,而在剩余的元素中寻找目标。于是问题的规模便以几何级数递减,例如 我们假设不管处理什么对象,算法都舍弃 1/4 的元素,而对剩余的 3/4 元素进行递归,那么 在第二次调用时元素的个数变为 3n/4,第三次调用时变为 9n/16,… 等等。现在假定在每 次调用中,算法对每个元素的处理时间不超过常数 c,则整个算法的运行时间为: cn + (3/4)cn + (3/4)2cn + … + (3/4)icn + … 这个几何级数的总和小于 = 4cn = Θ(n) ∑ ∞ =0i i)4/3(cn 根据上面的思想方法,可以如下设计算法来寻找数组 a 的中项或第 k 小元素: ⑴ 当n ≤n0时,直接对数组排序,返回第k个元素即可,否则转⑵。 ⑵ 把元素划分为 p = n/5 组,每组 5 个元素,不足 5 个元素的忽略。 ⑶ 取每组的中项,构成一个规模为 p 的数组 M。 ⑷ 对数组 M 递归求出其中项 mm。 ⑸ 使用 mm 将原数组分成 3 部分:a1 存放小于 mm 的元素,a2 存放等于 mm 的元素, 93 a3 存放大于 mm 的元素。 ⑹ 分三种情况分别处理 a1、a2、a3 如果|a1|≥k,对 a1 递归执行算法;否则 如果|a1|+|a2|≥k,mm 是第 k 小元素,返回;否则 对 a3 递归执行算法。 下面对以上步骤进行简要说明,步骤⑵—⑷的作用主要是为了求出 p 个中项的中项 mm, 参见图 5-4。 图 5-4 中项的中项 mm 的位置 X Y 箭头由小元素指向大元素 mm 于是我们知道矩形 X 中的元素均大于等于 mm,矩形 Y 中的元素均小于等于 mm。这 样在第⑸步中使用 mm 对数组 a 进行划分时,可以保证 a1 与 a3 中的元素个数大约不会小于 数组 a 中元素个数的 1/4。最后导致在第⑹步中不论对 a1 或是 a3 进行递归时都可以保证至 少舍弃大约 1/4 左右的元素。 以上过程的具体实现如算法 5-7。 算法 5-7 selectK 输入:整数数组 a[0, n-1] 输出:a 中的第 k 小元素 代码: public int selectK(int[] a, int n, int k){ if (n<38) { mergeSort (a, 0, a.length-1); //使用归并排序1直接对数组a排序 return a[k-1]; } int[] m = new int[n/5]; for (int i=0; imm) { a3[t++] = a[i]; continue;} } if (k<=r) return selectK (a1,r,k); else if (k<=r+s) return mm; else return selectK (a3,t,k-r-s); } 在算法中的第⑴步需要常数时间 Θ(1),第⑵、⑶步共需要 Θ(n)时间,第⑷步需要 T( ) 时间,第⑸步需要 Θ(n)时间,第⑹步所需时间分析如下: ⎣⎦5/n 通过图 5-4 我们知道 |a1|≥ ≥ ⎣⎦⎡⎤2/5/n3 ⎣5/n2 3 ⎦,因此 |a3|≤ n-|a1|≤ ⎟ ⎠ ⎞⎜ ⎝ ⎛ −− 5 4n 2 3n = 0.7n + 1.2 由对称性得到 |a3|≥ 3 n/52 ⎢⎣⎥⎦,|a1|≤ 0.7n + 1.2 如果令n0=38,则对于所有n>n0,0.7n + 1.2≤ 3n / 4⎢ ⎥⎣ ⎦ 。因此在第⑹步中无论对a1 还是a3 进行递归,时间都不会超过T( )时间。 ⎣4/3n ⎦ 因此 T(n)的递推关系式为 c n<38 ()( )T n/5 T 3n/4 cn n 38++≥⎢⎥ ⎢ ⎥⎣⎦ ⎣ ⎦ T(n) ≤ 解出 T(n)≤ 20cn = Θ(n)。 95 第六章 树 前面我们介绍了线性表、栈和队列,这些数据结构都是线性结构,在本章中我们介绍一 种重要的非线性结构——树。在第二章曾经介绍,在树结构中数据元素之间的逻辑关系是前 驱唯一而后续不唯一,即数据元素之间是一对多的关系。如果直观的观察,树结构是具有分 支的层次结构。树结构在客观世界中广泛存在,如行政区划、社会组织机构、家族世系等都 可以抽象为树结构。树结构在计算机科学领域也有非常广泛的应用,例如文件系统、编译系 统、数据库系统、域名系统等领域。 本章重点讨论二叉树的存储表示及其各种运算,并研究一般树和森林与二叉树的转换关 系,最后介绍树的应用实例。从本章开始逐渐将注意力转向算法,对于抽象数据类型的完整 封装实现可以通过本书提供的源代码获得。 6.1 树的定义及基本术语 树是由一个集合以及在该集合上定义的一种关系构成的。集合中的元素称为树的结点, 所定义的关系称为父子关系。父子关系在树的结点之间建立了一个层次结构。在这种层次结 构中有一个结点具有特殊的地位,这个结点称为该树的根结点,或简称为树根。我们可以形 式地给出树的递归定义如下: 树(tree)是 n(n ≥ 0)个结点的有限集。它 1) 或者是一棵空树(n = 0),空树中不包含任何结点。 2) 或者是一棵非空树(n > 0),此时有且仅有一个特定的称为根(root)的结点; 当n > 1 时,其余结点可分为m(m > 0)个互不相交的有限集T1,T2,…,Tm, 其中每一个本身又是一棵树,并且称为根的子树(sub tree)。 例如图 6-1(a)是一棵空树、6-1(b)是只有一个根节点的树、6-1(c)是一棵有 10 个结点的树,其中A是根,其余的结点分成 3 个不相交的集合:T1={B,E,F}、T2={C,G}、 T3={D,H,I,J},每个集合都构成一棵树,且都是根A的子树。例如T1是一棵树,其中B是根, 其余结点构成 2 个不相交的集合:T11={E}、T12={F}是B的子树,并且都是只有一个根结点 的树。 图 6-1 树的示例 A A GF I J H C E DB (c)(b) (a) 下面给出树结构中的一些基本术语: „ 结点的层次和树的深度 树的结点包含一个数据元素及若干指向其子树的若干分支。结点的层次(level)从根开 始定义,层次数为 0 的结点是根结点,其子树的根的层次数为 1。若结点在 L 层,其子树的 96 根就在 L+1 层。对于层次为 k(k > 0)的每个结点 c,都有且仅有一个层次为 k-1 的结点 p 与之对应,p 称为 c 的父亲(parent)或父结点。若 p 是 c 的父亲,则 c 称为 p 的孩子(child)。 父子之间的连线是树的一条边。在树中根结点没有父亲,其余结点只有一个父结点,但是却 可能有多个孩子,同一结点的孩子相互称为兄弟(sibling)。 树中结点的最大层次数称为树的深度(Depth)或高度。树中结点也有高度,其高度是 以该结点为根的树的高度。 例如,在图 6-1(c)中,结点 A 在第 0 层,结点 B、C、D 在第 1 层,结点 E、F、G、 H、I、J 在第 2 层。结点 A 是结点 B、C、D 的父亲,结点 B、C、D 是结点 A 的孩子。由 于结点 H、I、J 有同一个父结点 D,因此它们互为兄弟。 以 A 为根的树的高度为 2,结点 A 的高度也就为 2。 „ 结点的度与树的度 结点拥有的子树的数目称为结点的度(Degree)。度为 0 的结点称为叶子(leaf)或终 端结点。度不为 0 的结点称为非终端结点或分支结点。除根之外的分支结点也称为内部结点。 在这里需要注意的是结点的直接前驱结点,即它的父结点不计入其度数。 例如,在图 6-1(c)中,结点 A、D 的度为 3,结点 E、F、G、H、I、J 的度均为 0, 是叶子。 在树结构中有一个重要的性质如下: 性质 6.1 树中的结点数等于树的边数加 1,也等于所有结点的度数之和加 1。 这是因为除根结点以外每个结点都与指向它的一条边对应,所以除根结点以外的结点数 等于树中边数之和。因此树中的结点数等于树的边数加 1。而边数之和就是所有结点的度数 之和,因此树中的结点数也等于所有结点的度数之和加 1。 性质 6.1 说明在树中结点总数与边的总数是相当的,基于这一事实,在对涉及树结构的 算法复杂性进行分析时,可以用结点的数目作为规模的度量。 „ 路径 在树中k+1 个结点通过k条边连接构成的序列{(v0,v1),(v1,v2), … ,(vk-1,vk)| k ≥ 0}, 称为长度为k的路径(path)。注意,此时忽略了树中边的方向。由单个结点,0 条边构成的 是长度为 0 的路径。 例如,在图 6-1(c)中,{(F,B),(B,A), (A,C),(C,G)}构成了一条连接结点 F、 G 长度为 4 的路径。 通过观察,不难得到如下观察结论:树中任意两个结点之间都存在唯一的路径。这意味 着树既是连通的,同时又不会出现环路。从根结点开始,存在到其他任意结点的一条唯一路 径,根到某个结点路径的长度,恰好是该结点的层次数。 „ 祖先、子孙、堂兄弟 将父子关系进行扩展,就可以得到祖先、子孙、堂兄弟等关系。结点的祖先是从根到该 结点路径上的所有结点。以某结点为根的树中的任一结点都称为该结点的子孙。父亲在同一 层次的结点互为堂兄弟。 例如,在图 6-1(c)中,结点 H 的祖先为结点 A、D。结点 B 的子孙有结点 E、F。结 点 E、F 与结点 G、H、I、J 互为堂兄弟。 „ 有序树、m 叉树、森林 如果将树中结点的各子树看成是从左至右是有次序的,则称该树为有序树;若不考虑子 树的顺序则称为无序树。对于有序树,我们可以明确的定义每个结点的第一个孩子、第二个 孩子等,直到最后一个孩子。若不特别指明,一般讨论的树都是有序树。 97 树中所有结点最大度数为 m 的有序树称为 m 叉树。 森林(forest)是 m(m ≥ 0 )棵互不相交的树的集合。对树中每个结点而言,其子树 的集合即为森林。树和森林的概念相近。删去一棵树的根,就得到一个森林;反之,加上一 个结点作树根,森林就变为一棵树。 例如,在图 6-1(c)中,以结点 A 为根的树就是一棵 3 叉树。结点 A 的所有子树可以 组成一个森林。 下面给出树的抽象数据类型的定义。 ADT Tree{ 数据对象 D:D 是具有相同性质的数据元素的集合。 数据关系 R:若 D=Φ 则 R =Φ; 若 D≠Φ,则 R = {H},H 是如下二元关系: ① 在 D 中存在一个唯一的称为根的元素 root,它在 H 下无前驱; ② 除 root 以外,D 中每个结点在 H 下都有且仅有一个前驱。 基本操作: 序号 方法 功能描述 ⑴ getSzie () 输入参数:无 返回参数:非负整数 功能:返回树的结点数。 ⑵ getRoot() 输入参数:无 返回参数:结点 功能:返回树根结点。 ⑶ getParent(x) 输入参数:结点 x 返回参数:结点 功能:返回结点 x 的父结点。 ⑷ getFirstChild(x) 输入参数:结点 x 返回参数:结点 功能:返回结点 x 的第一个孩子。 ⑸ getNextSibling(x) 输入参数:结点 x 返回参数:结点 功能:返回结点 x 的下一个兄弟结点,如果 x 是最后一个孩子, 则返回空。 ⑹ getHeight(x) 输入参数:无 返回参数:整数 功能:返回以 x 为根的树的高度。 ⑺ insertChild(x,child) 输入参数:结点 x、结点 child 返回参数:无 功能:将结点 child 为根的子树插入树中,作为结点 x 的子树。 ⑻ deleteChild(x,i) 输入参数:结点 x、整数 i 返回参数:无 功能:删除结点 x 的第 i 棵子树。 ⑼ preOrder(x) postOrder(x) levelOrder(x) 输入参数:结点 x, 线性表 list 返回参数:迭代器 功能:先序、后序、按层遍历 x 为根的树。 }ADT Tree 98 6.2 二叉树 在进一步讨论树的存储结构及其操作之前,先讨论一种简单而极其重要的树结构——二 叉树。因为任何树都可以转化为二叉树进行处理,并且二叉树适合计算机的存储和处理,因 此在本章中二叉树是研究的重点。 6.2.1 二叉树的定义 每个结点的度均不超过 2 的有序树,称为二叉树(binary tree)。与树的递归定义类似, 二叉树的递归定义如下:二叉树或者是一棵空树,或者是一棵由一个根结点和两棵互不相交 的分别称为根的左子树和右子树的子树所组成的非空树。 由以上定义可以看出,二叉树中每个结点的孩子数只能是 0、1 或 2 个,并且每个孩子 都有左右之分。位于左边的孩子称为左孩子,位于右边的孩子称为右孩子;以左孩子为根的 子树称为左子树,以右孩子为根的子树称为右子树。 与树的基本操作类似,二叉树有如下基本操作: 序号 方法 功能描述 ⑴ getSzie () 输入参数:无 返回参数:非负整数 功能:返回二叉树的结点数。 ⑵ isEmpty () 输入参数:无 返回参数:boolean 功能:判断二叉树是否为空。 ⑶ getRoot() 输入参数:无 返回参数:结点 功能:返回二叉树的树根结点。 ⑷ getHeight() 输入参数:无 返回参数:整数 功能:返回二叉树的高度。 ⑸ find(e) 输入参数:元素 e 返回参数:结点 功能:找到数据元素 e 所在结点。若 e 不存在,则返回空。 ⑹ preOrder() inOrder() postOrder() levelOrder() 输入参数:无 返回参数:迭代器对象 功能:先序、中序、后序、按层遍历 x 为根的二叉树。结果由 迭代器对象返回。 6.2.2 二叉树的性质 在二叉树中具有以下重要性质。 性质 6.2 在二叉树的第i层上最多有 2i个结点。 该性质易由数学归纳法证明。证明略。 由性质 6.2 可以得到如下的进一步结论: 99 性质 6.3 高度为h的二叉树至多有 2 h+1-1 个结点。 证明略。 性质 6.4 对任何一棵二叉树T,如果其终端结点数为n0,度 为 2 的结点数为n2,则n0 = n2 + 1。 证明: 假设二叉树中结点总数为n,n1为度为 1 的结点。 于是有:n=n0+n1+n2 由性质 6.1 知:n =1×n1+2×n2+1 所以:n0 = n2 + 1 下面介绍两种特殊的二叉树,然后讨论其有关性质。 满二叉树:高度为k并且有 2 k+1-1 个结点的二叉树。在满二叉树中,每层结点都达到最 大数,即每层结点都是满的,因此称为满二叉树。图 6-2(a)所示的二叉树就是一棵满二叉 树。 可以对满二叉树的结点进行编号,约定编号从根结点起,层间自上而下,层内自左而右, 逐层由 1 到 n 进行标号。 完全二叉树:若在一棵满二叉树中,在最下层从最右侧起去掉相邻的若干叶子结点,得 到的二叉树即为完全二叉树。 如果按照上述对满二叉树结点编号的方法,对具有 n 个结点的完全二叉树中结点进行编 号,那么完全二叉树中 1~ n 号结点的位置与满二叉树中 1~ n 号结点的位置是一致的。图 6-2 (b)所示的二叉树就是一棵完全二叉树。 可见,满二叉树必为完全二叉树,而完全二叉树不一定是满二叉树。 图 6-2 满二叉树与完全二叉树 1 151413 12 11 10 9 8 76 5 4 32 (a)满二叉树 (b)完全二叉树 1 12 111098 7 6 54 3 2 性质 6.5 有 n 个结点的完全二叉树的高度为 ⎣ ⎦n log 。 证明: 假设高度为 h,根据性质 6.3 以及完全二叉树的定义有 2 h-1< n ≤ 2 h+1-1 或 2 h ≤ n < 2 h+1 即 h ≤ logn < h+1 因为 h 是整数,因此 h = ⎣ ⎦n log 。 从完全二叉树的定义不难得到以下观察结论:在固定结点数目的二叉树中,完全二叉树 的高度是最小的。由此可以得到二叉树的性质 6.6。 性质 6.6 含有 n≥1 个结点的二叉树的高度至多为 n-1;高度至少为 ⎣ ⎦n log 。 性质 6.7 如果对一棵有 n 个结点的完全二叉树的结点进行编号,则对任一结点 i(1≤i ≤n), 有 ⑴ 如果 i=1,则结点 i 是二叉树的根,无双亲;如果 i>1,则其双亲结点 PARENT(i)是 结点 ⎣ 。 ⎦2/i ⑵ 如果 2i>n,则结点 i 无左孩子;否则其左孩子是结点 2i。 ⑶ 如果 2i+1>n,则结点 i 无右孩子;否则其右孩子是结点 2i+1。 100 证明: 先证明结论⑵、⑶。 当 i=1 时,由完全二叉树的定义知,如果 2i = 2 ≤ n,说明二叉树中存在两个或两个 以上结点,所以其左孩子的编号为 2;若 2>n,说明二叉树中不存在编号为 2 的结点, 因此它的左孩子不存在。同理如果 2i +1 = 3 ≤ n,说明二叉树中存在三个或三个以上结 点,所以其右孩子的编号为 3;若 3>n,说明二叉树中不存在编号为 3 的结点,因此它 的右孩子不存在。 当i>1 时:① 若i是第j层的第一个结点,则i=2j,且其左孩子为j+1 层的第一个结点, 编号为 2j+1 = 2(2j) = 2i,如果 2i>n,则无左孩子;其右孩子为j+1 层的第二个结点,编 号为 2i+1,如 果 2i+1>n,则无右孩子。 ② 假设第j层上某个结点的编号为i,且 2i+1((HuffmanTreeNode)l.get(j)).getWeight()){ l.insert(j,node); return; 121 } l.insert(l.getSize(),node); } 算法 6-7 说明:算法使用一个线性表l保存在生成Huffman树过程中森林F的所有树的根 结点,并保持在线性表中这些根结点的权值从大到小有序。不难知道当线性表采用数组实现 时方法insertToList的运行时间为Ο(n)。因此初始化将n个叶子结点插入线性表的时间为Ο(n2)。 在有线性表l之后,取得最小权值的 2 个根结点,只需要Ο(1)的时间,合并 2 棵树需要Ο(1) 时间,将新树插入线性表l需要Ο(n)时间,循环执行n-1 次,因此构造Huffman树的时间为Ο(n2)。 综上所述,算法buildHuffmanTree的时间复杂度T(n)= Ο(n2)。 Huffman 编码可以在 Huffman 树中递归生成,算法 6-8 实现了这个操作。 算法 6-8 generateHuffmanCode 输入:Huffman 树根结点 输出:生成 Huffman 编码 代码: //递归生成 Huffman 编码 private static void generateHuffmanCode(HuffmanTreeNode root){ if (root==null) return; if (root.hasParent()){ if (root.isLChild()) root.setCoding(root.getParent().getCoding() + "0"); //向左为 0 else root.setCoding(root.getParent().getCoding() + "1"); //向右为 1 } generateHuffmanCode(root.getLChild()); generateHuffmanCode(root.getRChild()); } 122 第七章 图 图是一种较线性结构和树结构更为复杂的数据结构,在图结构中数据元素之间的关系可 以是任意的,图中任意两个数据元素之间都可能相关。由此,图的应用也极为广泛,在诸如 系统工程、控制论、人工智能、计算机网络等许多领域中,都将图作为解决问题的数学手段 之一。在离散数学中主要侧重于图的理论研究,在本章中主要是讨论图在计算机中的表示, 以及使用图解决一些实际问题的算法实现。 4.4 图的定义 4.4.1 图及基本术语 图(graph)是一种网状数据结构,图是由非空的顶点集合和一个描述顶点之间关系的 集合组成。其形式化的定义如下: Graph = ( V , E ) V = {x| x∈某个数据对象} E = {| P(u , v)∧(u,v∈V)} V 是具有相同特性的数据元素的集合,V 中的数据元素通常称为顶点(Vertex),R 是 两个顶点之间关系的集合。P(u , v)表示 u 和 v 之间有特定的关联属性。 若∈E,则表示从顶点 u 到顶点 v 的一条弧,并称 u 为弧尾或起始点,称 v 为弧头或终止点,此时图中的顶点之间的连线是有方向的,这样的图称为有向图(directed graph)。 若∈E 则必有∈E,即关系 E 是对称的,此时可以使用一个无序对(u , v) 来代替两个有序对,它表示顶点 u 和顶点 v 之间的一条边,此时图中顶点之间的连线是没有 方向的,这种图称为无向图(undirected graph)。 在无向图和有向图中 V 中的元素都称为顶点,而顶点之间的关系却有不同的称谓,即 弧或边,本章中有些内容是既涉及无向图也涉及有向图的,因此在描述图中顶点之间的关系 时,分别称为弧或边较为麻烦,我们统一的将它们称为边(edge)。并且我们还约定顶点集 与边集都是有限的,并记顶点与边的数量为|V|和|E|。 通过图的以上形式化定义,我们看到本章所讨论的“图”,并非通常所指的图形、图像 或数学上的函数图。 图 7-1 分别给出了一个无向图和有向图的示例。 图 7-1 图的示例 a c e d b a c e d b (a)无向图 (b)有向图 123 „ 简单图 图中所有的边不见得就是构成一个集合,准确地说它们构成一个复集——允许出现重复 元素的集合。例如,若在某对定点之间有多条无向边,就属于这种情况,此时的图也可以含 有实际的意义,比如用顶点表示城市,用边表示城市之间的航线,则有可能在一对城市之间 存在多条航线。另外在图中还有一种特殊情况:某条边的两个顶点是同一个顶点。 不过,以上特殊情况并不多见。不含上述特殊边的图,称为简单图。对简单图而言,图 中所有的边自然构成一个集合,并且每条边的两个顶点均不相同。在本章中所讨论的图均是 简单图。 „ 邻接点 对于无向图 G = ( V , E ),如果边(u , v) ∈E,则 称顶 点 u 与顶点 v 互为邻接点。边(u , v) 依附于顶点 u 和 v,或者说边(u , v)与顶点 u 和 v 相关联。 对于有向图 G = ( V , E ),如果边 ∈E,则称定点 u 邻接到顶点 v,顶点 v 邻接自 顶点 u,或称 v 为 u 的邻接点,u 为 v 的逆邻接点。同样我们称边与顶点 u 和 v 相关 联。从顶点 u 出发的边也称为 u 的出边或邻接边,而指向顶点 u 的边也称为 u 的入边或逆邻 接边。 „ 顶点的度、入度、出度 顶点的度(degree)是指依附于某顶点 v 的边数,通常记为 TD (v)。 在有向图中,要区别顶点的入度与出度的概念。顶点 v 的入度(in degree)是指以顶点 为终点的边的数目,记为 ID (v);顶点 v 出度(out degree)是指以顶点 v 为起始点的边的 数目,记为 OD (v)。对于有向图有 TD(v) = ID(v) + OD(v)。在无向图中每条边都可以看成 出边,也可以看成入边,此时 TD(v) = ID(v) = OD(v)。 例如在图 7-1(a)所示的无向图中 TD(a) = 3,TD(c) = TD(d) = TD(e) = 2,TD(b) = 1。 而在图 7-1(b)所示的有向图中 ID(a) = 2,ID(c) = ID(d) = ID(e) = 1,ID(b) = 0;OD(a) = OD(b) = OD(c) = OD(d) = OD(e) = 1;TD(a) = 3,TD(c) = TD(d) = TD(e) = 2,TD(b) = 1。 通过观察可以有以下观察结论。对于任何无向图G = ( V , E ),都有ΣTD(vi) = 2|E|,其 中vi∈V;因为在无向图中计算各点度数之和时,每条边都恰好被统计了两次。另外对于任 何有向图G = ( V , E ),都有∑ID(vi) = ∑OD(vi) = |E|,其中v∈V;这是因为在计算各个顶点 的出(入)度的过程中,每条有向边都只被统计了一次。由此对于有向图而言TD(vi) = ID(vi) + OD(vi) = 2|E|。通过以上分析,我们有以下结论: 观察结论 7.1 在任何图G = ( V , E )中,|E| = (∑TD(vi))/2。 „ 完全图 、稠密图、稀疏图 假设图中顶点个数为 n,边数为 m。 在无向图中当每个顶点都与其余 n-1 个顶点邻接时,图的边数达到最大,此时图中每两 个顶点之间都存在一条无向边,边数 m 为 n 个顶点任意取出 2 个的组合数,即 m = n(n-1)/2。 同样有向图中当每个顶点都有 n-1 条出边并有 n-1 条入边时,图中边数达到最大,此时 图中每两个顶点之间都存在方向不同的两条边,边数 e 为在 n 个顶点中任意取出 2 个并进行 排列的排列组合数,即 m = n(n-1)。 观察结论 7.2 假设在图 G = ( V , E )中有 n 个顶点和 m 条边。 1) 若 G 是无向图,则有 0 ≤ m ≤ n(n-1)/2。 2) 若 G 是有向图,则有 0 ≤ m ≤ n(n-1)。 由此,在具有n个顶点的图中,边的数目为Ο(n2)。由于图中边数与顶点数并非线性关系, 因此在对有关图的算法时间复杂度、空间复杂度进行分析时,我们往往以图中的顶点数和边 124 数作为问题的规模。 有 n(n-1)/2 条边的无向图称为无向完全图;有 n(n-1)条边的有向图称为有向完全图。有 很少边(如 m < n log n)的图称为稀疏图,反之边较多的图称为稠密图。 „ 子图 设图 G = ( V , E )和图 G' = ( V' , E' )。 如果 且 ,则称 G'是 G 的一个子图(subgraph)。以图 7-1(a)为例,若 V' = { a , b , c , d }且 E' = {( a , b ) , ( a , c ) , ( a , d )},则 G' = ( V' , E' )就是图 G 的子图。 VV'⊆ EE'⊆ 如果 且 ,则称 G'是 G 的一个生成子图(spanning subgraph)。图 7-2 显示 了子图与生成子图的示例。 VV'= EE'⊆ 图 7-2 子图与生成子图 a c e d b (a)原图 (b)子图 a c d b a c e d b (c)生成子图 „ 路径、环路及可达分量 所谓图中的一条通路或路径(path),就是由m+1 个顶点与m条边交替构成的一个序列ρ = { v0, e1 , v1 , e2 , v2 , … , em , vm},m ≥ 0,且ei = (vi-1 , vi),1 ≤ i ≤ m。路径上边的数目称为路 径长度,计作|ρ|。 长度|ρ| ≥ 1 的路径,若路径的第一个顶点与最后一个顶点相同,则称之为环路或环 (cycle)。 如果组成路径ρ的所有顶点各不相同,则称之为简单路径(simple path);如果在组成环 的所有顶点中,除首尾顶点外均各不相同,则称该环为简单环路(simple cycle)。如果组成 路径ρ的所有边都是有向边,且ei均是从vi-1指向vi,1 ≤ i ≤ m,则称ρ为有向路径,同样可以 定义有向环路。 在描述简单图的路径或环路时,我们只需要依次给出组成路径或环路的各个顶点,而不 必再给出具体的边。例如在图 7-1(b)中 {a , d , e , c}是一条简单有向通路,而{d , e , c , a , d} 是一条简单有向环路。 在有向图 G 中,若从顶点 s 到顶点 v 有一条通路,则称 v 是从 s 可达的。对于顶点 s, 从 s 可达的所有顶点所组成的集合,称作 s 在 G 中对应的可达分量。例如在图 7-1(b)中 顶点 a 的可达分量为顶点集{a , d , e , c}。 „ 连通性与连通分量 在无向图中,如果从一个顶点vi到另一个顶点vj(i≠j)有路径,则称顶点vi和vj是连通的。 如果图中任意两顶点vi、vj∈V,vi和vj都是连通的,则称该图是连通图(connected graph)。 例如,图 7-2(a)中的图是连通图;而图 7-2(c)中的图是非连通图,但该图有两个连通 分量。所谓连通分量(connected component),是指无向图的极大连通子图。显然任何连通 图的连通分量只有一个,即本身。而非连通图有多个连通分量,各个连通分量之间是分离的, 没有任何边相连。 在有向图中,若图中任意一对顶点vi和vj (i≠j)均有一条从顶点vi到另一个顶点vj的路径, 也有从vj到vi的路径,则称该有向图是强连通图。有向图的极大强连通子图称为强连通分量。 显然任何强连通图的强连通分量只有一个,即本身。而非强连通图有多个强连通分量,各个 125 强连通分量内部的任意顶点之间是互通的,在各个强连通分量之间可能有边也可能没有边存 在。例如图 7-3(a)中的图是非强连通图,它有两个强连通分量,如图 7-3(b)所示;如 果在图 7-3(a)的图中添加一条有向边,则可以得到一个强连通图,如图 7-3(c)所 示。 图 7-3 强连通图与强连通分量 a c e d b (a) (b) a c e d b a c e d b (c) „ 无向图的生成树 对于无向图 G = ( V , E )。如果 G 是连通图,则 G 的生成树(spanning tree),是 G 的 一个极小连通生成子图。 图 G 的生成树必定包含图 G 的全部 n 个顶点,以及足以构成一棵树的 n-1 条边。图 7-2 (a)中图 G 的生成树如图 7-4 所示。在生成树中添加任意一条属于原图中的边必定会产生 回路,因为生成树本身是连通的,新添加的边使其所依附的两个顶点之间有了第二条路径。 若生成树中减少任意一条边,则必然成为非连通的,因 为生成树是极小连通生成子图。 一棵有 n 个顶点的生成树有且仅有 n-1 条边。如果 一个图有 n 个顶点和小于 n-1 条边,则是非连通图。如 果它有多于 n-1 条边,则一定有环路,不是极小连通生 成子图。但是,有 n-1 条边的生成子图不一定是生成树。 例如图 7-2(c)中的图有 n-1 = 4 条边,但却不是图 7-2 (a)中图 G 的生成树。 图 7-4 无向图的生成树 a c e d b 7-2(a)的生成树 如果在生成树中确定某个顶点作为根结点,则生成树就可以成为我们在第六章中介绍的 树结构。 „ 权与网 在实际应用中,图不但需要表示元素之间是否存在某种关系,而且图的边往往与具有一 定实际意义的数有关,即每条边都有与它相关的实数,称为权。这些权值可以表示从一个顶 点到另一个顶点的距离或消耗等信息,在本章中假设边的权均为正数。这种边上具有权值的 图称为带权图(weighted graph)或网(network)。图 7-5 中的图就是带权图。 图 7-5 带权图 1 52 3 3 4 21 3 6 a c e d b a c e d b (a) (b) 126 4.4.2 抽象数据类型 与其他数据结构一样,在介绍图的存储结构之前,先给出图的抽象数据类型和 Java 接 口。在这里与前面介绍的数据结构不同的是,图有无向图和有向图之分,有些操作是无向图 支持的,例如我们只求无向图的最小生成树;而有些操作是只有有向图才支持的,例如拓扑 排序和求关键路径。 下面给出图的抽象数据类型定义。 ADT Graph{ 数据对象 D:D 是具有相同性质的数据元素的集合。 数据关系 R:R = {| P(u , v)∧(u,v∈D)} 基本操作: 序号 方法 功能描述 ⑴ getType() 输入参数:无 返回参数:整数,图的类型。 功能:返回当前图的类型。 ⑵ getVexNum() getEdgeNum() 输入参数:无 返回参数:整数。 功能:返回图中顶点数。返回图中边数。 ⑶ getVertex() getEdge() 输入参数:无 返回参数:迭代器 功能:返回图中所有顶点的迭代器。返回图中所有边的迭代器。 ⑷ remove(v) remove(e) 输入参数:顶点 v。边 e。 返回参数:无 功能:在图中删除特定的顶点 v。在图中删除特定的边。 ⑸ insert(v) insert(e) 输入参数:顶点 v。边 e。 返回参数:无 功能:在图的顶点集中添加一个新顶点。在图的边集中添加一 条新边。 ⑹ areAdjacent(u, v) 输入参数:顶点 u、v 返回参数:boolean 功能:判断顶点 v 是否为顶点 u 的邻接顶点。 ⑺ edgeFromTo(u, v) 输入参数:顶点 u、v 返回参数:边 功能:返回从顶点 u 到顶点 v 的边,如果不存在返回空。 ⑻ adjVertexs(u) 输入参数:顶点 u 返回参数:迭代器 功能:返回顶点 u 的所有邻接点。 ⑼ DFSTraverse(v) 输入参数:顶点 v 返回参数:迭代器 功能:从顶点 v 开始深度优先搜索遍历图。 ⑽ BFSTraverse(v) 输入参数:顶点 v 返回参数:迭代器 功能:从顶点 v 开始广度优先搜索遍历图。 127 ⑾ shortestPath(v) 输入参数:顶点 v 返回参数:迭代器 功能:求顶点 v 到图中所有顶点的最短路径。 ⑿ generateMST() 输入参数:无 返回参数:无 功能:求无向图的最小生成树。有向图不支持此操作。 ⒀ toplogicalSort() 输入参数:无 返回参数:迭代器 功能:求有向图的拓扑序列。无向图不支持此操作。 ⒁ criticalPath() 输入参数:无 返回参数:无 功能:求有向无环图的关键路径。无向图不支持此操作。 }ADT Graph 对应于上述抽象数据类型,下面给出图的 Java 接口。 代码 7-1 图的接口定义 public interface Graph { public static final int UndirectedGraph = 0;//无向图 public static final int DirectedGraph = 1;//有向图 //返回图的类型 public int getType(); //返回图的顶点数 public int getVexNum(); //返回图的边数 public int getEdgeNum(); //返回图的所有顶点 public Iterator getVertex(); //返回图的所有边 public Iterator getEdge(); //删除一个顶点 v public void remove(Vertex v); //删除一条边 e public void remove(Edge e); //添加一个顶点 v public Node insert(Vertex v); //添加一条边 e public Node insert(Edge e); //判断顶点 u、v 是否邻接,即是否有边从 u 到 v public boolean areAdjacent(Vertex u, Vertex v); //返回从 u 指向 v 的边,不存在则返回 null public Edge edgeFromTo(Vertex u, Vertex v); //返回从 u 出发可以直接到达的邻接顶点 public Iterator adjVertexs(Vertex u); //对图进行深度优先遍历 public Iterator DFSTraverse(Vertex v); 128 //对图进行广度优先遍历 public Iterator BFSTraverse(Vertex v); //求顶点 v 到其他顶点的最短路径 public Iterator shortestPath(Vertex v); //求无向图的最小生成树,如果是有向图不支持此操作 public void generateMST() throws UnsupportedOperation; //求有向图的拓扑序列,无向图不支持此操作 public Iterator toplogicalSort() throws UnsupportedOperation; //求有向无环图的关键路径,无向图不支持此操作 public void criticalPath() throws UnsupportedOperation; } 其中 UnsupportedOperation 是调用图不支持的操作时抛出的异常,定义如下: 代码 7-2 UnsupportedOperation 异常 public class UnsupportedOperation extends RuntimeException { public UnsupportedOperation(String err) { super(err); } } 4.5 图的存储方法 在介绍图的存储结构之前,先明确一个概念,即“顶点在图中的位置”。从图的逻辑结 构定义来看,无法将图中的顶点排列成为一个唯一的线性序列。在图中,可以将任何一个顶 点看成是图的第一个顶点。同理,对于任何一个顶点而言,它的邻接点之间也不存在顺序关 系。但为了对图的存储和操作能更加方便,需要将图中的顶点按任意序列排列起来(该排列 顺序完全是人为规定的)。所谓“顶点在图中的位置”就是指该顶点在人为确定的序列中的 位置。同理,也可以对某个顶点的邻接点进行人为的排序,在这个序列中自然的形成了第 i 个邻接点的概念。 由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在存 储区的位置来表示元素之间的关系,即图没有顺序映像的存储结构,但可以借助数组来表示 数据元素之间的关系。 4.5.1 邻接矩阵 图的邻接矩阵(adjacent matrix)表示法是使用数组来存储图结构的方法,也被称为数 组表示法。它采用两个数组来表示图:一个是用于存储所有顶点信息的一维数组,另一个是 用于存储图中顶点之间关联关系的二维数组,这个关联关系数组也被称为邻接矩阵。 假设图G=(V , E)有n个顶点,即V={v0,v1,…,vn-1},则表示G中各顶点关联关系的为一 个n×n的矩阵A,矩阵的元素为: A[i , j] 1 或(u , v)∈E ∞ 反之 图 7-6 中两个图的邻接矩阵分别为: 129 a d b c a d b c (a) (b) 图 7-6 使用邻接矩阵存储图 a b c d a b c d ∞ 1 1 1 1 1 1 1 1 ∞ ∞ ∞ ∞ ∞ ∞ ∞ a b c d a b c d ∞ 1 ∞ 1 ∞ 1 1 1 ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ 并且,此时顶点 a、b、c、d 在存储顶点的数组中所对应的下标分别为 0、1、2、3。 实际上这一表示形式也可以推广至带权图,若 G 是一个有 n 个顶点的带权图,则它的 邻接矩阵是具有如下性质的 n×n 的矩阵 A: A[i , j] Wij 或(u , v)∈E ∞ 反之 图 7-7 给出了一个有向带权图和它的邻接矩阵。 图 7-7 带权图及邻接矩阵 (a) (b) 2 7 4 1 5 2 3 3a d e b c a b c d a b c d ∞ 3 3 ∞ ∞ ∞ ∞ 4 1 ∞ ∞ 7 ∞ 5 ∞ ∞ ∞ ∞ ∞ 2 ∞ 2 ∞ ∞ ∞ e e 从图的邻接矩阵存储方法容易看出:首先,无向图的邻接矩阵一定是一个对称矩阵。因 此,在具体存放邻接矩阵时只需存放上(或下)三角矩阵的元素即可。其次,对于无向图, 邻接矩阵的第i行(或第i列)非∞元素的个数正好是第i个顶点的度TD(vi)。再次,对于有向 图,邻接矩阵的第i行(第i列)非∞元素的个数正好是第i个顶点的出度OD(vi)(入度ID(vi))。 最后,通过邻接矩阵很容易确定图中任意两个顶点之间是否有边相连;但是,要确定图中有 多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。 从空间上看,不论顶点u、v之间是否有边,在邻接矩阵中都需预留存储空间,因为每条 边所需的存储空间为常数,所以邻接矩阵需要占用Θ(n2)的空间,这一空间效率较低。具体 来说,邻接矩阵的不足主要在两个方面。首先,尽管由n个顶点构成的图中最多可以有n2条 边,但是在大多数情况下,边的数目远远达不到这个量级,因此,在邻接矩阵中大多数单元 都是闲置的。其次,矩阵结构是静态的,其大小N需要预先估计,然后创建N×N的矩阵。然 而,图的规模往往是动态变化的,N的估计过大会造成更多的空间浪费,如果估计过小则经 常会出现空间不够用的情况。 130 4.5.2 邻接表 由上面的分析可知,邻接矩阵的空间效率之所以低,是因为其中大量的单元所对应的边 有可能并未在图中出现,这是静态数组结构不可避免的问题。既然如此,则可以将静态的存 储结构改为动态的链式存储结构。按照这一思路可以得到图的另一种表示形式,即邻接表。 邻接表(adjacency list)是图的一种链式存储方法,邻接表表示法类似于树的孩子链表 表示法。在邻接表中对于图G中的每个顶点vi建立一个单链表,将所有邻接于vi的顶点vj链成 一个单链表,并在表头附设一个表头结点,这个单链表就称为顶点vi的邻接表。 在邻接表中共有两种结点结构,分别是边表结点和表头结点。每个边表结点由 3 个域组 成,如图 7-8(a)所示。其中邻接点域(adjvex)指示与顶点vi邻接的顶点在图中的位置, 链域(nextedge)指向下一条边所在的结点,数据域(info)存储和边有关的信息,如权值 等信息。在头结点中,结构如图 7-8(b)所示,除了设有链域(firstedge)指向链表中的第 一个结点之外,还有用于存储顶点vi相关信息的数据域(data)。 图 7-8 邻接表结点结构 adjvex nextedgeinfo 边表结点 firstedge data 头结点 (a) (b) 这些表头结点(可以链接在一起)以顺序的结构形式进行存储,以便随机访问任一顶点 的链表。图 7-9 给出了图的邻接表存储示例。 图 7-9 图的邻接表 e1 e3 e2 e4 a d b c (a) (b)a 图的邻接表 e8 e3 e5 e6 e4 e7 e2 e1 a d e b c 0 1 2 3 a b c d 1 0 0 0 ∧ e1 e1 e2 e3 2 e2 3 ∧ e3 3 ∧e4 1 ∧e4 (c) (d)c 图的邻接表 0 1 2 3 a b c d 1 4 1 0 ∧ e1 e7 e3 e5 2 ∧ e2 1 ∧ e6 4 e 3 ∧e8 3 ∧ e4 (e)c 图的逆邻接表 0 1 2 3 a b c d 3 1 0 2 ∧ ∧ e5 e1 e2 e4 4 ∧e8 4 e 1 ∧e7 2 e3 3 ∧ e6 131 就存储空间而言,对于 n 个顶点、m 条边的无向图,若采用邻接表作为存储结构,则需 要 n 个表头结点和 2m 个边表结点。显然在边稀疏(m<和与之关联的两个顶点 u、v 为例来说明图中顶点和边的结构以及它们之间的联系。 132 顶点、边的结构以及它们之间的联系如图 7-11 所示。 在顶点中有 3 个重要的指针域:顶点位置域、邻接边域、逆邻接边域。其中顶点位置域 指向顶点在顶点链接表中所在的结点,以此可以在Ο(1)时间内确定顶点在图中的位置。在 无向图中顶点的邻接边域指向的链接表存储了与该顶点关联的所有边的引用,顶点的逆邻接 边域为空;而在有向图中,顶点的邻接边域指向的链接表存储了该顶点所有出边的引用,顶 点的逆邻接边域指向的链接表存储了该顶点所有入边的引用。邻接边域和逆邻接边域相当于 图中顶点的邻接表和逆邻接表。通过这两个域可以很快的找到与该顶点相连的所有顶点和边 的信息。 图 7-11 顶点与边的结构 顶点位置 顶点信息 邻接边 逆邻接边 … … … … … 顶点链接表 边链接表 顶点位置 顶点信息 邻接边 逆邻接边 … 边位置 边信息 第一边表位置 第二边表位置 … 第一顶点 第二顶点 … … … … 边 顶点 u 顶点 v 在边中有 5 个重要的指针域:第一顶点域、第二顶点域、第一边表位置域、第二边表位 置域、边位置域。在有向图中,第一顶点域指向该边的起始顶点在顶点表中的位置,第二顶 点域指向该边的终止顶点在顶点表中的位置;如果是无向图,则分别指向边的两个顶点在顶 点表中的位置;通过这两个域可以在Ο(1)时间内定位与边关联的顶点。在有向图中,第一 边表位置域指向边在其起始点的出边表中的位置,第二边表位置域指向边在其终止点的入边 表中的位置;如果是无向图,则这两个域分别指向边在其第一、第二顶点的邻接边表(无向 图的顶点只有邻接边表,无逆邻接边表)中的位置。边位置域指向边在边表中的位置,通过 该域可以在Ο(1)时间内定位边在图中的位置。 如此,存储了图中所有的顶点与边,以及顶点与边的相邻关系,则存储了整个图结构。 下面给出双链式存储结构中顶点与边的 Java 定义。 代码 7-3 双链式存储结构的顶点定义 public class Vertex { private Object info; //顶点信息 private LinkedList adjacentEdges; //顶点的邻接边表 private LinkedList reAdjacentEdges; //顶点的逆邻接边表,无向图时为空 private boolean visited; //访问状态 133 private Node vexPosition; //顶点在顶点表中的位置 private int graphType; //顶点所在图的类型 private Object application; //应用。如求最短路径时为 Path,求关键路径时为 Vtime //构造方法:在图 G 中引入一个新顶点 public Vertex(Graph g, Object info) { this.info = info; adjacentEdges = new LinkedListDLNode(); reAdjacentEdges = new LinkedListDLNode(); visited = false; graphType = g.getType(); vexPosition = g.insert(this); application = null; } //辅助方法:判断顶点所在图的类型 private boolean isUnDiGraphNode(){ return graphType==Graph.UndirectedGraph;} //获取或设置顶点信息 public Object getInfo(){ return info;} public void setInfo(Object obj){ this.info = info;} //与顶点的度相关的方法 public int getDeg(){ if (isUnDiGraphNode()) return adjacentEdges.getSize(); //无向图顶点的(出/入)度为邻接边表规模 else return getOutDeg()+getInDeg(); //有向图顶点的度为出度与入度之和 } public int getOutDeg(){ return adjacentEdges.getSize(); //有(无)向图顶点的出度为邻接表规模 } public int getInDeg(){ if (isUnDiGraphNode()) return adjacentEdges.getSize(); //无向图顶点的入度就是它的度 else return reAdjacentEdges.getSize(); //有向图顶点入度为逆邻接表的规模 } //获取与顶点关联的边 public LinkedList getAdjacentEdges(){ return adjacentEdges;} public LinkedList getReAdjacentEdges(){ if (isUnDiGraphNode()) return adjacentEdges; //无向图顶点的逆邻接边表就是其邻接边表 else return reAdjacentEdges; 134 } //取顶点在所属图顶点集中的位置 public Node getVexPosition(){ return vexPosition;} //与顶点访问状态相关方法 public boolean isVisited(){ return visited;} public void setToVisited(){ visited = true;} public void setToUnvisited(){ visited = false;} //取或设置顶点应用信息 protected Object getAppObj(){ return application;} protected void setAppObj(Object app){ application = app;} //重置顶点状态信息 public void resetStatus(){ visited = false; application = null; } } 代码 7-3 说明:在 Vertex 中除了用于表示前面介绍的顶点中 3 个重要指针域的成员变量 之外,还有 info、visited、graphType 和 application 四个成员变量。info 主要用于存储顶点的 信息;visited 表示顶点的访问状态,在图的遍历、求最短路径等操作中使用;graphType 用 来表示顶点所在图的类型,在有向图和无向图中顶点的操作实现有一些差别;application 也 是在求最短路径等操作的实现中使用,具体的用法在后面详细介绍。 Vertex 中方法的基本功能如表 7-1 所示,而且方法的正确性不难理解。代码 7-3 中所有 方法的时间复杂度均为Ο(1)。 表 7-1 Vertex 类中方法的功能 序号 方法 功能描述 ⑴ getInfo() 取顶点信息。 ⑵ setInfo(info) 设置顶点信息。 ⑶ getDeg() 返回点的度。 ⑷ getOutDeg() 返回点的出度。 ⑸ getInDeg() 返回点的入度。 ⑹ getAdjacentEdges() 返回顶点的所有邻接边。 ⑺ getReAdjacentEdges() 返回顶点的所有逆邻接边。 ⑻ getVexPosition() 返回顶点在图顶点集中的位置,即在顶点链接表中的位置。 ⑼ isVisited() 判断顶点在某操作实现中是否被访问过。 ⑽ setToVisited() 将顶点访问状态设置为“已访问”。 ⑾ setToUnvisited() 将顶点访问状态设置为“未访问”。 ⑿ getAppObj() 取顶点应用状态信息。 ⒀ setAppObj(obj) 设置顶点应用状态信息。 ⒁ resetStatus() 重置顶点的所有状态信息,包括访问、应用状态。 135 在双链式存储结构中除了需要定义顶点还需要定义边,代码 7-4 给出了边的定义。 代码 7-4 双链式存储结构的边定义 public class Edge { public static final int NORMAL = 0; public static final int MST = 1; //MST 边 public static final int CRITICAL = 2;//关键路径中的边 private int weight; //权值 private Object info; //边的信息 private Node edgePosition; //边在边表中的位置 private Node firstVexPosition; //边的第一顶点与第二顶点 private Node secondVexPosition; //在顶点表中的位置 private Node edgeFirstPosition; //边在第一(二)顶点的邻接(逆邻接)边表中的位置 private Node egdeSecondPosition;//在无向图中就是在两个顶点的邻接边表中的位置 private int type; //边的类型 private int graphType; //所在图的类型 //构造方法:在图 G 中引入一条新边,其顶点为 u、v public Edge(Graph g, Vertex u, Vertex v, Object info){ this(g,u,v,info,1); } public Edge(Graph g, Vertex u, Vertex v, Object info, int weight) { this.info = info; this.weight = weight; edgePosition = g.insert(this); firstVexPosition = u.getVexPosition(); secondVexPosition = v.getVexPosition(); type = Edge.NORMAL; graphType = g.getType(); if (graphType==Graph.UndirectedGraph){ //如果是无向图,边应当加入其两个顶点的邻接边表 edgeFirstPosition = u.getAdjacentEdges().insertLast(this); egdeSecondPosition = v.getAdjacentEdges().insertLast(this); }else { //如果是有向图,边加入起始点的邻接边表,终止点的逆邻接边表 edgeFirstPosition = u.getAdjacentEdges().insertLast(this); egdeSecondPosition = v.getReAdjacentEdges().insertLast(this); } } //get&set methods public Object getInfo(){ return info;} public void setInfo(Object obj){ this.info = info;} public int getWeight(){ return weight;} public void setWeight(int weight){ this.weight = weight;} public Vertex getFirstVex(){ return (Vertex)firstVexPosition.getData();} 136 public Vertex getSecondVex(){ return (Vertex)secondVexPosition.getData();} public Node getFirstVexPosition(){ return firstVexPosition;} public Node getSecondVexPosition(){ return secondVexPosition;} public Node getEdgeFirstPosition(){ return edgeFirstPosition;} public Node getEdgeSecondPosition(){ return egdeSecondPosition;} public Node getEdgePosition(){ return edgePosition;} //与边的类型相关的方法 public void setToMST(){ type = Edge.MST;} public void setToCritical(){ type = Edge.CRITICAL;} public void resetType(){ type = Edge.NORMAL;} public boolean isMSTEdge(){ return type==Edge.MST;} public boolean isCritical(){ return type==Edge.CRITICAL;} } 代码 7-4 说明:在 Edge 中除了用于表示前面介绍的边中 5 个重要指针域的成员变量之 外,还有 4 个成员变量:weight、info、graphType 、type。其中 info 和 weight 都是用来表 示边的信息,但由于权值和边的其他信息相比更重要,并且会经常用到,因此将权值单独作 为一个成员变量;graphType 与顶点中该变量的意义相同,是表示边所在图的类型;type 是 用来表示边的类型的,目前只定义了 2 种特殊类型的边,一种是无向图最小生成树的边,一 种是有向无环图关键路径中的边。Edge 类的构造方法是在图 G 中引入一条新边,因此边在 加入边链接表的同时,还需要视图的类型将边加入与之关联的两个顶点的邻接边表或逆邻接 边表中去;而且,在构造边时可以同时指定其权值,如果不指定则权值默认为 1。 Edge 中方法的基本功能如表 7-2 所示,而且方法的正确性不难理解。代码 7-4 中所有方 法的时间复杂度均为Ο(1)。 表 7-2 Edge 类中方法的功能 序号 方法 功能描述 ⑴ getInfo() 取边信息。 ⑵ setInfo(info) 设置边信息。 ⑶ getWeight() 取边的权值。 ⑷ setWeight(weight) 设置边的权值。 ⑸ getFirstVex() 返回边的第一个顶点,有向图时的起始点。 ⑹ getSecondVex() 返回边的第二个顶点,有向图时的终止点。 ⑺ getFirstVexPosition() 返回边的第一个顶点在顶点集中的位置。 ⑻ getSecondVexPosition() 返回边的第二个顶点在顶点集中的位置。 ⑼ getEdgeFirstPosition() 返回边在第一顶点的边表中的位置。 ⑽ getEdgeSecondPosition() 返回边在第二顶点的边表中的位置。 ⑾ getEdgePosition() 返回边在图的边集中的位置。 ⑿ setToMST() 将边设置为最小生成树中的边。 ⒀ setToCritical() 将边设置为关键路径中的边。 ⒁ resetType() 重置边的类型,设置为普通边。 ⒂ isMSTEdge() 判断边是否是最小生成树中的边。 ⒃ isCritical() 判断边是否是关键路径中的。 137 4.6 图 ADT 实现设计 与线性结构、树结构抽象数据类型实现不同,图结构的抽象数据类型实现不是简单的写 一个类实现 Graph 接口即可。由于图有无向图和有向图之分,在 Graph 接口中有些接口方法 是有向图支持的,有些是无向图支持的,有些是二者都支持的;并且在二者都支持的操作中 有些操作的实现算法是一致的,有些操作实现的算法是有区别的;因此我们需要详细设计图 ADT 的实现方法。 一种简单的实现方法是编写两个类,一个类对应于有向图,一个类对应于无向图,这两 个类分别实现 Graph 接口。然而这种实现会造成两个类中具有许多重复的代码(两个类都支 持且具有相同算法的操作的代码),这样做既不利于代码的维护与管理,也违反了重构原则。 为此,我们对图 ADT 的实现作如下设计:首先,确定无向图与有向图都支持的操作中实现 算法相同的操作(见表 7-3),将这些操作的 实现放在一个抽象类 AbstractGraph 中;其次, 将两类图都支持但是实现算法不同的操作(见 表 7-4)放在两个不同的类 DirectGraph 和 UndirectedGraph 中分别实现,当然 DirectGraph 和 UndirectedGraph 类都继承自 AbstractGraph 抽象类;最后,在 DirectGraph 类中实现只有有向图才支持的操作,在 UndirectedGraph 类中实现只有无向图才支持 的操作。Graph 接口、AbstractGraph 抽象类、 DirectGraph 类、UndirectedGraph 类之间的关 系如图 7-12 所示。 图 7-12 图 ADT 实现结构 Graph AbstractGraph DirectGraph UndirectedGraph 上面介绍了图 ADT 实现中需要的类及其相互之间的关系,下面我们需要确定在各个类 中实现的具体操作有哪些。可以在 AbstractGraph 抽象类中实现的操作由表 7-3 列出。 表 7-3 AbstractGraph 抽象类实现的方法 序号 方法 功能描述 ⑴ getType() 返回当前图的类型。 ⑵ getVexNum() getEdgeNum() 返回图中顶点数。返回图中边数。 ⑶ getVertex() getEdge() 返回图中所有顶点的迭代器。返回图中所有边的迭代器。 ⑸ insert(v) insert(e) 在图的顶点集中添加一个新顶点。在图的边集中添加一条新 边。 ⑹ areAdjacent(u, v) 判断顶点 v 是否为顶点 u 的邻接顶点。 ⑼ DFSTraverse(v) 从顶点 v 开始深度优先搜索遍历图。 ⑽ BFSTraverse(v) 从顶点 v 开始广度优先搜索遍历图。 ⑾ shortestPath(v) 求顶点 v 到图中所有顶点的最短路径。 两类图都支持但是实现算法不同需要在 DirectGraph 类和 UndirectedGraph 类中分别实现的操 作由表 7-4 列出。除此之外,还剩下 3 个操作 generateMST()、toplogicalSort()、criticalPath()。 其中操作 generateMST()由无向图单独实现,操作 toplogicalSort()和 criticalPath()由有向图单 独实现。在 DirectGraph 类和 UndirectedGraph 类中如果遇到不支持的操作则直接抛出代码 7-2 中定义的 UnsupportedOperation 异常。 138 表 7-4 DirectGraph 和 UndirectedGraph 分别实现的方法 序号 方法 功能描述 ⑷ remove(v) remove(e) 在图中删除特定的顶点 v。在图中删除特定的边。 ⑺ edgeFromTo(u, v) 返回从顶点 u 到顶点 v 的边,如果不存在返回空。 ⑻ adjVertexs(u) 返回顶点 u 的所有邻接点。 图 ADT 的具体实现见本书提供的源代码,下面我们就图 ADT 所支持的操作中较为重 要,并较为复杂的操作分为四节进行详细介绍。 4.7 图的遍历 和树的遍历类似,在图中也存在遍历问题。图的遍历就是从图中某个顶点出发,按某种 方法对图中所有顶点访问且仅访问一次。图的遍历算法是求解图的连通性问题、拓扑排序和 求关键路径等算法的基础。 图的遍历要比树的遍历要复杂的多。由于图中顶点关系是任意的,任一顶点都可能和其 余的顶点相邻接;图可能是连通图也可能是非连通图;图中可能还存在环路,在访问了某个 顶点之后,可能沿着某条搜索路径又回到该顶点。为了保证图中的各个顶点在遍历过程中访 问且仅被访问一次,需要为每个顶点设一个访问标志,Vertex 类中的 visited 成员变量可以用 来作为是否被访问过的标志。 对于图的遍历,通常有两种方法,即深度优先搜索和广度优先搜索。这两种遍历方法对 有向图和无向图均适用,因此这两个操作在 AbstractGraph 抽象类中实现。 4.7.1 深度优先搜索 深度优先搜索(depth first search)遍历类似于树的先根遍历,是树的先根遍历的推广。 深度优先搜索的基本方法是:从图中某个顶点发 v 出发,访问此顶点,然后依次从 v 的未被访问的邻接点出发深度优先遍历图,直至图中所有和 v 有路径相通的顶点都被访问 到;若此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述 过程,直至图中所有顶点都被访问到为止。 以图 7-13(a)中无向图为例,对其进行深度优先搜索遍历的过程如图 7-13(c)所示, 其中黑色的实心箭头代表访问方向,空心箭头代表回溯方向,箭头旁的数字代表搜索顺序, 顶点 a 是起点。遍历过程如下:首先访问顶点 a,然后 a) 顶点 a 的未曾访问的邻接点有 b、d、e,选择邻接点 b 进行访问; b) 顶点 b 的未曾访问的邻接点有 c、e,选择邻接点 c 进行访问; c) 顶点 c 的未曾访问的邻接点有 e、f,选择邻接点 e 进行访问; d) 顶点 e 的未曾访问的邻接点只有 f,访问 f; e) 顶点 f 无未曾访问的邻接点,回溯至 e; f) 顶点 e 无未曾访问的邻接点,回溯至 c; g) 顶点 c 无未曾访问的邻接点,回溯至 b; h) 顶点 b 无未曾访问的邻接点,回溯至 a; i) 顶点 a 还有未曾访问的邻接点 d,访问 d; j) 顶点 d 无未曾访问的邻接点,回溯至 a。 到此,a 再没有未曾访问的邻接点,也不能向前回溯,从 a 出发能够访问的顶点均已访问, 139 并且此时图中再没有未曾访问的顶点,因此遍历结束。由以上过程得到的遍历序列为:a , b , c , e , f , d。 对于有向图而言,深度优先搜索的执行过程一样,例如图 7-13(b)中有向图的深度优 先搜索过程如图 7-13(d)所示。在这里需要注意的是从顶点 a 出发深度优先搜索只能访问 到 a , b , c , e , f,而无法访问到图中所有顶点,所以搜索需要从图中另一个未曾访问的顶点 d 开始进行新的搜索,即图 7-13(d)中的第 9 步。 图 7-13 深度优先搜索 9 0 (a) (b) a e b d f c a e b d f c 2 3 4 5 6 7 8 9 10 1 a e b d f c (c) 1 2 3 5 6 4 7 8 a e b d f c (d) 0 显然从某个顶点 v 出发的深度优先搜索过程是一个递归的搜索过程,因此可以简单的使 用递归算法实现从顶点 v 开始的深度优先搜索。然而从 v 出发深度优先搜索未必能访问到图 中所有顶点,因此还需找到图中下一个未曾访问的顶点,从该顶点开始重新进搜索。深度优 先搜索算法的具体实现见算法 7-1。 算法 7-1 DFSTraverse 输入:顶点 v 输出:图深度优先遍历结果 代码: public Iterator DFSTraverse(Vertex v) { LinkedList traverseSeq = new LinkedListDLNode();//遍历结果 resetVexStatus(); //重置顶点状态 DFSRecursion (v, traverseSeq); //从 v 点出发深度优先搜索 Iterator it = getVertex(); //从图未曾访问的其他顶点重新搜索(调用图操作③) for(it.first(); !it.isDone(); it.next()){ Vertex u = (Vertex)it.currentItem(); if (!u.isVisited()) DFSRecursion(u, traverseSeq); } return traverseSeq.elements(); } //从顶点 v 出发深度优先搜索的递归算法 private void DFSRecursion(Vertex v, LinkedList list){ v.setToVisited(); //设置顶点 v 为已访问 140 list.insertLast(v); //访问顶点 v Iterator it = adjVertexs(v); //取得顶点 v 的所有邻接点(调用图操作⑧) for(it.first(); !it.isDone(); it.next()){ Vertex u = (Vertex)it.currentItem(); if (!u.isVisited()) DFSRecursion(u,list); } } 在算法 7-1 中对图进行深度优先搜索遍历时,对图中每个顶点最多调用一次 DFSRecursion 方法,因为一旦某个顶点已被访问,就不用再从该顶点出发进行搜索。因此, 遍历图的过程实际就是查找每个顶点的邻接点的过程。当图采用双链式存储结构时,查找所 有顶点的邻接点所需时间为Ο(|E|);除此之外,初始化顶点状态、判断每个顶点是否访问过 以及访问图中所有顶点一次需要Ο(|V|)时间。由此,当以双链式结构作为图的存储结构时, 深度优先搜索遍历图的时间复杂度为Ο(|V|+|E|)。 图的深度优先搜索算法也可以使用堆栈以非递归的形式实现,使用堆栈实现深度优先搜 索的思想如下: ⑴ 首先将初始顶点 v 入栈; ⑵ 当堆栈不为空时,重复以下处理 栈顶元素出栈,若未访问, 则访问之并设置访问标志,将其未曾访问的邻接点入栈; ⑶ 如果图中还有未曾访问的邻接点,选择一个重复以上过程。 算法前两步的具体实现见算法 7-2,第三步与算法 7-1 中 DFSTraverse 方法实现类似,仅需 要将从某个顶点 v 出发开始深度优先搜索的调用由原来的 DFSRecursion 改为调用 DFS。 算法 7-2 DFS 输入:顶点 v,链接表 list 输出:从顶点 v 出发的深度优先搜索 代码: //从顶点 v 出发深度优先搜索的非递归算法 private void DFS(Vertex v, LinkedList list){ Stack s = new StackSLinked(); s.push(v); while (!s.isEmpty()){ Vertex u = (Vertex)s.pop(); //取栈顶元素 if (!u.isVisited()){ //如果没有访问过 u.setToVisited(); //访问之 list.insertLast(u); Iterator it = adjVertexs(u); //未访问的邻接点入栈(调用图操作⑧) for(it.first(); !it.isDone(); it.next()){ Vertex adj = (Vertex)it.currentItem(); if (!adj.isVisited()) s.push(adj); }//for }//if }//while } 141 4.7.2 广度优先搜索 广度优先搜索(breadth first search) 遍历类似于树的层次遍历,它是树的按层遍历的 推广。 假设从图中某顶点 v 出发,在访问了 v 之后依次访问 v 的各个未曾访问过的邻接点,然 后分别从这些邻接点出发依次访问它们的邻接点,并使“先被访问的顶点的邻接点”先于“后 被访问的顶点的邻接点”先被访问,直至图中所有已被访问的顶点的邻接点都被访问到。若 此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程, 直至图中所有顶点都被访问到为止。 图 7-14 广度优先搜索 5 0 3 4 2 1 a e b d f c (a) 1 2 3 4 a e b d f c (b) 0 5 图 7-14(a)、(b)分别显示了对图 7-13(a)、(b)中两个图的广度优先搜索过程。对 图 7-13(a)中无向图的广度优先搜索遍历序列为:a , b , d , e , c , f;对图 7-13(b)中有向 图的广度优先搜索遍历序列为:a , b , e , c , f , d。同样,在这里从顶点 a 出发广度优先搜索 只能访问到 a , b , e , c , f,所以搜索需要从图中另一个未曾访问的顶点 d 开始进行新的搜索, 即图 7-14(b)中的第 5 步。 通过上述搜索过程,我们发现,广度优先搜索遍历图的过程实际上就是以起始点 v 为起 点,由近至远,依次访问从 v 出发可达并且路径长度为 1、2、…的顶点。 广度优先搜索遍历的实现与树的按层遍历实现一样都需要使用队列,使用队列实现广度 优先搜索的思想如下: ① 首先访问初始顶点 v 并入队; ② 当队列不为空时,重复以下处理 队首元素出队,访问其所有未曾访问的邻接点,并它们入队; ③ 如果图中还有未曾访问的邻接点,选择一个重复以上过程。 算法的具体实现见算法 7-3。 算法 7-3 BFSTraverse 输入:顶点 v 输出:图广度优先遍历结果 代码: public Iterator BFSTraverse(Vertex v) { LinkedList traverseSeq = new LinkedListDLNode();//遍历结果 resetVexStatus(); //重置顶点状态 BFS(v, traverseSeq); //从 v 点出发广度优先搜索 Iterator it = getVertex(); //从图中未访问的顶点重新搜索(调用图操作③) for(it.first(); !it.isDone(); it.next()){ Vertex u = (Vertex)it.currentItem(); 142 if (!u.isVisited()) BFS(u, traverseSeq); } return traverseSeq.elements(); } private void BFS(Vertex v, LinkedList list){ Queue q = new QueueSLinked(); v.setToVisited(); //访问顶点 v list.insertLast(v); q.enqueue(v); //顶点 v 入队 while (!q.isEmpty()){ Vertex u = (Vertex)q.dequeue(); //队首元素出队 Iterator it = adjVertexs(u); //访问其未曾访问的邻接点,并入队 for(it.first(); !it.isDone(); it.next()){ Vertex adj = (Vertex)it.currentItem(); if (!adj.isVisited()){ adj.setToVisited(); list.insertLast(adj); q.enqueue(adj); }//if }//for }//while } 在算法 7-3 中每个顶点最多入队、出队一次,遍历图的过程实际就是寻找队列中顶点的 邻接点的过程,当图采用双链式存储结构时,查找所有顶点的邻接点所需时间为Ο(|E|),因 此,算法 7-3 的时间复杂度为Ο(|V|+|E|)。 4.8 图的连通性 4.8.1 无向图的连通分量和生成树 在对无向图进行遍历时,对于连通图,仅需从图中任何一个顶点出发,进行深度优先搜 索或广度优先搜索,便可访问到图中所有顶点。对于非连通图,则需从多个顶点出发进行搜 索,而每次从一个新的起始点出发进行搜索的过程中得到的顶点访问序列恰为其各个连通分 量中的顶点集。 例如,图 7-15(a)中的图是非连通图,若从顶点 a 开始进行深度优先搜索遍历,在选 择未曾访问的邻接点时按照顶点在图中的位置顺序(即 a , b , c , d , e , f , g , h)选择,2 次调 用 DFS 方法(分别从 a、d 出发)得到的访问序列为 a , b , c , f , e d , g , h 这两个顶点集加上所有依附于它们的边,便构成了非连通图的 2 个连通分量如图图 7-15(b) 所示。 设E是连通图G中所有边的集合,则从图中任意一个顶点出发遍历图时,必定将E分成两 个子集:Et和Eb,其中Et是遍历图过程中经历的边的集合;Eb是剩余边的集合。显然Et和图 中所有顶点一起构成连通图G的极小连通子图,即图G的生成树。并且由深度优先搜索得到 143 的为深度优先搜索生成树;由广度优先搜索得到的为广度优先搜索生成树。 图 7-15 生成树与生成森林 (a) (b) a e b d f c g h a e b f c d g h d g h a e b f c (c) (d) a e bd f c a e b d f c (e) 例如图 7-15(d)和图 7-15(e)所示(不包括虚线代表的边)分别为图 7-13(a)中连 通图的深度优先搜索生成树和广度优先搜索生成树,而图中虚线表示的边为集合Eb中的边。 对于非连通图,每个连通分量中的顶点集以及在遍历时走过的边一起构成若干棵生成 树,这些连通分量的生成树组成非连通图的生成树森林。例如图 7-15(c)所示为图 7-15(a) 的深度优先搜索森林,它由 2 棵深度优先搜索生成树组成。 4.8.2 有向图的强连通分量 在无向图中从某个顶点 v 出发深度优先搜索或广度优先搜索,就可以得到无向图中包含 v 在内的一个连通分量,然而从有向图中某个顶点 s 出发进行深度优先搜索或广度优先搜索, 只能得到顶点 s 的可达分量,不一定能够得到包含 s 在内的强连通分量。 例如从图 7-13(b)中有向图顶点 a 出发进行深度优先搜索,可以访问到的顶点序列为: a , b , e , c , f,然而以这些顶点无法构成一个强连通分量,因为从 a 可以到达 b , e , c , f,但 是从 b , e , c , f 却无法到达 a。下面我们来讨论在有向图中求强连通分量的算法。 对于任何有向边 e = ,称 R(e) = 为 e 的镜像边,即 R(e)的起点(终点)就 是 e 的终点(起点)。对于任何有向图 G = ( V , E ),我们称 R(E) = {R(e)|e∈E}为 E 的镜像边 集,也就是说,集合 R(E)是由 E 中各边的镜像边组成。此时,也称 R(G) = ( V , R(E) )为 G 的镜像图。 为构造图 G = ( V , E )中包含顶点 s 的强连通分量有如下方法:求出顶点 s 在图 G 中的 可达分量与顶点 s 在 R(G)中的可达分量的交集;而在有向图中求顶点 s 的可达分量,只需 要从 s 出发进行深度优先搜索或广度优先搜索即可。 例如在图 7-16(a)中灰色的顶点集为 a 在 G 中的可达分量;图 7-16(b)中灰色的顶 点为 a 在 R(G)中的可达分量;图 7-16(c)中灰色的顶点为顶点 a 在图 G 中的可达分量与 a 在 R(G)中的可达分量的交集,它们以及与它们关联的边就构成了包含 a 的强连通分量。 144 图 7-16 构造包含顶点 a 的强连通分量 a e b d f c a e b d f c a e b d f c (b)(a) (c) 如果需要确定有向图的所有强连通分量,可以从图中每个顶点出发重复上述操作,然而 这种方法时间复杂度较高,实际上为确定有向图的所有强连通分量,只需要进行两次深度优 先搜索即可,一次是在有向图 G 上进行,另一次是在 R(G)上进行。有兴趣的读者可以自行 构造相应的算法。 4.8.3 最小生成树 通过上 7.5.1 小节的内容,我们看到对于连通图而言从图中不同顶点出发或从同一顶点 出发按照不同的优先搜索过程可以得到不同的生成树。例如图 7-15(d)和图 7-15(e)所 示就是同一个图的两棵不同生成树。 如此,对于一个连通网(连通带权图)来说,生成树不同,每棵树的代价(树中每条边 上权值之和)也可能不同,我们把代价最小的生成树称为图的最小生成树(minimum spanning tree)。 最小生成树在许多领域都有重要的应用,例如利用最小生成树就可以解决如下工程中的 实际问题:网络 G 表示 n 各城市之间的通信线路网线路,其中顶点表示城市,边表示两个 城市之间的通信线路,边上的权值表示线路的长度或造价。可通过求该网络的最小生成树达 到求解通信线路长度或总代价最小的最佳方案。 需要进一步指出的是,尽管最小生成树必然存在,但不一定唯一。 假设已知一个无向连通图 G = (V , E),其边加权函数为 w: E→R,构造最小生成树的基 本思想是:每步形成最小生成树的一条边;算法设置了边集合 A,初始时为空,该集合一直 是某最小生成树的子集。在每步决定是否把边(u , v)添加到集合 A 中,其添加条件是 A∪{(u , v)}仍然是最小生成树的子集。我们称这样的边为 A 的安全边,因为可以安全地把它添加到 A 中而不会破坏上述条件。通过上述过程找到|V|-1 条边最后返回集合 A 时,A 就必然是一 棵最小生成树。 上述构造最小生成树的思想中最关键的部分就是如何找到安全边(u , v),下面我们将给 出一条确认安全边的规则。 在介绍这一规则之前,我们先介绍几个概念。无向图G = (V, E)的一个割(S , V - S)是对 顶点集的一个划分。图 7-16 说明了这个概念。当边(u , v)∈E的一个顶点在S中,而另外一个 顶点在V-S中,我们说边(u , v)横切割(S , V - S)。在横切割的所有边中,权最小的边称为轻边。 需要注意的是横切割的轻边可能不止一条。若集合A中没有边横切割,则我们说割不妨害边 的集合A。例如图 7-17 中边(a , h)、(b , h)、(b , c)、(d , c)、(d , f)、(e , f)横切割(S , V - S), 其中(d , c)是轻边。子集A包含加粗的那些边,注意由于A中没有边横切割,所以割(S , V - S) 不妨害A。 145 图 7-17 割及轻边的概念 S V-S 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e fgh i 10 定理 7.1 设图 G = (V, E)是一个无向连通图,且在 E 上定义了相应的加权函数 w,设 A 是 E 的一个子集且包含于 G 的某个最小生成树中,割(S , V - S)是 G 的不妨害 A 的任意割且 边(u , v)是横切割(S , V - S)的一条轻边,则边(u , v)对集合 A 是安全的。 证明: 设 T'是包含 A 的一棵最小生成树。 如果 T'包含轻边(u , v),则说明边(u , v)可以安全的加入 A 而不破坏 A 是某最小生成树 的子集这一性质,因此边(u , v)对集合 A 是安全的。 如果T'不包含轻边(u , v),由于T'是连通的,则在T'上必定存在一条不属于A的边(u' , v') 横切割(S , V - S),并且u和u'、v和v'之间均有路径相通。如此当将边(u , v)加入到T'中时,则 在T'中产生一条包含(u , v)的回路,如果我们删除(u' , v')便可消除上述回路,同时得到另一棵 生成树T。因为边(u , v)和(u' , v')横切割(S , V - S),而边(u , v)是轻边,因此有边(u , v)的权值 不大于(u' , v')的权值,即w(u, v) ≤ w(u' , v'),而生成树T的代价 w(T) = w(T') - w(u' , v') + w(u, v) ≤ w(T') 但T'是最小生成树,有w(T') ≤ w(T),所以w(T) = w(T'),因此T必定也是最小生成树,如此边 (u , v)对集合A是安全的。 证明完毕。 定理 7.1 使我们可以更好地了解前面算法思想在连通图G = (V , E)上的执行流程,算法 开始时,A为空集,图GA = (V , A)是一个森林,森林中包含|V|棵树,每个顶点对应一棵,一 共|V|个连通分量。在算法执行过程中,边(u , v)是不妨害A的任意割的轻边,因此在A中加入 这条安全的边(u , v) 都连接GA中不同的连通分量,且不会在A中产生回路,并且使得A中边 数加 1。每个迭代过程均将减少一棵树,当森林中只包含一棵树时,算法执行终止。 下述的两种最小生成树算法是对上述所介绍的算法思想的细化。在 Prim 算法中,集合 A 仅形成单棵树,加入集合 A 的安全边总是连结树与其他孤立顶点之间的轻边。在 Kruskal 算法中,集合 A 是一森林,加入集合 A 的安全边总是图中连结两不同连通分量的最小权边。 „ Prim 算法 假设G = (V, E)是连通网,A是G上最小生成树的边的集合。算法从S = {u0}(u0∈V), A={}开始,重复执行下述操作:找到横切割(S , V - S) 的轻边(u0 , v0)并入集合A,同时v0并 入S,直到S = V为止。此时A中必定有|V|-1 条边,则T = (V , A)为G的最小生成树。 图 7-18 说明了 Prim 算法在图 7-17 中连通网上的执行过程。 146 图 7-18 Prim 算法示例 (6) (7) 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e fg h i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e f gh i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e fg h i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e f gh i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e fg h i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e f gh i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e fg h i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e f gh i 10 4 8 11 1 2 7 6 4 2 8 7 9 14 d a b c e fg h i 4 1 2 4 2 8 7 9 d a b c e f gh i (0) (1) (2) (3) (4) (5) (8) (9) 在图 7-17 所示的构造最小生成树过程中,横切割(S , V - S) 的边会随着新加入 S 的顶点 k 变化而变化。为找到割(S , V - S) 的轻边,可以转化为如下操作:求出从 S 中顶点到达 V-S 中各个顶点的最短横切边,轻边是这些最短横切边中最小的一个。例如图 7-17(4)中,当 S = {a , b , c , i}时,到达 V-S 中每个顶点的最短横切边是:(c , d) = 7、(c , f) = 4、(i , g) = 6、 (i , h) = 7、到 e 为∞,割(S , V - S) 的轻边为(c , f) = 4。 假设初始化时 S = {a},从 S 到达 V-S 中各顶点的最短横切边初始化为 a 到其邻接顶点 的距离即可,与 a 不相邻的设为∞。在算法执行过程中,会不断有新的顶点 k 加入 S,k 的 147 加入可能使得原本 V-S 中不可达的顶点变的可达或原本可达的顶点能以更小的代价可达,因 此在 S 中引入新的顶点 k 后,需要以 k 为中间点更新到达 S-V 中各顶点的最短横切边。例 如在图 7-17(3)中,当 S = {a , b , c}时,原本 g 不可达,到达 h 的最小距离为 8,当 i 加入 S 之后,g 变的可达并且到达 h 的最短横切边由(a , h) = 8 变为(i , h) = 7。 在算法的具体实现中,我们采用以下策略:首先,以顶点的成员变量 visited 来表示该 顶点是否属于 S,visited = true 表示属于 S,否则不属于 S。其次,到达 V-S 中各个顶点的最 短横切边通过该顶点的成员变量 application 来表示,此时 application 指向的是 Edge 类的对 象,它是从 S 到达本顶点横切边中权值最小的一条。在构造最小生成树过程中,对顶点成 员变量 application 的操作方法见代码 7-5。最后,最小生成树的表示采用设置图中边的类型 来完成,即如果是最小生成树的边,将边的类型设置为 Edge.MST。 代码 7-5 求 MST 时,对 v.application 的操作 //获取到达顶点 v 的最小横切边权值 protected int getCrossWeight(Vertex v){ if (getCrossEdge(v)!=null) return getCrossEdge(v).getWeight(); else return Integer.MAX_VALUE; } //获取到达顶点 v 的最小横切边 protected Edge getCrossEdge(Vertex v){ return (Edge)v.getAppObj();} //设置到达顶点 v 的最小横切边 protected void setCrossEdge(Vertex v, Edge e){ v.setAppObj(e);} Prim 算法的具体实现见算法 7-4。 算法 7-4 generateMST 输入:无向连通带权图 输出:构造最小生成树 代码: public void generateMST(){ resetVexStatus(); //重置图中各顶点的状态信息 resetEdgeType(); //重置图中各边的类型信息 Iterator it = getVertex(); //(调用图操作③) Vertex v = (Vertex)it.currentItem();//选第一个顶点作为起点 v.setToVisited(); //顶点 v 进入集合 S //初始化顶点集合 S 到 V-S 各顶点的最短横切边 for(it.first(); !it.isDone(); it.next()){ Vertex u = (Vertex)it.currentItem(); Edge e = edgeFromTo(v,u); //(调用图操作⑦) setCrossEdge(u,e); //设置到达 V-S 中顶点 u 的最短横切边 } for (int t=1;t中用顶点表示活动,用有向边表示活动vi必须先于 活动vj进行。这种有向图叫做顶点表示活动的AOV网络(activity on vertices networks)。 例如,一件商品的生产就是一项工程,它可以用一个 AOV 网络来表示,如图 7-21(a) 所示。假设该商品的生产包含 4 项活动: a.购买原材料; b.生产零件 1; c.用零件 1 加工零件 2; d.生产零件 3; e.组装零件 2、3 得到成品。 图 7-21 AOV 网及拓扑序列 (b)(a) a b d e c a b c d e 现在的问题是如何判断某个 AOV 网络所表示的工程是否可以完成;以及如何完成,即 各个活动按照什么顺序完成。如果能够给出一个活动序列,该活动序列包含 AOV 网中所有 顶点表示的活动,并且该活动序列满足 AOV 网络中所有应存在的前驱和后继关系。则该活 动序列就是一种完成工程的方法。例如图 7-21(a)所示的工程可以图 7-21(b)所示的活 动序列:a , b , c , d , e 来完成整个工程。 通过上面的例子可以看到,在一个AOV网络中,若vi为vj的先行活动,vj为vk的先行活 动,则vi必为vk的先行活动,即活动的先行关系具有传递性。如果从离散数学的观点看AOV 网络中的活动关系可以看成是一个偏序关系,而上述给出工程完成活动的线性序列可以看成 是一个全序关系。 由某个集合上的一个偏序得到该集合上的一个全序,此操作称之为拓扑排序 (topological sort)。即将 AOV 网络各个顶点 (代表各个活动)排列成一个线性有序的序 列,使得 AOV 网络中所有应存在的前驱和后继关系都能得到满足。拓扑排序就是构造 AOV 网络顶点的拓扑有序序列的运算。 在一个 AOV 网络中是不能存在环的,因为如果存在环,说明某项活动的开始必须是以 157 自身的结束作为前提的。需要注意的是 AOV 网络的拓扑序列不是唯一的,例如图 7-21(a) 的另一个拓扑序列为:a , d , b , c , e。 为得到 AOV 网络的拓扑序列,可以使用以下方法: ⑴ 在 AOV 网络中选一个没有直接前驱的顶点,并输出之; ⑵ 从图中删去该顶点,同时删去所有它发出的有向边; ⑶ 重复以上⑴、⑵步, 直到全部顶点均已输出,或图中不存在无前驱的顶点。 图 7-22 图示了拓扑排序的过程。 图 7-22 拓扑排序的过程 得到拓扑序列为:e , a , d , c , b , f a b c d (a)有向无环图 f e a b c d (b)输出 e fe a b c d (c)输出 a f b c d (d)输出 d f b c (e)输出 c f b (f)输出 b f (g)输出 f f 以上拓扑排序过程的正确性不难理解,但在计算机中如何实现?针对上述操作的两个关 键步骤,我们作以下处理:首先,为每个顶点 v 设置一个变量,记录其在拓扑排序过程中的 入度,入度为 0 的顶点就是没有直接前驱的顶点。其次,删除一条有向边可以用有向边终止 点的入度减 1 来实现。由此,构造一个 AOV 网络的拓扑序列的算法可以描述如下: ⑴ 建立入度为零的顶点栈; ⑵ 当入度为零的顶点栈不空时,重复执行 从顶点栈中退出一个顶点,并输出之; 搜索以这个顶点发出的边,将边的终顶点入度减 1; 如果边的终顶点入度减至 0,则该顶点进入栈; ⑶ 如果输出顶点个数少于 AOV 网络的顶点个数,说明网络中存在有向环。 在具体的算法实现中,我们使用每个顶点的 application 成员变量指向一个 Integer 对象, 它表示顶点在算法执行中当前的入度。在拓扑排序过程中对顶点成员变量 application 的操作 方法见代码 7-8。 代码 7-8 拓扑排序算法中,对 v.application 的操作 //取或设置顶点 v 的当前入度 private int getTopInDe(Vertex v){ return ((Integer)v.getAppObj()).intValue(); } private void setTopInDe(Vertex v, int indegree){ v.setAppObj(Integer.valueOf(indegree)); } 算法 7-6 给出了拓扑排序的具体实现。 算法 7-6 toplogicalSort 158 输入:AOV 网络 输出:拓扑序列 代码: public Iterator toplogicalSort(){ LinkedList topSeq = new LinkedListDLNode(); //拓扑序列 Stack s = new StackSLinked(); Iterator it = getVertex(); for(it.first(); !it.isDone(); it.next()){ //初始化顶点集应用信息 Vertex v = (Vertex)it.currentItem(); v.setAppObj(Integer.valueOf(v.getInDeg())); if (v.getInDeg()==0) s.push(v); } while (!s.isEmpty()){ Vertex v = (Vertex)s.pop(); topSeq.insertLast(v); //生成拓扑序列 Iterator adjIt = adjVertexs(v); //对于 v 的每个邻接点入度减 1 for(adjIt.first(); !adjIt.isDone(); adjIt.next()){ Vertex adjV = (Vertex)adjIt.currentItem(); int in = getTopInDe(adjV)-1; setTopInDe(adjV, in); if (in==0) s.push(adjV); //入度为 0 的顶点入栈 }//for adjIt }//while if (topSeq.getSize()上,则e[k]是从源点v0到顶点 vi的最长路径长度。因此,e[k] = Ve[i]。 z 事件vi的最迟允许开始时间Vl[i]:是在保证汇点vn-1在Ve[n-1]时刻完成的前提下, 事件vi允许的最迟开始时间。 z 活动ak的最迟允许开始时间l[k]:设活动ak在边上,l[k]是在不会引起时间延 误的前提下,该活动允许的最迟开始时间。l[k] = Vl[j] - dur()。其中,dur() = weight()是完成ak所需的时间。 z 时间余量 l[k] - e[k]:表示活动ak的最早可能开始时间和最迟允许开始时间的时间 余量。l[k] = e[k]表示活动ak是没有时间余量的关键活动。 为找出关键活动,需要求各个活动的e[k]与l[k],以判别是否 l[k] = e[k]。为求得e[k]与 l[k],需要先求得从源点v0到各个顶点vi的Ve[i]和Vl[i]。 为求 Ve[i]和 Vl[i]需分两步进行: ⑴ 从 Ve[0] = 0 开始向汇点方向推进 Ve[j] = Max{ Ve[i] + dur()| vi是vj的所有直接前驱顶点} ⑵ 从 Vl[n-1] = Ve[n-1]开始向源点方向推进 Vl[i] = Min{ Vl[j] - dur()| vj是vi的所有直接后续顶点} 这两个递推公式的计算必须分别在拓扑有序和逆拓扑有序的前提下进行。也就是说 Ve[j] 必须在其所有直接前驱顶点的最早开始时间求得之后才能进行;Vl[i]必须在其所有直接后续 顶点的最迟开始时间求得之后才能进行。因此,可以在拓扑序列的基础上求解关键活动。 在图 7-23(a)所示的AOE网络上求关键活动的过程如图 7-23(b)。在图 7-23(b)中 首先求出了各个顶点的最早开始时间和最迟开始时间,然后求出各个活动的最早开始时间和 最迟开始时间,最后活动时间余量为 0 的活动即为关键活动,由关键活动组成的路径是关键 路径,即ρ = (v1 , v2 , v4 , v6)为关键路径。 160 图 7-23 关键活动 v3 v1 v2 v4 v6 v5 a1=4 a2=2 a5=4 a6=11 a7=6 a8=7 a4=3 a3=5 顶点 Ve Vl 活动 e l l-e v1 0 0 a1 0 0 0 v2 4 4 a2 0 2 2 v3 2 4 a3 4 4 0 v4 9 9 a4 4 5 1 v5 7 8 a5 2 5 3 v6 15 15 a6 2 4 2 a7 9 9 0 a8 7 8 1 (b) (a) 下面我们给出求关键路径的算法: ⑴ 对图中的顶点进行拓扑排序,求出拓扑序列与逆拓扑序列;若拓扑序列中顶点数少 于|V|,说明图中有环,返回; ⑵ Ve[0] = 0,在拓扑序列上求各顶点最早开始时间; ⑶ Vl[n-1] = Ve[n-1],在逆拓扑序列上求各顶点最迟开始时间; ⑷ 遍历图中所有边∈E,判断其是否为关键活动。 在算法的具体实现中采用如下策略:首先,各个顶点 v 的 Ve[v]、Vl[v]等信息使用顶点 的 application 成员变量存储,此时 application 指向 Vtime 类的对象,在算法中对 application 的操作见代码 7-9。其次,判断边是否为关键活动时,不用求出 l[k]与 e[k],只用判断 Ve(u)与 Vl(v) - weight()是否相等即可。最后,AOE 网络关键路径可能不只一条,因此 如果某边是关键活动,将该边标记为 Edge. CRITICAL 即可。 代码 7-9 求关键路径算法中,对 v.application 的操作 //取顶点 v 的最早开始时间与最迟开始时间 private int getVE(Vertex v){ return ((Vtime)v.getAppObj()).getVE(); } private int getVL(Vertex v){ return ((Vtime)v.getAppObj()).getVL(); } //设置顶点 v 的最早开始时间与最迟开始时间 private void setVE(Vertex v, int ve){ ((Vtime)v.getAppObj()).setVE(ve); } private void setVL(Vertex v, int vl){ 161 ((Vtime)v.getAppObj()).setVL(vl); } 其中 Vtime 类的定义见代码 7-10 代码 7-10 Vtime 类定义 public class Vtime { private int ve; //最早发生时间 private int vl; //最迟发生时间 //构造方法 public Vtime() { this(0,Integer.MAX_VALUE); } public Vtime(int ve, int vl){ this.ve = ve; this.vl = vl; } //get&set method public int getVE(){ return ve;} public int getVL(){ return vl;} public void setVE(int t){ ve = t;} public void setVL(int t){ vl = t;} } 算法 7-7 给出了求关键路径的具体实现。 算法 7-7 toplogicalSort 输入:AOE 网络 输出:标记关键路径 代码: public void criticalPath(){ Iterator it = toplogicalSort(); resetEdgeType(); //重置图中各边的类型信息 if (it==null) return; LinkedList reTopSeq = new LinkedListDLNode(); //逆拓扑序列 for(it.first(); !it.isDone(); it.next()){ //初始化各点 ve 与 vl,并生成逆拓扑序列 Vertex v = (Vertex)it.currentItem(); Vtime time = new Vtime(0,Integer.MAX_VALUE); //ve=0,vl=∞ v.setAppObj(time); reTopSeq.insertFirst(v); } for(it.first(); !it.isDone(); it.next()){ //正向拓扑序列求各点 ve Vertex v = (Vertex)it.currentItem(); Iterator adjIt = adjVertexs(v); for(adjIt.first(); !adjIt.isDone(); adjIt.next()){ Vertex adjV = (Vertex)adjIt.currentItem(); Edge e = edgeFromTo(v,adjV); if (getVE(v)+e.getWeight()>getVE(adjV)) //更新最早开始时间 setVE(adjV, getVE(v)+e.getWeight()); } 162 } Vertex dest = (Vertex)reTopSeq.first().getData(); setVL(dest, getVE(dest)); //设置汇点 vl=ve Iterator reIt = reTopSeq.elements(); for(reIt.first(); !reIt.isDone(); reIt.next()){ //逆向拓扑序列求各点 vl Vertex v = (Vertex)reIt.currentItem(); Iterator adjIt = adjVertexs(v); for(adjIt.first(); !adjIt.isDone(); adjIt.next()){ Vertex adjV = (Vertex)adjIt.currentItem(); Edge e = edgeFromTo(v,adjV); if (getVL(v)>getVL(adjV)-e.getWeight()) //更新最迟开始时间 setVL(v, getVL(adjV)-e.getWeight()); } } Iterator edIt = edges.elements(); for(edIt.first(); !edIt.isDone(); edIt.next()){ //求关键活动 Edge e = (Edge)edIt.currentItem(); Vertex u = e.getFirstVex(); Vertex v = e.getSecondVex(); if (getVE(u)==getVL(v)-e.getWeight()) e.setToCritical(); } } 算法 7-7 说明:算法首先调用拓扑排序算法得到拓扑序列,需要时间Ο(|V|+|E|);然后 初始化各顶点 Ve 和 Vl 并生成逆拓扑序列,需要时间Ο(|V|);在正向拓扑序列求各顶点 ve 过程中对所有顶点和边访问一次,需时Ο(|V|+|E|),逆向拓扑序列求各顶点 vl 需要同样的时 间Ο(|V|+|E|);最后遍历所有的边判断其是否为关键路径,需时Ο(|E|);因此,算法总的时间 复杂度为Ο(|V|+|E|)。 163 第八章 查找 在非数值运算问题中,数据存储量一般很大,为了在大量信息中找到某些值,需要用到 查找技术,为了提高查找效率,需要对一些数据进行排序。查找和排序的数据处理量占有非 常大的比重,故查找和排序的有效性直接影响到算法的性能,因而查找和排序是重要的处理 技术。从本章开始,我们将介绍查找和排序。 8.1 查找的定义 8.1.1 基本概念 首先介绍几个与查找有关的基本概念。 查找表(search table)是由同一类型的数据元素(或记录)构成的集合。由于数据元 素之间存在着完全松散的关系,因此查找表是一种非常灵活的结构,可以利用任意数据结构 实现。 关键字(key)是数据元素的某个数据项的值,用它可以标识查找表中一个或一组数据 元素。如果一个关键字可以唯一标识查找表中的一个数据元素,则称其为主关键字,否则为 次关键字。当数据元素仅有一个数据项时,其关键字即为该数据元素的值。 查找(search)根据给定的关键字值,在查找表中确定一个关键字与给定值相同的数据 元素,并返回该数据元素在查找表中的位置。若找到相应数据元素,则称查找成功,否则称 查找失败,此时返回空地址。 对于查找表的查找,一般有两种情况:一种是静态查找,指在查找过程中只是对数据元 素进行查找;另一种是动态查找,指在实施查找过程中,插入查找失败的元素,或从查找表 中删除已查到的某个元素,即允许表中元素发生变化。 平均查找长度(average search length , ASL)是为确定数据元素在查找表中的位置,需 要和给定的值进行比较的关键字个数的期望值,称为查找算法在查找成功时的平均查找长 度。 对于长度为 n 的查找表,查找成功时的平均查找长度为 ASL = P1C1 + P2C2 + … + PnCn = ∑ = n 1i iiCP 其中:Pi为查找查找表中第i个数据元素的概率,Ci为找到表中第i个数据元素时,已经进行 的关键字的比较次数。 需要注意,这里讨论的平均查找长度是在查找成功的情况下进行的讨论,换句话说,我 们认为每次查找都是成功的。前面提到查找可能成功也可能失败,但是在实际应用的大多数 情况下,查找成功的可能性要比不成功的可能性大得多,特别是查找表中数据元素个数 n 较大时,查找不成功的概率可以忽略不计。由于查找算法的基本运算是关键字之间的比较操 作,所以平均查找长度可以用来衡量查找算法的性能。 在一个结构中查找某个数据元素的过程依赖于这个数据元素在结构中所处的位置。因 此,对表进行查找的方法取决于表中数据元素以何种关系组织在一起,该关系是为进行查找 164 人为加在数据元素上的。为此查找有基于线性结构的查找还有基于树结构的查找,而这些查 找都是基于关键字的比较进行的,都属于比较式的查找;另一类查找法是计算式查找法,也 称为 HASH 查找法。 8.1.2 查找表接口定义 从上述定义中我们看到,在查找表中除了可以完成查找操作,还可以动态的改变查找表 中的数据元素,即可以进行插入和删除的操作。为此,下面给出查找表的接口定义。 代码 8-1 查找表接口定义 public interface SearchTable { //查询查找表当前的规模 public int getSize(); //判断查找表是否为空 public boolean isEmpty(); //返回查找表中与元素 ele 关键字相同的元素位置;否则,返回 null public Node search(Object ele); //返回所有关键字与元素 ele 相同的元素位置 public Iterator searchAll(Object ele); //按关键字插入元素 ele public void insert(Object ele); //若查找表中存在与元素 ele 关键字相同元素,则删除一个并返回;否则,返回 null public Object remove(Object ele); } 在这里需要注意的是,在查找表接口定义并没有使用各个数据元素的关键字作为查找或 删除的输入,而是以数据元素本身作为输入参数,这是考虑到关键字本身是数据元素的一部 分,使用第三章中代码 3-3 定义的 Strategy 接口即可完成对两个数据元素的按关键字的判等 和比较。因此,在查找表的具体实现中引入具体实现了 Strategy 接口的类的对象就可以完成 按照元素关键字进行的比较等操作。 8.2 顺序查找与折半查找 基于线性结构的查找主要介绍两种最常见的查找方法:顺序查找和折半查找。 „ 顺序查找 顺序查找的特点是,用所给的关键字与线性表中各元素的关键字逐个进行比较,直到成 功或失败。 算法的基本思想是:在查找表的一端设置一个称为“监视哨”的附加单元,存放要查找 的数据元素关键字。然后从表的另一端开始查找,如果在“监视哨”位置找到给定关键字, 则失败,否则成功返回相应元素的位置。 该算法思想与第三章中线性表的 indexOf 方法一致。只是在这里进行查找之前在表的一 端设置了一个监视哨,其目的在于免去查找过程中每一步都检测整个表是否查找完毕。使用 这一程序设计技巧,可以使得顺序查找在一个成功的检索中,比较次数大于等于 6 时就是一 个改进。实践证明,在查找表的规模 n ≥ 1000 时,进行一次查找所需的平均时间几乎减少一 165 半。该算法较为简单,请读者自行实现。 【查找分析】这里,我们用平均查找长度(ASL)分析顺序查找算法的性能。假设查找 表长度为n,查找每个数据元素的概率相等,均为 1/n。并且将监视哨设置在高端,那么查找 第i个数据元素时需要进行i次比较,即Ci = i,则平均查找长度为 ASL = = (1 + 2 + … + n)/n = (n+1)/2 ∑ = n 1i iiCP 顺序查找和我们后面将要介绍的其他查找算法相比,其缺点是平均查找长度较大,特别 是当n较大时,查找效率较低。然而,它的优点是:算法简单且适应面广。它对查找表的结 构没有要求,无论数据元素是否按关键字有序①均可适用。 „ 折半查找 折半查找又称为二分查找,这种查找方法需要待查的查找表满足两个条件:首先,查找 表必须使用顺序的存储结构;其次,查找表必须按关键字大小有序排列。 算法的基本思想是:首先,将查找表中间位置数据元素的关键字与给定关键字比较,如 果相等则查找成功;否则利用中间元素将表一分为二,如果中间元素关键字大于给定关键字, 则在前一子表中进行折半查找,否则在后一子表中进行折半查找。重复以上过程,直到找到 满足条件的元素,则查找成功;或直到子表为空为止,此时查找不成功。 例 8-1 已知如下 11 个数据元素的有序表,在有序表中查找关键字 27 和 78。 (5 , 13 , 17 , 27 , 36 , 41 , 46 , 50 , 75 , 88 , 92) 假设指针 lo 和 hi 分别指示待查元素所在范围的下界和上界,指针 mi 指示查找区间的 中间位置,mi = ⎣2/)hilo( ⎦+ 。在这里,lo 和 hi 的初始值分别为 0 和 10,即查找范围为[0 , 10]。 下面先说明给定关键字 27 的查找过程: 5 13 17 27 36 41 46 50 75 88 92 lo=0 mi=5 hi=10 首先,将给定关键字 27 与 mi 所指元素关键字 41 进行比较,结果是中间元素的关键字 大,因此下一步在[lo , mi-1]的范围内继续查找。因此,令指针 hi = mi – 1 = 4,并重新求得 mi = = 2。 ⎣2/)40( + ⎦ 5 13 17 27 36 41 46 50 75 88 92 lo=0 mi=2 hi=4 然后,仍然用给定关键字 27 与 mi 所指元素关键字 17 进行比较,结果是 mi 所指元素的关 键字小,因此下一步在[mi+1 , hi]范围内继续查找。因此,令指针 lo= mi + 1 = 3,并重新求 得 mi = = 3。 ⎣2/)43( + ⎦ 5 13 17 27 36 41 46 50 75 88 92 lo=3 mi=3 hi=4 此时,再将给定关键字 27 与 mi 所指元素关键字 27 进行比较,结果相等,则查找成功,返 回 mi 的值即为需要查找元素在表中的位置。 再看 key = 78 的查找过程: ① 如果表中所有元素的关键字按递增顺序排列,则称表中元素按关键字有序;反之称为表中元素按关键字 逆向有序。 166 5 13 17 27 36 41 46 50 75 88 92 lo=0 mi=5 hi=10 lo=6 hi=10 mi=8 lo=9 hi=10 mi=9 hi=8 lo=9 此时,因为下界 lo 大于上界 hi,则说明表中没有关键字为 78 的元素,查找失败。 下面我们给出一个在有序整型数组上的折半查找算法的实现。 算法 8-1 binSearch 输入:整型数组 s,查找范围 low、high,待查关键字 key 输出:查找结果在 s 中的位置 代码: public int binSearch(int[] s, int low, int high, int key){ while(low<=high){ int mid = (low + high)/2; if (s[mid]==key) return mid; else if (s[mid]>key) high = mid - 1; else low = mid + 1; } return -1; } 以上是折半查找算法的非递归形式,折半查找算法也可以写成递归的形式,并且可以将 这一算法扩展到 Object 类型的数据元素。相关程序请读者自行设计实现。 【查找分析】从折半查找过程看,以表的中间元素为比较对象,并以中间元素将表分割 为两个子表,对定位到的子表继续这种操作。所以,对表中每个数据元素的查找过程,可用 二叉树来描述,称这个描述查找过程的二叉树为判定树。 例如,例 8-1 中查找表所对应的判定树如图 8-1 所示。树中每个结点表示查找表中一个 数据元素在表中的位置,结点旁边的数字是每个元素的值。从判定树上可见,查找关键字 27 的过程正好是走了一条从根结点到与 27 对应的结点的路径,和给定关键字比较的次数为 该路径上的结点数。类似的,找到有序表中任意元素的过程都是走了一条从根到与该元素对 应结点的路径,和给定关键字比较的次数就是该结点的层次数加 1。 由于判定树的叶子结点所在层次相差不超 过 1,故虽然判定树不是完全二叉树,但具有 n 个结点的判定树的深度与 n 个结点的完全二叉 树的深度相同,均为 。折半查找在查找成 功时进行比较的关键字个数不超过树的深度加 1,即 +1。在查找失败时,折半查找走了 一条从根结点到空结点的路径,比较的关键字个 数是该路径上非空结点的个数,因此,折半查找 在查找失败时进行比较的关键字个数最多也不 超过树的深度加 1,也为 +1。 ⎣n log 图 8-1 例 8-1 的判定树 41 17 5 13 27 36 75 46 50 88 92 5 2 8 0 63 9 4 7 10 1 ⎦ ⎦ ⎦ ⎣n log ⎣n log 下面来分析折半查找的平均查找长度。为简化讨论,假设表的长度n = 2 h+1-1,则相应 167 判定树必为深度为h的满二叉树。又假设每个数据元素的查找概率相等,均为 1/n,则折半查 找的平均查找长度为: ∑∑ == ×+== h 0j j n 1i ii 2)1j(n 1CPASL 令 h210 h 0j j 2)1h(...2322212)1j(s ⋅+++⋅+⋅+⋅=×+= ∑ = 则 1h321 h 0j 1j 2)1h(...2322212)1j(2s + = + ⋅+++⋅+⋅+⋅=×+= ∑ 故 n)1n(log)1n()12(2)1h(22)1h(s-2s 1h1h h 0j j1h −++=−−⋅+=−⋅+= ++ = + ∑ 将 s 带入 ASL,得 1)1n(log1)1n(logn 1nASL −+≈−+×+= 可见,折半查找的效率比顺序查找高,但折半查找只适合于有序表,且限于顺序存储结 构,插入删除困难。因此折半查找适用于不经常变动而查找频繁的有序表。 8.3 查找树 基于树的查找方法是将待查表组织成特定的树结构,并在树结构的基础上实现查找的方 法。下面我们主要介绍二叉查找树、平衡二叉树和 B-树。 8.3.1 二叉查找树 所谓二叉查找树(binary search tree , BST)或者是一棵空树;或者是具有以下性质的 二叉树: ⑴ 若它的左子树不空,则其左子树中所有结点的值不大于根结点的值; ⑵ 若它的右子树不空,则其右子树中所有结点的值不小于根结点的值; ⑶ 它的左、右子树都是二叉查找树。 如图 8-2(a)所示就是一棵二叉查找树。需要注意的是,在这里并不要求所有结点元素 的关键字必须互异。 分析二叉查找树的结构和二叉树的中序遍历方法,我们有以下结论:中序遍历一棵二叉 查找树可以得到一个按关键字递增的有序序列。例如对图 8-2(a)中的二叉查找树进行中序 遍历,得到的遍历序列为:2 , 3 , 4 , 6 , 7 , 9 , 13 , 15 , 17 , 18 , 20。 下面分析在二叉查找树中进行查找以及动态修改查找表的算法。由于二叉查找树是二叉 树,所以实现二叉查找树的类 BSTree 可以通过继承第六章中实现的二叉树来完成。在以下 算法的实现中涉及了继承自 BinaryTreeLinked 类的两个成员变量:一个是二叉树结点类型的 root 变量,它指向二叉查找树的根结点;另一个是第三章中定义的 Strategy 接口类型变量 strategy,用于完成数据元素之间的比较操作;同时在二叉查找树中定义了一个新的二叉树 结点类型的成员变量 startBN,它的作用是用于平衡二叉树中实现平衡化操作而设,它的作 用和含义详见 8.3.2 小节。 168 图 8-2 二叉查找树 15 6 18 3 17 7 20 13 4 2 9 7 3 17 2 134 20 6 15 9 18 (a) (b) „ 查找算法 二叉查找树的基本查找方法是从根结点开始,递归的缩小查找范围,直到发现目标元素 为止(查找成功)或查找范围缩小为空树(查找失败)。 查找算法的基本思想是:若查找树不为空,将待查关键字与根结点元素关键字比较,若 相等则返回根结点;否则判断待查关键字与根结点关键字的大小,如果待查关键字小,则递 归的在查找树的左子树中查找,否则递归的在查找树的右子树中查找。 算法 8-2 实现了在二叉查找树中查找数据元素的算法。 算法 8-2 search 输入:待查元素 ele 输出:对应元素在二叉查找树中的结点位置 代码: public Node search(Object ele){ return binTSearchRe (root, ele); } private Node binTSearchRe(BinTreeNode rt, Object ele){ if (rt==null) return null; switch(strategy.compare(ele,rt.getData())){ case 0: return rt; //等于 case -1: return binTSearchRe(rt.getLChild(),ele); //小于 default: return binTSearchRe(rt.getRChild(),ele); //大于 } } 算法 8-2 说明:递归算法 binTSearchRe 从根结点 root 开始,算法在递归的执行过程中 只会沿着 case 语句的一条分支进行;查找成功时,实际上就是走了一条从根结点到某个结 点的路径,路径上结点的个数为算法执行中进行关键字比较的次数;查找失败时,走了一条 从根到某个空结点的路径,算法中进行关键字的比较次数依然是路径上结点个数;因此算法 的时间复杂度为 Ο(h),h 为二叉查找树的高度。 由于在二叉查找树中查找数据元素,实际上是判断当前结点,若不等则向左或右子树不 断深入的过程。因此也可以使用算法 8-3 所示的非递归形式实现。例如在图 8-2(a)所示的 二叉查找树中查找元素 13,就从根结点 15 开始向左、右、右走了一条到达 13 的路径,在 图中用虚线表示。 169 算法 8-3 binTSearch 输入:根结点 rt,待查元素 ele 输出:对应元素在 rt 为根的二叉查找树中的结点位置 代码: private Node binTSearch(BinTreeNode rt, Object ele){ while(rt!=null){ switch(strategy.compare(ele,rt.getData())){ case 0: return rt; //等于 case -1: rt = rt.getLChild(); break; //小于 default: rt = rt.getRChild(); //大于 } } return null; } 算法 8-3 在查找的过程中也是走了从根到某个结点的一条路径,因此算法的时间复杂度 与算法 8-2 一样,也是 Ο(h)。 【查找分析】通过图 8-2(a)以及上面的查找算法分析,我们知道二叉查找树的关键字 比较次数不超过树的高度。然而,和折半查找不同的是,对长度为 n 的查找表进行折半查找, 其的判定树是唯一的;而含有 n 个结点的二叉查找树却不唯一。图 8-2(a)与 8-2(b)的 两棵二叉查找树中结点的个数和值都相同,但一个的高度为 4,一个高度为 5。假设每个元 素的查找概率相等,均为 1/11,则(a)树的平均查找长度为 ASL(a) = (1+2+2+3+3+3+3+4+4+4+5)/11 = 34/11 而(b)树的平均查找长度为 ASL(b) = (1+2+2+3+3+3+3+4+4+4+4)/11 = 33/11 因此,含有n个结点的二叉查找树的平均查找长度和树的形态有关。假设二叉查找树中每个 结点的关键字互异。在具有n个结点的二叉树中,树的最小高度为log n ,即在最好的情况下 二叉查找树的平均查找长度与折半查找一样与log n 成正比。具有n个结点的二叉树可以退化 为一个单链表,其深度为n-1,此时其平均查找长度为(n+1)/2,与顺序查找相同,这是最差 的情况。在平均情况下,如果随机生成二叉查找树,其平均查找长度和log n 是等数量级的①。 „ 最大最小值 在二叉查找树中,最小元素总是能够通过根结点向左不断深入,直到到达最左的一个叶 子结点找到;而最大元素总是能够通过根结点向右不断深入,直到到达最右的一个叶子结点 找到。例如图 8-2 所示。下面的算法 8-4 和算法 8-5 分别返回指向给定根结点 v 的二叉查找 树中最大和最小元素所在的结点。 算法 8-4 min 输入:根结点 v 输出:在 v 为根的二叉查找树中最小元素的位置 代码: public Node min(BinTreeNode v){ if (v!=null) while (v.hasLChild()) v = v.getLChild(); ① 可以证明在平均情况下,当n≥2 时,二叉查找树的平均查找长度ASL≤2 nln n 11 ⎟ ⎠ ⎞⎜ ⎝ ⎛ + 。 170 return v; } 算法 8-5 max 输入:根结点 v 输出:在 v 为根的二叉查找树中最大元素的位置 代码: public Node max(BinTreeNode v){ if (v!=null) while (v.hasRChild()) v = v.getRChild(); return v; } 算法 8-4 和算法 8-5 的实现也是走了一条从根结点到叶子结点的路径,因此,算法的时 间复杂度和查找算法一样也是 Ο(h),h 是以 v 为根的树的高度。 „ 前驱和后续 对于二叉查找树中某个给定结点 v,在有些情况下确定该结点在中序遍历序列中的直接 前驱和后续是很重要的。如果查找树中结点关键字互异,那么其中序序列是一个严格递增的 序列,v 的后续是比 v 大的元素中关键字最小的,v 的前驱是比 v 小的元素中关键字最大的。 在二叉查找树中确定某个结点 v 的后续结点的算法思想如下:如果结点 v 有右子树,那 么 v 的后续结点是 v 的右子树中关键字最小的;如果结点 v 右子树为空,并且 v 的后续结点 存在,那么 v 的后续结点是从 v 到根的路径上第一个作为左孩子结点的父结点。例如图 8-2 (a)中,结点 15 的后续是它的右子树中最小的元素 17;结点 4 没有右子树,而结点 3 是 从结点 4 到根结点 15 的路径上第一个作为左孩子的结点,结点 3 的父结点 6 就是结点 4 的 后续。 算法 8-6 getSuccessor 输入:根结点 v 输出:返回 v 在中序遍历序列中的后续结点 代码: private BinTreeNode getSuccessor (BinTreeNode v){ if (v==null) return null; if (v.hasRChild()) return (BinTreeNode)min(v.getRChild()); while (v.isRChild()) v = v.getParent(); return v.getParent(); } 算法 8-6 无论是在结点 v 的右子树中找最小结点,或者还是沿着 v 的父指针域沿路上向, 算法的时间复杂度都是 Ο(h),h 是二叉查找树的高度。 确定结点 v 的直接前驱结点的算法思想与确定前驱结点的算法正好对称,算法的实现见 算法 8-7,且算法的时间复杂度同样是 Ο(h)。 算法 8-7 getPredecessor 输入:根结点 v 输出:返回 v 在中序遍历序列中的前驱结点 代码: private BinTreeNode getPredecessor(BinTreeNode v){ if (v==null) return null; if (v.hasLChild()) return (BinTreeNode)max(v.getLChild()); 171 while (v.isLChild()) v = v.getParent(); return v.getParent(); } „ 插入算法 为了在二叉查找树中插入一个结点,并且保持二叉查找树的特性,我们必须根据结点元 素的关键字,确定结点插入的位置及方向,然后将新结点作为叶子结点插入。 为了判定新结点的插入位置,需要从根结点开始逐层深入,判断新结点关键字与各子树 根结点关键字的大小,若新结点关键字小,则向相应根结点的左子树深入,否则向右子树深 入;直到向某个结点的左子树深入而其左子树为空,或向某个结点的右子树深入而其右子树 为空时,则确定了新结点的插入位置。图 8-3 图示了插入结点 14 的过程。 图 8-3 在二叉查找树中插入结点 15 6 18 3 17 7 20 13 4 2 9 15 6 18 3 17 7 20 1342 9 14 算法 8-8 实现了上述操作过程。 算法 8-8 insert 输入:待插元素 ele 输出:在二叉查找树中插入 ele 代码: public void insert(Object ele){ BinTreeNode p = null; BinTreeNode current = root; while (current!=null){ //找到待插入位置 p = current; if (strategy.compare(ele,current.getData())<0) current = current.getLChild(); else current = current.getRChild(); } startBN = p; //待平衡出发点 * if (p==null) root = new BinTreeNode(ele); //树为空 else if (strategy.compare(ele,p.getData())<0) p.setLChild(new BinTreeNode(ele)); else p.setRChild(new BinTreeNode(ele)); 172 } 算法 8-8 说明:在算法中使用了两个循环变量 current 和 p,其中 p 指向 current 的父结 点。while 循环从根结点不断向下深入,当 current 为空时,该空位置为新结点的待插位置, 而p即为新结点的父结点,判断新结点与p的关键字大小即可确定插入方向并完成插入操作。 算法走了一条从根到某个结点 p 的一条路径,因此算法的时间复杂度为 Ο(h)。另外,在算 法 8-8 中标记*的一行是用来实现 AV L 树的重新平衡,在这里可以暂时不予理会。 „ 删除算法 同样在二叉查找树中删除一个特定结点 v 时,我们也要在保持二叉查找树特性的前提下 进行删除。与在二叉查找树中插入结点不同的是,在二叉查找树中删除结点要复杂的多,因 为在二叉查找树中插入的新结点总是作为叶子结点插入,只要找到插入位置则插入操作十分 简单,而在二叉查找树中删除的结点不总是叶子结点,因此在删除一个非叶子结点时需要处 理该结点的子树。 图 8-4 在二叉查找树中删除结点 15 6 18 3 17 7 20 13 4 2 9 15 6 18 3 17 7 20 134 9 15 6 18 4 17 13 20 9 13 6 18 4 17 9 20 下面我们分三种情况讨论结点 v 的删除: ⑴ 如果结点v为叶子结点,即其左右子树Pl和Pr均为空,此时可以直接删除该叶子结点v, 而不会破坏二叉查找树的特性,因此直接从树中摘除v即可。例如在图 8-4 所示的二叉查找 树中删除结点 2,由于结点 2 是叶子结点,则直接摘除即可。 ⑵ 如果结点v只有左子树Pl或只有右子树Pr,此时,当结点v是左孩子时,只要令Pl或Pr为 其双亲结点的左子树即可;当结点v是右孩子时,只要令Pl或Pr为其双亲结点的右子树即可。 例如在图 8-4 所示的二叉查找树中删除结点 3 和 7,由于 3 是左孩子,因此在删除结点 3 之 后,将其右子树设为其父结点 6 的左子树即可;同样,因为 7 是右孩子,因此在删除结点 7 之后,将其右子树设为其父结点 6 的右子树即可。 ⑶ 如果结点v既有左子树Pl又有右子树Pr,此时,不可能进行如上简单处理。为了在删 除结点v之后,仍然保持二叉查找树的特性,我们必须保证删除v之后,树的中序序列必须仍 然有序。为此,我们可以先用中序序列中结点v的前驱或后序替换v,然后删除其前驱或后序 173 结点即可,此时v的前驱或后序结点必然是没有右孩子或没有左孩子的结点,其删除操作可 以使用前面规定的方法完成。例如在图 8-4 所示的树中删除结点 15,由于结点 15 既有左子 树又有右子树,则此时,可以先找到其前驱结点 13,并用 13 替换 15,然后删除结点 13 即 可。 算法 8-9 实现了在二叉查找树中删除结点的操作。 算法 8-9 remove 输入:待删除元素 ele 输出:在二叉查找树中删除 ele 代码: public Object remove(Object ele){ BinTreeNode v = (BinTreeNode)binTSearch(root,ele); if (v==null) return null; //查找失败 BinTreeNode del = null; //待删结点 BinTreeNode subT = null; //待删结点的子树 if (!v.hasLChild()||!v.hasRChild()) //确定待删结点 del = v; else{ del = getPredecessor(v); Object old = v.getData(); v.setData(del.getData()); del.setData(old); } startBN = del.getParent(); //待平衡出发点 * //此时待删结点只有左子树或右子树 if (del.hasLChild()) subT = del.getLChild(); else subT = del.getRChild(); if (del==root) { //若待删结点为根 if (subT!=null) subT.sever(); root = subT; } else if (subT!=null){ //del为非叶子结点 if (del.isLChild()) del.getParent().setLChild(subT); else del.getParent().setRChild(subT); } else//del为叶子结点 del.sever(); return del.getData(); } 算法 8-9 说明:在算法 8-9 中首先查找待删结点,如果找到则判断结点 v 的特性,如果 v 既有左子树又有右子树,则调用 getPredecessor 确定其前驱结点 pre,交换 v 与 pre 的数据, 此时 pre 为待删结点 del,否则找到的结点 v 即为待删结点 del。此时,del 必定只有左子树 174 或只有右子树,然后根据方法⑴、⑵删除 del 即可。算法执行查找的时间为 Ο(h),如果待删 结点 v 不为空,需时 Ο(h)确定 v 的前驱,其他操作常数时间即可完成,因此算法的时间复 杂度为 Ο(h)。另外,在算法 8-9 中标记*的一行是用来实现 AV L 树的重新平衡,在这里可 以暂时不予理会。 8.3.2 AVL 树 在 8.3.1 小节中介绍的二叉查找树中,我们看到在二叉查找树中进行查找,结点的插入 和删除等操作的时间复杂度都是 Ο(h),其中 h 为查找树的高度。可见,二叉查找树高度直 接影响到操作实现的性能,而在某些特殊的情况下二叉查找树会退化为一个单链表,如插入 的结点序列本身就有序的情况下,此时各操作的效率会下降到 Ο(n),其中 n 为树的规模。 因此,在结点规模固定的前提下,二叉查找树的高度越低越好,从树的形态来看,也就是使 树尽可能平衡。当二叉查找树的高度为 Ο(log n)时,此时各算法的时间复杂度均为 Ο(h)= Ο(log n);另一方面由于具有 n 个结点的树的高度为 Ω(log n),如此当树的高度为 Ο(log n) 时各操作实现的效率达到最佳。 为说明二叉树的平衡性,我们定义二叉树中结点平衡因子的概念。在二叉树中,任何一 个结点 v 的平衡因子都定义为其左、右子树的高度差。注意,空树的高度定义为-1。 在二叉查找树 T 中,若所有结点的平衡因子的绝对值均不超过 1,则称 T 为一棵 AV L 树。例如,图 8-2(a)所示的二叉查找树就不是 AV L 树,因为结点 7 和 15 的平衡因子分别 为-2 和+2;而 8-2(b)所示的二叉查找树是 AV L 树,其中每个结点的平衡因子的绝对值均 不操作过 1。 由于 AV L 上任何结点的左右子树的高度之差都不超过 1,则可以证明高度为 h 的 AV L 树至少包含 Fib(h+3)-1(其中 Fib(i)为 Fibonacci 数列的第 i 项,i ≥ 0)个结点,如此有 n 个 结点的 AV L 树的高度和 log n 是同数量级的。可见,在 AV L 树中查找、插入、删除等操作 的效率就渐进复杂度而言可以达到最佳。 AV L 树也是一棵二叉查找树,因此与一般二叉查找树一样,AV L 树也是动态的,也应 该支持插入、删除等操作。然而原本一棵 AV L 树在经过插入或删除之类的操作后,某些结 点的高度可能会发生变化,以至于不再满足 AV L 树的条件。在这种情况下,我们需要重新 调整树的结构使之重新平衡。 „ 旋转操作 在讨论调整树结构使之重新平衡的内容之前,先介绍调整树结构而不改变二叉查找树特 性的手段,即旋转操作。 1. 右旋(顺时针方向旋转) 如图 8-5 所示。假设图 8-5(a)中结点 v 是结点 p 的左孩子,X 和 Y 分别是 v 的左、 右子树,Z 为 p 的右子树。所谓围绕结点 p 的右旋操作,就是重新调整这些结点位置,将 p 作为 v 的右孩子,将 X 作为 v 的左子树,将 Y 和 Z 分别作为 p 的左、右子树。围绕 p 右旋 的结果如图 8-5(b)所示。 2. 左旋(逆时针方向旋转) 对称的,如图 8-6 所示。假设图 8-6(a)中结点 v 是结点 p 的右孩子,Y 和 Z 分别是 v 的左、右子树,X 为 p 的左子树。所谓围绕结点 p 的左旋操作,就是将 p 作为 v 的左孩子, 将 Z 作为 v 的右子树,将 X 和 Y 分别作为 p 的左、右子树。围绕 p 左旋的结果如图 8-6(b) 所示。 175 图 8-5 顺时针旋转 (a) (b) p v X Y Z v p Y Z X 右旋 图 8-6 逆时针旋转 (a) (b) p v Y Z X 左旋 v p X Y Z 在以上旋转过程中,每种旋转都只涉及常数次基本操作,因此左旋和右旋均可以在 Ο(1) 时间内完成。而且,无论是右旋还是左旋,都不改变二叉树的中序遍历结果。例如图 8-5(a) 所示二叉树的中序遍历序列为 X , v , Y , p , Z;右旋之后得到的图 8-5(b)所示二叉树的中 序遍历序列仍然为 X , v , Y , p , Z。可见,如果对二叉查找树中的结点进行旋转操作不会破 坏二叉查找树的特性。因此,当 AV L 树失去平衡后,我们可以采用旋转的方法,在不破坏 二叉查找树特性的基础上,重新使树得到平衡。 „ 失去平衡后的重新平衡 下面首先以插入操作为例,说明 AV L 树在失去平衡后,使之重新平衡的方法。 例如,我们按照普通二叉查找树的插入算法,在图 8-7(a)所示的 AV L 树中插入结点 9。于是,如图 8-7(b)所示,结点 7 和 15 将失去平衡。 图 8-7 插入结点后 AV L 树失去平衡 1 0 -1 -1 0 0 0 0 0 15 6 18 3 17 7 20 13 4 0 9 2 -1 -1 -2 0 1 0 0 0 15 6 18 3 177 20 134 (a) (b) 176 一般的,若在插入新的结点 x 之后 AV L 树 T 失去平衡,则失去平衡的结点只可能是 x 的祖先,且层次数小于等于 x 的祖父的结点;也就是说失去平衡的结点是从 x 的祖父到根的 路径上的结点,但这些结点并不都是失去平衡的结点,有些结点仍然可能是平衡的。例如, 图 8-7(b)中,失去平衡的结点 7 和 15 是插入结点 9 的祖父到根的路径上的结点,而该路 径上的另一个结点 13 仍然平衡。 为了修正失衡的现象,可以从结点 x 出发逆行向上,依次检查 x 祖先的平衡因子,找到 第一个失衡的祖先结点 g。在从 x 到 g 的路径上,假设 p 为 g 的孩子结点,v 为 p 的孩子结 点。根据前面的讨论,有结点 p、v 必不为空,p 至少是 x 的父亲,v 至少是 x 本身。 结点 g、p、v 之间的父子关系共有 4 种不同的组合,以下根据这 4 种不同的组合,通过 对结点 g、p 的旋转,使这一局部恢复平衡。 1. p 是 g 的左孩子,且 v 是 p 的左孩子; 在这种情况下,必定是由于在 v 的后代中插入了新结点 x 而使得 g 不再平衡。针对这种 情况,可以简单的通过结点 g 的单向右旋,即可使得以 g 为根的子树得到平衡。这一过程如 图 8-8 所示。 图 8-8 单向右旋 g v p T3 T2 T1 T0 x x gv p T3 T2 T1T0 xxh h-1 h+1 通过图 8-8 不难看出,当这一局部经过单向右旋调整后,不但失衡的结点 g 重新平衡, 而且经过这一调整后,这一局部子树的高度也恢复到插入 x 以前的高度。因此 x 所有其他祖 先的平衡因子也都会得到恢复,也就是说,在这种情况下,只需要一次旋转操作,即可使整 棵 AV L 树恢复平衡。例如,图 8-8 中插入结点 x 之前这一局部子树的高度为 h,经过旋转操 作之后,得到平衡的子树高度仍然为 h。 在插入结点 x 之前树是平衡的,此时树的高度为 Ο(log n),则从 x 出发查找 g 需要花费 的时间为 Ο(log n),而进行平衡所需的一次旋转操作只需 Ο(1)时间,因此整个平衡过程只需 Ο(log n)时间。 2. p 是 g 的右孩子,且 v 是 p 的右孩子; 与第一种情况对称,此时,失衡是由于在 g 的右孩子的右子树中插入结点 x 造成的。在 这种情况下需要通过结点 g 的单向左旋来完成局部的平衡。这一过程如图 8-9 所示。 通过图 8-9 不难看出,和单向右旋一样,在旋转之后不但失衡的结点 g 重新平衡,而且 子树的高度也恢复到插入 x 以前的高度,因此局部的平衡能使得整棵树得到平衡。这一操作 需要的时间与单向右旋相同,也为 Ο(log n)。 177 图 8-9 单向左旋 vg p T1 T0 T3 T2 xxh h-1 h+1 g v p T1 T0 T3 T2 xx 3. p 是 g 的左孩子,且 v 是 p 的右孩子; 如果是在 p 的左孩子的右子树中插入一个结点,则对应为第三种情况。在这种情况中, 要使得局部的失衡重新平衡,那么需要先后进行先左后右的双向旋转:第一次是结点 p 的左 旋,第二次是结点 g 的右旋。这一过程如图 8-10 所示。 图 8-10 先左后右双向旋转 g v p T3 T0 T2 T1 x x g p v T3 T0 T2 T1 x xh h-1 h+1 g v p T3 T0 T2 T1 x x 失衡的局部经过双向旋转后,失衡的结点 g 重新平衡,并且子树的高度也恢复到插入结 点 x 之前的高度,结点 x 的祖先结点的平衡因子都会恢复。在这种情况下,只需要两次旋转 操作就可以使得整棵 AV L 树恢复平衡。同样,为了确定 g 的位置需要 Ο(log n)时间,旋转 操作需要 Ο(1)时间,因此,整个双旋操作只需 Ο(log n)时间。 4. p 是 g 的右孩子,且 v 是 p 的左孩子; 第四种情况与第三种情况对称,其平衡调整过程也与第三种情况对称,可以通过先右后 左的双向旋转来完成,其中第一次是结点 p 的右旋,第二次是结点 g 的左旋。调整过程如图 8-11 所示。在旋转之后不但失衡的结点 g 重新平衡,而且子树的高度也恢复到插入 x 以前的 高度,即局部的平衡能使得整棵树得到平衡。这一操作需要的时间与先左后右双向旋转相同, 也为 Ο(log n)。 通过以上四种情况的分析,可以得到如下结论:在 AV L 树中插入一个结点后,至多只 需要进行两次旋转就可以使之恢复平衡。进一步,由于在 AV L 树中按一般二叉查找树的方 法插入结点需要 Ο(log n)时间,旋转需要 Ο(1)时间,则在 AV L 树中结点插入操作可以在 Ο(log n)时间内完成。 178 图 8-11 先右后左双向旋转 p g v T3 T0 T2 T1 x xh h-1 h+1 g v p T3 T0 T2 T1 x x g v p T3 T0 T2 T1 x x 按照通常的二叉查找树删除结点的方法在AV L 树中删除结点x①之后,如果发生失衡现 象,为了重新平衡,同样从x出发逆行向上找到第一个失衡的结点g,通过旋转操作实现AV L 树的重新平衡。使得结点g失衡的原因可以看成四种: 1. 失衡是由于 g 的左孩子的左子树过高造成的; 2. 失衡是由于 g 的右孩子的右子树过高造成的; 3. 失衡是由于 g 的左孩子的右子树过高造成的; 4. 失衡是由于 g 的右孩子的左子树过高造成的。 这四种情况与插入结点导致失衡的四种情况进行平衡化的旋转方法相同。但是读者需要 注意两个特殊情况:其中之一是失衡可能是由于左孩子的左、右子树同时过高造成的,这种 情况如图 8-12 所示。从图中可以看出对这种情况的处理可以归为情况 1 或 3 进行处理都是 可以的,即图(a)可以通过简单的结点 g 的右旋完成,如图(b)所示;或通过先左后右的 双向旋转完成,如图(c)所示。另一种特殊情况与此对称,同样也可以归结为情况 2 或 4 处理。 图 8-12 失衡结点左孩子的左、右子树等高的两种处理方式 (c) (b)(a) g v p T1 T0 T3 T2 u T4 x p v g T1T0 T3T2 u T4 h h-1 h+1 u p g T3 T2 T1T0 v T4 删除结点后,根据失衡结点 g 的四种不同情况进行相应的旋转操作,即可使得局部恢复 平衡,但是和插入结点使 AV L 树失衡不同的是,在删除结点的情况下,局部重新平衡不一 定会使得 AV L 整体恢复平衡,即会造成失衡的传递。例如在图 8-12 所示的情况下,子树在 删除结点之前和删除结点之后的高度都为 h+1,此时局部平衡会使 AV L 树整体平衡;然而 ① 如果在删除x之前,曾将x与其直接前驱w交换,则以下叙述中的x实际上是w,即实际删除的结点。 179 在有些情况下,删除结点并调整平衡之后,局部子树的高度会降低,如图 8-13 所示。 图 8-13 平衡后子树高度降低 gv p T3 T2 T1T0 h h-1 h+1 g v p T3 T2 T1 x T0 从图 8-13 可以看到,在删除结点 x 之前,子树的高度为 h+1,而在删除结点 x 并调整 平衡之后,子树的高度为 h,此时由于子树高度下降,会导致某些祖先结点将有可能因此失 去平衡。这种由于对局部的重新平衡而造成更上层祖先失衡的情况,称为“失衡传播”。当发 生失衡传播时,需要对到根结点路径上的失衡结点依次进行平衡操作,直到根结点为止。 由于 AV L 树的高度为 Ο(log n),如此,我们有以下结论:在 AV L 树中删除一个结点后, 至多需要 Ο(log n)次旋转即可使之平衡。进一步,由于在 AV L 树中按通常查找树的方法删 除结点需要 Ο(log n)时间,平衡调整也需要 Ο(log n)时间,因此,在 AV L 树中删除结点的操 作可以在 Ο(log n)时间内完成。 „ 旋转操作的统一实现方法① 前面介绍的 4 种平衡旋转操作,需要根据结点 g、p、v(p 是 g 的孩子,v 是 p 的孩子) 之间的相互关系来判断应该使用的旋转方法。为此,在这里引入一种统一的算法,这种实现 简洁、直观,容易实现。 在插入或删除结点 x 之后,从 x 出发逆行向上,找到第一个失衡的结点 g,然后根据 g 失衡的原因,设置 p 和 v 的指向,p 和 v 都是其父结点较高子树的根。此时,在这一局部, 以 p 的兄弟为根有一棵子树,即 g 较矮的子树;以 v 的兄弟为根有一棵子树,即 p 较矮的子 树;v 有两棵子树;一共有 4 棵子树。注意,在这 4 棵子树中可能有空树。显然,以失衡结 点 g 为根的局部平衡是在这 3 个结点和 4 棵子树间完成的。 在确定了这 3 个结点和 4 棵子树之后,接着为它们重命名。具体的命名方法是,从左到 右将 4 棵子树命名为 T0、T1、T2、T3;按照中序顺序将结点 g、p、v 分别命名为 a、b、c。 四种不同旋转情况的命名见图 8-14 所示。 一旦完成重命名,无论对于那种情况,我们都能以相同的方法将这 3 个结点和 4 棵子树 重新组合起来,以达到旋转平衡的目的。如图 8-14(e)所示,以结点 b 作为局部子树的根, 结点 a 和 c 分别是 b 的左孩子和右孩子,结点 a 的左右子树分别是 T0、T1,结点 c 的左右 子树分别是 T2、T3。 读者可将这一结果和前面的图 8-8、8-9、8-10、8-11 对照,即可看出统一算法的效果与 前面的单向旋转和双向旋转完全一致。 算法 8-10 给出了旋转操作实现的统一算法。 ① 见参考文献[] 180 图 8-14 统一平衡方法 (d)(c) (b)(a) ca b T3 T2T1 T0 c a b T3 T2 T1 T0 a c b T0 T1 T3 T2 c b a T3 T0 T2 T1 a b c T0 T3 T2 T1 (e) 算法 8-10 rotate 输入:失衡的结点 z 输出:平衡后子树的根结点 代码: private BinTreeNode rotate(BinTreeNode z){ BinTreeNode y = higherSubT(z); //取 y 为 z 更高的孩子 BinTreeNode x = higherSubT(y); //取 x 为 y 更高的孩子 boolean isLeft = z.isLChild(); //记录:z 是否左孩子 BinTreeNode p = z.getParent(); //p 为 z 的父亲 BinTreeNode a, b, c; //自左向右,三个节点 BinTreeNode t0, t1, t2, t3; //自左向右,四棵子树 // 以下分四种情况重命名 if (y.isLChild()) { //若 y 是左孩子,则 c = z; t3 = z.getRChild(); if (x.isLChild()) { //若 x 是左孩子(左左失衡) b = y; t2 = y.getRChild(); a = x; t1 = x.getRChild(); t0 = x.getLChild(); } else { //若 x 是右孩子(左右失衡) a = y; t0 = y.getLChild(); b = x; t1 = x.getLChild(); t2 = x.getRChild(); } } else { //若 y 是右孩子,则 181 a = z; t0 = z.getLChild(); if (x.isRChild()) { //若 x 是右孩子(右右失衡) b = y; t1 = y.getLChild(); c = x; t2 = x.getLChild(); t3 = x.getRChild(); } else { //若 x 是左孩子(右左失衡) c = y; t3 = y.getRChild(); b = x; t1 = x.getLChild(); t2 = x.getRChild(); } } //摘下三个节点 z.sever(); y.sever(); x.sever(); //摘下四棵子树 if (t0!=null) t0.sever(); if (t1!=null) t1.sever(); if (t2!=null) t2.sever(); if (t3!=null) t3.sever(); //重新链接 a.setLChild(t0); a.setRChild(t1); c.setLChild(t2); c.setRChild(t3); b.setLChild(a); b.setRChild(c); //子树重新接入原树 if (p!=null) if (isLeft) p.setLChild(b); else p.setRChild(b); return b;//返回新的子树根 } //返回结点 v 较高的子树 private BinTreeNode higherSubT(BinTreeNode v){ if (v==null) return null; int lH = (v.hasLChild()) ? v.getLChild().getHeight():-1; int rH = (v.hasRChild()) ? v.getRChild().getHeight():-1; if (lH>rH) return v.getLChild(); if (lH m,因此至少有两个关键字有相同的哈希值;因此完全地避免冲 突是不可能的。因而,一方面我们可以使随机哈希函数尽量“随机”使冲突的次数减到最小, 另一方面我们仍然需要解决可能出现冲突的办法。 在后面的两个小节中,我们介绍一些常见的哈希函数和解决冲突的方法。 8.4.2 哈希函数 许多哈希函数都假设关键字的值域为自然数集合 N={0, 1, 2, …}。因此,如果关键字不 是自然数,我们可以找到一种方法把他们转化为自然数。例如,一个字符串在合适的基数计 数法中就可以被转化为一个整数表示。例如,字符串"nt"首先可以用两个十进制整数 110 和 116 表示,因为在 ASCII 字符集中, n=110 且 t=116。那么,在以 128 为基数的计数法中, 字 符串"nt"被表示成(112*128)+116=14452。通常情况下,在某一特定的应用中,构造一些方法 把每个关键字转化成一个大的自然数是很简单的。在下面的讨论中,我们假定关键字都是自 然数。 构造哈希函数的方法很多,然而要构造一个好的哈希函数却需要相当的知识和技巧。一 个好的哈希函数应该(近似)满足简单一致分布的假设:即每个关键字等可能地散列到任一 个地址中去。下面我们介绍几种常见的哈希函数构造方法 „ 除留余数法 在除留余数法中,为了构造哈希函数,我们通过取 k 除以 m 的余数来将关键字 k 映射 到哈希表的 m 个不同地址的某一个中去。即,哈希函数为: h(k) = k mod m 例如,如果哈希表的大小 m=12 并且关键字 k=100,那么 h(k)=4。因为它仅仅需要一个 简单的除法操作,所以除留余数法的计算速度是相当快的。 当使用除留余数法时,我们通常要注意 m 的选择。如果 m 选择不当,将会产生大量冲 突。例如,对于关键字集合{200, 205, 210, 215, … 690, 695, 700},若选取 m=100,则每个关 190 键字的散列值至少和其他 4 个关键字的散列值产生冲突;而如果选取 m=101,则不会产生 冲突。 并且,m也不应该为 2 的整数次幂,因为如果m=2p,那么h(k)就是k的最低p位所代表的 数字。除非我们事先知道关键字的分布使得k的最低p位的各种排列形式出现的可能性相同, 否则在设计哈希函数时应当使得散列值依赖于k的每一位。我们通常把一个不太靠近 2 的整 数次幂的素数作为m的值。 „ 乘法散列 在乘法散列法中,为了构造哈希函数,我们需要两步操作:第一,我们将关键字 k 乘以 一个常量 A,它的取值范围为 0=low&&strategy.compare(temp,r[j])<0; j--) r[j+1] = r[j]; //记录后移 r[j+1] = temp; //插入到正确位置 } } 【效率分析】 空间效率:仅使用一个辅存单元。 时间效率:假设待排序的元素个数为 n,则向有序表中逐个插入记录的操作进行了 n-1 趟,每趟操作分为比较关键码和移动记录,而比较的次数和移动记录的次数取决于待排序列 按关键码的初始排列。 ⑴ 在最好情况下,即待排序序列已按关键字有序,每趟操作只需 1 次比较 0 次移动。 此时有: 总比较次数 = n-1 次 总移动次数 = 0 次 ⑵ 在最坏情况下,即待排序序列按关键字逆序排序,这时在第 j 趟操作中,为插入元 素需要同前面的 j 个元素进行 j 次关键字比较,移动元素的次数为 j+1 次。此时有: 总比较次数 = = n(n-1)/2 次 n-1 j1 j = ∑ 总移动次数 = = (n+2)(n-1)/2 次 n-1 j1 (j+1) = ∑ ⑶ 平均情况下:即在第 j 趟操作中,插入记录大约需要同前面的 j/2 个元素进行关键字 比较,移动记录的次数为 j/2+1 次。此时有: 总比较次数 ≈ n2/4 次 总移动次数 ≈ n2/4 次 由此,直接插入排序的时间复杂度为O(n2),并且是一个稳定的排序方法。 9.2.2 折半插入排序 从上一小节可见,直接插入排序算法简便、容易实现。当待排序元素的数量 n 很小时, 这是一种较好的排序方法,但是通常待排序元素数量 n 很大,则不宜采用直接插入排序方法, 此时需要对直接插入排序进行改进。 直接插入排序的基本操作是向有序序列中插入一个元素,插入位置的确定是通过对有序 序列中元素按关键字逐个比较得到的。既然是在有序序列中确定插入位置,则可以不断二分 有序序列来确定插入位置,即搜索插入位置的方法可以使用折半查找实现。 折半插入排序的实现如代码 9-2。 算法 9-2 binInsertSort 输入:数据元素数组 r,数组 r 的待排序区间[low..high] 输出:数组 r 以关键字有序 代码: public void binInsertSort(Object[] r, int low, int high){ 196 for (int i=low+1; i<=high; i++){ Object temp = r[i]; //保存待插入元素 int hi = i-1; int lo = low; //设置初始区间 while (lo<=hi){ //折半确定插入位置 int mid = (lo+hi)/2; if(strategy.compare(temp,r[mid])<0) hi = mid - 1; else lo = mid + 1; } for (int j=i-1;j>hi;j--) r[j+1] = r[j]; //移动元素 r[hi+1] = temp; //插入元素 }//for } 从算法 9-2 容易看出,折半插入排序所需的辅助空间与直接插入排序相同,从时间上比 较,折半插入排序仅减少了元素的比较次数,但是并没有减少元素的移动次数,因此折半插 入排序的时间复杂度仍为O(n2)。 9.2.3 希尔排序 希尔排序又称为“缩小增量排序”,它也是一种属于插入排序类的排序方法,是一种对直 接插入排序的改进,但在时间效率上却有较大的改进。 从对直接插入排序的分析中知道,虽然直接插入排序的时间复杂度为O(n2),但是在待 排序元素序列有序时,其时间复杂度可提高至O(n)。由此可知在待排序元素基本有序时,直 接插入排序的效率可以大大提高。从另一方面看,由于直接插入排序方法简单,则在n值较 小时效率也较高。希尔排序正是从这两点出发,对直接插入排序进行改进而得到的一种排序 方法。 希尔排序的基本思想是:首先将待排序的元素分为多个子序列,使得每个子序列的元素 个数相对较少,对各个子序列分别进行直接插入排序,待整个待排序序列“基本有序”后,再 对所有元素进行一次直接插入排序。 根据上述排序思想,下面我们给出希尔排序的排序过程: ⑴ 选择一个步长序列t1,t2,…,tk,其中ti>tj(i=low&&strateg p (te p,r[j])<0; j=j-deltaK) y.com are m r[j+deltaK] = r //记录后移 [j]; r[j+deltaK] = temp; //插入到正确位置 } } 通过前面的分析,从直观上我们可以预见希尔排序的效率会较直接插入排序要高,然而 对希尔排序的时间复杂度分析是一个复杂的问题,因为希尔排序的时间复杂度与步长序列的 选取密切相关,如何选择步长序列才能使得希尔排序的时间复杂度达到最佳,这还是一个有 待解决的问题。但是,对于希尔排序的研究已经得出许多有趣的局部结论①。例如,当步长 序列delta[k]=2t-k+1-1 时,希尔排序的时间复杂度为Ο(n3/2),其中t为希尔排序的趟数, 1≤k≤t≤⎣log (n+1)⎦。实际的应用中,在选择步长序列时应当注意:应使步长序列中的步长值 互质,并且最后一个步长值必须等于 1。 ① 计算机程序设计艺术第三卷,第 5 章,P69-76。 198 9.3 交换类排序 交换类排序主要是通过两两比较待排元素的关键字,若发现与排序要求相逆,则“交换” 之。在这类排序方法中最常见的是起泡排序和快速排序,其中快速排序是一种在实际应用中 具有很好表现的算法。 9.3.1 起泡排序 起泡排序的思想非常简单。首先,将 n 个元素中的第一个和第二个进行比较,如果两个 元素的位置为逆序,则交换两个元素的位置;进而比较第二个和第三个元素关键字,如此类 推,直到比较第 n-1 个元素和第 n 个元素为止;上述过程描述了起泡排序的第一趟排序过程, 在第一趟排序过程中,我们将关键字最大的元素通过交换操作放到了具有 n 个元素的序列的 最一个位置上。然后进行第二趟排序,在第二趟排序过程中对元素序列的前 n-1 个元素进行 相同操作,其结果是将关键字次大的元素通过交换放到第 n-1 个位置上。一般来说,第 i 趟 排序是对元素序列的前 n-i+1 个元素进行排序,使得前 n-i+1 个元素中关键字最大的元素被 放置到第 n-i+1 个位置上。排序共进行 n-1 趟,即可使得元素序列按关键字有序。 图 9-3 展示了一个起泡排序的实例。 9-3 起泡排序 26 53 48 11 13 48 32 15 初始关键字 第 1 趟 第 2 趟 第 3 趟 第 4 趟 第 5 趟 第 6 趟 第 7 趟 26 48 11 13 48 32 15 53 26 11 13 48 32 15 48 53 11 13 26 32 15 48 48 53 11 13 26 15 32 48 48 53 11 13 15 26 32 48 48 53 11 13 15 26 32 48 48 53 11 13 15 26 32 48 48 53 起泡排序的实现如算法 9-4 所示。 算法 9-4 bubbleSort 输入:数据元素数组 r,数组 r 的待排序区间[low..high] 输出:数组 r 以关键字有序 代码: public void bubbleSort(Object[] r, int low, int high){ int n = high - low + 1; for (int i=1;i0) { Object temp = r[j]; r[j] = r[j+1]; r[j+1] = temp; } 199 }//end of bubbleSort 【效率分析】 空间效率:仅使用一个辅存单元。 时间效率:假设待排序的元素个数为 n,则总共要进行 n-1 趟排序,对 j 个元素的子序 列进行一趟起泡排序需要进行 j-1 次关键字比较。由此,起泡排序的总比较次数为 2 jn n(n 1)(j 1) 2= −−=∑ 因此,起泡排序的时间复杂度为Ο(n2)。 9.3.2 快速排序 快速排序是将分治法运用到排序问题中的一个典型例子,快速排序的基本思想是:通过 一个枢轴(pivot)元素将 n 个元素的序列分为左、右两个子序列 Ll 和 Lr,其中子序列 Ll 中的元素均比枢轴元素小,而子序列 Lr 中的元素均比枢轴元素大,然后对左、右子序列分 别进行快速排序,在将左、右子序列排好序后,则整个序列有序,而对左右子序列的排序过 程直到子序列中只包含一个元素时结束,此时左、右子序列由于只包含一个元素则自然有序。 用分治法的三个步骤来描述快速排序的过程如下: 4. 划分步骤:通过枢轴元素 x 将序列一分为二, 且左子序列的元素均小于 x,右子 序列的元素均大于 x; 5. 治理步骤:递归的对左、右子序列排序; 6. 组合步骤:无 从上面快速排序算法的描述中我们看到,快速排序算法的实现依赖于按照枢轴元素 x 对待排序序列进行划分的过程。 对待排序序列进行划分的做法是:使用两个指针 low 和 high 分别指向待划分序列 r 的 范围,取 low 所指元素为枢轴,即 pivot = r[low]。划分首先从 high 所指位置的元素起向前 逐一搜索到第一个比 pivot 小的元素,并将其设置到 low 所指的位置;然后从 low 所指位置 的元素起向后逐一搜索到第一个比 pivot 大的元素,并将其设置到 high 所指的位置;不断重 复上述两步直到 low = high 为止,最后将 pivot 设置到 low 与 high 共同指向的位置。 使用上述划分方法即可将待排序序列按枢轴元素 pivot 分成两个子序列,当然 pivot 的 选择不一定必须是 r[low],而可以是 r[low..high]之间的任何数据元素。图 9-4 说明了一次划 分的过程。 算法 9-5 实现了一次划分的过程。 算法 9-5 partition 输入:数据元素数组 r,划分序列区间[low..high] 输出:将序列划分为两个子序列并返回枢轴元素的位置 代码: private int partition(Object[] r, int low, int high){ Object pivot = r[low]; //使用 r[low]作为枢轴元素 while (low=0) high--; r[low] = r[high]; //将比 pivot 小的元素移向低端 while(low=0) break; r[low] = r[j]; low = j; //向下筛选 } r[low] = temp; } 在算法 9-8 的基础上,则可以实现初始化建堆和排序的过程。 算法 9-8 heapSort 输入:数据元素数组 r 输出:对 r[1..length-1]排序 代码: public void heapSort(Object[] r){ int n = r.length - 1; for (int i=n/2; i>=1; i--) //初始化建堆 heapAdjust(r,i,n); for (int i=n; i>1; i--){ //不断输出堆顶元素并调整 r[1..i-1]为新堆 Object temp = r[1]; //交换堆顶与堆底元素 r[1] = r[i]; r[i] = temp; heapAdjust(r,1,i-1); //调整 } } 注:为了代码的易读性,在算法 9-8 中对数组 r 进行排序时,排序的范围是[1..length-1], 这一点和前面的算法是不一样的,前面的算法其排序范围是由参数指定的。当然堆排序也可 以对指定范围内的元素进行排序,只不过在对下标进行操作之前都必须进行相应的处理,读 者可自行设计实现指定范围的堆排序算法。 【效率分析】 空间效率:显然堆排序只需要一个辅助空间。 时间效率:首先,对于深度为k的堆,heapAdjust算法中所需执行的比较次数至多为 2k 次。则在初始建堆的过程中,对于具有n个元素、深度为h的堆而言,由于在i层上最多有 2i个 结点,以这些结点为根的二叉树深度最大为h-i,那么⎣n/2⎦次调用heapAdjust时总共进行的关 键字比较次数Tinit为: () () 0hh ih-j init ji i=h 1 j=1 j=1 i 0 jiT22hi2jn n22 ∞ − = ⎛⎞⎛⎞= • − =Ο • =Ο =Ο =⎜⎟⎜⎟ ⎝⎠⎝⎠ ∑∑∑因为 2∑ 即,初始化需要执行的比较操作的次数为 Ο(n)。 其次,在排序过程中,每输出一次堆顶元素需要进行一次调整,而每次调整所需的比较 次数为 Ο(log n),因此 n 次输出总共需要的比较次数为 Ο(n log n)。 由此,堆排序在任何情况下,其时间复杂度为 Ο(n log n)。这相对于快速排序而言是堆 排序的最大优点。 堆排序在元素较少时由于消耗较多时间在初始建堆上,因此不值得提倡,然而当元素较 多时还是很有效的排序算法。 207 9.5 归并排序 归并排序是另一类不同的排序方法,这种方法是运用分治法解决问题的典型范例。 归并排序的基本思想是基于合并操作,即合并两个已经有序的序列是容易的,不论这两 个序列是顺序存储还是链式存储,合并操作都可以在 Ο(m+n)时间内完成(假设两个有序表 的长度分别为 m 和 n)。为此,由分治法的一般设计步骤得到归并排序的过程为: 1. 划分:将待排序的序列划分为大小相等(或大致相等)的两个子序列; 2. 治理:当子序列的规模大于 1 时,递归排序子序列,如果子序列规模为 1 则成为有 序序列; 3. 组合:将两个有序的子序列合并为一个有序序列。 图 9-10 显示了归并算法的执行过程。假设待排序序列为{4, 8, 9, 5, 2, 1, 4, 6},如图所示, 归并排序导致了一系列递归的调用,而这一系列调用过程可以由一个二叉树来表示。树中每 个结点由两个序列组成,上端为该结点所表示的递归调用的输入,而下端为相应递归调用的 输出。树中的边用表示递归调用方向的两条边取代,边上的序号表示各个递归调用发生的次 序。 图 9-10 归并排序 15 28 2 7 8 13 16 21 22 27 3 4 5 6 9 10 11 12 17 18 19 20 23 24 25 26 1 14 4 8 9 5 2 1 4 6 1 2 4 4 5 6 8 9 4 8 9 5 2 1 4 6 4 8 9 5 4 8 4 5 8 9 4 8 5 9 9 5 4 8 9 5 2 1 4 6 2 1 4 6 2 1 4 6 1 2 4 6 1 2 4 6 归并排序中一个核心的操作是将一个序列中前后两个相邻的子序列合并为一个有序序 列,算法 9-9 给出了合并操作的实现。 算法 9-9 merge 输入:数据元素数组 a,a 待合并的两个有序区间[p..q]以及[q+1..r] 输出:将两个有序区间合并为一个有序区间 代码: private void merge(Object[] a, int p, int q, int r){ Object[] b = new Object[r-p+1]; int s = p; int t = q+1; int k = 0; while (s<=q&&t<=r) if (strategy.compare(a[s],a[t])<0) b[k++] = a[s++]; else 208 b[k++] = a[t++]; while (s<=q) b[k++] = a[s++]; while (t<=r) b[k++] = a[t++]; for (int i=0; i
还剩215页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享pdf获得金币 ] 2 人已下载

下载pdf