很多人觉得 AI 时代嘛,不懂排序也能靠现成函数干活,但真正上手做推荐、做搜索、做大模型应用时才发现,排序几乎是无处不在的“隐形基础设施”。我经常在面试和带新人时强调一个观点:语言和框架可以很快换,算法思维才是真正值钱的部分,而排序算法又是算法思维里最适合当切入点的内容。它不要求高深的数学背景,却能把数据结构、复杂度分析、边界条件、工程取舍全部串起来。这篇文章我就把自己在刷题、做系统和带新人过程中积累的排序经验写出来,照着练,比单纯背代码有用得多。
1. 为什么 AI 时代反而要回头啃排序
1.1 从 LLM 应用看排序无处不在
很多学 AI 的同学一开始会有一个错觉:现在大模型都能直接生成答案了,底层排序算法是不是就退出历史舞台了?实际恰恰相反。一个典型的 RAG 应用也好,Agent 任务规划也好,大量工作都在做“排序”:检索回来的文档要按相关性排序,多个工具调用的候选结果要按可靠性排序,甚至对话历史里的上下文片段也经常要按时间或重要程度重新排列。就算模型本身能处理语义,程序层面仍然需要一个明确、稳定、可预期的排序逻辑来控制数据的流动顺序。
再往底层看,训练数据清洗、特征工程、评估指标计算,也都离不开排序。大模型评测里常用到的 Recall@K、NDCG 这类指标,核心就是“先排序,再看前 K 个里命中了多少”。如果连排序都写不好,指标算出来也是错的。所以 AI 时代不是不需要排序,而是排序成为了更关键的中间层。掌握排序的人,看系统结构会比别人清晰很多。
1.2 排序能帮你建立真正的复杂度直觉
我认为算法思维里最值钱的东西不是会背某段代码,而是能一眼判断一套逻辑在数据量放大后是否还能跑得动。排序正好是训练这种直觉的绝佳材料。
同一个排序需求,用插入排序和用快速排序,在 10 个元素时几乎看不出差别,但数据量到 100 万时就会差出几千倍。这种“量级感”如果不亲手算一算、不亲自压一次数据,很难真正长在脑子里。我见过不少工程师写代码时只顾功能不管数据规模,最后线上数据一涨,接口直接超时,查下来往往就是某个冒泡式的双重循环在作怪。学排序的时候多想一想“最坏情况”“平均情况”“额外空间”,以后设计任何系统都会下意识去估算复杂度,这就是算法思维带来的复利。
1.3 所谓大经典,到底应该抓住什么主线
现在一提到经典排序,很多人会说是“八大排序”或“十大经典排序”。其实数量不重要,重要的是背后几条主线。按我的理解,可以分成四条主线:第一组是冒泡、选择、插入,适合理解最朴素的比较排序;第二组是希尔、归并、快排,核心是“减少比较次数”或“分而治之”;第三组是堆排序,本质是一种基于优先队列的选择排序优化;第四组是计数、桶、基数,它们跳出了“比较”这个框架,用空间和分布换时间。
把这些串起来后,你再看各种排序需求时,脑子里浮现的就不是一个个孤立的算法名,而是一棵决策树:数据量多大、数值范围多集中、是否需要稳定、内存够不够、能不能利用多线程。下面我先放一张速查表,适合放在手边随时看。
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 额外空间 | 稳定性 | 主要特点 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 实现简单,适合小数据 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 交换次数少 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 对接近有序的数据极快 |
| 希尔排序 | O(n^1.3~1.5) | O(n²) | O(1) | 不稳定 | 跨过远距离逆序 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 稳定且适合外部排序 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 工程应用最广 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | TopK 问题好手 |
| 计数排序 | O(n+k) | O(n+k) | O(k) | 稳定 | 只适合范围小的整数 |
| 桶排序 | O(n+k) | O(n²) | O(n+k) | 稳定 | 依赖数据分布均匀 |
| 基数排序 | O(d(n+k)) | O(d(n+k)) | O(n+k) | 稳定 | 按位分配,适合定长数据 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把六大比较类排序练到能默写的实操方法
2.1 冒泡、选择、插入:先写好最容易出错的三个边界
很多人觉得冒泡排序太基础不值得写,但真让他在白板上手写一次,能一次过的人并不占多数。冒泡的核心是每一轮把当前未排序区间里的最大值“冒”到最后,所以内层循环的结束条件必须是长度减一再减轮数。写错边界是新手最常见的,要么多比一次,要么漏掉最后的元素。
我建议不要只背写法,而是把三个算法放到一起对比。选择排序每一轮找最小值的下标,找到后才交换,它能把交换次数降到 O(n),不过因为“远距离交换”会把相同元素的相对顺序打乱,所以它不稳定。插入排序则是“从后往前挪位置”,像一个打扑克的人把新摸到的牌插进手里已经排好的牌中。它对“基本有序”的数据效率极高,这也是后面希尔排序能成立的前提。
这里给出一份极简的插入排序实现,我特别喜欢用它来热身:
python复制def insertion_sort(arr):
for i in range(1, len(arr)):
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
return arr
实际练习时,可以用一组随机数和一组“几乎有序”的数据分别跑一遍,观察比较次数和交换次数。这种对比练习比单纯重复默写更能帮你在面试时快速判断“这个场景该用谁”。
2.2 缩短逆序距离:希尔排序的跳跃思想
希尔排序是插入排序的升级版,底层思想很朴素:如果每次只能交换相邻元素,那一个很小的元素要从最后面挪到最前面,需要经历很多轮。能不能先让元素大步跳跃,让整体先变得“大致有序”,再做一次精细的插入排序?这就是希尔排序的 gap 操作。
实现时,一般先取较大的间隔,对间隔相同的元素做插入排序,然后逐步缩小间隔,直到间隔为 1。间隔序列取法会直接影响性能,常见的有 n//2 不断减半,也有更好的 Sedgewick 序列。实际工程里很少单独使用希尔排序,但理解它对理解“逆序对”和“预排序”概念特别有价值。做 MySQL 的索引设计时,核心想法其实和它异曲同工:先通过某个粗粒度顺序把数据总体理顺,再在局部精排,减少随机访问成本。
python复制def shell_sort(arr):
n = len(arr)
gap = n // 2
while gap > 0:
for i in range(gap, n):
temp = arr[i]
j = i
while j >= gap and arr[j - gap] > temp:
arr[j] = arr[j - gap]
j -= gap
arr[j] = temp
gap //= 2
return arr
练习希尔排序时,真正要关注的不是背这个双层循环,而是体会“间隔从大到小”这个过程如何逐步消灭逆序对。每轮 gap 变小后,数据不会退回无序状态,这种性质叫“非降序保持”,它是理解这个算法正确性的关键。
2.3 快速排序的 partition:面试最高频的考点
快速排序是工程上应用最广的排序,原因是在绝大多数随机数据上,它的常数非常小,而且可以很方便地做原地排序。面试里考快排通常不只是考递归框架,而是考 partition 函数能不能写清楚。partition 的目标是把数组分成两部分,左边都小于等于基准值,右边都大于等于基准值,并返回基准值最终所在下标。
初学者最容易遇到的问题有两个。第一个是基准值的选择,如果每次选第一个元素,而数据接近有序,快排会退化成 O(n²),所以工程上常用“三数取中”或者随机选基准值。第二个是左右指针相遇后的边界判断,一个不小心就会死循环或者漏掉相等元素。
python复制def quick_sort(arr, left, right):
if left >= right:
return
i, j = left, right
pivot = arr[(left + right) // 2]
while i <= j:
while arr[i] < pivot:
i += 1
while arr[j] > pivot:
j -= 1
if i <= j:
arr[i], arr[j] = arr[j], arr[i]
i += 1
j -= 1
quick_sort(arr, left, j)
quick_sort(arr, i, right)
我建议你在本地用几组特殊数据去测快排:全相同元素、倒序数组、只有两个元素的数组。全相同元素是很多经典写法的“头号杀手”,如果用的是单边扫描写法,很容易出现两边极其不平衡的递归。上面的双边扫描写法对相同元素相对友好,但也要配合 i<=j 的判断才不容易出错。
2.4 归并排序:稳定和好理解是它的最大护城河
归并排序的思路很直观:先把数组从中间分成两半,分别排好,再合并起来。因为合并时每次从左右两半的头部取较小的元素,元素不会发生长距离跳跃,所以相同元素的相对顺序能够保留,这是它比快排和堆排序稳定的重要原因。
代码上最需要注意的点是“合并前先拷贝临时数组”。新手经常会在同一个原数组里来回覆盖,导致后面的比较失效。我这里给大家一个比较稳妥的写法,先利用切片创建 left 和 right,这种方式在讲解时更直观,但实际工程里通常会用临时数组以避免频繁分配内存。
python复制def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
归并排序还有一个特别强的工程优势:它适合处理无法全部装入内存的大文件。外部排序的基本套路就是把大文件切成能放进内存的小块,分别排好写回磁盘,再用类似归并的方式多路合并。大数据场景里经常听到的“多路归并”,思想源头就在这里。所以别小看这个看起来“笨”的稳定排序,它才是大量严肃系统背后的主力。
3. 堆排序与三类线性排序的核心场景
3.1 堆排序:一切 TopK 问题的灵魂
堆排序从算法分类上看也是比较排序,但它和前面几种不太一样,它不是每轮把最小值单调地“选”出来,而是先把数组看成一棵完全二叉树,再调整成堆。最大堆的堆顶永远是全局最大值,把它和堆尾交换后,堆规模减一,然后调整剩余部分,反复执行就能得到升序序列。
很多实际场景并不需要完整排序,只想知道“最大的 K 个元素”。如果直接把序列完整排一遍,复杂度是 O(n log n),但如果用大小为 K 的小顶堆去扫描一遍数据,复杂度可以降到 O(n log K)。在大数据量下这个差距非常可观。C++ 里求一个 vector 最小的十个元素,我一般会直接用 std::partial_sort 或 std::nth_element,它们底层就是“堆”和“快选”的思路,而不是把整个数组排完。
以下是堆排序的参考实现,维护堆的时候记得使用从 1 开始计数会更方便,但数组下标是 0 基,必须处理好左右孩子的下标变换:
python复制def heapify(arr, n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and arr[left] > arr[largest]:
largest = left
if right < n and arr[right] > arr[largest]:
largest = right
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)
def heap_sort(arr):
n = len(arr)
for i in range(n // 2 - 1, -1, -1):
heapify(arr, n, i)
for i in range(n - 1, 0, -1):
arr[i], arr[0] = arr[0], arr[i]
heapify(arr, i, 0)
return arr
很多人在不动手写堆时觉得它抽象,其实只要记住一个直觉:堆排序就是一群候选人不断 PK,每轮把冠军放到最后,再让剩下的人重新 PK。理解这个“淘汰-补位”过程后,堆的相关题目就容易多了。
3.2 计数排序:看似简单,范围一大就崩
计数排序是非比较排序里最好理解的一种。既然数值范围有限,我就直接用数组下标记录每个值出现了多少次,最后按下标顺序输出。它的时间复杂度是 O(n+k),k 是数据范围,看起来非常理想,但代价是额外的计数数组空间。如果数据范围是 0 到 100 万,而实际只有 10 个元素,用它就非常浪费;如果数据跨度极大,比如从 -20 亿到 20 亿,那直接就没法用。
实际用它时还要注意负数。一个常用技巧是先遍历一遍找到最小值,然后给每个元素加上偏移使下标非负,输出时再减回去。顺序输出后,计数排序是稳定的,前提是你在输出阶段使用“累加位置数组 + 从后向前填充”的写法。下面这个版本直接统计后重建数组,简单但牺牲了稳定性:
python复制def counting_sort(arr):
if not arr:
return arr
min_val, max_val = min(arr), max(arr)
count = [0] * (max_val - min_val + 1)
for x in arr:
count[x - min_val] += 1
res = []
for i, cnt in enumerate(count):
res.extend([i + min_val] * cnt)
return res
如果面试时问“什么时候用计数排序”,标准回答是:数据量 n 很大,但数值范围 k 很小,并且数据是整数,比如统计几百万个学生的年龄。年龄范围只有 0 到 150,这种场景计数排序几乎无敌。
3.3 桶排序与基数排序:分布式思想的启蒙
桶排序是把数据按区间放进若干个“桶”,桶内再做排序,最后按桶顺序依次取出。它有点像学生按分数段分班,每个班内部排座次。桶排序要发挥威力,前提是数据“分布均匀”。如果所有数据都挤进同一个桶,性能就会急剧恶化,变成 O(n²)。在真实系统中,桶排序能作为一种“分而治之”的思路,比如 MapReduce 环境下的二次排序,经常就是先按 key 分桶,再对桶内排序。
基数排序则更像“按位逐次排序”。以整数排序为例,先从最低位开始,按照这一位的数字分到 0-9 的桶里,依次收集;然后处理下一位,重复直到最高位。只要每轮都是稳定排序,最终结果就是有序的。字符串排序中常见的那种“先按首字母分组,再按第二个字母排序”的思路,本质上也类似基数排序。这类非比较排序在数据库索引、大数据的 bucket 聚合、IP 地址排序等场景中依然很常见。
4. 工程里的隐形排序:数据库、容器、脚本案例
4.1 MySQL 排序不一定是真的在排序
SQL 里写 ORDER BY 很快,但很多人没想过数据库内部到底做了什么。MySQL 执行排序时有两种主要方式:如果查询能利用索引顺序,那数据本身就是有序的,执行计划里看不到 Filesort;如果无法利用索引,MySQL 就会把需要排序的行读入内存或临时表,再执行排序算法,这个操作在慢查询日志里会显示为 Using filesort,这也是 DBA 常常提醒要优化的点。
举个例子,你有一个联合索引 (category, create_time),查询条件 WHERE category = 1 ORDER BY create_time DESC 时,因为 category 已经定位到等值,create_time 在索引内部又是有序的,所以可以直接按索引反向扫描,不需要额外排序。但如果你把 ORDER BY 的字段换成 price,而 price 不在索引里,那就要回表读数据再排序。这个问题在数据量小的时候无所谓,一旦数据量过千万,代价立马显现。
我见过很多性能问题,都不是 SQL 写错了,而是没有意识到“排序”这一步在底层有多贵。排查的时候第一步先看执行计划,如果看到 Using filesort,再结合数据量判断是否真的需要优化。能用索引避免排序,就不要让数据库去临时排序。
4.2 Java 和 C++ 容器排序的常见姿势
Java 里对 HashMap 排序是一个很经典的面试题。HashMap 本身没有顺序,但你可以把 entrySet 转成 List,然后用 Collections.sort 或者 Stream 的 sorted 对它排序。如果按 value 排序,需要自定义 comparator。一个小技巧是,如果 value 是 int 或 long,不要直接相减再返回,因为减法可能溢出。更好的写法是使用 Integer.compare(a, b) 或 Comparator.comparingInt。
C++ 里排序也有类似讲究。问题“不排序的情况下取得一个 vector 中最小的十个元素”,最直接的思路是排序后取前十个,复杂度 O(n log n),但更好的做法是用 std::partial_sort 或 std::nth_element。前者会保证前十个元素有序,后者只保证第 n 个元素就位,但不保证前十个内部的顺序。如果你需要完整的最小十个有序元素,用 partial_sort;如果只是想知道第 K 小的值,用 nth_element 更快。
cpp复制#include <algorithm>
#include <vector>
std::vector<int> v = /* ... */;
std::partial_sort(v.begin(), v.begin() + 10, v.end());
// 此时 v[0..9] 就是最小的十个元素,并且有序
这类细节在面试里特别能体现工程经验,因为大多数人只会背“最小用堆”,落到具体语言的 API 时反而不知道有这些高效工具。掌握它们,不仅仅是让代码更短,更重要的是让代码能在更大数据量下仍然稳定。
4.3 Linux 下按目录大小排序的小命令
后端排查磁盘空间时,经常需要看某个目录下所有子目录的大小,并按大小排序。很多人会直接执行 du -sh *,但这样输出顺序是随机的,文件多时根本看不出来哪个目录最占空间。正确的做法是用管道把结果交给 sort -hr,这里的 -h 能识别 K、M、G 单位,-r 是倒序。
bash复制du -sh /path/to/* | sort -hr
这段命令看起来非常简单,但背后同样包含排序。sort 默认按字典序排,所以一个 1G 的目录可能排在 100M 前面,这是典型的“字符串排序”和“数值排序”混用踩坑现场。加 -h 后,排序工具会解析人类可读的单位,才能真正按大小递减排出来。日常工作里这种“隐形排序”太多了,我始终觉得,只有做过底层工具排查的人,才会真正理解排序不是一个抽象概念,而是每天都在和效率打交道。
4.4 前端表格排序与拖拽排序为什么要注意稳定性
前端表格点击表头排序时,也经常会有稳定性的坑。比如一个表格可以同时按“分类”和“价格”排序,用户先点击“分类”列,再点击“价格”列时,通常期望价格相同的情况下保留前一个分类顺序。如果排序算法不稳定,第二次排序后相同价格的数据顺序可能被打乱,用户就会觉得表格“跳来跳去”。
实现排序时,比较稳妥的做法是把多个排序字段合并成一个比较器。例如指定主排序字段和次排序字段,在比较器里先比较主字段,主字段相等再比较次字段。而不是把数组先按分类排序,再整体按价格排序,后者在不稳定排序下容易造成非预期结果。拖拽排序则要额外注意下标映射,数据项在拖拽后可能会触发多次 re-render,最好在拖拽结束时一次性更新数据顺序,避免在拖拽过程中频繁重排造成卡顿。
5. 现场排错实录:排序题和工程里最容易翻车的五类问题
5.1 比较器写得前后矛盾,排序结果全看心情
不管是 Java 还是 Python,自定义排序时最怕比较器本身不一致。所谓不一致,就是 compare(a,b) < 0 和 compare(b,a) > 0 不同时成立,或者相等时返回不一致结果。这种 bug 在数据量小的时候可能测不出来,但排序算法在不同数据分布下会异常,甚至直接抛异常。
Java 里规范要求如果 compare(a,b) 抛出异常或符号不一致,排序结果就是未定义的。有些 TreeMap 甚至因为这个原因丢掉数据。写比较器时要记住反身性、对称性、传递性三个规则,复杂对象排序时不要自己一个个 if 去比较,尽量用现成的 Comparator.comparing(...).thenComparing(...) 链式写法,既能减少手写错误,代码也更清晰。
5.2 reverse=True 把两个排序字段一起反转了
Python 的 sorted 特别强大,但使用 reverse=True 配合多字段 key 时经常会翻车。原因很简单,reverse=True 是对整个 key 结果做反转,而不是对每个字段单独反转。比如你想先按分数降序,如果分数相同按 id 升序,直接写成 sorted(students, key=lambda x: (x.score, x.id), reverse=True) 会变成 id 也降序。
有几种解法:第一,把 score 取负,id 保持为正,然后 reverse=False;第二,分两步排序,先按 id 升序排,再按 score 降序排,但前提是第二步排序算法稳定;第三,使用 key 函数返回复合对象时,对其中一个字段做反向转换,但字符串字段处理起来就比较麻烦。最常见的做法是分数取负。这个坑很容易被忽视,但在真实线上逻辑里,排序结果直接影响的可能是用户看到的内容顺序,错了会非常明显。
python复制students = [{"name": "a", "score": 90, "id": 1},
{"name": "b", "score": 90, "id": 2}]
students.sort(key=lambda x: (-x["score"], x["id"]))
5.3 递归深度的坑:快排为什么会栈溢出
快排和归并都用到了递归,但递归深度可能差别很大。归并排序每次二分,递归深度稳定在 O(log n)。快速排序如果每次 partition 都能把数组分成接近一半,深度也是 O(log n),但一旦基准值选得很差,比如数组已经有序且每次选第一个元素,递归深度会退化成 O(n)。当 n 大于系统栈限制时,程序直接栈溢出。
Python 里你可以用 sys.setrecursionlimit 调高递归限制,但这不是根本解决办法。更稳妥的做法是使用迭代版本,或者对快排采用随机基准值、三数取中等措施,避免最坏情况。另一个方法是只对较短的子数组递归,较长的子数组用循环处理,这样可以把递归深度控制在 O(log n) 级别。面试时如果让你写快排,一定要能解释清楚这个隐患,并说明如何避免,这才体现出“算法思维”而不只是背代码。
5.4 字符串与中文排序,结果和你以为的不一样
很多语言的默认字符串排序是基于 Unicode 码点或 ASCII 码的,纯字母数字没问题,但遇到中文就未必符合日常“按拼音”的感受。比如 MySQL 中 utf8mb4 默认排序规则对中文字符的处理取决于 collation;Java 里直接 String.compareTo 比较中文是按 Unicode 编码,不是按拼音或笔画;Linux sort 默认按字节序,中文环境中经常得不到想要的拼音排序效果。
如果产品需求就是中文按拼音排序,你需要明确使用拼音排序库,或依赖数据库的 collate 规则。解决这个问题时,最怕测试用例只放一个“啊”字和一个“不”字,看起来没问题,放到真实名称上却乱掉。更靠谱的思路是在一列里同时存储拼音首字母或拼音全拼字段,查询排序时用这个冗余字段,性能比实时计算拼音好得多,这也是很多业务系统的标准做法。
5.5 “奇数在前,偶数在后”这类题目到底想考什么
网上有个常见题叫“整数奇偶排序”,要求把奇数放前面、偶数放后面,但内部可能还要各自排序。这类题看起来简单,实际上是想考察 partition 思想和稳定性。如果只要求分离奇偶,不要求保持原来的相对顺序,那可以用双指针交换,类似快排的 partition。如果要求“奇数部分稳定在原相对顺序,偶数部分稳定在原相对顺序”,就不能简单交换了,必须用稳定 partition 或者把奇偶作为 key 做一次稳定排序。
很多工程上的排序需求并不完全是最小到最大,而是“把满足条件 A 的放在前面,满足条件 B 的看情况排在后面”。这种场景同样可以建模成带 key 的稳定排序,也可以用 partition 思路实现更高效的手动迁移。理解了这一点,“奇偶排序”就不再是死记硬背的问题,而是一个可以迁移到业务规则排序的通用方法。
6. 我的一点个人体会:排序学到什么程度才算“会了”
我第一次认真啃排序算法是大三准备面试的时候,当时以为自己把八大排序的代码背下来就是会了。直到后来在真实项目里遇到 filesort 导致慢查询,才意识到排序不是“输出一个有序数组”这么简单。这里说的“会”,包含三层意思:能不看资料写出核心代码,能解释每种算法的时间、空间、稳定性,以及能根据数据特征选出最合适的方案。缺一层,到现场都可能露馅。
这些年我带新人也发现一个规律:能把排序讲清楚的人,写其他复杂算法普遍比较稳。因为排序里包含了太多算法设计的通用思想,比如分治、递归、堆、哈希分布、空间换时间、稳定性权衡。你不需要把每个排序都背到肌肉记忆,但最好每个都亲手实现过至少一遍,并且能用测试数据验证它的边界。比如插入排序遇到全逆序数组、快排遇到全相同数组、计数排序遇到负数,这些特殊场景才能暴露出对算法本质的理解是否到位。
最后分享一个小技巧,我至今仍在用:学习任何算法时都准备一份“反例集”,专门挑那些能让标准写法失效的数据。对排序来说,反例集至少包含空数组、单元素数组、全相同数组、已排序数组、倒序数组、包含负数和大数的数组。每次写完一个排序,先跑这组数据再谈掌握。这种不满足于“能跑通”的习惯,在 AI 时代越发珍贵,因为模型会帮你写出看起来很对的代码,但只有人能判断它在极端输入下是不是真的稳。
