寻找两个正序数组的中位数——双数组的固定宽度的滑动窗口

发布时间 2023-05-24 22:21:38作者: Revc

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

给定两个大小分别为 mn 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数

算法的时间复杂度应该为 O(log (m+n))

示例 1:

输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2

示例 2:

输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5

提示:

  • nums1.length == m
  • nums2.length == n
  • 0 <= m <= 1000
  • 0 <= n <= 1000
  • 1 <= m + n <= 2000
  • -10^6 <= nums1[i], nums2[i] <= 10^6

定义

中位数

对于多重集 \(A\)(允许重复元素),令 \(X = (x_1, x_2, \cdots, x_n)\),其中 \(x_i \in A\)。此时,中位数 \(Q_{1/2}\) 满足如下等式,

\[ Q_{1/2} = \begin{cases} x_{|A|+1/2} & \text{if } |A| \text{ is odd.} \\ 1/2(x_{|A|/2} + x_{|A|/2+1}) & \text{if } |A| \text{ is even.} \end{cases} \]

滑动窗口

滑动窗口由一个左边界和一个右边界确定,包含了一个子数组(数组中一段连续元素产生的新数组)。通过检查窗口内的元素的性质,来“滑动”窗口——更改左边界或者右边界。

题解

要获取中位数,我们需要找到一个 \(x_m\),当 \(B = \{x \le x_m \mid x \in A \wedge x \text{ is not } x_m \}\)\(C = \{x \ge x_m \mid x \in A \wedge x \text{ is not } x_m \}\),使得 \(|B| = |C|\) 或者 \(|B| = |C| - 1\)。换一句话说,我们要找到一个数,使得集合中小于等于该数的元素数量 等于 大于等于该数的元素数量(或者减去 1)。此时,我们将集合划分成了两个部分——“小数部分”和“大数部分”。

题目的输入是两个数组,所以这里的集合就是这两个数组的所有元素。我们要将这两个数组的所有元素划分成两个部分——“小数部分”和“大数部分”。这里假设两个数组的长度分别为 \(m\)\(n\),那么集合中小于等于 \(x_m\) 的元素数量加上 \(x_m\) 的个数为 \(\lfloor \frac{m+n+1}{2} \rfloor\),也就是“小数部分”的元素个数为 \(\lfloor \frac{m+n+1}{2} \rfloor\)

已知两个数组是升序排列的,为了找到两个数组的“小数部分”,我们将两个数组的较小端拼接在一起,同时放置一个宽度为 \(\lfloor \frac{m+n+1}{2} \rfloor\) 的窗口。通过为窗口找到一个合适的位置,使得窗口内的所有元素 小于等于 窗口外的所有元素,进而我们可以找到两个数组的“小数部分”。

现在,我们已经有了窗口,接下来我们要思考如何在两个数组中进行窗口的滑动。

如何在两个数组中滑动窗口

假设窗口在左边数组的元素个数为 cntA,窗口在右边数组的元素个数为 cntB,因为窗口的宽度是固定的,所以一旦我们得知了 cntA 也就可以得知 cntB 了。此时,cntAcntB 定义了窗口的左边界和右边界。我们通过减小 cntA 向右滑动,增加 cntA 向左滑动。

如何确定滑动的方向

我们通过检查窗口的左边界的右侧和右边界的左侧中较大的那个值来确定窗口内元素的最大值 max;我们通过左边界左侧和右边界右侧的值来确定窗口外元素的最小值 min。如果 max < min,那就证明窗口内的元素不能构成“小数部分”,我们需要滑动窗口。一个基本策略是减小窗口内的最大值,同时增大窗口外的最小值。

考虑两种情况来选择窗口的滑动方向:

  1. 窗口的左边界的右侧大于窗口右边界的右侧,此时说明窗口在左侧数组中的元素太大了,我们可以向右移动来减小窗口在左侧数组中的值。
  2. 窗口的右边界的左侧大于窗口左边界的左侧,此时说明窗口在右侧数组中的元素太大了,我们可以向左移动来减小窗口在右侧数组中的值。

如何快速移动窗口

为了快速移动窗口,我们需要知道这样的一个性质,一旦窗口在某个点选择向某个方向移动,比如向左移动,这个窗口之后不会移动到这个节点的右侧。也就是每次判断都可以排除掉一般的可能性。这让我们联想到通过二分法来快速找到合适的窗口。

一种简单实施二分的方式是,找到 cntA 的取值下界和上界,然后进行二分。

如何处理窗口移动的限制

窗口并非能够完全自由的移动,我们要确保窗口的左边界必须停留在左侧的数组内,也就是左边界不能超出数组的左右边界,窗口的右边界也是同理,这时候窗口移动范围也就被限制了。cntA 的下界不一定是 0,上界也不一定是 (m+n+1)/2

如何处理窗口的某一侧没有元素

如果窗口的内侧没有元素,我们就认为这个不存在的元素无穷小;如果窗口边界的外侧没有元素,我们就认为这个不存在的元素无穷大。这样的取值在比较的时候,使得我们能够忽略这些不存在的元素。

如何计算中位数

如果 m+n 是奇数,那么只要返回窗口内的最大值即可;如果 m+n 是偶数,那么只要返回窗口内的最大值和窗口外的最小值即可。

代码

class Solution {
public:
    double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
        int m = nums1.size();
        int n = nums2.size();
        int begin = max((m + n + 1) / 2 - n, 0); // 处理窗口移动的限制——下界
        int end   = min((m + n + 1) / 2, m) + 1; // 处理窗口移动的限制——上界
        while(true) // 快速移动窗口——二分法
        {
            int cntA = (begin + end) / 2;
            int cntB = (m + n + 1) / 2 - cntA;
            int A_a = cntA > 0 ? nums1[cntA-1] : INT_MIN; // 某一侧没有元素——左侧右边(窗口内)
            int A_b = cntA < m ? nums1[cntA]   : INT_MAX; // 某一侧没有元素——左侧左边(窗口外)
            int B_a = cntB > 0 ? nums2[cntB-1] : INT_MIN; // 某一侧没有元素——右侧左边(窗口内)
            int B_b = cntB < n ? nums2[cntB]   : INT_MAX; // 某一侧没有元素——右侧右边(窗口外)

            if (A_a > B_b) // 确定滑动的方向——缩小 cntA,向右移动
            {
                end = cntA;
            }
            else if (B_a > A_b) // 确定滑动的方向——缩小 cntB,向左移动
            {
                begin = cntA + 1;
            }
            else
            {
                if ((m+n) & 1)
                {
                    return max(A_a, B_a); // 计算中位数——奇数
                }
                else
                {
                    return (max(A_a, B_a) + min(A_b, B_b)) * 0.5; // 计算中位数——偶数
                }
            }
        }
        return -1;
    }
};