「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。
前言
近几日刷题过程中发现一个问题:有些人其实还是对于「二分查找」的细节还是有点琢磨不透。
图来源于 LeetCode 题解的评论区。
也许你说二分查找的模板早已了然于心,一套一个准,但有些细节你真的把握得住吗?
话不多说,切入正题。
瞅瞅二分查找的「把式」
1. 左闭右闭
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left <= right) {
int middle = left + (right - left) / 2;
if (target > nums[middle]) {
left = middle + 1;
} else if (target < nums[middle]) {
right = middle - 1;
} else {
return middle;
}
}
return -1;
}
2. 左闭右开
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
int middle = left + (right - left) / 2;
if (target < nums[middle]) {
right = middle;
} else if (target > nums[middle]) {
left = middle + 1;
} else {
return middle;
}
}
return -1;
}
简写一下?
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
int middle = ((right - left) << 1) + left;
if (target <= nums[middle]) {
right = middle;
} else if (target > nums[middle]) {
left = middle + 1;
}
}
return left;
}
3. 左开右闭(错误示范)
🚄为什么左开右闭这种方式行不通,下文详细讲解💦
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
int middle = left + (right - left) / 2;
if (target >= nums[middle]) {
left = middle;
} else if (target < nums[middle]) {
right = middle - 1;
}
}
return -1;
}
眼花缭乱还是大相径庭呢?没关系,接下来详细剖析一下内部结构。
理解二分查找的「关键点」
很多时候,我们会过分关注于二分查找的执行过程(如下图),这样有可能反倒浪费没有必要的时间。
🏆二分查找的思想就是逐步缩小搜索区间;它由循环条件与区间赋值构成。
🏍循环条件比如 while(left <= right),区间赋值好比 right = middle - 1...
二者关系
唯二元素🌀
要想所编写的代码细节不出现差错,那么我们就要进行「边界值分析」。
⭐这里的边界值并非指代目标值 target 的取值,而是指代二分查找的执行次数。这就是写对二分查找代码细节的关键点。
执行 1 次就找到目标值那没什么好说的,这里所要探讨的是执行 次(二分查找最多次数)查找时所必然面临的一种情况:在 区间中唯有(有且仅有) 2 个元素时(如下图)。
此时查找的元素在 2 个元素的右边,而当且仅有 2 个元素时,middle 必然是在两者的左边(对于 middle = left + (right - left) / 2 而言)。
这就阐明了 left = middle + 1 存在的必要性;换句话说,左开右闭这种查找方式必然是会出问题的。
于是乎,二分查找就剩下左闭右开与左闭右闭这两类写法了。
所以 while(left < right) 究竟需要等号吗?
你没看错,没跑题。当你确定左闭右开与左闭右闭其中一种时,循环判断条件中是否存在等号 = 才真正决定了二分法能否跑起来..
两者情况不同,先告知结论,然后我们来反证一下。
- 左闭右闭:
while(left <= right) - 左闭右开:
while(left < right)
左闭右闭
注:
right = middle - 1;
如下图,当查找值🔍为 7 时,准备下一次判断前 left 和 right 都与 middle 相等,此时进入下一轮循环(索引值为 3 的元素未查找)继续查找 7,若循环判断条件为 left < right,那就会退出循环,直接返回 -1(未找到)。
显然这不是我们要的结果,所以左闭右闭找法的循环判断条件为 while(left <= right)。
左闭右开
注:
right = middle;
如下图,当查找值🔍为 0 时(显然数组中不存在),当处于如下的状态准备进入下一轮循环时(还未找到 0),此时 left 和 right 都与 middle 相等,且假设进入循环的条件为 left <= right,符合!所以继续循环,middle 赋值后还是和 left、right 相等,改变 right 状态(right = middle)无法缩小搜索范围,所以这轮循环后,三者恒成立,从而进入了死循环!
反证成功✔
毋庸置疑,左闭右开找法的循环判断条件为 while(left < right)。
二分查找「一览表」
如果以上分析听懂了,这个表格无需多看一眼。否则建议你结合该表格思考下。
循环条件|right 初始值 | right = nums.length - 1 | right = nums.length |
|---|---|---|
left <= right | right = middle:❌right = middle - 1:✔ | right = middle:❌right = middle - 1:❌(ArrayIndexOutOfBoundsException) |
left < right | right = middle:✔right = middle - 1:❌ | right = middle:✔right = middle - 1:❌ |
看表格不会写代码?举个例子就懂了,right = nums.length + left < right + right = middle:
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length;
while (left < right) {
int middle = left + (right - left) / 2;
if (target < nums[middle]) {
right = middle;
} else if (target > nums[middle]) {
left = middle + 1;
} else {
return middle;
}
}
return -1;
}
这里唯一需要注意的就是:当 right = nums.length 时,right = middle - 1 + left <= right 这种查找方式。
当 target 大于 nums[length - 1] (最大值) 时,会抛出越界异常而不是 -1(不存在),给个动图证明一下:
二分查找「类型题」
感谢作者 liweiwei 的整理。
看完后是不是跃跃欲试?那就来做一做「二分查找」的变型题温习一下吧。
题型一:二分求下标(在数组中查找符合条件的元素的下标)
| 题号 | 链接 |
|---|---|
| 704 | 二分查找(简单) |
| 35 | 搜索插入位置(简单) |
| 300 | 最长上升子序列(中等) |
| 34 | 在排序数组中查找元素的第一个和最后一个位置(简单) |
| 611 | 有效三角形的个数(中等) |
| 658 | 找到 K 个最接近的元素(中等) |
| 436 | 寻找右区间(中等) |
| 1237 | 找出给定方程的正整数解(中等) |
| 1300 | 转变数组后最接近目标值的数组和(中等) |
| 4 | 寻找两个有序数组的中位数(困难) |
使用二分查找的前提不一定非要是「有序数组」。旋转有序数组(下表前 4 题)、山脉数组(下表后 2 题)里的查找问题也可以使用「二分查找」。这些问题的解决思路是:利用局部单调性,逐步缩小搜索区间。
| 题号 | 链接 |
|---|---|
| 33 | 搜索旋转排序数组(中等) |
| 81 | 搜索旋转排序数组 II(中等) |
| 153 | 寻找旋转排序数组中的最小值(中等) |
| 154 | 寻找旋转排序数组中的最小值 II(困难) |
| 852 | 山脉数组的峰顶索引(简单) |
| 1095 | 山脉数组中查找目标值(中等) |
题型二:二分答案(在一个有范围的区间里搜索一个整数)
如果题目要我们找一个整数,这个整数有确定的范围,可以通过二分查找逐渐缩小范围,最后逼近到一个数。
定位一个有范围的整数,这件事情也叫「二分答案」或者叫「二分结果」。如果题目要求的是一个整数,这个整数有明确的范围,可以考虑使用二分查找。
事实上,二分答案是我们最早接触的二分查找的场景。「幸运 52」里猜价格游戏,就是「二分查找」算法的典型应用:先随便猜一个数,如果猜中,游戏结束。如果猜大了,往小猜;如果猜小了,往大猜。
| 题号 | 链接 |
|---|---|
| 69 | x 的平方根(简单) |
| 287 | 寻找重复数(中等) |
| 374 | 猜数字大小(简单) |
| 275 | H 指数 II(中等) |
| 1283 | 使结果不超过阈值的最小除数(中等) |
| 1292 | 元素和小于等于阈值的正方形的最大边长(中等) |
题型三:二分答案的升级版(每一次缩小区间的时候都需要遍历数组)
说明:这一类问题本质上还是「题型二」(二分答案),但是初学的时候会觉得有一些绕。这一类问题的问法都差不多,关键字是「连续」、「正整数」,请大家注意捕捉题目中这样的关键信息。
这里给出的问题解法都一样,会一题等于会其它题。问题的场景会告诉我们:目标变量和另一个变量有相关关系(一般是线性关系),目标变量的性质不好推测,但是另一个变量的性质相对容易推测(满足某种意义上的单调性)。这样的问题的判别函数通常会写成一个函数的形式。
这一类问题可以统称为「 最大值极小化 」问题,最原始的问题场景是木棍切割问题,这道题的原始问题是「力扣」第 410 题(分割数组的最大值(困难))。
思路是这样的:
- 分析出题目要我们找一个整数,这个整数有范围,所以可以用二分查找;
- 分析出 单调性,一般来说是一个变量 a 的值大了,另一个变量 b 的值就变小,而「另一个变量的值」 b 有限制,因此可以通过调整 a 的值达到控制 b 的效果;
- 这一类问题的题目条件一定会给出 连续、正整数 这样的关键字。如果没有,问题场景也一定蕴含了这两个关键信息。
以下给出的问题无一例外。
| 题号 | 链接 |
|---|---|
| 875 | 爱吃香蕉的珂珂(中等) |
| 410 | 分割数组的最大值(困难) |
| LCP 12 | 小张刷题计划(中等) |
| 1011 | 在 D 天内送达包裹的能力(中等) |
| 1482 | 制作 m 束花所需的最少天数(中等) |
| 1552 | 两球之间的磁力(中等) |
❤️/ END / 如果本文对你有帮助,点个「赞」支持下吧。