@
查找是各种软件系统中经常用到的操作。查找的效率非常重要,大型的系统尤其如此。
首先来看一些查找的基本概念和术语。
在查找表的组织方式中,线性表是最简单的一种。
在表的组织方式中,线性表是最简单的一种。而顺序查找是线性表查找中最简单的一种。
顺序查找的基本思想:从表的一端开始,顺序扫描线性表,依次扫描到的结点关键字和给定的K值相比较,若当前扫描到的结点关键字与 K相等,则查找成功;若扫描结束后,仍未找到关键字等于 K的结点,则查找失败。
图1:顺序查找示例图

順序査找方法既适用于线性表的顺序存储结构,也适用于线性表的链式存储结构。
/**
* 顺序查找
* @param data
* @param key
* @return
*/
public int linearSearch(int[] data,int key){
//遍历查找
for (int i=0;i<data.length;i++){
//查找到
if (key==data[i]){
return i;
}
}
//没有查找到
return -1;
}成功时的顺序査找的平均査找长度为:

在数据量为n的情况下,线性表的平均查找长度:
(n+……+2+1)/n=(n+1)/2
顺序查找需要从头开始不断地按顺序检查数据,因此在数据量大且目标数据靠后, 或者目标数据不存在时,比较的次数就会更多,也更为耗时。若数据量为 n,线性查找的时间复杂度便为 O(n)。
所以虽然顺序查找比较简单,且对表的结构无任何要求,但是查询效率较低,所以当n较大时不宜采用顺序査找。
二分查找也叫折半查找。
二分查找是一种效率相对较高的线性表查找方式,它要求被查找的线性表是有序的。
二分查找的基本思想是:先确定待查找记录所在的范围(区间),然后逐步缩小范围直到找到或找不到该记录为止。
具体步骤如下:
图2:二分查找示例图

/**
* 二分查找-非递归
* @param data
* @param key
* @return
*/
public int binarySearch(int[] data,int key){
int mid; //置查找区间初值
int left=0;
int right=data.length-1;
while (left<right){
mid=(left+right)/2;
if (key==data[mid]){ //找到待查元素
return mid;
}else if (key>data[mid]){ //在右子表查找
left=mid+1;
mid=(left+right)/2;
}else{ //在左子表查找
right=mid-1;
mid=(left+right)/1;
}
}
return -1; //没有查找到待查元素
} /**
* 二分查找-递归
*
* @param data
* @param left
* @param right
* @param key
* @return
*/
public int binarySearchRescu(int[] data, int left, int right, int key) {
if (left > right) {
return -1;
}
int mid = (left + right) / 2;
if (key == data[mid]) {
return mid;
} else if (key > data[mid]) { //在右子表查找
return binarySearchRescu(data, mid + 1, right, key);
} else {
return binarySearchRescu(data, left, mid - 1, key);
}
}折半查找的时间复杂度为 O(log2n), 折半查找的效率比顺序查找高,但折半查找只适用千有序表,且限于顺序存储结构。
折半查找的优点是:比较次数少,查找效率高。其缺点是:对表结构要求高,只能用于顺序存储的有序表。
如果对无序表进行二分查找,查找前需要排序,而排序本身是一种费时的运算。同时为了保持顺序表的有序性,对有序表进行插入和删除时,平均比较和移动表中一半元素,这也是一种费时的运算。因此,折半查找不适用于数据元素经常变动的线性表。
分块查找又称索引顺序查找。它是把顺序查找和二分査找相结合的一种查找方法,即把线性表分成若干块,块和块之间有序,但每一块内的结点可以无序。
分块查找的基本思想是:确定被査找的结点所在的块(采用二分查找法)后,对该块中的结点采用顺序查找。
分块査找介于顺序和二分查找之间,其优点是:在表中插入或删除一个记录时,只要找到该记录所属的块,就在该块内进行插入和删除运算。分块査找的主要代价是增加一个辅助数组的存储空间和将初始表分块排序的运算
在重学数据结构(六、树和二叉树) 里,对大量的输进行了详细的描述和实现,所以针对树表的查找,下面只是是做一些简单的描述。
当用线性表作为表的组织形式时,可以用 3 种查找法。其中以二分査找效率最高。但由于二分査找要求表中结点按关键字有序,且不能用链表作存储结构,因此,当表的插入或删除操作频繁时,为维护表的有序性,势必要移动表中很多结点。这种由移动结点引起的额外时间开销,就会抵消二分査找的优点。也就是说,二分查找只适用于静态查找表。若要对动态查找表进行高效率的查找,最好使用二叉排序树。
二叉排序树又称为是二叉查找树或二叉搜索树。二叉排序树是具有下列性质的二叉树:
图3:二分排序树示意图

由 BST性质可得:
二叉树虽然利于查找,但是存在一个最坏的情况——二叉树退化为线性表。
图4:二叉树退化

所以就需要就需要引入一些特性来使二叉树保持平衡。
例如最早提出的平衡二叉树AVL树,是具有如下特性的二叉树:
图5:平衡二叉树示意图

B树也是一种平衡查找树,不过不是二叉树。
B树也称B-树,它是一种多路平衡查找树。
一棵m阶的B树定义如下:
图6:B树示意图

B+树是B树的变体,也是一种多路搜索树。
B+树·和B树有一些共同的特性:
B+树和B树也有一些不一样的地方:
图7:B+树示意图

在前面学习了关于线性表、树表的查找,这类查找方法都是以关键字的比较为基础的。
在查找过程中只考虑各元素关键字之间的相对大小,记录在存储结构中的位置和其关键字无直接关系, 其查找时间与表的长度有关,特别是当结点个数很多时,查找时要大量地与无效结点的关键字进行比较,致使查找速度很慢。
如果能在元素的存储位置和其关键字之间建立某种直接关系,那么在进行查找时,就无需做比较或做很少次的比较,按照这种关系直接由关键字找到相应的记录。这就是散列查找法 (HashSearch)的思想,它通过对元素的关键字值进行某种运算,直接求出元素的地址, 即使用关键字到地址的直接转换方法,而不需要反复比较。因此,散列查找法又叫杂凑法或散列法。
图8:哈希示意图

下面来看一些散列查找法的术语:
构造散列函数的方法很多,一般来说,应根据具体问题选用不同的散列函数,通常要考虑以下因素:
构造一个 “好” 的散列函数应遵循以下两条原则:
下面介绍构造散列函数的几种常用方法。
如果事先知道关键字集合, 且每个关键字的位数比散列表的地址码位数多,每个关键字由n位数组成,如K1…Kn , 则可以从关键字中提取数字分布比较均匀的若干位作为散列地址。
例如,有80个记录,其关键字为8位十进制数。假设散列表的表长为100, 则可取两位十进制数组成散列地址,选取的原则是分析这80个关键字,使得到的散列地址尽最避免产生冲突。
假设这80个关键字中的一部分如下所列:
图9:数字分析法关键字示例

对关键字全体的分析中可以发现:第1、2位都是"8 、1", 第3位只可能取3或4, 第4位可能取2、 5或 7,因此这 4 位都不可取。由千中间的 4 位可看成是近乎随机的,因此可取其中任意两位,或取其中两位与另外两位的叠加求和后舍去进位作为散列地址。
数字分析法的适用情况:事先必须明确知道所有的关键字每一位上各种数字的分布情况。
在实际应用中,例如,同一出版社出版的所有图书,其ISBN号的前几位都是相同的,因此,若数据表只包含同一出版社的图书,构造散列函数时可以利用这种数字分析排除ISBN号的前几位数字。
通常在选定散列函数时不一定能知道关键字的全部情况,取其中哪几位也不一定合适,而一个数平方后的中间几位数和数的每一位都相关,如果取关键字平方后的中间几位或其组合作为散列地址,则使随机分布的关键字得到的散列地址也是随机的,具体所取的位数由表长决定。平方取中法是一种较常用的构造散列函数的方法。
图9:平方取中法示意图

平方取中法的适用情况:不能事先了解关键字的所有情况,或难千直接从关键字中找到取值较分散的几位。
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位) 作为散列地址,这种方法称为折叠法。根据数位叠加的方式,可以把折叠法分为移位叠加和边界叠加两种。移位叠加是将分割后每一部分的最低位对齐,然后相加;边界叠加是将两相邻的部分沿边界来回折叠,然后对齐相加。
例如,当散列表长为 1000 时,关键字key = 45387765213, 从左到右按 3 位数一段分割,可以得到 4 个部分:453 、 877 、 652 、 13。分别采用移位叠加和边界叠加,求得散列地址为 995 和914, 如下图 所示。
图10:由折叠法求得散列地址

折叠法的适用情况:适合于散列地址的位数较少,而关键字的位数较多,且难千直接从关键字中找到取值较分散的几位。
假设散列表表长为m, 选择一个不大千m的数p, 用p去除关键字,除后所得余数为散列地址,即
H(key) = key%p
这个方法的关键是选取适当的p, 一般情况下,可以选p为小于表长的最大质数。例如,表长m= 100 , 可取p= 97 。
除留余数法计算简单,适用范围非常广,是最常用的构造散列函数的方法。它不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模,这样能够保证散列地址一定落在散列表的地址空间中。
选取一个随机函数,取关键字的随机函数的值为散列地址。
H(k)=random(key)
选择一个 “好” 的散列函数可以在一定程度上减少冲突,但在实际应用中,很难完全避免发生冲突,所以选择一个有效的处理冲突的方法是散列法的另一个关键问题。创建散列表和查找散列表都会遇到冲突,两种情况下处理冲突的方法应该一致。
处理冲突的方法与散列表本身的组织形式有关。按组织形式的不同,通常分两大类:开放地址法和链地址法。
开放地址法的基本思想是:把记录都存储在散列表数组中,当某一记录关键字 key的初始散列地址H0=H(key)发生冲突时,以H0为基础 ,采取合适方法计算得到另一个地址H1, 如果H1仍然发生冲突,以H1为基础再求下一个地址H2,若H2仍然冲突,再求得H3。依次类推,直至Hk不发生冲突为止,则Hk为该记录在表中的散列地址。
这种方法在寻找 “下一个 “ 空的散列地址时,原来的数组空间对所有的元素都是开放的,所以称为开放地址法。通常把寻找 “下一个 “ 空位的过程称为探测,上述方法可用如下公式表示:
Hi = (H(key) +di)%m i = 1, 2, …,k(k<=m-1)
di= l, 2, 3, …, m-1
这种探测方法可以将散列表假想成一个循环表,发生冲突时,从冲突地址的下一单元顺序寻找空单元,如果到最后 一个位置也没找到空单元,则回到表头开始继续查找,直到找到一个空位,就把此元素放入此空位中。如果找不到空位,则说明散列表已满,需要进行溢出处理。

di = 伪随机数序列
例如,散列表的长度为 11, 散列函数 H(key) = key%11, 假设表中已填有关键字分别为 17 、60 、 29 的记录,如图 11 (a) 所示。现有第四个记录,其关键字为 38, 由散列函数得到散列地址为 5, 产生冲突。
图11:用开放地址法处理冲突时,关键字为 38 的记录插入前后的散列表

从上述线性探测法处理的过程中可以看到一个现象:当表中 i, i+1, i+2 位置上已填有记录时,下一个散列地址为i、i+ I 、i+2和i+3 的记录都将填入i+3 的位置,这种在处理冲突过程中发生的两个第一个散列地址不同的记录争夺同一个后继散列地址的现象称作 “二次聚集"(或称作 “堆积"),即在处理同义词的冲突过程中又添加了非同义词的冲突。
可以看出,上述三种处理方法各有优缺点。
链地址法的基本思想是:把具有相同散列地址的记录放在同一个单链表中,称为同义词链表。有 m 个散列地址就有 m 个单链表,同时用数组 HT[0…m-1]存放各个链表的头指针,凡是散列地址为 i 的记录都以结点方式插入到以 HT[i]为头结点的单链表中。
图13:链地址法

该方法的基本思想就是选择足够大的M,使得所有的链表都尽可能的短小,以保证查找的效率。对采用链地址法的哈希实现的查找分为两步,首先是根据散列值找到等一应的链表,然后沿着链表顺序找到相应的键。
散列表上的运算有查找、插入和删除。其中主要是查找,这是因为散列表主要用于快速查找,且插入和删除均要用到査找操作。
接下来建立一个简单的散列表,其散列函数采用上述的除留余数法,处理冲突的方法采用开放定址法下的线性探测法。
public class HashTable {
int[] elem;
int count;
private static final int Nullkey = -32768;
public HashTable(int count) {
this.count = count;
elem = new int[count];
for (int i = 0; i < count; i++) {
elem[i] = Nullkey; // 代表位置为空
}
}
/*
* 散列函数
*/
public int hash(int key) {
return key % count; // 除留余数法
}
/*
* 插入操作
*/
public void insert(int key) {
int addr = hash(key); // 求散列地址
while (elem[addr] != Nullkey) { // 位置非空,有冲突
addr = (addr + 1) % count; // 开放地址法的线性探测
}
elem[addr] = key;
}
/*
* 查找操作
*/
public boolean search(int key) {
int addr = hash(key); // 求散列地址
while (elem[addr] != key) {
addr = (addr + 1) % count; // 开放地址法的线性探测
if (addr == hash(key) || elem[addr] == Nullkey) { // 循环回到原点或者到了空地址
System.out.println("要查找的记录不存在!");
return false;
}
}
System.out.println("存在记录:" + key + ",位置为:" + addr);
return true;
}
public static void main(String[] args) {
int[] arr = {12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34};
HashTable aTable = new HashTable(arr.length);
for (int a : arr) {
aTable.insert(a);
}
for (int a : arr) {
aTable.search(a);
}
}
}
α标志散列表的装满程度。直观地看,α越小,发生冲突的可能性就越小;反之,α越大,表中已填入的记录越多,再填记录时,发生冲突的可能性就越大,则查找时,给定值需与之进行比较的关键字的个数也就越多。
用几种不同方法处理冲突的散列表的平均查找长度

查找是数据处理中经常使用的一种操作。 这里主要介绍了对查找表的查找 , 查找表实际上仅仅是一个集合,为了提高查找效率,将查找表组织成不同的数据结构,主要包括3种不同结构的查找表:线性表、 树表和散列表。
顺序查找、折半查找和分块查找的比较

* 二叉排序树的查找过程与折半查找过程类似
折半查找和二叉排序树查找虳比较

* 二叉排序树在形态均匀时性能最好,而形态为单支树时其查找性能则退化为与顺序查找相同,因此,二叉排序树最好是一棵平衡二叉树。 平衡二叉树的平衡调整方法就是确保二叉排序树在任何情况下的深度均为 O(log2n),平衡调整方法分为4种: LL型、RR型、LR型和RL型。
* B-树是一种平衡的多叉查找树,是一种在外存文件系统中常用的动态索引技术。 在 B-树上进行查找的过程和二叉排序树类似,是一个顺指针查找结点和在结点内的关键字中查找交叉进行的过程。 为了确保B-树的定义,在B-树中插入一个关键字,可能产生结点的 “分裂", 而删除一个关键字,可能产生结点的 “合并"。
* B+树是一种B-树的变型树,更适合做文件系统的索引。 在B+树上进行随机查找、 插入和删除的过程基本上与B-树类似,但具体实现细节又有所区别。
散列查找法主要研究两方面的问题:如何构造散列函数,以及如何处理冲突。
* 构造散列函数的方法很多,除留余数法是最常用的构造散列函数的方法。它不仅可以对关键字直接取模, 也可在折叠、平方取中等运算之后取模。
* 处理冲突的方法通常分为两大类:开放地址法和链地址法,二者之间的差别类似千顺序表和单链表的差别。
开放地址法和链地址法的比较

上一篇:重学数据结构(七、图)
本博客为学习笔记,参考资料如下! 水平有限,难免错漏,欢迎指正!
参考:
【1】:邓俊辉 编著. 《数据结构与算法》 【2】:王世民 等编著 . 《数据结构与算法分析》 【3】: Michael T. Goodrich 等编著.《Data-Structures-and-Algorithms-in-Java-6th-Edition》 【4】:严蔚敏、吴伟民 编著 . 《数据结构》 【5】:程杰 编著 . 《大话数据结构》 【6】:图解:如何理解与实现散列表 【7】:算法图解之散列表 【8】:数据结构与算法-Day17-哈希(散列)表 【9】:Java数据结构与算法解析(十二)——散列表 【10】:【Java】 大话数据结构(13) 查找算法(4) (散列表(哈希表))