吃透二分查找—— 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 0
length = len(nums)
# 只有一个数的列表情况
if length<2:
if nums[0]>=target:
return 0
else:
return 1
# 目标值不在列表值范围内情况
if target<=nums[0]:
return 0
if 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 处恰好为目标值,将其定为右边界就会导致中点无法取到该位置,故右边界我们取 mid
elif 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 0
else:
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 l
if nums[r]==target:
return r
# 如果左边界小于中点的值,说明左部是正常排序
if nums[l]<nums[mid]:
# 若此时目标值在左部,将右边界缩到 mid-1
if nums[l]<target and target < nums[mid]:
r = mid-1
# 否则调整左边界到 mid+1
else:
l = mid+1
# 右部正常排序的情况
else:
# 若目标值位于右部,调整左边界到 mid+1
if nums[mid]<target and target < nums[r]:
l = mid+1
# 否则,调整右边界
else:
r = mid-1
# 若上述过程循环完仍未取到目标值,说明没有,返回 -1
return -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 即起点位置,这时要检查下列表中该位置是否存在目标值
# 若列表该位置确实存在目标值,更新起始位置到 left
if nums[l]==target:
left = l
# 若不存在,列表中没有目标值,返回 -1 相关结果
else:
return [-1,-1]
# 接下来找结束位置
# 若此时已经是最后一位,那么结束位置与起始位置相同,直接返回
if l+1==length:
return [left,left]
# 若后续还有其它位,重新开启二分查找
# 取起始位置下一位开始作为边界
l,r = l+1,length-1
while l<r:
mid = (l+r)//2
# 如果中点值大于目标,更新右边界为 mid
if 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 道题只做出前两道,排名很惨烈。对于题目解法的储备不足,没有头绪是最浪费时间的,之后还要多加练习~