您的位置:首页 > 数据库

二分查找需要注意的问题,以及在数据库内核中的实现

2013-05-29 15:48 597 查看


转于/article/8071386.html


问题背景

今年的实习生招聘考试,我出了一道二分查找(Binary Search)的题目。题目大意如下:

给定一个升序排列的自然数数组,数组中包含重复数字,例如:[1,2,2,3,4,4,4,5,6,7,7]。问题:给定任意自然数,对数组进行二分查找,返回数组正确的位置,给出函数实现。注:连续相同的数字,返回第一个匹配位置还是最后一个匹配位置,由函数传入参数决定。

我为什么会出这道题目?

二分查找在数据库内核实现中非常重要

在数据库的内核实现中,二分查找是一个非常重要的逻辑,几乎99%以上的SQL语句(所有索引上的范围扫描/等值查询/Unique查询等),都会使用到二分查找进行数据的定位。

考虑一个数据库表t1(a int
primary key, b int),表上的b字段有一个B+树索引,表中记录的b字段取值,就是题目中的[1,2,2,3,4,4,4,5,6,7,7]序列。此时,给定以下的两条查询语句,就是使用到了不同的二分查找逻辑:

SQL1:
select * from t1 where b > 4;

SQL2:
select * from t1 where b >= 4;

针对SQL1,索引的二分查找,就需要跳过所有的4,从最后一个4之后开始返回所有记录;针对SQL2,二分查找就需要定位到第一个4,然后顺序读取所有记录。

除此之外,针对数据库中其他的查询逻辑,二分查找还需要附带更多的功能,例如:

SQL3:
select * from t1 where b < 2;

SQL4:
select * from t1 where b <= 2;

由于数据库索引同时支持反向扫描,因此SQL3、SQL4的语句,都可以使用索引反向扫描。反向扫描时,SQL3需要定位到索引中的第一个2;而SQL4,则需要定位到索引的最后一个2,然后开始反向返回满足查询条件的索引记录。

二分查找在程序设计中,是一个十分基础并且易错的功能

第一个真正正确的二分查找算法,在第一个二分查找实现之后的12年,才被发表出来。通过Google,输入Binary
Search或者是二分查找关键字,有大量的相关的文章或者博客讨论此话题。


二分查找实现,需要注意的问题

本文不准备详细介绍一个正确的二分查找应该是如何实现的,毕竟现在网上有着大量的正确版本。接下来,根据批改试卷过程中发现的一些问题,做一些简单的分析,希望对大家实现一个有效的二分查找算法,甚至是一个数据库内可用的二分查找算法,有所帮助。


问题一:是否检查参数的有效性

大量的试卷,在给出此问题的解决算法时,直接拿着low,high参数开始进行计算,但是却没有检查low/high参数。low/high是否相同,数组中是否存在记录?low/high构成的区间是否有效?代码的鲁棒性不足。

在数据库的二分查找实现中,一般是对一个索引页面进行二分查找。索引页面中有可能根本不存在用户的记录(索引页面中的记录全部被删除,又没有与兄弟页面合并时),此时,low/high均为0,此时如果根据low/high计算出来的mid进行记录的读取,就存在逻辑错误。


问题二:二分查找中值的计算

这是一个经典的话题,如何计算二分查找中的中值?试卷中,大家一般给出了两种计算方法:

算法一:
mid = (low + high) / 2

算法二:
mid = low + (high – low)/2

乍看起来,算法一简洁,算法二提取之后,跟算法一没有什么区别。但是实际上,区别是存在的。算法一的做法,在极端情况下,(low + high)存在着溢出的风险,进而得到错误的mid结果,导致程序错误。而算法二能够保证计算出来的mid,一定大于low,小于high,不存在溢出的问题。

回到数据库二分查找,数据库的一个索引页面(大小一般是8k或者是16k),能够存储的索引记录是有限的,因此肯定不会出现(low + high)溢出的风险。这也是为什么InnoDB中的中值,采用的就是算法一的实现。但是,作为一个严谨的程序设计人员,还是推荐使用算法二,将任何潜在的风险,扼杀于摇篮之中。


问题三:递归实现二分查找

超过一半的试卷,使用了递归调用的方式实现二分查找。不能说递归实现有错,而是在于实现效率问题。总所周知,递归调用存在着压栈/出栈的开销,其效率是比较低下的。而以数据库这样一个极端优化代码效率,提供快速查询响应的系统来说,效率是第一位的。不建议使用递归方式实现二分查找,至少在数据库内核实现中是不允许使用的。据我所知,所有的开源数据库系统,例如:InnoDB,PostgreSQL都未采用递归方式实现二分查找。


问题四:如何查找第一个/最后一个等值

回到题目,要求根据传入的参数不同,返回第一个/最后一个等值项。在本文的背景部分,我也解释了此问题对应的数据库查询(>,>=查询需求是不同的)。在试卷中,超过80%的同学的答案都是先进行二分查找,待定位到相同值之后,再根据传入的flag(用户需求:flag
= 1,返回第一个等值项
flag
= 0,返回最后一个等值项
),进行顺序遍历,直至定位到满足条件的项。

同样,不能说这个实现是错的,但是也存在着性能问题。性能性能性能,永远是数据库内核实现考虑的重点之一(相信也是所有应用程序的一个指标)。数据库中,除了主键索引/Unique索引能够保证键值唯一之外,很多二级辅助索引都是存在相同键值的,有时相同键值的项会超过千项(考虑一个用户的订单,或者是购买记录)。

假设一个索引页面,保存着400项记录,均为相同键值。此时,使用先二分查找,后顺序遍历的算法,二分查找只能使用一次,顺序遍历199次,最终对比了200次。效率非常之低。当然,我也欣喜的看到另外一小部分同学的做法(我期待看到的算法),用flag来纠正每次比较的最终结果。例如:比较相等(相等用0表示,大于为1,小于为-1),但是flag = 1,则返回纠正后的比较结果为1,需要移动二分查找的high到mid,继续二分(反之,若flag = 0,则返回纠正后的结果为-1,需要移动二分查找的low到mid,继续二分)。如此一来,等值仍旧可以进行二分查找,最终的对比只需要9次,远远小于200次。

此问题,进一步引出了下一个问题,数据库中如何实现一个通用的,更为复杂的二分查找算法?


问题五:数据库中的二分查找实现举例

数据库中的二分查找,更为复杂,需要实现一个通用型的二分查找算法,使用于各种不同的SQL查询场景。

InnoDB针对不同的SQL语句,总结出四种不同的Search Mode,分别为:

#define PAGE_CUR_G
1 >查询

#define PAGE_CUR_GE
2 >=,=查询

#define PAGE_CUR_L
3 <查询

#define PAGE_CUR_LE
4 <=查询

然后根据这四种不同的Search Mode,在二分查找碰到相同键值时进行调整。例如:若Search Mode为PAGE_CUR_G或者是PAGE_CUR_LE,则移动low至mid,继续进行二分查找;若Search
Mode为PAGE_CUR_GE或者是PAGE_CUR_L,则移动high至mid,继续进行二分查找。

我们的TNT引擎,采用了与InnoDB不同的方案,但是也实现了相同的功能。TNT引擎针对相同键值的调整总结为下图,在此我就不做解释了,大家可以尝试着自己进行分析。

/*
操作符 includeKey forward compare result: 1 0 -1
*/

=============================================================================

>= 1 1 |
1 -1 -1

=
1 1 | 1 -1 -1

>
0 1 | 1 1 -1

<
0 0 | 1 -1 -1

<= 1 0 | 1
1 -1

=============================================================================


六:代码实现

/*
* 测试样例
* 11
* 1 2 2 3 4 4 4 5 7 7 7
*/

#include <stdio.h>
#include <stdlib.h>
#define _DEBUG 1
#define MAX 20
int n;
int sets[MAX];
enum MATCH_POS{PRE,POST};//分别为第一个匹配、最后一个匹配

int bi_search(int *arr,int b,int e,int v,MATCH_POS pos){
int left,right,mid;
left=b-1;right=e;
while(left+1 < right){
mid=left+(right - left)/2;
if(v < arr[mid]){
right = mid;
}else if(v > arr[mid]){
left=mid;
}else{
if(pos==PRE){//如过寻找第一个匹配,将right向前移动
right = mid;
}else
left = mid;
}
}
if(arr[right] == v)
return right;
else if(arr[left] == v)
return left;
return -1;
}

int main(){
int i;
freopen("BiSearch.in","r",stdin);
scanf("%d\n",&n);
for(i=0;i<n;++i){
scanf("%d",&sets[i]);
}
int t=bi_search(sets,0,n-1,7,PRE);
printf("%d\n",t);
return 0;
}



总结

本文通过一个二分查找的题目,以及同学们在解答题目中暴露出来的问题,分析了一个安全可靠高效的二分查找,应该注意哪些问题。并简要分析了数据库内核实现中的二分查找实现,希望对大家在以后设计二分查找算法时,有所帮助。

以下转自:blog: http://www.cppblog.com/converse


二分查找算法基本思想



二分查找算法的前置条件是,一个已经排序好的序列(在本篇文章中为了说明问题的方便,假设这个序列是升序排列的),这样在查找所要查找的元素时,首先与序列中间的元素进行比较,如果大于这个元素,就在当前序列的后半部分继续查找,如果小于这个元素,就在当前序列的前半部分继续查找,直到找到相同的元素,或者所查找的序列范围为空为止.

用伪代码来表示, 二分查找算法大致是这个样子的:

left = 0, right = n -1

while (left <= right)

mid = (left + right) / 2

case

x[mid] < t: left = mid + 1;

x[mid] = t: p = mid; break;

x[mid] > t: right = mid -1;

return -1;

第一个正确的程序

根据前面给出的算法思想和伪代码, 我们给出第一个正确的程序,但是,它还有一些小的问题,后面会讲到

int search(int array[], int n, int v)

{

int left, right, middle;

left = 0, right = n - 1;

while (left <= right)

{

middle = (left + right) / 2;

if (array[middle] > v)

{

right = middle;

}

else if (array[middle] < v)

{

left = middle;

}

else

{

return middle;

}

}

return -1;

}

下面,讲讲在编写二分查找算法时可能出现的一些问题.

边界错误造成的问题

二分查找算法的边界,一般来说分两种情况,一种是左闭右开区间,类似于[left, right),一种是左闭右闭区间,类似于[left, right].需要注意的是, 循环体外的初始化条件,与循环体内的迭代步骤, 都必须遵守一致的区间规则,也就是说,如果循环体初始化时,是以左闭右开区间为边界的,那么循环体内部的迭代也应该如此.如果两者不一致,会造成程序的错误.比如下面就是错误的二分查找算法:

int search_bad(int array[], int n, int v)
{
int left, right, middle;

left = 0, right = n;

while (left < right)
{
middle = (left + right) / 2;
if (array[middle] > v)
{
right = middle - 1;
}
else if (array[middle] < v)
{
left = middle + 1;
}
else
{
return middle;
}
}

return -1;
}
这个算法的错误在于, 在循环初始化的时候,初始化right=n,也就是采用的是左闭右开区间,而当满足array[middle] > v的条件是, v如果存在的话应该在[left, middle)区间中,但是这里却把right赋值为middle - 1了,这样,如果恰巧middle-1就是查找的元素,那么就会找不到这个元素.

下面给出两个算法, 分别是正确的左闭右闭和左闭右开区间算法,可以与上面的进行比较:

int search2(int array[], int n, int v)
{
int left, right, middle;

left = 0, right = n - 1;

while (left <= right)
{
middle = (left + right) / 2;
if (array[middle] > v)
{
right = middle - 1;
}
else if (array[middle] < v)
{
left = middle + 1;
}
else
{
return middle;
}
}

return -1;
}

int search3(int array[], int n, int v)
{
int left, right, middle;

left = 0, right = n;

while (left < right)
{
middle = (left + right) / 2;

if (array[middle] > v)
{
right = middle;
}
else if (array[middle] < v)
{
left = middle + 1;
}
else
{
return middle;
}
}

return -1;
}
死循环

上面的情况还只是把边界的其中一个写错, 也就是右边的边界值写错, 如果两者同时都写错的话,可能会造成死循环,比如下面的这个程序:

int search_bad2(int array[], int n, int v)
{
int left, right, middle;

left = 0, right = n - 1;

while (left <= right)
{
middle = (left + right) / 2;
if (array[middle] > v)
{
right = middle;
}
else if (array[middle] < v)
{
left = middle;
}
else
{
return middle;
}
}

return -1;
}

这个程序采用的是左闭右闭的区间.但是,当array[middle] > v的时候,那么下一次查找的区间应该为[middle + 1, right], 而这里变成了[middle, right];当array[middle] < v的时候,那么下一次查找的区间应该为[left, middle - 1], 而这里变成了[left, middle].两个边界的选择都出现了问题, 因此,有可能出现某次查找时始终在这两个范围中轮换,造成了程序的死循环.

溢出

前面解决了边界选择时可能出现的问题, 下面来解决另一个问题,其实这个问题严格的说不属于算法问题,不过我注意到很多地方都没有提到,我觉得还是提一下比较好.

在循环体内,计算中间位置的时候,使用的是这个表达式:middle = (left + right) / 2;

假如,left与right之和超过了所在类型的表示范围的话,那么middle就不会得到正确的值.

所以,更稳妥的做法应该是这样的:middle = left + (right - left) / 2;

更完善的算法

前面我们说了,给出的第一个算法是一个"正确"的程序, 但是还有一些小的问题.

首先, 如果序列中有多个相同的元素时,查找的时候不见得每次都会返回第一个元素的位置, 比如考虑一种极端情况:序列中都只有一个相同的元素,那么去查找这个元素时,显然返回的是中间元素的位置.

其次, 前面给出的算法中,每次循环体中都有三次情况,两次比较,有没有办法减少比较的数量进一步的优化程序?

<<编程珠玑>>中给出了解决这两个问题的算法,结合前面提到溢出问题我对middle的计算也做了修改:

int search4(int array[], int n, int v)
{
int left, right, middle;

left = -1, right = n;

while (left + 1 != right)
{
middle = left + (right - left) / 2;

if (array[middle] < v)
{
left = middle;
}
else
{
right = middle;
}
}

if (right >= n || array[right] != v)
{
right = -1;
}

return right;
}

这个算法是所有这里给出的算法中最完善的一个,正确,精确且效率高.

参考资料

1.<<编程珠玑>>

2.wiki上关于二分查找的说明:http://en.wikipedia.org/wiki/Binary_search
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐