【JS笔记】二分查找算法汇总

177 阅读2分钟

简介

二分查找算法时间复杂度 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 而跳出循环(与判断中对 leftright的操作有关),此时的 leftright 就是要找的索引或值。 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 旋转排序数组中的最小值官方题解图) image.png 旋转数组中找极值需要注意:

  • 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(即没有发生实际旋转)时,结果会定位到右数第二个值,真正要找的值在右数第一的位置,因此要做判断。

笔记主要为自用,欢迎友好交流!