二分查找基础概念与经典题目(Leetcode题解-Python语言)二分索引型

二分查找的定义如下(引自Wiki):

在计算机科学中,二分查找算法(英语:binary search algorithm),也称折半搜索算法(英语:half-interval search algorithm)、对数搜索算法(英语:logarithmic search algorithm),是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。

二分查找算法在最坏情况下是对数时间复杂度的,需要进行 O(logn) 次比较操作(n在此处是数组的元素数量,O是大O记号,log 是对数)。二分查找算法使用常数空间,对于任何大小的输入数据,算法使用的空间都是一样的。除非输入数据数量很少,否则二分查找算法比线性搜索更快,但数组必须事先被排序。尽管一些特定的、为了快速搜索而设计的数据结构更有效(比如哈希表),二分查找算法应用面更广。

总结一句,由于二分必须在有序数组中进行,看到题目条件有有序数组的话就应该想到二分查找。

Leetbook上有关于二分查找的内容,但还是局限在多个模板套用上,且题目与知识点对应不上。更推荐的是这篇文章,真正做到了理解核心而不是套用模板。

二分查找中使用的术语:

目标 Target —— 你要查找的值
索引 Index —— 你要查找的当前位置
左、右指示符 Left,Right —— 我们用来维持查找空间的指标
中间指示符 Mid —— 我们用来应用条件来确定我们应该向左查找还是向右查找的索引

下面我们结合题目来解析二分查找的思路:

704. 二分查找

分法一:

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

        return -1

分法二:

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

        return -1

首先是左右指示符的设置,left = 0right = len(nums) - 1 基本上每题开头都是把整个区间作为我们想进行二分查找的区间,当然也有例外,后面会看到。

然后就是求中间指示符 mid 的环节,mid = (left + right) // 2 ,这里更推荐 mid = left + (right - left) // 2 的写法因为要防止 left + right 整形溢出。同时我们要注意到,// 2 相当于是向下取整的,如果是希望向上取整则写成mid = (left + right + 1) // 2 或者 mid = left + (right - left + 1) // 2 。向下取整时 mid 就会被分到左边,向上取整时 mid 就会被分到右边。

紧接着就是二分的核心部分,区间的划分了。很多模板会把区间划分为等于 target、大于 target 和小于 target 三个区间,实际上是有点绕了。此处我们统一每次只划分两个区间,可能存在目标元素的区间和一定不存在目标元素的区间,那么可能存在目标元素的区间要么在左边要么在右边,两种可能

又根据 mid 是被划分在左边的区间还是右边的区间,得到两种分法。因此共有4种情况,如下图所示:

在这里插入图片描述
分法一(默认) mid = (left + right) // 2
第一种情况:mid 在左边区间,目标元素在右边区间,nums[mid] < target, 则 left = mid + 1
第二种情况:mid 在左边区间,目标元素在左边区间,nums[mid] >= target, 则 right = mid

分法二 mid = (left + right + 1) // 2
第三种情况:mid 在右边区间,目标元素在右边区间,nums[mid] <= target,则 left = mid
第四种情况:mid 在右边区间,目标元素在左边区间,nums[mid] > target,则 right= mid - 1

其中第一种和第二种情况一定同时出现(分法一),第三种和第四种情况一定同时出现(分法二)。然后,我们来考虑下如果只剩下两个元素的情形,如下图所示:
在这里插入图片描述
如果是分法一,即left = mid + 1right = mid,此时 mid 必须等于 left,即向下取整,下一步才会有 left = mid + 1 = right 或者 right = mid = left,得到 left == right 从而退出循环。

如果是分法二,即left = midright = mid - 1,此时 mid 必须等于 right,即向上取整,下一步才会有 right = mid - 1 = left 或者 left = mid = right,得到 left == right 从而退出循环。

他们的共同点是,最后退出循环时 left 一定等于右边的那个元素(mid + 1)

最后,可知退出循环后一定有 left == right,如果 left (或者 right,一样的)满足条件(例如 nums[left] == target),则返回 left(或者right)。

35. 搜索插入位置

class Solution:
    def searchInsert(self, nums: List[int], target: int) -> int:
    	# 特殊情况
        if nums[-1] < target:
            return len(nums)
        
        left = 0
        right = len(nums)- 1
        
        while left < right:
            mid = left + (right - left) // 2
            if nums[mid] < target:
                left = mid + 1
            else:
                right = mid
        
        return left

本题与704题基本一样,区别只是不要求找到一样的元素,而是要找到第一个大于等于 target 的元素索引。此处使用的还是分法一,向下取整 mid = left + (right - left) // 2,mid 一定在左边的区间,if nums[mid] < target即左边的区间小于 target,那么第一个大于等于 target 的元素一定在右边的区间,因此到右边的区间去寻找元素, left = mid + 1。循环结束之后,一定有 left == right,由于它们的区间 [left, right] 一定有第一个大于等于 target 的元素,所以最后区间只有一个元素,它的索引 left 即为所求。

162. 寻找峰值

class Solution:
    def findPeakElement(self, nums: List[int]) -> int:
        length = len(nums)
        left, right = 0, length - 1

        while left < right:
            mid = left + (right - left) // 2
            if nums[mid] < nums[mid + 1]:
                left = mid + 1
            else:
                right = mid
        
        return left

找到大于左右相邻元素的值,若 nums[mid] < nums[mid + 1],则目标区间在右边,剩下两个元素时,mid向下取整等于left,可以取到更大值 left = mid + 1 = right

34. 在排序数组中查找元素的第一个和最后一个位置

class Solution:
    def searchRange(self, nums: List[int], target: int) -> List[int]:
        length = len(nums)
        # 特殊情况
        if (not nums) or nums[-1] < target or nums[0] > target or length == 0:
            return [-1, -1]
        
        # 找左边界
        left1, right1 = 0, length - 1
        while left1 < right1:
            mid1 = left1 + (right1 - left1) // 2 # 向下取整
            if nums[mid1] < target:
                left1 = mid1 + 1
            else:
                right1 = mid1
        
        # 数组中不存在target
        if nums[left1] != target:
            return [-1, -1]

        # 找右边界
        left2, right2 = left1, length - 1  # 此处优化了,找右边界的过程从left1到length - 1的区间中找
        while left2 < right2:
            mid2 = left2 + (right2 - left2 + 1) // 2 # 向上取整
            if nums[mid2] > target:
                right2 = mid2 - 1
            else:
                left2 = mid2
        
        return [left1, right2]

本题可以看作是704题的高阶版,数组中的元素是可能重复的,然后要找 target 在数组出现的第一个位置和最后一个位置。

在找第一个位置的时候,可以借助35题的思路,什么样的位置是第一次出现的位置呢?那就是第一个大于等于 target 的元素位置。还是用的分法一,判断条件是 if nums[mid1] < target,如果 mid1 小于 target 即左边的区间小于 target,所以右边的区间大于等于 target,到右边区间继续找left1 = mid1 + 1。循环结束后由于题目是要求 target 出现,所以判断 nums[left1] 与 target 是否相等,相等才继续。

然后找最后一个位置,显然,这相当于找第一个小于等于 target 的元素位置,用分法一,判断条件为 if nums[mid2] > target,如果 mid2 大于 target 即左边的区间大于 target,所以右边的区间小于等于 target,等等,顺序不对???左边的区间大于 target,左边的区间又小于右边的区间,怎么可能右边的区间小于等于 target 呢?因此,我们要改用分法二,向上取整,把 mid2 归到右边的区间,判断条件还是 if nums[mid2] > target,如果 mid2 大于 target 即右边的区间大于 target,所以左边的区间小于等于 target,到左边区间继续找right2 = mid2 - 1。能进行到这里说明 target 肯定会出现了,所以不用判断 nums[right2 ] 与 target 是否相等,直接返回答案。

33. 搜索旋转排序数组

class Solution:
    def search(self, nums: List[int], target: int) -> int:
        length = len(nums)
        if not nums:
            return -1
        left, right = 0, length - 1

        while left < right:
            mid = left + (right- left) // 2 # 分法一,mid在左边区间,向下取整
            if nums[mid] < nums[right]: # mid所在位置元素小于最右边元素,说明右边区间有序
                if nums[mid] < target <= nums[right]: # 如果target在右边区间
                    left = mid + 1
                else: # 否则在左边区间
                    right = mid
            else: # mid所在位置元素大于(不会等于)最右边元素,说明左边区间有序
                if nums[left] <= target <= nums[mid]: # 如果target在左边区间(mid也在左边区间,可能等于target)
                    right = mid
                else: # 否则在右边区间
                    left = mid + 1

        if nums[left] == target: # 等于目标值
            return left
        else: # 不存在目标值
            return -1

这题的数组是循环有序,对于 mid 来说,要么是 mid 所在的左边区间(分法一)有序,要么是右边区间有序,所以首先要判断哪个区间有序,再到有序区间进行 target 的寻找(因为 mid 与 target 的比较一定是在有序区间进行的)。

右边区间有序,判断条件是 if nums[mid] < target <= nums[right] ,第一个取小于号是因为 mid 在左边区间,一定小于在右边区间的 target,而第二个取小于等于号是因为 target 可能是最右边的元素。

左边区间有序,判断条件是 if nums[left] <= target <= nums[mid],同理,target 和 mid 都在左边区间,都可能等于最左边的元素。

81. 搜索旋转排序数组 II

分法一:

class Solution:
    def search(self, nums: List[int], target: int) -> bool:
        length = len(nums)
        left, right = 0, length - 1

        while left < right:
            mid = left + (right - left) // 2
            if nums[mid] < nums[right]: # 右边区间一定有序
                if nums[mid] < target <= nums[right]:
                    left = mid + 1
                else:
                    right = mid
            elif nums[mid] > nums[right]: # 左边区间一定有序(旋转点在右边区间)
                if nums[left] <= target <= nums[mid]:
                    right = mid
                else:
                    left = mid + 1
            else: # 无法判断是否有序,例如[3, 1, 2, 3, 3, 3, 3]
                if nums[right] == target:
                    return True
                else:
                    right -= 1
            
        return nums[left] == target

分法二:

class Solution:
    def search(self, nums: List[int], target: int) -> bool:
        length = len(nums)
        left, right = 0, length - 1

        while left < right:
            mid = left + (right - left + 1) // 2
            if nums[mid] < nums[right]: # 右边区间一定有序
                if nums[mid] <= target <= nums[right]:
                    left = mid
                else:
                    right = mid - 1
            elif nums[mid] > nums[right]: # 左边区间一定有序(旋转点在右边区间)
                if nums[left] <= target < nums[mid]:
                    right = mid - 1
                else:
                    left = mid
            else: # 无法判断是否有序,例如[3, 1, 2, 3, 3, 3, 3]
                if nums[right] == target:
                    return True
                else:
                    right -= 1
            
        return nums[left] == target

作为33题的进阶版,这道题难在数组中的元素是可能相同的,如果出现 nums[mid] == nums[right] 的情况,无法判断左边区间还是右边区间是有序的。解决方法就是对于这种情况,每次缩减 right - 1 即右边界左移一位,直到可以判断左右区间哪个有序为止。

题解既有分法一也有分法二,他们的核心区别是分法一把 mid 归到左边区间,分法二把 mid 归到右边区间。由此导致了向下与向上取整的不同、寻找目标区间的左右边界更新位置不同、以及 mid 与左右区间元素的大小关系不同。

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

class Solution:
    def findMin(self, nums: List[int]) -> int:
        length = len(nums)
        left, right = 0, length - 1

        while left < right:
            mid = left + (right - left) // 2
            if nums[mid] < nums[right]: # 右边区间有序,拐点一定在左边区间
                right = mid
            else: # 右边区间无序,拐点一定在右边区间
                left = mid + 1
        
        return nums[left]

本题由于没有 target,甚至比33题还要简单,只需要不断地找无序的区间(同时也是拐点所在的区间)即可,由剩余两个元素时的情况可以知道,退出循环时必然 left 等于右边的元素,即拐点的右边(最小值)。

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

class Solution:
    def findMin(self, nums: List[int]) -> int:
        length = len(nums)
        left, right = 0, length - 1

        while left < right:
            mid = left + (right - left) // 2
            if nums[mid] < nums[right]: # 右边区间有序,拐点一定在左边区间
                right = mid
            elif nums[mid] > nums[right]: # 右边区间无序,拐点一定在右边区间
                left = mid + 1
            else: # mid与右边界相等,无法判断,只能缩小范围
                right -= 1
        
        return nums[left]

本题是153题的进阶版,与81题类似,就是多了元素可能重复这个条件。由于存在无法判断是否有序的情况,所以要单独讨论,出现这种情况时就缩小范围 right -= 1,其余情况还是正常找拐点所在区间。

658. 找到 K 个最接近的元素

class Solution:
    def findClosestElements(self, arr: List[int], k: int, x: int) -> List[int]:
        n = len(arr)
        # 最小的起点为0,最大的起点为n-k,这样才能保证选取长度为k的连续子数组
        low, high = 0, n - k # 框长度为k,所以起点范围[0, n-k]
        while low < high:
            mid = (low + high) // 2
            if x - arr[mid] <= arr[mid + k] - x:   # x更靠近左边的元素,我们的框应该往左边找
                high = mid
            else: # x更靠近右边的元素,我们的框应该往右边找
                low = mid + 1

        return arr[low: low + k]

这题虽然代码很基本,但是思路不容易。找到 k 个与 x 最接近的数,可以把这 k 个数看作是一个长度为 k 的框,则框的左起点的范围是 [0, n-k]。然后二分查找这个左起点,若 x 与目前左起点 arr[mid] 的距离小于等于 x 与右起点 arr[mid + k] 的距离,if x - arr[mid] <= arr[mid + k] - x:,则框应该向左移,即左起点的取值范围从右边缩小, high = mid,反之从左边缩小,最后得到最接近 x 的 k 个数(框)。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值