二分查找
- 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
- 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
- 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。
二分查找
二分查找算法基本思想
二分查找算法的前置条件是,一个已经排序好的序列(在本篇文章中为了说明问题的方便,假设这个序列是升序排列的),这样在查找所要查找的元素时,首先与序列中间的元素进行比较,如果大于这个元素,就在当前序列的后半部分继续查找,如果小于这个元素,就在当前序列的前半部分继续查找,直到找到相同的元素,或者所查找的序列范围为空为止.
用伪代码来表示, 二分查找算法大致是这个样子的:
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)//这个循环维持的条件是left<right && array[left]<v<=array[right],所以
到最后的时候,
{//如果可以找到目标,则只剩下两个数,并且满足 array[left]<v<=array[right],是要查找的数是right
middle = left + (right -left) / 2;
if (array[middle] < v)//必须保证array[left]<v<=array[right],所以left = middle;
{//如果left =middle+1,则有可能出现array[left]<=v的情况
left = middle;
}
else
{
right = middle;
}
}
if (right >= n || array[right] != v)
{
right = -1;
}
return right;
}
这个算法是所有这里给出的算法中最完善的一个,正确,精确且效率高.
但是这个算法的还是不能很好的理解
可以用下面的算法,可以找出满足条件的数
[cpp]view plaincopy
1.int Bi_Search(int a[],int n,int b)//
2.{//返回等于b的第一个
3.if(n==0)
4.return -1;
5.int low = 0;
6.int high = n-1;
7.int last = -1;//用last记录上一次满足条件的下标
8.while (low<=high)
9. {
10.int mid = low +(high-low)/2;
11.if (a[mid]==b)
12. {
13. last = mid;
14. high = mid -1;
15. }
16.else if(a[mid]>b)
17. high = mid -1;
18.else
19. low = mid +1;
20. }
21.
22.return last;
23.
24.}
25.int Bi_Search1(int a[],int n,int b)//大于b的第一个数
26.{
27.if(n<=0)
28.return -1;
29.int last = -1;
30.int low = 0;
31.int high = n-1;
32.while (low<=high)
33. {
34.int mid = low +(high - low)/2;
35.if(a[mid]>b)
36. {
37. last = mid;
38. high = mid -1;
39. }
40.else if (a[mid]<=b)
41. {
42. low =mid +1;
43. }
44. }
45.
46.return last;
47.}
查找(二):二分查找
一、二分查找(Binary Search)
二分查找又称折半查找,它是一种效率较高的查找方法。
二分查找要求:线性表是有序表,即表中结点按关键字有序,并且要用向量作为表的存储结构。
不妨设有序表是递增有序的。
二、二分查找的基本思想
二分查找的基本思想是:(设R[low..high]是当前的查找区间)
(1)首先确定该区间的中点位置:
(2)然后将待查的K值与R[mid].key比较:若相等,则查找成功并返回此位置,否则须确定新的查找区间,继续二分查找,具体方法如下:
①若R[mid].key>K,则由表的有序性可知R[mid..n].keys均大于K,因此若表中存在关键字等于K的结点,则该结点必定是在位置mid左边的子表R[1..mid-1]中,故新的查找区间是左子表R[1..mid-1]。
②类似地,若R[mid].key<K,则要查找的K必在mid的右子表R[mid+1..n]中,即新的查找区间是右子表R[mid+1..n]。
下一次查找是针对新的查找区间进行的。
因此,从初始的查找区间R[1..n]开始,每经过一次与当前查找区间的中点位置上的结点关键字的比较,就可确定查找是否成功,不成功则当前的查找区间就缩小一半。
这一过程重复直至找到关键字为K的结点,或者直至当前的查找区间为空(即查找失败)时为止。
三、二分查找算法
public int BinSearch(SeqList<int> R, int Key)
{
int low = 0, high = R.GetLength() - 1, mid;//置当前查找区间上、下界的初值
while (low <= high)
{ //当前查找区间R[low..high]非空
mid = low + ((high - low) / 2);
if (R.Data[mid] == Key) return mid; //查找成功返回
if (R.Data[mid] > Key)
high = mid - 1; //继续在R[low..mid-1]中查找
else
low = mid + 1; //继续在R[mid+1..high]中查找
}
return -1; //当low>high时表示查找区间为空,查找失败
}
四、二分查找判定树
二分查找过程可用二叉树来描述:把当前查找区间的中间位置上的结点作为根,左子表和右子表中的结点分别作为根的左子树和右子树。
由此得到的二叉树,称为描述二分查找的判定树(Decision Tree)或比较树(Comparison Tree)。
注意:
判定树的形态只与表结点个数n相关,而与输入实例中R[1..n].keys的取值无关。
【例】具有11个结点的有序表可用下图所示的判定树来表示。
(1)二分查找判定树的组成
①圆结点即树中的内部结点。
树中圆结点内的数字表示该结点在有序表中的位置。
②外部结点:圆结点中的所有空指针均用一个虚拟的方形结点来取代,即外部结点。
③树中某结点i与其左(右)孩子连接的左(右)分支上的标记"<"、"("、">"、")"表示:当待查关键字K<R[i].key(K>R[i].key)时,应走左(右)分支到达i的左(右)孩子,将该孩子的关键字进一步和K比较。
若相等,则查找过程结束返回,否则继续将K与树中更下一层的结点比较。
(2)二分查找判定树的查找
二分查找就是将给定值K与二分查找判定树的根结点的关键字进行比较。
若相等,成功。
否则若小于根结点的关键字,到左子树中查找。
若大于根结点的关键字,则到右子树中查找。
【例】对于有11个结点的表,若查找的结点是表中第6个结点,则只需进行一次比较;若查找的结点是表中第3或第9个结点,则需进行二次比较;找第1,4,7,10个结点需要比较三次;找到第2,5,8,11个结点需要比较四次。
由此可见,成功的二分查找过程恰好是走了一条从判定树的根到被查结点的路径,经历比较的关键字次数恰为该结点在树中的层数。
若查找失败,则其比较过程是经历了一条从判定树根到某个外部结点的路径,所需的关键字比较次数是该路径上内部结点的总数。
【例】待查表的关键字序列为:(05,13,19,21,37,56,64,75,80,88,92),若要查找K=85的记录,所经过的内部结点为6、9、10,最后到达方形结点"9-10",其比较次数为3。
实际上方形结点中"i-i+1"的含意为被查找值K是介于R[i].key和R[i+1].key之间的,即R[i].key<K<R[i+1].key。
(3)二分查找的平均查找长度
设内部结点的总数为n=2h-1,则判定树是深度为h=lg(n+1)的满二叉树(深度h不计外部结点)。
树中第k层上的结点个数为2k-1,查找它们所需的比较次数是k。
因此在等概率假设下,二分查找成功时的平均查找长度为:
ASL bn≈lg(n+1)-1
二分查找在查找失败时所需比较的关键字个数不超过判定树的深度,在最坏情况下查找
成功的比较次数也不超过判定树的深度。
即为:
二分查找的最坏性能和平均性能相当接近。
五、二分查找的优点和缺点
虽然二分查找的效率高,但是要将表按关键字排序。
而排序本身是一种很费时的运算。
既使采用高效率的排序方法也要花费O(nlgn)的时间。
二分查找只适用顺序存储结构。
为保持表的有序性,在顺序结构里插入和删除都必须移动大量的结点。
因此,二分查找特别适用于那种一经建立就很少改动、而又经常需要查找的线性表。
对那些查找少而又经常需要改动的线性表,可采用链表作存储结构,进行顺序查找。
链表上无法实现二分查找。
原地归并排序
原地归并算法
归并排序算法(mergesort)是将一个序列划分为同样大小的两个子序列,然后对两个子序列分别进行排序,最后进行合并操作,将两个子序列合成有序的序列.在合成的过程中,一般的实现都需要开辟一块与原序列大小相同的空间,以进行合并操作,归并排序算法的示例在这里.
这里介绍一种不需要开辟新的空间就可以进行归并操作的算法.算法的核心部分是以下代码:
1/**
2* 算法: 合并二已排序的连续序列
3**/
4 template<typename T>
5void t_merge( T& v, size_t size, size_t pos )
6 {
7 size_t fir = 0, sec = pos;
8while ( fir < sec && sec < size )
9 {
10while ( fir < sec && v[fir] <= v[sec] ) fir++;
11 size_t maxMove = 0;
12while ( sec < size && v[fir] > v[sec] ) maxMove++, sec++;
13 t_exchange( &v[fir], sec - fir, sec - fir - maxMove );
14 fir += maxMove;
15 }
16 }
其中T是一个数组, size是数组尺寸, pos是归并划分的位置.也就是说[0,pos)和[pos, size)都分别是有序的.比如对序列1, 3, 5, 7, 2, 4, 6, 8进行归并操作, 此时size=8, pos = 4.
以<<算法导论>>中介绍的通过循环不变量的方法证明算法的正确性,在这个算法中, 循环不变量为[fir, sec)中的元素都是有序的:
1) 初始:此时fir = 0, sec = pos, 以前面介绍的函数参数的说明来看,满足循环不变量.
2) 迭代:来看看循环做了些什么操作.行10进行的操作为, 只要满足fir元素不大于sec元素, fir就一直递增;行12进行的操作是只要满足fir大于sec, sec就一直递增, 同时递增maxMove计数.因此, 进行完前面两个步骤之后, fir所指元素一定小于sec以及其后的所有元素.而位于sec之前的第二个子序列中的元素, 一定小于fir.因此, [sec-maxMove, sec)z中的元素小于所有[fir, sec - 1)的元素.通过调用t_exchange函数将位于[sec-maxMove, sec)中的元素"旋转"到fir之前.
也就是说, 这个过程在第二个已经排序好的子序列中寻找在它之内的小于目前第一个已经排序好的子序列的序列, 将它"旋转"到前面.
以序列 1, 3, 5, 7, 2, 4, 6, 8为例, 此时fir=1也就是指向3, sec=5也就是指向4, maxMove=1, 通过调用t_exchange函数之后将[sec-maxMove, sec)即[4,5)中的元素也就是2"旋转"到子序列3,5,7之前,于是该循环结束之后序列变为1,2,3,5,7,4,6,8, 此时fir=2, sec =5, 满足循环不变量.
3) 终止: 当循环终止时, fir或者sec之一超过了数组的尺寸, 显而易见, 此时序列变成了有序的.
完整的算法如下所示, 需要特别说明的是, 这段代码不是我想出来的, 原作者在这里:
#include <stdio.h>
#include <iostream>
using namespace std;
//int array[] = {1, 3, 5, 7, 2, 4, 6, 8};
int array[] = {3,5,7,8,1,2,4,6};
void display(int array[], int n)
{
for (int i = 0; i < n; ++i)
{
printf("%d ", array[i]);
}
printf("\n");
}
/**
* 算法: 交换二对象
**/
template<typename T>
void t_swap( T& v1, T& v2 )
{
T t = v1; v1 = v2; v2 = t;
}
/**
* 算法: 反转序列
**/
template<typename T>
void t_reverse( T* v, size_t size )
{
size_t s = 0, e = size-1;
while( s < e && s < size && e > 0 )
t_swap( v[s++], v[e--] );
}
/**
* 算法: 手摇算法,从指定位置旋转序列(见编程珠玑第二章)
**/
template<typename T>
void t_exchange( T* v, size_t size, size_t n )
{
t_reverse( v, n );
t_reverse( v + n, size - n );
t_reverse( v, size );
}
/**
* 算法: 合并二已排序的连续序列
**/
template<typename T>
void t_merge( T& v, size_t size, size_t pos )
{
size_t fir = 0, sec = pos;
while ( fir < sec && sec < size )
{
while ( fir < sec && v[fir] <= v[sec] ) fir++;
size_t maxMove = 0;
while ( sec < size && v[fir] > v[sec] ) maxMove++, sec++; t_exchange( &v[fir], sec - fir, sec - fir - maxMove );
fir += maxMove;
display(array, sizeof(array)/sizeof(int));
}
}
/**
* 算法: 归并排序
**/
template<typename T>
void t_merge_sort( T* v, size_t size )
{
if ( size <= 1 ) return;
t_merge_sort( v, size/2 );
t_merge_sort( v + size/2, size - size/2 );
t_merge( v, size, size/2 );
}
int main()
display(array, sizeof(array)/sizeof(int));
t_merge(array, sizeof(array) / sizeof(int), (sizeof(array)/sizeof(int))/2);
//t_merge_sort(array, sizeof(array) / sizeof(int));
display(array, sizeof(array)/sizeof(int));
return 0;
}。