二分查找
- 简介
- 原理分析
- 易错点分析
- 例题
- 33.搜索旋转排序数组
- 34.在排序数组中查找元素的第一个和最后一个位置
- 35.搜索插入位置
- 240.搜索二维矩阵 Ⅱ
简介
二分查找,是指在有序(升序/降序)数组查找符合条件的元素,或者确定某个区间左右边界,是一种效率较高的查找方法。
原理分析
必须是有序的序列,否则二分没有效果,数据元素一般是数据型,可以比较大小。
步骤:
(1)初始化l
和r
,确定循环条件(一般为 while(l < r)
)
(2)计算mid
值(一般为mid = (l + r) / 2
)
数据元素为奇数,例如[1234567]
数据元素为偶数,例如[123456]
(3)将目标元素与查找区间的中间值作比较(当二者相等时,查找结束),更新l
和r
的值。
(4)重复第(3)步。
易错点分析
关于mid是否要+1?
有的时候,mid
需要+1(如例题34),一般如果l=mid
的时候mid
需要+1,否则会死循环,l=mid+1
的时候mid
不需要+1。
关于整数溢出问题
mid = l + (r - l) / 2
这个计算方式避免了直接使用(l+ r) / 2
可能导致的整数溢出问题。
在二分查找法中,mid = l1 + (r1 - l1) / 2
是为了计算当前搜索区间的中间位置。这个计算方式避免了直接使用(l1 + r1) / 2
可能导致的整数溢出问题。
解释如下:
- 当
l
和r
都很大时,l + r
可能会超过整数的最大值,从而导致溢出。 - 使用
l + (r - l) / 2
,首先计算了r - l
,这个结果一定小于或等于r
和l
中的较大值,因此不会导致溢出。 - 然后将这个差值除以2,得到中间位置与
l
的偏移量。 - 最后将偏移量加到
l
上,得到中间位置的索引mid
。
这种方法确保了即使在l
和r
非常大时,也能正确计算中间位置的索引,而不会因为整数溢出而导致计算错误,这是二分查找中常用的一种技巧,以确保算法的鲁棒性。
关于边界判断,是l < r还是l <= r?以及对应情况下的l和r的更新
在二分查找中,while (l <= r)
和while (l < r)
的选择取决于搜索区间的定义。两种情况下的l
和r
更新方式略有不同,以适应不同的区间定义。
- 当使用
while (l <= r)
时,搜索区间是左闭右闭的,即包含l
和r
。在这种情况下:l
的初始值通常是0,表示搜索的起始位置。r
的初始值通常是数组的长度减1,表示搜索的结束位置。- 在循环中,如果中间元素不等于目标值,需要更新
l
或r
来缩小搜索区间。如果中间元素小于目标值,更新l = mid + 1
;如果中间元素大于目标值,更新r = mid - 1
。 - 循环继续直到
l
超过r
,此时搜索结束。
- 当使用
while (l < r)
时,搜索区间是左闭右开的,即包含l
但不包含r
。在这种情况下:l
的初始值通常是0,表示搜索的起始位置。r
的初始值通常是数组的长度,表示搜索的结束位置之后的位置。- 在循环中,如果中间元素不等于目标值,同样需要更新
l
或r
。如果中间元素小于目标值,更新l = mid + 1
;如果中间元素大于目标值,更新r = mid
,因为mid
位置的元素已经检查过,不需要再次检查。 - 循环继续直到
l
等于r
,此时搜索结束。
在两种情况下,计算中间位置mid
的方式通常是相同的,即mid = l + (r - l) / 2
。这样可以避免直接相加l
和r
可能导致的整数溢出问题。
总结来说,while (l <= r)
适用于左闭右闭的搜索区间,而while (l < r)
适用于左闭右开的搜索区间。在更新l
和r
时,需要根据区间的定义来决定是否包含中间位置mid
。
但是需要注意的是,这只是二分查找的基础用法,很多题目都需要根据问题进行具体的分析。
例题
33.搜索旋转排序数组
思路:
本题采用两次二分查找,第一次查找找到旋转点,从而将数组分为两个部分(因为数组是升序,所以只要比较中间点元素的值和nums[0]
的值,如果大于第一个元素的值,说明旋转点在中间点的右侧,更新l
值,如果小于,说明旋转点在中间点的左侧,更新r
值),这两个部分都是升序的,然后通过确定target
和第一个元素的值的大小关系可以确定target
在哪一个部分,并更新l
或者r
的值,再通过二分查找来确定target
的下标。视频讲解点击视频讲解-搜索旋转排序数组。
时间复杂度:
时间复杂度为O(logn)
,使用了两次二分查找。
代码实现:
class Solution {
public int search(int[] nums, int target) {
//二分法查找旋转点
int l = 0;
int r = nums.length - 1;
while(l < r){
int mid = l + (r - l) / 2 + 1;
if(nums[mid] >= nums[0]) l = mid;
else r = mid - 1;
}
//确定target的区间
if(target >= nums[0]) l = 0;
else{
l = l + 1;
r = nums.length - 1;
}
//二分法查找target的下标
while(l < r){
int mid = l + (r - l) / 2 + 1;
if(nums[mid] <= target) l = mid;
else r = mid -1;
}
//这里用r是因为前面有l+1,使用l可能会越界
return nums[r] == target ? r : -1;
}
}
注意:二分查找有很多变体,l和r更新的方式不同循环条件也会不同,根据易错点分析中边界判断的规律本题也可以写成下面的形式:
class Solution {
public int search(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
//当循环条件变为l <= r时,l的更新方式为l = mid + 1
//同时mid在计算时不需要+1
while(l <= r){
int mid = l + (r - l) / 2;
if(nums[mid] >= nums[0]) l = mid + 1;
else r = mid - 1;
}
if(target >= nums[0]){
l = 0;
}else {
l = l + 1;
r = nums.length - 1;
}
//当循环条件变为l <= r时,l的更新方式为l = mid + 1
//同时mid在计算时不需要+1
while(l <= r){
int mid = l + (r - l) / 2;
if(nums[mid] <= target) l = mid + 1;
else r = mid - 1;
}
return nums[r] == target ? r : -1;
}
}
34.在排序数组中查找元素的第一个和最后一个位置
思路:
本题的解题思路和33题目一样,通过两次二分查找来确定左右边界,左边界的查找方法是左边界左边的元素全部小于等于左边界,右边的元素大于等于左边界;有边界的查找方法是右边界左边的元素全部小于等于右边界,右边的元素大于等于右边界,也就是两次二分即可,最后将结果放入List
中,视频讲解点击视频讲解-在排序数组中查找元素的第一个和最后一个位置。
时间复杂度:
时间复杂度是 O(logn)
,因为它使用了二分查找的方式来确定左右边界。在最坏的情况下,它需要进行两次二分查找,所以时间复杂度是 O(logn)
。
代码实现:
class Solution {
public int[] searchRange(int[] nums, int target) {
//边界判断
if(nums.length == 0) return new int[] {-1,-1};
List<Integer> ans = new ArrayList<>(2);
//确定左边界
int l1 = 0;
int r1 = nums.length - 1;
while(l1 < r1){
int mid1 = l1 + (r1 - l1) / 2;
if(nums[mid1] >= target) r1 = mid1;
else l1 = mid1 + 1;
}
if(nums[l1] != target) return new int[] {-1,-1};
ans.add(l1);
//确定右边界
int l2 = 0;
int r2 = nums.length - 1;
while(l2 < r2){
int mid2 = l2 + (r2 - l2) / 2 + 1;
if(nums[mid2] <= target) l2 = mid2;
else r2 = mid2 - 1;
}
ans.add(r2);
//将List转为int[]
int[] result = new int[ans.size()];
for (int i = 0; i < ans.size(); i++) {
result[i] = ans.get(i);
}
return result;
}
}
其中,最后将List转换为数组除了上面的方法外,还可以用以下的方法:
return ans.stream().mapToInt(Integer::intValue).toArray();
这行代码使用了Java 8的流API
来将List<Integer>
转换为int[]
数组。stream()
方法创建一个流,mapToInt(Integer::intValue)
将流中的Integer
对象映射成int
值,最后toArray()
方法将流中的元素收集到一个int[]
数组中。
分析:
该题中的二分查找确实与常规的二分查找算法有所不同,特别是在确定左右边界的时候。这种差异主要是由于目标问题的特殊性:找到数组中等于给定值的元素的第一个和最后一个位置,这种情况下,我们需要对标准的二分查找进行一些调整。
让我们逐步分析代码中的不同之处:
- 确定左边界:
while(l1 < r1)
:这里使用<
而不是<=
,是因为我们希望在找到目标值时继续向左搜索,以找到它的第一个出现的位置。如果使用<=
,我们可能会在找到目标值后立即停止,这可能会导致我们得到的是目标值的最后一个位置。if(nums[mid1] >= target) r1 = mid1;
:如果中间元素大于或等于目标值,我们将右边界移动到中间位置。这是因为我们要找的是左边界,所以我们需要继续在左侧搜索。
- 确定右边界:
while(l2 < r2)
:同样,这里使用<
而不是<=
,是因为我们希望在找到目标值时继续向右搜索,以找到它的最后一个出现的位置。int mid2 = l2 + (r2 - l2) / 2 + 1;
:这里对中间位置的索引进行了调整,使其向上取整。这是因为在找到目标值时,我们希望继续在右侧搜索,而上取整的中间位置将帮助我们这样做。if(nums[mid2] <= target) l2 = mid2;
:如果中间元素小于或等于目标值,我们将左边界移动到中间位置。这是因为我们要找的是右边界,所以我们需要继续在右侧搜索。
这种调整使得我们能够在找到目标值后继续搜索,直到我们找到它的第一个和最后一个位置。在确定了左右边界之后,我们就可以返回目标值的范围了。
35.搜索插入位置
思路:
本题是二分查找比较基础的题目,查找target
在数组中的位置,需要注意的是,如果数组中没有target
,那么需要返回它的插入位置,所以需要分三种情况来判断返回l
、l - 1
和l + 1
,需要注意的是当target < nums[l]
的时候,直接返回l - 1
可能会引起数组越界,需要判断l - 1
是否小于0,若小于0,则直接插入到数组首尾,即下标为0的位置,视频讲解点击视频讲解-搜索插入位置。
时间复杂度:
时间复杂度为O(logn)
。
代码实现:
class Solution {
public int searchInsert(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
while(l < r){
int mid = l + (r - l) / 2 + 1;
if(nums[mid] <= target) l = mid;
else r = mid - 1;
}
if(nums[l] == target) return l;
else if(nums[l] < target) return l + 1;
else return l - 1 >= 0 ? l - 1 : 0;
}
}
240.搜索二维矩阵 Ⅱ
思路:
从矩阵的右上角开始,比较当前元素与目标值的大小,根据比较结果决定向左走还是向下走,如果目标值小于当前元素则向左走,否则向下走,直到找到目标值或走到矩阵的边界。
时间复杂度:
时间复杂度为O(m+n)
,其中m
为矩阵的行数,n
为矩阵的列数,在最坏的情况下,算法会遍历整个矩阵的一行或一列。
代码实现:
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
int i = 0;
int j = matrix[0].length - 1;
while(i < matrix.length && j >= 0){
//大于target,向左走;小于target,向右走
if(matrix[i][j] > target){
j--;
}else if(matrix[i][j] < target){
i++;
}else{
return true;
}
}
return false;
}
}
注意:
二分查找有很多变体,但是思想是不变的,比如易错点分析中总结了两种边界判断和对应l
和r
的更新,实际上可以比较常用的还有以下这种情况:
while(l < r){
int mid = l + (r - l) + 1;
if(...) l = mid;
else r = mid - 1;
}
此时循环结束后l
和r
指向同一个元素,方便后续操作,所以具体问题具体分析,可以自己举个例子走一遍流程,可以更加清楚的掌握某些边界条件应该如何去写。