这困难题是真难啊,这是我第一次做二分法章节,属实有点搞脑子。
**对于二分法,灵茶山艾府的视频下面有一个评论(@毫微纳皮飞** ) 写的很好:
我是在看了这篇文章,https://blog.csdn.net/groovy2007/article/details/78309120,里那句
“关键不在于区间里的元素具有什么性质,而是区间外面的元素具有什么性质。”
之后醍醐灌顶,建立了我自己的二分查找心智模型,和up主的有些类似。
也就是看最终左右指针会停在哪里。
如果我们要**找第一个大于等于x的位置,那么我就假设L最终会停在第一个大于等于x的位置,R停在L的左边。**
这样按照上面那句话,可以把循环不变式描述为“L的左边恒小于x,R的右边恒大于等于x” ,这样一来,其他的各种条件就不言自明了:
比如循环条件肯定是L小于等于R,因为我假设R停在L的左边。
而L和R转移的时候,根据循环不变式,如果mid小于x,肯定要令L等于mid+1;如果大于等于x,就令R等于mid-1。
至于初始的时候L和R怎么选,也是看循环不变式,
只需要保证初始L和R的选择满足“L的左边恒小于x,R的右边恒大于等于x”,并且不会出现越界的情况即可,
L必为0,因为0左边可以看作负无穷,恒小于x,
R取第一个一定满足条件的(防止mid取到非法值),例如n-1(n开始可以看作正无穷,恒大于等于x,如果保证x在数组里可以选择n-2,其实大于等于n也满足不变式,但是mid可能会取非法值),
而且这样一来即使是搜索数组的某一段,也可以很方便根据这个条件地找到初始位置。
如果假设L最终会停在第一个大于等于x的位置,R停在L的位置,那么循环不变式就是“L的左边恒小于x,R以及R的右边恒大于等于x” ,这样的话,循环条件就是L等于R的时候退出;转移的时候R=mid(因为若判断条件为mid对应值大于等于target,则R移到mid上,R及R右侧值必然大于等于target,则刚好和R的循环不变式部分对上了);初始时,一般取R=n(如果保证x在数组里,也可以取n-1)。
其他的情况也类似,比较直观的推导方法就是在要找的位置的分界处(比如在第一个大于等于x的位置后面)画一条线,然后假定L和R最终会停在这条线的左边还是右边,接着倒推各种条件即可。
另外可以学习这里的题解来加深对二分法的三种做法的印象:
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
35. 搜索插入位置 - 力扣(LeetCode)
引用官方题解下面的评论来解释为什么最后返回left
,写得非常之好:
最后直接返回left就可以了,
根据if的判断条件,left左边的值一直保持小于target,right右边的值一直保持大于等于target,
而且left最终一定等于right+1,
这么一来,循环结束后,在left和right之间画一条竖线,恰好可以把数组分为两部分:
left左边的部分和right右边的部分,
而且left左边的部分全部小于target,并以right结尾;
right右边的部分全部大于等于target,并以left为首。
所以最终答案一定在left的位置。
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
n = len(nums)
left = 0
right = n - 1
while left <= right:
mid = (left + right + 1) // 2
if nums[mid] < target:
left = mid + 1 # left左边一定是比target的小的数
else:
right = mid - 1 # right右边一定是大于等于target的数
# 本题中,插入位置i,对应着插在原数组索引i元素的左侧;
# 因此本题,相当于找第一个大于等于target的数的索引
return left
74. 搜索二维矩阵 - 力扣(LeetCode)
直接做法:
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
return any(target in row for row in matrix)
二分法:(建议开区间写法,比较直观形象)
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
m, n = len(matrix), len(matrix[0])
# 左边的数<=右边的数
# 保证left及其左边一定小于target,保证right及其右边一定大于等于target
# 开区间写法(left, right)!
left = -1 # 这里-1索引表示0的左侧是无穷小
right = m * n # 想象可以将matrix展成一个长为m * n的数组,这里m*n表示最后一个元素的索引m*n-1的右侧是无穷大
while left + 1 < right: # 区间不为空!
mid = (left + right + 1) // 2
x = matrix[mid // n][mid % n]
if x == target:
return True
if x < target:
left = mid
else: # 小于等于target
right = mid
# 循环若正常结束,left及其左边一定小于target,left右边一定大于等于target
# right左边一定小于target, right及其右边一定大于等于target
# right一定停在left右边一格(循环的非空条件)
# 所以right一定停在target或者第一个比target大的数字上!
return False
# 不能像下面这样return是因为,right有可能由于target过大而更新为m*n(或者过小)导致超出matrix索引范围!
# return target == matrix[right // n][right % n]
排除法(回想矩阵中的那道**搜索二维矩阵题** !几乎一样,只是横向不是严格递增)
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
m, n = len(matrix), len(matrix[0])
row, col = 0, n - 1
while row < m and col >= 0:
x = matrix[row][col]
if x == target:
return True
elif x > target:
col -= 1
else:
row += 1
return False
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
class Solution:
def findLowerBound(self, nums, target):
n = len(nums)
# 开区间写法,(left, right),left及其左边都小于target,right及其右边都大于等于target
left = -1
right = n
while left + 1 < right: # 开区间非空
mid = (left + right + 1) // 2
if nums[mid] < target:
left = mid
else:
right = mid
# 循环结束,由于是开区间,所以right处必然是第一个大于等于target的数,且可能达到n
return right
def searchRange(self, nums: List[int], target: int) -> List[int]:
n = len(nums)
start = self.findLowerBound(nums, target) # 找到nums中第一个大于等于target的数的下标
if start == n or nums[start] != target: # 由于是开区间,所以start处必然是第一个大于等于target的数,且可能达到n
return [-1, -1]
end = self.findLowerBound(nums, target + 1) - 1 # 此时必存在target,找到nums中第一个大于等于target+1的数的下标,那么它的左边格子的值必然为target
return [start, end]
摘录灵茶山艾府题解的库函数bisect_left、bisect_right
写法:
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
start = bisect_left(nums, target)
if start == len(nums) or nums[start] != target:
return [-1, -1]
end = bisect_right(nums, target) - 1
return [start, end]
153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)
这道题虽然是中等题,但是我看了特别久才看懂,但是好歹算是看懂了。
这里po出我的个人理解(开区间写法,不过搞懂之后应该不会再有问题了):
nums的最小值,记为minNum;nums的末尾元素记为endNum;n为nums长度
我们总是可以把nums看成一个左右两段分别递增的数组拼接而成,左段可以为空,最小值一定在右段上;
那么左段和右段的交界处就是最小值,左段的所有值又都大于右段的所有值;
所以nums的末尾endNum一定大于等于所有右段元素,小于所有左段元素; (这是最重要的,搞懂了这一点,这道题就顺畅了!!!)
因此,通过mid与endNum的比较,可以分辨mid到底是属于左段(大于endNum)还是右段(小于等于endNum) ,
从而分辨出现交界(也就是非递增)的那半边:
如果mid是左段,说明交界一定在mid右边;
如果mid是右段,说明交界一定在mid及其左边。
这样,能够不断缩小区间,最后控制停止循环的时候,其中一个指针落在交界右边(也就是右段第一个元素、最小值)的上面即可
我们用开区间二分法,(left, right),用来表示当前选取的子数组,left初始为-1,right初始为n(n为nums长度);
为了实现找到minNum的目标,我们需要依靠二分循环不断缩小(left, right)的范围,由于是开区间写法,所以循环结束时,必须是right落在minNum上,而left落在right左边一格。
所以令right及其右侧一定大于等于minNum(最后一轮循环时,相当于必然是mid等于minNum,此时right需要落在mid上),
left及其左边一定小于minNum;
那么该如何缩小范围呢? 回到前面的情况分析,我们可以发现:
若mid小于等于endNum,mid属于右段,minNum一定在mid及其左侧,则我们需要更新right到mid
若mid大于endNum,mid属于左段,minNum一定在mid右边,则我们需要更新left到mid。
如上,我们结束循环时(循环条件为区间非空),right就顺利落在了minNum上了!!!
class Solution:
def findMin(self, nums: List[int]) -> int:
n = len(nums)
left = -1
right = n
endNum = nums[-1]
while left + 1 < right: # 开区间非空
mid = (left + right + 1) // 2
# mid处总与endNum比较,判别mid属于左段还是右段
if nums[mid] <= endNum: # mid属于右段
right = mid
else: # mid属于左段
left = mid
return nums[right]
让gpt写了一个左开右开+动态右边界的写法,但是我搞得不太明白,感觉不如前面的解法好懂:
class Solution:
def findMin(self, nums: List[int]) -> int:
n = len(nums)
left = -1
right = n
endNum = nums[-1]
while left + 1 < right - 1: # 开区间非空
# 每次取到的新区间一定是包含左段和右段交界的那一半区间
mid = (left + right) // 2
# mid处总与endNum比较,判别mid属于左段还是右段
if nums[mid] <= nums[right-1]: # mid属于右段
right = mid + 1
else: # mid属于左段
left = mid
return nums[right-1]
或者这道题不用左开右开区间也会好理解的多!如下面的左闭右闭区间解法:
class Solution:
def findMin(self, nums: List[int]) -> int:
left, right = 0, len(nums) - 1 # 左闭右闭区间,如果用右开区间则不方便判断右值
while left < right: # 循环不变式,如果left == right,则循环结束
mid = (left + right) >> 1 # 地板除,mid更靠近left
if nums[mid] > nums[right]: # 中值 > 右值,最小值在右半边,收缩左边界
left = mid + 1 # 因为中值 > 右值,中值肯定不是最小值,左边界可以跨过mid
elif nums[mid] < nums[right]: # 明确中值 < 右值,最小值在左半边,收缩右边界
right = mid # 因为中值 < 右值,中值也可能是最小值,右边界只能取到mid处
return nums[left] # 循环结束,left == right,最小值输出nums[left]或nums[right]均可
灵茶山艾府的左开右开写法也是比较神奇的:
需要理解为什么right从n-1开始:
二分的范围可以是 (−1,n−1),也就是闭区间 [0,n−2]。
这是因为,如果 nums[n−1] 是数组最小值,那么 nums 分成两段,第一段 [0,n−2],第二段 [n−1,n−1],且第一段的所有数都大于 nums[n−1]。每次 x 和 nums[n−1] 比大小,一定是 x>nums[n−1]。这意味着每次二分更新的都是 left,那么循环结束后,答案自然就是 n−1 了。
注:这里有两个概念「二分范围」和「答案范围」。答案确实可以等于 n−1,但对于二分来说,代码中的 if (nums[mid] < nums[n - 1]) 在 mid=n−1 的时候一定不成立,我们可以直接知道 n−1 是蓝色(根据视频中的红蓝染色法),所以 n−1 无需在二分区间中。
class Solution:
def findMin(self, nums: List[int]) -> int:
left, right = -1, len(nums) - 1 # 开区间 (-1, n-1)
while left + 1 < right: # 开区间不为空
mid = (left + right) // 2
if nums[mid] < nums[-1]:
right = mid
else:
left = mid
return nums[right]
33. 搜索旋转排序数组 - 力扣(LeetCode)
是上一题的变式,依然学习灵茶山艾府的题解,摘抄、修改如下
用两次二分法:
class Solution:
def canFindTarget(self, nums, left, right, target):
# 开区间写法,找到nums中的(left, right)区间中第一个大于等于target的索引,并判断是否是target
while left + 1 < right:
mid = (left + right + 1) // 2
# 循环不变式:
# nums[right] >= target
# nums[left] < target
if nums[mid] < target:
left = mid
else:
right = mid
return right if nums[right] == target else -1
def search(self, nums: List[int], target: int) -> int:
n = len(nums)
# 二分法找到旋转排序后的最小值索引,开区间写法
endNum = nums[-1]
left, right = -1, n
while left + 1 < right:
mid = (left + right + 1) // 2
if nums[mid] > endNum: # mid属于左段
left = mid
else: # mid属于右段
right = mid
minIdx = right # 此时right就是最小值索引
# 这里的取法也很巧妙,要学会
if target > nums[-1]: # target对应左段(target大于右段中所有值)
return self.canFindTarget(nums, -1, minIdx, target)
else: # target对应右段(target小于等于右段最大值)
return self.canFindTarget(nums, minIdx - 1, n, target)
一次二分法:
这里po出我的个人理解(开区间写法,本方法同样也要力求搞懂):
开区间写法,循环不变量:
left:left左侧及其一定小于target;
right:right及其右侧一定大于等于target;
循环终止时,right落在第一个大于等于target的数处。
循环时,不断比较midNum(索引mid对应的值)和target(值)的相对位置:
那么如何比较midNum和target的相对位置呢?(最重要的地方)
直接考虑什么时候target在midNum及其左边(也就是isUpdateRight取真),(剩余情况必然都是target在midNum右边,也就是isUpdateRight取假)
对于target在midNum及其左边的情况:
若midNum大于nums末尾(情况1大前提),则midNum在左段,
此时target在midNum及其左边 等价于 target必须也在左段(target > nums[-1]),且target <= midNum;
否则,情况1大前提下的其他子情况都是target在midNum右边。
若midNum小于等于nums末尾(情况2大前提),则midNum在右段,左段可以为空或者非空,
此时target在midNum及其左边 等价于 target要么在左段(target > nums[-1])要么在右段且在midNum及其左侧(target <= midNum) ;
否则,情况2大前提下的其他子情况都是target在midNum右边,
以上情况1+情况2就是所有可能性全集,
我们在target在midNum及其左边时更新right,否则更新left即可!最终right将会停在第一个大于等于target的格子之上。
剩下的东西就都是前面第四题的框架了。
class Solution:
def search(self, nums: List[int], target: int) -> int:
n = len(nums)
def isUpdateRight(mid):
# 检查target是否在mid及其左侧,是的话可以更新right,否则要更新left
nonlocal target
midNum = nums[mid]
return (midNum > nums[-1] and (target > nums[-1] and target <= midNum)) \
or (midNum <= nums[-1] and (target > nums[-1] or target <= midNum))
left = -1
right = n
while left + 1 < right: # 开区间非空
mid = (left + right + 1) // 2
if isUpdateRight(mid):
right = mid
else:
left = mid
return right if nums[right] == target else -1
4. 寻找两个正序数组的中位数 - 力扣(LeetCode)
困难题
看灵神的题解头都晕了,我说实话,灵神优质的讲解太少了,更多是提供一个优质的代码和思路,讲解方面真的是完全从已经明白这道题的人的视角出发去讲解的,并不考虑不懂的人的感受。
(这次刷hot100也是对此感触颇深,以前看不懂的灵神题解,再学过做过一遍之后,回来再做就能看懂并明白灵神代码的优秀之处了。可惜就是讲解实在是对小白太不友好了,可以说是不讲人话的水平)
个人理解概要:
实际上这道题就是找到一个正确的对两个正序数组的分割,使得两者的左半边合起来放在一个袋子里叫左袋子,右半边合起来放在一个袋子里叫右袋子,
而且左袋子是两正序数组合并后中位数的左边(包含(m+n)//2
个数字,也就是说只要确定其中一个数组的分割就可以确定另一个数组的分割!),右袋子是中位数的右边(包含剩余数字),
其中会有一个特性就是左袋子的最大值一定小于等于右袋子的最小值,且左袋子最大值和右袋子最小值可以由分割处确定,分别对应两数组分割口左侧的数字的最大值和两数组分割口右侧的数字的最小值)。
因此就可以通过二分法去寻找其中一个数组的分割口了!
下面直接引用GPT对此问题的讲解(已经过个人校对)了,虽然比较冗长,但是讲的挺好的。
一、问题回顾
有两个升序数组 a
(长度 m)和 b
(长度 n)。
要在它们合并后的序列中,找到中位数:
m+n
为奇数,中位数就是第 ( m + n + 1 ) / 2 (m+n+1)/2 (m+n+1)/2 个元素;所以
时间复杂度要 O ( log ( m + n ) ) O(\log(m+n)) O(log(m+n))(更精确地是 O ( log min ( m , n ) ) O(\log\min(m,n)) O(logmin(m,n))),不能直接合并再取中位。
二、“左开右开”二分思路
为什么能二分?
我们不实际合并,而是在两个数组中“划一刀”:
刀在 a
数组上的位置为 i
的右边 ,在 b
数组上的位置为 j
的右边 。
a
数组 左半边 包含的是下标 0,1,…,i
这 i
个元素,右半边 则从下标 i+1
开始,包含 i+1,…
。b
数组 左半边 包含的是下标 0,1,…,j
这 j
个元素,右半边 则从下标 j+1
开始,包含 j+1, …
。因此我们所求其实是要求找到一对i, j,刚好a的左半和b的左半形成中位数隔出来的左半边,a的右半和b的右半形成中位数隔出来的右半边
刀左侧一共会有 ** ⌊ ( m + n + 1 ) / 2 ⌋ \lfloor(m+n+1)/2\rfloor ⌊(m+n+1)/2⌋** 个元素,刀右侧也是剩下的那部分。
当左侧最大的元素 ≤ 右侧最小的元素时,刀恰好切在了中位数边界上。
记号定义:
记号 | 含 义 | 说明 |
---|---|---|
i |
A 有i + 1 个元素位于左半区 |
刀口落在a[i] 右侧,a[0…i] 属左,a[i+1…] 属右 |
j |
B 有j + 1 个元素位于左半区 |
刀口落在b[j] 右侧,b[0…j] 属左,b[j+1…] 属右 |
ai /ai1 |
a[i] /a[i+1] |
左半区 A 尾、右半区 A 头 |
bj /bj1 |
b[j] /b[j+1] |
左半区 B 尾、右半区 B 头 |
中位数条件
元素数量:左半区元素总数需满足
( i + 1 ) + ( j + 1 ) = ⌊ m + n + 1 2 ⌋ (i+1) + (j+1) \;=\; \Bigl\lfloor\dfrac{m+n+1}{2}\Bigr\rfloor (i+1)+(j+1)=⌊2m+n+1⌋数值大小:
max ( a i , b j ) ≤ min ( a i 1 , b j 1 ) \max(ai,\,bj) \;\le\; \min(ai1,\,bj1) max(ai,bj)≤min(ai1,bj1)也就是左半区最大值 ≤ 右半区最小值。 (这里ai1表示a[i+1],bj1表示b[j+1])
三、算法步骤
始终在长度更短的数组 a
上二分,提高效率。
维护开区间 (left, right)
:
(-1, m)
;可取的 i
值是 left+1…right-1
。二分选 i
,配对 j
:
这样确保左半区元素数正好为 ⌊ ( m + n + 1 ) / 2 ⌋ \lfloor(m+n+1)/2\rfloor ⌊(m+n+1)/2⌋。
利用不变量比较 a[i]
与 b[j+1]
来决定收缩哪一侧。
收敛到 left+1 == right
时,唯一合法 i
就是 left
。
根据奇偶长度返回 max(ai, bj)
或平均 max ( a i , b j ) + min ( a i 1 , b j 1 ) 2 \dfrac{\max(ai,bj)+\min(ai1,bj1)}2 2max(ai,bj)+min(ai1,bj1)。
四、不变量与收敛证明
a[left] ≤ b[j+1]
left
(及其更小的刀口 i
)都满足中位数大小条件,可保留在左半边。a[right] > b[j+1]
right
(及其更大的刀口 i
)都会破坏大小条件,必须保留在右半边。指针 | 作用 | 语义(对候选 i 而言) |
---|---|---|
left |
当前满足大小条件的最大下标 | i ≤ left 的位置都已经验证过a[i] ≤ b[j+1] ,因此依旧有机会成为最终刀口——它们被“保留在左半边候选池” |
right |
当前第一个不满足大小条件的下标 | i ≥ right 的位置都已经出现a[i] > b[j+1] ,必定破坏“左≤右”条件,肯定不可能再是答案——它们“被挡在右半边候选池之外” |
每次把
(left, right)
区间长度至少减半,直至只剩一个候选下标i = left
。
五、完整代码(全部中文注释)
class Solution:
def findMedianSortedArrays(self, a: List[int], b: List[int]) -> float:
# 1) 确保 a 是较短的数组(提高二分效率)
if len(a) > len(b):
a, b = b, a
m, n = len(a), len(b)
# 2) 维护开区间 (left, right),允许的 i 在 (left, right) 内
# 初始为 (-1, m),表示 i 可以取 0…m-1
left, right = -1, m
# 3) 二分查找 i
while left + 1 < right: # 开区间仍有 ≥2 个候选
i = (left + right) // 2 # 中点 i(满足 left < i < right)
# 计算配对的 j,使左半区元素数 = floor((m+n+1)/2)
j = (m + n - 3) // 2 - i
# 4) 核心比较:判断 a[i] 是否仍可放入左半区
if a[i] <= b[j + 1]:
# 满足 a[i] ≤ b[j+1] ⇒ i 属于左半区,left 向右移到 i
left = i
else:
# 否则 i 属于右半区,right 向左移到 i
right = i
# 5) 收敛:left == right - 1,唯一合法 i 即 left
i = left
j = (m + n - 3) // 2 - i
# 6) 取四个边界值,注意超界用 正负无穷 占位
ai = a[i] if i >= 0 else float('-inf')
bj = b[j] if j >= 0 else float('-inf')
ai1 = a[i+1] if i+1 < m else float('inf')
bj1 = b[j+1] if j+1 < n else float('inf')
left_max = max(ai, bj) # 左半区最大
right_min = min(ai1, bj1) # 右半区最小
# 7) 奇偶分支
if (m + n) % 2 == 1:
return left_max # 奇数长度
else:
return (left_max + right_min) / 2 # 偶数长度
六、j = (m + n - 3)//2 - i
的由来
目标左半区元素总数
half = ⌊ m + n + 1 2 ⌋ \text{half} = \Bigl\lfloor\frac{m+n+1}{2}\Bigr\rfloor half=⌊2m+n+1⌋A 已经贡献 i + 1
个(下标 0…i
)
⇒ B 需再贡献
B 的下标从 0
开始,共取 j+1
个 ⇒
采用整数除 //
即 (m+n-3)//2
,确保与 Python 地板除一致。
七、复杂度 & 记忆口诀
时间复杂度
二分范围长 m = len(a)
,循环次数 O(log m)
;每轮常数操作 ⇒
空间复杂度:常数级 O ( 1 ) O(1) O(1)。
记忆口诀
短先排,开区间;j = half − i − 1;比头收,留一分;左大右小出中点。
这样就用最短数组二分、左开右开的方式,高效且严谨地找到了两有序数组的中位数。