0.最原始的二分查找
本文勉强算是递归系列的第八篇文章吧。不过实际上本文是二分专题。以多道LC上题介绍二分的解题思路与模板。
沿着递归的思路,今天我们来谈谈二分查找
严格来说,二分查找和递归联系不算太紧密,本文介绍的主要是非递归版本的二分查找类型习题的解析,话不多说,先来看看原始的BinarySearch吧!
给定一个没有重复元素的有序数组arr,查找目标值target的下标,若arr中没有则返回-1
由于是递归系列,那我们从递归版的二分开始:
(ps:本文代码中中部分边界判空条件省略)
/* 递归版二分查找 */
public static int binarySearchRecur(int[] arr ,int target, int left , int right){
if(left >= right) return -1;
int mid = (right + left) >> 1;
if(arr[mid] == target )
return mid;
if(arr[mid] > target)
return binarySearchRecur(arr, target , left , mid -1);
if(arr[mid] < target)
return binarySearchRecur(arr,target , mid + 1, right);
return -1;
}
下面改成非递归版的,也是我们今天得重头戏
/*
迭代版二分查找 LeetCode704
*/
public static int binarySearchIter(int[] arr, int target) {
int left = 0, right = arr.length - 1;
while (left <= right){
int mid = left + ((right - left) >> 1);
if(arr[mid] == target)
return mid;
if(arr[mid] > target)
right = mid - 1;
if(arr[mid] < target)
left = mid + 1;
}
return -1;
}
1.寻找第一个/最后一个出现target的位置
给定一个可能有重复元素的有序数组arr,查找目标值target第一次出现的下标,若arr中没有则返回-1
比如:[1,3,4,5,5,5,5,7] ,target = 5
返回 :3
[2,5,6,6,6,8],target = 7
返回:-1
给定一个可能有重复元素的有序数组arr,查找目标值target最后一次出现的下标,若arr中没有则返回-1
比如:[1,3,4,5,5,5,5,7] ,target = 5
返回 :6
这是二分中很经典的一类问题,求区间。
第一个题目问法有几种形式:
- 求第一个出现target的下标
- 求小于target的个数
- 求小于target的最大元素
两种种说法做法几乎的!!!其中第1和第2点完全相同,只是换了种说法,第3点在1,2结果减1即可
1.1求第一个出现target的下标 / 小于target的个数
在基本二分的框架中稍作修改:
/* 求第一个出现target的下标 / 小于target的个数 */
public static int findFirstTarget(int[] arr , int target){
int left = 0 , right = arr.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if(arr[mid] == target)
right = mid - 1; //注意1
if(arr[mid] > target)
right = mid - 1;
if(arr[mid] < target)
left = mid + 1;
}
if(left >= arr.length ||arr[left] != target) //注意2
return -1;
return left;
}
与上面纯正二分相比,注意代码中的两个地方:
-
当
arr[mid] == target时,并不是直接返回mid,为啥?因为这时并不知道mid所在位置是否为target的第一个(他的左边可能还有target)。因此将右边界缩小,往左边找。
-
while结束时,left停留在righ+1的位置。
若:right始终在最右边,则
left=arr.length,不存在该下标(target比所有元素都大,此时未找到target)若:left最终停留在中间位置,但
arr[left] != target,(此时找到的只是合适的插入target的位置,同样未找到target)
1.2求最后一个出现target的下标
和上面类似,直接上代码:
/*
最后出现target的位置
*/
public static int findLastTarget(int[] arr , int target){
int left = 0 , right = arr.length -1;
while (left <= right){
int mid = left + ((right - left) >> 1);
if(arr[mid] == target)
left = mid + 1; //与1.1不同之处:mid与target处值相同时,往右边缩
if(arr[mid] > target)
right = mid - 1;
if(arr[mid] < target)
left = mid + 1;
}
if(right < 0 || arr[right] != target)
return -1;
return right;
}
上面两个算是基本框架,掌握后小改就能解决不少问题。下面是这两种模型解题需要思考的地方,也就是对1.1和1.2需要改造的地方:
- 问题的要求是要找到哪个target?还是要找到第一个比target小(或等于)的元素?还是第一个比target大(或等于)的元素?
- 如果当前mid指向的值等于target了之后,应该怎么处理?是直接return?还是移动left或者right?
- 最后一次循环的时候(循环是以left <= right作为判断条件),left = right,那么这个时候,我是移动left还是移动right,移动之后,是left指向的我想返回的结果,还是right?
- 没找到怎么办?
1.3寻找比target小的最大元素
看Leetcode69:
实现 int sqrt(int x) 函数。 计算并返回 x 的平方根,其中 x 是非负整数。 由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
示例 1: 输入: 4 输出: 2 示例 2: 输入: 8 输出: 2 说明: 8 的平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。
代码:
/*
LeetCode 69
实现 int sqrt(int x) 函数。 计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
*/
public static int mySqrt(int x){
int left = 0 , right = x;
while (left <= right){
int mid = left + ((right - left) >> 1);
if(mid * mid == x)
return mid;
if(mid * mid > x)
right = mid - 1;
if(mid * mid < x)
left = mid + 1;
}
return right;
}
此题思路按照1.1复述即可,求小于target的最大元素即是求第一个target的前面那个元素
注意:
- 当mid * mid = x,即说明找到了平方根,返回即可
- 最后,为啥这里right是指向我想返回的结果呢?
1.4寻找比target大的最小元素
给定一个只包含小写字母的有序数组letters 和一个目标字母 target,寻找有序数组里面比目标字母大的最小字母。 数组里字母的顺序是循环的。举个例子,如果目标字母target = ‘z’ 并且有序数组为 letters = [‘a’, ‘b’],则答案返回 ‘a’。
示例: 输入: letters = ["c", "f", "j"] target = "a" 输出: "c" 输入: letters = ["c", "f", "j"] target = "c" 输出: "f" 输入: letters = ["c", "f", "j"] target = "j" 输出: "c"
代码:
/*
LeetCode 744
给你一个排序后的字符列表 letters ,列表中只包含小写英文字母。
另给出一个目标字母 target,请你寻找在这一有序列表里比目标字母大的最小字母。
*/
public static char nextGreatestLetter(char[] letters, char target) {
//按题意,先把特殊情况处理
if(target < letters[0] || target >= letters[letters.length - 1])
return letters[0];
int left = 0 , right = letters.length - 1;
while (left <= right){
int mid = left + ((right - left) >> 1);
if(letters[mid] == target)
left = mid + 1;
if(letters[mid] > target)
right = mid - 1;
if(letters[mid] < target)
left = mid + 1;
}
return letters[left];
}
此题思路按照1.2复述即可,求大于target的最小元素即是求最后那个target后面的那个元素
注意:
- 当
letters[mid] == target,需要继续往后找,寻找最后一个target - 最后,为啥这里
left是指向我们返回的结果呢?
1.5[查找元素的第一个和最后一个位置]
LeetCode 34
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回 [-1, -1]。
示例 1:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]示例 2:
输入: nums = [5,7,7,8,8,10], target = 6
输出: [-1,-1]
其实就是1.1和1.2的合体版,代码如下:
public int[] searchRange(int[] arr, int target) {
int first = findFirstTarget(arr , target);
int last = findLastTarget(arr , target);
return new int[] {first,last};
}
public static int findFirstTarget(int[] arr , int target){...}
public static int findLastTarget(int[] arr , int target){...}
1.6 第一个错误版本
你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。
假设你有 n 个版本 [1, 2, …, n],你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
示例:
给定 n = 5,并且 version = 4 是第一个错误的版本。
调用 isBadVersion(3) -> false
调用 isBadVersion(5) -> true
调用 isBadVersion(4) -> true所以,4 是第一个错误的版本。
其实就是在一堆false序列中,找到第一个变成true的位置即可。是不是思路和1.1或1.2一摸一样啊。哈哈哈~
/* The isBadVersion API is defined in the parent class VersionControl.
boolean isBadVersion(int version); */
public class Solution extends VersionControl {
public int firstBadVersion(int n) {
int left = 1 , right = n ;
while (left <= right){
int mid = left + ((right - left) >> 1);
if(!isBadVersion(mid) )
left = mid + 1;
if(isBadVersion(mid))
right = mid - 1;
}
return left;
}
}
最后,我们也来考虑考虑为啥结果返回的是left指向的元素。
回答遗留的问题:
-
我怎么知道最后应该返回left指向的还是right指向的呢?
假设法。
比如当
while (left <= right)时,就假设最后一步:left = right时,均到达题意要求的位置。比如上题,我们假设left和right都指向第一个true的位置。此时按照代码所写:
right = mid - 1;。这时候right变化,而left还停留在原地(所求位置)。所以最终left为我们所求。有小伙伴问那我假设
left = right同时停留在最后一个为false的位置呢?这时下一次循环会走left = mid + 1;,此时依然left指向正确位置。因此我们最后返回left。寻找第一个target或最后一个target也是这样推理出来的,各位可以试试。
2.旋转数组系列问题
该系列问题有四道,变化之处就是把有序数组进行旋转。
2.1寻找旋转排序(无重复)数组中的最小值
LeetCode153
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [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
/**
* LeetCode153
*/
public static int findMin(int[] nums) {
int left = 0 , right = nums.length - 1;
while (left < right){
int mid = left + ((right - left) >> 1);
if(nums[mid] > nums[right])
left = mid + 1;
else if(nums[mid] < nums[right])
right = mid;
else if(nums[mid] == nums[right])
right = mid;
}
return nums[left];
}
思路几乎类似,我们考虑的是,旋转的起点是在mid的左边还是右边。
- 当
nums[mid] > nums[right]数组最小值一定在mid的右边,反之一定在mid左半边
有小伙伴问:那能否通过nums[mid] < nums[left]进行判断呢?答案是不行:

如上图,同样不满足nums[mid] < nums[left],但最小值(旋转起点)却可能在左也可能在右
另外,为什么这里的while条件是left < right而不是left <= right呢?
2.2寻找旋转排序(有重复)数组中的最小值
LeetCode154
假设按照升序序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
注意数组中可能存在重复的元素。
示例 1:
输入: [1,3,5]
输出: 1
示例 2:输入: [2,2,2,0,1]
输出: 0
/*
LeetCode 154
*/
public static int findMinInDup(int[] nums) {
int left = 0 , right = nums.length - 1;
while (left < right){
int mid = left + ((right - left) >> 1);
if(nums[mid] > nums[right])
left = mid + 1;
else if(nums[mid] < nums[right])
right = mid ;
else
right -- ;
}
return nums[left];
}
1.2与1.1的区别仅在于:
当nums[mid] = nums[right]时,right向左缩一位(即右边界缩一位),这是由于我们不能确定最小值在 mid的左边还是右边。
2.3 在旋转排序数组(无重复)中寻找target
LeetCode33
给你一个升序排列的整数数组 nums ,和一个整数 target 。
假设按照升序排序的数组在预先未知的某个点上进行了旋转。(例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请你在数组中搜索 target ,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
示例 1:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4
示例 2:输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1
示例 3:输入:nums = [1], target = 0
输出:-1提示:
1 <= nums.length <= 5000
-10^4 <= nums[i] <= 10^4
nums 中的每个值都 独一无二
nums 肯定会在某个点上旋转
-10^4 <= target <= 10^4
思路:在数组中二分查找target,核心依然是确定left和right在mid两边如何缩小。
回想标准二分中,nums[mid] > target就把right缩到mid - 1 处,这是因为数组单调有序,target比nums[mid]还小,自然不可能在mid右边了。
在旋转数组中,我们依然希望寻找单调有序的部分,然后来进行上述判断。
随手写一个旋转数组:[4,5,6,7,0,1,2] 。我们可以看到,至少有一半的部分是符合单调有序的(比如4~7),但这个有序部分可左边,可右边。因此,我们抓住这个单调有序区间进行讨论。即,在两种情况下进行二分查找,觉得有些绕可先看代码:
/*
LeetCode 33
搜索旋转排序数组
*/
public static int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right){
int mid = left + ((right - left) >> 1);
if(nums[mid] == target)
return mid;
if(nums[mid] >= nums[right]){ //单调递增区间在mid左边的情况
if(target >= nums[left] && target <= nums[mid]) //此条件能判断target是否在该单调区间中
right = mid - 1; //在该区间,则右边界缩到mid-1处
else
left = mid + 1; //不在,则说明在mid右边,则左边界缩到mid+1处
}
if(nums[mid] <= nums[right]){
if(target >= nums[mid] && target <= nums[right])
left = mid + 1;
else
right = mid - 1;
}
}
return -1;
}
2.4 在旋转排序数组(有重复)中寻找target
LeetCode 81. 搜索旋转排序数组 II
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组
[0,0,1,2,2,5,6]可能变为[2,5,6,0,0,1,2])。编写一个函数来判断给定的目标值是否存在于数组中。若存在返回
true,否则返回false。示例 1:
输入: nums = [2,5,6,0,0,1,2], target = 0 输出: true示例 2:
输入: nums = [2,5,6,0,0,1,2], target = 3 输出: false
此题与2.3的区别就在于数组中的元素可重复了
因此与2.3的处理不同之处在于当nums[mid] = nums[right]时,并不知道target在那边,因此right向左缩一个。
/*
LeetCode 81
81. 搜索旋转排序数组2
*/
public boolean search2(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right){
int mid = left + ((right - left) >> 1);
if(nums[mid] == target) return true;
if(nums[mid] > nums[right]){
if(target >= nums[left] && target <= nums[mid])
right = mid - 1;
else
left = mid + 1;
}else if(nums[mid] < nums[right]){
if(target > nums[mid] && target <= nums[right]){
left = mid + 1;
}else {
right = mid;
}
}else{
right -- ;
}
}
return false;
}
3.LeetCode的类似题
3.1 求峰值
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。
public int findPeakElement(int[] nums) {
int left = 0, right = nums.length - 2;
while(left <= right){
int mid = left + ((right - left )>> 1);
if(nums[mid] < nums[mid + 1])
left = mid + 1;
else if(nums[mid] > nums[mid + 1])
right = mid-1;
}
return left;
}
3.2 求H指数 II
LeetCode 275
给定一位研究者论文被引用次数的数组(被引用次数是非负整数),数组已经按照 升序排列 。编写一个方法,计算出研究者的 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 指数是其中最大的那个。
public int hIndex(int[] citations) {
if(citations == null || citations.length == 0)
return 0;
if(citations.length == 1)
return Math.min(1,citations[0]);
int left = 0 , right = citations.length - 1;
while(left <= right){
int mid = left + ((right - left ) >> 1);
if(citations.length - mid == citations[mid])
return citations.length - mid ;
if( citations.length - mid > citations[mid])
left = mid + 1;
if(citations.length - mid < citations[mid])
right = mid - 1;
}
return citations.length - left;
}
3.3 求H指数I
LeetCode 274
给定一位研究者论文被引用次数的数组(被引用次数是非负整数)。编写一个方法,计算出研究者的 h 指数。
h 指数的定义:h 代表“高引用次数”(high citations),一名科研人员的 h 指数是指他(她)的 (N 篇论文中)总共有 h 篇论文分别被引用了至少 h 次。(其余的 N - h 篇论文每篇被引用次数 不超过 h 次。)
例如:某人的 h 指数是 20,这表示他已发表的论文中,每篇被引用了至少 20 次的论文总共有 20 篇。
示例:
输入:citations = [3,0,6,1,5] 输出:3 解释:给定数组表示研究者总共有 5 篇论文,每篇论文相应的被引用了 3, 0, 6, 1, 5 次。 由于研究者有 3 篇论文每篇 至少 被引用了 3 次,其余两篇论文每篇被引用 不多于 3 次,所以她的 h 指数是 3。**提示:**如果 h 有多种可能的值,h 指数是其中最大的那个。
public int hIndex(int[] citations) {
if(citations == null || citations.length == 0 )
return 0;
if(citations.length == 1 && citations[0] == 0 )
return 0;
int len = citations.length;
int[] paperCount = new int[len + 1];
//将大于len的全归为len吗,并且计数
for (int i = 1; i < len + 1; i++)
paperCount[Math.min(len,citations[i - 1])]++;
int i = len;
int sum = 0;
while (i >= 0 && sum <= i) //倒着找,找到第一个
sum += paperCount[i --];
return i+1;
}
}
小结:
本文从基本二分出发,通过几道leetcode相关题目,介绍了常见二分问题模式与思考方式。要明确,首先二分搜索就是搜索方式的一种,在有序表的情况代替顺序遍历降低时间复杂度。
核心在于迭代过程中每一次逐步缩小left与right之间的范围。难点1:在于考虑三种情况下的处理方式:等于mid时返回与否?若缩小往哪边缩?是mid = right 还是 mid = right -1? 诸如此类。难点2:最后返回是left还是right?这个用文中提到的假设演绎法即可。
往期回顾:
本文深入探讨二分查找算法,通过多个LeetCode题目展示不同变体,包括寻找目标值的第一个和最后一个出现位置、旋转数组中的最小值、在旋转排序数组中寻找目标等。详细解析了每个问题的解题思路和模板,强调了二分查找在有序数组中的应用及边界处理策略。

1746

被折叠的 条评论
为什么被折叠?



