最近在准备 coding 面试,把排序算法又从头捋了一遍。捋的过程里我有点被自己吓到:很多东西五年前我是真的记得的,比如快排的 partition 到底怎么写、为什么会退化,现在却要愣一下才能想起来。等翻到非比较排序那一块——计数排序、基数排序、桶排序——我发现那已经基本是一片空白了。

于是干脆把这一遍复习写下来,一方面给后来准备面试的人当个 reference,另一方面也是给未来的自己留个存档:等下次再要面试,我可以回到这里,照着扫一遍,很快就知道「这个我还会、这个我忘了,重点看一下」。

这篇文章的用法很简单:

  • 先看下面那张速查表,扫一眼就能自检哪些算法你已经忘了;
  • 想细看哪个,用右侧的目录(TOC)直接跳过去;
  • 每个算法都按同一个模板写:一句话思路 → Python 实现 → 复杂度 → 稳定性和是否原地 → 面试要点,方便对照。

代码全部用 Python 写,因为它最接近伪代码、最容易看清逻辑。

一页速查表

先把结论摆出来。下面这张表覆盖了本文的全部 11 种排序,面试里被问到复杂度、稳定性的时候,脑子里应该能立刻浮现出这张表。

算法最好平均最坏空间稳定原地
冒泡 BubbleO(n)O(n²)O(n²)O(1)
选择 SelectionO(n²)O(n²)O(n²)O(1)
插入 InsertionO(n)O(n²)O(n²)O(1)
希尔 ShellO(n log n)≈O(n^1.3)O(n²)O(1)
归并 MergeO(n log n)O(n log n)O(n log n)O(n)
快排 QuickO(n log n)O(n log n)O(n²)O(log n)
堆排 HeapO(n log n)O(n log n)O(n log n)O(1)
计数 CountingO(n+k)O(n+k)O(n+k)O(n+k)
基数 RadixO(d·(n+k))O(d·(n+k))O(d·(n+k))O(n+k)
桶 BucketO(n+k)O(n+k)O(n²)O(n+k)✅*
TimsortO(n)O(n log n)O(n log n)O(n)

几点说明,免得这张表骗到你:

  • 希尔排序的复杂度取决于「增量序列」,最好情况随用的序列不同而变,所以这里的数字只是常见量级。
  • 快排标的空间是平均情况下的递归栈深度 O(log n),最坏会退化到 O(n);它是原地分区,但递归本身要占栈。
  • 桶排序的稳定性打了星号:桶内用稳定排序(比如插入排序)它才稳定。
  • k 是数据的取值范围,d 是数字的位数——非比较排序的复杂度都和数据本身的特征绑在一起,这点后面会细说。

写在前面:几个绕不开的概念

在逐个看算法之前,有四个概念几乎每道排序面试题都会用到。把它们先讲清楚,后面就不用反复解释了。

比较排序 vs 非比较排序

比较排序只通过「两个元素谁大谁小」这一种操作来决定顺序——冒泡、插入、归并、快排、堆排都是。它们的共同点是:理论下界就是 O(n log n),谁也快不过(原因见下面)。

非比较排序不靠比较,而是利用元素本身的值去「算」出它该放的位置——计数、基数、桶都是。正因为绕开了比较,它们能做到线性时间 O(n),但代价是对数据有额外要求(比如必须是范围有限的整数)。

稳定性(stability)

如果两个元素的排序键相等,排完序后它们的相对先后顺序不变,这个排序就是稳定的

举个具体例子。有一批订单,已经按下单时间排好了,现在要再按金额排序:

排序前(已按时间): (100元, 9:00)  (50元, 9:01)  (100元, 9:02)
稳定排序后(按金额):(50元, 9:01)  (100元, 9:00)  (100元, 9:02)   ← 两个 100 元仍保持时间先后
不稳定排序后:       (50元, 9:01)  (100元, 9:02)  (100元, 9:00)   ← 两个 100 元的顺序被打乱

为什么面试爱问?因为多关键字排序依赖它:先按次要键排,再用一个稳定排序按主要键排,次要键的顺序就被保留下来了。记住哪些稳定(冒泡、插入、归并、计数、基数、Timsort)、哪些不稳定(选择、希尔、快排、堆排)几乎是必考点。

原地排序(in-place)

只用 O(1) 或 O(log n) 的额外空间就能完成排序,叫原地。归并排序要额外开一个 O(n) 的数组,所以不是原地;快排、堆排只在原数组上倒腾,是原地的。当面试官追问「内存很紧张怎么办」,问的往往就是这个。

复杂度为什么分最好/平均/最坏

同一个算法,面对不同输入表现可能天差地别。最典型的是快排:输入随机时是 O(n log n),但输入已经有序、又恰好每次都挑到最差的 pivot 时,会退化成 O(n²)。面试里报复杂度,最好顺带说清是哪种情况——这恰恰是体现你理解深度的地方。

为什么比较排序快不过 O(n log n)? 任何比较排序都可以画成一棵「决策树」:每个内部节点是一次比较,每个叶子是一种可能的最终排列。n 个元素共有 n! 种排列,所以树至少要有 n! 个叶子。一棵高度为 h 的二叉树最多有 2ʰ 个叶子,于是 2ʰ ≥ n!,即 h ≥ log₂(n!)。由斯特林公式,log₂(n!) ≈ n log n。树的高度就是最坏情况下的比较次数,所以下界是 Ω(n log n)。这也解释了为什么想更快,就只能绕开「比较」这件事——也就是非比较排序。

好,概念铺垫完了,开始逐个看。

比较类排序

冒泡排序 Bubble Sort

一句话思路:相邻两个元素两两比较,逆序就交换,每一轮都把当前最大的「冒泡」到末尾。

def bubble_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        swapped = False
        # 每一轮把未排序区间里最大的元素冒泡到右端
        for j in range(n - 1 - i):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:          # 一整轮都没交换,说明已经有序,提前收工
            break
    return arr
  • 复杂度:最坏、平均都是 O(n²);加了 swapped 提前退出后,对已经有序的输入是 O(n)。空间 O(1)。
  • 稳定性 / 原地:稳定(只在严格大于时才交换),原地。
  • 面试要点:实战里基本不用,但它是「稳定 + 提前退出能到 O(n)」的经典例子。注意那个 swapped 优化,常被拿来考。
  • LeetCode 例题912. 排序数组 —— LeetCode 没有专门考冒泡的题,这道通用排序题可以拿来练手实现(纯 O(n²) 在大数据下会超时,仅作练习)。

选择排序 Selection Sort

一句话思路:每一轮从未排序区间里挑出最小的,放到已排序区间的末尾。

def selection_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        min_idx = i
        # 在未排序区间 [i+1, n) 里找最小值的下标
        for j in range(i + 1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
    return arr
  • 复杂度:无论输入长什么样都是 O(n²)——它不会因为数据有序而变快。空间 O(1)。
  • 稳定性 / 原地不稳定,原地。比如 [5a, 5b, 2],第一轮把 25a 交换,两个 5 的相对顺序就反了。
  • 面试要点:唯一的亮点是交换次数最少(最多 n−1 次),在「写操作很贵」的场景下有意义。另外它是「最好情况也救不回来」的反例,常和插入排序对比着考。
  • LeetCode 例题912. 排序数组 —— 同样用这道通用题练手实现,体会它「交换少、但比较一点不少」的特点。

插入排序 Insertion Sort

一句话思路:像理扑克牌——从左到右,把每张新牌插到左边已经排好的牌中正确的位置。

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        # 把比 key 大的元素整体右移一位,给 key 腾出位置
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key
    return arr
  • 复杂度:最坏、平均 O(n²);对几乎有序的输入接近 O(n)。空间 O(1)。
  • 稳定性 / 原地:稳定(while 条件用 > 不是 >=),原地。
  • 面试要点:别小看它。数据量小或几乎有序时,插入排序比快排还快,所以它正是 Timsort、Introsort 这些工业级排序在「小块」上回退使用的算法。基础三件套里,它是最有实用价值的一个。
  • LeetCode 例题147. 对链表进行插入排序 —— 专门为插入排序准备的题:在链表上原地插入。

希尔排序 Shell Sort

一句话思路:插入排序的升级版。先按一个较大的「间隔」分组做插入排序,再逐步缩小间隔,最后一轮间隔为 1(就是普通插入排序),但此时数组已经「大致有序」,所以很快。

def shell_sort(arr):
    n = len(arr)
    gap = n // 2
    while gap > 0:
        # 对每个间隔为 gap 的子序列做插入排序
        for i in range(gap, n):
            key = arr[i]
            j = i - gap
            while j >= 0 and arr[j] > key:
                arr[j + gap] = arr[j]
                j -= gap
            arr[j + gap] = key
        gap //= 2
    return arr
  • 复杂度:取决于增量序列。上面这种 n//2 折半序列最坏是 O(n²);换更好的序列(如 Knuth 的 3k+1、Sedgewick 序列)能到 O(n^1.5) 甚至更好。空间 O(1)。
  • 稳定性 / 原地不稳定(跨间隔交换会打乱相等元素的相对位置),原地。
  • 面试要点:它是「为什么让数据先变得大致有序,能让插入排序提速」这个思想的代表。面试不常直接考,但作为承上启下的一环值得知道——它把简单的 O(n²) 排序往 O(n log n) 的方向推了一把。
  • LeetCode 例题912. 排序数组 —— 拿它练手实现希尔排序,可以试试不同增量序列对耗时的影响。

归并排序 Merge Sort

一句话思路:分治。把数组对半切到不能再切,再把两个已排序的小数组合并成一个大的有序数组。

def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)


def merge(left, right):
    result = []
    i = j = 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
  • 复杂度:最好、平均、最坏全是 O(n log n)——非常稳定,不会因为输入退化。空间 O(n)(合并时要额外数组)。
  • 稳定性 / 原地:稳定,不是原地
  • 面试要点:复杂度稳如磐石、天然稳定,是「需要稳定 + 最坏也要 O(n log n)」时的首选。
  • LeetCode 例题148. 排序链表 —— 排序链表的最优解就是归并;想练数组版可用 912. 排序数组

下面是两个高频延伸:

概念补充:链表排序和外部排序

链表排序:归并排序对链表特别友好——合并链表只要改指针,不需要额外数组,能做到 O(1) 额外空间(不算递归栈)。这也是为什么「O(n log n) 排序一个链表」的标准答案是归并而不是快排。

外部排序(external sort):当数据大到内存装不下(经典面试题:「1G 内存怎么排 10G 文件?」),答案就是外部归并排序——把大文件切成内存能装下的小块,逐块读进来排好序写回磁盘,再用「多路归并」把这些有序小文件合并成最终结果。归并的精髓「合并多个有序序列」在这里被用到了极致。

快速排序 Quick Sort

这是我自己复习时最该重点捡起来的一节,partition 的细节五年没碰就模糊了。慢慢来。

一句话思路:分治。选一个基准值(pivot),通过一次分区(partition)把数组拆成「比 pivot 小的」和「比 pivot 大的」两部分,pivot 归位后,再对两边递归。

1. Lomuto 分区(最好背的版本)

def quick_sort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low < high:
        p = partition(arr, low, high)
        quick_sort(arr, low, p - 1)    # 递归左半
        quick_sort(arr, p + 1, high)   # 递归右半
    return arr


def partition(arr, low, high):
    pivot = arr[high]            # Lomuto:固定取最右元素作 pivot
    i = low - 1                  # i 是「小于 pivot 区」的右边界
    for j in range(low, high):
        if arr[j] < pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]   # pivot 归位
    return i + 1

Lomuto 的好处是只用一个指针 i 推进,逻辑直观、最好记。面试里手写快排,默认写这个版本就行。

2. 为什么会退化,以及怎么救

固定取最右元素当 pivot 有个致命问题:输入已经有序(或逆序)时,每次分区都把数组切成 0 和 n−1 两块,递归深度变成 n,复杂度退化到 O(n²),还可能爆栈。

救法是别让 pivot 的选择被输入「预测」——随机选一个,或者取「头、中、尾」三个数的中位数(median-of-three):

import random

def partition(arr, low, high):
    rand = random.randint(low, high)
    arr[rand], arr[high] = arr[high], arr[rand]   # 随机选 pivot,再换到最右,复用上面的逻辑
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        if arr[j] < pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

只加两行,就把「有序输入退化」这个最常见的坑堵上了。面试官追问「快排最坏情况怎么办」,这就是标准答案。

3. 三路快排:对付大量重复元素

如果数组里有大量重复值(比如全是 0 和 1),普通快排还是会做很多无谓的递归。三路快排(基于「荷兰国旗问题」)把数组分成 < pivot== pivot> pivot 三段,等于 pivot 的那一整段直接跳过:

def quick_sort_3way(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low >= high:
        return arr
    pivot = arr[low]
    lt, i, gt = low, low, high   # [low,lt)<pivot  [lt,i)==pivot  (gt,high]>pivot
    while i <= gt:
        if arr[i] < pivot:
            arr[lt], arr[i] = arr[i], arr[lt]
            lt += 1
            i += 1
        elif arr[i] > pivot:
            arr[gt], arr[i] = arr[i], arr[gt]
            gt -= 1               # 换过来的元素还没检查,i 不动
        else:
            i += 1
    quick_sort_3way(arr, low, lt - 1)
    quick_sort_3way(arr, gt + 1, high)
    return arr
  • 复杂度:平均 O(n log n),最坏 O(n²)(用随机 pivot 后几乎遇不到)。空间 O(log n),是递归栈的开销。
  • 稳定性 / 原地不稳定(分区里的远距离交换会打乱相等元素),原地
  • 面试要点:手写默认 Lomuto;被追问最坏情况就讲随机/三数取中;被追问大量重复就讲三路快排。
  • LeetCode 例题912. 排序数组 —— 提交时记得用随机 pivot,否则遇到有序或大量重复的数据会超时、甚至递归栈溢出。

再加一个高频延伸——

概念补充:Quickselect(快速选择)

「求第 k 大/小的元素」是面试常客。如果只是要第 k 个,没必要全排序:用快排的 partition,每次分区后看 pivot 落在哪,只递归 k 所在的那一边。平均 O(n),比先排序再取(O(n log n))更快。

def quickselect(arr, k):
    """返回第 k 小的元素,k 从 1 开始计数"""
    low, high, target = 0, len(arr) - 1, k - 1
    while low <= high:
        p = partition(arr, low, high)
        if p == target:
            return arr[p]
        elif p < target:
            low = p + 1        # 目标在右边
        else:
            high = p - 1       # 目标在左边

练手215. 数组中的第 K 个最大元素 —— 用快速选择平均 O(n) 解,正好和堆的解法对照。

堆排序 Heap Sort

一句话思路:先把数组建成一个最大堆(每个父节点都 ≥ 子节点),堆顶就是最大值;把堆顶换到末尾,堆缩小 1,再调整堆顶下沉,重复直到排完。

def heap_sort(arr):
    n = len(arr)
    # 1. 建堆:从最后一个非叶子节点开始,逐个向下调整
    for i in range(n // 2 - 1, -1, -1):
        sift_down(arr, i, n)
    # 2. 反复把堆顶(最大值)换到末尾,再修复剩下的堆
    for end in range(n - 1, 0, -1):
        arr[0], arr[end] = arr[end], arr[0]
        sift_down(arr, 0, end)
    return arr


def sift_down(arr, root, size):
    while True:
        largest = root
        left, right = 2 * root + 1, 2 * root + 2
        if left < size and arr[left] > arr[largest]:
            largest = left
        if right < size and arr[right] > arr[largest]:
            largest = right
        if largest == root:      # 父节点已经最大,下沉结束
            break
        arr[root], arr[largest] = arr[largest], arr[root]
        root = largest
  • 复杂度:最好、平均、最坏全是 O(n log n)。建堆是 O(n)(不是 O(n log n),这是个常考的反直觉点),之后 n 次下沉每次 O(log n)。空间 O(1)。
  • 稳定性 / 原地不稳定原地
  • 面试要点:它是唯一既保证最坏 O(n log n)、又只用 O(1) 空间的排序——「内存极紧又不能退化」时选它。注意它和优先队列 / 堆(heap)是同一套机制:heapq、Top-K 问题、Dijkstra 里的堆,全是这个 sift_down 的变体。用最小堆维护一个大小为 k 的堆求 Top-K,是堆这块的连环考点。
  • LeetCode 例题215. 数组中的第 K 个最大元素 —— 堆的经典题(维护一个大小为 k 的最小堆);它也能用快速选择解,正好对照两种思路。

非比较类排序

前面所有算法都靠「比较」,所以卡在 O(n log n) 这条线上。接下来三个绕开了比较,用元素的值本身当索引去定位,从而做到线性时间——代价是对数据有要求。这一块也是我复习时空白最大的地方,写得细一点。

计数排序 Counting Sort

一句话思路:统计每个值出现了几次,再用「前缀和」算出每个值在结果里该放的位置,直接放进去。适合取值范围 k 不大的整数

def counting_sort(arr):
    if not arr:
        return arr
    lo, hi = min(arr), max(arr)
    k = hi - lo + 1
    count = [0] * k
    for x in arr:                # 1. 计数
        count[x - lo] += 1
    for i in range(1, k):        # 2. 前缀和:count[i] 变成「值 ≤ i 的元素个数」
        count[i] += count[i - 1]
    result = [0] * len(arr)
    for x in reversed(arr):      # 3. 从后往前填,保证稳定
        count[x - lo] -= 1
        result[count[x - lo]] = x
    return result
  • 复杂度:O(n + k),n 是元素个数,k 是取值范围。空间 O(n + k)。
  • 稳定性 / 原地:稳定(关键就在第 3 步从后往前遍历),不是原地。
  • 面试要点:当 k 远小于 n(比如给十万个 0–100 的分数排序)时,它吊打任何 O(n log n) 排序。但 k 一旦很大(比如要排任意 32 位整数),空间就爆了——这正是它的适用边界,也是基数排序要解决的问题。
  • LeetCode 例题75. 颜色分类 —— 只有 0、1、2 三种值,计数排序(或三路快排)一趟搞定。

基数排序 Radix Sort

一句话思路:按位排序。从最低位(个位)开始,对每一位用一次稳定的计数排序,一直排到最高位。因为每一轮都稳定,排完最高位时整体就有序了。

def radix_sort(arr):
    if not arr:
        return arr
    max_val = max(arr)
    exp = 1                           # 当前处理的位:1=个位, 10=十位, 100=百位…
    while max_val // exp > 0:
        arr = counting_sort_by_digit(arr, exp)
        exp *= 10
    return arr


def counting_sort_by_digit(arr, exp):
    count = [0] * 10                  # 十进制,每位只有 0–9
    for x in arr:
        count[(x // exp) % 10] += 1
    for i in range(1, 10):
        count[i] += count[i - 1]
    result = [0] * len(arr)
    for x in reversed(arr):           # 从后往前,保持这一位排序的稳定性(基数排序正确性的关键)
        digit = (x // exp) % 10
        count[digit] -= 1
        result[count[digit]] = x
    return result
  • 复杂度:O(d·(n + k)),d 是最大数字的位数,k 是基数(这里十进制 k=10)。空间 O(n + k)。
  • 稳定性 / 原地:稳定,不是原地。
  • 面试要点:它解决了计数排序「取值范围一大空间就爆」的问题——把一个大整数拆成几位小数字来排。上面的版本只处理非负整数;要支持负数,可以先整体平移成非负,或对正负分别处理。面试常考的点:为什么必须从低位排到高位?为什么每一位的排序必须稳定?(因为高位排序时要靠稳定性保留低位已经排好的顺序。)
  • LeetCode 例题164. 最大间距 —— 要求线性时间和空间,标准解法正是基数排序或桶排序。

桶排序 Bucket Sort

一句话思路:把数据按值域均匀分到若干个「桶」里,每个桶内部各自排序,最后按桶的顺序拼起来。适合均匀分布的数据。

def bucket_sort(arr):
    if not arr:
        return arr
    n = len(arr)
    buckets = [[] for _ in range(n)]
    for x in arr:                     # 假设元素均匀分布在 [0, 1)
        buckets[int(n * x)].append(x)
    result = []
    for bucket in buckets:
        insertion_sort(bucket)        # 桶内用稳定排序,整体才稳定
        result.extend(bucket)
    return result
  • 复杂度:数据均匀分布时平均 O(n + k);最坏情况(所有元素挤进同一个桶)退化到 O(n²)。空间 O(n + k)。
  • 稳定性 / 原地:取决于桶内排序——用插入排序就稳定;不是原地。
  • 面试要点:它的性能完全押在「数据分布是否均匀」上,这是它和计数/基数最大的区别。计数和基数对数据形态不敏感,桶排序敏感。经典适用场景:把一批均匀分布在 [0, 1) 的浮点数排序。
  • LeetCode 例题347. 前 K 个高频元素 —— 按出现频次分桶,是这道题最漂亮的解法。

现实世界里用的是什么:Timsort

前面这些都是「教科书算法」。但你每天 sorted() 一下,Python 背后跑的其实是 Timsort——一个为真实世界数据精心调校过的混合算法。值得单独说,因为面试里能讲出这个,往往是加分项。

核心思想:真实数据很少是完全随机的,往往局部已经有序。Timsort 抓住这一点:

  1. 先扫描数组,找出已经天然有序的连续片段,叫 run
  2. 太短的 run 用插入排序补齐到一个最小长度(minrun,通常 32–64)——前面说过,小数组上插入排序最快;
  3. 再用归并排序的方式,按一套规则把这些 run 两两合并,合并时还有「galloping(飞奔)」模式来加速。

所以 Timsort = 归并排序的骨架 + 插入排序的小块优化 + 对已有序数据的特判

  • 复杂度:最坏 O(n log n),但对几乎有序的数据能到 O(n)。空间 O(n)。
  • 稳定性:稳定。这也是为什么 Python 的 sorted()list.sort() 保证稳定。
  • 冷知识:Java 给对象排序(Arrays.sort(Object[]))用的也是 Timsort 的变体;而 C++ 的 std::sort 用的是另一个混合算法 Introsort(快排打底,递归太深时切换堆排避免退化,小块用插入排序)。「快排 + 堆排 + 插入排序」三合一,思路和 Timsort 异曲同工:没有银弹,工业级排序都是混合的
  • LeetCode 例题56. 合并区间 —— 先排序再扫描合并;在 Python 里那句 sorted() 跑的就是 Timsort,正好体会「真实数据局部有序」带来的加速。

面试里到底怎么选 / 怎么答

把上面的东西浓缩成一张「该用哪个」的决策清单:

  • 没有特殊要求,就要快 → 快排(随机 pivot)。这是大多数场景的默认选择。
  • 要稳定,且最坏也得 O(n log n) → 归并排序。
  • 内存极紧(要 O(1) 空间)又不能退化 → 堆排序。
  • 数据量很小(几十个)或几乎已经有序 → 插入排序。
  • 排链表 → 归并排序。
  • 整数且取值范围不大 → 计数排序。
  • 整数但范围很大(如定长整数 / 字符串) → 基数排序。
  • 数据均匀分布在一个区间 → 桶排序。
  • 只要第 k 大 / 中位数,不用全排序 → Quickselect。
  • 数据大到内存装不下 → 外部归并排序。

几个常见的连环追问,提前想好答案:

  • 「哪些排序是稳定的?」 → 冒泡、插入、归并、计数、基数、桶(桶内稳定时)、Timsort。
  • 「快排最坏情况和怎么避免?」 → 有序输入 + 坏 pivot 退化成 O(n²);用随机 pivot 或三数取中。
  • 「能不能比 O(n log n) 更快?」 → 比较排序不能(决策树下界);但如果数据是范围有限的整数,可以用非比较排序到 O(n)。
  • 「O(n log n) 又稳定又原地的排序存在吗?」 → 一般实现里不存在;归并稳定但不原地,堆排原地但不稳定,快排原地但不稳定。这是个很好的「理解权衡」的考点。

几个常见易错点

复习时我自己踩过或者差点踩的坑,列在这里:

  • 选择排序对有序输入也不会变快——它没有「提前退出」,永远 O(n²)。别和冒泡/插入搞混。
  • 建堆是 O(n) 不是 O(n log n)。直觉上是 n 个元素各 O(log n),但仔细算(大部分节点离底很近)是 O(n)。这是个高频反直觉考点。
  • 计数 / 基数排序「从后往前」填结果——这一步是稳定性的来源,反过来写就不稳定了,而基数排序一旦不稳定就直接错了。
  • 快排的空间不是 O(1)——虽然原地分区,但递归栈平均 O(log n)、最坏 O(n)。
  • 桶排序的最坏是 O(n²)——别只记平均 O(n),分布不均时会退化。
  • 稳定 ≠ 原地,这是两个独立的维度,面试里经常被一起问,别混为一谈。

小结

排序这块知识,框架其实很清楚:

  • 比较排序卡在 O(n log n),里面快排最快但会退化、归并稳定但费空间、堆排原地但不稳定——没有全能选手,全是权衡
  • 非比较排序靠数据特征换来线性时间,但对数据有要求;
  • 工业级排序(Timsort、Introsort)都是混合的,把几种算法的长处缝在一起。

如果你和我一样是隔了几年重新捡起来,建议照着最上面那张速查表过一遍:能默写出实现、说清复杂度和稳定性的就跳过,卡壳的就回到对应章节细看。等下次再准备面试,回到这里再扫一遍就好。

祝面试顺利。