理解二分查找
1. 什么是二分查找?
二分查找(Binary Search)是一种高效的查找算法,适用于已排序的数组中查找指定元素。它的基本思想是:首先确定待查找区间的中间位置,然后判断待查找元素与中间位置的元素大小关系,若相等则返回中间位置,若小于中间位置则在中间位置的右侧区间查找,若大于中间位置则在中间位置的左侧区间查找,不断缩小查找范围,直到找到目标元素或者查找区间为空。二分查找的时间复杂度为O(log n),是一种非常高效的查找算法。
2. 标准二分查找示例
以下是一个Java语言的二分查找示例代码:
public class BinarySearch {
public static int binarySearch(int[ ] arr, int key) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == key) {
return mid;
} else if (arr[mid] < key) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
public static void main(String[ ] args) {
int[ ] arr = { 2, 3, 4, 10, 40 };
int key = 10;
int index = binarySearch(arr, key);
if (index == -1) {
System.out.println("Element is not present in array");
} else {
System.out.println("Element is present at index " + index);
}
}
}
在上面的代码中,我们定义了一个名为 binarySearch 的静态方法,该方法接受一个已排序的整数数组和要查找的目标值。方法返回目标值在数组中的索引,如果目标值不在数组中,则返回 -1。
在 binarySearch 方法中,我们使用了两个指针 left 和 right 来跟踪要查找的目标值的左右边界。在每次迭代中,我们计算出数组的中间元素 mid,并将其与要查找的目标值进行比较。如果 mid 等于目标值,则返回 mid 的索引。如果 mid 小于目标值,则说明目标值可能在数组的右半部分,因此我们将 left 指针移动到 mid + 1
的位置。如果 mid 大于目标值,则说明目标值可能在数组的左半部分,因此我们将 right 指针移动到 mid - 1
的位置。如果在整个迭代过程中没有找到目标值,则返回 -1。
在 main 方法中,我们定义了一个已排序的整数数组 arr 和要查找的目标值 key,然后调用 binarySearch
方法来查找目标值在数组中的索引。如果返回值为 -1,则说明目标值不在数组中;否则,返回值就是目标值在数组中的索引。
3. 几个关键处理点分析
为什么要使用left <= right,而不是left < right
举个很简单的例子,假设数组就一个元素:[1]
,现在让你查找1,如果是left < right
还能查到吗?
再比如:[1,2,3,4]
这样的数组,当要查询1或4时,如果使用left < right
都会查询不到。
所以,实际上使用left <= right
主要就是为了处理目标值刚好落在left或者right位置上的情况。
为什么要使用left + (right - left) / 2求mid值
正常来说我们直接通过(left + right)/ 2
即可取得mid值,而使用left + (right - left) / 2
的方式可以避免(left + right)
溢出的情况发生。
注意:当数组长度为偶数时,
left + (right - left) / 2
取到的值为两个数靠前的一个值,比如数组为[1,2,3,4]
,mid值指向的是下标1的位置,而不是下标2的位置。
4. 二分查找需要注意的几点
- 数据必须有序:二分查找需要将待查找的数据有序,因此在使用二分查找之前,需要对数据进行排序,或者在数据中间插入新元素以维持有序状态。
- 每次查找都需要减半:在每次查找过程中,需要将待查找的数据范围减半,因此最多只能进行 log2(n) 次查找,其中 n 是数据的长度。
- 边界处理:在查找过程中,需要处理好边界值的问题。
题集练习
一、标准二分:
704. 二分查找(简单)
没什么好说的,就是标准二分查找
class Solution {
public int search(int[] arr, int key) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == key) {
return mid;
} else if (arr[mid] < key) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
}
374. 猜数字大小(简单)
依然是标准二分的使用场景
/**
* Forward declaration of guess API.
* @param num your guess
* @return -1 if num is higher than the picked number
* 1 if num is lower than the picked number
* otherwise return 0
* int guess(int num);
*/
public class Solution extends GuessGame {
public int guessNumber(int n) {
int first = 1;
int last = n;
while(first <= last){
int mid = (last - first) / 2 + first;
if(guess(mid) == 0){
return mid;
}else if (guess(mid) == -1){
last = mid - 1;
}else{
first = mid + 1;
}
}
return first;
}
}
二、找合适的位置
在二分查找的应用场景中,除了找到目标值之外,一般还可以应用于以下几种场景:
- 当目标值有重复时,找到第一个等于目标值的位置,或者最后一个等于目标值的位置。
- 当目标值不存在时,找到第一个小于目标值的位置。
- 当目标值不存在时,找到第一个大于目标值的位置。
下面,我们通过做题来理解一下。
278. 第一个错误的版本 (简单)
找到第一个等于目标值的位置
实际上本题的数组可以理解为是长这样的:[false,false,false,true,true]
所以我们可以理解为就是找到第一个为true的位置,所以可以直接通过二分的方式来查找。
- 我们同样先定义left和right两个边界。
- 每次获取mid值,如果为false,则表示mid值左边也肯定都为false,因此left直接更新为
mid + 1
,如果为true,则表示mid值右边也肯定都为true,但并不一定就是第一个true,因此我们继续更新right为mid - 1
。 - 当重复上述过程时,最终一定会来到
left == right
的情况,取left值即可。
为什么是取left而不是right呢?
因为在整个重复过程中,left每次是因为取到了false才加1,而right是因为取到了true才减1,因此当最终left == right
时,right会因为isBadVersion(mid)
为true,而又被多减了一位。而如果isBadVersion(mid)
为false,则left刚好应该再加一位。
/* 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;
int right = n;
while(left <= right){
int mid = (right - left) / 2 + left;
if(isBadVersion(mid)){
right = mid - 1;
}else{
left = mid + 1;
}
}
return left;
}
}
35. 搜索插入位置(简单)
找到第一个小于等于目标值的位置
理解一下题目的含义,实际上就是找到第一个小于等于目标值的位置。
几乎和标准二分查找一样的模板,理解一下上一题【第一个错误的版本】的分析就明白了。
class Solution {
public int searchInsert(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right){
int mid = (right - left) / 2 + left;
if(nums[mid] == target){
return mid;
}else if(nums[mid] < target){
left = mid + 1;
}else{
right = mid - 1;
}
}
return left;
}
}
744. 寻找比目标字母大的最小字母(简单)
找到第一个大于目标值的位置
道理是一样的,最终当left == right
相等时,还是取left值,只不过要根据题目要求,当left越界后,则要返回第一个下标的值。
class Solution {
public char nextGreatestLetter(char[] letters, char target) {
int left = 0;
int right = letters.length - 1;
while(left <= right){
int mid = (right - left) / 2 + left;
if(letters[mid] <= target){
left = mid + 1;
}else{
right = mid - 1;
}
}
if(left >= letters.length){
return letters[0];
}
return letters[left];
}
}
34. 在排序数组中查找元素的第一个和最后一个位置(中等)
有了前几题的经验,本题只要在原有的套路基础上稍微改动一下即可:
- 首先,无论是找第一个等于目标值还是最后一个等于目标值,只要当
nums[mid] == target
时,就先记下来。 - 然后,如果是找第一个等于目标值的情况,当
nums[mid] == target
时,就继续收缩右半部分值。 - 同理,如果是找最后一个等于目标值的情况,当
nums[mid] == target
时,就继续收缩左半部分值。
public int[] searchRange(int[] nums, int target) {
int[] ans = {-1, -1};
int left = 0;
int right = nums.length - 1;
while (left <= right) {
int mid = (right - left) / 2 + left;
if (nums[mid] == target) {
ans[0] = mid;
right = mid - 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
left = 0;
right = nums.length - 1;
while (left <= right) {
int mid = (right - left) / 2 + left;
if (nums[mid] == target) {
ans[1] = mid;
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return ans;
}
三、局部有序问题
当我们提到二分查找时,有时会将其局限于严格有序的数组中。但实际上,在一些局部有序的情况下,我们同样可以使用二分查找来解决问题。接下来,我们将探讨如何使用二分查找来处理局部有序性的问题。
33. 搜索旋转排序数组(中等)
虽然数组不是完全有序的,但是我们不难发现,通过一次二分后,必然有一半有序的,一半无序的(但是无序的情况依旧能通过二分变为一半有序一半无序),只要符合这样的规律,那么我们就可以依然可以使用二分进行查找。
图解说明(x轴表示数组下标,y轴表示值)
假设中间点在旋转点的左边,那么中间点的左半部分一定是有序的。
假设中间点在旋转点的右边,那么中间点的右半部分一定是有序的。
class Solution {
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right){
int mid = (right - left) / 2 + left;
if(nums[mid] == target){
return mid;
}
/**
* 如果nums[mid] < nums[right]成立,则表示mid 到 right区间一定是有序的,那么接下来按照有序的方式进行二分查找即可
* 否则表示left 到 mid的区间一定是有序的,那么同样在这个区间也可以按照二分的方式查找即可
*/
if(nums[mid] < nums[right]){
// 如果nums[mid] < target && target <= nums[right]成立,则在mid到right的范围内找,然后在left到mid的范围找
if(nums[mid] < target && target <= nums[right]){
left = mid + 1;
}else{
right = mid - 1;
}
}else{
if(nums[left] <= target && target < nums[mid]){
right = mid - 1;
}else{
left = mid + 1;
}
}
}
return -1;
}
}
81. 搜索旋转排序数组 II(中等)
这是上一题的延伸,与上一题的区别关键点就在于,有重复数据出现了,这就导致了可能会出现nums[left] == nums[mid] == nums[right]
的情况,且出现这种情况以后,没办法判断目标值是在左半部分还是右半部分。
下图是当nums[left] == nums[mid] == nums[right]
时,目标值可能在右半部分的情况。
下图是当nums[left] == nums[mid] == nums[right]
时,目标值可能在左半部分的情况。
为了解决遇到nums[left] == nums[mid] == nums[right]
的情况,我们可以分别将left加1,right减1之后,然后再重新进行二分查找。
class Solution {
public boolean search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right){
int mid = (right - left) / 2 + left;
if(nums[mid] == target){
return true;
}
if(nums[left] == nums[mid] && nums[mid] == nums[right]){
left++;
right--;
}
else if(nums[mid] <= nums[right]){
if(nums[mid] < target && target <= nums[right]){
left = mid + 1;
}else{
right = mid - 1;
}
}else{
if(nums[left] <= target && target < nums[mid]){
right = mid - 1;
}else{
left = mid + 1;
}
}
}
return false;
}
}
153. 寻找旋转排序数组中的最小值(中等)
从下图可以看出,当mid小于right时,则最小值一定在mid的左边,所以收缩右半部分即可,反之则一定在mid的右边,收缩左半部分即可。
class Solution {
public int findMin(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
int mid = (right - left) / 2 + left;
if (nums[mid] < nums[right]) {
right = mid;
} else {
left = mid + 1;
}
}
return nums[left];
}
}
本题有两个关键点处理:
为什么是left < right,而不是标准的left <= right ?
因为在满足nums[mid] < nums[right]
条件时,执行的是right=mid
,而不是right = mid + 1
,因此如果再用left <= right
,则会出现死循环,比如数组[1,2]
,会永远满足left <= right
的条件。
为什么是right = mid,而不是right = mid + 1 ?
如果第一个问题是因为right = mid
产生的,那为什么不按照right = mid + 1
处理呢?原因很简单,因为最小值可能就在mid的坐标上,因此不能过滤掉,而left则不存在这个问题(可以看图理解)。
154. 寻找旋转排序数组中的最小值 II(困难)
本题也是上一题的延伸,场景和【81. 搜索旋转排序数组 II(中等)】一样,多了重复的数据问题,所以解决方式也是一样的,直接移位即可。
class Solution {
public int findMin(int[] nums) {
int left = 0;
int right = nums.length - 1;
while(left < right){
int mid = (right - left ) / 2 + left;
if(nums[mid] < nums[right]){
right = mid;
}else if(nums[mid] > nums[right]){
left = mid + 1;
}else{
right = right - 1;
}
}
return nums[left];
}
}