简介
二分查找算法时间复杂度 o(log(n)),空间复杂度 o(1),从使用场景上可以大致分为4种:1.查找有序序列中目标值;2.在有序序列中找到目标值的插入位置;3.找边界;4.旋转数组找极值。
1.查值
查值二分算法模板:记忆点 <=
=== > <
return mid
// 从非降序数组 nums 中查找目标值 target
const n = nums.length
let left = 0, right = n - 1
while (left <= right) {
const mid = left + (right - left >> 1)
if (arr[mid] === target) return mid // 如果是否存在则用 return true
else if (arr[mid] > target) right = mid - 1
else left = mid + 1
}
return -1 // 如果程序运行到这里,说明不存在该值 return false
根据使用场景,如果是判断目标值是否存在,就输出 return true / false
;如果是找到目标值在序列的位置,就输出 return mid / -1
。
2.插值
插值二分算法模板:记忆点 <=
>= <
return left
// 从非降序数组 nums 中查找目标值 target 的插入位置
const n = nums.length
let left = 0, right = n - 1
while (left <= right) {
const mid = left + (right - left >> 1)
if (nums[mid] >= target) right = mid - 1 // 非降序序列用 >=; 非升序序列用 <=
else left = mid + 1
}
return left // left 指针为最终插值的位置
实际上插值算法同样可用于查值,只不过 left
的落点不一定是 target
, 所以需要进一步判断 left
是否合法且落点的值是否等于 target
。
return left >= 0 && left < nums.length && nums[left] === target // 判断是否存在目标值
查值还是建议用前一方法,毕竟当目标值存在时能进行枝剪 if (arr[mid] === target) return mid
,输出时也省去了判断。
3.找边界
找边界二分法模板:记忆点 <=
>= <
return ret
// 从非降序数组 nums 中查找不小于目标值 target 的最小值(即查找左边界)
const n = nums.length
let left = 0, right = n - 1, ret = n // 预设输出值,如果查右边界就设为 -1
while (left <= right) {
const mid = left + (right - left >> 1)
// 1.查左边界(大于等于目标值的最小值位置)
if (nums[mid] >= target) {
right = mid - 1
ret = mid // 记录过程值,用于输出
} else left = mid + 1
// 2.查右边界(小于等于目标值的最大值位置)
if (nums[mid] > target) right = mid - 1
else { // nums[mid] <= target
left = mid + 1
ret = mid // 记录过程值,用于输出
}
}
return ret // 输出时,如果 ret 为初始值,说明整个数组的元素都不满足条件
找边界算法中需要注意:
- 输出值
ret
是在过程中记录,与前两种二分算法最终的输出值都是循环结束时的某个参数不同,这里的ret
记录的可能是循环结束前一轮的参数状态。 ret
无论在左边界还是右边界算法中,都是在带=
的判断中记录。
在找边界问题中也可以用另一个方法,以一道题为例:记忆点 <
> <=
return left - 1 // 含特殊点
// 给一个非负整数 `x` ,计算并返回 `x` 的算术平方根(的整数部分)
let left: number = 0, right: number = x
while (left < right) {
const mid = left + (right - left >> 1)
if (mid * mid > x) right = mid // 这里尽量用 right = mid ,保持输出结果的一致性
else left = mid + 1
}
return left * left === x ? left : left - 1
// return right * right === x ? right : right - 1 // 两种输出都可以 left === right
用二分法估算 x
算数平方根的整数部分,相当于找右边界。如果在 wihle
里用 <
,则当 left === right
时也会中断循环,实际上大部分时候都是因为 left === right
而跳出循环(与判断中对 left
和 right
的操作有关),此时的 left
或 right
就是要找的索引或值。 BUT!!! 当查找值出现在有序数组的两端,甚至是“之外”时,输出结果会出人意料(自行测试),因此需要在输出时做额外的判断 left * left === x ? left : left - 1
。
以上方法可行,但为了减少记忆成本,保持输出方式一致,建议使用 <=
>= <
return ret
的方法。
4.旋转数组找极值
旋转数组找极值二分算法记忆点:<
<= >
return left
// 在非降序数组旋转后的 nums 中查找极值
let left = 0, right = nums.length - 1
while (left < right) { // 注意这里没有 = ,不超边界
const mid = left + (right - left >> 1)
// 1.找极小值
if (nums[mid] < nums[right]) right = mid
else if (nums[mid] > nums[right]) left = mid + 1
else --right
// 2.找极大值
if (nums[mid] > nums[left]) left = mid
else if (nums[mid] < nums[left]) right = mid - 1
else ++left
}
return nums[left] // 极小值
return left > 0 ? Math.max(nums[left], nums[left - 1]): nums[left] // 极大值
(leetcode 旋转排序数组中的最小值官方题解图)
旋转数组中找极值需要注意:
while
中用left < right
;- 判断过程不再是
mid
指针与target
比较,找极小值就和right
指针比,找极大值就和left
指针比(非升序序列则相反); - 当判断出现等值时,意味着序列中有重复的元素,为避免错漏要对指针进行步进移位,这导致最糟糕的情况下时间复杂度变为 o(n);
- 找极大值的算法在某些情况下可能会出错(用 [2,1] 这个序列就可以模拟),需要对输出结果进行一个判断
left > 0 ? Math.max(nums[left], nums[left - 1]): nums[left]
。
如果明确序列中不会有重复值,可以简化算法:
// 在升序数组旋转后的 nums 中查找极值
let left: number = 0, right: number = nums.length - 1
while (left < right) {
const mid = left + (right - left >> 1)
// 极小值
if (nums[mid] < nums[right]) right = mid
else left = mid + 1 // 将等值合并到这里
// 极大值
if (nums[mid] > nums[left]) left = mid
else right = mid - 1 // 将等值合并到这里
}
return nums[left] // 极小值
return left < nums.length - 1 ? Math.max(nums[left], nums[left + 1]) : nums[left] // 极大值
不出意外的话找极大值(与 left
指针作对比的情况)也还是有意外的情况发生:当序列实际旋转次数为0(即没有发生实际旋转)时,结果会定位到右数第二个值,真正要找的值在右数第一的位置,因此要做判断。
笔记主要为自用,欢迎友好交流!