归并排序

143 阅读12分钟

前文,我们讲解的递归是什么,要实现递归的三要素,递归在二分查找中的应用,这篇文章就讲一下递归在排序中的应用,如何将排序的时间复杂度从选择排序、插入排序的 O(n^2) 提升至 O(nlogn)

归并排序 Merge Sort

归并排序会把序列分成长度相同的两个子序列,直至无法继续往下拆分(也就是每个子序列中只有一个数据),这是的部分。

当子序列无法继续往下分,就对子序列进行归并(把两个排好序的子序列合并成一个有序序列),该操作会一直重复执行,直到所有子序列都归并为一个整体为止,这是的部分。

比如对数组 6,4,3,7,5,1,2 进行归并排序:

⨳ 要想对 6,4,3,7,5,1,2 进行排序,必先对 6,4,3,75,1,2 进行排序;

⨳ 要想对 6,4,3,7 进行排序,必先对 6,43,7 进行排序;

⨳ 要想对 6,4 进行排序,必先对 64 进行排序;

⨳ 要想对 64 进行排序,因子数组序列只有一个元素不需要排序,为递归终止条件。

image.png

递的过程写的这,就能写出归并排序的大致逻辑了:

// 对 数组 arr 的 [head,tail] 进行排序
public void  mergeSort(int[] arr,int head,int tail){
    if(head>=tail){
        return; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
    }
    // 拆分数组
    int mid = (head+tail) / 2 ;
    // 对 arr[head,mid] 进行排序
    mergeSort(arr,head,mid);
    // 对 arr[mid+1,tail] 进行排序
    mergeSort(arr,mid+1,tail);
    // 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
    merge(arr,head,mid,tail);
}

直观上,mergeSort 调用了更小子序列的 mergeSort,不用深入进去,仅仅需要知道,当代码执行到第 11 行时,arr[head,mid] 已经排好序了,arr[mid+1,tail] 也已经排好序了,你要做的仅仅是将两个有序的子序列合并。

如果不理解,可以再回想一下递归那篇讲的例题:

数组求和:已知函数 sum(arr,k) 可以计算出 arr[0,k] 中的和,现在求 sum(arr,n),直接调用 sum(arr,n-1) + arr[n] 即可

第n个斐波那契数:已知函数 fib(k) 可以计算出第 k 个斐波那契数是多少,所以求解fib(n),直接调用 fib(n-1) + fib(n-2) 即可

反转链表:已知函数 reverseList(head) 可以将 head 及其以后得链表进行反转,现在要反转以 node1 作为头节点的链表,只需要调用 reverseList(node1.next) 反转 node1 之后的子链表,再将 node1 挂载到子链表后面即可。

回归正题,mergeSort(arr,head,mid) 已经将 arr[head,mid] 排好序了,mergeSort(arr,mid+1,tail) 已经将 arr[mid+1,tail] 排好序了,现在要做的是怎么将两个排好序的子序列合并。

咋合并呢?

合并过程

这不就是数组 之 双指针法2 讲的合并两个有序数组题嘛。 leetcode.cn/problems/me…

给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。

请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。

注意: 最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。

这道题我们是使用双指针分别指向两个数组的开头,依次比较,较小的元素赋值到新数组中,最后新数组再覆盖 nums1

同样,对于 arr[head,mid]arr[mid+1,tail] 的合并也可以使用双指针进行头部比较,较小的插入到新数组,然后新数组的元素覆盖 [head,tail]

也可以反过来操作,先将 [head,tail] 赋值给新数组,双指针指向新数组进行头部比较,较小的插入到原数组 [head,tail] 位置。

这里采取反向操作:

// 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
private void merge(int[] arr, int head, int mid, int tail) {

    // 新数组
    int[] new_arr = new int[tail-head+1];
    int head_index = head;
    for(int i=0;i<new_arr.length;i++){
        new_arr[i] = arr[head_index++];
    }
    // 双指针指向新数组
    int left_head_index = 0; // 新数组左半部分遍历开始位置
    int left_tail_index = mid-head; // 新数组 左半部分遍历结束位置
    int right_head_index = mid-head+1; // 新数组 右半部分遍历开始位置
    int right_tail_index = tail-head; // 新数组 右半部分遍历结束位置

    for(int i=head;i<=tail;i++){
        // 新数组左半部分遍历完了,右半部分 还有剩余
        if(left_head_index > left_tail_index && right_head_index <= right_tail_index){
            arr[i] = new_arr[right_head_index++];
            continue;
        }
        // 右半部分遍历完了,左半部分 还有剩余
        if(right_head_index > right_tail_index && left_head_index <= left_tail_index){
            arr[i] = new_arr[left_head_index++];
            continue;
        }

        // 左半部分指针指向的元素比较小
        if(new_arr[left_head_index]  <= new_arr[right_head_index]){
            arr[i] = new_arr[left_head_index++];
        }else{
            arr[i] = new_arr[right_head_index++];
        }

    }
}

到此为止,合并结束。

回头再分析一下合并 merge 函数什么时候开始调用的。

arr[head,mid]arr[mid+1,tail] 区间最多只有一个元素时,mergeSort 函数 return,开始合并,最开始合并成两个元素的数组,再两两合并,再四四合并....直至全部合并完成。

image.png

合并优化

merge 函数可以合并 arr[head,mid]arr[mid+1,tail] 这两个有序数列,如果在合并操作前, arr[mid] 就已经小于 arr[mid+1] 了,也就是说 arr[head,tail] 已经是有序的了,那就不需要合并了吧。

 // 对 数组 arr 的 [head,tail] 进行排序
public void  mergeSort(int[] arr,int head,int tail){
    if(head>=tail){
        return; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
    }
    // 拆分数组
    int mid = (head+tail) / 2 ;
    // 对 arr[head,mid] 进行排序
    mergeSort(arr,head,mid);
    // 对 arr[mid+1,tail] 进行排序
    mergeSort(arr,mid+1,tail);
    if(arr[mid]>arr[mid+1]){
        // 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
        merge(arr,head,mid,tail);
    }
   
}

如果数组原本就是有序的,那 merge 函数一次也不用执行,时间复杂度减低到 O(n)

合并操作不就是将两个有序子序列合并排序嘛,不一定非得使用创建新数组进行双指针头插法,用其他排序也是可以的,如果数据规模很小,用插入排序也许会更快一些。

 // 对 数组 arr 的 [head,tail] 进行排序
public void  mergeSort(int[] arr,int head,int tail){
   // if(head>=tail){
   //     return; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
   // }
    if(tail-head<=20){
        // 对数据规模小的序列,插入排序也许会比双指针头插法快一点
        insertionSort(arr,head,tail);
        return;
    }
    // 拆分数组
    int mid = (head+tail) / 2 ;
    // 对 arr[head,mid] 进行排序
    mergeSort(arr,head,mid);
    // 对 arr[mid+1,tail] 进行排序
    mergeSort(arr,mid+1,tail);
    if(arr[mid]>arr[mid+1]){
        // 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
        merge(arr,head,mid,tail);
    }
   
}

再看创建新数组双指针头插法,每次调用 merge 函数合并,都要创建一个新数组,这涉及到内存的开辟和回收,很费性能。

如果在归并排序前,先创建一个大小和原数组容量相同的数组,每次 merge 操作都使用这个已经创建好的数组进行双指针头插,不就避免了新数组的创建与回收了嘛。

非递归实现

非递归实现就是不需要问题递出去,不需要压栈的过程,直接从最小问题出发,使用循环一步步将问题解决。

对于归并排序来说,最小问题是将两个大小为一的有序数组进行合并,整个循环要做的事情如下:

⨳ 第一次循环,将相邻的两个元素看作两个大小为 1 的有序数组,不断进行 merge 操作,直至整个数组每两个元素都是有序的

⨳ 第二次循环,将相邻的四个元素看作两个大小为 2 的有序数组,不断进行 merge 操作,直至整个数组每四个元素都是有序的

⨳ 第二次循环,将相邻的八个元素看作两个大小为 4 的有序数组,不断进行 merge 操作,直至整个数组每八个元素都是有序的

⨳ ....

循环从合并两个大小为 1 的有序数组开始,当有序数组的长度等于或大于整个数组的长度时,循环结束。

// 归并排序,非递归实现
public void  mergeSort(int[] arr){

    int arr_size = arr.length;//数组的长度
    int merge_size = 1;// 合并区间的长度

    while(merge_size< arr_size){
        // 寻找合并区间的起始位置
        int left_head = 0; // 左区间起止位置 [head,head+merge_size-1]
        int right_head = left_head+merge_size;// 右区间起止位置 [head+merge_size,head+merge_size*2-1] 或者 [head+merge_size,arr_size-1]
        // 当存在右区间时,才进行合并
        while(right_head<arr_size){
            int mid = left_head+merge_size-1;
            int tail = Math.min(right_head+merge_size-1,arr_size-1);
            // 将 arr[head,mide] 和 arr[mid+1,tail] 进行合并
            merge(arr,left_head,mid,tail);
            left_head = left_head+merge_size+merge_size; // 下一个合并区间的起始位置
            right_head = left_head+merge_size;
        }
        merge_size=merge_size*2; // 合并区间的长度翻倍
    }
}

交易中逆序对的总数

leetcode.cn/problems/sh…

在股票交易中,如果前一天的股价高于后一天的股价,则可以认为存在一个「交易逆序对」。

请设计一个程序,输入一段时间内的股票交易记录 record,返回其中存在的「交易逆序对」总数。

举例,record = [9, 7, 5, 4, 6],逆序对为 (9, 7), (9, 5), (9, 4), (9, 6), (7, 5), (7, 4), (7, 6), (5, 4)

最直观的解法就是两层暴力循环:

class Solution {
    public int reversePairs(int[] nums) {
        int res = 0;
        for(int i = 0; i < nums.length; i ++)
            for(int j = i + 1; j < nums.length; j ++)
                if(nums[i] > nums[j]) res ++;
        return res;
    }
}

如果使用归并排序的思想,怎么解决呢?

归并的含义是将左右两个相邻的有序区间进行合并,假设左区间为[left_head,left_tail],右区间为 [right_head,right_tail],左区间头部指针指向的元素为 left_ele,右区间头部指针指向的元素为 right_ele,如果只考虑这两个相邻区间中的元素有哪些逆序对,该怎么做呢:

⨳ 左区间 left_headleft_tail,因为是有序的,所以这一部分不会产生逆序对;

⨳ 右区间 right_headright_tail,因为是有序的,所以这一部分也不会产生逆序对;

⨳ 如果 left_ele 小于等于 right_ele ,则说明 left_ele 一定是两个有序区间中最小的元素,不会和任何元素组成逆序对;

⨳ 如果 left_ele 大于 right_ele ,则说明 right_ele一定是比整个 [left_head,left_tail] 区间中的元素小,也就是说,right_ele 可以和[left_head,left_tail] 区间中的任意元素构成逆序对。

对于两个有序区间进行 头指针比较 归并时,如果归并的是左区间的头部元素 left_ele,则不会产生逆序对,如果归并的是右区间的头部元素 right_ele,那左区间还剩几个元素,就会产生的几个与 right_ele 组成的逆序对。

对于两个有序区间,可以在归并排序中,顺带着可以将逆序对计算出来,那对于整个无序数组,这种解法好使吗?

更具体一点,当两个有序区间归并完了,逆序对也计算出来了,但归并完后这两个区间就有序了,这不会影响其他区间与该区间的逆序对的计算吗?

事实上是不会影响的,归并排序只会改变区间中元素的位置,而不改变与其他区间的相对位置,而且归并完成后,这个有序区间中的元素不会和其他同在这一区间的元素形成逆序对,也不会存在重复计算的问题。

那就改造一下归并排序代码呗。

class Solution {
    public int reversePairs(int[] record) {
        int head = 0;
        int tail = record.length-1;
        int[] tmp_arr = new int[record.length];
        return mergeSort(record,head,tail,tmp_arr);
    }



    // 对 数组 arr 的 [head,tail] 进行排序
    public int  mergeSort(int[] arr,int head,int tail,int[] tmp_arr){
        if(head>=tail){
            return 0; // 最小问题,[head,tail] 中只有一个元素或没有元素,无需排序
        }
        int pairs = 0;
        // 拆分数组
        int mid = (head+tail) / 2 ;
        // 对 arr[head,mid] 进行排序
        pairs = pairs + mergeSort(arr,head,mid,tmp_arr);
        // 对 arr[mid+1,tail] 进行排序
        pairs = pairs + mergeSort(arr,mid+1,tail,tmp_arr);
        if(arr[mid]>arr[mid+1]){
            // 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
            pairs = pairs +  merge(arr,head,mid,tail,tmp_arr);
        }
        return pairs;

    }
    // 将 arr[head,mid] 和 arr[mid+1,tail] 进行合并
    private int merge(int[] arr, int head, int mid, int tail,int[] tmp_arr) {

        // 临时数组
        int head_index = head;
        for(int i=head;i<=tail;i++){
            tmp_arr[i] = arr[head_index++];
        }
        // 双指针指向新数组
        int left_head_index = head; // 新数组左半部分遍历开始位置
        int left_tail_index = mid; // 新数组 左半部分遍历结束位置
        int right_head_index = mid+1; // 新数组 右半部分遍历开始位置
        int right_tail_index = tail; // 新数组 右半部分遍历结束位置

        int pairs = 0;
        for(int i=head;i<=tail;i++){
            // 新数组左半部分遍历完了,右半部分 还有剩余(没有逆序对)
            if(left_head_index > left_tail_index && right_head_index <= right_tail_index){
                arr[i] = tmp_arr[right_head_index++];
                continue;
            }
            // 右半部分遍历完了,左半部分 还有剩余(没有逆序对)
            if(right_head_index > right_tail_index && left_head_index <= left_tail_index){
                arr[i] = tmp_arr[left_head_index++];
                continue;
            }

            // 左半部分指针指向的元素比较小
            if(tmp_arr[left_head_index]  <= tmp_arr[right_head_index]){
                arr[i] = tmp_arr[left_head_index++];  // 没有逆序对
            }else{
                arr[i] = tmp_arr[right_head_index++];
                pairs = pairs + (mid-left_head_index +1);

            }

        }
        return pairs;
    }

}