今天来学习一下二分搜索算法。二分搜索算法针对有序数组,如果数组乱序,则无法使用二分搜索法。
先来看一下二分搜索算法的运行原理:
- 判断区间是否有效,无效区间则退出循环。
- 取待查找区间的中间位置元素与目标值对比:
- 如果目标值小于中间位置元素,则更新待查找区间索引,到左边子区间继续查找。
- 如果目标值大于中间位置元素,则更新待查找区间索引,到右边子区间继续查找。
- 相等,则找到元素,返回索引。
- 循环退出,区间左边索引取反后返回(负数表示未查找到目标值)。
我们举个例子直观感受一下:
有一个数组[1, 3, 4, 5, 9, 10, 11, 15, 16]
,我们要找到其中的一个值,比如说3。查找过程如图所示:
这里引入两个索引,lo索引,hi索引,用来确定查找区间[lo, hi),为左闭右开。
- 首先检查一下索引
lo=0
和hi=9
是否构成有效区间,很明显现在是有效区间。 - 然后我们去数组的中间位置查找,索引中间位置索引为
(lo + hi) / 2 = (0 + 8) / 2 = 4
,把索引为4的元素9与目标值3比较,发现3 < 9
,目标值比较小,所以我们要去左边子区间查找。左边子区间lo索引不用变,hi索引变为4。 - 区间[0, 4)是有效区间,我们到新区间[0, 4)查找,此时中间索引为
(0 + 4) / 2 = 2
,把索引为2的元素4与目标值3比较,发现3 < 4
,目标值比较小,所以我们还要去左边子区间查找。左边子区间lo索引不用变,hi索引变为2。 - 这时新区间变为了[0, 2),依旧是有效区间,中间索引为
(0 + 2) / 2 = 1
,把索引为1的元素3与目标值3比较,发现3=3
,找到目标值,所以返回索引1。
我们也可以通过类似的方法查找目标值5,如图所示:
不过也有可能找不到目标元素,如下图,我们要查找目标值6,就会发现无法找到6:
来看下整个查找过程:
- 区间[0, 9)是有效区间,我们去数组的中间位置索引为
(0 + 9) / 2 = 4
的地方查找,把索引为4的元素9与目标值6比较,发现6 < 9
,目标值比较小,所以我们要去左边子区间查找。左边子区间lo索引不用变,hi索引变为4。 - 区间[0, 4)是有效区间,我们去数组的中间位置索引为
(0 + 4) / 2 = 2
的地方查找,把索引为2的元素4与目标值6比较,发现4 < 6
,目标值比较大,所以我们要去右边子区间查找。右边子区间li索引不用变,lo索引变为3。 - 区间[3, 4)是有效区间,我们去数组的中间位置索引为
(3 + 4) / 2 = 3
的地方查找,把索引为3的元素5与目标值6比较,发现5 < 6
,目标值比较大,所以我们要去右边子区间查找。右边子区间li索引不用变,lo索引变为4。 - 此时[4, 4)不是有效区间,目标值查找失败,返回索引4的取反值-3。
我们会发现目标值元素找不到的时候刚好最后的lo索引就是目标元素插入位置的索引,这个是巧合还是必然呢?可以来分析一下:
当我们查找元素到最后,都会只剩下1个元素,区间为[lo, hi),如图:
此时无非两种情况:
- 当前元素小于目标值,目标元素位于
lo
右边,如果要插入,则插入到当前的lo + 1
位置,而我们更新区间后的lo
位置刚好是lo + 1
,所以最后返回的lo位置即是当前的lo+1
位置。 - 当前元素大于目标值,目标元素位于
lo
左边,如果要插入,则插入到当前的lo
位置,而我们更新区间后的lo
位置保持不变,所以最后返回的lo
位置即是当前的lo
位置。
综上两种情况,我们可以确认最后的lo
位置就是目标元素插入后的索引位置。
最后算法代码实现如下:
/**
* 有序数组的二分搜索算法
*
* @param nums 有序数组(顺序为从小到大)
* @param target 查找目标值
* @param lo 区间的lo索引(包含)
* @param hi 区间的hi索引(不包含)
*
* @return 目标值所在位置的索引,如果没有找到目标值,则返回如果将目标值插入指定区间后在数组中所在位置的索引的取反值;
* 如果数组为null,返回-1;
* 返回负数则表示查找不到
*/
public static int binarySearch(int[] nums, int target, int lo, int hi) {
// 数组为null则返回-1
if (nums == null) {
return -1;
}
// 索引检查
if (lo < 0 || nums.length <= lo) {
throw new IllegalArgumentException("lo索引必须大于0并且小于数组长度,数组长度:" + nums.length);
}
if (hi < 0 || nums.length < hi) {
throw new IllegalArgumentException("hi索引必须大于0并且小于等于数组长度,数组长度:" + nums.length);
}
if (hi <= lo) {
// lo索引必须小于hi索引(等于也不行,因为区间是左闭右开,如果等于,区间内元素数量就为0了)
throw new IllegalArgumentException("lo索引必须小于hi索引");
}
while (lo < hi) {
// 当lo索引小于hi索引,区间有效,否则区间无效,退出循环,查找结束
// int mid = (lo + hi) / 2; 的优化版,避免lo + hi超过int最大值
int mid = (lo / 2) + (hi / 2) + ((lo % 2) & (hi % 2));
int current = nums[mid];
if (current < target) {
// 目标值比当前值大,说明目标值位于区间右边
// 需要将lo索引变大
// 因为索引lo要包含在查找区间,mid已经被排除了,所以索引lo变成mid + 1
lo = mid + 1;
} else if (target < current) {
// 目标值比当前值小,说明目标值位于区间左边
// 需要将hi索引变小
// 因为索引hi不包含在查找区间,mid被排除后,索引hi变成mid
hi = mid;
} else {
// 查找到目标值
// 返回索引
return mid;
}
}
// 查找不到目标值,返回lo索引的取反值,此时lo索引刚好是将目标值插入指定区间后在数组中所在位置的索引
return ~lo;
}
测试代码如下:
int[] nums = {1, 3, 4, 5, 9, 10, 11, 16};
System.out.println("查找元素3:" + binarySearch(nums, 3, 0, nums.length));
System.out.println("查找元素5:" + binarySearch(nums, 5, 0, nums.length));
System.out.println("查找元素11:" + binarySearch(nums, 11, 0, nums.length));
System.out.println("查找元素6:" + binarySearch(nums, 6, 0, nums.length));
System.out.println("在区间[0, 5)查找元素11:" + binarySearch(nums, 11, 0, 5));
输出如下:
查找元素3,返回:1
查找元素5,返回:3
查找元素11,返回:6
查找元素6,返回:-5
在区间[0, 5)查找元素11,返回:-6
查找元素3,5,11,分别返回1,3,6,符合预期。
其中查找元素6返回-5,小于0,表示查找不到,我们可以对-5取反,得到4,表示如果要将元素6有序地插入数组中,会出现在的索引位置。
而在区间[0, 5)里面查找元素11,也返回-6,对于整个数组来说,11是存在的,但是因为限定了查找区间为[0, 5),所以最后结果也返回查找不到,并且插入位置也是相对于查找区间,所以返回的索引为-6,取反后得到5,表示如果要将元素11有序地插入到该数组的[0, 5)区间,插入后的索引位置在5上,符合我们的预期。