数据结构考研复习
数据结构基本概念
数据
信息载体
数据元素
数据元素是数据基本单位
一个数据元素由若干数据项(不可分割最小单位)组成
数据对象
数据类型
数据结构
数据结构是相互之间存在一种或多种特定关系的数据元素的集合。在任何问题中,数据元素都不是孤立存在的,它们之间存在某种关系,这种数据元素相互之间的关系称为结构(Structure)。数据结构包括三方面的内容:逻辑结构、存储结构和数据的运算。
数据的逻辑结构和存储结构是密不可分的两个方面
一个算法的设计取决于所选定的逻辑结构
而算法的实现依赖于所采用的存储结构
数据结构三要素
数据逻辑结构
数据存储结构
数据的运算
习题
算法效率度量
特性
时间复杂度
空间复杂度
习题
线性表
线性表
线性表的特点如下:
- 表中元素的个数有限。
- 表中元素具有逻辑上的顺序性,表中元素有其先后次序。
- 表中元素都是数据元素,每个元素都是单个元素。
- 表中元素的数据类型都相同,这意味着每个元素占有相同大小的存储空间。
- 表中元素具有抽象性,即仅讨论元素间的逻辑关系,而不考虑元素究竟表示什么内容。
线性表基本操作
习题
顺序表
一维数组可以是静态分配的,也可以是动态分配的。
对数组进行静态分配时,因为数组的大小和空间事先已经固定,所以一旦空间占满,再加入新数据就会产生溢出,进而导致程序崩溃。
而在动态分配时,存储数组的空间是在程序执行过程中通过动态存储分配语句分配的,一旦数据空间占满,就另外开辟一块更大的存储空间,将原表中的元素全部拷贝到新空间,从而达到扩充数组存储空间的目的,而不需要为线性表一次性地划分所有空间。
顺序表初始化
插入操作
删除操作
按值查找
线性表链式表示
通常用头指针 L(或 head 等)来标识一个单链表,指出链表的起始地址,头指针为 NULL 时表示一个空表。
此外,为了操作上的方便,在单链表第一个数据结点之前附加一个结点,称为头结点。
头结点的数据域可以不设任何信息,但也可以记录表长等信息。
单链表带头结点时,头指针 L 指向头结点,如图 2.4(a) 所示。
单链表不带头结点时,头指针 L 指向第一个数据结点,如图 2.4(b) 所示。
表尾结点的指针域为 NULL(用 “^” 表示)。
引入头结点后,可以带来两个优点:
- 第一个数据结点的位置被存放在头结点的指针域中,因此在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理。
- 无论链表是否为空,其头指针都是指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就得到了统一。
单链表初始化
求表长操作
按序号查找结点
按值查找结点
插入结点
删除结点
头插法建表
尾插法建表
双链表
插入操作
删除操作
循环链表
循环双链表
静态链表
顺序表和链表的比较
习题
栈,队列和数组
栈
操作
顺序栈
实现
栈顶指针:s.top,初始时设置 s.top = -1;栈顶元素:S.data[s.top]。
入栈操作:栈不满时,栈顶指针先加 1,再送值到栈顶。
出栈操作:栈非空时,先取栈顶元素,再将栈顶指针减 1。
栈空条件:s.top == -1;
栈满条件:s.top == MaxSize - 1;
栈长:s.top + 1
另一种常见的方式是:
初始设置栈顶指针 s.top = 0;入栈时先将值送到栈顶,栈顶指针再加 1;出栈时,栈顶指针先减 1,再取栈顶元素;
栈空条件是 s.top == 0;
栈满条件是 s.top == MaxSize
基本操作
共享栈
两个栈的栈顶指针都指向栈顶元素
top0 == -1 时 0 号栈为空,top1 == MaxSize 时 1 号栈为空;
仅当两个栈顶指针相邻(top1 - top0 == 1)时,判断为栈满。
当 0 号栈入栈时 top0 先加 1 再赋值,1 号栈入栈时 top1 先减 1 再赋值;出栈时则刚好相反。
共享栈是为了更有效地利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢。
其存取数据的时间复杂度均为 O(1),所以对存取效率没有什么影响。
栈的链式存储结构
习题
队列
操作
顺序存储队列
队列的顺序实现是指分配一块连续的存储单元存放队列中的元素
并附设两个指针:队首指针 front 指向队首元素,队尾指针 rear 指向队尾元素的下一个位置
循环队列
区分队空和队满方法
-
牺牲一个单元来区分队空和队满,入队时少用一个队列单元,这是一种较为普遍的做法,约定以“队首指针在队尾指针的下一位置作为队满的标志”,如图 3.7(d2) 所示。
- 队满条件:(Q.rear + 1) % MaxSize == Q.front。
- 队空条件:Q.front == Q.rear。
- 队列中元素的个数:(Q.rear - Q.front + MaxSize) % MaxSize。
-
类型中增设 size 数据成员,表示元素个数。若删除成功,则 size 减 1;若插入成功,则 size 加 1。则 size 加 1,队空时 Q.size == 0;队满时 Q.size == MaxSize,两种情况都有 Q.front == Q.rear。
-
类型中增设 tag 数据成员,以区分是队满还是队空。删除成功置 tag = 0,若导致 Q.front == Q.rear,则为队空;插入成功置 tag = 1,若导致 Q.front == Q.rear,则为队满。
操作
链式存储
双端队列
例题
习题(区别队尾元素和队尾指针)
栈和队列应用
算术表达式
中缀表达式(如 3+4)是人们常用的算术表达式,操作符以中缀形式处于操作数的中间。
与前缀表达式(如 +34)或后缀表达式(如 34+)相比,中缀表达式不容易被计算机解析,但仍被许多程序语言使用,因为它更符合人们的思维习惯。
与前缀表达式或后缀表达式不同的是,中缀表达式中的括号是必需的。计算过程中必须用括号将操作符和对应的操作数括起来,用于指示运算的次序。后缀表达式的运算符在操作数后面,后缀表达式中考虑了运算符的优先级,没有括号,只有操作数和运算符。
中缀表达式 A+B*(C-D)-E/F 对应的后缀表达式为 ABCD-*+EF/-,将后缀表达式与原表达式对应的表达式树(图 3.15)的后序遍历序列进行比较,可发现它们有异曲同工之妙。
中缀表达式转后缀表达式
下面先给出一种由中缀表达式转后缀表达式的手算方法。
- 按照运算符的运算顺序对所有运算单位加括号。
- 将运算符移至对应括号的后面,相当于按“左操作数 右操作数 运算符”重新组合。
- 去除所有括号。
例如,中缀表达式 A+B*(C-D)-E/F 转后缀表达的过程如下(下标表示运算符的运算顺序):
- 加括号:((A+③(B*②(C-①D))) - ⑤(E/④F))。
- 运算符后移:( (A (B (CD) - ①) * ②) + ③ (EF) / ④) - ⑤。
- 去除括号后,得到后缀表达式:ABCD-①*②+③EF/④-⑤。
中缀表达式转后缀表达式时需要借助一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右依次扫描中缀表达式中的每一项,具体转化过程如下:
- 遇到操作数,直接加入后缀表达式。
- 遇到界限符。若为“(”,则直接入栈;若为“)”,则不入栈,且依次弹出栈中的运算符并加入后缀表达式,直到遇到“(”为止,并直接删除“(”。
- 遇到运算符。① 若其优先级高于栈顶运算符或遇到栈顶为“(”,则直接入栈;② 若其优先级低于或等于栈顶运算符,则依次弹出栈中的运算符并加入后缀表达式,直到遇到一个优先级低于它的运算符或遇到“(”或栈空为止,之后将当前运算符入栈。
按上述方法扫描所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。
例如,中缀表达式 A+B*(C-D)-E/F 转后缀表达式的过程如表 3.1 所示。
后缀表达式求值
通过后缀表示计算表达式值的过程:从左往右依次扫描表达式的每一项,若该项是操作数,则将其压入栈中;
若该项是运算符<op>,则从栈中退出两个操作数Y和X,形成运算指令X<op>Y,并将计算结果压入栈中。当所有项都扫描并处理完后,栈顶存放的就是最后的计算结果。
队列在层次遍历中的应用
在信息处理中有一大类问题需要逐层或逐行处理。
这类问题的解决方法往往是在处理当前层或当前行时就对下一层或下一行做预处理,把处理顺序安排好,等到当前层或当前行处理完毕,就可以处理下一层或下一行。使用队列是为了保存下一步的处理顺序。下面用二叉树(见图3.17)层次遍历的例子,说明队列的应用。表3.3显示了层次遍历二叉树的过程。
队列在计算机系统中的应用
队列在计算机系统中的应用非常广泛,以下仅从两个方面来阐述:第一个方面是解决主机与外部设备之间速度不匹配的问题,第二个方面是解决由多用户引起的资源竞争问题。
对于第一个方面,仅以主机和打印机之间速度不匹配的问题为例做简要说明。主机输出数据给打印机打印,输出数据的速度比打印数据的速度要快得多,因为速度不匹配,若直接把输出的数据送给打印机打印,则显然是不行的。解决的方法是设置一个打印数据缓冲区,主机把要打印输出的数据依次写入这个缓冲区,写满后就暂停输出,转去做其他的事情。打印机就从缓冲区中按照先进先出的原则依次取出数据并打印,打印完后再向主机发出请求。主机接到请求后再向缓冲区写入打印数据。这样做既保证了打印数据的正确,又使主机提高了效率。由此可见,打印数据缓冲区中所存储的数据就是一个队列。
对于第二个方面,CPU(中央处理器,它包括运算器和控制器)资源的竞争就是一个典型的例子。在一个带有多终端的计算机系统上,有多个用户需要CPU各自运行自己的程序,它们分别通过各自的终端向操作系统提出占用CPU的请求。操作系统通常按照每个请求在时间上的先后顺序,把它们排成一个队列,每次把CPU分配给队首请求的用户使用。当相应的程序运行结束或用完规定的时间间隔后,令其出队,再把CPU分配给新的队首请求的用户使用。这样既能满足每个用户的请求,又使CPU能够正常运行。
习题
数组和特殊矩阵
数组的定义
数组是由 𝑛 (𝑛⩾1) 个相同类型的数据元素构成的有限序列,每个数据元素称为一个数组元素,每个元素在 𝑛 个线性关系中的序号称为该元素的下标,下标的取值范围称为数组的维界。
数组与线性表的关系:数组是线性表的推广。一维数组可视为一个线性表;二维数组可视为其元素是定长数组的线性表,以此类推。数组一旦被定义,其维数和维界就不再改变。因此,除结构的初始化和销毁外,数组只会有存取元素和修改元素的操作。
数组的存储结构
大多数计算机语言都提供了数组数据类型,逻辑意义上的数组可采用计算机语言中的数组数据类型进行存储,一个数组的所有元素在内存中占用一段连续的存储空间。
二维数组
特殊矩阵的压缩存储
压缩存储: 指为多个值相同的元素只分配一个存储空间,对零元素不分配空间。
特殊矩阵: 指具有许多相同矩阵元素或零元素,并且这些相同矩阵元素或零元素的分布有一定规律性的矩阵。常见的特殊矩阵有对称矩阵、上(下)三角矩阵、对角矩阵等。
特殊矩阵的压缩存储方法: 找出特殊矩阵中值相同的矩阵元素的分布规律,把那些呈现规律性分布的、值相同的多个矩阵元素压缩存储到一个存储空间中。
对称矩阵
三角矩阵
三对角矩阵
稀疏矩阵
若采用常规的方法存储稀疏矩阵,则相当浪费存储空间,因此仅存储非零元素。但通常非零元素的分布没有规律,所以仅存储非零元素的值是不够的,还要存储它所在的行和列。因此,将非零元素及其相应的行和列构成一个三元组(行标 𝑖,列标 𝑗,值 𝑎𝑖,𝑗),如图3.26所示。然后按照某种规律存储这些三元组线性表。稀疏矩阵压缩存储后便失去了随机存取特性。
图3.26 稀疏矩阵及其对应的三元组
稀疏矩阵的三元组表既可以采用数组存储,又可以采用十字链表存储(见6.2节)。当存储稀疏矩阵时,不仅要保存三元组表,而且要保存稀疏矩阵的行数、列数和非零元素的个数。
习题
串
简单的模式匹配算法
模式匹配是指在主串中找到与模式串(想要搜索的某个字符串)相同的子串,并返回其所在的位置。这里采用定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法。
在上述算法中,分别用计数指针 i
和 j
指示主串 S
和模式串 T
中当前待比较的字符位置。
算法思想是:从主串 S
的第一个字符起,与模式串 T
的第一个字符比较,若相等,则继续逐个比较后续字符;否则从主串的下一个字符起,再重新和模式串 T
的字符比较;以此类推,直至模式串 T
中的每个字符依次和主串 S
中的一个连续的字符序列相等,则称匹配成功,函数值为与模式串 T
中第一个字符相等的字符在主串 S
中的序号,否则称匹配不成功,函数值为零。
图 4.2 展示了模式串 T='abcac'
和主串 S
的匹配过程。
串的模式匹配算法——KMP算法
KMP算法的原理
要了解模式串的结构,首先要弄清楚几个概念:前缀、后缀和部分匹配值。
前缀是指除最后一个字符外,字符串的所有头部子串;
后缀是指除第一个字符外,字符串的所有尾部子串;
部分匹配值则是指字符串的前缀和后缀的最长相等前后缀长度。
下面以'ababa'为例进行说明:
- 'a' 的前缀和后缀都为空集,最长相等前后缀长度为 0。
- 'ab' 的前缀为 {a},后缀为 {b},{a} ∩ {b} = ∅,最长相等前后缀长度为 0。
- 'aba' 的前缀为 {a, ab},后缀为 {a, ba},{a, ab} ∩ {a, ba} = {a},最长相等前后缀长度为 1。
- 'abab' 的前缀 {a, ab, aba} ∩ 后缀 {b, ab, bab} = {ab},最长相等前后缀长度为 2。
- 'ababa' 的前缀 {a, ab, aba, abab} ∩ 后缀 {a, ba, aba, baba} = {a, aba},公共元素有两个,最长相等前后缀长度为 3。
还有一种特例,在上述举例中并未出现,当某趟第一个字符比较就失配时,应如何处理呢?此时,应让模式串向右滑动一位,再从主串当前位置的下一位开始比较。
next 数组的手算方法
每趟匹配失败时,只有模式串指针 i 在变化,主串指针 j 不会回溯,为此可以定义一个 next 数组,next[j] 的含义是当模式串的第 j 个字符失配时,跳到 next[j] 位置继续比较。
下面给出一种求 next 数组的手算方法,仍以模式串 'abcac' 为例。
next数组:把 T 串各个位置的 j 的变化定义为数组 next,next 的长度就是 T 串的长度。主串和模式串不匹配时,下一步 j 的值由 next[j] 决定。例如目标串 S="abcaba", T="aba", 根据前缀表求出 next=[-1,0 0], 当 j=2 时发生不匹配, next[2]=0, 下一步 j 将等于 0 进行字符匹配。
KMP 算法的进一步优化
前面定义的 next 数组在某些情况下尚有缺陷,还可以进一步优化。如图 4.5 所示,模式串 'aaaab' 在和主串 'aaabaaaaab' 进行匹配时。
习题(大题未粘贴)
树
树的定义
树是 𝑛(𝑛⩾0) 个结点的有限集。当 𝑛=0 时,称为空树。在任意一棵非空树中应满足:
-
有且仅有一个特定的称为根的结点。
-
当 𝑛>1 时,其余结点可分为 𝑚 (𝑚>0) 个互不相交的有限集 𝑇1,𝑇2,⋯ ,𝑇𝑚,其中每个集合本身又是一棵树,并且称为根的子树。
显然,树的定义是递归的,即在树的定义中又用到了其自身,树是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
-
树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱。
-
树中所有结点都可以有零个或多个后继。
树适用于表示具有层次结构的数据。树中的某个结点(除根结点外)最多只和上一层的一个结点(其父结点)有直接关系,根结点没有直接上层结点,因此在 𝑛 个结点的树中有 𝑛−1 条边。而树中每个结点与其下一层的零个或多个结点(其孩子结点)都有直接关系。
树的树形表示
-
祖先、子孙、双亲、孩子、兄弟和堂兄弟
考虑结点 𝐾。从根 𝐴A 到结点 𝐾 的唯一路径上的所有其他结点,称为结点 𝐾 的祖先。如结点 𝐵 是结点 𝐾 的祖先,而 𝐾 是 𝐵 的子孙,结点 𝐵 的子孙包括 𝐸,𝐹,𝐾,𝐿。路径上最接近结点 𝐾 的结点 𝐸 称为 𝐾 的双亲,而 𝐾 为 𝐸 的孩子。根 𝐴 是树中唯一没有双亲的结点。有相同双亲的结点称为兄弟,如结点 𝐾 和结点 𝐿 有相同的双亲 𝐸,即 𝐾 和 𝐿 为兄弟。双亲在同一层的结点互为堂兄弟,结点 𝐺 与 𝐸,𝐹,𝐻,𝐼,𝐽互为堂兄弟。
-
结点的层次、深度和高度
结点的层次从树根开始定义,根结点为第 1 层,它的孩子为第 2 层,以此类推。结点的深度就是结点所在的层数。树的高度(或深度)是树中结点的最大层数。结点的高度是以该结点为根的子树的高度。图 5.1 中树的高度为 4。
-
结点的度和树的度
树中一个结点的孩子个数称为该结点的度,树中结点的最大度数称为树的度。如结点 𝐵 的度为 2,结点 𝐷 的度为 3,结点 𝐶 的度为 3。
-
分支结点和叶结点
度大于 0 的结点称为分支结点(也称非终端结点);度为 0(没有孩子结点)的结点称为叶结点(也称终端结点)。在分支结点中,每个结点的分支数就是该结点的度。
-
有序树和无序树
树中结点的各子树从左到右是有次序的,不能互换,称该树为有序树,否则称为无序树。假设图 5.1 为有序树,若将子结点位置互换,则变成一棵不同的树。
-
路径和路径长度
树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。
-
森林
森林是 𝑚 (𝑚⩾0) 棵互不相交的树的集合。森林的概念与树的概念十分相近,因为只要把树的根结点删去就成了森林。反之,只要给 𝑚 棵独立的树加上一个结点,并把这 𝑚 棵树作为该结点的子树,则森林就变成了树。
树的性质
树具有如下最基本的性质:
-
树的结点数 𝑛 等于所有结点的度数之和加 1。
习题
二叉树的定义及其主要特性
二叉树的定义
二叉树是一种特殊的树形结构,其特点是每个结点至多只有两棵子树(二叉树中不存在度大于 2 的结点),并且二叉树的子树有左右之分,其次序不能任意颠倒。
与树相似,二叉树也以递归的形式定义。二叉树是 𝑛 (𝑛⩾0) 个结点的有限集合:
- 或者为空二叉树,即 𝑛=0。
- 或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
二叉树是有序树
二叉树的 5 种基本形态如图 5.2 所示。
(a) 空二叉树 (b) 只有根结点 (c) 只有左子树 (d) 左右子树都有 (e) 只有右子树
二叉树与度为 2 的有序树的区别:
- 度为 2 的树至少有 3 个结点,而二叉树可以为空。
- 度为 2 的有序树的孩子的左右次序是相对于另一个孩子而言的,若某个结点只有一个孩子,则这个孩子就无须区分其左右次序,而二叉树无论其孩子数是否为 2,均需确定其左右次序,即二叉树的结点次序不是相对于另一结点而言的,而是确定的。
几种特殊的二叉树
1.满二叉树。一棵高度为 ℎ,且有 2ℎ−1 个结点的二叉树称为满二叉树,即二叉树中的每层都含有最多的结点,如图 5.3(a)所示。满二叉树的叶结点都集中在二叉树的最下一层,且除叶结点之外的每个结点度数均为 2。
2.完全二叉树
高度为 ℎ、有 𝑛 个结点的二叉树,当且仅当其每个结点都与高度为 ℎ 的满二叉树中编号为 1∼𝑛 的结点一一对应时,称为完全二叉树
可以对满二叉树按层序编号:约定编号从根结点(根结点编号为 1)起,自上而下,自左向右。这样,每个结点对应一个编号,对于编号为 𝑖 的结点,若有双亲,则其双亲为 ⌊𝑖/2⌋; 若有左孩子,则左孩子为 2𝑖; 若有右孩子,则右孩子为 2𝑖+1。
3.二叉排序树。左子树上所有结点的关键字均小于根结点的关键字;右子树上所有结点的关键字均大于根结点的关键字;左子树和右子树又各是一棵二叉排序树。
4.平衡二叉树。树中任意一个结点的左子树和右子树的高度之差的绝对值不超过 1。关于二叉排序树和平衡二叉树的详细介绍,见本书中的 7.3 节。
5.正则二叉树。树中每个分支结点都有 2 个孩子,即树中只有度为 0 或 2 的结点。
二叉树的存储结构
顺序存储结构
二叉树的顺序存储是指用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素,即将完全二叉树上编号为 𝑖 的结点元素存储在一维数组下标为 𝑖−1 的分量中。
依据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映结点之间的逻辑关系,这样既能最大可能地节省存储空间,又能利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。
但对于一般的二叉树,为了让数组下标能反映二叉树中结点之间的逻辑关系,只能添加一些并不存在的空结点,让其每个结点与完全二叉树上的结点相对照,再存储到一维数组的相应分量中。然而,在最坏情况下,一个高度为 ℎ 且只有 ℎ 个结点的单支树却需要占据近 2ℎ−1 个存储单元。二叉树的顺序存储结构如图 5.4 所示,其中 0 表示并不存在的空结点。
链式存储结构
顺序存储的空间利用率较低,因此二叉树一般都采用链式存储结构,用链表结点来存储二叉树中的每个结点。在二叉树中,结点结构通常包括若干数据域和若干指针域,二叉链表至少包含 3 个域:数据域 data
、左指针域 lchild
和右指针域 rchild
,如图 5.5 所示。
使用不同的存储结构时,实现二叉树操作的算法也会不同,因此要根据实际应用场合(二叉树的形态和需要进行的运算)来选择合适的存储结构。
容易验证,在含有 𝑛 个结点的二叉链表中,含有 𝑛+1 个空链域(重要结论,经常出现在选择题中)。在下一节中,我们将利用这些空链域来组成另一种链表结构——线索链表。
习题
二叉树的遍历和线索二叉树
二叉树的遍历
二叉树的遍历是指按某条搜索路径访问树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。二叉树是一种非线性结构,每个结点都可能有两棵子树,因此需要寻找一种规律,以便使二叉树上的结点能排列在一个线性队列上,进而便于遍历。
由二叉树的递归定义可知,遍历一棵二叉树便要决定对根结点 𝑁、左子树 𝐿 和右子树 𝑅 的访问顺序。按照先遍历左子树再遍历右子树的原则,常见的遍历次序有先序(NLR)、中序(LNR)和后序(LRN)三种遍历算法,其中序指的是根结点在何时被访问。
由遍历序列构造二叉树
对于一棵给定的二叉树,其先序序列、中序序列、后序序列和层序序列都是确定的。然而,只给出四种遍历序列中的任意一种,却不能唯一地确定一棵二叉树。若已知中序序列,再给出其他三种遍历序列中的任意一种,就可以唯一地确定一棵二叉树。
由先序序列和中序序列构造二叉树
在先序序列中,第一个结点一定是二叉树的根结点;而在中序遍历中,根结点必然将中序序列分割成两个子序列,前一个子序列是根的左子树的中序序列,后一个子序列是根的右子树的中序序列。左子树的中序序列和先序序列的长度是相等的,右子树的中序序列和先序序列的长度是相等的。根据这两个子序列,可以在先序序列中找到左子树的先序序列和右子树的先序序列,如图 5.11 所示。如此递归地分解下去,便能唯一地确定这棵二叉树。
例如,求先序序列(ABCDEFGH
)和中序序列(BCAEDGHF
)所确定的二叉树。首先,由先序序列可知 A
为二叉树的根结点。中序序列中 A
之前的 BC
为左子树的中序序列,EDGHF
为右子树的中序序列。然后,由先序序列可知 B
是左子树的根结点,D
是右子树的根结点。以此类推,就能将剩下的结点继续分解下去,最后得到的二叉树如图 5.12(c) 所示。
由后序序列和中序序列构造二叉树
同理,由二叉树的后序序列和中序序列也可以唯一地确定一棵二叉树。因为后序序列的最后一个结点就如同先序序列的第一个结点,可以将中序序列分割成两个子序列,如图 5.13 所示,然后采用类似的方法递归地进行分解,进而唯一地确定这棵二叉树。
请读者分析后序序列(CBEHGIFDA
)和中序序列(BCAEDGHFI
)所确定的二叉树。
由层序序列和中序序列构造二叉树
在层序遍历中,第一个结点一定是二叉树的根结点,这样就将中序序列分割成了左子树的中序序列和右子树的中序序列。若存在左子树,则层序序列的第二个结点一定是左子树的根,可进一步划分左子树;若存在右子树,则层序序列中紧接着的下一个结点一定是右子树的根,可进一步划分右子树,如图 5.14 所示。采用这种方法继续分解,就能唯一确定这棵二叉树。
请读者分析层序序列(ABDCEFGIH
)和中序序列(BCAEDGHFI
)所确定的二叉树。
注意,先序序列、后序序列和层序序列的两两组合,无法唯一确定一棵二叉树。例如,图 5.15 所示的两棵二叉树的先序序列都为 AB
,后序序列都为 BA
,层序序列都为 AB
。
线索二叉树
线索二叉树的基本概念
遍历二叉树是以一定的规则将二叉树中的结点排列成一个线性序列,从而得到几种遍历序列,使得该序列中的每个结点(第一个和最后一个除外)都有一个直接前驱和直接后继。
引入线索二叉树正是为了加快查找结点前驱和后继的速度
以这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表,其中指向结点前驱和后继的指针称为线索。加上线索的二叉树称为线索二叉树。
中序线索二叉树的构造
二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索。而前驱或后继的信息只有在遍历时才能得到,因此线索化的实质就是遍历一次二叉树。
以中序线索二叉树的建立为例。附设指针 pre
指向刚刚访问过的结点,指针 p
指向正在访问的结点,即 pre
指向 p
的前驱。在中序遍历的过程中,检查 p
的左指针是否为空,若为空就将它指向 pre
;检查 pre
的右指针是否为空,若为空就将它指向 p
,如图 5.17 所示。
为方便起见,可在二叉树的线索链表上也添加一个头结点,令其 lchild
域的指针指向二叉树的根结点,其 rchild
域的指针指向中序遍历时访问的最后一个结点;令二叉树中序序列中的第一个结点的 lchild
域指针和最后一个结点的 rchild
域指针均指向头结点。这好比为二叉树建立了一个双向线索链表,方便从前往后或从后往前对线索二叉树进行遍历,如图 5.18 所示。
中序线索二叉树的遍历
中序线索二叉树的结点中隐含了线索二叉树的前驱和后继信息。在对其进行遍历时,只要先找到序列中的第一个结点,然后依次找结点的后继,直至其后继为空。在中序线索二叉树中找结点后继的规律是:若其右标志为“1”,则右链为线索,指示其后继,否则遍历右子树中最左下的结点为其后继。不含头结点的线索二叉树的遍历算法如下。
先序线索二叉树和后序线索二叉树
上面给出了建立中序线索二叉树的代码,建立先序线索二叉树和建立后序线索二叉树的代码类似,只需变动线索化改造的代码段与调用线索化左右子树递归函数的位置。
以图 5.19(a) 的二叉树为例给出手工求先序线索二叉树的过程:
先序序列为 ABCDF
,然后依次判断每个结点的左右链域,若为空,则将其改造为线索。结点 A
, B
均有左右孩子;结点 C
无左孩子,将左链域指向前驱 B
,无右孩子,将右链域指向后继 D
;结点 D
无左孩子,将左链域指向前驱 C
,无右孩子,将右链域指向后继 F
;结点 F
无左孩子,将左链域指向前驱 D
,无右孩子,也无后继,所以置空,得到的先序线索二叉树如图 5.19(b) 所示。求后序线索二叉树的过程:后序序列为 CDBFA
,结点 C
无左孩子,也无前驱,所以置空,无右孩子,将右链域指向后继 D
;结点 D
无左孩子,将左链域指向前驱 C
,无右孩子,将右链域指向后继 B
;结点 F
无左孩子,将左链域指向前驱 B
,无右孩子,将右链域指向后继 A
,得到的后序线索二叉树如图 5.19(c) 所示。
如何在先序线索二叉树中找结点的后继?若有左孩子,则左孩子就是其后继;若无左孩子但有右孩子,则右孩子就是其后继;若为叶结点,则右链域直接指示了结点的后继。
习题
树、森林
树的存储结构
树的存储方式有多种,既可采用顺序存储结构,又可采用链式存储结构,但无论采用何种存储方式,都要求能唯一地反映树中各结点之间的逻辑关系,这里介绍3种常用的存储结构。
双亲表示法
这种存储结构采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。如图5.20所示,根结点下标为0,其伪指针域为-1。
双亲表示法利用了每个结点(根结点除外)只有唯一双亲的性质,可以很快地得到每个结点的双亲结点,但求结点的孩子时则需要遍历整个结构。
区别树的顺序存储结构与二叉树的顺序存储结构
在树的顺序存储结构中,数组下标代表结点的编号,下标中所存的内容指示了结点之间的关系。而在二叉树的顺序存储结构中,数组下标既代表了结点的编号,又指示了二叉树中各结点之间的关系。当然,二叉树属于树,因此二叉树也可用树的存储结构来存储,但树却不都能用二叉树的存储结构来存储。
孩子表示法
孩子表示法是将每个结点的孩子结点视为一个线性表,且以单链表作为存储结构,则 𝑛 个结点就有 𝑛 个孩子链表(叶结点的孩子链表为空表)。而 𝑛 个头指针又组成一个线性表,为便于查找,可采用顺序存储结构。图 5.21(a) 是图 5.20(a) 中的树的孩子表示法。
与双亲表示法相反,孩子表示法寻找孩子的操作非常方便,而寻找双亲的操作则需要遍历 𝑛 个结点中孩子链表指针域所指向的 𝑛 个孩子链表。
孩子兄弟表示法
孩子兄弟表示法也称二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,以及指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点),如图5.21(b)所示。
孩子兄弟表示法比较灵活,其最大的优点是可以方便地实现树转换为二叉树的操作,易于查找结点的孩子等,但缺点是从当前结点查找其双亲结点比较麻烦。若为每个结点增设一个 parent
域指向其父结点,则查找结点的父结点也很方便。
树、森林与二叉树的转换
二叉树和树都可以用二叉链表作为存储结构。从物理结构上看,树的孩子兄弟表示法与二叉树的二叉链表表示法是相同的,因此可以用同一存储结构的不同解释将一棵树转换为二叉树。
树转换为二叉树
树转换为二叉树的规则:每个结点的左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则也称左孩子右兄弟。根结点没有兄弟,因此树转换得到的二叉树没有右子树,如图5.22所示。
- 在兄弟结点之间加一连线;
- 对每个结点,只保留它与第一个孩子的连线,而与其他孩子的连线全部抹掉;
- 以树根为轴心,顺时针旋转45°。
森林转换为二叉树
将森林转换为二叉树的规则与树类似。先将森林中的每棵树转换为二叉树,由于任意一棵树对应的二叉树的右子树必空,森林中各棵树的根也可视为兄弟关系,将第二棵树对应的二叉树当作第一棵二叉树根的右子树……以此类推,就可以将森林转换为二叉树。
森林转换为二叉树的画法:
- 将森林中的每棵树转换成相应的二叉树;
- 每棵树的根也可视为兄弟关系,在每棵树的根之间加一根连线;
- 以第一棵树的根为轴心顺时针旋转45°。 或者先在森林中每棵树的根之间加一根连线,然后再采用树转换为二叉树的方法。
二叉树转换为森林
二叉树转换为森林的规则:若二叉树非空,则二叉树的根及其左子树为第一棵树的二叉树形式,所以将根的右链断开。二叉树根的右子树又可视为一个由除第一棵树外的森林转换后的二叉树,应用同样的方法,直到最后只剩一棵没有右子树的二叉树为止,最后将每棵二叉树依次转换成树,就得到了原森林,如图5.23所示。二叉树转换为树或森林是唯一的。
树和森林的遍历
树的遍历
树的遍历是指用某种方式访问树中的每个结点,且仅访问一次。主要有两种方式:
- 先根遍历。若树非空,则按如下规则遍历:
- 先访问根结点。
- 再依次遍历根结点的每棵子树,遍历子树时仍遵循先根后子树的规则。
其遍历序列与这棵树相应二叉树的先序序列相同。
- 后根遍历。若树非空,则按如下规则遍历:
- 先依次遍历根结点的每棵子树,遍历子树时仍遵循先子树后根的规则。
- 再访问根结点。
其遍历序列与这棵树相应二叉树的中序序列相同。
图5.22的树的先根遍历序列为ABEFCDG
,后根遍历序列为EFBCGDA
。
另外,树也有层次遍历,与二叉树的层次遍历思想基本相同,即按层序依次访问各结点。
森林的遍历
按照森林和树相互递归的定义,可得到森林的两种遍历方法:
-
先序遍历森林。若森林为非空,则按如下规则遍历:
- 访问森林中第一棵树的根结点。
- 先序遍历第一棵树中根结点的子树森林。
- 先序遍历除去第一棵树之后剩余的树构成的森林。
-
中序遍历森林。森林为非空时,按如下规则遍历:
- 中序遍历森林中第一棵树的根结点的子树森林。
- 访问第一棵树的根结点。
- 中序遍历除去第一棵树之后剩余的树构成的森林。
图5.23的森林的先序遍历序列为ABCDEFGH
,中序遍历序列为BCDAFEHIG
。
当森林转换成二叉树时,其第一棵树的子树森林转换成左子树,剩余树的森林转换成右子树,可知森林的先序和中序遍历即其对应二叉树的先序和中序遍历。
习题
哈夫曼树和哈夫曼编码
哈夫曼树的定义
在介绍哈夫曼树之前,先介绍几个相关的概念:
- 在许多应用中,树中结点常常被赋予一个表示某种意义的数值,称为该结点的权。
- 从树的根到一个结点的路径长度与该结点上权值的乘积,称为该结点的带权路径长度。
- 树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记为WPL
在含有 𝑛 个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。例如,图 5.24 中的 3 棵二叉树都有 4 个叶结点 𝑎,𝑏,𝑐,𝑑,分别带权 7, 5, 2, 4,它们的带权路径长度分别为:
- (a) WPL = 7×2 + 5×2 + 2×2 + 4×2 = 36。
- (b) WPL = 4×2 + 7×3 + 5×3 + 2×1 = 46。
- (c) WPL = 7×1 + 5×2 + 2×3 + 4×3 = 35。
其中,图 5.24(c) 树的 WPL 最小。可以验证,它恰好为哈夫曼树。
哈夫曼树的构造
给定 𝑛 个权值分别为 𝑤1,𝑤2,…,𝑤𝑛的结点,构造哈夫曼树的算法描述如下:
- 将这 𝑛 个结点分别作为 𝑛 棵仅含一个结点的二叉树,构成森林 𝐹。
- 构造一个新结点,从 𝐹 中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
- 从 𝐹 中删除刚才选出的两棵树,同时将新得到的树加入 𝐹F 中。
- 重复步骤 2)和 3),直至 𝐹 中只剩下一棵树为止。
从上述构造过程中可以看出哈夫曼树具有如下特点:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
- 构造过程中共新建了 𝑛−1 个结点(双分支结点),因此哈夫曼树的结点总数为 2𝑛−1。
- 每次构造都选择 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为 1 的结点。
例如,权值 [7, 5, 2, 4] 的哈夫曼树的构造过程如图 5.25 所示。
哈夫曼编码
在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码要好得多,其特点是对频率高的字符赋以短编码,而对频率较低的字符则赋以较长一些的编码,从而可以使字符的平均编码长度减短,起到压缩数据的效果。
根据哈夫曼编码对编码序列进行译码
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。举例:设计字符 A、B 和 C 对应的编码 0、10 和 110 是前缀编码。对前缀编码的解码很简单,因为没有一个编码是其他编码的前缀。所以识别出第一个编码,将它翻译为原字符,再对剩余的码串执行同样的解码操作。例如,码串 0010110 可被唯一地翻译为 A、A、B 和 C。另举反例:若再将字符 D 的编码设计为 11,此时 11 是 110 的前缀,则上述码串中的后三位就无法唯一翻译。
前缀编码的分析及应用
可以利用二叉树来设计二进制前缀编码。假设为 A、B、C、D 四个字符设计前缀编码,可以用图 5.26 所示的二叉树来表示,4 个叶结点分别表示 4 个字符,且约定左分支表示 0,右分支表示 1,从根到叶结点的路径上用分支标记组成的序列作为该叶结点字符的编码,可以证明如此得到的必为前缀编码。由图 5.26 得到字符 A、B、C、D 的前缀编码分别为 0、10、110、111。
并查集
并查集的概念
并查集是一种简单的集合表示,它支持以下3种操作:
- Initial(S): 将集合S中的每个元素都初始化为只有一个单元素的子集合。
- Union(S, Root1, Root2): 把集合S中的子集合Root2并入子集合Root1。要求Root1和Root2互不相交,否则不执行合并。
- Find(S, x): 查找集合S中单元素x所在的子集合,并返回该子集合的根结点。
并查集的存储结构
通常用树的双亲表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组中。通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲域为负数(可设置为该子集合元素数量的相反数)。
例如,若设有一个全集合为 𝑆={0,1,2,3,4,5,6,7,8,9}S={0,1,2,3,4,5,6,7,8,9},初始化时每个元素自成一个单元素子集合,每个子集合的数组值如下图所示。
经过一段时间的计算后,这些子集合合并为3个更大的子集合,即 𝑆1={0,6,7,8},𝑆2={1,4,9},𝑆3={2,3,5}。此时并查集的树形和存储结构如图5.29所示。
为了得到两个子集合的并,只需将其中一个子集合根结点的双亲指针指向另一个集合的根结点。因此,𝑆1∪𝑆2可以具有如图5.30所示的表示。
并查集的基本实现
并查集的结构定义如下:
#define SIZE 100
int OFFSETS[SIZE]; // 集合元素数组(双亲指针数组)
下面是并查集主要运算的实现。
并查集的初始化操作
void Initial(int S[]) {
for (int i = 0; i < SIZE; i++) {
S[i] = -1; // 每个元素自成单元素集合
}
}
并查集的 find
操作
在并查集 𝑆S 中查找并返回包含元素 𝑥x 的树的根。
int Find(int S[], int x) {
while (S[x] >= 0) {
x = S[x]; // 循环寻找 $ x $ 的根
}
return x; // 返回 $ x $ 的根结点
}
判断两个元素是否属于同一集合,只需分别找到它们的根,再比较根是否相同即可。
并查集的 union
操作
将两个不交子集合合并为一个集合。若需将两个子集合合并为一个集合,则需要先找到两个子集合的根,再令一棵子集树的根指向另一棵子集树的根。
void Union(int S[], int Root1, int Root2) {
if (Root1 == Root2) return; // 若根相同则无需合并
if (S[Root1] > S[Root2]) { // 根据大小选择合并方向
S[Root2] += S[Root1]; // 更新根节点的大小
S[Root1] = Root2; // 将较小的树连接到较大的树下面
} else {
S[Root1] += S[Root2];
S[Root2] = Root1;
}
}
并查集实现的优化
在极端情况下,𝑛 元素构成的集合群的深度为 𝑚,则 Find
操作的最坏时间复杂度为 𝑂(𝑚)。改进的方法是在做 Union
操作之前,首先判断子集合中的成员数量,然后让成员少的树的根指向成员多的树的根,即把小树合并到大树,为此可令根结点的绝对值保存在集合树中的成员数量。
改进的 Union
操作
void Union(int S[], int Root1, int Root2) {
if (Root1 == Root2) return; // 若根相同则无需合并
if (S[Root1] > S[Root2]) { // 根据大小选择合并方向
S[Root2] += S[Root1]; // 更新根节点的大小
S[Root1] = Root2; // 将较小的树连接到较大的树下面
} else {
S[Root1] += S[Root2];
S[Root2] = Root1;
}
}
采用这种方法构造得到的集合树,其深度不超过 log2𝑛+1。
随着子集不断合并,集合树的深度越来越大。为了进一步减少确定元素所在集合的时间,还可以进一步对上述 Find
操作进行优化,当所查元素 𝑥 不在树的第二层时,在算法中增加一个压缩路径的功能,即将从根到元素 𝑥 路径上的所有元素都变成根的孩子。
改进的 Find
操作
int Find(int S[], int x) {
int root = x;
while (S[root] >= 0) {
root = S[root]; // 循环寻找根
}
while (x != root) {
int tmp = S[x];
S[x] = root; // 压缩路径
x = tmp; // 向前移动
}
return root; // 返回根结点编号
}
通过 Find
操作的压缩路径优化后,可使集合树的深度不超过 𝑂(𝛼(𝑛)),其中 𝛼(𝑛)是一个增长极其缓慢的函数,对于常见的正整数 𝑛,通常 𝛼(𝑛)≤4。
习题
图的基本概念
图的定义
图 𝐺 由顶点集 𝑉 和边集 𝐸 组成,记为 𝐺=(𝑉,𝐸),其中 𝑉(𝐺) 表示图 𝐺中顶点的有限非空集;𝐸(𝐺) 表示图 𝐺 中顶点之间的关系(边)集合。若 𝑉={𝑣1,𝑣2,⋯ ,𝑣𝑛},则用 ∣𝑉∣表示图 𝐺 中顶点的个数,𝐸={(𝑢,𝑣)∣𝑢∈𝑉,𝑣∈𝑉},用 ∣𝐸∣ 表示图 𝐺 中边的条数。
线性表可以是空表,树可以是空树,但图不可以是空图。也就是说,图中不能一个顶点也没有,图的顶点集 𝑉 一定非空,但边集 𝐸 可以为空,此时图中只有顶点而没有边。
1. 有向图
若 𝐸 是有向边(也称弧)的有限集合,则图 𝐺 为有向图。弧是顶点的有序对,记为 <𝑣,𝑤>,其中 𝑣,𝑤 是顶点,𝑣 称为弧尾,𝑤 称为弧头,<𝑣,𝑤> 称为从 𝑣 到 𝑤 的弧,也称 𝑣 邻接到 𝑤。
2. 无向图
若 𝐸 是无向边(简称边)的有限集合,则图 𝐺 为无向图。边是顶点的无序对,记为 (𝑣,𝑤) 或 (𝑤,𝑣)。可以说 𝑤 和 𝑣 互为邻接点。边 (𝑣,𝑤) 依附于 𝑤 和 𝑣,或称边 (𝑣,𝑤) 和 𝑣,𝑤 相关联。
3. 简单图、多重图
一个图 𝐺 若满足:①不存在重复边;②不存在顶点到自身的边,则称图 𝐺 为简单图。图 6.1 中 𝐺1 和 𝐺2 均为简单图。
4. 顶点的度、入度和出度
在无向图中,顶点 𝑣 的度是指依附于顶点 𝑣 的边的条数,记为 𝑇𝐷(𝑣)。在图 6.1(b) 中,每个顶点的度均为 3。无向图的全部顶点的度之和等于边数的 2 倍,因为每条边和两个顶点相关联。
在有向图中,顶点 𝑣 的度分为入度和出度。入度是以顶点 𝑣 为终点的有向边的数目,记为 𝐼𝐷(𝑣);而出度是以顶点 𝑣 为起点的有向边的数目,记为 𝑂𝐷(𝑣)。在图 6.1(a) 中,顶点 2 的出度为 2、入度为 1。顶点 𝑣 的度等于其入度与出度之和,即 𝑇𝐷(𝑣)=𝐼𝐷(𝑣)+𝑂𝐷(𝑣)。有向图的全部顶点的入度之和与出度之和相等,并且等于边数,这是因为每条有向边都有一个起点和终点。
5. 路径、路径长度和回路
6. 简单路径、简单回路
在路径序列中,顶点不重复出现的路径称为简单路径。除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
7. 距离
从顶点 𝑢 出发到顶点 𝑣 的最短路径若存在,则此路径的长度称为从 𝑢 到 𝑣 的距离。若从 𝑢 到 𝑣 根本不存在路径,则记该距离为无穷 (∞)。
8. 子图
设有两个图 𝐺=(𝑉,𝐸) 和 𝐺′=(𝑉′,𝐸′),若 𝑉′ 是 𝑉 的子集,且 𝐸′ 是 𝐸 的子集,则称 𝐺′ 是 𝐺 的子图。若有满足 𝑉(𝐺′)=𝑉(𝐺) 的子图 𝐺′,则称其为 𝐺 的生成子图。图 6.1 中 𝐺3 为 𝐺1 的子图。
并非 𝑉′和 𝐸′ 的任何子集都能构成 𝐺 的子图,因为这样的子集可能不是图,即 𝐸′ 的子集中某些边关联的顶点可能不在这个 𝑉′ 的子集中。
9. 连通、连通图和连通分量(无向图)
在无向图中,若从顶点 𝑣 到顶点 𝑤 有路径存在,则称 𝑣 和 𝑤 是连通的。若图 𝐺 中任意两个顶点都是连通的,则称图 𝐺 为连通图,否则称为非连通图。无向图中的极大连通子图称为连通分量,在图 6.2(a) 中,图 𝐺4 有 3 个连通分量如图 6.2(b) 所示。假设一个图有 𝑛 个顶点,若边数小于 𝑛−1,则此图必是非连通图;
10. 强连通图、强连通分量(有向图)
在有向图中,若有一对顶点 𝑣 和 𝑤,从 𝑣 到 𝑤 和从 𝑤 到 𝑣 之间都有路径,则称这两个顶点是强连通的。若图中任意一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量,图 𝐺1 的强连通分量如图 6.3 所示。思考,假设一个有向图有 𝑛 个顶点,若
在无向图中讨论连通性,在有向图中讨论强连通性。
11. 生成树、生成森林
连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为 𝑛,则它的生成树含有 𝑛−1 条边。包含图中全部顶点的极小连通子图,只有生成树满足这个极小条件,对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。在非连通图中,连通分量的生成树构成了非连通图的生成森林。图 𝐺2 的一个生成树如图 6.4 所示。
区分极大连通子图和极小连通子图。极大连通子图要求子图必须连通,而且包含尽可能多的顶点和边;极小连通子图是既要保持子图连通又要使得边数最少的子图。
12. 边的权、网和带权路径长度
在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。这种边上带有权值的图称为带权图,也称网。路径上所有边的权值之和,称为该路径的带权路径长度。
13. 完全图(也称简单完全图)
对于无向图,∣𝐸∣ 的取值范围为 0 到 𝑛(𝑛−1)/2,有 𝑛(𝑛−1)/2 条边的无向图称为完全图,在完全图中任意两个顶点之间都存在边。
对于有向图,∣𝐸∣ 的取值范围为 0 到 𝑛(𝑛−1),有 𝑛(𝑛−1) 条弧的有向图称为有向完全图,在有向完全图中任意两个顶点之间都存在方向相反的两条弧。图 6.1 中 𝐺2为无向完全图,而 𝐺3 为有向完全图。
14. 稠密图、稀疏图
15. 有向树
一个顶点的入度为 0、其余顶点的入度均为 1 的有向图,称为有向树。
习题
图的存储及基本操作
图的存储必须要完整、准确地反映顶点集和边集的信息。根据不同图的结构和算法,采用不同的存储方式将对程序的效率产生相当大的影响,因此所选的存储结构应适合于待求解的问题。
邻接矩阵法
所谓邻接矩阵存储,是指用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(各顶点之间的邻接关系)。存储顶点之间邻接关系的二维数组称为邻接矩阵。
图的邻接矩阵存储表示法具有以下特点:
-
无向图的邻接矩阵一定是一个对称矩阵(并且唯一)。 因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。
- 对于无向图,邻接矩阵的第 𝑖 行(或第 𝑖 列)非零元素(或非无穷元素)的个数正好是顶点 𝑖 的度 𝑇𝐷(𝑣𝑖)。
- 对于有向图,邻接矩阵的第 𝑖 行非零元素(或非无穷元素)的个数正好是顶点 𝑖 的出度 𝑂𝐷(𝑣𝑖), 第 𝑖 列非零元素(或非无穷元素)的个数正好是顶点 𝑖 的入度 𝐼𝐷(𝑣𝑖)。
- 用邻接矩阵存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。
- 稠密图(边数较多的图)适合采用邻接矩阵的存储表示。
邻接表法
当一个图为稀疏图时,使用邻接矩阵法显然会浪费大量的存储空间,而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。
所谓邻接表,是指对图 𝐺 中的每个顶点 𝑣𝑖 建立一个单链表,第 𝑖 个单链表中的结点表示依附于顶点 𝑣𝑖 的边(对于有向图则是以顶点 𝑣𝑖 为尾的弧),这个单链表就称为顶点 𝑣𝑖 的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储,称为顶点表,所以在邻接表中存在两种结点:顶点表结点和边表结点,如图 6.6 所示。
-
顶点表结点
- data:存储顶点 𝑣𝑖 的相关信息。
- firstarc:指向第一条边的边表结点。
-
边表结点
- adjvex:存储与头结点顶点 𝑣𝑖 邻接的顶点编号。
- nextarc:指向下一跳边的边表结点。
- 顶点表结点由两个域组成:顶点域(data)存储顶点 𝑣𝑖 的相关信息,边表头指针域(firstarc)指向第一条边的边表结点。
- 边表结点至少由两个域组成:邻接点域(adjvex)存储与头结点顶点 𝑣𝑖 邻接的顶点编号,指针域(nextarc)指向下一跳边的边表结点。
特点
- 若 𝐺 为无向图,则所需的存储空间为 𝑂(∣𝑉∣+2∣𝐸∣); 若 𝐺 为有向图,则所需的存储空间为 𝑂(∣𝑉∣+∣𝐸∣)。前者的倍数 2 是因为在无向图中,每条边在邻接表中出现了两次。
-
对于稀疏图(边数较少的图),采用邻接表表示将极大地节省存储空间。
-
在邻接表中,给定一个顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为 𝑂(𝑛)。但是,若要确定给定的两个顶点间是否存在边,则在邻接矩阵中可以立刻查到,而在邻接表中则需要在相应结点对应的边表中查找另一结点,效率较低。
-
在无向图的邻接表中,求某个顶点的度只需计算其邻接表中的边表结点个数。在有向图的邻接表中,求某个顶点的出度只需计算其邻接表中的边表结点个数;但求某个顶点 𝑥 的入度则需遍历全部的邻接表,统计邻接点(adjvex)域为 𝑥 的边表结点个数。
-
图的邻接表表示并不唯一,因为在每个顶点对应的边表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。
十字链表
十字链表是有向图的一种链式存储结构。在十字链表中,有向图的每条弧用一个结点(弧结点)来表示,每个顶点也用一个结点(顶点结点)来表示。两种结点的结构如下所示。
弧结点
- tailvex:存放弧尾的编号。
- headvex:存放弧头的编号。
- hlink:指向弧头相同的下一条弧。
- tlink:指向弧尾相同的下一条弧。
- info:存放该弧的相关信息。
顶点结点
- data:存放该顶点的数据信息,如顶点名称。
- firstin:指向以该顶点为弧头的第一条弧。
- firstout:指向以该顶点为弧尾的第一条弧。
- 顶点结点之间是顺序存储的,弧结点省略了 info 域。
- 在十字链表中,既容易找到 𝑉𝑖 为尾的弧,也容易找到 𝑉𝑖 为头的弧,因而容易求得顶点的出度和入度。图的十字链表表示是不唯一的,但一个十字链表表示唯一确定一个图。
邻接多重表
邻接多重表是无向图的一种链式存储结构。在邻接表中,容易求得顶点和边的各种信息,但求两个顶点之间是否存在边而执行删除边等操作时,需要分别在两个顶点的边表中遍历,效率较低。与十字链表类似,在邻接多重表中,每条边用一个结点表示,其结构如下所示。
结构
- ivex:存放该边依附的两个顶点的编号。
- ilink:指向依附于顶点 ivex 的下一条边。
- jvex:存放该边依附的两个顶点的编号。
- jlink:指向依附于顶点 jvex 的下一条边。
- info:存放该边的相关信息。
每个顶点也用一个结点表示,它由如下所示的两个域组成。
顶点结点
- data:存放该顶点的相关信息。
- firstedge:指向依附于该顶点的第一条边。
- 在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,因为每条边依附于两个顶点,所以每个边结点同时链接在两个链表中。
- 对无向图而言,其邻接多重表和邻接表的差别仅在于,同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。
习题
图的遍历
图的遍历是指从图中的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次,且仅访问一次。注意到树是一种特殊的图,所以树的遍历实际上也可视为一种特殊的图的遍历。图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。
为避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已被访问过的顶点,为此可以设一个辅助数组 visited[]
来标记顶点是否被访问过。
图的遍历算法主要有两种:广度优先搜索和深度优先搜索。
广度优先搜索
Dijkstra 单源最短路径算法和 Prim 最小生成树算法也应用了类似的思想。
换句话说,广度优先搜索遍历图的过程是以 𝑣 为起点,由近至远依次访问和 𝑣 有路径相通且路径长度为 1, 2, … 的顶点。广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点。
BFS 算法的性能分析
无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列 𝑄,𝑛 个顶点均需入队一次,在最坏的情况下,空间复杂度为 𝑂(∣𝑉∣)。
BFS 算法求解单源最短路径问题
若图 𝐺=(𝑉,𝐸) 为非带权图,定义从顶点 𝑢 到顶点 𝑣 的最短路径 𝑑(𝑢,𝑣) 为从 𝑢 到 𝑣 的任何路径中最少的边数;若从 𝑢 到 𝑣 没有通路,则 𝑑(𝑢,𝑣)=∞。
使用 BFS,我们可以求解一个满足上述定义的非带权图的单源最短路径问题,这是由广度优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的。
广度优先生成树
在广度遍历的过程中,我们可以得到一棵遍历树,称为广度优先生成树,如图 6.12 所示。需要注意的是,同一个图的邻接矩阵存储表示是唯一的,所以其广度优先生成树也是唯一的;但因为邻接表存储表示不唯一,所以其广度优先生成树也不是唯一的。
深度优先搜索
图的邻接矩阵表示是唯一的,但对邻接表来说,若边的输入次序不同,则生成的邻接表也不同。因此,对同样一个图,基于邻接矩阵的遍历得到的 DFS 序列和 BFS 序列是唯一的,基于邻接表的遍历得到的 DFS 序列和 BFS 序列是不唯一的。
性能分析
DFS 算法是一个递归算法,需要借助一个递归工作栈,所以其空间复杂度为 𝑂(∣𝑉∣)。
深度优先的生成树和生成森林
与广度优先搜索一样,深度优先搜索也会产生一棵深度优先生成树。当然,这是有条件的,即对连通图调用 DFS 才能产生深度优先生成树,否则产生的将是深度优先生成森林,如图 6.13 所示。与 BFS 类似,基于邻接表存储的深度优先生成树是不唯一的。
图的遍历与图的连通性
图的遍历算法可以用来判断图的连通性。对于无向图来说,若无向图是连通的,则从任意一个结点出发,仅需一次遍历就能够访问图中的所有顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。对于有向图来说,若从初始顶点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
因此,在 BFSTraverse()
或 DFSTraverse()
中添加了第二个 for
循环,再选取初始点,继续进行遍历,以防止一次无法遍历图的所有顶点。对于无向图,上述两个函数调用 BFS(G, i)
或 DFS(G, i)
的次数等于该图的连通分量数;而对于有向图则不是这样,因为一个连通的有向图分为强连通的和非强连通的,它的连通子图也分为强连通分量和非强连通分量,非强连通分量一次调用 BFS(G, i)
或 DFS(G, i)
不一定能访问到该子图的所有顶点,如图 6.14 所示。
习题
最小生成树
一个连通图的生成树包含图的所有顶点,并且只含尽可能少的边。对于生成树来说,若砍去一条边,则会使生成树变成非连通图;若增加一条边,则会在图中形成一条回路。
对于一个带权连通无向图 𝐺,生成树不同,每棵树的权(树中所有边的权值之和)也可能不同。权值之和最小的那棵生成树称为 𝐺 的最小生成树(Minimum-Spanning-Tree, MST)。
- 不难看出,最小生成树具有如下性质:
- 最小生成树的边数为顶点数减 1。
- 虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的。
- 若图 𝐺 中存在权值相同的边,则 𝐺 的最小生成树可能不唯一,即最小生成树的树形不唯一。当图 𝐺 中的各边权值互不相等时,𝐺 的最小生成树是唯一的;若无向连通图 𝐺 的边数比顶点数少 1,即 𝐺 本身是一棵树时,则 𝐺 的最小生成树就是它本身。
- 构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:假设 𝐺=(𝑉,𝐸) 是一个带权连通无向图,𝑈 是顶点集 𝑉 的一个非空子集。若 (𝑢,𝑣) 是一条具有最小权值的边,其中 𝑢∈𝑈,𝑣∈𝑉−𝑈,则必存在一棵包含边 (𝑢,𝑣) 的最小生成树。
基于该性质的最小生成树算法主要有 Prim 算法和 Kruskal 算法,它们都基于贪心算法的策略。对这两种算法应主要掌握算法的本质含义和基本思想,并能动手模拟算法的实现步骤。
Prim 算法
Prim(普里姆)算法的执行非常类似于寻找图的最短路径的 Dijkstra 算法(见下一节)。
Prim 算法构造最小生成树的过程如图 6.15 所示。初始时从图中任取一顶点(如顶点 1)加入树 𝑇,此时树中只含有一个顶点,之后选择一个与当前 𝑇T 中顶点集合距离最近的顶点,并将该顶点和相应的边加入 𝑇,每次操作后 𝑇 中的顶点数和边数都增 1。以此类推,直至图中所有的顶点都并入 𝑇,得到的 𝑇 就是最小生成树。此时 𝑇 中必然有 𝑛−1 条边。
Kruskal 算法
与 Prim 算法从顶点开始扩展最小生成树不同,Kruskal 算法是一种按权值的递增顺序选择合适的边来构造最小生成树的方法。
Kruskal 算法构造最小生成树的过程如图 6.16 所示。初始时为只有 𝑛 个顶点而无边的非连通图 𝑇=(𝑉,{})T=(V,{}),每个顶点自成一个连通分量。然后按照边的权值由小到大的顺序,不断选取当前未被选取且权值最小的边,若该边依附的顶点落在 𝑇 中不同的连通分量上(使用并查集判断这两个顶点是否属于同一棵集合树),则将此边加入 𝑇,否则舍弃此边而选择下一条权值最小的边。以此类推,直至 𝑇T 中所有顶点都在一个连通分量上。
最短路径
广度优先搜索查找最短路径只是对无权图而言的。当图是带权图时,把从一个顶点 𝑣0 到图中其余任意一个顶点 𝑣𝑖 的一条路径所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径长度最短的那条路径(可能不止一条)称为最短路径。
求解最短路径的算法通常都依赖于一种性质,即两点之间的最短路径也包含了路径上其他顶点间的最短路径。带有向图 𝐺 的最短路径问题一般可分为两类:
一是单源最短路径,即求图中某一顶点到其他各顶点的最短路径,可通过经典的 Dijkstra(迪杰斯特拉)算法求解;
二是求每对顶点间的最短路径,可通过 Floyd(弗洛伊德)算法来求解。
Dijkstra 算法求单源最短路径问题
Dijkstra 算法设置一个集合 𝑆 记录已求得的最短路径的顶点,初始时把源点 𝑣0 放入 𝑆,集合 𝑆 每并入一个新顶点 𝑣𝑖,都要修改源点 𝑣0 到集合 𝑉−𝑆 中顶点当前的最短路径长度值
Floyd 算法求各顶点之间最短路径问题
有向无环图
拓扑排序
AOV 网
若用有向无环图表示一个工程,其顶点表示活动,用有向边 <𝑉𝑖,𝑉𝑗> 表示活动 𝑉𝑖 必须先于活动 𝑉𝑗进行的这样一种关系,则将这种有向图称为顶点表示活动的网络,简称 AOV 网。在 AOV 网中,活动 𝑉𝑖 是活动 𝑉𝑗的直接前驱,𝑉𝑗 是 𝑉𝑖 的直接后继,这种前驱和后继关系具有传递性,且任何活动 𝑉𝑖 不能以它自己作为自己的前驱或后继。
拓扑排序
在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
- 每个顶点出现且只出现一次。
- 若顶点 𝐴 在序列中排在顶点 𝐵 的前面,则在图中不存在从 𝐵 到 𝐴 的路径。
或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点 𝐴 到顶点 𝐵 的路径,则在排序中 𝐵 出现在 𝐴 的后面。每个 AOV 网都有一个或多个拓扑排序序列。
对一个 AOV 网进行拓扑排序的算法有很多,下面介绍比较常用的一种方法的步骤:
- 从 AOV 网中选择一个没有前驱(入度为 0)的顶点并输出。
- 从网中删除该顶点和所有以它为起点的有向边。
- 重复 1 和 2 直到当前的 AOV 网为空或当前网中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环。
此外,利用上一节的深度优先遍历也可以实现拓扑排序,下面简单描述其思路,具体代码见本节后的习题。对于有向无环图 𝐺 中的任意结点 𝑢,𝑣,它们之间的关系必然是下列三种之一:
- 若 𝑢 是 𝑣 的祖先,则在调用 DFS 访问 𝑢 之前,必然已对 𝑣 进行了 DFS 访问,即 𝑣 的 DFS 结束时间先于 𝑢 的 DFS 结束时间。从而可考虑在 DFS 函数中设置一个时间标记,在 DFS 调用结束时,对各项点计时。因此,祖先的结束时间必然大于子孙的结束时间。
- 若 𝑢 是 𝑣 的子孙,则 𝑣 为 𝑢的祖先,按上述思路,𝑣 的结束时间大于 𝑢 的结束时间。
- 若 𝑢 和 𝑣 没有路径关系,则 𝑢 和 𝑣 在拓扑排序的关系任意。
于是,按结束时间从大到小排列,就可以得到一个拓扑排序序列。
对一个 AOV 网,若采用下列步骤进行排序,则称之为逆拓扑排序:
- 从 AOV 网中选择一个没有后继(出度为 0)的顶点并输出。
- 从网中删除该顶点和所有以它为终点的有向边。
- 重复 1 和 2 直到当前的 AOV 网为空。
用拓扑排序算法处理 AOV 网时,应注意以下问题:
入度为零的顶点,即没有前驱活动的或前驱活动都已经完成的顶点,工程可以从这个顶点所代表的活动开始或继续。
拓扑排序的结果可能不唯一。不少人误认为 AOV 网的各项点为线性序列是拓扑排序唯一的充要条件,而它其实只是充分非必要条件。拓扑排序是否唯一的判断条件是在每次输出顶点时,检测入度为 0 的顶点是否唯一,若每次都唯一,则说明拓扑排序唯一。
AOV 网中各项点的地位平等,每个顶点编号是人为的,因此可以按拓扑排序的结果重新编号,生成 AOV 网的新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵;但对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑排序;反之则不一定成立。
关键路径
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称 AOE 网。AOE 网和 AOV 网都是有向无环图,不同之处在于它们的边和顶点所代表的含义是不同的,AOE 网中的边有权值;而 AOV 网中的边无权值,仅表示顶点之间的前后关系。
AOE 网具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
在 AOE 网中仅有一个入度为 0 的顶点,称为开始顶点(源点),它表示整个工程的开始;也仅有一个出度为 0 的顶点,称为结束顶点(汇点),它表示整个工程的结束。
在 AOE 网中,有些活动是可以并行进行的。从源点到汇点的有向路径可能有多条,并且这些路径长度可能不同。完成不同路径上的活动所需的时间虽然不同,但是只有所有路径上的活动都已完成,整个工程才能算结束。因此,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。
完成整个工程的最短时间就是关键路径的长度,即关键路径上各活动花费开销的总和。这是因为关键活动影响了整个工程的时间,即若关键活动不能按时完成,则整个工程的完成时间就会延长。因此,只要找到了关键活动,就找到了关键路径,也就可以得出最短完成时间。
下面给出在寻找关键活动时所用到的几个参量的定义。
-
关键路径上的所有活动都是关键活动:它是决定整个工程的关键因素,因此可以通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动就可能会变成非关键活动。
-
网中的关键路径并不唯一:且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。
习题
、】、、、、、
查找
-
静态查找表。若一个查找表的操作只涉及查找操作,则无须动态地修改查找表,此类查找表称为静态查找表。与此对应,需要动态地插入或删除的查找表称为动态查找表。适合静态查找表的查找方法有顺序查找、折半查找、散列查找等;适合动态查找表的查找方法有二叉排序树的查找、散列查找等。
-
关键字。数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。例如,在由一个学生元素构成的数据集合中,学生元素中“学号”这一数据项的值唯一地标识一名学生。
顺序查找
顺序查找也称线性查找,它对顺序表和链表都是适用的。对于顺序表,可通过数组下标递增来顺序扫描每个元素;对于链表,可通过指针 next
来依次扫描每个元素。顺序查找通常分为对一般的无序线性表的顺序查找和对按关键字有序的线性表的顺序查找。下面分别进行讨论。
一般线性表的顺序查找
作为一种最直观的查找方法,其基本思想:
- 从线性表的一端开始,逐个检查关键字是否满足给定的条件;
- 若查找到某个元素的关键字满足给定条件,则查找成功,返回该元素在线性表中的位置;
- 若已经查找到表的另一端,但还没有查找到符合给定条件的元素,则返回查找失败的信息。
下面给出其算法,后面说明了算法中引入的“哨兵”的作用。
通常,查找表中记录的查找概率并不相等。若能预先得知每个记录的查找概率,则应先对记录的查找概率进行排序,使表中记录按查找概率由小至大重新排列。
综上所述,顺序查找的缺点是当 n
较大时,平均查找长度较大,效率低;优点是对数据元素的存储没有要求,顺序存储或链式存储皆可。对表中记录的有序性也没有要求,无论记录是否按关键字有序,均可应用。同时还需注意,对链表只能进行顺序查找。
有序线性表的顺序查找
若在查找之前就已知表是关键字有序的,则查找失败时可以不用再比较到表的另一端就能返回查找失败的信息,从而降低查找失败的平均查找长度。假设表 𝐿 是按关键字从小到大排列的,查找的顺序是从前往后,待查找元素的关键字为 key
,当查找到第 𝑖i 个元素时,发现第 𝑖i 个元素的关键字小于 key
,但第 𝑖+1i+1 个元素的关键字大于 key
,这时就可返回查找失败的信息,因为第 𝑖i 个元素之后的元素的关键字均大于 key
,所以表中不存在关键字为 key
的元素。
可以用如图 7.1 所示的判定树来描述有序线性表的查找过程。树中的圆形结点表示有序线性表中存在的元素;矩形结点称为失败结点(若有 𝑛n 个结点,则相应地有 𝑛+1n+1 个查找失败结点),它描述的是那些不在表中的数据值的集合。若查找到矩形结点,则说明查找失败。
在有序线性表的顺序查找中,查找成功的平均查找长度和一般线性表的顺序查找一样。查找失败时,查找指针一定走到了某个失败结点。这些失败结点是我们虚构的空结点,实际上是不存在的,所以到达失败结点时所查找的长度等于它上面的一个圆形结点的所在层数。查找不成功的平均查找长度在相等查找概率的情形下为
注意,有序线性表的顺序查找和后面的折半查找的思想是不一样的,且有序线性表的顺序查找中的线性表可以是链式存储结构,而折半查找中的线性表只能是顺序存储结构。
折半查找(仅适用于有序的顺序表)
折半查找的基本思想:
- 首先将给定值
key
与表中中间位置的元素比较,若相等,则查找成功,返回该元素的存储位置; - 若不等,则所需查找的元素只能在中间元素以外的前半部分或后半部分(例如,在查找表升序排列时,若
key
大于中间元素,则所查找的元素只可能在后半部分),然后在缩小的范围内继续进行同样的查找。重复上述步骤,直到找到为止,或确定表中没有所需要查找的元素,则查找不成功,返回查找失败的信息。
折半查找的过程可用图 7.2 所示的二叉树来描述,称为判定树。树中每个圆形结点表示一个记录,结点中的值为该记录的关键字值;树中最下面的叶结点都是方形的,它表示查找失败的区间。从判定树可以看出,查找成功时的查找长度为从根结点到目的结点的路径上的结点数,而查找失败时的查找长度为从根结点到对应失败结点的父结点的路径上的结点数;每个结点值均大于其左子结点值,且均小于其右子结点值。若有有序序列有 𝑛 个元素,则对应的判定树有 𝑛 个圆形的非叶结点和 𝑛+1 个方形的叶结点。显然,判定树是一棵平衡二叉树。
因为折半查找需要方便地定位查找区域,所以它要求线性表必须具有随机存取的特性。因此,该查找法仅适合于顺序存储结构,不适合于链式存储结构,且要求元素按关键字有序排列。
分块查找
分块查找也称索引顺序查找,它吸取了顺序查找和折半查找各自的优点,既有动态结构,又适于快速查找。
分块查找的基本思想:将查找表分为若干子块。块内的元素可以无序,但块间的元素是有序的,即第一个块中的最大关键字小于第二个块中的所有记录的关键字,第二个块中的最大关键字小于第三个块中的所有记录的关键字,以此类推。再建立一个索引表,索引表中的每个元素含有各块的最大关键字和各块中的第一个元素的地址,索引表按关键字有序排列。
分块查找的过程分为两步:第一步是在索引表中确定待查记录所在的块,可以顺序查找或折半查找索引表;第二步是在块内顺序查找。
例如,关键码集合为 {88, 24, 72, 61, 21, 6, 32, 11, 8, 31, 22, 83, 78, 54},按照关键码值 24, 54, 78, 88,分为 4 个块和索引表,如图 7.3 所示。
习题
树形查找
二叉排序树(BST)
构造一棵二叉排序树的目的并不是排序,而是提高查找、插入和删除关键字的速度,二叉排序树这种非线性结构也有利于插入和删除的实现。
定义
二叉排序树(也称二叉查找树)或者是一棵空树,或者是具有下列特性的二叉树:
- 若左子树非空,则左子树上所有结点的值均小于根结点的值。
- 若右子树非空,则右子树上所有结点的值均大于根结点的值。
- 左、右子树也分别是一棵二叉排序树。
根据二叉排序树的定义,左子树结点值 < 根结点值 < 右子树结点值,因此对二叉排序树进行中序遍历,可以得到一个递增的有序序列。例如,图 7.4 所示二叉排序树的中序遍历序列为 123468。
二叉排序树的查找
二叉排序树的查找是从根结点开始,沿某个分支逐层向下比较的过程。若二叉排序树非空,先将给定值与根结点的关键字比较,若相等,则查找成功;若不等,若小于根结点的关键字,则在根结点的左子树上查找,否则在根结点的右子树上查找。这显然是一个递归的过程。
二叉排序树的非递归查找算法:
例如,在图 7.4 中查找值为 4 的结点。首先 4 与根结点 6 比较。因为 4 小于 6,所以在根结点 6 的左子树中继续查找。因为 4 大于 2,所以在结点 2 的右子树中查找,查找成功。
同样,二叉排序树的查找也可用递归算法实现,递归算法比较简单,但执行效率较低。具体的代码实现,留给读者思考。
二叉排序树的插入
二叉排序树作为一种动态线性表,其特点是树的结构通常不是一次生成的,而是在查找过程中,当树中不存在关键字值等于给定值的结点时再进行插入的。
插入结点的过程如下:若原二叉排序树为空,则直接插入;否则,若关键字 k 小于根结点值,则插入到左子树,若关键字 k 大于根结点值,则插入到右子树。新插入的结点一定是一个叶结点,且是查找失败时的查找路径上访问的最后一个结点的左孩子或右孩子。如图 7.5 所示在一棵二叉排序树中依次插入结点 28 和结点 58,虚线表示的边是其查找的路径。
二叉排序树的删除
在二叉排序树中删除一个结点时,不能把以该结点为根的子树上的结点都删除,必须先把被删除结点从存储二叉排序树的链表上摘下,将因删除结点而断开的二叉链表重新链接起来,同时确保二叉排序树的性质不会丢失。删除操作的实现过程按3种情况来处理:
- 若被删除结点 𝑧 是叶结点,则直接删除,不会破坏二叉排序树的性质。
- 若结点 𝑧 只有一棵左子树或右子树,则让 𝑧 的子树成为 𝑧 父结点的子树,替代 𝑧 的位置。
- 若结点 𝑧 有左、右两棵子树,则令 𝑧 的直接后继(或直接前驱)替代 𝑧,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
图 7.7 显示了在3种情况下分别删除结点 45, 78, 78 的过程。
二叉排序树的查找效率
在等概率情况下,图7.8(a)查找成功的平均查找长度为
ASL𝑎=(1+2×2+3×4+4×3)/10=2.9
而图7.8(b)查找成功的平均查找长度为
ASL𝑏=(1+2+3+4+5+6+7+8+9+10)/10=5.5
从查找过程看,二叉排序树与二分查找相似。就平均时间性能而言,二叉排序树上的查找和二分查找差不多。但二分查找的判定树唯一,而二叉排序树的查找不唯一,相同的关键字其插入顺序不同可能生成不同的二叉排序树,如图7.8所示。
平衡二叉树
定义
为了避免树的高度增长过快,降低二叉排序树的性能,规定在插入和删除结点时,要保证任意结点的左、右子树高度差的绝对值不超过1,将这样的二叉树称为平衡二叉树(Balanced Binary Tree),也称 AVL 树。定义结点左子树与右子树的高度差为该结点的平衡因子,则平衡二叉树结点的平衡因子的值只可能是-1、0 或 1。
因此,平衡二叉树可定义为或是一棵空树,或是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的高度差的绝对值不超过1。图7.9(a)所示是平衡二叉树,图7.9(b)所示是不平衡的二叉树。结点中的数字为该结点的平衡因子。
插入
二叉排序树保证平衡的基本思想如下:每当在二叉排序树中插入(或删除)一个结点时,首先检查其插入路径上的结点是否因为此次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A,再对以A为根的子树,在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。
注意,每次调整的对象都是最小不平衡子树,即以插入路径上离插入结点最近的平衡因子的绝对值大于1的结点作为根的子树。图7.10中的虚线框内为最小不平衡子树。
平衡二叉树的插入过程的前半部分与二叉排序树相同,但在新结点插入后,若造成查找路径上的某个结点不再平衡,则需要做出相应的调整。可将调整的规律归纳为下列4种情况:
-
LL 平衡旋转(右单旋转)。由于在结点 𝐴 的左孩子(L)的左子树(L)上插入了新结点, 𝐴 的平衡因子由 1 增至 2,导致以 𝐴 为根的子树失去平衡,需要一次向右的旋转操作。将 𝐴 的左孩子 𝐵 向右上旋转代替 𝐴 成为根结点,将 𝐴 向右下旋转成为 𝐵 的右孩子,而 𝐵 的原右子树则作为 𝐴 的左子树。如图7.11所示,结点旁的数值代表结点的平衡因子,而用方块表示相应结点的子树,下方数值代表该子树的高度。
- (a) 插入结点前
- (b) 插入结点导致不平衡
- (c) LL旋转(右单旋转)
-
RR 平衡旋转(左单旋转)。由于在结点 𝐴 的右孩子(R)的右子树(R)上插入了新结点, 𝐴 的平衡因子由 -1 减至 -2,导致以 𝐴 为根的子树失去平衡,需要一次向左的旋转操作。将 𝐴 的右孩子 𝐵 向左上旋转代替 𝐴 成为根结点,将 𝐴 向左下旋转成为 𝐵 的左孩子,而 𝐵 的原左子树则作为 𝐴 的右子树,如图7.12所示。
- (a) 插入结点前
- (b) 插入结点导致不平衡
- (c) RR旋转(左单旋转)
-
LR 平衡旋转(先左后右双旋转)
-
由于在结点 𝐴 的左孩子(L)的右子树(R)上插入新结点, 𝐴 的平衡因子由 1 增至 2,导致以 𝐴 为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将 𝐴 的左孩子 𝐵 的右子树的根结点 𝐶 向左上旋转提升到 𝐵 的位置,然后把 𝐶 向右上旋转提升到 𝐴 的位置
RL 平衡旋转(先右后左双旋转)
由于在结点 𝐴 的右孩子(R)的左子树(L)上插入新结点,𝐴 的平衡因子由 -1 减至 -2,导致以 𝐴 为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将 𝐴 的右孩子 𝐵 的左子树的根结点 𝐶 向右上旋转提升到 𝐵 的位置,然后把 𝐶 向左上旋转提升到 𝐴 的位置,如图7.14所示。
(a) 插入结点前 (b) 插入结点导致不平衡 (c) RL旋转(双旋转)
- LR 和 RL 旋转时,新结点究竟是插入 𝐶 的左子树还是插入 𝐶 的右子树不影响旋转过程,而图7.13和图7.14中以插入 𝐶 的左子树为例。
- 以关键字序列 {15, 3, 7, 10, 9, 8} 构造一棵平衡二叉树的过程为例,图7.15(d) 插入 7 后导致不平衡,最小不平衡子树的根为 15,插入位置为其左孩子的右子树,所以执行 LR 旋转,先左后右双旋转,调整后的结果如图7.15(e) 所示。
- 图7.15(g) 插入 9 后导致不平衡,最小不平衡子树的根为 15,插入位置为其左孩子的左子树,所以执行 LL 旋转,右单旋转,调整后的结果如图7.15(h) 所示。
- 图7.15(i) 插入 8 后导致不平衡,最小不平衡子树的根为 7,插入位置为其右孩子的左子树,所以执行 RL 旋转,先右后左双旋转,调整后的结果如图7.15(j) 所示。
删除
与平衡二叉树的插入操作类似,以删除结点 𝑤 为例来说明平衡二叉树删除操作的步骤:
-
用二叉排序树的方法对结点 𝑤 执行删除操作。
-
若导致了不平衡,则从结点 𝑤 开始向上回溯,找到第一个不平衡的结点 𝑧(最小不平衡子树);𝑦 为结点 𝑧 的高度最高的孩子;𝑥 是结点 𝑦 的高度最高的孩子。
然后对以 𝑧 为根的子树进行平衡调整,其中 𝑥、𝑦 和 𝑧 可能的位置有4种情况:
- 𝑦y 是 𝑧 的左孩子,𝑥x 是 𝑦y 的左孩子(LL,右单旋转);
- 𝑦y 是 𝑧z 的左孩子,𝑥x 是 𝑦y 的右孩子(LR,先左后右双旋转);
- 𝑦y 是 𝑧z 的右孩子,𝑥x 是 𝑦y 的右孩子(RR,左单旋转);
- 𝑦y 是 𝑧z 的右孩子,𝑥x 是 𝑦y 的左孩子(RL,先右后左双旋转)。
这四种情况与插入操作的调整方式一样。不同之处在于,插入操作仅需要对以 𝑧 为根的子树进行平衡调整;而删除操作就不一样,先对以 𝑧 为根的子树进行平衡调整,若调整后子树的高度减1,则可能需要对 𝑧 的祖先结点进行平衡调整,甚至回溯到根结点(导致树高减1)。
以删除图7.16(a)的结点32为例,由于32为叶结点,直接删除即可,向上回溯找到第一个不平衡结点44(𝑧z),𝑧z 的高度最高的孩子结点为78(𝑦y),𝑦y 的高度最高的孩子结点为50(𝑥x),满足RL情况,先右后左双旋转,调整后的结果如图7.16(c)所示。
查找
红黑树
为了保持 AVL 树的平衡性,在插入和删除操作后,会非常频繁地调整全树整体拓扑结构,代价较大。为此在 AVL 树的平衡标准上进一步放宽条件,引入了红黑树的结构。
一棵红黑树是满足如下红黑性质的二叉排序树:
- 每个结点或是红色,或是黑色的。
- 根结点是黑色的。
- 叶结点(虚线的外部结点、NULL 结点)都是黑色的。
- 不存在两个相邻的红结点(红结点的父结点和孩子结点均是黑色的)。
- 对每个结点,从该结点到任意一个叶结点的简单路径上,所含黑结点的数量相同。
与折半查找树和 B 树类似,为了便于对红黑树的实现和理解,引入了 𝑛+1 个外部叶结点,以保证红黑树中每个结点(内部结点)的左、右孩子均非空。图 7.18 所示是一棵红黑树。
从某结点出发(不含该结点)到达一个叶结点的任意一个简单路径上的黑结点总数称为该结点的黑高(记为 bh),黑高的概念是由性质⑤确定的。根结点的黑高称为红黑树的黑高。
习题
B 树及其基本操作
所谓 m 阶 B 树是所有结点的平衡因子均等于 0 的 m 路平衡查找树。
一棵 m 阶 B 树或为空树,或为满足如下特性的 m 叉树:
- 树中每个结点至多有 m 棵子树,即至多有 m-1 个关键字。
- 若根结点不是叶结点,则至少有 2 棵子树,即至少有 1 个关键字。
- 所有非叶结点的结构如下:
- 所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找失败的结点,实际上这些结点并不存在,指向这些结点的指针为空)。
图 7.28 所示为一棵 5 阶 B 树,可以借助该实例来分析上述性质:
- 结点的孩子个数等于该结点中关键字个数加 1。
- 若根结点没有关键字就没有子树,则此时 B 树为空;若根结点有关键字,则其子树个数必然大于或等于 2,因为子树个数等于关键字个数加 1。
- 结点中的关键字从左到右递增有序,关键字两侧均有指向子树的指针,左侧指针所指子树的所有关键字均小于该关键字,右侧指针所指子树的所有关键字均大于该关键字。或者看成下层结点的关键字总是落在由上层结点的关键字所划分的区间内,如第二层最左结点的关键字划成了 3 个区间:(-∞, 5), (5, 11), (11, +∞),该结点中的 3 个指针所指子树的关键字均分别落在这 3 个区间内。
- 所有的叶结点都出现在同一层次上,并且不带任何信息
- 除根节点外的所有非叶结点至少有
棵子树,即至少含有
个关键字。
- 树中每个结点至多有m棵子树,即至多含有m-1个关键字。
B 树的查找
在 B 树上进行查找与二叉排序树很相似,只是每个结点都是多个关键字的有序表,在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。
B 树的查找包含两个基本操作:
- 在 B 树中找结点;
- 在结点内找关键字。B 树常存储在磁盘上,因此前一查找操作是在磁盘上进行的,而后一查找操作是在内存中进行的,即在磁盘上找到目标结点后,先将结点信息读入内存,然后再采用顺序查找法或折半查找法。因此,在磁盘上进行查找的次数即目标结点在 B 树上的层次数,决定了 B 树的查找效率。
在 B 树上查找到某个结点后,先在有序表中进行查找,若找到则查找成功,否则按照对应的指针信息到所指的子树中去查找(例如,在图 7.28 中查找关键字 42,首先从根结点开始,根结点只有一个关键字,且 42 > 22,若存在,必在关键字 22 的右边子树上,右孩子结点有两个关键字,而 36 < 42 < 45,则若存在,必在 36 和 45 中间的子树上,在该子结点中查到关键字 42,查找成功)。查找到叶结点时(对应指针为空),则说明树中没有对应的关键字,查找失败。
B 树的插入
与二叉排序树的插入操作相比,B 树的插入操作要复杂得多。在 B 树中查找到插入的位置后,并不能简单地将其添加到终端结点(最底层的非叶结点)中,因为此时可能会导致整棵树不再满足 B 树定义中的要求。将关键字 key 插入 B 树的过程如下:
-
定位。利用前述的 B 树查找算法,找出插入该关键字的终端结点(在 B 树中查找 key 时,会找到表示查找失败的叶结点,因此插入位置一定是最底层的非叶结点)。
-
插入。每个非根结点的关键字个数都在 [𝑚/2]−1,𝑚−1 之间。若结点插入后的关键字个数小于 𝑚,可以直接插入 若结点插入后的关键字个数大于 𝑚−1,必须对结点进行分裂。
分裂的方法是:取一个新结点,在插入 key 后的原结点中,从中间位置 ⌈𝑚/2⌉ 将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置 ⌈𝑚/2⌉ 的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致 B 树高度增 1。
对于 𝑚=3 的 B 树,所有结点中最多有 𝑚−1=2个关键字,若某结点中已有两个关键字,则结点已满,如图 7.29(a) 所示。插入一个关键字 60 后,结点内的关键字个数超过了 𝑚−1,如图 7.29(b) 所示,此时必须进行结点分裂,分裂的结果如图 7.29(c) 所示。
习题
散列(Hash)表
散列函数的构造方法
在构造散列函数时,必须注意以下几点:
- 散列函数的定义域必须包含全部关键字,而值域的范围则依赖于散列表的大小。
- 散列函数计算出的地址应尽可能均匀地分布在整个地址空间,尽可能地减少冲突。
- 散列函数应尽量简单,能在较短的时间内计算出任一关键字对应的散列地址。
下面介绍常用的散列函数。
-
直接定址法 直接取关键字的某个线性函数值为散列地址,散列函数为
式中,𝑎 和 𝑏是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
-
除留余数法 这是一种最简单、最常用的方法,假定散列表表长为 𝑚,取一个不大于 𝑚 但最接近或等于 𝑚 的质数 𝑝,利用以下公式把关键字转换成散列地址。散列函数为
𝐻(𝑘𝑒𝑦)=𝑘𝑒𝑦%𝑝
除留余数法的关键是选好 𝑝,使得每个关键字通过该函数转换后等概率地映射到散列空间上的任意一个地址,从而尽可能减少冲突的可能性。
-
数字分析法 设关键字是 𝑟 进制数(如十进制数),而 𝑟 个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时应选取数码分布较为均匀的若干位作为散列地址。这种方法适用于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
-
平方取中法 顾名思义,这种方法取关键字的平方值的中间几位作为散列地址。具体取多少位要视实际情况而定。这种情况下,不同的散列函数具有不同的性能,因此不能笼统地说哪种散列函数最好。在实际选择中,采用何种构造散列函数的方法取决于关键字集合的情况。
处理冲突的方法
采用开放定址法时,不能随便物理删除表中已有元素,否则会截断其他同义词元素的查找路径,删除元素时可以做一个删除标记,进行逻辑删除。具体举例见本书配套课程。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用。
2. 拉链法(链接法,chaining)
显然,对于不同的关键字可能会通过散列函数映射到同一地址。为了避免非同义词发生冲突,可以把所有的同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。假设散列地址为 𝑖 的同义词链表的头指针存放在散列表的第 𝑖 个单元中,因而查找、插入和删除操作主要在同义词链中进行。拉链法适用于经常进行插入和删除的情况。
例如,关键字序列为 {19,14,23,01,68,20,84,27,55,11,10,79}{19,14,23,01,68,20,84,27,55,11,10,79},散列函数 𝐻(𝑘𝑒𝑦)=𝑘𝑒𝑦%13H(key)=key%13,用拉链法处理冲突,建立的表如图 7.33 所示
散列表查找及性能分析的应用
散列表的查找过程与构造散列表的过程基本一致。对于一个给定的关键字 key,根据散列函数可以计算出其散列地址,执行步骤如下:
初始化:Addr = Hash(key);
- 检测查找表中地址为 Addr 的位置上是否有记录,若无记录,返回查找失败;若有记录,比较它与 key 的值,若相等,则返回查找成功标志,否则执行步骤②。
- 用给定的处理冲突方法计算“下一个散列地址”,并把 Addr 置为此地址,转入步骤①。
例如,关键字序列 {19, 14, 23, 01, 68, 20, 84, 27, 55, 11, 10, 79} 按散列函数 H(key) = key % 13 和线性探测处理冲突构造所得的散列表 L 如图 7.34 所示。
![]() |
---|
给定值 84 的查找过程为:首先求得散列地址 H(84) = 6,因 L[6] 不空且 L[6] ≠ 84,则找第一次冲突处理后的地址 H1 = (6 + 1) % 16 = 7,而 L[7] 不空且 L[7] ≠ 84,则找第二次冲突处理后的地址 H2 = (6 + 2) % 16 = 8,L[8] 不空且 L[8] = 84,查找成功,返回记录在表中的序号 8。
给定值 38 的查找过程为:先求散列地址 H(38) = 12,L[12] 不空且 L[12] ≠ 38,则找下一地址 H1 = (12 + 1) % 16 = 13,因为 L[13] 是空记录,所以表中不存在关键字为 38 的记录。
查找各关键字的比较次数如图 7.35 所示。
平均查找长度 ASL 为 ASL=(1×6+2+3×3+4+9)12=2.5ASL=12(1×6+2+3×3+4+9)=2.5
对同一组关键字,设定相同的散列函数,则不同的处理冲突的方法得到的散列表不同,它们的平均查找长度也不同。
从散列表的查找过程可见:
- 以平均查找长度作为衡量散列表的查找效率的度量。
- 散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子。
装填因子。散列表的装填因子一般记为 𝛼,定义为一个表的装满程度,即
散列表的平均查找长度依赖于散列表的装填因子 𝛼,而不直接依赖于 𝑛 或 𝑚。直观地看,𝛼 越大,表示装填的记录越“满”,发生冲突的可能性越大;反之发生冲突的可能性越小。
习题
排序
基本概念
算法的稳定性。若待排序表中有两个元素Ri和Rj,其对应的关键字相同,即key_i = key_j,且在排序前Ri在Rj的前面,若使用某一排序算法排序后,Ri仍然在Rj的前面,则称这个排序算法是稳定的,否则称这个排序算法是不稳定的。
算法是否具有稳定性并不能衡量一个算法的优劣,它主要是对算法的性质进行描述。
对于不稳定的排序算法,只需举出一组关键字的实例,说明它的不稳定性即可。
在排序过程中,根据数据元素是否完全存放在内存中,可将排序算法分为两类:
- 内部排序,是指在排序期间元素全部存放在内存中的排序;
- 外部排序,是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。
一般情况下,内部排序算法在执行过程中都要进行两种操作:比较和移动。通过比较两个关键字的大小,确定对应元素的前后关系,然后通过移动元素以达到有序。当然,并非所有的内部排序算法都要基于比较操作(基数排序就不基于比较操作)。
习题
插入排序
插入排序是一种简单直观的排序算法,其基本思想是每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序。
直接插入排序
根据上面的插入排序思想,不难得出一种最简单也最直观的直接插入排序算法。假设在排序过程中,待排序表 𝐿[1...𝑛] 在某次排序过程中的某一时刻状态如下:
折半插入排序
从直接插入排序算法中,不难看出每趟插入的过程中都进行了两项工作:①从前的有序子表中查找出待插入元素应该被插入的位置;②给插入位置腾出空间,将待插入元素复制到表中的插入位置。注意到在该算法中,总是边比较边移动元素。下面将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。当排序表为顺序表时,可以对直接插入排序算法做如下改进:因为是顺序存储的线性表,所以查找有序子表时可以用折半查找来实现。确定待插入位置后,就可统一地向后移动元素。
希尔排序(缩小增量排序)
希尔排序的基本思想是:先将待排序表分割成若干形如 𝐿[𝑖,𝑖+𝑑,𝑖+2𝑑,⋯ ,𝑖+𝑘𝑑]L[i,i+d,i+2d,⋯,i+kd] 的“特殊”子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。
希尔排序的过程如下:先取一个小于 𝑛 的增量 𝑑1,把表中的全部记录分成 𝑑1组,所有距离为 𝑑1 的倍数的记录放在同一组,在各组内进行直接插入排序;然后取第二个增量 𝑑2<𝑑1,重复上述过程,直到所取到的 𝑑𝑖=1,即所有记录已放在同一组中,再进行直接插入排序,此时已经具有较好的局部有序性,因此可以很快得到最终结果。到目前为止,尚未求得一个好的增量序列。仍以 8.2.1 节的关键字为例,假定第一趟取增量 𝑑1=5,将该序列分成 5 个子序列,即图中第 2 行至第 6 行,分别对各子序列进行直接插入排序,结果如第 7 行所示;假定第二趟取增量 𝑑2=3,分别对三个子序列进行直接插入排序,结果如第 11 行所示;最后对整个序列进行一趟直接插入排序,整个排序过程如图 8.2 所示。
习题
交换排序
所谓交换,是指根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
基于交换的排序算法很多,本书主要介绍冒泡排序和快速排序,其中冒泡排序算法比较简单,一般很少直接考查,通常会重点考查快速排序算法的相关内容。
冒泡排序
冒泡排序的基本思想是:从后往前(或从前往后)两两比较相邻元素的值,若为逆序(A[i-1] > A[i]),则交换它们,直到序列比较完。我们称它为第一趟冒泡,结果是将最小的元素交换到待排序列的第一个位置(或将最大的元素交换到待排序列的最后一个位置)。关键字最小的元素如气泡一般逐渐往上“漂浮”至“水面”(或关键字最大的元素如石头一般下沉至水底)。下一趟冒泡时,前一趟确定的最小元素不再参与比较,每趟冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置……这样最多做 𝑛−1n−1 趟冒泡就能把所有元素排好序。
图 8.3 所示为冒泡排序的过程,第一趟冒泡时:27 < 49,不交换;13 < 27,不交换;76 > 13,交换;97 > 13,交换;65 > 13,交换;38 > 13,交换;49 > 13,交换。通过第一趟冒泡后,最小元素已交换到第一个位置,也是它的最终位置。第二趟冒泡时对剩余子序列采用同样方法进行排序,如此重复,到第五趟结束后没有发生交换,说明表已有序,冒泡排序结束。
冒泡排序中所产生的有序子序列一定是全局有序的(不同于直接插入排序)。也就是说,有序子序列中的所有元素的关键字一定小于(或大于)无序子序列中所有元素的关键字,这样每趟排序都会将一个元素放置到其最终的位置上。
快速排序
1
快速排序的基本思想:在待排序表 𝐿[1..𝑛] 中任取一个元素 pivot 作为枢轴(或称基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分 𝐿[1..𝑘−1]L[1..k−1] 和 𝐿[𝑘+1..𝑛],使得 𝐿[1..𝑘−1] 中的所有元素小于 pivot,𝐿[𝑘+1..𝑛]中的所有元素大于或等于 pivot,则 pivot 放在了其最终位置 𝐿(𝑘)L(k) 上,这个过程称为一次划分。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或为空为止,即所有元素放在了其最终位置上。
一趟快速排序的过程是一个交替搜索和交换的过程,下面通过实例来介绍,附设两个指针 i 和 j,初值分别为 low 和 high,取第一个元素 49 为枢轴赋值到变量 pivot。
指针 j 从 high 往前搜索找到第一个小于枢轴的元素 27,将 27 交换到 i 所指位置。
适用性:快速排序仅适用于顺序存储的线性表。
注意
在快速排序算法中,并不产生有序子序列,但每一趟排序后会将上一趟划分的各个无序子表的枢轴(基准)元素放到其最终的位置上。