引言
二分搜索算法,作为一种基础且极为重要的算法策略,广泛应用于诸多领域。无论是在大规模数据的搜索与处理中,还是在数值计算的优化求解过程里,它都能展现出令人惊叹的效率。其核心思想基于对问题空间的巧妙划分与逐步逼近,通过不断将搜索范围减半,以指数级的速度收敛到目标解。这种独特的思维方式不仅为我们提供了一种高效的问题解决手段,更在算法设计的方法论层面给予我们深刻的启示。
无论是否系统地学习过二分算法,二分算法的基本思想,应该根植于每位程序员的思路中。本人在刚学习写代码的时候,如果运行的结果跟预期不一致,就会在代码近似一半的位置增加输出,先确认运行到一半时,各种中间变量的结果是否符合预期,如果此时不符合预期,就按照这个思路继续在前半代码的中间位置增加输出,否则,就在后半代码的中间位置增加输出,直到定位到问题。这种方法,不自觉使用到了二分的思想,可能可以称为:二分debug[手动狗头]
二分算法基础
让我们从一个常见的搜索问题开始理解二分算法:在一个有序数组中查找特定元素。假设我们有一个包含 n 个元素的有序数组 arr,我们要查找元素 target。
二分算法的思路是,首先确定整个数组的搜索范围,即左边界 left = 0 和右边界 right = n - 1。然后,在每次迭代中,计算中间元素的索引 mid = (left + right) / 2(这里的除法通常是向下取整)。接着,比较中间元素 arr [mid] 与目标元素 target 的大小关系:
- 如果 arr [mid] == target,那么我们就找到了目标元素,搜索结束。
- 如果 arr [mid] > target,这意味着目标元素在左半部分的区间 [left, mid - 1] 中,此时我们更新右边界 right = mid - 1,继续在新的区间内进行搜索。
- 如果 arr [mid] < target,说明目标元素在右半部分的区间 [mid + 1, right] 中,于是我们更新左边界 left = mid + 1,并在该区间继续查找。
通过这样不断地将搜索区间减半,我们能够快速地逼近目标元素,大大减少了搜索的时间复杂度。
一学就会,一写就废 -- 魔鬼藏在细节中
区间的定义
看了二分的基础介绍,是不是又觉得这么简单的算法,自己又行了?
那就从一道最简单的题目开始吧:leetcode-704
先看下如下的2种写法:
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left)>>1);
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
return -1;
}
public int search(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return -1;
}
以上2种写法不同,但都是正确的。仔细对比上述的写法后,新手可能会比较懵逼:
- 为什么right的初始值不同,一会儿是
nums.length - 1,一会儿又是nums.length - 为什么while循环的条件不同,一会儿是
left <= right,一会儿又是left < right - 为什么
nums[mid] > target时,一会儿是right = mid - 1,一会儿又是right = mid
这其实是二分算法最重要的一个细节:边界值的处理,什么时候该用left <= right,什么时候有该用left < right,以及什么时候right = mid - 1,什么时候right = mid。
要搞清楚这些边界值,首先要明确的是,搜索区间的定义。我们知道,对于从left到right的区间,根据开闭不同,可以有4种不同的组合,而我们一般定义区间比较常用的有前闭后闭、前闭后开,而上面2种写法就是分别使用这2中定义的写法,我们逐个分析。
前闭后闭
对于前闭后闭的区间,数学上的表示是[left, right]。明确了这个定义,我们分别看上面的3个问题
- right的初始值。此时right是包含在区间内部的,表示最后一个合法的元素,所以此时
right = nums.length - 1 - while的循环条件。while循环进入的条件是,区间内还存在需要被搜索的元素,当
left == right时,这个区间内,其实还存在1个需要搜索的元素,此时需要进入循环,所以,这里循环条件是left <= right nums[mid] > target时,我们需要继续搜索左半边的元素,而且接下来的搜索范围不需要再包含mid,由于是右侧是闭区间,所以是right = mid - 1,如果是right = mid的话,就多了一个元素mid
前闭后开
对于前闭后开的区间,数学上的表示是[left, right)。明确了这个定义,我们也分别看上面的3个问题
- right的初始值。此时right是不包含在区间内部的,表示最后一个合法的元素,所以此时
right = nums.length - while的循环条件。while循环进入的条件是,区间内还存在需要被搜索的元素,当
left == right时,这个区间内,这个区间内已经不包含任何元素了,此时不需要进入循环,所以,这里循环条件是left < right nums[mid] > target时,我们需要继续搜索左半边的元素,而且接下来的搜索范围不需要再包含mid,由于是右侧是开区间,所以是right = mid时,本来就不包含这个mid了
搞明白了上面2种写法为什么都是正确的,相信你也可以写出前开后开、前开后闭这2种区间定义的写法,虽然这2种区间定义在编程实践中并不常见。

如何计算mid
有了left和right,我们计算mid时,最自然而然的写法是
int mid = (left + right) / 2;
这就涉及到二分算法的第2个小细节了,注意此时left + right的结果值,可能超出了int的范围,所以,为了防止溢出,我们一般采取以下的写法:
int mid = left + (right - left) / 2;
这种写法不会溢出了,不过,我们还注意到,此时有个除以2的操作,我们知道,对于这种乘以2或者除以2的操作,用位移来进行运算会更加高效,所以,进一步优化成如下的写法:
int mid = left + ((right - left) >> 1);
总结
看起来很容易的二分算法,如果没有注意这些小细节,往往会造成WA,轻则费时,重则打击信心,还没入门,就要放弃了。魔鬼往往藏在这些细节中,我们要搞明白这些细节,才能变得比魔鬼更鬼。
看到这里了,点个赞再走呗
