二分查找
什么是二分查找?二分查找就是不断的找当前序列的中点,通过与二段性比较,如果满足,则解必然在另一半的序列中,将序列一分为二,在另一半中继续搜索,如此往复,因此每次减少一般的搜索空间,达到Ologn的时间复杂度。
二段性
二分查找最重要的就是必须拥有二段性,什么是二段性?必须序列的前一部分满足一个性质,后一部分不满足,因此我们可以通过二分查找找到两个性质转变的中间点。
举个例子,在s=[1,2,3,4,5]中查找一个数字target=4,即我们可以发现在s中有二段性,一部分是大于target的一部分是小于target的,因此我们要找到满足性质转换的那个点,也就是target。
还有比如检验问题,一个序列,在某个点检验失败后,后面的点全部是失败,让我们找第一个失败的点,这个也是有二段性,因为第一个失败点之前都是成功的,后面都是失败的,因此两段性质不同。
对具有二段性质的序列,我们可以使用二分查找,时间复杂度可以达到O(logn)
二分查找代码及逻辑
二分查找的代码可以有很多写法,在这里我们只介绍两种,一种是靠左,一种靠右
靠左的意思是,在选择中点的时候,如果长度是偶数,那么中点选在靠左的位置,这个时候,如果出现重复的target,那么靠左的代码最终选中的是相同target中最左边那个。靠右同理
首先是靠右:
l=0
r=n-1
while l<r:
mid=(l+r+1)>>1
if 是否满足二段性中其中一段:
r=mid-1
else:
l=mid
因为是靠右选的,如果选中的答案不满足性质,那么只考虑mid前面半部分
靠左:
l=0
r=n-1
while l<r:
mid=(l+r)>>1
if 是否满足二段性中其中一段:
l=mid+1
else:
r=mid
因为是靠左选的,如果选中的答案不满足性质,那么只考虑mid右边半部分
那么二分查找代码最终找到的位置为l=r
双闭区间:
def searchInsert(self, nums: List[int], target: int) -> int:
# 经典二分搜索
# 搜索区间为左闭右闭,nums无重复元素!
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
elif nums[mid] < target: # 说明中值偏小,在右边搜
left = mid + 1
return left
例题实战
1. 在一个升序数组nums中寻找target,如果找到返回target的位置,找不到,返回-1
解法:经典的二分查找做法,有序数组,因此考虑二分
class Solution(object):
def search(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: int
"""
l=0
r=len(nums)-1
while l<r:
mid=(l+r+1)>>1
if nums[mid]<=target:
l=mid
else:
r=mid-1
if nums[r]==target:
return r
else:
return -1
此处我们的判断二段性体现在,选中的中点数字如果大于target,那么target只能出现在中点左边,因此r=mid-1,在左半边继续搜索
2. 假设你有 n 个版本 [1, 2, …, n],你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
解法,即一个版本是错的,后面的版本都是错的,让我们找第一个出错的版本,很明显是有二段性,因此考虑二分查找
class Solution:
def firstBadVersion(self, n):
"""
:type n: int
:rtype: int
"""
l=0
r=n-1
while l<r:
mid=(l+r)>>1
if isBadVersion(mid+1):
r=mid
else:
l=mid+1
return r+1
此处我们判断二段的依据在于,我们要找的是第一个错误的版本,因此选择靠左的做法,判断中点是否是错误的,如果是,那么当前点也有可能是第一个错误的,因此从包含该点的左边搜索,因此r=mid
注意:代码中我们用的是0-n-1的下标,因此最后输出的版本要加1,中间判断的版本也要+1
那么为什么不能选择靠右呢? 这里我们考虑,因为要找的是第一个错误的版本,因此在判断是否错误的时候本身的答案是要继续搜索的,如果选择了靠右的代码,那么序列不能被均匀的一分为二,速度会下降,如果选择靠右,那么我们的二段性条件要改变,即要找的点应该是第一个错误的点前面那个点,也就是最后一个成功的点。
3. 统计一个数字在排序数组中出现的次数。 nums非递减
一个经典的二分做法,这道题就用到了我们的性质了,我们可以考虑靠左靠右同时使用, 分别找到相应的target的最左边和最右边,然后相减可以得到target的出现次数
class Solution(object):
def search(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: int
"""
if len(nums)==0:
return 0
l=0
r=len(nums)-1
while l<r:
mid=(l+r+1)>>1
if nums[mid]<=target:
l=mid
else:
r=mid-1
if nums[r]!=target:
return 0
right=r
l=0
r=len(nums)-1
while l<r:
mid=(l+r)>>1
if nums[mid]<target:
l=mid+1
else:
r=mid
return right-l+1
本题没什么可讲的,二段性那个条件可以画图试试就好了,确保分的时候均匀即可
4. 一个长度为n-1的递增排序数组中的所有数字都是唯一的,并且每个数字都在范围0~n-1之内。在范围0~n-1内的n个数字中有且只有一个数字不在该数组中,请找出这个数字。
解法,这道题很明显也是二分查找,但是二段性条件比较难想,首先我们要知道n-1的长度数组在0-n-1的数据范围且递增,缺了一个数,那么我们很轻易知道,缺失数字前面一部分的数字是和下标相同的,后面一部分和下标不同,因此考虑二分
class Solution(object):
def missingNumber(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
l=0
r=len(nums)-1
if nums[0]==1:
return 0
while l<r:
mid=(l+r+1)>>1
if nums[mid]!=mid:
r=mid-1
else:
l=mid
return nums[r]+1
首先对0缺失要单独考虑,因为最终算到的缺失点是数组边界之外,因此如果0缺失(即第一位是1),那么不管nums再长,直接返回0
我们的二段性条件找的是缺失的数字左边的那个数字,因此如果nums[mid]!=mid的时候,当前中点已经是错误点了,因此考虑r=mid-1,也就是前面一部分。最终我们找到的是缺失的数字左边那个数字,因此需要+1
5. 整数数组 nums 按升序排列,数组中的值 互不相同 。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。
给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1
解法,本题比较难,首先我们需要找到旋转点,因为旋转点两边各自有序
那么首先我们考虑二分去找旋转点,怎么找呢?二段性体现在哪?例如[1,2,3,4,5,6,7]数组在某个点旋转之后变成[5,6,7,1,2,3,4]那么我们发现旋转点是5,但是最终对两段有序分割的点是1,那么我们发现1这个点前面的5,6,7,因为是旋转之后的,所以必定是大于等于5的,同时我们发现1234必定是小于5的(因为原数组中数值各不相同)因此二段性体现在,一部分nums[i]>=nums[0] (此处是已经旋转之后的数组)另一部分相反。因此我们首先二分找到旋转点。代码:
class Solution(object):
def search(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: int
"""
slen=len(nums)
if slen == 0:
return -1
if slen ==1:
if nums[0]==target:
return 0
else:
return -1
l=0
r=slen-1
while l<r:
mid=(l+r+1)>>1
if nums[mid]>nums[0]:
l=mid
else:
r=mid-1
此时我们的l=r=旋转点前面一个点,也就是数组中最大的元素
因此我们需要对target判断是属于这个点前面还是后面一部分
if target>=nums[0]:
l=0
else:
l=l+1
r=slen-1
如果target是大于nums[0]的话,那么属于左边一部分,应该是[0-r],此时的r是最大元素所在位置
否则的话从[l+1,slen-1]搜索
最后一遍二分不用我多说了吧?有序数组中找一个target
while l<r:
mid=(l+r+1)>>1
if nums[mid]<=target:
l=mid
else:
r=mid-1
if nums[r]==target:
return r
else:
return -1
寻找峰值
题目让我们实现一个 O(logn) 算法,这是对使用「二分」的强烈暗示。
和往常的题目一样,我们应当从是否具有「二段性」来考虑是否可以进行「二分」。
不难发现,如果 在确保有解的情况下,我们可以根据当前的分割点 mid 与左右元素的大小关系来指导 l或者 r 的移动。
假设当前分割点 mid 满足关系 num[mid] > nums[mid + 1] 的话,一个很简单的想法是 num[mid]可能为峰值,而 nums[mid + 1] 必然不为峰值,于是让 r = mid,从左半部分继续找峰值。
估计不少同学靠这个思路 AC 了,只能说做法对了,分析没对。
上述做法正确的前提有两个:
对于任意数组而言,一定存在峰值(一定有解);
- 二分不会错过峰值。
- 我们分别证明一下。
证明 1 :对于任意数组而言,一定存在峰值(一定有解)
根据题意,我们有「数据长度至少为 1」、「越过数组两边看做负无穷」和「相邻元素不相等」的起始条件。
我们可以根据数组长度是否为 1 进行分情况讨论:
数组长度为 1,由于边界看做负无穷,此时峰值为该唯一元素的下标;
数组长度大于 1,从最左边的元素 nums[0] 开始出发考虑:
如果 nums[0] > nums[1],那么最左边元素 nums[0] 就是峰值(结合左边界为负无穷);
如果 nums[0] < nums[1],由于已经存在明确的 nums[0] 和 nums[1] 大小关系,我们将 nums[0]看做边界, nums[1]看做新的最左侧元素,继续往右进行分析:
如果在到达数组最右侧前,出现 nums[i] > nums[i + 1],说明存在峰值位置 i(当我们考虑到 nums[i],必然满足 nums[i]大于前一元素的前提条件,当然前一元素可能是原始左边界);
到达数组最右侧,还没出现 nums[i] > nums[i + 1],说明数组严格递增。此时结合右边界可以看做负无穷,可判定 nums[n - 1]为峰值。
综上,我们证明了无论何种情况,数组必然存在峰值。
证明 2 :二分不会错过峰值
其实基于「证明 1」,我们很容易就可以推理出「证明 2」的正确性。
整理一下由「证明 1」得出的推理:如果当前位置大于其左边界或者右边界,那么在当前位置的右边或左边必然存在峰值。
换句话说,对于一个满足 nums[x] > nums[x - 1] 的位置,xx 的右边一定存在峰值;或对于一个满足 nums[x] > nums[x + 1] 的位置,xx 的左边一定存在峰值。
因此这里的「二段性」其实是指:在以 mid为分割点的数组上,根据 nums[mid] 与 nums[mid±1] 的大小关系,可以确定其中一段满足「必然有解」,另外一段不满足「必然有解」(可能有解,可能无解)。
如果不理解为什么「证明 2」的正确性可以由「证明 1」推导而出的话,可以重点看看「证明 1」的第 2 点的证明。
至此,我们证明了始终选择大于边界一端进行二分,可以确保选择的区间一定存在峰值,并随着二分过程不断逼近峰值位置。
class Solution(object):
def findPeakElement(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
slen=len(nums)
left=0
right=slen-1
while left<right:
mid=(left+right)>>1
if nums[mid]>nums[mid+1]:
right=mid
else:
left=mid+1
return right