所谓查找(Search)又称检索,就是在一个数据元素集合中寻找满足某种条件的数据元素。查找在计算机数据处理中是经常使用的操作。查找算法的效率高低直接关系到应用系统的性能。查找的方法很多,本章将介绍一些常用的查找算法,主要有:线性表的查找、树表的查找和散列表的查找,并对有关的算法进行性能分析和对比
一.基本概念
1.数据表
就是指数据元素的有限集合。例如,为统计职工工作业绩,建立一个包括:职工编号、职工姓名、业绩等信息的表格。这个表格中的每一个职工的信息就是一个数据元素。对此表格可以根据职工编号查找职工的业绩等信息;也可以根据职工的姓名查找职工的业绩等信息。
2.关键字
数据表中数据元素一般有多个属性域(字段),即由多个数据成员组成,其中有一个属性域可用来区分元素,作为查找或排序的依据,该域即为关键字。每个数据表用哪个属性域作为关键字,要视具体的应用需要而定。即使是同一个表,在解决不同问题的场合也可能取不同的域做关键字。如果在数据表中各个元素的关键字互不相同,这种关键字即主关键字。
3.查找
查找(Search)是数据处理中最常用的一种运算。最常见的一种方式是事先给定一个值,在数据表中找到其关键字等于给定值的数据元素。查找的结果通常有两种可能:一种可能是查找成功,即找到关键字等于给定值的数据元素,这时作为查找结果,可报告该数据元素在数据表中的位置,还可进一步给出该数据元素的具体信息,后者在数据库技术中叫做检索;另一种可能是查找不成功(查找失败),即数据表中找不到其关键字等于给定值的数据元素,此时查找的结果可给出一个“空”记录或“空”指针。
4.静态查找表和动态查找表
数据表的组织有两种不同方式。其一,数据表的结构固定不变,当查找失败时,作为查找结果只报告一些信息,如失败标志、失败位置等,这类数据表称为静态查找表;其二,数据表的结构在插入或删除数据元素过程中会得到调整,当查找失败时,则把给定值的数据元素插入到数据表中,这类组织方式称为动态查找表。相比较而言,静态查找表的结构较为简单,操作较为方便,但查找的效率较低,而且需要考虑表的溢出问题。
其效率(查找次数)不超过【log2n】+1(以2为底n的对数)
其二分查找的过程类似一棵判断树
所谓判定树,给出度娘的定义:判定树指的是在处理活动的详细分析中,用树 形逻辑图对单个功能与活动的一种详细分析方法。用判定树进行功能和活动的判定,也较为简单明了,因而广泛地用于信息的系统分析和企业经营决策过程中。判定树又称决策树,是由国内学者钟鸣等人于1992年在《计算机研究与发展》第1期“示例学习的抽象信道模型及其应用”一文中首次使用,适合描述问题处理中具有多个判断,而且每个决策与若干条件有关。 [1]
5.查找的效率
查找是经常使用的一种运算,因此,查找的时间复杂度是人们关心的一个重要因素。查找的时间复杂度一般用平均查找长度(ASL)来衡量。平均查找长度是指在数据表中查找各数据元素所需进行的关键字比较次数的期望值,其数学定义为:
ASL=∑m=0nPi⋅Ci
ASL=∑m=0nPi⋅Ci
其中,PiPi表示待查找数据元素在数据表中出现的概率,CiCi表示查找此数据元素所需进行关键字的比较次数。
6.装载因子
设数据表的长度为m,表中数据元素个数为n,则数据表的装载因子α=n/m
二.静态查找
<1>有序表与顺序表
【1】查找概论
查找表是由同一类型是数据元素(或记录)构成的集合。
关键字是数据元素中某个数据项的值,又称为键值。
若此关键字可以唯一标识一个记录,则称此关键字为主关键字。
查找就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
查找分为两类:静态查找表和动态查找表。
静态查找表:只作查找操作的查找表。主要操作:
(1)查询某个“特定的”数据元素是否在查找表中。
(2)检索某个“特定的”数据元素和各种属性。
动态查找表:在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经已经存在的某个数据元素。 主要操作:
(1)查找时插入数据元素。
(2)查找时删除数据元素。
好吧!两者的区别: 静态查找表只负责查找任务,返回查找结果。
而动态查找表不仅仅负责查找,而且当它发现查找不存在时会在表中插入元素(那也就意味着第二次肯定可以查找成功)
【2】顺序表查找
顺序表查找又称为线性查找,是最基本的查找技术。 它的查找思路是:
逐个遍历记录,用记录的关键字和给定的值比较:
若相等,则查找成功,找到所查记录; 反之,则查找不成功。
顺序表查找算法代码如下:
对于这种查找算法,查找成功最好就是第一个位置找到,时间复杂度为O(1)。
最坏情况是最后一个位置才找到,需要n次比较,时间复杂度为O(n) 显然,n越大,效率越低下。
【3】有序表查找
所谓有序表,是指线性表的数据有序排列。
(1)折半查找(即二分查找)
关于这个算法前文已经说过,在此不做赘述,代码如下:
#include <iostream>
using namespace std;
// 折半查找算法(二分查找)
int Binary_Search(int* a,int n,int key)
{
int low = 1, high = n, mid = 0; // 初始化
while (low <= high) // 注意理解这里还有等于条件
{
mid = (low + high)/2; // 折半
if (key < a[mid])
high = mid -1; // 最高小标调整到中位小一位
else if (key > a[mid])
low = mid + 1; // 最低下标调整到中位大一位
else
return mid; // 相等说明即是
}
return 0;
}
void main ()
{
int a[11] = {0,9,23,45,65,88,90,96,100,124,210};
int n = Binary_Search(a,10, 9);
if (n != 0)
cout << "Yes:" << n << endl;
else
cout << "No:" << endl;
}
折半查找算法的时间复杂度为O(logn)。
(2)插值查找
考虑一个问题:为什么是折半?而不是折四分之一或者更多呢? 好吧,且看分解:
(3)斐波那契查找(不是必会)
斐波那契查找利用了黄金分割原理来实现。 如何利用斐波那契数列作为分割呢?
为了理清这个查找算法,首先需要一个斐波那契数列,如下图所示:
查找算法如下描述:
注意阅读以下详解之前,请先编译并运行第四部分的实例代码,结合代码再理解算法。
首先要明确一点:
如果一个有序表的元素个数为n,并且n正好是某个斐波那契数-1,即n == F[k]-1时,才能用斐波那契查找法。
1. 如果有序表的元素个数n不等于某个斐波那契数-1,即n != F[k]-1,如何处理呢?
这时必须要将有序表的元素个数扩展到比n大的第一个斐波那契数-1的个数才符合算法的查找条件。
通俗点讲,也就是为了使用斐波那契查找法,那么要求所查找顺序表的元素个数n必须满足n == F[k]-1这样的条件才可以。
因为查找表为从小到大的顺序表,所以如果数据元素个数不满足要求,只有在表末用顺序表的最大值补满。
代码中第9-10行的作用恰是如此。
2. 对于二分查找,分割点是从mid= (low+high)/2开始。
而对于斐波那契查找,分割是从mid = low + F[k-1] - 1开始的。 为什么如此计算?
用实例验证,比如本例中: 第一次进入查找循环时,数组元素个数准确说应该是12(包括随后补满的元素)
而黄金分割点比例为0.618,那么12*0.618=7.416,此值对应12个元素应该为a[8]
观察程序运行第一次mid=1+F[7-1]-1=8,正是此原理所体现。
key=59,a[8]=73,显然key<a[8],可知low=1,high=7,k=7-1=6
注意此条件意思即为7个数据元素,正好满足F[6]-1=7的再次查找客观要求
而同理,黄金分割点比例为0.618,那么7*0.618=4.326,此值对应7个元素应该为a[5]
再看第二次进入循环mid=1+F[6-1]-1=5,正是此原理所体现。
key=59,a[5]=47,显然key>a[5],可知low=6,high=7,k=6-2=4
注意此条件意思即为2个数据元素,正好满足F[4]-1=2的再次查找客观要求
而同理黄金分割点比例为0.618,那么2*0.618=1.236,此值对应2个元素中的第二个即为a[7]
key=59,a[7]=62,显然key<a[7],可知low=6,high=6,k=4-1=3
同理mid=6+F[3-1]-1=6。此时a[6]=59=key。 即查找成功。
3. 注意紧接着下面一句代码可以改写为:
return (mid <= n) ? mid : n;
当然这样写也没有功能错误,但是细细琢磨还是有逻辑问题:
mid == n时,返回为n; mid > n时返回也是n。
那么到底n属于那种情况下的返回值呢?是否有违背if的本质!
窃以为写成if(mid < n)会合理些。
另外,许多资料对于这步判断描述如下:
return (mid <= high) ? mid : n;
其实分析至此,我认为这种写法从代码逻辑而言更为合理。
4. 通过上面知道:数组a现在的元素个数为F[k]-1个,即数组长为F[k]-1。
mid把数组分成了左右两部分,左边的长度为:F[k-1]-1
那么右边的长度就为(数组长-左边的长度-1): (F[k]-1)-(F[k-1]-1)= F[k]-F[k-1]-1 = F[k-2] - 1
5. 斐波那契查找的核心是:
a: 当key == a[mid]时,查找成功;
b: 当key<a[mid]时,新的查找范围是第low个到第mid-1个,此时范围个数为F[k-1] - 1个,
即数组左边的长度,所以要在[low, F[k - 1] - 1]范围内查找;
c: 当key>a[mid]时,新的查找范围是第mid+1个到第high个,此时范围个数为F[k-2] - 1个,
即数组右边的长度,所以要在[F[k - 2] - 1]范围内查找。
关于斐波那契查找, 如果要查找的记录在右侧,则左侧的数据都不用再判断了,不断反复进行下去。
对处于中间的大部分数据,其工作效率要高一些。
所以尽管斐波那契查找的时间复杂度也为O(logn),但就平均性能来说,斐波那契查找要优于折半查找。
可惜如果是最坏的情况,比如这里key=1,那么始终都处于左侧在查找,则查找效率低于折半查找。
还有关键一点:折半查找是进行加法与除法运算的(mid=(low+high)/2)
插值查找则进行更复杂的四则运算(mid = low + (high - low) * ((key - a[low]) / (a[high] - a[low])))
而斐波那契查找只进行最简单的加减法运算(mid = low + F[k-1]-1)
在海量数据的查找过程中,这种细微的差别可能会影响最终的效率。
【4】斐波那契算法代码实现
实例算法代码如下:
#include <iostream>
#include <assert.h>
using namespace std;
#define MAXSIZE 11
// 斐波那契非递归
void Fibonacci(int *f)
{
f[0] = 0;
f[1] = 1;
for (int i = 2; i < MAXSIZE; ++i)
{
f[i] = f[i-1] + f[i-2];
}
}
// 斐波那契数列
/*---------------------------------------------------------------------------------
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
----------------------------------------------------------------------------------
| 0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 | 55 | 89 | 144 |
-----------------------------------------------------------------------------------*/
// 斐波那契数列查找
int Fibonacci_Search(int *a, int n, int key)
{
int low = 1; // 定义最低下标为记录首位
int high = n; // 定义最高下标为记录末位(一般输入的参数n必须是数组的个数减一)
int F[MAXSIZE];
Fibonacci(F); // 确定斐波那契数列
int k = 0, mid = 0;
// 查找n在斐波那契数列中的位置,为什么是F[k]-1,而不是F[k]?
while (n > F[k]-1)
{
k++;
}
// 将不满的数值补全
for (int i = n; i < F[k]-1; ++i)
{
a[i] = a[high];
}
// 查找过程
while (low <= high)
{
mid = low + F[k-1] - 1; // 为什么是当前分割的下标?
if (key < a[mid]) // 查找记录小于当前分割记录
{
high = mid - 1;
k = k - 1; // 注意:思考这里为什么减一位?
}
else if (key > a[mid]) // 查找记录大于当前分割记录
{
low = mid + 1;
k = k - 2; // 注意:思考这里为什么减两位?
}
else
{
return (mid <= high) ? mid : n; // 若相等则说明mid即为查找到的位置; 若mid > n 说明是补全数值,返回n
}
}
return -1;
}
void main()
{
int a[MAXSIZE] = {0,1,16,24,35,47,59,62,73,88,99};
int k = 0;
cout << "请输入要查找的数字:" << endl;
cin >> k;
int pos = Fibonacci_Search(a, MAXSIZE-1, k);
if (pos != -1)
cout << "在数组的第"<< pos+1 <<"个位置找到元素:" << k;
else
cout << "未在数组中找到元素:" << k;
}
若结合以上相关分析深入理解代码。
<2>线性索引
【1】线性索引
索引就是把一个关键字与它对应的记录相关联的的过程。
索引是为检索而存在的。
一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。
索引技术是组织大型数据库以及磁盘文件的一种重要技术。
索引按照结构可以分为线性索引,树形索引和多级索引。
所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。
重点了解三种线性索引:稠密索引,分块索引和倒排索引。
【2】稠密索引
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项。
所下图所示:
对于稠密索引这个索引表而言,索引项一定是按照关键码有序的排列。
为什么要这样做呢?
索引项有序也就意味着,我们要查找关键字时,可以用折半,插值及斐波那契等有序查找算法。
比如要查找关键字18的记录,如果直接从右侧的数据表中查找,那只能顺序查找。
需要查找6次才可以看到结果!!
而如果是从侧的索引表中查找,只需两次折半查找就可以得到18对应的指针。对应找到结果。
好吧!以上显然是稠密所以优点。
如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模。
对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能大大下降。
稠密索引文件的每个记录都有一个索引项,记录在数据区存放是任意的,但索引是按序的,这种索引称为稠密索引。
稠密索引文件的索引查找、更新都较方便,但由于索引项多,占用空间较大。
【3】分块索引(必会)
注意理解:稠密索引是因为索引项和数据集的记录个数相同,所以空间代价很大。
如何减少索引项的个数呢?
我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项(类似于图书馆的分块)。
分块有序是把数据集的记录分成了若干块,并且这些块需要满足两个条件:
(1)块内无序
每一块内的记录不要求有序
(2)块间有序
比如要求第二块所以记录的关键字均要大于第一块中所有记录的关键字,第三块要大于第二块。
只有块间有序才有可能在查找时带来效率。
对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引。
分块索引的索引项结构分为三个数据项:
a: 最大关键码--存储每一块中的最大关键字。
b: 存储每一块中记录的个数以便于循环时使用。
c: 用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。
如下图所示:
在分块索引表中查找,可以分为两步:
a: 在分块索引表中查找要查的关键字所在块。
由于分块索引表是块间有序的,因此很容易利用折半插值等算法得到结果。
b:根据块首指针找到相应的块,并在块中顺序查找关键码。
因为块中可以是无序的,因此只能顺序查找。
【4】倒排索引
关于倒排索引,最好再参见一下这几篇文章:
(2)搜索引擎-倒排索引基础知识_黄规速博客:学如逆水行舟,不进则退-CSDN博客_搜索引擎 的倒排索引
三.动态查找
<1>二叉搜索树(排序)
详见这篇博客:数据结构与算法随笔之------二叉查找(搜索)树详解_markconca的博客-CSDN博客
<2>平衡二叉树
详见这篇博客:数据结构与算法随笔之------初识平衡二叉树_markconca的博客-CSDN博客
<3>多路查找树
这个用的不多,暂时没空总结了。。。
四.散列表查找(哈希表)概述
这一部分的知识难点较大,但考研必须会!!!
《1》散列表
以上两个例子说明散列查找如果没有冲突的话查找效率是很高的(与问题规模无关)
《2》散列表的构造方法(两类)
1.数字关键词的散列函数构造
2.字符关键词的散列函数构造(好像是非重点)
《3》冲突处理方法(超重点!!!)
对于线性探测法,虽然查找效率不高,但是只要有空位,理论上是一定可以找的到的
《4》散列表的性能分析
总结: