YAOTU INSIGHTS

复杂度分析实战:从大O记号到性能优化

复杂度分析实战:从大O记号到性能优化
我见过太多把复杂度分析当成面试八股的人背了一堆结论真到线上接口超时的时候连从哪里下手都不知道。也有不少人觉得时间复杂度、空间复杂度是理论课内容跟日常写业务代码没关系直到某个深夜被一个数据量稍大的接口教做人。这篇就把我自己在实际项目中怎么用复杂度分析、怎么算、怎么踩坑的过程完完整整写出来希望能帮刚入门的朋友建立直觉也让写过一阵代码的人重新审视自己的习惯。1. 从一次接口超时说起复杂度到底在描述什么1.1 一段让接口从 200ms 变成 5s 的代码先讲个真实的事。之前接手过一个订单统计后台平时接口响应大概 200ms大家都没在意。某次大促之后订单量从几万涨到几十万接口直接飙到 5 秒以上前端不停报超时。第一反应是加缓存、加机器真正翻代码才发现问题出在一个不起眼的双层循环let result []; for (let i 0; i orders.length; i) { for (let j 0; j orders[i].items.length; j) { // 对每个订单里的每个商品做一些统计逻辑 result.push(summary(orders[i].items[j])); } }单看这段代码每一轮循环做的事情都不重单个订单的商品数也就个位数放在平时数据量小的时候完全没问题。但它最可怕的地方在于订单数 n 和商品数 m 都会随业务增长。当订单数和订单内商品数都在涨的时候总工作量就是 n 乘以 m两层循环一叠增长速度是指数级的膨胀。这个例子包含了我对复杂度分析最核心的理解时间复杂度不是用来算这段代码跑了多少毫秒的而是用来描述当数据量变大时工作量会以什么方式增长。性能瓶颈很多时候不是某个操作慢而是增长方式不对。1.2 大O记号脱掉常数外衣看本质理解了增长方式这个思路大O记号就很好懂了。它干的事情非常简单粗暴把常数系数、低阶项全部扔掉只保留随着输入规模变化的那一项。比如一段代码总共执行了3n 100次操作n 很小的时候那个 100 还挺重要但当 n 到十万、百万的时候100 就是个零头。所以大O写法直接写作 O(n)。同样n² 100n写作 O(n²)因为 n 足够大时100n 在 n² 面前不值一提。我当时理解大O的时候用了这样一个类比你要判断两个人跑步谁的耐力好不用去精确测量每一步的步幅只需要知道其中一个的步频会随距离越来越快另一个人保持匀速那距离拉长之后谁先倒下就显而易见了。大O记号就是在做这种趋势判断。这里有三条不成文的潜规则很多人忽略了忽略常数系数O(2n) 写作 O(n)O(0.5n²) 写作 O(n²)。系数只影响具体耗时不影响增长趋势。只保留最高阶项O(n² n) 写作 O(n²)低阶项在 n 足够大时没有存在感。对数底数不重要O(log₂n) 和 O(log₁₀n) 都写作 O(log n)因为换底公式只差一个常数倍大O不在乎。这三条规则的意义在于让你不要纠结于微观细节放眼宏观趋势。很多人刚接触大O会觉得这也太粗糙了但实际上正是这种粗糙让不同算法之间有了可比性。2. 时间复杂度的计算跟着代码走一遍完整推演2.1 单层循环的两个基本模型线性与对数计算时间复杂度最标准的方法是看代码里基本操作会随着输入规模 n 执行多少次而不是数代码行数。最简单的模型是单层循环。比如for (let i 0; i n; i) { console.log(i); // 基本操作 }这个很明显循环体执行 n 次所以时间复杂度 O(n)。它描述的增长趋势是线性的数据量翻倍耗时翻倍。但同样是单层循环换个步长结论完全不同let i 1; while (i n) { i i * 2; }这里 i 每次翻倍从 1 到 2 到 4 到 8……一直乘到超过 n。假设总共执行了 k 次那结束时2^k n所以k ≈ log₂n。这个循环的时间复杂度是 O(log n)。O(log n) 这个复杂度非常被低估。它的增长曲线有多平缓呢当 n 从 1000 涨到 100 万线性 O(n) 的耗时涨了 1000 倍但 O(log n) 只涨了大约 6 倍因为 2¹⁰≈10002²⁰≈100 万。二分查找、平衡二叉树、某些分治算法的核心优势就在这里。2.2 嵌套循环的加法与乘法什么时候是O(n²)嵌套循环是很多人第一个会算但算不清的地方。关键在于区分两层循环之间到底是加法关系还是乘法关系。先看顺次执行的循环for (let i 0; i n; i) { foo(); } // n 次 for (let i 0; i n; i) { for (let j 0; j n; j) { bar(); } // n² 次 }第一个循环执行 n 次第二个执行 n² 次总次数是n n²按大O规则取最高阶整体是 O(n²)。这叫加法法则——两个循环段顺序执行复杂度取其中最大的那个。再看两个嵌套的循环for (let i 0; i n; i) { for (let j 0; j n; j) { foo(i, j); } }外层 n 次内层每次都是 n 次总共n * n n²次这就是 O(n²)。这叫乘法法则——每层循环叠加时循环次数相乘。到这里都还没什么争议。真正的陷阱是内层循环的边界会跟着外层变量变化for (let i 0; i n; i) { for (let j i; j n; j) { foo(i, j); } }内层循环从 i 开始所以执行的次数不是 n 次而是n - i次。把所有 i 加起来n (n-1) (n-2) ... 1 n(n1)/2展开是n²/2 n/2去掉常数和低阶项依然是 O(n²)。虽然实际执行量大约是标准嵌套循环的一半但大O看不出来因为 n 足够大时n²/2和n²的增长趋势一样。再往上推一层三层嵌套for (let i 0; i n; i) { for (let j 0; j i; j) { for (let k 0; k j; k) { foo(i, j, k); } } }这是n³/6量级大O写作 O(n³)。我当时自己推的时候踩了个小坑三层嵌套不是总能直接说 O(n³)因为只有当内层边界都跟外层独立时才是严格的 n³一旦边界相互依赖精确值要老老实实求和。虽然大O结果可能不变但推导过程能帮你理解为什么是这个结论。2.3 递归的时间复杂度画一棵树比背公式靠谱递归的时间复杂度比循环要难直觉理解得多。我见过很多工程师张口就是这有个递归应该是 O(2^n)但实际上完全不是这么回事。核心方法是把递归过程展开成一棵树数每个节点的工作量。举个经典例子朴素递归斐波那契function 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) 又调用 fib(n-2) 和 fib(n-3)。这棵树每层大约翻一倍节点数树高是 n所以总节点数级是指数级的O(2^n)。但这个结论需要小心验证。实际节点数大约 1.618^n 左右斐波那契数列的通项大O统一记作 O(2^n)因为它确实是指数增长的。n50 的时候普通机器已经算不动了。再看归并排序function mergeSort(arr) { if (arr.length 1) return arr; let mid Math.floor(arr.length / 2); let left mergeSort(arr.slice(0, mid)); let right mergeSort(arr.slice(mid)); return merge(left, right); // merge 需要 O(n) }归并排序的处理很容易想偏。它的递推关系是T(n) 2T(n/2) n第一层1 个节点处理 n 个数据工作量 n。第二层2 个节点每个处理 n/2 个数据总工作量 n。第三层4 个节点每个处理 n/4 个数据总工作量 n。第 k 层总工作量仍然是 n。一共有 log₂n 层每层都是 n所以总工作量是n * log₂n也就是 O(n log n)。画递归树这个习惯帮我避免了很多死记硬背的错误。遇到递归先别急着下结论把树展开一层、两层、三层看看每层的工作量总和是多少再总结规律这个笨方法在面试和实际分析中都极其可靠。2.4 最好、最坏与平均面试喜欢问工程也要选复杂度分析还有一组容易混淆的概念最好情况、最坏情况、平均情况。快速排序平均 O(n log n)、最坏 O(n²)这个结论大家都会背但真要说为什么最坏是 O(n²)不少人答不上来。快速排序每一轮找到一个基准值把数组分成两部分。如果基准值每次都选到当前区间的最小值那每次划分只分出一个元素剩下的继续递归递归树就退化成一条线总工作量变成n (n-1) ... 1 n²/2也就是 O(n²)。这就是为什么有些快排实现会做三数取中或者随机选基准目的就是避免这个最坏情况。最好、最坏、平均这三个概念在工程上对应的问题也不一样最坏情况延迟敏感的系统要关注。比如在线支付接口如果某个算法在极端输入下会退化哪怕概率只有 0.1%也必须处理掉这个退化路径。平均情况大多数业务代码建议关注。用户输入的数据不是刻意构造的平均情况能反映日常体验。最好情况除了用来理解算法特性实际价值很低。但插入排序在近乎有序的数据上有 O(n) 的最好表现这个特性被用在了许多混合排序策略里后面第 4 节我会细说。3. 空间复杂度的完整拆解算的是同时占用而不是总共占用3.1 空间复杂度在统计什么输入 vs 辅助空间时间复杂度大家讨论得多空间复杂度往往是被一带而过的那一个。我在实际面试和评审里发现很多人对空间复杂度的理解停留在创建了多少变量这是一个非常容易出错的视角。空间复杂度计算的不是这个算法总共分配过多少内存而是在最深的那个时刻内存里同时存活的数据量有多大。换句话说它统计的是峰值占用不是累计占用。标准定义下空间复杂度通常排除输入本身占用的空间只看额外空间。因为输入数据无论用什么算法都得存那不是你得不得你真正需要关心的是为了完成计算你额外向内存要了多少地盘。3.2 原地操作与辅助结构O(1)和O(n)的分野用两个常见的操作对比一下。第一个是翻转数组function reverseArray(arr) { let len arr.length; for (let i 0; i Math.floor(len / 2); i) { let temp arr[i]; arr[i] arr[len - 1 - i]; arr[len - 1 - i] temp; } }这个函数只用了一个临时变量 temp不管数组多长额外空间都只有 O(1)。这类操作叫原地操作是空间复杂度的最优形态。第二个是归并排序function merge(left, right) { let result []; // 把 left 和 right 按顺序合并到 result return result; }归并的时候需要一个新的数组来装合并结果长度跟原数组一个量级所以额外空间是 O(n)。这就叫辅助结构。这里有一个工程上特别常见的认知偏差以为空间复杂度 O(n)就是内存占用翻倍。真实情况要具体分析。归并排序的空间 O(n) 指的是它需要一个和输入等长的临时数组而如果代码里用arr.slice(0, mid)这种切割方式每层递归都复制一遍子数组峰值空间甚至可能是 O(n log n)这就是为什么很多实现不用 slice而是传入区间下标让数组共享。判断一个算法是不是原地最简单的标准是除了输入数组和有限几个变量有没有申请随 n 增长的额外空间。有就是 O(n) 或更高没有就是 O(1)。3.3 递归的隐形开销调用栈每层都在占内存递归的空间复杂度是个大坑因为递归函数每一层调用都会在调用栈上占一个栈帧。栈帧不释放内存就不回收。举个例子。还是斐波那契那个递归function fib(n) { if (n 1) return n; return fib(n - 1) fib(n - 2); }它在时间上是 O(2^n)非常吓人但空间上并不是 O(2^n)。为什么因为每次只沿着一条路径往下钻。fib(10) 先调 fib(9)fib(9) 先调 fib(8)……这一条链钻到底才 10 层。等 fib(8) 的结果返回后栈帧弹出才去算 fib(7)。也就是说同一时刻栈上最多只有 n 个栈帧所以空间复杂度是 O(n)。这个例子特别适合用来纠正空间复杂度 总调用次数的错误理解。另一个常见例子是快速排序。快排是原地排序不需要额外数组但递归深度会占栈空间。平均情况下递归深度是 O(log n)最坏情况下退化成 O(n)。所以快排的空间复杂度平均是 O(log n)最坏 O(n)。有些人在分析快排空间时只说原地排序空间 O(1)这就把递归栈帧给漏掉了。我自己的经验是递归函数的时间看整棵树的节点数空间看最深路径的层数。这两个维度别混基本不会错。4. 复杂度对比与工程取舍大O不是排行榜4.1 一张表看清增长曲线的残酷差异很多初学者觉得 O(n²) 和 O(n log n) 只是差一点。我通常建议直接看数据用 10⁹ 次/秒约等于现代 CPU 每秒执行简单指令的规模来做粗略估算数据规模 nO(n)O(n log n)O(n²)O(2^n)101033100102410010066410000约 10³⁰10001000约 996610⁶大到没意义10⁶10⁶约 2×10⁷10¹²——估算耗时10⁶1ms20ms约 16 分钟——这个表展示了一个关键规律n100 的时候O(n²) 好像也能跑n1000 的时候O(n²) 已经需要 1 秒量级n10⁶ 的时候O(n²) 要跑十几分钟而 O(n log n) 只要 20 毫秒。同样是看起来没差多少的复杂度数据量一旦上来差距是天壤之别。我在评审代码时养成了一个习惯看到嵌套循环先问一句这个 n 最大能到多少。如果明确不超过 1000O(n²) 完全可以接受甚至比折腾一个 O(n log n) 但代码复杂的方案更实惠。复杂度没有绝对的好坏只有结合数据规模的合适与不合适。4.2 数据规模和工程权衡什么时候 O(n²) 反而赢很多人有个执念O(n log n) 一定比 O(n²) 好。我在工程实践里发现这个结论是错的至少在大数据量之前是错的。最典型的例子是插入排序和快速排序的对比。插入排序最坏是 O(n²)快速排序平均是 O(n log n)。但插入排序的常数极小、实现简单、对缓存友好当 n 很小比如小于 50的时候插入排序的实际速度往往优于快排。这也就是为什么很多标准库的排序实现会在递归到小数组时切换到插入排序。Java 的Arrays.sort()对小型数组就用插入排序的变体Python 的 TimSort 也是混合策略。这类设计的核心逻辑是大O描述的是渐进行为在小数据量区间常数系数和低阶项才是真正的统治者。还有一种情况是局部性带来的差距。一个 O(n log n) 但随机跳着访问内存的算法和一个 O(n²) 但顺序访问内存的算法在特定数据规模下实际表现可能是后者更快。因为 CPU 缓存对顺序访问的友好度非常高。这个层面大O完全无法体现但它真实影响着生产环境的性能。所以在真实的工程选型里我的判断顺序是先明确数据规模和增长预期。再看复杂度量级是否能承受。最后才用实际压测来决定而不是看一眼复杂度就拍板。4.3 空间换时间动态规划的本质选择空间的取舍也类似不是越省越好。最典型的就是哈希表 vs 排序。比如判断一个数组里有没有重复元素。最直观的做法是两层循环暴力查找for (let i 0; i arr.length; i) { for (let j i 1; j arr.length; j) { if (arr[i] arr[j]) return true; } }时间复杂度 O(n²)空间 O(1)。数据量一涨立刻完蛋。用一个哈希表记录已经见过的元素let seen new Set(); for (let value of arr) { if (seen.has(value)) return true; seen.add(value); }时间复杂度降到 O(n)代价是额外空间 O(n)。十个里得有八个会选哈希表方案因为它把耗时的双层探测变成了 O(1) 的哈希查询用空间换时间。而动态规划更是把空间换时间用到了极致。比如斐波那契数列朴素递归 O(2^n) 时间、O(n) 空间用一个数组缓存中间结果时间降到 O(n)再优化成两个滚动变量空间降到 O(1)。每一步都是在权衡你愿意付出多少空间换取多少时间收益。我的原则是空间换时间要有上限意识。如果数据集是千万级开一个 O(n) 的辅助数组可能就是几百 MB 内存这时候就要重新考虑方案。内存不是无限的为了快可以多用点内存这句话在有人跟你核算服务器成本的时候就不太成立。5. 复杂度分析里的高频翻车点我踩过的和看见别人踩过的5.1 把固定循环次数当成复杂度最常见的翻车是把循环次数直接当成时间复杂度而忽略了这次数到底跟 n 有没有关系。比如下面这段function process(arr) { for (let i 0; i 3; i) { console.log(arr[i]); } }不管 arr 多长这个函数永远只执行 3 次它的时间复杂度是 O(1)不是 O(n)。有人说这不是废话吗但实际代码里经常有这种场景一个查询接口内部固定只取前 10 条数据那它的主体复杂度就是 O(1)即使底层接的是一个巨大的数据源。分析复杂度之前先搞清楚哪个参数才是真正随输入规模变化的量。反过来说有一种更隐蔽的情况循环次数看着是固定的但每次循环内部的工作量跟 n 有关。这时候复杂度照样是 O(n)不能只看外层循环的次数。5.2 内层循环变量不独立误判嵌套循环嵌套循环不一定是 O(n²)这句已经被我说烂了但情况其实比想象的复杂。除了前面内层从 i 开始这种边界依赖还有一种是内层循环会被提前打断。比如在有序数组里做查找的朴素写法let count 0; for (let i 0; i n; i) { for (let j 0; j n; j) { if (arr[j] target) break; } }虽然写的是嵌套循环但如果 target 恰好出现在数组开头内层循环第一次就 break 了实际工作量接近 O(n)如果 target 在末尾或者不存在那才是真的 O(n²)。这就是为什么分析复杂度必须看最坏情况而不是看代码长得像什么。我见过一个真实 case一个扫全表 内层二分的代码很多人直接以为是 O(n * n)其实内层是 O(log n)整体是 O(n log n)这个差异直接影响了对接口瓶颈的判断。5.3 递归空间计数错误把总调用次数当成空间这个前面已经详细讲了但值得单独列出来再敲一次黑板。很多人在分析递归空间复杂度时把总共调用了多少次函数当成空间占用量得出来的结论直接翻车。斐波那契朴素递归是最典型的例子总调用次数是 O(2^n)但空间是 O(n)因为空间只看同时存在的栈帧。如果你在写递归时发现自己分析的空间复杂度和时间一样大先停下来想一下是不是把累计调用数和峰值栈深搞混了。5.4 被语言和数据结构的底层实现坑了还有一个很实际的坑复杂度分析必须基于你所用语言、所用 API 的真实实现而不是理论上的数组操作都是 O(1)。举两个高频例子。一个是字符串拼接。在 JavaScript/Python 里循环里反复用或拼接字符串复杂度经常是 O(n²)因为字符串不可变每次拼接都会创建一个新字符串并复制整个内容。看起来只有一层循环实际每轮都在做 O(n) 级别的复制工作。另一个是数组的splice/ 从中间删除元素。我在给一段代码做性能优化时发现一个只循环了一次的函数实际耗时随着数组变大在指数级增长最后定位到是arr.splice(i, 1)导致的——每次从数组中间删除元素后续所有元素都要往前挪一次操作就是 O(n)循环 n 次就是 O(n²)。还有很多类似情况JavaScript 的Set、Map平均 O(1) 但哈希冲突时可能退化Python 的 list 尾部 append 是摊销 O(1)但从头部 pop 是 O(n)。分析复杂度之前先确认你用的 API 在目标语言里到底是什么复杂度。这是新手和熟练工之间一个很大的分水岭。5.5 用实测数据验证复杂度一种简单可靠的方法书面分析难免出错我个人的习惯是用实测数据来反向验证。方法很简单把输入规模 n 从 100、200、400、800 依次翻倍跑记录耗时。如果 n 翻倍、耗时基本不变大概率是 O(1)。如果耗时也大致翻倍大概率是 O(n)。如果耗时翻 4 倍大概率是 O(n²)。如果耗时翻 8 倍大概率是 O(n³)。配合console.time()或 Python 的timeit这个方法能快速暴露我对代码复杂度的误判。我之前分析某个字符串去重逻辑书面觉得是 O(n)实测翻倍后耗时涨了约 4 倍回去一看果然是循环里隐式做了字符串复制实际是 O(n²)。书面分析和实测验证双轨并行是复杂度分析最可靠的做法。最后说点我自己的感受。复杂度分析这个东西表面上是一堆大O记号和规则本质上是一种调用前先算代价的思维习惯。我写代码的时候每写一个循环、每建一个辅助数组、每用一次递归都会下意识在脑子里过一遍这个操作随着数据量增长会变成什么样有没有更省的增长方式这个习惯帮我避免过线上事故也帮我在技术评审里说服过别人——比拍着桌子说这代码有问题有用得多。希望这篇能帮你把这个习惯也建立起来。