前言
最近在面试的过程中,面试官让我写一个二分查找,本以为手拿把掐,却没想到在面试官的追问下掉坑里去了,回答的不是很好,比如用左闭右开区间、计算中间值的方法能不能换一个、用递归如何实现、目标值存在多个呢?在此简单记录分享一下。
一、题目描述
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
二、关键点
只要看到有序无重复,或者有序查找目标值,就要想到二分法。
- 有序
- 无重复,目标唯一
- 有重复,目标不唯一
- 区间定义,左闭右闭,左闭右开,区间定义不同,写法有差异
- 右侧初始化值的设置也是根据区间不通设置为不同的值,数组长度,数组长度 - 1
- 区间和循环条件的定义相互影响,我们需要确保只在有意义、非空的查找区间内搜索
- 对于js来说中间值的计算也是需要注意的需要防止大数溢出
三、思路
1.左闭右闭区间[left, right]
在二分查找中,left
和 right
指针代表当前搜索的区域的边界,如果定义的是一个左闭右闭的区间,即区间包括 left
和 right
指向的元素。
循环条件 while (left <= right)
表示只要左边界没有超过右边界,区间内就还有元素待查找,查找过程就应该继续;如果左边界超过了右边界(即 left > right
),说明目标元素不存在于当前区间内,查找过程应该终止。
这个条件反映了以下几种情况:
- 当
left < right
:区间内至少有两个元素,查找应该继续。 - 当
left == right
:区间内只剩下一个元素,这个元素的索引就是left
和right
当前的位置,也就是middle
。此时还应该判断这个单一元素是否等于目标值。
由于二分查找的查找过程就是不断地将待查找区间分为两部分,然后选择其中一部分继续查找,所以这个区间是会不断缩小的。如果不能处理 left == right
的情况,则可能会遗漏掉区间内仅剩的那一个元素。
为了确保所有的情况都被覆盖到,因此需要使用 left <= right
作为循环的条件。当循环终止时,如果没有找到目标值,说明目标值不在数组中,函数返回 -1。
2.左闭右开区间[left, right)
如果二分查找的逻辑是使用一个左闭右开的区间 left
到 right
,即 [left, right)
。这意味着 left
是包含在查找区间内的,而 right
是不包含的。在这个模型中,当 left
和 right
相遇时(即 left == right
),它表示查找的区间为空,因为区间起始点等于终点,并且区间是左闭右开的。
循环的条件 while (left < right)
显示了我们只希望在查找区间非空(也就是至少有一个待查找的元素)时继续搜索。当 left
小于 right
,查找区间至少包含一个元素,我们继续进行二分查找;而当 left
等于 right
的时候,查找区间为空,意味着我们已经缩小至一个无效空间,所以没有继续查找的必要,可以结束循环。
这一版本中,如果 nums[middle] > target
,right
被设置为 middle
,而不是 middle - 1
,因为我们维持了 [left, middle)
作为新的查找区间,即新的 right
不包括 middle
。同样,当 nums[middle] < target
时, left
被设置为 middle + 1
,新的查找区间变成 [middle + 1, right)
,因此 middle
后面的元素仍然在查找范围内。
在这种策略下,使用 left < right
作为循环条件是合适的,因为它确保只在有意义的、非空的查找区间内进行搜索。
四、实现
1.[left, right]
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
let left = 0;
// 注意这里是nums.length - 1,right是数组最后一个数的下标,num[right]在查找范围内,是左闭右闭区间
let right = nums.length - 1;
let mid;
// 当left=right时,由于nums[right]在查找范围内,所以要包括此情况, 只有一个元素的时候
while(left <= right) {
mid = left + Math.floor((right - left) / 2);
if (target > nums[mid]) {
left = mid + 1;
} else if (target < nums[mid]) {
right = mid - 1;
} else {
return mid;
}
}
return -1;
};
2.[left, right)
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
let left = 0;
// 注意这里是nums.length,right是数组的长度,需要超过数组的索引长度,这样可以将最后一个包括在里面,是左闭右开
let right = nums.length;
let mid;
// left === right 无意义
while(left < right) {
mid = left + Math.floor((right - left) / 2);
// mid = left + ((right - left) >> 1); 右移运算符 >> 来代替除法
// 都是将 (right - left) 的结果右移一位,相当于除以2并向下取整,得到中间值 mid
if (target > nums[mid]) {
left = mid + 1;
} else if (target < nums[mid]) {
right = mid;
} else {
return mid
}
}
return -1;
};
五、复杂度分析
每次将查找范围缩小一半是对数级别的原因在于二分查找算法的每一步都在将问题规模减半。假设初始时查找范围为 n,经过一次比较后,查找范围被缩小为 n/2。再经过一次比较,查找范围被缩小为 n/4,依此类推。可以看出,经过 k 次比较后,查找范围被缩小为 n/(2^k)。当 n/(2^k) 等于 1 时,即查找范围缩小为 1 时,算法结束。解方程 n/(2^k) = 1 可得 k = log₂n。因此,二分查找算法的时间复杂度为 O(log n)。
- 时间复杂度:O(log n)
- 空间复杂度:O(1)
六、mid的计算方式
如下几个都是等价的,
mid = left + Math.floor((right - left) / 2);
mid = Math.floor((left + right) / 2)
mid = left + ((right - left) >> 1);
let mid = (begin + end) >>> 1;
这几个表达式都是用来计算两个数的中间值(取整数)的。
mid = left + Math.floor((right - left) / 2);
这个表达式首先计算出right
和left
之间的距离(right - left)
,然后除以2取整数部分,最后加上left
,得到的就是left
和right
的中间值。mid = Math.floor((left + right) / 2);
这个表达式先计算left
和right
的和,然后除以2取整数部分,即得到left
和right
的中间值。mid = left + ((right - left) >> 1);
这个表达式也是计算left
和right
的中间值,它首先计算出right
和left
之间的距离(right - left)
,然后右移一位,相当于除以2取整数部分,最后加上left
得到中间值。let mid = (begin + end) >>> 1;
这个表达式也是计算begin
和end
的中间值,>>>
是无符号右移操作符,它将begin
和end
的和右移一位,相当于除以2取整数部分,得到的就是begin
和end
的中间值。
这些表达式都是用来避免整数溢出的问题,因为对于非常大的left
和right
值,直接相加再除以2可能会导致溢出。因此,这些表达式都是通过减法和移位来避免这个问题。
七、改写为递归方式
如果不是看了神三元的文章还没想到过可以改成递归的写法
1.左闭右闭
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
let left = 0;
let right = nums.length - 1;
const searchNum = (nums, left, right, target) => {
// 递归终止条件,相等有意义
if (left > right) return -1;
let mid = left + Math.floor((right - left) / 2);
if (target > nums[mid]) {
return searchNum(nums, mid + 1, right, target);
} else if (target < nums[mid]) {
return searchNum(nums, left, mid - 1, target);
} else {
return mid;
}
return -1;
}
return searchNum(nums, left, right, target)
};
2.左闭右开
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
let left = 0;
let right = nums.length;
const searchNum = (nums, left, right, target) => {
// 递归终止条件,相等没有意义,需要返回
if (left >= right) return -1;
let mid = left + Math.floor((right - left) / 2);
if (target > nums[mid]) {
return searchNum(nums, mid + 1, right, target);
} else if (target < nums[mid]) {
return searchNum(nums, left, mid, target);
} else {
return mid;
}
}
return searchNum(nums, left, right, target)
};
八、目标值重复
这种属于二分查找的变体,其实也就是,就是找到target后不停止,然后继续查找,分别找出最左侧和最右侧的target的index,
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var searchRange = function(nums, target) {
let left = 0;
let right = nums.length - 1;
const findTarget = (nums, left, right, target, searchType) => {
let mid;
let border = -1;
while(left <= right) {
mid = left + Math.floor((right - left) / 2);
// 在右侧
if (target > nums[mid]) {
left = mid + 1;
} else if (target < nums[mid]) {
// 在左侧
right = mid - 1;
} else {
if (searchType === 'left') {
// 左侧持续搜索,这里是关键代码
border = mid;
right = mid - 1;
} else {
// 右侧持续搜索,这里是关键代码
border = mid;
left = mid + 1
}
}
}
return border;
}
const leftBorder = findTarget(nums, left, right, target, 'left');
const righBorder = findTarget(nums, left, right, target, 'right');
return [leftBorder, righBorder];
};