文章中其实有很多图来帮助理解,但是因为外链的原因,我电脑上的图不能直接拉过来,要完整版的可以评论我直接发PDF版本。个人笔记,仅供参考。
查找
查找
基本概念
查找——在数据集合中满足某种条件的数据元素的过程称为查找
查找表(查找结构)——用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
关键字——数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。
查找算法的评价指标
查找长度——在查找运算中,需要对比关键字的次数称为查找长度
平均查找长度(ASL, Average Search Length)——所有查找过程中进行关键字的比较次数的平均值
A
S
L
=
∑
i
=
1
n
P
i
C
i
ASL = \sum^n_{i=1}P_iC_i
ASL=i=1∑nPiCi
P
i
P_i
Pi为查找第i个元素的概率
C i C_i Ci为查找第i个元素的查找长度
顺序查找
又叫“线性查找”,通查用于线性表(顺序和链式)。
顺序查找的实现
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);
//查找成功,则返回元素下标i;若i等于表长,说明未查找目标,返回-1
return i==ST.TableLen? -1: i;
}
使用哨兵实现顺序查找
typedef struct{ //查找表的数据结构(顺序表)
ElemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//顺序查找
int Search_Seq(SSTable ST, ElemType key){
ST.elem[0] = key; //“哨兵”
int i;
for(i=ST.TableLen; ST.elem[i]!=key; --i); //从后往前找
//查找成功,则返回元素下标i;查找失败,则返回0
return i;
}
使用“哨兵”的好处是在for循环判定是否继续的时候,少了一个判定条件
i
<
S
T
.
T
a
b
l
e
L
e
n
i<ST.TableLen
i<ST.TableLen
提高了效率。
查找效率分析
A S L = ∑ i = 1 n P i C i A S L s u c c e s s = 1 + 2 + 3 + . . . + n n = n + 1 2 A S L f a i l = n + 1 ASL = \sum^n_{i=1}P_iC_i \\ASL_{success} = \frac{1+2+3+...+n}{n} = \frac{n+1}{2} \\ASL_{fail} = n+1 ASL=i=1∑nPiCiASLsuccess=n1+2+3+...+n=2n+1ASLfail=n+1
顺序查找的优化
按元素大小排序
排序后可以减少一定的循环次数
用查找判定树分析ASL
一个成功结点的查找长度 = 自身所在层数
一个失败结点的查找长度 = 其父节点所在层数
默认情况下,各种失败情况或成功情况都等概率发生
按被查概率排序
概率大的放在表的前面,在查找成功时ASL更少。
折半查找
又称“二分查找”,仅适用于有序的顺序表。
//折半查找
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
}
查找效率分析
折半查找判定树的构造
折半查找的判定树中,若 m i d = ⌊ ( l o w + h i g h ) / 2 ⌋ mid = \lfloor(low + high)/2\rfloor mid=⌊(low+high)/2⌋,则对于任何一个结点,必有:
右子树结点树 - 左子树结点树 = 0或1
所以,折半查找的判定树一定是平衡二叉树,只有最下面一层时不满的。
元素个数为n时树高 h = ⌈ l o g 2 ( n + 1 ) ⌉ h = \lceil log_2(n+1)\rceil h=⌈log2(n+1)⌉
失败结点:n+1个(等于成功结点的空链域数量)
解释一下空链域
因为每一个结点都有左右两个指针,n个结点共有2n个链域,
而n个结点只需用n-1个指针就可互连,
所以还剩下n+1个空的链域。
折半查找的查找效率
时间复杂度 = O ( l o g 2 n ) O(log_2n) O(log2n)
分块查找
算法思想
建立索引表去保存每个分块的最大关键字和分块的存储区间。
特点:块内无序,块间有序
//索引表
typedef struct{
ElemType maxValue;
int low, high;
}Index;
//顺序表存储实际元素
ElemType List[100];
过程:
- 在索引表中确定待查记录所属的分块(可顺序、可折半)
- 在块内顺序查找
用折半查找查索引
若索引表中不包含目标关键字,则折半查找索引表最终停在 l o w > h i g h low > high low>high,要在low所指分块中查找。
查找效率分析(ASL)
A S L = ∑ i = 1 n P i C i ASL = \sum_{i=1}^{n}P_iC_i ASL=∑i=1nPiCi
假设,长度为n的查找表被均匀地分为b块,每块s个元素。
设索引查找和块内查找的平均查找长度分别为
L
I
、
L
S
L_I、L_S
LI、LS,则分块查找的平均查找长度为:
A
S
L
=
L
I
+
L
S
ASL = L_I + L_S
ASL=LI+LS
用顺序查找查索引表,则
L
I
=
1
+
2
+
.
.
.
+
b
b
=
b
+
1
2
L_I = \frac{1+2+...+b}{b} = \frac{b+1}{2}
LI=b1+2+...+b=2b+1,
L
S
=
1
+
2
+
.
.
.
+
s
s
=
s
+
1
2
L_S = \frac{1+2+...+s}{s} = \frac{s+1}{2}
LS=s1+2+...+s=2s+1,
则 A S L = b + 1 2 + s + 1 2 = s 2 + 2 s + n 2 s ASL = \frac{b+1}{2} + \frac{s+1}{2} = \frac{s^2 + 2s + n}{2s} ASL=2b+1+2s+1=2ss2+2s+n,当 s = n s = \sqrt{n} s=n时, A S L 最 小 = n + 1 ASL_{最小} = \sqrt{n} + 1 ASL最小=n+1。
扩展
若查找表是”动态查找表“,需要插入或者删除元素,那么链式存储结构是一种更优的方式。
B树
5叉查找树
类似于二叉查找树并由其扩展,在每个树结点最多可以划分5块区间,每个结点最多保存4个关键字。
//5叉排序树的结点定义
struct Node{
ElemType keys[4]; //最多4个关键字
struct Node *child[5]; //最多5个孩子
int num; //结点中有几个关键字
};
为了保证查找效率,规定:
- 在 m m m叉查找树中,除了跟结点外,任何结点至少有 ⌈ m / 2 ⌉ \lceil m/2\rceil ⌈m/2⌉个分叉,即至少含有 ⌈ m / 2 ⌉ − 1 \lceil m/2\rceil - 1 ⌈m/2⌉−1个关键字。
- 在 m m m叉查找树中,规定对于任何一个结点,其所有子树的高度都要相同。
满足了上述的规定,则这个5叉查找树就称为5阶B树。
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个关键字;
- 所有的非叶结点在同一层的结构要满足分区域的同序,且被父结点划分;
- 所有的叶结点都出现在同一层次上,并且不带信息。
最下面含有实际数据的结点通常称为终端结点,失败结点称为叶子结点。
B树的高度
大多数情况计算B树的高度不包括叶子结点(失败结点)。
问题:含n个关键字的m阶B树,最小高度、最大高度是多少?
最小高度——让每个结点尽可能的满,有m-1个关键字,m个分叉,则有
n
<
=
(
m
−
1
)
(
1
+
m
+
m
2
+
m
3
+
.
.
.
+
m
h
−
1
)
=
m
h
−
1
n<=(m-1)(1+m+m^2+m^3+...+m^{h-1}) = m^h-1
n<=(m−1)(1+m+m2+m3+...+mh−1)=mh−1
因此,
h
>
=
l
o
g
m
(
n
+
1
)
h>=log_m(n+1)
h>=logm(n+1)。
其中,m-1是每个结点包含的关键字,1是根结点,m是第二层结点数,依次类推。
最大高度——让各层的分叉尽可能的少,即根结点只有2个分叉,其他结点有 ⌈ m / 2 ⌉ \lceil m/2\rceil ⌈m/2⌉个分叉
各层结点至少有:第一层1、第二层2、第三层 2 ∗ ⌈ m / 2 ⌉ 2*\lceil m/2\rceil 2∗⌈m/2⌉…第h层 2 ∗ ⌈ m / 2 ⌉ h − 2 2*\lceil m/2\rceil^{h-2} 2∗⌈m/2⌉h−2
第h+1层共有叶子结点 2 ∗ ⌈ m / 2 ⌉ h − 1 2*\lceil m/2\rceil^{h-1} 2∗⌈m/2⌉h−1
又知:n个关键字的B树必有n+1个叶子结点,则
n
+
1
>
=
2
∗
⌈
m
/
2
⌉
h
−
1
h
<
=
l
o
g
⌈
m
/
2
⌉
n
+
1
2
+
1
n+1 >= 2*\lceil m/2\rceil^{h-1} \\h<=log_{\lceil m/2\rceil}\frac{n+1}{2} + 1
n+1>=2∗⌈m/2⌉h−1h<=log⌈m/2⌉2n+1+1
插入与删除
插入
新元素一定是插入到最底层“终端节点”,用“查找”来确定插入位置。
在插入key后,若导致原结点关键字数超过上限,则从中间位置( ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置( ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉)的结点插入原结点的父结点。
若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增 l l l。
删除
非终端关键字
若被删除关键字在非终端节点,则用直接前驱或直接后继来替代被删除的关键字。
直接前驱:当前关键字左侧指针所指子树中“最右下”的元素
直接后继:当前关键字右侧指针所指子树中“最左下”的元素
所以,对非终端结点关键字的删除,必然可以转化为对终端结点的删除操作。
终端关键字
-
删除后结点关键字个数未低于下限,无需任何处理;
-
低于下限:
(1)右兄弟够借,则用当前结点的后继、后继的后继依次顶替空缺;(父子换位法)
(2)左兄弟够借,则用当前结点的前驱、前驱的前驱依次顶替空缺;(父子换位法)
(3)左(右)兄弟都不够借,则需要与父结点内的关键字、左(右)兄弟进行合并。合并后导致父结点关键字数量-1,可能需要继续合并。
B+树
定义
一棵m阶的B+树需满足下列条件:
-
每个分支结点最多有m棵子树(孩子结点);
-
非叶根结点至少有两棵子树,其他每个分支结点至少有 ⌈ m / 2 ⌉ \lceil m/2\rceil ⌈m/2⌉棵子树;(可以理解为,要追求“平衡”,即所有子树高度要相同)
-
结点的子树个数与关键字个数相等(同B树有差异);
-
所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小排序,并且相邻叶结点按大小顺序相互链接起来
-
所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。
B+树的查找
多路查找
从根结点出发查找
B+树中,无论查找成功与否,最终一定都要走到最下面一层结点,其指向了记录该结点的数据内容。
顺序查找
通过叶子结点的头部指针 p p p进行顺序查找
B+树 VS B树
在B树中,每一个结点,不论是分支结点还是叶子结点都会存放要记录的数据;
而在B+树中,只有叶子结点会存放要记录的数据。这样的话,计算机在从根结点向下搜索的过程中,只有对B+树的叶子结点会读取较多数据,增加了效率。
散列查找
一些定义
散列表(Hash Table),又称哈希表,是一种数据结构,特点是:数据元素的关键字与其存储地址直接相关。
通过散列函数(哈希函数)
A
d
d
r
=
H
(
k
e
y
)
Addr = H(key)
Addr=H(key)
建立起关键字与存储地址的关系。
若不同的关键字通过散列函数映射到同一个值,则称它们为“同义词”;
若通过散列函数确定的位置已经存放了其他元素,则称这种情况为“冲突”。
处理冲突的方法——拉链法
即:把所有“同义词”存储在一个链表中。
散列查找
通过已知的哈希函数对查找目标进行计算,得到其地址,然后对链表进行查找。
装填因子 α \alpha α = 表中记录数/散列表长度 (会直接影响散列表的查找效率)
常见的散列函数
除留余数法
H ( k e y ) = k e y % p H(key) = key \ \%\ p H(key)=key % p
其中 p p p为不大于 m m m但最接近或等于 m m m的质数
用质数取模,分布更均匀,冲突更少。
直接定址法
适用于关键字的分布基本连续的情况
H
(
k
e
y
)
=
k
e
y
o
r
H
(
k
e
y
)
=
a
∗
k
e
y
+
b
H(key) = key \ \ \ or \ \ \ H(key) = a * key + b
H(key)=key or H(key)=a∗key+b
数字分析法
选取数码分布较为均匀的若干为作为散列地址。
平方取中法
取关键字的平方值的中间几位作为散列地址。
处理冲突的方法——开放定址法
即:可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。
H
i
=
(
H
(
k
e
y
)
+
d
i
)
%
m
i
=
0
,
1
,
2
,
.
.
.
k
(
k
<
=
m
−
1
)
H_i = (H(key) + d_i) \% m \\i = 0,1,2,...k(k<=m-1)
Hi=(H(key)+di)%mi=0,1,2,...k(k<=m−1)
其中
m
m
m为散列表表长,
d
d
d为增量序列,
i
i
i可理解为“第
i
i
i次发生冲突”
采用“开放定址法”时,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填入散列表的同义词结点的查找路径,可以做一个“删除标记”,进行逻辑删除。
-
线性探测法—— d i = 0 , 1 , 2 , . . . , m − 1 d_i = 0,1,2,...,m-1 di=0,1,2,...,m−1;即发生冲突时,每次往后探测相邻的下一个单元是否为空。
-
平方探测法——当 d i = 0 2 , 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , k 2 , − k 2 d_i = 0^2,1^2,-1^2,2^2,-2^2,...,k^2,-k^2 di=02,12,−12,22,−22,...,k2,−k2,称为平方探测法,又称二次探测法,其中 k < = m / 2 k<=m/2 k<=m/2。
散列表长度 m m m必须时一个可以表示成 4 j + 3 4j+3 4j+3当素数,才能探测到所有位置。
-
伪随机序列法—— d i d_i di是一个伪随机序列。
处理冲突的方法——再散列法
除了原始的散列函数
H
(
k
e
y
)
H(key)
H(key)之外,多准备几个散列函数,当散列函数冲突时,用下一个散列函数计算一个新地址,直到不冲突位置。
H
i
=
R
H
i
(
K
e
y
)
H_i = RH_i(Key)
Hi=RHi(Key)