二分查找适用于 「有序」 的数据集合。
它的思路是:在有序数组nums中寻找目标值target,初始查找范围为整个数组nums。每次取查找范围的中点mid,比较nums[mid]和target的大小,如果相等则mid就是要寻找的下标,如果不相等则将查找范围缩小一半。
作为最基础又常考的搜索算法,二分查找有很多细节需要我们注意。
while里到底用<=还是<,- right 的取值是
nums.length还是nums.length - 1, - 到底要给
mid加一还是减一, - 为防止溢出,应该怎么计算
mid值。
二分查找的常用场景
有序数组中查找某个值
1. 循环迭代法
const binarySearch = (nums, target) => { // 假定 nums是升序数组
let left = 0;
let right = nums.length; // 注意
while (left < right) { // 注意
let mid = left + Math.floor((right - left) / 2); // 防止溢出
if (nums[mid] == target) {
return mid;
} else if (nums[mid] > target) {
right = mid; // 注意
} else if (nums[mid] < target) {
left = mid + 1; // 注意
}
}
return -1;
}
需注意的几点:
- 对于 while 循环中的条件,个人偏向用
<,因为这时对应的搜索区间是 [left, right) 左闭右开! 那么有什么好处呢?
while(left <= right)对应的是两端闭的区间[left, right],当区间为空时 left 和 right 不相等,只有一个是正确答案,返回时容易出错。而while(left < right)对应的搜索区间为[left, right),区间为空时 left 等于 right,「返回left或right均可」。
right取值等于nums.length。 因为while循环条件选用的是<,搜索区间如下图。因此要令right = nums.length,搜索区间为[left, right),才能包含全部的数组元素。
- 边界收缩 基于已知信息下,left 和 right 的收缩应该 「最大限度地收缩」 搜索区间,否则会出现死循环。
2. 递归法
const findIndex = (left, right, nums, target) => {
if (left >= right) return -1;
// 递归 取代的就是 while循环
let mid = left + Math.floor((right - left) / 2);
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
return findIndex(mid + 1, right, nums, target); // 注意 return
} else {
return findIndex(left, mid, nums, target); // 注意 return
}
}
const binarySearch = (nums, target) => {
return findIndex(0, nums.length, nums, target);
}
复杂度分析
- 时间复杂度:O(logn),n是数组的长度
- 空间复杂度:O(1)
寻找左侧边界
nums = [1, 4, 9, 11, 11, 12, 15], target = 11,返回下标 3。
const leftBound = function(nums, target) {
let left = 0;
let right = nums.length;
while (left < right) {
let mid = left + Math.floor((right - left) / 2);
if (nums[mid] == target) {
right = mid; // 注意
} else if (nums[mid] > target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
}
}
return right; // 注意,left或right均可
}
寻找右侧边界
nums = [1, 4, 9, 11, 11, 12, 15], target = 11,返回下标 4。
const rightBound = function(nums, target) {
let left = 0;
let right = nums.length;
while (left < right) {
let mid = left + Math.floor((right - left) / 2);
if (nums[mid] == target) {
left = mid + 1; // 注意
} else if (nums[mid] > target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
}
}
if (left == 0) {
return -1
}
return left - 1; // 注意!由于判断条件 left = mid + 1
}
最后返回的值是left(right)或left(right)加减某个数,不放心的话可以实例验证一下。
704. 二分查找 [迭代 / 递归]
153. 寻找旋转排序数组中的最小值
该题借助图线更加直观,它实际上就是单调递增直线做旋转后的几种情况。假设原数组 [0, 1, 2, 3, 4],那么当它旋转时中间端点值的情况有:
- 没有旋转时,左端点值 < 中间端点值 < 右端点值,此时最小值在左边、应该收缩右边界;
- 当旋转成 [2, 3, 4, 0, 1] 时,左端点值 < 中间端点值 > 右端点值,此时最小值在右侧、应该收缩左边界;
- 当旋转成 [4, 0, 1, 2, 3] 时,左端点值 > 中间端点值 < 右端点值,此时最小值在左侧,应该收缩右边界。
合并几种情况发现:当中值 < 右端点值时,收缩右边界;中值 > 右端点值时,收缩左边界。
另外,不需要考虑 中值 == 右端点值 的情况,因为数组中的元素值互不相同。如果相同时,那一定是同一个元素,直接返回即可。
const findMin = (nums) => {
let left = 0;
let right = nums.length - 1; // 区别于二分,nums[right]要存在,所以 right 不能越界
while (left < right) {
let mid = left + Math.floor((right - left) / 2);
if (nums[mid] > nums[right]) { // 收缩左边界
left = mid + 1;
} else if (nums[mid] < nums[right]){ // 收缩右边界。
right = mid; // 注意,和循环条件对应!!!
}
}
return nums[left]; // nums[right]也可以
};
时间复杂度:O(logn)。
空间复杂度:O(1)。
69. x 的平方根:返回值
x 平方根的整数部分 ans 是满足 k^2 <= x 的最大 k 值。所以可以对 k 进行二分查找!
跳出来的时候一定是在平方根附近的,最后判断一下如果平方大于 x 的话就返回它前面的一个值,否则就正常返回就行了。
const mySqrt = (x) => {
if (x < 2) return x;
let left = 0, right = x;
while (left < right) {
let mid = left + Math.floor((right - left) / 2);
if (mid * mid == x) {
return mid;
} else if (mid * mid > x) {
right = mid;
} else {
left = mid + 1;
}
}
return (left * left > x) ? left - 1 : left; // 注意
}
参考blog