王道408数据结构——第七章 查找

一、基本概念

查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找。
查找表(查找结构):用于查找的数据集。它由同一类型的数据元素(或记录)组成,可以是一个数组或链表等的数据类型。对查找表的常见操作一般有四种:

  1. 查询某个特定元素是否在查找表中;
  2. 检索满足条件的某个特定数据元素的各种属性;
  3. 在查找表中插入一个数据元素;
  4. 从查找表中删除某个数据元素。

静态查找表:若查找表涉及的操作只有上述的1、2,则无序动态地修改查找表。适合静态查找表的查找方法有顺序查找、折半查找、散列查找等。
动态查找表:若查找表涉及上述所有四个操作,则需要动态插入删除查找表。适合动态查找表的查找方式有二叉排序树的查找、二叉平衡树的查找、B树的查找、散列查找等
关键字:数据元素中唯一标识该元素的某个数据项的值。使用基于关键字的查找,其查找结果应是唯一的。
平均查找长度(ASL):在查找过程中,一次查找的长度是指需要比较的关键字次数,而平均查找长度是所有查找过程中关键字比较次数的平均值。ASL是衡量查找算法效率的最主要指标。

二、顺序查找(线性查找)

适用于顺序表和链表。对于顺序表,通过数组下标递增来顺序扫描每个元素;对于链表,通过next指针来依次扫描每个元素。

一般线性表的顺序查找

基本思想是从线性表的一端开始,逐个检查关键字是否满足给定条件。若查找到某个元素的关键字满足条件按,则查找成功,返回该元素在线性表中的位置;若已找到表的另一端,但还没找到符合条件的元素,则返回查找失败的信息。
下面给出算法

1
2
3
4
5
6
7
8
int searchSeq(SSTable ST, ElemType key){
// 引入哨兵元素,在创建查找表时,0号单元留空,预留给带查找元素关键字
// 引入哨兵元素的目的是使得循环不必判断数组是否越界,可以避免很多不必要的判断语句,提高效率
ST.elem[0] = key;
// 从后往前查找,满足i==0时,循环一定会跳出
for(i = ST.length; ST.elem[i] != key; --i);
return i;
}

对于有n个元素的表,定位第 i 个元素时,需进行n-i+1次比较。假设每个元素的查找概率相同,即Pi=1/nP_i=1/n时,$$ASL_{(成功)}=\sum_{i=1}^nP_i(n-i+1)=\frac{n+1}{2}$$
查找不成功时,与表中各关键字的比较次数为n+1次,$$ASL_{(不成功)}=n+1$$

通常,查找表中记录的查找概率并不相等,若能预先得知每个记录的查找概率,则可以先对记录按查找概率进行排序。

缺点:当n较大时,ASL较大,效率低。
优点:对数据元素的储存没有要求,顺序储存或链式存储(只能顺序查找)皆可。对表中记录的有序性也没有要求。

有序表的顺序查找

若在查找前就知道表的关键字是有序的,则可以不用再比较到表的另一端就能确定查找失败,从而降低失败的ASL。
可以用查找判定树来描述查找过程。树中圆形结点标识表中存在的元素;举行结点称为失败结点,描述的是不在表中的数据值的集合,若有n个结点,则相应有n+1个失败结点。
有序顺序表上的顺序查找判定树
查找成功的ASL和一般线性表相同。
查找失败是,查找指针一定走到了某个失败结点。失败结点时虚构的,所以到达失败结点时的查找长度等于其父结点所在的层数。

ASL_{(不成功)}=\sum_{j=1}^nq_j(l_j-1)=\frac{1+2+\cdots+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1}$$式中,$q_j$是到达第 j 个失败结点的概率,在相等查找概率的情形下,它为$1/(n+1)$;$l_j$是第 j 个失败结点所在层数。 # 二、折半查找(二分查找) **基本思想**:首先将给定值key于表**中间位置**的元素进行比较,若相等,则查找成功,返回元素位置;若不等,则目标元素只能在中间元素以外的前半部分或后半部分,然后在缩小的范围内继续同样的查找。 *算法如下*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int binnarySearch(SqList L, ElemType key){
int low = 0;
int high = L.length - 1;
int mid;
while(low <= high){
mid = (low + high) / 2;
if(L.elem[mid] == key)
return mid;
else if(L.elem[mid] < key)
low = mid + 1;
else
high = mid - 1;
}
return -1; // 查找失败
}
折半查找也可用判定树来描述。每个结点值均大于其左子结点,小于其右子结点。若有序序列有n个元素,则判定树中有n个非叶结点和n+1个叶节点。 显然,二分查找的判定树是一棵**平衡二叉树**。 ![描述折半查找过程的判定树](https://raw.githubusercontent.com/buttering/EasyBlogs/master/asset/pictures/f552c42121cd99be185059d96277d3fc/1f418a7c876a5621cee71aef3c065596.jpg)

\begin{aligned}
ASL_{(成功)}&=\frac{1}{n}\sum_{i=1}^nl_i\
&=\frac{1}{n}(1\times1+2\times2+\cdots+h\times2^{h-1})\
&=\frac{n+1}{n}\log_2(n+1)-1\
&\approx\log_2(n+1)-1(n较大时,可近似为)\
\end{aligned}

式中,h是树的高度,元素个数为 n 时$h=\lceil\log_2(n+1)\rceil$<font color=red>(与n个结点的完全二叉树相同)</font>。 时间复杂度为$O(\log_2n)$,平均情况下比顺序查找效率高。 计算失败的ASL时,同样已其父节点的高度作为失败结点的高度。 折半查找需要快速定位查找区域,要求线性表必须具有随机存取的特性。该查找法仅适合于顺序存储结构,且要求元素按关键字有序排序。 # 三、分块查找(索引顺序查找) **基本思想**:将查找表分为若干子**块**,块内元素可以无序,但**块之间是有序的**,即前一块中的最大关键字小于后一块中的最小关键字。再建立一个**索引表**,索引表中的每个元素含有各块中的最大关键字和各块中的第一个元素的地址,索引表按关键字有序排序。 分块查找吸取了顺序查找和折半查找各自的优点,既有动态结构,又适合于快速查找。查找分为两步,第一步是在索引表中确定待查记录所在的块,可以使用顺序查找或折半查找;第二部是在块内顺序查找。 分块查找的ASL为索引查找和块内查找的平均长度之和。设索引查找和块内查找ASL为$L_I$$、L_S$,则分块查找$ASL=L_I+L_S$。 将长度为 n 的查找表均匀分为 b 块,每块有 s 个记录,在等概率情况下: 若块内和索引表均采用顺序查找,则$$ASL_{(成功)}=L_I+L_S=\frac{b+1}{2}+\frac{s+1}{2}=\frac{s^2+2s+n}{2s}$$此时,若$s=\sqrt{n}$,则ASL有最小值$s=\sqrt{n}+1$; 若对索引表采用折半查找,则$$ASL_{(成功)}=L_I+L_S=\lceil\log_2(b+1)\rceil+\frac{s+1}2

四、B树

不考,暂略。

五、B+树

不考,暂略。

六、散列表

散列函数:把查找表中的关键字映射成该关键字对应地址的函数,记为Addr=Hash(key)。这里的地址可以是数组下标、索引或内存地址等。
冲突:散列函数可能把两个不同的关键字映射到相同地址,这种情况称为冲突。发生碰撞的不同关键字称为同义词。设计好的散列函数应尽可能避免冲突,但冲突总是不可避免的。
散列表:根据关键字直接进行访问的数据结构。散列表建立了关键字和储存地址的一种直接映射关系

理想情况下,散列表的查找时间复杂度为O(1)O(1),即与表中元素的个数无关。

构造散列函数

构造散列函数时,必须注意一下几点:

  1. 散列函数的定义域必须包含全部需要储存的关键字,而值域的范围依赖于散列表的大小。
  2. 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生。
  3. 散列函数应尽量简单,以减少计算时间。

1. 直接定址法

直接取关键字的某个线性函数值作为散列地址。散列函数为
H(key)=keyH(key)=key
H(key)=a×key+bH(key)=a\times key+b

这种方法计算最简单,且不会产生冲突。
适合关键字分布基本连续的情况,若关键字分布不连续,会导致存储空间的浪费。

2. 除留取余法

假定散列表表长为m,取一个不大于m但最接近m的质数p。散列函数为
H(key)=key%pH(key)=key\%p

这是一种最简单、最常用的方法。
关键是选好p,使得每个关键字通过散列函数转换后能等概率地映射到散列空间中,从而进坑了减少冲突的可能性。

3. 数字分析法

设关键字是 r 进制数,而r个数码在各位上的频率不一定相同,可能在某些位上分布均匀一些,此时应选取数码分布较为均匀的若干位作为散列地址。

这种方法适合已知的关键字集合。若更换了关键字,则需要重新构造新的散列函数。

4. 平方取中法

关键字平方值的中间几位作为散列地址。

这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适合关键字的每位取值都不够均匀或均小于散列地址所需的位数。

冲突处理

任何散列函数都不能绝对避免冲突,必须考虑发生冲突时的处理方法,即为冲突的关键字寻找下一个空的hash地址。若得到的另一个散列地址仍然发生冲突,则需要继续求下一个hash地址。

1. 开放定址法

指看存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开发。递推公式为$$H_i=(H(key)+d_i)%m$$式中,HiH_i表示处理冲突第 i 次探测得到的散列地址,m表示散列表表长,did_i为增量序列。

取定某一增量序列did_i后,对应的处理方法就是确定的。通常有以下4种取法:

  • 线性探测法(线性探测再散列法):di=0,1,2,m1d_i=0,1,2\cdots ,m-1
    • 冲突发生时,顺序查看表中下一个单元,直到找出一个空单元或查遍全表(此时表已满)。
    • 线性探查法可能使第 i 个散列地址的同义词存入第 i+1 个散列地址,这样本应存入第 i+1 个散列地址的元素就要争夺 i+2 个散列地址。造成大量元素在相邻的散列地址上聚集(堆积)起来,大大降低了查找效率。
  • 平方探测法(二次探测再散列法):di=02,12,12,22,22,,k2,k2(km2)d_i=0^2,1^2,-1^2,2^2,-2^2,\cdots,k^2,-k^2(k\leq\frac m2)
    • 散列表长度m必须是一个可以表示为4k+3的素数。
    • 是一种处理冲突的较好方法,可以避免出现堆积问题。
    • 缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。
  • 再散列法(双散列法):di=Hash2(key)d_i=Hash_2(key)
    • 当通过第一个散列函数得到的地址发生冲突时,利用第二个散列函数计算该关键字的地址增量
    • 具体散列函数为Hi=(H(key)+i×Hash2(key))%mH_i=(H(key)+i\times Hash_2(key))\%m
    • 最多经过m-1次探测就会遍历表中所有位置。
  • 伪随机序列法:did_i为伪随机数序列。

在开放定址法中,不能随便物理删除表中已有的元素,因为若删除元素,会截断其他具有相同散列地址的元素的查找地址。因此,要删除一个元素时,可以给它添加一个删除标记,进行逻辑删除。这种方法需要定期维护散列表,把删除标记的元素物理删除。

2. 拉链法(链地址法)

把所有同义词储存在一个线性链表中。这个线性链表由其散列地址唯一标识。
适合于经常进行插入和删除的情况。

性能分析

散列表的查找过程于构造散列表的过程基本一致。根据散列函数和关键字可以计算出记录的散列地址。若散列地址上:①无记录,说明查找失败;②若有记录且关键字相同,则查找成功;③若有记录但关键字不同,使用给定的处理冲突方法计算下一个散列地址,再次进行比较。
装填因子:定义为一个表的装满程度,即$$\alpha = \frac{表中记录数n}{散列表长度m}$$
散列表的查找效率取决于散列函数、处理冲突的方法和装填因子。
散列表的ASL依赖于装填因子,而不直接依赖于n或m。


王道408数据结构——第七章 查找
https://buttering.github.io/EasyBlog/2021/09/25/王道408数据结构——第七章 查找/
作者
Buttering
发布于
2021年9月25日
许可协议