// 初始化一个空栈
InitStack(&S)
// 判断一个栈是否为空,若栈S为空则返回true,否则返回false
StackEmpty(S)
// 进栈,若栈S未满,则将x加入使之成为新栈顶
Push(&S, x)
// 出栈,若栈S非空,则弹出栈顶元素,并用x返回
Pop(&S, &x)
// 读栈顶元素,若栈S非空,则用x返回栈顶元素
GetTop(S, &x)
// 销毁栈,并释放栈S占用的存储空间
DestroyStack(&S)
解答算法题时,若题干未做出限制,则可直接使用这些基本的操作函数
顺序栈
一组地址连续的存储单元存放自栈底到栈顶的数据元素,同时附设一个指针(top)指示当前栈顶元素的位置。
// 定义栈中元素的最大个数
#define MaxSize 50
typedef struct {
// 存放栈中元素
Elemtype data[MaxSize];
// 栈顶指针
int top;
} SqStack;
初始时,设置 S.top = -1,栈顶元素 S.data[S.top]
进栈:栈不满时,栈顶指针先加1,再送值到栈顶元素
出栈:栈非空时,先取栈顶元素值,再将栈顶指针减1
栈空条件:S.top == -1
栈满条件:S.top == MaxSize - 1
栈长:S.top+1
typedef struct Linknode {
// 数据域
ElemType data;
// 指针域
struct Linknode *next;
} *LiStack; // 栈类型定义
操作受限的线性表。只允许在表的一端进行插入,在表的另一端进行删除。
队头:允许删除的一端,又称 队首
队尾:允许插入的一端
栈和队列是操作受限的线性表,因此不是任何线性表的操作都可以作为栈和队列的操作。比如,不可以随便读取栈或队列中间的某个数据。
队列的顺序存储
队列的顺序实现是指分配一块连续的存储单元存放队列中的元素,并附设两个指针:队头指针front指向队头元素,队尾指针rear指向队尾元素的下一个位置(不同教材的定义不同)。
#define MaxSize 50
typedef struct {
ElemType data[MaxSize];
int front, rear;
} SqQueue;
初始状态(队空条件):Q.front == Q.rear == 0
进队操作:队不满时,先送值到队尾元素,再将队尾指针加1。
出队操作:队不空时,先取队头元素值,再将队头指针加1。
如果出队之后,不及时移动元素到队头位置,即固定队头位置,则可能出现假溢出,也就是队尾指针等于MaxSize,但实际上还有空余位置。如下
循环队列
解决上面顺序存储队列的问题。将顺序队列 臆造 为一个环状的空间,即把存储队列元素的表,从 逻辑上 视为一个环。当队首指针 Q.front = MaxSize - 1后,再前进一个位置就自动到0,这可以利用除法取余运算实现。
初始时:Q.front = Q.rear = 0
队首指针进1: Q.front = (Q.front + 1)% MaxSize
队尾指针进1: Q.rear = (Q.rear + 1)% MaxSize
队列长度:(Q.rear + MaxSize - Q.front)% MaxSize
出队入队时:指针都按顺时针方向进1,如下图
队列的链式存储
链队列,实际上是一个同时带有队头指针和队尾指针的单链表。头指针指向队头结点,尾指针指向队尾结点,即单链表的最后一个结点(注意与顺序存储的不同)
队列的链式存储类型可描述为
// 链式队列结点
typedef struct {
ElemType data;
struct LinkNode *next;
} LinkNode;
// 链式队列
typedef struct {
LinkNode *front, *rear;
} LinkQueue;
当Q.front == NULL 且 Q.rear == NULL 时,队列为空。
不带头结点的链式队列,在操作上,往往比较麻烦。因此通常设计成,带头结点的单链表。这样 插入 和 删除 就统一了。
用单链表表示的链式队列特别适合于数据元素变动比较大的情况,而且不存在队列满且产生溢出的问题。另外,假如程序中要使用多个队列,与多个栈的情形一样,最好使用链式队列,这样就不会出现存储分配不合理和溢出问题。
输出受限的双端队列:允许在一端进行插入和删除,但在另一端只允许插入的双端队列
输入受限的双端队列:允许在一端进行插入和删除,但在另一端只允许删除的双端队列
若限定双端队列从某个端点插入的元素只能从该端点删除,则该双端队列就蜕变为两个栈底相邻接的栈。
压缩存储,指为多个值相同的元素只分配一个存储空间,对零元素不分配存储空间。其目的是节省存储空间
特殊矩阵,指具有许多相同矩阵元素或零元素,并且这些相同矩阵元素或零元素的分布有一定规律性的矩阵
特殊矩阵的压缩存储方法,找出特殊矩阵中值相同的矩阵元素的分布规律,把那些呈现规律性分布的、值相同的多个矩阵元素压缩存储到一个存储空间中
对称矩阵
若对一个n阶方阵A[1…n][1…n]中的任意一个元素 a i , j a_{i,j} ai,j都有 a i , j = a j , i a_{i,j}=a_{j,i} ai,j=aj,i(1 <= i, j <= n),则称其为对称矩阵。对于一个n阶方阵,其中的元素可以划分为3个部分,即上三角区、主对角线、下三角区
将对称矩阵A[1…n][1…n]存放在一维数组B[n(n+1)/2]中,即元素 a i , j a_{i,j} ai,j存放在 b k b_k bk中,只存放下三角区(含对角线)的元素
元素** a i , j a_{i,j} ai,j在数组B中的下标 k = 1 + 2 + ⋯ + ( i − 1 ) + j − 1 = i ( i − 1 ) / 2 + j − 1 k=1+2+\dots+(i-1)+j-1 = i(i-1)/2 + j-1 k=1+2+⋯+(i−1)+j−1=i(i−1)/2+j−1,数组下标从0**开始,因此,元素下标之间的对应关系如下
k = { i ( i − 1 ) 2 + j − 1 , i ≥ j ( 下三角区和主对角线元素 ) j ( j − 1 ) 2 + i − 1 , i < j ( 上三角区元素 a i j = a j i ) k= \begin{cases} \frac{i(i-1)}{2}+j-1, i \ge j (下三角区和主对角线元素) \newline \frac{j(j-1)}{2}+i-1, i \lt j (上三角区元素 a_{ij} = a_{ji}) \end{cases} k={2i(i−1)+j−1,i≥j(下三角区和主对角线元素)2j(j−1)+i−1,i<j(上三角区元素aij=aji)
三角矩阵
下三角矩阵中,上三角区的所有元素均为同一常量。其存储思想与对称矩阵类似,不同之处在于存储完下三角区和主对角线上的元素之后,紧接着存储对角线上方的常量一次,故可以将下三角矩阵**A[1…n][1…n]压缩存储在B[n(n+1)/2+1]**中
元素下标之间的对应关系为
k = { i ( i − 1 ) 2 + j − 1 , i ≥ j ( 下三角区和主对角线元素 ) n ( n + 1 ) 2 , i < j ( 上三角区元素 ) k=\begin{cases} \frac{i(i-1)}{2} + j - 1, i \ge j (下三角区和主对角线元素) \newline \frac{n(n+1)}{2}, i \lt j (上三角区元素) \end{cases} k={2i(i−1)+j−1,i≥j(下三角区和主对角线元素)2n(n+1),i<j(上三角区元素)
三对角矩阵
稀疏矩阵
定长顺序存储表示
用一组地址连续的存储单元存储串值的字符序列
// 预定义最大串长为255
#define MAXLEN 255
typedef struct {
// 每个分量存储一个字符
char ch[MAXLEN];
// 串的实际长度
int length;
} SString;
堆分配存储表示
用一组地址连续的存储单元存放串值的字符序列,但它们的存储空间是在程序执行过程中动态分配得到的
C语言中,存在一个堆的自由存储区,并用malloc()和free()函数来完成动态存储管理。利用malloc()为每个新产生的串分配一块实际串长所需的存储空间
- 若分配成功,则返回一个指向起始地址的指针,作为串的基地址,这个串由指针来指示
- 若分配失败,则返回NULL,已分配的空间可用free()释放掉
typedef struct {
// 按串长分配存储区,ch指向串的基地址
char *ch;
// 串的长度
int length;
} HString;
块链存储表示
子串的定位操作通常称为串的模式匹配,它求的是子串(常称模式串)在主串中的位置。这里采用定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法
int Index(SString S, SString T) {
int i = 1, j = 1;
while(i <= S.length && j <= T.length) {
if (S.ch[i] == T.ch[j]) {
// 继续比较后续字符
++i;
++j;
} else {
// 指针后退重新开始匹配
i = i - j + 2;
j = 1;
}
}
if (j > T.length)
return i - T.length;
else
return 0;
}
在暴力匹配中,每趟匹配失败都是模式后移一位再从头开始比较。而某趟已匹配相等的字符序列是模式的某个前缀,这种频繁的重复比较相当于模式串在不断地进行自我比较,这就是低效率的根源。因此,可以从分析模式本身的结构着手,如果已匹配相等的前缀序列中有某个后缀正好是模式的前缀,那么就可以将模式向后滑动到与这些相等字符对齐的位置,主串i指针无须回溯,并从该位置开始继续比较。而模式向后滑动位数的计算仅与模式本身的结构有关,与主串无关。
字符串的前缀、后缀、部分匹配值
前缀指除最后一个字符以外,字符串的所有头部子串
后缀指除第一个字符外,字符串的所有尾部子串
部分匹配值指字符串的前缀和后缀的最长相等前后缀长度
利用上述方法,将部分匹配值写成数组形式,就得到了部分匹配值(Partial Match,PM)的表
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | c | a | c |
PM | 0 | 0 | 0 | 1 | 0 |
匹配过程中,发现第3位不匹配,前面2个字符是匹配的,查表可知,最后一个匹配字符b对应的部分匹配值为0,因此按照下面的公式算出子串需要向后移动的位数
因为2 - 0 = 2,所以将子串向后移动2位,再进行匹配
KMP算法的原理是什么
层次
深度
高度
二叉树的定义
二叉树与度为2的有序树的区别
几个特殊的二叉树
满二叉树
完全二叉树
高度为h,有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树
完全二叉树就是对应相同高度的满二叉树缺失最下层、最右边的一些连续叶子结点
二叉排序树
平衡二叉树
二叉树的性质
非空二叉树上的叶子结点数等于度为2的结点数加1
扩展到任意一棵树,若结点数量为n,则边的数量为n - 1
非空二叉树上第k层上至多有** 2 k − 1 2^k-1 2k−1**个结点(k >= 1)
高度为h的二叉树最多有** 2 h − 1 2^h-1 2h−1**个结点(h >= 1)
对完全二叉树按从上到下、从左到右的顺序依次编号1,2,…,n,则有以下关系
具有n个(n > 0)结点的完全二叉树的高度为 ⌈ l o g 2 ( n + 1 ) ⌉ \lceil log_2(n+1) \rceil ⌈log2(n+1)⌉或 ⌊ l o g 2 n ⌋ + 1 \lfloor log_2n \rfloor + 1 ⌊log2n⌋+1
顺序存储结构
链式存储结构
由于顺式存储空间利用率较低,因此二叉树一般采用链式存储结构。在二叉树中,结点结构至少包含3个域:数据域data、左指针域lchild、右指针域rchild
typedef struct BiTNode {
ElemType data;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
容易验证,在含有n个结点的二叉链表中,含有n+1个空链域
先序遍历
访问根结点
先序遍历访问左子树
先序遍历访问右子树
void PreOrder(BiTree T) {
if (T != NULL) {
visit(T);
PreOrder(T -> lchild);
PreOrder(T -> rchild);
}
}
中序遍历
中序遍历左子树
访问根结点
中序遍历右子树
void InOrder(BiTree T) {
if (T != NULL) {
InOrder(T -> lchild);
visit(T);
InOrder(T -> rchild):
}
}
后序遍历
后序遍历左子树
后序遍历右子树
访问根结点
void PostOrder(BiTree T) {
if (T != NULL) {
PostOrder(T -> lchild);
PostOrder(T -> rchild);
visit(T);
}
}
三种遍历算法中,递归遍历左、右子树的顺序都是固定的,只是访问根结点的顺序不同。不管采用哪种遍历算法,每个结点都访问一次且仅访问一次。故时间复杂度都是O(n)。
递归算法和非递归算法的转换
分析中序遍历
写出算法
void InOrder2(BiTree T) {
// 初始化栈S
InitStack(S);
// p是遍历指针
BiTree p = T;
while (p || !IsEmpty(S)) {
// 一路向左
if (p) {
// 当前结点入栈
Push(S, p);
// 左孩子不空,一直向左走
p = p -> lchild;
} else {
// 出栈,并转向出栈结点的右子树
Pop(S, p);
// 栈顶元素出栈,访问出栈结点
visit(p);
// 向右子树走,p赋值为当前结点的右孩子
p = p -> rchild;
}
}
}
先序遍历非递归版本
void PreOrder2(BiTree T) {
initStack(S);
BiTree p = T;
while (p || !IsEmpty(S)) {
if (p) {
visit(p);
Push(s, p);
p = p -> lchild;
} else {
Pop(S, p);
p = p -> rchild;
}
}
}
后序遍历非递归版本是三种遍历方法中最难的。因为在后序遍历中,需要保证左孩子和右孩子都已被访问并且左孩子在右孩子前访问才能访问根结点。思路分析如下
层次遍历
要进行层次遍历,需要借助一个队列。先将二叉树根结点入队,然后出队,访问出队结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队。然后出队,访问出队结点,如此往复
void LevelOrder(BiTree T) {
InitQueue(Q);
BiTree p;
EnQueue(Q, T);
while (!IsEmpty(Q)) {
DeQueue(Q, p);
visit(p);
if (p -> lchild != NULL) {
EnQueue(Q, p -> lchild);
}
if (p -> rchild != NULL) {
EnQueue(Q, p -> rchild):
}
}
}
遍历是二叉树各种操作的基础,可以在遍历的过程中对结点进行各种操作
- 已知树,求结点的双亲、结点的孩子结点、二叉树的深度、二叉树的叶子结点个数、判断两棵二叉树是否相等
由遍历序列构造二叉树
基本概念
以一定的规则将二叉树中的结点排列成一个线性序列,从而得到几种遍历序列,使得该序列中的每个结点(第一个和最后一个结点除外)都有一个直接前驱和直接后继
传统的二叉链表存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱或后继
前面提到,在含n个结点的二叉树中,有n+1个空指针。这时因为每个叶结点有2个空指针,每个度为1的结点有1个空指针,所以空指针总数为n+1。由此设想能否利用这些空指针来存放指向其前驱、后继的指针,这样就可以像遍历单链表那样方便地遍历二叉树。引入线索二叉树正是为了加快查找前驱、后继的速度
规定:若无左子树,令lchild指向其前驱结点;若无右子树,令rchild指向其后继结点。还需增加两个标志域标识指针域是指向左右孩子或者前驱后继
typedef struct ThreadNode {
// 数据元素
ElemType data;
// 左右孩子指针
struct ThreadNode *lchild, *rchild;
// 左右线索标志
int ltag, rtag;
} ThreadNode, *ThreadTree;
中序线索二叉树的构造
二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索。而前驱或后继的信息只有在遍历时才能得到,因此线索化的实质就是遍历一次二叉树
以中序线索二叉树的建立为例。附设指针pre指向刚刚访问过的结点,指针p指向正在访问的结点,即pre指向p的前驱。在中序遍历的过程中,检查p的左指针是否为空,若为空就将它指向pre;检查pre的右指针是否为空,若为空就将它指向p
通过中序遍历对二叉树线索化的递归算法如下
void InThread(ThreadTree &p, ThreadTree &pre) {
if (p != NULL) {
// 递归,线索化左子树
InThread(p -> lchild, pre);
// 左子树为空,建立前驱线索
if (p -> lchild == NULL) {
p -> lchild = pre;
p -> ltag = 1;
}
if (pre != NULL && pre -> rchild == NULL) {
// 建立前驱结点的后继线索
pre -> rchild = p;
pre -> rtag = 1;
}
// 标记当前结点成为刚刚访问过的结点
pre = p;
// 递归,线索化右子树
InThread(p -> rchild, pre);
}
}
通过中序遍历建立中序线索二叉树的主过程算法如下
void CreateInThread(ThreadTree T) {
ThreadTree pre = NULL;
if (T != NULL) {
// 非空二叉树,线索化
InThread(T, pre);
// 线索化二叉树
pre -> rchild = NULL;
// 处理遍历的最后一个结点
pre -> rtag = 1;
}
}
中序线索二叉树的遍历
双亲表示法
采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。如下图,根结点下标为0,伪指针域为-1
双亲表示法的存储结构描述如下
// 树中最多结点数
#define MAX_TREE_SIZE 100
// 树的结点定义
typedef struct {
ElemType data;
// 双亲位置域
int parent;
} PTNode;
// 树的类型定义
typedef struct {
// 双亲表示
PTNode nodes[MAX_TREE_SIZE];
// 结点数
int n;
} PTree;
该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质,可以很快得到每个结点的双亲结点,但求结点的孩子时,需要遍历整个结构
区别树的顺序储存结构与二叉树的顺序存储结构。在树的顺序存储结构中,数组下标代表结点的编号,下标中所存的内容指示了结点之间的关系。而在二叉树的顺序存储结构中,数组下标既代表了结点的编号,又指示了二叉树中各结点之间的关系。当然,二叉树属于树,因此二叉树都可以用树的存储结构来存储,但树却不都能用二叉树的存储结构来存储
孩子表示法
孩子兄弟表示法
(二叉树表示法)
以二叉链表作为树的存储结构。孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针、指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点)
typedef struct CSNode {
// 数据域
ElemType data;
// 第一个孩子和右兄弟指针
struct CSNode *firstchild, *nextsibling;
} CSNode, *CSTree;
这种存储表示法比较灵活,其最大的优点是可以方便地实现树转换为二叉树的操作,易于查找结点的孩子,但缺点是从当前的结点查找其双亲结点比较麻烦。若为每个结点增设一个parent域指向其父结点,则查找结点的父结点也很方便
树转换为二叉树的规则:每个结点左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则又称为左孩子右兄弟。由于根结点没有兄弟,所以对应的二叉树没有右子树
树转换为二叉树的画法
将森林转换为二叉树的规则与树类似
二叉树转换为森林的规则
先根遍历
后根遍历
先序遍历森林
中序遍历森林
二叉排序树的定义(二叉查找树)
二叉排序树的查找
从根结点开始,沿某个分支逐层向下比较的过程
非递归查找算法
BSTNode *BST_Search(BiTree T, ElemType key ) {
while ( T != NULL && key != T -> data ) {
if ( key < T -> data )
T = T -> lchild;
else
T = T -> rchild;
}
return T;
}
二叉排序树的插入
二叉排序树作为一种动态树表,其特点是树的结构通常不是一次生产的,而是在查找过程中,当树中不存在关键字值等于给定值得结点时再进行插入的
插入结点的过程如下
插入操作的算法
int BST_Insert(BiTree &T, KeyType k) {
// 原树为空,新插入的记录为根结点
if ( T == NULL ) {
T = (BiTree) malloc(sizeof(BSTNode));
T -> key = k;
T -> lchild = T -> rchild = NULL;
// 返回1,插入成功
return 1;
} else if ( k == T -> key) {
// 树中存在相同关键字的结点,插入失败
return 0;
} else if ( k < T -> key) {
// 插入到T的左子树
return BST_Insert(T -> lchild, k);
} else {
// 插入到T的右子树
return BST_Insert(T -> rchild, k);
}
}
二叉排序树的构造
从一棵空树出发,依次输入元素,将它们插入二叉排序树中的合适位置
构造二叉排序树的算法如下
void Create_BST(BiTree &T, KeyType str[], int n) {
T = NULL;
int i = 0;
while ( i < n ) {
BST_Insert(T, str[i]);
i++;
}
}
二叉排序树的删除
二叉排序树的查找效率分析
平衡二叉树的定义
平衡二叉树的插入
保持平衡的基本思想如下
调整不平衡的规律如下
LL平衡旋转(右单旋转)
RR平衡旋转(左单旋转)
LR平衡旋转(先左后右双旋转)
RL平衡旋转(先右后左双旋转)
平衡二叉树的查找
在平衡二叉树上进行查找的工作与二叉排序树的相同。因此,在查找过程中,与给定值进行比较的关键字个数不超过树的深度。假设以** n h n_h nh表示深度为h的平衡树中含有的最少结点数。显然,有 n 0 = 0 , n 1 = 1 , n 2 = 2 n_0=0, n_1=1, n_2=2 n0=0,n1=1,n2=2,并且有 n h = n h − 1 + n h − 2 + 1 n_h = n_{h-1} + n_{h-2} + 1 nh=nh−1+nh−2+1。可以证明,含有n个结点的平衡二叉树的最大深度为 O ( l o g 2 n ) O(log_2n) O(log2n),因此平衡二叉树的平均查找长度为 O ( l o g 2 n ) O(log_2n) O(log2n)**,如下图
该结论可用于求解给定结点数的平衡二叉树的查找所需的最多比较次数(或树的最大高度)
哈夫曼树的定义
哈夫曼树的构造
哈夫曼编码
在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码
由哈夫曼树得到哈夫曼编码是很自然的过程。首先,将每个出现的字符当作一个独立的结点,其权值为它出现的频度(或次数),构造出对应的哈夫曼树。显然,所有字符结点都出现在叶结点中。我们可将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为0表示转向左孩子,标记为1表示转向右孩子
0和1究竟是表示左子树还是右子树没有明确规定。左右孩子结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度WPL相同且为最优。此外,如有若干权值相同的结点,则构造出的哈夫曼树更可能不同,但WPL必然相同且是最优的
图G由顶点集V和边集E组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间的关系(边)集合。若V={ v 1 , v 2 , . . . , v n v_1,v_2,...,v_n v1,v2,...,vn},则用**|V|表示图G中顶点的个数**,E={$(u,v)|u \in V, v \in V $},用**|E|表示图G中边的条数**
线性表可以是空表,树可以是空树,但图不可以是空图。就是说,图中不能一个顶点也没有,图的顶点集V一定非空,但边集E可以为空,此时图中只有顶点而没有边
有向图
无向图
简单图、多重图
完全图(简单完全图)
子图
连通、连通图和连通分量
强连通图、强连通分量
无向图中讨论连通性,有向图中讨论强连通性
生成树、生成森林
顶点的度、入度和出度
在无向图中,顶点v的度是指依附于顶点v的边的条数,记为TD(v)。无向图的全部顶点的度的和等于边数的2倍,因为每条边和两个顶点相关联
在有向图中,顶点v的度分为入度、出度,入度是以顶点v为终点的有向边的数目,记为ID(v)。而出度是以顶点v为起点的有向边的数目,记为OD(v)。
顶点v的度,等于其入度 + 出度,即TD(v) = ID(v) + OD(v)
对于具有n个顶点、e条边的有向图,其全部顶点的入度之和与出度之和相等,并且等于边数,这是因为每条有向边都有一个起点和终点
∑ i = 1 n I D ( v i ) = ∑ i = 1 n O D ( v i ) = e \rm \sum^n_{i=1}ID(v_i) = \sum^n_{i=1}OD(v_i)=e i=1∑nID(vi)=i=1∑nOD(vi)=e
边的权和网
稠密图、稀疏图
路径、路径长度和回路
简单路径、简单回路
距离
有向树
所谓邻接矩阵存储,是指用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(即各顶点之间的邻接关系),存储顶点之间邻接关系的二维数组称为邻接矩阵
结点数为n的图G=(V,E)的邻接矩阵A是n*n的。将G的顶点编号为 v 1 , v 2 , . . . , v n v_1,v_2,...,v_n v1,v2,...,vn。若 ( v i , v j ) ∈ E (v_i,v_j) \in E (vi,vj)∈E,则A[i][j] = 1,否则A[i][j] = 0
A [ i ] [ j ] = { 1 ,若 ( v i , v j ) 或 < v i , v j > 是 E ( G ) 中的边 0 ,若 ( v i , v j ) 或 < v i , v j > 不是 E ( G ) 中的边 A[i][j] = \begin {cases} 1,若(v_i,v_j)或
对于带权图而言,若顶点 v i v_i vi和 v j v_j vj之间有边相连,则邻接矩阵中对应项存放着该边对应的权值,若顶点 V i V_i Vi和 V j V_j Vj不相连,则用 ∞ \infin ∞来代表两个顶点之间不存在边
A [ i ] [ j ] = { w i j ,若 ( v i , v j ) 或 < v i , v j > 是 E ( G ) 中的边 0 或 ∞ ,若 ( v i , v j ) 或 < v i , v j > 不是 E ( G ) 中的边 A[i][j] = \begin{cases} w_{ij},若(v_i,v_j)或
图的邻接矩阵存储结构定义如下
// 顶点数目的最大值
#define MaxVertexNum 100
// 顶点的数据类型
typedef char VertextType;
// 带权图中边上权值的数据类型
typedef int EdgeType;
typedef struct {
// 顶点表
VertexType Vex[MaxVertexNum];
// 邻接矩阵,边表
EdgeType Edge[MaxVertexNum][MaxVertexNum];
// 图的当前顶点数和弧数
int vexnum, arcnum;
} MGraph;
图的邻接矩阵存储表示法具有以下特点
当一个图为稀疏图时,使用邻接矩阵法显然要浪费大量的存储空间,而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费
所谓邻接表,是指对图G中的每个顶点 v i v_i vi建立一个单链表,第i个单链表中的结点表示依附于顶点 v i v_i vi的边(对于有向图则是以顶点 v i v_i vi为尾的弧),这个单链表就称为顶点 v i v_i vi的边表(对于有向图,则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点
顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc)构成,边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc)构成
无向图和有向图的邻接表的实例如下图
图的邻接表存储结构定义如下
// 图中顶点数目的最大值
#define MaxVertexNum 100
// 边表结点
typedef struct ArcNode {
// 该弧所指向的顶点的位置
int adjvex;
// 指向下一条弧的指针
struct ArcNode *next;
} ArcNode;
// 顶点表结点
typedef struct VNode {
// 顶点信息
VertexType data;
// 指向第一条依附该顶点的弧的指针
ArcNode *first;
} VNode, AdjList[MaxVertexNum];
// 邻接表
typedef struct {
// 邻接表
AdjList vertices;
// 图的顶点数和弧数
int vexnum, arcnum;
} ALGraph;
图的邻接表存储方法具有以下特点
有向图的一种链式存储结构。在十字链表中,对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点。这些结点的结构如下
弧结点中有5个域
顶点结点中有3个域
下图为有向图的十字链表法。注意,顶点结点之间是顺序储存的
邻接多重表是无向图的另一种链式存储结构
在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边或对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低
与十字链表法类似,在链接多重表中,每条边用一个结点表示,其结构如下
每个顶点也用一个结点表示,它由如下所示的两个域组成
在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,由于每条边依附于两个顶点,因此每个边结点同时链接在两个链表中。对无向图而言,其邻接多重表和邻接表的差别仅在于
下图为无向图的邻接多重表表示法。邻接多重表的各种基本操作的实现和邻接表类似
Adjacent(G, x, y)
Neighbors(G, x)
InsertVertex(G, x)
DeleteVertex(G, x)
AddEdge(G, x, y)
RemoveEdge(G, x, y)
FirstNeighbor(G, x)
NextNeighbor(G, x, y)
Get_edge_value(G, x, y)
Set_edge_value(G, x, y, v)
图的遍历算法
基本思想
是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点
伪代码如下
// 访问标记数组
bool visited[MAX_VERTEX_NUM];
// 对图G进行广度优先遍历
void BFSTraverse(Graph G) {
// 访问标记数组初始化
for( i = 0; i < G.vexnum; ++i ) {
visited[i] = FALSE;
}
// 初始化辅助队列Q
InitQueue(Q);
// 从0号顶点开始遍历
for( i = 0; i < G.vexnum; ++i ) {
// 对每个连通分量调用一次BFS
if ( !visited[i] ) {
// vi未访问过,从vi开始BFS
BFS(G, i);
}
}
}
// 从顶点v出发,广度优先遍历图G
void BFS(Graph G, int v) {
// 访问初始顶点v
visit(v);
// 对v做已访问标记
visited[v] = TRUE;
// 顶点v入队列Q
Enqueue(Q, v);
while( !isEmpty(Q)) {
// 顶点v出队列
DeQueue(Q, v);
// 检测v所有邻接点
for ( w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w) ) {
// w为v的尚未访问的邻接顶点
if ( !visited[w] ) {
// 访问顶点w
visit(w);
// 对w做已访问标记
visited[w] = TRUE;
// 顶点w入队列
EnQueue(Q, w);
}
}
}
}
BFS算法的性能分析
BFS算法求解单源最短路径问题
若图G=(V,E)为非带权图,定义从顶点u到顶点v的最短路径d(u, v)为从u到v的任何路径中最少的边数;若从u到v没有通路,则d(u, v) = ∞ \infin ∞
使用BFS,这是由广度优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的
算法如下
void BFS_MIN_Distance(Graph G, int u) {
// d[i]表示从u到i结点的最短路径
for (i =0; i < G.vexnum; ++i ) {
// 初始化路径长度
d[i] = -1;
}
visited[u] = TRUE;
d[u] = 0;
EnQueue(Q, u);
while( !isEmpty(Q) ) {
// 队头元素u出队
DeQueue(Q, u);
for ( w = FirstNeighbor(G, u); w >= 0; w = NextNeighbor(G, u, w) ) {
if (!visited[w]) {
// w为u的尚未访问的邻接顶点
// 设已访问标记
visited[w] = TRUE;
// 路径长度+1
d[w] = d[u] + 1;
// 顶点w入队
EnQueue(Q, w);
}
}
}
}
广度优先生成树
类似于树的先序遍历
基本思想
算法过程如下
// 访问标记数组
bool visited[MAX_VERTEX_NUM];
// 对图G进行深度优先遍历
void DFSTraverse(Graph G) {
for ( v = 0; v < G.vexnum; ++v ) {
// 初始化已访问标记数据
visited[v] = FALSE;
}
// 本代码中是从v=0开始遍历
for ( v = 0; v < G.vexnum; ++v ) {
if ( !visited[v] )
DFS(G, v);
}
}
// 从顶点v出发,深度优先遍历图G
void DFS(Graph G, int v) {
// 访问顶点v
visit(v);
// 设已访问标记
visited[v] = TRUE;
for ( w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w) ) {
// w为v的尚未访问的邻接顶点
if ( !visited[w] )
DFS(G, w);
}
}
图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同。因此,对于同样一个图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历所得到的DFS序列和BFS序列是不唯一的
DFS算法的性能分析
深度优先的生成树和生成森林
一个连通图的生成树包含图中的所有顶点,并且只含尽可能少的边。对于生成树来说,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路
对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的那棵生成树,则T称为G的最小生成树(Minimum-Spanning-Tree,MST)
不难看出,最小生成树具有如下性质
构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质
假设G=(V,E)是一个带权连通无向图,U是顶点集V的一个非空子集。若(u,v)是一条具有最小权值的边,其中 u ∈ U , v ∈ V − U u \in U, v \in V-U u∈U,v∈V−U,则必存在一棵包含边(u,v)的最小生成树
基于该性质的最小生成树算法主要有Prim算法、Kruskal算法,基于贪心算法的策略
通用的最小生成树算法
GENERIC_MST(G) {
T = NULL;
while T 未形成一棵生成树;
do 找到一条最小代价边(u, v)并且加入T后不会产生回路
T = T U(并) (u, v);
}
Prim算法
类似于寻找图的最短路径的Dijkstra算法
过程如下图所示
算法步骤如下
简单实现如下
void Prim(G, T) {
T = NULL;
U = {w};
while( (V-U) != NULL ) {
设(u,v)是使u属于U与v属于(V-U),且权值最小的边
// 边归入树
T = T U {(u, v)};
// 顶点归入树
U = U U {v};
}
}
Prim算法的时间复杂度为O( ∣ V ∣ 2 |V|^2 ∣V∣2),不依赖|E|,因为它适用于求解边稠密的图的最小生成树。虽然采用其他方法能改进Prim算法的时间复杂度,但增加了实现的复杂性
Kruskal算法
与Prim算法从顶点开始扩展最小生成树不同,Kruskal算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法
构造最小生成树的过程
算法简单实现如下
void Kruskal(V, T) {
// 初始化树T,仅含顶点
T = V;
// 连通分量数
numS = n;
// 若连通分量数大于1
while ( numS > 1 ) {
// 从E中取出权值最小的边(v,u)
if ( v和u属于T中不同的连通分量) {
// 将此边加入生成树中
T = T U {(v, u)};
// 连通分量数减1
numS--;
}
}
}
根据图的相关性质,若一条边连接了两棵不同树中的顶点,则对这两棵树来说,它必定是连通的,将这条边加入森林中,完成两棵树的合并,直到整个森林合并成一棵树
通常在Kruskal算法中,采用堆来存放边的集合,因此每次选择最小权值的边只需O(log|E|)的时间。此外,由于生成树T中的所有边可视为一个等价类,因此每次添加新的边的过程类似于求解等价类的过程,由此可以采用并查集的数据结构来描述T,从而构造T的时间复杂度为O(|E|log|E|)。因此,Kruskal算法适合于边稀疏而顶点较多的图
当图是带权图时,把从一个顶点到图中其余任意一个顶点的一条路径(可能不止一条)所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径长度最短的那条称为最短路径
一般分为两类问题,一是单源最短路径,可通过Dijkstra算法求解;二是求每对顶点间的最短路径,可通过Floyd算法求解
Dijkstra算法求单源最短路径问题
设置一个集合S记录已求得的最短路径的顶点,初始时,把源点放入S,集合S每并入一个新顶点,都要修改源点到集合V-S中顶点当前的最短路径长度值
在构造的过程中,还设置了两个辅助数组
dist[]
path[]
算法步骤如下
初始化,集合S初始为{0},dist[]的初始值dist[i] = arcs[0][i],i=1,2,…,n-1
从顶点集合V-S中选出v,满足dist[j] = Min{dist[i] | v ∈ V − S v \in V-S v∈V−S},v就是当前求得的一条从源点出发的最短路径的终点,令S = S ∪ \cup ∪ {j}
修改从源点出发到集合V-S上任一顶点v可达的最短路径长度,若
d i s t [ j ] + a r c s [ j ] [ k ] < d i s t [ k ] dist[j] + arcs[j][k] < dist[k] dist[j]+arcs[j][k]<dist[k]
则更新dist[k] = dist[j] + arcs[j][k]
重复2~3操作共n-1次,直到所有的顶点都包含在S中
使用邻接矩阵表示时,时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。使用带权的邻接表表示时,虽然修改dist[]的时间可以减少,但由于在dist[]中选择最小分量的时间不变,时间复杂度仍为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
边上带有负权值时,Dijkstra算法并不适用。若允许边上带有负权值,则在与S(已求得最短路径的顶点集,归入S内的结点的最短路径不再变更)内某点a以负边相连的点b确定其最短路径时,其最短路径长度加上这条负边的权值结果可能小于a原先确定的最短路径长度,而此时a在Dijkstra算法下是无法更新的
Floyd算法求各顶点之间最短路径问题
若一个有向图中不存在环,则称为有向无环图,简称DAG图
有向无环图是描述含有公共子式的表达式的有效工具,例如表达式可以用二叉树表示,有一些相同的子表达式,在二叉树中,它们也重复出现。若利用有向无环图,则可实现对相同子式的共享,从而节省存储空间
AOV网:若用DAG图表示一个工程,其顶点表示活动,用有向边 < V i , V j >
拓扑排序:在图论中,有一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑
或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列
对一个AOV网进行拓扑排序的算法有很多,比较常用的方法的步骤是
算法实现如下
bool TopologicalSort( Graph G ) {
// 初始化栈,存储度为0的顶点
InitStack( S );
for ( int i = 0; i < G.vexnum; i++ )
if ( indegree[i] == 0 )
// 将所有入度为0的顶点入栈
Push( S, i );
// 计数,记录当前已经输出的顶点数
int count = 0;
// 栈不空,则存在入度为0的顶点
while ( !IsEmpty( S ) ) {
// 栈顶元素出栈
Pop( S, i );
// 输出顶点i
print[count++] = i;
for ( p = G.vertices[i].firstarc; p ; p = p -> nextarc ) {
// 将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈S
v = p -> adjvex;
if ( ! ( --indegree[v] ) )
// 入度为0,则入栈
Push( S, v );
}
}
if ( count < G.vexnum )
// 排序失败,有向图中有回路
return false;
else return true;
}
由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为O(|V|+|E|)
对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序
用拓扑排序算法处理AOV网时,需要注意以下问题
查找
查找表(查找结构)
静态查找表
关键字
平均查找长度
在查找过程中,一次查找长度是指需要比较的关键字次数,而平均查找长度则是所有查找过程中进行关键字的比较次数的平均值,其数学定义为
A S L = ∑ i = 1 n P i C i \rm ASL=\sum^n_{i=1}P_iC_i ASL=i=1∑nPiCi
对顺序表和链表都是适用的。
顺序查找通常分为对一般的无序线性表的顺序查找和对按关键字有序的线性表的顺序查找
一般线性表的顺序查找
基本思想
算法
typedef struct {
// 元素存储空间基址,建表时按实际长度分配,0号单元留空,方便实现哨兵机制
ElemType *elem;
int TableLen;
} SSTable;
int Search_Seq(SSTable ST, ElemType key) {
// 将ST.elem[0]称为”哨兵“。引入它的目的是使得Search_Seq内的循环不必判断数组是否会越界
// 因为满足i==0时,循环一定会跳出
// 在程序中引入”哨兵”并不是这个算法独有的。引入“哨兵”可以避免很多不必要的判断语句,
// 从而提高程序效率
ST.elem[0] = key;
for (i = ST.TableLen; ST.elem[i] != key; --i);
return i;
}
对于有n个元素的表,给定值key域表中第i个元素相等,即定位第i个元素时,需进行** n − i + 1 n-i+1 n−i+1**次关键字的比较,即 C i = n − i + 1 C_i=n-i+1 Ci=n−i+1。查找成功时,顺序查找的平均长度为
A S L 成功 = ∑ i = 1 n P i ( n − i + 1 ) \rm ASL_{成功}=\sum^n_{i=1}P_i(n-i+1) ASL成功=i=1∑nPi(n−i+1)
当每个元素的查找概率相等,即 P i = 1 / n P_i=1/n Pi=1/n时,有
A S L 成功 = ∑ i = 1 n P i ( n − i + 1 ) = n + 1 2 \rm ASL_{成功}=\sum^n_{i=1}P_i(n-i+1)=\frac{n+1}{2} ASL成功=i=1∑nPi(n−i+1)=2n+1
查找不成功时,与表中各关键字的比较次数显然是n+1次,从而顺序查找不成功的平均查找长度为 A S L 不成功 = n + 1 ASL_{不成功}=n+1 ASL不成功=n+1
通常,查找表中记录的查找概率并不相等。若能预先得知每个记录的查找概率,则应先对记录的查找概率进行排序,使表中记录按查找概率由大至小重新排列
综上所述,顺序查找的缺点是当n较大时,平均查找长度较大,效率低;优点是对数据元素的存储没有要求,顺序存储或链式存储皆可。对表中记录的有序性也没有要求,无论记录是否按关键字有序,均可应用。同时还需注意,对线性的链表只能进行顺序查找
有序表的顺序查找
仅适用于有序的顺序表
基本思想
算法如下
int Binary_Search(SeqList L, ElemType key) {
int low = 0, high = L.TableLen - 1, mid;
while ( low <= high ) {
mid = ( low + high ) / 2;
if ( L.elem[mid] == key )
return mid;
else if ( L.elem[mid] > key )
high = mid - 1;
else
low = mid + 1;
}
return -1;
}
在等概率查找时,查找成功的平均查找长度为
A S L = 1 n ∑ i = 1 n l i = 1 n ( 1 × 1 + 2 × 2 + . . . + h × 2 h − 1 ) = n + 1 n l o g 2 ( n + 1 ) − 1 ≈ l o g 2 ( n + 1 ) − 1 ASL = \frac{1}{n}\sum^n_{i=1}l_i=\frac{1}{n}(1 \times 1 + 2 \times 2 + ... + h \times 2^{h-1}) = \frac{n+1}{n}log_2(n+1)-1 \approx log_2(n+1)-1 ASL=n1i=1∑nli=n1(1×1+2×2+...+h×2h−1)=nn+1log2(n+1)−1≈log2(n+1)−1
吸取了顺序查找和折半查找的优点,既有动态结构,又适于快速查找
基本思想
分块查找分两步
分块查找的平均查找长度为索引查找和块内查找的平均长度之和
将长度为n的查找表均匀地分为b块,每块有s个记录,在等概率的情况下,若在块内和索引表中均采用顺序查找,则平均查找长度为
A S L = L I + L S = b + 1 2 + s + 1 2 = s 2 + 2 s + n 2 s \rm ASL=L_I+L_S=\frac{b+1}{2}+\frac{s+1}{2}=\frac{s^2+2s+n}{2s} ASL=LI+LS=2b+1+2s+1=2ss2+2s+n
B树(多路平衡查找树),B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树可以是空树,或者满足以下条件的m叉树
树中每个结点至多有m棵子树,即至多含有m-1个关键字
若根结点不是终端结点,则至少有两棵子树
除根结点外的所有非叶结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树,即至少含有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉-1个关键字
所有非叶结点的结构如下
所有叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)
B树是所有结点的平衡因子均等于0的多路平衡查找树
B树的高度
(磁盘存取次数)
B树中的大部分操作所需的磁盘存取次数与B树的高度成正比
因为B树中每个结点最多有m棵子树,m-1个关键字,所以在一棵高度为h的m阶B树中关键字的个数应满足 n ≤ ( m − 1 ) ( 1 + m + m 2 + . . . + m h − 1 = m h − 1 n \le (m-1)(1+m+m^2+...+m^{h-1}=m^h-1 n≤(m−1)(1+m+m2+...+mh−1=mh−1,因此有
h ≥ l o g m ( n + 1 ) \rm h \ge log_m(n+1) h≥logm(n+1)
若让每个结点中的关键字个数达到最少,则容纳同样多关键字的B树的高度达到最大
B树的查找
在B树上进行查找与二叉查找树相似,只是每个结点都是多个关键字的有序表,在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定
B树查找包含两个基本操作
由于B树常存储在磁盘上,因此前一个查找操作是在磁盘上进行,后一个查找操作是在内存中进行,即在找到目标结点后,先将结点信息读入内存,然后在结点内采用顺序查找法或折半查找法
B树的插入
定位
插入
在B树中,每个非失败结点的关键字个数都在区间[ ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉-1, m-1]内
B树的删除
B树的删除与插入操作类似,但要复杂一些,即要使得删除后的结点中的关键字个数 ≥ ⌈ m / 2 ⌉ − 1 \ge \lceil m/2 \rceil -1 ≥⌈m/2⌉−1,因此将涉及结点的合并问题
当被删除关键字k不在终端结点(最底层非叶结点)中时,可以用k的前驱(或后继) k 1 k^1 k1 来替代k,然后在相应的结点中删除** k 1 k^1 k1,关键字 k 1 k^1 k1必定落在某个终端结点中,则转换成了被删关键字在终端结点中**的情形
当被删关键字在终端结点(最底层非叶结点)中时,有下列三种情况
直接删除关键字
。若被删除关键字所在结点的关键字个数 ≥ ⌈ m / 2 ⌉ \ge \lceil m/2 \rceil ≥⌈m/2⌉,表明删除关键字后仍满足B树的定义,则直接删去该关键字
兄弟够借
。若被删除关键字所在结点删除前的关键字个数 = ⌈ m / 2 ⌉ − 1 =\lceil m/2 \rceil -1 =⌈m/2⌉−1,且与此结点相邻的右(或左)兄弟结点的关键字个数 ≥ ⌈ m / 2 ⌉ \ge \lceil m/2 \rceil ≥⌈m/2⌉,则需要调整该结点、右(或左)兄弟结点及其双亲结点(父子换位法),以达到新的平衡
兄弟不够借
。若被删除关键字所在结点删除前的关键字个数 = ⌈ m / 2 ⌉ − 1 = \lceil m/2 \rceil -1 =⌈m/2⌉−1,且此时与该结点相邻的左、右兄弟结点的关键字个数均 = ⌈ m / 2 ⌉ − 1 =\lceil m/2 \rceil -1 =⌈m/2⌉−1,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并
在合并过程中,双亲结点中的关键字个数会减1
B+树是应数据库所需而出现的一种B树的变形树
一棵m阶的B+树应满足以下条件
m阶的B+树与m阶的B树的主要差异如下
分支结点的某个关键字是其子树中最大关键字的副本
通常在B+树中有两个头指针
因此,可以对B+树进行两种查找运算
B+树的查找、插入、删除操作和B树的基本类似。只是在查找过程中,非叶结点上的关键字值等于给定值时并不终止,而是继续向下查找,直到叶结点上的该关键字为止。
构造散列函数时必须注意以下几点
常用的散列函数
直接定址法
直接取关键字的某个线性函数值为散列地址,散列函数为
H ( k e y ) = k e y 或 H ( k e y ) = a × k e y + b H(key)=key \qquad 或 \qquad H(key)=a \times key + b H(key)=key或H(key)=a×key+b
除留余数法
数字分析法
平方取中法
用 H i H_i Hi表示处理冲突中,第i次探测得到的散列地址,假设得到的另一个散列地址 H 1 H_1 H1仍然发生冲突,只得继续求下一个地址 H 2 H_2 H2,依次类推,直到 H k H_k Hk不发生冲突为止,则 H k H_k Hk为关键字在表中的地址
开放定址法
可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为
H i = ( H ( k e y ) + d i ) ( m o d m ) H_i=(H(key)+d_i) \pmod m Hi=(H(key)+di)(modm)
取定某一增量序列后,对应的处理方法就是确定的,通常有以下4种取法
线性探测法
平方探测法
再散列法
(双散列法)
伪随机序列法
在开放定址的情形下,不能随便物理删除表中的已有元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。因此,要删除一个元素时,可给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除
拉链法
(链接法,chaining)
虽然散列表在关键字与记录的存储位置之间建立了直接映像,但由于冲突的产生,使得散列表的查找过程仍然是一个给定值和关键字进行比较的过程。因此,仍需要以平均查找长度作为衡量散列表的查找效率的衡量
散列表的查找效率取决于三个因素:散列函数、处理冲突的方法、装填因子
装填因子,在散列表中一般记为 α \alpha α,定义为一个表的装满程度,即
α = 表中记录数 n 散列表长度 m \rm \alpha=\frac{表中记录数n}{散列表长度m} α=散列表长度m表中记录数n
散列表的平均查找长度依赖于散列表的装填因子,而不直接依赖于n或m。直观的看,表示装填的记录越满,发生冲突的可能性越大
假设在排序过程中,待排序表**L[1…n]**在某次排序过程中的某一时刻状态如下
要将元素L(i)插入已有序的子序列L[1…i-1],需要执行以下操作
性能分析如下
空间效率:仅使用了常数个辅助单元,因而空间复杂度为O(1)
时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了n-1趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态
大部分排序算法都仅适用于顺序储存的线性表
直接插入排序算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),但若待排序列为正序时,其时间复杂度可提高至O(n),由此可见,它更适用于基本有序的排序表和数据量不大的排序表。希尔排序正是基于这两点分析对直接插入排序改进而来,又称缩小增量排序
基本思想
先将待排序表分割成若干形如L[i, i+d, i+2d, …, i+kd]的特殊子表,即把相隔某个增量的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈基本有序时,再对全体记录进行一次直接插入排序
过程如下
先取一个小于n的步长 d 1 d_1 d1,把表中的全部记录分成 d 1 d_1 d1组,所有距离为 d 1 d_1 d1的倍数的记录放在同一组,在各组内进行直接插入排序
取第二个步长 d 2 < d 1 d_2 < d_1 d2<d1,重复上述过程,直到所取得到的 d t = 1 d_t=1 dt=1,即所有记录已放在同一组中,再进行直接插入排序,由于此时已经具有较好的局部有序性,故可以很快得到最终结果
希尔提出的方法是 d 1 = n / 2 , d i + 1 = ⌊ d i / 2 ⌋ d_1=n/2, d_{i+1}=\lfloor d_i/2 \rfloor d1=n/2,di+1=⌊di/2⌋,并且最后一个增量等于1
性能分析
快速排序的基本思想是基于分治法的:在待排序表L[1…n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1…k-1]和L[k+1…n],使得L[1…k-1]中的所有元素小于pivot,L[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置上L(k)上,这个过程称为一趟快速排序,然后分别递归地对两个子表重复上述过程
性能分析
在快速排序算法中,并不产生有序子序列,但每趟排序后会将枢轴(基准)元素放到其最终的位置上
堆的定义如下,n个关键字序列L[1…n]称为堆,当且仅当该序列满足
可以将该一维数组视为一棵完全二叉树
堆排序的思路很简单
堆排序需要解决两个问题
如何将无序序列构造成初始堆
输出堆顶元素后,如何将剩余元素调整成新堆
堆排序算法的性能分析
不基于比较、移动进行排序,而基于关键字各位的大小进行排序。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法
为实现多关键字排序,通常有两种方法
过程
性能分析如下
算法种类 | 时间复杂度最好 | 时间复杂度平均 | 时间复杂度最坏 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|---|
直接插入排序 | O(n) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O(1) | 是 |
冒泡排序 | O(n) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O(1) | 是 |
简单选择排序 | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O(1) | 否 |
希尔排序 | O(1) | 否 | |||
快速排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n 2 ) O(n^2) O(n2) | O ( l o g 2 n ) O(log_2n) O(log2n) | 否 |
堆排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O(1) | 否 |
2路归并排序 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | O ( n ) O(n) O(n) | 是 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O® | 是 |
因为磁盘读写的机械动作所需的时间远远超过内存运算的时间,因此在外部排序过程中的时间代价,主要考虑访问磁盘的次数,即I/O次数
外部排序通常采用归并排序法。它包括两个相对独立的阶段
在外部排序中实现两两归并时,由于不可能将两个有序段及归并结果段同时存放在内存中,因此需要不停地将数据读出、写入磁盘,而这会耗费大量的时间,一般情况下
外部排序的总时间 = 内部排序所需的时间 + 外存信息读写的时间 + 内部归并所需的时间