C语言数据结构排序算法详解(下):冒泡排序、快速排序、归并排序和计数排序

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 ✨ 数据即知识,压缩即智能
目录
- C语言数据结构排序算法详解(下):冒泡排序、快速排序、归并排序和计数排序
-
- 前言
- 一、交换排序是什么?
- 二、冒泡排序:相邻元素两两比较
-
- [1. 冒泡排序过程演示](#1. 冒泡排序过程演示)
- 三、冒泡排序代码实现
-
- 代码解读
- [exchange 标记的作用](#exchange 标记的作用)
- 四、冒泡排序的复杂度与稳定性
-
- [1. 时间复杂度](#1. 时间复杂度)
- [2. 空间复杂度](#2. 空间复杂度)
- [3. 稳定性](#3. 稳定性)
- 五、快速排序:分治思想的经典代表
- 六、快速排序递归框架
- [七、Hoare 版本 partition](#七、Hoare 版本 partition)
-
- [Hoare 版本代码](#Hoare 版本代码)
- 为什么右边要先走?
- [八、挖坑法 partition](#八、挖坑法 partition)
- [九、前后指针法 partition](#九、前后指针法 partition)
- 十、快速排序优化
-
- [1. 三数取中优化](#1. 三数取中优化)
- [2. 小区间使用插入排序](#2. 小区间使用插入排序)
- 十一、快速排序非递归实现
- 十二、快速排序复杂度与稳定性
-
- [1. 时间复杂度](#1. 时间复杂度)
- [2. 空间复杂度](#2. 空间复杂度)
- [3. 稳定性](#3. 稳定性)
- 十三、归并排序:先分解,再合并
- 十四、归并排序代码实现
- 十五、归并排序复杂度与稳定性
-
- [1. 时间复杂度](#1. 时间复杂度)
- [2. 空间复杂度](#2. 空间复杂度)
- [3. 稳定性](#3. 稳定性)
- [4. 归并排序适合什么场景?](#4. 归并排序适合什么场景?)
- 十六、计数排序:不靠比较也能排序
- 十七、计数排序代码实现
-
- [为什么要减去 min?](#为什么要减去 min?)
- 十八、计数排序复杂度与适用场景
-
- [1. 时间复杂度](#1. 时间复杂度)
- [2. 空间复杂度](#2. 空间复杂度)
- [3. 计数排序是否稳定?](#3. 计数排序是否稳定?)
- [4. 计数排序适合什么场景?](#4. 计数排序适合什么场景?)
- 十九、排序算法复杂度与稳定性总表
- 二十、实际开发中如何选择排序算法?
-
- [1. 数据量很小](#1. 数据量很小)
- [2. 数据基本有序](#2. 数据基本有序)
- [3. 通用内存排序](#3. 通用内存排序)
- [4. 要求稳定性](#4. 要求稳定性)
- [5. 要求 O(1) 额外空间](#5. 要求 O(1) 额外空间)
- [6. 数据范围集中](#6. 数据范围集中)
- [7. 外部排序](#7. 外部排序)
- 二十一、排序算法常见易错点
-
- [1. 把稳定性理解成"排序结果是否正确"](#1. 把稳定性理解成“排序结果是否正确”)
- [2. 认为快速排序一定是 O(NlogN)](#2. 认为快速排序一定是 O(NlogN))
- [3. 忘记归并排序需要额外空间](#3. 忘记归并排序需要额外空间)
- [4. 误以为计数排序是通用排序](#4. 误以为计数排序是通用排序)
- [5. 堆排序升序建错堆](#5. 堆排序升序建错堆)
- [6. 快速排序 partition 边界写错](#6. 快速排序 partition 边界写错)
- [7. 冒泡排序内层循环范围写错](#7. 冒泡排序内层循环范围写错)
- 全文总结
-
- [1. 冒泡排序](#1. 冒泡排序)
- [2. 快速排序](#2. 快速排序)
- [3. 归并排序](#3. 归并排序)
- [4. 计数排序](#4. 计数排序)
前言
上一篇我们已经讲了排序算法中的几类基础算法:
- 直接插入排序
- 希尔排序
- 直接选择排序
- 堆排序
这一篇继续讲剩下几种非常重要的排序算法:
- 冒泡排序
- 快速排序
- 归并排序
- 计数排序
其中,快速排序 和 归并排序 是这一篇的重点。
快速排序和归并排序都体现了一个非常重要的算法思想:
分治思想:把一个大问题拆成若干个小问题,分别解决后再组合结果。
不过它们的思路并不一样。
快速排序是:
先选基准值,把数组划分成左右两个区间,再递归处理左右区间。
归并排序是:
先把数组不断拆小,再把有序小区间逐步合并成大区间。
这一篇会从思想、代码实现、复杂度和稳定性几个角度系统讲清楚。
一、交换排序是什么?
交换排序的核心思想是:
根据两个元素关键字的比较结果,交换它们在序列中的位置。
也就是说,交换排序不是直接"插入",也不是单纯"选择最值",而是通过元素之间的位置交换,让数据逐渐变得有序。
典型的交换排序有两种:
| 排序算法 | 核心特点 |
|---|---|
| 冒泡排序 | 相邻元素两两比较,大的逐渐往后移动 |
| 快速排序 | 选基准值,将数组划分成左右两个部分 |
冒泡排序很好理解,但是效率一般。
快速排序理解起来稍微复杂一些,但综合性能非常优秀,是排序算法中非常重要的一种。
二、冒泡排序:相邻元素两两比较
冒泡排序是最容易理解的排序之一。
它的规则非常简单:
从前往后比较相邻的两个元素,如果前一个比后一个大,就交换它们。
这样一轮下来,最大的元素会被交换到数组最后。
这个过程就像气泡从水底一点点冒到水面,所以叫"冒泡排序"。
1. 冒泡排序过程演示
假设数组为:
| 下标 | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 数据 | 5 | 3 | 8 | 1 | 6 |
第一轮比较:
| 比较元素 | 是否交换 | 结果 |
|---|---|---|
| 5 和 3 | 交换 | 3 5 8 1 6 |
| 5 和 8 | 不交换 | 3 5 8 1 6 |
| 8 和 1 | 交换 | 3 5 1 8 6 |
| 8 和 6 | 交换 | 3 5 1 6 8 |
第一轮结束后,最大值 8 已经到了最后。
第二轮只需要继续处理前 4 个元素:
| 当前数组 | 3 | 5 | 1 | 6 | 8 |
|---|
继续比较后,第二大的元素会来到倒数第二个位置。
不断重复,最终整个数组有序。
三、冒泡排序代码实现
c
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int exchange = 0;
for (int j = 0; j < n - 1 - i; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
exchange = 1;
}
}
if (exchange == 0)
{
break;
}
}
}
代码解读
外层循环控制排序轮数。
c
for (int i = 0; i < n - 1; i++)
最多需要 n - 1 轮。
内层循环负责相邻元素比较。
c
for (int j = 0; j < n - 1 - i; j++)
为什么是 n - 1 - i?
因为每一轮结束后,数组末尾都会多一个已经排好的元素。
第 1 轮结束后,最后 1 个元素有序。
第 2 轮结束后,最后 2 个元素有序。
所以后面已经有序的部分不需要再比较。
exchange 标记的作用
c
int exchange = 0;
如果某一轮没有发生任何交换,说明数组已经有序。
这时候可以提前结束排序。
比如数组本来就是:
| 数据 | 1 | 2 | 3 | 4 | 5 |
|---|
第一轮比较时不会发生任何交换。
如果没有 exchange 优化,冒泡排序还会继续进行很多无意义的比较。可以一定程度上优化冒泡排序的速度。
有了 exchange,就可以直接退出。
四、冒泡排序的复杂度与稳定性
1. 时间复杂度
最坏情况是逆序:
| 数据 | 5 | 4 | 3 | 2 | 1 |
|---|
这种情况下,每一轮都要进行大量交换。
时间复杂度是:
| 情况 | 时间复杂度 |
|---|---|
| 最好情况 | O(N) |
| 平均情况 | O(N²) |
| 最坏情况 | O(N²) |
最好情况成立的前提是使用了 exchange 优化。
2. 空间复杂度
冒泡排序只使用少量临时变量。
空间复杂度是:
| 指标 | 结果 |
|---|---|
| 空间复杂度 | O(1) |
3. 稳定性
冒泡排序是稳定的。
原因在于交换条件是:
c
if (a[j] > a[j + 1])
只有前一个元素严格大于后一个元素时才交换。
如果两个元素相等,不会交换。
所以相同元素的相对顺序不会改变。
五、快速排序:分治思想的经典代表
快速排序是非常经典的排序算法。
它的核心思想是:
选一个基准值 key,把数组划分成两部分:左边都比 key 小,右边都比 key 大,然后递归处理左右两边。
比如数组:
| 数据 | 6 | 1 | 2 | 7 | 9 | 3 | 4 | 5 | 10 | 8 |
|---|
如果选择 6 作为基准值,那么一趟划分之后,希望得到类似结构:
| 左区间 | key | 右区间 |
|---|---|---|
| 小于 6 的元素 | 6 | 大于 6 的元素 |
也就是:
6左边都比它小;6右边都比它大;6来到了最终排序后应该在的位置。
接下来只需要递归排序左区间和右区间。

快速排序为什么像二叉树前序遍历?
快速排序的递归过程可以理解为:
- 先处理当前区间,确定基准值位置;
- 再递归处理左区间;
- 再递归处理右区间。
这和二叉树前序遍历很像:
- 根
- 左子树
- 右子树
所以写快速排序递归框架时,可以类比二叉树前序遍历。
六、快速排序递归框架
下面使用左闭右闭区间 [left, right]。
c
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int div = PartSort(a, left, right);
QuickSort(a, left, div - 1);
QuickSort(a, div + 1, right);
}
这里最核心的是:
c
int div = PartSort(a, left, right);
PartSort 的作用是:
做一趟划分,让基准值来到最终位置,并返回这个位置。
常见划分方法有三种:
- Hoare 版本
- 挖坑法
- 前后指针法
下面逐个讲。
七、Hoare 版本 partition
Hoare 版本是快速排序中非常经典的一种划分方式。
它的基本步骤是:
- 选最左边元素作为 key;
- 右指针从右往左找比 key 小的元素;
- 左指针从左往右找比 key 大的元素;
- 找到后交换左右指针对应元素;
- 左右指针相遇后,将 key 放到相遇位置。
Hoare 版本代码
c
int PartSort1(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
return left;
}
为什么右边要先走?
如果选择最左边元素作为 key,通常让右边先走,反之亦然。
这样做的目的是:
保证最后相遇位置上的值小于等于 key。
最后再把 key 和相遇位置交换,key 就能来到合理位置。
如果左边先走,在某些情况下可能会导致相遇位置不符合预期。
所以 Hoare 版本中,"左 key,右先走"是一个重要细节。
八、挖坑法 partition
挖坑法比 Hoare 版本更形象。
可以这样理解:
先把 key 拿出来,原位置形成一个坑,然后不断找元素填坑。
挖坑法过程
假设第一个位置是 key。
第一步:
- 保存 key;
- 第一个位置形成坑。
第二步:
- 右边找小值,填到左边的坑;
- 右边原位置形成新坑。
第三步:
- 左边找大值,填到右边的坑;
- 左边原位置形成新坑。
不断重复,直到左右指针相遇。
最后把 key 填到最后的坑里。
挖坑法代码
c
int PartSort2(int* a, int left, int right)
{
int key = a[left];
int hole = 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;
}
挖坑法的优点
挖坑法比起前面那种更容易理解。
因为它不是频繁交换,而是围绕一个"坑"移动数据。
可以把整个过程理解成:
- 坑在左边,就从右边找小值填坑;
- 坑在右边,就从左边找大值填坑;
- 最后 key 回到坑里。
九、前后指针法 partition
前后指针法使用两个指针:
| 指针 | 作用 |
|---|---|
prev |
小于 key 区间的边界 |
cur |
当前扫描位置 |
它的思想是:
cur一路向后扫描,遇到比 key 小的元素,就把它放到小区间后面。
前后指针法代码
c
int PartSort3(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
前后指针法的区间理解
在扫描过程中,可以把数组分成三段:
| 区间 | 含义 |
|---|---|
[left + 1, prev] |
小于 key 的区间 |
[prev + 1, cur - 1] |
大于等于 key 的区间 |
[cur, right] |
还未扫描的区间 |
当 cur 遇到小于 key 的元素时,就扩大前面的小值区间。
最后把 key 交换到 prev 位置。
十、快速排序优化
快速排序平均很快,但它有退化风险。
如果每次选到的 key 都是当前区间的最大值或最小值,那么划分会非常不均衡。
比如数组已经有序:
| 数据 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|
如果每次都选择最左边作为 key,那么:
- 左区间为空;
- 右区间几乎还是原数组规模;
- 递归深度接近 N。
这时快速排序会退化成 O(N²)。

1. 三数取中优化
三数取中就是从三个位置中选择中间大小的元素作为 key:
- left
- mid
- right
这样可以减少选到最大值或最小值的概率。
c
int GetMidIndex(int* a, int left, int right)
{
int mid = left + (right - left) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
else
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
}
使用时,把中间值交换到最左边:
c
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
然后继续使用前面的 partition 逻辑。
2. 小区间使用插入排序
当区间很小时,继续递归的开销可能比直接插入排序更大。
因此可以设置一个阈值。
比如区间长度小于 10 时,直接使用插入排序。
c
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
return;
}
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
int div = PartSort1(a, left, right);
QuickSort(a, left, div - 1);
QuickSort(a, div + 1, right);
}
这种优化在很多实际排序实现中都有类似思想。
十一、快速排序非递归实现
快速排序递归的本质是:
保存还没有处理的区间。
递归版本用的是系统调用栈。
非递归版本可以自己创建一个栈,把区间左右边界压进去。
非递归实现思路
步骤如下:
- 将整个数组区间入栈;
- 栈不为空时,取出一个区间;
- 对这个区间进行 partition;
- 得到基准位置
div; - 将左区间和右区间继续入栈;
- 重复直到栈为空。
非递归代码
c
void QuickSortNonR(int* a, int left, int right)
{
Stack st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (!StackEmpty(&st))
{
int end = StackTop(&st);
StackPop(&st);
int begin = StackTop(&st);
StackPop(&st);
if (begin >= end)
{
continue;
}
int div = PartSort1(a, begin, end);
if (div + 1 < end)
{
StackPush(&st, div + 1);
StackPush(&st, end);
}
if (begin < div - 1)
{
StackPush(&st, begin);
StackPush(&st, div - 1);
}
}
StackDestroy(&st);
}
这里的 Stack 可以复用前面数据结构章节中实现过的动态栈。
栈里保存的是区间边界,而不是数组元素本身。
十二、快速排序复杂度与稳定性
1. 时间复杂度
快速排序的平均时间复杂度是:
| 情况 | 时间复杂度 |
|---|---|
| 最好情况 | O(NlogN) |
| 平均情况 | O(NlogN) |
| 最坏情况 | O(N²) |
当每次划分都比较均衡时,递归层数约为 logN,每层整体处理 N 个数据,所以是 O(NlogN)。
当每次划分都极度不均衡时,就会退化成 O(N²)。
2. 空间复杂度
快速排序空间主要来自递归栈。
| 情况 | 空间复杂度 |
|---|---|
| 平均情况 | O(logN) |
| 最坏情况 | O(N) |
基础学习中通常记平均空间复杂度为:
- O(logN)
3. 稳定性
快速排序是不稳定的。
原因是 partition 过程中会交换元素,相等元素的相对顺序可能被打乱。
十三、归并排序:先分解,再合并
归并排序也是分治思想的典型应用。
它的核心思想是:
先把数组不断拆分,拆到每个小区间只有一个元素,再把这些有序小区间两两合并。
一个元素天然是有序的。
所以归并排序真正重要的不是"分",而是"合"。

归并排序过程演示
假设数组为:
| 数据 | 10 | 6 | 7 | 1 | 3 | 9 | 4 | 2 |
|---|
先拆分:
| 第一次拆分 | 左半部分 | 右半部分 |
|---|---|---|
| 结果 | 10 6 7 1 | 3 9 4 2 |
继续拆:
| 区间 | 拆分结果 |
|---|---|
| 10 6 7 1 | 10 6 和 7 1 |
| 3 9 4 2 | 3 9 和 4 2 |
继续拆到单个元素:
| 单元素区间 |
|---|
| 10,6,7,1,3,9,4,2 |
然后开始合并:
| 合并前 | 合并后 |
|---|---|
| 10 和 6 | 6 10 |
| 7 和 1 | 1 7 |
| 3 和 9 | 3 9 |
| 4 和 2 | 2 4 |
继续合并:
| 合并前 | 合并后 |
|---|---|
| 6 10 和 1 7 | 1 6 7 10 |
| 3 9 和 2 4 | 2 3 4 9 |
最后合并:
| 合并前 | 合并后 |
|---|---|
| 1 6 7 10 和 2 3 4 9 | 1 2 3 4 6 7 9 10 |

十四、归并排序代码实现
c
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
{
return;
}
int mid = left + (right - left) / 2;
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int i = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
for (int j = left; j <= right; j++)
{
a[j] = tmp[j];
}
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
合并过程怎么理解?
归并排序中最核心的是合并两个有序区间。
例如:
| 左区间 | 1 | 6 | 7 | 10 |
|---|---|---|---|---|
| 右区间 | 2 | 3 | 4 | 9 |
每次比较两个区间最前面的元素。
谁小,就先放进临时数组。
这个过程类似两个队伍按身高排好后,再合并成一个总队伍。
为什么归并排序稳定?
关键在这句:
c
if (a[begin1] <= a[begin2])
当左右区间元素相等时,优先取左区间元素。
因为左区间元素在原数组中本来就在右区间元素前面。
所以相同元素的相对顺序不会改变。
这就是归并排序稳定的原因。
十五、归并排序复杂度与稳定性
1. 时间复杂度
归并排序每一层都要处理所有元素。
每一层处理量是 N。
拆分层数大约是 logN。
所以时间复杂度是:
| 情况 | 时间复杂度 |
|---|---|
| 最好情况 | O(NlogN) |
| 平均情况 | O(NlogN) |
| 最坏情况 | O(NlogN) |
归并排序的时间复杂度非常稳定。
2. 空间复杂度
归并排序需要额外临时数组。
空间复杂度是:
| 指标 | 结果 |
|---|---|
| 空间复杂度 | O(N) |
虽然递归栈也会占用 O(logN) 空间,但主导空间还是临时数组 O(N)。
3. 稳定性
归并排序是稳定的。
它是几种常见排序中非常重要的稳定排序算法。
4. 归并排序适合什么场景?
归并排序适合:
- 要求稳定排序
- 数据规模较大
- 需要稳定的 O(NlogN) 时间复杂度
- 链表排序
- 外部排序
归并排序的缺点是需要额外空间。
但是在外部排序中,归并思想非常重要。
因为外部排序经常需要把磁盘上的多个有序文件段合并成一个大有序文件。
十六、计数排序:不靠比较也能排序
前面讲的排序基本都属于比较排序。
也就是说,它们都要比较两个元素大小。
计数排序不一样。
它的核心思想是:
统计每个值出现了多少次,然后按次数把数据回收到原数组中。
比如数组:
| 数据 | 3 | 1 | 2 | 3 | 2 | 1 | 3 |
|---|
统计次数:
| 数值 | 1 | 2 | 3 |
|---|---|---|---|
| 出现次数 | 2 | 2 | 3 |
然后按次数回收:
| 排序结果 | 1 | 1 | 2 | 2 | 3 | 3 | 3 |
|---|
这就是计数排序。

十七、计数排序代码实现
下面这个版本支持包含负数的数据。
核心做法是:
先找最大值和最小值,再用
value - min映射到计数数组下标。
c
void CountSort(int* a, int n)
{
int min = a[0];
int max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc fail");
exit(1);
}
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int index = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[index++] = i + min;
}
}
free(count);
}
为什么要减去 min?
假设数据范围是:
| 最小值 | 最大值 |
|---|---|
| 100 | 105 |
如果直接用原值做下标,就要开到 105。
但实际上只需要 6 个位置。
映射关系如下:
| 原值 | 映射下标 |
|---|---|
| 100 | 0 |
| 101 | 1 |
| 102 | 2 |
| 103 | 3 |
| 104 | 4 |
| 105 | 5 |
所以统计时:
c
count[a[i] - min]++;
回收时:
c
a[index++] = i + min;
这样可以节省空间。
十八、计数排序复杂度与适用场景
1. 时间复杂度
计数排序主要有三步:
| 步骤 | 复杂度 |
|---|---|
| 找最大值和最小值 | O(N) |
| 统计元素出现次数 | O(N) |
| 按范围回收数据 | O(range) |
所以整体时间复杂度是:
- O(N + range)
也可以写成:
- O(MAX(N, range))
2. 空间复杂度
计数排序需要额外计数数组。
空间复杂度是:
- O(range)
3. 计数排序是否稳定?
上面这个基础版本主要用于整数数组排序,没有体现稳定性。
如果要实现稳定计数排序,通常需要:
- 统计次数;
- 计算前缀和;
- 从后往前遍历原数组;
- 把元素放入结果数组中的正确位置。
这样可以保证相同元素的相对顺序不变。
4. 计数排序适合什么场景?
计数排序适合:
| 场景 | 是否适合 |
|---|---|
| 整数排序 | 适合 |
| 数据范围集中 | 适合 |
| 年龄、分数、编号排序 | 适合 |
| 浮点数排序 | 不适合 |
| 字符串排序 | 不适合 |
| 范围特别大但数据很少 | 不适合 |
比如:
| 数据量 | 数据范围 | 是否适合 |
|---|---|---|
| 100000 | 0 到 100 | 很适合 |
| 100000 | 0 到 1000000000 | 不适合 |
| 100 | -50 到 50 | 适合 |
计数排序很快,但不是通用排序。
十九、排序算法复杂度与稳定性总表

二十、实际开发中如何选择排序算法?
学习排序算法时,不要只看:
哪个排序最快?
更准确的问题应该是:
当前数据有什么特点?我需要什么性质?

1. 数据量很小
可以考虑:
- 插入排序
- 选择排序
- 冒泡排序
其中,插入排序通常更实用。
尤其是数据基本有序时,插入排序表现很好。
2. 数据基本有序
可以考虑:
- 直接插入排序
- 冒泡排序加提前结束优化
因为它们在接近有序时可以接近 O(N)。
3. 通用内存排序
可以考虑:
- 快速排序
- 堆排序
- 归并排序
- 混合排序策略
快速排序平均性能优秀,实际使用非常广。
4. 要求稳定性
可以考虑:
- 插入排序
- 冒泡排序
- 归并排序
- 稳定版本计数排序
如果数据规模较大,并且要求稳定性,归并排序是很重要的选择。
5. 要求 O(1) 额外空间
可以考虑:
- 堆排序
- 插入排序
- 选择排序
- 冒泡排序
如果数据规模较大,堆排序更值得考虑。
6. 数据范围集中
可以考虑:
- 计数排序
比如年龄、成绩、编号等小范围整数。
7. 外部排序
如果数据太大,无法一次性放入内存,可以考虑归并思想。
一般流程是:
- 分批读取数据;
- 每批在内存中排序;
- 写成多个有序文件段;
- 多路归并生成最终有序文件。
这就是外部排序中非常经典的思路。
二十一、排序算法常见易错点
1. 把稳定性理解成"排序结果是否正确"
稳定性不是说排序对不对。
稳定性关注的是:
相同关键字的记录,排序前后的相对顺序是否保持不变。
2. 认为快速排序一定是 O(NlogN)
快速排序平均是 O(NlogN),但最坏情况会退化成 O(N²)。
所以实际实现时常用:
- 三数取中
- 随机选 key
- 小区间插入排序优化
来降低退化风险。
3. 忘记归并排序需要额外空间
归并排序通常需要临时数组。
空间复杂度是 O(N)。
4. 误以为计数排序是通用排序
计数排序只适合范围集中的整数。
如果数据范围巨大,计数数组会非常浪费空间。
5. 堆排序升序建错堆
升序排序通常建大堆。
因为每次要把最大值放到当前未排序区间末尾。
降序排序通常建小堆。
6. 快速排序 partition 边界写错
快排最容易错的地方包括:
- 左闭右闭还是左闭右开没有统一;
left、right移动顺序写反;- key 的位置没有保存;
- 递归区间边界写错;
- 相等元素处理不当。
建议初学时固定一种区间写法。
比如全程使用左闭右闭 [left, right]。
7. 冒泡排序内层循环范围写错
第 i 轮冒泡后,末尾已经有 i 个元素有序。
所以内层循环一般写:
c
for (int j = 0; j < n - 1 - i; j++)
不要每一轮都比较到最后。
全文总结
排序是数据结构中非常核心的一章。
它不仅考察代码实现,更考察算法思想。
1. 冒泡排序
核心思想:
相邻元素两两比较,把较大的元素逐步冒到后面。
特点:
- 简单易懂;
- 稳定;
- 效率较低;
- 适合教学和小规模数据。
2. 快速排序
核心思想:
选 key,分左右,再递归排序。
特点:
- 平均性能优秀;
- 是非常重要的分治排序算法;
- 不稳定;
- 最坏情况可能退化为 O(N²)。
3. 归并排序
核心思想:
先分解,再合并两个有序区间。
特点:
- 时间复杂度稳定为 O(NlogN);
- 稳定;
- 需要 O(N) 额外空间;
- 适合外部排序和稳定排序场景。
4. 计数排序
核心思想:
先统计每个值出现的次数,再按次数回收数据。
特点:
- 不是比较排序;
- 数据范围集中时效率很高;
- 需要 O(range) 空间;
- 不适合范围巨大或类型复杂的数据。
学习排序时,重点不是"背八个模板",而是理解:
不同数据特征、不同空间限制、不同稳定性要求,会决定我们选择不同的排序算法。