二分搜索模板

源码,是最好的学习资料。

之前做二分查找没有一个方法准则,总是现抠代码实现的,对于边界情况总是把握不准,知道它是有一定准则的,但总是疏于整理。后来看到 go 的 sort 包中有实现二分查找算法,非常简洁,所以这里设计思路分析一波(自己再也不怕手撕二分了)。

go 源码 sort.Search

sort.Search(n int, f func(int) bool)

源码 sort.Search() 给出了使用准则:

  • 第一个参数 n 表示数列元素个数
  • 第二个参数为闭包函数 f func(i int) bool
  • sort.Search 方法查找的就是满足函数 f(i) == true 的第一个下标位置 k,所以对 f 函数有约定满足公式:
    f(i) == false, (i < k)
    f(i) == true, (i >= k)
    

sort.Search 的实现思路

为了更好的解释,这里不是 copy 的源码。而是一个普通的二分查找算法寻找 target 的下标或可插入的位置坐标。这可以是自己实现的一个模板。

所有的二分查找都可以转换成这个查找:查找符合条件的第一个元素的下标 k。这个条件 f 满足:当 i < k 时,f(i) == false;当 i >= k 时,f(i) == true

用二分的前提是数列一定是有序的,这不再多解释。

由上条件我们可以将这个数列简化为 [false, false, true, true, true],即找第一个为 true 的下标。

二分法的最基本的模板应该是这样:

for FOR_COND:
    mid = (left + right) / 2 // 取得中间点
    if cond(mid):
        right = NEXT_RIGHT // 动 right 
    else 
        left = NEXT_LEFT // 动 left

但细节就在模板里的三个占位语句上。如 FOR_COND 该选择 left < right 还是 left <= right?NEXT_RIGHT 或 NEXT_LEFT 应该是 mid±1 还是 mid。

这里我们将 left = left + 1 称为“left 向前游走一次”;right = right - 1 称为 “right 向后游走一次”。每次 mid = (left + right) / 2 后,mid 总要替换 left 或 right,即若替换 left 时可以为 left = mid 就是说“不游走”,若为 left = mid + 1 就是说“left 游走一次”。即可发现:

  • 让 left 游走,就可能让 left 从指向 false 变为指向 true
  • 让 right 游走,就可能让 right 从指向 true 变为指向 false

所以,NEXT_LEFT 的条件其实主要就是上述两种影响。

考虑

  • mid = (left + right) / 2 的计算中,left != right 时,mid 是有可能等于 left 而不可能等于 right
  • 而上述中排除的 left == right 条件正好可以做终止循环的条件。

所以,

  • 由第一个条件,我们最好让 left 游走,而 right 永远指向 true 的位置最好了。
  • 即 NEXT_RIGHT 处为 right = mid;NEXT_LEFT 处为 left = mid + 1
  • 这时,FOR_COND 就可以是 left < right
  • 由于 right 永远指向 true 的位置,为了避免全数列都是 false 的情况,我们让 right 最初为 n(n 为数列长度),即假设了 f(n) == true
  • 同时注意,数列全为 false 时,返回的下标是 n,是数列溢出的下标位置。

综上,我们可以写出一下模板:

func binarySearch:
    left, right = 0, n 
    for left < right:
        mid = left + (right - left) / 2 // 防溢出
        if cond(mid):
            left = mid + 1
        else 
            right = mid
// 最终得到 left 值为第一个满足 cond 的位置(注意可能为 n)

练习题

由上拿 力扣:35. 搜索插入位置 (找到 target 可插入的位置)为例,不难写出如下代码:

func searchInsert(nums []int, target int) int {
    cond := func(i int) bool { return nums[i] >= target }

    left := 0
    right := len(nums) // right may not idx
    for left < right { // stop when left == right
        mid := left + (right-left)/2 // mid could be left but won't be right, if left != right
        if cond(mid) {
            right = mid // right is always hit the cond
        } else {
            left = mid + 1 //
        }
    }
    return left
}

力扣:278. 第一个错误的版本 更像是进一步让自己设计出 sort.Search 的代码出的题:

// var isBadVersion func(i int) bool // 力扣的伪代码
func firstBadVersion(n int) int {
    left:= 0
    right:= n

    for left < right {
        mid:= left + (right - left) / 2
        if isBadVersion(mid) {
            right = mid
        }else {
            left = mid + 1
        }
    }
    return left
}

总结

可见,你可以通过变更上述的 cond 函数(必须满足前面都是 false,后面都是 true),来查找到合适的点(第一个使 cond(i) == true 的位置点 k)


香饽饽

go 包的 sort.Search 真实太香了。下面的题看到题后直接抠码三分钟就完成。

可见,熟悉内置的一些算法方法,可以让做题效率提升很多。力扣竞赛中常常有 20 分钟内做完四道题的人,可想而知。

  • 力扣中等题 162. 寻找峰值
func findPeakElement(nums []int) int {
    switch len(nums) {
    case 1:
        return 0
    case 2:
        if nums[0] > nums[1] {
            return 0
        }
        return 1
    }

    x := sort.Search(len(nums)-1, func(i int) bool {
        switch i {
        default:
            return nums[i]-nums[i+1] > 0
        }
    })
    return x
}
  • 力扣中等题 153. 寻找旋转排序数组中的最小值
func findMin(nums []int) int {
    if len(nums) == 0 {
        return nums[0]
    }
    x := sort.Search(len(nums), func(i int) bool {
        return nums[i] < nums[0]
    })
    if x < len(nums) {
        return nums[x]
    }
    return nums[0]
}

你可能感兴趣的:(二分搜索模板)