实际写过二分查找的人应该都有体会到细节是魔鬼,连计算机领域的泰斗都说过:Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky。
目前刷题时遇到好几个二分查找的题,一开始是处处碰壁。看大神的题解和博客找到一个比较好的模板,可以解决常见的二分查找问题。趁着这个机会自己再梳理总结下,免得以后再采坑。
参考资料:特别好用的二分查找法模板(第 2 版)
模板说明
核心思想:把待搜索的目标元素放在最后判断,每一次循环排除掉不存在目标元素的区间,目的依然是确定下一轮搜索的区间。
step1:确定左右边界,即left和right的初始值。
step2:查找中通常只写两个分支(排除中位数和不排除中位数),并且先写容易识别的分支(通常是排除中位数的分支)。
step3:根据分支逻辑确定mid为左中位数((left+right)//2)还是右中位数((left+right+1)//2),依据是确保不陷入死循环。
step4:退出查询循环时left=right,根据具体问题确定后处理步骤。
二分查找题目汇总
正用-直接套模板
旋转数组的
变形用-分支比较的目标值取数组值
变形用-分支比较的目标值计算而得
变形用-待描述
正用-直接套模板
704. 二分查找
题目描述:
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
题解:
easy级别,直接套用模板即可。注意while循环的判断条件是left<right,这保证在结束循环时left等于right,简化后处理,不需要再做过多的逻辑判断。
class Solution:
def search(self, nums: List[int], target: int) -> int:
#step1:确定左右边界
#左右边界一般固定为0和len(nums)
left, right = 0, len(nums)
while left<right: #二分查找的固定语句
#step3:根据分支确定mid取左中位数还是右中位数,依据是不陷入死循环
mid = (left+right)>>1
#step2:只写两个分支,并且先写容易识别的分支
#当nums[mid]比目标值小时,可以确定target不在[left, mid]间, 因此left=mid+1
if nums[mid]<target:
#print(left, right)
left = mid+1
else:
#print(left, right)
right = mid
#step4:退出循环时left=right, 根据实际情况做后处理
if left==len(nums) or nums[left]!=target:
return -1
else:
return left
35. 搜索插入位置
题目描述:
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
题解:
easy级别,直接套用模板即可。
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
# step1: 确认左右边界
# 由于本题的结果有可能是len(nums), 所以右边界必须取len(nums)
left, right = 0, len(nums)
while left<right:
# step3:确定位数据取值
mid = (left+right)>>1 #左中位数
# step2:只写两个分支,并且先写容易识别的分支
# 由于当target不存在时, 需要返回其应该插入的位置
# 因此只有在nums[mid]<target, 能确定target不能插入[left,mid]上
if nums[mid]<target:
left = mid + 1
else:
right = mid
# step4: 不需要后处理, 直接返回索引即可
return left
34. 在排序数组中查找元素的第一个和最后一个位置
题目描述:
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值,返回 [-1, -1]。
题解:
题目的要求等价于求target在有序数组中的左右边界。
左边界的求解要求只有在很明确的情况下才移动左边界,即在nums[mid]<target时可以确定左边界不在[left,mid]中(对应left=mid+1);
右边界的求解要求只有在很明确的情况下才移动右边界,即在nums[mid]>target时可以确定右边界不在[mid,right](对right=mid-1)。
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
def leftEdge(nums, target):
if len(nums)==0:
return -1
left = 0
right = len(nums)-1
while left<right:
mid = left+(right-left)//2
if nums[mid]<target:
left = mid+1 #只有在很明确的条件下才做-1+1移位
else:
right = mid
return left if nums[left]==target else -1
def rightEdge(nums, right):
if len(nums)==0:
return -1
left = 0
right = len(nums)-1
while left<right:
mid = left+(right-left+1)//2 #取右中位数,免得如下陷入死循环
if nums[mid]>target:
right = mid-1 #只有在很明确的条件下才做-1+1移位
else:
left = mid
return left if nums[left]==target else -1
return [leftEdge(nums, target), rightEdge(nums, target)]
旋转数组使用二分查询
33. 搜索旋转排序数组
题目描述:
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] 。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
假设数组中不存在重复的元素。
题解:
本题的难点在于分支的设定,由于是旋转数组,两个分支不能完全包住,需要增加分支。但是整体思想依然是先写容易识别的分支,也就是排除中位数的分支,此时分两种场景。
case1:左分支有序且target不在左分支,即nums[left]<nums[mid]且nums[left]>target or nums[mid]<target此时left=mid+1。
case2:左分支存在旋转点且target不在左分支,即nums[left]>nums[mid]且nums[left]<target <nums[mid],此时left=mid+1。
otherwise:right=mid。
由于nums不存在重复数据,因此不需要考虑nums[left]==nums[mid]的场景。
class Solution:
def search(self, nums: List[int], target: int) -> int:
if len(nums)==0:
return -1
#step1:确定左右边界
#由于target可能不存在,因此right=len(nums)
left, right = 0, len(nums)-1
while left<right:
#step3:根据分支逻辑确定mid取左中位数还是右中位数,依据是不陷入死循环
mid = (left+right)>>1
#step2:只写两个分支,并且先写容易识别分支(例外)
#左分支有序,且target不在左分支的范围内
if nums[left]<nums[mid] and (nums[left]>target or nums[mid]<target):
left = mid+1
#左分支无序,且target不在左分支范围内
elif nums[left]>nums[mid] and (nums[mid]<target<nums[left]):
left = mid+1
else:
right = mid
#step4:退出循环时left=right,根据具体问题做后处理
if nums[left]!=target:
return -1
else:
return left
81. 搜索旋转排序数组 II
题目描述:
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
例如,数组 [0,0,1,2,2,5,6] 可能变为 [2,5,6,0,0,1,2] 。
编写一个函数来判断给定的目标值是否存在于数组中。若存在返回 true,否则返回 false。
题解:
这个题目和33题的区别在于本题中nums中可能存在重复数据。这导致分支判断中当nums[left]==nums[mid]时无法确定往那边移动,这时还需要再判断
case1:左分支有序且target不在左分支,即nums[left]<nums[mid]且nums[left]>target or nums[mid]<target此时left=mid+1。
case2:左分支存在旋转点且target不在左分支,即nums[left]>nums[mid]且nums[left]<target <nums[mid],此时left=mid+1。
case3:nums[left]==nums[mid]。
case3.1:nums[left]==target,则直接返回。
case3.2:否则只能确定left不是target,left右移一位。
otherwise:right=mid
class Solution:
def search(self, nums: List[int], target: int) -> bool:
if len(nums)==0:
return False
# step1:确定左右边界
left, right = 0, len(nums)-1
while left<right:
# step3:确定中位数是取左中位数还是右中位数
mid = (left+right)>>1
# step2:先确定容易识别的分支
# 本题选择什么条件下一定在右分支查询
# case1:左分支有序,且target不在左分支
if nums[left]<nums[mid] and (nums[left]>target or nums[mid]<target):
left = mid+1
# case2:左分支无序,且target不在左分支
elif nums[left]>nums[mid] and (nums[mid]<target<nums[left]):
left = mid+1
elif nums[left]==nums[mid]: #由于可能有重复数据,所以必须考虑该场景
if nums[left]==target:
return True
else:
left = left+1
else:
right = mid
return nums[left]==target
变形用-分支比较的目标值取数组值
162. 寻找峰值
题目描述:
峰值元素是指其值大于左右相邻值的元素。给定一个输入数组 nums,其中nums[i] ≠ nums[i+1],找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回任何一个峰值所在位置即可。你可以假设 nums[-1] = nums[n] = -∞。
题解:
我们对二分查找进行一点修改。首先从数组 nums 中找到中间的元素mid。若该元素恰好位于降序序列或者一个局部下降坡度中(通过将nums[i]与右侧比较判断),则说明峰值会在本元素的左边。于是,我们将搜索空间缩小为mid的左边(包括其本身),并在左侧子数组上重复上述过程。
若该元素恰好位于升序序列或者一个局部上升坡度中(通过将nums[i]与右侧比较判断),则说明峰值会在本元素的右边。于是,我们将搜索空间缩小为mid的右边,并在右侧子数组上重复上述过程。
就这样,我们不断地缩小搜索空间,直到搜索空间中只有一个元素,该元素即为峰值元素。
class Solution:
def findPeakElement(self, nums: List[int]) -> int:
# 二分查找, 判断mid和右侧数值大小
# 如果mid大, 说明峰值在左侧, right左移
# 如果mid小, 说明峰值在右侧, left右移
# step1, 确定左右边界
left, right = 0, len(nums)-1
while left<right:
# step3: 确定中位数是取左中位数还是右中位数
mid = (left+right)>>1
# step2: 先写容易识别的分支
if nums[mid]<nums[mid+1]:
left = mid+1
else:
right = mid
return left
变形用-分支比较的目标值取数组值
287. 寻找重复数
题目描述:
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
- 不能更改原数组(假设数组是只读的)。
- 只能使用额外的 O(1) 的空间。
- 时间复杂度小于 O(n2)
题解: 分支比较中目标值是统计数组中有多少数字小于当前的mid。
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
#nums的取值范围在1到n, 每次选1个数mid遍历nums找到小于该数的个数cnt
#如果cnt大于1半,说明重复数字比mid小
#否则说明重复数据比mid大
#可以用二分查找缩减搜索范围
# step1:确认左右边界
left, right = 1, len(nums)-1
while left<right:
# step3:确定中位数取左中位数还是右中位数
mid = (left+right)>>1
cnt = 0
for v in nums:
if v<=mid:
cnt += 1
# step2:
# 写分支, 通常写两个分支
# 先写容易识别的分支
if cnt>mid:
right = mid
else:
left = mid+1
return left
378.有序矩阵中第K小的元素
题目描述:给定一个 n x n 矩阵,其中每行和每列元素均按升序排序,找到矩阵中第 k 小的元素。
请注意,它是排序后的第 k 小元素,而不是第 k 个不同的元素。
题解:整个二维数组中 左上角为最小值l,右下角为最大值r,任取一个数 mid 满足 l<=mid<=r,那么矩阵中不大于 mid 的数,肯定全部分布在矩阵的左上角,统计其数量为cnt。
对于第k小的数,不妨假设答案为 x,那么可以知道 l<=x<=r,这样就确定了二分查找的上下界。
每次对于「猜测」的答案 mid,计算矩阵中有多少数不大于 mid:
-
如果数量不少于k,那么说明最终答案 x 不大于 mid;
-
如果数量少于k,那么说明最终答案 x 大于 mid。
这样我们就可以计算出最终的结果 x了。
class Solution:
def kthSmallest(self, matrix: List[List[int]], k: int) -> int:
# 统计矩阵中小于等于目标值的数字个数
def get_less_mid_count(matrix, target, n):
# 从左下角开始找
r,c = n-1,0
cnt = 0
while r>=0 and c<n:
# 如果当前元素小于等于目标值,则需要右移一列
if matrix[r][c]<=target:
cnt += r+1
c += 1
# 否则需要上移1个位置
else:
r -= 1
return cnt
# 二分查找, 目标值在最小的matrix[0][0]和最大的matrix[n-1][n-1]之间
n = len(matrix)
# step1:确定左右边界
left, right = matrix[0][0], matrix[n-1][n-1]
while left<right:
# step3:确定中位数取值
mid = (left+right)>>1
# 对本题的特殊处理
cnt = get_less_mid_count(matrix, mid, n)
# step2:写两个分支,先写容易识别的分支
if cnt<k:
left = mid+1
else:
right = mid
变形用-待描述
4. 寻找两个正序数组的中位数
题目描述:
给定两个大小为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。
请你找出这两个正序数组的中位数,并且要求算法的时间复杂度为 O(log(m + n))。
你可以假设 nums1 和 nums2 不会同时为空
题解:
由于要求时间复杂对为O(log(m + n)),首先考虑二分查找。两个数组的左中位数在合并数组的索引是是left_mid=(m+n)>>1,右中位数的索引是right_mid=(m+n+1)>>1。这个题目转换为求nums1和nums2中第k大的数。由于数列是有序的,其实我们完全可以一半儿一半儿的排除。假设我们要找第 k 小数,我们可以每次循环排除掉 k/2 个数。
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
def getKth(nums1, s1, e1, nums2, s2, e2, k):
len1 = e1-s1+1
len2 = e2-s2+1
if len1>len2: #确保nums1的长度小于nums2
return getKth(nums2, s2, e2, nums1, s1, e1, k)
if len1==0: #当更短的nums1,长度为0时,直接从nums2中取数
return nums2[s2+k-1]
if k==1: #当k为1时,取两数组起始的小值
return min(nums1[s1], nums2[s2])
idx_1 = s1+min(len1,k//2)-1 #每次只取k的一半
idx_2 = s2+min(len2,k//2)-1 #
if nums1[idx_1]>nums2[idx_2]:
return getKth(nums1, s1, e1, nums2, idx_2+1, e2, k-(idx_2-s2+1))
else:
return getKth(nums1, idx_1+1, e1, nums2, s2, e2, k-(idx_1-s1+1))
len1 = len(nums1)
len2 = len(nums2)
left = (len1+len2+1)//2
right = (len1+len2+2)//2
return (getKth(nums1, 0, len1-1, nums2, 0, len2-1, left)+getKth(nums1, 0, len1-1, nums2, 0, len2-1, right))/2