数据结构
第一章 绪论
1.1 数据结构基本概念
总结:基本概念之间的关系
数据项 构成 数据元素,
数据元素 集合构成 数据对象,
数据元素 之间的关系构成 数据结构
数据类型是根据数据对象的性质进行的一种划分:
- 原子类型:不可再分,例如整型
- 结构类型:包含一个或多个属性的小整体,可以再分解
- 抽象数据类型:数学化语言定义的数据的逻辑结构和运算,和具体实现无关
数据结构的三要素:逻辑结构、物理结构和数据的运算
逻辑结构独立于物理结构,是抽象的表达不依附于具体的实现
物理结构是逻辑结构在硬件上的映射,不能独立于逻辑结构
数据运算的定义是在逻辑上的、实现是在物理上的
1.2 算法和算法评价
1.2.1 算法的基本概念
程序和算法的区别:
- 程序的运行是无穷的
- 算法的运行是有穷的
1.2.2 算法的时间复杂度
时间复杂度的本质是反映问题规模和算法运行所需要的时间之间的一种量级关系
总结:时间复杂度的比较序列
$$
O(1) < O(\log_2n) < O(n) < O(n\log_2n) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)
$$
在讨论算法的时间复杂度的时候,不需要关注顺序代码执行,只需要关注最深层循环代码的执行次数即可
在计算算法的时间复杂度的时候,因为算法的执行时间和输入数据有一定关联,所以还需要考虑算法在 最好、最坏和平均 情况下的时间复杂度
1.2.3 算法的空间复杂度
原地工作:指空间复杂度 S(n)=O(1) 的算法
空间复杂度不是 O(1) 的两种情况
-
在算法中声明一块大小与问题规模 n 有关的区域
int array[n]={0}
-
递归调用
void func(int n){ int a, b, c; // 声明的局部变量 // ... 执行的其他运算 if (n > 1) func(n-1); }
递归调用会占用与问题规模有关的空间大小的根本原因是:
在函数内部执行自己的时候,会再次声明一块不同的空间用于存储局部变量,每一次递归都会声明一块这样的区域,直到递归全部结束后清空 空间复杂度 = 递归调用的深度 * 声明的参数的量级
这块被声明的区域叫做 函数调用栈
第二章 线性表
2.1 线性表的定义和基本操作
线性表、顺序表和链表之间的关系
没有关系,线性表是一种逻辑结构,表示元素之间一对一的相邻关系
顺序表和链表是一种存储结构,既可以用来存储线性表,也可以用来存储非线性表
2.2 线性表的顺序表示
2.2.1 顺序表的定义
顺序表的实现——静态分配
顺序表的定义
#define MaxSize 10 // 定义最大长度
typedef struct{
ElemType data[MaxSize]; // 使用静态数组存放数据元素
int length; // 顺序表的当前长度
}SqList; // 顺序表的类型定义
基本操作(初始化顺序表)
void InitList(SqList *L){
for(int i=0; i< MaxSize; i++)
L->data[i]=0; // 设置数据元素的数据项初始值为0
L->length=0; // 设置顺序表初始长度
}
int main(){
SqList L;
InitList(&L);
// ...remain code
return 0;
}
顺序表的实现——动态分配
顺序表的定义
#define InitSize 10 // 默认的最大长度
typedef struct{
ElemType *data; //指示动态分配数组的指针
int MaxSize; // 顺序表的最大容量
int length; // 顺序表的当前长度
}SqList;
基本操作(初始化顺序表)
void InitList(SqList *L){
// 用 malloc 函数申请一片连续的存储空间
L->data=(ElemType*)malloc(InitSize * sizeof(ElemType));
L->length=0;
L->maxSize=InitSize;
}
基本操作(增加动态数组的长度)
void IncreaseSize(SqList *L, int len){
int *p = L->data;
L->data=(ElemType*)malloc((L->MaxSize+len)*sizeof(ElemType));
for(int i=0; i<L->length; i++){
L->data[i]=p[i]; // 将数据复制到新区域
}
L->MaxSize=L->MaxSize + len; // 顺序表最大长度增加 len
free(p); // 释放原来的内存空间
}
顺序表的特点:
- 随机访问:
LOC(L) + n * sizeof(ElemType)
- 存储密度高
- 拓展容量不方便
2.2.2 顺序表的基本操作
插入操作
算法实现
// 在位序为 i 处插入元素 e
int ListInsert(SqList * L, int i, ElemType e){
if(L->length == L->MaxSize) // 校验表是否存满
return -1;
if(i < 1 || i > L->length + 1) // 校验插入位置是否合理
return -2;
for(int j=L->length; j>i; j--)
L->data[j]=L->data[j-1]; // 将第 i 个元素及之后的元素后移
L->data[i-1]=e; // 在位置 i 处放入 e
L->length++; // 长度加1
return 1;
}
算法时间复杂度
-
最好请情况:直接插入表尾 O(1)
-
最坏情况:插入表头 O(n)
-
平均情况:np + (n-1)p + ... + 1p = $\frac n2$
删除操作
算法实现
// 删除位序为 i 的元素,并将其返回给变量 e
int ListDelete(SqList * L, int i, ElemType *e){
if(L->length == 0) // 校验表是否空
return -1;
if(i < 1 || i > L->length) // 校验删除位置是否合理
return -2;
*e=L->data[i-1]; // 被删除的元素赋值给 e
for(int j=i; j<L->length; j++) // 将第 i 个为之后的元素前移
L->data[j-1]=L->data[j];
L->length--; // 线性表长度减1
return 1;
}
算法时间复杂度
- 最好情况:删除表尾元素 O(1)
- 最坏情况:删除第一个元素 O(n)
- 平均情况:O($\frac n2$)
顺序表按位查找
算法实现
// 查找位序为 i 的数据元素并返回
ElemType GetElem(SqList L, int i){
return L.data[i-1];
}
时间复杂度:O(1)
顺序表按值查找
// 在顺序表中查找第一个元素值等于 e 的元素,并返回其位序
int LocateElem(SqList L, ElemType e){
for(int i=0; i<L.length; i++)
if(L.data[i] == e)
return i+1;
return -1;
}
时间复杂度
最好 O(1) 最坏 O(n) 平均 O(n)
2.3 线性表的链式表示
2.3.1 单链表的定义
单链表的实现
typedef int ElemType;
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
单链表的初始化(不带头结点)
// 初始化单链表(不带头节点)
bool InitLinkList(LinkList *L){
*L = NULL;
return true;
}
单链表的初始化(带头结点)
bool InitLinkList(LinkList *L){
*L = (LNode *)malloc(sizeof(LNode));
if*(L == NULL)
return false; // 内存不足分配失败
(*L)->next = NULL;
return true;
}
2.3.2 单链表的基本操作
单链表的插入(带头结点)
// 在第 i 个位置插入元素 e (带头结点)
bool ListInsert(LinkList *L, int i, ElemType e){
if(i<1) return false;
LNode *p; // 指针 p 只想当前扫描到的结点
int j=0; // j 表示当前指向的是第 j 个结点
p = *L; // L 指向头结点,头结点是第 0 个结点(不存储数据)
while(p!=NULL && j<i-1){ // 循环找到第 i-1 个结点
p=p->next;
j++;
}
if(p==NULL) return false; // i 值不合法(p 指向最后一个元素但仍不是插入的前一个位置)
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
单链表的插入(不带头结点)
// 在第 i 个位置插入元素 e (不带头结点)
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;
L = s;
return true;
}
LNode *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 = p->next;
p->next = s;
return true;
}
单链表在给定结点之后插入
// 方法 1:在给定结点 p 后插入一个 data 是 e 的元素
bool InsertNextNode(LNode *p, ElemType e){
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;
}
通过调用该方法,可以简化元素插入操作
bool ListInsert(LinkList *L, int i, ElemType e){
if(i<1) return false;
LNode *p; // 指针 p 只想当前扫描到的结点
int j=0; // j 表示当前指向的是第 j 个结点
p = *L; // L 指向头结点,头结点是第 0 个结点(不存储数据)
while(p!=NULL && j<i-1){ // 循环找到第 i-1 个结点
p=p->next;
j++;
}
if(p==NULL) return false; // i 值不合法(p 指向最后一个元素但仍不是插入的前一个位置)
return InsertNextNode(p, e);
}
单链表在给定结点之前插入
// 方法 2:在给定结点 p 之前插入 data 是 e 的结点
bool InsertPriorElem(LNode *p, ElemType e){
if(p==NULL) return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
if(s==NULL) return false; // 内存分配失败
s->next = p->next;
p->next = s;
s->data = p->data;
p->data = e;
return true;
}
// 方法3:在给定结点 p 之前插入给定结点 s
bool InsertPriorNode(LNode *p, LNode *s){
if(p==NULL || s==NULL) return false;
s->next = p->next;
p->next = s; // s 连接到 p 之后
ElemType temp = p->data; // 交换数据域部分
p->data = s->data;
s->data = temp;
return true;
}
单链表的删除
// 删除结点操作
bool ListDelete(LinkList *L, int i, ElemType *e){
if(i<1) return false;
LNode *p; // 指针 p 指向当前扫描到的结点
int j=0;
p = *L;
while(p!=NULL && j<i-1){
p=p->next;
j++;
}
if(p==NULL) return false; // 第 i-1 个结点不存在
if(p->next == NULL) return false; // 第 i-1 个结点没有后继结点
LNode *q = p->next;
*e = q->data;
p->next = q->next;
free(q);
return true;
}
删除指定结点
// 方法 4:删除 非最后一个结点 的指定结点
bool DeleteNotTheLastNode(LNode *p){
if(p == NULL) return false;
LNode *q = p->next; // 令指针 q 指向 **p 的后继节点
p->data = p->next->data; // 和后继结点交换数据域 (如果删除的是最后一个结点,这一步就会出错,因为 p->next 为 NULL)
p->next=q->next; // 将 *q 结点从链中断开
free(q);
return true;
}
2.3.3 单链表的查找
按位查找
// 按位查找
LNode * GetElem(LinkList L, int i){
if(i<0) return NULL;
LNode *p; // 指针 p 指向当前扫描到的结点
int j=0; // 当前 p 指向的是第几个结点,j=0 表示指向头结点
p = L;
while(p != NULL && j < i){ // 找到第 i 个结点
p = p->next;
j++;
}
return p;
}
根据按位查找优化插入和删除
// 根据按位查找和方法 1 修改插入
bool ListInsert(LinkList *L, int i, ElemType e){
if(i<1) return false;
LNode *p = GetElem(*L, i-1); // 获取第 i-1 个位置上的结点
return InsertNextNode(p, e); // 将 data 为 e 的结点添加到 p 后面
}
// 根据按位查找和方法 1 修改删除
bool ListDelete(LinkList *L, int i, ElemType *e){
if(i<1) return false;
LNode *p = GetElem(*L, i-1);
if(p->next->next == NULL){ // 要删除的是最后一个结点
*e = p->next->data;
p->next = NULL; // 将倒数第二个节点的 next 置空
return true;
}
p=p->next;
*e = p->data;
return DeleteNotTheLastNode(&p); // 删除非最后一个结点
}
按值查找
// 按值查找
LNode * LocateElem(LinkList L, ElemType e){
LNode *p = L->next;
// 从第一个结点开始查找数据域为 e 的结点
while(p != NULL && p->data != e)
p=p->next;
return p;
}
求表长
// 求表长
int Length(LinkList L){
int len = 0;
LNode *p = L;
while(p->next != NULL){
p=p->next;
len++;
}
return len;
}
2.3.4 单链表的建立
尾插法建立单链表
// 尾插法建立单链表
bool ListTailInsert(LinkList *L){
int x;
*L = (LinkList)malloc(sizeof(LNode)); // 建立头结点
LNode *s, *r = *L;
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; // 尾指针置空
return true;
}
头插法建立单链表
// 头插法建立单链表
bool ListHeadInsert(LinkList *L){
int x;
*L = (LinkList)malloc(sizeof(LNode)); // 建立头结点
(*L)->next = NULL;
scanf("%d", &x); // 输入结点的值
LNode *s;
while(x != 9999){
s = (LNode *)malloc(sizeof(LNode));
s->data = x;
s->next = (*L)->next;
(*L)->next=s; // 将新的结点插入到表中,*L 为头指针
scanf("%d", &x);
}
return true;
}
2.3.5 双链表
初始化双链表
// 结构体
typedef int ElemType;
typedef struct DLinkList{
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;
(*L)->next = NULL;
return true;
}
双链表的插入
// 在 p 结点之后插入 s 结点 (p 结点不可以是最后一个结点)
bool InsertNextDNode(DNode *p, DNode *s){
s->next = p->next;
if(p->next != NULL) p->next->prior = s;
s->prior = p;
p->next = s;
}
双链表的删除
// 双链表的删除
bool DeleteNextDNode(DNode *p){
if(p == NULL) return false;
DNode *q = p->next; // 找到 p 的后继
p->next = q->next;
if(q->next != NULL) q->next->prior = p;
free(q);
return true;
}
// 删除整个单链表
void DestoryList(DLinkList *L){
// 循环释放各个数据节点
while ((*L)->next != NULL)
DeleteNextDNode(*L);
free(*L); // 释放头结点
*L=NULL; // 头指针指向 NULL
}
2.3.6 循环链表
初始化循环单链表
// 循环单链表结构体
typedef int ElemType;
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;
}
初始化循环双链表
// 循环双链表结构体
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)->next = *L;
(*L)->prior = *L;
return true;
}
// 判断循环双链表是否为空
bool Empty(DLinkList L){
if(L->next == L) return true;
return false;
}
// 判断 p 是否指向循环双链表的表尾
bool isTail(LinkList L, DNode *p){
if (p->next = L) return true;
return false;
}
循环双链表的操作
// 循环双链表:在 p 结点后插入 s 结点 (适用于任何一个结点)
bool InsertNextDNode(DNode *p, DNode *s){
s->next = p->next;
p->next->prior = s;
s->prior = p;
p->next = s;
}
// 循环双链表:删除 p 结点 (适用于任何一个结点)
bool DeleteDNode(DNode *p){
DNode *q = p->prior;
q->next = p->next;
p->next->prior = q;
free(p);
}
2.3.7 静态链表
静态链表也需要分配一块连续的空间,与单链表不同的是:
- 单链表的指针指向下一个数据元素的地址
- 静态链表的指针其实是一个『 游标 』,仅记录下一个数据元素的下标(索引)
下标 | data | next |
---|---|---|
0 | Head | 2 |
1 | e1 | -1 |
2 | e2 | 1 |
定义结构体
#define MaxSize 10
typedef int ElemType;
typedef struct Node{
ElemType data; // 存储数据元素
int next; // 下一个元素下标
}SLinkList[MaxSize];
初始化静态链表
#define MaxSize 10
typedef int ElemType;
typedef struct Node{
ElemType data; // 存储数据元素
int next; // 下一个元素下标
}Node, SLinkList[MaxSize];
// 初始化静态链表
void InitSLinkList(SLinkList L){
L[0].next = -1;
}
2.4 顺序表和链表对比
第三章 栈、队列和数组
3.1 栈
3.1.1 栈的定义
栈(Stack)是 只允许在一端进行插入或者删除操作 的线性表
- 栈顶:允许插入和删除的一端
- 栈底:不允许插入和删除的一端
- 空栈:没有数据元素的栈
特点:后进先出
3.1.2 栈的基本操作
InitStack(&S)
初始化栈
DestoryStack(&S)
销毁栈
Push(&S, x)
进栈
Pop(&S, &x)
出栈
对于 n 个元素进栈,对应的出栈顺序有:
$$
\frac1{n+1}C_{2n}^n\ 种
$$
3.1.3 顺序栈
定义栈的结构体
// 定义 栈 的结构体
#define MaxSize 10
typedef struct{
ElemType data[MaxSize]; // 静态数组存放栈种元素
int top; // 栈顶指针
}SqStack;
进栈操作
// 进栈操作
bool Push(SqStack *S, ElemType x){
if(S->top == MaxSize-1) return false; // 栈满
S->data[++(S->top)] = x;
return true;
}
出栈操作
// 出栈操作
bool Pop(SqStack *S, ElemType *x){
if(S->top == -1) return false; // 栈空
*x = S->data[(S->top)--];
return true;
}
3.1.4 链式栈
结构体
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode, *LinkStack;
操作
链栈本质就是头插法的单链表,且只能从头部删除结点
void Push(LinkStack *L, ElemType e){
LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
s->data = e;
s->next = (*L)->next;
(*L)->next = s;
}
ElemType Pop(LinkStack *L){
ElemType e = (*L)->next->data;
LinkNode *p = (*L)->next;
(*L)->next = p->next;
free(p);
return e;
}
3.2 队列
3.2.1 队列的定义
队列(Queue)是 只允许在一段进行插入,在另一端进行删除 的线性表
重要属于:队头、队尾、空队列
InitQueue(&Q)
:初始化队列DestoryQueue
:销毁队列EnQueue(&Q, x)
:入队,若队列 Q 未满,将 x 加入,使之称为新的队尾DeQueue(&Q, &x)
:出队,若队列 Q 非空,删除队头元素,并返回之GetHead(Q, &x)
:读队头元素,若队列 Q 非空,则将队头元素赋值给 x
3.2.2 队列的顺序实现
结构体
#define MaxSize 10
typedef struct{
ElemType data[MaxSize];
int front, rear;
}SqQueue;
入队操作
需要牺牲一个存储单元来区分 队空 和 队满 两种情况(rear 永远指向下一个入队元素的位置)
// 入队(循环队列)
bool EnQueue(SqQueue *Q, ElemType x){
if((Q->front + 1)%MaxSize == Q->rear) return false; // 判断队满
Q->data[Q->rear] = x; // 新元素入队
Q->rear = (Q->rear - 1)%MaxSize; // 队尾指针 加1取模
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;
}
获取队头元素
// 获取队头元素的值,用 x 返回
bool GetHead(SqQueue Q, ElemType *x){
if(Q.rear == Q.front) return false; // 队空
x = Q.data[Q.front];
return true;
}
获取队列元素个数
已知队头是 front 队尾是 rear ,队列最大长度为 MaxSize,问当前队列元素个数
总结:求队列元素个数
(rear + MaxSize - front)%MaxSize
int NumElem(SqQueue Q){
return (Q.rear + MaxSize - front)%MaxSize;
}
判断队列空/满
方法一:
设立 flag
初始化时候是 0 ,检测到 rear == front
的时候,判定 flag
,如果是 0 则表示为空,如果是 1 则表示为满,同时,在入队和出队操作之后要检测一次 rear == front
入队之后满足,则将 flag
置 1,出队之后满足,则将 flag
置为 0
方法二:
设置 size
表示元素个数,判定 队空 / 队满 的条件变成 size == 0
和 size == MaxSize
方法三:
空出一个元素位置来帮助判定为满 (rear + 1)%MaxSize == front
则为满,rear == front
则为空
3.2.3 队列的链式实现
单链表:只能使用头指针删除结点 + 只能使用尾指针添加结点 = 链式队列
结构体
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front, *rear;
}LinkQueue;
初始化(带头结点)
void InitQueue(LinkQueue *Q){
// 初始时 front、rear 都指向头节点
Q->front = Q->rear = (LinkNode *)malloc(sizeof(LinkNode));
Q->front->next = NULL; // 头结点的指针置空
}
// 判断为空
bool IsEmpty(LinkQueue Q){
if(Q.front == Q.rear) return true;
else return false;
}
初始化(不带头结点)
void InitQueue(LinkQueue *Q){
// 初始时 fron、rear 都指向 NULL
Q->front = Q->rear = NULL;
}
// 判断为空
bool isEmpty(LinkQueue Q){
if(Q.front == NULL) return true;
else return false;
}
入队操作(带头结点)
// 新元素入队
void EnQueue(LinkQueue *Q, ElemType x){
LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;
Q->rear->next = s;
Q->rear = s;
}
入队操作(不带头结点)
// 新元素入队(不带头结点)
void EnQueue(LinkQueue *Q, ElemType x){ // 要分成是否是第一个元素
LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
s->data = x;
s->next = NULL;
if(Q->front == NULL){
Q->front = s; // 第一个结点,队头指针指向新节点
Q->rear = s; // 队尾指针也指向新结点
}else{
Q->rear->next = s;
Q->rear = s;
}
}
出队操作(带头结点)
// 队头元素出队
bool DeQueue(LinkQueue *Q, ElemType *x){
if(Q->front == Q->rear) return false; // 队空
LinkNode *p=Q->front->next; // p 指向要出队的结点
*x = p->data;
Q->front->next = p->next;
if(Q->rear == p) Q->rear = Q->front; // 如果删除的是最后一个结点,修改尾指针
free(p);
return true;
}
出队操作(不带头结点)
// 队列出队
bool DeQueue(LinkQueue *Q, ElemType *x){
if(Q->front == NULL) return false; // 队空
LinkNode *p = Q->front; // p 指向出队结点
*x = p->data;
Q->front = p->next; // 调整队头指针指向下一个结点
if(Q->rear == p){ //
Q->front = NULL;
Q->rear = NULL; // 删除的是最后一个结点,置空头尾节点
}
free(p);
return true; // 释放结点空间
}
3.2.4 双端队列
-
双端队列:允许从两端插入、两端删除的队列
-
输入受限的双端队列:只允许从一端插入、两端删除的线性表
-
输出受限的双端队列:只允许从一端删除、两端插入的线性表
3.3 栈和队列的应用
3.3.1 栈在括号匹配中的应用
算法基本思想:
设置一个栈,当扫描到左括号的时候,压入栈,当扫描到右括号的时候,
- 若栈非空,弹出栈顶的左括号与之匹配
- 若匹配成功,继续扫描
- 若匹配失败,返回失败
- 若栈空,返回失败
当扫描完毕所有的括号,若此时栈中仍然有左括号,则返回失败
// 栈的应用,符号匹配
bool bracketCheck(char A[], int length){
SqStack S;
InitStack(&S); // 初始化栈
for(int i; i < length; i++){
if(A[i] == '(' || A[i] == '[' || A[i] == '{')
Push(&S, A[i]); // 扫描到左括号,入栈
else{ // 扫描到右括号
if(IsEmpty(S)) return false; // 栈空
ElemType e;
Pop(&S, &e);
if(A[i] == ')' && e != '(') return false; // 括号不匹配 ()
if(A[i] == ']' && e != '[') return false; // 括号不匹配 []
if(A[i] == '}' && e != '{') return false; // 括号不匹配 {}
}
}
return IsEmpty(S); // 扫描结束,若栈空则返回 true,否则返回 false
}
3.3.2 栈在表达式求值中的应用
- 中缀表达式:
a + b
- 后缀表达式:
ab+
- 前缀表达式:
+ab
表达式的转换
中缀表达式转后缀表达式:
- 确定中缀表达式的而运算顺序
- 选择下一个运算符,按照 『 左操作数 右操作数 运算符 』 的方式组合成一个新的操作数
- 如果还有运算没被处理,就继续 2
左优先原则: 只要左边的运算符能先计算,就优先算左边的
中缀表达式: A + B - C * D / E + F 左优先级: 1 4 2 3 5 后缀表达式: AB+CD*E/-F+
后缀表达式计算方法:
从左往右扫描,每遇到一个运算符,就让运算符前面最近两个操作数执行对应运算,合体为一个操作数
计算方式的特点: 从左往右扫描,后扫描的元素先进行运算 —— 栈
用栈实现后缀表达式的计算:
- 从左往右扫描下一个元素,直到处理完所有元素
- 扫描到操作数,入栈,回到 1
- 扫描到运算符,弹出栈顶两个元素,执行运算并将结果入栈,回到 1
中缀转前缀表达式和后缀类似,但是要遵循 右优先 的原则
中缀表达式转后缀表达式的实现
初始化一个栈,用于保存暂时不能确定的顺序运算符。
从左到右处理各个元素,直到末尾可能出现的三种情况:
-
遇到操作数,加入后缀表达式
-
遇到界限符
- 左括号,入栈
- 右括号,弹出栈顶运算符加入表达式直到遇到左括号,弹出左括号(不加入表达式)
-
遇到运算符,按照 优先级 依次弹出 高于等于当前运算符 的所有运算符,并加入表达式,然后压入当前运算符
遇到左括号 或者 栈空 停止弹出
-
全部扫描完毕,将剩余的运算符依次弹出,并加入表达式
思考:如何实现运算符之间的优先级比较?
A = [[1, 1, 0, 0],
[1, 1, 0, 0],
[-1, -1, 1, 1],
[-1, -1, 1, 1]]
+ 0
- 1
* 2
/ 3
遇到 运算符 检测 A[运算符对应数字, S.data[S.top]]
- 若为 1 则表示栈顶元素优先级与当前运算符
- 若为 0 则表示栈顶元素优先级高于当前运算符
- 若为 -1 则表示栈顶元素优先级低于当前运算符
// 伪代码实现第 3 步: 运算符计算
int CacuFunc(SqStack &S, int s, SqList &L){
if(IsEmpty(S) || S.data[S.top] == '(') return -1; // 停止弹出
// 低于,压入栈
if(A[s, S.data[S.top]] == -1) {
Push(S, s);
return -1;
}
// 高于或等于,出栈并继续检测栈顶
if(A[s, S.data[S.top]] == 0 || A[s, S.data[S.top]] == 1){
AddElem(L, Pop(S));
CacuFunc(SqStack &S, int s, SqList &L);
}
}
中缀表达式的求值
中缀转后缀 + 后缀求值
3.3.3 栈在递归中的调用
函数调用的特点:最后被调用的函数最先执行阶数(LIFO) —— 栈
递归函数:深层函数后调用先执行,浅层函数先调用后执行
递归算法:可以把原始问题转换为属性相同,但是规模较小的问题
递归算法求阶乘
inf factorial(int n){
if(n == 0 || n == 1) return 1;
else return n*factorial(n-1);
}
递归调用时,函数调用栈可称为 『 递归工作栈 』
- 每进入一层递归,就将递归调用所需信息压入栈顶
- 每退出一层递归,就从栈顶弹出相应的信息
如何将 递归实现的算法 改造成 非递归实现 ?
自定义一个 栈 来辅助实现
3.3.4 队列的应用
- 树的层次遍历
- 图的广度优先遍历
- 进程调度:先来先服务
3.4 特殊矩阵的压缩存储
一维数组
ElemType a[5]; // 一维数组在内存中线性存储
二维数组
ElemType b[2][2]; // 二维数组在内存中按照 行优先 / 列优先 存储
普通矩阵
使用二维数组存储
对称矩阵
对称矩阵: $a_{ij} = a_{ji}$
压缩存储策略(示例):只存储主对角线 + 下三角区;按照 行优先 原则将个元素存入一维数组中
映射函数:$f:a_{ij}(i>j)\rightarrow b[k],\ k=f(i, j)=\frac{i(i+1)}2+j-1;\ a_{ij}(i<j)=a_{ji},\ k=f(j,i)$
三角矩阵
三角矩阵:除了主对角线和 上/下 三角区,其余元素都相同
压缩存储策略(示例 - 上三角矩阵):只存储主对角线 + 对应元素三角区;按照行优先原则存入一维数组,并在最后一个位置存入常量 c
映射函数:$f:a_{ij}(i>j)\rightarrow b[k],\ k=f(i, j)=\begin{cases}&\frac{(i-1)(2n-i+2)}2+(j-i)&i\leq j\ &\frac{n(n+1)}2&i>j\end{cases}$
三对角矩阵
三对角矩阵:除了对角线和对角线 前和后 一个元素之外,其余的元素相同
压缩存储策略(示例):只存储带状部分;按照行优先原则存入一维数组
映射函数:$f:a_{ij}(i>j)\rightarrow b[k],\ k=2i+j-3$
稀疏矩阵的压缩存储
- 三元组
|i|j|data|
- 十字链表法
第四章 串
4.1 串的定义和实现
4.1.1 串的定义
串:由零个或多个字符组成的有限序列
子串:串中任意个 连续的 字符组成的子序列
主串:包含子串的串
字符在主串中的位置:字符在串中的序号
子串在主串中的位置:子串中第一个字符在主串中的位置
空格也是字符,空格串不是空串,空串没有包含任何字符
4.1.2 串的基本操作
-
赋值操作:
StrAssign(&T, chars)
把串 T 赋值为 chars -
复制操作:
StrCopy(&T, S)
复制串 S 得到串 T -
判空操作:
StrEmpty(S)
-
求串长:
StrLength(S)
-
清空操作:
ClearString(&S)
-
销毁串:
DestoryString(&S)
-
串联串:
Concat(&T, S1, S2)
联接 S1 和 S2 并赋值给 T -
求子串:
SubString(&Sub, S, pos, len)
将串 S 中从索引为 pos 开始长度为 len 的子串赋值给 Sub -
定位操作:
Index(S, T)
返回串 T 在主串 S 中相同子串的位置,如果找不到返回 -1 -
比较操作:
StrCompare(S, T)
比较操作按照 ASCII 码中的编码依次进行比较
4.2 串的存储结构
4.2.1 串的顺序存储
结构体和初始化
#define MAXLEN 255
typedef struct{
char ch[MAXLEN]; // 静态数组实现:定长的顺序存储
int length;
}SString;
typedef struct{
char *ch; // 按串长分配存储区,ch 指向串的基地址
int length; // 串的长度
}HString;
// 初始化
void InitHString(HString *S){
S->ch = (char *)malloc(MAXLEN * sizeof(char));
S->length = 0;
}
实际上实现的时候,字符的存储是从下标为 1 的位置开始存储的,这样做的目的是让字符的位序和数组的下标相同
NULL w a n g d a o 7 (length)
基本操作
对于一些操作可以采用更巧妙的方式,例如
ClearString()
只需要将 length 变量的值置为 0 即可
基本操作——求子串
// 求子串
bool SubString(SString *Sub, SString S, int pos, int len){
if(pos + len-1 > S.length) return false; // 子串范围越界
for(int 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){
for(int i=1; i<=S.length && i<=T.length; i++){
if(S.ch[i]!=T.ch[i]) return S.ch[i]-T.ch[i];
}
// 扫描过的所有字符都相同,则长度长的串更大
return S.length-T.length;
}
基本操作——定位子串
// 定位子串
int Index(SString S, SString T){ // S 是主串,T 是要匹配串
int i=1, n=S.length, m=T.length;
SStrinng sub; // 用于暂存子串
while(i<n-m+1){ // 从 与要匹配的串长度相等 的倒数第一个子串起始位置开始往前匹配
SubString(&sub, S, i, m); // 将从该位置开始 返回与匹配串长度相等的子串
if(StrCompare(sub, T)!=0) ++i; // 匹配失败,则 i 往左移,匹配倒数第二个子串
else return i; // 匹配成功,返回该子串的位置
}
return 0; // S 中找不到对应的子串
}
4.2.2 串的链式存储
结构体和初始化
如果链式存储每个结点只存储一个字符,则一个结点总共 5 个 Byte,空间利用率只有 20%
因此在设计结构体的时候,我们让一个结点中保存 4 个字符
typedef struct StringNode{
char ch[4]; // 每个结点存 4 个字符
struct StringNode *next;
}StringNode, *String;
4.3 字符串的模式匹配
概念:在主串中找到与模式串相同的子串,并返回其所在的位置
4.3.1 朴素模式匹配算法
朴素模式匹配算法:将主串中所有长度为 m 的子串依次与模式串对比,直到找到一个完全匹配的子串,或所有的子串都不匹配为止——至多匹配 n-m+1 次
算法实现
使用 i, j 两个指针分别指向主串和模式串对应匹配的位置,若匹配失败则 i 指向下一个子串第一个位置,j 回到模式串第一个位置
int Index(SString S, SString T){ // 主串 S 模式串 T
int i=1, j=1;
while(i <= S.length && j <= T.length){
if(S.ch[i] == T.ch[i]){
++i; ++j; // 继续比较后续字符
}else{
i = i-j+2;
j=1; // 指针后退重新匹配下一个子串
}
}
if(j>T.length) return i-T.length; // 匹配成功,返回子串的第一个字符位置
else return 0; // 匹配失败
}
最坏时间复杂度:每个子串要对比 m 个字符,共 n-m+1 个子串,时间复杂度为 O((n-m+1)m)=O(mn)
4.3.2 KMP 算法
对于一个模式串来说,当其和子串匹配失败的时候,不一定要从头开始匹配下一个子串
例如模式串 abaabc ,当 c 与子串最后一位不匹配的时候,说明该子串前 5 位与模式串是相同的,所以可以让模式串第三位与该子串最后一位比较
对于模式串
T = 'abaabc'
- 当第 6 个元素匹配失败时,可令主串指针 i 不变,模式串 j=3
- 当第 5 个元素匹配失败时,可令主串指针 i 不变,模式串指针 j=2
- 当第 4 个元素匹配失败时,可令主串指针 i 不变,模式串指针 j=2
- 当第 3 个元素匹配失败时,可令主串指针 i 不变,模式串指针 j=1
- 当第 2 个元素匹配失败时,可令主串指针 i 不变,模式串指针 j=1
- 当第 1 个元素匹配失败时,匹配下一个相邻子串,令 j=0,然后 j++; i++
表示匹配到某一位失败时候,该位置和 j 指针回溯到模式串的位置之间的关系,可以使用一个数组来表示
next 数组:
next[0] next[1] next[2] next[3] next[4] next[5] next[6] 0 1 1 2 2 3 用代码表示 next 数组与 i,j 之间的关系
if (S[i]!=T[j]) j=next[j]; if(j==0) {i++;j++}
算法实现(使用 next 数组)
int Index_KMP(SString S, SString T, int next[]){
int i=1, j=1;
while(i <= S.length && j <= T.length){
if(j==0 || S.ch[i] == T.ch[j]){
++i; ++j; // 继续比较后续字符
}
else j=next[j];
}
if(j>T.length) return i-T.length; // 匹配成功
else return 0; // 匹配失败
}
最坏算法时间复杂度:O(m+n) = O(m)[求 next 数组] + O(n)[使用 next 数组不回溯]
求 next 数组
对于任意一个模式串 next[1]=0; next[2]=1
手算 next 数组方法
- 在不匹配的位置前 划一根分界线
- 模式串一步一步后退,直到分界线之前能全部对应,或模式串完全跨过分界线
- 此时 j 所指向的位置就是 next 数组对应的值
next 数组的优化思路
思考:对于模式串
aaaab
求 next 数组为
next[0] next[1] next[2] next[3] next[4] next[5] 0 1 2 3 4 但是例如 next[4] ,实际上当第四位不匹配的时候,移动到第三位,但是第三位的字符与第四位是完全相同的,所以第三位也必定会不匹配,按照这个逻辑,最终第四位不匹配会导致 j 指向 0 而非 3
所以按照这个思路,针对模式串
aaaab
可以得到一下优化后的数组
nextval[0] nextval[1] nextval[2] nextval[3] nextval[4] nextval[5] 0 0 0 0 4
对于其他模式串的 next 数组,也是同样的道理,当 next[k] 数组中指定跳转位置 m 的字符,与当前字符相等,则可以直接将对应 next[k] 中的值修改为跳转位置字符对应的 next[m] 的指定跳转位置(这个过程可以继续迭代,直到字符不相等,或者指定跳转位置 为 0 ),通过这种方式得到的就是优化后的 next 数组,即 nextval 数组
对于 nextval 数组在模式匹配中的使用,可以完全参照 next 数组的使用
求 nextval 数组
int InitNextval(SString T, int next[]){
int nextval[T.length]={};
nextval[1] = 0;
for(int j=2; j<T.length; j++){
nextval[j] = NextToNextval(T, next, nextval, j);
}
return nextval[];
}
// 定义一个函数用于找到 next 数组中最后一个与模式串中对应字符不匹配的字符对应的数值
int NextToNextval(SString T, int next[], int nextval[], int j){
if(j!=1 && T.ch[next[j]] == T.ch[j]){ // 没找到第一个位置或者仍然相等就继续递归
return NextToNextval(T, next, nextval, next[j]);
}
if(j==1) return 0; // 是第一个位置,返回 0
else return next[j]; // 不相等,返回对应的 next 数值
}
第五章 树与二叉树
5.1 树的基本概念和性质
一对多数据结构的特点:除了根节点以外,任何一个节点只有一个确定的前驱
5.1.1 树的基本概念
树的定义
树是 n (n>=0) 个结点的有限集合,n=0 时,称为空树,在任意一颗非空树中应该满足:
- 有且仅有一个特定的称为根的结点
- 当 n>1 时,其余结点可以分为 m (m>0) 个互不相交的有限集合 T1, T2, T3, ..., Tm,其中每个集合本身又是一棵树,且称为子树
术语:树结点的属性
-
结点的层次(深度):从上往下数
-
结点的高度:从下往上数
-
树的高度(深度):总共多少层
-
结点的度:结点的后继分支
度非 0 的结点一般称为分支节点
度为 0 的结点一般称为叶子结点
有序树和无序树
有序树:结点从左到右不可以交换
无序树:结点无次序
树和森林
森林是互不相交的树的集合
5.1.2 树的性质
度和结点个数之间的关系
对于任意一颗非空树,结点的个数 = 结点的度之和 + 1
『度』和『叉』之间的关系
度为 m 的树:一个结点至多有 m 个分支;至少有一个结点的度为 m ;一定非空
叉为 m 的树:一个结点至多有 m 个分支;可以所有结点度都小于 m ;可以为空
度(叉)和层数之间的关系
度(叉)为 m 的树第 i 层结点个数至多为 $m^{i-1}$ 个
据此可以推导,高度为 h 的树至多有 $1 + m + m^2 + \dots + m^{h-1} =\frac{m^h-1}{m-1}$ 个结点
高度为 h 的树至少有多少个结点?
- 对于度数为 m 的树,至少有 h+m-1 个结点
- 对于叉数为 m 的树,至少有 h 个结点
综上可以得到,对于一个 m 叉树有 n 个结点,则当树的高度最小时候, n 和 m 满足关系
h-1层的满 m 叉树结点个数 < n <= h 层满 m 叉树结点的个数,即
$$
\frac{m^{h-1}-1}{m-1} < n \leq \frac{m^h -1}{m-1}
$$
因此,具有 n 个结点的 m 叉树最小高度为 $\log_m(n(m-1)+1)(向上取整)$
5.2 二叉树
5.2.1 二叉树的基本概念
二叉树是 n 个结点的有限集合
- 或者为空二叉树,n=0
- 或者是由一个根节点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一颗二叉树
特点:每个结点至多只有两棵子树;左右子树不能颠倒
满二叉树
除了叶子节点之外,每个结点都有两个分支
一颗高度为 h,且含有 $2^h-1$ 个结点的二叉树
特点:
- 只有最后一层有叶子节点
- 不存在度为 1 的结点
- 按层序从 1 开始编号,结点 i 的左孩子为 2i,右孩子为 2i+1。结点 i 如果有父结点,父节点为 i/2(向下取整)
完全二叉树
当且仅当其每个结点都与高度为 h 的满二叉树中编号为 1~n 的结点一一对应,称为完全二叉树
特点:
- 只有最后两层可能有叶子结点
- 最多只有一个度为 1 的结点
5.2.2 几种特殊的二叉树
二叉排序树(BST)
- 左子树上所有结点的关键字均小于根节点的关键字
- 右子树上所有结点的关键字均大于根节点的关键字
- 左子树和右子树又各是一颗二叉排序树
平衡二叉树(AVL)
树上任一结点的左子树和右子树的深度之差不超过 1
对于一颗二叉排序树,符合平衡二叉树的具有更高的搜索效率
5.3 二叉树的常考性质
度和结点个数的关系
对于一棵非空二叉树中度为 0、1 和 2 的结点个数分别为 $n_0、n_1和n_2$ ,则满足关系 $n_0=n_2+1$
推导:
- $n=n_0+n_1+n_2$
- $n=n_1+2n_2$
由 1、2 可得结论
结点和层数的关系
二叉树的第 i 层至多有 $2^{i-1}$ 个结点
结点和高度的关系
高度为 h 的二叉树至多有 $2^h-1$ 个结点(满二叉树)
具有 n 个结点的完全二叉树的高度 h 为 $\log_2(n+1)(向上取整)或 \log_2n+1(向下取整)$
完全二叉树结点和结点度数之间的关系
对于一颗完全二叉树,结点个数为 n
- $n_1 = 0 \ 或者 \ 1$
- $n_0=n_2+1 \rightarrow n_0+n_2 = 2n_2+1 是奇数$
- 对于一颗完全二叉树 $\begin{cases} 若有 2k 个结点,则 n_1=1,n_0=k,n_2=k-1 \ 若有 2k+1 个结点,则 n_1=0,n_0=k+1,n_2=k \end{cases}$
5.4 二叉树的存储结构
5.4.1 二叉树的顺序存储
结构体
构造一个数组用来存储二叉树,根结点是 t[1] 元素
#define MaxSize 100
typedef struct {
ElemType value; // 结点中的数据元素
bool isEmpty; // 结点是否为空
}TreeNode;
int main(){
TreeNode t[MaxSize];
}
对于一个普通的二叉树,也要按照完全二叉树的编号一一对应来存储,即使对应的结点上没有元素也要空出
对于一个高度为 4 度为 1 的二叉树,也要至少占据 8 个存储单元
5.4.2 二叉树的链式存储
结构体
// 二叉树的结点
typedef struct BiTNode {
ElemType data; // 数据域
struct BiTNode *lchild, *rchild; // 左、右孩子指针
}BiTNode, *BiTree;
int main(){
// 定义一颗空树
BiTree root = NULL;
// 插入根节点
root = (BiTree)malloc(sizeof(BiTNode));
root -> data = 1;
root -> lchild = NULL;
root -> rchild = NULL;
// 插入新的结点
BiTNode * p = (BiTNode *)malloc(sizeof(BiTNode));
p -> data = 2;
p -> lchild = NULL;
p -> rchild = NULL;
root -> lchild = p;
}
找到指定节点的左右孩子,只需获取对应的左右孩子指针即可 p->lchild->data; p->rchild->data
找到指定节点的父节点,就只能从根节点开始遍历全部节点的左右孩子,直到有一个是 p 所指的节点
三叉链表的结构体
使用三叉链表,可以从当前节点直接找到其父节点
typedef struct BiTNode {
ElemType data; // 数据域
struct BiTNode *lchild, *rchild; // 左、右孩子指针
struct BiTNode *parent; // 父节点指针
}BiTNode, *BiTree;
5.5 二叉树的遍历
5.5.1 二叉树的先/中/后序遍历
遍历:按照某种次序把所有节点都访问一遍
- 先序遍历:根左右
- 中序遍历:左根右
- 后序遍历:左右根
graph TD A-->B A-->C B-->D B-->E C-->F C-->R1[-] D-->R2[-] D-->G
- 先序遍历:ABDGECF
- 中序遍历:DGBEAFC
- 后序遍历:GDEBFCA
先序遍历的实现
void PreOrder(BiTree T){
if(T!=NULL){
visit(T); // 访问根节点
PreOrder(T->lchild); // 访问左子树
PreOrder(T->rchild);// 访问右子树
}
}
中序遍历的实现
void PreOrder(BiTree T){
if(T!=NULL){
PreOrder(T->lchild); // 访问左子树
visit(T); // 访问根节点
PreOrder(T->rchild);// 访问右子树
}
}
后序遍历的实现
void PreOrder(BiTree T){
if(T!=NULL){
PreOrder(T->lchild); // 访问左子树
PreOrder(T->rchild);// 访问右子树
visit(T); // 访问根节点
}
}
空间复杂度:O(h+1)(h 为二叉树的高度)
遍历的应用:求树的深度
已知在求一棵树的深度要先找到其叶子节点,然后返回各个叶子节点的高度并进行比较,因此求树深度算法的核心是后序遍历(左右子树都为空则返回根的高度)
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;
}
}
5.5.2 二叉树的层次遍历
层次遍历就是按照层数一层一层从左到右遍历该层的所有结点
实现算法思想
-
初始化一个辅助队列
-
根节点入队
-
判断队列:
-
若队列为空则停止
-
若队列非空,则头结点出队,访问该结点,并将其左、右孩子插入队尾(如果存在),然后重复 3
-
算法实现
// 链式队列结点
typedef struct LinkNode {
BiTNode *data;
struct LinkNode *next;
}LinkNode;
typedef struct {
LinkNode *front, *rear; // 队头队尾
}LinkQueue;
// 层序遍历
void LevelOrder(BiTree T){
LinkQueue 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); // 右孩子入队
}
}
5.5.3 由遍历序列构造二叉树
一种遍历序列可能对应多种二叉树的形态,因此想要唯一确定一个二叉树的形态,单单知道一种遍历序列是不够的
前序 + 中序遍历序列
前序:根 + 左子树的前序遍历序列 + 右子树的前序遍历序列
中序:左子树的中序遍历序列 + 根 + 右子树的中序遍历序列
其中两个序列中的子树序列长度相等,可以按照同样的规则继续递归,直到只含有一个根节点子树,即原二叉树的叶子节点
后序 + 中序遍历序列
后序:左子树的后序遍历序列 + 右子树的后序遍历序列 + 根
中序:左子树的中序遍历序列 + 根 + 右子树的中序遍历序列
同样的原理,可以通过递归找到各个子树的根节点,从而恢复二叉树
层序 + 中序遍历
层序:根 + 左子树的根 + 右子树的根
中序:左子树的中序遍历序列 + 根 + 右子树的中序遍历序列
可以通过层序确定根,然后根据中序确定根与根之间的关系(层序中按顺序出现的结点会分布在中序中根节点的两侧)
前序 + 后序 + 层序 能否唯一确定一棵二叉树?
无法确定一颗唯一的二叉树,因为无法确定子树的顺序
5.6 线索二叉树
5.6.1 线索二叉树的定义
由于一棵普通的二叉树的叶子节点左右孩子都是空,因此当访问某个结点 从该结点开始的中序遍历序列 或者 该结点在中序序列中的前去和后继 必须从根节点开始遍历。
是否可以通过修改叶子结点和只含有一个结点的双亲结点的左右孩子指针,来达到从任意一个结点都可以得到其对应的结点在中序序列中的 从该结点开始的遍历序列 和中序序列中的 前驱结点和后继结点 呢?
对于一个有 n 个结点的链式二叉树,其一共有 n+1 个空链域,可以用来记录前驱、后继信息
线索二叉树
在普通二叉树的基础上,空链域用于存储相应的线索指针,用于指向当前结点在 前序/中序/后序 遍历中的前驱和后继
5.6.2 线索二叉树的存储结构
结构体
// 线索二叉树的结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; // 左、右线索标志: tag == 0 表示指针指向孩子 tag== 1 表示指针指向前驱后继
}ThreadNode, *ThreadTree;
5.6.3 二叉树的线索化
使用普通二叉树找到给定结点的中序前驱结点
// 在普通二叉树中找到指定结点的中序前驱结点
void FindPreNodeInOrder(BiTree T, BiTNode *p){
BiTNode *pre = NULL, *final = NULL;
// 访问函数
void visit(BiTNode *q){
if(q == p) final = pre; // 当前访问的是指定的结点,则 pre 指向前驱
else pre = q; // 否则的话,让 pre 指向当前结点
}
// 中序遍历
void InOrder(BiTree T){
if(T != NULL){
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
}
InOrder(T);
}
使用线索二叉树找到给定结点的中序前驱结点
中序线索化
// 中序遍历线索二叉树
void InThread(ThreadTree T){
if(T!=NULL){
InThread(T->lchild);
visit(T);
InThread(T->rchild);
}
}
void visit(ThreadNode *q){
// 全局变量 pre 指向当前访问节点的前驱
if(q->lchild==NULL){
q->lchild=pre;
q->rtag=1;
}
if(pre!=NULL && pre->rchild==NULL){
pre->rchild=q;
pre->rtag=1;
}
}
// 构建线索二叉树的全局函数
void CreateInThread(ThreadTree T){
ThreadNode *pre=NULL;
if(T!=NULL){
InThread(T);
// 处理最后一个结点
pre->rchild = NULL;
pre->rtag = 1;
}
处理最后一个结点:如果中序遍历的最后一个结点的右孩子是空(其实必然是空,因为中序遍历结束后一定是停在最右侧的结点,不可能还有右孩子),则还需要把该结点的 rtag 设置为 1
对于 先序线索化 和 后序线索化 ,只需要修改访问顺序即可
但是要注意 『 转圈 』 情况:
先序遍历 的时候,可能会把左线索指向当前结点的双亲结点(根左右),之后访问该结点的左孩子,则会造成
访问左孩子 -> 双亲结点 -> 访问双亲结点左孩子 -> 当前结点 -> 访问左孩子 -> 双亲结点 ...
的无限循环为了避免这种情况,在访问结点之前 要确定该指针是作为指向孩子的指针还是作为指向线索的指针
if(T->ltag == 0) InThread(T->lchild);
为什么 中序 和 后序 不会出现 『 转圈 』 情况?
答:因为中序和后序都在访问根节点之前访问过了左子树
5.6.4 找到线索二叉树的前驱和后继
- | 中序线索二叉树 | 先序线索二叉树 | 后续线索二叉树 |
---|---|---|---|
找前驱 | √ | × | √ |
找后继 | √ | √ | × |
找到中序后继结点
对于一个结点,其右子树最左下的结点就是它在中序遍历中的前驱
// 找到以当前结点为根的子树最左下结点(第一个被中序遍历的结点)
ThreadNode * Firstnode(ThreadNode *p){
while(p->ltag==0) p=p->lchild;
return p;
}
// 找到中序后继结点
ThreadNode * Nextnode(ThreadNode *p){
if(p->rtag==0) return Firstnode(p->rchild); // 找到 p 右子树中最左下结点,即为 p 的后继结点
else return p->rchild;
}
访问给定结点之后的中序序列(非递归)
使用 Nextnode()
方法,可以使得求中序序列的空间复杂度为 O(1)
void InOrder(ThreadTree T){
// 找到当前结点的前驱结点,然后依次访问每一个结点的后继
for(ThreadNode *p=Firstnode(T); p!=NULL; p=Nexnode(p))
visit(p);
}
找到中序前驱结点
对于一个结点,其左子树最右下的结点就是它在中序遍历中的前驱
// 找到以 p 为根的子树中,最后一个被中序遍历的结点
ThreadNode * Lastnode(ThreadNode *p) {
while(p->rtag == 0) p=p->rchild;
return p;
}
// 在中序线索二叉树中找到结点 p 的额前驱结点
ThreadNode * Prenode(ThreadNode *p){
// 左子树中最右下结点
if(p->ltag==0) return 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);
}
先序线索二叉树找先序后继
设 p 指向当前结点
-
p->rtag == 1, next = p->rchild;
-
p->rtag==0
-
若 p 有左孩子,则先序后继为左孩子
-
若 p 没有左孩子,则先序后继为右孩子
-
ThreadNode * Nextnode(ThreadNode *p){
// p 没有线索化后继指针
if(p->rtag==0){
if(p->ltag == 0) return p->lchild;
else return p->rchild;
}
// p 有线索化后继指针
else return p->lchild;
}
先序线索二叉树找先序前驱
设 p 指向当前结点
-
p->ltag == 1, next = p->lchild;
-
p->ltag==0
p->rtag==0
只能证明 p 有左孩子,先序遍历(根、左、右)中,p 的左右孩子都是其后继,而无法在左右子树中找到前驱,因此 无法找到 p 的前驱如果是三叉先序线索链表,能否找到 p 的先序
- p 是左子树的根节点,则 p 的前驱就是其双亲结点
p->parent
- p 是右子树根节点,且双亲结点没有左子树,则 p 的前驱就是其双亲结点
p->parent
- p 是右子树根节点,且双亲结点有左子树,则 p 的前驱就是 其兄弟结点作为根节点的子树的最后一层最右侧的结点
- p 没有双亲结点,其是根节点,其没有前驱
- p 是左子树的根节点,则 p 的前驱就是其双亲结点
后序线索二叉树找后序前驱
设 p 指向当前结点
p->ltag == 1, next = p->lchild;
p->ltag==0
- 如果 p 有右孩子,则前驱结点为右孩子(右孩子作为右子树的根节点最后一个访问)
- 如果 p 没有右孩子,则前驱结点为左孩子
后序线索二叉树找后序后继
设 p 指向当前结点
-
p->rtag == 1, next = p->rchild;
-
p->rtag==0
与先序遍历找前驱一样,因为 p 的子树均是其前驱结点,因此不可能通过遍历子树的方法找到其后序后继
三叉链表:
-
p 是右子树根节点,则双亲结点是其后序后继
-
p 是左子树根节点且没有右兄弟,则双亲结点是其后序后继
-
p 是左子树且有右兄弟,则其后继就是右兄弟中 第一个被访问的结点 ,即右兄弟最后一层最左侧的结点
-
p 没有双亲结点,没有后继
-
5.7 树、森林
5.7.1 树的存储结构
1. 双亲表示法
对于一棵不确定度数的树上的某一个结点来说,其孩子个数无法确定,但是其双亲结点个数只有一个,所以可以通过存储每一个结点和其双亲结点的索引的办法,实现对于一棵不确定度数的树的存储
index | data | parent |
---|---|---|
0 | A | -1 |
1 | B | 0 |
2 | C | 0 |
3 | D | 0 |
4 | E | 1 |
按照该双亲表示法从 叶子节点 向 根节点 构建树
数据结构的定义
#define MAX_TREE_SIZE 100
typedef struct{
ElemType data;
int parent;
}PTNode;
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int n; // 结点数
}PTree;
双亲表示法的优缺点:
- 优点:找到给定节点的双亲结点非常容易
- 缺点:找到给定节点的孩子结点必须遍历整棵树
对于经常找双亲结点的应用场景,有一种更常用的数据结构:并查集,其原理和双亲表示法类似
2. 孩子表示法
用数组顺序存储各个结点,然后每个节点中保存数据元素、孩子链表的头指针
孩子链表用于存储该结点对应的所有孩子
index | data | *firstChild | - | - | - |
---|---|---|---|---|---|
0 | A | *--> |
1|--> |
2|--> |
3|^ |
1 | B | *--> |
4|^ |
||
2 | C | ^ |
|||
3 | D | ^ |
|||
4 | E | ^ |
数据结构的定义
// 孩子链表结点
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;
孩子表示法也适用于存储森林,但 r 就要替换成 r[n] ,用于记录森林中多个根的位置
孩子表示法优缺点:
- 优点:找孩子很方便
- 缺点:找双亲需要遍历整棵树
3. 孩子兄弟表示法
每个结点有两个指针,左指针指向自己的第一个孩子,右指针指向自己的兄弟结点
数据结构定义
typedef struct CSNode{
ElemType data; // 数据域
struct CSNode *firstchild, *nextsibling; // 第一个孩子指针和右兄弟指针
}CSNode, * CSTree;
5.7.2 树、森林与二叉树之间的转换
树 $\to$ 二叉树
- 先在二叉树中画出根节点
- 按照树的 『 层序遍历 』 依次处理每个结点
森林 $\to$ 二叉树
- 先把所有树的根节点画出来,在二叉树中用右指针穿起来
- 按照森林的 『 层序遍历 』 依次处理每个结点
二叉树 $\to$ 树
- 先画出树的根节点
- 从树的根节点开始,按 『 树的层序 』 恢复每个结点的孩子
二叉树 $\to$ 森林
- 先将二叉树的根节点和右子树的根节点,以及右子树右子树一直到没有右子树的所有结点 连着对应的左子树 分别拆下来
- 按照 『 森林的层序 』 依次恢复每个结点的孩子
5.7.3 树、森林的遍历
1. 先根遍历: 若树非空,先访问根节点,再依次对每棵子树进行先根遍历
// 树的先根遍历
void PreOrder(TreeNode *R){
if(R!=NULL) {
visit(R); // 访问根节点
while(!isLeave(R)) PreOrder(R->child); // R不是叶子节点,先根遍历其所有的子树
}
}
2. 后根遍历: 若树非空,先依次对每棵子树进行后根遍历,最后再访问根节点
void PostOrder(TreeNode *R){
if(R!=NULL){
while(!isLeave(R)) PostOrder(R->child);
visit(R);
}
}
3. 层次遍历(用队列实现): 若树非空,则根节点入队;若队列非空,队头元素出队并访问,然后将其孩子入队并重复这一步直到队列为空
1. 先序遍历森林: 森林非空,则访问森林第一棵树的根节点,先根遍历第一棵树,然后将该树从森林中去除,重复该步骤
也可以先将森林转化为二叉树,然后先序遍历二叉树
2. 中序遍历森林: 等同于依次对各个树进行后根遍历
也可以先将森林转化为二叉树,然后中序遍历二叉树
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
5.8 树与二叉树的应用
结点的权:有某种现实含义的数值
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积
第三层第四个结点的带权路径长度为:3 $\times$ 3 = 9
树的带权路径长度: 树中所有叶结点的带权路径长度之和(WPL)
$$
WPL=\sum_{i=1}^{n}w_il_i
$$
该树的 WPL = 5 * 3 + 1 * 3 + 10 * 3 + 1 * 3 + 4 * 1 = 55
5.8.1 哈夫曼树
在所有的含有 n 个给定带权叶子结点的二叉树中,带权路径长度最小的二叉树称为 哈夫曼树
哈夫曼树的构造
给定 n 个权值分别为 $w_1,2_2,\dots, w_n$ 的结点,构造哈夫曼树的算法描述如下:
- 将这 n 个结点分别作为 n 棵仅含一个结点的二叉树,构成森林 F
- 构造一个新结点,从 F 中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将 新结点的权值置为左、右子树上根结点的权值之和
- 从 F 中删除刚才选出的两棵树,同时将新得到的树加入 F 中
- 重复步骤 2、3 直到 F 中只剩下一棵树
哈夫曼树构造哈夫曼编码
固定长度编码——每个字符用相等长度的二进制位表示
A--00
B--01
C--10
D--11
可变长度编码
- 前缀编码(可变长度编码前提下,任意一个结点的编码必须不是其他结点的前缀)
A--10
B--111
C--0
D--110
5.8.2 并查集
集合:将各个元素划分为若干个互不相交的子集,元素之间的关系就分成了两种
- 两个元素属于同一个集合
- 两个元素属于不同的集合
从一个给定元素去找它所属于的集合,本质上就是从 儿子 找 双亲 的过程——『 双亲存储结构 』
合并两个集合,本质上也是将两棵不同子树的根节点划分到一个共同双亲下(或者让一个树成为另一个树的子树)
存储结构
数据元素 | A | B | C | D | E | F | G | H | I | J |
---|---|---|---|---|---|---|---|---|---|---|
数组下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
S[ ] | -1 | 0 | -1 | -1 | 1 | 1 | 2 | 3 | 3 | 3 |
#define SIZE 13
int UFSets[SIZE]; // 集合元素数组
// 初始化并查集
void Initial(int S[]) {
for(int il i<SIZE; i++)
S[i]=-1;
}
基础操作——并
并操作就是把给定的根合并
void Union(int S[], int Root1, int Root2){
if(Root1==Root2) return; // 要求 Root1 与 Root2 是不同的集合
S[Root2]=S[Root1]; // 将 Root2 连接到 Root1 下面
}
基础操作——查
查操作就是查给定元素的根
int Find(int S[], int x){
while(S[x]>0) // 循环找到 x 的根,根的 S[] 小于0
x=S[x];
return x;
}
可以合并二者,查并——将任意给定的两个元素合并到一个集合
时间复杂度:
-
并 O(1)
-
查 最坏 O(n),最好O(1)
查操作的时间复杂度与构造的并查集树的高度 h 有关,最坏情况下 h = n ,因此为了降低时间复杂度,就要求树的高度尽可能低,即:当合并两棵给定的并查集树的时候,应该让高度小的树称为高度大的树的子树
改进方案:让根节点对应的数值不全为 -1,而是存储其对应的结点总数,但为了与普通结点做区分,应该存储对应结点总数的相反数,例如
graph K-->E L-->E E-->B F-->B B-->A则 A 对应数组中的值应该是 -6
改进的并操作
通过限定 小树合并到大树 ,可以保证树额高度不超过 logn,因此 Find 的时间复杂度优化为 O(logn)
void Union(int S[], int Root1, int Root2){
if(Root1==Root2) return;
if(S[Root2]>R[Root1]) { // Root2 的结点更少
S[Root1] += S[Root2];
S[Root2]=Root1; // Root2 成为 Root1 的子树
}else{
S[Root2]+=S[Root1];
S[Root1]=Root1;
}
}
改进的查操作(压缩路径)
先找到根节点,再将查找路径上所有结点都挂到根节点下
使用优化后的查找来查找 L
// Find 操作优化,先找到根节点,在进行 『 压缩路径 』
int Find(int S[], int x){
int root = x;
while(S[root]>=0) root=S[root]; // 循环找到根
while(x!=root){ // 循环让 S[x]指向根
int t=S[x];
S[x]=root;
x=t;
}
return root; // 返回根节点编号
}
每次 Find 操作都使得树的高度逐渐降低,最终树的高度不超过 $O(\alpha(n)),\alpha(n)$ 是一个增长非常缓慢的函数,近似于常数
总结
$$
\begin{flalign}
& Find操作=最坏树高=O(n) \
& 将 n个独立元素通过多次 Union 合并为一个集合 O(n^2)(合并 n-1 个元素,每个元素查找时间复杂度为 O(n)) \
Union优化\Rightarrow \
& Find操作=最坏树高=O(\log n) \
& 将 n个独立元素通过多次 Union 合并为一个集合 O(n\log n)(合并 n-1 个元素,每个元素查找时间复杂度为 O(\log n)) \
Find优化\Rightarrow \
& Find操作=最坏树高=O(\alpha(n)) \
& 将 n个独立元素通过多次 Union 合并为一个集合 O(n\alpha(n))(合并 n-1 个元素,每个元素查找时间复杂度为O(n\alpha(n)))
\end{flalign}
$$
操作动画
第六章 图
6.1 图的定义
图 G 是由顶点集 V 和边集 E 组成,记为 G=(V, E),E(G) 表示顶点之间的关系
-
|V| 表示图 G 的顶点个数;|E| 表示图 G 的边条数
-
无向边(边)记作 (v, w) 或者 (w, v);有向边(弧)记作 <v, w>$\neq$<w, v>
边是描述两个顶点之间关系的概念,所以如果只有一条单独的边而没有两端的顶点,就无法构成一个图
简单图多重图
简单图 | 多重图(不考虑) |
---|---|
不存在重复的边 | 至少有两个顶点之间边数多于一条 |
且 不存在顶点到自身的边 | 且 允许顶点通过一条边和自己关联 |
顶点的度
无向图 | 有向图 |
---|---|
依附于该顶点的边的条数,记作 TD(v) | 入度: 以顶点 v 为终点的有向边的数目,记作 ID(v) 出度: 以顶点 v 为起点的有向边的数目,记作 OD(v) 度 = 入度 + 出度(TD(v) = ID(v) + OD(v)) |
路径和回路
路径 | 回路 |
---|---|
顶点 v1 到顶点 vn 之间的一条路径 v1, v2, ..., vn | 第一个顶点和最后一个顶点相同的路径 v1, v2, v3, ..., v1 |
简单路径:任何一个顶点都不重复出现 | 简单回路:只有第一个顶点在最后一次出现,其他顶点都不重复出现 |
连通和强连通,连通图和强连通图,连通分量和强连通分量
连通:无向图 从顶点 v 到顶点 w 之间之间有路径存在,则称 v 和 w 是连通的
强连通:有向图 从顶点 v 到顶点 w 之间,和从顶点 w 到顶点 v 之间都有路径
连通图 | 强连通图 |
---|---|
图中任意两个顶点是连通的 连通分量: 无向图中的极大连通子图 |
图中任意两个顶点都是强连通的 强连通分量: 有向图中的极大强连通分量 |
对于有 n 个顶点的无向图 G
- 若 G 是连通图,则最少要有 n-1 条边
- 若 G 是非连通图,则最多可能有 $C_{n-1}^2$ 条边
对于有 n 个顶点的有向图 G
- 若 G 是强连通图,则最少有 n 条边(形成回路)
如何理解 『 极大 』 的概念?
假设全国的铁路网是一张无向图,则大陆,台湾和海南的部分就分别构成一个连通分量(极大就是尽可能连接到尽可能多的顶点和尽可能多的边)
子图、生成子图、生成树和生成森林
子图 | 生成子图 | 生成树 | 生成森林 |
---|---|---|---|
从图 G 中挑出 能构成一个图 的子集 V' 和 E' 所构成的图 G'=(V', E') | 满足 V = V' 的子图 | 包含图中 全部顶点 的一个 极小连通子图(尽可能少的顶点和尽可能少的边,但是生成树限定了顶点数必须是全部顶点) | 非连通图的 全部连通分量 的生成树 |
对于一棵生成树,若有 n 个顶点,则一定有 n-1 条边,多加一条边就会形成回路,少一条边就会变成非连通图
边的权,带权图/网
给图的每条边附加一个权值,形成了带权图,也称作 网
带权路径长: 一条路径上所有边的权值之和
完全图
无向完全图 | 有向完全图 |
---|---|
无向图中任意两个顶点之间都存在边 $ |
E |
一个无向图的边如果大于 n-1 条,则一定存在回路
树和有向树
树 | 有向树 |
---|---|
不存在回路,且连通的无向图 | 一个顶点入度为 0,其余顶点入度均为 1 的有向图 |
6.2 图的存储
6.2.1 邻接矩阵
无向图的邻接矩阵中,0 表示两个顶点之间没有边,1 表示两个顶点之间有边
有向图的邻接矩阵中,i 行 j 列的 0 表示没有从 V[i] 到 V[j] 的弧,i 行 j 列的 1 表示有从 V[i] 到 V[j] 的弧
邻接矩阵的结构体
除了二维数组之外,还需要一个一维数组存储各个顶点的信息
#define MaxVertexNum 100 // 顶点最大数目
typedef struct {
char Vex[MaxVertexNum]; // 顶点表
int Edge[MaxVertexNum][MaxVertexNum]; // 邻接矩阵,边表
int Vexnum, arccnum; // 图的当前顶点数和边数
} MGraph;
$结点数为n的图G=(V,E)的邻接矩阵 A 是n\times n的。将G的顶点编号为v_1,v_2,\dots,v_n,则有$
$$
A[i][j]=
\begin{cases}
1, &若(v_i,v_j)或<v_i,v_j>是E(G)中的边 \
2, &若(v_i,v_j)或<v_i,v_j>不是E(G)中的边
\end{cases}
$$
对于无向图:第 i 个结点的度 = 第 i 行(或第 i 列)的非零元素个数
对于有向图:
-
第 i 个结点的出度(入度)=第 i 行(列)的非零元素个数;
-
度 = 第 i 行的非零元素个数 + 第 i 列的非零元素个数
空间复杂度:$O(|V|^2)$ ——只和顶点数有关,和边数无关
对于无向图的邻接矩阵其实是一个对称矩阵,所以可以考虑对称矩阵的压缩存储方式
邻接矩阵的性质
图 G 的邻接矩阵为 A(矩阵元素为 0 和 1 ),则 A^n 的元素 A^n[i][j] 等于顶点 i 到顶点 j 长度为 n 的路径的数目
6.2.2 邻接表(顺序 + 链式存储)
结构体
typedef char VertexType;
// 顶点
typedef struct VNode {
VertexType data; // 顶点信息
ArcNode *first; // 第一条边/弧
}VNode, AdjList[MaxVertexNum];
// 边 / 弧
typedef struct ArcNode {
int adjvex; // 边 / 弧指向结点
struct ArcNode *next; // 指向吓一跳弧的指针
// InfoType info; // 边权值
}ArcNode;
// 邻接表存储的图
typedef struct {
AdjList vertices;
int vexnum, arcnum;
}ALGraph;
在存储无向图的时候,每条边都会被记录两次,所以邻接表存储无向图的空间复杂度为 $O(|V|+2|E|)$
在存储有向图的时候,每条弧只会对应一个边结点,所以存储有向图的空间复杂度为 $O(|V|+|E|)$
对比邻接表和邻接矩阵
- | 邻接表 | 邻接矩阵 |
---|---|---|
空间复杂度 | 无向图 O(|V|+2|E|);有向图 O(|V|+|E|) | O(|V|^2) |
适用于 | 存储稀疏图 | 存储稠密图 |
表示方式 | 不唯一 | 唯一 |
计算度/出度/入度 | 计算有向图的度、入度不方便,其余很方便 | 必须遍历对应行或列 |
找相邻的边 | 找有向图的入边不方便,其余很方便 | 必须遍历对应行或列 |
6.2.3 十字链表法
存储有向图
弧结点
tailvex | headvex | hlink | tlink | (info) |
---|---|---|---|---|
弧尾结点 | 弧头结点 | 弧头相同的指针 | 弧尾相同的指针 | 权值 |
顶点结点
data | firstin | firstout |
---|---|---|
顶点数值 | 作为弧尾指向的第一条弧 | 作为弧头指向的第一条弧 |
空间复杂度: $O(|V|+|E|)$
找到指定顶点的所有出边——沿着 hlink
找到指定顶点的所有入边——沿着 tlink
注意: 十字链表只能用于存储 有向图
6.2.4 邻接多重表
存储无向图
边结点
i | j | info | iLink | jLink |
---|---|---|---|---|
顶点 | 顶点 | 权值 | 依附于顶点 i 的下一条边 | 依附于顶点 j 的下一条边 |
顶点结点
data | firstedge |
---|---|
数据域 | 与该顶点相连的第一条边 |
空间复杂度: $O(|V|+|E|)$
注意: 邻接多重表只能用于存储 无向图
除了邻接矩阵,其他三种存储方式,对于一个确定的图的表示方式均不唯一
6.2.5 图的基本操作
操作 | 介绍 | 无向图邻接矩阵 | 无向图邻接表 | 有向图邻接矩阵 | 有向图邻接表 |
---|---|---|---|---|---|
Adjacent(G, x, y) |
判断图 G 是否存在边 <x, y> 或 (x, y) | O(1) | O(|V|) | O(1) | O(|V|) |
Neighbors(G, x) |
列出图 G 中与结点 x 邻接的边 | O(|V|) | O(|V|) | O(|V|) | O(|V|+|E|) |
InsertVertex(&G, x) |
在图 G 中插入顶点 x | O(1) | O(1) | O(1) | O(1) |
DeleteVertex(&G, x) |
从图 G 中删除顶点 x | O(|V|) | O(|E|) | O(|V|) | O(|V|+|E|) |
AddEdge(&G, x, y) |
若无向边 (x, y) 或有向边 <x, y> 不存在,则向图 G 中添加该边 | O(1) | O(1) | O(1) | O(1) |
RemoveEdge(&G, x, y) |
若无向边 (x, y) 或有向边 <x, y> 存在,则从图 G 中删除该边 | O(1) | O(|V|) | O(1) | O(|V|) |
FirstNeighbor(G, x) |
求图 G 中顶点 x 的第一个邻接点,若有则返回顶点号。若 x 没有邻接点或者图中不存在 x ,则返回 -1 | O(|V|) | O(1) | O(|V|) | O(1) |
NextNeighbor(G, x, y) |
假设图 G 中顶点 y 是顶点 x 的一个邻接点,返回除 y 之外顶点 x 的下一个邻接点的顶点号,若 y 是 x 的最后一个邻接点,则返回 -1 | O(|V|) | O(1) | O(|V|) | O(1) |
GetEdgeValue(G, x, y) |
获取图 G 中边 (x, y) 或 <x, y> 对应的权值 | O(1) | O(|V|) | O(1) | O(|V|) |
SetEdgeValue(&G, x, y, v) |
设置图 G 中边 (x, y) 或 <x, y>对应的权值为 v | O(1) | O(|V|) | O(1) | O(|V|) |
在考虑有向图的邻接顶点,一般考虑出度连接,而非入度 因此时间复杂度是 O(1)
6.3 图的遍历
6.3.1 广度优先遍历
类似于树的层序遍历,先遍历和根节点临近的结点,在依次对这些结点进行广度优先遍历
因此实现图的广度优先遍历也需要设置一个辅助队列
广度优先遍历的实现
- 找到一个顶点相邻的所有顶点
- 标记哪些顶点北方问过
- 需要一个辅助队列
// 访问标记数组
bool visited[MAX_VERTEX_NUM]={false};
// 广度优先遍历
void BFS(Graph G, int v) {
Queue Q;
InitQueue(&Q); // 初始化队列
visit(v); // 访问初始顶点 v
visited[v] = true; // 将 v 的访问队列标记为访问过
EnQueue(&Q, v); // 将顶点 v 入队
while(!IsEmpty(Q)) {
DeQueue(&Q, &v); // 队头顶点出队,将顶点值赋给 v
// 访问 v 的所有的邻接结点
for(int w=FirstNeighbor(G, v); w>=0; w=NextNeighbor(Gm v, w))
// 检测 v 的邻接结点 w 是否被访问过
if(!visited[w]) {
visit(w); // w 未被访问,则访问
visited[w] = true; // 将 w 的访问序列标记为已访问
EnQueue(Q, w) // 将顶点 w 入队
}
// 访问完毕,进行下一层循环
}
}
采用邻接矩阵的方式所获得广度优先遍历序列是唯一的
采用邻接表的方式所获得的广度优先遍历序列不唯一(邻接表表示方法不唯一)
对于上述实现 BFS 的代码而言,如果考虑 G 是非连通图,则是否能够遍历到各个连通分量呢?
答:不能,因此需要额外设置一个函数在每次 BFS 结束之后,对访问数组进行依次遍历,看是否还有未被访问的顶点,再依次通过这些顶点对不相邻的连通分量进行遍历
// 遍历访问标记数组
void BFSTraverse(Graph G) {
// // 初始化访问队列
// for(int i=0; i< G.vexnum; i++)
// visited[i] = false;
// 遍历访问队列找到下一个未被访问的顶点
for(int i=0; i<G.vexnum; i++)
if(!visited[i])
BFS(G, i); // 对未被访问到的顶点进行 BFS
}
空间复杂度: $S(n)=O(|V|)$
时间复杂度: $邻接矩阵:T(n)=O(|V|^2);邻接表:T(n)=O(|V|+|E|)$
广度优先生成树
按照广度优先算法遍历一个无向图的时候,对于 n 个顶点,总共沿着 n-1 条边进行依次访问各个顶点,而顶点和这些边所构成的最小生成树也就是广度优先生成树
广度优先生成森林
对于非连通图的广度优先遍历,可以的到广度优先生成森林
6.3.2 深度优先遍历
图的深度优先遍历类似于树的先根遍历,先访问第一个邻接顶点,然后如果这个顶点还有其他邻接顶点,则先访问该顶点的其他临界顶点,如果这个顶点没有其他顶点,则返回上一级访问上一级顶点的其他邻接顶点,使用了递归的思想(也可以使用辅助栈)
深度优先遍历的实现
bool visited[MAx_VERTEX_NUM]={false}; // 访问标记数组
void DFS(Graph G, int v) { // 从顶点 v 出发,深度优先遍历图 G
visit(v);
visited[v] = true;
for(int w=FirstNeighbor(G, v); w>=0; w=NextNeighbor(G, v, w))
if(!visited[w]) { // w 为 v 的尚未访问的邻接顶点
DFS(G, w); // 没有访问,则对其进行深度优先遍历
}
}
优化访问数组
// 遍历访问标记数组(防止图有多个连通子图)
void DFSTraverse(Graph G) {
// 遍历访问队列找到下一个未被访问的顶点
for(int i=0; i<G.vexnum; i++)
if(!visited[i])
DFS(G, i); // 对未被访问到的顶点进行 DFS
}
空间复杂度:
- 最坏情况下,图是一条线,递归的深度等于顶点个数,即 $S(n)=O(|V|)$
- 最好情况下,图是一棵度为 n-1 的树,递归深度为 1 ,即 $S(n)=O(1)$
时间复杂度:$O(|V|^2)、O(|V|+|E|)$
6.4 图的应用
6.4.1 最小生成树
对于一个 带权连通无向图 ,生成树中的权值之和最小的那一棵,就是最小生成树
注意:最小生成树可能不唯一
普里姆算法(Prim)
从某一个顶点开始构建生成树,每次将代价最小的顶点纳入生成树,直到所有顶点都纳入为止
时间复杂度: $O(|V|^2)$
实现算法思路:
设置一个标记数组,用于标记每个顶点是否加入到最小生成树中
设置一个代价数组,用于标记每个顶点进入最小生成树的代价
选择一个顶点开始,将其标记数组中的值置为 true,将其到所有的邻接顶点的代价填入代价数组,如果有顶点非邻接,则代价为 $\infty$
然后选择其他顶点中,代价数组对应值最小的顶点,将其标记数组中的值置为 true,将其所有邻接顶点的代价,如果小于代价数组中的值则填入,否则什么也不做
重复步骤 4 直到所有的结点都在标记数组的值中被置为 true
bool isJoin[MAx_VERTEX_NUM]={false}; // 标记数组 int lowCost[MAx_VERTEX_NUM]; // 代价数组
n 个顶点,每次选择一个顶点,每个顶点要遍历周围所有邻接的顶点找到代价最小的,再次遍历所有顶点更新代价数组,即 循环轮次 * ( 遍历所有邻接结点找到代价最小 + 遍历所有邻接结点替换代价数组)—— (n-1)*2n 次,即 $O(n^2)$
克鲁斯卡尔算法(Kruskal)
每次选择一条权值最小的边,使这条边两头连通,若两头原本已经连通,则舍弃这条边,直到所有结点都被连通
时间复杂度:$O(|E|\log_2|E|)$
实现算法思路:
- 先将各条边按照权值进行从第到高排序
- 从低到高依次检查权值表中的边,检查两个顶点是否连通(使用并查集来检测是否属于一个集合)
- 如果是,则删除该边(可以将权值表中该边对应的权值置为 $\infty$)
- 如果不是,则保留该边
e 条边组成的并查集树的高度大概是 loge 数量级,而依次检查每条边时间复杂度为 e,因此克鲁斯卡尔算法的时间复杂度为 O(e * loge)
6.4.2 求最短路径问题
BFS 算法求单源无权最短路径
BFS 求单源最短路径的原理是,每向下遍历一层,所有顶点到源点的距离 + 1,所以在原有的 BFS 算法基础上,增加两个数组,分别表示 该点到源点的路径长度 和 路径上该点的上一级顶点
#define 65535 MAX
int d[MAx_VERTEX_NUM]={MAX};
int path[MAx_VERTEX_NUM]={-1};
bool visited[MAx_VERTEX_NUM]={false};
// 求顶点 u 到其他顶点的最短路径
void BFS_MIN_Distance(Graph G, int u) {
// d[] 表示顶点 u 到顶点 i 之间的最短路径
d[u] = 0;
visited[u]=true;
EnQueue(&Q, u);
while(!IsEmpty(Q)) {
DeQueue(&Q, &u);
for(w=FirstNeighbor(G, u); w>=0; w=NextNeighbor(G, u, w))
if(!visited[w]) {
d[w]=d[u] + 1; // 记录该点到源点的距离,等于路径上上一级到源点的距离 + 1
path[w]=u; // 记录在路径上该点的上一个顶点
visited[w]=true;
EnQueue(&Q, w);
}
}
}
- | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|
d[] | 1 | 0 | 2 | 3 | 2 | 1 | 2 | 3 |
path[] | 2 | -1 | 6 | 3 | 1 | 2 | 6 | 7 |
例如:求从源点到顶点 8 的最短路径 8 <- 7 <- 6 <- 2
长度为 3
Dijkstra 算法
初始化三个长度为 MAx_VERTEX_NUM
的数组,分别是 final[]
、dist[]
和 path[]
path[]
:标记该顶点在最短路径中的上一个顶点
dist[]
:记录从源点到该点的距离,初始时均为 $\infty$
final[]
:标记该顶点是否已在最短路径中
- 设置源点的
final[0]=true
、dist[0]=0
、path[0]=-1
- 从
fianl[]
数组中第一个值为 true 的顶点i
开始,遍历其所有邻接且在final[]
数组中值为 false 的顶点,如果cost + dist[i]
(cost
是i
到该邻接顶点的权值) 小于该邻接顶点到源点的距离,则令其代替之 - 在所有
final[]
数组中值为 false 的顶点中,找到距离源点最小的下一个顶点j
,令final[j]=true
,重复 2 3 直到所有顶点在final[]
中的值都为 true
- | v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|---|
final[] |
true | true | true | true | true |
dist[] |
0 | 8 | 9 | 7 | 5 |
path[] |
-1 | 4 | 1 | 4 | 0 |
例:寻找 v0 到 v2 的路径 v2 <- v1 <- v4 <- v0
路径带权长度为 9
练习:写出符合的伪代码
bool final[MAx_VERTEX_NUM]={false};
int dist[MAx_VERTEX_NUM]={MAX};
int path[MAx_VERTEX_NUM]={-1};
void Dijkstra(Graph G, int u)(Graph G, int u) {
final[u] = true;
dist[u] = 0;
visited[u]=true;
EnQueue(&Q, u);
while(!IsFinal(final)) { // 是否所有顶点的 final 值都为 true
for(w=FirstNeighbor(G, u); w>=0; w=NextNeighbor(G, u, w)){
// 修改 path 和 dist
if(dist[w]==MAX) {
dist[w]=GetEdgeValue(G, u, w);
path[w]=u;
} else {
if(dist[w] >= GetEdgeValue(G, u, w) + dist[u])
continue;
else {
GetEdgeValue(G, u, w) + dist[u];
path[w]=u;
}
}
int d = dist; // 拷贝一份 dist 用于寻找最小且 final 为 false 值
int next;
// 找到 dist 数组中最小的且 final 值不为 true 的顶点作为下一次遍历的起始顶点
for(int i=0; i<G.vertexnum; i++) {
i = FindMinNum(d); // d 数组中最小的元素的索引
if(final[i] == true) {
d[i] = MAX;
continue;
} else {
next = i; // 找到最小且 final 值为 false 的顶点,作为下一次遍历的起始
} // else
} // for
} // for
} // while
} // void
如果带权图中有 负值 的权值,则该算法会失效
Floyd 算法
设置两个顶点矩阵,A 矩阵表示各顶点之间的最短路径长度,path 矩阵表示两个顶点之间中转的顶点
# 初始 :不允许在其他顶点中转,最短路径是?
$A^{(-1)}=\left[\begin{matrix}0 &6 &13 \ 10 &0 &4 \ 5 &M &0\end{matrix}\right]$ $path^{(-1)}=\left[\begin{matrix}-1 &-1 &-1 \ -1 &-1 &-1 \ -1 &-1 &-1\end{matrix}\right]$
# k :若允许在 v0,v1,...,vk 中转,则最短路径是?
遍历上一阶段的矩阵 A ,检查 $A{(k-1)}[i][j]>A[i][k]+A^{(k-1)}[k][j]$ 是否满足:
- 若满足,则令 $A{(k)}[i][j]=A[i][k]+A{(k-1)}[k][j]; path[i][j]=k$
- 若不满足,则什么也不做
例如对于 $A^{(-1)}[2][1]$ 则检查 $A^{)(-1)}[2][1] > A{(-1)}[2][0]+A[0][1]$ ,显然成立,则将 $A{(0)}[2][1]=5+6=11; paht[2][1]=0$
$A^{(0)}=\left[\begin{matrix}0 &6 &13 \ 10 &0 &4 \ 5 &11 &0\end{matrix}\right];\ path^{(0)}=\left[\begin{matrix}-1 &-1 &-1 \ -1 &-1 &-1 \ -1 &0 &-1\end{matrix}\right]$
$A^{(1)}=\left[\begin{matrix}0 &6 &10 \ 10 &0 &4 \ 5 &11 &0\end{matrix}\right];\ path^{(1)}=\left[\begin{matrix}-1 &-1 &1 \ -1 &-1 &-1 \ -1 &0 &-1\end{matrix}\right]$
$A^{(2)}=\left[\begin{matrix}0 &6 &10 \ 9 &0 &4 \ 5 &11 &0\end{matrix}\right];\ path^{(2)}=\left[\begin{matrix}-1 &-1 &1 \ 2 &-1 &-1 \ -1 &0 &-1\end{matrix}\right]$
$\dots \dots \dots$
代码实现
void Floyd(Graph G){
int A = [MAx_VERTEX_NUM][MAx_VERTEX_NUM]=G.Vex; // 假设图 G 是用邻接矩阵存储
int path = [MAx_VERTEX_NUM][MAx_VERTEX_NUM]={-1};
for(int k=0; k<G.vextnum; k++) { // 考虑以 Vk 作为中转点
for(int i=0; i<G.vextnum; i++) { // 遍历整个矩阵
for(int j=0; j<G.vexnum; j++) {
if(A[i][j]>A[i][k]+A[k][j]) {
A[i][j]=A[i][k]+A[k][j]; // 更新最短路径
path[i][j]=k; // 中转点
} // if
} // for3
} // for2
} // for1
}// void
时间复杂度:$O(|V|^3)$
空间复杂度:$O(|V|^2)$
例:对于最终的 A 矩阵和 path 矩阵分别为
$$
A=\left[\begin{matrix}
0 &2 &1 &3 &4 \
M &0 &M &1 &2 \
M &1 &0 &2 &3 \
M &M &M &0 &1 \
M &M &M &M &0
\end{matrix}\right]\
path=\left[\begin{matrix}
-1 &2 &-1 &2 &3 \
-1 &-1 &-1 &-1 &3 \
-1 &-1 &-1 &1 &3 \
-1 &-1 &-1 &-1 &-1 \
-1 &-1 &-1 &-1 &-1 \
\end{matrix}\right]
$$
找到 v0 到 v4 的路径 v4 <- v3 <- v2 <- v1 <- v0
路径长度为 4
对于有 n 个顶点的图,弗洛伊德矩阵的比大小总共需要计算 $n^{n-1}$ 次
Floyd 算法可以解决带有负权值的图,但前提是这个负权值不能构成一个回路
6.4.3 有向无环图描述表达式
若一个有向图中不存在环,则称这个有向图为有向无环图,简称 DAG 图
DAG 描述表达式
- 把各个操作数不重复地排成一排
- 标出各个运算符的生效顺序
- 按顺序加入运算符,注意 『 分层 』
- 从底向上逐层检查同层的运算符是否可以合并
6.4.4 拓扑排序
AOV 网
用 DAG 图表示一个工程,顶点表示活动,有向边 <vi, vj> 表示活动 vi 必须先于活动 vj 进行
AOV 网一定是一个有向无环图
拓扑排序的实现
- 从 AOV 网中选择一个没有 前驱 的顶点并输出
- 从网中删除该顶点和所有以它为起点的有向边
- 重复 1 和 2 直到当前 AOV 网为空或当前网中不存在无前驱的顶点为止
DAG 图一定存在拓扑序列,而存在回路的有向图一定不存在拓扑序列
邻接表存储 AOV 网
typedef struct ArcNode { // 边表结点
int adjvex; // 该弧所指向的顶点的位置
struct ArcNode *nextarc; // 指向下一条弧
// InfoType info'; // 边网的权值
}
typedef struct VNode {
VertexType data; // 顶点信息
ArcNode *firststarc; // 指向第一条依附于该顶点的弧的指针
}VNode, AdjList[MaxVertexNum];
typedef struct {
AdjList vertices; // 邻接表
int vexnum, arcnum; // 图的顶点数和弧数
} Graph; // Graph 是以邻接表存储的图类型
拓扑排序
int indegree[G.vexnum]; // 设置一个数组用于存储顶点 v 的全部
for(int i; i<G.vexnum; i++) {
indegree[i] = InDegreeArc(G, i); // 求给定顶点的入度
}
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; // 初始化计数器,记录当前已经输出的顶点数
int i; // 用于暂存出栈的顶点
int visited[G.vexnum];
while(!IsEmpty(S)) {
Pop(&S, &i); // 栈顶元素出栈
visited[count++]=i; // 设置该元素为已访问
// 遍历以 i 为弧头的邻接顶点
for(VNode *p=G.vertices[i].firststarc; p; p=p->nextarc) {
// 将所有邻接顶点的入度减少 1,并且将入度减为 0 的顶点压入栈 S
int v=p->adjvex; // p 指向边表结点,令 v 等于结点中记录的顶点位置
if(!(--indegree[v])) // 如果该顶点的度数减一后为 0 ,则将该邻接点入栈
Push(&S, v);
} // for1
} // while
if(count < G.vexnum) return false; // 排序失败,未能通过拓扑访问所有顶点
else return true; // 排序成功
}
// 使用递归的伪代码
bool Topo(G, v) {
if(v.indegree == 0) // 如果顶点 v 的入度为0
v.visited; // 访问顶点 v
if(visit(v) == NULL) return true; // 是最后一个顶点,拓扑结束
t=visit(v); // 令 t 等于下一个未被访问的顶点
G.remove(v); // 在图中移除 v,同时修改每个元素的 indegree 值
Topo(G, t); // 对 t 进行拓扑排序
v=visit(v); // 如果顶点 v 的入度不为 0, 则访问下一个顶点
Topo(G, v);
}
时间复杂度:$O(|V|+|E|)$
如果采用邻接矩阵,则时间复杂度为 $O(|V|^2)$
对于邻接表来说,找到给定元素的入度并不方便,因此 indegree 数组的建立会耗费大量时间,因此可以考虑 逆拓扑排序 即依次找出度为 0 的顶点,但是这样的话,因为邻接表难以找到弧尾为指定顶点的弧头顶点,因此在进行拓扑排序会很低效
逆拓扑排序的实现(DFS)
已知逆拓扑排序每次输出的顶点都是出度为 0 的顶点,考虑有哪种图的遍历方式,会访问到具有这一特征的顶点?
DFS 深度优先遍历中,每次会先沿着一条路径找到头,此时这个 头 就是出度为 0 或者说去掉已访问的顶点后 出度为 0的顶点,因此只需要在 DFS 一次遍历结束后输出访问顶点,得到的就是逆拓扑排序
bool visited[MAx_VERTEX_NUM]={false}; // 访问标记数组
void DFS(Graph G, int v) { // 从顶点 v 出发,深度优先遍历图 G
visit(v);
visited[v] = true;
for(int w=FirstNeighbor(G, v); w>=0; w=NextNeighbor(G, v, w))
if(!visited[w]) { // w 为 v 的尚未访问的邻接顶点
DFS(G, w); // 没有访问,则对其进行深度优先遍历
}else{
return false; // 沿着一条路径找到了已经访问过的顶点,则说明图中存在环路
}
print(v); // 输出顶点,得到的就是逆拓扑排序序列
}
void DFSTraverse(Graph G) {
// 遍历访问队列找到下一个未被访问的顶点
for(int i=0; i<G.vexnum; i++)
if(!visited[i])
DFS(G, i); // 对未被访问到的顶点进行 DFS
}
如果图中有环,如何通过修改算法确定该图没有拓扑序列?
当沿着一条路径访问到已经访问过的顶点,则说明出现了环路
if(!visited[w]) { DFS(G, w); }else{ return false; }
第七章 查找
7.1 查找的基本概念
查找——在数据集合中找到符合要求的数据
查找表——用于查找的数据集合
关键字——唯一标识数据元素的数据项
查找算法的评价标准
查找长度——在查找运算中,需要对比关键字的次数称为查找长度
平均查找长度(ASL)——所有查找过程中进行关键字的比较次数的平均值
通常认为查找每个元素的概率是相同的,且要考虑查找成功、查找失败两种情况下的 ASL
7.2 顺序查找和折半查找
7.2.1 顺序查找
对于顺序表,根据数组下标递增顺序扫描每个元素直到找到对应相等的元素,或者查找失败
顺序查找的实现
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;
}
改进的顺序查找
在普通顺序查找时候,需要进行两次判断,一次判断循环的边界条件,一次判断查找是否成功,能否简化成只需要一次判断即可?
将下标为 0 的元素设置为 『 哨兵 』 元素,查找某个元素时候,将该元素放在下标为 0 的位置,然后依次从最后一个元素向第一个元素遍历查找,当找到相等的元素时候,直接返回下标
// 含有哨兵的顺序查找
int Search_Seq(SSTable ST, ElemType key) {
ST.elem[0] = key;
int i;
for(i = ST.TableLen; ST.elem[i]!=key; --i); // 从后往前查找
return i; // 无需判断直接返回元素下标
}
时间复杂度:O(n)
ASL 成功:$\frac{1+2+3+\dots+n}{n}=\frac{n+1}2$ ,O(n)
ASL 失败:查找失败要对比 n+1 次,O(n)
思考:对于一个有序表,是否需要每次查找从头遍历到尾呢?
答:不需要,假设有序表是从小到大排序,当扫描到有值 > 当前寻找的元素,或者遍历最后一个元素 < 当前寻找的元素,即可认定为查找失败
ASL 失败:$\frac{1+2+3+\dots+n+n}{n+1}=\frac n2+\frac n{n+1}$ (注意:加两次 n 的含义:大于
e[n-1]
小于e[n]
(对比 n 次) ;大于e[n]
(对比 n 次))
还可以通过找频率,让频率更大的元素在顺序表中的位置更靠前,从而提高整体的 ASL 成功(但是 ASL 失败不变)
7.2.2 折半查找
思考:对一个有序的序列进行查找,如果按照顺序查找的思想,需要挨个和表中的元素对比大小,但是当查找的元素大于表中第 i 个元素的时候,其已经大于从第 1 到第 i-1 个元素,前面的 i-1 次比大小就没有必要,那么这个 i 应该取多少比较合适呢? 显然,综合来看 n/2 最好
折半查找 又称 『 二分查找 』 ,仅适用于有序的顺序表
- 初始化三个指针 low、high、mid,low 指向第一个元素,high 指向最后一个元素,mid 指向第 (low + high)/2 个元素(向上向下取整皆可)
- 对比查找元素和 mid 所指元素的大小
- 如果查找元素大于 mid 所指向的元素,则 low = mid + 1
- 如果查找元素小于 mid 所指的元素,则 high = mid - 1
- 重新计算 mid 的值,重复 2 直到
elem[mid] == e
(查找成功)或者 low > high (查找失败)
折半查找实现
// 折半查找
int Binary_Search(SSTable L, ElemType key) {
int low = 0, high = L.TableLen-1, mid;
while(low <= high) {
mid = (low + high)/2; // 取 mid
if(L.elem[mid] == key)
return mid;
else if(L.elem[mid] > key) high=mid-1;
else low = mid+1;
}
return -1; // 查找失败
}
时间复杂度:O(logn)
ASL 成功 和 ASL 失败:基于查找判定树具体分析
折半查找判定树的构造
如果 low 和 high 之间有奇数个元素,则 mid 分隔后,左右两部分元素个数相等
如果 low 和 high 之间有偶数个元素,则 mid 分隔后
- mid = (low + high)/2 向下取整,则左半部分比右半部分少一个元素
- mid = (low + high)/2 向上取整,则左半部分比右半部分多一个元素
即,对于一棵折半查找的判定树,对于任何一个结点
-
如果向下取整,左子树比右子树少 1 或 0 个结点
-
如果向上取整,则左子树比右子树多 1 或 0 个结点
向下取整,右比左多;向上取整,左比右多
失败结点即判定树的空链域,即一棵二叉树满足 空链域 = 结点数 + 1,因此查找失败结点 = 成功结点 + 1
查找判定树的结点关键字满足 左 < 中 < 右,即满足平衡二叉树的定义,中序遍历查找判定树,得到的是一个有序序列
假设查找判定树的高度为 h(不包含失败结点),前 h-1 层符合满二叉树,第 h 层至少有 1 个结点,至多有 2^(h-1)个结点 因此树高满足 $h=\log_2 (n+1)(向上取整)$ ,查找成功 ASL ≤ h,查找失败 ASL ≤ h
折半查找的速度一定比顺序查找要快吗?
顺序序列 大部分情况下是,但是如果查找的元素是最小的的那几个,则 顺序查找可能优于折半查找
7.3 二叉排序树
7.3.1 二叉排序树的定义
二叉排序树,又称二叉查找树,是满足以下性质的二叉树:
- 左子树上所有结点关键字均小于根节点关键字
- 右子树上所有结点关键字均大于根节点关键字
- 左子树和右子树又各是一棵二叉排序树
二叉排序树的查找的实现
- 若树非空,则目标值与根结点的值比较
- 若相等,则成功
- 若小于,则在左子树上寻找
- 若大于,则在右子树上寻找
- 查找成功,返回指针节点;查找失败,返回 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(h) 的空间复杂度,h 为树的高度
7.3.2 二叉排序树的操作
二叉排序树的插入
算法思想
-
若原二叉排序树为空,则直接插入结点
-
若不为空
- 关键字 k 小于根节点,插入左子树
- 关键字 k 大于根节点,插入右子树
// 在二叉排序树插入关键字为 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)
// 在二叉排序树插入关键字为 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; // 插入成功
}
BSTNode *pre = NULL, *s=*T;
int flag = 0; // 表示 s 是 pre 的左孩子还是右孩子
while(s!=NULL) {
pre = s;
if(k == s->key) return 0; // 存在相同关键字的结点,插入失败
else if(k < s->key) {
s = s->lchild; // 插入左子树
flag = 0;
}
else {
s = s->rchild; // 插入右子树
flag = 1;
}
}
s=(BSTree)malloc(sizeof(BSTNode));
s->key = k;
s->lchild = s->rchild = NULL;
if(flag == 0) pre->lchild = s;
else pre->rchild = s;
return 1; // 插入成功
}
构造二叉排序树
// 构造二叉排序树
void Creat_BST(BSTree *T, int str[], int n) {
T=NULL;
int i = 0;
while(i < n) {
BST_Insert(&T, str[i]);
i++;
}
}
在构造一棵二叉排序树的时候,给出的序列不同,构造的二叉排序树也不同
二叉排序树的删除
搜索找到目标结点
-
如果被删除的结点是叶子节点,则直接删除
-
如果被删除的结点只有一棵左子树或者右子树,让该结点的子树变为该结点父结点的子树,即该节点子树的根结点代替该结点
-
如果被删除的结点既有左子树又有右子树,让该结点在中序序列的后继(前驱)节点代替该结点,然后删除该结点的后继(前驱)结点
这样可以保证删除该结点后的 BST 中序遍历仍然是有序序列
7.3.3 二叉排序树的查找效率
查找长度——在一次查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作的时间复杂度
查找成功的 ASL
对于一棵二叉排序树的查找效率,很大概率上取决于这棵树的高度 h
log n < h < n,因此查找的时间复杂度为:O(logn) ~ O(n)
查找失败的 ASL
与查找成功相似,也是取决于树的高度 h
7.3.4 平衡二叉树
平衡二叉树(AVL)——树上任一结点的左子树和右子树高度之差不超过 1
// 平衡二叉树结点
typedef struct AVLNode {
int key; // 数据域
int balance; // 平衡因子
struct AVLNode *lchild, *rchild;
}AVLNode, *AVLTree;
平衡二叉树的插入
插入新结点之后,如何保持平衡?
首先,从插入点往回寻找第一个不平衡的结点(balance > 1),调整以该结点为根的子树,即每次调整的对象都是 『 最小不平衡子树 』
如何调整最小不平衡子树?
调整 A —— LL
目标:恢复平衡;符合 BST 特性
1)右单旋转,让 A 的左子树的根节点 B 代替 A 成为根节点,A 成为 B 的右孩子,原本 B 的右子树变为 A 的左子树
调整 A —— RR
2)左单旋转,让 A 的右子树的根节点 B 代替 A 成为根节点,A 成为 B 的左孩子,原本 B 的左子树变为 A 的右子树
调整 A —— LR
3)先让 A 的左子树左单旋转,让 A 的左子树的根节点 B 的右子树的根节点 C 成为 A 的左子树根节点,然后整体右旋转,让 C 代替 A 成为根节点,A 成为 C 的右子树的根节点,C 的右子树变为 A 的左子树
左旋调整左子树,右旋调整最小不平衡树
调整 A —— RL
4)先让 A 的右子树右单旋转,让 A 的右子树的根节点 B 的左子树的根节点 C 成为 A 的右子树的根节点,然后整体左旋转,让 C 代替 A 成为根节点,A 成为 C 的左子树的根节点,C 的左子树变为 A 的右子树
右旋调整右子树,左旋调整最小不平衡树
操作实现(核心部分)
// 左旋操作,f 是给定根节点
AVLNode *p = f->rchild;
AVLNode *gf = f->parent;
f->rchild = p->lchild; // 给定结点右孩子指向其原本右孩子的左孩子
p->lchild = f; // 原本右孩子的左孩子指向给定结点
gf->lchild/rchild = p; // 原本右孩子代替给定结点的位置
// 右操作,f 是给定根节点
AVLNode *p = f->lchild;
AVLNode *gf = f->parent;
f->lchild = p->rchild; // 给定结点左孩子指向其原本左孩子的右孩子
p->rchild = f; // 原本左孩子的右孩子指向给定结点
gf->lchild/rchild = p; // 原本左孩子代替给定结点的位置
平衡二叉树的递归特性
对于一颗平衡二叉树来说,任意结点左子树和右子树的高度之差不超过 1,假设以 $n_h$ 表示深度为 h 的平衡树中含有的最少结点数,则有 $n_0=0,n_1=1,n_2=2$ ,并且有 $n_h=n_{h-1}+n_{h-2}+1$
因此,对于有 x 个结点的平衡二叉树来说,其最大高度不超过 h,h 满足 $n_{h-1}\leq x\leq n_h$
根据这个递推式,可以最终得出的是,对于一棵平衡二叉树,其最大深度为 O(log n) ,平均查找长度/查找时间的复杂度为 O(log n)
平衡二叉树的删除
-
按照二叉排序树的删除操作删除对应的结点
-
从删除结点的父节点开始依次向上扫描各个根节点的平衡因子
-
如果都小于 2 ,则什么也不做
-
如果有结点的平衡因子等于 2 ,则根据删除子孙的为位置,对最高的儿子、孙子进行平衡调整(LL/RR/LR/RL)
-
-
如果调整导致不平衡向上传导,重复 2 直到整棵树符合平衡二叉树(?)
找到最高的儿子、孙子的含义是,根据孙子和儿子的位置确定平衡的方法
- 最高的儿子在右子树上,最高的孙子在最高的儿子的右子树上——RR 调整
- 最高的儿子在左子树上,最高的孙子在最高的儿子的左子树上——LL 调整
- 最高的儿子在右子树上,最高的孙子在最高的儿子的左子树上——RL 调整
- 最高的儿子在左子树上,最高的孙子在最高的儿子的右子树上——LR 调整
不平衡向上传导 的含义是:对子树的调整可能导致子树的高度发生变化,进而导致子树的根结点的根结点所在的树不再平衡,发生了最小不平衡树从子树的根节点传递到该结点的根节点,因此需要继续调整,并且第二次的调整仍有可能继续发生这种传导,直到整棵树
例如本来右子树的高度就比左子树低,删除右子树的某一个结点后,对右子树进行平衡调整,导致右子树的高度再次 -1,不平衡就从右子树传递到整棵树,这时就需要对整棵树进行平衡调整
平衡二叉树的删除操作的时间复杂度为 O(log n)
7.3.5 红黑树(RBT)
- | BST | AVL | RBT |
---|---|---|---|
Search | O(n) | O(log n) | O(log n) |
Insert | O(n) | O(log n) | O(log n) |
Delete | O(n) | O(log n) | O(log n) |
考虑到平衡二叉树,为什么已经插入删除寻找的时间复杂度都是最优,还会出现红黑树?
平衡二叉树的缺点:虽然找到对应的元素非常快速,但是对于平衡二叉树的调整是一件非常复杂的事,尤其是当平衡二叉树的树高 h 非常大的时候,每次调整的开销都会非常大,如何考虑一种即能保证查询最优,同时又不需要频繁调整树结构的数据结构,就成为红黑树出现的前提
平衡二叉树:适用于以查为主,很少插入/删除的场景
红黑树:适用于频繁插入/删除的场景
红黑树的定义
-
红黑树是二叉排序树
-
每个结点或是红色的,或是黑色的
-
根节点是黑色的
-
叶节点(也叫外部节点、失败节点、空结点)是黑色的
-
不存在两个相邻的红结点(红结点的父节点和孩子节点一定是黑色)
-
对每个结点,从该节点到任意一个叶节点的简单路径上,所含黑结点的数目相同
左根右,根叶黑,不红红,黑路同
黑高 —— 从某个结点出发,到达任意空结点路径上黑结点总个数(不包含空结点)
红黑树的性质
- 从根节点到任意结点的最长路径不大于最短路径的两倍(黑路同)
- 有 n 个内部节点的红黑树高度 $h\leq 2\log_2(n+1)\to$ 红黑树的查找时间复杂度为 O(log n) )(?)
红黑树的插入
-
先查找,确定插入位置,插入新结点
- 新节点是根——染为黑色
- 新节点非根——染为红色
-
检查插入后的红黑树是否满足定义
- 满足定义,结束
- 不满足定义,需要调整,使其重新满足红黑树定义
graph a[爷爷]-->b[叔叔] a-->c[父亲] c-->d[新节点]调整方法:根据叔叔的颜色确定
- 叔叔为黑色,旋转 + 染色
- LL 父换爷 + 染色父和爷
- RR 父换爷 + 染色父和爷
- LR 儿换爷 + 染色儿和爷
- RL 儿换爷 + 染色儿和爷
- 叔叔为红色,染色 + 变新
- 叔父爷染色,爷变为新节点(对于新节点的处理,回到 1)
红黑树的最长路径和最短路径之差不超最短路径过两倍,而平衡二叉树的限制是左右子树高度之差不大于 1,因此在插入删除时红黑树的特性不容易被破坏,更为稳定
思考:RBT 的黑高为 h,则内部节点至少有多少个?至多呢?
答:若根节点黑高为 h,内部节点关键字最少有 $2^h-1$ 个(满二叉树,全是黑色结点没有红色结点),而最多即根节点的总高度为 2h,内部节点关键字最多有 $2^{2h}-1$ 个
因此,有 n 个节点的 RBT,树的高度介于 $\log (n+1)$ 和 $2\log(n+1)$ 之间,因此查找的时间复杂度为 O(log n)
红黑树的删除
不考
7.3.6 B 树
B 树的定义
5 叉查找树:每个结点最少有 1 个关键字,2 个分叉;最多有 4 个关键字,5 个分叉
// 5 叉排序树的结点定义
struct Node {
ElemType keysp[4]; // 最多四个关键字
struct Node * child[5]; // 最多 5 个孩子
int num; // 结点中的关键字个数
}
如何保证 5 叉查找树的查找效率?
当每个结点中只存储一个元素的时候,5 叉查找树就会退化成 2 叉查找树,因此要保证效率必须使得每个结点中的关键字尽可能多,同时树的高度还要尽可能低
规定:1. 在 m 叉查找树中,除了根节点,任何结点都要至少有 m/2(向上取整)个分叉,即至少含有 m/2(向上取整)-1 个关键字;2. 任何一个结点,其所有子树的而高度都一样
两个条件其实是相互制约的,如果想让结点的关键字尽可能多,就可能导致子树高度不一致,想让子树高度完全一致,结点关键字至少要少于一半关键字(一层满了如果不能向上传递只能分裂,则必须分裂成两棵等高子树)
符合上述要求的树,其实就是一棵 B 树(又称为多路平衡查找树)m 称为 B 树的阶
含有 n 个关键字的 m 阶 B 树,最小高度和最大高度是多少?
最小高度:满 m 叉树
$$
n\leq (m-1)(1+m+m2+\dots+m)=m^h-1,\ h \geq \log_m(n+1) \
也可以使用第 h+1 层叶子结点个数为 n+1 计算得到,n+1\leq m^h
$$
最大高度:每个结点至少 m/2(向上取整)-1 个结点
$$
\begin{flalign}
& []——向上取整 \
& 第一层:1 \
& 第二层:2 \
& 第三层:2\times [\frac m 2] \
& \dots \
& 第四层:2\times [\frac m 2]^2 \
& 第 h 层:2\times[\frac m 2]^{h-2} \
& 第 h+1 层(失败节点):2\times([\frac m 2])^{h-1} \
& 失败节点个数为 n + 1,\ 则有 n+1\geq \ 2\times([\frac m 2])^{h-1} \
& h\leq \log_{[\frac m 2]}\frac{n+1}2+1 \
\end{flalign}
$$
为什么最大高估是取的小于等于,而最小高度是取得大于等于呢?
因为当树的高度处于极限最高的时候,其中任意一个结点被删除都会导致整棵树的高度-1,因此要保证树的高度最高,必须使得极限高度所需要的结点个数小于等于实际上的结点个数,即 n 要大于等于 $2([\frac m 2])^{h-1}-1$
同理,当树的高度处于极限最低的时候,任意一个结点的插入都会导致整棵树的高度 + 1,因此要保证树的高度最低,必须使得极限树高所需要的结点个数大于实际上的节点个数,即 n 要小于等于 $m^h-1$
考虑到关键字最多(最少)的时候,还要乘以每个结点的元素最多(最少)的个数
B 树的插入
新元素的插入一定是发生在终端节点中,当一个节点的元素超出上限就会分裂
-
如果是根节点,则分裂成三个元素,分别含有[m/2]-1、[m/2]-1和 1 个元素
-
如果是终端结点,则原始节点只保留 [m/2]-1 个元素,剩下 [m/2] 个关键字的第 1 个关键字 成为父节点中的关键字,后 [m/2]-1 个变为由该父节点中关键字所指向的新节点
-
如果父节点在第 2 步后也使得结点的元素超出上限
- 父节点是根节点,则父节点会分裂成三个结点,中间成为根节点,左右依次各带 [m/2]-1 个关键字
- 父节点不是根节点,则中间关键字会向上传递,并且后 [m/2]-1 个关键字以及他们的结点会成为中间关键字所指向的结点,最终导致 B 树的高度 +1
核心要求,任何一个结点的关键字必须满足
- 子树0 < 关键字1 < 子树1 < ... ...
- 除了根节点外每个结点的关键字个数满足 [m/2]-1 < n < m-1
B 树的删除
B 树的删除其实是插入的逆过程
- 如果删除的是非终端节点,直接删除该元素,然后使用直接前驱或者直接后继代替该元素,然后删除直接前驱或者直接后继
- 如果删除的是终端结点
- 删除后终端节点的关键字满足 B 树要求,则什么也不做
- 删除后终端节点的关键字不足,若有右兄弟含有超过 [m/2]-1 个结点,则可以让右兄弟的关键字代替父结点的关键字,原本父节点的关键字填入删除了关键字的结点;若有左兄弟,也是同理
- 删除后终端节点的关键字不足,若左右兄弟都没有足够的结点,则挑一个兄弟和父节点中对应的关键字三个一起合并,充当父节点中下一个关键字所指向的结点,如果以此方式造成父节点中的关键字不足,则父节点也可以依照该方式继续合并,最终导致 B 树的高度 -1
7.3.7 B+ 树
B+ 树类似于分块查找的查找树,对于一棵 m 阶 B+ 树需满足下列条件
- 每个分支结点最多有 m 棵子树
- 非叶节点至少有两棵子树,其他每个分支结点至少有 [m/2] 棵子树
- 结点的子树个数与关键字数相等
- 所有叶结点包含全部关键字,及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且 相邻叶结点按大小顺序相互链接起来
- 分支结点中仅包含它各个子结点中关键字的最大值及指向其子结点的指针
对于一棵 B+ 树,无论查找成功还是失败,都必须查找到最后一层叶子结点
- | B 树 | B+ 树 |
---|---|---|
根结点关键字数 | [1, m-1] | [1, m] |
其他结点关键字数 | [[m/2]-1, m-1] | [[m/2], m] |
7.4 散列表
7.4.1 散列表的基本概念
散列表(哈希表、Hash Table):是一种数据结构,可以根据数据元素关键字计算出它在散列表中的存储地址
散列函数(哈希函数):Addr = H(key)
建立了 『 关键字 』$\to$ 『 存储地址 』的映射关系
理想情况下,在散列表中查找一个元素的时间复杂度为 O(1)
冲突: 在散列表中插入元素时候,映射的地址已经存储了其他元素,则这种情况称为冲突
同义词: 不同的关键字通过散列函数映射到同一个存储地址,则称他们为同义词
如果减少冲突的发生?
- 构造更合适的散列函数
- 处理冲突——拉链法、开放定址法
7.4.2 散列函数的构造
设计散列函数应该注意的问题:
- 定义域涵盖全部关键字
- 值域不能超出散列表的地址范围
- 散列函数计算出的地址应该能够均匀分布在整个地址空间
- 散列函数应该尽量简单,能够快速计算出任意一个关键字的对应地址
除留余数法——H(key) = key % p
散列表表长为 m ,取一个不大于 m ,但是最接近或等于 m 的质数 p
对质数取余,会使得散列函数的结果更为均匀
直接定址法——H(key) = key(a*key+b)
适用于分布连续的关键字
数字分析法——选取 key 的若干位
适用于按照一定规律编排的号码,例如手机号
平方取中法——选取 key^2 的若干位
对于相等长度但是无规律的关键字,通过平方取中,可以使得关键字对应的哈希值分部更为均匀
原理是对于一串数字的平方,中间的几位数会受到来自前后乘积的影响,会使得中间的几位分部更为均匀
7.4.3 处理冲突的方法
拉链法
类似于邻接表,当插入新的关键字,会先映射到对应散列表中的位置,该位置存储的是一个指向关键字结点的指针,然后插入新的关键字(头插法或者尾插法),查找的时候也是先映射到对应的位置,然后遍历对应的边表找到对应的关键字
对于拉链法创建的散列表,ASL 失败 最坏查找长度为最长的边表长度,最好的查找长度为 0 (映射到的是空指针)
查找长度计算的是关键字对比的次数,在链表中如果映射到的是 NULL,则可以直接判定为查找失败,无需任何关键字对比,因此查找长度为 0
开放定址法
如果发生冲突,就给新元素找另一个空闲位置,寻找的方法就决定探测的顺序 $d_0, d_1, \dots, d_i$ i 表示重定向的次数
即,对于一个元素,其发生第 i 次冲突时的散列地址 $H_i=(H(key)+d_i)%m$ 其中 $d_i$ 是偏移量,m 是表长
对于构造探测序列,有四种常见的方法
- 线性探测法:$d_i=0,1,2,3,\dots,m-1$
- 平方探测法:$d_i=0,1,-1,22,-22,32,-32,\dots,k2,-k2$ 其中 $k\leq \frac m2$
- 双散列法:$d_i=i\times H_2(key)$
- 伪随机序列法:$d_i$ 是一个伪随机序列
对于开放定址法创建的散列表,在删除元素时候,可能会导致后序的被二次定址的元素无法再被查到,因此在删除元素的时候不能进行物理删除,而是逻辑删除标明该位置曾今有元素,之后查找时候还可以从该位置继续向后探测
散列查找 ASL 的计算
计算成功 ASL = 散列表中每个存在的关键字 * 找到该关键字需要对比的次数
计算失败的 ASL
-
对于拉链法中,因为是如果映射到的数组是空,其存储的其实不是一个指针而是 NULL ,因此使用拉链法对于空链域的失败查找长度为 0 ,失败 ASL = 有关键字的链表长度之和 / 非空链域
-
对于开放定址法,空数组单元的失败查找长度为 1,有关键字的单元的成功查找长度 = 从该关键字遵循定址法一直到空或者越界所需要对比的次数,失败 ASL = 散列表中每一个单元的失败查找长度之和
第八章 排序
8.1 排序的基本概念
8.1.1 排序的定义
排序,就是重新排列列表中的元素,使表中的元素按照顺序排列
8.1.2 算法的稳定性
对于一个排序表中的任意两个相同的元素 A1 和 A2 ,在排序前后 A1 和 A2 的相对位置保持不变,我们就称这个排序算法是稳定的,否则就是不稳定的
稳定的排序算法一定比不稳定的好吗?
答:否,依据现实情况而定
8.1.3 排序算法的分类
8.2 插入排序
算法思想:每次将一个待排序的记录按照其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成
8.2.1 直接插入排序
算法实现
// 直接插入排序
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; --j) // 检查所有前面已排好序的元素
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[0] 是哨兵,元素从 A[1] 开始
if(A[i] < A[i-1]) {
A[0] = A[i];
for(j=i-1; A[0]<A[j]; --j)
A[j+1]=A[j]; // 向后挪
A[j+1] = A[0]; // 复制到插入位置
}
}
算法效率分析
最好情况: O(n)
最坏情况:O(n²)
平均时间复杂度:O(n²)
8.2.2 折半插入排序
使用折半查找的方式找到待排元素应该插入的位置,然后移动该元素
当 low > high 的时候,low 指针所指向的位置就是待排元素应该插入的位置
为了保证算法的稳定性,在判断 low = mid + 1 的条件应该是 A[0] ≥ A[mid]
当找到相同的元素的时候,因该视作这个元素小于待排元素,继续在该元素的后面寻找插入位置,即将待排元素插入到相同元素的后面,保证后排的的元素在后面,即可保证算法的稳定性
算法实现
// 折半插入排序
void InsertSort(int A[], int n) {
int i, j, low, high, mid;
for(int i=2; i<=n; i++) {
A[0] = A[i];
low = 1; high = i-1;
while(low <= high) { // 当 low > high 时候,停止查找
mid = (low + high) / 2;
if(A[mid] > A[0]) high = mid-1; // 查找左半子表
else low = mid + 1; // A[mid] <= A[0] 都要查找右半子表,保证算法稳定性
}
for(j=i-1; j>high+1; --j)
A[j+1] = A[j]; // 后移元素空出插入位置
A[high+1] = A[0]; // 插入操作
}
}
算法效率分析
不难看出,折半插入排序优化了寻找待排元素的插入位置的过程,但是在插入待排元素的时候,仍然要对数量级为 n 的元素进行移动,因此 寻找 + 插入的数量级仍然为 n 而无法降低到 log n
所以整体而言,折半插入算法的时间复杂度仍为 O(n²)
对链表进行插入排序
对于链表的插入排序而言,插入元素的时间复杂度为 O(1) ,但是由于链表的结构特性,寻找待排元素的插入位置的数量级无法达到 log n 数量级(不能使用折半查找),因此整体的时间复杂度仍为 O(n²)
6.2.3 希尔排序
思想:先追求表中元素部分有序,再逐渐逼近全局有序
算法:
- 将待排序表分成若干子表 $L_i=[i, i+d, i+2d,\dots,i+kd]$
- 对各个子表进行直接插入排序
- 缩小增量 d = d / 2 ,然后重复 1 2 直到 d=1 ,对整体进行插入排序(缩小增量的方式可以自定义)
// 希尔排序
void ShellSort(int A[], int n) {
int d, i, j;
for(d=n/2; d >= 1; d=d/2)
for(i=d+1; i<=n; ++i)
if(A[i] < A[i-d]) {
A[0] = A[i];
for(j=i-d; j>0 && A[0]<A[j]; j-=d)
A[j+d] = A[j]; // 记录后移,查找插入的位置
A[j+d] = A[0]; // 插入待排元素
}
}
算法效率分析
空间复杂度为 O(1)
时间复杂度在 $O(n^{1.3})\sim O(n^2)$ 之间
希尔排序是不稳定的,因为相同的元素可能会被划分到不同的子表当中
8.3 交换排序
交换排序:根据序列中两个关键字的比较结果来决定是否交换这两个记录在序列中的位置
8.3.1 冒泡排序
算法思想
一趟冒泡:从最后一个元素开始,从后往前两两对比相邻元素的值,若为逆序,则交换他们,直到第 i 个元素
可以看出,每一趟冒泡,会使第 i 小的元素到达第 i 个位置,即获得前 i 个元素的有序序列
冒泡排序:进行 n-1 趟冒泡,每次从最后一个位置往前冒直到前 n-1 个元素有序,已经有序的部分就不需要再进行对比
如果在某次冒泡中没有发生交换,则认为序列已经有序
算法实现
// 交换
void swap(int &a, int &b) {
int temp = a;
a = b;
b = temp;
}
// 冒泡排序
void BubbleSort(int A[], int n) {
for(int i=0; i<= n-1; i++) {
bool flag = false; // 表示本趟冒泡是否发生了交换,如果没有则说明已经有序
for(int 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(1)
- 最坏:O(n²)
在插入排序算法中,一趟插入元素的移动次数是 n 的量级次
在冒泡排序算法中,一次交换元素的移动次数是 3(
temp = a; a = b; b = temp;
)
稳定性:冒泡排序是稳定的
思考:冒泡排序是否适用于链表?
可以,只需要使用一个扫描指针,交换两个元素的步骤
// 交换值域 temp = p->data; p->data = p->next->data; p->next->data = temp;
8.3.2 快速排序⭐
每一次确定一个中间元素的位置,且确定的时间复杂度只有 log n 量级,最终排序的时间复杂度可以达到 O(nlog n)
算法思想
- 在待排序表中任取一个元素 pivot 作为枢轴(通常取首元素)
- 进行一趟特殊的排序,将排序表分成两个部分 L1 和 L2 ,L1 中的元素全部小于 pivot,L2 中的元素全部大于 pivot
- 依次对这两个部分重复 1 2,直到每个部分只有一个元素或者为空
使用怎么样的排序方法能确定这两个序列,且保证时间复杂度不超过 O(log n) 呢?
首先肯定不能遍历全部元素来进行交换,否则就已经达到 O(n),所以至少会有两个指针,每个负责一边,将小于枢轴的交换到左边,将大于枢轴移动到枢轴右边
特殊排序的算法思想
- 初始化两个指针 low 和 high,分别指向第一个元素和最后一个元素(初始时 pivot = A[low])
- 如果 low 和 high 指向同一个位置,跳转到 4 ,否则检测 high 指针所指元素与枢轴元素的大小关系
- A[high] > pivot ,high 向左移动
- A[high] ≤ pivot ,A[low] = A[high],然后跳转到 3
- 如果 low 和 high 指向同一个位置,跳转到 4 ,否则检测 low 指针所指的元素与枢轴元素的大小关系
- A[low] < pivot ,low 向右移动
- A[low] ≥ pivot ,A[high] = A[low],然后跳转到 2
- 将 pivot 放到 low 和 high 共同指向的位置,一趟完毕,划分的两部分分别是 1~low-1 和 low+1 ~n
代码实现
// 一趟划分
int Partition(int A[], int low, int high) {
int pivot = A[low]; // 第一个元素作为枢轴
while(low < high) {
while(low < high && A[high]>pivot) --high; // A[high] > pivot ,high 向左移动
A[low]=A[high]; // A[high] ≤ pivot 交换
while(low < high && A[low]<pivot) ++low; // low 向右移动
A[high]=A[low]; // A[low] ≥ pivot 交换
}
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); // 处理右半部分
}
}
算法效率分析
空间复杂度:递归的层数
理想情况下,第一层快速排序,获得两个子表,确定一个元素的位置;第二层快速排序,获得四个子表,确定三个元素的位置;第三层快速排序,获得八个子表,确定七个元素的位置,以此类推,第 i 层快速排序后可以获得 $2^i$ 个子表,确定 $2^i-1$ 个元素,当 $2^i-1=n$ 时候,所有的元素均在自己的位序上,即层数为 $\log_2(n+1)$ 空间复杂度为 O(log n)
实际上如果每次只能多确定一个子表的位置(即每次 piovt 的位置都是在最左或者最右边),那么递归层数也会达到 n 层,这种情况下待排序序列的效果最差
- 最好:O(log n)
- 最坏:O(n)
- 平均:O(log n)
时间复杂度:每一层快速排序只需要处理剩余的待排序元素,时间复杂度不超过 O(n),综合下来整体的时间复杂度为 O(n * 递归的层数) = O(n log n)
- 最好:O(n log n)
- 最坏:O(n²)
- 平均:O(n log n)
针对特殊情况下,快速排序的优化方案:
- 选取 头、中、尾 三个位置元素的中间值作为枢轴元素
- 随机选取枢轴元素
稳定性:快速排序不是稳定的
8.4 选择排序
选择排序:每一趟在待排序列中选取关键字最大 / 最小的元素加入有序序列
8.4.1 简单选择排序
扫描待排序列的非有序部分,找到最小的元素加入有序序列
代码实现
void SelectSort(int A[], int n) {
for(int i=0; i<n-1; i++) { // 一共进行 n-1 趟,i 指向无序第一个位置
int min=i;
for(int j=i+1; j<n; j++) // 每一趟扫描从第 i 个元素开始到 n-i 个元素,找到最小的
if(A[j]<A[min]) min=j;
if(min!=i) swap(A[i], A[min]); // 如果最小元素不等于第 i 个元素,交换
}
}
算法效率分析
空间复杂度:O(1)
时间复杂度:O(n²)
无论有序、逆序还是乱序,都要进行 n-1 趟排序,一共对比 n-1 + n-2 + ... + 1 = $\frac{n(n-1)}2$ 次
稳定性:不稳定,适用于链表和顺序表
最小元素所应该在的位置如果被元素 A 所占据,交换二者后可能 A 就会和相同的元素 A' 的相对位置发生改变
8.4.2 堆排序
什么是堆?
一棵完全二叉树,满足任意分支结点的关键字大于其左右孩子的关键字,这棵二叉树就是一个大根堆
同样的,满足任意分支节点小于其左右孩子的,就是一个小根堆
顺序存储的大根堆满足对于下标为 i 的元素,有 A[i] <= A[2*i]; A >= A[2*i+1]
(1 ≤ i ≤ n/2)
顺序存储的小根堆满足对于下标为 i 的元素,有 A[i] <= A[2*i]; A <= A[2*i+1]
(1 ≤ i ≤ n/2)
建立根堆(以大根堆为例)
- 从右到左遍历待排数组,找到不符合大根堆要求的元素,直到第一个元素也符合要求
- 交换其和其最大的孩子节点,然后检查交换后的孩子结点是否满足大根堆的要求,
- 若满足,重复 1
- 若不满足,重复 2
思考:从右到左遍历,需要从最后一个元素开始吗?
答:不需要,从第 n/2 个元素开始即可,后面的元素没有左右孩子结点,不用考虑是否要和孩子结点交换
堆排序
堆排序:每一趟将堆顶元素加入有序子序列(与待排序序列中最后一个元素交换,即将该元素放到末尾,将末尾的元素放置到堆顶,之后进行调整时候,只处理前 n-i 个元素,i 为待排序序列元素个数)
// 堆排序
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); // 把剩余的待排序元素整理成堆
}
}
算法效率分析
建堆的时间复杂度
对于一个结点,每 『 下坠 』 一层,最多只需要对比关键字 2 次
若树高为 h ,某结点在第 i 层,则将这个结点向下跳帧最多只需要 『 下坠 』 h-i 层,因此关键字的对比次数不超过 2(h-i) 次
对于一个堆,其高度 h 和结点数 n 之间的关系是 h = [log n] + 1 (向下取整)
第 i 层最多有 $2^{i-1}$ 个结点,而只有第 1 ~ (h-1) 层的结点才有可能需要 下坠 调整,因此对于一棵有 n 个结点的大根堆,总共需要调整
$$
1\times2(h-1)+2\times 2(h-2)+2^2\times 2(h-3)+\dots+2^{h-2}\times 2=\sum_{i=h-1}122(h-i) \
令 j=h-i,I=\sum_{j=1}{h-1}2j,代入 h=\log_2[n]+1 \
I=\sum_{j=1}{h-1}2j\leq\sum_{j=1}{h-1}2n\times2j\leq4n
$$
因此建堆的总体过程对比次数不超过 4n ,时间复杂度不超过 O(n)
除了第一次是从下向上调整的,之后每次都只对堆顶的元素进行调整,因此之后的 n-1 个元素每个只对比 2(h-1) 次,h 为树的高度,因此时间复杂度不超过 O(log n),堆排序的总体时间复杂度为 O(n) + O(n log n) 即 O(n log n),空间复杂度为 O(1)
稳定性:当一个根节点的左右子树都相等且大于根节点,则在调整的时候一定会先将左孩子优先交换,而优先交换的左孩子会在有序序列靠近末尾的一端,即一定会和与其相等的元素发生相对位置改变
堆的拓展
对于一个大根堆,如何插入元素 / 删除元素?
- 插入元素:将元素放在最后一个位置,然后递归调整双亲节点,直到不发生交换或者到根节点
- 删除元素:将堆底的元素代替删除元素的位置,然后递归调整孩子结点,直到不发生交换或者到叶子节点
在插入元素的时候,元素在 上升 的过程中每次只需要对比一次双亲结点
在删除元素的时候,元素在 下坠 的过程中每次需要对比左右孩子各一次(也可能值只对比一次,即没有右孩子)
8.5 归并排序
归并:把两个或多个已经有序的序列合并成一个
k 路归并:将 k 个已经有序的序列合并成一个,需要设置 k+1 个指针,分别指向各个已经有序的子序列和要归并的序列首位,每次从 k 个元素中选出一个最小的放在待排序序列的第一个位置,即每选出一个元素需要对比关键字 k-1 次
算法思想(二路归并)
-
设置 low,mid,high 三个指针将待排序列 A 分成两个部分
-
设置一个与要归并的序列长度之和等长的辅助数组 B ,将待排序列复制到该数组中
-
设置 i,j 两个指针,分别指向辅助数组中两个用于归并的部分起始位置,即 i = low,j = mid
-
设置 k 指针指向待排序列的其实位置,即 k = low
-
对比 i 和 j 所指的元素
- 若 B[i] < B[j] 将小的元素放在 k 的位置,然后 i, k 指针右移
- 若 B[j] < B[i] 将小的元素放在 k 的位置,然后 j, k 指针右移
-
检查指针的位置
-
若 i 超过了 mid 的位置,则将 j 所指的后序元素加入到 k 所指的后序位置,结束
-
若 j 超过 high 的位置,则将 i 所指的后序元素加入到 k 所指的后序位置,结束
-
若都没有,则回到 5
-
代码实现
// 将待排数组中的某两个部分进行二路归并
void Merge(int A[], int low, int mid, int high, int B[]) {
int i, j, k;
for(k=low; k<=high; k++)
B[k] = A[k];
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(i<=mid) A[k++]=B[j++];
}
// 归并排序
void MergeSort(int A[], int low, int high, int B[]) {
if(low < high) {
int mid = (low + high) / 2; // 从中间划分
MergeSort(A, low, mid, B); // 对左半部分进行归并排序
MergeSort(A, mid+1, high, B); // 对右半部分进行归并排序
Merge(A, low, mid, high, B);
}
}
// 归并排序接口
void MergeSortInterface(int A[], int n) {
int *B=(int *)malloc(n*sizeof(int));
MergeSort(A, 0, n-1, B);
free(B);
}
算法效率分析
二路归并的过程形态上是一棵类似于并查集的树
对 n 个元素进行二路归并,一共要归并 [log n]+1 (向下取整) 趟
每一趟归并的时间复杂度为 O(n)
整体的时间复杂度为 O(n log n)
每次归并有一点类似于冒泡排序,因为归并序列本身是有序的,所以冒泡不是和临近的元素比较,而是和另一个数组中的比较,且比较之后不发生交换,而是将较大或者较小的一方插入到辅助队列的末尾,一次 『 冒泡 』 后就能使得两个数组整体有序了
空间复杂度为 O(n)(辅助数组) + O(log n)(递归深度) = O(n)
归并排序是 稳定的 排序算法
8.6 基数排序
分配表:长度为 10 的邻接表
分配:将各个元素按照关键字某一位上的数字(一般是按照从低位到高位),分配到分配表中(相同的放在边表中,且按照原序列的顺序排列)
收集:遍历分配表将各个元素收集到原来的表中
一趟分配收集,会获得一串在某一位上有序的序列
算法思路
- 设置一个数组长度为 10 的邻接表
- 遍历待排数组依次对 个位、十位、百位、... 进行分配收集
- 达到最大位数后,获得的就是有序序列
$$
假设长度为 n 的线性表中每个结点 a_j 的关键字由 d 元组(k_j^{d-1}, k_j{d-2},k_j,\dots,k_j1,k_j0)组成 \
其中,0\leq k_j^i\leq r-1(0\leq j<n, 0\leq i \leq d-1)\ ,r 称为基数
$$
基数排序得到递减序列的过程
初始化:设置 r 个空队列 Q1, Q2, ..., Qr
按照各个关键字位权重递增的次序,对 d 个关键字分别做 分配 和 收集
分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入 Qx 队尾
收集:把 Q1, Q2, ..., Qr 各个队列中的结点依次出队并链接
算法实现
不要求
算法效率分析
空间复杂度:需要 r 个辅助队列,空间复杂度为 O(r)
时间复杂度:一趟分配 O(n),一趟收集 O(r),总共 d 趟分配,总时间复杂度为 O(d(n+r))
基数排序是稳定的
基数排序的应用
基数排序每次分配收集可以是不同的序列,例如一串年月日 20021015 可以分别按照日、月、年 来进行分配收集,并且序列也可以划分为 4 2 2,无需按照每个数字来进行分配
总结,基数分配对 d 和 r 的选择可以更加开放,只要关键字的某一部分在一个区间内,就可以对这个区间每一个出现的点分配一个数组来进行收集
实际应用中,当 r 和 d 的取值比较合理的时候,基数排序的时间复杂度远小于快速排序
8.7 外部排序
8.7.1 k 路平衡归并
外部排序:数据元素太多,无法一次性全部读入内存进行排序
使用归并排序对读入内存的数据进行排序,归并排序要求各个子序列有序,每次读入两个块的内容,进行内容排序后写回磁盘
算法思想
- 构造初始的归并段:将内存划分为两个输入缓冲区,每个缓冲区的大小为 1KB(一个磁盘块大小),一个输出缓冲区,按照磁盘块的顺序,每次读入两块,然后进行排序,再通过输出缓冲区依次写回到磁盘中原先的位置,这样就可以将全部待排序的磁盘块中的数据变成初始的归并段,每个初始归并段有 2KB
- 归并排序:
- 第一趟归并:依照磁盘块的顺序,依次读入两个初始归并段的前 1KB 部分 A 和 B,同时在外存中开辟一块新的写入区域,长度为 4KB ,然后使用归并排序的将 A 和 B 中前 1KB 的有序数据存入新写入区域的第一个盘块,在归并后 1KB 的数据,在归并过程中,如果写入第一块归并段的磁盘块的输入缓冲区为空,就应该立即载入第一块归并段中下一个磁盘块用于排序,存入第二块归并段的缓冲区也是同理
- 第二趟归并:第一趟归并后每个有序序列的长度为 4KB,第二趟归并需要在外存中开辟长度为 8KB 的写入区域,然后按顺序将两个归并段的第一个磁盘块分别读入内存进行归并排序,将排序的结果写到空盘区的下一个磁盘块,同理,如果写入第一块归并段的输入缓冲区为空,就应该立即载入第一块归并段的下一个磁盘块,如果写入第二块归并段的输入缓冲区为空,就应该立即载入第二块归并段的下一个磁盘块
- 之后的归并也是同理,从而使用较小的内存区域完成了对大片区域的排序
总结
外部排序的时间开销 = 读写外存的时间 + 内部排序所需时间 + 内部归并所需时间
优化外部排序思路
对 r 个初始归并段做 k 路归并,则归并树可以用 k 叉树表示,若树高为 h,则归并趟数 = $h-1 = \lceil \log_k r \rceil$ ,所以如果想通过减少归并次数的方式来优化,可以从增大 k 和减小 r 来进行
-
增大 k :增加输入缓冲区个数
输入缓冲区太多的负面影响:内存开销增加;内部归并所需时间增加
-
减少 r :增加输入缓冲区的大小,或者增加输入缓冲区的个数,可以减少下一次归并生成的归并段个数
8.7.2 败者树
在进行 k 路归并的时候,每次得到一个归并后的关键字要对比 k-1 次,因此当 k 较大的时候效率会很低
败者树
其实就是一棵完全二叉树,每两个结点对比得到一个较大者进入下一轮对比,对于 k 个元素需要进行 k-1 次比较,得出一个最大者,当取出最大元素后添加新的元素,只需要让新的元素顶替最大的元素在最底层的位置,然后分别与最大的元素所比较的元素进行比较,将较大者进入下一轮即可,对比的次数等于这棵败者树的树高
使用 k 个归并段构造败者树
-
从每个归并段中选出第一个关键字,然后两两配对构造败者树,并且在败者树的每个分支节点中标注该轮对比中的失败者来自哪一个归并段
-
对比的出的最小关键字放入输出缓冲区,然后从该关键字所在的归并段中选出下一个关键字替代胜出关键字所在的最底层位置
-
对该关键字进行向上对比,重复 2
构造完毕的败者树,每次选出最小的元素只需要对比 $\lceil \log_2k \rceil$ 次
在使用代码实现败者树的时候,只需要构建一个长度为 k 的数组 s 即可,s[0] 表示冠军,s[1] ~ s[k-1] 分别表示各个分支节点存储失败者来自的归并序列,事实上,我们并不需要构建所有的叶子节点,或者说 k 路归并的 k 个指针就是指向下一个叶子节点,谁的关键字胜出,谁的指针就指向下一个元素
8.7.3 置换-选择排序
内存的内部工作区可以容纳 l 个记录,每个初始归并段就只能包含 l 个记录,若文件共有 n 个记录,则初始归并段的数量为 r = n / l
由此可以看出,归并段的大小会受到内存空间大小的限制,如何突破这种限制?
置换-选择排序:内存的内部工作区可以容纳 l 个记录,则可以一次读入 l 条记录,然后将最小的关键字输入到归并段 1 中,同时记录归并段 1 中的最大的元素 MINMAX,然后读入下一条记录, l 条记录中最小的关键字如果大于 MINMAX ,则将其输出到归并段 1 中的下一个位置,更新 MINMA;如果小于,则查看次小的关键字是否满足,以此递归,直到 l 条记录均小于 MINMAX,或者归并段的长度达到预期要求,归并段 1 就设置完毕
实际上并不是把每个关键字存入外存,而是设置一个缓冲区,当缓冲区满就存入外存
8.7.5 最佳归并树
对于一棵归并树,假设叶子节点的权值是其归并段的长度,则分支节点的长度等于叶子结点之和,而归并段的长度就是其需要读、写的次数,因此对于一棵最佳归并树而言,其带权路径长度 WPL = 叶子节点需要归并的次数 * 叶子节点的权值 就是其归并排序中需要内存读写的次数
如果 WPL 满足最小,则这棵归并树就是一棵 最佳归并树
对于一棵 k 叉哈夫曼树,满足 $n_0=(k-1)n_k+1,即 n_k=\frac{n_0-1}{k-1}$ ,所以要使得能偶满足构建一棵哈夫曼树,需要保证叶子节点个数能够被 k-1 整除(不满足则 补 0 结点)