文章目录
前言
本文会深入浅出的讲解二分搜索和二分答案
对于还不知道的同学,本文会带你入门,了解其本质原理和应用
对于已经了解,但每次应用还需要模板辅助的同学,本文会让你不再依赖模板,从死记硬背进化到轻松推演
对于已经对二分法滚瓜烂熟的同学,本文会给你带来新的理解,刻入骨子里一辈子都不会忘
二分搜索是一个非常常用的基础算法,应用于在有序数组(增序和减序是一样的,一般我们都用增序为例)中搜索目标值,有三个小分类:
- 精确查找一个等于目标值的下标
- 找第一个大于(等于)目标值的下标
- 找最后一个小于(等于)目标值的下标
二分答案是算法题中的一个利器,可以有效的题目降低难度,将一道难题变成中等题,将中等题变成简单题,将简单题变成水题
二分搜索
精确查找
最朴素的做法
最朴素的做法就是,遍历一遍数组 a,当遍历到元素等于目标值时,就找到了,如果遍历完都没找到,那么意味着数组 a 中没有目标值
代码如下:
int findTarget(vector<int> &a, int target) {
for (int i = 0; i < a.size(); i ++) {
if (a[i] == target) {
return i;
}
}
return -1;
}
这种做法的复杂度是 O ( n ) O(n) O(n),当数组非常大时,是非常耗时的
Tips:
在各种语言中,在各种容器上都有类似 find 的函数,来查找目标值第一个所在下标,用的都上述算法,都是 O ( n ) O(n) O(n) 的复杂度
比如c++: vector.find
,python: List.index
,js: Array.indexOf
这种运算由于只有一个语句,对于复杂度不敏感的工程师,往往会忽略它的复杂度,以为是O(1)
这种问题在算法比赛中很少遇到,但是工程上经常会遇到
切记一定要避免
随机取值
因为组数非常大,我们顺序遍历可能会花费很大的代价才能找到目标
那么能不能随机取值呢,如果恰巧随机到目标,那岂不是很快就能结束查找
代码如下:
int randSearch(vector<int> &a, int target) {
while (true) {
// 随机到天荒地老
int index = rand() % a.size();
if (a[index] == target) {
return index;
}
}
}
显然,上述程序有以下这两个问题:
- 没有随机结束条件,如果数组 a 中没有目标值,一直不会结束
- 随机是无序的,可能某个下标会被反复随机到
优化一下
上述随机函数没有利用到有序(递增)数组这一特性,我们可以根据这个特性进行优化
我们用另一个数组 comp 来做辅助解释,comp 的规则为:
- 当 a[i] < Target 时,comp[i] = ‘S’ (smaller)
- 当 a[i] == Target 时,comp[i] = ‘E’ (equal)
- 当 a[i] > Target 时,comp[i] = ‘B’ (bigger)
由于 a 是有序数组,所以 comp 数组一定符合以下特征:
S
,
S
,
.
.
.
S
⏟
0
o
r
m
o
r
e
S
,
E
,
E
,
.
.
.
E
⏟
0
o
r
m
o
r
e
E
,
B
,
B
,
.
.
.
B
⏟
0
o
r
m
o
r
e
B
\underbrace{S, S, ... S}_{0\ or\ more\ S}, \underbrace{E, E, ... E}_{0\ or\ more\ E}, \underbrace{B, B, ... B}_{0\ or\ more\ B}
0 or more S
S,S,...S,0 or more E
E,E,...E,0 or more B
B,B,...B
所以我们可以得出以下几个推论:
- 当 a[i] == target 既 comp[i] == ‘E’
- 找到目标
- 当 a[i] < target 既 comp[i] == ‘S’
- i 左边的一定都是 S,意味着左边的数据没有继续搜索的价值,我们只需要搜索 i 右边的内容就行
- 既可以把后续的搜索范围的下边界设置为 i + 1
- 当 a[i] > target$ 既 comp[i] == ‘B’
- i 右边的一定都是 B,意味着右边的数据没有继续搜索的价值,我们只需要搜索 index 左边的内容就行
- 既可以把后续的搜索范围的上边界设置为 i - 1
根据上述推论,我们就可以对之前的随机查找函数进行优化
同时,也确定了搜索结束的时机:
因为我们会不断缩小搜索范围,直到为空,如果搜索范围为空了都没找到目标值,那就代表着搜索结束,数组 a 中没有目标值
代码如下:
int randSearch(vector<int> &a, int target) {
int lo = 0;
int hi = a.size() - 1; // 首先,设定我们的搜索范围的上下边界 [0, a.size() - 1]
while (lo <= hi) { // 搜索范围不为空的时,进行搜索,当 left > right 时,意味着搜索范围为空
int index = lo + rand() % (hi - lo + 1) // 随机一个在搜索范围内的下标
if (a[index] == target) {
return index;
} else if (a[index] < target) {
lo = index + 1; // 前面的数字一定都小于 target,没有搜索的必要,将搜索的下边界增大
} else {
hi = index - 1; // 后续的数字一定都大于 target,没有搜索的必要,将搜索的上边界减少
}
}
return -1; // 搜索范围缩小到空都没找到,意味着数组 a 中没有目标值
}
这个算法其实很难测算复杂度,每次缩小的搜索范围都是随机的,可能只缩小了1个,也有可能一下子就缩小到非常接近目标
我们按最差复杂度来算,每次只缩小1个,所以它的复杂度依然还是 O(n)
二分查找
上述算法因为每次都是随机,每次可能只缩小一点点范围,导致最差复杂度依然很高
为了快速搜索到目标值,最好是每次能稳定的减少一半的搜索范围,而选取搜索范围最中间的那个下标,就可以让搜索范围减半
- 中间下标的值等于目标值,找到目标
- 中间下标的值小于目标值,则左半边的不用再搜,范围缩小了一半
- 中间下标的值大于目标值,则右半边的不用再搜,范围缩小了一半
因为每次范围都缩小了一半,所以最差经过
l
o
g
2
n
log_2n
log2n 次查找后,一定能找到目标值或者将范围缩小为空
所以整体的复杂度是
O
(
l
o
g
n
)
O(logn)
O(logn)
代码如下:
int binarySearch(vector<int> &a, int target) {
int lo = 0;
int hi = a.size() - 1;
while (lo <= hi) {
int mid = (lo + hi) >> 1; // 利用位运算快速计算除2
if (a[mid] == target) {
return mid;
} else if (a[mid] < target) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return -1; // 找不到
}
运行示例
a = [0, 2, 4, 6, 8, 10], target = 6
a 0 2 4 6 8 10
index 0 1 2 3 4 5
第一次 l m h // a[mid] = 4 < 6 => lo = mid + 1
第二次 l m h // a[mid] = 8 > 6 => hi = mid - 1
第三次 l/m h // a[mid] = 6 == 6 => findTarget
查找第一个大于(等于)目标值的下标
最朴素的做法(遍历枚举)就不再赘述,这里来介绍下如何将上述精确查找的思路进行修改升级
由于不是精确查找,所以原本的辅助数组 comp 不再使用,我们该用另一个数组 match 来辅助解释,match 的生成规则为:
- 当 a[i] not 大于(等于)目标值时,match[i] = ‘F’ (false)
- 当 a[i] 大于(等于)目标值时,match[i] = ‘T’ (true)
由于 a 是有序数组,所以 match 一定符合以下特征:
F , F , . . . F ⏟ 0 o r m o r e F , T , T , . . . T ⏟ 0 o r m o r e T \underbrace{F, F, ... F}_{0\ or\ more\ F}, \underbrace{T, T, ... T}_{0\ or\ more\ T} 0 or more F F,F,...F,0 or more T T,T,...T
我们将这个特征称之为,数组的二分性:既以某个下标为分界线,下标左边都匹配/不匹配某个规则,下标右边都不匹配/匹配某个规则
显然,对于有序数组,规则为【大于(等于)目标值】是符合二分性的
我们同样可以得到以下几个推论:
- 当 a[i] < target 既 match[i] == ‘F’$
- i 左边的一定都是 F,意味着左边的数据没有继续搜索的价值,我们只需要搜索 index 右边的内容就行
- 既可以把后续的搜索范围的下边界设置为 i + 1
- 如果 a[i] >= target 既 comp[i] == ‘T’
- 找到匹配【大于(等于)目标】的值,但它并不一定是第一个,但它右边的数据一定不是第一个,也就失去了搜索的价值,我们只需要再尝试搜索 i 左边的内容就行
- 既可以把后续的搜索范围的上边界设置为 i - 1
- 同时我们将这个下标暂时标记为答案
- 如果后续还能找到匹配条件的值,那么会更新这个答案
- 如果后续找不到匹配条件的值,那么说明这个答案就是第一个
结束条件同样是搜索范围为空,当数组不存在大于(等于)目标值的情况,答案也不会被更新
代码如下:
bool match(int number, int target) {
return number > target; // number >= target
}
int firstBinarySearch(vector<int> &a, int target) {
int lo = 0;
int hi = a.size() - 1;
int answer = -1;
while (lo <= hi) {
int mid = (lo + hi) >> 1;
if (match(a[mid], target)) {
hi = mid - 1;
answer = mid;
} else {
lo = mid + 1;
}
}
return answer;
}
运行示例
a = [0, 2, 4, 6, 8, 10], target = 5
a 0 2 4 6 8 10
match F F F T T T
index 0 1 2 3 4 5
第一次 l m h // a[mid] = 4 > 5 => false => lo = mid + 1
第二次 l m h // a[mid] = 8 > 5 => true => hi = mid - 1
第三次 l/m h // a[mid] = 6 > 5 => true => hi = mid - 1
第四次 h l // break
answer = hi + 1 = 3
查找最后一个小于(等于)目标值的下标
和查找第一个大于(等于)目标值的下标一样
不过数组的二分性变成了
T
,
T
,
.
.
.
T
⏟
0
o
r
m
o
r
e
T
,
F
,
F
,
.
.
.
F
⏟
0
o
r
m
o
r
e
F
\underbrace{T, T, ... T}_{0\ or\ more\ T}, \underbrace{F, F, ... F}_{0\ or\ more\ F}
0 or more T
T,T,...T,0 or more F
F,F,...F
推导过程类似,大家可以自己尝试推导
对应的代码如下:
bool match(int number, int target) {
return number < target; // number <= target
}
int firstBinarySearch(vector<int> &a, int target) {
int lo = 0;
int hi = a.size() - 1;
while (lo <= hi) {
int mid = (lo + hi) >> 1;
if (match(a[mid], target)) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return lo - 1;
}
思考:
这里代码中省略了 answer 的赋值,直接用 lo - 1 代替,大家自行可以思考下为什么可以这样子
在
运行示例
a = [0, 2, 4, 6, 8, 10], target = 5
a 0 2 4 6 8 10
match T T T F F F
index 0 1 2 3 4 5
第一次 l m h // a[mid] = 4 < 5 => true => lo = mid + 1
第二次 l m h // a[mid] = 8 < 5 => false => hi = mid - 1
第三次 l/m/h // a[mid] = 6 < 5 => false => hi = mid - 1
第四次 h l // break
answer = lo - 1
STL lower_bound, upper_bound
c++ 的 STL 中自带了 lower_bound 和 upper_bound 两个函数可以直接对一个递增数组进行二分搜索
lower_bound 是查找第一个不小于目标值的元素
upper_bound 是查找第一个大于目标值的元素
示例:
vector<int> a = {0, 2, 4, 6, 8};
int firstIndexNotLessThan6 = lower_bound(a.begin(), a.end(), 6) - a.begin(); // 3
int firstIndexGreaterThan6 = upper_bound(a.begin(), a.end(), 6) - a.begin(); // 4
二分答案
上述的二分是都是应用在有序数组中的查找,而在算法题中,二分还有一个常用的方法,就是对答案进行二分
通常是针对那些答案无法直接计算出来,同时答案在枚举区间内符合二分性的算法题
Case
我们以一个实际的 case 来进行讲解:
leetcode 410 难
给定一个非负整数数组 nums 和一个整数 m ,你需要将这个数组分成 m 个非空的连续子数组。
设计一个算法使得这 m 个子数组各自和的最大值最小。
1 <= nums.length <= 1000
0 <= nums[i] <= 10 6 ^6 6
1 <= m <= min(50, nums.length)
思路
直接求这个答案没有任何思路,而完全枚举所有的可能性,有 C n − 1 m C_{n-1}^{m} Cn−1m(n 是数组的长度) 种可能,是个天文数字,不可行
我们将题目用另一个方式来描述:
找到一个最小的整数 x,使其匹配以下条件: m 个子数组的各自的最大值小于等于 x。
是不是和上文已经讲解的查找第一个大于(等于)目标值的下标
很像?
找到一个最小的下标 index,使其匹配以下条件:a[index] > target
我们非常愉快的发现,整个题目结构是一模一样的,这就意味着可以用二分的模板来解决
但是在套用二分的模板之前,我们还有几个问题需要解决:
- 答案是否符合二分性
- 二分搜索的范围是数组的下标,那么二分答案的范围是啥
- 匹配条件比之前的 a[index] > target 复杂很多,应该如何编写
答案的二分性
先回忆一下二分搜索中提到数组的二分性:以某个下标为分界线,下标左边都匹配/不匹配某个规则,下标右边都不匹配/匹配某个规则
映射到答案的二分性即为:以某个数字为分界线,小于(等于)该数字的都匹配/不匹配某个规则,大于(等于)该数字的都不匹配/匹配某个规则
在此题下,我们需要证明,如果 x 匹配条件,那么 y(y > x) 一定也匹配条件。很容易证明:
我们将 m 个子数组的各自的最大值记为
m
a
x
s
u
m
m
max_{sum_m}
maxsumm
已经知
(1). x 匹配条件【 m 个子数组的各自的最大值 小于等于 x】,既
m
a
x
s
u
m
m
≤
x
max_{sum_m} \leq x
maxsumm≤x
(2). x < y
由 (1)(2) 可得:
m
a
x
s
u
m
m
<
y
max_{sum_m} < y
maxsumm<y,既 m 个子数组的各自的最大值 小于 y,既 y 匹配条件
搜索范围
搜索的范围就是答案可能的范围,不用特别的精确,一般我们通过构造极限 case 来限定
本题中
- 当 m = 1 时,只能分成一个子串,那么答案最大可能是 ∑ i = 0 n − 1 a i \sum_{i=0}^{n-1}a_i ∑i=0n−1ai
- 当 m = n 时,每个元素各自成为一个子串,那么答案最小可能是 m a x ( a i ) max(a_i) max(ai)
在二分算法中,我们并不会特别在意搜索范围的大小,因为就算搜索范围扩大了1000倍,实际log之后,也就仅仅多了10次搜索
所以不管 nums、m 如何变化,我们都可以采用相同的搜索范围,既:
lo = 0 (对应 nums[i] = 0, n = 1, m = 1 的情况)
hi = 10
9
^9
9 (对应 nums[i] = 10
6
^6
6, n = 1000, m = 1的情况)
匹配函数
我们可以将匹配条件【 m 个子数组的各自的最大值 小于等于 x】写成一个匹配函数
函数参数为二分的答案x(可能还需要配合其他辅助计算的参数),函数的主体就是判断 x 是否符合条件
本题中匹配函数代码见下面代码的 match 函数(这不是二分答案的重点,推导过程略过)
代码
class Solution {
bool match(int x, vector<int>& nums, int k) {
int cnt = 1;
int sum = 0;
for (int item : nums) {
if (item > x) {
return false;
}
if (sum + item > x) {
cnt++;
sum = item;
} else {
sum += item;
}
}
return cnt <= k;
}
public:
int splitArray(vector<int>& nums, int k) {
int lo = 0;
int hi = 1e9;
int answer = -1;
while (lo <= hi) {
int mid = (lo + hi) >> 1;
if (match(mid, nums, k)) {
answer = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return answer;
}
};
总结
对于求一个最大/最小值的题,如果没有直接的计算思路,可以先尝试将题目描述转化成
寻找一个最大/最小值 x,使 x 匹配 xxx 条件
并且证明其二分性,那么就可以使用二分答案的套路
二分答案可以非常有效的降低题目难度,因为将原本求解的题变成判断题(只需要编写匹配函数)
以 codeforces 为例,二分答案这个思路通常就值 800 分,原题 2400 难度的题,用二分答案讲选择题转变为判断题后,判断题部分其实只有 1600 难度
以 leetcode 为例,二分答案后直接将题目难度降低一个等级(难 -> 中等,中等 -> 简单)
tips:
一般题目要求计算最小值最大 或 最大值最小 的都可以尝试用二分答案,但并不绝对,不要盲目套用
严谨的还是以很难直接计算得到结果,答案符合二分性这两个判断为准