二分查找 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;
}