「力扣」第 4 题:两个排序数组的中位数(困难)

说明:这篇题解是我原来在准备视频题解的时候写的题解,目前来看已经没有什么用了,所以发布在这里,留一个备份。

视频题解请在官方题解观看,或者在 B 站 观看。

二分查找经典问题:第 4 题:两个排序数组的中位数

这一节,我们来看一个非常经典且常见的问题,这道题是「力扣」第 4 号问题:寻找两个有序数组的中位数

这道题的题意十分简单,题目中给出两个已经排好序的数组,让我们求出这两个有序数组的中位数,请注意,不是让我们分别求出两个数组的中位数,而是要我们求出,两个有序数组合并成一个有序数组以后的中位数。

这道题难在最后给出的要求:让我们在对数级别的时间复杂度解决这个问题。虽然这是这道题的难点,但首先,在有序数组中查找一个数,本身就是二分搜索方法能够解决的问题,同时对数级别的复杂度提示,也是在提示我们二分查找方法的确可以完成这道题。

我们先不考虑题目最后给出的时间复杂度的要求。看看怎么解决这个问题。其实刚刚在念题目的过程中,我已经把最简单、朴素的那个解法给说了出来,那就是依据定义。

方法一:暴力解法

将两个有序数组合并以后,再排序,然后求出中位数。注意,如果合并以后的数组的长度是奇数,此时中位数只有 1 个,把这个值返回即可。如果合并以后的数组的长度是偶数,此时中位数有 2 个,根据题意,返回它们二者的平均数即可。

这就是第一种解法,暴力解法。暴力解法虽然时间复杂度高,但是对数据的容错性最好,即使两个数组不是有序的,这个方法也能正确求出两个数组的中位数。并且在思考暴力解法的过程中,我们注意到了,需要中位数需要根据数组长度是奇数和偶数做不同情况的讨论。

Java 代码:

import java.util.Arrays;

public class Solution {
    
    

    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
    
    

        int m = nums1.length;
        int n = nums2.length;

        int[] merge = new int[m + n];
        System.arraycopy(nums1, 0, merge, 0, m);
        System.arraycopy(nums2, 0, merge, m, n);

        Arrays.sort(merge);

        if (((m + n) & 1) == 1) {
    
    
            return merge[(m + n - 1) >>> 1];
        } else {
    
    
            return (double) (merge[(m + n - 1) >>> 1] + merge[(m + n) >>> 1]) / 2;
        }
    }
}

复杂度分析

  • 时间复杂度: O ( ( M + N ) log ⁡ ( M + N ) ) O((M + N) \log (M + N)) O((M+N)log(M+N)),这里 M M M N N N 分别是两个数组的长度。
  • 空间复杂度: O ( M + N ) O(M + N) O(M+N)

方法二:实现归并排序的过程中找中位数

此外,依据排序的思路,我们学习过归并排序,归并排序最基本的情况就是合并两个有序数组。我们可以在合并两个有序数组的过程中,得到中位数。只不过我们不用真的合并完这两个有序数组,只需要直到合并到中位数所在位置那么多个的数即可。

这个方法我们留给大家来完成,相信是一个很容易实现的问题。

Java 代码:

public class Solution {
    
    

    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
    
    
        int m = nums1.length;
        int n = nums2.length;

        // 最后要找到合并以后索引是 median_index 的这个数
        int medianIndex = (m + n) >>> 1;

        // 计数器从 -1 开始,在循环开始之前加 1
        // 这样在退出循环的时候,counter 能指向它最后赋值的那个元素
        int counter = -1;

        // nums1 的索引
        int i = 0;
        // nums2 的索引
        int j = 0;

        int[] res = new int[]{
    
    0, 0};
        while (counter < medianIndex) {
    
    
            counter++;
            // 先写 i 和 j 遍历完成的情况,否则会出现数组下标越界
            if (i == m) {
    
    
                res[counter & 1] = nums2[j];
                j++;
            } else if (j == n) {
    
    
                res[counter & 1] = nums1[i];
                i++;
            } else if (nums1[i] < nums2[j]) {
    
    
                res[counter & 1] = nums1[i];
                i++;
            } else {
    
    
                res[counter & 1] = nums2[j];
                j++;
            }
        }
        
        // 如果 m + n 是奇数,median_index 就是我们要找的
        // 如果 m + n 是偶数,有一点麻烦,要考虑其中有一个用完的情况,其实也就是把上面循环的过程再进行一步
        if (((m + n) & 1) == 1) {
    
    
            return res[counter & 1];
        } else {
    
    
            return (double) (res[0] + res[1]) / 2;
        }
    }
}

复杂度分析

  • 时间复杂度: O ( M + N ) O(M + N) O(M+N),这里 M M M N N N 分别是两个数组的长度,代码只看了数组长度之和的一半,常数系数视为 1 1 1

  • 空间复杂度: O ( 1 ) O(1) O(1),我们没有借助其它的空间,使用到的临时变量也只有常数个。

方法三:二分查找

接下来我们介绍一下如何使用二分查找解决这个问题,依然从中位数的定义出发。

根据暴力法的分析,在只有一个有序数组的时候:

(1)如果数组的元素个数是偶数,此时我们可以想象有一条分界线,把数组分成两个部分,中位数就是介于这个分界线两边的两个元素的平均值。

image-20191216154514844

(2)如果数组的元素个数是奇数,此时我们也可以想象有一条分界线,把数组分成两个部分,此时我们让分割线左边多一个元素,此时分割线的左边的那个元素就是这个有序数组的中位数。

至于为什么把中位数分到这个分割线的左边而不是右边,我们马上就会看到。

接下来看两个有序数组的时候,我们依然是可以用这种画分界线的方式来找中位数。

这条分割线的特点是:

1、当数组的总长度为偶数的时候,分割线左右的数字个数总和相等;当数组的总长度为奇数的时候,分割线左数字个数比右边仅仅多 1;

2、分割线左边的所有元素都小于等于(不大于)分割线右边的所有元素。

如果这条分割线可以找到,那么中位数就可以确定下来,同样得分奇偶性:

(1)当数组的总长度为偶数的时候,中位数就是分割线左边的最大值与分割线右边的最小值的平均数;

(2)当数组的总长度为奇数的时候,中位数就是分割线左边的最大值。因此,在数组长度是奇数的时候,中位数就是分割线左边的所有数的最大值。

因此,我们让分割线左边在整个数组长度是奇数的时候,多 1 个数的原因,就是让引入分割线定义的中位数在 1 个数组和 2 个数组的时候统一起来。

因为两个数组本别是有序数组,因此,我们只需要判定交叉的关系中,是否满足左边依然小于等于右边即可,即

(1)第 1 个数组分割线左边的第 1 个数小于等于第 2 个数组分割线右边的第 1 的数;

(2)第 2 个数组分割线左边的第 1 个数小于等于第 1 个数组分割线右边的第 1 的数。

接下来,我们就来看一下分割线怎么着,需要在第 1 个数组上划一刀,再在第 2 个数组上划一刀,但事实上,分割线左边的元素个数是固定的,我们只要能确定 1 个数组上元素的位置,自然另一个位置就可以确定下来。它们之间的关系其实很简单,我们刚刚也已经分析过了。

我们把其中一个数组称之为 nums1 ,另一个数组称之为 nums2

(1)当数组的总长度为偶数的时候,左边一共有 l e n ( n u m 1 ) + l e n ( n u m s 2 ) 2 \cfrac{len(num1) + len(nums2)}{2} 2len(num1)+len(nums2) 个元素;

(2)当数组的总长度为奇数的时候,左边一共有 l e n ( n u m 1 ) + l e n ( n u m s 2 ) 2 + 1 \cfrac{len(num1) + len(nums2)}{2} + 1 2len(num1)+len(nums2)+1 个元素;

我们仔细观察一下这两个表达式,发现奇数的时候,因为除以 2 2 2 是下取整,所以计算左边元素总数的时候,就得 + 1,事实上,我们可以修改这个下取整的行为,让它上取整。上面这两种情况就可以统一起来。

具体如下:我们计算左边元素个数的时候,可以用一个统一的式子,即:

l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 \cfrac{len(num1) + len(nums2) + 1}{2} 2len(num1)+len(nums2)+1

这里用到了一个小技巧,把下取整,修改为上取整的时候,只需要在被除数的部分,加上除数减 1 即可,这里除数是 2 ,因此被除数加 1 即可。大家可以自行验证一下,就拿我们上面举出的例子来验证一下这个事实。

(1)当 len(nums1) = 5len(nums2) = 5 的时候, l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 = 5 \cfrac{len(num1) + len(nums2) + 1}{2} = 5 2len(num1)+len(nums2)+1=5

(2)当 len(nums1) = 4len(nums2) = 5 的时候, l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 = 5 \cfrac{len(num1) + len(nums2) + 1}{2} = 5 2len(num1)+len(nums2)+1=5,左边比右边多一个元素。


这个问题解决以后,问题就转化为我们在其中一个数组找到 i 个元素,则另一个数组的元素个数就一定是 l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 − i \cfrac{len(num1) + len(nums2) + 1}{2} - i 2len(num1)+len(nums2)+1i。于是怎么找 i 的位置,就是我们要解决的问题。

i 个元素,我们通常的做法是找索引为 i的元素,因为下标是从 0 开始编号的,因此编号为 i 的元素,就刚刚好前面有 i 个元素。因此,i 就是第 1 个数组分割线的右边的第 1 个元素。

下面我们来看怎么找 i

二分法的思想就是我们先根据搜索范围,找到中间的那个数,然后看目标元素在这个中间数的左边还是右边,或者就是中间数。因此我们先来看两个不是中间数的情况。

image-20191216164952779

如图所示,此时能够保证分割线左边的元素总数比右边多 1,但是第 1 个数组分割线右边第 1 个数 6 小于第 2 个数组分割线左边第 1 个数 8。说明,第 1 个数组左边的数少了,分割线要右移。

image-20191216165734716

如图所示,此时能够保证分割线左边的元素总数比右边多 1,但是第 1 个数组分割线左边第 1 个数 8 小于第 2 个数组分割线左边第 1 个数 7。说明,第 1 个数组左边的数多了,分割线要左移。

因此,就是在这种不断缩小搜索范围的方法中,定位这个索引 i 是多少。


这里要注意一个问题,那就是我们要在一个短的数组上搜索 i ,因为在搜索的过程中,我们会比较分割线左边和右边的数,即 nums[i]nums[i - 1]nums[j]nums[j - 1],因此,这几个数的索引不能越界。

一个比较极端的情况如下:

image-20191216170435095

此时,分割线在第 2 个数组的左边没有值,会导致 nums2[j - 1] 的访问越界。因此我们必须在短的数组上搜索 ii 的定义是分割线的右边,而它的左边一定有值

这样就能保证,分割线在第 2 个数组的左右两边一定有元素,即分割线一定可以在第 2 个数组的中间切一刀。


下面就是这个题目的最后一个细节了,那就是即使我在短数组上搜索边界 i ,还真就可能遇到 i 或者 j 的左边或者右边取不到元素的情况,它们一定出现在退出循环的时候。

image-20191216170934239

为此,我们单独做一个判断就可以了。

image-20191216171333129

我们把最关心的“边界线”两旁的 4 个数的极端值都考虑一下:

1、考虑 nums1
(1)当 i = 0 的时候,对应上图右边,此时数组 nums1 在红线左边为空,可以设置 nums1_left_max = 负无穷
这样在最终比较的时候,因为左边粉色部分要选择出最大值,它一定不会被选中,于是能兼容其它情况;

(2)当 i = m 的时候,对应上图左边,此时数组 nums1 在红线右边为空,可以设置 nums1_right_min = 正无穷
这样在最终比较的时候,因为右边蓝色部分要选择出最小值,它一定不会被选中,于是能兼容其它情况。

2、考虑 nums2
(1)当 j = 0 的时候,对应上图左边,此时数组 nums2 在红线左边为空,可以设置 nums2_left_max = 负无穷
这样在最终比较的时候,因为左边粉色部分要选择出最大值,它一定不会被选中,于是能兼容其它情况;

(2)当 j = n 的时候,对应上图右边,此时数组 nums2 在红线右边为空,可以设置 nums2_right_min = 正无穷
这样在最终比较的时候,因为右边蓝色部分要选择出最小值,它一定不会被选中,于是能兼容其它情况。

Java 代码:

public class Solution {
    
    
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
    
    
        // 为了让搜索范围更小,我们始终让 num1 是那个更短的数组,PPT 第 9 张
        if (nums1.length > nums2.length) {
    
    
            int[] temp = nums1;
            nums1 = nums2;
            nums2 = temp;
        }

        // 上述交换保证了 m <= n,在更短的区间 [0, m] 中搜索,会更快一些
        int m = nums1.length;
        int n = nums2.length;

        // 使用二分查找算法在数组 nums1 中搜索一个索引 i
        int left = 0;
        int right = m;

        // 因为 totalLeft 这个变量会一直用到,因此单独赋值,表示左边粉红色部分一共需要的元素个数
        int totalLeft = (m + n + 1) >>> 1;
        while (left < right) {
    
    
            int i = (left + right) >>> 1;
            int j = totalLeft - i;

            if (nums2[j - 1] > nums1[i]) {
    
    
                left = i + 1;
            } else {
    
    
                right = i;
            }
        }

        int i = left;
        int j = totalLeft - left;
      
        int nums1LeftMax = i == 0 ? Integer.MIN_VALUE : nums1[i - 1];
        int nums1RightMin = i == m ? Integer.MAX_VALUE : nums1[i];

        int nums2LeftMax = j == 0 ? Integer.MIN_VALUE : nums2[j - 1];
        int nums2RightMin = j == n ? Integer.MAX_VALUE : nums2[j];

        if (((m + n) & 1) == 1) {
    
    
            return Math.max(nums1LeftMax, nums2LeftMax);
        } else {
    
    
            return (double) ((Math.max(nums1LeftMax, nums2LeftMax) + Math.min(nums1RightMin, nums2RightMin))) / 2;
        }
    }
}

复杂度分析

  • 时间复杂度: O ( log ⁡ ( min ⁡ ( M , N ) ) ) O(\log(\min(M,N))) O(log(min(M,N))),为了使得搜索更快,我们把更短的数组设置为 nums1 ,因为使用二分查找法,在它的长度的对数时间复杂度内完成搜索。

  • 空间复杂度: O ( 1 ) O(1) O(1),只使用了常数个的辅助变量。

这就是这一节的内容。通过这两道问题的学习,我们可以认识到,二分查找法不仅可以用于在一个有序数组里查找元素,甚至也可以应用与在一个范围里定位元素。而这个范围其实我们也可以把它看做一个有序的数组。

而更广义上,只要是能够减而治之,特别是一下子能排除一半甚至一半以上的区间,我们都可以使用二分法去完成。这就是这一节的内容,下一节我们学习在数组上的一类经典问题,“滑动窗口”问题的解法。

猜你喜欢

转载自blog.csdn.net/lw_power/article/details/107388382
今日推荐