排序,可能是你这一辈子写得最多的算法。给它排个序、给排行榜排个名、把数据从小到大理一理——无处不在。

但排序真正的价值,不是"把数字排整齐"这么简单,而是它教会我们"快"到底是怎么炼成的:同样是排序,笨办法 O(n²),聪明办法 O(n log n),数据一大,一个要等一天,一个一秒完事。这一课,我们从最笨的冒泡讲起,一路升级到快排,亲手摸一遍"快"的门道。


一、先立个靶子:排序到底在解决什么

排序 = 把一堆乱序的数,按从小到大(或从大到小)排好。

nums = [5, 2, 8, 1, 9, 3]
# 目标:变成 [1, 2, 3, 5, 8, 9]

看起来简单,但排序的难处在于:你没法"一眼看穿"全局顺序,只能一步步比较、交换,慢慢逼近有序。 不同算法,就是不同的"逼近策略",策略不同,速度天差地别。

一个贯穿全程的问题:"我们最少要比多少次,才能确定顺序?" 这个问题的答案——O(n log n)——是排序算法的天花板,后面我们会讲到为什么。


二、笨办法三兄弟:冒泡、选择、插入

这三个都是 O(n²),思路朴素,适合当入门垫脚石,也适合小数据量。

1. 冒泡排序:相邻俩俩换,大的"冒"上去

思路:一遍遍扫,相邻两个比一比,谁大谁往后换。每扫一遍,最大的那个就像气泡一样"冒"到最后。

def bubble_sort(nums):
    n = len(nums)
    for i in range(n):               # 一共扫 n 遍
        swapped = False
        for j in range(n - 1 - i):   # 每次少比一个(最后已有序)
            if nums[j] > nums[j + 1]:
                nums[j], nums[j + 1] = nums[j + 1], nums[j]
                swapped = True
        if not swapped:              # 这一遍没交换,说明已有序
            break
    return nums

print(bubble_sort([5, 2, 8, 1, 9, 3]))   # [1, 2, 3, 5, 8, 9]

2. 选择排序:每次挑最小的,放到最前面

思路:从头到尾找最小的,放到第 0 位;再找次小的,放到第 1 位……一步步"选出"正确位置的人。

def selection_sort(nums):
    n = len(nums)
    for i in range(n):
        min_idx = i                 # 假设 i 位是最小的
        for j in range(i + 1, n):
            if nums[j] < nums[min_idx]:
                min_idx = j         # 找到更小的,记下来
        nums[i], nums[min_idx] = nums[min_idx], nums[i]   # 换到前面
    return nums

print(selection_sort([5, 2, 8, 1, 9, 3]))

3. 插入排序:像打牌一样,一张张插进手里

思路:想象你在理扑克牌——每次拿起一张新牌,插进手里已经排好的那部分正确位置。

def insertion_sort(nums):
    for i in range(1, len(nums)):
        key = nums[i]               # 手上这张新牌
        j = i - 1
        while j >= 0 and nums[j] > key:   # 前面比它大的,往后挪
            nums[j + 1] = nums[j]
            j -= 1
        nums[j + 1] = key           # 插到正确位置
    return nums

print(insertion_sort([5, 2, 8, 1, 9, 3]))

三兄弟的共同点:都是两层循环,最坏 O(n²)。其中插入排序有个隐藏优点——数据基本有序时几乎 O(n),所以它常常被用在"快排递归到底层、数据已经差不多排好"的时候收尾。


三、一个重要的概念:稳定性

先记一个面试常问的词——稳定性(stable)

如果两个相等的元素,排序前后相对顺序不变,这个排序就是稳定的

举个例子:你有一组学生,先按成绩排了序,现在想按"班级"再排一次,且希望同班级内仍按成绩高低排列。这时你就需要稳定的排序——第二次排序不会打乱第一次已排好的相对顺序。

算法稳定?
冒泡✅ 稳定
插入✅ 稳定
选择❌ 不稳定
归并✅ 稳定
快排❌ 不稳定

记住:需要"多次排序、层层细化"的场景(比如先按 A 排再按 B 排,且要保留 A 的顺序),选稳定的排序。


四、聪明办法:归并排序(分治的第一次亮相)

笨办法慢,是因为它们都在"原地慢慢挪"。聪明办法换了个思路——分治(后面会专门讲):把大问题拆成小问题,解决小问题,再合并。

归并排序三步:

  1. :把数组从中间切成两半。
  2. :分别对两半排序(递归调用自己)。
  3. :把两个已经排好的半段,合并成一个有序数组。
def merge_sort(nums):
    if len(nums) <= 1:
        return nums
    mid = len(nums) // 2
    left = merge_sort(nums[:mid])    # 递归排左半
    right = merge_sort(nums[mid:])   # 递归排右半
    return merge(left, right)

def merge(a, b):
    result = []
    i = j = 0
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            result.append(a[i]); i += 1
        else:
            result.append(b[j]); j += 1
    result += a[i:]      # 把剩下的接上
    result += b[j:]
    return result

print(merge_sort([5, 2, 8, 1, 9, 3]))   # [1, 2, 3, 5, 8, 9]

复杂度怎么算:每层递归,所有元素都被"合并"一次,是 O(n)。一共会切出 log n 层(每次砍半)。所以 O(n log n)。这就是从 O(n²) 到 O(n log n) 的第一次跨越。

代价:归并需要额外的空间来装合并结果,是 O(n) 的辅助空间——"用空间换时间"的典型。


五、聪明办法的巅峰:快速排序

快排,业界公认"平均最快"的排序,Python 的 sort() 和很多语言底层都在用它(或其变体)。

快排的核心思想还是分治,但换了个切法——选一个"基准"(pivot),把比它小的放左边,比它大的放右边,然后左右各自递归。

def quick_sort(nums):
    if len(nums) <= 1:
        return nums
    pivot = nums[len(nums) // 2]        # 选中间那个当基准
    left  = [x for x in nums if x < pivot]
    mid   = [x for x in nums if x == pivot]
    right = [x for x in nums if x > pivot]
    return quick_sort(left) + mid + quick_sort(right)

print(quick_sort([5, 2, 8, 1, 9, 3]))   # [1, 2, 3, 5, 8, 9]

这段代码清晰得感人,但它是"用额外数组"的简化版。真正的工程实现是原地分区(省内存)。原地分区的核心就一段:把比基准小的往左边赶。

复杂度:平均 O(n log n)——每次基准把数组砍成两半,砍 log n 层,每层 O(n)。

但快排有个著名的坑:如果每次选的基准都"倒霉"到是最大或最小,数组就只被切成"1 个 + 剩下的 n-1 个",退化成了 O(n²)。所以工程上会用"三数取中""随机基准"来躲开这个坑。


六、为什么排序的"下界"是 O(n log n)?

这是排序里最有意思的一问:能不能发明一个 O(n) 的排序?

答案:基于"比较"的排序,做不到比 O(n log n) 更快。

直觉是这样:排序的本质,是把 n 个元素所有可能的排列(一共 n! 种)确定成一种。每比一次,你最多排除一半的可能(就像猜数字"大了还是小了")。所以要确定到底哪一种,至少要比 log₂(n!) 次。

而数学上有条斯特林公式告诉你:log₂(n!) ≈ n log n

所以 O(n log n) 是"比较排序"的理论下限,不是我们不够聪明,是天花板就在这。快排、归并、堆排序都已经摸到这个天花板了。

那有没有 O(n) 的排序?有——但它们是"不比较"的,比如计数排序、桶排序,靠"数一数每个值出现几次"来排。它们快,但对数据有要求(值域不能太大)。Python 处理"全是小整数"时可以用 sorted 或特定场景用计数排序。

七、复杂度与特性一览

算法平均最坏空间稳定
冒泡O(n²)O(n²)O(1)
选择O(n²)O(n²)O(1)
插入O(n²)O(n²)O(1)
归并O(n log n)O(n log n)O(n)
快排O(n log n)O(n²)O(log n)

选型口诀:小数据、要稳定、图省事 → 插入/冒泡;要稳定、不怕费点内存 → 归并;要最快、平均性能最好 → 快排。


八、突破下界:非比较排序 + 快速选择

上一节说排序下界是 O(n log n),但那是"靠两两比较"才有的限制。如果不比较,而是利用数据本身的特性,能更快。

1. 计数排序:数一数有几个

前提:数据是有限范围内的整数。思路简单到不像算法——数每个值出现了几次,再按次数"铺"出来:

def counting_sort(a):
    m = max(a)
    count = [0] * (m + 1)
    for x in a:
        count[x] += 1
    out = []
    for v in range(m + 1):
        out += [v] * count[v]   # 值 v 出现几次就铺几次
    return out

print(counting_sort([4, 2, 2, 8, 3, 3, 1]))   # [1, 2, 2, 3, 3, 4, 8]

复杂度 O(n + m),m 是值域大小。值域不大时(分数 0~100、年龄 0~150),它碾压所有比较排序。

2. 桶排序 / 基数排序

  • 桶排序:把数据分到几个"桶"里,桶内再排序。数据分布均匀时接近 O(n)。
  • 基数排序:按"位数"从低位到高位,每一轮用稳定的计数排序排一遍。排整数、日期、IP 都很高效,O(d·n),d 是位数。

它们和计数排序一样,都不靠两两比较,所以绕开了 O(n log n) 的下界。

3. 快速选择:不用全排,也能找"第 k 小"

如果只是想找"第 k 小的数",根本不用把整个数组排完——快排的分区思想就够了:每次选一个基准,比它小的放左边、大的放右边,然后只往目标那一边继续找

def quickselect(a, k):          # 找第 k 小(k 从 0 数)
    if len(a) == 1:
        return a[0]
    p = a[0]
    lo = [x for x in a if x < p]
    eq = [x for x in a if x == p]
    hi = [x for x in a if x > p]
    if k < len(lo):
        return quickselect(lo, k)              # 在左边找
    if k < len(lo) + len(eq):
        return p                                # 正好是基准
    return quickselect(hi, k - len(lo) - len(eq))

a = [5, 1, 8, 3, 9, 2, 7]
print(quickselect(a, 2))   # 3(排序后 [1,2,3,5,7,8,9] 的第 3 小)

平均 O(n),比"先全排再取"(O(n log n))快。这是"topK 问题"的标准答案之一,后面讲堆时我们还会用堆再解一次。

记住:比较排序被 O(n log n) 卡死,但利用数据特性(范围小、是整数、只需第 k 小),就能更快——这就是"针对问题特点设计算法"的力量。


九、动手时间 🎯

实验 1:亲眼看看 O(n²) 和 O(n log n) 差多少

import time, random

n = 5000
nums = [random.randint(0, 100000) for _ in range(n)]

# 冒泡(O(n²))
arr = nums[:]
start = time.time()
bubble_sort(arr)
print("冒泡耗时:", round(time.time() - start, 3), "秒")

# 快排(O(n log n))
arr = nums[:]
start = time.time()
quick_sort(arr)
print("快排耗时:", round(time.time() - start, 3), "秒")

# Python 内置(C 实现的 Timsort,也是 O(n log n))
arr = nums[:]
start = time.time()
arr.sort()
print("内置 sort 耗时:", round(time.time() - start, 3), "秒")

你会看到:冒泡要等好几秒,快排和内置 sort 几乎瞬间。这就是 O(n²) 和 O(n log n) 的天壤之别。

实验 2:把 n 翻倍,观察时间怎么涨

n = 5000 改成 n = 10000 再跑:

  • 冒泡时间大概翻 4 倍(O(n²))
  • 快排时间大概翻 2 倍多一点(O(n log n))

实验 3:验证稳定性

# 元组:(班级, 成绩)。先按成绩排,再按班级排(稳定排序应保留成绩顺序)
students = [(2, 90), (1, 85), (2, 75), (1, 95)]
# 先按成绩(第二个元素)排
by_score = sorted(students, key=lambda x: x[1], reverse=True)
print("按成绩排:", by_score)
# 再按班级(第一个元素)稳定排
by_class = sorted(by_score, key=lambda x: x[0])
print("按班级稳定排:", by_class)   # 同班级内,成绩高的仍在前

sortedlist.sort() 都是稳定的,放心用。

实验 4(挑战):不用 sorted,手写"找出第 k 小的数"

思路:借用快排的"分区"思想,不用全排完,只要定位到第 k 小就停。

def kth_smallest(nums, k):
    if len(nums) == 1:
        return nums[0]
    pivot = nums[len(nums) // 2]
    left = [x for x in nums if x < pivot]
    mid = [x for x in nums if x == pivot]
    right = [x for x in nums if x > pivot]
    if k < len(left):
        return kth_smallest(left, k)
    elif k < len(left) + len(mid):
        return pivot
    else:
        return kth_smallest(right, k - len(left) - len(mid))

print(kth_smallest([5, 2, 8, 1, 9, 3], 2))   # 第 2 小(下标2)= 3

思考:这个办法为什么平均只需 O(n),比"全排序再取第 k 个"(O(n log n))更快?


十、小结

  1. 排序是"逼近有序"的策略游戏——冒泡/选择/插入是 O(n²) 的笨办法,归并/快排靠"分治"把复杂度砍到 O(n log n)。
  2. 比较排序的理论下限是 O(n log n)——因为要确定 n! 种排列,至少比 log₂(n!) ≈ n log n 次,这是天花板不是我们的无能。
  3. 选排序看场景——要稳定选归并/插入,要平均最快选快排,数据基本有序时插入排序近乎 O(n)。

先别急着往后翻,把这四个实验敲熟,"排序快慢"的手感就长在你脑子里了。

标签: none

添加新评论