二分查找也叫折半查找,是一种常见的查找方法,它将原本是线性时间提升到了对数时间范围,大大缩短了搜索时间。
二分查找必须具备两个条件,一是数列必须使用顺序存储结构(例如数组),二是数列必须有序。
以升序数列为例,比较一个元素与数列中的中间位置的元素的大小,如果比中间位置的元素大,则继续在后半部分的数列中进行二分查找;如果比中间位置的元素小,则在数列的前半部分进行比较;如果相等,则找到了元素的位置。
每次比较的数列长度都会是之前数列的一半,直到找到相等元素的位置或者最终没有找到要找的元素。
举一个实例来看一下二分查找的详细过程。
假设待查找数列为 1、3、5、7、9、11、19,我们要找的元素为 18。首先待查数列下图所示,我们找到中间的元素 7( (1+7)/2=4,第 4 个位置上的元素)。
中间元素为 7,我们要找的元素比 7 大,继续在后半部分查找。如下图所示,现在后半部分数列为 9、11、19,继续寻找后半部分的中间元素。
中间元素为 11,与 11 比较,比 11 大,则继续在后半部分查找,后半部分只有一个元素 19 了,这时直接与 19 比较,若不相等,则说明在数列中没有找到元素,结束查找。
递归方法实现二分查找的过程如下所示
public class BinarySearch {
private int[] array;
/**
* 递归实现二分查找
* @param target
* @return
*/
public int searchRecursion(int target) {
if (array != null) {
return searchRecursion(target, 0, array.length - 1);
}
return -1;
}
private int searchRecursion(int target, int start, int end) {
if (start > end) {
return -1;
}
int mid = start + (end - start) / 2;
if (array[mid] == target) {
return mid;
} else if (target < array[mid]) {
return searchRecursion(target, start, mid - 1);
} else if (target > array[mid]) {
return searchRecursion(target, mid + 1, end);
}
}
}
进行二分查找时注意的技巧:
- 计算 mid 时建议写成: mid = left + (right - left) / 2,防止 left+right 造成数据溢出。
- 写二分查找时,每一种可能尽量用else if全部写清楚,尽量少用else,可以更好地展示所有细节。
非递归方式实现二分查找的过程如下所示
public class BinarySearch {
private int[] array;
/**
* 初始化数组
* @param array
*/
public BinarySearch(int[] array) {
this.array = array;
}
/**
* 二分查找
* @param target
* @return
*/
public int search(int target) {
if (array == null) {
return -1;
}
int start = 0;
int end = array.length - 1;
while (start <= end) { //防止只有1个元素的搜索空间[left,right]--left==right时,如果是<,则搜索空间为空
int mid = start + (end - start) / 2;
if (array[mid] == target) {
return mid;
} else if (target < array[mid]) {
end = mid - 1; //变成mid-1或mid+1的原因是,mid在上一轮搜索中已经搜索过了,需要从搜索空间中移除
} else {
start = mid + 1;
}
}
return -1;
}
}
这里要注意的点:
循环的判定条件是:start <= end
,防止只有一个元素,即start == end时,如果用<
,会导致此时的搜索空间为空。
提出一个思路:为什么用二分查找,而不是三分之一、四分之一查找?
举一个实际的例子:我们在查字典的时候,如果要查以a开头的单词,则你会怎么翻字典?肯定是从最前面开始翻;如果要查以 z 开头的单词,则应该会从最后开始翻。显而易见,你不会采用二分查找的方式去查这个单词在哪,因为这样你会很累。
同样,假设数据的范围是 1~10000,让你找 10,你会怎么做?简单来说,我觉得直接用顺序查找都比二分查找更快,因为数列是升序的,用顺序查找比二分查找的比较次数少。
综上考虑,我们可以优化一下二分查找,并不一定要从正中间开始分,而是尽量找到一个更接近我们要找的那个数字的地方
,这样能够减少很多查找次数。
之前我们都是根据长度去找到这个中间位置,现在是根据 key 所在的序列范围区间去找到这个位置。要查找的位置 P = low + (key-a[low]) / (a[high]-a[low]) × (high-low)
,这是有点复杂,但是仔细看一下,这种计算方式其实就是为了找 key 所在的相对位置,让 key 的值更接近划分的位置,从而减少比较次数。
这种对二分查找的优化叫作插值查找,插值查找对于数列比较大并且比较均匀的数列来说,性能会好很多;但是如果数列极不均匀,则插值查找未必会比二分查找的性能好。
二分查找的平均查找长度为 ((n+1)log2(n+1))/n-1,有的书上写的是 log2(n+1)-1,或者是 log2n,具体计算比较麻烦,这里就不讨论了。
二分查找有个很重要的特点,就是不会查找数列的全部元素,而查找的数据量其实正好符合元素的对数,正常情况下每次查找的元素都在一半一半地减少。所以二分查找的时间复杂度为 O(log2n)
。当然,最好的情况是只查找一次就能找到,但最坏和一般情况下的确比顺序查找好了很多。
二分查找要求数列本身有序,所以在选择的时候需要确认数列是否本身有序,如果无序,则还需要进行排序,确认这样的代价是否符合实际需求。其实我们在获取一个列表的很多时候,可以直接使用数据库针对某个字段进行排序,在程序中需要找出某个值的元素时,就很适合使用二分查找了。
二分查找适合元素稍微多一些的数列,如果元素只有十几或者几十个,则其实可以直接使用顺序查找(当然,也有人在顺序查找外面用了一个或几个大循环,执行这几层大循环需要计算机执行百万、千万遍,没有考虑到机器的性能)。
一般对于一个有序列表,如果只需要对其进行一次排序,之后不再变化或者很少变化,则每次进行二分查找的效率就会很高;但是如果在一个有序列表中频繁地插入、删除数据,那么维护这个有序列表的代价就比较高昂。
比如有序数组 nums = [1,2,2,2,3],target = 2,此算法返回的索引是 2。但如果想得到 target 的左侧边界,即索引 1,或者想得到 target 的右侧边界,即索引 3,此时普通的二分查找算法是无法处理的。
这样的需求很常见。你也许会说,找到一个 target 索引,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的时间复杂度了。为满足上述需求,需要对二分查找进行扩展,即寻找左侧和右侧边界的二分查找。
寻找左侧边界的二分查找
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) { // 注意
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return left;
}
为什么这个算法能够查找到左侧边界
关键在于对 nums[mid] == target 这种情况的处理:
if(nums[mid] == target) right = mid;
可见,找到 target 时不会立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
寻找右侧边界的二分查找
寻找右侧边界和寻找左侧边界的代码差不多,只有两处不同,已标注:
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1; // 1 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // 2 注意
}
二分查找的思想很简单,但细节处需要十分留意:循环结束的判断条件、搜索空间是左闭右开还是其他方式等等,需要仔细确认自己设置的边界条件。