二分查找本质

二分查找本质

二分查找

说到二分查找,对于稍微了解过经典算法的计算机学生来说,简直太熟悉不过。能够将线性查找时间复杂度O(N)缩为O(logN)。但很多时候,理解二分思想,和能够在正确的场景正确的位置上使用二分之间还是有段距离了,这里面需要有对于二分算法的本质理解。

python 复制代码
def binary_search(nums, target):
    left, right = 0, len(nums)-1
    while left <= right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid       # 找到直接返回
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return -1

二分本质

二分查找为什么能够将线性查找时间复杂度O(N)缩为O(logN)?普通查找遍历一个数组,需要从头到尾挨个遍历判断,而二分查找,每次取中点元素进行判断,满足与不满足条件,都能砍掉一半待判断元素。

"砍掉一半待判断元素" 就是二分的本质。为了能砍掉一半待判断元素,二分查找的应用才需要数组的"有序"属性,这个**"有序"** 属性,可以是直观上的有顺序,例如待遍历元素值递增、递减顺序等,也可以是逻辑上的有顺序,待求的目标值,在遍历的过程中是有顺序的。

结合实际例子来讲,最简单的,给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果 target 存在返回下标,否则返回 -1

java 复制代码
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4

正常去挨个遍历,时间复杂度为O(N),而使用二分,每次先取中点位置元素,进行条件校验(这里是判断中点元素mid与目标值target的大小关系),若等于,则直接返回,若比较大了,由于序数组升序,则mid右侧那一半元素肯定更大 ,直接砍掉,待遍历区间少一半;若比较小了,由于序数组升序,则mid左侧那一半元素肯定更小 ,直接砍掉,待遍历区间少一半 。这里的本质很好找,直接观察元素值大小即可。

再来一个例子,给你两个正整数数组 spells 和 potions ,长度分别为 n 和 m ,其中 spells[i] 表示第 i 个咒语的能量强度,potions[j] 表示第 j 瓶药水的能量强度。同时给你一个整数 success 。一个咒语和药水的能量强度 相乘 如果 大于等于 success ,那么它们视为一对 成功 的组合。请你返回一个长度为 n 的整数数组 pairs,其中 pairs[i] 是能跟第 i 个咒语成功组合的 药水 数目

java 复制代码
输入:spells = [3,1,2], potions = [8,5,8], success = 16
输出:[2,0,2]
解释:
- 第 0 个咒语:3 * [8,5,8] = [24,15,24] 。总共 2 个成功组合。
- 第 1 个咒语:1 * [8,5,8] = [8,5,8] 。总共 0 个成功组合。
- 第 2 个咒语:2 * [8,5,8] = [16,10,16] 。总共 2 个成功组合。
所以返回 [2,0,2] 。

正常思路就是挨个遍历spells元素spells[i],再挨个遍历potions元素potions[j],判断spells[i]*potions[j] >= success,若大于,则res[i]++,此时时间复杂度O(N²)。但是我们可以想一想,假如让spells或者potions升序(为了让结果数据res更好赋值,这里假设让potions升序),那么只要spells[i]*potions[j] success的大小关系判断出来了,例如spells[i]*potions[j] >= success,那么由于升序关系,spells[i]*potions[j...n-1]spells[i]*potions[j]右边的任一元素都会更大,肯定同样满足条件,那么直接累计答案个数,无需再遍历potions的下标 j - n-1,即砍掉了一半的待遍历元素

我们只需要二分找到第一个potionsj,满足spellsi*potionsj >= success即可,外层依旧是挨个遍历spells元素,时间复杂度O(NlogN)。

值得一提的是,二分找到第一个目标元素(数组元素不要求重复),和二分查找目标元素的写法有一点区别,这里还是以左闭右毕为例:

python 复制代码
# 找第一个大于等于 target 的下标
def lower_bound(nums, target):
    left = 0
    right = len(nums) - 1
    res = len(nums)  # 兜底:全部元素都小于target,返回数组长度
    while left <= right:
        mid = (left + right) // 2
        if nums[mid] >= target:
            # 当前mid符合条件,先记下来,去左边找更小下标
            res = mid
            right = mid - 1
        else:
            # 当前太小,左边全部作废,往右找
            left = mid + 1
    return res

当然,以上只是两个最简单的题目例子,大多数时候,实际的算法题目不会将要二分的"对象"暴露的这么明显(例如最大化最小值、最小化最大值等),但是本质一定还是:通过一个代表元素的条件判断,代替一半区间元素条件判断,砍掉一半待遍历元素