二分查找
正常实现
Input : [1,2,3,4,5]
key : 3
return the index : 2
public int binarySearch(int[] nums, int key) {
int l = 0, h = nums.length - 1;
while (l <= h) {
int m = l + (h - l) / 2;
if (nums[m] == key) {
return m;
} else if (nums[m] > key) {
h = m - 1;
} else {
l = m + 1;
}
}
return -1;
}
时间复杂度
二分查找也称为折半查找,每次都能将查找区间减半,这种折半特性的算法时间复杂度为 O(logN)。
m 计算
有两种计算中值 m 的方式:
l + h 可能出现加法溢出,也就是说加法的结果大于整型能够表示的范围。但是 l 和 h 都为正数,因此 h - l 不会出现加法溢出问题。所以,最好使用第二种计算法方法。
未成功查找的返回值
循环退出时如果仍然没有查找到 key,那么表示查找失败。可以有两种返回值:
变种
二分查找可以有很多变种,变种实现要注意边界值的判断。例如在一个有重复元素的数组中查找 key 的最左位置的实现如下:
public int binarySearch(int[] nums, int key) {
int l = 0, h = nums.length - 1;
while (l < h) {
int m = l + (h - l) / 2;
if (nums[m] >= key) {
h = m;
} else {
l = m + 1;
}
}
return l;
}
该实现和正常实现有以下不同:
在 nums[m] >= key 的情况下,可以推导出最左 key 位于 [l, m] 区间中,这是一个闭区间。h 的赋值表达式为 h = m,因为 m 位置也可能是解。
在 h 的赋值表达式为 h = m 的情况下,如果循环条件为 l <= h,那么会出现循环无法退出的情况,因此循环条件只能是 l < h。以下演示了循环条件为 l <= h 时循环无法退出的情况:(所以写while循环条件就根据h的赋值来决定是否需要=号)
nums = {0, 1, 2}, key = 1
l m h
0 1 2 nums[m] >= key
0 0 1 nums[m] < key
1 1 1 nums[m] >= key
1 1 1 nums[m] >= key
...
当循环体退出时,不表示没有查找到 key,因此最后返回的结果不应该为 -1。为了验证有没有查找到,需要在调用端判断一下返回位置上的值和 key 是否相等。
1. 求开方
69. Sqrt(x) (Easy)
Input: 4
Output: 2
Input: 8
Output: 2
Explanation: The square root of 8 is 2.82842..., and since we want to return an integer, the decimal part will be truncated.
一个数 x 的开方 sqrt 一定在 0 ~ x 之间,并且满足 sqrt == x / sqrt。可以利用二分查找在 0 ~ x 之间查找 sqrt。
对于 x = 8,它的开方是 2.82842...,最后应该返回 2 而不是 3。在循环条件为 l <= h 并且循环退出时,h 总是比 l 小 1,也就是说 h = 2,l = 3,因此最后的返回值应该为 h 而不是 l。
public int mySqrt(int x) {
if (x <= 1) {
return x;
}
int l = 1, h = x;
while (l <= h) {
int mid = l + (h - l) / 2;
int sqrt = x / mid;
if (sqrt == mid) {
return mid;
} else if (mid > sqrt) {
h = mid - 1;
} else {
l = mid + 1;
}
}
return h;
}
2. 大于给定元素的最小元素
744. Find Smallest Letter Greater Than Target (Easy)
Input:
letters = ["c", "f", "j"]
target = "d"
Output: "f"
Input:
letters = ["c", "f", "j"]
target = "k"
Output: "c"
题目描述:给定一个有序的字符数组 letters 和一个字符 target,要求找出 letters 中大于 target 的最小字符,如果找不到就返回第 1 个字符。
public char nextGreatestLetter(char[] letters, char target) {
int n = letters.length;
int l = 0, h = n - 1;
while (l <= h) {
int m = l + (h - l) / 2;
if (letters[m] <= target) {
l = m + 1;
} else {
h = m - 1;
}
}
return l < n ? letters[l] : letters[0];
}
3. 有序数组的 Single Element
540. Single Element in a Sorted Array (Medium)
Input: [1, 1, 2, 3, 3, 4, 4, 8, 8]
Output: 2
题目描述:一个有序数组只有一个数不出现两次,找出这个数。
要求以 O(logN) 时间复杂度进行求解,因此不能遍历数组并进行异或操作来求解,这么做的时间复杂度为 O(N)。
令 index 为 Single Element 在数组中的位置。在 index 之后,数组中原来存在的成对状态被改变。如果 m 为偶数,并且 m + 1 < index,那么 nums[m] == nums[m + 1];m + 1 >= index,那么 nums[m] != nums[m + 1]。
从上面的规律可以知道,如果 nums[m] == nums[m + 1],那么 index 所在的数组位置为 [m + 2, h],此时令 l = m + 2;如果 nums[m] != nums[m + 1],那么 index 所在的数组位置为 [l, m],此时令 h = m。
因为 h 的赋值表达式为 h = m,那么循环条件也就只能使用 l < h 这种形式。
public int singleNonDuplicate(int[] nums) {
int l = 0, h = nums.length - 1;
while (l < h) {
int m = l + (h - l) / 2;
if (m % 2 == 1) {
m--; // 保证 l/h/m 都在偶数位,使得查找区间大小一直都是奇数
}
if (nums[m] == nums[m + 1]) {
l = m + 2;
} else {
h = m;
}
}
return nums[l];
}
4. 第一个错误的版本
278. First Bad Version (Easy)
题目描述:给定一个元素 n 代表有 [1, 2, ..., n] 版本,在第 x 位置开始出现错误版本,导致后面的版本都错误。可以调用 isBadVersion(int x) 知道某个版本是否错误,要求找到第一个错误的版本。
如果第 m 个版本出错,则表示第一个错误的版本在 [l, m] 之间,令 h = m;否则第一个错误的版本在 [m + 1, h] 之间,令 l = m + 1。
因为 h 的赋值表达式为 h = m,因此循环条件为 l < h。
public int firstBadVersion(int n) {
int l = 1, h = n;
while (l < h) {
int mid = l + (h - l) / 2;
if (isBadVersion(mid)) {
h = mid;
} else {
l = mid + 1;
}
}
return l;
}
5. 旋转数组的最小数字
153. Find Minimum in Rotated Sorted Array (Medium)
Input: [3,4,5,1,2],
Output: 1
public int findMin(int[] nums) {
int l = 0, h = nums.length - 1;
while (l < h) {
int m = l + (h - l) / 2;
if (nums[m] <= nums[h]) {
h = m;
} else {
l = m + 1;
}
}
return nums[l];
}
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
解答:两种方法:第一种,遍历找最小值,运行时间为O(n).
这里主要讲一下第二种方法:二分法。
旋转后的数组可以分为两个部分,前一部分、后一部分,两个部分均有序,并且前一部分的第一个元素(最小元素) 大于 后一部分的最后一个元素(最大元素)。
搞清楚以上这一点,接下来就很简单了。首先设置left, rigth两个指针,指向首尾两个元素,如果nums[left] < nums[right],则说明没有旋转,直接返回第一个元素即可。如果大于,则说明发生旋转,令middle = left + (right - left) /2, 并比较nums[middle] 和nums[left]值,如果nums[left] <= nums[middle] 则令 left = middle + 1, 否则令 right = middle, 再比较left和right所指向的元素的值,。。。如此迭代即可。
代码:
class Solution {
public:
int findMin(vector
int left = 0, right = nums.size() - 1;
if( nums[left] > nums[right]) //发生旋转
while( left < right){
int middle = left + (right - left) / 2;
if( nums[middle] < nums[left])
right = middle;
else if( nums[ middle] >= nums[left])
left = middle + 1;
if( nums[left] > nums[right])//依旧存在旋转
continue;
else
return nums[left];
}
else//没有发生旋转
left = 0;
return nums[left];
}
};
做几点说明r代表旋转点, 第三种情况就是没有旋转。
重点考虑第一二种情况
https://github.com/luliyucoordinate/Leetcode/blob/master/src/0153-Find-Minimum-in-Rotated-Sorted-Array/0153.cpp
#include |
#include |
using namespace std; |
static int x = []() {std::ios::sync_with_stdio(false); cin.tie(0); return 0; }(); |
class Solution |
{ |
public: |
int findMin(vector |
{ |
int low = 0, high = nums.size() - 1; |
while (low <= high) |
{ |
int mid = (high - low)/2 + low; |
if (nums[low] <= nums[mid]) |
{ |
if (nums[mid] <= nums[high]) return nums[low]; |
//针对有序情况,避免死循环 0 1 2 |
else low = mid + 1; |
} |
else |
{ |
if (nums[mid] <= nums[high]) high = mid; |
} |
} |
return -1; |
} |
}; |
int main() |
{ |
vector |
cout << Solution().findMin(nums) << endl; |
return 0; |
}}{}
}
154. 寻找旋转排序数组中的最小值 II
};
Leetcode 33. 搜索旋转排序数组
class Solution {
public:
int search(vector
int l = 0, h = nums.size() -1;
while(l <= h)
{
int m = (h - l)/2 + l;
if(nums[m] == target )
return m;
if(nums[m] >= nums[l])//针对只有一个情况 包含等号
{ //旋转点在m之后, 只在旋转点前半有序序列找
if(target <=nums[m] && target >= nums[l])
h = m;
else
l = m + 1;
}
else
{ //旋转点在m之前,只在旋转点之后的后半有序序列找
if(target >=nums[m] && target <= nums[h])
l = m + 1;
else
h = m;
}
}
return -1;
}
};
6. 查找区间
34. Find First and Last Position of Element in Sorted Array
Input: nums = [5,7,7,8,8,10], target = 8
Output: [3,4]
Input: nums = [5,7,7,8,8,10], target = 6
Output: [-1,-1]
public int[] searchRange(int[] nums, int target) {
int first = binarySearch(nums, target);
int last = binarySearch(nums, target + 1) - 1;
if (first == nums.length || nums[first] != target) {
return new int[]{-1, -1};
} else {
return new int[]{first, Math.max(first, last)};
}
}
private int binarySearch(int[] nums, int target) {
int l = 0, h = nums.length; // 注意 h 的初始值
while (l < h) {
int m = l + (h - l) / 2;
if (nums[m] >= target) {
h = m;
} else {
l = m + 1;
}
}
return l;
}
34. 在排序数组中查找元素的第一个和最后一个位置
每次记录的都是最后等于target的值不用去做边界值得判断了。
二分查找的思路
我相信对很多读者朋友来说,编写二分查找的算法代码属于玄学编程,虽然看起来很简单,就是会出错,要么会漏个等号,要么少加个 1。
不要气馁,因为二分查找其实并不简单。看看 Knuth 大佬(发明 KMP 算法的那位)怎么说的:
Although the basic idea of binary search is comparatively straightforward,
the details can be surprisingly tricky...
这句话可以这样理解:思路很简单,细节是魔鬼。
本文以问答的形式,探究几个最常用的二分查找场景:寻找一个数、寻找左侧边界、寻找右侧边界。第一个场景是最简单的算法形式,解决 这道题,后两个场景就是本题。
而且,我们就是要深入细节,比如不等号是否应该带等号,mid 是否应该加一等等。分析这些细节的差异以及出现这些差异的原因,保证你能灵活准确地写出正确的二分查找算法。
零、二分查找框架
int binarySearch(int[] nums, int target) {
int left = 0, right = ...;
while(...) {
int mid = (right + left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。本文都会使用 else if,旨在讲清楚,读者理解后可自行简化。
其中 ... 标记的部分,就是可能出现细节问题的地方,当你见到一个二分查找的代码时,首先注意这几个地方。后文用实例分析这些地方能有什么样的变化。
另外声明一下,计算 mid 时需要技巧防止溢出,即 mid=left+(right-left)/2。本文暂时忽略这个问题。
一、寻找一个数(基本的二分搜索)
这个场景是最简单的,肯能也是大家最熟悉的,即搜索一个数,如果存在,返回其索引,否则返回 -1。
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 注意
while(left <= right) {
int mid = (right + left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
1. 为什么 while 循环的条件中是 <=,而不是 < ?
答:因为初始化 right 的赋值是 nums.length-1,即最后一个元素的索引,而不是 nums.length。
这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 [left, right],后者相当于左闭右开区间 [left, right),因为索引大小为 nums.length 是越界的。
我们这个算法中使用的是前者 [left, right] 两端都闭的区间。这个区间其实就是每次进行搜索的区间,我们不妨称为「搜索区间」。
什么时候应该停止搜索呢?当然,找到了目标值的时候可以终止:
if(nums[mid] == target)
return mid;
但如果没找到,就需要 while 循环终止,然后返回 -1。那 while 循环什么时候应该终止?搜索区间为空的时候应该终止,意味着你没得找了,就等于没找到嘛。
while(left <= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候搜索区间为空,因为没有数字既大于等于 333 又小于等于 222 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。
while(left < right) 的终止条件是 left == right,写成区间的形式就是 [left, right],或者带个具体的数字进去 [2, 2],这时候搜索区间非空,还有一个数 222,但此时 while 循环终止了。也就是说这区间 [2, 2] 被漏掉了,索引 222 没有被搜索,如果这时候直接返回 -1 就是错误的。
当然,如果你非要用 while(left < right) 也可以,我们已经知道了出错的原因,就打个补丁好了:
//...
while(left < right) {
// ...
}
return nums[left] == target ? left : -1;
2. 为什么 left = mid + 1,right = mid - 1?我看有的代码是 right = mid 或者 left = mid,没有这些加加减减,到底怎么回事,怎么判断?
答:这也是二分查找的一个难点,不过只要你能理解前面的内容,就能够很容易判断。
刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 [left, right]。那么当我们发现索引 mid 不是要找的 target 时,如何确定下一步的搜索区间呢?
当然是 [left, mid - 1] 或者 [mid + 1, right] 对不对?因为 mid 已经搜索过,应该从搜索区间中去除。
3. 此算法有什么缺陷?
答:至此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但是,这个算法存在局限性。
比如说给你有序数组 nums = [1,2,2,2,3],target = 2,此算法返回的索引是 222,没错。但是如果我想得到 target 的左侧边界,即索引 111,或者我想得到 target 的右侧边界,即索引 333,这样的话此算法是无法处理的。
这样的需求很常见。你也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。
我们后续的算法就来讨论这两种二分查找的算法。
二、寻找左侧边界的二分搜索
直接看代码,其中的标记是需要注意的细节:
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) { // 注意
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return left;
}
1. 为什么 while(left < right) 而不是 <= ?
答:用相同的方法分析,因为 right = nums.length 而不是 nums.length - 1 。因此每次循环的「搜索区间」是 [left, right) 左闭右开。
while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止。
2. 为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?
答:因为要一步一步来,先理解一下这个「左侧边界」有什么特殊含义:
binarySearch
对于这个数组,算法会返回 111。这个 111 的含义可以这样解读:nums 中小于 222 的元素有 111 个。
比如对于有序数组 nums = [2,3,5,7], target = 1,算法会返回 000,含义是:nums 中小于 111 的元素有 000 个。
再比如说 nums 不变,target = 8,算法会返回 444,含义是:nums 中小于 888 的元素有 444 个。
综上可以看出,函数的返回值(即 left 变量的值)取值区间是闭区间 [0, nums.length],所以我们简单添加两行代码就能在正确的时候 return -1:
while (left < right) {
//...
}
// target 比所有数都大
if (left == nums.length) return -1;
// 类似之前算法的处理方式
return nums[left] == target ? left : -1;
3. 为什么 left = mid + 1,right = mid ?和之前的算法不一样?
答:这个很好解释,因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去掉 mid 分割成两个区间,即 [left, mid) 或 [mid + 1, right)。
4. 为什么该算法能够搜索左侧边界?
答:关键在于对于 nums[mid] == target 这种情况的处理:
if (nums[mid] == target)
right = mid;
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
5. 为什么返回 left 而不是 right?
答:都是一样的,因为 while 终止的条件是 left == right。
三、寻找右侧边界的二分查找
寻找右侧边界和寻找左侧边界的代码差不多,只有两处不同,已标注:
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // 注意
}
1. 为什么这个算法能够找到右侧边界?
答:类似地,关键点还是这里:
if (nums[mid] == target) {
left = mid + 1;
当 nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的下界 left,使得区间不断向右收缩,达到锁定右侧边界的目的。
2. 为什么最后返回 left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对。
答:首先,while 循环的终止条件是 left == right,所以 left 和 right 是一样的,你非要体现右侧的特点,返回 right - 1 好了。
至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在这个条件判断:
if (nums[mid] == target) {
left = mid + 1;
// 这样想: mid = left - 1
binarySearch
因为我们对 left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target。更多精彩文章欢迎关注我的众公号 labuladong。
至于为什么 left 的更新必须是 left = mid + 1,同左侧边界搜索,就不再赘述。
3. 为什么没有返回 −1-1−1 的操作?如果 nums 中不存在 target 这个值,怎么办?
答:类似之前的左侧边界搜索,因为 while 的终止条件是 left == right,就是说 left 的取值范围是 [0, nums.length],所以可以添加两行代码,正确地返回 −1-1−1:
while (left < right) {
// ...
}
if (left == 0) return -1;
return nums[left-1] == target ? (left-1) : -1;
四、最后总结
来梳理一下这些细节差异的因果逻辑:
第一个,最基本的二分查找算法:
因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid+1 和 right = mid-1
因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回
第二个,寻找左侧边界的二分查找:
因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid
因为我们需找到 target 的最左侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧右侧边界以锁定左侧边界
第三个,寻找右侧边界的二分查找:
因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid
因为我们需找到 target 的最右侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧左侧边界以锁定右侧边界
又因为收紧左侧边界时必须 left = mid + 1
所以最后无论返回 left 还是 right,必须减一
如果以上内容你都能理解,那么恭喜你,二分查找算法的细节不过如此。
通过本文,你学会了:
分析二分查找代码时,不要出现 else,全部展开成 else if 方便理解。
注意「搜索区间」和 while 的终止条件,如果存在漏掉的元素,记得在最后检查。
如需要搜索左右边界,只要在 nums[mid] == target 时做修改即可。搜索右侧时需要减一。
以后就算遇到其他的二分查找变形,运用这几点技巧,也能保证你写出正确的代码。
最后,点击我的头像可以查看更多详细题解,希望读者多多点赞,让我感受到你的认可~
这部分转载公众号 labuladong 致力于把算法问题讲清楚~