data_struct
图片加载失败时,请下载本地图片文件夹:国内下载
其他补充资料
第一章-绪论
1.数据:
- 数据是对客观事物的符号总称(了解符号学),是计算机中所有能输入到计算机中并被计算机程序处理的符号关系的总称。
- 数据元素:数据元素是数据的基本单位,在计算机中通常考虑做一个整体进行处理。数据项包含于数据元素。
- 数据项:数据项是数据最小的不可分割单位
- 数据对象:性质相同的数据元素的集合,是数据的一个子集。
- 数据结构:相互之间存在一种或多种特定关系的数据元素的集合。数据结构包含三方面:数据的逻辑结构、数据的存储结构和数据的运算。
2.数据的逻辑结构:
- 定义:数据的逻辑结构即数据元素之间的逻辑关系,独立于计算机。
- 线性结构:一对一,唯一直接前驱且唯一直接后继(或不存在)
- 树形结构:一对多,唯一直接前驱(或不存在)且不唯一直接后继
- 图形结构(网状结构):多对多,不唯一直接前驱且不唯一直接后继
3.数据的存储结构:
- 顺序存储方法:该方法就是将逻辑上相邻的点存储在物理上也表示相邻,节点间的相邻关系由存储上的相邻关系体现。一般用数组实现。
- 链接存储方法:该方法不要求逻辑上相邻的点存储在物理上也相邻,节点间的相邻关系由附加的指针字段表示的,得到的存储表示成为链式存储结构。一般借用指针实现 。
- 索引存储方法:该方法通常是在存储节点信息的基础上,还建立附加啊的索引表。索引表中的每一项称作索引项,索引项的一般关键形式是:(关键字,地址)。关键字唯一标识节点,地址做指向节点的指针。
- 散列存储方法:该方法的基本思想是根据关键字直接计算出节点的存储地址。
4.数据的运算
数据的运算是在数据的逻辑结构定义的操作算法,如检索、插入、删除、更新和排序等。
5.数据的类型
- 原子类型:其值不可再分的数据类型,类比数据项记忆
- 结构类型:其值可再分的数据类型,类比数据元素记忆
- 抽象数据类型:抽象数据组织和与之相关的操作。
6.数据的操作
- 插入:在数据结构的指定位置上增添新的数据元素
- 删除:删除数据结构中某位置的数据元素
- 更新:改变某位置上数据元素的值,在概念上等价于删除和原位插入操作
- 查找:在数据结构中查找某个满足特定要求的数据元素的位置或值
- 排序:(在线性结构中)重新安排数据元素之间的逻辑顺序关系,使之按值由小到大或相反次序排列。
(三)算法和算法分析
1.算法
- 算法:算法是对某一问题求解步骤的描述,它是有限序列,每一条指令具有操作层面上的明确性。
- 特点:(1)有穷性;(2)确定型;(3)可行性;(4)输入性:0或多个输入;(5)输出性:0或多个输出。
- 算法设计的要求:(1)正确性;(2)可读性;(3)健壮性;(4)效率与低存储量要求
2.算法效率的度量
-
时间复杂度:一个语句的频度,是指该语句在算法中被重复执行的次数。算法中所有语句的频度之和记做 t(n),它是该算法求解问题规模n的函数。当问题无限大时,t(n)的数量级称为渐近时间复杂度,简称为时间复杂度,记作 t(n)=o(f(n)).
-
空间复杂度:算法的空间复杂度s(n),定义为该算法耗费存储空间,它是问题规模为n的函数。渐进空间复杂度也常常称为空间复杂度,记作s(n)=o(f(n)).
三、典例
例1:设有数据结构line = (D,R),其中
D={01,02,03,04,05,06,07,08,09,10},R={r},
r={<05,01>,<01,03>,<03,08>,<08,02>,<02,07>,<07,04>,<04,06>,<06,09>,<09,10>}
- 解答:线性,原因:定义,一一对应关系
计算时间复杂度
第二章-线性表
一、线性表的定义
线性表(List):零个或多个数据元素的有限序列
二、线性表的抽象数据类型
lnitList(&L);//初始化操作,建立一个空的线性表
istEmpty(L);//若线性表为空,返回true,否则返回false
ClearList(&L);//清空线性表
GetElem(L,i);//按位查找,将线性表L中的第i个位置元素值返回给e
LocateElem(L,e);//按值查找,查找与e相等的元素,若有,返回元素的序号,否则追回0表示失败
Listlnsert(&L,i,e);//在线性表L中的第i个位置插入新元素e
ListDelete(&L,i,&e);//删除线性表L中第i个位置元素,并用e返回其值
ListLength(L);//返回线性表L的元素个数
三、顺序存储定义
线性表的顺序存储定义:用一段地址连续的存储单元依次存储线性表的数据元素
1、顺序存储方式
线性表的每个数据元素的类型都相同,所以可以使用一维数据来实现顺序存储结构,即把第一个数据元素存放在数组下标为0的位置中,然后接着把线性表相邻的元素存放在数组相邻的位置
1、顺序存储的结构代码
#define MAXSIZE 20 /*存储空间初始化分配量,线性表的最大存储容量:数组长度MAXSIZE*/
typedef int Status
typedef int ElemType;
typedef Struct{
ElemType data[MAXSIZE]; /*数组存储数据元素*/
int length; /*线性表当前长度 :length*/
}SqList;
// 描述顺序存储结构的三个属性
#动态存储(仍是顺序存储,物理结构无变化
#define Initsize=100
typedef struct{
ElemType *data;//指示动态分配数组的指针
int MaxSize,length;//数组的最大容量和当前个数
}SeqList;//动态分配数组的类型定义
#C++动态分配语句
L.data=new ElemType(InitSize);
2、顺序存储结构的操作
初始化顺序表
void lnitList(SqList L){
if (L == NULL) {
return;
}
L->length=0;
}//算法的时间复杂度为O(1)
获取元素(按值查找)
int LocateElem(SqList &L,ElemType e){
for(int i=0;i<L.length;i++){
if(L.data[i]==e){
return i+1;
}
}
return 0;
}
//算法的时间复杂度为O(1)
插入操作(插入到第i个位置,位序是i,下标是i-1)
bool ListInsert(SqList &L,int i,ElemType e){
if(i<1||i>L.length+1){ //插入的范围
return false;
}if(L.length>=MaxSize){//当前空间已满
return false;
}
for(int j=L.length;j>=i;j--){//第I个元素及其后面右移一位
L.data[j]=L.data[j-1];
}
L.data[i-1]=e;//插入
L.length++;
return true;
}
//最好情况,表尾插入不执行后移;O(1)
//最坏情况,表头插入,后移N次,O(n)
//平均是O(n)
删除操作
bool ListDelete(SqList &L,int i,ElemType &e){
if(i<1||i>L.length){
return false;
}
e=L.data[i-1];//被删除的元素
for(int j=i;j<L.length;j++){//第i个元素后面的元素左移
L.data[j-1]=L.data[j];
}
L.length--;
return true;
}
//最好情况,表尾删除不执行左移;O(1)
//最坏情况,表头删除,左移N-1次,O(n)
//平均是O(n)
线性表的顺序存储结构在存、读数据的时候,不管是哪个位置,时间复杂度都为0(1);
而插入或删除时,平均时间复杂度都是0(n)
四、顺序存储结构的优缺点和特点
优点:无须为表示表中元素之间的逻辑关系为增加额外的存储空间, 可以快速地存取表中任一位置的元素
缺点:插入和删除操作需要移动大量元素,当线性表长度变化较大时,难以确定空间的容量, “碎片”
五、线性表的链式存储结构定义
为了表示每个数据元素与它的后继数据元素的逻辑关系,对于数据元素a来说,除了存储其本身的信息之外(数据域),还需要存储一个指示其直接后继的存储位置(指针域)。这两部分信息组成数据元素a的存储映像,称为结点(Node)。
n个结点链成一个链表,即为线性表(a1,a2,a3,...,an)的链式存储结构
1、单链表
在每个结点中除包含有数据域外,只设置一个指针域,用以指向其后继结点
头指针:链表中的第一个结点的存储位置叫做头指针
头结点:在单链表的第一个结点前附设一个结点,叫做头结点
/*线性表的单链表存储结构*/
typedef struct LNode{
ElemType data; //数据域
struct Node *next;//指针域
}LNode,*LinkList;
typedef struct Node &LinkList;/*定义LinkList*/
1.1单链表的创建
(1)头插法建立单链表
从一个空表开始,读取数组a的元素,生成新结点,将读取的数据放在新结点的数据域中,然后将新结点插入到当前链表的表头,直到结束为止,
LinkList List_HeadInsert(LinkList &L){
LNode *s;
int x;
L=(LinkList)malloc(sizeof(LNode));//创建头结点
L->next=NULL;//初始为空链表
scanf("%d",&x);
while(x!=9999){
s=(LNode)malloc(sizeof(LNode));//创建新节点
s->data=x;
s->next=L->next;//插入后指针域 为L之前指向的节点
L-next->=s; //L指向新插入的节点
scanf("%d",&x);
}
return L;
}
(2)尾插法建立单链表
从一个空表开始,读取数组a的元素,生成新结点,将读取的数据放在新结点的数据域中,然后将新结点插入到当前链表的尾结点,但是要增加一个尾结点指针r。使他始终指向当前链表的表尾上,直到结束为止
LinkList List_TailInsert(LinkList &L){
LNode *s,*r=L;//r为表尾指针
int x;
L=(LinkList)malloc(sizeof(LNode));//创建头结点
scanf("%d",&x);
while(x!=9999){
s=(LNode)malloc(sizeof(LNode));//创建新节点
s->data=x;
r->next=s;
r=s;//r指向新的表尾结点
scanf("%d",&x);
}
r->next=NULL;//最后表尾节点的指针域为NULL
return L;
}
1.2单链表的读取
获取第i个数据的算法思路
LNode *GetElem(LinkList L,int i){//按序号查找
int j=1;//计数
LNode *p=L->next; //头结点指针赋值给P
if(i==0){ //返回头结点
return L;
}
if(i<1){
return null;
}
while(p&&j<i){
p=p->next;
p++;
}
return p;//返回第i个结点的指针,
}
//按序号查找O(n)
LNode *GetElem(LinkList L,int i){//按值查找
LNode *p=L->next; //头结点指针赋值给P
whIle(p!=null&&p->data!=e)
p=p->next;
return p;
}
//按值查找O(n)
1.3单链表的插入与删除
单链表第i个数据插入结点的算法思路

#尾插(通常采用)
p=GetElem(L,i-1);//查找到插入位置的前驱结点
s->next=p->next;
p->next=s;
#前插 c
单链表第i个数据删除结点的算法思路

#假设*p为找到的被删结点的前驱结点
p=GetElem(L.i-1);
q=p->next;
p->next=q-next;
free(q);
#时间复杂度O(n) 主要浪费在查找上
1.4单链表整表删除
单链表的整表删除算法思路
- 声明一个结点p和q
- 将第一个结点赋值给p
- 循环
-
- 将下一个结点赋值给q;
- 释放p
- 将q赋值给p
Status ClearList(LinkList &L){
LinkList p,q;
p=(&L)->next;
while(p){
q=p->next;
free(p);
p=q;
}
(&L)->next=NULL;
return OK;
}
2、双链表
在每个结点中除包含数值域外,设置有两个指针域,分别用以指向其前驱结点和后继结点(便于快速找到前驱结点,插入删除操作为O(1))
//双链表结点类型DLinkList的声明如下
typedef struct DNode{
ElemType data;
struct DNode *prior; //指向前驱结点
struct DNode *next; //指向后继结点
}DNode,*DLinkList;
#2.1双链表的创建
(1)头插法建立双链表
从一个空表开始,读取数组a的元素,生成新结点,将读取的数据放在新结点的数据域中,然后将新结点插入到当前链表的表头,直到结束为止
void CreateDListF(LinkList *&L,ElemType a[],int n){
DLinkList *s;int i;
L=(DLinkList*)malloc(sizeof(DLinkList));/*创建头结点*/
L->next=L->prior=NULL;
for (i = 0; i < n; i++){
s=(DLinkList*)malloc(sizeof(DLinkList));/*创建新结点*/
s->data=a[i];
s->next=L->next;
if (L->next!=NULL) {
L->next->prior=s;
}
L->next=s;
s->prior=L;
}
}
2.2双链表的基本运算法
(1)查找指定元素的结点
在双链表中查找第一个data域值为X的结点、从第一个结点开始。变遍历边比较。若找到这样的结点。则返回序列。否则返回0
int Finfnode(DLinkList &L,ElemType X){
LinkList *P=l->next;
int i=1;
while(p!=NULL&&p->data!=x){
i++;
p=p->next;
}
if (p==NULL)return 0;
else return i;
}
(2)插入结点操作
s->next=p->next;
p->next-prior=s;
s->prior=p;
p->next=s;
(3)删除结点操作
p->next=q->next; //删除*p结点后的结点
q->next-prior=p;
free(q);
//在双链表L中删除第i个结点
int ListDelete(DLinkList *&L,int i,ElemType e){
DLinkList *p=L,*q;
int j=0;
while(j<i-1 && p!=NULL){ //查找第i-1个元素
j++;
p=p->next;
}
if (p==NULL)return 0; //不存在第i-1个节点
else{} //找到第i-1个节点*p
q=p->next; //q指向要删除的结点
if (q==NULL)return 0; //不存在第i个结点
p->next=q->next; //从链表中删除*q结点
if (p->next!=NULL)
p->next->prior=p;
free(q); //释放*q结点
return 1;
}
}
3、循环双链表
特殊点:头结点prior指向表尾结点
循环双链表中,某节点*p为尾结点市,p-->next=L;
当为空表时,头结点的prior和next域都等于L
4、静态链表
借助数组描述线性表的链式存储结构,结点也有数据与data和指针域next,但是不同链表指针的是,这里的指针是结点的相对地址(下标)
静态链表需要预先分配一块连续的内存空间
静态链表以next=-1为结束标志,其插入删除与动态链表相同,只需要更改指针。
六、顺序表和链表的比较
1.存储(读写)方式
顺序表可顺序可随机,链表需要顺序存取(从表头顺序读取)
2.逻辑和物理结构
顺序存储--一一对应
链式存储--逻辑关系通过指针链接
3.查找,插入,删除
3.1按值查找
顺序表无序时,两者均为O(n),顺序表有序时可以折半查找为O(log2n)
3.2按序查找
顺序表为O(1)(随机下标访问),链表平均为O(N),
顺序表差 插入,删除平均移动一半的表长元素,链表只需更改指针
4.空间分配
顺序存储在静态存储中,易发生溢出,因此需要分配足够大的空间
顺序存储在动态分配时,虽然可以扩充但是需要移动大量元素,
链式存储只需要在申请分配,操作灵活
第三章-栈和队列
一、栈的基本概念
1、栈的定义:栈是一种只能在一端进行插入或删除操作的线性表。表中允许进行插入、删除操作的一端称为栈顶。栈顶的位置是动态的,栈顶的当前位置由一个称为栈顶指针的位置指示器来指示,栈的另一端称为栈底,当栈中没有数据元素的时候称为空栈。栈的插入称为入栈或者进栈。栈的删除称为出栈或者退栈。
2、栈的特点:主要特点为“先进后出”,及后进栈的数据先出战。栈也称为后进先出表
3、栈的基本运算
InitStack(&st); //初始化栈,构造有个空栈st
StackEmpty(st); //判断栈是否为空,如果st栈为空则返回为真,否则为假
Push(&st,x); //进栈,将元素X插入到栈st中作为栈顶元素
Pop(&st,&x); //出栈,从栈st中退出栈顶元素,并将其赋值给x
GetTop(st,&x); //取栈顶元素。返回当前栈顶元素,并将其赋值给x
4、数学性质
n个不同元素进站,出战元素不同排列的个数为1/n+1 *C n 2n(卡特兰数)
5、栈的存储结构:顺序结构和链表结构
二、顺序栈的操作
顺序栈的定义
采用顺序存储结构存储栈,即分配一块连续的存储区域存放栈中元素,并用一个元素指向栈顶
定义栈类型SqStack
typedef struct{
ElemType data[MaxSize];
int top;
}SqStack;
//栈空:st.top==-1;
//栈满:st.top==MaxSize-1;
//栈长:st.top+1
//元素x进栈操作:st.top++; st.data[st.top]=x;
//出栈x操作:x=st.data[st.top]; st.top--;
顺序栈的基本运算
(1)初始化栈:将栈顶指针置为-1
void InitStack(SqStack &s){
s.top=-1;
}
(2)判断
bool StackEmpty(SqStack &s){
if(s.top==-1)
return true;
else
return false;
}
(3)进栈
bool Push(SqStack &s,ElemType x){
if(s.top==MaxSize-1){
return false;
}
s.data[++s.top]=x;
return true;
}
(4)出栈
bool Pop(SqStack &s,ElemType &x){
if(s.top==-1){
return false;
}
x=s.data[s.top--];
return true;
}
(5)读栈顶元素(不出战)
bool Pop(SqStack &s,ElemType &x){
if(s.top==-1){
return false;
}
x=s.data[s.top];
return true;
}
三、共享栈
顺序栈采用数组存放栈中的元素,如果需要用到两个相同类型的栈,这时若为它们各自开辟一个数组空间,但容易出现第一个栈满了,在进制就溢出了,而另一个还有很多的存储空间空闲,这时可以将两个栈合起来,用一个数组实现这两个栈,称为:共享栈
一个大小为MaxSize的数组,有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0,另一个栈的栈底为数组的末端,即下标为MaxSize-1,增加元素时,两端点向中间延伸
共享栈的要素
栈空:栈1空:top1=-1,栈2空 top2=MaxSize
栈满:top1==top2-1
元素x进栈操作:进栈1操作:top1++;data[top1]=x; 进栈2操作:top2--;data[top2]=x;
出栈x操作:出栈1操作:x=data[top1];top--; 出栈2操作:x=data[top2];top++;
四、链栈的操作
1.链栈的定义:采用链式存储结构存储栈。链栈的优点在于不存在栈满上溢的情况,所有操作都在单链表的表头操作
链表中数据节点的类型LiStack定义
typedef struct linknode{
ElemType data;
struct linknode *nextl
}LiStack;
//栈空:lst->next==NULL;
//栈满:通常不存在栈满的情况
//元素*p进栈操作:p->next=lst->next;lst->next=p;
//出栈元素x操作:p=lst->next;x=p->data;lst->next=p->next;free(p);
#2、链表的基本运算
(1)、初始化栈:建立一个空栈st,实际上是创建链栈的头节点,并将其next赋值为NULL
void InitStack(LiStack &lst) {
lst=(LiStack *)malloc(sizeof(LiStack));
lst->next=NULL;
}
(2)、判断栈是否为空算法:栈st为空时返回1,否则返回0
int StackEmpty(LiStack lst){
return(lst->next==NULL);
}
(3)、进栈算法:将新节点插入到头节点之后
void Push(LiStack &lst,ElemType x){
LiStack *p;
p=(LiStack *)malloc(sizeof(LiStack));
p->data=x;
p->next=lst->next;
lst->next=p;
}
(4)、出栈算法:在栈不为空的情况下,将头节点的后继数据节点的数据域赋值给x,然后再将其删除
int Pop(LiStack &lst,ElemType &x){
LiStack *p;
if (lst->next==NULL){
return 0;
}
p =lst->next;
x=p->data;
lst->next=p->next;
free(p);
return 1;
}
(5)、取栈顶元素算法:在栈不为空的情况下,将头节点的后继数据节点的数据域赋值给x
int GetTop(LiStack &st,ElemType &x){
LiStack *p;
if (lst->next==NULL){
return 0;
}
p=lst->next;
x=p->data;
lst->next=p->next;
return 1;
}
五、队列
像栈一样,队列(queue)也是一种线性表,它的特性是先进先出,插入在一端,删除在另一端。就像排队一样,刚来的人入队(push)要排在队尾(rear),每次出队(pop)的都是队首(front)的人。如图1,描述了一个队列模型。
和栈一样,队列也有数组实现和链表实现两种,两种实现都能给出快速的O(1)运行时间,区别在于链表实现指针域要占用空间,频繁的new和delete会消耗不少的时间开销,数组实现唯一的缺点是建立时要确定空间大小。
假如一个队列最多只能站10个人,当占满10个人后,第11个人就不能入队,这种情况成为溢出。而如果第一个人出队了,剩下的9个人依然还在原来的位置,队列里空出了一个位置,但第11个人还是不能入队,这种情况成为假溢出。克服假溢出有效的办法是使用循环队列。
循环队列就是把队尾和队首连接起来,形成一个环,队尾的下一个位置就是队首,这样可以有效的防止假溢出现象,但队列的实际容量已然固定。
队列常见基本操作
InitQueue(&Q);//初始化构造控队列Q
QueueEmpty(Q);//判断队列空
EnQueue(&Q,X);//入队
DeQueue(&Q,&x);//出队
GetHead(Q,&x);//读取头元素
//因为是受限的线性表,所以不可以读取栈和队列中间某个元素
循环队列
//队空:front == rear=0;
//进队 Q.front=(Q.front+1)%MaxSize
//出队 Q.rear=(Q.rear+1)%MaxSize
//队长 (Q.rear+MaxSize-Q.front)%MaxSize
//如果入队速度快于出队的速度,则队尾很快赶上首指针,也可能出现队满时Q.front=Q。rear,如何区分?
//1.牺牲一个单元来判断,入队时少用一个单元,约定队头指针在队尾指针的下一个位置为队满标志
//队满:(rear+1)%maxsize == front
//2.增加表示元素个数的数据成员
//队满:Q.size=MaxSize 队空:Q.size==0
//3.类型中增设tag数据成员,区分满还是空,tag为0,若因删除导致front == rear,为队空;若tag为1,若因插入导致front == rear,为队满
六、队列的存储
队列的顺序存储
队列的顺序存储结构通常由一个一维数组和一个记录队列头元素位置的变量front以及一个记录队列尾元素位置的变量rear组成
#define MaxSize <储存数据元素的最大个数>
typedef struct {
ElementType Data[ MaxSize ];
int rear;
int front;
} Queue;
//初始,对空 Q.front==Q.rear==0
//进队,不满时 Q[rear++]=x
//出队,不空时 x=Q[front++]
//队满 不能Q.front==Q.rear可能是假溢出
循环队列的操作
//初始化
void InitQueue(SqQueue &Q){
Q.rear=Q.front=0;
}
//判断
bool isEmpty(SqQueue Q){
if(Q.rear==Q.front) return true;
else return false;
}
//入队
bool EnQueue(SqQueue &Q,ElemType x){
if((Q.rear+1)%MaxSize==Q.front)
return false;
Q.data[Q.rear]=x;
Q.rear=(Q.rear+1)%MaxSize;
return true;
}
//出队
bool DeQueue(SqQueue &Q,ElemType &x){
if(Q.rear=Q.front) return false;
x=Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize;
return true;
}
队列的链式存储

队列的链式存储类型
typedef struct{ //链式队列结点
ElemType data;
struct LinkLNode *next;
}LinkLNode;
typedef struct{//链式队列
LinkLNode *front,*rear; //队列的队头和队尾
}LinkQueue;
//当Q.front=null且Q.rear=null时,队列为空
链式队列的基本操作
//初始化
void InitQueue(LinkQueue &Q){
Q.front=Q.rear=(LinkLNode*)malloc(sizeof(LinkNode));//建立头节点
Q.front->next=null;
}
//判断队列为空
bool isEmpty(LinkQueue Q){
if(Q.front==Q.rear) return true;
else return false;
}
//入队
void EnQueue(LinkQueue &Q,ElemType e){
LinkNode *s=(LinkNode*) malloc(sizeof(LinkNode));
s->data=e;s->next=null;
Q.rear->rear=s; ??
Q.rear=s
}
//出队
bool DeQueue(LinkNode &Q,ElemType &e){
if(Q.front==Q.rear){
return false;
}
LinkNode *p=Q.front->next;
x=p->data;
Q.front->next=p->next;
if(Q.rear==p){//原队列只有一个节点,删除后为空
Q.rear=Q.front
}
free(p);
}
七、双端队列
双端队列有两个端部,首部和尾部,并且项在集合中保持不变。
双端队不同的地方是添加和删除项是非限制性的。可以在前面或后面添加新项;同样,可以从任一端移除现有项。
八、栈和队列的应用
1.在括号匹配的应用
采用的是栈--后进先出的技术,可以匹配最急迫期待消解的括号
中缀表达式A+B*(C-D)-E/F
后缀表达式ABCD-*+EF/-
计算过程:顺序扫描每一项,
1.如果是操作数压入栈中;
2.如果是操作符,连续从栈中取出两个元素,行程运算指令X
3.处理完毕后栈顶即为计算结果
3.栈在递归中的应用
4.队列在层次遍历中的应用
层次遍历时,1.根结点入队列,2.如果队空结束,否则3,3.第一个结点出队,并访问它,有左右孩子则将其入队列,然后返回上一步
5.队列的其他应用
5.1解决了主机与外部设备(打印机)速度不匹配的问题(设置打印数据缓冲区,打印数据依次写入缓冲区,写满后暂停输入,转去打印
5.2CPU的资源竞争
九、特殊矩阵的压缩存储
1.数组的定义
2.数组的存储结构
3.矩阵的压缩存储
压缩存储:多个值相同的元素只分配一个存储空间,对0元素不分配存储空间(节省)
3.1对称矩阵
n阶对称矩阵上三角和下三角元素对应相同,
二维数组存放浪费一般空间,一般只存放下三角(包括主队角)的元素
对称矩阵A[1...N][1...N] 存放在一维数组B[n(n+1)/2]
数组元素总数为n(n+1)/2
以存储下三角区域为例
第一行存储1个元素.......第i-1行存储i-1个元素,第i行存储j-1个元素(不全部存)
元素Aij在数组B 的下标为k=1+2+3+...+(i-1)+j-1 =i(i-1)/2+j-1
3.2三角矩阵
区别于对称矩阵的是:在存储完上(下)三角区域和主对角线以后,需要紧接着存在对角线下(上)方的常量一次
对称矩阵A[1...N][1...N] 存放在一维数组B[n(n+1)/2+1]
数组元素总数为n(n+1)/2+1
以存储上三角矩阵为例
第一行有n个元素,第二行有n-1个元素 ........第i-1行有n-(i-1)+1个元素 第i行有j-(i-1)个元素
元素Aij在数组B 的下标为k=n+(n-1)+...+(n-i+2) +(j-i+1)-1
如果以存储下三角为例如下
3.3三对角矩阵
3.4稀疏矩阵
非零元素很少很少
第四章-树
一、树的基本概念
- 树是n(n>=0)个节点的有限集合。当n=0时,称为空树。
任意非空树满足的条件:- 有且仅有一个根结点。
- 当n>1时,其余节点可以分为m(m>0)个互不相交的有限集合,其中每一个集合本身又是一棵树,称为结点的子树。
1.前驱结点(父结点、双亲结点)和后继结点(子结点、孩子结点):
- 树的根节点没有前驱结点,但其它结点有且只有一个前驱节点。
- 任何一个结点都可以有零个或者多个后继结点。
- n个结点的树只有n-1条边(每个结点都连接一个父节点,但根节点没有)。
2.祖先结点和子孙结点:
- 祖先结点:从根结点到结点E的唯一路径上的任意结点,称为结点E的祖先结点。
- 子孙结点:相对应的,如果A结点是B结点的祖先结点,那么B节点就是A结点的子孙节点。
3.兄弟结点:两个结点的父结点相同。
4.叔叔结点:(红黑树的时候会遇到)该结点的父节点的父节点的子结点。
5.度:
- 度:树中一个结点的孩子的个数。
- 树的度:树中最大度数(任何子节点最多n个)为树的度。
- 分支结点:度大于0的结点(有子结点)。叶子结点:度为0的结点(没有子结点)。
6.结点的层数、高度、深度:
- 结点的层数:层数会从1或者0开始数,要看题目规定。
- 结点的深度:自顶向下逐层累加的,根的深度为0。
- 结点的高度:自底向上逐层累加的,所有树叶的高度为0。
- 树的高度(深度):树的高度和深度等于它最大层数

7.有序树和无序树:
判断:树中任意节点的子结点之间没有顺序关系
交换同一个父结点下的两个兄弟结点子树,前后如果是无序树的话就是同一种树,
8.路径:
- 两个结点之间的路径是由这两个结点之间所经过的结点序列构成的。路径上所经历边的个数即长度
- 树中的分支是有向的,从父结点指向子结点,所以路径一定是自上而下的。
9.森林:n(n>=0)棵互不相交的树的集合。
10.n叉树:n叉树,每个子结点最多n个。
11.子树和子结构:(剑指offer遇到了判断树的子结构)
- 子树:只要包含了一个结点,就得包含这个结点下的所有节点。
- 子结构:包含了一个结点,可以只取左子树或者右子树,或者都不取。
二、树的性质
-
n个结点的树只有n-1条边(每个结点都连接一个父节点,但根节点没有)。
-
树中的结点数=所有结点的度数+1(原理和上面一条一样)。
-
度为m的树中第i层最多有m^(i-1)个节点(每个结点的度都为m情况下考虑)。
-
高度为h的m叉树至多(m^h-1)/(m-1)个节点(每个结点的度都为m情况下考虑,等比数列求和)。
-
具有n个节点的m叉树的最小高度为logm( n(m-1) + 1 ) 向上取整
(除了最后一层结点可能不满,其它层都为满,用上面一条公式求高度,向上取整)。
三、二叉树
二叉树:满足树的定义,但每个结点最多两个子结点,二叉树是一个有序树。(区分左右子结点或者说左右孩子)
容易混淆的概念:
- 二叉树和度为2的有序树:
- 二叉树可以为空树,但度为2的有序树至少有三个结点,以为度为2表示结点中最大的度为2;
- 二叉树中的子结点有左右之分,而度为2的有序树是相对的,也就是只有一个子结点的时候是不存在左右之分。
满二叉树:
- 定义:一颗高度为h的二叉树,且含有2^h-1个结点的二叉树称为满二叉树(每层都填满了且有序)。
- 对于一个满二叉树,给每个结点从上层到下层、从左到右编号,存在着编号规律。
- 父结点编号为i/2取下界,左孩子为2i、右孩子为2i+1。通过这个规律可以用结构体数组来构造二叉树。
完全二叉树:
- 定义:设一个高度为h有n个结点的二叉树,当且仅当每个结点都与高度为h的满二叉树1~n的结点一一对应时,称为完全二叉树。(只有最后一层可以不满,且排放从左到右没有缺失)
- 如果i<n/2的取下界,则该结点i为分支结点,否则为叶子结点。(通过满二叉树中,对于编号为i的结点,父结点编号为i/2,就可以理解了)
- 度为1的结点若存在,则可能有一个,且编号最大的分支结点
二叉排序树:若二叉树非空,任意结点(左子树节点小于该结点,右子树节点大于该结点)
平衡二叉树:树上任意结点的左子树和右子树的深度值差不超过1。
二叉树的性质:
-
非空二叉树上的叶子结点数等于度为2的结点数加1,n0=n2+1 。
(总结点数等于度为1、度为2、度为0的结点数相加n=n0+n1+n2;
总结点数等于度为1、度为2的子节点数再加上根结点n=n1+2*n2+1。两个公式相减就好了。)
-
非空二叉树第k层至多2^(k-1)个结点。
-
高度为h的二叉树至多有2^h-1个结点。(1+2+4+...等比数列)
-
结点i所在的层次为log2i取下界+1 。(通过满二叉树就可以知道每一层的编号大小范围)
-
具有n个(n>0)结点的完全二叉树的高度为log2n+1取下界或者log2(n+1)取上界。(等比数列求和)
-
对于一个满二叉树,给每个结点从上层到下层、从左到右编号,存在着编号规律。对于编号为i的结点,父结点编号为i/2取下界,左孩子为2i、右孩子为2i+1。通过这个编号规律可以用结构体数组来构造二叉树。
二叉树的存储结构
数组
编号规律:对于一个满二叉树,对于编号为i的结点,父结点编号为i/2取下界,左孩子为2i、右孩子为2i+1。
构建思路:采用数组,根据编号规律,让数组下标来充当编号。这样对于某个数组下标为i的值,它的左节点数组下标为2i、右结点下标为2i+1、父结点下标为i/2,但是如果不是满二叉树的话,就会有空间浪费。
依据二叉树的性质,完全二叉树和满二叉树采用顺序存储较为合适,树中结点序号可以为以反映节点之间的逻辑关系,尽可能节省空间,又能利用数组元素下标值确定二叉树中的位置
但一般二叉树,为了能够让下标反映二叉树逻辑间关系,需要添加一些并不存在的空姐点,这样一个高度为h且只有h个结点的树需要2^h-1个存储单元
链表
顺序存储空间利用率低,一般采用链式存储,链表结点至少包括3个域,数据域,左指针域,右指针域;
但是在实际应用中,可能还会设置指向父节点的指针,变为三叉链表的存储结构
左边是顺序存储,右边是链式指针结构
四、二叉树的遍历和线索二叉树
遍历的分类:按照根和左右子树的遍历顺序来分的,无论那种都是先左子树再右子树,只是根节点(这里根结点也适用于每个子树)的顺序不同。
- 先序遍历:先遍历根节点,再遍历左子树,再遍历右子树。
- 中序遍历:先遍历左子树,再遍历根结点,再遍历右子树。
- 后序遍历:先遍历左子树,再遍历右子树,再遍历根结点。
先序遍历
void PreOrder(BiTree T){
if(T!=null){
visit(T);
PreOrder(T->lchild);
PrdOrder(T->rchild);
}
}
/*1.沿着根依次访问并入栈,直至左孩子为空(说明找到可以输出的结点),
2.栈顶元素出栈并访问,判断没有右子树继续执行出栈访问,有右子树则将其右子树执行1*/
void InOrder(BiTree T){
InitStack(S); BiTree p=T;//p是遍历指针
while(p||!isEmpty(S)){//p不为空,或者栈不为空
if(p){//p不为空,访问并入栈
Push(S,p);
visit(p);//看有没有左孩子
p=p->lchild;
}else{//p为空,没有左孩子,取出栈顶,去查找右子树
Pop(S,p);
p=p->rchild;
}
}
}
中序遍历
void InOrder(BiTree T){
if(T!=null){
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
/*1.沿着根找左子树依次入栈,直至左子树为空(说明找到可以输出的结点),
2.栈顶元素出栈并访问,判断没有右子树继续执行出栈访问,有右子树则将其右子树执行1*/
void InOrder(BiTree T){
InitStack(S); BiTree p=T;//p是遍历指针
while(p||!isEmpty(S)){//p不为空,或者栈不为空
if(p){//p不为空,有左孩子
Push(S,p);
p=p->lchild;
}else{//p为空,没有左孩子,取出栈顶,去查找右孩子
Pop(S,p);
visit(p);
p=p->rchild;
}
}
}
后序遍历
void PostOrder(BiTree T){
if(T!=null){
InOrder(T->lchild);
InOrder(T->rchild);
visit(T);
}
}
/*后续遍历非递归最难,要保证左孩子和右孩子都已经被访问,并且左孩子在右孩子之前被访问
思路:1.根节点开始,将其入栈,沿着左子树搜索,直至没有左孩子,但是此时不出栈!
如果存在右子树需要继续进行相同处理,直至上述不能操作*/
当访问到E时,ABD已经入栈,图中D已经出栈,栈内A和B,这是E的全部祖先,
实际上,访问一个节点P时,栈中那个节点恰好全是P结点所有祖先,从栈底到栈顶恰好构成根节点到P 的一条路径,因此常用于“求根到某个节点的陆军,两个结点的最近公共祖先等
层次遍历
void LevelOrder(BiTree T){
InitQueue(Q);
BiTree p;
EnQueue(Q,T);//根节点入队
while(!isEmpty(Q)){//队列不为空,
DeQueue(Q,p);//取出队头
visit(p);
if(p->lchild!=null){
EnQueue(Q,p->lchild);
}
if(p->rchild!=null){
EnQueue(Q,p-rchild);
}
}
}
深度计算
public int treeDepth(TreeNode<T> node){
if (node == null){
return 0;
}
int l = treeDepth(node.LChild) + 1;
int r = treeDepth(node.RChild) + 1;
return l > r ? l : r;
}
//层序遍历计算多少层就好了,树的深度等于层数。用一个nowLever存当前层的结点数,nextLever统计当前层数的左右孩子个数,每遍历一个当前层数结点就让nowLever–,当nowLever结束的时候就说明当前层结束了,让nowLever=nextLever并nextLeve=0,就可以开始下一层了。
public int treeDepthWithoutRecursion(TreeNode<T> node){
Queue<TreeNode<T>> queue = new Queue<TreeNode<T>>();
queue.Enqueue(node);
//下一层的结点数量,层数计算,当前层结点的剩余数量
int nextLever = 0, ans = 0, nowLever = 1;
while (queue.Count != 0){
node = queue.Dequeue();
nowLever--;
if (node.LChild != null){
queue.Enqueue(node.LChild);
nextLever++;
}
if (node.RChild != null){
queue.Enqueue(node.RChild);
nextLever++;
}
//当前层所有结点都遍历完毕
if (nowLever == 0){
ans++;
nowLever = nextLever;
nextLevr = 0;
}
}
return ans;
}
总结
1.三种只是访问根节点的顺序不同,不管哪种每个结点都访问一次并且仅访问一次,时间复杂度O(n)
2.递归工作站的深度就是树的深度
3.由先序+中序 确定唯一的一课二叉树(先序第一个结点就是根节点,中序分割两部分,两部分同样为中序)
4..由后序+中序 确定唯一的一课二叉树
五、线索二叉树
1. 产生背景
现有一棵结点数目为n的二叉树,采用二叉链表的形式存储。
对于每个结点均有指向左右孩子的两个指针域,而结点为n的二叉树一共有n-1条有效分支路径。那么,
则二叉链表中存在
$$
2n-(n-1)=n+1
$$
个空指针域。那么,这些空指针造成了空间浪费。例图所示一棵二叉树一共有10个结点,空指针^有11个。

2. 线索化
现将某结点的空指针域指向该结点的前驱后继,定义规则如下:
若结点的左子树为空,lchild指向其前驱结点。ltag记为1
若结点的右子树为空,rchild指向其后继结点。rtag记为1
这种指向前驱和后继的指针称为线索。将一棵普通二叉树以某种次序遍历,并添加线索的过程称为线索化。
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild *rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
2.1中序线索二叉树的构建
二叉树的线索化---二叉链表中的空指针改为指向前驱或者后继的线索,
而前驱或者后继的线索只有在遍历的时候才能得到,线索化实质:遍历一次二叉树
中序遍历对二叉树线索化的递归算法
void InThread(ThreadTree &p,ThreadTree &pre){
if(p!=null){
InThread(p->lchild,pre);//递归,线索化左子树
if(p->lchild==null){//如果左子树为空,lchild存储前驱结点pre,ltag记为1
p->lchild=pre;
p->ltag=1;
}
if(pre!=null&&pre->rchild==null){//判断pre当前结点,如果右指针为空
pre->rchild=p;//记录右子树为其后继结点
pre->rtag=1;
}
pre=p;//标记当前结点为刚访问过的结点
InThread(p->rchild,pre);//递归,线索化右子树
}
}
//通过中序遍历建立中序线索二叉树的主要过程
void CreateThrea(ThreadTree T){
ThreadTree pre=null;
if(T!=null){
Inthread(T,pre);//非空二叉树线索化
pre->rchild=null;//处理遍历后最后一个结点
pre->rtag=1;
}
}
2.2中序线索二叉树的遍历
中序线索二叉树隐藏了线索二叉树的前驱和后继信息。遍历时,只需要找到序列的第一个节点,然后依次找出后继信息,直至为空。
规律:如果右标志为1,则右链为线索,指示其后继,否则遍历右子树的第一个访问的结点(右子树中最坐下的结点)为后继,不含头结点的线索二叉树遍历算法如下
//求中序线索二叉树 中序序列的第一个结点
ThreadNode *Firstnode(ThreadNode *p){
while(p->ltag==0) p=p->lchild; //最左下面的结点
return p;
}
//求中序线索二叉树 结点p在中序序列的后继
ThreadNode *Firstnode(ThreadNode *p){
if(p->rtag==0) return Firstnode(p->rchild);
else return p->rchild;
}
2.3先序线索二叉树和后序线索二叉树
六、树、森林
6.1树的存储结构
//双亲表示法(每个结点只有一个双亲结点):一组连续空间存储,增设一个位置:存储双亲结点在数组中的位置,根节点下标0,为指针域为-1
#definr Max_tree_size 100
typedef struct{
ElemType data;
int parent;
}PTNode;
typedef struct{
PTNode nodes[Max_tree_size];
int n;//结点数
}PTree;
#//缺点:求结点孩子的时候需要遍历整个
//区别:树的顺序存储结构和二叉树的顺序存储结构
//1.二叉树可以用树的存储结构,但是树却不能都用二叉树的存储结构存储
6.1.2孩子表示法
每个结点的孩子节点都用单链表连接成线性结构,
n个结点的n个孩子链表(叶子的孩子链表为空)
优点:寻找子女方便
缺点:寻找双亲需要遍历n个结点中孩子链表中指针域所指向的n个孩子链表
6.1.3孩子兄弟表示法
二叉链表存储
三部分存储:1.结点值2.指向结点第一个孩子结点的指针3.指向结点的下一个兄弟结点的指针]
优点:灵活,方便实现树和二叉树的转换,易于查找结点的孩子等
缺点:从当前结点查找双亲较为麻烦(为每个节点增设一个parent结点,更方便)
//孩子兄弟表示
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild; *nextsbling;
}
6.2树、二叉树、森林转换
因为二叉树和树都可以用二叉链表作为存储结构,因为二叉链表作为媒介可以作为两者的关系
树—-》二叉树
(1)加线。在所有兄弟结点之间加一条连线。
(2)去线。树中的每个结点,只保留它与第一个孩子结点的连线,删除它与其它孩子结点之间的连线。
(3)层次调整。以树的根节点为轴心,将整棵树顺时针旋转一定角度,使之结构层次分明。(
口诀:兄弟相连,长兄为父,孩子靠左。
核心:左孩子,右兄弟

二叉树---------》树
是树转换为二叉树的逆过程。还原结点A的孩子,结点A的左孩子开始,一直向右走,这些结点就是结点A的孩子,遇见顺序就是它们作为结点A孩子的顺序。
(1)加线。若某结点X的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点…,都作为结点X的孩子。将结点X与这些右孩子结点用线连接起来。
(2)去线。删除原二叉树中所有结点与其右孩子结点的连线。
(3)层次调整。

森林--》二叉树
(1)把每棵树转换为二叉树。
(2)第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树根结点的右子树,用线

二叉树--》森林
假如一棵二叉树的根结点有右孩子,则这棵二叉树能够转换为森林,否则将转换为一棵树。在二叉树种A有右子树上向右的一连串结点都是A的兄弟,那么就把兄弟分离,A的每个兄弟结点作为森林中树的根结点。
(1)从根结点开始,若右孩子存在,则把与右孩子结点的连线删除。再查看分离后的二叉树,若其根结点的右孩子存在,则连线删除…。直到所有这些根结点与右孩子的连线都删除为止。
(2)将每棵分离后的二叉树转换为树

6.3树和森林的遍历
树的遍历
定义:以某种方式访问树中的每一个结点,且仅访问一次。 树的遍历主要有先根遍历和后根遍历。
(1)先根遍历:若树非空,则先访问根结点,再按照从左到右的顺序遍历根结点的每一棵子树。这个访问顺序与这棵树对应的二叉树的先序遍历顺序相同。
(2)后根遍历:若树非空,则按照从左到右的顺序遍历根结点的每一棵子树,之后再访问根结点。其访问顺序与这棵树对应的二叉树的中序遍历顺序相同。

根据以上这幅图有如下结果:
树的先根遍历:A-B-E-F-G-C-H-D-I-J
对应的二叉树的先序遍历:A-B-E-F-G-C-H-D-I-J。由此可知二者是一致的。
树的后根遍历:E-F-G-B-H-C-I-J-D-A
对应的二叉树的后序遍历:G-F-E-H-J-I-D-C-B-A
对应的二叉树的中序遍历:E-F-G-B-H-C-I-J-D-A(与树的后根遍历相一致)
注意到我们并没有定义一般树的中根遍历,因为子结点该怎么分两部分并没有定义,所以只定义先、后根。
森林的遍历
1.前序遍历
前序遍历的定义为:
(1)访问森林中第一棵树的根结点;
(2)前序遍历第一棵树的根结点的子树;
(3)前序遍历去掉第一棵树后的子森林。
2.中序遍历
中序遍历的定义为:
(1)中序遍历第一棵树的根结点的子树;
(2)访问森林中第一棵树的根结点;
(3)中序遍历去掉第一棵树后的子森林。

由上图看看这个森林和二叉树的各种遍历如下:
森林的先根遍历:A-B-C-D-E-F-G-H-J-I
二叉树森林的先序遍历:A-B-C-D-E-F-G-H-J-I(相同)
完整二叉树的先序遍历:A-B-C-D-E-F-G-H-J-I (相同)
森林的后根遍历:B-C-D-A-F-E-J-H-I-G
二叉树森林的后序遍历:D-C-B-A-F-E-J-I-H-G
完整二叉树的后序遍历:D-C-B-F-J-I-H-G-E-A(不同于二叉树森林的后序遍历)
二叉树森林的中序遍历:B-C-D-A-F-E-J-H-I-G(与森林的后根遍历相同)
完整二叉树的中序遍历:B-C-D-A-F-E-J-H-I-G(与森林的后根遍历相同,自然也与二叉树森林的中序遍历相同)
结论:
◆ 树的先序遍历实质上与将树转换成二叉树后对二叉树的先序遍历相同。
◆ 树的后序遍历实质上与将树转换成二叉树后对二叉树的中序遍历相同。
◆ 森林的前序遍历和中序遍历与所转换的二叉树的先序遍历和中序遍历的结果序列相同。
七、树与二叉树的应用
7.1二叉排序(查找)树BST
或是一棵空树;或者是具有如下性质的非空二叉树:
(1)若左子树不为空,左子树的所有结点的值均小于根的值;
(2)若右子树不为空,右子树的所有结点均大于根的值;
(3)它的左右子树也分别为二叉排序树。
对二叉排序树中进行中序遍历,可以的得到一个递增的
7.1.1二叉排序树的查找
BTSNode *BST_Search(BiTree T,ElemType key){
while(T!=null&&key!=T->data){
if(key<T->data) T=T->lchild;
else T=T->rchild;
}
return T;
}
7.1.2二叉排序树的插入
二叉排序树作为动态链表,树的结构通常不是一次生成,而是在查找过程中如果不存在时再插入的
ins BTS_Insert(BiTree &T,KeyType k){
if(T==null){//原树为空,新插入的结点记为根节点,左右子树为空
T=(BiTree)malloc(sizeof(BiTree));
T->key=k;
T->lchild=T->rchild=null;
return 1;
}
else if(k==T->key){//插入元素即为根节点,不再插入
return 0;
}else if(k<T->key){//插入到左子树
return BTS_Insert(T->lchild,k);
}else{//插入到右子树
return BTS_Insert(T->rchild,k);
}
}
7.1.3 二叉排序树的构建
从一棵空树出发, 依次输入元素,插入到二叉排序树的合适位置
void Create_BST(BiTree &T,keyType str{},int n){
T=null;//初始T为空树
int i=0;
while(i<n){
BST_Insert(T,str{i});
i++;
}
}
7.1.4[二叉排序树的删除]二叉排序树的删除 - 寻觅beyond - 开发者的网上家园 (cnblogs.com))
- 如果是叶子结点,直接删除即可
- 如果只有左子树或者右子树,其子树成为删除结点的父结点的子树即可
- 如果左右子树都存在,令删除结点的中序下的直接后继或者(前驱)****代替删除结点,然后从二叉排序中删除此“直接后继或者前驱”,成为第二种情况
7.1.5 二叉排序树的查找效率
主要取决于树的高度
1.如果是只有(左)右孩子的单支树,查找平均长度O(n)
2.如果左右子树的高度之差绝对值不超过1(即平衡二叉树),查找O(log2n);
7.2平衡二叉树
为了避免树增长高度过快,降低二叉排序树的性能,称为AVL树(有别于AVL算法),
性质:它是一 棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。【平衡因子只能是1,-1,0】
平衡二叉树大部分操作和二叉查找树类似,主要不同在于插入删除的时候平衡二叉树的平衡可能被改变,并且只有从那些插入点到根结点的路径上的结点的平衡性可能被改变,因为只有这些结点的子树可能变化。
7.2.1 平衡二叉树的插入
每当插入或者删除时,首先检查其插入的就上的节点是否因为此次操作导致不平衡,如果是,先找到插入路径中---离插入点最近的-----平衡因子绝对值大于1----的结点A,以A为根的子树(最小不平衡树)进行调整
7.3插入的调整规律
1.左子树过高,向右旋转 操作:2为根节点,3作为2的右孩子

2.右子树过高,向左旋转。操作: 将2作为根结点, 将1作为2的左孩子

3.右子树过高,向左旋转。步骤:将3作为根结点,将3的左孩子作为1的右孩子, 将1作为3的左孩子

如上,我们发现,旋转之后树并没有恢复平衡。对比图9,我们发现,根的右子树不一致。在上面的三个例子我们可以看出,我们对不平衡的树进行旋转的时候,不仅需要考虑需要最小失衡子树的根结点的平衡因子,还要考虑根结点较高子树的根结点的平衡因子。如图9与图11中,较高子树都为右子树,右子树不同,旋转后有着完全不同的结果。
为了方便讨论,我们使用连续的两个字母来表示平衡因子,以表示各种不同的情况。
第一个字母表示最小不平衡子树根结点的平衡因子,
第二个字母表示最小不平衡子树较高子树的根结点的平衡因子。
使用L表示左子树较高,R表示右子树较高,E表示左右子树等高。
如上述图11,根为的平衡因子R,较高子树的根为L,我们将这种情况表示为RL型
下面我们将对所有的失衡情况进行讨论。大致分为两大类,一左子树过高,二右子树过高。顺带提一下记忆的方法,读者对于具体某一种类型只要记住最后哪一个结点作为根即可,也就是下面标红色的部分。
【AVL平衡二叉树详解与实现_某熊的全栈之路 - SegmentFault 思否】
RR左旋,右子树右子节点
当新插入的结点为右子树的右子结点时,我们需要进行左旋操作来保证此部分子树继续处于平衡状态。
我们应该找到离新插入的结点最近的一个非平衡结点,来以其为轴进行旋转,下面看一个比较复杂的情况:
LL:右旋,左子树左子节点
当新插入的结点为左子树的左子结点时,我们需要进行右旋操作来保证此部分子树继续处于平衡状态。
下面看一个比较复杂的情况:
LR型:先左旋再右旋,左子树右子节点
举例:
RL:先右旋再左旋,右子树左子节点
平衡二叉树的查找
与二叉排序树相同,比较关键字的个数不超过树的深度
n个结点的平衡二叉树最大深度为O(log2N)
因此平衡二叉树的平均查找长度为O(log2N)
7.4哈夫曼(最优二叉树)
基础
简单了解:什么是哈夫曼树?
例:将学生的百分制成绩转换为五分制成绩:≥90 分: A,80~89分: B,70~79分: C,60~69分: D,<60分: E;如果每次输入量都很大,那么应该考虑程序运行的时间

如果学生的总成绩数据有10000条,则5%的数据需 1 次比较....因此 10000 个数据比较的数为:
$$
10000 (5%+2×15%+3×40%+4×40%)=31500
$$
关于树的基本概念:
路径:若树中存在一个结点序列k1,k2,…,kj,使得ki是ki+1的双亲,则称该结点序列是从k1到kj的一条路径。
路径长度:等于路径上的结点数减1。
结点的权:在许多应用中,常常将树中的结点赋予一个有意义的数,称为该结点的权。
结点的带权路径长度:该结点到树根之间的路径长度与该结点上权的乘积。
树的带权路径长度:树中所有叶子结点的带权路径长度之和,通常记作:
$$
\sum_{i=1}^{n}{w_il_i}
$$
其中,n表示叶子结点的数目,wi和li分别表示叶子结点ki的权值和树根结点到叶子结点ki之间的路径长度。
$$
WPL=71+52+23+43
$$
赫夫曼树(哈夫曼树,huffman树)定义:
在权为w1,w2,…,wn的n个叶子结点的所有二叉树中,带权路径长度WPL最小的二叉树称为赫夫曼树或最优二叉树。
构造
1.根据给定的n个权值{w1,w2,…,wn}构成二叉树集合F={T1,T2,…,Tn},其中每棵二叉树Ti中只有一个带权为wi的根结点,其左右子树为空.
2.在F中选取两棵根结点权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为左右子树根结点的权值之和.
3.在F中删除这两棵树,同时将新的二叉树加入F中.
4.重复2、3,直到F只含有一棵树为止.(得到哈夫曼树)
注意:
1、满二叉树不一定是哈夫曼树
2、哈夫曼树中权越大的叶子离根越近 (很好理解,WPL最小的二叉树)
3、具有相同带权结点的哈夫曼树不惟一
4、哈夫曼树的结点的度数为 0 或 2, 没有度为 1 的结点。
5、包含 n 个叶子结点的哈夫曼树中共有 2n – 1 个结点。
6、包含 n 棵树的森林要经过 n–1 次合并才能形成哈夫曼树,共产生 n–1 个新结点
哈夫曼编码
哈夫曼树的应用很广,哈夫曼编码就是其在电讯通信中的应用之一。广泛地用于数据文件压缩的十分有效的编码方法。其压缩率通常在20%~90%之间。
例:如果传送电文为 ‘ABACCDA’,它只用到四种字符,用两位二进制编码便可分辨。假设 A, B, C, D 的编码分别为 00, 01,10, 11,则上述电文便为 ‘00010010101100’(共 14 位),译码员按两位进行分组译码,便可恢复原来的电文。
实际应用中各字符的出现频度不相同,用短(长)编码表示频率大(小)的字符,使得编码序列的总长度最小,使所需总空间量最少
数据的最小冗余编码问题
在上例中,若假设 A, B, C, D 的编码分别为 0,00,1,01,则电文 ‘ABACCDA’ 便为 ‘000011010’(共 9 位),但此编码存在多义性:可译为: ‘BBCCDA’、‘ABACCDA’''等。
译码的惟一性问题
要求任一字符的编码都不能是另一字符编码的前缀,这种编码称为前缀编码(其实是非前缀码)。 在编码过程要考虑两个问题,数据的最小冗余编码问题,译码的惟一性问题,利用最优二叉树可以很好地解决上述两个问题
用二叉树设计二进制前缀编码
以电文中的字符作为叶子结点构造二叉树。然后将二叉树中结点引向其左孩子的分支标 ‘0’,引向其右孩子的分支标 ‘1’; 每个字符的编码即为从根到每个叶子的路径上得到的 0, 1 序列。如此得到的即为二进制前缀编码。
第五章-图
一、图的基本概念
定义

分类


注意第二个图A->B!=B->A因为它是有方向的,所以属于简单图
完全图

子图
连通(无向图)与强连通(有向图)

连通,连通图,连通分量
在无向图中,两顶点有路径存在,就称为连通的。
若图中任意两顶点都连通,同此图为连通图。无向图中的极大连通子图称为连通分量。
极大连通子图要求:连通子图包含所有的边,极小连通子图:图要连通,且边数最少
强连通图、强连通分量
在有向图中,两顶点两个方向都有路径,两顶点称为强连通。
若任一顶点都是强连通的,称为强连通(图)。
有向图中极大强连通子图为有向图的强连通分量。

结论:
- 如果原图是连通图或者强连通图,那么它的连通分量或者强连通分量都是与原图一样的。
- 如果原图不是连通图或者强连通图,那么它的连通分量或者强连通分量会有许多个
生成树和生成森林
生成树:连通图中包含全部顶点的一个极小连通子图(不唯一)
- 必须要是无向图的连通图
- 连通图包含全部顶点
- 边数最少
如果砍去一条边,就会变成非连通图,如果加上一条边就会形成回路
n个顶点图的生成树有n-1条边

生成森林:非连通图中,所有连通分量的生成树 构成非连通图的生成森林

顶点的度
- 无向图中顶点的度 就是 连接该顶点的边数
- n个顶点,e条边的无向图中度的总数为2e【每条边和两个顶点关联】
- 有向图中顶点的度=出度+入度
- n个顶点,e条边的有向图中 出度的总数为e,入度的总数为e【每条有向边都有1个起点和终点】
网:在图中,每条边都可以加一个权值,这种每个边都有权值的图称为带权图,或网
稠密图和稀疏图:根据边数多少,一般当满足|E|<|V|log|V|可将其视为稀疏图
有向树:一个顶点的入度为0,其他顶点的入度为1的有向树【树的边成为箭头】
有向树跟树的区别是:有向树是图
二、图的存储和基本操作
相对于其他的线性数据结构,图的存储要复杂很多,因为顶点数相同的图,其边(或弧)的数量相差很大。比如一个有n个顶点e条边的图,若是以顶点为结点来存储,由于各个顶点的度数不一致,无法指定结点的指针域中需要的指针数。虽然可以定义结点的指针域存在n-1个指针,但这样存储过于复杂,存储密度过小;若以边为结点来存储,又不便于顶点遍历。所以一般情况下,图需要同时存储顶点和边的信息。
邻接矩阵
用一个一维数组存储图中顶点信息,用一个二维数组存储图中各边的信息,
存储顶点间的邻接关系的二维数组即邻接矩阵
/*
c结点数为n的图G=(V,E)的邻接矩阵A是n*n的,
顶点编号为v1,v2,.......,如果v1到v2之间存在连接,则A[I][J]=1
对于带权图来说,如果v1到v2之间存在连接,则A[I][J]=Wij
*/
#define MaxVertexum 100
typedef char VertexType;//顶点数据类型
typedef int EdgeType;//带权图中权值的数据类型
typedef struct{
VertexType Vex[MaxVertexum[];//顶点表
EdgeType Edge[MaxVertexum][MaxVertexum];//邻接矩阵,边表
int vexnum,arcnum;//当前的顶点数和狐数
}MGraph;
注:1.无向图的邻接矩阵是对称矩阵(且唯一);如果规模大可以采用压缩矩阵存储
2.邻接矩阵的空间复杂度为O(n2),n为顶点数|V|
3.对于无向图,其第i行(列)非零元素的个数为 第i个顶点的度
4.对于有向图,其第i行(列)非零元素的个数为 第i个顶点的入度(出度)
5.邻接矩阵存储便于观察任意两个顶点之间是否存在项链,但是要确定一共的边数,必须要按行按列扫描,时间代价很大
6.了解:设图G的邻接矩阵为A,则A的n次方元素
A"[i][j]
=等于由 顶点i到顶点j的长度为n 的路径 数目
邻接表
当图为稀疏图,使用邻接矩阵很浪费,邻接表是指每个顶点Vi建立一个单链表,第i个单链表存放了 依附于顶点Vi的边(对于有向图就是以顶点Vi为尾的弧),这个单链表就是顶点Vi的边表(对于有向图就是出边表)
边表的头指针和顶点数据采用顺序存储(称为顶点表),所以存在两种结点
顶点表 顶点域和指向第一条邻接边的指针域构成
边表 邻接点域和指向下一条邻接边的指针域构成
邻接表存储的特点
1.图的邻接表并不唯一
2.如果G为无向图,所需存储空间O(|V|+2|E|)
,因为每条边在邻接表出现2次
如果G为有向图,所需存储空间O(|V|+|E|)
3.对于稀疏表,采用邻接表更节省空间
4.邻接表中,给出一个顶点很容易找到所有的邻边,只需要扫描一行花费时间O(n),但是要确定两个顶点是否存在边,需要在相应结点对应的边表查询另一个节点,效率相对较低
5.有向图中,求某一顶点的出度只需要计算邻接表中结点个数,但是在求其顶点的入度,需要遍历整个邻接表
十字链表
有向图 的链式存储结构
在邻接表法里,找到顶点的出边是很容易的,但是找到顶点的入边却要遍历整个所有顶点的边表,很复杂。
但是十字链表里,寻找顶点的出边和入边都很容易
我们重新定义*顶点表结构*
firstin表示入边表头指针,指针用于 以当前顶点为弧头(终点)的其他顶点构成的链表
firstout表示出边表头指针,指针用于 以当前顶点为弧尾(起点)的其他顶点构成的链表
由此可以看出,十字链表实质上就是为每个顶点建立两个链表,分别存储以该顶点为弧头的所有顶点和以该顶点为弧尾的所有顶点。
重新定义了边表结点结构
其中
tailvex是指这条弧 的 起点 的数组下标,
headvex是指这条弧 的 终点 的数组下标
headlink是指入边表指针域,指向终点相同的下一条边【链接下一个存储以首元节点为弧头的节点】
taillink是指出边表指针域,指向起点相同的下一条边【链接下一个存储以首元节点为弧尾的节点】
如果是网,还可以再增加一个weight域来存储权值。
例如v1 v2 v3分别在顶点表的数组下表为0 1 2
注:十字链表不是唯一的,但是一个十字链表可以确定一个图
邻接多重表
无向图的另一种链式存储结构
邻接表中,易求得顶点和各边信息,但是在邻接表中求两个顶点是否存在边以及删除等,需要遍历两个顶点的边表,较为麻烦
注意,邻接多重表仅适用于存储无向图或无向网。
邻接多重表存储无向图的方式,可看作是邻接表和十字链表的结合。同邻接表和十字链表存储图的方法相同,都是独自为图中各顶点建立一张链表,存储各顶点的节点作为各链表的首元节点,同时为了便于管理将各个首元节点存储到一个数组中。各首元节点结构如图 1 所示:
其中:data:存储此顶点的数据;firstedge:指针域,用于指向同该顶点有直接关联的存储其他顶点的节点。
其中:mark:标志域,用于标记此节点是否被操作过,例如在对图中顶点做遍历操作时,为了防止多次操作同一节点,mark 域为 0 表示还未被遍历;mark 为 1 表示该节点已被遍历;
- ivex 和 jvex:数据域,分别存储图中各边两端的顶点所在数组中的位置下标;
- ilink:指针域,【横着】指向下一个存储与 ivex 有直接关联顶点的节点;
- jlink:指针域,【竖着】指向下一个存储与 jvex 有直接关联顶点的节点;
- info:指针域,用于存储与该顶点有关的其他信息,比如无向网中各边的权;
图的基本操作
基本操作独立于图的存储结构,不同存储方式有着不同的算法和性能
Ajacent(G,x,y);//判断是否存在边<x,y>或者(x,y)
Neighbors(G,x);//列出G中域结点x相邻的边
InsertVertex(G,x);
DeleteVertex(G,x);
AddEdge(G,x,y);
RemoveEdge(G,x,y);
FirstNeighbor(G,x);//顶点x 的第一个邻接点,有返回顶点号没有返回-1
NextNeighbos(G,x,y);//如果y是x的一个邻接点,返回除y以外顶点x的下一个邻接点,如果y是x最后的一个邻接点则返沪-1
Get_edge_value(G,x,y);
Set_edge_valye(G,x,y,v);
三、图的遍历
图的遍历要比树复杂的多,因为肯恶搞在访问某个顶点后沿着某条路径有回到该点上,为避免此现象,在遍历的过程中需要记下每个被访问过的顶点,因此可以设计一个辅助数组visisted[]来标记顶点是否被访问过
广度优先BFS
广度优先搜索类似于树的层次遍历。
从图中的某一顶点出发,遍历每一个顶点时,依次遍历其所有的邻接点,然后再从这些邻接点出发,同样依次访问它们的邻接点。按照此过程,直到图中所有被访问过的顶点的邻接点都被访问到。
最后还需要做的操作就是查看图中是否存在尚未被访问的顶点,若有,则以该顶点为起始点,重复上述遍历的过程。
bool visited[Max_Vertex_Num];//访问标记数组
void BFSTraverse(Graph G){
for(i=0;i<G.vexnum;i++){//访问标记数组初始化
visited[i]=false;
}
InitQueue(Q);//初始化辅助队列Q
for(i=0;i<G.vexnum;i++){//从0号开始遍历
if(!visited[i]){
BFS(G,i);
}
}
void BFS(Graph G,int v){
visit(v);//访问结点
visited[v]=true;//节点设为true
EnQueue(Q,v);//顶点v(第一个是根节点)入队列
while(!isEmpty(Q)){
DeQueue(Q,v);//如果队列不为空,出队寻找邻接点入队
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
//寻找顶点v的所有邻接点;如果顶点v除了w还有其他邻接点则继续for循环
if(!visited[w]){//如果没有被访问过
visit(w);//访问
visited[w]=true;//设为true
EnQueue(Q,w);//入队
}
}
}
}
}
性能分析
- 需要辅助队列,n个顶点均需要入队一次,空间复杂度为
O(|V|)
- 邻接表存储时,每个顶点均需要搜索一次(入队一次),时间复杂度
O(|V|)
,搜索任意一个顶点的邻接点,每个边至少访问一次,时间复杂度O(|E|)
,总的时间复杂度O(|V|+|E|)
- 邻接矩阵存储时,查找每个顶点的邻接点需要耗时
O(|V|)
,总时间复杂度O(|V|2)
广度优先生成树
遍历过程中,可以得到一颗遍历树,即广度优先生成树,需要注意
- 邻接矩阵存储表示唯一,故广度优先生成树唯一
- 邻接表存储表示不唯一,故广度优先生成树不唯一
BFS求解单源最短路径
如果图为非带权图,求顶点u到顶点v任何路径中最少的边数,没有为∞
使用BFS:因为BFS总数按照距离由近到远来遍历图中每个顶点
void BFS_Min_Distance(Graph h,int u){
//d[i]表示从顶点u到i的最短路径
for(i=0;i<G.Vexnum;++i)
d[i]=∞;
visited[u]=true;d[u]=0;//以上初始化
EnQueue(Q);
while(!isEmpty(Q)){
DeQueue(Q);
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,U,W)){
if(!visited[w]){
visited[w]=true;
d[w]=d[u]+1;//路径长度+1
EnQueue(Q,w);//顶点w入队列
}
}
}
}
深度优先DFS
深度优先搜索类似树的先序遍历,
是从图中的一个顶点出发,每次遍历当前访问顶点的临界点,一直到访问的顶点没有未被访问过的临界点为止。然后采用依次回退的方式,查看来的路上每一个顶点是否有其它未被访问的临界点。访问完成后,判断图中的顶点是否已经全部遍历完成,如果没有,以未访问的顶点为起始点,重复上述过程。
bool visited[Max_Vertex_Num];//访问标记数组
void DFSTraverse(Graph G){
for(i=0;i<G.vexnum;i++){//访问标记数组初始化
visited[i]=false;
}
for(i=0;i<G.vexnum;i++){//从0号开始遍历
if(!visited[i]){
DFS(G,i);
}
}
void DFS(Graph G,int v){
visit(v);//访问结点
visited[v]=true;//节点设为true
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
//寻找顶点v的所有邻接点;如果顶点v除了w还有其他邻接点则继续for循环
if(!visited[w]){//如果没有被访问过
DFS(G,w);
}
}
}
}
性能分析
- 实质是一个递归算法,需要一个递归工作栈,空间复杂度为
O(|V|)
- 遍历图的实质是对每个结点查找其邻接点的过程,耗费时间取决于所存储的结构
- 邻接表存储时,每个顶点均需要搜索一次(入队一次),时间复杂度
O(|V|)
,搜索任意一个顶点的邻接点,每个边至少访问一次,时间复杂度O(|E|)
,总的时间复杂度O(|V|+|E|)
- 邻接矩阵存储时,查找每个顶点的邻接点需要耗时
O(|V|)
,总时间复杂度O(|V|2)
注意:
- 邻接矩阵存储表示唯一,故遍历所得到的BFS和DFS序列唯一
- 邻接表存储表示不唯一,如果输入的次序不同,生成邻接表不同,故遍历所得到的BFS和DFS序列不唯一
深度优先生成树和生成森林
前提:对连通图调用DFS才会有深度优先生成树,否则将会是生成森林
图的遍历与连通性
-
无向图 -连通的
从任意结点出发,仅需一次遍历就能访问图中所有结点
-
无向图 - 非连通
从任意结点出发,一次遍历只能访问图中该顶点所在连通分量的所有顶点
-
有向图
如果初始点到图中都有路径,一次就可以,否则不能
因此,BFS和DFS添加第二个for循环,再次选举初始点,以防止一次遍历无法遍历图中所有顶点,
四、图的应用
4.1 最小生成树
问题:假设要在n个城市之间建立通信联络网,则连通n个城市只需要n—1条线路。这时,自然会考虑这样一个问题,如何在最节省经费的前提下建立这个通信网。
定义:如果无向连通图是一个网,那么,它的所有生成树中必有一棵边的权值总和最小的生成树,我们称这棵生成树为最小生成树,简称为最小生成树。
注:
- 对于有n 个顶点的无向连通图,无论其生成树的形态如何,所有生成树中都有且仅有n-1 条边。
- 最小生成树的 边的权值之和是唯一的(而且是最小的),但是最小生成树不唯一
- 最小生成树不是唯一的(树形),可能有多个,1.如果说各边权值互不相等则最小生成树唯一,2.如果G 的边数比顶点数少1,且G本身是一棵树,最小生成树就是本身。
两种常用的构造最小生成树的算法:普里姆(Prim)和克鲁斯卡尔(Kruskal)。利用了最小生成树的性质和贪心算法
4.1.1 普里姆算法Prim
选定任意一个顶点V0,然后再从剩下的顶点中选择具有最小权值的边(u,v),加入到树中,并连线,依次重复上述步骤
void Prim(G,T){
T=空集;
U={w};//添加任意一个顶点,U集合为已经选择的顶点(最小生成树包含的顶点)
while((V-U)!=空集){//如果存在顶点未被访问
设{u,v}是使u∈U与v∈(V-U),且权值最小的边;
T=T∪{{u,v}};//边 归入 树
U=U∪{v};//顶点 归入 树
}
}
注:时间复杂度为O(|v|2)
,不依赖于|E|,适用于求稠密图的,其他算法虽然改进了普里姆算法时间复杂度,但是增加实现的复杂性
4.1.2 克鲁斯卡尔算法ruskal
初始化时,每个顶点都是一个独立的树,T此时是一个含有|v|个顶点的森林,
按照权值递增次序依次从 未被选择的边集合中 选择,如果加入这条边不会构成回路,则加入到已选择的顶点中,直至含有N-1条边【通常采用堆(7章)存放边的集合】
void Prim(G,T){
T=V;
numS=n;//连通分量数
while(numS>1){
从E中选取权值最小的边{v,u};
if(v和u不属于T中不同的连通分量){
T=T∪{{u,v}};//边 归入 树
numS--;//连通分量-1
}
}
}
注:因为每次选择最小权值的边只需要O(|logE|)
,此外,由于生成树T的所有便可以视为一个等价类,因此每次添加新边类似于求等价类的过程,由此可以采用并查集的数据结构描述,构造T的时间复杂度为O(|E|log|E|)
,适用于稀疏图,顶点多的图
4.2 最短路径
广度优先搜索查找最短路径是对于无权图,当是有权图的时候,从一个顶点V0到Vi的路径为所经过权值之和,因此把带权路径长度最短的称为最短路径
特点:两点间的最短路径也包含了路径上其他顶点间的最短路径
两类问题:1. 单源最短路径:图中顶点到其他各个顶点的最短路径,通过迪杰斯特拉
2.求每队顶点间的最短路径,通过弗洛伊德算法
4.2.1 迪杰斯特拉算法Dijkstra
使用邻接矩阵表示时,时间复杂度O(|v|2)
,使用带权的邻接表表示时,仍为O(|v|2)
注:权值带有负值的时候并不适用
4.2.2 [弗洛伊德算法Floyd](弗洛伊德算法 - 小白菜的文章 - 知乎 https://zhuanlan.zhihu.com/p/139112162)
从出发地到目的地的过程中,我们并不是一下子就到达目的地,而是要经过很多的中转站来帮助我们一步一步地到达目的地。例如下面的图中,假设 AB,BC 之间的距离分别为 7 和 2。我们看到,A 和 C 之间不能直接通达,因此,要计算 A 和 C 之间的距离,我们就要借助 B 来作为中转站,因此 A 到 C 的距离为 7 + 2 = 9。

但是,单凭一个中转站在有些情况下也是不行的,比如从 D 到 F 就不能只通过一个中转站。因此,计算所有节点之间的最短距离这个大问题可以分为借助 $k$ 个中转站的情况下计算各节点之间的最短路径,其中$0<=k<=|V|$ 。

4.3 拓扑排序
AOV网:在AOV网络中,如果活动Vi必须在Vj之前进行,则存在有向边<Vi,Vj><Vi,Vj>,并称ViVi是VjVj的直接前驱,VjVj是ViVi的直接后继。这种前驱与后继的关系具有传递性和反自反性,这要求AOV网络中不能出现回路,即有向环。因此,对于给定的AOV网络,必须先判断它是否存在有向环。
对于一个有向无环图
(1)统计所有节点的入度,对于入度为0的节点就可以分离出来,然后把这个节点指向所有的节点的入度−1
(2)重复(1),直到所有的节点都被分离出来,拓扑排序结束。
(3)如果最后不存在入度为0的节点,那就说明有环,无解。
解释一下,假设A为一个入度为0的结点,就表示A结点没有前驱结点,可以直接做,把A完成后,对于A的所有后继结点来说,前驱结点就完成了一个,入度进行−1

由于输出每个顶点的同时,还需要删除以他为起点的边,所以拓扑排序的时间复杂度为O(|V|+|E|)
注:1.如果一个顶点有多个直接后继,则排序结果不唯一,但是如果已经排在一个线性有序的序列中,每个顶点有唯一的前后关系则唯一
- 入度为0的顶点,表示没有前驱或者前驱已经全部完成,可以从这个活动开始
4.4 关键路径
数据结构 图之关键路径 - 简书 (jianshu.com)
4.5 有向无环图DAG的描述表达式
如果一个有向图中没有环,称为有向无环图DAG
第六章-查找
一、查找
查找定义:根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
查找算法分类:
- 静态查找和动态查找;
注:静态或者动态都是针对查找表而言的。动态表指查找表中有删除和插入操作的表。
- 无序查找和有序查找。
无序查找:被查找数列有序无序均可;
有序查找:被查找数列必须为有序数列。
平均查找长度(Average Search Length,ASL):需和指定key进行比较的关键字的个数的期望值,称为查找算法在查找成功时的平均查找长度。
对于含有n个数据元素的查找表,查找成功的平均查找长度为:ASL = Pi*Ci的和。
注:Pi:查找表中第i个数据元素的概率;Ci:找到第i个数据元素时已经比较过的次数。
二、线性查找
顺序查找
一般顺序查找
//最直观的方式,从一端开始依次比较
typedef struct{
ElemType *elem;//元素存储空间基址,建表时按实际长度分配,0号单元留空
int TableLen;//表长
}SSTable;
int search_Seq(SSTable st,ElemType key){
st.elem[0]=key;//哨兵--使得数组内部循环的时候不会越界,i==0时循环跳出
for(o=st.TableLen;st.elem[i]!=key;--i);
return i;
}
/*查找后定位到第i个关键字,进行n-i+1次比较 ASL=ΣP(n-i+1)
如果查找的概率相同,即P=1/n时,ASL=(n+1)/2
查找失败:比较次数为(n+1)次,ASL=n+1;
优点:对数据存储形式无要求,对表中记录顺序无要求
缺点:n较大,效率低
注意:只能对线性链表进行顺序查找
有序表的顺序查找
//原始有序,如果查找失败不用再比较表的全部即可返回
//查找成功同上
//查找失败:如查找判定树上,查找失败是到我们虚构的空结点上,比较次数等于它上面的圆形节点所在层数,在相同概率下ASL=(1+2+...+n)/(n+1)
折半查找(二分)
用给定值k先与中间结点的关键字比较,中间结点把线形表分成两个子表,若相等则查找成功;若不相等,再根据k与该中间结点关键字的比较结果确定下一步查找哪个子表,这样递归进行,直到查找到或查找结束发现表中没有这样的结点。
int Binary_Search(SeqList L,ElemType key){
int low=0,high=L.TableLen-1,mid;
while(low<=high){
mid=(low+high)/2;//取中间
if(L.elem[mid]==key){
return mid;
}else if(L.elem[mid]>key){//从前半部分
high=mid-1;
}else{//从后半部分
low=mid+1;
}
}
return -1;
}
/*折半查找也可以生成二叉树,根据二叉树可知,查找比较的次数不会超过树的高度,等概率情况下
ASL=(1*1+2*2+3*4+...+h*2^h-1)=log2(n+1)-1
平均时间复杂度O(log2n)
优点:方便定位查找区域
缺点:仅适用于顺序存储结构,不适合链式,且要求关键字有序
分块查找
//查找表分成若干块,块内可以无序,但是块之间必须有序【第一块中最大元素小于第二块中所有元素】
//以此类推,在建立索引表,表中元素含有各块的最大关键字和第一个元素地址,按关键字有序排列
//asl由索引查找和块内查找两部分组成
//长度为n,均分成b块,每块s个记录,等概率情况下,若采取顺序查找
//ASL=(b+1)/2+(s+1)/2
//如果
三、树形查找(B树)
基本概念
n个关键字,m阶,高度为h的B树:
- B树的每个结点最多
m
棵子树,除根结点外的非叶节点,最少[m/2]
棵子树【向上】 - 结点中关键字个数不超过
m-1
个,【反推:结点孩子个数=该节点关键字个数+1】 - 除根节点外非叶节点至少有
[m/2]
棵子树,所以关键字个数至少[m-1]/2-1
个 - 结点关键字必须从小到大排序,每个结点左子树关键字必须小于该节点关键字,右子树大于该节点关键字
最小高度
最小高度时,每层结点数都应该是最大的,且每个结点的关键字个数都应该是最多的,因为每个结点最多m-1个关键字,最多有m棵子树,所以得出上述
最大高度
查找
基本操作
1.在B树找结点
2.在结点内找关键字
由于B树存在磁盘上,前一个查找基本是在磁盘今昔那个,后一个查找上是在内存中进行,找到目标节点将节点信息存储到内存,然后节点内部采取顺序查找或者折半查找
插入与删除
-
定位:利用查找算法找出插入该关键字的最低层中的某个非叶节点(插入的位置一定是最低层中的某个非叶节点
-
插入,每个非失败节点的关键字个数都在(
[m/2]
,'m-1
)之间,插入关键字后,结点关键字个数小于m可以直接插入,如果大于m-1
,需要进行分裂分裂方法:取出新结点,再插入的关键词后面,从中间位置
[m/2]
将关键字分为两部分;左边放在原先结点,右边放到新节点中,中间的放到原先节点的父节点中,如果导致父节点关键字个数超过上限,则进行同样的操作
删除操作
要满足核心要求:m阶B树,除根结点外,关键字个数[m/2]-1
--m-1
;左小右大
-
如果删除终端结点,删除后节点关键字个数不影响,直接删除
-
如果删除终端结点,删除后节点关键字个数小于
[m/2]-1
- 或者找左兄弟的中当前前驱,前驱的前驱依次顶替
- 或者找右兄弟的中当前后继,后继的后继依次顶替
- 或者找父节点中内的关键字进行左右合并
-
如果删除非终端节点,找直接前驱或者直接后继替代,称为对终端结点的删除
- 直接前驱(后继):当前关键字左边指针(右边指针)所指的子树 最右下(左下)的元素
B+树
与B树的差异【B+树总结 - 简书 (jianshu.com)】
B+树 | B树 |
---|---|
每个结点(非根内部节点)关键字个数ceil[m/2] -m |
每个结点(非根内部节点)关键字个数ceil[m/2]-1 -m-1 |
n个关键字的结点只有n棵子树,每个关键字对应一棵子树 | n个关键字的结点只有n+1棵子树 |
叶节点含有信息,非叶节点只是索引 | |
叶节点含有全部关键字,即使非叶节点出现的关键字也会在叶节点出现 | 叶节点包含的关键字与其他节点的关键字不重复的 |
四、散列表
是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
基本概念
-
冲突:散列函数把两个及以上的不同关键字映射到同一个地址【不同关键字称之为同义词】
-
散列函数:查找表关键字映射成该关键字对应的函数地址
-
散列表:建立了关键字和存储地址之间的一种直接映射关系
- 查找复杂度基本为
O(1)
- 查找复杂度基本为
散列函数
-
直接寻址法:取关键字或关键字的某个线性函数值为散列地址。
- $$
H(key)=key 或者 H(key)=a*key+b
$$
- $$
-
数字分析法:通过对数据的分析,发现数据中冲突较少的部分,并构造散列地址。例如同学们的学号,通常同一届学生的学号,其中前面的部分差别不太大,所以用后面的部分来构造散列地址。
-
平方取中法:当无法确定关键字里哪几位的分布相对比较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为散列地址。这是因为:计算平方之后的中间几位和关键字中的每一位都相关,所以不同的关键字会以较高的概率产生不同的散列地址。
-
除留取余法:【除数取余数】取关键字被某个不大于散列表的表长 n 的数 m 除后所得的余数 p 为散列地址。这种方式也可以在用过其他方法后再使用。该函数对 m 的选择很重要,一般取素数或者直接用 n。
当需要存储值时,对Key哈希之后,发现这个地址已经有值了,这时该怎么办?不能放在这个地址,不然之前的映射会被覆盖。冲突的处理方式也有很多,下面介绍几种。
-
开放地址法(也叫开放寻址法):
H= (H(key)+d) % m
- 线性探测:对计算的地址进行一个探测再哈希,比如往后移动一个地址,如果没人占用,就用这个地址。如果超过最大长度,则可以对总长度取余。这里移动的地址是产生冲突时的增列序量。
- 平方探测法:d为从0,1,-1,2,-2,......的平方,再去探测【解决冲突较好但是不能探测到表中所有单元】
- 再散列法:使用两个散列函数计算增量
H=(H(key)+i*Hash(key))%m
注:开放定地址的情形下,不能随便删除已有的元素,如果删除会阶段其他具有相同散列地址的元素的查找地址,因此删除时应该做一个删除标记,进行逻辑删除
副作用:多次删除后,看起来散列表很满,实际很空,需要多次定期维护散列表清除标记元素
-
链地址法:链地址法其实就是对Key通过哈希之后落在同一个地址上的值,做一个链表。其实在很多高级语言的实现当中,也是使用这种方式处理冲突的,我们会在后面着重学习这种方式。
散列查找的性能分析:
-
平均查找长度需要依据实际的散列表,去找到每个关键字的比较次数
-
虽然关键字和记录存储位置之间有影像,但是冲突的产生,使得查找过程仍然是一一比较的过程,需要以平均查找长度作为查找效率的度量
-
散列因子:
取决于:散列函数,处理冲突的办法;装填因子
装填因子:a=表中记录数n/散列表的总长度m
a越大说明记录越满,发生冲突可能性越大
-
平均查找长度依赖于A
第七章-排序
概念
- 稳定性:待排序列中相同关键字 在排序之后是否发生位置的变化,不变就是稳定的,比如R1=R2,R1在R2前面,排序后仍不变。
- 内部和外部排序:数据元素是否全部在内存中
- 通常分为5类:插入,交换,选择, 归并,基数排序
排序算法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
插入排序 | O(N^2) | O(N^2) | O(N) | O(1) | 稳定 |
希尔排序 | O(N^1.3) | O(N^2) | O(N) | O(1) | 不稳定 |
选择排序 | O(N^2) | O(N^2) | O(N^2) | O(1) | 不稳定 |
堆排序 | O(N*log2(N)) | O(N*log2(N)) | O(N*log2(N)) | O(1) | 不稳定 |
冒泡排序 | O(N^2) | O(N^2) | O(N) | O(1) | 稳定 |
快速排序 | O(N*log2(N)) | O(N^2) | O(N*log2(N)) | O(N*log2(N)) | 不稳定 |
归并排序 | O(N*log2(N)) | O(N*log2(N)) | O(N*log2(N)) | O(N) | 稳定 |
计数排序 | O(N+K) | O(N+K) | O(N+K) | O(N+K) | 稳定 |
1.1直接插入排序
把待排序的元素逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
void InsertSort(int arr[],int n){
int i,j;
for(i=2;i<=n;i++){
if(arr[i]<arr[i-1]){
arr[0]=arr[i];//arr[o]作为哨兵,不放元素
for(j=i-1;arr[0]<arr[j];--j){
arr[j+1]=arr[j];
}
arr[j+1]=arr[0];//复制插入
}
}
}
- 空间:O(1)
- 时间:向有序插入N-1次,每次是比较+移动,取决于初始状态
- 原始有序,插入时只比较不移动,复杂度O(n)
- 原始逆序,比较次数最多_____,移动次数最大_____,复杂度O(n2)
- 稳定算法,从后向前比较
- 使用顺序存储和链式存储的线性表(大部分都适用顺序的线性表)
1.2折半插入排序
原始直插排序时,折半查找插入的位置
void InsertSort(int a[],int n){
int i,j;int low,mid,high;
for(i=2;i<=n;i++){
a[0]=a[i];
low=1;high=i-1;
while(low<high){
mid=(low+high)/2;//取下界
if(a[mid]>a[0]) high=mid-1;
else low=mid+1;
}
for(j=i-1;j>=high+1;--j){//元素后移
a[j+1]=a[j];
}
a[high+1]=a[0];//插入
}
}
- 空间:O(1)
- 时间:向有序插入N-1次,每次是比较+移动,但是比较次数减少了,约为O(nlog2n),比较次数不取决于初始状态,但是移动次数仍取决于
- 原始有序,插入时只比较不移动,复杂度O(n)
- 原始逆序,比较次数最多_____,移动次数最大_____,复杂度约为O(n*nlog2n)
- 稳定算法,从后向前比较
1.3希尔排序(插排改良版)
是对直接插入排序的优化,它对序列先进行多次预排序使之接近有序,因为最后接近有序使用直接插入排序非常快。
void ShellSort(int a[],int n){
//a[0]不是哨兵,只是暂存单元,当j<=0时,插入位置已定
for(dk=n/2;dk>=1;dk=dk/2){
for(i=dk+1;i<=n;i++){
if(a[i]<a[i-dk]){
for(j=i-dk;j>0&&a[0]<a[j];j-=dk)
a[j+dk]=a[j];
a[j+dk]=a[0];
}
}
}
}
- 空间:O(1)
- 时间:需要依据增量序列的函数,一般为O(n 1/3)
- 不稳定算法

- 当gap越大,预排序越快,但是越不接近有序
- 当gap越小,数据处理越慢,越接近有序
- 当gap为1即直接插入排序
2.1冒泡排序
通过遍历比较左右值得大小,例如排升序即左值大于右值交换,最后最大值即排到最右边
void BubbleSort(int a[],int n){
for(int i=0;i<n-1;i++){
flag=false;//本次是否发生交换
for(j=n-1;j>i;j--){
if(a[j-1]>a[j]){
swap(a[j-1],a[j]);
flag=true;
}
if(flag==false) return ;
}
}
}
- 空间:O(1) 常数个辅助单元
- 时间:
- 初始有序,比较n-1次结束,复杂度O(n)
- 初始逆序,n-1次排序,每次需要n-i次比较,每次需要移动元素3次,复杂度为O(n2)
- 稳定算法
- 【冒泡排序产生的有序子序列一定是全局有序的,即有序的所有关键字一定大于或者小于无序的所有关键字,每次必定有一个元素放置最终位置】
2.2快速排序
分而治之,取元素pivot为基准,通过一趟排序将其划分两部分,左小右大,pivo放在最终位置,直至每部分只有一个元素即结束
void QuickSort(int a[],int low,int high){
if(low<high){
//Partition()就是划分操作
int pivotpos=Partition(low,high);
QuickSort(a,low,pivotpos=1);
QuickSort(a,pivotpos+1,high);
}
}
int Partition(int a[],int low,int high){
int piovt=a[low];//第一个元素为基准
while(low<high){
while(low<high&&a[high]>=pivot) --high;//右边元素大,继续
a[low]=a[high];//比low小,移动左面
while(low<high&&a[low]<=pivot) ++low;//左边元素小,继续
a[high]=a[low];//比low大,移到右边
}
a[low]=pivot;
return low;//返回基准的位置
}
- 空间:递归工作栈保存调用信息,最好情况O(log2n)最坏O(n)平均O(log2n)
- 时间:与划分是否对称有关
- 最坏情况,两个区域分别包含n-1和0个元素,即元素基本有序或者逆序,复杂度为O(n2)
- 理想情况,两个部分的大小都不可能大于n/2,复杂度为O(nlog2n)
- 提高效率:1. 尽量选取可以将数据中分的基本,
- 不稳定算法
- 每次必定有一个元素放置最终位置
- 且是所有内部排序中平均性能最优的算法
3.1简单选择排序
在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后以此类推,直到所有元素均排序完毕。
void SelectSort(int a[],int n){
for(int i=0;i<n-1;i++){
min=i;//最小元素位置
for(int j=i+1;j<n;j++){//找出最小的元素的位置
if(a[j]<a[min]) min=j;
}
if(min!=i) swap(a[i],a[min]);//移动3次
}
}
- 空间:O(1) 常数个辅助单元
- 时间:元素移动操作很少,不超过3(n-1)次,但比较次数不变n(n-1)/2次
- 最好原始有序,移动0次,复杂度O(n)
- 最坏移动3(n-1)次,复杂度O(n2)
- 不稳定算法
3.2堆排序
堆排序应该满足L(i)>=L(2i)且L(i)>=L(2i+1) 或者L(i)<=L(2i)且L(i)<=L(2i+1)
可以将一维数组视为完全二叉树,大根堆(满足最大元素在根节点,非根节点的值小于等于双亲结点的值)
void HeapSort(int a[],int len){
BuildMaxHeap(a,len);//建造
for(int i=len;i>1;i--){
swap(a[i],a[1]);//输出堆顶元素(和堆底元素交换)
HeadAdjust(a,1,i-1);//调整,将剩余的n-1个元素整理成堆
}
}
void BuildHeap(int a[],int len){
for(int i=len/2;i>0;i--) {
HeadAdjust(A,i,len);
}
}
void HeadAdjust(int a[],int k,int len){
a[0]=a[k];//a[0]暂存子树的根节点
for(int i=2*k;i<=len;i*=2){//沿着key寻找较大子节点
if(i<len&&a[i]<a[i+1]) i++;//取key较大的子节点的下标
if(a[0]>=a[i]) break;//筛选结束
else{
a[k]=a[i];k=i;//将a[i]调整到双亲节点上,修改k继续向下
}
}
a[k]=a[0];//将筛选节点的值放入最终位置
}
- 空间:O(1) 常数个辅助单元
- 时间:建堆时间O(N),n-1次向下调整,每次调整时间复杂度O(h)
- 平均复杂度O(nlog2n)
- 不稳定算法
堆是一种特殊的完全二叉树(complete binary tree)。完全二叉树的一个“优秀”的性质是,除了最底层之外,每一层都是满的,这使得堆可以利用数组来表示(普通的一般的二叉树通常用链表作为基本容器表示),每一个结点对应数组中的一个元素。
如下图,是一个堆和数组的相互关系:
二叉堆一般分为两种:最大堆和最小堆。
最大堆:
最大堆中的最大元素值出现在根结点(堆顶)
堆中每个父节点的元素值都大于等于其孩子结点(如果存在)
最小堆:
最小堆中的最小元素值出现在根结点(堆顶)
堆中每个父节点的元素值都小于等于其孩子结点(如果存在)
堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。在堆中定义以下几种操作:
- 最大堆调整(Max-Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
- 创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆
- 堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算 继续进行下面的讨论前,需要注意的一个问题是:数组都是 Zero-Based,这就意味着我们的堆数据结构模型要发生改变
删除一个元素是如此,插入一个新元素也是如此。不同的是,我们把新元素放在末尾,然后和其父节点做比较,即自下而上筛选。
4.1归并排序
将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
int *B=(int *)malloc((n+1)*sizeof(int));//辅助数组B
void Merge(int a[],int low,int mid,int high){
//两段a[low...mid]和a[mid...high]各自有序,合并成一个
for(int k=low;k<=high;k++){
B[k]=a[k];//a中所有元素复制到B
}
for(int i=low,j=mid+1,k=i;i<=mid&&j<=high;k++){
if(B[i]<B[j]) a[k]=B[i++];//比较B中左右两段的元素
else a[k]=B[j++];
}
while(i<=mid ) a[k++]=B[i++];//若为第一个表没有结束,复制
while(j<=high) a[k++]=B[j++];//若为第二个表没有结束,复制
}
void MergeSort(int a[],int low,int high){
if(low<high){
int mid=(low+high)/2;//中间划分2个子序列
MergeSort(a,low,mid);//左侧子序列递归排序
MergeSort(a,mid+1,high);///右侧子序列递归排序
Merge(a,low,mid,high);//归并
}
}
A表的两段a[low...mid]和a[mid...high]各自有序,复制到辅助数组B,
每次从对应B中的两段取出一个记录进行比较,较小放入A中,当数组B中,有一段下标超出其对应的表长【该段所有元素都已经复制到A中】。将另一端直接复制到里
- 空间:辅助空间刚好O(n)
- 时间:每趟归并复杂度的为O(n),需要进行log2n趟归并,
- 复杂度为O(nlog2n)
- 稳定算法
4.2基数排序
不基于比较和移动,而是基于关键字的各位大小进行排序
通常采用最高为优先法MSD,按关键字 位权重递减依次逐层划分若干更小的子序列
例如排序队列
- 空间:一趟排序需要辅助空间为r(r个队列,r个队头指针和对尾指针,但是以后会重复使用这个队列,因此为O(r)
- 时间:需要进行d趟牌香烟,一趟需要O(n),一趟收集需要O(r),所以为O(d"(n+r)),与初始状态无关
- 稳定算法
4.3计数排序
- 统计相同元素出现次数根据
- 统计的结果将序列回收到原来的序列中
- 计数排序只适用于范围集中且重复数据较高的数据
本文来自博客园,作者:cyz1005,转载请注明原文链接:https://www.cnblogs.com/cyz1005/p/18167001
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)