4.寻找两个正序数组的中位数

59 阅读9分钟

题目描述如下:

给定两个大小分别为 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 == m
  • nums2.length == n
  • 0 <= m <= 1000
  • 0 <= n <= 1000
  • 1 <= m + n <= 2000
  • -106 <= nums1[i], nums2[i] <= 106

代码如下:

容易疑惑的问题: 。


1) 为什么要确保 nums1 是较短的数组?

结论:为了保证时间复杂度为 O(log(min(m,n)))、避免 j 出界、并且使实现更简单可靠。

详细原因

  • 我们对 划分点 inums1 上做二分查找(i 的搜索空间是 0..m)。如果 m 很大而 n 很小,二分在大的数组上会更慢 —— 为了满足题目复杂度要求,要在较短的数组上二分。
  • 公式 j = (m + n + 1) / 2 - i 依赖 i0..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 是“划分点/分割线”的索引,可以取 0m(共 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-1left = i+1 严格地收缩区间,因而不会死循环。

详细区分与直观理解

  • left <= right 常用于你要检查 mid 是否满足某个精确条件并可能立即返回的二分(比如“找精确等于 target 的索引”或像我们通过条件直接返回中位数)。每次迭代我们要么返回结果,要么把区间缩小(left = mid + 1right = mid - 1),区间是严格缩小的,最终 left > right 结束。
  • left < right 常用于那种“通过 right = mid(不 -1)或 left = mid + 1 的方式保留 mid 作为候选”的收敛写法(例如查找旋转数组最小值时),此时 right = mid 可能不改变 rightmid == right,所以必须用 left < right 保证 mid < right,从而避免死循环。

在本题为什么 <= 合适

  • 我们对 i 做搜索,i 的范围为 [0, m](整数点)。在每轮:

    • 若当前划分正确直接 return
    • maxLeft1 > minRight2,表示 i 太大,需要 right = i - 1(严格减小右边界);
    • maxLeft2 > minRight1,表示 i 太小,需要 left = i + 1(严格增大左边界)。
  • 这两种更新都会使 leftright 严格移动,因此 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=4T=7leftCount=(7+1)/2=4。若 i=3,则 j=1(左边是 nums1 全部 3 个 + nums2 的 1 个,总共 4 个)。
  • 如果用了 (m+n)/2 = 3,左边只有 3 个,会错过中位数的位置。

5) 为什么要对边界做特殊处理(i==0i==mj==0j==n)?

结论:为了避免越界访问并把“空部分”逻辑化为不影响比较的极值(即把不存在的左边看作 -∞,不存在的右边看作 +∞),从而使判断 maxLeft <= minRight 的表达式成立或不成立可直接使用。

更具体的理由

  • i == 0 时,nums1 的左半部分为空,不能访问 nums1[i-1](会越界)。但在比较 maxLeft 时,我们想表示“左半部分没有元素”,它对 max(left) 应该没贡献,相当于 -∞(因为任何实际值都会 >= -∞,不会阻止判断)。
  • i == m 时,nums1 的右半部分为空,不能访问 nums1[i];表示右半部分没有元素,相当于 +∞(因为任何实际值 <= +∞,不会阻止判断)。
  • 类似对 j == 0j == n 做同样处理。

为什么用 Integer.MIN_VALUEInteger.MAX_VALUE

  • 用这两个极值作为哨兵值,能在比较时自然与真实值比较且不会影响正确性:

    • 如果 maxLeft1 = Integer.MIN_VALUE,则 maxLeft1 <= minRight2 通常成立(除非 minRight2 也是极小),这符合“左边为空不阻止成立”的语义。
    • 如果 minRight1 = Integer.MAX_VALUE,则 maxLeft2 <= minRight1 通常成立。
  • 这样就可以把边界情况统一到普通比较逻辑中,不需要额外的分支判断。

举例

  • i = 0nums1 左为空):

    • maxLeft1 = Integer.MIN_VALUE
    • minRight1 = nums1[0](若存在)
    • 在判断 max(maxLeft1, maxLeft2) 时,maxLeft1 不会干扰真实的左边最大值计算。
  • i = mnums1 右为空):

    • minRight1 = Integer.MAX_VALUE,在比较右最小值时不产生错误影响。

要点:这种方式既避免越界,又让比较逻辑统一、简洁。


6) 这个问题的核心本质是什么?

一句话:把“找两个已排序数组合并后中位数”问题 转化为找一个合适的“划分点” ,使得合并后左半部分和右半部分满足 max(left) <= min(right),然后根据奇偶从左最大或左右中间两个值计算中位数。

更详细的本质与思路

  • 合并后中位数只与左右两边的边界元素有关(左边最大和右边最小),而不是要完整构造整个合并数组。

  • 我们不在值空间做二分(比如查找某个值),而是在划分点的索引空间上做二分:这是“在答案/分割点上做二分”的经典思路。

  • 目标是选择 inums1左边元素个数)和 jnums2左边元素个数)使得

    1. i + j = (m + n + 1) / 2(左边元素数正确)
    2. maxLeft1 <= minRight2maxLeft2 <= 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 = 7leftCount = (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。正确。