数据结构学习笔记
ch1.绪论
1.1 数据结构相关概念
数据元素:组成数据的基本单位。
数据对象:是具有相同性质的数据元素的集合,是数据的一个子集。
数据类型:一组性质相同的值的集合以及定义于这个值集合上的一组操作的总称。
ADT:Abstract Data Types(抽象数据类型),有以下三个基本属性:
- Encapsulation(封装)
- Inheritance(继承)
- Polymorphism(多态)
主要包含:数据对象、数据关系、基本操作。
Operations:
Precondition:执行前系统所在的状态
Input:输入
Process:加工,具体去做某事【顺序、分支、循环】
Output:输出
Postcondition:执行后对系统状态的改变【后置条件】
数据结构:
是相互之间存在一种或多种特定关系的数据元素的集合。
-
逻辑(Logical structure):线性、非线性
-
存储(表示):存储映像【物理】、what?【数据元素,Virtual elements,etc】、How【顺序、链式存储、Indexed Sequential、哈希】
-
操作:Query/search【判空、求长度、Get which element、前/后元素】、Process【值传递、地址传递】(Init()、Destroy()、clear()、Insert()、Delete()、update()等)
AKA(Also know as)、b/t(between)
相关规范:
-
Comments(函数功能、输入输出参数、关键语句)
-
Identifier naming(标志符的命名)
-
Structural control
-
File/document
-
Robustness(鲁棒性):系统的稳定,如Precondition,input,process
1.2 数据结构的三要素
1.2.1 逻辑结构
逻辑结构是对数据元素之间逻辑关系的描述,它与数据在计算机中存储方式无关,根据数据元素之间的不同特性,可以对数据的逻辑结构进行分类,如:
集合:各个元素同属一个集合,别无其他集合。
线性结构:数据元素之间是一对一的关系。除了第一个元素,所有元素都有唯一前驱;除了最后一个元素,所有元素都有唯一后继。
树形结构:数据元素之间是一对多的关系。
图结构:数据元素之间是多对多的关系。
1.2.2 数据的运算
针对于某种逻辑结构,结合实际需求,定义基本运算。
1.2.3 物理结构(存储结构)
顺序存储、链式存储、索引存储、散列存储。
1.3 算法效率的度量
算法复杂度的定义公式是:
T = ∑ 1 M N i ∗ τ i T = {\sum\limits_{1}^{M}N_{i}}*\tau_{i} T=1∑MNi∗τi
其中前者为该种控制结构的总执行次数(Frequency),后者为该控制结构需要花的时间
1.3.1 算法时间复杂度
事前预估算法时间开销T(n)
与问题规模n
的关系(T表示time)
(1)顺序执行的代码只会影响常数项,可以忽略。
(2)只需挑循环中的一个基本操作分析它的执行次数与n的关系即可。
例:
void loveYou(int flag[],int n)
{
printf("I am iron man\n");
for(int i=0;i<n;i++)
{
if(flag[i]==n) //即找到该元素n
{
printf("I love you %d\n",n);
break;
}
}
}
其中flag数组中乱序存放了1-n这些数,调用该函数。计算上述算法的时间复杂度T(n)
.
在这个例子中,最好的情况是,元素n在第一个位置,最好时间复杂度T(n)=O(1);最坏的情况是,元素n在最后一个位置,最坏时间复杂度T(n)=O(n)。而平均情况是:
假设元素n在任意一个位置的概率相同为1/n
,循环次数
x
=
(
1
+
2
+
3
+
…
+
n
)
1
n
=
n
(
1
+
n
)
2
1
n
=
1
+
n
2
x = \left( {1 + 2 + 3 + \ldots + n} \right)\frac{1}{n} = \frac{n\left( 1 + n \right)}{2}\frac{1}{n} = \frac{1 + n}{2}
x=(1+2+3+…+n)n1=2n(1+n)n1=21+n,平均时间复杂度T(n)=O(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\left( 1 \right) < O\left( {{log}_{2}n} \right) < O\left( n \right) < O\left( {{nlog}_{2}n} \right) < O\left( n^{2} \right) < O\left( n^{3} \right) < O\left( 2^{n} \right) < O\left( {n!} \right) < O\left( n^{n} \right) O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)
1.3.2 算法空间复杂度
S(n)
用大O表示法。
函数递归调用带来的内存开销:找到递归调用的深度x与问题规模n的关系:x=f(n)
void loveYou(int n)
{
int flag[n];
if(n>1)
loveYou(n-1);
printf("I love you %d\n",n);
}
int main()
{
loveYou(5);
}
算法空间复杂度为:
1 + 2 + 3 + … + n = n ( 1 + n ) 2 = 1 2 n 2 + 1 2 n 1 + 2 + 3 + \ldots + n = \frac{n\left( {1 + n} \right)}{2} = \frac{1}{2}n^{2} + \frac{1}{2}n 1+2+3+…+n=2n(1+n)=21n2+21n
S ( n ) = n 2 S\left( n \right) = n^{2} S(n)=n2
1.4 C语言相关重点知识回顾
1.4.1 结构体
typedef struct (s-name)
{
char *name;
int age;
}stu1,stu2[100],*stu3;
stu1 s1;//stu1为类型标识符
stu2 s2;
stu3 s3;
等价于stu1 *s3
,引用结构体变量s3.name
等价于(*s3).name
等价于s3->name
.
1.4.2 值回传方法
(1)传指针——参数传递;
(2)全局静态变量——两个函数耦合,关系过于密切;
(3)return——只能回传一个。
能不用if就不用if(改为求表达式的值a>b?a:b)。
exit 结束程序状态
ch2.线性表
线性结构要求具有直接前驱(predecessor)和后继(successor)。
2.1 线性表的定义与ADT
2.1.1 线性表的定义
线性表是具有相同数据类型(每个数据元素所占空间一样大)的n(n>=0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则一般表示为L=(a1,a2,…,ai,ai+1,…,an)。
Q:何时传入参数的引用”&
“? A:对参数的修改结果需要”带回来“。
线性表的ADT如下:
ADT List {
Data object: D={ai |ai ∈ElemType, i=1,2,...,n, n≥0 }
Relation: R1={ <ai-1 ,ai >|ai-1 ,ai∈D, i=2,...,n }
Operations:
InitList(&L)
DestroyList( &L )
ListEmpty(L)
ListLength(L)
PriorElem(L,cur_e, &pre_e )
NextElem(L,cur_e, &next_e )
GetElem(L,i,&e)
LocateElem(L,e,compare( ) )
ListTraverse(L,visit( ))
ClearList(&L)
PutElem(&L,i,e)
ListInsert(&L,i,e)
ListDelete(&L,i,&e)
} ADT List;
2.1.2 集合的并运算与元素剔除
集合的并运算代码如下:把B中不与A重复的并到A里面
void union(List &La, List Lb) {
//
La_len = ListLength(La);
Lb_len =ListLength(Lb); // length
for (i = 1; i <= Lb_len; i++) {
GetElem(Lb, i, e);// e is the ith element
if(!LocateElem(La, e, Equal())
ListInsert(La, ++La_len, e);//}
} // union
剔除(Purge)两线性表的重复元素:
法1:利用集合的并,先创建一个空表
A
A
A,
L
A
=
∅
LA=\varnothing
LA=∅,再用Union
操作。
法2:每次比较后面的数与当前的数是否相同,通过从当前后面一个数到末尾while循环,把后面所有重复的元素剔除。
注:
1.若是剔除当前元素,则不仅麻烦,而且会破坏顺序;
2.要注意LB的长度会因为剔除而变短,故第八行应为
while (j<listLength(LB))
而不是LB_len
。
代码如下:
void Purge(LB)
{
int i=1;j,x,y;
while (i<ListLength(LB))
{
Getelem(L,i,x);
j=i+1;
while (j<listLength(LB))
{
Getelem(LB,j,y);
if (x==y)
ListDelete(LB,j);
else
j++;
}
i++;
}
}
2.1.3 合并(Merge)有序序列
合并有序序列,按大小顺序合并,其中A和B各自都有顺序。
void MergeList(List La, List Lb, List &Lc)
{
La_len = ListLength(La);
Lb_len = ListLength(Lb);
Lc_len =0; //创建一个空表,这样可以一个个比较后放入C中
i = j = 1;
while (i<= La_len) && (j<= Lb_len) // A/B都没放完;循环截止到其中一个放完为止
{
GetElem(La, i, &x);
GetElem(Lb, j, &y);
if (x<=y)
{
ListInsert(Lc, ++Lc_len, x);
++i;
}
else{
ListInsert(Lc, ++Lc_len, y);
++j;
}
}
while(i<= La_len) //A没放完,B放完了
{
GetElem(La, i, &x);
ListInsert(Lc, ++Lc_len, x);
i++;
}
while(j<= Lb_len) //B没放完,A放完了
{
GetElem(Lb, j, &x);
ListInsert(Lc, ++Lc_len, x);
j++;
}
} //merge_list
2.2 顺序表
顺序表即用顺序存储的方式实现线性表。知道一个数据元素的大小,用sizeof(ElemType)
。需要预分配大片连续空间。若分配空间过小,则之后不方便拓展容量;若分配空间过大,则浪费内存资源。
顺序表的特点:
随机访问,即可以在O(1)
时间内找到第i
个元素;
存储密度高,每个节点只存储数据元素;
拓展容量和插入、删除操作不方便。
2.2.1 静态分配
#define MaxSize 10
typedef struct
{
ElemType data[MaxSize];
int length; //顺序表的当前长度
}SqList;
//初始化一个顺序表
void InitList(SqList &L)
{
for(int i=0;i<MaxSize;i++)
L.data[i]=0;
L.length=0;
}
int main()
{
SqList L; //声明一个顺序表
InitList(L); //初始化顺序表
//……
return 0;
}
【应用】
在实际应用中, x [ 0 ] x[0] x[0]可作哨兵元素,也可作暂存元素;在后面的排序算法中,若一个序列已经差不多有序,或元素少,可以选择插入排序。
2.2.2 动态分配
#define InitSize 10 //顺序表的初始长度
typedef struct
{
ElemType *data; //指示动态分配数组的指针
int MaxSize;
int length;
}SeqList;
动态申请和释放内存空间:
malloc、free函数:malloc函数的参数指明要分配多大的连续内存空间。
L.data=(ElemType*)malloc(sizeof(ElemType)*InitSize);
#include<stdlib.h> //malloc、free函数的头文件
#include<stdio.h>
#define InitSize 10
typedef struct
{
int *data; //指示动态分配数组的指针
int MaxSize;
int length;
}SeqList;
void InitList(SeqList &L)
{
//用malloc函数申请一片连续的内存空间
L.data = (int *)malloc(InitSize * sizeof(int));
L.length = 0;
L.MaxSize = InitSize;
}
void IncreaseSize(SeqList &L, int len) //增加动态数组的长度
{
int *p = L.data;
L.data = (int *)malloc((L.MaxSize + len) * sizeof(int));
for (int i = 0;i < L.length;i++)
{
L.data[i] = p[i]; //将数据复制到新区域
}
L.MaxSize += len; //顺序表最大长度增加len
free(p);
}
int main()
{
SeqList L; //声明一个顺序表
InitList(L); //初始化顺序表
//往顺序表中随便插入几个元素
IncreaseSize(L, 5);
return 0;
}
2.2.3 基本操作
主要包含创建、销毁、增加、删除、更改、查找等操作。对于销毁操作,静态分配的静态数组是由系统自动回收空间,对于动态分配(链表),需要依次删除各个结点(free
操作)。
插入
#include<stdlib.h> //malloc、free函数的头文件
#include<stdio.h>
#define MaxSize 10
typedef struct
{
int data[MaxSize];
int length;
}SqList;
void ListInsert(SqList &L, int i, int e)
{
for (int j = L.length;j >= i;j--)
L.data[j] = L.data[j - 1]; //将第i个元素及以后的元素右移
L.data[i - 1] = e;
L.length++;
}
int main()
{
SeqList L; //声明一个顺序表
InitList(L); //初始化顺序表
//插入元素
IncreaseSize(L, 5);
return 0;
}
在ListInsert
函数中,还应判断i
的范围是否有效(将函数类型定义为Bool
类型),即:
if(i<1||i>L.length+1) return false;if(L.length>=MaxSize) return false;
问题规模n=L.length
,最好情况是新元素插入到表尾,不需要移动元素,最好时间复杂度O(1),最坏为O(n)。平均情况:
假设新元素插入到任何一个位置的概率相同,即i=1,2,3,…,length+1
的概率都是p=1/(n+1)
;
当i=1
,循环n
次;i=2
,循环n-1
次;i=3
,循环n-2
次;…;i=n+1
,循环0次。
则平均循环次数=np+(n-1)p+…+1*p=(n(n+1)/2)*(1/n+1)=n/2
,故平均时间复杂度为O(n)。
删除
bool ListDelete(SqList &L, int i, int &e)
{
if (i<1 || i>L.length) 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--; //线性表长度减1
return true;
}
int main()
{
SeqList L; //声明一个顺序表
InitList(L); //初始化顺序表
int e = -1;
if (ListDelete(L, 3, e))
printf("已删除第三个元素,删除的元素值=%d\n", e);
else printf("位序i不合法,删除失败\n");
return 0;
}
时间复杂度为 O ( n ) O(n) O(n)。
查找
- 按位查找
GetElem(L,i)
: 获取表L中第i个位置的元素的值,其时间复杂度为
O
(
1
)
O(1)
O(1)
- 按值查找
LocateElem(L,e)
:在表L中查找具有给定关键值的元素,其时间复杂度为
O
(
n
)
O(n)
O(n)
若表内元素有序,可在O(log2n)
的时间内找到。
2.3 单链表
2.3.1 单链表的定义
typedef struct LNode
{
ElemType data;
struct LNode *next;
}LNode,*LinkList;
要表示一个单链表时,只需声明一个头指针L,指向单链表的第一个结点:LNode *L
或LinkList L
。(声明一个指针指向单链表第一个结点)
一般来说,若强调这是一个单链表,使用LinkList
;若强调这是一个结点,使用LNode
。
2.3.2 头结点的使用与单链表初始化
2.3.2.1 头结点的使用
在第一个结点之前附设头结点(Header Node):
头结点的数据域:
- 存放线性表长度等附加信息
- 可以不存储任何信息
头结点的指针域:存储指向首元结点(即存储首元位置)的指针
此时,头指针指向头结点:
注:头指针无论头指针是否存在,头指针均存在。
头指针的意义有:
- 统一空表和非空表操作
- 使每一结点均含有直接前驱
2.3.2.2 初始化空表
下面是带头结点的单链表和不带头结点的单链表初始化空表操作的代码:
- 不带头结点的单链表
bool InitList(LinkList &L)
{
L = NULL; //空表,防止脏数据
return true;
}
- 带头结点的单链表:初始空链表后,需执行
L->next=NULL
。
bool InitList(LinkList &L)
{
L = (LNode *)malloc(sizeof(LNode)); //分配一个头结点
if (L == NULL)
return false; //内存不足,分配失败
L->next = NULL;
return true;
}
2.3.2.3 初始化带有元素的表
在这里,使用头结点初始化带有元素的表。
- 头插法建立单链表:
void CreateList_L(LinkList &L, int n)
{
L = (LinkList) malloc (sizeof (LNode));
L->next = NULL; // establishs a header node
for (i = n; i > 0; --i)
{
p = (LinkList) malloc (sizeof (LNode));
// Generate the fresh node
scanf(&p->data); //'s input element value
p->next = L->next; L->next = p; //
}
} // CreateList_L
其算法时间复杂度为O(ListLength(L))
。
- 尾插法建立单链表:
这里用到了指针r来记录每次链表的最后一个元素;还可以设置变量length记录链表长度。
LinkList create()
{
head=(LinkList) malloc (sizeof(LNode));
head->next = NULL;
r=head;
ch=getchar();
while(ch<>’$’)
{
s=(LinkList) malloc (sizeof(LNode));
s->data=ch;
r->next=s;
r=s; //每次更新r,让r指向最后一个结点
ch=getchar(); //吞回车
}
r->next=NULL; // is as to the non- empty list
return head;
}
2.3.3 插入操作
2.3.3.1 按位序插入和后插操作
头结点可以看作”第0个结点“:
//不带头结点
bool ListInsert(LinkList &L, int i, ElemType e)
{
if (i < 1)
return false;
LNode *p;
int j = 0; //当前p指向的是第几个结点
p = L; //L指向头结点,头结点是第0个结点(不存数据)
while (p != NULL && j < i - 1) //循环找到第i-1个结点
{
p = p->next;
j++;
}
//后插操作
if (p == NULL) //i值不合法
return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = e;
s->next = p->next;
p->next = s; //将结点s连到p之后
return true;
}
注:
如果不带头结点,则插入、删除第1个元素时,需更改头指针L;
若不考虑指定位置处插入元素,一般使用带头结点的单链表,并进行头插。
2.3.3.2 前插操作
基本思想:
法1:先找前驱,再后插
法2:先后插,再交换数据域(常用)
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;
}
2.3.4 删除操作
- 按位序删除
bool ListDelete(LinkList &L, int i, ElemType &e)
{
if (i < 1) return false;
LNode *p;
int j = 0;
p = L;
while (p != NULL && j < n - 1) //循环找到第n-1个结点
{
p = p->next;
j++;
}
if (p == NULL)
return false;
if (p->next == NULL) //第i-1个结点之后已无其他结点
return false;
LNode *q = p->next; //令q指向被删除的结点
e = q->data; //用e返回元素的值
p->next = q->next; //将*q结点从链中断开
free(q);
return true;
}
- 指定结点的删除
bool DeleteNode(LNode *p)
{
if (p == NULL) return false;
LNode *q = p->next; //令q指向*p的后继结点
p->data = p->next->data;
p->next = q->next;
free(q);
return true;
}
【注】如果p是最后一个结点,则只能从表头开始找到p的前驱,时间复杂度是O(n)
。
单链表的局限性:无法逆向检索,有时候不太方便。
2.4 双向链表
对于双向链表,有:p == p→prior→next == p→next→prior
。
2.4.1 初始化
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 = NULL; //头结点的prior永远指向NULL
L->next = NULL; //头结点之后暂时还没有结点
return true;
}
2.4.2 插入操作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RYHBuT0Z-1652600777925)(https://qianzeshu.oss-cn-hangzhou.aliyuncs.com/img/3TE`LT0]LYV0]E{FO$%BX%K.jpg)]
//在p结点之后插入s结点
bool InsertNextDNode(DNode *p,DNode *s)
{
if(p==NULL||s==NULL)
return false;
if(p->next!=NULL) //如果p结点有后继结点
p->next->prior=s;
s->prior=p;
p->next=s;
return true;
}
2.4.3 删除操作
bool DeleteNextDNode(DNode *p)
{
if(p==NULL) return false;
DNode *q=p->next; //找到p的后继结点q
if(q==NULL) return false; //p没有后继
p->next=q->next;
if(q->next!=NULL) q->next->prior=p;
free(q);
return true;
}
2.4.4 遍历
前向遍历/后向遍历,时间复杂度为O(n)。
2.5 循环链表
单链表表尾结点的next
指针指向NULL
,而循环单链表表尾结点的next
指针指向头结点。
循环单/双链表从一个结点出发,可以找到其他任何一个结点。
2.6 静态链表
data
表示数据元素,next
表示游标
适用场景:不支持指针的语言;数据元素数量固定不变的场景(即问题规模已知,如操作系统的文件分配表FAT)
2.6.1 定义静态链表
#define MaxSize 10
struct Node
{
ElemType data;
int next; //下一个元素的数组下标
}
main
函数中struct Node a[MaxSize];
,用数组a作为静态链表。
定义的另一种方式:
#define MaxSize 10 //静态链表的最大长度
typedef struct
{
ElemType data;
int next;
}SLinkList[MaxSize];
这种定义方式等价于:
#define MaxSize 10 //静态链表的最大长度
struct Node
{
ElemType data;
int next;
};
typedef struct Node SLinkList[MaxSize];
即,可用SLinkList
定义”一个长度为MaxSize
的Node
型数组。
在主函数中,声明一个静态链表可用SLinkList a;
语句。
2.6.2 基本操作
初始化静态链表:把a[0]
的next
设为-1。
查找:从头结点出发挨个往后遍历结点。
插入位序为i的结点:
【1】找到一个空的结点,存入数据元素;
注:判断结点是否为空,可在初始化过程中把空闲元素赋某个值;之后查找,若某结点的数值为该值,说明该结点时空闲的。
【2】从头结点出发找到位序为i-1
的结点;
【3】修改新结点的next
;
【4】修改i-1
号结点的next
。
ch3.栈和队列
3.1 栈
只允许在一端进行插入或删除操作的线性表。
特点:后进先出 Last In First Out
n个不同元素进栈,出栈元素不同排列的个数为 1 n + 1 C 2 n n \frac{1}{n + 1}C_{2n}^{n} n+11C2nn(卡特兰数)。
3.1.1 顺序栈的定义和基本操作
- 定义
#define MaxSize 10
typedef struct
{
ElemType data[MaxSize]; //静态数组存放栈中元素
int top;
}SqStack;
初始化栈时,将初始化栈顶指针赋为-1。
- 进栈
bool Push(SqStack &S,ElemType x)
{
if(S.top==MaxSize-1) //栈满,报错
return false;
S.top=S.top+1;
S.data[S.top]=x; //新元素入栈
return true;
}
- 出栈
bool Pop(SqStack &S,ElemType &x)
{
if(S.top==-1) //栈空,报错
return false;
x=S.data[S.top]; //栈顶元素先出栈
S.top=S.top-1; //指针再-1
}
3.1.2 共享栈
- 定义及存储结构:两个栈共享同一片空间。
#define MaxSize 10
typedef struct
{
ElemType data[MaxSize];
int top0; //0号栈栈顶指针
int top1; //1号栈栈顶指针
}ShStack;
- 初始化栈操作
void InitStack(ShStack &s)
{
S.top0=-1;
S.top1=MaxSize;
}
栈满的条件:top0+1==top1
。
3.1.3 栈的链式存储结构
栈的链式存储结构中,将栈顶设置在头部,可以免去栈顶指针的设置问题(因为单链表有头指针)。
typedef struct StackNode
{
ElemType data;
struct StackNode *next;
}StackNode,*LinkStackPtr;
typedef struct
{
LinkStackPtr top;
int count;
}LinkStack;
/* 构造一个空栈S */
Status InitStack(LinkStack *S)
{
S->top = (LinkStackPtr)malloc(sizeof(StackNode));
if(!S->top)
return ERROR;
S->top=NULL;
S->count=0;
return OK;
}
/* 插入元素e为新的栈顶元素 */
Status Push(LinkStack *S,SElemType e)
{
LinkStackPtr s=(LinkStackPtr)malloc(sizeof(StackNode));
s->data=e;
s->next=S->top; /* 把当前的栈顶元素赋值给新结点的直接后继,见图中① */
S->top=s; /* 将新的结点s赋值给栈顶指针,见图中② */
S->count++;
return OK;
}
/* 若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR */
Status Pop(LinkStack *S,SElemType *e)
{
LinkStackPtr p;
if(StackEmpty(*S))
return ERROR;
*e=S->top->data;
p=S->top; /* 将栈顶结点赋值给p,见图中③ */
S->top=S->top->next; /* 使得栈顶指针下移一位,指向后一结点,见图中④ */
free(p); /* 释放结点p */
S->count--;
return OK;
}
3.2 队列
只允许在一端进行插入,在另一端删除的线性表。
队列的特点:先进先出(FIFO)
重要术语:队头、队尾、空队列
3.2.1 顺序存储结构
分析思路:分析front
、rear
指针的指向;确定判空、判满的方法。
- 初始化
#define MaxSize 10 //定义队列中元素的最大个数
typedef struct
{
ElemType data[MaxSize];
int front,rear; //队头指针和队尾指针
}SqQueue; //sequence顺序
初始化时,可将front
指向队头元素,rear
指向队尾元素的后一个位置(下一个应该插入的位置)。
void InitQueue(SqQueue &Q)
{
//初始时,队头、队尾指针指向0
Q.rear=Q.front=0;
}
判断队列是否为空,可用Q.rear==Q.front
来判断。
- 入队
只能从队尾入队(插入)。(循环队列)
bool EnQueue(SqQueue &Q,ElemType x)
{
if()
return false;
Q.data[Q.rear]=x;
Q.rear=(Q.rear+1)%MaxSize; //队尾指针+1取模
return true;
}
- 出队
//删除一个队头元素,并用x返回
bool DeQueue(SqQueue &Q,ElemType &x)
{
if(Q.rear==Q.front) //判断队空
return false;
x=Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize;
return true;
}
获得队头元素的值的操作:
//获得队头元素的值,用x返回
bool GetHead(SqQueue &Q,ElemType &x)
{
if(Q.rear==Q.front)
return false; //队空则报错
x=Q.data[Q.front];
return true;
}
队列已满的条件:队尾指针的再下一个位置是队头,即(Q.rear+1)%MaxSize==Q.front
。
队列的元素个数=(rear-front+MaxSize)%MaxSize
。
对于判空、判满的方法,另有下列2个方案:
方案2:引入一个变量int size
作为保存队列的当前长度,当size==MaxSize
时,队列满。
方案3:判断队列已满/已空
每次删除操作成功时,都令tag=0
;每次插入操作成功时,都令tag=1
。
逻辑:只有删除操作,才可能导致队空;只有插入操作,才可能导致队满。
队空条件:front==rear&&tag==0
。
队满条件:front==rear&&tag==1
。
3.2.2 链式存储结构
typedef struct LinkNode
{
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct
{
LinkNode *front,*rear; //队列的队头和队尾指针
}LinkQueue;
- 入队
新元素连接到的是队列尾部。
带头结点:
void EnQueue(LinkQueue &Q,ElemType x)
{
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));
s->data=x;
s->next=NULL;
Q.rear->next=s; //新结点插入到rear之后
Q.rear=s; //修改表尾指针
}
不带头结点:
void EnQueue(LinkQueue &Q,ElemType x)
{
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));
s->data=x;
s->next=NULL;
if(Q.front==NULL) //修改front和rear的指向
{
Q.front=s;
Q.rear=s;
}
else
{
Q.rear->next=s;
Q.rear=s;
}
}
- 出队
带头结点:
bool DeQueue(LinkQueue &Q, ElemType &x)
{
if (Q.front == Q.rear)
return false;
LinkNode *p = Q.front->next; //用变量x返回队头元素
x = p->data;
Q.front->next = p->next; //修改头结点的next指针
if (Q.rear == p) //本次是最后一个结点出队
Q.rear = Q.front;
free(p);
return true;
}
不带头结点:则LinkNode *p=Q.front;
,即p
是队列的队头。
3.3 栈的应用
3.3.1 栈在括号匹配中的应用
- 原则
遇到左括号就入栈;遇到右括号,就“消耗”一个左括号。当发现当前扫描到的右括号与栈顶左括号不匹配,则本次扫描失败。
- 算法实现
需要用到的函数如下:
//初始化栈
void InitStack(SqStack &S);
//判断栈是否为空
bool StackEmpty(SqStack S);
//新元素入栈
bool Push(SqStack &S, char x);
//栈顶元素出栈,用x返回
bool Pop(SqStack &S, char &x);
代码如下:
#define MaxSize 10
typedef struct
{
char data[MaxSize];
int top;
}SqStack;
bool bracketCheck(char str[], int length)
{
SqStack S;
InitStack(S); //初始化一个栈
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;
Pop(S, topElem); //栈顶元素出栈
if (str[i] == ')'&&topElem != '(')
return false;
if (str[i] == ']'&&topElem != '[')
return false;
if (str[i] == '}'&&topElem != '{')
return false;
}
}
return StackEmpty(S); //检索完全部括号后,栈空则说明匹配成功
}
3.3.2 栈在表达式求值中的应用
3.3.2.1 相关表达式的定义
波兰表达式(前缀表达式)、逆波兰表达式(后缀表达式)。
前缀表达式:运算符在两个操作数前面,如+ab
,-+abc
,-+ab*cd
;
中缀表达式:运算符在两个操作数之间,如a+b
,a+b-c
,a+b-c*d
;
后缀表达式:运算符在两个操作数后面,如ab+
,ab+c-
(或abc-+
),ab+cd*-
。
3.3.2.2 中缀表达式转后缀表达式
- 手算
(1)确定中缀表达式中各个运算符的运算顺序;
(2)选择下一个运算符,按照【左操作数 右操作数 运算符】的方式组合成一个新的操作数;
(3)如果还有运算符没被处理,就继续步骤2。
【注】”左优先”原则:只要左边的运算符能先计算,就优先算左边的。(可保证运算顺序唯一)
- 机算
从左到右出理各个元素,直到末尾。可能遇到以下三种情况:
(1)遇到操作数:直接加入后缀表达式。
(2)遇到界限符:遇到"(“直接入栈;遇到”)“则依次弹出栈内运算符并加入后缀表达式,直到弹出”(“为止。(注:”("不加入后缀表达式)
(3)遇到运算符:依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到"("或栈空则停止。之后再把当前运算符入栈。
3.3.2.3 用栈实现表达式的计算
- 后缀表达式的计算
(1)从左到右扫描下一个元素,直到处理完所有元素;
(2)若扫描到操作数则压入栈,并回到(1),否则执行(3);
(3)若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到(1)。
- 中缀表达式的计算
初始化两个栈,操作数栈和运算符栈。
若扫描到操作数,压入操作数栈;
若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)。
3.3.3 栈在递归中的应用
函数调用时,需要用一个栈存储调用返回地址、实参、局部变量。
3.4 队列的应用
树的层次遍历、图的广度优先遍历、队伍在操作系统中的应用。
队伍在操作系统中的应用:
多个进程争抢着使用有限的系统资源时,FCFS(First Come First Service,先来先服务)是一种常用的策略。
例:CPU资源的分配,一台打印机打印论文(使用缓冲区,可用“队列”组织打印数据,可缓解主机与打印机速度不匹配的问题)。
ch4.字符串
4.1 字符串的定义与基本操作
4.1.1 字符串的定义
字符串,即是由零个或多个字符组成的有限序列,一般记为S='a1a2…an'(n>=0)
。
其中,S是串名,单引号括起来的字符序列是串的值,ai
可以是字母、数字或其他字符;串中字符的个数n称为串的长度,n=0时的串称为空串。
例:S="HelloWorld!"
(Java、C为双引号) T='iPhone 11 pro max?'
(Python为单引号)
子串:串中任意个连续的字符组成的子序列。 主串:包含子串的串。
字符在主串中的位置:字符在串中的序号。
4.1.2 字符串的基本操作
在对字符串的基本操作中,通常以“串的整体”作为操作对象。如在字符串中查找某一子串、在串的某个位置上插入一个子串及删除一个子串等。
比较操作StrCompare(S,T)
:从最左开始一个一个比较ASCII
的值,一旦某字符比出大小,就停止。
长串的前缀与短串相同时,长串更大。
//比较操作。若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;
}
定位操作Index(S,T)
:在主串S
的第pos
个字符后找到与T
相等的子串,并返回该子串第一个字符在S
中的位置。
int Index (String S, String T, int pos) {
if (pos > 0){
n = StrLength(S);
m = StrLength(T);
i = pos;
while (i <= n-m+1){
SubString (sub, S, i, m);
if (StrCompare(sub,T) != 0)
++i ;
else
return i;
}
}
return 0;
}
4.2 字符串的存储结构
4.2.1 顺序存储
4.2.1.1 定长存储
#define MAXSTRING 255
typedef unsigned char SString[MAXSTRLEN +1]; //0号单元存放字符串的长度
SString s;
S[0] = 0;
4.2.1.2 堆分配存储表示
仍以一组地址连续的存储单元存放串值字符序列,但存储空间是动态分配的;存储成功,则返回一个指向起始地址的指针。
typedef struct{
char *ch;
int length; //串长,无需像第一种方式需在0号单元存长度
}HString;
void StrAssign(HString *str, char *chars) {
char *p = chars;
int length, i;
if(str->ch)
{/* free space */
free(str->ch);
str->ch = NULL;
str->length = 0;
}
/* compute length */
while(*p++)
;
length = p - chars - 1;
if(length == 0)
str->length = 0;
else{/* apply for space */
str->length = length;
str->ch = (char *)malloc(sizeof(char)*length);
assert(str->ch);//ensure
for(i=0; i<length; i++)
str->ch[i] = chars[i];
}
}
4.2.1.3 索引顺序存储
typedef struct
{
char name[maxsize];
int length;
char *staaddr; //起始指针,还可添加尾指针
}LNode;
4.2.2 链式存储
4.2.2.1 单链存储
typedef struct StringNode
{
char ch; //每个结点存1个字符
string StringNode *next;
}StringNode,*String;
该存储结构存储密度低,每个字符1B,每个指针4B。
注:存储密度=串值所占的存储位/实际分配的存储位。
4.2.2.2 块链存储
#define CHUNKSIZE 80 //块的大小由用户定义
typedef struct Chunk { //定义块
char ch[CHUNKSIZE];
struct Chunk *next;
}Chunk;
typedef struct{
Chunk *head, *tail; //串的头尾指针
int curlen; //串的当前长度
}LString;
该存储结构存储密度提高。
4.3 字符串的模式匹配
模式匹配算法问题主要由模式串决定。
4.3.1 朴素模式匹配算法
字符串模式匹配:在主串中找到与模式串相同的子串,并返回其所在位置。
朴素模式匹配算法:将主串中所有长度为m的子串依次与模式串对比,直到找到一个完全匹配的子串,或所有的子串都不匹配为止。(最多对比n-m+1
个子串)(Index定位操作即朴素模式匹配算法)
通过数组下标实现朴素模式匹配算法:
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++; //继续比较后继字符
}
else
{
i=i-j+2; //指向下一个子串的起始位置
j=1; //指针后退重新开始匹配
}
if(j>T.length)
return i-T.length;
else
return 0;
}
}
设主串长度为n,模式串长度为m,则最坏时间复杂度=O(nm)
,最好时间复杂度=O(n)
。
最坏时间复杂度:每次均为匹配到模式串最后一个字母时发现匹配失败。
在最坏的情况中,每个子串都要对比m个字符,共n-m+1
个子串,复杂度=O((n-m+1)m)
=O(nm)
。(注:很多时候,n远大于m)
在最好的情况中,每个子串的第一个字符就匹配失败,共n-m+1
个子串,复杂度=O(n-m+1)
=O(n)
。
4.3.2 KMP算法
4.3.2.1 算法分析
算法精髓:利用好已经匹配过的模式串的信息。
Assuming s:
s1, s2…si-j+1…si-k+1, si-k+2…si-1,si,s1+i …sn
Pattern string p:
p1,p2…pj-k,pj-k+1,…,pk-1,pk,…,pj-1,pj,…,pm
Si compare with pk :
“p1p2,…pk-1”=“si-k+1si-k+2,…si-1”
Si compare with pj,:
“p1p2…pj-k+1…pj-1”="si-j+1,si-j+2…si-1“
Because k<j:
“pj-k+1pj-k+2…pj-1"=“si-k+1si-k+2,…si-1”
So :“p1p2,…pk-1”=“pj-k+1pj-k+2…pj-1”
int Index_KMP((SString S,SString T,int next[]))
{
int i=1,j=1;
while(i<=S.length&&j<=T.length)
{
if(j==0||S.ch[i]==T.ch[i])
{
i++;j++; //继续比较后继字符
}
else
j=next[j]; //模式串向右移动,i保持不变
if(j>T.length)
return i-T.length;
else
return 0;
}
}
KMP算法的最坏时间复杂度为O(m+n)
,其中,求next数组时间复杂度O(m)
,模式匹配过程最坏时间复杂度O(n)
。
4.3.2.2 求next数组
next
数组的作用:当模式串的第j
个字符失配时,从模式串的第next[j]
个继续往后匹配。
next[1]
、next[2]
在任何情况下均分别为0、1。对于其他的next
,在不匹配的位置前,划一条分界线。模式串一步一步往后退,直到分界线之前“能对上”,或模式串完全跨过分界线为止。此时j指向哪,next
数组值就是多少。
另一种方法是,当此集合不空,且j不等于1时,next[j]
即第j个元素前j-1
个元素首尾重合部分个数再加一。
代码逻辑:
如果$P_{j} \neq P_{\text {next }[j]} , 那 么 , 那么 ,那么next[j+1] 可 能 的 次 大 值 为 可能的次大值为 可能的次大值为next[next[j]]+1$ ,以此类推即可高效求出 n e x t [ j + 1 ] next [j+1] next[j+1]。
//通过计算返回子串T的next数组
void get_next(SString T,int *next)
{
int i,k;
i=1,k=0;
next[1]=0;
while(i<T[0])
{
if(k==0||T[i]==T[k])
{
i++;
k++;
next[i]=k;
}
else
k=next[k]; //r
}
}
4.3.2.3 求nextval数组
例如: ‘aaaab’,有 n e x t [ j ] = 01234 , n e x t v a l [ j ] = 00004 next[j]= 01234, nextval[j]=00004 next[j]=01234,nextval[j]=00004。
void get_nextval(SString &P, int &nextval[])
{
i = 1; j = 0;
nextval[1] = 0;
while (i < P[0])
{
if (j == 0 || P[i] == P[j])
{
++i; ++j;
if (P[i] != P[j])
nextval[i] = j;
else nextval[i] = nextval[j];
}
else j = nextval[j];
}//while
} // get_nextval
ch5.广义表、数组和矩阵
5.1 广义表
5.1.1 逻辑结构
广义表即”表中有表“,记作 L S = ( α 1 , α 2 , … , α n ) LS=(\alpha_{1},\alpha_{2},…,\alpha_{n}) LS=(α1,α2,…,αn),n为其长度, α i \alpha_{i} αi可以为单个元素,也可以为广义表,分别称广义表LS的原子和子表。当广义表非空时,称第一个元素 α 1 \alpha_{1} α1为LS的表头(Head),其余元素组成的表 ( α 2 , α 3 , … , α n ) (\alpha_{2},\alpha_{3},…,\alpha_{n}) (α2,α3,…,αn)是LS的表尾(Tail)。
注意:每次取表尾就是构建广义表的过程,如D=(A,B,C),GetTail(D)=(B,C),GetTail((B,C))=©.
广义表的长度为表中最上层元素的个数,而广义表的深度为表中括号的最大层数。求深度时,可将子表展开,如某广义表可以展开为 ( ( d , e ) , ( b , ( c , d ) ) ) ((d,e),(b,(c,d))) ((d,e),(b,(c,d))),则深度为3。
5.1.2存储结构
广义表中的数据元素可以为原子或列表,表结点由三个域构成:标志域(tag=1)、指示表头的指针域和指示表尾的指针域,原子结点由三个域构成:标志域(tag=0)、值域。
5.2 数组的表示
对数组:
a11 | a12 | …… | a1n |
---|---|---|---|
a21 | a22 | …… | a2n |
…… | …… | …… | …… |
am1 | am2 | …… | amn |
以行为主序: L o c ( a i j ) = L o c ( a 11 ) + [ ( i − 1 ) m + ( j − 1 ) ] ∗ l Loc(aij)=Loc(a11)+[(i-1)m+(j-1)]*l Loc(aij)=Loc(a11)+[(i−1)m+(j−1)]∗l(l为每个单元的字节数);
以列为主序: L o c ( a i j ) = L o c ( a 11 ) + [ ( j − 1 ) m + ( i − 1 ) ] ∗ l Loc(aij)=Loc(a11)+[(j-1)m+(i-1)]*l Loc(aij)=Loc(a11)+[(j−1)m+(i−1)]∗l(l为每个单元的字节数)。
5.3 结构化稀疏矩阵的存储
5.3.1 下三角矩阵
5.3.2 对称矩阵
5.4 稀疏矩阵的存储表示
5.4.1 三元组表
稀疏矩阵由非零元的三元组及其行列数唯一确定。其中,行列数可以单独设置变量,也可以直接用零号单元进行存储(转置运算例子采用零号单元存储)。
//稀疏矩阵的三元组顺序表存储表示
typedef struct{
int i,j; //非零元的行下标和列下标
ElemType e;
}Triple;
typedef struct{
Triple data[MaxSize+1];
int mu,nu,tu; //矩阵的行数、列数和非零元个数,也可以直接存在data[0]中
}TSMatrix;
5.4.2 重要实例:稀疏矩阵的转置运算
方法1:将矩阵的行列值相互交换,然后排序。其时间复杂度为 O ( n 2 + n l o g n ) O(n^{2}+nlogn) O(n2+nlogn)。
方法2:遍历三元组表,找到 j m i n j_{min} jmin(列优先),互换后得到三元组表。每次遍历的时候,从 c o l u m n = 1 column=1 column=1开始( c o l u m n = 1 , 2 , … , n column=1,2,…,n column=1,2,…,n),将符合条件的进入新三元组表中。其时间复杂度为 O ( N u ∗ t u ) O(N_{u}*t_{u}) O(Nu∗tu)( N u N_{u} Nu为列数, t u t_{u} tu为非零元个数)。这个方法的缺点在于反复搜索。
方法3:实现:设两个数组
n u m [ c o l ] num[col] num[col]:表示矩阵 M M M中第 c o l col col列中非零元个数
c p o t [ c o l ] cpot[col] cpot[col]:指示 M M M中第 c o l col col列第一个非零元在 m b mb mb中位置
显然有:
c
p
o
t
[
1
]
=
1
;
cpot[1]=1;
cpot[1]=1;
c
p
o
t
[
c
o
l
]
=
c
p
o
t
[
c
o
l
−
1
]
+
n
u
m
[
c
o
l
−
1
]
;
(
2
≤
c
o
l
≤
m
a
[
0
]
.
c
o
l
s
)
cpot[col]=cpot[col-1]+num[col-1]; (2\leq col \leq ma[0].cols)
cpot[col]=cpot[col−1]+num[col−1];(2≤col≤ma[0].cols)
每次保存到新三元组表中后,将 c p o t [ c o l ] cpot[col] cpot[col]进行自增。其算法的时间复杂度为 O ( N u + t u ) O(N_{u}+t_{u}) O(Nu+tu)( N u N_{u} Nu为列数, t u t_{u} tu为非零元个数)。该算法以空间换时间。
5.4.3 行/列逻辑链表
5.4.3.1 单向链表表示
5.4.3.2 带行指针向量的单向链表
每行的非零元用一个单链表存放;
设置一个行指针数组,指向本行第一个非零元结点;若本行无非零元,则指针为空。需存储单元个数为3t+m(t为非零元素个数,m为行数)。
typedef struct node
{ int col;
int val;
struct node *link;
}RNode;
typedef struct node *TD;
5.4.4 十字链表
设行指针数组和列指针数组,分别指向每行、列第一个非零元。结点定义如下:
typedef struct Node
{
int row,col,val;
struct Node *down, *right;
}OLNode;
ch6.树与二叉树
非线性数据结构主要有集中式、分散式、集散式等。树型结构是一类重要的非线性数据结构。
树的递归定义----->算法递归
- at least one base(出口条件)
- recursive case(递归情况):往base case走
6.1 树的定义及基本术语
树的定义:
树是n(n大于等于0)个结点的有限集合。在任意一棵非空树中应满足:
(1)有且仅有一个特定的称为根的结点。
(2)当n大于1时,其余结点可分为m个互不相交的有限集合T1、T2、…、Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
基本术语:
-
树的结点包含一个数据元素及若干指向其子树的分支
-
结点拥有的子树数称为结点的度(degree)
-
结点的深度(depth)是指从根结点到此结点的长度
-
结点的高度(height)是指从此结点到最深结点(the deepest leaf)的长度
-
结点的**层次(level)**等于父结点的层次+1,基情况国内定义为1,level=depth。
-
假设有一度为m的树,则称其为m-ary(叉) tree。
【区分】:
树的度(度为m的树):各结点的度的最大值,任意结点的度小于等于m,至少有一个结点度=m(有m个孩子),且该树至少有m+1个结点。
m叉树:每个结点最多只能有m个孩子的树,允许所有结点的度都小于m,且可以是空树。
-
二叉树是一种特殊的树形结构,它的树型要么为空(empty),要么存在3个disjoint(不相交)的subsets(子集)【分别为root、左子树、右子树】。在严格二叉树中,若存在 n n n个叶子结点(leaves),则存在 2 n − 1 2n-1 2n−1个总结点数。
二叉排序树:
一个二叉树或是空二叉树,是具有以下性质的二叉树:
左子树上所有结点的关键字均小于根节点的关键字;右子树上所有结点的关键字均大于根节点的关键字。
左子树和右子树又各是一棵二叉排序树。
平衡二叉树:
树上任一结点的左子树和右子树的深度之差不超过1。平衡二叉树是“胖胖的、丰满的树”,能有更高的搜索效率。
6.2 二叉树的性质
-
二叉树中,结点数=总度数+1;对于任意一棵二叉树T,若其终端结点数为 n 0 n_{0} n0,度为2的结点数为 n 2 n_{2} n2,则 n 0 = n 2 + 1 n_{0}=n_{2}+1 n0=n2+1.
-
在二叉树的第i层上至多有 2 i − 1 2^{i-1} 2i−1个结点(n叉则为 n i − 1 n^{i-1} ni−1),深度为k的二叉树至多有 2 k − 1 2^{k}-1 2k−1个结点。
【推广】高度为h的m叉树至多有 m h − 1 m − 1 \frac{m^{h} - 1}{m - 1} m−1mh−1个结点。(第一层最多m的0次,第二层m的1次,第三层m的2次,进行等比数列求和)
- 满二叉树与完全二叉树:
满二叉树:一棵高度为h,且含有 2 h − 1 2^{h} - 1 2h−1个结点的二叉树。
完全二叉树:当且仅当其每个结点都与高度为h的满二叉树中编号为 1 1 1至 n n n的结点一一对应时,称为完全二叉树。
满二叉树一定是完全二叉树,而完全二叉树不一定是满二叉树。
完全二叉树的特点:
(1)只有最后两层可能有叶子结点;
(2)最多只有一个度为1的结点;
(3)按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1,结点i的父节点为 ⌊ i / 2 ⌋ \left\lfloor {i/2} \right\rfloor ⌊i/2⌋。
(4)当 i < ⌊ n / 2 ⌋ i < \left\lfloor {n/2} \right\rfloor i<⌊n/2⌋时为分支结点,当 i > ⌊ n / 2 ⌋ i > \left\lfloor {n/2} \right\rfloor i>⌊n/2⌋时为叶子结点。
对于完全二叉树而言,如果某结点只有一个孩子,那么一定是左孩子。也即,若含有右孩子,必含有左孩子。
具有n个结点的完全二叉树的深度为 ⌈ l o g 2 ( n + 1 ) ⌉ \left\lceil {{log}_{2}\left( n + 1 \right)} \right\rceil ⌈log2(n+1)⌉或 ⌊ l o g 2 n ⌋ + 1 \left\lfloor {{log}_{2}n} \right\rfloor + 1 ⌊log2n⌋+1.
**[推导]**高为h的满二叉树共有 2 h − 1 2^{h} - 1 2h−1个结点,高为h-1的满二叉树共有 2 h − 1 − 1 2^{h-1} - 1 2h−1−1个结点,则:
2 h − 1 − 1 < n ≤ 2 h − 1 2^{h - 1} - 1 < n \leq 2^{h} - 1 2h−1−1<n≤2h−1,则 h − 1 < l o g 2 ( n + 1 ) ≤ h h - 1 < {log}_{2}\left( {n + 1} \right) \leq h h−1<log2(n+1)≤h,故h= ⌈ l o g 2 ( n + 1 ) ⌉ \left\lceil {{log}_{2}\left( n + 1 \right)} \right\rceil ⌈log2(n+1)⌉。
另一边,高为h的完全二叉树至少有 2 h − 1 2^{h-1} 2h−1个结点,至多有 2 h − 1 2^{h} - 1 2h−1个结点,故 2 h − 1 ≤ n ≤ 2 h 2^{h - 1} \leq n \leq 2^{h} 2h−1≤n≤2h,则 h − 1 < l o g 2 n < h h - 1 < {log}_{2}n < h h−1<log2n<h,故h= ⌊ l o g 2 n ⌋ + 1 \left\lfloor {{log}_{2}n} \right\rfloor + 1 ⌊log2n⌋+1.
(5)(性质1的推论)对于完全二叉树,可以由结点数n推出度为0、1、2的结点个数为n0、n1、n2。
**[推导]**完全二叉树最多只有一个度为1的结点,即n1=0或1,而n0=n2+1,即n0+n2一定为奇数,则:
若完全二叉树有2k(偶数)个结点,必有n1=1,n0=k,n2=k-1;
若完全二叉树有2k-1(奇数)个结点,必有n1=0,n0=k,n2=k-1。
6.3 二叉树的存储结构
6.3.1 顺序存储
法1:用一组地址连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素。
#define MaxSize 100
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
}
定义一个长度为MaxSize的数组t:TreeNode t[MaxSize]
,按照从上至下、从左至右的顺序依次存储完全二叉树中的各个结点。初始化时利用循环将所有结点标记为空:t[i].isEmpty=true;
二叉树的顺序存储中,要把二叉树的结点编号与完全二叉树对应起来。最坏情况是:高度为h且只有h个结点的单支树(所有结点只有右孩子),也至少需要 2 h − 1 2^{h} - 1 2h−1个存储单元。这种顺序存储方式只适合存储完全二叉树。
注:顺序存储结构若需判空,则一般设置一个长度length变量,存储当前存储了多少结点。
法2:定义结点类型,存储数据data和它在完全二叉树中的编号,定义树使用结构体数组。
data | No. |
---|
法3:定义结点类型,存储数据data
、它双亲的编号以及一个变量LR
标记它是左孩子还是右孩子,定义树使用结构体数组。
data | parent | LR(孩子) |
---|
6.3.2 链式存储
三叉链表增设parent
指针
在n个结点的二叉链表共有n+1个空链域。
[推导]在含有n个结点的二叉链表中,链域一共有2*n个(每个点有两个链域),而除根结点以外,其余结点均有前驱,则有n-1个前驱,即一共有n-1个指针指向某个结点,故在n个结点的二叉链表共有n+1个空链域。(空链域可用于构造线索二叉树)
typedef struct BiTNode
{
ElemType data; //数据域
struct BiTNode *lchild,*rchild; //左、右孩子指针
}BiTNode,*BiTree;
//定义一棵空树
BiTree root=NULL;
//插入根结点
root= (BiTree)malloc(sizeof(BiTNode));
root->data={1};
root->lchild=NULL;
root->rchild=NULL;
//插入新结点
BiTNode *p= (BiTNode *)malloc(sizeof(BiTNode));
p->data={2};
p->lchild=NULL;
p->rchild=NULL;
root->lchild=p; //作为根结点的左孩子
6.3.3 静态链表方式存储
6.4 二叉树的遍历
6.4.1 先中后序遍历
根结点集合仅1个元素,以此划分为:
先序遍历:根左右(NLR) 中序遍历:左根右(LNR) 后序遍历:左右根(LRN)
以先序遍历为例:
- 非空:访问根,遍历左子树,遍历右子树
- 为空:返回
void Preorder(BiTNode *bt)
{
if(bt!=NULL)
{
printf("%d\t",bt->data);
Preorder(bt->lchild);
Preorder(bt->rchild);
}
}
中序遍历的非递归方式:
void Inorder(BiTNode *bt)
{
int i=0;
BiTNode *p,*s[M];
p=bt;
do
{ while(p!=NULL)
{ s[i++]=p;
p=p->lchild;
}
if(i>0)
{ p=s[--i];
printf("%d\t",p->data);
p=p->rchild;
}
}while(i>0||p!=NULL);
}
[注]
- 若先序与中序的序列相同,则有VLR=LVR,则VR=VR,仅有右子树,没有左子树。已知两种序列(需包括中序序列)可以唯一确定二叉树的形态,根据根的位置先划清界限,然后进行判断即可。
-
树的深度:后序遍历,取左子树、右子树的最大深度+1。
-
表达式树(Expression Tree):用于表示一种树状的数据结构,树上的每一个节点都表示为某种表达式类型,如图:
6.4.2 层序遍历
采用先进先出的方式,故采用队列这一数据结构。
算法模式:
void PrintLevelOrder(T)
{
InitQueue(Q);
Enqueue(Q,T); //将根结点入队
while (!Q.IsEmpty()){
t = DeQueue(Q); //或DeQueue(Q,t);将队首元素出队
visit(t); //访问该结点
if(t->lchild) EnQueue(Q,t->lchild);
if(t->rchild) EnQueue(Q,t->rchild);
}
}
层序求树的高度,则关键问题是知道每层的最后一个结点(可以通过前层最后一个入队的孩子获取)。
6.5 线索二叉树
线索是一种对二叉树的操作,意思是对二叉树进行线索化,其目的是使线索化后的二叉树具有方便被遍历的特点,即不使用递归和栈也可以对线索化之后的树进行中序遍历。
线索指向的是该序的前驱/后继。
BiTNode *zxxsh(BiTNode *bt)
{
BiTNode *p,*pr,*s[M],*t;
int i=0;
t=(BiTNode *)malloc(sizeof(BiTNode));
t->leftthread=0; t->rightthread=1; t->rc=t;
if(bt==NULL) t->lc=t;
else
{ t->lc=bt; pr=t; p=bt;
do{ while(p!=NULL)
{ s[i++]=p; p=p->lc; }
if(i>0)
{ p=s[--i];
printf("%c ",p->data);
if(p->lc==NULL)
{ p->leftthread=1; p->lc=pr;}
if(pr->rc==NULL)
{ pr->rightthread=1; pr->rc=p;}
pr=p; p=p->rc;
}
}while(i>0||p!=NULL);
pr->rc=t; pr->rightthread=1; t->rc=pr;
}
return(t);
}
在中序线索二叉树中找结点后继的方法:
(1)若rt=1, 则rc域直接指向其后继
(2)若rt=0, 则结点的后继应是其右子树的左链尾(lt=1)的结点
在中序线索二叉树中找结点前驱的方法:
(1)若lt=1, 则lc域直接指向其前驱
(2)若lt=0, 则结点的前驱应是其左子树的右链尾(rt=1)的结点
在线索树上进行遍历,只要先找到序列中的第一个结点,然后一次找结点后继,直至后继为空:
BiTNode PreInThread(P)
{
if(p->ltag==1)
return p->lc;
else{
q = p->lc;
while(q->rtag==0){
q=q->rc;
}
return q;
}
}
下面详细介绍线索二叉树找前驱/后继的方法:
1.中序线索二叉树
中序遍找历中序后继:
左 根 右
左 根 (左 根 右)
左 根 ((左 根 右) 根 右)
在中序线索二叉树中找到指定结点*p的中序后继next:
(1)若p->rtag==1,则next=p->rchild
(2)若p->rtag==0,则p必有有孩子。
//找到以p为根的子树中,第一个被中序遍历的结点
ThreadNode *Firstnode(ThreadNode *p)
{
//循环找到最左下结点(不一定是叶结点)
while(p->ltag==0) p=p->lchild;
return p;
}
//在中序线索二叉树中找到结点p的后继结点
ThreadNode *NextNode(ThreadNode *p)
{
if(p->rtag==0) return Firstnode(p->rchild);
else return p->rchild; //rtag==1直接返回后继线索
}
//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法)
void Inorder(ThreadNode *T)
{
for(ThreadNode *p=FirstNode(T);p!=NULL;p=NextNode(p))
visit(p);
}
2.先序线索二叉树
先序遍历找先序后继:(假设有左孩子)
根 左 右
根 (根 左 右) 右
[结论]若p有左孩子,则先序后继为左孩子。
(假设没有左孩子)
根 右
根 (根 左 右)
[结论]若p有右孩子,则先序后继为右孩子。
在中序线索二叉树中找到指定结点*p的先序后继next:
(1)若p->rtag==1,则next=p->rchild
(2)若p->rtag==0,则p必有右孩子。
(仅改用三叉链表或从根开始遍历寻找才能完成)
3.后序线索二叉树
后序遍历找后序前驱:(假设有右孩子)
左 右 根
左 (左 右 根) 根
[结论]若p有右孩子,则后序前驱为左孩子。
(假设没有右孩子)
左 根
(左 右 根) 根
[结论]若p没有右孩子,则后序前驱为左孩子。
在中序线索二叉树中找到指定结点*p的后续前驱next:
(1)若p->ltag==1,则next=p->lchild
(2)若p->ltag==0,则p必有左孩子。
6.6 哈夫曼树
编码方式:流水号、前缀码、定长码(fixed-length)、变长码(variable-length)[使用频率高,码长短]。
6.6.1 哈夫曼树的定义及特点
结点的权:有某些现实含义的数值;
结点的带权路径长度(WPL):从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
在含有n个带权叶结点的二叉树中,带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
哈夫曼树的特点:
-
每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
-
哈夫曼树有 2 n − 1 2n-1 2n−1个结点,它是严格二叉树,不存在度为1的结点
-
哈夫曼树形态不唯一,但WPL必然相同且为最优
6.6.2 哈夫曼算法
给定n个权值分别为w1, w2,…, wn的结点,构造哈夫曼树的算法(哈夫曼算法)描述如下:
- 给定N个子树,初始化声明集合$F=\left { T_{1},T_{2},……,T_{n} \right } $(仅含根结点且带权值)
- 从F中选根结点权值最小的两棵树
T
i
,
T
j
T_{i},T_{j}
Ti,Tj作为左右子树构造一棵新的二叉树S,有:
- S − > l c ⇐ T i S->lc \Leftarrow T_{i} S−>lc⇐Ti
- S − > r c ⇐ T j S->rc \Leftarrow T_{j} S−>rc⇐Tj
- 新二叉树根结点的权值: W S = W T i + W T j W_{S}=W_{T_{i}}+W_{T_{j}} WS=WTi+WTj
- 在 F F F中删除 T i , T j T_{i},T_{j} Ti,Tj,同时将新得到的二叉树 S S S加入 F F F中
- 重复2-3步,直到 F F F中只有一棵子树。
存储结构:问题规模已知—>静态链表。
typedef struct {
unsigned int weight;
unsigned int parent,lc,rc;
}HTNode,*HuffmanTree; //动态分配数组存储哈夫曼树
typedef char ** HuffmanCode; //动态分配数组存储哈夫曼编码表
遍历使用“栈”这一数据结构。
6.7 哈夫曼编码
可变长度编码:允许对不同字符用不等长的二进制位表示(使得总编码长度尽量减少)。
前缀编码必须保证任意一个字符的编码都不是另一个字符的编码的前缀,可以利用二叉树来设计二进制的前缀编码。
求哈夫曼编码的算法如下:
void HuffmanCoding(&HT,&HC, w,n)
{
if (n<=1) return;
m=2n-1;
HT=(HuffmanTree)malloc((m+1)*());
for (p=HT,i=1;i<=n;++i,++p,++w)
*p={*w,0,0,0};
for (; i<=m;++i,++p) *p={0,0,0,0};
for (i=n+1;i<=m;i++) {
select(HT,i-1,s1,s2);
HT[s1].parent=i; HT[s2].parent=i;
HT[i].lc=s1;HT[i].rc=s2;
HT[i].weight=HT[s1].weight+HT[s2].weight;
}
//coding
HC=(HuffmanCode)malloc((n+1)*());
cd=(char *)malloc(n*());
cd[n-1]='\0';
for (i=1;i<=n;i++)
{
start=n-1;
for(c=i,f=HT[i].parent;f!=0;c=f,f=HT[f].parent) //f!=0判断是否到达根节点
if (HT[f].lc==c) cd[--start]=‘0’;
else cd[--start]=‘1’;
HC[i]=(char *)malloc((n-start)*());
strcpy(HC[i],&cd[start]);
}
free(cd);
}
6.8 树与森林
6.8.1 树的存储结构
6.8.1.1 双亲表示法
箭头向上指,每个结点保存指向双亲的指针。
6.8.1.2 孩子表示法
顺序存储各个节点,每个节点中保存孩子的链表头指针
孩子链表 带双亲的孩子链表
存储结构:
data | child1 | child2 | child3 | …… | child d |
---|
6.8.1.3 孩子兄弟表示法
存储结构:
data | firstchild | nextSibling |
---|
该存储结构便于实现很多树的操作,如易于实现找结点孩子的操作,如访问结点x
的第i
个孩子,只要先从firstchild
域找到第1个孩子,然后沿着nextsibling
域连续走i-1
步,即可找到x
的第i
个孩子。
6.8.2 树与二叉树的转换
将树转换成二叉树:
- 加线:在兄弟之间加一连线
- 抹线:对每个结点,除了其左孩子外,去除其与其余孩子之间的关系
- 旋转:以树的根结点为轴心,将整树顺时针转45°
将二叉树转换成树:
- 加线:若p结点是双亲结点的左孩子,则将p的右孩子,右孩子的右孩子,……沿分支找到的所有右孩子,都与p的双亲用线连起来
- 抹线:抹掉原二叉树中双亲与右孩子之间的连线
- 调整:将结点按层次排列,形成树结构
6.8.3 树的遍历
- 先根遍历:先访问树的根结点,然后依次先根遍历根的每棵子树------->对应二叉树的先序遍历
- 后根遍历:先依次后根遍历每棵子树,然后访问根结点------->对应二叉树的中序遍历
- 按层次遍历:先访问第一层上的结点,然后依次遍历第二层,……,第n层的结点
6.8.4 树与森林的转换
森林转换成二叉树:
- 将各棵树分别转换成二叉树
- 将每棵树的根结点用线相连
- 以第一棵树根结点为二叉树的根,再以根结点为轴心,顺时针旋转,构成二叉树型结构
二叉树转换成森林:
- 抹线:将二叉树中根结点与其右孩子连线,及沿右分支搜索到的所有右孩子间连线全部抹掉,使之变成孤立的二叉树
- 还原:将孤立的二叉树还原成树
*6.9 二叉排序树
二叉排序树又称二叉查找树(BST,Binary Search Tree),在该树中,左子树结点值<根结点值<右子树结点值。对该数进行中序遍历,可以得到一个递增的有序序列。
- 查找:
算法:
查找效率分析:
查找长度:在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度。查找成功的平均查找长度称ASL(Average Search Length)。
若树高为h,找到最下层的一个结点需要对比h次。最好情况:n个结点的二叉树最小高度为 ⌊ l o g 2 n ⌋ + 1 \left\lfloor {{log}_{2}n} \right\rfloor + 1 ⌊log2n⌋+1,平均查找长度= O( l o g 2 n {log}_{2}n log2n);
最坏情况:每个结点只有一个分支,树高h=结点数n。平均查找长度=O(n)。
- 插入:
若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树。由于新插入的结点一定是叶子,且该算法是递归实现的,故最坏的空间复杂度为O(h)(h为树的高度)。
//在二叉排序树插入关键字为k的新结点(递归实现)
int BST_insert(BSTree &T,int k)
{
if(T==NULL)
{
T=(BSTree)malloc(sizeof(BSTNode));
T->key=k;
T->lchild=T->rchild=NULL;
return 1; //返回1,插入成功
}
else if(k==T->key) //树中存在相同关键字的结点,插入失败
return 0;
else if(k<T->key) //插入到T的左子树
return BST_Insert(T->lchild,k);
else //插入到T的左子树
return BST_Insert(T->rchild,k);
}
- 删除:
先搜索找到目标结点:
(1)若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质(即左子树结点值<根结点值<右子树节点值);
(2)若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置;
(3)若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去原来位置上的直接后继(或直接前驱),这样就转换成了第一或第二种情况。
[注]进行中序遍历,可以得到一个递增的有序序列,故z的后继即为z的右子树中最左下结点(该结点一定没有左子树,然后可以参考第二种情况)。
*6.10 平衡二叉树
6.10.1 定义
平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树),其树上任一结点的左子树和右子树的高度之差不超过1。
结点的平衡因子=左子树高-右子树高。
在插入操作中,每次调整的对象都是“最小不平衡子树”,只要将最小不平衡子树调整平衡,则其他祖先结点都会恢复平衡。
6.10.2 调整最小不平衡子树A
目标:【1】恢复平衡;【2】保持二叉排序树的特性(即左子树结点值<根结点值<右子树结点值)。
只有左孩子才能右旋,只有有孩子才能左旋。
- LL插入:
在A的左孩子的左子树中插入导致不平衡
由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
- RR插入:
由于在结点A的右孩子(R)的右子树(R)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。
代码思路:
- LR插入:
由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置。
- RL插入:
6.10.3 查找效率分析
在平衡二叉树中,树上任一结点的左子树和右子树的高度之差不超过1,假设以 n h n_{h} nh表示深度为h的平衡树中含有的最少结点数,则有n0=0,n1=1,n2=2,且有 n h = n h − 1 + n h − 2 + 1 n_{h} = n_{h - 1} + n_{h - 2} + 1 nh=nh−1+nh−2+1。
含有n个结点的平衡二叉树的最大深度为 O ( l o g 2 n ) O\left( {log}_{2}n \right) O(log2n),即平衡二叉树的平均查找长度为 O ( l o g 2 n ) O\left( {log}_{2}n \right) O(log2n)。
ch7.图
7.1 图的相关术语
7.1.1 图的定义
图 G G G由顶点集 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中顶点的个数,也称图 G G G的阶, E = ( u , v ) ∣ u ∈ V , v ∈ V E = {(u, v) | u∈V, v∈V} E=(u,v)∣u∈V,v∈V,用 ∣ E ∣ |E| ∣E∣表示图 G G G中边的条数。
若图是有向图,则 < v , w > <v,w> <v,w>表示从 v v v到 w w w的一条弧,且称 v v v为弧尾, w w w为弧头。
在无向图中,顶点的度是指与其相关联的边的数目;
在有向图中,定义:
以顶点v
为头的弧的数目称为v
的入度(InDegree),记作ID(v)
,
以顶点v
为尾的弧的数目称为v
的出度(OutDegree),记作OD(v)
,
对于无向图,边的数目 e e e的取值是 ( 0 , 1 2 n ( n − 1 ) ) (0,\frac{1}{2} n(n-1)) (0,21n(n−1))(上界即$C_{n}^{2} ) , 有 ),有 ),有\frac{1}{2} n(n-1)$条边的无向图称为完全图。
对于有向图,边的数目 e e e的取值是 ( 0 , n ( n − 1 ) ) (0,n(n-1)) (0,n(n−1))(上界即$P_{n}^{2} , P 即 P e r m u t a t i o n 排 列 ) , 有 ,P即Permutation排列),有 ,P即Permutation排列),有n(n-1)$条边的有向图称为有向完全图。
有很少条边的图称为稀疏图(Sparse graph),反之称为稠密图(Dense graph)。
序列中顶点不重复出现的路径称为简单路径;除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环(Cycle)。
【注】线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集;但图的边集可为空集。
7.1.2 (强)连通图与连通子图
在无向图 G G G中,如果从顶点 v v v到顶点 v ′ v^{'} v′有路径,则称 v v v和 v ′ v^{'} v′是连通的。
在无向图中,若对于图中任意两个顶点 v i 、 v j ∈ V v_{i}、v_{j}\in V vi、vj∈V, v i 、 v j v_{i}、v_{j} vi、vj都是连通的,则称** G G G是连通图**。
无向图中的极大连通子图称为连通分量(Connected Component)。(子图必须连通,且包含尽可能多的顶点和边,最小为1,最大为n)
注:
若G是连通图,则最少有n-1条边;
无环连通图是树。
在有向图 G G G中,如果对于每一对 v i 、 v j ∈ V , v i ≠ v j v_{i}、v_{j}\in V,v_{i}≠v_{j} vi、vj∈V,vi=vj,从 v i v_{i} vi到 v j v_{j} vj和从 v j v_{j} vj到 v i v_{i} vi都存在路径,则称 G G G是强连通图。
若G是强连通图,则最少有n条边(形成回路);
若G是非连通图,则最多有可能有 C n − 1 2 C_{n-1}^{2} Cn−12条边(抛开最后一个顶点,其余的连满)。
有向图中的极大强连通子图称为强连通分量。(子图必须连通,且保留尽可能多的边)
设有两个图 G = ( V , E ) G=(V,E) G=(V,E)和 G ′ = ( V ′ , E ′ ) G'=(V',E') G′=(V′,E′),若 V ’ V’ V’是 V V V的子集,且 E ′ E' E′是 E E E的子集,则称 G ’ G’ G’是 G G G的子图。
若有满足 V ( G ′ ) = V ( G ) V(G')=V(G) V(G′)=V(G)的子图 G ′ G' G′,则称其为 G G G的生成子图。
连通图的生成树是包含图中全部顶点的一个极小连通子图。(边尽可能地少,但要保持连通)
边上带有权值的图称为带权图,也称网。
7.2 图的存储
图的存储方式很多,主要介绍以下几种方法:
- 邻接矩阵(稠密图)
- 邻接表(稀疏图)
- 邻接多重表(无向图)
- 十字链表(有向图)
除了上述方法外,还有用序偶来表示(共有 ∣ V ∣ |V| ∣V∣条序偶,插删、查找不方便,适用于稀疏图)等:
v i v_{i} vi | v j v_{j} vj |
---|---|
…… | …… |
7.2.1 邻接矩阵法
typedef Arctype ArcCell,
AdjMatrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
typedef struct {
VertexType vexs[MAX_VERTEX_NUM]; //顶点向量
AdjMatrix arcs; //邻接矩阵
int vexnum, arcnum; //图当前顶点数和弧数
GraphKind kind;
}MGraph;
邻接矩阵的行为出边邻接点(弧尾),列为入边邻接点(弧头)。
无向图:第i个结点的度=第i行(或第i列)的非零元素个数。
有向图:第i个结点的出度=第i行的非零元素个数。
第i个结点的入度=第i列的非零元素个数。
第i个结点的度=第i行、第i列的非零元素个数之和。
空间复杂度: O ( ∣ n ∣ 2 ) O\left( \left| n \right|^{2} \right) O(∣n∣2),适合存储稠密图。
无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角区/下三角区)。
A n A^{n} An的元素 A n [ i ] [ j ] A^{n}[i][j] An[i][j]等于由顶点i到顶点j的长度为n的路径的数目。
Status CreateVDN(MGraph &g){
int i,j,k;
float w;
scanf("%d%d",&g.vernum,&g.arcnum);
for(i=0;i<g.vernum;i++) g.vex[i]=getchar( );
for(i=0;i<g.vernum;i++)
for(j=0;j<g.vernum;j++)
g.arcs[i][j]={INFIMTY,NULL};
for(k=0;k<g.arcnum;++k)
{
scanf("%d%d%f",&i,&j,&w);
g.arcs[i][j]=w;
g.arcs[j][i]=w;
}
return ok;
}
7.2.2 邻接表
带权则加一个域表示权值。对于有向图,分为入边表和出边表。
#define MAX_VERTEX_NUM 20
typedef struct ArcNode{
int adjvex;
struct ArcNode *nextarc; //指向下一条弧的指针
} ArcNode;
typedef struct VNode{
VertexType data;
ArcNode *firstarc; //指向第一条依附该顶点的弧的指针
} VNode, AdjList[MAX_VERTEX_NUM];
typedef struct {
AdjList vertices;
int vernum, arcnum;
int kind;
} ALGraph;
//得到图的邻接表
VNode *creat_ALgraph(int e)
{
int i,k,j;
VNode *G;
ArcNode *p;
scanf(“%d%d”,&g.vernum,&g.arcnum);
G=(VNode *)malloc(g.vernum*sizeof(VNode));
printf("Input vertex:\n");
for (i=0;i<g.vernum;++i)
{
scanf("%c",&G[i].data);
G[i].firstarc=NULL;
}
for(k=0;k<g.arcnum;k++)
{
printf("Input the i j");
scanf("%d%d",&i,&j);
p=(ArcNode *)malloc(sizeof(ArcNode));
p->adjvex=j;
p->nextarc=G[i].firstarc;
G[i].firstarc=p;
p=(ArcNode *)malloc(sizeof(ArcNode));
p->adjvex=i;
p->nextarc=G[j].firstarc;
G[j].firstarc=p;
}
return G;
}
无向图边结点的数量是 2 ∣ E ∣ 2|E| 2∣E∣( E E E表示边, 2 ∣ E ∣ 2|E| 2∣E∣即表示存在冗余存储);
有向图的空间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O\left( \left| V \right|+\left| E \right|\right) O(∣V∣+∣E∣)。
7.2.3 邻接多重表
无向图的存储可以使用邻接表,但在实际使用时,如果想对图中某顶点进行实操(修改或删除),由于邻接表中存储该顶点的节点有两个,因此需要操作两个节点。为了提高在无向图中操作顶点的效率,引入适用于存储无向图的方法——邻接多重表。
7.2.4 十字链表
用于存储有向图,其存储的空间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O\left( \left| V \right|+\left| E \right|\right) O(∣V∣+∣E∣).
边结点的结构:
mark | tailvex | headvex | tlink | hlink |
---|---|---|---|---|
/ | 尾域指示弧尾顶点的位置 | 头域指示弧头顶点的位置 | 链域指向弧尾相同的下一条弧 | 链域指向弧头相同的下一条弧 |
顶点结点的结构:
data | Firstin | Firstout |
---|---|---|
/ | 以该顶点为弧头的第一个弧结点 | 以该顶点为弧尾的第一个弧结点 |
存储结构通过定义边结点和顶点结点,然后定义十字链表下的图结构即可。
7.3 图的遍历
7.3.1 广度优先遍历(BFS)
广度优先遍历类似于树的按层序遍历的过程。其要点如下:
(1)找到与一个顶点相邻的所有顶点;(2)标记哪些顶点被访问过;(3)需要一个辅助队列。
void BFS(g,k,visited)
{
// using adjmatrix,采用的方式为非递归遍历图g,需要采用辅助队列和访问标志数组visited
setnull(Q);
Enqueue(q,k);
while (!empty(q)) { //队列不为空
i=dequeue(q);
printf("%c\n",g.vexs[i]);
visited[i]=true;
for (j=0;j<n;j++){
if ((g.arcs[i][j]==1) && (!visited[j]))
{
//(i,j)->T,T代表的是图的广度优先生成树
Enqueue(q,j);
}
}
}
}
void Traverse(g[],n)
{
int i;
static int visited[M];
for(i=1;i<=n;i++){
visited[i] = 0;
}
for(i=1;i<=n;i++){
if(visited[i]==0)
{
//count++; 记录连通分量的数量
BFS(g,i,visited);
}
}
}
邻接矩阵存储的图:
访问 ∣ V ∣ \left| V \right| ∣V∣个顶点需要 O ( | V | ) O\left( \middle| V \middle| \right) O(∣V∣)的时间。查找每个顶点的邻接点都需要 O ( | V | ) O\left( \middle| V \middle| \right) O(∣V∣)的时间,而总共有 ∣ V ∣ \left| V \right| ∣V∣个顶点,其时间复杂度= O ( ∣ V ∣ 2 ) O\left( \left| V \right|^{2} \right) O(∣V∣2)。
邻接表存储的图:
访问 ∣ V ∣ \left| V \right| ∣V∣个顶点需要 O ( | V | ) O\left( \middle| V \middle| \right) O(∣V∣)的时间。查找每个顶点的邻接点都需要 O ( | E | ) O\left( \middle| E \middle| \right) O(∣E∣)的时间,其时间复杂度= O ( ∣ V ∣ + ∣ E ∣ ) O\left( \left| V \right| + \left| E \right| \right) O(∣V∣+∣E∣)。
【注】其实对于⽆向图来说,查找每个顶点的邻接点都需要 O ( 2 | E | ) O\left( 2\middle| E \middle| \right) O(2∣E∣)的时间。
7.3.2 深度优先遍历(DFS)
基本思路:采用递归算法。
从图中某个顶点出发,访问此顶点,然后**依次从其未被访问的邻接点出发深度优先遍历图**,直至所有与其有路径相同的顶点都被访问到;若此时图中尚有顶点未被访问,则另选图中一个未被访问的顶点作为起始点,重复以上过程,直至图中所有顶点都被访问到为止。
depthFirstSearch(v)
{
Label vertex v as reached
for (each unreached vertex u adjacent from v)
depthFirstSearch(u);
}
代码如下:
Boolean visited[MAX];
Status (* VisitFunc)(int v);
//dfs using adjmatrix
void dfs(i)
{
//count++; 记录连通分量的数量
printf("node:%c\n",g.vexs[i]);
visited[i]= TRUE;
for (j=0;j<n;j++)
{
if ((g.arcs[i][j]==1) && (!visited[j]))
{
//(i,j)->T,T代表的是图的深度优先生成树
dfs(j);
}
}
}
//dfs using adjlist
void dfs(i)
{
printf("node:%c\t",gl[i].vertex);
visited[i]=1;
p=gl[i].link;
while (p!=NULL)
{
if (!visited[p->adjvex])
dfs(p->adjvex);
p=p->next;//找下一邻接点
}
}
复杂度分析:
空间复杂度:来自函数调用栈,最坏情况,递归深度为 O ( | V | ) O\left( \middle| V \middle| \right) O(∣V∣).
时间复杂度与BFS是一样的,主要取决于使用哪种数据结构来存储图:
邻接矩阵存储的图:
访问 ∣ V ∣ \left| V \right| ∣V∣个顶点需要 O ( | V | ) O\left( \middle| V \middle| \right) O(∣V∣)的时间。查找每个顶点的邻接点都需要 O ( | V | ) O\left( \middle| V \middle| \right) O(∣V∣)的时间,而总共有 ∣ V ∣ \left| V \right| ∣V∣个顶点,其时间复杂度= O ( ∣ V ∣ 2 ) O\left( \left| V \right|^{2} \right) O(∣V∣2)。
邻接表存储的图:
访问 ∣ V ∣ \left| V \right| ∣V∣个顶点需要 O ( | V | ) O\left( \middle| V \middle| \right) O(∣V∣)的时间。查找每个顶点的邻接点都需要 O ( | E | ) O\left( \middle| E \middle| \right) O(∣E∣)的时间,其时间复杂度= O ( ∣ V ∣ + ∣ E ∣ ) O\left( \left| V \right| + \left| E \right| \right) O(∣V∣+∣E∣)。
7.4 最小生成树
对于带权连通无向图 G = ( V , E ) G=(V,E) G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设 R R R为 G G G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则T称为G的最小生成树(MST)。
生成树的一些概念:
深度优先生成树与广度优先生成树(前面一节有讲怎么构成)
生成森林:非连通图每个连通分量的生成树一起组成非连通图的集合叫做生成森林。
[说明]
一个图可以有许多棵不同的生成树,所有生成树具有以下共同特点:
- 生成树的顶点个数与图的顶点个数相同
- 生成树是图的极小连通子图
- 一个有n个顶点的连通图的生成树有n-1条边
- 生成树中任意两个顶点间的路径是唯一的
- 在生成树中再加一条边必然形成回路
- 含n个顶点n-1条边的图不一定是生成树
最小生成树有以下重要性质:[注意红、蓝、紫的颜色标记]
假设 ( u , v ) (u,v) (u,v)具有最小权,其中 u ∈ U [ r e d ] , v ∈ V − U [ b l u e ] u\in U[red],v\in V-U[blue] u∈U[red],v∈V−U[blue],则必定 ∃ M S T c o n t a i n s ( u , v ) [ p u r p l e ] \exists MST\; contains\; (u,v)[purple] ∃MSTcontains(u,v)[purple]。
下面的算法将用到这一性质。
7.4.1 Prim算法
从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。算法的步骤如下:
-
Step 1: x ∈ V x\in V x∈V, L e t A = x , B = V − x Let\;A = {x}, B = V - {x} LetA=x,B=V−x
-
Step 2: Select ( u , v ) ∈ E (u, v)\in E (u,v)∈E, u ∈ A , v ∈ B u\in A, v\in B u∈A,v∈B such that ( u , v ) (u, v) (u,v) has the smallest weight between A and B
-
Step 3: ( u , v ) (u, v) (u,v) is in the tree. A = A ∪ { v } , B = B − { v } A = A\cup \{v\}, B = B - \{v\} A=A∪{v},B=B−{v}
-
Step 4: If B = ∅ B =\varnothing B=∅, stop; otherwise, go to Step 2.
其时间复杂度为 O ( n 2 ) O\left( n^{2} \right) O(n2)(其中 n = ∣ V ∣ n=\left| V \right| n=∣V∣),适合用于边稠密图。
Red
、Blue
点可用以下方式标记:
Color Mark
O(n)
set
集合O(n)
BitString
位串
代码实现:
有两个需要注意的变量:
lowcost[v]
adjvex[v]
记录是哪里来的结点
typedef struct{
VertexType adjvex;
VRType lowcost;
}closedge[MAX_VERTEX_NUM];
//USING Matrix
//closedge[k].lowcost=0 //red point notation
void MiniSpanTree_Prim(MGraph G,VertexType u){
k = LocateVex(G,u); //u为开始结点
for (j=0; j<G.vexnum; ++j)
if (j!=k)
closedge[j] = { u, G.arcs[k][j].adj };
closedge[k].lowcost = 0; // U={u}
for (i=0; i<G.vexnum; ++i) {
k = Minimum(closedge); //求出下一个结点
printf(closedge[k].adjvex, G.vexs[k]); //求出生成树的边
closedge[k].lowcost = 0; //加入red point set
for (j=0; j<G.vexnum; ++j){
if (G.arcs[k][j].adj < closedge[j].lowcost&& closedge[j].lowcost!=0)
//新节点并入后重新表示蓝点的最短路径
closedge[j] = { G.vexs[k], G.arcs[k][j].adj };
}
}
}
7.4.2 Kruskal算法
每次选择一条权值最小的边,使这条边的两头连通(原先已经连通的就不选),直到所有结点都连通。
在这个算法中需要关注的问题是:
- 一个点能否遍历到另一个点
- 两端点是否在连通分量(集合)中(该算法使用)------->避圈法
在该算法中运用到的是并查集(Union-Find Set,存在查找集合和合并集合),并查集使用树的双亲表示法。
作为“双亲”,初始双亲指向自己;双亲可以直接指向根(通过经历的边寻找),并即是一个树的根=另一个树的根(双亲)。
在算法中,当n-1
条边全部进入生成树中时则结束。算法的时间复杂度为
O
(
∣
E
∣
l
o
g
∣
E
∣
)
O\left( \left| E \right|{log}\left| E \right| \right)
O(∣E∣log∣E∣),适合用于边稀疏图。
7.5 最短路径(SP)问题
7.5.1 BFS算法
只适用于不带权图。
#define infinity 1000000
//求顶点u到其他顶点的最短路径
void BFS_MIN_Distance(Graph G, int u)
{
//d[i]表示从u到i结点的最短路径
for (int i = 0;i < G.vexnum;i++)
{
//d[i]表示从u到i结点的最短路径
for (i = 0;i < G.vexnum;i++)
{
d[i] = infinity; //初始化路径长度
path[i] = -1; //最短路径从哪个顶点过来
}
d[u] = 0;
visited[u] = TRUE;
EnQueue(Q, u);
while (!isEmpty(Q)) //BFS算法主过程
{
DeQueue(Q, 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] = TRUE; //设为已访问标记
EnQueue(Q, w); //顶点w入队
}
}
}
}
}
7.5.2 Dijkstra算法
算法求单个源点到其余各顶点的最短路径,计算思路是离源点最低路径长度的开序。——贪心算法(每次取离源点最近的点)
设C
数组为蓝点集,起点为红点。当红点集合加入值时,关联的蓝点将更新SP
[对比Prim
算法,更新的判断式子不同]。执行以下操作:
//arcs[i,j]: the weight along path from i to j.
//Dist[k]: length of the shortest special path from source to k.
//j in C : shortest path from source to j is not known.
//k in S : shortest path from source to k is known.
Dijkstra (arcs[1…n, 1…n]) : array [2…n]
array Dist [2…n]
C <- {2,3,…,n}
for i <- 2 to n do Dist[i] <- arcs[1,i]
repeat n - 2 times
v <- some element of C minimizing Dist[v]
C <- C \ {v}
for each w in C do
Dist[w] <- min (Dist[w] , Dist[v] + arcs[v,w])
return Dist
算法实现代码如下:
void Shortpath(cost,v0,dist) //cost数组记录邻接点的带权长度
{
for (i=0;i<n;i++)
{
dist[i] = cost[v0][i];
s[i] = 0; //设为蓝点
p[i] = 0;
}
s[v0]=1; //将起始顶点设为红点
do {
wm = Maxint; //初值设为无穷大,该变量记录当前比较的最短路径
u = v0;
for (i=0;i<n;i++)
if (s[i]==0) //是蓝点
if (dist[i]) < wm)
{
u= i;
wm = dist[i];
};
s[u]=1; //将u设为红点
for (i=0;i<n;i++){
//更新蓝点的最短路径
if (s[i]==0)
if (dist[u] + cost[u][i] < dist[i])
{
dist[i] = dist[u]+cost[u][i];
p[i] = u; //用p数组记录经过的红点
}
}
num++;
} while(num != n-1);
}
path数组可以通过记录经过的红点,可以读取源点0到终点v的最短路径。以下表中的顶点4为例:
path[4] = 2 → path[2] = 3 →path[3] = 0,反过来排列,得到路径0, 3, 2, 4,这就是源点0到终点4的最短路径。
Dijkstra算法中各辅助数组的变化:
Dijkstra算法的时间复杂度:
-
对于邻接矩阵表示,为 O ( n 2 ) O\left( n^{2} \right) O(n2)(其中 n = ∣ V ∣ n=\left| V \right| n=∣V∣);
-
对于邻接表表示,为 O ( n + ∣ E ∣ ) O\left( n+|E|\right) O(n+∣E∣)(其中 n = ∣ V ∣ n=\left| V \right| n=∣V∣, ∣ E ∣ |E| ∣E∣为边数[结点的度的加和])。
若要用Dijkstra算法求每一对顶点之间的最短路径,则再加入一个
for
循环即可,算法的时间复杂度为 O ( n 3 ) O\left( n^{3} \right) O(n3)。
7.5.3 Floyd算法
Floyd算法求每一对顶点之间的最短路径的时间复杂度也为 O ( n 3 ) O\left( n^{3} \right) O(n3),但算法形式比Dijkstra算法要简单。
Floyd算法是在研究传递闭包问题时设计的。
求出每一对顶点之间的最短路径。使用动态规划思想:
将顶点的集合设为n个点序列,k
从起始位置到结束位置依次遍历,计算Dist[i,j]=min(Dist[i,j],Dist[i,k]+Dist[k,j])
。
void ShortestPath_FLOYD(MGraph G,PathMatrix &path,DistancMatrix &d)
{
for ( int i = 0; i < n; i++ )
for ( int j = 0; j < n; j++ ) {
d[i][j] = G.arcs[i][j]; //将初始邻接矩阵的距离赋值给d数组
if ( i <> j && d[i][j] < MAXINT )
path[i][j] = i; // i到j有路径,记录经过的红点(该怎么走)
else path[i][j] = 0; // i到j无路径
}
for ( int k = 0; k < n; k++ )
for ( i = 0; i < n; i++ )
for ( j = 0; j < n; j++ )
if ( d[i][k] + d[k][j] < d[i][j] ) { //下图矩阵的第k行第k列
d[i][j] = d[i][k] + d[k][j];
path[i][j] = path[k][j];
} //缩短路径长度, 绕过k到j
}
}
对于a[1][0] = 11
, path数组有:path[1][0] = 2,path[1][2] = 3,path [1][3] = 1
,则:
最短路径为:vertex 0 <- vertex 2 <- vertex 3 <- vertex 1,即**<1, 3>,❤️, 2>,<2, 0>**。
Floyd算法的时间复杂度为 O ( ∣ V ∣ 3 ) O\left( \left| V \right|^{3} \right) O(∣V∣3),空间复杂度为 O ( ∣ V ∣ 2 ) O\left( \left| V \right|^{2} \right) O(∣V∣2)。其可以用于“负权值”的图,但不能解决带有“负回路“的图(有负权值的边组成回路),这种图有可能没有最短路径。
7.6 AOV网与拓扑排序
7.6.1 基本概念
若一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclic Graph)。
可以用有向图表示一个工程。在这种有向图中,用顶点表示活动,用有向边 < V i , V j > <V_{i},V_{j}> <Vi,Vj>表示活动 V i V_{i} Vi必须先于活动 V j V_{j} Vj进行。这种有向图叫做顶点表示活动的AOV网络 (Activity On Vertices)。
拓扑排序是由某个集合上的一个偏序得到该集合上的一个线序(全序)的排序。
- 一个DAG的拓扑序列通常表示某种方案切实可行
- 一个DAG可能有多个拓扑序列
[注]
[1]线序意即:要么xRy,要么yRx。
[2]在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
每个顶点出现且只出现一次。
若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。
7.6.2 拓扑排序的实现
(1)从AOV网中选择一个没有前驱(入度为0)的顶点并输出;
(2)从网中删除该顶点和所有以它为尾的弧;
(3)重复(1)和(2)直到当前的AOV网为空或当前网中不存在无前驱的顶点为止(即有向图中存在环)。
入度可以同通过在邻接表的表头结点增加一个ID域,记录入度,或创建一个ID数组。
为了避免重复检测入度为0的顶点,可用栈暂存所有入度为0的顶点(也可以使用队列)。栈的构造方式可以是独立的辅助栈,表示如后面的图所示,也可以是ID域栈(静态链栈)。方法是:
设一个栈顶位置的指针,将所有未处理过的入度为0的结点连接起来,形成一个链栈;栈初始化时将top指针赋为-1,表示该栈为空栈。
将顶点i进栈时,使用头插法,执行以下指针的修改:
//id相当于后继next,将入度为0的顶点链起来
dig[i].id = top;
top = i;
退栈操作,使用头删法,可以写成:
//位于栈顶的顶点位置记于j,top退到次栈顶
j = top;
top = dig[i].id;
使用Queue暂存的排序方式称为广度拓扑排序,使用Stack暂存的排序方式称为深度拓扑排序。
独立的辅助栈表示如下:
==静态链栈的代码实现==如下:
typedef int datatype;
typedef int vextype;
typedef struct node
{
int adjvex;
struct node* next;
} edgenode;
typedef struct {
vextype vertex;
int id;
edgenode* link;
} vexnode;
vexnode dig[n]; //Directed Graph(有向图)
void Topsort(dig)
{
top = -1;
for (i = 0; i < n; i++)
if (dig[i].id == 0)
{
dig[i].id = top;
top = i;
}
while (top != -1)
{
j = top; //出栈,接下来对其有关的顶点入度进行操作
top = dig[top].id; //栈顶指针指向次栈顶的顶点
printf("%d\t",dig[j].vertex);
count ++; //代表有多少顶点进行了拓扑排序,对输出顶点进行计数
p = dig[j].link;
while (p)
{
k = p->adjvex; //邻接点的数字信息
dig[k].id --;
if (dig[k].id == 0)
{
dig[k].id = top;
top = k;
}
p = p->next; //对下一个邻接点进行操作
}//while p
} //while top
if (count < n) printf("has a cycle!\n"); //该有向图有回路
}
其时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O\left( \left| V \right| + \left| E \right| \right) O(∣V∣+∣E∣);若采用邻接矩阵,则需 O ( ∣ V ∣ 2 ) O\left( \left| V \right|^{2} \right) O(∣V∣2)。
*7.6.3 逆拓扑排序的实现
利用DFS算法实现,在顶点退栈前输出。
void DFSTraverse(Graph G) //对图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
visited[v]=TRUE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0;x=NextNeighbor(G,v,w))
{
if(!visited[w]) //w为u的尚未访问的邻接顶点
DFS(G,w);
}
print(v); //输出顶点
}
7.7 AOE网与关键路径
7.7.1 AOE网
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)。
AOE网具有以下两个性质:
(1)只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
(2)只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
另外,有些活动可以并行进行。
在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;
也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
7.7.2 关键路径概述
定义:从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。
完成整个工程的最短时间就是关键路径的长度。若关键活动不能按时完成,则整个工程的完成时间就会延长。
-
事件 v k v_{k} vk的最早发⽣时间
ve(k)
——假设开始点是 v 1 v_{1} v1,则从 v 1 v_{1} v1到 v i v_{i} vi的最长路径长度称为其最早开始时间,决定了所有从 v k v_{k} vk开始的活动能够开工的最早时间 -
活动 a i a_{i} ai的最早开始时间
e(i)
——指该活动弧的起点所表示的事件的最早发⽣时间 -
事件 v k v_{k} vk的最迟发生时间
vl(k)
——它是指在不推迟整个工程完成的前提下,该事件最迟必须发⽣的时间 -
活动 a i a_{i} ai的最迟开始时间
l(i)
——它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差 -
活动 a i a_{i} ai的时间余量
d(i)=l(i)-e(i)
,表示在不增加完成整个⼯程所需总时间的情况下,活动 a i a_{i} ai可以拖延的时间
若⼀个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0
即==l(i) = e(i)
的活动是关键活动==;关键活动确定后,路径上的获得都是关键活动的路径就是关键路径。
7.7.3 求关键路径
- 求
Ve(i)
——拓扑排序可以得到
v e ( k ) = { 0 ( k = 1 ) max { v e ( j ) + w e i g h t ( < v j , v k > } ( < v j , v k > ∈ p ( k ) v_{e}(k)=\left\{\begin{array}{ll} 0 & (k=1) \\ \max \left\{v_{e}(j)+weight\left(<v_{j}, v_{k}>\right\}\right. & \left(<v_{j}, v_{k}>\in p(k)\right. \end{array}\right. ve(k)={0max{ve(j)+weight(<vj,vk>}(k=1)(<vj,vk>∈p(k)
其中 p ( k ) p(k) p(k)表示所有到达 v k v_{k} vk的有向边的集合, w e i g h t ( < v j , v k > ) weight(<v_{j},v_{k}>) weight(<vj,vk>)为有向边 < v j , v k > <v_{j},v_{k}> <vj,vk>上的权值。
- 求
Vl(j)
——逆拓扑排序可以得到
v l ( k ) = { v e ( n ) ( k = n ) min { v l ( j ) − w e i g h t ( < v k , v j > } ( < v k , v k > ∈ s ( k ) v_{l}(k)=\left\{\begin{array}{ll} v_{e}(n) & (k=n) \\ \min \left\{v_{l}(j)-weight\left(<v_{k}, v_{j}>\right\}\right. & \left(<v_{k}, v_{k}>\in s(k)\right. \end{array}\right. vl(k)={ve(n)min{vl(j)−weight(<vk,vj>}(k=n)(<vk,vk>∈s(k)
其中 s ( k ) s(k) s(k)为所有从 v ( k ) v(k) v(k)发出的有向边的集合。
- 求
e(i)
若活动 a i a_{i} ai是由弧 < v k , v j > <v_{k},v_{j}> <vk,vj>表示的,则只有事件 v k v_{k} vk发生了,活动 a i a_{i} ai才能开始,则活动 a i a_{i} ai的最早开始时间应等于事件 v k v_{k} vk的最早开始时间,即: e ( i ) = v e ( k ) e(i)=v_{e}(k) e(i)=ve(k)。
- 求
l(i)
活动 a i a_{i} ai的最晚开始时间是指在不推迟整个工程完成日期的前提下,必须开始的最晚时间。若由弧 < v k , v j > <v_{k},v_{j}> <vk,vj>表示,则 a i a_{i} ai的最晚开始时间要保证事件 v j v_{j} vj的最迟发生时间不拖后,则有: l ( i ) = v l ( j ) − w e i g h t ( < v k , v j > ) l(i)=v_{l}(j)-weight(<v_{k},v_{j}>) l(i)=vl(j)−weight(<vk,vj>)。
- 计算
l(i)-e(i)
l(i)-e(i)
称为活动的时间余量;若
l
(
i
)
=
e
(
i
)
l(i) = e(i)
l(i)=e(i),则该活动为关键活动。
关键路径代码求解如下:
void CriticalPath() {
//在此算法中需要对邻接表中单链表的结点加以修改, 在各结点中增加一个int域cost, 记录该结点所表示的边上的权值。
for (i = 0; i < n; i++) Ve[i] = 0;
for (i = 0; i < n; i++) {
p = NodeTable[i].firstadj;
while (p != NULL) {
k = p->adjvex;
if (Ve[i] + p->cost > Ve[k])
Ve[k] = Ve[i] + p->cost;
p = p->nextadj;
}
}
for (i = 0; i < n; i++)
Vl[i] = Ve[n - 1];
for (i = n - 2; i; i--) {
p = NodeTable[i].firstadj;
while (p != NULL) {
k = p->adjvex;
if (Vl[k] - p->cost < Vl[i])
Vl[i] = Vl[k] - p->cost;
p = p->nextadj;
}
}
for (i = 0; i < n; i++) {
p = NodeTable[i].adj;
while (p != NULL) {
k = p->adjvex;
e = Ve[i]; l = Vl[k] - p->cost;
if (l == e) //输出<i, k>是关键活动
p = p->link;
}
}
}
在拓扑排序求Ve(i)
和逆拓扑有序求Vl(i)
时, 所需时间为
O
(
n
+
e
)
O(n+e)
O(n+e), 求各个活动的e(k)
和l(k)
时所需时间为
O
(
e
)
O(e)
O(e), 则算法的时间复杂度仍然是
O
(
n
+
e
)
O(n+e)
O(n+e)。
ch8.查找
8.1 查找算法的评价指标
查找长度:在查找运算中,需要对比关键字的次数称为查找长度;
平均查找长度(ASL,Average Search Length):所有查找过程中进行关键字的比较次数的平均值。
A
S
L
=
∑
i
=
1
n
P
i
C
i
ASL = {\sum\limits_{i = 1}^{n}{P_{i}C_{i}}}
ASL=i=1∑nPiCi(
P
i
P_{i}
Pi为查找第i
个元素的概率,
C
i
C_{i}
Ci为查找第i
个元素的查找长度,n
为数据元素个数)
8.2 静态查找表
8.2.1 顺序查找
0号位置添加哨兵(sentry)实现:
typedef struct
{
ST.elem[0]=key; //"哨兵"
int TableLen; //表的长度
}SSTable;
int Search_Seq(SSTable ST,ElemType key) //顺序查找
{
ST.elem[0]=key; //0号位置存“哨兵”
int i;
for(i=ST.TableLen;ST.elem[i]!=key;--i){} //从后往前找
return i; //查找成功,则返回元素下标;查找失败,则返回0
}
查找效率分析:
A S L 成 功 = 1 + 2 + 3 + … + n n = n + 1 2 {ASL}_{成功} = \frac{1 + 2 + 3 + \ldots + n}{n} = \frac{n + 1}{2} ASL成功=n1+2+3+…+n=2n+1(第一个查找到花1次,第二个花两次,以此类推),时间复杂度为 O ( n ) O(n) O(n)。
[注]
平均查找长度也可以这样表示: A S L 成 功 = ∑ i = n 1 ( n − i + 1 ) / n = ( n + 1 ) / 2 ASL_{成功}=\sum_{i=n}^{1}(n-i+1) / n=(n+1) / 2 ASL成功=∑i=n1(n−i+1)/n=(n+1)/2;
若存在查找失败的情况,则需要另外进行讨论。
若顺序查找思想放在有序表中,表中元素有序存放(递增/递减),若……,则说明查找失败。
一个成功结点的查找长度=自身所在层数;一个失败结点的查找长度=其父结点所在层数。
默认情况下,各种失败情况或成功情况都等概率发生。
若各个关键字被查概率不同,可按照被查概率降序排列,这样可以使查找成功时ASL更少。
8.2.2 折半查找
又称“二分查找”,仅适用于有序表的查找(有序表在计算机学科中默认正序)。
int Binary_Search(SSTable L,ElemType key)
{
int low=0,high=L.TableLen-1,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 分隔后,左半部分比右半部分少一个元素。
折半查找的判定树中,若 m i d = ⌊ ( l o w + h i g h ) / 2 ⌋ mid = \left\lfloor {\left( low + high \right)/2} \right\rfloor mid=⌊(low+high)/2⌋,则对于任何一个结点,必有右子树结点数-左子树结点数=0或1。
折半查找的判定树一定是平衡二叉树。
折半查找的判定树中,只有最下面一层是不满的,因此当元素个数为n时,树高 h = ⌈ l o g 2 ( n + 1 ) ⌉ h = \left\lceil {{log}_{2}\left( n + 1 \right)} \right\rceil h=⌈log2(n+1)⌉(不包含失败结点)。
查找的ASL不超过h,故其时间复杂度为 O ( l o g 2 n ) O\left( {log}_{2}n \right) O(log2n)。
量**;若 l ( i ) = e ( i ) l(i) = e(i) l(i)=e(i),则该活动为关键活动。
关键路径代码求解如下:
void CriticalPath() {
//在此算法中需要对邻接表中单链表的结点加以修改, 在各结点中增加一个int域cost, 记录该结点所表示的边上的权值。
for (i = 0; i < n; i++) Ve[i] = 0;
for (i = 0; i < n; i++) {
p = NodeTable[i].firstadj;
while (p != NULL) {
k = p->adjvex;
if (Ve[i] + p->cost > Ve[k])
Ve[k] = Ve[i] + p->cost;
p = p->nextadj;
}
}
for (i = 0; i < n; i++)
Vl[i] = Ve[n - 1];
for (i = n - 2; i; i--) {
p = NodeTable[i].firstadj;
while (p != NULL) {
k = p->adjvex;
if (Vl[k] - p->cost < Vl[i])
Vl[i] = Vl[k] - p->cost;
p = p->nextadj;
}
}
for (i = 0; i < n; i++) {
p = NodeTable[i].adj;
while (p != NULL) {
k = p->adjvex;
e = Ve[i]; l = Vl[k] - p->cost;
if (l == e) //输出<i, k>是关键活动
p = p->link;
}
}
}
在拓扑排序求Ve(i)
和逆拓扑有序求Vl(i)
时, 所需时间为
O
(
n
+
e
)
O(n+e)
O(n+e), 求各个活动的e(k)
和l(k)
时所需时间为
O
(
e
)
O(e)
O(e), 则算法的时间复杂度仍然是
O
(
n
+
e
)
O(n+e)
O(n+e)。