题目描述如下:
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
算法的时间复杂度应该为 O(log (m+n)) 。
示例 1:
输入: nums1 = [1,3], nums2 = [2]
输出: 2.00000
解释: 合并数组 = [1,2,3] ,中位数 2
示例 2:
输入: nums1 = [1,2], nums2 = [3,4]
输出: 2.50000
解释: 合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5
提示:
nums1.length == mnums2.length == n0 <= m <= 10000 <= n <= 10001 <= m + n <= 2000-106 <= nums1[i], nums2[i] <= 106
代码如下:
容易疑惑的问题: 。
1) 为什么要确保 nums1 是较短的数组?
结论:为了保证时间复杂度为 O(log(min(m,n)))、避免 j 出界、并且使实现更简单可靠。
详细原因:
- 我们对 划分点
i在nums1上做二分查找(i的搜索空间是0..m)。如果m很大而n很小,二分在大的数组上会更慢 —— 为了满足题目复杂度要求,要在较短的数组上二分。 - 公式
j = (m + n + 1) / 2 - i依赖i在0..m。如果不保证m <= n,某些i值可能导致j出现在0..n之外(需要额外检查或逻辑),使实现更复杂或容易出错。把短的数组当作被搜索数组可保证j总能合理落在0..n范围内(经过合理的left/right收缩)。 - 实务上:交换一次数组非常廉价(只交换引用),却让后续证明与实现都干净利落。
举例:
若 m = 1000, n = 3,在 nums1 上二分会在 1000 长度上做 ~10 次循环;若先交换(把长度为 3 的数组作为 nums1),则只需 ~2 次循环,效率明显更好。
2) 为什么 right = m 而不是 m - 1?
结论:因为 i 是“划分点/分割线”的索引,可以取 0 到 m(共 m+1 个可能位置),所以 right 设为 m 才能包含 i = m 的情况。
更直观的解释(分割线模型) :
把 nums1 看作元素与元素之间的“分割线”。例如 nums1 = [a0, a1, a2],元素位置和分割线可视化为:
位置索引(元素): 0 1 2
元素: a0 a1 a2
分割线可取值 i: ^0 ^1 ^2 ^3
i 表示左部分包含 i 个元素:
i=0:左边没有元素(分割在最左边)i=1:左边是[a0]i=3:左边是[a0,a1,a2](右边为空)
因此 i 的合法范围是 0..m(包含 m),所以 right = m。
如果用 m-1 会怎样?
- 那样就不能允许
i = m(右半边为空)的情形,会漏掉一种合法划分,从而出错或需要额外处理。
3) 为什么 while (left <= right) 而不是 while (left < right)?
结论:两种写法都有其适用情形;在我们求划分 i 的场景中用 left <= right 更合适,因为我们允许 i 恰好等于 left == right 时被检查,并且每次循环都会通过 right = i-1 或 left = i+1 严格地收缩区间,因而不会死循环。
详细区分与直观理解:
left <= right常用于你要检查mid是否满足某个精确条件并可能立即返回的二分(比如“找精确等于 target 的索引”或像我们通过条件直接返回中位数)。每次迭代我们要么返回结果,要么把区间缩小(left = mid + 1或right = mid - 1),区间是严格缩小的,最终left > right结束。left < right常用于那种“通过right = mid(不 -1)或left = mid + 1的方式保留mid作为候选”的收敛写法(例如查找旋转数组最小值时),此时right = mid可能不改变right当mid == right,所以必须用left < right保证mid < right,从而避免死循环。
在本题为什么 <= 合适:
-
我们对
i做搜索,i的范围为[0, m](整数点)。在每轮:- 若当前划分正确直接
return; - 若
maxLeft1 > minRight2,表示i太大,需要right = i - 1(严格减小右边界); - 若
maxLeft2 > minRight1,表示i太小,需要left = i + 1(严格增大左边界)。
- 若当前划分正确直接
-
这两种更新都会使
left或right严格移动,因此while (left <= right)能保证最终收敛且不死循环。并且i有可能等于left == right时就是正确i,需要检查返回。
对比示例:
- 若在另一个问题中你用了
right = mid(而不是mid - 1),那left <= right会在left==right时导致mid==right==left执行right=mid导致不缩小区间 -> 可能死循环。这就是为什么那类问题要用left < right+right = mid的写法。
4) 为什么 j = (m + n + 1) / 2 - i 而不是 (m + n) / 2 - i?
结论:(m + n + 1) / 2 确保“左半部分”包含合并数组中必要的元素个数,并且在奇数长度时把中间元素放到左边,方便统一处理奇偶两种情况(用 max(left) 或平均值)。
推导与直观解释:
-
合并后数组总长度
T = m + n。 -
我们想把合并后数组分成左右两部分,使左边和右边元素数接近:
- 若
T是奇数(例如 7),中位数是第4(1-based)的元素。这时我们希望左边有4个元素,右边3个。 - 若
T是偶数(例如 8),中位数由第4与第5元素决定,此时把左边设为前4个元素也合理(或前T/2个)。
- 若
公式 leftCount = (T + 1) / 2:
- 若
T为奇数:(T + 1) / 2=(7+1)/2=4(左边 4 个,含中位数) - 若
T为偶数:(T + 1) / 2=(8+1)/2=4(左边仍为T/2)
所以 左边元素数量应为 leftCount = (m + n + 1) / 2。
在 nums1 中取 i 个,nums2 中取 j 个,使得 i + j = leftCount,因此 j = leftCount - i = (m + n + 1) / 2 - i。
为什么不直接用 (m+n)/2?
(m + n) / 2对偶/奇都会向下取整,若T是奇数会少 1,导致左边数量不足(中位数所在的那一项可能会被放到右边),使奇数情形处理复杂化。+1的写法统一奇偶处理:奇数时左边多1个元素(中位数在左),偶数时左右均等。
举例:
m=3, n=4→T=7→leftCount=(7+1)/2=4。若i=3,则j=1(左边是nums1全部 3 个 +nums2的 1 个,总共 4 个)。- 如果用了
(m+n)/2= 3,左边只有 3 个,会错过中位数的位置。
5) 为什么要对边界做特殊处理(i==0、i==m、j==0、j==n)?
结论:为了避免越界访问并把“空部分”逻辑化为不影响比较的极值(即把不存在的左边看作 -∞,不存在的右边看作 +∞),从而使判断 maxLeft <= minRight 的表达式成立或不成立可直接使用。
更具体的理由:
- 当
i == 0时,nums1的左半部分为空,不能访问nums1[i-1](会越界)。但在比较maxLeft时,我们想表示“左半部分没有元素”,它对max(left)应该没贡献,相当于-∞(因为任何实际值都会 >=-∞,不会阻止判断)。 - 当
i == m时,nums1的右半部分为空,不能访问nums1[i];表示右半部分没有元素,相当于+∞(因为任何实际值 <=+∞,不会阻止判断)。 - 类似对
j == 0和j == n做同样处理。
为什么用 Integer.MIN_VALUE 和 Integer.MAX_VALUE?
-
用这两个极值作为哨兵值,能在比较时自然与真实值比较且不会影响正确性:
- 如果
maxLeft1 = Integer.MIN_VALUE,则maxLeft1 <= minRight2通常成立(除非minRight2也是极小),这符合“左边为空不阻止成立”的语义。 - 如果
minRight1 = Integer.MAX_VALUE,则maxLeft2 <= minRight1通常成立。
- 如果
-
这样就可以把边界情况统一到普通比较逻辑中,不需要额外的分支判断。
举例:
-
i = 0(nums1左为空):maxLeft1 = Integer.MIN_VALUEminRight1 = nums1[0](若存在)- 在判断
max(maxLeft1, maxLeft2)时,maxLeft1不会干扰真实的左边最大值计算。
-
i = m(nums1右为空):minRight1 = Integer.MAX_VALUE,在比较右最小值时不产生错误影响。
要点:这种方式既避免越界,又让比较逻辑统一、简洁。
6) 这个问题的核心本质是什么?
一句话:把“找两个已排序数组合并后中位数”问题 转化为找一个合适的“划分点” ,使得合并后左半部分和右半部分满足 max(left) <= min(right),然后根据奇偶从左最大或左右中间两个值计算中位数。
更详细的本质与思路:
-
合并后中位数只与左右两边的边界元素有关(左边最大和右边最小),而不是要完整构造整个合并数组。
-
我们不在值空间做二分(比如查找某个值),而是在划分点的索引空间上做二分:这是“在答案/分割点上做二分”的经典思路。
-
目标是选择
i(nums1左边元素个数)和j(nums2左边元素个数)使得i + j = (m + n + 1) / 2(左边元素数正确)maxLeft1 <= minRight2且maxLeft2 <= minRight1(合并后左右是有序的)
-
一旦满足上述,就能直接计算中位数:
- 若
T奇数:中位数 =max(maxLeft1, maxLeft2) - 若
T偶数:中位数 =(max(maxLeft1, maxLeft2) + min(minRight1, minRight2)) / 2.0
- 若
为什么这很高效?
- 因为我们只在较短数组上做二分,搜索空间是
m+1个划分点,时间复杂度O(log m)(如果先保证m <= n,就是O(log(min(m,n))))。 - 没有真正合并数组,节省
O(m+n)时间和空间。
直观类比:
想象把两个堆叠的牌分成左右两摞(左摞放前半个元素,右摞放后半个元素),你只需调整从第一堆抽多少张(i),从第二堆抽多少张(j),直到左摞的最大牌不超过右摞的最小牌。中位数就是两摞分界处的那张(或两张的均值)。
简短实例回顾(把上面各点串起来)
nums1 = [1, 3, 8] (m=3),nums2 = [7, 9, 10, 11] (n=4)
T = 7 → leftCount = (7+1)/2 = 4
- 初始
left=0, right=m=3,二分得i=1, j=3→ 检查边界并调整(见前面逐步例子) - 最终找到
i=3, j=1满足:maxLeft1=8, maxLeft2=7, minRight1=+∞, minRight2=9 - 因为
T奇数,中位数 =max(8,7) = 8。正确。