二分算法详解

1. 二分定义

二分查找(百度百科):二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。
上述的定义只是狭义上的二分查找定义,在上述定义中提到了一个概念:有序,但实际上,我们只需要让线性表满足二段性即可使用二分。

2. 二段性

那么,什么是二段性呢?
所谓二段性,就是在线性表中有一个元素,使得该元素的左侧满足性质1,右侧满足性质2。
举个🌰,有一个数组nums = [4, 5, 6, 7, 0, 1, 2],该数数组原本是严格递增的,但是被按照某个点旋转了一次。现在我们需要找出该数组的原始起点(当然,直接遍历一遍是一种有效但并不优美的做法)。在这例子中,起点当然是0了,并且我们通过观察可以发现,0的左侧满足所有的元素都大于等于nums[0] = 4(性质1),而0及其右侧元素都小于nums[0] = 4(性质2)。那么此时,元素0就是让这个线性表具有二段性的元素之一(为什么说之一呢,因为例如7也能使该线性表具有二段性)。
为什么具有二段性就能使用二分呢?
还是拿上述例子进行说明,我们既然清楚了我们需要查找的元素具有二段性,那么,我们是否可以利用这个性质缩小查询范围以不断逼近并最终查询到这个元素呢?

3. 利用二段性实现二分

答案是肯定的。每一次,我们取整个线性表的中间元素(下标记为mid),判断nums[mid]满足性质1还是性质2

  • 如果满足性质1,则说明nums[mid]在目标元素的左侧,此时我们将区间左端点(l)移动到mid + 1(因为此时我们可以明确的知道nums[mid]并不是我们需要的元素)。
  • 如果满足性质2,则说明nums[mid]就是目标元素或是在目标元素右侧,此时我们将区间右端点移动到mid

于是,二分查找的代码就有了:

1
2
3
4
5
6
7
int l = 0, r = n - 1;
int x = nums[0];
while (l < r) {
int mid = l + r >> 1;
if (nums[mid] >= x) l = mid + 1;
else r = mid;
}

l + r >> 1等价于(l + r) / 2

到此处,我们几乎能用一句话总结二分,即利用二段性不断逼近直到查找到目标元素

4. 二分常见模板

这类题目一般能够使用二分(因为他们通常情况下具有二段性):

  • 查找最小的最大值
  • 查找最大的最小值

模板1:

1
2
3
4
5
6
7
int l = 0, r = n - 1;
while (l < r) {
int mid = l + r >> 1;
// check() 判断 mid 是否满足性质1或性质2
if (check(mid)) r = mid;
else l = mid + 1;
}

模板2:

1
2
3
4
5
6
int l = 0, r = n - 1;
while (l < r) {
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}

为什么会有两种模板呢?
主要是出于两个目的:

  1. 时时刻刻保证目标元素在所维护区间的内部
  2. 处理边界问题(直接背过就行,当出现r = mid - 1时,mid就定义为int mid = l + r + 1 >> 1;,前人总结出的经验)。

在次给出几道leetcode例题练练手(通过看优质题解是很好的进步方式):

5. 扩展:二分答案

对于二分的题目,很多时候还会以二分答案的形式出题。
什么是二分答案?
我们以一道例题为例:跳石头

5.1 题目描述

一年一度的“跳石头”比赛又要开始了!
这项比赛将在一条笔直的河道中进行,河道中分布着一些巨大岩石。组委会已经选择好了两块岩石作为比赛起点和终点。在起点和终点之间,有 NN 块岩石(不含起点和终点的岩石)。在比赛过程中,选手们将从起点出发,每一步跳向相邻的岩石,直至到达终点。
为了提高比赛难度,组委会计划移走一些岩石,使得选手们在比赛过程中的最短跳跃距离尽可能长。由于预算限制,组委会至多从起点和终点之间移走 MM 块岩石(不能移走起点和终点的岩石)。

输入格式

第一行包含三个整数 L,N,ML,N,M,分别表示起点到终点的距离,起点和终点之间的岩石数,以及组委会至多移走的岩石数。保证 L1L \geq 1NM0N \geq M \geq 0
接下来 NN 行,每行一个整数,第 ii 行的整数 Di(0<Di<L)D_i\,( 0 < D_i < L), 表示第 ii 块岩石与起点的距离。这些岩石按与起点距离从小到大的顺序给出,且不会有两个岩石出现在同一个位置。

输出格式

一个整数,即最短跳跃距离的最大值。

样例输入 #1

1
2
3
4
5
6
25 5 2 
2
11
14
17
21

样例输出 #1

1
4

输入输出样例 1 说明

将与起点距离为 221414 的两个岩石移走后,最短的跳跃距离为 44(从与起点距离 1717 的岩石跳到距离 2121 的岩石,或者从距离 2121 的岩石跳到终点)。

数据规模与约定

对于 20%20\%的数据,0MN100 \le M \le N \le 10
对于 50%50\% 的数据,0MN1000 \le M \le N \le 100
对于 100%100\% 的数据,0MN50000,1L1090 \le M \le N \le 50000,1 \le L \le 10^9

5.2 题解

本题中,我们的目的是要寻找最短跳跃距离的最大值,这个答案肯定在1L1\thicksim L之间,最暴力的方法就是从LL枚举到11,找到的第一个合法答案就是最优解。那么什么样的答案能被称为合法呢?题目要求最多只能挪走MM块石头,所以如果在挪走MM块石头之前能够满足每两块石头间的距离大于等于我们枚举的答案,那么这个答案就是合法的。而挪走石头的顺序就是要先解决距离最小的石头,因此从小到大开始挪石头即可,每次挪完石头记录挪走的石头数量,然后更新一下下一块石头与挪走的石头的上一块石头之间的距离。若挪完石头发现挪走石头的数量大于MM,那么我们枚举的这个答案就不是满足题目要求的最短跳跃距离的最大值,就继续往前找。

我们可以发现,在上面的分析过程中,我们是先从答案入手,再去判断答案是否满足题目的限制条件。而这一暴力过程其实是可以使用二分优化的。这种从答案出发,使用二分优化的方式就被称为二分答案

重点分析check函数,二分的精髓。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.io.*;
import java.util.Arrays;
public class Main {
static final int N = 50010;
static int L, n, m;
// dist记录当前点与前一个点的距离
static int[] a = new int[N], dist = new int[N];
public static boolean check(int mid) {
int cnt = 0;
int[] backup = Arrays.copyOf(dist, dist.length);
for (int i = 1; i <= n; i ++)
if (dist[i] < mid) {
cnt ++;
dist[i + 1] += dist[i];
}
// 每一轮挪完石头需要将dist数组还原,因为每轮挪石头都是独立的
dist = Arrays.copyOf(backup, backup.length);
return cnt <= m;
}
public static void main(String[] args) throws IOException{
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String[] s = br.readLine().split(" ");
L = Integer.parseInt(s[0]);
n = Integer.parseInt(s[1]);
m = Integer.parseInt(s[2]);
for (int i = 1; i <= n; i ++) {
a[i] = Integer.parseInt(br.readLine().split(" ")[0]);
dist[i] = a[i] - a[i - 1];
}
dist[n + 1] = L - a[n];
n ++;
int l = 1, r = L;
while (l < r) {
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
System.out.print(l);
}
}