算法(五):二分查找

42 阅读3分钟

概念

二分查找算法是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
二分查找算法在最坏情况下是对数时间复杂度,即O(logn),二分查找算法使用常数空间,对于任何大小的输入数据,算法使用的空间都是一样的。除非输入数据数量很少,否则二分查找算法比线性搜索更快,但数组必须事先被排序。尽管一些特定的、为了快速搜索而设计的数据结构更有效(比如哈希表),二分查找算法应用面更广。

基本的二分查找

我们可以从这道题目来了解二分查找的具体做法:704. 二分查找:给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。这道题的答案几乎可以当成二分查找的模板:

function search(nums: number[], target: number): number {
  // 左右指针
  let left = 0, right = nums.length - 1;
  // while循环条件:left<=right
  while(left <= right) {
    // 中间位置索引
    const mid = Math.floor((left + right) / 2);
    const num = nums[mid];
    if (target === num) {
      return mid
    } else if(target > num) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  return -1;
};

这里有几个值得注意的细节:

  1. while循环的结束条件是left <= right:因为left和right的初始值是数组的开始和结束索引,是一个闭区间[left, right],所以当left===right时还要进行一次比较,否则就会漏掉mid为left(或者right)的情况。
  2. 为什么left和right赋值为mid加或者减1:有人见过的二分算法是left或者right等于mid,那么这里为啥要加或者减1呢?原因也非常简单,mid已经比较过了,不是我们要的值,所以在接下来的查找区间不必包含mid。

二分查找的边界问题

上述基本的二分查找遇到特定的问题时可能无法处理,比如我要找[1, 3, 5, 5, 5, 10, 20]数组中的第一个5的索引或者最后一个5的索引,那么上述二分算法只能找到索引为3位置的5。那么对于这种边界查找的问题该如何处理?

查找左边界

在基础二分查找的基础上改造,思考在target等于nums[mid]的情况下,我们不能直接返回mid,因为我们要找target的左边界,所以此时我们要收缩右边界:right = mid - 1,此外的操作与基本二分算法都一样,等到循环结束会出现以下几种情况:

  1. 如果target比数组中的所有值都大,那么此时left===nums.length,此时返回-1
  2. 如果target在数组最大值与最小值范围之间,但是数组中没有该值,也得返回-1,所以这里还要做一个判断:nums[left] === target ? left : -1

那么根据这个思路具体实现:

function getLeftBound(nums: number[], target: number): number {
  // 左右指针
  let left = 0, right = nums.length - 1;
  // while循环条件:left<=right
  while(left <= right) {
    // 中间位置索引
    const mid = Math.floor((left + right) / 2);
    const num = nums[mid];
    if (target === num) {
      right = mid - 1
    } else if(target > num) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  if (left === nums.length) return -1
  return nums[left] === target ? left : -1;
};

查找右边界

知道查找左边界的写法,那么查找右边界也变得非常简单了,只需要修改target === nums[mid]时收缩左边界即可:left = mid + 1,返回值也需要类似的判断,由于是查找右边界,所以得判断right有没有超出数组的索引范围,那么最终的实现:

function getRightBound(nums: number[], target: number): number {
  // 左右指针
  let left = 0, right = nums.length - 1;
  // while循环条件:left<=right
  while(left <= right) {
    // 中间位置索引
    const mid = Math.floor((left + right) / 2);
    const num = nums[mid];
    if (target === num) {
      left = mid + 1
    } else if(target > num) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  if (right === -1) return -1
  return nums[right] === target ? right : -1;
};

那么知道了左右边界的查找方法,我们可以来练习一下:34. 在排序数组中查找元素的第一个和最后一个位置:给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
思路:像这种要求时间复杂度为O(logn)的题目基本都是使用二分查找的方法来处理,利用上述的左右边界来处理这道题非常的简单:

function searchRange(nums: number[], target: number): number[] {
  return [getLeftBound(nums, target), getRightBound(nums, target)];
};