这是本人根据王道考研数据结构课程整理的笔记,希望对您有帮助。
7.1 查找的基本概念
查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找
查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。
对查找表的常见操作
- 查找符合条件的数据元素
- 静态查找表:仅关注查找速度即可
- 插入、删除某个数据元素
- 动态查找表:除了查找速度,也要关注插、删操作是否方便实现
查找算法的评价指标
查找长度:在查找运算中,需要对比关键字的次数
平均查找长度(ASL, Average Search Length):所有查找过程中进行关键字的比较次数的平均值
ASL
=
∑
i
=
1
n
P
i
C
i
\text{ASL}=\sum_{i=1}^n {P_iC_i}
ASL=i=1∑nPiCi
其中,
n
n
n 是数据元素的个数,
P
i
P_i
Pi 是查找第
i
i
i 个元素的概率,
C
i
C_i
Ci 是查找第
i
i
i 个元素的查找长度。
二叉查找树(BST)
7.2 顺序查找和折半查找
7.2.1 顺序查找
//查找表的数据结构(顺序表)
typedef struct
{
ElemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//顺序查找
int Search_Seq(SSTable ST, ElemType key)
{
int i;
for(i = 0; i < ST.TableLen && ST.elem[i] != key; ++i);
//查找成功,则返回元素下标;查找失败,则返回-1
return i = ST.TableLen? -1 : i;
}
7.2.2 二分查找
折半查找,又称二分查找,仅适用于有序的顺序表。
顺序表拥有随机访问的特性,链表没有,因此折半查找不适用于链表。
typedef struct //查找表的数据结构(顺序表)
{
ElemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//【二分查找】
int Binary_Search(SSTable 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; //查找失败,返回-1
}
在以上代码中, mid = ⌊ ( low + high ) / 2 ⌋ \text{mid}=\lfloor (\text{low}+\text{high})/2 \rfloor mid=⌊(low+high)/2⌋
时间复杂度: O ( log 2 n ) O(\log_2n) O(log2n)
7.2.3 分块查找
特点:块内无序、块间有序
//索引表
typedef struct
{
ElemType maxValue;
int low, high;
}Index;
//顺序表存储实际元素
ElemType List[100];
算法过程如下:
- 在索引表中确定待查记录所属的分块(可顺序、可折半)
- 在块内顺序查找
用二分查找查索引
若索引表中不包含目标关键字,则折半查找索引表最终停在low>high,要在low所指分块中查找。
链式存储
若查找表是“动态查找表”(可能需要插入删除),可以考虑使用链式存储。
例如:如果需要在表中插入元素8,只要插在第一列10的后面即可,不需要挪动全部元素。
7.3 B树和B+树
7.3.1 B树
5叉查找树
若每个结点内关键字太少,导致树变高,要查更多层结点,效率低。
不够“平衡”,树会很高,要查很多层结点。
B树
B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树或为空树,或为满足如下特性的m叉树:
-
树中每个结点至多有m棵子树,即至多含有m-1个关键字
-
若根结点不是终端结点,则至少有两棵子树
-
除根结点外的所有非叶结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉ 棵子树,即至少含有 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1 个关键字
-
所有非叶结点的结构如下:
其中, K i ( i = 1 , 2 , … , n ) K_i(i=1,2,\dots,n) Ki(i=1,2,…,n) 为结点的关键字,且满足 K 1 < K 2 < ⋯ < K n K_1<K_2<\dots<K_n K1<K2<⋯<Kn; P i ( i = 0 , 1 , … , n ) P_i(i=0,1,\dots,n) Pi(i=0,1,…,n) 为指向子树根结点的指针,且指针 P i − 1 P_{i-1} Pi−1 所指子树中所有结点的关键字均小于 K i K_i Ki, P i P_i Pi 所指子树中所有结点的关键字均大于 K i K_i Ki, n ( ⌈ m / 2 ⌉ − 1 ≤ n ≤ m − 1 ) n(\lceil m/2 \rceil -1 \le n \le m-1) n(⌈m/2⌉−1≤n≤m−1) 为结点中关键字的个数。
-
所有的叶结点(表示查找失败的范围)都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判断树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)
m阶B树的核心特性
-
根结点的子树数 ∈ [ 2 , m ] \in [2,m] ∈[2,m],关键字数 ∈ [ 1 , m − 1 ] \in[1,m-1] ∈[1,m−1]
其他结点的子树数 ∈ [ ⌈ m / 2 ⌉ , m ] \in[ \lceil m/2 \rceil,m] ∈[⌈m/2⌉,m];关键字数 ∈ [ ⌈ m / 2 ⌉ − 1 , m − 1 ] \in [ \lceil m/2 \rceil -1,m-1] ∈[⌈m/2⌉−1,m−1]
-
对任一结点,其所有子树高度都相同
-
关键字的值:子树0 < 关键字1 < 子树1 < 关键字2 < 子树2 < ··· (类比二叉查找树 左 < 中 < 右)
B树的高度
约定:算B树的高度不包括叶子结点(失败结点)
含
n
n
n 个关键字的m叉B树,其高度满足:
log
m
(
n
+
1
)
≤
h
≤
log
⌈
m
/
2
⌉
n
+
1
2
+
1
\log _{m}(n+1) \leq h \leq \log _{\lceil m / 2\rceil} \frac{n+1}{2}+1
logm(n+1)≤h≤log⌈m/2⌉2n+1+1
7.3.2 B树的插入和删除
核心要求
- 对m阶B树,除根结点外,结点关键字个数 n n n满足: ⌈ m / 2 ⌉ − 1 ≤ n ≤ m − 1 \lceil m/2 \rceil -1\le n \le m-1 ⌈m/2⌉−1≤n≤m−1
- 关键字的值:子树0 < 关键字1 < 子树1 < 关键字2 < 子树2 < ···
B树的插入
-
在插入key后,若导致原结点关键字数超过上限,则从中间位置( ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置( ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉)的结点插入原结点的父结点。
-
若此时导致其父结点的关键字个数叶超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度+1。
新元素一定是插入到最底层“终端结点”,用“查找“来确定插入位置。(下图为插入90和99)
加入某一个结点满了:(想再插入88)
把88提到父节点上,左右两个部分分开,得到如下的B树:
如果根结点满了:
就拆分往上新建一个结点,B树高度+1:
B树的删除
-
若被删除关键字在终端结点,则直接删除该关键字(要注意结点关键字个数是否低于下限 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1)
-
若被删除关键字在非终端结点,则用直接前驱或直接后继来替代被删除的关键字。
直接前驱:当前关键字左侧指针所指子树中“最右下”的元素
直接后继:当前关键字右侧指针所指子树中“最左下”的元素
(下图是删除80的过程,用77和82都行,这里只展示77)
-
若删除后结点关键字个数是低于下限 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1
-
右兄弟够借,则用当前结点的后继、后继的后继依次顶替空缺
(删除38的过程)
49移下来,70移上去,71和72左移:
-
左兄弟够借,则用当前结点的前驱、前驱的前驱依次顶替空缺
(删除90的过程)
88移下来,87移上去:
-
左(右)兄弟都不够借,则需要与父结点内的关键字、左(右)兄弟进行合并。合并后导致父结点关键字数量-1,可能需要继续合并。
(删除49的过程)
合并25 70 71 72,左移73:
(此时发现73这个结点关键字的个数低于下限,需要继续合并)
-
7.3.3 B+树
一棵m阶的B+树需满足下列条件:
-
每个分支结点最多有 m m m 棵子树(孩子结点)
-
非叶子结点的根结点至少有两棵子树,其他每个分支结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉ 棵子树
-
结点的子树个数与关键字个数相等
-
所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来(支持顺序查找)。
-
所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。
B+树的查找
- B+树中,无论查找成功与否,最终一定都要走到最下面一层结点
- 可以从根结点开始逐层往下查找,也可以通过指针p进行顺序查找
B+树 VS B树
m阶B+树:
- 结点中的n个关键字对应n棵子树
- 根结点的关键字数 ∈ [ 1 , m ] \in[1,m] ∈[1,m],其他结点的关键字数 ∈ [ ⌈ m / 2 ⌉ , m ] \in [ \lceil m/2 \rceil,m] ∈[⌈m/2⌉,m]
- 在B+树中,叶结点包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中
- 在B+树中,叶结点包含信息,所有非叶节点仅起索引作用,非叶结点的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址
m阶B树:
- 结点中的n个关键字对应n+1棵子树
- 根结点的关键字数 ∈ [ 1 , m − 1 ] \in[1,m-1] ∈[1,m−1],其他结点的关键字数 ∈ [ ⌈ m / 2 ⌉ − 1 , m − 1 ] \in [ \lceil m/2 \rceil -1,m-1] ∈[⌈m/2⌉−1,m−1]
- 在B树中,各结点中包含的关键字是不重复的
- B树的结点中都包含了关键字对应的记录的存储地址
B+树的优势
在B+树中,非叶结点不含有该关键字对应记录的存储地址。可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快。
7.4 散列查找
7.4.1 散列表/哈希表
散列表(Hash Table):又称==哈希表==,数据元素的关键字与其存储地址直接相关。
散列函数(哈希函数):用来建立“关键字”与“存储地址”的联系。如:Addr = H(key)
(以下为简单例子)
若不同的关键字通过散列函数映射到了同一个值,则称它们为“同义词”
通过散列函数确定的为止已经存放了其他元素,则称这种情况为“冲突”
处理冲突的方法——拉链法
用拉链法(又称链接法、链地址法)处理“冲突”:把所有“同义词”存储在一个链表中
在插入新元素时,保持关键字有序,可微微提高查找效率。
处理冲突的方法——开放定址法
开放定址法:可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:
H
i
=
(
H
(
k
e
y
)
+
d
i
)
H_i = (H(key) + d_i) % m
Hi=(H(key)+di)
其中,
i
=
0
,
1
,
2
,
…
,
k
(
k
≤
m
−
1
)
i=0,1,2,\dots,k(k\le m-1)
i=0,1,2,…,k(k≤m−1),
m
m
m 表示散列表表长;
d
i
d_i
di 为增量序列;
i
i
i 可理解为“第
i
i
i 次发生冲突”
- 线性探测法:
d
i
=
0
,
1
,
2
,
3
,
…
,
m
−
1
d_i=0,1,2,3,\dots,m-1
di=0,1,2,3,…,m−1;即发生冲突时,每次往后探测相邻的下一个单元是否为空
- 线性探测法很容易造成同义词、非同义词的“聚集(堆积)”现象,严重影响查找效率。
- 原因:冲突后再探测一定是放在某个连续的位置。
- 平方探测法:
d
i
=
0
2
,
1
2
,
−
1
2
,
2
2
,
−
2
2
,
…
,
k
2
,
−
k
2
(
k
≤
m
/
2
)
d_i=0^2,1^2,-1^2,2^2,-2^2,\dots,k^2,-k^2(k\le m/2)
di=02,12,−12,22,−22,…,k2,−k2(k≤m/2),又称二次探测法。
- 平方探测法比起线性探测法更不易产生“聚集(堆积)”问题。
- 散列表长度 m m m 必须是一个可以表示成 4 j + 3 4j+3 4j+3 的素数,才能探测到所有位置
- 伪随机序列法: d i d_i di 是一个伪随机序列,如 d i = 0 , 5 , 24 , 11 , … d_i=0, 5, 24, 11, \dots di=0,5,24,11,…
处理冲突的方法——再散列法
再散列法(再哈希法):除了原始的散列函数H(key)
之外,多准备几个散列函数(
R
H
i
RH_i
RHi),当散列函数冲突时,用下一个散列函数计算一个新地址,直到不冲突为止:
H
i
=
R
H
i
(
k
e
y
)
,
i
=
1
,
2
,
…
,
k
H_i=RH_i(key),i=1,2,\dots,k
Hi=RHi(key),i=1,2,…,k
7.4.2 常见的散列函数
除留余数法:H(key) = key % p
。散列表表长为m,取一个不大于m但最接近或等于m的质数p。
为什么要取质数?
用质数取模,分布更均匀,冲突更少。
(散列函数的设计要结合实际的关键字分布特点来考虑,不要教条化)
直接定址法:H(key) = key
或H(key) = a * key + b
。其中,a和b是常数。
这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
数字分析法:选取数码分布较为均匀的若干位作为散列地址
设关键字是 r 进制数(如十进制数),而 r 个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
平方取中法:取关键字的平方值的中间几位作为散列地址
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。