cv.cm

编码 · 算法

归并排序详解:原理、代码与时间复杂度

阅读约 6 分钟

简短回答

归并排序是一种分治排序算法。它把列表分成两半,递归地对每一半排序,然后通过反复取出两个有序部分中较小的队首元素,把它们合并成一个有序列表。它始终以 O(n log n) 时间运行,是稳定的,合并时需要 O(n) 额外内存。它是 Timsort(Python 的 sorted() 背后的算法)的基础,也是内存放不下的数据所用的外部排序的基础。

打开行排序

通俗地说,思路是什么

只有零个或一个元素的列表本身就是有序的。对更长的列表:

  1. 分:把列表分成左半部分和右半部分。
  2. 治:用归并排序分别对每一半排序。
  3. 合:合并两个有序的部分。比较各自的第一个元素,把较小的放进输出,重复直到某一半为空;再把另一半剩下的部分复制过来。

真正的工作都发生在合并步骤中,合并总长度为 n 的两个有序列表,最多只需 n − 1 次比较。

逐步演示

对 [38, 27, 43, 3, 9, 82, 10] 排序。

层级子列表
拆分 1[38, 27, 43] [3, 9, 82, 10]
拆分 2[38] [27, 43] [3, 9] [82, 10]
拆分 3[38] [27] [43] [3] [9] [82] [10]
合并 1[38] [27, 43] [3, 9] [10, 82]
合并 2[27, 38, 43] [3, 9, 10, 82]
合并 3[3, 9, 10, 27, 38, 43, 82]

最后一次合并比较队首元素:27 对 3 取 3,27 对 9 取 9,27 对 10 取 10,27 对 82 取 27,38 对 82 取 38,43 对 82 取 43,最后复制 82。七个元素共六次比较。

Python 实现

def merge(left, right):
    out, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:     # <= takes from left on ties: stable
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
    out.extend(left[i:])            # one of these is empty
    out.extend(right[j:])
    return out
def merge_sort(items):
    """Return a new sorted list."""
    if len(items) <= 1:
        return list(items)
    mid = len(items) // 2
    return merge(merge_sort(items[:mid]), merge_sort(items[mid:]))
print(merge_sort([38, 27, 43, 3, 9, 82, 10]))   # [3, 9, 10, 27, 38, 43, 82]
print(merge_sort([]))                           # []

这个自顶向下的版本很清晰,但会复制切片。自底向上的版本完全避免了递归:先合并宽度为 1 的段,再合并宽度为 2 的,然后是 4,依此类推。

def merge_sort_bottom_up(items):
    a = list(items)
    width = 1
    while width < len(a):
        for lo in range(0, len(a), 2 * width):
            mid, hi = lo + width, lo + 2 * width
            a[lo:hi] = merge(a[lo:mid], a[mid:hi])
        width *= 2
    return a

自底向上的版本复用了上面的 merge。标准库里还有 heapq.merge,它能惰性地合并任意多个有序的可迭代对象。

时间与空间复杂度

情况时间原因
最好O(n log n)无论顺序如何,它总是拆分再合并
平均O(n log n)约 log₂ n 层,每层合并工作量为 O(n)
最坏O(n log n)递归过程相同;不存在不利的输入
额外空间O(n)合并用的缓冲区,外加 O(log n) 的递归栈

递推式为 T(n) = 2T(n/2) + O(n)。递归树的每一层总共做 O(n) 的工作,约有 log₂ n 层,所以总共是 O(n log n)。对于一百万个元素,大约是 20 层。

归并排序与其他排序算法的比较

算法最好平均最坏额外空间稳定
归并排序O(n log n)O(n log n)O(n log n)O(n)是
快速排序O(n log n)O(n log n)O(n²)O(log n)否
堆排序O(n log n)O(n log n)O(n log n)O(1)否
插入排序O(n)O(n²)O(n²)O(1)是
冒泡排序O(n)O(n²)O(n²)O(1)是
选择排序O(n²)O(n²)O(n²)O(1)否
Timsort(Python 的 sorted)O(n)O(n log n)O(n log n)O(n)是

归并排序是稳定的,因为遇到相等的元素时,合并会优先取左半部分的,所以相等元素保持原有顺序。与快速排序相比,它的最坏情况有 O(n log n) 的保证,但需要 O(n) 额外内存,并且在数组上通常要搬动更多数据。与堆排序相比,它是稳定的,而且按顺序访问内存,对缓存和磁盘都很友好。

变体与常见错误

  • 自然归并排序会检测已经有序的段并合并它们,对有序输入可达 O(n)。Timsort 是改进后的自然归并排序,对短段使用二分插入排序。
  • 对小的子列表(例如少于约 10 到 30 个元素)改用插入排序,以减少开销。
  • 在链表上,归并排序不需要额外缓冲区:合并只是重新连接节点,所以它是链表排序的常规选择。
  • 外部归并排序先对能放进内存的块排序,写入磁盘,再做 k 路归并。数据库以及 GNU sort 等工具对大文件就采用这一思路。
  • 统计逆序对:合并时,每当从右半部分取出一个元素,它就比左半部分所有剩余元素都小。

何时使用归并排序

当你需要有 O(n log n) 时间保证的稳定排序、要给链表排序,或者数据太大放不进内存时,就选归并排序。在 Python 中直接调用 sorted() 或 list.sort() 即可:它们实现的是 Timsort,一种归并排序的变体,所以你已经拥有这些特性。手写归并排序是经典的面试题,常延伸到统计逆序对或合并 k 个有序列表。

常见问题

归并排序稳定吗?

稳定,前提是元素相等时合并取左半部分的(用 <=)。这样相等的元素就保持原有顺序。

归并排序的时间复杂度是多少?

最好、平均和最坏情况下都是 O(n log n)。列表被对半拆分约 log₂ n 次,每一层合并的代价是 O(n)。

归并排序为什么需要额外空间?

在原数组上高效地原地合并两半很困难,所以常规实现把合并的结果写入一个大小为 n 的独立缓冲区。原地的变体存在,但很复杂,实际中也更慢。

归并排序比快速排序快吗?

对内存中的数组通常不是:快速排序平均而言往往更快,因为它搬动的数据更少,也不需要缓冲区。当你需要稳定性、最坏情况保证、链表或外部排序时,归并排序更胜一筹。

Python 用的是归并排序吗?

Python 的 sorted() 和 list.sort() 使用 Timsort,它是归并排序和插入排序的混合体。从 Python 3.11 起,合并策略基于 powersort。

全部编码教程