题目链接
4. Median of Two Sorted Arrays
题目描述
给定两个非降序数组nums1和nums2,大小分别为m和n,返回两个数组合并后的中位数。要求时间复杂度为O(log(m+n)).
示例
输入:nums1 = [1,3],nums2 = [2]
输出:2.00000
输入:nums1 = [1,2],nums2 = [3,4]
输出:2.50000
输入:nums1= [],nums2 = [1]
输出:1.0000
插一句题外话,这个题目在18年写过【死磕算法之1刷Leetcode】——找出两个有序数组的中位数【Median of Two Sorted Arrays】O(log(m+n)),在那篇文章里有提到时间复杂度为O(m+n)的解法,这篇文章将着重复习时间复杂度为O(log(m+n))的解法以及学习时间复杂度为O(log(min(m,n)))的解法,题目这种东西确实还是常温常新。
解决思路一
找中位数的问题和“将两个非降序数组nums1和nums2合并后进行非降序排列,得到数组nums,查找nums中第k个元素”是相通的。因此下面将先介绍查找合并排序数组中第k个元素的通用解决方法。
查找合并排序数组中第k个元素
给定两个非降序数组nums1和nums2,数组长度分别为m和n,将nums1和nums2合并后得到非降序数组nums,返回nums中第k个元素。
用二分搜索的思想和递归方法来解决这个问题,假设要找第k个值,每次就通过二分思想排除掉k/2个值。下面通过具体的例子说明。
由于每次要比较两个数组nums[k/2]的大小,需要考虑k/2超过剩余数组长度的情况,如下图所示。如果k/2超出剩余数组长度,如k/2 > len(nums1),那么比较nums1的第len(nums1)个元素和nums2的第k/2个元素即可。如果排除完元素后某个数组为空,就只从剩下的非空数组中找第k个元素。如下面这个例子所示。
这种思路可以用递归来实现,每次比较第min(k/2,len(nums1),len(nums2))个元素,把较小的元素及其前面的所有元素都排除,k也对应减去排除掉的元素个数,进入下一层递归。递归的出口就是k = 1或某个数组长度为0。
查找合并排序数组中的中位数
对于长度为m的数组nums1和长度为n的数组nums2,合并排序数组为nums。如果m+n为奇数,中位数为nums的第(m+n+1)/2个元素;如果m + n为偶数,中位数为合并排序数组的第(m+n)/2个元素和第(m+n+2)/2个元素相加后除以2。代码见下:
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
def getKthItem(nums1,nums2,k):
#递归退出条件
if len(nums1) == 0:
return nums2[k-1]
if len(nums2) == 0:
return nums1[k-1]
if(k == 1):
return min(nums1[0],nums2[0])
nums1_comparedindex = min(k//2,len(nums1)) - 1 #第i个元素对应的index为i-1
nums2_comparedindex = min(k//2,len(nums2)) - 1
if(nums1[nums1_comparedindex] > nums2[nums2_comparedindex]):
#nums2要排除一些元素
nums2 = nums2[nums2_comparedindex+1:] #直接舍弃[0,nums2_comparedindex]闭区间范围内的元素
k -= (nums2_comparedindex + 1)
else:
nums1 = nums1[nums1_comparedindex+1:]
k -= (nums1_comparedindex + 1)
return getKthItem(nums1,nums2,k) #这里要进行递归调用,不然最后会返回None,属于尾递归。
m = len(nums1)
n = len(nums2)
if(m + n) % 2 == 0:#和是偶数
return (getKthItem(nums1,nums2,(m+n)//2) + getKthItem(nums1,nums2,(m+n+2)//2))/2
else:
return getKthItem(nums1,nums2,(m+n+1)//2)
在代码中没有使用start1,end1,start2,end2这类表示nums1、nums2剩余元素范围的变量,而是对于不可能是第k个元素的部分数组元素直接舍弃,这样能够减少出错的概率。
时间复杂度和空间复杂度
由于常规过程中,每执行一次递归就减少k/2个元素,因此时间复杂度为log(k),又因为在计算过程中k = (m+n)//2或 (m+n+1)//2或 (m+n+2)//2,因此时间复杂度为O(log(m+n))。
由于代码中的递归属于尾递归(递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分),编译器不用保存栈帧,因此空间复杂度为O(1)。
解决思路二
从中位数的定义出发,中位数被定义为“按顺序排列的一组数据中居于中间位置的数,代表一个样本、种群或概率分布中的一个数值,其可将数值集合划分为相等的上下两部分。”
对于一个长度为m的数组nums1,有m+1个可选择的位置能将数组切成两部分。如下图所示:
先找出切分两个数组的位置(i,j),其中nums1[0],...nums[i-1],nums2[0],...nums[j-1]共同组成“左半部分”,nums1[i],...nums[m-1],nums2[j],...nums[n-1]共同组成“右半部分”。
如果m+n是偶数,中位数是合并升序排序数组中最中间两个数值的平均数。要求中位数,首先找到一种切分方式(i,j),使得切分后的数组满足下面两个条件:
1、左半部分元素个数(i+j)和右半部分元素个数(m+n-i-j)相等,整理得到。
2、max(nums1[i-1],nums2[j-1]) <= min(nums1[i],nums2[j]),即左半部分的最大值要小于等于右半部分的最小值。
那么合并排序数组的中位数就可以通过下式计算出来:[max(nums1[i-1],nums2[j-1])+min(nums1[i],nums2[j])] / 2,即左半部分最大值和右半部分最小值之和除以2。
如果m+n是奇数,中位数是合并升序排序数组中最中间的数。同样的,先找到一种切分方式(i,j),使得切分后的数组满足下面两个条件:
1、左半部分元素个数(i+j)比右半部分元素个数(m+n-i-j)多一个,整理得到 。
2、max(nums1[i-1],nums2[j-1]) <= min(nums1[i],nums2[j]),即左半部分的最大值要小于等于右半部分的最小值。
那么合并排序数组的中位数就是max(nums1[i-1],nums2[j-1]) ,即左半部分最大值。
关于(i,j)需要满足的第一个条件:
由于当m+n为偶数时,(m+n)/2和(m+n+1)/2相等(其中"/"向下取整),因此可以将m+n为奇数和m+n为偶数的第一个条件合并:无论m+n是奇数还是偶数,要寻找的目标切分方式(i,j)都要满足 。
接着,由于,可根据式
推出
,而j作为nums2的切分位置,j应当符合条件
。那么[(n-m+1)/2,(n+m+1)/2]应该在[0,n]的范围内。
因此m和n应该满足下面两个不等式:
整理不等式(1)得到,整理不等式(2)得到
,两者取交集得到
。
因此对于长度为m的nums1和长度为n的nums2,无论m+n是奇数还是偶数,切分方式(i,j)要满足的条件1可以表达为:并且m<=n。
关于(i,j)需要满足的第二个条件:
由于nums1和nums2都是升序序列,nums1[i-1]<nums1[i],nums2[j-1]<nums2[j],那么要想保证max(nums1[i-1],nums2[j-1]) <= min(nums1[i],nums2[j]),只需要保证nums1[i-1]<= nums2[j]以及nums2[j-1] <= nums1[i]。那么在寻找(i,j)时,对于nums1[i-1] > nums2[j]的情况以及nums2[j-1]>nums1[i]的情况要进行处理。
将第一个条件当作约束,初始化left = 0,right = m作为二分搜索i的边界,i = (left + right) /2,二分搜索的目标不是元素的索引,而是数组的切分点。如果(i,j)满足nums1[i-1] > nums2[j]或nums2[j-1]>nums1[i],要对left或right进行相应移动。因此在一个二分搜索的循环里:
1、如果nums1[i-1] > nums2[j],为了保证索引有效,需要约束i != 0且j != n。对于这种情况要令i减小,同时由于约束,j会自动变大,因此更新right = i-1,继续循环;
2、如果nums2[j-1]>nums1[i],为了保证索引有效,需要约束j != 0且i != m。对于这种情况要令i增大,同时由于约束,j会自动变小,因此更新left = i + 1,继续循环;
对于不满足nums1[i-1] > nums2[j]以及nums2[j-1]>nums1[i]的情况以及i,j为边界值的情况:
3、如果i == 0,nums1从最左边的位置(nums[0]前)被切为两部分,因此左边部分的最大值为nums2[j-1];同样的,如果j == 0,左边部分的最大值为nums2[i-1];对于其他情况左边部分最大值为max(nums1[i-1],nums2[j-1])。如果m+n为奇数,此处就可以把左半部分最大值作为中位数返回。
4、如果i == m,nums1从最右边的位置(nums[-1]后)被切成两部分,因此右边部分的最小值为nums2[j];同样的,如果j == n,右边部分的最小值为nums[i];对于其他情况右边部分最大值为min(nums1[i],nums2[j])。根据左半部分最大值和右半部分最大值计算中位数。
总结以上,给出解决思路二的具体步骤:
(1)判断len(nums1)和len(nums2)的大小,保证len(nums1)<=len(nums2)。如果len(nums1)>len(nums2),调用函数将传入参数互换位置即可。
(2)在条件一的约束下,利用第二个条件缩小二分搜索的搜索区间,最终确定i。
(3)根据满足条件一和条件二的(i,j)划分方法计算中位数。
下面举个例子,让算法的步骤更清楚。
举例说明
nums1=[4],nums2=[2,3]
解决思路二Python实现
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
m = len(nums1)
n = len(nums2)
if(m > n):
return self.findMedianSortedArrays(nums2,nums1)#保证第一个数组的元素个数小于等于第二个数组
left,right = 0,m#i的搜索区间为[left,right]
while(left <= right):
i = (left + right) // 2
j = (m + n + 1)//2 - i
if(i != 0 and j != n and nums1[i-1] > nums2[j]):
right = i - 1#i需要减小,因此缩小搜索区间为[left,i-1]
elif(j != 0 and i != m and nums2[j-1] > nums1[i]):
left = i + 1#i需要增大,因此缩小搜索区间为[i+1,right]
else:
#处理边界,确定左半部分最大值
if(i == 0):
left_max = nums2[j-1]
elif(j == 0):
left_max = nums1[i-1]
else:
left_max = max(nums1[i-1],nums2[j-1])
if((m+n) % 2 == 1): #如果数组个数和为奇数,left_max即为中位数,直接返回即可。
return left_max
#处理边界,确定右半部分最大值
if(i == m):
right_max = nums2[j]
elif(j == n):
right_max = nums1[i]
else:
right_max = min(nums1[i],nums2[j])
#计算中位数
return (left_max + right_max) /2
#不能把“根据奇数偶数计算中位数”这一步挪到最后,否则对于nums1为[],nums2中只有一个元素如[1]这样的情况,当i == 0时,j == n,如果继续求右半部分最大值,满足i==m(0)条件,会访问nums2[j],因此会报索引超出范围的错误。
需要注意的是,不能把“根据两数组个数和是奇数还是偶数计算中位数”这一步挪到最后,否则对于nums1为[],nums2中只有一个元素如[1]这样的情况,当i == 0时,j == n ==1,如果继续求右半部分最大值,满足i==m(0)条件,会访问nums2[j],因此会报索引超出范围的错误。
时间复杂度与空间复杂度
被二分搜索的i是长度较短的nums1的划分位置,因此时间复杂度为O(log(min(m,n)))。空间复杂度为O(1),因为没有开辟新数组。
这篇文章其实是将自己实现过程中的想法总结出来,希望能对读者有所帮助。感谢https://windliang.wang/2018/07/18/leetCode-4-Median-of-Two-Sorted-Arrays/的这篇总结,讲解的非常清楚。