二分查找的解读汇总(一)
二分查找,最基本的算法之一,也是面试中常被考察的重点,因为基本的算法最能反映出一个人的基础是否扎实。本文对二分查找相关题目做一个总结。
在学习算法的过程中,我们除了要了解某个算法的基本原理、实现方式,更重要的一个环节是利用big-O理论来分析算法的复杂度。在时间复杂度和空间复杂度之间,我们又会更注重时间复杂度。
时间复杂度按优劣排差不多集中在:
O(1), O(log n), O(n), O(n log n), O(n2), O(nk), O(2n)
到目前为止,似乎我学到的算法中,时间复杂度是O(log n),好像就数二分查找法,其他的诸如排序算法都是 O(n log n)或者O(n2)。但是也正是因为有二分的 O(log n), 才让很多 O(n2)缩减到只要O(n log n)。
关于二分查找法
二分查找法主要是解决在“一堆数中找出指定的数”这类问题。
而想要应用二分查找法,这“一堆数”必须有一下特征:
存储在数组中
有序排列
所以如果是用链表存储的,就无法在其上应用二分查找法了。至于是顺序递增排列还是递减排列,数组中是否存在相同的元素都不要紧。不过一般情况,我们还是希望并假设数组是递增排列,数组中的元素互不相同。
二分查找法的基本实现
二分查找法在算法家族大类中属于“分治法”,分治法基本都可以用递归来实现的,二分查找法的递归实现如下:
int bsearch(int array[], int low, int high, int target)
{
if (low > high) return -1;
int mid = (low + high)/2;
if (array[mid]> target)
return binarysearch(array, low, mid -1, target);
if (array[mid]< target)
return binarysearch(array, mid+1, high, target);
//if (midValue == target)
return mid;
}
不过所有的递归都可以自行定义stack来解递归,所以二分查找法也可以不用递归实现,而且它的非递归实现甚至可以不用栈,因为二分的递归其实是尾递归,它不关心递归前的所有信息。
int bsearchWithoutRecursion(int array[], int low, int high, int target)
{
while(low <= high)
{
int mid = (low + high)/2;
if (array[mid] > target)
high = mid - 1;
else if (array[mid] < target)
low = mid + 1;
else //find the target
return mid;
}
//the array does not contain the target
return -1;
}
只用小于比较(<)实现二分查找法
在前面的二分查找实现中,我们既用到了小于比较(<)也用到了大于比较(>),也可能还需要相等比较(==)。而实际上我们只需要一个小于比较(<)就可以。因为错逻辑上讲a>b和b<a应该是有相当的逻辑值;而a==b则是等价于 !((a<b)||(b<a)),也就是说a既不小于b,也不大于b。
当然在程序的世界里, 这种关系逻辑其实并不是完全正确。另外,C++还允许对对象进行运算符的重载,因此开发人员完全可以随意设计和实现这些关系运算符的逻辑值。
不过在整型数据面前,这些关系运算符之间的逻辑关系还是成立的,而且在开发过程中,我们还是会遵循这些逻辑等价关系来重载关系运算符。
干嘛要搞得那么羞涩,只用一个关系运算符呢?因为这样可以为二分查找法写一个template(模板),又能减少对目标对象的要求。模板会是这样的:
template <typename T, typename V>
inline int BSearch(T& array, int low, int high, V& target)
{
while(!(high < low))
{
int mid = (low + high)/2;
if (target < array[mid])
high = mid - 1;
else if (array[mid] < target)
low = mid + 1;
else //find the target
return mid;
}
//the array does not contain the target
return -1;
}
我们只需要求target的类型V有重载小于运算符就可以。而对于V的集合类型T,则需要有[]运算符的重载。当然其内部实现必须是O(1)的复杂度,否则也就失去了二分查找的效率。
用二分查找法找寻边界值
之前的都是在数组中找到一个数要与目标相等,如果不存在则返回-1。我们也可以用二分查找法找寻边界值,也就是说在有序数组中找到“正好大于(小于)目标数”的那个数。
用数学的表述方式就是:
在集合中找到一个大于(小于)目标数t的数x,使得集合中的任意数要么大于(小于)等于x,要么小于(大于)等于t。
举例来说:
给予数组和目标数
int array = {2, 3, 5, 7, 11, 13, 17};
int target = 7;
那么上界值应该是11,因为它“刚刚好”大于7;下界值则是5,因为它“刚刚好”小于7。
用二分查找法找寻上界
//Find the fisrt element, whose value is larger than target, in a sorted array
int BSearchUpperBound(int array[], int low, int high, int target)
{
//Array is empty or target is larger than any every element in array
if(low > high || target >= array[high]) return -1;
int mid = (low + high) / 2;
while (high > low)
{
if (array[mid] > target)
high = mid;
else
low = mid + 1;
mid = (low + high) / 2;
}
return mid;
}
与精确查找不同之处在于,精确查找分成三类:大于,小于,等于(目标数)。而界限查找则分成了两类:大于和不大于。
如果当前找到的数大于目标数时,它可能就是我们要找的数,所以需要保留这个索引,也因此if (array[mid] > target)时 high=mid; 而没有减1。
用二分查找法找寻下界
//Find the last element, whose value is less than target, in a sorted array
int BSearchLowerBound(int array[], int low, int high, int target)
{
//Array is empty or target is less than any every element in array
if(high < low || target <= array[low]) return -1;
int mid = (low + high + 1) / 2; //make mid lean to large side
while (low < high)
{
if (array[mid] < target)
low = mid;
else
high = mid - 1;
mid = (low + high + 1) / 2;
}
return mid;
}
下届寻找基本与上届相同,需要注意的是在取中间索引时,使用了向上取整。若同之前一样使用向下取整,那么当low == high-1,而array[low] 又小于 target时就会形成死循环。因为low无法往上爬超过high。
这两个实现都是找严格界限,也就是要大于或者小于。如果要找松散界限,也就是找到大于等于或者小于等于的值(即包含自身),只要对代码稍作修改就好了:
去掉判断数组边界的等号:
target >= array[high]改为 target > array[high]
在与中间值的比较中加上等号:
array[mid] > target改为array[mid] >= target
用二分查找法找寻区域
之前我们使用二分查找法时,都是基于数组中的元素各不相同。假如存在重复数据,而数组依然有序,那么我们还是可以用二分查找法判别目标数是否存在。不过,返回的index就只能是随机的重复数据中的某一个。
此时,我们会希望知道有多少个目标数存在。或者说我们希望数组的区域。
结合前面的界限查找,我们只要找到目标数的严格上届和严格下届,那么界限之间(不包括界限)的数据就是目标数的区域了。
//return type: pair<int, int>
//the fisrt value indicate the begining of range,
//the second value indicate the end of range.
//If target is not find, (-1,-1) will be returned
pair<int, int> SearchRange(int A[], int n, int target)
{
pair<int, int> r(-1, -1);
if (n <= 0) return r;
int lower = BSearchLowerBound(A, 0, n-1, target);
lower = lower + 1; //move to next element
if(A[lower] == target)
r.first = lower;
else //target is not in the array
return r;
int upper = BSearchUpperBound(A, 0, n-1, target);
upper = upper < 0? (n-1):(upper - 1); //move to previous element
//since in previous search we had check whether the target is
//in the array or not, we do not need to check it here again
r.second = upper;
return r;
}
它的时间复杂度是两次二分查找所用时间的和,也就是O(log n) + O(log n),最后还是O(log n)。
在轮转后的有序数组上应用二分查找法
之前我们说过二分法是要应用在有序的数组上,如果是无序的,那么比较和二分就没有意义了。不过还有一种特殊的数组上也同样可以应用,那就是“轮转后的有序数组(Rotated Sorted Array)”。它是有序数组,取期中某一个数为轴,将其之前的所有数都轮转到数组的末尾所得。比如{7, 11, 13, 17, 2, 3, 5}就是一个轮转后的有序数组。非严格意义上讲,有序数组也属于轮转后的有序数组——我们取首元素作为轴进行轮转。
下边就是二分查找法在轮转后的有序数组上的实现(假设数组中不存在相同的元素)
int SearchInRotatedSortedArray(int array[], int low, int high, int target)
{
while(low <= high)
{
int mid = (low + high) / 2;
if (target < array[mid])
if (array[mid] < array[high])//the higher part is sorted
high = mid - 1; //the target would only be in lower part
else //the lower part is sorted
if(target < array[low])//the target is less than all elements in low part
low = mid + 1;
else
high = mid - 1;
else if(array[mid] < target)
if (array[low] < array[mid])// the lower part is sorted
low = mid + 1; //the target would only be in higher part
else //the higher part is sorted
if (array[high] < target)//the target is larger than all elements in higher part
high = mid - 1;
else
low = mid + 1;
else //if(array[mid] == target)
return mid;
}
return -1;
}
对比普通的二分查找法,为了确定目标数会落在二分后的那个部分,我们需要更多的判定条件。但是我们还是实现了O(log n)的目标。
二分法的解读汇总(二)
基于“轮转后的有序数组(Rotated Sorted Array)”检查某一个数是否存在。
找到轮转后的有序数组中第K小的数
对于普通的有序数组来说,这个问题是非常简单的,因为数组中的第K-1个数(即A[K-1])就是所要找的数,时间复杂度是O(1)常量。但是对于轮转后的有序数组,在不知道轮转的偏移位置,我们就没有办法快速定位第K个数了。
不过我们还是可以通过二分查找法,在log(n)的时间内找到最小数的在数组中的位置,然后通过偏移来快速定位任意第K个数。当然此处还是假设数组中没有相同的数,原排列顺序是递增排列。
在轮转后的有序数组中查找最小数的算法如下:
//return the index of the min value in the Rotated Sorted Array, whose range is [low, high]
int findIndexOfMinVaule(int A[], int low, int high)
{
if (low > high) return -1;
while (low < high) {
int mid = (low + high)/2;
if (A[mid] > A[high])
low = mid +1;
else
high = mid;
}
//at this point, low is equal to high
return low;
}
接着基于此结果进行偏移,再基于数组长度对偏移后的值取模,就可以找到第K个数在数组中的位置了:
int findKthElement(int A[], int m, int k)
{
if (k > m) return -1;
int base = findIndexOfMinVaule(A, 0, m-1);
int index = (base+k-1)%m;
return index;
}
找出两个有序数组中第K个数
之前我谈到,对于一个有序数组来说,找到第K个数是非常简单的,假如我们有两个有序的数组,希望从中找到第K小的数呢?
这个问题最直观的解决方法就是像归并排序中的归并算法那样,从头开始比较,找到那第K小的数,那么平均时间复杂度就是O(m+n),其中m,n分别是两个数组的长度。
不过通过二分查找法,得到一个复杂度为O(log(m+n))的算法(很多地方说这个算法的复杂度是O(log m + log n),我没有进行准确的演算和统计,但是个人认为O(log(m+n))才对)。先来看算法代码,然后来分析。
这里对于参数的假设如下:数组的索引是以0为基数并且m+n > 0;k是以1为基数并且1<=k <= m+n,两个数组的集合没有重复元素。在这样的假设下,暗示我们总是可以找到那个第k个数。
//return the value of kth element in union of two sorted array
int findKthElement(int A[], int m, int B[], int n, int k) {
int i = int(double(m)/(m+n)*(k -1));
int j = (k-1) - i;
//A[i] or B[j] is the Kth element, return it
if ((j <= 0 || B[j-1] < A[i]) && (j >= n || A[i] < B[j]))
return A[i];
if ((i <= 0 || A[i-1] < B[j]) && (i >= m || B[j] < A[i]))
return B[j];
//A[i] is too small, get rid of lower part of A and higher part of B
if (0 < j && A[i] < B[j-1])
return findKthElement(A+i+1, m-i-1, B, j, k-i-1);
//B[j] is too small, get rid of higher part of A and lower part of B
else //if(i > 0 && B[j] < A[i-1])
return findKthElement(A, i, B+j+1, n-j-1, k-j-1);
}
个人认为这里面最繁琐的是数组索引因为不同的基数而引起的转换问题。比如里面的i和j,很显然i+j == k-1。而实际上,数组A中0-i的元素加上数组B中0-j的元素一共有(i+1)+(j+1) == k+1个数。
因为数组A和B都是有序的,所以我们知道A[i] > A[0…i-1]都大,B[j] > B[0…j-1]。
进一步,如果B[j-1] < A[i] < B[j],那么A[j]就正好大于 A中前i个数B中的前j个数也就是总共k-1个数,于是A[j]就是我们要找的目标数;
反之如果A[i-1] < B [j] < A[i],那么B[j]就成立我们要找的数。
万一A[i]和B[j]都不是我们要找的数,要么A[i]比B[j-1]小,要么B[j]比A[i-1]小。
假如是A[i]比B[j-1]小,那么我们可以分析推测出来,A[0…i]都太小而不能成为我们要找的目标,而B[j…n-1]又太大,也不可能是我们要找的目标。所以我们就可以开始二分查找的第二步操作——剪枝,让我们的范围缩小。而因为我们去除了A中较小的部分,所以我们要查找的数也从第k个变成了第(k-i-1)个。
对于B[j]比A[i-1]小的情况也是一样的。
从递归调用的地方,我们看出k总是在不断减小的,简单分析更可以知道,如果k是1的话就会停止递归。(这也是为什么我会认为总的时间复杂度是O(log(m+n))的地方。)所以位于i和j的值选取就变得比较关键。
一开始,我设定 int i = k > m? m-1: k-1;也就是让i相对比较大。虽然平均效率上差不多,但是如果剪枝时总是去除B的前段的话,k减小的速度就比较慢。例如最坏的情况:A中所有的数比B中都要大,而我们正好要找第n个数(也就是B中最后一个数),于是每次递归k都只减小了1。此时的复杂度就成了O(n)。
按数组大小来分配i和j可以做到对于任意的案例k的减少都是比较平均的。
对于那些边界检查,在之前的假设之下其实只是会有相等的情况出现,不过检查区域并不会比检查点糟糕。
进一步,其实剪枝时的(0 < j && A[i] < B[j-1]) 并不需要去检查 0 < j,因为j为0的情况只可能出现在n为0 (即B是一个空数组)。而此时,A[i]已是我们要找的目标而被返回了。写上(0 < j && A[i] < B[j-1]) 只是暗示它其实是((j <= 0 || B[j-1] < A[i])的取反。
整数的求平方根函数
这个其实也是毕竟常见的面试问题,要求不调用math库,实现对整数的sqrt方法,返回值只需要是整数。
其实这个问题用数学的表达方式就是:对于非负整数x,找出另一个非负整数n,其中n满足 n2 ≤ x < (n+1)2。
所以最直接的方法就是从0到X遍历过去直到找到满足上述条件的n。这个算法的复杂度自然是O(n)。
仔细想想,其实我们要找的数是在0和X之间,而他正巧可以视为一个有序的数组。似乎有可以运用二分查找法的可能。再回想二分查找法是要找到满足“与目标数相等”这一条件的数,而这里同样也是要找满足一定条件的数。所以我们就可以用二分法来解这个问题了,让复杂度降为O(log n)。
为方便起见,我假设传入的参数是非负的,因此使用unsigned int。
unsigned int sqrt(unsigned int x)
{
//no value should larger than max*max, otherwise it would be overflow
unsigned int max = (1 << (sizeof(x)/2*8))-1; //65535
if (max*max < x)return max;
unsigned int low = 0;
unsigned int high = max-1;
unsigned int mid = 0;
while (1) {
mid = (low+high)/2;
if (x < mid * mid)
high = mid-1;
else if((mid+1)*(mid+1) <= x)
low = mid+1;
else //if(mid * mid <= x && x < (mid+1)*(mid+1))
break;
}
return mid;
}
当然在这个问题上,还有其它好的算法,这里只是想借次来指出二分查找法的应用。
二分查找法扩展
之前已经介绍了二分查找法的不少内容。下边,我还想再讲讲碰到的一些面试题目中可用二分查找法的题目。不过这些都是基于“找出两个有序数组中第K个数”的扩展问题。
找出两个有序数组中的中数
其实这个问题还是假设数组中不会有相同的值,直观的解法也是O(m+n)的遍历。网上有很多的解答方法(可见文后引用),不过都存在一定问题。该问题主要出在中数(Median)的计算方式上:
如果数组有个数是奇数,那么中数的值就是有序时处于中间的数;如果数组个数是偶数的,那么就是有序时中间两个数平均时。
网上的解法,有些假定两个数组相同长度,因此不具有一般性;另一种解法,则是针对两种情况做出不同的解法。
其实参照“找出两个有序数组中第K个数”,我们又已经两个数组的长度,其实就已经知道我们要找的数其实是第(m+n)/2 + 1个数(奇数时)或者是第(m+n)/2 和第(m+n)/2 + 个数(偶数时)。即使是调用两次,复杂度也依然是O(log(m+n))。
找出多个服务器中第K个数
问题的描述是这样的:在多个服务器上每个服务器都有一批数,每个服务器直接并不知道对方的内容,你有一个主机可以联到每个服务器上去请求数据,现在要求从这些服务器的数据中找出第K小的数。
一个常用的方法就是在主机上创建一个存储K个数据堆结构然后获取每个服务器的数据并与堆中数据进行比较和交换。最后我们就可以得到所要的数据。
该问题其实可视为是“找出两个有序数组中第K个数”的多服务器大规模变体。一般诸如Google,Facebook,Microsoft这样的大公司会比较喜欢问(我有个朋友面试FB时就问到了这个问题)。
问题并没有表明每个服务器下的数是否有序的,不过我们可以让每个服务器对自己的数据进行排序,这是同步的,所以最多是O(n log n)的时间。在主机上则维护一个K大的有序数组,然后于每个服务器之间进行进行“找出两个有序数组中第K个数”,之后保留下新的前K个数。其复杂度应该是O(n log k),其中n是服务器数量,k是所求目标。加上服务器排序时间,基本可以维持在O(n log n)的级别。
二分查找法的缺陷
二分查找法的O(log n)让它成为十分高效的算法。不过它的缺陷却也是那么明显的。就在它的限定之上:
必须有序,我们很难保证我们的数组都是有序的。当然可以在构建数组的时候进行排序,可是又落到了第二个瓶颈上:它必须是数组。
数组读取效率是O(1),可是它的插入和删除某个元素的效率却是O(n)。因而导致构建有序数组变成低效的事情。
解决这些缺陷问题更好的方法应该是使用二叉查找树了,最好自然是自平衡二叉查找树了,自能高效的(O(n log n))构建有序元素集合,又能如同二分查找法一样快速(O(log n))的搜寻目标数。