【基础算法】排序算法 —— 归并排序

 

排序算法 时间复杂度 空间复杂度 稳定性 算法核心
归并排序 O(nlog2n) O(n) 稳定 比较合并

 

一、算法原理

归并排序的核心思想是:

  1. 将待排序数组从中间分成两个子数组,对两个子数组分别排序,然后将排好序的两个子数组合并,这样整个数组就排序完成了。每次合并从两个子数组中逐个取最小值放入合并后数组,这样可以保证合并后的数组有序。
  2. 分治思想。将待排序数组从中间分成两个子数组,再将两个子数组分别从中间分开,一直分到最底层子数组只有1个元素为止。

 

示例:使用归并排序对数组 arr = [8,5,2,7,6,1,3,4] 从小到大排序。

 

归并排序使用的是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成几个小的子问题,小的子问题解决了,大问题也就解决了。

分治思想一般都是用递归来实现的,分治是一种算法思想,递归是一种编程技巧。所以,实现归并排序就需要找到递归的递推公式终止条件

递推公式:
mergeSort(l...r) = merge(mergeSort(l...mid), mergeSort(mid + 1...r))

终止条件:
l >= r 代表分解到子数组只有1个数字,不用再分解

mergeSort(l...r) 表示,给下标从 l 到 r 之间的数组排序。我们将这个排序问题转化为了两个子问题,mergeSort(l…mid) 和 mergeSort(mid+1…r),其中下标 mid 是 l 和 r 的中间位置,也就是 (l+r)/2。当下标从 l 到 mid 和从 mid+1 到 r 这两个子数组都排好序之后,再将两个有序的子数组合并,这样下标从 l 到 r 之间的数据也就排好序了。

 

上述递推公式转换成伪代码如下:

mergeSort(arr, l, r) {
    if (l >= r) { // 递归终止条件
        return
    }
    
    mid = (l + r) / 2
    // 分治递归
    mergeSort(arr, l, mid)
    mergeSort(arr, mid + 1, r)
    // 合并 [l...mid][mid + 1...r] 为 [l...r],保证 [l...r] 有序
    merge(arr[l...mid], arr[mid + 1...r])
}

其中,merge(arr[l...mid], arr[mid + 1...r]) 的作用是将已经有序的 arr[l...mid] 和 arr[mid + 1...r] 合并成一个有序数组。

 

合并时,需要申请一个临时数组 tmp,大小与  arr[l...r] 相同。用两个索引 i 和 j,分别指向 arr[l...mid] 和  arr[mid + 1...r] 的第一个元素,比较这两个元素 arr[i] 和 arr[j],如果 arr[i] <= arr[j],就把 arr[i] 放入临时数组 tmp,并且 i 后移一位,否则将 arr[j] 放入数组 tmp,j 后移一位。重复上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次放入临时数组,这时候,临时数组中存储的就是两个子数组合并之后的结果。最后再把临时数组 tmp 中的数据拷贝到原数组 arr[l...r] 中。图示如下:

merge 函数转换成伪代码如下:

merge(arr, l, mid, r) {
    i = l // 左边子数组索引
    j = mid + 1 // 右边子数组索引
    k = 0 // tmp 数组索引
    tmp = new Array[r - l + 1] // tmp 数组
    
    // 从左右子数组取 较小的数字 放入 tmp 数组,直到左边子数组取完或者右边子数组取完
    while(i <= mid && j <= r) {
        if(arr[i] <= arr[j]) {
            tmp[k] = arr[i]
            i++
        } else {
            tmp[k] = arr[j]
            j++
        }
        k++
    }
    
    // 如果左边子数组还有剩余元素,依次放入 tmp 数组
    while(i <= mid) {
        tmp[k] = arr[i]
        i++
        k++
    }
    
    // 如果右边子数组还有剩余元素,依次放入 tmp 数组
    while(j <= r) {
        tmp[k] = arr[j]
        j++
        k++
    }
    
    // 将 tmp 数组的元素复制到原数组的 l...r 位置
    for(i = 0, len = tmp.length; i < len; i++) {
        arr[l + i] = tmp[i]
    }
}

 

二、代码实现

public static void main(String[] args) {
    int[] arr = {4, 5, 6, 3, 2, 1};
    mergeSort(arr, 0, arr.length - 1);
}

/**
 * 归并排序,时间复杂度 O(nlogn),空间复杂度 O(n),稳定
 *
 * @param arr   待排序数组
 * @param left  待排序数组左边索引
 * @param right 待排序数组右边索引
 */
public static void mergeSort(int[] arr, int left, int right) {
    if (left >= right) { // 递归终止条件
        return;
    }
    
    // 分治递归
    int mid = (left + right) >> 1;
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);
    // 合并 [left...mid][mid + 1...right] 为 [left...right],保证 [left...right] 有序
    merge(arr, left, mid, right);
}

private static void merge(int[] arr, int left, int mid, int right) {
    int l = left; // 左边子数组索引
    int r = mid + 1; // 右边子数组索引
    int i = 0; // tmp 数组索引
    int[] tmp = new int[right - left + 1]; // tmp 数组

    // 从左右子数组取 较小的数字 放入 tmp 数组,直到左边子数组取完或者右边子数组取完
    while (l <= mid && r <= right) {
        tmp[i++] = arr[l] <= arr[r] ? arr[l++] : arr[r++];
    }

    // 如果左边子数组还有剩余元素,依次放入 tmp 数组
    while (l <= mid) {
        tmp[i++] = arr[l++];
    }

    // 如果右边子数组还有剩余元素,依次放入 tmp 数组
    while (r <= right) {
        tmp[i++] = arr[r++];
    }

    // 将 tmp 数组的元素复制到原数组的 left...right 位置
    for (int j = 0, len = tmp.length; j < len; j++) {
        arr[left + j] = tmp[j];
    }
}

 

三、算法评价

3.1 时间复杂度

归并排序使用递归实现,时间复杂度的分析与递归代码一样。递归的适用场景是,一个问题 a 可以分解为子问题 b、c,那求解问题 a 就可以分解为求解问题 b、c。问题 b、c 解决之后,再把 b、c 的结果合并成 a 的结果。

如果定义求解问题 a 的时间是 T(a),求解问题 b、c 的时间分别是 T(b) 和 T( c),那么就可以得到这样的递推关系式:

T(a) = T(b) + T(c) + K

其中,K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。

从刚刚的分析,我们可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。套用这个公式,我们来分析一下归并排序的时间复杂度。

假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:

T(1) = C;  // n = 1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2 * T(n/2) + n;  // n > 1

进一步分解一下计算过程如下:

T(n) = 2 * T(n/2) + n
     = 2 * (2 * T(n/4) + n/2) + n = 4 * T(n/4) + 2 * n
     = 4 * (2 * T(n/8) + n/4) + 2 * n = 8 * T(n/8) + 3 * n
     = 8 * (2 * T(n/16) + n/8) + 3 * n = 16 * T(n/16) + 4 * n
     ......
     = 2^k * T(n/2^k) + k * n

通过这样一步步分解推导,可以得到 T(n) = 2k * T(n/2k) + k * n。当 T(n/2k) = T(1) 时,也就是 n/2k = 1,代表子数组分到只有1个元素,不需要再分了。这时候,可以得到 k = log2n,将 k 值代入前面的公式,得到 T(n) = C * n + n * log2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlog2n)。所以归并排序的时间复杂度是 O(nlog2n)。 

从以上分析可以看出,归并排序的执行效率与原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlog2n)。 

 

3.2 空间复杂度

归并排序的 merge() 函数,在将两个有序子数组合并成一个有序数组的过程中,需要借助额外的存储空间(tmp数组)。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。

 

3.3 稳定性

归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码。在合并的过程中,如果左右子数组元素相同,可以先把左边子数组的元素放入 tmp 数组,这样就保证了值相同的元素,在排序前后顺序不变。所以,归并排序是一个稳定的排序算法。

 

posted @ 2023-10-04 23:44  有点成长  阅读(58)  评论(0编辑  收藏  举报