一、绪论
1.1数据结构的基本概念
数据:用来描述客观事物的数、计算机中是字符及所有能输入并被程序识别和处理的符号的集合。
数据元素:数据的基本单位,一个数据元素可由若干数据项组成。
数据结构:指相互之间存在一种或多种特定关系的数据元素的集合。
数据结构的三要素:逻辑结构、存储结构、数据的运算。
数据对象:具有相同性质的数据元素的集合,是数据的一个子集。
逻辑结构:指数据元素之间的逻辑关系
- 集合:各个元素同属一个集合,别无其它关系。
- 线性结构:数据元素之间是一对一的关系。
- 树型结构:数据元素之间是一对多的关系。
- 图状结构(网状结构):数据元素之间存在多对多的关系。
存储结构(物理结构):用计算机表示数据元素的逻辑关系(后三种统称为非顺序存储)
- 顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
- 链式存储:逻辑上相邻的元素在物理位置上可以不相邻,可借助指示元素存储地址的指针来表示元素之间的逻辑关系。
- 索引存储:在存储元素信息的同时,建立附加的索引表。索引表中的每项称为索引项,索引项 的一般形式是(关键字,地址)。
- 散列存储:根据元素的关键字直接计算出该元素的存储地址,又称哈希(Hash)存储。
- 若采用顺序存储,则各个数据元素在物理上必须是连续的;
若采用非顺序存储,则各个数据元素在物理上可以是离散的。 - 数据的存储结构会影响存储空间分配的方便程度
- 数据的存储结构会影响对数据运算的速度
数据的运算:运算的定义是针对逻辑结构的, 指出运算的功能;运算的实现是针对存储结构的,指出运算的具体操作步骤。
例:结合现实需求定义队列这种逻辑结构的运算:
①队头元素出队; ②新元素入队; ③输出队列长度;
数据类型:数据类型是一个值的集合和定义在此集合上的一组操作的总称。
1)原子类型。其值不可再分的数据类型
2)结构类型。其值可以再分解为若干成分(分量)的数据类型
抽象数据类型(ADT):是抽象数据组织及与之相关的操作。
1.2算法和算法评价
1.2.1 算法的基本概念
算法:对特定问题求解步骤的一种描述,是指令的有限序列。其中的每条指令表示一个或多个操作
算法的特性:有穷性、确定性、可行性、输入、输出。
- 有穷性:一个算法必须总在执行有穷步之后结束,且每一步都可在有穷时间内完成。
用有限步骤解决某个特定的问题 - 确定性:算法中每条指令必须有确切的含义,对于相同的输入只能得出相同的输出。
- 可行性:算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现。
- 输入:一个算法有零个或多个输入,这些输入取自于某个特定的对象的集合。
- 输出:一个算法有一个或多个输出,这些输出是与输入有着某种特定关系的量。
算法的设计目标:正确性,可读性,健壮性,高效率与低存储量需求。
- 正确性。算法应能够正确地解决求解问题。
- 可读性。算法应具有良好的可读性,以帮助人们理解
- 健壮性。输入非法数据时算法能适当地做出反应或进行处理,而不会产生莫名其妙的输出结果
- 高效率与低存储量需求。花费时间少时间复杂度低;不费内存,空间复杂度低。
1.2.2 算法的时间复杂度
算法时间复杂度:事前预估算法时间开销T(n)与问题规模 n 的关系(T 表示 “time).
如何计算:
- 找到一个基本操作(最深层循环)
- 分析该基本操作的执行次数x与问题规模n的关系x=f(n)
- x的数量级O(x)就是算法时间复杂度T (n)
大O表示“同阶”,同等数量级,即当n→∞时,二者之比为常数。
结论:可以只考虑阶数高的部分,问题规模足够大时,常数项系数也可以忽略。
常用技巧:
a)加法规则
多项相加,只保留最高阶的项,且系数变为1
b)乘法规则
多项相乘,都保留
c)“常对幂指阶” 常数级<对数级<幂函数级<指数级<阶层级
三种时间复杂度:
- 最坏时间复杂度:最坏情况下算法的时间复杂度
- 平均时间复杂度:所有输入示例等概率出现的情况下,算法的期望运行时间
- 最好时间复杂度:最好情况下算法的时间复杂度 (一般不考虑)
1.2.3 算法的空间复杂度
二、线性表
2.1线性表的定义和基本操作
2.1.1 线性表的基本概念
线性表:是具有相同数据类型的 n 个数据元素的有限序列。
(Eg:所有的整数按递增次序排列,不是顺序表,因为所有的整数是无限的)
其中n为表长,当n=0时线性表是一个空表。若用L表示一个线性表,则
是线性表中的第i个元素,称为线性表中的位序
是表头元素;是表尾元素。
除第一个元素外,每个元素有且仅有一个直接前驱;
除最后一个元素外,每个元素有且仅有一个直接后继
2.1.2 线性表的基本操作
- InitList(&L):初始化表。构造一个空的线性表 L,分配内存空间。
- DestroyList(&L):销毁操作。销毁线性表,并释放线性表 L 所占用的内存空间。
- ListInsert(&L, i, &e):插入操作。在表 L 的第 i 个位置插入指定元素 e 。
- ListDelete(&L, i, &e):删除操作。删除表 L 中第 i 个位置的元素,并用 e 返回删除元素的值。
- LocateElem(L, e):按值查找操作。在表 L 中查找具有给定关键字值的元素。
- GetElem(L, i):按位查找操作。获取表 L 中第 i 个位置的元素的值。
其他常用操作
- Length(L):求表长。返回线性表 L 的长度,即表中元素的个数。
- PrintList(L):输出操作。按前后顺序输出线性表 L 的所有元素值。
- Empty(L):判空操作。若表L 为空表,则返回 true,否则返回 false。
对数据操作的思路:创销、增删改查
什么时候要传入引用“&”—―对参数的修改结果需要“带回来”时
#include<stdio.h>
void test ( int &x) {
x=1024;
printf ( "test函数内部x=%d\n",x) ;
}
int main() {
int x =1;
printf( "调用test前x=d\n",x) ;
test (x);
printf ( "调用test后x=%din",x);
}
2.2线性表的顺序表示
2.2.1 顺序表的定义
顺序表:用顺序存储的方式实现线性表
顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中
C语言中通过sizeof(ElementType)可以知道一个数据元素的大小
2.2.2 顺序表的实现
静态分配
#define MaxSize 10 // 定义最大长度
typedef struct {
int data[MaxSize]; // 使用静态的数组存放数据元素
int length; // 顺序表的当前长度
}SqList; //顺序表的类型定义
//基本操作 —— 初始化一个顺序表
void InitList(SqList &L) {
for(int i=0;i<MaxSize;i++)
L.data[i]=0; //将所有数据元素设置为默认初始值
L.length = 0; // 顺序表初始长度为0
}
int main() {
SqList L; // 声明一个顺序表
InitList(L); // 初始化顺序表
return 0;
}
如果不设置数据元素的默认值
静态数组的表长确定后就无法更改(存储空间是静态的),最好使用动态分配
动态分配
#include <stdlib.h> //malloc函数要使用的头文件
#define InitSize 10 // 顺序表的初始长度
typedef struct {
int *data; // 声明动态分配数组的指针
int MaxSize; // 顺序表的最大容量
int length; // 顺序表的当前长度
}SeqList;
// 初始化顺序表
void InitList(SeqList &L) {
// 用malloc函数申请一片连续的存储空间
L.data = (int *)malloc(InitSize * sizeof(int));
//(int*)把malloc返回的指针转换成定义的同类型的指针
L.length = 0; //把数据表的长度设为0
L.MaxSize = InitSize; //把数据表的最大长度设为初始值
}
// 增加动态数组的长度
void IncreaseSize(SeqList &L, int len) {
int *p = L.data; //把顺序表的data指针的值赋给指针p
L.data = (int *)malloc((L.MaxSize+len) * sizeof(int));
for (int i = 0; i < L.length; i++)
L.data[i] = p[i]; // 将数据复制到新区域
L.MaxSize = L.MaxSize + len; // 顺序表最大长度增加len
free(p); // 释放原来的内存空间
}
int main() {
SeqList L; // 声明一个顺序表
InitList(L); // 初始化顺序表
...//往数据表中随便插入几个元素
IncreaseSize(L, 5); //再多申请5个空间
return 0;
}
顺序表的特点:
- 随机访问,即可以在 O(1) 时间内找到第 i 个元素
- 存储密度高,每个节点只存储数据元素
- 拓展容量不方便(即使使用动态分配的方式实现,拓展长度的时间复杂度也比较高,要把数据复制到新的区域)
- 插入删除操作不方便,需移动大量元素
2.2.3 顺序表的插入删除
Listlnsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e
#define MaxSize 10 // 定义最大长度 10个元素
typedef struct {
int data[MaxSize]; // 用静态的数组存放数据元素
int length; // 顺序表的当前长度
}SqList; //定义数据表SqlList
// 在顺序表i位置插入e
bool ListInsert(SqList &L, int i, int e) { //注意位序、数组下标的关系
if (i < 1 || i > L.length+1) // 判断i的范围是否有效
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 i-1 下标
L.length++; // 长度+1
return true;
}
int main() {
SqList L; //声明一个顺序表
InitList(L); //初始化顺序表
...//此次省略一些代码,插入几个元素
ListInsert(L, 3, 3); //调用函数 在顺序表L的第三个位置插入数据3
return 0;
}
插入操作的时间复杂度 问题规模n=L.length(表长)
最好情况:新元素插入到表尾,不需要移动元素 i = n+1,循环0次;
最好时间复杂度 = O(1)
最坏情况:新元素插入到表头,需要将原有的 n 个元素全都向后移动 i = 1,循环 n 次;
最坏时间复杂度 = O(n)
平均情况:假设新元素插入到任何一个位置的概率相同,即 i = 1,2,3, … , length+1 的概率都是 p = ,循环 n 次;i=2 时,循环 n-1 次;i=3,循环 n-2 次 …… i =n+1时,循环0次 ,平均循环次数 np + (n-1)p + (n-2)p +... + 1⋅p ==
平均时间复杂度 = O(n)
ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
#define MaxSize 10
typedef struct {
int data[MaxSize];
int length;
} SqList;
// 删除顺序表i位置的数据并存入e
bool ListDelete(SqList &L, int i, int &e) { //注意e加了&引用,这里处理的e跟main函数中的e在内存中对应的是同一份数据
if (i < 1 || i > L.length) // 判断i的范围是否有效
return false;
e = L.data[i-1]; // 将被删除的元素赋值给e
for (int j = i; j < L.length; j++) //将第i个位置后的元素前移
L.data[j-1] = L.data[j];
L.length--; //线性表长度-1
return true;
}
int main() {
SqList L; //声明一个顺序表
InitList(L); //初始后顺序表
...//此次省略一些代码,插入几个元素
int e = -1; //用变量e把删除的元素“带回来”
if (ListDelete(L, 3, e)) //调用删除操作,删除第三个位置的元素用e返回
printf("已删除第3个元素,删除元素值为%d\n", e);
else
printf("位序i不合法,删除失败\n");
return 0;
}
插入操作的时间复杂度 问题规模n=L.length(表长)
最好情况:删除表尾元素,不需要移动其他元素 i = n,循环 0 次;
最好时间复杂度 = O(1)
最坏情况:删除表头元素,需要将后续的 n-1 个元素全都向前移动 i = 1,循环 n-1 次;
最坏时间复杂度 = O(n)
平均情况:假设删除任何一个元素的概率相同,即 i = 1,2,3, … , length 的概率都是 p = ,i=1时,循环 n-1 次;i=2 时,循环 n-2 次;i=3,循环 n-3 次 …… i =n时,循环0次 ,平均循环次数 (n-1)p + (n-2)p +... + 1⋅p ==
平均时间复杂度 = O(n)
2.2.4 顺序表的查找
GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。
// 静态分配的按位查找
#define MaxSize 10 //定义最大长度
typedef struct {
ElemType data[MaxSize]; //用静态的数组存放元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义
ElemType GetElem(SqList L, int i) { //位序从1开始
return L.data[i-1]; //数组下标从0开始,所以要-1
}
// 动态分配的按位查找
#define InitSize 10 //顺序表的初始长度
typedef struct {
ElemType *data; //指示动态分配数组的指针 *data变量是一个指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SeqList; //顺序表的类型定义
ElemType GetElem(SeqList L, int i) {
return L.data[i-1];
}
//*data指向了malloc分配的一整片连续空间的起始地址 即data[i-1]
按位查找的时间复杂度 = O(1)
由于顺序表的各个数据元素在内存中连续存放, 因此可以根据起始地址和数据元素大小立即找到 第 i 个元素——“随机存取”特性
LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。
#define InitSize 10
typedef struct {
ElemType *data; //指示动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SqList;
// 查找第一个元素值为e的元素,并返回其位序
int LocateElem(SqList L, int e) {
for (int i = 0; i < L.length; i++)
if (L.data[i] == e) //依次判断数据表中的数据元素跟传入的数据e是否相等
return i+1; // 数组下标为i的元素值等于e,返回其位序i+1
return 0; // 没有查找到
}
基本数据类型:int、char、double、 float 等可以直接用运算符“==”比较,结构类型的数据元素不能
按值查找的时间复杂度
2.3线性表的链式表示
顺序表的优缺点:
优点:可随机存储,存储密度高
缺点:要求大片连续空间,且改变容量不方便
2.3.1 单链表的基本概念
单链表:用链式存储实现了线性结构。一个结点存储一个数据元素,各结点间的前后关系用一个指针表示。
优点:不要求大片连续空间,改变容量方便。
缺点:不可随机存取,要耗费一定空间存放指针。
查找指定结点时需要从表头开始遍历,依次查找
//用代码定义一个单链表
struct LNode{ //LNode是结点
ElemType data; //data为数据域 定义单链表结点类型
struct LNode *next; //*next是指针域 指针指向下一个结点
}
struct LNode *p = (struct LNode *)malloc(sizeof(struct LNode));
//增加一个新的结点:在内存中申请一个结点所需空间,并用指针n指向这个结点
typedef关键字——数据类型重命名
typedef <数据类型> <别名>
typedef struct LNode LNode; //这样使用就可以直接哟弄个LNode 不需要前面加一长串
typedef struct LNode *LinkList;
要表示一个单链表时,只需要表明一个头指针L,指向单链表的第一个结点
LNode *L; //声明一个指向单链表第一个结点的指针
或
LinkList L; //声明一个指向单链表第一个结点的指针 代码可读性更强
头结点和头指针的关系:不管带不带头结点,头指针始终指向链表的第一个结点,而头结点则是带头结点的链表中的第一个结点,结点内通常不存储信息。
不带头结点的单链表
typedef struct LNode{ //定义单链表结点类型
ElemType data; //每个节点存放一个数据元素
struct LNode *next; //指针指向下一个节点
}LNode, *LinkList;
//初始化一个空的单链表
bool InitList(LinkList &L){
L = NULL; //空表,暂时还没有任何结点 防止脏数据
return true;
}
void test(){
LinkList L; //声明一个指向单链表的头指针
//初始化一个空表
InitList(L);
...
}
//判断单链表是否为空
bool Empty(LinkList L){
return (L==NULL)
}
带头结点的单链表
typedef struct LNode{
ElemType data;
struct LNode *next; //指针指向下一结点
}LNode, *LinkList;
// 初始化一个单链表(带头结点)
bool InitList(LinkList &L){
L = (LNode *)malloc(sizeof(LNode)); //分配一个头结点
if (L == NULL) //内存不足,分配失败
return false;
L->next = NULL; //头结点之后暂时还没有结点
return true;
}
void test(){
LinkList L; //声明一个指向单链表的头指针
//初始化一个空表
InitList(L);
...
}
//判断单链表是否为空
bool Empty(LinkList L){
if (L->next == NULL)
return true;
else
return false;
}
不带头结点,写代码更麻烦 对第一个数据结点和后续数据结点的 处理需要用不同的代码逻辑 对空表和非空表的处理需要用不同的 代码逻辑
带头结点,写代码更方便
2.3.2_1单链表的插入删除
ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e
按位序插入(带头结点):
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode,*LinkList;
//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L,int i ElemType e){ //i是要插入的位序
if(i<1) //i表示的是位序 位序最小是1
return false;
LNode *p; //指针p指向当前扫描到的结点
int j=0; //当前p指向的是第几个结点
p=L; //L指向头结点,头结点是第0个结点,不存数据
while(p!=NULL && j<i-1){ //循环找到第i-1个结点 如果i>length p最后会等于NULL
p=p->next;
j++;
}
if(p==NULL) //i值不合法
return false;
LNode *s=(LNode*)malloc(sizeof(LNode)); //malloc开辟一块空间s
s->data=e; //把要插入的元素存到新结点中
s->next=p->next; //让s指向的next的指针等于p结点的next指针指向的位置
p->next=s; //让p的结点的next指针指向新的结点s 即将结点s连到p之后
return true; //插入成功
}
时间复杂度:这里问题规模n指的是表的长度
最好时间复杂度:O(1)
最坏时间复杂度:O(n)
平均时间复杂度:O(n)
按位序插入(不带头结点):
由于不存在“第0个”结点,因此i=1时需要特殊处理
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
//在第i个位置插入元素e
bool ListInsert(LinkList &L, int i, ElemType e){
//判断i的合法性
if(i<1)
return false;
//需要判断插入的位置是否是第1个 对第一个结点单独写操作进行处理
if(i==1){
LNode *s = (LNode *)malloc(size of(LNode)); //malloc申请一个结点
s->data =e; //把要插入的e放到新申请的结点中
s->next =L; //让新结点的指针指向L
L=s; //头指针指向新结点
return true;
}
//i>1的情况与带头结点一样,唯一区别是j的初始值为1
LNode *p; //指针p指向当前扫描到的结点
int j=1; //当前p指向的是第几个结点
p = L; //p指向的是第1个结点,注意不是头结点
//循环找到第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;
return true;
}
结论:不带头结点写代码更不方便,推荐用带头结点注意
指定结点的后插操作:
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
// 在结点p后插入元素e
bool InsertNextNode(LNode *p, ElemType e){
if(p==NULL){
return false;
}
LNode *s = (LNode *)malloc(sizeof(LNode));
if(s==NULL) //内存分配失败
return false;
s->data = e; //用结点s保存数据元素e
s->next = p->next;
p->next = s; //将结点s连接到p后
return true;
}
// 按位序插入的函数中可以直接调用后插操作
bool ListInsert(LinkList &L, int i, ElemType 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++;
}
return InsertNextNode(p, e)
}
时间复杂度:O(1)
指定结点的前插操作:
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
//前插操作: 在结点p前插入元素e
bool InsertPriorNode(LNode *p, ElemType e){
if(p==NULL)
return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
if(s==NULL) // 内存分配失败
return false;
s->next = p->next;
p->next = s; // 将新结点s连到结点p之后
s->data = p->data; //将p中元素复制到s中
p->data = e; //p中元素覆盖为e
return true;
}
按位序删除(带头结点)
ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值
找到第i-1个结点,将其指针指向第i+1个结点,并释放第i个结点,头结点可看做“第0个”结点
typedef struct LNode{
ElemType data;
struct LNode *next;}LNode, *LinkList;
// 删除第i个结点并将其所保存的数据存入e
bool ListDelete(LinkList &L, int i, ElemType &e){
if(i<1)
return false;
LNode *p; //指针p指向当前扫描到的结点
int j=0; //当前p指向的是第几个结点
p = L; //L指向头结点,头结点是第0个结点,不存数据
while(p!=NULL && j<i-1){ //循环找到第i-1个结点
p = p->next; //如果i>lengh,p和p的后继结点会等于NULL
j++;
}
if(p==NULL) //i值不合法
return false;
if(p->next == NULL) //第i-1个结点之后已无其他结点
return false;
LNode *q = p->next; //令q指向被删除的结点
e = q->data; //把删除的结点的值存到变量e
p->next = q->next; //p的next指向q的next 就是null 相当于将*q结点从链中“断开”
free(q); //释放结点的存储空间
return true; //删除成功
}
指定结点的删除
删除结点p,需要修改其前驱 结点的 next 指针
方法1:传入头指针,循环寻找 p 的前驱结点
方法2:类似于结点前插的实现
// 删除指定结点p
bool DeleteNode(LNode *p){
if(p==NULL)
return false;
LNode *q = p->next; // 令q指向p的后继结点
// 如果p是最后一个结点,则q指向NULL,继续执行就会报错
p->data = q->data; //把后继结点的数据复制到p结点的数据域中
p->next = q->next; //让p结点的next指针指向q结点后的位置 相当于将*q结点从链中“断开”
free(q); //释放q结点的空间
return true;
}
2.3.2_2单链表的插入查找
GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
// 查找指定位序i的结点并返回
LNode * GetElem(LinkList L, int i){
if(i<0)
return NULL;
LNode *p; //指针p指向当前扫描到的结点
int j=0; //当前p指向的是第几个结点
p = L; //L指向头结点,头结点是第0个结点(不存数据)
while(p!=NULL && j<i){
p = p->next;
j++;
}
return p;
}
// 在结点p后插入元素e
bool InsertNextNode(LNode *p, ElemType e){
if(p==NULL){
return false;
}
LNode *s = (LNode *)malloc(sizeof(LNode));
if(s==NULL) //内存分配失败
return false;
s->data = e; //用结点s保存数据元素e
s->next = p->next;
p->next = s; //将结点s连接到p后
return true;
}
// 封装后的插入操作,在第i个位置插入元素e
bool ListInsert(LinkList &L, int i, ElemType e){
if(i<1)
return False;
// 找到第i-1个元素
LNode *p = GetElem(L, i-1);
// 在p结点后插入元素e
return InsertNextNode(p, e);//调用查询操作和后插操作
}
平均时间复杂度:O(n)
LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。
// 查找数据域为e的结点指针,否则返回NULL
LNode * LocateElem(LinkList L, ElemType e){
LNode *p = L->next; //定义一个指针p,让他指向头结点的下一个结点
// 从第一个结点开始查找数据域为e的结点
while(p!=NULL && p->data != e){ //当p不为NULL且数据跟传入的e不相等时循环
p = p->next; //让p指向下一个结点
}
return p; //跳出循环后返回p
}
平均时间复杂度:O(n) 只能依次往后查找
求表的长度
// 计算单链表的长度
int Length(LinkList L){
int len=0; //统计表长
LNode *p = L;
while(p->next != NULL){
p = p->next;
len++;
}
return len;
}
2.3.2_3单链表的插入建立
尾插法
// 使用尾插法建立单链表L
LinkList List_TailInsert(LinkList &L){
int x; //设ElemType为整型int
L = (LinkList)malloc(sizeof(LNode)); //建立头结点(初始化空表)
LNode *s, *r = L; //r为表尾指针
scanf("%d", &x); //输入要插入的结点的值
while(x!=9999){ //输入9999表示结束
s = (LNode *)malloc(sizeof(LNode));
s->data = x;
r->next = s; //在r结点之后插入元素x
r = s; //r指针指向新的表尾结点
scanf("%d", &x);
}
r->next = NULL; //尾结点指针置空
return L;
}
头插法
LinkList List_HeadInsert(LinkList &L){ //逆向建立单链表
LNode *s;
int x;
L = (LinkList)malloc(sizeof(LNode)); //建立头结点
L->next = NULL; //初始为空链表,先把头指针指向NULL,这步很关键
scanf("%d", &x); //输入要插入的结点的值
while(x!=9999){ //输入9999表结束
s = (LNode *)malloc(sizeof(LNode)); //创建新结点
s->data = x;
s->next = L->next;
L->next = s; //将新结点插入表中,L为头指针
scanf("%d", &x);
}
return L;
}
头插法实现链表的逆置:
// 将链表L中的数据逆置并返回
LNode *Inverse(LNode *L){
LNode *p, *q;
p = L->next; //p指针指向第一个结点
L->next = NULL; //头结点置空
// 依次判断p结点中的数据并采用头插法插到L链表中
while (p != NULL){
q = p;
p = p->next;
q->next = L->next;
L->next = q;
}
return L;
}
头插法逆置详见【数据结构】单链表逆置:头插法图解
2.3.3 双链表
单链表:单链表结点中只有一个指向其后继的指针,使得单链表只能从前往后依次遍历,无法逆向检索,有时候不太方便
双链表的定义:双链表结点中有两个指针prior和next,分别指向其直接前驱和直接后继
表头结点的prior域和尾结点的next域都是NULL。
双链表结点类型描述如下:
typedef struct DNode{ //定义双链表结点类型
ElemType data; //数据域
struct DNode *prior, *next; //前驱和后继指针
} DNode, *DLinklist;
双链表在单链表结点中增加了一个指向其前驱的指针 prior
双链表的初始化 (带头结点):
typedef struct DNode{ //D表示double
ElemType data;
struct DNode *prior, *next;
}DNode, *DLinklist;
// 初始化双链表
bool InitDLinkList(Dlinklist &L){
L = (DNode *)malloc(sizeof(DNode)); //分配一个头结点
if(L==NULL) //内存不足,分配失败
return false;
L->prior = NULL; //头结点的prior指针永远指向NULL
L->next = NULL; //头结点之后暂时还没有结点,置空
return true;
}
void testDLinkList(){
DLinklist L; //L是一个链表 初始化双链表
InitDLinkList(L);
...
}
// 判断双链表是否为空
bool Empty(DLinklist L){
if(L->next == NULL)
return true;
else
return false;
}
双链表的插入操作:
typedef struct DNode{
ElemType data;
struct DNode *prior, *next;
}DNode, *DLinklist;
bool InsertNextDNode(DNode *p, DNode *s){
if(p==NULL || s==NULL)
return false;
s->next = p->next; // 将结点s插入到结点p之后
if (p->next != NULL) // 判断结点p之后是否有后继结点
p->next->prior = s; //p结点的后置结点的前向指针指向新插入的s
s->prior = p; //把s的前向指针指向p结点
p->next = s; //把p结点的后向指针指向s结点
return true;
}
双链表的删除操作:
// 删除p结点的后继结点
bool DeletNextDNode(DNode *p){
if(p==NULL)
return false;
DNode *q =p->next; // 找到p的后继结点q
if(q==NULL) //p没有后继就返回false
return false;
p->next = q->next; //q结点不是最后一个结点
if(q->next != NULL)
q->next->prior=p;
free(q); //释放结点空间
return true;
}
// 销毁一个双链表
bool DestoryList(DLinklist &L){
// 循环释放各个数据结点
while(L->next != NULL){
DeletNextDNode(L);
free(L); //释放头结点
L=NULL; // 头指针指向NULL
}
}
双链表的遍历:
// 向后遍历
while(p!=NULL){
// 对结点p做相应处理
p = p->next;
}
// 向前遍历
while(p!=NULL){
// 对结点p做相应处理
p = p->prior;
}
// 跳过头结点的遍历
while(p->prior!=NULL){
//对结点p做相应处理
p = p->prior;
}
双链表不可随机存取,按位查找、按值查找操作都只能用遍历的方式实现。时间复杂度O(n)
2.3.4 循环链表
循环单链表
循环单链表和单链表的区别在于,表中最后一个结点的指针不是NULL,而改为指向头结点,从而整个链表形成一个环
在循环单链表中,表尾结点*r的next域指向L,故表中没有指针域为NULL的结点,因此,循环单链表的判空条件不是头结点的指针是否为空,而是它是否等于头指针L。
循环单链表的实现:
typedef struct LNode{ //定义单链表结点类型
ElemType data;
struct LNode *next; //指针指向下一个元素
}DNode, *Linklist;
// 初始化循环单链表
bool InitList(LinkList &L){
L = (LNode *)malloc(sizeof(LNode)); //分配一个头结点
if(L==NULL) //内存不足,分配失败
return false;
L->next = L; // 最后一个结点的next指针指向头结点
return true;
}
// 判断循环单链表是否为空
bool Empty(LinkList L){
if(L->next == L)
return true;
else
return false;
}
// 判断结点p是否为循环单链表的表尾结点
bool isTail(LinkList L, LNode *p){
if(p->next == L) //看p结点的下一个结点是否是头结点
return true;
else
return false;
}
单链表:从一个结点出发只能找到后续的各个结点
循环单链表:从一个结点出发可以找到其他任何一个结点
循环双链表
循环双链表的初始化
typedef struct DNode{
ElemType data;
struct DNode *prior, *next;
}DNode, *DLinklist;
// 初始化空的循环双链表
bool InitDLinkList(DLinklist &L){
L = (DNode *) malloc(sizeof(DNode)); //分配一个头结点
if(L==NULL) //内存不足,分配失败
return false;
L->prior = L; //头结点的prior指针指向最后一个结点
L->next = L; //最后一个结点的next指针指向头结点
}
void testDLinkList(){
//初始化循环双链表
DLinkList L;
InitDLinkList(L);
//...
}
// 判断循环双链表是否为空
bool Empty(DLinklist L){
if(L->next == L)
return true;
else
return false;
}
// 判断结点p是否为循环双链表的表尾结点
bool isTail(DLinklist L, DNode *p){
if(p->next == L) //结点的next指针指向头结点
return true;
else
return false;
}
循环双链表的插入
// 将结点s插入到结点p之后
bool InsertNextDNode(DNode *p, DNode *s){
s->next = p->next;
//循环双链表不用担心p结点的下一个结点为空
p->next->prior = s;
s->prior = p;
p->next = s;
}
循环双链表的删除
// 删除p结点的后继结点
bool DeletNextDNode(DNode *p){
// 找到p的后继结点q
DNode *q =p->next;
//循环双链表不用担心q结点的下一个结点为空
p->next = q->next;
q->next->prior=p;
free(q);
return true;
}
2.3.5 静态链表
静态链表:用数组的方式实现的链表
优点:增、删操作不需要大量移动元素
缺点:不能随机存取,只能从头结点开始依次往后查找,容量固定不可变
适用场景:
①不支持指针的低级语言;
②数据元素数量固定不变的场景(如操作系统的文件分配表FAT)
静态链表是用数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,与前面所讲的链表中的指针不同的是,这里的指针是结点在数组中的相对地址(数组下标),又称游标。
静态链表的定义
#define MaxSize 10 //静态链表的最大长度
struct Node{ //静态链表结构类型的定义
ElemType data; //存储数据元素
int next; //下一个元素的数组下标 即游标
};
// 用数组定义多个连续存放的结点
void testSLinkList(){
struct Node a[MaxSize]; //数组a作为静态链表, 每一个数组元素的类型都是struct Node
...
}
王道书上是这么定义的,侧重于强调 a 是一个静态链表而非数组。
#define MaxSize 10 //静态链表的最大长度
typedef struct{ //静态链表结构类型的定义
ELemType data; //存储数据元素
int next; //下一个元素的数组下标
}SLinkList[MaxSize];
void testSLinkList(){
SLinkList a;
}
静态链表基本操作实现
初始化静态链表:
把a[0]的next 设为-1
把其他结点的next 设为一个特殊值用来表示结点空闲,如-2
查找结点:
从头结点出发挨个往后遍历结点
插入位序为 i 的结点:
①找到一个空的结点,存入数据元素
②从头结点出发找到位序为 i-1 的结点
③修改新结点的 next
④修改 i-1 号结点的 next
删除某个结点:
①从头结点出发找到前驱结点
②修改前驱结点的游标
③被删除结点 next 设为 -2
2.3.6 顺序表和链表的比较
1、存取(读/写)方式
顺序表可以顺序存取,也可以随机存取,在第i个位置上执行存取操作,顺序表仅需一次访问.
链表只能从表头开始依次顺序存取,链表在第i个位置执行存取则需从表头开始依次访问i次.
2、逻辑结构与物理结构
顺序表和链表都属于线性表,都是线性结构。
顺序存储逻辑上相邻的元素,对应的物理存储位置也相邻,链式存储逻辑上相邻的元素物理存储位置不一定相邻,对应的逻辑关系是通过指针链接来表示的。
3、创销、增删改查
基本操作—创建
- 顺序表:需要预分配大片连续空间。 若分配空间过小,则之后不 方便拓展容量;若分配空间 过大,则浪费内存资源。
静态分配:静态数组,容量不可改变。
动态分配:动态数组(malloc、free),容量可以改变,但是需要移动大量元素,时间代价高。 - 链表:只需分配一个头结点(也可 以不要头结点,只声明一个 头指针),之后方便拓展
基本操作 - 销毁
- 顺序表:修改 Length = 0
- 链表:依次删除各个结点 (free)。
基本操作 - 增/删
- 顺序表:插入 / 删除元素要将后续元素后移 / 前移
时间复杂度O(n),时间开销主要来自于移动元素
若数据元素很大,则移动的时间代价很高 - 链表:插入 / 删除元素只需要修改指针即可
时间复杂度O(n),时间开销主要来自查找目标元素
查找元素的时间代价更低
基本操作 - 查找
- 顺序表
按位查找:时间复杂度O(1)
按值查找:时间复杂度O(n),若表内元素有序,可在 O(log2n) 时间内找到(二分法) - 链表
按位查找:时间复杂度O(n)
按值查找:时间复杂度O(n)
开放性问题答题思路
Q:请描述顺序表和链表的 bla bla bla… 实现线性表时,用顺序表还是链表好?
A:顺序表和链表的逻辑结构都是线性结构,都属于线性表。 但是二者的存储结构不同,顺序表采用顺序存储…(特点,带来的优点缺点);链表采用链式存储…(特 点、导致的优缺点)。 由于采用不同的存储方式实现,因此基本操作的实现效率也不同。当初始化时…;当插入一个数据元 素时…;当删除一个数据元素时…;当查找一个数据元素时...
三、栈、队列和数组
3.1 栈
3.1.1 栈的基本概念
线性表是具有相同数据类型的n(n≥0)个数据元素的有限 序列,其中n为表长,当n = 0时线 性表是一个空表。若用L命名线性表,则其一般表示为 L = (a1, a2, … , ai , ai+1, … , an)
栈(Stack)是只允许在一端进行插入或删除操作的线性表
逻辑结构:与普通线性表相同 数据的运算:插入、删除操作有区别
重要术语:栈顶、栈底、空栈
栈顶:允许插入 和删除的一端 (最后进的即最上面的为栈顶元素)
栈底:不允许插 入和删除的一端(最先进的即最下面的为栈底元素)
空栈:不含任何元素的栈
特点:后进先出(后进栈的元素先出栈) 记为LIFO (Last In First Out)
栈的基本操作
- 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
栈的常考题型
进栈顺序为: a à b à c à d à e 有哪些合法的出栈顺序?
3.1.2 栈的顺序存储实现
顺序栈的定义:
#define MaxSize 10 //定义栈中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //静态数组存放栈中元素
int top; //栈顶指针 用于指向此时栈中的栈顶元素 一般用来记录数组的下标
}SqStack;
void testStack(){
SqStack S; //声明一个顺序栈(分配空间)
}
顺序存储:给各个数据元素分配连续的存储空间,大小为MaxSize*sizeof(ElemType)
初始化操作
#define MaxSize 10 //定义栈中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //静态数组存放栈中元素
int top; //栈顶指针
}SqStack;
// 初始化栈
void InitStack(SqStack &S){
S.top = -1; //初始化栈顶指针
}
void testStack(){
SqStack S; //声明一个顺序栈(分配空间)
InitStack(S);
//...后续操作...
}
// 判断栈是否为空
bool StackEmpty(SqStack S){
if(S.top == -1) //栈空
return true;
else //不空
return false;
}
进栈操作
#define MaxSize 10 //定义栈中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //静态数组存放栈中元素
int top; //栈顶指针
}SqStack;
// 新元素进栈
bool Push(SqStack &S, ElemType x){ // 判断栈是否已满 满了报错
if(S.top == MaxSize - 1)
return false;
S.top = S.top+1; //指针先加1
S.data[S.top]=x; //新元素入栈 把x存到top指针所在的位置
return true;
}
S.top = S.top+1; //指针先加1
S.data[S.top]=x; //新元素入栈 把x存到top指针所在的位置
上面两句代码等价于
S.data[++S.top]=x; //++top表示,先让top的值加1 再来使用top
出栈操作
#define MaxSize 10 //定义栈中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //静态数组存放栈中元素
int top; //栈顶指针
}SqStack;
// 出栈
bool Pop(SqStack &x, ElemType &x){ // 判断栈是否为空
if(S.top == -1) //栈空报错
return false;
x = S.data[S.top--];
return true;
}
x = S.data[S.top--];
等价于
x=S.data[S.top]; //栈顶元素先出栈
S.top=S.top-1; //指针再减1
top指针减1 数据还残留在内存中,指示逻辑上被删除了
栈顶指针:S.top,初始化时设置S.top=-1;栈顶元素:S.data[S.top]
进栈操作:栈不满时,栈顶指针先加1,再送值到栈顶
出栈操作:栈非空时,先取栈顶元素,再将栈顶指针减1
栈空条件:S.top==-1,栈满条件:S.top==MaxSize-1;栈长:S.top+1
读取栈顶元素
// 读栈顶元素
bool GetTop(SqStack S, ElemType &x){
if(S.top == -1) //栈空 报错
return false;
x = S.data[S.top]; //x记录栈顶元素 和出栈操作基本一样,唯一区别是这里top不需要--
return true;
}
仅为读取栈顶元素,并没有出栈操作,因此原栈顶元素依然保留在栈中
共享栈(利用栈底位置相对不变的特性,让两个顺序栈共享一个一维数组空间):
将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸
#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;
}
/*仅当两个栈顶指针相邻(top1-top0=1)时,判断为栈满
0号栈进栈时top0先加1再赋值
1号栈进栈时top1先减1再赋值
出栈时则相反*/
栈满的条件:top0 + 1 == top1
共享栈是为了更有效地利用存储空间,两个栈的空间相互调剂,只有在整个存储空间被占满时才发生上溢,其存取数据的时间复杂度为O(1),所以对存取效率没有什么影响
3.1.3 栈的链式存储实现
采用链式存储的栈称为链栈,链栈的优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况,通常采用单链表实现,并规定所有操作都是在单链表的表头进行的
链栈的定义 和单链表的定义几乎没差别
typedef struct Linknode{
ElemType data; //数据域
Linknode *next; //指针域
}*LiStack;
void testStack(){
LiStack L; //声明一个链栈
}
采用链式存储,便于结点的插入与删除。链栈的操作与链表类似,入栈和出栈的操作都在链的表头进行。需要注意的是,对于带头结点和不带头结点的链栈,具体的实现会有所不同。
链栈的初始化
typedef struct Linknode{
ElemType data;
Linknode *next;
}*LiStack;
// 初始化栈
bool InitStack(LiStack &L){
L = (Linknode *)malloc(sizeof(Linknode));
if(L == NULL)
return false;
L->next = NULL;
return true;
}
// 判断栈是否为空
bool isEmpty(LiStack &L){
if(L->next == NULL)
return true;
else
return false;
}
入栈出栈
// 新元素入栈
bool pushStack(LiStack &L,ElemType e){
Linknode *s = (Linknode *)malloc(sizeof(Linknode));
if(s == NULL) //内存分配失败
return false;
s->data = e; //用结点s保存数据元素e
s->next = L->next; //头插法
L->next = s; //把s结点连到L后
return true;
}
// 出栈
bool popStack(LiStack &L, int &e){
if(L->next == NULL) // 栈空不能出栈
return false;
Linknode *s = L->next;
x = s->data;
L->next = s->next;
free(s);
return true;
}
3.2 队列
3.2.1 队列的基本概念
队列(Queue)是只允许在一端进行插入,在另一端删除的线性表
重要术语:队头、队尾、空队列
队列的特点:先进先出 First In First Out(FIFO)
队列的基本操作
InitQueue(&Q):初始化队列,构造一个空队列Q。
DestroyQueue(&Q):销毁队列。销毁并释放队列Q所占用的内存空间。
EnQueue(&Q,x):入队,若队列Q未满,将x加入,使之成为新的队尾。
DeQueue(&Q,&x):出队,若队列Q非空,删除队头元素,并用x返回。
GetHead(Q,&x):读队头元素,若队列Q非空,则将队头元素赋值给x。
其他常用操作:
QueueEmpty(Q):判队列空,若队列Q为空返回true,否则返回false
3.2.2 队列的顺序实现
顺序队列的定义
#define MaxSize 10; //定义队列中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //用静态数组存放队列元素
int front, rear; //队头指针和队尾指针
}SqQueue;
void testQueue(){
SqQueue Q; //声明一个队列(顺序存储)
//后续操作
}
//对头指针指向队头元素,队尾指针一般指向队尾元素的后一个位置(下一个应该插入的位置)
顺序队列的初始化
#define MaxSize 10; //定义队列中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //用静态数组存放队列元素
int front, rear; //队头指针和队尾指针
}SqQueue;
// 初始化队列
void InitQueue(SqQueue &Q){
// 初始化时,队头、队尾指针指向0
Q.rear = Q.front = 0; // 队尾指针指向的是即将插入数据的数组下标
// 队头指针指向的是队头元素的数组下标
}
// 判断队列是否为空
bool QueueEmpty(SqQueue Q){
if(Q.rear == Q.front) //队头指针和队尾指针是否相等来判断队空
return true;
else
return false;
}
void testQuenue(){
//声明一个队列(顺序存储)
SqQueue();
InitQueue(Q);
//...后续操作
}
入队操作 只能从队尾入队(插入)
#define MaxSize 10; //定义队列中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //用静态数组存放队列元素
int front, rear; //队头指针和队尾指针
}SqQueue;
// 新元素入队
bool EnQueue(SqQueue &Q, ElemType x){
// 如果队列已满直接返回
if((Q.rear+1)%MaxSize == Q.front) //牺牲一个单元区分队空和队满 检查队尾指针的后一个位置是否等于队头指针指向的位置
return false; //队满则报错
Q.data[Q.rear] = x; //把x插入到队尾的位置
Q.rear = (Q.rear+1)%MaxSize; //队尾指针加1取模 循环队列
return true;
}
// (Q.rear+1)%MaxSize的结果为{0, 1, 2, …, MaxSize-1}
将存储空间在逻辑上变成了“环状”
取模运算,即取余运算。两个整数 a,b,a%b == a除以b的余数 。也可表示为 a MOD b
模运算将无限的整数 域映射到有限的整数 集合 {0, 1, 2, …, b-1} 上
出队操作 只能让对头元素出队
// 出队 (删除一个队头元素,并用x返回)
bool DeQueue(SqQueue &Q, ElemType &x){
// 如果队列为空直接返回
if(Q.rear == Q.front) //判断队空
return false; //队空则报错
x = Q.data[Q.front]; //队列不空则把队头元素赋给x
Q.front = (Q.front+1)%MaxSize; //队头指针往后移 对MaxSize取模 让指针转圈圈移动
return true;
}
获取队头元素
// 获取队头元素并存入x,用x返回
bool GetHead(SqQueue &Q, ElemType &x){
if(Q.rear == Q.front)
return false; //队空则报错
x = Q.data[Q.front]; //把队头指针的元素赋值给x
return true;
}
队列的元素个数:(rear+MaxSize-front)%MaxSize;
判断队列已满/已空
初始化时rear=front=0
队列已满的条件:队尾指针的再下一个位置是队头,即(Q.rear+1)%MaxSize==Q.front
队空条件:Q.rear==Q.front
如果不牺牲一个单元来区分队空和队满的话,不能使用Q.rear == Q.front作为判空的条件
因为当队列已满时也符合该条件,会与判空发生冲突
解决方法一:设置一个size变量来记录队列长度
#define MaxSize 10;
typedef struct{
ElemType data[MaxSize];
int front, rear; //队头指针 队尾指针
int size; //队列当前长度
}SqQueue;
// 初始化队列
void InitQueue(SqQueue &Q){
Q.rear = Q.front = 0;
Q.size = 0; //初始化时size为0,插入成功++,删除成功--
}
// 判断队列是否为空
bool QueueEmpty(SqQueue 0){
if(Q.size == 0)
return true;
else
return false;
}
// 新元素入队
bool EnQueue(SqQueue &Q, ElemType x){
if(Q.size == MaxSize)
return false;
Q.size++; //入队size++
Q.data[Q.rear] = x;
Q.rear = (Q.rear+1)%MaxSize;
return true;
}
// 出队
bool DeQueue(SqQueue &Q, ElemType &x){
if(Q.size == 0)
return false;
Q.size--; //出队size--
x = Q.data[Q.front];
Q.front = (Q.front+1)%MaxSize;
return true;
}
解决方法二:设置 tag 变量记录队列最近的操作。
(tag=0:最近进行的是删除操作;tag=1 :最近进行的是插入操作)
#define MaxSize 10;
typedef struct{
ElemType data[MaxSize];
int front, rear;
int tag; //最近进行的是删除(0)/插入(1)
}SqQueue;
// 初始化队列
void InitQueue(SqQueue &Q){
Q.rear = Q.front = 0;
Q.tag = 0; //初始化tag为0
}
// 判断队列是否为空
bool QueueEmpty(SqQueue 0){
if(Q.front == Q.rear && Q.tag == 0)
return true;
else
return false;
}
// 新元素入队
bool EnQueue(SqQueue &Q, ElemType x){
if(Q.rear == Q.front && tag == 1) //队满条件
return false;
Q.data[Q.rear] = x;
Q.rear = (Q.rear+1)%MaxSize;
Q.tag = 1; //每次插入操作成功时都令tag=1
return true;
}
// 出队
bool DeQueue(SqQueue &Q, ElemType &x){
if(Q.rear == Q.front && tag == 0) //队空条件
return false;
x = Q.data[Q.front];
Q.front = (Q.front+1)%MaxSize;
Q.tag = 0; //每次删除操作成功时都令tag=0
return true;
}
rear指针指向队尾元素时,可以让front指向0,让rear指向n-1的位置
初始化队尾指针
Q.rear=(Q.rear+1)%MaxSize;
Q.data[Q.rear]=x;
判空:(Q.rear+1)%MaxSize==Q.front //判断队尾指针的下一个位置是不是队头
判满:不能直接判断队尾指针和队头指针是否相等
方案一:牺牲一个存储单元
方案二:增加辅助变量
3.2.3 队列的链式实现
链式队列的定义
typedef struct LinkNode{ // 链式队列结点
ElemType data;
struct LinkNode *next;
}
typedef struct{ // 链式队列
LinkNode *front, *rear; // 头指针和尾指针
}LinkQueue;
链式队列的初始化(带头结点)
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front, *rear;
}LinkQueue;
// 初始化队列
void InitQueue(LinkQueue &Q){
// 初始化时,front、rear都指向头结点
Q.front = Q.rear = (LinkNode *)malloc(sizeof(LinkNode));
Q.front -> next = NULL; //头结点的next指针指向NULL则队列为空
}
// 判断队列是否为空
bool IsEmpty(LinkQueue Q){
if(Q.front == Q.rear)
return true;
else
return false;
}
// 新元素入队(带头结点)
void EnQueue(LinkQueue &Q, ElemType x){
LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode)); //malloc申请新结点
s->data = x;
s->next = NULL;
Q.rear->next = s; //新结点插入到rear之后
Q.rear = s; //修改表尾指针指向新的结点
}
// 队头元素出队 (带头结点)
bool DeQueue(LinkQueue &Q, ElemType &x){
if(Q.front == Q.rear) //判断队列是否为空
return false;
LinkNode *p = Q.front->next; //将p指针指向要删除的结点 对带头结点来说就是指向带头结点的后面一个结点
x = p->data; //用变量x返回要删除的队头元素
Q.front->next = p->next; //修改头结点的next指针
if(Q.rear == p) // 如果p是最后一个结点 需要进行特殊处理
Q.rear = Q.front; //修改队尾指针 跟队头指针一样也指向NULL 即变为空队列
free(p); //释放结点空间
return true;
}
链式队列的初始化(不带头结点)
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front, *rear;
}LinkQueue;
// 初始化队列(不带头结点)
void InitQueue(LinkQueue &Q){
//初始时front头指针和rear尾指针都指向NULL
Q.front = NULL;
Q.rear = NULL;
}
// 判断队列是否为空(不带头结点)
bool IsEmpty(LinkQueue Q){
if(Q.front == NULL)
return true;
else
return false;
}
// 新元素入队 (不带头结点)
void EnQueue(LinkQueue &Q, ElemType 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; //新结点插入到rear结点之后
Q.rear = s; //修改rear指针 指向新的结点
}
}
//队头元素出队(不带头结点)
bool DeQueue(LinkQueue &Q, ElemType &x){
if(Q.front == NULL)
return false; //空队返回false
LinkNode *p = Q.front; //p指向此次出队的头结点
x = p->data; //用变量x返回队头元素
Q.front=p->next; //修改front指针
if(Q.rear == p){ //最后一个结点出队后要把队头队尾指针都指向NULL
Q.front = NULL;
Q.rear = NULL;
}
free(p);
return true;
}
3.2.4 双端队列
双端队列:只允许从两端插入、两端删除的线性表
3.3 栈和队列的应用
3.3.1 栈在括号匹配中的应用
用栈实现括号匹配:
- 最后出现的左括号最先被匹配 (栈的特性——LIFO)。
- 遇到左括号就入栈,遇到右括号,就“消耗”一个左括号(出栈)。
匹配失败情况:
- 扫描到右括号且栈空,则该右括号单身。
- 扫描完所有括号后,栈非空,则该左括号单身。
- 左右括号不匹配。
算法实现
// 判断长度为length的字符串str中的括号是否匹配
bool bracketCheck(char str[], int length){
SqStack S;
InitStack(S); //初始化一个栈
// 遍历str
for(int i=0; i<length; i++){ //循环从左往右依次扫描字符
if(str[i] == '(' || str[i] == '[' || str[i] == '{'){
Push(S, str[i]); // 扫描到左括号,入栈
}else{
if(StackEmpty(S)) // 扫描到右括号且当前栈空
return false; // 匹配失败
char topElem; // 用topElem接收栈顶元素
Pop(S, topElem); // 栈顶元素出栈
if(str[i] == ')' && topElem != '(' ) //不匹配的情况直接匹配失败
return false;
if(str[i] == ']' && topElem != '[' )
return false;
if(str[i] == '}' && topElem != '{' )
return false; }
}
return StackEmpty(S); // 扫描完毕若栈空则说明匹配成功
}
#define MaxSize 10 //定义栈元素中的最大个数
typedef struct{
char data[MaxSize]; //静态数组存放栈中元素
int top; //栈顶指针
}SqStack;
//初始化栈
void InitStack(SqStack &S);
//判断是否为空
bool StackEmpty(SqStack &S);
//新元素入栈
bool Push(SqStack &S, char x);
//栈顶元素出栈,用x返回
bool Pop(SqStack &S, char &x);
3.3.2_1 栈在表达式求值中的应用(上)
算数表达式
(15 ÷ (7 − (1 + 1))) × 3) − (2 + (1 + 1))
③ ② ① ④ ⑦ ⑥ ⑤
15 ÷ 7 − 1 + 1 × 3 − 2 + 1 + 1
① ② ④ ③ ⑤ ⑥ ⑦
由三个部分组成:操作数、运算符、界限符
界限符是必不可少的, 反映了计算的先后顺序
上面这种常用的算术表达式就是中缀表达式
中缀表达式是一种通用的算术或逻辑公式表示方法,运算符以中缀形式处于操作数的中间。但对于计算机来说中缀表达式是很复杂的,因此计算表达式的值时,通常需要先将中缀表达式转换为前缀或后缀表达式,然后再进行求值。
前缀表达式(波兰表达式):前缀表达式的运算符位于两个操作数之前。
后缀表达式(逆波兰表达式):后缀表达式的运算符位于两个操作数之后。
中缀转后缀的手算方法:
① 确定中缀表达式中各个运算符的运算顺序
② 选择下一个运算符,按照「左操作数 右操作数 运算符」的方式组合成一个新的操作数
③ 如果还有运算符没被处理,就继续 ②
为了保证手算和机算结果相同可以采用“左优先”原则:只要左边的运算符能先计算就优先算左边的
后缀表达式的手算方法:
从左往右扫描,每遇到一个运算符,就让运算符前面最近的两个操作数执行对应运算, 合体为一个操作数
注意:两个操作数的左右顺序
用栈实现后缀表达式的计算(机算):
①从左往右扫描下一个元素,直到处理完所有元素
②若扫描到操作数则压入栈,并回到①;否则执行③
③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①
中缀转前缀的手算方法:
① 确定中缀表达式中各个运算符的运算顺序
② 选择下一个运算符,按照「运算符 左操作数 右操作数」的方式组合成一个新的操作数
③ 如果还有运算符没被处理,就继续 ②
右优先”原则:只要右边的运算符能先计算,就优先算右边的
用栈实现前缀表达式的计算:
①从右往左扫描下一个元素,直到处理完所有元素
②若扫描到操作数则压入栈,并回到①;否则执行③
③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①
注意:先出栈的是"左操作数"
3.3.2_2 栈在表达式求值中的应用(下)
初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。
从左到右处理各个元素,直到末尾。可能遇到三种情况:
① 遇到操作数。直接加入后缀表达式。
② 遇到界限符。遇到“(”直接入栈;遇到“)”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。注意:“(”不加入后缀表达式。
③ 遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式, 若碰到“(” 或栈空则停止。之后再把当前运算符入栈。
按上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式
#define MaxSize 40
typedef struct{
char data[MaxSize];
int top;
}SqStack;
typedef struct{
char data[MaxSize];
int front,rear;
}SqQueue;
void InitStack(SqStack &S);
bool StackEmpty(SqStack S);
bool Push(SqStack &S, char x);
bool Pop(SqStack &S, char &x);
void InitQueue(SqQueue &Q);
bool EnQueue(LQueue &Q, char x);
bool DeQueue(LQueue &Q, char &x);
bool QueueEmpty(SqQueue Q);
// 判断元素ch是否入栈
int JudgeEnStack(SqStack &S, char ch){
char tp = S.data[S->top];
// 如果ch是a~z则返回-1
if(ch >= 'a' && ch <= 'z')
return -1;
// 如果ch是+、-、*、/且栈顶元素优先级大于等于ch则返回0
else if(ch == '+' && (tp == '+' || tp == '-' || tp == '*' || tp == '/'))
return 0;
else if(ch == '-' && (tp == '+' || tp == '-' || tp == '*' || tp == '/'))
return 0;
else if(ch == '*' && (tp == '*' || tp == '/'))
return 0;
else if(ch == '/' && (tp == '*' || tp == '/'))
return 0;
// 如果ch是右括号则返回2
else if(ch == ')')
return 2;
// 其他情况ch入栈,返回1
else return 1;
}
// 中缀表达式转后缀表达式
int main(int argc, char const *argv[]) {
SqStack S;
SqQueue Q;
InitStack(S);
InitQueue(Q);
char ch;
printf("请输入表达式,以“#”结束:");
scanf("%c", &ch);
while (ch != '#'){
// 当栈为空时
if(StackEmpty(&S)){
// 如果输入的是数即a~z,直接入队
if(ch >= 'a' && ch <= 'z')
EnQueue(Q, ch);
// 如果输入的是运算符,直接入栈
else
Puch(S, ch);
}else{
// 当栈非空时,判断ch是否需要入栈
int n = JudgeEnStack(S, ch);
// 当输入是数字时直接入队
if(n == -1){
EnQueue(Q, ch);
}else if(n == 0){
// 当输入是运算符且运算符优先级不高于栈顶元素时
while (1){
// 取栈顶元素入队
char tp;
Pop(S, tp);
EnQueue(Q, tp);
// 再次判断是否需要入栈
n = JudgeEnStack(S, ch);
// 当栈头优先级低于输入运算符或者栈头为‘)’时,入栈并跳出循环
if(n != 0){
EnStack(S, ch);
break;
}
}
}else if(n == 2){
// 当出现‘)’时 将()中间的运算符全部出栈入队
while(1){
char tp;
Pop(S, tp);
if(tp == '(')
break;
else
EnQueue(Q, tp);
}
}else{
// 当运算符优先级高于栈顶元素或出现‘(’时直接入栈
Push(S, ch);
}
}
scanf("%c", &ch);
}
// 将最后栈中剩余的运算符出栈入队
while (!StackEmpty(S)){
char tp;
Pop(S, tp);
EnQueue(Q, tp);
}
// 输出队中元素
while (!QueueEmpety(Q)){
printf("%c ", DeQueue(Q));
}
return 0;
}
3.3.3 栈在递归中的应用
函数调用的特点:最后被调用的函数最先执行结束(LIFO)
函数调用时,需要用一个栈存储:
① 调用返回地址
② 实参
③ 局部变量
递归调用时,函数调用栈可称为“递归工作栈” 每进入一层递归,就将递归调用所需信息压入栈顶 每退出一层递归,就从栈顶弹出相应信息
缺点:效率低,太多层递归可能会导 致栈溢出;可能包含很多重复计算
可以自定义栈将递归算 法改造成非递归算法
适合用“递归”算法解决:可以把原始问题转换为属性相同,但规模较小的问题
3.3.4 队列的应用
- 树的层次遍历
- 图的广度优先遍历
- 队列在操作系统中的应用
多个进程争抢着使用有限的系统资源时,FCFS(First Come First Service,先来先服务)是一种常用策略。
3.4 数组和特殊矩阵
3.4.1一维数组的存储结构
各数组元素大小相同,且物理上连续存放。
数组元素a[i] 的存放地址 = LOC + i * sizeof(ElemType) (0≤i<10)
注:除非题目特别说明,否则数组下标默认从0开始
3.4.2 二维数组的存储结构
M行N列的二维数组 b[M][N] 中,若按行优先存储,则b[i][j] 的存储地址 = LOC + (i*N + j) * sizeof(ElemType)
M行N列的二维数组 b[M][N] 中,若按列优先存储,则b[i][j] 的存储地址 = LOC + ( j*M+ i ) * sizeof(ElemType)
3.4.3 普通矩阵的存储
对称矩阵的压缩存储
策略:只存储主对角线+下三角区 按行优先原则将各元素存入一维数组中
三角矩阵的压缩存储
三对角矩阵的压缩存储
三角矩阵的压缩存储
稀疏矩阵的压缩存储
四、串
4.1 串的定义和实现
4.1.2 串的定义
- 串:即字符串(String)是由零个或多个字符组成的有限序列。
例:T=‘iPhone 11 Pro Max?’ - 子串:串中任意个连续的字符组成的子序列。 Eg:’iPhone’,’Pro M’ 是串T 的子串
- 主串:包含子串的串。 Eg:T 是子串’iPhone’的主串
- 字符在主串中的位置:字符在串中的序号。 Eg:’1’在T中的位置是8(第一次出现)
- 子串在主串中的位置:子串的第一个字符在主串中的位置 Eg:’11 Pro’在 T 中的位置为8
注意:串的位序是从1开始而不是从0开始 - 空串:M=‘’ M是空串,里面没有存任何东西
- 空格串:N=‘ ’ N是由三个空格字符组成的空格串,每个空格字符占1B
串是一种特殊的线性表,数据元素之间呈线性关系
串的数据对象限定为字符集(如中文字符、英文字符、数字字符、标点字符等)
4.1.3 串的基本操作
- 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.2 串的存储结构
4.2.1 串的顺序存储
串的顺序存储(静态数组):
// ch[0]废弃不用,声明int型变量length来存放串的长度
#define MAXLEN 255 //预定义最大串长为255
typedef struct{
char ch[MAXLEN]; //每个分量存储一个字符 静态数组实现(定长顺序存储)
int length; //串的实际长度
}SString;
// 串的初始化
bool InitString(SString &S){
S.length = 0;
return true;
}
// 求串的长度
int StrLength(SString S){
return S.length;
}
// 求子串
bool SubString(SString &Sub, SString S, int pos, int len){
if(pos+len-1 > S.length) //子串范围越界
return false;
for(int i=pos; i<pos+len; i++)
Sub.ch[i-pos+1] = S.ch[i];
Sub.length = len;
return true;
}
// 比较操作。笔记S、T的大小,若S>T,则返回值大于0;若S=T,则返回值等于0;若S<T,则返回值小于0
int StrCompare(SString S, SString T){
for(int i=1; i<=S.length && i<=T.length; i++){
if(S.ch[i]!=T.ch[i])
return S.ch[i]-T.ch[i];
}
// 扫描过的所有字符都相同,则长度长的串更大
return S.length-T.length;
}
//定位操作。主串S中存在与串T值相同的子串则返回串T在主串S中第一次出现的位置,若无法定位则返回0
int Index(SString S, SString T){
int i=1, n=StrLength(S), m=StrLength(T); //n求出S的长度,m表示T的长度
SString sub; //用于暂存子串
while(i<=n-m+1){ //从头到尾依次取
SubString(sub, S, i, m);
if(StrCompare(sub, T)!=0)
++i;
else
return i; //返回子串在主串中的位置
}
return 0; //S中不存在与T相等的子串
}
void test{
SString S;
InitString(S);
...
}
串的顺序存储(动态数组):
#define MAXLEN 255
typedef struct{
char *ch; //按串长分配存储区,ch指向串的基地址
int length;
}HString; //动态数组实现(堆分配存储)
bool InitString(HString &S){
S.ch = (char *)malloc(MAXLEN * sizeof(char)); //malloc分配一片连续的存储空间
if(S.ch == NULL)
return false;
S.length = 0;
return true;
}
void test{
HString S;
InitString(S);
...
}
4.2.2 串的链式存储
typedef struct StringNode{
char ch; //每个结点存1个字符
struct StringNode *next;
}StringNode, *String;
上述方式存储密度低, 一般采用下面的方式,使每个结点存储多个字符
typedef struct StringNode{
char ch[4]; //每个结点存多个字符
struct StringNode *next;
}StringNode, *String;
4.2.3 基本操作的实现
4.2 串的模式匹配
4.2.1_朴素模式匹配算法
字符串模式匹配:在主串中找到与模式串相同的⼦串,并返回其所在位置
主串⻓度为n,模式串⻓度为 m 朴素模式匹配算法:将主串中所有⻓度为m的⼦串依次与模式串对⽐,直到找到⼀个完全匹配的⼦串, 或所有的⼦串都不匹配为⽌。 最多对⽐ n-m+1 个⼦串
Index(S,T):定位操作。若主串S中存在与串T值相同的⼦串,则返回它在主串S中第⼀次出现 的位置;否则函数值为0
接下来不使用字符串的基本操作,直接通过数组下标实现朴素模式匹配算法
// 在主串S中找到与模式串T相同的子串并返回其位序,否则返回0
int Index(SString S, SString T){
int i=1, j=1;
while(i<=S.length && j<=T.length){
if(S.ch[i] == T.ch[j]){ //如果i里面存的字符和j里面存的相同的话
++i; ++j; //++继续比较后继字符
}else{
i=i-j+2; //i指针指向下一个子串的起始位置
j=1; //j指针后退回到第一个位置重新开始匹配
}
}
if(j>T.length)
return i-T.length;
else
return 0;
}
设主串⻓度为 n,模式串⻓度为 m,则 最坏时间复杂度 = O(nm)
最坏的情况,每个⼦串都要对⽐ m 个字符,共 n-m+1 个⼦串,复杂度 = O((n-m+1)m) = O(nm)
4.2.2_1_KMP算法
朴素模式匹配算法的缺点
⼀旦发现当前这个⼦串中某个字符不匹配,就只能转⽽匹配下⼀个⼦串(从头开始)
用代码实现这个处理逻辑 可以用一个next数组来存储让模式串指针指向的位置
next数组只和短短的模式串 有关,和长长的主串⽆关
KMP算法:当子串和模式串不匹配时,主串指针 i 不回溯,模式串指针 j=next[j]。
KMP算法最坏时间复杂度 O(m+n)
其中,求 next 数组时间复杂度 O(m)
模式匹配过程最坏时间复杂度 O(n)
KMP算法的代码实现
// 获取模式串T的next[]数组
void getNext(SString T, int next[]){
int i=1, j=0;
next[1]=0;
while(i<T.length){
if(j==0 || T.ch[1]==T.ch[j]){
++i; ++j;
next[i]=j;
}else
j=next[j];
}
}
// KPM算法,求主串S中模式串T的位序,没有则返回0
int Index_KMP(SString S, SString T){
int i=1, j=1;
int next[T.length+1];
getNext(T, next);
while(i<=S.length && j<=T.length){
if(j==0 || S.ch[i]==T.ch[j]){ //如果主串的元素和模式串的元素相等或j等于0时
++i;
++j; //i和j++,继续比较后继字符
}else
j=next[j]; //模式串向后移动
}
if(j>T.length)
return i-T.length; //j大于模式串长度说明匹配成功
else
return 0;
}
int main() {
SString S={"ababcabcd", 9};
SString T={"bcd", 3};
printf("%d ", Index_KPM(S, T)); //输出9
}
KMP算法精髓:利用已经匹配过的模式串的信息,求出next数组→利用next数组进行匹配(主串指针不回溯)
4.2.2_2_求next数组
next数组的作⽤:当模式串的第 j 个字符失配时,从模式串的第 next[j] 的继续往后匹配
任何模式串第⼀个字符不匹配时,只能匹配下⼀个⼦串,因此,next[1]都⽆脑写 0
第2个字符不匹配时,应尝试匹配模式串的第1个字符, 因此,next[2]都⽆脑写 1
接下来的字符,在不匹配的位置前划一根分界线,模式串一步一步往后退,直到分界线前的“对的上”,或模式串完全越过分界线位置,如下面为第3个字符不匹配的情况
第四个字符不匹配
第五个字符不匹配
第六个字符不匹配
4.2.3_KMP算法的进一步优化
第3个字符和第1个字符相同,所以 可以直接跳到next[1]指向的位置,第5个字符跟第2个字符相同,直接跳到next[2]指向的位置
void getNextval(SString T, int nextval[]){
int i=1,j=0;
nextval[1]=0;
while(i<T.length){
if(j==0 || T.ch[i]==T.ch[j]){
++i; ++j;
if(T.ch[i]!=T.ch[j])
nextval[i]=j;
else
nextval[i]=nextval[j];
}else
j=nextval[j];
}
}
五、树
5.1 树的基本概念
5.1.1 树的定义
树是n(n>=0)个结点的有限集合,结点数为0的树称为空树
非空树的特性
- 有且仅有一个根节点
- 没有后继的结点称为“叶子结点”(或终端结点)
- 有后继的结点称为“分支结点”(或非终端结点)
- 除了根节点外,任何一个结点都有且仅有一个前驱
- 每个结点可以有0个或多个后继。
5.1.2 树的基本术语
树的属性
- 结点的层次(深度)——从上往下数 (默认从1开始)
- 结点的高度——从下往上数
- 树的高度(深度)——总共多少层
- 结点的度——有几个孩子(分支)★
- 树的度——各结点的度的最大值 ★
有序树——逻辑上看,树中结点的各子树从左至右是有次序的,不能互换
无序树——逻辑上看,树中结点的各子树从左至右是无次序的,可以互换
森林。森林是m(m≥0)棵互不相交的树的集合。m为0时是“空森林”
5.1.3 树的常考性质
常见考点1:结点数=总度数+1
结点的度—结点有几个孩子(分支)
常见考点2:度为m的树、m叉树 的区别
树的度——各结点的度的最大值 m叉树——每个结点最多只能有m个孩子的树
度为m的树 | m叉树 |
任意结点的度 ≤ m(最多m个孩子) | 任意结点的度 ≤ m(最多m个孩子) |
至少有一个结点度 = m(有m个孩子) | 允许所有结点的度都 < m |
一定是非空树,至少有m+1个结点 | 可以是空树 |
常见考点3:度为m的树第 i 层至多有 个结点(i≥1)
m叉树第 i 层至多有 个结点(i≥1)
常见考点4:高度为h的m叉树至多有 个结点。
等比数列求和公式:
常见考点5:高度为h的m叉树至少有 h 个结点。
高度为h、度为m的树至少有 h+m-1 个结点
常见考点6:具有n个结点的m叉树的最小高度为
高度最小的情况——所有结点都有m个孩子
5.2 二叉树的概念
5.2.1 二叉树的定义
二叉树是n(n≥0)个结点的有限集合:
① 或者为空二叉树,即n = 0。
② 或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
特点:①每个结点至多只有两棵子树 ②左右子树不能颠倒(二叉树是有序树)
二叉树的五种状态
- 空二叉树
- 只有左子树(即右子树为空)
- 只有右子树(即左子树为空)
- 只有根节点(左右子树都为空)
- 左右子树都有
几个特殊的二叉树
满二叉树。一棵高度为h,且含有2^h - 1个结点的二叉树
特点:
①只有最后一层有叶子结点
②不存在度为1的结点
③按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点的父节点为(向下取整)
完全二叉树。当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为 完全二叉树
特点:
①只有最后两层可能有叶子结点
②最多只有一个度为1的结点
③按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点的父节点为(向下取整)
④为分支结点, 为叶子结点
二叉排序树。一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
左子树上所有结点的关键字均小于根结点的关键字;
右子树上所有结点的关键字均大于根结点的关键字。
左子树和右子树又各是一棵二叉排序树。
二叉排序树可用于元素的排序、搜索
平衡二叉树。树上任一结点的左子树和右子树的深度之差不超过1。
平衡二叉树能有更高的搜索效率
5.2.2 二叉树的常考性质
常见考点1:设非空二叉树中度为0、1和2的结点个数分别为n0、n1和n2,则 n0 = n2 + 1 (叶子结点比二分支结点多一个)
常见考点2:二叉树第 i 层至多有个结点(i≥1)
m叉树第 i 层至多有个结点(i≥1)
常见考点3:高度为h的二叉树至多有个结点(满二叉树)
高度为h的m叉树至多有 个结点。
等比数列求和公式:
完全二叉树的常考性质
常见考点1:具有n个(n>0)结点的完全二叉树高度h为或
常见考点2:对于完全二叉树,可以由的结点数 n 推出度为0、1和2的结点个数为n0、n1和n2
完全二叉树最多只有一个度为1的结点,即
5.2.3 二叉树的存储结构
二叉树的顺序存储
定义一个长度为 MaxSize 的数组 t,按照从上至下、从左至右的顺序依次存储完全二叉树中的各个结点
#define MaxSize 100
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
}
//初始化树T
bool initTree(TreeNode T[]){
for(int i=0; i<MaxSize; i++){
t[i].isEmpty=true; //初始化时所有结点标记为空
}
return true;
}
void test(){
struct TreeNode t[MaxSize]; //TreeNode里面的每个元素对应一个结点
initTree(T);
}
常考的基本操作:
- i的左孩子 2i
- i的右孩子 2i+1
- i的父结点 [i/2]向下取整
- i所在的层次 或
若完全二叉树中有n个结点,则
- 判断i是否有左孩子 2i<n
- 判断i是否有右孩子 2i+1<n
- 判断i是否有叶子/分支结点 i>[n/2]向下取整
如果不是完全二叉树,顺序存储时一定要把二叉树的结点编号与完全二叉树对应起来
但是判断结点为空就不能用完全二叉树的公式方式判断,只能通过最开始定义的isEmpty来判但是但是这也会浪费很多存储空间,最坏情况:高度h且只有h个结点的单支树(所有结点只有右孩子),也至少需要2^h-1个存储单元,所以一般不采用链式存储
二叉树的链式存储
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild, *rchild; //左右孩子指针
}BiTNode, *BiTree;
//初始化 插入根结点
bool initTree(BiTree &root){
root = (BiTree)malloc(sizeof(BiTNode));
root->data={1};
if(root == NULL)
return false;
root->lchild = NULL;
root->rchild = NULL;
return true;
}
struct ElemTpye{
int value;
};
//定义一颗空树
BiTree root=NULL;
树中的每个结点都会有一个data域,用来存放实际的数据元素,还会有两个指针分别指向左孩子和右孩子,如果没有可以直接把指针设为nullz
n个结点就会有2n个指针域
n个结点的二叉链表共有n+1个空链域 (可以用于构造线索二叉树)
5.3. 二叉树的遍历和线索二叉树
5.3.1_1 二叉树的先中后序遍历
遍历:按照某种次序把所有结点都访问一遍
二叉树的递归特性:
①要么是个空二叉树
②要么就是由“根节点+左子树+右子树”组成的二叉树
先序遍历:根左右(NLR)
中序遍历:左根右(LNR)
后序遍历:左右根(LRN)
先序遍历(PreOrder)的操作过程如下:
1. 若二叉树为空,则什么也不做;
2. 若二叉树非空:
①访问根结点;
②先序遍历左子树;
③先序遍历右子树。
代码实现如下:
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
// 先序遍历
void PreOrder(BiTree T){
if(T!=NULL){
visit(T); //访问根结点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
}
先序遍历—第一次路过时访问结点,图示如下
中序遍历(InOrder)的操作过程如下:
1. 若二叉树为空,则什么也不做;
2. 若二叉树非空:
①中序遍历左子树;
②访问根结点;
③中序遍历右子树;
代码实现如下:
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
// 中序遍历
void PreOrder(BiTree T){
if(T!=NULL){
PreOrder(T->lchild); //递归遍历左子树
visit(T); //访问根结点
PreOrder(T->rchild); //递归遍历右子树
}
}
中序遍历—第二次路过时访问结点,图示如下
后序遍历(InOrder)的操作过程如下:
1. 若二叉树为空,则什么也不做;
2. 若二叉树非空:
①后序遍历左子树;
②后序遍历右子树;
③访问根结点。
代码实现如下:
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
// 后序遍历
void PreOrder(BiTree T){
if(T!=NULL){
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
visit(T); //访问根结点
}
}
后序遍历—第三次路过时访问结点 ,图示如下
// 应用:求树的深度
int treeDepth(BiTree T){
if(T == NULL){
return 0;
}else{
int l = treeDepth(T->lchild);
int r = treeDepth(T->rchild);
//树的深度=Max(左子树深度,右子树深度)+1
return l>r ? l+1 r+1;
}
}
5.3.1_2 二叉树的层次遍历
算法思想:
①初始化一个辅助队列
②根结点入队
③若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
④重复③直至队列为空
代码实现:
// 二叉树结点(链式存储)
typedef struct BiTNode{
char data;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
// 链式队列结点
typedef struct LinkNode{
BiTNode *data; //存结点的指针而不是结点本身
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front, *rear; //队头队尾
}LinkQueue;
// 层序遍历
void LevelOrder(BiTree T){
LinkQueue Q;
InitQueue(Q); //初始化辅助队列
BiTree p;
EnQueue(Q, T); //将根结点入队
while(!IsEmpty(Q)){ //队列不空则循环
DeQueue(Q, p); //队头结点出队
visit(p); //访问出队结点
if(p->lchild!=NULL)
EnQueue(Q, p->lchild); //左孩子入队
if(p->rchild!=NULL)
EnQueue(Q, p->rchild); //右孩子入队
}
}
5.3.1_3 由遍历序列构造二叉树
若只给出一棵二叉树的 前/中/后/层 序遍历序列中的一种,不能唯一确定一棵二叉树
一种遍历序列可能对应多种形态
前序 + 中序遍历序列
由前序遍历可推出根节点在中序遍历中的位置,从而确定左右结点,再依此类推
后序 + 中序遍历序列
由后序遍历可推出根结点在中序遍历中的位置,从而确定左右结点在中序遍历的位置
层序 + 中序遍历序列
由层序遍历可推出根结点,再根据根节点进一步确定左右的根的位置
5.3.2_1 线索二叉树的概念
普通二叉树进行遍历时,找前驱、后继很不方便,且每次都要从根结点出发,无法从一个指定的结点开始遍历。
n 个结点的二叉树,有 n+1 个空链域,可用来记录前驱、后继的信息。
指向前驱、后继的指针被称为“线索”,形成的二叉树就称为线索二叉树。
线索二叉树的存储
线索二叉树的结点在原本二叉树的基础上,新增了左右线索标志 tag。
tag == 0 时,表示指针指向孩子;tag == 1 时,表示指针是“线索”。
// 线索二叉树的结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; //左、右线索标志
}ThreadNode,*ThreadTree;
中序线索二叉树的存储
先序线索二叉树
5.3.2_2 二叉树的线索化
中序线索化
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; //左、右线索标志
}ThreadNode, *ThreadTree;
// 中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T) {
if (T != NULL) {
InThread(p->lchild); //中序遍历左子树
visit(T); //访问根结点
InThread(p->rchild); //中序遍历右子树
}
}
void visit(ThreadNode *q) {
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre; //
q->ltag=1; //修改ltag=1,只有变成1才表示指针是线索
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre=NULL; //pre没有前驱,最开始指向NULL
// 中序化线索二叉树T
void CreateInThread(ThreadTree T) {
pre = NULL; //pre初始化为NULL
if (T != NULL) { //非空二叉树才能线索化
InThread(T, pre); //中序化线索二叉树
if(pre->rchild = NULL)
pre->rtag = 1; //处理遍历的最后一个结点
}
}
先序线索化
// 先序遍历二叉树T
void PreThread(ThreadTree T) {
if (T != NULL) { //非空二叉树才能线索化
visit(T); //先处理根结点
if(T->ltag ==0) //lchild不是前驱线索
PreThread(T->child);
PreThread(T->child);
}
}
void visit(ThreadNode *q) {
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre; //
q->ltag=1; //修改ltag=1,只有变成1才表示指针是线索
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre=NULL; //pre没有前驱,最开始指向NULL
void CreateInThread(ThreadTree T) {
pre = NULL; //pre初始化为NULL
if (T != NULL) { //非空二叉树才能线索化
PreThread(T); //先序化线索二叉树
if(pre->rchild = NULL)
pre->rtag = 1; //处理遍历的最后一个结点
}
}
后序线索化
// 后序遍历二叉树T
void PostThread(ThreadTree T) {
if (T != NULL) { //非空二叉树才能线索化
PreThread(T->child); //后序遍历左子树
PreThread(T->child); //后序遍历右子树
visit(T); //访问根结点
}
}
void visit(ThreadNode *q) {
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre; //
q->ltag=1; //修改ltag=1,只有变成1才表示指针是线索
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
//全局变量pre,指向当前访问结点的前驱
ThreadNode *pre=NULL; //pre没有前驱,最开始指向NULL
//后续线索化二叉树T
void CreateInThread(ThreadTree T) {
pre = NULL; //pre初始化为NULL
if (T != NULL) { //非空二叉树才能线索化
PostThread(T); //后续线索化二叉树
if(pre->rchild = NULL)
pre->rtag = 1; //处理遍历的最后一个结点
}
}
5.3.2_3 在线索二叉树中找前驱后继
在中序线索二叉树中找到指定结点*p 的中序后继 next
①若 p->rtag==1,则 next = p->rchild
②若 p->rtag==0,则 next = p 的右子树中最左下结点
// 找到以p为根的子树中,第一个被中序遍历的结点
ThreadNode *FirstNode(ThreadNode *p){
// 循环找到最左下结点(不一定是叶结点)
while(p->ltag==0)
p=p->rchild;
return p;
}
// 在中序线索二叉树中找到结点p的后继结点
ThreadNode *NextNode(ThreadNode *p){
// 右子树中最左下的结点
if(p->rtag==0)
return FirstNode(p->lchild);
else
return p->rchild; //rtage==1直接返回后继线索
}
// 对中序线索二叉树进行中序循环(利用线索实现的非递归方法) 空间复杂度O(1)
void InOrder(ThreadNode *T){
for(ThreadNode *p=FirstNode(T); p!=NULL; p=NextNode(p))
visit(p);
}
在中序线索二叉树中找到指定结点*p 的中序前驱 pre
①若 p->ltag==1,则 pre = p->lchild
②若 p->ltag==0
// 找到以p为根的子树中,最后一个被中序遍历的结点
ThreadNode *LastNode(ThreadNode *p){
// 循环找到最右下结点(不一定是叶结点)
while(p->rtag==0)
p=p->rchild;
return p;
}
// 在中序线索二叉树中找到结点p的前驱结点
ThreadNode *PreNode(ThreadNode *p){
// 左子树中最右下的结点
if(p->ltag==0)
return LastNode(p->lchild);
else
return p->lchild; //ltage==1直接返回前驱线索
}
// 对中序线索二叉树进行中序循环(非递归方法实现)
void RevOrder(ThreadNode *T){
for(ThreadNode *p=LastNode(T); p!=NULL; p=PreNode(p))
visit(p);
}
在先序线索二叉树中找到指定结点*p 的先序后继 next
①若 p->rtag==1,则 next = p->rchild
②若 p->rtag==0
在先序线索二叉树中找到指定结点*p 的先序前驱 pre
①若 p->ltag==1,则 next = p->lchild
②若 p->ltag==0
在后序线索二叉树中找到指定结点*p 的后序前驱 pre
①若 p->ltag==1,则 pre = p->lchild
②若 p->ltag==0
在后序线索二叉树中找到指定结点*p 的后序后继 next
①若 p->rtag==1,则 next = p->rchild
②若 p->rtag==0
5.4 树和森林
5.4.1 树的存储结构
树的存储1:双亲表示法
用数组顺序存储各结点,每个结点中保存数据元素、指向双亲结点(父结点)的“指针”
#define MAX_TREE_SIZE 100
// 树的结点
typedef struct{
ElemType data;
int parent;
}PTNode;
// 树的类型
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int n; //结点数量
}PTree;
优点:找双亲(父结点)很方便
缺点:找孩子不方便,只能从头到尾遍历整个数组
树的存储2:孩子表示法(顺序+链式存储)
#define MAX_TREE_SIZE 100
struct CTNode{
int child; //孩子结点在数组中的位置
struct CTNode *next; //下一个孩子
}
typedef struct{
ElemType data;
struct CTNode *firstChild; //第一个孩子
}CTBox;
typedef struct{
CTBox node[MAX_TREE_SIZE];
int n,r; //结点数和根的位置
}CTree;
优点:找孩子很方便
缺点:找双亲(父结点)不方便,只能遍历每个链表
树的存储3:孩子兄弟表示法
树的孩子兄弟表示法与二叉树类似,采用二叉链表实现,每个结点内保存数据元素和两个指针,但两个指针的含义和二叉树结点不同
//孩子兄弟表示法结点
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟结点
}CSNode, *CSTree;
5.4.2 树、森林与二叉树的转换
树到二叉树的转换
①先在二叉树中,画一个根节点。
②按“树的层序”依次处理每个结点。
处理一个结点的方法是:如果当前处理的结点在树中有孩子,就把所有孩子结点 “用右指针串成糖葫芦”,并在二叉树中把第一个孩子挂在当前结点的左指针下方
森林到二叉树的转换
①先把所有树的根结点画出来,在二叉树中用右指针串成糖葫芦。
②按“森林的层序”依次处理每个结点。
处理一个结点的方法是:如果当前处理的结点在树中有孩子,就把所有孩子结点“用右 指针串成糖葫芦”,并在二叉树中把第一个孩子挂在当前结点的左指针下方
注意:森林中各棵树的根节点视为平级的兄弟关系
二叉树到树的转换
①先画出树的根节点
②从树的根节点开始,按“树的层序”恢复每个结点的孩子
如何恢复一个结点的孩子:在二叉树中,如果当前处理的结点有左孩子,就把左孩 子和“一整串右指针糖葫芦” 拆下来,按顺序挂在当前结点的下方
二叉树到森林的转换
①先把二叉树的根节点和“一整串右指针糖葫芦”拆下来,作为多棵树的根节点
②按“森林的层序”恢复每个结点的孩子
如何恢复一个结点的孩子:在二叉树中,如果当前处理的结点有左孩子,就把左孩子和“一整串右指针糖葫 芦” 拆下来,按顺序挂在当前结点的下方
5.4.3 树和森林的遍历
树的先根遍历。若树非空,先访问根结点, 再依次对每棵子树进行先根遍历。(深度优先遍历)
void PreOrder(TreeNode *R){
if(R!=NULL){
visit(R);
while(R还有下一个子树T)
PreOrder(T);
}
}
树的先根遍历序列与这棵树相应二叉树的先序序列相同
树的后根遍历。若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。(深度优先遍历)
树的后根遍历序列与这棵树相应二叉树的中序序列相同。
树的层次遍历(用队列实现):(广度优先遍历)
①若树非空,则根节点入队
②若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
③重复②直到队列为空
森林的先序遍历
森林。森林是m (m>0)棵互不相交的树的集合。每棵树去掉根节点后,其各个子树又组成森林。
若森林为非空,则按如下规则进行遍历:
- 访问森林中第一棵树的根结点。
- 先序遍历第一棵树中根结点的子树森林。
- 先序遍历除去第一棵树之后剩余的树构成的森林。
效果等同于依次对各个树进行先根遍历,等同于对二叉树的先序遍历。
森林的中序遍历
- 中序遍历森林中第一棵树的根结点的子树森林。
- 访问第一棵树的根结点。
- 中序遍历除去第一棵树之后剩余的树构成的森林
效果等同于依次对各个树进行后根遍历,等同于对二叉树的中序遍历。
5.5 树与二叉树的应用
5.5.1 哈夫曼树
结点的权:有某种现实含义的数值。
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL,Weighted Path Length)
哈夫曼树的定义:在含有 n 个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树
哈夫曼树的构造
给定n个权值分别为w1, w2,..., wn的结点,构造哈夫曼树的算法描述如下:
1) 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F.
2) 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
3) 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
4) 重复步骤2)和3),直至F中只剩下一棵树为止。
哈夫曼树的性质
1) 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
2) 哈夫曼树的结点总数为 2n−1。
3) 哈夫曼树中不存在度为 1 的结点。
4) 哈夫曼树并不唯一,但 WPL 必然相同且为最优。
哈夫曼编码
固定长度编码――每个字符用相等长度的二进制位表示
可变长度编码――允许对不同字符用不等长的二进制位表示
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码
有哈夫曼树得到哈夫曼编码――字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树
哈夫曼编码可用于数据压缩
5.5.2_1 并查集
逻辑结构:数据元素之间为“集合”关系
集合的两个基本操作——“并”和“查”
Find ——“查”操作:确定一个指定元 素所属集合
Union ——“并”操作:将两个不想交 的集合合并为一个
注:并查集(Disjoint Set)是逻辑结构——集合的一种具体实现,只进行 “并”和“查”两种基本操作
“并查集”的存储结构
“并查集”的代码实现——初始化
#define SIZE 13
int uFsets [ SIZE]; //集合元素数组
//初始化并查集
void Initial (int S[]){
for(int i=0;i<SIZE;i++)
s[i]=-1;
}
“并查集”的代码实现——并、查
//Find“查”操作,找x所属集合(返回x所属根结点)
int Find (int S[],int x){
while(S[x]>=0) //循环寻找x的根
x=S[x] ;
return x; //根的s[]小于0
)
// union“并”操作,将两个集合合并为一个
void union(int S[],int Root1,int Root2){
//要求Root1与Root2是不同的集合
if(Root1==Root2)return;
//将根Root2连接到另一根Root1下面
S[Root2]=Root1;
Union操作的优化
优化思路:在每次Union操作构建树的时候,尽可能让树不长高
①用根节点的绝对值表示树的结点总数
②Union操作,让小树合并到大树
// Union“并”操作,小树合并到大树
void Union (int S[],int Root1,int Root2 ){
if(Root1==Root2 ) return;
if(S[Root2]>S[Root1]) { //Root2结点数更少
S[Root1]+=S[Root2]; //累加结点总数
S[Root2]=Root1; //小树合并到大树
}else{
S[Root2]+=S[Root1]; //累加结点总数
S[Root1]=Root2; //小树合并到大树
}
}
5.5.2_2 并查集的进一步优化
拓展:Find操作的优化(压缩路径)
先找到根结点,再将查找路径上所有结点都挂到根结点下
//Find“查"操作优化,先找到根节点,再进行“压缩路径”
int Find(int S[],int x){
int root = x;
while(S[root]>=0) root=S[root]; //循环找到根
while(x! =root){ //压缩路径
int t=S[x]; //t指向x的父节点
S[x]=root; //x直接挂到根节点下
x=t;
}
return root; //返回根节点编号
}
六、图
6.1 图的基本概念
图的定义
图:图G由顶点集V和边集E组成,记为G = (V, E),其中V(G)表示图G中顶点的有限非空集;E(G) 表示图G中顶点之间的关系(边)集合。若V = {v1, v2, … , vn},则用|V|表示图G中顶点的个 数,也称图G的阶,,用|E|表示图G中边的条数。
注意:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集
无向图:若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v, w)或(w, v),因为(v, w) = (w, v),其 中v、w是顶点。可以说顶点w和顶点v互为邻接点。边(v, w) 依附于顶点w和v,或者说边(v, w)和顶点v、w相关联
有向图:若E是有向边(也称弧)的有限集合时,则图G为有向图。 弧是顶点的有序对,记为<v,w>,其中v、w是顶点,v称为弧尾,w称为弧头,<v,w>称为从顶点v到顶点w的弧,也称 v邻接到w,或w邻接自v。<v,w> ≠<w,v>
简单图——① 不存在重复边; ② 不存在顶点到自身的边 (数据结构课程只探讨 “简单图”)
多重图——图G中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联
顶点的度、入度、出度
无向图:顶点v的度是指依附于该顶点的边的条数,记为TD(v)。
在具有n个顶点、e条边的无向图中, 即无向图的全部顶点的度的和等于边数的2倍
有向图:入度是以顶点v为终点的有向边的数目,记为ID(v);
出度是以顶点v为起点的有向边的数目,记为OD(v)。
顶点v的度等于其入度和出度之和,即TD(v) = ID(v) + OD(v)。
在具有n个顶点、e条边的有向图中,,即入度和出度的数量相等且等于e
顶点的关系描述
路径——顶点vp到顶点vq之间的一条路径是指顶点序列,
回路——第一个顶点和最后一个顶点相同的路径称为回路或环
简单路径——在路径序列中,顶点不重复出现的路径称为简单路径。
简单回路——除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
路径长度——路径上边的数目
点到点的距离——从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。 若从u到v根本不存在路径,则记该距离为无穷(∞)。
无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的
有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的
图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。
若图中任何一对顶点都是强连通的,则称此图为强连通图。
研究图的局部—子图、生成子图
设有两个图G = (V, E)和G ′ = (V ′ , E ′ ),若V ′ 是V的子集,且 E ′ 是 E的子集,则称G ′ 是G的子图
若有满足V(G ′ ) = V(G)的子图G ′ ,则称其为G的生成子图
有向图的子图和生成子图也是一样的
无向图中的极大连通子图称为连通分量
子图必须连通,且包含尽可能多的顶点和边
有向图中的极大强连通子图称为有向图的强连通分量
子图必须强连通,同时 保留尽可能多的边
生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图。
若图中顶点数为n,则它的生成树含有 n-1 条边。对生成树而言,若砍去它的一条边,则会变成非连通 图,若加上一条边则会形成一个回路。(因此边要尽可能的少,但要保持连通)
生成森林:在非连通图中,连通分量的生成树构成了非连通图的生成森林
边的权、带权图/网
边的权——在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
带权图/网——边上带有权值的图称为带权图,也称网。
带权路径长度——当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
特殊形态的图
无向完全图——无向图中任意两个顶点之间都存在边
若无向图的顶点数|V|=n,则
有向完全图——有向图中任意两个顶点 之间都存在方向相反的两条弧
若有向图的顶点数|V|=n,则
稀疏图:边数很少的图称为稀疏图 反之称为稠密图
树——不存在回路,且连通的无向图
n个顶点的树,必有n-1条边。
常见考点:n个顶点的图,若 |E|>n-1,则一定有回路
有向树——一个顶点的入度为0、其余顶点的 入度均为1的有向图,称为有向树
6.2 图的存储及基本操作
6.2.1 邻接矩阵法
邻接矩阵存储无向图、有向图
#define MaxVertexNum 100 //顶点数目的最大值
typedef struct{
char Vex[MaxVertexNum]; //顶点表
int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vexnum,arcnum; //图的当前顶点数和边数
}MGraph;
第i个结点的度 = 第i行(或第i列)的非零元素个数
第i个结点的出度 = 第i行的非零元素个数
第i个结点的入度 = 第i列的非零元素个数
第i个结点的度 = 第i行、第i列的非零元素个数之和
邻接矩阵法求顶点的度/出度/入度的时间复杂度为O(|V|)
邻接矩阵法存储带权图
#define MaxVertexNum 100 //顶点数目的最大值
#define INFINITY 2147483647; //表示“无穷”
typedef char VertexType; //顶点数据类型
typedef int EdgeType; //边数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点表
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //边的权值
int vexnum,arcnum; //图的当前顶点数和弧数
}MGraph;
邻接矩阵法的性能分析
空间复杂度:O(|V|^2) ——只和顶点数相关,和实际的边数无关
适合用于存储稠密图
无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角区/下三角区)
邻接矩阵法的性质
6.2.2 邻接表法
邻接表法(顺序+链式存储)
#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;
邻接表 | 邻接矩阵 | |
空间复杂度 | 无向图 O(|V| + 2|E|) ;有向图O(|V| + |E|) | O(|V|^2 |
适合用于 | 存储稀疏图 | 存储稠密图 |
表示方式 | 不唯一 | 唯一 |
计算度/出度/入度 | 计算有向图的度、入度不方便,其余很方便 | 必须遍历对应行或列 |
找相邻的边 | 找有向图的入边不方便,其余很方便 | 必须遍历对应行或列 |
6.2.3 十字链表
十字链表存储有向图
#define MAX_VERTEX_NUM 20 //最大顶点数量
typedef struct ArcBox{ //弧结点
int tailvex, headvex; //弧尾,弧头顶点编号(一维数组下标)
struct ArcBox *hlink, *tlink; //弧头相同、弧尾相同的下一条弧的链域
InfoType info; //权值
}ArcBox;
typedef struct VexNode{ //顶点结点
VertexType data; //顶点数据域
ArcBox *firstin, *firstout; //该顶点的第一条入弧和第一条出弧
}VexNode;
typedef struct{ //有向图
VexNode xlist[MAX_VERTEX_NUM]; //存储顶点的一维数组
int vexnum, arcnum; //有向图的当前顶点数和弧数
}OLGraph;
十字链表法性能分析
空间复杂度:O(|V|+|E|)
顺着绿色线路找可以找到指定顶点的所有出边
顺着橙色线路找可以找到指定顶点的所有入边
注意:十字链表只用于存储有向图
6.2.4 邻接多重表
#define MAX_VERTEX_NUM 20 //最大顶点数量
struct EBox{ //边结点
int i,j; //该边依附的两个顶点的位置(一维数组下标)
EBox *ilink,*jlink; //分别指向依附这两个顶点的下一条边
InfoType info; //边的权值
};
struct VexBox{
VertexType data;
EBox *firstedge; //指向第一条依附该顶点的边
};
struct AMLGraph{
VexBox adjmulist[MAX_VERTEX_NUM];
int vexnum,edgenum; //无向图的当前顶点数和边数
};
空间复杂度:O(|V|+|E|)
删除边、删除节点等操 作很方便
注意:邻接多重表只适 用于存储无向图
6.2.5 图的基本操作
- Adjacent(G,x,y):判断图G是否存在边<x, y>或(x, y)。(<>表示有向图,()表示无向图)
- Neighbors(G,x):列出图G中与结点x邻接的边。
- lnsertVertex(G,x):在图G中插入顶点x。
- DeleteVertex(G,x):从图G中删除顶点x。
- AddEdge(G,x,y):若无向边(x,y)或有向边<x, y>不存在,则向图G中添加该边。RemoveEdge(G,x,y):若无向边(x, y)或有向边<x, y>存在,则从图G中删除该边。
- FirstNeighbor(G,x):求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1。
- NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。
- Get_edge_value(G,x,y):获取图G中边(x, y)或<x, y>对应的权值。
- Set edge value(G,x,y,v):设置图G中边(x, y)或<x, y>对应的权值为v。
6.3 图的遍历
6.3.1 图的广度优先遍历
⼴度优先遍历(Breadth-First-Search, BFS)要点:
1. 找到与⼀个顶点相邻的所有顶点
2. 标记哪些顶点被访问过
3. 需要⼀个辅助队
FirstNeighbor(G,x):求图G中顶点x的第⼀个邻接点,若有则返回顶点号。 若x没有邻接点或图中不存在x,则返回-1。
NextNeighbor(G,x,y):假设图G中顶点y是顶点x的⼀个邻接点,返回除y之外 顶点x的下⼀个邻接点的顶点号,若y是x的最后⼀个邻接点,则返回-1
bool visited[MAX_VERTEX_NUM]; //访问标记数组 初始都为false
void BFSTraverse(Graph G){ // 对图G进行广度优先遍历
for(i=0; i<G.vexnum; ++i)
visited[i]=FALSE; //访问标记数组初始化
InitQueue(Q); //初始化辅助队列
for(i=0; i<G.vexnum; ++i) //从0号结点开始遍历
if(!visited[i]) //对每个连通分量进行一次广度优先遍历
BFS(G,i); //vi未访问过,从vi开始BFS
}
//广度优先遍历
void BFS(Graph G,int v){ //从顶点v开始广度优先遍历图G
visit(G,v); //访问图G的结点v
visited[v]=TREE; //对v做已访问标记
EnQueue(Q,v); //顶点v入队列Q
while(!isEmpty(Q)){
DeQueue(Q,v); //队列头节点出队并将头结点的值赋给v
for(w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w)){
//检测v的所有邻结点
if(!visited[w]){ //w为v的尚未访问的邻接顶点
visit(w); //访问顶点w
visited[w]=TREE;//对w做已访问标记
EnQueue(Q,w); //顶点w入队列
}
}
}
}
复杂度分析
空间复杂度:最坏情况,辅助队列大小为O(|V|)
邻接矩阵存储的图:
访问 |V| 个顶点需要O(|V|)的时间
查找每个顶点的邻接点都需要O(|V|)的时间,⽽总共有|V|个顶点
时间复杂度= O(|V|^2)
邻接表存储的图:
访问 |V| 个顶点需要O(|V|)的时间
查找各个顶点的邻接点共需要O(|E|)的时间
时间复杂度= O(|V|+|E|)
广度优先生成树由广度优先 遍历过程确定。
由于邻接表的表示方式不唯⼀,因此基 于邻接表的广度优先生成树 也不唯⼀。
对非连通图的广度优先遍历,可得到广度优先生成森林
6.3.1 图的深度优先遍历
bool visited[MAX_VERTEX_NUM]; //访问标记数组
// 对图G进行深度优先算法
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);
}
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visit(G,v); //访问顶点v
visited[v]=TREE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v)){
if(!visited[w])
DFS(G,v); //w为u的尚未访问的邻接顶点
}
}
复杂度分析
空间复杂度:来自函数调用栈
最坏情况递归深度为O(|V|)
最好情况O(1)
时间复杂度=访问各结点所需时间+探索各条边所需时间
邻接矩阵存储的图:
访问|V|个顶点需要O(|V|)的时间
查找每个顶点的邻接点都需要O(|V|)的时间,而总共有|N个顶点时间复杂度=O(|V|^2)
邻接表存储的图:
访问V个顶点需要O(|V|)的时间
查找各个顶点的邻接点共需要O(E)的时间,时间复杂度=O(|V|+|E|)
同一个图的邻接矩阵表示方式唯一,因此深度优先遍历序列唯一
同一个图邻接表表示方式不唯一,因此深度优先遍历序列不唯一
图的遍历与图的连通性
6.4 图的应用
6.4.1 最小生成树
对于⼀个带权连通⽆向图G = (V, E),⽣成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有⽣成树的集合,若T为R中边的权值之和最小的生成树,则T称为G的最小生成树(Minimum-Spanning-Tree, MST)。
最小生成树可能有多个,但边的权值之和总是唯一且最小的
最小生成树的边数=顶点数–1。砍掉一条则不连通,增加一条边则会出现回路
求最小生成树
Prim算法(普里姆):
从某一个顶点开始构建生成树;
每次将代价最小的新顶点纳入生成树,
直到所有顶点都纳入为止。
Kruskal算法(克鲁斯卡尔):
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选)
直到所有结点都连通
Prim算法的实现思想
第1轮:循环遍历所有个结点,找到lowCost最低的,且还没加入树的顶点
第2轮︰循环遍历所有个结点,找到lowCost最低的,且还没加入树的顶点
第3轮:循环遍历所有个结点,找到lowCost最低的,且还没加入树的顶点
第4轮:循环遍历所有个结点,找到lowCost最低的,且还没加入树的顶点
第5轮:循环遍历所有个结点,找 到lowCost最低的,且还没加入树的顶点
Kruskal 算法的实现思想
第1轮:检查第1条边的两个顶点是否 连通(是否属于同⼀个集合)
第2轮:检查第2条边的两个顶点是否 连通(是否属于同⼀个集合)
第3轮:检查第3条边的两个顶点是否 连通(是否属于同⼀个集合)
第4轮:检查第4条边的两个顶点是否 连通(是否属于同⼀个集合)
第5轮:检查第5条边的两个顶点是否 连通(是否属于同⼀个集合)
第6轮:检查第6条边的两个顶点是否 连通(是否属于同⼀个集合)
6.4.2_1 最短路径问题_BFS算法
BFS求无权图的单源最短路径
#define MAX_LENGTH 2147483647 //地图中最大距离,表示正无穷
//求顶点u到其他顶点的最短路径
void BFS_MIN_Disrance(Graph G,int u){
//d[i]表示从u到i结点的最短路径
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]){ //w为u的尚未访问的邻接顶点
d[w]=d[u]+1; //路径长度+1
path[w]=u; //最短路径应从u到w
visited[w]=TREE; //设已访问标记
EnQueue(Q,w); //顶点w入队
}
}
}
}
6.4.2_2 最短路径问题_Dijkstra算法
使用 Dijkstra算法求最短路径问题,需要使用三个数组:
final[]数组用于标记各顶点是否已找到最短路径。
dist[]数组用于记录各顶点到源顶点的最短路径长度。
path[]数组用于记录各顶点现在最短路径上的前驱。
算法思想:循环遍历所有结点,找到还没确定最短路径、且dist最小的顶点Vi,令final[i]=ture。
检查所有邻接自Vi的顶点,若其final值为false,则更新dist和path信息。
#define MAX_LENGTH = 2147483647;
// 求顶点u到其他顶点的最短路径
void BFS_MIN_Disrance(Graph G,int u){
for(int i=0; i<G.vexnum; i++){ //初始化数组
final[i]=FALSE;
dist[i]=G.edge[u][i];
if(G.edge[u][i]==MAX_LENGTH || G.edge[u][i] == 0)
path[i]=-1;
else
path[i]=u;
final[u]=TREE;
}
for(int i=0; i<G.vexnum; i++){
int MIN=MAX_LENGTH;
int v;
// 循环遍历所有结点,找到还没确定最短路径,且dist最⼩的顶点v
for(int j=0; j<G.vexnum; j++){
if(final[j]!=TREE && dist[j]<MIN){
MIN = dist[j];
v = j;
}
}
final[v]=TREE;
// 检查所有邻接⾃v的顶点路径长度是否最短
for(int j=0; j<G.vexnum; j++){
if(final[j]!=TREE && dist[j]>dist[v]+G.edge[v][j]){
dist[j] = dist[v]+G.edge[v][j];
path[j] = v;
}
}
}
}
Dijkstra算法能够很好的处理带权图的单源最短路径问题,但不适⽤于有负权值的带权图。
6.4.2_3 最短路径问题_Floyd算法
Floyd算法:求出每⼀对顶点之间的最短路径,使⽤动态规划思想,将问题的求解分为多个阶段。
Floyd算法使用到两个矩阵:
dist[]:目前各顶点间的最短路径。
path[]:两个顶点之间的中转点。
//...准备工作,根据图的信息初始化矩阵A和path
for(k=0;k<n;k++){ //考虑以Vk作为中转点
for(i=0;i<n;i++){ //遍历整个矩阵,i为行号,j为列号
for(j=0;j<n;j++){
if(A[i][j]>A[i][k]+A[k][j]){ //以Vk为中转地按的路径更短
A[i][j]=A[i][k]+A[k][j]; //更新最短路径长度
path[i][j]=k; //中转点
}
}
}
}
Floyd算法不能解决带有“负权回路”的图(有负权值的边组成回路),这种图有可能没有最短路径
最短路径算法比较:
BFS算法 | Dijkstra算法 | Floyd算法 | |
无权图 | ✔ | ✔ | ✔ |
带权图 | ✘ | ✔ | ✔ |
带负权值的图 | ✘ | ✘ | ✔ |
带负权回路的图 | ✘ | ✘ | ✘ |
时间复杂度 | O(|V|^2)或O(|V|+|E|) | O(|V|^2) | O(|V|^3) |
通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
6.4.3 有向无环图描述表达式
有向无环图:若一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclic Graph)
解题方法
练习
6.4.4 拓扑排序
AOV网(Activity on vertex Network,用顶点表示活动的网):
用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边<Vi,Vj>表示活动Vi必须先于活动Vj进行
拓扑排序︰在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
①每个顶点出现且只出现一次。
②若顶点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; //拓扑排序成功
}
拓扑排序 每个顶点都需要处理一次 每条边都需要处理一次
时间复杂度:O(|V|+|E|)
若采用邻接矩阵,则需O(|V^2|)
逆拓扑排序
对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
①从AOV网中选择一个没有后继(出度为O)的顶点并输出。
②从网中删除该顶点和所有以它为终点的有向边。
③重复①和②直到当前的AOV网为空。
逆拓扑排序的实现(DFS算法)
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0; v<G.vexnum;++V)
visited[v]=FALSE; //初始化已访问标记数据
for(v=;v<G.vexnum; ++V) //本代码中是从v=0开始遍历
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visited [v]=TRUE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0 ; w=NextNeighor(G,v,w))
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
}//if
print(v); //输出顶点
}
6.4.5 关键路径
AOE 网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称 AOE 网 (Activity On Edge NetWork)。
AOE网具有以下两个性质:
①只有在某顶点所代表的事件发⽣后,从该顶点出发的各有向边所代表的活动才能开始;
②只有在进⼊某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发⽣。
在 AOE 网中仅有⼀个入度为 0 的顶点,称为开始顶点(源点),它表示整个⼯程的开始; 也仅有⼀个出度为 0 的顶点,称为结束顶点(汇点),它表示整个⼯程的结束。
在AOE网中,有些活动是可以并行进行的。从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。
寻找冠军活动时所用到的几个参量的定义:
1. 事件 vk的最早发生时间 ve(k):决定了所有从vk 开始的活动能够开工的最早时间。
2. 事件 vk的最迟发⽣时间 vl(k):它是指在不推迟整个工程完成的前提下,该事件最迟必须发⽣的时间。
3.活动ai的最早开始时间e(i):指该活动弧的起点所表示的事件的最早发生时间。
4.活动ai的最迟开始时间l(i):它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
5.活动 ai的时间余量d(i)=l(i)−e(i),表示在不增加完成整个⼯程所需总时间的情况下,活动ai可以拖延的时间若⼀个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0 即l(i)=e(i) 的活动ai是关键活动,由关键活动组成的路径就是关键路径。
求关键路径的步骤:
1.求所有事件的最早发生时间ve():按拓扑排序序列,依次求各个顶点的ve(k), ve(源点)=0, ve(k) =Max{vef)+W eight(vj , vk )},vj为vk的任意前驱。
2.求所有事件的最迟发生时间vl():按逆拓扑排序序列,依次求各个顶点的 vl(k),vl(汇点)= ve(汇点),vl(k)=Min{vl(j)- W eight(vk , vi)},v为Vv\k 的任意后继。
3.求所有活动的最早发生时间e():若边<Vk ,Vj>表示活动a;,则有e(i)=ve(k)。
4.求所有活动的最迟发生时间1():若边<Vk,Vj >表示活动a,则有l(i)= vl(j)- Weight(vi , vj)。
5.求所有活动的时间余量d(): d(i)= l(i)-e(i)。
关键活动、关键路径的特性:
1.若关键活动耗时增加,则整个工程的工期将增长。
2.缩短关键活动的时间,可以缩短整个工程的工期。
3.当缩短到一定程度时,关键活动可能会变成非关键活动。
4.可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。
七、查找
7.1 查找的基本概念
7.1.1 基本概念
查找 —— 在数据集合中寻找满⾜某种条件的数据元素的过程称为查找
查找表(查找结构)—— ⽤于查找的数据集合称为查找表,它由同⼀类型的数据元素(或记录)组成
关键字 —— 数据元素中唯⼀标识该元素的某个数据项的值,使⽤基于关键字的查找,查找结果应该是唯⼀的
7.1.2 对查找表的常见操作
① 查找符合条件的数据元素
② 插入、删除某个数据元素
7.2.3 查找算法的评价指标
查找长度——在查找运算中,需要对比关键字的次数称为查找长度
平均查找长度(ASL, Average Search Length)——所有查找过程中进行关键字的比较次数的平均值
ASL 的数量级反应了 查找算法时间复杂度
”
7.2 顺序查找和折半查找
7.2.1 顺序查找
顺序查找:⼜叫“线性查找”,通常⽤于线性表。
算法思想:从头到尾挨个找(或者从尾到头)。
代码实现:
typedef struct{ //查找表的数据结构(顺序表)
ElemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//顺序查找
int Search_Seq(SSTable ST,ElemType key){
int i;
for(i=0;i<ST.TableLen && ST.elem[i]!=key;++i);
// 查找成功返回数组下标,否则返回-1
return i=ST.TableLen? -1 : i;
}
“哨兵”方式实现
typedef struct{ //查找表的数据结构(顺序表)
ElemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//顺序查找
int Search_Seq(SSTable ST,ElemType key){
ST.elem[0]=key;
int i;
for(i=ST.TableLen;ST.elem[i]!=key;--i)
// 查找成功返回数组下标,否则返回0
return i;
}
查找效率:查找成功和查找失败都是O(n)的数量级
一个成功结点的查找长度=自身所在层数
一个失败结点的查找长度=其父节点所在层数
默认情况下,各种失败情况或成功情况都等概率发生
7.2.2 折半查找
折半查找:⼜称“⼆分查找”,仅适⽤于有序的顺序表。(顺序表拥有随机访问的特性,链表没有)
typedef struct{
ElemType *elem;
int TableLen;
}SSTable;
// 折半查找
int Binary_Search(SSTable L,ElemType key){
int low=0,high=L.TableLen,mid;//先让low和high分别指向队头和队尾的位置
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; //查找失败
}
查找效率分析
折半查找的判定树一定是平衡二叉树
折半查找的判定树中(只有最下面一层是不满的,因此,元素个数为n时树高h =[log2(n+1)]
7.2.3 分块查找
分块查找的特点:块内⽆序、块间有序
分块查找,⼜称索引顺序查找,算法过程如下:
①在索引表中确定待查记录所属的分块(可顺序、可折半)
②在块内顺序查找
// 索引表
typedef struct{
ElemType maxValue;
int low,high;
}Index;
// 顺序表存储实际元素
ElemType List[100];
若索引表中不包含目标关键字,则折半查找索引表最终停在low>high,要在low所指分块中查找
7.3 树形查找
7.3.1 二叉排序树
二叉排序树的定义:二叉排序树,又称二叉查找校BST, Binary Search Tree)
一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
左子树上所有结点的关键字均小于根结点的关键字;
右子树上所有结点的关键字均大于根结点的关键字。
左子树和右子树又各是一棵二叉排序树。
左子树结点值<根结点值<右子树结点值
进行中序遍历,可以得到一个递增的有序序列
二叉排序树的查找
二叉排序树的删除
先搜索找到目标结点:
①若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。
②若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置。
③若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
查找效率分析
查找长度――在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度
若树高h,找到最下层的一个结点需要对比h次
最好情况:n个结点的二叉树最小高度为[log2n]+1,平均查找长度=O(log2n)
最坏情况:每个结点只有一个分支,树高h=结点数n,平均查找长度=O(n)
7.3.2_1 平衡二叉树
平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)―一树上任一结点的左子树和右子树的高度之差不超过1。
结点的平衡因子=左子树高-右子树高。
平衡二叉树的插入
在插入操作中,只要将最小不平衡子树调整平衡,则其他祖先结点都会恢复平衡
调整最小不平衡子树LL
1)LL平衡旋转(右单旋转)。由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
调整最小不平衡子树RR
2)RR平衡旋转(左单旋转)。由于在结点A的右孩子(R)的右子树(R)插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩8向左上旋扭代替A成为根结点,将A结点向左下旋转戊为B的左子树的根结点,而B的原左子树则作为A结点的右子树
调整最小不平衡子树LR
3)LR平衡旋转(先左后右双旋转)。由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树去去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点c向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置
调整最小不平衡子树LR
4)RL平衡旋转(先右后左双旋转)。由于在A的右孩子(R)的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点c向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置
练习:插入90 右孩子左旋
练习:插入63 右旋+左旋
练习
查找效率分析
若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过o(h)
7.3.2_2 平衡二叉树的删除
平衡二叉树的删除操作:
删除结点后,要保持二叉排序树的特性不变(左<中<右)
若删除结点导致不平衡,则需要调整平衡
平衡二叉树的删除操作具体步骤:
①删除结点(方法同“二叉排序树”)
若删除的结点是叶子,直接删。
若删除的结点只有一个子树,用子树顶替删除位置
若删除的结点有两棵子树,用前驱(或后继)结点顶替,并转换为对前驱(或后继)结点的删除。
②一路向上找到最小不平衡子树,找不到就完结撒花
③找最小不平衡子树下,“个头”最高的儿子、孙子
④根据孙子的位置,调整平衡(LL/RR/LR/RL)
⑤如果不平衡向上传导,继续②
对最小不平衡子树的旋转可能导致树变矮,从而导致上层祖先不平衡(不平衡向上传递)
AVL树删除操作——例1
删除结点9,往上找发现都没有最小不平衡子树,无需再做处理
AVL树删除操作——例2
删除结点55 往上找发现75不平衡 ,需要找到个头最高的儿子、孙子
④根据孙子的位置,调整平衡(LL/RR/LR/RL)
孙子在LL:儿子右单旋
孙子在RR:儿子左单旋
孙子在LR:孙子先左旋,再右旋
孙子在RL:孙子先右旋,再左旋
AVL树删除操作——例3
删除叶子结点33 往上找最小不平衡子树
例1到例3为重点,例4看一下,后面的了解就行
AVL树删除操作——例4
删除结点32
向上找到最小不平衡子树 先右旋再左旋树变矮
树恢复平衡,但由于树高变矮,不平衡向上传导
需要继续回到②操作
AVL树删除操作——例5
被删除结点有左右子树,用前驱结点顶替(复制数据即可)
并转化为对前驱结点的删除 即转变为对60这个结点的删除
删除的结点只有55这一个子树,则直接用子树顶替删除位置,最后如下,
接下来找到最小不平衡子树
7.3.3_1 红黑树的定义和性质
平衡二叉树AVL:插入/删除很容易破坏“平衡”特性,需要频繁调整树的形态。如:插入操作导致不平衡,则需要先计算平衡因子,找到最小不平衡子树(时间开销大),再进行LL/RR/LR/RL调整
红黑树RBT:插入/删除很多时候不会破坏“红黑”特性,无需频繁调整树的形态。即便需要调整,一般都可以在常数级时间内完成
平衡二叉树:适用于以查为主、很少插入/删除的场景
红黑树:适用于频繁插入、删除的场景,实用性更强
红黑树的定义
红黑树是二叉排序树一左子树结点值≤根结点值≤右子树结点值
①每个结点或是红色,或是黑色的②根节点是黑色的
③叶结点(外部结点、NULL结点、失败结点)均是黑色的
④不存在两个相邻的红结点(即红结点的父节点和孩子结点均是黑色)
⑤对每个结点,从该节点到任一叶结点的简单路径上,所含黑结点的数目相同
左根右,根叶黑,不红红,黑路同
结点的黑高bh――从某结点出发(不含该结点)到达任一空叶结点的路径上黑结点总数
红黑树的性质
性质1:从根节点到叶结点的最长路径不大于最短路径的2倍
性质2:有n个内部节点的红黑树高度h≤ 2log2(n +1)
红黑树的查找,与BST、AVL相同,从根出发,左小右大,若查找到一个空叶节点,则查找失败
7.3.3_2 红黑树的插入
先查找,确定插入位置(原理同二叉排序树),插入新结点
新结点是根――染为黑色
新结点非根――染为红色
·若插入新结点后依然满足红黑树定义,则插入结束
·若插入新结点后不满足红黑树定义,需要调整(看新结点叔叔的脸色),使其重新满足红黑树定义
·黑叔:旋转+染色
·LL型:右单旋,父换爷+染色
·RR型:左单旋,父换爷+染色
·LR型:左、右双旋,儿换爷+染色
·RL型:右、左双旋,儿换爷+染色
·红叔:染色+变新
·叔父爷染色,爷变为新结点
从一棵空的红黑树开始,插入:20,10,5,30, 40,57,3,2,4,35,25,18,22,23,24,19,18
红叔:染色+变新
黑叔:旋转+染色
LR型:左、右双旋,儿换爷+染色
插入24
破坏“ 不红红”,红叔:染色+变新
插入19
插入18
黑叔:旋转+染色
RL型:右、左双旋,儿换爷+染色
|
7.3.3_3 红黑树的删除
7.4 B树 B+树
5叉查找树
//5叉排序树的结点定义
struct Node {
ElemType keys[4]; //最多4个关键字
struct Node &child[5]; //最多5个孩子
int num; //结点中有几个关键字
};
如何保证查找效率?
eg:对于5叉排序树,规定除了根节点处。任何结点都至少有3个分叉,2个关键字
策略: ①m叉查找树中,规定除了根节点外,任何结点至少有个分叉,即至少含有个关键字
②m叉查找树中,规定对于任何一个结点,其所有子树的高度都要相同。
7.4.1 B树
B树,又称多路平衡查找树,B树中所被允许的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树或为空树,或为满足如下特性的m叉树:
1)树中每个结点至多有m棵子树,即至多含有m-1个关键字。
2)若根结点不是终端结点,则至少有两棵子树。
3)除根结点外的所有非叶结点至少有棵子树,即至少含有个关键字。
5)所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
7.4.1_2 B树的插入和删除
B树的插入
7.4.1_3 B+树
一棵m阶的B+树需满足下列条件:
1)每个分支结点最多有m棵子树(孩子结点)。
2)非叶根结点至少有两棵子树,其他每个分支结点至少有「m/2]棵子树。
3)结点的子树个数与关键字个数相等。
4)所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。
5)所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。
B+树 VS B树
m阶B+树:
1)结点中的n个关键字对应n棵子树
2)根节点的关键字数n∈[1, m]
其他结点的关键字数
3)在B+树中,叶结点包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中
4)在B+树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。
m阶B树:
1)结点中的n个关键字对应n+1棵子树
2)根节点的关键字数n∈[1, m-1]。
其他结点的关键字数
3)在B树中,各结点中包含的关键字是不重复的
4)B树的结点中都包含了关键字对应的记录的存储地址
在B+树中,非叶结点不含有该关键字对应记录的存储地址。
可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,
读磁盘次数更少,查找更快
7.5 散列表
7.5.1 散列表的基本概念
散列表(哈希表,Hash Table)︰是一种数据结构。特点是∶可以根据数据元素的关键字计算出它在散列表中的存储地址
散列函数(哈希函数)︰Addr=H(key)建立了“关键字”→“存储地址”的映射关系
冲突(碰撞)︰在散列表中插入一个数据元素时,需要根据关键字的值确定其存储地址,若该地址已经存储了其他元素,则称这种情况为“冲突(碰撞)”
同义词:若不同的关键字通过散列函数映射到同一个存储地址,则称它们为“同义词”
如何减少“冲突”?
对于散列函数 H(key)=key%13 来说,1 和 14 是“同义词”,可以构造更适合的散列函数,让各个关键字尽可能地映射到不同的存储位置,从而减少“冲突”
Eg:把散列函数改为H(key)=key%12,则不发生冲突
19%12=7
14%12=2
23%12=11
1%12=1
如何处理冲突?
拉链法(又称链接法、链地址法)︰把所有“同义词”存储在一个链表中。
开放定址法:如果发生“冲突”,就给新元素找另一个空闲位置。
7.5.2 散列函数的构造
散列函数(哈希函数)︰Addr=H(key)建立了“关键字”→“存储地址”的映射关系。
设计散列函数时应该注意:
除留余数法——H(key) = key % p
散列表表长为m,取一个不大于m但最接近或等于m的质数p
注︰质数又称素数。指除了1和此整数自身外,不能被其他自然数整除的数
适用场景:较为通用,只要关键字是整数即可
拓展:为什么除留余数法要对质数取余?
直接定址法——H(key) = key或H(key) = a*key + b
其中,a和b是常数。这种方法计算最简单,且不会产生冲突。若关键字分布不连续,空位较多,则会造成存储空间的浪费。
适用场景:关键字分布基本连续
数字分析法——选取数码分布较为均匀的若干位作为散列地址
设关键字是r进制数〈如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。
适用场景:关键字集合已知,且关键字的某几个数码位分布均匀。
平方取中法——取关键字的平方值的中间几位作为散列地址。
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀。
适用场景:关键字的每位取值都不够均匀。
7.5.3_1 处理冲突的方法_拉链法
拉链法(又称链接法、链地址法)︰把所有“同义词”存储在一个链表中
如何在散列表(拉链法解决冲突)中插入一个新元素?
Step 1:结合散列函数计算新元素的散列地址
Step 2:将新元素插入散列地址对应的链表(可用头插法,也可用尾插法)
散列表的插⼊操作(拉链法解决冲突)
散列表的查找操作(拉链法解决冲突)
查找目标20,计算目标元素存储地址:20%13=7; 20查找成功,查找长度=1
查找目标66,计算目标元素存储地址:66%13=1; 66查找失败,查找长度=4
查找目标21,计算目标元素存储地址:21%13=8; 21查找失败,查找长度=0
查找长度——在查找运算中,需要对比关键字的次数称为查找长度.
注:有的教材会把“空指针的对比”也计入查找长度。但考试中默认只统计 “关键字对比次数”
散列表的删除操作(拉链法解决冲突)
删除目标:27 ,计算目标元素存储地址:27%13=1; 27查找成功,删除成功
删除目标:20 ,计算目标元素存储地址:20%13=7; 20查找成功,删除成功
删除目标:66 ,计算目标元素存储地址:66%13=1; 66查找失败,删除失败
7.5.3_2 处理冲突的方法_开放定址法
开放定址法的基本原理
开放定址法︰如果发生“冲突”,就给新元素找另一个空闲位置。
注:d表示第i次发生冲突时,下一个探测地址与初始散列地址的相对偏移量。
根据散列函数H(key),求得初始散列地址。若发生冲突,如何找到“另一个空闲位置”?
如何删除一个元素:
Step 1∶先根据散列函数算出散列地址,并对比关键字是否匹配。若匹配,则“查找成功”
Step 2∶若关键字不匹配,则根据“探测序列”对比下一个地址的关键字,直到“查找成功”或“查找失败”
Step 3:若“查找成功”,则删除找到的元素
特别注意:关于删除操作
例︰长度为13的散列表状态如下图所示,散列函数H(key)=key%13,采用线性探测法解决冲突。
正确示范:查找元素1
计算元素1的初始散列地址=1%13=1。对比位置#1,关键字不等于1;
根据线性探测法的探测序列,继续对比位置#2,关键字不等于1;
根据线性探测法的探测序列,继续对比位置#3,该位置原关键字已删,继续探测后一个位置;
根据线性探测法的探测序列,继续对比位置#4,关键字等于1,查找成功。
八、排序
8.1 排序的基本概念
排序(Sort),就是重新排列表中的元素,使表少的元素满足按关键字有序的过程。
输入∶n个记录R1,R2...., Rn,对应的关键字为k1, k2,... , kn
输出:输入序列的一个重排R1',R2'....,Rn',使得有k1'≤k2'≤...≤kn'(也可递减)
排序算法的评价指标
算法的稳定性。若待排序表中有两个元素R,和R,其对应的关键字相同即key = keyj,且在排序前R;在R,的前面,若使用某一排序算法排序后,R仍然在R的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。
8.2 插入排序
8.2.1 插入排序
算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
算法实现
// 对A[]数组中共n个元素进行插入排序
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; //复制到插入位置
}
}
}
算法实现(带哨兵)
//直接插入排序(带哨兵)
void InsertSort(int A[], int n){
int i,j;
for(i=2; i<=n; i++){ //依次将A[2]~A[n]插入到前面已排序列
if(A[i]<A[i-1]){ //若A[i]关键码小于其前驱,将A[i]插入有序表
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]; //复制到插入位置
}
}
}
算法效率分析
空间复杂度:O⑴
时间复杂度:主要来自对比关键字、移动元素若有n个元素,则需要n-1趟处理
最好情况∶原本就有序
共n-1趟处理,每一趟只需要对比关键字1次,不用移动元素
最好时间复杂度——O(n)
最坏情况:原本为逆序
第1趟:对比关键字2次,移动元素3次
第2趟:对比关键字3次,移动元素4次
...
第i趟:对比关键字i+1次,移动元素 i+2次
...
第n-1趟:对比关键字n次,移动元素n+1次
最坏时间复杂度——O(n^2)
空间复杂度:O⑴
最好时间复杂度(全部有序): O(n)
最坏时间复杂度(全部逆序):O(n^2)
平均时间复杂度:O(n^2)
算法稳定性:稳定
优化——折半插入排序
思路:先用折半查找找到应该插入的位置,再移动元素
当low>high时折半查找停止,应将[low, i-1]内的元素全部右移,并将A[0]复制到 low 所指位置
当A[mid]==A[0]时,为了保证算法的“稳定性”,应继续在mid 所指位置右边寻找插入位置
当 low>high时折半查找停止,应将[low, i-1]内的元素全部右移,并将A[0]复制到low 所指位置当Amid]==A[0]时,为了保证算法的“稳定性”,应继续在 mid 所指位置右边寻找插入位置
//对A[]数组中共n个元素进行折半插入排序
void InsertSort(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; high=i-1; //设置折半查找的范围
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]; //插入操作
}
}
对链表进行插入排序
//对链表L进行插入排序
void InsertSort(LinkList &L){
LNode *p=L->next, *pre;
LNode *r=p->next;
p->next=NULL;
p=r;
while(p!=NULL){
r=p->next;
pre=L;
while(pre->next!=NULL && pre->next->data<p->data)
pre=pre->next;
p->next=pre->next;
pre->next=p;
p=r;
}
}
移动元素的次数变少了,但是关键字对比的次数依然是O(n^2)数量级,
整体来看时间复杂度依然是O(n^2)
8.2.2 希尔排序
希尔排序︰先将待排序表分割成若干形如L[i, i + d, i + 2d,..., i + kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复上述过程,直到d=1为止。
// 对A[]数组共n个元素进行希尔排序
void ShellSort(ElemType A[], int n){
int d,i,j;
//A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
for(d=n/2; d>=1; d=d/2){ //步长d递减
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]; //插入
}
}
}
}
算法性能分析
空间复杂度:O(1)
时间复杂度:和增量序列d1,d2,d3...的选择有关,目前无法用数学手段证明确切的时间复杂度
最坏时间复杂度为O(n^2),当n在某个范围内时,可达O(n^1.3)
适用性:仅适用于顺序表,不适用于链表
8.3 交换排序
基于“交换”的排序︰根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置
8.3.1 冒泡排序
从后往前(或从前往后)两两比较相邻元素的值,若为逆序〈即A[i-1]>A[i]),则交换它们,直到序列比较完。这样过程称为“一趟”冒泡排序。
// 交换a和b的值
void swap(int &a, int &b){
int temp=a;
a=b;
b=temp;
}
// 对A[]数组共n个元素进行冒泡排序
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)
return; //若本趟遍历没有发生交换,说明已经有序
}
}
算法效率分析:
时间复杂度:最好情况 O(n),最差情况O(n^2),平均情况 O(n^2)
空间复杂度:O(1)。
稳定性:稳定。
适用性:冒泡排序可以用于顺序表、链表。
8.3.2 快速排序
算法思想∶在待排序表L_[1...n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1...k-1和LIk+1..n],使得L[1...k-1]中的所有元素小于pivot,L[k+1...n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止。
快速排序是所有内部排序算法中性能最优的排序算法。
在快速排序算法中每一趟都会将枢轴元素放到其最终位置上。(可用来判断进行了几趟快速排序)
快速排序可以看作数组中n个元素组织成二叉树,每趟处理的枢轴是二叉树的根节点,递归调用的层数是二叉树的层数。
代码实现:
// 用第一个元素将数组A[]划分为两个部分
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; //返回存放枢轴的最终位置
}
// 对A[]数组的low到high进行快速排序
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); //划分右子表
}
}
算法效率分析:
时间复杂度:快速排序的时间复杂度 = O(n×递归调用的层数)。
最好情况O(nlog2n),最差情况O(n^2),平均情况O(n^2)。
空间复杂度:快速排序的空间复杂度 = O(递归调用的层数)。
最好情况O(log2n),最差情况O(n),平均情况O(n^2)
稳定性:不稳定。
若每一次选中的“枢轴”将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高
快速排序算法优化思路:尽量选择可以把数据中分的枢轴元素。
eg:①选头、中、尾三个位置的元素,取中间值作为枢轴元素;②随机选一个元素作为枢轴元素
平均时间复杂度=O(nlog2n)
8.4 选择排序
8.4.1 简单选择排序
选择排序︰每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列。
n个元素的简单选择排序需要n-1趟处理
算法实现:
// 交换a和b的值
void swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
// 对A[]数组共n个元素进行选择排序
void SelectSort(int A[], int n){
for(int i=0; i<n-1; i++){ //一共进行n-1趟,i指向待排序序列中第一个元素
int min = i; //记录最小元素位置
for(int j=i+1; j<n; j++){ //在A[i...n-1]中选择最小的元素
if(A[j]<A[min]) //更新最小元素位置
min = j; //封装的swap()函数共移动元素3次
}
if(min!=i)
swap(A[i], A[min]);
}
}
算法性能分析
空间复杂度:O(1)
稳定性:不稳定
适用性:既可用于顺序表,也可用于链表
8.4.2_1 堆排序
什么是堆?
若n个关键字序列L[ 1...n ]满足下面某一条性质,则称为堆(Heap) :
若满足∶L(i)≥L(2i)且L(i)≥L(2i+1) (1 ≤i <n/2 ) 一大根堆(大顶堆)
若满足∶L(i)≤L(2i)且L(i)≤L(2i+1) (1≤i <n/2 ) ―小根堆(小顶堆)
思路:把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整
检查当前结点是否满足根≥左、右,若不满足,将当前结点与更大的一个孩子互换
若元素互换破坏了下一级的堆,则采用相同的方法继续往下调整(小元素不断“下坠”)
- i的左孩子 -—2i
- i的右孩子 -—2i+1
- i的父节点 -—[i/2]
建立大根堆(代码)
// 对初始序列建立大根堆
void BuildMaxHeap(int A[], int len){
for(int i=len/2; i>0; i--) //从后往前调整所有非终端结点
HeadAdjust(A, i, len);
}
// 将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len){
A[0] = A[k]; //A[0]暂存子树的根结点
for(int i=2*k; i<=len; i*=2){ //沿k较大的子结点向下调整
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]; //被筛选结点的值放入最终位置
}
// 交换a和b的值
void swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
// 对长为len的数组A[]进行堆排序
void HeapSort(int A[], int len){
BuildMaxHeap(A, len); //初始建立大根堆
//i 指向待排元素序列中的最后有关(堆底元素)
for(int i=len; i>1; i--){ //n-1趟的交换和建堆过程
swap(A[i], A[1]);
HeadAdjust(A,1,i-1);
}
}
时间复杂度:O(nlog2n)。建堆时间O(n),之后进行 n-1 次向下调整操作,每次调整时间复杂度为 O(log2n)
空间复杂度:O(1)。
稳定性:不稳定。
8.4.2_2 堆排序的插入删除
在堆中插入新元素
对于小根堆,新元素放到表尾,与父节点对比,若新元素比父节点更小,则将二者互换。新元素就这样一路“上升”,直到无法继续上升为止
在堆中删除元素
被删除的元素用堆底元素替代,然后让该元素不断“下坠”,直到无法下坠为止
8.5 归并排序和基数排序
8.5.1 归并排序
归并:把两个或多个已经有序的序列合并成一个
“4路”归并——每选出一个小元素注需对比关键字3次
m路归并,每选出一个元素需要对比关键字m-1次
算法思想:把待排序表看作 n 个有序的长度为1的子表,然后两两合并,得到 ⌈n/2⌉ 个长度为2或1的有序表……如此重复直到合并成一个长度为n的有序表为止。
// 辅助数组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]; //将A中所有元素复制到B中
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++]; //将较小值复制到A中
}
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); //归并
}
}
8.5.2基数排序
基数排序的算法效率分析
需要r个辅助队列,空间复杂度=O(r)
一趟分配O(n),一趟手机O(r),总共d趟分配、收集,总的时间复杂度=O(d(n+r))
(把关键字拆为d个部分 每个部分可能取得r个值)
收集一个队列只需O(1)的时间
稳定性:基数排序是稳定的
基数排序擅长解决的问题:
①数据元素的关键字可以方便地拆分为d 组,且d较小。 反例:给5个人的身份证号排序
②每组关键字的取值范围不大,即r较小。 反例:给中文人名排序
③数据元素个数n较大。 擅长:给十亿人的身份证号排序
8.7 外部排序
8.7.1+8.7.2外部排序
外存、内存之间的数据交换
操作系统以“块”为单位对磁盘存储空间进行管理,如:每块大小1KB
各个磁盘块内存放着各种各样的数据
磁盘的读/写以“块"为单位数据读入内存后才能被修改修改完了还要写回磁盘
外部排序原理
构造初始“归并段”
构造初始归并段:需要16次“读”和16次“写”
第一趟归并 将两个有序归并段归并为一个 把8个有序子序列(初始归并段)两两归并
外部排序时间开销=读写外存的时间+内部排序所需时间+内部归并所需时间
若能增加初始归并段的长度,则可减少初始归并段数量r
8.7.3败者树
败者树的构造
败者树——可视为一棵完全二叉树(多了一个头头)。k个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。
败者树在多路平衡归并中的应用
对于k路归并,第一次构造败者树需要对比关键字k-1次
有了败者树,选出最小元素,只需对比关键字 ⌈log2k⌉ 次
五路归并的败者树 int ls[5];
8.7.4置换-选择排序
可以用一片更大的内存区域来进行内部排序(如:可容纳18个记录)
每个“初始归并段”可包含18个记录
用于内部排序的内存工作区WA可容纳l个记录,则每个初始归并段也只能包含Ⅰ个记录,若文件共有n个记录,则初始归并段的数量r = n/l
置换—选择排序
把最小的元素“置换”出去
注:假设用于内部排序的内存工作区只能容纳3个记录
若内存工作区的元素比minimax小,不可能放到归并段末尾
8.7.5最佳归并树
每个初始归并段看作一个叶子结点,归并段的长度作为结点权值,则上面这棵归并树的带权路径长度WPL= 2*1 +(5+1+6+2)*3 =44=读磁盘的次数=写磁盘的次数
重要结论:归并过程中的磁盘I/O次数=归并树的WPL*2
要让磁盘/Oi次取,就要使归并树WPL最小——哈夫曼树!
构建2路归并的最佳归并树
最佳归并树WPLmin= (1+2)*4+2*3+5*2+6*1=34
读磁盘次数=写磁盘次数=34次;总的磁盘I/O次数=68
多路归并的情况
WPL =(9+30+12+18+3+17+2+6+24)*2= 242
归并过程中磁盘I/O总次数=484次
注意:对于k叉归并,若初始归并段的数量无法构成严格的k叉归并树,则需要补充几个长度为0的“虚段”,再进行k 叉哈夫曼树的构造。