LeetCode 4. 寻找两个正序数组的中位数:二分优化思路详解

0 阅读6分钟

在LeetCode的Hard题目中,「寻找两个正序数组的中位数」绝对是经典中的经典。它不仅考察对中位数概念的理解,更核心的是对时间复杂度的极致要求——O(log (m+n)),这就意味着暴力合并数组(O(m+n))的思路直接出局,必须用到二分查找的思想来优化。

今天就来一步步拆解这道题,从题目分析到代码实现,再到细节坑点,带你彻底搞懂如何用二分法高效求解,同时吃透给出的代码逻辑。

一、题目回顾:明确需求与核心难点

题目给出两个正序(从小到大)排列的数组nums1和nums2,大小分别为m和n,要求找出这两个数组的中位数,并且算法的时间复杂度必须是O(log (m+n))。

先明确中位数的定义:将两个数组合并后,按从小到大排序,若总长度为奇数,中位数是中间位置的数;若为偶数,中位数是中间两个数的平均值。

核心难点在于「时间复杂度O(log (m+n))」。二分查找的时间复杂度是O(log k)(k为查找范围),因此我们需要将问题转化为“在两个正序数组中,查找第k小的数”——而中位数,本质上就是第「(m+n+1)/2」小的数(奇数情况),或第「(m+n)/2」和「(m+n)/2 +1」小的数的平均值(偶数情况)。

二、核心思路:二分法缩小查找范围

我们的目标是找到第k小的数(k = Math.floor((totalLen + 1) / 2),totalLen = m + n),核心思想是「每次排除一半不可能是第k小的元素」,从而将查找范围缩小一半,达到log级别的时间复杂度。

具体逻辑如下:

  1. 初始化两个偏移量offset1、offset2,分别表示nums1、nums2中已经排除的元素个数(即当前待查找的起始位置)。

  2. 每次从两个数组的当前起始位置开始,各取k/2个元素(k为当前剩余待查找的元素个数),比较这两个位置的元素大小。

  3. 若nums1的第(offset1 + k/2 -1)个元素小于nums2的对应位置元素,则说明nums1中从offset1到该位置的所有元素,都不可能是第k小的数,可直接排除(offset1 += k/2);反之则排除nums2中的对应元素(offset2 += k/2)。

  4. 重复上述步骤,直到offset1 + offset2等于k,此时找到的最大元素即为第k小的数(leftMax)。

  5. 根据总长度是奇数还是偶数,计算最终的中位数:奇数直接返回leftMax,偶数则需要找到leftMax的下一个最小元素,取两者的平均值。

三、代码逐行解析:吃透每一个细节

给出的代码已经实现了上述思路,并且处理了所有边界情况,下面逐行拆解,帮你理清每一步的作用。

1. 初始化变量

const len1: number = nums1.length;
const len2: number = nums2.length;
const totalLen: number = len1 + len2;
const medianIndex: number = Math.floor((totalLen + 1) / 2);
let offset1 = 0; // nums1的排除偏移量
let offset2 = 0; // nums2的排除偏移量
let leftMax = -Infinity; // 记录第k小的数(leftMax)

这里的关键是medianIndex的计算:无论总长度是奇数还是偶数,我们先找到「第medianIndex小的数」(leftMax)。比如总长度为5(奇数),medianIndex=3,leftMax就是第3小的数(中位数);总长度为4(偶数),medianIndex=2,leftMax是第2小的数,后续再找第3小的数,取两者平均即可。

2. 二分查找核心循环

while (offset1 + offset2 < medianIndex) {
  let k = medianIndex - offset1 - offset2; // 当前剩余待查找的元素个数
  k = Math.max(1, Math.floor(k / 2)); // 每次取k/2个元素,避免k为0
  let left1 = offset1 + k - 1; // nums1中待比较的位置
  let left2 = offset2 + k - 1; // nums2中待比较的位置

  // 处理数组越界:若待比较位置超出数组长度,视为无穷大(无法被选中)
  let val1 = left1 < len1 ? nums1[left1] : Infinity;
  let val2 = left2 < len2 ? nums2[left2] : Infinity;

  // 排除不可能的元素,更新leftMax和偏移量
  if (val1 > val2) {
    leftMax = Math.max(leftMax, val2);
    offset2 += k; // 排除nums2中offset2到left2的元素
  } else if (val1 < val2) {
    leftMax = Math.max(leftMax, val1);
    offset1 += k; // 排除nums1中offset1到left1的元素
  } else {
    // 两元素相等,同时排除,leftMax取该值
    leftMax = val1;
    offset1 += k;
    offset2 += k;
  }
}

这部分是整个算法的核心,重点注意3个细节:

  • k的计算:每次k是“剩余待查找的元素个数”,取k/2是为了每次排除一半元素;Math.max(1, ...)是避免k为0(比如剩余1个元素时,k=1)。

  • 越界处理:当left1超出nums1长度时,val1设为Infinity,意味着nums1中没有更多元素可排除,只能排除nums2的元素;反之同理。

  • leftMax的更新:每次排除元素时,要记录被排除元素中的最大值——因为这些被排除的元素都比第k小的数小,最终leftMax就是第k小的数。

3. 计算最终中位数(分奇偶情况)

if (totalLen % 2 === 0) {
  // 新增:两数组均遍历完,右半最小值等于左半最大值(所有元素已处理)
  if (offset1 === len1 && offset2 === len2) {
    return leftMax; // 此时leftMax就是中间值,两数平均后仍等于leftMax
  }
  if (offset1 === len1) {
    return (leftMax + nums2[offset2]) / 2;
  }
  if (offset2 === len2) {
    return (leftMax + nums1[offset1]) / 2;
  }
  return (leftMax + Math.min(nums1[offset1], nums2[offset2])) / 2;
} else {
  return leftMax;
}

这里处理了所有边界情况,尤其是新增的“两数组均遍历完”的场景(虽然实际中很少出现,但能避免异常):

  • 奇数情况:直接返回leftMax(第medianIndex小的数,就是中位数)。

  • 偶数情况:需要找到leftMax的下一个最小元素(即当前两个数组未排除部分的第一个元素的最小值),取两者平均。

  • 边界处理:若其中一个数组已全部排除(offset等于数组长度),则下一个最小元素就是另一个数组当前起始位置的元素。

四、关键坑点与优化说明

1. 坑点:越界处理

如果不处理left1、left2越界的情况,会导致数组下标异常。将越界后的val设为Infinity,能正确引导程序排除未越界数组的元素,避免错误。

2. 坑点:k的取值

必须用Math.max(1, Math.floor(k/2)),否则当k=1时,Math.floor(k/2)=0,会导致left1=offset1-1,出现负下标异常。

3. 优化点:新增的边界判断

代码中新增的“两数组均遍历完”的判断,虽然极端情况才会触发(比如两个数组长度之和刚好等于medianIndex),但能避免程序在特殊情况下返回错误结果,让代码更健壮。

五、总结

这道题的核心是「将中位数问题转化为第k小元素问题」,通过二分法每次排除一半不可能的元素,实现O(log (m+n))的时间复杂度。给出的代码不仅正确实现了该思路,还处理了所有边界情况,尤其是新增的两数组遍历完的判断,让代码更健壮。

其实二分法的难点在于“确定每次排除哪些元素”,只要抓住“比较两个数组的k/2位置元素,排除较小的那部分”这个核心,就能理清整个逻辑。