二分查找详解


二分查找 (Binary Search) 是一种效率较高的查找方法,不过其对保存的数据有所要求,数据需要线性顺序存储,同时需要保证数据是有序排列的。

虽然二分查找的思路很简单,却有很多的细节问题,如整型溢出、边界的递进等等,这里详细介绍其使用方法。

简介

二分查找可以用来查找值、边界,在进行二分查找时,包括了几个比较关键的点:A) 确定搜索的范围;B) 停止搜索条件;C) 边界递进的条件。

边界确定也就是开始设置的 left 值的大小,可以是 length - 1 或者 length ,那么其对应的初始搜索空间分别为 [0, length - 1] 以及 [0, length) ,为了方便处理,在进行迭代时也会按照相同的属性进行,也就是 [left, right] 或者 [left, right)

计算中间值

在获取中间值得时候,采用的是 left + (right - left) / 2 而非 (left + right) / 2 ,虽然两者等价,但是前者可以防止由于整形溢出导致的计算结果错误,假设使用无符号 8bits 保存索引,范围是 [0, 255],那么当索引是 200220 时就会溢出。

(200 + 220)/2 = (420 - 255)/2 = 165/2 = 82

另外,当范围内是偶数个元素时,因为整除的原因,会选择偏左的值,例如 6 第一元素为 2 而非 3

查找范围

假设数组的序号从 0 开始,那么查找时有两个范围,也就是 [0, length - 1] 以及 [0, length) ,而此时也意味着结束条件有所变化,这里以 [0, length - 1] 为例,还可以参考 闭区间 的介绍。

基本搜索

当通过中值查找到了对应的目标值后,就可以直接返回结果了;但是如果目标不存在,最终实际会通过 while() 循环中的判断条件退出,这样退出条件的判断也就很关键了。

示例代码如下。

int binary_search(int *array, int length, int target)
{
        int left, right, mid;

        left = 0;
        right = length - 1; // range [0, length - 1]
        while (left <= right) { // NOTE#1 [left, right]
                mid = left + (right - left) / 2;
                if (array[mid] == target)
                        return mid;
                else if (array[mid] < target)
                        left = mid + 1;
                else if (array[mid] > target)
                        right = mid - 1;
        }

        return -1;
}

这里以如下的数组为例,假设查找的目标是 23 ,那么详细的查找过程如下。

binary search example

最后一次计算的 mid=3 也就是要查找的值。

退出条件

最好是可以直接找到对应元素,但是如果没有,那么终止条件就是已经没有任何需要搜索的元素了。

使用 while (left <= right) 时,其终止条件为 left = right + 1 ,也就对应了 [right + 1, right] 区间,其中比较关键的是会比较最后一次值,也就是 right (此时 leftright 相等)。

以如上的示例为例,如果查找的是 25 ,那么最后一次更新时 mid=3 值不存在,而该值要小于 25 ,那么更新 left=4,此时会再查找一次,也就只有当此时才没有搜索空间。

while(left < right) 的终止条件是 left = right,会忽略掉比较 right 对应的值,即使值存在,那么也可能会返回不存在。当然,也可以在最后添加 return nums[left] == target ? left : -1; 进行修改,消除异常。

边界更新

上述实际上已经判断过了 mid 值是否满足条件,因为是闭区间,那么显然接下来应该从 [left, mid - 1] 或者 [mid + 1, right] 继续搜索,这也就对应的每次搜索边界的更新方式。

左边界搜索

假设存在有序数组 [1, 2, 2, 2, 3],需要搜索 2 ,默认的二分查找返回的是 2 ,如果要得到左侧边界 (索引 1) 或者右侧边界 (索引 3) ,上述的方法是无法进行处理的。

当然可以线性查找,但是这样就无法保证二分查找对数级的复杂度了。

另外,很多语言内置的库,一般还会将左边界搜索称为 lower_bound()

实现

这里的关键是,在判断 array[mid] == target 满足条件后,并没有直接返回,而是执行 right = mid - 1 ,也就是更新的搜索范围,因为这里搜索的是左侧边界,所以更新后的搜索范围是 [left, mid - 1]

int left_bound(int *array, int length, int target)
{
        int left, right, mid;

        left = 0;
        right = length - 1;
        while (left <= right) { // [left, right]
                mid = left + (right - left) / 2;
                if (array[mid] == target)
                        right = mid - 1;
                else if (array[mid] < target)
                        left = mid + 1;
                else if (array[mid] > target)
                        right = mid - 1;
        }
        //return left; // how many items less than target.
        if (left >= length || array[left] != target)
                return -1;
        return left;
}

如果直接返回 left 或者 right 实际的含义是,数组中小于目标元素的个数,例如 array = [2, 3, 5, 7], target = 1 返回 0array = [2, 3, 5, 7], target = 8 返回 4

而如上的代码则判断是否存在元素。

右边界搜索

唯一特殊的是返回值为 left - 1 ,这主要与 left 的更新策略有关,使用的是 left = mid + 1 ,那么返回时就需要减去 1

int right_bound(int *array, int length, int target)
{
        int left, right, mid;

        left = 0;
        right = length - 1;
        while (left <= right) { // [left, right]
                mid = left + (right - left) / 2;
                if (array[mid] == target)
                        left = mid + 1;
                else if (array[mid] < target)
                        left = mid + 1;
                else if (array[mid] > target)
                        right = mid - 1;
        }

        //return right; // how many items less or equal to target.
        if (right < 0 || array[right] != target)
                return -1;
        return right;
}

总结

边界条件或者检查范围决定了搜索范围的更新策略,而 while 循环则决定了终止的条件。