问题来源:在写二分查找类算法题时,总是不知道如何去对写搜索区间的左右指针变量初始化、赋值以及循环结束条件。
二分查找的思路:尽可能地根据题意,寻找其中的单调性。即在有序数组中进行二分查找,有序性这一点很重要!下面从力扣【875.爱吃香蕉的珂珂】来初步了解一下二分查找的应用思路。
题目描述:珂珂喜欢吃香蕉。这里有
n
堆香蕉,第i
堆中有piles[i]
根香蕉。警卫已经离开了,将在h
小时后回来。珂珂可以决定她吃香蕉的速度k
(单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉k
根。如果这堆香蕉少于k
根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。 珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。返回她可以在h
小时内吃掉所有香蕉的最小速度k
(k
为整数)。示例 1:输入:piles = [3,6,7,11], h = 8 输出:4
// 暴力解法:两次for循环
class Solution {
public int minEatingSpeed(int[] piles, int h) {
int time = 0;
for(int k = 1; ; k++) {
for(int i = 0; i < piles.length; i++) {
time += (piles[i] == k ? (piles[i] / k) : (piles[i] / k)+ 1);
}
if(time <= h) {
return k;
} else {
time = 0;
}
}
}
}
根据题意:吃香蕉速度越小,耗时越多。反之,速度越大,耗时越少,这是本题中的单调性。优化的无非是吃香蕉的速度区间中,不符合的速度,从而找到第一个满足条件(>=)的速度值。
// 二分搜索:本质在于二分查找优化了暴力思路的不必要取值
class Solution {
public int minEatingSpeed(int[] piles, int h) {
int maxSpeed = piles[0];
for(int i = 1; i < piles.length; i++) {
maxSpeed = Math.max(maxSpeed, piles[i]);
}
int left = 1, right = maxSpeed; // 这里取的是左闭右开区间,即[left, right)
while(left < right) {
int mid = (right - left) / 2 + left;
if(calculateSum(piles, mid) > h) {
left = mid + 1; // [mid + 1, right]
} else {
right = mid;
}
}
return left;
}
public int calculateSum(int[] piles, int speed) {
int time = 0;
for(int pile : piles) {
time += (pile % speed == 0 ? (pile / speed) : (pile / speed) + 1);
// 上取整也可以这样写:sum += (pile + speed - 1) / speed;
}
return time;
}
}
可以看到,本题求的是【第一个满足条件(>=)的速度值】,注意这里标注的(>=),后面的思路探讨也是基于此展开。
接下来再看力扣【34.在排序数组中查找元素的第一个和最后一个位置】,如下:
描述:给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
示例 1:输入:nums = [5,7,7,8,8,10], target = 8 输出:[3,4]
开始位置:即 >=target
的数的位置;结束位置:即 <=target
的数的位置
class Solution {
public int[] searchRange(int[] nums, int target) {
// 开始位置,即第一个 '>= target' 的数
int start = findNum(nums, target);
if(start == nums.length || nums[start] != target) {
return new int[]{-1, -1};
}
// 结束位置,即第一个 '>= (target + 1)' 的数再'-1'
int end = findNum(nums, target + 1) - 1;
return new int[]{start, end};
}
// 寻找第一个 '>= target' 的数(左闭右闭区间写法)
public int findNum(int[] nums, int target) {
int l = 0, r = nums.length - 1; // 注意右指针位置
while(l <= r) {
int mid = (r - l) / 2 + l;
if(nums[mid] < target) {
l = mid + 1; // [mid + 1, r]
} else {
r = mid - 1; // [l, mid - 1]
}
}
return l;
}
}
💡思路扩展:(有序数组中的二分查找思路)
从本题中的
第一个和最后一个位置
所对应的>=target
和<=target
两种情况,可以衍生为四种情况:【>
、>=
、<
、<=
】。其中【>
、<
、<=
】都可以通过【>=
】进行转化计算得到(如下):
>= : find(x)
(通过方法 findNum() 寻找第一个大于等于 x 的数)
>
:find(≥ x) + 1 (寻找第一个 大于等于 x 的数,再取其右边的数(即+1))<
:find(≥ x) - 1 (寻找第一个大于等于 x 的数,再取其左边的数(即-1))<=
:find(> x) - 1(寻找第一个大于等于 x + 1 的数,再取其左边的数(即-1))💡可以这么理解区间开闭:
- 左闭右闭区间:循环结束条件是
l <= r
,[l, r] 区间还存在一个元素要检查,故while(l <= r)。l = r 时还要检查一次,不能漏;
- 左闭右开区间:循环结束条件是
l < r
,[l, r)区间已不存在元素要检查,故while(l < r)。l = r 时直接退出;
- 左开右开区间:循环结束条件是
l + 1 <= r
,(l, r)区间已不存在元素要检查,故while(l + 1 < r)。l + 1 = r 时退出。
// findNum()方法:寻找第一个 >=target 的数(即开始位置)
// 【左闭右闭区间】
public int findNum(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while(l <= r) {
int mid = (r - l) / 2 + l;
if(nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return l;
}
// 【左闭右开区间】
public int findNum(int[] nums, int target) {
int l = 0, r = nums.length;
while(l < r) {
int mid = (r - l) / 2 + l;
if(nums[mid] < target) {
l = mid + 1;
} else {
r = mid;
}
}
return l;
}
// 【左开右开区间】
public int findNum(int[] nums, int target) {
int l = -1, r = nums.length;
while(l + 1 < r) {
int mid = (r - l) / 2 + l;
if(nums[mid] < target) {
l = mid;
} else {
r = mid;
}
}
return r;
}
注意三点:
① 变量 left 和 right 的初始化;② left 和 right 的赋值操作;③ 返回结果是 left 还是 right
最后,前两种写法都是返回 left,左开右开写法返回的是 right。三种实现本质结果都一样,只是根据起始位置的影响不同,可以选择不同实现。
调用这个方法时,需要注意参数的始末位置(见题 2563.统计公平数对的数目,代码如下)
findNum()
方法是 左闭右闭区间[left, right]
,因此搜索区间始末位置是【 left = i + 1
;right = nums.length - 1】;对应地,如果写的是左开右开区间(left, right)
,则传入的始末位置参数是【 left = i
;right = nums.length】;
class Solution {
public long countFairPairs(int[] nums, int lower, int upper) {
Arrays.sort(nums);
long res = 0;
for(int i = 0; i < nums.length; i++) {
// 符合条件的元素区间(注意 findNum 是左闭右闭区间,搜索区间起始位置是'i + 1')
int target1 = lower - nums[i];
int start = findNum(nums, i + 1, target1); // 第一个'大于等于lower'位置
int target2 = upper - nums[i];
int end = findNum(nums, i + 1, target2 + 1) - 1; // 第一个'小于等于upper'位置
res += end - start + 1;
}
return res;
}
// 寻找第一个 >=target 的数(即开始位置)
public int findNum(int[] nums, int start, int target) {
int l = start, r = nums.length - 1;
while(l <= r) {
int mid = (r - l) / 2 + l;
if(nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return l;
}
}
完!😄