数据结构

这是我关于数据结构课程的笔记。
1 绪论
1.1 基本概念和术语
1.1.1 数据
数据(data):对客观事物的符号表示,在计算机科学中指所有能输入到计算机中并被计算机程序处理的符号的总称。
数据元素(data element):数据的基本单位。在计算机中通常作为一个整体进行考虑喝处理。
数据项(data item):是组成数据元素、有独立含义的、不可分割的最小单位。
数据对象(data object):性质相同的数据元素的集合,是数据的一个子集。
1.1.2 数据结构
数据结构(data structure):相互之间存在一种或多种特定关系的数据元素的集合。
结构(structure):数据元素相互之间的关系。
逻辑结构
数据的逻辑结构是从逻辑关系上描述数据,它与数据的存储无关,是独立于计算机的。
因此,数据的逻辑结构 可以看作是 从具体问题抽象出来的数学模型。
四类基本结构:
- 集合 set:数据元素之间就是“属于同一个集合”
- 线性结构 list:数据元素之间存在着一对一的线性关系
- 树形结构 tree:数据元素之间存在者一对多的层次关系
- 图状结构 或 网状结构 graph:数据元素之间存在着多对多的任意关系
存储结构
存储结构:数据结构在计算机中的表示(又称映像),数据的物理结构。包括数据元素的表示和关系的表示。
在计算机中,表示信息的最小单位是二进制数的一位,叫做 位(bit)。计算机中,用一个由若干位组合起来形成的一个位串表示一个数据元素。通常称这个位串为元素(element)或结点(node)。当数据元素由若干个数据项组成时,位串中对应于各个数据项的子位串称为数据域(data field)。
元素 或 结点 可以看成是数据元素在计算机中的映像。
数据元素之间的关系在计算机中有两种不同的表示方法:顺序映像 和 非顺序映像,并由此得到两种不同的存储结构:顺序存储结构 和 链式存储结构。(其实还有两种存储方式:索引存储(索引项:关键词、地址)、散列存储(结点地址 = F(关键字) ))
顺序映像 的特点是:借助元素在存储器中的相对关系来表示数据元素之间的逻辑关系
非顺序映像 的特点是:借助指示元素存储地址的 指针(pointer)来表示 数据元素之间的逻辑关系
数据存储原则:
- 不仅要存储数据本身的值,还要保存数据间的关系。
- 存储数据的目的是为了对它们进行处理。

数据的 逻辑结构 属于用户视图,是面向问题的,反映了数据内部的构成方式,数据的逻辑结构独立于计算机
数据的 物理存储结构 的实现要用计算机语言中的 数据类型 来描述,因而是依赖于具体的计算机语言而言的,是面向计算机的。
一种数据的逻辑结构可以用多种存储结构来存储,而采用不同的存储结构,其数据存储的效率往往是不同的
1.1.3 数据的运算
数据的运算有两方面的含义:运算的定义与算法的实现
运算的定义:取决于数据的逻辑结构。根据问题中的数据及数据间的关系,设计相应的数据处理方法。
常见的运算操作:
- 初始化:对存储结构设置初始值,或者申请存储空间。
- 判空:判断存储空间是否为未存放有效值的状态
- 查找:判断是否包含指定元素
- 遍历:按某种次序访问所有元素,每个元素只被访问一次
- 插入:增加指定元素
- 删除:移去指定元素
- ……
1.1.3 数据类型和抽象数据类型
- 数据类型
数据类型(data type)是 一个值的集合和定义在这个值集上的一组操作的总称。
- 抽象数据类型
抽象数据类型(Abstract Data Type,简称 ADT)是指一个数学模型以及定义在该模型上的一组操作。
抽象数据类型的定义仅取决于它的一组逻辑特性,而与其在计算机内部如何表示和实现无关。
抽象数据类型,不仅包括各处理器已经定义及实现的数据类型,还包括用户自己定义的数据类型。
抽象数据类型具体包括三部分:数据对象、数据对象上关系的集合以及对抽象对象的基本操作
抽象数据类型的定义格式:
1 | ADT 抽象数据类型名{ |
基本操作的定义格式:
1 | 基本操作名(参数表) |
1.2 算法和算法分析
1.2.1 算法
算法(algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作。
算法的 5 个重要特性:
- 有穷性:一个算法总是在执行有穷步之后结束,且每一步都可在 有穷时间 内完成。
- 确定性:算法中每一条指令必须有确切的含义,读者理解时不会产生二义性。而且,在任何条件下,算法只有唯一的一条执行路径,即对相同的输入只能得到相同的输出。
- 可行性:算法中所描述的操作都是可以通过已经实现的基本运算执行有限次来实现。
- 输入:一个算法有零个或多个输入,这些输入来自某个特定的对象的集合
- 输出:一个算法有一个或多个的输出,这些输出是同输入有着某些特定关系的量
1.2.2 算法设计的要求
- 正确性:指算法至少应该具有输入、输出和加工处理无歧义性,能正确反映问题的需求,能够得到问题的正确答案。
- 可读性:算法设计的另一个目的是为了方便阅读、理解和交流
- 健壮性:有成鲁棒性或容错性,是指算法对不合理输入的反应能力和处理能力。一个好的算法,应该能够识别出错误的输入数据并进行适当的处理和反应,而不是产生异常或莫名其妙的结果。
- 效率:具有 时间效率高 和 存储空间少 的要求。
1.2.3 算法的描述
自然语言描述 —— Pipeline Chart
- 优点:容易理解
- 限制:冗余、含糊
- 用途:粗略地描述算法的目的
流程图 —— Pipeline Chart
- 优点:步骤直观
- 限制:缺乏严谨性和灵活性
- 用途:描述简单的算法
编程语言 —— Programming Language
- 优点:电脑可以运行
- 显示:抽象性差,编程要求高
- 用途:需要调试算法
伪代码 —— Pseudo Code
介于自然语言和程序设计语言之间的方法,它采用某一程序设计语言的基本语法,操作指令可以结合自然语言来设计。
优点:表达能力强,抽象性强,容易理解
2 线性表
2.1 线性表的类型定义
线性结构的特点:
在数据元素的非空有限集中,
- 存在唯一的一个被称作“第一个”的数据元素
- 存在唯一的一个被称作“最后一个”的数据元素
- 除第一个之外,集合中的每一个数据元素均只有一个前驱
- 除最后一个之外,集合中的每一个数据元素均只有一个后继
线性表是最常用且最简单的一种数据结构。一个线性表是
1 | // 例子 |
在稍复杂的线性表中,一个数据元素可以由若干个 数据项 (item)组成。在这种情况下,常常把数据元素称为 记录(record),含有大量记录的线性表又称 文件(file)。
例如,一个学校的学生健康情况登记表如图所示,表中每个学生的情况为一个记录,它由姓名、学号、性别、年龄、班级和健康状况等 6 个数据项组成。
【注】线性表的元素可以是各种各样的,但是在同一线性表中的元素必定具有相同特性,即属同一数据对象,相邻数据元素之间存在序偶关系。
线性表是具有相同特性的数据元素的一个有限序列,例如:
有限序列的第一个元素叫做线性起点,每个元素的前面一个元素叫做 它的直接前趋,后一个元素叫做它的直接后继,序列的最后一个元素叫做线性终点。
线性表(Linear List):由
- 数据元素个数
定义为 线性表的长度 - 当
时称为空表 是第一个数据元素, 是最后一个数据元素, 是第 个数据元素,称 为数据元素 在线性表中的位序 - 将非空的线性表
记作: - 这里的数据元素
只是一个抽象的符号,其具体含义在不同的情况下可以不同。
同一线性表中的元素必定具有相同的特性,数据元素间的关系是线性关系。
线性表的逻辑特征
非空的线性表中:
- 有且仅有一个开始结点
,它没有直接前趋,而仅有一个直接后继 - 有且仅有一个终端结点
,它没有直接后继,而仅有一个直接前驱 - 其余内部结点
都有且仅有一个直接前趋 和一个直接后继 。
线性表是一个相当灵活的数据结构,它的长度可以根据需要增加或缩短,即对线性表的数据元素不仅可以进行访问,还可以进行插入和删除。
顺序存储结构存在问题:
- 存储空间分配不灵活
- 运算的空间复杂度高
抽象数据类型线性表的定义如下:
1 | ADT List{ |
例:合并两个线性表 LA 和 LB,从线性表 LB 中依次取每个数据元素,并依值在线性表 LA 中进行查访,若不存在,则插入之。
1 | void union(List& La, List Lb) |
时间复杂度:O(ListLength(LA)*ListLength(LB))
例:合并两个递增有序序列 LA 和 LB:
1 | void MergeList(List La, List Lb, List& Lc){ |
时间复杂度:O(ListLength(LA)+ListLength(LB))
2.2 线性表的顺序表示和实现
在计算机中,线性表有两种基本的存储结构:顺序存储结构 和 链式存储结构
线性表的顺序表示 指的是用 一组地址连续单元 依次存储 线性表的数据元素。
2.2.1 线性表的线性存储表示
线性表的顺序表示又称为顺序存储结构或顺序映像。
顺序存储定义:把逻辑上相邻的数据元素存储在物理相邻的存储单元中的存储结构。
依次存储,地址连续,中间没有空出存储单元。
线性表顺序存储结构占用一片连续的存储空间,知道某个元素的存储位置就可以计算其他元素的存储位置。
假设线性表的每个元素需占
个存储单元,则第 个数据元素的存储位置和第 个数据元素的存储位置之间满足关系: 。 由此所有数据元素的存储位置均可由第一个数据元素的存储位置得到:
,第一个元素的地址是基地址。 每一个数据元素的存储位置都和线性表的起始位置相差一个和数据元素在线性表中的位序成正比的常数。
线性表的顺序存储结构 是一种 随机存取的存储结构
2.2.2 线性表的实现
高级程序设计语言中的数组类型也有随机存取的特性,因此通常用数组来描述数据结构中的顺序存储结构。
顺序表的特点:以物理位置相邻表示逻辑关系。任一元素均可随机存取。(优点)
顺序表(元素):地址连续、依次存放、随意存取、类型相同。
=> 可以用高级语言的 一维数组表示顺序表示。
线性表的表长可变(删除),数组长度不可动态定义。=> 用一个变量表示顺序表的长度属性
通用代码:
1 |
|
注意:上面代码的数组是静态分配的
数组动态分配:
1 | typedef struct{ |
1 | SqList L; |
多项式的顺序存储结构类型定义
: => 线性表:
P = ((p1,e1), (p2, e2), ..., (pm, em))
1
2
3
4
5
6
7
8
9
10
typedef struct{ // 多项式非零项的定义
float p; // 系数
int e; // 指数
}Polynomial;
typedef struct{
Polynomial* elem; // 存储空间的基地址
int length;
}SqList;
2.2.3 顺序表基本操作的实现
预定义常量和类型
1 | // 函数结果状态代码 |
线性表的初始化
1
2
3
4
5
6
7
8
9Status InitList_Sq(SqList& L)
{
// 构造一个空的线性表 L
L.elem = (ElemType*)malloc(LIST_INIT_SIZE*sizeof(ElemType));
if(!L.elem) exit(OVERFLOW); // 存储分配失败
L.length = 0; // 空表长度为 0
L.listsize = LIST_INIT_SIZE; // 初始存储容量
return OK;
} // InitList_Sq元素的插入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18Status ListInsert_Sq(SqList &L, int i, ElemType e){
// 在顺序线性表 L 中 第 i 个位置之前插入新的元素 e
// i 的合法值为 1<= i <= ListLength_Sq(L)+1
if(i<1 || i>L.length+1) return ERROR;
if(L.length >= L.listsize){
newbase = (ElemType*) malloc(L.elem,
(L.listsize+LISTINCREMENT)*sizeof(ElemType));
if(!newbase) exit(OVERFLOW); // 分配地址失败
L.elem = newbase; // 新基址
L.listsize = LISTINCREMENT; // 增加存储容量
}
q = &(L.elem[i-1]); // q 为插入位置
for(p = &(L.elem[L.length-1]); p>=q; --p) *(p+1) = *p; // 插入位置及之后的元素右移
*q = e; // 插入 e
++L.length; // 表长加1
return OK;
}// ListInsert_Sq元素的删除
1
2
3
4
5
6
7
8
9
10
11
12Status ListDelete_Sq(SqList &L, int i, ElemType &e){
// 在顺序线性表L中删除第 i 个元素,并用 e 返回其值
// i 的合法值为 1<= i <= ListLength_Sq(L)
if((i<1) || (i>L.length)) return ERROR; // i 值不合法
p = &(L.elem[i-1]); // p 为被删除元素的位置
e = *p; // 被删除元素的值赋给 e
q = L.elem + L.length - 1; // 表尾元素的位置
for(++p; p<=q; ++p) // 被删除元素之后元素左移
*(p-1) = *p;
--L.length; // 表长减 1
return OK;
} // ListDelete_Sq
若表长为
找到满足
compare
关系的第一个元素1
2
3
4
5
6
7
8
9
10int LocateElem_Sq(SqList L, ElemType e,
Status(* compare)(ElemType, ElemType)){
// 在顺序线性表 L 中查找第 1 个值与 e 满足 compare() 关系的位序
// 若找到,则返回其在 L 的位序,否则返回 0
i = 1;
p = L.elem;
while(i<=L.length && !(*compare)(*p++, e)) ++i;
if(i <= L.length) return i;
else return 0;
} // LocateElem_Sq合并递增有序顺序线性表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void MergeList_Sq(SqList La, SqList Lb, SqList &Lc){
// 已知顺序线性表 La 和 Lb 的元素按值非递减排序
// 归并 La 和 Lb 得到新的顺序线性表 Lc,Lc 的元素也按值非递减排列
pa = La.elem; pb = Lb.elem;
Lc.listsize = Lc.length = La.length + Lb.length;
pc = Lc.elem = (ElemType*)malloc(Lc.listsize*sizeof(ElemType));
if(!Lc.elem) exit(OVERFLOW); // 存储分配失败
pa_last = La.elem + La.length - 1;
pb_last = Lb.elem + Lb.length - 1;
while(pa <= pa_last && pb <= pb_last){
if(*pa <= *pb) *pc++ = *pa++;
else *pc++ = *pb++;
}
while(pa <= pa.last) *pc++ = *pa++; // 插入 La 的剩余元素
while(pb <= pb.last) *pc++ = *pb++; // 插入 Lb 的剩余元素
} // MergeList_Sq
2.3 顺序表的链式表示和实现
链式存储结构,不要求逻辑上相邻的元素在物理位置也相邻,因此它没有顺序存储结构所具有的弱点(在插入或删除操作时,需移动大量元素),同时也失去了顺序表 随机存取 的优点。
2.3.1 线性链表
线性表的链式存储结构
- 特点:用一组 任意的存储单元 存储线性表的数据元素(这组存储单元可以是连续的,也可以是不连续的)
- 对于数据元素
来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(即直接后继的存储位置)。这两部分信息组成数据元素 的 存储映像,称为 结点 (node)。它包括两个域: - 数据域:存储数据元素信息
- 指针域:存储直接后继存储位置,指针域存储的信息称做 指针 或 链。
个结点( 的存储映像)链结成一个链表,即线性表 的链式存储结构。 - 此链表的每个结点只包含一个指针域,故又称 线性链表 或 单链表
整个链表必须从 头指针
开始进行,头指针指示链表中第一个结点(即第一个数据元素的存储映像)的存储位置。同时,由于最后一个数据元素没有直接后继,则线性链表中最后一个结点的指针为
“空” (
- 用线性链表表示线性表时,数据元素之间的逻辑关系是由结点中的指针指示的。换句话说,指针为数据元素之间的逻辑关系的映像。
- 这种存储结构称为 非顺序映像 或 链式映像
2.3.2 线性链表的实现
单链表的描述:
1 | typedef struct LNode{ |
设 L 是 LinkList 型变量,则 L 为单链表的头指针,它指向表中第一个结点。若 L 为 “空” (L = NULL) ,则所表示的线性表为 “空” 表,其长度
为 “零” 。 有时,在单链表的第一个结点之前附设一个结点,称之为头结点,头结点的数据域可以不存储任何信息,也可以存储诸如 线性表的长度等类的附加信息。头结点的指针域指向第一个结点的指针(即第一个元素结点的存储位置)。
若线性表为空表,则头结点的指针域为 “空”
单链表是非随机存取的存储结构。
2.3.3 线性链表基本操作的实现
获取链表中的元素
1
2
3
4
5
6
7
8
9
10
11
12
13Status GetElem_L(LinkList L, int i, ElemType & e){
// L 为带头结点的单链表的头指针
// 当第 i 个元素存在时,其值赋给 e 并返回 OK,否则返回 ERROR
p = L->next; j = 1; // 初始化, p 指向第一个结点, j 为计数器
while(p && j<i){ // 顺指针向后查找, 知道 p 指向第 i 个元素或 p 为空
p = p->next;
++j;
}
if(!p || j>i) return ERROR; // 第 i 个元素不存在
e = p->data; // 取 第 i 个元素
return OK;
} // GetElem_L插入元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15Status ListInsert_L(LinkList & L, int i, ElemType e){
// 在带头结点的单链表线性表 L 中第 i 个位置之前插入元素 e
p = L; j = 0;
while(p->next && j<i-1){ // 寻找第 i-1 个结点
p = p->next;
++j;
}
if(!p || j > i-1) return ERROR; // i 小于 1 或者大于表长加 1
s = (LinkList)malloc(sizeof(LNode)); // 生成新结点
s->data = e;
s->next = p->next;
p->next = s;
return OK;
} // ListInsert_L删除结点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Status ListDelete_L(LinkList &L, int i, ElemType & e){
// 在带头结点的单链线性表L中,删除第 i 个元素,并用 e 返回其值
p = L; j = 0;
while(p && j<i-1){ // 寻找第 i-1 个结点
p = p->next;
++j;
}
if(!p || j>i-1) return ERROR; // i 小于 1 或者大于表长加1
q = p->next;
p->next = p->next->next;
e = q->data;
free(q);
return OK;
} // ListDelete_L插入和删除操作,复杂度均为
,因为查找第 i-1 个结点的复杂度为 逆向建立链表
1
2
3
4
5
6
7
8
9
10
11void CreateList_L(LinkList &L, int n){
// 逆位序输入 n 个元素的值,建立带表头结点的单链线性表 L
L = (LinkList)malloc(sizeof(LNode));
L->next = NULL;
for(i = n; i>0; --i){
p = (LinkList)malloc(sizeof(LNode)); // 生成新的结点
scanf(& p->data);
p->next = L->next;
L->next = p;
}
} // CreateList_L合并两个有序链表(非递减序列)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19void MergeList_L(LinkList & La, LinkList & Lb, LinkList & Lc){
// 已知单恋线性表 La 和 Lb 的元素按值非递减排列
// 归并 La 和 Lb 得到新单链线性表 Lc, Lc 的元素也按值非递减排列
pa = La->next; pb = Lb->next;
Lc = pc = La; // 用 La 的头结点组为 Lc 的头结点
while(pa && pb){
if(pa->data <= pb->data){
pc->next = pa;
pc = pa;
pa = pa->next;
}else{
pc->next = pb;
pc = pb;
pb = pb->next;
}
}
pc->next = pa?pa:pb; // 插入剩余段
free(Lb); // 释放 Lb 的头结点
}
2.3.4 静态链表
1 | // 线性表的静态单链表存储结构 |
这种存储结构需要预先分配一个较大的空间,但在线性表的插入和删除操作时不需要移动元素,仅需修改指针,故仍具有链式存储结构的主要优点。下面展示 插入元素 "SHI" 和 删除元素 "ZHENG" 之后的状况。
假设 S 为 SLinkList 变量,则 S[0].cur
指示第一个结点在数组中的位置,若设 i = S[0].cur
,则
S[i].data
存储线性表的第一个数据元素。若第 i
个分量表示链表的第 k 个结点,则 S[i].cur
指示第 k+1
个结点的位置。因此在静态链表中,以整型游标 i
代替动态指针
p
。i = S[i].cur
实为指针后移(类似
p=p->next
)
需要自己实现 malloc
和 free
这两个函数。为了辨明数组中哪些分量未被使用,解决办法是将所有未被使用过以及被删除的分量用游标链成一个备用链表,每当插入时,便可从备用链表取第一个结点作为待插入的新结点。反之,在删除时将从链表中删除下来的结点链接到备用链表上。
初始化静态链表
1
2
3
4
5
6void InitSpace_SL(SLinkList &space){
// 将一维数组 space 中各分量链成一个备用链表,space[0].cur 为头指针
// “0” 表示空指针
for(i = 0; i<MAXSIZE-1; ++i) space[i].cur = i+1;
space[MAXSIZE-1].cur = 0;
} // InitSpace_SL自定义
malloc
函数1
2
3
4
5
6
7
8int Malloc_SL(SLinkList & space){
// 将一维数组 space 中各分量链成一个备用链表, space[0].cur 为头指针
// '0' 表示空指针
i = space[0].cur;
if(space[0].cur) space[0].cur = space[i].cur;
// 将备用链表的头指针指向备用链表中第一个节点的下一个节点。这样,备用链表中的第一个节点被分配出去,不再是备用节点。
return i;
} // Malloc_SL自定义
free
函数1
2
3
4
5void Free_SL(SLinkList &space, int k){
// 将下标为 k 的空闲结点回收到备用链表
space[k].cur = space[0].cur; // 回收结点的指针域指向现在链表第一个结点
space[0].cur = k; // 头结点指针域指向回收结点
} // Free_SL建立结合
(A_B)⋃(B-A)
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38void difference_SL(SLinkList &space, int& S){
//依次输入集合 A 和 B 的元素,在一维数组 space 中建立表示集合(A_B)⋃(B-A)
// 的静态链表,S 为其头指针。假设备用空间足够大,space[0].cur 为其头指针
InitSpace_SL(space); // 初始化备用空间
S = Malloc_SL(space); // 生成 S 的头结点
r = S; // r 指向 S 的当前最后结点
scanf(m, n); // 输入 A 和 B 的元素个数
for(j = 1; j<=m ;++j){ // 建立集合 A 的链表
i = Malloc_SL(space); // 分配结点
scanf(space[i].data); // 输入 A 的元素值
space[r].cur = i; // 插入到表尾
r = i;
} // 尾结点的指针为空
space[r].cur = 0; // 尾结点的指针为空
for(j = 1; j<=n; ++j){ // 依次输入 B 的元素,若不在当前表中,则插入,否则删除
scanf(b);
p = S;
k = space[S].cur; // k指向集合 A 中的第一个结点
while(k != space[r].cur && space[k].data != b){ // 在当前表中查找
p = k;
k = space[k].cur;
}
if(k == space[r].cur){
// 当前表中不存在该元素,插入在 r 所指结点之后
//且 r 的位置不变
i = Malloc_SL(space);
space[i].data = b;
space[i].cur = space[r].cur;
space[r].cur = i;
}
else{ // 该元素在表中,删除之
space[p].cur = space[k].cur;
Free_SL(space, k);
if(r == k) r = p; // 若删除的是 r 所指结点,则需修改尾指针
}
}
}时间复杂度:
2.3.5 循环链表
循环链表(circular linked list) 是另一种形式的链式存储结构。它的特点是表中最后一个结点的指针指向头结点,整个链表形成一个环。
因此,从表中任一结点出发均可找到表中其他结点。
在这种结构中,链表操作中的循环条件是 p->next
是否等于头指针。
有时候,在循环链表中设立尾指针而不设头指针,可以使得某些操作化简。例如两个线性表合并成一个表时,仅需将一个表的表尾和另一个表的表头相接。运算时间
2.3.6 双向链表
双向链表的结点有两个指针域,其一指向直接后继,另一指向直接前驱。C 语言中可描述如下:
1 | typedef struct DuLNode{ |
双向链表中存在两个环,有
d->next->prior = d->prior->next = d
在双向链表中,有些操作:ListLength, GetElem 和 LocateElem 等仅需涉及一个方向的指针,则它们的算法描述和线性链表的操作相同。但在 插入、删除 时有很大的不同,在双向链表中需 同时修改两个方向上的指针。
1 | Status ListInsert_DuL(DuLinkList &L, int i, ElemType e){ |
1 | Status ListDelete_DuL(DuLinkList &L, int i, ELemType &e){ |
链表有空间的合理利用上和插入、删除时不需要移动等优点。但,它也存在着实现某些基本操作,如求线性表的长度时不如顺序存储结构的缺点。另一个方面,由于在链表中,结点之间的关系用指针表示,则数据元素在线性表中的 “位序” 的概念已淡化,而被数据元素在线性链表中的 “位置” 所代替。
2.4 一元多项式的表示和相加
一元多项式
在处理多项式的次数可能很高且变化很大,例如
可以用一个长度为 每个元素有两个数据项(系数项和指数项)的线性表:
利用 线性链表 可以更好地实现 改变多项式的系数和指数的运算。
抽象数据类型一元多项式的定义:
1 | ADT Polynomial{ |
实现上述定义的一元多项式,显然应该采用链式存储结构。例如:表示多项式
利用有序链表实现 一元多项式。有序链表的基本操作定义 与 线性链表 有两处不同,一是 LocateElem 的职能不同,二是需要增加有序关系进行插入操作 OrderInsert:
1 | Status LocateElem(LinkList L, ElemType e, Position &q, |
抽象数据类型 Polynomial 的实现:
1 | typedef struct{ |
基本操作算法描述:
1 | int cmp(term a, term b) |
多项式相加:
1 | void AddPolyn(polynomial & Pa, polynomial & Pb){ |
3 栈和队列
- 从 数据结构 角度看,栈和队列也是线性表,其特殊性在于栈和队列的基本操作是线性表操作的子集,它们是 操作受限的线性表 。因此,可称为限定性的数据结构。
- 从 数据类型 角度看,它们是和线性表大不相同的抽象数据类型。
3.1 栈
3.1.1 抽象数据类型栈的定义
栈 (stack) 是限定 仅在表尾进行插入或删除 的线性表。
- 表尾段称为 栈顶(top),表头段称为栈底(bottom)。不含元素的空表称为空栈。
- 栈 是 后进先出 (last in first out) 的线性表 (简称 LIFO 结构)
栈的数据结构的抽象定义:
1 | ADT Stack{ |
3.1.2 栈的表示和实现
栈的两种存储表示方式:顺序栈 和 链栈 。
顺序栈,栈的顺序存储结构,利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素,同时附设指针top指示栈顶元素在顺序栈的位置。通常以 top = 0 表示空栈。
设定两个常量STACK_INIT_SIZE
(存储空间初始分配量)和
STACKINCREMENT
(存储空间分配增量),并以下述类型说明作为顺序栈的定义。
1 | typedef struct{ |
stacksize
指示栈的当前可使用的最大容量。
顺序栈的模块说明
1 | // ADT Stack 的表示和实现 |
基本操作的算法描述(部分)
1 | Status InitStack(SqStack &S){ |
链栈:
共享栈:

3.2 栈的应用
3.2.1 数制转换
十进制数 N 和其他 d 进制数的转换。基本原理:
上述计算过程是从低位到高位顺序产生八进制数的各个数位,而打印输出一般是从高位到地位进行,伪代码:
1 | void conversion(){ |
c++代码:
1 | void conversion(){ |
3.2.2 表达式求值
四则运算求值 规则:(1)先乘除,后加减;(2)从左算到右;(3)先括号内,后括号外
设定任意两个相继出现的运算符

为了实现算符优先算法,可以使用两个工作栈。一个称作 OPTR,用以寄存 运算符;另一个叫做 OPND,用于 寄存操作数或运算结果。算法基本思想:
( 1 )首先置操作数栈为空栈,表达式起始符 "#" 为运算符栈的栈底元素
( 2 )依次读入表达式中每个字符,若是操作数则进入 OPND 栈,若是运算符则和 OPTR 栈的栈顶运算符进行优先权 比较后进行相应运算操作。
( 3 )如此,直至整个表达式求值完毕 (即 OPTR 栈的栈顶元素和读入的字符均为 "#" )
算法实现
1 | OperandType EvaluateExpression(){ |
3.3 栈与递归的实现
一个直接调用自己,或通过一系列调用语句间接地调用自己的函数,称做递归函数。
递归的应用
阶乘函数:
3.3.1 n 阶汉诺塔问题
假设有 3 个分别命名为 X、Y 和 Z 的塔座,在塔座 X 上插有
如何实现移动圆盘操作:
- n = 1 时,只要将编号为 1 的圆盘从 塔座X 直接移动到 塔座 Z
- ,n > 1 时,需利用塔座 Y 作辅助塔座,若能设法将压在编号为 n 的圆盘之上的 n-1 圆盘从塔座 X 移动到 塔座 Y 上,则可先将 编号为 n 的圆盘从塔座 X 移至 塔座 Z,然后再将塔座 Y 上的 n-1 个圆盘(依照上述法则)移至塔座 Z 上。 而如何将 n-1 个圆盘从一个塔座移至另一个塔座是一个和原问题具有相同的特征属性的问题,指示问题规模小 1,因此可以用相同的方法求解。
算法实现代码:
1 | void hanoi(int n, char x, char y, char z) |
3.3.2 递归函数运行实质
当在一个函数的运行期间调用另一个函数,在运行被调用函数之前,系统需先完成 3 件事:
- 将 所有的实在参数、返回地址 等信息传递 给被调用函数保存
- 为 被调用函数的局部变量 分配存储区
- 将 控制转移到被调函数的入口
从被调用函数返回调用函数之前,系统也应完成 3 件工作:
- 保存 被调函数的计算结果
- 释放 被调函数的数据区
- 依照 被调函数保存的返回地址 将控制转移到 调用函数
一个递归函数的运行过程 类似于 多个函数的嵌套调用,只是 调用函数 和 被调用函数 是同一个函数,因此,和每次调用相关的一个重要的概念是递归函数运行的 “层次” 。 假设调用该递归函数的主函数为 第 0 层,则从主函数调用递归函数为进入 第 1 层;从第 i 层递归调用本函数为 进入 “下一层”,即 i+1 层。反之,退出第 i 层递归则返回 i-1 层。
为保证 递归函数 正确执行,系统需设立一个 “递归工作栈” 作为整个递归函数运行期间使用的数据存储区。每一层递归所需信息构成一个“工作记录”,其中包括 所有的实在参数、所有的局部变量以及上一层的返回地址。 每进入一层递归,就产生一个新的工作记录压入栈顶。每退出一层递归,就从栈顶弹出一个工作记录,则当前执行层的工作记录必定是递归工作栈栈顶的工作记录,称这个记录 “活动记录”,并称指示活动记录的栈顶指针为 “当前环境指针”。
3.4 队列
3.4.1 抽象数据类型队列的定义
队列(queue)是一种 先进先出(first in first out,缩写为 FIFO)的线性表。只允许在表的一端进行插入,而在另一端删除元素。允许插入的一端叫做 队尾(rear),允许删除的一端则称为 队头(front)。
队列的应用:操作系统中的作业排队。
队列的抽象数据类型定义:
1 | ADT Queue{ |
双端队列是限定插入和删除操作在表的两端进行的线性表。这两段分别称为端点1和端点2。
3.4.2 链队列 —— 队列的链式表示和实现
队列有两种存储表示:链式存储和线性存储。
链队列,需要两个分别指示队头和队尾的指针(分别称为 头指针 和 尾指针)。为操作方便,给队列添加一个头结点,并令头指针指向头结点。
单链队列的实现:
1 | typedef struct QNode{ |
基本操作的算法描述(部分)
1 | Status InitQueue(LinkQueue &Q){ |
3.4.3 循环队列——队列的顺序表示和实现
在非空队列中,队首指针始终指向队头元素,而队尾指针始终指向队尾元素的下一个位置。
在顺序队列中存在 “假溢出” 现象。因为在入队和出队操作中,头、尾指针只增加不减少,导致删除元素的空间永远无法重新利用。
因此,尽管队列中实际元素个数可能远远小于数组大小,但可能由尾指针已超出数组空间的上界而不能做入队操作。这种现象称为假溢出。
为充分利用数组空间,克服上述“假溢出”现象的方法:将队列分配的数组空间看成一个首位相接的圆环,并称这种队列为 循环队列。
在循环队列中 进行出队、入队操作时,队首、队尾指针仍要加 1,朝前移动。只不过当队首、队尾指针指向数组上界(MAX_QUEUE_SIZE-1)时,其加 1 操作的结果是指向数组的下界 0:
1
2if(i+1 == MAX_QUEUE_SIZE) i = 0;
else i++;其中 i 代表 队首指针或队尾指针
可以用 模运算简化为 :
i = (i+1)%MAX_QUEUE_SIZE
- 入队时尾指针向前追赶头指针,出队时,头指针向前追赶尾指针,故队空和队满尾指针均相等。
- 因此无法通过
front = rear
来判断队列 “空” 还是 “满”。 解决方法:约定入队前,测试尾指针在循环意义下加 1 是否等于头指针,若相等则认为队满。即:- rear 所指的单元始终为空
- 循环队列为空:front = rear
- 循环队列满:
(rear+1)%MAX_QUEUE_SIZE = front
循环队列 vs. 非循环队列
- 队列主要用于保存中间数据,而且保存的数据满足先产生先处理的特点。非循环队列可能存在数据假溢出,即队列中还有空间,可是队满的条件却成立了。为此,改为循环队列,这样克服了假溢出。
- 但并不能说循环队列一定优于非循环队列,因为循环队列中出队元素的空间可能被后来进队的元素占用,如果算法要求 队列操作结束后利用进队的所有元素实现某种功能,这样循环队列就不适合了,这种情况下就需要使用非循环队列。
3.4.4 优先队列
普通的队列是一种先进先出的数据结构,元素在队列尾追加,从队列头删除。
在优先队列中,每个元素都有一个优先级。具有最高优先级的元素最先出队。优先队列具有 最高级先出 的行为特征。优先队列执行的操作有 查找、插入一个新元素 和 删除 三种。
优先队列和普通队列都适用在处理或服务对象需要按序逐个进行操作的场合,不过 优先队列关心对象的优先级,将队列按 优先级顺序 作 出队 处理。
例子:
操作系统的任务调度
对操作系统而言,当有多个任务需要处理的时候,一般而言,解决这个问题的简单策略就是把任务放在一个队列中,先来者先服务,这是公平的,虽然对每个任务本身来说,都期望是能够被立即执行而不仅仅是被执行。 实际中会出现提交一个执行时间很短的任务可能要等很长的时间才能被执行的情形,因为在它之前已经有许多执行时间很长的任务,在队列中等待执行,这样会导致用户的使用感受不好。 解决此问题的一个策略是利用优先队列,按照“任务需要执行的时间越短,它的优先权越高”的原则,按任务的轻重顺序执行,可以使用户的平均等待时间最短。
多媒体通讯网络中的数据包调度
集合中的元素搜索
3.4.5 队列的应用
- 只要满足 先来先服务 特性的应用均可采用队列作为其数据组织方式或中间数据结构。
- 调度或缓冲:消息缓冲器、邮件缓冲器、计算机的硬件设备之间的通信也需要队列作为数据缓冲、操作系统的资源管理
- 广度优先搜索
回文串的判断
1 | bool Palindrome_Test(char * str) |
用两个栈 S1 和 S2 模拟一个队列,写出入队和出队的算法(可用栈的基本操作)
1 | Status EnQueue(DualStack &Q, QElemType e){ |
4 串
4.1 串类型的定义
串 (string)(或字符串) 是由零个或多个字符组成的有限序列
- 一般记为
。其中, 是串的名,用双引号括起来的字符序列是串的值; 可以是 字母、数字或其他字符; - 串中字符的数目
称为串的 长度。 - 零个字符的串称为 空串 (null
string),它的长度为零。用
表示 “空串”。 - 通常将仅由一个或多个空格组成的串称为
空白串(Blank String),注意 空串和空白串的不同。
""
和" "
分别表示长度为0的空串和长度为 1 的空白串。 - 串中 任意个 连续的字符 组成的子序列称为串的子串。包含子串的串相应地称为 主串。
- 通常称字符在序列中的序号为该字符在串中的位置,子串在主串中的位置则以子串的第一个字符在主串中的位置来表示。
- 只有当两个串的长度相等,并且各个对应位置的字符都相等时两个串才相等
- 串值必须用一对双引号括起来。
字符串常数:"\n"
字符串变量:a = "program"
串的逻辑结构和线性表极为相似,区别在于 串的数据对象约束为字符集。在串的基本操作中,通常以 “串的整体” 作为操作对象,例如在串中查找某个子串,求取一个子串、在串的某个位置上插入一个子串以及删除一个子串等。
串的抽象数据类型定义如下:
1 | ADT Queue{ |
串类型的 最小操作子集:
- 串赋值(StrAssign)
- 串比较(StrCompare)
- 求串长(StrLength)
- 串联结(Concat)
- 求子串(SubString)
字符(char):组成字符串的基本单元,在C和C++中,字符占单个字节(8 bits),采用 ASCII码 对 128个符号(字符集 charset)进行编码。
标准字符串:将 C++ 的 <string.h>
函数库作为字符串数据类型的方案。例如char S[M]
字符串的结束标记:'\0'
,'\0'
是 ASCII 码中
8位全0码,又称 NULL符
函数库的字符串操作函数:
1 | // 字符串长函数 |
4.2 串的表示和实现
串是一种特殊的线性表,存储方式取决于对字符串所进行的操作。字符串在计算机中有 3 种表示方式:
- 定长顺序存储表示:将字符串定义成字符数组,利用字符串名可以直接访问字符串值。用这种表示方式,字符串的存储空间在编译时确定,其大不能改变。
- 堆分配存储方式:仍然用一组地址连续的存储单元来依次存储字符串中的字符序列,但字符串的存储空间是在程序运行时根据字符串的实际长度动态分配的。
- 块链存储方式:是一种链式存储结构表示
4.2.1 定长顺序存储表示
用一组地址连续的存储单元存储串值的字符序列。在串的定长顺序存储结构中,按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区,则可用定长数组如下描述:
1 | // 串的定长顺序存储表示 |
串的实际长度可以在这预定义长度范围内,超过预定义长度的串值则被舍弃,称之为 “截断”。
字符串的联结操作
1 | Status StrConcat(StringType &s, StringType t) |
求子串操作(已知位置)
1 | Status SubString(StringType s, int pos, int len, StringType* sub){ |
4.2.2 堆分配存储表示
这种存储表示的特点,仍以一组地址连续的存储单元存放串值字符序列,但它们的存储空间是在程序执行过程中动态分配而得。
实现方式:在 C 语言中,存在一个称之为 “堆” 的自由存储区,并由 C
语言的动态分配函数 malloc()
和 free()
来管理。利用函数 malloc()
为每个新产生的串分配一块实际串长所需的存储空间,若分配成功,则返回一个指向初始地址的指针,作为串的基址。
1 | // 串的堆分配存储表示 |
字符串的联结操作
1 | Status StrConcat(HString &T, HString S1, HString S2){ |
指定位置的字符串插入
1 | Status StrInsert(HString &S, int pos, HString T){ |
ADT String 的表示和实现
1 | // 串的堆分配存储表示 |
4.2.3 串的块链存储表示
可以采用 链表方式 存串值。结构中的每个数据元素是一个字符,则用链表存储串值时,存在一个“结点大小”的问题,即每个结点可以存放一个字符,也可以存放多个字符。
串的块链存储表示:
1 |
|
串的存储密度:
4.3 串的模式匹配算法
4.3.1 求子串位置的定位函数
Index(S, T, pos)
子串的定位操作 通常称为 串的模式匹配(其中 T 称为 模式串),是各种串处理系统中最重要的操作之一。最朴素的模式匹配算法:
1 | int Index(SString S, SString T, int pos){ |
4.3.2 KMP算法
算法流程
假设现在文本串匹配到 i 位置,模式串 P 匹配到 j 位置
- 如果 j = -1,或者当前字符匹配成功(即
S[i] == P[j]
),都令i++, j++
,继续匹配下一个字符 - 如果 j != -1 且当前字符匹配失败(即
S[i] != P[i]
),则令 i 不变,j = next[j]
,这意味着模式串 P 相对于文本串 S 向右移动了j-next[j]
位。
算法关键
- 寻找前缀和后最最长公共元素长度
- 求 next 数组
- 匹配失配的处理
算法实现
1 | int KmpSearch(char* s, char* p) |
求解 next 数组:
1 | void GetNext(char* p, int next[]){ |
改进的 nextval 数组:
1 | void GetNextval(char* p, int nextval[]){ |
5 数组和广义表
5.1 数组的定义
数组是一组偶对(下标值,数据元素值)的集合。在数组中,对于一组有意义的下标,都存在一个与其对应的值。一维数组对应着一个下标值,二维数组对应着两个下标值,如此类推。
抽象数据类型数组可形式地定义为:
ADT Array
:
1 | 基本操作: |
数组的所有数据元素都必须属于同一数据类型。
数组中每个数据元素都对应于一组下标
,每个下标的取值范围是 , 称为 第 维的长度 。显然,当 时, 维数组就退化为定长的线性表。数组是一种随机存取结构,给定一组下标,就可以访问与其对应的数据元素。
数组中的数据元素个数是固定的。
我们可以把二维数组看成是这样一个定长线性表:它的每个数据元素也是一个定长的线性表。
数组一旦被定义,它的维数和维界就不再改变。 因此,除了 结构的初始化和销毁 之外,数组只有 存取元素 和 改变元素值 的操作。
a
5.2 数组的顺序表示和实现
由于数组一般不作插入或删除操作,一旦建立了数组,则结构中的数据元素个数和元素之间的关系就不再发生变动。因此采用顺序存储结构表示数组。
由于存储单元是一维结构,而数组是个多维的结构,则用一组连续存储单元存放数组的数据元素就有个 次序约定 问题。即必须按某种次序将数组元素排成一列序列,然后将这个线性序列存放到内存中。
对二维数组可有两种存储方式:
以列序为主序(列优先顺序)—— 将数组按列向量排列,第
个列向量紧接在第 个列向量之后,A 的 个元素按列优先顺序存储的线性序列为:以行序为主序(行优先顺序)—— 将数组元素按行排列,第
个行向量紧接在第 个行向量后面,以二维数组为例, 按行优先顺序存储的线性序列为:
一旦规定了它的维数和各维的长度,便可为它分配空间。反之,只要给出一组下标便可求得相应数组元素的存储位置。
下面仅以行序为主序为例:
假设每个数据元素占据 L 个存储单元,则二维数组 A 中任一元素
n 维数组的数据元素存储位置计算公式:
上面的公式称为
对于
的矩阵 A,元素下标 ,则按 行优先顺序 存储:
按 列优先顺序 存储:
实例:假设二维数组A的规模为
,每个元素用相邻的6个字节存储,存储器按字节编址。已知A的起始存储位置(基地址)为1000,元素a00为起始,计算:数组 A 的体积(即存储量)
数组 A 的最后一个元素
的第一个字节的地址按行存储时,元素
的第一个字节的地址按列存储时,元素
的第一个字节的地址
5.3 矩阵的压缩存储
5.3.1 特殊矩阵
若
对于 对称矩阵,我们可以为每一对对称元分配一个存储空间,则可将
假设以一维数组
这种压缩存储方法同样也适用于三角矩阵,上(下)三角矩阵。
对角矩阵,可以按照某个原则(或以行为主,或以对角线的顺序)将其压缩在一维数组上。

5.3.2 稀疏矩阵
假设在
抽象数据类型 稀疏矩阵 的定义:
1 | ADT SparseMatrix{ |
- 三元组顺序表
以顺序存储结构来表示三元组表,即可得到 稀疏矩阵 的一种压缩存储方式——三元组顺序表
1 | // 稀疏矩阵的三元组顺序表存储表示 |
转置矩阵:对于一个
对于重拍三元组之间的次序,有两种处理方法:(设有矩阵 B 是 矩阵A 的转置)
按照 转置后矩阵 中三元组的次序依次在 原矩阵的三元组 中找到相应的三元组进行转置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18Status TransposeSMatrix(TSMatrix M, TSMatrix &T){
// 采用三元组表存储表示,求稀疏矩阵M的转置矩阵T
T.mu = M.nu; T.nu = M.mu; T.tu = M.tu;
if(T.tu){
q = 0;
for(col = 1; col<=M.nu; ++col){
for(p = 0; p<M.tu; ++p){
if(M.data[p].j == col){
T.data[q].i = M.data[p].j;
T.data[q].j = M.data[p].i;
T.data[q].e = T.data[p].e;
++q;
}
}
}
}
return OK;
}时间复杂度:
快速转置:按照 原矩阵 中三元组 a.data 的次序进行转置,并将转置后的三元组置入 转置矩阵 恰当的位置。应先求M的每一列非零元的个数,进而求得每一列的第一个非零元在 转置矩阵的三元组b.data 中应有的位置。那么在对 a.data 中的三元组依次作转置时,便可直接放到 b.data 中恰当位置上去。
需要设置
num
和cpot
两个向量。num[col]
表示矩阵M中第col
列中非零元的个数,cpot[col]
指示 M 中第col
列的第一个非零元在b.data
中的恰当位置。显然有 算法实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20Status FastTransposeSMatrix(TSMatrix M, TSMatrix &T){
// 采用三元组顺序表存储表示,求稀疏矩阵M的转置矩阵T
T.mu = M.nu; T.nu = M.mu; T.tu = M.tu;
if(T.tu){
for(col = 1; col<=M.nu; ++col) num[col] = 0;
// 求M中每一列含非零元个数
for(t = 1; t<=M.tu; ++t) ++num[M.data[t].j];
cpot[1] = 1;
// 求第col列中第一个非零元在 b.data 中的序号
for(col = 2; col<=M.nu; ++col) cpot[col] cpot[col-1]+num[col-1];
for(p = 1; p<=M.tu; ++p){
col = M.data[p].j; q = cpot[col];
T.data[q].i = M.data[p].j;
T.data[q].j = M.data[p].i;
T.data[q].e = M.data[q].e;
++cpot[col];
}// for
}//if
return OK;
}时间复杂度:
- 行逻辑链接的顺序表
为了方便随机存取任意一行的非零元,则需知道每一行的第一个非零元在三元组表的位置。为此,可将 快速转置矩阵算法中创建的,指示“行”信息的辅助数组 cpot 固定在稀疏矩阵的存储结构中。
这种 “带行链接信息” 的三元组表 为行逻辑链接的顺序表,其类型描述如下:
1 | typedef struct{ |
- 十字链表
当矩阵的非零元个数和位置在操作过程中变化较大时,就不宜采用顺序存储结构来表示三元组的线性表。此时,采用 链式存储结构 表示三元组的线性表更为恰当。
在链表中
- 每个非零元可用一个含 5 个域的结点表示,其中 i、j 和 e 这三个域分别表示该非零元所在的行、列和非零元的值。
- 向右域 right 用以 链接同一行中下一个非零元,向下域 down 用以链接同一列中下一个非零元。
- 同一行 的非零元 通过 right 域 链接成 一个线性链表,同一列 的非零元通过 down 域 链接成一个线性链表。
- 每一个非零元既是某个行链表中的一个结点,又是某个列链表中的一个结点,整个矩阵构成了一个十字交叉的链表。
1 | // 稀疏矩阵的十字链表存储表示 |
5.4 广义表的定义
广义表,是线性表的推广。广义表一般记作:
- 在线性表的定义中,
只限于单个元素。而在广义表的定义中, 可以是 单个元素,也可以是广义表,分别称为 广义表 LS 的 原子 和 子表。 习惯上,用大写字母表示广义表的名称,用小写字母表示原子 - 当 广义表 LS 非空时,称第一个元素
为 LS 的 表头(Head),称其余元素组成的表 是 LS 的表尾(Tail)
广义表的例子:
- A = () —— A是一个空表,它的长度为零
- B = (e) —— 列表 B 只有一个原子 e,B 的长度为 1
- C = (a, (b,c,d)) —— 列表 C 的长度为 2,两个元素分别为 原子 a 和 子表(b,c,d)
- D = (A,B,C) —— 列表 D 的长度为 3,3个元素都是列表
- E = (a, E) —— 这是一个递归的表,它的长度为 2。E 相当于一个无限的列表 E = (a, (a,(a, ···)))
广义表的重要定义:
- 广义表的元素可以是原子,也可以是子表,子表的元素又可以是子表。即广义表是一个多层次的结构。
- 广义表可以被其他广义表所共享,也可以共享其他广义表。广义表共享其他广义表时通过表名引用。
- 广义表本身可以是一个递归表。
- 任何一个非空广义表的表头可以是原子,也可以是子表,而表尾必定是广义表。
5.5 广义表的存储结构
通常采用 链式存储结构 表示广义表,每个数据元素可用一个结点表示。
1 | // 广义表的头尾链表存储表示 |
对于上述存储结构,有如下几个特点: (1) 若广义表为空,表头指针为空;否则,表头指针总是指向一个表结点,其中hp指向广义表的表头结点(或为原子结点,或为表结点) , tp指向广义表的表尾(表尾为空时,指针为空,否则必为表结点)。 (2) 这种结构求广义表的长度、深度、表头、表尾的操作十分方便。 (3) 表结点太多,造成空间浪费
6 树和二叉树
6.1 树的定义和基本术语
树(Tree) 是
抽象数据类型树的定义:
1 | ADT Tree{ |
树的结构定义是一个递归的定义,即在树的定义中又用到树的概念。
树结构的基本术语
树的 结点:包含一个数据元素及若干指向其子树的分支
结点的 度:结点拥有的子树数
叶子(终端结点):度为 0 的结点
除根节点以外的分支结点称为 内部结点
树的 度:树内各结点的度的最大值
结点的 孩子:结点子树的根
同时,该结点称为孩子的 双亲
同一个双亲的孩子之间互称为 兄弟
结点的 祖先:从根节点到该结点所经分支上的所有结点
结点的 子孙:以该结点为根的子树中的任一结点
结点的层次,从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第
l
层,则其子树的根 就在 第l+1
层。 其双亲在同一层的结点互称为 堂兄弟。深度(高度):树中结点的最大层次。
如果将树中结点的各子树看成从左到右是有次序的(即不能互换),则称该树为有序树,否则称为无序树。在有序树最左边的子树的根称为第一个孩子,最右边的称为最后一个孩子。
森林 是
棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。由此,也可以森林和树互相递归定义来描述树。就逻辑结构而言,任何一棵树是一个二元组
,其中: 是数据元素,称做树的根结点; 是 棵树的森林, ,其中 称做根 的第 棵树;当 时,在树根和其子树森林之间存在下列关系:
6.2 二叉树
6.2.1二叉树的定义
二叉树 (Bianry Tree)是另一种树型结构,它的特点是每个结点至多只有两棵子树(即二叉树中不存在度大于 2 的结点),并且,二叉树的子树有左右之分,其次序不能任意颠倒。
抽象数据类型二叉树的定义如下:
1 | ADT BinaryTree: |
上述数据结构的递归定义表明二叉树或为空,或是由一个根结点加上两棵分别成为左子树和右子树的、互不相交的的二叉树组成。二叉树有5个基本形态。
6.2.2 二叉树的性质
性质1 在二叉树的第 i 层上至多有
性质2 深度为 k 的二叉树至多有
性质3 对任何一棵二叉树
性质4 具有
性质5 如果对一棵有 n 个结点的完全二叉树(其深度为
(1)如果
(2)如果
(3)如果
6.2.3二叉树的存储结构
顺序存储结构
1
2
3
4//----- 二叉树的顺序存储表示 -----
typedef TElemType SqBiTree[MAX_TREE_SIZE]; // 0 号单元存储根结点
SqBiTree bt;用一组地址连续的存储单元依次 自上而下、自左至右 存储完全二叉树上的结点元素,即将完全二叉树 上编号为
的结点存储在如上定义的一维数组中下标为 的分量中。顺序存储结构仅适用于完全二叉树。链式存储结构
表示二叉树的链表中的结点至少包含 3 个域:数据域 和 左、右指针域。有时为了方便查找双音,则还可在结点结构中增加一个指向其双亲结点的指针域。利用这两种结点结构所得二叉树的存储结构分别称之为二叉链表和三叉链表。
链表的头结点指向二叉树的根结点。在含有
个结点的二叉链表中有 个空链域。1
2
3
4
5
6
7
8
9
10
11
12//----- 二叉树的二叉链表存储表示 -----
typedef struct BiTNode{
TElemType data;
struct BiTnode* lchild, *rchild; // 左右孩子指针
}BiTNode, *BiTree;
//----- 基本操作的函数原型说明 -----
Status CreateBiTree(BiTree &T);
// 先序顺序输入二叉树中结点的值(一个字符),空格字符表示空树
Status PreOrderTraverse(BiTree T, Status(*Visit)(TElemType e));
Status InOrderTraverse(BiTree T, Status(*Visit)(TElemType e));
Status PostOrderTraverse(BiTree T, Status(*Visit)(TElemType e));
Status LevelOrderTraverse(BiTree T, Status(*Visit)(TElemType e));

6.3 遍历二叉树和线性二叉树
6.3.1 遍历二叉树
遍历二叉树(traversing binary tree),按某条搜索路径巡访树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。
先序遍历
若二叉树为空,则空操作,否则
- 访问根节点
- 先序遍历左子树
- 先序遍历右子树
先序遍历算法描述:
1 | Status PreOrderTraverse(BiTree T, Status(* Visit)(TElemType e)){ |
可类似地实现中序遍历和后序遍历的递归算法
可运行的递归代码:
1 | vector<int> preorderTraversal(TreeNode* root) { |
先序遍历
非递归代码:
1 | vector<int> preorderTraversal(TreeNode* root) |
中序遍历
若二叉树为空,则空操作,否则
- 中序遍历左子树
- 访问根节点
- 中序遍历右子树
递归代码:
1 | class Solution { |
后序遍历
若二叉树为空,则空操作,否则
- 后序遍历左子树
- 后序遍历右子树
- 访问根结点
递归代码:
1 | class Solution { |
遍历的非递归算法
如图所示的二叉树表示表达式:a+b*(c-d)-e/f
若先序遍历此二叉树,按访问结点的先后次序将结点排列起来,可得到二叉树的先序序列为:-+a*b-cd/ef
类似地,中序遍历此二叉树,可得到此二叉树的中序序列:a+b*c-d-e/f
后序遍历此二叉树,可得到此二叉树的后序序列:abcd-*+ef/-
从表达式来看,以上 3 个序列恰好为表达式的前缀表示(波兰式)、中缀表示和后缀表示(逆波兰式)
由以上二叉树遍历的定义可知,3种遍历算法之不同处仅在于访问根结点和遍历左、右子树的先后关系。
仿照递归算法执行过程中递归工作栈的状态变化状况可直接写出相应的非递归算法。
例如,从中序遍历递归算法执行过程中递归工作栈的状态可见: (1)工作记录中包含两项,其一是递归调用的语句编号,其二是指向根结点的指针,则当栈顶记录中的指针非空时,则应遍历左子树,即指向树根的指针进栈; (2)若栈顶记录中的指针值为空,则应退至上一层,若是从左子树返回,则应访问当前层即栈顶记录中指针所指的根结点 (3)若是从右子树返回,则表明当前层的遍历结束,应继续退栈。这意味着,遍历右子树时,不需要保留当前层的根指针,可直接修改栈顶记录中的指针即可。
1 | Status InOrderTraverse(BiTree T, Status(*Visit)(TElemType e)){ |
1 | Status InOrderTraverse(BiTree T, Status(*Visit)(TElemType e)){ |
先序遍历的非递归算法
1 | void PreOrder2(BiTree T){ |
后序遍历的非递归算法
1 | void PostOrder(BiTree T) |
每次出栈访问完一个结点就相当于 遍历完以该结点为根的子树,需将 p 置 NULL。
“遍历” 是二叉树各种操作的基础,可以在遍历过程中对结点进行各种操作,如:对于一棵已知树可求结点的双亲,求结点的孩子结点,判定结点所在层次等,反之,也可在遍历过程中生成结点,建立二叉树的存储结构。
按下列次序顺序读入字符:A B C ⌀ ⌀ D E ⌀ G ⌀ ⌀ F ⌀ ⌀ ⌀
,(其中
⌀
表示空格字符)可建立相应的二叉链表。
1 | Status CreateBiTree(BiTree &T){ |
遍历二叉树的算法,无论按哪种次序进行遍历,对含
所需的空间为遍历过程中栈的最大容量,即树的深度,最坏情况下为
先序/后序/中序 遍历的非递归算法的通用代码:
1 | // 中序遍历 |
层序遍历
1 | void LevelOrder(BiTree T){ |
1 | vector<vector<int>> levelOrder(TreeNode* root) { |
6.3.2 线索二叉树
试作如下规定:若结点有左子树,则其 lchild 域指示其左孩子,否则令 lchild 域指示其前驱;若结点有右子树,则令其 rchild 域指示其右孩子,否则令 rchild 域指示其后继。所以需要增加两个标志域:
lchild | LTag | data | RTag | rchild |
---|
其中:
例如,下图(a)为中序线索二叉树,与其对应的中序线索链表如图(b)所示。其中实线为指针(指示左、右子树),虚线为线索(指向前驱和后继)。
线索化:对二叉树以某种次序遍历使其变为线索二叉树的过程。
在线索树上进行遍历,只要先找到序列中的第一个结点,然后依次找结点后继直至其后继为空时而止。
如何在搜索树中找结点的后继?以上图的中序搜索树为例来看,树中所有叶子结点的右链是线索,则右链域直接指示了结点的后继,如结点
b
的后继为结点
*
。树中所有非终端结点的右链均为指针,则由此无法得到后继的信息。
然而,根据中序遍历的规律可知,结点的后继应是遍历其右子树时访问的第一个结点,即右子树中最左下的结点。例如在找结点
*
的后继时,首先沿右指针找到其右子树的根结点
-
,然后顺其左指针往下直至其左标志为1的结点,记为
*
的后继,在图中是结点 c
。
反之,在中序搜索树中找结点前驱的规律是:若其左标志为 1,则左链为线索,指示其前驱,否则遍历左子树时最后访问的一个结点(左子树中最右下的结点)为其前驱。
在后序搜索树中找结点后继可分为3种情况: (1)若结点 x 是二叉树的根,则其后继为空 (2)若结点 x 是其双亲的右孩子或是双亲的左孩子且其双亲没有右子树,则其后继即为双亲结点 (3)若结点 x 是其双亲的左孩子,且其双亲有右子树,则其后继为双亲的右子树上按后序遍历列出的第一个结点
在中序线索二叉树上遍历二叉树,虽然时间复杂度亦为
1 | // ----- 二叉树的二叉线索存储表示 ----- |
为了方便起见,仿照线性表的存储结构,在二叉树的线性链表上也添加一个头结点,并令其 lchild 域的指针指向二叉树的根结点,其 rchild 域的指针指向中序遍历时访问的最后一个结点。令二叉树中序序列中的第一个结点的 lchild 域指针和最后一个结点 rchild 域的指针均指向头结点。 类似为二叉树建立了一个双向线性链表,既可从第一个结点起顺后继进行遍历,也可从最后一个结点起顺前驱进行遍历。
1 | Status InOrderTraverse_Thr(BiThrTree T, Status(*Visit)(TElemType e)){ |
线索化的实质:将二叉链表中的空指针改为指向前驱或后继的线索,而前驱或后继的信息只有在遍历时才能得到,因为线索化过程即为在遍历的过程中修改空指针的过程。
为了记下遍历过程中访问节点的先后关系,附设一个指针 pre 始终指向刚刚访问过的结点,若指针 p 指向当前访问的结点,则 pre 指向它的前驱。
1 | Status InOrderThreading(BiThrTree & Thrt, BiThrThree T){ |
线索树的示例:

例题

答案
6.4 树和森林
6.4.1 树的存储结构
双亲表示法
假设以一组连续空间存储树的结点,同时在每个结点中附设一个指示器指示其双亲结点在链表中的位置,其形式说明如下:
1
2
3
4
5
6
7
8
9
10// ----- 树的双亲表存储表示 -----
typedef struct PTNode{ // 结点结构
TElemType data;
int parent; // 双亲位置域
}PTNode;
typedef struct{ // 树结构
PTNode node[MAX_TREE_SIZE];
int r, n; // 根的位置和结点数
}PTree;这种结构充分利用了每个结点(除根以外)只有惟一的双亲的性质。PARENT(T,x) 操作可以在常量时间内实现。反复调用 PARENT 操作,直到遇见无双亲的结点时,便找到了树的根,这就是 ROOT(x) 操作的执行过程。但是,在这种表示法中,求结点的孩子时需要遍历整个结构。
孩子表示法
由于树中每个结点可能有多棵子树,则可用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,此时链表中的结点可以有如下两种结点格式:
第一种结点格式,多重链表中的结点是同构的,其中 d 为树的度。由于树中很多结点的度小于 d,所以链表中有很多空链域,空间较浪费。若采用第二个结点格式,多重链表的结点不同构,操作不方便。
另一种方法就是:把每个结点的孩子结点排列起来,看成一个线性表,且以链表作存储结构,则 n 个结点有 n 个孩子链表(叶子的孩子链表为空表)。而 n 个头指针又组成一个线性表,为了方便查找,可采用顺序存储结构。
1
2
3
4
5
6
7
8
9
10
11
12
13
14// ----- 树的孩子链表存储表示 -----
typedef struct CTNode{ // 孩子结点
int child;
struct CTNode* next;
} *ChildPtr;
typedef struct{
TElemType data;
ChildPtr firstchild; // 孩子链表头结点
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n, r; // 结点数和根的位置
}CTree;孩子表示法便于涉及孩子的操作的实现,却不适用于 PARENT(T,x) 操作。
孩子兄弟表示法
又称二叉树表示法,或二叉链表表示法。链表中的结点的两个链域分别指向该结点的第一个孩子结点和下一个兄弟结点,分别命名为 firstchild 域 和 nextsibling 域
1
2
3
4
5// ----- 树的二叉链表(孩子-兄弟)存储表示 -----
typedef struct CSNode{
ElemType data;
struct CSNode * firstchild, * nextsibling;
}CSNode, *CSTree;利用这种存储结构便于实现各种树的操作。首先易于实现找结点孩子等的操作。例如:若要访问结点 x 的第 i 个孩子,则只要先从 firstchild 域找到第 1 个孩子结点,然后沿着孩子结点的 nextsibling 域连续走 i-1 步,便可找到 x 的第 i 个孩子。
6.4.2 森林和二叉树的转换


给定一棵树,可以找到惟一的一棵二叉树与之对应。任何一棵和树对应的二叉树,其右子树必空。若把森林的第二棵树的根结点看成第一棵树的根结点的兄弟,同样可导出森林和二叉树的对应关系。
树与二叉树的转换
约定树中每个结点的孩子结点按从左到右的次序顺序编号。将树转为二叉树的方法是: (1)树中所有相邻兄弟之间加一条连线。 (2)对树中的每个结点,只保留它与第一个孩子之间的连线,删去它与其他孩子结点之间的连线。 (3)以树的根结点为轴心,将整棵树顺时针转动一定的角度,使之结构层次分明。
在二叉树中,左分支上的各结点在原来的树中是父子关系,而右分支上的各结点在原来的树中是兄弟关系。由于树的根结点没有兄弟,所以转换后的二叉树的根结点的右孩子必为空。
事实上,一棵树采用孩子兄弟表示法建立的存储结构与它所对应的二叉树的二叉链表存储结构是完全相同的。
森林与二叉树的转换
由森林的概念可知,森林是若干棵树的若干棵树的集合,只要将森林中各棵树的根视为兄弟,每棵树又可以用二叉树表示,这样,森林也同样可以用二叉树表示。
森林转换为二叉树的方法如下: (1)将森林中的每棵树转换为相同的二叉树。 (2)第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树根结点的右孩子,当所有二叉树连起来后,此时所得到的二叉树就是由森林转换得到的二叉树。
二叉树转换为树和森林
(1)若某结点是其双亲的左孩子,则把该结点的右孩子、右孩子的右孩子......都与该结点的双亲结点用线连起来
(2)删去原二叉树中所有的双亲结点与右孩子结点的连线
(3)整理由(1)、(2)两步所得到的树或森林,使之结构层次分明。
- 树与二叉树的转换 对于一棵无序树,树中结点的各孩子的次序是无关紧要的,而二叉树 中结点的左、右孩子结点是有区别的。为避免发生混淆,我们约定树中每一个结点的孩子结点按从左到右的次序顺序编号。
- 森林转换为二叉树 由森林的概念可知,森林是若干棵树的集合,只要将森林中各棵树的根视为兄弟,每棵树又可以用二叉树表示,这样,森林也同样可以用二叉树表示。
- 二叉树转换为树和森林 树和森林都可以转换为二叉树,二者不同的是树转换成的二叉树,其根结点无右分支,而森林转换后的二叉树,其根结点有右分支。
6.4.3 树和森林的遍历
由树结构的定义可引出两种次序遍历树的方法:一种是先根(次序)遍历树,即:先访问树的根结点,然后依次先根遍历根的每棵子树;另一种是后根(次序)遍历,即:先依次后根遍历每棵子树,然后访问根结点。
先根遍历
先根遍历的定义为: ① 访问根结点; ② 按照从左到右的顺序先根遍历根结点的每一棵子树。
后根遍历
后根遍历的定义为: ① 按照从左到右的顺序后根遍历根结点的每一棵子树; ② 访问根结点。
层次遍历 按从上到下、从左到右访问各结点。
先序遍历森林
若森林非空,则可按下述规则遍历之:
(1)访问森林中第一棵树的根结点
(2)先序遍历第一棵树根结点的子树森林
(3)先序遍历除去第一棵树之后剩余的树构成的森林
中序遍历森林
(1)中序遍历森林中第一棵树的根结点的子树森林
(2)访问第一棵树的根结点
(3)中序遍历除去第一棵树之后剩余的树构成的森林
树的先根次序遍历
当树非空时
访问根结点
依次先根遍历根的各棵子树
树的先序遍历:ABEFCDG
对应二叉树先序遍历 ABEFCDG
树的先根遍历结果与其对应二叉树表示的先序遍历结果相同
树的先根遍历可以借助对应二叉树的先序遍历算法实现
递归算法实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14template<class Type>
void Tree<Type>::PreOrder(){
// 以当前指针 current 为根,先根次序遍历
if(IsEmpty() == 0){
Visit(); // 访问根结点
TreeNode<Type> *p = current;
current = current->firstChild; // 第一棵子树
while(current != NULL){
PreOrder(); // 递归先根遍历子树
current = current->nextSibling;
}
current = p; // 恢复当前指针
}
}
树的后根次序遍历
当树非空时
依次后根遍历根的各棵子树
访问根结点。
树的后根遍历:EFBCGDA
对应二叉树中序遍历:EFBCGDA
树的后根遍历结果与其对应二叉树表示的中序遍历结果相同。
树的后根遍历可以借助对应二叉树的中序遍历算法实现。
递归算法实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14template<class Type>
void Tree<Type>::PostOrder(){
// 以当前指针 current 为根,按后根次序遍历树
if(IsEmpty() == 0){
TreeNode<Type> *p = current;
current = current->firstChild;
while(current != NULL){
PostOrder(); // 递归先根遍历子树
current = current->nextSibling;
}
current = p; // 恢复当前指针
Visit(); // 最后访问根结点
}
}
树的层次遍历
广度优先(层次次序)遍历
按广度优先次序遍历树的结果:ABCDEFG
层序遍历算法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19template <class Type>
void Tree<Type> ::LevelOrder(){
// 按广度优先次序分层遍历树,树的根结点是当前指针current,算法中使用了队列
Queue<TreeNode<Type>*> Qu(DefaultSize);
TreeNode<Type>*p;
if(current != NULL){ // 当前指针不空
p = current; // 保存当前指针
Qu.EnQueue(current);
while(Qu.IsEmpty() == 0){
current = Qu.getFront();
Qu.DeQueue();
Visit(); // 队列中取一个并访问之
while(current != NULL){
Qu.EnQueue(current); // 待访问结点的子女结点进队列
current = current->nextSibling;
}
}
}
}
6.6 赫夫曼编码
6.6.1 最优二叉树(赫夫曼树)
路径长度:从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径,路径上的分支数目称作路径长度。
树的路径长度:是从树根到每一结点的路径长度之和。
路的带权路径长度:树中所有叶子结点的带权路径长度之和,通常记为
假设有 n 个权值
图中 3 棵二叉树,都有 4 个叶子结点 a、b、c、d,分别带权
7、5、2、4,则它们的带权路径长分别为: (a)
赫夫曼算法: (1)根据给定的 n 个权值
6.6.2 赫夫曼编码
若要设计长度不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码叫做 前缀编码。
可以利用二叉树来设计二进制的前缀编码。
为电文设计总长最短的二进制前缀编码:假设每种字符在电文中出现的次数为
具体做法:由于赫夫曼树中没有度为1的结点(这类树又称严格的(strict)(或正则的)二叉树),则一棵拥有 n 个叶子结点的赫夫曼编码共有 2n-1 个结点。在构成赫夫曼树之后,求编码需从叶子节点出发走一条从叶子到根的路径;而为译码需从根出发走一条从根到叶子的路径。则对每个结点而言,既需告知双亲的信息,又需知孩子结点的信息。
设定下述存储结构:
1 | // ----- 赫夫曼树和赫夫曼编码的存储表示 ----- |
从根出发求得各个叶子结点所表示的字符的赫夫曼编码:
1 | HC = (HuffmanCode)malloc((n+1)*sizeof(char*)); |
堆
1 堆的定义及其实现
最小堆:根节点是所有结点中最小的
最大堆:根结点是所有结点中最大的
堆的性质:
- 从逻辑的角度来讲,堆是一种树形结构,而且是一种特殊的完全二叉树。每个结点对应于序列中的一个关键码。
- 堆序,只是局部有序,即 最小堆 对应的完全二叉树中所有内部结点的值均不大于其左右孩子关键码值,而一个结点和其兄弟之间没有必然的联系。
- 最小堆不像二叉排序树那样实现了关键码的完全排序。相比较而言,只有当结点之间是父子关系时候,才可以确定这两个关键码的大小关系。
最小堆的类定义:
1 |
|
最小堆向下调整调整算法:
1 | template<class Type> |
最小堆向上调整算法:
1 | template<class Type> |
最小堆的插入:
1 | template<class Type> |
最小堆的删除:
1 | template <class Type> |
7 图
图(graph),结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
7.1 图的定义和术语
抽象数据类型图的定义:
1 | ADT Graph{ |
在图中的数据元素通常称为 顶点(Vertex),V
是顶点的有穷非空集合;VR 是两个顶点之间的关系的集合。若
图(a)中,
其中:
图(b)中,
其中:
用 n 表示图中顶点数目,用 e 表示边和弧的数目。若考虑
有很少条边或弧的图称为 稀疏图,反之称为 稠密图。
有时图的边或弧具有与它有关的数,这种与图的边或弧相关的数叫做 权。这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种 带权的图 通常称为 网。
假设有两个图
对于无向图
对于有向图
一般地,如果顶点
路径长度 是路径上边或弧的数目。第一个顶点和最后一个顶点相同的路径 称为 回路 或 环。序列中 顶点不重复出现的路径 称为 简单路径。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为 简单回路 或 简单环。
在无向图
在有向图
一个连通图的 生成树
是一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的
如果一个有向图恰好有一个顶点的入度为 0,其余顶点的入度均为 1,则是一棵有向树。一个有向图的 生成森林 由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
图中的顶点之间不存在全序的关系(即无法将图中顶点排列成一个线性序列),任何一个顶点都可以被看成是第一个顶点;另一方面,任一顶点的邻接点之间也不存在次序关系。
7.2 图的存储结构
图没有顺序映像的存储结构。
常用的有 邻接表、邻接多重表 和 十字链表。
7.2.1 数组表示法
用两个数组分别存储数据元素(顶点)的信息和数据元素之间的关系(边或弧)的信息。其形式描述如下:
1 | //- - - - - 图的数组(邻接矩阵)存储表示 - - - - -// |
借助于邻接矩阵,容易判定任意两个顶点之间是否有边(或弧)相连,并容易求得各个顶点的度。对于无向图,顶点
网的邻接矩阵可定义为
在邻接矩阵存储结构 MGraph 上对图的构造操作的实现框架,它根据图 G 的种类调用具体构造算法。
1 | Status CreateGraph(MGraph &G){ |
构造 无向网:
1 | Status CreateUDN(MGraph &G){ |
7.2.2 邻接表
邻接表
是图的一个链式存储结构。在邻接表中,对图中每个顶点建立一个单链表,第

表头结点(可以链相接)通常以顺序结构的形式存储,以便随机访问顶点的链表。
1 | // ----- 图的邻接表存储表示 ----- |
若无向图中有
在无向图的邻接表中,顶点
7.2.3 十字链表
十字链表是有向图的另一种链式存储结构。可以看成将有向图的邻接表和逆邻接表结合起来得到的一种链表。在十字链表中,对应于有向图中每一条弧有一个结点,对应于每个顶点也有一个结点。这些结点的结构如下:

弧结点: 尾域 tailvex:指示弧尾顶点在图中的位置 头域 headvex:指示弧头顶点在图中的位置 链域 hlink 指向弧头相同的下一条弧 链域 tlink 指向弧尾相同的下一条弧 info 域:指向该弧的相关信息
头结点: data 存储和顶点相关的信息 firstin链域,指向以顶点为弧头的第一个弧结点 firstout链域,指向以顶点为弧尾的第一个弧结点

1 | // ----- 有向图的十字链表存储表示 ----- |
只要输入
1 | Status CreateDG(OLGraph &G){ |
7.2.4 邻接多重表
邻接多重表是无向图的另一种链式存储结构。
每一条边用一个结点表示,它由如下所示的 6 个域组成:

每个 顶点也用一个结点表示,由两个域组成:data 和 firstedge。
7.3 图的遍历
图的遍历:从某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次。
7.3.1 深度优先搜索
深度优先搜索(Depth_First Search)
1 | // 使用的全局变量 |
7.3.2 广度优先搜索
1 | void BFSTraverse(Graph G, Status(*Visit)(int v)) |
7.4 图的连通性问题
7.4.1 无向图的连通分量和生成树
在对无向图进行遍历时,对于连通图,仅需从图中任一顶点出发,进行深度优先搜索或广度优先搜索,便可访问到图中所有顶点。对于非连通图,需要从多个顶点出发进行搜索,而每一次从新的起始点出发进行搜索的过程中得到的顶点访问序列恰为其各个连通分量的顶点集。
设


对于非连通分量,每个连通分量中的顶点集,和遍历时走过的边一起构成若干棵生成树,这些连通分量的生成树组成非连通图的生成森林。
生成非连通图的深度优先生成森林:
1 | void DFSForest(Graph G, CSTree &T){ |
7.4.2 有向图的强连通分量
求有向图 G 的强连通分量的基本步骤是:
(1)对 G 进行深度优先遍历,生成 G 的 深度优先生成森林 T
(2)对森林 T 的顶点按 中序遍历 顺序进行编号
(3)改变 G 中每一条弧的方向,构成一个新的有向图 G'
(4)按(2)中标出的顶点编号,从编号最大的顶点开始对G’进行深度优先搜索,得到一棵深度优先生成树。 若一次完整的搜索过程没有遍历G'的所有顶点,则从未访问的顶点中选择一个编号最大的顶点,由它开始再进行深度优先搜索,并得到另一棵深度优先生成树。 在该步骤中,每一次深度优先搜索所得到的生成树中的顶点就是G的一个强连通分量的所有顶点。
(5)重复步骤(4),直到 G' 所有顶点都被访问。

1 | void Connected_DG(OLGraph* G) |
❗ tarjan 算法
7.4.3 最小生成树
- 如果连通图是一个带权图,则其生成树中的边也带权,生成树中所有边的权值之和称为生成树的代价。
- 最小生成树:带权连通图中代价最小的生成树 称为最小生成树
构造最小生成树的算法,基本原则:
- 尽可能选取权值最小的边,但不能构成回路
- 选择 n-1 条边构成最小生成树
Kruskal 算法,是用来寻找最小生成树的算法。
Prim 算法,可在加权连通图里搜索最小生成树。
Kruskal 算法思想
- 设有一个有
个顶点的连通网络 ,最初先构造只有 个顶点,没有边的非连通图 ,图中每个项目自成一个连通分量。 - 在
中选到一条具有 最小权值的边 时,若该边的两个顶点落在不同的连通分量上,则将此边加入到 中。 - 否则将此边舍弃,重新选择一条权值最小的边。
- 如此重复下去,知道所有顶点在同一连通分量上为止。
算法的关键:
- 如何判断边 e 所连接的两个节点 v 和 u 在不同的一棵树上
- 如何把两个节点 u 和 v 所在的树合并为一棵树
适合边比较稀疏的情况
并查集
树如何合并的问题,属于子集归并的并查集问题
并查集的子集合并运算,即要合并两个元素所属的子集
Prim 算法
基本思想:
- 从连通网络
中的某一顶点 出发,选择与它关联的具有最小权值的边 ,将其顶点加入到生成树顶点集合 U 中。 - 以后每一步从一个顶点在 U 中,而另一个顶点不在 U
中的各条边中选择权值最小的边
,把它的顶点加入到集合 中。如此下去,知道所有顶点都加入到生成树集合 中。
设置两个辅助数组:
lowcost[]
:存放生成树顶点集合内顶点到生成树外各顶点的各边上的当前最小权值。nearvex[]
:记录生成树顶点集合外各顶点距离集合内哪个顶点最近 (即权值最小)
适合顶点数量少的情况
1 | void MiniSpanTree_PRIM(MGraph G, VertexType u){ |
7.4.4 关节点和重连通分量
假若在删去顶点
利用深度优先搜索便可求得图的关节点,并由此判断图是否重连通的。
由深度优先生成树可得出两类关节点的特性:
(1)若生成树的根有两棵或两棵以上的子树,则此根顶点必为关节点。
(2)若生成树中某个非叶子顶点
7.5 有向无环图及其应用
一个无环的有向图称做 有向无环图,简称 DAG图。DAG图是一类较有向树更一般的特殊有向图。
7.5.1 拓扑排序
拓扑排序:由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为 拓扑排序。
偏序关系:集合X上的关系R是自反的、反对称的和传递的 设 R 是集合X上的 偏序 ,如果对每个
必有 或 ,则称 是集合 上的全序关系
偏序指的是集合中仅有部分成员之间可比较,全序是指集合中全体成员之间均可比较。
例如:下图两个有向图,图中弧
若在(a)的有向图上人为地加一个表示
例
一个软件专业学生必须学习一系列基本课程,其中有些是基础课,独立于其他课程,而另一些课程必须在学完作为它的基础的先修课程才能开始。下图中,把顶点表示课程,有向边(弧)表示先决条件。若课程
这种用 顶点表示活动,用弧表示活动间的优先关系的有向图称为顶点表示活动的网,简称 AOV-网。
拓扑排序-算法思想
(1)在有向图中选一个没有前驱的顶点且输出之
(2)从图中删除该顶点和所有以它为尾的弧
重复上述两步,直至全部顶点均已输出,或者当前图中不存在无前驱的顶点为止(说明有向图中存在环)。

为了避免重复检测入度为0的顶点,可另设
1 | Status TopologicalSort(ALGraph G){ |
7.5.2 关键路径
与 AOV-网 相对应的是 AOE-网 ,即边表示活动的网。 AOE-网 是一个带权的有向无环图,其中 顶点 表示 事件,弧 表示 活动,权 表示活动持续的时间。通常,AOE-网 可用来估算工程的完成时间。
例如,上图是一个假想的有11项活动的AOE-网。其中有 9 个事件
AOE-网有待研究的问题是:(1)完成整项工程至少需要多少时间?(2)哪些活动是影响工程进度的关键?
活动可以并行进行,故 完成工程的最短时间 是从开始点到完成点的 最长路径的长度(这里说的路径长度是指路径上各活动持续时间之和)。路径长度最长的路径叫做 关键路径。
- 事件
的最早发生时间:从 (设为开始点) 到 的最长路径长度。这个时间决定了 所有以 为尾的弧所表示的活动的最早开始时间。 :活动 的最早开始时间 :活动的最迟开始时间,这是在推迟整个工程完成的前提下,活动 最迟必须开始进行的时间。- 两者之差
表示活动 的时间余量。 - 把
的活动叫做 关键活动。
关键路径上的所有活动都是关键活动,因此提前完成非关键活动并不能加快工程的进度。辨别关键活动就是要找到
求
(1)从
(2)从
这两个递推公式的计算必须分别在拓扑有序和逆拓扑有序的前提下进行。
AOE-网求关键路径和关键活动的算法
(1)输入
(2)从源点
(3)从汇点
(4)根据各顶点的
计算各顶点的
1 | Status TopologicalOrder(ALGraph G, Stack &T){ |
算法分析
设AOE网有n个事件,e个活动,则算法的主要执行是:
- 进行拓扑排序:时间复杂度是O(n+e)
- 求每个事件的ve值和vl值:时间复杂度是O(n+e)
- 根据ve值和vl值找关键活动:时间复杂度是O(n+e)
据ve值和vl值找关键活动:时间复杂度是O(n+e)
7.6 最短路径问题
7.6.1 从某个源点到其余各顶点的最短路径
单源点的最短路径问题:给定带权有向图

Dijkstra 算法
解决问题:单源点的最短路径
其主要思想是贪心,具体地说:
将所有节点分成两类:已确定从起点到当前点的最短路长度的节点,以及未确定从起点到当前点的最短路长度的节点(下面简称「未确定节点」和「已确定节点」)。
每次从「未确定节点」中取一个与起点距离最短的点,将它归类为「已确定节点」,并用它「更新」从起点到其他所有「未确定节点」的距离。直到所有点都被归类为「已确定节点」。
用节点 A「更新」节点 B 的意思是,用 起点到节点 A 的最短路长度 加上 从节点 A 到节点 B 的边的长度,去比较 起点到节点 B 的最短路长度,如果前者小于后者,就用前者更新后者。这种操作也被叫做「松弛」。
1 | void ShortestPath_DIJ(MGraph G, int v0, PathMatrix &P, ShortPathTable &D){ |
7.6.2 每一对顶点之间的最短路径
形式简单的算法:Floyd 算法。
1 | void ShortestPath_FLOYD(MGraph G, PathMatrix &P[], DistancMatrix &D) |
第9章 查找
关键字(Key):数据元素(或记录)中某个数据项的值,用它可以识别一个数据元素(或记录)
查找(Searching):根据给定的某个值,在查找表中确定一个其关键字等于给定值的记录或数据元素。若表中存在这样的记录,则称查找是成功的。
查找表(查找结构):用于查找的数据集合,由同一类型的数据元素组成。
对查找表经常进行的一般操作:
- 查询某个特定数据元素是否在查找表中
- 检索满足条件的某个特定的数据元素的各种属性
- 在查找表中插入一个数据元素
- 从查找表中删除某个数据元素
静态查找表:查找表的操作只涉及查询和检索。
查找方法:顺序查找、折半查找、散列查找等。
动态查找表:需要动态地插入或删除的查找表。
查找方法:二叉排序树的查找、散列查找等。
查找算法在查找成功时的 平均查找长度
对于含有 n 个记录的表,查找成功时的平均查找长度为
其中: 为查找表中第 i 个记录的概率,且 为找到表中其关键字与给定值相等的第 i 个记录时,和给定值已进行过比较的关键字个数。典型的关键字类型和数据元素类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17typedef float KeyType;
typedef int KeyType;
typedef char* KeyType;
typedef struct{
KeyType key; // 关键字域
... // 其他域
}SElemType;
// 对数值型关键字的比较
// 对字符串型关键字的比较
9.1 静态查找表
1 | ADT StaticSearchTable{ |
9.1.1 顺序查找
又称线性查找。对顺序表和链表都适用。
1.一般线性表的顺序查找
作为一种最直观的查找方法,其基本思想是从线性表的一端开始,逐个检查关键字是否满足给定的条件。
1 | typedef struct{ // 查找表的数据结构 |
上述算法中,将 ST.elem[0] 称为哨兵,引入它的目的是使得 Search_Seq 内的循环不必判断数组是否会越界。引入“哨兵”可以避免很多不必要的判断语句,从而提高程序效率。
顺序表查找的平均查找长度(各记录查找概率相等情况下):
查找不成功的比较次数显然是
- 有序表的顺序查找
有序线性表的顺序查找中,查找成功的平均查找长度和一般线性表的顺序查找一样。
查找失败的平均查找长度在相等查找概率的情形下为
9.1.2 折半查找
折半查找,又称二分查找,它仅适用于有序的顺序表。
折半查找的基本思想:首先将给定值 key 与表中中间位置的元素比较,若相等,则查找成功,返回该元素的存储位置;若不等,则所需查找的元素只能在中间元素以外的前半部分或后半部分。
1 | int Binary_Search(SSTable L, ElemType key){ |
折半查找的过程可用二叉树来描述,称为 判定树。树中每个圆形结点表示一个记录,结点中的值为该记录的关键字值;树中最下面的叶节点都是方形的,它表示查找不成功的情况。
- 查找成功时的查找长度为从根结点到目的节点的路径上的节点数
- 查找不成功时的查找长度为从根结点到对应失败结点的父结点的路径上的结点数。

【注】判定树也是二叉排序树,所以中序遍历的结果应该是有序的,且左子树的关键字值<根结点关键字值<右子树关键之值
查找成功的平均查找长度为
上图的判定树中,在等概率的情况下,查找成功(圆形结点)的
查找不成功(方形结点)的
9.1.3 分块查找
分块查找,又称索引顺序查找。吸取了顺序查找和折半查找各自的优点,既有动态结构,又适合于快速查找。
分块查找的基本思想:将查找表分为若干子块。块内元素可以无序,但块间的元素是有序的。第一块中的最大关键字小于第二块中所有记录的关键字,以此类推。
分块查找的过程分为两步:第一步是在索引表中确定待查记录所在的块,可以顺序查找或折半查找索引表,第二步是在块内顺序查找。
分块查找的平均查找长度为索引查找和块内查找的平均长度之和。设索引查找和块内查找的平均查找长度分别为
9.2 动态查找表
9.2.1 二叉排序树和平衡二叉树
二叉排序树,或者是一棵空树,或者是具有一下性质的二叉树:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值
- 它的左、右子树也分别为二叉排序树
二叉排序树的查找:
1 | BiTree SearchBST(BiTree T, KeyType key){ |
二叉排序树的插入,在查找不成功时进行插入,插入的结点一定是一个新添加的叶子结点,并且查找不成功时候查找路径上访问的最后一个结点一定是新添加的左孩子或右孩子结点。要对上面的查找函数进行一些修改。
1 | Status SearchBST(BiTree T, KeyType key, BiTree f, BiTree &p){ |
插入结点:
1 | Status InsertBST(BiTree&T, ElemType e){ |
二叉排序树既拥有类似于折半查找的特性,又采用链表作存储结构,因此是动态查找表的一个适宜表示。对于二叉排序树,删去树上一个结点相当于删去有序序列的一个记录,只要在删除某个结点之后依旧保持二叉排序树的特性即可。
删除结点的三种情况:(假设 p 为被删除结点,其双亲结点为 f,不失一般性,假设 p 是 f 的左孩子)
- 若 *p 结点为叶子结点,即
和 均有空树。由于删去叶子结点不破坏整棵树的结构,则只需修改其双亲结点的指针即可。 - 若 p 结点只有左子树
或者只有右子树 ,此时只要令 或 直接成为其双亲结点 f 的左子树即可。 - 若 *p 的结点的左子树和右子树均不空。有两种处理方法:
- 令 p 的左子树 为 f 的左子树,而 p 的右子树为 s 的右子树。(图(c))
- 令 p 的直接前驱(或直接后继)代替 p,然后再从二叉排序树中删去它的直接前驱(或直接后继)。(图(d))

综合上述情况得到的删除算法:
1 | Status DeleteBST(BiTree &T, KeyType key){ |
二叉排序树效率分析
成功:每个结点的深度相加除以结点个数
失败:每个结点的外点深度相加除以外点个数

对于上面这幅图,
平衡二叉树
平衡二叉树,又称 AVL 树,它或者是一棵空树,或者是具有以下性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。
二叉树上结点的平衡因子 BF(Balance Factor):该节点的左子树的深度减去它的右子树的深度,则平衡二叉树上所有结点的平衡因子只可能就是 -1、0 和 1。
平衡二叉树的插入
二叉排序树保证平衡的基本思想:每当在二叉树中插入(或删除)一个结点时,首先检查其插入路径上的结点是否因为这次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A,再以A为根的子树,再保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。
注意:每次调整的对象都是 最小不平衡子树。
平衡二叉树的插入过程的前半部分与二叉排序树相同,但在新结点插入后,若造成查找路径上某个结点不再平衡,则需要作出相应的调整。可将调整的规律归纳为下列 4 种情况:
LL 平衡旋转(右单旋转)
情况:由于在结点A的左孩子的左子树上插入了新结点,A的平衡因子由 1 增至 2,导致以 A 为根的子树失去平衡,需要一次向右的旋转操作。
操作:将A的左孩子 B 向上旋转代替 A 成为根结点,将 A 结点向右下旋转成为 B 的右子树的根结点,而B的原右子树则作为 A 结点的左子树。
RR平衡旋转(左单旋转)
情况:由于在结点 A 的右孩子的右子树上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。
操作:将 A 的右孩子 B 向左上旋转代替 A 成为根结点,将 A 结点向左下旋转成为 B 的左子树的根结点,而 B 的原左子树则作为 A 结点的右子树。
LR 平衡旋转(先左后右双旋转
情况:由于在 A 的左孩子 L 的右子树 R 上插入新结点,A 的平衡因子由 1 增至 2,导致以 A 为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。
操作:先将 A 结点的左孩子B的右子树的根结点 C 向左上旋转提升到 B 的位置,然后把该 C 结点向右上旋转提升到 A 结点的位置。
RL 平衡旋转(先右后左)
情况:由于 A 的右孩子 R 的左子树 L 上插入新结点,A 的平衡因子由 -1 减至 -2,导致以 A 为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。
操作:将 A 结点的右孩子 B 的左子树的根结点 C 向右上旋转提升到 B 结点的位置,然后把该 C 结点向左上旋转提升到 A 结点的位置。
平衡二叉树的删除
删除平衡二叉树的步骤,以删除结点 w 为例:
- 用二叉排序树的方法对结点 w 执行删除操作
- 若导致了不平衡,则从结点 w 开始向上回溯,找到第一个不平衡的结点 z(即最小不平衡子树):y 为结点 z 的高度最高的孩子结点 ,x 是结点 y 的高度最高的孩子结点。
- 然后对以 z 为根的子树进行平衡调整,其中 x、y 和 z 可能的位置有 4
种情况:
- y 是 z 的左孩子,x 是 y 的左孩子(LL,右单旋转)
- y 是 z 的左孩子,x 是 y 的右孩子(LR,先左后右双旋转)
- y 是 z 的右孩子,x 是 y 的右孩子(RR,左单旋转)
- y 是 z 的右孩子,x 是 y 的左孩子(RL,先右后左双旋转)
这四种情况与插入操做的调整方式一样。不同之处在于,插入操作仅需要以 z 为根的子树进行平衡调整;而删除操作就不一样,先对以 z 为根的子树进行平衡调整,如果调整后子树的高度减 1,则可能需要对 z 的祖先结点进行平衡调整,甚至回溯到根结点(导致树高减1)
平衡二叉树查找的分析
在平衡树上进行查找的时间复杂度为
9.3 散列表
散列表:根据关键字而直接进行访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。
散列函数:一个把查找表中的关键字映射成关键字对应的地址的函数,记为 Hash(key) = Addr
散列函数可能会把两个或两个以上的不同关键字映射到同一地址,这种情况称为 冲突,这些发生碰撞的不同关键字称为 同义词。
一般情况下,冲突只能尽可能地少,而不能完全避免。
9.3.1 散列函数的构造方法
直接定址法
取关键字或关键字的某个线性函数值为哈希地址。即:
其中,a 和 b 为常数或 数学分析法
平方取中法
折叠法
除留余数法
取关键字被某个不大于哈希表表长 m 的数 p 除后所得到余数为哈希地址。即:
一般情况下,可以 选 p 为质数 或不包含小于 20 的质因数的合数。随机数法
9.3.2 处理冲突的方法
开放定址法
其中: 为哈希函数;m 为哈希表表长,和 除留余数法的p不一样。 为增量序列,有下面三种取法:- 线性探测再散列:
- 二次探测再散列:
- 伪随机探测再散列:
= 伪随机探测序列
- 线性探测再散列:
再哈希法
链地址法
所有关键字为同义词的记录存储在同一线性链表中。建立指针型向量(数组),每个分量的初始状态都是空指针。
9.3.3 哈希表的查找及其分析
注:查找失败的对比数量要包括和空值的一次比较
10 内部排序
10.1 概述
如果按排序过程中依据的不同原则对内部排序方法进行分类,则大致可分为 插入排序、交换排序、选择排序、归并排序 和 计数排序 五类。
如果按内部排序过程中所需的工作量来区分,则可分为三类:
- 简单的排序方法,其时间复杂度为
- 先进的排序方法:时间复杂度为
- 基数排序:时间复杂度为
10.3 快速排序
快速排序 是对冒泡排序的一种改进。
基本思想:通过一趟排序将待排序记录分割成独立的两部分,其中一部分的关键字均比另一部分的关键字小,则可分别将这两部分记录继续进行排序,以达到整个序列有序。
一趟快速排序的具体做法:附设两个指针 low 和 high,他们的初值分别是 low 和 high,设 枢轴记录的关键字为 pivotkey,则首先从 high 所指位置向前搜索找到第一个关键字小于 pivotkey 的记录和枢轴记录相互交换,然后从 low 所指位置向后搜索,找到第一个关键字大于 pivotkey 的记录和枢轴记录互相交换,重复这两步直至 low = high 为止。
算法:
1 | int Partition(SqList &L, int low, int high){ |
具体实现上述算法,每交换一对记录需进行3次记录移动操作,而实际上,在排序过程中对枢轴记录的过程是多余的,因为只有在一趟排序结束时,即 low = high 的位置才是枢轴记录的最后位置。因此可改写上述算法,先将枢轴记录暂存在 r[0]的位置上,排序过程只作 r[low] 或 r[high] 的单向移动,直至一趟排序结束后再将枢轴记录移至正确位置上。
1 | int Partition(SqList &L, int low, int high){ |
排序过程举例
递归形式的快速排序算法:
1 | void QSort(SqList &L, int low, int high){ |
快速排序是所有内部排序算法中平均性能最优的排序算法。时间复杂度为
10.4 选择排序
10.4.1 简单选择排序
一趟简单选择排序的操作为:通过 n-i 次关键字间的比较,从 n-i+1
个记录中选出关键字最小的记录,并和第 i (
10.4.3 堆排序
堆排序只需要一个记录大小的辅助空间,每个待排序的记录仅占一个存储空间。
堆的定义如下:n 个元素的序列
实现堆排序需要解决两个问题:
- 如何由一个无序序列建成一个堆
- 如何在输出堆顶元素之后,调整剩余元素成为一个新的堆
例如,图 10.11所示,(a)是一个堆,假设输出栈顶元素之后,以堆中最后一个元素替代之,如(b)所示,此时根的左右子树均为堆,则仅需自上而下调整即可。首先以 堆顶元素和其左、右子树的根结点的值比较,由于右子树根结点的值小于左子树根结点的值,则将 27 和 97 交换。由于 97 替代了 27 之后破坏了右子树的 “堆”,则需进行上述同样的调整,直至叶子结点,调整后的状态如图(c)所示,此时堆顶为 n-1 个元素中的最小值。重复上述过程,将堆顶元素 27 和堆最后一个元素 97 交换且调整,得到 图(d) 所示的新堆。
这个过程称为 “筛选”。

从一个无序序列建堆的过程就是一个反复“筛选”的过程。若将此序列看成一个完全二叉树,则最后一个非终端结点是第
例如:图10.12(a) 中的二叉树表示一个具有 8 个元素的无序序列
1 | typedef SqList HeapType; |
算法效率分析
空间复杂度:仅适用了常数个辅助单元,空间复杂度为 O(1)
时间复杂度:
- 建堆时间为 O(n)
- 之后有 n-1 次向下调整的操作,每次调整的时间复杂度为
O(h),h为堆的树高,故在最好、最坏和平均情况下,堆排序的时间复杂度为
稳定性:是一个 不稳定 的排序方法
10.6 基数排序
基数排序 不需要进行关键字间的比较。基数排序 是一种借助多关键字排序的思维,对单逻辑关键字进行排序的方法。
10.6.1 多关键字的排序
假设有 n 个记录的序列
- 第一种方法,最高位优先法,MSD 法:先对最主位关键字
进行排序,将序列分成若干子序列,每个子序列记录都具有相同给 值,然后分别就每个子序列对关键字 进行排序,按 的值分成若干更小的子序列,依次重复,直至对 进行排序之后得到的每一个子序列中的记录都具有相同的关键字 ,而后分别每个子序列对 进行排序,最后再对高一位的关键字 进行排序。最后将所有子序列一次联接在一起成为一个有序序列。 - 第二种方法,最低位优先法,LSD 法:从最次位关键字
进行排序。然后再对高一位的关键字 进行排序,依次重复,直至对 进行排序后便成为一个有序序列。
10.6.2 链式基数排序
基数排序,是借助 分配 和 收集 两种操作对 单逻辑关键字 进行排序。
有的单逻辑关键字可以看成若干个关键字复合而成的。例如,若关键字是数值,且其值都在
只要从最低数位关键字起,按关键字的不同值将序列种记录 “分配”到 RADIX
个队列中后再“收集”之,如此重复
1 |
|
f[i] 和 e[i] 分别为 第 i 个队列的头指针和尾指针
链式基数排序中一趟分配的算法:
1 | void Distribute(SLCell &r, int i, ArrType &f, ArrType &e){ |
10.7 各种内部排序方法的比较讨论

- 稳定的内部排序:直接插入排序、冒泡排序、2路归并排序、基数排序
- 不稳定的内部排序:简单选择排序、写入排序、快速排序
- 标题: 数据结构
- 作者: 卡布叻_米菲
- 创建于 : 2024-01-13 09:29:29
- 更新于 : 2024-02-08 11:44:28
- 链接: https://carolinebaby.github.io/2024/01/13/数据结构/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。