☰
数组排序方法全解析:从算法原理到多语言实战
2026/9/29 15:32:05 网站建设 项目流程

数组排序方法,听起来是每门编程语言第一课就会讲的东西,可真到了项目里写起来,却远没有想象中省心。我最近处理一个内部报表需求,前端要按多字段排序,后端 MySQL、Oracle、SQL Server 各有各的规则,算法组那边还在跑 MapReduce 分组排序,最后发现一个“数组排序方法”的题目,硬生生拆成了五六个技术栈的活。这篇文章不打算背 API,而是把“给数组排序”这件事的底层思路和我在真实项目里踩过的坑串起来,覆盖算法选型、JS/Java/C++/Python 的写法、SQL 和 Excel 里的应用,以及分布式场景下的自定义排序。适合正在写业务代码,又想把排序这块一次弄透的人。

1. 排序思路拆解:先定三件事再动手

1.1 排序结果好不好,不是“排完没排完”说了算

很多人一说排序就只想到升序降序,但实际业务里测试用例可不会只问你“数组是不是有序”。真正该先确认的是三个维度:稳定性、原地性、比较规则。

  • 稳定排序:相同关键字元素的前后顺序保持不变。比如表格里先按时间排序,再按城市排序,城市相同的人还要保持原来的时间顺序,这时必须用稳定排序,否则分页位置会跳。
  • 原地排序:只使用常量级额外内存,例如插入排序、堆排序。对于内存紧张的嵌入式场景,这是硬约束。
  • 比较规则:数字按数值、字符串按字典序、对象按某个字段、中文按拼音还是拼音+笔画,这些完全不是一回事。

我踩过最深的一个坑是 JavaScript 的sort()默认行为。数组[3, 15, 8, 29, 2]直接sort(),结果不是按数字大小,而是按字符串 Unicode 排序,输出[15, 2, 29, 3, 8]。这不是 Bug,是规范就是这么定的。所以无论用什么语言,第一件事永远是把“比较器”定义清楚。

1.2 数据规模决定算法,不能只看时间复杂度

教科书喜欢讲 Big-O,但工程里同一场景的数据量差异很大。我给团队定了一个非常粗的参考线:

数据规模推荐方案原因
百级以内插入排序、选择排序常数小,代码简单
万级到百万级快速排序、Timsort、归并排序能利用缓存和局部性
百万级以上外部排序、分布式排序内存放不下,要分块归并
数值范围小但有大量重复计数排序、桶排序从 O(n log n) 降到 O(n + k

这张表不是为了背,而是提醒你先看数据特征。去年有个报表接口对 2000 条数据用了快速排序,结果性能反而比插入排序差,因为队列本身就接近有序,快速排序每次切分都不均衡。换成插入排序后,最好情况 O(n),实测少了十几毫秒。

1.3 排序对象不一定只是数字

数组元素可能是字符串,可能是对象,可能是二维数组的行,也可能是指针。最容易被翻车的是字母数字混合排序,比如文件列表["a2","a10","a1"],字典序排序会得到["a1","a10","a2"],但用户期望的是自然排序["a1","a2","a10"]。

处理这种需求不能只靠简单比较器,要么拆开数字部分,要么使用带自然排序的 API。JavaScript 里的Intl.Collator就支持numeric: true,C++ 里可以写自定义 compare 函数按位拆数字,后面会具体展开。

2. 经典排序算法原理与选型

2.1 冒泡、选择、插入这些 O(n^2) 算法,什么时候还有价值

这三个算法适合教学,也适合在数据量极小的时候作为手写兜底。但真正生产环境里,冒泡基本可以放一边,选择排序虽然比较次数固定,但没有利用输入的有序性。插入排序反而是三个里面最实用的,因为它对近乎有序数组的复杂度接近 O(n),而且稳定、原地。

下面是一个很常见的插入排序实现:

function insertionSort(arr) { for (let i = 1; i < arr.length; i++) { const cur = arr[i]; let j = i - 1; while (j >= 0 && arr[j] > cur) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = cur; } return arr; }

实现要点是先把当前值cur存下来,再把比它大的元素后移,最后插入空位。没必要写成每次比较都交换,那样赋值次数会翻倍。

2.2 快排、归并、堆排的生产级取舍

工程里真正常用的是分层混合策略。Java 的Arrays.sort()对基础类型用双轴快排,对对象类型用 Timsort;C++ 的std::sort()用 introspective sort,递归深度过大时切到堆排;Python 的sorted()和list.sort()都是 Timsort。这些标准库已经处理好了退化问题,普通业务直接调用就行。

需要自己写排序的场景,主要出现在以下两类:

  • 稳定性要求高:用归并排序。SQL 里的ORDER BY分组后再排序,底层本质也是归并或堆排序实现的稳定排序。
  • 内存受限:用堆排序。它原地且最坏 O(n log n),但堆排序的缓存命中率不如快排,现实速度往往没那么理想。

我个人的建议是:业务代码不要手写快排,除非你明确知道数据分布和分治细节。快排退化到 O(n^2) 的典型原因是每次 pivot 都选到最小或最大值,正确做法是三数取中或随机选 pivot,但这些细节容易在赶工时忽略。

2.3 特殊数据用非比较排序,O(n log n) 不是唯一答案

如果数组元素是有限范围内的整数,比如成绩是 0 到 100 分,那根本不需要比较排序。用计数排序,先统计每个分数出现次数,再按顺序回填,K 个分数就 O(n + K) 搞定。

USACO 里有道经典题目“三值排序”,数组只含 1、2、3,要求最少交换次数排好序。很多人第一反应是写冒泡,其实最优解是用计数统计三类数字的落位情况,复杂度 O(n)。这种题的价值在于提醒你:看到数据范围固定且取值稀疏时,计数排序、桶排序、基数排序往往比通用比较排序划算一个量级。

还有一个偏门但老牌的 Batcher 排序器,它属于排序网络,固定比较顺序,适合硬件并行和 GPU 场景。普通服务器上用不到,但如果看到“排序器”这个名词,知道它不是sort(),而是一套固定深度的比较交换电路即可。

3. 主流语言里的数组排序实操

3.1 JavaScript:数组排序的几种正确姿势

JS 里最常用的是Array.prototype.sort()。自 ES2019 起规范要求稳定排序,Node 和现代浏览器都没问题。关键是你必须传比较器:

const nums = [3, 15, 8, 29, 2]; nums.sort((a, b) => a - b); // 升序 nums.sort((a, b) => b - a); // 降序

千万不要写nums.sort()然后立刻交给测试。用户往往会输入两位数,默认字典序会直接翻车。

多字段排序就返回差值或比较结果的“或”关系:

const users = [ { name: 'A', age: 30 }, { name: 'B', age: 25 }, { name: 'C', age: 25 }, ]; users.sort((a, b) => b.age - a.age || a.name.localeCompare(b.name));

这里先按 age 降序,如果年龄相同再按 name 升序。

二维数组按某一列排,也很常见:

const matrix = [[3, 10], [2, 5], [5, 8]]; matrix.sort((a, b) => a[1] - b[1]);

至于字母数字混合,最简单的方案是Intl.Collator:

const collator = new Intl.Collator('zh', { numeric: true }); ['a2', 'a10', 'a1'].sort(collator.compare); // ['a1', 'a2', 'a10']

如果只是想取排序后的副本,记得[...arr].sort(...)或arr.slice().sort(...),不要直接修改原数组,否则后续逻辑经常会受污染。

3.2 Java 与 C++:标准库、动态数组、指针与多维数组

Java 分两种:数组用Arrays.sort(),集合用Collections.sort()。

int[] nums = { 3, 15, 8, 29, 2 }; Arrays.sort(nums); // 基础类型升序 User[] users = { ... }; Arrays.sort(users, Comparator.comparingInt(User::getAge) .thenComparing(User::getName));

数据量很大时可以用Arrays.parallelSort(),它把排序任务拆给 ForkJoinPool 并行执行。但有两点要注意:对象数组必须保证比较器能正确比较;并行排序在数据量几万个以下时未必更快,因为线程切分也有开销。

C++ 这边,动态数组通常用std::vector,排序带区间迭代器:

std::vector<int> v = {3, 15, 8, 29, 2}; std::sort(v.begin(), v.end());

需要稳定排序时用std::stable_sort。自定义结构体可以通过 lambda 指定比较字段:

struct User { std::string name; int age; }; std::sort(users.begin(), users.end(), [](const User& a, const User& b) { if (a.age != b.age) return a.age > b.age; return a.name < b.name; });

C 风格数组和指针数组也一样能排。指针数组本质上每个元素就是指针,排序时比较的是指针指向的内容,不能用默认的<直接比较指针地址:

const char* words[] = {"banana", "apple", "cherry"}; std::sort(std::begin(words), std::end(words), [](const char* a, const char* b) { return std::strcmp(a, b) < 0; });

多维数组要按所有元素整体排序,因为二维数组在内存里是连续铺开的,可以直接把起始地址当一维数组处理:

int arr[3][4] = { ... }; std::sort(&arr[0][0], &arr[0][0] + 3 * 4);

千万别用std::sort(arr, arr + 3),那样比较的是三个“长度为 4 的数组指针”,语义完全不对。

3.3 Python:sorted、切片与常用排序组合

Python 的排序非常省心,list.sort()原地,sorted()返回新列表。真正值得花时间的是key参数:

users = [{"name": "A", "age": 30}, {"name": "B", "age": 25}] users.sort(key=lambda u: (-u["age"], u["name"]))

单用sorted排二维数组也一样,按第二列:

arr = [[3, 10], [2, 5], [5, 8]] arr.sort(key=lambda row: row[1])

数组切片arr[::-1]是反转,不是排序。排序和切片经常放在同一段代码里,但语义要分清。字符串数组排中文时,sorted(arr)是按 Unicode 码点排,如果你想要拼音,需要装pypinyin或locale.strxfrm,这属于业务规则,不是语言自带能力。

3.4 SQL 排序:MySQL、Oracle、SQL Server 的差异

SQL 里的排序,核心就是ORDER BY,但不同数据库有很多容易忽略的差异。

SELECT * FROM users ORDER BY age DESC, name ASC;

MySQL 默认对 NULL 排在最前,Oracle 默认 NULL 排在最后,SQL Server 默认 NULL 在最前。需要稳定行为时,Oracle 要显式写NULLS FIRST或NULLS LAST。

分组后的组内序号,是 SQL 排序里特别常见又特别容易写错的需求。比如按部门分组,组内按分数倒序编号:

SELECT dept_id, emp_name, score, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY score DESC) AS group_seq FROM exam_score;

这种写法在 SQL Server 和 MySQL 8+ 都能用,Oracle 天然支持。它和普通GROUP BY完全不同,PARTITION BY不会压缩行数,而是给每一行分配组内排名。

MySQL 里按别名排序有个坑,ORDER BY可以引用SELECT里的别名,但如果别名是保留字或含中文,会直接报错。更稳的做法是外层包一层子查询再排。Sequelize 这类 ORM 里别名排序则需要特别注意order里的字符串要原样传 alias,否则 JOIN 时会拼出错误的列名。

3.5 Excel 与 VBA:数组公式和宏里的排序

Excel 365 有了动态数组,排序可以直接用公式:

=SORT(A2:C20, 2, -1)

SORT返回一个动态数组,会溢出到周边单元格,所以不要写在已经有很多数据的列旁边。第二个参数是按第几列排,-1表示降序。

如果需要“数组分割并显示包含某一字符”的筛选加排序,可以用FILTER配SORT:

=SORT(FILTER(A2:B100, ISNUMBER(SEARCH("华东", B2:B100))), 1, 1)

VBA 里没有内置的数组Sort方法,这是很多人第一次写 VBA 时被卡住的地方。最快的自写方案是把数组拷到工作表区域用Range.Sort,或者用System.Collections.ArrayList:

Dim list As Object Set list = CreateObject("System.Collections.ArrayList") list.Add "banana" list.Add "apple" list.Sort

VBA 数组对比最快的方式不是嵌套循环,而是先把两个数组排序,再用双指针逐个比较,复杂度 O(n log n + n)。这其实就是“先排序,再处理”思路的经典应用。

4. 业务场景中的排序方案:从对象到分布式

4.1 多字段、字母数字混合与本地化排序

多字段排序的通用方案是“复合比较器”:先比较第一个字段,相同才比较第二个字段。Java 的thenComparing、JS 的||、Python 的元组 key、SQL 的连续ORDER BY,本质都一样。

字母数字混合的排序,核心是拆分数字段。下面是一个简单的 JS 自然排序比较器:

function naturalCompare(a, b) { return a.localeCompare(b, 'zh', { numeric: true }); }

这个方案对“第2章”“第10章”这类标题非常合适,但要注意localeCompare的浏览器实现有差异,Node 环境下一直很稳。C++ 里没有现成的自然排序,只能用一个字符一个字符扫描的循环,遇到数字就整体拼接再比较大小。这种自写逻辑没什么高级魔法,慢就慢在每次比较要产生临时字符串,优化手段是预解析成(文本前缀, 数字后缀)的数组,再对数组排序。

4.2 树状数组与排序后的区间统计

排序本身只是第一步,很多高难度场景是排序后还要频繁做区间统计。比如一个长度 n = 16 的序列,排序后要持续查询前缀和,还要修改某个位置的值。这种需求不适合每次重新排序或遍历累加,要用树状数组。

int n = 16; int bit[17]; void add(int idx, int x) { // 单点修改:add(3, x) while (idx <= n) { bit[idx] += x; idx += idx & -idx; } } int sum(int idx) { // 前缀和:sum(11) int res = 0; while (idx > 0) { res += bit[idx]; idx -= idx & -idx; } return res; }

它维护的不是原始数组,而是“按二进制低位分组”的前缀块。idx += idx & -idx是跳到下一个覆盖区间,idx -= idx & -idx是回退到前一个区间。排序后的数组如果只是静态查询区间最大值,还可以用 ST 表或者稀疏表,预处理 O(n log n),查询 O(1),但一旦有修改就得换线段树或树状数组思路。

4.3 MapReduce 自定义排序与分组排序

分布式场景下的排序和单机不同。MapReduce 默认在 Shuffle 阶段按键排序,同一个 key 的所有 value 会进入同一个 Reduce,并且 value 也是有序的。但默认排序只针对 key,如果业务要求组内再按 value 排序,需要自定义分区器和组合键。

常见做法是定义一个WritableComparable复合键,compareTo先比 key,再比 value:

public int compareTo(MyKey o) { int cmp = this.key.compareTo(o.key); if (cmp != 0) return cmp; return this.value.compareTo(o.value); }

这样在 Shuffle 排序后,每个 key 内部的值也自然有序,Reduce 阶段就可以直接处理“分组排序”后的结果。很多平台上的“第 1 关:MapReduce 排序”练习其实就是让手写这个compareTo,核心是理解“排序分两段:键排序负责分组连续性,组内排序靠组合键的第二字段”。

4.4 排序在经典算法题里的组合用法

有些场景看着不是排序,但排序能让问题简化。比如“三个数组最大的乘积”,最直观的解法是排序后比较两个极端组合:最大的三个正数,或者两个最小的负数加一个最大的正数。

def maximum_product(nums): nums.sort() return max(nums[-1] * nums[-2] * nums[-3], nums[0] * nums[1] * nums[-1])

再比如“一列数,已知固定数值,确定哪些数据和等于固定值”,这是子集和问题,排序只是预处理。先把数组从小到大排序,再用回溯剪枝,当前和超过目标就停止,能省掉大量无效递归。这类题如果你只记排序 API,不掌握排序后如何配合双指针、前缀和、二分查找,效率会差很多。

5. 常见问题与排查技巧实录

5.1 排序结果“不对”的四个检查点

我在代码评审里看到最多的排序 Bug,都集中在以下四个地方:

  • 比较器没有实现传递性。比如(a, b) => a.xxx可能返回NaN,一旦出现NaN,V8 会当作 0 处理,顺序完全不可预期。
  • 多字段比较漏了“相同再看下一字段”。很多人只写ORDER BY dept_id,组内顺序就不是想要的。
  • null 和 undefined 没预处理。JS 里undefined参与比较会转成NaN,Java 里拆箱空对象会 NPE,SQL 里 NULL 顺序各库不一。
  • 字符串数字混排没做类型转换。'10'和'9'比较会得到'10' < '9',在用户面前就是明显的排序错误。

排查时不要直接看排序库,先构造一组最小复现数据,比如['10', '9', '2'],再逐步加字段。这个方法看起来简单,但能解决 90% 的排序“玄学”。

5.2 大数据量排序时踩过的性能坑

有些排序慢不是算法问题,是循环里重复排序。比如一个for循环里每次都去ORDER BY,等于每行重排一次,应该把排序结果提出来一次性排完。

大数组内存溢出的处理思路是外部排序:把数据分成可以放进内存的多个块,每一块内部排序后写入临时文件,最后多路归并。这在 Java 里可以直接用PriorityQueue做 k 路归并,比盲目扩大堆内存可靠得多。

还有一点想特别提醒:MySQL 的ORDER BY如果涉及未索引列,会产生 filesort。不是说一定不能用 filesort,而是当你发现某个排序查询在百万行上要几秒,先看执行计划里的Using filesort,再看能否通过联合索引覆盖排序字段。能走索引排序的情况,性能差距是几十倍。

5.3 高频问题速查表

问题原因正解
JS 数字排序出错默认按字典序传(a,b)=>a-b
SQL 分组后组内没有序号没有用窗口函数ROW_NUMBER() OVER(PARTITION BY ...)
Oracle NULL 排序不对默认 NULL 最大显式写NULLS FIRST/LAST
VBA 数组无法直接排序VBA 无内置 Sort用ArrayList或自写快排
字母数字混合排序不对字典序自然排序 /numeric参数
对象数组多字段排错比较器只比一个字段组合比较 /thenComparing
数组去重后顺序变了使用 Set 但没保持原序若需原序,用 filter 或 Map
删除指定元素误改原数组splice 直接在原数组操作先slice()再删

另外,数组去重、数组转字符串、数组分割这些操作经常和排序写在同一段数据处理流程里。比如去重后再排序,可以先排序再去重,也可以先去重再排序;如果需要保持第一次出现的顺序,只能用Set遍历原数组。join(',')转字符串后,注意数字数组会默认去掉末尾的.0,如果精度敏感,别用隐式转换。

6. 我在项目里的排序习惯

最后分享几个我自己长期坚持的习惯。第一个,任何排序需求先问一句“这里面有没有用户自定义排序规则”。前阵子做菜单列表,用户要求把“置顶”项放前面,其他项按更新时间倒序。这类需求用稳定排序就能做到:先把置顶标志排好,再对整体做一次稳定排序,置顶项不会被打乱。

第二个,代码里不要写裸奔的比较器。定义好命名函数或专门的 comparator 对象,方便测试。我一般会写一个sort.test或在单测里把原顺序、目标顺序都标记出来,否则过三个月再看代码,谁能记住当时为什么要正序又倒序。

第三个,SQL 排序尽量只把结果集做小再排,不要全表排序。先 where 缩窄范围,把排序推给索引,最后再补充 frontend 侧的字段排序。

排序这件事,底层理论几十年没变,但每个语言、数据库、算法题里的表现形态都不一样。把“稳定、原地、比较器、数据范围”这四个词刻在脑子里,再结合你手头实际的数据量去选,就不会再为排序翻车。顺便说一句,排序前先确认数据类型和 null 策略,能帮你省下 80% 的排查时间。

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

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

立即咨询