1.1什么是数据结构
是一门研究非数值计算的程序设计问题中计算机的操作对象以及它们之间的关系和操作等的学科
1.2基本概念和术语
1.数据:数据是对客观事物的符号表示,在计算机科学中指所有能输入到 计算机中并被计算机程序处理的符号的总称。
数值性数据:如整数、实数……主要应用于工程计算
非数值性数据:如文字、图形、语音……
2.数据元素:数据的基本单位。在计算机程序中常作为一个整体进行考虑和处理。有时一个数据元素可以由若干数据项(Data Item)组成。数据元素又称为元素、结点、记录。数据项是数据的不可分割的最小单位。
3.数据对象:性质相同的数据元素的集合。
4.数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。记为:DataStructure = (D, S) 其中,D 是数据元素的有限集,S 是D(该集合中所有数据元素)上关系的有限集。
5.数据的(逻辑)结构:是数据元素相互之间的关系,数据的逻辑结构可以看作是从具体问题抽象出来的数学模型
6.数据的存储结构/物理结构:数据结构在计算机中的表示(映像)。
顺序存储结构:借助元素在存储器中的相对位置来表示数据元素之间的逻辑关系。所有元素存放在一片连续的存储单元中,逻辑上相邻的元素存放到计算机内存仍然相邻。
链式存储结构:借助指示元素存储地址的指针表示数据元素之间的逻辑关系。所有元素存放在可以不连续的存储单元中,但元素之间的关系可以通过地址确定,逻辑上相邻的元素存放到计算机内存后不一定是相邻的。
1.3抽象数据类型的表示与实现
1.数据类型:定义:一组性质相同的值的集合, 以及定义于这个值集合上的一组操作的总称。
数据类型分为:原子类型和结构类型。
原子类型可以看作是计算机中已实现的数据类型。
结构类型也叫构造数据类型,由不同成员构成。
2.抽象数据类型:定义:是指一个数学模型以及定义在此数学模型上的一组操作。
特征: 抽象性 用ADT描述程序处理的实体时,强调的是其本 质的特征、其所能完成的功能以及它和外部用户的接 口(即外界使用它的方法)
封装性 将实体的外部特性和其内部实现细节分离,并且对外 部用户隐藏其内部实现细节。
3.抽象数据类型的表示和实现:ADT 抽象数据类型名{
数据对象:<数据对象的定义>
数据关系:<数据关系的定义>
基本操作:<基本操作的定义>
//基本操作名(参数表) //初始条件:<初始条件描述> //操作结果:<操作结果描述> }ADT 抽象数据类型名
1.4算法和算法分析
1.算法:定义:对特定问题求解步骤的一种描述。
性质:有穷性:一个算法必须总是在执行有穷步之后结束,且每一步都可在有穷时间内完成。
确定性:算法中每一条指令必须有确切的含义,不会产生二义性
可行性:算法中描述的操作可以通过已经实现的基本运算执行有限次来实现。
输入:一个算法有零个或多个输入。
输出:一个算法有零个或多个输出
2.算法描述方法:用自然语言描述算法:用我们日常生活中的自然语言(可以是中文形式,也可以是英文形式)也可以描述算法
用流程图描述算法:一个算法可以用流程图的方式来描述,输入输出、判断、处理分别用不同的框图表示,用箭头表示流程的流向。这是一种描述算法的较好方法,目前在一些高级语言程序设计中仍然采用。也有其他的图形辅助工具
用其它方式描述算法:我们还可以用数学语言或约定的符号语言来描述算法
3.算法效率的度量:事后统计:计算机内部计时功能。
事前分析 算法选用的策略
问题的规模
书写程序的语言——级别越高,效率越低
编译程序产生的机器代码的质量
机器执行指令的速度
4.时间复杂度(渐近时间复杂度):从算法中选取一种对于所研究的问题是基本操作的原操作,以该基本操作重复执行的次数作为算法的时间度量。算法的执行时间和该基本操作(乘法)重复执行的次数n3成正比,记作T(n)=O(n3)。
算法中基本操作重复执行的次数是问题规模n的某个函数f(n),算法的时间复杂度记作:T(n)=O(f(n)) 时间复杂度表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同。
5.空间复杂度:算法的空间复杂度定义为:S(n) = O(f(n))
表示随着问题规模 n 的增大,算法运行所需存储量S(n)的增长率与 f(n) 的增长率相 同,称S(n)(渐近)空间复杂度。
线性表
一、线性表的类型定义
(一)线性表的定义
- 线性表是n(≥0)个数据元素的有限序列,记作(a_1, a_2, \cdots, a_n),其中a_i是线性表中数据元素,n是线性表长度。a_i称为a_{i + 1}的直接前驱,a_{i + 1}称为a_i的直接后继,i为数据元素a_i在线性表中的位序。
- 例如:
- 26个英文字母的字母表:(A,B,C,…,Z)是字符类型的线性表。
- 学校从1978年到1983年各种类型号的计算机拥有量的变化情况,可用整型线性表形式表示:(6,17,28,50,92,188)。
(二)线性表的特点
1. 除第一个元素外,其他每一个元素有一个且仅有一个直接前驱。
2. 除最后一个元素外,其他每一个元素有一个且仅有一个直接后继。
(三)抽象数据类型线性表定义
- 数据对象:D = \{a_i | a_i \in ElemSet, i = 1,2, \cdots, n, n \geq 0\}
- 数据关系:R1 = \{<a_{i - 1}, a_i> | a_{i - 1}, a_i \in D, i = 1,2, \cdots, n\}
- 基本操作:
- InitList(&L):构造一个空的线性表L。
- DestroyList(&L):销毁线性表L(初始条件:线性表L已存在)。
- ClearList(&L):将线性表L重置为空表(初始条件:线性表L已存在)。
- ListEmpty(L):若L为空表,则返回TRUE,否则返回FALSE(初始条件:线性表L已存在)。
- ListLength(L):返回L中数据元素个数(初始条件:线性表L已存在)。
- GetElem(L, i, &e):用e返回L中第i个数据元素的值(初始条件:线性表L已存在,1≤i≤ListLength(L))。
- LocateElem(L, e, compare()):返回L中第一个与e满足关系compare()的数据元素的位序,若不存在则返回值为0(初始条件:线性表L已存在,compare()是数据元素判定函数)。
- PriorElem(L, cur_e, &pre_e):若cur_e是L的数据元素且不是第一个,则用pre_e返回它的前驱,否则操作失败,pre_e无定义(初始条件:线性表L已存在)。
- NextElem(L, cur_e, &next_e):若cur_e是L的数据元素且不是最后一个,则用next_e返回它的后继,否则操作失败,next_e无定义(初始条件:线性表L已存在)。
- ListInsert(&L, i, e):在L中第i个位置之前插入新的数据元素e,L的长度加1(初始条件:线性表L已存在,1≤i≤ListLength(L)+1)。
- ListDelete(&L, i, &e):删除L的第i个数据元素,并用e返回其值,L的长度减1(初始条件:线性表L已存在且非空,1≤i≤ListLength(L))。
- ListTraverse(L, visit()):依次对L的每个数据元素调用函数visit(),一旦visit()失败,则操作失败(初始条件:线性表L已存在)。
(四)线性表基本操作示例
1. 求并集
- 假设利用两个线性表LA和LB分别表示两个集合A和B,现要求一个新的集合A = A \cup B。操作过程为扩大线性表LA,将存在于线性表LB中而不存在于线性表LA中的数据元素插入到线性表LA中。
- 算法描述如下:
void union(List &la,List Lb){
//将所有在线性表Lb中但不在La中的数据元素插入到La中
La.len = ListLength(La);Lb.len = ListLength(Lb);//求线性表的长度
for(i = 1;i <= Lb.len;i++){
GetElem(Lb,i,e); //取Lb中第i个数据元素赋给e
if(!LocateElem(La,e,equal)) ListInsert(La,++La.len,e);
// La中不存在和e相同的数据元素,则插入之
}
}
2. 归并有序表
- 已知线性表LA和LB中的数据元素按值非递减有序排列,现要求将LA和LB归并为一个新的线性表LC,且LC中的数据元素仍按值非递减有序排列。
- 算法描述如下:
void MergeList(List La,List Lb, List &Lc){
//已知线性表La和Lb中的数据元素按值非递减排列。
//归并La和b得到新的线性表Lc,Lc的数据元素也按值非递减排列。
InitList(Lc);
i = j = 1;k = 0;
La.len = ListLength(La);Lb.len = ListLength(Lb);
while((1 <= La.len)&&(j <= Lb.len)){//La和Lb均非空
GetElem(La,i,ai);GetElem(Lb,j,bj);
if(ai <= bj){ListInsert(Lc,++k,ai);++i;}
else {ListInsert(Lc,++k,bj);j++;}
}
while (i <= La.len){
GetElem(La,i++,ai);ListInsert(Lc,++k,ai);
}
while(j <= Lb_len){
GetElem(Lb,j++,bj);ListInsert(Lc,++k,bj);
}
}
二、线性表的顺序表示和实现
(一)顺序表的定义和特点
1. 定义:用一组地址连续的存储单元依次存储线性表的数据元素。
2. 特点:
- 逻辑相邻的两个元素在物理位置上也相邻,可以随机存取表中任一元素。
- 存储结构是一种随机存取的存储结构。
(二)顺序存储结构及初始化
1. 相关定义
#define LIST_INIT_SIZE 100//线性表存储空间的初始分配量
#define LISTINCREMENT 10//线性表存储空间的分配增量
typedef struct{
ElemType elem; //存储空间基址
int length; //当前长度
int listsize; //当前分配的存储容量(以sizeof(ElemType)为单位)
}SqList;
2. 初始化操作
Status 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;
}
(三)顺序表插入
1. 操作描述:在顺序线性表L中第i个位置之前插入新的元素e,i的合法值为1≤i≤ListLength_Sq(L)+1。
2. 算法实现
Status ListInsert_Sq(SqList &L,int i,ElemType e){
if((1 < 1)||(i > L.length + 1)) return ERROR;//i值不合法
if(L.length >= L.listsize){//当前存储空间已满,增加分配
newbase =(ElemType *)realloc(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;
}
(四)顺序表删除
1. 操作描述:在顺序线性表中删除第i个元素,并用e返回其值,i的合法值为1≤i≤ListLength_Sq(L)。
2. 算法实现
Status ListDelete_Sq(SqList &L,int i,ElemType &e){
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;
}
(五)插入删除操作的时间复杂度
1. 插入
- 平均次数E_{is}=\sum_{i = 1}^{n + 1}p_i(n - i + 1),p_i为插入元素i的概率。
- 当概率相等时,即p_i=\frac{1}{n + 1}时,E_{is}=\frac{1}{n + 1}\sum_{i = 1}^{n + 1}(n - i + 1)=\frac{n}{2}。
2. 删除
- 平均次数E_{dl}=\sum_{i = 1}^{n}q_i(n - i),q_i为删除元素i的概率。
- 当概率相等时,即q_i=\frac{1}{n}时,E_{dl}=\frac{1}{n}\sum_{i = 1}^{n}(n - i)=\frac{n - 1}{2}。
3. 插入删除的时间复杂度为O(n)。
(六)线性表顺序存储结构的特点
1. 优点:逻辑相邻的两个元素在物理位置上也相邻,可以随机存取表中任一元素。
2. 缺点:在插入和删除操作时,需移动大量元素。
三、线性表的链式表示和实现
(一)线性链表
1. 线性表链式存储结构的特点:用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。
2. 单链表(Singly Linked List)
- 概念:用一组地址任意的存储单元存放线性表中的数据元素,以“结点的序列”表示线性表,结点包含数据域(data)和指针域(next)。
- 存储结构
typedef struct LNode {
ElemType data; //数据域
struct LNode *next; //指针域
} LNode, *LinkList;
LinkList L; //L为单链表的头指针
- 相关操作
- 以线性表中第一个数据元素的存储地址作为线性表的地址,称作线性表的头指针。有时为操作方便,在第一个结点之前虚加一个“头结点”,这时指向头结点的指针称为链表的头指针。
- GetElem(L,i,&e)实现
Status GetElem_L(LinkList L, int i, ElemType &e) {
//L是带头结点的链表的头指针,以e返回第i个元素
p = L->next; j = 1; //p指向第一个结点,j为计数器
while (p && j < i) {
p = p->next; ++j;
} //顺指针向后查找,直到p指向第i个元素或p为空
if (!p || j > i ) return ERROR; //第i个元素不存在
e = p->data; //取得第i个元素
return OK;
}
//时间复杂度:O(ListLength(L))
- 单链表的插入
Status ListInsert_L(LinkList &L, int i, ElemType e) {
//L为带头结点的单链表的头指针,本算法在链表中第i个结点之前插入新的元素e
p = L; j = 0;
while (p && j < i - 1) {
p = p->next; ++j;
} //寻找第i - 1个结点
if (!p || j > i - 1)
return ERROR; //i大于表长或者小于1
s = (LinkList)malloc(sizeof(LNode)); //生成新结点
s->data = e; s->next = p->next; p->next = s; //插入L中
return OK;
}
//时间复杂度:O(ListLength(L))
- 单链表的删除
Status ListDelete_L(LinkList &L, int i, ElemType &e) {
//删除以L为头指针(带头结点)的单链表中第i个结点
p = L; j = 0;
while (p->next && j < i - 1) {
p = p->next; ++j;
}
//寻找第i个结点,并令p指向其前趋
if (!(p->next) || j > i - 1) return ERROR; //删除位置不合理
q = p->next; p->next = q->next; //删除并释放结点
e = q->data; free(q);
return OK;
}
//时间复杂度:O(ListLength(L))
- ClearList(&L)的实现
void ClearList(&L) {
//将单链表重新置为一个空表
while (L->next) {
p = L->next; L->next = p->next;
}
}
//时间复杂度:O(ListLength(L))
- 逆向建立单链表
void CreateList_L(LinkList &L, int n) {
//逆序输入n个数据元素,建立带头结点的单链表
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; //插入
}
}
//时间复杂度:O(n)
- 有序单链表归并
void 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的头结点
}
(二)循环链表(Circular List)
1. 循环链表是单链表的变形,最后一个结点的next指针不为NULL,而是指向了表的前端。
2. 为简化操作,在循环链表中往往加入表头结点。
3. 特点:只要知道表中某一结点的地址,就可搜寻到所有其他结点的地址。
(三)双向链表(Doubly Linked List)
1. 双向链表是指在前驱和后继方向都能游历(遍历)的线性链表。
2. 每个结点结构包含前驱指针(prior)、数据域(element)和后继指针(next)。
3. 双向链表通常采用带表头结点的循环链表形式。
4. 相关操作
- 双向链表插入
Status ListInsert_DuL(DLinkList &L,int i, ElemType e){
//在带头结点的双链循环线性表L中第i个位置之前插入元素e,
//i的合法值为1 <表长+1。
if (!(p = GetElem_DuL(L,i)))//在L中确定插入位置指针p
return ERROR; //i等于表长加1时,指向头结点;i大于表长加1时,p = NULL
if(!(s = (DuLinkList)malloc(sizeof(DLNode)))) return ERROR;
s->data
栈和队列
一、基本概念
(一)定义
栈和队列是限定插入和删除只能在表的“端点”进行的线性表,是两种常用的线性表结构。
(二)特点
1. 栈:限定仅在表尾进行插入和删除操作,允许插入和删除的一端(表尾)称为栈顶(top),另一端(表头)称为栈底(bottom),特点是后进先出(LIFO)。
2. 队列:一种先进先出(FIFO, First In First Out)的线性表,只允许在一端删除(队头front),在另一端插入(队尾rear)。
二、栈(Stack)
(一)抽象数据类型栈的定义
1. 数据对象:D = \{a_i | a_i \in ElemSet, i = 1,2, \cdots, n, n \geq 0\}
2. 数据关系:R1 = \{<a_{i - 1}, a_i> | a_{i - 1}, a_i \in D, i = 1,2, \cdots, n\},约定a_n端为栈顶,a_1端为栈底。
3. 基本操作
- InitStack(&S):构造一个空栈S。
- DestroyStack(&S):销毁栈S(初始条件:栈S已存在)。
- ClearStack(&S):将S清为空栈(初始条件:栈已存在)。
- StackEmpty(S):如栈S为空,则返回true,否则返回false(初始条件:栈S已存在)。
- StackLength(S):返回S的元素个数,即栈的长度(初始条件:栈S已存在)。
- GetTop(S, &e):用e返回S的栈顶元素(初始条件:栈S已存在且非空)。
- Push(&S, e):插入元素e为新的栈顶元素(初始条件:栈已存在)。
- Pop(&S, &e):删除栈顶元素,并用e返回其值(初始条件:栈已存在且非空)。
- StackTraverse(S, visit()):从栈底到栈顶依次对栈的每个元素调用函数(*visit)(初始条件:栈已存在且非空)。
(二)栈的表示和实现
1. 顺序栈
- 利用一组地址连续的存储单元(数组)依次存放从栈底到栈顶的数据元素,同时附设指针top指示栈顶元素在顺序栈中的位置。
- 定义
c++
#define STACK_INIT_SIZE 100 //存储空间初始分配量
#define STACKINCREMENT 10 //存储空间分配增量
typedef struct {
SElemType *base; //在栈构造之前和销毁之后,base的值为NULL
SElemType *top; //栈顶指针
int stacksize; //当前已分配的存储空间,以元素为单位
} SqStack;
- 部分基本操作算法描述
c++
Status InitStack(SqStack &S) {
//构造一个空栈S
S.base = (SElemType *)malloc(STACK_INIT_SIZE * sizeof(SElemType));
if (!S.base) exit(OVERFLOW); //存储分配失败
S.top = S.base;
S.stacksize = STACK_INIT_SIZE;
return OK;
}
Status GetTop(SqStack S, SElemType &e) {
//若栈不空,则用e返回S的栈顶元素,并返回OK;否则返回ERROR
if (S.top == S.base) return ERROR;
e = *(S.top - 1);
return OK;
}
Status Push(SqStack &S, SElemType e) {
//插入元素e为新的栈顶元素
if (S.top - S.base >= S.stacksize) { //栈满,追加存储空间
S.base = (SElemType *)realloc(S.base,
(S.stacksize + STACKINCREMENT) * sizeof(SElemType));
if (!S.base) exit(OVERFLOW); //存储分配失败
S.top = S.base + S.stacksize;
S.stacksize += STACKINCREMENT;
}
*S.top++ = e;
return OK;
}
Status Pop(SqStack &S, SElemType &e) {
//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR
if (S.top == S.base) return ERROR;
e = *--S.top;
return OK;
}
2. 链式栈
- 无栈满问题,空间可扩充,插入与删除仅在栈顶处执行,栈顶在链头。
- 入栈和出栈操作简单,一般不使用头结点直接实现。
(三)栈的应用举例
1. 数制转换
- 十进制数N和其他d进制数转换规则:N=(N div d)×d + N mod d(其中:div为整除运算,mod为求余运算)。
- 例如:(1348)_{10}=(2504)_8,其运算过程如下:
| N | N div 8 | N mod 8 |
| --- | --- | --- |
| 1348 | 168 | 4 |
| 168 | 21 | 0 |
| 21 | 2 | 5 |
| 2 | 0 | 2 |
- 算法实现
c++
void conversion() {
//对于输入的任意一个非负十进制整数,打印输出与其等值的八进制数
InitStack(S); //构造空栈
scanf("%d", &N);
while (N) {
Push(S, N % 8);
N = N / 8;
}
while (!StackEmpty(S)) {
Pop(S, e);
printf("%d", e);
}
}
2. 括号匹配的检验
- 判别用字符串表示的表达式中括号(,), [,], {,}是否配对出现。例如字符串{a*[b+c*(e-f])}中括号不匹配。
三、队列(Queue)
(一)队列的定义
1. 数据对象:D = \{a_i | a_i \in ElemSet, i = 1,2, \cdots, n, n \geq 0\}
2. 数据关系:R1 = \{<a_{i - 1}, a_i> | a_{i - 1}, a_i \in D, i = 1,2, \cdots, n\},约定a_1端为队列头,a_n端为队列尾。
3. 基本操作
- InitQueue(&Q):构造一个空队列Q。
- DestroyQueue(&Q):销毁队列Q(初始条件:队列Q已存在)。
- ClearQueue(&Q):清空队列(初始条件:队列Q已存在)。
- QueueEmpty(Q):若Q为空队列,则返回true,否则返回false(初始条件:队列Q已存在)。
- QueueLength(Q):返回Q的元素个数,即队列的长度(初始条件:队列Q已存在)。
- GetHead(Q, &e):用e返回Q的队头元素(初始条件:Q为非空队列)。
- EnQueue(&Q, e):插入元素e为Q的新的队尾元素(初始条件:队列Q已存在)。
- DeQueue(&Q, &e):删除Q的队头元素,并用e返回其值(初始条件:Q为非空队列)。
- QueueTraverse(Q, visit()):从队头到队尾依次对队列的每个元素调用函数visit()(初始条件:Q已存在且非空)。
(二)链队列
1. 基本概念
- 链式队列在入队时无队满问题,但有队空问题,队空条件为front == rear。
2. 定义
c++
typedef struct QNode {
QElemType data;
struct QNode *next;
} QNode, *QueuePtr;
typedef struct {
QueuePtr front; //队头指针
QueuePtr rear; //队尾指针
} LinkQueue;
3. 部分基本操作算法描述
c++
Status InitQueue(LinkQueue &Q) {
//构造一个空队列Q
Q.front = Q.rear = (QueuePtr)malloc(sizeof(QNode));
if (!Q.front) exit(OVERFLOW); //存储分配失败
Q.front->next = NULL;
return OK;
}
Status DestroyQueue(LinkQueue &Q) {
//销毁队列Q
while (Q.front) {
Q.rear = Q.front->next;
free(Q.front);
Q.front = Q.rear;
}
return OK;
}
Status EnQueue(LinkQueue &Q, QElemType e) {
//插入元素e为Q的新的队尾元素
p = (QueuePtr)malloc(sizeof(QNode));
if (!p) exit(OVERFLOW); //存储分配失败
p->data = e;
p->next = NULL;
Q.rear->next = p;
Q.rear = p;
return OK;
}
Status DeQueue(LinkQueue &Q, QElemType &e) {
//若队列不空,则删除Q的队头元素,用e返回其值,并返回OK;否则返回ERROR
if (Q.front == Q.rear) return ERROR;
p = Q.front->next;
e = p->data;
Q.front->next = p->next;
if (Q.rear == p) Q.rear = Q.front;
free(p);
return OK;
}
(三)循环队列(顺序存储)
1. 顺序队列的问题
- 入队和出队操作时,队头front和队尾rear指针移动,可能出现“假溢出”,即队列实际可用空间未用完,但无法插入新元素。
2. 循环队列的解决方法
- 将队列元素存放数组首尾相接,形成循环(环形)队列。队头front、队尾rear自加1时从MAXSIZE - 1直接进到0,可用语言的取模(余数)运算实现。队头front自加1:front = (front + 1) % MAXSIZE;队尾rear自加1:rear = (rear + 1) % MAXSIZE。
3. 循环队列空与满的判定
- 循环队列空:Q.front = Q.rear。
- 循环队列满:Q.front = Q.rear。判定方法:
- 另设一个标志位以区别队列是“空”还是“满”。
- 少用一个元素空间,约定以“队列头指针front在队列尾指针rear的下一个位置上”作为队列“满”的标志。
4. 循环队列的定义
c++
#define MAXQSIZE 100 //最大队列长度
typedef struct {
QElemType *base; //初始化的动态分配存储空间
int front; //头指针,若队列不空,指向队列头元素
int rear; //尾指针,若队列不空,指向队列尾元素的下一个位置
} SqQueue;
5. 循环队列基本操作的算法描述
c++
Status InitQueue(SqQueue &Q) {
//构造一个空队列Q
Q.base = (QElemType *)malloc(MAXQSIZE * sizeof(QElemType));
if (!Q.base) exit(OVERFLOW); //存储分配失败
Q.front = Q.rear = 0;
return OK;
}
int QueueLength(SqQueue Q) {
//返回Q的元素个数,即队列的长度
return (Q.rear - Q.front + MAXQSIZE) % MAXQSIZE;
}
Status EnQueue(SqQueue &Q, QElemType e) {
if ((Q.rear + 1) % MAXQSIZE == Q.front) return ERROR; //队列满
//插入元素e为Q的新的队尾元素
Q.base[Q.rear] = e;
Q.rear = (Q.rear + 1) % MAXQSIZE;
return OK;
}
Status DeQueue(SqQueue &Q, QElemType &e) {
//若队列不空,则删除Q的队头元素,用e返回其值,并返回OK;否则返回ERROR
if (Q.front == Q.rear) return ERROR;
e = Q.base[Q.front];
Q.front = (Q.front + 1) % MAXQSIZE;
return OK;
}
5.1数组
数组元素的存储顺序
3. 以行序为主序(按行排列):先排最右的下标,依次向左,最后排最左的下标。
4. 以列序为主序(按列排列):先排最左的下标,依次向右,最后排最右的下标。
C中二维数组元素的存储位置
- 二维数组a[b1][b2]中任一元素ai,j的存储位置Loc(i, j)=Loc(0,0)+(b_{2} × i+j) × L,称为基地址或基址。
一般意义二维数组元素的存储位置
- 对于一般意义二维数组a[c_{1}: d_{1}, c_{2}: d_{2}],设每个元素占用L个存储单元,Loc(c_{1}, c_{2})是第一个元素a_{c1c2}的存储位置。
- 按行存放时,a_{ij}的存储位置为:Loc(i, j)=Loc(c_{1}, c_{2})+[(d_{2}-c_{2}+1)(i-c_{1})+(j-c_{2})]L
- 按列存放时,a_{ij}的存储位置为:Loc(i, j)=Loc(c_{1}, c_{2})+[(d_{1}-c_{1}+1)(j-c_{2})+(i-c_{1})]L
C中n维数组元素的存储位置
- 推广到一般情况,可得到n维数组数据元素按行序的存储位置有如下关系:
\begin{aligned} Loc\left(j_{1}, j_{2},..., j_{n}\right)= & Loc(0,0,..., 0)+\left[j_{1} × b_{2} ×... × b_{n}\right. \\ & +j_{2} × b_{3} ×... × b_{n} \\ &...... \\ & +j_{n-1} × b_{n} \\ & \left.+j_{n}\right] L & =Loc(0,0,..., 0)+i \frac{\sum}{2} c_{i} j_{i} \end{aligned},其中c_{n}=L,c_{i - 1}=b_{i} × c_{i},1 < i ≤ n。
一般意义n维数组元素的存储位置
- 对于n维数组的一般情况a[c_{1}: d_{1}, c_{2}: d_{2},..., c_{n}: d_{n}],设每个元素占用L个存储单元,Loc(c_{1}, c_{2},..., c_{n})是第一个元素a_{c1c2...cn}的存储位置。
- 按行存放时,a_{j1j2...jn}的存储位置如下:
\begin{aligned} Loc\left(j_{1} j_{2}... j_{n}\right)=Loc\left(c_{1}, c_{2},\right. & \left...., c_{n}\right)+\left[\left(j_{1}-c_{1}\right)\left(d_{2}-c_{2}+1\right)...\left(d_{n}-c_{n}+1\right)\right. \\ & +\left(j_{2}-c_{2}\right)\left(d_{3}-c_{3}+1\right)...\left(d_{n}-c_{n}+1\right) \\ &...... \\ & +\left(j_{n-1}-c_{n-1}\right)\left(d_{n}-c_{n}+1\right) \\ & \left.+\left(j_{n}-c_{n}\right)\right] L \end{aligned}
- 按列存放时,a_{j1j2...jn}的存储位置如下:
5.2矩阵
特殊矩阵
- 特殊矩阵指元素(特别是非零元素)在矩阵中的分布有一定规则,例如:对称矩阵、三角矩阵、三对角矩阵。
对称矩阵的压缩存储
- 按行序为主序:
k=\left\{\begin{array}{c}i(i - 1) / 2 + j - 1, i \geq j \\ j(j - 1) / 2 + i - 1, i < j\end{array}\right.
三角矩阵的压缩存储
- 按行序为主序:
k=\frac{i(i - 1)}{2}+j - 1
稀疏矩阵(Sparse Matrix)
- 稀疏矩阵是非零元素个数远远少于矩阵元素个数的矩阵,如A_{6 × 7}=\left(\begin{array}{ccccccc}0 & 0 & 0 & 22 & 0 & 0 & 15 \\ 0 & 11 & 0 & 0 & 0 & 17 & 0 \\ 0 & 0 & 0 & -6 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 39 & 0 \\ 91 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 28 & 0 & 0 & 0 & 0\end{array}\right)
稀疏矩阵的问题及解决原则
- 以常规方法表示高阶稀疏矩阵时产生的问题:
- 零值元素占了很大空间;
- 计算中进行了很多和零值的运算,遇除法还需判别除数是否为零。
- 解决问题的原则:
- 尽可能少存或不存零值元素;
- 尽可能减少没有实际意义的运算;
- 操作方便,即能尽可能快地找到与下标值(i,j)对应的元素。
稀疏矩阵基本操作
7. int GetRows() const:初始条件为稀疏矩阵已存在,操作结果是返回稀疏矩阵行数。
8. int GetCols() const:初始条件为稀疏矩阵已存在,操作结果是返回稀疏矩阵列数。
9. int GetNum() const:初始条件为稀疏矩阵已存在,操作结果是返回稀疏矩阵非零元素个数。
10. bool Empty() const:初始条件为稀疏矩阵已存在,若稀疏矩阵为空,则返回true,否则返回false。
11. bool SetElem(int r, int c, const ElemType &v):初始条件为稀疏矩阵已存在,操作结果是设置指定位置的元素值。
12. bool GetElem(int r, int c, ElemType &v):初始条件为稀疏矩阵已存在,操作结果是求指定位置的元素值。
稀疏矩阵的存储方式
- 稀疏矩阵的顺序压缩存储:三元组表示法。
- 稀疏矩阵的链式压缩存储:十字链表。
三元组顺序表
- 对每个非零元素,用三元组(行号,列号,元素值)来表示,以顺序表存储三元组表,可得到稀疏矩阵的顺序存储结构 - 三元组顺序表。在三元组顺序表中,用三元组表表示稀疏矩阵时,为避免丢失信息,增设了一个信息元组,形式为:(行数,列数,非零元素个数)。
三元组顺序表示例
已知稀疏矩阵a=\left[\begin{array}{llllll}0 & 0 & 2 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 8 \\ 1 & 0 & 3 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 \\ 4 & 0 & 6 & 0 & 0 & 0\end{array}\right],其三元组表示为\left[\begin{array}{lll} 5 & 6 & 6 \\ 1 & 3 & 2 \\ 2 & 6 & 8 \\ 3 & 1 & 1 \\ 3 & 3 & 3 \\ 5 & 1 & 4 \\ 5 & 3 & 6 \end{array}\right]
稀疏矩阵三元组顺序表类模板
稀疏矩阵的转置
- 算法基本思想:第一次从转置前稀疏矩阵source中取出应放置到转置后稀疏矩阵dest中第一个位置的元素,行列号互换后放入dest中第一个位置;第二次从source中选取应放到dest中第二个位置的元素,依次类推。由于转置后列号变行号,所以转置后元素的行序排列实质上是原矩阵元素的列序排列。实现算法可形式化描述为:
destPos = 0; // 稀疏矩阵dest的下一个三元组的存放位置
for(col = 最小列号; col <=最大列号; col++)
{
在source中从头查找有无列号为col的三元组;
若有,则将其行、列号交换后,依次存入dest中destPos所指位置,同时destPos加1;
}
稀疏矩阵的转置实现
十字链表
5.3广义表
广义表基础
- 广义表通常简称为表,由n(n >= 0)个表元素组成的有限序列,记作GL=(a_{1}, a_{2}, a_{3},..., a_{n}),GL是表名,a_{i}为表元素,可分为子表元素(子表)和数据元素(原子)。
- n为表的长度,n = 0的广义表为空表。
- n > 0时,表的第一个表元素称为广义表的表头(head),其他表元素组成的表称为广义表的表尾(tail)。例如G=((a,(b, c)), x,(y, z))
广义表的深度
- 广义表GL的深度Depth(GL)定义如下:
Depth(GL)=\begin{cases}0 & GL为原子元素 \\ 1 & GL为空表 \\ 1 + Max(Depth(a_{i}) | 1 \leq i \leq n) & 其它情况\end{cases}
广义表的深度本质上就是广义表表达式中括号的最大嵌套层数,如2深度为0(括号层数为0),()深度为1(括号层数为1),(2, (3, 6))深度为2(括号层数为2)。
广义表基本操作
7. GenListNode *First() const:初始条件为广义表已存在,操作结果是返回广义表的第一个元素。
8. GenListNode *Next(GenListNode *elemPtr) const:初始条件为广义表已存在,elemPtr指向广义表的元素,操作结果是返回elemPtr指向的广义表元素的后继。
9. bool Empty() const:初始条件为广义表已存在,若广义表为空,则返回true,否则返回false。
10. void Push(const ElemType &e):初始条件为广义表已存在,操作结果是将原子元素e作为表头加入到广义表最前面。
11. void Push(GenList &subList):初始条件为广义表已存在,操作结果是将子表subList作为表头加入到广义表最前面。
12. int Depth():初始条件为广义表已存在,操作结果是返回广义表的深度。
广义表的存储结构
- 广义表通常借助引用数链式存储结构 - 引用数法广义表。每一个表结点由三个域组成,结点可分为三类:
- 头结点:用标志域tag = HEAD标识,数据域ref用于存储引用数,广义表的引用数表示能访问此广义表的广义表或指针个数。
- 原子结点:用标志域tag = ATOM标识,原子元素用原子结点存储,数据域atom用于存储原子元素的值。
- 表结点:用标志域tag = LIST标识,指针域subLink用于存储指向子表头结点的指针。
引用数法广义表类模板
求广义表的深度
- 例如,对于广义表Depth(GL)=\begin{cases}0 & GL为原子元素 \\ 1 & GL为空表 \\ 1 + Max(Depth(a_{i}) | 1 \leq i \leq n) & 其它情况\end{cases},如E (B (a, b), D (B (a, b), C (u, (x, y, z)), A ( ) ) )深度的计算过程。
树和二叉树
6.1树的基本概念
树的定义
- 树是具有相同特性的数据元素的集合。若集合为空,则称为空树;否则,在集合中存在唯一的称为根的数据元素root,当n>1时,其余结点可分为m(m>0)个互不相交的有限集T_{1}, T_{2},..., T_{m},其中每一子集本身又是符合本定义的树,称为根root的子树。
抽象数据类型树的定义
- 数据对象D:具有相同特性的数据元素的集合。
- 数据关系R:若D为空集,则称为空树;否则,(1)在D中存在唯一的称为根的数据元素root;(2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T_{1}, T_{2},..., T_{m},其中每一棵子集本身又是一棵符合本定义的树,称为根root的子树。
基本操作
- 查找类:TreeEmpty(T)判定树是否为空树;TreeDepth(T)求树的深度;Root(T)求树的根结点;Value(T, cur_e)求当前结点的元素值;Parent(T, cur_e)求当前结点的双亲结点;LeftChild(T, cur_e)求当前结点的最左孩子;RightSibling(T, cur_e)求当前结点的右兄弟;TraverseTree(T, Visit())遍历。
- 插入类:InitTree(&T)构造空树T;CreateTree(&T, definition)按定义构造树;Assign(T, cur_e, value)给当前结点赋值;InsertChild(&T, &p, i, c)将以c为根的树插入为结点p的第i棵子树。
- 删除类:DestroyTree(&T)销毁树;ClearTree(&T)将树清空;DeleteChild(&T, &p, i)删除结点p的第i棵子树。
对比树型结构和线性结构的结构特点(此处可根据具体对比内容进行详细阐述,文档中未给出完整表格信息)
基本术语
- 结点:数据元素+若干指向子树的分支。
- 结点的度:分支的个数。
- 树的度:树中所有结点的度的最大值。
- 叶子结点:度为零的结点。
- 分支结点:度大于零的结点。
- 结点的层次:假设根结点的层次为1,第l层的结点的子树根结点的层次为l + 1。
- 树的深度:树中叶子结点所在的最大层次称为树的深度,简称为树的深,也称树的高度,也可简称为树的高。
- 路径:由从根到该结点所经分支和结点构成。
- 孩子结点、双亲结点、兄弟结点、堂兄弟结点、祖先结点、子孙结点。
- 有序树:子树之间存在确定的次序关系。
- 无序树:子树之间不存在确定的次序关系。
- 森林:是m(m≥0)棵互不相交的树的集合。任何一棵非空树是一个二元组Tree=(root, F),其中root被称为根结点,F被称为子树森林。
6.2二叉树
二叉树的定义
- 二叉树或为空树,或是由一个根结点加上两棵分别称为左子树和右子树的、互不交叉的二叉树组成。
二叉树的五种基本形态
1. 空树。
2. 只含根结点。
3. 左子树为空树。
4. 右子树为空树。
5. 左右子树均不为空树。
二叉树的主要基本操作
Root(T);Value(T, e);Parent(T, e);LeftChild(T, e);RightChild(T, e);LeftSibling(T, e);RightSibling(T, e);BiTreeEmpty(T);BiTreeDepth(T);PreOrderTraverse(T, Visit());InOrderTraverse(T, Visit());PostOrderTraverse(T, Visit());LevelOrderTraverse(T, Visit())。
二叉树的性质
1. 性质1:在二叉树的第i层上至多有2^{i - 1}个结点。(i≥1)
2. 性质2:深度为k的二叉树上至多含2^{k}-1个结点。(k≥1)
3. 性质3:对任何一棵二叉树,若它含有n_{0}个叶子结点、n_{2}个度为2的结点,则必存在关系式:n_{0}=n_{2}+1。
4. 性质4:具有n个结点的完全二叉树的深度为\lfloor log_{2}n\rfloor + 1。
5. 性质5:若对含n个结点的完全二叉树从上到下且从左至右进行1至n的编号,则对完全二叉树中任意一个编号为i的结点:(1)若i = 1,则该结点是二叉树的根,无双亲,否则,编号为\lfloor i/2\rfloor的结点为其双亲结点;(2)若2i>n,则该结点无左孩子,否则,编号为2i的结点为其左孩子结点;(3)若2i + 1>n,则该结点无右孩子结点,否则,编号为2i + 1的结点为其右孩子结点。
二叉树的存储结构
1. 顺序存储结构
- 完全二叉树的数组表示。
- 一般二叉树的数组表示:一般二叉树需仿照完全二叉树存储,可能浪费很多存储空间,单支树是极端情况。
- 定义:#define MAX_TREE_SIZE 100 // 二叉树的最大结点数 typedef TElemType SqBiTree[MAX_TREE_SIZE]; // 0号单元存储根结点 SqBiTree bt;
2. 链式存储结构
- 二叉链表:typedef struct BiTNode { // 结点结构 TElemType data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree;
- 三叉链表:typedef struct TriTNode { // 结点结构 TElemType data; struct TriTNode *lchild, *rchild; struct TriTNode *parent; } TriTNode, *TriTree;
6.3二叉树遍历
遍历二叉树
1. 遍历的定义:顺着某一条搜索路径巡访二叉树中的结点,使得每个结点均被访问一次,而且仅被访问一次。“访问”含义广泛,如输出结点信息等。二叉树是非线性结构,存在多种遍历方式。
2. 遍历算法
- 对“二叉树”而言,有三类搜索路径:先上后下的按层次遍历;先左(子树)后右(子树)的遍历;先右(子树)后左(子树)的遍历。
- 设访问根结点记作D;遍历根的左子树记作L;遍历根的右子树记作R,则可能的遍历次序有前序DLR、逆前序DRL、中序LDR、逆中序RDL、后序LRD、逆后序RLD、层次遍历。
二叉树遍历应用举例
1. 统计二叉树中叶子结点的个数
- 算法基本思想:先序(或中序或后序)遍历二叉树,在遍历过程中查找叶子结点并计数。
- 实现:在遍历算法中增添“计数”参数,将“访问结点”操作改为若为叶子则计数器增1。
2. 统计二叉树中结点的个数:二叉树的结点个数等于左子树的结点数加上右子树的结点数再加上根结点数1,求二叉树结点数可分解为计算其左右子树的结点数目问题。
3. 求二叉树的高/深度
- 算法基本思想:二叉树的高为其左、右子树高的最大值加1,需先分别求得左、右子树的高。
- 实现:“访问结点”操作为求得左、右子树高的最大值然后加1。
4. 二叉树的复制
- 基本操作为生成一个结点。
- 实现过程包括复制左子树、复制右子树,然后生成新结点并连接左右子树。
5. 由二叉树的先序和中序序列建立二叉树:已知二叉树的先序序列和中序序列可唯一确定一棵二叉树,通过分析先序和中序序列中根结点、左子树、右子树的位置关系来建立二叉树。
6.4线索二叉树
线索二叉树
- 遍历二叉树的结果是求得结点的一个线性序列,指向该线性序列中的“前驱”和“后继”的指针称作“线索”,包含“线索”的存储结构称作“线索链表”,与其相应的二叉树称作“线索二叉树”。
对线索链表中结点的约定
- 在二叉链表的结点中增加两个标志域leftTag和rightTag,规定:leftTag=\begin{cases}0 & leftChild存储指向左孩子的指针 \\ 1 & leftChild存储指向前驱的指针 \end{cases},rightTag=\begin{cases}0 & rightChild存储指向右孩子的指针 \\ 1 & rightChild存储指向后继的指针 \end{cases}。
中序线索二叉树
- 给出中序线索二叉树的示例,包括不带表头结点和带表头结点的示意图。
6.5树和森林
树的存储表示
1. 双亲表示法:用一维数组存储树的结点,同时在每个结点中附设一个指示器指示其双亲结点在数组中的位置。
2. 孩子链表表示法:把每个结点的孩子结点排列起来,看成是一个线性表,且以单链表作存储结构,则n个结点有n个孩子链表(叶子结点的孩子链表为空表)。
3. 孩子兄弟表示法:又称二叉树表示法,或二叉链表表示法。即以二叉链表作树的存储结构,链表中每个结点的两个指针域分别指向其第一个孩子结点和下一个兄弟结点。
树和森林的遍历
1. 树的遍历
- 先根遍历:若树为空,则空操作,结束;否则按如下规则遍历:(1)访问根结点;(2)分别先根遍历根的各棵子树。
- 后根遍历:若树为空,则空操作,结束;否则按如下规则遍历:(1)分别后根遍历根的各棵子树;(2)访问根结点。
2. 森林的遍历
- 先序遍历:若森林F为空,返回;否则:(1)访问F的第一棵树的根结点;(2)先序次序遍历第一棵树的子树森林;(3)先序次序遍历其它树组成的森林。
- 中序遍历:若森林F为空,返回;否则:(1)中序次序遍历第一棵树的子树森林;(2)访问F的第一棵树的根结点;(3)中序次序遍历其它树组成的森林。
树和森林与二叉树的转换
1. 树和二叉树的转换:树的各种操作可对应二叉树的操作来完成,和树对应的二叉树,其左、右孩子的概念已改变为左是树中第一个孩子,右是树中下一个兄弟。
2. 森林与二叉树的转换
- 森林转化成二叉树的规则:设二叉树B的根为root(B),左子树为LB,右子树为RB,森林F由树T_{1}, T_{2},..., T_{n}组成。若F为空,则对应的二叉树B为空二叉树;若F不空,则对应二叉树B的根root(B)是F中第一棵树T_{1}的根root(T_{1}),其左子树LB由(T_{11}, T_{12},..., T_{1m})转化而来(T_{11}, T_{12},..., T_{1m}是root(T_{1})的子树),其右子树RB由(T_{2}, T_{3},..., T_{n})转化而来(T_{2}, T_{3},..., T_{n}是除T_{1}外其它树构成的森林)。
- 二叉树转换为森林的规则:设二叉树B的根为root(B),左子树为LB,右子树为RB,森林F由树T_{1}, T_{2},..., T_{n}组成。如果B为空,则对应的森林F也为空;如果B非空,则F中第一棵树T_{1}的根为root(B);T_{1}的根的子树森林(T_{11}, T_{12},..., T_{1m})是由root(B)的左子树LB转换而来,F除了T_{1}之外其余的树组成的森林(T_{2}, T_{3},..., T_{n})是由root(B)的右子树RB转换而成的森林。
6.6哈夫曼树与哈夫曼编码
哈夫曼树的基本概念
1. 结点间路径长度:连接两结点的路径上的分支数。
2. 结点的路径长度:从根结点到该结点的路径上分支的数目。
3. 树的路径长度:树中每个结点的路径长度之和。
4. 树的带权路径长度(Weighted Path Length, WPL):树的各叶结点所带的权值与该结点到根的路径长度的乘积的和WPL=\sum_{i = 1}^{n}w_{i}*l_{i}。
5. 哈夫曼树:在所有含n个叶子结点、并带相同权值的m叉树中,必存在一棵其带权路径长度取最小值的树,称为“最优树”,或“哈夫曼树”(Huffman Tree)。
哈夫曼树构造算法
1. 根据给定的n个权值{w_{1}, w_{2},..., w_{n}},构造n棵二叉树的集合F = {T_{1}, T_{2},..., T_{n}},其中二叉树T_{i}中均只含一个带权值为w_{i}的根结点,其左、右子树为空树(i = 1,2,..., n)。
2. 在F中选取其根结点的权值为最小的两棵二叉树,分别作为左、右子树构造一棵新的二叉树,并置这棵新的二叉树根结点的权值为其左、右子树根结点的权值之和。
3. 从F中删去这两棵树,同时加入刚生成的新树。
4. 重复2和3两步,直至F中只含一棵树为止。
哈夫曼编码
1. 前缀编码:任何一个字符的编码都不是同一字符集中另一个字符的编码的前缀。利用哈夫曼树可以构造一种不等长的二进制编码,并且构造所得的哈夫曼编码是一种最优前缀编码,使得编码的总长度最短。
2. 哈夫曼编码实例:给出通过哈夫曼树构造哈夫曼编码的具体实例,以及与等长编码对比总编码长度的计算过程。
7.1图的定义和术语
图的定义
- 图是由一个顶点集V和一个边集E构成的数据结构Graph=(V,\{E\}),其中E={<v, w>|v, w\in V}且P(v, w);<v,w>表示从v到w的一条边,v为起点,w为终点;P(v, w)定义了边<v,w>的意义或信息。
基本术语
1. 有向图:边有方向,如G1=(V1,\{E1\}),其中V1=\{A, B, C, D, E\},E1=\{<A, B>,<A, E>,<B, C>,<C, D>,<D, B>,<D, A>,<E, C>\}。
2. 无向图:若有<v, w>\in E,必有<w, v>\in E,用无序对(v,w)表示,如G2=(V2,\{E2\}),其中V2=\{A, B, C, D, E, F\},E2=\{(A,B), (A,E),(B,E), (C,D), (D,F), (B,F), (C,F)\}。
3. 网、子图
- 网:边带权的有向图称作有向网,边带权的无向图称为无向网。
- 子图:设图G=(V,{E})和图G'=(V',{E'})且V'\subseteq V,E'\subseteq E,则称G'为G的子图。
4. 完全图
- 含有e=n(n - 1)/2条边的无向图称作无向完全图。
- 含有e=n(n - 1)条边的有向图称作有向完全图。
- 边数较少为稀疏图,边数较多为稠密图。
5. 无向图的基本术语
- 若顶点v和顶点w之间存在一条边,则称顶点v和w互为邻接点,边(v,w)和顶点v、w相关联,和顶点v关联的边的数目定义为顶点的度,如D(B)=3,D(A)=2。
6. 有向图的基本术语
- 顶点的出度:以顶点v为起点的边的数目。
- 顶点的入度:以顶点v为终点的边的数目。
- 顶点的度(D)=出度(OD)+入度(ID),如OD(B)=1,ID(B)=2,D(B)=3。
7. 有向图的路径相关术语
- 路径:设图G=(V,{E})中的顶点序列\{u = v_{i,0}, v_{i,1},..., v_{i,m}=w\}中,(v_{i,j - 1}, v_{i,j})\in E(对于无向图),或<v_{i,j - 1}, v_{i,j}>\in E(对于有向图),其中1\leq j\leq m,则称从顶点u到顶点w之间存在一条路径,路径上边的条数称作路径长度,如长度为3的路径\{A,B,C,F\}。
- 简单路径:序列中顶点不重复出现的路径。
- 回路(环):序列中第一个顶点和最后一个顶点相同。
- 简单回路(环):序列中只有第一个顶点和最后一个顶点相同,其它顶点不重复出现的路径。
8. 图的连通性相关术语
- 若无向图G中任意两个顶点之间都有路径相通,则称此图为连通图;若非连通图,则图中各个极大连通子图称作此图的连通分量。
- 对有向图,若任意两个顶点之间都存在一条有向路径,则称此有向图为强连通图,否则,其各个极大强连通子图称作它的强连通分量。
- 假设一个连通图有n个顶点和e条边,其中n - 1条边和n个顶点构成一个极小连通子图,称该极小连通子图为此连通图的生成树;对非连通图,则称由各个连通分量的生成树的集合为此非连通图的生成森林。
图基本操作
1. 结构的建立和销毁
- CreatGraph(&G, V, VR):按定义(V, VR)构造图。
- DestroyGraph(&G):销毁图。
2. 对顶点的访问操作
- LocateVex(G, u):若G中存在顶点u,则返回该顶点在图中“位置”;否则返回其它信息。
- GetVex(G, v):返回v的值。
- PutVex(&G, v, value):对v赋值value。
3. 对邻接点的操作
- FirstAdjVex(G, v):返回v的“第一个邻接点”,若该顶点在G中没有邻接点,则返回“空”。
- NextAdjVex(G, v, w):返回v的(相对于w的)“下一个邻接点”,若w是v的最后一个邻接点,则返回“空”。
4. 插入或删除顶点
- InsertVex(&G, v):在图G中增添新顶点v。
- DeleteVex(&G, v):删除G中顶点v及其相关的弧。
5. 插入和删除弧
- InsertArc(&G, v, w):在G中增添弧<v,w>,若G是无向的,则还增添对称弧<w,v>。
- DeleteArc(&G, v, w):在G中删除弧<v,w>,若G是无向的,则还删除对称弧<w,v>。
6. 遍历
- DFSTraverse(G, v, Visit()):从顶点v起深度优先遍历图G,并对每个顶点调用函数Visit一次且仅一次。
- BFSTraverse(G, v, Visit()):从顶点v起广度优先遍历图G,并对每个顶点调用函数Visit一次且仅一次。
7.2图的存储表示
图的多重链表存储表示
- 一个数据域+多个指针域=结点,指针域存储指向其邻接点的指针。按度数最大顶点设计结点结构会浪费存储单元,按每个顶点度数设计结点结构会给操作带来不便。
图的数组(邻接矩阵)存储表示
1. 有向图的邻接矩阵元素定义为Matrix[i][j]=\begin{cases}1, & 存在边<v_{i}, v_{j}> \\ 0, & 不存在边<v_{i}, v_{j}>\end{cases}。
2. 无向图的邻接矩阵元素定义为Matrix[i][j]=\begin{cases}1, & 存在边(v_{i}, v_{j}) \\ 0, & 不存在边(v_{i}, v_{j})\end{cases},无向图的邻接矩阵是对称矩阵,在无向图中,统计第i行(列)1的个数可得顶点V_{i}的度;有向图的邻接矩阵一般为非对称矩阵,在有向图中,统计第i行1的个数可得顶点v_{i}的出度,统计第j列1的个数可得顶点v_{j}的入度。
3. 网(边带权值的图)的数组(邻接矩阵)存储表示
- 有向网的邻接矩阵元素定义为Matrix[i][j]=\begin{cases}w_{ij}, & 存在边<v_{i}, v_{j}> \\ \infty, & 不存在边<v_{i}, v_{j}>\end{cases}。
- 无向网的邻接矩阵元素定义为Matrix[i][j]=\begin{cases}w_{ij}, & 存在边(v_{i}, v_{j}) \\ \infty, & 不存在边(v_{i}, v_{j})\end{cases}。
图的邻接表存储
1. 在邻接矩阵中,当边数较少时,邻接矩阵中有大量的0元素,将耗费大量存储空间。本质上邻接表就是将矩阵中的行用链表来存储,并且只存储非0元素。
2. 设图中有e条边,n个顶点,用邻接表表示无向图(网)时,需要存储n个顶点和2e条边;对于有向图(网),则需要存储n个顶点和e条边。当e<<n^2时,邻接表比邻接矩阵更节约存储空间。
3. 弧的结点结构:
- typedef struct ArcNode {
int adjvex; // 该弧所指向的顶点的位置
struct ArcNode *nextarc; // 指向下一条弧的指针
InfoType *info; // 该弧相关信息的指针
} ArcNode;
4. 顶点的结点结构:
- typedef struct VNode {
VertexType data; // 顶点信息
ArcNode *firstarc; // 指向第一条依附该顶点的弧
} VNode, AdjList[MAX_VERTEX_NUM];
5. 图的结构定义:
- typedef struct {
AdjList vertices;
int vexnum, arcnum;
int kind; // 图的种类标志
} ALGraph;
图的十字链表(此处可根据具体内容详细阐述十字链表的结构和特点,文档中未详细展开)
习题
- 给出下图的邻接矩阵和邻接表存储结构。
7.3图的遍历
深度优先搜索遍历图
1. 连通图的深度优先搜索遍历:递归地访问它的所有未被访问到的相邻的顶点,实际结果是沿着图的某一分支进行搜索,直至末端为止,然后再进行回溯,沿另一分支进行搜索,依此类推,深度优先搜索的搜索过程将产生一棵深度优先搜索树,此树由图遍历过程中所有连接某一新(未被访问的)顶点边所组成,并不包括那些连接已访问顶点的边,DFS算法适合于所有类型的图。
2. 非连通图的深度优先搜索遍历
- 首先将图中每个顶点的访问标志设为false。
- 之后搜索图中每个顶点,如果未被访问,则以该顶点为起始点,进行深度优先搜索,否则继续检查下一顶点。
广度优先搜索遍历图
1. 对连通图,从起始点v到其余各顶点必定存在路径,从图中的某个顶点v出发,并在访问此顶点之后依次访问v的所有未被访问过的邻接点,之后按这些顶点被访问的先后次序依次访问它们的邻接点,直至图中所有和v有路径相通的顶点都被访问到。若此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
习题
- 对于给定图从顶点d出发分别按深度优先搜索与广度优先搜索方法进行遍历,写出相应的顶点序列。
7.4最小生成树
问题
- 假设要在n个城市之间建立通讯联络网,连通n个城市只需要修建n - 1条线路,如何在最节省经费的前提下建立这个通讯网?该问题等价于构造连通无向网的一棵最小生成树,即在e条带权的边中选取n - 1条边(不能构成长度大于2的简单回路),使“权值之和”为最小。
构造最小生成树的准则
- 必须使用且仅使用n - 1条边来连接连通无向网中的n个顶点。
- 必须没有长度大于2的简单回路。
- 各边上的权值的总和达到最小。
普里姆(Prim)算法
1. 取图中任意一个顶点v作为生成树的根,之后往生成树上添加新的顶点。
2. 在生成树的构造过程中,网中n个顶点分属两个集合:已落在生成树上的顶点集U和尚未落在生成树上的顶点集V - U,则应在所有连通U中顶点和V - U中顶点的边中选取权值最小的边(u,v)(这里u属于U,v属于V - U),将v添加到U中。
3. 重复2继续往生成树上添加顶点,直至生成树上含有n个顶点为止。
克鲁斯卡尔(Kruskal)算法
1. 考虑问题的出发点:为使生成树上边的权值之和达到最小,则应使生成树中每一条边的权值尽可能地小。
2. 具体做法:先构造一个只含n个顶点的子图SG,然后从权值最小的边开始,若它的添加不使SG中产生长度大于2的简单回路,则在SG上加上这条边,如此重复,直至加上n - 1条边为止。
算法分析
- Prim算法的时间复杂度O(n^2)。
- Kruscal算法中排序代价为O(eloge),Kruscal的时间复杂度为O(n + eloge),假设eloge>n,则时间复杂度为O(eloge)。
- 对于稠密网,比较适合用Prim算法构造最小生成树;对于稀疏网,比较适合用Kruskal算法构造最小生成树。
最小生成树练习
- 给定图,求其最小生成树(可根据具体图进行分析和计算)。
7.5有向无环图及应用
拓扑排序
1. 引例:学生课程学习关系图为有向无环图,如课程代号、课程名称、先修课程的关系。
2. 概念:按照有向图给出的次序关系,将图中顶点排成一个线性序列,对于有向图中没有限定次序关系的顶点,则可以人为加上任意的次序关系,由此所得顶点的线性序列称之为拓扑有序序列,如对于某些有向图可求得拓扑有序序列,反之,若图中存在回路则不能求得拓扑有序序列。
3. 进行拓扑排序的步骤
- 从有向图中选取一个没有前驱的顶点,并输出之。
- 从有向图中删去此顶点以及所有以它为起点的边。
- 重复上述两步,直至图空,或者图不空但找不到无前驱的顶点为止
9.1查找的基本概念
查找表
- 查找表是由同一类型的数据元素(也可称为记录)构成的集合。由于“集合”中的数据元素之间存在着松散的关系,因此查找表是一种应用灵便的结构。在每个数据元素中可能存在有若干数据项,可以标识数据元素的数据项称为关键字。所谓查找,就是在数据集合中寻找满足某种条件的数据元素(记录)。查找结果分为两种情况:查找成功,即找到满足条件的数据元素,这时,作为结果,可报告该元素出现的位置;查找不成功或查找失败,作为结果,可报告一些信息,如失败标志。
查找表分类
1. 静态查找表:仅作查找操作的查找表。对静态查找表经常进行的操作有查询某个“特定的”数据元素是否在查找表中、检索某个“特定的”数据元素的各种属性。
2. 动态查找表:有时在查找之后,还需要将“查找”结果为“不在查找表中”的数据元素插入到查找表中;或者,从查找表中删除其“查找”结果为“在查找表中”的数据元素。
9.2静态表的查找
顺序查找
- 以顺序表或线性链表表示静态查找表。顺序查找过程为:从表头开始,逐个将元素与给定值进行比较,若相等则查找成功,若遍历完整个表都未找到相等元素,则查找失败。例如,给定顺序表elem[11] = \{21, 37, 88, 19, 92, 05, 64, 56, 80, 75, 13\},假设给定值key = 64,从elem[0]开始依次比较,直到找到elem[6] = 64,此时查找成功。
- 顺序表的实现如下:
cpp
template <class ElemType, class KeyType>
int Search_Seq(ElemType elem[], int n, KeyType key)
// 操作结果:在顺序表中查找关键字的值等于key的记录,如查找成功,则返回此记录的序号,否则返回 -1
{
int i; // 临时变量
for (i = 0; i < n && elem[i]!= key; i++);
if (i < n)
{ // 查找成功
return i;
}
else
{ // 查找失败
return -1;
}
}
- 顺序查找效率分析:平均查找长度ASL是为确定元素在查找表中的位置执行关键字比较次数。查找成功的平均比较次数(从0开始,查找第i个元素)ASL_{succ}=\sum_{i=0}^{n - 1}p_{i}c_{i}=\sum_{i=0}^{n - 1}p_{i}\cdot(i + 1)=\sum_{i=0}^{n - 1}\frac{i + 1}{n}=\frac{n\cdot(n + 1)}{2}\cdot\frac{1}{n}=\frac{n + 1}{2}。查找不成功的比较次数为n。总结:查找成功的平均查找长度较长,但对表没有什么特别的要求;线性链表只能进行顺序查找。
有序表的查找 - 折半查找
- 前面顺序查找表的查找算法简单,但平均查找长度较大,特别不适用于表长较大的查找表。若以有序表表示静态查找表,则查找过程可以基于“折半”进行。
- 基于有序顺序表的折半查找过程如下:设n个数据元素(或记录)存放在一个有序顺序表中,并按从小到大排序。折半查找时,先求位于查找区间正中的数据元素的下标mid,用其记录的值与给定值key比较。若key == elem[mid],则查找成功;若key < elem[mid],把查找区间缩小到表的前半部分,继续折半查找;若key > elem[mid],把查找区间缩小到表的后半部分,继续折半查找。如果查找区间已缩小到没有元素(记录),仍未找到想要查找的元素(记录),则查找失败。例如,对于有序表elem[11] = \{19, 21, 37, 56, 64, 75, 80, 88, 92\},查找key = 64,初始时low = 0,high = 10,则mid = (0 + 10) / 2 = 5,比较elem[5] = 75与64,因为64 < 75,所以新的查找区间为前半部分,即high = mid - 1 = 4,继续计算mid = (0 + 4) / 2 = 2,比较elem[2] = 37与64,因为64 > 37,所以新的查找区间为后半部分,即low = mid + 1 = 3,再次计算mid = (3 + 4) / 2 = 3,比较elem[3] = 56与64,因为64 > 56,所以low = mid + 1 = 4,最后计算mid = (4 + 4) / 2 = 4,找到elem[4] = 64,查找成功。
- 折半查找的实现如下:
cpp
template <class ElemType, class KeyType>
int Search_Bin(ElemType elem[], int n, KeyType key)
// 操作结果:在有序表中查找其关键字的值等于key的记录,如查找成功,则返回此记录的序号,否则返回 -1
{
int low = 0, high = n - 1; // 查找区间初值
while (low <= high)
{
int mid = (low + high) / 2; // 查找区间中间位置
if (key == elem[mid]) return mid; // 查找成功
else if (key < elem[mid]) high = mid - 1; // 继续在左半区间进行查找
else low = mid + 1; // 继续在右半区间进行查找
}
return -1; // 查找失败
}
- 折半查找判定树:折半查找的过程可以用判定树来描述。例如,对于上述有序表的折半查找判定树,根结点为中间元素elem[5] = 75,左子树为前半部分元素,右子树为后半部分元素,以此类推构建判定树。
- 折半查找效率分析:折半查找成功或不成功时和给定值进行比较的关键字个数至多为\lfloor log_{2}n\rfloor + 1。总结:折半查找的效率比顺序查找高,但折半查找只适用于有序表,且限于顺序存储结构,对线性链表无法有效进行折半查找。
习题
- 有序表\{4,6,10,12,20,30,50,70,88,100\},画出判定树如下:
plaintext
30
/ \
10 70
/ \ / \
4 12 50 88
\ \ \
20 60 100
- 查找元素58的过程:首先与根结点30比较,58 > 30,所以在右子树继续查找;与右子树的根70比较,58 < 70,在左子树继续查找;与50比较,58 > 50,在右子树继续查找;此时右子树为空,查找失败。
9.3动态查找表
动态查找表特点
- 表结构本身是在查找过程中动态生成的,即对于给定值key,若表中存在其关键字等于key的记录,则查找成功返回;否则,插入关键字等于key的记录。
二叉排序树(二叉查找树)
1. 定义与特性:二叉排序树也称为二叉查找树,二叉排序树或者是一棵空树;或者是具有如下特性的二叉树:若它的左子树不空,则左子树上所有结点的值均小于根结点的值;若它的右子树不空,则右子树上所有结点的值均大于根结点的值;它的左、右子树也都分别是二叉排序树。二叉排序树的中序遍历将得到所有元素值的一个从小到大的有序序列。例如,二叉树50(根),左子树为30(根),25(左),40(右),10(30的左),35(30的右),23(25的左),右子树为80(根),65(左),88(右),85(88的左)是二叉排序树,而二叉树50(根),左子树为30(根),25(左),40(右),10(30的左),35(30的右),23(25的左),右子树为80(根),65(左),90(右),85(90的左)不是二叉排序树(因为85 < 90不满足右子树大于根的特性)。
2. 查找算法:若二叉排序树为空,则查找不成功;否则,若给定值等于根结点的值,则查找成功;若给定值小于根结点的值,则继续在左子树上进行查找;若给定值大于根结点的值,则继续在右子树上进行查找。例如,在上述二叉排序树中查找关键字50,首先与根结点50比较,相等,查找成功;查找关键字35,与根结点50比较,35 < 50,在左子树中查找,与左子树的根30比较,35 > 30,在右子树中查找,找到35,查找成功;查找关键字90,与根结点50比较,90 > 50,在右子树中查找,与右子树的根80比较,90 > 80,在右子树中查找,与88比较,90 > 88,继续在右子树中查找,此时右子树为空,查找失败。
3. 查找性能分析:对于每一棵特定的二叉排序树,均可按照平均查找长度的定义来求它的ASL值。显然,由值相同的n个关键字,构造所得的不同形态的各棵二叉排序树的平均查找长度的值不同,甚至可能差别很大。例如,由关键字序列1,2,3,4,5构造的二叉排序树,其平均查找长度ASL=(1 + 2 + 3 + 4 + 5) / 5 = 3;而由关键字序列3,1,2,5,4构造的二叉排序树,其平均查找长度ASL=(1 + 2 + 3 + 2 + 3) / 5 = 2.2。
4. 建立过程:输入关键字\{53,78,65,17,87,09,81,15\},建立二叉查找树的过程如下:首先插入53,此时二叉排序树只有一个结点53;插入78,因为78 > 53,所以78成为53的右子结点;插入65,因为65 > 53且65 < 78,所以65成为78的左子结点;插入17,因为17 < 53,所以17成为53的左子结点;插入87,因为87 > 78,所以87成为78的右子结点;插入09,因为09 < 53且09 < 17,所以09成为17的左子结点;插入81,因为81 > 78且81 < 87,所以81成为87的左子结点;插入15,因为15 < 53且15 > 17,所以15成为17的右子结点。同样3个关键字\{1,2,3\},输入顺序不同,建立起来的二叉查找树的形态也不同,如输入顺序为\{1,2,3\}时,二叉排序树为1(根),2(右),3(2的右);输入顺序为\{1,3,2\}时,二叉排序树为1(根),3(右),2(3的左);输入顺序为\{2,1,3\}时,二叉排序树为2(根),1(左),3(右);输入顺序为\{3,1,2\}时,二叉排序树为3(根),1(左),2(1的右);输入顺序为\{3,2,1\}时,二叉排序树为3(根),2(左),1(2的左)。如果输入序列选得不好,会建立起一棵单支树,使得二叉查找树的高度达到最大。
平衡二叉树(AVL树)
1. 定义:平衡二叉树的定义为一棵AVL树或者是空树,或者是具有下列性质的二叉查找树:它的左子树和右子树都是AVL树,且左子树和右子树的高度之差的绝对值不超过1。例如,二叉树3(根),左子树为2(根),1(左),右子树为4(根),5(右)是平衡树;而二叉树2(根),左子树为1(根),3(右),4(3的右),5(4的右)不是平衡树(因为左子树高度为2,右子树高度为3,高度差绝对值为1)。
2. 平衡因子:每个结点附加一个数字,给出该结点左子树的高度减去右子树的高度所得的高度差,这个数字即为结点的平衡因子。AVL树任一结点平衡因子只能取-1,0,1。
3. 平衡化旋转
- 当在一棵平衡的二叉查找树中插入一个新结点,造成了不平衡时,必须调整树的结构,使之平衡化。平衡化旋转有两类:单旋转(左旋和右旋)和双旋转(左旋加右旋和右旋加左旋)。
- 右单旋转(LL型):例如,对于二叉树A(根),左子树为B(根),B的左子树为BL,右子树为BR,A的右子树为AR,且B的高度大于A的右子树高度,此时进行右单旋转,旋转后B成为根结点,A成为B的右子结点,B的原左子树BL仍为B的左子结点,A的原左子树B成为A的左子结点,A的原右子树AR成为B的右子结点。
- 左单旋转(RR型):例如,对于二叉树A(根),右子树为B(根),B的左子树为BL,右子树为BR,A的左子树为AL,且B的高度大于A的左子树高度,此时进行左单旋转,旋转后B成为根结点,A成为B的左子结点,B的原右子树BR仍为B的右子结点,A的原右子树B成为A的右子结点,A的原左子树AL成为B的左子结点。
- 左右双旋转(LR型):分为两种情况,一种是插入结点在C的子树上,例如,对于二叉树A(根),左子树为B(根),B的右子树为C(根),C的左子树为CL,右子树为CR,A的右子树为AR,先对B和C进行左旋(C成为B的父结点,B成为C的左子结点,C的原左子树CL成为B的右子结点),然后对A和C进行右旋(C成为根结点,A成为C的右子结点,C的原右子树CR成为A的左子结点);另一种情况是C为插入结点,例如,对于二叉树A(根),左子树为B(根),B的右子树为C,A的右子树为AR,先对B和C进行左旋(C成为B的父结点,B成为C的左子结点),然后对A和C进行右旋(C成为根结点,A成为C的右子结点)。
- 右左双旋转(RL型):同样分为两种情况,一种是插入结点在C的子树上,例如,对于二叉树A(根),右子树为B(根),B的左子树为C(根),C的左子树为CL,右子树为CR,A的左子树为AL,先对B和C进行右旋(C成为B的父结点,B成为C的右子结点,C的原右子树CR成为B的左子结点),然后对A和C进行左旋(C成为根结点,A成为C的左子结点,C的原左子树CL成为A的右子结点);另一种情况是C为插入结点,例如,对于二叉树A(根),右子树为B(根),B的左子树为C,A的左子树为AL,先对B和C进行右旋(C成为B的父结点,B成为C的右子结点),然后对A和C进行左旋(C成为根结点,A成为C的左子结点)。
4. 插入操作
- 用待插入数据元素创建一个结点。
- 查找树,找到新结点应在树中的位置。
- 将新结点插入树中。
- 从插入结点回溯至根结点的路径,该路径是为查找新结点在树中的位置而建立的。如有必要,调整结点的平衡因子,或者重构路径上的某一结点。例如,已知关键字序列\{12,26,38,89,56\}构造平衡二叉树的过程如下:首先插入12,此时平衡二叉树只有一个结点12;插入26,因为26 > 12,所以26成为12的右子结点;插入38,因为38 > 26,所以38成为26的右子结点;插入89,因为89 > 38,所以89成为38的右子结点,此时发现38的平衡因子为2(右子树高度为3,左子树高度为1),需要进行平衡化旋转,通过左旋(以26为根进行左旋,38成为26的父结点,26成为38的左子结点,38的原左子树不变)调整为平衡二叉树;插入56,因为56 > 38且56 < 89,所以56成为89的左子结点,此时又发现89的平衡因子为2(右子树高度为2,左子树高度为0),需要进行平衡化旋转,通过先右旋(以56为根进行右旋,89成为56的右子结点,56的原右子树不变)再左旋(以38为根进行左旋,56成为38的父结点,38成为56的左子结点,56的原左子树不变)调整为平衡二叉树。
B - 树
1. 特点与定义
- B - 树是一种平衡的多路查找树,它在文件系统中很有用。B - 树特点如下:
- B树总是树高平衡的,所有的叶子结点都在同一层。
- 更新和检索操作只影响一些磁盘块,因此性能很好。
- B树把相关的记录(即关键字有类似的值)放在同一磁盘块中,从而利用了访问局部性原理。
- B树保证了树中内部结点的最小孩子个数,也就保证了最少关键字个数。这样在检索和更新操作期间减少需要的磁盘读写次数。
- 一棵m阶B树,或为空树,或为满足下列特性的m叉树:
- 根或者是一个叶子结点,或者至少有两个孩子。
- 除了根结点以外,每个内部结点有\lceil\frac{m}{2}\rceil到m个孩子。
- 所有叶子结点在树结构的同一层,并且不含任何信息(可看成是外部结点或查找失败的结点),因此m阶B树结构总是树高平衡的。例如,68(根),左子树为38(根),16(左),56(右),右子树为80(根),69(左),81(中),86(右),89(86的右),98(89的右)是一棵4阶B树(其中\lceil\frac{4}{2}\rceil = 2,每个内部结点的孩子个数在2到4之间)。
2. 插入操作
- 找到最下层的内部结点。
- 如果结点的孩子个数小于m,那么就直接插入关键字。
- 如果结点的孩子个数等于m,那么就把这个结点分裂成两个结点,并且把中间的关键字提升到父结点(向上繁殖)。如果父结点也已经满了,就再分裂父结点,并且再次提升中间的关键字。插入过程保证所有结点至少半满。例如,在3阶B树上依次插入关键字65、24、50和38的过程如下:
- 初始为空树,插入65,此时B树只有一个结点65。
- 插入24,因为24 < 65,所以24成为65的左子结点,此时B树为65(根),24(左)。
- 插入50,因为50 > 65,所以50成为65的右子结点,此时B树为65(根),24(左),50(右)。
- 插入38,因为24 < 38 < 65,所以38成为65的中孩子结点,此时65结点已满(3阶B树每个内部结点最多3个孩子),需要分裂。将65分裂为两个结点,38提升到父结点(此时新的根结点),24成为38的左子结点,50成为38的右子结点,65成为38的中孩子结点,此时B树为38(根),24(左),65(中),50(右)。
3. 删除操作
- 在内部结点:使用叶子结点中的直接前驱或后继替代。
- 在叶子结点:
- 元素个数>\lceil\frac{m}{2}\rceil - 1:直接删除。
- 元素个数=\lceil\frac{m}{2}\rceil - 1:
- 兄弟结点元素个数>\lceil\frac{m}{2}\rceil - 1:借。
- 兄弟结点元素个数=\lceil\frac{m}{2}\rceil - 1:合并。例如,在5阶B树中删除76的过程如下:
- 初始B树为54(根),12(左),30(中),69(右),78(69的右),76(78的左)等(省略部分叶子结点)。
- 因为76在叶子结点且该叶子结点元素个数>\lceil\frac{5}{2}\rceil - 1 = 2(该叶子结点有76和78两个元素),所以直接删除76,此时B树为54(根),12(左),30(中),69(右),78(69的右)等(省略部分叶子结点)。
9.4散列表(哈希表)
散列表的概念
- 之前查找表结构中记录位置与关键字无确定关系,查找效率取决于比较关键字个数,平均查找长度不为零。而散列表通过哈希函数H(key)建立关键字与记录存储位置的确定关系,以函数值作为存储位置,但哈希函数易产生冲突(H(key1)=H(key2)但key1\neq key2),构造散列表需选择好的哈希函数并处理冲突。
构造哈希函数的方法
1. 对数字关键字
- 平方取中法:以关键字平方值中间几位作存储地址。
- 除留余数法:H(key)=key\%p(p\leq m且最好为不大于m的素数或不含20以下质因子)。
- 随机数法:H(key)=Random(key)(Random()为伪随机函数)。
2. 非数字关键字:需先数字化处理。
处理冲突的方法
1. 开放定址法(闭域法):为冲突地址求得地址序列H_{0},H_{1},H_{2},...,H_{s}(1\leq s\leq m - 1),H_{0}=H(key),H_{i}=(H(key)+d_{i})\%m(i = 1,2,...,s),增量d_{i}有线性探测再散列(d_{i}=i)和随机探测再散列(d_{i}是伪随机数列)两种取法,如\{19,01,23,14,55,68,11,82,36\},H(key)=key\%11采用线性探测再散列处理冲突的示例。
2. 链地址法(开域法):将哈希值相同记录链接在同一链表中,如\{19,01,23,14,55,68,11,82,36\},H(key)=key\%7采用链地址法处理冲突的示例。
10.1概述
什么是排序
- 排序是计算机内经常进行的一种操作,就是将数据元素的任意序列,重新排序成按关键字有序的序列。例如,将关键字序列52, 49, 80, 36, 14, 58, 61, 23, 97, 75调整为14, 23, 36, 49, 52, 58, 61, 75, 80, 97。
稳定的和不稳定的排序方法
- 对于任意key_{i}==key_{j}(0<=i<j<=n - 1),排序前数据元素e_{i}在e_{j}的前面,排序后e_{i}也在e_{j}的前面,这样的排序方法称为稳定的;反之,如可能排序后数据元素e_{i}在e_{j}的后面,则称排序方法是不稳定的。
内部排序和外部排序
- 若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。反之,若参加排序的元素数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。
内部排序的方法
1. 插入类:将无序子序列中的一个元素“插入”到有序序列中,从而增加有序子序列的长度。
2. 交换类:通过“交换”无序序列中的元素从而得到其中关键字最小或最大的元素,并将它加入到有序子序列中,以此方法增加有序子序列的长度。
3. 选择类:从元素的无序子序列中“选择”关键字最小或最大的元素,并将它加入到有序子序列中,以此方法增加有序子序列的长度。
4. 归并类:通过“归并”两个(2路归并)、三个(3路归并)或多个(多路归并)有序子序列,逐步增加有序序列的长度。
按内排序过程中所需的工作量分类
1. 简单排序方法,时间复杂度为O(n^{2})。
2. 先进排序方法,时间复杂度为O(nlogn)。
3. 基数排序方法,时间复杂度为O(d×n)。
10.2插入排序
直接插入排序
- 一趟直接插入排序的基本思想:将待排序元素插入到已排序的有序序列中,从而使有序序列长度增加。例如,对于原始序列(18)8 56 96 8 1,第一趟将8插入到(18)中,得到(8 18)56 96 8 1;第二趟将56插入到(8 18)中,得到(8 18 56)9 68 8;以此类推。
- 直接插入排序的实现:
cpp
void StraightInsertSort(ElemType elem[], int n) // 操作结果:对数组elem作直接插入排序序
{
for (int i = 1; i < n; i++) { // 第i趟直接插入排序
ElemType e = elem[i]; // 暂存elem[i]
int j; // 临时变量
for (j = i - 1; j >= 0 && e < elem[j]; j--) { // 将比e大的元素都后移
elem[j + 1] = elem[j]; // 后移
}
elem[j + 1] = e; // j + 1为插入位置
}
}
折半插入排序
- 折半插入排序的实现:
cpp
void BInsertSort(ElemType elem[], int n) {
for (int i = 1; i < n; i++) {
int j, mid, low = 0, high = i - 1;
ElemType e = elem[i]; // 暂存elem[i]
while (low <= high) {
mid = (low + high) / 2;
if (e < elem[mid]) high = mid - 1;
else low = mid + 1;
}
for (j = i - 1; j >= high + 1; j--) elem[j + 1] = elem[j]; // 后移
elem[j + 1] = e; // j + 1为插入位置
}
}
希尔排序
- 希尔排序的基本思想:先将整个待排序数据元素序列分割成若干子序列,分别对各子序列进行直接插入排序,等整个序列中的数据元素基本有序时,再对全体数据元素进行一次直接插入排序。例如,对于序列16 25 12 30 47 11 23 36 9 18 31,第一趟希尔排序,设增量d = 5,得到11 23 12 9 18 16 25 36 30 47 31;第二趟希尔排序,设增量d = 3,得到9 18 12 11 23 16 25 31 30 47 36;第三趟希尔排序,设增量d = 1,得到9 11 12 16 18 23 25 30 31 36 47。
- 希尔排序实现:
cpp
template <class ElemType>
void ShellSort(ElemType elem[], int n, int inc[], int t) // 操作结果:按增量序列inc[0..t - 1]对数组elem作Shell排序
{
for (int k = 0; k < t; k++) { // 第k趟Shell排序
ShellInsert(elem, n, inc[k]);
}
}
template <class ElemType>
void ShellInsert(ElemType elem[], int n, int incr) // 操作结果:对数组elem作一趟增量为incr的Shell排序,对插入排序作出的修改是子序列中前后相邻元素的增量为incr,而不是1
{
for (int i = incr; i < n; i++) { // 第i趟插入排序
ElemType e = elem[i];
// 暂存elem[i]
int j; // 临时变量
for (j = i - incr; j >= 0 && e < elem[j]; j -= incr) { // 将子序列中比e大的元素都后移
// 将子序列中比e大的元素都后移
elem[j + incr] = elem[j]; // 后移
}
elem[j + incr] = e; // j + incr为插入位置
}
}
- 对序列49, 38, 65, 97, 76, 13, 27, 49, 55, 4进行shell排序,增量值分别为5, 3, 1,每一趟的结果如下:
- 初始序列:49, 38, 65, 97, 76, 13, 27, 49, 55, 4
- 第一趟(增量d = 5):4 38 27 49 55 13 65 49 76 97
- 第二趟(增量d = 3):4 13 27 38 49 49 55 65 76 97
- 第三趟(增量d = 1):4 13 27 38 49 49 55 65 76 97
10.3交换排序
起泡排序
- 起泡排序的基本思想:比较相邻元素,将关键字最大的元素交换到n - i的位置上。例如,对于初始序列38 19 5 8 15 18 8 5,第一趟排序后得到5 19 8 15 18 8 5 38(将最大元素38交换到最后位置);第二趟排序后得到5 8 15 18 8 5 19 38(将次大元素19交换到倒数第二位置);以此类推。
- 起泡排序的实现:
cpp
template <class ElemType>
void BubbleSort(ElemType elem[], int n) // 操作结果:在数组elem中用起泡排序进行排序
{
for (int i = 1; i < n; i++) { // 第i趟起泡排序
for (int j = 0; j < n - i; j++) { // 比较elem[j]与elem[j + 1]
if (elem[j] > elem[j + 1]) { // 如出现逆序,则交换elem[j]和elem[j + 1]
Swap(elem[j], elem[j + 1]);
}
}
}
}
快速排序
- 一趟快速排序:找一个元素(枢轴),凡其关键字小于枢轴的元素均移动至该元素之前,反之,凡关键字大于枢轴的元素均移动至该元素之后。之后分别对分割所得两个子序列“递归”进行快速排序。例如,对于关键字序列75 49 36 58 97 61 52 14 52 80 23,设elem[0] = 52为枢轴,经过一趟快速排序后调整为(23, 49, 14, 36, ) 52 (58, 61, 97, 80, 75 )。
- 快速排序的实现:
cpp
void QuickSort(ElemType elem[], int n)
// 操作结果:对数组elem进行快速排序
{
QuicksortHelp(elem, 0, n - 1);
}
void QuicksortHelp(ElemType elem[], int low, int high) // 操作结果:对数组elem[low..high]中的记录进行快速排序
{
if (low < high) {
// 子序列elem[low..high]长度大于1
int pivotLoc = Partition(elem, low, high); // 进行一趟划分
QuicksortHelp(elem, low, pivotLoc - 1); // 对子表elem[low, pivotLoc - 1]递归排序
QuicksortHelp(elem, pivotLoc + 1, high); // 对子表elem[pivotLoc + 1, high]递归排序
}
}
int Partition(ElemType elem[], int low, int high) // 操作结果:交换elem[low..high]中的元素,使枢轴移动到适当位置,要求在枢轴之前的元素不大于枢轴,在枢轴之后的元素不小于枢轴,并返回枢轴的位置
{
while (low < high) {
while (low < high && elem[high] >= elem[low]) { // elem[low]为枢轴,使high右边的元素不小于elem[low]
high--;
}
Swap(elem[low], elem[high]);
while (low < high && elem[low] <= elem[high]) { // elem[high]为枢轴,使low左边的元素不大于elem[high]
low++;
}
Swap(elem[low], elem[high]);
}
return low; // 返回枢轴位置
}
10.4选择排序
简单选择排序
- 假设排序过程中,待排元素序列的状态为:从无序序列中选出关键字最小的元素,放入有序序列末尾。例如,对于原始序列49 38 66 97 49 26,第一趟选择最小元素26,与49交换,得到26 (38) 66 97 49 49;第二趟选择最小元素38,与自身交换(位置不变),得到26 38 (66 97 49) 49;以此类推。
- 简单选择排序的实现:
cpp
void SimpleSelectionSort(ElemType elem[], int n) // 操作结果:对数组elem作简单选择排序
{
for (int i = 0; i < n - 1; i++) { // 第i趟简单选择排序
int lowIndex = i; // elem[i..n - 1]中最小元素下标
for (int j = i + 1; j < n; j++) {
if (elem[j] < elem[lowIndex]) {
lowIndex = j;
}
}
Swap(elem[i], elem[lowIndex]); // 交换
}
}
堆排序
- 堆的定义:堆是满足下列性质的序列{e_{0}, e_{1},..., e_{n - 1}},若将该数列视作完全二叉树,则e_{2i + 1}是e_{i}的左孩子;e_{2i + 2}是e_{i}的右孩子。\begin{cases}e_{i} \leq e_{2i + 1} \\ e_{i} \leq e_{2i + 2}\end{cases}(小顶堆)或\begin{cases}e_{i} \geq e_{2i + 1} \\ e_{i} \geq e_{2i + 2}\end{cases}(大顶堆)。例如,序列12 36 27 65 40 98 81 73 55 49不是堆,序列98 81 49 73 36 27 40 55 64 12是大顶堆(满足\begin{cases}98 \geq 81 \\ 98 \geq 49\end{cases},\begin{cases}81 \geq 73 \\ 81 \geq 55\end{cases}等)。
- 堆排序即是利用堆的特性对元素序列进行排序的一种排序方法。例如,对{40, 55, 49, 73, 12, 27, 98, 81, 64, 36}进行堆排序,先建大顶堆得到{98, 81, 49, 73, 36, 27, 40, 55, 64, 12},交换98和12,然后重新调整为大顶堆得到{81, 73, 49, 64, 36, 27, 40, 55, 12, 98},以此类推。
- 堆排序需要解决的两个问题:
- 如何由一个无序序列建成一个堆?
- 如何在输出堆顶元素后,调整剩余元素成为一个新的堆?
- 堆排序的实现:
cpp
void HeapSort(ElemType elem[], int n) // 操作结果:对数组elem进行堆排序
{
int i;
for (i = (n - 2) / 2; i >= 0; --i) {
// 将elem[0..n - 1]调整成大顶堆
SiftAdjust(elem, i, n - 1);
}
for (i = n - 1; i > 0; --i) {
// 第i趟堆排序
Swap(elem[0], elem[i]);
// 将堆顶元素和当前未经排序的子序列elem[0..i]中最后一个元素交换
SiftAdjust(elem, 0, i - 1); // 将elem[0..i - 1]重新调整为大顶堆
}
}
void SiftAdjust(ElemType elem[], int low, int high) // 操作
- 堆排序的实现(续):
cpp
void SiftAdjust(ElemType elem[], int low, int high) // 操作结果:elem[low..high]中记录除elem[low]以外都满足堆定义,调整elem[low]使整个elem[low..high]成为一个大顶堆(小顶堆类似)
{
int f = low, i = 2 * low + 1; // f为被调整结点,i为f的最大孩子
while (i <= high) {
if (i < high && elem[i] < elem[i + 1]) {
// 右孩子更大,i指向右孩子
i++;
}
if (elem[f] > elem[i]) {
// 已成为大顶堆
break;
}
Swap(elem[f], elem[i]); // 交换elem[f]与elem[i]
f = i; // 成为新的调整结点
i = 2 * f + 1;
}
}
- 判别以下序列是否为堆,如果不是,将其调整为堆:
- 序列(100, 86, 48, 73, 35, 39, 42, 57, 66, 21):
- 首先判断是否为堆,根据堆的定义,对于大顶堆,父节点应大于等于子节点。计算可得100是根节点,其左子节点86和右子节点48满足100 \geq 86且100 \geq 48;继续判断86的子节点73和35,满足86 \geq 73且86 \geq 35;48的子节点39和42,满足48 \geq 39且48 \geq 42;以此类推,该序列是大顶堆。
- 序列(12, 70, 33, 65, 24, 56, 48, 92, 86, 33):
- 同样根据堆的定义判断,12是根节点,其左子节点70和右子节点33,12 < 70,不满足大顶堆条件,所以该序列不是堆。
- 调整为堆的过程(以调整为大顶堆为例):
- 从最后一个非叶子节点开始调整,最后一个非叶子节点是65((n - 2) / 2 = (10 - 2) / 2 = 4,对应元素65)。
- 比较65与其子节点24和56,65最大,无需调整。
- 继续调整上一个非叶子节点70,比较70与其子节点33和48,70最大,无需调整。
- 调整根节点12,12小于其较大子节点70,交换12和70,得到(70, 12, 33, 65, 24, 56, 48, 92, 86, 33)。此时12所在位置需继续调整,比较12与其子节点24和33,33最大,交换12和33,得到(70, 33, 12, 65, 24, 56, 48, 92, 86, 33)。继续比较12与其子节点65和56,65最大,交换12和65,得到(70, 33, 65, 12, 24, 56, 48, 92, 86, 33)。此时12已小于其子节点,调整完成,最终得到大顶堆(70, 33, 65, 12, 24, 56, 48, 92, 86, 33)。
10.5归并排序
归并排序
- 归并排序的基本思想:将序列看成n个有序的子序列(每个序列的长度为1),然后两两归并,得到\lceil\frac{n}{2}\rceil个长度为2或1的有序子序列,然后两两归并……这样重复下去,直到得到一个长度为n的有序子序列,这种排序方法称为2 -路归并排序。例如,对于初始序列(44) (38) (56) (30) (88) (80) (38),第一趟归并后得到(38 44) (30 56) (80 88) (38);第二趟归并后得到(30 38 44 56) (38 80 88);第三趟归并后得到(30 38 38 44 56 80 88)。
- 归并排序的实现:
cpp
template<class ElemType>
void MergeSort(ElemType elem[], int n)
// 操作结果:对elem进行归并排序
{
ElemType *tmpElem = new ElemType[n];
MergeSortHelp(elem, tmpElem, 0, n - 1);
delete []tmpElem;
}
template<class ElemType>
void MergeSortHelp(ElemType elem[], ElemType tmpElem[], int low, int high) // 操作结果:对elem[low..high]进行归并排序
{
if (low < high) {
int mid = (low + high) / 2;
MergeSortHelp(elem, tmpElem, low, mid); // 对elem[low..mid]进行归并排序,将elem[low..high]平分为elem[low..mid]和elem[mid + 1..high]
MergeSortHelp(elem, tmpElem, mid + 1, high); // 对elem[mid + 1..high]进行归并排序
Merge(elem, tmpElem, low, mid, high); // 对elem[low..mid]和elem[mid + 1..high]进行归并
}
}
template<class ElemType>
void Merge(ElemType elem[], ElemType tmpElem[], int low, int mid, int high) // 操作结果:将有序子序列elem[low..mid]和elem[mid + 1..high]归并为新的有序序列elem[low..high]
{
int i, j, k; // 临时变量
for (i = low, j = mid + 1, k = low; i <= mid && j <= high; k++) {
// 为归并时elem[low..mid]当前元素的下标,j为归并时elem[mid + 1..high]当前元素的下标,k为tmpElem中当前元素的下标
if (elem[i] <= elem[j]) {
// elem[i]较小,先归并
tmpElem[k] = elem[i];
i++;
} else {
// elem[j]较小,先归并
tmpElem[k] = elem[j];
j++;
}
}
for (; i <= mid; i++, k++) {
// 归并elem[low..mid]中剩余元素
tmpElem[k] = elem[i];
}
for (; j <= high; j++, k++) {
// 归并elem[mid + 1..high]中剩余元素
tmpElem[k] = elem[j];
}
for (i = low; i <= high; i++) elem[i] = tmpElem[i]; // 将tmpElem[low..high]复制到elem[low..high]
}
- 归并排序特点:归并排序的时间代价并不依赖于待排序数组的初始情况,其最好、平均和最坏情形的时间复杂度都为O(nlogn),这一点比快速排序好,并且归并排序还是稳定的,在平均情况下,快速排序最快(常数因子更小)。
10.6基数排序
基数排序
- 基数排序是多关键字排序,每一趟基数排序通过“分配”和“收集”实现。例如,对于扑克牌,有花色和面值两个关键字,假设花色的次序关系为\heartsuit < \diamondsuit < \clubsuit < \spadesuit,面值的次序关系为2 < 3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A,可以将扑克牌排序。
- 对于给定的关键字序列\{27, 91, 01, 97, 17, 23, 72, 25, 05, 67, 84, 07, 21, 31\},首先按其“个位数”取值分别为0, 1, …, 9“分配”成10组,然后按从0至9的顺序将它们“收集”在一起,得到01 21 31 05 07 17 27 67 97 23 72 25 84 91;接着按其“十位数”取值分别为0, 1, …, 9“分配”成10组,再按从0至9的顺序将它们“收集”在一起,得到01 05 07 17 21 23 25 27 31 67 72 84 91 97。
- 基数排序的实现:
cpp
template<class ElemType>
void Radixsort(ElemType elem[], int n, int r, int d) // 初始条件:r为基数,d为关键字位数,操作结果:对elem进行基数排序
{
LinkList<ElemType> *list; // 用于存储被分配的线性表数组
list = new LinkList<ElemType>[r];
for (int i = 1; i <= d; i++) { // 第i趟分配与收集
Distribute(elem, n, r, d, i, list); // 分配
Colect(elem, n, r, d, i, list); // 收集
}
delete []list;
}
template<class ElemType>
void Distribute(ElemType elem[], int n, int r, int d, int i, LinkList<ElemType> list[])
// 初始条件:r为基数,d为关键字位数,list[0..r - 1]为被分配的线性表数组,操作结果:进行第i趟分配
{
for (int power = (int)pow((double)r, i - 1), j = 0; j < n; j++) {
// 进行第i趟分配
int index = (elem[j] / power) % r;
list[index].Insert(list[index].Length() + 1, elem[j]);
}
}
template<class ElemType>
void Colect(ElemType elem[], int n, int r, int d, int i, LinkList<ElemType> list[])
// 初始条件:r为基数,d为关键字位数,list[0..r - 1]为被分配的线性表数组,操作结果:进行第i趟收集
{
for (int k = 0, j = 0; j < r; j++) {
// 进行第i趟分配
ElemType tmpElem;
while (!list[j].Empty()) {
// 收集list[j]
list[j].Delete(1, tmpElem);
elem[k++] = tmpElem;
}
}
}
10.7内部排序方法讨论
各种排序方法性能(表10.1)
表格
排序分类 排序名称 平均时间 时间复杂度 最坏时间 辅助空间 稳定性
简单排序 直接插入排序 稳定
起泡排序 稳定
简单选择排序 不稳定
高级排序 快速排序 不稳定
堆排序 不稳定
归并排序 稳定
其他排序 Shell排序 - 由增量序列确定 - 不稳定
基数排序 稳定
时间性能
1. 平均的时间性能
- 时间复杂度为O(nlogn):快速排序、堆排序、归并排序。
- 时间复杂度为O(n^{2}):直接插入排序、起泡排序、简单选择排序。
- 时间复杂度为O(n):基数排序(当d为常数时)。
2. 待排元素序列按关键字顺序有序
- 直接插入排序能达到O(n)的时间复杂度(因为只需比较n - 1次)。
- 快速排序的时间性能蜕化为O(n^{2})(因为每次划分只能得到一个元素的有序序列)。
3. 排序的时间性能不随关键字分布而改变的排序
- 简单选择排序、起泡排序、堆排序和归并排序的时间性能不随元素序列中关键字的分布而改变。
排序方法的稳定性能
- 稳定的排序方法指的是,对于两个关键字相等的元素,它们在序列中的相对位置,在排序之前和经过排序之后,没有改变。例如,排序前(56, 34, 47, 23, 66, 18, 82, 47),若排序后得到结果(18, 23, 34, 47, 47, 56, 66, 82),则稳定;若排序后得到结果(18, 23, 34, 47, 47, 56, 66, 82)(两个47的相对位置改变),则不稳定。快速排序、所有选择排序(包括堆排序)和希尔排序是不稳定的排序方法。
10.8外部排序
外部排序
- 问题的提出:待排序的元素数量很大,不能一次装入内存,无法利用前面讨论的内部排序方法。
- 基本过程:
- 按可用内存大小,利用内部排序方法,构造若干(元素的)有序子序列,通常称外存中这些元素有序子序列为“归并段”。
- 通过“归并”,逐步扩大(元素的)有序子序列的长度,直至外存中整个元素序列按关键字有序为止。
外部排序实例
- 假设有一个含10,000个元素的磁盘文件,而当前所用的计算机一次只能对1000个元素进行内部排序,则首先利用内部排序的方法得到10个初始归并段,然后进行逐趟归并。假设进行2 -路归并(即两两归并),则第一趟由10个归并段得到5个归并段;第二趟由5个归并段得到3个归并段;第三趟由3个归并段得到2个