二分查找算法

二分查找又称折半搜索算法。 狭义地来讲,二分查找是一种在有序数组查找某一特定元素的搜索算法。bisect模块学习

基本概念

解空间

解空间指的是题目所有可能的解构成的集合。比如一个题目所有解的可能是 1,2,3,4,5,但具体在某一种情况只能是其中某一个数(即可能是 1,2,3,4,5 中的一个数)。那么这里的解空间就是 1,2,3,4,5 构成的集合,在某一个具体的情况下可能是其中任意一个值,我们的目标就是在某个具体的情况判断其具体是哪个。如果线性枚举所有的可能,就枚举这部分来说时间复杂度就是 O ( n ) O(n) O(n)
举了例子:
如果让你在一个数组 nums 中查找 target,如果存在则返回对应索引,如果不存在则返回 -1。那么对于这道题来说其解空间是什么?
很明显解空间是区间 [-1, n-1],其中 n 为 nums 的长度。
需要注意的是上面题目的解空间只可能是区间 [-1,n-1] 之间的整数。而诸如 1.2 这样的小数是不可能存在的。这其实也是大多数二分的情况。 但也有少部分题目解空间包括小数的。如果解空间包括小数,就可能会涉及到精度问题,这一点大家需要注意。
比如让你求一个数 x 的平方根,答案误差在 1 0 − 6 10^{-6} 106 次方都认为正确。这里容易知道其解空间大小可定义为 [1,x](当然可以定义地更精确,之后我们再讨论这个问题),其中解空间应该包括所有区间的实数,不仅仅是整数而已。这个时候解题思路和代码都没有太大变化,唯二需要变化的是:
更新答案的步长。 比如之前的更新是 l = mid + 1,现在可能就不行了,因此这样可能会错过正确解,比如正确解恰好就在区间 [mid,mid+1] 内的某一个小数。
判断条件时候需要考虑误差。由于精度的问题,判断的结束条件可能就要变成 与答案的误差在某一个范围内。
对于搜索类题目,解空间一定是有限的,不然问题不可解。对于搜索类问题,第一步就是需要明确解空间,这样你才能够在解空间内进行搜索。这个技巧不仅适用于二分法,只要是搜索问题都可以使用,比如 DFS,BFS 以及回溯等。只不过对于二分法来说,明确解空间显得更为重要。如果现在还不理解这句话也没关系,看完本文或许你就理解了。
定义解空间的时候的一个原则是: 可以大但不可以小。因为如果解空间偏大(只要不是无限大)无非就是多做几次运算,而如果解空间过小则可能错失正确解,导致结果错误。比如前面我提到的求 x 的平方根,我们当然可以将解空间定义的更小,比如定义为 [1,x/2],这样可以减少运算的次数。但如果设置地太小,则可能会错过正确解。这是新手容易犯错的点之一。
有的同学可能会说我看不出来怎么办呀。我觉得如果你实在拿不准也完全没有关系,比如求 x 的平方根,就可以甚至为 [1,x],就让它多做几次运算嘛。我建议你给上下界设置一个宽泛的范围。等你对二分逐步了解之后可以卡地更死一点。

序列有序

我这里说的是序列,并不是数组,链表等。也就是说二分法通常要求的序列有序,不一定是数组,链表,也有可能是其他数据结构。另外有的序列有序题目直接讲出来了,会比较容易。而有些则隐藏在题目信息之中。乍一看,题目并没有有序关键字,而有序其实就隐藏在字里行间。比如题目给了数组 nums,并且没有限定 nums 有序,但限定了 nums 为非负。这样如果给 nums 做前缀和或者前缀或(位运算或),就可以得到一个有序的序列啦。
虽然二分法不意味着需要序列有序,但大多数二分题目都有有序这个显著特征。只不过:

  • 有的是题目直接限定了有序。这种题目通常难度不高,也容易让人想到用二分。
  • 有的是需要你自己构造有序序列。这种类型的题目通常难度不低,需要大家有一定的观察能力。
    比如Triple Inversion。题目描述如下:
Given a list of integers nums, return the number of pairs i < j such that nums[i] > nums[j] * 3.

Constraints: n ≤ 100,000 where n is the length of nums
Example 1
Input:
nums = [7, 1, 2]
Output:
2
Explanation:
We have the pairs (7, 1) and (7, 2)

这道题并没有限定数组 nums 是有序的,但是我们可以构造一个有序序列 d,进而在 d 上做二分。代码:

class Solution:
    def solve(self, A):
        d = []
        ans = 0

        for a in A:
            i = bisect.bisect_right(d, a * 3)
            ans += len(d) - i
            bisect.insort(d, a)#等于insort_right
        return ans

极值

这里的极值是静态的,而不是动态的。这里的极值通常指的是求第 k 大(或者第 k 小)的数。堆的一种很重要的用法是求第 k 大的数,而二分法也可以求第 k 大的数,只不过二者的思路完全不同。使用堆求第 k 大的思路我已经在前面提到的堆专题里详细解释了。那么二分呢?这里我们通过一个例子来感受一下:这道题是 Kth Pair Distance,题目描述如下:

Given a list of integers nums and an integer k, return the k-th (0-indexed) smallest abs(x - y) for every pair of elements (x, y) in nums. Note that (x, y) and (y, x) are considered the same pair.

Constraints:n ≤ 100,000 where n is the length of nums
Example 1
Input:
nums = [1, 5, 3, 2]
k = 3
Output:
2
Explanation:

Here are all the pair distances:

abs(1 - 5) = 4
abs(1 - 3) = 2
abs(1 - 2) = 1
abs(5 - 3) = 2
abs(5 - 2) = 3
abs(3 - 2) = 1

Sorted in ascending order we have [1, 1, 2, 2, 3, 4].

简单来说,题目就是给的一个数组 nums,让你求 nums 第 k 大的任意两个数的差的绝对值。当然,我们可以使用堆来做,只不过使用堆的时间复杂度会很高,导致无法通过所有的测试用例。这道题我们可以使用二分法来降维打击。
对于这道题来说,解空间就是从 0 到数组 nums 中最大最小值的差,用区间表示就是 [0, max(nums) - min(nums)]。明确了解空间之后,我们就需要对解空间进行二分。对于这道题来说,可以选当前解空间的中间值 mid ,然后计算小于等于这个中间值的任意两个数的差的绝对值有几个,我们不妨令这个数字为 x。

  • 如果 x 大于 k,那么解空间中大于等于 mid 的数都不可能是答案,可以将其舍弃。
  • 如果 x 小于 k,那么解空间中小于等于 mid 的数都不可能是答案,可以将其舍弃。
  • 如果 x 等于 k,那么 mid 就是答案。

基于此,我们可使用二分来解决。这种题型,我总结为计数二分。我会在后面的四大应用部分重点讲解。
代码:

class Solution:
    def solve(self, A, k):
        A.sort()
        def count_not_greater(diff):
            i = ans = 0
            for j in range(1, len(A)):
                while A[j] - A[i] > diff:
                    i += 1
                ans += j - i
            return ans
        l, r = 0, A[-1] - A[0]#对差值做二分

        while l <= r:
            mid = (l + r) // 2
            if count_not_greater(mid) > k:
                r = mid - 1
            else:
                l = mid + 1
        return l

一个中心

二分法的一个中心大家一定牢牢记住。其他(比如序列有序,左右双指针)都是二分法的手和脚,都是表象,并不是本质,而折半才是二分法的灵魂。
前面已经给大家明确了解空间的概念。而这里的折半其实就是解空间的折半。
比如刚开始解空间是 [1, n](n 为一个大于 n 的整数)。通过某种方式,我们确定 [1, m] 区间都不可能是答案。那么解空间就变成了 (m,n],持续此过程知道解空间变成平凡(直接可解)。
注意区间 (m,n] 左侧是开放的,表示 m 不可能取到。
显然折半的难点是根据什么条件舍弃哪一步部分。这里有两个关键字:

  • 什么条件
  • 舍弃哪部分

几乎所有的二分的难点都在这两个点上。如果明确了这两点,几乎所有的二分问题都可以迎刃而解。幸运的是,关于这两个问题的答案通常都是有限的,题目考察的往往就是那几种。这其实就是所谓的做题套路。关于这些套路,我会在之后的四个应用部分给大家做详细介绍。

两种类型

问题定义

这里的问题定义是一个狭义的问题。而如果你理解了这个问题之后,可以将这个具体的问题进行推广以适应更复杂的问题。关于推广,我们之后再谈。
给定一个由数字组成的有序数组 nums,并给你一个数字 target。问 nums 中是否存在 target。如果存在, 则返回其在 nums 中的索引。如果不存在,则返回 - 1。
这是二分查找中最简单的一种形式。当然二分查找也有很多的变形,这也是二分查找容易出错,难以掌握的原因。
常见变体有:

  • 如果存在多个满足条件的元素,返回最左边满足条件的索引。
  • 如果存在多个满足条件的元素,返回最右边满足条件的索引。
  • 数组不是整体有序的。 比如先升序再降序,或者先降序再升序。
  • 将一维数组变成二维数组。
  • 。。。

接下来,我们逐个进行查看。

前提

数组是有序的(如果无序,我们也可以考虑排序,不过要注意排序的复杂度)
这个有序的数组可能是题目直接给的,也可能是你自己构造的。比如求数组的逆序数就可以在自己构造的有序序列上做二分。
术语
为了后面描述问题方便,有必要引入一些约定和术语。
二分查找中使用的术语:

  • target —— 要查找的值
  • index —— 当前位置
  • l 和 r —— 左右指针
  • mid —— 左右指针的中点,用来确定我们应该向左查找还是向右查找的索引(其实就是收缩解空间)

查找一个数

前面我们已经对问题进行了定义。接下来,我们需要对定义的问题进行分析和求解。
为了更好理解接下来的内容,我们解决最简单的类型 - 查找某一个具体值 。
算法描述:

  • 先从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;
  • 如果目标元素大于中间元素,那么数组中小于中间元素的值都可以排除(由于数组有序,那么相当于是可以排除数组左侧的所有值),解空间可以收缩为 [mid+1, r]。
  • 如果目标元素小于中间元素,那么数组中大于中间元素的值都可以排除(由于数组有序,那么相当于是可以排除数组右侧的所有值),解空间可以收缩为 [l, mid - 1]。
  • 如果在某一步骤解空间为空,则代表找不到。

思维框架

大家不要小看这样的一个算法。就算是这样一个简简单单,朴实无华的二分查找, 不同的人写出来的差别也是很大的。 如果没有一个思维框架指导你,不同的时间你可能会写出差异很大的代码。这样的话,犯错的几率会大大增加。这里给大家介绍一个我经常使用的思维框架和代码模板。
首先定义解空间为 [left, right],注意是左右都闭合,之后会用到这个点
你可以定义别的解空间形式,不过后面的代码也相应要调整,感兴趣的可以试试别的解空间。
由于定义的解空间为 [left, right],因此当 left <= right 的时候,解空间都不为空,此时我们都需要继续搜索。 也就是说终止搜索条件应该为 left <= right。
举个例子容易明白一点。 比如对于区间 [4,4],其包含了一个元素 4,因此解空间不为空,需要继续搜索(试想 4 恰好是我们要找的 target,如果不继续搜索, 会错过正确答案)。而当解空间为 [left, right) 的时候,同样对于 [4,4],这个时候解空间却是空的,因为这样的一个区间不存在任何数字·。
循环体内,我们不断计算 mid ,并将 nums[mid] 与 目标值比对。

  • 如果 nums[mid] 等于目标值, 则提前返回 mid(只需要找到一个满足条件的即可)
  • 如果 nums[mid] 小于目标值, 说明目标值在 mid 右侧,这个时候解空间可缩小为 [mid + 1, right] (mid 以及 mid 左侧的数字被我们排除在外)
  • 如果 nums[mid] 大于目标值, 说明目标值在 mid 左侧,这个时候解空间可缩小为 [left, mid - 1] (mid 以及 mid 右侧的数字被我们排除在外)
    循环结束都没有找到,则说明找不到,返回 -1 表示未找到。

代码模板

def binarySearch(nums, target):
    # 左右都闭合的区间 [l, r]
    l, r = 0, len(nums) - 1
    while l <= r:
        mid = (left + right) >> 1
        if nums[mid] == target: return mid
        # 解空间变为 [mid+1, right]
        if nums[mid] < target: l = mid + 1
        # 解空间变为 [left, mid - 1]
        if nums[mid] > target: r = mid - 1
    return -1

寻找最左插入位置

上面我们讲了寻找满足条件的值。如果找不到,就返回 -1。那如果不是返回 -1,而是返回应该插入的位置,使得插入之后列表仍然有序呢?
比如一个数组 nums: [1,3,4],target 是 2。我们应该将其插入(注意不是真的插入)的位置是索引 1 的位置,即 [1,2,3,4]。因此寻找最左插入位置应该返回 1,而寻找满足条件的位置 应该返回-1。
另外如果有多个满足条件的值,我们返回最左侧的。 比如一个数组 nums: [1,2,2,2,3,4],target 是 2,我们应该插入的位置是 1。

思维框架

具体算法:
首先定义解空间为 [left, right],注意是左右都闭合,之后会用到这个点。

  • 由于我们定义的解空间为 [left, right],因此当 left <= right 的时候,解空间都不为空。 也就是说我们的终止搜索条件为 left <= right。
  • 当 A[mid] >= x,说明找到一个备胎,我们令 r = mid - 1 将 mid 从解空间排除,继续看看有没有更好的备胎。
  • 当 A[mid] < x,说明 mid 根本就不是答案,直接更新 l = mid + 1,从而将 mid 从解空间排除。
    最后解空间的 l 就是最好的备胎,备胎转正。

代码模板

def bisect_left(nums, x):
    # 内置 api
    bisect.bisect_left(nums, x)
    # 手写
    l, r = 0, len(A) - 1
    while l <= r:
        mid = (l + r) // 2
        if A[mid] >= x: r = mid - 1
        else: l = mid + 1
    return l

寻找最右插入位置

思维框架

具体算法:
首先定义解空间为 [left, right],注意是左右都闭合,之后会用到这个点。

  • 由于我们定义的解空间为 [left, right],因此当 left <= right 的时候,解空间都不为空。 也就是说我们的终止搜索条件为 left <= right。
  • 当 A[mid] > x,说明找到一个备胎,我们令 r = mid - 1 将 mid 从解空间排除,继续看看有没有更好的备胎。
  • 当 A[mid] <= x,说明 mid 根本就不是答案,直接更新 l = mid + 1,从而将 mid 从解空间排除。
    最后解空间的 l 就是最好的备胎,备胎转正。

代码模板

def bisect_right(nums, x):
    # 内置 api
    bisect.bisect_right(nums, x)
    # 手写
    l, r = 0, len(A) - 1
    while l <= r:
        mid = (l + r) // 2
        if A[mid] <= x: l = mid + 1
        else: r = mid - 1
    return l

以上就是两种二分的基本形式了。而在实际的写代码过程中,我不会使用寻找满足条件的值模板,而是直接使用最左 或者 最右 插入模板。为什么呢?因为后者包含了前者,并还有前者实现不了的功能。比如我要实现寻找满足条件的值,就可直接使用最左插入模板找到插入索引 i,只不过最后判断一下 nums[i] 是否等于 target 即可,如果不等于则返回 -1,否则返回 i。这也是为什么我将二分分为两种类型,而不是三种甚至四种的原因。
另外最左插入和最右插入可以结合使用从而求出有序序列中和 target 相等的数的个数,这在有些时候会是一个考点。代码表示:

nums = [1,2,2,2,3,4]
i = bisect.bisect_left(nums, 2) # get 1
j = bisect.bisect_right(nums, 2) # get 4
#j - i 就是 nums 中 2 的个数

为了描述方便,以后所有的最左插入二分我都会简称最左二分,代码上直接用 bisect.bisect_left 表示,而最右插入二分我都会简称最右二分,代码上用 bisect.bisect_right 或者 bisect.bisect 表示。

四大应用

基础知识铺垫了差不多了。接下来,我们开始干货技巧。
接下来要讲的:

  • 能力检测和计数二分本质差不多,都是普通二分 的泛化。
  • 前缀和二分和插入排序二分,本质都是在构建有序序列。

能力检测二分

能力检测二分一般是:定义函数 possible, 参数是 mid,返回值是布尔值。外层根据返回值调整"解空间"。
示例代码(以最左二分为例):

def ability_test_bs(nums):
  def possible(mid):
    pass
  l, r = 0, len(A) - 1
  while l <= r:
      mid = (l + r) // 2
      # 只有这里和最左二分不一样
      if possible(mid): l = mid + 1
      else: r = mid - 1
  return 

和最左最右二分这两种最最基本的类型相比,能力检测二分只是将 while 内部的 if 语句调整为了一个函数罢了。因此能力检测二分也分最左和最右两种基本类型。
基本上大家都可以用这个模式来套。明确了解题的框架,我们最后来看下能力检测二分可以解决哪些问题。这里通过三道题目带大家感受一下,类似的题目还有很多,大家自行体会。
Leetcode875. 爱吃香蕉的珂珂

class Solution:
    def minEatingSpeed(self, piles: List[int], h: int) -> int:
        l,r=1,max(piles)
        def time(speed):
            ans=0
            for p in piles:
                ans+=p//speed if p%speed==0 else p//speed+1
            return ans<=h
        while l<=r:
            m=(l+r)//2
            if time(m):r=m-1
            else: l=m+1
        return l

题目是让我们求H 小时内吃掉所有香蕉的最小速度。
符合直觉的做法是枚举所有可能的速度,找出所有的可以吃完香蕉的速度,接下来选择最小的速度即可。由于需要返回最小的速度,因此选择从小到大枚举会比较好,因为可以提前退出。 这种解法的时间复杂度比较高,为 O ( N ∗ M ) O(N * M) O(NM),其中 N 为 piles 长度, M 为 Piles 中最大的数(也就是解空间的最大值)。
观察到需要检测的解空间是个有序序列,应该想到可能能够使用二分来解决,而不是线性枚举。可以使用二分解决的关键和前面我们简化的二分问题并无二致,关键点在于如果速度 k 吃不完所有香蕉,那么所有小于等于 k 的解都可以被排除。
二分解决的关键在于:

  • 明确解空间。 对于这道题来说, 解空间就是 [1,max(piles)]。
  • 如何收缩解空间。关键点在于如果速度 k 吃不完所有香蕉,那么所有小于等于 k 的解都可以被排除。

综上,我们可以使用最左二分,即不断收缩右边界。

最小灯半径

You are given a list of integers nums representing coordinates of houses on a 1-dimensional line. You have 3 street lights that you can put anywhere on the coordinate line and a light at coordinate x lights up houses in [x - r, x + r], inclusive. Return the smallest r required such that we can place the 3 lights and all the houses are lit up.

Constraints

n ≤ 100,000 where n is the length of nums
Example 1
Input
nums = [3, 4, 5, 6]
Output
0.5
Explanation
If we place the lamps on 3.5, 4.5 and 5.5 then with r = 0.5 we can light up all 4 houses.

本题和力扣 475. 供暖器 类似。
这道题的意思是给你一个数组 nums,让你在 [min(nums),max(nums)] 范围内放置 3 个灯,每个灯覆盖半径都是 r,让你求最小的 r。
之所以不选择小于 min(nums) 的位置和大于 max(nums) 的位置是因为没有必要。比如选取了小于 min(nums) 的位置 pos,那么选取 pos 一定不比选择 min(nums) 位置结果更优。
这道题的核心点还是一样的思维模型,即:

  • 确定解空间。这里的解空间其实就是 r。不难看出 r 的下界是 0, 上界是 max(nums) - min(nums)。
    没必要十分精准,只要不错过正确解即可,这个我们在前面讲过,这里再次强调一下。
  • 对于上下界之间的所有可能 x 进行枚举(不妨从小到大枚举),检查半径为 x 是否可以覆盖所有,返回第一个可以覆盖所有的 x 即可。
    注意到我们是在一个有序序列进行枚举,因此使用二分就应该想到。可使用二分的核心点在于:如果 x 不行,那么小于 x 的所有半径都必然不行。
  • 接下来的问题就是给定一个半径 x,判断其是否可覆盖所有的房子。

判断其是否可覆盖就是所谓的能力检测,我定义的函数 possible 就是能力检测。
首先对 nums 进行排序,这在后面会用到。 然后从左开始模拟放置灯。先在 nums[0] + r 处放置一个灯,其可以覆盖 [nums[0] , nums[0] + 2r]。由于 nums 已经排好序了,那么这个等可以覆盖到的房间其实就是 nums 中坐标小于等于 2 \ r 所有房间,使用二分查找即可。对于 nums 右侧的所有的房间我们需要继续放置灯,采用同样的方式即可。

class Solution:
    def solve(self, nums):
        nums.sort()
        N = len(nums)
        if N <= 3:
            return 0
        LIGHTS = 3
        # 这里使用的是直径,因此最终返回需要除以 2
        def possible(diameter):
            start = nums[0]
            end = start + diameter
            for i in range(LIGHTS):
                idx = bisect_right(nums, end)
                if idx >= N:
                    return True
                start = nums[idx]
                end = start + diameter
            return False

        l, r = 0, nums[-1] - nums[0]
        while l <= r:
            mid = (l + r) // 2
            if possible(mid):
                r = mid - 1
            else:
                l = mid + 1
        return l / 2

778. 水位上升的泳池中游泳

在一个 N x N 的坐标方格  grid 中,每一个方格的值 grid[i][j] 表示在位置 (i,j) 的平台高度。

现在开始下雨了。当时间为  t  时,此时雨水导致水池中任意位置的水位为  t 。你可以从一个平台游向四周相邻的任意一个平台,但是前提是此时水位必须同时淹没这两个平台。假定你可以瞬间移动无限距离,也就是默认在方格内部游动是不耗时的。当然,在你游泳的时候你必须待在坐标方格里面。

你从坐标方格的左上平台 (00) 出发。最少耗时多久你才能到达坐标方格的右下平台  (N-1, N-1)?

示例 1:

输入: [[0,2],[1,3]]
输出: 3
解释:
时间为 0 时,你位于坐标方格的位置为 (0, 0)。
此时你不能游向任意方向,因为四个相邻方向平台的高度都大于当前时间为 0 时的水位。

等时间到达 3 时,你才可以游向平台 (1, 1). 因为此时的水位是 3,坐标方格中的平台没有比水位 3 更高的,所以你可以游向坐标方格中的任意位置
示例 2:

输入: [[0,1,2,3,4],[24,23,22,21,5],[12,13,14,15,16],[11,17,18,19,20],[10,9,8,7,6]]
输出: 16
解释:
0 1 2 3 4
24 23 22 21 5
12 13 14 15 16
11 17 18 19 20
10 9 8 7 6

最终的路线用加粗进行了标记。
我们必须等到时间为 16,此时才能保证平台 (0, 0)(4, 4) 是连通的

提示:

2 <= N <= 50.
grid[i][j] 位于区间 [0, ..., N*N - 1] 内。

我们从 (0,0) 开始在一个二维网格中搜索,直到无法继续或达到 (N-1,N-1),如果可以达到 (N-1,N-1),我们返回 true,否则返回 False 即可。

class Solution:
    def swimInWater(self, grid: List[List[int]]) -> int:
        l, r = 0, max([max(vec) for vec in grid])
        n=len(grid)
        seen=set()
        def CanSwim(m,x,y):
            if x<0 or y<0 or x>=n or y>=n:
                return False
            if grid[x][y]>m:#求最小值,如果存在grid[x][y]>m,那么无法通过grid[x][y]
                return False
            if x==n-1 and y==n-1:
                return True
            if (x,y) in seen:
                return False
            seen.add((x,y))
            return CanSwim(m,x-1,y) or CanSwim(m,x,y-1) or CanSwim(m,x+1,y) or CanSwim(m,x,y+1)
        while l<=r:
            m=(l+r)//2
            if CanSwim(m,0,0):r=m-1
            else:l=m+1
            seen=set()
        return l

计数二分

def count_bs(nums, k):
  def possible(mid, k):
    # xxx
    return cnt > k
  l, r = 0, len(A) - 1
  while l <= r:
      mid = (l + r) // 2
      if possible(mid, k): r = mid - 1
      else: l = mid + 1
  return l

题目推荐:第k小距离对

前缀和二分

前面说了:如果数组全是正的,那么其前缀和就是一个严格递增的数组,基于这个特性,我们可以在其之上做二分。类似的有单调栈/队列。这种题目类型很多,为了节省篇幅就不举例说明了。提出前缀和二分的核心的点在于让大家保持对有序序列的敏感度。

插入排序二分

除了上面的前缀和之外,我们还可以自行维护有序序列。一般有两种方式:

  • 直接对序列排序。
nums.sort()
bisect.bisect_left(nums, x) # 最左二分
bisect.bisect_right(nums, x) # 最右二分
  • 遍历过程维护一个新的有序序列,有序序列的内容为已经遍历过的值的集合。
    比如无序数组 [3,2,10,5],遍历到索引为 2 的项(也就是值为 10 的项)时,我们构建的有序序列为 [2,3,10]。
    注意我描述的是有序序列,并不是指数组,链表等具体的数据结构。而实际上,这个有序序列很多情况下是平衡二叉树。后面题目会体现这一点。
    代码表示:
d = SortedList()
for a in A:
    d.add(a) # 将 a 添加到 d,并维持 d 中数据有序
上面代码的 d 就是有序序列。

题目练习:

  1. 区间和的个数(困难)
给定一个整数数组 nums 。区间和 S(i, j) 表示在 nums 中,位置从 i 到 j 的元素之和,包含 i 和 j (i ≤ j)。

请你以下标 i (0 <= i <= nums.length )为起点,元素个数逐次递增,计算子数组内的元素和。

当元素和落在范围 [lower, upper] (包含 lower 和 upper)之内时,记录子数组当前最末元素下标 j ,记作 有效区间和 S(i, j) 。

求数组中,值位于范围 [lower, upper] (包含 lower 和 upper)之内的 有效 区间和的个数。

注意:
最直观的算法复杂度是 $O(n^2)$ ,请在此基础上优化你的算法。

示例:

输入:nums = [-2,5,-1], lower = -2, upper = 2,
输出:3
解释:
下标 i = 0 时,子数组 [-2][-2,5][-2,5,-1],对应元素和分别为 -232 ;其中 -22 落在范围 [lower = -2, upper = 2] 之间,因此记录有效区间和 S(0,0),S(0,2) 。
下标 i = 1 时,子数组 [5][5,-1] ,元素和 54 ;没有满足题意的有效区间和。
下标 i = 2 时,子数组 [-1] ,元素和 -1 ;记录有效区间和 S(2,2) 。
故,共有 3 个有效区间和。
提示:
0 <= nums.length <= 10^4

想法:
由前缀和的性质知道:区间 i 到 j(包含)的和 sum(i,j) = pre[j] - pre[i-1],其中 pre[i] 为数组前 i 项的和 0 <= i < n。

但是题目中的数字可能是负数,前缀和不一定是单调的啊?这如何是好呢?答案是手动维护前缀和的有序性。

比如 [-2,5,-1] 的前缀和 为 [-2,3,2],但是我们可以将求手动维护为 [-2,2,3],这样就有序了。但是这丧失了索引信息,因此这个技巧仅适用于无需考虑索引,也就是不需要求具体的子序列,只需要知道有这么一个子序列就行了,具体是哪个,我们不关心。

比如当前的前缀和是 cur,那么前缀和小于等于 cur - lower 有多少个,就说明以当前结尾的区间和大于等于 lower 的有多少个(前缀和小于等于 cur - lower的是以cur前面的值为结尾的前缀和,个数为s个,比如以 i 结尾,那么(i+1,cur)就是大于等于lower,这样的区间个数也是s 个)。类似地,前缀和小于等于 cur - upper 有多少个,就说明以当前结尾的区间和大于等于 upper 的有多少个。

基于这个想法,我们可使用二分在 l o g n logn logn 的时间快速求出这两个数字,使用平衡二叉树代替数组可使得插入的时间复杂度降低到 O ( l o g n ) O(logn) O(logn)。Python 可使用 SortedList 来实现, Java 可用 TreeMap 代替。
解答:

from sortedcontainers import SortedList
class Solution:
    def countRangeSum(self, A: List[int], lower: int, upper: int) -> int:
        ans, pre, cur = 0, SortedList([0]), 0
        for a in A:
            cur += a
            ans += pre.bisect_right(cur - lower) - pre.bisect_left(cur - upper)
            pre.add(cur)
        return ans

python中二分查找模块为bisect模块,其中只有几个函数:
x_insert_point = bisect.bisect_left(L,x)
在有序列表或者是容器L中查找x左侧的位置,若是不存在则返回应该插入的位置
x_insert_point = bisect.bisect_right(L,x)
在有序列表或者是容器L中查找x右侧的位置,若是不存在则返回应该插入的位置
x_insert_poin = bisect.insort_left(L,x)
将x插入到有序列表中,若列表中存在,则插入到其左侧
x_insert_poin = bisect.insort_right(L,x)
将x插入到有序列表中,若列表中存在,测插入到其右侧
python有序容器sortedcontainers:
主要包括SortedList、SortedDict、SortedSet三个实现类型,可以实现列表、词典、Set去重集合的去重,SortedSet相当于C++中的Set,SortedDict相当于map,基于红黑树实现,可以实现快速查找。
SortedSet常用方法:
Add():添加元素,
pop():删除元素
remove():移除某指定元素
count():计数
index():返回某元素索引等
bisect_left(val):返回小于等于某元素的最左边的值,同bisect模块中的一致
bisect_right(val):返回大于等于某元素的最右边的值,同bisect模块中的一致
update(可迭代对象):在原数组中,从可迭代对象中更新元素,返回其自身
union(可迭代对象):在原数组中,从可迭代对象中合并元素生成一个新的集合
SortedList常用方法同SortedSet一致
SortedDict()中有peekitem(index=- 1)方法,默认返回词典中最后一组(key,val),可以指定索引,例如st.peekitem(index = 0)返回第一组元素

Leetcode 493. 翻转对(困难)

给定一个数组 nums ,如果 i < j 且 nums[i] > 2*nums[j] 我们就将 (i, j) 称作一个重要翻转对。

你需要返回给定数组中的重要翻转对的数量。

示例 1:

输入: [1,3,2,3,1]
输出: 2


示例 2:

输入: [2,4,3,5,1]
输出: 3


注意:

给定数组的长度不会超过50000。
输入数组中的所有数字都在32位整数的表示范围内。

思路与上题类似,解答如下:

from sortedcontainers import SortedList
class Solution:
    def reversePairs(self, nums: List[int]) -> int:
        ans,pre=0,SortedList([])
        for num in nums:
            idx=pre.bisect_right(2*num)
            ans+=len(pre)-idx
            pre.add(num)
        return ans

小结

四个应用讲了两种构造有序序列的方式,分别是前缀和,插入排序。 另外理论上单调栈/队列也是有序的,也可是用来做二分,但是相关题目太少了,因此大家只要保持对有序序列的敏感度即可。

能力检测二分很常见,不过其仅仅是将普通二分的 if 部分改造成了函数而已。而对于计数二分,其实就是能力检测二分的特例,只不过其太常见了,就将其单独提取出来了。

另外,有时候有序序列也会给你稍微变化一种形式。比如二叉搜索树,大家都知道可以在 l o g n logn logn 的时间完成查找,这个查找过程本质也是二分。二叉查找树有有序序列么?有的!二叉查找树的中序遍历恰好就是一个有序序列。因此如果一个数比当前节点值小,一定在左子树(也就是有序序列的左侧),如果一个数比当前节点值大,一定在右子树(也就是有序序列的右侧)。

总结

本文主要讲了两种二分类型:最左和最右,模板已经给大家了,大家只需要根据题目调整解空间和判断条件即可。关于四种应用更多的还是让大家理解二分的核心折半。表面上来看,二分就是对有序序列的查找。其实不然,只不过有序序列很容易做二分罢了。因此战术上大家保持对有序序列的敏感度,战略上要明确二分的本质是折半,核心在于什么时候将哪一半折半。

一个问题能否用二分解决的关键在于检测一个值的时候是否可以排除解空间中的一半元素。比如我前面反复提到的如果 x 不行,那么解空间中所有小于等于 x 的值都不行。

对于简单题目,通常就是给你一个有序序列,让你在上面找满足条件的位置。顶多变化一点,比如数组局部有序,一维变成二维等。

中等题目可能需要让你自己构造有序序列。

困难题则可能是二分和其他专题的结合,比如上面的 778. 水位上升的泳池中游泳(困难),就是二分和搜索(我用的是 DFS)的结合。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值