一、查找Search
文章目录
1.查找的基本概念
1.1基本概念
查找(Searching):就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素( 或记录)。
查找表(Search Table):是由同一类型的数据元素(或记录)构成的集合。
可以是线性结构、树形结构、图状结构…
关键字(Key):数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。
例如,在由一个学生元素构成的数据集合中,学生元素中“学号”这一数据项的值唯一地标识一名学生。
静态查找表(Static Search Table):只作查找操作的查找表(只查不改)。主要操作:
- 查询某个“特定的”数据元素是否在查找表中。
- 检索某个“特定的”数据元素和各种属性。
动态查找表(Dynamic Search Table): 在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素(边查边改)。主要操作:
- 查找时插入不存在的数据元素。
- 查找时删除已存在的数据元素。
1.2算法评价标准
查找长度:在查找运算中,一次查找需要对比关键字的次数称为查找长度。
平均查找长度(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
n
:数据元素个数Pi
:查找第 i 个元素的概率,一般认为每个数据元素的查找概率相等,即Pi= 1/nCi
:查找第 i 个元素的查找长度
例如:
二、线性结构
1.顺序表查找
❗1.1顺序查找
顺序查找(Sequential Search) 又叫线性查找,是最基本的查找技术。通常用于线性表。
1.1.1算法思想
算法思想(严谨):从线性表的一端开始,逐个检查关键字是否满足给定的条件。若查找到某个元素的关键字满足给定条件,则查找成功,返回该元素在线性表中的位置;若已经查找到表的另一端,但还没有查找到符合给定条件的元素,则返回查找失败的信息。
算法思想(个人):从头到尾挨个找(或者反过来也OK)
有哨兵顺序查找:倒序查找,在下标为0处(查找尽头)设置为关键字,称之为哨兵。返回其他值为查找成功,返回0表示失败。
//有哨兵顺序查找
int Sequential_Search(int *a, int n, int key){
int i;
a[0] = key; //设置a[0]为关键字,称之为“哨兵”
i = n; //循环从数组尾部开始
while(a[i] != key){
i--;
}
return i; //返回0则说明查找失败
}
这种在查找方向的尽头放置“哨兵”免去了在查找过程中每一次比较后都要判断查找位置是否越界的小技巧,看似与原先差别不大,但在总数据较多时,效率提高很大,是非常好的编码技巧。
上述顺序表查找时间复杂度是O(n)。
1.1.2顺序查找效率分析
倒序查找,如果第一个就是查找目标,那么只需要对比关键字1次,如果是倒数第2个,那么就对比2次。如果查找失败,那么就直接遍历到0号元素。
所以:
成功
A
S
L
=
∑
i
=
1
n
P
i
C
i
=
1
n
∗
∑
i
=
1
n
C
i
=
1
n
∗
(
1
+
2
+
3
+
.
.
.
+
n
)
=
n
+
1
2
失败
A
S
L
=
n
+
1
\begin{equation}\begin{split} 成功ASL &= \sum ^n_{i=1} P_iC_i \\ &= \frac 1n * \sum ^n_{i=1}C_i \\ &= \frac 1n *(1+2+3+...+n) \\ &= \frac {n+1}2 \\ 失败ASL = n+1 \end{split}\end{equation}
成功ASL失败ASL=n+1=i=1∑nPiCi=n1∗i=1∑nCi=n1∗(1+2+3+...+n)=2n+1
2.有序表查找
如果一个表是从小到大(升序)或者从大到小(降序),那么查找效率可以提高很多。
❗2.1折半查找
折半查找(Binary Search)技术,又称为二分查找。它的前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须采用顺序存储。
2.1.1算法思想
算法思想(严谨):在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。
算法思想(个人):因为顺序表有顺序,那么直接从中间开始查找,把整个表一分为二,如果关键字比中间的值更大,那么就往更大的一半查找。依然从中间开始查找对比大小。
// 从小到大,升序
int Binary_Search(SeqList 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){
high = mid - 1; //从前半部分继续查找
}else{
low = mid + 1; //从后半部分继续查找
}
}
return -1; //查找失败,返回-1
}
查找判定树:折半查找的过程可用二叉树来描述,称为判定树。折半查找的判定树一定是平衡二叉树(只有最下面一层是不满的)。
我们知道,具有n个结点的二叉树的深度(树高)为 ⌈ l o g 2 ( n + 1 ) ⌉ 或者 ⌊ l o g 2 n ⌋ + 1 \lceil log_2(n+1) \rceil或者\lfloor log_2n \rfloor +1 ⌈log2(n+1)⌉或者⌊log2n⌋+1,那么 A S L = ⌈ l o g 2 ( n + 1 ) ⌉ ASL=\lceil log_2(n+1) \rceil ASL=⌈log2(n+1)⌉
所以,折半查找的时间复杂度为O(log2n)。
平均情况下(但是不是所有情况),比顺序查找的效率高。
因为折半查找需要方便地定位查找区域,所以它要求线性表必须具有随机存取的特性。因此,该查找法仅适合于顺序存储结构,不适合于链式存储结构,且要求元素按关键字有序排列。
2.1.2判定树构造
因为折半查找是删去中间的元素,然后进行两边平分的,那么就需要关注整体的元素的个数是奇数还是偶数。
如果是奇数个元素(11):
如果是偶数个元素(10):
总结:
- 奇数个元素,则mid分隔后,左右两部分元素个数相等。
- 偶数个元素,则mid分隔后,左半部分比右半部分少一个元素(右边多,因为计算机是向下取整)。
折半查找的判定树中,若
m
i
d
=
⌊
(
l
o
w
+
h
i
g
h
)
/
2
⌋
+
1
mid=\lfloor (low+high)/2 \rfloor +1
mid=⌊(low+high)/2⌋+1,则对于任何一个结点,必有:
右子树结点数
−
左子树结点数
=
0
或
1
右子树结点数-左子树结点数=0或1
右子树结点数−左子树结点数=0或1
也即是左右子树的深度之差不会超过 1。那么折半查找的判定树一定是平衡二叉树。
2.1.3通过判定树进行查找效率分析
失败节点的个数是n+1,也就是成功结点的空链域的数量。
一个成功结点的查找长度C = 自身所在的层数。
一个失败结点的查找长度C = 父结点所在的层数。
默认情况下,各种失败情况或成功情况都等概率发生。
2.1.4被查找概率不相等(优化)
被查找概率不相等时候,那么可以把查找概率大的元素放在前面,先进行查找,这样可以降低查找成功的ASL。
不足:但是这样就打破了排序,那么查找失败的ASL就需要全部遍历到哨兵才可以。
2.2插值查找(折半优化)
现在我们的新问题是,为什么一定要折半,而不是折四分之一或者折更多呢?比如要在取值范围0 ~ 10000之间100个元素从小到大均匀分布的数组中查找5,我们自然会考虑从数组下标较小的开始查找。所以,折半查找还是有改善空间的。
对上述折半查找的mid值获取等式变换后可得到:
mid = (low + high)/2 = low + (high - low)/2;
也就是mid等于最低下标low加上最高下标high与low的差的一半。
大佬们考虑的就是将这个1/2
进行改进,改进为下面的计算方案:
mid = low + (key-L.elem[low]) / ( (L.elem[high] - L.elem[low]) * (high-low) ); //插值
就得到了另一种有序表查找算法,插值查找法。
**插值查找(Interpolation Search)**是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式。
从时间复杂度来看,它也是O(log2 n),但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。
反之,数组中如果分布类似0,1,2,2000,2001,…999998,99999这种极端不均匀的数据,用插值查找未必是很合适的选择。
2.3斐波那契查找
斐波那契查找(Fibonacci Search),它是利用了黄金分割原理来实现的。
斐波那契数列(Fibonacci sequence),又称黄金分割数列,以兔子繁殖为例子而引入,故又称“兔子数列”。其数值为:1、1、2、3、5、8、13、21、34……在数学上,这一数列以如下递推的方法定义:
F(0)=0,
F(1)=1,
F(n)=F(n-1) + F(n-2)
//(n ≥ 2, n ∈ N)
//F(n) = 前两数之和
这里定义一个斐波那契数列:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
F | 0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 | … |
斐波那契查找算法的核心在于:
- 当key = a[mid]时,查找就成功;
- 当key < a[mid]时,新范围是第low个到第mid-1个,此时范围个数为F[k-1]-1个;
- 当key > a[mid]时,新范围是第m+1个到第high个,此时范围个数为F[k-2]-1个。
也就是说,如果要查找的记录在右侧,则左侧的数据都不用再判断了,不断反复进行下去,对处于当中的大部分数据,其工作效率要高一些,而且斐波那契查找只是最简单加减法运算,所以尽管斐波那契查找的时间复杂也为O(logn),但就平均性能来说,斐波那契查找要优于折半查找。
不过如果是最坏情况,比如这里key=1,那么始终都处于左侧长半区在查找,则查找效率要低于折半查找。
算法如下:
//斐波那契查找
int Fibonacci_Search(int *a, int n, int key){
int low, high, mid, i, k;
low = 0; //定义最低下标为记录首位
high = n; //定义最高下标为记录末尾
k = 0;
while(n > F[k] - 1){
//计算n位于斐波那契数列的位置
k++;
}
for(i=n; i<F[k]; i++){
//在尾部补上F[k]-n-1个数,大小等于尾部最大值,否则会存在数组溢出
a[i]=a[n];
}
while(low <= hight){
mid = low + F[k-1]-1; //计算当前分隔的下标
if(key < a[mid]){
//若查找记录小于当前分隔记录
hight = mid - 1; //最高下标调整到分隔下标mid-1处
k = k - 1; //斐波那契数列下标减一位
}else if(key > a[mid]){
//若查找记录大于当前分隔记录
low = mid + 1; //最低下标调整到分隔下标mid+1处
k = k - 2; //斐波那契数列下标减两位
}else{
if(mid <= n){
return mid; //若相等则说明mid即为查找的位置
}else{
return n; //若mid>n说明是补全数值,返回n
}
}
}
return -1;
}
3.线性索引查找
现实生活中计算机要处理的数据量是极其庞大的,而数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。
索引就是把一个关键字与它对应的记录相关联的过程, 一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。
- 索引
- 索引项
- 关键字
- 关键字对应的记录在存储器的位置
- 索引项
索引按照结构可以分为:
- 线性索引
- 稠密索引
- 分块索引
- 倒排索引
- 树形索引
- 多级索引
这里主要介绍线性索引,所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。我们重点介绍三种线性索引:稠密索引、分块索引和倒排索引。
3.1稠密索引
稠密索引是很简单直白的一种索引结构。
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,而索引项一定是按照关键码有序的排列。如下图所示:
索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,提高了效率。这是稠密索引优点,但是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。
❗3.2分块索引
稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项的个数。
分块有序,又称索引顺序查找。是把数据集的记录分成了若千块,并且这些块需要满足两个条件:
- 块内无序:即每一块内的记录不要求有序。
- 块间有序:例如,要求第二块所有记录的关键字均要大于第一块中所有记录的关键字,第三块的所有记录的关键字均要大于第二块的所有记录关键字…因为只有块间有序,才有可能在查找时带来效率。
对于分块有序的数据集,将每块对应一个索引项, 这种索引方法叫做分块索引。我们定义的分块索引的索引项结构分三个数据项:
- 最大关键码:它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大。
- 块长:存储了块中的记录个数,以便于循环时使用。
- 块首指针:用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。
3.2.1算法思想
- 在索引表中确定待查记录所属的分块(可顺序、可折半);
- 在块内顺序查找(全部遍历)。
//索引表
typedef struct {
ElemType maxValue;
int low,high;
}Index;
//顺序表存储实际元素
ElemType List [100] ;
【易错点】对索引表进行折半查找时,若索引表中不包含目标关键字,则折半查找最终停在low>high,要在low所指分块中查找。
3.2.2查找效率分析
如上图,有0-13共14个元素,它们各自被查找的概率都是1/14。
假设,长度为 n 的查找表被均匀地分为 b 块,每块 s 个元素,那么可以推断n=sb。
设索引查找和块内查找的平均查找长度分别为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 S = 1 + 2 + . . . + s s = s + 1 2 所以 : A S L = b + 1 2 + s + 1 2 并且 : n = s b 那么 : A S L = s 2 + 2 s + n 2 s = 1 2 s + 1 + n 2 s 当 s = n 时 , A S L m i n = n + 1 L_I = \frac {1+2+...+b}b = \frac {b+1}2 \\ L_S = \frac {1+2+...+s}s = \frac {s+1}2 \\ \\ 所以:ASL = \frac{b+1}2 + \frac{s+1}2 \\ 并且:n=sb \\ 那么:ASL = \frac{s^2+2s+n}{2s} = \frac 12s+1+\frac n{2s}\\ \\ 当s=\sqrt n时,ASL_{min}=\sqrt n +1 LI=b1+2+...+b=2b+1LS=s1+2+...+s=2s+1所以:ASL=2b+1+2s+1并且:n=sb那么:ASL=2ss2+2s+n=21s+1+2sn当s=n时,ASLmin=n+1
也就是,当把元素分为 n \sqrt n n 块,每块有 n \sqrt n n 个元素的时候,ASL最小。
eg: 若元素有n=10000个,求最小ASL。
10000 = 100 \sqrt {10000}=100 10000=100 ,那么最小 ASL=100+1 = 101
- 如果用折半查找来查找索引
L I = ⌈ l o g 2 ( b + 1 ) ⌉ L S = 1 + 2 + . . . + s s = s + 1 2 所以 : A S L = ⌈ l o g 2 ( b + 1 ) ⌉ + s + 1 2 L_I = \lceil log_2(b+1) \rceil \\ L_S = \frac {1+2+...+s}s = \frac {s+1}2 \\ \\ 所以:ASL = \lceil log_2(b+1)\rceil + \frac{s+1}2 \\ LI=⌈log2(b+1)⌉LS=s1+2+...+s=2s+1所以:ASL=⌈log2(b+1)⌉+2s+1
3.2.3分块索引的优化
若查找表是“动态查找表”,那么因为要删除和添加新的元素,那么就需要把整个表的下标都进行前移或后移。所以使用链式存储的方式:
3.3倒排索引
搜索引擎中涉及很多搜索技术,这里介绍一种最简单,也是最基础的搜索技术:倒排索引。
倒排索引(inverted index):其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引。
我们举个简单的例子:
假如下面两篇极短的文章。
- Books and friends should be few but good.
- A good book is a good friend.
忽略字母大小写,我们统计出每个单词出现在哪篇文章之中:文章1、文章2、文章(1,2),得到下面这个表,并对单词做了排序:
英文单词(次关键码) | 文章编号(记录号表) |
---|---|
a | 2 |
and | 1 |
be | 1 |
book | 1,2 |
but | 1 |
few | 1 |
friend | 1,2 |
good | 1,2 |
is | 2 |
should | 1 |
有了这样一张单词表,我们要搜索文章,就非常方便了。如果你在搜索框中填写“book"关键字。系统就先在这张单词表中有序查找“book" ,找到后将它对应的文章编号1和2的文章地址返回。
在这里这张单词表就是索引表,索引项的通用结构是:
- 次关键码,例如上面的“英文单词”;
- 记录号表,例如上面的“文章编号"。
这名字为什么要叫做“倒排索引”呢?
顾名思义,倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录(或主关键编码)。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。
当然,现实中的搜索技术是非常复杂的,要考虑诸多因素用到诸多技术,由于本文的侧重点并非搜索引擎,所以于此不再赘述。