1.2基本概念和术语
1.2.1数据、数据元素、数据项和数据对象
数据是客观事物的符号表示,是所有能输入到计算机中并被计算机程序处理的符号的总称。
数据元素是数据的基本单位。数据元素通常用于完整地描述一个对象。例如一名学生记录。
数据项是组成数据元素的、有独立含义的、不可分割的最小单位。例如学生基本信息表中的学号、姓名、性别等。
数据对象是性质相同的数据元素的集合,是数据的一个子集。
1.2.2数据结构
数据结构是相互之间存在一种或多种特定关系(“结构”)的数据元素的集合。
数据结构包括逻辑结构和存储结构两个层次。
1.逻辑结构
(1) 集合结构
(2) 线性结构
(3) 树结构
(4) 图结构或网状结构
其中集合结构、树结构和图结构都属于非线性结构。
线性结构包括线性表、栈和队列、字符串、数组、广义表。
非线性结构包括树和二叉树、有向图和无向图。
2.存储结构
(1) 顺序存储结构
(2) 链式存储结构
1.4.1算法的定义及特性
算法是为了解决某类问题而规定的一个有限长的操作序列。
一个算法必须满足一下五个重要特性:
(1) 有穷性。一个算法必须总是在执行有穷步后结束,且每一步都必须在有穷时间内完成。
(2) 确定性。对于每种情况下所应执行的操作,在算法中都有确切的规定,不会产生二义性,使算法的执行者或阅读者都能明确其含义及如何执行。
(3) 可行性。算法中的所有操作都可以通过已经实现的基本操作运算执行有限次来实现。
(4) 输入。一个算法有零个或多个输入。
(5) 输出。一个算法有一个或多个输出。
1.4.3算法的时间复杂度
算法的时间复杂度取决于问题的规模和待处理数据的初态。
一般情况下,算法中基本语句重复执行的次数是时间规模n的某个函数f(n),算法的时间量度记作T(n)=O(f(n)),它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称做算法的渐进时间复杂度,简称时间复杂度。
若f(n)=amnm+am-1nm-1+…+a1n+a0是一个m次多项式,则T(n)=O(nm)。
1.4.4算法的空间复杂度
关于算法的存储空间需求,类似于算法的时间复杂度,我们采用渐进空间复杂度作为算法所需存储空间的量度,简称空间复杂度,它也是问题规模n的函数,记作:S(n)=O(f(n))。
若算法执行时所需要的辅助空间相对于输入数据量而言是个常数,则称这个算法为原地工作,辅助空间为O(1)。
线性表特征:线性结构的基本特点是除第一个元素无直接前驱,最后一个元素无直接后继外,其他每个数据元素都有一个前驱和后继。
2.1线性表的定义和特点
由n(n>=0)个数据特性相同的元素构成的有限序列称为线性表(同一性、有穷性、有序性)。
线性表中元素的个数 n(n>=0)定义为线性表的长度,n=0时称为空表。
2.4线性表的顺序表示和实现
2.4.1线性表的顺序存储表示
线性表的顺序表示指的是用一组地址连续的存储单元依次存储线性表的数据元素,这种表示也称作线性表的顺序存储结构。
假设线性表的每个元素需占用l个存储单元,并以所占的第一个单元的存储地址(基地址)作为数据元素的存储起始位置。
线性表的第i个数据元素ai的存储位置为:LOC(ai)=LOC(a1)+(i-1)*l
第i+1个数据元素的存储位置和第i个数据元素的存储位置满足:LOC(ai+1)=LOC(ai)+l。
只要确定了存储线性表的起始位置,线性表中任一数据元素都可随机存取,所以线性表的顺序存储结构是一种随机存取的存储结构。
2.4.2顺序表中基本操作的实现
1.初始化
Status InitList(SqList &L)
{
L.elem=new ElemType[MAXSIZE]; //为顺序表分配一个大小为MAXSIZE的数组空间
if(!L.elem) exit(OVERFLOW);
L.length=0; //当前长度为0,为空表
return OK;
}
2.取值
elem[i-1]单元存储第i个数据元素。
Status GetElem(SqList L,int I,ElemType &e)
{
if(i<1||i>L.length) return ERROR;
e=L.elem[i-1];
return OK;
}
取值算法的时间复杂度为O(l)。
3.查找
int LocateElem(SqList L,ElemType e)
{
for(i=0;i<L.length;i++)
if(L.elem[i]==e) return i+1; //查找成功,返回序号i+1
return 0; //查找失败,返回0
}
平均查找长度ASL=(n+1)/2,平均时间复杂度为O(n)。
4.插入
Status ListInsert(SqList &L,int i,ElemType e)
{
if((i<1)||(i>L.length+1)) return ERROR; //i不合法
if(L.length==MAXSZIE) return ERROR; //存储空间已满
for(j=L.length-1;j>=i-1;j--)
L.elem[j+1]=L.elem[j]; //插入位置后的元素后移
L.elem[i-1]=e; //新元素e放入第i个位置
++L.length; //表长加1
return OK;
}
所需移动元素次数的期望值Eins=n/2,插入算法的平均时间复杂度为O(n)。
5.删除
Status ListDelete(SqList &L,int i)
{
if((i<1)||(i>L.length)) return ERROR; //i不合法
for(j=i;j<L.length-1;j++)
L.elem[j-1]=L.elem[j]; //被删除元素之后的元素前移
--L.length; //表长减1
return OK;
}
所移动元素的期望值Edel=(n-1)/2,删除算法的平均时间复杂度O(n)。
2.5线性表的链式表示和实现
线性表链式存储结构的特点是:用一组任意的存储单元存储线性表的数据元素(这组存储单元可以是连续的,也可以是不连续的)。元素本身的信息和指示其直接后继的信息组成数据元素ai的存储映像,称为结点。它包括两个域:其中存储数据元素信息的域称为数据域;存储直接后继存储位置的域称为指针域。由于此链表的每个结点中只包含一个指针域,故又称线性链表或单链表。
链表增加头节点的作用:(1)便于首元结点的处理(2)便于空表和非空表的统一处理。
2.5.2单链表基本操作的实现
操作特点:顺链操作,指针保留
1.初始化
Status InitList(LinkList &L)
{
L=new LNode;
L->next=NULL;
return OK;
}
2.取值
Status GetElem(LinkList L,int i,ElemType &e) //取序号为i的元素的值
{
p=L->next;
j=1;
while(p&&j<i) //p为空或p指向第i个元素跳出循环
{
p=p->next;
++j;
}
if(!p||j>i) return ERROR; //i>n或i<=0,不合法
e=p->data;
return OK;
}
平均查找长度ASL=(n-1)/2,平均时间复杂度为O(n)。
3.查找
分为按序号查找和按值查找,从链表的首元结点出发(只能顺链查找)。
按值查找:
LNode *LocateElem(LinkList L,ElemType e)
{
p=L->next;
while(p&&p->data!=e) //p为空或p结点的数据域等于e跳出循环
p=p->next;
return p;
}
平均时间复杂度为O(n)。
4.插入
Status ListInsert(LinkList &L,int i,ElemType e)
{
p=L;
j=0;
while(p&&(j<i-1))
{p=p->next;++j;}
if(!p||j>i-1) return ERROR;
s=new LNode;
s->data=e;
s->next=p->next;
p->next=s;
return OK;
}
平均时间复杂度为O(n)。
5.删除
Status ListDelete(LinkList &L,int i)
{
p=L;
j=0;
while((p->next)&&(j<i-1)) //查找第i-1个结点
{p=p->next;++j;}
if(!(p->next)||(j>i-1)) return ERROR; //i>n或i<1删除位置不合法
q=p->next;
p->next=q->next; // p->next=p->next->next
delete q;
return OK;
}
平均时间复杂度为O(n)。
6.创建单链表
(1)前插法/头插法
void CreateList_H(LinkList &L,int n)
{
L=new LNode;
L->next=NULL;
for(i=0;i<n;++i)
{
p=new LNode;
cin>>p->data;
p->next=L->next;
L->next=p; //p插入到头结点之后
}
}
时间复杂度为O(n)。
(2)后插法/尾插法
Void CreateList_R(LinkList &L,int n)
{
L=new LNode;
L->next=NULL;
r=L; //尾指针r指向头结点
for(i=0;i<n;++i)
{
p=new LNode;
cin>>p->data;
p->next=NULL;
r->next=p; //新节点p插入尾结点r之后
r=p; //r指向新的尾结点p
}
}
时间复杂度为O(n)。
2.5.3循环链表
其特点是表中最后一个结点的指针域指向头结点(首尾相接)。
判别当前指针p是否指向表尾结点的终止条件为p!=L或p->next!=L。
2.5.4双向链表
在双向链表的结点中有两个指针域,一个指向直接后继,另一个指向直接前驱。
插入:
Status ListInsert_Dul(DuLinkList &L,int i,ElemType e)
{ //在第i个位置之前插入元素e
if(!(p=GetElem_Dul(L,i))) return ERROR; //第i个元素不存在
s=new DulNode;
s->data=e;
s->prior=p->prior;
p->prior->next=s;
s->next=p;
p->prior=s;
return OK;
}
删除:
Status ListDelete_Dul(DuLinkList &L,int i)
{ //删除第i个元素
if(!(p=GetElem_Dul(L,i))) return ERROR; //第i个元素不存在
p->prior->next=p->next;
p->next->prior=p->prior;
delete p;
return OK;
}
2.6顺序表和链表的比较
2.6.1空间性能的比较
(1)存储空间的分配
当线性表的长度变化较大,难以预估存储规模时,宜采用链表作为存储结构。
(2)存储密度的大小
存储密度是指数据元素本身所占用的存储量和整个结点结构所占用的存储量之比。存储密度越大,存储空间的利用率就越高。顺序表的存储密度为1,而链表的存储密度小于1。
当线性表的长度变化不大,易于事先确定其大小时,为了节约存储空间,宜采用顺序表为存储结构。
2.6.2时间性能比较
(1)存取元素的效率
若线性表的主要操作是和元素位置紧密相关的这类取值操作,很少做插入或删除时,宜采用顺序表作为存储结构。
(2)插入和删除操作的效率
对于频繁进行插入或删除操作的线性表,宜采用链表作为存储结构。
运算的位置限定在表的端点。数据元素可是任意类型,但必须属于同一个数据对象,数据之间是线性关系。
3.1.1栈的定义和特点
栈是限定仅在表尾进行插入或删除操作的线性表。表尾端称为栈顶,表头端称为栈底。不含元素的空表称为空栈。栈又称为后进先出的线性表。
3.1.2队列的定义和特点
队列是一种先进先出的线性表。只允许在表的一端进行插入,而在另一端删除元素。在队列中,允许插入的一端称为队尾,允许删除的一端称为队头。
3.3栈的表示和操作的实现
3.3.2顺序栈的表示和实现
顺序栈,即利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素。指针top指示栈顶元素(top=0/-1表示空栈),指针base指示栈底元素。当top和base的值相等时,表示空栈。
栈空时,top和base的值相等;栈非空时,top始终指向栈顶元素的上一个位置。
1.初始化
Status InitStack(SqStack &S)
{
S.base=new SElemType[MAXSIZE];
if(!S.base) exit(OVERFLOW);
S.top=S.base;
S.stacksize=MAXSIZE;
return OK;
}
2.入栈
Status Push(SqStack &S,SElemType e)
{
if(S.top-S.base==S.stacksize) return ERROR; //栈满
*S.top++=e; //*S.top=e; S.top++; e压入栈顶,栈顶指针加1
return OK;
}
3.出栈
Status Pop(SqStack &S,SElemType &e)
{
if(S.top==S.base) return ERROR; //栈空
e=*--S.top; //S.top--;e=*S.top; 栈顶指针减1,栈顶元素赋给e
return OK;
}
4.取栈顶元素
SElmType GetTop(SqStack &S)
{
if(S.top!=S.base) //栈非空
return *(S.top-1); //返回栈顶元素的值,栈顶指针不变
}
3.3.3链栈的表示和实现
以链表的头部作为栈顶,无需附加头结点。
Status InitStack(LinkStack &S)
{
S=NULL;
return OK;
}
Status Push(LinkStack &S,SElemType e)
{
p=new StackNode;
p->data=e;
p->next=S; //新节点插入栈顶
S=p; //栈顶指针指向p
return OK;
}
Status Pop(LinkStack &S,SElemType &e)
{
if(S==NULL) return ERROR; //栈空
e=S->data; //栈顶元素赋给e
p=S;
S=S->next; //修改栈顶指针
delete p;
return OK;
}
4.取链栈的栈顶元素
SElmType GetTop(LinkStack S)
{
if(S!=NULL)
return S->data; //返回栈顶元素的值,栈顶指针不变
}
两栈共享技术:两栈共享栈空间。
3.4栈与递归
3.4.1采用递归算法解决的问题
若在一个函数、过程或者数据结构定义的内部又直接(或间接)出现定义本身的应用,则称它们是递归的。
一个递归算法必须包括终止条件和递归部分。
3.5队列的表示和操作的实现
3.5.2循环队列——队列的顺序表示和实现
附设两个整型变量front和rear分别指示队列头元素及队列尾元素的位置。
约定:初始化创建空队列时,令front=rear=0,每当插入新的队列尾元素时,尾指针rear增1;每当删除队列头元素时,头指针front增1。因此,在非空队列中,头指针始终指向队列头元素,而尾指针始终指向队列尾元素的下一个位置。
为解决上述约定的“假溢出”问题,将顺序队列变为一个环状的空间,称之为循环队列。头、尾指针 “依环状增1”的操作可用“模”运算来实现。
循环队列不能以头、尾指针的值是否相同来判断队列空间是“满”还是“空”,如何区分队满还是队空,通常有以下两种处理方法:
(1)少用一个元素空间,即队列空间大小为m时,有m-1个元素就认为是队满;当头、尾指针的值相同时,则认为队空。
队空的条件:Q.front==Q.rear
队满的条件:(Q.rear+1)%MAXSIZE==Q.front
(2)另设一个标志位以区别队列是“空”还是“满”。
1.循环队列的初始化
Status InitQueue(SqQueue &Q)
{
Q.base=new QElemType[MAXSIZE];
if(!Q.base) exit(OVERFLOW);
Q.front=Q.rear=0;
return OK;
}
2.求循环队列的长度
int QueueLength(SqQueue Q)
{
return (Q.rear-Q.front+MAXSIZE)%MAXSIZE;
}
3.循环队列的入队
Status EnQueue(SqQueue &Q,QElemType e)
{
if((Q.rear+1)%MAXSIZE==Q.front) //队满
return ERROR;
Q.base[Q.rear]=e; //e入队尾
Q.rear=(Q.rear+1)%MAXSIZE; //队尾指针加1
return OK;
}
4.循环队列的出队
Status DeQueue(SqQueue &Q,QElemType &e)
{
if(Q.front==Q.rear) return ERROR; //队空
e=Q.base[Q.front]; //取队头元素
Q.front=(Q.front+1)%MAXSIZE; //队头指针加1
return OK;
}
5.取循环队列的队头元素
SElemType GetHead(SqQueue Q)
{
if(Q.front!=Q.rear) //队非空
return Q.base[Q.front]; //取队头元素的值,队头指针不变
}
3.5.3链队——队列的链式表示和实现
一个链队需要两个分别指向队头和队尾的指针才能唯一确定,给链队天骄一个头结点,并令头指针始终指向头结点。
1.链队的初始化
Status InitQueue(LinkQueue &Q)
{
Q.front=Q.rear=new QNode; //队头和队尾的指针指向头结点
Q.front->next=NULL;
return OK;
}
2.链队的入队
Status EnQueue(LinkQueue &Q,QElemType e)
{
p=new QNode;
p->data=e;
p->next=NULL;
Q.rear->next=p; //新节点插到队尾
Q.rear=p; //修改队尾指针
return OK:
}
3.链队的出队
Status DeQueue(LinkQueue &Q,QElemType &e)
{
if(Q.front==Q.rear) return ERROR; //队空
p=Q.front->next;
e=p->data; //取队头元素
Q.front->next=p->next; //修改头结点的指针域
if(Q.rear==p) Q.rear=Q.front; //最后一个元素被删,队尾指针指向头结点
delete p;
return OK;
}
4.取链队的队头元素
SElemType GetHead(LinkQueue Q)
{
if(Q.front!=Q.rear) //队非空
return Q.front->next->data; //返回队头元素,队头指针不变
}
4.1串的定义
串(或字符串)是由零个或多个字符组成的有限序列。一般记为
s=“a1a2…an” (n>=0)
其中,s是串的名,用双引号括起来的字符序列是串的值;ai(1<=i<=n)(单字符)可以是字母、数字或其他字符;串中字符的数目n称为串的长度。零个字符的串称为空串,其长度为零。
串中任意个连续的字符组成的子序列称为该串的子串。包含子串的串相应地称为主串。子串在主串中的位置以子串的第一个字符在主串中的位置来表示。
称两个串是相等的,当且仅当这两个串的值相等。只有当两个串的长度相等,并且各个对应位置的字符都相等时才相等。
4.3串的类型定义、存储结构及其运算
4.3.2串的存储结构(顺序串,堆串,块链串)
1.串的顺序存储
#define MAXLEN 255 //串的最大长度
typedef struct{
char ch[MAXLEN+1]; //存储串的一维数组
int length; //串的当前长度
}SString;
2.串的堆式顺序存储
typedef struct{
char *ch; //若是非空串,则按串长分配存储区,否则ch为NULL
int length; //串的当前长度
}HString;
3.串的链式存储
#define CHUNKSIZE 80 //可由用户定义的块大小
typedef struct Chunk{
char ch[CHUNKSIZE];
struct Chunk *next;
}Chunk;
typedef struct{
Chunk *head,*tail; //串的头和尾指针
int length; //串的当前长度
}LString;
存储密度小,运算处理方便,然而存储占用量大。
4.3.3串的模式匹配算法
子串的定位运算通常称为串的模式匹配或串匹配。
1.BF算法
int Index_BF(SString S,SString T,int pos)
{ //返回子串T在主串S中从第pos个字符开始第一次出现的位置
i=pos;
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,最好的情况下匹配成功的平均比较次数为(n+m)/2,时间复杂度为O(n+m);最坏的情况下匹配成功的平均比较次数为m*(n-m+2)/2,时间复杂度为O(n*m)。
2.KMP算法
int Index_KMP(SString S,SString T,int pos)
{ //利用模式串T的next函数求T在主串S中从第pos个字符之后的位置
i=pos;
j=1;
while(i<=S.length&&j<=T.length)
{
if(j==0&&S.ch[i]==T.ch[j]) {++i;++j;}
else {j=next[j]};
}
if(j>T.length) return i-T.length;
else return 0;
}
计算next函数值:
void get_next(SString T,int next[])
{
i=1;
next[1]=0;
j=0;
while(i<T.length)
{
if(j==0||T.ch[i]==T.ch[j]) {++i;++j;next[i]=j;}
else j=next[j];
}
}
计算next函数修正值:
void get_nextval(SString T,int nextval[])
{
i=1;
nextval[1]=0;
j=0;
while(i<T.length)
{
if(j==0||T.ch[i]==T.ch[j])
{
++i;
++j;
if(T.ch[i]!=T.ch[j]) nextval[i]=j;
else nextval[i]=nextval[j];
}
else j=nextval[j];
}
}
next数组计算:
next[1]=-1:next[i]为模式串第i-1个字符前与前缀匹配的字符串长度。
Next[1]=0:Next[i]=next[i]+1。
nextval数组计算:
nextval[1]=-1/0。
当T.ch[i]==T.ch[next[i]]时,nextval[i]=nextval[next[i]];
当T.ch[i]!=T.ch[next[i]]时,nextval[i]= next[i]。
4.4数组
数组时由类型相同的数据元素构成的有序集合。数组是线性表的原因:ai是单元素或者ai是带有结构信息的元素。
4.4.2数组的顺序存储
采用顺序存储结构表示数组比较合适。
对二维数组可有两种存储方式:一种是以列序为主序的存储方式;一种是以行序为主序的存储方式。
以行序为主序的存储结构:每个数据元素占L个存储单元,二维数组A[0…m-1,0…n-1]中任一元素aij的存储位置LOC(i,j)=LOC(0,0)+(n*i+j)L。
n维数组A[0…b1-1,0…b2-1,…,0…bn-1]的数据元素存储位置的计算公式:LOC(j1,j2,…,jn)=LOC(0,0,…,0)+(b2*…bnj1+b3*…bnj2+…+bn*jn-1+jn)L。
4.4.3特殊矩阵的压缩存储
1.对称矩阵(aij=aji)
以一维数组sa[n(n+1)/2]作为n阶对称矩阵A的存储结构,则sa[k]和矩阵元aij之间存在一一对应的关系:
2.三角矩阵
(1)上三角矩阵
sa[k]和矩阵元aij之间的对应关系为:
(2)下三角矩阵
sa[k]和矩阵元aij之间的对应关系为:
3.对称矩阵(带状矩阵)
按某个原则(或以行为主,或以对角线的顺序)将其压缩存储到一维数组上。
4.稀疏矩阵
其非零元较零元少,且分布没有一定规律,称之为稀疏矩阵。(三元组表示法或十字链表)
4.5广义表
4.5.1广义表的定义
广义表是线性表的推广,也成为列表。一般记作LS=(a1,a2,…,an).
其中,LS是广义表的名称,n是其长度。线性表中ai只限于是单个元素。而在广义表的定义中,ai可以是单个元素,也可以是广义表,分别称为广义表LS的原子和子表。用大写字母表示广义表的名称,用小写字母表示原子。
广义表的定义是一个递归的定义。
A=()——A是一个空表,其长度为零
B=(e)——B只有一个原子e,其长度为1
C=(a,(b,c,d))——C的长度为2,两个元素分别为原子a和子表(b,c,d)
D=(A,B,C)——D的长度为3,3个元素都是广义表,D=((),(e), (a,(b,c,d)))
E=(a,E)——这是一个递归的表
广义表的运算:
(1)取表头GetHead(LS):取出的表头为非空广义表的第一个元素,可以是一个单原子,也可以是一个子表。
(2)取表尾GetTail(LS):取出的表尾为除去表头之外,由其余元素构成的表。即表尾一定是一个广义表。
4.5.2广义表的存储结构
1.头尾链表的存储结构
表结点:
tag=1 | hp | tp |
---|
原子结点:
tag=0 | atom |
---|
(1)除空表的表头指针为空外,对任意非空广义表,其表头指针均指向一个表结点,且该结点中hp域指示广义表表头(或为原子结点,或为表结点),tp域指向广义表表尾(除非表尾为空,则指针为空,否则必为表结点)。
(2)容易分清列表中原子和子表所在层次。
(3)最高层的表结点个数即为广义表的长度。
2.扩展线性链表的存储结构
表结点:
tag=1 | hp | tp |
---|
原子结点:
tag=0 | atom | tp |
---|
(1)tp指向同层的下一个
广义表的长度:n
广义表的深度:最多的括号层数
树结构是一类重要的非线性数据结构(1:m,一对多的结构),树是以分支关系定义的层次结构。
5.1树和二叉树的定义
5.1.1树的定义
树是n(n>=0)个结点的有限集,它或为空树(n=0);或为非空树,对于非空树T:
(1)有且仅有一个称之为根的结点(没有直接前驱);
(2)除根结点以外的其余结点可分为m(m>0)个互不相交的有限集T1,T2,…,Tm,其中每一个集合本身又是一棵树,并且称为根的子树。
树的图解表示法:倒置树结构(树形表示法),文氏图表示法(嵌套集合形式),广义表形式(嵌套括号表示法),凹入表示法。
5.1.2树的基本术语
(1)结点:树中的一个单独单位。包含一个数据元素及若干指向其子树的分支。
(2)结点的度:结点拥有的子树数称为结点的度。
(3)树的度:树的度是树内各结点度的最大值。
(4)叶子:度为0的结点称为叶子或终端结点。
(5)非终端结点:度不为0的结点称为非终端结点或分支结点。
(6)双亲和孩子:结点的子树的根称为该结点的孩子,该结点称为孩子的双亲。
(7)兄弟:同一个双亲的孩子之间互称兄弟。
(8)祖先:从根到该结点所经分支上的所有结点。
(9)子孙:以某结点为根的子树中的任一结点都称为该结点的子孙。
(10)层次:结点的层次从根开始定义起,根为第一层,根的孩子为第二层。
(11)堂兄弟:双亲在同一层的结点互为堂兄弟。
(12)树的深度:树中结点的最大层次称为树的深度或高度。
(13)有序树和无序树:如果将树中结点的各子树看成从左至右是有次序的(即不能互换),则称该树为有序树,否则称为无序树。
(14)森林:是m(m>=0)棵互不相交的树的集合。
5.1.3二叉树的定义
二叉树是n(n>=0)个结点所构成的集合,它或为空树(n=0);或为非空树,对于非空树T:
(1)有且仅有一个称之为根的结点;
(2)除根结点以外的其余结点分为两个互不相交的子集T1和T2,分别称为T的左子树和右子树,且T1和T2本身又都是二叉树。
二叉树与树一样具有递归性质,二叉树与树的区别主要有以下两点:
(1)二叉树每个结点至多只有两棵子树(即二叉树中不存在度大于2的结点);
(2)二叉树的子树有左右之分,其次序不能任意颠倒。
5.4二叉树的性质和存储结构
5.4.1二叉树的性质
性质1:在二叉树的第i层上至多有2i-1个结点(i>=1)。
性质2:深度为k的二叉树至多有2k-1个结点(k>=1)。
性质3:对任何一棵二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。
满二叉树:深度为k且含有2k-1个结点的二叉树。
满二叉树特点:每一层上的结点数都是最大结点数,即每一层i的结点数都具有最大值2i-1。
完全二叉树:深度为k,有n个结点的二叉树,当且仅当其每一个结点都与深度为k的满二叉树中编号从1至n的结点一一对应时,称之为完全二叉树。
完全二叉树的特点:
(1)叶子结点只可能在层次最大的两层上出现。
(2)对任一结点,若其右分支下的子孙的最大层次为l,则其左分支下的子孙的最大层次必为l或l+1.
性质4:具有n个结点的完全二叉树的深度为⌊log2n⌋+1。
性质5:如果对一棵有n个结点的完全二叉树(其深度为⌊log2n⌋+1)的结点按层序编号(从第1层到第⌊log2n⌋+1层,每层从左到右),则对任一结点i(1<=i<=n),有
(1)如果i=1,则结点i是二叉树的根,无双亲,如果i>1,则其双亲parent(i)是结点⌊i/2⌋。
(2)结点i的左孩子lchild是结点2i,如果2i>n,则结点i无左孩子(结点i为叶子结点)。
(3)结点i的右孩子rchild是结点2i+1,如果2i+1>n,则结点i无右孩子。
5.4.2二叉树的存储结构
1.顺序存储结构
#define MAXSIZE 100 //二叉树的最大结点数
typedef TElemType SqBiTree[MAXSIIZE]; //0号单元存储根节点
SqBiTree bt;
2.链式存储结构
typedef struct BiTNode{
TElemType data; //结点数据域
struct BiTNode *lchild,*rchild; //左右孩子指针
}BiTNode,*BiTree;
5.5遍历二叉树和线索二叉树
5.5.1遍历二叉树
遍历二叉树是指按某条搜索路径巡防树中每个结点,使得每个结点均被访问一次,而且仅被访问一次(统计叶子结点)。
若限定先左后右,则有三种情况,分别称之为先(根)序遍历、中(根)序遍历和后(根)序遍历。
表达式的前缀表示(波兰式)、中缀表示和后缀表示(逆波兰式)。计算:波兰式从左到右扫描到第一个运算符号(#)后跟两个数据(AB),计算A#B替代#AB,再从左到右扫描;逆波兰式计算从右到左扫描。
中序遍历的递归算法:
void InOrderTraverse(BiTree T)
{
if(T) //若二叉树非空
{
InOrderTraverse(T->lchild); //中序遍历左子树
cout<<T->data; //访问根节点
InOrderTraverse(T->rchild); //中序遍历右子树
}
}
中序遍历的非递归算法:
void InOrderTraverse(BiTree T)
{
InitStack(S);
p=T;
q=new BiiTNode;
while(p||!StackEmpty(S))
{
if(p) //p非空
{
Push(S,p); //根指针进栈
p=p->lchild; //遍历左子树
}
else //p为空
{
Pop(S,q); //退栈
cout<<q->data; //访问根结点
p=q->rchild; //遍历右子树
}
}
}
二叉树遍历算法的应用:
先序遍历的顺序建立二叉链表:
void CreateBiTree(BiTree &T)
{
cin>>ch;
if(ch==’#’) T=NULL; //递归结束,建立空树
else //递归创建二叉树
{
T=new BiTNode; //生成根节点
T->data=ch; //根节点数据域置为ch
CreateBiTree(T->lchild); //递归创建左子树
CreateBiTree(T->rchild); //递归创建右子树
}
}
复制二叉树:
void Copy(BiTree T,BiTree &NewT)
{
if(T==NULL) //如果是空树,递归结束
{
NewT=NULL;
return ;
}
else
{
NewT=new BiTNode;
NewT->data=T->data; //复制根节点
Copy(T->lchild,NewT->lchild); //递归复制左子树
Copy(T->rchild,NewT->rchild); //递归复制右子树
}
}
计算二叉树的深度:
int Depth(BiTree T)
{
if(T==NULL) return 0; //空树深度为0
else
{
m=Depth(T->lchild); //递归计算左子树的深度为m
n=Depth(T->rchild); //递归计算右子树的深度为n
if(m>n) return(m+1); //二叉树的深度为m与n的较大者加1
else return(n+1);
}
}
统计二叉树中结点的个数:
int NodeCount(BiTree T)
{
if(T==NULL) return 0; //空树,结点个数为0
else return NodeCount(T->lchild)+NodeCount(T->rchild)+1;
//否则结点个数为左子树结点个数+右子树结点个数+1(本结点)
}
5.5.2线索二叉树
线索二叉树的结点形式:
lchild | LTag | data | RTag | rchild |
---|
typedef struct BiThrNode
{
TElemType data;
struct BiThrNode *lchild,*rchild; //左右孩子指针
int LTag,RTag; //左右标志
}BiThrNode,*BiThrTree;
以这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表,其中指向结点前驱和后继的指针,叫做线索。加上线索的二叉树称之为线索二叉树。
引入二叉线索树的目的是加快查找结点前驱或后继的速度。
构造线索二叉树:
线索二叉树构造的实质是将二叉链表中的空指针改为指向前驱或后继的线索。
对二叉树按不同的遍历次序进行线索化,可以得到不同的线索二叉树,包括先序线索二叉树、中序线索二叉树和后序线索二叉树。
画线索二叉树:线索指针用虚线画出,指向前驱或后继。
5.6树和森林
5.6.1树的存储结构
1.双亲表示法
以一组连续的存储单元存储树的结点,每个结点除了数据域data外,还附设一个parent域用以指示其双亲结点的位置。
很容易求树的根,但求结点的孩子时需要遍历整个结构。
2.孩子表示法
用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根节点。
3.孩子兄弟法
又称二叉树表示法,或二叉链表表示法,即以二叉链表做树的存储结构。链表中结点的两个链域分别指向该结点的第一个孩子结点和下一个兄弟结点。
5.6.2森林与二叉树的转换
1.树转换成二叉树
(1)树中所有相邻兄弟之间架一条连线
(2)对树中的每个结点,只保留其与第一个孩子结点之间的连线,删去与其他孩子结点的连线
(3)以树的根结点为轴心,整理
2.二叉树还原成树或森林
(1)若某结点是其双亲的左孩子,则把该结点的右孩子,右孩子的右孩子…都与该结点的双亲结点用线连起来
(2)删掉原二叉树中所有双亲结点与右孩子结点的连线
3.森林转换成二叉树
(1)森林中的每棵树转换成二叉树(根T1,T2,T3…)
(2)T2为T1的右孩子,T3为T2的右孩子…
树的孩子兄弟表示法=二叉树的链表表示法
5.6.3树和森林的遍历
1.树的遍历
一种是先根(次序)遍历树,即:先访问树的根节点,然后依次先根遍历根的每棵子树;
一种是后根(次序)遍历,即:先依次后跟遍历每棵子树,然后访问根结点。
2.森林的遍历
(1)先序遍历森林(根-子森林-余森林)
访问森林中第一棵树的根结点;先序遍历第一棵树的根结点的子树森林;先序遍历除去第一棵树之后剩余的树构成的森林。
(2)中序遍历森林(子森林-根-余森林)
中序遍历第一棵树的根结点的子树森林;访问第一棵树的根结点;中序遍历除去第一棵树之后剩余的树构成的森林。
(3)后序遍历森林(子森林-余森林-根)
5.7哈夫曼树及其应用
5.7.1哈夫曼树的基本概念
哈夫曼树又称最优树,是一类带权路径长度最短的树。
(1)路径:从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径。
(2)路径长度:路径上的分支数目称作路径长度。
(3)树的路径长度:从树根到每一结点的路径长度之和。
(4)权:赋予某个实体的一个量。对于有结点权和边权。如果一棵树中的结点上带有权值,则对应的就有带权树等概念。
(5)结点的带权路径长度:从该结点到树根之间的路径长度与结点上权的乘积。
(6)树的带权路径长度:树中所有叶子结点的带权路径长度之和,记作WPL
(7)哈夫曼树:带权路径长度WPL最小的二叉树称作最优二叉树或哈夫曼树。
在哈夫曼树中,权值越大的结点离根节点越近。
5.7.2哈夫曼树的构造算法
1.哈夫曼树的构造过程
(1)给定的n个权值{w1,w2,…,wn},构造n棵只有根节点的二叉树,这n棵二叉树构成一个森林F。
(2)在森林F中选取两棵根节点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根节点的权值为其左、右子树上根节点的权值之和。
(3)在森林F中删除这两棵树,同时将新得到的二叉树加入F中。
(4)重复(2)和(3),直到F只含有一棵树为止。这棵树便是哈夫曼树。
2.哈夫曼算法的实现
哈夫曼树结点的形式(静态三叉链表):
weight | parent | lchild | rchild |
---|
哈夫曼树中没有度为1的结点,一棵有n个叶子结点的哈夫曼树共有2n-1个结点。可以存储在一个大小为2n-1的一维数组中。
typedef struct{
int weight;
int parent,lchild,rchild;
}HTNode,*HuffmanTree;
构造哈夫曼树:
void CreateHuffmanTree(HuffmanTree &HT,int n)
{
if(n<=1) return ;
m=2*n-1;
HT=new HTNode[m+1];
for(i=1;i<=m;++i)
{HT[i].parent=0;HT[i].lchild=0; HT[i].rchild=0;}
for(i=1;i<=n;++i)
cin>>HT[i].weight; //输入前n个单元叶子节点的权值
for(i=n+1;i<=m;++i)
{
Select(HT,i-1,s1,s2); //返回权值最小的结点s1,s2
HT[s1].parent=i; HT[s2].parent=i; //s1,s2的双亲域改为i
HT[i].lchild=s1;HT[i].rchild=s2; //s1,s2分别作为i的左右孩子
HT[i].weight= HT[s1].weight+ HT[s2].weight; //i的权值为左右孩子权值之和
}
}
5.7.3哈夫曼编码
(1)前缀编码:一个编码方案中,任一个编码都不是其他任何编码的前缀(最左子串),则称编码是前缀编码。
(2)哈夫曼编码:每个左分支赋予0,右分支赋予1,则从根到每个叶子的路径上,各分支的赋值分别构成一个二进制串,该二进制串就称为哈夫曼编码。
哈夫曼编码满足两个性质:
性质1:哈夫曼编码是前缀编码。
性质2:哈夫曼编码是最优前缀编码。
哈夫曼编码的算法实现:
typedef char **HuffmanCode;
void CreatHuffmanCode(HuffmanTree HT,HuffmanCode &HC,int n)
{
HC=new char*[n+1];
cd=new char[n];
cd[n-1]=’\0’;
for(i=1;i<=n;i++)
{
start=n-1;
c=i;f=HT[i].parent;
while(f!=0)
{
--start;
if(HT[f].lchild==c) cd[start]=’0’;
else cd[start]=’1’;
c=f;f=HT[f].parent;
}
HC[i]=new char[n-start];
strcpy(HC[i],&cd[start]);
}
delete cd;
}
图:结点间邻接关系任意(多对多)。
6.1.1图的定义
图G由两个集合V和E组成,记为G=(V,E),其中V是顶点的有穷非空集合,E是V中顶点偶对的有穷集合,这些顶点的偶对称为边。E(G)可以为空集,则图G只有顶点而没有边。
在有向图中,顶点对
无向图,顶点(x,y)是无序的,无向图的顶点对用一对圆括号括起来。
6.1.2图的基本术语
(1)子图:假设有两个图G=(V,E)和G’=(V’,E’),如果V’⊆V且E’⊆E,则称G’为G的子图。
(2)无向完全图和有向完全图:无向图若具有n(n-1)/2条边,则称为无向完全图。有向图若具有n(n-1)条弧,则称为有向完全图。
(3)稀疏图和稠密图:少边少弧(e
(4)权和网:每条边可以标上具有某种含义的数值,该数值称为该边上的权。
这种带权的图通常称为网。
(5)邻接点:对于无向图G,如果图的边(v,v’)∈E,则称顶点v和v’互为邻接点,即v和v’相邻接。边(v,v’)依附于顶点v和v’,或者说边(v,v’)与顶点v和v’相关联。
(6)度、入度和出度:顶点v的度是指和v相关联的边的数目,记为TD(v)。对于有向图,顶点v的度分为入度和出度。入度是以顶点v为头的弧的数目,记为ID(v);出度是以顶点v为尾的弧的数目,记为OD(v);顶点v的度为TD(v)=ID(v)+OD(v)。
(7)路径和路径长度:无向图G中,从顶点v到顶点v’的路径是一个顶点序列(v=vi,0,vi,1,…,vi,m=v’),其中(vi,j-1,vi,j) ∈E,1<=j<=m。若G是有向图,则路径也是有向的,顶点序列满足
(8)回路或环:第一个顶点和最后一个顶点相同的路径称为回路或环。
(9)简单路径、简单回路或简单环:序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点外,其余顶点不重复出现的回路,称为简单回路或简单环。
(10)连通、连通图和连通分量:在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点都是连通的,则称G是连通图。所谓连通分量,指的是无向图中的极大连通子图。
(11)强连通图和强连通分量:在有向图G中,如果对于每一对vi,vj∈V,vi≠vj,从vi到vj和从vj到vi都存在路径,则称G是强连通图(至少n条边)。有向图中的极大强连通子图称作有向图的强连通分量。
(12)连通图的生成树:一个极小连通子图,它包含有图中全部顶点,但只有足以构成一棵树的n-1条边,这样的连通子图称为连通图的生成树。
(13)有向树和生成森林:有一个顶点的入度为0,其余顶点的入度均为1的有向图称为有向树。一个有向树的生成森林是由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
6.4图的存储结构
6.4.1邻接矩阵
邻接矩阵是表示顶点之间相邻关系的矩阵。
设G(V,E)是具有n个顶点的图,则G的邻接矩阵是具有如下性质的n阶方阵:
若G是网,则邻接矩阵可以定义为:
用邻接矩阵表示法表示图,除了一个用于存储邻接矩阵的二维数组外,还需要用一个一维数组来存储顶点信息。
对于无向图,邻接矩阵第i行元素之和就是顶点i的度。
对于有向图,第i行元素之和就是顶点i的出度,第i列元素之和就是顶点i的入度。
6.4.2邻接表
邻接表是图的一种链式存储结构(只存储图中有关联的边的信息)。在邻接表中,对图中每个顶点vi建立一个单链表,把与vi相邻接的顶点放在这个链表中。这样的邻接表由两部分组成:表头结点和边表。
(1)表头结点表:每个顶点信息与其边链表的头指针构成。表头结点包括数据域和链域。数据域用于存储vi的名称或其他有关信息,链域用于指向链表中第一个结点。
(2)边表:由表示图中顶点间关系的(有向图n个/无向图2n个)边链表组成。边链表中边结点包括邻接点域、数据域和链域。邻接点域指示与顶点vi邻接的点在图中的位置
表头结点:
data | firstarc |
---|
边结点:
adjvex | info | nextarc |
---|
有向图的逆邻接表,即对每个顶点vi建立一个链接所有进入vi的边的表(入度表)。
有向图邻接表,无向图邻接表,有向图逆邻接表:
6.4.3十字链表
十字链表是有向图的另一种链式存储结构。可以看成是将有向图的邻接表和逆邻接表结合起来得到的一种链表。
6.4.4邻接多重表
邻接多重表是无向图的另一种链式存储结构。
6.5图的遍历
图的遍历是从图中某一顶点出发,按照某种方法对图中所有顶点访问且仅访问一次。
为了避免同一顶点被访问多次,设一个辅助数组visited[n](访问标志数组),其初值为false或0,一旦访问了顶点vi,便置visited[i]为true或1。
6.5.1深度优先搜索
深度优先搜索(DFS)遍历类似于树的先序遍历(借助于栈结构实现(递归))。过程:
(1)从图中某个顶点v出发,访问v
(2)找出刚访问过的顶点的第一个未被访问的邻接点,访问该结点。以该结点为新结点,重复此步骤,直至刚访问过的顶点没有未被访问的邻接点为止。
(3)返回前一个访问过且仍有未被访问的邻接点的顶点,找出该顶点的下一个未被访问的邻接点,访问该顶点。
(4)重复(2)和(3),直至图中所有顶点都被访问过,搜索结束。
深度优先搜索遍历连通图:
bool visited[MVNum];
void DFS(Graph G,int v)
{
cout<<v;visited[v]=true; //访问第v个顶点
for(w=FirstAdjVex(G,v);w>=0;w=NextAdjVex(G,v,w))
{ //FirstAdjVex(G,v)表示v的第一个邻接点
//NextAdjVex(G,v,w)表示v相对于w的下一个邻接点,w>=0表示存在邻接点
if(!visited[w])
{
Printf(v,w); //打印遍历路径
DFS(G,w); //对v的尚未访问的邻接顶点w递归调用DFS
}
}
}
深度优先搜索遍历非连通图:
void DFSTraverse(Graph G)
{
for(v=0;v<G.vexnum;++v) visited[v]=false; //访问标志数组初始化
for(v=0;v<G.vexnum;++v) //循环调用算法DFS
if(!visited[v]) DFS(G,v); //对尚未访问的顶点调用DFS
}
采用邻接矩阵表示图的深度优先搜索遍历:
void DFS_AM(AMGraph G,int v)
{
cout<<v;visited[v]=true; //访问第v个顶点
for(w=0;w<G.vexnum;w++) //依次检查矩阵v所在的行
if((G.arcs[v][w]!=0)&&(!visited[w]))
DFS_AM(G,w);
//G.arcs[v][w]!=0表示w是v的邻接点,如果w未访问,则递归调DFS_AM
}
采用邻接表表视图的深度优先搜索遍历:
void DFS_AL(ALGraph G,int v)
{
cout<<v;visited[v]=true; //访问第v个顶点
p=G.vertices[v].firstarc; //p指向v的边链表的第一个边结点
while(p!=NULL) //边结点非空
{
w=p->adjvex; //表示w是v的邻接点
if(!visited[w]) DFS_AL(G,w); //如果w未访问,则递归调用DFS_AL
p=p->nextarc; //p指向下一个边结点
}
}
6.5.2广度优先搜索
广度优先搜索(BFS)遍历类似于树的按层次遍历的过程(借助于队列结构实现)。过程:
(1)从图中的某个顶点v出发,访问v。
(2)依次访问v的各个未曾访问过的邻接点。
(3)分别从这些邻接点出发依次访问它们的邻接点,并使“先被访问的顶点的邻接点”先于“后被访问的顶点的邻接点”被访问。重复步骤(3),直至图中所有已被访问的顶点的邻接点都被访问到。
广度优先搜索遍历连通图:
void BFS(Graph G,int v)
{
cout<<v;visited[v]=true; //访问v
InitQueue(Q); //辅助队列Q初始化
EnQueue(Q,v); //v进队
while(!QueueEmpty(Q)) //队列非空
{
DeQueue(Q,u); //队头元素出队并置为u
for(w=FirstAdjVex(G,u);w>=0;w=NextAdjVex(G,u,w))
{ //FirstAdjVex(G,u)表示u的第一个邻接点
//NextAdjVex(G,u,w)表示u相对于w的下一个邻接点,w>=0表示存在邻接点
if(!visited[w])
{
cout<<w;visited[w]=true; //访问w
Printf(u,w); //打印访问路径(生成树结点)
EnQueue(Q,w); //w进队
}
}
}
}
无向图的连通分量:调用搜索过程的次数就是该图连通分量的个数。
连通图:仅调用一次搜索过程。
非连通图:需调用多次搜索过程。
6.6图的应用
6.6.1最小生成树(无向图)
在一个连通网的所有生成树中,各边的代价之和最小的那颗生成树称为该连通网的最小代价生成树,简称为最小生成树。
1.普里姆算法
加点法:
假设N=(V,E)是连通网,TE是N上最小生成树中边的集合。
(1)U={u0}(u0∈V),TE={}。
(2)在所有u∈U,v∈V-U的边(u,v)∈E中找一条权值最小的边(u0,v0)并入集合TE,同时v0并入U。
2.克鲁斯卡尔算法
加边法:
假设连通网N=(V,E),将N中的边按权值从小到大的顺序排列。
(1)初始状态为只有n个顶点而无边的非连通图T=(V,{}),图中每个顶点自成一个连通分量。
(2)在E中选择权值最小的边,若该边依附的顶点落在T中不同的连通分量上(即不形成回路),则将此边加入到T中,否则舍去此边而选择下一条权值最小的边。
(3)重复(2),直至T中所有顶点都在同一连通分量上为止。
6.6.2最短路径
路径上的第一个顶点为源点,最后一个顶点为终点。
两种最常见的最短路径问题:一种是求从某个源点到其余各顶点的最短路径,另一种是求每一对顶点之间的最短路径。
1.从某个源点到其余各顶点的最短路径
迪杰斯特拉算法:
原来v0到vi的最短路径长度为D[i],加入vk之后,以vk作为中间顶点的“中转”路径长度为:D[k]+G.arcs[k][i],若D[k]+G.arcs[k][i] 时间复杂度为O(n2)。 2.每一对顶点之间的最短路径 求解每一对顶点之间的最短路径有两种方法:其一是分别以图中的每个顶点为源点共调用n次迪杰斯特拉算法;其二是采用弗洛伊德算法。时间复杂度为O(n3)。 弗洛伊德算法: if(D[i][k]+D[k][j] 6.6.3拓扑排序 特性:先行关系具有可传递性;拓扑序列不唯一。 1.AOV-网 用顶点表示活动,用弧表示活动间的优先关系的有向图称为顶点表示活动的网,简称AOV-网。 AOV-网中,不应该出现有向环。对给定的AOV-网应首先判定网中是否存在环。检测的办法是对有向图的顶点进行拓扑排序,若网中所有顶点都在它的拓扑有序序列中,则该AOV-网中必定不存在环。 拓扑排序就是将AOV-网中所有顶点排成一个线性序列,该序列满足:若在AOV-网中由顶点vi到顶点vj有一条路径,则在该线性序列中的顶点vi必定在顶点vj之前。 2.拓扑排序的过程 (1)在有向图中选一个无前驱的顶点且输出它。 (2)从图中删除该顶点和所有以它为尾的弧。 (3)重复(1)和(2),直至不存在无前驱的顶点。 (4)若此时输出的顶点数小于有向图中的顶点数,则说明有向图中存在环,否则输出的顶点序列即为一个拓扑排序。 基于邻接矩阵表示的存储结构: A为有向图G的邻接矩阵,则有 (1)找G中无前驱的顶点——在A中找到值全为0的列。 (2)删除以i为起点的所有弧——将矩阵中i对应的行置为全0。 6.6.4关键路径 1.AOE-网 AOE-网,即以边表示活动的网。AOE-网是一个带权的有向无环图,其中,顶点表示事件,弧表示活动,权表示活动持续的时间。 网中只有一个入度为零的点,称为源点,也只有一个出度为零的点,称作汇点。在AOE-网中,一条路径各弧上的权值之和称为该路径的带权路径长度。要估算整项工程完成的最短时间,就是要找一条从源点到汇点的带权路径长度最长的路径,称为关键路径。关键路径上的活动叫做关键活动。 如何确定关键路径,首先定义4个描述量: (1)事件vi的最早发生时间ve(i) ve(i)是从源点到vi的最长路径长度。 (2)事件vi的最迟发生时间vl(i) vi的最迟发生时间:其后继事件vk的最迟发生时间减去活动 (3)活动ai= 活动ai的最早开始时间等于事件vj的最早发生事件ve(j)。 (4)活动ai= 活动ai的最晚开始时间l(i)等于事件vk的最迟发生时间vl(k)减去活动ai的持续时间wj,k 一个活动ai的最迟开始时间l(i)和其最早开始时间e(i)的差值l(i)-e(i)是该活动完成的时间余量。当l(i)-e(i)=0,即l(i)=e(i)时的活动ai是关键活动。