文章目录
数据结构(基于C语言)
第一章 单链表
- 单链表分为两种:带头结点的单链表和不带头结点的单链表
- 单链表的特点
- 优点:不要求大片的连续空间,改变容量方便
- 缺点:不可以随机存取,要耗费一定的空间存放指针
- 在写代码时,不带头结点的单链表在对第一个元素进行操作时需要写额外的代码逻辑,不推荐
- 单链表的局限性:无法逆向检索
单链表的建立
- 单链表建立步骤
- 初始化一个单链表
- 每次取一个数据元素,插入表尾/表头
头插法
LinkList CreatList(LinkList &L){
LNode *s;
int x;
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL;
scanf("%d",&x);
while(x!=-1){
s=(LNode *)malloc(sizeof(LNode));
s->data=x;
s->next=L->next;
L->next=s;
scanf("%d",&x);
}
return L;
}
尾插法
LinkList CreatList(LinkList L){
ElemType x;
//初始化一个空表
L=(LinkList)malloc(sizeof(LNode));
//r为表尾指针
LNode *s,*r=L;
scanf("%d",&x);
//-1为取定的特殊值,意味着当输入的值为-1时退出循环
while(x!=-1){
//在r节点之后插入元素x
s=(LNode *)malloc(sizeof(LNode));
s->data=x;
r->next=s;
//永远保持r指向最后一个节点
r=s;
scanf("%d",x);
}
//尾结点指针为空
r->next=NULL;
return L;
}
- 此算法的时间复杂度为O(n)
带头结点的单链表
按位序插入
- 带头结点的单链表在插入节点时比较方便,不需要像不带头结点的单链表一样对在第一个位置插入节点进行特殊处理
![](imgs/带头结点的单链表结构.png)
typedef struct LNode(){
ElemType data;
struct LNode *next;
}LNode,*LinkList;
bool ListInsert(LinkList &L,int i,ElemType e){
//单链表的节点下标是从1开始的,若是传入的i值小于1,说明传入的i值不合法,插入失败
if(i<1>{
return false;
})
//指针p指向当前扫描到的节点
LNode *p;
//记录当前p扫描到第几个节点
int j=0;
p=L;
//循环查找第i-1个节点
/*
若是在i=3的位置插入节点
1.j=0,i-1=2;进入while循环,p指针后移
2.j=1, ;进入while循环,p指针再次后移
3.j=2, ;跳出while循环,此时p指针指向第二个节点
*/
while(p!=NULL && j<i-1>){
p=p->next;
j++;
}
//若是p=NULL,说明i的值不合法,插入失败
if(p==NULL){
return false;
}
LNode *s=(Lnode *)malloc(sizeof(LNode));
s->data=e;
s->next=p->next;
p-next=s;
return true;
}
- 按位插入的时间复杂度为O(n)
按位序删除
- ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值,并用free()函数释放节点
bool ListDelete(Linklist &L,int i,ElemType &e){
if(i<1){
return false;
}
LNode *p;
int j=0;
p=L;
while(p!=NULL && j<i-1){
p=p->next;
j++;
}
//i值不合法
if(p==NULL){
return false;
}
//第i-1节点之后已无其他节点
if(p->next=NULL){
return false;
}
LNode *q=p->next;
e=q->data;
p->next=q->next;
free(q);
return true;
}
- 最坏、平均时间复杂度为O(n),最好时间复杂度为O(1)
删除指定节点
bool DeleteNode(LNode *p){
if(p==NULL){
return false;
}
//令q指向p的后继节点
LNode *q=p->next;
//等价于p->data=p->next->data
//和后继节点交换数据域
p->data=q->data;
//将后继节点从链表中断开
p->next-q->next;
//释放后继节点的存储空间
free(q);
return true;
}
- 此算法的时间复杂度为O(1)
不带头结点的单链表
按位序插入
- 不带头结点的单链表在插入时,方法和带头结点的单链表一致。都是先遍历链表找到第i-1个节点,然后在第i个位置插入节点,再改变指针所指的节点。但是不带头结点的单链表在对于i=1位位置进行操作时需要特别处理。
typedef struct LNode(){
ElemType data;
struct Lnode *next;
}LNode,*LinkList;
bool ListInsert(LinkList &L,int i,ElemType e){
if(i<1){
return false;
}
if(i==1){
LNode *s=(LNode *)malloc(sizeof(LNode));
s->data=e;
//将s的next指针指向L所指向的节点
s->next=L;
//将头指针L指向新节点
L=s;
return true;
}
LNode *p;
//表名此时指针p指向的节点是第一个节点
int j=1;
p=L;
while(p!=NULL && j<i-1){
p=p->next;
j++;
}
if(p==NULL){
return false;
}
LNode *s=(LNode *)malloc(sizeof(LNode));
s->data=e;
s->next=L->next;
p->next=s;
return true;
}
按位序删除
指定节点的后插操作
- 在给定指针p以后,指针p所指结点以后的区域是已知的,可以通过遍历得到;指针p所指结点之前的区域是未知的
typedef struct LNode(){
ElemType data;
struct Lnode *next;
}LNode,*LinkList;
//后插操作,在p节点之后插入元素e
bool InsertNextNode(LNode *p,ElemType e){
//指针p指向的节点为空
if(p==NULL){
return false;
}
LNode *s=(LNode *)malloc(sizeof(LNode));
//内存分配失败,例如内存已满
if(s==NULL){
return false
}
s->data=e;
s->next=p->next;
p->next=s;
return true;
}
- 此代码的时间复杂度为O(1)
指定节点的前插操作
- 因为节点之前的区域是未知的,所以要实现前插操作有两种方式
- 向函数中传入头指针,就可以从头指针开始向后遍历,然后实现插入操作,时间复杂度为O(n)
- 在指定节点后创建新的节点,交换p节点和新节点的值,时间复杂度为O(1)
/*
方法一:需要自己创建新的节点s
*/
bool InsertPriorNode(LNode *p,ElemType e){
if(p==NULL){
return false;
}
LNode *s=(LNode *)malloc(sizeof(LNode));
if(s==NULL){
return false;
}
s->next=p->next;
//将新节点s连到p之后
p->next=s;
//将p中的元素复制到s中
s->data=p->data;
//将p中的元素覆盖为e
p->data=e;
return true;
}
/*
方法二:给出新节点s,直接传入函数
*/
bool InserPriorNode(LNode *p,LNode *s){
if(p==NULL || s=NULL){
return false;
}
s->next=p->next;
p->next=s;
ElemType temp=s->data;
s->data=p->data;
p->data=temp;
return true;
}
单链表的查找
按位查找
LNode * GetElem(LinkList L,int i){
if(i<0){
return NULL;
}
LNode *p;
int j=0;
p=L;
while(p!=NULL && j<i){
p=p->next;
j++;
}
return p;
}
- 平均时间复杂度为O(n)
按值查找
LNode * LocateElem(LinkList L,ElemType e){
LNode *p=L->next;
while(p!=NULL && p->data!=e){
p=p->next;
}
return p;
}
- 此算法的平均时间复杂度为O(n)
求单链表的长度
int Length(LinkList L){
LNode *p=L;
int len=0;
while(p->next!=NULL){
p=p->next;
len++;
}
return len;
}
- 此算法的平均时间复杂度为O(n)
第二章 双链表
- 在单链表的基础上再添加一个指针域,用来指向先前节点
typedef struct DNode{
ElemType data;
struct DNode *prior,*next;
}DNode,*DLinkList;
双链表的初始化(带头结点)
//初始化双链表
bool InitDLinkList(DLinkList &L){
L=(DNode *)malloc(sizeof(DNode));
if(L==NULL){
return false;
}
L->prior=NULL; //头结点的prior永远指向NULL
L->next=NULL;
return true;
}
void testDLinkList(){
DLinkList L;
InitDLinkList(L);
...
}
//判断双链表是否为空
bool Empty(DLinkList L){
if(L->next==NULL){
return true;
}
else{
return false;
}
}
双链表的插入
//在p节点之后插入s节点
bool InsertDNode(DNode *p,DNode *s){
s->next=p->next;
p->next->prior=s;
s->prior=p;
p->next=s;
}
/*
异常优化:若是p指针指向节点的next指针指向的是NULL,就会发生错误,
因为NULL是没有prior指针的
*/
bool InsertDNode(DNode *p,DNode *s){
s->next=p->next;
if(p->next!=NULL){
p->next->prior=s;
}
s->prior=p;
p->next=s;
}
双链表的删除
//删除p节点的后继节点q
bool DeleteNextDNode(DNode *p){
if(p==NULL){return false;}
DNode *q=p->next; //找到后继节点q
if(q==NULL){return false;} //p没有后继节点
p->next=q->next;
if(q->next!=NULL){ //q节点不是最后一个节点
q->next->prior=p;
}
free(q);
return true;
}
//销毁一个双链表
bool DestoryList(DLinkList &L){
//循环释放各数据节点
while(L->next!=NULL){
DeleteNextDNode(L);
}
free(L); //释放头结点
L=NULL; //头指针指向NULL
}
双链表的遍历
- 双链表的遍历分为前向遍历和后向遍历两种
- 双链表不可以随机存取,按值查找、按位查找操作都只能用遍历的方式实现。时间复杂度为O(n)
//前向遍历
while(p!=NULL){
p=p->next;
}
//后向遍历
while(p!=NULL){
p=p->prior;
}
//后向遍历(不处理头结点)
while(p->prior!=NULL){
p=p->prior;
}
循环链表
循环单链表
- 在判断循环单链表是否为空时,只需要判断头结点的next指针是否指向自身;
- 在判断循环单链表中的某一节点p是否为表尾节点时,只需要判断p节点的下一个节点是否为头结点
typedef struct LNode{
ELemType data;
struct LNode next;
}LNode,*LinkList;
//初始化一个循环单链表
bool InitList(LinkList &L){
L=(LNode *)malloc(sizeof(LNode));
if(L==NULL){return false;}
L->next=L;
return true;
}
//判断循环单链表是否为空
bool Empty(LinkList L){
if(L->next==L){
return true;
}
else{
return false;
}
}
//判断节点p是否为循环单链表的表尾节点
bool isTail(LinkList L,LNode *p){
if(p->next==L){
return true;
}
else{
return false;
}
}
循环双链表
- 循环双链表初始化后是以下结构
//初始化空的循环双链表
bool InitDLinkList(DLinkList &L){
L=(DLNode *)malloc(sizeof(DLNode));
if(L==NULL){
return false;
}
L->next=L;
L->prior=L;
return true;
}
//判断循环双链表是否为空
bool Empty(DLinkList L){
if(L->next == L){
return true;
}
else{
return false;
}
}
//判断节点p是否为循环双链表的表尾节点
bool isPrior(DLinkList L,DNode *p){
if(p->prior==L){
return true;
}
else{
return false;
}
}
第三章 静态链表
-
静态链表:分配一整片连续的内存空间,各个节点集中安置。静态链表的中的没一个节点包含了数据元素和下一个节点的数组下标(游标)
-
若是节点中的每个数据元素和游标都占4B,且起始地址为addr。则
节点地址=addr+(4=4)*数组下标
-
静态链表的基本操作
- 初始化:在初始化时可以将next值设为一个特殊值(例如-2
- 查找:从头结点出发挨个往后遍历节点,时间复杂度为O(n)
- 插入位序为i的节点
- 找到一个空的节点,存入数据元素
- 从头节点出发找到位序为i-1的节点
- 修改新的节点的next
- 修改i-1号节点的next
- 删除某个节点
- 从头结点出发找到前驱结点
- 修改前驱结点的游标
- 将被删除节点的游标设为-2
-
静态链表实际上就是用数组实现的链表
-
优点:增删操作不需要大量的移动元素
-
缺点:不能随机存取,只能从头节点开始依次往后查找;容量固定不变
/*
定义一个静态链表
*/
//方法一
#define MaxSize 10 //静态链表的最大长度
struct Node{ //静态链表结构类型的定义
ElemType data; //存储数据元素
int next; //下一个元素的数组下标
};
void testSLinkList(){
struct Node a[MaxSize]; //数组a作为静态链表
...
}
//方法二
#define MaxSize 10
typedef struct{
ElemType data;
int next;
}SLinkList[MaxSize];
以上方法等价于
/*
先定义一个名为struct Node的结构体,再将struct Node用typedef重命名
最后再用SLinkList定义一个长度为MaxSize的Node型数组
*/
#define MaxSize 10
struct Node{
ElemType data;
int next;
};
typedef struct Node SLinkList[MaxSize];
第四章 顺序表 VS 链表
- 逻辑结构:顺序表和链表都属于线性表,都是线性结构。数据元素之间是一对一的关系
- 存储结构
- 顺序表是顺序存储
- 优点:拥有随机存取的特性。顺序表中的每个节点只需要存储数据元素本身,不需要存储其他的冗余信息,因此数据存储的密度高。
- 缺点:需要大片的连续空间,改变容量不方便
- 链表是链式存储
- 优点:离散的小空间分配方便,改变容量方便
- 缺点:不可随机存取,存储密度低
- 顺序表是顺序存储
请描述顺序表和链表的… ,在实现线性表时,用顺序表好还是链表好?
顺序表和链表的逻辑结构都是线性结构,都属于线性表。
但是两者的存储结构不同。顺序表采用的顺序存储,具有随机存去的特性,并且数据存储的密度高;但是顺序表需要大片的连续区域,在改变容量时不方便。
链表采用的是链式存储,其离散的小空间分配方便,改变容量方便;但是不可以随机存取,存储密度低。
由于采用不同的存储方式实现,因此基本操作的实现效率也不同。当初始化时,顺序表需要分配一片连续的空间,而链表只需要分配一个头指针和头结点的空间;在插入一个元素时,顺序表需要将元素插入位置以后的元素后移,而链表只需要改变指针的指向节点;当删除一个元素时,顺序表需要将被删元素之后的元素前移,而链表只需要改变被删节点的前后节点的指针指向即可;当查找一个元素时,顺序表按位查找的时间复杂度为O(1),按值查找的时间复杂度为O(n);对于链表来说,无论是按值查找还是按位查找都需要遍历链表,时间复杂度为O(n)。
第五章 栈、队列和数组
栈
- 栈是只能在一端进行插入和删除的线性表
- 栈顶:允许进行插入和删除操作的一端
- 栈底:不允许进行插入和删除操作的一端
- 特点:后进先出(LIFO)
- 栈在逻辑结构上与线性表相同,在数据运算方面插入和删除操作有所区别
顺序栈的实现
- 顺序栈:用顺序存储的方式实现的栈
初始化栈
//初始化
#define MaxSize 10 //定义栈中元素的最大个数
typedef struct{
ElemType data[maxSize]; //静态数组存放栈中的元素
int top; //栈顶指针,记住的是数组下标
}SqStack;
void InitStack(SqStack &S){
//初始化栈顶指针。因为栈中没有元素,所以栈顶指针指向-1
S.top=-1;
}
//判断栈是否为空
bool Empty(SqStack S){
if(S.top==-1){
return true
}
return false;
}
进栈操作(增)
bool Push(SqStack &S,ElemType x){
if(S.top==MaxSize-1){
return false;
}
/*
S.top=S.top+1; //指针先加一
S.data[S.top]=x; //新元素入栈
*/
S.data[++S.top]=x; //注意不要写为S.data[S.top++]=x
return true;
}
出栈操作(删)和 读取栈顶元素操作(查)
- 出栈操作只是将数据在逻辑上删除了,其实数据还存在于内存中
//出栈操作
bool Pop(SqStack &S,ElemType &x){
if(S.top==-1){
return false;
}
/*
x=S.data[S.top];
S.top=S.top-1;
*/
x=S.data[S.top--];
return true;
}
- 读取栈顶元素操作和出栈操作基本相同,区别只在于出栈操作每次会将栈顶元素下移一位
bool GetTop(SqStack S,ELemType &x){
if(S.top==-1){return false;}
x=S.data[S.top];
return true;
}
链式栈的实现
- 链式栈的实现和单链表的实现基本一致,区别在于链式栈只能在栈顶插入和删除元素,而不能对其他位置的元素进行操作
- 链式栈同样有两种:带头结点和不带头结点,两种栈在判断是否为空上有所区别
typedef struct Linknode{
ElemType data;
struct Linknode *next;
}*LiStack;
栈的应用
栈的应用–括号匹配
#define MaxSize 10
typedef struct{
char data[MaxSize];
int top;
}SqStack;
//初始化栈
void InitStack(SqStack &S)
//判断栈是否为空
bool StackEmpty(SqStack &S)
//新元素入栈
bool Push(SqStack &S,char &x)
//栈顶元素出栈
bool Pop(SqStack &S,char &x)
bool bracketCheck(char str[],int length){
SqStack S;
InitStack(S);
for(int i=0;i<length;i++){
if(str[i]=='(' || str[i]=='[' || str[i]=='{'){
Push(S,str[i]);
}
else{
if(StackEmpty){return false;}
char topElem;
Pop(S,topElem); //栈顶元素出栈
if(str[i]==')' && topElem != '('){return false;}
if(str[i]=='[' && topElem != ']'){return false;}
if(str[i]=='{' && topElem != '}'){return false;}
}
}
return StackEmpty(S); //检索完全部的括号后,若是栈为空,则匹配成功
}
栈的应用–表达式求值
- 算术表达式包括操作数、运算符和界限符
- 表达式分为前缀表达式、中缀表达式和后缀表达式
前缀表达式:+ a b(运算符在数字之前)
中缀表达式:a + b + c(运算符在数字中间)
后缀表达式:a b +(运算符在数字后面)
- 中缀转后缀方法
- 确定中缀表达式中各个运算符的运算顺序
- 选择下一个运算符,按照【左操作数 右操作数 运算符】的方式组成一个新的操作数
- 如果还有运算符没被处理,就重复第二步
- 后缀表达式计算方法
- 从左往右扫描,每遇到一个运算符,就让运算符最近的两个操作数执行对应运算,合体为一个操作数
- 注意:先弹出的是右操作数
- 中缀表达式转前缀表达式
- 确定中缀表达式中各个运算符的运算顺序
- 确定下一个运算符,按照【运算符 左操作数 右操作数】的方式组合成一个新的操作数
- 如果还有运算符没有被处理,就重复第二步
- 注意:先弹出的是左操作数
右优先原则:只要右边的运算符能先计算,就有先算右边的
栈的应用–递归
- 递归调用的缺点:效率低,太多层递归可能会导致栈溢出;可能包含很多重复计算
队列
- 队列同样是操作受限的线性表,只允许在一端进行插入(入队),在另一端删除(出队)的线性表
- 队列特点:先进先出(FIFO)
队列的顺序实现
- 方法一:由队头指针和队尾指针是否相等来判断队列是否为空
//初始化
#define MaxSize 10
typedef struct{
ElemType data[MazSize]; //用静态数组存放队列元素
int front,rear //用来标记队头和队尾位置
}SqQueue;
void InitQueue(SqQueue &Q){
Q.rear=Q.front=0; //初始化时,使队头指针和队尾指针都指向0
}
//判断队列是否为空
void Empty(SqQueue Q){
if(Q.rear==Q.front){return true;}
else{return false;}
}
//入队操作
bool EnQueue(SqQueue &Q,ELemType x){
/*
此时队列中是有一个位置没有存元素的,这个位置也不允许存储元素。
若是这个位置也存储了元素会使rear后移与front相等,
而在判断队列是否为空时,就是使用的rear和front是否相等来判断的,
这会造成冲突
*/
if((Q.rear+1) % MaxSize==Q.front){return false;}
Q.data[Q.rear]=x;
Q.rear=(Q.rear+1) % MxSize; //队尾指针加一并对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;
}
//查询操作
bool GetHead(SqQueue Q,ElemType &x){
if(Q.rear==Q.front){return false;}
x=Q.data[Q.front];
return true;
}
- 方法二:定义第三个变量size来记录队列的长度
//初始化
#define MaxSize 10
typedef struct{
ELemType data[MaxSize];
int front,rear,size;
}SqQueue;
void InitQueue(SqQueue&Q){
Q.rear=Q.front=Q.size=0;
}
//判断队列是否为空
void Empty(SqQueue &Q){
if(size==0){return true;}
else{return false;}
}
//判断队列是否已满
void FullQueue(SqQueue Q){
if(size==MaxSize){return true;}
else{return false;}
}
- 方法三:定义变量tag,用来标记最近一次的操作时插入还是删除
- 每次删除成功时,令tag=0
- 每次插入成功时,令tag=1
//只有插入操作才会使队列变满,只有删除操作才会使队列变空
//初始化
#define MaxSize 10
typedef struct{
ElemType data[MaxSize];
int rear,front,tag;
}SqQueue;
void InitQueue(SqQueue &Q){
Q.rear=Q.front=Q.tag=0;
}
//判断队列是否为空
void EmptyQueue(SqQueue Q){
if(Q.rear==Q.front && tag==0){return true;}
else{return false;}
}
//判断队列是否已满
void FullQueue(SqQueue Q){
if(Q.rear==Q.front && tag==1){return true;}
else{return false;}
}
队列的链式实现
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode;
//初始化(带头结点)
void InitQueue(LinkNode &Q){
//初始化时,front和rear都指向头结点
Q.front=Q.rear=(LinkNode*)malloc(sizeof(LinkNode)); //申请一个头结点,并使front、rear指向头结点
Q.front->next=NULL;
}
void IsEmpty(LinkNode Q){
if(Q.front==Q.rear){return true;}
else{return false;}
}
//初始化(不带头结点)
void InitQueue(LinkNode &Q){
//初始化时,front和rear都指向空
Q.front=Q.rear=NULL;
}
void IsEmpty(LinkNode Q){
if(Q.front==NULL){
return true;
}
else{
return false;
}
}
- 入队
//带头结点
void EnQueue(LinkNode &Q,ElemType x){
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));
s->next=NULL;
s->data=x;
Q.rear->next=s; //新节点插入到rear之后
Q.rear=s; //修改尾指针
}
//不带头结点
void EnQueue(LinkNode &Q,ElemType x){
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));
s->data=x;
s->next=NULL;
if(Q.front==NULL){
Q.front=s;
Q.rear=s;
}
Q.rear->next=s;
Q.rear=s;
}
- 出队
//带头结点
bool DeQueue(LinkNode &Q,ElemType &x){
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);
return true;
}
//不带头结点
bool DeQueue(LinkNode &Q,ElemType &x){
if(Q.front->next==NULL){return false;}
LinkNode *p=Q.front;
x=p->data;
Q.front=p->next;
if(Q.rear==p){
Q.front=NULL;
Q.rear=NULL;
}
free(p);
return true;
}
双端队列
- 双端队列:只允许在两端插入、两端删除的线性表
- 输入受限的双端队列:只允许在一端插入、两端删除的线性表
- 输出受限的双端队列:只允许从两端插入、一段删除的线性表
第六章 矩阵
对称矩阵的压缩存储
- 对称矩阵的存储主要有两种方法
- 普通存储:使用n*n的二维数组进行存储
- 压缩存储:只存储主对角线和下三角区域(主对角线和上三角区域)
第七章 字符串
- 字符串就是由零个或多个字符组成的有限序列
- 子串:串中任意个连续的字符组成的子序列
- 字符串的下标是从一开始的
- 串是一种特殊的线性表,数据元素之间呈线性关系;串的数据元素限定为字符集
顺序存储
- 字符串顺序存储的优缺点可以参考顺序表的优缺点
定长顺序存储(静态数组)
#define MaxSize 255
typedef struct{
char ch[MaxSize]; //每个分量存储一个字符
int length; //串的实际长度
}SString;
- 定长顺序存储的基本操作
//求子串:求从第pos个字符开始,向后数len个字符
bool SubString(SString &Sub,SString S,int pos,int len){
//子串范围越界
int i;
if(pos+len-1 > S.length){
return false;
}
for(i=pos;i<pos+len;i++){
Sub.ch[i-pos+1]=S.ch[i];
}
Sub.length=len;
return true;
}
/*
比较操作:若 S>T,则返回值大于0
若 S=T,则返回值等于0
若 S<T,则返回值小于0
*/
int StrCompare(SString S,SString T){
int i;
for(i=0;i<S.length && i<T.length;i++){
if(S.ch[i] != T.ch[i]){
return S.ch[i]-T.ch[i];
}
}
//扫描过的所有字符都相同,则长度长的串更大
return S.ch[i]-T.ch[i];
}
//定位操作:查找相符合的子串
int Index(SString S,SString T){
int i=1,n=StrLength(S),m=StrLength(T);
SString sub; //用于暂时保存子串
while(i<n-m+1){ //共可以取(n-m+1)个子串
SubString(sub,S,i,m);
if(StrCompare(sub,T)!=0){
i++;
}
return i; //返回子串在主串中的位置
}
return 0; //S中不存在于T相等的子串
}
堆分配存储(动态数组)
typedef struct{
char *ch; //按串场分配存储区,ch指向串的基地址
int length; //串的长度
}HString;
HString S;
S.ch=(char *)malloc(sizeof(char)*MaxSize);
S.length=0;
链式存储
/*
方法一:这种方式使用1B来存储有效信息,4B来存储指针。
存储密度低
*/
typedef struct StringNode{
char ch; //每个节点存储1个字符
struct StringNode *next;
}StringNode,*String;
/*
方法二:使用数组存储多个字符,提高存储密度
若是最后的数组填不满,可以使用特殊字符进行填充(例如 #)
*/
typedef struct StringNode{
char ch[4];
struct StringNode *next;
}StringNode,*String;
串的朴素模式匹配算法
- 串的模式匹配:在主串中找到与模式串相同的子串,并返回其所在的位置
int Index(SString S,SString T){
int k=1;
int i=k,j=1;
while(i<=S.length && j<=T.length){
if(S.ch[i]==T.ch[i]){
i++;
j++;
}
else{
/*
//直接用i和j的关系,使每一次的对比实现后移
i=i-j+2;
j=1;
*/
k++;
i=k;
j=1;
}
}
if(j>T.length){
//retrun i-T.length;
return k; //防止k越界
}
else{
return 0;
}
}
- 若模式串的长度为m,主串的长度为n,则最好的时间复杂度为O(n-m),约等于O(n);最坏的时间复杂度为O(nm)
KMP算法
- KMP算法的核心就是对模式串进行分析,得出next数组;next数组用来存储当匹配不成功时,模式串指针应该回溯到的位置
- KMP算法相比较于朴素模式匹配算法优化地方仅仅是某些子串与模式串能部分匹配的情况,若是这种情况不经常出现,则两种算法的效率相差不大
//手算next数组的KMP算法的实现
int IndexKMP(SString S,SString T,int next[]){
int i=j=1;
while(i<=S.length && i<=T.length){
if(j==0 || S.ch[i]==T.ch[i]){
++i;
++j;
}
else{
j=next[i]; //模式串右移
}
}
if(j>T.length){
return i-T.length; //匹配成功
}
else{
return 0;
}
}
//代码实现求next数组
void getNext(SString T,int next[]){
int i=1,j=0;
next[1]=0;
while(i<T.length){
if(j==0 || T.ch[i]==T.ch[j]){
++i;
++j;
//若pi=pj,则next[j+1]=next[j]+1
next[i]=j;
}
else{
//否则就令j=next[j],循环继续
j=next[j];
}
}
}
//KMP算法
int IndexKMP(SString S,SString T){
int i=j=1;
int next[T.length+1];
getNext(T,next);
while(i<=S.length && i<=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;
}
}
- 此算法的时间复杂度为O(n+m)
- 对KMP算法的next数组进行优化
+ 若模式串所对应的元素相等,就将它们的nextval数组设为相同的值
第八章 树与二叉树
树的定义与基本术语
- 树的属性
- 节点的层次(深度)–从上往下数
- 节点的高度–从下往上数
- 树的高度(深度)–总共多少层
- 节点的度–有几个孩子(分支)
- 树的度–各节点的度的最大值
- 有序树:逻辑上看,树中节点的各子树从左至右是有次序的,不能交换
- 无序树:逻辑上看,树中节点的各子树从左至右是无次序的,可以交换
- 森林:森林是m(m>=0)棵互不相交的树的集合
树的性质
- 节点数=总度数+1
- 树的度和m叉树
- 树的度–各节点的度的最大值
- m叉树–每个节点最多只能有m个孩子的树
度为m的树 | m叉树 |
---|---|
任意结点的度小于等于m(最多m个孩子) | 任意结点的度小于等于m(最多有m个孩子) |
至少有一个节点的度等于m(有m个孩子) | 允许所有节点的度都<m |
一定是非空树,至少有m+1个节点 | 可以是非空树 |
- 度为m的树第i层至多有m的i-1次方个节点(i>=1);m叉树第i层至多有m的i-1次方个节点(i>=1)
- 高度为h的m叉树最多有 (m^h-1)/(m-1) 个节点
- 高度为h的m叉树至少有h个节点;高度为h、度为m的树至少有(h+m-1)个节点
二叉树的定义与基本术语
- 二叉树是n(n>=0)个节点的有限集合
- 当n=0时,为空二叉树
- 由一个根节点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别为一棵二叉树
- 二叉树的特点
- 每个节点至多只能有两棵子树
- 左右子树不能颠倒(二叉树是有序树)
- 满二叉树–一棵高度为h,且含有(2^h-1)个结点的二叉树
- 只有最后一层有叶节点
- 不存在度为一的节点
- 按层序从1开始编号,结点i的左孩子为2i,有孩子为(2i+1);结点i的父节点为 i/2(向下取整)
- 完全二叉树–当且仅当其每个节点都与高度为h的满二叉树中编号1~n的节点一一对应时,称为完全二叉树
- 只有最后两层有叶节点
- 最多只有一个度为1的节点
- 按层序从1开始编号,结点i的左孩子为2i,有孩子为(2i+1);结点i的父节点为 i/2(向下取整)
- i<=n/2(向下取整)为分支节点;i>n/2(向下取整)为叶节点
- 二叉排序树–一棵二叉树或者是空二叉树,或是具有如下性质的二叉树:
- 左子树上所有结点的关键字均小于根节点的关键字
- 左子树上所有结点的关键字均大于根节点的关键字
- 左子树和右子树又各是一棵二叉排序树
- 平衡二叉树–树上任一结点的左子树和右子树的深度(高度)之差不超过一
二叉树的性质
-
设非空二叉树度为0、1和2的结点个数分别为n0、n1和n2,则n0=n2+1
- 即叶结点的个数比度为2的结点个数多一
证明:
假设树中结点总数为n,则:
(因为二叉树中只含有度为0、1和2的结点,所以这三种结点的和就是此二叉树中所有的结点)
① n=n0+n1+n2
(树的结点数=总度数+1;在这个树中含有n1个度为1的结点,n2个度为2的节点)
② n=n1+2*n2+1
②-①得,n0=n2+1 -
二叉树第i层至多有2(i-1)个结点(i>=1);m叉树第i层至多有m(i-1)个结点(i>=1)
-
高度为h的二叉树至多有2h-1个结点(满二叉树);高度为h的m叉树至多有(mh-1)/(m-1)个结点
-
具有n个(n>0)结点的完全二叉树的高度h为 log(n+1)[上取整] 或 log(n)+1[下取整]
高为h的满二叉树共有2^h-1个结点
高度为h-1的满二叉树共有2^(h-1)-1个结点
2^(h-1)-1 < n <= 2^h-1
2^(h-1) < n+1 <= 2^h
h-1 < log(n+1) <= h
h=log(n+1) [上取整]高为h-1的满二叉树共有2^(h-1)-1个结点
高为h的满二叉树至少有2h-1个结点,至多有2h个结点
2^(h-1) <= n <2^h
h-1 <= log(n) < n
h <= log(n)+1 [下取整] -
对于完全二叉树,可以由总结点数n推出度为0、1和2的结点的个数,即n0、n1和n2
完全二叉树最多只有一个度为1的结点,即:
n1=0或1
n0=n2+1 --> n0+n2(2n2+1)一定是奇数
所以,若完全二叉树有2k(偶数)个结点,则必有n1=1,n0=k,n2=k-1
若完全二叉树有(2*k-1)个结点,则必有n1=0,n0=k,n2=k-1
二叉树的存储结构
二叉树的顺序存储
#define MaxSize 100
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
}
TreeNode t[MaxSize];
//初始化
void init(TreeNode t){
for(int i=0;i<MaxSize;i++){
t[i].isEmpty=true;
}
}
- 完全二叉树顺序存储的基本操作
- 若是根节点的序号为1
- m的左孩子----2m
- m的右孩子----2m+1
- m的父节点----m/2 [下取整]
- m所在的层次—log(m+1)[上取整]或log(m)+1[下取整]
- 若完全二叉树中有n个结点,则
- 判断结点i是否有左孩子------2i<=n?
- 判断结点i是否有右孩子------2i+1<=n?
- 判断i是否是叶子/分支节点—i>n/2[下取整]
- 若是根节点的序号为0
- m的左孩子----2m+1
- m的右孩子----2m+2
- m的父节点----m/2-1 [上取整]
- m所在的层次—log(m+2)[上取整]或log(m+1)+1[下取整]
- 若完全二叉树中有n个结点,则
- 判断结点i是否有左孩子------2i+1<=n?
- 判断结点i是否有右孩子------2i+2<=n?
- 判断i是否是叶子/分支节点—i>(n-1)/2[下取整]?
- 若是根节点的序号为1
- 如果一颗二叉树不是完全二叉树,就不能按照以上方法进行存储,因为结点之间不再存在逻辑关系;但是可以使非完全二叉树的节点编号与完全二叉树的结点编号一一对应,就可以通过节点编号来确定结点间的逻辑关系,若是想判断i节点是否有左孩子或是右孩子,就只能通过数组的isEmpty来判断
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pK91TYTN-1683793669689)(imgs//非完全二叉树的存储.png#pic_center)]
结论:当一棵二叉树只含有右结点时会造成大量的空间浪费,因此顺序存储只适合存储完全二叉树
二叉树的链式存储
- 若是一棵二叉树有n个结点,则有2n个指针域;出了根节点以外的每一个结点都被一个指针相连,所以共有(n-1)个指针域被占用,(n+1)个空指针域
struct ElemType{
int value;
};
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
//定义一棵空树
BiTNode root=NULL;
//插入根节点
root=(BiTree)malloc(sizeof(BiTNode));
root->data={1};
root->lchild=NULL;
root->rchild=NULL;
//插入新节点
BiTNode *p=(BiTNode *)malloc(sizeof(BiTNode));
p->data={2};
p->lchild=NULL;
p->child=NULL;
root->lchild=p; //将p结点作为root的左孩子
- 优缺点分析
- 因为含有左右指针,所以可以很方便的访问某结点的左右孩子,但是若想访问其父节点,就必须从根节点开始遍历。因此可以定义一个指针指向其父节点,构造三叉链表
struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
struct BiTNode *parent;
}BiTNode,*BiTree;
二叉树的遍历
- 二叉树的遍历分为三种
- 先序遍历–根左右(NLR)
- 中序遍历–左根右(LNR)
- 后序遍历–左右根(LRN)
- 递归遍历算法的应用–求树的高度
int treeDepth(BiTree T){
if(T==NULL){
return 0;
}
else{
int l=treeDepth(T->lchild);
int r=treeDepth(T->rchild);
//树的深度=Max(左子树深度,右子树深度)+1
return l>r ? l+1 : r+1;
}
}
递归遍历
前序遍历
- 若是遍历的树的高度为h,则该算法的时间复杂度为O(h+1)/O(h)
typedef struct BiLNode{
ElemType data;
struct BiLNode *lchild,*rchild;
}BiLNode,*BiTree;
void PreOrder(BiTree T){
if(T!=NULL){
visit(T); //访问根节点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
}
中序遍历
typedef struct BiLNode{
ElemType data;
struct BiLNode *lchild,*rchild;
}BiLNode,*BiTree;
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
后序遍历
typedef struct BiLNode{
ElemType data;
struct BiLNode *lchild,*rchild;
}BiLNode,*BiTree;
void PostOrder(BiTree T){
if(T!=NULL){
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
}
层次遍历
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mvLmKEs1-1683793669698)(imgs//二叉树的层序遍历.png)]
- 算法思想
- 初始化一个辅助队列
- 根节点入队
- 若队列非空,则队头结点出队,访问该结点,并将其左右孩子插入队尾(如果有的话)
- 重复③直到队列为空
//二叉树结点(链式存储)
typedef struct BiTNode{
ElemType data;
struct BiLNode *lchild,rchild;
}BiLNode,*BiTree;
//创建链式队列结点
typedef struct LinkNode{
BiLNode *data;
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear; //队头队尾指针
}LinkQueue;
//初始化链式队列
void InitQueue(LinkQueue Q){
Q.front=Q.rear=(LinkNode*)malloc(sizeof(LinkNode));
Q.front->next=NULL;
}
//入队方法
void EnQueue(LinkQueue &Q,ElemType x){
LinkNode *p=(LinkNode*)malloc(sizeof(LinkNode));
p->data=x;
p->next=NULL;
rear->next=p;
rear=p;
}
//出队方法
void DeQueue(LinkQueue &Q,ElemType x){
if(Q.front==Q.rear){return false;}
LinkNode *p=Q.front->next; //因为链表是带头结点的,所以front指向头结点
x=p->data;
Q.front->next=p->next;
if(p == Q.rear){
Q.front=Q.rear;
}
free(p);
}
//层序遍历
void levelOrder(BiTree T){ //此时的树中只有一个根节点
LinkNOde Q;
InitQueue(Q);
BiTree p; //创建新的子鼠
EnQueue(Q,T); //将根节点入队
while(!isEmpty(Q)){
DeQueue(Q,p); //队头结点出队
visit(p); //访问出队结点
if(p->lchild != NULL){
EnQueue(Q,p->lchild); //将左子树入队
}
if(p->rchild != NULL){
EnQueue(Q,p->rchild);
}
}
}
线索二叉树
- 二叉树是一种逻辑结构,但线索二叉树是加上线索后的链表结构,即它是二叉树在计算机中的一种存储结构,所以是一种物理结构
- 二叉树在线索化后仍不能有效求解的问题是:后序线索二叉树中求后序后继
- 若一棵二叉树的前序序列和后序序列刚好相反,那么不可能存在一个结点同时有左右孩子
- 先序序列为a,b,c,d的不同二叉树的个数是(14)
根据前序遍历和中序遍历的算法中递归工作栈的状态变化得出:
前序序列和中序序列的关系相当于以前序序列的顺序入栈,以中序序列的顺序出栈。
因为前序序列和中序序列可以唯一确定一棵二叉树,所以题意相当于“以序列a,b,c,d为入栈次序,则出栈次序的个数为?”
对于n个不同元素进栈,出序列的个数为 1/(n+1)*C(n,2n)
- 在一棵具有n个结点的二叉树中,具有(n+1)个空指针域,为了方便多次访问某个结点遍历(前序/中序/后序)序列的前驱和后继,可以在使空指针域的指针指向其遍历序列的前驱和后继,构造线索二叉树
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QEycdp5X-1683793669698)(imgs//线索二叉树结构.png)]
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
/*
当tag=0时,表示指针指向的是孩子
当tag=1时,表示指针指向的是遍历序列的前驱或后继,即“线索”
*/
//线索二叉树的结点
typedef struct ThreadNode{
ELemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左右线索的标志
}ThreadNode,*ThreadTree;
中序线索化
- 在二叉中序线索树中,某结点若有左孩子,则按照中序“左根右”的顺序,该节点的前驱结点为左子树中最右的一个结点(不一定是最右叶节点)
//线索二叉树的节点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
struct ThreadNOde *ltag,*rtag;
}ThreadNode,*ThreadTree;
//定义全局变量
TreadNode *pre=NULL;
//中序遍历二叉树,一边遍历一边线索化
void InThread(BiTree T){
if(T!=NULL){
InThread(T->lchild);
visit(T);
InThread(T->rchild);
}
}
void visit(ThreadNode *p){
if(p->lchild==NULL){ //左子树为空,建立前驱线索
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL && pre->rchild==NULL){
pre->rchild=p; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=p;
}
//中序线索化二叉树T
void CreateInThread(ThreadTree T){
pre=NULL; //pre初始化NULL
if(T!=NULL){
InThread(T); //中序线索化二叉树
if(T->rchild==NULL){
T->rtag=1; //处理遍历的最后一个节点
}
}
}
//方法二
//中序线索化二叉树T
void CreatInThread(ThreadTree T){
ThreadTree pre=NULL;
if(T!=NULL){
InThread(T);
pre->rchild=NULL;
pre->rtag=1;
}
}
//中序线索化
void InThread(TnreadTree T,ThreadNode &pre){
if(T!=NULL){
InThread(T->lchild,pre); //递归遍历左子树,并将其线索化
if(p->lchild==NULL){ //左子树为空,建立前驱线索
p->child=pre;
p->ltag=1;
}
if(pre!=NULL && pre->rchild==NULL){
pre->rchild=p; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=p;
InThread(T->rchild,pre); //递归遍历右子树,并将其线索化
}
}
先序线索化
- 先序线索化和中序线索化基本相同,只需要改变访问节点的顺序即可。但要注意,当节点前驱线索化完成后,其左子树不再为NULL,继续向下执行会重新回到根节点,从而陷入无限循环,因此要添加限定条件
ThreadTree pre=NULL;
void CreatePreThread(ThreadThree T){
if(T!=NULL){
PreThread(T);
if(pre->rchild=NULL){
pre->rtag=1;
}
}
}
void PreThread(ThreadTree T){
if(T!=NULL){
visit(T);
if(T->ltag==0){
PreThread(T->lchild);
}
PreThread(T->rchild);
}
}
void visit(ThreadNode *p){
if(p->lchild==NULL){
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL && pre->rchild==NULL){
pre->rchild=p;
pre->rtag=1;
}
pre=p;
}
后序线索化
- 后序线索化代码逻辑和中序线索化相同,只是结点访问顺序不同
线索二叉树找前驱/后继
中序线索二叉树查找中序后继/前驱
- 此算法的时间复杂度为O(1)
/*
查找中序后继
*/
//以p为根的子树中,第一个被遍历的结点
ThreadNode *Firstnode(ThreadNode *p){
//循环得到最左下的结点(不一定是叶节点)
while(p->ltag==0){
return Firstnode(p->lchild);
}
return p;
}
//在中序线索二叉树中找到结点p的后继节点
ThreadNode *Nextnode(ThreadNode *p){
if(p->rtag==0){
Firstnode(p->rchild); //遍历得到右子树中最左下的结点
}
else{
return p->rchild; //rtag=1,直接返回其后继
}
}
//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法)
void InOrder(ThreadNode *T){ //传入的是根节点的指针T
for(ThreadNode *p=Firstnode(T);p!=NULL;p=Nextnode(p)){
visit(p);
}
}
/*
中序线索二叉树查找中序前驱
*/
//找到以p为根的子树中,最后一个被中序遍历的节点
ThreadNode *Lastnode(ThreadNode *p){
//循环找到最右下角的结点(不一定是叶节点)
while(p->rtag==0){
Lastnode(p->rchild);
}
return p;
}
//在中序线索二叉树中找到结点p的前驱结点
ThreadNode *Prenode(ThreadNode *p){
//左子树最右下的结点
if(p->ltag==0){
Lastnode(p->lchild);
}
else return p->lchild; //若ltag=1,直接返回前驱线索
}
//对中序线索二叉树进行逆向中序遍历
void RevInorder(ThreadNode *T){
for(ThreadNode *p=Lastnode(T);p!=NULL;p=Prenode(p)){
visit(p);
}
}
先序线索二叉树查找前驱/后继
ThreadNode *Firstnode(ThreadNode *p){
if(p->ltag==0){
Firstnode(p->)
}
}
后序线索二叉树找后续前驱
树、森林
树的存储结构
双亲表示法(顺序存储)
- 双亲表示法:每个结点中保存指向双亲的“指针”[双亲结点在数组中存放的位置下标]
#define MAX_TREE_SIZE 100 //树中最多节点数
typedef struct{ //树的节点定义
ElemType data; //数据元素
int parent; //双亲在数组中存放的位置下标
}PTNode;
typedef struct{ //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲标识
int n; //结点数
}PTree;
- 插入元素–可以直接添加到数组末尾,无需按逻辑上的次序存储
- 删除元素
- 方案一:将parent的值修改为-1,表示这个位置是空的
- 方案二:可以将尾部的数据上移填充空白
- 优点:查询指定结点的双亲更加方便
- 缺点:查指定结点的孩子只能从头遍历
孩子表示法(顺序+链式存储)
struct CTNode{ //保存孩子的数组下标
int child; //孩子结点在数组的位置
struct CTNode *next; //下一个孩子
};
typedef struct{ //结点的实际数据
ElemType data;
struct CTNode *firstChild; //第一个孩子
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n,r; //结点数和根的位置
}CTree;
孩子兄弟表示法(链式存储)![在这里插入图片描述](https://img-blog.csdnimg.cn/80a7fd038618466192af08863039d8f8.png#pic_center)
- 优点:可以用二叉树的操作来处理树
树和森林的遍历
树的遍历
- 树的遍历方式分为三种
- 先根遍历
- 后根遍历
- 层序遍历
- 先根遍历–若树非空,先访问根节点,再依次对每棵子树进行先根遍历
//树的先根遍历
void PreOrder(TreeNode *R){
if(R!=NULL){
visit(R); //访问根节点
while(R还有下一个子树T){
PreOrder(T); //先根遍历下一棵子树
}
}
}
- 后根遍历–若树非空,先依次对每棵子树进行后根遍历,最后再访问根节点
void PostOrder(TreeNode *R){
if(R != NULL){
while(R还有下一棵子树){
PostOrder(T);
}
visit(R);
}
}
- 树的先根遍历和后根遍历又被称为深度优先遍历
- 层序遍历(用队列实现)[广度优先遍历]
- 若树非空,则根节点入队
- 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
- 重复②直到队列为空
森林的遍历
- 森林:森林是m(m>=0)棵互不相交的树的集合。每棵树去掉根节点后,其各个子树又组成森林
- 森林的遍历分为两种
- 先序遍历
- 中序遍历
- 森林的先序遍历[效果等同于依次对各个树进行先根遍历]
- 若森林为非空,则按如下规则进行遍历
- 访问森林第一棵树的根节点
- 先序遍历第一棵树中根节点的子树森林
- 先序遍历除去第一棵树之后剩余的树构成的森林
- 森林的中序遍历[效果等同于依次对各个树进行后根遍历]
- 若树为非空,则按如下规则进行遍历
- 中序遍历森林中第一棵树的根节点的子树森林
- 访问第一棵树的根节点
- 中序遍历除去第一棵树之后剩余的树构成的森林
哈夫曼树
哈夫曼树的基本概念
- 结点的权–有某种现实含义的数值(如表示结点的重要性等)
- 结点的带权路径长度–从树的根到该节点的路径长度(经过的边数)与该节点上权值的乘积
- 树的带权路径长度–树中所有叶节点的带权路径长度之和
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-niB8muwA-1683793669704)(imgs//树的带权路径长度.png)] - 哈夫曼树(最优二叉树)–在含有n个带权叶结点的二叉树中,带权路径长度(WPL)最小的二叉树。上图的中间两棵树就是哈夫曼树
哈夫曼树的构造算法
- 同样的叶节点构造出的哈夫曼树可能会有所不同,但是它们的带权路径长度是相等的
哈夫曼编码
- 固定长度编码–每个字符用相等长度的二进制位表示
- 可变长度编码–允许对不同字符用不等长的二进制位表示
- 前缀编码–没有一个编码是另一个编码的前缀
- 哈夫曼编码–字符集中的每个字符作为一个叶子结点,每个字符出现的频度作为结点的权值构造哈夫曼树
第九章 图
常错考点
-
无向图的全部顶点的度的和等于边数的2倍
-
对于无向连通图,边最少即构成一棵树的情形;对于强连通有向图,边最少即构成一个有向环
-
n个顶点的生成树是具有n-1条边的极小连通子图
-
由n个顶点构成的完全无向图,需要n(n-1)/2条边
-
零元素个数
- 在一个含有n个顶点和e条边的无向图的邻接矩阵中,零元素的个数为(n²-2e)
- 详解:在邻接矩阵中,矩阵的大小为n²,因为有e条边,所以非零元素有2e个(无向图的边不区分方向)
- 在一个含有n个顶点和e条边的有向图的邻接矩阵中,零元素的个数为(n²-e)
- 详解:在邻接矩阵中,矩阵的大小为n²,因为有e条有向边,所以非零元素有e个
- 在一个含有n个顶点和e条边的无向图的邻接矩阵中,零元素的个数为(n²-2e)
-
无向图的邻接矩阵是对称矩阵
-
在存储邻接表时,顶点数n决定了顶点表结点的个数,边数e决定了边表结点的个数。无向图的每条边要存储两次,空间复杂度为O(n+2e);邻接矩阵的大小只与顶点个数有关,为O(n²)
-
若邻接表中有奇数个边表结点,则一定是有向图
- 无向图采用邻接表表示时,每条边存储两次,所以边表结点个数一定是偶数
-
n个顶点的无向图的邻接表最多有 n*(n-1) 个边表结点
-
若一个有n个顶点和e条边的有向图用邻接表表示,则删除与某个节点相关的所有边的时间复杂度为O(n+e)
- 与顶点v相关的有入边和出边
- 对于出边,只需要遍历v的顶点表结点和边表结点,出边数最多为n-1,时间复杂度为O(n)
- 对于入边,则需要遍历整个表,时间复杂度为O(n+e)
- 与顶点v相关的有入边和出边
-
任何一个无向连通图的最小生成树有一颗或多棵
-
用Prim算法和Kruskal算法构造图的最小生成树,所得到的的最小生成树可能相同也可能不同
-
只要无向连通图中没有权值相同的边,则其最小生成树唯一
-
最短路径一定是简单路径
-
深度优先遍历算法、拓扑排序和求关键路径都可以判断一个有向图是否有环
- 求最短路径不可以,因为求最短路径是允许图有环的
-
在拓扑排序算法中为暂存入度为零的顶点,既可以使用栈又可以使用队列
-
若一个有向图的顶点不能排成一个拓扑序列,则判定该有向图含有顶点数大于1的强连通分量
- 一个有向图中的顶点不能排成一个拓扑序列,表明其中存在一个顶点数目大于1的回路,该回路构成一个强连通分量
-
最小生成树的树形可能不一样(因为可能存在权值相同的边),但代价一定是唯一的
-
若用邻接矩阵存储有向图,矩阵中主对角线以下的元素均为零,说明此有向图一定是无环图,因此一定存在拓扑排序,但拓扑排序可能不唯一
-
从源点到汇点的邮箱路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动
-
当带权连通图中任意一个环中所包含的边的权值均不相同时,其MST(最小生成树,最小代价树)是唯一的
图的基本概念
-
图:由顶点集V和边集E组成,记为G=(V,E)。其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间的关系(边)集合。若V={v1,v2,v3,…,vn},则用
|V|
表示图G中顶点的个数,也称图G的阶,E={(u,v) | u∈V,v∈V},用|E|
表示图G中边的条数 -
线性表可以是空表,书可以是空树,但图不可以为空,即V一定是非空集
-
无向图和有向图
- 在有向图中,两个顶点的位置互换后表示的是两条不同的弧,而在无向图中表示的是同一条边
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uyclpgV4-1683793669705)(imgs//无向图和有向图.png)]
- 在有向图中,两个顶点的位置互换后表示的是两条不同的弧,而在无向图中表示的是同一条边
-
简单图和多重图
-
顶点的度
- 对于无向图–顶点v的度是指依附于该顶点的边的条数,记为TD(v)
- 无向图的全部顶点的度的和等于边数的2倍
- 对于有向图
- 入度:以顶点v为终点的有向边的数目,记为ID(v)
- 出度:以顶点v为起点的有向边的数目,记为OD(v)
- 顶点v的度等于其入度和出度的和,即TD(v)=ID(v)+OD(v)
- 有向图的入度和出度相等,都等于有向图的边数
- 对于无向图–顶点v的度是指依附于该顶点的边的条数,记为TD(v)
-
顶点与顶点的关系
- 路径:顶点v1到v2之间的一条路径是指顶点序列
- 回路:第一个顶点和最后一个顶点相同的路径称为回路或环
- 简单路径:在路径序列中,顶点不重复出现的路径称为简单路径
- 简单回路:除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路
- 路径长度:路径上边的数目
- 点到点的距离:从顶点u出发到顶点v的最短路径若存在,则此路径长度称为u到v的距离。若u到v根本不存在路径,则记该距离为无穷
- 无向图中,若顶点v到顶点w有路径存在,则称v和w是连通的
- 有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的
-
连通图:若图G中任意两个顶点是连通的,则称图G为连通图,否则就成为非连通图
- 对于n个顶点的无向图G
- 若G是连通图,则至少有n-1条边
- 若G是非连通图,则最多有C(2)(n-1)条边
- 对于n个顶点的无向图G
-
强连通图:若图中任何一对顶点都是强连通的,则称此图为强连通图
- 对于有n个顶点的有向图G,若G是强连通图,则至少有n条边(形成回路)
-
子图:设有两个图G=(V,E),G’(V’,E’),若V’是V的子集,且E’是E的子集,则称G’是G的子图
-
生成子图:若有满足V(G’)=V(G)的子图G’,则称其为G的生成子图(即子图包含了原图的所有顶点,对边没有要求)
-
连通分量:无向图中的极大连通子图称为连通分量
- 极大连通子图:子图必须连通,且包含尽可能多的顶点和边
-
强连通分量:有向图中的极大强连通子图称为有向图的强连通分量
- 极大强连通子图:子图必须强连通,同时保留尽可能多的边
-
生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图
- 若一个极小连通子图有n个顶点,则只需要n-1条边,且一个连通图的生成树不唯一
- 对于生成树而言,若是减少一条边就会变成非连通图,若是加上一条边,就会形成回路
-
生成森林:在非连通图中,连通分量的生成树构成了非连通图的生成森林
-
边的权:在一个图中,每条边都可以标上具有某种含义的数值,该数值也称为该边的权值
-
带权图/网:边上带有权值的图称为带权图,也称为网
-
带权路径长度:当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
-
几种特殊形态的图
-
无向完全图:无向图中任意两个顶点之间都存在边
- 若有n个顶点,则共有C(2)(n)条边
-
有向完全图:有向图中任意两个顶点之间都存在方向相反的两条弧
- 若有n个顶点,则共有2*C(2)(n)条边
-
稀疏图:边数很少的图
-
稠密图:边数很多的图
-
树:不存在回路,且连通的无向图
- 若有n个顶点,则有n-1条边
-
一个n个顶点的图,若|E|(边数)>n-1,则一定有回路
-
有向树:一个顶点的入度为0,其余顶点的入度均为1的有向图,称为有向树(不是强连通图)
-
图的存储
邻接矩阵法
- 邻接矩阵法
-
结点数为n的图G=(V,E)的邻接矩阵A是n*n的
- 若A[i][j]=1,说明(vi,vj)或<vi,vj>是E(G)中的边
- 若A[i][j]=0,说明(vi,vj)或<vi,vj>不是E(G)中的边
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sj3VMViu-1683793669708)(imgs//邻接矩阵法.png)]
-
在矩阵中求度(不带权)
- 在无向图中,第i个结点的度=第i行(或第i列)的非零元素(时间复杂度为O(n))
- 在有向图中,第i个结点的度=第i行的非零元素个数+第i列的非零元素个数(时间复杂度为O(n))
-
在带有权值的邻接矩阵中0或无穷代表与之对应的两个顶点之间不存在边
-
邻接矩阵法的性能分析
- 空间复杂度为O(|V|^2),只和顶点数有关,和实际边数无关
- 邻接矩阵比较适合存储稠密图
- 无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角区/下三角区)
-
- 邻接矩阵的性质
- 在无向图中,设图的矩阵为A(不带权),则An的元素An[i] [j]等于由顶点i到顶点j的长度为n的路径的数目
- 在无向图中,设图的矩阵为A(不带权),则An的元素An[i] [j]等于由顶点i到顶点j的长度为n的路径的数目
邻接表法(顺序+链式存储)
//顶点
typedef struct VNode{
VertexType data; //顶点信息
ArcNode *first; //第一条边/弧
}VNode,AdjList[MaxVertexNum];
//用邻接表存储图
typedef struct{
AdjList vertices;
int vexnum,arcnum; //图中的顶点数和边数
}ALGraph;
//边/弧
typedef struct ArcNode{
int adjvex; //边/弧指向那个结点
struct ArcNode *next; //指向下一条弧的指针
//InfoType info; //边权值
}ArcNode;
- 空间复杂度
- 在无向图中,边结点的数量是2*|E|,整体空间复杂度为O(|V|+2*|E|)
- 在有向图中,边结点的数量是|E|,整体空间复杂度为O(|V|+|E|)
- 顶点的度
- 在无向图中,只需要遍历和这个顶点相关的边链表即可,有多少个边结点,度就是多少。遍历边链表也就意味着可以找到和这个顶点相连的所有的边
- 在有向图中,出度可以由遍历和这个结点的相关的边链表得到;出度就必须遍历所有链表进行匹配
- 对于一个给定的图,确定编号后,它的邻接表表示并不唯一,但是邻接矩阵是唯一的
十字链表法和邻接多重表
-
十字链表法用于存储有向图,邻接多重表用于存储无向图
-
十字链表法性能分析
- 空间复杂度为O(|V|+|E|)
- 顺着绿色路线可以找到所有出边;顺着橙色路线可以找到所有入边
-
邻接多重表存储无向图
-
邻接多重表性能分析
- 空间复杂度为O(|V|+|E|)
- 删除边、删除结点等操作很方便
图的遍历
广度优先遍历(BFS)
-
图的广度优先遍历可以参考树的广度优先遍历(层次遍历),两者思想相同
-
区别
- 树不存在“回路”,搜索相邻近的结点时,不可能搜到已经访问过的结点
- 图在搜索相邻的节点时,有可能搜到已经访问过的顶点
-
要点
- 找到一个顶点相邻的所有顶点
- 标记那些顶点被访问过
- 需要一个辅助队列
-
bool visited[MAX_VERTEX_NUM]; //访问标记数组
//广度优先遍历
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
EnQueue(Q,v); //顶点v入队列Q
while(!isEmpty(Q)){
DeQueue(Q,v); //顶点v出队列
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
//检测v所有相邻结点
if(!visited[w]){ //w为v的尚未访问的相邻结点
visit(w); //访问节点w
visited[w]=TRUE; //对w做已访问标记
EnQueue(Q,w);
}
}
}
}
/*
但是如果是非连通图,则无法遍历完所有结点
思路:再从头开始遍历数组,找到第一个visited的值不为true的结点,再调用BFS算法
*/
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void BFSTraverse(Graph G){ //对图G进行广度优先遍历
for(int i=0;i<G.vexnum;i++){
visited[i]=FALSE; //对访问的标记数组进行初始化
}
InitQueue(Q); //初始化辅助队列Q
for(int i=0;i<G.vexnum;i++){ //从0号顶点开始遍历
if(!visited[i]){ //对每个连通分量调用一次BFS
BFS(G,i); //i结点未访问过,从i开始进行广度优先遍历
}
}
}
//广度优先遍历
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
EnQueue(Q,v); //顶点v入队列Q
while(!isEmpty(Q)){
DeQueue(Q,v); //顶点v出队列
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
//检测v所有相邻结点
if(!visited[w]){ //w为v的尚未访问的相邻结点
visit(w); //访问节点w
visited[w]=TRUE; //对w做已访问标记
EnQueue(Q,w);
}
}
}
}
-
结论:对于无向图,调用BFS函数的次数=连通分量数(极大连通子图)
-
遍历序列的可变性
- 同一个图的邻接矩阵表示方式唯一,因此广度优先遍历序列唯一
- 同一个图的邻接表表示方式不唯一,因此广度优先遍历序列不唯一
-
复杂度分析
- 空间复杂度:O(|V|)
- 时间复杂度
- 采用邻接矩阵存储的图:O(|V|²)
- 查找每个顶点的邻接点共需要O(|E|)的结点
- 采用邻接表存储的图:O(|V|+|E|)
- 查找各个顶点的邻接点需要O(|E|)的时间
- 访问|V|个顶点需要O(|E|)的时间
- 采用邻接矩阵存储的图:O(|V|²)
-
广度优先生成树
-
广度优先生成树的可变性
- 若是一个无向图采用邻接矩阵存储,那么他的广度优先生成树是唯一的
- 若是一个无向图采用邻接表存储,那么他的广度优先生成树是不唯一的
-
广度优先生成森林
深度优先遍历(DFS)
- 图的深度优先遍历算法和树的先根遍历算法类似
//最终版,解决了非连通图无法遍历完所有结点的问题
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void DFSTravese(Graph G){ //对图G进行深度优先遍历
for(i=0;i<G.vexnum;i++){
visited[i]=FALSE; //初始化标记数组
}
for(i=0;i<G.vexnum;i++){ //从v=0开始遍历
if(!visited[i]){
DFS(G,v);
}
}
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visit(v); //访问顶点v
visited[v]=TRUE; //修改已访问顶点的标记
for(w=FirstNeighbor(G,v);w>0;w=NextNeighbor(G,v,w)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
}
}
}
-
复杂度分析
- 空间复杂度:最好的情况下是O(1),最坏的情况是O(|V|)
- 时间复杂度
- 若采用邻接矩阵的方式存储,时间复杂度为O(|V|²)
- 若采用邻接表的方式存储,时间复杂度为O(|V|+|E|)
-
深度优先生成树
- 同一个图的邻接矩阵表示方法唯一,因此深度优先遍历序列唯一,深度优先生成树也唯一
- 同一个图邻接表表示方式不唯一,因此深度优先遍历序列不唯一,深度优先生成树也不唯一
-
图的遍历与图的连通性
- 对于无向图来说
- 调用BFS/DFS函数的次数=连通分量数(极大连通子图)
- 对于连通图,只需要调用一次BFS/DFS
- 对于有向图来说
- 对于有向图进行BFS/DFS遍历,调用BFS/DFS函数的次数要具体问题具体分析
- 若起始顶点到其他顶点都有路径,则只需要调用1次BFS/DFS函数
- 对于强连通图,从任一结点出发都只需要调用1次BFS/DFS
- 对于无向图来说
图的应用
最小生成树(最小代价树)
- 生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图
- 对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则称T为G的最小生成树
- 最小生成树可能有多个,但边的权值之和总是唯一且最小的
- 最小生成树的边数=顶点数-1。去掉一条边则不连通,增加一条边则会出现回路
- 如果一个连通图本身就是一棵树,择期最小生成树就是它本身
- 只有连通图才有生成树,非连通图只有生成森林
Prim算法(普里姆)
- 从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止
- 时间复杂度:O(|V|²),适合用于边稠密图
Kruskal算法(克鲁斯卡尔)
-
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通
-
时间复杂度:O(|E|log|E|)【以2为底】,适合用于边稀疏图
最短路径
单元最短路径
BFS算法(无权图)
- 无权图可以视为一种特殊的带权图,只是每条边的权值都为一
//求顶点u到其他顶点的最短路径
void BFS_MIN_Distance(Graph G,int u){
//d[i]表示从u到i结点的最短路径
for(i=0;i<G.vexnum;++i){
d[i]=INT_MAX; //初始化路径长度
path[i]=-1; //记录最短路径是有那个顶点过来
}
d[u]=0;
visited[u]=true;
EnQueue(Q,u);
while(!isEmpty(Q)){
DeQueue(Q,u); //队头元素u出队
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)){
if(!visited(w)){ //w为u的尚未访问的邻接顶点
d[w]=d[u]+1; //路径长度加1
path[w]=u; //最短路径应从u到w(如1号顶点是从2号顶点过来)
visited[w]=true; //设已访问标记
EnQueue(Q,w); //顶点w入队
}
}
}
}
Dijkstra算法(带权图、无权图)
-
带权路径长度:在带权图中,一条路径上所有边的权值之和,称为该路径的带权路径长度
-
时间复杂度:O(n²)即O(|V|²)
-
Dijkstra算法适合求解有回路的带权图的最短路径,也可以求任意两个顶点的最短路径;但不适用于求有负权值的带权图
-
Dijkstra算法详解
- 第1步:初始化距离,其实指与D直接连接的点的距离。dis[c]代表D到C点的最短距离,因而初始dis[C]=3,dis[E]=4,dis[D]=0,其余为无穷大。设置集合S用来表示已经找到的最短路径。此时,S={D}。现在得到D到各点距离**{D(0),C(3),E(4),F(*),G(*),B(*),A(*)}**,其中*代表未知数也可以说是无穷大,括号里面的数值代表D点到该点的最短距离。
- 第2步:不考虑集合S中的值,因为dis[C]=3,是当中距离最短的,所以此时更新S,S={D,C}。接着我们看与C连接的点,分别有B,E,F,已经在集合S中的不看,dis[C-B]=10,因而dis[B]=dis[C]+10=13,dis[F]=dis[C]+dis[C-F]=9,dis[E]=dis[C]+dis[C-E]=3+5=8>4**(初始化时的dis[E]=4)不更新。此时{D(0),C(3),E(4),F(9),G(*),B(13),A(*)}。**
- 第3步:在第2步中,E点的值4最小,更新S={D,C,E},此时看与E点直接连接的点,分别有F,G。dis[F]=dis[E]+dis[E-F]=4+2=6(比原来的值小,得到更新),dis[G]=dis[E]+dis[E-G]=4+8=12(更新)。此时**{D(0),C(3),E(4),F(6),G(12),B(13),A(*)}**。
- 第4步:在第3步中,F点的值6最小,更新S={D,C,E,F},此时看与F点直接连接的点,分别有B,A,G。dis[B]=dis[F]+dis[F-B]=6+7=13,dis[A]=dis[F]+dis[F-A]=6+16=22,dis[G]=dis[F]+dis[F-G]=6+9=15>12(不更新)。此时**{D(0),C(3),E(4),F(6),G(12),B(13),A(22)}.**
- 第5步:在第4步中,G点的值12最小,更新S={D,C,E,F,G},此时看与G点直接连接的点,只有A。dis[A]=dis[G]+dis[G-A]=12+14=26>22(不更新)。{D(0),C(3),E(4),F(6),G(12),B(13),A(22)}.
- 第6步:在第5步中,B点的值13最小,更新S={D,C,E,F,G,B},此时看与B点直接连接的点,只有A。dis[A]=dis[B]+dis[B-A]=13+12=25>22(不更新)。{D(0),C(3),E(4),F(6),G(12),B(13),A(22)}.
- 第7步:最后只剩下A值,直接进入集合S={D,C,E,F,G,B,A},此时所有的点都已经遍历结束,得到最终结果**{D(0),C(3),E(4),F(6),G(12),B(13),A(22)}.**
各顶点之间的最短路径
Floyd算法(带权图、无权图)
/**Floyd算法核心代码**/
//准备工作,根据图的信息初始化矩阵A和path
for(int k=0;k<n;k++){ //考虑以vk作为中转点
for(int i=0;i<n;i++){ //遍历整个矩阵,i为行号,j为列号
for(int j=0;j<n;j++){
if(A[i][j]>A[i][k]+A[k][j]){ //以vk为中转点的路径更短
A[i][j]=A[i][k]+A[k][j]; //更新最短路径长度
path[i][j]=k; //中转点
}
}
}
}
- 时间复杂度为:O(|V|^3);空间复杂度为:O(|V|²)
- Floyd算法可以用于求解带负权值的带权图;但是Floyd算法不能解决带有“负权回路”的图(有负权值的边组成的回路),这种图可能没有最短路径。
有向无环图描述表达式
- 有向无环图:若一个有向图中不存在环,则称为有向无环图,简称DAG图
拓扑排序和逆拓扑排序
拓扑排序
-
AOV网:用DAG图(有向无环图)表示一个工程,顶点表示活动,有向边<Vi,Vj>表示活动Vi必须先于Vj进行。AOV网中不允许存在环路
-
拓扑排序:在图论中,有一个有向无环图的顶点组成的序列,当且仅当满足以下条件时,成为该图的一个拓扑排序
- 每个顶点出现且只出现一次
- 若顶点A在序列中排在顶点B的前面,则在图中不存在顶点B到顶点A的路径
- 每个AOV网都有一个或多个拓扑排序
-
拓扑排序的实现
- 从AOV网中选择一个没有前驱(入读为0)的顶点并输出
- 从网中删除该顶点和所有以它为起点的有向边
- 重复1和2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止(说明有回路)
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧指向的顶点的位置
struct ArcNode *nextarc; //指向下一条弧的指针
//InfoType info; //网的边权值
}ArcNode;
typedef struct VNode{ //顶点表结点
VertexType data; //顶点信息
ArcNode *firstarc; //指向第一条边依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];
typedef struct{
AdjList vertices; //邻接表
int vexnum,arcnum; //图的顶点数和弧数
}Graph; //Graph是以邻接表存储的图类型
bool TopologicalSort(Graph G){
InitStack(S); //初始化栈,存储入度为0的顶点
for(int i=0;i<G.vexnum;i++){
if(indegree[i]==0){
Push(S,i); //将所有入度为0的顶点进栈
}
}
int count=0; //计数,记录当前已经输出的顶点数
while(!isEmpty(S)){ //栈不空,则存在入度为0的顶点
Pop(S,i); //栈顶元素出栈
print[count++]=i; //输出顶点i
for(p=G.vertices[i].firstarc;p;p=p->nextarc){
//将所有i指向的顶点入度减1,并且将入度减为0的点压入栈S
v=p->adjvex;
if(!(--indegree[v])){
Push(S,v); //入度为0,则入栈
}
}
}
if(count<G.vexnum){
return false; //排序失败,有向图中有回路
}
else{
return true; //拓扑排序成功
}
}
- 时间复杂度
- 若采用邻接表存储,则需O(|V|+|E|)
- 若采用邻接矩阵存储,则需O(|V|²)
逆拓扑排序
- 对一个AOV网,如果采用下列步骤进行排序,则称为逆拓扑排序
- 从AOV网中选择一个没有后继(出度为0)的顶点并输出
- 从网中删除该顶点和所有以它为终点的有向边
- 重复1和2直到当前的AOV网为空
/**逆拓扑排序的实现(DFS算法)**/
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0;v<G.vexnum;++v){
visited[v]=false; //初始化标记数组
}
for(v=0;v<G.vexnum;++v){
if(!visited[v]){
DFS(G,v);
}
}
}
void DFS(Graph G,int v){ //顶点从v出发,深度优先遍历图G
visited[v]=true; //修改标记为已访问
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbot(G,v,w)){
if(!visited[w]){ //w为u的未访问的邻接顶点
DFS(G,w);
}
}
print(v); //输出顶点
}
关键路径
- AOE网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(完成活动所需的时间),称之为用边表示活动的网络,称为AOE网
- AOE网的性质
- 只有某顶点所代表的的事件发生后,从该顶点出发的各有向边所代表的的活动才能开始
- 只有进入某顶点的各有向边所代表的的活动都已结束,该顶点所代表的的时间才能发生。另外,有些活动是可以并行进行的
- 在AOE我那个中仅有一个入度为0的顶点称为开始顶点(源点),它表示整个工程的开始;也仅有一个出度为0的顶点,称之为结束顶点(汇点),它表示整个工程的结束
- 从源点到汇点的邮箱路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动
- 完成整个工程的最短时间就是关键路径长度,若关键活动不能按时完成,则整个工程的完成时间就会延长
- 概念
- 事件Vk最早发生时间Ve(k):决定了所有从Vk开始的活动能够开工的最早时间
- 事件Vk最迟发生时间Vl(k):它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间
- 活动Ai的最早开始时间e(i):指该活动弧的起点所表示的时间的最早发生时间
- 活动Aj的最迟开始时间l(i):它是指该活动弧的终点所表示的事件的最迟发生时间与该活动所需时间之差
- 活动Ai的时间余量d(i)=l(i)-e(i):表示在不增加整个工程所需总时间的情况下,活动Ai可以拖延的时间
- 若一个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0即l(i)=e(i)的活动Ai是关键活动
- 有关键活动组成的路径就是关键路径
- 关键活动、关键路径的特性
- 若关键活动增加,整个工程的工期将增长
- 缩短关键活动的时间,可以缩短整个工程的工期
- 当缩短到一定程度时,关键活动可能会变为非关键活动
- 可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的
第十章 查找
易错考点
- 对于顺序查找,不论线性表是有序还是无序的,查找成功的平均查找时间都是相同的。因为元素查找成功的次数只与其位置有关,与是否有序无关
- 折半查找的性能分析可以用二叉排序树来判定衡量。但是二叉排序树的查找性能与数据的输入顺序有关,最好情况下的平均查找长度与折半查找相同;最坏的情况是形成单枝树
查找的基本概念
- 查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找
- 查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
- 关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的
- 查找表的分类
- 静态查找表–只需要查找符合条件的数据元素,不会进行增删
- 适合的查找方法有顺序查找、折半查找、散列查找等
- 动态查找表–急需要查找数据元素又需要插入/删除某个数据元素
- 适合额的查找方法有二叉排序树、散列查找等
- 静态查找表–只需要查找符合条件的数据元素,不会进行增删
- 查找长度–在查找运算中,需要对比关键字的次数称为查找长度
- 平均查找长度(ASL):所有查找过程中进行关键字的比较次数的平均值
- ASL的数量级反映了查找算法的时间复杂度
顺序查找
- 顺序查找:又叫“线性查找”,通常用于线性表(顺序表,链表)
typedef struct{ //查找表的数据结构(顺序表)
ELemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//顺序查找
int Search_Seq(SSTable ST,ElemType key){
int i;
for(i=0;i<ST.TableLen && ST.elem[i] != key;i++);
//查找成功,则返回元素下标;查找失败,则返回-1
return i==ST.TableLen ? -1 : i;
}
//顺讯查找的实现(哨兵)
/*
思想:数据表从下标为1的位置开始存储,0号位置用被查元素作为哨兵进行填充
优点:无需判断是否越界,效率更高
查找成功或失败的界定:若是查找表中存在被查关键字key,那么返回的元素下标就不可能为0;
若是返回的元素下标为0,则说明查找表中不包含关键字key(0号位存储的是哨兵)
*/
typedef struct{
ElemType *elem;
int TableLen;
}SSTable;
int Search_Seq(SSTable ST,ElemType key){
ST.elem[0]=key; //“哨兵”
int i;
for(i=ST.TableLen;ST.elem[i] != key; i--); //从后往前找
return i; //查找成功,则返回元素下标;查找失败,则返回0
}
-
用查找判定树分析ASL
- 一个成功结点的查找长度=自身所在层数
- 一个失败结点的查找长度=其父节点所在层数
- 默认情况下,各种失败情况或成功情况都等可能发生
-
顺序查找的优化(被查概率不相等)
- 可以将被查概率更大的数据元素放在靠前的位置
- 可以将被查概率更大的数据元素放在靠前的位置
折半查找
- 折半查找:又称“二分查找”,仅适用于有序的顺序表
- 链表是没有办法实现折半查找的,因为链表不具备随机存取的特性
typedef struct{
Elem *elem;
int TableLen;
}SSTable;
//折半查找
int Binary_Search(SSTable L,ElemType key){
int low=0;high=L.TableLen-1,mid;
while(low<=high){
mid=(low+high)/2;
if(L.elem[mid]==key){
return mid;
}
else if(L.elem[mid] > key){
high=mid-1;
}
else{
low=mid+1;
}
}
return -1;
}
-
查找效率分析
-
查找判定树的构造
- 在折半查找判定树中,若mid=(low+high)/2,则对于任何一个结点,必有右子树结点数-左子树结点数=0或1,即左子树结点数不可能比右子树结点数大,而右子树结点数最多被左子树结点数大1
- 折半查找判定树一定是平衡二叉排序树
- 折半查找判定树中,只有最下面一层是不满的。因此,元素个数为n时,树高h=log(n+1)[向上取整]
- 判定失败结点的数量=空针指域的数量,都有n+1个
- 对于有n个结点的二叉树来说,一共会有n+1个空指针域
- 对于有n个结点的二叉树来说,一共会有n+1个空指针域
-
对于折半查找来说,不论查找失败还是成功,ASL(平均查找长度)都是小于等于树高h的
-
折半查找的时间复杂度=O(logn)
分块查找(常考于选择题)
- 分块查找,又称索引顺序查找
- 在索引表中确定待查记录所属的分块(可顺序查找,可分块查找)
- 在块内顺序查找
- 若索引表中不包含目标关键字,则折半查找索引表最终停在low>high,要在low所指的分块中查找
- 原因:最终low左边一定小于目标关键字,high右边一定大于目标关键字。而分块存储的索引表中保存的是各个分块的最大关键字
- 原因:最终low左边一定小于目标关键字,high右边一定大于目标关键字。而分块存储的索引表中保存的是各个分块的最大关键字
//索引表
typedef struct{
ELemType maxValue;
int low,high;
}Index;
//顺序表存储实际元素
ElemType List[100];
二叉排序树(BST)
- 二叉排序树,又称二叉查找树(BST)
- 左子树结点值 < 根节点值 < 右子树结点值
- 左子树上所有结点的关键字均小于根节点的关键字
- 右子树所有结点的关键字均大于根节点的关键字
- 左子树和右子树又各是一棵二叉排序树
- 进行中序遍历,可以得到一个递增的有序序列
- 二叉排序树可用于元素的有序组织和搜索
- 左子树结点值 < 根节点值 < 右子树结点值
- 二叉排序树的查找
- 若树非空,目标值与根节点的值比较
- 若相等,则查找成功
- 若小于根节点,则在左子树上查找;否则在右子树上查找
- 若查找成功,返回结点指针;查找失败返回NULL
//二叉排序树结点
typedef struct BSTNode{
int key;
struct BSTNode *lchild,*rchild;
}BSTNode,*BSTree;
//在二叉排序树中查找值为 key 的结点(非递归实现)
BSTNode *BST_Search(BSTree T,int key){
while(T !=NULL && key!=T->key){ //若树空或等于根节点的值,则循环结束
if(key < T->key){
T=T->lchild;
}
else{
T=T->rchild;
}
}
return T;
}
//在二叉排序树中查找值为 key 的结点(递归实现)
BSTNode *BSTSearch(BSTree T,int key){
if(T == NULL){
return NULL; //查找失败
}
if(key == T->key){
return T; //查找成功
}
else if(key < T->key){
return BSTSearch(T->lchild,key);
}
else{
return BSTSearch(T->rchild,key);
}
}
- 二叉排序树的空间复杂度
- 非递归实现=O(1)
- 递归实现=O(h)[h是二叉排序树的树高]
- 二叉排序树的查找是自上而下的,其平均查找长度主要取决于树的高度
- 二叉排序树的插入
//在二叉排序树中插入关键字为 k 的新节点(递归实现)
int BST_Insert(BSTree &T,int k){
if(T==NULL){ //原树为空,新插入的结点为根节点
T=(BSTree)malloc(sizeof(BSTNode));
T->key=k;
T->lchild=T->rchild=NULL;
return 1;
}
else if(k=T->key){
return 0; //树中存在相同关键字的结点,插入失败
}
else if(k < T->key){
return BST_Insert(T->lchild,k);
}
else{
return BST_Insert(T->rchild,k);
}
}
- 空间复杂度=O(h)[h为树的高度]
- 二叉排序树的构造
//按照str[]中的关键字序列建立二叉排序树
void Creat_BST(BSTree &T,int str[],int n){
T=NULL; //初始T为空树
int i=0;
while(i<n){ //遍历str数组,将每一个元素插入到二叉排序树中
BST_Insert(T,str[i]);
i++;
}
}
- 二叉排序树的删除
- 若被删除结点z是叶节点,则直接删除,不会破坏二叉排序树的性质
- 若结点z只有一棵左子树或右子树,则让z的子树成为z的父节点的子树,替代z的位置
- 若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成第一或第二种情况
- z的直接后继–z的右子树最左下的结点,即该子树中序遍历的第一个结点(该结点一定没有子树)
- z的直接前驱–z的左子树最右下的结点,即中序遍历的最后一个结点(该结点一定没有右子树)
- 若一棵二叉排序树树高为h,则找到最下层的一个结点需要对比h次
- 最好情况:n个结点的二叉排序树最小高度为(logn+1)[向下取整]{ 或 log(n+1) [向上取整] },平均查找长度=O(logn)
- 最坏情况:每个节点只有一个分支,树高h=结点数n,平均查找长度=O(n)
- 给定一个序列,创建最佳的二叉排序树
- 当各关键字的查找概率相同时,最佳二叉排序树应是高度最小的二叉排序树
- 构造过程
- 对关键字按值从小到大排序
- 按照折半查找的判定树的构造方法构造二叉排序树
平衡二叉树(AVL)
平衡二叉树的定义
- 平衡二叉树:简称平衡树(AVL树),树上任一结点的左子树和右子树高度之差不超过1
- 结点的平衡因子=左子树高-右子树高
- 平衡二叉树结点的平衡因子的值只可能是 -1、0或1
- 只要有任一结点的平衡因子的绝对值大于1,就不是平衡二叉树
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rf3BPkdi-1683793669721)(imgs//平衡二叉树–平衡因子.png)]
//平衡二叉树结点
typedef struct AVLNode{
int key; //数据域
int balance; //平衡因子
struct AVLNode *lchild,*rchild;
}AVLNode,*AVLTree;
-
在插入一个新节点以后,查找路径上的所有结点都有可能受到影响;从插入的点往回找到第一个不平衡结点,调整该节点为根的子树
- 每次调整的对象都是最小不平衡子树
- 在插入操作中,只要将不平衡子树调整平衡,则其他祖先结点都会恢复平衡
- 因为插入操作会导致“最小不平衡子树”的高度+1,经调整后高度会恢复
- 因为插入操作会导致“最小不平衡子树”的高度+1,经调整后高度会恢复
-
调整最小不平衡子树(左孩子右旋,右孩子左旋)
-
LL–在A的左孩子的左子树中插入导致不平衡
/* gf是指向A结点的父节点的指针,A结点即可能是它父节点的左孩子,又有可能是右孩子 f是指向A结点的指针 p是指向B结点的指针 */ //实现f向右下旋转,p向右上旋转 f->lchild=p->rchild; p->rchild=f; gf->lchild/rchild=p;
-
RR–在A的右孩子的右子树中插入导致不平衡
//实现f向左下旋转,p向左上旋转 f->rchild=p->lchild; p->lchild=f; gf->lchild/rchild=p;
-
LR–在A的左孩子的右子树中插入导致不平衡
-
RL–在A的右孩子的左子树中插入导致不平衡
-
-
查找效率分析
- 若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度(ASL)不可能超过O(h)
- 若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度(ASL)不可能超过O(h)
平衡二叉树的删除
- 平衡二叉树的删除操作
- 删除节点后,要保持二叉排序树的特性不变
- 若删除节点导致不平衡,则需要调整平衡
B树
-
对于m叉树来说,若是每个结点内关键字太少,导致树变高,要查更多层结点,效率低
-
保证查找效率
- 在m叉树中,规定除了根节点外,任何结点至少有(m/2)[向上取整]个分叉,即至少含有(m/2-1)[向上取整]个关键字
- 例如:对于5叉排序树,规定除了根节点外,任何结点都至少有3个分叉,2个关键字
- 在m叉查找树中,规定对于任何一个结点,其所有子树的高度都要相同
- 在m叉树中,规定除了根节点外,任何结点至少有(m/2)[向上取整]个分叉,即至少含有(m/2-1)[向上取整]个关键字
-
B树:又称多路平衡查找树,B树中所有节点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树要么是一棵空树,要么是满足如下特性的m叉树
- 树中每个结点至多有m棵子树,即至多含有(m-1)个关键字
- 若根节点不是终端节点,则至少有两棵子树
- 除根节点外的所有非叶节点至少有(m/2)[向上取整]棵子树,即至少含有(m/2-1)[向上取整]个关键字
- 所有的叶节点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)
-
m阶B树的核心特性
- 根节点的子树数∈[2,m],关键字数∈[1,m-1];其他节点的子树数∈[m/2,m];关键字数∈[m/2-1,m-1]
- 对任一结点,其所有子树高度都相同
- 关键字的值:子树0 < 关键字1 < 子树1 < 关键字2 < 子树2 < … (类比二叉查找树(又叫二叉排序树) 左<中<右)
-
B树的高度(通常来说算B树的高度都是不包括叶子结点的【失败结点】)
-
B树的插入
- 新元素一定是插入到最底层“终端节点”,用“查找”来确定插入位置
- 在插入key后,若导致原结点关键字数查过上限,则从中间位置m/2[向上取整]将关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新节点中,中间位置m/2[向上取整]的结点插入原结点的父节点
- 若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根节点为止,进而导致B树的高度加1
-
B树的删除
- 若被删除的关键字在非终端结点,则用直接前驱或直接后继来替代被删除的关键字
- 直接前驱:当前关键字左侧指针所指子树中“最右下”的元素
- 直接后继:当前关键字右侧指针所指子树中“最左下”的元素
- 若是删除节点后导致关键字数低于下限
- 兄弟够借。若被删除关键字所在结点删除前的关键字个数低于下限,且与此结点右(或左)兄弟结点的关键字个数还很宽裕,则需要调整该结点、右(或左)兄弟节点及其双亲结点(父子换位法)
- 兄弟不够借。若被删除关键字所在结点删除前的关键字个数低于下限,且此时与该结点相邻的左、右兄弟节点的关键字个数均等于(m/2-1),则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并
- 若被删除的关键字在非终端结点,则用直接前驱或直接后继来替代被删除的关键字
-
对于非终端节点的删除操作必然可以转化为对终端结点的删除操作
B+树
-
m阶B+树的查找比较类似于多级分块查找
-
一棵m阶的B+树满足以下条件
- 每个分支结点最多有m棵子树(孩子结点)
- 非叶根节点至少有两棵子树,其他每个分支结点至少有(m/2)[向上取整]棵子树
- 结点的子树个数与关键字个数相等
- 注意与B树的区别,B树的结点的子树个数等于关键字个数加1
- 所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互连接起来(支持顺序查找)
- 所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针
-
在B+树中,无论查找成功与否,最终一定都要走到最下面一层结点
-
B+树和B树的区别,针对m阶树进行讨论
B+树 | B树 |
---|---|
结点中的n个关键字对应n棵子树 | 结点中的n个关键字对应n+1棵子树 |
最多m个分支 根节点的关键字数n∈[1,m] 其他结点的关键字数n∈[m/2,m](m/2向上取整) | 最多m个分支 根节点的关键字数∈[1,m-1] 其他结点的关键字数∈[m/2-1,m-1](m/2向上取整) |
叶结点中包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中 | 各结点中包含的关键字是不重复的 |
叶结点包含信息,所有非叶节点只起到索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址 | 叶结点中包含了关键字对应的记录的存储地址 |
支持顺序查找和随机查找 | 支持随机查找 |
- 在B+树中,非叶结点不含有该关键字对应记录的存储地址。可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快;而B树的结点中都包含了关键字对应的记录的存储地址
- 在实际应用中,B+树比B树更加适合于操作中系统的文件索引和数据库索引
散列查找
- 散列表:又称哈希表,是一种数据结构
- 特点:数据元素的关键字与其存储地址直接相关
- 通过散列函数(哈希函数)实现
- 若不同的关键字通过散列函数映射到同一个值,则称它们为同义词
- 通过散列函数确定的位置已经存放了其他元素,则称这种情况为冲突
- 常见的散列函数
- 除留余数法–H(key)=key % p
- 假设散列表的表长为m,取一个不大于m但最接近或等于m的质数p(取的p为质数的话,可以让关键字的冲突尽可能的少,分布更加均匀)
- 直接定址法–H(key)=key 或 H(key)=a*key+b
- a和b是常数,这种方法计算更加简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不均匀,空位较多,则会造成存储空间的浪费
- 数字分析法–选取数码分布较为均匀的若干位作为散列地址
- 设关键字为r进制(如十进制),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,这种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布比较均匀的若干位作为散列地址。这种方法适合已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数
- 平方取中法–取关键字的平方值的中间几位作为散列地址
- 具体取多少位要视情况而定。这种方法取得的散列地址与关键字的每一位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值不够均匀或均小于散列地址所需的位数
- 除留余数法–H(key)=key % p
- 散列查找是典型的“用空间换时间”的算法,只要散列函数设计的合理,则散列函数越长,冲突的概率越低
- 在散列表中,平均查找长度与装填因子直接相关,表的查找效率不直接依赖于表中已有表项个数或表长
冲突处理(重点)
-
拉链法(链接法、链接地址法)处理冲突
-
空指针的判断不会算作一次
-
用拉链法处理冲突就是将所有的同义词存储在一个链表中
-
装填因子=表中记录数 / 散列表长度
- 描述散列表存储数据的程度,装填因子越大,散列表存储的数据越多
- 装填因子的大小会直接影响散列表的查找效率
-
链地址法处理冲突时将同义词放在同一个链表中,不会引起聚集现象
-
-
开放定址法处理冲突
-
开放定址法是指可存放新表项的空闲地址即向它的同义词表项开放,又向他的非同义词表项开放
-
线性探测法很容易造成同义词、非同义词的聚集(堆积)现像,严重影响查找效率(产生原因:冲突后再探测一定是放在某个连续位置)
-
在平方探测法中,散列表长度m必须是一个可以表示成(4j+3)的素数,才能探测到所有位置
========
========
-
-
在采用开放定址法时,删除节点不能简单地将被删节点的空间置为空,否则将截断在它之后填入散列表的同义词结点的查找路径,可以做一个“删除标记”,进行逻辑删除
第十一章 排序
- 算法的稳定性
- 关键字相同的元素在排序之后相对位置不变,就称其为稳定的
- 关键字相同的元素在排序之后相对位置改变,就称其为不稳定的
- 排序算法的分类
- 内部排序–数据在内存中
- 外部排序数据太多,无法全部放入内存
- 对于任意序列进行基于比较的排序,求最少的比较次数因考虑最坏的情况。
- 对任意n个关键字排序的比较次数至少为log(n!) [以2为底,向上取整]
- 内部排序算法总结
- 从时间复杂度来看
- 简单选择排序、直接插入排序和冒泡排序平均情况下的时间复杂度都为O(n²),但直接插入排序最好情况下的时间复杂度可以达到O(n),而简单选择排序与序列的初始状态无关
- 希尔排序作为插入排序的拓展,对较大规模的数据可以达到很高的效率
- 堆排序可以在线性时间内完成建堆,在O(nlog2n)内完成排序
- 从空间复杂度来看
- 简单选择排序、插入排序、冒泡排序、希尔排序和堆排序都只需要常数级的辅助空间
- 快速排序需要一个递归工作栈,平均大小为O(log2n),最坏情况下会增长到O(n)
- 从稳定性来看
- 插入排序、冒泡排序、归并排序和基数排序是稳定的排序算法
- 简单选择排序、快速排序、希尔排序和堆排序是不稳定的排序算法
- 平均时间复杂度为O(nlog2n)的稳定排序算法只有归并排序
- 从时间复杂度来看
插入排序
- 算法思想:每次将一个待排序的记录按照关键字大小插入前面已排好序的子序列中,直到全部记录插入完成
//直接插入排序
void InsertSort(int A[],int n){
int i,j,temp;
for(i=1;i<n;i++){ //将各元素插入已排好序的序列中
if(A[i]<A[i-1]){ //若关键字A[i]关键字小于前驱
temp=A[i]; //用temp暂存A[i]
for(j=i-1;j>0 && A[j]>temp;i--){ //检查所有前面已排好序的元素
A[j+1]=A[j]; //将所有大于temp的元素都向后挪位
}
A[j+1]=temp; //复制到插入位置
}
}
}
//直接插入排序(带哨兵)
void InsertSort(int A[],int n){
int i,j;
for(i=2;i<=n;i++){ //依次将A[2]~A[n]插入到前面已排序序列
if(A[i]<A[i-1]){ //若A[i]的关键字小于其前驱,将A[i]插入有序表
A[0]=A[i]; //复制为哨兵,A[0]不存放元素
for(j=i-1;A[0]<A[j];i--){ //从后往前查找待插入位置
A[j+1]=A[j]; //向后移位
}
A[j+1]=A[0]; //复制到插入位置
}
}
}
- 算法效率
- 空间复杂度:O(1)
- 时间复杂度:最好的情况是序列有序,时间复杂度为O(n),最坏的情况是序列逆序,时间复杂度为O(n²);平均时间复杂度为O(n²)
- 算法的稳定性:稳定
- 直接插入排序在最坏的情况下要做n(n-1)/2次关键字的比较;最好的情况下要进行n-1次比较
- 优化–折半插入排序
- 折半插入排序相对于直接插入排序优化的只是比较的次数,而移动次数并未发生变化
- A[mid]==A[0]是为了保证算法的稳定性
- 时间复杂度为O(n²)
//折半插入排序
void InsertSort(int A[],int n){
int i,j,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]; //插入操作
}
}
希尔排序
- 希尔排序:先追求表中元素部分有序,再逐渐逼近全局有序
//希尔排序
void ShellSort(int A[],int n){
int d,i,j;
//A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
for(d=n/2;d>=1;d=d/2){ //步长变化,n为关键字个数
for(i=d+1;i<=n;i++){
if(A[i]<A[i-d]){ //需要将A[j]插入有序增量子表
A[0]=A[i]; //暂存A[0]
for(j=i-d;j>0 && A[0]<A[j];j-=d){
A[j+d]=A[j]; //记录后移
}
A[j+d]=A[0]; //插入
}
}
}
}
//代码详解
/*
如果A[i]<A[i-d],就先将A[i]的值复制到A[0],再比较A[0]和A[i-d]的大小,
若A[0]<A[j],则A[i]=A[j],最后再让A[j+d]=A[0]
例:n=8,d=n/2=4
第一次循环:i=5,j=i-d=1
A[0]=A[5]=>进入for循环,判断A[0]是否小于A[1]=>若成立则A[5]=A[1],j=j-d=1-4=-3
不符合条件j>0,跳出for循环=>此时j=-3,所以j+d=1,A[1]=A[0]
*/
if(A[i]<A[i-d]){
//需要将A[j]插入有序增量子表
A[0]=A[i]; //暂存A[0]
for(j=i-d;j>0 && A[0]<A[j];j-=d){
A[j+d]=A[j]; //记录后移
}
A[j+d]=A[0]; //插入
}
- 性能分析
- 空间复杂度为O(1)
- 时间复杂度:时间复杂度和增量序列d的选取有关,目前无法用数学手段证明确切的时间复杂度。最坏的时间复杂度为O(n²)
- 希尔排序是不稳定的
- 希尔排序仅适用于顺序表,不适用于链表
交换排序–冒泡排序
- 在冒泡排序中,若有一趟排序没有发生交换,则说明此序列已整体有序
//交换
void Swap(int &a,int &b){
int temp=a;
a=b;
b=tmep;
}
//冒泡排序
void BubbleSort(int A[],int n){
for(int i=0;i<n-1;i++){ //i之前的位置是已经排好序的
bool 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)
- 时间复杂度
- 最好的情况是序列本就是有序的,时间复杂度为O(n)
- 最坏的情况是序列是逆序的吗,时间复杂度为O(n²)
- 冒泡排序是稳定的
- 足以区分移动元素的次数和交换次数
- 每交换一次就会移动三次元素
- 冒泡排序也适用于链表
快速排序
- 选取枢轴(基准)元素,比枢轴元素打的移动到high的右边,比枢轴元素小的移动到low的左边,当low和high相等时,就是枢轴元素的存放位置
//用第一个元素将待排序序列划分成左右两个部分
int Partition(int A[],int low,int high){
int pivot=A[low]; //选取第一个元素作为枢轴
while(low<high){
while(low<high && A[high]>=pivot){
high--;
}
A[low]=A[high]; //比枢轴元素小的移动到左端
while(low<high && A[low]<=pivot){
low++;
}
A[high]=A[high]; //比枢轴元素大的移动到右端
}
A[low]=pivot; //将枢轴元素存放到最终位置
return low; //返回枢轴元素的最终位置
}
//快速排序
void QuickSort(int A[],int low,int high){
if(low<high){ //递归跳出的条件
int pivotpos=Partition(A,low,high); //划分
QuickSort(A,low,pivotpos-1); //划分左子表
QuickSort(A,pivotpos-1,high); //划分右子表
}
}
-
算法效率分析
- 每一层函数调用只需要处理剩余的待排序元素,时间复杂度不会超过O(n),所以快速排序的时间复杂度为O(n*递归层数)
- 最好为O(nlogn)
- 最坏为O(n²)
- 空间复杂度为O(递归层数)
- 最好为O(logn)
- 最坏为O(n)
- 每一层函数调用只需要处理剩余的待排序元素,时间复杂度不会超过O(n),所以快速排序的时间复杂度为O(n*递归层数)
-
若每一次选中的枢轴元素将待排序序列划分为均匀的两部分,则递归深度最小,算法效率最高
- 若初始序列有序或逆序,则快速排序的性能最差,因为每次选择的都是最靠边的元素
-
优化思路
- 选取头、中、尾三个位置的元素,取中间值作为枢轴元素
- 随机选取一个元素作为枢轴元素
-
把n个元素组织成二叉树,二叉树的层数就是递归调用的层数
- 可以将递归深度的问题转化成求二叉树高度的上限下限的判断
-
稳定性:不稳定
选择排序
- 选择排序:每一趟在待排序元素中选取关键字最小(最大)的元素加入有序子序列
简单选择排序
- 对于有n个元素的简单排序需要n-1趟处理
//简单选择排序
void SelectSort(int A[],int n){
for(int i=0;i<n-1;i++){
int 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]);
}
}
}
- 简单选择排序的比较次数为O(n²),移动次数为O(n)
- 算法效率分析
- 空间复杂度:O(1)
- 时间复杂度:O(n²)
- 稳定性:不稳定
- 适用性:既可以用于顺序表,也可以用于链表
堆排序
-
从逻辑上来说堆就是一个顺序存储的完全二叉树
-
在小根堆对应的完全二叉树中,根 <= 左、右
//建立大根堆
void BuildMaxHeap(int A[],int len){
for(int i=len/2;i>0;i--){ 从后往前调整所有非终端节点
HeadAdjust(A,i,len);
}
}
//将以k为根的子树调整为大根堆
void HeadAdjust(int A[],int k,int len){
A[0]=A[k]; //A[0]暂存子树的根节点
for(int i=2*k;i<=len;i*=2){ //沿k较大的子节点向下筛选
/*
只有i<len成立才能保证i是有右兄弟的
在一次循环后,i*=2会让i再次指向此时所指结点的左孩子
*/
if(i<len && A[i]<A[i+1]){
i++; //取k较大的子节点的下标
}
if(A[0]>=A[i]){
break; //筛选结束
}
else{
A[k]=A[i]; //将A[i]调整到双亲结点上
k=i; //修改k值,以便继续向下筛选
}
}
A[k]=A[0]; //被筛选结点的值放入最终位置
}
//建立大根堆
void BuildMaxHeap(int A[],int len)
//将以k为根的子树调整为大根堆
void HeadAdjust(int A[],int k,int len)
//堆排序完整逻辑
void HeapSort(int A[],int len){
BuildMaxHeap(A,len); //初始建堆
for(int i=len;i>1;i--){ //n-1趟的交换和建堆过程
swap(A[i],A[1]); //堆顶元素和堆底元素交换
HeadAdjust(A,1,i-1); //把剩余的排序元素调整成堆
}
}
- 堆排序:每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换)并将待排序元素序列再次调整为大根堆(小元素不断下坠)
- 一个结点,每下坠一层,最多只需对比关键字2次。若树高为h,某结点在第i层,则将这个节点向下调整最多只需要下坠 h-i 层,关键字对比次数不超过 2(h-i)[n各节点的完全二叉树树高h=logn+1,向下取整]
- 算法效率分析
- 空间复杂度为O(1)
- 时间复杂度为O(nlogn)
- 在建堆的过程中,关键字的对比次数不超过4n,建堆的时间复杂度为O(n)
- 根节点最多下坠h-1层,每下坠一层最多只需要对比关键字2次,因此每一趟排序的时间复杂度不超过O(h)=O(logn),共n-1趟,总时间复杂度为O(nlogn)
- 堆排序是不稳定的
- 向具有n个结点的堆中插入一个新元素和删除一个元素的时间复杂度都为O(logn)
归并排序
- 归并:将两个或多个已经有序的序列合并为一个的过程
- 二路归并:将两个已经有序的序列合并为一个
- 没选出一个小元素需要对比关键字一次
- 若进行m路归并,没选出一个元素需要对比关键字m-1次
int *B=(int *)malloc(n*sizeof(int)); //辅助数组B
//A[low...mid]和A[mid+1...high]各自有序,将两个部分归并
void Merge(int A[],int low,int mid,int high){
int i,j,k;
for(k=low;k<=high;k++){
B[k]=A[k]; //将A中所有元素复制到B中
}
for(i=low,j=mid+1,k=i;i<=mid && j<=high;k++){
if(B[i]<=B[j]){
A[k]=B[i++]; //将较小的值复刻到A中
}
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; //从中间划分
MergeSort(A,low,mid); //对左半部分进行归并排序,使其有序
MergeSort(A,mid+1,high); //对右半部分进行归并排序,使其有序
Merge(A,low,mid,high); //对两部分有序序列进行归并排序
}
}
- 算法效率分析
- 时间复杂度:O(nlogn)
- 归并的趟数为O(logn) [向上取整]
- 每一趟归并的时间复杂度为O(n)
- 空间复杂度为O(n)
- 递归调用的深度O(logn) [向上取整] + O(n) 【辅助数组大小】
- 稳定性:稳定的
- 时间复杂度:O(nlogn)
基数排序
- 思路(关键字都以三位数为例)
- 第一趟按“个位”分配,得到按“个位”递减排序的序列
- 第二趟按“十位”分配,得到按“十位”递减排序的序列,“十位”相同的按“个位”递减排序
- 第三趟按“百位”分配,得到按”百位“递减排序的序列,若”百位“相同则按”十位“递减排列,若”十位“相同则按”个位“递减排序
-
算法效率分析
- 空间复杂度:O®
- r 指的是每一个关键字位有可能取的多少种取值。如以上图例关键字取值范围为0~9,所以 r=10,即需要 10 个辅助队列
- 时间复杂度:O(d*(n+r))
- 一趟分配的时间复杂度为O(n),一共进行了d趟的分配(d:指关键字可以被拆分成几个部分,如上图例关键字可以被拆分成3个部分)
- 一趟收集的时间复杂度为O®,因为一共有r个队列
- 稳定性:稳定的
- 空间复杂度:O®
-
基数排序擅长解决的问题
- 数据元素的关键字可以方便的拆分为d组,且d较小
- 每组关键字的取值范围不大,即r较小
- 数据元素个数n较大