☰
C++冒泡排序及其优化算法
2026/11/6 5:30:52 网站建设 项目流程

前言


冒泡排序(bubble sort)是最早接触的排序算法之一:反复比较相邻的两个元素,逆序就交换,每一轮把一个极值「冒」到区间末尾。它的最坏和平均时间复杂度都是 O(n²),实际项目里几乎不会用它,但它非常适合用来说明「有序区间的扩张」「循环不变式」「提前退出」这些概念,也经常作为优化题出现。


常见的误解有两个。一是以为加了标志位之后算法就「变快了一个数量级」——标志位只改善了最好情况(原本就有序时从 O(n²) 降到 O(n)),最坏和平均仍是 O(n²)。二是以为「优化过的冒泡」能和插入排序、快速排序相提并论——在随机数据上,冒泡的交换次数远多于插入排序,常数也更大,优化改变不了这个量级。


本文从最基本的双层循环讲起,依次加入三种常见优化:提前退出标志、记录最后一次交换位置、双向冒泡(鸡尾酒排序),并说明每种优化改善的是哪一类输入。最后给出复杂度对照表和实现时最容易写错的边界。


一、基本冒泡排序


算法维护一个不变式:第 i 轮结束时,区间末尾的 i 个元素已经是全局最大的 i 个,且有序。每一轮从下标 0 开始,比较相邻元素,逆序则交换,直到本轮区间的末尾。


// 适用于 C++17
#include <cstddef>
#include <utility>
#include <vector>

void bubbleSort(std::vector<int>& a) {
const std::size_t n = a.size();
for (std::size_t i = 0; i + 1 < n; ++i) {
for (std::size_t j = 0; j + 1 < n - i; ++j) {
if (a[j] > a[j + 1]) {
std::swap(a[j], a[j + 1]);
}
}
}
}

外层循环执行 n-1 轮就够了:每轮至少把一个元素放到最终位置,n-1 轮之后剩下的那个自然也在正确位置。内层上界是n - i,因为末尾 i 个元素已经有序,不需要再比较。


关于下标类型:这里用std::size_t,循环条件写成j + 1 < n - i而不是j < n - i - 1。后者在n - i为 0 时会发生无符号下溢,变成一个极大的数,循环失控。写成j + 1 < n - i时,j + 1最多等于n - i,不会溢出(j本身远小于SIZE_MAX),条件自然为假。这是无符号下标倒序/边界循环最常见的防御写法。


正确性可以用不变式说明:内层循环每比较一次,较大者向右移动一位,因此一轮结束后本轮区间的最大值一定停在区间末尾。下一轮把它排除在外,重复下去,整个数组有序。


基本版本的比较次数固定为 n(n-1)/2,与输入无关。即使数组已经有序,它仍然会把所有比较做完,这是后面优化的出发点。


二、优化一:提前退出标志


如果某一轮里一次交换都没有发生,说明任意相邻元素都满足顺序,整个数组已经有序,可以立即结束。用一个布尔标志记录本轮是否交换过:


// 适用于 C++17
void bubbleSortFlag(std::vector<int>& a) {
const std::size_t n = a.size();
for (std::size_t i = 0; i + 1 < n; ++i) {
bool swapped = false;
for (std::size_t j = 0; j + 1 < n - i; ++j) {
if (a[j] > a[j + 1]) {
std::swap(a[j], a[j + 1]);
swapped = true;
}
}
if (!swapped) {
break;
}
}
}

这个优化让最好情况(输入已经升序)只做一轮 n-1 次比较,复杂度 O(n)。对「几乎有序、只有少量逆序对」的数据也有帮助,因为一旦某一轮没有交换就提前结束。但要注意,最坏情况(完全逆序)仍然要做满 n-1 轮,比较次数不变,交换次数是 n(n-1)/2。


标志位必须每轮重新置为false,不能放在外层循环外面只初始化一次,否则第一轮之后永远为真,提前退出失效(或者反过来永远为假,第一轮就退出)。这是实现时最常见的错误之一。


三、优化二:记录最后一次交换的位置


观察内层循环:如果某一轮中,从某个位置之后再也没有发生交换,说明那段后缀已经有序,下一轮不必再比较到末尾。记录本轮最后一次交换发生的下标lastSwap,下一轮的内层上界就收缩到那里。


// 适用于 C++17
void bubbleSortLastSwap(std::vector<int>& a) {
std::size_t n = a.size();
while (n > 1) {
std::size_t lastSwap = 0;
for (std::size_t j = 0; j + 1 < n; ++j) {
if (a[j] > a[j + 1]) {
std::swap(a[j], a[j + 1]);
lastSwap = j + 1; // 最后一次交换的右端点
}
}
n = lastSwap; // 之后的元素已经有序
}
}

这里用n表示「还需要处理的区间长度」,每轮结束后把它更新为lastSwap。如果整轮没有交换,lastSwap保持 0,n变成 0,循环自然结束,所以这个版本连标志位都不需要。


正确性在于:最后一次交换发生在lastSwap - 1与lastSwap之间,说明从lastSwap到本轮末尾的相邻元素都满足顺序,而本轮末尾又是当前最大值,因此这段后缀整体有序,可以永久排除。


这个优化对「尾部已经有序、头部还有少量乱序」的数据效果明显,但它不改变最坏复杂度:完全逆序时每轮最后一次交换都在末尾,n只减少 1,比较次数仍是 O(n²)。


四、优化三:双向冒泡


基本冒泡有一个弱点:如果一个很小的元素位于数组末尾,它每轮只能向左移动一位,需要很多轮才能到位(常被称为「乌龟」问题)。双向冒泡(鸡尾酒排序,cocktail shaker sort)在一轮里先从左到右把最大值送到右端,再从右到左把最小值送到左端,两个方向交替进行。


// 适用于 C++17
void cocktailSort(std::vector<int>& a) {
std::size_t begin = 0;
std::size_t end = a.size();
bool swapped = true;
while (swapped && begin + 1 < end) {
swapped = false;
for (std::size_t i = begin; i + 1 < end; ++i) {
if (a[i] > a[i + 1]) {
std::swap(a[i], a[i + 1]);
swapped = true;
}
}
--end; // 右端已就位
if (!swapped) {
break;
}

swapped = false;
for (std::size_t i = end - 1; i > begin; --i) {
if (a[i - 1] > a[i]) {
std::swap(a[i - 1], a[i]);
swapped = true;
}
}
++begin; // 左端已就位
}
}

边界要仔细核对。初始end = a.size(),正向循环i从begin到end - 2,把最大值送到end - 1,随后--end把它排除。反向循环i从end - 1开始,比较i - 1与i,一直做到begin + 1,把最小值送到begin,随后++begin。外层条件begin + 1 < end保证区间里至少还有两个元素;空数组和单元素数组直接不进循环。


双向冒泡对「小元素在末尾」的输入改善明显,最好情况仍是 O(n),最坏和平均仍是 O(n²)。它并没有改变量级,只是把某些输入形态下的轮数减少了一半左右。实现时容易把--end和++begin的位置放错,导致跳过元素或重复处理,建议先用小数组手工推演边界。


五、复杂度与稳定性




版本最好平均最坏额外空间稳定



基本冒泡O(n²)O(n²)O(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)是



稳定性来自交换条件a[j] > a[j + 1]:只有严格逆序才交换,相等元素不会被交换,相对次序保持不变。如果把条件写成>=,相等元素也会交换,排序结果仍然正确,但不再稳定。这一点在只关心数值时无所谓,在按某个键排序、要求保持原有次序时就必须注意。


交换次数方面,冒泡排序的交换次数等于数组的逆序对数量,这一点和插入排序相同;但冒泡的比较次数固定为 O(n²)(除非提前退出),而插入排序在部分有序时比较次数接近 O(n)。所以即使都做了优化,插入排序通常也更适合「基本有序」的数据。实际工程中应当直接用std::sort,它的最坏复杂度是 O(N log N);手写冒泡主要用于教学或面试题。


如果确实要在小数组上用手写排序,插入排序的常数更小、代码同样简单,是更现实的选择。冒泡排序的价值在于它清晰地展示了「每轮确定一个极值」和「提前终止」的思想。


常见坑点


1. 内层上界写错❌for (std::size_t j = 0; j < n - i - 1; ++j),当i == n - 1时n - i - 1下溢。 ✅for (std::size_t j = 0; j + 1 < n - i; ++j),避免无符号减法。


2. 标志位不重置❌bool swapped = false;写在外层循环之外,第一轮之后状态残留。 ✅ 每轮进入内层前重新置false。


3. 提前退出条件写反❌if (swapped) break;—— 只要发生过交换就退出,排序不完整。 ✅if (!swapped) break;。


4. 用>=比较破坏稳定性❌if (a[j] >= a[j + 1]) std::swap(...),相等元素被交换。 ✅if (a[j] > a[j + 1]),只在严格逆序时交换。


5. 最后交换位置更新错❌ 记录lastSwap = j(左端点),下一轮上界少算一位。 ✅ 记录lastSwap = j + 1,它是「已有序后缀的起点」。


6. 双向冒泡边界偏移❌ 正向结束后忘记--end,或反向循环从end开始访问a[end]越界。 ✅ 正向把最大值送到end - 1后--end;反向从end - 1开始比较i - 1与i。


7. 认为优化后最坏也是 O(n)❌ 宣称「加了标志位,冒泡排序平均 O(n)」。 ✅ 最好情况 O(n),平均与最坏仍是 O(n²)。


8. 对空数组或单元素数组没验证❌ 外层直接写for (i = 0; i < n - 1; ++i),n == 0时下溢。 ✅ 用i + 1 < n或先判断n < 2直接返回。


总结




优化改善的输入不改善的部分



提前退出标志已有序、几乎有序完全逆序仍 O(n²)

记录最后交换位置尾部已有序最坏仍 O(n²)

双向冒泡小元素集中在末尾最坏仍 O(n²)

稳定比较(严格大于)保持相等元素次序不改变复杂度



三种优化针对的是不同的输入形态,共同点是都不改变 O(n²) 的最坏复杂度。理解它们的价值在于看清「哪一段已经有序」「什么时候可以停」这两个判断如何减少无用比较,而不是指望手写冒泡在实际项目里替代标准库排序。真正需要排序时用std::sort,需要稳定时用std::stable_sort,手写冒泡留在教学和面试场景即可。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询