二分查找

针对有序数据集合的查找算法:二分查找(Binary Search)算法,也叫折半查找算法。

假设有10个数据:8,11,19,23,27,33,45,55,67,98,快速找到19这个值是否在数据中。

利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围,过程如下图所示,其中,lowhigh表示待查找区间的下标,mid表示待查找区间的中间元素下标。

二分查找
  1. 二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。

  2. 每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。

时间复杂度

假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。

时间复杂度
  1. 这是一个等比数列

  2. 其中 n2k=1\frac{n}{2^k}=1 时,k 的值就是总共缩小的次数

  3. 每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度是 O(k)

  4. 通过 n2k=1\frac{n}{2^k}=1,求得 k=log2nk=log2n,时间复杂度是 O(logn)

O(logn)这种对数时间复杂度,是一种极其高效的时间复杂度,有时甚至比时间复杂度是常量级O(1)的算法还要高效。

最简单情况的代码实现

最简单的情况就是有序数组中不存在重复元素

  1. 创建三个变量lowhighmid都是数组下标

  2. 其中 lowhigh 表示当前查找的区间范围,初始low=0high=n-1

  3. mid 表示[low, high]的中间位置

  4. 通过对比a[mid]value的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为0,就退出

注意:

  1. 循环退出条件:是 low>=high,而不是 low>igh

  2. mid的取值mid=low+high2mid=\frac{low+high}{2}在数据较大时可能溢出,改成low+(highlow)2low+\frac{(high-low)}{2},优化性能可以将除法运算改为位运算low+((high-low)>>1)

  3. lowhigh的更新low=mid+1high=mid-1,如果直接写成low=mid或者high=mid,就可能会发生死循环

也可以使用递归实现:

  1. 递推公式:binarySearch(p...r,value)=binarySearch(p...mid-1,value) or binarySearch(mid+1...r,value)

  2. 终止条件:A[mid] = value or low >= high

应用场景的局限性

  1. 二分查找依赖的是顺序表结构(简单点说就是数组):因为二分查找算法需要按照下标随机访问元素

  2. 二分查找针对的是有序数据:如果数据没有序,需要先排序,排序的时间复杂度最低是O(nlogn)。所以,如果针对一组静态数据(没有频繁地插入、删除),可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低

  3. 数据量太小不适合二分查找:数据量太小,遍历数组就足够(如果数据之间的比较操作非常耗时,需要尽可能减少比较次数,推荐使用二分查找

  4. 数据量太大也不适合二分查找:底层依赖数组,数组实现随机访问需要内存中连续的空间,这个条件比较苛刻,所以太大的数据用数组存储就比较吃力,就不能用二分查找

常见变形问题的代码实现

有序数据集合中不存在重复的数据。

查找第一个值等于给定值的元素

a[mid]跟要查找的 value 的大小关系有三种情况:

  • 对于 a[mid]>value 的情况,更新 high=mid-1

  • 对于 a[mid]<value 的情况,更新 low=mid+1

  • 对于 a[mid]=value 的情况

    • 如果查找的是任意一个值等于给定值的元素,当 a[mid] 等于要查找的值时,a[mid]就是要找的元素。

    • 如果查找的是第一个值等于给定值的元素,当 a[mid] 等于要查找的值时,就需要确认一下这个 a[mid]是不是第一个值等于给定值的元素。

func BinarySearchFirst(list []int, value int) int {
    low := 0
    high := len(list) - 1

    for low <= high {
        mid := low + ((high - low) >> 1)
        if list[mid] < value {
            low = mid + 1
        } else if list[mid] > value {
            high = mid - 1
        } else {
            if mid == 0 || list[mid-1] != value {
                return mid
            } else {
                high = mid - 1
            }
        }
    }

    return -1
}

看第 12 行代码。

  1. 如果 mid 等于 0,那这个元素已经是数组的第一个元素;

  2. 如果 mid 不等于 0,但 a[mid] 的前一个元素 a[mid-1] 不等于 value,那也说明 a[mid]就是要找的第一个值等于给定值的元素。

  3. 如果经过检查之后发现 a[mid]前面的一个元素 a[mid-1]也等于 value,那说明此时的 a[mid]肯定不是要查找的第一个值等于给定值的元素。更新 high=mid-1,要找的元素肯定出现在[low, mid-1]之间。

查找最后一个值等于给定值的元素

基于上面的分析:

  1. 将第12行代码中if的判定条件修改为mid == len(a)-1 || list[mid+1] != value

  2. 将第15行代码修改为low=mid+1

查找第一个大于等于给定值的元素

func BinarySearchGE(list []int, value int) int {
    length := len(list)
    low := 0
    high := length - 1

    for low <= high {
        mid := low + ((high - low) >> 1)
        if list[mid] >= value {
            if mid == 0 || list[mid-1] < value {
                return mid
            } else {
                high = mid - 1
            }
        } else {
            low = mid + 1
        }
    }
    return -1
}

看第7、8行代码:

  1. 如果list[mid]>=value,且mid==0,那就是要查找的值

  2. 如果list[mid]的前一个值小于value,那就是要查找的值

  3. 否则说明要查找的值在[low,mid-1]之间

  4. 如果list[mid]<value,说明要查找的之在[mid+1,high]之间

查找最后一个小于等于给定值的元素

func BinarySearchLE(list []int, value int) int {
    length := len(list)
    low := 0
    high := length - 1

    for low <= high {
        mid := low + ((high - low) >> 1)
        if list[mid] > value {
            high = mid - 1
        } else {
            if mid == len(list)-1 || list[mid+1] > value {
                return mid
            } else {
                low = mid + 1
            }
        }
    }
    return -1
}

实际上,求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显

上述这些变体的二分查找算法很容易因为细节处理不好而产生 Bug,这些容易出错的细节有:

  • 终止条件

  • 区间上下界更新方法

  • 返回值选择

常考考点

二分查找

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

思路

经典二分查找应用场景,注意:

  1. 退出条件是 start>end,也就是start<=end的情况都满足条件(只要一个值的时候也可以比较)

  2. 因为mid值已经比较过不用再考虑,所以start和end值更新为mid+1和mid-1

示例代码

func search(nums []int, target int) int {
    n := len(nums)
    if n < 1 {
        return -1
    }

    start, end := 0, n-1
    for start <= end {
        mid := start + (end-start)/2
        if nums[mid] < target {
            start = mid + 1
        } else if nums[mid] > target {
            end = mid - 1
        } else {
            return mid
        }
    }
    return -1
}

搜索插入位置

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

可以假设数组中无重复元素。

思路

要找到待插入位置,其实就是寻找对个大于等于taget值的位置。

当都没有找到的时候,说明target值比数组中的值都大,那么就在末尾插入。

示例代码

func searchInsert(nums []int, target int) int {
    n := len(nums)
    if n < 1 {
        return -1
    }

    start, end := 0, n-1
    for start <= end {
        mid := start + (end-start)/2
        if nums[mid] >= target {
            if mid == 0 || nums[mid-1] < target {
                return mid
            } else {
                end = mid - 1
            }
        } else {
            start = mid + 1
        }
    }
    return n
}

搜索二维矩阵

编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:

每行中的整数从左到右按升序排列。 每行的第一个整数大于前一行的最后一个整数。

思路

根据附加条件,可以将二维数组转换为一维有序数组,然后使用二分查找,关键点就是如何计算mid值在二位数组中对应的坐标:

col表示二位数组中每一行的长度:

i:= mid/col j:= mid%col

通过上面的转换,就可以将mid对应的值和target进行比较。

示例代码

func searchMatrix(matrix [][]int, target int) bool {
    if len(matrix) == 0 || len(matrix[0]) == 0 {
        return false
    }

    row := len(matrix)
    col := len(matrix[0])

    start, end := 0, row*col-1
    for start <= end {
        mid := start + (end-start)/2
        // 求mid在二维数组中对应的坐标
        val := matrix[mid/col][mid%col]
        if val > target {
            end = mid - 1
        } else if val < target {
            start = mid + 1
        } else {
            return true
        }
    }
    return false
}

第一个错误的版本

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。

假设你有 n 个版本 [1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

思路

查找有序数组中第一个大于等于目标值的索引。

示例代码

func firstBadVersion(n int) int {
    start := 0
    end := n
    for start <= end {
        mid := start + (end-start)/2
        if isBadVersion(mid) {
      // 当前版本能够成功
      // 那么查看前一个版本能否成功
            if !isBadVersion(mid - 1) {
                return mid
            } else {
                end = mid - 1
            }
        } else {
            start = mid + 1
        }
    }
    return -1
}

寻找旋转排序数组中的最小值

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请找出其中最小的元素。

你可以假设数组中不存在重复元素。

思路

因为是在中间的某个点进行了旋转,所以将数组最后一个元素作为目标值,然后要在数组中遵照最后一个小于等于它的元素,当循环退出时,start在end之前,所以start就是我们要找的结果。

示例代码

func findMin(nums []int) int {
    n := len(nums)
    if n == 0 {
        return -1
    }

    target := nums[n-1]
    start, end := 0, n-1
    for start <= end {
        mid := start + (end - start)
        if nums[mid] <= target {
            end = mid - 1
        } else {
            start = mid + 1
        }
    }
    return nums[start]
}

寻找旋转排序数组中的最小值【变体1】

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请找出其中最小的元素。

注意数组中可能存在重复的元素。

思路

与上一题一样,关键是数组中存储重复的元素,在进行二分查找之前,现将重复的元素去除。

示例代码

func findMin(nums []int) int {
    n := len(nums)
    if n == 0 {
        return -1
    }

    start, end := 0, n-1
    for start+1 < end {
        for start < end && nums[end] == nums[end-1] {
            end--
        }
        for start < end && nums[start] == nums[start+1] {
            start++
        }

        mid := start + (end-start)/2
        if nums[mid] <= nums[end] {
            end = mid
        } else {
            start = mid
        }
    }

    if nums[start] > nums[end] {
        return nums[end]
    }
    return nums[start]
}

搜索旋转排序数组

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。

你可以假设数组中不存在重复的元素。

你的算法时间复杂度必须是 O(log n) 级别。

思路

示例代码

最后更新于

这有帮助吗?