相关概念
- 查找表
- 静态查找表:只有查找操作
- 动态查找表:查找、插入 / 删除
- 记录
- 关键字 / 关键码 → 主关键字、次关键字
顺序查找
- 在查找方向的尽头放置“哨兵”:避免了在查找过程中每一次比较后都要判断查找位置是否越界
/* 《大话数据结构》 有哨兵顺序查找 */
int Sequential_Search2(int *a, int n, int key) {
int i;
a[0] = key; /* 设置a[0]为关键字值,我们称之为“哨兵” */
i = n; /* 循环从数组尾部开始 */
while (a[i] != key){
i--;
}
return i; /* 返回0则说明查找失败 */
}
性能
- 平均查找次数:
- 时间复杂度:
n很大时,查找效率极为低下;
个算法非常简单,对静态查找表的记录没有任何要求,适合小型数据
有序查找
mid 选择不同
折半查找 / 二分查找
前提:线性表必须采用顺序存储,关键码有序
在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。
性能
- 时间复杂度:
不适合频繁插入删除
插值查找
待查找关键字 key 与最大、最小关键字比较
性能
- 时间复杂度:
适合表比较长,关键字分布均匀的查找表
斐波那契查找
- 斐波那契数列:F={0,1,1,2,3,5,8,13,21,......}
在斐波那契数列找一个等于略大于查找表中元素个数的数 F[n],将原查找表扩展为长度为F[n]-1(不足则补a[n])。然后通过斐波那契分割,将 F[n]-1 个元素分割为前半部分 F[n-1]-1 个元素,后半部分 F[n-2]-1 个元素,和中间 F[n-1] 位置的元素
key < a[mid],k - 1;k > a[mid],k - 2
性能
- 时间复杂度:
只涉及加减法运算,不涉及乘除运算
代码
// 《大话数据结构》
int Fibonacci_Search(int *a, int n, int key)
{
int low, high, mid, i, k;
low = 1; /*定义最低下标为记录首位 */
high = n; /*定义最高下标为记录末位 */
k = 0;
while (n > F[k] - 1) /* 计算n位于斐波那契数列的位置 */
k++;
for (i = n; i < F[k] - 1; i++) /* 将不满的数值补全 */
a[i] = a[n];
while (low <= high)
{
mid = low + F[k - 1] - 1;
if (key < a[mid])
{
high = mid - 1; /* 最高下标调整到分隔下标mid-1处 */
k = k - 1; /* 斐波那契数列下标减一位 */
}
else if (key > a[mid])
{
low = mid + 1;
k = k - 2;
}
else
{
if (mid <= n)
return mid;
else
return n; /* 若mid>n说明是补全数值,返回n */
}
}
return 0;
}
// 生成斐波那契数列
int[] F = new int[n];
F[0] = 0;
F[1] = 1;
for (int i = 2; i < n; i++)
F[i] = F[i - 1] + F[i - 2];
线性索引查找
按先后顺序存储的数据(回帖数据、服务器日志数据……)
- 索引:把一个关键字与它对应的记录相关联。
一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息
- 按结构:线性索引、树形索引和多级索引
稠密索引
一个记录对应一个索引项;索引项按照关键码有序排列
可以使用折半、插值、斐波那契等有序查找算法
分块索引
分块,每块对应一个索引项;块内无序,块间有序
分块索引表:
- 最大关键码:每一块最大关键字
- 记录个数
- 首元素指针
性能
- 平均查找次数:
,n 个记录分成 m 块,每块有 t 条
- 时间复杂度:大于O(logn),小于O(n)
倒排索引
索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。
索引表:
- 次关键码
- 记录号表:存储具有相同次关键字的所有记录的记录号
二叉排序树 / 二叉查找树
动态查找中重要的数据结构,兼顾查找性能和插入、删除操作
或者是一棵空树,或者具有以下性质:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结构的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树。
构造一棵二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度
- 查找、插入
- 删除:
- 叶子结点
- 仅有左或右子树的结点
- 左、右子树都有的结点:找到需要删除的结点p的直接前驱(或直接后继)s,用s来替换结点p,然后再删除此结点s
typedef struct BiTNode{
int data; // 数据
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
// 删除
Status DeleteBST(BiTree *T, int key)
{
if (!*T)
return FALSE;
else
{
if (key == (*T)->data)
return Delete(T);
else if (key < (*T)->data)
return DeleteBST(&(*T)->lchild, key);
else
return DeleteBST(&(*T)->rchild, key);
}
}
// 从二叉排序树中删除结点p,并重接它的左或右子树
Status Delete(BiTree *p)
{
BiTree q, s;
if ((*p)->rchild == NULL)
{
q = *p;
*p = (*p)->lchild;
free(q);
}
else if ((*p)->lchild == NULL)
{
q = *p;
*p = (*p)->rchild;
free(q);
}
else
{
q = *p; s = (*p)->lchild;
while (s->rchild)
{
q = s; s = s->rchild;
}
(*p)->data = s->data; // s指向被删结点的直接前驱
if (q != *p)
q->rchild = s->lchild;
else
q->lchild = s->lchild;
free(s);
}
return TRUE;
}
性能
查找性能取决于二叉排序树的形状
平衡二叉树(AVL树)
- 高度平衡的 二叉排序树
或者为空树,或者左、右子树都是平衡二叉树,所有结点的平衡因子只可能为-1、0、1
- 平衡因子BF:二叉树上结点左子树深度减去右子树深度的值
- 最小不平衡子树:以距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树
构建
在构建二叉排序树的过程中,每插入一个结点,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树
旋转
- 最小不平衡子树的根结点与它的子结点符号相同时,计算根结点BF:
BF > 1,最小不平衡子树右旋(顺时针旋转);
BF < -1,最小不平衡子树左旋(逆时针旋转);
- 不同时:先将最小不平衡子树符号统一
性能
- 时间复杂度:
- 查找、插入、删除:
- 查找、插入、删除:
代码
// 《大话数据结构》
typedef struct BiTNode
{
int data;
int bf;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
右旋
当传入一个二叉排序树P,将它的左孩子结点定义为L,将L的右子树变成P的左子树,再将P改成L的右子树,最后将L替换P成为根结点
void R_Rotate(BiTree *P)
{
BiTree L;
L = (*P)->lchild;
(*P)->lchild = L->rchild;
L->rchild = (*P);
*P = L;
}
左旋
void L_Rotate(BiTree *P)
{
BiTree R;
R = (*P)->rchild;
(*P)->rchild = R->lchild;
R->lchild = (*P);
*P = R;
}
左平衡旋转
#define LH +1 /* 左高 */
#define EH 0 /* 等高 */
#define RH -1 /* 右高 */
void LeftBalance(BiTree *T)
{
BiTree L,Lr;
L = (*T)->lchild;
switch (L->bf) /* 检查T的左子树的平衡度,并作相应平衡处理 */
{
/* 新结点插入在T的左孩子的左子树上,要作单右旋处理 */
case LH:
(*T)->bf = L->bf = EH;
R_Rotate(T);
break;
/* 新结点插入在T的左孩子的右子树上,要作双旋处理 */
case RH:
Lr = L->rchild;
switch (Lr->bf) /* 修改T及其左孩子的平衡因子 */
{
case LH:
(*T)->bf = RH;
L->bf = EH;
break;
case EH:
(*T)->bf = L->bf = EH;
break;
case RH:
(*T)->bf = EH;
L->bf = LH;
break;
}
Lr->bf = EH;
L_Rotate(&(*T)->lchild); /* 对T的左子树作左旋平衡处理 */
R_Rotate(T); /* 对T作右旋平衡处理 */
}
}
右平衡旋转
红黑树
Red-Black Tree
一种高效的自平衡二叉搜索树(BST),通过额外的颜色标记和旋转操作维护树的平衡性,从而保证插入、删除和查找操作的时间复杂度为
是许多高级数据结构(如 std::set
和 std::map
在 C++ STL 中的实现)的基础
性质
- 结点是红色或黑色:每个结点都有一个颜色属性,要么是红色,要么是黑色
- 根结点是黑色
- 叶子结点(NIL 结点)是黑色:红黑树中的叶子结点是空结点(NIL)
- 红色结点的子结点必须是黑色:不能有两个连续的红色节点
- 从任一结点到其每个叶子结点的路径包含相同数量的黑色节点
红黑树的高度最多是,
为树中结点数量
插入
插入新结点时,默认将其颜色设为红色(除非是根结点),然后根据父结点和叔结点的颜色进行调整:
-
情况 1:新结点的父结点是黑色
-
直接插入,无需调整
-
-
情况 2:新结点的父结点是红色,且叔结点也是红色
-
将父结点和叔结点变为黑色,祖父结点变为红色
-
递归检查祖父结点
-
-
情况 3:新结点的父结点是红色,且叔结点是黑色或 NIL
-
通过旋转和重新着色调整树的结构
-
删除
删除结点时,可能需要通过以下步骤恢复平衡:
-
如果删除的结点是红色,直接删除,不会影响红黑树的性质
-
如果删除的结点是黑色,需要通过旋转和重新着色来恢复平衡
-
调整兄弟结点和父结点的颜色。
-
根据具体情况选择左旋或右旋
-
旋转
左旋
-
将某个结点的右子结点提升为父结点,原结点成为新父结点的左子结点。
-
适用于右子树过高的情况
右旋
-
将某个结点的左子结点提升为父结点,原结点成为新父结点的右子结点。
-
适用于左子树过高的情况
应用
-
C++ STL:
std::set
、std::map
、std::multiset
和std::multimap
的底层实现 -
Java:
TreeMap
和TreeSet
的底层实现 -
数据库:用于实现索引结构
-
操作系统:用于管理内存分配和进程调度
红黑树 与 AVL树
特性 | 红黑树 | AVL树 |
---|---|---|
平衡性 | 相对宽松,允许一定程度的不平衡 | 严格平衡,左右子树高度差不超过 1 |
插入 / 删除 | 更快,旋转和重新着色操作较少 | 较慢,可能需要更多旋转操作 |
查找 | 稍慢,因为树的高度可能较高 | 更快,因为树的高度更严格 |
适用场景 | 频繁插入和删除 | 频繁查找 |
多路查找树(B树)
针对内外存之间的存取设计
多路查找树是一种自平衡的搜索树,用于存储和管理大量数据,广泛应用于数据库和文件系统中,特别适用于外部存储(如硬盘),通过平衡的结构和多路分支,能够在大规模数据集上进行高效的查找、插入和删除操作。
-
自平衡:平衡树,所有叶子结点位于同一层
-
每个结点包含多个元素
-
阶(degree):结点最大孩子数,通常用
表示,一个结点最多包含
个元素
-
最小度数 / 最小阶数:通常用
表示,分支结点可以包含的最小子结点数量
-
根结点:最少
个元素;如果不是叶子结点,可以有最少
个子结点
-
-
每个内部结点有
~
个子结点,每个结点包含
~
个元素
-
根结点:如果根结点不是叶子结点,有
~
个元素;是叶子结点,至少有
个元素
-
-
有序性:每个结点内元素按升序排列
在B树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程
在含有 个关键字的
阶B树上查找时,从根结点到关键字结点的路径上涉及的结点数
操作 | 时间复杂度 | |
---|---|---|
查找 | 与二叉查找树类似,可通过比较结点中的元素来决定查找哪个子树 | |
插入 | 保证树的平衡性 插入会导致分裂(split)操作,分裂后的中间元素会被提升到父节点中,可能会继续导致父节点的分裂 | |
删除 | 可能会触发合并(merge)或借元素(borrow)操作,以维持树的平衡 |
B+树
应文件系统所需而设计的B树的变种。常用于数据库和文件系统中作为索引结构,对范围查询有更好的支持,其存储结构也更加适合外部存储的访问模式
- 所有实际数据(记录、数据指针等)都存储在叶子结点,叶子结点之间有链指针,按照键值大小排序(有序链表),便于范围查询
- 内部结点只存储索引,不存储实际数据,只存储键值(其子树最大或最小关键字)和指向子结点的指针
- 树的高度低,查询效率高
B+树和B树的区别
特性 | B+树 | B树 |
---|---|---|
数据存储 | 数据仅存储在叶子结点中 | 数据存储在所有结点中(根结点、内部结点、叶子结点) |
叶子结点 | 叶子结点之间通过链表形成有序链表 | 叶子结点之间无链表连接 |
查询效率 | 只需要访问叶子结点,查找路径较短 | 查找过程中,内部结点和叶子结点都可能被访问 |
范围查询 | 非常高效,通过叶子结点链表可以顺序遍历 | 范围查询较复杂,需要多次遍历不同层级的结点 |
存储效率 | 内部结点只存储索引,存储效率较高 | 内部结点存储数据,可能导致较低的存储效率 |
查找
与B树类似,从根结点开始,沿着指针递归查找直到叶子结点,每次查找最终都会在叶子结点找到数据(分支结点上的关键字只是用来索引的,不能提供实际记录的访问)
插入
通过查找确定数据的插入位置,插入到相应的叶子结点
如果叶子结点已满,进行结点分裂。分裂操作会将中间值提升到父结点,可能导致父结点的分裂,如果根结点分裂,树的高度会增加
删除
在叶子结点中进行
如果叶子结点的元素个数少于最小度数,需要进行结点合并 / 从兄弟结点借元素
删除操作也会引发父结点的调整,以保持树的平衡
范围查询
2-3树
每一个结点都具有两个孩子(2结点)或三个孩子(3结点)
- 一个2结点包含一个元素和两个孩子(或没有孩子),左子树包含的元素小于该元素,右子树包含的元素大于该元素
- 要么没有孩子,要有就有两个,不能只有一个孩子,3结点同理
- 一个3结点包含一小一大两个元素和三个孩子(或没有孩子),左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素
插入元素
2-3树插入操作一定发生在叶子结点上
- 空树:插入一个2结点即可
- 插入到 2结点叶子:将其升级为3结点
- 插入到 3结点叶子:拆分3结点,从其包含的两元素和插入元素中选择一个元素向上移动一层。如果插入使根结点拆分,树的高度会增加
删除元素
- 删除 3结点叶子上元素:在该结点处删除该元素。不会影响到整棵树的其他结点结构
- 删除 2结点叶子上元素:
- 此结点 双亲是2结点,右孩子3结点:删除后左旋
- 此结点 双亲是2结点,右孩子2结点
- 此结点 双亲是3结点
- 当前树是满二叉树:删除任何一个叶子都会破坏2-3树结构,此时需要考虑将层数减少
- 删除元素位于非叶子结点的内部结点:通常将树按中序遍历,得到此元素的前驱或后继元素,补位即可
2-3-4树
2结点、3结点、4结点(小中大三元素和四个孩子 / 没有孩子)
散列表查找(哈希表)
在记录的存储位置和它的关键字之间建立一个确定的对应关系 ,使得每个关键字
对应一个存储位置
- 对应关系
即为散列函数 / 哈希(Hash)函数
- 通过散列函数将数据元素映射到一个固定大小的连续存储空间(称为散列表 / 哈希表)中,通过键值快速访问数据
散列过程:
- 存储:通过散列函数计算记录的散列地址,按此散列地址存储该记录
- 查找:用同一个散列函数计算记录的散列地址,按此散列地址访问该记录
散列表适合对查找性能要求高、记录之间关系无要求的数据,不适合范围查找
散列函数的构造方法
构造散列函数时主要考虑:计算散列地址所需时间、关键字长度、散列表大小、关键字的分布情况、记录查找的频率
直接定址法
取关键字的某个线性函数值为散列地址:
,
为常数
简单、均匀,不会产生冲突,但需要事先知道关键字的分布情况,适合查找表较小且连续的情况
数字分析法
- 抽取方法:使用关键字的一部分来计算散列存储位置
适合处理关键字位数比较大、事先知道关键字的分布且关键字的若干位分布较均匀的情况(手机号码后四位用作散列地址)
平方取中法
适合不知道关键字的分布,关键字位数不大的情况
折叠法
将关键字从左到右分割成位数相等的几部分(最后一部分位数可不足),将这几部分叠加求和,并按散列表表长,取后几位作为散列地址
不需要知道关键字的分布,适合关键字位数较多的情况
除留余数法
对于散列表长为 的散列函数:
,mod是取模(求余数)
可以对关键字直接取模,也可以在折叠、平方取中后取模, 通常取小于或等于表长(最好接近表长)的最小质数或不包含小于20质因子的合数
- 合数:在大于1的整数中,除了能被1和本身整除外,还能被其他数(0除外)整除的数
随机数法
取关键字的随机函数值为它的散列地址:,
是随机函数
适合关键字的长度不等时的情况
散列冲突
在理想情况下,每一个关键字通过散列函数计算出来的地址都是不一样的,但实际上散列表的容量是有限的,会碰到,而
的情况,这种现象称为冲突,
、
称为这个散列函数的同义词
链地址法(Separate Chaining)
每个位置存储一个链表,冲突的元素会被插入到该位置的链表中(不换地儿,在原地想办法)
- 同义词子表:将所有关键字为同义词的记录存储在一个单链表中
散列表只存储所有同义词子表的头指针。查找时,计算哈希值后查找链表中的元素,直到找到匹配的键为止
开放地址法(Open Addressing) / 线性探测法
当发生冲突时,寻找下一个空槽位置来存储元素(进不去就换地儿)
,
是探测次数,
为表长,探测步长
- 堆积:不是同义词却需要争夺一个地址的情况
二次探测法
通过平方函数更新探测步长
,
是探测次数,
是常数
随机探测法
探测步长采用随机函数计算
再散列函数法
事先准备多个散列函数,发生冲突时就换
公共溢出区法
为所有冲突的关键字建立公共的溢出区存放
在查找时,通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表顺序查找
负载因子和扩容
- 散列表的 负载因子 / 装填因子(Load Factor):散列表已填入元素数量与散列表长度的比值
散列表的平均查找长度取决于负载因子,而不是查找集合中的记录个数。负载因子过高会导致冲突增多,影响查找效率。当负载因子超过某个阈值时,需要 扩容 散列表(增加槽位数量),并重新计算所有元素的哈希值