二分查找法的实现和应用(进阶篇)
在《二分查找法的实现和应用汇总》中,我介绍了二分查找法的基本应用,不过在面试的准备过程中,我还碰到了更多对于二分查找法的更进一步的使用。其实在《二分查找法的实现和应用汇总》的最后,我已经介绍了一个非常规的使用,也就是基于“轮转后的有序数组(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)的级别。
- Find the k-th Smallest Element in the Union of Two Sorted Arrays – LeetCode
- Binary Search to Compute Square root (Java) – StackOverflow
- Median of two sorted arrays – GeeksforGeeks
- Median of Two Sorted Arrays –LeetCode
- Microsoft Interview Question – CareerCup
- Find the largest k numbers in k arrays stored across k machines – StackOverflow