912. 排序数组

LeetCode 原题链接

1. 题目描述

给定一个整数数组 nums,请将数组按升序排列,并返回排序后的数组。

题目要求不能只依赖内置排序来糊过去,面试中通常希望能手写一个时间复杂度稳定的排序算法。

示例:

输入:nums = [5, 2, 3, 1]
输出:[1, 2, 3, 5]
解释:把数组中的元素从小到大排列。

示例 2:

输入:nums = [5, 1, 1, 2, 0, 0]
输出:[0, 0, 1, 1, 2, 5]
解释:重复元素也要保留,并按照升序放到正确位置。

2. 题型判断

本题是典型的数组排序题。

可以选择快速排序、归并排序、堆排序等 O(n log n) 级别算法。这里优先使用归并排序,因为它的最坏时间复杂度也是 O(n log n),不会像普通快排那样在极端输入下退化到 O(n^2)

归并排序的核心是分治:

  • 先把数组不断拆成更小的区间。
  • 当区间长度为 1 时,它天然有序。
  • 再把两个已经有序的子区间合并成一个更大的有序区间。

3. 核心思路

使用自顶向下的归并排序。

对区间 [left, right] 来说:

  1. 如果 left >= right,说明区间里最多只有一个元素,已经有序。
  2. 取中点 mid,递归排序左半区间 [left, mid]
  3. 递归排序右半区间 [mid + 1, right]
  4. 使用双指针合并两个有序区间。

合并时维护两个指针:

  • i 指向左半区间当前还没有合并的元素。
  • j 指向右半区间当前还没有合并的元素。

每次比较 nums[i]nums[j],把较小的元素放入临时数组 temp。当某一边用完后,把另一边剩余元素全部追加进去,最后再把 temp 覆盖回原数组的 [left, right] 区间。

4. 示例步骤图解

nums = [5, 2, 3, 1] 为例。

拆分过程

[5, 2, 3, 1]
      |
      +-- [5, 2]
      |      |
      |      +-- [5]
      |      +-- [2]
      |
      +-- [3, 1]
             |
             +-- [3]
             +-- [1]

长度为 1 的区间已经有序,接下来开始向上合并。

合并过程

步骤左有序区间右有序区间合并结果
1[5][2][2, 5]
2[3][1][1, 3]
3[2, 5][1, 3][1, 2, 3, 5]

第 3 步合并细节:

比较选择temp
21选择 1[1]
23选择 2[1, 2]
53选择 3[1, 2, 3]
左侧剩余 5追加 5[1, 2, 3, 5]

最终数组变为 [1, 2, 3, 5]

5. 变量与区间含义

本题使用闭区间 [left, right]

  • left:当前排序区间的左边界。
  • right:当前排序区间的右边界。
  • mid:当前区间的中点,用来把区间拆成左右两半。
  • i:合并时左半区间 [left, mid] 的扫描指针。
  • j:合并时右半区间 [mid + 1, right] 的扫描指针。
  • temp:临时数组,保存当前区间合并后的升序结果。
  • k:把 temp 写回 nums 时使用的下标。

递归函数 mergeSort(nums, left, right) 的含义是:把 nums 的闭区间 [left, right] 排成升序。

6. 推进规则

拆分规则

  • 如果 left >= right,区间长度小于等于 1,直接返回。
  • 否则取 mid = left + Math.floor((right - left) / 2)
  • 先排序 [left, mid],再排序 [mid + 1, right]

合并规则

合并两个已经有序的区间 [left, mid][mid + 1, right]

  • 如果 nums[i] <= nums[j],把 nums[i] 放入 temp,然后 i++
  • 如果 nums[i] > nums[j],把 nums[j] 放入 temp,然后 j++
  • 如果左半区间还有剩余元素,依次追加。
  • 如果右半区间还有剩余元素,依次追加。
  • 最后把 temp 写回 nums[left]nums[right]

这里使用 nums[i] <= nums[j] 时优先放左边元素,可以让归并排序保持稳定性。虽然本题只返回数值数组,稳定性不是必须的,但这是一个好习惯。

7. 边界条件与易错点

  • 空数组或单元素数组本身已经有序,递归会自然返回。
  • mid 要写成 left + Math.floor((right - left) / 2),避免在其他语言中出现整数溢出。
  • 合并前必须保证左右两段已经分别有序,所以要先递归排序,再合并。
  • 合并后要把 temp 写回原数组对应区间,否则上一层递归拿到的仍然是旧顺序。
  • 写回时下标要从 left 开始,而不是从 0 开始覆盖。
  • 普通快速排序平均复杂度是 O(n log n),但最坏会退化;本题数据可能比较刁钻,归并排序更稳。

8. 代码实现

归并排序特点:

  • 分治思想很清晰,先拆分,再合并。
  • 时间复杂度稳定,最好、平均、最坏都是 O(n log n)
  • 需要额外临时数组,空间复杂度是 O(n)
  • 是稳定排序,相等元素的相对顺序不会被改变。
  • 很适合作为本题主解,因为它不怕数组有序、逆序、重复元素多等极端情况。

归并排序图解:

nums = [5, 2, 3, 1]

先拆分:
[5, 2, 3, 1]
   /       \
[5, 2]   [3, 1]
 /  \     /  \
[5] [2] [3] [1]

再合并:
[5] + [2]       => [2, 5]
[3] + [1]       => [1, 3]
[2, 5] + [1, 3] => [1, 2, 3, 5]
/**
 * @param {number[]} nums
 * @return {number[]}
 */
var sortArray = function (nums) {
  mergeSort(nums, 0, nums.length - 1);
  return nums;
};

function mergeSort(nums, left, right) {
  // 闭区间 [left, right] 中最多只有一个元素时,天然有序。
  if (left >= right) {
    return;
  }

  const mid = left + Math.floor((right - left) / 2);

  // 先分别排好左右两半。
  mergeSort(nums, left, mid);
  mergeSort(nums, mid + 1, right);

  // 再把两个有序区间合并成一个有序区间。
  merge(nums, left, mid, right);
}

function merge(nums, left, mid, right) {
  const temp = [];
  let i = left;
  let j = mid + 1;

  // 两边都还有元素时,每次取更小的那个。
  while (i <= mid && j <= right) {
    if (nums[i] <= nums[j]) {
      temp.push(nums[i]);
      i++;
    } else {
      temp.push(nums[j]);
      j++;
    }
  }

  // 左半区间如果还有剩余,直接追加。
  while (i <= mid) {
    temp.push(nums[i]);
    i++;
  }

  // 右半区间如果还有剩余,直接追加。
  while (j <= right) {
    temp.push(nums[j]);
    j++;
  }

  // 把合并结果写回原数组的 [left, right] 区间。
  for (let k = 0; k < temp.length; k++) {
    nums[left + k] = temp[k];
  }
}

9. 复杂度分析

  • 时间复杂度:O(n log n)
    • 每一层合并会扫描全部 n 个元素。
    • 递归拆分一共有 log n 层。
  • 空间复杂度:O(n)
    • 合并过程需要临时数组。
    • 递归调用栈需要 O(log n),整体主导空间是临时数组的 O(n)

10. 其他解法

解法一:随机快速排序

快速排序也很常见。它通过 partition 把数组分成两部分:

  • 小于等于 pivot 的元素放左侧。
  • 大于 pivot 的元素放右侧。

然后递归排序左右两边。

为了降低退化概率,最好随机选择 pivot。不过即使随机化,理论最坏时间复杂度仍然是 O(n^2)

快速排序特点:

  • 原地排序,不需要像归并排序一样开 O(n) 的辅助数组。
  • 平均时间复杂度是 O(n log n),实际运行通常很快。
  • 最坏时间复杂度是 O(n^2),例如每次 pivot 都选到极端位置。
  • 不稳定,相同元素可能因为交换而改变相对顺序。
  • 面试中常考 partition 分区逻辑,要特别注意左右边界和 pivot 归位。

快速排序图解:

nums = [5, 2, 3, 1],假设第一次随机到的 pivot = 3 为例:

原数组:
[5, 2, 3, 1]

按 pivot = 3 分区:
[2, 1]  3  [5]
 小于等于    大于

继续递归处理左右两侧:
[2, 1] => [1, 2]
[5]    => [5]

拼回整体:
[1, 2, 3, 5]
var sortArray = function (nums) {
  quickSort(nums, 0, nums.length - 1);
  return nums;
};

function quickSort(nums, left, right) {
  if (left >= right) {
    return;
  }

  const p = partition(nums, left, right);
  quickSort(nums, left, p - 1);
  quickSort(nums, p + 1, right);
}

function partition(nums, left, right) {
  const pivotIndex = left + Math.floor(Math.random() * (right - left + 1));
  [nums[pivotIndex], nums[right]] = [nums[right], nums[pivotIndex]];

  const pivot = nums[right];
  let storeIndex = left;

  for (let i = left; i < right; i++) {
    if (nums[i] <= pivot) {
      [nums[storeIndex], nums[i]] = [nums[i], nums[storeIndex]];
      storeIndex++;
    }
  }

  [nums[storeIndex], nums[right]] = [nums[right], nums[storeIndex]];
  return storeIndex;
}

解法二:堆排序

堆排序也能做到原地 O(n log n),额外空间是 O(1)。它先建立大顶堆,然后反复把堆顶最大值交换到数组末尾,再缩小堆的有效范围。

堆排序特点:

  • 原地排序,额外空间复杂度是 O(1)
  • 最好、平均、最坏时间复杂度都是 O(n log n)
  • 不稳定,因为堆顶和末尾交换可能打乱相同元素的相对顺序。
  • 不依赖随机化,复杂度比快排更稳。
  • 代码重点在 heapify,要理解父节点和左右孩子的下标关系。

堆排序图解:

nums = [5, 2, 3, 1] 为例,先把数组看成完全二叉树:

数组下标: 0  1  2  3
数组内容:[5, 2, 3, 1]

        5
      /   \
     2     3
    /
   1

建立大顶堆后,堆顶就是当前最大值。每轮把堆顶交换到末尾,再对剩余部分重新调整:

轮次当前堆区间交换堆顶到末尾后已排序区间
初始[5, 2, 3, 1]-[]
1[5, 2, 3, 1][3, 2, 1, 5][5]
2[3, 2, 1][2, 1, 3, 5][3, 5]
3[2, 1][1, 2, 3, 5][2, 3, 5]
var sortArray = function (nums) {
  const n = nums.length;

  // 建立大顶堆。
  for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
    heapify(nums, n, i);
  }

  // 每次把当前最大值放到末尾。
  for (let end = n - 1; end > 0; end--) {
    [nums[0], nums[end]] = [nums[end], nums[0]];
    heapify(nums, end, 0);
  }

  return nums;
};

function heapify(nums, heapSize, root) {
  let largest = root;
  const left = root * 2 + 1;
  const right = root * 2 + 2;

  if (left < heapSize && nums[left] > nums[largest]) {
    largest = left;
  }

  if (right < heapSize && nums[right] > nums[largest]) {
    largest = right;
  }

  if (largest !== root) {
    [nums[root], nums[largest]] = [nums[largest], nums[root]];
    heapify(nums, heapSize, largest);
  }
}

解法三:计数排序

如果已知数组元素范围不大,可以使用计数排序。

LeetCode 912 的常见约束是 -5 * 10^4 <= nums[i] <= 5 * 10^4,数值范围大约是 10^5,可以开一个计数数组记录每个数字出现了多少次,然后按从小到大的顺序写回 nums

计数排序不是基于比较的排序。它的时间复杂度和数组长度、数值范围有关。

计数排序特点:

  • 不是比较排序,而是统计每个值出现的次数。
  • 当数值范围 k 不大时,时间复杂度可以做到 O(n + k)
  • 需要额外计数数组,空间复杂度是 O(k)
  • 适合整数排序,不适合值域很大或小数、字符串等场景。
  • 如果需要处理负数,要用 num - min 做偏移,不能直接把数字当数组下标。

计数排序图解:

nums = [5, 1, 1, 2, 0, 0] 为例:

min = 0
max = 5

统计每个值出现次数:
值:   0  1  2  3  4  5
次数: 2  2  1  0  0  1

按照次数从小到大写回:
0 出现 2 次 => [0, 0]
1 出现 2 次 => [0, 0, 1, 1]
2 出现 1 次 => [0, 0, 1, 1, 2]
5 出现 1 次 => [0, 0, 1, 1, 2, 5]
var sortArray = function (nums) {
  if (nums.length <= 1) {
    return nums;
  }

  let min = nums[0];
  let max = nums[0];

  for (const num of nums) {
    min = Math.min(min, num);
    max = Math.max(max, num);
  }

  const count = new Array(max - min + 1).fill(0);

  for (const num of nums) {
    count[num - min]++;
  }

  let index = 0;

  for (let i = 0; i < count.length; i++) {
    const value = i + min;

    while (count[i] > 0) {
      nums[index] = value;
      index++;
      count[i]--;
    }
  }

  return nums;
};

复杂度:

  • 时间复杂度:O(n + k)k = max - min + 1
  • 空间复杂度:O(k)

解法四:插入排序

插入排序会维护一个已经有序的前缀区间。每次取出当前位置 i 的元素,把它插入到左侧有序区间中的正确位置。

它适合小规模数组,或者数组本身已经接近有序的场景。对于 LeetCode 912 这种大数据排序题,最坏 O(n^2) 通常不够稳。

插入排序特点:

  • 维护左侧有序区间,把当前元素插入到正确位置。
  • 对接近有序的数组很友好,最好时间复杂度可以到 O(n)
  • 平均和最坏时间复杂度是 O(n^2),不适合大规模乱序数组。
  • 原地排序,空间复杂度是 O(1)
  • 稳定排序,因为相等元素不会跨过彼此。

插入排序图解:

nums = [5, 2, 3, 1] 为例,左侧灰色含义是“已经有序的前缀区间”:

初始:
[5 | 2, 3, 1]

i = 1,插入 2:
[2, 5 | 3, 1]

i = 2,插入 3:
[2, 3, 5 | 1]

i = 3,插入 1:
[1, 2, 3, 5]
var sortArray = function (nums) {
  for (let i = 1; i < nums.length; i++) {
    const current = nums[i];
    let j = i - 1;

    // 把比 current 大的元素整体右移。
    while (j >= 0 && nums[j] > current) {
      nums[j + 1] = nums[j];
      j--;
    }

    nums[j + 1] = current;
  }

  return nums;
};

复杂度:

  • 时间复杂度:平均和最坏 O(n^2),最好 O(n)
  • 空间复杂度:O(1)
  • 稳定性:稳定。

解法五:选择排序

选择排序每一轮从未排序区间中找到最小值,把它交换到当前轮的起始位置。

它的实现很直观,但无论数组是否接近有序,都要做大量比较,因此时间复杂度固定是 O(n^2)

选择排序特点:

  • 每一轮从未排序区间里选出最小值,放到当前起点。
  • 交换次数少,最多交换 n - 1 次。
  • 比较次数固定很多,最好、平均、最坏时间复杂度都是 O(n^2)
  • 原地排序,空间复杂度是 O(1)
  • 通常不稳定,因为最小值交换到前面时可能跨过相同元素。

选择排序图解:

nums = [5, 2, 3, 1] 为例,每一轮选出未排序区间的最小值:

初始:
[5, 2, 3, 1]

第 1 轮:未排序区间 [5, 2, 3, 1],最小值是 1,交换到下标 0
[1 | 2, 3, 5]

第 2 轮:未排序区间 [2, 3, 5],最小值是 2,位置不变
[1, 2 | 3, 5]

第 3 轮:未排序区间 [3, 5],最小值是 3,位置不变
[1, 2, 3 | 5]

最终:
[1, 2, 3, 5]
var sortArray = function (nums) {
  const n = nums.length;

  for (let i = 0; i < n - 1; i++) {
    let minIndex = i;

    for (let j = i + 1; j < n; j++) {
      if (nums[j] < nums[minIndex]) {
        minIndex = j;
      }
    }

    if (minIndex !== i) {
      [nums[i], nums[minIndex]] = [nums[minIndex], nums[i]];
    }
  }

  return nums;
};

复杂度:

  • 时间复杂度:O(n^2)
  • 空间复杂度:O(1)
  • 稳定性:通常不稳定,因为交换可能改变相同元素的相对顺序。

解法六:冒泡排序

冒泡排序每一轮比较相邻元素,如果顺序错误就交换。每一轮结束后,当前未排序区间中的最大值会被“冒泡”到末尾。

可以用 swapped 做提前结束优化:如果某一轮没有发生交换,说明数组已经有序。

冒泡排序特点:

  • 每次只比较相邻元素,顺序不对就交换。
  • 每一轮会把当前未排序区间的最大值放到末尾。
  • 加上 swapped 后,数组已经有序时最好时间复杂度是 O(n)
  • 平均和最坏时间复杂度是 O(n^2),不适合大规模数组。
  • 稳定排序,因为相等元素不会发生交换。

冒泡排序图解:

nums = [5, 2, 3, 1] 为例,每轮把当前最大值交换到未排序区间末尾:

第 1 轮:
[5, 2, 3, 1]
 5 > 2,交换 => [2, 5, 3, 1]
 5 > 3,交换 => [2, 3, 5, 1]
 5 > 1,交换 => [2, 3, 1 | 5]

第 2 轮:
[2, 3, 1 | 5]
 2 <= 3,不交换
 3 > 1,交换 => [2, 1 | 3, 5]

第 3 轮:
[2, 1 | 3, 5]
 2 > 1,交换 => [1 | 2, 3, 5]
var sortArray = function (nums) {
  const n = nums.length;

  for (let end = n - 1; end > 0; end--) {
    let swapped = false;

    for (let i = 0; i < end; i++) {
      if (nums[i] > nums[i + 1]) {
        [nums[i], nums[i + 1]] = [nums[i + 1], nums[i]];
        swapped = true;
      }
    }

    if (!swapped) {
      break;
    }
  }

  return nums;
};

复杂度:

  • 时间复杂度:平均和最坏 O(n^2),最好 O(n)
  • 空间复杂度:O(1)
  • 稳定性:稳定。

解法七:希尔排序

希尔排序可以看作插入排序的优化版。它先按较大的间隔 gap 分组做插入排序,让元素更快接近最终位置;再逐步缩小 gap,直到 gap = 1 时完成最后一次插入排序。

它的代码不复杂,实际表现通常比普通插入排序好,但复杂度分析和 gap 序列有关,不如归并、堆排那样稳定清晰。

希尔排序特点:

  • 是插入排序的改进版,通过较大的 gap 先让元素快速靠近最终位置。
  • 原地排序,空间复杂度是 O(1)
  • 实际表现通常比普通插入排序好。
  • 时间复杂度和 gap 序列有关,不像归并、堆排那样容易给出统一结论。
  • 不稳定,因为相同元素可能在不同 gap 分组移动时改变相对顺序。

希尔排序图解:

nums = [5, 2, 3, 1] 为例,先用较大的 gap 分组,再缩小到普通插入排序:

初始:
[5, 2, 3, 1]

gap = 2,分成两组:
下标 0, 2: [5, 3] => [3, 5]
下标 1, 3: [2, 1] => [1, 2]

数组变为:
[3, 1, 5, 2]

gap = 1,对整个数组做插入排序:
[1, 2, 3, 5]
var sortArray = function (nums) {
  const n = nums.length;

  for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
    for (let i = gap; i < n; i++) {
      const current = nums[i];
      let j = i - gap;

      while (j >= 0 && nums[j] > current) {
        nums[j + gap] = nums[j];
        j -= gap;
      }

      nums[j + gap] = current;
    }
  }

  return nums;
};

复杂度:

  • 时间复杂度:和 gap 序列有关,常见写法最坏可能到 O(n^2)
  • 空间复杂度:O(1)
  • 稳定性:不稳定。

排序算法选择总结

算法平均时间最坏时间额外空间稳定性适用场景
归并排序O(n log n)O(n log n)O(n)稳定追求稳定复杂度,适合本题主解
随机快排O(n log n)O(n^2)O(log n)不稳定实战常用,平均性能好
堆排序O(n log n)O(n log n)O(1)不稳定需要原地排序且保证最坏复杂度
计数排序O(n + k)O(n + k)O(k)可稳定整数范围较小时很快
插入排序O(n^2)O(n^2)O(1)稳定小数组或接近有序数组
选择排序O(n^2)O(n^2)O(1)不稳定教学理解,不适合大数据
冒泡排序O(n^2)O(n^2)O(1)稳定教学理解,不适合大数据
希尔排序取决于 gap取决于 gapO(1)不稳定插入排序的进阶优化