题目描述
给定两个大小分别为 m
和 n
的正序(从小到大)数组 nums1
和 nums2
。请你找出并返回这两个正序数组的 中位数。
要求算法的时间复杂度为 O(log (m+n))
。
示例:
1 | 输入:nums1 = [1,3], nums2 = [2] |
解题思路
这道题乍看之下,似乎可以通过合并两个数组后找出中位数来解决,但这样做的时间复杂度是 O(m+n)
,而题目要求 O(log(m+n))
。一看到对数级的时间复杂度,我们应该想到二分查找。
核心思想
本题的核心思想是将两个数组进行虚拟切分,使得:
- 左半部分的元素个数等于右半部分(或者多一个,当总数为奇数时)
- 左半部分的所有元素都小于或等于右半部分的所有元素
找到这样的切分后,中位数就可以轻松计算出来了。
假设有两个数组:
1 | nums1: [a1, a2, a3, a4, a5] |
我们的目标是在这两个数组中找出一个切分位置,将所有元素分为数量相等(或左侧多一个)的两部分:
1 | 左半部分:[a1, a2, a3 | b1, b2, b3, b4] |
当左半部分的最大值小于或等于右半部分的最小值时,我们就找到了正确的切分位置。中位数可以根据总元素个数的奇偶性计算:
- 元素总数为奇数:中位数 = 左半部分的最大值
- 元素总数为偶数:中位数 = (左半部分的最大值 + 右半部分的最小值) / 2
实现的关键点解析
1. 切分点的含义
重要的是,mid表示的是元素间的切分位置,而不是具体的元素。例如:
对于数组 [1, 3, 5, 7, 9]
:
- mid=0 表示在1之前切分:
[] | [1, 3, 5, 7, 9]
- mid=3 表示在7之前切分:
[1, 3, 5] | [7, 9]
- mid=5 表示在所有元素之后切分:
[1, 3, 5, 7, 9] | []
这就是为什么right初始化为len1而不是len1-1,因为我们需要考虑将所有元素都放在左半部分的情况。
2. 为什么使用 left <= right
而不是 left < right
?
在这个二分查找中,使用 left <= right
是必要的。当搜索区间收缩到只有一个元素时(即 left == right
),这个元素可能就是正确的切分点。如果使用 left < right
,循环会提前退出,可能会错过正确解。
特别是当nums1只有一个元素时,如果使用 left < right
,算法可能无法正确处理这种情况。
3. 为什么只检查 mid1 < len1 和 mid1 > 0,不检查 mid2 的边界?
这是一个很好的问题!算法中我们检查了:
1 | if mid1 < len1 && nums1[mid1] < nums2[mid2-1] { |
不需要检查mid2的边界,原因如下:
-
mid2的计算保证了某些边界条件:mid2 = halfLen - mid1,其中halfLen = (len1+len2+1)/2
-
mid2的范围是有保证的:
- 当mid1增大时,mid2减小,反之亦然
- 我们知道 0 <= mid1 <= len1,因此 (halfLen-len1) <= mid2 <= halfLen
-
算法保证了mid2的合理性:
- 我们总是对较短的数组进行二分(通过交换nums1和nums2确保len1 <= len2)
- 这确保了halfLen-len1 >= 0,即mid2 >= 0
- 同样,由于halfLen <= (len1+len2+1)/2 <= (len2+len2+1)/2,可以推导出mid2 <= len2
因此,对mid1进行边界检查已经足够,不需要显式检查mid2的边界。这是算法设计的巧妙之处。
4. 为什么halfLength计算要加1?
在代码中,我们看到halfLength的计算使用了:
1 | halfLength := (len1+len2+1)>>1 // 左半部分应该包含的元素个数 |
这里的+1
非常关键,它确保了在奇数长度的情况下,左半部分会多包含一个元素。让我们分析不同情况:
情况1:总长度为偶数
假设len1 + len2 = 8
- 使用
(8) >> 1 = 4
,左边和右边各4个元素 - 使用
(8 + 1) >> 1 = 4
,结果相同,左右各4个元素
情况2:总长度为奇数
假设len1 + len2 = 7
- 使用
(7) >> 1 = 3
,左边3个元素,右边4个元素 - 使用
(7 + 1) >> 1 = 4
,左边4个元素,右边3个元素
在处理中位数时,我们的代码逻辑是:
1 | // 如果两个数组的总长度为奇数,中位数就是左半部分的最大值 |
这里的算法设计假设:
- 奇数长度时,中位数是左半部分的最大值
- 偶数长度时,需要计算左半部分最大值和右半部分最小值的平均值
为了使这个逻辑正确工作,在奇数长度情况下,我们必须确保中位数在左半部分。例如,对于总长度7的数组,中位数是第4个元素,所以左半部分必须包含4个元素。
这就是为什么要使用(len1+len2+1)>>1
而不是(len1+len2)>>1
的原因:加上这个+1
确保了在奇数长度情况下,左半部分会多包含一个元素,从而包含了中位数,使得算法的判断逻辑能够正确工作。
完整代码实现
1 | /** |
实例分析
让我们通过一个具体例子来说明算法的执行过程:
假设 nums1 = [1, 3, 5],nums2 = [2, 4, 6]
-
初始化:len1=3, len2=3, halfLen=(3+3+1)/2=3, left=0, right=3
-
第一次迭代:
- mid1 = (0+3)/2 = 1,表示在nums1中[1] | [3,5]切分
- mid2 = 3-1 = 2,表示在nums2中[2,4] | [6]切分
- 检查:nums1[mid1-1]=1 <= nums2[mid2]=6 且 nums2[mid2-1]=4 > nums1[mid1]=3
- 需要增加mid1,所以left = mid1+1 = 2
-
第二次迭代:
- mid1 = (2+3)/2 = 2,表示在nums1中[1,3] | [5]切分
- mid2 = 3-2 = 1,表示在nums2中[2] | [4,6]切分
- 检查:nums1[mid1-1]=3 <= nums2[mid2]=4 且 nums2[mid2-1]=2 <= nums1[mid1]=5
- 找到了合适的切分点!
- 左半部分:[1, 3, 2],最大值是3
- 右半部分:[5, 4, 6],最小值是4
- 总元素个数为偶数,所以中位数 = (3+4)/2 = 3.5
复杂度分析
- 时间复杂度:O(log(min(m,n))),其中m和n分别是两个数组的长度。我们对较短的数组进行二分查找。
- 空间复杂度:O(1),只使用了常数额外空间。
总结
这道题的核心在于理解如何在两个有序数组中找到合适的切分点,使得:
- 左半部分和右半部分的元素个数大致相等
- 左半部分的所有元素小于等于右半部分的所有元素
关键点解析:
- mid表示元素间的切分位置,而不是元素本身
- 使用
left <= right
确保能检查所有可能的切分点 - right初始化为len1以考虑所有可能的切分情况
- 只需检查mid1的边界条件,因为算法设计已经保证了mid2的合理性
理解了这些关键点,这道Hard难度的题目就变得清晰易懂了。二分查找的应用远不止是在单一有序数组中查找元素,它还可以用来解决更复杂的问题,比如此题中的两个有序数组的中位数查找。