数据结构复习知识点总结
Posted 计算机考研助手
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构复习知识点总结相关的知识,希望对你有一定的参考价值。
《数据结构》重点在线性表、树、图、查找和排序。参考书目是《数据结构》(C语言版)严蔚敏、吴伟民编著。通过对线性表、队列、栈和数组的了解,进一步理解其含义,熟悉各种例如进栈、出栈等基本操作,熟悉各种基本操作的算法如何实现,这是同学们编程能力的基础。然后一定要熟记各种树、图的操作及其各种定义和原理。最后是查找和排序,有很多不同的算法,同学们不能硬记,而是理解各个算法的基本原理,理解记忆和联系,适当做笔记加深记忆,反复练习。
一、绪论(时间和空间复杂度)
掌握数据结构的基本概念、基本原理和基本方法。
掌握数据的逻辑结构、存储结构及基本操作的实现,能够对算法进行基本的时间复杂度与空间复杂度的分析。
能够运用数据结构基本原理和方法进行问题的分析与求解,具备采用C或C++语言设计与实现算法的能力。
数据结构的基本概念
数据(data) 对客观事物的符号表示,在计算机科学中是指所有能输入到计算机中并被计算机程序处理的符号的总称。
数据元素(data element) 数据的基本单位,在计算机程序中通常作为一个整体进行考虑和处理。
数据项(data item) 有时一个数据元素可由若干数据项组成,数据项是数据不可分割的最小单位。
数据对象(data object)性质相同的数据元素的集合,数据的一个子集。
数据结构(data structure) 是相互之间存在一种或多种特定关系的数据元素的集合。
结构(structure) 数据元素之间关系的不同特性,有四类基本结构
集合:数据元素除“同属一个集合”外无其它关系。
线性结构:数据元素之间存在一个对一个的关系。
树形结构:数据元素之间存在一个对多个的关系。
图状结构或网状结构:数据元素之间存在多个对多个的关系。
数据类型(data type) 一个值的集合和定义在这个值集上的一组操作的总称。
原子类型:不可分解。
结构类型:由若干成分按某种结构组成的,成分可以是非结构的也可以是结构的。
抽象数据类型(Abstract Data Type,简称ADT) 指一个数学模型以及定义在该模型上的一组操作。
数据的逻辑结构
数据结构的形式定义为:数据结构是一个二元组 $$Data_Structure=(D,S)$$。其中 $D$ 是数据元素的有限集, $S$ 是 $D$ 上关系的有限集。此处“关系”描述的是数据元素之间的逻辑关系,因此又称为数据的逻辑结构。
数据的存储结构
数据结构在计算机中的表示(又称映像)称为数据的物理结构,又称存储结构。
数据元素之间的关系在计算机中有两种不同的表示方法:顺序映像和非顺序映像,并由此得到两种不同的存储结构:顺序存储结构和链式存储结构。
课本中讨论的存储结构是数据结构在 C 虚拟处理器中的表示,不妨称为虚拟存储结构。
算法
定义
是对特定问题求解步骤的一种描述。
基本特性
算法具有5个重要特性:
有穷性:必须总是(对任何合法的输入值)在执行有穷步之后结束,且每一步都在有穷时间内完成。
确定性:每一条指令必须有确切的含义。对于相同的输入只能得出相同的输出。
可行性:描述的操作都是可以通过已经实现的基本运算执行有限次来实现的。
输入:有零个或多个的输入。
输出:有一个或多个的输出。
设计一个“好”的算法应考虑达到:
正确性(correctness):对“正确”的理解有四个层次:
通常以第3层意义的正确性作为标准。
不含语法错误
对于几组输入数据能够得出满足要求的结果
对于精心选择的典型、苛刻而带有刁难性的几组输入数据能够得出满足要求的结果
对于一切合法的输入数据都能得出满足要求的结果
可读性(readability):有助于人对算法的理解。
健壮性(robustness):输入数据非法时应能适当地作出反应或进行处理。
效率与低存储量需求:时间复杂度与空间复杂度较低。
算法分析的基本概念
事后统计法:计算机内部进行执行时间和实际占用空间的统计。
必须先运行根据算法贬值的程序;依赖于计算机的软硬件的环境因素事前分析法
时间复杂度:以最深层循环内的语句中的原操作的频度(重复执行的次数)来表示。
基本操作的频度是问题规模 $n$ 的某个函数 $f(n)$,算法的时间量度记作 $T(n)=O(f(n))$。它表示随 $n$ 的增大,算法执行时间的增长率和 $f(n)$ 的增长率相同,称作算法的渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
$O$ :若 $f(n)$ 是正整数 $n$ 的一个函数,则 $x_n=O(f(n))$ 表示 $exist M geq 0$,使得 $ngeq n_0$ 时,$|x_n|leq M|f(n)|$。可理解为函数 $f(n)$ 的“下界”,无穷大接近。空间复杂度:算法所需存储空间的量度。
$S(n) = O(f(n))$。
$O(1)<O(log n)<O(n)<O(nlog n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)$
二、线性表知识点
线性表的定义、基本操作
线性表是n个数据元素的有限序列。
初始化线性表,销毁线性表,将表置空,判断是否为空,求表长,获得指定索引的值,插入元素,删除元素,求前驱或后继……
线性表的存储结构及操作实现
顺序存储结构
构造原理
// --- 线性表的动态分配顺序存储结构 ---
#define LIST_INIT_SIZE 100 // 线性表存储空间的初始分配量
#define LISTINCREMENT 10 // 线性表存储空间的分配增量
typedef struct{
ElemType *elem; // 存储空间基址
int length; // 当前长度
int listsize; // 当前分配的存储容量(以 sizeof(ElemType)为单位)
}SqList;
主要操作的算法设计与实现
初始化线性表
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;
}
插入元素
插入一个元素后,需要将原该位置至最后一个元素向右移动一位。
Status ListInsert_Sq(SqList *L, int i, ElemType e){
// 在顺序线性表 L 中第 i 个位置之前插入新的元素 e
// i 的合法值为 [1, ListLength_Sq(L) + 1]
if(i < 1 || i > L->length+1) return ERROR; // i 值不合法
if(L->length >= L->listsize){ // 当前存储空间已满,增加分配
ElemType *newbase = (ElemType *)realloc(L->elem,
(L->listsize+LISTINCREMENT) * sizeof(ElemType));
if (! newbase) exit(OVERFLOW); // 存储分配失败
L->elem = newbase; // 新基址
L->listsize += LISTINCREMENT; // 增加存储容量
}
ElemType *q = &(L->elem[i-1]); // q 为插入位置
ElemType *p;
for(p = &(L->elem[L.length - 1]); p>=q; --p)
*(p+1) = *p; // 插入位置及之后的元素右移
*q = e; // 插入 e
++L->length; // 表长增1
return OK;
}
平均时间复杂度为 $O(n)$。
删除元素
删除一个元素后,需要将原该位置至最后一个元素向左移动一位。
Status ListDelete_Sq(SqList *L, int i, ElemType *e){
// 在顺序线性表 L 中删除第 i 个元素,并用 e 返回其值
// i 的合法值为 [1, ListLength_Sq(L)]
if(i < 1 || i > L->length) return ERROR; // i 值不合法
ElemType *p, *q;
p = &(L.elem[i-1]); // p 为被删除元素的位置
*e = *p; // 被删除元素的值赋给 e
q = L->elem + L->length - 1; // q 为表尾元素的位置
for(++p; p<=q; ++p)
*(p-1) = *p; // 删除位置及之后的元素左移
--L.length; // 表长减1
return OK;
}
平均时间复杂度为 $O(n)$。
定位元素
int LocateElem_Sq(SqList *L, ElemType e,
Status(*compare)(EmemType, ElemType)){
// 在顺序线性表 L 中查找第1个值与 e 满足 compare() 的元素的位序
// 若找到,则返回其在 L 中的位序,否则返回0
int i;
ElemType *p;
i = 1; // i 的初值为第一个元素的位序
p = L->elem; // p 的初值为第一个元素的存储位置
while(i <= L->length && (*compare)(*p, e)!=0){
p++; i++;
}
if(i <= L->length) return i;
else return 0;
}
平均时间复杂度为 $O(n)$。
顺序表的合并
void MergeList_Sq(SqList *La, SqList *Lb, SqList*Lc){
// 已知顺序线性表 La 和 Lb 的元素按值非递减排列
// 归并 La 和 Lb 得到新的顺序线性表 Lc,Lc 的元素也按值非递减排列
ElemType *pa, *pb, *pc, *pa_last, *pb_last;
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++ = *pa++;
}
while(pa <= pa_last) *pc++ = *pa++; // 插入 La 的剩余元素
while(pb <= pb_last) *pc++ = *pb++; // 插入 Lb 的剩余元素
}
时间复杂度为 $O(La->length + Lb->length)$。
链式存储结构
可以用一组任意的存储单元存储线性表的数据元素。
结点(node)是数据元素的存储映像,它包括数据域和指针域。
数据域中存储数据元素信息。
指针域中存储直接后继存储位置,这一位置信息被称为指针或链。
单链表、循环链表和双向链表
单链表的每个结点中只包含一个直接指向后继指针域。
循环链表整个链表的指针域链接成环。
双向链表每一个结点包含两个指针域,其一指向直接后继,另一指向直接前驱。
双向循环链表将头结点和尾结点链接起来的双向链表。
静态链表借用一维数组来描述线性链表。
构造原理
// --- 线性表的单链表存储结构 ---
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, LinkList;
// --- 线性表的双向链表存储结构 ---
typedef struct DuLNode{
ElemType data;
struct DuLNode *prior, *next;
}DuLNode, DuLinkList;
// --- 线性表的静态单链表存储结构 ---
# define MAXSIZE 100
typedef struct{
ElemType data;
int cur;
}component, SLinkList[MAXSIZE];
主要操作的算法设计与实现
创建单链表
LinkList *CreateList_L(int n){
// 逆序输入 n 个元素的值,建立带表头结点的单链表 L
LinkList *L, *p; int i;
L = (LinkList *)malloc(sizeof(LNode));
L->next = NULL; // 先建立一个带头结点的单链表
for(i=n; i>0; --i){
p = (LNode *)malloc(sizeof(LNode)); // 生成新结点
p->data = random(200); // 填入随机数
p->next = L->next; L->next = p; // 插入到表头
}
return L;
}
插入元素
Status ListInsert_L(LinkList *L, int i, ElemType e){
// 在带头结点的单链表 L 中第 i 个位置之前插入元素 e
LinkList *p, *s;
p = L; int j = 0;
while(p && j<i-1) {p = p->next; ++j;} // 寻找第 i-1 个结点
if(!p || j>i-1) return ERROR; // i 小于1或大于表长加1
s = (LinkList*)malloc(sizeof(LNode)); // 生成新结点
s->data = e; s->next = p->next; // 插入 L 中
p->next = s;
return OK;
}
Status ListInsert_DuL(DuLinkList *L, int i, ElemType e){
// 在带头结点的双向循环链表 L 中第 i 个位置之前插入元素 e
// i 的合法值为[1, 表长+1]
DuLinkList *p, *q; int j;
if(i<1) return ERROR;
p = L; j = 0;
while(p->next!=L && j<i-1){ // 在 L 中确定插入位置
p = p->next; j++;
}
if(p->next!=L || (p->next==head&&j==i-1)){
q = (DuLinkList*)malloc(sizeof(DuLinkList));
q->data = e;
q->next = p->next; q->prior = p;
p->next->prior = q; p->next = q;
return OK;
}
else return ERROR;
}
平均时间复杂度均为 $O(1)$。
删除元素
Status ListDelete_L(LinkList *L, int i, ElemType *e){
// 在带头结点的单链表 L 中删除第 i 个元素,并由 e 返回其值
int j = 0; LinkList *p, *q;
p = L;
while(p->next && j<i-1){ // 寻找第 i 个结点,并令 p 指向其前驱
p = p->next; ++j;
}
if(!(p->next) || j>i-1) return ERROR; // 删除位置不合理
q = p->next; p->next = q->next; // 删除并释放结点
e = q->data; free(q);
return OK;
}
Status ListDelete_DuL(DuLinkList *L, int i, ElemType *e){
// 删除带头结点的双向循环链表 L 的第 i 个元素,i 的合法值为[1, 表长]
DuLinkList *p, *q; int j;
if(i<1) return ERROR;
p = L->next; j = 1;
while(p!=L && j<i){
p = p->next; j++;
}
if(p==L) return ERROR;
p->prior->next = p->next;
p->next->prior = p->prior;
*e = p->data;
free(p);
return OK;
}
平均时间复杂度均为 $O(1)$。
顺序表的合并
LinkList *MergeList_L(LinkList *La, LinkList *Lb){
// 已知单链表 La 和 Lb 的元素按值非递减排列
// 归并 La 和 Lb 得到新的单链表 Lc,Lc 的元素也按值非递减排列
LinkList *Lc, *pa, *pb, *pc;
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 的头结点
return Lc;
}
时间复杂度为 $O(m+n)$。
线性表的应用
一元多项式的表示及相加:多项式的次数可能很高且变化很大,且很多不同次数项的系数可能为0,故也可采用利用结构体同时存储系数和指数的链式存储。
为0,故也可采用利用结构体同时存储系数和指数的链式存储。
三、堆和栈知识点
基本概念
栈是限制在线性表的一端进行插入和删除操作的线性表,也称为后进先出(Last In First Out, LIFO)线性表。
栈顶(top)允许进行插入、删除操作的一段,也称为表尾。
栈底(bottom)固定端,也称为表头。
空栈 表中没有元素。
基本操作
进栈(push)、出栈(pop)。
初始化、求长、判空……
存储结构及操作实现
顺序存储结构
// --- 栈的动态分配顺序存储结构 ---
#define STACK_INIT_SIZE 100 // 存储空间初始分配量
#define STACKINCREMENT 10 // 存储空间分配增量
typedef struct {
SElemType *base; // 在栈构造之前和销毁之后,base 的值为 NULL
SElemType *top; // 栈顶指针
int stacksize; // 当前已分配的存储空间,以元素为单位
}DySqStack;
// --- 栈的静态分配顺序存储结构 ---
# define MAX_STACK_SIZE 100
typedef struct{
SElemType stack_array[MAX_STACK_SIZE];
int top;
}StSqStack;
链式存储结构
typedef struct Node{
SElemType data;
struct Node *next;
}LinkedStack;
应用
数制转换
十进制数 $N$ 和其他 $d$ 进制数的转换基于原理:$N=(N / d) imes d + N % d$,其中 $/$ 为整除运算,$%$为求余运算。
void conversion(int n, int d){
int e;
DySqStack s;
InitStack(&s);
while(n){
Push(&s, n%d);
n = n/d;
}
while(!IsStackEmpty(&s)){
Pop(&s, &e); printf("%d", e);
}
}
算术表达式求值
设置 OPTR 和 OPND 两个工作栈,分别用于存放算符,和操作数及结果。
#define OPSETSIZE 7
char OPSET[OPSETSIZE]={'+', '-', '*', '/', '(', ')', '#'};
bool In(char test, char *testOp); // 判断 test 是否属于 testOp
char precede(char a, char b); // 返回两算符之间的优先关系
float Operate(float a, char theta, float b); // 执行四则运算
float EvaluateExpression(char* MyExpression){
StackChar OPTR;
StackFloat OPND;
char TempData[20]; strcpy(TempData, ' ');
float data, a, b; char thera, *c, x, Dr[2];
InitStack(OPTR); Push(OPTR, '#');
InitStack(OPND); c = MyExpression;
while(*c!='#' || GetTop(OPTR)!='#'){
if(!In(*c, OPSET)){ // *c 不是运算符,则进运算数栈
Dr[0] = *c; Dr[1] = ' '; strcat(TempData, Dr);
c++;
if(In(*c, OPSET)){
data = (float)atof(TempData); Push(OPND, data);
strcpy(TempData, ' ');
}
} else { // *c 是运算符,则根据它与栈顶的优先关系做相应的操作
switch(precede(GetTop(OPTR), *c)){
case '<': // 栈顶元素优先级低,则将读到的算符进栈
Push(OPTR, *c); c++; break;
case '=': // 脱括号并接受下一字符
Pop(OPTR, &x); c++; break;
case '>': // 栈顶算符出栈并将运算结果入操作数栈
Pop(OPTR, &theta); Pop(OPND, &b); Pop(OPND, &a);
Push(OPND, Operate(a, theta, b)); break;
}// switch
}// if
}// while
return GetTop(OPND);
}
递归的实现
递归(recursive)一个函数直接或间接地调用自己。有效的递归包括两部分:递推规则和终止条件。
例(Hanoi 塔):假设有三个分别命名为 X、Y 和 Z 的柱子,在柱子 X 上有 n 个直径大小各不相同、从小到大编号为1,2,…n 的圆盘。现要求将圆盘移至 Z 柱上并按同样顺序叠排,圆盘移动时必须遵循下列规则:每次只能移动一个圆盘;圆盘可以插在任一柱之上;任何时刻都不能将一个较大的圆盘压在较小的圆盘之上。
解:
int c = 0;
void move(char x, int n, char z){
printf("%2d. Move disk %d from %c to %c. ", ++c, n, x, z);
}
void hanoi(int n, char x, char y, char z){
if(n == 1)
move(x, 1, z); // 将编号为1的圆盘从 x 移到 z
else{
hanoi(n-1, x, z, y); // 将 x 上编号为1至n-1的圆盘移到 y,z 作辅助塔
move(x, n, z); // 将编号为n的圆盘从 x 移到 z
hanoi(n-1, y, x, z); // 将 y 上编号为1至n-1的圆盘移到 z,x 作辅助塔
}
}
每进入一层递归,就产生一个新的工作记录压入栈顶;每退出一层递归,就从栈顶弹出一个工作记录。
队列
基本概念
队列(queue)只允许在表的一端进行插入,而在另一端删除,是一种先进先出(First In First Out, FIFO)的线性表。
队头(front)允许进行删除的一段。
队尾(rear)允许进行插入的一端。
循环队列(circular queue)将为队列分配的向量空间看作一个首尾相接的圆环。
基本操作
入队(enqueue)、出队(dequeue)。
初始化、求长、判空……
存储结构及操作实现
顺序存储结构
#define MAXQSIZE 100
typedef struct{
QElemType queue_array[MAX_QSIZE]; // 最大存储空间
int front; // 头指针,若队列不空,指向队列头元素
int rear; // 尾指针,若队列不空,指向队列尾元素的下一个位置
}SqQueue;
链式存储结构
typedef struct QNode{
QElemType data;
struct QNode *next;
}QNode, *QueuePtr;
typedef struct{
QueuePtr front; // 队头指针
QueuePtr rear; // 队尾指针
}LinkQueue;
应用
杨辉三角/二项式系数生成
杨辉三角:每个数字等于其上一行的左右两个数字之和。
void PascTriangle(int total_row){
// 生成总共 total_row 行的杨辉三角值,total_row > 1
SqQueue q; int e;
int cur_row_i, cur_row_i_1, next_row_i;
InitQueue(&q); Enqueue(&q, 1); Enqueue(&q, 1);
printf("1 ");
for(int r = 1; r<total_row; r++){
Enqueue(&q, 0);
for(int c = 1; c<=r+2; c++){
Dequeue(&q, &e);
cur_row_i = e;
if(e) printf("%3d ", e);
next_row_i = cur_row_i + cur_row_i_1;
Enqueue(&q, next_row_i);
cur_row_i_1 = cur_row_i;
}
printf(" ");
}
}
运动会日程安排
例:某运动会设立 N 个比赛项目,每个运动员可以参加一至三个项目,试问如何安排比赛日程可以使同一运动员参加的项目不安排在同一单位时间进行。先假设该运动会共9个项目,七名运动员报名参加的项目分别为(1,4,8)(1,7)(8,3)(1,0,5)(3,4)(5,6,2)(6,4)。解:子集划分问题:将 n 个元素组成的集合 A 划分成 k 个互不相交的子集,使同一子集中的元素均无冲突关系。
算法思想:从第一个元素开始,凡与第一个元素不冲突的元素划归为一个子集;再将剩下的元素重新找出互不冲突的元素,划归为第二个子集;依此类推,知道所有元素都进入某个子集为止。
用矩阵
conflictMatrix[n][n]
表示元素之间的冲突关系。用队列存放集合元素。
用数组
result[n]
存放每个元素的分组号。用工作数组
clash[n]
记录与第 k 组已入组元素有冲突的元素情况,可减少重复查看conflictMatrix
数组的时间。
全体集合元素入队列;
取队头元素,组号为1,设置 clash 为队头元素在矩阵中的行值;
while(队列不空){
取队头元素 x;
若它与当前组的元素没有冲突,即 clash[x] == 0{
在 result 中设置该元素对应的分组号;
在 clash 记录之上叠加该元素的冲突情况;
}
否则,将 x 再次入队;
判断是否走完一轮,即当前队列的队头的值是否小于前次取的队头的值;
若走完一轮,则组号加1,clash 初始化为0;
}
四、数组和广义表知识点
数组的概念
多维数组的实现
n 维数组的特点是每一个数据元素受 n 个线性关系的约束,可以有多个直接前驱和多个直接后继。
二维数组 A[m][n]
可以看成是由 m 个行向量组成,也可以看成是由 n 个列向量组成,有两种顺序映像的方式:以行序为主序和以列序为主序。其中任一元素 a[i][j](0 <= i < m, 0 <= j < n)
的存储位置为:
以行为先:$LOC(i, j) = LOC(0, 0) + (i * n + j)L$
以列为先:$LOC(i, j) = LOC(0, 0) + (j * m + i)L$
随机存储结构:存取数组中任一元素的时间相等。
压缩存储
特殊矩阵:值相同的元素或零元素在矩阵的分布中有一定规律。
对称矩阵
可以只存上三角矩阵或下三角矩阵,即可将 $n^2$ 个元压缩存储到 $n(n+1)/2$ 个元的空间中。
三对角矩阵:除主对角线及在主对角线上下最邻近的两条对角线上的元素外,其它所有元素均为0。共有 $3n-2$ 个非零元素。
稀疏矩阵
稀疏因子:在 $m imes n$ 的矩阵中,有 $t$ 个元素不为零,$delta = frac{t}{m imes n}$。
稀疏矩阵:$delta leq 0.05$。
三元组顺序表
// --- 稀疏矩阵的三元组顺序表存储表示 ---
#define MAXSIZE 12500
typedef struct{
int i, j; // 该非零元的行下标和列下标
ElemType e;
}Triple;
typedef struct{
Tyiple data[MAXSIZE + 1]; // 非零元三元组表,data[0] 未用
int mu, nu, tu; // 矩阵的行数、列数和非零元个数
}TSMatrix;
Status TransposeSMatrix(TSMatrix M, TSMatrix* T){
// 采用三元组表存储表示,求稀疏矩阵 M 的转置矩阵 T
// 时间复杂度为 O(mu * tu)
int p, q, col;
T->mu = M.nu; T->nu = M.mu; T->tu = M.tu;
if(T.tu){
q = 1;
for(col = 1; col <= M.nu; ++col)
for(p = 1; 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 = M.data[p].e;
q++;
}
}
return OK;
}
Status FastTransposeSMatrix(TSMatrix M, TSMatrix* T){
// 采用三元组表存储表示,求稀疏矩阵 M 的转置矩阵 T
// 时间复杂度为 O(nu + tu)
int col, t, p, q;
int num[M.nu + 1], cpot[M.nu + 1];
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;
for(t = 1; t <= M.tu; t++) ++num[M.data[t].j]; // 求 M 中每一列含非零元个数
cpot[1] = 1;
for(col = 2; col <= M.nu; ++col) // 求第 col 列中第一个非零元在 T->data 中的序号
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[p].e; ++cpot[col];
}
}
return OK;
}
行逻辑链接的顺序表
typedef struct{
Triple data[MAXSIZE + 1];
int rpos[MAXMN + 1]; // 各行第一个非零元的位置表
int mu, nu, tu;
}RLSMatrix;
Status MultSMatrix(RSLMatrix M, RSLMatrix N, RSLMatrix* Q){
// 求矩阵乘积 Q=M*N,采用行逻辑链接存储表示
if(M.nu != N.mu) return ERROR;
Q->mu = M.mu; Q->nu = N.nu; Q->tu = 0; // Q 初始化
int arow, brow, tp, p, t, ccol;
if(M.tu * N.tu != 0){ // Q 是非零矩阵
for(arow = 1; arow <= M.mu; ++arow){ // 处理 M 的每一行
int ctemp[N.nu] = {0}; // 当前行各元素累加器清零
Q->rpos[arow] = Q.tu + 1;
if(arow < M.mu) tp = M.rpos[arow + 1];
else tp = M.tu + 1;
for(p = M.rpos[arow]; p < tp; ++p){ // 对当前行中每一个非零元
brow = M.data[p].j; // 找到对应元在 N 中的行号
if(brow < N.mu) t = N.rpos[brow + 1];
else t = N.tu + 1;
for(q = N.rpos[brow]; q < t; ++q){
ccol = N.data[q].j; // 乘积元素在 Q 中列号
ctemp[ccol] += M.data[p].e * N.data[q].e;
}
}
for(ccol = 1; ccol <= Q->nu; ++ccol){ // 压缩存储该行非零元
if(ctemp[ccol]){
if(++Q->tu > MAXSIZE) return ERROR;
Q->data[Q->tu].i = arow;
Q->data[Q->tu].j = ccol;
Q->data[Q->tu].e = ctmp[ccol];
}// if
}// for ccol
}// for arow
}
return OK;
}
十字链表
typedef struct OLNode{
int i, j; // 该非零元的行和列下标
ElemType e;
struct OLNode *right, *down; // 该非零元所在行表和列表的后继链域
}OLNode, *OLink;
typedef struct{
OLink *rhead, *chead; // 行和列链表头指针向量基址在创建时分配
int mu, nu, tu; // 稀疏矩阵的行数、列数和非零元个数
}CrossList;
广义表的基本概念
广义表是递归定义的线性结构,是 $n(geq 0)$ 个表元素组成的有限序列。其中每个表元素既可以是广义表(称为子表),也可以是数据元素(称为原子)。
当表非空,即 $n>0$ 时,第一个表元素被称为广义表的表头(head),其它表元素组成的表称为广义表的表尾(tail)。
typedef enum {ATOM, LIST} ElemTag; // ATOM==0:原子,LIST==1:子表
// --- 广义表的头尾链表存储表示 ---
typedef struct GLNode{
ElemTag tag; // 公共部分,用于区分原子结点和表结点
union{ // 原子结点和表结点的联合部分
AtomType atom; // atom 是原子结点的值域
struct {struct GLNode *hp, *tp;}ptr;
// ptr 是表结点的指针域,ptr.hp 和 ptr.tp 分别指向表头和表尾
}
}*GList;
// --- 广义表的扩展线性链表存储表示 ---
typedef struct GLNode{
ElemTag tag; // 公共部分,用于区分原子结点和表结点
union{ // 原子结点和表结点的联合部分
AtomType atom; // 原子结点的值域
struct GLNode *hp; // 表结点的表头指针
};
struct GLNode *tp; // 相当于线性链表的 next,指向下一个元素结点
}*GList;
int GListDepth(GList L){
// 采用头尾链表存储结构,求广义表 L 的深度。
if(!L) return 1; // 空表深度为1
if(L->tag == ATOM) return 0; // 原子深度为0
int max, dep;
GList pp;
for(max = 0, pp = L; pp; pp = pp->ptr.tp){
dep = GListDepth(pp->ptr.hp); // 求以 pp->ptr.hp 为头指针的子表深度
if(dep > max) max = dep;
}
return max + 1; // 非空表的深度是各元素深度的最大值加1
}
五、树和二叉树知识点
树及图部分尤其推荐结合图片理解。
树
树(tree)是 $n(ngeq 0)$ 个结点的有限集。
在任意一棵非空树中,有且仅有一个称为根(root)的结点,当 $n>1$ 时,其余结点可分为 $m(m>0)$ 个互不相交的有限集,其中每一个集合本身又是一棵树,称为根的子树(subtree)。
结点拥有的子树数称为结点的度(degree),树的度为树中所有结点的最大值,度为0的结点称为叶子(leaf)或终端结点,度不为0的结点称为分支结点或非终端结点。
结点的子树的根称为该结点的孩子(child),该结点称为孩子的双亲(parent),同一个双亲的孩子之间互称兄弟(sibling),结点的祖先是从根到该结点所经分支上的所有结点,以某结点为根的子树中的任一结点都称为该结点的子孙。
如果将树中结点的各子树看成从左至右是有次序的,则称该树为有序树,否则成为无序树。
结点的层次(level)从根开始定义起,根为第一层,根的孩子为第二层。树中结点的最大层次称为树的深度(depth)。
森林(forest)是 $m(mgeq 0)$ 课互不相交的树的集合。
二叉树
概念
二叉树(binary tree)的特点是每个结点至多只有两棵子树,且是有序树。
性质
在二叉树的第 $i$ 层上至多有 $2^{i-1}$ 个结点 $(igeq 1)$。
深度为 $k$ 的二叉树至多有 $2^k-1$ 个结点 $(kgeq 1)$。
对任何一棵二叉树 $T$,如果其终端结点数为 $n_0$,度为2的结点数为 $n_2$,则 $n_0=n_2+1$。
一棵深度为 $k$ 且有 $2^k-1$ 个结点的二叉树称为满二叉树(full binary tree)。
树中所含的 $n$ 个结点与同深度满二叉树中从上到下、从左到右编号为1至 $n$ 的结点一一对应时,称为完全二叉树(complete binary tree)。
完全二叉树的两个重要性质:
具有 $n$ 个节点的完全二叉树深度为 $lfloor log_2n floor+1$。
如果对一棵有 $n$ 个结点的完全二叉树的结点按层序编号,则对任一结点 $i(1leq i leq n)$ 有:
如果 $i=1$,则结点 $i$时二叉树的根,无双亲;如果 $i>1$,则其双亲 PARENT($i$) 是结点 $lfloor i/2 floor$。
如果 $2i>n$,则结点 $i$ 无左孩子(结点 $i$ 为叶子结点);否则其左孩子 LCHILD($i$) 是结点 $2i$。
如果 $2i+1>n$,则结点 $i$ 无右孩子;否则其右孩子 RCHILD($i$) 是结点 $2i+1$。
顺序存储结构
把同深度的满二叉树按从上到下,从左到右的顺序给结点编号,按编号储存。仅适用于完全二叉树。
// --- 二叉树的顺序存储表示 ---
#define MAX_TREE_SIZE 100
typedef TElemType SqBiTree[MAX_TREE_SIZE];
SqBiTree bt;
链式存储结构
// --- 二叉树的二叉链表存储表示 ---
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;
遍历
遍历二叉树(traversing binary tree)按某条搜索路径巡访树中每个结点,使得每个结点均被访问一次且只被访问一次。
若将访问根节点记作D,遍历根的左子树记作L,遍历根的右子树记作R,则先序遍历访问次序为 DLR,中序遍历访问次序为 LDR,后序遍历访问次序为 LRD。
Status PreorderTraverse(BiTree T, Status(*Visit)(TElemType e)){
// 先序遍历二叉树的递归算法
if(T){
if(Visit(T->data))
if(PreorderTraverse(T->lchild, Visit))
if(PreorderTraverse(T->rchild, Visit))
return OK;
return ERROR;
}else return OK;
}
Status PreorderTraverse(BiTree T, Status (*Visit)(TElemType e)){
// 先序遍历二叉树的迭代算法
stack S;
BiTree p;
InitStack(S);
if(T) Push(S, T);
while(!StackEmpty(S)){
Pop(S, p); Visit(p->data);
if(p->rchild) Push(S, p->rchild);
if(p->lchild) Push(S, p->lchild);
}
}
森林
存储结构
#define MAX_TREE_SIZE 100
// --- 树的双亲表存储表示 ---
typedef struct PTNode{
TElemType data;
int parent; // 双亲位置域
}PTNode;
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int r, n; // 根的位置和结点数
}PTree;
// --- 树的孩子链表存储表示 ---
typedef struct CTNode{
int child;
CTNode *next;
}*ChildPtr;
typedef struct{
TElemType data;
ChildPtr firstchild; // 孩子链表头指针
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n, r; // 结点数和根的位置
}CTree;
// --- 树的二叉链表(孩子-兄弟)存储表示 ---
typedef struct CSNode{
ElemType data;
CSNode *firstchild, *nextsibling;
}CSNode, *CSTree;
遍历
若将森林中所有树的根结点看作兄弟,可实现森林与二叉树的互相转换。
可对树进行先根次序遍历(先访问根节点,依次先根遍历各棵子树)和后根次序遍历(先依次后根遍历各课子树,再访问根节点),分别对应二叉树的前序遍历和中序遍历。
线索二叉树
基本概念
二叉树的遍历实际上是将二叉树的非线性结构线性化,使任一数据都有它的前驱和后继,但这种信息只有在遍历过程中才能得到。将某种遍历顺序下的前驱、后继关系(线索)记在树的存储结构中,称为二叉树的线索化。
加上线索的二叉树称为线索二叉树(threaded binary tree)。
构造
$n$ 个结点的二叉树必有 $n+1$ 个空链域,可利用这些空链域进行线索化。
typedef enum PointerTag{Link, Thread}; // Link==0:指针,Thread==1:线索
typedef struct BiThrNode{
TElemType data;
struct BiThrNode *lchild, *rchild; // 左右指针
PointerTag LTag, RTag; // 左右标志
}BiThrNode, *BiThrTree;
Status InOrderTraverse_Thr(BiThrTree T, Status(*Visit)(TElemType e)){
// T 指向头结点,头结点的左链指向根节点,可参见线索化算法。
// 中序遍历线索二叉树的非递归算法。
BiThrTree p = T->lchild;
while(p != T){
while(p->LTag == Link) p = p->lchild;
if(!Visit(p->data)) return ERROR;
while(p->RTag == Thread && p->rchild != T){
p = p->rchild; Visit(p->data);
}
p = p->rchild;
}
return OK;
}
BiThrTree pre; // 全局变量,始终指向刚访问的结点。
Status InOrderThreading(BiThrTree* Thrt, BiThrTree T){
// 中序遍历二叉树 T,并将其中序线索化,Thrt 指向头结点。
if(!(Thrt = (BiThrTree)malloc(sizeof(BiThrNode)))) exit(OVERFLOW);
Thrt->LTag = Link; Thrt->RTag = Thread;
Thrt->rchild = Thrt;
if(!T) Thrt->lchild = Thrt;
else{
Thrt->lchild = T; pre = Thrt;
InThreading(T);
pre->rchild = Thrt; pre->RTag = Thread; // 最后一个结点线索化
Thrt->rchild = pre;
}
return OK;
}
void InThreading(BiThrTree p){
if(p){
InThreading(p->lchild);
if(!p->lchild){p->LTag = Thread; p->lchild = pre;} // 前驱线索
if(!pre->rchild){pre->RTag = Thread; pre->rchild = p;} // 后继线索
pre = p;
InThreading(p->child);
}
}
哈夫曼(Huffman)树
概念
从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径,路径上的分支数目称做路径长度。树的路径长度是从树根到每一结点的路径长度之和。树的带权路径长度为树中所有叶子结点的带权路径长度之和。
带权路径长度 WPL 最小的二叉树称做最优二叉树或哈夫曼树(赫夫曼树)。
赫夫曼树中没有度为1的结点,任意非叶子结点都有2个儿子,这类树又称为正则二叉树。
构造算法
贪心算法是指在对问题求解时,总是做出在当前看来是最好的选择,即做出的是在某种意义上的局部最优解。但对相当广范围的许多问题是能产生整体最优解的,或者是整体最优解的近似解。
哈夫曼算法如下:
根据给定的 $n$ 个权值构成 $n$ 棵二叉树的集合,其中每棵二叉树中只有一个带权的根节点,其左右子树均为空。
从集合中选取两棵根结点权值最小的树作为左右子树构造一棵新的二叉树,根结点的权值为左右子树根结点权值之和。
从集合中删除这两棵树,并将新生成的树加入集合。
重复 2 和 3,直到集合中只剩一棵树为止,这棵树就是哈夫曼树。
哈夫曼编码
前缀编码:任意一个字符的编码都不是另一个字符的编码的前缀。
构造以出现频率为权值的哈夫曼树,就能得到相应的哈夫曼编码,这是一种最优前缀编码,即使所传电文的总长度最短。
六、图的知识点
图的基本概念
图中的数据元素通常称作顶点(vertex),若两个顶点之间存在关系,则 $<v, w>$ 可表示从顶点 $v$ 到顶点 $w$ 的一条弧(arc),且称 $v$ 为弧尾(tail)或初始点(initial node),$w$ 为弧头(head)或终端点(terminal node),此时的图称为有向图(digraph)。若有 $<v, w>$ 则必有 $<w, v>$ ,即顶点之间的关系集合是对称的,则以无序对 $(v, w)$ 代替两个有序对,表示 $v$ 和 $w$ 之间的一条边(edge),此时的图称为无向图(undigraph)。
每条边连接两个不同的顶点且没有两条不同的边连接一对相同顶点的图称为简单图(simple graph),反之称为多重图(multi graph)。在每对不同顶点之间都恰好有一条边的简单图,即有 $n(n-1)/2$ 条边的无向图称为完全图(complete graph),有 $n(n-1)$ 条弧的有向图称为有向完全图。
有很少边或弧的图 ($e<nlog n$)的图称为稀疏图(sparse graph),反之称为稠密图(dense graph)。
有时图的边或弧具有与它相关的数,叫做权(weight),权可表示一个顶点到另一个顶点的距离或耗费。带权的图通常称为网(network)。
假设有两个图 $G=(V,{E})$ 和 $G'=(V',{E'})$,如果 $V'subset V$ 且 $E'subset E$,则称 $G'$ 是 $G$ 的子图(subgraph)。如果 $V'=V$ 且 $E'subset E$,则 $G'$ 是 $G$ 的生成子图(spanning subgraph)。
对于无向图,如果有边 $(v, w)$,则称顶点 $v$ 和 $w$ 互为邻接点(adjacent),即 $v$ 和 $w$ 相邻接。边 $(v, w)$ 依附(incident)于顶点 $v$ 和 $w$。顶点 $v$ 的度(degree)是和 $v$ 相关联的边的数目,记为 $TD(v)$。握手定理(handshaking theorem):所有顶点的度的和是图中边的2倍。
对于有向图,如果有弧 $<v, w>$,则称顶点 $v$ 邻接到顶点 $w$,顶点 $w$ 邻接自顶点 $v$。弧 $(v, w)$ 和顶点 $v$, $w$ 相关联。以顶点 $v$ 为头的弧的数目称为 $v$ 的入度(indegree),记为 $ID(v)$;以顶点 $v$ 为尾的弧的数目称为 $v$ 的出度(outdegree),记为 $OD(v)$;顶点 $v$ 的度为出度和入度之和,即 $TD(v)=ID(v)+OD(v)$。
图的存储
邻接矩阵法
以一维数组存储顶点信息,以二维数组存储边或弧的信息。
// --- 图的数组(邻接矩阵)存储表示 ---
# define INFINITY INT_MAX // 最大值
# define MAX_VERTEX_NUM 20 // 最大顶点个数
typedef enum {DG, DN, UDG, UDN} GraphKind; // {有向图,有向网,无向图,无向网}
typedef struct ArcCell{
VRType adj; // 顶点关系类型。对无权图用1或0表示是否相邻,对带权图则为权值类型。
InfoType *info; // 该弧相关关系的指针
}ArcCell, AdjMatrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
typedef struct{
VertexType vexs[MAX_VERTEX_NUM]; // 顶点向量
AdjMatrix arcs; // 邻接矩阵
int vexnum, arcnum; // 图的当前顶点数和弧数
GraphKind kind; // 图的种类标志
}MGraph;
邻接表法
链式存储,对每个顶点建立一个单链表来表示依附于该顶点的边或以该顶点为尾的弧。
// --- 无向图的邻接表存储表示 ---
# define MAC_VERTEX_NUM 20
typedef struct node{
int vindex; // 邻接点在头结点数组中的位置(索引)
node *next; // 指向下一个表结点
}NodeLink; // 表结点类型定义
typedef struct VNode{
VertexType data; // 顶点指针
NodeLink *first; // 指向第一条依附该顶点的边的指针
}VNode, AdjList[MAX_VERTEX_NUM];
typedef struct{
AdjList v;
int vexnum, arcnum; // 图的当前顶点数和弧数
int kind; // 图的种类标志
}ALGraph;
十字链表法
有向图的一种链式存储结构,对应每条弧和每个顶点各有一个结点。
// --- 有向图的十字链表存储表示 ---
# define MAX_VERTEX_NUM 20
typedef struct ArcBox{
int tailvex, headvex; // 该弧的尾和头顶点的位置
struct ArcBox *hlink, *tlink; // 分别为弧头相同和弧尾相同的弧的链域
InfoType *info; // 该弧相关信息的指针
}ArcBox;
typedef struct VexNode{
VertexType data;
ArcBox *firstin, *firstout; // 分别指向该定点第一条出弧和入弧
}VexNode;
typedef struct{
VexNode xlist[MAX_VERTEX_NUM]; // 表头向量
int vexnum, arcnum; // 有向图的当前顶点数和弧数
}OLGraph;
临界多重表法
无向图的一种链式存储结构。
// --- 无向图的临界多重表存储表示 ---
# define MAX_VERTEX_NUM 20
typedef enum {unvisited, visited} VisitIf;
typedef struct EBox{
VisitIf mark; // 访问标记
int ivex, jvex; // 该边依附的两个顶点的位置
struct EBox *ilink, *jlink; // 分别指向依附这两个顶点的下一条边
InfoType *info; // 该边信息指针
}EBox;
typedef struct VexBox{
VertexType data;
EBox *firstedge; // 指向第一条依附该顶点的边
}VexBox;
typedef struct{
VexBox adjmulist[MAX_VERTEX_NUM];
int vexnum, edgenum; // 无向图的当前顶点数和边数
}AMLGraph;
图的遍历操作
从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(traversing graph)。
深度优先搜索
深度优先搜索(depth-first search, dfs)是树的先根遍历的推广。
// 采用邻接表存储表示
int visited[MAX_VERTEX_NUM]; // 访问标志数组
void DFS(AGraph *g, int x){
NodeLink *p;
visited[x] = 1;
VisitFunc(g->v[x]);
p = g->v[x].first;
while(p){
if(!visited[p->vindex]) // 对 x 的尚未访问的邻接顶点
DFS(g, p->vindex);
p = p->next;
}
}
void DFSGraph(AGraph *g){
// 对图 G 作深度优先遍历
int i;
for(i = 0; i < g->vexnum; i++)
visited[i] = 0; // 访问标志数组初始化
for(i = 0; i < g->vexnum; i++)
if(!visited[i]) DFS(g, i);
}
时间复杂度为 $O(n+e)$。
广度优先搜索
广度优先搜索(broadth-first search, bfs)类似于树的按层次遍历的过程。
int visited[MAX_VERTEX_NUM]; // 访问标志数组
void BFS(AGraph *g, int x){
// 用一个数组 q 作辅助队列,q[0, front) 存放的是访问过的顶点
// q[front, rear) 存放的是已访问顶点的相邻点,是马上要访问的顶点
int q[MAX_VERTEX_NUM], front, rear, i;
NodeLink *p;
front = rear = 0;
q[rear++] = x;
while(front != rear){
x = q[front++]; // 顶点出队列并访问它
VisitFunc(g->v[x]); visited[x] = 1;
p = g->v[x].first;
while(p != NULL){
for(i = 0; i < rear; i++) // 判断邻接点是否在数组 q 中
if(p->vindex == q[i]) break;
if(i == rear) // 邻接点未访问且不在队列中,则入队列
q[rear++] = p->vindex;
p = p->next; // 找 x 的下一个邻接点
}
}
}
void BFSGraph(AGraph *g){
// 对图 G 作广度优先遍历
int i;
for(i = 0; i < g->vexnum; i++)
visited[i] = 0; // 访问标志数组初始化
for(i = 0; i < g->vexnum; i++)
if(!visited[i]) BFS(g, i);
}
时间复杂度为 $O(n+e)$。
原理与实现
最小生成树
概念
无向图 $G=(V, {E})$ 中从顶点 $v$ 到 $v'$ 的路径(path)是一个顶点序列($v=v_{i,0},v_{i,1},cdots,v_{i,m}=v'$),其中 $(v_{i,j-1}, v_{i,j})in E, 1geq jgeq m$。如果 $G$ 是有向图,则路径也是有向的。路径的长度是路径上的边或弧的数目。第一个顶点和最后一个顶点相同的路径称为回路或环(cycle)。序列中顶点不重复出现的路径称为简单路径。
在无向图中,如果两个顶点之间有路径,则称这两个顶点是连通的。图中任意两个顶点都是连通的,则称图为连通图(connected graph)。图的极大连通子图称为连通分量(connected component)。
在有向图中,如果任意两个顶点之间正向、反向都是连通的,则称图为强连通图(strongly connected graph)。图的极大强连通子图称为有向图的强连通分量。
连通图的生成树是一个极小连通子图,它含有图中全部定点和足以构成一棵树的 $n-1$ 条边。有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵互不相交有向树的弧。
若从一个连通图中删去某个顶点和与其相关联的边,该连通图被分割成两个或两个以上的连通分量,则称此顶点为关节点(articulation point)或割点(cut point)。没有关节点的连通图为重(双)连通图(biconnected graph)。
带权连通图 $G$ 上的最小代价生成树(minimum cost spanning tree, MST)。
普里姆(Prim)算法
假设有连通网 $N=(V,{E})$,$TE$ 是 $N$ 上最小生成树中边的集合。算法从 $U={v_0}$,$TE={}$ 开始,重复下述操作:在所有 $uin U$ ,$vin T-U$ 的边 $(u,v)in E$ 中找一条权值最小的边 $(u_0, v_0)$ 并入集合 $TE$,同时 $v_0$ 并入 $U$,直至 $U=V$ 为止。
算法的时间复杂度为 $O(n^2)$。
克鲁斯卡尔(Kruskal)算法
先构造一个只含有 $n$ 个顶点的子图 $SG$,然后从权值最小的边开始,若添加该边不会使 $SG$ 中产生回路,则在 $SG$ 上加上这条边。如此重复直到加上 $n-1$ 条边为止。
根据贪心原则,时间复杂度为 $O(elog e)$。
最短路径
迪杰斯特拉(Dijkstra)算法
解决边上权值非负的单源最短路径问题。
利用最短路径的特点:或者是直接从源点到该点;或者是从源点经过已求得最短路径的顶点,再到达该点。
S
是已求得最短路径的终点的集合。定义一个数组
dist[n]
,其每个dist[i]
分量保存从源点出发中间只经过S
中的顶点而到达 vi 的所有路径中长度最小的值。
初始状态为:若从源点到顶点 vi 有边,则dist[i]
为该边的权值;否则为无穷。下一条最短路径的终点 vj 必定是不在
S
中且是dist
中值最小的顶点。
算法的时间复杂度为 $O(n^2)$。
弗洛伊德(Floyd)算法
解决边上权值为任意值的每对顶点间的最短路径问题,不允许有包含带负权值的边组成的回路。
S
是已求得最短路径的终点的集合。定义一个数组
D[n][n]
,其每个元素D[i][j]
保存从 vi 出发只经过S
中的顶点到达 vj 的最短路径长度。初始状态为:若从顶点 vi 到顶点 vj 有弧,则D[i][j]
为该弧的权值;D[i][i]
为0;其余为无穷。将图中的一个顶点 vk 加入
S
中,修改D[i][j] = min(D[i][j], D[i][k] + D[k][j])
。重复上一步直到所有顶点都加入
S
中为止。
算法的时间复杂度为 $O(n^3)$。
关键路径
概念
用顶点表示事件,用弧表示活动,弧上权值表示活动所需时间或费用的有向图称为 AOE 网(activity on edge network)。只有一个入度为0的点(称为起点)和一个出度为0的点(称为汇点)。
从起点到汇点长度最长的路径称为关键路径(critical path)。关键路径的长度表示完成整个工程所需的最短时间。关键路径上的活动称为关键活动。
若活动 $a_i$ 是弧 $<j,k>$,持续时间是 $dut(<j,k>)$。
$e(i)$ 表示活动的最早开始时间, $l(i)$ 表示活动的最迟开始时间。
$ve(j)$ 表示事件 $vj$ 的最早发生时间,$vl(j)$ 表示事件 $vk$ 的最迟发生时间。
$e(i)=ve(j)$, $l(i)=vl(k)-dut(<j,k>)$。
$l(i)-e(i)$ 表示活动的时间余量,显然若 $l(i)-e(i)=0$ 则表示该活动为关键活动。
算法
利用拓扑排序计算图的一个拓扑序列。
从拓扑排序序列的第一个顶点(起点)开始,按拓扑顺序依次计算每个顶点的最早发生时间。
按拓扑顺序的逆序,从最后一个顶点(汇点)开始,依次计算每个顶点的最迟发生时间。
计算 $e(i)$ 与 $l(i)$,输出 $l(i)-e(i)=0$ 的活动。
拓扑排序算法
概念
若集合上的某关系是自反的、反对称的和传递的,则称该关系是该集合上的偏序(partial order)关系,设 $R$ 是集合 $A$ 上的偏序关系,且 $forall a, b in A$,必有 $aRb$ 或 $bRa$,即集合中任何两个元素都可互相比较,则称 $R$ 是集合 $A$ 上的全序关系。由某个集合上的一个偏序得到该集合上的一个全序的操作称为拓扑排序(topological sort)。
可以用有向无环图(directed acycling graph, DAG)表示集合元素及元素之间的关系。
用顶点表示活动,用弧表示活动间优先关系的有向图称为 AOV 网(activity on vertex network)。
算法
在 AOV 网中选择一个没有前驱的顶点并输出。选择不同顶点会产生不同的拓扑排序结果。
在 AOV 网中删除该顶点以及从该顶点出发的所有弧。
重复执行前两步,直到图中所有顶点都已输出(图中无环)或图中不存在无前驱的顶点(图中必有环)。
时间复杂度为 $O(n+e)$
七、查找相关的知识点
查找的基本概念
查找表(search table)是由同一类型的数据元素(或记录)组成的集合。关键字(key)是数据元素(或记录)中某个数据项的值,可以用来标识一个数据元素(或记录)。若此关键字可以唯一地标识一个记录,则成为主关键字(primary key);否则称为次关键字(secondary key)。
查找(searching)是根据给定的某个值,在查找表中确定一个关键字等于给定值的数据元素或记录。若只能对查找表进行查询,则称为静态查找,该表称为静态查找表(static search table);若查找的同时可插入表中不存在的记录或从表中删除已存在的记录,则称为动态查找,该表称为动态查找表(dynamic search table)。
平均查找长度(average search length, ASL)定义为需要和给定值进行比较的关键字的个数的期望值,即 $ASL = sum_{i = 1}^n P_iC_i$。不失一般性可认为查找每个记录的概率相等,即 $P_1 = P_2 = cdots = P_n = 1/n$。
查找方法
顺序查找法
原理
从表的一段开始,逐个将记录的关键字和给定的 key 值进行比较。
查找成功时:$ASL = (n+1)/2$。
查找失败时:$ASL=n+1$。
实现
typedef struct SSTable{
// 建表时按实际长度分配,0号单元留空
ElemType *elem;
int length; // 实际元素个数
}SSTable;
int SearchSSTable(SSTable *t, KeyType key){
t->elem[0].key = key; // 设置哨兵,查找失败返回0
for(int i = t->length; !EQ(t->elem[i].key, key); i--);
return i;
}
分块查找法
原理
将查找表分成几块,满足块间有序,块内无序。块间有序即第 i+1 块的所有记录关键字均大于(或小于)第 i 块记录关键字。
在查找表的基础上附加一个索引表,按关键字有序,记录块内最大关键字和该块起始指针。
若索引块和块内查找都用顺序查找,表长为 n 个记录,均分为 b 块,每块有 s 个记录,则 $ASL = (b+1) / 2 + (s+1) / 2$。$s = sqrt{n}$ 时, $ASL$ 最小,为 $sqrt{n}+1$。
实现
typedef struct IndexType{
KeyType maxkey; // 块中最大的关键字
int startpos; // 块的起始位置指针
}Index;
int SearchSSTableBlock(SSTable *t, Index *ind, KeyType key, int n, int b){ // 表长为 n,块数为 b
int i = 0, j, k;
while((i < b) && LT(ind[i].maxkey, key)) i++; // 在块间顺序查找
if(i > b) return 0;
j = ind[i].startpos;
while((j<=n) && LQ(t->elem[j].key, ind[i].maxkey)){ // 在块内顺序查找
if(EQ(t->elem[j].key, key)) break;
j++;
}
if(j > n || !EQ(t->elem[j].key, key)) return 0;
return j;
}
折半查找法
原理
基于有序表的查找,即表中所有记录是按关键字有序(升序或降序)排列的。
查找过程中可每次将待查记录所在的区间缩小一半,直到找到或找不到记录为止。
查找成功时,若对应二叉树为满二叉树:$ASL approx log_2(n+1)-1$。
查找失败时:$ASL = lfloor log_2 n floor + 1$。
实现
int SearchSSTable(SSTable *t, KeyType key){
int low = 1;
int high = t->length;
int mid;
while(low <= high){
mid = (low + high) / 2;
if(EQ(key, t->elem[mid].key))
return mid;
else if(LT(key, t->elem[mid].key))
high = mid - 1;
else low = mid + 1;
}
return 0;
}
树的拓展
二叉排序树
概念
二叉排序树(binary sort tree, BST),是指一棵空树或者具有下列性质的二叉树:
若任意结点的左子树不空,则左子树上所有结点的值均小于它的根节点的值;
若任意结点的右子树不空,则右子树上所有结点的值均大于它的根节点的值;
任意结点的左、右子树也分别为二叉查找树。
每个结点的值互不相同。
可知中序遍历二叉排序树会得到一个递增序列。
删除操作
Status Delete(BiTree *p){
// 从二叉排序树中删除结点 p,并重接它的左或右子树
if(!p->rchild){ // 右子树空则只需重接它的左子树
BiTree *q = p; p = p->lchild; free(q);
}else if(!p->lchild){ // 左子树空则只需重接它的右子树
BiTree *q = p; p = p->rchild; free(q);
}else{ // 左右子树均不空
BiTree *q = p;
BiTree *s = p->lchild;
while(s->rchild){q = s; s = s->rchild;}
p->data = s->data;
if(q != p) q->rchild = s->lchild;
else q->lchild = s->lchild;
free(s);
}
return OK;
}
查找分析
二叉排序树上的查找次数不会超过二叉树的深度,而具有 $n$ 个结点的二叉排序树的深度最好为 $log n$,最坏为 $n$。
平衡二叉树
概念
平衡二叉树(balanced binary tree)是空树或者具有下列性质的二叉树:
左子树和右子树的深度之差的绝对值不大于1
左子树和右子树也都是平衡二叉树
该结点的左子树深度减去右子树深度称为该结点的平衡因子(balance factor)。
最经典的平衡二叉排序树称为AVL树,变种有红黑树等。
旋转
每插入一个新结点时,相关结点的平衡状态会发生改变。因此在插入一个新结点后,需要从插入位置沿通向根的路径回溯,检查各结点的平衡因子。
树的平衡化基于树旋转操作。
void R_Rotate(BSTree *p){
// 在某结点的左子女的左子树插入新结点导致不平衡
// 对以 p 为根的二叉排序树作右旋处理
// 处理之后 p 指向新的根结点,即旋转处理之前的左子树的根节点
BSTree *lc;
lc = p->lchild; // lc 指向 p 的左子树根结点
p->lchild = lc->rchild; // lc 的右子树挂接变为 p 的左子树
lc->rchild = p;
p = lc;// p 指向新的根结点
}
void LR_Rotate(BSTree *p){
// 在某结点的左子女的左子树插入新结点导致不平衡
// 对以 p 为根的二叉排序树做先左后右旋转
BSTree *lc, *rc;
rc = p; lc = rc->lchild; // 初始化
p = lc->rchild; // 重新确定根
lc->rchild = p->lchild;
p->lchild = lc; // 设置新根的左孩子
rc->lchild = p->rchild;
p->rchild = rc;
}
void RL_Rotate(BSTree *p){
// 在某结点的左子女的左子树插入新结点导致不平衡
// 对以 p 为根的二叉排序树做先右后左旋转
BSTree *lc, *rc;
lc = p; rc = lc->rchild; // 初始化
p = rc->lchild;
rc->lchild = p->rchild;
p->lchild = rc;
lc->rchild = p->lchild;
p->lchild = lc;
}
B 树及其基本操作
一棵 m 阶的 B 树,或为空树,或为满足下列特性的 m 叉树:
每个结点至多有 m 棵子树。
若根结点不是叶子结点,则至少有两棵子树。
除根之外的所有非终端结点至少有 $lceil m/2 ceil$ 棵子树。
所有非终端结点包含下列信息数据 $(n, A_0, K_1, A_1, K_2, cdots, K_n, A_n)$,其中 $K_i$ 为关键字,且 $K_i<K_{i+1}$;$A_i$ 为指向子树根结点的指针,且指针 $A_{i-1}$ 所指子树中所有结点的关键字均大于 $K_{i-1}$ 并小于 $K_i$;$n$ 是结点中关键字的个数。
所有叶子结点都在树的同一层上,且不带信息。
#define m 3 // m 阶 B 树定义
typedef struct BTNode{
int keynum; // 结点中关键字个数,即结点的大小
BTNode *parent; // 指向父节点的指针
KeyType key[m+1]; // 关键字,0号单元不用
Record *recptr[m+1]; // 记录指针向量,0号单元不用
BTNode *ptr[m+1]; // 子树指针向量
}BTNode, *BTree;
typedef struct{
BTNode *pt; // 指向找到的结点
int i; // 在结点中的关键字序号
int tag; // 1:查找成功 0:查找失败
}Result;
int Search(BTree p; KeyType K){
for(int i = 0; i < p->keynum && p->key[i+1] <= K; i++);
return i;
}
Result SearchBTree(BTree T, KeyType K){
BTree p, q; int found, i, j; Result R;
p = T; q = NULL; found = i = j = 0;
while(p && !found){
i = Search(p, K); // 在 p->key[1..keynum] 中查找 i,
// 使得 p->key[i] <= K < p->key[i+1]
if(i > 0 && p->key[i] == K)
found = 1; // 找到待查关键字
else{q = p; p = p->ptr[i];}
}
if(found){ // 查找成功,pt 所指结点中第 i 个关键字等于 K
R.pt = p; R.i = i; R.tag = 1;
}else{ // 查找失败,K 应插入在 pt 所指结点中的第 i 和 i+1 个关键字之间
R.pt = q; R.i = i; R.tag = 0;
}
return R;
}
插入操作:
在 B 树中查找关键字,失败于某个叶子结点,将 K 插入到该结点中。若该结点的关键字数<m-1,则直接插入;若结点的关键字数=m-1,则将结点分裂。
从其中间位置分为两个结点,将中间关键字 $K_{lceil m/2 ceil}$ 插入到 p 的父结点中,以分裂后的两个结点作为中间关键字 $K_{lceil m/2 ceil}$ 的两个子结点,检测父结点是否满足 m 阶 B 树的要求。
不满足则继续分裂,直到父结点满足要求或没有父结点为止。其中当根结点分裂时,建立一个新的根,B 树增高一层。
删除操作
首先找到关键字所在结点 N,然后在 N 中进行关键字 K 的删除操作。
若 N 不是叶子结点,设 K 是 N 中的第 i 个关键字,则将指针 $A_{i-1}$ 所指子树中的最大关键字 K' 放在 K 的位置,然后删除 K'。
若 N 是叶子结点:
若结点 N 中的关键字个数> $lceil m/2 ceil -1$:则在结点中直接删除关键字 K。
若结点 N 中的关键字个数= $lceil m/2 ceil -1$,而结点 N 的左右兄弟结点中的关键字个数> $lceil m/2 ceil -1$:则将 N 的左(右)兄弟结点中的最大(最小)关键字上移到父结点中,父结点中大于(小于)且紧靠上移关键字的关键字下移到结点 N。
若结点 N 和其兄弟结点中的关键字个数= $lceil m/2 ceil -1$:则删除结点 N 中的关键字,再将结点 N 中的关键字、指针与其兄弟节点以及分割二者的父结点中的某个关键字 $K_i$ 合并为一个结点。若因此使父结点中的关键字个数< $lceil m/2 ceil -1$,则依此类推。
B+树的基本概念
B+树的所有叶子结点中包含了全部记录的关键字信息以及这些关键字记录的指针,而且叶子结点按关键字的大小从小到大顺序链接,构成一个有序链表。
B+树的所有非叶子结点可以看成索引,结点中只含有其子树的根结点中的最大(或最小)关键字。
typedef enum {branch, left} NodeTag;
typedef struct BPNode{
NodeTag tag; // 结点标志
int keynum; // 结点中关键字的个数
BPNode *parent; // 指向父节点的指针
KeyType key[m+1]; // 关键字向量,key[0] 未用
union pointer{
BPNode *ptr[m+1]; // 子树指针向量
RecType *recptr[m+1]; // recptr[0] 未用
}ptrType; // 用联合体定义子树指针和记录指针
}BPNode;
散列(Hash)表
概念
对关键字 $k_i$、$k_j$,若 $k_i eq k_j$,但 $H(k_i)=H(k_j)$ 的现象叫冲突(collision)。由于哈希函数通常是一种压缩映像,所以冲突不可避免。具有相同函数值的关键字对哈希来说称做同义词(synonym)。
哈希函数种类
直接定址法
数字分析法
平方取中法
折叠法
移位叠加:将分割后的几部分低位对齐相加。
间界叠加:从一端到另一端沿分割界来回折叠,然后对齐相加。
适用于关键字位数很多,且每一位上数字分布大致均匀。
除留余数法
若选 $p=2^i$,便于用移位来计算,但高位不同而低位相同的关键字都变为了同义词。
p 一般可选为质数。
随机数法
冲突处理方法
开放定址法
线性探测法(linear probing):$d_i = 1, 2, dots, m-1$。
二次探测法(quadratic probing):$d_i = 1^2, -1^2, 2^2, -2^2, dots, k^2, -k^2(kleqlfloor m/2 floor)$。
伪随机探测法:用伪随机函数获得伪随机序列。
再哈希法
链地址法
将所有关键字为同义词的记录存储在一个单链表中,并用一维数组存放链表的头指针。
建立公共溢出区
在基本哈希表之外,另外设立一个溢出表保存与基本表中记录冲突的所有记录。
性能分析
定义哈希表的装填因子 $alpha$ 为 $alpha = frac{表中填入的记录数}{哈希表长度}$。哈希表的 $ASL$ 是 $alpha$ 的函数而不是 $n$ 的函数。
字符串模式匹配
算法原理
KMP 算法:每当一趟匹配过程中出现字符比较不相等时,不回溯主串指针,而是将模式串向右滑动恰当位置,继续比较。
每次模式串右移的位数与目标串无关,仅依赖于模式串本身和模式串当前指针所在位置。即该算法挖掘了模式串的内在关联信息。
引入 next
数组,长度与模式串长度相同。当模式串中第 j 个字符与主串中相应字符失配时,模式串应当由 next[j]
位置的字符与主串中刚失配的字符比较。
例:
模式串 | a | b | a | a | b | c | a | c |
---|---|---|---|---|---|---|---|---|
模式串的下标变量j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
next[j] | -1 | 0 | 0 | 1 | 1 | 2 | 0 | 1 |
还可对 next
数组进行优化,若模式串 P 的第 k 个字符失配,且 P[k] == P[next[k]]
,则下一次匹配一定不成功,串要滑到 next[next[k]]
位置。算法的时间复杂度为 $O(m+n)$, $m$、 $n$ 分别为模式串和主串的长度。
实现
void get_next(HString *pattern, int *next){
int j, k;
j = 0; // 模式子串的位置
k = -1; // 模式自匹配指针
next[0] = -1;
while(j < pattern->length){
if(k == -1 || pattern->ch[j] == pattern->ch[k]){
j++; k++;
next[j] = k;
}else k = next[k];
}
}
void get_nextval(HString *pattern, int *nextval){
int j, k;
j = 0; k = -1; next[0] = -1;
while(j < pattern->length){
if(k == -1 || pattern->ch[j] == pattern->ch[k]){
j++; k++;
if(pattern->ch[j] == pattern->ch[k])
nextval[j] = nextval[k];
else nextval[j] = k;
}else k = nextval[k];
}
}
八、排序知识点
基本概念
排序(sorting)是将一个数据元素(或记录)的任意序列,重新排成一个按关键字有序的序列。
当输入含重复关键字时,若重复元素在输入、输出序列中的相对次序保持不变,则称该排序算法是稳定的。
若整个排序过程中数据元素全部存放在内存,不需要访问外存便能完成,则称此类排序为内部排序;反之,若参加排序的记录数量很大,不能同时放在内存,必须不断在内、外存之间移动的排序称为外部排序。
算法原理与复杂度
直接插入排序
直接插入排序(straight insertion sort)是一种最简单的排序方法,基本操作是将一个记录插入到已排好序的有序表中,从而得到一个新的、记录数增1的有序表。
void InsertSort(SqList *L){
for(int i = 2; i <= L->length; ++i){
if(LT(L->r[i].key, L->r[i-1].key)){
L->r[0] = L->r[i]; // 复制为哨兵
L->r[i] = L->r[i-1];
for(int j = i - 2; LT(L->r[0].key, L->r[j].key); --j)
L->r[j+1] = L.r[j]; // 记录后移
L->r[j+1] = L->r[0]; // 插入到正确位置
}
}
}
时间复杂度为 $O(n^2)$,空间复杂度为 $O(1)$。
折半插入排序
将插入过程中的查找操作改用折半查找来实现,称为折半插入排序(binary insertion sort)。
空间复杂度也为 $O(1)$,而由于移动操作所需时间与顺序插入排序相同,故时间复杂度也相同,为 $O(n^2)$。
气泡排序
依次比较每一对相邻元素,若有必要则交换,一趟排序之后最大值必然就位。重复上述操作直至有序即为气泡排序(bubble sort)。
void BubbleSort(SqList *L){
int i = L->length;
bool sorted = false;
while(!sorted){ // 第[i...n]大元素已排序,寻找第 i-1 大元素
sorted = true;
for(int j = 1; j < i; j++) // 从[i...i-1]寻找第 i-1 大元素
if(L->r[j+1].key < L->r[j].key){ // 将大的记录后移
Swap(L->r[j+1], L->r[j]);
sorted = false; // 记下进行交换的记录
}
i--;
}
}
时间复杂度为 $O(n^2)$,空间复杂度为 $O(1)$。
简单选择排序
一趟简单选择排序(simple selection sort)的操作为:通过 n-i 次关键字间的比较,从 n-i+1 个记录中选出关键字最小的记录,并和第 i 个记录交换。
时间复杂度为 $O(n^2)$,空间复杂度为 $O(1)$。
快速排序
快速排序(quick sort)的基本思想为通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分小,则可分别对这两部分继续排序,最终达到整个序列有序。
int Partition(SqList *L, int low, int high){
// 交换顺序表中子表 r[low...high] 的记录,轴枢记录到位,并返回其所在位置
// 此时在它之前(后)的记录均不大(小)于它
L->r[0] = L->r[low]; // 用子表的第一个记录作轴枢记录
pivotkey = L->r[low].key; // 轴枢记录关键字
while(low < high){ // 从表的两端交替地向中间扫描
while(low < high && L->r[high].key >= pivotkey) high--;
L->r[low] = L->r[high]; // 将比轴枢记录小的记录移到低端
while(low < high && L->r[low].key <= pivotkey) low++;
L->r[high] = L->r[low]; // 将比轴枢记录大的记录移到高端
}
L->r[low] = L->r[0]; // 轴枢记录到位
return low; // 返回轴枢位置
}
时间复杂度为 $O(nlog n)$,最坏为 $O(n^2)$,可采用随机选取等方法降低最坏情况的概率。空间复杂度为 $O(1)$。
堆排序
堆(heap)是满足下列性质的数列:
若满足 $r_ileq r_{2i}$, $r_ileq r_{2i+1}$,则称为小顶堆。
若满足 $r_igeq r_{2i}$, $r_igeq r_{2i+1}$,则称为大顶堆。
可看出逻辑上等同于完全二叉树。
若在输出堆顶的最小值之后,使得剩余元素的序列重又建成一个堆,则得到所有元素的次小值,如此反复执行便可得到一个有序序列,即为堆排序(heap sort)。
typedef SqList HeapType;
void HeapAdjust(HeapType *H, int s, int m){
// 已知 H->r[s...m] 中记录的关键字除 H->r[s].key 外均满足堆的定义
// 本函数调整 H->r[s] 的关键字,使 H->r[s...m] 变为大顶堆
ElemType rc = H->r[s];
for(int j = 2*s; j <= m; j++){ // 沿 key 较大的孩子结点向下筛选
if(j < m && LT(H->r[j].key, H->r[j+1],key)) j++; // j 为 key 较大的记录的下标
if(!LT(rc.key, H->r[j].key)) break; // rc 应插入在位置 s 上
H->r[s] = H->r[j]; s = j;
}
H->r[s] = rc;
}
void HeapSort(HeapType *H){
for(i = H->length/2; i > 0; --i)
HeapAdjust(H, i, H->length); // 建堆
for(i = H->length; i > 1; --i){
H->r[1] <--> H->r[i];
HeapAdjust(H, 1, i-1);
}
}
时间复杂度为 $O(nlog n)$,最坏情况也是 $O(nlog n)$。空间复杂度为 $O(1)$。
二路归并排序
归并排序(merge sort)采用分治思想,初始 n 个记录可看作 n 个有序表,然后不断两两合并,最终合并为一个有 n 个记录的有序表。
void Merge(int low, int mi, int hi){
ElemType *A = elements + low;
int lb = mi - low;
ElemType B[lb];
for(int i = 0; i < lb; B[i] = A[i++]);
int lc = hi - mi;
ElemType *C = elements + mi;
for(i = 0, int j = 0, int k = 0; j < lb || k < lc;){
if((j < lb) && (lc <= k || B[j].key <= C[k].key)) A[i++] = B[j++];
if((k < lc) && (lb <= j || C[k].key < B[j].key)) A[i++] = C[k++];
}
delete(B);
}
void mergesort(int low, int hi){
if(hi - lo < 2) return;
else{
mi = (low + hi) / 2;
mergesort(low, mi);
mergesort(mi + 1, hi);
Merge(low, mi, hi);
}
}
时间复杂度为 $O(nlog n)$,最坏情况也是 $O(nlog n)$。空间复杂度为 $O(n)$。
基数排序
基数排序(radix sort)是一种借助多关键字排序思想对单逻辑关键字进行排序的方法。
n 个记录的序列 ${R_i, R_2, cdots, R_n}$ 对关键字 $(K_i^0, K_i^1, cdots, K_i^{d-1})$有序是指:对于序列中任意两个记录 $R_i$ 和 $R_j(1leq i < j leq n)$ 都满足下列有序关系:$(K_i^0, K_i^1, cdots, K_i^{d-1}) < (K_j^0, K_j^1, cdots, K_j^{d-1})$。其中 $K^0$ 被称为最主位关键字,$K^{d-1}$ 被称为最次位关键字。
实现多关键字排序有两种做法:
最高位优先法(most significant digit first, MSD):先对 $K^0$ 进行排序,将记录序列分成若干子序列之后,分别对 $K^1$ 排,依此类推,最后对最次位关键字排序。这种排序方法必须将序列逐层分割为若干子序列,然后对子序列分别排序。
最低位优先法(least significant digit first, LSD):先对 $K^{d-1}$ 进行排序,将记录序列分成若干子序列之后,分别对 $K^{d-2}$ 排,依此类推,最后对最主位关键字排序。可不必分成子序列,对每个关键字都是整个序列参加排序。
为减少所需辅助存储空间,应采用链表作存储结构,即链式基数排序,做法为:
待排序记录以指针相链,构成一个链表;
“分配”时,按当前“关键字位”所取值,将记录分配到不同的“链队列”中,每个队列中记录的“关键字位”相同;
“收集”时,按当前关键字位取值从小到大将各队列首位相链成一个链表;
对每个关键字位均重复 2 和 3 两步。
算法的时间复杂度为 $O(d(n+rd))$,其中 d 表示需要执行 d 趟“分配”与”收集“,rd 表示每个关键字的取值范围为 rd 个值,即共有 rd 个队列。空间上需增加 $n+2rd$ 个附加链接指针。
外部排序
外部排序过程主要分为两个阶段:
按可用内存大小,将外存上含 n 个记录的文件划分为若干长度为 l 的段,用某种内排序方法对各段进行排序。经过排序的段叫做归并段(run),生成后就被写到外存中去。
把 1 中生成的初始归并段加以归并,一趟趟扩大归并段和减少归并段数,直至整个文件有序。
硬件条件不变的情况下,归并排序时间取决于内部排序时间、总的外存读写次数与归并的趟数。其中外存读写最慢,所以应尽量减少总的外存读写次数。而总的外存读写次数又与归并趟数成正比,故应尽量减少归并趟数。
k 路归并排序(k-way balanced merging)
败者树(tree of loser)是一棵完全二叉树,其中:
每个叶结点存放各归并段在归并过程中当前参加比较的记录;
每个非叶结点记忆它两个子女节点中记录排序码大的结点(即败者)。
归并路数 k 并不是越大越好。k 增大相应需增加输入缓冲区个数,则势必要减少每个输入缓冲区的容量,使内外存交换数据的次数增大。
计算机考研报考院校分析系列文章如下:
持续更新…………
计算机考研交流群: 495487162
计算机考研助手TOP1群: 185469307
院校考研交流群:
深圳大学 560801876
上海大学 772892871
浙江大学 716278248
杭州电子科技大学 580950115
电子科技大学 720797104
西安电子科技大学 157491662
北京邮电大学 739981211
南京大学 852875797
西安交通大学 305174352
吉林大学 731165424
合肥工业大学 581517900
郑州大学 695292662
中国科学技术大学 685573929
安徽大学 697570596
以上是关于数据结构复习知识点总结的主要内容,如果未能解决你的问题,请参考以下文章
mysql期末数据库复习指南(《数据库系统概率》知识点总结,数据库系统原理,数据库设计课程复习)