深入理解 LeetCode 第 4 题:寻找两个正序数组的中位数

62 阅读7分钟

在算法学习的旅程中,有些题目像灯塔一样,不仅考察你对基础知识的掌握,更引导你走向更高阶的思维模式。LeetCode 第 4 题——“寻找两个正序数组的中位数”正是这样一道题。

表面上看,它只是要求我们求两个有序数组合并后的中位数;但深入挖掘,你会发现它蕴含着二分查找的精妙应用虚拟分割的思想以及边界处理的艺术。更重要的是,它迫使我们跳出“必须合并”的直觉陷阱,学会用更抽象、更高效的方式思考问题。

本文将带你从最朴素的想法出发,一步步推导出最优解,并彻底讲清楚其中的关键细节。


一、从暴力解法说起:为什么不能合并?

假设你刚看到这道题,最自然的想法是什么?
大概率是:把两个数组合并成一个有序数组,然后取中间的元素。

function findMedianSortedArrays(nums1, nums2) {
    const merged = [...nums1, ...nums2].sort((a, b) => a - b);
    const n = merged.length;
    if (n % 2 === 1) {
        return merged[Math.floor(n / 2)];
    } else {
        return (merged[n / 2 - 1] + merged[n / 2]) / 2;
    }
}

这个方法逻辑清晰,容易实现。但它有两个致命问题:

  1. 时间复杂度是 O(m + n) —— 即使使用归并(而非 sort),也需要遍历所有元素;
  2. 空间复杂度也是 O(m + n) —— 需要额外存储合并后的数组。

而题目明确要求时间复杂度为 O(log(m + n)) 。这意味着我们必须避免线性操作,转而使用对数级的搜索策略——也就是二分查找

但问题来了:二分查找通常用于单个有序数组,这里有两个数组,怎么用?

答案是:我们不直接找中位数,而是找一个“分割点”,使得左右两部分满足特定条件。


二、高效解法:完整参考代码

在深入分析前,先看最终的正确实现。这段代码能在 O(log(min(m, n))) 时间内解决问题:

var findMedianSortedArrays = function(nums1, nums2) {
    // 确保 nums1 是较短的数组,优化二分效率
    if (nums1.length > nums2.length) {
        [nums1, nums2] = [nums2, nums1];
    }

    const m = nums1.length;
    const n = nums2.length;
    const total = m + n;
    const half = Math.ceil(total / 2); // 左半部分应包含的元素个数

    let l = 0;
    let r = m; // 注意:右边界是 m,不是 m - 1!

    while (l <= r) {
        const i = l + Math.floor((r - l) / 2); // nums1 的分割点
        const j = half - i;                    // nums2 的分割点

        // 边界处理:用 ±Infinity 代替越界访问
        const left1 = i === 0 ? -Infinity : nums1[i - 1];
        const right1 = i === m ? Infinity : nums1[i];
        const left2 = j === 0 ? -Infinity : nums2[j - 1];
        const right2 = j === n ? Infinity : nums2[j];

        // 检查当前分割是否合法:左 ≤ 右
        if (left1 <= right2 && left2 <= right1) {
            if (total % 2 === 1) {
                return Math.max(left1, left2); // 奇数:左半最大即中位数
            } else {
                return (Math.max(left1, left2) + Math.min(right1, right2)) / 2;
            }
        }

        // 调整二分搜索方向
        if (right1 < left2) {
            l = i + 1; // nums1 右侧太小,需向右移动分割点
        } else {
            r = i - 1; // nums1 左侧太大,需向左移动分割点
        }
    }

    throw new Error("No valid partition found");
};

接下来,我们将逐层拆解这段代码背后的五大核心思想。


三、核心思想一:用“分割点”代替“合并”

很多人卡在这道题的第一步:题目说“两个数组的中位数”,直觉就是先合并。但合并意味着必须遍历所有元素,无法达到对数时间复杂度。

关键突破在于换个角度思考:中位数的本质,是把整个有序序列切成左右两半的那个“切口”

我们并不关心元素具体怎么排列,只关心:

  • 左半部分有多少个元素;
  • 左半部分的最大值是多少;
  • 右半部分的最小值是多少。

既然两个数组本身有序,我们完全可以在每个数组内部选一个“切口”,把它们各自分成左右两段。只要这两刀切得恰到好处,使得:

  • 左边所有元素 ≤ 右边所有元素;
  • 左边总元素数符合中位数要求;

那么合并后的中位数就完全由这四个边界值决定。

我们从未真正合并,却得到了合并后的结果——这就是“虚拟分割”的威力。


四、核心思想二:如何定义分割点?

在代码中,ij 就是两个分割点:

const i = l + Math.floor((r - l) / 2);
const j = half - i;
  • i 表示在 nums1 中,i 个元素属于左半部分,即索引范围 [0, i)
  • j 表示在 nums2 中,j 个元素属于左半部分,即索引范围 [0, j)

注意:i 的取值范围是 0m(共 m+1 种可能),因为:

  • i = 0nums1 全在右边;
  • i = mnums1 全在左边。

为了让左右平衡,左半部分总元素数必须为:

const half = Math.ceil(total / 2);

为什么用 ceil

  • 总长度为奇数(如 5):左半需 3 个元素,中位数在左半最大;
  • 总长度为偶数(如 4):左半需 2 个元素,中位数由左右边界共同决定。

ceil 可以统一处理这两种情况。

于是问题简化为:nums1 上找一个 i,使得分割后满足有序性


五、核心思想三:为什么用 -Infinity 和 Infinity 处理边界?

当我们尝试某个 i 时,需要获取 nums1[i - 1](左最大)和 nums1[i](右最小)。但如果 i = 0i = m,就会越界。

传统做法是写一堆 if 判断,但这里有个优雅解法:

const left1 = i === 0 ? -Infinity : nums1[i - 1];
const right1 = i === m ? Infinity : nums1[i];
  • i = 0nums1 左边为空,其“最大值”设为 -Infinity,因为任何实际数字都比它大;
  • i = mnums1 右边为空,其“最小值”设为 Infinity,因为任何实际数字都比它小。

这样,无论 ij 取何值,我们都能安全地写出统一判断条件:

if (left1 <= right2 && left2 <= right1)

这不仅让代码简洁,更体现了算法设计中的“抽象思维”:用理想化的数学概念处理现实中的边界。


六、核心思想四:找到分割点后,中位数怎么算?

一旦满足条件,说明分割成功。此时:

  • 整个左半部分的最大值是 Math.max(left1, left2)
  • 整个右半部分的最小值是 Math.min(right1, right2)

根据总长度奇偶性决定:

if (total % 2 === 1) {
    return Math.max(left1, left2); // 奇数:左半最大即中位数
} else {
    return (Math.max(left1, left2) + Math.min(right1, right2)) / 2;
}

例如:

  • 合并后 [1,2,3] → 左半 [1,2] 最大是 2 → 中位数 2;
  • 合并后 [1,2,3,4] → 左半最大 2,右半最小 3 → 中位数 2.5。

这种计算方式直接源于中位数的定义,无需额外逻辑。


七、核心思想五:为什么二分搜索的右边界必须是 r = m?

这是最容易出错、也最值得深究的一点。

很多初学者会写 r = m - 1,理由是“数组下标最大是 m - 1”。但这里 i 不是下标,而是分割位置

考虑这个例子:

nums1 = [1, 2], nums2 = [3, 4]

正确分割是 i = 2nums1 全在左),j = 0nums2 全在右)。

如果初始化为:

let r = m - 1; // 错误!

那么 i 最大只能是 1,程序永远不会尝试 i = 2,导致漏解。

i = m 的含义是:在 nums1 最后一个元素之后切一刀,把所有元素划入左半部分。这是完全合法且必要的场景。

因此,必须写:

let r = m; // 包含 i = m 的可能性

这就像在一排 m 个物品之间及两端插隔板,共有 m + 1 个位置可选。少一个,就可能错过正确答案。


八、总结:这道题教会我们的思维方式

核心思想可以归纳为:

  • 抽象代替具体:不真正合并数组,而是通过“虚拟分割”模拟合并后的结构;
  • 数学统一处理:用 ceil 统一奇偶情况,用 ±Infinity 统一边界情况;
  • 搜索空间全覆盖:理解 i 的真实含义,确保二分范围包含所有可能解;
  • 问题转化能力:把“找中位数”转化为“找满足条件的分割点”。