您现在的位置是:首页 >技术教程 >归并排序的一些拓展应用网站首页技术教程

归并排序的一些拓展应用

愿为梧桐木 2024-06-14 17:20:10
简介归并排序的一些拓展应用

1. 归并排序

1.1. 归并排序的递归解法

归并排序的整体思路是利用递归, 先让左边排好序, 再让右边排好序, 然后通过 merge 操作让整体有序.

merge 操作其实就是合并两个有序数组, 这些内容写以前的博客中也写到过, 就不赘述了.

归并排序可以做到时间复杂度 O(N*logN), 但是 merge 过程需要辅助数组, 所以额外空间复杂度为 O(N).

代码实现:

/**
 * 递归法归并排序
 */
public static void mergeSort1(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    process(arr, 0, arr.length - 1);
}

// 把arr[left..right]排有序
public static void process(int[] arr, int left, int right) {
    if (left == right) { // base case
        return;
    }
    // 求中点
    int mid = left + ((right - left) >> 1);
    // 让左边有序
    process(arr, left, mid);
    // 让右边有序
    process(arr, mid + 1, right);
    // 合并左右两边, 让整体有序
    merge(arr, left, mid, right);
}
// arr[left……mid]已经有序
// arr[mid+1……rright]也已经有序
// 将arr[left……right]整体变有序
public static void merge(int[] arr, int left, int mid, int right) {
    // 辅助数组
    int[] help = new int[right - left + 1];
    int i = 0;
    int p1 = left;
    int p2 = mid + 1;
    while (p1 <= mid && p2 <= right) {
        // 谁小拷贝谁到辅助数组中
        help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
    }
    // 左边或者右边剩余部分直接拷贝到辅助数组中
    while (p1 <= mid) {
        help[i++] = arr[p1++];
    }
    while (p2 <= right) {
        help[i++] = arr[p2++];
    }
    // 将辅助数组的最终结果拷贝到原数组
    for (i = 0; i < help.length; i++) {
        arr[left + i] = help[i];
    }
}

1.2. 归并排序的迭代版本

因为任何递归函数都可以用非递归函数来实现, 所以, 归并排序有对应的迭代方法, 思路如下:

  1. 设置一个步长, 从 1 开始, 1, 2, 4, 8, 16……2^n 方式递增.
  2. 每次处理对应步长的数组区间范围内的排序.
  3. 步长超过或者等于数组长度, 则整个数组排序完成.

比如 [1,3,4,2,5,6,4,6,8]

先设置步长为 1, 数组分成如下区间

[0……1],[2……3],[4……5],[6……7],[8……8]

注: 最后一组不够分, 则单独作为一组处理.

将如上区间内部排好序, 得到的数组为

[1,3,2,4,5,6,4,6,8]

然后设置步长为 2, 数组分成如下区间

[0……3],[4……7],[8……8]

然后将上述区间内部先排好序, 得到数组为

[1,2,3,4,4,5,6,6,8]

然后设置步长为 4, 数组分成如下区间

[0……7],[8……8]

然后将上述区间内部先排好序, 得到数组为

[1,2,3,4,4,5,6,6,8]

最后设置步长为 8, 数组只有一个区间, 直接排序, 得到最后结果

[1,2,3,4,4,5,6,6,8]

代码实现:

/**
 * 迭代版本归并排序
 */
public static void mergeSort2(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    int len = arr.length;
    // // 步长,1,2,4,8…….
    int step = 1;
    while (step < len) {
        // 当前左组的,第一个位置
        int left = 0;
        while (left < len) {
            if (step >= len - left) {
                // 没有右组
                break;
            }
            int mid = left + step - 1;
            // 右组不能越界
            int right = mid + Math.min(step, len - mid - 1);
            merge(arr, left, mid, right);
            left = right + 1;
        }
        // 防止溢出
        if (step > len / 2) {
            break;
        }
        step <<= 1;
    }
}

2. 小和问题

在一个数组中, 一个数左边比它小的数的总和, 叫该数的小和, 所有数的小和累加起来, 叫数组小和.

例子: [1,3,4,2,5]

1左边比1小的数: 没有

3左边比3小的数: 1

4左边比4小的数: 1, 3

2左边比2小的数: 1

5左边比5小的数: 1, 3, 4, 2

所以数组的小和为1+1+3+1+1+3+4+2=16

这里要求给定一个数组arr, 求数组小和.

在线OJ: 数组单调和

仔细琢磨一下这句话, 每一个数左边比当前数小的数累加起来是数组的小和, 我们可以把问题转化一下: 就小和就是统计每个元素前有多少小于该数的元素之和.

这个题目其实是可以用到归并的思想, 归并在合并两个已经排好序的部分时, 是分开前后比较大小, 然后放入辅助空间的, 我们可以利用这个合并比较放入辅助空间的过程, 来实现后一部分中找到比前一部分某一个数大的有多少个, 然后用数量乘以该数即可算出包含该数的最小和.

举个栗子说明一下:

我们假设下面这两个数组是同一个数组在归并过程已经分好的左组和右组,

  • 左组: [1,4,6]
  • 右组: [3,5,9]

这个时候 merge 一下, 我们要创建一个新的辅助数组 [0,0,0,0,0,0],

我们先比较两个数组的第一个元素, 发现 1 比 3 小,

那我们根据题目, 1 在 3 的左边, 那这个 1 是不是要被加, 那这个时候我们创建一个 sum,

那我们这个 1 要被加几次呢?

是不是右组还有多少个数就要被加几次, 因为数字 1 在右组的左边,

那我们比较左组的第二个元素和右组的第一个元素,

我们发现右组的元素比左组元素小, 那这个时候我们只是拷贝不做其他的, 这个是为什么呢?

也可以理解, 因为我们左边的数字比右边大, 是不用加的,

那如果两个数相同的时候, 是放哪个呢?

应该先放右边的, 因为我们是在右组中找比左组大的元素, 此时我们是要找到这个大的元素, 千万不能此时就去放左边的, 会算漏的.

这样往复下来, 整个归并的过程就会把该算的都给累加上, 不会有遗漏, 因为在两个部分归并的时候,虽然我们只算后一部分对前一部分的最小和, 但此时前一部分是已经算过了的, 前一部分也可以划一半, 那一半也经历过一次归并

代码实现:

public class SmallSum {
	public static int smallSum(int[] arr) {
		if (arr == null || arr.length < 2) {
			return 0;
		}
		return process(arr, 0, arr.length - 1);
	}

	public static int process(int[] arr, int left, int right) {
		if (left == right) {
			return 0;
		}
		// left < right
		int mid = left + ((right - left) >> 1);
		return 
				process(arr, left, mid)
				+ 
				process(arr, mid + 1, right)
				+ 
				merge(arr, left, mid, right);
	}

	public static int merge(int[] arr, int left, int mid, int right) {
		int[] help = new int[right - left + 1];
		int i = 0;
		int p1 = left;
		int p2 = mid + 1;
		int res = 0;
		while (p1 <= mid && p2 <= right) {
			res += arr[p1] < arr[p2] ? (right - p2 + 1) * arr[p1] : 0;
			help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
		}
		while (p1 <= mid) {
			help[i++] = arr[p1++];
		}
		while (p2 <= right) {
			help[i++] = arr[p2++];
		}
		for (i = 0; i < help.length; i++) {
			arr[left + i] = help[i];
		}
		return res;
	}
}

这里的代码其实只是在上面归并代码基础上多了几行, 但就这几行也得花点时间去理解, 区别有以下几点:

  1. 一开始的调用函数 smallSum, 返回值有了确定的 int, 不在是 void, 为了返回最小和.
  2. 后面的主递归函数, 不是直接调用两个函数, 而是返回值两个递归过程和 merge 过程的结果想加; 逻辑上是: 左边部分的最小和加上右边部分的最小和, 再加上两个部分合并的最小和.
  3. 如何的合并过程的函数有了返回值, 然后在第一个合并的 while 过程中加入了最小值的运算:
    res += arr[p1] < arr[p2] ? (right - p2 + 1) * arr[p1] : 0;

还要注意这里给出的在线 OJ 给小和的定义是小于等于, 而这里的定义是小于, 只要改一下合并过程的符号即可.

res += arr[p1] <= arr[p2] ? (right - p2 + 1) * arr[p1] : 0;
help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];

3. 逆序对问题

在一个数组中, 任何一个前面的数 a, 和任何一个后面的数 b, 如果 (a,b) 是降序的, 就称为逆序对, 这里要求给定一个数组 arr, 求数组的降序对总数量.

在线OJ: 剑指 Offer 51. 数组中的逆序对

这个也和上面思路是差不多的, 可以把原来的升序的归并排序的合并过程改一下, 原来的 merge 是从前往后合并, 这里改成从后往前合并, 这样针对这道题就更方便一些, 然后在合并的过程中, 像小和那样, 找出所有右边比左边小的数.

代码实现;

class Solution {
    public static int reversePairs(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        return process(arr, 0, arr.length - 1);
    }
    public static int process(int[] arr, int left, int right) {
        if (left == right) {
            return 0;
        }
        // left < right
        int mid = left + ((right - left) >> 1);
        return process(arr, left, mid) + process(arr, mid + 1, right) + merge(arr, left, mid, right);
    }

    public static int merge(int[] arr, int left, int mid, int rgith) {
        int[] help = new int[rgith - left + 1];
        int i = help.length - 1;
        int p1 = mid;
        int p2 = rgith;
        int res = 0;
        while (p1 >= left && p2 > mid) {
            res += arr[p1] > arr[p2] ? (p2 - mid) : 0;
            help[i--] = arr[p1] > arr[p2] ? arr[p1--] : arr[p2--];
        }
        while (p1 >= left) {
            help[i--] = arr[p1--];
        }
        while (p2 > mid) {
            help[i--] = arr[p2--];
        }
        for (i = 0; i < help.length; i++) {
            arr[left + i] = help[i];
        }
        return res;
    }
}

这个题也可以使用降顺归并排序实现, 整体思想还是一样的, 代码实现如下:

public class Solution {
    public static int reversePairs(int[] arr) {
        if (null == arr || arr.length < 2) {
            return 0;
        }
        return process(arr, 0, arr.length - 1);
    }

    private static int process(int[] arr, int left, int right) {
        if (left == right) {
            return 0;
        }
        int mid = left + ((right - left) >> 1);
        return process(arr, left, mid) + process(arr, mid + 1, right) + merge(arr, left, mid, right);
    }

    private static int merge(int[] arr, int left, int mid, int rgiht) {
        int[] help = new int[rgiht - left + 1];
        int i = 0;
        int p1 = left;
        int p2 = mid + 1;
        int res = 0;
        while (p1 <= mid && p2 <= rgiht) {
            res += arr[p1] > arr[p2] ? rgiht - p2 + 1 : 0;
            help[i++] = arr[p1] > arr[p2] ? arr[p1++] : arr[p2++];
        }
        while (p1 <= mid) {
            help[i++] = arr[p1++];
        }
        while (p2 <= rgiht) {
            help[i++] = arr[p2++];
        }
        for (i = 0; i < help.length; i++) {
            arr[left + i] = help[i];
        }
        return res;
    }
}

4. 翻转对问题

在线OJ: 493. 翻转对

本题也是利用 merge 过程, 不同于上两个问题, 本题在 merge 两个区间之前, 就要先统计一下 num[i] > 2 * num[j] 的数量.

代码实现:

class Solution {
    public static int reversePairs(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        return process(arr, 0, arr.length - 1);
    }

    public static int process(int[] arr, int left, int right) {
        if (left == right) {
            return 0;
        }
        // left < right
        int mid = left + ((right - left) >> 1);
        return process(arr, left, mid) + process(arr, mid + 1, right) + merge(arr, left, mid, right);
    }

    public static int merge(int[] arr, int left, int mid, int rgiht) {
        // [left....M] [M+1....R]
        int ans = 0;
        // windowR是能取到的值的后一个位置(取不到的位置), 包括进来的数是从[M+1, windowR)
        int windowR = mid + 1;
        for (int i = left; i <= mid; i++) {
            while (windowR <= rgiht && (long) arr[i] > (long) arr[windowR] * 2) {
                windowR++;
            }
            ans += windowR - mid - 1;
        }

        // 以下是经典merge过程
        int[] help = new int[rgiht - left + 1];
        int i = 0;
        int p1 = left;
        int p2 = mid + 1;
        while (p1 <= mid && p2 <= rgiht) {
            help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
        }
        while (p1 <= mid) {
            help[i++] = arr[p1++];
        }
        while (p2 <= rgiht) {
            help[i++] = arr[p2++];
        }
        for (i = 0; i < help.length; i++) {
            arr[left + i] = help[i];
        }
        return ans;
    }
}

5. 区间和的个数问题

在线OJ: 327. 区间和的个数

首选我们可以根据给出的数组来得到一个前缀和数组 preSum, preSum[ i ] 代表的是 [ 0, i ] 的前缀和, 那么区间 [i, j] 的区间和就等于 preSum[ j ] - prefiaSum[ i- 1 ],

此时原问题就转化为给定前缀和数组 preSum, 存在多少个数对 [ i, j ] (i<=j), 使得 preSum[ j ] - preSum[ i-1 ][lower, upper] 范围内, 后面的思路都是基于这个转化后的问题,

此时我们可以再把问题转化一下, 所求的结果就相当于所有以每一个位置的元素结尾的子区间有多少个在 [ lower, upper] 范围内, 此时就可以考虑用归并的过程来实现,

每次合并操作, 对于右组中的每一个数 preSum[ i ], 求左组所有数中有多少个范围在 [preSum[ i ] - upper, preSum[ i ] - lower] 上, 将满足条件的个数累加起来即为最后的结果, 要注意这个题在归并排序的 merge 方法内, 统计满足条件的累加和个数和合并操作要分开进行.

merge 过程中, [ preSum[ i ] - upper, preSum[ i ] - lower ]是存在单调性的, 每次区间的上下界都是增长的, 这样就保证了整个过程不需要回退就可以完成, 不会增加归并的整体时间复杂度.

class Solution {
    public static int countRangeSum(int[] nums, int lower, int upper) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        // 前缀和数组
        long[] preSum = new long[nums.length];
        preSum[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            preSum[i] = preSum[i - 1] + nums[i];
        }
        return process(preSum, 0, preSum.length - 1, lower, upper);
    }

    public static int process(long[] preSum, int left, int right, int lower, int upper) {
        if (left == right) {
            return preSum[left] >= lower && preSum[left] <= upper ? 1 : 0;
        }
        int M = left + ((right - left) >> 1);
        return process(preSum, left, M, lower, upper) + process(preSum, M + 1, right, lower, upper)
                + merge(preSum, left, M, right, lower, upper);
    }

    public static int merge(long[] arr, int left, int mid, int right, int lower, int upper) {
        // 先进行统计
        int ans = 0;
        int windowL = left;
        int windowR = left;
        // [windowL, windowR)
        // 区间和存在单调性, 使用滑动窗口定位上下界, 不回退, 所以O(logN)
        for (int i = mid + 1; i <= right; i++) {
            long min = arr[i] - upper;
            long max = arr[i] - lower;
            while (windowR <= mid && arr[windowR] <= max) {
                windowR++;
            }
            while (windowL <= mid && arr[windowL] < min) {
                windowL++;
            }
            ans += windowR - windowL;
        }

        // merge经典实现
        long[] help = new long[right - left + 1];
        int i = 0;
        int p1 = left;
        int p2 = mid + 1;
        while (p1 <= mid && p2 <= right) {
            help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
        }
        while (p1 <= mid) {
            help[i++] = arr[p1++];
        }
        while (p2 <= right) {
            help[i++] = arr[p2++];
        }
        for (i = 0; i < help.length; i++) {
            arr[left + i] = help[i];
        }
        return ans;
    }
}

6. 总结

对于所有类似的, 找一个数组(部分)前面与后面两个元素关系的, 可以考虑使用归并排序的改写, 用递归方式进行求解; 要注意的是, 对两部分已经排好序的性质的运用, 像最小和, 求个数, 那么直接 right-p2+1 得到所有比 arr[ p1 ] 大的数的个数, 可以这样做原因仅仅是因为已经是排好序的, p2 后面的部分都大于 arr[ p2 ].

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。