题目
给定两个大小分别为 m m m 和 n n n 的正序(从小到大)数组 n u m s 1 nums1 nums1 和 n u m s 2 nums2 nums2。请你找出并返回这两个正序数组的中位数 。
算法的时间复杂度应该为 O ( l o g ( m + n ) ) O(log (m+n)) O(log(m+n))。
示例
-
输入: n u m s 1 = [ 1 , 3 ] , n u m s 2 = [ 2 ] nums1 = [1,3], nums2 = [2] nums1=[1,3],nums2=[2]
输出: 2.00000 2.00000 2.00000
解释:合并数组 = [ 1 , 2 , 3 ] [1,2,3] [1,2,3] ,中位数 2 2 2 -
输入: n u m s 1 = [ 1 , 2 ] , n u m s 2 = [ 3 , 4 ] nums1 = [1,2], nums2 = [3,4] nums1=[1,2],nums2=[3,4]
输出: 2.50000 2.50000 2.50000
解释:合并数组 = [ 1 , 2 , 3 , 4 ] [1,2,3,4] [1,2,3,4] ,中位数 ( 2 + 3 ) / 2 = 2.5 (2 + 3) / 2 = 2.5 (2+3)/2=2.5
思路
1 暴力解法
简单地将这两个数组合并,然后重新排序,再找出中间值。
- 时间复杂度: O ( ( m + n ) l o g ( m + n ) ) O((m+n)log(m+n)) O((m+n)log(m+n))
- 空间复杂度: O ( m + n ) O(m+n) O(m+n)
- 缺点:没有使用到题目中数组有序这一条件
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
# 将nums2添加到nums1的末尾
nums1.extend(nums2)
# 对nums1进行排序。python中的sort排序是快速排序,复杂度为O(nlog(n))
nums1.sort()
# 计算合并后nums1的长度,判断长度为奇数还是偶数
n = len(nums1)
# 如果长度除以2余1为奇数,中位数只有一个,直接返回中间值
if n % 2 == 1:
return nums1[n//2]
# 否则为偶数,中位数有两个,返回最中间两个数的平均数
return (nums1[n//2]+nums1[n//2-1])/2
2 归并排序
和暴力排序一样的思路,只是在排序的使用归并排序。
- 时间复杂度: O ( m + n ) O(m+n) O(m+n)
- 空间复杂度: O ( m + n ) O(m+n) O(m+n)
如果不太了解归并排序的可以查看这篇文章归并排序
# 两个数组的长度
n = len(nums1)
m = len(nums2)
# p1和p2分别指向两个数组的头部
p1, p2 = 0, 0
# 创建一个新的数组用于存储排序之后的数组
new = []
# 指针不可以超出数组长度
while p1 < n or p2 < m:
# 当指针p1超出数组nums1,将nums2中剩余的数字都添加到新的数组中
if p1 == n:
new.append(nums2[p2])
p2 += 1
# 指针p2同上
elif p2 == m:
new.append(nums1[p1])
p1 += 1
# 比较两个数组元素的大小,谁大存谁
elif nums1[p1] < nums2[p2]:
new.append(nums1[p1])
p1 += 1
else:
new.append(nums2[p2])
p2 += 1
# 奇偶判断
if (m + n) % 2 == 1:
return new[(m + n) // 2]
else:
return (new[(m + n) // 2] + new[(m + n) // 2 - 1]) / 2
下面是官方的归并排序解法,它将可以融合的条件融合在了一起,并且不需要产生新的数组。
n = len(nums1)
m = len(nums2)
l = m + n
left, right = -1, -1
p1, p2 = 0, 0
for i in range(l // 2 + 1):
# 如果还能继续执行说明右指针还可以右移,那么让左指针等于右指针
left = right
# 当p1小于nums数组长度并且nums1的元素小于nums2的元素,或者nums1还存在但nums2已经遍历完成,让右指针指向偏小的数
if n < p1 and (p2 >= m or nums1[p1] < nums2[p2]):
right = nums1[p1]
p1 += 1
else:
right = nums2[p2]
p2 += 1
if l % 2 == 1:
return right
return (left + right) / 2
3 二分查找
首先先熟悉一下二分查找算法,我们举一个用Python实现二分查找的例子:
在该函数中,我们首先初始化左右两个指针,分别指向数组的首尾元素。然后进入循环,每次找到中间位置 mid,判断目标元素是否等于 arr[mid],如果是则返回 mid,如果不是,则根据目标元素与 arr[mid] 的大小关系,移动左指针或右指针,继续进行二分查找。如果最终没有找到目标元素,则返回-1。
需要注意的是,该函数要求输入的数组必须是已经排序好的。如果输入的数组没有排序,则需要先调用 sort() 函数对数组进行排序。
def binary_search(arr, target):
"""
在已排序的数组 arr 中查找目标元素 target
返回目标元素的索引值,如果目标元素不存在则返回-1
"""
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
-
中位数:在只有一个有序数组的时候,中位数可以把数组划分为两个部分。
-
根据定义,按数组长度为奇数和偶数讨论:
如果数组的长度为偶数的话,中位线将数组划分为两个子数组,左数组和右数组长度相等,它的中位数为左边数组最大值和右边数组最小值的平均值。
如果数组的长度为奇数的话,中位线将数组划分为俩个不等长度的子数组,可以左边数组比右边数组多一个数,也可以右边数组比左边数组多一个数。
-
解决过程
-
题目中有两个有序数组,并且要求的时间复杂度为 O ( l o g ( m + n ) ) O(log(m+n)) O(log(m+n)),因此想到使用二分查找来做,二分查找不需要合并两个数组。
-
根据一个有序数组的情况,在两个有序数组的情况下,用一条分割线也可以将两个数组划分为两个部分。那么中位数一定与在分割线两边的元素(也就是图中这四个元素)有关,那么我们的目标就是确定这个分割线的位置,确定这个位置我们需要使用二分查找算法。
这条分割线应该有以下两个条件:- 这两个部分的元素个数应该相等,或者一个部分的个数比另一个部分的个数多一个。
- 分割线左边的所有数值 < = <= <= 分割线右边的所有数值。
-
首先满足第一个条件
-
如果两个有序数组长度之和为偶数的话,红线左边和右边部分的个数相等,即 m + n 2 \frac{m+n}{2} \quad 2m+n它的中位数为左边部分的最大值和右边部分的最小值的平均数。
-
如果两个有序数组之和为奇数的话,我们规定红线左边部分的个数比右边部分的个数多一个(当然你也可以反着)。因此左边部分的元素个数为: m + n + 1 2 \frac{m+n+1}{2}\quad 2m+n+1它的中位数为左边部分的最大值。
-
因为是向下取整,合并这两个公式为 k = m + n + 1 2 k=\frac{m+n+1}{2}\quad k=2m+n+1那么如果从第一个数组左侧取 a a a 个数,那么第二个数组左侧就取 b = k − a b=k-a b=k−a 个数。
-
-
接下来满足第二个条件
- 在同一数组内,因为是都是有序数组,所以一定满足分割线左边的元素小于等于右边的元素。
- 在不同数组之间,应该保证元素 n u m s 1 [ a ] > = n u m s 2 [ b − 1 ] nums1[a]>=nums2[b-1] nums1[a]>=nums2[b−1] 和 n u m s 1 [ a − 1 ] < = n u m s 2 [ b ] nums1[a-1]<=nums2[b] nums1[a−1]<=nums2[b]。 n u m s 1 [ a ] nums1[a] nums1[a] 和 n u m s 2 [ b ] nums2[b] nums2[b] 分别为第一和第二个数组分割线右侧的元素,因此 n u m s 1 [ a ] nums1[a] nums1[a] 之前有 a a a 个数, n u m s 2 [ b ] nums2[b] nums2[b] 之前有 b = k − a b=k-a b=k−a 个数,只要存在 − 1 -1 −1 则为分割线左边的元素。
-
知道这两个条件后,我们通过二分查找来确定分割线 a a a 和 b = k − a b=k-a b=k−a 的值
- 首先在第一个数组 n u m s 1 nums1 nums1 的区间 [ 0 , m ] [0,m] [0,m] 中查找 a a a,让 l e f t left left 指向它的头部位置 0 0 0,让 r i g h t right right 指向它的尾部位置 m m m。(这里为什么不是和上面二分查找的举例一样指向 n − 1 n-1 n−1,而是指向了 n n n,在文章的最后我们会解释)
- 每次取最中间的分割线作为 a a a,则 b = k − a b=k-a b=k−a,然后判断第二个条件,如果 n u m s 1 [ a ] < n u m s 2 [ b − 1 ] nums1[a]<nums2[b-1] nums1[a]<nums2[b−1] 则将分割线 a a a 右移,在 [ a + 1 , r i g h t ] [a+1, right] [a+1,right]这个区间查找元素;反之,我们让最右侧等于分割线 a a a ,t t取它的相反区间 [ l e f t , m ] [left, m] [left,m]。
- 那么通过上述操作,极容易出现数组下标越界的两种情况。其中第一种发生在两个数组长度不一致的时候为:
我们可以看到为了使另一个长度较长的数组的分割线两侧都有元素,我们需要在较短的数组上确定分割线的位置,这样就不会出现访问数组下标越界的情况。
第二种情况为数组长度相等的时候:
这个在下面代码也有讨论。
-
找到分割线后,根据两个数组长度之和的奇偶性来计算对应的中位数。
- 如果为奇数的话 ,中位数为两个数组分割线左侧的两个数 n u m s 1 [ a − 1 ] nums1[a-1] nums1[a−1]和 n u m s 2 [ b − 1 ] nums2[b-1] nums2[b−1]中的最大值。
- 如果为偶数的话,中位数为分割线右侧部分的最小值(即 n u m s 1 [ a ] nums1[a] nums1[a]和 n u m s 2 [ b ] nums2[b] nums2[b]二者之一)和左侧部分的最大值(即 n u m s 1 [ a − 1 ] nums1[a-1] nums1[a−1]和 n u m s 2 [ b − 1 ] nums2[b-1] nums2[b−1]二者之一)。
class Solution
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
n = len(nums1)
m = len(nums2)
# 为了使第二个数组分割线左右两侧都有元素,不会出现访问数组下标越界的情况,保持nums1数组长度较短,nums2长度较长的状态
if n > m:
return self.findMedianSortedArrays(nums2, nums1)
# 合并后左侧应该有的数组长度
k = (n + m + 1) // 2
# 在区间[0,n]寻找分割线
# left指向开头
left = 0
# right指向nums1的末尾
right = n
while left < right:
# nums1分割线a
a = (left + right) // 2
# nums2分割线b
b = k - a
# 看到nums2[b-1]这种情况,我们就要考虑下标越不越界这种情况了,因为在取中位线的时候我们是上取整的,因此在[0,n]区间,我们是取不了n的,因此不用担心以上4种图的最后一种情况,在前三种中,b-1都不会造成下标越界。
if nums1[a] < nums2[b-1]:
left = a + 1
else:
right = a
a = left
b = k - a
# 取左侧最大值 float("-inf")为防止越界的操作
result1 = max(nums1[a-1] if a > 0 else float("-inf"), nums2[b-1] if b > 0 else float("-inf"))
if (m + n) % 2 == 1:
return result1
# 取右侧最小值
result2 = min(nums1[a] if a < n else float("inf"), nums2[b] if b < m else float("inf"))
return (result1 + result2) / 2
解释 r i g h t = n right=n right=n
在查找一个数的时候,我们用
r
i
g
h
t
=
n
−
1
right=n-1
right=n−1,这里不管是
l
e
f
t
left
left 还是
r
i
g
h
t
right
right 都是指向列表中的数字,最后返回的也是列表中的数字。
但是在本题中,我们寻找的是分割线的位置,而不是数字。假如第一行数组长度为偶数,用
r
i
g
h
t
=
n
right = n
right=n第一次遍历,如下图,整个数组的不同位置的分割线有
5
5
5条,
4
4
4个数字平均分为
2
2
2组,前后各两个,说明分割线在
b
b
b后面。
但是如果我们用
r
i
g
h
t
=
n
−
1
right = n-1
right=n−1,
(
0
+
3
)
/
/
2
=
1
(0+3)//2=1
(0+3)//2=1,如果是查找数字它指向了字母
b
b
b,但本题是分割数组,说明它将这
4
4
4个数字分成了左侧
1
1
1个数字和右侧
3
3
3个数字两部分,并不是左右侧各两个,那么说明我们并没有实现真正的二分法,它并没有实现二分查找,如果要用这种方法,可能第二次分割线才会右移,变到
b
b
b的后面,那这样我们就多遍历了一次。
class Solution:
def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
n = len(nums1)
m = len(nums2)
if n > m:
return self.findMedianSortedArrays(nums2, nums1)
k = (n + m + 1) // 2
left = 0
right = n - 1
# 和上面代码不一样的地方:left<=right
while left <= right:
a = (left + right ) // 2
b = k - a
if nums1[a] < nums2[b - 1]:
left = a + 1
else:
# 和上面一样都要-1,right会左移,因此可能会出现left=right的情况
right = a - 1
a = left
b = k - a
result1 = max(nums1[a - 1] if a > 0 else float("-inf"), nums2[b - 1] if b > 0 else float("-inf"))
if (m + n) % 2 == 1:
return result1
result2 = min(nums1[a] if a < n else float("inf"), nums2[b] if b < m else float("inf"))
return (result1 + result2) / 2
最后,以上这种会多出遍历次数,复杂度升高,但是我们可以调整分割线的计算方式,使 a = ( l e f t + r i g h t ) / / 2 a = (left + right ) // 2 a=(left+right)//2 变为 a = ( l e f t + r i g h t + 1 ) / / 2 a = (left + right + 1) // 2 a=(left+right+1)//2,其实就是把它变为了向上取整,这样分割线还是居中的。