归并例题 in Java

59 阅读8分钟

发现很多利用归并思想的做法其实都是基于归并排序的,而归并排序的核心在于merge的过程是基于两个有序数组的操作,基于多个有序数组的操作往往都能做到每个数组的指针不回溯,这就是复杂度降低的原因。

而归并解法能降低时间复杂度的原因也在于利用了已经存在的有序,所以可以说:这些解法就像是利用了归并排序在不同时期提供的钩子函数,依此完成统计工作。

例一:经典归并排序

public static int[] HELP;
//help数组的作用是:在merge时需要一个新的空间,存放从两个有序部分合并起来的部分
//可以在方法中每次merge时new一个新的数组来用,但很耗空间.

public static void mergeSort(int[] ints,int l,int r){
    HELP = new int[ints.length];
    if(l == r){
        return;
    }
    /*
    这里为什么只需要在 l == r 退出即可?不用再在 r - l = 1退出?
    考虑在哪里退出就是为了防止出现死循环,当r和l差一个时代入方法任能向下分治,只是mid并不是中间元素罢了
    */
    int mid = l + ((r-l)>>1);
    mergeSort(ints,l,mid);
    mergeSort(ints,mid+1,r);
    merge(ints,l,mid,r);

}
public static void merge(int[] ints,int l,int m,int r){

    int a = l; //第一部分的指针
    int b = m+1; //第二部分的指针
    int index = l; //汇总到新空间的指针
    
    //此while循环是当两个指针都未越界时的
    while (a<=m && b<=r){
        if(ints[a]<=ints[b]){
            HELP[index] = ints[a];
            index++;
            a++;
        }else {
            HELP[index] = ints[b];
            index++;
            b++;
        }
    }
    //此while是当b越界,将a剩余部分全部写入
    while (a<=m){
        HELP[index] = ints[a];
        index++;
        a++;
    }
    //此while是当a越界,将b剩余部分全部写入,两个while最多只会有一个执行
    while (b<=r){
        HELP[index] = ints[b];
        index++;
        b++;
    }
    //复制HELP回原数组,即用merge后有序的数组替代
    for (int i = l; i <= r; i++) {
        ints[i] = HELP[i];
    }
}

例二:LeetCode 493

image.png

怎么判断哪些问题可以用归并解决?

以下是两个指标:

  1. 原问题可以分为左边部分,右边部分,左对右的部分
    • 什么是左对右的部分?就是无论左右部分是否有序,单独考虑左边每一个元素对于右边元素的效果(例题中更直白)
  2. 左右两部分变有序可以优化原问题求解

第一个是递归往下分治调用的基础,第二个则是归并能降低复杂度的原因

在这个例题中,对于[1,3,2,3,1],要找出其中所有反转对(就是前面比后面大很多的数对),我们可以分为求前一半中的反转对,后一半中的反转对,以及由前一部分的一个元素和后一部分的一个元素组成的反转对。

这就是归并吗?优点在哪里?

例如我们已经完成了底层的代码,现在在执行最上层的统计反转对的工作,也就是已经归并出了a = [1,2,3]和b = [1,3],接下来要找依次对比a和b的元素,看看是否满足题目条件。归并就是利用了两个数组有序,如果a中前面的数比b中某个数大,那a后面的元素一定也比b中那个数大;如果a中某个元素确定比b中某个元素大,那一定比b中那个数之前的元素大。

也就是说:如果用两个指针遍历两个数组,则这两个指针不会回溯就遍历完成并找出所有匹配元素。

具体流程如下:

image.png

基本思路是找到每个i索引下能够满足条件的j索引

一开始ij指针位于起始位置,在这个位置j对应的数已经大到不满足条件了,所以无法移动j,要移动i

image.png

此时的i下j满足,就可以移动j,再来判断是否符合条件。而假如i的后面还有元素的话,移动j能够保证下一个i也一定和前面的j符合关系

image.png

听起来归并使用有一个条件————就是两个数组之间元素一定是比大小的关系。(后面有题目不是这个关系,LeetCode官方题解是先转化成这个关系)

代码:

public static int MAXN = 5001;
public static int[] help = new int[MAXN];
public int reversePairs(int[] nums) {
    return counts(nums, 0, nums.length - 1);
}

public static int counts(int[] arr, int l, int r) {
    if (l == r) {
        return 0;
    }
    int m = (l + r) / 2;
    return counts(arr, l, m) + counts(arr, m + 1, r) + merge(arr, l, m, r);
}

public static int merge(int[] arr, int l, int m, int r) {
    int ans = 0;
    int j = m + 1;

    /*
    核心在于,归并时,怎么利用两个数组有序,双指针不回溯地,进行统计 —— 实则类似抽象的归并排序 —— 双指针的移动利用了有序的规则
     */

    for (int i = l; i <= m; i++) {
        while (j <= r) {
            if ((long) arr[i] > (long) arr[j] * 2) {
                j++;
            }else {
                break;
            }
        }
        ans += j - (m + 1);
    }
    int i = l;
    int a = l;
    int b = m+1;
    while (a<=m && b<=r){
        if(arr[a]<arr[b]){
            help[i] = arr[a];
            a++;
            i++;
        }else {
            help[i] = arr[b];
            b++;
            i++;
        }
    }
    while (a<=m){
        help[i] = arr[a];
        a++;
        i++;
    }
    while (b<=r){
        help[i] = arr[b];
        b++;
        i++;
    }
    for (int k = l; k <= r; k++) {
        arr[k] = help[k];
    }
    return ans;
}

基本框架和归并排序没有区别了,唯一一点是在merge时完成统计

  • 另外有一点:其实merge时只是计算了我们上面所说的左边对右边的部分中产生的结果,但其实基本上是所有的结果,因为最底层的merge是两个元素之间的嘛,再往上一层层merge并起来;也就是说,其实之前说的统计左边部分,右边部分,左边对右边的部分本质上都是在计算左边对右边的部分。

例三:LeetCode 315

image.png

可以用归并吗?明显可以,因为可以划分出左右,而且也可以通过排序优化比大小的逻辑。

代码:(我的)

public List<Integer> countSmaller(int[] nums) {
    //问题是:排序导致加错位置了
//        Map<Integer,Integer> map = new HashMap<>();
//        for (int i = 0; i < nums.length; i++) {
//            map.put(nums[i],i);
//        }
        int[] indexs = new int[nums.length];
        for (int i = 0; i < nums.length; i++) {
            indexs[i] = i;
        }
        int[] counts = counts(nums, 0, nums.length - 1,indexs);
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < counts.length; i++) {
            list.add(counts[i]);
        }
        return list;
    }

    public static int[] counts(int[] nums, int l, int r,int[] indexs) {
        int[] help = new int[nums.length];
        if (l == r) {
            return help;
        }
        int m = (l + r) / 2;
        int[] counts = counts(nums, l, m,indexs);
        int[] counts2 = counts(nums, m + 1, r,indexs);
        int[] counts3 = merge(nums, l, m, r,indexs);

        for (int i = 0; i < nums.length; i++) {
            counts[i] = counts[i] + counts2[i] + counts3[i];
        }
        return counts;
    }

    public static int[] merge(int[] nums, int l, int m, int r,int[] indexs) {
        int[] ans = new int[nums.length];
        int j = m + 1;
        for (int i = l; i <= m; i++) {
            while (j <= r && nums[i] > nums[j]) {
                j++;
            }
//            ans[i]+=j-m-1;
            ans[indexs[i]]+=j-m-1;
        }
        int i = l;
        int a = l;
        int b = m+1;
        int[] help = new int[nums.length];
        int[] indexHelp = new int[nums.length];
        while (a<=m && b<=r) {
            if(nums[a]<nums[b]){
                help[i] = nums[a];
                indexHelp[i] = indexs[a];
                i++;
                a++;
            }else {
                help[i] = nums[b];
                indexHelp[i] = indexs[b];
                i++;
                b++;
            }
        }
        while (a<=m){
            help[i] = nums[a];
            indexHelp[i] = indexs[a];
            i++;
            a++;
        }
        while (b<=r){
            help[i] = nums[b];
            indexHelp[i] = indexs[b];
            i++;
            b++;
        }
        for (int k = l; k <= r; k++) {
            nums[k] = help[k];
            indexs[k] = indexHelp[k];
        }
        return ans;
    }

这里面有一个陷阱,但并不和归并的思路冲突

陷阱是:每当我们获取到左边/右边得到的结果(一个对应每个元素的数组,是基于两部分还未排序的顺序的),在这一层merge时,是基于新的顺序得到的结果数组,这导致每个merge产生的结果数组顺序都不同,导致加错位了。

解决方法就是用一个同等大小的数组标记该索引元素的实际位置,每次merge排序时同步移动此数组的元素。

例四:LeetCode 327

image.png

这个题可以用归并吗?看似可以哦:分别考虑左边数组的子范围,右边数组的子范围,再考虑左边跨右边数组的子范围。

但其实有一个问题 ———— 左边的元素和右边的元素并不是比较关系,导致无法有效利用有序的信息

本题中是判断区间内元素的和是否在范围内,这样的话无论怎么移动哪个指针都不会有明确的结果。

那怎么办?————想想办法转化成左右比大小的类型————将原数组转化成前序和数组,那么原数组区间内的和就是新数组两个元素的差值。

代码:

public int countRangeSum(int[] nums, int lower, int upper) {
    long s = 0;
    long[] sum = new long[nums.length + 1];
    for (int i = 0; i < nums.length; ++i) {
        s += nums[i];
        sum[i + 1] = s;
    }
    return countRangeSumRecursive(sum, lower, upper, 0, sum.length - 1);
}

public int countRangeSumRecursive(long[] sum, int lower, int upper, int left, int right) {
    if (left == right) {
        return 0;
    } else {
        int mid = (left + right) / 2;
        int n1 = countRangeSumRecursive(sum, lower, upper, left, mid);
        int n2 = countRangeSumRecursive(sum, lower, upper, mid + 1, right);
        int ret = n1 + n2;

        // 首先统计下标对的数量
        int i = left;
        int l = mid + 1;
        int r = mid + 1;
        while (i <= mid) {
            while (l <= right && sum[l] - sum[i] < lower) {
                l++;
            }
            while (r <= right && sum[r] - sum[i] <= upper) {
                r++;
            }
            ret += r - l;
            i++;
        }

        // 随后合并两个排序数组
        long[] sorted = new long[right - left + 1];
        int p1 = left, p2 = mid + 1;
        int p = 0;
        while (p1 <= mid || p2 <= right) {
            if (p1 > mid) {
                sorted[p++] = sum[p2++];
            } else if (p2 > right) {
                sorted[p++] = sum[p1++];
            } else {
                if (sum[p1] < sum[p2]) {
                    sorted[p++] = sum[p1++];
                } else {
                    sorted[p++] = sum[p2++];
                }
            }
        }
        for (int j = 0; j < sorted.length; j++) {
            sum[left + j] = sorted[j];
        }
        return ret;
    }
}

还是有难度的哎