思路一:在循环体中查找元素
先看位于数组中间的那个元素的值:
- 如果中间的那个元素正好等于目标元素,我们就可以直接返回这个元素的下标;
- 否则我们就需要在中间这个元素的左边或者右边继续查找。
public class Solution {
// 「力扣」第 704 题:二分查找
public int search(int[] nums, int target) {
int len = nums.length;
int left = 0;
int right = len - 1;
// 目标元素可能存在在区间 [left, right]
while (left <= right) {
// 推荐的写法是 int mid = left + (right - left) / 2;
int mid = (left + right) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
// 目标元素可能存在在区间 [mid + 1, right]
left = mid + 1;
} else {
// 目标元素可能存在在区间 [left, mid - 1]
right = mid - 1;
}
}
return -1;
}
}
细节 1:循环可以继续的条件
while (left <= right)
表示在区间里只剩下一个元素的时候,我们还需要继续查找,因此循环可以继续的条件是 left <= right
,这一行代码对应了二分查找算法的思路 1:在循环体中查找元素。
细节 2:取中间数的代码
取中间数的代码int mid = (left + right) / 2;
,严格意义上是有 bug 的,这是因为在 left 和 right 很大的时候,left + right
有可能会发生整型溢出,这个时候推荐的写法是:
int mid = left + (right - left) / 2;
这里要向大家说明的是 /2
这个写法表示 下取整。这里可能有的朋友有疑问:这里取中间位置元素的时候,为什么是取中间靠左的这个位置,能不能取中间靠右那个位置呢?答案是完全可以的。先请大家自己思考一下这个问题,我们放在细节 3 说。
有些朋友可能会看到 int mid = (left + right) >> 1;
这样的写法,这是因为整数右移 1 位和除以 2(向下取整)是等价的,这样写的原因是因为位运算比整除运算要快一点。但事实上,高级的编程语言,对于/ 2
和除以 2
的方幂的时候,在底层都会转化成为位运算,我们作为程序员在编码的时候没有必要这么做,就写我们这个逻辑本来要表达的意思即可,这种位运算的写法,在 C++ 代码里可能还需要注意优先级的问题。
在 Java 和 JavaScript 里有一种很酷的写法:
int mid = (left + right) >>> 1;
这种写法也是完全可以的,这是因为 >>>
是无符号右移,在 left + right
发生整型溢出的时候,右移一位由于高位补 0 ,依然能够保证结果正确。如果是写 Java 和 JavaScript 的朋友,可以这样写。在 Python 语言里,在 32 位整型溢出的时候,会自动转成长整形,这些很细枝末节的地方,其实不是我们学习算法要关注的重点。
我个人认为这几种种写法差别不大,因为绝大多数的算法面试和在线测评系统给出的测试数据,数组的长度都不会很长,遇到 left + right
整型溢出的概率是很低的,我们推荐大家写 int mid = left + (right - left) / 2;
,让面试官知道你注意了整型溢出这个知识点即可。
细节 3:取中间数可不可以上取整
我们在「细节 2」里介绍了int mid = (left + right) / 2;
这个表达示里 / 2
这个除号表示的含义是下取整。很显然,在区间里有偶数个元素的时候位于中间的数有 2 个,这个表达式只能取到位于左边的那个数。一个很自然的想法是,可不可以取右边呢?遇到类似的问题,首先推荐的做法是:试一试就知道了,刚刚我们说了实证的精神,就把
int mid = (left + right + 1) / 2;
或者
int mid = left + (right - left + 1) / 2;
提交给「力扣」第 704 题的系统测评。结果是可以通过测评。
我们想一想这是为什么呢?因为我们的思路是根据中间那个位置的数值决定下一轮搜索在哪个区间,每一轮要看的那个数当然可以不必是位于中间的那个元素,靠左和靠右都是没有问题的。
甚至取到每个区间的三分之一、四分之一、五分之四,都是没有问题的。下面大家看到的这两种写法是可以通过系统测评的:
int mid = left + (right - left) / 3;
或者
int mid = left + 4 * (right - left) / 5;
一般而言,取位于区间起点二分之一处,首先是因为这样写简单,还有一个更重要的原因是:取中间位置的那个元素在平均意义下效果最好。
思路二:在循环体里排除一定不存在目标元素的区间
边界设置的两种写法:
right = mid
和left = mid + 1
和int mid = left + (right - left) / 2;
一定是配对出现的;right = mid - 1
和left = mid
和int mid = left + (right - left + 1) / 2;
一定是配对出现的。
mid 被分到左边区间
这个时候区分被分为两部分:[left, mid]
与 [mid + 1, right]
,对应设置边界的代码为right = mid
和 left = mid + 1
参考代码1
public class Solution {
// 「力扣」第 704 题:二分查找
public int search(int[] nums, int target) {
int len = nums.length;
int left = 0;
int right = len - 1;
// 目标元素可能存在在区间 [left, right]
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 下一轮搜索区间是 [mid + 1, right]
left = mid + 1;
} else {
// 下一轮搜索区间是 [left, mid]
right = mid;
}
}
if (nums[left] == target) {
return left;
}
return -1;
}
}
mid 被分到右边区间
这个时候区分被分为两部分: [left, mid - 1]
与 [mid, right]
,对应设置边界的代码为right = mid - 1
和 left = mid
。
注意:这种情况下,当搜索区间里只剩下两个元素的时候,一定要将取中间数的行为改成上取整,也就是在括号里加 1。
这是因为 [left, right]
区间里只剩下两个元素的时候,如果是取中间数 mid 是下取整,一旦进入 left = mid
这个分支,区间就不会再缩小,下一轮搜索的区间还是 [left, right]
,下一次循环取 mid
进入right = mid - 1
这个分支也会出现负数的情况
参考代码2
public class Solution {
// 「力扣」第 704 题:二分查找
public int search(int[] nums, int target) {
int len = nums.length;
int left = 0;
int right = len - 1;
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (nums[mid] > target) {
// 下一轮搜索区间是 [left, mid - 1]
right = mid - 1;
} else {
// 下一轮搜索区间是 [mid, right]
left = mid;
}
}
if (nums[left] == target) {
return left;
}
return -1;
}
}
接下来我们再介绍一种二分的写法,相信只要是理解了前两种思路,这种写法就不难理解了。
参考代码 3(不推荐):
public class Solution {
// 「力扣」第 704 题:二分查找
public int search(int[] nums, int target) {
int len = nums.length;
int left = 0;
int right = len - 1;
// 目标元素可能存在在区间 [left, right]
while (left + 1 < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid;
} else {
right = mid;
}
}
if (nums[left] == target) {
return left;
}
if (nums[right] == target) {
return right;
}
return -1;
}
}
代码说明:
-
这种思路循环可以继续的条件是
while (left + 1 < right)
,在待搜索区间剩下两个元素的时候,退出循环,所以它不需要考虑死循环的问题;但是正是由于退出循环的时候区间里有 2 个元素,所以在退出循环的时候一定得做判断; -
在循环体内分为 3 个分支,这一点和思路 1 是一样的;在循环体内,就没有
mid + 1
和mid - 1
这样的表达式了。把中间数 mid 全部纳入下一轮要考虑的范围里。
这种写法,我们不建议大家这样写,理由如下:
left + 1 < right
这种写法不是很自然;- 退出循环的时候,一定要处理两个元素区间是两个元素的逻辑,这一步是附加的逻辑,是有可能出错的,相对于思路 2 是不太好的做法;
- 对于
mid
是不是在下一轮要考虑的区间里,这件事情只要思路清晰,是可以准确得出结论的,而这种写法恰好屏蔽了这些细节。