1. 引言
在数据结构的学习中,排序算法是重中之重。前面我们学习了插入排序(直接插入、希尔排序)和选择排序(简单选择、堆排序),它们分别通过"插入"和"选择"的思路完成排序。今天,我们来学习第三大类------交换类排序。
交换类排序的核心思想是:通过不断交换逆序元素的位置,逐步使整个序列有序。这类排序中最具代表性的两个算法是:
- 冒泡排序:简单直观,适合入门理解交换思想。
- 快速排序:平均性能最优的排序算法之一,应用极其广泛。
本文将带你从原理、图解、代码实现到复杂度分析,彻底搞懂这两大交换排序算法。
2. 交换类排序的基本思想
交换类排序的共同特点是:在排序过程中,通过比较相邻(或不相邻)元素,若发现逆序(即顺序与目标排序方向相反),则交换它们的位置,直到整个序列不再存在逆序对为止。
用一句话概括:排序的过程就是不断消除逆序对的过程。
#mermaid-svg-uDNFI4TJTCv2s7GY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uDNFI4TJTCv2s7GY .error-icon{fill:#552222;}#mermaid-svg-uDNFI4TJTCv2s7GY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uDNFI4TJTCv2s7GY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uDNFI4TJTCv2s7GY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uDNFI4TJTCv2s7GY .marker.cross{stroke:#333333;}#mermaid-svg-uDNFI4TJTCv2s7GY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uDNFI4TJTCv2s7GY p{margin:0;}#mermaid-svg-uDNFI4TJTCv2s7GY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uDNFI4TJTCv2s7GY .cluster-label text{fill:#333;}#mermaid-svg-uDNFI4TJTCv2s7GY .cluster-label span{color:#333;}#mermaid-svg-uDNFI4TJTCv2s7GY .cluster-label span p{background-color:transparent;}#mermaid-svg-uDNFI4TJTCv2s7GY .label text,#mermaid-svg-uDNFI4TJTCv2s7GY span{fill:#333;color:#333;}#mermaid-svg-uDNFI4TJTCv2s7GY .node rect,#mermaid-svg-uDNFI4TJTCv2s7GY .node circle,#mermaid-svg-uDNFI4TJTCv2s7GY .node ellipse,#mermaid-svg-uDNFI4TJTCv2s7GY .node polygon,#mermaid-svg-uDNFI4TJTCv2s7GY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uDNFI4TJTCv2s7GY .rough-node .label text,#mermaid-svg-uDNFI4TJTCv2s7GY .node .label text,#mermaid-svg-uDNFI4TJTCv2s7GY .image-shape .label,#mermaid-svg-uDNFI4TJTCv2s7GY .icon-shape .label{text-anchor:middle;}#mermaid-svg-uDNFI4TJTCv2s7GY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uDNFI4TJTCv2s7GY .rough-node .label,#mermaid-svg-uDNFI4TJTCv2s7GY .node .label,#mermaid-svg-uDNFI4TJTCv2s7GY .image-shape .label,#mermaid-svg-uDNFI4TJTCv2s7GY .icon-shape .label{text-align:center;}#mermaid-svg-uDNFI4TJTCv2s7GY .node.clickable{cursor:pointer;}#mermaid-svg-uDNFI4TJTCv2s7GY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uDNFI4TJTCv2s7GY .arrowheadPath{fill:#333333;}#mermaid-svg-uDNFI4TJTCv2s7GY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uDNFI4TJTCv2s7GY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uDNFI4TJTCv2s7GY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uDNFI4TJTCv2s7GY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uDNFI4TJTCv2s7GY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uDNFI4TJTCv2s7GY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uDNFI4TJTCv2s7GY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uDNFI4TJTCv2s7GY .cluster text{fill:#333;}#mermaid-svg-uDNFI4TJTCv2s7GY .cluster span{color:#333;}#mermaid-svg-uDNFI4TJTCv2s7GY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uDNFI4TJTCv2s7GY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uDNFI4TJTCv2s7GY rect.text{fill:none;stroke-width:0;}#mermaid-svg-uDNFI4TJTCv2s7GY .icon-shape,#mermaid-svg-uDNFI4TJTCv2s7GY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uDNFI4TJTCv2s7GY .icon-shape p,#mermaid-svg-uDNFI4TJTCv2s7GY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uDNFI4TJTCv2s7GY .icon-shape .label rect,#mermaid-svg-uDNFI4TJTCv2s7GY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uDNFI4TJTCv2s7GY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uDNFI4TJTCv2s7GY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uDNFI4TJTCv2s7GY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
待排序序列
是否存在逆序对?
交换逆序元素位置
排序完成
3. 冒泡排序
3.1 算法原理
冒泡排序是最简单的交换排序。它的基本思路是:从序列的一端开始,依次比较相邻的两个元素 ,如果它们的顺序错误(如升序时前一个大于后一个),就交换它们。每一趟排序都会让当前未排序部分中最大(或最小)的元素"冒泡"到序列的一端 。

3.2 图解过程
以序列 [5, 1, 4, 2, 8] 升序排序为例:
第一趟排序:
- 比较 5 和 1,5 > 1,交换 →
[1, 5, 4, 2, 8] - 比较 5 和 4,5 > 4,交换 →
[1, 4, 5, 2, 8] - 比较 5 和 2,5 > 2,交换 →
[1, 4, 2, 5, 8] - 比较 5 和 8,5 < 8,不交换 →
[1, 4, 2, 5, 8]
第一趟结束,最大值 8 已就位。
第二趟排序:
- 比较 1 和 4,不交换 →
[1, 4, 2, 5, 8] - 比较 4 和 2,4 > 2,交换 →
[1, 2, 4, 5, 8] - 比较 4 和 5,不交换 →
[1, 2, 4, 5, 8]
第二趟结束,5 已就位。
第三趟排序:
- 比较 1 和 2,不交换 →
[1, 2, 4, 5, 8] - 比较 2 和 4,不交换 →
[1, 2, 4, 5, 8]
第三趟没有发生任何交换,说明序列已经有序,可以提前结束。
3.3 代码实现
c
void BubbleSort(int* arr, int n)//冒泡排序
{
//1.冒泡排序的核心在于两两比较,并确定最大/小值的位置,需要两次循环
for (int j = 0; j < n; j++)//3.然后再写一层循环确认终止点
{
int flag = 1;
for (int i = 1; i < n-j; i++)//2.先写两两比较确认最大值的循环
{
if (arr[i - 1] > arr[i])
{
Swap(&arr[i - 1], &arr[i]);
flag = 0;
}
}
//4.可以做一些优化,如果排着排着使数组已经有序,就没必要再进行下一步的循环
if (flag == 1)
break;//注意,该优化让最好情况的时间复杂度变成了O(n),但不影响平均和最坏时间复杂度
}
}
3.4 复杂度分析
| 指标 | 最好情况 | 平均情况 | 最坏情况 |
|---|---|---|---|
| 时间复杂度 | O(n) | O(n²) | O(n²) |
| 空间复杂度 | O(1) | O(1) | O(1) |
| 稳定性 | 稳定 | 稳定 | 稳定 |
- 最好情况:序列本身有序,只需一趟扫描,比较 n-1 次,无交换。
- 最坏情况:序列完全逆序,需要 n-1 趟,每趟比较 n-i 次,总比较次数约为 n²/2。
- 稳定性:冒泡排序是稳定的,因为只有在前一个元素严格大于后一个元素时才交换,相等元素不会交换位置。
3.5 冒泡排序 vs 插入排序:同为 O(n²),差距在哪?
3.6 大厂面试与工程实践常见问题
学完冒泡排序,很多同学会好奇:这算法这么"菜",大厂还考吗?其实考的不是冒泡本身,而是它背后的思维。下面整理几个面试和实际工作中高频出现的问题:
Q1:冒泡排序能优化吗?优化后时间复杂度变了吗?
能。最经典的优化就是加一个标志位(flag),当某一趟完全没有发生交换时,说明序列已经有序,可以提前结束。但要注意:这个优化只把最好情况从 O(n²) 降到了 O(n),平均和最坏情况依然是 O(n²)。因为大 O 描述的是增长趋势,标志位改变的只是常数因子和特定输入下的实际趟数,并没有改变最坏情况下的量级。
Q2:为什么实际开发中几乎没人用冒泡排序?
因为它的"性价比"太低。同样是 O(n²) 量级,插入排序对"接近有序"的数据几乎线性,而冒泡排序即使加了标志位,对"接近有序"的数据依然要跑很多趟。工程里数据往往不是完全乱序的,插入排序的适应性让它更实用。
Q3:冒泡排序稳定吗?为什么?
稳定。因为只有在前一个元素严格大于后一个元素时才交换,相等元素不会交换位置,所以相等元素的相对顺序不会改变。这一点在需要保持原始顺序(比如按多个字段排序)时很重要。
Q4:冒泡排序和快速排序有什么关系?
冒泡排序是快速排序的"思想启蒙"。快速排序本质上也是通过交换来消除逆序对,但它不再局限于相邻元素,而是通过基准划分,一次交换就能让一个元素归位,效率大幅提升。可以说,冒泡是"笨拙版"的交换排序,快排是"聪明版"的交换排序。
Q5:什么场景下冒泡排序还有用?
- 数据量很小(比如 n < 50)且对代码简洁度要求高时;
- 教学演示,帮助理解"交换"和"逆序对"的概念;
- 某些嵌入式或教学环境,代码越简单越好。
一个真实的工程小故事:
早期有些系统在排序前会先判断"数据是否基本有序",如果基本有序就直接用插入排序或冒泡排序的优化版,而不是一上来就快排。因为快排对"接近有序"的数据反而会退化到 O(n²)。这告诉我们:没有绝对最好的排序,只有最适合当前数据特征的排序。
3.7 循环不变量证明:为什么冒泡排序一定正确?
很多同学学排序只记代码,却很少问一句:凭什么这样排几趟就一定有序? 这里我们用循环不变量来严格证明冒泡排序的正确性。
什么是循环不变量?
循环不变量是在循环的每次迭代开始前都成立的一个性质。只要满足三个条件,就能证明算法正确:
- 初始化:循环开始前,不变量成立。
- 保持:如果某次迭代前不变量成立,那么这次迭代后它依然成立。
- 终止:循环结束时,不变量能推出我们想要的结果。
冒泡排序的循环不变量:
外层循环第 j 趟开始前,数组末尾的 j 个元素已经是全局最大的 j 个元素,且它们已经按升序排好。
证明过程:
① 初始化(j = 0):
第 0 趟开始前,末尾 0 个元素"已经排好"------空集自然满足条件,不变量成立。
② 保持(假设第 j 趟前成立,证明第 j+1 趟前也成立):
第 j 趟内层循环从 i = 1 扫描到 i = n-j-1,每次比较 arr[i-1] 和 arr[i],如果前者更大就交换。这等价于把当前未排序部分 [0, n-j-1] 中的最大值"冒泡"到了位置 n-j-1。
为什么?因为内层循环每比较一次,较大的值就往右"走"一步,一趟下来,未排序部分的最大值一定被推到了最右端。而根据不变量,末尾 j 个元素已经是全局最大的 j 个,所以这个"冒泡上来的最大值"就是全局第 j+1 大的元素,它被放到了位置 n-j-1。
于是第 j+1 趟开始前,末尾 j+1 个元素是全局最大的 j+1 个,且已按升序排好。不变量保持成立。
③ 终止(j = n-1):
外层循环结束时,末尾 n 个元素(也就是整个数组)是全局最大的 n 个元素,且已按升序排好。整个数组有序,算法正确。
加上标志位后,证明需要调整吗?
不需要。标志位只是让算法在"某一趟无交换"时提前终止。而"某一趟无交换"恰恰说明这一趟里没有任何相邻逆序对,即整个数组已经有序。此时提前退出,不变量依然成立,正确性不受影响------优化的是效率,不是正确性。
一句话总结:
循环不变量告诉我们,冒泡排序的每一趟都在"锁定"一个最大元素到末尾,n-1 趟之后所有元素都被锁定,自然有序。这也是为什么它虽然慢,但逻辑上绝对可靠。
很多同学会有疑问:冒泡排序和插入排序的平均时间复杂度都是 O(n²),为什么实际使用中几乎没人用冒泡排序?关键在于它们对"接近有序"数据的适应能力完全不同。
| 对比维度 | 冒泡排序 | 插入排序 |
|---|---|---|
| 最好情况触发条件 | 序列完全有序(严格升序/降序) | 序列接近有序即可 |
| 最好情况比较次数 | n-1 次 | 约 n 次 |
| 最好情况移动次数 | 0 次 | 约 n 次 |
| 对"接近有序"数据的表现 | 仍需大量比较,优化有限 | 几乎线性,非常高效 |
| 最坏情况 | 完全逆序,O(n²) | 完全逆序,O(n²) |
为什么冒泡排序的最好情况这么"严苛"?
冒泡排序的提前退出条件是:某一趟完全没有发生交换 。这意味着只有当序列已经严格有序时,它才能在第一趟就发现"无需交换"并提前结束。如果序列只是"接近有序"(比如只有少数几个元素错位),冒泡排序依然要跑完几乎所有的趟数,每趟都要做完整的相邻比较,优化效果非常有限。
为什么插入排序对"接近有序"数据这么友好?
插入排序的核心是:把每个新元素插入到前面已有序部分的正确位置。当序列接近有序时,绝大多数元素只需要比较一两次就能找到自己的位置,几乎不需要移动。所以插入排序在"接近有序"的数据上,实际比较次数接近 O(n),表现接近线性。
一个直观的例子:
假设序列 [1, 2, 3, 5, 4, 6, 7, 8],只有 5 和 4 错位:
- 冒泡排序:第一趟需要完整比较 7 次,把 5 冒泡到末尾;第二趟还要比较 6 次......直到把 4 也归位,总共要跑好几趟。
- 插入排序:从前往后扫描,只有遇到 4 时,发现它比前面的 5 小,往前插入到 3 和 5 之间,其余元素都只需比较一次,几乎线性完成。
结论:
- 冒泡排序的 O(n) 最好情况几乎只在"完全有序"时才出现,实际意义不大。
- 插入排序的 O(n) 最好情况在"接近有序"时就能触发,这在工程中非常常见(比如对几乎排好序的数据做微调)。
- 所以同样是 O(n²) 量级,插入排序在真实场景中往往比冒泡排序快得多,这也是为什么实际开发中几乎不用冒泡排序,而插入排序(尤其是希尔排序的基础)仍有实用价值。
- 至于简单选择排序呢?复杂度上面拉完了,跟冒泡坐一桌
4. 快速排序
4.1 算法原理
快速排序(Quick Sort)是交换排序的进阶版,顾名思义它在综合情况下几乎是最快的排序方式,也是目前应用最广泛的排序算法之一。它采用分治策略:
- 从序列中选取一个"基准"元素(pivot)。
- 通过一趟排序,将序列分成两部分:左边所有元素 ≤ 基准,右边所有元素 ≥ 基准。
- 对左右两部分分别递归地执行上述过程,直到每个部分只有一个元素。
4.2 图解过程
以序列 [6, 1, 2, 7, 9, 3, 4, 5, 10, 8] 为例,选择第一个元素 6 作为基准:
第一趟划分:
- 从右向左找第一个小于 6 的元素:找到 5,交换 →
[5, 1, 2, 7, 9, 3, 4, 6, 10, 8] - 从左向右找第一个大于 6 的元素:找到 7,交换 →
[5, 1, 2, 6, 9, 3, 4, 7, 10, 8] - 从右向左找小于 6 的元素:找到 4,交换 →
[5, 1, 2, 4, 9, 3, 6, 7, 10, 8] - 从左向右找大于 6 的元素:找到 9,交换 →
[5, 1, 2, 4, 6, 3, 9, 7, 10, 8] - 从右向左找小于 6 的元素:找到 3,交换 →
[5, 1, 2, 4, 3, 6, 9, 7, 10, 8]
此时基准 6 已归位,左边 [5, 1, 2, 4, 3] 全部小于 6,右边 [9, 7, 10, 8] 全部大于 6。
递归处理左右两部分:
- 对
[5, 1, 2, 4, 3]以 5 为基准继续划分。 - 对
[9, 7, 10, 8]以 9 为基准继续划分。
不断递归,直到每个子序列只有一个元素,排序完成。
4.3 代码实现
说明:4.2 图解里用「交换」来描述划分过程,而下面的代码用挖坑法实现,两者效果等价,但挖坑法用赋值代替交换,减少了元素搬动次数。
c
// 前置声明:Partition 定义在 QuickSort 之后,C 语言中需先声明,否则会报隐式声明警告
int Partition(int* arr, int low, int high);
void QuickSort(int* arr, int left, int right)//快速排序
{
//1.快速排序思想:选择枢轴,以其为中心遍历数据,大于等于枢轴的放右边,小于等于枢轴的放左边。
// 单轮划分可实现两个效果:一是确认枢轴在数组中的准确位置,二是将数组分为两部分,且实现部分相对有序
//2.在此基础上重复该操作,需要递归,可套用二叉树的逻辑结构来理解,递归前要设置终止条件
if (left >= right)//当左区间与右区间相遇,说明该区间不存在,直接返回
return;
//3.选择枢轴并完成一次划分(这里使用挖坑法)
int pivotIndex = Partition(arr, left, right);
//4.分别递归左右两部分
QuickSort(arr, left, pivotIndex - 1);
QuickSort(arr, pivotIndex + 1, right);
}
int Partition(int* arr, int low, int high)//挖坑法
{
//1.挖坑法思想:把一端的数据视作"坑位",用其他位置的数据把它填上,再按方向遍历数组重复该过程。
// 目标是:坑位左边都小于等于枢轴,右边都大于等于枢轴
//2.假设把左边的数据视作坑位,先将其复制一份保存,然后:
// 右指针先走,找小于 pivot 的元素填左坑;再左指针走,找大于 pivot 的元素填右坑;
// 直到 left 和 right 重合,重合位置就是枢轴的最终位置
int pivot = arr[low];//保存枢轴的值(注意:这里存的是值,不是下标)
while (low < high)
{
//右指针从右往左找第一个小于 pivot 的元素
//用 >= 而非 >,可减少相等元素的无效移动
while (low < high && arr[high] >= pivot)
{
high--;
}
arr[low] = arr[high];//把找到的小元素填入左坑,high 位置成为新坑
//左指针从左往右找第一个大于 pivot 的元素
while (low < high && arr[low] <= pivot)
{
low++;
}
arr[high] = arr[low];//把找到的大元素填入右坑,low 位置成为新坑
}
arr[low] = pivot;//left 与 right 重合处即为枢轴最终位置
return low;//返回枢轴下标
}
几个容易踩的坑:
- 函数名拼写 :
Parttion应为Partition,且定义在QuickSort之后时必须先声明,否则编译器会按隐式声明处理,返回值类型可能被误判。 - 变量命名 :
pivot存的是基准值 ,pivotIndex存的是下标,两者分开命名可避免混淆。 - 比较符号 :内层循环用
>=/<=而不是>/<,能让相等元素尽量不被搬动,减少无效赋值。 - 递归顺序优化(进阶):先递归较短的一侧、再对较长的一侧用循环处理,可把递归栈深度稳定控制在 O(log n),避免最坏情况下栈溢出。
4.4 划分方法对比:挖坑法、Hoare 法与 Lomuto 法
如果你翻过不同教材或网上的快排代码,会发现它们长得完全不一样,甚至同一个"快速排序"能写出好几种 Partition。这不是谁写错了,而是划分(partition)这一步本来就有多种实现流派。主流的有三种:
| 划分方法 | 核心思路 | 交换/赋值次数 | 边界处理难度 | 常见出处 |
|---|---|---|---|---|
| Hoare 法(原版) | 左右双指针相向而行,找到逆序对就交换 ,最后返回 left |
交换次数少 | 较难,返回值是分割点而非枢轴位置 | Hoare 1962 年原始论文、不少经典教材 |
| 挖坑法 | 保存枢轴值,把一端当"坑",用另一端的元素填坑,最后把枢轴填回重合处 | 用赋值代替交换,搬动更少 | 中等,返回枢轴最终下标 | 国内教材、考研 408 常见写法 |
| Lomuto 法 | 用 i 维护"小于等于枢轴"区域的右边界,j 从左到右扫描,遇到小的就 swap(++i, j) |
交换次数较多 | 最简单,不易写错 | 《算法导论》、多数教学语言实现 |
挖坑法算主流吗?
算。在国内的数据结构教材和考研场景里,挖坑法(也叫"填坑法")是非常主流的写法,因为它只用赋值、不用交换 ,一趟划分的元素搬动次数比 Hoare 法更少,而且返回的就是枢轴最终下标,递归时直接 pivotIndex ± 1 即可,写起来很顺手。本文 4.3 的代码用的就是它。
Hoare 法是最早的版本,以发明者 Tony Hoare 命名(读作 /hɔːr/,他正是 1960 年前后提出快速排序的人)。它的写法大致是:
c
int Partition_Hoare(int* arr, int low, int high)
{
int pivot = arr[low];//以最左元素为基准值
int i = low, j = high;
while (i < j)
{
//右指针先走,找小于 pivot 的元素
while (i < j && arr[j] >= pivot)
j--;
//左指针再走,找大于 pivot 的元素
while (i < j && arr[i] <= pivot)
i++;
if (i < j)
Swap(&arr[i], &arr[j]);//找到一对逆序元素,交换
}
//最后把基准值换到分界位置
Swap(&arr[low], &arr[i]);
return i;//返回的是枢轴最终下标
}
注意 Hoare 法的两个易错点:必须让右指针先走 (否则基准值归位时会出错),以及循环结束后还要再做一次 Swap(&arr[low], &arr[i]) 把基准值换到中间。
Lomuto 法 则是最"傻瓜"的一种,思路是维护一个边界 i,表示 [low, i] 区间内所有元素都 ≤ 枢轴:
c
int Partition_Lomuto(int* arr, int low, int high)
{
int pivot = arr[high];//以最右元素为基准值
int i = low - 1;//i 指向"小于等于枢轴"区域的最后一个位置
for (int j = low; j < high; j++)
{
if (arr[j] <= pivot)
{
i++;
Swap(&arr[i], &arr[j]);
}
}
Swap(&arr[i + 1], &arr[high]);//把枢轴放到正确位置
return i + 1;
}
它的优点是逻辑极其清晰、几乎不会写错,非常适合教学和面试手写;缺点是即使元素已经在正确位置,也可能发生无意义的自交换,实际交换次数偏多。
三者的本质区别:
- Hoare 法 :一趟划分后,枢轴不一定 在最终位置上,返回的
i只是"左半部分 ≤ 右半部分"的分界点,所以递归要写成QuickSort(arr, low, i)和QuickSort(arr, i + 1, high),不能 用i - 1/i + 1,否则会死循环或漏元素。 - 挖坑法 / Lomuto 法 :一趟划分后,枢轴一定 落在最终位置,返回的就是它的下标,递归写
pivotIndex - 1/pivotIndex + 1即可,这也是它们更受初学者欢迎的原因。 - 性能上:三者平均都是 O(n log n),最坏都是 O(n²)。Hoare 法的常数因子通常最小(交换次数少),挖坑法次之,Lomuto 法在大量重复元素时表现较差(可用三路划分优化)。
学习建议:
对一篇讲"交换类排序"的入门博客来说,没必要把三种代码全背下来。更划算的做法是:
- 主实现掌握挖坑法(本文 4.3 已给出),能独立手写出来;
- 知道"划分方法不止一种",能说出 Hoare 法和 Lomuto 法的思路差异;
- 重点记住递归边界写法的区别------这是面试和考试里最容易踩的坑,比多背一份代码有价值得多。
理解了这一点,再看别人的快排代码就不会被"为什么这里写 i 而不是 i - 1"绕晕了。
那现实生产中,这三种划分方式到底哪种最常用?
结论很明确:工程实现里最常见的是 Hoare 法(及其变体),教学和面试里最常见的是 Lomuto 法,而国内教材和考试里最常见的是挖坑法。
- 工业级标准库 :C 的
qsort、C++ 的std::sort(introsort 的划分部分)、Java 早期的Arrays.sort等,划分逻辑基本都源自 Hoare 法 。原因是它一趟划分的交换次数最少、常数因子最小,在大数组上性能最好。当然,工业实现会在 Hoare 法基础上加三数取中选基准 、三路划分处理重复元素等优化,但骨架仍是 Hoare 那一套。 - 教学与面试手写 :Lomuto 法 出现频率最高。它只有一个
for循环加一个边界i,逻辑线性、几乎不会写错,非常适合白板手写和讲解"划分"这个概念。《算法导论》用的也是它。 - 国内教材与考研 :挖坑法是主流,408 真题和大多数国内数据结构教材都采用这种写法,因为它用赋值代替交换、返回枢轴最终下标,递归边界写起来最直观。
所以可以这样记:性能看 Hoare,教学看 Lomuto,考试看挖坑。三者平均复杂度都是 O(n log n),差别主要在常数因子和边界处理的难易程度上------理解了这一点,就不会再纠结"到底该背哪一种"了。
4.5 复杂度分析
| 指标 | 最好情况 | 平均情况 | 最坏情况 |
|---|---|---|---|
| 时间复杂度 | O(n log n) | O(n log n) | O(n²) |
| 空间复杂度 | O(log n) | O(log n) | O(n) |
| 稳定性 | 不稳定 | 不稳定 | 不稳定 |
- 最好/平均情况:每次划分都能将序列大致均分,递归深度为 log n,每层比较 n 次,总复杂度 O(n log n)。
- 最坏情况:序列已经有序或逆序,每次划分只减少一个元素,递归深度为 n,退化为 O(n²)。
- 稳定性:快速排序是不稳定的,因为基准元素的交换可能改变相等元素的相对顺序。
5. 生产环境中到底哪种排序最常见、最重要?
把冒泡排序和快速排序放在一起对比,差距一目了然:
| 对比维度 | 冒泡排序 | 快速排序 |
|---|---|---|
| 思想 | 相邻元素两两比较交换 | 分治 + 基准划分 |
| 平均时间复杂度 | O(n²) | O(n log n) |
| 最好时间复杂度 | O(n) | O(n log n) |
| 最坏时间复杂度 | O(n²) | O(n²) |
| 空间复杂度 | O(1) | O(log n) ~ O(n) |
| 稳定性 | 稳定 | 不稳定 |
| 适用场景 | 小规模数据、教学演示 | 大规模数据、工程实践 |
为什么生产环境中快速排序最常见、最重要?
- 量级上的碾压:数据量一旦上万,O(n²) 和 O(n log n) 就是天壤之别。10 万条数据,冒泡排序大约要做 10¹⁰ 次比较,而快速排序只需约 1.7×10⁶ 次,差了四个数量级,这不是靠优化常数因子能追回来的。
- 原地排序、缓存友好:快速排序不需要额外的大块内存,划分过程是顺序扫描数组,对 CPU 缓存非常友好,实际运行速度往往比同为 O(n log n) 的归并排序还快。
- 工程上可优化空间大:随机选基准、三数取中、三路划分、小区间切换插入排序、递归深度过大时切换堆排序(introsort),这些手段能把最坏情况的风险压到极低,让快排在大规模数据上既快又稳。
- 工业级标准库的默认选择 :C 的
qsort、C++ 的std::sort、Java 对基本类型的Arrays.sort(双轴快排)等,核心都是快速排序或其变体。标准库的选择本身就是最有力的背书。
为什么冒泡排序在生产环境中基本不用?
- 效率太低:平均和最坏都是 O(n²),数据量稍大就慢到无法接受,而且它的 O(n) 最好情况几乎只在"完全有序"时才出现,实际意义有限。
- 适应性差:对"接近有序"的数据,冒泡排序依然要跑很多趟,而插入排序在这类数据上几乎线性,工程中数据往往不是完全乱序的,冒泡的劣势更明显。
- 没有不可替代性:它稳定、原地、代码简单,但这些优点插入排序同样具备,而且更快。所以冒泡排序更多是作为教学工具存在,帮助理解"交换"和"逆序对"的思想,而不是生产环境里的排序方案。
一句话总结:快速排序是工程实践中的主力,冒泡排序是理解交换思想的入门教材。学冒泡不是为了用它,而是为了看懂快排"聪明"在哪里。
6. 总结
交换类排序通过不断交换逆序元素来达到有序的目的:
- 冒泡排序:实现简单、稳定,但效率较低,适合小规模数据或入门学习。
- 快速排序:平均性能优异,是工程中最常用的排序算法之一,但需要注意最坏情况下的退化问题(可通过随机选择基准来优化)。
在实际开发中,Java 的 Arrays.sort() 对基本类型使用双轴快速排序,对对象类型使用 TimSort,这充分说明了快速排序在工程中的地位。
下一篇文章,我们可以继续学习归并排序和基数排序,进一步丰富排序算法的知识体系。如果你有任何疑问,欢迎在评论区留言讨论!