一直觉得“排序”是算法入门路上最容易被低估的一块。很多人觉得排序不就是把数字从小到大排一遍吗,背个快排代码就完事了。但真到了实际开发里,你会发现排序的形态千奇百怪:有给结构体按某个字段排的,有在数据库里排的,有在前端表头点击排的,还有像拓扑排序这种根本不是比大小的“排序”。如果你准备认真学算法,或者工作中经常被各种排序需求搞得头疼,《初识基本排序》这个主题很适合你踏踏实实看一遍。
这篇内容写给第一次正经接触排序的人,也写给背过不少排序但总觉得“差点意思”的人。我会从排序到底在解决什么问题讲起,把选择、冒泡、插入这三种入门排序掰开揉碎,然后带你看看快排、归并、堆排序这些进阶方案,最后落到工程里的排序实践上。你会搞清楚为什么有的排序稳定、有的不稳定,为什么数据量大到一定程度算法差距是几百倍,也会明白语言自带的排序方法到底帮你做了什么。
1. 排序到底在解决什么问题
1.1 排序不是“把数字排整齐”那么简单
先做一个思想实验。你去图书馆找一本书,如果书架是乱的,你得一本一本翻,运气不好翻到最后才找到;如果书架按编号排好了,你直接按区间缩小范围,几秒就能锁定位置。排序做的就是这件事:它让无序的数据变得有规律,从而让后续的查找、去重、统计、合并都变得高效得多。
所以排序的真正价值不在于“排序本身”,而在于排序之后那些操作能提速。比如经典问题“从一亿个数里找最大的100个”,你可以全部排序后取前100个,但这其实浪费了太多计算;更聪明的做法是用堆结构维护一个大小为100的小顶堆。你看,想理解堆排序的场景,前提就是你能把排序的价值模型建立起来。
初学的时候,很多人把排序当成“把数组变有序”这个孤立动作,于是学完就忘。我建议换一种视角:排序是一系列问题预处理的第一步。数组有序之后,二分查找才能用;重复元素会聚在一起,去重就能一趟完成;两个有序数组合并,双指针就能做到线性复杂度。理解了这层,你才会真正重视排序。
1.2 稳定性:排序里最容易被忽略的两个字
我第一次学排序时,压根没注意“稳定性”这三个字。直到后来做项目,遇到一个需求:表格里先按姓名排序,再按年龄排序,但希望年龄相同的人之间保留先前的姓名顺序。如果排序算法是不稳定的,第二次排序会把第一次的顺序打乱,用户看到的就是同一批年龄的数据顺序来回乱跳。
稳定排序的定义很简单:两个键值相等的元素,在排序前后的相对位置不变。冒泡排序、插入排序、归并排序都是稳定的;选择排序、快速排序、堆排序通常不稳定。这个性质在工程里非常重要,因为真实数据往往“按多个字段先后排序”,稳定排序可以让你用“串联排序”的方式实现多级排序,而不需要写复杂到爆炸的复合比较器。
另外顺带提一个概念:如果数据量大到内存放不下,需要用外存进行排序,这就涉及到“外排序”。“内排序”是全部数据都能装进内存时的排序。归并排序是外排序的基础思想,因为它的分治结构天然适合处理分块数据。这些细节初学不必深究,但知道有这个方向,未来遇到大数据量时不会被吓到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初学者的第一组排序:选择、冒泡、插入
2.1 选择排序:最直觉的做法
选择排序的思路可以用一句话描述:每一轮从未排序部分找出最小的元素,放到已排序部分的末尾。就好像你手里一把乱牌,每次抽出最小的一张放到桌上,抽完就排好了。
用C++写出来是这样:
cpp复制void selectionSort(vector<int>& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; i++) {
int minIdx = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
swap(arr[i], arr[minIdx]);
}
}
这个算法的时间复杂度是O(n²):无论原始数据是有序还是逆序,内层循环都要完整扫描未排序部分,所以它不会因为数据基本有序而变快。选择排序的交换次数是O(n),在“交换成本远高于比较成本”的场景下有优势,但总体还是属于教学级排序。
2.2 冒泡排序:相邻交换的经典范式
冒泡排序的思路是不断比较相邻两个元素,如果顺序反了就交换,每轮结束后最大的元素会“冒泡”到末尾。我第一次写的时候觉得这名字特别传神,每轮遍历就像一个气泡从底部升到顶部。
写个基础版,再加个常见优化:
cpp复制void bubbleSort(vector<int>& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; i++) {
bool swapped = false;
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
swapped = true;
}
}
if (!swapped) break; // 本轮没有交换说明已经有序
}
}
这个优化很关键:如果某一轮遍历过程中没有发生任何交换,说明数组已经有序,直接退出即可。于是冒泡排序在最好情况下的时间复杂度降到O(n),最坏和平均仍然是O(n²)。冒泡排序也是稳定排序,因为只有相邻元素反序时才交换,相同元素不会相互跨越。
2.3 插入排序:打牌时的天然思维
如果你打过扑克牌,那你已经会插入排序了。抓牌的时候,你拿起一张新牌,从右往左比较,找到合适的位置插进去,手里的牌始终保持有序。
插入排序的代码非常短,但它是很多人第一次觉得“排序原来可以这样灵活”的转折点:
cpp复制void insertionSort(vector<int>& arr) {
int n = arr.size();
for (int i = 1; i < n; i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
这段代码的细节值得细品:我们先把当前元素key拿出来,然后依次把比它大的元素往后移,最后把key放进去。这个过程是通过“移动”而不是“交换”来完成的,所以赋值次数虽然多,但每次赋值都是廉价的,常数因子比选择排序和冒泡排序都小。
插入排序真正的杀手锏是:对几乎有序的数据,它的性能接近O(n)。很多现代排序算法会在数据量小于某个阈值时,改用插入排序来收尾,原因就在这。另外,希尔排序就是插入排序的改进版,通过间隔分组做插入排序,让数据先“大致有序”,再整体插入排序,有兴趣的话可以沿这个方向继续深入。
2.4 三个入门排序怎么选
把三个排序放在一起对比,你会发现它们分别代表了三种不同的思路:选择排序是“挑最小值放前面”,冒泡排序是“相邻比较交换”,插入排序是“保持前缀有序再插入新元素”。思想上差异很大,但代码量都很小。
| 排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
我给初学者的建议是:先别急着背代码,把这三种排序按流程在纸上画几遍,画到你能清楚说出“每轮结束后哪些元素已经就位”为止。然后再动手写代码,写完用随机数组验证。
曾经有初学者问我,这三种排序在实际项目中还有用吗?我的回答是:插入排序有用,其他两个基本是教学工具。很多高级排序在数据量小的时候都会切到插入排序,而选择排序和冒泡排序更多是为了让你理解“比较”“交换”“迭代”这些基础操作。但别觉得白学了——没有它们打底,你直接啃快排和归并,会很吃力。
3. 进阶排序:快排、归并、堆排序
3.1 快速排序:分治思想的代表作
快速排序是应用最广泛的高级排序之一,它的核心是“选一个基准值,把数组分成小于等于和大于等于两部分,再递归处理两部分”。这个过程叫分区操作。
每次分区之后,基准值已经落在它最终应该在的位置上。然后对左右两侧递归执行同样操作。平均时间复杂度是O(n log n),而且常数因子小,所以在大多数场景下它比同类高级排序跑得快。
但快排有个要注意的坑:当输入数组已经有序,而基准值总是取第一个或最后一个元素时,分区会极度不平衡,递归深度退化到O(n),时间复杂度变成O(n²),大数组下栈都可能爆掉。工程上常见的解决方案是“三数取中”或随机选基准值。下面是一个加了随机基准的Python实现:
python复制import random
def quick_sort(arr, left, right):
if left >= right:
return
pivot_idx = random.randint(left, right)
arr[pivot_idx], arr[right] = arr[right], arr[pivot_idx]
pivot = arr[right]
i = left
for j in range(left, right):
if arr[j] < pivot:
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[right] = arr[right], arr[i]
quick_sort(arr, left, i - 1)
quick_sort(arr, i + 1, right)
随机基准的作用是消除“输入有序导致退化”的极端情况。虽然理论上仍有可能选到很差的位置,但概率极低。实际工程里C++的std::sort用的是introsort:先快排,如果递归深度超过某个阈值就切换到堆排序,数据量小时切到插入排序,综合了三者的优点。
3.2 归并排序:稳定性的“最优解”
归并排序的思路是把数组不断对半分,分成单个元素后再两两合并有序序列。它始终是稳定排序,这一点在需要保持相对顺序的场景里非常值钱。
代价是需要O(n)的额外空间。它不像快排那样原地排序,但“稳定”和“可预测”这两点让它在很多场景里不可替代:比如对链表排序、外部排序(数据量超过内存时)、以及某些要求排序稳定的数据库内部实现中。
我实际跑过10万元素的随机数据,归并排序虽然比快排慢一些,但差别并没有想象中那么夸张。反倒是递归带来的空间占用和函数调用开销,在小数据量下会比较明显。所以在绝大多数通用场景,快排仍是首选,归并排序更多出现在对稳定性有硬性要求的地方。
3.3 堆排序:利用完全二叉树的结构
堆排序是基于堆这种数据结构的排序算法。它把数组看成一颗完全二叉树,先构建一个大顶堆,然后反复把堆顶元素(最大值)与末尾元素交换,再将剩余部分重新调整成堆。
堆排序的时间复杂度稳定在O(n log n),空间复杂度为O(1),这是它的优势。但它没有“缓存友好性”,因为访问元素的跨度很大,不像快排那样密集访问连续内存,所以实际运行速度通常不如快排,也因为它不稳定、常数因子高,在工程通用排序里用得不多。
不过堆排序的思想很重要。当题目是“找到前K个最大元素”而K远小于N时,维护一个大小K的小顶堆O(n log K)是经典解法。你学了堆排序,就能顺手掌握优先队列的底层逻辑,这是后续图算法、贪心算法里的常客。
3.4 八大排序到底是哪些
很多人面试前都会刷“八大排序”,常见说法是:冒泡、选择、插入、希尔、快速、归并、堆、基数。
- 冒泡、选择、插入是O(n²)级别的入门三件套;
- 希尔排序是插入排序的改进版,利用间隔分组;
- 快排、归并、堆是O(n log n)级别的主流排序;
- 基数排序则不是基于比较的排序,它通过按位分配来排整数,在特定数据分布下可以做到O(n)。
这八种排序如果按“比较排序的下界”来理解,会更有框架感:所有基于比较的排序,在最坏情况下时间复杂度的下界是O(n log n),也就是说不存在通用的、基于比较的排序能突破这个界限。快排、归并、堆排序都达到了这个下界,所以它们被称为“最优比较排序”。
这部分的内容是“初识”,你不需要一口气把八大排序全啃完。先掌握选择和插入,再把快排和归并吃透,堆排序理解思路,基数排序知道“有另一条路”就行。剩下的,后续遇到具体场景再逐个击破。
4. 复杂度的意义与工程中“不用手写排序”
4.1 复杂度不是数学题,是“规模感受”
很多初学者把时间复杂度当成面试题考点,背得滚瓜烂熟,但真要解释O(n²)和O(n log n)差多少时,反而说不出个所以然。我建议你用实际规模去感受:
- 当n = 100时,n² = 10000,n log n ≈ 664,差别只是15倍;
- 当n = 10000时,n² = 1亿,n log n ≈ 13万,差别变成700多倍;
- 当n = 100万时,n² = 1万亿,n log n ≈ 2000万,O(n²)要几小时,O(n log n)只要一秒钟左右。
我有个习惯,写代码前先估算规模,再决定用什么排序或者需不需要排序。如果数据量只有几百条,用插入排序和用快排没感知差异;但如果数据量到了百万级别,写一个O(n²)的排序在线上就是在事故现场。
4.2 语言内置排序:你该重点学的
进入工程后,你99%的情况下不需要手写排序算法,直接调用语言内置排序即可。但内置排序不是黑魔法,你要知道它大概怎么实现,以及它的返回值规则。
- Python:sorted()返回新列表,list.sort()原地排序,都支持key参数。Python默认的Timsort算法是结合了归并排序和插入排序的自适应算法,对真实世界经常出现的“部分有序”数据非常友好。
- Java:Arrays.sort()对基本类型使用双轴快速排序,对对象类型使用Timsort,因为对象排序需要稳定。Collections.sort()同理。
- C++:std::sort()在大多数标准库实现里是introsort,综合快排、堆排、插入排;还有std::stable_sort()能在需要稳定时使用归并排序。
- JavaScript:Array.prototype.sort()在不同引擎里实现不同,而且它默认把元素转成字符串再排序,所以对数字排序必须传比较函数,这是我见过新手踩得最多的坑之一。
所以,扎实掌握一门语言内置排序的用法,比死记硬背十个排序算法在工程里更有实际收益。但懂底层依然是必要的,比如你面试时被问“为什么这里用Timsort而不是快排”,如果你能说出“Timsort对部分有序数据几乎线性”,就说明你真的理解了。
4.3 自定义比较器与“按某元素排序”
工程里最常遇到的问题是“我有一堆对象,想按某个字段排序”。不同语言有不同写法,但核心都是提供一个比较器或key函数。
Java按对象的某个字段排序,经典写法是:
java复制list.sort(Comparator.comparing(User::getAge));
// 按年龄升序,再按姓名降序
list.sort(Comparator.comparing(User::getAge)
.thenComparing(Comparator.comparing(User::getName).reversed()));
Python多维列表按某个下标排序:
python复制# 按第二个元素排序
data.sort(key=lambda x: x[1])
# 按第二个元素降序,第三个元素升序
data.sort(key=lambda x: (-x[1], x[2]))
C++用lambda写比较器:
cpp复制sort(people.begin(), people.end(),
[](const Person& a, const Person& b) {
if (a.age != b.age) return a.age < b.age;
return a.name < b.name;
});
这里最容易翻车的点是比较器返回值方向搞反。以升序为例,返回 true 的时候a排前面;你想降序就把比较方向反过来。写完之后最好用随机数据、边界数据各测一遍,不要只测一个用例。
5. 真实场景中的排序问题
5.1 MySQL 排序:ORDER BY 与索引
数据库里的排序和我们前面讲的“数组中排序”不太一样。它不会把整张表全部加载到内存里排好再输出,而是依赖执行计划和索引。
如果你给排序字段建了索引,MySQL可以直接按索引顺序读取数据,避免filesort;如果没有合适的索引,MySQL就要把符合条件的行先查出来,再在内存或磁盘上做排序。数据量大时,filesort会明显拖慢查询。
所以优化排序查询的第一原则是:给ORDER BY涉及的字段建立合适的索引,尤其是排序字段和WHERE条件字段能组成联合索引时,效果立竿见影。另外,ORDER BY配合LIMIT时,如果排序字段没有索引,数据库可能要排出全量结果再取前几条,这往往就是慢查询的根源。
中文排序是另一个常见的坑。默认情况下UTF-8排序很可能不是按拼音排的,而是按编码或特定collation排的。如果你要做“按拼音排序”的功能,需要明确指定排序规则,或单独存储拼音字段。这个坑我在实际项目里踩过一回,最后是加了个拼音列才解决。
5.2 前端表格排序与拖拽排序
前端“点击表头排序”本质上就是把表格数据数组按某字段排一遍,再重新渲染。如果数据已经在后端排序好,前端只负责展示;如果数据量不大,前端可以直接用JavaScript的sort方法。
Vue2里实现拖拽排序,很多方案依赖“排序”的语义:拖拽时其实是调整数组里元素的位置,再触发列表重绘。底层用的不是排序算法,而是“把目标元素从原位置移到新位置”的数组操作。但这个过程中,如果你给列表项设置了某些过渡动画或key值,排序稳定性就会影响视图状态——所以你会发现很多拖拽组件会要求列表项有唯一key。
数据可视化工具里的排序,像Tableau里的“排序”就包含两种不同概念:一种是显示数据的排序,一种是计算字段里的排名计算。前者是操作层面的排序,后者其实是“排名”这种分析指标。遇到这类工具时,先弄清楚你要的是“物理排序”还是“分析排名”,别让一个按钮解决所有问题。
5.3 拓扑排序:不是数值排序
拓扑排序和前面所有排序都不太一样,它不比较大小,而是根据“依赖关系”排出先后顺序。举个例子,大学课程有先修关系:修完高等数学才能修线性代数,修完线性代数才能修概率论。把课程当作节点、依赖关系当作有向边,拓扑排序就是找一条不违背所有依赖关系的上课顺序。
拓扑排序只适用于有向无环图,实现思路一般是Kahn算法:不断找出入度为0的节点,移除它并更新其他节点的入度。C语言实现时会用到队列或栈,核心代码不长。这类排序在任务调度、编译器依赖分析、包管理工具里都有应用。
你学基础排序时记住一句话:数值排序是“按大小排”,拓扑排序是“按依赖排”。它们都叫排序,但解决的问题完全不同,这个意识能避免很多概念混淆。
5.4 排序统计与指标类“热词”的视野
平时搜排序相关的内容,会遇到“排序统计”、“尾盘甄选排序指标”这类词。这些往往不是算法层面的排序,而是业务层面的数据加工:把数据按某个指标降序排列,再取前几名做后续分析。它们的底层仍然是排序,但难点在于“指标怎么算”,而不是“怎么排”。
还有“水排序求解器”这类游戏工具,本质上也不是在做数值排序,而是在解一个状态空间搜索问题:每一步倒水会产生一个新状态,目标是找到到达目标状态的路径。这类问题常用BFS/DFS甚至A*搜索,而不是“把瓶子排整齐”那么简单。
拓宽视野的要点是:排序是一种基础操作,但真正的复杂度往往在数据定义、指标计算和系统约束中。别被“这也要排序”的表象吓到,回到本质去分析,你会更从容。
6. 常见问题与排查技巧实录
6.1 我遇到过的排序“翻车现场”
写代码这么多年,排序相关的坑我踩过不少,有些问题几乎每隔一阵就会在新人身上重现一次。我把印象深的几个整理出来,遇到相同症状的可以直接对症下药。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 排序后顺序不对,元素乱跳 | 比较器返回值写反 | 记住:升序时a.compareTo(b)返回负数表示a在前 |
| 相同值的元素顺序变化 | 排序算法不稳定 | 改用稳定排序(归并、Timsort),或多字段串联排序 |
| 中文按拼音排不准 | 编码/collation问题 | 指定排序规则或单独存拼音字段 |
| 大数据量递归爆栈或超时 | 快排退化、递归太深 | 改用随机基准、三数取中,或改用非递归排序 |
| 排序后原数组没变 | 看错方法是原地还是返回新数组 | 确认sort是原地修改还是返回新序列 |
| 对象字段不能直接比较 | 缺少比较器或字段是null | 写自定义比较器并处理null值 |
6.2 用三个测试用例检验排序是否正确
我对排序代码的正确性验证,习惯不看完整运行日志,而是准备三个针对性输入:
第一是随机数组,主要查算法逻辑是否完整;第二是完全有序数组,查最优场景下会不会出问题,同时也确认“已经有序”时算法能不能正确收尾;第三是大量相同元素的数组,这个能最快暴露不稳定的问题。除此之外,边界情况要用空数组、单元素数组、两个相同元素这些都测一遍。
我之前帮一个同事排查线上排序错乱,他用的前端排序没有传自定义比较函数,JavaScript把数字转成字符串后发现“10”排在了“2”前面。这种问题只要在测试用例里加入“10和2同时出现”的情况,一秒就能暴露。多写有针对性的测试,真的能省很多排查时间。
6.3 一百万个元素实测:O(n²)和O(n log n)差距有多大
我自己跑过一个简单的实测,生成100万个随机整数,分别用冒泡排序和Java内置排序(双轴快排实现)排一遍。冒泡排序跑到几分钟都还没结束,内置排序大概0.2秒就结束了。这个对比不是说冒泡排序一无是处,而是让你形成体感:在数据量级上来之后,算法的复杂度直接决定方案可不可用。
今天很多业务数据是百万级、千万级的,如果一个排序环节用了O(n²)的实现,性能数字会非常难看。反过来,遇到几万条数据的内部排序,你其实不用纠结选什么算法,语言内置排序足够快,把精力花在比较器和稳定性上更值。
6.4 初学排序的一个高效练习法
最后分享一个我自己带新人训练时常用的方法:拿到一个排序算法后,先不急着看代码,而是拿一副扑克牌或者一组卡片,手动走一遍这个算法的过程。比如插入排序,你手上抓5张乱牌,一张一张插入到已排序的牌堆里,这个动作本身就是插入排序;再用纸笔记录每轮数组的状态,走出两三轮之后,再看代码会特别通透。
熟练基本排序之后,可以升级练习:不用语言内置排序,手写快排和归并,用随机数据、有序数据、重复数据各跑一遍,记录耗时。然后换语言内置排序跑同样数据,比较差异,再想想为什么。做完这些,你对排序的认知会比单纯看十篇教程都深。
