Skip to main content

算法 - Binary Search

info

笔者将在本文记录和学习 二分搜索 的解题笔记。

二分搜索是基础算法,但是并不简单。二分搜索真正的坑在于到底要给 mid +1 还是 -1while 里到底用 <= 还是 <.

Ref: labuladong, liweiwei, etc.

Intuition

Binary search is an efficient array search algorithm. It works by narrowing down the search range by half each time. 是用来在一个有序数组中查找某一元素的算法。

前提

可以使用「二分查找」的前提是:问题告诉我们的 单调性。例如,数组是有序的。 有些问题虽然没有指出单调性,但是可以从题目中的描述得到可以 逐步缩小搜索区间 的条件,这些问题也可以使用「二分查找」来解决。

一定要找到某种单调性,才可以根据在区间 [left..right] 里猜的一个数 mid 的性质,决定:

  • mid 是否有可能是解.
  • 下一轮搜索是在 mid 的左边还是在右边继续查找.

性质

时间复杂度

二分查找的最优时间复杂度为 O(1). 二分查找的平均时间复杂度和最坏时间复杂度均为 O(logn)。因为在二分搜索过程中,算法每次都把查询的区间减半,所以对于一个长度为 n 的数组,至多会进行 O(logn) 次查找.

空间复杂度

迭代版本的二分查找的空间复杂度为 O(1). 递归 (无尾调用消除) 版本的二分查找的空间复杂度为 O(logn).

while (left < right) 模板

此解法是笔者在一位打 ACM 的同学那里学习而来,也是他推荐的二分模板。同时该模板也是 liweiwei 所推荐的模板。惭愧的是,笔者对二分搜索迟迟没有理解透彻。故写下思路,以致巩固学习。

Explanation

假设目标值在闭区间 [left, right] 中, 每次将区间长度缩小一半,当 left = right 时,我们就找到了目标值。

  • while (left < right) 表示当 leftright 重合的时候停止搜索,这样我们就不用思考返回 left 还是 right.
  • while 里面只写两个分支,即只有 ifelse,表示:非此即彼,把其中一种情况考虑好,不满足这种情况的区间就是上一个区间的反面区间。
  • left, right 是否 +1:
    • 如果 mid 在答案区间内,即 mid.
    • 如果 mid 不在答案区间内,即 mid + 1.

版本 1

当我们将区间 [left, right] 划分成 [left, mid][mid + 1, right] 时,其更新操作是 right = mid 或者 left = mid + 1; 计算 mid 时不需要加 1

在单调递增序列 A 中查找 >=x 的数当中最小的一个

// 目标元素可能存在在区间 [left..right]
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] >= x) {
// 下一轮搜索区间 [left..mid]
right = mid;
} else {
// 下一轮搜索区间 [mid + 1..right]
left = mid + 1;
}
}
// 退出循环以后 left == right 成立
// 视情况判断 nums[left] 是否是我们要找的

版本 2

当我们将区间 [left, right] 划分成 [left, mid - 1][mid, right] 时,其更新操作是 right = mid - 1 或者 left = mid; 此时为了防止死循环,计算 mid 时需要加 1。

在单调递增序列 A 中查找 <=x 的数当中最大的一个

// 目标元素可能存在在区间 [left..right]
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (A[mid] <= x) {
// 下一轮搜索区间是 [mid..right]
left = mid;
} else {
// 下一轮搜索区间是 [left..mid - 1]
right = mid - 1;
}
}
// 退出循环以后 left == right 成立
// 视情况判断 nums[left] 是否是我们要找的

死循环

在搜索区间里只有两个元素的时候,mid = (left + right) / 2 取到的是中间位置靠左的元素的位置,mid = (left + right + 1) / 2 取到的是中间位置靠右的元素的位置。

使用流程

  1. 分析问题,左右半段哪个是可行区间,mid 归属哪半段
  2. 根据分析结果,选择两种配套形式之一:
    • right = mid, left = mid + 1, mid = (left + right) >> 1
    • left = mid, right = mid - 1, mid = (left + right + 1) >> 1
  3. 二分终止条件是 left == right,该值就是答案所在位置

while (left <= right) 模板

本节模板学习参考自 Labuladong 东哥 探究几个最常用的二分查找场景:寻找一个数、寻找左侧边界、寻找右侧边界。而且,我们就是要深入细节,比如不等号是否应该带等号,mid 是否应该 +1 等等。分析这些细节的差异以及出现这些差异的原因,目标是能够灵活准确地写出正确的二分查找算法。

0. Template

int binarySearch(int[] nums, int target) {
int left = 0, right = ...;

while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}

分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节

其中 ... 标记的部分,就是可能出现细节问题的地方,当你见到一个二分查找的代码时,首先注意这几个地方。后文用实例分析这些地方能有什么样的变化。

1. 基本二分搜索

这个场景是最简单的,可能也是大家最熟悉的,即搜索一个数,如果存在,返回其索引,否则返回 -1。

int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;

while(left <= right) {
int mid = left + (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;
}

此算法有什么缺陷?

比如说给你有序数组 nums = [1,2,2,2,3]target = 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。

2. 寻找左侧边界的二分搜索

统一写法模板的最终版本

int leftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// 搜索区间为 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
right = mid - 1;
}
}
// 判断 target 是否存在于 nums 中
// 此时 target 比所有数都大,返回 -1
if (left == nums.length) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
}

为什么该算法能够搜索左侧边界?

答:关键在于对于 nums[mid] == target 这种情况的处理:

if (nums[mid] == target)
right = mid - 1;

可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid-1] 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。

如何判断我们搜索到的结果?

对于这个数组,算法会返回索引 1

这个索引 1 的含义可以解读为 「nums中小于 2 的元素有 1 个」

比如对于有序数组 nums = [2, 3, 5, 7], target = 1,算法会返回 0,含义是 nums中小于 1 的元素有 0 个。

再比如 nums = [2, 3, 5, 7], target = 8,算法会返回 4,含义是 nums中小于 8 的元素有 4 个。

综上可以看出,函数的返回值(即 left 变量的值)的取值区间是 [0, nums.length],所以我们需要对 left 是否越界进行判断。

while (left <= right) {
//...
}
// 此时 target 比所有数都大,返回 -1
if (left == nums.length) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;

3. 寻找右侧边界的二分搜索

统一写法模板的最终版本

int rightBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 这里改成收缩左侧边界即可
left = mid + 1;
}
}
// 最后改成返回 left - 1
if (left - 1 < 0) return -1;
return nums[left - 1] == target ? (left - 1) : -1;
}

为什么这个算法能够找到右侧边界?

答:类似地,我们在收缩左侧边界。当 nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的左边界 left,使得区间不断向右靠拢,达到锁定右侧边界的目的。

if (nums[mid] == target) {
left = mid + 1;
}

为什么最后返回 left - 1 而不像搜索左侧边界的函数,返回 left

答:为什么要 -1,这是搜索右侧边界的一个特殊点,关键在锁定右边界时的这个条件判断:

// 增大 left,锁定右侧边界
if (nums[mid] == target) {
left = mid + 1;
// 这样想: mid = left - 1
}

如图,我们需要得到的答案是 index = 2.

因为我们对 left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target

至于为什么 left 的更新必须是 left = mid + 1,是为了把 nums[mid] 排除出搜索区间。

如何判断我们搜索到的结果?

答:left 的取值范围是 [0, nums.length],但由于我们最后返回的是 left - 1,所以 left 取值为 0 的时候会造成索引越界。我们需要判断越界。并且判断 nums[left - 1] == target.

常见题型

Sorted Array 排序数组

Implicitly Sorted Array 隐式排序

二分答案

实数二分