从区间左闭右开体会【基于相遇指针的二分查找算法精髓】 2020-01-12(未经允许,禁止转载)

不想看的可以直接拉到最后看模板

只要问题可以转化为【在有一定规律的区间内查找一个target】,就可以使用本文的二分查找模板

区间的左闭右开

数学上区间边界有【开闭】之分。我们通过开闭的组合可以形成4种区间的表达方式。以序列0,1,2,3为例:

  • 双闭 0 <= x <= 3
  • 双开 -1 < x < 4
  • 左闭右开 0 <= x < 4
  • 左开右闭 -1 < x <= 3

【双闭】和【左闭右开】是最符合人类自然逻辑的2种区间表示方法
而大神Dijkstra早在1982年就对这4种区间表示方法的优劣进行了论述,详见链接https://www.cs.utexas.edu/users/EWD/ewd08xx/EWD831.PDF,结论是左闭右开是表示区间最友好最出色的方式,原因如下:

  • 1.上下界之差直接表示元素的个数
  • 2.在表示两个相邻子序列时相当方便和简洁:一个子序列的上界就是另一个子序列的下界
  • 3.当上界等于下界时即可表示空集,不会出现上界小于下界的情况。而双闭在表示空集时必然需要上界小于下界,比较难看,如0 <= x <= -1

因为原因2和3的存在,左闭右开在表示子序列以及子序列的边界关系上具有优势。左闭右开的优势在于能够高效完成序列切片并且保证edge case的正确

因此,左闭右开表示法在编程中处理线性序列问题时对边界情况非常友好。事实上,编程中出现了左闭右开的大量应用,如python的range()函数,slice操作等等

从左闭右开到二分查找

二分查找的思想或许很容易用文字描述,但要真正编程实现,在细节处理上(尤其一些边界问题)往往会出现各种各样的bug

我们可能习惯于使用双闭方式去描述一个区间,现在我们要基于以上对左闭右开方式的优势分析,使用左闭右开实现二分查找。我们会发现,这异常简洁。以下是基于左闭右开的二分查找插入位置【下界】的基本模板:

# 给定一个非降序数组和target,返回在数组中插入target的最小下标
class Solution:
    def binarySearchInsert(self, nums: List[int], low, high, target: int) -> int:
        while low < high:
            mid = low + ( high - low ) // 2
            if nums[mid] < target:
                # 当【mid小于target】时,target必在mid的右侧,将low提高至mid+1
                low = mid + 1     
            else:
                # 当【mid大于或者等于target】时,target必在mid的左侧或mid位置,将high移动至mid
                high = mid
        # 跳出循环,low == high,定位待插入位置
        return low

左闭右开的一些小优点

  • 1.代码分支更少。将普通二分查找(实际上是3分,> = & < 3种情况)转为真二分查找
  • 2.边界条件更加简洁。无需在mid分割的左右区间边界上过多地讨论+-1,只需讨论一个分支
  • 3.阅读和编写友好。当查找区间只包含一个元素时,low和high不会重合

注意

  • 1.取中点mid = (low + high) // 2虽然在python中不会产生溢出,因为python可以表示无限大的数,但是在c++/java等语言中则可能面临low + high的数值溢出问题。为增强程序的鲁棒性,使用mid = low + (high - low) // 2 会更优
  • 2.在左闭右开时,使用mid = low + (high - low) // 2,而不能使用使用mid = high - (high - low) // 2,因为(high - low) // 2是取整后的结果,high - (high - low) // 2可能造成mid偏大或者出现溢出,例如区间[3, 4)计算mid时,high - (high - low) // 2 = 4 - 0 = 4从而出现溢出。mid的计算必须从闭端出发向中心靠拢

但事实上,以上做法的本质似乎并不在于左闭右开,而是左闭右开促使我们的代码逻辑发生了变化。真正的本质在于代码逻辑的变化——
在左闭右开的情况下,实际上将常规的二分查找(会形成左子序列,中数,右子序列,这实际上是3分查找了)实现为真二分查找(只形成左子序列和右子序列),返回值index将会落在区间[low, high]里面,如果在index = high位置插入,这时是在数组的末尾append啦,也就是闭端对应的位置


从左闭右开体会相遇指针的精髓【由上延申】

左闭右开写二分简洁高效,本质上是因为【左闭+右开形成的区间覆盖了target可能的全部任意位置】

我们可以这样理解,不管是开还是闭,在程序里面其实都是一个指针嘛,指针并没有开闭之分,只是标记了一个位置,仅此而已。我把右指针指向原数组nums的最后一个元素后面,也就是index = len(nums)的地方,因为target完全可能在最后面插入的。这样它就能够和左指针协作,覆盖target可能的全部任意位置。这就是所谓的左闭右开的本质,例如数组[2,4,6,8,10],right首先指向index=5,即10后面的位置,因为待插入元素可能的插入位置就是0-5。而如果我把右指针指向原数组nums的最后一个元素,等于使用双闭区间,不能覆盖完所有情况,相对更繁琐

认识到这一点之后,我们再看上面的所谓左闭右开的二分查找,说白了过程就是这样:
1.设置左右指针,指向待插入元素可能的插入位置的最小最大处,覆盖all situation
2.求得mid指针,将数组分为左右2部分,part_left = mid左 + mid,part_right = mid右
3.判断target在左还是右,然后重新调整左右指针的位置形成新的查找区间。直到左右指针重合即为所求位置

summary:到这里,我们可以抛弃区间开闭论,而改为理解成:用左右指针去标记可能的上下界,让其相遇命中位置


例题:
leetcode33.查找旋转有序数组
https://leetcode-cn.com/problems/search-in-rotated-sorted-array
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的最小索引,否则返回 -1。要求时间复杂度O(logn)
【相似的题目还有leetcode153, 154】

思路:
时间复杂度O(logn),肯定是要分治,上二分查找
二分查找?这里不是完全有序可以吗?可以啊。这里一定要理解到位,二分查找并不要求整个数组完全有序,【而是要求每一次二分后可以定位target到底在左侧还是右侧,只要能定位target在哪部分就可以】。而整个数组有序是见的最多的方便定位target的场景
这里我们同样能够二分后定位target,只不过要多一点判断

注意一下,按题目要求,如果数组中存在这个目标值,则返回它的最小索引,否则返回 -1,因此right指针初始时应该指向index = len(nums)-1

解法1:比较mid和left指向元素的大小,来确认旋转数组的有序部分

class Solution:
    def search(self, nums: List[int], target: int) -> int:
        return self.biSearch(nums, 0, len(nums)-1, target)


    def biSearch(self, nums, left, right, target):
        if not nums:
            return -1
        
        while left < right:
            mid = left + (right - left) // 2
            # 左侧有序,注意:通过mid和left的大小关系来判断左侧有序,需要囊括等号情况,因为2个元素时,左偏的mid与left重合
            if nums[mid] >= nums[left]:
                # 如果target在左侧,搜左侧
                if nums[left] <= target <= nums[mid]:
                    right = mid
                else:
                    left = mid + 1
            # 右侧有序
            else:
                # 在右侧搜
                if nums[right] >= target > nums[mid]:
                    left = mid + 1                   
                else:
                    right = mid
        # left对应target在数组中的插入位置
        if nums[left] != target:
            return -1
        
        return left

解法2:比较mid和right指向元素的大小,来确定旋转数组的有序部分

class Solution:
    def search(self, nums: List[int], target: int) -> int:
        if not nums:
            return -1
        
        l, r = 0, len(nums)-1
        while l < r:
            mid = l + (r-l) // 2
            if nums[mid] < nums[r]:
                if nums[mid] < target <= nums[r]:
                    l = mid + 1
                else:
                    r = mid
            else:
                if nums[l] <= target <= nums[mid]:
                    r = mid
                else:
                    l = mid + 1
        if nums[l] == target:
            return l
        return -1

leetcode 162. 寻找峰值
峰值元素是指其值大于左右相邻值的元素。
给定一个输入数组 nums,其中 nums[i] ≠ nums[i+1],找到峰值元素并返回其索引。nums可以存在多个峰值,返回其中一个的index即可,你可以假设 nums[-1] = nums[n] = -∞。要求时间复杂度O(logN)

思路:
看到时间复杂度O(logN),不用想肯定是分治,二分查找。
根据写二分查找的3个步骤,
1.确定可能的index的上下界,显然有left = 0,right = len(nums) - 1
2.求出mid,然后将区间划分为2部分,mid根据需要划归入左区间或者右区间
3.确定是要往左区间搜索,还是往右区间搜索
规律一:如果nums[i] > nums[i+1],则在i之前(包含i)一定存在峰值元素
规律二:如果nums[i] < nums[i+1],则在i+1之后(包含i+1)一定存在峰值元素
然后调整left或right指针,直到left == right

class Solution:
    def findPeakElement(self, nums: List[int]) -> int:
        if not nums:
            return -1
        new_list = [-float('inf')] + nums + [-float('inf')]
        return self.biSearch(new_list, 0, len(new_list)-1) - 1

    def biSearch(self, nums, left ,right):    
        while left < right:
            mid = left + (right - left) // 2
            # 注意,mid - 1不一定在当前的left和right范围内,但mid+1一定在,所以用mid和mid+1做比较
            if nums[mid] > nums[mid + 1]:
                right = mid
            elif nums[mid] < nums[mid + 1]:
                left = mid + 1
            
        return left

在峰值上我们再上点难度

leetcode1095. 山脉数组中查找目标值

虽然难度是hard,但是在掌握了二分查找的模板下,就是easy,一次ac了
思路:
先利用二分查找,找到MountainArray的峰值
找到峰值后,在左侧上升区间找一次target,在右侧下降区间也找一次target,比较2个查找结果即可

下面额外定义了3个findxxx方法,用于找峰值点,增区间找target和减区间找target,都是基于相遇指针的二分查找算法,结构一样,大同小异

# """
# This is MountainArray's API interface.
# You should not implement it, or speculate about its implementation
# """
#class MountainArray:
#    def get(self, index: int) -> int:
#    def length(self) -> int:

class Solution:
    def findInMountainArray(self, target: int, mountain_arr: 'MountainArray') -> int:
        peak_index = self.findPeak(mountain_arr)
        res1 = self.findInLeft(target, mountain_arr, peak_index)
        res2 = self.findInRight(target, mountain_arr, peak_index)
        if res1 != -1:
            return res1
        if res2 != -1:
            return res2
        return -1

        
        # 找峰值点
    def findPeak(self, mountain_arr: 'MountainArray'):
        right = mountain_arr.length() - 1
        left = 0
        while left < right:
            mid = left + (right - left) // 2
            a, b = mountain_arr.get(mid), mountain_arr.get(mid+1)
            if a < b:
                left = mid + 1
            elif a > b:
                right = mid
        return left

        # 在峰值左边找
    def findInLeft(self, target: int, mountain_arr: 'MountainArray', peak_index):
        right = peak_index
        left = 0
        while left < right:
            mid = left + (right - left) // 2
            a, b = mountain_arr.get(mid), mountain_arr.get(mid+1)
            if a < b:
                if target <= a:
                    right = mid
                else:
                    left = mid + 1
            elif a > b:
                if target < a:
                    left = mid + 1
                else:
                    right = mid
        if target == mountain_arr.get(left):
            return left
        return -1

        # 在峰值右边找
    def findInRight(self, target: int, mountain_arr: 'MountainArray', peak_index):
        right = mountain_arr.length() - 1
        left = peak_index
        while left < right:
            mid = left + (right - left) // 2
            a, b = mountain_arr.get(mid), mountain_arr.get(mid+1)
            if a < b:
                if target <= a:
                    right = mid
                else:
                    left = mid + 1
            elif a > b:
                if target < a:
                    left = mid + 1
                else:
                    right = mid
        if target == mountain_arr.get(left):
            return left
        return -1
    

再看一道更加隐晦一点的二分查找

1011. 在 D 天内送达包裹的能力

思路:根据题意,结果一定落在【max(weights), sum(weights)】这个区间之间,因为左端点对应每天一条船,右端点对应只有一条超级大船。 然后利用二分法,每一次循环模拟运货的过程,然后根据需要的天数与 输入 D 的关系来调整区间左右端点。

class Solution(object):
    def shipWithinDays(self, weights, D):
        """
        :type weights: List[int]
        :type D: int
        :rtype: int
        """
        lo, hi = max(weights), sum(weights)
        while(lo < hi):
            mid = (lo + hi) // 2 # mid 即为当前运送的capacity
            
            #------以下为模拟运货的过程,temp表示当天这条船承载的重量,day表示已用的天数-------
            temp = 0
            day = 1
            for weight in weights:
                temp += weight
                if temp > mid:# 当前货运不动 需要新的一艘船才行
                    day += 1
                    temp = weight
            #------以上为模拟运货的过程-----------------
            
            if day > D: # 当前的capacity太小了,不够,需要更大容量才能及时运完
                lo = mid + 1
            elif day <= D:
                hi = mid

        return lo

基于相遇指针的二分查找模板(本文核心)

    def biSearch(nums, left, right, target):
        while left < right:
            # mid指针
            mid = left + (right - left) // 2
            
            # 二分为左右两侧
            # 判断要去左侧搜还是去右侧搜,然后调整指针
            # 如果在右侧
            if target in right side:
                left = mid + 1
            # 如果在左侧
            else:
                right = mid
                
        return left

注意,只能使用left = mid + 1和right = mid,而不能使用left = mid和right = mid - 1。这是因为当数组长度为2时,如[3,5],left = 0, mid = 0, right = 1,使用right = mid - 1的话,right就变成-1了,left/right也就不能相遇

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容