引子
如果想要找到一个有重复元素的有序数列中第一个大于等于x的元素的下标,只要将最基本的二分查找的代码稍作修改即可。而且可以总结出一个行之有效能够解决一类问题的模板。我在准备算法考试的过程中多次使用了这个模板,但是直到今天因为出现了超时的问题我才发现原来这么短短几行代码还有一些值得注意的细节。在详细分析之前首先贴上一段用于解决一开始提到的问题的代码。
int lower_bound(int l, int r, int A[], int x) {
if (l > r) return -1; // 参数非法
while(l < r) {
int mid = (r - l) / 2 + l;
if (A[mid] >= x)
r = mid;
else
l = mid + 1;
}
return l;
}
简单的分析
假设整个数组是非降的。
当
A[mid] > x
的时候,说明待查元素可能在mid
的左边,也可能就在mid
处,此时数组中没有x
这个元素;
当A[mid] == x
的时候,mid
可能就是我们要查找的位置,也可能不是第一个,所以待查元素要么就 在mid
处,要么就在mid
的左边;
当A[mid] < x
的时候,待查元素一定在mid
的右边;
综合以上三种情况,当A[mid] >= x
的时候,我们令r = mid
,当A[mid] < x
时,令l = mid + 1
;
细节1
我们一开始讨论的问题是“如何找到一个有重复元素的有序数列中第一个大于等于x的元素的下标?”,如果把这个问题修改成“如何找到一个有重复元素的有序数列中最后一个小于x的元素的下标?”,代码要做哪些修改呢?我们简单的分析一下,思路跟之前类似。
如果
A[mid] > x
,那么待查元素一定在mid
的左边;
如果A[mid] == x
,那么待查元素也一定在mid
的左边;
如果A[mid] < x
,那么待查元素可能在mid
处或者在mid的右边。
因此当A[mid] >= x
的时候我们令r = mid - 1
,当A[mid] < x
的时候令l = mid
;也就是下面这个样子:
int lower_bound(int l, int r, int A[], int x) {
if (l > r) return -1; // 查找失败
while(l < r) {
int mid = (r - l) / 2 + l;
//与之前有区别的只有下面这一个条件语句
if (A[mid] >= x)
r = mid - 1;
else
l = mid;
}
return l;
}
这段代码实际上存在着导致超时的隐患。与之前的代码相比,区别仅在于r = mid
变成了l = mid
,可是在碰到特殊数据的时候情况完全不同了。当l + 1 = r的时候,l + r一定是一个奇数.当A[mid] < x
成立时因为整数除法的关系有l = mid = (r + l) / 2,问题就在这里,这一轮循环l
和r
都没有改变,那么接下来的循环会一直这么下去也就是陷入了死循环,从而导致超时。所以在编写类似的代码的时候一定不能够让l = mid
。
为了解决这个问题,我们可以寻找原问题的等价问题,要寻找“一个有重复元素的有序数列中最后一个小于x的元素的下标”,等价于寻找“一个有重复元素的有序数列中第一个大于等于x的元素的下标”(也就是本文一开始的问题)然后将这个值减1.按照我们之前的分析思路,就可以将l = mid
转化成r = mid
的情况了。具体的分析不再重复。
细节2
最原始的二分查找算法用于解决在一个无重复元素有序数组中找到值x的位置,代码如下:
int binarySearch(int l, int r, int A[], int x) {
while(l < r) {
int mid = (r - l) / 2 + l;
if (A[mid] == x) return mid;
else if (A[mid] > x)
r = mid - 1;
else
l = mid + 1;
}
return -1;//查找失败
}
与之前的区别在于while语句的判断条件从l < r
变成了l <= r
。而且也不存在查找失败返回-1的说法。这是因为此时如果查找失败,返回的位置是“假设这个元素在数组中,它应该存在的位置”。比如在数组:
A = {1,1,3,4,4,4}
中查找第一个大于等于5的元素的下标,返回的将是6,也就是假设将5添加进数组中,它的下标应该是6(下标从0开始)。所以我们在调用函数的时候,查找下界l
从0开始没有问题,但是查找上界r
比数组最大的下标加1,也就是数组的大小,这样才能够覆盖元素下标所有可能的取值。这也是与最原始的二分查找在使用上的一个区别。
通用模板
//解决“寻找有序序列中第一个满足某条件的元素的位置”问题的固定模板
//二分区间为[l, r],一个闭区间,覆盖所有可能的取值
int solve(int l, int r) {
int mid;
while(l < r) {
mid = (r - l) / 2 + l;
if (条件成立) {
r = mid;
}
else {
l = mid + 1;
}
}
return l;
}
总结
其实导致超时是因为我们的问题不符合模板的使用条件,当我们想要用二分的方法查找“最后一个符合某条件的元素”的时候,我们应该首先把问题转化成等价的查找“第一个符合某条件”的问题,然后将得到的结果减1即可。最后要注意的就是二分的查找范围应该是**[0, 数组大小]**这样一个闭区间。