3

前言

这次我们介绍另一种时间复杂度为O(nlogn)的排序算法叫做归并排序。归并排序在数据量大且数据递增或递减连续性好的情况下,效率比较高,且是O(nlogn)复杂度下唯一一个稳定的排序。

自顶向下的归并排序

归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序是一种稳定的排序方法。

实现归并的一种直截了当的办法是将两个不同的有序数组归并到第三个数组中。实现的方法很简单,创建一个适当大小的数组然后将两个输入数组中的元素一个个从小到大放入这个数组中。但是,当用归并将一个大数组排序时,我们需要进行很多次归并,这样每次归并时都创建一个新数组来存储排序结果就会浪费空间,因此我们可以使用原地归并。

原地归并的思路是:同样需要创建一个新数组作为辅助空间,但是这个数组不是用于存放归并后的结果,而是存放归并前的结果,然后将归并后的结果一个个从小到大放入原来的数组中。

原地归并的步骤如下:

  1. 创建一个和需要归并的数组相同的新数组,让k指向原来数组的第一个位置,i指向新数组左半部分的第一个元素,j指向右半部分的一个元素。

  1. 如果i指向的元素ei小于j指向的元素ej,则将ei放入k指向的位置,然后i++指向下一个元素,k++指向下一个需要存放的位置。否则如果ei>ej,则将ej放入k指向的位置,然后j++指向下一个元素,k++指向下一个需要存放的位置。

  1. 如果左半部分i指向的位置已经超过中间位置,而此时右半部分j还未移动到末尾,那么将j指向位置后面的所有元素都移动到k指向位置的后面,反之类似。

下图展示了对数组[8, 7, 6, 5, 4, 3, 2, 1]进行从小到大归并排序的过程:

归并排序的代码:

public static void sort(Comparable[] arr) {
    
    int n = arr.length;
    sort(arr, 0, n - 1);
}

// 递归使用归并排序,对arr[l...r]的范围进行排序
private static void sort(Comparable[] arr, int l, int r) {
    
    if (l >= r) {
        return;
    }
    // 这种写法防止溢出
    int mid = l + (r - l) / 2;
    sort(arr, l, mid);
    sort(arr, mid + 1, r);
    merge(arr, l, mid, r);
}

// 将arr[l...mid]和arr[mid+1...r]两部分进行归并
private static void merge(Comparable[] arr, int l, int mid, int r) {
    
    Comparable[] aux = Arrays.copyOfRange(arr, l, r + 1);
    // 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
    int i = l, j = mid + 1;
    for (int k = l; k <= r; k++) {
        if (i > mid) {  // 如果左半部分元素已经全部处理完毕
            arr[k] = aux[j - l];
            j++;
        } else if (j > r) {   // 如果右半部分元素已经全部处理完毕
            arr[k] = aux[i - l];
            i++;
        } else if (aux[i - l].compareTo(aux[j - l]) < 0) {  // 左半部分所指元素 < 右半部分所指元素
            arr[k] = aux[i - l];
            i++;
        } else {  // 左半部分所指元素 >= 右半部分所指元素
            arr[k] = aux[j - l];
            j++;
        }
    }
}

优化1

和快速排序一样,对于小规模数组,我们可以使用直接插入排序。其次,对于近乎有序的数组,我们可以减少归并的次数。

优化的归并排序代码:

public static void sort(Comparable[] arr) {

    int n = arr.length;
    sort(arr, 0, n - 1);
}

private static void sort(Comparable[] arr, int l, int r) {

    // 对于小规模数组, 使用插入排序
    if (r - l <= 15) {
        InsertionSort.sort(arr, l, r);
        return;
    }
    int mid = (l + r) / 2;
    sort(arr, l, mid);
    sort(arr, mid + 1, r);
    // 对于arr[mid] <= arr[mid+1]的情况,不进行merge
    // 对于近乎有序的数组非常有效,但是对于一般情况,有一定的性能损失
    if (arr[mid].compareTo(arr[mid + 1]) > 0) {
        merge(arr, l, mid, r);
    }
}

private static void merge(Comparable[] arr, int l, int mid, int r) {

    Comparable[] aux = Arrays.copyOfRange(arr, l, r + 1);
    // 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
    int i = l, j = mid + 1;
    for (int k = l; k <= r; k++) {
        if (i > mid) {  // 如果左半部分元素已经全部处理完毕
            arr[k] = aux[j - l];
            j++;
        } else if (j > r) {   // 如果右半部分元素已经全部处理完毕
            arr[k] = aux[i - l];
            i++;
        } else if (aux[i - l].compareTo(aux[j - l]) < 0) {  // 左半部分所指元素 < 右半部分所指元素
            arr[k] = aux[i - l];
            i++;
        } else {  // 左半部分所指元素 >= 右半部分所指元素
            arr[k] = aux[j - l];
            j++;
        }
    }
}

优化2

我们对空间进行优化,上述归并排序由于每次调用merge方法都会申请新的辅助空间,递归深度过大,就会造成 OOM。

然而我们可以通过参数的方式传递给子函数,这样只需要在开始的时候申请一次辅助空间。

优化代码:

public static void sort(Comparable[] arr) {

    int n = arr.length;
    Comparable[] aux = new Comparable[n];
    sort(arr, aux, 0, n - 1);
}

private static void sort(Comparable[] arr, Comparable[] aux, int l, int r) {

    // 对于小规模数组, 使用插入排序
    if (r - l <= 15) {
        InsertionSort.sort(arr, l, r);
        return;
    }
    int mid = (l + r) / 2;
    sort(arr, aux, l, mid);
    sort(arr, aux, mid + 1, r);
    // 对于arr[mid] <= arr[mid+1]的情况,不进行merge
    // 对于近乎有序的数组非常有效,但是对于一般情况,有一定的性能损失
    if (arr[mid].compareTo(arr[mid + 1]) > 0) {
        merge(arr, aux, l, mid, r);
    }
}

private static void merge(Comparable[] arr, Comparable[] aux, int l, int mid, int r) {

    System.arraycopy(arr, l, aux, l, r - l + 1);
    // 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
    int i = l, j = mid + 1;
    for (int k = l; k <= r; k++) {
        if (i > mid) {  // 如果左半部分元素已经全部处理完毕
            arr[k] = aux[j];
            j++;
        } else if (j > r) {   // 如果右半部分元素已经全部处理完毕
            arr[k] = aux[i];
            i++;
        } else if (aux[i].compareTo(aux[j]) < 0) {  // 左半部分所指元素 < 右半部分所指元素
            arr[k] = aux[i];
            i++;
        } else {  // 左半部分所指元素 >= 右半部分所指元素
            arr[k] = aux[j];
            j++;
        }
    }
}

自底向上的归并排序

自底向上的归并排序是先归并小数组,然后成对归并得到的子数组,即先进行两两归并(把每个元素想象成大小为 1 的数组),然后是四四归并(把两个大小为 2 的数组归并成一个有 4 个元素的数组),然后是八八归并,一直下去。在每一轮归并中,最后一次归并的第二个可能比第一个子数组要小,否则所有的归并中两个数组的大小都应该一样,而在下一轮中子数组的大小会翻倍。

过程如下图,利用迭代实现:

自底向上的归并排序代码:

public static void sort(Comparable[] arr) {

    int n = arr.length;
    // 外循环控制归并数组的大小
    for (int len = 1; len < n; len += len) {
        // 内循环根据外循环分配的大小进行两两归并
        for (int i = 0; i < n - len; i += len + len) {
            // 对 arr[i...i+len-1] 和 arr[i+len...i+2*len-1] 进行归并
            // 需要满足 i+len < n 且 i+2*len-1 < n
            merge(arr, i, i + len - 1, Math.min(i + len + len - 1, n - 1));
        }
    }
}

private static void merge(Comparable[] arr, int l, int mid, int r) {

    Comparable[] aux = Arrays.copyOfRange(arr, l, r + 1);
    // 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
    int i = l, j = mid + 1;
    for (int k = l; k <= r; k++) {

        if (i > mid) {  // 如果左半部分元素已经全部处理完毕
            arr[k] = aux[j - l];
            j++;
        } else if (j > r) {   // 如果右半部分元素已经全部处理完毕
            arr[k] = aux[i - l];
            i++;
        } else if (aux[i - l].compareTo(aux[j - l]) < 0) {  // 左半部分所指元素 < 右半部分所指元素
            arr[k] = aux[i - l];
            i++;
        } else {  // 左半部分所指元素 >= 右半部分所指元素
            arr[k] = aux[j - l];
            j++;
        }
    }
}

优化

优化思路同上:

  • 对于小数组改用直接插入排序;
  • 对于有序的数组减少归并的次数;
  • 复用辅助数组空间。

优化的代码:

public static void sort(Comparable[] arr) {

    int n = arr.length;
    Comparable[] aux = new Comparable[n];
    // 对于小数组, 使用插入排序优化
    for (int i = 0; i < n; i += 16) {
        InsertionSort.sort(arr, i, Math.min(i + 15, n - 1));
    }
    for (int len = 16; len < n; len += len) {
        for (int i = 0; i < n - len; i += len + len) {
            // 对于arr[mid] <= arr[mid+1]的情况,不进行merge
            if (arr[i + len - 1].compareTo(arr[i + len]) > 0) {
                merge(arr, aux, i, i + len - 1, Math.min(i + len + len - 1, n - 1));
            }
        }
    }
}

private static void merge(Comparable[] arr, Comparable[] aux, int l, int mid, int r) {

    System.arraycopy(arr, l, aux, l, r - l + 1);
    // 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
    int i = l, j = mid + 1;
    for (int k = l; k <= r; k++) {
        if (i > mid) {  // 如果左半部分元素已经全部处理完毕
            arr[k] = aux[j - l];
            j++;
        } else if (j > r) {   // 如果右半部分元素已经全部处理完毕
            arr[k] = aux[i - l];
            i++;
        } else if (aux[i - l].compareTo(aux[j - l]) < 0) {  // 左半部分所指元素 < 右半部分所指元素
            arr[k] = aux[i - l];
            i++;
        } else {  // 左半部分所指元素 >= 右半部分所指元素
            arr[k] = aux[j - l];
            j++;
        }
    }
}

总结

对比归并排序与快速排序:

  • 归并排序是先切分、后排序,快速排序是切分、排序交替进行。
  • 归并排序的递归发生在处理整个数组(先递归切分再对数组排序)之前,快速排序的递归发生在处理整个数组之后(先对数组排序再递归到子数组)。
  • 归并排序是稳定的排序,而快速排序是不稳定的排序。
  • 归并排序在最坏和最好情况下的时间复杂度均为O(nlogn),而快速排序最坏O(n^2),最好O(n)


知识不加糖
51 声望6 粉丝

引用和评论

0 条评论