目录

你第一次学排序,大概率是被冒泡排序领进门的。

它慢得离谱、工程里几乎没人用,却偏偏是算法课的第一课。

为什么?因为它最符合人的直觉:把较大的元素像水底气泡上浮一样,每次和相邻的数比较、逆序就交换,一步步挪到数组末尾,最大的先到位、次大的跟着到位……全部浮完就排好了。

看一遍就能复述,写一遍就能跑通。


它是什么

核心思想 冒泡排序(Bubble Sort) 是一种最基础的比较排序。

其工作原理就是反复遍历数组,每次比较相邻的两个元素,如果顺序不对就交换,直到整个数组有序。

每一轮遍历,当前未排好序部分里最大的元素会像气泡一样,一步步冒到它该在的位置。

它有以下三个特性:

  • 原地排序(in-place):不需要额外开一个同规模数组。
  • 稳定排序(stable):相等元素不交换,相对次序保持不变。
  • 时间复杂度 O(n²):慢,这是它最大的短板。

伪代码

通过下面的伪代码先熟悉下冒泡排序的实现原理。

算法 BubbleSort(A):
    输入: 数组 A[0..n-1]
    输出: 原地有序的 A
    for i = 0 to n-1:
        swapped = false
        for j = 0 to n-i-2:          // 已就位的 n-i 个不用再比
            if A[j] > A[j+1]:
                交换 A[j], A[j+1]
                swapped = true
        if swapped == false:          // 本轮无交换 → 已经有序
            break

核心就两层循环 + 一个 swapped 标志:外层控制轮数,内层做相邻比较与交换,整轮没交换就提前收工。

下面用流程图把这段伪代码拆开来看。

工作原理流程图

把上面的伪代码拆成三层,一层套一层:

flowchart LR
    subgraph 第1层_外层循环
        direction TB
        A1["i = 0, 1, 2, ..."] --> A2{"i < n ?"}
        A2 -- 是 --> A3["进入第 i 轮"]
        A2 -- 否 --> A4(["结束"])
        A3 --> A5["i++ 继续下一轮"]
        A5 --> A2
    end

外层骨架控制总共跑多少轮。

每轮开始时,数组末尾已经有 i 个元素坐稳了,不用再碰它们。

flowchart LR
    subgraph 第2层_内层循环_每轮内部
        direction TB
        B1["j = 0"] --> B2{"j+1 < n-i ?"}
        B2 -- 是 --> B3{"a[j] > a[j+1] ?"}
        B3 -- 是 --> B4["交换 + 标记 swapped"]
        B3 -- 否 --> B5["j++"]
        B4 --> B5
        B5 --> B2
        B2 -- 否 --> B6(["本轮结束"])
    end

中间这层是干活的主力:从左到右扫描未排序部分,相邻两个逆序就换。

一轮跑下来,最大的那个自然被推到了最右边。

flowchart LR
    subgraph 第3层_提前退出优化
        direction TB
        C1["本轮结束后检查 swapped"] --> C2{"发生过交换?"}
        C2 -- 是 --> C3["继续下一轮 ✓"]
        C2 -- 否 --> C4(["提前收工: 已有序 ✓"])
    end

最后一层是最常见的优化:如果整整一轮都没交换过一次,说明数组已经有序了,没必要再跑后面的轮次,直接收工。

三张图拼起来就是完整算法:外层控制轮数 → 内层做相邻比较交换 → 每轮结束检查是否可提前退出。

逐趟演示

光看逻辑不够直观,下面用真实程序跑一遍 [5, 3, 8, 4, 2],每轮把本轮最大的元素冒到末尾(用 [x] 标出已经坐稳的位置):

初始  :  5   3   8   4   2
第 1 轮:  3   5   4   2  [8]      ← 8 最大,冒到末尾
第 2 轮:  3   4   2  [5] [8]      ← 5 冒到倒数第二
第 3 轮:  3   2  [4] [5] [8]      ← 4 落位
第 4 轮:  2  [3] [4] [5] [8]      ← 2 和 3 交换后也落位
第 5 轮: [2] [3] [4] [5] [8]      ← 本轮无交换,提前结束

注意最后一行:第 5 轮进入后内层循环范围为空(0 次比较),swapped 仍为 false,说明数组已经有序,于是直接收工。这个提前退出就是冒泡排序最常见的优化点,后面讲复杂度时会看到它的价值。

稳定性证明

前面给了一个标签叫稳定排序,这里补一个直观例子说明原因。

假设数组里有两组相等的元素,用下标区分它们:

初始:  [3a, 2, 3b, 1]     (3a 在 3b 前面)

冒泡排序的比较条件是严格大于(>),不是大于等于(>=)。所以当 3a 和 3b 相邻时:

比较 3a 和 3b:  3 > 3 为假 → 不交换 → 3a 仍然在 3b 前面

只有严格逆序才触发交换,相等时不动 — 这就是稳定性的来源。

如果你把比较条件从 > 改成 >=,稳定性就被破坏了,这是一个常见面试的问题(可以记录一下)。


能解决什么问题

适用场景 冒泡排序解决的是最朴素的问题:把一个无序序列变成有序序列。

它真正适合的场景其实不多,主要是代价低、好处大的几种:

场景为什么适合
教学与算法入门逻辑直白,是理解比较排序、稳定性、原地排序这些概念的最佳起点
几乎有序的小规模数据如果数据本来就接近有序,提前退出优化能让它逼近 O(n),比动规模子更划算
代码体积 / 实现成本极度敏感的环境比如某些嵌入式裸机、笔试手写、面试白板 — 三五行就能写完,不依赖任何库
需要稳定排序且数据量极小它是稳定排序,且实现简单,数据量个位数时完全够用

只有数据量很小或几乎已经有序时,才轮得到冒泡排序出场。

局限性

好处说完了,现在再泼冷水 — 生产环境基本不该用冒泡排序。

局限说明
时间复杂度高平均 / 最坏都是 O(n²),数据量翻 10 倍,耗时翻约 100 倍
大量无效往返最小的元素每次只能往前挪一格,排在末尾的最小值要 n-1 轮才能归位
缓存不友好虽然顺序访问,但 O(n²) 的总操作量摆在那,大数组下完全跑不动
没有利用分治 / 局部性比起快排、归并、堆排,它没有一次处理一大片的能力

什么时候明确别用:

  • 数据量上千:直接用 std::sort(introsort,平均 O(n log n))。
  • 追求性能的关键路径:冒泡排序在任何规模下都不是最优解。
  • 需要原地但又要快:选堆排序(O(n log n) 且原地)。

它的价值在理解,而不在使用。


C++ 实现与复杂度分析

通用 C++ 模板实现 冒泡排序只依赖比较运算符 >,可以直接写成模板,适用于任意可比较类型(int / double / string 等):

#include <utility>   // std::swap
#include <vector>

template <typename T>
void bubbleSort(std::vector<T>& arr) {
    int n = static_cast<int>(arr.size());
    for (int i = 0; i < n; ++i) {
        bool swapped = false;
        for (int j = 0; j < n - 1 - i; ++j) {
            if (arr[j] > arr[j + 1]) {
                std::swap(arr[j], arr[j + 1]);
                swapped = true;
            }
        }
        if (!swapped) break;   // 提前退出优化
    }
}

完整 C++ 实现

正式可编译的完整代码在 src/cpp/algorithm/bubble_sort.cpp(含逐轮演示与三个测试场景)。

排序核心函数如下:

// 标准冒泡排序(带提前退出优化),原地排序
void bubbleSort(std::vector<int>& arr) {
    const size_t n = arr.size();
    for (size_t i = 0; i < n; ++i) {
        bool swapped = false;                 // 本轮是否发生过交换
        for (size_t j = 0; j + 1 < n - i; ++j) {
            if (arr[j] > arr[j + 1]) {        // 逆序 → 交换
                std::swap(arr[j], arr[j + 1]);
                swapped = true;
            }
        }
        if (!swapped) break;                  // 已有序,提前结束
    }
}

内层循环的条件是 j + 1 < n - i,意味着 j 最大取到 n - i - 2,最后一次比较的是下标 n - i - 2 和 n - i - 1 这一对。也就是说,每轮结束后末尾有 i 个元素不再参与比较 — 这是冒泡排序能少做一点活的关键。

时间复杂度怎么算

分析任何算法的复杂度,都可以按照下面的思路进行:

  1. 找基本操作:选一个随数据规模 n 增长且主导耗时的操作。排序里通常是一次比较(或一次交换)。
  2. 数执行次数:把基本操作的执行次数表示成关于 n 的函数 T(n)。
  3. 取量级:去掉常数系数和低阶项,用大 O 表示法只保留增长最快的那一项。

下面按照这个思路对冒泡排序的复杂度进行分析。

冒泡排序的基本操作是一次相邻比较。

  • 内层循环:第 i 轮(从 0 数起)比较 n - 1 - i 次。因为 j 从 0 跑到 n - i - 2,共 n - i - 1 次比较。
  • 外层循环最多跑 n 轮,所以总比较次数(最坏情况,没有提前退出)为:
T(n) = (n-1) + (n-2) + (n-3) + ... + 1 + 0
     = n(n-1) / 2
     ≈ n² / 2

这是等差数列求和:首项 n-1、末项 0、项数 n,和 = n(首项+末项)/2 = n(n-1)/2。

n²/2 去掉常数系数 1/2,主导项是 n²,所以时间复杂度是 O(n²)。

按场景细分:

情况比较次数交换次数时间复杂度
最好(已有序,带优化)n-10O(n)
最坏(完全逆序)n(n-1)/2n(n-1)/2O(n²)
平均(随机数据)≈ n²/4*≈ n²/4*O(n²)

注:平均情况的 n²/4 是一个近似值。精确的期望比较次数取决于输入分布;对于均匀随机排列且带提前退出的冒泡排序,实际期望值在 O(n²) 量级但系数小于 1/2。记住量级即可,不需要背精确系数。

  • 最好情况 O(n):数组已经有序,第一轮做完 n-1 次比较后发现 swapped == false,直接 break,只跑了一轮。这就是提前退出优化的意义。
  • 最坏 O(n²):完全逆序时每次比较都触发交换,提前退出永远用不上,老老实实跑满 n(n-1)/2 次比较。
  • 平均 O(n²):随机数据下平均需要约 n/2 轮,每轮的工作量递减,总工作量仍在 O(n²) 量级。

空间复杂度怎么算

看额外开的内存是不是随 n 增长:

  • 排序在原数组上原地完成,只用了 i、j、swapped、交换用的临时变量这几个与 n 无关的标量。
  • 这些变量的空间是常数,不随数据规模变化。

所以空间复杂度是 O(1) — 这也是原地排序的准确含义。

经过上述分析可以得出冒泡排序的复杂度为:时间 O(n²)(最好 O(n))、空间 O(1)、稳定、原地。


总结

  • 冒泡排序是什么:反复相邻比较、逆序就交换,每轮把最大元素冒到末尾,直到整体有序。
  • 解决什么问题 / 用在哪:把无序变有序,适合教学、几乎有序的小数据、极致简单的手写场景。
  • 局限:O(n²) 太慢,生产环境尽量别用,上千规模的排序直接上 std::sort就可以了。
  • 复杂度怎么算:找基本操作(比较)→ 数次数(等差数列求和 ∑(n-1-i) = n(n-1)/2)→ 去系数留主项得 O(n²);空间只看额外变量,与 n 无关 → O(1)。