吃透二分查找—— LeetCode 第 33、34、35 题记
昨天没能完成 34,今天来补上。恰好第 35 题也是二分查找算法的应用,放到一起来记录。
首先看下二分查找算法的概念:
❝二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。百度百科:二分查找
❞
使用的题目中,一般会提到要求时间复杂度为 O(log n) 级别,以及涉及到的列表、数组是有序排列的。结合今天要记的三道题,我们来练习下这种解法的应用。在难度上,第 35 题简单,33、34 是中等难度,我们先看简单的。
题目一
「第 35 题:搜索插入位置」
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
你可以假设数组中无重复元素。
示例 1:输入: [1,3,5,6], 5输出: 2示例 2:输入: [1,3,5,6], 2输出: 1示例 3:输入: [1,3,5,6], 7输出: 4示例 4:输入: [1,3,5,6], 0输出: 0#来源:力扣(LeetCode)#链接:https://leetcode-cn.com/problems/search-insert-position
题目分析
要在排序数组中找目标值的位置,很典型的二分查找的应用场景。当目标不存在时,返回目标应该被插入的位置。这个我们先把特殊情况择出来:列表长度不到 2 的情况,目标值大小超出列表值范围情况。正常二分查找的操作类似双指针法,定义一前一后两个索引,每次取其中间位置的值来进行判断,直到最终定位出结果。
比如示例 1,我们分别先取第一位 1 和最后一位 6 作为前后位置,此时中间位置为第 2 位 3,3 小于目标值,所以我们把范围缩小到右半部;缩小范围后,第 2 位 3 成了左侧边界,最后一位 6 仍是右边界,此时中间位置为 第 3 位 恰好为 5 目标值,所以返回第 3 位的坐标 2,任务完成。
这题要注意的点是,若存在目标值,返回其坐标;若不存在,要返回目标应该插入的位置。
代码实现
看二分法,通常都会纠结于比较完中点值后,对之后左右边界如何划分,究竟取 mid、mid-1 还是 mid+1 作为新的坐标,这个要具体来分析。
class Solution:def searchInsert(self, nums: List[int], target: int) -> int:# 空列表情况if nums==[]:return 0length = len(nums)# 只有一个数的列表情况if length<2:if nums[0]>=target:return 0else:return 1# 目标值不在列表值范围内情况if target<=nums[0]:return 0if target> nums[-1]:return length# 二分查找法开始# 定义左右边界位置,最左端和最右端索引l,r = 0,length-1# while 循环控制 l 和 r 来缩小范围while l<r:# 取中点,这里的取值会偏左,比如第 1、2 位取的中点是第 1 位mid = (l+r)//2# 如果中点处值为目标,直接返回中点索引if nums[mid]==target:return mid# 若中点值大于目标,目标在左边部分,此时将右边界变为 mid# 这里不取 mid-1 的原因:取中点是偏左的,是无法取到右边界处的;如果 mid-1 处恰好为目标值,将其定为右边界就会导致中点无法取到该位置,故右边界我们取 midelif nums[mid]>target:r = mid# 左边界可以跳过 mid 取 mid+1 原因在于,取中点可以取到左边界else:l = mid+1# 无论是否存在目标,经历过循环后,l 为目标的位置索引return l
提交代码测试:
执行用时 : 40 ms, 在所有 Python3 提交中击败了 79.75% 的用户内存消耗 : 14.4 MB, 在所有 Python3 提交中击败了 7.14% 的用户
当然也有其它取巧的方法,我们先忽略,主要练习这个二分查找,继续看下一道
题目二
「第 33 题:搜索旋转排序数组」
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
你可以假设数组中不存在重复的元素。
你的算法时间复杂度必须是 O(log n) 级别。
示例 1:输入: nums = [4,5,6,7,0,1,2], target = 0输出: 4示例 2:输入: nums = [4,5,6,7,0,1,2], target = 3输出: -1
题目分析
如果暴力遍历的话,时间复杂度是 O(n) 级别,因为需要对 n 个元素一一处理。若使用二分查找,时间复杂度就变为 log2 n 了,因为每次都是对半,比如有 8 个数、我们 3 次对半分便能完成定位。
此题目中提到原本排好序的列表,被调整了一次。看似不太符合二分查找时对排序列表的要求,但即使列表被调整,当我们对半分时,总有一半是完全排序的,我们依据这半部分来分析同样可以完成任务。比如示例 1 ,在列表中找目标 0,如下图:
我们先取中点,值为 7,此时左边正常排序,右边顺序有变。此时,判断目标不在正常排序的左边,所以将边界调整,直接取右半部分。
接下来再取中点,值为 0,此时右边正常排序,但中点的值已经是目标值了,任务结束,返回此刻中点左边即可。
类似地,我们每次取中点,找两边正常排序的部分,看目标是否位于该部分,然后调整边界缩小范围至一半,直至最终定位。
代码实现
在这段代码中,为了不纠结缩小范围换边界时究竟选用 mid 还是 mid+1、mid-1,我就单独把边界处可能取到目标值的情况也给做了处理,一旦检测到目标值,直接返回。
class Solution:def search(self, nums: List[int], target: int) -> int:length = len(nums)# 对空列表和只有一个数的列表特殊处理if length<2:if nums and nums[0]==target:return 0else:return -1# l 左边界,r 右边界l,r = 0,length-1# l 与 r 相等时结束循环while l<r:# 取中值mid = (r+l)//2# 如果中值处为目标值,直接返回if nums[mid]==target:return mid# 如果边界处为目标值,也直接返回if nums[l]==target:return lif nums[r]==target:return r# 如果左边界小于中点的值,说明左部是正常排序if nums[l]<nums[mid]:# 若此时目标值在左部,将右边界缩到 mid-1if nums[l]<target and target < nums[mid]:r = mid-1# 否则调整左边界到 mid+1else:l = mid+1# 右部正常排序的情况else:# 若目标值位于右部,调整左边界到 mid+1if nums[mid]<target and target < nums[r]:l = mid+1# 否则,调整右边界else:r = mid-1# 若上述过程循环完仍未取到目标值,说明没有,返回 -1return -1
提交测试表现:
执行用时 : 32 ms, 在所有 Python3 提交中击败了 97.01% 的用户内存消耗 : 13.9 MB, 在所有 Python3 提交中击败了 7.69% 的用户
表现是挺亮眼的,接下来,会一会最费时间的题目。
题目三
「第 34 题:在排序数组中查找元素的第一个和最后一个位置」
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回 [-1, -1]。
示例 1:输入: nums = [5,7,7,8,8,10], target = 8输出: [3,4]示例 2:输入: nums = [5,7,7,8,8,10], target = 6输出: [-1,-1]
题目分析
这道题很奇怪,你说它能用二分查找法吧,应该是能用的:排序列表,查找元素位置。但是要查目标值出现的起始和结束两个位置,这个要怎么做?
同时找两个位置肯定不好找,我们需要分步来:先用二分法查找起始位置。查完后,再从起始位置到列表结束,继续用二分法查找结束位置。
比如示例 1,先通过二分法定位到目标 8 的起点;再从该起点开始继续二分查找终点位置:与之前不同的点在于,找起点位置过程中,即使取到的中点值与目标值相等,我们仍然要取左侧部分继续分析,因为我们要找的目标值的起点;同理,找结束位置时,即使取到的中点值与目标相等,我们仍要取右侧部分继续分析。
代码实现
class Solution:def searchRange(self, nums: List[int], target: int) -> List[int]:length = len(nums)# 特殊情况处理if length<2:# 单数列表,且该数与目标值相等if nums and nums[0]==target:return [0,0]# 不匹配的单数列表、空列表else:return [-1,-1]# 目标值超出列表值范围情况if target<nums[0] or target>nums[-1]:return [-1,-1]# 先定义好默认值left,right = -1,-1# 起始位置的二分查找开始l,r = 0,length-1# 老样子,while 循环while l<r:# 取中点mid = (l+r)//2# 若中点小于目标值,调整左边界至 mid+1# 因为取中点偏左边界,所以可以不取 mid 直接下一位 mid+1,即使 mid+1 真就是起始位置,也可以通过取中点取到该处if nums[mid]<target:l = mid+1# 若中点值大于等于目标,因为我们要找目标的起点,所以把右边界调整到 m# 不取 mid-1 的原因在于,若 mid-1 为起点,把其作为右边界的话,无法通过取中点定位到该位置elif nums[mid]>=target:r = mid# while 循环结束时,l 即起点位置,这时要检查下列表中该位置是否存在目标值# 若列表该位置确实存在目标值,更新起始位置到 leftif nums[l]==target:left = l# 若不存在,列表中没有目标值,返回 -1 相关结果else:return [-1,-1]# 接下来找结束位置# 若此时已经是最后一位,那么结束位置与起始位置相同,直接返回if l+1==length:return [left,left]# 若后续还有其它位,重新开启二分查找# 取起始位置下一位开始作为边界l,r = l+1,length-1while l<r:mid = (l+r)//2# 如果中点值大于目标,更新右边界为 midif nums[mid]>target:r = mid# 如果中点值小于等于目标,将左边界更新elif nums[mid]<=target:l = mid+1# 当while循环结束时,有可能 l 是结束位置,也可能是结束位置的下一位# 若为结束位置if nums[l]==target:right = l# 若其上一位是结束位置elif nums[l-1]==target:right = l-1# 将更新完毕的位置返回return[left,right]
最后求结束位置时还分情况讨论了下,按照我们区分边界的分析,最终求出的左边 l 应该是结束位置的下一位,结束位置应该是 l-1;但是若该列表以重复出现的目标值作为最后元素,比如 [1,2,2] 目标值为 2,又因为右边界的值无法通过中点取到,所以最终拿到的结束位置 l = 2,恰好也是目标值,所以也需要对这种情况下做一个判断处理。
提交测试表现:
执行用时 : 40 ms, 在所有 Python3 提交中击败了 81.86% 的用户内存消耗 : 14.6 MB, 在所有 Python3 提交中击败了 7.69% 的用户
结论
经过三道题目两天的练习,就基本可以掌握二分查找的用法了。该算法麻烦的点在于取完中点值后对下一半部左右边界的取值,以及配合题意变换做一些特殊情况考虑处理。
乍一看会觉得一团糟,但理清思路后,便可以一步步完成代码了。当然,还是会有些问题,比如 34 题求最后结束位置时对不同情况的特殊处理,这个一般还挺难考虑到,得通过提交测试时返回的特殊例子来检查出漏洞予以修复。
今天 10:30-12:00,试着参与了下 LeetCode 周赛,4 道题只做出前两道,排名很惨烈。对于题目解法的储备不足,没有头绪是最浪费时间的,之后还要多加练习~
