目录
[四、mid 的计算方式](#四、mid 的计算方式)
[1. 左边界二分要找什么?](#1. 左边界二分要找什么?)
[2. 左边界二分代码](#2. 左边界二分代码)
[3. 左边界二分的移动逻辑](#3. 左边界二分的移动逻辑)
[4. 左边界二分为什么用偏左 mid?](#4. 左边界二分为什么用偏左 mid?)
[5. 左边界二分模板](#5. 左边界二分模板)
[1. 右边界二分要找什么?](#1. 右边界二分要找什么?)
[2. 右边界二分代码](#2. 右边界二分代码)
[3. 右边界二分的移动逻辑](#3. 右边界二分的移动逻辑)
[4. 右边界二分为什么用偏右 mid?](#4. 右边界二分为什么用偏右 mid?)
[5. 右边界二分模板](#5. 右边界二分模板)
[七、查找第一个和最后一个 target](#七、查找第一个和最后一个 target)
[1. 查找第一个等于 target 的位置](#1. 查找第一个等于 target 的位置)
[2. 查找最后一个等于 target 的位置](#2. 查找最后一个等于 target 的位置)
前言
二分查找是算法中非常经典的基础内容。
它的思想很简单:
每次取中间位置,然后根据判断结果排除一半范围。
但是二分真正容易出错的地方,不是思想,而是代码边界。
比如:
while (left <= right)
还是:
while (left < right)
mid 是这样写:
int mid = left + (right - left) / 2;
还是这样写:
int mid = left + (right - left + 1) / 2;
找左边界时为什么是:
right = mid;
找右边界时为什么又是:
left = mid;
这些问题都和二分的搜索区间、边界更新方式有关。
本文会从普通二分开始,逐步介绍:
1. 什么是二分查找
2. 普通二分怎么写
3. mid 为什么要这样计算
4. 左边界二分怎么理解
5. 右边界二分怎么理解
6. 二分复杂度为什么是 O(log n)
一、什么是二分查找?
二分查找适用于有序数组,或者具有单调性的问题。
比如有一个升序数组:
int a[] = {1, 3, 5, 7, 9, 11, 13};
如果要查找 11,可以先看中间值 7。
因为:
11 > 7
所以 11 不可能在 7 的左边,只可能在右边。
于是左半部分可以直接排除。
这就是二分的核心:
每次排除一半不可能的范围。
所以二分查找的前提是:
有序性,或者单调性。
如果数据没有顺序,也没有单调规律,就不能直接使用二分。
二、普通二分查找
普通二分用于在有序数组中查找某个值是否存在。
代码如下:
int binarySearch(int a[], int n, int target) {
int left = 0;
int right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (a[mid] == target) {
return mid;
} else if (a[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
这里使用的是闭区间:
[left, right]
也就是说,left 和 right 都是当前搜索范围内的有效下标。
所以循环条件是:
while (left <= right)
当:
left == right
时,区间里还有一个元素需要判断。
当:
left > right
时,说明搜索区间已经为空,查找失败。
三、普通二分的执行逻辑
普通二分每次取中间位置:
int mid = left + (right - left) / 2;
然后比较 a[mid] 和 target。
如果:
a[mid] == target
说明找到了,直接返回 mid。
如果:
a[mid] < target
说明 mid 以及 mid 左边的元素都小于目标值,答案只可能在右边。
所以更新:
left = mid + 1;
如果:
a[mid] > target
说明 mid 以及 mid 右边的元素都大于目标值,答案只可能在左边。
所以更新:
right = mid - 1;
普通二分适合查找某个值是否存在。
但是如果数组中有重复元素,它不保证找到第一个或者最后一个。
例如:
int a[] = {1, 3, 3, 3, 5, 7};
查找 3 时,普通二分可能返回下标 1、2 或 3。
如果要找第一个 3,就需要左边界二分。
如果要找最后一个 3,就需要右边界二分。
四、mid 的计算方式
很多人会这样写 mid:
int mid = (left + right) / 2;
这个写法在数学上没有问题,但在代码中可能出现整数溢出。
比如:
left = 2000000000;
right = 2100000000;
此时:
left + right
可能超过 int 的范围。
所以更推荐写成:
int mid = left + (right - left) / 2;
它先计算:
right - left
再加回 left,可以避免 left + right 过大。
这个写法得到的是偏左中点。
例如:
left = 0;
right = 5;
那么:
mid = 2;
如果想得到偏右中点,可以写成:
int mid = left + (right - left + 1) / 2;
此时:
left = 0;
right = 5;
mid = 3;
所以:
int mid = left + (right - left) / 2;
是偏左中点。
int mid = left + (right - left + 1) / 2;
是偏右中点。
偏左中点和偏右中点,在左右边界二分中非常重要。
五、左边界二分
1. 左边界二分要找什么?
左边界二分不是直接找 target,而是找:
第一个 >= target 的位置
对于升序数组来说,可以按照 target 把数组分成两部分:
< target | >= target
左边是小于 target 的部分。
右边是大于等于 target 的部分。
左边界二分要找的,就是右半部分的第一个位置。
例如:
int a[] = {1, 3, 3, 3, 5, 7, 9};
int target = 3;
可以看成:
1 | 3 3 3 5 7 9
↑
左边是 < target
右边是 >= target
所以第一个 >= 3 的位置是下标 1。
2. 左边界二分代码
int lowerBound(int a[], int n, int target) {
if (n == 0) return -1;
int left = 0;
int right = n - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return a[left] >= target ? left : -1;
}
这个函数返回的是:
第一个 >= target 的下标
如果不存在,就返回 -1。
3. 左边界二分的移动逻辑
左边界二分要找的是:
第一个 >= target 的位置
所以判断条件是:
a[mid] >= target
如果:
a[mid] >= target
说明 mid 已经进入了右半部分。
也就是说:
mid 可能是答案
答案也可能在 mid 左边
所以更新:
right = mid;
这里不能写成:
right = mid - 1;
因为 mid 本身可能就是第一个 >= target 的位置,不能丢掉。
如果:
a[mid] < target
说明 mid 还在左半部分。
也就是说:
mid 和 mid 左边都小于 target
它们都不可能是答案
所以更新:
left = mid + 1;
4. 左边界二分为什么用偏左 mid?
左边界二分中,mid 要这样计算:
int mid = left + (right - left) / 2;
这是偏左中点。
原因是左边界二分里面会出现:
right = mid;
当区间只剩两个元素时:
left = 3;
right = 4;
使用偏左中点:
mid = 3;
如果:
a[mid] >= target
执行:
right = mid;
区间变成:
[3, 3]
如果:
a[mid] < target
执行:
left = mid + 1;
区间变成:
[4, 4]
无论哪种情况,区间都会缩小。
但是如果左边界二分错误地使用偏右中点:
int mid = left + (right - left + 1) / 2;
当:
left = 3;
right = 4;
时:
mid = 4;
如果此时执行:
right = mid;
区间还是:
[3, 4]
范围没有变化,就可能死循环。
所以左边界二分要使用偏左中点。
5. 左边界二分模板
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
它对应的数组划分是:
< target | >= target
目标是找:
右半部分的第一个位置
六、右边界二分
1. 右边界二分要找什么?
右边界二分不是直接找 target,而是找:
最后一个 <= target 的位置
对于升序数组来说,可以按照 target 把数组分成两部分:
<= target | > target
左边是小于等于 target 的部分。
右边是大于 target 的部分。
右边界二分要找的,就是左半部分的最后一个位置。
例如:
int a[] = {1, 3, 3, 3, 5, 7, 9};
int target = 3;
可以看成:
1 3 3 3 | 5 7 9
↑
左边是 <= target
右边是 > target
所以最后一个 <= 3 的位置是下标 3。
2. 右边界二分代码
int upperBoundLastLE(int a[], int n, int target) {
if (n == 0) return -1;
int left = 0;
int right = n - 1;
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (a[mid] <= target) {
left = mid;
} else {
right = mid - 1;
}
}
return a[left] <= target ? left : -1;
}
这个函数返回的是:
最后一个 <= target 的下标
如果不存在,就返回 -1。
3. 右边界二分的移动逻辑
右边界二分要找的是:
最后一个 <= target 的位置
所以判断条件是:
a[mid] <= target
如果:
a[mid] <= target
说明 mid 还在左半部分。
也就是说:
mid 可能是答案
答案也可能在 mid 右边
所以更新:
left = mid;
这里不能写成:
left = mid + 1;
因为 mid 本身可能就是最后一个 <= target 的位置,不能丢掉。
如果:
a[mid] > target
说明 mid 已经进入了右半部分。
也就是说:
mid 和 mid 右边都大于 target
它们都不可能是答案
所以更新:
right = mid - 1;
4. 右边界二分为什么用偏右 mid?
右边界二分中,mid 要这样计算:
int mid = left + (right - left + 1) / 2;
这是偏右中点。
原因是右边界二分里面会出现:
left = mid;
当区间只剩两个元素时:
left = 3;
right = 4;
如果使用偏左中点:
int mid = left + (right - left) / 2;
那么:
mid = 3;
如果执行:
left = mid;
区间还是:
[3, 4]
范围没有变化,就会死循环。
所以右边界二分必须使用偏右中点:
int mid = left + (right - left + 1) / 2;
这样当:
left = 3;
right = 4;
时:
mid = 4;
如果:
a[mid] <= target
执行:
left = mid;
区间变成:
[4, 4]
如果:
a[mid] > target
执行:
right = mid - 1;
区间变成:
[3, 3]
无论哪种情况,区间都会缩小,不会死循环。
5. 右边界二分模板
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (a[mid] <= target) {
left = mid;
} else {
right = mid - 1;
}
}
它对应的数组划分是:
<= target | > target
目标是找:
左半部分的最后一个位置
七、查找第一个和最后一个 target
有了左边界和右边界二分,就可以解决重复元素的问题。
1. 查找第一个等于 target 的位置
找第一个等于 target,可以转化为:
找第一个 >= target 的位置
然后判断这个位置是否真的等于 target。
代码如下:
int firstEqual(int a[], int n, int target) {
if (n == 0) return -1;
int left = 0;
int right = n - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return a[left] == target ? left : -1;
}
例如:
int a[] = {1, 3, 3, 3, 5, 7};
查找第一个 3,返回下标 1。
2. 查找最后一个等于 target 的位置
找最后一个等于 target,可以转化为:
找最后一个 <= target 的位置
然后判断这个位置是否真的等于 target。
代码如下:
int lastEqual(int a[], int n, int target) {
if (n == 0) return -1;
int left = 0;
int right = n - 1;
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (a[mid] <= target) {
left = mid;
} else {
right = mid - 1;
}
}
return a[left] == target ? left : -1;
}
例如:
int a[] = {1, 3, 3, 3, 5, 7};
查找最后一个 3,返回下标 3。
八、二分查找的复杂度分析
二分查找的效率很高,原因在于它每次都会把搜索范围缩小一半。
假设一开始有 n 个元素。
第一次查找后,范围变成:
n / 2
第二次查找后,范围变成:
n / 4
第三次查找后,范围变成:
n / 8
继续下去,第 k 次查找后,范围变成:
n / 2^k
当搜索范围缩小到只剩 1 个元素时,查找基本结束。
也就是说:
n / 2^k = 1
两边同时乘以 2^k,得到:
n = 2^k
所以:
k = log₂n
这里的 k 就是二分查找大概需要执行的次数。
因此,二分查找的时间复杂度是:
O(log n)
九、为什么二分比普通遍历快?
普通遍历是一个一个查找。
如果数组长度是 1000000,最坏情况下可能需要查找:
1000000 次
而二分查找每次排除一半范围。
大概只需要:
log₂1000000 ≈ 20
也就是说,面对一百万个有序数据,二分查找大约二十次左右就可以完成查找。
这就是二分查找比普通遍历快很多的原因。
十、二分的空间复杂度
如果使用循环写法,二分只需要几个变量:
int left;
int right;
int mid;
没有额外数组,也没有递归调用栈。
所以空间复杂度是:
O(1)
如果使用递归写法,函数会不断递归调用,产生调用栈。
递归深度和二分次数一样,大约是:
log₂n
所以递归写法的空间复杂度是:
O(log n)
实际写代码时,更推荐使用循环写法。
十一、左右边界二分总结
左边界二分:
目标:
找第一个 >= target 的位置
数组划分:
< target | >= target
判断条件:
a[mid] >= target
满足条件:
right = mid
不满足条件:
left = mid + 1
mid 写法:
int mid = left + (right - left) / 2;
循环条件:
while (left < right)
右边界二分:
目标:
找最后一个 <= target 的位置
数组划分:
<= target | > target
判断条件:
a[mid] <= target
满足条件:
left = mid
不满足条件:
right = mid - 1
mid 写法:
int mid = left + (right - left + 1) / 2;
循环条件:
while (left < right)
十二、总结
普通二分用于查找某个值是否存在。
模板如下:
while (left <= right) {
int mid = left + (right - left) / 2;
if (a[mid] == target) {
return mid;
} else if (a[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
左边界二分用于查找第一个满足条件的位置。
可以理解为:
< target | >= target
找右半部分的第一个位置。
模板如下:
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
右边界二分用于查找最后一个满足条件的位置。
可以理解为:
<= target | > target
找左半部分的最后一个位置。
模板如下:
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (a[mid] <= target) {
left = mid;
} else {
right = mid - 1;
}
}
最后记住三句话:
普通二分:找某个值是否存在。
左边界二分:找第一个 >= target 的位置,用偏左 mid。
右边界二分:找最后一个 <= target 的位置,用偏右 mid。
二分查找的时间复杂度是:
O(log n)
循环写法的空间复杂度是:
O(1)
二分看起来简单,但真正的重点在于边界控制。只要想清楚数组如何被 target 分成两部分,以及自己要找哪一部分的边界,二分就会清晰很多。