跳到主要内容交换排序详解:冒泡优化与快速排序四种实现 | 极客日志C算法
交换排序详解:冒泡优化与快速排序四种实现
综述由AI生成交换排序算法核心在于元素比较与位置互换。文章详解冒泡排序的提前终止优化,并深度剖析快速排序的四种实现方式:Hoare 双指针、挖坑法、前后指针法及非递归栈模拟。结合三数取中与小区间插入排序优化,解决了基准值选取导致的性能退化问题,同时对比了系统栈与堆区内存模型在递归与非递归场景下的差异,为工业级排序实现提供实践参考。
苹果系统4 浏览 前言
交换排序的核心逻辑非常纯粹:通过两个元素之间的比较与位置交换,让有序的序列逐渐浮现。本文将深度拆解冒泡排序的极致优化,以及快速排序的四种经典实现方式。
一、冒泡排序
1.1 算法思想
冒泡排序模拟了水中气泡上升的过程。大的数值(大气泡)会通过相邻位置的比较,一层一层地往数组末尾浮动。它不跨级,只和邻居讲道理。
核心步骤:
- 比较相邻的两个元素。如果第一个比第二个大,就交换它们。
- 对每一对相邻元素做同样的工作,从第一对到最后一对。
- 每一轮结束,最大的数都会被挪到当前待排序列的最后一位。
- 重复上述步骤,直到没有任何一对数字需要比较。

1.2 为什么你的冒泡排序总是比别人慢?
很多教科书给出的冒泡排序是'死'的。如果数组已经有序了,它依然会傻傻地跑完所有循环。
极致优化思路: 设置一个标记。如果在一轮遍历中没有发生任何交换,说明数组已经有序,直接提前终止!
1.3 代码实现
void BubbleSort(int* a, int n) {
for (int i = 0; i < n; i++) {
int flag = 0;
for (int j = 0; j < n - i - 1; j++) {
if (a[j] > a[j + 1]) {
Swap(&(a[j]), &(a[j + 1]));
flag = ;
}
}
(flag == ) ;
}
}
1
if
0
break
二、快速排序
快速排序(Quick Sort)是计算机科学领域最伟大的算法之一。它的核心是一种'分而治之'的策略:选一个基准值(key),通过一次排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
2.1 初始版本:Hoare 版
快排的创始人 Tony Hoare 最初设计的逻辑非常简洁:选取一个基准值(key),通过左右指针的'对冲'交换,实现左右分区。
- 选基准:通常取区间最左侧元素为 key。
- 右指针(R)先行:从右向左寻找比 key 小的值。
- 左指针(L)后行:从左向右寻找比 key 大的值。
- 交换:将 L 和 R 指向的元素对调。
- 相遇归位:当 L 与 R 相遇时,将 key 与相遇点的值交换。
这里得问问为什么了,为什么相遇的位置一定是 key 的准确位置呢?为什么必须是 R 先走呢?当 L 和 R 相遇时,只有两种情况,而这两种情况在 R 先走的前提下都是安全的:
- 过程:L 在上一轮交换后停下的位置,或者就在初始位置。由于 L 停下的位置要么是 key 本身,要么是刚刚换过来的比 key 小的值。
- 结果:R 向左移动寻找比 key 小的值,直到撞上 L。此时相遇点的值是 L 停留的值(即 ≤ key 的值),交换是安全的。
- 过程:R 先走,已经找出了一个比 key 小的值并停了下来。
- 结果:随后 L 向右移动寻找大值,如果没有找到,最终会撞上停在'小值点'的 R。此时相遇点正是 R 停下的位置,该值必然 < key,交换也是安全的。
- 左边作 key,右边先走:保证相遇点 ≤ key。
- 右边作 key,左边先走:保证相遇点 ≥ key。
一句话总结:先走的那一方,实际上是在为最后一次交换'踩点',确保交换回来的数符合分区的要求。
2.1.1 初始代码
void QuickSort(int* a, int left, int right) {
if (left >= right) return;
int begin = left;
int end = right;
int key_pos = left;
while (begin < end) {
while (begin < end && a[end] >= a[key_pos]) end--;
while (begin < end && a[begin] <= a[key_pos]) begin++;
Swap(&(a[begin]), &(a[end]));
}
Swap(&(a[begin]), &(a[key_pos]));
key_pos = begin;
QuickSort(a, left, key_pos - 1);
QuickSort(a, key_pos + 1, right);
}
2.1.2 优化一:三数取中
在快速排序中,'三数取中'(Median-of-Three)是一项极其关键的优化技术。它的核心目的只有一个:防止算法在处理'最差情况'时退化,确保排序效率的稳定性。
- 规避 O(N²) 的性能陷阱:快速排序的平均时间复杂度是 O(N log N),但这取决于基准值(Pivot)能否将数组大致对半分。
- 理想情况:每次基准值都能落在区间的中位数附近,递归树是平衡的,层数为 log N。
- 最差情况:如果数组已经有序(升序或降序),而你总是选最左边的数作为基准值。此时,基准值每次都是最大或最小值,导致划分出的子区间一个为空,另一个包含 N−1 个元素。递归树会变成一根'长棍',时间复杂度直接退化到 O(N²)。
- 提高'基准值'的代表性:'三数取中'通过取区间左端、右端和中间位置的三个数,并选出其中的中位数作为基准值。
- 逻辑:通过这三个位置的采样,极大地降低了选到'极端值'(最大或最小)的概率。
- 效果:它强制让基准值更趋向于整个区间的平均水平,从而保证递归划分更加均匀。
2.1.3 优化二:小区间优化
快速排序是一个递归算法。随着递归的深入,子区间的长度会越来越短。当区间长度减小到一定程度(例如 10 左右)时,继续使用快排会带来两个负面影响:
- 递归开销过大:每一层递归都需要建立函数栈帧,涉及参数传递、寄存器保护等操作。对于只有几个元素的区间,递归带来的系统开销远超排序本身的计算开销。
- 效率递减:在数据量极小时,快排 O(N log N) 的复杂性优势并不明显,而简单的排序算法(如插入排序)因为逻辑简单,在小规模数据下表现更好。
假设此图为快速排序递归树,如果每一次递归都进行到区间只有一个元素为止,那么最后一层将产生最多的函数调用。根据统计,递归树最后 3 到 4 层的节点数占了整棵树总节点数的 80% 以上。
那就可以将最后几层优化掉,也就是使用别的排序替换掉最后几层的快排。你可以选择任何你喜欢的排序算法,这里我选插入排序。
if ((right - left + 1) < 10) {
InsertSort(a + left, right - left + 1);
return;
}
当子区间的 right - left + 1 小于某个阈值(通常设定为 10~15)时,我们不再进行划分,而是直接调用插入排序(Insertion Sort)。
- 趋于有序:在快排的过程中,数据已经在大体上变得有序了。而插入排序在处理'接近有序'的数据时,效率极高,接近 O(N)。
- 空间开销小:插入排序是原地排序,没有额外的递归成本。
2.2 版本二:前后指针法
在快速排序的多种单趟排序实现中,前后指针法以其代码极其精简、逻辑高度一致的特点,成为现代工程实践中最受推崇的版本之一。
2.2.1 核心思想:推土机策略
前后指针法的核心思想可以形象地比喻为 '推土机'或'区域划分':
- key (基准值):作为参照的标杆,通常选定为区间的第一个元素。
- prev (后指针):它是小于 key 区域的边界。它的左边(包括它自己)存放的都是已经发现的比 key 小的值。
- cur (前指针):它是侦察兵。它从左向右扫描数组,寻找比 key 小的元素。
2.2.2 算法执行步骤
- 初始化:通过'三数取中'优化选出基准值并换到 left 位置。令 prev 指向 left,cur 指向 left + 1。
- 侦察阶段 (cur 移动):
- 如果 a[cur] 大于或等于 key,cur 直接继续向后走。
- 如果 a[cur] 小于 key,说明找到了一个需要被'归位'的小数:
- prev 向前移动一步(扩展小于区边界)。
- 交换 a[prev] 和 a[cur],将这个小数扔进 prev 维护的'安全区'内。
- 基准值归位:当 cur 遍历完整个区间后,prev 所在的位置就是小于 key 区域的最后一个位置。此时交换 a[prev] 和 a[key_pos],key 正好落在了大小数的分界点上。
2.2.3 实现代码
int PartSort3(int* a, int left, int right) {
int midi = getMidi(a, left, right);
Swap(&(a[midi]), &(a[left]));
int key_pos = left;
int prev = left;
int cur = left + 1;
while (cur <= right) {
if (a[cur] < a[key_pos]) {
prev++;
if (prev != cur) Swap(&(a[cur]), &(a[prev]));
}
cur++;
}
Swap(&(a[prev]), &(a[key_pos]));
return prev;
}
2.3 版本三:挖坑法 (最易理解)
挖坑法是 Hoare 版本的变体,以其极其直观的逻辑,成为了最容易理解的版本。通过'填坑'的动作,形象地展示了数据如何根据基准值(key)进行左右分流,不需要纠结谁先走。
2.3.1 核心思想:填空游戏
挖坑法的核心在于利用一个临时变量存出基准值,从而在数组中制造一个'坑位',随后通过左右指针交替填坑。
- 基准值 (key):通常选定区间第一个元素,将其取出保存,此时该位置(left)形成第一个坑。
- 右指针 (right):从右向左移动,寻找比 key 小的数,将其填入左边的坑中,随后 right 位置变成新坑。
- 左指针 (left):从左向右移动,寻找比 key 大的数,将其填入右边的坑中,随后 left 位置变成新坑。
2.3.2 算法执行步骤
- 初始挖坑:首先进行三数取中优化,将中位数交换至 left。将 a[left] 赋值给变量 key,令 hole = left。
- 右填左:从 right 开始向左找比 key 小的数。找到后,将 a[right] 填入 a[hole],并更新坑位 hole = right。
- 左填右:从 left 开始向右找比 key 大的数。找到后,将 a[left] 填入 a[hole],并更新坑位 hole = left。
- 循环终止:重复上述步骤直到 left 与 right 相遇。
- 回填基准:最后将 key 填入相遇点的坑位 a[hole]。
2.3.3 实现代码
int PartSort2(int* a, int left, int right) {
int midi = getMidi(a, left, right);
Swap(&(a[midi]), &(a[left]));
int hole = left;
int key = a[left];
while (left < right) {
while (left < right && a[right] >= key) --right;
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= key) ++left;
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
2.4 版本四:非递归版本 (栈的妙用)
在面对海量数据时,深度递归可能会导致栈溢出(Stack Overflow),因为系统栈的空间是有限的。为了提高算法的稳健性,我们可以利用 堆区(Heap) 上开辟的自定义栈(Stack)来手动模拟递归过程。
为什么在处理工业级大数据时,非递归版本更受青睐?这涉及到计算机底层内存的布局差异:
| 内存区域 | 递归(系统栈) | 非递归(自定义栈) |
|---|
| 所属位置 | 栈区 (Stack) | 堆区 (Heap) |
| 空间大小 | 极小,通常为 1MB ~ 8MB | 极大,取决于物理内存 (GB 级别) |
| 溢出风险 | 高(深度递归易导致 Stack Overflow) | 极低(通过 realloc 动态扩容) |
| 管理方式 | 操作系统自动分配与回收 | 程序员手动初始化与销毁 |
把每次函数递归时不一样的部分画在递归树上可以得到,每次就是待排序数组的边界不一样,也就是区间,那就只需要把区间存起来就好了,然后每次取出需要用的那个区间就好,现在要考虑的就是存取顺序而已,在递归版本的代码,首先是对左部分区间进行递归排序,那就跟递归版本保持一致,也从左区间开始存取,但栈是后进先出,所以怎么压栈就是最大的难题。
先排左,那就代表需要先取出左,那就只能先压右,注意这里区间有左右两个数,这个顺序也很重要,不要取反了。
2.4.1 核心操作接口
- *STInit(ST pst)**:初始化栈。将数组指针置空,容量与栈顶位置归零。
- *STDestroy(ST pst)**:销毁栈。释放动态开辟的内存空间,防止内存泄漏。
- *STPush(ST pst, STDataType x)**:入栈。
- 关键点:当栈满时(top == capacity),利用 realloc 进行扩容。
- 在快排中的作用:将待处理子区间的 left 和 right 下标压入栈中。
- *STPop(ST pst)**:出栈。
- 关键点:需要断言(assert)栈不为空,直接将 top 减 1 即可实现逻辑删除。
- *STTop(ST pst)**:获取栈顶元素。
- 关键点:返回 a[top - 1] 位置的值。在快排中,这用于获取当前要处理的区间边界。
- *isEmpty(ST pst)**:判空。
- 这是 while 循环的终止条件。只要栈不为空,就说明还有待排序的子区间。
这些接口之前都已经实现过了,所以这里只给出接口,代码参考之前的栈实现博客。
2.4.2 核心执行流程
- 初始入栈:将整个待排序数组的左右边界下标压入栈中(例如先入 right 再入 left)。
- 循环迭代:只要栈不为空,就说明还有待处理的任务。
- 出栈分区:从栈中成对取出 begin 和 end,调用单趟排序函数(如 singleTripSort)确定基准值的位置 key_pos。
- 任务拆分:根据 key_pos 将原区间拆分为左右两个子区间。
- 入栈子区间:如果子区间依然合法(即包含多个元素),则将子区间的边界再次压入栈中,等待下一轮循环处理(注意入栈顺序)。
2.4.3 实现代码
void QuickSortNonR(int* a, int left, int right) {
ST st;
STInit(&st);
STPush(&st, right);
STPush(&st, left);
while (!isEmpty(&st)) {
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
int key_pos = singleTripSort(a, begin, end);
if (key_pos + 1 < end) {
STPush(&st, end);
STPush(&st, key_pos + 1);
}
if (key_pos - 1 > begin) {
STPush(&st, key_pos - 1);
STPush(&st, begin);
}
}
STDestroy(&st);
}
三、总结
3.1 算法哲学的跨越:从'渐进有序'到'分治天下'
- 冒泡排序(Bubble Sort):本质是局部有序化。它像慢速的推土机,通过相邻交换稳步推进。即便加入了 flag 优化,其本质仍未脱离 O(N²) 的限制,适用于小规模数据或教学入门。
- 快速排序(Quick Sort):本质是全局平衡化。它通过'跨越式交换'迅速缩减问题规模。每一次分区(Partition)都是在确立一个元素的最终物理位置,这种'一战定乾坤'的思想是其高效的根源。
3.2 快排单趟排序的三重境界
- Hoare 版本(对冲逻辑):最原始、最经典的指针碰撞,通过左右指针向中间逼近。技术要点是必须由'基准值对侧'的指针先走,以确保相遇点的安全交换。
- 挖坑法(填补逻辑):通过'取值成坑、对向填补'将逻辑解耦,规避了 Hoare 版本中复杂的先后手限制,是最易于代码落地和教学演示的版本。
- 前后指针法(搬运逻辑):利用 prev 和 cur 像推土机一样线性推进。这种方法逻辑高度一致,代码最为精简,且对 CPU 缓存(Cache)更加友好,是工业级实现的首选。
3.3 量化调优:剪枝与避坑
- 三数取中(Median-of-three):通过物理采样规避了有序数组导致 O(N²) 的'性能陷阱',强行保证递归树的平衡。
- 小区间优化(Small Subarray Optimization):
- 量化支撑:递归树最后 3-4 层的节点数占整棵树的 80% 以上。
- 策略:当区间长度小于阈值(如 10)时切换为插入排序,相当于直接砍掉了 80% 的函数栈帧开销,让算法在微观层面也保持高效。
3.4 内存模型:从系统栈到堆区的突围
- 物理突破:将区间任务从只有几个 MB 的系统栈区(Stack)转移到了 GB 级别的 堆区(Heap)。
- 逻辑模拟:利用 LIFO 栈结构手动维护待处理的区间,模拟了深度优先搜索(DFS)的遍历路径。这是处理千万级、亿级海量数据时防止程序崩溃的终极保障。
3.5 综合性能对比表
| 特性 | 冒泡排序(优化版) | 快速排序(递归 + 优化) | 快速排序(非递归) |
|---|
| 平均时间复杂度 | O(N²) | O(N log N) | O(N log N) |
| 空间复杂度 | O(1) | O(log N)(栈帧消耗) | O(log N)(堆区开销) |
| 核心风险 | 效率极低 | 深度递归导致栈溢出 | 需要手动管理内存 |
| 最佳应用场景 | 教学、极小规模数据 | 通用高性能排序 | 工业级、海量数据处理 |
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online