LeetCode刷题—数组(二分法)
二分查找
目录如下:
相关题目:
704,二分查找
34,在排序数组中查找元素的第一个和最后一个位置
278,第一个错误的版本
875,爱吃香蕉的可可
1011,在D天内送达包裹的能力
69,x的平方根
744,寻找比目标字母大的最小字母
153,寻找旋转排序数组中的最小值
引入
常用的二分查找场景:寻找一个数、寻找重复数的左侧边界、寻找重复数的右侧边界。
二分查找框架
int binarySearch(int[] nums, int target) { |
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
704,二分查找,easy
给定一个升序数组nums
和一个目标值target
,搜索 nums
中的target
,如果存在返回下标,否则返回 -1。
示例 1:
输入: nums = [-1,0,3,5,9,12], target = 9 |
示例 2:
输入: nums = [-1,0,3,5,9,12], target = 2 |
提示:
你可以假设 nums
中的所有元素是不重复的。 n 将在 [1, 10000]之间。 nums
的每个元素都将在 [-9999, 9999]之间。
题解:
此题是二分查找最简单的场景。通过代码理解上面框架的细节。
代码:
class Solution { |
细节:
while 内的循环条件为什么是
<=
而不是<
?答:初始化
right
的索引为nums.length - 1
,相当于两端都闭区间[left, right]
,这个区间其实就是每次进行搜索的区间。当找到目标值(
nums[mid] == target
)停止搜索,如果没找到,就需要while循环终止,然后返回 -1 。那 while 循环什么时候应该终止?搜索区间为空的时候应该终止,意味着没得找了,就等于没找到。
while(left <= right)
的终止条件是left == right + 1
,写成区间的形式就是[right + 1, right]
,或者带个具体的数字进去[3, 2]
,可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。while(left < right)
的终止条件是left == right
,写成区间的形式就是[right, right]
,或者带个具体的数字进去[2, 2]
,这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间[2, 2]
被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。如果非要用
while(left < right)
也可以,我们已经知道了出错的原因,就打个补丁好了://...
while(left < right) {
// ...
}
return nums[left] == target ? left : -1;为什么
left = mid + 1
,right = mid - 1
?我看有的代码是right = mid
或者left = mid
,没有这些加加减减,到底怎么回事,怎么判断?答:本题中搜索区间的两端都是闭的,
[left, right]
。当索引mid
对应元素不是target
,需要改变left
和right
重新搜索,搜索区间变为[left, mid - 1]
或者[mid + 1, right]
。此算法的缺陷?
答:当一个有序数组中含有多个目标值时,无法处理。例如:有序数组
nums = [1,2,2,2,3]
,target
为 2,此算法返回的索引是 2。
引入
上面题目说明了在有序数组中找到元素 target
的方法,下面介绍 搜索左右边界 的方法。
一、寻找左侧边界的二分搜索
- 下面是最常见的代码形式:
int left_bound(int[] nums, int target) { |
细节:
while 内的循环条件为什么是
<
而不是<=
?答:因为
right = nums.length
,搜索区间是[left,right)
左闭右开。while终止的条件是
left == right
,此时搜索区间是[left,left)
为空,可以正确终止。为什么
left = mid + 1
,right = mid
?答:由于搜索区间左闭右开,遍历到
nums[mid]
之后,重新分成的两个区间应该为[mid + 1, right)
和[left, mid)
。为什么返回的是 left 而不是 -1 ?
答:因为要一步一步来,先理解一下这个「左侧边界」有什么特殊含义:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qt9xu791-1611046986289)(https://gblobscdn.gitbook.com/assets%2F-MQPy5wekRlwH59Y-EnB%2Fsync%2Fda0a55ba889b5c1682dee99f8180d2bfcea66610.jpg?alt=media)]
对于这个数组,算法会返回 1。这个 1 的含义可以这样解读:
nums
中小于 2 的元素有 1 个。比如对于有序数组
nums = [2,3,5,7]
,target = 1
,算法会返回 0,含义是:nums
中小于 1 的元素有 0 个。再比如说
nums = [2,3,5,7], target = 8
,算法会返回 4,含义是:nums
中小于 8 的元素有 4 个。综上可以看出,函数的返回值(即
left
变量的值)取值区间是闭区间[0, nums.length]
,所以我们简单添加两行代码就能在正确的时候 return -1:while (left < right) {
//...
}
// target 比所有数都大
if (left == nums.length) return -1;
// 类似之前算法的处理方式
return nums[left] == target ? left : -1;另外,返回left 或 right 都可以,因为while终止的条件是
left == right
。
最终代码:
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) { // 注意
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
// target 比所有数都大
if (left == nums.length) return -1;
// 类似之前算法的处理方式
return nums[left] == target ? left : -1;
}
二、寻找右侧边界的二分查找
与上面思想类似。
代码:
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
//向右收缩
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1;// 注意
}细节:
为什么找右边界
left = mid + 1
?答:当
nums[mid] == target
时,增大搜索区间 的下界 left,使区间向右收缩。由于搜索区间为[left,right)
,则向右收缩时,新的下界为left = mid + 1
。寻找左边界向左收缩时,新的上界right = mid
。为什么最后返回
left - 1
而不像左边界 返回left
?答:while终止条件
left == right
,也就是返回right - 1
。因为对
left
的更新必须是left = mid + 1
,(也就是mid = left - 1
),while 循环结束时,nums[left]
一定不等于target
了,而nums[left-1]
可能是target
。为什么没有返回 -1 的操作?如果
nums
中不存在target
这个值,怎么办?答:类似之前的左侧边界搜索,因为 while 的终止条件是
left == right
,就是说left
的取值范围是[0, nums.length]
,所以可以添加两行代码,正确地返回 -1:while (left < right) {
// ...
}
if (left == 0) return -1;//注意
return nums[left-1] == target ? (left-1) : -1;
最终代码:
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
//向右收缩
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
// target 比所有数都小
if (left == 0) return -1;
return nums[left-1] == target ? (left-1) : -1;
}
34,在排序数组中查找元素的第一个和最后一个位置,medium
给定一个按照升序排列的整数数组 nums
,和一个目标值 target
。找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target
,返回 [-1, -1]。
进阶:
你可以设计并实现时间复杂度为 O(log n) 的算法解决此问题吗?
示例 1:
输入:nums = [5,7,7,8,8,10], target = 8 |
示例 2:
输入:nums = [5,7,7,8,8,10], target = 6 |
示例 3:
输入:nums = [], target = 0 |
提示:
0 <= nums.length
<= 105 -109 <= nums[i]
<= 109 nums
是一个非递减数组 -109 <= target
<= 109
题解:
思路就是找 target 的左右边界,如果找不到就返回 [-1, -1]。
代码:
class Solution { |
278,第一个错误的版本,easy
你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。
假设你有 n 个版本 [1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用 bool isBadVersion(version)
接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
示例:
给定 n = 5,并且 version = 4 是第一个错误的版本。 |
题解
利用二分法找第一个错误的版本,搜索范围为 \([1,n)\),左边界。
代码
/* The isBadVersion API is defined in the parent class VersionControl. |
875,爱吃香蕉的可可,medium
珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。
珂珂可以决定她吃香蕉的速度 K (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 K 根。如果这堆香蕉少于 K 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。
珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
返回她可以在 H 小时内吃掉所有香蕉的最小速度 K(K 为整数)。
示例 1:
输入: piles = [3,6,7,11], H = 8 |
示例 2:
输入: piles = [30,11,23,4,20], H = 5 |
示例 3:
输入: piles = [30,11,23,4,20], H = 6 |
提示:
1 <= piles.length <= 10^4 piles.length <= H <= 10^9 1 <= piles[i] <= 10^9
题解:
题中求在 H 小时吃掉全部香蕉的最小速度 K,即在所有可能吃完的速度中找到左边界 K,就转换为 二分法寻找左边界的问题。
对于吃的速度来说,最小为 1,最大为 max(piles),因为每小时只能选一堆香蕉吃。
应用上面寻找左边界的框架,并根据实际场景修改。
- 先找到数组的最大元素
maxPile
- 在
[1, maxPile]
范围内使用二分法找 K,target
为是否能吃完香蕉。- 如果当前速度能吃完,向左收缩继续找。
- 如果当前速度不能吃完,向右找。 代码:
class Solution { |
1011,在D天内送达包裹的能力,medium
传送带上的包裹必须在 D 天内从一个港口运送到另一个港口。
传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。
返回能在 D 天内将传送带上的所有包裹送达的船的最低运载能力。
示例 1:
输入:weights = [1,2,3,4,5,6,7,8,9,10], D = 5 |
示例 2:
输入:weights = [3,2,2,4,1,4], D = 3 |
示例 3:
输入:weights = [1,2,3,1,1], D = 4 |
提示:
1 <= D <= weights.length <= 50000 1 <= weights[i] <= 500
题解:
与上一题相同,是二分法在实际问题的应用。
搜索范围是 能在D天内将所有包裹送达的运载能力 [max(weights),sum(weights)]
。
寻找左边界。
target:以此运载能力 cap
可以在D天全部送达
代码:
class Solution { |
69,x的平方根,easy
实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
示例 1:
输入: 4 |
示例 2:
输入: 8 |
题解:
很容易想到 x 的平方根比 x / 2
小,但事实如此吗?
解方程 \(√x < x / 2\) ,得 $x > 4 $,即 \(x = 1,2,3\) 时,平方根为 1。在写代码时就要考虑这一特殊情况。
还要注意此题向下取整,具体怎么做在代码中体现。
class Solution { |
细节
做题时对向下取整和边界问题困扰很久,这种方法比较好理解,也不用考虑那么多。
744,寻找比目标字母大的最小字母,easy
给你一个排序后的字符列表 letters ,列表中只包含小写英文字母。另给出一个目标字母 target,请你寻找在这一有序列表里比目标字母大的最小字母。
在比较时,字母是依序循环出现的。举个例子:
如果目标字母 target = 'z' 并且字符列表为 letters = ['a', 'b'],则答案返回 'a'
示例:
输入: |
提示:
letters长度范围在[2, 10000]区间内。 letters 仅由小写字母组成,最少包含两个不同的字母。 目标字母target 是一个小写字母。
题解
- 方法一:线性搜索。因为数组已排序,遍历 letters 数组,找到第一个比目标字母大的字母就返回。若都比目标字母小,则返回第一个。
class Solution { |
- 方法二:二分搜索。在 letters 数组中找到比 目标字母大的字母 中的左边界。
class Solution { |
细节
因为可能全部字母都比目标字母大,有可能找不到左边界,还要单独判断一下 left == letters.length
,如果是,返回第一个字母。
153,寻找旋转排序数组中的最小值,medium
假设按照升序排序的数组在预先未知的某个点上进行了旋转。例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] 。
请找出其中最小的元素。
示例 1:
输入:nums = [3,4,5,1,2] |
示例 2:
输入:nums = [4,5,6,7,0,1,2] |
示例 3:
输入:nums = [1] |
提示:
1 <= nums.length
<= 5000 -5000 <= nums[i] <= 5000 nums 中的所有整数都是 唯一 的 nums 原来是一个升序排序的数组,但在预先未知的某个点上进行了旋转
- 方法一:线性搜索。如果升序数组发生了旋转,比较相邻两元素,如果前一个元素比后一个元素大,返回后者。如果没旋转,返回首个元素即最小值。
class Solution { |
方法二:二分法。此题的目的是找到数组的最小值。
注意此时不一定是有序数组,可能发生了旋转。在找到 mid 时,要确定最小值在 mid 的左侧还是右侧。
class Solution { |