二分查找算法分析
二分查找算法其实也是对撞指针的另一种用法,左右两个指针分别指向数据的左右端点,然后双指针向中间移动。
朴素二分查找
上面这道题是朴素的二分查找算法,由于数据是有序的,我们可以从中间值入手
如果中间值大于目标值说明目标值位于绿色区间则需要修改右指针,如果中间值小于目标值的那说明目标值位于蓝色区间则需要修改左指针,如果相等的话直接返回下标即可。
这里的 mid = left + (right - left) / 2 是为了防止溢出,大家应该会这样写 mid = (left + right) / 2,但是由于整型数据是有范围的,所以直接加的话可能会出现溢出现象,为了避免这一现象的出现,我们使用 left + (right - left) / 2,利用减法获取一半。
补充: mid = left + (right - left) / 2 或者 mid = left + (right - left + 1) / 2 ,在朴素的二分查找算法是一样的。
class Solution {
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] > target) {
right = mid - 1;
} else if(nums[mid] < target) {
left = mid + 1;
} else {
return mid;
}
}
return -1;
}
}
左右端点二分查找
非递减顺序排列指的是可以是递增,也可以存在相同是数据,例如:【1,2,3,3,4,4,4,5,5,6,7】
我们要找到 target 的左端点和右端点,并且要求时间复杂度是 logN,大概率要使用二分查找算法来做,那现在我们需要考虑怎么二分,首先target 的左端点就分成大于等于target 这个区间和 小于target 这个区间,target 的右端点就分成 小于等于 target 这个区间和 大于 target 这个区间。
算法实现:
循环条件:left < right
为什么不加上等于?因为加上等于的话,根据上面我们的二分条件可以知道在循环中我们最后会让 left == right,这个时候就是二者相遇,并且可能会出现 nums[left] == taregt 又或者不相等,如果是相等的话,就会卡死在循环里,所以循环条件不能加等于
算法内部条件细节:
左端点: if(nums[mid] >= target)
这个需要 right = mid
,为什么不能是 right = mid - 1 呢? 因为mid 所对应的数据可能就是 target ,如果是那就不能跳过了,如果不是那就需要跳过,所以当 nums[mid] < target 时,要left = mid + 1;
右端点也是和上面类似的,if(nums[mid] >= target)
这个条件出现的时候,说明 mid 可能对应的就是target , 所以不能跳过,left = mid;
否则就是 nums[mid] > target,就需要right = mid - 1
mid 的处理:
mid = left + (right - left) / 2 或者 mid = left + (right - left + 1) / 2 在这里就是不一样的,我们要根据实际情况分析,如果是查找左端点,就要使用 mid = left + (right - left) / 2,right 就会移动到 left ,否则 right 就不会移动,这时候就是死亡循环了,右端点也是同理可得的。
本题细节处理:
如果数组长度为零,需要单独讨论,避免数组越界访问。
在第一个二分左端点的时候,需要判断此时的left 对应的数据是不是 target ,如果不是,说明不存在target ,直接返回答案,如果是,则要修改答案数组,既然存在target ,就是可以进行右端点的查找,并且此时一定存在右端点
class Solution {
public int[] searchRange(int[] nums, int target) {
int[] ans = {-1,-1};
if(nums.length == 0) {
return ans;
}
int left = 0;
int right = nums.length - 1;
//二分左端点
while(left < right) {
int mid = left + (right - left) / 2;
if(nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
if(nums[left] != target) {
return ans;
}
ans[0] = left;
right = nums.length - 1;
//二分右端点
while(left < right) {
int mid = left + (right - left + 1) / 2;
if(nums[mid] <= target) {
left = mid;
} else {
right = mid - 1;
}
}
ans[1] = left;
return ans;
}
}
小结
当数据具有二段性的时候,我们可以使用二分查找算法来查找目标的数据。
朴素二分算法是最基础的,一般要使用到二分的算法题基本用到的是左右端点的二分算法思路。
左右端点二查找算法的模板:
while(left < right) {
int mid = ...
if(nums[mid] ... target) {
...
} else {
...
}
}
mid 的取值:可以这样子记忆,如果判断条件出现 -1 ,说明 mid 就要 +1,即 mid = left + (right - left + 1) / 2
实战演练
使用二分查找算法的时候,一定要找到二段性,只要找到了,一切都好办,剩下的就是套模板。
搜索插入位置
二段性:小于 target ,大于等于 target
最后有三种情况,要么是找到了 target ,直接返回下标,如果没有找到,这时候此时的下标对应的数据要么大于target 要么小于 target ,如果是大于target 也是直接返回下标,因为是插入,如果是小于,那就要返回下标加一。
class Solution {
public int searchInsert(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left < right) {
int mid = left + (right - left) / 2;
if(nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
if(nums[right] >= target) {
return right;
}
return right + 1;
}
}
x 的平方根
暴力算法是枚举所有的数字,但是这里我们可以使用二分查找算法,当 中间值的平方和大于 target ,说明中间值和大于中间值的数据都是不符合的,如果中间值是小于的话,那就说明中间值以及小于中间值的数据都是不符合的,符合二段性
这里建议使用左右端点的算法思路,这是万能的,接下来就是等于放在哪个条件,这个交给你们,都是没有问题的。
最后要注意溢出问题,因为是通过平方来比较,所以很有可能会出现溢出,这里强制类型转化一下即可。
class Solution {
public int mySqrt(int x) {
int left = 0;
int right = x;
while(left < right) {
int mid = left + (right - left) / 2;
if((long)mid * mid >= x) {
right = mid;
} else if((long)mid * mid < x) {
left = mid + 1;
}
}
if(left * left == x) {
return left;
}
return left - 1;
}
}
山脉数组的峰顶索引
二段性超明显,可能大家目前不知道怎么写代码,不过大家对山脉那可以了如指掌,类似一个三角形,只要进行二分找到峰顶即可。
利用 arr[mid] 前一个数据或者后一个数据进行比较就可以了。
最后二段性自然也就出来了。
class Solution {
public int peakIndexInMountainArray(int[] arr) {
int left = 0;
int right = arr.length - 1;
while(left < right) {
int mid = left + (right - left + 1) / 2;
if(arr[mid] > arr[mid - 1]) {
left = mid;
} else {
right = mid - 1;
}
}
return left;
}
}
寻找峰值
这个和上面的山脉数组的峰顶索引一模一样,这里就交给聪明的大家了。
class Solution {
public int findPeakElement(int[] nums) {
int left = 0;
int right = nums.length - 1;
while(left < right) {
int mid = left + (right - left + 1) / 2;
if(nums[mid] > nums[mid - 1]) {
left = mid;
} else {
right = mid - 1;
}
}
return left;
}
}
寻找旋转排序数组中的最小值
将军莫虑,且看此图:
因为是有序的数组,经过旋转之后,最小值的左边一定都大于它,最小值的右边同样也都大于它,但是有一个特殊的地方,就是最小值的右区间的最小值是比有区间的最大值要大的,左右区间的最值很好找,就是端点对应的数值。
二段性:
nums[left] > nums[right] 时,left 要 + 1 ,避免跳过了最小值,相反则是 nums[left] <= nums[right],这时候说明left 对应的就是最小的元素,直接返回即可。
class Solution {
public int findMin(int[] nums) {
int left = 0;
int right = nums.length - 1;
while(left < right) {
if(nums[left] > nums[right]) {
left++;
} else {
return nums[left];
}
}
return nums[left];
}
}
点名
这里可以使用暴力美学,比较简单
如果要使用二分查找,就需要利用好数组自身内容和下标,如果发现是缺人的话,下标和数组自身内容是不匹配的:
那么二段性也就出来了,首先 mid == nums[mid] 的话,需要将 left 移动到 mid + 1,然后继续二分
如果 mid != nums[mid] 的话,需要将 right 移动到 mid ,但不能是 mid - 1 ,可能mid 就是答案。
综上所述,使用的是 左右端点二分查找算法的模板,大家直接套模板即可。
现在讨论特殊情况,如果刚好缺的是最后一个学号,这时候,经过二分查找之后,会有两种情况,一种是刚好下标等于数组内容,那么缺席的就是 right + 1 这个学号,另一种是下标不等于数组内容,这种直接返回下标即可。
class Solution {
public int takeAttendance(int[] records) {
int left = 0;
int right = records.length - 1;
while(left < right) {
int mid = left + (right - left) / 2;
if(mid != records[mid]) {
right = mid;
} else {
left = mid + 1;
}
}
if(right == records.length - 1 && records[right] == right) {
return right + 1;
}
return right;
}
}