文章目录
1. 二分法简介 #1
二分法是数学领域术语,在数学上,对于区间
[
a
,
b
]
[a,b]
[a,b]上连续不断且
f
(
a
)
⋅
f
(
b
)
<
0
f(a)\cdot f(b)<0
f(a)⋅f(b)<0的函数
y
=
f
(
x
)
y=f(x)
y=f(x),通过不断地把函数
f
(
x
)
f(x)
f(x)的零点所在的区间一分为二,使区间的两个端点逐步逼近零点,进而得到零点近似值的方法叫二分法。而在算法与数据结构中,二分法也通常用于寻找一个满足特定条件的值。一般地,二分法作用于有序段区间。使用二分法的流程主要包括:首先寻找区间中点位置的值,并判断其是否满足条件;然后根据区间中点值的特点,不断缩小区间;最后根据循环退出条件得到此次二分的结果。如在区间
[
1
,
2
,
4
,
5
,
7
,
8
,
10
,
11
,
14
,
15
]
[1,2,4,5,7,8,10,11,14,15]
[1,2,4,5,7,8,10,11,14,15]内使用二分法查找元素
14
14
14的位置:
在上图中,通过比较数组在 m i d mid mid处的值与目标值的大小,从而确定区间缩小的规则。这里,我们可以写出二分法在某有序区间内查找给定值的算法形式:
// 初始化,l指向区间的第一个元素,r指向区间的最后一个元素
int l = 0, r = nums.size() - 1;
// 这里循环退出的条件为区间变为[l,l+1]或[r+1,r],即存在不合理区间
while (l <= r) {
int mid = (l + r) / 2;
if (a[mid] == target) {
return mid;
}
// 缩小区间时去掉a[mid]这个元素
else if (a[mid] < target) {
l = mid + 1;
}
else {
r = mid - 1;
}
}
// 查找失败
return -1;
上述是二分法的基础版本,值得注意的是:由于位置 l l l和 r r r都是合理的,所以循环的判断条件为 l ≤ r l\leq r l≤r。这时,如果 r r r的值设置为 n u m s . s i z e ( ) nums.size() nums.size(),则循环判断条件改为 l < r l< r l<r,循环退出的条件为 [ l , l ) [l,l) [l,l)或 [ r , r ) [r,r) [r,r)。此外,由于此时 r r r的位置是不合理的,所以在去掉元素 a [ m i d ] a[mid] a[mid]时, r r r指向 m i d mid mid即可。程序变为:
// 初始化,l指向区间的第一个元素,r指向区间的最后一个元素
int l = 0, r = nums.size();
// 这里循环退出的条件为区间变为[l,l)或[r,r),即存在不合理区间
while (l < r) {
int mid = (l + r) / 2;
if (a[mid] == target) {
return mid;
}
// 缩小区间时去掉a[mid]这个元素
else if (a[mid] < target) {
l = mid + 1;
}
else {
r = mid;
}
}
// 查找失败
return -1;
对于二分法的时间复杂度的计算,每次二分都会将问题规模缩小为原来的一半。假设我们共二分了 k k k次才找到给定元素,则: n ⋅ ( 1 2 ) k = 1 n\cdot (\frac{1}{2})^k=1 n⋅(21)k=1
可以解得: k = log 2 ( n ) k=\log_2(n) k=log2(n)
其中, n n n为问题的原始规模。所以,在遇到某算法题的时间复杂度要求为 O ( log 2 ( n ) ) O(\log_2(n)) O(log2(n)),则一般是使用二分/折半的思想解决。
2. 二分法的高级形式
上面介绍的是二分法在某有序区间内查找是否存在某指定元素的例子,而我们遇到的问题往往会更加复杂。
2.1 寻找左侧边界(返回比当前元素小的元素个数)# 2
同样以序列 [ 1 , 2 , 4 , 5 , 5 , 5 , 7 , 8 , 10 , 11 , 14 , 15 ] [1,2,4,5,5,5,7,8,10,11,14,15] [1,2,4,5,5,5,7,8,10,11,14,15]为例,查找比元素 5 5 5小的元素个数。这里,如果给定序列中不包含重复元素,则程序与上一节的一致。现在,如果满足 a [ m i d ] = t a r g e t a[mid]=target a[mid]=target,我们并不能直接返回位置 m i d mid mid,因为该位置可能并不是所在元素最左边的一个。所以,这时我们应该合理修改 r r r,同时不能去掉元素 a [ m i d ] a[mid] a[mid](如果当前元素就是最左边的元素,此时去掉了就会产生错误)。同时,由于我们需要返回一个合理的位置,所以循环退出的条件必须是 l = r l=r l=r。则最终的程序如下:
int l = 0, r = nums.size() - 1;
// 循环退出条件是l=r
while (l < r) {
int mid = (l + r) / 2;
// 该语句是确保找到左侧边界的关键
if (nums[mid] == target) {
// 需要保留位置mid
r = mid;
}
else if (nums[mid] > target) {
r = mid - 1;
}
else {
l = mid + 1;
}
}
// 判断循环退出的位置所对应的元素是否是所查找的元素
return nums[l] == target ? l : -1;
2.2 寻找右侧边界(返回不比当前元素大的元素个数)# 3
和上一节的内容类似,同样以序列 [ 1 , 2 , 4 , 5 , 5 , 5 , 7 , 8 , 10 , 11 , 14 , 15 ] [1,2,4,5,5,5,7,8,10,11,14,15] [1,2,4,5,5,5,7,8,10,11,14,15]为例,查找不比元素 5 5 5大的元素个数。同样地,如果满足 a [ m i d ] = t a r g e t a[mid]=target a[mid]=target,我们并不能直接返回位置 m i d mid mid,因为该位置可能并不是所在元素最右边的一个。所以,这时我们应该合理修改 l l l,同时不能去掉元素 a [ m i d ] a[mid] a[mid]。同时,由于我们需要返回一个合理的位置,所以循环退出的条件必须是 l = r l=r l=r。则最终的程序如下:
int l = 0, r = nums.size() - 1;
// 循环退出条件是l=r
while (l < r) {
// 假设某个时刻l和r指向的元素均为target,且r=l+1。
// 则此时mid=(l+r)/2=l,则会不断执行下面第一条if语句,程序陷入死循环,即l和r均没有更新。
// 回顾上一段程序,第一个if语句的内容为r=mid,同样假设某个时刻l和r指向的元素均为target,且r=l+1。
// 则此时mid=(l+r)/2=l,此时更新r=mid=l,满足循环退出条件,程序不会陷入死循环。
int mid = (l + r + 1) / 2;
// 该语句是确保找到右侧边界的关键
if (nums[mid] == target) {
// 需要保留位置mid
l = mid;
}
else if (nums[mid] > target) {
r = mid - 1;
}
else {
l = mid + 1;
}
}
// 判断循环退出的位置所对应的元素是否是所查找的元素
return nums[l] == target ? l : -1;
上面是二分法的三种基本形式,其他问题均可以在上面三种程序上小改即可得到。值得注意的是,所过所给长度序列过大,则 ( l + r ) / 2 (l+r)/2 (l+r)/2可能会产生溢出,此时可以使用 l + ( r − l ) / 2 l+(r-l)/2 l+(r−l)/2代替。下一节将介绍 L e e t C o d e {\rm LeetCode} LeetCode中的使用二分法解决问题的经典案例。
3. 二分法经典例题
3.1 在排序数组中查找第一个和最后一个位置
题目描述 给定一个升序排列的整数数组nums
,和一个整数target
。找出给定目标值在数组中的开始位置和结束位置。如果数组不存在目标值,则返回[-1,-1]
;否则返回开始位置和结束位置的索引。
该问题是上面第二个二分法#2
和第三个二分法#3
的综合。所以,整体程序如下:
vector<int> searchRange(vector<int>& nums, int target) {
if (nums.empty()) {
return {-1, -1};
}
int l = 0, r = nums.size() - 1;
// 寻找左边界
while (l < r) {
int mid = (l + r) / 2;
if (nums[mid] == target) {
r = mid;
}
else if (nums[mid] > target) {
r = mid - 1;
}
else {
l = mid + 1;
}
}
// 如果没有找到左边界,则直接返回
if (nums[l] != target) {
return {-1, -1};
}
int l_i = l;
l = 0, r = nums.size() - 1;
// 寻找右边界
while (l < r) {
int mid = (l + r + 1) / 2;
if (nums[mid] == target) {
l = mid;
}
else if (nums[mid] < target) {
l = mid + 1;
}
else {
r = mid - 1;
}
}
return {l_i, l};
}
其他题解 官方题解
3.2 搜索旋转排序数组
题目来源 33.搜索旋转数组
题目描述 给定一个升序排列的整数数组nums
,和一个整数target
。假设按照升序排序的数组在预先未知的某个点上进行了旋转。如数组[0,1,2,4,5,6,7]
旋转为[4,5,6,7,0,1,2]
。完成程序要求在数组中搜索给定的目标值,如果数组中存在这个目标值,则返回它的索引;否则返回-1
。
最直观的做法就是一次遍历,时间复杂度为
O
(
n
)
O(n)
O(n),空间复杂度为
O
(
1
)
O(1)
O(1)。前面提到过二分法适用于有序区间段,而本题给出的有序区间是经过旋转的,但总体仍可看作是多个有序区间的组合,即也可以使用二分法解题。由于题目要求是在区间内寻找某一特定值,这就符合了上面第一个二分法#1
。但是在本题使用二分法是存在几种特殊情况,即中点值大于目标值和中点值小于目标值时,这时我们要根据旋转点的位置来确定区间缩小规则,如:
旋转点在中点左侧,如果此时target=7
,如果是普通二分则下一步取右半部分区间。但由于旋转点在左侧,目标值也可能位于前半部分区间。所以,这时我们需要使用强条件将搜索区间限制在右半部分,该强条件是旋转点在左侧,中点值小于目标值且数组的最后一个元素大于目标值,那么目标值肯定在右侧区间;其他所有情况下,目标值在左侧区间。再来看旋转点在中点右侧的情况,如:
如果此时target=0
,如果是普通二分则下一步取左半部分区间。但由于旋转点在右侧,目标值也有可能位于后半部分区间。所以,这时我们需要使用强条件将搜索区间限制在左半部分,该强条件是旋转点在右侧,中点值大于目标值且数组的第一个元素小于目标值,那么目标值肯定在左侧区间;其他所有情况下,目标值在右侧区间。最后,整体程序如下:
int search(vector<int>& nums, int target) {
// 标准二分法
int l = 0, r = nums.size() - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] == target) {
return mid;
}
// 根据旋转点所在侧确定缩小区间的规则
// 旋转点在左侧
if (nums[mid] < nums[0]) {
if (nums[nums.size() - 1] == target) {
return nums.size() - 1;
}
// 取右侧区间
if (nums[mid] < target && nums[nums.size() - 1] > target) {
l = mid + 1;
}
// 取左侧区间
else {
r = mid - 1;
}
}
// 旋转点在右侧或者区间整体有序
else {
if (nums[0] == target) {
return 0;
}
// 取左侧区间
if (nums[mid] > target && nums[0] < target) {
r = mid - 1;
}
// 取右侧区间
else {
l = mid + 1;
}
}
}
// 没有找到,返回-1
return -1;
}
其他题解 官方题解
3.3 有序矩阵中第K小的元素
题目来源 378.有序矩阵中第K小的元素
题目描述 给定一个
n
×
n
n×n
n×n矩阵,其中每行和每列元素均按升序排序,找到矩阵中第k
小的元素。如给定矩阵为[[1,5,9],[10,11,13],[12,13,15]]
,第
k
=
8
k=8
k=8小的元素为13
。
首先,我们需要明确的一点是,对于该种类型的二维矩阵,搜索起点只能选在左下角或右上角。如图:
如图,如果选择红色箭头的位置为起点,当当前元素小于或大于目标值时,我们无法判断下一步的操作,因为这两个起点在两个方向上的变化是同步的。以左上角顶点为例,如果目标值为 4 4 4,这时无法确定是往右寻找,还是往下寻找。而绿色箭头的位置可以完成搜索,我们以左下角为例,如果目标值为 7 7 7,那么肯定在 13 13 13的上一行,直到找到第二行。然后目标元素比 5 5 5大,向右寻找,找到目标值 7 7 7。
回到该题本身,要找到二维矩阵中第 k k k小的数,即该元素满足矩阵中共有 k k k个元素不大于目标值。首先,我们根据此点写出从矩阵左下角开始寻找,不大于给定值的元素数量:
int countNotMoreThanMid(vector<vector<int>> matrix, int target) {
// 从矩阵的左下角开始搜索,matrix[size-1][0]
int size = matrix.size();
int i = size - 1, j = 0, count = 0;
// 循环遍历开始搜索
while (i >= 0 && j < size) {
// 第j列有i+1个元素均不大于target
if (matrix[i][j] <= target) {
count += i + 1;
++j; // 换下一列
}
// 如果不满足上式,我们需要向上搜索,即--i
else
{
--i;
}
}
// 返回不大于target的数目
return count;
}
上面程序帮助我们找到不大于给定值的元素个数,现在我们利用这一点使用二分法找到第
k
k
k小的元素。前面的二分法我们都是对索引进行二分,我们这里对值进行二分。首先明确二分循环退出的条件,由于题目所给定的
k
k
k值是有效的,所以返回的肯定是矩阵的某个元素,即退出条件为l==r
,此时返回
l
l
l或
r
r
r均可。此时设置二分左边界为矩阵中的最小值,即matrix[0][0]
;右边界为矩阵中的最大值,即matrix[n-1][n-1]
。然后每次取中点值,如果比中点值小的元素个数等于
k
k
k,此时我们并不能直接返回中点值,因为此时的中点值可能并不存在,如:
寻找第
k
=
5
k=5
k=5小的数,第一次二分取中值为
8
8
8,且不大于
8
8
8的元素个数为
5
5
5。而如果此时返回中值
8
8
8,则会得到错误答案,我们还是要以l==r
的条件自动返回所寻找的值。直观上,此时我们应该在左半部分区间寻找元素,此时设置r=mid
以保证我们搜索的目标值一定位于区间[l,r]
内。这里不能设置为r=mid-1
的原因是如果将上述矩阵的
6
6
6替换成
8
8
8,r=mid-1
就会漏掉目标值;在来看,比中点值小的元素个数大于
k
k
k,同上我们应该在左半部分区间寻找元素;如果比中点值小的元素个数小于
k
k
k,那么目标元素肯定在右面区间,这时缩小区间l = mid + 1
。
最后,我们再来看一点,为什么当l==r
结束循环时,l
值一定位于矩阵内。其实在上面缩小区间时,我们已经在每次缩小时目标元素肯定位于区间[l,r]
内,从而不会漏掉目标值。最后,总体程序如下:
int kthSmallest(vector<vector<int>>& matrix, int k) {
// 相关变量
int size = matrix.size();
// 二分边界
int l = matrix[0][0];
int r = matrix[size - 1][size - 1];
while (l < r)
{
// 每次循环保证mid位于[l,r]之间
// 这样在推出循环的时候,mid即为所求
int mid = (l + r) / 2;
// 边界收缩,且cnt值不一定保证是数组中的值
int cnt = countNotMoreThanMid(matrix, mid);
if (cnt < k) {
l = mid + 1;
}
else
{
r = mid;
}
}
// 返回l或者r均可
return l;
}
其他题解 官方题解
4. 总结
二分法是一种效率较高的查找方法,而前提是待查找区间有序,可以在数据规模的对数时间复杂度内完成查找。同时,二分法要求线性表具有随机访问的特点,也要求线性表能够根据中间元素推测它两侧元素的性质,以达到快速缩减问题规模的效果。二分法的一大难点是边界条件的确定,往往需要考虑多种情况。
参考
- https://leetcode-cn.com/tag/binary-search/.