数据结构与算法——二分查找

50 阅读9分钟

🍑 一、概念

二分查找是一种查找算法,指的是在一个有序且没有重复的数组中,查找某个指定的元素,并返回指定元素的位置,如果没有找到,则返回-1。

🍑 二、分治思想

二分查找的原理也是分治思想,即定位在指定区间n-m的中间元素k,判断中间元素k跟要查找的值是否相等,如果相等就返回k,如果大于就m=k-1,如果小于就n=k+1,继续递归处理,直到查找区间被缩小为0。

注意:如果猜测范围的数字有偶数个,中间数有两个,就选择较小的那个。

如下图:0-100之间找到数字23,只需要7次就能找到目标数值

image.png

🍑 二分查找时间复杂度—— O(logn)

二分查找是一种非常高效的查找算法,它的时间复杂度分析如下,我们假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。被查找区间的变化如下图:

image.png 如图是一个等比数列。其中 n/2k=1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n/2k=1,我们可以求得 k=log2n,所以时间复杂度就是 O(logn)。

O(logn) 这种对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。因为用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。

🍑 三、算法实现

循环实现

假定有序数组中不存在重复元素,写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right)。区间的定义就是不变量,要在二分查找的过程中保持不变量,就是while循环中每一次边界的处理要根据区间的定义来操作。

1、左闭右闭写法————[left, right]

首先,我们定义target实在一个左闭右闭的区间,也就是[left, right] (非常重要) 。 看以下实现代码

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
var search = function(nums, target) {
    // right是数组最后一个数的下标,num[right]在查找范围内,是左闭右闭区间
    let mid, left = 0, right = nums.length - 1;
    // 当left=right时,由于nums[right]在查找范围内,所以要包括此情况
    while (left <= right) {
        // 位运算 + 防止大数溢出
        mid = left + ((right - left) >> 1);
        // 如果中间数大于目标值,要把中间数排除查找范围,所以右边界更新为mid-1;如果右边界更新为mid,那中间数还在下次查找范围内
        if (nums[mid] > target) {
            right = mid - 1;  // 去左面闭区间寻找
        } else if (nums[mid] < target) {
            left = mid + 1;   // 去右面闭区间寻找
        } else {
            return mid;
        }
    }
    return -1;
};

注意:

  • 1、循环退出的条件

while (left <= right) 要使用 <= ,因为left == right是有意义的,所以使用 <=

  • 2、mid 的取值

最简单的办法是mid=(left+right)/2,如果 left 和 right 比较大的话,两者之和就有可能会溢出。

改进:mid=left+(right-left)/2

优化:mid=left+((right-left)>>1)

相比除法运算来说,计算机处理位运算要快很多

  • 3、left和right的更新

if (nums[middle] > target) right 要赋值为 middle - 1,因为当前这个nums[middle]一定不是target,那么接下来要查找的左区间结束下标位置就是 middle - 1,如果直接等于mid可能导致死循环,如:当 right=3,left=3 时,如果 arr[3]不等于 value,就会导致一直循环不退出。

2、左闭右开写法————[left, right)

递归实现

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
var search = function(nums, target) {
    return bsearchInternally(nums, target, 0, nums.length-1);  
};

function bsearchInternally(nums, target, left, right) {
    if (left > right) return -1;  
    let mid = low + Math.floor((high - low) / 2); // 或者使用位运算提升效率,mid=left+((right-left)>>1) 
    if (nums[mid] === value) {  
        return mid;  
    } else if (nums[mid] < value) {  
        return bsearchInternally(nums, mid + 1, right, value);  
    } else {  
        return bsearchInternally(nums, left, mid - 1, value);  
    }
} 

🍑 四、二分查找应用条件

1、二分查找依赖的是顺序表结构,简单点说就是数组。

二分查找依赖的是顺序表结构,因为二分查找算法需要按照下标随机访问元素。数组按照下标随机访问数据的时间复杂度是 O(1)。

2、二分查找针对的是有序数据

二分查找的数据必须是有序的,如果无序,需要先对数据进行排序。二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。

3、数据量太小不适合二分查找

如果要处理的数据量很小,直接顺序遍历就可以了,但是如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找

4、数据量太大也不适合二分查找

二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间。所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。

🍑 五、二分查找———“近似”查找问题

1、查找数组中第一个值等于给定值的元素

如题:比如在[1,2,3,6,7,8,8,8,11,18]这样一个有序数组,其中,a[5],a[6],a[7]的值都等于 8,是重复的数据。我们希望查找第一个等于 8 的数据。代码如下:

function bsearch(arr, target) {  
  let left = 0;  // 数组最左边下标
  let right = arr.length - 1;  // 数组最右边下标
  while (left <= right) {  
    let mid = left+((right-left)>>1);  // 位运算防止内存溢出,得到数组中间位置下标
    if (arr[mid] > target) {
      right = mid - 1;  // 如果中间元素大于目标匀速,去左区间找,给right赋值为mid-1
    } else if (arr[mid] < target) {  
      left = mid + 1;  // 如果中间元素小于目标匀速,去右边区间找,给left赋值为mid+1
    } else {  
      if ((mid === 0) || (arr[mid - 1] !== target)) return mid;  // 如果是最后一位或者前面一位不等于target,说明mid是第一个等于给值的元素,否则再往前找,给right赋值为mid-1
      else right = mid - 1;  
    }  
  }  
  return -1;  // 如果都找到,返回-1
}

2、查找数组中最后一个值等于给定值的元素

如题:比如在[1,2,3,6,7,8,8,8,11,18]这样一个有序数组,其中,a[5],a[6],a[7]的值都等于 8,是重复的数据。我们希望查找最后一个等于 8 的数据。代码如下:

function bsearch(arr, target) {
  let n = arr.length; // 数组长度
  let left = 0;  // 数组最左边下标
  let right = n-1;  // 数组最右边下标
  while (left <= right) {  
    let mid = left+((right-left)>>1);  // 位运算防止内存溢出,得到数组中间位置下标
    if (arr[mid] > target) {
      right = mid - 1;  // 如果中间元素大于目标匀速,去左区间找,给right赋值为mid-1
    } else if (arr[mid] < target) {  
      left = mid + 1;  // 如果中间元素小于目标匀速,去右边区间找,给left赋值为mid+1
    } else {  
      if ((mid === n-1) || (arr[mid + 1] !== target)) return mid;  // 如果是最后一位或者前面一位不等于target,说明mid是第一个等于给值的元素,否则再往后找,给left赋值为mid+1
      else right = mid + 1;  
    }  
  }  
  return -1;  // 如果都找到,返回-1
}

3、查找数组中第一个大于等于给定值的元素

js实现代码如下:

function bsearch(arr, target) {
  let left = 0;  // 数组最左边下标
  let right = arr.length-1;  // 数组最右边下标
  while (left <= right) {  
    let mid = left+((right-left)>>1);  // 位运算防止内存溢出,得到数组中间位置下标
    if (arr[mid] >= target) {
       if ((mid === 0) || (arr[mid - 1] < target)) return mid;  // 如果是第一位或者前面一位小于target,说明mid是第一个大于等于给定值的元素,否则再往前找,给right赋值为mid-1
      else right = mid - 1;
    } else {  
      left =  mid + 1; //中间值小于目标值,去右边区间找,给left赋值
    }  
  }  
  return -1;  // 如果都找到,返回-1
}

4、查找数组中最后一个小于等于给定制的元素

js实现代码如下:

function bsearch(arr, target) {
  let n = arr.length; // 数组长度
  let left = 0;  // 数组最左边下标
  let right = n-1;  // 数组最右边下标
  while (left <= right) {  
    let mid = left+((right-left)>>1);  // 位运算防止内存溢出,得到数组中间位置下标
    if (arr[mid] <= target) {
       if ((mid === n-1) || (arr[mid + 1] > target)) return mid;  // 如果是第一位或者前面一位小于target,说明mid是第一个大于等于给定值的元素,否则再往前找,给right赋值为mid-1
      else left = mid + 1;
    } else {  
      right =  mid - 1; //中间值小于目标值,去右边区间找,给left赋值
    }  
  }  
  return -1;  // 如果都找到,返回-1
}

🍑 六、leetcode上相关算法题

  • 34.在排序数组中查找元素的第一个和最后一个位置
  • 35.搜索插入位置
  • 69.x 的平方根
  • 367.有效的完全平方数
  • 704.二分查找