目录
前言
学习了二分查找相关的知识点,它是一种折半查找算法,用于在有序数组中查找特定元素。基本步骤如下:
关于左右区间开闭问题,本次练习均采用左闭右闭区间,即left=0,right=len(nums)-1.这样可以简化代码考虑的情况.
关于mid的取值问题:
为了防止整型溢出问题,会采用以下的写法:
一、方法思路
这里简单介绍二分查找两种不同的实现思路。
1.直接法
直接法思想是在循环体中找到元素后直接返回结果。思路过程如下:
代码如下:
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1
# 在区间 [left, right] 内查找 target
while left <= right:
# 取区间中间节点
mid = left + (right - left) // 2
# 如果找到目标值,则直接范围中心位置
if nums[mid] == target:
return mid
# 如果 nums[mid] 小于目标值,则在 [mid + 1, right] 中继续搜索
elif nums[mid] < target:
left = mid + 1
# 如果 nums[mid] 大于目标值,则在 [left, mid - 1] 中继续搜索
else:
right = mid - 1
# 未搜索到元素,返回 -1
return -1
这种方法适用于解决简单题目,针对查找元素简单,数组中都为非重复元素,且==、<、>的情况非常好写时。
2.排除法
排除法的思想是在循环体中排除目标元素不存在的区间。
基本思想如下:
针对这一思路,一共有两种不同的实现:
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1
# 在区间 [left, right] 内查找 target
while left < right:
# 取区间中间节点
mid = left + (right - left) // 2
# nums[mid] 小于目标值,排除掉不可能区间 [left, mid],在 [mid + 1, right] 中继续搜索
if nums[mid] < target:
left = mid + 1
# nums[mid] 大于等于目标值,目标元素可能在 [left, mid] 中,在 [left, mid] 中继续搜索
else:
right = mid
# 判断区间剩余元素是否为目标元素,不是则返回 -1
return left if nums[left] == target else -1
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1
# 在区间 [left, right] 内查找 target
while left < right:
# 取区间中间节点
mid = left + (right - left + 1) // 2
# nums[mid] 大于目标值,排除掉不可能区间 [mid, right],在 [left, mid - 1] 中继续搜索
if nums[mid] > target:
right = mid - 1
# nums[mid] 小于等于目标值,目标元素可能在 [mid, right] 中,在 [mid, right] 中继续搜索
else:
left = mid
# 判断区间剩余元素是否为目标元素,不是则返回 -1
return left if nums[left] == target else -1
由于终止条件使用的是left<right,所以退出循环时一定有left==right,不用再判断left和right哪个要返回。
总的逻辑就是优先排除不可能的区间,在剩余区间内查找元素。
特别要注意的是,为了避免陷入死循环,第二种情况mid应该向上取整,即mid = left + (right - left + 1) // 2。
两种方法的记忆方法是:第一种取的mid相对于中间位置偏左,所以left更新位置应该+1使其平衡,right正常等于mid;第二种取的mid相对于中间位置偏右,所以right更新位置应该-1使其平衡,left正常等于mid.
下面结合Leetcode具体题目进行探索。
二、Leetcode真题
1.二分查找
思路:二分查找入门级别题目,直接套用直接法的模板即可。
class Solution:
def search(self, nums: List[int], target: int) -> int:
left=0
right=len(nums)-1
while left<=right:
mid=(left+right)//2
if nums[mid]==target:
return mid
elif nums[mid]>target:
right=mid-1
else:
left=mid+1
return -1
2.搜索插入位置
仍然可以套用直接法的模板,不同的是目标值不存在,要返回插入位置,由直接法模板,可以判断出当跳出循环时有left>right,以数组[1,3,5,6]为例,若要求目标值为4,经过若干轮查找后,right=1,left=2.
显然4应在放在索引为2的位置,所以返回left的值。
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
left=0
right=len(nums)-1
while left<=right:
mid=(left+right)//2
if nums[mid]==target:
return mid
elif nums[mid]<target:
left=mid+1
else:
right=mid-1
return left
3.猜数字大小
思路:读清楚题意,当取-1时,猜的数字过大,要取左半区间;当取1时,猜的数字过小,要取右半区间,再套用直接法模板即可。
class Solution:
def guessNumber(self, n: int) -> int:
left=1
right=n
while left<=right:
mid=(left+right)//2
ans=guess(mid)
if ans==0:
return mid
elif ans==-1:
right=mid-1
else:
left=mid+1
return 0
4.x的平方根
思路:取左边界为0,右边界为整数x.套用直接法模板,不同的是,不采用判断中间值mid的if,直接涵盖在小于的情况里面(如果一直相等,则left会一直+1跳出循环),不断更新ans结果,最终返回。
class Solution:
def mySqrt(self, x: int) -> int:
left=0
right=x
ans=-1
while left<=right:
mid=(left+right)//2
if mid*mid<=x:
ans=mid
left=mid+1
else:
right=mid-1
return ans
5.两数之和II-输入有序数组
难度开始飙升,最直观的思路是使用暴力法求解:
class Solution:
def twoSum(self, numbers: List[int], target: int) -> List[int]:
size = len(numbers)
for i in range(size):
for j in range(i + 1, size):
if numbers[i] + numbers[j] == target:
return [i + 1, j + 1]
return [-1, -1]
但时间复杂度过高,为o(n^2),超出时间限制,这里考虑采用二分查找来减少时间复杂度。
具体做法如下:
这里采用排除法的模板去写
class Solution:
def twoSum(self, numbers: List[int], target: int) -> List[int]:
for i in range(len(numbers)):
left, right = i + 1, len(numbers) - 1
while left < right:
mid = left + (right - left) // 2
if numbers[mid] + numbers[i] < target:
left = mid + 1
else:
right = mid
if numbers[left] + numbers[i] == target:
return [i + 1, left + 1]
return [-1, -1]
这个方法的时间复杂度为o(nlogn)
使用双指针可以进一步减少时间复杂度到o(n),具体思路如下:
这一思路可以实现的原因是数组的升序排列的,所以可以缩小区间范围直到找到目标索引。判断条件要注意的是不能返回相同索引,所以跳出循环即认为没找到。
class Solution:
def twoSum(self, numbers: List[int], target: int) -> List[int]:
left = 0
right = len(numbers) - 1
while left < right:
total = numbers[left] + numbers[right]
if total == target:
return [left + 1, right + 1]
elif total < target:
left += 1
else:
right -= 1
return [-1, -1]
6.在D天内送到包裹的能力
思路:问题的关键在于明确二分查找的左右边界,这里指的最小运载能力下界应该是max(weights),因为要确保可以运到其中的单个货物,上界是sum(weights),满足题意。
class Solution:
def shipWithinDays(self, weights: List[int], D: int) -> int:
left = max(weights)
right = sum(weights)
while left < right:
mid = (left + right) //2
days = 1
cur = 0
for weight in weights:
if cur + weight > mid:
days += 1
cur = 0
cur += weight
if days <= D:
right = mid
else:
left = mid + 1
return left
实现逻辑是,先初始化天数为1天,载货为0,不断遍历weights列表,增加载货与中间值进行判断,如果大于中间值,则超过载货能力,让载货清零,天数加1.
循环结束后,判断天数和D的关系,若小于等于,证明载货能力过大,取左半区间;否则取右半区间。
7.第一个错误的版本
思路:分析清楚题意,错误版本之后所有的版本都是错误的,是要找最初的错误版本。所以此次二分查找没有中间值判断,采用排除法的模板。当mid的版本为错误时,更新right为mid处,查找左半区间;否则更新left为mid+1处,查右半区间。退出循环时,返回left
class Solution:
def firstBadVersion(self, n):
left = 1
right = n
while left < right:
mid = (left + right) // 2
if isBadVersion(mid):
right = mid
else:
left = mid + 1
return left
8.搜索旋转排序数组
思路:旋转数组的特点就是可能会将升序数组打乱,但是从局部来看,无论从数组哪一个位置切分,剩下的两个数组都至少有一个为升序数组,这是此题的突破口。
先上代码:
class Solution:
def search(self, nums: List[int], target: int) -> int:
if not nums:
return -1
left,right=0,len(nums)-1
while left<=right:
mid=left+(right-left)//2
if nums[mid]==target:
return mid
if nums[0]<=nums[mid]:
if nums[0]<=target<nums[mid]:
right=mid-1
else:
left=mid+1
else:
if nums[mid]<target<=nums[right]:
left=mid+1
else:
right=mid-1
return -1
代码基于直接法的思路。中间判断环节的逻辑是,当与中间值相等,就返回mid;再判断左半区间是否升序,若升序,如果目标值在区间中,则right左移;否则一定在右半区间中,left右移。
若左半区间不升序,则右半区间一定升序,再判断目标值是否在区间中,同样确定left和right的移动。
9.寻找旋转排序数组中的最小值
思路:数组性质与上题相同,直接定义两个指针left,right,如果中间值大于右边值,则最小值在右半区间,left=mid+1;否则最小值在左半区间,right=mid
class Solution:
def findMin(self, nums: List[int]) -> int:
left = 0
right = len(nums) - 1
while left < right:
mid = left + (right - left) // 2
if nums[mid] > nums[right]:
left = mid + 1
else:
right = mid
return nums[left]