题目描述

✅ 补充题 5. 手撕归并排序

image-20260928200355293

给定整数数组 nums,请将其按非递减顺序排列并返回,所有元素及其重复次数都要保留。

本补充题沿用力扣「排序数组」的输入输出,要求手动实现归并排序,不能直接调用内置排序函数。

示例 1:

输入:nums = [5,2,3,1]
输出:[1,2,3,5]

示例 2:

输入:nums = [5,1,1,2,0,0]
输出:[0,0,1,1,2,5]

提示:

  • 1 <= nums.length <= 5 * 10^4。
  • -5 * 10^4 <= nums[i] <= 5 * 10^4。
  • 力扣原题要求 O(n log n) 时间,并尽量减少额外空间。

题意分析

手动实现归并排序,将整数数组按从小到大的顺序排列并返回。重复值要完整保留,负数也按通常的数值大小比较,不调用现成的排序函数。

本题的重点是实现“拆分子问题,再合并有序结果”的排序过程。下面的实现把最终结果写回原数组,同时借助一个等长辅助数组完成合并;修改原数组不代表只使用常数额外空间。

解法:递归分治归并排序

核心思路

[!blue]

一次把整个无序区间排好不容易,但两个已经有序的区间可以线性合并。因此先把当前区间分成两半,分别排序,再合并它们。持续拆分到空区间或单元素区间时,它们本身就有序,递归自然结束。

定义 mergeSort(left, right) 的作用为:返回时,原数组闭区间 [left, right] 已经升序排列,且其中元素一个不少、一个不多。左右递归调用分别保证 [left, mid] 和 [mid + 1, right] 有序,当前层只需完成合并。

合并时,i、j 分别指向左右两段尚未处理的首元素,idx 指向辅助数组的下一个写入位置。因为每段内部有序,全部未处理元素中的最小值一定是 nums[i]、nums[j] 中较小的那个;把它写入 temp[idx] 并移动对应指针,就能让已写入部分一直保持有序。相等时先取左侧,使相等元素维持原有先后顺序,得到稳定排序。

一侧耗尽后,另一侧的剩余元素本来就有序,也不会小于已经写入的元素,可以直接依次追加。最后把 temp[left..right] 写回原数组,当前层便满足返回时区间有序的约定,上层也能继续正确合并。

不能在当前这套双指针流程中直接覆盖 nums:写入较小值时可能覆盖还没读出的元素。辅助数组隔开了读取与写入;它在入口只创建一次,各递归调用复用自己的区间,既不丢数据,也避免反复分配。

解题步骤

  1. 创建与输入等长的辅助数组 temp,从闭区间 [0, n - 1] 开始排序。
  2. 当 left >= right 时返回;否则计算中点,递归排序 [left, mid] 和 [mid + 1, right]。
  3. 左右两段都有序后,用两个指针比较当前值,将较小值写入 temp;相等时先写左侧值。
  4. 一侧耗尽后,将另一侧的所有剩余元素追加到 temp。
  5. 将当前合并区间完整写回 nums;最外层递归结束后返回原数组。

代码实现

class Solution {
    public int[] sortArray(int[] nums) {
        int[] temp = new int[nums.length];

        mergeSort(nums, temp, 0, nums.length - 1);

        return nums;
    }

    private void mergeSort(int[] nums, int[] temp, int left, int right) {
        if (left >= right) {
            return;
        }

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

        mergeSort(nums, temp, left, mid);
        mergeSort(nums, temp, mid + 1, right);
        merge(nums, temp, left, mid, right);
    }

    private void merge(int[] nums, int[] temp, int left, int mid, int right) {
        int i = left;
        int j = mid + 1;
        int idx = left;

        while (i <= mid && j <= right) {
            // 相等时优先取左侧元素,保持归并排序稳定性。
            if (nums[i] <= nums[j]) {
                temp[idx++] = nums[i++];
            } else {
                temp[idx++] = nums[j++];
            }
        }

        while (i <= mid) {
            temp[idx++] = nums[i++];
        }

        while (j <= right) {
            temp[idx++] = nums[j++];
        }

        // 合并结果写回本区间,保证递归返回时原数组的这一段已有序。
        for (int k = left; k <= right; k++) {
            nums[k] = temp[k];
        }
    }
}
func sortArray(nums []int) []int {
    temp := make([]int, len(nums))
    mergeSort(nums, temp, 0, len(nums)-1)
    return nums
}

func mergeSort(nums []int, temp []int, left int, right int) {
    if left >= right {
        return
    }
    mid := left + (right-left)/2
    mergeSort(nums, temp, left, mid)
    mergeSort(nums, temp, mid+1, right)
    merge(nums, temp, left, mid, right)
}

func merge(nums []int, temp []int, left int, mid int, right int) {
    i := left
    j := mid + 1
    idx := left
    for i <= mid && j <= right {
        if nums[i] <= nums[j] {
            // 相等时先放左侧,排序过程保持稳定。
            temp[idx] = nums[i]
            i++
        } else {
            temp[idx] = nums[j]
            j++
        }
        idx++
    }
    for i <= mid {
        temp[idx] = nums[i]
        i++
        idx++
    }
    for j <= right {
        temp[idx] = nums[j]
        j++
        idx++
    }
    // 合并结果写回本区间,保证递归返回时原数组的这一段已有序。
    for k := left; k <= right; k++ {
        nums[k] = temp[k]
    }
}

复杂度分析

设数组长度为 $n$。

  • 时间复杂度:$O(n\log n)$。区间每次近似减半,共有 $O(\log n)$ 层;每层合并和写回合计处理 $n$ 个元素,输入原有顺序不影响这个数量级。
  • 辅助空间复杂度:$O(n)$。共享数组占 $O(n)$,递归栈占 $O(\log n)$,不会为每一层再分配一整份数组。

关键点总结

[!green]

  • 递归返回时要保证原数组的当前区间有序,合并建立在两个子区间已经有序的基础上。
  • 每次只需比较两侧尚未处理的最小值,就能找到下一个全局最小值。
  • 辅助数组避免覆盖未读数据,相等时先取左值保证稳定性。

易错点总结

[!yellow]

  • 递归出口只写 left > right,会让单元素区间继续递归,规模无法缩小;应使用 left >= right。
  • 右半必须从 mid + 1 开始,若仍从 mid 开始,会与左半重叠,并可能产生无法终止的递归。
  • 当前合并流程不能直接把结果写进 nums,否则可能覆盖尚未读取的原元素。
  • 一侧耗尽后,另一侧剩余元素仍必须全部搬完;也要把合并结果写回当前区间,不能留在辅助数组里就返回。
  • 相等时若优先取右值,数值仍能排成有序,但会改变相等元素的原有先后顺序,失去稳定性。

相似题目

题目 难度 关联与区别
148. 排序链表 中等 链表归并排序也先分治再合并,本题用共享数组缓存,链表通过指针重接。
剑指 Offer 51. 数组中的逆序对 困难 归并过程中还能批量统计跨左右两区的逆序对,本题只完成排序。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/34361985
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!