算法 2.4 归并排序 + 二分查找:寻找两个正序数组的中位数【leetcode 4】

题目描述

给定两个大小为 m 和 n 的正序(从小到大)数组 nums1 和 nums2
请你找出这两个正序数组的中位数

进阶:你能设计一个时间复杂度为 O(log(m + n)) 的算法解决此问题吗?

提示:
• 0 <= m、n <= 1000
• m+n >= 1
• -106 <= nums1[i] 、nums2[i] <= 106

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

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

数据结构

  • 数组

算法思维

  • 归并排序、二分查找

关键知识点:归并排序(Merge Sort)

  • 将序列拆成两个或两个以上分别排序,然后再合并成一个
    采用分治思想,基于归并操作的一种稳定排序算法
    稳定排序:对于关键字相等的元素在排序前后相对顺序保持不变
  • 归并排序原理
    寻找两个有序数组的最小值,即两个数组最小值中较小的那个,放入合并后数组的第一位
    寻找两个数组第2小的值
    寻找两个数组第3小的值
    ......
    依次进行直到结束


  首先将数组拆分成两部分
  对这两部分分别递归排序
  元素个数大于1,继续拆分
  只有一个元素时无需排序,结束递归
  在对有序数组进行两两合并

class Solution{
    public static int[] mergeSort(int[] arr) {
        if (arr.length < 2) return arr;
        //计算中间位置
        int mid = arr.length / 2;
        //分解为左右两部分,分别排序
        int[] left = Arrays.copyOfRange(arr, 0, mid);
        left = mergeSort(left);
        int[] right=Arrays.copyOfRange(arr,mid,arr.length);
        right = mergeSort(right);
        //合并两个排序后的数组为一个数组
        return merge(left, right);
    }

    private static int[] merge(int[] l, int[] r) {
        int[] result = new int[l.length + r.length];
        int lIndex = 0;
        int rIndex = 0;
        for (int i = 0; i < result.length; i++) {
            if (lIndex < l.length && rIndex < r.length) {
                if (l[lIndex] <= r[rIndex]) {
                    result[i] = l[lIndex++];
                } else {
                    result[i] = r[rIndex++];
                }
            } else if (lIndex >= l.length) {
                result[i] = r[rIndex++];
            } else {
                result[i] = l[lIndex++];
            }
        }
        return result;
    }
}

时间复杂度:O(nlogn)
  • 需要递归的将数组切割 logn 次,然后进行两两归并,时间复杂度为 O(nlogn)

空间复杂度:O(n)
  • 递归深度是 O(logn)
  • 每次递归在合并时需额外辅助空间,长度与待排序的数组长度相等
  • 每次递归都会释放掉所占的辅助空间,最大辅助空间为 O(n)
  • 所以空间复杂度为 O(n+logn) = O(n)

关键知识点:二分查找(折半查找)

使用折半的方式在有序数组中查找某一特定元素

  • 从数组的中间元素开始查找,如果中间元素等于目标元素,查找结束;
  • 如果中间元素小于目标元素,则在右半部分继续查找;
  • 如果中间元素大于目标元素,则在左半部分继续查找;
  • 如果在某一步骤数组为空,则代表找不到,查找结束;

每一次比较都使查找范围缩小一半

//二分查找算法,在a[start] ~ a[end]中查找key
public int binarySearch(int key, int a[], int start, int end) {
    if (start > end) //未找到key,返回-1
        return -1;

    int m = (start + end) / 2;
    if (a[m] == key) //找到key,返回key的id
        return m;
    if (a[m] > key)
        return binarySearch(key, a, start, m - 1);//在m左侧继续查找
    
    return binarySearch(key, a, m + 1, end);//在m右侧继续查找
}


解题步骤


一. Comprehend 理解题意
寻找两个数组的中位数
  • 两个数组都是正序数组
  • 寻找两个数组所有元素的中位数
进阶要求
  • 算法的时间复杂度为 O(log(m+n))
宽松限制
  • m + n >= 1:数组不同时为空(中位数一定存在)
细节问题
  • 某个数组可能为空
  • 元素总数为偶数时,中位数是中间两数平均值(不一定是整数)

二. Choose 选择数据结构与算法
数据结构选择
  • 输入的数据类型为两个整形数组
  • 输出的数据类型为一个浮点数
  • 因为需要处理的是两个有序的整数集合,我们采用数组作为数据结构
算法思维选择
  • 题目要求寻找中位数,我们可以对两个数组进行合并和排序
  • 在排序后的数组中我们可以很容易地找到中位数
  • 有没有更好的合并、排序方案? -- 归并排序
    解题思路
    • 题目中给定两个有序数组
    • 直接使用归并排序的最后一步对两个数组进行合并即可
    时间复杂度
    • 需要对两个数组各浏览一遍,进行 m+n-1 次比较,时间复杂度为 O(m+n)
    空间复杂度
    • 需要额外的空间存储合并排序后的数组,空间复杂度为 O(m+n)
算法思维优化
  • 题目要求寻找中位数,其实我们并不需要得到合并后的有序数组
  • 归并排序进行到一半的时候,我们其实已经找到了中位数
  • 算法可以被改进为遍历两个有序数组 num1 和 nums2:
    • 若 |nums1|+|nums2| 为奇数,寻找第 (|nums1|+|nums2|+1)/2 小的数
    • 若 |nums1|+|nums2| 为偶数,寻找第 (|nums1|+|nums2|)/2 和 (|nums1|+|nums2|+2)/2 小的数

三. Code 编码实现基本解法
解题思路剖析
  • 遍历有序数组 nums1 和 nums2,寻找中位数
  • 使用归并排序的思想,但是并不真正构建归并后的数组
  • 遍历有序数组nums1和nums2,寻找中位数
  • |nums1|+|nums2|= 4+3 = 7, 中位数为第 (7+1)/2=4 小的数
代码实现
class Solution {
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        int m = nums1.length;
        int n = nums2.length;
        
        //定义指针p1,p2 分别指代nums1和nums2的当前元素
        int p1 = 0, p2 = 0;
        //定义中位数m1和m2,指代当前第i-1大的数和第i大的数
        int m1 = 0, m2 = 0;
        
        for (int i = 0; i <= (m + n) / 2; i++) {
            m1 = m2; //指针右移
            //若nums1未处理完,并且 nums2已处理完 或 p1元素小于p2元素
            //则第i大的元素为nums1当前元素
            if (p1 < m && (p2 >= n || nums1[p1] < nums2[p2])) {
                m2 = nums1[p1++];
            } else { //否则第i大的元素为nums2当前元素
                m2 = nums2[p2++];
            } 
        }

        //若m+n为奇数,返回m2,若m+n为偶数,返回(m1+m2)/2.0
        if (((m + n) % 2) == 0) return (m1 + m2) / 2.0;
        else return m2;
    }
}

时间复杂度:O(m+n)
  • 需要多次比较两个数组中元素的大小
  • 只需要找到中间位置的元素,并不需要完成整个归并,总的比较次数为 (m+n)/2 + 1
  • 时间复杂度为 O(m+n)

空间复杂度:O(1)
  • 常数级的变量空间 O(1)

执行耗时:3 ms,击败了 69.10% 的Java用户
内存消耗:41 MB,击败了 24.82% 的Java用户

四. Consider 思考更优解
剔除无效代码 优化空间消耗
寻找更好的算法思维
  • 当前浏览了输入 nums1 和 nums2 总数据的一半
  • 有没有可能浏览更少的元素就可以确定中位数?
中位数的性质
  • 中位数可以将数组分成 2 份
    • 小于等于中位数的元素
    • 大于中位数的元素
    这两个数组的元素个数之差小于等于 1
  • 寻找中位数的问题 变为 寻找满足下列条件的分界线:
    1)分界线左边的元素都小于等于分界线右边的元素
    2)0 <= 分界线左边的元素个数 - 分界线右边的元素个数 <= 1
  • 如何查找分界线? -- 二分查找

五. Code 编码实现最优解
解题思路剖析
  • 找到 nums1 和 nums2 的分界线,将所有元素划分为两部分,使得:
    分界线左边的元素都小于等于分界线右边的元素
    0 <= 分界线左边的元素个数-分界线右边的元素个数 <= 1
  • 在 nums1 和 nums2 中元素较少的数组进行二分查找分界线
  • 若 |nums1|+|nums2| 为奇数,返回分界线左侧元素的最大值
  • 若 |nums1|+|nums2| 为偶数,返回分界线左侧元素最大值与右侧元素最小值的平均
class Solution {
    public double findMedianSortedArrays(int[] nums1, int[] nums2) {
        int m = nums1.length, n = nums2.length;
        //较短的数组在前,确保m <= n
        if (m > n) return findMedianSortedArrays(nums2, nums1);

        //定义p、q为nums1分界线范围,共m+1个可能的划分位置
        int p = 0, q = m;
        int i = 0, j = 0; //nums1、num2的分界位置

        //使用循环代替递归,减少空间消耗
        while (p <= q) {
            i = (p + q) / 2; //二分确定nums1当前分界位置
            //根据i确定nums2的分界位置,使得左侧元素数-右侧元素数为0或1
            j = (m + n + 1) / 2 - i;
            //nums1右侧最小值小于nums2左侧最大值,nums1划分位置在[i+1, q]之间
            if (j != 0 && i != m && nums1[i] < nums2[j - 1]) p = i + 1;
            //nums1左侧最大值大于nums2右侧最小值,nums1划分位置在[p, i-1]之间
            else if (i != 0 && j != n && nums1[i - 1] > nums2[j]) q = i - 1;
            //当前划分位置左侧的最大值小于右侧的最小值,满足要求
            else break;
        }

        //m+n为奇数,返回左侧的最大值 左侧最大值:三种情况 nums1为空、nums2为空、都不为空
        int maxLeft = i == 0 ? nums2[j - 1] : (j == 0 ? nums1[i - 1] : Math.max(nums1[i - 1], nums2[j - 1]));
        if ((m + n) % 2 == 1) return maxLeft;

        //m+n为偶数,返回左侧最大值与右侧最小值的平均 右侧最小值:三种情况 nums1为空、nums2为空、都不为空
        int minRight = i == m ? nums2[j] : (j == n ? nums1[i] : Math.min(nums1[i], nums2[j]));
        return (maxLeft + minRight) / 2.0;
    }
}

时间复杂度:O(log(min(m,n)))
  • 只需要对 nums1 和 nums2 中较短数组进行二分查找
  • 二分查找的时间复杂度为 O(log(min(m,n)))

空间复杂度:O(1)
  • 常数级内存空间 O(1)

执行耗时:2 ms,击败了 100.00% 的Java用户
内存消耗:40.9 MB,击败了 35.53% 的Java用户

六. Change 变形与延伸
题目变形
  • (练习)如果输入 nums1 和 nums2 变为逆序数组该如何实现?
延伸扩展
  • 二分查找是一种基础高效的算法思维
  • 实际工作中应用非常广泛
    数据库中使用最频繁的查找算法 select * from tb where dt = 2021-01-30
    数学方程求根

你可能感兴趣的:(算法 2.4 归并排序 + 二分查找:寻找两个正序数组的中位数【leetcode 4】)