刷到一个作业题,编号7-1,分值12分,要求写"多数组排序",而且点名了三种做法:冒泡排序、重写sort方法、利用sort和栈。第一眼觉得简单,排序嘛,哪个后端没写过。但仔细一想,这个"三种做法"的限定其实很妙——它逼着你在同一个目标下走三条完全不同的路,每一路都能带出不同的知识点。这篇就围绕这个题目,把这三种做法从头到尾拆开揉碎,讲清楚各自的核心逻辑、代码写法、边界场景,以及我在实际刷题和项目里踩过的坑。
无论你是刚学编程没多久的学生,还是想巩固基础、准备机试的开发者,这篇都值得看完。我会用JavaScript来写代码,因为sort和重写sort是前端最熟悉的那一套,但冒泡和栈的思维同样适用于C、Java、Python,只要你理解了思路,换成别的语言就是改几行语法的事。
1. 题目拆解:多数组排序到底在考什么
1.1 题目本质与隐藏考点
先把这个题目从字面上拆开。"多数组"意味着不是给你一个数组,而是给你一组数组,可能是二维数组,也可能是若干独立数组分布在输入行里。"排序"这个动作看似明确,但仔细想有几个问题需要先定下来:
- 是对每个子数组单独排序,还是把所有数组合并成一个数组再统一排序?
- 排序结果是升序还是降序?
- 原数组的结构是否需要保留?比如二维数组的行列关系。
- 如果数组长度不一致,如何处理?
从我刷题的经验来看,这类"多数组排序"最常见的设定是:输入若干个数组,每个数组内部需要排好序,然后把所有排序后的结果按行输出。这种设定能考察一个核心能力——在循环中反复执行排序逻辑,并且处理好每个子数组的独立性。很多新手在单个数组排序时很溜,一到多数组就懵,原因在于没有意识到排序逻辑应该被封装、复用,而不是把一整套冒泡代码复制粘贴N遍。
另一个隐藏考点是对排序算法稳定性的理解。题目虽然不直接问"什么是稳定排序",但当你用冒泡时,相同元素相对顺序不变;用JavaScript的sort时,V8引擎在特定长度下用的是稳定排序;用栈合并时,稳定性取决于你的合并策略。这些细节在题目里不会明说,却是阅卷和面试里喜欢追问的点。
1.2 三种做法的选型思路对比
标题给出的三种做法,其实代表了三个层次:
- 冒泡排序:最底层的算法实现,不依赖任何语言特性,纯粹用循环和交换完成排序,考察的是你对排序原理的掌握。
- 重写sort方法:利用语言内置排序能力,但要理解默认sort的坑(比如JavaScript默认按字符串字典序排序),通过重写比较函数来达到数值排序的目的,考察的是对语言API的理解和自定义能力。
- 利用sort和栈:这是个进阶组合,先用sort把每个子数组搞定,然后借助栈这种数据结构完成多路合并或逆序输出。考察的是数据结构的灵活运用。
这三条路线没有绝对的优劣。冒泡写起来最啰嗦,但最不依赖环境;重写sort最简洁,但如果你不知道默认排序规则,反而会写错;sort加栈思路最巧妙,但对初学者来说,理解"为什么需要栈"需要一点抽象思维。
我在实际教学中见过太多学生一上来就抱着sort用到底,结果遇到"要求手写排序算法"的题目直接傻眼。反过来,也有学生只会背冒泡模板,遇到大数组性能拉胯。所以这道题"三种做法"的精髓不在于让你挑一个最快的,而在于让你同时掌握三种思路,以后遇到不同场景能对症下药。
2. 做法一:冒泡排序——最笨但最扎实的做法
2.1 冒泡排序的核心逻辑与代码实现
冒泡排序的逻辑一句话就能说清:从头开始,两两比较相邻元素,如果顺序不对就交换,每轮结束后,最大的元素像气泡一样冒到数组末尾,下一轮再对剩余部分重复操作。
多数组场景下,最关键的改动是包一层循环。看代码:
function bubbleSort(arr) { // 拷贝一份,避免修改原数组 const res = [...arr]; const len = res.length; // 外层循环控制轮数,每轮确定一个最大值的位置 for (let i = 0; i < len - 1; i++) { // 内层循环负责本轮的相邻比较 // 因为最后 i 个元素已经有序,不需要再比较 for (let j = 0; j < len - 1 - i; j++) { if (res[j] > res[j + 1]) { // 交换相邻元素 [res[j], res[j + 1]] = [res[j + 1], res[j]]; } } } return res; } // 多数组排序:对二维数组中的每一行调用冒泡排序 function bubbleSortMultiArrays(arrays) { return arrays.map(arr => bubbleSort(arr)); } // 测试 const input = [[3, 1, 2], [5, 4], [9, 7, 8, 6]]; console.log(bubbleSortMultiArrays(input)); // 输出:[[1, 2, 3], [4, 5], [6, 7, 8, 9]]这里有两个容易出错的地方。第一,内层循环的j < len - 1 - i这个边界条件,很多人写成j < len - 1,虽然结果也对,但会导致每一轮都从头比到尾,效率变差,而且最后一轮会出现无效比较。第二,如果你直接在原数组上操作,调用多次后原数组顺序就被破坏了,我建议传入前先拷贝一份,或者让函数返回新数组,这样多数组之间互不干扰。
2.2 多数组场景下的边界处理
多数组排序和单数组排序有个本质区别:单数组的边界问题只有"数组长度"一个维度,而多数组还涉及"数组数量"和"数组长度不一致"这两个变量。
先看"数组数量为0"的情况。如果输入是[],map会返回一个空数组,这个没问题。但如果数据结构是[[], []],也就是有两个空数组,你需要确保bubbleSort([])不会报错——上面代码中len为0,len - 1是-1,外层循环直接不执行,返回[],表现正常。
再看"数组长度不一致"。比如[[3, 1], [5, 4, 2]],第一行长度2,第二行长度3。由于我们是用map逐个处理,每个子数组独立走一遍冒泡,所以长度不一致根本不构成问题。真正的问题在于,如果题目要求你把所有数组一起排序后再按原结构回填,那冒泡的实现就要反过来从外层的整体出发,这种我放到第三种做法里一起讲。
2.3 冒泡排序的优缺点与适用场景
冒泡排序的时间复杂度是O(n²),空间复杂度O(1),属于原地排序,同时也是稳定排序(前提是判断条件写成>而不是>=,写成>=就会交换相等元素,破坏稳定性)。这些特性在面试里是基本盘,但实际项目中我不会用它排大数组。
它真正的价值在于教学和概念理解。当你用冒泡写完一个多数组排序,你对"循环嵌套""数组索引""元素交换"这些基础操作的理解会上升一个台阶。而且冒泡有个漂亮的优化点,可以加一个标志位:
function bubbleSortOptimized(arr) { const res = [...arr]; const len = res.length; for (let i = 0; i < len - 1; i++) { let swapped = false; for (let j = 0; j < len - 1 - i; j++) { if (res[j] > res[j + 1]) { [res[j], res[j + 1]] = [res[j + 1], res[j]]; swapped = true; } } // 如果本轮没有发生任何交换,说明已经有序,提前结束 if (!swapped) break; } return res; }这个swapped标志位能在数组接近有序时提前退出,把最好情况的时间复杂度降到O(n)。虽然题目不要求优化,但写上这个细节,阅卷印象分会好很多。
3. 做法二:重写sort方法——用语言特性简化排序
3.1 默认sort的坑:字典序问题
JavaScript内置的Array.prototype.sort()是很多新手的第一选择,因为它确实太方便了。但如果你不了解它的默认行为,这里有坑。
默认的sort()先把数组元素转换成字符串,然后按**字典序(Unicode码点顺序)**排列。也就是说:
const nums = [10, 9, 2, 1]; console.log(nums.sort()); // 输出:[1, 10, 2, 9]没错,10排在了2前面,因为字符串"10"的字典序小于"2"。这在数值排序里是绝对不可接受的。解决办法就是重写比较函数。
3.2 重写比较函数的关键写法
比较函数的规则很简单:接收两个参数a和b,返回值小于0表示a排在b前面,大于0表示a排在b后面,等于0表示位置不变(稳定性由引擎保证)。所以升序排序写成:
function sortAsc(arr) { return [...arr].sort((a, b) => a - b); }降序就反过来:
function sortDesc(arr) { return [...arr].sort((a, b) => b - a); }注意我依然先用展开运算符拷贝了一份,因为sort是原地排序,会修改原数组。在多数组场景里,如果不拷贝,你的输入数组会在第一次排序后就被改掉,后续处理全乱套。
多数组情况下,只需要用map包装一层:
function sortMultiArrays(arrays) { return arrays.map(arr => [...arr].sort((a, b) => a - b)); }3.3 多数组排序时sort的优雅处理
用sort来处理多数组排序,最大的好处是代码量极简。但隐藏的考点在于:你是否知道为什么不能直接调用arr.sort()。如果题目要求同时对多个子数组排序,并且在排序后输出,只需要上面三行代码就搞定。但如果题目要求"合并后整体排序并输出",那sort配合扁平化也很方便:
const arrays = [[3, 1, 2], [5, 4], [9, 7, 8, 6]]; // 先展开成一维,再排序 const flattened = arrays.flat(); const sorted = flattened.sort((a, b) => a - b); console.log(sorted); // 输出:[1, 2, 3, 4, 5, 6, 7, 8, 9]Array.prototype.flat()是ES2019引入的,默认只展开一层。如果子数组里还有嵌套数组,需要传深度参数,比如flat(2)或flat(Infinity)。
这里我想多说一句:重写sort虽然简单,但在面试或机试中,考官想看的往往不是"你会不会用sort",而是"你敢不敢不用sort"——因为很多场景下内置排序的黑盒会让你失去对时间复杂度的掌控。所以这道题把"重写sort方法"作为三种做法之一,合理的理解是:你不但要会用sort,还要会写如何自定义排序规则。如果你能讲出sort的稳定性、原地性、时间复杂度(V8在数组长度小于等于10时用插入排序,大于10用快速排序的变体),这道题12分基本稳了。
4. 做法三:利用sort和栈——换个思路解决排序问题
4.1 栈在排序中的角色
标题里的"栈"是这道题最有意思的部分。栈是后进先出(LIFO)的数据结构,常见操作只有push(进栈)和pop(出栈),怎么看都跟排序不沾边。但它在这里可以承担两个角色:
第一,结果收集器。你把排序后的元素按顺序压入栈中,最后再依次弹出,利用后进先出的特性实现某种反转效果。这在降序输出时特别自然——先升序排序,然后逐个压栈,再弹出,得到的就是降序。
第二,多路归并的辅助结构。当你有多个已经排好序的子数组,想合并成一个整体有序的数组,传统做法是"多指针比较",也就是每次从所有子数组的当前元素中挑一个最小的,放入结果集。这个过程如果用栈来模拟,思路是从后往前压栈,每次弹出栈顶的最小元素。
我按照这道题的常见设定,把"利用sort和栈"理解为:先用sort把每个子数组排好序,然后借助栈把所有子数组合并成一个有序数组。这样做的好处是,你同时用到了两种能力的组合,而不仅仅是会写冒泡或会用sort。
4.2 完整实现思路与代码
先理一下步骤:
- 对每个子数组调用sort升序排序,使它们各自有序。
- 设定一个指针数组(游标),记录每个子数组当前取到哪个位置。
- 循环遍历所有子数组的当前元素,找到最小值,压入结果栈。
- 更新对应子数组的游标,继续循环,直到所有元素都压完。
- 最后依次弹出栈,完成排序。
不过这里有个细节要说明:如果直接用结果栈压入最小值,最后弹出时顺序正好是反的(从最大到最小)。所以更合理的做法是:
- 把最小值压入栈 → 全部压完后弹出 → 结果降序。
- 想要升序的话,就得把最大值先压进去,或者把push改成从结果数组头部插入,或者干脆不用栈的LIFO特性,只把栈当作普通数组。
为了让"栈"这个考点体现得更清晰,我用一个变通方法:用小顶堆不太合适,那就用"单调栈"的思路——把每个子数组的末尾元素压入栈,因为每个子数组已经升序,末尾元素是当前这个子数组的最大值。然后循环弹出栈中所有元素,选出最大值放进结果数组尾部?这个思路有点绕。
更直接且符合栈特性的实现是这样:
每个子数组升序排序后,从尾部开始处理。开一个栈,把所有子数组的最后一个元素压进去,然后不断从栈中弹出元素?不,这样无法保证全局顺序。
我得换个思路。经典解法其实是"每轮从所有子数组的当前元素中找最小值,推进结果数组",这个可以被认为是"最小堆"或"多路归并",并不天然需要栈。为了让栈自然出现,可以这样设计:
把所有子数组排好序后,从后往前遍历,将元素依次压入"归并栈",每次从多个子数组中挑出当前最大的元素压栈,这样栈底是最小的,栈顶是最大的,最后直接pop就能得到升序?不对,pop是先最大后最小,那是降序。
打住。我直接给一个明确的、逻辑自洽的实现,并且向读者说明:这里的"栈"我用一个简单数组模拟,核心思想是先排序子数组,再用栈实现多路归并的反向输出。
做法:每个子数组升序排序后,利用一个游标数组从各子数组末尾开始取值,取当前最大的那个元素压入栈中(因为升序子数组的末尾就是该数组当前最大元素)。把最大元素压栈,持续这个过程,栈底是最小的……不对,压入顺序从大到小,栈底是第一个压入的,也就是最大的,栈顶是最后压入的,最小的。弹出后是升序?栈顶最小,依次弹出就是从小到大。对,这就是正确思路:
- 各子数组升序排序。
- 游标初始指向每个子数组的最后一个元素(最大值)。
- 每一轮,比较所有游标位置的元素,取最大值压栈。
- 该子数组游标左移;如果某子数组已经取完,就跳过。
- 全部压完后,由于先压入大值、后压入小值,栈顶是最小值,依次弹出得到升序结果。
这样栈的LIFO特性被真正用上了。代码实现:
function sortWithStack(arrays) { // 1. 对每个子数组升序排序 const sortedArrays = arrays.map(arr => [...arr].sort((a, b) => a - b)); // 2. 初始化游标:从每个子数组的末尾开始 const pointers = sortedArrays.map(arr => arr.length - 1); // 3. 栈用来存放归并过程中的元素 const stack = []; // 统计剩余元素数量 let remaining = sortedArrays.reduce((sum, arr) => sum + arr.length, 0); while (remaining > 0) { // 找出当前游标位置最大的元素 let maxVal = -Infinity; let maxIdx = -1; for (let i = 0; i < sortedArrays.length; i++) { if (pointers[i] >= 0 && sortedArrays[i][pointers[i]] > maxVal) { maxVal = sortedArrays[i][pointers[i]]; maxIdx = i; } } // 将最大值压入栈 stack.push(maxVal); // 对应子数组的游标左移 pointers[maxIdx]--; remaining--; } // 4. 弹出栈中所有元素,得到升序数组 const result = []; while (stack.length > 0) { result.push(stack.pop()); } return result; } // 测试 const arrays = [[3, 1, 2], [5, 4], [9, 7, 8, 6]]; console.log(sortWithStack(arrays)); // 输出:[1, 2, 3, 4, 5, 6, 7, 8, 9]这个思路的精髓在于:每个子数组排序后,我们从尾部取元素比较,每次找到最大的压入栈中。因为栈是LIFO,先压入的是全局最大值(栈底),最后压入的是全局最小值(栈顶),弹出时刚好从最小值开始,完美得到升序数组。如果你想要降序,就在最后不弹出,直接返回栈,栈从底到顶就是降序。
我在实际测试这个实现时,发现一个容易忽略的问题:当多个子数组的当前最大值相同的时候,maxIdx只会记录第一个遇到的。这对排序结果没有影响,但如果你需要保持相同元素的原始相对顺序(稳定性),这个实现是有问题的,因为后面的相同元素会晚一步压栈,最后会出现在前面,相对顺序被反转。如果题目对稳定性有要求,需要在比较时加一个"相等时取游标更靠后的"之类的条件,我建议在刷题时明确需求后再决定要不要处理。
4.3 三种做法的复杂度与稳定性对比
把三种做法放到一起来看,这是我自己整理的一张对比表,刷题时候可以对照着选:
| 做法 | 时间复杂度 | 空间复杂度 | 稳定性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(1) | 稳定 | 中等 | 小数据量、教学演示 |
| 重写sort | O(n log n) | O(1)或O(n)(取决于引擎) | 稳定(现代引擎) | 低 | 常规业务排序 |
| sort+栈 | O(n log n + n*k)(k为子数组个数) | O(n) | 可能不稳定 | 较高 | 多路有序序列合并、理解数据结构组合 |
有个细节值得说明:sort+栈的空间复杂度是O(n),因为栈里保存了全部元素;冒泡是原地交换,空间O(1)。如果你在机试时遇到内存限制,冒泡反而有优势。但这道题子数组通常不会太大,三者都能通过。
我在实际翻阅一些高分解答时发现,不少同学在第三种做法里根本没用到LIFO特性,只是把栈当普通数组用,这样其实没有体现"栈"的价值。我这版实现里,压入和弹出两段操作是真正利用了后进先出的特性,跟单纯用数组push/pop是有本质区别的。看完这段,你可以自己试着把压入顺序从"最大值"改成"最小值",看看最终结果会有什么不同——这会加深你对LIFO的理解。
5. 常见问题与排查技巧实录
5.1 多数组排序的典型错误
我把这几年改作业和看代码时遇到的典型错误集中整理了一下,每一类都有真实的踩坑背景。
第一类错误:sort默认字典序导致数值混乱。
这是做"重写sort方法"做法时最容易犯的错。很多人写完arr.sort()发现[1, 2, 10]变成了[1, 10, 2],第一反应是"这语言有病"而不是"我没传比较函数"。排查方法很简单:先打印一下默认sort的返回值,观察数字位数超过1时的排序结果,你就能立刻发现是不是字典序问题。
第二类错误:直接修改原数组导致多数组互相污染。
无论用哪种做法,如果你直接对入参数组做排序,第一次排序之后原数组顺序就变了。当你处理第二个数组时,如果代码里引用了原数组做某些判断(比如去重、比较),大概率会出错。我在做冒泡版本时就遇到过:先排序了第一个子数组,然后在排序第二个子数组时,因为第一个子数组已经被修改,导致结果正确但输入数据被破坏,后面想复用数据就麻烦了。所以我的习惯是:函数内部一律先拷贝再操作。
第三类错误:边界条件没处理好。
这里列举几个高频边界:
- 空数组:
[]或者[[], [], []],排序后应该仍然是空,不能报错。 - 单元素数组:
[[1]],不进入任何排序循环,直接返回。 - 负数和浮点数:比较函数
a - b对负数有效,但对浮点数要小心精度问题,比如0.1和0.2在二进制表示下可能产生精度误差。不过单纯排序比较大小是没问题的,只有求和或等式判断才需要精度处理。 - 数组包含
NaN:a - b在NaN参与时会返回NaN,导致排序结果不可预期。刷题一般不会遇到,但真实业务里处理数据是可能的,我会提前过滤掉NaN。
第四类错误:栈的弹出时机不对。
sort+栈的实现里,最容易错的是在元素还没全部压入时就提前弹出。比如while (stack.length > 0)用错了位置,或者压入的轮数判断错误,导致一部分元素留在栈里没进入结果数组。我的排查经验是:在压栈循环结束后打印一下stack.length,对比所有子数组元素总数,如果不相等,立刻检查remaining的更新是否漏了。
5.2 测试用例与自测方法
刷题时最忌讳只跑通一个用例就提交。每次我会至少准备四组测试数据:
// 常规用例 console.log(bubbleSortMultiArrays([[3, 1, 2], [5, 4], [9, 7, 8, 6]])); // 包含负数和重复值 console.log(sortMultiArrays([[-3, 1, 0], [2, -1, -1], [4, 4]])); // 大量子数组 const bigCase = Array.from({ length: 100 }, (_, i) => [i, 99 - i]); console.log(sortWithStack(bigCase.slice(0, 3))); // 空数组和单元素数组 console.log(sortWithStack([[], [1], []]));还有一个小技巧:多数组排序结果是否正确,可以把所有子数组合并后看最终是否整体有序。比如对[[3, 1], [2, 4]],所有实现都应该返回[[1, 3], [2, 4]](子数组分别升序)或[1, 2, 3, 4](整体排序)。用一个checkSorted函数验证:
function checkSorted(arr) { for (let i = 1; i < arr.length; i++) { if (arr[i - 1] > arr[i]) return false; } return true; }如果你调试一个多小时都找不出bug,我建议把你的三种实现分别打上console.log,在每个关键节点输出当前数组和栈的状态,尤其是sum数组的总长度和栈的总长度,几乎一眼就能定位问题。
5.3 我在实际项目中的排序选型心得
刷题归刷题,落到实际项目里,我的选择会不一样。我可以分享一下我的习惯:日常业务代码里,只要不是数组规模特别大,我直接用sort加比较函数,因为代码最简洁、可读性最高。遇到"多个有序列表合并成一个有序列表"的场景,我不会用手写栈,而是优先用Array.prototype.flat加sort,或者用标准的最小堆(比如优先队列)来保证性能。但如果是面试官要求你"不能调用内置排序"或者"请实现多路归并",那么冒泡和栈的知识就能救场。
有一点我觉得很重要:不要因为有了sort就彻底不看冒泡,也不要因为冒泡简单就在所有场景都用冒泡。真正有经验的开发者,是知道每种方法的适用边界并能在边界内果断做出选择的人。这就是为什么这种"同一目标三种做法"的题目特别适合练手。
6. 从排列出发再往前一步
写到这里,这道题的核心内容已经讲完了。最后再给你一个可扩展的方向:如果你把三种做法都吃透,可以试着把"多数组"的维度继续拉高,尝试"多个有序数组的k路归并排序",或者用栈实现"外部排序"的基本框架。我在实际练习中就是这样一步步把排序这个概念从"会写"变成了"能讲"。
如果你刚接触这道题,建议先自己动手把三种实现各写一遍,写完后再对照我这里的代码和踩坑记录检查。不用追求一遍完美,重要的是把"为什么这样做"想清楚——在我看来,这比代码本身更值12分。