数据结构相关知识点

数据结构相关知识点

image

效率的度量

时间复杂度

一个语句的频度是指该语句在算法中被重复执行的次数。算法中所有语句的频度之和记为 \(T(n)\),是算法问题规模 n 的函数。

算法中基本运算(最深层循环内的语句)的频度与 \(T(n)\) 通数量级,因此常采用算法中基本运算的频度 \(f(n)\) 来分析算的时间复杂度,记为:\(T(n)=O(f(n))\)

加法规则

\[T(n)=T_{1}(n)+T_{2}(n)=O(f(n))+O(g(n))=O(\max (f(n), g(n))) \]

乘法规则

\[T(n)=T_{1}(n) \times T_{2}(n)=O(f(n)) \times O(g(n))=O(f(n) \times g(n)) \]

常见的渐进时间复杂度

\[O(1)<O\left(\log _{2} n\right)<O(n)<O\left(n \log _{2} n\right)<O\left(n^{2}\right)<O\left(n^{3}\right)<O\left(2^{n}\right)<O(n !)<O\left(n^{n}\right) \]

例题

  1. 试算下列算法的时间复杂度:

\[T(n)=\left\{\begin{array}{ll} 1, & n=1 \\ 2 T(n / 2)+n, & n>1 \end{array}\right. \]

\[n=2^k\\ T(n)=2 T(2^{k-1})+2^k=2^kT(1)+k\times2^k=(k+1)\times2^k \]

所以结果是:\(n\log_{2}{n}\)

image

空间复杂度

定义

算法的空间复杂度 S(n) 定义为该算法所耗费的存储空间,它是问题规模n的函数,记为:\(S(n)=O(g(n))\)

  • 存储算法本身所占用的存储空间。
  • 算法的输入输出数据所占用的存储空间。
  • 算法在运算过程中临时占用的存储空间

线性表

线性表的逻辑结构和基本操作

线性表的定义

线性表是具有相同数据类型的 n 个数据元素的有限序列,n 为表长,若用L命名线性表,一般表示为:\(L=\left(a_{1}, a_{2}, \cdots, a_{i}, a_{i+1}, \cdots, a_{n}\right)\)

线性表分为顺序表和链表

顺序表:线性表的顺序表示

线性表的顺序储存又称为顺序表。它使用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素物理位置上也相邻。因此顺序表的特点是表中元素的逻辑顺序与物理顺序相同。

注意:顺序表有可能有序,也有可能无序,虽然叫顺序表,只是为了和链表区分一下。

线性表的顺序存储结构

假设线性表L存储的起始位置为 LOC(A)sizeof(ElemType) 是每个数据元素所占用存储空间的大小,则表L对应的顺序存储如图所示。

注意:线性表中元素的位序是从 1 开始的,而数组中元素的下标是从 0 开始的 。

线性表的顺序存储类型描述

顺序表的操作

插入操作:平均时间复杂度 \(O(n)\)

\[\sum_{i=1}^{n+1} p_{i}(n-i+1)=\sum_{i=1}^{n+1} \frac{1}{n+1}(n-i+1)=\frac{1}{n+1} \sum_{i=1}^{n+1}(n-i+1)=\frac{1}{n+1} \frac{n(n+1)}{2}=\frac{n}{2} \]

\(p_{i}\) 是在第 \(i\) 个位置上插入一个结点的概率

删除操作:平均时间复杂度 \(O(n)\)

\[\sum_{i=1}^{n} p_{i}(n-i)=\sum_{i=1}^{n} \frac{1}{n}(n-i)=\frac{1}{n} \sum_{i=1}^{n}(n-i)=\frac{1}{n} \frac{n(n-1)}{2}=\frac{n-1}{2} \]

\(p_{i}\) 是在删除第i个位置上结点的概率

按值查找(顺序查找):平均时间复杂度 \(O(n)\)

\[\sum_{i=1}^{n} p_{i} \times i=\sum_{i=1}^{n} \frac{1}{n} \times i=\frac{1}{n} \frac{n(n+1)}{2}=\frac{n+1}{2} \]

\(p_{i}\) 是查找的元素在第 i 个位置上的概率

链表:线性表的链式表示

链表的每个存储节点不仅包含元素本身的信息(数据域),而且包含表示元素之间逻辑关系的信息(指针域)。因为线性表中每个元素最多只有一个前驱元素和一个后继元素。

顺序表可以随时存取表中任意一个元素,它的存储位置可以用一个简单直观的公式表示,但是插入和删除操作需要移动大量元素。链式存储线性表时,不需要使用地址连续的存储单元,即不要求逻辑上相邻的元素在物理位置上也相邻,它通过“链”建立起数据元素之间的逻辑关系,因此插入和删除操作不需要移动元素,而只需修改指针,但也会失去顺序表可随机存取的优点。

头指针和头结点:通常用头指针来标识一个单链表;在单链表第一个节点之前附加一个结点,称为头结点,头结点的指针域指向线性表的第一个元素结点。

头指针和头结点的区别:不管带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点的链表的第一个结点,结点内通常不存储信息。

引入头结点的优点

  1. 链表第一个位置和其他位置的操作一致
  2. 空表和非空表的处理统一

单链表

线性表的链式存储又称单链表,它是通过一组任意的存储单元来存储线性表中的数据元素。为了建立数据元素之间的关系,对每个链表节点,除存放元素自身的信息外,还需要存放一个指向其后继的指针。

插入操作:顺序不可颠倒
  1. \(p=GetElem(L, i-1)\)
  2. \(s\to next=p\to next\)
  3. \(p\to next=s\)

s 和 p 代表指针,也就是结点地址;\(p\to data\) 代表位于地址的内容;\(p\to next\) 代表新的指针,这个地址就是它下一个元素的地址

删除操作
  1. \(p=GetElem(L, i-1)\)
  2. \(q=p\to next\)
  3. \(p\to next=q\to next\)
  4. \(free(q)\)

初始化线性链表
  1. 头插法:与栈一样,从一个空表开始一次读取数组a中的元素,生成一个新节点,将读取的数组元素存放在该节点的数据域中,然后后将其插入到当前链表的表头上(头节点之后),采用头插法建表时单链表中的数据节点的顺序与数组a中的元素顺序相反
  2. 尾插法:增加尾指针,把新节点插入到表尾上。数据节点的顺序与数组a中的元素顺序相同

双链表

image

image

顺序表和链表的比较

存取方式

  • 顺序表可以顺序存取,也可以根据起始地址加上元素的序号随机存取
  • 链表只能从表头顺序存取元素

逻辑结构和物理结构

  • 顺序存储时,逻辑上相邻的元素,对应的物理存储位置也相邻
  • 链式存储,逻辑上相邻的元素,物理位置不一定相邻,对应的逻辑关系是通过指针链接来表示的。

查找、插入和删除操作

  • 按值查找,顺序表无序时,两者的时间复杂度均为 \(O(n)\);顺序表有序时,采用折半查找,此时时间复杂度为 \(O(logn)\)
  • 按序号查找,顺序表支持随机访问,时间复杂度仅为 \(O(1)\),而链表的平均时间复杂度为 \(O(n)\)
  • 顺序表的插入、删除操作,平均需要移动半个表长的元素
  • 链表的插入、删除操作,只需要修改相关结点的指针域即可。
  • 由于链表每个结点都带有指针域,故而存储密度不够大

关于空间分配

  • 顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,需要预先分配足够大的存储空间
  • 顺序存储在动态存储分配情况下,虽然存储空间可以扩充,但是需要移动大量元素导致操作效率降低(要把先前的元素复制到新找到的大空间),如果内存中没有更大块的连续存储空间,则会导致分配失败
  • 链式存储的结点空间在需要时申请分配,只要内存有空间就可以分配,操作灵活、高效。

栈的定义

定义

栈是一种只能再一端进行插入或删除操作的线性表。表中允许操作的一端称为栈顶,表的另一端称为栈底。栈的操作特性可以概括为“后进先出

例题

有 n 种不同的元素进栈,问有多少种不同的出栈顺序?

\[f(n)=\frac{1}{n+1}C_{2n}^{n} \]

解体思路:构造关于 S 和 X 序列(各n个),进栈顺序已经定了,也就是说这 n 个写有不同数字的小球之间进栈只有一种顺序,所以可把它们都认为是一样的小球

  1. 总序列数为 \(C_{2n}^{n}\)

  2. 错误序列,例如前 2k+1 个里有 k 个 S,k+1 个 X

  3. 正确序列=总-错

第一个犯错误的位置(X 的个数 = S 个数 +1)

  1. 将前 \(2k+1\) 个元素翻转:S 变为 X,X 变为 S,从而得到一个新的序列:\(n+1\) 个 S,\(n-1\) 个 X

\[SXSSXXX\quad SSXSX \to XSXXSSS\quad SSXSX \]

  1. 而对于任一含有 \(n-1\) 个S,\(n-1\) 个 X 的序列,必有某一位置,在该位置时 S 个数 = X 个数 +1,以 \(t\) 记此时 X 个数
  2. 对前 \(2t+1\) 个位置的元素再进行翻转,从而得到新序列,有 n 个 s,n 个 x;该序列的前 \(2t+1\) 个元素有 \(t+1\) 个 X,t 个 S,表明新得到的序列也是错误序列
  3. 所以错误序列与有 \(n+1\) 个 S 和 \(n-1\) 个 X 的序列一一对应,而后者一共有 \(C_{2n}^{n-1}\)
  4. 所以正确序列 = \(C_{2n}^{n}-C_{2n}^{n-1}=\frac{1}{n+1}C_{2n}^{n}\)

栈的顺序存储结构

栈是一种操作受限的线性表,类似于线性表,按存储结构的不同分为顺序栈和链栈。

顺序栈

采用顺序存储的栈称为顺序栈,它利用一组地址连续的存储单元存放自栈底到栈顶的数据元素,同时附设一个指针(top)指示当前栈顶元素的位置。

进栈和出栈运算的实现

  • 栈顶指针:S.top, 初始时设置 S.top= -1;栈顶元素:S.data\[S.top]
  • 进栈操作:栈不满时,找顶指针先加 1 ,再送值到栈顶元素,S.data[++S.top=x]
  • 出栈操作:栈非空时,先取栈顶元素值,再将栈顶指针减1,x=S.data[S.top--]
  • 栈空条件:S.top== -1;栈满条件:S.top==MaxSize-1(数组的最大下标);栈长:S.top+1(数组中元素的个数)

image

共享栈

顺序栈采用一个数组存放栈中的元素。利用栈底位置相对不变的特性,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸

image

栈的链式存储结构

链式栈

链栈的优点是便于多个栈共享存储空间,不存在栈满上溢的情况。规定所有操作在单链表的表头进行。

image

进栈和出栈

  • 栈空条件:s \(\to\) next == NULL
  • 栈满条件:只有内存溢出才出现栈满
  • 进栈操作:新建一个结点存放元素 e(由 p 指向它),将结点 p 插入到头结点之后
  • 出栈操作:取出首结点的 data 值并将其删除

队列

栈和队列具有相同的逻辑结构,都是属于线性结构,只是对数据的运算不同

队列的定义

队列也是一种操作受限的线性表,限制为仅允许在表的一端进行插入操作,而在表的另一端进行删除操作,进行插入的一端称为队尾,进行删除的一端是队首。队的操作特性可以概括为“先进先出

队列的顺序存储结构(顺序队)

队列中数据元素的逻辑关系呈现线性关系,所以队列可以像线性表一样采用顺序存储结构,即分配一块连续存储空间来存放队列中的元素,并用两个整型变量来反映队列中元素的变化,他们分别存储队首元素和队尾元素的下标位置,分别称为队首指针和队尾指针。

image

image

顺序队中队列的基本运算

  • 队空条件:q \(\to\) front == q \(\to\) rear
  • 队满条件:q \(\to\) rear == MaxSize-1(data数组的最大下标)
  • 进队操作:先将rear增1,然后将元素e放在data数组的rear位置
  • 出队操作:先将front增1,然后取出data数组中front位置的元素

环形队中队列的基本运算

image

假溢出: 在顺序队列中队满时不能再进队列元素。实际上,q \(\to\) rear == MaxSize-1 成立时,队列中可能还有空位置,这种因为队满条件设置不合理导致队满条件成立时而队列中仍有空位置的情况称为假溢出

解决方案: 将data数组的前端和后端连接起来,形成一个环形数组,当队尾指针 q \(\to\) rear == MaxSize-1 后,再前进一个位置就到达 0,于是可以使用另一端的空位置存放队列元素了。环形队列队头指针 front 和队尾指针初始化时都置为 0,在进队元素和出队元素时分别循环增 1。

\[front=(front+1)\%MaxSize\\ rear=(rear+1)\%MaxSize \]

区分队空和队满: 将队满条件改为以队尾元素循环增 1 时等于队头指针

  • 队空条件:q \(\to\) front == q \(\to\) rear
  • 队满条件:(q \(\to\) rear+1)%MaxSize == q \(\to\) front
  • 进队操作:q \(\to\) rear = (q \(\to\) rear+1)%MaxSize; q \(\to\) data[q \(\to\) rear] = e;

队列的链式存储结构的实现

因为链队中只允许单链表的表头进行删除操作(出队),表尾进行插入操作(进队),因此需要使用队头指针 front 和队尾指针 rear。和链栈一样,链队也不存在队满上溢出的情况。

image

image

链队的基本运算

  • 队空条件:q \(\to\) rear == NULL
  • 队满条件:不考虑
  • 进队操作:新建一个结点p存放元素e,将新结点插入作为尾结点的后面,然后将尾指针指向新结点\(q\to rear\to next=p; q\to rear = p\)
  • 出队操作:取出队首结点的data值并将其删除

在插入新结点时,如果链队为空,则新结点既是队首结点又是队尾结点

串的基本概念

  • 串是由零个或多个字符组成的有限序列;
  • 两个串相等当且仅当这两个串的长度相等并且各对应位置上的字符都相同;
  • 一个串中任意个连续字符组成的序列称为该串的子串。

串的存储结构

和线性表一样,串也有顺序存储和链式存储结构

顺序串

顺序串的字符被依次放在一组连续的存储单元里。一般来说,一个字节(8位)可以表示一个字符。而计算机内存以字为存储单位,一个字可能包含多个字节

  • 非紧缩格式:每个字只存一个字符
  • 紧缩格式:每个字存放多个字符

链串

链串与链表的主要区别是:链串的一个结点可以存储多个字符,将链串中每个结点所存储的字符个数称为结点大小。

image

  • 结点大小越大,存储密度越大,但基本操作如插入、删除等有所不便且可能引起大量字符的移动
  • 结点越小,存储密度下降

串的模式匹配

设有两个串 s 和串 t,串t的定位就是要在串 s 中找到一个与 t 相等的字串。所以串定位查找也称为串的模式匹配

暴力匹配算法(Brute-Force)

BF 算法,采用穷举方法,基本思路是从目标串 s 的第一个字符开始和模式串 t 的第一个字符比较,若相等,则继续逐个比较后续字符;否则从目标串s的第二个字符开始重新与t的第一个字符进行比较。若从模式串 s 的第 i 个字符开始,每个字符依次与目标串 t 中对应字符相等,则匹配成功。

此法效率不高,因为在比较中,只要有一个字符比较不相等,就需要回溯。平均时间复杂度 \(O(n\times m)\)

KMP 算法

主要优点是消除了主串指针的回溯

从模式串t中提取加速匹配的信息

对t的每个字符 \(t_i\) 存在一个整数 k,使得模式串 t 中开头的k个字符依次与 \(t_i\) 前面的 k 个字符相同,如果这样的 k 有多个,采用其中最大的一个。

\[\operatorname{next}[j]=\left\{\begin{array}{ll} -1 & \text { 当 } j=0 \text { 时 } \\ \operatorname{MAX}\left\{k \mid 0<k<j \text { 且 }^{} t_{0} t_{1} \cdots t_{k-1} = t_{j-k} t_{j-k+1} \cdots t_{j-1} \right\} & \text { 当此集合非空时 } \\ 0 & \text { 其他情况 } \end{array}\right. \]

如果有 \(next[j]=k\),表示有 \(t_{0} t_{1} \cdots t_{k-1}= t_{j-k} t_{j-k+1} \cdots t_{j-1}\)

KMP 算法的模式匹配过程

当求出模式串 t 的 next 数组表示的信息后,就可与i用来消除主串指针的回溯。

image

A=B, B=C,所以 A=C,将 A 和 C 对齐

保持主串指针 \(i\) 不变,模式串 \(t\) 右滑 \(j-next[j]\) 个位置,让 \(s_i\)\(t_{next[j]}\) 对齐进行比较。

树的基本概念和性质

树的定义

树是由 n 个结点或元素组成的有限集合,有且仅有一个结点作为树的根结点,简称为根

树的逻辑表示方法

  1. 树形表示法
  2. 文氏图表示法
  3. 凹入表示法
  4. 括号表示法

树的基本术语

  1. 结点的度:某个结点的子树个数
  2. 树的度:树中所有结点的度中的最大值
  3. 分支结点与叶子结点:前者度不为0,后者度为0
  4. 路径长度:路径所通过的结点数减1,就是路径上的分支数目
  5. 孩子结点、双亲结点和兄弟结点
  6. 结点层次和树的高度/深度
  7. 有序树和无序树
  8. 森林

树的性质

  1. 树中的结点数等于所有结点度数之和加 1
  2. 度为 m 的树中第 i 层上最多有 \(m^{i-1}\) 个结点
  3. 高度为 h 的 m 次数最多有\(\frac{m^{h}-1}{m-1}\)个结点
  4. 具有 n 个结点的 m 次树最小高度为\(\left\lceil\log _{m}(n(m-1)+1)\right\rceil\)

在该树中前 h-1 层都是满的,第 h 层结点数可能满,也可能不满,但至少有一个结点,则该树具有最小的高度

\[\begin{aligned} \frac{m^{h-1}-1}{m-1}+1 &\leqslant n \leqslant \frac{m^{h}-1}{m-1} \\ \frac{m^{h-1}-1}{m-1}&<n \leqslant \frac{m^{h}-1}{m-1} \\ m^{h-1}&<n(m-1)+1 \leqslant m^{h} \\ h-1&<\log _{m}(n(m-1)+1) \leqslant h \\ \log _{m}(n(m-1)+1) &\leqslant h<\log _{m}(n(m-1)+1)+1 \end{aligned} \]

\[h=\left\lceil\log _{m}(n(m-1)+1)\right\rceil \]

树的遍历

graph TB A-->B A-->C A-->D B-->E B-->F C-->G D-->H D-->I D-->J H-->M E-->K E-->L
  1. 先根遍历: ABEKLFCGDHMIJ
  2. 后根遍历: KLEFBGCMHIJDA
  3. 层次遍历: ABCDEFGHIJKLM

树的存储结构

存储树的基本要求是既要存储结点的数据元素本身,又要存储结点之间的逻辑关系。

  1. 双亲存储结构:求某个结点的双亲结点十分容易,但是求某个节点的孩子结点需要遍历整个存储结构
  2. 孩子链存储结构:每个结点不仅包含结点值,还包括指向所有孩子结点的指针,一般按照树的度来设计结点的孩子结点的指针域个数
  3. 孩子兄弟链存储结构:每个结点有3个域,一个数据元素域,一个指向该结点的左边第一个孩子结点(长子)的指针域,一个指向该结点下一个兄弟结点的指针域。

孩子兄弟链存储结构固定有两个指针域,并且这两个指针是有序的,所以这个结构实际上是把该树转换为二叉树的存储结构。

image

image

image

二叉树

二叉树定义

  1. 满二叉树:一棵深度为 k 且有 \(2^k-1\) 个结点的二叉树称为满二叉树,根据二叉树的性质,满二叉树每一层的结点个数都达到了最大值
  2. 完全二叉树:一棵深度为 k 的有 n 个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为 \(i(1≤i≤n)\)的结点与满二叉树中编号为i的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。

完全二叉树的特点:叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部。

二叉树的性质

  1. 非空二叉树上的叶子结点数等于双分支节点数加 1:\(n_{1}+2 n_{2}=n_{0}+n_{1}+n_{2}-1\)

总分支数 = 总结点数 -1

  1. 非空二叉树的第i层上最多有 \(2^{i-1}\) 个结点
  2. 高度为h的二叉树上最多有有 \(2^h-1\) 个结点
  3. 完全二叉树中层序编号为i的结点有以下性质:
    • \(i \leqslant\lfloor n / 2\rfloor\),则编号为i的结点为分支结点,否则为叶子结点
    • 若 n 为奇数,则每个分支结点都既有左孩子结点又有右孩子结点;若 n 为偶数,则编号最大的分支结点只有左孩子结点,没有右孩子结点,其余分支结点都有左右孩子结点
    • 若编号为i的结点有左孩子结点,则左孩子结点编号为 \(2i\),若有右孩子结点,则右孩子结点为 \(2i+1\)
    • 除了根结点外,若一个结点的编号为i,则其双亲结点的编号为 \(\lfloor i / 2\rfloor\)
  4. 具有 n 个结点的完全二叉树的高度为 \(\left\lceil\log _{2}(n+1)\right]\)
    image

二叉树与树、森林之间的转换

将一棵树转化为一棵二叉树:左孩子右兄弟
  1. 树中所有相邻兄弟之间加一条连线
  2. 对树中每个结点只保留它和长子之间的连线,删除与其他孩子之间的连线
  3. 以树的根结点为轴心,将整棵树顺时针转动45°,使之结构层次分明

左孩子右兄弟:每个结点左指针指向他的第一个孩子,右指针指向他在树中相邻的右兄弟

image

将森林转化为一棵二叉树
  1. 将森林中的每棵树都转换成相应的二叉树
  2. 第一棵二叉树不动,从第二棵开始,依次把有一棵二叉树的根结点作为前一棵二叉树根结点的右孩子结点,当所有二叉树连在一起后,此时得到的二叉树就是由森林转换得到的二叉树

根结点上连着的延续的右结点数目是森林中树的数目

image

二叉树还原为树
  1. 若某结点是其双亲的左孩子,则把该结点的右孩子、右孩子的右孩子等都与该结点的双亲结点用连线连接起来
  2. 删除原二叉树中所有双亲结点与右孩子结点之间的连线
  3. 整理由前面两步得到的树,即以根结点为轴心,逆时针旋转 45°,使之层次分明

image

二叉树还原为森林
  1. 抹掉二叉树根结点右链上的所有结点之间双亲-右孩子关系,将其分成若干以右链上的结点为根结点的二叉树,设这些二叉树为 bt1、bt2...
  2. 分别将 bt1、bt2... 二叉树各自还原为一棵树

image

二叉树的存储结构

二叉树的顺序存储结构

二叉树的顺序存储结构就是用一组地址连续的存储单元来存放二叉树的数据元素,因此必须确认好树中各元素的存放次序,使得各数据元素在这个存放次序中的相互位置能反映出数据元素之间的逻辑关系。

对于完全二叉树和满二叉树,树中结点的层序编号可以唯一反映出结点之间的逻辑关系,所以可以用一维数组按从上到下、从左到右的顺序存储树中所有结点值,通过数组元素的下标关系反映完全二叉树结点之间的逻辑关系。

对于一般二叉树,可以通过添加一些虚节点来使其变为一棵完全二叉树。

image

和树的存储结构相比,二叉树的顺序存储结构类似于树的双亲存储结构,不同的是,双亲存储机构中,每个数组元素都附设了一组伪指针去指示双亲结点的数组下表,但在二叉树的顺序存储中,结点的下标已经反映了双亲结点和孩子结点之间的位置关系,所以不用再增设伪指针了

顺序存储的二叉树,插入、删除等运算十分不便,因此不常使用

二叉树的链式存储结构

用一个链表来存储一棵二叉树,二叉树的每一个结点用链表中的一个结点来存储。二叉链中通过根结点指针 b 来唯一标识整个存储结构,称为二叉树 b

lchild data rchild
#### 二叉树的遍历
  1. 先序遍历:先根,再左,再右
  2. 中序遍历:先左,再根,再右
  3. 后序遍历:先左,,再右,再根
  4. 层次遍历:非递归,一层一层访问二叉树所有结点

二叉树的构造

  1. 任何n个不同结点的二叉树,都可由它的中序序列和先序序列唯一地确定
  2. 任何n个不同结点的二叉树,都可由它的中序序列和后序序列唯一地确定

二叉排序树

二叉排序树的定义
  • 若左子树非空,则左子树上所有结点的值均小于根结点的值
  • 若右子树非空,则右子树上所有结点的值均大于根结点的值
  • 左右子树也分别是一棵二叉树

所以,对二叉排序树进行中序遍历,可以得到一个递增的有序序列。

二叉排序树的优点
  • 当有序表是静态查找表时,宜用顺序表作为其存储结构,采用二分查找实现查找操作;
  • 当有序表是动态查找表时,应用二叉排序树作为其存储结构
  • 二叉排序树的中序序列是一个有序序列,所以如果知道二叉排序树的先序序列或者中序序列就可以画出这个二叉树

动态查找表的意思是:在查找过程中,当树中不存在关键字值等于给定值的结点时就进行插入

二叉排序树的删除
  • 若被删除结点 Z 是叶子结点,则直接删除
  • 若结点 Z 只有一棵左子树或右子树,则让 Z 的子树成为 Z 父结点的子树
  • 若结点 Z 有左右子树,则令 Z 的直接后继(或直接前驱,就是左子树的最右边)替代 Z,然后从二叉树中删去这个直接后继,这样就转变为第一或第二种情况
    image
二叉排序树的查找效率分析

二叉排序树的查找效率,主要取决于树的高度。若二叉排序树的高度之差绝对值不超过1,则这样的二叉排序树称为平衡二叉树,平均查找长度为 \(O(logn)\)

平衡二叉树

定义

为避免树的高度增长过快,降低二叉排序树的性能,规定在插入和删除二叉树结点时,要保证任意结点的左右子树高度差绝对值不超过 1,这样的二叉树称为平衡二叉树

插入

每当在二叉排序树中插入或删除一个结点时,首先检查其插入路径上的结点是否因为此次操作导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于 1 的结点 A,再对以 A 为根的子树,在保持二叉排序树的前提下,调整各结点的位置关系,使之重新达到平衡

image

递推公式

image

平衡二叉树满足平衡的最少结点情况:所有非叶子结点的平衡因子为 1

平衡二叉树结点数的递推推公式为

\[n_0 = 0, n_1 = 1, n_2 = 2 n_h = 1+n_{h-1}+n_{h-2} \]

h 为平衡二叉树高度,\(n_h\) 为构造此高度的平衡二叉树所需的最少结点数

对平衡二叉树而言,在查找过程中,与给定值进行比较的关键字个数不超过树的深度,而含有 n 个结点的平衡二叉树的最大深度为 \(O(logn)\),所以平衡二叉树的平均查找长度为 \(O(logn)\)

哈夫曼树

哈夫曼树概述

再许多应用中经常将树中的结点赋予一个有意义的数值,称此数值为该结点的权。从根结点到该结点之间的路径长度与该结点上权的乘积称为结点的带权路径长度 WPL。树中所有叶子结点的带权路径长度之和称为该树的带权路径长度。

在 \(n_0\) 个带权叶子结点构成的所有二叉树中,带权路径长度 WPL 最小二叉树称为哈夫曼树或最优二叉树。

哈夫曼树的构造方法

给定 \(n_0\) 个权值,如何构造一棵含有 n 个带有给定权值的叶子结点的二叉树,使其带权路径长度 WPL 最小?

哈夫曼算法:

  1. 根据给定的 \(n_0\) 个权值,对应结点构成 \(n_0\) 棵二叉树的森林F=(T1, T2...T\(n_0\)),其中每棵二叉树中都只有一个带权值的根结点,左右子树均为空
  2. 在森林F中选取两棵结点权值最小的子树分别作为左右子树构造一棵新的二叉树,并且置新的二叉树根结点的权值为左右子树上根的权值之和
  3. 再森林F中,用新得到的二叉树代替这两棵树
  4. 重复 2 和 3,直到F只含有一棵树为止,这就是哈夫曼树
    image
哈夫曼树的特点:
  1. 对于具有 \(n_0\) 个叶子结点的哈夫曼树,需要 \(n_0-1\) 次合并过程,每次合并新建一个分支结点,所以有 \(n_0-1\) 个分支结点,共有 \(2n_0-1\) 个结点
  2. 每个初始结点最终都成为叶子结点,且权重越小的结点到根结点的路径长度越大
  3. 每次构造都选择 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为 1 的结点
哈夫曼编码

在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码;若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码要好的对,其特点是对频率高的字符赋以短编码,而对频率低的字符赋以较长一些的编码,从而可以使字符平均长度减短,起到压缩数据的效果。

若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。在哈夫曼编码中,一个编码不能是任何其他编码的前缀

由哈夫曼树构造哈夫曼编码:

  • 当每个出现的字符当做一个独立的结点,其权值为它出现的频数或次树
  • 显然所有字符都出现在叶结点中,边标记为 0 表示转向左孩子,标记为 1 表示转向右孩子
    image

\[WPL = 1\times45+3\times(13+12+16)+4\times(5+9)=224 \]

  • WPL可视为最终编码得到二进制编码的长度,共 224 位。若采用 3 位固定长度编码,则得到的二进制编码长度为 300 位,因此哈夫曼编码共压缩了 25% 的数据

线索二叉树

概述

遍历二叉树是以一定的规则将二叉树中的结点排列成一个线性序列,从而得到几种遍历序列,使得该序列中的每个结点(除了第一个和最后一个)都有一个直接前驱和直接后继。但是传统的二叉链表存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱或后继。

在含有 n 个结点的二叉树中,有 n+1 个空指针。能否利用这些空指针来存放指向其前驱或后继的指针?这样就可以像遍历单链表那样方便的遍历二叉树。引入线索二叉树正是为了加快查找结点前驱和后继的速度。

规定:若无左子树,令 lchild 指向其前驱结点;若无右子树,令rchild指向其后继结点;还需要增加两个标志域标识指针域是指向左右孩子还是指向前驱后继。

lchild ltag data rtag rchild
$$ \begin{aligned} &\text { ltag }\left\{\begin{array}{ll} 0, & \text { lchild 域指示结点的左孩子 } \\ 1, & \text { lchild 域指示结点的前驱 } \end{array}\right.\\ &\text { rtag }\left\{\begin{array}{ll} 0, & \text { rchild 域指示结点的右孩子 } \\ 1, & \text { rchild 域指示结点的后继 } \end{array}\right. \end{aligned} $$ ##### 中序线索二叉树的构造

二叉树的线索化就是将二叉链表中的空指针改为指向前驱或后继的线索。而前驱或后继的信息只有在遍历时才能得到,因此线索化的实质就是遍历一次二叉树。

在中序遍历的过程中,检查 p 的左指针是否为空,若为空就将它指向 pre(p的前驱);检查 pre 的右指针是否为空,若为空就将它指向 p(pre的后继)

中序线索二叉树的遍历

中序线索二叉树的结点中隐含了线索二叉树的前驱和后继信息。对其遍历时,只要先找到序列中的第一个结点,然后依次找结点的后继,直到后继为空。若其右标志为1,则右链为线索,指示其后继,否则遍历右子树中第一个访问的结点为其后继。

图的基本概念

定义

图 G 由定点集 V 和边集 E 组成,记为 \(G = (V, E)\), 其中 V(G) 表示图 G 中顶点的有限非空集;E(G) 表示图 G 中顶点之间的关系也就是边的集合。如果 \(V=\{ v_1,v_2,...,v_n\}\),则用 |V| 表示图 G 中顶点的个数,也称图 G 的阶,用 |E| 表示图 G 中边的条数。

线性表可以是空表,树可以是空树,但是图不可以是空图,图中不能一个顶点也没有,但是边可以没有

分类

有向图
graph LR 1-->2 2-->1 2-->3

\[G_1 = (V_1, E_1)\\ V_1 = \{1,2,3\}\\ E_2 = \{<1,2>,<2,1>,<2,3>\} \]

无向图
graph LR 1---2 2---3 1---3 2---4 3---4 1---4

\[G_2 = (V_2, E_2)\\ V_1 = \{1,2,3,4\}\\ E_2 = \{(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)\} \]

简单图
  • 不存在重复边
  • 不存在顶点到自身的边
多重图
  • 某两个结点之间的边数多余一条
  • 允许顶点通过同一条边和自己关联
完全图
  • 无向图:|E|的取值范围是 0 到 $C_{n}^{2} $,有 $C_{n}^{2} $ 条边的无向图称为完全图,任意两个顶点之间都存在边
  • 有向图:|E|的取值范围是 0 到 $A_{n}^{2} $,有 $A_{n}^{2} $ 条边的无向图称为有向完全图,任意两个顶点之间都存在方向相反的两条弧
子图
连通、连通图和连通分量
  • 无向图:从顶点 v 到顶点 w 有路径存在,则称 v 和 w 是连通的。
  • 若图 G 中任意两个顶点都是连通的,则称图 G 是连通图。无向图中的极大连通子图称为连通分量(注意这个最大
  • 如果一个图有 n 个顶点,并且边数小于 n-1,则此图必为非连通图
    image

完全图肯定是连通图,但是连通图不一定是完全图,因为两个节点之间可能通过其他的路径联通而不直接相连。

连通图的连通分量只有一个(本身),而非连通图有多个连通分量。

对于连通无向图,边最少即构成一棵树的情形;而对于强连通有向图,边最少即构成一个环的情形。

极大连通子图称为连通分量,极大的含义是依附于连通分量中顶点的所有边都加上,所以连通分量中可能存在回路,这样就不是生成树了;而一个连通图的生成树是一个极小连通子图

强连通图、强连通分量
  • 有向图:从顶点 v 到顶点 w 和从顶点 w 到顶点 v 之间都有路径,则称这两个顶点是强连通的。
  • 若图中任何一对顶点都是强连通的,则称此图为强连通图

在一个非强连通图中找强连通分量的方法:

  1. 在图中找有向环
  2. 扩展该有向环:如果某个顶点到该环中任一顶点都有路径,并且该环中任一顶点到这个顶点也有路径,则加入这个顶点
生成树、生成森林
  • 连通图的生成树是包含图中全部顶点的一个极小连通子图
  • 若图中定点数为 n,则它的生成树含有 n-1 条边
  • 对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路
顶点的度、入度和出度
  • 顶点的度定义为以该顶点为一个端点的边的数目
  • 无向图: 顶点 v 的度指依附于该顶点的边的条数,记为 TD(v)
  • 无向图的全部定点的度的和等于边数的2倍,因为每条边和两个顶点相关联
  • 有向图: 顶点 v 的度分为入度和出度,入度是以顶点 v 为终点的有向边的数目,记为 ID(v);出度是以顶点 v 为起点的有向边的数目,记为OD(v)
  • 有向图的全部顶点的入度之和与出度之和相等,并且等于边数,因为每条有向边都有一个起点和终点。
边的权和网
  • 每条边都可以标上某种含义的数值,该数值称为该边的权值,这种图称为带权图
  • 注意在树中是在节点处带上了权值,注意比较
稠密图、稀疏图
  • 稀疏图:边数很少的图
  • 稠密图:边数很多的图
路径、路径长度和回路
  • 路径长度:路径上边的数目
  • 第一个顶点和最后一个顶点相同的路径称为回路或环
  • 若一个图有 n 个顶点,并且有大于 n-1 条边,则此图一定有环
简单路径、简单回路
  • 简单路径:路径序列中顶点不重复出现的路径
  • 简单回路:除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路
距离
  • 距离:从顶点u出发到v的最短路径,若不存在,记为 \(\infty\)
有向树
  • 一个顶点入度为 0,其余顶点的入度均为 1 的有向图,称为有向树

图的存储和基本操作

图的存储必须要完整、准确地反映顶点集和边集的信息。

邻接矩阵法、邻接表法

邻接矩阵

定义:用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(各顶点之间的邻接关系),存储顶点之间邻接关系的二维数组称为邻接矩阵。

\[A[i][j]=\left\{\begin{array}{ll} 1, & \text { 若 }\left(v_{i}, v_{j}\right) \text { 或 }\left\langle v_{i}, v_{j}\rangle\text { 是 } E(G)\right. \text { 中的边 } \\ 0, & \text { 若 }\left(v_{i}, v_{j}\right) \text { 或 }\left\langle v_{i}, v_{j}\rangle\text { 不是 } E(G)\right. \text { 中的边 } \end{array}\right. \]

graph LR 1-->2 1-->3 3-->4 4-->2

\[A_{1}=\left[\begin{array}{llll} 0 & 1 & 1 & 0 \\ 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 \\ 1 & 0 & 0 & 0 \end{array}\right] \]

graph LR 1---2 1---4 2---5 2---3 3---5 3---4

\[A_{2}=\left[\begin{array}{lllll} 0 & 1 & 0 & 1 & 0 \\ 1 & 0 & 1 & 0 & 1 \\ 0 & 1 & 0 & 1 & 1 \\ 1 & 0 & 1 & 0 & 0 \\ 0 & 1 & 1 & 0 & 0 \end{array}\right] \]

  • 无向图的邻接矩阵一定是一个对称矩阵(并且唯一)。因此,在实际存储邻接矩阵时只需要存储上或下三角矩阵的元素
  • 对于无向图,邻接矩阵的第i行或第i列非零元素的个数正好是第i个顶点的度TD(\(v_i\))
  • 对于有向图,邻接矩阵的第i行非零元素的个数正好是第i个顶点的出度OD(\(v_i\)),第i列非零元素的个数正好是第i个顶点的入度
  • 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按照行、列对每个元素进行检测,所花费的时间代价很大。
  • 稠密图适合邻接矩阵法
邻接表法

当一个图为稀疏图时,使用邻接矩阵法显然要浪费大量存储空间,而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。

邻链表,指对图G中每个顶点vi建立一个单链表,第i个单链表中的结点表示依附于顶点vi的边,这个单链表就称为顶点vi的边表。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点结点和边表结点
image

顶点表结点由顶点域和指向第一条邻接边的指针构成,边表结点由邻接点域和指向下一条邻接边的指针域构成。如下图所示,1的邻居是2和5;2的邻居是1、2、4、3;3的邻居是2与4;4的邻居是2,3,5;5的邻居是1、2、4

image

image
1. 如何判断图中有多少条边?

  • 对于邻接矩阵表示的无向图,边数等于矩阵中1的个数除以2;对于邻接表表示的无向图,边数等于边结点的个数除以2
  • 对于邻接矩阵表示的有向图,边数等于矩阵中1的个数,对于邻接表表示的有向图,边数等于边结点的个数

2. 如何判断任意两个顶点i和j是否有边相连?

3. 任意一个顶点的度是多少?

  • 邻接矩阵表示的无向图,顶点i的度等于第i行中1的个数;对于邻接矩阵表示的有向图,顶点i的出度等于第i行中1的个数,入度等于第i列中1的个数
  • 对于邻接表表示的无向图,顶点i的出度等于顶点表结点i的单链表中边表结点的个数,顶点i的入度等于邻接表中所有编号为i的边表结点数

邻接多重表、十字链表

十字链表

十字链表是有向图的一种链式存储结构,在十字链表中,对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点。
image

image

邻接多重表

邻接多重表是无向图的另一种链式存储结构。
在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。

在邻接多重表中,每条边用一个结点表示,每个顶点也用一个结点表示
image

图的遍历

从图的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次且仅访问一次。图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。

图中的任一顶点都可能和其余的顶点相邻接,所以在访问某个顶点后,可能沿着某条路径搜索又回到该顶点上。为避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已访问过的顶点,为此可以设一个辅助数组visited[]来标记顶点是否被访问过。

广度优先遍历

广度优先搜索BFS类似于二叉树的层序遍历算法。基本思想是:首先访问起始顶点v,接着由v出发,一次访问v的各个未被访问过的邻接顶点w1,w2,w3...然后依次访问w1,w2,w3...的所有未被访问过的邻接顶点;再从这些访问过的顶点出发,访问它们所有未被访问过的邻接顶点,直至图中所有顶点都被访问过为止。若此时图中尚有顶点未被访问,则另选图中一个未曾访问的顶点作为起始点,重复上述过程,直至图中所有顶点都被访问到为止。

广度优先搜索遍历图的过程是以v为起始点,有近至远依次访问和v有路径相同且路径长度为1,2...的顶点。广度优先搜索是一种分层的查找过程,每向前一步可能访问一批顶点,不像深度优先搜索那样往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点

BFS算法的性能分析
BFS算法求解单源最短路径问题

可以利用广度优先遍历算法求解从顶点u到顶点v的最短路径,注意,这个算法只能用在不带权的图中,这样我们可以把每条边的长度都看做1,因此求顶点u和顶点v的最短路径就是求距离顶点u到顶点v的边数最少的顶点序列。

广度优先遍历算法用到一个队列,队列中的每个顶点都有唯一的前驱结点,可以利用这一性质采用广度优先遍历算法找出最短路径。

从u出发一层层地向外扩展,当第一次找到顶点v时队列中便包含了从顶点u到顶点v的最短路径,再利用队列输出最短路径。

在一个不带权图中搜索从顶点u到v的一条路径时,采用DFS算法求出的路径不一定是最短路径,而采用BFS求出的路径一定是最短路径,为什么?

如果我们按照最短路径长度将顶点分层,起点是第0层,与起点相距最短路径长度为1的放在第一层,依次类推。

因为DFS算法求出的路径中的顶点可能在同一层中,所以该路径不一定是最短路径,而BFS算法求出的路径中所有顶点一定在不同层中,所以一定是最短路径

深度优先遍历

深度优先搜索DFS类似于树的先序遍历。基本思想是:首先访问w1邻接且未被访问的任一顶点w2...重复上述过程。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。

DFS算法的性能分析

DFS算法是一个递归算法,需要借助一个递归工作栈,故其空间复杂度为O(|V|)。遍历图的过程实质上对每个顶点查找其邻接点的过程,其耗费的时间取决于所用的存储结构。见BFS算法性能分析,这里一样。

图的遍历与图的连通性

  • 无向图:如果无向图连通,则从任一结点出发,仅需一次遍历就能够访问图中的所有顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问
  • 有向图:若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点
  • 利用深度优先遍历可以判断图G中是否存在回路。对于无向图来说,若深度优先遍历过程中遇到了回边,则必定存在环;对有向图来说,这条回边可能是指向深度优先森林中另一棵生成树上的顶点的弧,但是,从有向图的某个顶点v出发进行深度优先遍历时,若在DFS结束之前出现一条从顶点u到顶点v的回边,且u在生成树上是v的子孙,则有向图中必定存在包含顶点v和u的环。

如果一条深度遍历的路线有节点被第二次访问到,则必定存在环。我们用一个变量来标记某节点的访问状态,然后判断每一个节点的深度遍历路线即可。

  • 拓扑排序判断是否存在回路:方法是重复寻找一个入度为0的顶点,将该顶点从图中删除(即放进一个队列里存着,这个队列的顺序就是最后的拓扑排序,具体见程序),并将该结点及其所有的出边从图中删除(即该结点指向的结点的入度减1),最终若图中全为入度为1的点,则这些点至少组成一个回路。
  • 一个无向图G是一棵树的条件是,G必须是无回路的连通图或有n-1条边的连通图。对连通的判定,棵用能否遍历全部顶点来实现。可以采用深度优先搜索算法在比那里图的过程中统计可能访问到的顶点个数和边的条数,若一次遍历就能访问到n个顶点和n-1条边,则可断定此图是一棵树

图的基本应用

学习重点:手工模拟给定图的各个算法的执行过程,还需掌握对给定模型建立相应的图去解决问题的方法

最小生成树

一个连通图的生成树包含图的所有顶点,并且只含(n-1)条边。对于生成树来说,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路。对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权也不同,树权值之和最小的那棵生成树,称为G的最小生成树MST。

  • 最小生成树的树形不唯一。当图G中各边权值互不相等时,G的最小生成树是唯一的;若无向连通图G的边数比顶点数少1,即G本身是一棵树,则G的最小生陈述就是它本身。
  • 最小生成树的边的权值之和总是唯一的
  • 最小生成树的边数为顶点数减1

构造最小生成树的算法:假设G=(V,E)是一个带权连通无向图,U是顶点集V的一个非空子集。若(u,v)是一条具有最小权值的边,则必存在一棵包含边(u,v)的最小生成树

GENERIC_MST(G){
    T=NULL;
    while T 未形成一棵生成树;
        do 找到一条最小代价边(u,v)并且加入T后不会产生回路;
            T=T U (u,v)
}
  • 必须只使用该图中的边来构造最小生成树
  • 必须使用且仅使用(n-1)条边来连接图中的n个顶点
  • 不能使用产生回路的边
Prim 算法

初始时从图中任取一丁点加入树T,此时树中只含有一个顶点,之后选择一个与当前T中顶点集合距离最近的顶点,并将该顶点和相应的边加入T,每次操作后T中的顶点数和边数都增1。以此类推,直至图中所有顶点都并入T,得到的T就是最小生成树。此时T中必然有n-1条边。
image

Kruskal 算法

Kruskal算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。

初始时只有n个顶点而无边的非连通图T={V,{}},每个顶点自成一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入T,否则舍弃此边而选择下一条权值最小的边,直到T中所有顶点都在一个连通分量上。
image

避圈法和破圈法

避圈法:招工,先找要价低的工人,小团体中赶剩一个;就是Kruskal算法,每加一条权重最小的边,都不构成一个圈,直到边把所有顶点连接起来为止。

最小生成树不是唯一的。

破圈法: 招工,把人全部放进来,再把要价高的请出去;若G不含圈,则G为最小生成树;若G含圈,任取圈G,把权最大的边割掉(保证仍连通),递归,最后得到最小生成树。

无向图的连通分量和生成树

  • 深度优先生成树
  • 广度优先生成树

image

image

image

image

最短路径

带权路径长度最短的那条路径称为最短路径。求解最短路径的算法通常依赖于一种性质,即两点之间的最短路径也包含了路径上其他顶点间的最短路径。

  • 单源最短路径:Dijkstra算法
  • 每对顶点间的最短路径:Floyd算法

拓扑排序:AOV网

AOV网

若用DAG图表示一个工程,顶点表示活动,用有向边<Vi, Vj>表示活动Vi必须先于活动Vj进行,这种有向图称为顶点表示活动的网络。
image

拓扑排序

在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:

  • 每个顶点出现且只出现一次
  • 若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。

拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面

排序步骤
  1. 从AOV网中选择一个没有前驱的顶点并输出
  2. 从网中删除该顶点和所有以它为起点的有向边
  3. 重复1和2直到AOV网为空或当前网中不存在无前驱的顶点为止

顶点无有向边指向等价于所有先修课程均已经修完。

时间复杂度

由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为O(|V|+|E|)

判断有向图是否存在环?

拓扑排序时,当某顶点不为任何边的头时才能加入序列,存在环路时环路中的顶点一直是某条边的头,不能加入拓扑序列。也就是说,还存在无法找到下一个可以加入拓扑序列的顶点,则说明此图存在回路。

深度优先遍历也可以用来判断:使用深度优先遍历,若从有向图上某个顶点\(u\)出发,在DFS结束之前如果出现一条从顶点\(v\)\(u\)的边,则图中必定存在包含\(v\)\(u\)的环。

可以证明:对有向图中的顶点适当的编号,使其邻接矩阵为三角矩阵且主对角元素全部为0的充分必要条件是:该有向图可以进行拓扑排序

另外,若一个有向图的邻接矩阵为三角矩阵(对角线元素为0),则图中必不存在环,因此其拓扑序列必然存在。

关键路径:AOE网

AOE网

在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销比如时间,称之为用边表示活动的网络

注意,AOE网中的边有权值,而AOC网中的边无权值,仅表示顶点之间的前后关系。

AOE网性质
  1. 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始
  2. 只有在进入某顶点的各有向边所代表的的活动都已经结束时,该顶点所代表的事件才能发生
关键路径

在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),表示整个工程的开始;网中也仅存在一个出度为0的顶点,称为结束顶点(汇点),表示整个工程的结束。

从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。

查找

查找的基本概念

静态查找

适合静态查找表的查找方法有:

  • 顺序查找
  • 折半查找
  • 分块查找

动态查找

在查找的同时对表做修改操作(如插入或删除),则相应的查找表称为动态查找表。如果查找过程中不涉及表的修改操作,则相应的查找表称为静态查找表。

  • 二叉排序树查找
  • 散列查找

线性结构

顺序查找法

平均查找长度:查找运算中时间主要花费在关键字的比较上面,把平均需要和给定值k进行比较的关键字次数称为平均查找长度ASL

一般线性表的顺序查找
  • 查找成功时,顺序查找的平均长度:

\[\mathrm{ASL}_{\text {成功 }}=\frac{1}{n}\sum_{i=1}^{n} i=\frac{n+1}{2} \]

  • 查找失败时,顺序查找的平均长度:

\[\mathrm{ASL}_{\text {失败 }}=n \]

顺序查找的时间复杂度为O(n)

在顺序查找中,可以在R的末尾增加一个关键字为K的记录,称之为哨兵,这样查找过程不再需要判断i是否超界,提高查找速度。while(R[i].key!=k)

有序表的顺序查找

如果在查找之前就已经知道表是关键字有序的,则查找失败时可以不用比较到表的另一端就能返回查找失败的信息,从而降低顺序查找失败的平均查找长度。
image
树中的圆形结点表示有序顺序表中存在的元素;树中的矩形结点称为失败结点(如果有n个结点,则相应地有n+1个查找失败结点),它描述的是那些不在表中的数据值得集合。若查找到失败结点,则说明查找不成功。

查找失败时,查找指针一定走到了某个失败结点,到达失败结点时所查找的长度等于它上面的一个圆形结点的所在层数。

  • 查找失败时,顺序查找的平均长度:

\[\mathrm{ASL}_{\text {不成功 }}=\sum_{j=1}^{n} q_{j}\left(l_{j}-1\right)=\frac{1+2+\cdots+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1} \]

折半查找法:二分查找(要求是有序表)

判定树:树的圆形节点表示一个记录,节点中的值为该记录的关键字值;树中最下面的叶节点都是方形的,它表示查找不成功的情况。

判定树是一棵平衡二叉树,因为每次把一个数组从中间结点分割时,总是把数组分为结点数相差最多不超过1的两个子数组,从而使得对应的判定树的两棵子树高度差的绝对值不超过1。\(\operatorname{mid}=\lfloor(\text { low }+\text { high }) / 2\rfloor\)

所以查找一个不存在的元素所需的比较次数最多为判定树的高度\(\left\lceil\log _{2} (n+1)\right\rceil\),最少是\(\left\lceil\log _{2} (n+1)\right\rceil-1\)

  • 查找成功时的查找长度为从根结点到目的结点的路径上的结点数
  • 查找不成功时的查找长度为从根结点到对应失败结点的父节点的路径上的结点数
  • 元素在比较树的第k层,则查找k次
  • 查找成功时,二分查找的平均查找长度(不妨设判定树中内部节点的总数为\(n=2^h-1\),将该判定树近似看成是高度为\(log_2(n+1)\)的满二叉树):

\[\mathrm{ASL}=\frac{1}{n} \sum_{i=1}^{n} l_{i}=\frac{1}{n}\left(1 \times 1+2 \times 2+\cdots+h \times 2^{h-1}\right)=\frac{n+1}{n} \log _{2}(n+1)-1 \approx \log _{2}(n+1)-1 \\ h表示第h层;2^{h-1}表示第h层的结点个数 \]

  • 二分查找的时间复杂度为\(O\left(\log _{2} n\right)\)

例如,已知11个元素的有序表{7, 10, 13, 16, 19 , 29 , 32 , 33 , 37 , 41 , 43},要查找值为11和32的元素:
image

注意,虽然折半查找效率高,但是要求查找表按关键字有序,而且折半查找需要确定查找的区间,因此要求查找表的存储结构具有随机存取的特性,只适合顺序表,不适合链表。

另外,如果表的插入或删除操作频繁,为维护表的有序性,需要移动表中很多元素,这种由移动元素引起的额外时间开销会抵消折半查找的优点。如果对动态查找表进行高效率的查找,可以采用二叉排序树,树表。

分块查找法:索引顺序查找(要求表分块有序)

吸取了顺序查找和折半查找各自的优点,既有动态结构,又适于快速查找。

基本思想

将查找表分为若干子块。块内的元素可以无序,但块之间是有序的;再建立一个索引表,索引表中的每个元素含有各块的最大关键字和各块中的第一个元素地址,索引表按关键字有序排列。

过程
  1. 首先我们要有一个分块有序的表
  2. 在索引表中确定待查记录所在块,可以顺序查找或折半查找索引表
  3. 在块内顺序查找

image

平均查找长度

有n个元素,每块中有s个元素,分为了b块,在查找成功时的平均查找长度为:

  1. 折半查找

\[\begin{aligned} \operatorname{ASL}_{\text {blk }} &=\mathrm{ASL}_{\text {bn }}+\mathrm{ASL}_{\text {sq }} \\ &=\log _{2}(b+1)-1+\frac{s+1}{2} \\ & \approx \log _{2}(n / s+1)+\frac{s}{2}\left(\text { 或 } \log _{2}(b+1)+\frac{s}{2}\right) \end{aligned} \]

采用折半查找确定块时每块的长度越小越好。

  1. 顺序查找

\[\mathrm{ASL}_{\mathrm{b} \overline{\mathrm{k}}}^{\prime}=\mathrm{ASL}_{\mathrm{bn}}+\mathrm{ASL}_{\mathrm{s}_{4}}=\frac{b+1}{2}+\frac{s+1}{2}=\frac{1}{2}\left(\frac{n}{\mathrm{~s}}+s\right)+1\left(\text { 或 } \frac{1}{2}(b+s)+1\right) \]

采用顺序查找确定块时各块的元素选定为\(\sqrt{n}\)时效果最佳

树形结构

二叉排序树

和折半查找的判定树类似,二叉排序树中的结点作为内部结点,可以添加相应的外部结点。具有n个内部结点的二叉排序树,其外部结点的个数为n+1。关键字比较的次数不超过树的高度。

然而,用折半查找法查找长度为n的有序表,其判定树是唯一的,而含有n个元素的二叉排序树却不唯一。由于元素插入的先后次序不同,所构成的二叉排序树的形态和高度可能不同。

就平均时间性能而言,二叉排序树上的查找和折半查找差不多。但就维护表的有序性而言,二叉排序树更有效,因为无需移动元素,只需要修改指针就可以完成结点的插入和删除。

二叉平衡树

B 树

二叉排序树和平衡二叉树都是用作内查找的数据结构,即被查找数据集不大,可以放在内存中。而接下来介绍的B树是用作外查找的数据结构,其中的数据存放在外存中。

B树,又称为多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B数或为空树,或为满足如下特性的m叉树:

  • 树中每个结点至多有m棵子树,即至多含有m-1个关键字
  • 若根结点不是终端结点,则至少有两棵子树
  • 除根结点外所有非叶子结点至少有\(\lceil m / 2\rceil\)棵子树,即至少含有\(\lceil m / 2\rceil-1\)个关键字
  • 所有叶结点都出现在同一层次,并且不带任何信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)
  • 结点中关键字从左到右递增有序,关键字两侧均有指向子树的指针,左边指针所指子树的所有关键字均小于该关键字,右边指针所指子树的所有关键字均大于该关键字。
    image

B树是所有结点的平衡因子均等于0的多路平衡查找树

B 树的高度(磁盘存取次树)

B树中大部分的操作所需磁盘存取次树与B树的高度称正比。(不包括最后的不带任何信息的叶结点所处的那一层)

\(n\ge 1\), 则对任意一棵包含n个关键字、高度为h、阶数为m的B树:

  1. 因为B树中每个结点最多有m棵子树,m-1个关键字,所以在一棵高度为h的m阶B数中关键字的个数应满足\(n\le m^h-1\), 因此有\(h\ge log_m(n+1)\)
  2. 若让每个结点中的关键字个数达到最少,则容纳同样多关键字的B树的高度达到最大。
B 树的查找

B树查找,只是每个结点都是多个关键字的有序表,在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。

  1. 在B树中找结点
  2. 在结点内找关键字

由于B树常存储在磁盘上,因此前一个查找操作是在磁盘上进行的,而后一个查找操作是在内存中进行的,即在找到目标结点后,先将结点信息读入内存,然后在结点内采用顺序查找法或折半查找法。

B 树的插入

在二叉查找树中,仅需查找到需要插入的终端结点的位置。但是,在B树中找到插入的位置后,并不能简单地将其添加到终端结点中,因此此时可能会导致整棵树不再满足B树定义的要求

image

  1. 定位。找出插入该关键字的最低层中的某个非叶结点
  2. 插入。在B树中,每个非失败结点的关键字个数都在区间\([\lceil m / 2\rceil-1, m-1]\)。插入后的结点关键字个数小于m,可以直接插入;插入后检查被插入结点内关键字的个数,当插入后的结点关键字个数大于m-1时,必须对结点进行分裂。

分裂的方法: 取一个新结点,在插入key后的原结点,从中间位置将其中的关键字分为两部分,左部分包含关键字放在原结点中,右部分包含的关键字放在新节点中,中间位置的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续分裂,直到传到根结点位置,进而导致B树高度增1。

B 树的删除

涉及结点的“合并”问题

当被删关键字k不在终端结点(最底层非叶子结点)中时,可以用k的前驱或后继k1来替代k,然后在相应的结点中删除k1,关键字k1必定落在某个终端结点中,则转换成了被删除关键字在终端结点中的情形。

image

当被删除关键字在终端结点(最底层非叶结点)中时:

  1. 直接删除关键字。若被删除关键字所在结点的关键字个数\(\ge\lceil m / 2\rceil\),表明删除该关键字后仍满足B树的定义,则直接删去该关键字
  2. 兄弟够借。若被删除关键字所在结点删除前的关键字个数\(=\lceil m / 2\rceil-1\),且与此结点相邻的右或左兄弟结点的关键字个数\(\ge\lceil m /2\rceil\),则需要调整该结点、右或左兄弟结点及其双亲结点(父子换位法),以达到新的平衡。
  3. 兄弟不够借。若被删除关键字所在结点删除前的关键字个数\(=\lceil m / 2\rceil-1\),且此时与该结点相邻的左右兄弟结点的关键字个数均\(=\lceil m / 2\rceil-1\),则将关键字删除后与左或右兄弟结点及双亲结点中的关键字进行合并。

image

B+ 树

一棵m阶B+树需满足下列条件
  1. 每个分支结点最多有m棵子树
  2. 非叶根结点至少有两棵子树,其他每个分支结点至少有\(\lceil m / 2\rceil\)棵子树
  3. 结点的子树个数与关键字个数相等
  4. 所有叶结点包含全部关键字及指向相应记录的指针,叶结点将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来
  5. 所有分支结点(可视为索引的索引)中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针
m 阶的 B+ 树和 m 阶的 B 树主要差异如下:
  1. 在B+树中,具有n个关键字的结点只含有n棵子树,即每个关键字对应一棵子树;而在B树中,具有n个关键字的结点含有n+1棵子树。
  2. 在B+树中,每个结点(非根内部结点)的关键字个数n的范围是\(\lceil m / 2\rceil\le n \le m\),根结点的范围是\(1\le n \le m\);而在B树中,每个结点(非根内部结点)的关键字个数n的范围是\(\lceil m / 2\rceil-1\le n \le m-1\),根结点的范围是\(1\le n \le m-1\)
  3. 在B+树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址
  4. 在B+树中,叶结点包含了全部的关键字,即在非叶结点中出现的关键字也会出现在叶结点中;而在B树中,叶结点包含的关键字和其他结点包含的关键字是不重复的。
  5. B+树上的查找,在查找过程中,非叶结点上的关键字值等于给定值时并不终止,而是继续向下查找,直到叶结点上的该关键字为止,所以在B+树中查找,无论成功与否,每次查找都是一条从根结点到叶结点的路径

image

散列结构

散列表

基本概念

在前面介绍的线性表和树表的查找中,记录在表中的位置与记录的关键字之间不存在确定关系,因此,在这些表中查找记录时需进行一系列关键字比较。这类查找方法建立在比较的基础上,查找的效率取决于比较的次数。
散列函数: 一个把查找表中关键字映射成该关键字对应的地址的函数,记为Hash(key)=Addr(这里的地址可以是数组下表、索引或内存地址)

散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为冲突,这些发生碰撞的关键字称为同义词

散列表: 根据关键字而直接进行访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。理想情况下,对散列表进行查找的时间复杂度为O(1),即与表中元素的个数无关。

散列函数的构造方法

散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围; 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生;散列函数应尽量简单,能够在较短的时间内计算出任一关键字对应的散列地址。

  • 直接定址法:\(H(key)=a\times key+b\)
  • 除留余数法:\(H(key)=key\%p\)
  • 数字分析法
  • 平方取中法
冲突处理

1.3.1 开放定址法

1.3.2 拉链法
把所有同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。
image

关键字序列为{19,14,23,01,68,20,84,27,55,11,10,79},散列函数\(H(key)=key\%13\),用拉链法处理冲突

性能分析

1.4.1 步骤

  1. 初始化:Addr=Hash(key)
  2. 检查表中地址为Addr的位置上是否有记录,若无记录,则返回查找失败;若有记录,比较它与Key的值,若相等,则返回查找成功的标志,否则执行步骤3
  3. 用给定的处理冲突方法计算下一个散列地址,并把Addr置为此地址,转为步骤1

1.4.2 查找效率的度量

  • 散列函数
  • 处理冲突的方法
  • 装填因子:定义为一个表的装满程度

\[\alpha = \frac{表中记录数n}{散列表长度m} \]

效率指标

平均查找长度ASL

查找成功

\[\mathrm{ASL}_{\text {成功 }}=\sum_{i=1}^{n} p_{i} c_{i} \]

设一个查找集合中已有n个数据元素,每个元素的查找概率为\(p_i\),查找成功的比较次数为\(c_i\)

查找失败

\[\mathrm{ASL}_{\text {失败 }}=\sum_{i=1}^{n} q_{j} c_{j} \]

设一个查找集合中已有n个数据元素,不在此集合中的数据元素分布在这n个元素的间隔构成的n+1个子集合内,每个子集合元素的查找概率为\(q_j\),查找不成功的比较次数为\(c_j\)

概率分开考虑

\[\sum_{i=1}^{n} p_i = 1\\ \sum_{j=0}^{n} q_j = 1\\ p_i = \frac{1}{n}\\ q_j = \frac{1}{n+1} \]

排序

排序的基本概念

排序:整理表中的元素,使之按关键字递增或递减有序排列。

算法的稳定性: 若待排序列表中有两个元素\(R_i\)\(R_j\),其对应的关键字相同即\(key_i=key_j\),且在排序前\(R_i\)\(R_j\)的前面,若使用某一排序算法排序后,\(R_i\)仍在\(R_j\)前面,则称这个排序算法是稳定的。

算法是否具有稳健性并不能衡量一个算法的优劣,它主要是对算法的性质进行描述。如果待排序表中的关键字不允许重复,则排序结果是唯一的,那么选择排序算法时的稳定与否就无关紧要。

根据数据元素是否完全在内存中,可以将排序算法分为两类:1. 内部排序,在排序期间元素全部存放在内存中的排序;2. 外部排序,指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内外存之间移动的排序。

一般情况下,内部排序算法在执行过程中都要进行两种操作:比较和移动。通过比较两个关键字的大小,确定对应元素的前后关系,然后通过移动元素以达到有序

排序算法最快能有多快?

image

n个元素,有\(n!\)种排序结果,对应的判定树是一颗有\(n!\)个叶子节点的高度最小的二叉树,其中单分支节点个数为0,结点总数=\(n_0+n_2=2n!-1\),如高度为h,可以求出:

\[h=\left\lceil\log _{2} 2 n !\right\rceil=\left\lceil\log _{2} n !\right\rceil+1\\ \left[\log _{2} n !\right] \approx n \log _{2} n \]

解释一下为什么结点总数是\(n_0+n_2=2n!-1\),因为结点总数\(n=n_0+n_1+n_2\),枝的根数\(n-1=2n_2\),联立两个式子就有\(n_2=n_0+n_1-1\)

对应的关键字比较次数最多为\(h-1\),大约需要\(n \log _{2} n\)次关键字比较,移动次数也是同样的数量级,所以排序时间复杂度为\(O(n \log _{2} n)\)

插入排序

基本思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成

直接插入排序

有序序列L[1...i-1]L(i)无序序列L[i+1...n]
  1. 先将L(i)暂时放到tmp
  2. 查找L(i)在L[1...i-1]中的插入位置k
  3. 将L[k...i-1]中的所有元素依次向后一个位置
  4. 将tmp值复制到L(K)

插入排序在实现上通常采用就地排序,空间复杂度为O(1),因而在从后向前的比较过程中,需要反复把已排序元素逐步向后挪位,为新元素提供插入空间

算法分析
  1. 最好情况:初始数据序列按关键字递增有序(正序),每趟只需要比较一次。C代表比较次数,M代表移动次数

\[C_{\min }=\sum_{i=1}^{n-1} 1=n-1, \quad M_{\min }=0 \]

  1. 最坏情况:反序,每趟中待插入元素L[i]需要和有序区所有元素进行i次关键字比较(有序区L[0,..i-1]),所有元素均后移,进行i次移动,再加上tmp的两次,每趟进行i+2次移动

\[\begin{aligned} &C_{\max }=\sum_{i=1}^{n-1} i=\frac{n(n-1)}{2}=O\left(n^{2}\right) \\ &M_{\max }=\sum_{i=1}^{n-1}(i+2)=\frac{(n-1)(n+4)}{2}=O\left(n^{2}\right) \end{aligned} \]

  1. 平均情况:每趟中待插入元素L[i]需要和有序区所有元素进行i/2次关键字比较,平均移动元素次数为i/2+2

\[\sum_{i=1}^{n-1}\left(\frac{i}{2}+\frac{i}{2}+2\right)=\sum_{i=1}^{n-1}(i+2)=\frac{(n-1)(n+4)}{2}=O\left(n^{2}\right) \]

插入排序是一种稳定的排序算法,因为如果后来的牌大小和前面一张一样,则放在相同的前面那张的后面,保证相同元素之间的前后关系不变(有序区的元素只有大于待插入元素才会往后移,等于的时候不移动)

折半插入排序

折半插入排序将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素

和普通插入相比,移动次数没变,不过比较次数减少了。

\[\sum_{i=1}^{n-1}\left(\log _{2}(i+1)-1+\frac{i}{2}+2\right)=O\left(n^{2}\right) \]

希尔排序

基本思想: 先将待排序表分割成若干形如L[i,i+d,i+2d...,i+kd]的特殊子表,即把相隔某个增量\(d_1\)的记录组成一个子表,对各个子表分别进行直接插入排序;然后取第二个增量\(d_2\),重复上述的分组和排序,直到所取的增量=1,即所有元素放在同一组中进行直接插入排序为止。

希尔排序是减少增量的排序方法。希尔排序每趟并不产生有序区,在最后一趟排序结束前,所有元素并不一定归位了,但是在希尔排序每趟完成后的数据越来越接近有序。

排序过程

image

算法分析

一般认为希尔排序的时间复杂度为\(O(n^{1.3})\)

希尔排序比直接插入排序好,因为:

  1. 希尔排序开始时增量\(d_1\)较大,分组较多,每组元素较少,所以各组内直接插入较快
  2. 后来增量\(d_i\)逐渐缩小,分组数逐渐减少,而各组元素数目逐渐增多,但由于已经按\(d_{i-1}\)作为距离排过序,使数据接近于有序的状态,所以新的一趟排序过程也比较快。

希尔排序算法空间复杂度为\(O(1)\),属于就地排序

希尔排序算法是一种不稳定的算法。因为两个相同的元素可能分到不同的组中,在不同的组中交换次序可能就会让他们的相对次序发生改变。

一般来说,一个排序算法在排序过程中需要以较大的间隔交换元素或者把元素移动一个较大的距离时该排序方法就是不稳定的,因为可能把排在前面的元素移动到具有相同关键字的另一个元素后面。

交换排序

所谓交换,是指根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。

冒泡排序

基本思想

从后往前或从前往后两两比较相邻元素的值,若为逆序,则交换它们,直到序列比较完。第一趟冒泡结果是将最小的元素交换到待排序列的第一个位置(或将最大的元素交换到待排序列的最后一个位置),关键字最小的元素如气泡一样逐渐往上漂浮到水面,或像石头一样下沉到水底。++下一趟冒泡时,前一趟确定的最小元素不再参与比较。++ 每趟冒泡的结果是把序列中的最小元素放到序列的最终位置,这样n-1趟冒泡就能把所有元素排好序

改进:一旦算法中某一趟比较时不出现任何元素交换,说明已经排好序了,就可以结束算法

算法分析
  1. 最好情况:初始序列正序

\[C_{\min }=n-1, \quad M_{\min }=0 \]

  1. 最坏情况:初始序列反序,要进行\(n-1\)趟排序,第\(i\)趟比较次数为\(n-i\),且每次比较都必须移动元素3次来达到交换元素位置的目的

\[\begin{aligned} &C_{\max }=\sum_{i=1}^{n-1}(n-i)=\frac{n(n-1)}{2}=O\left(n^{2}\right) \\ &M_{\max }=\sum_{i=1}^{n-1} 3(n-i)=\frac{3 n(n-1)}{2}=O\left(n^{2}\right) \end{aligned} \]

  1. 平均情况时间复杂度:\(O(n^2)\)

  2. 冒泡排序算法空间复杂度为\(O(1)\),属于就地排序。也是稳定的排序方法。

快速排序

image

基本思想

快速排序基本思想是基于分治法,在待排序表L[1...n]中任取一个元素pivot作为枢轴量,通过一趟排序将待排序列表划分为独立的两部分L[1...k-1]和L[k+1...n],使得L[1...k-1]中的所有元素都小于pivot,L[k+1...n]中的所有元素都大于等于pivot。分别递归地对两个子表重复上述过程,直至每部分只有一个元素或空位置。

算法分析
  1. 空间效率:快速排序是递归的,需要借助一个递归工作栈来保存每层递归调用的必要信息,其容量应与递归调用的最大深度一致(也就是递归的次数),最好情况是\(O(logn)\)

递归算法的空间复杂度=每次递归的空间复杂度*递归深度,一般情况下,每次递归中需要的空间是一个常量,为O(1); 而每次递归所需的空间被压到调用栈里(这是内存管理里面的数据结构,和算法中的栈一样),看递归算法的空间消耗,就是要看调用栈所占用的大小,一次递归结束,这个栈就是把本次递归的数据弹出去。所以这个栈最大的长度就是递归的深度。把一个分支的函数压进栈。

  1. 快速排序的运行时间与划分是否对称有关,最坏情况发生在两个区域分别包含n-1个元素和0个元素时,这种最大程度的不对称性若发生在每层递归上,即对应初始排序表基本有序或基本逆序时,递归树的高度为\(O(n)\),需要做\(n-1\)次划分,得到最坏情况下时间复杂对为\(O(n^2)\); 空间复杂度为\(O(n)\)
  2. 最好情况下每次都得到最平衡的划分,这样的递归树的高度为\(O(logn)\),而每一层划分的时间为\(O(n)\)(目标元素要和剩下的所有元素做一次比较),所以时间复杂度\(O(nlogn)\);空间复杂度为\(O(logn)\)
  3. 平均情况:每一次划分将n个元素划分两个长度分别为\(k-1\)\(n-k\)的子区间,k的取值范围为1~n,共n种情况。计算可知,算法的平均时间复杂度接近最好情况。

\[\begin{aligned} &T_{\text {avg }}=c n+\frac{1}{n} \sum_{k=1}^{n}\left(T_{\text {avg }}(k-1)+T_{\text {avg }}(n-k)\right)\\ &T_{\text {avg }}=O\left(n \log _{2} n\right) \end{aligned} \]

image

  1. 快速排序算法不是就地算法(用到递归工作栈)
  2. 快速排序算法是一种不稳定的排序算法

一般来说,一个排序算法在排序过程中需要以较大的间隔交换元素或者把元素移动一个较大的距离时该排序方法就是不稳定的,因为可能把排在前面的元素移动到具有相同关键字的另一个元素后面。

提高算法的效率:一种是尽量选取一个可以将数据中分的枢轴元素,如从序列的头尾及中间选取三个元素,再取三个元素的中间值作为最终的枢轴元素;或者随机从当前表中选取枢轴元素,这样可以使得最坏情况再实际排序中几乎不会发生。

在快速排序算法中,并不产生有序子序列,但是每趟排序后会将枢轴元素放到最终的位置上。

当待排序数据为基本有序时,每次选取第n个元素为基准,会导致划分区间分配不均匀,不利于发挥快速排序算法的优势。相反,当待排序数据分布较为随机时,基准元素能够将序列划分为两个长度大致相等的序列,这时才能发挥快速排序算法的优势。

选择排序

基本思想: 每一趟在待排序元素中选取关键字最小的元素,顺序放在已排好序的子表的最后,直到全部元素排序完毕。由于选择排序方法每一趟藏式从无序区中选出全局最大或最小的关键字,所以适合于从大量的元素中选择一部分排序元素,例如从1000个元素中选择出关键字大小为前10的元素。

简单选择排序

基本思想

假设排序表为L[1...n],第i趟排序即从L[i...n]中选择关键字最小的元素与L(i)交换,每一趟排序可以确定一个元素的最终位置,这样经过n-1趟就可使得整个排序表有序

image

算法分析

在第\(i\)趟排序中选出最小关键字的元素,内for循环需要做\(n-i\)次比较

\[C(n)=\sum_{i=1}^{n-1}(n-i)=\frac{n(n-1)}{2}=O\left(n^{2}\right) \]

至于元素的移动次数,当初始表为正序时,移动次数为0;当表初态为反序时,每趟排序均要执行交换操作,所以总的移动次数为最大值\(3(n-1)\),然而,无论元素的初始序列如何排列,所需进行的关键字比较相同,因此总的平均时间复杂度为\(O(n^2)\)

简单选择排序算法空间复杂度为\(O(1)\),是就地排序。

另外,简单选择排序算法是一个不稳定的排序算法,因为你在选择最小的数和第一个数交换的时候,可能把第一个数调到了相同数的后面。

堆排序

堆排序是一种属性选择排序方法,将序列看成是一棵完全二叉树的顺序存储结构,将这组序列的数据依次放入完全二叉树中,如果双亲节点和孩子节点满足如下的关系:

\[k_{i} \leqslant k_{2 i} \text { 且 } k_{i} \leqslant k_{2 i+1} \]

这样的堆称为小根堆

如果满足:

\[k_{i} \geqslant k_{2 i} \text { 且 } k_{i} \geqslant k_{2 i+1}(1 \leqslant i \leqslant\lfloor n / 2\rfloor) \]

则称为大根堆

基本思想

首先将存放在L[1...n]中的n个元素建成初始堆,堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已经不满足大顶堆的性质,将堆顶元素向下调整使其继续保持大顶堆的 性质,再输出堆顶元素。
image

  1. 建立初始堆:对所有分支结点所连接的子树应用sift()算法,自下而上,自右而左对所以分支结点应用该算法
  2. 反复重建堆:将根节点放入数组R中;将序号最大的元素放在根的位置
算法分析

堆排序的时间主要由建立初始堆反复重建堆这两部分的时间构成,均调用sift()实现。

对于高度为k的完全二叉树,调用sift()算法时,while循环最多执行\(k-1\)次,所以最多进行\(2(k-1)\)次关键字比较,最多进行\(k+1\)次元素移动,因此主要以关键字比较来分析时间性能。

堆排序的最坏时间复杂度为\(O(nlogn)\)

由于初始建堆所需要的比较次数较多,所以堆排序不适合元素数较少的排序表。

在进行筛选时可能把后面相同把关键字的元素调整到前面,所以堆排序算法是一种不稳定的排序方法。

通常,取一大堆数据中的k个最大或最小元素时,都优先采用堆排序。

堆排序利用了顺序存储的随机访问特性,只能用于顺序存储方式,而且堆排序是用来排序的,不能用于查找(和二叉排序树区分)

二路归并排序

归并的含义是将两个或两个以上的有序表组合成一个新的有序表组合成一个新的有序表。

基本思想

将长度为n的数组看成是n个长度为1的有序序列。然后进行两两归并,得到\(\left\lceil\ n/2 \right]\)个长度为2的(最后一个有序序列长度可能小于2)的有序序列,再进行两两归并,得到\(\left\lceil\ n/4 \right]\)个长度为4的有序序列(最后一个有序序列长度可能小于4),...一直得到一个长度为n的有序序列。

  1. 先把两段有序表复制到辅助数组B
  2. 每次从B中两个段取出一个位序较低的记录进行关键字的比较,较小者放入A中
  3. 当数组中有一段的下标超出其对应的表长时,将另一段中的剩余部分直接复制到A中

归并如何使用

  1. 分解:将列表越分越小,直至分成一个元素
  2. 一个元素是有序的
  3. 将两个有序列表归并,列表越来越大

归并算法分析

  1. 对于长度为n的排序表,二路归并需要进行\(\left\lceil\log _{2} n\right\rceil\)趟,每趟归并时间为\(O(n)\),所以其平均时间复杂度为\(O(nlog _{2} n)\)
  2. 总的辅助空间复杂度为\(O(n)\)
  3. 二路归并排序是一种稳定的排序算法

归并排序每趟产生的有序区只是局部有序的,也就是说在最后一趟排序结束前所有元素并不一定归位了。

基数排序

原理:分配和收集

基数排序是通过分配和收集过程来实现排序,不需要进行关键字的比较,是一种借助于多关键字排序的思想对单关键字排序的方法。

分配:把元素放到桶里;收集:把元素从桶里串起来

元素由\(d\)位数字或者字符组成,r是基数,如果是10进制,r=10

\[\begin{aligned} & K_{\mathrm{d}-1} K_{\mathrm{d}-2} \ldots K_{2} K_{1} K_{0} \end{aligned} \]

基数排序有两种,分为最低位优先和最高位优先,这里讨论最低位优先。

先按最低位的值对元素进行排序,在此基础上再按次低位进行排序,以此类推。由高位向低位,每趟都是根据关键字的一位并在前一趟的基础上对所有元素进行排序,直至最高位。

在对一个数据序列排序时采用最低位优先还是最高位优先排序方法是由数据序列的特点决定。例如对整数序列递增排序,由于个位数的重要性低于十位数,一般越重要的位越放在后面排序。

image

简单说来,基数排序的基本思想就是:把元素先从个位排好序,以此为基础再从十位排好序...一直到元素中最大数的最高位排好序,那么整个元素就是有序的了。

步骤

在排序过程中使用r个队列 \(Q_{0}, Q_{1}, \cdots, Q_{r-1}\),排序过程如下:

  1. 分配:开始时,把 \(Q_{0}, Q_{1}, \cdots, Q_{r-1}\)各个队列置成空队列,然后依次考察线性表中每一个元素\(a_j\),如果元素 \(a_j\) 的关键字=k,就把元素 \(a_j\)插入到队列 \(Q_k\)
  2. 收集:将 \(Q_{0}, Q_{1}, \cdots Q_{r-1}\)各个队列中的元素依次首尾相接,得到新的元素序列,从而组成新的线性表。
  3. 在d趟执行后数据序列就有序了

算法分析

  1. 在基数排序中共进行了d趟(关键字由d位数字组成)的分配和收集。每一趟分配需要扫描所有结点,而收集过程是按队列进行的,所以一趟的执行时间是 \(O(n+r)\),因此基数排序的时间复杂度为 \(O(d(n+r))\)
  2. 在基数排序中第一趟排序需要的辅助存储空间为r,因为要创建r个队列,但是以后的各趟排序中重复使用这些队列,所以总的辅助空间复杂度为 \(O(r)\)
  3. 在基数排序中使用的是队列,排在后面的元素只能排在相同关键字元素的后面,相对位置不会发生变化,它是一种稳定的排序算法

基数排序每趟并不产生有序区,在最后一趟排序结束前所有元素并不一定归位了

外部排序

各种排序算法的比较

image

  • 若n较小,可采用直接插入或简单选择排序
  • 若文件初始状态基本有序(正序),则选用直接插入或冒泡排序
  • 若n较大,应采用时间复杂度为 \(O(nlogn)\) 的排序方法;当待排序的关键字是随机分布时,快速排序的平均时间最少;但堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况;但这两种排序都是不稳定的,若要排序稳定,可选择二路归并排序
  • 如果需要将两个有序表合并成一个新的有序表,最好用二路归并排序算法。
  • 注意选择排序中的简单选择排序和堆排序,其时间性能和初始序列的顺序无关,其最好、最坏、平均情况都是一样的

排序算法的应用

排序趟数和原始序列相关吗?

  1. 交换类的排序:其趟数和原始序列状态有关;
  2. 直接插入排序:每趟排序都插入一个元素,所以排序趟数固定为n-1
  3. 简单选择排序:每趟排序都选出一个最小或最大的元素,所以排序趟数固定为 n-1
  4. 基数排序:每趟排序都进行分配和收集,排序趟数固定为 d

空间复杂度

  1. 堆排序的空间复杂度:\(O(1)\)
  2. 快速排序的空间复杂度:最坏 \(O(n)\),最好 \(O(logn)\)
  3. 归并排序的空间复杂度:\(O(n)\)
  4. 基数排序的空间复杂度:\(O(r)\)

每趟排序都至少确定一个元素最终位置

  1. 冒泡排序、简单选择排序、快速排序和堆排序每趟都至少确定一个元素最终位置
  2. 希尔排序每次对划分的子表进行排序,得到局部有序的结果,不能保证每趟排序结束都能确定一个元素最终位置
  3. 直接插入排序也不能保证每趟排序结束都能确定一个元素的最终位置
  4. 2路归并排序每趟对子表进行两两归并,从而得到若干局部有序的结果,但无法确定最终位置

数据基本有序时

数据基本有序时,直接插入排序的效率最高,冒泡和直接插入的时间复杂度都是 \(O(n)\),而归并排序仍然是 \(O(nlogn)\)

数据基本有序时,最好不要用快速排序

posted @ 2023-05-08 14:01  Lockegogo  阅读(105)  评论(0编辑  收藏  举报