在算法学习的旅程中,有些题目像灯塔一样,不仅考察你对基础知识的掌握,更引导你走向更高阶的思维模式。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;
}
}
这个方法逻辑清晰,容易实现。但它有两个致命问题:
- 时间复杂度是 O(m + n) —— 即使使用归并(而非 sort),也需要遍历所有元素;
- 空间复杂度也是 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");
};
接下来,我们将逐层拆解这段代码背后的五大核心思想。
三、核心思想一:用“分割点”代替“合并”
很多人卡在这道题的第一步:题目说“两个数组的中位数”,直觉就是先合并。但合并意味着必须遍历所有元素,无法达到对数时间复杂度。
关键突破在于换个角度思考:中位数的本质,是把整个有序序列切成左右两半的那个“切口” 。
我们并不关心元素具体怎么排列,只关心:
- 左半部分有多少个元素;
- 左半部分的最大值是多少;
- 右半部分的最小值是多少。
既然两个数组本身有序,我们完全可以在每个数组内部选一个“切口”,把它们各自分成左右两段。只要这两刀切得恰到好处,使得:
- 左边所有元素 ≤ 右边所有元素;
- 左边总元素数符合中位数要求;
那么合并后的中位数就完全由这四个边界值决定。
我们从未真正合并,却得到了合并后的结果——这就是“虚拟分割”的威力。
四、核心思想二:如何定义分割点?
在代码中,i 和 j 就是两个分割点:
const i = l + Math.floor((r - l) / 2);
const j = half - i;
i表示在nums1中,前i个元素属于左半部分,即索引范围[0, i);j表示在nums2中,前j个元素属于左半部分,即索引范围[0, j)。
注意:i 的取值范围是 0 到 m(共 m+1 种可能),因为:
i = 0:nums1全在右边;i = m:nums1全在左边。
为了让左右平衡,左半部分总元素数必须为:
const half = Math.ceil(total / 2);
为什么用 ceil?
- 总长度为奇数(如 5):左半需 3 个元素,中位数在左半最大;
- 总长度为偶数(如 4):左半需 2 个元素,中位数由左右边界共同决定。
用 ceil 可以统一处理这两种情况。
于是问题简化为:在 nums1 上找一个 i,使得分割后满足有序性。
五、核心思想三:为什么用 -Infinity 和 Infinity 处理边界?
当我们尝试某个 i 时,需要获取 nums1[i - 1](左最大)和 nums1[i](右最小)。但如果 i = 0 或 i = m,就会越界。
传统做法是写一堆 if 判断,但这里有个优雅解法:
const left1 = i === 0 ? -Infinity : nums1[i - 1];
const right1 = i === m ? Infinity : nums1[i];
- 当
i = 0,nums1左边为空,其“最大值”设为-Infinity,因为任何实际数字都比它大; - 当
i = m,nums1右边为空,其“最小值”设为Infinity,因为任何实际数字都比它小。
这样,无论 i 和 j 取何值,我们都能安全地写出统一判断条件:
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 = 2(nums1 全在左),j = 0(nums2 全在右)。
如果初始化为:
let r = m - 1; // 错误!
那么 i 最大只能是 1,程序永远不会尝试 i = 2,导致漏解。
i = m 的含义是:在 nums1 最后一个元素之后切一刀,把所有元素划入左半部分。这是完全合法且必要的场景。
因此,必须写:
let r = m; // 包含 i = m 的可能性
这就像在一排 m 个物品之间及两端插隔板,共有 m + 1 个位置可选。少一个,就可能错过正确答案。
八、总结:这道题教会我们的思维方式
核心思想可以归纳为:
- 抽象代替具体:不真正合并数组,而是通过“虚拟分割”模拟合并后的结构;
- 数学统一处理:用
ceil统一奇偶情况,用±Infinity统一边界情况; - 搜索空间全覆盖:理解
i的真实含义,确保二分范围包含所有可能解; - 问题转化能力:把“找中位数”转化为“找满足条件的分割点”。