每日算法 — 使用java实现排序:归并排序与分治策略

在软件开发中,排序是最基础也最频繁的操作之一。当我们需要处理大规模数据时,排序算法的稳定性与最坏情况下的性能保证至关重要。快速排序虽然平均性能优异,但在极端数据下会退化到 O(n²)。而归并排序(Merge Sort)凭借严格的分治策略,能够稳定地保持 O(n log n) 的时间复杂度,并且是少数几种稳定排序算法之一,在数据库查询优化、外部排序等场景中有着不可替代的地位。

本文将用 Java 完整实现归并排序,深入剖析分治思想的”分解-求解-合并”三步曲,并扩展至链表排序与逆序对计数等实战场景。

一、归并排序的核心思想:分而治之

归并排序的策略非常直观:

  1. 分解(Divide):将数组从中间一分为二,递归地对左右两半分别排序
  2. 求解(Conquer):当子数组长度为 1 时,它天然就是有序的
  3. 合并(Merge):将两个已经有序的子数组合并成一个更大的有序数组

这个过程就像整理一副扑克牌:先把牌分成两堆分别排好,再按照大小顺序把两堆牌交错合并成一叠整齐的牌。

1.1 合并操作:双指针的艺术

合并是归并排序的灵魂。假设左右两个子数组已经有序,如何高效地将它们合并?

左半部分: [2, 5, 8]    右半部分: [1, 3, 9]
          ↑                    ↑
         left               right

比较 2 和 1,取 1 放入结果
比较 2 和 3,取 2 放入结果
比较 5 和 3,取 3 放入结果
...
最终结果: [1, 2, 3, 5, 8, 9]

双指针分别遍历两个有序子数组,每次取较小者放入临时数组,时间复杂度仅为 O(n)。

二、完整Java实现

import java.util.Arrays;

/**
 * 归并排序完整实现
 * 时间复杂度:最坏/平均/最好均为 O(n log n)
 * 空间复杂度:O(n),需要额外临时数组
 * 稳定性:稳定排序
 */
public class MergeSort {

    /**
     * 对外接口:对数组进行升序归并排序
     * @param arr 待排序数组
     */
    public static void sort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        // 预先分配临时数组,避免递归过程中反复创建
        int[] temp = new int[arr.length];
        mergeSort(arr, 0, arr.length - 1, temp);
    }

    /**
     * 递归分治:对 arr[left..right] 区间进行归并排序
     * @param arr   原数组
     * @param left  区间左边界(包含)
     * @param right 区间右边界(包含)
     * @param temp  临时数组,用于合并操作
     */
    private static void mergeSort(int[] arr, int left, int right, int[] temp) {
        // 递归终止条件:区间只有一个元素,天然有序
        if (left >= right) {
            return;
        }

        // 取中点,防止整数溢出:left + (right - left) / 2
        int mid = left + (right - left) / 2;

        // 分解:递归排序左半部分和右半部分
        mergeSort(arr, left, mid, temp);
        mergeSort(arr, mid + 1, right, temp);

        // 合并:将两个有序子数组合并
        merge(arr, left, mid, right, temp);
    }

    /**
     * 合并操作:将 arr[left..mid] 和 arr[mid+1..right] 合并为有序数组
     * 前提条件:左右两个子数组已经分别有序
     */
    private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
        // 将待合并区间复制到临时数组
        // 注意:只复制当前合并区间,不是整个数组
        for (int i = left; i <= right; i++) {
            temp[i] = arr[i];
        }

        int i = left;      // 左半部分指针
        int j = mid + 1;   // 右半部分指针
        int k = left;      // 结果数组写入位置

        // 双指针遍历,取较小者放入原数组
        while (i <= mid && j <= right) {
            if (temp[i] <= temp[j]) {
                arr[k] = temp[i];
                i++;
            } else {
                arr[k] = temp[j];
                j++;
            }
            k++;
        }

        // 左半部分可能有剩余,直接复制
        while (i <= mid) {
            arr[k] = temp[i];
            i++;
            k++;
        }

        // 右半部分可能有剩余,直接复制
        while (j <= right) {
            arr[k] = temp[j];
            j++;
            k++;
        }
    }

    /**
     * 可视化辅助方法:打印数组片段
     */
    private static void printRange(int[] arr, int left, int right, String label) {
        System.out.print(label + " [");
        for (int i = left; i <= right; i++) {
            System.out.print(arr[i]);
            if (i < right) System.out.print(", ");
        }
        System.out.println("]");
    }

    // 主程序:演示与测试
    public static void main(String[] args) {
        // 测试用例1:随机数组
        int[] arr1 = {64, 34, 25, 12, 22, 11, 90, 5, 77, 30};
        System.out.println("=== 测试1:随机数组 ===");
        System.out.println("排序前: " + Arrays.toString(arr1));
        MergeSort.sort(arr1);
        System.out.println("排序后: " + Arrays.toString(arr1));
        System.out.println("验证: " + (isSorted(arr1) ? "通过" : "失败"));
        System.out.println();

        // 测试用例2:已排序数组(快排最坏情况,归并排序依然 O(n log n))
        int[] arr2 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        System.out.println("=== 测试2:已排序数组 ===");
        System.out.println("排序前: " + Arrays.toString(arr2));
        MergeSort.sort(arr2);
        System.out.println("排序后: " + Arrays.toString(arr2));
        System.out.println();

        // 测试用例3:逆序数组
        int[] arr3 = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
        System.out.println("=== 测试3:逆序数组 ===");
        System.out.println("排序前: " + Arrays.toString(arr3));
        MergeSort.sort(arr3);
        System.out.println("排序后: " + Arrays.toString(arr3));
        System.out.println();

        // 测试用例4:大量重复元素
        int[] arr4 = {5, 5, 5, 1, 1, 3, 3, 3, 3, 2};
        System.out.println("=== 测试4:重复元素 ===");
        System.out.println("排序前: " + Arrays.toString(arr4));
        MergeSort.sort(arr4);
        System.out.println("排序后: " + Arrays.toString(arr4));
        System.out.println("稳定性验证: 相等元素相对顺序保持不变");
        System.out.println();

        // 测试用例5:单元素与空数组
        int[] arr5 = {42};
        MergeSort.sort(arr5);
        System.out.println("=== 测试5:单元素 ===");
        System.out.println("排序后: " + Arrays.toString(arr5));
        int[] arr6 = {};
        MergeSort.sort(arr6);
        System.out.println("空数组排序: 通过");
    }

    private static boolean isSorted(int[] arr) {
        for (int i = 1; i < arr.length; i++) {
            if (arr[i] < arr[i - 1]) {
                return false;
            }
        }
        return true;
    }
}

三、关键代码解读

3.1 中点计算的防溢出技巧

int mid = left + (right - left) / 2;

leftright 接近 Integer.MAX_VALUE 时,(left + right) / 2 可能发生整数溢出。使用减法方式可以安全地计算中点。

3.2 临时数组的预分配策略

int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);

在整个排序过程中只创建一次临时数组,递归时复用同一块内存,避免了频繁的内存分配与回收开销。

3.3 双指针合并的边界处理

while (i <= mid && j <= right) { ... }
while (i <= mid) { ... }  // 处理左半剩余
while (j <= right) { ... } // 处理右半剩余

前两个 while 处理”交错”阶段,后两个 while 处理”收尾”阶段。由于左右子数组已经有序,剩余元素无需比较,直接追加即可。

四、复杂度分析

指标 归并排序 快速排序 堆排序
平均时间复杂度 O(n log n) O(n log n) O(n log n)
最坏时间复杂度 O(n log n) O(n²) O(n log n)
空间复杂度 O(n) O(log n) O(1)
稳定性 稳定 不稳定 不稳定

归并排序的核心优势在于最坏情况保证稳定性。当数据包含多个关键字、需要保持相等元素的原始相对顺序时(如先按年龄排序、再按姓名排序的多级排序场景),归并排序是理想选择。

五、扩展应用一:链表排序

归并排序非常适合链表结构,因为链表合并无需额外空间,只需调整指针指向即可实现 O(1) 空间复杂度。

/**
 * 链表节点的定义
 */
class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
}

/**
 * 链表归并排序:O(n log n) 时间,O(1) 额外空间
 * 经典面试题:LeetCode 148. Sort List
 */
public class LinkedListMergeSort {

    public static ListNode sortList(ListNode head) {
        if (head == null || head.next == null) {
            return head;
        }

        // 使用快慢指针找到链表中点
        ListNode slow = head, fast = head.next;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }

        // 断开链表为两部分
        ListNode rightHead = slow.next;
        slow.next = null;

        // 递归排序左右两部分
        ListNode left = sortList(head);
        ListNode right = sortList(rightHead);

        // 合并两个有序链表
        return merge(left, right);
    }

    private static ListNode merge(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(0);
        ListNode tail = dummy;

        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                tail.next = l1;
                l1 = l1.next;
            } else {
                tail.next = l2;
                l2 = l2.next;
            }
            tail = tail.next;
        }

        tail.next = (l1 != null) ? l1 : l2;
        return dummy.next;
    }
}

快慢指针找中点是链表分治的经典技巧:fast 每次走两步,slow 每次走一步,当 fast 到达末尾时,slow 正好位于中点。

六、扩展应用二:逆序对计数

逆序对是指数组中满足 i < jarr[i] > arr[j] 的数对,常用于衡量数组的”混乱程度”。归并排序的合并过程天然适合统计逆序对数量。

/**
 * 利用归并排序统计逆序对数量
 * 时间复杂度:O(n log n)
 */
public class InversionCounter {

    private long inversionCount = 0;

    public long count(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return 0;
        }
        int[] temp = new int[arr.length];
        mergeSortAndCount(arr, 0, arr.length - 1, temp);
        return inversionCount;
    }

    private void mergeSortAndCount(int[] arr, int left, int right, int[] temp) {
        if (left >= right) return;

        int mid = left + (right - left) / 2;
        mergeSortAndCount(arr, left, mid, temp);
        mergeSortAndCount(arr, mid + 1, right, temp);
        mergeAndCount(arr, left, mid, right, temp);
    }

    private void mergeAndCount(int[] arr, int left, int mid, int right, int[] temp) {
        for (int i = left; i <= right; i++) {
            temp[i] = arr[i];
        }

        int i = left, j = mid + 1, k = left;

        while (i <= mid && j <= right) {
            if (temp[i] <= temp[j]) {
                arr[k++] = temp[i++];
            } else {
                // 关键:当右半部分的 temp[j] 更小时
                // temp[i..mid] 的所有元素都与 temp[j] 构成逆序对
                inversionCount += (mid - i + 1);
                arr[k++] = temp[j++];
            }
        }

        while (i <= mid) arr[k++] = temp[i++];
        while (j <= right) arr[k++] = temp[j++];
    }

    public static void main(String[] args) {
        int[] arr = {7, 5, 6, 4};
        InversionCounter counter = new InversionCounter();
        long count = counter.count(arr);
        System.out.println("逆序对数量: " + count); // 输出: 5
        // 解释: (7,5), (7,6), (7,4), (5,4), (6,4)
    }
}

temp[j] < temp[i] 时,由于左半部分 temp[i..mid] 已经有序,从 imid 的所有元素都大于 temp[j],因此产生 (mid - i + 1) 个逆序对。这是将问题复杂度从 O(n²) 优化到 O(n log n) 的关键。

七、总结

归并排序通过”分而治之”的策略,将复杂的全局排序问题分解为可管理的局部问题,再系统化地合并结果。掌握归并排序不仅意味着掌握一种稳定的排序算法,更意味着理解了分治思想的精髓——这种思维方式在并行计算(如 MapReduce)、外部排序(处理超出内存的数据)以及诸多算法设计场景中都有深远影响。

从代码层面看,归并排序的递归结构和合并逻辑也常被用于解决其他问题:链表重排、区间合并、计算小和、统计翻转对等。熟练掌握归并排序,是进阶算法学习的重要基石。