ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

数据结构必学:时间复杂度与空间复杂度从入门到实战

2026/9/13 15:27:29 拓冰建站 浏览量
数据结构必学:时间复杂度与空间复杂度从入门到实战 面试百题里那道“斐波那契数列到底该用递归还是循环”的题为什么答案从来不只是代码能跑而是要看递归版的时间复杂度是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² 是 3005n 是 50100 是常数三项加起来 450似乎 n 的一次项和常数项都有存在感。但 n 1000 的时候3n² 是 30000005n 是 5000常数项 100 基本可以无视了。再到 n 100000n² 项更是碾压级别。所以这个算法的时间复杂度就是 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) 这种形式不再发怵第三准备一个笔记本把每种数据结构增删改查的复杂度手动整理一张表对照着写代码验证。排序算法是综合应用题学的时候不只要背复杂度还要能模拟每一轮排序的过程、能写出不同优化的变体、能解释为什么某些算法稳定而另一些不稳定。等到你能不看资料画出递归树、能解释快排在有序数组上为什么会退化复杂度和数据结构的底层逻辑才算真正打通了。回过头来说我当年刚学数据结构的时候也走过弯路花大量时间背代码模板却对“为什么这个结构比那个结构快”毫无概念。后来在刷题中发现复杂度其实是最好的思维框架——看到一个题目先估算最优复杂度再往那个方向想解法比闷头穷举节省太多时间。这个思维一旦养成读源码、做选型、调优都会变得有据可依而不是靠猜。