二分查找 Binary Search
二分查找,也叫折半查找算法,它只能查找已经排好序的数据。
二分查找通过比较数组中间的数据与目标数据的大小,可以得知目标数据是在数组的左边还是右边。因此,比较一次就可以把查找范围缩小一半。重复执行该操作就可以找到目标数据,或得出目标数据不存在的结论。
怎么用递归的思想理解二分查找呢?比如在有序的数组 1,2,3,4,5,6,7,8,9,10,11 中查找 2 的位置:
⨳ 要想在 1,2,3,4,5,6,7,8,9,10,11 中查找 2 的位置,必须知道 2 在 1,2,3,4,5 中的位置
⨳ 要想知道 2 在 1,2,3,4,5 中的位置,必须先知道 2 在 1,2 中的位置
⨳ 要想知道 2 在 1,2 中的位置,必须先知道 2 在 2 中的位置
⨳ 要想知道 2 在 2 中的位置,直接返回即可,递归结束
也就是说,二分查找在递的过程中就解决了问题,至于归仅对应着调用方法弹出栈。
二分查找是很符合常识的查找算法,比如在字典中查找 ji 这个字,如果不看字典的目录,肯定先翻到字典中间,看看中间的汉字拼音是不是以 J 开头,假如翻到的都是 N,那就再翻字典前一半的中间 ... 每次翻书,都将字典的范围缩减一半,直到找到 ji。
// 假设 target 在 arr[head, tail] 中
public int binarySearch(int[] arr, int head, int tail, int target){
// 空数组,返回 - 1 ,表示没找到
if(head > tail) {
return -1;
}
// 取区间的中间索引
int middle = (head + tail) / 2;
// target 在左区间,问题缩减一半
if(arr[middle]>target){
return binarySearch(arr, head,middle-1, target);
}
// target 在右区间,问题缩减一半
else if(arr[middle]<target){
return binarySearch(arr, middle+ 1, tail, target);
}
// target 在中间
else return middle;
}
二分查找每次比较都使搜索范围缩小一半,时间复杂度为Ο(logn)。
假设数据大小是
n,每次查找后数据都会缩小为原来的一半,最坏情况下,直到查找区间被缩小为空,才停止。被查找区间的变化:n, ,,,... ,...
当 =1 时,
k的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了k次区间缩小操作,时间复杂度就是O(k)。通过 =1,我们可以求得 k=,所以时间复杂度就是
O(logn)。
唐纳德·克努特(Donald E.Knuth)在《计算机程序设计艺术》的第3卷《排序和查找》中说到:“尽管第一个二分查找算法于1946年出现,然而第一个完全正确的二分查找算法实现直到1962年才出现。”
middle = (head + tail) / 2 //可能整型溢出
middle = head + (tail - head) / 2
非递归实现
二分查找用递归还是很容易理解的,如果用非递归实现呢?
public int binarySearch(int[] arr,int target){
// 假设 target 在左闭右闭的区间内 [head, tail] 中
int head = 0;
int tail = arr.length-1;
while(head<=tail){
// 取区间的中间索引
int middle = head + (tail-head)/2 ;
// target 在左区间
if(arr[middle] > target)
tail = middle - 1;
// target 在右区间
else if(arr[middle] > target)
head = middle + 1;
// target 就在中间
else return middle;
}
return -1;
}
代码还是很容易理解的,但就是不好写清楚,主要是因为对区间的定义没有想清楚,上述代码不管是递归还是非递归实现,选取的区间都是 [head, tail](左闭右闭)。
假设每次循环前,target 就在 [head, tail] 区间中 ,所以 while 循环的条件是 head <= tail,而不是 head < tail,因为当 head == tail时,区间中是存在元素的。
同理,当 arr[middle] 大于 target 时, tail 要赋值为 middle - 1 而不是赋值为 middle 。
如果换一个区间定义:
假设每次循环前,target 在 [head, tail) 区间中 ,那 while 循环的条件是 head < tail,而不是 head <= tail,因为当 head == tail时,区间中是没有元素的。
同理,当 arr[middle] 大于 target 时, tail 要赋值为 middle 而不是赋值为 middle-1 。
区间的定义就是不变量,那么在循环中坚持根据查找区间的定义来做边界处理,就是循环不变量规则。
上界问题
先看第一道上界问题:查找第一个大于给定值的元素
在有序数组
arr中查找大于target的最小值所在的索引;比如
[1,3,5,10,25,31,40]中大于3的最小值所在索引为2。
对于这个问题区间怎么定义呢?
假如 head 指向数组第一个元素,tail 指向数组最后一个元素,使用前闭后闭 [head,tail] 定义区间可以吗?
取 [head,tail] 的中间值 middle:
⨳ 如果 middle 指向的值小于等于 target,则 [head,middle] 中的值一定都小于 target ,都不符合条件,下一次遍历区间为 [middle+1,tail];
⨳ 如果 middle 指向的值大于 target,则 [middle+1,tail] 中的元素都大于 target ,但 middle 指向的值更符合条件,下一次遍历区间为 [head,middle]。
这样定义貌似是可以的,[head,middle] + [middle+1,tail] 一个元素也没有遗漏掉。
递归或循环终止条件呢?
⨳ 如果数组中存在最小值,当 [head,tail] 缩减到只有一个元素时,即 head==tail时 ,该元素肯定是大于 target 的最小值;
⨳ 如果数组中不存在最小值,也就是所有元素都小于target时,[head,tail] 也会缩减到没有元素,即 head > tail 时。
这就很难受了,终止条件冲突,当 head > tail 时,肯定会经过 head==tail,所以要在终止条件上作区分:
// 在有序数组 `arr` 中查找大于 `target` 的最小值所在的索引
public int upper(int[] arr,int target){
int head = 0;
int tail = arr.length-1;
return upper(arr,head,tail,target);
}
public int upper(int[] arr,int head,int tail,int target){
if(head >= tail){ // 数组中至多一个元素
return arr[head]>target?head:-1;
}
// 取区间的中间索引
int middle = (head + tail) / 2;
// `[head,middle]` 中的值一定都小于 `target`,不符合条件
if(arr[middle] <= target){
return upper(arr, middle+ 1, tail, target);
}
// `middle` 指向的值更符合条件
else{
return upper(arr, head,middle, target);
}
}
再看一道上界问题:查找第一个大于等于给定值的元素
在有序数组
arr中查找target:▪ 如果
target存在,返回其最小的索引;▪ 如果
target不存在,返回大于target的最小值所在的索引。
这道题改两个符号就完事了,
public int upper(int[] arr,int head,int tail,int target){
if(head >= tail){
// return arr[head]>target?head:-1;
return arr[head]>=target?head:-1;
}
int middle = (head + tail) / 2;
// if(arr[middle] <= target)
if(arr[middle] < target){ =
return upper(arr, middle+ 1, tail, target);
}
else{
return upper(arr, head,middle, target);
}
}
如果这道题变一下呢?如果 target 存在,返回其最大的索引,也就是查找最后一个等于给定值的元素所在索引。
这更好办了:
⨳ 先使用第一道上界问题的解法,找到大于 target 的最小值所在的索引 index;
⨳ index-- ,如果此时 index 指向的元素等于 target ,则其就是指向 target 的最大的索引;如果此时 index 指向的元素不等于 target,则 target 不存在。
下界问题
先看第一道下界问题:查找最后一个小于给定值的元素
在有序数组
arr中查找小 target` 的最大值所在的索引;比如
[1,3,5,10,25,31,40]中小于10的最大值所在索引为2。
还是使用 [head,tail] 前闭后闭区间进行处理:
⨳ 如果 middle 指向的值大于等于 target,则 [middle,tail] 中的值一定都不符合条件,下一次遍历区间为 [head,middle-1];
⨳ 如果 middle 指向的值小于 target,则 [head,middle+1,tail] 中的元素都小于 target ,但 middle 指向的值更符合条件,下一次遍历区间为 [middle,tail]。
这道题和查找第一个大于给定值的元素处理逻辑相反:
public int lower(int[] arr,int target){
int head = 0, tail = arr.length-1;
return lower(arr,head,tail,target);
}
public int lower(int[] arr,int head,int tail,int target){
if(head >= tail){
return arr[head]<target?head:-1;
}
int middle = (head+tail+1) / 2;
if(arr[middle]>= target){
return lower(arr, head, middle-1, target);
}
// `middle` 指向的值更符合条件
else{
return lower(arr, middle,tail, target);
}
}
注意,不再是使用 (head + tail) / 2 取中 ,而是 (head+tail+1) / 2。
为啥呢?
因为运算符 / 会向下取整,假设 head+1=tail,则 middle = (head + tail) / 2 = (2*head+1) / 2 = head ,也就是说 当 head 与 tail 相邻的时候,中间值还是 head。
如果此时 arr[middle] 小于 target,则 lower(arr, middle,tail, target) 会一直调用,区间不会缩小。
将 (head + tail) / 2 改为 (head+tail+1) / 2,是让 middle 取值向上取整。
大家可以分析一下,为什么寻找上界,使用向下取整是可以的。
下界问题,还有查找最后一个小于等于给定值的元素所在索引...这里就不赘述了。
无论二分查找怎么变,只要定义好递归区间,确认好终止条件,更新好区间上下界,就不会容易写错的。
下面看几道力扣上面的题。
爱吃香蕉的珂珂
珂珂喜欢吃香蕉。这里有
n堆香蕉,第i堆中有piles[i]根香蕉。警卫已经离开了,将在h小时后回来。珂珂可以决定她吃香蕉的速度
k(单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉k根。如果这堆香蕉少于k根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
返回她可以在
h小时内吃掉所有香蕉的最小速度k(k为整数)。
举例 piles = [3,6,7,11], h = 8,
⨳ 假设 k=3 即每小时吃 3根香蕉,那吃第 0 堆需要 1 小时,吃第 1 堆需要 2 小时,吃第 2 堆需要 3 小时,吃第 3 堆需要 4 小时,共计 10 小时,不符合要求;
⨳ 假设 k=4 即每小时吃 4根香蕉,那吃第 0 堆需要 1 小时,吃第 1 堆需要 2 小时,吃第 2 堆需要 2 小时,吃第 3 堆需要 3 小时,共计 8 小时,符合要求;
由题可知每小时吃的(k)越少,用时越长,每小时吃的(k)越多,用时越短。
最少的k可设为 1,最多的k可设为最多的那一堆香蕉个数 Max(piles),在 1 和 Max(piles) 之间选择最小符合条件的 k 即可。
class Solution {
public int minEatingSpeed(int[] piles, int h) {
int min_k = 1 ; // 每小时最少吃的香蕉数
int max_k = piles[0]; // 每小时最多吃的香蕉数
for(int i=0;i<piles.length;i++){
if(piles[i]>max_k) max_k = piles[i];
}
return minEatingSpeed(piles,h,min_k,max_k);
}
// 递归调用
private int minEatingSpeed(int[] piles, int h,int min_k,int max_k){
if(min_k>=max_k){
return piles.length > h?-1:min_k;
}
int mid_k = (min_k+max_k)/2;
int mid_h = getHourByK(piles,mid_k);
// mid_k 符合条件,就往少了吃
if(mid_h <= h){
return minEatingSpeed(piles,h,min_k,mid_k);
// mid_k 不符合条件,就往多了吃
}else{
return minEatingSpeed(piles,h,mid_k+1,max_k);
}
}
// 根据 速度 k,获取吃完的小时数
private int getHourByK(int[] piles,int k){
int h = 0;
for(int i= 0;i<piles.length;i++){
h = h + (piles[i] % k == 0? piles[i]/k:piles[i]/k+1 );
}
return h;
}
}
在D天内送达包裹的能力
传送带上的包裹必须在
days天内从一个港口运送到另一个港口。传送带上的第
i个包裹的重量为weights[i]。每一天,我们都会按给出重量(weights)的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。返回能在
days天内将传送带上的所有包裹送达的船的最低运载能力。
举例 weights = [1,2,3,4,5,6,7,8,9,10], days = 5
⨳ 假设船的最低运载能力为 10,那第一天可以运 1,2,3 这三个包裹,第二天可以运 4,5,第三天可以运 6,第四天可以运 7 ,第五天可以运 8,第六天可以运 9,第七天可以运 10,一共花费 7 天,不符合条件。
⨳ 假设船的最低运载能力为 15,那第一天可以运 1, 2, 3, 4, 5 这三个包裹,第二天可以运 6, 7,第三天可以运 8,第四天可以运 9 ,第五天可以运 10,符合条件。
可以说,这道题和爱吃香蕉的珂珂一模一样,设最低运载能力为k,最少的k可设为数组中最大的元素Max(weights),最多的k可设为数组的和 Sum(weights),在 Max(weights) 和 Sum(weights) 之间选择最小符合条件的 k 即可。
public int shipWithinDays(int[] weights, int days) {
int min_k = weights[0]; // 船的最低运载能力的最小值
int max_k = 0; // 船的最低运载能力的最大值
for(int i=0;i<weights.length;i++){
max_k+=weights[i];
if(weights[i]>min_k) min_k=weights[i];
}
return shipWithinDays(weights,days,min_k,max_k);
}
// 递归调用
private int shipWithinDays(int[] weights, int days, int min_k, int max_k) {
if(min_k>=max_k){
return days<1?-1:min_k; // 如果一天都运不走最大的货物,那就是期限 days 给的太小了
}
int mid_k = (min_k+max_k) /2;
int mid_d = getDaysByK(weights,mid_k);
// mid_d 符合条件,还可以再小一点
if(mid_d<=days){
return shipWithinDays(weights,days,min_k,mid_k);
}else{
return shipWithinDays(weights,days,mid_k+1,max_k);
}
}
private int getDaysByK(int[] weights,int k){
int days = 0;
int cur_k = 0;
for(int i=0;i<weights.length;i++){
// 传送带还可以装货
if(cur_k+weights[i]<=k){
cur_k+=weights[i];
}else{
days++;
cur_k = weights[i];
}
}
days++;
return days;
}