二分查找(Binary Search)算法,也叫折半查找算法。
二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。
假设有 1000 条订单数据,已经按照订单金额从小到大排序,每个订单金额都不同,并且最小单位是元。现在想知道是否存在金额等于 19 元的订单。如果存在,则返回订单数据,如果不存在则返回 null。
利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。下图中,low 和 high 表示待查找区间的下标,mid 表示待查找区间的中间元素下标。
二分查找的时间复杂度:
假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。
被查找区间的大小变化:
n, n/2, n/4, n/8, ...,n/2k...
这是一个等比数列。其中 n/2k = 1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n/2k = 1,
可以求得 k=log2n,所以时间复杂度就是 O(logn)。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。
因为 logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。
用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。所以,常量级时间复杂度的算法有时候可能还没有 O(logn) 的算法执行效率高。
反过来,对数对应的就是指数。一个非常著名的“阿基米德与国王下棋的故事”。 指数时间复杂度的算法在大规模数据面前是无效的。
2. 二分查找的递归与非递归实现
/**
* 1.循环退出条件是 low <= high,而不是 low < high。
* 2.mid = (low + high) / 2这种写法,在 low 和 high 比较大时,两者之和可能会溢出。
* 改进的方法是将 mid 的计算方式写成 low + (high - low) / 2, 转化成位运算 low+((high-low)>>1) 更佳。
* 3.low=mid+1,high=mid-1。注意这里的 +1 和 -1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环。
* 比如,当 high=3,low=3 时,如果 a[3] 不等于 value,就会导致一直循环不退出。
* 时间复杂度为 O(logn)
* @param arr
* @param n
* @param value
* @return
*/
public int bsearch(int[] arr, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + (high - low) / 2; // (low + high) / 2, 可能会发生溢出
if (arr[mid] > value ) { // 中间值 > 目标值, 往左边找, mid - 1
high = mid - 1;
} else if (arr[mid] < value) { // 中间值 < 目标值, 往右边找, mid + 1
low = mid + 1;
} else { // 中间值 == 目标值
return mid;
}
}
return -1;
}
/**
* 2. 二分查找的递归实现
* @param arr
* @param n
* @param value
* @return
*/
public int bsearch_recurse(int[] arr, int n, int value) {
return recursive(arr, 0, n - 1, value);
}
private int recursive(int[] arr, int low, int high, int value) {
if (low > high) return -1;
int mid = low + ((high - low) >> 1);
if (arr[mid] > value) {
return recursive(arr, low, mid - 1, value);
} else if (arr[mid] < value) {
return recursive(arr, mid + 1, high, value);
} else {
return mid;
}
}
3. 二分查找应用场景的局限性
首先,二分查找依赖的是顺序表结构,简单点说就是数组。
那二分查找能否依赖其他数据结构呢?比如链表。答案是不可以的,主要原因是二分查找算法需要按照下标随机访问元素。数组按照下标随机访问数据的时间复杂度是 O(1),而链表随机访问的时间复杂度是 O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高。
二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找。
如果数据使用链表存储,二分查找的时间复杂就会变得很高, 选择用数组而不是链表来实现二分查找了。 假设链表长度为n,二分查找每次都要找到中间点(计算中忽略奇偶数差异): 第一次查找中间点,需要移动指针n/2次; 第二次,需要移动指针n/4次; 第三次需要移动指针n/8次; ...... 以此类推,一直到1次为值 总共指针移动次数(查找次数) = n/2 + n/4 + n/8 + ...+ 1,这显然是个等比数列,根据等比数列求和公式:Sum = n - 1. 最后算法时间复杂度是:O(n-1),忽略常数,记为O(n),时间复杂度和顺序查找时间复杂度相同 但是稍微思考下,在二分查找的时候,由于要进行多余的运算,严格来说,会比顺序查找时间慢