面试百题里那道“斐波那契数列到底该用递归还是循环”的题,为什么答案从来不只是代码能跑,而是要看递归版的时间复杂度是O(2^n)、循环版是O(n)这种数量级的差距?因为数据结构这门课里最容易被低估的两把尺子,就是时间复杂度与空间复杂度。它们在衡量一个算法“够不够好”这件事上,比任何花哨的优化技巧都要硬核。
这篇内容要解决的,就是让你从“会写代码”进阶到“能看懂算法效率”:先搞懂复杂度的本质到底在度量什么,再拿出完整套路把普通代码、循环嵌套、递归三种场景的复杂度一步步算出来,然后把最容易踩坑的空间复杂度讲透,接着用排序算法这道巨型综合体把所有知识点串起来,最后给出面试高频追问方向和自查清单。不管你是刚学数据结构的本科生、准备考研或大厂面试的应届生,还是在补算法基础的在职开发,这套思路都能直接拿去用。
1. 复杂度的本质:算法效率不是“跑得有多快”,而是“增长得有多慢”
1.1 为什么不能用秒表来评价算法
有个很反直觉的事实:同一个算法,在不同的机器上跑出来的耗时完全不同。五年前的笔记本和现在的 M 系列芯片跑同一个排序,时间差个三倍五倍很正常;同样的机器,用 Python 和用 C 语言跑同一个二分查找,也可能差出几十倍。如果你用“耗时几毫秒”来评价一个算法的好坏,那评价结果只适用于当时那一台机器、那一个编译器、那一个数据规模,换个环境结论就废了。
复杂度分析做的事情,是把这些和算法本身无关的变量全部丢掉,只留下一个东西:当输入规模 n 增大的时候,这个算法的操作次数跟着怎么变。这里的 n 通常指数据量,比如数组的长度、矩阵的边长、字符串的字符数。复杂度不关心你具体执行了多少条指令,只关心操作次数关于 n 的“增长趋势”。
这就好比你评价一个人的跑步水平,不该说他百米跑了多少秒(因为风速、跑道、鞋都有影响),而该说他是不是一个“耐力型选手”——距离从 1 公里加到 10 公里,他是越跑越慢还是能稳定配速。复杂度就是算法的“耐力标签”。
1.2 大 O 表示法到底在说什么
大 O 表示法用 O(f(n)) 来描述复杂度的上界,读作“big O of f(n)”。它只保留增长趋势里最主导的那一项,把常数系数和低阶项全部忽略。为什么可以忽略?因为当 n 足够大的时候,决定算法能不能扛住的核心因素只有那一项。
举个例子:某个算法的操作次数 T(n) = 3n² + 5n + 100。n = 10 的时候,3n² 是 300,5n 是 50,100 是常数,三项加起来 450,似乎 n 的一次项和常数项都有存在感。但 n = 1000 的时候,3n² 是 3000000,5n 是 5000,常数项 100 基本可以无视了。再到 n = 100000,n² 项更是碾压级别。所以这个算法的时间复杂度就是 O(n²),至于前面的系数 3,最终也会在数量级对比中被忽略。
大 O 表示法里常见的有这么几个档次,从好到差排列:
- O(1):常数时间,不管 n 多大,操作次数固定。比如数组按下标取值。
- O(log n):对数时间,数据翻倍后操作次数只增加一点点。比如二分查找。
- O(n):线性时间,数据和操作次数同比例增长。比如简单遍历。
- O(n log n):线性对数时间,常见于优秀的排序算法,比如归并排序、堆排序。
- O(n²):平方时间,双层循环的典型复杂度,冒泡排序、选择排序都在这一档。
- O(2^n):指数时间,n 稍微一大就跑不动了,比如朴素的递归斐波那契。
- O(n!):阶乘时间,n = 20 就已经是天文数字,基本只在教学题里出现。
记住一个直觉:O(1) 和 O(log n) 属于“几乎不随数据量增长而增长”,O(n) 属于“线性可接受”,O(n log n) 属于“数据量大了之后的主流天花板”,O(n²) 及以上就要开始警惕,n 上万之后会非常吃力。
1.3 为什么说复杂度是数据结构的“选型标尺”
学数据结构最核心的一个能力,不是背下各种结构长什么样,而是“在合适的场景选合适的结构”。而选型的依据,就是复杂度。
数组支持 O(1) 的随机访问,但插入和删除是 O(n),因为要挪动元素;链表的插入和删除如果是已知节点位置就是 O(1),但按值查找是 O(n),因为它不支持跳跃访问;哈希表查找是平均 O(1),但它的空间开销相对更大,而且对哈希函数的质量敏感;平衡二叉树(比如红黑树)查找是 O(log n),好处是数据天然有序,可以范围查询。
同样是“存一组数”,四种结构各有各的复杂度画像。如果没有复杂度这把尺子,你只能凭“感觉”选容器,感觉这种东西在数据量小的时候几乎不会有问题,一到线上数据量暴涨,选错的代价就是接口超时、内存被打满、服务雪崩。
2. 时间复杂度实操计算:从一段代码到一条公式的完整套路
2.1 三个基本规则先记住
计算时间复杂度的过程,本质上是在数“基本操作的执行次数”。所谓基本操作,指的是赋值、比较、加减乘除、数组访问这类单次执行成本固定的语句。在实际分析中,不用每一行都数得特别精确,而是要抓住执行次数和 n 相关的那些语句。
第一条规则:常数项和系数不影响复杂度。每个循环体内部就算有 10 条语句,10 这个系数也要丢掉,最后只看数量级。
第二条规则:只保留最高阶项。如果代码里有三段串行执行的逻辑,分别贡献 O(n)、O(n²)、O(n log n),那整体复杂度就是 O(n²),因为 n 足够大时最高阶项是绝对主导。
第三条规则:嵌套循环用乘法,串行代码用加法。循环套循环,次数是各自循环次数的乘积;两段并列的循环,次数相加,最后再按规则一和规则二化简。
这三条规则并不复杂,但很多人在实际分析时会被杂乱的代码绕晕,原因就是没有先划清“哪些语句是主导语句”,而是试图把每条语句的次数都算一遍。
2.2 从最简单的单层循环开始
先看一个最平常的求和代码:
int sum = 0; for (int i = 0; i < n; i++) { sum += i; }这代码里跟 n 直接相关的是 for 循环的循环条件判断和 sum += i 这条语句。循环执行 n 次,所以总操作次数大约是 n 的某个常数倍,丢掉系数后时间复杂度是 O(n)。
再稍微变形一点:
for (int i = 0; i < n; i += 2) { printf("%d\n", i); }i 每次加 2,循环次数是 n / 2,系数 1/2 丢掉,还是 O(n)。同理,i += 3、i += 100 都不改变复杂度,只要 i 的增长是加一个常数,循环次数就和 n 保持线性关系。
但把 i += 2 换成 i *= 2,情况就完全变了:
for (int i = 1; i < n; i *= 2) { printf("%d\n", i); }i 的取值序列是 1、2、4、8、16……,循环条件 i < n。设循环执行 k 次后 i = 2^k,循环结束条件是 2^{k} ≥ n,所以 k ≈ log₂n。这就是为什么时间复杂度是 O(log n) 的来源——不是“感觉上快”,而是循环次数确实以对数的方式成长。
很多初学者一看到 for 循环就默认 O(n),这是不对的。循环变量怎么变化,比“有没有 for 关键字”重要得多。
2.3 双层循环怎么拆:以冒泡排序做教材
双层嵌套循环是面试里出现频率最高的复杂度分析场景,而冒泡排序是最经典的入门案例。下面是不带优化的原始版本:
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } } }外层循环 i 从 0 跑到 n - 2,内层循环 j 从 0 跑到 n - 2 - i。当 i = 0 时内层跑 n - 1 次,i = 1 时跑 n - 2 次,i = n - 2 时跑 1 次。总执行次数是 1 + 2 + ... + (n - 1) = n(n - 1)/2,展开后是 (n² - n)/2。去掉低阶项、去掉系数 1/2,结果就是 O(n²)。
这个案例值得反复看,因为它展示了两个细节:一是“内层循环次数随外层变量变化”时,要用等差数列求和而不是简单乘法;二是,即使最内层有交换和赋值多条语句,最后在数量级上完全没有影响。
面试时如果被问到“冒泡排序能不能优化到 O(n)”,标准答案是:加一个标志位,如果在某一轮遍历中没有发生任何交换,说明数组已经有序,可以提前退出。此时最好情况是 O(n),最坏情况依然是 O(n²)。这个优化不改变平均复杂度,但实际工程中收益明显。
2.4 递归复杂度:递推公式比肉眼观察更可靠
递归代码的复杂度没法直接数循环次数,因为执行次数藏在递归调用的层级和每层的分支数里。分析方法通常是写出递推关系式,然后求解。
典型的例子:二分查找的递归版本。每次调用只处理一半的数据,并且只产生一个递归调用,所以递推式是 T(n) = T(n/2) + O(1)。这里的 O(1) 代表每次递归里的比较和计算开销。展开这个式子:
T(n) = T(n/2) + 1 = T(n/4) + 1 + 1 = T(n/8) + 1 + 1 + 1 = ...
设展开 k 层之后 n 变成 1,此时 k = log₂n,累计的常数项也是 log₂n 个,所以 T(n) = O(log n)。
再比如归并排序的递归版本,每次把数组分成两半,对两半分别排序然后线性合并。递推式是 T(n) = 2T(n/2) + O(n)。展开:
T(n) = 2T(n/2) + n = 2(2T(n/4) + n/2) + n = 4T(n/4) + 2n = 8T(n/8) + 3n = ...
第 k 层有 2^k 个子问题,每个子问题规模是 n / 2^k,每层的合并开销总和约等于 n,总共有 log₂n 层,所以总复杂度是 O(n log n)。
遇到形式上更复杂的递推式,比如 T(n) = 3T(n/2) + n²,这种就需要用主定理(Master Theorem)。主定理处理的是形如 T(n) = aT(n/b) + f(n) 的递推式,比较 f(n) 和 n^{log_b(a)} 谁的增长速度更快,然后直接得出复杂度。虽然考试里出现过,但实际刷题时更常见的情况是“肉眼展开几层找规律”,所以我建议先把展开法练熟,再回头补主定理。
3. 空间复杂度:被忽略的隐形扣分点
3.1 空间复杂度到底在统计什么
空间复杂度描述的是一个算法在运行过程中“额外需要使用多少内存”,这里的额外通常指的是除了输入数据本身之外的开销。它和时间复杂度一样也使用大 O 表示法,核心关注点是内存占用随 n 增长的变化趋势。
常见的统计对象有四类:
- 局部变量:单个变量固定占 O(1),如果是一个长度为 n 的数组,就是 O(n)。
- 递归调用栈:递归每深入一层,系统就要为这一层保存参数、局部变量、返回地址,所以递归深度决定了这部分空间。
- 动态分配的内存:比如手动 new 出来的数组、哈希表、链表节点。
- 函数调用的临时空间:比如归并排序在合并阶段需要的辅助数组。
很容易踩坑的地方在于,很多人只计算显式声明的数组,忽略了递归栈的空间开销。一个递归深度为 n 的函数,就算内部只定义了常数个变量,空间复杂度也是 O(n),因为每一层调用都在栈上占着位置,直到递归终止才开始释放。
3.2 从 O(1) 到 O(n):三个典型场景
O(1) 空间:只使用固定数量的临时变量,和 n 无关。比如求数组最大值的算法,只需要一个 max 变量来记录最大值,遍历一遍数组,无论 n 是 100 还是 100 万,额外空间都是那一个变量,所以是 O(1)。常被称为“原地算法”的那些操作,比如原地反转数组,也属于这一类。
O(n) 空间:需要额外开辟一个和输入规模线性相关的空间。最典型的例子是归并排序,它的合并阶段需要一个和当前区间等长的辅助数组。虽然合并是一段一段进行的,但整个递归过程中辅助数组的最大长度和原数组等长,所以空间复杂度是 O(n)。另一个常见例子是哈希表去重:把 n 个元素存进哈希表,空间就是 O(n)。
O(n²) 空间:需要二维数组,比如邻接矩阵存图。一个 n 个顶点的图,邻接矩阵是 n × n,空间就是 O(n²)。这种场景往往在数据规模上给人沉重一击:n = 10000 的时候,n² 是 1 亿个元素,如果是 int 数组就是 400MB,一般内存直接扛不住。这也是为什么现实中的图算法几乎都是基于邻接表而不是邻接矩阵来做的——邻接表在稀疏图上的空间是 O(n + e),e 是边数,通常远小于 n²。
3.3 空间换时间是算法设计里的永恒权衡
有一类经典面试题,考的其实是空间和时间的权衡。比如“怎么把一个数组里的重复元素去掉并保持原有顺序”,朴素做法是两层循环,外层每个元素和内层已选出的元素比较,时间是 O(n²)、空间 O(1);用哈希表记录已经出现过的元素,时间是 O(n)、空间 O(n)。
面试官问这种题目,想看的往往不是你能不能写出来,而是你知不知道这两种方案都有各自的适用场景。如果 n 只有几百,O(n²) 反而因为代码简单、占用内存小,可能更合适;如果 n 是百万级,O(n²) 就完全不可接受,必须掏出哈希表。
类似的还有动态规划。很多 DP 问题的朴素版本需要 O(n²) 的二维数组,但仔细观察状态转移方程会发现,当前状态只依赖前一行的数据,于是可以把二维数组压缩成一维,空间从 O(n²) 降到 O(n),这就是滚动数组优化。典型如背包问题、最长公共子序列。这一招在笔试中非常加分,但前提是你真的理解了状态依赖关系,不能只背模板。
4. 排序算法复杂度全览:一道高频面试题串起整个知识网络
4.1 八大排序的时间、空间、稳定性汇总
排序算法几乎是数据结构课程里最“卷”的章节,因为考察点极其密集:每种算法的时间复杂度、空间复杂度、稳定性、适用场景,任何一个都可能被单独拎出来问。先把核心数据整理成一张可以随时翻的对照表:
| 排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 希尔排序 | O(n^1.3) | O(n log²n) | O(n²) | O(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
| 计数排序 | O(n + k) | O(n + k) | O(n + k) | O(k) | 稳定 |
希尔排序的时间复杂度比较特殊,因为它的复杂度依赖于增量序列的选择,表里写的是常见增量下的经验结果,不同教材给的数值不完全一致。计数排序属于非比较排序,k 代表数据范围,它不再遵循基于比较的排序下限 O(n log n),所以当 k 不大且 n 很大时非常占优势。
4.2 为什么快排平均 O(n log n) 却可能退化成 O(n²)
快速排序的原理是选一个基准值(pivot),把小于它的放左边、大于它的放右边,然后对左右两边递归排序。每次划分后,基准值就固定到了最终位置。理想情况下,每次基准值都能把区间均匀分成两半,递归深度是 log n,每层划分合计 O(n),所以总复杂度是 O(n log n)。
但基准值如果每次都选到当前区间的最小值或最大值,比如数组已经有序而你固定选第一个元素当基准,那么划分后一边是空的、一边是 n - 1 个元素,递归深度变成 n,每层划分依然是 O(n),总复杂度就变成 O(n²)。
这也解释了为什么工程上很少直接用固定位置的基准值,而是用“三数取中法”——取区间首、中、尾三个位置的元素,用它们的中位数当基准。加上递归深度超过一定阈值时改用插入排序,可以进一步避免因为递归过深导致栈溢出。快排在工程上的地位之所以那么高,是因为经过这些优化之后,它的最坏情况几乎不可能被触发,而它的常数系数又比堆排序、归并排序小,实际跑起来最快。
4.3 稳定性为什么会被单独考察
排序算法的稳定性指的是:如果两个元素的值相等,排序之后它们的相对顺序保持不变。稳定排序的价值在现实场景中非常明显。比如要对一个员工列表先按部门排序、再按薪资排序,如果第二次排序是稳定的,那么第一次按部门的排序结果就能保留下来;如果第二次排序不稳定,之前的结果就全乱了。
从实现角度来看,稳定性的来源各有不同。冒泡排序只在相邻逆序时才交换,相等的元素不会被交换,所以稳定;插入排序把新元素插到第一个比它大的元素前面,相等的元素不会被越过,所以稳定;归并排序合并时遇到相等元素先取左半部分,所以稳定。选择排序因为会跳着交换,相等的元素可能被换到后面去,所以不稳定;快排的交换过程同样会破坏相对顺序;堆排序在堆调整过程中长距离交换,也不稳定。
面试里经常结合一道题来问:链表排序应该用哪个算法?答案是归并排序,因为链表不支持随机访问,快排要频繁按下标定位,不方便;而归并排序只需要顺序遍历和指针操作,天然适配链表,而且稳定性也是要求的加分项。这种题考的正是“复杂度 + 数据结构特性 + 稳定性”的综合应用。
5. 复杂度实战拆解:三道典型题快速检验理解程度
5.1 题目一:二分查找为什么是 O(log n)
这是所有复杂度分析里最适合作为第一道例题的题目。给定一个有序数组和一个目标值,返回目标值的下标,不存在就返回 -1。标准写法:
def binary_search(nums, target): left, right = 0, len(nums) - 1 while left <= right: mid = (left + right) // 2 if nums[mid] == target: return mid elif nums[mid] < target: left = mid + 1 else: right = mid - 1 return -1每次循环,搜索区间都缩小一半。区间从 n 缩小到 1 需要的次数是 log₂n 次。时间复杂度 O(log n)。
空间复杂度要分版本:如果没有递归,只有 left、right、mid 三个局部变量,是 O(1);如果写成递归版本,递归深度是 log₂n,空间复杂度是 O(log n),因为每一层递归都要在栈上保存参数。
这个例子可以引申出一类思想:每次迭代把问题规模减少固定比例,复杂度就是 O(log n)。反过来,每次迭代只减少一个元素(比如线性查找),复杂度就是 O(n)。面试官还喜欢在这个基础上追问:在一个“先升后降”的数组中找峰值,能不能做到 O(log n)?如果你理解了二分的思想,会意识到峰值搜索只需要比较 mid 和它旁边元素的大小关系来决定往哪边收缩,同样能做到 O(log n)。
5.2 题目二:递归斐波那契的指数复杂度在哪里
斐波那契数列的朴素递归版本是很多人的第一道“递归恐惧”来源:
def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2)时间复杂度的关键在于递归树:fib(n) 会调用 fib(n - 1) 和 fib(n - 2),fib(n - 1) 又调用两个子问题,逐层展开后是一棵接近满二叉树的结构,节点数大约是 2^n 的数量级,所以时间复杂度是 O(2^n)。这里的指数增长不是夸张的说法,n = 50 的时候,普通笔记本上就已经跑不出结果了。
空间复杂度反而是 O(n),而不是 O(2^n)。原因在于递归调用栈的深度只和最长的一条路径有关,而最深的路径是从 fib(n) 一路走到 fib(1) 或 fib(0),深度是 n。同一时刻栈上只保留了这条路径上各层的函数帧,子问题返回后栈帧就释放了。这个例子经常被拿来考察“空间复杂度看深度不看节点数”这个易错点。
优化方案就是在递归里加缓存(Memoization),把已经算过的 fib(k) 存进字典,这样每个子问题只计算一次,时间复杂度直接降到 O(n),空间复杂度 O(n)。再进一步,既然只需要前两个值,可以用两个变量滚动更新,时间复杂度保持 O(n),空间降到 O(1)。三个版本放在一起对比,复杂度分析的威力就体现出来了。
5.3 题目三:合并两个有序数组,时间复杂度容易算,空间容易错
合并两个有序数组是很经典的归并思想入门题。输入两个长度分别为 m 和 n 的有序数组,输出一个合并后的有序数组。一种是另开一个新数组存结果,代码写起来很简单:
def merge(nums1, nums2): i = j = 0 res = [] while i < len(nums1) and j < len(nums2): if nums1[i] <= nums2[j]: res.append(nums1[i]) i += 1 else: res.append(nums2[j]) j += 1 res.extend(nums1[i:]) res.extend(nums2[j:]) return res时间上,两个数组每个元素都访问一次,总共 O(m + n);空间上,res 数组长度是 m + n,所以空间也是 O(m + n)。时间复杂度很容易算,但空间复杂度很多人会漏掉 res 这个额外的数组,直接答成 O(1),这是面试里一个很典型的失误。
LeetCode 88 题的变体是另一个高频题:nums1 的长度是 m + n,前 m 个元素是有效数据,后 n 个位置是占位的 0,要求把 nums2 合并进 nums1,不开新数组。做法是从后往前比较,把较大值放到 nums1 的末尾。因为 nums1 预留了空间,所以不需要额外数组,空间是 O(1)。从后往前遍历这个技巧很反直觉,但理解了“覆盖顺序”就不会忘:从前覆盖会丢失 nums1 原有的数据,从后覆盖则安全。
6. 从面试题到工程实践:复杂度的真正考法
6.1 面试官追问的三个方向
第一类追问是“这个算法能不能优化”。比如上面斐波那契的例子,你写完递归版本,面试官一定会问你时间复杂度是多少,然后问你有没有更好的做法。这时候你能答出缓存优化和滚动变量优化,并且能说出三种写法的复杂度差异,这道题就算通关了。
第二类追问是“两个方案怎么选”。比如让你实现一个固定容量的缓存淘汰算法,你用链表还是数组?用数组删除元素是 O(n),链表删除已知节点是 O(1),但是链表在查找时要 O(n),所以需要配合哈希表做到 O(1) 查找和 O(1) 删除。这实际上就是 LRU Cache 的标准解法,考察的完全就是复杂度组合能力。
第三类追问是“数据规模大了怎么办”。比如题目要求你对一个超大文件里的数据进行排序,内存装不下。这时候你就得意识到外部排序的存在,它会用到多路归并的思路,时间和空间的复杂度计算方式和内存排序完全不同。这种问题不常见,但一旦出现,筛选的就是有没有真实工程经验的人。
6.2 复杂度分析最容易翻车的五个错误
第一个错误是把 O(2^n) 和 O(n²) 混为一谈。这两个数量级在 n = 20 的时候可能看起来差不多,但 n = 50 时一个是千万亿级,另一个只是几千,差距是天壤之别。凡是看到递归里每个节点分成两个子问题且没有缓存,就要警惕指数级。
第二个错误是忽略循环变量不是递增 1 的情况。for (i = 1; i < n; i *= 2) 这种循环很容易被误判成 O(n),实际是 O(log n)。判断依据是循环变量每次乘以常数,而不是加常数。
第三个错误是只算时间不算空间。递归算法尤其容易在这里栽跟头。比如上面斐波那契,时间指数、空间线性,两者完全不同;再比如深度优先搜索的递归遍历,空间不仅包括显式的集合,还包括递归栈本身。
第四个错误是忽略输入数据规模的不同变量。合并两个长度分别为 m 和 n 的数组,复杂度是 O(m + n),不是 O(n)。树的复杂度里,节点数 n 和边数 e 也需要分开计算。有些题目里两者会同时出现,比如图论里 BFS 的复杂度是 O(n + e)。
第五个错误是直接背复杂度而不知道来源。面试官问“快排为什么平均是 O(n log n)”,如果你只答“因为这是快排的时间复杂度”而没有解释“因为递归深度 log n、每层划分总代价 n”,评分一定不高。背结论和讲清楚推导过程,在面试里的差距非常大。
6.3 给初学者的完整学习路径建议
我的建议是不要一上来就背排序算法的复杂度表格,而是先做三件事:第一,把时间复杂度计算的三条基本规则练熟,找 20 道简单的循环代码题,每一道都动手写出操作次数表达式再化简;第二,把递归递推式的展开法练到本能反应,见到 T(n) = aT(n/b) + f(n) 这种形式不再发怵;第三,准备一个笔记本,把每种数据结构增删改查的复杂度手动整理一张表,对照着写代码验证。
排序算法是综合应用题,学的时候不只要背复杂度,还要能模拟每一轮排序的过程、能写出不同优化的变体、能解释为什么某些算法稳定而另一些不稳定。等到你能不看资料画出递归树、能解释快排在有序数组上为什么会退化,复杂度和数据结构的底层逻辑才算真正打通了。
回过头来说,我当年刚学数据结构的时候也走过弯路:花大量时间背代码模板,却对“为什么这个结构比那个结构快”毫无概念。后来在刷题中发现,复杂度其实是最好的思维框架——看到一个题目先估算最优复杂度,再往那个方向想解法,比闷头穷举节省太多时间。这个思维一旦养成,读源码、做选型、调优都会变得有据可依,而不是靠猜。