资讯详情

资讯详情

从命令行到MIPS汇编,彻底搞懂SORT排序的底层本质

如果你在大学里学过计算机相关课程或者工作中写过几年代码多半会对“SORT”这三个字母产生一种“我早就懂了”的错觉。可真正拿起键盘的时候你会发现有人用命令行 sort 处理海量数据有人在 C 里用 std::sort 给结构体排成绩表还有人在 MIPS MARS 里对着一段 #sort 汇编代码反复检查 .data 区域的数组初始化甚至因为数组下标忘乘 4 而卡住半小时。SORT 表面上是一个排序动作底子却牵扯到函数设计、数据组织、内存布局和算法复杂度。这篇内容就从“SORT”这个关键词铺开把我实际用过的命令排序、结构体排序、MIPS 汇编排序一次讲透特别是 MARS 环境里那段 .word 3,10,8,2,5,2,3 的朴素数组我会带你把每一行指令为什么这么写都理清楚。适合正在做汇编课设、准备软件笔试或者想搞懂 sort 底层逻辑的人。1. 先把“SORT”拆开命令、函数、算法和汇编标签1.1 一个符号背后的四层含义SORT 之所以让人困惑多半因为这个词在不同的语境里背了完全不同身份。第一层身份是 Unix/Linux 的命令行工具比如echo 3,10,8 | tr , \n | sort -n它按行读入数据然后根据参数做字典序或数值排序第二层身份是编程语言里的标准库函数比如 C 的 qsort、C 的 std::sort、Python 的 list.sort这些函数是别人已经写好的排序能力第三层身份是算法本身冒泡、选择、插入、快速、归并全部都属于 SORT 的研究范围第四层身份则是你在 MARS 里敲的sort:标签或者代码块中的#sort注释它是一个让学生模拟底层执行过程的自定义手动排序函数。我在带新人的时候更喜欢把这四层放在一起讲。很多人一上来就背“快速排序最优”却连 shell 里的 sort 默认按字典序排都不知道导致对数字排序时出现 10 排在 2 前面的诡异结果。实际上SORT 的第一原则从来不是“排多快”而是“排序目标到底是什么”。想清楚这个问题再去选命令、选函数、选算法才不会南辕北辙。1.2 什么时候该自己写什么时候该用现成的一句话回答工程代码里优先用现成实现课程设计和自定义复杂对象时才考虑自己写底层的排列逻辑。现成函数经过多年优化比如 C 标准库的 std::sort通常采用内省排序在数据接近有序、完全逆序、数据量极大等不同情况下都有对应的分支策略比普通开发者临时写的冒泡排序稳得多。可如果每个人只需要调用现成函数为什么 MARS 的题目里还要出现 #sort答案在“学习路径”上。你调用 std::sort底层是帮你把内存里的元素移动来移动去你在 MIPS 汇编写 sort每条指令都要自己控制寄存器、计算地址、处理跳转稍有遗漏程序就死循环。正是这种“自己搬砖”的过程让你理解了排序的关键不在循环嵌套而在于数组的“比较-交换”如何落到真实寄存器上。补一句我自己的建议别小看用 .data array 存七个整数的汇编题它一旦跑通你对指针、内存、地址和函数调用的理解会上一个台阶。2. sort函数排序结构体核心逻辑其实只有一个点2.1 用 qsort、std::sort 给结构体按成绩排序“sort 函数排序结构体”大概是考试和工作中出现频率最高的需求比如一个保存学生信息的结构体数组里面有姓名、学号、成绩现在想按成绩从高到低排。结构体不是 int 或 float 这种基本类型编译器没办法天然知道“哪个结构体算大、哪个算小”所以编程语言的 sort 函数都会提供一个扩展点让调用者写一个比较规则。C 语言里用的是 qsort它的第五个参数是函数指针。下面是我常用的写法#include stdio.h #include stdlib.h #include string.h typedef struct { char name[32]; int score; } Student; int cmp_desc(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; return (sb-score sa-score) ? 1 : -1; } int main() { Student arr[5] { {Tom, 88}, {Jerry, 95}, {Alice, 76}, {Bob, 95}, {Max, 83} }; int n 5; qsort(arr, n, sizeof(Student), cmp_desc); for (int i 0; i n; i) { printf(%s %d\n, arr[i].name, arr[i].score); } return 0; }这里最关键的问题是为什么比较函数返回的是 1 或 -1而不是直接返回两个分数的差值如果我用return sa-score - sb-score确实也能排序但一旦成绩很大超过了 int 的表示范围比如一个 20 亿、一个 -20 亿减法直接溢出结果可能是正数变成负数排序顺序就反了。这也印证了一件事自己写比较函数时尽量把逻辑写完整不要因为偷懒使用减法。C 的 std::sort 也是同理只是比较器的返回类型从三态数字变成了 bool表达“前一个是否应该排在后一个前面”。#include algorithm #include vector struct Student { std::string name; int score; }; bool score_desc(const Student a, const Student b) { return a.score b.score; } int main() { std::vectorStudent arr { {Tom, 88}, {Jerry, 95}, {Alice, 76} }; std::sort(arr.begin(), arr.end(), score_desc); return 0; }如果你看到这里有点绕我提供一个生活化类比qsort 和 std::sort 就像快递分拣中心它只负责把包裹从 A 区搬到 B 区但不关心你按什么规则分类。你提供的比较函数贴在包裹上的分拣标签标签上写着“北京地区放南京前面”还是“成绩高的放前面”分拣机器并不知道它只管照着你的标签搬。因此出错的大概率不是机器而是标签本身写反了。2.2 Python、Java 后缀写法里的排序思维Python 的处理方式比 C 更贴近直觉比如有字典组成的列表想按 score 降序排序可以这样写arr [ {name: Tom, score: 88}, {name: Jerry, score: 95}, {name: Alice, score: 76}, ] arr.sort(keylambda x: x[score], reverseTrue) print(arr)注意区分arr.sort()和sorted(arr)前者是原地排序直接修改原列表大多数时候更省内存后者返回一个新的排序后列表原列表不变。对初学者来说我建议先搞清楚自己是否还需要原来的乱序如果后面还要做别的处理就使用 sorted。Java 里稍微啰嗦一点但思路一致students.sort((s1, s2) - Integer.compare(s2.getScore(), s1.getScore()));Java 的 Lambda 表达式说白了也是一个比较规则Integer.compare避免了手写减法带来的溢出风险。还有一个 Python 的小坑不少新人想先按成绩排成绩相同再按姓名排于是写两个 key 嵌套调用结果发现不稳定或顺序不对。更稳的方法是用 sorted 两次第二次排序的 key 等级更高arr.sort(keylambda x: x[name]) # 先按姓名排 arr.sort(keylambda x: x[score], reverseTrue) # 再按分数排2.3 比较函数最容易踩的三个坑第一个坑是返回方向和直觉相反。很多人认为“返回 1”等于把 a 放在前面但不同语言里的定义存在细微差别。C 的 qsort 要求cmp(a, b)返回负值表示 a 应排在 b 前正数表示 a 排在 b 后C 的std::sort返回 true 则表示 a 确实排在 b 前面。写错方向最直观的后果就是升序写成了降序或者反过来。第二个坑是比较函数不满足严格弱序。比如return a.score b.score当两个成绩相等时返回 true这破坏了等价关系可能导致 sort 越界或行为不稳定。严格弱序的要求是a 和 b 相等时比较函数的两个方向都必须返回 false也就是既不认为 a 应在 b 前也不认为 b 应在 a 前。听起来很数学实际写的时候记住“相等返回 false”就够。第三个坑是只关注元素内容忽略了结构体拷贝成本。qsort 在做交换时会按sizeof(Student)整块拷贝内存如果结构体里有超大数组或许多字符串字段搬运成本会很高。工程上遇到这种情况优先考虑对指针数组或者下标数组排序而不是把结构体本身拿来倒腾。总之比较函数是“给排序算法看的说明书”写它的时候要多想一步。3. MIPS MARS 下的 #sort从内存布局开始写排序3.1 为什么选“选择排序”而不是快速排序MARS 是 MIPS Assembler and Runtime Simulator 的缩写常被拿来跑教学用汇编程序。在汇编语境下代码里若有.data array: .word 3,10,8,2,5,2,3含义是开辟一段连续内存每个 word 占 4 字节分别存放 3、10、8、2、5、2、3。你的任务通常是在.text段写一个排序过程可以叫 sort也可以叫 #sort 注释下方的某个标签目的是把这个数组变成升序。我说说为什么推荐用选择排序来做实验。MIPS 汇编不能像 C 那样直接写复杂的 for 循环和临时变量交换必须手动管理寄存器。选择排序的逻辑最简单每一轮找到剩余元素中的最小值下标然后和当前轮起始位置交换。它内部的交换次数最多是 n-1 次比冒泡排序那种频繁交换更容易在汇编层面排查逻辑错误。虽然选择排序时间负责度稳定是 O(n^2)但对于 7 个整数来讲运行时间根本不是瓶颈你真正需要解决的是“地址计算”和“循环边界”这两个理解难点。3.2 能在 MARS 里直接运行的完整排序代码我把数组大小加载进 $a1再把数组首地址加载进 $a0然后调用 sort 过程。sort 内部变量较多需要保护调用者保存寄存器下面这段是我在 MARS 里调试通过的版本.data array: .word 3, 10, 8, 2, 5, 2, 3 size: .word 7 msg: .asciiz \nSorted array: space: .asciiz .text .globl main main: la $a0, array # $a0 array base address lw $a1, size # $a1 number of elements jal sort # print result la $a0, msg li $v0, 4 syscall la $s0, array # pointer to current element li $s1, 0 # loop index lw $s2, size # loop limit print_loop: bge $s1, $s2, print_done lw $a0, 0($s0) li $v0, 1 syscall la $a0, space li $v0, 4 syscall addi $s0, $s0, 4 addi $s1, $s1, 1 j print_loop print_done: li $v0, 10 syscall # Select sort in MIPS sort: addiu $sp, $sp, -24 sw $ra, 20($sp) sw $s0, 16($sp) sw $s1, 12($sp) sw $s2, 8($sp) sw $s3, 4($sp) sw $s4, 0($sp) move $s0, $a0 # base address move $s1, $a1 # n blez $s1, sort_done addi $s3, $s1, -1 # outer loop goes to n-2 li $s2, 0 # i 0 outer_loop: bge $s2, $s3, sort_done move $s4, $s2 # min_idx i addi $t0, $s2, 1 # j i 1 inner_loop: bge $t0, $s1, do_swap sll $t1, $t0, 2 addu $t1, $s0, $t1 lw $t2, 0($t1) # array[j] sll $t3, $s4, 2 addu $t3, $s0, $t3 lw $t4, 0($t3) # array[min_idx] slt $t5, $t2, $t4 beq $t5, $zero, not_smaller move $s4, $t0 # update min_idx not_smaller: addi $t0, $t0, 1 j inner_loop do_swap: beq $s4, $s2, no_swap sll $t1, $s4, 2 addu $t1, $s0, $t1 lw $t5, 0($t1) # temp array[min_idx] sll $t3, $s2, 2 addu $t3, $s0, $t3 lw $t6, 0($t3) # array[i] sw $t5, 0($t3) # array[i] temp sw $t6, 0($t1) # array[min_idx] old array[i] no_swap: addi $s2, $s2, 1 j outer_loop sort_done: lw $ra, 20($sp) lw $s0, 16($sp) lw $s1, 12($sp) lw $s2, 8($sp) lw $s3, 4($sp) lw $s4, 0($sp) addiu $sp, $sp, 24 jr $ra我刚开始调试这段代码时最头疼的地方不是循环本身而是寄存器分配。MIPS 里有些寄存器是临时性的调用其他函数后不保证还被保留比如 $t0、$t1有些寄存器则约定在当前过程返回前保持不变比如 $s0-$s7。sort 过程中同时用到了 i、n、min_idx 和 base address 四个不能随意丢失的量所以我选择了 $s0、$s1、$s2、$s3、$s4 这五个保存寄存器并在函数入口处压栈保护。这样即使外层 main 也在使用 $s0-$s2sort 返回也不会影响它们。使用jal sort压入返回地址 $ra 后sort 内部如果再嵌套调用其他过程$ra 就会被覆盖。我的排序没有再调用子函数但仍保存 $ra纯粹是为了让这个过程具备被其他函数调用时不破坏返回链路的能力。这就像出门前先锁门今天没小偷不代表你可以不关门。3.3 数组下标为什么要乘 4这才是整个汇编排序真正的重点。数组声明是.word一个字等于 4 字节所以 array[2] 的地址等于首地址 2 * 4。代码里对应写成sll $t1, $t0, 2 addu $t1, $s0, $t1 lw $t2, 0($t1)sll是逻辑左移左移两位等于乘 4。如果下标 j 存的是 3左移两位得到 12首地址加上 12 后再通过lw取回的数据才是 array[3]。很多人上来忘掉这一步直接让首地址加下标 j结果读到的不是整数而是从错误地址开始的一段字节拼接排序结果自然是乱七八糟。MIPS 使用“地址 offset”的方式来访问内存lw $t2, 0($t1)表示从 $t1 指向的内存地址读 4 字节。MARS 模拟器通常使用小端存储从低地址读到的字节会被放到寄存器低位还是高位底层已经处理好了你不需要像手工计算十六进制那样逐字节拼接。只要下标乘 4 没错核心十有八九就不会错。另外MARS 里的数组并不是一排连续存放的“数格子”而是一段线性地址空间。理解这一点再回头看 C 语言的指针你会发现自己突然明白了为什么数组名可以当指针用为什么 p i 等价于 array[i]。MIPS 的地址偏移就是指针运算最不加修饰的样子。4. 排序参数的取舍复杂度、稳定性与数据规模4.1 时间复杂度不是唯一答案很多人一提到算法就只背一个“快排 O(n log n)”但工程上选择排序策略时我通常会把四个维度摆在一起看规模、初始有序程度、内存开销、以及元素的移动代价。MIPS 里我选择选择排序是因为它能在代码复杂度、交换次数和寄存器占用之间取得平衡。可如果你在数据库里对上千万行数据执行 ORDER BY引擎就不会用选择排序而会考虑归并或快排因为排序不再局限于内存还牵涉外部排序和 I/O 成本。拿排序函数横向比一比实现方式平均时间复杂度最坏时间复杂度是否稳定主要开销C 的 qsortO(n log n)O(n^2)不稳定实现有关函数指针多次调用C std::sortO(n log n)O(n log n)不稳定可能使用递归栈Python list.sortO(n log n)O(n log n)稳定Timsort 利用部分有序选择排序O(n^2)O(n^2)不稳定比较次数固定要注意 std::sort 并不是一种严格算法而是一个算法策略容器通常在内省排序、插入排序、堆排序之间切换。Python 排序也不简单Timsort 会优先寻找已经有序或逆序的连续片段然后合并这些片段因此对“大部分已经有序”的数据表现极好。这说明排序函数内部的优化逻辑比你想的要精致得多。4.2 稳定性的实际影响稳定性就是指值相等的元素排完之后它们的相对顺序有没有改变。比如你先把学生按姓名排好再用 sort 按成绩排如果算法稳定相同成绩的学生会保留原来按姓名排列的顺序如果算法不稳定相同成绩的学生顺序可能被打乱。课堂练习通常不关注稳定性但业务场景里很常见。举一个我实际遇到的例子商品列表需要先按销量降序再按上架时间降序如果不稳定就会导致销量相同的商品内部时间顺序混乱。大多数稳定排序用归并思想实现Python 的 list.sort 和 Java 对对象数组的排序都做到了稳定。C 的 qsort 和 C 的 std::sort 则通常不稳定文档里都不会承诺稳定。这个并不是缺陷而是为了速度付出的代价。如果你在两个相等元素间非常关心原始顺序就要看语言文档而不是漫无目的地猜。4.3 数据规模不同选择完全不同有一个经典例子插入排序虽然平均复杂度是 O(n^2)但当数据规模小于 16 或 32 时它的常数因子很小实际跑起来比快速排序还快。这也是为什么很多标准库在递归到小规模子数组时会切到插入排序比如 Java 的排序实现里就有类似阈值。真正的高手不会只看复杂度符号还看常数因子、缓存访问和元素拷贝成本。做 MIPS 汇编题时.data 里通常只有几个整数用选择排序就够了。但如果我在 MARS 里给一个 10000 个元素的数组排序还继续用选择排序就会感觉到肉眼可见的卡顿因为 O(n^2) 和 O(n log n) 差别是指数级的。普通笔记本处理 7 个元素的差异你根本感知不到可一旦数据规模翻到 10000选择排序的 5000 万次操作就会暴露出来。排序学习里还有一个被低估的技巧在动手实现前先根据数据规模做个小估算心里有数很多优化方案自然而然就能想明白。5. 常见问题与排查技巧实录5.1 MARS 排序常见报错与处理MARS 里运行上面的排序代码时我见过最多的问题集中在四类第一程序输出空或者输出完成后直接弹“address out of range”。原因通常是外层循环边界条件错了比如 i 到达 n 时仍然继续执行内部循环j 被赋成 n1sll 后地址已经越过数组末尾。修复办法是把外层循环退出条件设置成 i n-1而不是 i n内层循环再设置 j n双保险。第二打印结果出现巨大数字而且原始数组顺序没变化。这个问题大多数由“下标没有乘 4”导致代码里用 sll 左移两位或者手动 addu 地址时漏掉偏移。第三使用 syscall 打印数组时少读了元素或读错顺序。我建议打印循环单独用一个指针每打印一次指针加 4而不是用基地址加下标再手动算地址这样可以让流程更直白。指针偏移虽然也是地址运算但循环体里一眼就能看到不容易错。第四在 sort 内部调用别的函数后原本保存 i 或 min_idx 的 $s4 被函数改写。这属于最隐蔽的寄存器风险。比如说如果我在排序内为了调试临时加了一个jal print_helperprint_helper 内部使用了 $s4 却没有保存sort 的后续逻辑就会凭空变异。MIPS 约定被调用者负责保存 $s0-$s7 和 $ra谁改动谁负责保存这是不变法则。5.2 结构体排序的经典翻车现场结构体排序的翻车现场我见得太多了。最常发生的是 C 语言里用 qsort 时忘了把比较函数参数从const void *转回Student *结果 void 指针直接做成员访问编译直接报错。“不能从 void* 推导成员”只是编译器提醒你你需要告诉它“这块内存其实是结构体”。转换之后就没事了。第二个高发问题是排序顺序和预期相反。假如我期望成绩降序在 C 的 std::sort 里写bool asc(const Student a, const Student b) { return a.score b.score; } std::sort(arr.begin(), arr.end(), asc);只有当函数返回 true 时才表示 a 排在 b 前面。想降序就把比较符改成。很多新手被英语文档里的 “ascending/descending” 搞反但本质只需要自己脑子模拟一次a 是当前对象b 是后面对象函数返回 true 表示 a 留下不换 b。第三个翻车是结构体里包含价格、性能等浮点字段比较时直接相减返回。浮点减法在精度上会给出很微小的误差如果两个数本该相等但减法返回了一个负数排序就可能认为它们顺序需要互换进而频繁交换甚至造成排列不稳定。最稳的做法是明确写if (a.price b.price) return -1; if (a.price b.price) return 1; return 0;把判断写得直白些比任何所谓“简洁技巧”都可靠。5.3 一个值得长期保留的调试习惯最后分享一个我从汇编调试里学到的习惯输出中间状态而不是只盯着最终结果。写 MIPS 排序时我会先让代码只比较不交换然后打印每一轮的当前数组确认 min_idx 是否找对再打开交换逻辑打印每一轮交换后的数组确认交换的两个下标对不对。这个过程看似麻烦实际上能很快定位是循环边界出错还是地址偏移出错还是寄存器破坏。结构体排序也一样。如果你用 Python可以在排序前打印一份原始顺序再用 key 加一个小人工“批次号”验证稳定性如果你用 C/C可以单独写一个只有 3 个元素的测试用例手动模拟一遍比较器的返回值再跑到 1000 个元素的完整数据集。我发现很多“玄学”排序问题其实都出在最小用例上没验证。你把这套习惯坚持下来后面处理复杂业务逻辑时也会受益很多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →