魔鬼的二分查找

原理

二分查找并不简单,Knuth 大佬(发明 KMP 算法的那位)都说二分查找:思路很简单,细节是魔鬼。很多人喜欢拿整型溢出的 bug 说事儿,但是二分查找真正的坑根本就不是那个细节问题,而是在于到底要给 mid 加一还是减一,while 里到底用 <= 还是 <

你要是没有正确理解这些细节,写二分肯定就是玄学编程,有没有 bug 只能靠菩萨保佑。

框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int binarySearch(int[] nums, int target) {
int left = 0, right = ...;

while(...) {
int mid = left + (right - left) / 2;// 可有效防止left + right溢出
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}

举例

寻找一个数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

1、为什么 while 循环的条件中是 <=,而不是 <

答:因为初始化 right 的赋值是 nums.length - 1,即最后一个元素的索引,而不是 nums.length

这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 [left, right],后者相当于左闭右开区间 [left, right),因为索引大小为 nums.length 是越界的。

我们这个算法中使用的是前者 [left, right] 两端都闭的区间。这个区间其实就是每次进行搜索的区间

什么时候应该停止搜索呢?当然,找到了目标值的时候可以终止:

1
2
if(nums[mid] == target)
return mid;

但如果没找到,就需要 while 循环终止,然后返回 -1。那 while 循环什么时候应该终止?搜索区间为空的时候应该终止,意味着你没得找了,就等于没找到嘛。

while(left <= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。

while(left < right) 的终止条件是 left == right,写成区间的形式就是 [left, right],或者带个具体的数字进去 [2, 2]这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间 [2, 2] 被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。

当然,如果你非要用 while(left < right) 也可以,我们已经知道了出错的原因,就打个补丁好了:

1
2
3
4
5
//...
while(left < right) {
// ...
}
return nums[left] == target ? left : -1;

总的来说,如果一开始的right是length - 1,那么你需要让left = right 这种情况也被搜索一遍,如果一开始right是length,left = right这种临界情况其实已经超出了数组的有效范围

2、为什么 left = mid + 1right = mid - 1?我看有的代码是 right = mid 或者 left = mid,没有这些加加减减,到底怎么回事,怎么判断

答:这也是二分查找的一个难点,不过只要你能理解前面的内容,就能够很容易判断。

刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 [left, right]。那么当我们发现索引 mid 不是要找的 target 时,下一步应该去搜索哪里呢?

当然是去搜索 [left, mid-1] 或者 [mid+1, right] 对不对?因为 mid 已经搜索过,应该从搜索区间中去除

3、此算法有什么缺陷

答:至此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但是,这个算法存在局限性。

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

这样的需求很常见,你也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了

寻找左侧边界的二分查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意

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

1、为什么 while 中是 < 而不是 <=?

答:用相同的方法分析,因为 right = nums.length 而不是 nums.length - 1。因此每次循环的「搜索区间」是 [left, right) 左闭右开。

while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止。

2、为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办

答:因为要一步一步来,先理解一下这个「左侧边界」有什么特殊含义:

对于这个数组,算法会返回 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],所以我们简单添加两行代码就能在正确的时候 return -1:

1
2
3
4
5
6
7
while (left < right) {
//...
}
// target 比所有数都大
if (left == nums.length) return -1;
// 类似之前算法的处理方式
return nums[left] == target ? left : -1;

3、为什么 left = mid + 1right = mid ?和之前的算法不一样

答:这个很好解释,因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去掉 mid 分割成两个区间,即 [left, mid)[mid + 1, right)

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

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

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

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

5、为什么返回 left 而不是 right

答:都是一样的,因为 while 终止的条件是 left == right

6、能不能想办法把 right 变成 nums.length - 1,也就是继续使用两边都闭的「搜索区间」?这样就可以和第一种二分搜索在某种程度上统一起来了

答:当然可以,只要你明白了「搜索区间」这个概念,就能有效避免漏掉元素,随便你怎么改都行。下面我们严格根据逻辑来修改:

因为你非要让搜索区间两端都闭,所以 right 应该初始化为 nums.length - 1,while 的终止条件应该是 left == right + 1,也就是其中应该用 <=

1
2
3
4
5
6
7
int left_bound(int[] nums, int target) {
// 搜索区间为 [left, right]
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
// if else ...
}

因为搜索区间是两端都闭的,且现在是搜索左侧边界,所以 leftright 的更新逻辑如下:

1
2
3
4
5
6
7
8
9
10
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;
}

由于 while 的退出条件是 left == right + 1,所以当 targetnums 中所有元素都大时,会存在以下情况使得索引越界:

img

因此,最后返回结果的代码应该检查越界情况:

1
2
3
if (left >= nums.length || nums[left] != target)
return -1;
return left;

至此,整个算法就写完了,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int left_bound(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;
}
}
// 检查出界情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}

这样就和第一种二分搜索算法统一了,都是两端都闭的「搜索区间」,而且最后返回的也是 left 变量的值。只要把住二分搜索的逻辑,两种形式大家看自己喜欢哪种记哪种吧。

寻找右侧边界的二分查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int right_bound(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;
}
}
// 这里改为检查 right 越界的情况,见下图
if (right < 0 || nums[right] != target)
return -1;
return right;
}

原文链接: 二分法详解

二分法变种(2021.04.08更新)

今天的leetcode每日一题是一道二分法的题目,但是不同于上面讲的这些二分法,在这里更新记录一下

这是题目链接:https://leetcode-cn.com/problems/find-minimum-in-rotated-sorted-array/

这里我就直接使用官方的题解来解答,只不过会对这个题解加以更加详细的解释

一个不包含重复元素的升序数组在经过旋转之后,可以得到下面可视化的折线图:

其中横轴表示数组元素的下标,纵轴表示数组元素的值。图中标出了最小值的位置,是我们需要查找的目标。

我们考虑数组中的最后一个元素 xx:在最小值右侧的元素(不包括最后一个元素本身),它们的值一定都严格小于 xx;而在最小值左侧的元素,它们的值一定都严格大于 xx。因此,我们可以根据这一条性质,通过二分查找的方法找出最小值。

在二分查找的每一步中,左边界为low,右边界为high,区间的中点为pivot,最小值就在该区间内。我们将中轴元素nums[pivot] 与右边界元素 nums[high] 进行比较,可能会有以下的三种情况:

第一种情况是nums[pivot]<nums[high]。如下图所示,这说明 nums[pivot] 是最小值右侧的元素,因此我们可以忽略二分查找区间的右半部分。

第二种情况是nums[pivot]>nums[high]。如下图所示,这说明 nums[pivot] 是最小值左侧的元素,因此我们可以忽略二分查找区间的左半部分。

由于数组不包含重复元素,并且只要当前的区间长度不为 1,pivot 就不会与 high 重合;而如果当前的区间长度为 11,这说明我们已经可以结束二分查找了。因此不会存在 nums[pivot]=nums[high] 的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
var findMin = function(nums) {
let low = 0;
let high = nums.length - 1;
while (low < high) {
const pivot = low + Math.floor((high - low) / 2);
if (nums[pivot] < nums[high]) {
high = pivot;
} else {
low = pivot + 1;
}
}
return nums[low];
};

几个问题

看完上面那些解释,大家可能觉得自己明白了,但是还有几个问题我们需要思考一下:

  • 搜索区间是什么

    根据原理那部分所讲,由于high = nums.length - 1,所以搜索区间是[low, high]

  • 为什么是low < high 的时候才执行,也就是说为什么low = high的时候不需要判断

    这是因为当low = high的时候,区间长度为1,那最小值就是这个唯一值,所以直接返回就好

  • 为什么是比较pivot和high而不是low

    左、中、右三个位置的值相比较,有以下几种情况:

    左值 < 中值, 中值 < 右值 :没有旋转,最小值在最左边,可以收缩右边界

    1
    2
    3



    左值 > 中值, 中值 < 右值 :有旋转,最小值在左半边,可以收缩右边界

    1
    2
    3



    左值 < 中值, 中值 > 右值 :有旋转,最小值在右半边,可以收缩左边界

    1
    2
    3



    左值 > 中值, 中值 > 右值 :单调递减,不可能出现

    1
    2
    3



    我们可以分析一下上面几种情况,比较中值和右值只分两种情况就好。中值大于左值还要再分两种情况

  • 为什么缩小区间时,high = pivot,而 low = pivot + 1

    因为当nums[pivot] < nums[high]时,这个nums[pivot]还有可能是最小值,所以pivot仍然在搜索区间内。而当nums[pivot] > nums[high]nums[pivot]不可能是最小值