查找算法
查找操作是数据处理中使用最频繁的一项操作。二分查找是顺序表中最常用的查找方式。
查找(search) 是指在数据集合中寻找满足某种条件的数据元素的过程。用于查找的数据集合则称为 查找表(search table) 。查找表中的数据元素类型是一致的,并且能够唯一标识出元素的 关键字(keyword) 。如果从查找表中找出了关键字等于某个给定值的数据元素,则称为 查找成功,否则称 查找不成功。
通常对查找表有4种操作:
- 查找:在查找表种查看某个特定的记录是否存在。
- 检索:查找某个特定记录的各种属性。
- 插入:将某个不存在的数据元素插入到查找表中。
- 删除:从查找表中删除某个特定元素。
如果对查找表只执行前两种操作,则称这类查找表为 静态查找表(static search table)。静态查找表建立以后,就不能再执行插入或删除操作,查找表也不再发生变化。对应的,如果对查找表还要执行后两种操作,则称这类查找表为 动态查找表(dynamic search table)。在这里我们要介绍的查找算法都是针对静态查找表的,比如顺序查找、折半查找、分块查找等。而对于动态查找表,往往使用二叉平衡树、B-树或哈希表来处理。
对于各种各样的查找算法,通常我们使用 **平均查找长度(average search length, ASL)**来衡量查找算法的性能。对于含有n个元素的查找表,定义查找成功的平均长度为:
n
ASL = ∑ PiCi
i=0
其中Pi
是搜索查找表中第i
个记录的概率,并且ASL = ∑ Pi = 1
(通常我们认为美国元素被查找的概率相等,即Pi = 1 / n
)。Ci
是指搜索查找表中第i
个元素时直到查找成功为止,表中元素的比较次数。考虑到查找不成功的情况,查找算法的平均查找长度应该是查找成功的平均查找长度和查找不成功的平均查找长度之和。通常我们在说平均查找长度时,不考虑查找不成功的情况。
比如一个给定的查找表A = [1,2,3,4,5]
,其中每个Pi = 1/5
。若对某个查找算法,每个元素到查找成功为止的比较次数C = [1,2,3,4,5]
。则
n n
ASL = ∑ PiCi = 1/5 ∑ Ci = 3
i=0 i=0
所以,该查找算法的平均查找长度为3。
顺序查找
**顺序查找(又称线性查找,sequential search)**是指在线性表中进行查找是算法。顺序查找算法是最直观的一种查找算法,它从线性表的一端出发,逐个对比关键字是否满足给定条件。
顺序查找按照查找表中数据的性质,分为对一般的无序线性表的顺序查找和对按关键字有序的线性表的顺序查找。下面我们分别对这两种查找算法进行讲解。
对于一般线性表的查找,基本思想是从线性表的一端开始,逐个比对关键字是否满足给定的条件。若找到某个元素的关键字满足给定的要求,则查找成功,若一直找到线性表的另一端仍未有满足要求的元素,则查找不成功。让我们来一起分析一下在一般线性表上的查找算法的平均查找长度。
对于有n
个元素a[0]
,a[1]
,…,a[n-1]
的查找表,每个元素的查找概率Pi = 1/n
。若每次查钊都从第一个元素a[0]
开始,则查找第i
个元素a[i-1]
时,需要进程C[i-1] = i
次比较操作。因此,查找成功的平均查找长度为
n
ASL = ∑ Pi*(i + 1)= (n + 1) / 2;
i=0
而当查找不成功时,与查找表中各个元素的比较次数为n
次,因此查钊不成功的平均长度为n
。
对于有序表的顺序查找,在查找成功时与一般线性表的查找是一样的。而对于查找不成功的情况,无需和表中所有元素都进行比对就可以确认查找不成功,这样能降低查找不成功时的平均查找长度。
具体来说,假设查找表a[0]
,a[1]
,…,a[n-1]
是从小到大排列的,查找的顺序是从左到右。若待检索的关键字为key
,当查找到第i
个元素时,如果第i
个元素的值大于key
,而之前并没有查找成功时,就可以认为查找不成功了。
很显然,通过这样的优化,我们将查找不成功的平均长度降低了。假设对于所有查找不成功的关键字key
,落在(-∞,a[0])
,(a[0], a[1])
,…,(a[n-2], a[n-1])
,(a[n-1], ∞)
这个n+1
个区间的概率是相等的,都是1/(n+1)
,那么查找不成功的平均查找长度为
ASL(failed) = (1+2+3+...+n+n) / (n+1) = n/2 + n/(n+1)
大致是之前查找不成功的平均查找长度n
的一半,效率提升还是很明显的。当然,这种方法只适用于有序表。
顺序查找代码演示
/*************************************************************************
> File Name: 8.line_search.c
> Author: 陈杰
> Mail: 15193162746@163.com
> Created Time: 2021年04月05日 星期一 18时53分38秒
> 线性查找演示
************************************************************************/
#include<stdio.h>
#define MAX_N 100
/*
* 顺序查找函数
* @param data: 要查找的顺序表指针
* @param length: 要查找顺序表的长度
* @param val: 要查找的值
* */
int search(int *data, int length, int val){
for(int i = 0; i < length; i++) {
if(data[i] == val) return i;
else if(data[i] > val) return -1;
}
return -1;
}
int main() {
int data[MAX_N];
for(int i = 0; i < MAX_N; i++) {
data[i] = i*2;
}
int ret = search(data, MAX_N, 10);
if(~ret) printf("search success! data[%d] = 10\n", ret);
else printf("search fail!\n");
ret = search(data, MAX_N, 81);
if(~ret) printf("search success! data[%d] = 81\n", ret);
else printf("search fail!\n");
ret = search(data, MAX_N, 100);
if(~ret) printf("search success! data[%d] = 100\n", ret);
else printf("search fail!\n");
return 0;
}
折半算法
折半查找算法的基本流程如下:
- 首先确定待查关键字在有序(这里我们假设是升序,即从小到大)的查找表中的范围。通常用两个下标来表示范围:
left=0, right = length - 1
。 - 然后用给定的关键字和查找表的正中间位置(下标为
mid = (left + right) / 2
)元素的关键字进行比较,若相等,则查找成功。若待查关键字比正中间位置的关键字大,则继续对右子表(left = mid + 1)进行折半查找,否则对左子表(right = min - 1)进行折半查找。 - 如此重复进行,直到查找成功或范围缩小为空
left > right
即查找不成功为止。
折半查找的时间复杂度为 O(logn)
,明显优于时间复杂度为 O(n)
的顺序查找算法。不过一定要注意,折半查找只适用于关键字有序的顺序表,无序的线性表如果想使用折半查找要先进行排序操作,而链表因为无法随机存取所以没有办法使用折半查找。当然我们也可以在一个单调函数中,用二分查找精确求解中某一点的值。
折半查找代码演示
/*************************************************************************
> File Name: 8.binary_search.c
> Author: 陈杰
> Mail: 15193162746@163.com
> Created Time: 2021年04月05日 星期一 19时09分01秒
> 折半查找代码演示
************************************************************************/
#include<stdio.h>
#define MAX_N 100
/*
* 折半查找函数
* @param data: 待查找的数组
* @param length: 数组长度
* @param val: 要查找的值
* */
int search(int *data, int length, int val) {
int l = 0, r = length - 1;
while(l <= r) {
int mid = (l + r) >> 1;
if(data[mid] == val) return mid;
if(data[mid] < val) l = mid + 1;
else r = mid - 1;
}
return -1;
}
int main() {
int data[MAX_N];
for(int i = 0; i < MAX_N; i++) {
data[i] = i*2;
}
int ret = search(data, MAX_N, 10);
if(~ret) printf("search success! data[%d] = 10\n", ret);
else printf("search fail!\n");
ret = search(data, MAX_N, 81);
if(~ret) printf("search success! data[%d] = 81\n", ret);
else printf("search fail!\n");
ret = search(data, MAX_N, 100);
if(~ret) printf("search success! data[%d] = 100\n", ret);
else printf("search fail!\n");
return 0;
}
折半查找扩展代码演示
/*************************************************************************
> File Name: 8.binary_expand_search.c
> Author: 陈杰
> Mail: 15193162746@163.com
> Created Time: 2021年04月05日 星期一 19时19分41秒
> 二分算法扩展
************************************************************************/
#include<stdio.h>
#define MAX_N 100
/*
* 二分查找扩展算法一
* @model :00000000111111找第一个1
* @param data: 待查找的数组
* @param length: 待查找数组的长度
* @param val : 待查找值的参考值
* */
int binary_search1(int *data, int length, int val) {
int l = 0, r = length - 1;
while(l < r) {
int mid = (r + l) >> 1;
if(data[mid] >= val) r = mid;
else l = mid + 1;
}
return l;
}
/*
* 二分查找扩展算法二
* @model :11111100000000找最后一个1
* @param data: 待查找的数组
* @param length: 待查找数组的长度
* @param val : 待查找值的参考值
* */
int binary_search2(int *data, int length, int val) {
int l = 0, r = length - 1;
while(l < r) {
int mid = (r + l + 1) >> 1;
if(data[mid] <= val) l = mid;
else r = mid - 1;
}
return r;
}
int main() {
int data[MAX_N];
for(int i = 0; i < MAX_N; i++) {
data[i] = 2 * i;
}
int ret = binary_search1(data, MAX_N, 77);
printf("search success! data[%d] = %d is bigger than 77 first number!\n",ret,data[ret]);
ret = binary_search2(data, MAX_N, 77);
printf("search success! data[%d] = %d is smaller than 77 last number!\n",ret,data[ret]);
return 0;
}
三分法查找
如果函数是一个 凸性函数(在某一点左侧,函数递增,在该点右侧,函数递减,该点称为极大值点)或者 凹性函数(在某一点左侧,函数递减,在该点右侧,函数递增,该点称为极小值点),我们也可以借鉴二分查找的方法来求解。故此,可以用 三分查找 来解决凸性函数或者凹性函数求极值点的问题。
三分查找的过程如下:
- 首先将区间
[L,R]
平均分成三部分:[L,m1]
、[m1, m2]
、[m2, R]
。 - 计算三等分点
m1
和m2
对应的函数值f(m1)
和f(m2)
。 - 比较
f(m1)
和f(m2)
的大小。
+ 如果f(m1) > f(m2)
,则说明点T一定不在区间[m2, R]
内,我们可以把右边界R
更新成m2
。
+ 如果f(m1) < f(m2)
,则说明点T一定不在区间[L, m1]
内,我们可以把左边界L
更新成m1
。
+ 如果f(m1) = f(m2)
,则说明点T一定落在区间[m1, m2]
内。另外,我们可以将这种情况归为上面两种情况的任意一种。 - 重复以上操作,不断缩小查找区间,直到在精度要求的范围内,左边界
L
等于右边界R
,这时的边界点(L, f(L))
或者(R, f(R))
即是我们查找的极大值点T
。
同理,如果凹性函数的极小值点,只需在第三步中,将大于号和小于号反一下即可。
我们来看看算法的正确性:
- 如果
m1
和m2
在极大值点T
的同一侧。由于凸性函数两侧的单调性,两点中函数值更大的点离点T
更近,也就是说,远离点T
的区间是可以舍弃的。 - 如果
m1
和m2
在极大值点T
的异侧。由于点T
在区间[m1,m2]
内,舍弃两边任何一个区间都不会影响结果。
三分查找每次都会把区间平均分成三等分,在依次比较之后,都会舍弃一个区间,也是就是说,在一次比较之后,都会将区间长度缩成原来的2/3
。所以三分查找的时间复杂度为O(log n)
。
如果序列符合凸性函数或者凹性函数,那我们就可以用三分查找来求极值点。
三分法代码演示
/*************************************************************************
> File Name: 8.ternary_find_max.c
> Author: 陈杰
> Mail: 15193162746@163.com
> Created Time: 2021年04月05日 星期一 21时45分45秒
> 三分查找的实现
************************************************************************/
#include<stdio.h>
int find_max(int *data, int length) {
int left = 0, right = length - 1; // 设置左边界0,设置右边界为长度减一
while(right - left > 1) {
int m1 = left + (right - left) / 3; // 左边界加上区间长度的三分之一
int m2 = right - (right - left + 2) / 3; // 右边界减去区间长度的三分之一,涉及取整问题,所以将长度做一点调整,尽量让m2往极右靠
// 如果data[m]大于等于data[m2],表示极点一定不区间[m2,right]内
if(data[m1] >= data[m2]) right = m2;
// 说明极点一定不在区间[left, m1]内,右边界left更新
else left = m1 + 1;
}
// 最后只区间内只剩下两个值,将大的一个的下标返回即可
return data[left] > data[right] ? left : right;
}
int main() {
int a[5] = {1, 2, 7, 5, 4};
printf("%d\n", find_max(a, 5));
return 0;
}
分块查找
在前面我们已经学习了顺序查找和折半查找,这一节我们来学习一种查找效率结于两者之间的查找算法- 分块查找(Blocking Search)。
分块查找的基本思想是将一个线性表分成若干个子表,在查找时,先确定目标元素所在的子表再在该自表中去查找它。
分块查找也被叫做 索引顺序查找,在分块查找方法中,我们需要建立一根索引表。索引表中包含两类信息:关键字和指针。其中,关键字指的是每个子表中最大的关键字,指针则表示盖子表中第一个元素在整个表中的下标。
该如何去确定待查找的记录存在哪一根子表中呢?事实上,分块查找要求正线性表是分块有序的。分块有序指的是当将一根线性表分成若干子表后,第i
个子表中所有元素的关键字都小于i+1
个子表中所有元素的关键字。我们可以理解成第i
个子表中最大的关键字小于第i+1
个子表中最小的关键字。
索引表中的每一项是按照关键字进行排序,因此,我们可以很容易在索引表中使用顺序查找或折半查找的方法找到目标所在的子表。而在每一个子表中,元素的排列是随意的,我们只能够通过顺序查找的方法在子表中完成最终的查找工作。
分块查找的效率是基于顺序查找和折半查找之间的。这是因为一般在进行分块查找时,我们会将一个包含n个元素的线性表均分为k
个含有s = n/k
个元素的子表。最终查找的长度为在索引表中的查找长度加上在字块内的查找长度。当使用顺序查找在索引表中进行查找时,其平均查找长度为(k+1)/2
,此时总的平均查找长度为:
ASL = (1/k)(1+2+3+...+k) + (1/s)(1+2+3+...+k) =(k + 1) / 2 + (s + 1) / 2;
分块查找的优势在于,由于字块中的元素是随意排序的,我们只要找到对应的块就能直接进行插入和删除操作,而不用大量移动其他的元素,因此它适用于线性表需要频繁动态变化的情况。
分块查找的缺点在于它需要一定的内存空间来存放索引表并且要求对索引表进行排序。