二分查找算法专题
注
:本博客是学习完 LeetCode暑假打卡 - B站 后的产物,若是对本博客不感兴趣的可以直接去看原视频。
转载的朋友请附上原博地址:原博地址
在学习二分的时候发现了个很好用的二分查找算法模板
它将其分成了两个情况,接下来一个一个的进行讲解。(以下两个模板均来自 二分查找算法模板 )
该模板的算法思路:假设目标值在闭区间 $[l, r]$ 中, 假设 $M$ 是我们最后要的答案,那么这个区间就会被 $M$ 分成两个部分。
而在判断的时候我们是将 $M$ 放在左半部分还是右半部分,就会决定着我们的代码会是一个怎么的样子, 而这两个模板就是针对这两种情况而提供的。
版本一:
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
版本一是当我们将区间 $[l, r]$ 划分成 $[l, mid]$ 和 $[mid + 1, r]$ 时(既 $M$ 属于右半部分的时候),其更新操作是 $r = mid$ 或者 $l = mid + 1;$ ,计算 $mid$ 时不需要加1。
这个模板中为什么 $l = mid + 1$ 呢?
是因为,当给 $l$ 赋值的时候,是 $mid$ 不在区间的右半部分的时候。又因为答案 $M$ 是在右半部分,所以可以知道 $mid$ 一定不是我们要的答案,因此 $l = mid + 1$ 。(这里是满足区间相邻两个数差值为 $1$时的情况下 $+1$)
版本二:
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
版本二是当我们将区间 $[l, r]$ 划分成 $[l, mid - 1]$ 和 $[mid, r]$ 时(既 $M$ 属于左半部分的时候),其更新操作是 $r = mid - 1$ 或者 $l = mid;$ ,此时为了防止死循环,计算 $mid$ 时需要加 $1$。
对于为什么 $r = mid - 1$ 是因为,当给 $r$ 赋值的时候,是 $mid$ 不在区间的左半部分的时候。又因为答案 $M$ 是在左半部分,所以可以知道 $mid$ 一定不是我们要的答案,因此 $r = mid - 1$ 。(这里是满足区间相邻两个数差值为 $1$时的情况下 $+1$)
那 $mid = l + r + 1 >> 1$ 是怎么回事呢?
我们可以想一下,若 $l + 1 = r$ 时,我们采用 $mid = l + r>> 1$ 会出现一个什么样的情况?
$$ \because mid = l + r = (2 * l + 1) / 2 = l$$$$\therefore l = mid = l$$$$\therefore区间范围依然是[l, r]$$
所以为了出现这种死循环的情况,我们要这样计算 $mid = l + r + 1 >> 1$ 。
例题:
了解了二分的基础后,就来做一点题进行实践。
- LeetCode 69. x 的平方根
- LeetCode 35. 搜索插入位置
- LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置
- LeetCode 74. 搜索二维矩阵
- LeetCode 153. 寻找旋转排序数组中的最小值
- LeetCode 33. 搜索旋转排序数组
- LeetCode 278. 第一个错误的版本
- LeetCode 162. 寻找峰值
- LeetCode 287. 寻找重复数
- LeetCode 275. H指数 II
No.1 LeetCode 69. x 的平方根
原题链接:x 的平方根 - 力扣
题目描述
请实现 int sqrt(int x)
。
请计算并返回 $x$ 的正平方根,保证 $x$ 是一个非负整数。
注意返回类型是整数,所以我们只返回正平方根的整数部分。
样例1
输入:4
输出:2
样例2
输入:8
输出:2
解释:8的正平方根是 2.82842...,它的整数部分是2.
解题思路:
首先我们要思考采用它是属于哪一种类型的模板。
最初我以为两个模板都可以使用,于是就直接用了第一个模板来写了一个,最后连样例都没有过…
然后思考后发现,这道题只能采用第二个模板,即答案 $M$ 在左边的情况。
因为我们这道题要求的时 “由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。” 因此如果结果时 $2.333, 2.999, 2.123$ 之类的最后输出都只能是 $2$。
在确定了属于哪种类型后就直接套模板写代码即可。
$AC$代码:
int mySqrt(int x){
int l = 0, r = x;
while(l < r){
int mid = l + (long long)r + 1 >> 1;
if(mid <= x / mid)
l = mid;
else
r = mid -1;
}
return r;
}
需要注意的是防止数据过大而导致数据溢出,上面的代码也做了防止溢出的相应处理。
No.2 LeetCode 35. 搜索插入位置
原题链接:LeetCode 35. 搜索插入位置
题目描述
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
你可以假设数组中无重复元素。
样例1
输入: [1,3,5,6], 5
输出: 2
样例2
输入: [1,3,5,6], 2
输出: 1
样例3
输入: [1,3,5,6], 7
输出: 4
样例4
输入: [1,3,5,6], 0
输出: 0
解题思路:
这道题也是对公式的一个套用,因为我要寻找与 $target$ 相同的数字。若无相同的数字,则返回它将会被按顺序插入的位置。
于是我们就可以知道我们要寻找的是,$nums[mid] >= target$ 区间的第一个位置。
所以显而易见选用模板一,解决问题。
$AC$代码:
int searchInsert(int* nums, int numsSize, int target){
int l = 0, r = numsSize - 1;
if(nums[numsSize - 1] < target)
return numsSize;
while(l < r){
int mid = l + r >> 1;
if(nums[mid] >= target)
r = mid;
else
l = mid + 1;
}
return l;
}
No.3 LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置
LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置
题目描述
给定一个按照升序排列的整数数组nums
,和一个目标值target
。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回[-1, -1]
。
请计算并返回 $x$ 的正平方根,保证 $x$ 是一个非负整数。
注意返回类型是整数,所以我们只返回正平方根的整数部分。
样例1
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]
样例2
输入: nums = [5,7,7,8,8,10], target = 6
输出: [-1,-1]
解题思路:
首先我们要寻找的是我们的开始处,$begin$ 的位置,这个时候 $begin$ 是 $nums[mid] >= target$ 区间里面的第一个数,因此采用模板一。
然后再寻找 $end$ 的位置,这个时候 $end$ 是 $nums[mid] <= target$ 区间的最后一个数,因此采用模板二。
这道题,是模板一和模板二的综合应用的题。如果对上面两个模板掌握的好的话,这道题也没有上面难度。
$AC$代码:
/**
* Note: The returned array must be malloced, assume caller calls free().
*/
int* searchRange(int* nums, int numsSize, int target, int* returnSize){
int *x=(int *)malloc(2*sizeof(int));
*x=-1;
*(x+1)=-1;
*returnSize = 2;
if(numsSize == 0)
return x;
int l = 0, r = numsSize - 1;
while(l < r){
int mid = l + r >> 1;
if(nums[mid] >= target)
r = mid;
else
l = mid + 1;
}
if(nums[l] != target)
return x;
*x = l;
l = 0, r = numsSize - 1;
while(l < r){
int mid = l + r + 1 >> 1;
if(nums[mid] <= target)
l = mid;
else
r = mid - 1;
}
*(x+1) = r;
return x;
}
No.4 LeetCode 74. 搜索二维矩阵
题目描述
编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:
- 每行中的整数从左到右按升序排列。
- 每行的第一个整数大于前一行的最后一个整数。
样例1
输入:
matrix = [
[1, 3, 5, 7],
[10, 11, 16, 20],
[23, 30, 34, 50]
]
target = 3
输出: true
样例2
matrix = [
[1, 3, 5, 7],
[10, 11, 16, 20],
[23, 30, 34, 50]
]
target = 13
输出: false
解题思路:
其实这道题的本质内容上和 LeetCode 35. 搜索插入位置 是相似的,这道题的核心问题是如何对一个二维的数组进行判断。
首先我们第一种方法是把这个二维数组重新按照从左到右,从上到下的顺序存储到一个一维数组里面,然后再二分进行判断。但这样做不如按照顺序循环一遍依次判断是否存在 $target$。
因此我们可以用 $n、m$ 来存储矩阵的行数和列数。
然后把它看成一条线(只是看成一条线),则它的初始范围就是 $l = 0, r = n * m - 1$.
然后就是一个和前面类似的用二分来找我们的 $target$。
这里需要注意的是,我们只是把这个矩阵看成了一条线,但它本身并不是一条线,依然是一个二维数组。所以查看该位置的值的时候,要用 $matrix[mid$ $/$ $m][$$mid$ % $m]$ 来表示。
$AC$代码(算法一):
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
if(matrix.empty() || matrix[0].empty())
return false;
int n = matrix.size(), m = matrix[0].size();
int l = 0, r = n * m - 1;
while(l < r){
int mid = l + r >> 1;
if(matrix[mid / m][mid % m] >= target)
r = mid;
else
l = mid + 1;
}
if(matrix[r / m][r % m] != target)
return false;
else
return true;
}
};
第二种做法,先用二分查找判断每一行的第一个数,找到 $matrix[mid][0] <= target$ 区间第一个,就是我们 $target$ 可能存在的的那一行。
如果 $target$ 存在,则一定在这一行里,然后我们再在这一行里面用二分,找到 $matrix[r][mind] <= target$ 区间里的第一个位置。
然后判断这个位置的答案是否和我们要寻找的 $target$ 是否相等。
如果相等放回 $true$, 否则返回 $false$。
注意要判断一下空集的情况。
$AC$代码(算法二):
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
if(matrix.empty() || matrix[0].empty())
return false;
int l = 0, r = matrix.size() - 1;
while(l < r){
int mid = l + r + 1 >> 1;
if(matrix[mind][0] <= target)
l = mid;
else
r = mid - 1;
}
int n = 0, m = matrix[0].size() - 1;
while(n < m){
int mid = n + m + 1 >> 1;
if(matrix[r][mid] <= target)
n = mid;
else
m = mid - 1;
}
if(matrix[r][m] == target)
return true;
else
return false;
}
};
No.5 LeetCode 153. 寻找旋转排序数组中的最小值
题目描述
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7]
可能变为 [4,5,6,7,0,1,2]
)。
请找出其中最小的元素。
你可以假设数组中不存在重复元素。
样例1
输入: [3,4,5,1,2]
输出: 1
样例2
输入: [4,5,6,7,0,1,2]
输出: 0
解题思路:
这道题有些人第一眼看到会以为这是要排序,但其实这道题也是可以用二分来做的。
只是这道题对于二分要分成两段的那个判断不是很清楚。
但我们细想一下可以发现,这个数组的后半部分都是小于等于最后一个数的,于是乎我们可以知道我们要寻找的是满足 $nums[mid] <= nums.back()$ 区间的第一个数。
然后就是套用模板一写出AC代码。
$AC$代码:
class Solution {
public:
int findMin(vector<int>& nums) {
int l = 0, r = nums.size() - 1;
while(l < r){
int mid = l + r >> 1;
if(nums[mid] <= nums.back())
r = mid;
else
l = mid + 1;
}
return nums[l];
}
};
No.6 LeetCode 33. 搜索旋转排序数组
题目描述
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7]
可能变为 [4,5,6,7,0,1,2]
)。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1
。
你可以假设数组中不存在重复的元素。
你的算法时间复杂度必须是 O(log n) 级别。
样例1
输入: nums = [4,5,6,7,0,1,2], target = 0
输出: 4
样例2
输入: nums = [4,5,6,7,0,1,2], target = 3
输出: -1
解题思路:
这道题其实上是上一道题的一个升级版本,这个数组我们无法直接的将他分成两个部分,所以说不能直接对他运用二分进行求解。
但是这道题还是可以用二分进行求解。
首先我们用和上一道题一样的方法找到最小值,这样我们能知道这个数组是从哪来开始分成两个部分的。
找到最小值后,我们将 $target$ 与数组的最后一个数进行比较,这样我们就可以确定我们要找的数是在左半部分、还是右半部分了。
最后再在选中的部分进行二分来找我们有无与 $target$ 相等的值。
$AC$代码:
class Solution {
public:
int search(vector<int>& nums, int target) {
if(nums.empty())
return -1;
int l = 0, r = nums.size() - 1;
while(l < r){
int mid = l + r >> 1;
if(nums[mid] <= nums.back())
r = mid;
else
l = mid + 1;
}
if(target <= nums.back())
r = nums.size() - 1;
else
l = 0, r--;
while(l < r){
int mid = l + r >> 1;
if(nums[mid] >= target)
r = mid;
else
l = mid + 1;
}
if(nums[l] == target)
return l;
else
return -1;
}
};
No.7 LeetCode 278. 第一个错误的版本
题目描述
你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。
假设你有 n 个版本[1, 2, ..., n]
,你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用bool isBadVersion(version)
接口来判断版本号version
是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
样例
给定 n = 5,并且 version = 4 是第一个错误的版本。
调用 isBadVersion(3) -> false
调用 isBadVersion(5) -> true
调用 isBadVersion(4) -> true
所以,4 是第一个错误的版本。
解题思路:
这道题是真的很简单,直接判断一下是用模板一还是模板二即可,而且这个题的 bool isBadVersion(version)
就是我们的判断条件。
所以这道题没什么说的,直接敲代码AC完事。
唯一需要注意的是,数据可能过大而导致的溢出。
$AC$代码:
// Forward declaration of isBadVersion API.
bool isBadVersion(int version);
class Solution {
public:
int firstBadVersion(int n) {
int l = 1, r = n;
while(l < r){
int mid = l + (long long)r >> 1;
if(isBadVersion(mid))
r = mid;
else
l = mid + 1;
}
return l;
}
};
No.8 LeetCode 162. 寻找峰值
题目描述
峰值元素是指其值大于左右相邻值的元素。
给定一个输入数组 nums
,其中 nums[i] ≠ nums[i+1]
,找到峰值元素并返回其索引。
数组可能包含多个峰值,在这种情况下,返回任何
一个峰值所在位置即可。
你可以假设 nums[-1] = nums[n] = -∞
。
样例1
输入: nums = [1,2,3,1]
输出: 2
解释: 3 是峰值元素,你的函数应该返回其索引 2。
样例2
输入: nums = [1,2,1,3,5,6,4]
输出: 1 或 5
解释: 你的函数可以返回索引 1,其峰值元素为 2;
或者返回索引 5, 其峰值元素为 6。
说明:
- 你的解法应该是 O(logN) 时间复杂度的。
解题思路:
这道题虽然不能直接把数组分成两个部分,但由于它只需要我们返回任何一个
峰值所在的位置,所以我们每次可以通过二分来折半我们的判断范围。
我们发现,峰值有个特点,那就是它比左右两边都要大。
于是我们每次判断 $nums[mid] > nums[mid + 1]$ 。若大于,则说明在当前这个点的位置及它左边的位置一定有个峰值;若小于,则说明在当前这个点的位置及它右边的位置一定有个峰值。
而若是这个点的某一边是呈单调变化的,则端点就是一个峰值,因为题目已经假设 $nums[-1] = nums[n] = -∞$.
然后套用模板,就AC了。
$AC$代码:
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int l = 0, r = nums.size() - 1;
while(l < r){
int mid = l + r >> 1;
if(nums[mid] > nums[mid + 1])
r = mid;
else
l = mid + 1;
}
return l;
}
};
No.9 LeetCode 287. 寻找重复数
题目描述
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
样例1
输入: [1,3,4,2,2]
输出: 2
样例2
输入: [3,1,3,4,2]
输出: 3
说明
- 不能更改原数组(假设数组是只读的)。
- 只能使用额外的 O(1) 的空间。
- 时间复杂度小于 O(n2) 。
- 数组中只有一个重复的数字,但它可能不止重复出现一次。
解题思路:
这道题二分的判断方法和抽屉问题类似。
抽屉问题是什么呢,简而言之就是把至少 n + 1个的苹果,按任意确定的方式分进n个抽屉里面,那么一定至少有一个抽屉中,含有至少两个苹果。
这道题也可以按照抽屉问题的思维来看,每次计算出 $mid$ 后,判断 $cnt > mid - l + 1$($cnt$ 是表示数组中有多少个在 $l \rightarrow r$ 范围内的整数。
若结果是大于,则说明在 $l \rightarrow r$ 范围内的苹果,大于该范围内的抽屉数,所以这个范围内一定有重复的数。反之,则不存在。
然后实现这个思路即可。
$AC$代码:
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int l = 1, r = nums.size() - 1;
while(l < r){
int mid = l + r >> 1;
int cnt = 0;
for(auto x : nums)
if(x >= l && x <= mid)
cnt++;
if(cnt > mid - l + 1)
r = mid;
else
l = mid + 1;
}
return l;
}
};
No.10 LeetCode 275. H指数 II
题目描述
给定一位研究者论文被引用次数的数组(被引用次数是非负整数),数组已经按照升序排列。编写一个方法,计算出研究者的 h 指数。
h 指数的定义:
“h 代表“高引用次数”(high citations),一名科研人员的 h 指数是指他(她)的 (N 篇论文中)至多有 h 篇论文分别被引用了至少 h 次。(其余的 N - h 篇论文每篇被引用次数不多于 h 次。)”
样例
输入: citations = [0,1,3,5,6]
输出: 3
解释: 给定数组表示研究者总共有 5 篇论文,每篇论文相应的被引用了 0, 1, 3, 5, 6 次。
由于研究者有 3 篇论文每篇至少被引用了 3 次,
其余两篇论文每篇被引用不多于 3 次,
所以她的 h 指数是 3。
说明
- 如果 h 有多有种可能的值 ,h 指数是其中最大的那个。
解题思路:
这道题其实看清了本质后很简单的,就是判断 $citations[citations.size() - mid] >= mid$。
若该位置的值小于该位置距离末尾的长度,就说明我们寻找的 $h$ 在它的右边,而且它也有可能是这个 $h$。
然后AC.
$AC$代码:
class Solution {
public:
int hIndex(vector<int>& citations) {
int l = 0, r = citations.size();
while(l < r){
int mid = l + r + 1>> 1;
if(citations[citations.size() - mid] >= mid)
l = mid;
else
r = mid - 1;
}
return r;
}
};
小结
首先,感谢闫学灿
大神的教学,真的学到了很多东西。
闫学灿
大神的这两套模板,基本上大部分的二分算法题都可以解决,重要的是学会如何判断是用模板一,还是模板二。以及 $if$ 判断时候的条件怎么写。
总之这么学习了一遍后,我感觉对于二分查找算法有一定程度的掌握了。
最后,再次感谢闫学灿
大神,真的,真的学到了超多的东西。
你好,我想问一下为什么34区间[l,r)为什么 r初始化为n-1而不是n呢
最后一个题目也可以这么写:
因为我们这道题要求的时 “由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。” 因此如果结果时 2.333,2.999,2.1232.333,2.999,2.123 之类的最后输出都只能是 22。
选择模板2的依据是什么?请教下详细的思考过程?
你可以这样想,因为我只保留整数,那么就说明我最后取的结果是小于等于实际答案的,也就是说我们取的结果是小于等于某个值的区间里的最后一个数,可得到我们要的结果在左边部分,故选用模板2
No.3 LeetCode34那里,“然后再寻找 end 的位置,这个时候 end 是 nums[mid]<=target 区间的第一个数,因此采用模板二。”这儿是不是写错了?end不应该是第一个数呀?
嗯嗯,end是该区间的最后一个数,谢谢提醒。
写得很棒!学习了
谢谢 :)
No.8 LeetCode 162. 寻找峰值
“于是我们每次判断 nums[mid]>nums[mid+1]nums[mid]>nums[mid+1] 。若大于,则说明在当前这个点的位置及它右边的位置一定有个峰值;” 这句话里头大于的话,峰值是不是在左边,在[l,mid]里面有峰值,所以跟新了r=mid?
嗯嗯,之前那里写错了,谢谢提醒