寻找两个正序数组的中位数
在力扣上刷到一个感觉有意思的算法题,感觉是一个不错的二分题
题目:寻找两个正序数组的中位数 - 力扣(LeetCode)
解法一:合并数组
最初没看清楚题目,以为要求的时间复杂度是 O(m+n),很快想到合并数组,感觉困难题有点水。后面解完发现不对,排名还挺靠后,再仔细一看才发现题目要求的是 O(log(m+n)) 的时间复杂度。
这种解法很简单:分别遍历两个数组,把每次较小的值 push 进新数组,然后移动指针即可,最后处理一下奇偶的计算就能通过了。
var findMedianSortedArrays = function(nums1, nums2) {
let merge = [];
let i = 0, j = 0;
// 归并两个有序数组
while (i < nums1.length && j < nums2.length) {
if (nums1[i] <= nums2[j]) {
merge.push(nums1[i++]);
} else {
merge.push(nums2[j++]);
}
}
// 添加剩余元素
while (i < nums1.length) merge.push(nums1[i++]);
while (j < nums2.length) merge.push(nums2[j++]);
// 计算中位数
const len = merge.length;
if (len % 2 === 1) {
return merge[Math.floor(len / 2)];
} else {
return (merge[len / 2 - 1] + merge[len / 2]) / 2;
}
};
解法二:二分查找
看到 O(log(m+n)) 的时间复杂度,很容易就想到了二分查找。但是两个独立的数组不能直接二分,想了很久没什么结果。后面看了题解,感觉这种“切割点”式的二分做法很有意思。下面是代码和我的一些个人理解。
题目本质
对于题目中的中位数,我们可以理解为:在两个数组合并后的数组(抽象的数组)中寻找第 K 小的数,K 就是两数组长度和的一半(需要统一处理奇偶情况)。
那么我们就可以抽象地理解:将两个数组都拆分为左右两部分,它们的左半部分共同构成抽象数组的左半区,右半部分共同构成右半区。而中位数就是左半区的最大值和右半区的最小值的平均值。
于是,这道题就变成了:寻找两个数组应该在哪里进行切割。所以,“寻找切割点”就是这道题的核心。
var findMedianSortedArrays = function(nums1, nums2) {
// 确保 nums1 是较短的数组,优化二分效率
if (nums1.length > nums2.length) {
[nums1, nums2] = [nums2, nums1];
}
const m = nums1.length;
const n = nums2.length;
const halfLen = Math.floor((m + n + 1) / 2); // 左半部分应有元素数
let left = 0, right = m;
while (left <= right) {
const i = Math.floor((left + right) / 2); // nums1 的切割点
const j = halfLen - i; // nums2 的切割点
// 边界处理:避免数组越界
const nums1Left = (i === 0) ? -Infinity : nums1[i - 1];
const nums1Right = (i === m) ? Infinity : nums1[i];
const nums2Left = (j === 0) ? -Infinity : nums2[j - 1];
const nums2Right = (j === n) ? Infinity : nums2[j];
// 检查是否找到合法划分
if (nums1Left <= nums2Right && nums2Left <= nums1Right) {
if ((m + n) % 2 === 1) {
// 奇数:中位数 = 左半部分最大值
return Math.max(nums1Left, nums2Left);
} else {
// 偶数:中位数 = (左最大 + 右最小) / 2
return (Math.max(nums1Left, nums2Left) + Math.min(nums1Right, nums2Right)) / 2;
}
}
// 调整二分方向
else if (nums1Left > nums2Right) {
// nums1 左边太大 → 减小 i
right = i - 1;
} else {
// nums2 左边太大 → 增大 i
left = i + 1;
}
}
};
基础的逻辑:
-
一直让
nums1是较小的那个数组,方便后面处理if (nums1.length > nums2.length) { [nums1, nums2] = [nums2, nums1]; } -
const halfLen = Math.floor((m + n + 1) / 2);
这里表示整个左半区应该有多少个元素(即总元素个数的一半,向上取整)。 -
开始二分查找切割点
如果从nums1数组取i个数,那么就要从nums2数组中取halfLen - i个数,以保证左半区元素总数正确。const i = Math.floor((left + right) / 2); const j = halfLen - i; -
边界处理和边界值更新
const nums1Left = (i === 0) ? -Infinity : nums1[i - 1]; // nums1 的左半区最大值 const nums1Right = (i === m) ? Infinity : nums1[i]; // nums1 的右半区最小值 const nums2Left = (j === 0) ? -Infinity : nums2[j - 1]; // nums2 的左半区最大值 const nums2Right = (j === n) ? Infinity : nums2[j]; // nums2 的右半区最小值 -
切割点调整
这里相信也好理解:如果当前左半区的最大值比右半区的最小值还大,说明划分不合理,需要调整切割点,使得左边元素恒小于右边。else if (nums1Left > nums2Right) { // nums1 左边太大 → 减小 i right = i - 1; } else { // nums2 左边太大 → 增大 i left = i + 1; }
难理解的点:
在理解过程中,我觉得比较让我头晕的部分就是关于合法划分的判断:
if (nums1Left <= nums2Right && nums2Left <= nums1Right)
对于这个判断一开始没明白,后面搜集了一些资料才理解:
这里其实是在确保两个数组拆分后构成的左半区所有元素 ≤ 右半区所有元素。
因为每个数组本身是有序的,所以只需保证:
nums1左半区的最大值 ≤nums2右半区的最小值nums2左半区的最大值 ≤nums1右半区的最小值
这样就能保证整个“虚拟合并数组”的有序性。
一个图大家应该就能理解了:
后面的判断就比较简单了:
- 如果总长度是奇数,中位数就是左半区最大值;
- 如果是偶数,就需要用左半区最大值和右半区最小值求平均。
if ((m + n) % 2 === 1) {
// 奇数:中位数 = 左半部分最大值
return Math.max(nums1Left, nums2Left);
} else {
// 偶数:中位数 = (左最大 + 右最小) / 2
return (Math.max(nums1Left, nums2Left) + Math.min(nums1Right, nums2Right)) / 2;
}
总结
这道题从表面看是求中位数,实则考察的是如何在不真正合并数组的前提下,通过逻辑划分模拟合并后的结构。
解法一虽然直观,但时间和空间复杂度都较高,无法满足题目对 O(log(m + n)) 的要求;而解法二通过二分查找切割点,巧妙地将问题转化为“寻找一个合法的划分位置”,使得左半区的最大值 ≤ 右半区的最小值,从而在 O(log(min(m, n))) 时间内高效求解。
整个过程让我深刻体会到:
算法的优化往往不是“更快地做同一件事”,而是“换一种方式思考问题”。
尤其是“切割点”的抽象思维——不需要真实合并,只需维护左右边界关系——这种思想在很多高级算法题中都有体现(比如找第 k 小的数、有序矩阵搜索等)。
虽然一开始被 if (nums1Left <= nums2Right && nums2Left <= nums1Right) 这个条件绕晕,但一旦理解了它是在确保跨数组的有序性,整个逻辑就豁然开朗了。
最后,这道题也提醒我:刷题时一定要看清题目要求!
差点因为忽略时间复杂度限制,错过一次深入理解二分查找本质的机会 😅。