大概理解二分查找:
- 在数组两边找标记好左右边界。
- 找一个中间点(奇数向下取整)。
- 如果目标值的数(前提,数组为升序排序)在左边界与中间点隔离开的区间,则丢弃两外一半(反之也一样)
- 一直循环下去,直到中间点值为目标值或者最后左边界与有边界重合后值依然不存在后结束循环。
时间复杂度之间比较(越小越好):
O ( n ! ) O(n!) O(n!)> O ( 2 n ) O(2^n) O(2n)> O ( n 2 ) O(n^2) O(n2)> O ( n l o g n ) O(nlog_n) O(nlogn)> O ( n ) O(n) O(n)> O ( l o g n ) O(log_n) O(logn)> O ( 1 ) O(1) O(1)
二分查找时间复杂度: O ( l o g n ) O(log_n) O(logn)
需求
在有序数组A内(升序),查找值target
- 如果找到,返回索引
- 找不到,返回 -1;
代码
/*
* 二分查找基础版
* Params: a - 待查找的升序数组
* target - 待查找的目标值
* Returns:
* 找到返回索引
* 找不到返回索引
* */
public static int binarySearchBasic(int[] a, int target) {
int i = 0, j = a.length - 1; //设置指针初值
while (i <= j) { //范围内有东西
// int m = (i + j)/2; //int 类型变量除法自动向下取整
int m = (i + j) >>> 1;
if (target < a[m]) { //目标在中间值的左边
j = m - 1;
} else if (a[m] < target) { //目标在中间值的右边
i = m + 1;
} else { //target = a[m]
return m;
}
}
return -1;
}
问题1:为什么是 i<= j 而不是 i < j ?
i < j 会漏掉 i 与 j 同时指向一个元素的时候的结果,而直接跳出循环。
问题2:(i + j)/2有没有问题?
在大数运算时 当 i+j 超过了int表示的范围则会出错(变成负数)java中将最高位视为符号位
优化 :利用无符号右移运算 把(i + j)/2 改为 (i + j) >>> 1
整数
0000_1000 右移一位 0000_0100 8 -> 4
整数
0000_0111 右移一位 0000_011 7 -> 3 (相当于向下取整)
这样还能自动向下取整(可以适应更多的语言);
问题三 : 为什么 if判断与while都写小于符号
由于数组升序排列,这样写的话保证了左边的都比右边大,符合升序 更易于阅读
平衡版二分查找(以最上面代码为例具体分析)
由于代码中向下取整,在逻辑上i向右m+1只需判断一次,而j向左m-1需要经过两次判断(第一次if (target < a[m]) { //目标在中间值的左边,第二次 } else if (a[m] < target) { //目标在中间值的右边)整体上倾向于左边,导致不平衡,所以我们做了改进,如下(缺点在理想的最优查找时间复杂度由 O ( 1 ) O(1) O(1)变为了 O ( l o g n ) O(log_n) O(logn)):
/**
* <h3>二分查找平衡版</h3>
*
* <ol>
* <li>不奢望循环内通过 m 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过 i)</li>
* <li>i 指针可能是查找目标</li>
* <li>j 指针不可能是查找目标</li>
* <li>因为 1. 2. 3. 当区域内还剩一个元素时, 表示为 j - i == 1</li>
* <li>改变 i 边界时, m 可能就是目标, 同时因为 2. 所以有 i=m</li>
* <li>改变 j 边界时, m 已经比较过不是目标, 同时因为 3. 所以有 j=m</li>
* <li>三分支改为二分支, 循环内比较次数减少</li>
* </ol>
*
* @param a 待查找的升序数组
* @param target 待查找的目标值
* @return <p>找到则返回索引</p>
* <p>找不到返回 -1</p>
*/
public static int binarySearchBalance(int[] a, int target) {
int i = 0, j = a.length;
while (1 < j - i) { // 范围内待查找的元素个数 > 1 时
int m = (i + j) >>> 1;
if (target < a[m]) { // 目标在左边
j = m;
} else { // 目标在 m 或右边
i = m;
}
}
return (target == a[i]) ? i : -1;
}
处理相同值问题
如果存在相同值,上面的二分查找也能找到该值的位置,但是具体的位置不确定,假如有三个相同的值,不同的数组找到的位置就会有三种不同的可能,这样看似没有问题,但是一旦涉及到插入的话就会出现问题,所以这里有两个改进版本,一个是找最左边的那个目标值,一个是找最右边。
binarySearchLeftmost(左)
/**
* <h3>二分查找 Leftmost </h3>
*
* @param a 待查找的升序数组
* @param target 待查找的目标值
* @return <p>找到则返回最靠左索引</p>
* <p>找不到返回 -1</p>
*/
public static int binarySearchLeftmost1(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
// 记录候选位置
candidate = m;
j = m - 1;
}
}
return candidate;
}
binarySearchRightmost(右)
/**
* <h3>二分查找 Rightmost </h3>
*
* @param a 待查找的升序数组
* @param target 待查找的目标值
* @return <p>返回 ≤ target 的最靠右索引</p>
*/
public static int binarySearchRightmost1(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
// 记录候选位置
candidate = m;
i = m + 1;
}
}
return candidate;
}
在这里面做出了改进添加了candidate来标记查找到的值,之后左边的话就让j = m - 1在往左边继续查找,如果没有了就返回candidate,有则记录后继续查找;
Java中封装的二分查找
在java.util.Arrays中我们可以找到binarySearch这样的方法,不难认出他就是二分查找我们看参数为int型的也可以看懂,binarySearch0里面就是我们二分查找的逻辑;
binarySearch0源码:
// Like public version, but without range checks.
private static int binarySearch0(int[] a, int fromIndex, int toIndex,
int key) {
int low = fromIndex;
int high = toIndex - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
int midVal = a[mid];
if (midVal < key)
low = mid + 1;
else if (midVal > key)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}
都能看懂,我就之说一下 最后错误返回 -(low + 1);
- 其实就是判断插入点的位置(思考一下最后找不到元素左侧边界low是不是就是插入点的值)
为什么+1:
- 如果返回的low为0呢(-0也是0吧),我们知道,返回负数代表找不到,但是0不是负数呀,所以可以推断出+1就是为了判断low为0这特殊位置;