第1章 数据结构和算法
三个主要目的:
- 学习常用的数据结构,形成一个程序员的基本数据结构工具箱(toolkit),这些工具是解决许多问题的理想选择;
- 引入并加强权衡(tradeoff)的概念,每一个数据结构都有其相关的代价和效益的权衡;
- 评估一个数据额结构或算法的有效性,通过分析确定哪个数据结构对一个新问题而言最合适。
计算机程序设计的核心有两个目标(有时两者相互冲突):
- 设计一种容易理解、编码和调试的算法(软件工程原理);
- 设计一种能有效利用计算机资源的算法。
类型(type)是一组值的集合;数据项(data item)是一条信息或者其值数目某种类型的一条记录,又被称为数据类型的成员(member)。
数据类型(data type)是指带有一组操作的类型(type)。
抽象数据类型(abstract data type, ADT)是数据类型的一种软件组件概念的实现。ADT不会指定数据类型如何实现,这些细节对于ADT的用户是隐藏的,且通过封装(encapsulation)来阻止外部对它的访问。
数据结构(data structure)是ADT的实现。在诸如C++之类的面向对象语言中,ADT及其实现统统组成了类(class)。ADT的每个操作由一个成员函数(member function)来实现;定义数据型所需存储空间的变量被称为数据成员(data member)。对象(object)是类的一个实例,在一个计算机程序的执行期间创建、并占用一些存储空间。
数据项有逻辑形式和物理形式,用ADT给出的数据项的定义是它的逻辑形式,数据结构中对数据项的实现是它的物理形式。
问题(problem):从直觉上讲,问题无非是一个需要完成的任务,即一组输入有一组相应的输出。问题的定义不应包含有关问题怎样解决的限制。从数学角度讲,可把问题看作函数(function)。函数是输入(即定义域,domain)和输出(即值域,range)之间的一种映射关系。
算法(algorithm):指解决问题的一种方法或一个过程。如果将问题看作函数,则算法把输入转换成输出。一个问题可以用多种算法解决,一种给定的算法解决一个特定的问题。一种算法应包含以下几条性质:
- 正确性(correct)
- 具体步骤(concrete steps)
- 确定性(no ambiguity)
- 有限性(finite)
- 可终止性(terminable)
程序(
program):一个计算机程序一般是一种算法的实例或用某种程序设计语言的具体实现。
算法的可终止性要求意味着在技术上不是所有的计算机程序都满足一个算法的定义。比如,操作系统是一个程序,但不是算法。然而,可把操作系统的各种任务看成是一些单独的问题(有相应的输入输出),每个问题由实现某种算法的操作系统程序解决,得到输出结果后便终止。
第2章 算法分析
如果一种算法调用自己来完成它的部分工作,就称这种算法是
递归的(
recursive)。一个递归算法必须有两个部分:初始情况(
base case)和递归部分。初始情况只处理可以不需要再次递归调用的简单直接的输入;递归部分则包含对算法的一次或者多次递归调用,每一次的调用参数都在某种程度上比原始调用参数更接近初始情况。
给出一个级数求和,用一个能直接计算级数和的等式来代替它,这样的一个等式称为“闭合形式解”(
closed form solution),用闭合形式解替换级数的过程称为解(
solving)技术。 Hunger:用来算时间复杂度。
算法的增长率(
growth rate)是指当输入的值增长时,算法代价的增长速率。
如果某种算法的增长率上限(最差情况下)是f(n),则这种算法在(集合)O(f(n))中,或者说增长率的上限为f(n)。(类似,下限为Ω(g(n)))当上、下限相等时,可用θ(h(n))表示。
化简法则
- 若f(n)在O(g(n))中,且g(n)在O(h(n))中,则f(n)在O(h(n))中;
- 若f(n)在O(kg(n))中,对于任意常数k>0成立,则f(n)在O(g(n))中;
- 若f1(n)在O(g1(n))中,且f2(n)在O(g2(n))中,则f1(n)+f2(n)在O(max(g1(n)+g2(n)))中;
- 若f1(n)在O(g1(n))中,且f2(n)在O(g2(n))中,则f1(n)f2(n)在O((g1(n)g2(n)))中。
对于Ω和θ类似。当估算一种算法的时间或其它代价时,经常忽略其系数,简化算法分析,使注意力集中在最重要的一点(即增长率),这被称为渐近算法分析。准确地说,渐近分析是指当输入规模很大,或者说达到极限(微积分意义上)时,对一种算法的研究。然而,并不是任何情况都能忽略常数,当算法要解决的问题规模n很小时,系数会起到举足轻重的作用,这是渐近分析的局限性。此外,在某些情况下,要准确分析一种算法并给出其时间代价函数,需要
多个参数。
上(下)限不等同最差(佳)情况,对于某些算法,问题
规模相同,如果
输入数据不同,其时间代价也不同,从而有最差情况(
worst case)、最佳情况(
best case)和平均情况(
average case)。
关于控制语句的开销:while循环的分析方法与for循环类似;if语句的最差情况时间代价是then和else语句中时间代价较大的那一个;switch语句的最差情况时间代价是所有分支中开销最大的那一个(子程序调用时,加上执行子程序的时间即可)。有少数情况,执行if或switch语句中某一个分支的概率是输入规模的函数。对这类问题进行分析时,不能简单地将其理解成开销较大分支的时间代价(均摊分析法,
amortized analysis)。
那些并非真正数据的附加信息称为结构性开销(
overhead)。理论上,这种结构性开销应该尽量小,而访问路径又应该尽可能多且有效,这些相互矛盾的目标之间的权衡是算法设计的一个重要原则,即空间/时间权衡原则(
space/time tradeoff)。
代码调整法(
code tuning),程序中大多数语句对于程序的运行时间并没有太大的影响,通常只是几个关键的子程序,甚至是这些子程序中的几行关键命令,占用了绝大部分的运行时间。对那种只占总时间1%的子程序进行调整,并没有很大意义,而应该把精力集中在那些关键部分上。调正代码是,注意收集好时间统计数据,许多编译器和操作系统中都带有剖视工具(profiler)和其它特殊工具,可以得到有关时间、空间代价情况的数据。特别地,不要玩弄小技巧,使程序的可读性降低。最后,先调整算法,后调整代码。
第3章 线性表、栈和队列
线性表(
List)是由称为元素(
element)的数据项组成的一种有限且有序的序列。这个定义中的有序指线性表中的每个元素都有自己的位置,每个元素有一种数据类型(相同或不同)。线性表中不包含任何元素时,被称为空表(empty list);当前存储的元素数目称为线性表的长度(length);线性表的开始结点称为表头(head),结尾结点称为表尾(tail);为了定义当前位置的概念,假设线性表由两个分离部分(partition)组成,这两部分被一个栅栏(
fence)分开,且与当前位置相对应。一个线性表应该:
- 在长度上能够增长和缩短;
- 在任何地方插入或移除元素;
- 可以获得元素的值、读出或改变该值;
- 能够生成和清除(或重新初始化)线性表;
- 由当前结点找到它的前驱和后继元素。
线性表的实现有两种标准方法——
顺序表(
array-based list或
sequential list)和
链表(
linked list)。
顺序表的优点是对于表中的每一个元素没有浪费空间,缺点是大小事先固定;链表的优点是只有实际在链表中的对象需要空间,只要有可分配的内存空间,链表中的元素的个数就没有限制。链表的空间需求为
θ(n),而顺序表的空间需求是
Ω(n),而且还可能更多。对于安危置随机访问的操作,使用顺序表快一些,需要的时间一般为θ(1);而单链表所需的平均时间和最差时间均为θ(n)。给出链表中合适位置的指针后,insert和remove函数所需的时间仅为θ(1);而顺序表必须在数组内将其余的元素向前或向后移动,则所需的平均时间和最差时间均为θ(n)。对许多应用而言,插入和删除是最主要的操作,因此它们的时间效率是举足轻重的,就此而言,链表往往比顺序表好。
一般来说,当线性表元素数目变化较大或未知时,最好使用链表实现;而如果事先知道线性表的大致长度,使用顺序表的空间效率会更高。
在链表的实现中,线性表结点的建立和删除使得编写Link类的程序员能够提供简单有效的内存管理例程,以代替系统级的存储分配和回收操作符。Link类能管理自己的
可利用空间表(
freelist,被一个静态数据成员访问),以取代反复调用的new和delete。可利用空间表存放当前那些不用的线性表结点,从一个链表上删除的结点可放到可利用空间表的首端;当需要把一个新元素增加到链表上时,先检查可利用空间表,看是否有可用的线性表结点,如果有空结点,则从可利用空间表中取走一个结点。只有当可利用空间表为空时,才调用标准操作符new。
线性表的用户可选择存储元素的副本或指向元素的指针。
双链表(
doubly linked list)存储了两个指针:一个指向它的后继结点,另一个指向它的前驱结点。故其可以从一个表结点出发,在线性表中随意访问它的前驱结点和后继结点。双链表与单链表相比唯一的缺点就是使用更多的空间。有一种基于异或(XOR)函数性质的方法,可用来消除额外的空间需求,只是它会使实现变得复杂且速度稍微减慢。这种方法是一个使用空间/时间权衡的例子。
一个简单数据库的接口,且称之为字典(dictionary)。将字典定义成一个ADT,提供在数据库中存储、查找和删除记录的功能。关键码(
key)描述要查找的内容,用于在数据库中搜索一条给定记录。为了能实现检索功能,还要求该关键码是可比的(comparable),即至少能取两个关键码,并能正确地确定它们是否相等。
栈(
stack)是限定仅在一端进行插入或删除操作的线性表。栈的可访问元素称为
栈顶(
top)元素,元素插入栈称为
入栈(
push),删除元素称为
出栈(
pop)。栈被称为
LIFO(
Last In First Out)线性表。栈的实现方法多种多样,其中有:顺序栈(array-based stack)和链式栈(linked stack)。
关于顺序栈的实现方法,top可定义为栈中的第一个空闲位置,则空栈的top为0;top也可以定义为栈中最上面那个元素的位置,则空栈(初始化)的top为-1。将顺序栈的实现方法稍作修改即可支持变长元素(如字符串)。push操作把一个需要i个存储单元的元素存储到从top的当前值开始的i个位置中,然后在top+i的位置存放数值i,并把top的值置为top+i+1;pop操作需要先查看存储在top-1位置的长度值,然后弹出适当数目的存储单元。
当需要实现多个栈时,可利用顺序栈单向延伸的特性,即使用一个数组来存储两个栈,每个栈从各自的端点向中间延伸。但只有当两个栈的空间需求有相反关系才奏效,即一个栈增长时另一个栈缩短(从一个栈中取出元素放入另一个栈,这种方法非常有效)。
实现顺序栈和链式栈的所有操作都只需要常数时间,而另一个比较基准是空间。初始时顺序栈必须说明一个固定长度,当栈不够满时,一些空间将浪费掉。链式栈的长度可变,但对于每个元素需要一个链接域,从而产生结构性开销。
使用栈可以模拟递归。递归方法实现起来比较容易而且清晰,但有时希望避免由递归函数调用而产生庞大的时间和空间代价。在某些情况下,递归可以用
迭代来代替。然而,并不总能用迭代来代替递归,当实现有多个分支的算法时,就很难用迭代来代替递归,而必须使用递归或与递归等价的算法。
度娘:
iterative是反复的意思,所以有时候,
迭代也会指循环执行、反复执行的意思。利用迭代算法解决问题,需要做好以下三个方面的工作:
- 确定变量:在可以用迭代算法解决的问题中,至少存在一个直接或间接地不断由旧值递推出新值的变量,这个变量就是迭代变量;
- 建立关系式:所谓迭代关系式,指如何从变量的前一个值推出其下一个值的公式(或关系),迭代关系式的建立是解决迭代问题的关键,通常可以使用递推或倒推的方法来完成;
- 过程控制:在什么时候结束迭代过程?这是编写迭代程序必须考虑的问题。不能让迭代过程无休止地重复执行下去。迭代过程的控制通常可分为两种情况:一种是所需的迭代次数是个确定的值,可以计算出来;另一种是所需的迭代次数无法确定。对于前一种情况,可以构建一个固定次数的循环来实现对迭代过程的控制;对于后一种情况,需要进一步分析出用来结束迭代过程的条件。
中序表达式转后序表达式
卡塔兰数
队列(
queue)也是一种受限的线性表,其元素只能从队尾插入(
enqueue)以及从队首删除(
dequeue)。队列别称为
FIFO(
First In First Out)线性表。
假设有一个含有n个元素的顺序队列(array-base queue),需要区分满队列和空队列的状态。如果固定front的值,则rear应该有n+1种不同的取值来区分n+1中状态。但实际上rear只有n种可能的取值,除非为空队列发明一种特殊情形。鸽笼原理:给定n个鸽笼和n+1只鸽子,当所有的鸽子都进入笼里时,可确信至少一个鸽笼中的鸽子数大于1.故使用front和rear的相对值,n+1种状态必定有两种不能区分。一种方法是记录队列中元素的个数,或至少用一个布尔变量来指示队列是否为空;另一种方法是设置数组的大小为n+1,但只存储n个元素。
链式队列(linked queue)的实现只是对链表的实现做了简单的修改。若实现方法没有表头结点,需在队列函数中单独处理插入到空队列的特殊情形,以及dequeue函数导致的空队列的特殊情形。从本质上将,enqueue只是简单地把新元素放到链表尾部(rear指向的结点),然后修改rear指针指向新的链表结点;dequeue指示简单地去掉表中最前面一个结点并修改front指针。
实现顺序队列和链式队列的所有成员函数都需要常数时间,空间比较问题与栈实现类似。只是,顺序队列不想顺序栈那样,不能在一个数组中存储两个队列,除非总有数据项从一个队列转入另一个队列。在顺序队列中存储变长记录的方式与顺序栈中的方式相同。
双端队列(double-ended queue, deque, not dequeue)。
第5章 二叉树
一棵
二叉树(
binary tree)由结点(
node)的有限集合组成,这个集合或者为空(
empty),或者由一个根结点(
root)以及两棵不相交的二叉树组成,这两棵二叉树分别称为这个根的左子树(
left subtree)和右子树(
right subtree)。这两棵子树的根称为此二叉树根结点的子结点(
children);从一个结点到其两个子结点都有边(
edge)相连,这个结点称为其子结点的父结点(
parent)。
如果一棵树有一串结点n1,n2,...,nk,且ni是ni+1的父结点(1<=ipath),其长度为
k-1。有一条路径从结点R至结点M,则R称为M的祖先(
ancestor),M称为R的子孙(
descendant)。
结点M的深度(
depth)就是从根结点到M的
路径的长度。任何
深度为d的结点的层数(
level)都为d,其中根结点的层数为0,深度也为0。树的高度(
height)等于最深结点的
深度加1。
没有非空子树的结点称为叶结点(
leaf);至少有一个非空子树的结点称为分支结点或内部结点(
internal node)。
满二叉树(
full binary tree)的每一个结点或者是一个分支结点,并恰好有两个非空子结点,或者是叶节点。
完全二叉树(
complete binary tree)有严格的形状要求:从根节点起每一层从左到右填充。一棵高度为d的完全二叉树除了d-1层,每一层都是满的,即底层叶结点集中在左边的若干位置上。
满二叉树定理:非空满二叉树的
叶结点等于其
分支结点数加1。
推理:一棵非空二叉树
空子树的数目等于其
结点数加1。
某些二叉树的实现只用叶结点存储数据,而用分支结点存储结构信息。更一般地说,二叉树的实现需要用一定空间来存储分支结点,其空间大小可能与存储叶结点的空间大小不同。因此,为了分析这种实现方式的空间代价,知道一棵具有n个分支结点的二叉树的叶结点在全部结点中可能的最小与最大比例是十分有用的。
最常见的结点实现方法包含一个数据区和两个指向子结点的指针。在利用指针实现的二叉树中,叶结点与分支结点是否使用相同的类定义十分重要,某些应用只需要叶结点存储数据,某些要求分支结点与叶结点存储不同类型的数据。区别叶结点和分支结点的一种方法是使用C++联合结构。然而,若叶结点子类型与分支结点子类型的长度差别太大,会使联合结构变得低效。C++通过类继承提供了更好的区分叶结点和分支结点的解决方案,用基类来声明一般意义上的结点,而子类用来定义分支和叶结点。
按照一定的顺序访问二叉树的结点,称为一次周游或遍历(
traversal),对每个结点都进行一次访问并将其列出,称为二叉树结点的枚举(
enumeration)。先访问结点,后访问其子结点,这种访问方法称为前序周游(
preorder traversal);,后序周游(
postorder traversal)先访问结点的子结点(包括其子树),再访问该结点(比如,如果要求释放树中所有结点占用的存储空间,在删除结点前应该先删除该结点的子结点,然后再删除结点本身,这要求子结点的子结点先被删除,依此类推);中序周游(
inorder traversal)先访问左子结点(包括其子树),然后访问该结点,最后访问右子结点(包括其子树),二叉搜索树就使用这种周游方法。周游可以很自然地使用递归函数来实现。
一种实现遍历的方法不要求结点类明确支持traverse函数,这种方法很容易给树类添加新的周游方法。另一种方法要求任何需要对树进行周游的新操作都要在结点的子类中实现,这使得traverse函数不必了解结点子类独特的功能细节,因为子类自己负责周游的处理工作。而且traverse函数不需要明确地枚举所有不同的结点子类,如果有很多子类,其方便之处就会很明显;其缺点是周游操作一定不能用NULL指针来调用。这种方法有时被称为复合(
Composite)设计模式。
一般情况下,若traverse是树类的一个成员函数,且结点子类对树类用户透明,优先选择第一种方法;若有意把结点与树相互分开,不让树的用户知道结点的存在,应优先考虑第二种,因为隐藏结点内部的行为变得更为重要。
一般情况下,二叉树的结构性开销取决于指针所占空间与数据域所占空间的相对比值;而如果只是叶结点存储数据,则结构性开销在全部开销中所占的比例取决于二叉树是否“满”。对于只在叶结点存储数据的满二叉树来说,较好的实现方法是分支结点只存储两个指针,没有数据区;叶结点只包含一个数据区。这种方法算出来的结构性开销比例增高,但需要的存储空间总量减少了。然而,如果分别实现分支结点与叶结点,则需要一种方法来区别两种结点类型。
完全二叉树有其实际的用途,例如堆数据结构,被用来实现优先队列和外排序算法。n个结点的完全二叉树只可能有一种形状,假设逐层而下、从左到右,其结点的位置可完全由序号确定。因此,
数组可以有效地存储完全二叉树的结构,把每一个数据存放在其结点对应序号的位置上,这意味着
不存在结构性开销。
叉查二找树(
Binary Search Tree,
BST),或称二叉检索树、二叉排序树。二叉查找树的任何一个结点,设其值为K,则该结点左子树中任意一个结点的值都小于K,该结点右子树中任意一个结点的值都大于或等于K。
BST的形状取决与各个元素被插入二叉树的先后顺序,一个新的元素作为一个新的叶结点被添加到二叉树中,有可能增加树的深度。通常情况下BST的高度越小越好。查找、插入和删除的时间代价均取决于对应结点的深度,最差的情况等于树的深度,故尽量保持二叉查找树的平衡。平衡二叉树每次操作的平均时间代价为θ(logn),而严重不平衡的BST在最差情况下平均每次操作的时间代价为θ(n)。二叉树平衡的时候其实现简单且效率高,但它为非平衡状态的可能性很大。许多技巧可组织二叉树并使其形态良好,如AVL树和伸展树(splay tree),某些检索树肯定是平衡的,如2-3树。不论树的形状如何,周游二叉树的时间代价为θ(n)。
堆(heap)由两条性质来定义:首先,它是一棵
完全二叉树;其次,堆中存储的数据是局部有序的。
最大值堆(
max-heap)任意一个结点的值都大于或等于其任意一个子结点的值,故其根结点存储这该树所有结点中的最大值。
最小值堆(
min-heap)每一个结点存储的值都小于或等于其子结点的值,故根节点存储了该树所有结点的最小值。
一种源于归纳法的较好建堆算法:假设根的左右子树都已经是堆,且根元素为R。若:
- R的值大于或等于其两个子结点,则堆结构已经完成;
- R的值小于某一个或全部两个子结点的值,此时R应与两个子结点中值较大的一个交换;若R仍小于其新子结点的一个或两个,则继续将R“拉下来”,直至到达某一层使其大于它的子结点或成为叶结点(这个过程用堆类的私有成员函数siftdown实现)。
这种方法假设子树已经是堆,则可以在访问结点本身之前先访问其子结点。实际上,建堆过程不必访问叶结点,所以可从
最后一个结点的父结点开始(下划线部分为Hunger的观点)。在最坏情况下该算法的复杂度为θ(n)。最大值堆在查找一个任意值时效率不高,它只适合于查找最大值。
Huffman编码树(
Huffman coding tree),简称Huffman树,是一棵满二叉树,其每个叶结点对应一个字母,叶结点的权重为对应字母的出现频率。Huffman树具有最小外部路径权重(
minimum external path weight),即对于给定的叶结点集合,具有最小加权路径长度。一个叶结点的加权路径长度(
weighted path length)定义为权乘以深度。
建立n个结点的Huffman树的过程很简单。首先,创建n棵初始的Huffman树,每课树只包含单一的叶结点,这n棵树按照权值(如频率)大小顺序排列;接着,拿走前两棵树(即权值最小的两棵),把它们标记为一棵新的Huffman树的一个分支结点的两个叶子结点,分支结点的权值为两个叶结点权值之和,新书放回序列中适当的位置。重复上述步骤,直至序列中只剩下一个元素,则Huffman建立完毕。
引理:一棵至少包含两个结点的Huffman树,会把字母使用频率最小的两个字母作为兄弟结点存储,其深度不比树中其它任何叶结点小。
定理:对于一组给定的字母,函数buildHuff实现了
最小外部路径权重。
一旦Huffman树构造完成,可把每个字母用代码标记。从根结点开始,分别把“0”或“1”标于树的每条边上,“0”对应于连接左子结点的那条边,“1”则对应于连接右子结点的边。字母的Huffman编码就是从根结点到对应于该字母叶结点路径的二进制代码。对信息代码反编码的过程为:从左到右逐位判别代码串,直至确定一个字母。如果一组代码中的任何一个代码都不是另一个代码的前缀,则称这组代码符合
前缀特性(
prefix property),这种前缀特性保证了代码串被反编码时不会有多种可能。
第6章 树
一棵
树(
tree)T是由一个或一个以上结点组成的有限集,其中一个特定的结点R称为T的根结点,其余结点可被划分为不相交的子集,每个子集都是树(被称为T的子树),且其相应根结点均为R的子结点。子树从左到右排列,其中T0被称为R的最左子结点。结点的出度(
out degree)定义为该结点的
子结点数目。
森林(
forest)定义为一棵或多棵树的集合。
对于树(
通用树,
general tree),因为不能预先知道其某个结点有多少个,所以无法给出直接访问每个子结点的函数。一种方法是提供一个函数,它有一个参数,指定了子结点的序号(这种方法偏向于采用数组来存储所有结点,而实际上通常基于链表来实现树)。另一种方法是提供对结点第一个(最左)子结点以及下一个(右相邻)兄弟结点的访问。
树的中根周游对树不具有自然的定义,因为树内部的结点不具有特定数目的子结点,一般不使用树的中根周游。
实现树的最简单方法就是对每个结点只保存一个指针域指向其父结点,这种实现称为父指针(
parent pointer)表示法。父指针表示法常常用来维护一些不相交子集构成的集合,包含两种基本操作:
判断两个结点是否在同一集合;
归并两个集合。
查找一个给定结点的根结点的过程称为FIND,两个集合被合并的过程常常称为UNION,整个操作以“
UNION/FIND算法(或并查算法)”命名。UNION/FIND算法用一棵树代表一个集合,若两个结点在同一棵树中,则认为它们在同一集合中。
考虑把一个集合中的元素分配到称为等价类(
equivalence class)的不相交子集中的问题,问题是要快速地将结点集合划分为不相交集或连通分支。若某两个结点间存在一条通路,则认为这两个结点是等价的。等价边(即相连的边)集合的子集称为连通分支(
connected component)。
UNION/FIND算法可以很容易地解决等价类问题。开始时,每个元素都在独立的只包含一个结点的树中,而它自己就是根结点。通过使用函数differ可以检查一个等价对中的两个元素是否在同一棵树中。若是,则它们在同一等价类中,不需要做变动;否则两个等价类可以用UNION函数归并。把两个等价类归并到一起时,使树的高度尽量小,在理想情况下,每棵树的结点应该直接指向根结点。一个简单的技术,称为重量权衡合并规则(
weighted union rule),把结点较少的一棵树与结点较多的一棵树归并时,将结点较少树的根结点指向结点较多树的根结点,因合并后树中最深结点的深度最多只比合并前的最大深度大1,可把树的整体深度限制在O(logn)。结点较少树中所有结点深度均增加1,且合并后树的结点至少是结点较少树的两倍,故当处理完n个等价对后,任何结点的深度最多只会增加logn次。
路径压缩(
path compression)是一种可以产生极浅的树的方法。设根结点为R,路径压缩把由X到R的路径上每个结点的父指针均设置为直接指向R。函数FIND是一种递归算法,不仅返回当前结点的根结点,而且把当前结点所有祖先结点的父指针都指向根结点。路径压缩使FIND的代价(非常)接近于常数,对n个结点进行n次FIND操作的代价(用重量权衡合并规则来归并集合,路径压缩是在FIND操作中进行,而不是归并操作)为θ(nlog*n)。表达式log*n与阿克曼(Ackeman)函数的逆函数密切相关,详情可查看Robert E.Tarjan的论文
On the efficiency of a good but not linear set merging algorithms。
子结点表(
list of children)表示法中每个分支结点均存储其
子结点按从左到右顺序形成的一个链表。子结点表示法在数组中存储树的结点,每个结点包括结点值、一个父指针以及一个指向子结点链表的指针,链表中子结点的顺序从左到右。每个链表表项(
element)均包含指向一个子结点的指针。如果两棵树分别存储在不同数组中,归并会十分困难;如果存储在同一结点数组中,添加树T成为结点R的子树,只需要将T的根结点添加到R的子结点表中即可。
左子结点/右兄弟结点表示法,每个结点都存储结点的值,以及指向父结点、最左子结点和右侧兄弟结点的指针,若两棵树存储在同一
数组,归并时把其中一棵添加为另一颗树的子树只需简单设置三个指针值即可。这种表示法比子结点表表示法的
空间效率更高,且结点数组中每个结点只需
固定大小的存储空间。
动态左子结点/右兄弟结点表示法,本质上,这种实现用二叉树来替换树,左子结点是树中结点的最左子结点,右子结点是结点的右侧兄弟结点。这种表示法还可以推广到森林,因为森林中每棵树的根结点可以看成互为兄弟结点。
动态结点表示法:一种方法是对任意一个结点可能有的子结点数加以限制,且对每个结点分配确定数目的指针域;另一种实现方法是为每个结点分配可变的存储空间。第二种实现其一是将一个指向子结点的指针数组作为结点的一部分,分配给结点,当子结点数目不变时(不是说每个子结点一样),这种方法效果最佳;另一种是每个结点存储一条子结点指针链表,这种方法更灵活,但需要更多空间。
K叉树(
K-ary tree)的结点有K个子结点,二叉树就是2-ary树。当K变大时,空指针的潜在数目会增加,且叶结点与分支结点在所需空间大小的差异性上也会更显著。满K叉树和完全K叉树与满二叉树和完全二叉树是类似的,二叉树的许多性质可以推广到K叉树。
顺序树表示法(
sequential tree implementation)的目的在于存储一系列结点值,且包含了尽可能少但对于重建树结构必不可少的信息。这种方法的优点是节省空间,因为无须存储指针;缺点是对任何结点的存取,都必须顺序查找所有在结点表中排在它前面的结点。由于顺序树表示法节省空间,所以是一种把树压缩在磁盘上以备以后使用的理想方法,且树结构在处理需要时可以重建。
树的顺序表示法还能用来序列化(
serialize)树结构,即把一个对象以一系列字节形式存储,以便这种数据结构在计算机间传输,这对于分布式处理环境中的数据结构很重要。树的顺序表示法把结点值按照它们在先根周游中出现的顺序存储起来,同时把描述树形状的充足信息也存储起来。如果树具有受限的形状(如满二叉树),则需要存储的有关结构的信息可以少一些;反之,越灵活的结构需要越多的附加信息来描述。
B树(多路搜索树)
B树的研究通常归功于R.Bayer和E.McCreight,他们在1972年的论文中描述了B树。一个m阶B树(balanced tree of order m)定义为有以下特性:
- 根或者是一个叶结点,或者至少有两个子女;
- 除了根结点以外,每个内部结点有m/2(向上取整)到m个子女;
- 所有叶结点在树结构的同一层,因此树结构总是树高平衡的。
2-3树是一个三阶B树。B树插入是2-3树插入的推广:第一步是找到应当包含待插入关键码的叶结点,并检查是否有空间;如果该结点中有地方,则插入关键码,否则把结点分裂成两个结点,并把中间的关键码提升到父结点;如果父结点也满了,就再分裂父结点,并再次提升中间的关键码。插入过程保证所有结点至少半满。
B+树
B+树只在叶结点存储记录,内部结点存储关键码值,用于引导检索,并把每个关键码与一个指向子女结点的指针关联起来。B+树的叶结点一般链接起来,形成一个双链表,这样通过访问链表中的所有叶结点,就可以按排序的次序遍历全部记录。
AVL树(平衡二叉树)
树中的每个结点,其左右子树的高度最多差1。 旋转操作。
第7章 内排序
如果一种排序算法不改变
关键码值相同的记录的
相对顺序,则称其为稳定的(
stable)。分析排序算法时,传统方法是衡量关键码之间进行
比较的次数,这种方法通常与算法消耗的时间紧密相关;而有时候记录很大,其移动称为影响程序整个运行时间的重要因素,这时应统计算法中的
交换次数。
插入排序
template
void inssort(Elem A[], int n){
for(int i=1; i0)&&(Comp::lt(A[j],A[j-1])); j--)
swap(A,j,j-1);
}
考虑比较次数,最差为θ(n^2),最佳为θ(n),平均的代价为最差的一半,仍为θ(n^2)。平均情况下,数组的前i-1个记录中有一半关键码值比第i个记录的关键码值大。
考虑交换次数,每执行一次内层for循环就要比较一次并交换一次(除了每一轮的最后一次没有进行交换),故总交换次数是总比较次数减去n-1,则在最佳情况下为0,在最差及平均情况下为θ(n^2)。
起泡排序
template
void bubsort(Elem A[], int n){
for(int i=0; ii; j--)
if(Comp::lt(A[j],A[j-1]))
swap(A,j,j-1);
}
考虑比较情况,起泡排序的比较次数十分简单,不需要考虑数组中结点的组合情况,其最佳、平均、最差情况均为θ(n^2)。
假定平均情况下,一个结点比它前一个结点关键码值小的概率为比较次数的一半,故起泡排序的平均交换代价为θ(n^2),事实上起泡排序的交换次数与插入排序相同。
冒泡排序及两种优化方式
选择排序
template
void selsort(Elem A[], int n){
for(int i=0; ii; j--)
if(Comp::lt(A[j],A[lowindex]))
lowindex = j;
swap(A,i,lowindex);
}
}
选择排序的第i次选择数组中第i小的记录,并将其放到数组的第i个位置。为了寻找下一个最小关键码值,需要检索数组整个未排序的部分,但只交换一次即可将待排序的记录放到正确位置,故需要的总交换次数是n-1(最后一个记录无须比较和交换),即θ(n)。
选择排序实质上就是起泡排序(减少了交换次数,最佳情况除外),故比较次数为θ(n^2)。
有一种方法可降低各种排序算法用于交换记录的时间:使数组中的每个元素存储指向该元素记录的指针而不是记录本身,则交换操作只互换指针,虽然需要一些空间存放指针,但效率得到提高。以上三种排序算法之所以慢,瓶颈在于只比较
相邻的元素,则比较和移动只能一步步地进行(选择排序的交换除外),交换相邻记录叫做一次交换(
exchange),故这三种排序被称为交换排序(
exchange sort)。任何一种将比较限制在相邻两元素之间进行的交换算法的平均时间代价均为θ(n^2)。
Shell排序
template
void inssort2(Elem A[], int n, int incr){ //参数n是数组大小!
for(int i=incr; i=incr)&&(Comp::lt(A[j],A[j-incr])); j-=incr)
swap(A,j,j-incr);
}
template
void shellsort(Elem A[], int n){
for(int i=n/2; i>=2; i/=2)
for(int j=0; j(&A[j],n-j,i);
inssort2(A,n,1);
}
也称
缩小增量排序(
diminishing increment sort),利用了插入排序的最佳时间特性,试图将待排序序列变成基本有序的(mostly sorted),然后再用插入排序来完成最后的排序工作。Shell排序将序列分成子序列,然后分别对子序列进行排序,最后将子序列组合起来。分析Shell排序是很困难地,因此必须不加证明地承认Shell排序的平均运行时间是θ(n^1.5)(对于选择“增量每次除以3”递减而言),选择其它增量序列可减少这个上界。
快速排序(
Quicksort)
template
void qosrt(Elem A[], int i, int j){ //第一次调用qsort(array,0,n-1);
if(j <= i)return;
int pivotindex = findpivot(A,i,j);
swap(A,pivotindex,j);
int k = partition(A,i-1,j,A[j]);
swap(A,k,j);
qsort(A,i,k-1);
qsort(A,k+1,j);
}
template
int partition(Elem A[], int l, int r, Elem& pivot){
do{
while(Comp::lt(A[++l],pivot));
while((r!=0)&&Comp::gt(A[--r,pivot]));
swap(A,l,r);
}while(l < r);
swap(A,l,r);
return l;
}
template
int findpivot(Elem A[], int i, int j){
return (i+j)/2; //A simple function.
}
//no swap
void Qsort(int a[], int low, int high)
{
if(low >= high)
{
return;
}
int first = low;
int last = high;
int key = a[first];/*用字表的第一个记录作为枢轴*/
while(first < last)
{
while(first < last && a[last] >= key)
{
--last;
}
a[first] = a[last];/*将比第一个小的移到低端*/
while(first < last && a[first] <= key)
{
++first;
}
a[last] = a[first];
/*将比第一个大的移到高端*/
}
a[first] = key;/*枢轴记录到位*/
Qsort(a, low, first-1);
Qsort(a, first+1, high);
}
快速排序首先选择一个轴值(
pivot),假设输入的数组中有k个小于轴值的结点,则这些结点被放在数组最左边的k个位置上,而大于轴值的结点被放在数组最右边的n-k个位置,这称为数组的一个分割(
partition)。在给定分割中的值不必被排序,只要求所有结点都放到了正确的分组位置,而轴值的位置就是下标k。快速排序再对轴值的左右子数组分别进行类似的操作,其中一个子数组有k个元素,而另一个有n-k-1个元素。
关于函数partition,执行while循环时,左右边界下标都先移动,再与轴值进行比较。这是为了保证每个while循环都有所进展,即使当最后一次do循环中两个被交换的值都等于轴值时也同样处理。另外在第二个while循环中r保持正值,保证了当轴值为该子数组的最小值(分割出来的左半部分的长度为0),r不至于超出数组的下界(下溢出)。函数返回右半部的第一个下标的值,可用于确定递归调用qsort的子数组的边界。
关于算法代价,函数findpivot使用的是常数时间,而整个partition函数的总时间代价为θ(s)(s为子数组长度)。最差情况出现在轴值未能很好分割数组的时候,此时分治策略未能很好地完成分割任务,下一次处理的子问题只比原始问题的规模小1,若这种情况发生在每一次分割过程中,则算法总时间代价为θ(n^2)。最佳情况出现在每个轴值都将数组分成相等的两部分时,此时整个算法的时间代价为θ(nlogn)。平均情况可设想,每一次分割,轴值处于最终排好序的数组中的位置的概率是一样的,即轴值可能将数组分成长度为0和n-1,1和n-2,......,等等,其概率相等。在这种情况下,时间代价可推算为T(n) = c*n + (1/n)*sum(k=0~n-1)[T(k)+T(n-1-k)],T(0)=T(1)=c。这是一个递归公式,c*n是findpivot和partition函数所用的时间,公式所推算出来的时间代价为(nlogn)。
快速排序算法的运行时间是可以改进的(通常改变常数因子),最明显的可改进之处与函数findpivot有关,因为快速排序的最差情况发生在轴值不能将数组分成长度相等的子数组的时候。一种较好的方法是“三者取中法”,即取三个随机值的中间一个,用随机数字生成器选择位置耗时较多,因此比较普遍的方法是查看当前子数组中第一个、中间一个及最后一个位置的数值。而事实上当n很小时,快速排序是很慢的,一个简单的改进是用能较快处理较小数组的方法来代替快速排序。比如当快速排序的子数组小于某个长度时,什么也不要做,此时子数组中的数值是无序的,整个数组基本有序。这样的待排序数组正适合于使用插入排序,最好的组合方式是当n(子数组的长度)减小至9或更小值就选择使用插入排序。还可以使用栈来模拟递归调用的方法来优化。
BST排序
归并排序(
Mergesort)
List mergesort(List inlist){
if(length(inlist)<=1) return inlist;
List l1 = half of the items from inlist;
List l2 = other half of the items from inlist;
return merge(mergesort(l1), mergesort(l2));
}
template
void mergesort(Elem A[], Elem temp[], int left, int right){
if((right-left)<=THERSHOLD){
inssort(&A[left],right-left+1);
return;
}
int i, j, k, mid=(left+right)/2;
if(left==right) return;
mergesort(A, temp, left, mid);
mergesort(A, temp, mid+1, right);
//Do the merge operation. First, copy 2 halves to temp.
for(i=mid; i>=left; i--) temp[i] = A[i];
for(j=1; j<=right-mid; j++) temp[right-j+1] = A[j+mid];
//Merge sublists back to A
for(i=left, j=right, k=left; k<=right; k++)
if(temp[i]
归并排序将一个序列分成两个长度相等的子序列,为每一个子序列排序,然后将其合并成一个序列。合并两个有序子序列的过程称为归并(merging)。归并排序的运行时间并不依赖于输入数组中元素的组合方式,如此避免了快速排序中的最差情况,但在某些特殊数组中,在平均情况下,归并排序不一定更快(由于常数因子影响)。
R.Sedgewick发明了一个十分巧妙的优化归并排序方法,它在复制时将第二个子数组中元素的顺序颠倒了一下,两子数组从两端开始向中间推进,互相称为“监视哨”,从而不用检查子序列被处理完的情况。此外,这里用了插入排序来处理较短的子数组。
归并算法是一个递归程序,当被排序元素的数目为n时,递归的深度为logn,每一层需要θ(n)的时间代价,因此总的时间代价为θ(nlogn),这也是归并排序最佳、平均、最差的运行时间。
堆排序
template
void heapsort(Elem A[], int n){
Elem mval;
maxheapH(A, n, n);
for(int i=0; i
关于堆排序,建堆要用θ(n)的时间,且n次取堆的最大元素要用θ(logn)的时间,因此整个时间代价为θ(nlogn),这是堆排序的最佳、平均、最差时间代价。尽管典型情况下比快速排序在常数因子上慢,但若希望找到数组中第k大的元素,可以用θ(n+klogn)的时间,如果k很小,则它的速度比之前的方法都快得多。
分配排序(
Binsort)
for(int i=0; i
void binsort(Elem A[], int n){
List B[MaxKeyValue];
Elem item;
for(i=0; i
该分配排序算法可以对关键码处于0到MaxKeyValue-1之间的序列进行排序。在分配排序中,必须检查每个盒子以确认里面是否有记录,故时间代价为θ(n+MaxKeyValue)。若MaxKeyValue比n大,且相差悬殊,算法会变差。另外,大的关键码值范围需要较大的数组B来存储,所以扩展的分配排序也只适用于有限的关键码范围。
桶排序,时间复杂度为O(n)。
基数排序(
Radix Sort)
template
void radix(Elem A[], Elem B[], int n, int k, int r, int cnt){
//cnt[i] stores number of records in bin[i]
int j;
for(int i=0, rtok=1; i=0; j--) B[--cnt[(A[j]/rtok)%r]] = A[j];
for(j=0; j
对于n个数据的序列,假设基数为r,这个算法需要k趟分配工作,每趟分配的时间为θ(n+r),故总时间代价为θ(nk+rk)。r是基数,比较小,可以用2或10作为基数,对于字符串的排序,采用26作为基数比较好(因为有26个英文字母)。考察算法的渐近复杂行时,可以把r看成一个常数值。变量k与关键码长度有关,它是以r为基数时关键码可能具有的最大位数。假设N代表n个记录所使用的不同关键码值的数目,则N<=n,可知k>=logrN。若没有重复关键码,则n个互不相同的关键码(n=N),需要n个不同的编码来表示,因此k>=logrn,此时要对n个不同的关键码值进行基数排序要耗用Ω(nlogn)的(最佳)时间代价。
枚举排序
对各种排序算法的实验比较
- 时间复杂度为O(n^2)的排序对大数组的性能很差,除了数组为逆序的情况外,插入排序在这一组中是最好的;
- Shell排序在数组规模到达1000的时候明显好于任何一个复杂度为O(n^2)的排序算法;
- 改进的快速排序明显是所有算法中最出色的,甚至对较小的数组而言,改进的快速排序由于在调用插入排序之前做了一次分割,所以表现依然很好;
- 与其它复杂都为O(nlogn)的算法相比,堆排序相当慢;
- 基数排序的表现差得让人吃惊,如果代码改成用关键码值的位偏移,则可能从本质上得到改善,但会严重地限制算法所支持的元素类型的范围。
排序问题的下限
一个问题的上线可以定义为已知算法中速度最快的渐近时间代价;其下限是解决这个问题所有算法的最佳可能效率,包括那些尚未设计出来的算法。一旦问题的上限与下限相同,可知从渐近分析的意义上说,不可能有更有效的算法。
一种估计问题下限的简答方法是计算必须读入的输入长度及必须写出的输出长度。任何算法的时间代价当然都不可能小于它的I/O时间,于是没有任何排序算法能够将时间降到Ω(n)以下,因为算法至少要花n步来读入n个待排序的数据,输出排序后的n个结果。则可以说排序的时间在Ω(n)到O(nlogn)之间。
对于任何一种
基于比较的排序算法,最差情况的时间代价为Ω(nlogn),已知所有排序算法都需要O(nlogn)的运行时间,故排序问题需要θ(nlogn)的运行时间。
排序算法稳定性
第8章 文件管理和外排序
有时候,应用程序需要存储、处理大量的数据,且不能同时把数据都放到主存中,每次只能有选择地读入其中一部分数据进行处理。一般来说,计算机存储设备分为
主存储器(
primary memory或
main memory)和
辅助存储器(
secondary storage或
peripheral storage)。主存储器通常指随机访问存储器(Random Access Memory,RAM),辅助存储器指硬盘、软盘和磁带这样的设备。
由于磁盘和主存的访问时间比率是100万比1,使得基于磁盘的应用程序需要使得
磁盘访问次数最少。一般来说,有两种方法能够使磁盘访问次数最少。第一种方法是适当安排信息位置,以尽可能少的访问次数得到所需数据,且最好第一次访问就能得到。对于在辅助存储器中存储的数据,其数据结构就称为文件结构(file structure),文件结构的组织应当使磁盘访问次数最少。另一种减少磁盘访问次数的方法是合理组织信息,使每次磁盘访问都能得到更多的数据(若准确地猜测出以后需要的数据),可减少将来的访问需要(从磁盘或磁带中读取几百个连续字节数据与读取一个字节数据所需的时间没有太大的差别)。
减少磁盘访问次数的一种办法是压缩存储在磁盘中的信息,因为与CPU的处理时间相比,从磁盘读取信息花费的时间非常多。若通过减少磁盘存储需求节省了访问时间,而同时解压缩数据增加了额外处理时间,则几乎在任何情况下,节省的时间都比增加的时间多。
磁盘通常称为直接访问(
direct access)存储设备,即访问文件中的任何一条记录都会花几乎相同的时间(实际上只是近似于直接访问);而磁带是顺序访问(
sequential access)存储设备,从开始处处理数据,指导到达需要的位置。
一块硬盘由一个或多个圆形盘片(
platter)组成,这些盘片从上到下排列,与一个中心主轴(
spindle)相连,并以恒定速率连续转动。盘片的每个可用表面都有一个读/写磁头(
read/write head),或者称为I/O磁头(
I/O head)。每个磁头都固定到一个连杆(
arm)的一端,连杆的另一端与支杆(
boom)相连,支杆可以把所有磁头一起向内或向外移动。磁头在一个盘片的某个位置上可以访问的所有数据构成了一个磁道(
track),与主轴具有相同距离的、分布在各个盘片上的所有磁道称为一个柱面(
cylinder)。每个磁道分为多个扇区(
sector),两个相邻扇区之间有扇区间间隙(
intersector gap)。扇区间间隙内不存储数据,磁头可以通过这些间隙识别扇区的结束,每个扇区中都包含相同的数据量。
从硬盘读取一个或者多个字节数据可以分成三个独立的步骤。第一步,移动I/O磁头,把它定位到包含数据的
磁道上,这个移动过程称为
寻道(
seek);第二步,磁头等待包含数据的
扇区旋转到磁头下面,等待的时间称为
旋转延迟(
rotational delay或
rotational latency);第三步,数据的实际传送(如读出或写入),读取数据花费的时间相对较少,仅仅是所有数据经过磁头下面所需的时间。磁盘的设计每次请求读取一个扇区的数据,故一个扇区就是一次读出或写入的最小数据量。
由于每个磁盘的旋转速率是固定的,计算机对每个扇区数据的处理时间也是固定的,则可知从第一个扇区已读取到I/O磁头准备读取第二个扇区的这段时间间隔中,磁盘旋转了多远。与其让第二个逻辑扇区与第一个逻辑扇区物理上相邻,倒不如让第二个逻辑扇区与第一个逻辑扇区间隔一段距离,使得I/O磁头准备读取第二个逻辑扇区时,其正好处在I/O磁头下,这种安排扇区的方法称为
交错法(
interleaving),逻辑上相邻的扇区之间的物理距离称为
交错因子(
interleaving factor)。
一般来说,最好把一个文件的所有扇区都放在一起,使占用的磁道尽可能少,这是基于以下两点假设:
- 寻道时间慢(一般是I/O操作花费最大的部分)
- 如果读出了文件的一个扇区,很可能要读出文件的下一个扇区(此假设被称为引用的局部性,locality of reference)
多个扇区通常集结成组,称为一个
簇(
cluster)。簇是文件分配的最小单位,则所有文件都是一个或几个簇的大小,簇的大小由操作系统决定,文件管理器记录每个文件是由哪些簇组成的。在MS-DOS系统中,磁盘中有一个指定的部分,称为文件分配表(
File Allocation Table),此表记录了哪些扇区属于哪个文件。而UNIX系统则不使用簇,在UNIX系统中,文件分配的最小单位和读出/写入的最小单位是一个扇区,在UNIX术语中称为一个块(
block),UNIX系统维护相关信息,这些信息称为索引结点(
i-node),记录了文件由哪些块组成。属于同一文件的一组物理上相连的簇称为一个范围(
extent)。当文件的逻辑记录长度和扇区长度不匹配时,可能会出现空闲的剩余空间,这些空间就称为内部碎片(
internal fragmentation)。
每种磁盘组织方式都要用一些磁盘空间管理扇区、簇等。必须存储在磁盘中的信息一般包括文件分配表、包含地址标识和每个扇区状态信息(是否可用)的扇区头(sector headers)和扇区间间隙。
如果只读取一个字节的数据,则与读出整个扇区的数据相比,且考虑寻道和旋转延迟的代价,时间上的减少并不显著。故每当访问磁盘,甚至只请求一个字节的数据时,几乎所有磁盘驱动器都会自动读出或写入整个扇区的数据。一旦读取了一个扇区,则将其存储在主存中,这称为缓冲(buffering)或缓存(caching)信息。如此下一次磁盘请求访问同一扇区,就不需要再从磁盘中读取。储存在一个缓冲区中的信息通常称为一页(page),缓冲区合起来称为缓冲池(buffer pool)。当缓冲池被填满,需要作出选择,放弃某些缓冲区中的信息,以便为新请求的信息提供空间。在替换缓冲池中的信息时,只能根据一些启发式方法(heuristic)作出决策,比如:
- 先进先出法(FIFO)
- 最不频繁使用法(LFU)
- 最近最少使用法(LRU)
许多操作系统都支持虚拟存储(virtual memory),程序员可以假定主存比实际存在的更多。
磁盘中存储
虚拟存储器的全部内容,根据存储器的访问需要把块读入主存缓冲池。当然,使用虚拟存储技术的程序会比不使用虚拟存储技术而把数据全部存储在主存中的程序慢。
进行外部排序的一个很好的算法源于归并算法,其最简单的形式是对记录顺序地完成一系列扫描,在每一趟扫描中,归并的子列越来越大。这些年来提出了外部排序算法的各种遍体,大多数依据同样的原理,基于下面两步:
- 把文件分成大的初始顺串(run file);
- 把所有顺串归并到一起,形成一个已排序的文件。
假设一个尽可能大的RAM分配给一个大数组,可最多存储M条记录,则可以把输入文件分成长度为M的初始顺串。置换选择(replacement selection)算法在平均情况下可
创建长度为2M条记录的顺串(假定到来的关键码值在关键码范围内平均分布),其工作方式如下
- 从磁盘中读出数据放到数组中,设置LAST = M-1;
- 建立一个最小值堆;
- 重复一下步骤,直到数组为空:
-
- 把具有最小关键码值的记录(根结点)送到输出缓冲区;
- 设R是输入缓冲区的下一跳记录,若R的关键码值大于刚刚输出的关键码值,则把R放到根结点,否则使用数组中LAST位置的记录代替根结点,然后把R放到LAST位置,设置LAST = LAST -1;
- 筛出根结点,重新排列堆。
一次归并多个顺串,可更好地利用主存空间,大大减少归并顺串所需的扫描趟数。假设存在一个B路归并,分配B个块的空间,而顺串的数目R比B大,需要进行多趟扫描。先每次归并B个顺串,然后再每次归并B个超级顺串。第一轮B路归并,平均每次处理2B^2个块大小的文件(初始顺串的平均长度为2B个块);在第K轮B路归并中,平均每次可以处理2B^k+1个块大小的文件。
总的来说,建立大的初始顺串可以把运行时间大大减少,比标准归并排序的三分之一还要多,而使用多路归并可以进一步把时间减半。一个好的外部排序算法会尽量做好以下几个方面:
- 建立尽可能大的初始顺串;
- 在所有阶段尽可能使输入、处理和输出并行;
- 使用尽可能多的工作主存,实际上,更多的主存比更快的磁盘效果显著,而对于外部排序而言,更快的CPU在运行时间方面不会有更大的改进;
- 如果可以,使用多块磁盘,以便I/O处理有更大的并行性,并且允许顺序文件处理。
第9章 检索
假定k1,k2,...,kn是互不相同的关键码值,有一个包含n条记录的集合C,形式为:(k1,I1),(k2,I2),...,(kn,In)。其中Ij是与关键码值kj相关联的信息,1<=j<=n。给定某个关键码值K,检索问题(
search problem)就是在C中定位记录(kj,Ij),使得kj = K。
检索(
searching)就是定位关键码值kj = K的记录的系统化方法。检索成功就是找到至少一个关键码值为kj的记录,使得kj = K;检索失败就是找不到记录,使得kj = K(可能不存在这样的记录)。
精确匹配查询(
exact-match query)是指检索关键码值与某个特定值匹配的记录。范围查询(
range query)是指检索关键码值在某个指定值范围内的所有记录。
检索算法可以分成三类:
- 顺序表和线性表方法
- 根据关键码值直接访问方法(散列法)
- 树索引法
对一个未排序的线性表进行顺序检索,在平均情况和最差情况下需要θ(n)时间。减少检索时间的一种方法是通过排序记录进行预处理。对于已排序的表,最常用的检索算法是二分法检索,如果对关键码值的分布不了解,则二分法检索是最好的算法。
一种经过计算的二分法检索形式称为字典检索(
dictionary search)或者插值检索(
interpolation search)。插值检索试图利用表中存储的记录的
关键码值分布的估算知识,某个关键码在关键码范围内的位置被翻译成表中相应记录的估算位置,并首先检查这个位置。接下来的检查根据新的计算做出,此过程不断继续,直到找到需要的记录,或者表缩小到没有记录剩下。当关键码值分布的估算符合关键码值分布的实际情况时,插值检索比二分法检索更有效率;如果分布估算与实际分布有显著差异,则插值检索的效率就会非常低。
一种组织线性表的方法是根据估算的访问频率排列记录,然而在许多应用程序中,无法事先知道哪条记录被访问到的频率最高。自组织线性表(self-organizing lists)根据实际的记录访问模式在线性表中修改记录顺序,使用启发式规则决定如何重新排列线性表。以下是三个传统的启发式规则:
- 为每条记录保存一个访问计数,而且一直按照这个顺序维护记录,这种方法称为计数(count)方法,类似于缓冲池替代策略中的“最不频繁使用(LFU)”法;
- 如果找到一条记录就把它放到线性表的最前面,而把其它记录后退一个位置,这种方法类似于缓冲池替代策略中的“最近最少使用(LRU)”法,称为移至前端(move-to-front)法;
- 把找到的记录与它在线性表中的前一条记录交换位置,这种启发式规则称为转置(transpose)。
Ziv-Lempel编码(Ziv-Lempel coding)在遇到字符串重复出现时,用一个指向字符串在文件第一次出现位置的指针来代替。为了加快检索一个前面已经出现的单词所需的时间,编码存储在一个自组织线性表中。
确定一个值是否为某集合的元素,这是在一组记录中检索关键码的一种特殊情况。在关键码值范围有限的情况下,可采用一种简单的技术,存储一个位数组,为每个可能的元素分配一个比特位位置。如果元素确实包含在实际集合中,就把它对应的位置设为1,否则设置为0。这种表示方法称为位向量(bit vector)或者位图(bitmap)。这种根据位向量计算集合的方法有时可用于文档检索(document retrieval)。对于每一个
关键字,文档检索系统存储一个位向量,每个文档一位;还可以为每个
文档存储一个位向量,标识在文档中出现的关键字,这种组织方法称为签名文件(signature file),通过对签名的操作可以找到带有所需关键字组合的文档。
散列方法(
Hashing)
把关键码值映射到表中的位置来访问记录,这个过程称为散列(hashing)。
把关键码值映射到位置位置的函数称为散列函数(hashing function),通常用h表示。
存放记录的数组称为散列表(hash table),用HT表示;散列表中的一个位置称为一个槽(slot)。
在一个根据散列方法组织的数据库中,找到带有关键码值K的记录包括两个过程:
- 计算表的位置h(K);
- 从槽h(K)开始,使用冲突解决策略(collision resolution policy)找到包含关键码值K的记录。
散列方法:除留余数法、平方取中法(mid-square method)、折叠方法(folding method)、ELFhash(Executable and Linking)、单旋转法
冲突解决技术可以分为两类:
- 开散列方法(open hashing),也称为单链方法(separate chaining)
- 闭散列方法(closed hashing),也称为开地址方法(open addressing)
开散列方法把冲突记录存储在表外,一种简单形式是把每个槽定义为一个链表的表头,散列到一个槽的所有记录都放到该槽的链表内。槽链接的链表中的记录可以按照多种方式排列:按插入次序排列、按关键码值次序排列或者按访问频率次序排列。
闭散列方法把所有记录直接存储在散列表中:
- 桶式散列,把散列表中的槽分成多个桶(bucket)。散列函数把每一条记录分配到某个桶的第一个槽中,如果该槽已经被占用,则顺序地沿桶查找,直到找到一个空槽。如果一个桶全部被沾满了,那么就把这条记录存储在表后面具有无限容量的溢出桶(overflow bucket)。
- 线性探查、伪随机探查
- 二次探查(quadratic probing)
- 双散列(double hashing)
在被删除记录的位置上置一个特殊标记,称为墓碑(tombstone)。墓碑标志了一条记录曾经占用过这个槽,但是现在已经不再占用,如果沿着探查序列检索时遇到墓碑,检索过程会继续下去。为了避免插入两个相同的关键码,检索过程仍然需要沿着探查序列走下去,直到找到一个真正的空位置。
单向散列函数,填装因子。
KMP算法。
第11章 图
图可以用G=(V,E)来表示,每个图都包括一个顶点集合V和一个边集合E。顶点总数记为|V|,边的总数记为|E|,|E|的取值范围是0到θ(|V|^2)。边数较少的图称为稀疏图(sparse graph),边数较多的图称为密集图(dense graph),包括所有可能边的图称为完全图(complete graph)。如果图的边限定为从一个顶点指向另一个顶点,则称这个图为有向图(directed graph或di-graph);如果图中的边没有方向性,则称之为无向图(undirected graph)。如果图中各顶点均带标号,则称之为标号图(labeled graph);边上标有权的图称为带权图(weighted graph)。
图有两种常用的表示方法,相邻矩阵(adjacency matrix)和邻接表(adjacency list)。
相邻矩阵是一个|v| x |V|数组,如果从vi到vj存在一条边,则对第i行的第j个元素进行标记。相邻矩阵的每个元素需占用一位,但如果希望用数值来标记每条边(如标记两个顶点之间的权或距离),则矩阵的每个元素必须占足够大的空间来存储这个数值。不论哪种情况,相邻矩阵的空间代价均为θ(|V|^2)。
邻接表是一个以链表为元素的数组,该数组包含|V|个元素,其中第i个元素存储的是一个指针,指向顶点vi的边构成的链表,此链表存储顶点vi的邻接点。邻接表的空间代价与图中边的数目和顶点数目均有关系。每个顶点都要占据一个数组元素的位置(即使该顶点没有邻接点,因而表示该顶点边的链表中没有元素),且每条边必须出现在其中某个顶点的边链表中。所以,邻接表的空间代价为θ(|V|+|E|)。
哪种表示方法存储效率更高取决于图中边的数目。相邻矩阵不需要指针的结构性开销,图越密集,相邻矩阵的空间效率相应地也越高;对稀疏图则使用邻接表可能获得较高的空间效率。与邻接表相比,相邻矩阵在图的算法中常常导致相对较高的渐进时间代价。其原因是访问某个顶点所有邻接点的操作在图算法中相当普遍。使用邻接表则只需检查连接此顶点与其相邻顶点的实际存在的边,而使用相邻矩阵则必须查看所有|V|条可能的边,导致其总时间代价为θ(|V|^2),而使用邻接表的时间代价为θ(|V|+|E|)。
将一个有向无环图(DAG,directed acyclic graph)中所有顶点在不违反先决条件规定的基础上排成线性序列的过程称为拓扑排序(topological sort)。
使用队列代替递归来实现拓扑排序:首先访问所有的边,计算指向每个顶点的边数(即计算每个顶点的先决条件数目)。将所有没有先决条件的顶点放入队列,然后开始处理队列。当从队列中删除一个顶点时,把它打印出来,同时将其所有相邻顶点的先决条件数减1.当某个相邻顶点的计数为0时,就将其放入队列。如果还有顶点未被打印,而队列已经为空,则图中必然包含
回路(即不可能不违反任何先决条件来为这些任务安排一个合理顺序)。
最短路径问题
单源最短路径(
single-source shortest paths),
Dijkstra算法:从s到x的最短路径长度为,从集合S中任取顶点u,计算从s到集合S中任意顶点u的长度,加上u到x的边长之和,取这些和之中的最小值。
每对顶点间的最短路径(
all-pairs shortest-paths),
Floyd算法:时间复杂度V^3。
给定一个连通无向图G,其每条边均有相应的长度或权值,则最小支撑树(MST,minimum-cost spanning tree)是一个包括G所有顶点及其一部分的图,其中包括的边是图G的子集,这些边满足下列条件:
- 这个子集中所有边的权之和为所有子集中最小的
- 子集中的边能保证图是连通的
Prim算法和Kruskal算法。
若在带圈有向图G中,以顶点表示事件,边表示活动,权表示活动持续的时间,则此带权有向图称为用边表示活动的网(
Activity On Edge Network),简称
AOE网。
从源点(入度为0)到汇点(出度为0)之间的长度最长的路径称为关键路径。假设开始点是顶点1,从顶点1到顶点i的最长路径长度称为事件i的最早发生时间,顶点i事件的发生表明了所有以顶点i为尾的活动都已结束,而所有以i为头的活动都可以开始,因此,这个时间也是所有以顶点i为头的活动的最早开始时间。用e(i)来表示活动ai的最早开始时间,还需定义一个活动的最迟开始时间l(i),它是在不推迟整个工期的前提下,活动ai最迟必须开始的时间,两者之差l(i)-e(i)意味着活动ai的时间余量,l(i)=e(i)的活动叫做关键活动。显然关键路径上的所有活动都是关键活动,因此提前完成非关键活动并不能加快整个工程的进度。
第12章 线性表和数组高级技术
广义表
把线性表的定义加以扩展,允许元素是任意的,一般来说,线性表的元素是以下两种类型之一:
- 一个原子(atom),原子是某种类型的一条数据记录,如一个数值、一个符号或者一个字符串
- 另外一个线性表,称为一个子表