归并排序 (Merge Sort)

22 阅读4分钟

1. 原理解析

归并排序是**“分治法(Divide and Conquer)”的经典体现。核心思想就三个字:“先分,后合”**。

  • 分(Divide):将数组不断从中间对半劈开,一直劈到每个子数组只剩下一个元素(此时每个子数组天然有序)。
  • 合(Merge):将两个有序的子数组合并成一个更大的有序数组。不断向上合并,直到最终合并成一个完整的有序数组。

生活中的例子:整理两组已经按学号排好的试卷。我们只需要比较两组试卷最上面的一张,谁小谁就先放到新桌子上,直到全部放完。

2. 使用场景

  • 需要极度稳定排序时间的场景:归并排序的最好、最坏、平均时间复杂度都是严格的 O(nlogn)O(n \log n),不会像快速排序那样在极端情况下退化。
  • 外部排序(处理海量数据):当数据量大到内存装不下时(比如几百GB的日志),可以把文件切成小块排好序,再用归并的思想把多个有序小文件合并成大文件。
  • Java中的对象排序:Java 的 Arrays.sort() 在对对象(Object)进行排序时,底层通常采用归并排序(或 TimSort,归并的变种),因为它是一种稳定排序(相等的元素排序后相对位置不变)。

3. 代码实战

Java 实现(工业级:传递下标,复用 temp 数组)

public class MergeSort {
    public static void mergeSort(int[] arr) {
        if (arr == null || arr.length <= 1) return;
        // 准备一张和原数组一样大的“草稿纸”,避免频繁开辟空间
        int[] temp = new int[arr.length]; 
        sort(arr, 0, arr.length - 1, temp);
    }

    private static void sort(int[] arr, int left, int right, int[] temp) {
        if (left >= right) return; // 只剩一个元素,递归终止
        
        int mid = left + (right - left) / 2; // 防溢出的写法
        
        // 1. 分:递归排好左半边和右半边
        sort(arr, left, mid, temp);      
        sort(arr, mid + 1, right, temp); 
        
        // 2. 合:将两个有序部分合并
        merge(arr, left, mid, right, temp);
    }

    private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
        int i = left;      // 左边部分的起点
        int j = mid + 1;   // 右边部分的起点
        int t = left;      // temp 数组的写入下标(这里优化为从 left 开始,方便直接抄回)
        
        // 比较两边,谁小谁上桌
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) {
                temp[t++] = arr[i++];
            } else {
                temp[t++] = arr[j++];
            }
        }
        
        // 兜底:如果左半边有剩余,直接扫进 temp
        while (i <= mid) {
            temp[t++] = arr[i++];
        }
        // 兜底:如果右半边有剩余,直接扫进 temp
        while (j <= right) {
            temp[t++] = arr[j++];
        }
        
        // 将排好序的 temp 数组内容抄回原数组 arr 的对应位置
        int k = left;
        while (k <= right) {
            arr[k] = temp[k];
            k++;
        }
    }
}

Python 实现(教学切片版,便于理解思想)

def merge_sort(arr):
    # 数组为空或只有一个元素时,本来就是有序的,直接返回
    if len(arr) <= 1:
        return arr
    
    mid = len(arr) // 2
    left_half = arr[:mid]
    right_half = arr[mid:]
    
    # 递归分
    sorted_left = merge_sort(left_half)
    sorted_right = merge_sort(right_half)
    
    # 合并
    return merge(sorted_left, sorted_right)

def merge(left, right):
    result = [] 
    i, j = 0, 0 
    
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])  
            i += 1                  
        else:
            result.append(right[j]) 
            j += 1                  
            
    # 兜底操作
    result.extend(left[i:])  
    result.extend(right[j:]) 
    
    return result

4. 核心难点/易错点详解

  1. 合并时的剩余元素处理(兜底):在比较合并时,必然有一边会先出完牌(指针越界)。此时必须立刻用循环(或 extend)把另一边剩下的元素按原顺序全部追加到临时数组的末尾。因为剩下的元素本身就是有序的,不需要再比较。
  2. 数组越界与拷贝范围:在原数组上操作时(如 Java 版),temp 数组拷贝回 arr 时,下标必须严格对应当前的 leftright 区间,不能一股脑从 0 抄到尾。
  3. 递归的执行顺序(压栈与弹栈):大老板(主函数)会一直等待两个小弟(递归调用)都执行完毕并返回有序结果后,才会执行最后的合并。计算机是深度优先执行的,即“先一路劈到底,再一层层缝合上来”。

5. 复杂度分析

  • 时间复杂度:O(nlogn)O(n \log n)
    • 分(层数):将长度为 nn 的数组不断对半劈开,劈到长度为 1 需要多少次?答案是 log2n\log_2 n 次(相当于一棵二叉树的深度)。
    • 合(每层的工作量):在每一层的合并过程中,不管分成多少块,所有元素的总数还是 nn。合并操作本质上就是把 nn 个元素分别看一眼并放进新数组,需要遍历 nn 次,也就是 O(n)O(n)
    • 总时间:层数 ×\times 每层操作 = O(logn)×O(n)=O(nlogn)O(\log n) \times O(n) = O(n \log n)。且无论原数组长什么样,都要老老实实劈开再合并,所以最好、最坏、平均时间复杂度全都是 O(nlogn)O(n \log n)
  • 空间复杂度:O(n)O(n)
    • 归并排序最大的痛点在于它不是原地排序。为了把两个有序数组合并,必须借用一张大小为 nn 的“新桌子”(即代码中的 temp 数组)。所以空间复杂度是 O(n)O(n)