第1章 数据结构绪论
1.1 基本概念和术语
数据:描述客观事物的符号。计算机可以操作的对象,是能被计算机识别, 并输入给计算机处理的符号集合
数据元素:组成数据的基本单位
数据项:一个数据元素由若干个数据项组成。数据项是数据的最小单位
数据对象:性质相同的数据元素的集合,是数据的子集
数据结构:相互之间存在一种或多种特定关系的数据元素的集合
数据对象是一个数据类型的具体实例;
一个数据对象中有若干数据元素;
一个数据元素中有若干数据项
1.2 逻辑结构与物理结构
逻辑结构:指数据对象中数据元素之间的相互关系
- 集合结构:数据元素在同一个集合,但之间没有其他关系
- 线性结构:一对一
- 树形结构:一对多
- 图形结构:多对多
表示数据的逻辑结构:
- 每一个数据元素看作结点,用圆圈表示
- 元素之间的逻辑关系用连线表示,有方向则连线加箭头
物理结构:指数据的逻辑结构在计算机中的存储形式
- 顺序存储结构:数据元素存放在地址连续的存储单元里,其数据间的逻辑关系和物理关系一致
- 链式存储结构:数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的
1.3 抽象数据类型
数据类型:一组性质相同的值的集合及定义在此集合上的一些操作的总称
- 原子类型:不可分割,比如整型
- 结构类型:可以分割,比如整型数组、结构体
抽象数据类型(Abstruct Data Type, ADT):一个数学模型及定义在该模型上的一组操作
描述抽象数据类型:
ADT 抽象数据类型名
Data
数据元素之间逻辑关系的定义
Operation
操作1
初始条件
操作结果描述
操作2
……
操作n
……
endADT
第2章 算法
2.1 定义、特性、要求
算法:解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作
算法的特性:
- 输入输出:零个或多个输入,一个或多个输出
- 有穷性:不能出现死循环,处理时间可接受
- 确定性:每一个步骤都有确定的含义,没有二义性
- 可行性:每一个步骤都能通过执行有限次数完成
算法设计的要求:
- 正确性
- 可读性
- 健壮性:输入数据不合法时也能做出相关处理
- 时间效率高、存储量低
算法效率的度量方法:
-
事后统计方法
-
事前分析估算方法
一个程序的运行时间,依赖于算法的好坏和问题的输入规模
计算运行时间的方法:设算法输入规模为n,计算对运行时间有消耗的基本操作的执行次数f(n)
函数的渐进增长:在输入规模n没有限制的情况下,只要超过一个数值N,这个函数总是大于另一个函数,就称这个函数渐进增长快于另一个函数
只关注最高次项的阶数
2.2 算法时间复杂度
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n变化情况并确定T(n)的数量级
算法的时间复杂度,即算法的时间度量,记作:T(n) = O(f(n))
。它表示随问题规模n的增大,算法执行时间的增长率和问题规模n的某个函数f(n)的增长率相同,称作算法的渐近时间复杂度,简称时间复杂度
大O记法
一般情况下,随着n的增大,T(n)增长最慢的算法为最优算法
-
常数阶O(1)
顺序结构,执行的次数恒定
int sum = 0, n = 100; /* 执行一次 */ sum = (1 + n) * n / 2; /* 执行一次 */ printf("%d", sum); /* 执行一次 */
-
线性阶O(n)
单层循环结构
int i; for(i = 0; i < n; i++) { /* 时间复杂度为O(1)的程序步骤序列 */ }
-
对数阶O(logn)
int count = 1; while (count < n) { count = count * 2; /* 执行log2n次 */ /* 时间复杂度为O(1)的程序步骤序列 */ }
-
平方阶
循环嵌套结构
O(n2)
int i,j; for(i = 0; i < n; i++) { for(j = 0; j < n; j++) { /* 时间复杂度为O(1)的程序步骤序列 */ } }
O(m×n)
int i,j; for(i = 0; i < m; i++) { for(j = 0; j < n; j++) { /* 时间复杂度为O(1)的程序步骤序列 */ } }
时间复杂度排序: O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)
一般指的是最坏时间复杂度
2.3 算法空间复杂度
计算算法所需的存储空间,计算公式:S(n) = O(f(n))
,n为问题规模,f(n)为算法语句关于n所占存储空间的函数
存储空间:指令、常数、变量、输入数据、操作数据分配的空间
若算法执行时所需的辅助空间相对于输入数据量而言是一个常数,空间复杂度为O(1)
复杂度一般指时间复杂度
第3章 线性表
3.1 定义
线性表(List):零个或多个数据元素的有限序列,元素间具有线性关系,即前驱后继关系
线性表的抽象数据类型
ADT 线性表(List)
data
线性表的数据对象集合为{a1,a2,...,an},每个元素类型为DataType。除第一个和最后一个元素之外,每个元素都有一个直接前驱元素和直接后继元素。数据元素之间是一对一的关系。
Operation
InitList(*L): 初始化操作,建立一个空的线性表L
ListEmpty(L): 若线性表为空,返回true,否则返回false
ClearList(*L): 将线性表清空
GetElem(L, i, *e): 将表中第i个位置上的元素返回给e
LocateElem(L, e): 在线性表中查找与e相等的值。查找成功,返回元素序号;否则返回0
ListInsert(*L, i, e): 在第i个位置插入元素e
ListDelete(*L, i, *e): 删除表中第i个元素,并用e返回值
ListLength(L): 返回线性表中的元素
endADT
两个线性表集合的并集操作
思路:循环B中的元素,判断是否在A中。若不存在,插入A中
/*将所有的在线性表Lb中但不在La中的数据元素插入到La中*/
void unionL(SqList *La,SqList Lb)
{
int La_len,Lb_len,i;
ElemType e; /*声明与La和Lb相同的数据元素e*/
La_len=ListLength(*La); /*求线性表的长度 */
Lb_len=ListLength(Lb);
for (i=1;i<=Lb_len;i++)
{
GetElem(Lb,i,&e); /*取Lb中第i个数据元素赋给e*/
if (!LocateElem(*La,e)) /*La中不存在和e相同数据元素*/
ListInsert(La,++La_len,e); /*插入*/
}
}
3.2 线性表的顺序存储结构
用一段地址连续的存储单元依次存储线性表的数据元素,如一维数组
结构代码
#define MAXSIZE 20 /* 存储空间初始分配量 */
typedef int ElemType; /* ElemType类型根据实际情况而定,这里假设为int */
typedef struct
{
ElemType data[MAXSIZE]; /* 数组,存储数据元素 */
int length; /* 线性表当前长度 */
}SqList;
描述顺序存储结构:
- 存储空间的起始位置:数组data;
- 线性表的最大存储容量:数组长度MAXSIZE;
- 线性表的当前长度:length
- 数组长度:存放线性表的存储空间的长度,一般不可改变
- 线性表的长度:线性表中数据元素的个数,随着表中数据的插入、删除而改变
地址计算的方法:假设存储一个数据元素占用c个存储单元,LOC(ai) = LOC(a1) + (ai-1)*c
顺序存储结构为随机存取结构:存入、读出元素的时间性能为O(1)
获取元素操作:O(1)
#define OK 1
#define ERROR 0
#define TRUE 1
#DEFINE FALSE 0
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
/* 初始条件:顺序线性表L已存在,1≤i≤ListLength(L) */
/* 操作结果:用e返回L中第i个数据元素的值,注意i是指位置,第1个位置的数组是从0开始 */
Status GetElem(SqList L,int i,ElemType *e)
{
if(L.length==0 || i<1 || i>L.length)
return ERROR;
*e=L.data[i-1];
return OK;
}
插入操作:O(n)
算法思路:
- 如果插入位置不合理,抛出异常
- 如果线性表的长度大于数组长度,则抛出异常或者动态增加容量
- 从最后一个元素开始向前遍历到第i个位置,分别将他们向后移动一个位置
- 将要插入元素填入位置i处
- 表长加1
/* 初始条件:顺序线性表L已存在,1≤i≤ListLength(L), */
/* 操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加1 */
Status ListInsert(SqList *L,int i,ElemType e)
{
int k;
if (L->length==MAXSIZE) /* 顺序线性表已经满 */
return ERROR;
if (i<1 || i>L->length+1)/* 当i比第一位置小或者比最后一位置后一位置还要大时 */
return ERROR;
if (i<=L->length) /* 若插入数据位置不在表尾 */
{
for(k=L->length-1;k>=i-1;k--) /* 将要插入位置之后的数据元素向后移动一位 */
L->data[k+1]=L->data[k];
}
L->data[i-1]=e; /* 将新元素插入 */
L->length++;
return OK;
}
删除操作:O(n)
算法思路:
- 如果删除位置不合理,抛出异常
- 取出删除元素
- 从删除位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置
- 表长减1
/* 初始条件:顺序线性表L已存在,1≤i≤ListLength(L) */
/* 操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减1 */
Status ListDelete(SqList *L,int i,ElemType *e)
{
int k;
if (L->length==0) /* 线性表为空 */
return ERROR;
if (i<1 || i>L->length) /* 删除位置不正确 */
return ERROR;
*e=L->data[i-1];
if (i<L->length) /* 如果删除不是最后位置 */
{
for(k=i;k<L->length;k++)/* 将删除位置后继元素前移 */
L->data[k-1]=L->data[k];
}
L->length--;
return OK;
}
线性表顺序存储结构的优缺点
优点:
- 无须为表示元素之间的逻辑关系而增加额外的存储空间
- 可以快速地存取表中任意位置的元素
缺点:
- 插入和删除操作需要移动大量的元素
- 当线性表长度变化较大时,难以确定存储空间的容量
- 造成存储空间“碎片”
3.3 线性表的链式存储结构
3.3.1 单链表
链式结构中,除了存储数据元素信息(数据域),还要存储后继元素的存储地址(指针域)。这两部分组成数据元素ai的存储映像,也叫结点(Node),n个结点组成一个链表
单链表:每个结点只包含一个指针域
链表中第一个结点的存储位置称为头指针;最后一个结点的指针指向NULL(空);有时单链表的第一个结点前附设一个结点,称为头结点,头结点的指针域存储指向第一个结点的指针
头指针与头结点的异同
头指针:
- 头指针是指单链表中指向第一个结点的指针,如果有头结点,头指针指向头结点的位置,头结点的指针域指向第一个结点a1的位置
- 头指针具有表示作用,所以常以头指针冠以链表的名字
- 无论链表是否为空,头指针均不为空。头指针是链表的必要元素
头结点:
- 头结点是为了统一操作和方便而设立的,放在第一元素之前,其数据域一般没有意义(也可存放链表的长度)
- 有了头结点,对在第一个元素结点前插入和删除结点,其操作与其他结点的操作一样
- 头结点不是链表的必要元素
结构代码
/* 线性表的单链表存储结构 */
typedef struct Node
{
ElemType data;
struct Node *next;
}Node;
typedef struct Node *LinkList; /* 定义LinkList */
单链表的读取:O(n)
算法思路:
- 声明一个指针p指向链表的第一个结点,初始化j从1开始
- 当j<i时,就遍历链表,让p的指针向后移动,不断指向下一个结点,j+1
- 若到链表末尾p为空,则说明第i个结点不存在
- 否则查找成功,返回结点p的数据
/* 初始条件:链式线性表L已存在,1≤i≤ListLength(L) */
/* 操作结果:用e返回L中第i个数据元素的值 */
Status GetElem(LinkList L,int i,ElemType *e)
{
int j;
LinkList p; /* 声明一结点p */
p = L->next; /* 让p指向链表L的第一个结点 */
j = 1; /* j为计数器 */
while (p && j<i) /* p不为空或者计数器j还没有等于i时,循环继续 */
{
p = p->next; /* 让p指向下一个结点 */
++j;
}
if ( !p || j>i )
return ERROR; /* 第i个元素不存在 */
*e = p->data; /* 取第i个元素的数据 */
return OK;
}
单链表的插入:O(n)
算法思路:
- 声明一个指针p指向链表头结点,初始化j从1开始
- 当j<i时,遍历链表,让p的指针向后移动,j+1
- 若到链表末尾p为空,则说明第i个结点不存在
- 否则查找成功,在系统中生成一个空结点s
- 将数据元素e赋值给s->data
- 单链表插入标准语句:
s->next = p->next; p->next = s;
- 返回成功
/* 初始条件:链式线性表L已存在,1≤i≤ListLength(L), */
/* 操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加1 */
Status ListInsert(LinkList *L,int i,ElemType e)
{
int j;
LinkList p,s;
p = *L;
j = 1;
while (p && j < i) /* 寻找第i个结点 */
{
p = p->next;
++j;
}
if (!p || j > i)
return ERROR; /* 第i个元素不存在 */
s = (LinkList)malloc(sizeof(Node)); /* 生成新结点(C语言标准函数) */
s->data = e;
s->next = p->next; /* 将p的后继结点赋值给s的后继 */
p->next = s; /* 将s赋值给p的后继 */
return OK;
}
单链表的删除:O(n)
算法思路:
- 声明一个指针p指向链表头结点,初始化j从1开始
- 当j<i时,遍历链表,让p的指针向后移动,j+1
- 若到链表末尾p为空,则说明第i个结点不存在
- 否则查找成功,将p->next赋给q
- 单链表删除标准语句:
p->next = q->next;
- 将q结点中的数据赋给e,作为返回值
- 释放结点q
- 返回成功
/* 初始条件:链式线性表L已存在,1≤i≤ListLength(L) */
/* 操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减1 */
Status ListDelete(LinkList *L,int i,ElemType *e)
{
int j;
LinkList p,q;
p = *L;
j = 1;
while (p->next && j < i) /* 遍历寻找第i个元素 */
{
p = p->next;
++j;
}
if (!(p->next) || j > i)
return ERROR; /* 第i个元素不存在 */
q = p->next;
p->next = q->next; /* 将q的后继赋值给p的后继 */
*e = q->data; /* 将q结点中的数据给e */
free(q); /* 让系统回收此结点,释放内存 */
return OK;
}
对于多项插入和删除,链式存储结构只需要遍历一次O(n),插入和删除都是O(1);而顺序存储结构每一次都是O(n)
单链表的整表创建
算法思路:
- 声明一指针p和计数器变量i
- 初始化一空链表L
- 让L的头结点的指针指向NULL,即建立一个带头结点的单链表
- 循环:
- 生成一新结点赋值给p
- 随机生成一数字赋值给p的数据域p->data
- 将p插入到链表中
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(头插法) */
void CreateListHead(LinkList *L, int n)
{
LinkList p;
int i;
srand(time(0)); /* 初始化随机数种子 */
*L = (LinkList)malloc(sizeof(Node));
(*L)->next = NULL; /* 先建立一个带头结点的单链表 */
for (i=0; i<n; i++)
{
p = (LinkList)malloc(sizeof(Node)); /* 生成新结点 */
p->data = rand()%100+1; /* 随机生成100以内的数字 */
p->next = (*L)->next;
(*L)->next = p; /* 插入到表头 */
}
}
头插法:始终让新结点在第一个位置
尾插法:始终让新结点在最后一个位置
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(尾插法) */
void CreateListTail(LinkList *L, int n)
{
LinkList p,r;
int i;
srand(time(0)); /* 初始化随机数种子 */
*L = (LinkList)malloc(sizeof(Node)); /* L为整个线性表 */
r=*L; /* r为指向尾部的结点 */
for (i=0; i<n; i++)
{
p = (Node *)malloc(sizeof(Node)); /* 生成新结点 */
p->data = rand()%100+1; /* 随机生成100以内的数字 */
r->next=p; /* 将表尾终端结点r的指针指向新结点 */
r = p; /* 将当前的新结点定义为表尾终端结点 */
}
r->next = NULL; /* 表示当前链表结束 */
}
注意L和r的关系,L是链表(头结点),而r是指向尾结点的变量
单链表的整表删除
算法思路:
- 声明一结点p和q
- 将第一个结点赋值给p
- 循环:
- 将下一个结点赋值给q
- 释放p
- 将q赋给p
/* 初始条件:链式线性表L已存在。操作结果:将L重置为空表 */
Status ClearList(LinkList *L)
{
LinkList p,q;
p=(*L)->next; /* p指向第一个结点 */
while(p) /* 没到表尾 */
{
q=p->next;
free(p);
p=q;
}
(*L)->next=NULL; /* 头结点指针域为空 */
return OK;
}
单链表结构与顺序存储结构的优缺点
顺序存储结构 链式存储结构(单链表) 存储方式 用一段连续的存储单元依次存储线性表的数据元素 用一组任意的存储单元存放线性表的元素 时间性能 查找:O(1)
插入和删除:O(n)查找:O(n)
插入和删除:在找出某位置的指针后,O(1)空间性能 需要预分配存储空间,分大了,浪费,分小了易发生上溢 不需要预分配存储空间,只要有就可以动态分配,元素个数不受限制 选择 频繁查找,很少插入和删除 频繁插入和删除
元素个数较大或者不确定
3.3.2 静态链表
用数组描述的链表
游标实现法
让数组的元素都是由两个数据域组成,data和cur。数组的每个下标都对应着一个data和一个cur。数据域data存放数据元素;cur相当于单链表中的next指针,存放该元素的后继在数组中的下标,也把cur叫做游标
结构代码
/* 线性表的静态链表存储结构 */
#define MAXSIZE 1000 /* 存储空间初始分配量 */
typedef struct
{
ElemType data;
int cur; /* 游标(Cursor) ,为0时表示无指向 */
} Component,StaticLinkList[MAXSIZE];
备用链表
未被使用的数组元素称为备用链表。数组的第一个元素,即下标为0的元素的cur存放备用链表(空闲空间的第一个元素)的第一个结点的下标,相当于尾指针,便于后续操作;数组最后一个元素的cur存放第一个有数值的元素的下标,相当于单链表中头结点的作用,当整个链表为空时,则为0
/* 将一维数组space中各分量链成一个备用链表,space[0].cur为头指针,"0"表示空指针 */
Status InitList(StaticLinkList space)
{
int i;
for (i=0; i<MAXSIZE-1; i++)
space[i].cur = i+1;
space[MAXSIZE-1].cur = 0; /* 目前静态链表为空,最后一个元素的cur为0 */
return OK;
}
面临的问题:如何用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放
解决方法:自己实现malloc()和free()两个函数的操作
静态链表的插入
malloc()算法思路:为了辨明数组中哪些分量未被使用,解决的办法是将所有的未被使用过的及已被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取得第一个结点作为待插入的新结点
malloc()的实现
/* 若备用空间链表非空,则返回分配的结点下标,否则返回0 */
int Malloc_SSL(StaticLinkList space)
{
int i = space[0].cur; /* 当前数组第一个元素的cur存的值 */
/* 就是要返回的第一个备用空闲的下标 */
if (space[0]. cur)
space[0]. cur = space[i].cur; /* 由于要拿出一个分量来使用了, */
/* 所以我们就得把它的下一个 */
/* 分量用来做备用 */
return i;
}
插入操作
/* 在L中第i个元素之前插入新的数据元素e */
Status ListInsert(StaticLinkList L, int i, ElemType e)
{
int j, k, l;
k = MAXSIZE - 1; /* 注意k首先是最后一个元素的下标 */
if (i < 1 || i > ListLength(L) + 1)
return ERROR;
j = Malloc_SSL(L); /* 获得空闲分量的下标 */
if (j)
{
L[j].data = e; /* 将数据赋值给此分量的data */
for(l = 1; l <= i - 1; l++) /* k指向第i个元素之前的位置 */
k = L[k].cur;
L[j].cur = L[k].cur; /* 把第i个元素之前的cur赋值给新元素的cur */
L[k].cur = j; /* 把新元素的下标赋值给第i个元素之前元素的cur */
return OK;
}
return ERROR;
}
静态链表的删除
free()的实现
/* 将下标为k的空闲结点回收到备用链表 */
void Free_SSL(StaticLinkList space, int k)
{
space[k].cur = space[0].cur; /* 把第一个元素的cur值赋给要删除的分量cur */
space[0].cur = k; /* 把要删除的分量下标赋值给第一个元素的cur */
}
删除操作
/* 删除在L中第i个数据元素 */
Status ListDelete(StaticLinkList L, int i)
{
int j, k;
k = MAXSIZE - 1;
if (i < 1 || i > ListLength(L))
return ERROR;
for (j = 1; j <= i - 1; j++) /* k指向第i个元素之前的位置 */
k = L[k].cur;
j = L[k].cur; /* 第i个元素之前元素的cur赋值给要删除的元素下标 */
L[k].cur = L[j].cur; /* 把要删除元素的cur赋值给第i个元素之前的cur */
Free_SSL(L, j); /* 删除第i个元素 */
return OK;
}
获得静态链表中元素的个数
/* 初始条件:静态链表L已存在。操作结果:返回L中数据元素个数 */
int ListLength(StaticLinkList L)
{
int j=0;
int i=L[MAXSIZE-1].cur;
while(i)
{
i=L[i].cur;
j++;
}
return j;
}
静态链表优缺点
优点:
在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进了在顺序存储结构中插入与删除操作需要移动大量元素的缺点
缺点:
- 没有解决连续存储分配带来的表长难以确定的问题
- 失去了顺序结构随机存取的特性
3.3.3 循环链表
将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked list)。为使空链表与非空链表处理一致,通常设置头结点
循环结束条件:p->next等于头结点
使用O(1)的时间由链表指针访问到最后一个结点:用指向终端结点的尾指针表示链表,则
- 终端节点为rear
- 头结点为rear->next
- 开始结点为rear->next->next
两个循环链表的合并
设尾指针为rearA、rearB
p=rearA->next; /* 保存A表的头结点 */
rearA->next=rearB->next->next; /* 将本是指向B表的第一个结点(不是头结点)*/
/* 赋值给reaA->next */
q=rearB->next;
rearB->next=p; /* 将原A表的头结点赋值给rearB->next */
free(q); /* 释放B表原本的头结点 */
3.3.4 双向链表
双向链表(double linked list):在单链表中的每个结点中,再设置一个指向其前驱结点的指针域
结构代码
/*线性表的双向链表存储结构*/
typedef struct DulNode
{
ElemType data;
struct DuLNode *prior; /*直接前驱指针*/
struct DuLNode *next; /*直接后继指针*/
} DulNode, *DuLinkList;
恒等式:
p->prior->next = p = p->next->prior
双向链表是单链表中扩展出来的结构,涉及一个方向的指针的操作域单链表相同,如求长度ListLength、查找元素GetElem、获得元素位置LocateElem等
插入操作
/* 将s插入到p和p->next之间 */
s -> prior = p; /*把p赋值给s的前驱*/
s -> next = p -> next; /*把p->next赋值给s的后继*/
p -> next -> prior = s; /*把s赋值给p->next的前驱*/
p -> next = s; /*把s赋值给p的后继*/
删除操作
p->prior->next=p->next; /*把p->next赋值给p->prior的后继*/
p->next->prior=p->prior; /*把p->prior赋值给p->next的前驱*/
free(p); /*释放结点*/
第4章 栈与队列
4.1 栈
4.1.1 栈的定义
栈(stack):限定仅在表尾进行插入和删除的线性表
栈的应用:“后退”键、撤销操作(undo)
- 栈顶(top):允许插入和删除的一端
- 栈底(bottom):另一端
- 空栈:不含任何元素的栈
- 栈又称后进先出(Last In First Out, LIFO)的线性表,简称LIFO结构
栈的插入操作(push):压栈、进栈、入栈
栈的删除操作(pop):出栈、弹栈
如果有n个元素,根据入栈时间的不同,有C2nn-C2nn+1种出栈顺序
栈的抽象数据类型
ADT 栈(Stack)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
InitStack(*S): 初始化操作,建立一个空栈S
DestroyStack(*S): 若栈存在,则销毁。
ClearStack(*S): 将栈清空
StackEmpty(S): 若栈为空,返回true,否则返回false
GetTop(S, * e): 若栈存在且非空,用e返回栈顶元素
Push(*S, e): 若栈S存在,插入新元素e成为栈顶
Pop(*S, *e): 删除栈S中栈顶元素,用e返回其值
StackLength(S): 返回栈的元素个数
endADT
4.1.2 栈的顺序存储结构
以数组下标为0的一端为栈底,空栈的判定条件为栈顶元素位置top=-1
结构定义
typedef int SElemType; /* SElemType类型根据实际情况而定,这里假设为int */
/* 顺序栈结构 */
typedef struct
{
SElemType data[MAXSIZE];
int top; /* 用于栈顶指针 */
}SqStack;
进栈操作:O(1)
/* 插入元素e为新的栈顶元素 */
Status Push(SqStack *S,SElemType e)
{
if(S->top == MAXSIZE -1) /* 栈满 */
{
return ERROR;
}
S->top++; /* 栈顶指针增加一 */
S->data[S->top]=e; /* 将新插入元素赋值给栈顶空间 */
return OK;
}
出栈操作:O(1)
/* 若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR */
Status Pop(SqStack *S,SElemType *e)
{
if(S->top==-1)
return ERROR;
*e=S->data[S->top]; /* 将要删除的栈顶元素赋值给e */
S->top--; /* 栈顶指针减一 */
return OK;
}
两栈共享空间
适用情况:两个具有相同数据类型的栈,空间需求有相反的关系
算法思路:
- 数组有两个端点,两个栈有两个栈底。让一个栈的栈底为数组的始端,即下标为0处;另一个栈为数组的末端,即下标为数组长度n-1处。这样,两个栈如果增加元素,则两端点向中间延伸
- 栈1为空:
top1=-1
,栈2为空:top2=n
,栈满:top1+1=top2
/* 两栈共享空间结构 */
typedef struct
{
SElemType data[MAXSIZE];
int top1; /* 栈1栈顶指针 */
int top2; /* 栈2栈顶指针 */
}SqDoubleStack;
/* 插入元素e为新的栈顶元素 */
/* stackNumber用于判断栈1还是栈2 */
Status Push(SqDoubleStack *S,SElemType e,int stackNumber)
{
if (S->top1+1==S->top2) /* 栈已满,不能再push新元素了 */
return ERROR;
if (stackNumber==1) /* 栈1有元素进栈 */
S->data[++S->top1]=e; /* 若是栈1则先top1+1后给数组元素赋值 */
else if (stackNumber==2) /* 栈2有元素进栈 */
S->data[--S->top2]=e; /* 若是栈2则先top2-1后给数组元素赋值 */
return OK;
}
/* 若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR */
Status Pop(SqDoubleStack *S,SElemType *e,int stackNumber)
{
if (stackNumber==1)
{
if (S->top1==-1)
return ERROR; /* 说明栈1已经是空栈,溢出 */
*e=S->data[S->top1--]; /* 将栈1的栈顶元素出栈 */
}
else if (stackNumber==2)
{
if (S->top2==MAXSIZE)
return ERROR; /* 说明栈2已经是空栈,溢出 */
*e=S->data[S->top2++]; /* 将栈2的栈顶元素出栈 */
}
return OK;
}
4.1.3 栈的链式存储结构的实现
栈顶放在单链表的头部,栈空时top=NULL
,不需要头结点
结构代码
/* 链栈结构 */
typedef struct StackNode
{
SElemType data;
struct StackNode *next;
}StackNode,*LinkStackPtr;
typedef struct LinkStack
{
LinkStackPtr top;
int count;
}LinkStack;
进栈操作:O(1)
/* 插入元素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;
}
出栈操作:O(1)
/* 若栈不空,则删除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;
}
4.1.4 栈的应用
递归
递归函数:一个直接调用自己或通过一系列语句间接地调用自己的函数。每个递归定义必须至少有一个条件,满足时递归不再进行
f ( n ) = { 0 , if n =0 1 , if n =1 F ( n − 1 ) + F ( n − 2 ) , if n >1 f(n)= \begin{cases} 0, & \text{if $n$=0}\\ 1,& \text{if $n$=1}\\ F(n-1)+F(n-2),& \text{if $n$>1} \end{cases} f(n)=⎩⎪⎨⎪⎧0,1,F(n−1)+F(n−2),if n=0if n=1if n>1
/* 斐波那契数列常规实现 */
int main()
{
int i;
int a[40];
a[0]=0;
a[1]=1;
printf("%d ",a[0]);
printf("%d ",a[1]);
for(i = 2;i < 40;i++)
{
a[i] = a[i-1] + a[i-2];
printf("%d ",a[i]);
}
return 0;
}
/* 斐波那契的递归函数,编译器用栈实现递归 */
int Fbi(int i)
{
if( i < 2 )
return i == 0 ? 0 : 1;
return Fbi(i-1)+Fbi(i-2); /* 这里Fbi就是函数自己,等于在调用自己 */
}
int main()
{
int i;
printf("递归显示斐波那契数列:\n");
for(i = 0;i < 40;i++)
printf("%d ", Fbi(i));
return 0;
}
迭代使用的是循环结构,递归使用的是选择结构。但是大量的递归调用会建立函数的副本,会耗费大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存
递归过程退出的顺序是它前行顺序的逆序。在退回的过程中,可能要执行某些操作,包括恢复在前行过程中存储起来的某些数据
在前行阶段,对于每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶元素的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分,即恢复调用过的状态
四则运算表达式求值
-
括号处理:只要碰到左括号就进栈,而后面出现右括号时,就让栈顶的左括号出栈,期间让数字运算。这样,最终有括号的表达式从左到右巡查一遍,栈应该是由空到有元素,最终再因全部匹配成功后成为空栈
-
后缀表达式求值:从左到右遍历表达式的每个数字和符号,遇到数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进行计算,运算结果进栈,一直到最终获得结果
-
中缀表达式转后缀表达式:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级不高于栈顶元素(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止
4.2 队列
4.2.1 队列的定义
队列(queue):只允许在一端进行插入操作,而在另一端进行删除操作的线性表
-
允许插入的一端称为队尾,允许删除的一端称为队头
-
队列是一种先进先出的线性表(First In First Out, FIFO)
队列的抽象数据类型
ADT 队列(Queue)
Data
同线性表。元素具有相同的数据类型,相邻的元素具有前驱和后继的关系。
Operation
InitQueue(*Q) : 初始化操作,建立一个空队列。
DestroyQueue(*Q): 若队列存在,则销毁
ClearQueue(*Q): 将队列清空
QueueEmpty(Q): 空,返回true
GetHead(Q, *e): 若Q存在且非空,用e返回队头元素
EnQueue(*Q, e): 若Q存在,插入e成为队尾元素
DeQueue(*Q, *e): 若Q存在,删除队头元素,并用e返回其值
QueueLength(Q): 返回元素个数
endADT
4.2.2 循环队列
队列的顺序存储结构:
- 建立数组,让下标为0的一端为队头
- 入队操作即追加一个元素,时间复杂度为O(1)
- 出队时所有元素需要向前移动,时间复杂度为O(n)
问题:
- 出队性能
- 当只有一个元素时,队头和队尾重合
引入两个指针,front指向队头元素,rear指向队尾元素的下一个位置。当front=rear时,空队列
假溢出:数组长度固定时,随着动态出队和入队,rear指针指向数组之外,数组越界而front之前空闲
循环队列:把队列头尾相接的顺序存储结构
若rear的前一个元素是数组最后一个,则指向下标为0的位置。此时,当front = rear时,队列满
front=rear时队列状态的判断:
- 方法一:设置标志变量flag,当front = rear且flag = 0时空队列,当front = rear且flag = 1时队列满
- 方法二:当队列为空时,front = rear;当队列满时,保留一个元素空间,即队列满时,数组中还有一个空闲单元。设队列的最大尺寸为QueueSize,则队满条件为
(rear + 1) % QueueSize == front
。队列长度计算公式:(rear - front + QueueSize) % QueueSize
结构代码
typedef int QElemType; /* QElemType类型根据实际情况而定,这里假设为int */
/* 循环队列的顺序存储结构 */
typedef struct
{
QElemType data[MAXSIZE];
int front; /* 头指针 */
int rear; /* 尾指针,若队列不空,指向队列尾元素的下一个位置 */
}SqQueue;
初始化代码
/* 初始化一个空队列Q */
Status InitQueue(SqQueue *Q)
{
Q->front=0;
Q->rear=0;
return OK;
}
求队列长度
/* 返回Q的元素个数,也就是队列的当前长度 */
int QueueLength(SqQueue Q)
{
return (Q.rear-Q.front+MAXSIZE)%MAXSIZE;
}
入队操作:O(1)
/* 若队列未满,则插入元素e为Q新的队尾元素 */
Status EnQueue(SqQueue *Q,QElemType e)
{
if ((Q->rear+1)%MAXSIZE == Q->front) /* 队列满的判断 */
return ERROR;
Q->data[Q->rear]=e; /* 将元素e赋值给队尾 */
Q->rear=(Q->rear+1)%MAXSIZE; /* rear指针向后移一位置, */
/* 若到最后则转到数组头部 */
return OK;
}
出队操作:O(1)
/* 若队列不空,则删除Q中队头元素,用e返回其值 */
Status DeQueue(SqQueue *Q,QElemType *e)
{
if (Q->front == Q->rear) /* 队列空的判断 */
return ERROR;
*e=Q->data[Q->front]; /* 将队头元素赋值给e */
Q->front=(Q->front+1)%MAXSIZE; /* front指针向后移一位置, */
/* 若到最后则转到数组头部 */
return OK;
}
4.2.3 队列的链式存储结构
链队列:就是尾进头出的单链表,front指向头结点(不是首元结点,数据域为空),rear指向终端结点。空队列时,front和rear都指向头结点
结构代码
typedef int QElemType; /* QElemType类型根据实际情况而定,这里假设为int */
typedef struct QNode /* 结点结构 */
{
QElemType data;
struct QNode *next;
}QNode,*QueuePtr;
typedef struct /* 队列的链表结构 */
{
QueuePtr front,rear; /* 队头、队尾指针 */
}LinkQueue;
入队操作:O(1)
/* 插入元素e为Q的新的队尾元素 */
Status EnQueue(LinkQueue *Q,QElemType e)
{
QueuePtr s=(QueuePtr)malloc(sizeof(QNode));
if(!s) /* 存储分配失败 */
exit(OVERFLOW);
s->data=e;
s->next=NULL;
Q->rear->next=s; /* 把拥有元素e的新结点s赋值给原队尾结点的后继 */
Q->rear=s; /* 把当前的s设置为队尾结点,rear指向s */
return OK;
}
出队操作:O(1)
/* 若队列不空,删除Q的队头元素,用e返回其值,并返回OK,否则返回ERROR */
Status DeQueue(LinkQueue *Q,QElemType *e)
{
QueuePtr p;
if(Q->front==Q->rear)
return ERROR;
p=Q->front->next; /* 将欲删除的队头结点暂存给p */
*e=p->data; /* 将欲删除的队头结点的值赋值给e */
Q->front->next=p->next; /* 将原队头结点的后继p->next赋值给头结点后继 */
if(Q->rear==p) /* 若队头就是队尾,则删除后将rear指向头结点 */
Q->rear=Q->front;
free(p);
return OK;
}
第5章 串
5.1 串的定义
串(string):由零个或多个字符组成的有限序列,即字符串,记为s=“a1a2…an”
- 空串:0个字符
- 空格串:只包含空格
- 串中任意个数的连续字符组成的子序列称为该串的子串,包含子串的串称为主串
- 子串在主串中的位置是其第一个字符在主串中的序号
串的比较:通过组成串的字符之间的编码来进行的
- 给定两个串s=“a1a2…an”,t=“b1b2…bm”,当且仅当n=m,ai=bi时,s=t;
- 给定两个串s=“a1a2…an”,t=“b1b2…bm”,当满足以下条件之一时,s<t:
- n<m,ai=bi(i=1,2,…,n);
- 存在k≤min(m,n),ai=bi(i=1,2,…,k-1),ak<bk
串的抽象数据类型
ADT 串(string)
Data
串中元素仅由一个字符组成,相邻元素具有前驱和后继的关系。
Operation
StrAssign(T, *chars): 生成一个其值等于字符串常量chars 的串T
StrCopy(T, S): 串S存在,由串S复制给T
ClearString(S): 将串清空
StringEmpty(S): 判断是否为空
StrLength(S): 返回串的元素个数
StrCompare(S, T): 若S>T,返回值>0;若S=T,返回值等于0;若S<T,返回值<0;
Concat(T, S1, S2): 用T返回由S1和S2连接形成的新串
SubString(Sub, S, pos, len): 串S存在。1<= pos <= StrLength(S),且0 <= len <= StrLength(S) - pos + 1,用Sub返回串S的第pos个字符起长度为len的子串
Index(S, T, pos): 串S、T存在,T是非空串,1 <= pos <= StrLength(S)。若主串S中存在和串T值相同的子串,则返回它在主串S中第pos个字符出现之后第一次出现的位置,否则返回0
Replace(S, T, V):串S,T,V存在,T是非空串。用V替代主串S中出现所有与T相等的不重叠的子串
StrInsert(S, pos, T): 串S,T存在,1 <= pos <= StrLength(S) + 1。在串S的第pos个字符前插入串T
StrDelete(S, pos, len):串S存在,1 <= pos <= StrLength(S) -len + 1。从串S中删除第pos个字符起长度为len的子串
endADT
Index的实现(使用串的其他操作)
/* T为非空串。若主串S中第pos个字符之后存在与T相等的子串, */
/* 则返回第一个这样的子串在S中的位置,否则返回0 */
int Index(String S, String T, int pos)
{
int n,m,i;
String sub;
if (pos > 0)
{
n = StrLength(S); /* 得到主串S的长度 */
m = StrLength(T); /* 得到子串T的长度 */
i = pos;
while (i <= n-m+1) /* 索引位置i-1 <= n-m */
{
SubString(sub, S, i, m); /* 取主串中第i个位置长度与T相等的子串给sub */
if (StrCompare(sub,T) != 0) /* 如果两串不相等 */
++i;
else /* 如果两串相等 */
return i; /* 则返回i值 */
}
}
return 0; /* 若无子串与T相等,返回0 */
}
5.2 串的存储结构
5.2.1 串的顺序存储结构
用一组地址连续的存储单元来存储串中的字符序列。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区。一般用定长数组来定义
一般一个将实际的串长度值保存在数组0下标的位置,有的也会保存在数组的最后一个坐标的位置。或者在串值后面加入一个不计长度的结束标记字符,比如“\0”来表示字符的终结
存在的问题:存储长度有限
改进:存储空间动态分配。如自由存储区(堆),用malloc()和free()管理
5.2.2 串的链式存储结构
与线性表相似,但如果一个结点对应一个字符,会导致空间浪费。往往一个结点可以存放多个字符,最后一个结点若是未占满,可以用“#”或其他非串值补全。
除了连接串与串操作,链式存储不如顺序存储灵活,性能也不如顺序存储好
5.3 朴素的模式匹配算法
串的模式匹配:子串的定位操作
算法思路:
对主串的每一个字符作为字串的开头,与要匹配的字符串进行匹配。对主串做大循环,每个字符开头做子串长度的小循环,直到匹配成功或全部遍历完成为止
Index的实现(使用数组)
/* 返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回值为0。 */
/* 其中,T非空,1≤pos≤StrLength(S)。 */
/* 假设主串S和子串T的长度存在S[0]和T[0]中。 */
int Index(String S, String T, int pos)
{
int i = pos; /* i用于主串S中当前位置下标值,从pos位置开始匹配 */
int j = 1; /* j用于子串T中当前位置下标值 */
while (i <= S[0] && j <= T[0]) /* 若i小于S的长度并且j小于T的长度时,循环继续 */
{
if (S[i] == T[j]) /* 两字母相等则继续 */
{
++i;
++j;
}
else /* 指针后退重新开始匹配 */
{
i = i-j+2; /* i退回到上次匹配首位的下一位 */
j = 1; /* j退回到子串T的首位 */
}
}
if (j > T[0])
return i-T[0];
else
return 0;
}
若主串S和子串T的长度分别为m和n,那么上述算法最坏时间复杂度为O((n - m + 1) * m)
5.4 KMP模式匹配算法
算法思路:
-
主串遍历指针i取消不必要的回溯
-
子串遍历指针j需要回溯,且只与子串结构有关,取决于当前的串的前后缀的相似度
定义j值的变化为数组next,则next的长度就是子串T的长度
n e x t ( j ) = { 0 , if j =1 M a x { k ∣ 1 < k < j , 且 ′ p 1 . . . p k − 1 ′ = ′ p j − k + 1 . . . p j − 1 ′ } , if 此集合不为空 1 , others next(j)= \begin{cases} 0, & \text{if $j$=1}\\ Max\{k|1<k<j,且'p_1...{p_{k-1}}'='p_{j-k+1}...{p_{j-1}}'\},& \text{if 此集合不为空}\\ 1,& \text{others} \end{cases} next(j)=⎩⎪⎨⎪⎧0,Max{k∣1<k<j,且′p1...pk−1′=′pj−k+1...pj−1′},1,if j=1if 此集合不为空others
- next[]数组初始化next[1] = 0
- 当存在公共前后缀时,j为公共后缀的起始下标,即从这向后与i的下一个元素对齐比较
- 当不存在公共前后缀时,j = 1,即直接移动子串到i的后一个元素处继续向后比较
n个字符相等,k = n+1
Index的实现(KMP)
/* 通过计算返回子串T的next数组。 */
void get_next(String T, int *next)
{
int i,k;
i=1;
k=0;
next[1]=0;
while (i<T[0]) /* 此处T[0]表示串T的长度 */
{
if(k==0 || T[i]== T[k])
{
++i;
++k;
next[i] = k;
}
else
k= next[k]; /* 若字符不相同,则k值回溯 */
}
}
/* 返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回值为0。 */
/* T非空,1≤pos≤StrLength(S)。 */
int Index_KMP(String S, String T, int pos)
{
int i = pos; /* i用于主串S中当前位置下标值,从pos位置开始匹配 */
int j = 1; /* j用于子串T中当前位置下标值 */
int next[255]; /* 定义一next数组 */
get_next(T, next); /* 对串T作分析,得到next数组 */
while (i <= S[0] && j <= T[0]) /* 若i小于S的长度并且j小于T的长度时,循环继续 */
{
if (j==0 || S[i] == T[j]) /* 两字母相等则继续,与朴素算法增加了j=0判断 */
{
++i;
++j;
}
else /* 指针后退重新开始匹配 */
{
j = next[j]; /* j退回合适的位置,i值不变 */
}
}
if (j > T[0])
return i-T[0];
else
return 0;
}
若主串S和子串T的长度分别为m和n,那么上述算法最坏时间复杂度为O(n + m)
KMP算法仅当模式与主串之间存在许多部分匹配的情况下才体现出它的优势
Index的实现(改进KMP)
算法思路:如果后续字符与第一个字符相等,就用首位next值取代后续next值
/* 求模式串T的next函数修正值并存入数组nextval */
void get_nextval(String T, int *nextval)
{
int i,k;
i=1;
k=0;
nextval[1]=0;
while (i<T[0]) /* 此处T[0]表示串T的长度 */
{
if(k==0 || T[i]== T[k]) /* T[i]表示后缀的单个字符,T[k]表示前缀的单个字符 */
{
++i;
++k;
if (T[i]!=T[k]) /* 若当前字符与前缀字符不同 */
nextval[i] = k; /* 则当前的k为nextval在i位置的值 */
else
nextval[i] = nextval[k]; /* 如果与前缀字符相同,则将前缀字符的 */
/* nextval值赋值给nextval在i位置的值 */
}
else
k= nextval[k]; /* 若字符不相同,则k值回溯 */
}
}
/* 返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回值为0。 */
/* T非空,1≤pos≤StrLength(S)。 */
int Index_KMP(String S, String T, int pos)
{
int i = pos; /* i用于主串S中当前位置下标值,从pos位置开始匹配 */
int j = 1; /* j用于子串T中当前位置下标值 */
int next[255]; /* 定义一next数组 */
get_nextvalc(T, next); /* 对串T作分析,得到next数组 */
while (i <= S[0] && j <= T[0]) /* 若i小于S的长度并且j小于T的长度时,循环继续 */
{
if (j==0 || S[i] == T[j]) /* 两字母相等则继续,与朴素算法增加了j=0判断 */
{
++i;
++j;
}
else /* 指针后退重新开始匹配 */
{
j = next[j]; /* j退回合适的位置,i值不变 */
}
}
if (j > T[0])
return i-T[0];
else
return 0;
}
第6章 树
6.1 树的定义
树(Tree):是n(n≥0)个结点的有限集。n=0时称为空树。
在任意一棵非空树(n>0)中:
- 有且仅有一个特定的称为根(Root)的结点
- n>1时,其余结点可分为m(m>0)个互不相交的有限集T1,T2,……,Tm,其中每一个集合本身又是一棵树,并且称为根的子树(Subtree)
6.1.1 结点分类
树的结点包含一个数据元素和若干指向其子树的分支。结点拥有的子树数称为结点的度(Degree)
度为0的结点称为叶结点(Leaf)或终端结点;度不为0的结点称为非终端结点或分支结点
除根节点之外,分支结点也称为内部结点。树的度就是各结点的度的最大值
6.1.2 结点间关系
结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)
同一个双亲的孩子之间互称兄弟(Sibling)。结点的祖先是从根到该结点所经分支上所有的结点,以某结点为根的子树中任一结点都成为该结点的子孙
结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。双亲在同一层的结点互为堂兄弟。树的结点的最大层次称为树的深度(Depth)或高度
如果将树中结点的各子树看成从左到右是有次序的,不能交换的,则称该树为有序树,否则称为无序树
森林(Forest)是m(m>=0)棵互不相交的树的集合
- 线性结构:第一个元素无前驱;最后一个元素无后继;中间元素一个前驱一个后继
- 树结构:根结点无双亲,唯一;叶结点无孩子,可以多个;中间结点一个双亲多个孩子
树的抽象数据类型
ADT 树(tree)
Data
树是由一个根结点和若干棵子树构成的。树中结点具有相同的数据类型及层次关系。
Operation
InitTree(*T) :构造空树T
DestroyTree(*T): 销毁树
CreateTree(*T, definition):按definition中给出树的定义来构造树
ClearTree(*T): 清空树
TreeEmpty(*T): 空树返回true
TreeDepth(*T): 返回T的深度
Root(*T): 返回T的根结点
Value(T, cur_e): cur_e是树T中的一个结点,返回此结点的值
Assign(T, cur-e, value): 给树T的结点cur_e赋值为value
Parent(T, cur_e): 若cur_e不是根结点,则返回它的双亲
LeftChild(T, cur_e): 若cur_e是树T的非叶结点,则返回它的最左孩子,否则返回空。
RightSibling(T, cur_e): 若cur_e有右兄弟,则返回右兄弟,否则返回空。
InsertChild(*T, *p, i, c):其中p指向树T的某个结点,i为所指结点p的度加上1,若非空树c与T不相交,操作结果为插入c为树T中p指向结点的第i棵子树。
DeleteChild(*T, *p, i): 其中p为指向树T的某个结点,i为所指结点p的度,操作结果为删除T中P所指向结点的第i棵子树。
endADT
6.2 树的存储结构
6.2.1 双亲表示法
假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指向双亲结点在数组中的位置。data是数据域,parent是指针域,存储该结点的双亲在数组中的下标(根结点为-1)
结构代码
/* 树的双亲表示法结点结构定义 */
#define MAX_TREE_SIZE 100
typedef int TElemType; /* 树结点的数据类型,目前暂定为整型 */
typedef struct PTNode /* 结点结构 */
{
TElemType data; /* 结点数据 */
int parent; /* 双亲位置 */
} PTNode;
typedef struct /* 树结构 */
{
PTNode nodes[MAX_TREE_SIZE]; /* 结点数组 */
int r,n; /* 根的位置和结点数 */
} PTree;
找双亲:O(1);找孩子:遍历整个结构
改进
关注孩子结点:增加一个结点最左边孩子的域(长子域),没有孩子,则置-1
关注兄弟关系:增加右兄弟域,记录右兄弟的下标,没有则置-1
6.2.2 孩子表示法
使用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点
方案一:指针域的个数等于树的度(各结点度的最大值),可能浪费空间
方案二:每个结点的指针域的个数等于该结点的度。专门取一个位置来存储结点指针域的个数(度域)。各结点是不同的结构,加上要维护结点的度的数值,在运算上会带来时间的损耗
-
把每个结点的孩子结点排列起来,以单链表结构存储,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空
-
n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。
-
孩子链表的结点:
child数据域,存储某个结点在表头数组中的下标;next指针域,存储指向下一个孩子结点的指针
-
表头数组的表头结点:
data数据域,存储某结点的数据信息;firstchild指针域,存储该结点的孩子链表的头指针。
结构代码
/* 树的孩子表示法结构定义 */
#define MAX_TREE_SIZE 100
typedef int TElemType; /* 树结点的数据类型,目前暂定为整型 */
typedef struct CTNode /* 孩子结点 */
{
int child;
struct CTNode *next;
} *ChildPtr;
typedef struct /* 表头结构 */
{
TElemType data;
ChildPtr firstchild;
} CTBox;
typedef struct /* 树结构 */
{
CTBox nodes[MAX_TREE_SIZE]; /* 结点数组 */
int r,n; /* 根的位置和结点数 */
} CTree;
- 查找孩子、兄弟只需访问这个节点的孩子单链表
- 遍历整棵树只需要循环头结点数组
- 很难找到双亲
改进:双亲孩子表示法,表头数组的表头结点增加双亲域
6.2.3 孩子兄弟表示法
任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是惟一的。因此,我们可以设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟
结构代码
/* 树的孩子兄弟表示法结构定义 */
typedef struct CSNode
{
TElemType data;
struct CSNode *firstchild,*rightsib;
} CSNode,*CSTree;
便于查找某个结点的某个孩子;如果需要查找双亲,可以增加parent指针域
该方法最大的好处是将一棵复杂的树变成了一棵二叉树
6.3 二叉树
6.3.1 二叉树的定义
二叉树(Binary Tree)是n(n≥0)个结点的有限集合,该集合或者为空集(空二叉树),或者是由一个根结点和两课互不相交的、分别称为根结点的左子树和右子树的二叉树组成的
- 每个结点最多有两个子树
- 左子树与右子树有顺序,次序不能随意颠倒
- 即使某结点只有一颗树,也要区分左右
特殊二叉树
-
斜树:所有结点只有左子树或者右子树
-
满二叉树:所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上
-
完全二叉树:对一棵具有n个结点的二叉树按层序编号,如果编号为i(1<i≤n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中的位置完全相同
完全二叉树只允许右侧叶子结点为空;满二叉树一定是完全二叉树,反之不一定;同样结点数的二叉树,完全二叉树的深度最小
6.3.2 二叉树的性质
-
在二叉树中的第i层上至多有**2i-1**个结点
-
深度为k的二叉树至多有2k - 1个结点
-
对任何一棵二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0 = n2 + 1
证明:结点总数:n = n0 + n1 + n2; 总支线数:n - 1 = n1 + n2 = n0 + n1 + n2 - 1
具有n个结点的完全二叉树的深度为log2n + 1向下取整
-
如果对一棵有n个结点的完全二叉树的结点按层序编号,对任意结点i有:
- 如果i=1,则i是二叉树的根,无双亲;如果i>1,则双亲为i / 2向下取整
- 如果2i>n,则结点i是叶子结点,否则其左孩子是结点2i
- 如果2i+1>n,则结点i是叶子结点,否则其右孩子为2i+1
6.3.3 二叉树的存储结构
顺序存储结构
用一维数组存储二叉树的结点,并且结点的存储位置(数组的下标)体现结点之间的逻辑关系(按层序编号即可),不存在的结点设为空
顺序存储结构一般只用于完全二叉树
二叉链表
一个数据域,两个指针域,分别存放指向左孩子和右孩子的指针
结构定义
/* 二叉树的二叉链表结点结构定义 */
typedef struct BiTNode /* 结点结构 */
{
TElemType data; /* 结点数据 */
struct BiTNode *lchild,*rchild; /* 左右孩子指针 */
}BiTNode,*BiTree;
6.3.4 遍历二叉树
二叉树的遍历(traversing binary tree):从根结点出发,按照某种次序依次访问二叉树中所有的结点,使得每个结点被访问一次且仅被访问一次
遍历的实质:将树中的结点变成不同的线性序列
前序遍历
若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树
根结点→左子树→右子树,ABDHKECFIGJ
/* 二叉树的前序遍历递归算法 */
/* 初始条件: 二叉树T存在 */
/* 操作结果: 前序递归遍历T */
void PreOrderTraverse(BiTree T)
{
if(T==NULL)
return;
printf("%c",T->data); /* 显示结点数据,可以更改为其它对结点操作 */
PreOrderTraverse(T->lchild);/* 再先序遍历左子树 */
PreOrderTraverse(T->rchild);/* 最后先序遍历右子树 */
}
中序遍历
若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后访问根结点,最后中序遍历右子树
左子树→根结点→右子树,HKDBEAIFCGJ
/* 二叉树的中序遍历递归算法 */
/* 初始条件: 二叉树T存在 */
/* 操作结果: 中序递归遍历T */
void InOrderTraverse(BiTree T)
{
if(T==NULL)
return;
InOrderTraverse(T->lchild); /* 中序遍历左子树 */
printf("%c",T->data); /* 显示结点数据,可以更改为其它对结点操作 */
InOrderTraverse(T->rchild); /* 最后中序遍历右子树 */
}
后序遍历
若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后访问根结点
左子树→右子树→根节点,KHDEBIFJGCA
/* 二叉树的后序遍历递归算法 */
/* 初始条件: 二叉树T存在 */
/* 操作结果: 后序递归遍历T */
void PostOrderTraverse(BiTree T)
{
if(T==NULL)
return;
PostOrderTraverse(T->lchild); /* 先后序遍历左子树 */
PostOrderTraverse(T->rchild); /* 再后序遍历右子树 */
printf("%c",T->data); /* 显示结点数据,可以更改为其它对结点操作 */
}
层序遍历
若树为空,则空操作返回,否则将从树的第一层,也就是根结点开始访问,从上向下逐层遍历,在同一层中,按从左到右的顺序对结点逐一访问
二叉树遍历的性质:
- 已知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树
- 已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树
- 已知前序遍历序列和后序遍历序列,不能确定一棵二叉树。
6.3.5 二叉树的建立
扩展二叉树,使每个存有数据的结点都有左右孩子,虚结点中存储特殊字符,如“#”;然后求出二叉树的前序(中序或者后序)遍历序列,按遍历顺序(递归方法)将其依次输入即可
/* 二叉树的二叉链表结点结构定义 */
typedef struct BiTNode /* 结点结构 */
{
TElemType data; /* 结点数据 */
struct BiTNode *lchild,*rchild; /* 左右孩子指针 */
}BiTNode,*BiTree;
/* 按前序输入二叉树中结点的值(一个字符) */
/* #表示空树,构造二叉链表表示二叉树T。 */
void CreateBiTree(BiTree *T)
{
TElemType ch;
scanf("%c",&ch);
ch=str[index++];
if(ch=='#')
*T=NULL;
else
{
*T=(BiTree)malloc(sizeof(BiTNode));
if(!*T)
exit(OVERFLOW);
(*T)->data=ch; /* 生成根结点 */
CreateBiTree(&(*T)->lchild); /* 构造左子树 */
CreateBiTree(&(*T)->rchild); /* 构造右子树 */
}
}
如果输入字符顺序为前序序列,则递归生成也是前序序列,注意递归函数的顺序
6.3.6 线索二叉树
充分利用空指针域,使其存放指向结点在某种遍历次序下的前驱和后继的地址
把指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树(Threaded Binary Tree)
线索化:对二叉树以某种次序遍历使其变为线索二叉树的过程,这个过程将二叉树转化为了双向链表。
为明确lchild(rchild)存放是孩子还是前驱,需要设置两个标志域,ltag和rtag,通常为0或1;tag为0表示孩子,为1表示前驱或后继
结构定义
/* 二叉树的二叉线索存储结构定义 */
typedef char TElemType;
typedef enum {Link,Thread} PointerTag; /* Link==0表示指向左右孩子指针, */
/* Thread==1表示指向前驱或后继的线索 */
typedef struct BiThrNode /* 二叉线索存储结点结构 */
{
TElemType data; /* 结点数据 */
struct BiThrNode *lchild, *rchild; /* 左右孩子指针 */
PointerTag LTag;
PointerTag RTag; /* 左右标志 */
} BiThrNode, *BiThrTree;
线索化的实质就是将二叉表中的空指针改为指向前驱或者后继的线索。由于前驱和后继的信息只有在遍历该二叉树的过程中才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程
中序遍历线索化
BiThrTree pre; /* 全局变量,始终指向刚刚访问过的结点 */
/* 中序遍历进行中序线索化 */
void InThreading(BiThrTree p)
{
if(p)
{
InThreading(p->lchild); /* 递归左子树线索化 */
if(!p->lchild) /* 没有左孩子 */
{
p->LTag=Thread; /* 前驱线索 */
p->lchild=pre; /* 左孩子指针指向前驱 */
}
if(!pre->rchild) /* 前驱没有右孩子 */
{
pre->RTag=Thread; /* 后继线索 */
pre->rchild=p; /* 前驱右孩子指针指向后继(当前结点p) */
}
pre=p; /* 保持pre指向p的前驱 */
InThreading(p->rchild); /* 递归右子树线索化 */
}
}
如果某结点的左指针域为空,因为刚访问了前驱结点,所有将pre赋给p->lchild,且修改LTag;而此时还未访问p的后继,故可用pre->rchild做判断,如果为空,则p就是pre的后继,所以有pre->rchild=p,且修改RTag;最后,pre = p,进行下一次使用
线索化后中序遍历:O(n)
- 在二叉树线索链表上添加一个头结点,并令其lchild域的指针指向二叉树的根节点,其rchild域的指针指向中序遍历时访问的最后一个结点
- 令二叉树的中序序列的第一个结点中lchild域的指针和最后一个结点的rchild域的指针均指向头结点
- 这样既可以从第一个结点起顺后继进行遍历,也可以从最后一个结点其顺前驱进行遍历
/* T指向头结点,头结点左链lchild指向根结点,头结点右链rchild指向中序遍历的*/
/* 最后一个结点。中序遍历二叉线索链表表示的二叉树T */
Status InOrderTraverse_Thr(BiThrTree T)
{
BiThrTree p;
p=T->lchild; /* p指向根结点 */
while(p!=T) /* 空树或遍历结束时,p==T */
{
while(p->LTag==Link) /*当LTag==0时循环到中序序列第一个结点 */
p=p->lchild;
printf("%c",p->data); /* 显示结点数据,可以更改为其他对结点操作 */
while(p->RTag==Thread && p->rchild!=T)
{
p=p->rchild;
printf("%c",p->data); /* 访问后继结点 */
}
p=p->rchild; /* p进至其右子树根 */
}
return OK;
}
如果二叉树需要经常遍历,或查找结点时需要某种遍历序列的前驱和后继,用线索二叉树
6.3.7 树、森林与二叉树的转换
树转换为二叉树
- 加线:所有兄弟结点之间加一条连线
- 去线:只保留与第一个孩子结点的连线
- 层次调整:顺时针旋转,第一个孩子是二叉树结点的左孩子,从兄弟转换过来的孩子是结点的右孩子
森林转换为二叉树
- 把每个树转换为二叉树
- 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点做为前一棵二叉树的根节点的右孩子
二叉树转换为树
- 加线:若某个结点的左孩子存在,则将这个左孩子结点的连续n个右孩子结点都作为此结点的孩子
- 去线:删除原二叉树中所有结点与其右孩子结点的连线
- 层次调整
二叉树转换为森林
如果一棵二叉树的根结点有右孩子,就可以转换为森林,否则只能转换为一棵树
- 从根节点开始,连续删除n个与右孩子的连线
- 将每个分离后的二叉树转换为树
树的遍历
- 先根遍历:先访问树的根结点,再依次先根遍历每个子树
- 后根遍历:先依次后根遍历每个子树,再访问根结点
森林的遍历
- 前序遍历:先访问第一棵树的根结点,然后依次先根遍历根的每棵子树,再依次用同样的方式遍历剩余树构成的森林,与二叉树的前序遍历结果相同
- 后序遍历:先访问森林中的第一棵树,后根遍历的方式遍历每棵子树,然后访问根结点,再依次用同样的方式遍历剩余树构成的森林,与二叉树的中序遍历结果相同
6.3.8 哈夫曼树
从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称作路径长度,树的路径长度是从树根到每一结点的路径长度之和
树结点间的边相关的数叫做权,结点的带权路径长度为从该结点到树根之间的路径长度与结点上权的乘积,树的带权路径长度为所有叶子结点的带权路径长度之和,带权路径长度(WPL)最小的二叉树称作哈夫曼树
哈夫曼树的构造:
- 根据给定的n个权值{w1, w2, …, wn}构成n棵二叉树的集合F={T1, T2, …, Tn},其中每棵二叉树Ti中只有一个带权为wi的根结点,其左右子树均为空
- 在F中选取两棵根结点的权值最小的树作为左右子树(较小的为左孩子)构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和
- 在F中删除这两棵树,同时将新得到的二叉树加入F中
- 重复2和3步骤,直到F只含一棵树为止,这棵树便是哈夫曼树
哈夫曼编码:
设需要编码的字符集为{d1, d2, …, dn},各个字符在电文中出现的次数或频率集合为{w1, w2, …, wn},以{d1, d2, …, dn}作为叶子结点,以w1, w2, …, wn作为相应叶子结点的权值来构造一棵哈夫曼树。规定哈夫曼树的左分支代表0, 右分支代表1,则从根节点到叶子结点所经的路径分支组成的0和1序列便是该结点对应字符的编码,这就是哈夫曼编码
第7章 图
7.1 图的定义
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为: G(V,E),其中,G表示一个图, V是图G中顶点的集合,E是图G中边的集合
- 数据元素在线性表中被称为元素,在树中被称为结点,而在图中被称为顶点(Vertext)
- 顶点集合V有穷非空
- 线性表中相邻的数据元素之间有线性关系,树结构中相邻两层的节点有层次关系,图中顶点之间的逻辑关系用边来表示,边集可以为空
7.1.1 相关概念
无向边:若顶点vi到vj之间的边没有方向,则称这条边为无向边(Edge), 用无序偶(vi, vj)来表示
无向图:若图中任意两个顶点之间的边都是无向边,则为无向图(Undirected graphs)
有向边:若从顶点vi到vj之间的边有方向,则称这条边为有向边,也称为弧(Arc),用有序偶<vi,vj>表示,vi称为弧尾, vj称为弧头
有向图:如果图中任意两个顶点之间的边都是有向边,则称该图为有向图(Directed graphs)
简单图:不存在顶点到其自身的边,且同一条边不重复出现,这样的图为简单图
无向完全图:任意两个顶点都存在边的无向图。 含有n个顶点的无向完全图有n(n−1)/2条边
有向完全图:任意两个顶点之间存在互为相反的两条弧的有向图。含有n个顶点的有向完全图有**n(n−1)**条边
稀疏图:有很少条边或弧的图称为稀疏图,反之为稠密图
权(Weight):与图的边或弧相关的数叫做权
网(Network):带权的图统称为网
子图(Subgraph):假设有两个图G=(V, {E})和G′=(V′, {E′}), 如果V′⊆V且 E′⊆E, 则称G′为G的子图
7.1.2 图的顶点与边间关系
无向图的邻接点(Adjacent):对于无向图G=(V,{E}), 如果边(v,v′)∈E, 则称顶点v和v′互为邻接点,即v和v′相邻接。边(v,v′)依附于顶点v和v′,或者说(v,v′)与顶点v和v′相关联
无向图的度(Degree):顶点v的度是和v相关联的边的数目, 记为TD(v)
有向图的邻接: 对于有向图G=(V,{E}),如果弧<v,v′>∈E,则称顶点v邻接到顶点v′, 顶点v′邻接自顶点v。弧<v,v′>和顶点v,v′相关联
有向图的入度(InDegree):以顶点v为头的弧的数目称为v的入度,记为ID(v)
有向图的出度(OutDegree):以顶点v为尾的弧的数目称为v的出度,记为OD(v)
有向图的度: 顶点v的度为TD(v)=ID(v)+OD(v)
路径(Path):无向图G=(V,{E})中从顶点v到顶点v’的路径是一个顶点序列(v=vi,0, vi,1, …, vi,m=v’),其中(vi,j-1, vi,j)∈E, 1≤j≤m。如果G是无向图,则路径也是有向的,顶点序列应满足<vi,j-1, vi,j>∈E, 1≤j≤m。路径的长度是路径上边或弧的数目
回路或环(Cycle):第一个顶点到最后一个顶点相同的路径称为回路或环
简单路径:序列中顶点不重复出现的路径称为简单路径
简单回路或简单环:除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环
7.1.3 连通图相关术语
连通图(Connected Graph):在无向图G中,如果从顶点v到顶点v′有路径,则称v和v′是连通的。如果对于图中任意两个顶点vi,vj∈V, vi和vj都是连通的, 则称G是连通图
连通分量:无向图中的极大连通子图称为连通分量,连通分量可以有多个
强连通图: 在有向图G中,如果对于每一对vi,vj∈V、vi≠vj,从vi到vj和从vj到vi都存在路径,则称G是强连通图
强连通分量:有向图中的极大强连通子图称作有向图的强连通分量
生成树:一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边
有向树:如果一个有向图恰有一个顶点的入度为0,其余顶点的入度均为1,则是一棵有向树
生成森林:一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向图的弧
图的抽象类型
ADT 图(Graph)
Data
顶点的有穷非空集合和边的集合
Operation
CreateGraph(*G, V, VR):按照顶点集 V 和边弧集 VR 的定义构造图 G
DestroyGraph(*G):图 G 存在则销毁
LocateVex(G, u):若图 G 中存在顶点 u, 则返回图中的位置
GetVex(G, v):返回图 G 中顶点 v 的值
PutVex(G, v, value):将图 G 中顶点 v 赋值 value
FirstAdjVex(G, *v):返回顶点 v 的一个邻接顶点, 若顶点在 G 中无邻接顶点返回空
NextAdjVex(G, v, *w):返回顶点 v 相对于顶点 w 的下一个邻接顶点,若 w 是 v 的最后一个邻接点则返回空
InsertVex(*G, v):在图 G 中增添新节点 v
DeleteVex(*G, v):删除图 G 中顶点 v 及其相关的弧
InsertArc(*G, v, w):在图 G 中增点弧 <v, w>, 若 G 是无向图,还需要增添对称弧 <w, v>。
DeleteArc(*G, v, w):在图 G 中删除弧 <v, w>,若 G 是无向图,则还需要删除对称弧 <w, v>。
DFSTraverse(G):对图 G 中进行深度优先遍历,在遍历过程对每个顶点调用
HFSTraverse(G):对图 G 中进行广度优先遍历,在遍历过程对每个顶点调用
endADT
7.2 图的存储结构
- 由于任意两个顶点之间都可能存在联系,不能以数据元素在内存中的物理位置来表示元素之间的关系
- 多重链表(一个数据域多个指针域)会导致资源浪费
7.2.1 邻接矩阵
图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息
设图中G有n个顶点,则邻接矩阵是一个n×n的方阵,定义为
$ arc[i][j]= \begin{cases} 1,& \text{若( v i v_i vi, v j v_j vj)∈ E E E或< v i v_i vi, v j v_j vj>∈ E E E}\ 0,& \text{反之} \end{cases} $
- 邻接矩阵便于判断两个顶点的连通
- 某个顶点的度,是该顶点vi在邻接矩阵中第i行(或第i列)的元素之和
- 某个顶点的邻接点,是该顶点vi在邻接矩阵中第i行为1的元素所对应的列
网的邻接矩阵:
$ arc[i][j]= \begin{cases} W_{ij},& \text{若( v i v_i vi, v j v_j vj)∈ E E E或< v i v_i vi, v j v_j vj>∈ E E E}\ 0,& \text若i=j\ ∞,& \text反之 \end{cases} $
∞表示一个计算机允许的、大于所有边上权值的值
结构代码
typedef char VertexType; /* 顶点类型应由用户定义 */
typedef int EdgeType; /* 边上的权值类型应由用户定义 */
#define MAXVEX 100 /* 最大顶点数,应由用户定义 */
#define INFINITY 65535 /* 用65535来代表∞ */
typedef struct
{
VertexType vexs[MAXVEX]; /* 顶点表 */
EdgeType arc[MAXVEX][MAXVEX]; /* 邻接矩阵,可看作边表 */
int numNodes, numEdges; /* 图中当前的顶点数和边数 */
}MGraph;
创建无向网图
/* 建立无向网图的邻接矩阵表示 */
void CreateMGraph(MGraph *G)
{
int i,j,k,w;
printf("输入顶点数和边数:\n");
scanf("%d,%d",&G->numNodes,&G->numEdges); /* 输入顶点数和边数 */
for(i = 0;i <G->numNodes;i++) /* 读入顶点信息,建立顶点表 */
scanf(&G->vexs[i]);
for(i = 0;i <G->numNodes;i++)
for(j = 0;j <G->numNodes;j++)
G->arc[i][j]=INFINITY; /* 邻接矩阵初始化 */
for(k = 0;k <G->numEdges;k++) /* 读入numEdges条边,建立邻接矩阵 */
{
printf("输入边(vi,vj)上的下标i,下标j和权w:\n");
scanf("%d,%d,%d",&i,&j,&w); /* 输入边(vi,vj)上的权w */
G->arc[i][j]=w;
G->arc[j][i]= G->arc[i][j]; /* 因为是无向图,矩阵对称 */
}
}
n个顶点,e条边,时间复杂度为O(n+n2+e)
问题:对于边数相对顶点较少的图,浪费存储空间
7.2.2 邻接表
邻接表(Adjacencty List)是将数组与链表相结合的存储方法
- 图中顶点用一维数组存储,便于读取顶点信息。每个数组元素存储指向第一个邻接点的指针,便于查找该顶点的边信息
- 每个顶点vi的所有邻接点用单链表存储,无向图称为顶点的vi边表,有向图称为顶点vi作为弧尾的出边表
- 某个顶点的度,是该顶点边表中结点的个数
- 判断两个顶点vi和vj之间是否存在边,需要测试顶点vi的边表中邻接点域是否存在结点vj的下标
- 某个顶点的邻接点,是遍历该顶点的边表所得的邻接点域
有向图的邻接表方便得到每个顶点的出度,若想得到入度,可建立逆邻接表
网图在边表结点定义中再增加一个权值的数据域
结构代码
typedef char VertexType; /* 顶点类型应由用户定义 */
typedef int EdgeType; /* 边上的权值类型应由用户定义 */
typedef struct EdgeNode /* 边表结点 */
{
int adjvex; /* 邻接点域,存储该顶点对应的下标 */
EdgeType weight; /* 用于存储权值,对于非网图可以不需要 */
struct EdgeNode *next; /* 链域,指向下一个邻接点 */
}EdgeNode;
typedef struct VertexNode /* 顶点表结点 */
{
VertexType data; /* 顶点域,存储顶点信息 */
EdgeNode *firstedge; /* 边表头指针 */
}VertexNode, AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numNodes,numEdges; /* 图中当前顶点数和边数 */
}GraphAdjList;
创建无向图的邻接表
/* 建立图的邻接表结构 */
void CreateALGraph(GraphAdjList *G)
{
int i,j,k;
EdgeNode *e;
printf("输入顶点数和边数:\n");
scanf("%d,%d",&G->numNodes,&G->numEdges); /* 输入顶点数和边数 */
for(i = 0;i < G->numNodes;i++) /* 读入顶点信息,建立顶点表 */
{
scanf(&G->adjList[i].data); /* 输入顶点信息 */
G->adjList[i].firstedge=NULL; /* 将边表置为空表 */
}
for(k = 0;k < G->numEdges;k++) /* 建立边表 */
{
printf("输入边(vi,vj)上的顶点序号:\n");
scanf("%d,%d",&i,&j); /* 输入边(vi,vj)上的顶点序号 */
e=(EdgeNode *)malloc(sizeof(EdgeNode)); /* 向内存申请空间,生成边表结点 */
e->adjvex=j; /* 邻接序号为j */
e->next=G->adjList[i].firstedge; /* 将e的指针指向当前顶点上指向的结点 */
G->adjList[i].firstedge=e; /* 将当前顶点的指针指向e */
e=(EdgeNode *)malloc(sizeof(EdgeNode)); /* 向内存申请空间,生成边表结点 */
e->adjvex=i; /* 邻接序号为i */
e->next=G->adjList[j].firstedge; /* 将e的指针指向当前顶点上指向的结点 */
G->adjList[j].firstedge=e; /* 将当前顶点的指针指向e */
}
}
对于无向图,一条边对应两个顶点,在循环中一次就针对i和j分别进行了插入
对于n个顶点,e条边,时间复杂度为O(n+e)
7.2.3 十字链表
十字链表(Orthogonal List):把邻接表和逆邻接表结合起来
- 定义顶点表结构:数据域、入边表头指针指向该顶点入边表中第一个结点、出边表头指针指向该顶点出边表中第一个结点
- 定义边表结点结构:弧起点在顶点表的下标、弧终点在顶点表的下标、入边表指针域指向终点相同的下一条边、出边表指针域指向起点相同的下一条边
容易找到以某顶点为起点和终点的弧,进而求得顶点的出度和入度
时间复杂度与邻接表相同,便于有向图的应用
7.2.4 邻接多重表
在无向图中,定义边表结点结构:与某条边依附的两个顶点在顶点表中的下标、依附第一个顶点的下一条边、依附第二个顶点的下一条边
e条边的图,邻接多重表中有2e条连线
与邻接表的差别:同一条边在邻接表中用两个结点表示,在邻接多重表中只有一个结点
7.2.5 边集数组
由两个一维数组构成:一个存储顶点的信息;另一个存储边的信息,边数组每个元素由一条边的起点下标、终点下标和权组成
更适合对边依次进行处理的操作,而不适合对顶点相关的操作
7.3 图的遍历
图的遍历(Traversing Graph):从图的某一顶点出发访遍图中其余顶点,且使每个顶点仅被访问一次
设置一个访问数组visited[n],n为顶点的个数,初值为0,访问后设置为1
7.3.1 深度优先遍历
深度优先遍历(Depth_First_Search, DFS),也称深度优先搜索,类似于树的前序遍历
- 连通图:从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到(递归)
- 非连通图:对它的连通分量分别进行深度优先遍历,即在先前一个顶点进行一次深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作为起始点,重复操作直到图中所有的顶点都被访问到
邻接矩阵的深度优先遍历算法
#define MAXVEX 9
Boolean visited[MAXVEX]; /* 访问标志的数组 */
/* 邻接矩阵的深度优先递归算法 */
void DFS(MGraph G, int i)
{
int j;
visited[i] = TRUE;
printf("%c ", G.vexs[i]); /* 打印顶点,也可以其它操作 */
for(j = 0; j < G.numVertexes; j++)
if(G.arc[i][j] == 1 && !visited[j])
DFS(G, j); /* 对为访问的邻接顶点递归调用 */
}
/* 邻接矩阵的深度遍历操作 */
void DFSTraverse(MGraph G)
{
int i;
for(i = 0; i < G.numVertexes; i++)
visited[i] = FALSE; /* 初始所有顶点状态都是未访问过状态 */
for(i = 0; i < G.numVertexes; i++)
if(!visited[i]) /* 对未访问过的顶点调用DFS,若连通图仅执行一次 */
DFS(G, i);
}
对于n个顶点,e条边,时间复杂度为O(n2)
邻接表的深度优先遍历算法
/* 邻接表的深度优先递归算法 */
void DFS(GraphAdjList GL, int i)
{
EdgeNode *p;
visited[i] = TRUE;
printf("%c ",GL->adjList[i].data); /* 打印顶点,也可以其它操作 */
p = GL->adjList[i].firstedge;
while(p)
{
if(!visited[p->adjvex])
DFS(GL, p->adjvex); /* 对为访问的邻接顶点递归调用 */
p = p->next;
}
}
/* 邻接表的深度遍历操作 */
void DFSTraverse(GraphAdjList GL)
{
int i;
for(i = 0; i < GL->numVertexes; i++)
visited[i] = FALSE; /* 初始所有顶点状态都是未访问过状态 */
for(i = 0; i < GL->numVertexes; i++)
if(!visited[i]) /* 对未访问过的顶点调用DFS,若是连通图,只会执行一次 */
DFS(GL, i);
}
对于n个顶点,e条边,时间复杂度为O(n+e)
7.3.2 广度优先遍历
广度优先遍历(Breadth_First_Search, BFS),也称广度优先搜索,类似于树的层序遍历
- 指定一个起始顶点,将顶点按照边的连接顺序依次放入队列中
邻接矩阵的广度优先遍历算法
/* 邻接矩阵的广度遍历算法 */
void BFSTraverse(MGraph G)
{
int i, j;
Queue Q;
for(i = 0; i < G.numVertexes; i++)
visited[i] = FALSE;
InitQueue(&Q); /* 初始化一辅助用的队列 */
for(i = 0; i < G.numVertexes; i++) /* 对每一个顶点做循环 */
{
if (!visited[i]) /* 若是未访问过就处理 */
{
visited[i]=TRUE; /* 设置当前顶点访问过 */
printf("%c ", G.vexs[i]); /* 打印顶点,也可以其它操作 */
EnQueue(&Q,i); /* 将此顶点入队列 */
while(!QueueEmpty(Q)) /* 若当前队列不为空 */
{
DeQueue(&Q,&i); /* 将队首元素出队列,赋值给i */
for(j=0;j<G.numVertexes;j++)
{
/* 判断其它顶点若与当前顶点存在 */
/* 边且未访问过 */
if(G.arc[i][j] == 1 && !visited[j])
{
visited[j]=TRUE; /* 将找到的此顶点标记为已访问 */
printf("%c ", G.vexs[j]); /* 打印顶点 */
EnQueue(&Q,j); /* 将找到的此顶点入队列 */
}
}
}
}
}
}
对于n个顶点,e条边,时间复杂度为O(n2)
邻接表的广度优先遍历算法
/* 邻接表的广度遍历算法 */
void BFSTraverse(GraphAdjList GL)
{
int i;
EdgeNode *p;
Queue Q;
for(i = 0; i < GL->numVertexes; i++)
visited[i] = FALSE;
InitQueue(&Q);
for(i = 0; i < GL->numVertexes; i++)
{
if (!visited[i])
{
visited[i]=TRUE;
printf("%c ",GL->adjList[i].data); /* 打印顶点,也可以其它操作 */
EnQueue(&Q,i);
while(!QueueEmpty(Q))
{
DeQueue(&Q,&i);
p = GL->adjList[i].firstedge; /* 找到当前顶点的边表链表头指针 */
while(p)
{
if(!visited[p->adjvex]) /* 若此顶点未被访问 */
{
visited[p->adjvex]=TRUE;
printf("%c ",GL->adjList[p->adjvex].data);
EnQueue(&Q,p->adjvex); /* 将此顶点入队列 */
}
p = p->next; /* 指针指向下一个邻接点 */
}
}
}
}
}
对于n个顶点,e条边,时间复杂度为O(n+e)
7.4 最小生成树
最小生成树(Minimum Cost Spanning Tree):构造连通网的最小代价生成树
7.4.1 Prim算法
以某顶点为起点,逐步找各顶点上权值最小的边来构建最小生成树
适用于稠密图
假设N=(V, {E})是连通网,TE是N上最小生成树中边的集合。从U={u0}(u0∈V),TE={}开始,重复执行下述操作:
在所有u∈U,v∈V-U的边(u, v)∈E中找一条代价最小的边(u0, v0)并入集合TE,同时v0并入U,直至U=V为止。此时TE中必有n-1条边,则T=(V, {TE})为N的最小生成树
/* Prim算法生成最小生成树 */
void MiniSpanTree_Prim(MGraph G)
{
int min, i, j, k;
int adjvex[MAXVEX]; /* 保存相关顶点间边的权值点下标 */
int lowcost[MAXVEX]; /* 保存相关顶点间边的权值 */
lowcost[0] = 0; /* 初始化第一个权值为0,即v0加入生成树。*/
adjvex[0] = 0; /* 初始化第一个顶点下标为0 */
for(i = 1; i < G.numVertexes; i++) /* 循环除下标为0外的全部顶点 */
{
lowcost[i] = G.arc[0][i]; /* 将v0顶点与之有边的权值存入数组 */
adjvex[i] = 0; /* 初始化都为v0的下标 */
}
for(i = 1; i < G.numVertexes; i++)
{
min = INFINITY; /* 初始化最小权值为∞,可以是较大数字如65535等 */
j = 1;k = 0;
while(j < G.numVertexes) /* 循环全部顶点 */
{
if(lowcost[j]!=0 && lowcost[j] < min)
{ /* 如果权值不为0且权值小于min */
min = lowcost[j]; /* 则让当前权值成为最小值 */
k = j; /* 将当前最小值的下标存入k */
}
j++;
}
printf("(%d, %d)\n", adjvex[k], k); /* 打印当前顶点边中权值最小的边 */
lowcost[k] = 0; /* 将当前顶点权值设置为0,此顶点已完成任务 */
for(j = 1; j < G.numVertexes; j++) /* 循环所有顶点 */
{ /* 如果下标为k顶点各边权值小于此前这些顶点未被加入生成树权值 */
if(lowcost[j]!=0 && G.arc[k][j] < lowcost[j])
{
lowcost[j] = G.arc[k][j]; /* 将较小的权值存入lowcost相应位置 */
adjvex[j] = k; /* 将下标为k的顶点存入adjvex */
}
}
}
}
对于n个顶点,e条边,时间复杂度为O(n2)
7.4.2 Kruskal算法
直接找权值最小的边来构建最小生成树,要考虑是否生成环路
适用于稀疏图
假设N=(V, {E})是连通网,令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V, {}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。直至T中所有顶点都在同一连通分量上为止。
/* 对边集数组Edge结构的定义 */
typedef struct
{
int begin;
int end;
int weight;
}Edge;
/* Kruskal算法生成最小生成树 */
void MiniSpanTree_Kruskal(MGraph G)
{
int i, n, m;
Edge edges[MAXEDGE];/* 定义边集数组,edge的结构为begin,end,weight,均为整型 */
int parent[MAXVEX]; /* 定义一数组用来判断边与边是否形成环路 */
/* 此处省略将邻接矩阵G转化为边集数组edges并按权由小到大排序的代码*/
for (i = 0; i < G.numVertexes; i++)
parent[i] = 0; /* 初始化数组值为0 */
for (i = 0; i < G.numEdges; i++) /* 循环每一条边 */
{
n = Find(parent,edges[i].begin);
m = Find(parent,edges[i].end);
if (n != m) /* 假如n与m不等,说明此边没有与现有的生成树形成环路 */
{/* 将此边的结尾顶点放入下标为起点的parent中。表示此顶点已经在生成树集合中 */
parent[n] = m;
printf("(%d, %d) %d\n", edges[i].begin,
edges[i].end, edges[i].weight);
}
}
}
/* 查找连线顶点的尾部下标 */
int Find(int *parent, int f)
{
while ( parent[f] > 0)
{
f = parent[f];
}
return f;
}
对于n个顶点,e条边,时间复杂度为O(eloge)
7.5 最短路径
对于非网图,最短路径指的是两顶点之间经过的边数最少的路径
对于网图,最短路径指的是两顶点之间经过的边上权值之和最少的路径,称路径上第一个顶点为源点,最后一个顶点为终点
7.5.1 Dijkstra算法
按路径长度递增的次序产生最短路径
#define MAXEDGE 20
#define MAXVEX 20
#define INFINITY 65535
typedef struct
{
int vexs[MAXVEX];
int arc[MAXVEX][MAXVEX];
int numVertexes, numEdges;
}MGraph;
typedef int Patharc[MAXVEX]; /* 用于存储最短路径下标的数组 */
typedef int ShortPathTable[MAXVEX]; /* 用于存储到各点最短路径的权值和 */
/* Dijkstra算法,求有向网G的v0顶点到其余顶点v的最短路径P[v]及带权长度D[v] */
/* P[v]的值为前驱顶点下标,D[v]表示v0到v的最短路径长度和 */
void ShortestPath_Dijkstra(MGraph G, int v0, Patharc *P, ShortPathTable *D)
{
int v, w, k, min;
int final[MAXVEX]; /* final[w]=1表示求得顶点v0至vw的最短路径 */
for (v = 0; v < G.numVertexes; v++) /* 初始化数据 */
{
final[v] = 0; /* 全部顶点初始化为未知最短路径状态 */
(*D)[v] = G.arc[v0][v]; /* 将与v0点有连线的顶点加上权值 */
(*P)[v] = -1; /* 初始化路径数组P为-1 */
}
(*D)[v0] = 0; /* v0至v0路径为0 */
final[v0] = 1; /* v0至v0不需要求路径 */
/* 开始主循环,每次求得v0到某个v顶点的最短路径 */
for (v = 1; v < G.numVertexes; v++)
{
min = INFINITY; /* 当前所知离v0顶点的最近距离 */
for (w = 0; w < G.numVertexes; w++) /* 寻找离v0最近的顶点 */
{
if (!final[w] && (*D)[w] < min)
{
k = w;
min = (*D)[w]; /* w顶点离v0顶点更近 */
}
}
final[k] = 1; /* 将目前找到的最近的顶点置为1 */
for (w = 0; w < G.numVertexes; w++) /* 修正当前最短路径及距离 */
{
/* 如果经过v顶点的路径比现在这条路径的长度短的话 */
if (!final[w] && (min + G.arc[k][w] < (*D)[w]))
{ /* 说明找到了更短的路径,修改D[w]和P[w] */
(*D)[w] = min + G.arc[k][w]; /* 修改当前路径长度 */
(*P)[w] = k;
}
}
}
}
对于n个顶点,e条边,时间复杂度为:
- 求某个源点到其余各顶点的最短路径O(n2)
- 求某个源点到某一特定顶点的最短路径O(n2)
- 求任一顶点到其余各顶点的最短路径,多加一次循环O(n3)
7.5.2 Floyd算法
通过更新前驱矩阵P和邻接矩阵D产生最短路径
适用于求所有顶点到所有顶点的最短路径
typedef int Patharc[MAXVEX][MAXVEX];
typedef int ShortPathTable[MAXVEX][MAXVEX];
/* Floyd算法,求网图G中各顶点v到其余顶点w的最短路径P[v][w]及带权长度D[v][w]。 */
void ShortestPath_Floyd(MGraph G, Patharc *P, ShortPathTable *D)
{
int v, w, k;
for (v = 0; v < G.numVertexes; ++v) /* 初始化D与P */
{
for (w = 0; w < G.numVertexes; ++w)
{
(*D)[v][w] = G.arc[v][w]; /* D[v][w]值即为对应点间的权值 */
(*P)[v][w] = w; /* 初始化P */
}
}
for (k = 0; k < G.numVertexes; ++k)
{
for (v = 0; v < G.numVertexes; ++v)
{
for (w = 0; w < G.numVertexes; ++w)
{
if ((*D)[v][w] > (*D)[v][k] + (*D)[k][w])
{ /* 如果经过下标为k顶点路径比原两点间路径更短 */
(*D)[v][w] = (*D)[v][k] + (*D)[k][w]; /* 将当前两点间权值设更小一个 */
(*P)[v][w] = (*P)[v][k]; /* 路径设置为经过下标为k的顶点 */
}
}
}
}
}
printf("各顶点间最短路径如下:\n");
for (v = 0; v < G.numVertexes; ++v)
{
for (w = v + 1; w < G.numVertexes; w++)
{
printf("v%d-v%d weight: %d ", v, w, D[v][w]);
k = P[v][w]; /* 获得第一个路径顶点下标 */
printf(" path: %d", v); /* 打印源点 */
while (k != w) /* 如果路径顶点下标不是终点 */
{
printf(" -> %d", k); /* 打印路径顶点 */
k = P[k][w]; /* 获得下一个路径顶点下标 */
}
printf(" -> %d\n", w); /* 打印终点 */
}
printf("\n");
}
对于n个顶点,e条边,时间复杂度为O(n3)
7.6 拓扑排序
设G=(V, E)是一个具有n个顶点的有向图,V中的顶点序列v1, v2, …, vn,满足若从顶点vi到vj有一条路径,则在顶点序列中顶点vi必在vj之前。我们称这样的顶点序列为一个拓扑序列
拓扑排序就是对一个有向图构造拓扑序列的过程:选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧;继续重复此步骤,直到输出全部顶点或者图中不存在入度为0的顶点为止
由于需要删除顶点,使用邻接表,结点添加入度域
结构代码
typedef struct EdgeNode /* 边表结点 */
{
int adjvex; /* 邻接点域,存储该顶点对应的下标 */
int weight; /* 用于存储权值,对于非网图可以不需要 */
struct EdgeNode *next; /* 链域,指向下一个邻接点 */
} EdgeNode;
typedef struct VertexNode /* 顶点表结点 */
{
int in; /* 顶点入度 */
int data; /* 顶点域,存储顶点信息 */
EdgeNode *firstedge; /* 边表头指针 */
} VertexNode, AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numVertexes, numEdges; /* 图中当前顶点数和边数 */
} graphAdjList, *GraphAdjList;
拓扑排序算法
/* 拓扑排序,若GL无回路,则输出拓扑排序序列并返回1,若有回路返回0。 */
Status TopologicalSort(GraphAdjList GL)
{
EdgeNode *e;
int i, k, gettop;
int top = 0; /* 用于栈指针下标 */
int count = 0; /* 用于统计输出顶点的个数 */
int *stack; /* 建栈将入度为0的顶点入栈 */
stack = (int *)malloc(GL->numVertexes * sizeof(int));
for (i = 0; i < GL->numVertexes; i++)
if (0 == GL->adjList[i].in) /* 将入度为0的顶点入栈,执行n次 */
stack[++top] = i;
while (top != 0)
{
gettop = stack[top--]; /* 出栈 */
printf("%d -> ", GL->adjList[gettop].data); /* 打印此顶点 */
count++; /* 统计输出顶点数 */
for (e = GL->adjList[gettop].firstedge; e; e = e->next) /* 对此顶点弧表遍历,执行e次 */
{
k = e->adjvex;
if (!(--GL->adjList[k].in)) /* 将k号顶点邻接点的入度减1*/
stack[++top] = k; /* 若为0则入栈,以便下次循环输出 */
}
}
if (count < GL->numVertexes) /* count小于顶点数,说明存在环 */
return ERROR;
else
return OK;
}
对于n个顶点,e条边,时间复杂度为O(n+e)
7.7 关键路径
AOV网:有向图顶点表示活动,弧表示优先关系的网
AOE网:有向图顶点表示事件,弧表示活动,权值表示时间的网
把路径上各个活动所持续的时间之和称为路径长度,从源点到终点具有最大长度的路径称为关键路径,在关键路径上的活动叫关键活动
找到所有活动的最早开始时间和最晚开始时间,如果二者相等就以为这次活动是关键活动,活动间的路径为关键路径
求拓扑序列
找到事件的最早发生时间
$ etv[k]= \begin{cases} 0,& \text{ k = 0 k=0 k=0}\ \max{etv[i]+len<v_i, v_k>},& \text{ k ≠ 0 k≠0 k=0且< v i v_i vi, v k v_k vk>∈ P [ k ] P[k] P[k]} \end{cases} $
其中*P[k]*是所有到达顶点vk的弧的集合,len<vi, vk>是弧<vi, vk>上的权值
int *etv, *ltv; /* 事件最早发生时间和最迟发生时间数组 */
int *stack2; /* 用于存储拓扑序列的栈 */
int top2; /* 用于stack2的指针 */
/* 拓扑排序 */
Status TopologicalSort(GraphAdjList GL)
{ /* 若GL无回路,则输出拓扑排序序列并返回1,若有回路返回0。 */
EdgeNode *e;
int i, k, gettop;
int top = 0; /* 用于栈指针下标 */
int count = 0; /* 用于统计输出顶点的个数 */
int *stack; /* 建栈将入度为0的顶点入栈 */
stack = (int *)malloc(GL->numVertexes * sizeof(int));
for (i = 0; i < GL->numVertexes; i++)
if (0 == GL->adjList[i].in) /* 将入度为0的顶点入栈 */
stack[++top] = i;
top2 = 0; /* 初始化 */
etv = (int *)malloc(GL->numVertexes * sizeof(int)); /* 事件最早发生时间数组 */
for (i = 0; i < GL->numVertexes; i++)
etv[i] = 0; /* 初始化 */
stack2 = (int *)malloc(GL->numVertexes * sizeof(int)); /* 初始化拓扑序列栈 */
while (top != 0)
{
gettop = stack[top--];
count++; /* 输出i号顶点,并计数 */
stack2[++top2] = gettop; /* 将弹出的顶点序号压入拓扑序列的栈 */
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{
k = e->adjvex;
if (!(--GL->adjList[k].in))
stack[++top] = k;
if ((etv[gettop] + e->weight) > etv[k]) /* 求各顶点事件的最早发生时间etv值 */
etv[k] = etv[gettop] + e->weight;
}
}
if (count < GL->numVertexes)
return ERROR;
else
return OK;
}
对于n个顶点,e条边,时间复杂度为O(n+e)
求关键路径
把拓扑序列倒过来,找到事件的最早发生时间
$ ltv[k]= \begin{cases} etv[k],& \text{ k = n − 1 k=n-1 k=n−1}\ \min{ltv[j]-len<v_k, v_j>},& \text{ k < n − 1 k<n-1 k<n−1且< v k v_k vk, v j v_j vj>∈ S [ k ] S[k] S[k]} \end{cases} $
其中*S[k]*是所有从顶点vk出发的的弧的集合,len<vk, vj>是弧<vk, vj>上的权值
int *etv, *ltv; /* 事件最早发生时间和最迟发生时间数组 */
int *stack2; /* 用于存储拓扑序列的栈 */
int top2; /* 用于stack2的指针 */
/* 求关键路径,GL为有向网,输出G的各项关键活动 */
void CriticalPath(GraphAdjList GL)
{
EdgeNode *e;
int i, gettop, k, j;
int ete, lte; /* 声明活动最早发生时间和最迟发生时间变量 */
TopologicalSort(GL); /* 求拓扑序列,计算数组etv和stack2的值 */
ltv = (int *)malloc(GL->numVertexes * sizeof(int)); /* 事件最早发生时间数组 */
for (i = 0; i < GL->numVertexes; i++)
ltv[i] = etv[GL->numVertexes - 1]; /* 初始化ltv */
while (top2 != 0) /* 计算ltv */
{
gettop = stack2[top2--];
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{
k = e->adjvex;
if (ltv[k] - e->weight < ltv[gettop]) /*求各顶点事件最晚发生时间ltv*/
ltv[gettop] = ltv[k] - e->weight;
}
}
for (j = 0; j < GL->numVertexes; j++) /* 求ete,lte和关键活动 */
{
for (e = GL->adjList[j].firstedge; e; e = e->next)
{
k = e->adjvex;
ete = etv[j]; /* 活动最早发生时间 */
lte = ltv[k] - e->weight; /* 活动最迟发生时间 */
if (ete == lte) /* 两者相等即在关键路径上 */
printf("<v%d - v%d> length: %d \n",
GL->adjList[j].data, GL->adjList[k].data, e->weight);
}
}
}
对于n个顶点,e条边,时间复杂度为O(n+e)
算法总体时间复杂度为O(n+e)
第8章 查找
查找表(Search Table)是由同一种类型的数据元素(或记录)构成的集合
关键字(Key)是数据元素中某个数据项的值,又称为键值,可以标识一个数据元素;也可以标识一个记录的某个数据项(字段),称为关键码
- 主关键字(Primary Key):唯一地标识一个记录,所在的数据项称为主关键码
- 次关键字(Secondary Key):标识的记录不唯一,所在的数据项称为次关键码
查找(Searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)
- 静态查找表(Static Search Table):只作查找操作的查找表
- 查询某个特定的数据元素是否在查找表中
- 检索某个特定的数据元素和各种属性
- 动态查找表(Dynamic Search Table):在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素
- 查找时插入数据元素
- 查找时删除数据元素
查找结构:面向查找操作的数据结构
9.1 顺序表查找
顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术
从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值都不相等时,则表中没有所查的记录,查找不成功
顺序表查找算法:O(n)
/* 无哨兵顺序查找,a为数组,n为要查找的数组个数,key为要查找的关键字 */
int Sequential_Search(int *a,int n,int key)
{
int i;
for(i=1;i<=n;i++)
{
if (a[i]==key)
return i;
}
return 0;
}
每次循环时都需要对i是否越界进行判断
平均查找次数为:(n+1)/2
顺序表查找优化:O(n)
/* 有哨兵顺序查找 */
int Sequential_Search2(int *a,int n,int key)
{
int i;
a[0]=key;
i=n;
while(a[i]!=key)
{
i--;
}
return i;
}
从尾部开始查找,由于a[0]=key,如果a[i]中有key则返回i值,查找成功;否则返回0,说明a[1]~a[n]中没有key,查找失败。在总数据较多时,效率提高很多
顺序查找简单低效,适用于小型数据的查找
9.2 有序表查找
折半查找:O(logn)
折半查找(Binary Search),又称二分查找,前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须是顺序存储
在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止
/* 折半查找 */
int Binary_Search(int *a,int n,int key)
{
int low,high,mid;
low=1; /* 定义最低下标为记录首位 */
high=n; /* 定义最高下标为记录末位 */
while(low<=high)
{
mid=(low+high)/2; /* 折半 */
if (key<a[mid]) /* 若查找值比中值小 */
high=mid-1; /* 最高下标调整到中位下标小一位 */
else if (key>a[mid])/* 若查找值比中值大 */
low=mid+1; /* 最低下标调整到中位下标大一位 */
else
return mid; /* 若相等则说明mid即为查找到的位置 */
}
return 0;
}
适用于一次排序后不再变化的静态查找表,对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序工作量较大,不适用二分查找
插值查找:O(logn)
根据要查找的关键值key与查找表中最大最小记录的关键字比较后进行查找
m i d = ( h i g h − l o w ) ∗ ( k e y − a [ l o w ] ) a [ h i g h ] − a [ l o w ] mid = \frac{(high-low)*(key-a[low])}{a[high]-a[low]} mid=a[high]−a[low](high−low)∗(key−a[low])
/* 插值查找 */
int Interpolation_Search(int *a,int n,int key)
{
int low,high,mid;
low=1; /* 定义最低下标为记录首位 */
high=n; /* 定义最高下标为记录末位 */
while(low<=high)
{
mid=low+ (high-low)*(key-a[low])/(a[high]-a[low]); /* 插值 */
if (key<a[mid]) /* 若查找值比插值小 */
high=mid-1; /* 最高下标调整到插值下标小一位 */
else if (key>a[mid])/* 若查找值比插值大 */
low=mid+1; /* 最低下标调整到插值下标大一位 */
else
return mid; /* 若相等则说明mid即为查找到的位置 */
}
return 0;
}
对于表长较大,关键字分布较均匀的查找表,插值查找要优于折半查找
斐波那契查找:O(logn)
斐波那契数组:F={0 1 1 2 3 5 8 13 21 ……}
- key==a[mid]时,查找成功;
- key<a[mid],新范围是第low个到第mid-1个,单位个数为F[k-1]-1
- key>a[mid],新范围是第m+1个到第high个,单位个数为F[k-2]-1
/* 斐波那契查找 */
int Fibonacci_Search(int *a,int n,int key)
{
int low,high,mid,i,k=0;
low=1; /* 定义最低下标为记录首位 */
high=n; /* 定义最高下标为记录末位 */
while(n>F[k]-1)
k++;
for (i=n;i<F[k]-1;i++)
a[i]=a[n];
while(low<=high)
{
mid=low+F[k-1]-1;
if (key<a[mid])
{
high=mid-1;
k=k-1;
}
else if (key>a[mid])
{
low=mid+1;
k=k-2;
}
else
{
if (mid<=n)
return mid; /* 若相等则说明mid即为查找到的位置 */
else
return n;
}
}
return 0;
}
如果要查找的数据在右侧,左侧的数据就不用再判断了,效率更高
计算mid值只进行加减法
9.3 线性索引查找
索引:把一个关键字与它对应的记录相关联
线性索引:将索引项集合组织为线性结构,也称为索引表
稠密索引
在线性索引中,将数据集中的每个记录对应一个索引项。索引表中的索引项一定是按照关键码的有序排列
因为索引项有序,在查找关键字时可以使用有序表查找算法,提高了效率
索引项与数据集的记录个数相同,不适用于数据太多,使得索引表太长
分块索引
为了减少索引项的个数,可以对数据集进行分块,使其分块有序,再对每一块建立一个索引项
分块有序:块内无序、块间有序
每一块的索引项结构:
- 最大关键码:存储每一块的最大关键字,小于在它之后的下一块中的最小关键字
- 块中的记录个数
- 指向块首数据元素的指针,便于开始对这一块中记录进行遍历
步骤:
- 在分块索引表中查找要查关键字所在的块,有序查找
- 根据块首指针找到相应的块,并在块中顺序查找关键码,顺序查找
最好情况:块数m=块中记录数t,总记录数n=m×t=m2,ASLw=n1/2+1
logn<ASLw<n
倒排索引
不是由记录值来确定属性值,而是由属性值来确定记录值
索引项结构:
- 次关键码
- 记录号表,存储具有相同次关键字的所有记录的记录号
9.4 二叉排序树
二叉排序树(Binary Search Tree),又称二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树:
- 若它的左子树不空,则左子树上的所有结点的值均小于它的根结点的值
- 若它的右子树不空,则右子树上的所有结点的值均大于它的根结点的值
- 它的左、右子树也分别为二叉排序树
目的:为了提高查找和插入删除关键字的速度,中序遍历后是一个有序的序列
结构定义
/* 二叉树的二叉链表结点结构定义 */
typedef struct BiTNode /* 结点结构 */
{
int data; /* 结点数据 */
struct BiTNode *lchild, *rchild; /* 左右孩子指针 */
} BiTNode, *BiTree;
查找操作
/* 递归查找二叉排序树T中是否存在key, */
/* 指针f指向T的双亲,其初始调用值为NULL */
/* 若查找成功,则指针p指向该数据元素结点,并返回TRUE */
/* 否则指针p指向查找路径上访问的最后一个结点并返回FALSE */
Status SearchBST(BiTree T, int key, BiTree f, BiTree *p)
{
if (!T) /* 查找不成功 */
{
*p = f;
return FALSE;
}
else if (key==T->data) /* 查找成功 */
{
*p = T;
return TRUE;
}
else if (key<T->data)
return SearchBST(T->lchild, key, T, p); /* 在左子树中继续查找 */
else
return SearchBST(T->rchild, key, T, p); /* 在右子树中继续查找 */
}
插入操作
/* 当二叉排序树T中不存在关键字等于key的数据元素时, */
/* 插入key并返回TRUE,否则返回FALSE */
Status InsertBST(BiTree *T, int key)
{
BiTree p,s;
if (!SearchBST(*T, key, NULL, &p)) /* 查找不成功 */
{
s = (BiTree)malloc(sizeof(BiTNode));
s->data = key;
s->lchild = s->rchild = NULL;
if (!p)
*T = s; /* 插入s为新的根结点 */
else if (key<p->data)
p->lchild = s; /* 插入s为左孩子 */
else
p->rchild = s; /* 插入s为右孩子 */
return TRUE;
}
else
return FALSE; /* 树中已有关键字相同的结点,不再插入 */
}
删除操作
- 叶子结点:直接删除
- 只有左子树或右子树:将左子树或右子树整个移动到删除节点的位置
- 既有左子树又有右子树:找到需要删除的结点p的直接前驱(或直接后继)s,用s来替换结点p,再删除s
/* 从二叉排序树中删除结点p,并重接它的左或右子树。 */
Status Delete(BiTree *p)
{
BiTree q,s;
if((*p)->rchild==NULL) /* 右子树空则只需重接它的左子树(待删结点是叶子也走此分支) */
{
q=*p; *p=(*p)->lchild; free(q);
}
else if((*p)->lchild==NULL) /* 只需重接它的右子树 */
{
q=*p; *p=(*p)->rchild; free(q);
}
else /* 左右子树均不空 */
{
q=*p; s=(*p)->lchild;
while(s->rchild) /* 转左,然后向右到尽头(找待删结点的前驱) */
{
q=s;
s=s->rchild;
}
(*p)->data=s->data; /* s指向被删结点的直接前驱(将被删结点前驱的值取代被删结点的值) */
if(q!=*p)
q->rchild=s->lchild; /* 重接q的右子树 */
else
q->lchild=s->lchild; /* 重接q的左子树 */
free(s);
}
return TRUE;
}
/* 若二叉排序树T中存在关键字等于key的数据元素时,则删除该数据元素结点, */
/* 并返回TRUE;否则返回FALSE。 */
Status DeleteBST(BiTree *T,int key)
{
if(!*T) /* 不存在关键字等于key的数据元素 */
return FALSE;
else
{
if (key==(*T)->data) /* 找到关键字等于key的数据元素 */
return Delete(T);
else if (key<(*T)->data)
return DeleteBST(&(*T)->lchild,key);
else
return DeleteBST(&(*T)->rchild,key);
}
}
二叉树的情况是不确定的,对于平衡二叉树(深度与完全二叉树相同),查找的时间复杂度为O(logn);不平衡的最坏情况是斜树,查找的时间复杂度为O(n)
9.5 平衡二叉树(AVL树)
平衡二叉树是一种二叉排序树,其中每一个结点的左子树和右子树的高度差至多等于1
高度平衡:空树;左右子树都是平衡二叉树,且左子树和右子树的深度(层次)之差的绝对值不超过1
平衡因子BF:二叉树上结点的左子树深度减去右子树深度,平衡二叉树所有结点的BF只能是±1或0
最小不平衡子树:距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树
构建平衡二叉树:
在构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性。若是,则找出最小不平衡子树,在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的连接关系,进行相应的旋转,使之称为新的平衡子树
- 当最小不平衡子树根结点的平衡因子BF>1时,就右旋;BF<-1时,就左旋
- 插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,需要先对结点进行一次旋转使得符号相同,再进行一次反向旋转完成平衡操作
结构定义
/* 二叉树的二叉链表结点结构定义 */
typedef struct BiTNode /* 结点结构 */
{
int data; /* 结点数据 */
int bf; /* 结点的平衡因子 */
struct BiTNode *lchild, *rchild; /* 左右孩子指针 */
} BiTNode, *BiTree;
右旋操作
/* 对以p为根的二叉排序树作右旋处理, */
/* 处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点 */
void R_Rotate(BiTree *P)
{
BiTree L;
L=(*P)->lchild; /* L指向P的左子树根结点 */
(*P)->lchild=L->rchild; /* L的右子树挂接为P的左子树 */
L->rchild=(*P);
*P=L; /* P指向新的根结点 */
}
左旋操作
/* 对以P为根的二叉排序树作左旋处理, */
/* 处理之后P指向新的树根结点,即旋转处理之前的右子树的根结点0 */
void L_Rotate(BiTree *P)
{
BiTree R;
R=(*P)->rchild; /* R指向P的右子树根结点 */
(*P)->rchild=R->lchild; /* R的左子树挂接为P的右子树 */
R->lchild=(*P);
*P=R; /* P指向新的根结点 */
}
左平衡旋转处理
当前T的子树不平衡,根结点的BF>1
#define LH +1 /* 左高 */
#define EH 0 /* 等高 */
#define RH -1 /* 右高 */
/* 对以指针T所指结点为根的二叉树作左平衡旋转处理 */
/* 本算法结束时,指针T指向新的根结点 */
void LeftBalance(BiTree *T)
{
BiTree L,Lr;
L=(*T)->lchild; /* L指向T的左子树根结点 */
switch(L->bf)
{ /* 检查T的左子树的平衡度,并作相应平衡处理 */
case LH: /* 新结点插入在T的左孩子的左子树上,要作单右旋处理 */
(*T)->bf=L->bf=EH;
R_Rotate(T);
break;
case RH: /* 新结点插入在T的左孩子的右子树上,要作双旋处理 */
Lr=L->rchild; /* Lr指向T的左孩子的右子树根 */
switch(Lr->bf)
{ /* 修改T及其左孩子的平衡因子 */
case LH: (*T)->bf=RH;
L->bf=EH;
break;
case EH: (*T)->bf=L->bf=EH;
break;
case RH: (*T)->bf=EH;
L->bf=LH;
break;
}
Lr->bf=EH;
L_Rotate(&(*T)->lchild); /* 对T的左子树作左旋平衡处理 */
R_Rotate(T); /* 对T作右旋平衡处理 */
}
}
右平衡旋转处理
/* 对以指针T所指结点为根的二叉树作右平衡旋转处理, */
/* 本算法结束时,指针T指向新的根结点 */
void RightBalance(BiTree *T)
{
BiTree R,Rl;
R=(*T)->rchild; /* R指向T的右子树根结点 */
switch(R->bf)
{ /* 检查T的右子树的平衡度,并作相应平衡处理 */
case RH: /* 新结点插入在T的右孩子的右子树上,要作单左旋处理 */
(*T)->bf=R->bf=EH;
L_Rotate(T);
break;
case LH: /* 新结点插入在T的右孩子的左子树上,要作双旋处理 */
Rl=R->lchild; /* Rl指向T的右孩子的左子树根 */
switch(Rl->bf)
{ /* 修改T及其右孩子的平衡因子 */
case RH: (*T)->bf=LH;
R->bf=EH;
break;
case EH: (*T)->bf=R->bf=EH;
break;
case LH: (*T)->bf=EH;
R->bf=RH;
break;
}
Rl->bf=EH;
R_Rotate(&(*T)->rchild); /* 对T的右子树作右旋平衡处理 */
L_Rotate(T); /* 对T作左旋平衡处理 */
}
}
构建平衡二叉树
/* 若在平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个 */
/* 数据元素为e的新结点,并返回1,否则返回0。若因插入而使二叉排序树 */
/* 失去平衡,则作平衡旋转处理,布尔变量taller反映T长高与否。 */
Status InsertAVL(BiTree *T,int e,Status *taller)
{
if(!*T)
{ /* 插入新结点,树“长高”,置taller为TRUE */
*T=(BiTree)malloc(sizeof(BiTNode));
(*T)->data=e; (*T)->lchild=(*T)->rchild=NULL; (*T)->bf=EH;
*taller=TRUE;
}
else
{
if (e==(*T)->data)
{ /* 树中已存在和e有相同关键字的结点则不再插入 */
*taller=FALSE; return FALSE;
}
if (e<(*T)->data)
{ /* 应继续在T的左子树中进行搜索 */
if(!InsertAVL(&(*T)->lchild,e,taller)) /* 未插入 */
return FALSE;
if(*taller) /* 已插入到T的左子树中且左子树“长高” */
switch((*T)->bf) /* 检查T的平衡度 */
{
case LH: /* 原本左子树比右子树高,需要作左平衡处理 */
LeftBalance(T); *taller=FALSE; break;
case EH: /* 原本左、右子树等高,现因左子树增高而使树增高 */
(*T)->bf=LH; *taller=TRUE; break;
case RH: /* 原本右子树比左子树高,现左、右子树等高 */
(*T)->bf=EH; *taller=FALSE; break;
}
}
else
{ /* 应继续在T的右子树中进行搜索 */
if(!InsertAVL(&(*T)->rchild,e,taller)) /* 未插入 */
return FALSE;
if(*taller) /* 已插入到T的右子树且右子树“长高” */
switch((*T)->bf) /* 检查T的平衡度 */
{
case LH: /* 原本左子树比右子树高,现左、右子树等高 */
(*T)->bf=EH; *taller=FALSE; break;
case EH: /* 原本左、右子树等高,现因右子树增高而使树增高 */
(*T)->bf=RH; *taller=TRUE; break;
case RH: /* 原本右子树比左子树高,需要作右平衡处理 */
RightBalance(T); *taller=FALSE; break;
}
}
}
return TRUE;
}
平衡二叉树的查找、插入、删除的时间复杂度都是O(logn)
对于无序集合,需要频繁的查找、插入、删除操作,则可以选择构建平衡二叉树
9.6 多路查找树(B树)
多路查找树:每个结点的孩子数可以多于两个,且每一个结点出可以存储多个元素
9.6.1 2-3树
每个结点都具有两个孩子(2结点)或3个孩子(3结点)
- 一个2结点包含一个元素和两个孩子(或没有孩子)
- 左子树包含的元素小于该元素,右子树包含的元素大于该元素
- 不能只有一个孩子
- 一个3结点包含一小一大两个元素和三个孩子(或没有孩子)
- 左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素
- 2-3树的所有叶子都在同一层次上
插入:在叶子结点
- 空树:插入一个2结点
- 插入到2结点的叶子上:升级为3结点
- 插入到3结点的叶子上:拆分3结点,原有2个元素+1个插入选择一个上移;如果需要拆分根结点,树的高度就会增加
删除:
- 删除元素位于3结点的叶子上:直接删除
- 删除元素位于2结点的叶子上:
- 此结点的双亲也是2结点,且拥有一个3结点的孩子:旋转另一个孩子
- 此结点的双亲也是2结点,且拥有一个2结点的孩子:对整棵树变形
- 此结点的双亲是3结点:拆分2结点
- 满二叉树:合并3结点,减少树的层数
- 删除元素位于非叶子的结点:将树按中序遍历后得到此元素的前驱和后继元素,用其补位
9.6.2 2-3-4树
4结点:包含小中大三个元素和四个孩子(或没有孩子)
9.6.3 B树
B树(B-tree)是一种平衡的多路查找树,结点最大的孩子数目称为B树的阶(order),2-3树是3阶B树,2-3-4树是4阶B树
一个m阶的B树具有如下属性:
- 如果根结点不是叶子结点,则其至少有两个子树
- 每一个非根结点都有k-1个元素和k个孩子,每一个叶子结点都有k-1个元素,其中,m/2向上取整≤k≤m
- 所有的叶子结点都位于同一层次
- 所有的分支节点都包含信息数据:n, A0, …, Ki, Ai, …。其中Ki(i=1,2,…,n)为关键字,且Ki<Ki+1,;Ai为指向子树根结点的指针,且指针Ai-1所指子树中所有结点的关键字均小于Ki;An所指子树中所有结点的关键字均大于Kn;n+1为子树的个数
在B树上查找的过程是一个顺时针查找结点和在结点中查找关键字的交叉过程
减少了必须访问结点和数据块的数量,便于内外存的数据交互
在含有n个关键字的m阶B树上查找时,从根结点到关键字结点的路径上涉及的结点数不超过logm/2向上取整((n+1)/2)+1
9.6.4 B+树
B+树是应文件系统所需而提出的一种B树的变形树
在B+树种,出现在分支结点中的元素会被当作它们在该分支结点位置的中序后继者(叶子结点)中再次列出,每一个叶子结点都会保存一个指向后一叶子结点的指针
- 在n棵子树的结点中包含n个关键字
- 所有的叶子节点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序连接
- 所有的分支结点可以看成是索引,结点中仅含有其子树的最大(或最小)关键字
适合带有范围的查找,插入和删除都是在叶子结点上进行
9.7 散列表查找(哈希表)
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key),f称为散列函数或哈希(Hash)函数
采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表或哈希表(Hash Table),关键字对应的记录存储位置称为哈希地址
冲突:key1≠key2,f(key1)=f(key2),key1和key2称为这个散列函数的同义词
9.7.1 散列函数的构造方法
原则:计算简单、散列地址均匀分布
-
直接定址法:取关键字的某个线性函数值为散列地址
f(key) = a * key + b
简单、均匀、不会产生冲突;需要知道关键字的分布,适合查找表较小且连续的情况
-
数字分析法:抽取关键字的一部分来计算散列存储位置
适合处理关键字位数比较大的情况,事先知道关键字的分布且关键字的若干位分布较均匀
-
平方取中法:将关键字进行平方运算,在抽取中间的几位
不知道关键字的分布,位数不是很大
-
折叠法:将关键字从左到右分成位数相等的几部分叠加求和,按照散列表表长取最后极为作为散列地址
不知道关键字的分布,位数较多
-
除留余数法:最常用
f(key) = key % p, p≤散列表长
-
随机数法:选择一个随机数,取关键字的随机函数值作为它的散列地址
f(key) = random(key)
关键字的长度不等
9.7.2 处理散列冲突的方法
-
开放定址法:一旦发生冲突,寻找下一个空的散列地址
线性探测法、二次探测法、随机探测法
-
再散列函数法:换一个散列函数计算
-
链地址法:将所有关键字为同义词的记录存储在一个单链表中,称为同义词子表,在散列表中只存储所有同义词子表的头指针
-
公共溢出区法:冲突的关键字存放到溢出表中
散列表查找实现
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 /* 定义散列表长为数组的长度 */
#define NULLKEY -32768
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef struct
{
int *elem; /* 数据元素存储基址,动态分配数组 */
int count; /* 当前数据元素个数 */
}HashTable;
int m=0; /* 散列表表长,全局变量 */
/* 初始化散列表 */
Status InitHashTable(HashTable *H)
{
int i;
m=HASHSIZE;
H->count=m;
H->elem=(int *)malloc(m*sizeof(int));
for(i=0;i<m;i++)
H->elem[i]=NULLKEY;
return OK;
}
/* 散列函数 */
int Hash(int key)
{
return key % m; /* 除留余数法 */
}
/* 插入关键字进散列表 */
void InsertHash(HashTable *H,int key)
{
int addr = Hash(key); /* 求散列地址 */
while (H->elem[addr] != NULLKEY) /* 如果不为空,则冲突 */
{
addr = (addr+1) % m; /* 开放定址法的线性探测 */
}
H->elem[addr] = key; /* 直到有空位后插入关键字 */
}
/* 散列表查找关键字 */
Status SearchHash(HashTable H,int key,int *addr)
{
*addr = Hash(key); /* 求散列地址 */
while(H.elem[*addr] != key) /* 如果不为空,则冲突 */
{
*addr = (*addr+1) % m; /* 开放定址法的线性探测 */
if (H.elem[*addr] == NULLKEY || *addr == Hash(key)) /* 如果循环回到原点 */
return UNSUCCESS; /* 则说明关键字不存在 */
}
return SUCCESS;
}
第9章 排序
9.1 排序的定义
排序:使序列{r1, r2, …, rn}成为一个按关键字{k1, k2, …, kn}有序的序列
排序的稳定性:假设ki=k,i≠j,且再排序前ri在rj前,如果排序后ri还在rj前,则排序方法是稳定的,否则是不稳定的
内排序:在排序整个过程中,待排序的所有记录全部被放置在内存中
插入排序、交换排序、选择排序、归并排序
外排序:由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行
结构定义
#define MAXSIZE 10000 /* 用于要排序数组个数最大值,可根据需要修改 */
typedef struct
{
int r[MAXSIZE+1]; /* 用于存储要排序数组,r[0]用作哨兵或临时变量 */
int length; /* 用于记录顺序表的长度 */
}SqList;
元素交换
/* 交换L中数组r的下标为i和j的值 */
void swap(SqList *L,int i,int j)
{
int temp=L->r[i];
L->r[i]=L->r[j];
L->r[j]=temp;
}
9.2 冒泡排序
冒泡排序(Bubble Sort)是一种交换排序,两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止
冒泡排序初级版
与L中剩余元素交换
/* 对顺序表L作交换排序(冒泡排序初级版) */
void BubbleSort0(SqList *L)
{
int i,j;
for(i=1;i<L->length;i++)
{
for(j=i+1;j<=L->length;j++)
{
if(L->r[i]>L->r[j])
{
swap(L,i,j);/* 交换L->r[i]与L->r[j]的值 */
}
}
}
}
冒泡排序:O(n2)
交换相邻元素
/* 对顺序表L作冒泡排序 */
void BubbleSort(SqList *L)
{
int i,j;
for(i=1;i<L->length;i++)
{
for(j=L->length-1;j>=i;j--) /* 注意j是从后往前循环 */
{
if(L->r[j]>L->r[j+1]) /* 若前者大于后者(注意这里与上一算法的差异)*/
{
swap(L,j,j+1);/* 交换L->r[j]与L->r[j+1]的值 */
}
}
}
}
冒泡排序改进版
因为是升序,上一次比较没有数据交换,下一次也没有,避免不必要的循环判断
/* 对顺序表L作改进冒泡算法 */
void BubbleSort2(SqList *L)
{
int i,j;
Status flag=TRUE; /* flag用来作为标记 */
for(i=1;i<L->length && flag;i++) /* 若flag为true说明有过数据交换,否则停止循环 */
{
flag=FALSE; /* 初始为False */
for(j=L->length-1;j>=i;j--)
{
if(L->r[j]>L->r[j+1])
{
swap(L,j,j+1); /* 交换L->r[j]与L->r[j+1]的值 */
flag=TRUE; /* 如果有数据交换,则flag为true */
}
}
}
}
最好的情况:没有数据交换,O(n)
最坏的情况:待排序表逆序,交换(n-1)+…+2+1=n(n-1)/2次,O(n2)
9.3 简单选择排序
简单选择排序(Simple Selection Sort)就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并与第i个记录进行交换
简单选择排序:O(n2)
/* 对顺序表L作简单选择排序 */
void SelectSort(SqList *L)
{
int i,j,min;
for(i=1;i<L->length;i++)
{
min = i; /* 将当前下标定义为最小值下标 */
for (j = i+1;j<=L->length;j++)/* 循环之后的数据 */
{
if (L->r[min]>L->r[j]) /* 如果有小于当前最小值的关键字 */
min = j; /* 将此关键字的下标赋值给min */
}
if(i!=min) /* 若min不等于i,说明找到最小值,交换 */
swap(L,i,min); /* 交换L->r[i]与L->r[min]的值 */
}
}
移动数据次数少,关键字比较(n-1)+…+2+1=n(n-1)/2次
对于交换次数而言,最好为0次,最差为n-1次
总时间复杂度:比较+交换,O(n2)
9.4 直接插入排序
直接插入排序(Straight Insertion Sort)就是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表
直接插入排序:O(n2)
/* 对顺序表L作直接插入排序 */
void InsertSort(SqList *L)
{
int i,j;
for(i=2;i<=L->length;i++) /* 假设r[1]已经放好位置,后面选择插入到左侧还是右侧 */
{
if (L->r[i]<L->r[i-1]) /* 需将L->r[i]插入有序子表 */
{
L->r[0]=L->r[i]; /* 设置哨兵 */
for(j=i-1;L->r[j]>L->r[0];j--)
L->r[j+1]=L->r[j]; /* 记录后移 */
L->r[j+1]=L->r[0]; /* 插入到正确位置 */
}
}
}
需要一个辅助记录空间,时间复杂度O(n2)
9.5 堆排序
堆(Heap)是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值(大顶堆),或每个结点的值都小于或等于其左右孩子结点的值(小顶堆)
使用大顶堆进行堆排序(Heap Sort):
将待排序的序列构造成一个大顶堆。此时整个序列的最大值就是堆顶的根结点。将它移走,然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素的次大值……反复执行就能得到一个有序序列
堆排序:O(nlogn)
/* 对顺序表L进行堆排序 */
void HeapSort(SqList *L)
{
int i;
for(i=L->length/2;i>0;i--) /* 把L中的r构建成一个大顶堆 */
HeapAdjust(L,i,L->length);
for(i=L->length;i>1;i--)
{
swap(L,1,i); /* 将堆顶记录和当前未经排序子序列的最后一个记录交换 */
HeapAdjust(L,1,i-1); /* 将L->r[1..i-1]重新调整为大顶堆 */
}
}
/* 已知L->r[s..m]中记录的关键字除L->r[s]之外均满足堆的定义, */
/* 本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆 */
void HeapAdjust(SqList *L,int s,int m)
{
int temp,j;
temp=L->r[s];
for(j=2*s;j<=m;j*=2) /* 沿关键字较大的孩子结点向下筛选 */
{
if(j<m && L->r[j]<L->r[j+1])
++j; /* j为关键字中较大的记录的下标 */
if(temp>=L->r[j])
break; /* rc应插入在位置s上 */
L->r[s]=L->r[j];
s=j;
}
L->r[s]=temp; /* 插入 */
}
堆排序的比较与交换是跳跃式进行的,因此是不稳定排序
由于出吃构建堆所需的比较次数较多,因此不适合待排序序列个数较少的情况
9.6 归并排序
归并排序(Merging Sort):假设初始序列含有n个记录,则看成是n个有序的子序列,每个子序列的长度为1;然后两两归并,得到n/2向上取整个长度为2或1的有序子序列;再两两归并……,直至得到一个长度为n的有序序列为止
递归实现归并排序:O(nlogn)
/* 对顺序表L作归并排序 */
void MergeSort(SqList *L)
{
MSort(L->r,L->r,1,L->length);
}
/* 递归法 */
/* 将SR[s..t]归并排序为TR1[s..t] */
void MSort(int SR[],int TR1[],int s, int t)
{
int m;
int TR2[MAXSIZE+1];
if(s==t)
TR1[s]=SR[s];
else
{
m=(s+t)/2; /* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
MSort(SR,TR2,s,m); /* 递归地将SR[s..m]归并为有序的TR2[s..m] */
MSort(SR,TR2,m+1,t); /* 递归地将SR[m+1..t]归并为有序的TR2[m+1..t] */
Merge(TR2,TR1,s,m,t); /* 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t] */
}
}
/* 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n] */
void Merge(int SR[],int TR[],int i,int m,int n)
{
int j,k,l;
for(j=m+1,k=i;i<=m && j<=n;k++) /* 将SR中记录由小到大地并入TR */
{
if (SR[i]<SR[j])
TR[k]=SR[i++];
else
TR[k]=SR[j++];
}
if(i<=m)
{
for(l=0;l<=m-i;l++)
TR[k+l]=SR[i+l]; /* 将剩余的SR[i..m]复制到TR */
}
if(j<=n)
{
for(l=0;l<=n-j;l++)
TR[k+l]=SR[j+l]; /* 将剩余的SR[j..n]复制到TR */
}
}
比较占用内存,稳定排序
非递归实现归并排序:O(n)
/* 对顺序表L作归并非递归排序 */
void MergeSort2(SqList *L)
{
int* TR=(int*)malloc(L->length * sizeof(int));/* 申请额外空间 */
int k=1;
while(k<L->length)
{
MergePass(L->r,TR,k,L->length);
k=2*k;/* 子序列长度加倍 */
MergePass(TR,L->r,k,L->length);
k=2*k;/* 子序列长度加倍 */
}
}
/* 非递归法 */
/* 将SR[]中相邻长度为s的子序列两两归并到TR[] */
void MergePass(int SR[],int TR[],int s,int n)
{
int i=1;
int j;
while(i <= n-2*s+1)
{/* 两两归并 */
Merge(SR,TR,i,i+s-1,i+2*s-1);
i=i+2*s;
}
if(i<n-s+1) /* 归并最后两个序列 */
Merge(SR,TR,i,i+s-1,n);
else /* 若最后只剩下单个子序列 */
for(j =i;j <= n;j++)
TR[j] = SR[j];
}
使用归并排序时,尽量考虑用非递归方法
9.7 快速排序
快速排序(Quick Sort):通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序
快速排序:O(n2)
/* 对顺序表L作快速排序 */
void QuickSort(SqList *L)
{
QSort(L,1,L->length);
}
/* 对顺序表L中的子序列L->r[low..high]作快速排序 */
void QSort(SqList *L,int low,int high)
{
int pivot;
if(low<high)
{
pivot=Partition(L,low,high); /* 将L->r[low..high]一分为二,算出枢轴值pivot */
QSort(L,low,pivot-1); /* 对低子表递归排序 */
QSort(L,pivot+1,high); /* 对高子表递归排序 */
}
}
/* 交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置 */
/* 此时在它之前(后)的记录均不大(小)于它。 */
int Partition(SqList *L,int low,int high)
{
int pivotkey;
pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
while(low<high) /* 从表的两端交替地向中间扫描 */
{
while(low<high&&L->r[high]>=pivotkey)
high--;
swap(L,low,high);/* 将比枢轴记录小的记录交换到低端 */
while(low<high&&L->r[low]<=pivotkey)
low++;
swap(L,low,high);/* 将比枢轴记录大的记录交换到高端 */
}
return low; /* 返回枢轴所在位置 */
}
快速排序比较与交换是跳跃式进行的,因此是不稳定排序
三数取中
median-of-three,取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数
备份枢轴,减少不必要的交换
/* 快速排序优化算法 */
int Partition1(SqList *L,int low,int high)
{
int pivotkey;
int m = low + (high - low) / 2; /* 计算数组中间的元素的下标 */
if (L->r[low]>L->r[high])
swap(L,low,high); /* 交换左端与右端数据,保证左端较小 */
if (L->r[m]>L->r[high])
swap(L,high,m); /* 交换中间与右端数据,保证中间较小 */
if (L->r[m]>L->r[low])
swap(L,m,low); /* 交换中间与左端数据,保证左端较小 */
pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
L->r[0]=pivotkey; /* 将枢轴关键字备份到L->r[0] */
while(low<high) /* 从表的两端交替地向中间扫描 */
{
while(low<high&&L->r[high]>=pivotkey)
high--;
L->r[low]=L->r[high];
while(low<high&&L->r[low]<=pivotkey)
low++;
L->r[high]=L->r[low];
}
L->r[low]=L->r[0];
return low; /* 返回枢轴所在位置 */
}
小数组用直接插入排序
void QSort1(SqList *L,int low,int high)
{
int pivot;
if((high-low)>MAX_LENGTH_INSERT_SORT)
{
pivot=Partition1(L,low,high); /* 将L->r[low..high]一分为二,算出枢轴值pivot */
QSort1(L,low,pivot-1); /* 对低子表递归排序 */
QSort1(L,pivot+1,high); /* 对高子表递归排序 */
}
else
InsertSort(L);
}
尾递归
用一次迭代代替递归调用
/* 尾递归 */
void QSort2(SqList *L,int low,int high)
{
int pivot;
if((high-low)>MAX_LENGTH_INSERT_SORT)
{
while(low<high)
{
pivot=Partition1(L,low,high); /* 将L->r[low..high]一分为二,算出枢轴值pivot */
QSort2(L,low,pivot-1); /* 对低子表递归排序 */
low=pivot+1; /* 尾递归 */
}
}
else
InsertSort(L);
}
9.8 总结
排序分类:
- 插入排序类:直接插入排序
- 选择排序类:简单选择排序、堆排序
- 交换排序类:冒泡排序、快速排序
- 归并排序类:归并排序
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 稳定 |
直接插入排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n2) | O(logn)~O(n) | 不稳定 |
基本有序:冒泡排序、直接插入排序
占用内存少:不考虑归并排序、快速排序
兼顾排序稳定性和速度:归并排序
待排序个数越少,采用三种简单排序方法越合适
移动次数:
排序方法 平均情况 最好情况 最坏情况 冒泡排序 O(n2) 0 O(n2) 简单选择排序 O(n) 0 O(n) 直接插入排序 O(n2) O(n) O(n2) 数据量不是很大而记录的关键字信息量较大的排序要求,采用简单选择排序
综合考量:经过优化的快速排序