LeetCode——寻找两个有序数组的中位数

Q:给定两个大小为 m 和 n 的有序数组 nums1 和 nums2。
请你找出这两个有序数组的中位数,并且要求算法的时间复杂度为 O(log(m + n))。
你可以假设 nums1 和 nums2 不会同时为空。

示例 1:
nums1 = [1, 3]
nums2 = [2]
则中位数是 2.0
示例 2:
nums1 = [1, 2]
nums2 = [3, 4]
则中位数是 (2 + 3)/2 = 2.5

A:鉴于这个题目的标记是“困难”,用多种解法解题。
引用:详细通俗的思路分析,多解法
1.第一种,也是最简单的方法,两个数组一拼,然后直接找中位数。
代码直接借用一下别人的(@windliang):

public double findMedianSortedArrays(int[] nums1, int[] nums2) {
    int[] nums;
    int m = nums1.length;
    int n = nums2.length;
    nums = new int[m + n];
    if (m == 0) {
        if (n % 2 == 0) {
            return (nums2[n / 2 - 1] + nums2[n / 2]) / 2.0;
        } else {

            return nums2[n / 2];
        }
    }
    if (n == 0) {
        if (m % 2 == 0) {
            return (nums1[m / 2 - 1] + nums1[m / 2]) / 2.0;
        } else {
            return nums1[m / 2];
        }
    }

    int count = 0;
    int i = 0, j = 0;
    while (count != (m + n)) {
        if (i == m) {
            while (j != n) {
                nums[count++] = nums2[j++];
            }
            break;
        }
        if (j == n) {
            while (i != m) {
                nums[count++] = nums1[i++];
            }
            break;
        }

        if (nums1[i] < nums2[j]) {
            nums[count++] = nums1[i++];
        } else {
            nums[count++] = nums2[j++];
        }
    }

    if (count % 2 == 0) {
        return (nums[count / 2 - 1] + nums[count / 2]) / 2.0;
    } else {
        return nums[count / 2];
    }
}

时间复杂度:遍历全部数组 (m+n)
空间复杂度:开辟了一个数组,保存合并后的两个数组 O(m+n)

2.我开始想,创建两个指针,根据大小往后移,直到走到中间。但我没反应过来,有可能两个中位数的值都在同一个array里面。这样的方法是错的。
正确的方法是:
用 len 表示合并后数组的长度,如果是奇数,我们需要知道第 (len+1)/2 个数就可以了,如果遍历的话需要遍历 int(len/2 ) + 1 次。如果是偶数,我们需要知道第 len/2和 len/2+1 个数,也是需要遍历 len/2+1 次。所以遍历的话,奇数和偶数都是 len/2+1 次。
返回中位数的话,奇数需要最后一次遍历的结果就可以了,偶数需要最后一次和上一次遍历的结果。所以我们用两个变量 left 和 right,right 保存当前循环的结果,在每次循环前将 right 的值赋给 left。这样在最后一次循环的时候,left 将得到 right 的值,也就是上一次循环的结果,接下来 right 更新为最后一次的结果。
循环中该怎么写,什么时候 A 数组后移,什么时候 B 数组后移。用 aStart 和 bStart 分别表示当前指向 A 数组和 B 数组的位置。如果 aStart 还没有到最后并且此时 A 位置的数字小于 B 位置的数组,那么就可以后移了。也就是aStart<m&&A[aStart]< B[bStart]。
但如果 B 数组此刻已经没有数字了,继续取数字 B[ bStart ],则会越界,所以判断下 bStart 是否大于数组长度了,这样 || 后边的就不会执行了,也就不会导致错误了,所以增加为 aStart<m&&(bStart) >= n||A[aStart]<B[bStart]) 。

代码:

public double findMedianSortedArrays(int[] A, int[] B) {
    int m = A.length;
    int n = B.length;
    int len = m + n;
    int left = -1, right = -1;
    int aStart = 0, bStart = 0;
    for (int i = 0; i <= len / 2; i++) {
        left = right;
        if (aStart < m && (bStart >= n || A[aStart] < B[bStart])) {
            right = A[aStart++];
        } else {
            right = B[bStart++];
        }
    }
    if ((len & 1) == 0)
        return (left + right) / 2.0;
    else
        return right;
}

3.第三种解法:(这个算法蛮精妙的)
解法二中,我们一次遍历就相当于去掉不可能是中位数的一个值,也就是一个一个排除。由于数列是有序的,其实我们完全可以一半儿一半儿的排除。假设我们要找第 k 小数,我们可以每次循环排除掉 k/2 个数。看下边一个例子。
假设我们要找第 7 小的数字。

我们比较两个数组的第 k/2 个数字,如果 k 是奇数,向下取整。也就是比较第 3 个数字,上边数组中的 4 和下边数组中的 3,如果哪个小,就表明该数组的前 k/2 个数字都不是第 k 小数字,所以可以排除。也就是 1,2,3 这三个数字不可能是第 7 小的数字,我们可以把它排除掉。将 1349 和 45678910 两个数组作为新的数组进行比较。
更一般的情况 A[1] ,A[2] ,A[3],A[k/2] ... ,B[1],B[2],B[3],B[k/2] ... ,如果 A[k/2]<B[k/2] ,那么A[1],A[2],A[3],A[k/2]都不可能是第 k 小的数字。
A 数组中比 A[k/2] 小的数有 k/2-1 个,B 数组中,B[k/2] 比 A[k/2] 小,假设 B[k/2] 前边的数字都比 A[k/2] 小,也只有 k/2-1 个,所以比 A[k/2] 小的数字最多有 k/1-1+k/2-1=k-2个,所以 A[k/2] 最多是第 k-1 小的数。而比 A[k/2] 小的数更不可能是第 k 小的数了,所以可以把它们排除。
橙色的部分表示已经去掉的数字。

由于我们已经排除掉了 3 个数字,就是这 3 个数字一定在最前边,所以在两个新数组中,我们只需要找第 7 - 3 = 4 小的数字就可以了,也就是 k = 4。此时两个数组,比较第 2 个数字,3 < 5,所以我们可以把小的那个数组中的 1 ,3 排除掉了。

我们又排除掉 2 个数字,所以现在找第 4 - 2 = 2 小的数字就可以了。此时比较两个数组中的第 k / 2 = 1 个数,4 == 4,怎么办呢?由于两个数相等,所以我们无论去掉哪个数组中的都行,因为去掉 1 个总会保留 1 个的,所以没有影响。为了统一,我们就假设 4 > 4 吧,所以此时将下边的 4 去掉。

由于又去掉 1 个数字,此时我们要找第 1 小的数字,所以只需判断两个数组中第一个数字哪个小就可以了,也就是 4。
所以第 7 小的数字是 4。
我们每次都是取 k/2 的数进行比较,有时候可能会遇到数组长度小于 k/2的时候。

此时 k / 2 等于 3,而上边的数组长度是 2,我们此时将箭头指向它的末尾就可以了。这样的话,由于 2 < 3,所以就会导致上边的数组 1,2 都被排除。造成下边的情况。

由于 2 个元素被排除,所以此时 k = 5,又由于上边的数组已经空了,我们只需要返回下边的数组的第 5 个数字就可以了。
从上边可以看到,无论是找第奇数个还是第偶数个数字,对我们的算法并没有影响,而且在算法进行中,k 的值都有可能从奇数变为偶数,最终都会变为 1 或者由于一个数组空了,直接返回结果。
所以我们采用递归的思路,为了防止数组长度小于 k/2,所以每次比较 min(k/2,len(数组) 对应的数字,把小的那个对应的数组的数字排除,将两个新数组进入递归,并且 k 要减去排除的数字的个数。递归出口就是当 k=1 或者其中一个数字长度是 0 了。
代码:

public double findMedianSortedArrays(int[] nums1, int[] nums2) {
    int n = nums1.length;
    int m = nums2.length;
    int left = (n + m + 1) / 2;
    int right = (n + m + 2) / 2;
    //将偶数和奇数的情况合并,如果是奇数,会求两次同样的 k 。
    return (getKth(nums1, 0, n - 1, nums2, 0, m - 1, left) + getKth(nums1, 0, n - 1, nums2, 0, m - 1, right)) * 0.5;  
}
    
    private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) {
        int len1 = end1 - start1 + 1;
        int len2 = end2 - start2 + 1;
        //让 len1 的长度小于 len2,这样就能保证如果有数组空了,一定是 len1 
        if (len1 > len2) return getKth(nums2, start2, end2, nums1, start1, end1, k);
        if (len1 == 0) return nums2[start2 + k - 1];

        if (k == 1) return Math.min(nums1[start1], nums2[start2]);

        int i = start1 + Math.min(len1, k / 2) - 1;
        int j = start2 + Math.min(len2, k / 2) - 1;

        if (nums1[i] > nums2[j]) {
            return getKth(nums1, start1, end1, nums2, j + 1, end2, k - (j - start2 + 1));
        }
        else {
            return getKth(nums1, i + 1, end1, nums2, start2, end2, k - (i - start1 + 1));
        }
    }

时间复杂度:每进行一次循环,我们就减少 k/2 个元素,所以时间复杂度是 O(log(k),而 k=(m+n)/2,所以最终的复杂也就是 O(log(m+n))。
空间复杂度:虽然我们用到了递归,但是可以看到这个递归属于尾递归,所以编译器不需要不停地堆栈,所以空间复杂度为 O(1)。

  1. 第四种解法:划分数组
    为了使用划分的方法解决这个问题,需要理解「中位数的作用是什么」。在统计中,中位数被用来:
    将一个集合划分为两个长度相等的子集,其中一个子集中的元素总是大于另一个子集中的元素。
    如果理解了中位数的划分作用,就很接近答案了。

首先,在任意位置 i 将 \(A\) 划分成两个部分:

       left_A            |          right_A
A[0], A[1], ..., A[i-1]  |  A[i], A[i+1], ..., A[m-1]

由于 \(\text{A}\) 中有 m 个元素, 所以有 m+1 种划分的方法(\(i \in [0, m]\))。

\(len(left\_A)=i,len(right\_A)=m−i\).

注意:当$ i = 0$ 时,\(left\_A\) 为空集, 而当$ i = m$ 时, \(right\_A\) 为空集。

采用同样的方式,在任意位置 j 将 \(B\) 划分成两个部分:

       left_B            |          right_B
B[0], B[1], ..., B[j-1]  |  B[j], B[j+1], ..., B[n-1]

\(left\_A\)\(left\_B\) 放入一个集合,并将 \(right\_A\)\(right\_B\)} 放入另一个集合。 再把这两个新的集合分别命名为 \(left\_part\)\(right\_part\)

      left_part          |         right_part
A[0], A[1], ..., A[i-1]  |  A[i], A[i+1], ..., A[m-1]
B[0], B[1], ..., B[j-1]  |  B[j], B[j+1], ..., B[n-1]

\(A\)\(B\) 的总长度是偶数时,如果可以确认:

  • \(len(left\_part)=len(right\_part)\)
  • \(max(left\_part)≤min(right\_part)\)

那么,\({A,B}\) 中的所有元素已经被划分为相同长度的两个部分,且前一部分中的元素总是小于或等于后一部分中的元素。中位数就是前一部分的最大值和后一部分的最小值的平均值:

\[\text{median} = \frac{\text{max}(\text{left}\_\text{part}) + \text{min}(\text{right}\_\text{part})}{2} \]

\(\text{A}\)\(\text{B}\) 的总长度是奇数时,如果可以确认:

  • \(\text{len}(\text{left_part}) = \text{len}(\text{right_part})+1\)
  • \(\max(\text{left_part}) \leq \min(\text{right_part})\)

那么,\(\{\text{A}, \text{B}\}\) 中的所有元素已经被划分为两个部分,前一部分比后一部分多一个元素,且前一部分中的元素总是小于或等于后一部分中的元素。中位数就是前一部分的最大值:

\[\text{median} = \text{max}(\text{left}\_\text{part}) \]

第一个条件对于总长度是偶数和奇数的情况有所不同,但是可以将两种情况合并。第二个条件对于总长度是偶数和奇数的情况是一样的。

要确保这两个条件,只需要保证:

  • \(i + j = m - i + n - j\)(当 \(m+n\) 为偶数)或 \(i + j = m - i + n - j + 1\)(当 \(m+n\) 为奇数)。等号左侧为前一部分的元素个数,等号右侧为后一部分的元素个数。将 i 和 j 全部移到等号左侧,我们就可以得到$ i+j = \frac{m + n + 1}{2}$ 。这里的分数结果只保留整数部分。

  • \(0 \leq i \leq m,0 \leq j \leq n\)。如果我们规定 \(\text{A}\) 的长度小于等于$ \text{B}$ 的长度,即 \(m \leq n\)。这样对于任意的$ i \in [0, m]\(,都有\) j = \frac{m + n + 1}{2} - i \in [0, n]$,那么我们在 \([0, m]\) 的范围内枚举 i 并得到 j,就不需要额外的性质了。

    • 如果 \(\text{A}\) 的长度较大,那么我们只要交换 \(\text{A}\)\(\text{B}\) 即可。

    • 如果 \(m > n\) ,那么得出的 j 有可能是负数。

  • \(\text{B}[j-1] \leq \text{A}[i]\) 以及 \(\text{A}[i-1] \leq \text{B}[j]\),即前一部分的最大值小于等于后一部分的最小值。

为了简化分析,假设 \(\text{A}[i-1], \text{B}[j-1], \text{A}[i], \text{B}[j]\) 总是存在。对于 i=0、i=m、j=0、j=n 这样的临界条件,我们只需要规定 \(A[-1]=B[-1]=-\infty\)\(A[m]=B[n]=\infty\) 即可。这也是比较直观的:当一个数组不出现在前一部分时,对应的值为负无穷,就不会对前一部分的最大值产生影响;当一个数组不出现在后一部分时,对应的值为正无穷,就不会对后一部分的最小值产生影响。

所以我们需要做的是:
在$ [0, m]$ 中找到 i,使得:\(\text{B}[j-1] \leq \text{A}[i]\)\(\text{A}[i-1] \leq \text{B}[j]\),其中 \(j = \frac{m + n + 1}{2} - i\)

我们证明它等价于:
在$ [0, m]$ 中找到最大的 i,使得:$ \text{A}[i-1] \leq \text{B}[j]\(,其中\) j = \frac{m + n + 1}{2} - i$

这是因为:

  • 当 i 从 \(0 \sim m\) 递增时,\(\text{A}[i-1]\) 递增,\(\text{B}[j]\) 递减,所以一定存在一个最大的 i 满足 \(A[i-1] \leq B[j]\)
  • 如果 i 是最大的,那么说明 i+1 不满足。将 i+1 带入可以得到 \(A[i] > B[j-1]\),也就是 \(B[j - 1] < A[i]\),就和我们进行等价变换前 i 的性质一致了(甚至还要更强)。

因此我们可以对 i 在 [0,m] 的区间上进行二分搜索,找到最大的满足 \(A[i-1] \leq B[j]\) 的 i 值,就得到了划分的方法。此时,划分前一部分元素中的最大值,以及划分后一部分元素中的最小值,才可能作为就是这两个数组的中位数。

    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        if (nums1.length > nums2.length) {
            return findMedianSortedArrays(nums2, nums1);
        }

        int m = nums1.length;
        int n = nums2.length;
        int left = 0, right = m, ansi = -1;
        // median1:前一部分的最大值
        // median2:后一部分的最小值
        int median1 = 0, median2 = 0;

        while (left <= right) {
            // 前一部分包含 nums1[0 .. i-1] 和 nums2[0 .. j-1]
            // 后一部分包含 nums1[i .. m-1] 和 nums2[j .. n-1]
            int i = (left + right) / 2;
            int j = (m + n + 1) / 2 - i;

            // nums_im1, nums_i, nums_jm1, nums_j 分别表示 nums1[i-1], nums1[i], nums2[j-1], nums2[j]
            int nums_im1 = (i == 0 ? Integer.MIN_VALUE : nums1[i - 1]);
            int nums_i = (i == m ? Integer.MAX_VALUE : nums1[i]);
            int nums_jm1 = (j == 0 ? Integer.MIN_VALUE : nums2[j - 1]);
            int nums_j = (j == n ? Integer.MAX_VALUE : nums2[j]);

            if (nums_im1 <= nums_j) {
                ansi = i;
                median1 = Math.max(nums_im1, nums_jm1);
                median2 = Math.min(nums_i, nums_j);
                left = i + 1;
            }
            else {
                right = i - 1;
            }
        }

        return (m + n) % 2 == 0 ? (median1 + median2) / 2.0 : median1;
    }
posted @ 2020-04-27 13:48  Shaw_喆宇  阅读(222)  评论(0编辑  收藏  举报