说明:
1、本文档为作者考研准备数据结构时期所作笔记,所用资料为王道视频。
2、文中少部分内容来源:王道数据结构笔记
3、文中部分图片、文字内容来源于互联网
4、文中一些示例图片由作者绘制,出现错误难以避免,如发现错误,欢迎指出
5、根据网友的需求,这里提供可以在本地进行编辑的原始文档文件,包含PDF版本和Markdown版本,各位可根据需要自行下载。
原始文档链接(原链接失效,重新分享了):
PDF:
链接: https://pan.baidu.com/s/1IgCAFvz6WqXGPO9mc4s3iQ?pwd=ai1c 提取码: ai1c
markdown:
链接: https://pan.baidu.com/s/1ebAKKlfTrgDAz5EmOEhl3w?pwd=6ebb 提取码: 6ebb
文章目录
- 第一章 绪论
- 二、线性表
- 第三章 栈和队列
- 第四章 串
- 第五章 树
- 第六章 图
- 第七章 查找
- 第八章 排序
第一章 绪论
1.1数据结构的基本概念
1.数据:数据是信息的载体,是描述客观事物属性的数、字符以及所有能输入到计算机中并被程序识别和处理的符号的集合。
2.数据元素:数据元素是数据的基本单位,通常作为一个整体进行考虑和处理。一个数据元素可由若干数据项组成,数据项是构成数据元素的不可分割的最小单位。例如,学生记录就是一个数据元素,它由学号、姓名、性别等数据项组成。
3.数据对象:数据对象是具有相同性值的数据元素的集合,是数据的一个子集。
4.数据类型:数据类型是一个值的集合和定义再此集合上的一组操作的总称。
1)原子类型。其值不可再分的数据类型。如bool 和int 类型。
2)结构类型。其值可以再分解为若干成分(分量)的数据类型。
3)抽象数据类型。抽象数据组织及与之相关的操作。
5.数据结构:数据结构是相互之间存在一种或多种特定关系的数据元素的集合。
6.ADT:ADT是指抽象数据的组织和与之相关的操作。可以看作是数据的逻辑结构及其在逻辑结构上定义的操作
【例】在数据结构中,ADT称为抽象数据类型,它是指一个数学模型以及定义在该模型上的一组_______。
【答案】操作
1.2数据结构的三要素
1.数据的逻辑结构:
逻辑结构是指数据元素之间的逻辑关系,即从逻辑关系上描述数据。
逻辑结构包括:
- 集合结构:结构中的数据元素之间除“同属一个集合”外,别无其它关系。
- 线性结构:结构中的数据元素之间只存在一对一的关系,除了第一个元素,所有元素都有唯一前驱;除了最后一个元素,所有元素都有唯一后继。
- 树形结构:结构中数据元素之间存在一对多的关系。
- 图状结构:数据元素之间是多对多的关系。
2.数据的存储结构(物理结构):
存储结构是指数据结构在计算机中的表示(又称映像),也称物理结构。
存储结构包括:
- 顺序存储:把逻辑上相邻的元素存储在物理位置也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
- 链式存储:逻辑上相邻的元素在物理位置上可以不相邻,借助指示元素存储地址的指针来表示元素之间的逻辑关系。
- 索引存储:在存储元素信息的同时,还建立附加的索引表,索引表中的每项称为索引项,索引项的一般形式是(关键字,地址)
- 散列存储:根据元素的关键字直接计算出该元素的存储地址,又称哈希(Hash)存储。
3.数据的运算:施加在数据上的运算包括运算的定义何实现。运算的定义是针对逻辑结构的,指出运算的功能;运算的实现是针对存储结构的,指出运算的具体操作步骤。
1.3算法的基本概念
程序=数据结构+算法
算法(algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。
算法的特性:
1.有穷性:一个算法必须总在执行有穷步之后结束,且每一步都可在有穷时间内完成。
算法必定是有穷的,程序可以是无穷的
2.确定性:算法中每条指令必须有确定的含义,对于相同的输入只能得到相同的输出。
3.可行性:算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现。
4.输入:一个算法有零个或多个输入,这些输入取自于某个特定的对象的集合。
5.输出:一个算法有一个多个输出,这些输出是与输入有着某种特定关系的量。
好的算法达到的目标:
-
正确性:算法应能够正确的求接问题。
-
可读性:算法应具有良好的可读性,以帮助人们理解。
算法可以用伪代码或文字描述,关键是无歧义地描述出解决问题的步骤
-
健壮性:输入非法数据时,算法能适当地做出反应或进行处理,而不会产生莫名奇妙地输出结果。
-
效率与低存储量需求:效率是指算法执行的时间,存储量需求是指算法执行过程中所需要的最大存储空间,这两者都与问题的规模有关。
效率:执行速度快,时间复杂度低
低存储量:不费内存,空间复杂度低
*算法的运行时长会因为性能、编程语言、编译产生的代码质量相关,且会有不能事后统计的算法,这种算法使用时间复杂度来进行评估。
1.4算法的时间复杂度
一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数f(n),算法的时间量度记作
T
(
n
)
=
O
(
f
(
n
)
)
T(n)=O(f(n))
T(n)=O(f(n))
它表示随问题规模n的增大而增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称时间复杂度。取f(n)中随n增长最快的项,将其系数置为1作为时间复杂度的度量。
时间复杂度:事先预估算法时间开销T(n)与问题规模n的关系。
时间复杂度还有最好时间复杂度、最坏时间复杂度和平均时间复杂度。其中,最好时间复杂度的参考意义不大。
在分析一个程序的时间复杂度时,有以下两条规则:
(1) 加法规则
T
(
n
)
=
T
1
(
n
)
+
T
2
(
n
)
=
O
(
f
(
n
)
)
+
O
(
g
(
n
)
)
=
O
(
m
a
x
(
f
(
n
)
,
g
(
n
)
)
)
T(n)=T_1(n)+T_2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n)))
T(n)=T1(n)+T2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n)))
多项相加,只保留最高项
(2) 乘法规则
T
(
n
)
=
T
1
(
n
)
×
T
2
(
n
)
=
O
(
f
(
n
)
)
×
O
(
g
(
n
)
)
=
O
(
f
(
n
)
×
g
(
n
)
)
T(n)=T_1(n)×T_2(n)=O(f(n))×O(g(n))=O(f(n)×g(n))
T(n)=T1(n)×T2(n)=O(f(n))×O(g(n))=O(f(n)×g(n))
多项连乘,都保留
常见的渐进时间复杂度为:
O
(
1
)
<
O
(
l
o
g
2
n
)
<
O
(
n
)
<
O
(
n
l
o
g
2
n
)
<
O
(
n
2
)
<
O
(
n
3
)
<
O
(
2
n
)
<
O
(
n
!
)
<
o
(
n
n
)
O(1)<O(log_2n)<O(n)<O(nlog_2n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<o(n^n)
O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)<o(nn)
1.5算法的空间复杂度
算法的空间复杂度S(n)定义为该算法所耗费的存储空间,它是问题规模n的函数。记为S(n)=O(g(n))。
算法原地工作所需内存
空间复杂度大多数情况下等于递归调用的深度。
二、线性表
2.1线性表的定义
线性表是具有相同数据类型的n(n>0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。
2.2顺序表的定义
2.2.1. 顺序表的基本概念
线性表的顺序存储又称 顺序表 。它是用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得**逻辑上相邻的两个元素在物理上也相邻。**顺序表的特点是 表中元素的逻辑顺序与其物理顺序相同。
特点:
- 随机访问,即可以在 O(1)时间内找到第 i 个元素。
顺序表最大的特点是随机访问,可通过首地址和元素序号在O(1)的时间复杂度内找到指定的元素,因为顺序表是连续存放的。
-
存储密度高,每个节点只存储数据元素。
-
拓展容量不方便(即使使用动态分配的方式实现,拓展长度的时间复杂度也比较高,因为需要把数据复制到新的区域)。
-
插入删除操作不方便,需移动大量元素:O(n)
2.2.2. 顺序表的实现
静态实现:
// 顺序表实现(静态分配)
#define MaxSize 10
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int data[MaxSize];
int length;
} SqList;
void InitList(SqList &L) {
L.length = 0;
}
int main() {
SqList L;
InitList(L);
for (int i = 0; i < MaxSize; i++) {
printf("data[%d]=%d\n", i, L.data[i]);
}
printf("%d", L.length);
return 0;
}
动态实现:
//顺序表实现(动态分配)
#define InitSize 10 // 初始化顺序表的长度
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *data; // 声明动态分配数组的指针
int MaxSize; // 最大长度
int length; // 当前长度
} SeqList;
// 初始化顺序表
void InitList(SeqList &L) {
// 用malloc函数申请一片连续的存储空间
L.data = (int *) malloc(sizeof(int) * InitSize);
L.length = 0;
L.MaxSize = InitSize;
}
// 增加动态数组的长度,本质上是将数据从旧的区域复制到新区域
void IncreaseSize(SeqList &L, int len) {
// 使p指针和data指向同一目标
int *p = L.data;
L.data = (int *) malloc(sizeof(int) * (L.MaxSize + len)); // 申请一块新的连续空间用于存放新表,并将data指针指向新区域
for (int i = 0; i < L.length; i++) {
L.data[i] = p[i]; //将数据复制到新区域
}
L.MaxSize += len;
free(p); //释放原区域的内存空间
}
// 打印顺序表
void printList(SeqList L) {
for (int i = 0; i < L.length; i++) {
printf("%d, ", L.data[i]);
}
}
int main() {
SeqList L;
InitList(L);
printf("增加前顺序表的长度:%d \n", L.MaxSize);
printList(L);
IncreaseSize(L, 5);
printf("增加后顺序表的长度:%d \n", L.MaxSize);
return 0;
}
malloc() 函数的作用:会申请一片存储空间,并返回存储空间第一个位置的地址,也就是该位置的指针。
2.2.3. 顺序表的基本操作
插入
// 将元素e插入到顺序表L的第i个位置
bool ListInsert(SqList& L, int i, int e) {
if (i < 1 || i > L.length + 1)
return false;
if (L.length >= MaxSize)
return false;
for (int j = L.length; j >= i; j--) { // 将第i个元素及之后的元素后移
L.data[j] = L.data[j - 1];
}
L.data[i - 1] = e; // 在位置i处插入元素e
L.length++;
return true;
}
最好时间复杂度:O(1)(插入在表尾)
最坏时间复杂度:O(n)(插入在表头)
平均时间复杂度:O(n)
删除
bool ListDelete(SqList& L, int i, int& e) {
if (i < 1 || i > L.length) { //判断i的范围是否有效
return false;
}
e = L.data[i - 1];
for (int j = i; j < L.length; j++) { //将第i个位置后的元素前移
L.data[j - 1] = L.data[j];
}
L.length--;
return true;
}
最好时间复杂度:O(1)(删除表尾元素)
最坏时间复杂度:O(n)(删除表头元素)
平均时间复杂度:O(n)
查找
- 按位查找
// 按位查找
int getElemByLoc(SqList L, int i) {
return L.data[i - 1];
}
时间复杂度:O(1)
因为顺序表是连续存放的,故可以在O(1)的时间复杂度内通过下标找到元素。
- 按值查找
int getElemByValue(SqList L, int value) {
for (int i = 0; i < L.length; i++) {
if (L.data[i] == value) {
return i + 1;
}
}
return 0;
}
注意:在数据结构初试中,手写代码可以直接使用“==”,无论Elemtype是基本数据类型还是结构体类型。
最好时间复杂度:O(1)
最坏时间复杂度:O(n)
平均时间复杂度:O(n)
2.3 链表
2.3.1 单链表
- 单链表:用链式存储实现了线性结构。一个结点存储一个数据元素,各结点间的前后关系用一个指针表示。
- 特点:
- 优点:不要求大片连续空间,改变容量方便。插入和删除操作不需要移动大量元素
- 缺点:不可随机存取,要耗费一定空间存放指针。
- 两种实现方式:
- 带头结点,写代码更方便。头结点不存储数据,头结点指向的下一个结点才存放实际数据。(L = NULL;)
- 不带头结点,麻烦。对第一个数据结点与后续数据结点的处理需要用不同的代码逻辑,对空表和非空表的处理需要用不同的代码逻辑。(L->next = NULL)
单链表中结点类型的描述如下:
typedef struct LNode {
int data;
struct LNode* next; // 由于指针域中的指针要指向的也是一个节点,因此要声明为 LNode 类型
} LNode, *LinkList; //这里的*LinkList强调元素是一个单链表.LNode强调元素是一个节点。本质上是同一个结构体
单链表的元素离散分布在各个存储空间中,是非随机存取的存储结构,不能直接找到表中每个特定的结点。查找结点时,需要从头往后依次遍历。
2.3.2 单链表的基本操作
1、单链表的初始化
// 不带头结点
bool InitList(LinkList &L) {
L = NULL; // 空表,不含任何结点
return true;
}
// 带头结点
bool InitListWithHeadNode(LinkList &L) {
L = (LNode *) malloc(sizeof(LNode)); // 分配一个结点
if (L == NULL) { // 内存不足,分配失败
return false;
}
L->next = NULL; // 单链表后面还没有结点
return true;
}
2、头插法建立单链表
头插法的一个重要应用:单链表的逆置
// 头插法建立单链表
LinkList List_HeadInsert(LinkList &L) {
LNode *s;
int x;
L = (LinkList) malloc(sizeof(LNode));
L->next = NULL;
cout << "请输入结点的值,输入9999结束:" << endl;
cin >> x;
while (x != 9999) {
s = (LNode *) malloc(sizeof(LNode)); // 创建新结点
s->data = x;
s->next = L->next;
L->next = s;
cin >> x;
}
return L;
}
3、尾插法建立单链表
// 尾插法建立单链表
LinkList List_TailInsert(LinkList &L) {
int x;
L = (LinkList) malloc(sizeof(LNode));
LNode *s;
LNode *r = L; // 尾指针
cout << "请输入结点的值,输入9999结束:" << endl;
cin >> x;
while (x != 9999) {
s = (LNode *) malloc(sizeof(LNode));
s->data = x;
r->next = s;
r = s;
cin >> x;
}
r->next = NULL;
return L;
}
4、按序号查找结点
在单链表中从第一个结点出发,顺指针next 域逐个往下搜索,直到找到第i个结点为止,否则返回最后一个结点指针域NULL。
// 从单链表中查找指定位序的结点,并返回
LNode *getElem(LinkList L, int i) {
if (i < 0) {
return NULL;
}
LNode *p;
int j = 0; // 定义一个j指针标记下标
p = L; // p指针用来标记第i个结点
while (p != NULL && j < i) { // p==NULL 超出表长,返回空值 j>i 超出所查询元素的下标
p = p->next;
j++; // j指针后移
}
return p;
}
5、按值查找结点
从单链表的第一个结点开始,由前往后依次比较表中各结点数据域的值若某结点数据域的值等于给定值e,则返回该结点的指针;若整个单链表中没有这样的结点,则返回NULL。
LNode *LocateElem(LinkList L, int value){
LNode *p = L->next;
while(p!=NULL && p->data != value){
p = p->next;
}
return p;
}
6、头插法插入元素
插入结点操作将值为x的新结点插入到单链表的第i个位置上。先检查插入位置的合法性,然后找到待插入位置的前驱结点,即第i-1个结点,再在其后插入新结点。
算法首先调用按序号查找算法GetElem(L,i-1),查找第i-1个结点。假设返回的第i-1个结点为p,然后令新结点s的指针域指向p 的后继结点,再令结点p 的指针域指向新插入的结点*s。
// 在第i个元素前插入元素value
bool ListInsert(LinkList &L, int i, int e) {
if (i < 1)
return false;
LNode *p; //指针p指向当前扫描到的结点
int j = 0; //当前p指向的是第几个结点
p = L; //循环找到第i-1个结点
while (p != NULL && j < i - 1) { //如果i>lengh,p最后会等于NULL
p = p->next;
j++;
}
//p值为NULL说明i值不合法
if (p == NULL)
return false;
//在第i-1个结点后插入新结点
LNode *s = (LNode *) malloc(sizeof(LNode));
s->data = e;
s->next = p->next;
p->next = s;
//将结点s连到p后
return true;
}
7、删除结点
删除结点操作是将单链表的第i个结点删除。先检查删除位置的合法性,后查找表中第i-1个结点,即被删结点的前驱结点,再将其删除。
// 删除第i个结点并将其所保存的数据存入value
bool ListDelete(LinkList &L, int i, int &value) {
if (i < 1)
return false;
LNode *p; //指针p指向当前扫描到的结点
int j = 0; //当前p指向的是第几个结点
p = L;
//循环找到第i-1个结点
while (p != NULL && j < i - 1) {
//如果i>lengh,p和p的后继结点会等于NULL
p = p->next;
j++;
}
if (p == NULL)
return false;
if (p->next == NULL)
return false;
//令q暂时保存被删除的结点
LNode *q = p->next;
value = q->data;
p->next = q->next;
free(q);
return true;
}
2.3.3 双链表
单链表结点中只有一个指向其后继的指针,使得单链表只能从头结点依次顺序地向后遍历。要访问某个节点的前驱结点(插入、删除操作)时,只能从头开始遍历,访问后继节点的时间复杂度为O(1),访问前驱结点的时间复杂度为O(n)。
为了解决如上问题,引入了双链表,双链表结点中有两个指针prior和next,分别指向其前驱结点和后继结点。
双链表的类型描述如下:
typedef struct DNode {
int data; // 数据域
struct DNode *prior, *next; // 前驱和后继指针
}DNode, *DLinkList;
1、双链表的初始化
bool InitDLinkList(DLinkList &L) {
L = (DNode *) malloc(sizeof(DNode));
if (L == NULL) {
return false;
}
L->prior = NULL; // 头结点的前驱指针永远指向NULL
L->next = NULL; // 后继指针暂时为空
return true;
}
2、双链表的后插操作
// 将节点s插入到结点p之后
bool InsertNextDNode(DNode *p, DNode *s) {
if (p == NULL || s == NULL) {
return false;
}
s->next = p->next; // 将p的后继赋给s的后继
// 判断p之后是否还有前驱节点
if (p->next != NULL) {
p->next->prior = s;
}
s->prior = p;
p->next = s;
return true;
}
双链表的前插操作、按位序插入操作都可以转换成后插操作
3、双链表的删除操作
// 删除p结点的后续结点
bool DeleteNextDNode(DNode *p) {
if (p == NULL) {
return false;
}
// 找到p的后继结点q
DNode *q = p->next;
if (q == NULL) {
return false;
}
p->next = q->next; // 将q的后继赋给p的后继
if (q->next != NULL) { // 若q的后继结点不为空
q->next->prior = p; // 将p赋给q的后继节点的前驱节点
}
free(q); // 释放q
return true;
}
// 销毁一个双链表
bool DestoryList(DLinkList &L) {
// 循环释放各个数据结点
while (L->next != NULL) {
DeleteNextDNode(L);
free(L);
// 头指针置空
L = NULL;
}
}
双链表不可随机存取,按位查找、按值查找操作都只能用遍历的方式实现。
2.3.4 循环链表
循环链表是另一种形式的链式存储结构。它的特点是表中最后一个结点的指针域指向头结点,整个链表形成一个环。
循环链表:
双向循环链表:
判断循环单链表是否为空:
if (L->next == L){ return true; } else { return false; }
循环单链表可以从任意结点开始往后遍历整个链表。
2.3.5 静态链表
用数组的方式实现的链表。分配一整片连续的内存空间,各个结点集中安置,每个结点包括了数据元素和下一个结点的数组下标。
- 优点:增、删操作不需要大量移动元素。
- 缺点:不能随机存取,只能从头结点开始依次往后查找,容量固定不变!
定义1:
#define MaxSize 10 //静态链表的最大长度
struct Node{ //静态链表结构类型的定义
ElemType data; //存储数据元素
int next; //下一个元素的数组下标
};
定义2:
#define MaxSize 10 //静态链表的最大长度
typedef struct{ //静态链表结构类型的定义
ELemType data; //存储数据元素
int next; //下一个元素的数组下标
}SLinkList[MaxSize];
void testSLinkList(){
SLinkList a;
}
第一种是我们更加熟悉的写法,第二种写法则更加侧重于强调 a 是一个静态链表而非数组。
2.4 顺序表vs链表
顺序表 | 链表 | |
---|---|---|
逻辑结构 | 属于线性表,都是线性结构 | 属于线性表,都是线性结构 |
存储结构 | 顺序存储 优点:支持随机存取,存储密度高 缺点:大片连续空间分配不方便,改变容量不方便 | 链式存储 优点:离散的小空间分配方便,改变容量方便 缺点:不可随机存取,存储密度低 |
基本操作——创建 | 需要预分配大片连续空间。若分配空间过小,则之后不方便拓展容量;若分配空间过大,则浪费内存资源。 静态分配:静态数组,容量不可改变。 动态分配:动态数组,容量可以改变,但是需要移动大量元素,时间代价高(使用 malloc() 、free() )。 | 只需要分配一个头结点或者只声明一个头指针。 |
基本操作——销毁 | 修改 Length = 0 静态分配:静态数组——系统自动回收空间。 动态分配:动态数组——需要手动 free() 。 | 依次删除各个结点 free() 。 |
基本操作——增删 | 插入 / 删除元素要将后续元素后移 / 前移; 时间复杂度:O(n),时间开销主要来自于移动元素。 | 插入 / 删除元素只需要修改指针; 时间复杂度:O(n),时间开销主要来自查找目标元素。 |
基本操作——查找 | 按位查找:O(1) 按值查找:O(n),若表内元素有序,可在O(log2n) 时间内找到(二分法) | 按位查找:O(n) 按值查找:O(n) |
第三章 栈和队列
3.1 栈
3.1.1 栈的基本概念
栈是特殊的线性表:只允许在一端进行插入或删除操作,其逻辑结构与普通线性表相同。
- 栈顶:允许进行插入和删除的一端 (最上面的为栈顶元素)。
- 栈底:不允许进行插入和删除的一端 (最下面的为栈底元素)。
- 空栈:不含任何元素的空表。
- 特点:后进先出(后进栈的元素先出栈)、LIFO(Last In First Out)。
- 缺点:栈的大小不可变,解决方法:共享栈。
栈的数学性质:n个不同元素进栈,出栈元素不同排列的个数是 1 n + 1 C 2 n n \frac{1}{n+1}C_{2n}^{n} n+11C2nn
3.1.2. 栈的基本操作
InitStack(&S)
:初始化栈。构造一个空栈 S,分配内存空间。DestroyStack(&S)
:销毁栈。销毁并释放栈 S 所占用的内存空间。Push(&S, x)
:进栈。若栈 S 未满,则将 x 加入使其成为新的栈顶元素。Pop(&S, &x)
:出栈。若栈 S 非空,则弹出(删除)栈顶元素,并用 x 返回。GetTop(S, &x)
:读取栈顶元素。若栈 S 非空,则用 x 返回栈顶元素。StackEmpty(S)
:判空。断一个栈 S 是否为空,若 S 为空,则返回 true,否则返回 false。
3.1.3 栈的顺序存储(顺序栈)
1、顺序栈的实现
typedef struct {
int data[MaxSize]; // 存放栈中元素
int top; // 栈顶指针,记录栈顶坐标
}SqStack;
栈顶指针:S.top,初始时,设置S.top = -1(有的教材中会设置为0,规定top指针指向的是栈顶元素的下一存储单元)
进栈操作:栈不满时,栈顶指针先加1,在赋值给栈顶元素
出栈操作:栈非空时,先取栈顶元素值,再将栈顶指针减1
栈空条件:S.top == -1
栈满条件:S.top == MaxSize - 1
2、顺序栈的初始化
bool InitStack(SqStack &S) {
S.top = -1;
return true;
}
3、判断栈是否为空
bool StackEmpty(SqStack S) {
if (S.top == -1) {
return true;
} else {
return false;
}
}
4、进栈
bool Push(SqStack &S, int x) {
if (S.top == MaxSize - 1) { // 栈满,报错
cout << "栈满" << endl;
return false;
}
S.data[++S.top] = x; // 指针先加1,再进栈
return true;
}
5、出栈
// 出栈
bool Pop(SqStack &S, int &x) {
if (S.top == -1) {
cout << "当前栈空,无法出栈" << endl;
return false;
}
x = S.data[S.top--]; // 先让x记录栈顶元素,再让栈顶指针减1
return true;
}
6、读取栈顶元素
int GetTop(SqStack S) {
if (S.top == -1) {
cout << "当前栈为空" << endl;
return NULL;
}
return S.data[S.top];
}
3.1.4 共享栈
让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶同时向共享空间的中间延伸。
#define MaxSize 10 //定义栈中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //静态数组存放栈中元素
int top0; //0号栈栈顶指针
int top1; //1号栈栈顶指针
}ShStack;
// 初始化栈
void InitSqStack(ShStack &S){
S.top0 = -1;
S.top1 = MaxSize;
}
3.1.5 栈的链式存储(链栈)
采用链式存储的栈称为链栈。链栈的优点是便于多个栈共享存储空间和提高效率,且不存在栈满上溢的清空。通常采用单链表实现,并且规定所有操作都是在单链表的表头进行上的(因为头结点的 next
指针指向栈的栈顶结点)。
1、链栈的定义
其结构定义如下:
typedef struct LinkNode {
int data;
struct LinkNode *next;
} *LiStack;
2、链栈的初始化
bool InitStack(LiStack &L) {
L = (LinkNode *) malloc(sizeof(LinkNode));
if(L == NULL){
return false;
}
L->next = NULL;
return true;
}
3.2 队列
3.2.1 队列的基本概念
队列是操作受限的线性表:只允许在一端进行插入 (入队),另一端进行删除 (出队)。
队列的特性:先进先出(FIFO, First In First Out)
3.2.2 队列的顺序存储
队列的顺序实现是指分配一块连续的存储单元存放队列中的元素,并附设两个指针:
队头指针front指向队头元素,
队尾指针rear指向队尾元素的下一个位置。
其代码定义如下:
typedef struct {
int data[MaxSize];
int front, rear;
}SqQueue;
其基本操作的文字描述如下:
初始状态:Q.front == Q.rear == 0
进队操作:队列不满时,先将值送到队尾,再将队尾指针加1
出队操作:队不空时,先取队头元素值,再将对头指针加1
值得注意的是,Q.rear == MaxSize 不能作为队列满的条件,如上图右1所示,此时Q.rear已经等于MaxSize了,但是队列并没有满。data数组中仍然有能存放元素的其他位置,这是一种假溢出。
// 初始化顺序队
bool InitQueue(SqQueue &Q) {
Q.front = Q.rear = 0;
}
// 判断队列是否为空
bool QueueEmpty(SqQueue Q) {
if (Q.rear == Q.front) {
return true;
}
return false;
}
3.2.3 循环队列
为了解决上述问题,提出了循环队列的概念。将顺序队列臆造为一个环状的空间,即把存储队列元素的表从逻辑上视为一个环,称为循环队列。当队首指针Q.front=MaxSize-1后,再前进一个位置就自动到0,这可以利用除法取余运算(%)来实现。
初始时:Q.front = Q.rear = 0
队首指针进1:Q.front = (Q.front + 1) % MaxSize
队尾指针进1:Q.rear = (Q.rear + 1) % MaxSize
队列长度:(Q.rear + MaxSize - Q.front) % MaxSize
出队入队时:指针都往顺时针方向进1
按照上述情况进行设计,队空和队满的条件都是: Q . f r o n t = = Q . r e a r Q.front == Q.rear Q.front==Q.rear,这种情况下无法区分队空队满。
为了区分队空队满的情况,有以下三种处理方式:
(1)牺牲一个存储单元来区分队空或队满(或者增加辅助变量),这是一种普遍的方式,约定:队头指针在队尾指针的下一位置作为队满的标志,如上图d2所示。
此时:
队满条件:$(Q.rear + 1) & MaxSize == Q.front $
队空条件: Q . f r o n t = = Q . r e a r Q.front == Q.rear Q.front==Q.rear
队列中元素的个数: ( Q . r e a r − Q . f r o n t + M a x S i z e ) (Q.rear - Q.front + MaxSize) % MaxSize (Q.rear−Q.front+MaxSize)
(2)类型中增设表示元素个数的数据成员。这样,队空的条件就位Q.size == 0,队满的条件就是Q.size == MaxSize。这两种情况下都有 Q . f r o n t = = Q . r e a r Q.front == Q.rear Q.front==Q.rear.
typedef struct {
int data[MaxSize];
int front, rear;
int size;
} SqQueue;
(3)类型中增设tag数据成员,用来区分是队满还是队空。tag等于0时,若因删除导致 Q . f r o n t = = Q . r e a r Q.front == Q.rear Q.front==Q.rear,则为队空。tag等于1时,若因插入导致 Q . f r o n t = = Q . r e a r Q.front == Q.rear Q.front==Q.rear,则为队满。
1、入队(循环队列)
// 将x入队
bool EnQueue(SqQueue &Q, int x) {
if ((Q.rear + 1) % MaxSize == Q.front) { // 队满
cout << "队满,无法插入" << endl;
return false;
}
Q.data[Q.rear] = x;
Q.rear = (Q.rear + 1) % MaxSize;
return true;
}
2、出队(循环队列)
// 出队,并将出队元素存储到x中
bool DeQueue(SqQueue &Q, int &x) {
if (Q.rear == Q.front) {
cout << "队空,无法出队" << endl;
return false;
}
x = Q.data[Q.front];
Q.front = (Q.front + 1) % MaxSize;
return true;
}
3.2.4 队列的链式存储(链队)
队列的链式表示称为链队列,它实际上是一个同时带有队头指针和队尾指针的单链表。
当 Q . f r o n t = = N U L L Q.front == NULL Q.front==NULL 且 $Q.rear == NULL $时,链队为空。
typedef struct LinkNode { // 链队结点
int data;
struct LinkNode *next;
} LinkNode;
typedef struct { // 链式队列
LinkNode *front, *rear; // 头尾指针
} LinkQueue;
不带头结点的链队操作起来会比较麻烦,因此通常将链队设计成带头结点的单链表。
用单链表表示的链式队列特别适合于数据元素变动比较大的情形,而且不存在队列满且产生溢出的问题。另外,假如程序中要使用多个队列,与多个栈的情形一样,最好使用链式队列,这样就不会出现存储分配不合理和“溢出”的问题。
1、链队的初始化
a) 带头结点
bool InitQueue(LinkQueue &Q) {
Q.front = Q.rear = (LinkNode *) malloc(sizeof(LinkNode)); // 建立头结点
Q.front->next = NULL; // 初始为空
}
b) 不带头节点
bool InitQueue(LinkQueue &Q) {
Q.front = NULL;
Q.rear = NULL;
}
2、判断链队是否为空
a) 带头结点
bool QueueEmpty(LinkQueue Q) {
if (Q.front == Q.rear) {
return true;
} else {
return false;
}
}
b) 不带头结点
bool QueueEmpty(LinkQueue Q) {
if (Q.front == NULL) {
return true;
} else {
return false;
}
}
3、入队
a) 带头结点
bool EnQueue(LinkQueue &Q, int x) {
LinkNode *s = (LinkNode *) malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;
Q.rear->next = s;
Q.rear = s;
return true;
}
b) 不带头节点
void EnQueue(LinkQueue &Q, int x) {
LinkNode *s = (LinkNode *) malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;
// 第一个元素入队时需要特别处理
if (Q.front == NULL) {
Q.front = s;
Q.rear = s;
} else {
Q.rear->next = s;
Q.rear = s;
}
}
4、出队
a) 带头结点
bool DeQueue(LinkQueue &Q, int &x) {
if (Q.front == Q.rear)
return false;
LinkNode *p = Q.front->next;
x = p->data;
Q.front->next = p->next;
// 如果p是最后一个结点,则将队头指针也指向NULL
if (Q.rear == p)
Q.rear = Q.front;
free(p);
return true;
}
b) 不带头节点
bool DeQueue(LinkQueue &Q, int &x) {
if (Q.front == NULL)
return false;
LinkNode *s = Q.front;
x = s->data;
if (Q.front == Q.rear) {
Q.front = Q.rear = NULL;
} else {
Q.front = Q.front->next;
}
free(s);
return true;
}
3.2.5 双端队列
- 定义:
- 双端队列是允许从两端插入、两端删除的线性表。
- 如果只使用其中一端的插入、删除操作,则等同于栈。
- 输入受限的双端队列:允许一端插入,两端删除的线性表。
- 输出受限的双端队列:允许两端插入,一端删除的线性表。
- 考点:判断输出序列的合法化
- 例:数据元素输入序列为 1,2,3,4,判断 4! = 24 个输出序列的合法性
栈中合法的序列,双端队列中一定也合法
栈 | 输入受限的双端队列 | 输出受限的双端队列 |
---|---|---|
14个合法 1 n + 1 C 2 n n \frac{1}{n+1}C_{2n}^{n} n+11C2nn | 只有 4213 和 4231 不合法 | 只有 4132 和 4231 不合法 |
3.3 栈和队列的应用
3.3.1 栈在括号匹配中的应用
括号匹配的规律:最后出现的左括号最先被匹配(LIFO,用栈来实现该特性是最优解)
每当出现一个右括号,就“消耗”一个左括号;这里的消耗就对应于出栈的过程,当我们遇到左括号就把它压入栈中,当我们遇到右括号的时候,就把栈顶的那个左括号弹出
3.3.2 栈在表达式求值中的应用
前缀表达式(波兰表达式):运算符在两个操作数的前面
中缀表达式:运算符在两个操作数中间
后缀表达式(逆波兰表达式):运算符在两个操作数后
前缀表达式 | 中缀表达式 | 后缀表达式 |
---|---|---|
+ a b | a + b | a b + |
- + a b c | a + b - c | a b + c - |
- + a b * c d | a + b - c * d | a b + c d * - |
中缀转后缀的手算方法:
①确定中缀表达式中各个运算符的运算顺序
②选择下一个运算符,按照「左操作数右操作数运算符」的方式组合成一个新的操作数
③如果还有运算符没被处理,就继续②
注意:
1、运算顺序是不唯一的,所有手算得到的后缀表达式也不唯一
2、得到的不唯一的后缀表达式客观上都是正确的,但是机算得到的结果只有一种
3、为了保证手算机算结果相同,我们在手算时,要遵循“左优先原则”,只要左边的运算符能先计算,就计算左边的。
机算:
初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右处理各个元素,直到末尾。可能遇到三种情况:
①遇到操作数。直接加入后缀表达式。
②遇到界限符。遇到“(”直接入栈;遇到“)”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。注意:“(”不加入后缀表达式。
③遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(”或栈空则停止。之后再把当前运算符入栈。
按上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。
后缀表达式的手算方法:
从左往右扫描,每遇到一个运算符,就让运算符前面最近的两个操作数执行对应运算,合体为一个操作数
注意两个操作数的操作顺序!
机算:
用栈实现后缀表达式的计算:
①从左往右扫描下一个元素,直到处理完所有元素
②若扫描到操作数则压入栈,并回到①;否则执行③
③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①
注意:先出栈的是 右操作数
注意:先出栈的是“右操作数”
若表达式合法,则最后栈中只会留下一个元素,就是最终结果
以下是机算的图解:
例:
中缀表达式转前缀表达式
中缀转前缀的手算方法:
①确定中缀表达式中各个运算符的运算顺序
②选择下一个运算符,按照「运算符左操作数右操作数」的方式组合成一个新的操作数
③如果还有运算符没被处理,就继续②
“右优先”原则:只要右边的运算符能先计算,就优先算石边的
用栈实现前缀表达式的计算:
①从右往左扫描下一个元素,直到处理完所有元素
②若扫描到操作数则压入栈,并回到①;否则执行③
③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①
机算
用栈实现中缀表达式的计算:
初始化两个栈,操作数栈和运算符栈若扫描到操作数,压入操作数栈
若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)
3.3.3 栈在递归中的应用
递归:将原始问题转化成属性相同,但规模更小的问题
函数调用的特点:最后被调用的函数最先执行结束(LIFO)。
函数调用时,需要用一个“函数调用栈” 存储:
调用返回地址
实参
局部变量
递归调用时,函数调用栈可称为“递归工作栈” 。每进入一层递归,就将递归调用所需信息压入栈顶;每退出一层递归,就从栈顶弹出相应信息。
缺点:效率低,太多层递归可能会导致栈溢出;可能包含很多重复计算。
可以自定义栈将递归算法改造成非递归算法。
3.3.4 队列的应用
- 树的层次遍历
- 图的广度优先遍历
- 操作系统中多个进程争抢着使用有限的系统资源时,先来先服务算法(First Come First Service)是是一种常用策略。
3.4 数组和特殊矩阵
3.4.1 数组的存储
除非题目特别说明,否则数组下标默认从0开始。
一维数组的存储:各数组元素大小相同,且物理上连续存放。以一维数组A[0…n-1]为例,其存储关系式为:
数组元素
a
[
i
]
的存储地址
=
L
O
C
+
i
∗
s
i
z
e
o
f
(
E
l
e
m
T
y
p
e
)
数组元素a[i]的存储地址=LOC + i * sizeof(ElemType)
数组元素a[i]的存储地址=LOC+i∗sizeof(ElemType)
其中,L是每个数组元素所占的存储单元。
多维数组的存储:
m行n列的二维数组b[m][n]
中,若按照行优先存储:
b
[
i
]
[
j
]
的存储地址
=
L
O
C
+
(
i
∗
n
+
j
)
∗
s
i
z
e
o
f
(
E
l
e
m
T
y
p
e
)
b[i][j]的存储地址=LOC+(i*n+j)*sizeof(ElemType)
b[i][j]的存储地址=LOC+(i∗n+j)∗sizeof(ElemType)
m行n列的二维数组b[m][n]
中,若按照列优先存储:
b
[
i
]
[
j
]
的存储地址
=
L
O
C
+
(
j
∗
m
+
i
)
∗
s
i
z
e
o
f
(
E
l
e
m
T
y
p
e
)
b[i][j]的存储地址=LOC+(j*m+i)*sizeof(ElemType)
b[i][j]的存储地址=LOC+(j∗m+i)∗sizeof(ElemType)
3.4.2 特殊矩阵的压缩存储
1、对角矩阵
所交矩阵中存在着大量相同元素,若仍然采用二维数组存储,则会浪费几乎一半的空间。
解决策略:只存储主对角线元素+上三角区或下三角区的元素。
其需要的一维数组大小
=
1
+
2
+
3
+
.
.
.
+
n
=
n
(
1
+
n
)
/
2
其需要的一维数组大小=1+2+3+...+n=n(1+n)/2
其需要的一维数组大小=1+2+3+...+n=n(1+n)/2
对于具体的元素的查找,我们不能和二维数组一样直接使用下标进行查找,而是需要建立一个映射函数来在一维数组中进行查找,如:
2、三角矩阵
- 下三角矩阵:除了主对角线和下三角区,其余的元素都相同。
- 上三角矩阵:除了主对角线和上三角区,其余的元素都相同。
压缩存储策略:按行优先原则将主对角线+下三角区存入一维数组中,并在最后一个位置存储常量。
3、三对角矩阵
4、稀疏矩阵
- 稀疏矩阵的非零元素远远少于矩阵元素的个数。压缩存储策略:
- 顺序存储:三元组 <行,列,值>
- 链式存储:十字链表法
第四章 串
4.1 串的定义
4.1.1 串的相关概念
- 串:即字符串(String)是由零个或多个字符组成的有限序列。
- 串的长度:中字符的个数 n,n = 0 n = 0n=0 时的串称为空串。
- 子串:串中任意个连续的字符组成的子序列。
- 主串:包含子串的串。
- 字符在主串中的位置:字符在串中的序号。
- 子串在主串中的位置:子串的第一个字符在主串中的位置 。
4.1.2 串的基本操作
StrAssign(&T, chars)
:赋值操作。把串 T 赋值为 chars。StrCopy(&T, S)
:复制操作。由串 S 复制得到串 T。StrEmpty(S)
:判空操作。若 S 为空串,则返回 TRUE,否则返回 FALSE。StrLength(S)
:求串长。返回串 S 中元素的个数。ClearString(&S)
:清空操作。将 S 清为空串。DestroyString(&S)
:销毁串。将串 S 销毁(回收存储空间)。Concat(&T, S1, S2)
:串联接。用 T 返回由 S1 和 S2 联接而成的新串 。SubString(&Sub, S, pos, len)
:求子串。用 Sub 返回串 S 的第 pos 个字符起长度为 len 的子串。Index(S, T)
:定位操作。若主串 S 中存在与串 T 值相同的子串,则返回它在主串 S 中第一次出现的位置;否则函数值为 0。StrCompare(S, T)
:比较操作。若 S>T,则返回值>0;若 S=T,则返回值=0;若 S<T,则返回值<0。
4.1.3 串的存储结构
1、定长顺序存储表示
typedef struct {
char ch[MAXLEN]; // 每个分量存储一个字符
int length; // 串的实际长度
} SString;
2、堆分配存储表示(动态存储)
typedef struct {
char *ch; // 按串长分配存储区,ch指向串的基地址
int length; // 串的长度
} HString;
3、块链存储表示
默认情况下存储密度低,每个节点都只能存储一个字符
解决方法:一个结点存储多个字符
4.2 串的模式匹配
即子串的定位操作
4.2.1 简单的模式匹配算法
一个示例:
分析:
简单模式匹配算法的最坏时间复杂度是O(nm),即每个子串都要对比到最后一个字符,如下面这种情况:
- 主串:1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2
- 子串:1 1 1 1 1 1 1 1 2
其中,n和m分别是主串和模式串的长度。
最好的情况(对于每个子串都只需对比一次):
- 匹配成功:O(m)
- 匹配失败:O(n-m+1)=O(n-m)≈O(n)
4.2.2 KMP算法
要了解子串的结构,首先需要了解以下几个概念:前缀、后缀和部分匹配值。
前缀:除了最后一个字符外,字符串的所有头部子串
后缀:除了第一个字符外,字符串的所有尾部子串
‘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。
故字符串’ababa’的部分匹配值为00123
接下来详解一下上面这个例子:
由上述方法求子串’abcac’的部分匹配值:
'ab’的前缀{a},后缀{b} {a}∩{b} = ∅
'abc’的前缀{a,ab}, 后缀{c, bc} {a,ab}∩{c, bc} = ∅
'abca’的前缀{a,ab,abc},后缀{a,ca,bca} {a,ab,abc}∩{a,ca,bca} = {a}
'abcac’的前缀{a,ab,abc,abca},后缀{c,ac,cac,bcac} {a,ab,abc}∩{c,ac,cac,bcac} = ∅
将其部分匹配值写成数组形式,就得到了部分匹配值(PM)的表:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
PM | 0 | 0 | 0 | 1 | 0 |
接下来可以使用PM表来进行字符串匹配,其过程如下
KMP算法的原理
当c与b不匹配时,已匹配’abca’的前缀a和后缀a为最长公共元素。已知前缀a与b、c均不同,与后缀a相同,故无须比较,直接将子串移动“已匹配的字符数–对应的部分匹配值”,用子串前缀后面的元素与主串匹配失败的元素开始比较即可。
对算法的改进
已知:右移位数=已匹配的字符数-对应的部分匹配值。写成:
M
o
v
e
=
(
j
−
1
)
−
P
M
[
j
−
1
]
Move=(j-1)-PM[j-1]
Move=(j−1)−PM[j−1]
现在这种情况下,我们在匹配失败时,需要去查找它前一个元素的部分匹配值,这样使用起来有点不方便,故我们可以将PM表右移一位,这样哪个元素匹配失败,则直接看它自己的部分匹配值即可。
将上例的PM表右移一位,则得到了next数组
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
next | -1 | 0 | 0 | 0 | 1 |
我们注意到:
1)第一个元素右移以后空缺的用-1来填充,因为若是第一个元素匹配失败,则需要将子串向右移动一位,而不需要计算子串移动的位数。
2)最后一个元素在右移的过程中溢出,因为原来的子串中,最后一个元素的部分匹配值是其下一个元素使用的,但显然已没有下一个元素,故可以舍去。
这样,上式就改写为:
M
o
v
e
=
(
j
−
1
)
−
n
e
x
t
[
j
]
Move=(j-1)-next[j]
Move=(j−1)−next[j]
就相当于将子串的比较指针回退到:
j
=
j
−
M
o
v
e
=
j
−
(
(
j
−
1
)
−
n
e
x
t
[
j
]
)
=
n
e
x
t
[
j
]
+
1
j=j-Move=j-((j-1)-next[j])=next[j]+1
j=j−Move=j−((j−1)−next[j])=next[j]+1
但为了让公式更加简洁,我们将next数组整体加1
next数组也可以写成:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
next | 0 | 1 | 1 | 1 | 2 |
最终子串指针变化公式为:
j
=
n
e
x
t
[
j
]
j=next[j]
j=next[j]
在实际匹配过程中,子串在内存里是不会移动的,而是指针在变化,书中画图举例只是为了让问题描述得更加形象。next[j]的含义是:在子串的第j个字符与主串发生失配时,则跳到子串的next[j]位置重新与主串当前位置进行比较。
【重要】求next数组,根据如下示例来学习:
KMP算法的进一步优化
问题的产生:
所以引入了nextval数组,对KMP算法进行进一步优化。
故我们在模式串中,当前模式串p和对应的next数组p_next的模式串值相等时,继续查找对应p_next模式串的next数组对应的模式串,直到模式串对应的值不相等。
以下是匹配过程:
4.3 广义表(408不考,部分自命题科目会考察)
4.3.1 广义表的概念
广义表(又称列表):是n个表元素组成的有限序列,记作
L
s
=
(
a
0
,
a
1
,
a
2
,
.
.
,
a
n
)
L_s=(a_0,a_1,a_2,..,a_n)
Ls=(a0,a1,a2,..,an)
其中:
L
s
L_s
Ls是表名,
a
i
a_i
ai是表元素,它可以是表 (称为子表),可以是数据元素(称为原子)。 n为表的长度。n = 0 的广义表为空表。
广义表和线性表的区别:
- 线性表的成分都是结构上不可分的单元素
- 广义表的成分可以是单元素,也可以是有结构的表
- 线性表是一种特殊的广义表
- 广义表不一定是线性表,也不一定是线性结构
4.3.2 广义表的基本操作
广义表常考两种基本操作:求表头- G e t H e a d ( L ) GetHead(L) GetHead(L)和求表尾 G e t T a i l ( L ) GetTail(L) GetTail(L)
求表头获取的是非空广义表的第一个元素,可以是一个单元素,也可以是一个子表
求表尾得到的是非空广义表除去表头元素以外其它元素所构成的表。表尾一定是一个表
以下几个例子就能很清楚的说明操作的原理:
例1:表L如下图所示:
此时,由于表为空,则 G e t H e a d ( L ) GetHead(L) GetHead(L)和 G e t T a i l ( L ) GetTail(L) GetTail(L)均无定义。
例2:表L如下图所示:
此时, G e t H e a d ( L ) = a GetHead(L)=a GetHead(L)=a, G e t T a i l ( L ) = b GetTail(L)=b GetTail(L)=b
例3:表L如下图所示:
此时, G e t H e a d ( L ) = a GetHead(L)=a GetHead(L)=a, G e t T a i l ( L ) = { } GetTail(L)=\{\} GetTail(L)={}
例4:表L如下图所示:
此时, G e t H e a d ( L ) = a GetHead(L)=a GetHead(L)=a, G e t T a i l ( L ) = { b , c , d , e } GetTail(L)=\{b,c,d,e\} GetTail(L)={b,c,d,e}
例5:表L如下图所示:
此时, G e t H e a d ( L ) = { a } GetHead(L)=\{a\} GetHead(L)={a}, G e t T a i l ( L ) = { b , c , d , e } GetTail(L)=\{b,c,d,e\} GetTail(L)={b,c,d,e}
例6:表L如下图所示:
此时, G e t H e a d ( L ) = { a , b , c , d , e } GetHead(L)=\{a,b,c,d,e\} GetHead(L)={a,b,c,d,e}, G e t T a i l ( L ) = { } GetTail(L)=\{\} GetTail(L)={}
来一个复杂一点的例子:
大家可以自行计算一下: G e t H e a d ( G e t T a i l ( G e t H e a d ( G e t T a i l ( G e t T a i l ( L ) ) ) ) ) GetHead(GetTail(GetHead(GetTail(GetTail(L))))) GetHead(GetTail(GetHead(GetTail(GetTail(L)))))
最终得到的结果是: b b b
广义表存储结构等更详细的内容可以参考这篇文章:广义表_OoZzzy的博客-CSDN博客_广义表,如考研初试考查此内容,基本上考查的都是求表头和求表尾部分,掌握如何计算即可。
第五章 树
5.1 树的基本概念
- 树是n(n≥0)个结点的有限集合,n = 0时,称为空树。
- 空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当n > 1时,其余结点可分为m(m>0)个互不相交的有限集合T1,T2,…,Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
- 度:树中一个结点的孩子个数称为该结点的度。所有结点的度的最大值是树的度。
- 度大于0的结点称为分支结点,度为0的结点称为叶子结点。
- 结点的层次(深度):从上往下数。
- 结点的高度:从下往上数。
- 树的高度(深度):树中结点的层数。
- 有序树:逻辑上看,树中结点的各子树从左至右是有次序的,不能互换。
- 若树中结点的各子树从左至右是有次序的,不能互换,则该树称为有序树,否则称为无序树。
- 树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。
- 森林:森林是m(m≥0)棵互不相交的树的集合。
5.1.2 树的常考性质
- 结点数 = 总度数 + 1
- 度为 m 的树、m 叉树的区别:
度为m的树 | m叉树的区别 |
---|---|
任意结点的度≤m(最多m个孩子) | 任意结点的度≤m(最多m个孩子) |
至少有一个结点度=m(有m个孩子) | 允许所有结点的度都<m |
一定是非空树,至少有m+1个结点 | 可以是空树 |
- 度为 m 的树第 i 层至多有**m^(i-1)个结点(i≥1);m 叉树第 i 层至多有m^(i-1)**个结点(i≥1)。
-
高度为 h 的 m 叉树至多有
m h − 1 / m − 1 {m^h-1}/{m-1} mh−1/m−1
个结点。(等比数列求和) -
高度为 h 的 m 叉树至少有 h 个结点;高度为 h、度为 m 的树至少有(h+m-1)个结点。
-
具有 n 个结点的 m 叉树的最小高度为
⌈ l o g m [ n ( m − 1 ) + 1 ] ⌉ ⌈log_m[n(m-1)+1]⌉ ⌈logm[n(m−1)+1]⌉
5.2 二叉树
5.2.1. 二叉树的定义
- 二叉树是 n(n≥0)个结点的有限集合:
- 或者为空二叉树,即 n = 0。
- 或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成,左子树和右子树又分别是一棵二叉树。
- 二叉树的特点:
- 每个结点至多只有两棵子树。
- 左右子树不能颠倒(二叉树是有序树)。
- 二叉树的五种状态:
- 空二叉树
- 只有左子树
- 只有右子树
- 只有根节点
- 左右子树都有
5.2.2 几个特殊的二叉树
1、满二叉树
一棵高度为h,结点数为2^h-1
的二叉树称为满二叉树,即 树中的每层都含有最多的结点。
特点:
- 满二叉树的叶子结点都集中在二叉树的最下一层
- 除了叶子结点外的每个节点的度都为2。
- 可以对满二叉树按照层序编号,约定编号从根节点(编号为1)起,自上而下,自左向右。这样每个结点对应一个编号,对于编号为i的结点,若有双亲,则其双亲为⌊i/2⌋,若有左孩子,则其左孩子为2i,若有右孩子,则其右孩子为2i+1.
2、完全二叉树
高度为h,有n个结点的二叉树,当且仅当每个节点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。
其有如下特点:
- 若i≤⌊i/2⌋,则结点i为分支节点,否则为叶子结点。
- 叶子结点只可能在层次最大的两层上出现。
- 若有度为1的结点,则只可能有一个,且该结点只有左孩子。
- 按照层序编号后,一旦出现某结点(编号为i)为叶子结点或只有左孩子,则编号大于i的结点均为叶子结点。
- 若n为奇数,则每个分支节点都有左右孩子,若n为偶数,则编号最大的分支结点(n/2)只有左孩子,没有右孩子。其余分支节点左右孩子都有。
满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。
3、二叉排序树
左子树上的所有结点的关键字均小于根节点的关键字;右子树上的所有结点的关键字均大于根节点的关键字;左子树和右子树又各自是一颗二叉排序树。
4、平衡二叉树
树上的任一结点的左子树和右子树的深度之差不超过1.
5.2.3 二叉树的性质
5.2.4 二叉树的存储结构
1、顺序存储
包含的结点个数有上限
顺序存储完全二叉树:定义一个长度为 MaxSize 的数组 t,按照从上至下、从左至右的顺序依次存储完全二叉树中的各个结点。让第一个位置空缺,保证数组中下标和结点编号一致。
根据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一反映结点之间的逻辑关系,这样既能最大程度上的节省空间,又能根据数组元素的下标来确定结点在二叉树中的位置以及结点间的关系。
这样可以使用二叉树的性质求一些问题:
而对于一般的二叉树而言,若使用顺序存储,则只能添加一些并不存在的空结点,让每个结点与二叉树上的结点相对照,再存储到一维数组的相应分量中。这样存在着空间的浪费,不建议使用顺序存储。因此,二叉树的顺序存储结构,只适合存储完全二叉树。
顺序存储的结构描述如下:
#define MaxSize 100
// 二叉树的顺序存储
struct TreeNode {
int data; // 结点中的数据元素
bool isEmpty; // 结点是否为空
};
TreeNode t[MaxSize]; // 定义一个长度为MaxSize的数组t,按照从上到下,从左到右的顺序依次存储完全二叉树的各个节点
2、链式存储
为了解决存储一般二叉树的空间浪费问题,一般二叉树的存储使用链式存储结构。使用链表结点来存储二叉树中的各个结点。在二叉树中,结点的结构通常包括若干数据域以及若干指针域。
二叉链表的存储结构如下:
其结构描述如下:
typedef struct BiTNode {
ElemType data;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
【重要】在含有n个结点的二叉链表中,含有n+1个空链域。
为了找到指定结点的父结点,一般要从根节点开始遍历,可在BiTNode中设置一个新的指针存储父结点来解决此问题。
5.3 二叉树的遍历与线索二叉树
二叉树的遍历类似:
先序遍历:前缀表达式
中序遍历:中缀表达式(需添加界限符)
后序遍历:后缀表达式
5.3.1 二叉树的遍历
二叉树的遍历是 按照某条搜索路径访问树中的每个结点,使得每个节点均被访问一次,而且仅被访问一次。
1、先序遍历(PreOrder)
先序遍历的操作过程如下:
若二叉树为空,则什么都不做,否则:
- 访问根节点
- 先序遍历左子树
- 先序遍历右子树
对应的递归算法如下:
void PreOrder(BiTree T) {
if (T == NULL) return;
visit(T);
PreOrder(T->lchild);
PreOrder(T->rchild);
}
2、中序遍历(InOrder)
中序遍历的操作过程如下:
若二叉树为空,则什么也不做,否则:
-
中序遍历左子树;
-
访问根结点;
-
中序遍历右子树。
对应的递归算法如下:
void InOrder(BiTree T) {
if (T == NULL) return;
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
3、后序遍历(PostOrder)
后序遍历的操作过程如下:
若二叉树为空,则什么也不做,否则:
-
后序遍历左子树;
-
后序遍历后子树;
-
访问根结点;
对应的递归算法如下:
void PostOrder(BiTree T) {
if (T == NULL) return;
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
4、层序遍历
按照层序来进行遍历,如下图所示:
要进行层次遍历,需要借助一个队列。先将二叉树根结点入队,然后出队,访问出队结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队。然后出队,访问出队结点……如此反复,直至队列为空。
其示例如下:
5、由遍历序列构造二叉树
- 一个前序遍历序列可能对应多种二叉树形态。同理,一个后序遍历序列、一个中序遍历序列、一个层序遍历序列也可能对应多种二叉树形态。即:若只给出一棵二叉树的 前/中/后/层序遍历序列 中的一种,不能唯一确定一棵二叉树。
- 由二叉树的遍历序列构造二叉树:
- 前序+中序遍历序列
- 后序+中序遍历序列
- 层序+中序遍历序列
- 由 前序+中序遍历序列 构造二叉树:由前序遍历的遍历顺序(根节点、左子树、右子树)可推出根节点,由根节点在中序遍历序列中的位置即可推出左子树与右子树分别有哪些结点。
- 由 后序+中序遍历序列 构造二叉树:由后序遍历的遍历顺序(左子树、右子树、根节点)可推出根节点,由根节点在中序遍历序列中的位置即可推出左子树与右子树分别有哪些结点。
- 由 层序+中序遍历序列 构造二叉树:由层序遍历的遍历顺序(层级遍历)可推出根节点,由根节点在中序遍历序列中的位置即可推出左子树与右子树分别有哪些结点。
示例:
5.3.2 线索二叉树
线索二叉树是一种物理结构!
1、线索二叉树的基本概念
传统的二叉链表只能体现一种父子关系, **不能直接得到结点在遍历中的前驱和后继。**而考虑到在含有n个结点的二叉树中,**有n+1个空指针。**考虑能否利用这些空指针来存放指向其前驱或后继的指针?这样就可以更加方便地遍历二叉树。
故含n个结点的线索二叉树共有n+1个线索
引入线索二叉树正是为了加快查找结点前驱和后继的速度。
线索二叉树的结点结构如下:
规定:
- 若无左子树,则lchild指向其前驱节点,ltag为1,否则lchild指向左孩子,ltag为0
- 若无右子树,则rchild指向其后继结点,rtag为0,否则rchild指向左孩子,rtag为0
其存储结构描述如下:
typedef struct ThreadNode {
int data; // 数据域
struct ThreadNode *lchild, *rchild; // 左右孩子指针
int ltag, rtag; // 左右线索标志
} ThreadNode, *ThreadBiTree;
以这种结点结构构成的二叉链表作为二叉树的存储结构,称为二叉链表。其中指向结点前驱和后继的指针称为线索,加上线索的二叉树称为线索二叉树。
2、中序线索二叉树的构造
二叉树的线索化是将二叉链表中的空指针改为指向前驱或者后继的线索。而前驱或后继的信息只有在遍历时才能够得到,因此二叉树的线索化的本质就是遍历一次二叉树。
- p的左指针:
- 空:
p->lchild = pre
- 非空:跳过
- 空:
- pre的右指针:
- 空:
pre->rchild=p
- 非空:跳过
- 空:
以下是对上图所示二叉树进行中序线索化的一个示例过程:
其代码实现如下:
// 中序线索化二叉树
void InThread(ThreadBiTree &p, ThreadBiTree &pre) {
if (p != NULL) { // 若p非空,结点没有全部遍历
InThread(p->lchild, pre); // 递归调用
if (p->lchild == NULL) { // 若p的左孩子为空
p->lchild = pre; // p的左孩子指向前驱
p->ltag = 1; // 标记为线索
}
if (pre != NULL && pre->lchild == NULL) { // pre存在且右孩子为空
pre->lchild = p; // pre的右孩子指向后继
pre->rtag = 1; // 标记为线索
}
pre = p; // pre指向p的上一个位置
InThread(p->rchild, pre); // 对右孩子建立线索
}
}
线索化后,存储结构如下:
4、先序和后序遍历
先序和后序遍历的方法类似中序遍历,这里不再给出具体流程。
5、在线索二叉树中查找前驱、后继
中序线索二叉树找到指定结点 *p 的中序后继 next:
- 若
p->rtag==1
,则next = p->rchild
; - 若
p->rtag==0
,则 next 为 p 的右子树中最左下结点。
中序线索二叉树找到指定结点 *p 的中序前驱 pre:
- 若
p->ltag==1
,则pre = p->lchild
; - 若
p->ltag==0
,则 next 为 p 的左子树中最右下结点。
先序线索二叉树找到指定结点 * p 的先序后继 next:
- 若
p->rtag==1
,则next = p->rchild
; - 若
p->rtag==1
,则next = p->rchild
;- 若 p 有左孩子,则先序后继为左孩子;
- 若 p 没有左孩子,则先序后继为右孩子。
先序线索二叉树找到指定结点 *p 的先序前驱 pre:
- 前提:改用三叉链表,可以找到结点 * p 的父节点。
- 如果能找到 p 的父节点,且 p 是左孩子:p 的父节点即为其前驱;
- 如果能找到 p 的父节点,且 p 是右孩子,其左兄弟为空:p 的父节点即为其前驱;
- 如果能找到 p 的父节点,且 p 是右孩子,其左兄弟非空:p 的前驱为左兄弟子树中最后一个被先序遍历的结点;
- 如果 p 是根节点,则 p 没有先序前驱。
先序遍历中,每个子树的根节点是最先遍历到的,若根节点有左右孩子,则其左右指针都指向了孩子,这种情况下,没有办法直接找到子树根节点的前驱。
后序线索二叉树找到指定结点 *p 的后序前驱 pre:
- 若
p->ltag==1
,则pre = p->lchild
; - 若
p->ltag==0
:- 若 p 有右孩子,则后序前驱为右孩子;
- 若 p 没有右孩子,则后续前驱为右孩子。
后序线索二叉树找到指定结点 *p 的后序后继 next:
- 前提:改用三叉链表,可以找到结点 * p 的父节点。
- 如果能找到 p 的父节点,且 p 是右孩子:p 的父节点即为其后继;
- 如果能找到 p 的父节点,且 p 是左孩子,其右兄弟为空:p 的父节点即为其后继;
- 如果能找到 p 的父节点,且 p 是左孩子,其右兄弟非空:p 的后继为右兄弟子树中第一个被后序遍历的结点;
- 如果 p 是根节点,则 p 没有后序后继。
后序遍历中,每个子树的根节点是最后遍历到的,若根节点有左右孩子,则其左右指针都指向了孩子,这种情况下,没有办法直接找到子树根节点的后继。
【考点】二叉树线索化之后,仍不能有效求解的问题:
- 查找后序线索二叉树的后续后继
- 查找先序线索二叉树的先序前驱
5.4 树和森林
5.4.1 树的存储结构
1、双亲表示法(顺序存储)
采用一组连续空间来存储每个节点,同时在每个节点中设置一个伪指针,指示其双亲结点在数组中的位置。
- 根结点固定存储在0号位置,-1表示其没有双亲。
- 插入结点时只需在空白位置添加一行即可。(与二叉树的顺序存储不同)
- 树的顺序存储结构中,数组下标只代表结点的编号,不表示各个结点间的关系。
优点:查找指定节点的双亲很方便
缺点:
1、查找指定节点的孩子只能从头开始遍历;
2、空数据导致结点的遍历更慢。
2、孩子表示法(顺序+链式存储)
-
孩子表示法中,每个结点的孩子都使用了单链表链接起来形成一个线性结构,这时n个结点就有n个孩子链表(叶节点的孩子链表为空表)。
-
这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表。
3、孩子兄弟表示法(链式存储)
孩子兄弟表示法又称二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点)。
这种存储表示法比较灵活,其最大的优点是可以方便地实现树转换为二叉树的操作,易于查找结点的孩子等,但缺点是从当前结点查找其双亲结点比较麻烦。若为每个结点增设一个parent域指向其父结点,则查找结点的父结点也很方便。
5.4.2 树、森林和二叉树的转换
1、树和二叉树的转化
树转换二叉树的原则:每个结点的左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟。
记忆:”左孩子右兄弟“
由于根节点没有兄弟,所以树转化成的二叉树没有右子树。
2、树和森林
森林是m (m≥0)棵互不相交的树的集合。
将森林转化成二叉树:先将森林中的每棵树转换为二叉树,由于任何一棵和树对应的二叉树的右子树必空,若把森林中第二棵树根视为第一棵树根的右兄弟,即将第二棵树对应的二叉树当作第一棵二叉树根的右子树,将第三棵树对应的二叉树当作第二棵二叉树根的右子树,以此类推,就可以将森林转换为二叉树。
5.4.3 树和森林的遍历
1、树的遍历
- 先根遍历。若树非空,先访问根结点,再依次遍历根结点的每棵子树,遍历子树时仍遵循先根后子树的规则。其遍历序列与这棵树相应二叉树的先序序列相同。
- 后根遍历。若树非空,先依次遍历根结点的每棵子树,再访问根结点,遍历子树时仍遵循先子树后根的规则。其遍历序列与这棵树相应二叉树的中序序列相同。
- 层序遍历。按照层序依次访问各个结点。
2、森林的遍历
- 先序遍历森林。若森林为非空,则按如下规则进行遍历:
- 访问森林中第一棵树的根结点。
- 先序遍历第一棵树中根结点的子树森林。
- 先序遍历除去第一棵树之后剩余的树构成的森林。
效果等同于对各个树依次进行先根遍历,也等同于对对应二叉树进行先序遍历
-
中序遍历森林。森林为非空时,按如下规则进行遍历:
-
中序遍历森林中第一棵树的根结点的子树森林。
-
访问第一棵树的根结点.
-
中序遍历除去第一棵树之后剩余的树构成的森林。
-
效果等同于依次对各个树进行后根遍历,也等同于对对应二叉树进行中序遍历
树和森林的遍历与二叉树遍历的关系
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
5.5 树和二叉树的应用
5.5.1 哈夫曼树和哈夫曼编码
1、哈夫曼树的定义
结点的权:有某种现实含义的数值(如:表示结点的重要性等)
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL, Weighted Path Length)
W
P
L
=
∑
i
=
1
n
w
i
l
i
WPL=\sum_{i=1}^nw_il_i
WPL=i=1∑nwili
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
哈夫曼树不是唯一的!
2、构造哈夫曼树
给定n个权值分别为wl, w2,…, wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
- 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
- 重复步骤2和3,直至F中只剩下一棵树为止。
构造哈夫曼树的注意事项:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
- 哈夫曼树的结点总数为 2n−1。
- 哈夫曼树中不存在度为 1 的结点。
- 哈夫曼树并不唯一,但 WPL 必然相同且为最优。
3、哈夫曼编码
将字符频次作为字符结点权值,构造哈夫曼树,即可得哈夫曼编码,可用于数据压缩
前缀编码:没有一个编码是另一个编码的前缀
固定长度编码:每个字符用相等长度的二进制位表示
可变长度编码:允许对不同字符用不等长的二进制位表示
第六章 图
6.1 图的基本概念
6.1.1 图的定义
图由顶点集
V
V
V和边集
E
E
E组成,记为
G
=
(
V
,
E
)
G=(V,E)
G=(V,E),其中
V
(
G
)
V(G)
V(G)表示图
G
G
G中顶点的有线非空集。
E
(
G
)
E(G)
E(G)表示图G中顶点间的关系(边)的集合。若
V
=
{
v
1
,
v
2
,
…
,
v
n
}
V= \{v_1,v_2,…,v_n\}
V={v1,v2,…,vn}
则使用
∣
V
∣
|V|
∣V∣来表示图
G
G
G中顶点的个数,
E
=
{
(
u
,
v
)
∣
u
∈
V
,
E
∈
V
}
E=\{(u,v)|u∈V,E∈V\}\
E={(u,v)∣u∈V,E∈V}
则使用
∣
E
∣
|E|
∣E∣来表示图G中边的条数。
【注意】线性表可以是空表,树可以是空树,但图不能是空图。图的顶点集V一定非空,但是边集合E可以为空,此时图中只有顶点没有边。
1、有向图
若E是有向边(也称弧)的有限集合时,则图G是有向图。弧是顶点的有序对,记为
<
v
,
w
>
<v,w>
<v,w>,其中v,w是顶点,v称为弧尾,w称为弧头,
<
v
,
w
>
<v,w>
<v,w>称为从v到w的弧,也称v邻接到w。
上图所示的有向图可以表示为
G
=
(
V
,
E
)
G=(V,E)
G=(V,E)
V = { A , B , C , D , E } V=\{A,B,C,D,E\} V={A,B,C,D,E}
E = { < A , B > , < A , C > , < A , E > , < B , E > , < C , D > } E=\{<A,B>,<A,C>,<A,E>,<B,E>,<C,D>\} E={<A,B>,<A,C>,<A,E>,<B,E>,<C,D>}
2、无向图
若 E 是无向边(也称边)的有限集合时,则图 G 为无向图。边是顶点的无序对,记为(v,w) 或(w,v),其中 v、w 是顶点。可以说顶点 w 和顶点 v 互为邻接点,边 ( v , w ) (v,w) (v,w) 依附于顶点 w 和 v;或者说边(v,w) 和顶点 v、w 相关联。
上图可以表示为:
G
=
(
V
,
E
)
G=(V,E)
G=(V,E)
V = { A , B , C , D , E } V=\{A,B,C,D,E\} V={A,B,C,D,E}
E = { ( A , B ) , ( A , C ) , ( A , D ) , ( B , D ) , ( B , E ) } E=\{(A,B),(A,C),(A,D),(B,D),(B,E)\} E={(A,B),(A,C),(A,D),(B,D),(B,E)}
3、简单图与多重图
满足以下两个条件的图称为简单图:
- 不存在重复边
- 不存在顶点到自身的边
【注意】之后提到的图默认为简单图,数据结构中只讨论简单图。
多重图与简单图是相对的两个概念。若图G中某两个顶点之间的边数大于一条,又允许顶点通过一条边与自身关联,则这样的图称为多重图。
4、完全图(简单完全图)
对于无向图,任意两个顶点间都存在边的图,这样的图称为无向完全图。
对于有向图,任意两个顶点间存在着方向相反的两条弧,这样的图称为有向完全图。
5、子图
设有两个图G=(V,E)和G’(V’,E’),若V’是V的子集,E’是E的子集,则称G’是G的子图。若V(G) = V(G’)则称G’是G的生成子图(包含原图的所有结点,可以不包含全部)。
【注意】并非所V和E的任何子集都能构成G的子图,因为这样的子集可能不是图,即E的子集中的某些边关联的顶点可能不再这个V的子集中。
6、连通、连通图和连通分量
连通:在无向图中,若顶点v到顶点w有路径存在,则称v和w是连通的。
连通图:图中任意两点之间均至少有一条通路,否则称为非连通图。
连通分量:无向图中的极大连通子图称为连通分量。
若图是非连通图,则边数最多可以有 E m a x = C n − 1 2 E_{max}=C_{n-1}^2 Emax=Cn−12条。
当 顶点数 − 边数 = 1 顶点数-边数=1 顶点数−边数=1时,刚好可以做到连通且无环。(注意:是可以做到,不是一定),不满足这个条件是无法连通的。
几个概念之间的关系如下:
7、强连通图、强连通分量
强连通:在有向图中,如果一对顶点v和w,从v到w和从w到v之间都有路径,则称这两个顶点是连通的。
强连通图:若有向图中任意一对顶点都是强连通的,则称此图为强连通图。
强连通分量:有向图中的极大强连通子图称为有向图的强连通分量。
8、生成树、生成森林
连通图的生成树是包含图中所有顶点的一个极小连通子图。
在非连通图中,连通分量的生成树构造了非连通图的生成森林。
9、顶点的度、入度和出度
在无向图中,顶点的度是依附于顶点v的边的条数。
在有向图中,顶点的度分为入度和出度,入度是以顶点v为终点的有向边的数目,出度是以顶点v为起点的有向边的数目。
10、边的权和网
在一个图中,每条边上可以设置一些具有某些意义的数值,该数值称为边的权。这种边上带有权值的图称为带权图,也称网。
11、稠密图、稀疏图
边数很少的图称为稀疏图,反之称为稠密图。
这两个概念本身是模糊的概念,稀疏图和稠密图是相对而言的。
12、路径、路径长度和回路
路径是两个顶点间访问需要经过的结点序列。路径上边的数目称为路径长度,第一个顶点和最后一个顶点相同的路径称为回路或环。
顶点间可能不存在路径。
13、简单路径、简单回路
在路径序列中,顶点不重复出现的路径称为简单路径。除第一个顶点和最后一个顶点外不重复出现的回路称为简单回路。
14、距离
若两个顶点间的最短路径存在,则此路径的长度称为两个结点间的距离。若路径不存在,则距离为无穷(∞)。
15、有向树
若一个顶点的入度为0,其余顶点的入度都为1的有向图,称为有向树。
6.2 图的存储及其基本操作
6.2.1 邻接矩阵法
所谓邻接矩阵存储,是使用一个一维数组存储图中各个顶点的信息,一个二维数组存储图中边的信息,存储结点邻接关系的二维数组称为邻接矩阵。
上图的邻接矩阵如下:
[
0
1
1
1
0
1
0
0
1
1
1
0
0
0
0
1
1
0
0
0
0
1
0
0
0
]
\left[ \begin{matrix} 0 & 1 & 1 & 1 & 0\\ 1 & 0 & 0 & 1 & 1 \\ 1 & 0 & 0 & 0 & 0 \\ 1 & 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 & 0 \end{matrix} \right]
0111010011100001100001000
【注意】
- 在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略)。
- 当邻接矩阵的元素仅表示相应边是否存在时,EdgeType可采用值为0和1的枚举类型。
- 无向图的邻接矩阵是对称矩阵,对规模特大的邻接矩阵可采用压缩存储。
- 邻接矩阵表示法的空间复杂度为 O ( N 2 ) O(N^2) O(N2),其中n为图的顶点数|V|。
存储结构定义如下:
typedef char VertexType; // 顶点的数据类型
typedef int EdgeType; // 边的数据类型
typedef struct {
VertexType Vex[100];
EdgeType Edge[100][100];
int vexNum,arcNum; //图的当前顶点和弧数
}MGraph;
6.2.2 邻接表法(顺序+链式存储)
邻接表法是对图G中的每个顶点 v i v_i vi建立一个单链表,第i个单链表中的结点表示依附于顶点 v i v_i vi的边。
【注意】
- 邻接表的表示方式不唯一
- 对于无向图,邻接表的每条边会对应两条信息,删除顶点、边等操作复杂度高
- 无向图采用邻接表存储所需要的存储空间为 O ( ∣ V ∣ + 2 ∣ E ∣ ) O(|V|+2|E|) O(∣V∣+2∣E∣)(2E是因为在无向图中,每条边在邻接表中出现了两次),有向图采用邻接表存储所需的存储空间为 O ( ∣ V ∣ = ∣ E ∣ ) O(|V|=|E|) O(∣V∣=∣E∣)。
- 对于稀疏图,采用邻接表法表示可以节省大量存储空间。
- 在邻接表中,找到一个顶点的邻边很容易,但是若要确定给定的两个顶点中是否存在边,在邻接矩阵中可以立刻查到,但是在邻接表中效率很低。
其存储结构表示如下:
#define MVNum 100 //最大顶点数
typedef struct ArcNode { //边/弧
int adjvex; //邻接点的位置
struct ArcNode *next; //指向下一个表结点的指针
} ArcNode;
typedef struct VNode {
char data; //顶点信息
ArcNode *first; //第一条边/弧
} VNode, AdjList[MVNum]; //AdjList表示邻接表类型
typedef struct {
AdjList vertices; //头结点数组
int vexnum, arcnum; //当前的顶点数和边数
} ALGraph;
6.2.3 十字链表(只能存储有向图)
十字链表是有向图的一种链式存储结构,对应于有向图中的每条弧有一个结点,对于每个顶点也有一个结点。结点的结构如下图所示:
绘制是十字链表的过程如下:
1、将顶点和弧分别用上述两种顶点表示出来:
2、根据关系连线:
十字链表性能分析
空间复杂度: O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
想要找到十字链表指定顶点的所有入边和出边,只需要沿着某个顶点的hlink或tlink一直找即可。
6.2.4 邻接多重表存储无向图
邻接多重表是存储无向图的另一种存储结构。
性能分析:
空间复杂度: O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
删除边、删除结点等操作很方便。
只适用于存储无向图。
图的四种存储方法的比较
邻接表 | 邻接矩阵 | 十字链表 | 邻接多重表 | |
---|---|---|---|---|
空间复杂度 | 无向图:$O( | V | +2 | E |
适合存储 | 稀疏图 | 稠密图 | 仅有向图 | 仅无向图 |
表示方式 | 不唯一 | 唯一 | 不唯一 | 不唯一 |
计算度/出度/入度 | 计算有向图的度、入度不方便,其余很方便 | 必须遍历对应行或列 | 很方便 | 很方便 |
删除边或顶点 | 无向图中删除边和顶点都不方便 | 删除边很方便,删除顶点需要大量移动数据。 | 很方便 | 很方便 |
找邻边 | 找有向图的入边不方便,其余很方便 | 必须遍历对应行或列 | 很方便 | 很方便 |
6.3 图的遍历
6.3.1 图的广度优先遍历(BFS)
1、理论原理及要点
【回顾】树的广度优先遍历:层序遍历
广度优先遍历指的是从图的一个未遍历的节点出发,先遍历这个节点的相邻节点,再依次遍历每个相邻节点的相邻节点。
【要点】
找到与⼀个顶点相邻的所有顶点;
标记哪些顶点被访问过;
需要⼀个辅助队列。
其手动遍历方式如下:
最终得到的遍历序列为:A B C D E
2、代码实现
广度优先遍历所需要的操作:
FirstNeighber(G,x)
:求图中顶点x的第一个邻接点,有则返回顶点号,否则返回-1;NextNeighber(G,x,y)
:假设图中顶点y是顶点x的一个邻接点,则返回除了y以外,顶点x的下一个邻接点的编号,若y是x的最后一个邻接点,则返回-1;- bool visited[MAX_VERTEX_NUM]; // 访问标记数组
// 本示例为伪代码,主要是为了表现BFS的原理
#include "iostream"
#define MAX_VERTEX_NUM 10
using namespace std;
typedef int *Queue; // 定义队列类型,这里使用伪代码表示,方便代码的阅读,当做队列使用
typedef int *Graph; // 伪代码使用,作用同上
Queue Q;
bool visited[MAX_VERTEX_NUM];
void EnQueue(Queue Q, int v); // v入队
void DeQueue(Queue Q, int v); // v出队
bool isEmpty(Queue Q); // v出队
void visit(int w); // 访问结点w
int FirstNeighbor(Graph G, int v); // 求图中顶点x的第一个邻接点
int NextNeighbor(Graph G, int v, int w); // 求除了w外,v的下一个邻接点的编号
// 广度优先遍历
void BFS(Graph G, int v) { // 从顶点v出发,广度优先遍历图G
visit(v); // 访问起始节点v
visited[v] = true; // 访问标记
EnQueue(Q, v); // v结点入队
while (!isEmpty(Q)) { // 当队列不为空
DeQueue(Q, v); // 顶点v出队
// 检查v的所有邻接点
for (int w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) {
if (!visited[w]) { // w是v的尚未访问的邻接点
visit(w); // 访问结点w
visited[w] = true; // 访问标记
EnQueue(Q, w); // 顶点w入队
}
}
}
}
遍历序列的可变性:
- 同一个图的邻接矩阵表示法唯一,因此广度优先遍历序列唯一
- 同一个图的邻接表表示法不唯一,因此广度优先遍历序列不唯一
算法存在的问题:如果是非连通图,则上述代码无法遍历完所有结点
解决方法:遍历整个visited数组,检查是否还有未访问过的结点。
【结论】对于无向图,调用BFS的次数=连通分量数
3、性能分析
空间复杂度:最坏的情况下,辅助队列大小为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)
时间复杂度:
- 采用邻接表存储方式时,每个顶点均需要搜索一遍,故时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),在查找某个顶点的邻接点共需要 O ( ∣ E ∣ ) O(|E|) O(∣E∣)的时间,总的时间复杂度为: O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。
- 采用邻接矩阵存储方式时,访问V个顶点需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间。查找每个顶点的邻接点需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间,时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
4、广度优先生成树
在广度遍历时,我们可以得到一棵遍历树,称为广度优先生成树。
因为图的邻接矩阵存储表示是唯一的,所以广度优先生成树也是唯一的。
而邻接表法表示不唯一,故使用邻接表法表示的图的广度优先生成树不唯一。
6.3.2 深度优先遍历(DFS)
1、概念
DFS的基本思想:首先访问图中的某一起始顶点v,然后从v出发,访问与v邻接且未被访问的任一顶点w1,再访问与w1邻接且未被访问的任一顶点w2……重复上述过程,直到不能继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点继续重复上述搜索过程,直到所有顶点都被访问为止。
以下是DFS的一个示例:
2、代码实现
// 本示例为伪代码,主要是为了表现DFS的原理
#include "iostream"
#define MAX_VERTEX_NUM 10
using namespace std;
typedef char VertexType; // 顶点的数据类型
typedef int EdgeType; // 边的数据类型
typedef struct {
VertexType Vex[100];
EdgeType Edge[100][100];
int vexNum, arcNum; //图的当前顶点和弧数
} Graph;
bool visited[MAX_VERTEX_NUM];
int v;
void visit(int w); // 访问结点w
int FirstNeighbor(Graph G, int v); // 求图中顶点x的第一个邻接点
int NextNeighbor(Graph G, int v, int w); // 求除了w外,v的下一个邻接点的编号
// 深度优先遍历
void DFS(Graph G, int v) { // 从顶点v出发,深度优先遍历图G
visit(v); // 访问顶点v
visited[v] = true; // 设置已访问标记
for (int w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) {
if (!visited[w]) { // w为v的未访问的邻接节点
DFS(G, w);
}
}
}
void DFSTraverse(Graph G) {
for (v = 0; v < G.vexNum; ++v) {
visited[v] = false;
}
for (v = 0; v < G.vexNum; ++v) {
if (!visited[v]) {
DFS(G, v);
}
}
}
注意:图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同。因此,对于相同的图,基于邻接矩阵遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历所得到的DFS和BFS序列是不唯一的。
3、效率分析
- DFS是一个递归算法,需要借助函数调用栈,故其空间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。
- 遍历图的过程实质上是对每个顶点查找其邻接点的过程,其耗费的时间取决于其所用的存储结构:
- 以邻接矩阵表示时:
- 查找每个顶点的邻接点所需的时间为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)
- 总的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
- 以邻接表表示时:
- 查找所有顶点的邻接点所需时间为 O ( ∣ E ∣ ) O(|E|) O(∣E∣)
- 访问顶点所需的时间为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)
- 总的时间复杂度为 O ( ∣ E ∣ + ∣ V ∣ ) O(|E|+|V|) O(∣E∣+∣V∣)
- 以邻接矩阵表示时:
4、深度优先生成树和生成森林
与广度优先搜索一样,深度优先搜索也会产生一棵深度优先生成树。当然,这是有条件的,即对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林。与 BFS类似,基于邻接表存储的深度优先生成树是不唯一的。
6.3.3 图的遍历与图的连通性
图的遍历算法可以用来判断图的连通性。
对于无向图来说:
-
若无向图是连通的,则从任一结点出发,仅需一次遍历就能够访问图中的所有顶点;
-
若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。
对于有向图来说:
- 若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
强连通图使用一次BFS就能完成遍历。
6.4 图的应用
6.4.1 最小生成树
对于一个带权连通无向图 G = ( V , E ) G=(V,E) G=(V,E),生成树不同,每棵树的权(树中所有边上的权值和)也不同,设 R R R为 G G G的所有生成树的集合,若 T T T为 R R R中权值和最小的生成树,则 T T T称为 G G G的最小生成树(Minimum-Spanning-Tree,MST)
一个图中可能存在多条相连的边,我们**一定可以从一个图中挑出一些边生成一棵树。**这仅仅是生成一棵树,还未满足最小,当图中每条边都存在权重时,这时候我们从图中生成一棵树(n - 1 条边)时,生成这棵树的总代价就是每条边的权重相加之和。
【注意】
1、最小生成树可能有多个,但边的权值之和总是唯一且最小的
2、最小生成树的边数=定点数-1,砍掉一条则不连通,增加一条则会出现回路
3、若一个连通图本身就是一颗树,则其最小生成树就是它本身
4、只有连通图才有生成树,非连通图只有生成森林
1、Prim算法
从某一个顶点(所以存在多个最小生成树)开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
类似贪心算法
示例如下:
2、Kruskal算法
每次选择权值最小的边,使这条边的两头连通(原本已近连通则不选)直到所有结点都连通。
示例如下:
3、对比Prim和Kruskal算法
- Prim算法:
- 时间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
- 适合边稠密的图
- Kruskal算法
- 时间复杂度: O ( ∣ E ∣ l o g 2 ∣ E ∣ ) O(|E|log_2|E|) O(∣E∣log2∣E∣)
- 适用于求解边稀疏图
6.4.2 最短路径
最短路径算法
1、BFS求解最短路径
使用 BFS算法求无权图的最短路径问题,需要使用三个数组:
d[]
数组用于记录顶点 u 到其他顶点的最短路径。path[]
数组用于记录最短路径从那个顶点过来。visited[]
数组用于记录是否被访问过。
代码实现:
#define MAX_LENGTH 2147483647 //地图中最大距离,表示正无穷
// 求顶点u到其他顶点的最短路径
void BFS_MIN_Disrance(Graph G,int u){
for(i=0; i<G.vexnum; i++){
visited[i]=FALSE; //初始化访问标记数组
d[i]=MAX_LENGTH; //初始化路径长度
path[i]=-1; //初始化最短路径记录
}
InitQueue(Q); //初始化辅助队列
d[u]=0;
visites[u]=TREE;
EnQueue(Q,u);
while(!isEmpty[Q]){ //BFS算法主过程
DeQueue(Q,u); //队头元素出队并赋给u
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)){
if(!visited[w]){
d[w]=d[u]+1;
path[w]=u;
visited[w]=TREE;
EnQueue(Q,w); //顶点w入队
}
}
}
}
以下为手算示例:
2、Dijkstra算法求解单源最短路径问题
BFS算法的局限性:BFS算法求单源最短路径只适⽤于⽆权图,或所有边的权值都相同的图。
Dijkstra算法需要用到三个数组:
final[]
:标记各个顶点是否已经找到最短路径dist[]
:最短路径长度path[]
:路径上的前驱
以下是示例执行过程:
3、Floyd算法
Floyd算法用于求出每⼀对顶点之间的最短路径,使⽤动态规划思想,将问题的求解分为多个阶段。
Floyd算法使用到两个矩阵:
-
dist[][]
:目前各顶点间的最短路径。 -
path[][]
:两个顶点之间的中转点。
以下是执行过程:
代码实现如下:
int dist[MaxVertexNum][MaxVertexNum];
int path[MaxVertexNum][MaxVertexNum];
void Floyd(MGraph G){
int i,j,k;
// 初始化部分
for(i=0;i<G.vexnum;i++){
for(j=0;j<G.vexnum;j++){
dist[i][j]=G.Edge[i][j];
path[i][j]=-1;
}
}
// 算法核心部分
for(k=0;k<G.vexnum;k++){
for(i=0;i<G.vexnum;i++){
for(j=0;j<G.vexnum;j++){
if(dist[i][j]>dist[i][k]+dist[k][j]){
dist[i][j]=dist[i][k]+dist[k][j];
path[i][j]=k;
}
}
}
}
}
Floyd算法的时间复杂度为 O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3),但由于其代码很紧凑,不包含其他复杂的数据结构,其隐含的常数系数是很小的,在处理中等规模的问题上,还是很有效的。
Floyd算法可以⽤于负权值带权图,但是不能解决带有“负权回路”的图(有负权值的边组成回路),这种图有可能没有最短路径。
4、最短路径算法比较
BFS算法 | Dijkstra算法 | Floyd算法 | BFS |
---|---|---|---|
无权图 | ✔ | ✔ | ✔ |
带权图 | ✘ | ✔ | ✔ |
带负权值的图 | ✘ | ✘ | ✔ |
带负权回路的图 | ✘ | ✘ | ✘ |
通常⽤于 | 求⽆权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
6.4.3 有向无环图描述表达式
1、有向无环图
若⼀个有向图中不存在环,则称为有向⽆环图,简称 DAG图(Directed Acyclic Graph)。
手动构造有向无环图的步骤:
- 把各个操作数不重复地排成一排
- 标出各个运算符的生效顺序(不完全唯一)
- 按顺序加入运算符,注意“分层”
- 从底向上逐层检查同层的运算符是否可以合体。
手动构造有向无环图的示例如下:
合并运算符
6.3.4 拓扑排序
AOV网:用顶点表示活动的网
拓扑排序:找到做事的先后顺序
拓扑排序的一个过程如下:
拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
①每个顶点出现且只出现一次。
②若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。
或定义为:
拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列。
代码实现的文字描述如下:
- 从 AOV ⽹中选择⼀个没有前驱(⼊度为0)的顶点并输出。
- 从⽹中删除该顶点和所有以它为起点的有向边。
- 重复 ① 和 ② 直到当前的 AOV ⽹为空或当前⽹中不存在⽆前驱的顶点为⽌。
#define MaxVertexNum 100 //图中顶点数目最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点位置
struct ArcNode *nextarc; //指向下一条弧的指针
}ArcNode;
typedef struct VNode{ //顶点表结点
VertexType data; //顶点信息
ArcNode *firstarc; //指向第一条依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];
typedef struct{
AdjList vertices; //邻接表
int vexnum,arcnum; //图的顶点数和弧数
}Graph; //Graph是以邻接表存储的图类型
// 对图G进行拓扑排序
bool TopologicalSort(Graph G){
InitStack(S); //初始化栈,存储入度为0的顶点
for(int i=0;i<g.vexnum;i++){
if(indegree[i]==0)
Push(S,i); //将所有入度为0的顶点进栈
}
int count=0; //计数,记录当前已经输出的顶点数
while(!IsEmpty(S)){ //栈不空,则存入
Pop(S,i); //栈顶元素出栈
print[count++]=i; //输出顶点i
for(p=G.vertices[i].firstarc;p;p=p=->nextarc){
//将所有i指向的顶点的入度减1,并将入度为0的顶点压入栈
v=p->adjvex;
if(!(--indegree[v]))
Push(S,v); //入度为0,则入栈
}
}
if(count<G.vexnum)
return false; //排序失败
else
return true; //排序成功
}
6.4.5 关键路径
AOE网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)
- AOE⽹具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发⽣。
- 另外,有些活动是可以并行进行的。
- 在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
- 从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动
- 完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长
- 事件 v k v_k vk的最早发生时间 v e ( k ) ve(k) ve(k):决定了所有从 v k v_k vk开始的活动能够开工的最早时间。
- 事件 v k v_k vk的最迟发生时间 v l ( k ) vl(k) vl(k):它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
- 活动 a i a_i ai的最早开始时间 e ( i ) e(i) e(i):指该活动弧的起点所表示的事件的最早发生时间。
- 活动 a i a_i ai的最迟开始时间 l ( i ) l(i) l(i):它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
- 活动
a
i
a_i
ai的时间余量
d
(
i
)
=
l
(
i
)
−
e
(
i
)
d(i)=l(i)-e(i)
d(i)=l(i)−e(i):表示在不增加完成整个工程所需总时间的情况下,活动
a
i
a_i
ai可以拖延的时间。若一个活动的时间余量为0,则说明该活动必须要如期完成,否则会拖延整个工程的进度,所以称
l
(
i
)
−
e
(
i
)
=
0
l(i)-e(i)=0
l(i)−e(i)=0即
l
(
i
)
=
e
(
i
)
l(i)=e(i)
l(i)=e(i)的活动为关键活动。由关键活动组成的路径称为关键路径。
关键路径的求解方法为:
以下是手算求解关键路径的一个示例(重复的步骤已合并在一步内完成):
- 关键活动、关键路径的特性:
- 若关键活动耗时增加,则整个⼯程的⼯期将增⻓。
- 缩短关键活动的时间,可以缩短整个⼯程的⼯期。
- 当缩短到⼀定程度时,关键活动可能会变成⾮关键活动。
- 可能有多条关键路径,只提⾼⼀条关键路径上的关键活动速度并不能缩短整个⼯程的⼯期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短⼯期的⽬的。
第七章 查找
7.1 查找的基本概念
- 查找:在数据集合中寻找满⾜某种条件的数据元素的过程称为查找。
- 查找表(查找结构):⽤于查找的数据集合称为查找表,它由同⼀类型的数据元素(或记录)组成。
- 关键字:数据元素中唯⼀标识该元素的某个数据项的值,使⽤基于关键字的查找,查找结果应该是唯⼀的。
- 对查找表的常⻅操作:
- 查找符合条件的数据元素
- 插⼊、删除某个数据元素
只进⾏查找操作最好使用静态查找表,若需要进⾏大量插入删除操作可使用动态查找表。
- 查找⻓度:在查找运算中,需要对⽐关键字的次数称为查找⻓度。
- 平均查找⻓度(ASL, Average Search Length):所有查找过程中进⾏关键字的⽐较次数的平均值。
A S L = ∑ i = 1 n P i C i ASL=\sum_{i=1}^{n}P_iC_i ASL=i=1∑nPiCi
7.2 顺序查找和折半查找
7.2.1 顺序查找
- 顺序查找:⼜叫“线性查找”,通常⽤于线性表。
- 算法思想:从头到尾挨个找(或者从尾到头)。
- 代码实现:
typedef struct { // 顺序查找的数据结构
int *elem; // 动态数组基址
int TableLen; // 表长
} SSTable;
int Search_Seq(SSTable ST, int key) {
int i;
for (i = 0; i < ST.TableLen && ST.elem[i] != key; ++i);
// 查找成功,则返回元素下标,否则返回-1
return i == ST.TableLen ? -1 : i;
}
带哨兵方式(优点:不用判断数组是否越界,效率略高):
typedef struct { // 顺序查找的数据结构
int *elem; // 动态数组基址
int TableLen; // 表长
} SSTable;
int Search_Seq(SSTable ST, int key) {
ST.elem[0] = key;
int i;
for (i = ST.TableLen; ST.elem[i] != key; --i)
// 查找成功返回数组下标,否则返回0
return i;
}
查找效率分析:
A S L 成功 = 1 + 2 + 3 + . . . + n n = n + 1 2 ASL_{成功}=\frac{1+2+3+...+n} {n}=\frac{n+1} {2} ASL成功=n1+2+3+...+n=2n+1
A S L 失败 = n + 1 ASL_{失败}=n+1 ASL失败=n+1
7.2.2 折半查找
折半查找,又称“二分查找”,仅适用于有序的顺序表。
顺序表具有随机访问的特性,而链表没有。
其查找示例如下:
查找成功的情况:
查找失败的情况:
代码实现:
typedef struct {
ElemType *elem;
int TableLen;
} SSTable;
// 折半查找
int Binary_Search(SSTable L, ElemType key) {
int low = 0, high = L.TableLen, mid;
while (low <= high) {
mid = (low + high) / 2;
if (L.elem[mid] == key)
return mid;
else if (L.elem[mid] > key)
high = mid - 1; //从前半部分继续查找
else
low = mid + 1; //从后半部分继续查找
}
return -1;
}
查找效率分析
折半查找的过程可以由一棵查找判定树来表示。其构造过程如下:
构造原则:
如果当前low和high之间有奇数个元素,则mid分隔后,左右两部分元素个数相等
如果当前low和high之间有偶数个元素,则mid分隔后,左半部分比右半部分少一个元素
最终构造完成的折半查找判定树如下:
【注意】
- 折半查找判定树一定是平衡二叉树
- 折半查找判定树中,只有最下面一层是不满的,因此,元素个数为n时,树高 h = ⌈ l o g 2 ( n + 1 ) ⌉ h=⌈log_2(n+1)⌉ h=⌈log2(n+1)⌉(该树高不包括失败结点),计算方法同完全二叉树。
查找成功的 A S L ≤ h ASL≤h ASL≤h,查找失败的 A S L ≤ h ASL≤h ASL≤h
折半查找的时间复杂度= O ( l o g 2 n ) O(log_2n) O(log2n)
7.2.3 分块查找
1、算法思想
分块查找使用索引表来保存每个分块的最大关键字和分块存储区间。其特点为块内无序,快间有序。
其结构定义如下:
// 索引表
typedef struct {
ElemType maxValue;
int low, high;
} Index;
// 顺序表存储实际元素
ElemType List[100];
其查找流程如下:
在索引表中确定待查记录所属的分块(可使用顺序、折半查找)
【注意】使用折半查找时,若索引表不含目标关键字,则折半查找最终停留在low > high,此时,需要在low所指向的分块中查找。若low超出索引表范围,则查找失败,
在找到的分块内顺序查找(因为块内无序,无法使用折半查找)
分块查找一般不考察代码。
2、算法效率分析
- A S L = 查找索引表的平均查找长度 + 查找分块的平均查找长度 ASL=查找索引表的平均查找长度+查找分块的平均查找长度 ASL=查找索引表的平均查找长度+查找分块的平均查找长度
- 设有
n
n
n个记录,均匀分为
b
b
b块,每块
s
s
s个记录。
- 顺序查找索引表:
- A S L = b + 1 2 + s + 1 2 ASL=\frac{b+1}{2}+\frac{s+1}{2} ASL=2b+1+2s+1
- 当 s = n s=\sqrt{n} s=n时, A S L 最小 = n + 1 ASL_{最小}=\sqrt{n}+1 ASL最小=n+1 *这里的s为理想块长
- 折半查找索引表:
- A S L = ⌈ l o g 2 ( b + 1 ) ⌉ + s + 1 2 ASL=⌈log_2(b+1)⌉+\frac{s+1}{2} ASL=⌈log2(b+1)⌉+2s+1
- 顺序查找索引表:
【注意】对索引表进行折半查找时,若索引表中不包含目标关键字,则折半查找最终停在low>high,要在low所指分块中查找。
7.3 树形查找
7.3.1 二叉排序树(BST)
1、定义
二叉排序树(也称二叉查找树)或者是一棵空树,或者是具有下列特性的二叉树:
-
若左子树非空,则左子树上所有结点的值均小于根结点的值。
-
若右子树非空,则右子树上所有结点的值均大于根结点的值。
-
左、右子树也分别是一棵二叉排序树。
左子树节结值 < 根结点值 < 右子树结点值
经过中序遍历,可也得到一个递增的有序序列。
如上述二叉排序树经过中序遍历得到的序列为: 7 , 9 , 10 , 13 , 16 , 19 , 20 , 29 , 32 , 33 , 37 , 41 如上述二叉排序树经过中序遍历得到的序列为:7,9,10,13,16,19,20,29,32,33,37,41 如上述二叉排序树经过中序遍历得到的序列为:7,9,10,13,16,19,20,29,32,33,37,41
其结构定义如下:
typedef struct BSTNode {
int key;
struct BSTNode *lchild, *rchild;
} BSTNode, *BSTree;
2、二叉排序树的查找
若树非空,目标值与根结点的值比较;若相等,则查找成功;
若小于根结点,则在左子树上查找,否则在右子树上查找。
查找成功,返回结点指针;
查找失败返回NULL。
代码实现:
// 非递归写法
BSTNode *BST_Search(BSTree T, int key) {
while (T != NULL && key != T->key) { // 若树空或等于根节点值,结束循环
if (key < T->key)
T = T->lchild; // 根节点值大于关键字的值,在左子树中查找
else
T = T->rchild; // 根节点值小于关键字的值,在右子树中查找
}
return T;
}
// 递归写法
BSTNode *BSTSearch(BSTree T, int key) {
if (T == NULL)
return NULL;
if (key == T->key)
return T;
else if (key < T->key)
return BSTSearch(T->lchild, key);
else
return BSTSearch(T->rchild, key);
}
非递归写法最坏空间复杂度: O ( 1 ) O(1) O(1)
递归写法最坏空间复杂度: O ( h ) O(h) O(h) (h为树高)
3、二叉排序树的插入
若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树。
新插入的结点一定是叶子结点
代码实现如下:
bool BST_Insert(BSTree &T, int k) {
if (T == NULL) { // 树为空,此时插入的结点为根节点
T = (BSTree) malloc(sizeof(BSTNode));
T->key = k;
T->lchild = T->rchild = NULL;
return true;
} else if (k == T->key) { // 树中村赞关键字相同的结点,插入失败
return false;
} else if (k < T->key) { // 插入T的左子树
return BST_Insert(T->lchild, k);
} else { // 插入T的右子树
return BST_Insert(T->rchild, k);
}
}
最坏时间复杂度: O ( h ) O(h) O(h)(h为树高)
4、二叉排序树的构造——不断插入新结点的过程
代码实现:
void Create_BST(BSTree &T, int str[], int n) {
T = NULL;
int i = 0;
while (i < n) {
BST_Insert(T, str[i]);
i++;
}
}
【注意】不同的关键字序列可能得到同款二叉排序树,也可能得到不同的二叉排序树。
5、二叉排序树的删除
先搜索到目标结点,此后有两种情况:
(1)若被删除的结点z是叶子结点,则直接删除,不会破坏二叉排序树的性质。(左子树结点值<根节点值<右子树结点值)
(2)若被删除的结点z只有一棵左子树或右子树,则让z的子树称为z的父结点的子树,替代z的位置。
(3)若被删除的结点z有左右两颗子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删除这个直接后继(或直接前驱),这样就转化成了第一种或第二种情况。
这里提到的直接前驱和直接后继指的是中序遍历序列的前驱和后继。
6、查找效率分析
查找长度:在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度
这样的二叉排序树可能会出现右图这种情况,影响排序效率。
最好情况:n个结点的二叉树最小高度为 ⌊ l o g 2 n ⌋ + 1 ⌊log_2n⌋+1 ⌊log2n⌋+1,平均查找长度为 O ( l o g 2 n ) O(log_2n) O(log2n)
最坏情况:每个结点只有一个分支,树高 h = 结点数 n h=结点数n h=结点数n,平均查找长度= O ( n ) O(n) O(n)
7.3.2 平衡二叉树(AVL)
1、定义
平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)
树上任一结点的左子树和右子树的高度之差不超过1。
结点的平衡因子=左子树高-右子树高。
平衡因子的值只能为0,1-1
只要有任一结点的平衡因子的绝对值大于1,就不是平衡二叉树
其结构定义如下:
typedef struct AVLNode {
int key; // 数据域
int balance; // 平衡因子
struct AVLNode *lchild, *rchild;
} AVLNode, *AVLTree;
2、平衡二叉树的插入
解决方法:通过调整“最小不平衡子树”来再次达到平衡!!!
根据插入情况的不同,有如下四种情况:
- LL:在A的左孩子的左子树中插入导致不平衡
- RR:在A的右孩子的右子树中插入导致不平衡
- LR:在A的左孩子的右子树中插入导致不平衡
- RL:在A的右孩子的左子树中插入导致不平衡
(1)LL平衡旋转
假定所有的子树的高度都是H,是为了在插入节点后,导致树的平衡性被破坏。
目标:1、恢复平衡;2、保持二叉排序树特性。
操作:右单旋转:由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
(2)RR平衡旋转
操作:RR平衡旋转(左单旋转):由于在结点A的右孩子(R)的右子树®上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树
(3)代码思路
实现f向右下旋转,p向右上旋转。
实现f向左下旋转,p向左上旋转。
(4)LR平衡旋转
先左后右双旋转:由于在A的左孩子(L)的右子树®上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置
(5)RL平衡旋转
先右后左双旋转:由于在A的右孩子®的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置
总结:
- LL:在A的左孩子的左子树中插入导致不平衡
- 调整:A的左孩子节点右上旋
- RR:在A的右孩子的右子树中插入导致不平衡
- 调整:A的右孩子节点右上旋
- LR:在A的左孩子的右子树中插入导致不平衡
- 调整:A的左孩子的右孩子节点 先左上旋后右上旋
- RL:在A的右孩子的左子树中插入导致不平衡
- 调整:A的右孩子的左孩子节点 先右上旋后左上旋
3、查找效率分析
若树高为 h h h,则最坏情况下,查找一个关键字最多需要对比 h h h次,即查找的时间复杂度不超过 O ( h ) O(h) O(h)
【记忆】平衡二叉树的平均查找长度为 O ( l o g 2 n ) O(log_2n) O(log2n)(证明十分麻烦,记住即可)
4、平衡二叉树的删除
平衡二叉树的删除操作具体步骤:
- 删除结点(方法同“二叉排序树”)
- 若删除的结点是叶子,直接删。
- 若删除的结点只有一个子树,用子树顶替删除位置
- 若删除的结点有两棵子树,用前驱(或后继)结点顶替,并转换为对前驱(或后继)结点的删除
- 向上找到最小不平衡子树,找不到则说明已经平衡。
- 找最小不平衡子树下,“个头”最高的儿子、孙子
- 根据孙子的位置,调整平衡(LL/RR/LR/RL)
- 孙子在LL:儿子右单旋
- 孙子在RR:儿子左单旋
- 孙子在LR:孙子先左旋,再右旋
- 孙子在RL:孙子先右旋,再左旋
- 如果不平衡向上传导,继续第二步
- 对最小不平衡子树的旋转可能导致树变矮,从而导致上层祖先不平衡(不平衡向上传递)
平衡二叉树删除的时间复杂度: O ( l o g 2 n ) O(log_2n) O(log2n)
7.4 B树
7.4.1 B树及其基本操作
1、m叉查找树
查找成功的情况:
查找失败的情况
2、如何保证查找效率
规定:
- m叉查找树中,除了根节点外,任何结点至少有 ⌈ m / 2 ⌉ ⌈m/2⌉ ⌈m/2⌉个分叉,即至少含有 ⌈ m / 2 ⌉ − 1 ⌈m/2⌉-1 ⌈m/2⌉−1个关键字。
否则会很稀疏,浪费空间
- m叉查找树中,对于任何一个结点,其所有子树的高度要相同。
否则,树高过高,需要查询很多层结点
3、B树定义
B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树或为空树,或为满足如下特性的m叉树:
- 树中每个结点至多有m棵子树,即至多含有m-1个关键字。
- 若根结点不是终端结点,则至少有两棵子树。
- 除根结点外的所有非叶结点至少有[m/2]棵子树,即至少含有「m/2]-1个关键字。
- 所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
其结点结构如下:
m阶B树的核心特性:
根节点的子树数 ∈ [ 2 , m ] ∈[2,m] ∈[2,m],关键字数 ∈ [ 1 , m − 1 ] ∈[1,m-1] ∈[1,m−1]
其他节点的子树数 ∈ [ ⌈ m / 2 ⌉ , m ] ∈[⌈m/2⌉,m] ∈[⌈m/2⌉,m],关键字数 ∈ [ ⌈ m / 2 ⌉ − 1 , m − 1 ] ∈[⌈m/2⌉-1,m-1] ∈[⌈m/2⌉−1,m−1]
对任一结点,其所有子树高度都相同
关键字的值:子树0<关键字1<子树1<关键字2<子树2<…(类比二叉查找树 左<中<右)
B树的高度:含n个关键字的m阶B树,最小高度、最大高度为多少?
l
o
g
m
(
n
+
1
)
≤
h
<
l
o
g
⌈
m
/
2
⌉
m
+
1
2
+
1
log_m(n+1)≤h<log_{⌈m/2⌉}\frac{m+1}{2}+1
logm(n+1)≤h<log⌈m/2⌉2m+1+1
7.4.2 B树的基本操作
1、B树的插入
如下示例**(此处省略失败节点)**:
情形1:插入根节点
情形2:
情形3:
情形4:
2、B树的删除
情形1: 被删除结点是非终端结点,使用直接前驱或直接后继来替代被删除的关键字
情形2:删除后导致B树特性失效(结点数小于⌈n/2⌉),则看兄弟结点的元素是否够借
情形3:兄弟结点不够借,将关键字删除后,与左(或右)兄弟结点及双亲结点中的关键字合并
总结:
7.5 B+树
1、定义
下图是一颗4阶B+树:
一颗m阶的B+树需要满足以下条件:
-
每个分支节点最多有m棵子树(孩子结点)
-
非叶根节点至少有两颗子树,其他每个分支节点至少有 ⌈ n / 2 ⌉ ⌈n/2⌉ ⌈n/2⌉棵子树
-
结点的子树个数与关键字个数相等
-
所有叶节点包含全部关键字及指向相应记录的指针,叶节点中将关键字按照大小顺序排列,并且相邻叶节点按大小顺序相互链接起来。
支持顺序查找
-
所有分支结点中仅包含它的各个子节点中关键字的最大值及指向其子节点的指针。
2、B+树的查找
多路查找
顺序查找
在B+树中,非叶节点不包含改关键字对应记录的存储地址,这样可以使一个磁盘块中包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快。
3、B树与B+树
m阶B树 | m阶B+树 | |
---|---|---|
类比 | 二叉查找树的进化–>m叉查找树 | 分块查找的进化–>多级分块查找 |
关键字与分叉 | n个关键字对应n+1个分叉(子树) | n个关键字对应n个分叉 |
节点包含的信息 | 所有结点都包含记录的信息 | 只有最下层叶节点才包含记录的信息 |
查找方式 | 不支持顺序查找,查找成功时,可能会停留在任何一层结点,查找速度“不稳定” | 支持顺序查找。查找成功或失败都会到达最下一层结点,查找速度“稳定" |
相同点 | 除了根节点意外,最少**⌈n/2⌉**个分叉(确保结点不要太空),任何一个结点的子树都要一样高(确保绝对平衡。 | 除了根节点意外,最少**⌈n/2⌉**个分叉(确保结点不要太空),任何一个结点的子树都要一样高(确保绝对平衡。 |
7.6 散列查找
7.6.1 散列表
散列表,又叫哈希表,是一种数据结构。其特点是:数据元素的关键字与其存储地址直接相关。
如何建立“关键字”与“存储地址”的关系?
通过散列函数(哈希函数): A d d r = H ( k e y ) Addr=H(key) Addr=H(key)
若不同的关键字通过散列函数映射到同一个值,则称它们为**“同义词”**
通过散列函数确定的位置已经存放了其他元素,则称这种情况为**“冲突”**
7.6.2 处理冲突的方法
1、拉链法(链地址法)
用拉链法(又称链接法、链地址法)处理“冲突”:把所有“同义词”存储在一个链表中
查找方法:
2、开放定址法
所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放,其数学递推式为:
H
i
=
(
H
(
k
e
y
)
+
d
i
)
%
m
H_i=(H(key)+d_i)\%m
Hi=(H(key)+di)%m
其中,
i
=
0
,
1
,
2
,
.
.
.
,
k
(
k
≤
m
−
1
)
i=0,1,2,...,k(k≤m-1)
i=0,1,2,...,k(k≤m−1),
m
m
m表示散列表表长,
d
i
d_i
di为增量序列,
i
i
i 可以理解为“第
i
i
i 次发生冲突”。
我们需要学习的开放定址法有如下三种:
- 线性探测法
- 平方探测法
- 伪随机探测法
(1)线性探测法
d i = 0 , 1 , 2 , 3 , . . . , m − 1 d_i=0,1,2,3,...,m-1 di=0,1,2,3,...,m−1,即发生冲突时,每次往后探测相邻的下一个单元是否为空。
查找操作:根据计算到的地址,依次往后比对,直到遇见空位置,说明查找失败。
删除操作:使用这种方法删除元素时,不能简单地将被删除结点的空间置为空,需要做标记,否则会结点它之后填入的散列表的同义词结点的查找路径,可以做一个删除标记,进行逻辑删除。
查找效率分析
线性探测法操作简单,但是很容易造成同义词、非同义词的“聚集现象”,严重影响查找效率。
产生原因:冲突后再探测一定是放在某个连续的位置。
(2)平方探测法
d i = 0 2 , 1 2 , − 1 2 , 2 2 . − 2 2 , . . . , k 2 m − k 2 d_i=0^2,1^2,-1^2,2^2.-2^2,...,k^2m-k^2 di=02,12,−12,22.−22,...,k2m−k2时,称为平方探测法,又称二次探测法,其中 k ≤ m / 2 k≤m/2 k≤m/2
平方探测法相比线性探测法更不容易出现”聚集(堆积)问题“
(3)伪随机序列法
d i d_i di是一个伪随机序列,如 d i = 0 , 5 , 24 , 11 , . . . d_i=0,5,24,11,... di=0,5,24,11,...
【注意】采用开放定址法时,删除结点不能简单地将被删除结点的空间置为空,否则将截断在他之后填入散列表的同义词的查找路径,可以做一个删除标记,进行逻辑删除。
3、再散列法
准备多个散列函数,一旦冲突,就用下一个。
4、查找效率
取决于散列函数、处理冲突的方法、装填因子α。
5、总结
第八章 排序
8.1 排序的基本概念
8.1.1 排序的定义
排序(sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。
排序算法的评价指标、时间复杂度、空间复杂度、算法的稳定性
排序算法的分类:
- 内部排序:数据都在内存中,重点关注如何使算法的时间复杂度和空间复杂度更低。
- 外部排序:数据太多,无法全部放入内存中,所以还要关注如何使磁盘的读/写次数更少。
8.2 插入排序
8.2.1 直接插入排序
1、算法思想
每次将一个待排序的记录按照其关键字大小插入到前面已经排好序的子序列中,直到全部记录插入完成。
演示如下:
2、代码
不带哨兵:
void InsertSort(int A[], int n) {
int i, j, temp;
for (i = 1; i < n; i++) { // 将各个元素插入到已排好序的序列中
if (A[i] < A[i - 1]) { // 若A[i]关键字小于前驱
temp = A[i]; // 用temp暂存A[i]
for (j = i-1; j >= 0 && A[j] > temp; --j) { // 检查所有前面排序好的元素
A[j + 1] = A[j]; // 所有大于temp的元素都向后移动一位
}
A[j + 1] = temp; // 复制到插入的位置
}
}
}
带哨兵:(不用每轮循环都判断j>=0)
void InsertSortWithGuard(int A[], int n) {
int i, j;
for (i = 2; i <= n; i++) {
if (A[i] < A[i - 1]) {
A[0] = A[i]; //复制为哨兵,A[0]不放元素
for (j = i - 1; A[0] < A[j]; --j)
A[j + 1] = A[j];
A[j + 1] = A[0];
}
}
}
3、算法效率分析
- 空间复杂度: O ( 1 ) O(1) O(1)
- 时间复杂度:主要来自对比关键字、移动元素,若有n个元素,则需要n-1趟处理
- 最好情况:原本就有序,共 n − 1 n-1 n−1趟处理,每一趟只需要对比关键字一次,不用移动元素。时间复杂度: O ( n ) O(n) O(n)
- 最坏情况:原本为逆序, n − 1 n-1 n−1 趟处理,第 i i i 趟要移动 i + 1 i+1 i+1 次元素 ( i = 1 , 2 , 3 , . . . , n ) (i=1,2,3,...,n) (i=1,2,3,...,n)。时间复杂度: O ( n 2 ) O(n^2) O(n2)
- 稳定性:移动时不会改变值相等元素的先后顺序,故插入排序是稳定的。
【考点】
1、在元素基本有序的情况下,直接插入排序的效率是最高的
2、直接插入排序可能会出现:在最后一趟排序开始前,所有元素都不在最终位置。
8.2.2 折半插入排序——直接插入排序的优化
1、算法思路
先用折半查找找到应该插入的位置,再移动元素。当low>high时,折半查找停止,将 [ l o w , i − 1 ] [low,i-1] [low,i−1]内的元素全部右移,并将 A [ 0 ] A[0] A[0]复制到low所指位置
当 A [ m i d ] A[mid] A[mid]== A [ 0 ] A[0] A[0]时,为了算法的稳定性,应该继续在mid所指位置右边寻找插入位置。
以下是一趟排序的示例:
2、代码实现
void BinaryInsertSort(int A[], int n) {
int i, j, low, high, mid;
for (i = 2; i <= n; i++) { // 依次将A[2]~A[n]插入到前面的已排序序列
A[0] = A[i]; // 将A[i]暂存到A[0] (哨兵)
low = 1; // 设置折半查找的范围(low为第一个元素)
high = i - 1; // 设置折半查找的范围 (high为待排序元素指针的前一位)
while (low <= high) { // 折半查找(默认递增有序)
mid = (low + high) / 2;
if (A[mid] > A[0]) {
high = mid - 1;
} else {
low = mid + 1;
}
}
for (j = i - 1; j >= high + 1; --j) { // 统一右移元素,空出插入位置
A[j + 1] = A[j];
}
A[high + 1] = A[0]; // 将元素插入,一趟排序完成
}
}
与直接插入排序相比,比较关键字的次数减少了,但是移动元素的次数没有变。时间复杂度仍为 O ( n 2 ) O(n^2) O(n2)
8.2.3 希尔排序
1、概念
算法步骤:现将待排序表分割成若干形如 L [ i , i + d , i + 2 d , . . . , i + k d ] L[i,i+d,i+2d,...,i+kd] L[i,i+d,i+2d,...,i+kd]的特殊子表,对各个子表分别进行直接插入排序。缩小增量 d d d ,重复进行上述过程,直到d=1为止。
【考点】希尔排序的组内排序使用的是直接插入排序
算法思想:先追求表中元素部分有序,在逐渐逼近全局有序
希尔排序的示例如下:
【注意】考试时出现的第一个增量不一定是n/2,可能会出现各种增量
2、代码实现
void ShellSort(int A[], int n) {
int d, i, j;
for (d = n / 2; d >= 1; d = d / 2) { // 步长变化
for (i = d + 1; i <= n; ++i) {
if (A[i] < A[i - d]) { // 将A[i]插入增量有序子表
A[0] = A[i]; // 暂存在A[0]
for (j = i - d; j > 0 && A[0] < A[j]; j -= d) {
A[j + d] = A[j]; // 记录后移,查找插入的位置
}
A[j + d] = A[0]; // 插入
}
}
}
}
3、算法效率分析
- 空间复杂度: O ( 1 ) O(1) O(1)
- 时间复杂度: O ( n 1.3 ) − O ( n 2 ) O(n^{1.3})-O(n^{2}) O(n1.3)−O(n2) (无需记忆,只需了解希尔排序优于直接插入排序即可)
- 稳定性:不稳定
- 适用性:仅适用于顺序表
高频题型:给出增量序列,分析每一趟排序后的状态
8.3 交换排序
基于“交换”的排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置
8.3.1 冒泡排序
1、算法思想
从后往前(或从前往后)两两比较相邻元素的值,若为逆序( A [ i − 1 ] > A [ i ] A[i-1]>A[i] A[i−1]>A[i]),则交换它们,直到序列比较完。称这样的过程为“一趟“冒泡排序。
若某一趟排序过程中未发生“交换”,则算法可提前结束,
以下是冒泡排序的一趟过程:
2、代码实现
void BubbleSort(int A[], int n) {
for (int i = 0; i < n - 1; ++i) {
bool flag = false; // 表示此趟排序是否发生交换
for (int j = n - 1; j > i; --j) { // 一趟冒泡
if (A[j - 1] > A[j]) { // 若为逆序
swap(A[j - 1], A[j]); // 交换元素
flag = true;
}
}
if (flag == false) { // flag为false说明本次遍历后没有发生交换,表已经有序
return;
}
}
}
3、算法效率分析
- 空间复杂度: O ( 1 ) O(1) O(1)
- 时间复杂度
- 最好时间复杂度(有序): O ( n ) O(n) O(n)
- 最坏时间复杂度(逆序): O ( n 2 ) O(n^2) O(n2)
- 平均时间复杂度: O ( n 2 ) O(n^2) O(n2)
- 稳定性:只有
A[j-1]>A[j]
时才交换,因此算法是稳定的 - 适用性:顺序表和链表都适用
8.3.2 快速排序
1、算法思想
在待排序表 L [ 1... n ] L[1...n] L[1...n] 中任取一个元素 p i v o t pivot pivot 作为枢轴(或者基准,通常去首元素),通过一趟排序将待排序表划分为独立的两部分 L [ 1... k − 1 ] L[1...k-1] L[1...k−1] 和 L [ K + 1... n ] L[K+1...n] L[K+1...n] ,使得 L [ 1... k − 1 ] L[1...k-1] L[1...k−1] 中的所有元素小于 p i v o t pivot pivot, L [ k + 1... n ] L[k+1...n] L[k+1...n]中的所有元素大于等于 p i v o t pivot pivot,则 p i v o t pivot pivot放在了其最终位置 L ( k ) L(k) L(k) 上,这个过程称为一个划分。然后分别递归地对两个子表重复上述过程,直到每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。
以下是完整的一趟快排流程:
2、代码实现(递归)
// 用第一个元素将待排序序列划分为左右两个部分
int Partition(int A[], int low, int high) {
int pivot = A[low]; // 第一个元素作为枢轴
while (low < high) { // 用low和high搜索枢轴的最终位置
while (low < high && A[high] >= pivot) {
--high;
}
A[low] = A[high]; // 比枢轴小的元素移动到左端
while (low < high && A[low] <= pivot) {
++low;
}
A[high] = A[low]; // 比枢轴大的元素移动到右端
}
A[low] = pivot; // 枢轴元素存放的最终位置
return low; // 返回枢轴元素的最终位置
}
// 快速排序
void QuickSort(int A[], int low, int high) {
if (low < high) { // 递归跳出的条件
int pivotpos = Partition(A, low, high); // 划分
QuickSort(A, low, pivotpos - 1); // 划分左子表
QuickSort(A, pivotpos + 1, high); // 划分右子表
}
}
3、算法效率分析
- 空间复杂度:
O
(
递归层数
)
O(递归层数)
O(递归层数)
- 最好空间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 最坏空间复杂度: O ( n ) O(n) O(n)
- 时间复杂度:
O
(
n
∗
递归层数
)
O(n*递归层数)
O(n∗递归层数)
- 最好时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 最坏时间复杂度: O ( n 2 ) O(n^2) O(n2)
- 稳定性:不稳定
- 快速排序是所有内部排序算法中,平均性能最优的排序算法。
【考点】
1、就平均性能而言,目前最好的排序算法是快速排序
2、常考题:下列哪个序列使用快速排序时,速度最快,哪个最慢?
最快的情形:每次的基准元素都能将待排序元素分成个数完全相等的两部分。
最慢的情形:待排序元素有序,因为有序的元素进行划分时,有一侧不含元素,需要递归n层。
3、对n个元素进行快速排序,最大递归深度是( n n n),最小递归深度是( l o g 2 n log_2n log2n)
最大递归深度就是待排序元素有序的情况,最小递归深度是最快的情形所描述的情况,即每次的基准元素都能将待排序元素分成个数完全相等的两部分。
4、常考题:下列哪个序列不可能是快速排序第*趟的结果(*表示趟数)
这种情况下,如果划分元素是边界元素,则其所划分的部分的下一趟划分只会产生一个处于最终位置的元素,若划分元素不是边界元素,则其所划分的部分的下一趟划分会产生两个处于最终位置的元素。
如:
【2019年 408 第10题】排序过程中,对尚未确定最终位置的所有元素进行一遍处理称为一“趟”。下列排序中,不可能是快速排序第二趟结果的是()
A. 5, 2, 16, 12, 28, 60, 32, 72
B. 2, 16, 5, 28, 12, 60, 32, 72
C. 2, 12, 16, 5, 28, 32, 72, 60
D. 5, 2, 12, 28, 16, 32, 72, 60答案:D (D项应该有三个处于最终位置的元素。)
最终排序位置是:
2, 5, 12, 16, 28, 32, 60, 72
A. 5, 2, 16, 12, 28, 60, 32, 72
B. 2, 16, 5, 28, 12, 60, 32, 72
C. 2, 12, 16, 5, 28, 32, 72, 60
D. 5, 2, 12, 28, 16, 32, 72, 60
把n个元素组织成二叉树,二叉树的层数就是递归调用的层数。
8.4 选择排序
8.4.1 简单选择排序
1、算法思想
每一趟从待排序序列中选取最大(或最小)的元素加入有序子序列。必须进行n-1趟处理。
【考点】选择排序的比较次数与原序列的状态无关,始终为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)
其示例如下:
2、代码实现
void SelectSort(int A[], int n) {
for (int i = 0; i < n - 1; ++i) { // 一共进行n-1趟
int min = i; // 记录最小元素位置
for (int j = i + 1; j < n; ++j) { // 在A[i...n-1]中选择最小的元素
if (A[j] < A[min]) {
min = j; // 更新最小元素位置
}
}
if (min != i) {
swap(A[i], A[min]); // 移动元素位置
}
}
}
3、算法效率分析
- 空间复杂度: O ( 1 ) O(1) O(1)
- 时间复杂度: O ( n 2 ) O(n^2) O(n2)
-
考点:简单选择排序的移动次数是 O ( n ) O(n) O(n)次,比较次数是 O ( n 2 ) O(n^2) O(n2)次,不要把移动次数也记成 O ( n 2 ) O(n^2) O(n2)
- 稳定性:不稳定
- 适用性:即可用于顺序表,也可用于链表
8.4.2 堆排序
1、什么是堆?
若n个关键字序列 L [ 1... n ] L[1...n] L[1...n] 满足下面某一条性质,则称为堆:
- 若满足: L ( i ) ≥ L ( 2 i ) L(i)≥L(2i) L(i)≥L(2i)且 L ( i ) ≥ L ( 2 i + 1 ) , ( 1 ≤ i ≤ n / 2 ) L(i)≥L(2i+1),(1≤i≤n/2) L(i)≥L(2i+1),(1≤i≤n/2),则称为大根堆(大顶堆)
- 若满足: L ( i ) ≤ L ( 2 i ) L(i)≤L(2i) L(i)≤L(2i)且 L ( i ) ≤ L ( 2 i + 1 ) , ( 1 ≤ i ≤ n / 2 ) L(i)≤L(2i+1),(1≤i≤n/2) L(i)≤L(2i+1),(1≤i≤n/2),则称为小根堆(小顶堆)
大根堆:
逻辑视角:大根堆中,根 ≥ ≥ ≥左、右
小根堆:
逻辑视角:根 ≤ ≤ ≤左、右
2、基于“堆”来进行排序
(1)建立大根堆
思路:把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整。(不满足,将当前结点与更大的一个孩子互换)
建立大根堆的一趟流程如下:
代码实现:
// 将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len) {
A[0] = A[k]; // A[0]暂存子树的根节点
for (int i = 2 * k; i <= len; i *= 2) { // 沿着key较大的子节点向下筛选
if (i < len && A[i] < A[i + 1]) {
i++; // 取key较大的子节点的下标
}
if (A[0] >= A[i]) { // 筛选结束
break;
} else {
A[k] = A[i]; // 将A[i]调整到双亲结点上
k = i; // 修改k值,以便继续向下筛选
}
}
A[k] = A[0]; // 将被筛选结点的值放入最终位置
}
// 建立大根堆
void BuildMaxHeap(int A[], int len) {
for (int i = len / 2; i > 0; --i) { // 从后往前调整所有非终端节点
HeadAdjust(A,i,len);
}
}
(2)堆排序
每一趟将堆顶元素加入有序子序列(与待排序序列的最后一个元素互换),并将待排序元素序列再次调整成为大根堆。
其示例如下:
【注意】基于大根堆的堆排序得到的是递增序列
void HeapSort(int A[], int len) {
BuildMaxHeap(A, len);
for (int i = len; i > 1; --i) {
swap(A[i], A[1]);
HeadAdjust(A, 1, i - 1);
}
}
(3)算法效率分析
-
时间复杂度: O ( n ) [ 建堆时间 ] + O ( n l o g 2 n ) [ 调整堆时间 ] = O ( n l o g 2 n ) [ 堆排序时间 ] O(n)[建堆时间]+O(nlog_2n)[调整堆时间]=O(nlog_2n)[堆排序时间] O(n)[建堆时间]+O(nlog2n)[调整堆时间]=O(nlog2n)[堆排序时间]
-
【考点】建堆时间复杂度和调整堆的时间复杂度是不同的,注意区分。
-
空间复杂度: O ( 1 ) O(1) O(1)
-
稳定性:不稳定
-
基于大根堆的堆排序得到递增序列,基于小根堆的堆排序得到递减序列。
(4)堆的插入删除
-
堆的插入:对于大(或小)根堆,要插入的元素放到表尾,然后与父节点对比,若新元素比父节点更大(或小),则将二者互换。新元素就这样一路==“上升”==,直到无法继续上升为止。
-
堆的删除:被删除的元素用堆底元素替换,然后让该元素不断==“下坠”==,直到无法下坠为止。
-
【考点】给出大根堆或小根堆,选出插入或删除某元素后堆的状态或元素的比较次数。
以下是计算比较次数的一个需要注意的点:
8.5 归并排序和基数排序
8.5.1 归并排序
1、什么是归并?
归并:将两个或多个已经有序的序列合并成一个。
二路归并:把两个已经有序的序列合并成一个。
多路归并:把多个已经有序的序列合并成一个。
四路归并:对比p1、p2、p3、p4所指元素,选择更小的一个放入k所指位置。
【结论】m路归并,每选出一个元素需要对比关键字 m − 1 m-1 m−1次
2、归并排序
核心操作:将数组内的两个有序序列归并成一个。
归并排序一般会考察某一趟排序后得到的序列,基本上不可能考察代码。
3、代码实现
王道书中代码如下:
// 辅助数组B
int *B=(int *)malloc(n*sizeof(int));
// A[low,...,mid],A[mid+1,...,high]各自有序,将这两个部分归并
void Merge(int A[], int low, int mid, int high){
int i,j,k;
for(k=low; k<=high; k++)
B[k]=A[k];
for(i=low, j=mid+1, k=i; i<=mid && j<= high; k++){
if(B[i]<=B[j])
A[k]=B[i++];
else
A[k]=B[j++];
}
while(i<=mid)
A[k++]=B[i++];
while(j<=high)
A[k++]=B[j++];
}
// 递归操作
void MergeSort(int A[], int low, int high){
if(low<high){
int mid = (low+high)/2;
MergeSort(A, low, mid);
MergeSort(A, mid+1, high);
Merge(A,low,mid,high); //归并
}
}
4、算法效率分析
- 空间复杂度: O ( n ) O(n) O(n)
- 时间复杂度: O ( n l o g n ) O(nlogn) O(nlogn)
-
【考点】归并排序的比较次数与初始序列无关。
- 稳定性:稳定
8.5.2 基数排序
1、概念
基数排序是一种很特别的排序算法,它不急于比较和移动进行排序,而是基于关键字各位的大小进行排序。
基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的算法。
为实现多关键字排序,通常有两种方法:
- 最高位优先:按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序蓄力
- 最低位优先:按关键字权重递增依次进行排序,最终形成一个有序序列。
以下是堆排序的一个示例:
最终得到了一个递减序列。
2、算法过程
基数排序得到递减序列的过程如下:
初始化:设置r个空队列, Q r − 1 , Q r − 2 , . . . , Q 0 Q_{r-1},Q_{r-2},...,Q_0 Qr−1,Qr−2,...,Q0
按照各个关键字位权重递增的次序(个、十、百、…)的次序,对d个关键字位分别做“分配”和“收集”
分配:顺序扫描各个元素,根据当前处理的关键字位,将元素插入相应的队列。
收集:把各个队列中的结点依次出队并链接。
基数排序不考察代码。
3、算法效率分析
整个关键字拆分为 d d d位(或“组”),按照各个关键字位权重递增的次序,需要做 d d d趟分配和收集。
当前处理的关键字位可能取到 r r r个值,则需要建立 r r r个队列。
如上述例子: d = 3 d=3 d=3, r = 10 r = 10 r=10
- 空间复杂度: O ( r ) O(r) O(r)
- 时间复杂度: O ( d ( n + r ) ) O(d(n+r)) O(d(n+r))
- 稳定性:稳定
4、算法应用
基数排序适合处理:
- 数据元素的关键字可以方便地拆分成 d d d组,且 d d d 较小
- 每组关键字的取值范围不大,即 r r r 较小
- 数据元素个数 n n n 较大
8.6 内部排序算法的比较
算法类型 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
冒泡排序 | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
简单选择排序 | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 不稳定 |
希尔排序 | / | 查阅资料, O ( n 1.3 ) − O ( n 2 ) O(n^{1.3})-O(n^{2}) O(n1.3)−O(n2) 无需记忆,只需了解优于直接插入排序即可 | / | O ( 1 ) O(1) O(1) | 不稳定 |
快速排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n 2 ) O(n^2) O(n2) | O ( l o g 2 n ) O(log_2n) O(log2n) | 不稳定 |
堆排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( 1 ) O(1) O(1) | 不稳定 |
2路归并排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n ) O(n) O(n) | 稳定 |
基数排序 | O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)) | O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)) | O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)) | O ( r ) O(r) O(r) | 稳定 |