23 KiB
查找
基本概念
- 查找:在数据集合中寻找满足某种条件的数据元素的过程。
- 查找表(查找结构):用于查找的数据集合,由同一类型的数据元素或记录组成。
- 关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该唯一。
- 静态查找表:只查找符合条件的数据元素。(顺序查找、折半查找、散列查找)
- 动态查找表:出来要查找,还要进行删除或插入,除了查找速度还要考虑插入删除操作是否方便。(二叉排序树查找、散列查找)
- 查找长度:查找运算中,需要对比关键字的次数。
- 平均查找长度$ASL$:所有查找过程中进行关键字比较次数的平均值。$ASL=\sum_{i=1}^nP_iC_i$。其中$P_i$表示查找第$i$个元素的概率,$C_i$表示查找第$i$个元素的查找长度。
线性表查找
顺序查找
又称为线性查找,常用于线性表,从头到尾逐个查找。
一般查找
在算法实现时一般将线性表的$0$号索引的元素值设为查找的值,从表最后面开始向前查找,当没有找到时就直接从$0$返回。这个数据称为哨兵,可以避免不必要的判断线性表长是否越界语句从而提高程序效率。
$ASL$查找成功为$\dfrac{1+2+3+\cdots+n}{n}=\dfrac{n+1}{2}$,$ASL$查找失败为$n+1$,时间复杂度为$O(n)$。
顺序表
可以让数据集变为有序的,这样对比数据大小也可以知道是否还需要遍历从而减少查找时间。这样顺序结构从逻辑上就变成了一个二叉树结构(一般是顺序的),左子树代表小于(失败结点),右子树代表大于(需要继续向右查找),所有数据结点挂在右子树上。
查找情况还要比普通乱序查找加上一个大于最大值的情况。
$ASL$查找失败为$\dfrac{1+2+3+\cdots+n+n}{n+1}=\dfrac{n}{2}+\dfrac{n}{n+1}$。成功结点的查找长度等于自身所在层数,失败结点的查找长度等于其父结点所在层数。
查找概率不等
当数据元素查找概率不等时,可以将查找概率更大的元素放在靠前的位置,以减少大概率元素被遍历的时间。
$ASL$查找成功为$\sum\limits_{i=1}^nP_i(n-i+1)$,$P_i$为第$i$个元素出现概率。
但是此时数据是乱序的,所以查找失败的时间复杂度与没有优化的是一样的。
折半查找
也称为二分查找,只适用于有序的顺序表,链表无法适用,因为链表很难折半找到元素。
折半查找的结构
定义三个指针,$low$指向查找范围的最小值,$high$指向查找范围的最大值,$mid$指向查找范围的中间值,$mid=\lfloor(low+high)\div2\rfloor$。(也可以向上取整,过程会有所不同)
折半查找的过程
- 定义左边界$low$,默认为0,右边界$high$,默认为$length-1$,循环执行折半查找(2,3两步)。
- 计算出$mid=\lfloor(low+high)\div2\rfloor$。
- 判断中间索引值$data\lbrack mid\rbrack$是否与搜索值$target$相等。
- 若$data\lbrack mid\rbrack=target$,返回中间索引。
- 若$data\lbrack mid\rbrack<target$,则将$high=mid-1$。
- 若$data\lbrack mid\rbrack>target$,则将$low=mid+1$的值。
- 当查找最后$low>high$则查找失败。
在对$mid$进行取值时,如果数据量太大,查找到右侧时计算$mid$进行两数相加$low+high$可能会数值溢出。那么如何解决?
- 变幻公式:$(low+high)\div2\rightarrow low\div2+high\div2$或$\rightarrow low-(low\div2-high\div2)\rightarrow low+(high-low)\div2$。
- 无符号右移运算:$mid=(low+hight) >>> 1$。直接讲除以2变为右移运算,速度更快,且舍去了小数位不需要进行取整运算。
$ASL$查找成功为$\dfrac{1+2+3+\cdots+n}{n}=\dfrac{n+1}{2}$,$ASL$查找失败为$n+1$,时间复杂度为$O(n)$。
折半查找判定树
折半查找的过程可用二叉树来描述,称为判定树。树中每个圆形结点表示一个记录,结点中的值为该记录的关键字值;树中最下面的叶结点都是方形的,它表示查找不成功的情况。从判定树可以看出,查找成功时的查找长度为从根结点到目的结点的路径上的结点数,而查找不成功时的查找长度为从根结点到对应失败结点的父结点的路径上的结点数。
- 若当前$low$和$high$有奇数个元素,则$mid$分割后左右两部分元素个数相等。
- 若当前$low$和$high$有奇数个元素,则$mid$分割后左部分元素个数小于右部分一个。
- 折半查找判定树一定是一个平衡二叉树。只有最下面一层不满,元素个数为$n$时树高与完全二叉树相等$h=\lceil\log_2(n+1)\rceil$。
- 根据折半查找判定树可以计算对应的$ASL$:查找成功的$ASL$=($\sum\limits_{i=1}^n$第$i$层的成功结点数
\times i)$\div$成功结点总数,查找失败的$ASL$=($\sum\limits_{i=1}^n$第$i$层的失败结点数\times i)$\div$失败结点总数。 - 折半查找判定树也是一个二叉排序树,失败结点$=n+1$(成功结点的空链域节点数)。
- 折半查找判定树的中序序列应该是一个有序序列。
$ASL$查找成功查找失败都一定小于折半查找树的树高,时间复杂度为$O(\log_2n)$。
分块查找
分块查找又称为索引顺序查找,需要对数据进行一定的排序,不一定全部是顺序的,但是要求在一个区间内是满足一定条件的,即块内无序,块间有序。将查找表分割为若干子块,其中分割的块数和每块里的数据个数都是不定的。
分块查找的结构
除了保存数据的顺序表外还需要定义一个索引表,保存每个分块的最大关键字和每块的存储空间。
很明显这种定义方式定义了两个顺序结构,并且如果插入删除时需要大量移动元素,所以可以采用链表的形式。
定义一种最大元素结点,包含数据、指向后继最大元素结点的指针、指向分块内元素的指针;定义一种块内元素结点,包含数据、指向后继分块内元素的指针。但是这时候就无法折半查找,只能顺序查找。
所以总的来说分块查找还是一种静态查找,动态插入删除的效率较低。
分块查找的过程
在查找时先根据关键字遍历索引表,然后找到索引表的分块(可以顺序也可以折半),再到存储数据的顺序表的索引区间中查找。
若适用折半查找查找索引表的分块,索引表中若不存在目标关键字,则折半查找索引表最终会停在$low>high$,要在$low$所指向分块中查找。
分块查找的效率
$ASL$查找成功失败的情况都十分复杂,所以一般不会考。
假设长度为$n$的查找表被均匀分为$b$块,每块$s$个元素,假设索引查找和块内查找的平均查找长度ASL分别为$L_I$和$L_S$,则分块查找的平均查找长度为$ASL=L_I+L_S$。
使用顺序查找索引表,则$L_I=\dfrac{(1+2+\cdots+b)}{b}=\dfrac{b+1}{2}$,$L_S=\dfrac{(1+2+\cdots+s)}{s}=\dfrac{s+1}{2}$。所以$ASL=\dfrac{b+1}{2}+\dfrac{s+1}{2}=\dfrac{s^2+2s+n}{2s}$,当$s=\sqrt{n}$时,$ASL_{min}=\sqrt{n}+1$。
使用折半查找索引表,则$L_I=\lceil\log_2(b+1)\rceil$,$L_S=\dfrac{(1+2+\cdots+s)}{s}=\dfrac{s+1}{2}$。所以$ASL=\lceil\log_2(b+1)\rceil+\dfrac{s+1}{2}$。
数表查找
B树
B树的定义
为了保证$m$叉查找树中每个结点都能被有效利用,避免大量结点浪费导致树高过大,所以规定$m$叉查找树中,除了根结点以外,任何结点至少有$\lceil\dfrac{m}{2}\rceil$个分叉,即至少包含$\lceil\dfrac{m}{2}\rceil-1$个结点。
为了保证$m$叉查找树是一棵平衡树,避免树偏重导致树高过大,所以规定$m$叉查找树中任何一个结点,其所有子树的高度都要相同。
而能保证这两点的查找树,就是一棵$B$树,即多路平衡查找树,多少叉,就是一棵多少阶的$B$树。
非叶结点定义:${n,P_0,K_1,P_1,\cdots,K_n,P_n}$。其中$K_i$为结点关键字,$K_1<K_2<\cdots<K_n$,$P_i$为指向子树根节点的指针。$P_{i-1}$所指子树所有结点的关键字均小于$K_i$,$P_i$所指子树的关键字均大于$K_i$。
B树的性质
- 树的每个结点至多包含$m$棵子树,至多包含$m-1$个关键字。
- 若根结点不是终端结点,则至少有两颗子树,任意结点的每棵子树都是绝对平衡的。
- 除根结点以外的所有非叶结点至少有$\lceil\dfrac{m}{2}\rceil$棵子树,即至少包含$\lceil\dfrac{m}{2}\rceil-1$个结点。
- 每个结点中的关键字是有序的。子树$0$<子树$1$<子树$2$<……。
- 所有叶结点都出现在同一个层次上且不带信息。
- $B$树最底端的失败的不存在的结点就是常说的叶子结点,而最底端的存在数据的结点就是终端结点。(一般的树的叶子结点和终端结点都是指最底端的有数据的结点)
- 携带数据的是内部结点,最底部的叶子结点也称为外部结点。
B树的高度
$B$树中的大部分操作所需的磁盘存取次数与$B$树的高度成正比。
计算$B$树高度大部分不包括叶子结点。若含有$n$个关键字的$m$阶$B$树。
- 最小高度:让每个结点尽可能满,有$m-1$个关键字,$m$个分叉,则一共有$(m-1)(m^0+m^1+m^2+\cdots+m^{h-1})$个结点,其中$n$小于等于这个值,从而求出$h\geqslant\log_m(n+1)$。
- 最大高度:
- 让各层分叉尽可能少,即根结点只有两个分叉,其他结点只有$\lceil\dfrac{m}{2}\rceil$个分叉,所以第一层$1$个,第二层$2$个,第$h$层$2(\lceil\dfrac{m}{2}\rceil)^{h-2}$个结点,而$h+1$层的叶子结点有$2(\lceil\dfrac{m}{2}\rceil)^{h-1}$个,且$n$个关键字的$B$树必然有$n+1$个叶子结点,从而$n+1\geqslant2(\lceil\dfrac{m}{2}\rceil)^{h-1}$,即$h\leqslant\log_{\lceil\frac{m}{2}\rceil}\dfrac{n+1}{2}+1$。
- 让各层关键字尽可能少,记$k=\lceil\dfrac{m}{2}\rceil$。第一层最少结点数和最少关键字为$1$;第二层最少结点数为$2$,最少关键字为$2(k-1)$,第三层最少结点数为$2k$,最少关键字为$2k(k-1)$,第$h$层最少结点数为$2k^{h-2}$,最少关键字为$2k^{h-2}(k-1)$,从而$h$层的m阶B数至少包含关键字总数$1+2(k-1)(k^0+k^1+\cdots+k^{h-2})=1+2(k^{h-1}-1)$,若关键字总数小于这个值,则高度一定小于$h$,所以$n\geqslant 1+2(k^{h-1}-1)$,则$h\leqslant\log_{\lceil\frac{m}{2}\rceil}\dfrac{n+1}{2}+1$。
B树的查找
$B$树的查找包含两个基本操作:
- 在$B$树中找结点。
- 在结点内找关键字。
由于$B$树常存储在磁盘上,因此前一个查找操作是在磁盘上进行的,而后一个查找操作是在内存中进行的,即在找到目标结点后,先将结点信息读入内存,然后在结点内采用顺序查找法或折半查找法。
在$B$树上查找到某个结点后,先在有序表中进行查找,若找到则查找成功,否则按照对应的指针信息到所指的子树中去查找,则说明树中没有对应的关键字,查找失败。
B树的插入
新元素插入一定是插入到最底层的终端结点,使用$B$树的查找来确定插入位置。
若导致原结点关键字数量超过上限溢出,就从中间位置$\lceil\dfrac{m}{2}\rceil$分开,将左部分包含的关键字放在原来结点,右部分包含的关键字放在一个新结点,并插入到原结点的父结点的后一个位置上,而在原结点的父结点连接后的结点后移一个连接让位给分割出来的右半部分结点,中间的一个结点$\lceil\dfrac{m}{2}\rceil$插入到原结点的父结点上,并考虑在父结点的顺序对指针进行调整保证顺序。
若父结点插入时也溢出了,则同理在父结点的中间进行分割,左半部分在原来父结点;右半部分新建一个父结点,并把中间结点右边开始的所有连接移动到新父结点上;中间的结点上移到祖父结点,如果没有就新建,然后建立两个指针分别指向原父结点和新父结点。
B树的删除
- 若被删除关键字在终端结点,且结点关键字个数不低于下限,则直接删除该关键字,并移动后面的关键字。
- 若被删除关键字在非终端结点,则用直接前驱或直接后继来替代被删除关键字,然后后面的元素直接前移。
- 直接前驱:当前关键字左侧指针所指子树遍历到最右下的元素。
- 直接后继:当前关键字右侧指针所指子树遍历到最左下的元素。
- 若被删除关键字在终端结点,但是结点关键字个数删除后低于下限:
- 右兄弟够借:若原结点右兄弟结点里的关键字在删除一个后高于下限,则可以用结点的后继以及后继的后继来顶替:
- 将原结点在父结点的连接的后一个关键字下移到原结点并放在最后面。
- 将原结点右兄弟结点的第一个关键字上移插入到下移的元素的空位。
- 原结点右兄弟结点里的关键字全部前移一位。
- 左兄弟够借:若原结点里右兄弟的关键字在删除一个后低于下限,但是左兄弟的结点足够,则可以用结点的前驱以及前驱的前驱来顶替:
- 将原结点在父结点的连接的前一个关键字下移到原结点并放在最前面,其余元素后移。
- 将原结点左兄弟结点的最后一个关键字上移插入到原结点父结点的连接的前面。
- 原结点左兄弟结点里的关键字全部前移一位。
- 左右兄弟都不够借:若左右兄弟结点的关键字个数均等于下限值,则将关键字删除后与左或右兄弟结点以及父结点中的关键字进行合并:
- 将原结点的父结点连接后的关键字插入到原结点关键字最后面。
- 将原结点的左或右兄弟结点的关键字合并到原结点(前插或后插),并将连接也转移到原结点上。
- 若父结点的关键字个数又不满于下限,则父结点同样要于与它的兄弟父结点进行合并,并不断重复这个过程。
- 若父结点为空则删除父结点。
- 右兄弟够借:若原结点右兄弟结点里的关键字在删除一个后高于下限,则可以用结点的后继以及后继的后继来顶替:
B+树
$B+$树考的并不是很深。用于数据库。
与分块查找的思想类似,是对$B$树的一种变型。
B+树的定义
一个$m$阶的$B+$树需要满足以下条件:
- 每个分支结点最多有$m$棵子树或孩子结点。
- 为了保持绝对平衡,非叶根结点至少有两棵子树,其他每个分支结点至少有$\lceil\dfrac{m}{2}\rceil$棵子树。(不同于$B$树,$B+$树又重新将最下面的保存的数据定义为叶子结点)
- 结点的子树个数与关键字个数相等。($B$树结点子为树个数与关键字个数加$1$)
- 所有叶结点包含所有关键字以及指向记录的指针,叶结点中将关键字按大小排序,并且相邻叶子结点按大小顺序相互连接起来。所以$B+$树支持顺序查找。
- 所有分支结点中仅包含其各子结点中关键字的最大值以及指向其子结点的指针(即分支结点只是索引)。
B+树的查找
无论查找成功与否,$B+$树的查找一定会走到最下面一层结点,因为对应的信息指针都在最下面的结点。而$B$树查找可以停留在任何一层。
$B+$树可以遍历查找,即从根结点出发,对比每个结点的关键字值,若目标值小于当前关键字值且大于前一个关键字值,则从当前关键字的指针向下查找。
$B+$树可以顺序查找,在叶子结点的块之间定义指向后面叶子结点块的指针,从而能顺序查找。
B+树与B树的区别
对于$m$阶$B+$树与$B$树:
| B+树 | B树 | |
|---|---|---|
| 结点的n个关键字对应的子树个数 | n |
n+1 |
| 根结点的关键字数 | [1,m] |
[1,m-1] |
| 其他结点的关键字数 | [\lceil\dfrac{m}{2}\rceil,m] |
[\lceil\dfrac{m}{2}\rceil-1,m-1] |
| 关键字分布 | 叶子结点包含所有关键字,非叶结点包含部分重复关键字 | 所有结点的关键字不重复 |
| 结点作用 | 叶子结点包含信息,非叶子结点是索引作用 | 所有结点都包含信息 |
| 结点存储内容 | 叶子结点包含关键字与对应记录的存储地址,非叶子结点包含对应子树的最大关键字和指向该子树的指针 | 所有结点都包含关键字与对应记录存储地址 |
| 查找位置 | 需要查找到叶子结点的最底层才能判断是否查找成功或失败 | 查找到数的任何地方都能判断 |
| 查找速度 | 非叶子结点不包含关键字对应记录的存储地址,可以使一个磁盘块含有多格关键字,从而让树的阶数更大,树更矮,读磁盘次数更是,查找更快 | 所有结点都包含存储地址,保存的关键字数量更少,树高更高,所以读写磁盘次数更多,查找更慢 |
散列表查找
线性表和树表中,数据位置与数据关键字无关,而散列表数据位置与关键字有关。
散列表定义
散列表又称哈希表,是一种数据结构,数据元素的关键字与其存储地址直接相关。一个散列结构是一块地址连续的存储空间。
散列函数
关键字与地址通过散列函数(哈希函数)来实现映射。即记录位置=散列函数(记录关键字)。
- 直接定址法:可表示为散列函数(关键字)=$a\times$关键字$+b$,其中$a$、$b$均为常数。这种方法计算特别简单,并且不会发生冲突,但当关键字分布不连续时,会出现很多空闲单元,会将造成大量存贮单元的浪费。
- 数字分析法:分析关键字的各个位的构成,截取其中若干位作为散列函数值,尽可能使关键字具有大的敏感度,即最能进行区分的关键字位,这些位数都是连续的。
- 平方取中法:先求关键字的平方值,然后在平方值中取中间几位为散列函数的值。因为一个数平方后的中间几位和原数的每一位都相关,因此,使用随机分布的关键字得到的记录的存储位置也是随机的。适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
- 折叠法:将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为散列函数的值。例如,假设关键字为某人身份证号码$430,1046,8101,5355$,则可以用$4$位为一组进行叠加,即有$5355+8101+1046+430=14932$,舍去高位,则有$H(430104681015355)=4932$。
- 随机数法:对于存储位置给定随机数安排,查找起来会很麻烦。
- 除留余数法:散列函数(关键字)=关键字$%p$,$p$一般是不大于表长的最大质数。这种方法使用较多,关键是选取较理想的$p$值,使得每一个关键字通过该函数转换后映射到散列空间上任一地址的概率都相等,从而尽可能减少发生冲突的可能性。一般情形下,取$p$为一个素数较理想,如果是合数则因为可以被多个数整除从而多个关键字余数相同造成冲突。
映射冲突
一般情况下,设计出的散列函数很难是单射的,即不同的关键字对应到同一个存储位置,这样就造成了冲突(碰撞)。此时,发生冲突的关键字互为同义词。
开放地址法
可存放新表项的空闲地址既向同义词开放也向非同义词开放。从发生冲突的那个单元开始,按照一定的次序,从哈希表中找出一个空闲的存储单元,把发生冲突的待插入关键字存储到该单元中,从而解决冲突。既指如果当前冲突,则将元素移动到其他空闲的地方。
$H_i=(H(key)+d_i)\mod m$。
- $i$表示发生第$i$次冲突,$i=1,2,\cdots,m-1)$。
- $m$为散列表长度,类似于循环队列,超出表长以后就循环到最左边。
- $d_i$为增量序列,是指发生第$i$次冲突的时候,$H(key)$偏移了多少位。
增量序列选择:
- 线性探测法:$d_i=1,2,3,\cdots,m-1$。线性探测法充分利用了哈希表的空间,但在解决一个冲突时,可能造成新的冲突(聚集:同义和非同义关键字都堆积到一起)。另外,也不能随便对结点进行删除。
- 二次探测法:$di=1,-1,2^2,-2^2\cdots,(\dfrac{m}{2})^2,-(\dfrac{m}{2})^2$。对比线性探测法更不容易产生聚集问题。注意:散列表长度$m$必须是一个可以表示为$4j+3$的素数才能探测到所有位置。
- 伪随机探测法:定义$d_i$是一个伪随机数。
- 再散列法:$d_i=Hash_2(key)$,又称双散列法。需要使用两个散列函数,当通过第一个散列函数$H(key)$得到的地址发生冲突时,则利用第二个散列函数$Hash_2(key)$计算该关键字的地址增量。它的具体散列函数形式:$H_i=(H(key)+i\times Hash_2(key))%m$。初始探测位置$H=H(key)%m$。$i$是冲突的次数,初始为$0$。在再散列法中,最多经过$m-1$次探测就会遍历表中所有位置,回到$H_0$位置。
注意:在开放定址的情形下,不能随便物理删除表中的已有元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。因此,要删除一个元素时,可给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
链地址法
又称为拉链法或链接法,是把相互发生冲突的同义词用一个单链表链接起来,若干组同义词可以组成若干个单链表。思想类似于邻接表的基本思想,且这种方法适合于冲突比较严重的情况。
指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。
每次冲突都要重新哈希,计算时间增加。
公共溢出区法
为所有冲突的关键字记录建立一个公共的溢出区来存放。在查找时,对给定关键字通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表进行顺序查找。如果相对于基本表而言,在有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
散列查找
先通过散列函数计算目标元素存储地址,然后根据解决冲突的方法进行下一步的查询。
如果使用拉链法通过散列函数计算得到存储地址为空,则可以直接代表查找失败,这时候一般定义查找长度这里不算。
而如果使用开放地址法计算得到空位置的时候,代表查找失败,但是这时候需要定义查找长度要算这个地址。
若散列函数设计得足够好,散列查找时间复杂度可以达到$O(1)$,即不存在冲突。
散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子。
装填因子=表中记录数÷散列表长度。装填因子代表一个散列表中的满余情况,越大则查找效率越低。
若只给出了装填因子$\alpha$,则此时平均查找长度为:$ASL=\dfrac{1}{2}(1+\dfrac{1}{1-\alpha})$。