数据结构(汇总)

数据结构

第一章绪论

1.1数据结构的基本概念

1,基本概念
1,数据

数据是信息的载体,是描述客观事物属性的数、字符及所有能输入到计算机中并被计算机程序识别和处理的符号的集合。数据是计算机程序加工的原料。

2,数据元素、数据项

数据元素是数据的基本单位,通常作为一个整体进行考虑和处理。
一个数据元素可由若干数据项组成,数据项是构成数据元素的不可分割的最小单位。

3,数据对象、数据结构

数据对象是具有相同性质的数据元素的集合,是数据的一个子集。
数据结构是相互之间存在一种或多种特定关系的数据元素的集合。

2,三要素
1,逻辑结构
1,集合

各个元素同属一个集合,别无其他关系

2,线性结构

数据元素之间是一对一的关系。

除了第一个元素,所有元素都有唯一前驱;除了最后一个元素,所有元素都有唯一后继

3,树形结构

数据元素之间是一对多的关系。

4,图结构

数据元素之间是多对多的关系。

2,数据的运算

针对于某种逻辑结构,结合实际需求来定义基本运算。

运算的定义是针对逻辑结构的,指出运算的功能;
运算的实现是针对存储结构的,指出运算的具体操作步骤。

3,物理结构
1,顺序存储

把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。

2,链式存储(非顺序存储)

逻辑上相邻的元素在物理位置上可以不相邻,借助指示元素存储地址的指针来表示元素之间的逻辑关系。

3,索引存储(非顺序存储)

在存储元素信息的同时,还建立附加的索引表。索引表中的每项称为索引项,索引项的一般形式是(关键字,地址)。

4,散列存储(非顺序存储)

根据元素的关键字直接计算出该元素的存储地址,又称哈希(Hash)存储。

4,数据类型和抽象数据类型(补充)
数据类型是一个值的集合和定义在此集合上的一组操作的总称。

1)原子类型。其值不可再分的数据类型。
2)结构类型。其值可以再分解为若干成分(分量)的数据类型。

抽象数据类型

(Abstract Data Type,ADT)是抽象数据组织及与之相关的操作。

1.2算法和算法评价(重点)

1.2.1算法的基本概念
1,什么是算法

程序=数据结构+算法

算法(Algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。

2,算法的五个特性(必须具备的)
1,有穷性

一个算法必须总在执行有穷步之后结束,且每一步都可在有穷时间内完成。
注:算法必须是有穷的,而程序可以是无穷的

2,确定性

算法中每条指令必须有确切的含义,对于相同的输入只能得出相同的输出。

3,可行性

算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现。

4,输入

一个算法有零个或多个输入,这些输入取自于某个特定的对象的集合。

5,输出

一个算法有一个或多个输出,这些输出是与输入有着某种特定关系的量。

3,好算法的特质(尽量追求的目标)
1,正确性

算法应能够正确地解决求解问题。

2,可读行

算法应具有良好的可读性,以帮助人们理解。

3,健壮性

输入非法数据时,算法能适当地做出反应或进行处理,而不会产生莫名其妙的输出结果。

4,高效率与低存储

花的时间少。时间复杂度低。

不费内存。空间复杂度低。

1.2.2算法的时间复杂度

事前预估算法时间开销T(n)与问题规模n的关系(T表示“time”)

1,如何计算

①找到一个基本操作**(最深层循环)**。
②分析该基本操作的执行次数x与问题规模n的关系x=f(n)
③x的数量级O(x)就是算法时间复杂度T(n)

2,常用技巧

加法规则:O(f(n))+O(g(n))=O(max(f(n),g(n)))
乘法规则:O(f(n))×O(g(n))=O(f(n)×g(n))

“常对幂指阶”

O(1) < O(log2n) < O(n) < O(nlog2n) < O(n^2) < O(n^3)< O(2^n) < O(n!) < O(n^n)

3,三种复杂度

最坏时间复杂度:考虑输入数据“最坏”的情况。
平均时间复杂度:考虑所有输入数据都等概率出现的情况。
最好时间复杂度:考虑输入数据“最好”的情况。

1.2.3算法的空间复杂度
1,如何计算

算法原地工作一一算法所需内存空间为常量

1,普通程序

①找到所占空间大小与问题规模相关的变量
②分析所占空间x与问题规模n的关系x=f(n)
③x的数量级O(x)就是算法空间复杂度S(n)

2,递归程序

①找到递归调用的深度x与问题规模n的关系x=f(n)
②x的数量级O(x)就是算法空间复杂度S(n)
注:有的算法各层函数所需存储空间不同,分析方法略有区别

2,常用技巧

“常对幂指阶”

第二章线性表

2.1线性表的定义和基本操作

1,定义(逻辑上是线性的)

线性表是具有相同数据类型的n(n20)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。

1,值得注意的特性

数据元素同类型、有限、有序

2,重要术语

a;是线性表中的“第i个”元素线性表中的位序
a1是表头元素;a是表尾元素。

除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅
有一个直接后继

2,基本操作
1,创销、增删改查(所有数据结构适用的)

InitList(&L):初始化表。构造一个空的线性表L,分配内存空间。
DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间。

Listlnsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。
ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。

LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。
GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。

2,其他常用操作:

Length(L):求表长。返回线性表L的长度,即L中数据元素的个数
PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。
Empty(L):判空操作。若L为空表,则返回true,否则返回false。

2.2线性表的顺序表示

2.2.1顺序表的定义

顺序表——用顺序存储的方式实现线性表顺序存储。把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。

1,存储结构

顺序存储——逻辑上相邻的数据元素物理上也相邻。

2,实现方式
1,静态分配

使用“静态数组”实现
大小一旦确定就无法改变

2,动态分配

使用“动态数组”实现。
L.data = (ElemType *) malloc (sizeof(ElemType) * size);
顺序表存满时,可再用malloc动态拓展顺序表的最大容量.
需要将数据元素复制到新的存储区域,并用free函数释放原区域.

3,特点

①随机访:问能在O(1)时间内找到第i个元素

②存储密度高,每个节点只存储数据元素

③拓展容量不方便(即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高)

④插入、删除操作不方便,需要移动大量元素

2.2.2顺序表的插入删除
1,插入
1,代码实现

Listinsert(&L,i,e)
将元素e插入到L的第i个位置。

插入位置之后的元素都要后移

2,时间复杂度分析

最好O(1)、最坏O(n)、平均O(n)

2,删除
1,实现

ListDelete(&L,i,&e)。

将L的第i个元素删除,并用e返回。

删除位置之后的元素都要前移。

2,时间复杂度

最好O(1)、最坏O(n)、平均O(n)

3,代码要点

代码中注意位序i和数组下标的区别
算法要有健壮性,注意判断i的合法性

跨考同学注意:移动元素时,从靠前的元素开始?还是从表尾元素开始?
分析代码,理解为什么有的参数需要加“&”引用

如果不加“&”,则被调用函数中处理的是参数数据的复制品

2.2.3顺序表的查找
1,按位查找
1,代码实现

GetElem(L,i)

获取表L中第i个位置的元素的值
用数组下标即可得到第i个元素L.data[i-1]。

2,时间复杂度

O(1)

由于顾序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第i个元素——“随机存取”特性

2,按值查找
1,代码实现

LocateElem(L,e)

在顺序表L中查找第一个元素值等于e的元素,并返回位序

从第一个元素开始依次往后检索。

2,时间复杂度

最好O(1):目标元素在第一个位置
最坏O(n):目标元素在最后一个位置
平均O(n):目标元素在每个位置的概率相同

2.3线性表的链式表示

2.3.1单链表的定义
1,概念

用链式存储”(存储结构)实现了“线性结构”(逻辑结构)

一个结点存储一个数据元素

各结点间的先后关系用一个指针表示

优点:不要求大片连续空间,改变容量方便
缺点:不可随机存取,要耗费一定空间存放指针

2,用代码定义一个单链表
typedef struct LNode{
	ElemType data;
	struct LNode *next;
}LNode, *LinkList;
3,两种实现
1,不带头结点

空表判断:L==NULL。写代码不方便。

2,带头结点

空表判断:L->next==NULL。写代码更方便

4,补充

typedef关键字的用法

"LinkList"等价于"LNode*"前者强调这是链表,后者强调这是结点合适的地方使用合适的名字,代码可读性更高

2.3.2单链表的插入删除
1,插入
1,按位序插入

找到第i-1个结点,将新结点插入其后

//在第i个位置插入元素e,带头节点
bool ListInsert(LinkList &L, int i, ElemType e)
{
    if(i<1)
        return false;
    LNode *p; //指针P指向当前扫描到的节点
    int j = 0;//当前P指向的是第几个节点
    p = L;	//L指向头节点,头节点是第0个节点,不存储数据 
    while(p!=NULL && j<i-1)//循环找到第i-1个节点
    {
        p=p->next;
        j++;
    }
    if(p==NULL)
        return false;//i值不合法
    LNode *s = (LNode *)malloc(sizeof(Lnode));
   s->data=e;
   s->next==p->next;
   p->next=s;
   return true;
}
//在第i个位置插入元素e,不带头节点
//不带头结点当i=1需要特殊处理
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; //指针P指向当前扫描到的节点
    int j = 1;//当前P指向的是第几个节点,在带头节点时这里是0.
    p = L;	//L指向头节点,头节点是第0个节点,不存储数据 
    while(p!=NULL && j<i-1)//循环找到第i-1个节点
    {
        p=p->next;
        j++;
    }
    if(p==NULL)
        return false;//i值不合法
    LNode *s = (LNode *)malloc(sizeof(Lnode));
   s->data=e;
   s->next==p->next;
   p->next=s;
   return true;
}
2,指定节点的后插操作
//后插操作:在p节点后插入元素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;
    p->next = s;
    s->next = NULL;
    return true;
}
3,指定节点的前插操作
//前插操作:在p节点前面插入元素e
bool InsertBeforeNode(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连接到p后面
    s->data = p->data;//将p中的元素复制到s中
    p->data = e;	//将p的元素用e覆盖
    return true;
}
2,删除
1,按位序删除

ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。

找到第i-1个结点,将其指针指向第i+1个结点,并释放第i个结点

bool ListDelete(LinkList &L,int i, ElemType &e)
{
    if(i < 1)	
        return false;
    LNode *p;
    int j = 0;
    while(p!=NULL && j < i - 1)
    {
        p++;
        j++;
    }
    if(p==NULL)	//i值不合法,过大
        return false;
    if(p->next == NULL)//第i-1个节点后没有节点,无法删除,i值不合法
        return false;
    LNode *d = p->next; //要删除的节点
    p->next = d->next;//将要删除的节点从链表中断开
    e = d->data;//用e返回要删除的元素的值
    free(d);
}
2,指定节点删除

删除结点p,需要修改其前驱结点的next 指针

方法1:传入头指针,循环寻找p的前驱结点
方法2:偷天换日(类似于结点前插的实现)

bool DeleteNode(LNode *p)
{
    if(p == NULL)
        return false;
    LNode *s = p->next;	//将节点s指向要删除节点p的后继节点
    p->data = s->data;//将节点p和其后继节点交换数据
    p->next = s->next;//将节点p指向其后继节点的下一个节点
    free(s);//释放p的后继节点
}

有坑:指定结点是最后一个结点时,需要特殊处理

2.3.3单链表的查找
1,按位查找

GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。

LNode *GetElem(LinkList L, int i)
{
    if(i < 0)
        return NULL;
    LNode *p = L;//指向当前扫描到的节点
    int j = 0;	//当前p指向的是第几个节点
    while(p != NULL && j < i)//循环找到第i个节点
    {
        p++;
        j++;
    }
    return p;
}
2,按值查找

LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。

LNode *LocateElem(LNode *L, ElemType e)
{
    LNode *p = L;//当前扫描到的节点
    while(p != NULL)
    {
        if(p->data == e)
            break;
        p= p->next;
    }
    return p;
}
3,求单链表长度
int Length(LinkList L)
{
    int len = 0;
    LNode *p = L;
    while(p != NULL)
    {
        len++;
        p = p-> next;
    }
    return len;
}
4,Key

三种基本操作的时间复杂度都是O(n)
如何写循环扫描各个结点的代码逻辑
注意边界条件的处理

2.3.4单链表的建立

step1:初始化一个单链表
Step2:每次娶一个数据元素,插入到表尾/表头

1,尾插法

对链表的尾节点执行后插操作

//定义单链表的节点类型
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 = NULL;	//头节点之后暂时还没有节点	
    return true;
}
//尾插法建立单链表
LinkList List_TailInsert(LinkList &L)
{
    int x;
    L = (LNode *)malloc(sizeof(LNode));
    L = NULL;
    LNode *s;
    LNode *r=L;		//r为表尾指针
    scanf("%d", &x);
    while(x!=999)//结束条件
    {
        s = (LNode *)malloc(sizeof(LNode));	//为新节点分配空间
        s->data = x;//给新节点赋值
        r->next = s;//把新节点插入到链表尾上,后插
        r = s;	//把r始终指向表尾节点
        scanf("%d", &x);
    }
   r->next = NULL;	//尾结点指针置空
   return L;
}
2,头插法

对链表的头节点执行后插操作。

LinkList List_HeadInsert(LinkList &L)
{
    L = (LNode *)malloc(sizeof(LNode));
    L = NULL;
    LNode *r = L;
    int x;
    scnaf("%d", &x);
    while(x != 999)
    {
        r = (LNode *)malloc(sizeof(Lnode));
        r->data = x;
        r->next = L->next;
        L->next = r;
        scanf("%d", &x);
    }
    r->next = NULL;
    return L;
}
2.3.5双链表
1,初始化(带头节点)

头结点的prior、next都指向NULL

//双链表的定义
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 = NULL;
    L->prior = NULL;
    return true;
}
2,插入(后插)

注意新插入结点、前驱结点、后继结点的指针修改边界情况;

新插入结点在最后一个位置,需特殊处理

//在p节点之后插入节点s
bool InsertNextDNode(DNode *p, DNode *s){
    if(p == NULL || s = NULL)
        return false;
    s->next = p->next;
    if(p->next != NULL)//如果P有后继节点
    	p->next->prior = s;
    p->next =s;
    s->prior = p;
}
3,删除(后删)

注意删除结点的前驱结点、后继结点的指针修改边界情况;

如果被删除结点是最后一个数据结点,需特殊处理

//删除P节点的后继节点
bool DeleteNextDNode(DNode *p)
{
    if(p == NULL)
        return false;
    DNode *q = p->next;//p的后继节点
    if(q == NULL)
        return false;
    p->next = q->next;
    if(q->next != NULL)
        q->next->prior = p;
    free(q);
}
//删除整个链表
void DestoryList(DLinklist &L)
{
    while(L->next != NULL)
    {
        DeleteNextDnode(p);
    }
    free(L);
    L = NULL;
}
4,遍历

从一个给定结点开始,后向遍历、前向遍历的实现(循环的终止条件)

链表不具备随机存取特性,查找操作只能通过顺序遍历实现

2.3.6循环链表
1,循环单链表

循环单链表:表尾节点的next指针指向头节点

//初始化一个循环单链表
bool InitList(LinkList &L)
{
    L = (LNode *)malloc(sizeof(LNode));
    if(L == NULL)
        return false;
    L->next = L;//头结点的next指向头结点自身
    return true;
}
//判断循环单链表是否为空
bool Empty(LinkList L)
{
    if(L->next == L)
        return true;
    else
        retrun false;
}
//判断节点p是否为循环单链表的尾节点
bool isTail(LinkList L, LNode *p)
{
    if(p->next == L)
        return true;
    else 
        return false;
}
2,循环双链表

表头结点的prior指向表尾结点;

表尾结点的 next指向头结点

//定义双链表
typedef struct DLnode{
	ElemType data;
	struct DLnode *next, *prior;
}DLnode, *DLinkList;
//初始化空的循环双链表
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 IsTail(DLinkList L, DLnode *p)
{
    if(p->next == L)
        return true;
    else
        return false;
}
//在循环双链表的p节点后插入节点s
bool InsertNextDLinkList(DLnode *p, DLnode *s)
{
    s->next=p->next;
    s->prior = p;
    p->next->prior = s;
    p->next = s;
}
3,代码问题

1,如何判空

2,如何判断结点p是否是表尾/表头结点(后向/前向遍历的实现核心)

3,如何在表头、表中、表尾插入/删除一个结点(插入、删除操作的不易错思路)

2.3.7静态链表表
1,什么是静态链表

静态链表:分配一整片连续的内存空间,各个结点集中安置。用数组的方式实现的链表

优点:增、删操作不需要大量移动元素

缺点:不能随机存取,只能从头结点开始依次往后查找;容量固定不可变

适用场景:①不支持指针的低级语言;②数据元素数量固定不变的场景(如操作系统的文件分配表FAT)

2,如何定义静态链表
#define MaxSize 10//静态链表的最大长度
struct Node{
    ElemType data;
    int next;//下一个元素的数组下标
};
3,简述基本操作的实现

初始化静态链表:把a[0]的next设为-1

把其他结点的next 设为一个特殊值用来表示结点空闲,如-2。

查找:从头结点出发挨个往后遍历结点

插入位序为i的结点:

①找到一个空的结点,存入数据元素

②从头结点出发找到位序为i-1的结点

③修改新结点的 next

④修改i-1号结点的next

删除某个结点:

①从头结点出发找到前驱结点

②修改前驱结点的游标

③被删除结点next设为-2

2.3.8顺序表和链表的比较
1,逻辑结构

都属于线性表,都是线性结构

一对一

2,存储结构(物理结构)
顺序表

优点:支持随机存取、存储密度高

缺点:大片连续空间分配不方便,改变容量不方便

链表

优点::离散的小空间分配方便,改变容量方便

缺点:不可随机存取,存储密度低

3,数据的运算/基本操作
1,创
  • 顺序表

需要预分配大片连续空间。若分配空间过小,则之后不方便拓展容量;

若分配空间过大,则浪费内存资源;

静态分配:静态数组(容量不可改变)

动态分配:动态数组(malloc、free)容量可改变

  • 链表

只需分配一个头结点(也可以不要头结点,只声明一个头指针),之后方便拓展。

2,销毁
  • 顺序表

修改Length = 0

静态分配:静态数组(系统自动回收空间)

动态分配:动态数组(malloc、free)需要手动free

  • 链表

依次删除各个节点,free

3,增/删
  • 顺序表

插入/删除元素要将后续元素都后移/前移

时间复杂度O(n),时间开销主要来自移动元素

若数据元素很大,则移动的时间代价很高

  • 链表

插入/删除元素只需修改指针即可

时间复杂度O(n),时间开销主要来自查找目标元素

查找元素的时间代价更低

4,查找
  • 顺序表

按位查找:0(1)按值查找:0(n)

若表内元素有序,可在O(log2n)时间内找到

  • 链表

按位查找:O(n)

按值查找:O(n)

4,选择

表长难以预估、经常要增加/删除元素——链表

表长可预估、查询(搜索)操作较多——顺序表

第三章栈、队列和数组

3.1栈

3.1.1栈的基本概念
1,定义

线性表是具有相同数据类型的n(n20)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。

栈(Stack)是只允许在一端进行插入或删除操作的线性表。

术语
  • 栈顶:

允许插入和删除的一端

  • 栈底:

不允许插入和删除的一端

  • 空栈:

特性:后进先出(LIFO)

2,基本操作

InitList(&L):初始化表。构造一个空的线性表L,分配内存空间。
DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间

Listlnsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。
ListDelete(&L,i&e):删除操作。删除表L中第1个位置的元素,并用e返回删除元素的值。

LocateElem(L.e):按值查找操作。在表L中查找具有给定关键字值的元素。
GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。

其他常用操作:
Length(L):求表长。返回线性表L的长度,即L中数据元素的个数。
PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。
Empty(L):判空操作。若L为空表,则返回true,否则返回false。

3.1.2栈的顺序存储实现(top=-1,指向当前元素)

顺序存储,用静态数组实现,并需要记录栈顶指针。

//顺序栈的定义
#define MaxSize 10
typedef struct
{
    ElmType data[MaxSize];//静态数组存放栈中元素
    int top;//栈顶指针,指向此时栈顶元素的位置
}SqStack;
1,基本操作
1,创(初始化)
void InitStack(SqStack &S)
{
    S.top = -1;//初始化栈顶指针
}
2,增(进栈)

先判断栈是否满了

//新元素入栈
bool Push(SqStack &S, ElemType x)
{
    if(s.top == MaxSize - 1)//栈满,报错
        return false;
    S.top = S.top + 1;//指针先加一
    s.data[S.top] = x;//新元素入栈
    return true;
}
3,删(出栈)

数据还残留在内存中,只是逻辑上被删除了

//出栈操作
bool Pop(SqStack &S, ElemType &x)
{
    if(S.top == -1)\\栈空,报错
        return false;
    x = S.data[S.top];
    S.top--;
    return true;
}
4,查(获取栈顶元素)
//读栈顶元素
bool GetTop(SqStack S, ElemType &x)
{
    if(S.top == -1)//栈空报错
        return false;
    x = S.data[s.top];
    retur true;
}
5,判空、判满
//判断栈空
bool StackEmpty(SqStack S)
{
    if(S.top == -1)
        return true;
    else
        return false;
}
2,两种实现

初始化时top=0和top=-1

前者代表栈顶指针指向下一个要插入的位置

后者指向当前元素的位置

3,共享栈

两个栈共享同一片内存空间,两个栈从两边往中间增长

初始化

0号栈栈顶指针初始时 top0=-1;

1号栈栈顶指针初始时 top1=MaxSize;

栈满条件

top0+ 1 == top1;

3.1.3栈的链式存储实现

跟单链表差不多,限制只能头插,删除也只能删除第一个元素

1,定义

用链式存储方式实现的栈

2,带头结点

两种实现方式
不带头结点(推荐)

3,重要基本操作

创(初始化)
增(进栈)
删(出栈)
查(获取栈顶元素)
如何判空、判满?

3.2队列

3.2.1队列的基本概念
1,定义

队列(Queue)是只允许在一端进行插入,在另一端删除的线性表。

特点:

先进入队列的元素先出队(FIFO)。

术语:

队头,队尾,空队列

队头:允许删除的一端

队尾:允许插入的一瑞

2,基本操作

InitQueue(&Q):初始化队列,构造一个空队列Q。
DestroyQueue(&Q):销毁队列。销毁并释放队列Q所占用的内存空间

EnQueue(&Q,x):入队,若队列Q未满,将x加入,使之成为新的队尾。
DeQueue(&Q,&x):出队,若队列Q非空,删除队头元素,并用x返回。

GetHead(Q,&x):读队头元素,若队列Q非空,则将队头元素赋值给x。

其他常用操作:
QueueEmpty(Q):判队列空,若队列Q为空返回true,否则返回false。

3.2.2队列的顺序存储实现
1,队列的实现
#define MaxSize 10
typedef struct
{
    ElemType data[MaxSize]; //用静态数组存放队列元素
    int front,rear;//队头指针和队尾指针
}SqQueue;

分配一块连续的存储单元存放队列中的元素,并附设两个指针

队尾指针:指向队尾元素的后一个位置(下一个应该插入的位置)。

队头指针:指向队头元素。

2,基本操作

进队操作:队不满时,先送值到队尾元素,再将队尾指针加1
出队操作:队不空时,先取队头元素值,再将队头指针加1

1,初始化
//初始化队列
void InitQueu(SqQueue &Q)
{
    //初始化时,队头、队尾指针指向0
    Q.read = Q.front =0;
}
2,判空
bool QueueEmpty(SqQueue Q)
{
    if(Q.rear == Q.front0)
        return true;
    else
        return false;
}
3,增/删(入队/出队操作)

进队操作:队不满时,先送值到队尾元素,再将队尾指针加1
出队操作:队不空时,先队头元素值,再将队头指针加1

假溢出:这种溢出并不是真正的溢出,在data数组中依然存在可以存放元素的空位置

3,循环队列

把存储队列元素的表从逻辑上视为一个环,称为循环队列

初始时:Q.front=Q.rear=0
队首指针进1:Q.front=(Q.front+1)%MaxSize
队尾指针进1:Q.rear=(Q.rear+1)%MaxSize
队列长度:(Q.rear+MaxSize-Q.front)%MaxSize.

判断条件:

队空:Q.front=Q.rear

队满:

牺牲一个单元来区分队空和队满,即”队头指针在队尾指针的下一位置作为队满的标志”。

类型中增设表示元素个数的数据成员。

类型中增设tag数据成员,以区分是队满还是队空

3.2.3队列的链式存储实现
1,队列的实现
typedef struct LinkNode//链式队列节点
{
    ElemType data;
    struct LinkNode *next;
}LinkNode;
typedef struct{			//链式队列
    LinkNode *front, *rear;//队列的头和尾指针
}LinkQueue;
2,基本操作
1,创(初始化)
//初始化队列(带头节点)
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)
{
    //初始化时front、rear都指向NULL
    Q.front = NULL;
    Q.front = NULL;
}

//判断队列是否为空(带头节点)
bool IsEmpty(LinkQueue Q)
{
    if(Q.front == NULL)
        return true;
    else 
        return false;
}
2,增(入队)
//新元素入队(带头结点)
void EnQueue(LinkQueue &Q, ElemType x)
{
    LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
    s->data = x;
    s->next = NULL;
    Q.rear->next = s;//新节点插入到rear之后
    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;//新节点插入到rear之后
   	 	Q.rear = s;//修改表尾指针
    }
}
3,删(出队)
//队头元素出队(带头节点)
bool DeQueu(LinkQueue &Q, ElemType &x)
{
    if(Q.front == Q.rear)//空队
        return false;
    LinkNode *p = Q.front->next;
    x = p->data;
    Q.front->next = p->next;//修改头节点的next指针
    if(Q.rear == p)//如果此次是最后一个节点出队
        Q.rear = Q.front;//修改rear指针的指向
    free(p);
    return true;
}

//队头元素出队(不带头节点)
bool DeQueu(LinkQueue &Q, ElemType &x)
{
    if(Q.front == Q.rear)//空队
        return false;
    LinkNode *p = Q.front;
    x = p->data;
    Q.front = p->next;//修改头节点的next指针
    if(Q.rear == p)//如果此次是最后一个节点出队
    {
        Q.rear = NULL;
        Q.front = NULL;
    }
    free(p);
    return true;
}
4,查(获取头元素)
5,判满

链式存储——一般不会队满,除非内存不足

3.2.4双端队列

栈:只允许从一端插入和删除的线性表

队列:只允许从一端插入、另一端删除的线性表

双端队列:只允许从两端插入、两端删除的线性表(若只使用其中一端的插入,删除操作,效果等同于栈)

输入受限的双端队列:允许从两端删除、从一端插入的队列
输出受限的双端队列:允许从两端插入、从一端删除的队列

考点:对输出序列合法性的判断(在栈中合法的输出序列,在双端队列中必定合法)

3.3栈和队列的应用

3.3.1栈在括号匹配中的应用

用栈实现括号匹配:

依次扫描所有字符,遇到左括号入栈,遇到右括号则弹出栈顶元素检查是否匹配。

匹配失败情况:①左括号单身②右括号单身③左右括号不匹配

3.3.2栈在表达式求值中的应用(上)
1,概念

运算符、操作数、界限符(DIY概念:左操作数/右操作数)

2,三种表达式

中缀表达式:运算符在操作数中间

后缀表达式(逆波兰式):运算符在操作数后面

前缀表达式(波兰式):运算符在操作数前面

3,后缀表达式考点
1,中缀转后缀(手算)

①确定中缀表达式中各个运算符的运算顺序

②选择下一个运算符,按照「左操作数右操作数运算符]的方式组合成一个新的操作数

③如果还有运算符没被处理,就继续②

“左优先”原则:只要左边的运算符能先计算,就优先算左边的(可保证运算顺序唯一)

2,后缀转中缀(手算)

从左往右扫描,每遇到一个运算符,就将<左操作数 右操作数 运算符>变为(左操作数 运算符 右操作数)的形式。

3,后缀表达式的计算(机算)

用栈实现后缀表达式的计算:

①从左往右扫描下一个元素,直到处理完所有元素

②若扫描到操作数则压入栈,并回到①;否则执行③(注意:先出栈的是“右操作数”)

③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①

4,前缀表达式
1,中缀转前缀

中缀转前缀的手算方法:

① 确定中缀表达式中各个运算符的运算顺序

②选择下一个运算符,按照「运算符左操作数右操作数】的方式组合成一个新的操作数

③如果还有运算符没被处理,就继续②右边

“右优先”原则:只要右边的运算符能先计算,就优先算右边的

2,计算

用栈实现前缀表达式的计算:

①从右往左扫描下一个元素,直到处理完所有元素

②若扫描到操作数则压入栈,并回到①;否则执行③(注意:先出栈的是“左操作数”)

③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①

3.3.3栈在表达式求值中的应用(下)
1,用栈实现中缀表达式转后缀表达式:

初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右处理各个元素,直到末尾。可能遇到三种情况:

①遇到操作数。直接加入后缀表达式。

②遇到界限符。遇到“(”直接入栈;遇到“)”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。注意:“(”不加入后缀表达式。

③遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(”或栈空则停止。之后再把当前运算符入栈。按上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。

2,用栈实现后缀表达式的计算:

①从左往右扫描下一个元素,直到处理完所有元素

②若扫描到操作数则压入栈,并回到①;否则执行③

③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①

3,用栈实现中缀表达式的计算:

初始化两个栈,操作数栈和运算符栈

若扫描到操作数,压入操作数栈

若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)

3.3.4栈在递归中的应用

函数调用的特点:最后被调用的函数最先执行结束(LIFO)

函数调用时,需要用一个“函数调用栈”存储:

①调用返回地址②实参③局部变量

递归调用时,函数调用栈可称为“递归工作栈

”每进入一层递归,就将递归调用所需信息压入栈顶

每退出一层递归,就从栈顶弹出相应信息

缺点:效率低,太多层递归可能会导致栈溢出;可能包含很多重复计算。

3.3.5队列的应用

树的层次遍历

图的广度优先遍历

实现先来先服务管理调度策略

3.4特殊矩阵的压缩存储

1,对称矩阵
1,特点

若n阶方阵中任意一个元素aij都有ai,j = aj,i则该矩阵为对称矩阵

2,压缩存储策略:

只存储主对角线+下三角区(或主对角线+上三角区)

按行优先或者按列优先

2,三角矩阵
1,特点

下三角矩阵:除了主对角线和下三角区,其余的元素都相同

上三角矩阵:除了主对角线和上三角区,其余的元素都相同

2,压缩

按行优先/列优先规则依次存储非常量区域,并在最后一个位置存放常量c

3,三对角矩阵(带状矩阵)
1,特点

当li-jl>1时,有ai,j=0(1<= i, j<=n)

2,压缩

按行优先/列优先规则依次存储带状区域

4,稀疏矩阵
1,特点

非零元素远远少于矩阵元素的个数

2,策略
策略一:

顺序存储——三元组<行,列,值>

策略二:

链式存储——十字链表法

第四章串

4.1串的定义和实现

4.1.1串的定义和基本操作
1,定义

串,即字符串(String)是由零个或多个字符组成的有限序列。一般记为S=‘a1a2……an’ (n 20)其中,s是串名,单引号括起来的字符序列是串的值;a;可以是字母、数字或其他字符;串中字符的个数n称为串的长度。n=0时的串称为空串(用(表示)。

子串:串中任意个连续的字符组成的子序列。

主串:包含子串的串。字

符在主串中的位置:字符在串中的序号。(第一次出现的位置,从1开始)

子串在主串中的位置:子串的第一个字符在主串中的位置。

2,串VS线性表

串是一种特殊的线性表,数据元素之间呈线性关系。

串的数据对象限定为字符集(如中文字符、英文字符、数字字符、标点字符等)

串的基本操作,如增删改查等通常以子串为操作对象

3,基本操作

StrAssign(&T,chars):赋值操作。把串T赋值为chars。

StrCopy(&T,S):复制操作。由串S复制得到串T。

StrEmpty(S):判空操作。若S为空串,则返回TRUE,否则返回FALSE。

StrLength(S):求串长。返回串s的元素个数。

ClearString(&S):清空操作。将s清为空串。

DestroyString(&S):销毁串。将串s销毁(回收存储空间)。

Concat(&T,S1,S2):串联接。用T返回由S1和S2联接而成的新串

SubString(&Sub,S,pos,len):求子串。用Sub返回串s的第pos个字符起长度为len的子串。

Index(S,T):定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现的位置;否则函数值为0。

StrCompare(S,T):比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<O。(从第一个字符开始往后依次对比,先出现更大字符的串就更大)

4,字符集编码

每个字符在计算机中对应一个二进制数,比较字符的大小其实就是比较二进制数的大小。

4.1.2串的存储结构
1,顺序存储
1,静态数组
#define MAXLEN 255
typedef struct{
    char ch[MAXLEN];//每个分量存储一个字符
    int length;//串的实际长度
}SString;
2,动态数组
typedef struct{
    char *ch;//按串长分配存储区,ch指向串的基地址
    int length;//串的长度
}HString;
S.ch = (char *)malloc(MAXLEN * sizeof(char));
S.length = 0;
2,链式存储
typedef struct StringNode{
    char ch;//每个节点存一个字符,存储密度低
    struct StringNode *next;
}StringNode, *String;
typedef struct StringNode{
    char ch[4];//每个节点存多个字符,存储密度提高
    struct StringNode * next;
}StringNode, *String;
3,基本操作实现

定义

#define MAXLEN 255
typedef struct{
    char ch[MAXLEN];//每个分量存储一个字符
    int length;//串的实际长度
}SString;

求子串:bool SubString(SString &Sub,SString S,int pos,int len)

//用Sub返回串s的第pos个字符起长度为len的子串
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;
}

串的比较:int StrCompare(SString S,SString T)

//若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值相同的子串,则返回它在主串S中第一次出现的位置,否则函数值为0
int Index(SString S, SString T)
{
    int i = 1, n = StrLength(S), m = StrLength(T);
    SString sub; //用于暂存子串
    while(i <= n - m + 1)
    {
        SubString(sub, S, i, m);
        if(StrCompare(sub, T) != 0)
            ++i;
        else
            return i;//返回子串在主串中的位置
    }
    return 0;//S中不存在与T相等的子串
}

4.2串的模式匹配

4.2.1朴素模式匹配算法(暴力解决)
1,算法思想

主串长度n,模式串长度m

将主串中所有长度为m的子串与模式串对比(n-m+1个子串)

找到第一个与模式串匹配的子串,并返回子串起始位置

若所有子串都不匹配,则返回0

int Index(SString S, SString T){
    int i = 1, j = 1;
    while(i <= S.length && j <= T.length)
    {
        if(S.ch[i] == T.ch[j])
        {
            i++;j++;//继续比较后继字符
        }
        else
        //若当前子串匹配失败,则主串指针i指向下一个子串的第一个位置,模式串指针j回到模式串的第一个位置
        {
            i = i - j + 2;
            j = 1;			//指针回退重新开始匹配
        }
    }
    if(j > T.length)
        return i - T.length;
    else
        return 0;
}
2,最坏时间复杂度

设主串长度为n,模式串长度为m,则最坏时间复杂度=O(nm)

4.2.2KMP算法

根据模式串T,求出next数组

next数组只和短短的模式串有关,和长长的主串无关

利用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;
}

KMP算法,最坏时间复杂度O(m+n)

其中,求next数组时间复杂度O(m)

模式匹配过程最坏时间复杂度O(n)。

4.2.3KMP算法——求next数组(手算)

next数组的作用:当模式串的第j个字符失配时,从模式串的第 next[j]的继续往后匹配

next[1]都无脑写0

next[2]都无脑写1

其他next:在不匹配的位置前,划一根美丽的分界线模式串一步一步往后退,直到分界线之前“能对上”,或模式串完全跨过分界线为止。此时j指向哪儿,next数组值就是多少。

4.2.4KMP算法的进一步优化

手算解题:先求next数组,再由next数组求nextval数组

第五章树

5.1树的基本概念

5.1.1树的定义和基本术语
1,基本概念

树:从树根生长,逐级分支

空树——结点数为0的树

非空树的特性:

有且仅有一个根节点

没有后继的结点称为“叶子结点”(或终端结点)

有后继的结点称为“分支结点”(或非终端结点)

除了根节点外,任何一个结点都有且仅有一个前驱

每个结点可以有0个或多个后继。

2,基本术语
1,结点之间的关系

父节点(双亲结点)、孩子结点

祖先结点、子孙结点

兄弟结点、堂兄弟结点

结点之间的路径——只能从上往下

路径长度——路径上经过多少条边

2,结点、树的属性

结点的层次(深度)——从上往下数

结点的高度——从下往上数

树的高度(深度)——总共多少层

结点的度——有几个孩子(分支)

树的度——各结点的度的最大值

3,有序树VS无序树

有序树——逻辑上看,树中结点的各子树从左至右是有次序的,不能互换

无序树——逻辑上看,树中结点的各子树从左至右是无次序的,可以互换

4,森林

森林是m(m≥0)棵互不相交的树的集合

5.1.2树的性质
考点1

结点数=总度数+1

结点的度——结点有几个孩子(分支)

考点2

树的度——各结点的度的最大值

m叉树——每个结点最多只能有m个孩子的树

度为m的树m叉树
任意节点的度<=m(最多m个孩子)任意节点的度<=m(最多m个孩子)
至少有一个节点度=m(有m个孩子)允许所有节点的度都<m
一定是非空树,至少有m+1个节点可以是非空树
考点3

度为m的树第i至多有m^(i-1)个结点(i>=1)

m叉树第i层至多有m^(i-1)个结点(i>=1)

考点4

高度为h的m叉树至多有个(m^h-1)/(m-1)结点。(性质2的等比数列求和)

考点5

高度为h的m叉树至少有h个结点。

高度为h、度为m的树至少有h+m-1个结点。

考点6

具有n个结点的m叉树的最小高度为| logm(n(m-1)+1)]

高度最小的情况——所有结点都有m个孩子。

5.2二叉树的概念

5.2.1二叉树的定义和基本术语
1,基本概念
二叉树是n(n≥0)个结点的有限集合:

①或者为空二叉树,即n=0

②或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树

特点:

①每个结点至多只有两棵子树

②左右子树不能颠倒(二叉树是有序树)

二叉树的五种状态:
  • 空二叉树

  • 只有左子树

  • 只有右子树

  • 只有根节点

  • 左右子树都有

2,特殊二叉树
1,满二叉树

一棵高度为h,且含有(2^h)-1个结点的二叉树

特点:

①只有最后一层有叶子结点

②不存在度为1的结点

③按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点i的父节点为[i/2](如果有的话)

2,完全二叉树

当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。

特点:

①只有最后两层可能有叶子结点

②最多只有一个度为1的结点

③同满二叉树的第三个特点

④is[n/2]为分支结点,i>ln/2]为叶子结点

3,二叉排序树

左子树上所有结点的关键字均小于根结点的关键字;

右子树上所有结点的关键字均大于根结点的关键字。

左子树和右子树又各是一棵二叉排序树。

4,平衡二叉树

树上任一结点的左子树和右子树的深度之差不超过1。

平衡二叉树能有更高的搜索效率。

5.2.2二叉树的性质
1,二叉树
常见考点1:

设非空二叉树中度为0、1和2的结点个数分别为no、n1和n2,则no=n2+1(叶子结点比二分支结点多一个)

假设树中结点总数为n,则

①n=no+n1+n2

②n=n1+2n2+1

常见考点2:

二叉树第i层至多有2^(i-1)个结点(i>=1)

m叉树第i层至多有m^(i-1)个结点(i>=1)

常见考点3:

高度为h的二叉树至多有(2^h)-1个结点(满二叉树)

高度为h的m叉树至多有个(m^h-1)/(m-1)结点

2,完全二叉树
常见考点1:

具有n个(n>0)结点的完全二叉树的高度h为(log2(n+1))或(log2n)+1

高为h的满二叉树共有2^h-1个

结点高为h-1的满二叉树共有2^(h-1)-1个结点

常见考点2:

对于完全二叉树,可以由的结点数n推出度为0、1和2的结点个数为no、n1和n2

完全二叉树最多只有一个度为1的结点,即n1= 0或1

n0=n2+1——》n0+n2一定是偶数

若完全二叉树有2k个(偶数)个结点,则必有n1=1,no=k,n2=k-1

若完全二叉树有2k-1个(奇数)个结点,则必有n1=0,no=k,n2=k-1

5.2.3二叉树的存储结构
1,顺序存储
//定义
#define MaxSize 100
struct TreeNode{
    ElemType vlaue;//节点中的数据元素
    bool isEmpty;//节点是否为空
};
struct TreeNode t[MaxSize];
//初始化时给所有节点标记为空,可以让第一个位置空缺,保证数组下标和节点编号一致
for(int i = 0; i < MaxSize; i++)
{
    t[i].isEmpty = true;
}
2,链式存储
//定义(二叉链表)
struct ElemTyped{
    int value;
}
typedef struct BitNode{
    ElemType data;			//数据域
    struct BiNode *lchild, *rchlid;//左右孩子指针
}BiTNode, *BiTree;
//创建一棵二叉树
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;//作为根节点的左孩子
//三叉链表定义
typedef struct BiTNode{
    ElemType data;
    struct BiTNode *lchild, *rchild;
    struct BiTNode *parent;  //父节点指针
}BiTNode, *BiTree;

5.3二叉树的遍历和线索二叉树

5.3.1二叉树的先中后序遍历

遍历:按照某种次序把所有结点都访问一遍

层次遍历:基于树的层次特性确定的次序规则

先/中/后序遍历:基于树的递归特性确定的次序规则

1,三种方法
//定义二叉树
typedef struct BiTNode{
    ElemType data;
    struct BiTNode *lchild, rchild;
}BiTNode, *BiTree;
1,先序遍历

根左右(NLR)

先序遍历(PreOrder)的操作过程如下:

1.若二叉树为空,则什么也不做;

2.若二叉树非空:

①访问根结点;

②先序遍历左子树;

③先序遍历右子树。

//先序遍历
void PreOrder(BiTree T){
	if(T != NULL)
    {
        visit(T);			//访问根节点
        ProOrder(T->lchild);//递归遍历左子树
        ProOrder(T->rchild);//递归遍历右子树
    }
}
2,中序遍历

左根右(LNR)

中序遍历(InOrder)的操作过程如下:

1.若二叉树为空,则什么也不做;

2.若二叉树非空:

①中序遍历左子树;

②访问根结点;

③中序遍历右子树。

//中序遍历
void PreOrder(BiTree T){
	if(T != NULL)
    {
        ProOrder(T->lchild);//递归遍历左子树
        visit(T);			//访问根节点
        ProOrder(T->rchild);//递归遍历右子树
    }
}
3,后序遍历

左右根(LRN)

后序遍历(InOrder)的操作过程如下:

1.若二叉树为空,则什么也不做;

2.若二叉树非空:

①后序遍历左子树;

②后序遍历右子树;

③访问根结点。

//后序遍历
void PreOrder(BiTree T){
	if(T != NULL)
    {
        ProOrder(T->lchild);//递归遍历左子树
        ProOrder(T->rchild);//递归遍历右子树
        visit(T);			//访问根节点
    }
}
2,遍历算数表达式树

先序遍历得前缀表达式

中序遍历得中缀表达式(没有括号)

后序遍历得后缀表达式

3,考点:求遍历序列

分支节点逐层展开法

5.3.2二叉树的层次遍历

算法思想:

①初始化一个辅助队列

②根结点入队

③若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)

④重复③直至队列为空

//二叉树的节点(链式存储)
typedef struct BiTNode{
    char data;
    struct BiTNode *lchild,*rchild;
} BiTNode, *BiTree;
    
    
//链式队列节点
typedef struct LinkNode{
    BiTNode *data;
    struct LinkNode *next;
}LinkNode;

typedef struct{
    LinkNode *front, *rear;
}

//层序遍历
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.3.3由遍历序列构造二叉树

结论:若只给出一棵二叉树的前/中/后/层序遍历序列中的一种,不能唯一确定一棵二叉树。

而如果给出下列组合中的一种组合,就可以确定

1,前序+中序遍历

前序遍历:根结点、前序遍历左子树、前序遍历右子树

中序遍历:中序遍历左子树、根结点、中序遍历右子树

2,后序+中序遍历

后序遍历:前序遍历左子树、前序遍历右子树、根结点

中序遍历:中序遍历左子树、根结点、中序遍历右子树

3,层序+中序遍历

根节点,左根,右根……

中序遍历:中序遍历左子树、根结点、中序遍历右子树

4,补充

Key:找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点

前序、后序、层序序列的两两组合无法唯一确定一科二叉树

5.3.4线索二叉树的概念
1,作用

指向前驱、后继的指针称为“线索”。

方便从一个指定结点出发,找到其前驱、后继;方便遍历

2,存储结构

在普通二叉树结点的基础上,增加两个标志位Itag 和rtag

Itag1时,表示lchild指向前驱;Itag0时,表示lchild指向左孩子

rtag1时,表示rchild指向后继;rtag0时,表示rchild指向右孩子

//线索二叉树节点
typedef struct ThreadNode{
    ElemType data;
    struct ThreadNode *lchild, *rchild;
    int ltag,rtag;//左右线索标志
}ThreadNode, *ThreadTree;

//tag==0,表示指针指向孩子
//tag==1,表示指针是线索
3,三种线索二叉树

中序线索二叉树——以中序遍历序列为依据进行“线索化"

先序线索二叉树——以先序遍历序列为依据进行“线索化"

后序线索二叉树——以后序遍历序列为依据进行“线索化"

4,几个概念

线索——指向前驱/后继的指针称为线索

中序前驱/中序后继;先序前驱/先序后继;后序前驱/后序后继

5,手算画出线索二叉树

①确定线索二叉树类型——中序、先序、or后序?

②按照对应遍历规则,确定各个结点的访问顺序,并写上编号

③将n+1个空链域连上前驱、后继

5.3.5二叉树的线索化
1,中序线索化
//线索二叉树节点
typedef struct ThreadNode{
    ElemType data;
    struct ThreadNode *lchild, *rchild;
    int ltag,rtag;//左、右线索标志
}ThreadNode, * ThreadTree;

//全局变量pre,指向当前访问节点的前驱
ThreadNode *pre = NULL;

//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T)
{
    if(T != NULL)
    {
        InThread(T->lchild);//中序遍历左子树
        visit(T);//访问根节点
        InThread(T->rchild);//中序遍历右子树
    }
}

void vist(ThreadNode *q){
    if(q->lchild == NULL){//左子树为空,建立前驱线索
        q->lchild = pre;
        q->tag = 1;
    }
    if(pre != NULL && pre->rchild == NULL){
        pre->rchild = q;//建立前驱节点的后继线索
        pre->tag = 1;
    }
    pre = q;
}

//中序线索化二叉树
void CreatInThread(ThreadTree T)
{
    pre = NULL;//pre初始为NULL
    if(T != NULL)//非空二叉树才能线索化
    {
        InThread(T);//中序线索化二叉树
        if(pre->rchild == NULL)
            pre->rtag = 1;//处理遍历的最后一个节点
    }
}
2,先序线索化
3,后序线索化
4,核心

中序/先序/后序遍历算法的改造,当访问一个结点时,连接该结点与前驱结点的线索信息

用一个指针pre记录当前访问结点的前驱结点

5,易错点

最后一个结点的rchild、rtag的处理

先序线索化中,注意处理爱滴魔力转圈圈问题,当ltag==0时,才能对左子树先序线索化

5.3.6线索二叉树找前驱/后继
1,中序线索二叉树
1,后继

在中序线索二叉树中找到指定结点*p的中序后继next

①若p->rtag==1,则next=p->rchild

②若p->rtag==0,则next = p的右子树的最左下节点

//找到以P为根节点的子树中,第一个被中序遍历的节点
ThreadNode *Firstnode(ThreadNode *p)
{
    //循环找到最左下节点(不一定是叶节点)
    while(p->ltag == 0)
        p=p->lchild;
    return p;
}

//在中序线索二叉树中找打节点p的后继节点
ThreadNode *Nextnode(ThreadNode *p){
    //右子树中最左下节点
    if(p->rtag == 0)
        return FirstNode(p->rchild);
    else
        return p->rchild;	//rtag==1直接返回后继线索
}

//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法)
void Inorder(ThreadNode *T)
{
    for(ThreadNode *p = Firstnode(T); p != NULL; p = nextnode(p))
        vist(p);
}
2,前驱

在中序线索二叉树中找到指定结点*p的中序前驱pre

①若p->ltag==1,则pre=p->lchild(已经被线索化)

②若p->ltag==0,则pre=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);
}
2,先序线索二叉树
1,后继

①若p有左孩子,则先序后继为左孩子③若p没有左孩子,则先序后继为右孩子

2,前驱

①如果能找到p的父节点,且p是左孩子,则p的父节点为其前驱

②如果能找到p的父节点,且p是右孩子,其左兄弟为空,则p的父节点即为其前驱

③如果能找到p的父节点,且p是右孩子,其左兄弟非空,则p的前驱为其左兄弟子树最后一个被先序遍历的结点

④如果p是根节点,则p没有先序前驱

3,后序线索二叉树
1,后继

①如果能找到p的父节点,且p是右孩子,p的父节点即为其后继

②如果能找到p的父节点,且p是左孩子,其右兄弟为空,p的父节点即为其后继

③如果能找到p的父节点,且p是左孩子,其右兄弟非空,则p的后继为其右兄弟子树中第一个被后序遍历的结点

④如果p是根节点,则p没有后序后继

2,前驱

①若p有右孩子,则后序前驱为右孩子

②若p没有右孩子,则后序前驱为左孩子

5.4树、森林

5.4.1树的存储结构
1,双亲表示法

顺序存储结点数据,结点中保存父节点在数组中的下标

优点:找父节点方便;缺点:找孩子不方便。

2,孩子表示法

顺序存储结点数据,结点中保存孩子链表头指针(顺序+链式存储)

优点:找孩子方便;缺点:找父节点不方便

3,孩子兄弟表示法

用二叉链表存储树——左孩子右兄弟

孩子兄弟表示法存储的树,从存储视角来看形态上和二叉树类似

考点:树与二叉树的相互转换。本质就是用孩子兄弟表示法存储树

//树的存储——孩子兄弟表示法
typedef struct CSNode{
    ElemType data;
    struct CSNode *firstchild, *nextsibling;//第一个孩子和右兄弟指针
}
3,森林与二叉树的转换

本质:用二叉链表存储森林——左孩子右兄弟

森林中各个树的根节点之间视为兄弟关系

5.4.2树、森林的遍历
1,树的遍历
1,先根遍历

若树非空,先访问根结点再依次对每棵子树进行先根遍历。

树的先根遍历序列与这棵树相应二叉树的先序序列相同。

2,后根遍历

若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。

树的后根遍历序列与这棵树相应二叉树的中序序列相同。

3,层序遍历

3)层次遍历(用队列实现)

①若树非空,则根节点入队

②若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队

③重复②直到队列为空

2,森林的遍历

森林。森林是m(m≥0)棵互不相交的树的集合。每棵树去掉根节点后,其各个子树又组成森林。

1,先序遍历

若森林为非空,则按如下规则进行遍历:

访问森林中第一棵树的根结点。

先序遍历第一棵树中根结点的子树森林。

先序遍历除去第一棵树之后剩余的树构成的森林。

(效果等同于依次对各个子树的先根遍历)

2,中序遍历

若森林为非空,则按如下规则进行遍历:

中序遍历森林中第一棵树的根结点的子树森林。

访问第一棵树的根结点。

中序遍历除去第一棵树之后剩余的树构成的森林。

(效果等同于依次对各个树进行后根遍历)

5.5树与二叉树的应用

5.5.1哈夫曼树
1,概念

结点的权:有某种现实含义的数值(如:表示结点的重要性等)。

结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL,Weighted Path Length)。

在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。

2,构造哈夫曼树

给定n个权值分别为w1,w2,…wn的结点,构造哈夫曼树的算法描述如下:
1)将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
2)构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新
结点的权值置为左、右子树上根结点的权值之和。
3)从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
4)重复步骤2)和3),直至F中只剩下一棵树为止。

特点:

1)每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
2)哈夫曼树的结点总数为2n-1
3)哈夫曼树中不存在度为1的结点。
4)哈夫曼树并不唯一,但WPL必然相同且为最优

3,哈夫曼编码

将字符频次作为字符结点权值,构造哈夫曼树,即可得哈夫曼编码,可用于数据压缩
前缀编码——没有一个编码是另一个编码的前缀
固定长度编码——每个字符用相等长度的二进制位表示
可变长度编码——允许对不同字符用不等长的二进制位表示

5.5.2并查集
1,三要素
逻辑结构

逻辑结构——元素之间为“集合”关系

基本操作

初始化——初始化并查集,将所有数组元素初始化为-1
Find(S[].x)—“查”,找到元素x所属集合
Union(S[],root1,root2)——“并”,将两个集合合并为一个集合

存储结构

顺序存储,每一个集合组织成一棵树,采用“双亲表示法”

2,优化

优化思路:在每次union操作构建树的时候,尽可能让树不长高高

①用根节点的绝对值表示一棵树(集合)的结点总数
②Union操作合并两棵树时,小树并入大树

5.5.3并查集的终极优化

压缩路径——Find 操作,先找到根节点.再将查找路径上所有结点都挂到根结点下

每次Find 操作,先找根,再“压缩路径”,可使树的高度不超过o(a(n))。

a(n)是一个增长很缓慢的函数,对于常见的n值,通常a(n)<4, 因此优化后并查集的Find、Union操作时间开销都很低。

第六章图

6.1图的基本概念

1,定义

图G由顶点集V和边集E组成,记为G=(V,E),其中VG)表示图G中顶点的有限非空集:E(G)表示图G中顶点之间的关系(边)集合。

若V=(V1uV2…,Va],则用|V表示图G中顶点的个数,也称图G的阶,E={(u,v)I ueV,veV,用|E|表示图G中边的条数。
注意:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集

无向图

若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v.w)或(w.v),因为(v.w)=(.v),其中v、w是顶点。可以说顶点w和顶点v互为邻接点。边(v,w)依附于顶点w和v,或者说边(r.w)和顶点v、w相关联。

有向图

若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v.w>,其中r、w是顶点,v称为弧尾,w称为弧头,<v.w>称为从顶点v到项点w的弧,也称v邻接到w,或w邻接自v。<v,w>!= <w,v>

简单图

①不存在重复边;②不存在顶点到自身的边

多重图

图G中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则G为多重图

定点的度

对于无向图:顶点v的度是指依附于该顶点的边的条数,记为TD(v)。

对于有向图:
入度是以顶点v为终点的有向边的数目,记为ID(v);
出度是以顶点v为起点的有向边的数目,记为OD(v)。
顶点v的度等于其入度和出度之和,即TD(v)=ID(v)+OD(V)。

2,顶点到顶点的关系

.路径—-顶点vp到顶点va之间的一条路径是指顶点序列
·回路一一第一个顶点和最后一个顶点相同的路径称为回路或环
·简单路径一一在路径序列中,顶点不重复出现的路径称为简单路径。
·简单回路一一除第一个顶点和最后一个顶点外,其余项点不重复出现的回路称为简单回路。
·路径长度——路径上边的数目
·点到点的距离——从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若从u到v根本不存在路径,则记该距离为无穷(oo)
·无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的
·有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的

若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。

常见考点:

对于n个顶点的无向图G,
若G是连通图,则最少有n-1条边
若G是非连通图,则最多可能有C2,n-1条边

若图中任何一对顶点都是强连通的,则称此图为强连通图。

常见考点:

对于n个顶点的有向图G,
若G是强连通图,则最少有n条边(形成回路)。

3,图的局部
1,子图

设有两个图G=(V,E)和G’=(V’,E’),若V‘是V的子集,且E‘是E的子集,则称G’是G的子图。
若有满足V(G’)=V(G)的子图G’,则称其为G的生成子图

2,连通分量

无向图中的极大连通子图称为连通分量。

子图必须连通,且包含尽可能多的顶点和边。

3,强连通分量

有向图中的极大强连通子图称为有向图的强连通分量

子图必须强连通,同时保留尽可能多的边。

4,连通无向图的生成树

连通图的生成树是包含图中全部顶点的一个极小连通子图。

边尽可能的少,但要保持连通。

若图中顶点数为n,则它的生成树含有n-1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。

5,非连通无向图的生成森林

在非连通图中,连通分量的生成树构成了非连通图的生成森林。

6,边的权,带权图/网

边的权一一在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
带权图/网——边上带有权值的图称为带权图,也称网。
带权路径长度一一当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度

4,几种特殊形态的图

无向完全图——无向图中任意两个顶点之间都存在边

有向完全图——有向图中任意两个顶点之间都存在方向相反的两条弧

边数很少的图称为稀疏图,反之称为稠密图

树——不存在回路,且连通的无向图

有向树——一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。

6.2图的存储及基本操作

6.2.1图的存储——邻接矩阵法(顺序存储)

·如何计算指定顶点的度、入度、出度(分无向图、有向图来考虑)?时间复杂度如何?
·如何找到与顶点相邻的边(入边、出边)?时间复杂度如何?
·如何存储带权图?
·空间复杂度——O(lV|^2),适合存储稠密图
·无向图的邻接矩阵为对称矩阵,如何压缩存储?
·设图G的邻接矩阵为A(矩阵元素为0/l),则An的元素An[]U]等于由顶点i到顶点j的长度为n的路径的数目

6.2.2图的存储——邻接表法(顺序+链式存储)
邻接表邻接矩阵
空间复杂度无向图O(|v|+2|E|);有向图O(|V|+|E|)O(|V|^2)
适用于存储稀疏图存储稠密图
表示方式不唯一唯一
计算度/出度/入度计算有向图的度、入度不方便,其余很方便必须遍历对应行或列
找相邻的边找有向图的入边不方便,其余很方便必须遍历对应行或列
6.2.3图的存储——十字链表(有向图)、邻接多重表(无向图)
邻接矩阵邻接表十字链表邻接多重表
空间复杂度O(|v|^2)无向图O(|v| + 2|E|);有向图O(|V|+|E|)O(|V|+|E|)O(|V|+|E|)
找相邻边遍历对应行或列时间复杂度为O(|v|)找有向图的入边必须遍历整个邻接表很方便很方便
删除边或顶点删除边很方便,删除顶点需要大量移动数据无向图中删除边或顶点都不方便很方便很方便
适用于稠密图稀疏图和其他只能存有向图只能存无向图
表示方式唯一不唯一不唯一不唯一
6.2.4图的基本操作

·Adjacent(G,x,y):判断图G是否存在边<x,y或(x,y)。

.Neighbors(G,x):列出图G中与结点x邻接的边。

.InsertVertex(G,x):在图G中插入顶点x。DeleteVertex(G,x):从图G中删除顶点x。

.DeleteVertex(G,x):从图G中删除顶点x。

.AddEdge(G,x,y):若无向边(x,y)或有向边<x,y>不存在,则向图G中添加该边。

.RemoveEdge(G,x,y):若无向边(x,y)或有向边<x,y>存在,则从图G中删除该边。

.FirstNeighbor(G,x):求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1。

.NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。

.Get_edge_value(G,x,y):获取图G中边(x,y)或<x,y>对应的权值。

.Set_edge_value(G,x,y,v):设置图G中边(x,y)或<x,y对应的权值为v。

6.3图的遍历

6.3.1图的广度优先遍历(BFS)

类似于树的层序遍历(广度优先遍历)

1,算法要点

1.找到与一个顶点相邻的所有顶点
2.标记哪些顶点被访问过
3.需要一个辅助队列

补充:

同一个图的邻接矩阵表示方式一,因此广度优先遍历序列唯一
同一个图邻接表表示方式不峰一,因此广度优先遍历序列不维一

2,复杂度
1,空间复杂度

空间复杂度:最坏情况,辅助队列大小为O(V)

2,时间复杂度
  • 邻接矩阵存储的图:

访问M|个顶点需要O(|V|)的时间
查找每个顶点的邻接点都需要O(V)的时间,而总共有|M个顶点
时间复杂度=O(|V|^2)

  • 邻接表存储的图:

访问M个顶点需要O(|V|)的时间
查找各个顶点的邻接点共需要O(|E|)的时间,
时间复杂度=O(|V|+|E|)

3,广度优先生成树

由广度优先遍历确定的树。
邻接表存储的图表示方式不唯一,遍历序列、生成树也不唯一。
遍历非连通图可得广度优先生成森林。

6.3.2图的深度优先遍历
1,算法要点

递归地深入探索未被访问过的邻接点(类似于树的先根遍历的实现)
如何从一个结点找到与之邻接的其他顶点
visitod 数组防止重复访问
如何处理非连通图

2,复杂度分析
1,空间复杂度

空间复杂度:来自函数调用栈,最坏情况,递归深度为O(|V|)

空间复杂度:最好情况,O(1)

2,时间复杂度

时间复杂度=访问各结点所需时间+探索各条边所需时间

  • 邻接矩阵存储的图:

访问M个顶点需要O(|V|)的时间
查找每个顶点的邻接点都需要O(V)的时间,而总共有V|个顶点
时间复杂度=O(|V|^2)

  • 邻接表存储的图:

访问V个顶点需要O(|V|)的时间
查找各个顶点的邻接点共需要O(|E|)的时间,
时间复杂度=O(V|+|E|)

3,深度优先生成树

同一个图的邻接矩阵表示方式唯一,因此深度优先遍历序列唯一
同一个图邻接表表示方式不唯一,因此深度优先遍历序列不唯一

由深度优先遍历确定的树。
邻接表存储的图表示方式不唯一,深度优先遍历序列、生成树也不唯一。
深度优先遍历非连通图可得深度优先生成森林。

4,图的遍历和图的连通性
无向图

DFS/BFS函数调用次数=连通分量数

有向图

若从起始顶点到其他顶点都有路径,则只需调用1次DFS/BFS函数

对于强连通图,从任一顶点出发都只需调用1次DFS/BFS函数

6.4图的应用(上)

6.4.1最小生成树
1,最小生成树的概念

对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则T称为G的最小生成树(Minimum-Spanning-Tree,MST)。

  • 最小生成树可能有多个,但边的权值之和总是唯一且最小的

  • 最小生成树的边数=顶点数-1。砍掉一条则不连通,增加一条边则会出现回路

  • 如果一个连通图本身就是一棵树,则其最小生成树就是它本身

  • 只有连通图才有生成树,非连通图只有生成森林

2,Prim算法(普里姆算法)

从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。

时间复杂度:O(|V|^2)
适合用于边稠密图

3,Kruskal算法(克鲁斯卡尔算法)

每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选)。
直到所有结点都连通。

时间复杂度:O(|E|log2lE|)
适合用于边稀疏图

6.4.2最短路径问题——BFS算法(单源最短路径,无权图)

就是对BFS的小修改,在vislt一个顶点时,修改其最短路径长度 d[]并在 path[]记录前驱结点。

其广度优先生成树的高度就代表了最短路径。

6.4.3最短路径问题——Dijkstra算法(单源最短路径,带权图,无权图)
1,辅助数组

dist[]:记录从源点v0到其他各顶点当前的最短路径长度,它的初态为:若从v0到vi有弧则dist[]为弧上的权值;否则置dist为无穷大
pathl:path门表示从源点到顶点i之间的最短路径的前驱结点

2,实现过程

1)初始化:集合S初始为{0),dist]的初始值dist[i]=arcs[O][i],i=1,2.….n-1
2)从顶点集合V-S中选出Vj,满足dist[j]=Min(dist[i]Iv.eV-S}vj就是当前求得的一条从vO出发的最短路径的终点。
3)修改从Vo出发到集合V-S上任一顶点vk可达的最短路径长度:若dist[j]+arcsfj][k]<dist[k],则更新dist[k]=dist[j]+arcsJj][k]
4)重复2)~3)操作共n-1次,直到所有的顶点都包含在S中

3,时间复杂度

时间复杂度O(|V|^2)

4,不适用于权值存在负数的情况
6.4.4最短路径问题——Floyd算法(各顶点间的最短路径,带权图,无权图)
1,实现过程

初始时,对于任意两个顶点vi和可j,若它们之间存在边,则以此边上的权值作为它们之间的最短路径长度
若它们之间不存在有向边,则以无穷大作为它们之间的最短路径长度
以后逐步尝试在原路径中加入顶点k(k=0,1,2…n-1)作为中间顶点
若增加中间顶点后,得到的路径比原来的路径长度减少了,则以此新路径代替原路径

2,时间复杂度O(|V|^3)
3,允许图中有带负权值的边,但不允许有包含带负权值的边组成的回路
4,适用于带权无向图
6.4.5有向无环图描述表达式

有向无环图:若一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclc Graph)

Step1:把各个操作数不重复地排成一排
Step 2:标出各个运算符的生效顾序(先后顺序有点出入无所谓)
Step 3:按顺序加入运算符,注意“分层”

Step4:从底向上逐层检查同层的运算符是否可以合体

6.4.6拓扑排序
1,AOV网

AOV网(Activity on Vertex NetMork.用顶点表示活动的网):
用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边<V.V>表示活动V必须先于活动V,进行

2,拓扑排序

拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
①每个顶点出现且只出现一次。
②若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。

或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列。

拓扑排序的实现:

①从AOV网中选择一个没有前驱(入度为0)的顶点并输出。
②从网中删除该顶点和所有以它为起点的有向边。
③重复①和②直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。

3,逆拓扑排序

对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
①从AOV网中选择一个没有后继(出度为0)的顶点并输出。
②从网中删除该顶点和所有以它为终点的有向边。
③重复①和2直到当前的AOV网为空。

4,另一种方式实现

用DFS实现拓扑排序/逆拓扑排序

5,性质

拓扑排序、逆拓扑排序序列可能不唯一
若图中有环,则不存在拓扑排序序列/逆拓扑排序序列

6.4.7关键路径
1,AOE网

在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如
完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)。

AOE网具有以下两个性质:

①只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
②只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
另外,有些活动是可以并行进行的

相关概念:

在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;
也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。

从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。

完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长

活动a的最早开始时间e(l)一一指该活动弧的起点所表示的事件的最早发生时间
活动a,的最迟开始时间4(i)一-它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
活动a的时间余量d(i)=l(i)-e(i)),表示在不增加完成整个工程所需总时间的情况下,活动a可以拖延的时间
若一个活动的时间余量为零,则说明该活动必须要如期完成,d(()=0即/(i)=c())的活动a是关键活动
由关键活动组成的路径就是关键路径

2,求解方法

①求所有事件的最早发生时间ve()

②求所有事件的最迟发生时间vl()

③求所有活动的最早发生时间e()

④求所有活动的最迟发生时间I()

⑤求所有活动的时间余量d()

d(k)=0的活动就是关键活动,由关键活动可得关键路径

3,关键活动、关键路径的特性

若关键活动耗时增加,则整个工程的工期将增长
缩短关键活动的时间,可以缩短整个工程的工期
当缩短到一定程度时,关键活动可能会变成非关键活动
可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括
在所有关键路径上的关键活动才能达到缩短工期的目的。

第七章查找

7.1查找的基本概念

1,基本概念
1,查找

在数据集合中寻找满足某种条件的数据元素的过程称为查找

2,查找表(查找结构)

用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成

1,静态查找表

只需要查找操作

2,动态查找表

除了查找,还需要增/删数据操作

3,关键字

数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的.

2,查找算法的效率评价

查找长度——在查找运算中,需要对比关键字的次数称为查找长度
平均查找长度(ASL,Average Search Length)——所有查找过程中进行关键字的比较次数的平均值

评价一个查找算法的效率时,通常考虑查找成功/查找失败两种情况的ASL。

7.2顺序查找和折半查找

7.2.1顺序查找
1,算法实现

从头到jio(或者从jio到头)挨个找
适用于顺序表、链表,表中元素有序无序都可以
可在0号位置存“哨兵”,从尾部向头部挨个查找
优点:循环时无需判断下标是否越界

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);
    return i == ST.Tablelen? -1 : i;
}
2,优化
1,若表中元素有序

当前关键字大于(或小于)目标关键字时,查找失败
优点:查找失败时ASL更少

查找判定树:

成功结点的关键字对比次数=结点所在层数
失败结点的关键字对比次数=其父节点所在层数

2,若各个关键字被查概率不同

可按被查概率降序排列
优点:查找成功时ASL更少

3,时间复杂度

查找成功和查找失败的时间复杂度都是O(n)

7.2.2折半查找
1,适用范围

折半查找,又称“二分查找”,仅适用于有序的顺序表。

2,算法思想

在[low,high]之间找目标关键字,每次检查mid=(low+high)/2
根据mid所指元素与目标关键字的大小调整low或high,不断缩小low和high的范围
若low>high 则查找失败

typedef struct{
    Elemtype *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;
    }
}
3,判定树
1,构造

由mid所指元素将原有元素分割到左右子树中
Key:右子树结点数-左子树结点树=0或1

2,特性

折半查找的判定树是平衡的二叉排序树(左<中<右)
折半查找判定树,只有最下面一层是不满的
若查找表有n个关键字,则失败结点有n+1个

树高h=「log2(n+1)]
(不包含失败结点)

4,时间复杂度

o(log2n)

7.2.3分块查找

又称“索引顺序查找”,数据分块存储,块内无序、块间有序

1,算法思想

索引表中记录每个分块的最大关键字、分块的区间
分块查找,又称索引顺序查找,算法过程如下:
①在索引表中确定待查记录所属的分块(可顺序、可折半)
②在块内顺序查找

2,ASL

ASL=查索引表的平均查找长度+查分块的平均查找长度

顺序查找索引表

折半查找索引表

3,易错点

对索引表进行折半查找时,若索引表中不包含目标关键字,则折半查找最终停在low>high,要在low所指分块中查找

7.3树形查找

7.3.1二叉排序树(BST)
1,二叉排序树的定义

二叉排序树,又称二叉查找树(BST,Binary Search Tree)

一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:

左子树上所有结点的关键字均小于根结点的关键字;

右子树上所有结点的关键字均大于根结点的关键字。

左子树和右子树又各是一棵二叉排序树。

对二叉排序树进行中序遍历,可以得到一个递增的有序序列

2,查找操作

从根节点开始,目标值更小往左找,目标值更大往右找

//二叉排序树节点
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;
}
3,插入操作

找到应该插入的位置(一定是叶子结点),一定要注意修改其父节点指针。

若原二叉排序树为空,则直接插入结点;

否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树。

//在二叉排序树插入关键字为K的新结点
int BST_Insert(BSTree &T, int k)
{
    if(T == NULL)//原树为空,新插入节点为根节点
    {
        T = (BSTree)malloc(sizeof(BSNode));
        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);
}
4,删除操作

先搜索找到目标结点:

①若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。

②若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置。

③若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。

z的后继:z的右子树中最左下结点(该节点一定没有左子树)

z的前驱:z的左子树中最右下结点(该节点一定没有右子树)

5,查找效率分析

取决于树的高度,最好O(log n),最坏O(n)

平均查找长度的计算

查找成功的情况

查找失败的情况(需补充失败结点)

7.3.2平衡二叉树(AVL)
1,定义

平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)——树上任一结点的左子树和右子树的高度之差不超过1。

结点的平衡因子=左子树高-右子树高。

2,插入操作

和二叉排序树一样,找合适的位置插入新插入的结点可能导致其祖先们平衡因子改变,导致失衡

3,调整不平衡

找到最小不平衡子树进行调整,记最小不平衡子树的根为A

在A的左孩子的左子树中插入导致不平衡(LL)

1)LL平衡旋转(右单旋转)。

由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。

在A的右孩子的右子树中插入导致不平衡(RR)

2)RR平衡旋转(左单旋转)。

由于在结点A的右孩子(R)的右子树(R)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。

在A的左孩子的右子树中插入导致不平衡(LR)

3)LR平衡旋转(先左后右双旋转)。

由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置。

在A的右孩子的左子树中插入导致不平衡(RL)

4)RL平衡旋转(先右后左双旋转)。

由于在A的右孩子(R)的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置。

补充做题技巧!!!

只有左孩子才能右上旋,只有右孩子才能左上旋

  • LL

在A的左孩子的左子树中插入导致不平衡

调整:A的左孩子结点右上旋

  • RR

在A的右孩子的右子树中插入导致不平衡

调整:A的右孩子结点左上旋

  • LR

在A的左孩子的右子树中插入导致不平衡

调整:A的左孩子的右孩子先左上旋再右上旋

  • RL

在A的右孩子的左子树中插入导致不平衡

调整:A的右孩子的左孩子先右上旋后左上旋

4,查找效率分析

若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过o(h)

平衡二叉树—一树上任一结点的左子树和右子树的高度之差不超过1。

假设以n,表示深度为h的平衡树中含有的最少结点数。

则有no=0,n1=1,n2=2,并且有nh=nh-1+nh-2+1。

可以证明含有n个结点的平衡二叉树的最大深度为O(log2n),平衡二叉树的平均查找长度为O(log2n)。

7.3.3平衡二叉树(删除操作)

①删除结点(方法同“二叉排序树”)

·若删除的结点是叶子,直接删。
·若删除的结点只有一个子树,用子树顶替删除位置
·若删除的结点有两棵子树,用前驱(或后继)结点顶替,并转换为对前驱(或后继)结点的删除。

②一路向北找到最小不平衡子树,找不到就完结撒花

③找最小不平衡子树下,“个头”最高的儿子、孙子

④根据孙子的位置,调整平衡(LL/RR/LR/RL)

·孙子在LL:儿子右单旋
·孙子在RR:儿子左单旋
·孙子在LR:孙子先左旋,再右旋
·孙子在RL:孙子先右旋,再左旋

⑤如果不平衡向上传导,继续②

对最小不平衡子树的旋转可能导致树变矮,从而导致上层祖先不平衡(不平衡向上传递)。

7.3.4红黑树的定义和性质
1,背景

平衡二叉树AVL:插入/删除很容易破坏“平衡”特性,需要频繁调整树的形态。如:插入操作导致不平衡,则需要先计算平衡因子,找到最小不平衡子树(时间开销大),再进行LL/RR/LR/RL调整

红黑树RBT:插入/删除很多时候不会破坏“红黑”特性,无需频繁调整树的形态。即便需要调整,一般都可以在常数级时间内完成

平衡二叉树:适用于以查为主、很少插入/删除的场景

红黑树:适用于频繁插入、删除的场景,实用性更强

2,定义

左子树结点值≤根结点值≤右子树结点值

①每个结点或是红色,或是黑色的
②根节点是黑色的
③叶结点(外部结点、NULL结点、失败结点)均是黑色的
④不存在两个相邻的红结点(即红结点的父节点和孩子结点均是黑色)
⑤对每个结点,从该节点到任一叶结点(不是叶子节点,而是失败节点)的简单路径上,所含黑结点的数目相同

左根右——RBT是一种BST,需满足左s根s右
根叶黑——根节点、叶结点一定是黑色
不红红——任何一条查找路径上不能连续出现两个红结点
黑路同——从任一结点出发,到达任一空叶结点的路径上经过的黑结点数量都相同

3,性质

性质1:从根节点到叶结点的最长路径不大于最短路径的2倍
性质2:有n个内部节点的红黑树高度h≤2logz(n+1)

红黑树查找操作时间复杂度=0(log2n)

4,查找

与BST、AVL相同,从根出发,左小右大,若查找到一个空叶节点,则查找失败

7.3.5红黑树的插入

先查找,确定插入位置(原理同二叉排序树),插入新结点
新结点是根一一染为黑色
新结点非根一—染为红色

若插入新结点后依然满足红黑树定义,则插入结束
若插入新结点后不满足红黑树定义,需要调整,使其重新满足红黑树定义

黑叔:旋转+染色

·LL型:右单旋,父换爷+染色
·RR型:左单旋,父换爷+染色
·LR型:左、右双旋,儿换爷+染色
·RL型:右、左双旋,儿换爷+染色

红叔:染色+变新

·叔父爷染色,爷变为新结点

7.3.6红黑树的删除

①红黑树删除操作的时间复杂度=O(log2n)
②在红黑树中删除结点的处理方式和“二叉排序树的删除”一样
③按②删除结点后,可能破坏“红黑树特性”,此时需要调整结点颜色、位置,使其再次满足“红黑树特性”。

7.4B树和B+树

7.4.1B树

B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树或为空树,或为满足如下特性的m叉树:
1)树中每个结点至多有m棵子树,即至多含有m-1个关键字。
2)若根结点不是终端结点,则至少有两棵子树。
3)除根结点外的所有非叶结点至少有[m/2]棵子树,即至少含有[m/2]-1个关键字。
5)所有的叶结点(失败节点)都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。

m阶B树的核心特性:
1)根节点的子树数E[2,m],关键字数E[1,m-1]。
其他结点的子树数E[[m/21,m];关键字数E[[m/21-1,m-1]
2)对任一结点,其所有子树高度都相同
3)关键字的值:子树0<关键字1<子树1<关键字2<子树2<…(类比二叉查找树左<中<右)

7.4.2B树的插入删除
1,插入

核心要求:
①对m阶B树——除根节点外,结点关键字个数[m/21-1≤n≤m-1
②子树0<关键字1<子树1<关键字2<子树2<……
新元素一定是插入到最底层“终端节点”,用“查找”来确定插入位置
在插入key后,若导致原结点关键字数超过上限,则从中间位置([m/2])将其中的关键字分为两部分,左部分包
含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置([m/2])的结点插入原结点的父结点。
若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进
而导致B树高度增1。

2,删除
1,非终端节点关键字

用其直接前驱或直接后继替代其位置,转化为对“终端结点”的删除
直接前驱:当前关键字左边指针所指子树中“最右下”的元素
直接后继:当前关键字右边指针所指子树中“最左下”的元素

2,终端节点关键字

1,删除后结点关键字个数未低于下限,无需任何处理

2,低于下限

右兄弟够借,则用当前结点的后继、后继的后继依次顶替空缺
左兄弟够借,则用当前结点的前驱、前驱的前驱依次顶替空缺
左(右)兄弟都不够借,则需要与父结点内的关键字、左(右)兄弟进行合并。
合并后导致父节点关键字数量-1,可能需要继续合并。

7.4.3B+树
1,定义

一棵m阶的B+树需满足下列条件:
1)每个分支结点最多有m棵子树(孩子结点)。

2)非叶根结点至少有两棵子树,其他每个分支结点至少有[m/2]棵子树。

3)结点的子树个数与关键字个数相等。

4)所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。

5)所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。

2,比较

m阶B+树:
1)结点中的n个关键字对应n棵子树

m阶B树:
1)结点中的n个关键字对应n+1棵子树

m阶B+树:
2)根节点的关键字数ne[1,m]
其他结点的关键字数ne[[m/2],m]

m阶B树:
2)根节点的关键字数nE[1,m-1]。
其他结点的关键字数nE[[m/2]-1,m-1]

m阶B+树:
3)在B+树中,叶结点包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中

m阶B树:
3)在B树中,各结点中包含的关键字是不重复的

3,总结对比
m阶B树m阶B+树
类比二叉查找树的进化——>m叉查找树分块查找的进化——>多级分块查找
关键字与分叉n个关键字对应n+1个分叉(子树)n个关键字对应n个分叉
节点包含的信息所有节点中都包含记录的信息只有最下层叶子节点才包含记录的信息(可使树更矮)
查找方式不支持顺序查找。查找成功时,可能停留在任何一层节点,查找速度”不稳定“支持顺序查找。查找成功或失败都会到达最下一层节点,查找速度“稳定”

相同:

除根节点外,最少[m/2]个分叉(确保节点不要太空)

任何一个节点的子树都要一样高(确保“绝对平衡“)

7.5散列查找

1,概念

散列表(HashTable),又称哈希表。是一种数据结构,特点是:数据元素的关键字与其存储地址直接相关。

若不同的关键字通过散列函数映射到同一个值,则称它们为“同义词”
通过散列函数确定的位置已经存放了其他元素,则称这种情况为“冲突”

散列查找是典型的“用空间换时间”的算法,只要散列函数设计的合理,则散列表越长,冲突的概率越低。

2,常见散列函数
1,除留余数法

除留余数法—H(key)=key%p
散列表表长为m,取一个不大于m但最接近或等于m的质数p

质数又称素数。指除了1和此整数自身外,不能被其他自然数整除的数

用质数取模,分布更均匀,冲突更少。

2,直接定址法

直接定址法——H(key)=key 或H(key)=a*key + b
其中,a和b是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。

3,数字分析法

数字分析法——选取数码分布较为均匀的若干位作为散列地址
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。

4,平方取中法

平方取中法——取关键字的平方值的中间几位作为散列地址。
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。

3,冲突处理
1,拉链法(链地址法)

用拉链法(又称链接法、链地址法)处理“冲突”:把所有“同义词”存储在一个链表中

2,开放定址法

所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开
放。其数学递推公式为:

Hi=(H(key)+di)% m
i=0.1.2…k(k≤m-l),m表示散列表表长;di为增量序列;i可理解为“第i次发生冲突”

①线性探测法–di=0,1,2,3,…,m-1;即发生冲突时,每次往后探测相邻的下一个单元是否为空。

②平方探测法。当di=02,12,-12,22,-22,…,k2,(-k)^2时,称为平方探测法,又称二次探测法其中k<=m/2

③伪随机序列法。di是一个伪随机序列,如d=0,5,24,11,…

3,再散列法

再散列法(再哈希法):除了原始的散列函数H(key)之外,多准备几个散列函数,当散列函数冲突时,用下一个散列函数计算一个新地址,直到不冲突为止:

4,查找效率

取决于散列函数、处理冲突的方法、装填因子a。

第八章排序

8.1排序的基本概念

1,定义

排序(sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。

2,评价指标

稳定性:关键字相同的元素经过排序后相对顺序是否会改变

时间复杂度、空间复杂度

3,分类
1,内部排序

数据都在内存中

2,外部排序

数据太多,无法全部放入内存

8.2插入排序

8.2.1插入排序
1,算法思想

算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。

2,直接插入排序

顺序查找到插入的位置,适用于顺序表,链表

//直接插入排序
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;
        }
    }
}

空间复杂度O(1)

最好时间复杂度(全部有序):O(n)
最坏时间复杂度(全部逆序):O(n^2)
平均时间复杂度:O(n^2)

稳定性:稳定

3,折半插入排序

折半查找找到应插入的位置,仅适用于顺序表

//折半插入排序
void InsertSort(int A[], int n)
{
    int i, j, low, high, mid;
    for(i = 2; i <= n; i++)//依次将A[2]~A[n]插入到前面已经排序的序列
    {
        A[0] = A[i];	//将A[i]暂存到A[0]
        low = 1, high = i - 1;//设置折半查找的范围
        while(low <= high)//折半查找默认递增有序
        {
            mid = (low + high) / 2;//取中间点
            if(A[mid] > A[0])
                high = mid -1;//查找左半子表
            else
                lwo = mid + 1//查找右半子表
        }
        for(j = i - 1; j >= high + 1; --j)
            A[j + 1] = A[j];//统一后移元素,空出插入位置
        A[high + 1] = A[0];//插入操作
    }
}

注意:一直到low>high时才停止折半查找。当mid所指元素等于当前元素时,应继续令low=mid+1. 以保证“稳定性” 最终应将当前元素插入到low所指位置(即high+1)

移动元素的次数变少了,但是关键字对比的次数依然是O(n2)数量级,整体来看时间复杂度依然是O(n2)

4,性能
1,空间复杂度

O(1)

2,时间复杂度

最好:原本有序O(n)
最坏:原本逆序O(n^2)
平均:O(n^2)

3,稳定性

稳定

8.2.2希尔排序
1,算法思想

希尔排序:先将待排序表分割成若干形如L[i.i+d.i+2d…….i+kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复上述过程,直到d=1为止。

//希尔排序
void ShellSort(int A[], int n)
{
    int d, i, j;
    //A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
    for(d = n/2; d >= 1; d = d/2)//步长变化
    {
        for(i = d + 1; i <= n; ++i)
        {
            if(A[i] < A[i - d])//需要将A[i]插入有序增量子表
            {
                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];//插入
            }
        }
    }
}
2,性能

空间复杂度:O(1)
时间复杂度:未知,但优于直接插入排序
稳定性:不稳定
适用性:仅可用于顺序表

3,高频题型

给出增量序列,分析每一趟排序后的状态

8.3交换排序

8.3.1冒泡排序
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 == flase)
        {
            return;//本趟遍历后没有发送交换,说明已经有序,提前结束算法
        }
    }
}
2,性能
空间复杂度:O(1)
时间复杂度:

最好O(n),有序

最差O(n^2),逆序
平均O(n^2)

稳定性:稳定

适用性:顺序表、链表都可以

8.3.2快速排序
1,算法思想

算法思想:在待排序表L[1…n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1.k-1]和L[k+1.n],使得L[1…k-1]中的所有元素小于pivot,L[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

//用第一个元素将待排序序列划分成左右两个部分
int Partition(int A[], int low, int high)
{
    int pivot = A[low];//第一个元素作为枢轴
    while(low < high)//用low、high搜索枢轴的最终位置
    {
        while(low < high && A[high] >= pivot){
			--high;            
        }
        A[low] = A[high];//比枢轴小的元素移动到左端
        while(low < high && A[low] <= pivot)
        {
            ++low;
        }
        A[high] = A[low];//比枢轴大的元素移动到右端
    }
    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, pivot + 1, high);//划分右子表
    }
}
2,性能

算法表现主要取决于递归深度,若每次“划分”越均匀,则递归深度越低。“划分”越不均匀,递归深度越深。

空间复杂度

最好:O(n)
最坏:O(log n)

时间复杂度

最好:O(nlog2n),每次划分很平均

最坏:O(n^2),原本正序或逆序

平均:O(nlog2n)

稳定性

不稳定

8.4选择排序

选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列

8.4.1简单选择排序
1,算法原理

每一趟在待排序元素中选取关键字最小的元素加入有序子序列

n个元素的简单选择排序需要n-1趟处理

//简单选择排序
void SelectSort(int A[], int n)
{
    for(int i = 0; i < n - 1; i++)//一共进行n-1趟
    {
        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]);
    }
}

//交换
void swap(int &a, int &b)
{
    int temp = a;
    a = b;
    b = temp;
}
2,性能

空间复杂度:0(1)
时间复杂度:O(n^2)
稳定性:不稳定
适用性:顺序表、链表都可以

8.4.2堆排序
1,堆

顺序存储的“完全二叉树”

结点i的左孩子是2i;右孩子是2i+1;父节点是i/2
编号≤n/2的结点都是分支结点

大根堆(根≥左、右);小根堆(根≤左、右)

2,算法思想
1,建堆

编号≤n/2的所有结点依次“下坠”调整(自底向上处理各分支节点)
调整规则:小元素逐层“下坠”(与关键字更大的孩子交换)

//建立大根堆
void BuildMaxHeap(int A[], int len)
{
    for(int i = len/2; i > 0; i--)//从后往前调整所有非终端节点
    {
        HeadAdjusr(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)//沿key较大的子节点向下筛选
    {
        if(i < len && A[i] < A[i + 1])
            i++;//取key较大的子节点的下标
        if(A[0] >= A[i])
            break;//筛选结果
        else
        {
            A[k] = A[i];//将A[i]调整到双亲节点上
            k = i;//修改k值,以便继续向下筛选
        }
    }
    A[k] = A[0];//被筛选节点的值放入最终位置
}
2,排序

堆顶元素加入有序子序列(堆顶元素与堆底元素交换)
准底元素换到堆顶后,需要进行“下坠”调整,恢复“大根堆”的特性
上述过程重复n-1趟

3,特性

空间复杂度:O(1)
时间复杂度:建堆O(n)、排序O(nlogn);总的时间复杂度=O(nlog n)
稳定性:不稳定

基于大根堆的堆排序得到“递增序列”,基于小根堆的堆排序得到“递减序列”

8.4.3堆的插入和删除
1,插入

新元素放到表尾(堆底)
根据大/小根堆的要求,新元素不断“上升”,直到无法继续上升为止

2,删除

被删除元素用表尾(堆底)元素替代
根据大/小根堆的要求,替代元素不断“下坠”,直到无法继续下坠为止

3,关键字对比次数

每次“上升”调整只需对比关键字1次
每次“下坠”调整可能需要对比关键字2次,也可能只需对比1次

4,基本操作

i的左孩子–2i
i的右孩子–2i+1
i的父节点–[i/2]

8.5归并排序和基数排序

8.5.1归并排序
1,定义

把两个或多个有序的子序列合并为一个
2路归并——二合一
k路归并——k合一

m路归并选择一个元素需要比较m-1次

2,算法

①若low<high,则将序列分从中间mid=(low+high)/2分开
②对左半部分[low,mid]递归地进行归并排序
③对右半部分[mid+1,high]递归地进行归并排序
④将左右两个有序子序列Merge为一个

3,性能

空间复杂度:O(n)
时间复杂度:O(nlog n)
稳定性:稳定的

8.5.2基数排序
1,算法思想

将整个关键字拆分为d位(或“组”)
按照各个关键字位权重递增的次序(如:个、十、百),做d趟“分配”和“收集”,若当前处理的关键字位可能取得r个值,则需要建立r个队列

分配:顺序扫描各个元素,根据当前处理的关键字位,将元素插入相应队列。一趟分配耗时O(n)
收集:把各个队列中的结点依次出队并链接。一趟收集耗时O(r)

2,性能

空间复杂度:O®
时间复杂度:O(d(n+r))
稳定性:稳

3,擅长处理

①数据元素的关键字可以方便地拆分为d组,且d较小
②每组关键字的取值范围不大,即r较小
③数据元素个数n较大

8.7外部排序

8.7.1外部排序的基本概念
1,外存与内存之间的数据交互

操作系统以“块”为单位对磁盘存储空间进行管理,如:每块大小1KB。各个磁盘块内存放着各种各样的数据

磁盘的读/写以“块”为单位
数据读入内存后才能被修改
修改完了还要写回磁盘

2,外部排序的原理

外部排序:数据元素太多,无法一次全部读入内存进行排序

使用“归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大文件进行排序

3,如何进行K路归并

把k个归并段的块读入k个输入缓冲区
用“归并排序”的方法从k个归并段中选出几个最小记录暂存到输出缓冲区中
当输出缓冲区满时,写出外存

4,外部排序时间开销

读写外存的时间+内部排序所需时间+内部归并所需时间

5,优化
增加归并路数K,进行多路平衡归并:

代价1:需要增加相应的输入缓冲区

代价2:每次从k个归并段中选一个最小元素需要(k-1)次关键字对比

减少初始归并段数量r
8.7.2败者树

败者树解决的问题:使用多路平衡归并可减少归并趟数,但是用老土方法从k个归并段选出一个最小/最大元素需要对比关键字k-1次,构造败者树可以使关键字对比次数减少到[log2k]。

败者树可视为一棵完全二叉树(多了一个头头)。k个叶结点分别对应k个归并段中当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。

8.7.3置换——选择排序

设初始待排文件为F,初始归并段输出文件为FO,内存工作区为WA,FO和WA的初始状态为空,WA可容纳w个记录。置换-选择算法的步骤如下:
1)从F输入w个记录到工作区WA。
2)从WA中选出其中关键字取最小值的记录,记为MINIMAX记录。
3)将MINIMAX记录输出到FO中去。
4)若F不空,则从F输入下一个记录到WA中。
5)从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的
MINIMAX记录。
6)重复3)~5),直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输
出一个归并段的结束标志到FO中去。
7)重复2)~6),直至WA为空。由此得到全部初始归并段。

8.7.4最佳归并树
1,理论基础

每个初始归并段对应一个叶子结点,把归并段的块数作为叶子的权值
归并树的WPL=树中所有叶结点的带权路径长度之和
归并过程中的磁盘l/O次数=归并树的WPL*2

2,注意:

k叉归并的最佳归并树一定是严格k叉树,即树中只有度为k、度为0的结点

3,如何构造
1,补充虚段

①若(初始归并段数量-1)%(k-1)=0,说明刚好可以构成严格k叉树,此时不需要添加虚段
②若(初始归并段数量-1)%(k-1)=u≠0,则需要补充(k-1)-u个虚段

2,构造k叉哈夫曼树

每次选择k个根节点权值最小的树合并,并将k个根节点的权值之和作为新的根节点的权值

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值