从合并到二分:深入理解“寻找两个正序数组的中位数”

75 阅读6分钟

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

在力扣上刷到一个感觉有意思的算法题,感觉是一个不错的二分题
题目:寻找两个正序数组的中位数 - 力扣(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 右半区的最小值

这样就能保证整个“虚拟合并数组”的有序性。

一个图大家应该就能理解了:

image.png

后面的判断就比较简单了:

  • 如果总长度是奇数,中位数就是左半区最大值;
  • 如果是偶数,就需要用左半区最大值和右半区最小值求平均。
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) 这个条件绕晕,但一旦理解了它是在确保跨数组的有序性,整个逻辑就豁然开朗了。

最后,这道题也提醒我:刷题时一定要看清题目要求!
差点因为忽略时间复杂度限制,错过一次深入理解二分查找本质的机会 😅。