
数据结构这个系列写到第二篇上篇聊了算法效率的基本衡量思路这篇单独把时间复杂度里的“渐进”两个字拉出来好好讲讲。很多初学者第一次看到 T(n) O(n²) 这类记号脑子里大概就一个印象“哦O是大概的意思n²就是平方级复杂度。”这么理解不算错但到了分析复杂代码、做考研计算题、参加技术面试手撕算法的时候你会发现只懂个大概完全不够——渐进上界、渐进下界、紧确界、最好最坏平均情况这些概念不彻底搞清楚做题全靠猜面试一问就露馅。这篇我会把渐进时间复杂度、渐进上界大O记号、渐进下界大Ω记号、以及连接两者的紧确界大Θ记号全部拆开从数学定义讲到代码实操配上排序算法复杂度对照表和递归主定理的使用方法最后把新手最容易踩的坑一起列出来。不管你是正在被数据结构折磨的大学生、准备考研408的还是马上要参加技术面试想恶补基本功这篇应该都够你用上一阵子。1. 先搞清楚时间复杂度到底在度量什么1.1 为什么不用秒表计时非要整个渐进分析先回答一个最基础的问题评估一个算法快不快为什么不能直接跑一遍测时间其实工程上当然可以跑这叫基准测试benchmark。但基准测试有三个绕不开的问题。第一硬件影响太大同一段代码在顶级CPU和入门CPU上跑出来的时间差好几倍第二输入数据不同耗时差异也巨大快速排序对有序数组可能退化到O(n²)对随机数组却快得吓人第三代码还没写完的时候你想比较两个算法的优劣根本没得测。所以我们需要一种“与机器无关、与输入规模强相关”的度量方式。也就是说我们关注的是当输入规模 n 增大时算法运行时间的“增长趋势”是怎样的。这就是渐进分析的核心思想把目光放到 n 足够大的情形研究运行时间 T(n) 随 n 增长的变化率而不是某个具体输入下跑了多少毫秒。提示“渐进”的英文是 asymptotic意思是“趋近于无穷时的行为”。说白了就是我不关心你 n10 时跑得多快我关心 n10000、n1000000 时你的算法会不会被拖垮。1.2 三个基本假设衡量增长趋势而不是绝对时间用渐进分析衡量复杂度背后有几个默认假设很多人不会明说但对后面的推导理解很有帮助。第一个假设只关心足够大的 n。原因是小规模数据下算法间的差距往往被常数因子、语言运行环境、甚至CPU缓存命中率掩盖只有在 n 很大时增长阶数的差异才会真正主导性能。10个元素的冒泡排序和快速排序几乎没差别100万个元素就完全不是一个量级了。第二个假设忽略常数因子和低阶项。比如 T(n) 3n² 5n 7我们把3去掉把5n7也去掉直接记 O(n²)。理由也很直白当 n 足够大时n² 的增长速度完全碾压 n常数因子不过是把曲线拉高了一点但改变不了它是抛物线而不是直线的本质。第三个假设以“最坏情况”作为默认讨论对象。为什么要盯最坏情况因为对于很多算法最好情况好得离谱比如快速排序在数组有序且固定选首元素做 pivot 时会退化到O(n²)你却只告诉别人“它是O(n log n)”——这话不算错但容易让听的人误以为“退化无所谓”。实际系统中最怕的就是最坏情况线上偶发超时的代码很多就是命中退化场景了。这三个假设一起才让我们能用 O、Ω、Θ 这些数学语言精确描述复杂度的上界、下界和紧确界。2. 渐进上界、渐进下界与紧确界一次分清2.1 大O记号渐进上界算法不会比这个更慢大O记号是所有记号里出场率最高的一个面试、考研、刷题几乎天天见。它的严格定义是设 f(n) 和 g(n) 是定义在自然数集上的函数如果存在正常数 c 和 n₀使得当 n ≥ n₀ 时总有 0 ≤ f(n) ≤ c·g(n)那么记作 f(n) O(g(n))。翻译成人话从某个足够大的 n 开始f(n) 的图像一直被卡在 c·g(n) 这条线的下方。换句话说g(n) 是 f(n) 增长速度的一个“上限”。这就像高速公路的限速牌——你的车速 f(n) 再快也不会超过某个上限O记号就是给算法性能画了一条“上限线”。举一个最典型的例子T(n) 3n² 5n 7。当 n ≥ 1 时5n ≤ 5n²7 ≤ 7n²所以 T(n) ≤ 3n² 5n² 7n² 15n²。取 c 15n₀ 1就有 T(n) ≤ 15n²于是 T(n) O(n²)。注意这里完全不需要等 n 很大再验证随着 n 增长n² 那项对其他项的碾压只会越来越明显。2.2 大Ω记号渐进下界至少得花这么多时间大Ω记号和大O正好镜像对称。定义是存在正常量 c 和 n₀使得当 n ≥ n₀ 时总有 0 ≤ c·g(n) ≤ f(n)记作 f(n) Ω(g(n))。意思是从某个足够大的 n 开始f(n) 始终压在 c·g(n) 这条线的上方g(n) 是 f(n) 的“下限”。Ω记号在工程开发中很少挂在嘴边但在理论证明里非常重要。最经典的例子是对“比较排序”的证明任何基于比较的排序算法平均情况下至少要做 Ω(n log n) 次比较。这个结论意味着归并排序、堆排序这些达到 O(n log n) 的算法在最坏情况下已经是“顶格发挥”了不可能再通过优化常数之外的手段突破 n log n 这个下限。如果用考驾照类比Ω记号就像“保底分”——你的算法再快也快不过这条保底线。这个“保底”概念在证明问题复杂度时极其关键后面4.2小节会展开讲。2.3 大Θ记号上界和下界终于碰头当你发现 f(n) 既满足 f(n) O(g(n))又满足 f(n) Ω(g(n)) 时就称 f(n) Θ(g(n))意思是 f(n) 和 g(n) 属于同一个增长阶数。数学定义合起来就是存在正常量 c₁、c₂ 和 n₀当 n ≥ n₀ 时0 ≤ c₁·g(n) ≤ f(n) ≤ c₂·g(n)。这个记号表达的信息量最大它表示“f(n) 的增长速度被 g(n) 上下夹住了”既不会比 g(n) 快出一个数量级也不会慢出一个数量级。比如 3n²5n7 就是 Θ(n²)因为它的上界是 O(n²)、下界是 Ω(n²)两条线之间夹得严严实实。这里顺便说一个小知识点大O记号里的底数问题。比如二分查找是 O(log₂ n)但很多人直接写成 O(log n)其实两个都行因为 log₂n log₁₀n / log₁₀2底数只差一个常数而常数在渐进分析中可以被忽略。所以以后看到 O(log n) 不用纠结底数是2还是10没有区别。2.4 三个记号的使用场景分析用O证明最优性用Ω把三个记号放在一起对比你就能看到它们各自擅长什么。分析一段代码、估算一个算法性能的时候我们通常说“它是O(n²)”意思是“它不会比 n² 更差”。这是工程语句也是考试和面试里最常写的答案。但严格来说O 只给了上界并没有说明它“到底多快”。比如说一段代码实际上是 O(n)你说它是 O(n³) 也不算错只是这个上界太松了没有信息量。所以真正准确表达“这个算法就是 n² 这个级别”必须用 Θ。比如“归并排序的时间复杂度是 Θ(n log n)”这比“O(n log n)”强得多因为它同时告诉你最好、最坏都是这个量级不会突然退化。Ω 一般在证明“某类问题不可能更快”时出场。比如你已经知道比较排序下界是 Ω(n log n)然后有人声称发明了一个“更快的比较排序”你可以直接判定不可能——下限就在那里数学结论不会给你留例外。注意面试时如果面试官问“快速排序的时间复杂度”标准答案是“平均 O(n log n)、最坏 O(n²)”而不是笼统说“O(n log n)”。这里体现的正是对上界、最坏情况理解是否透彻。3. 拿真实代码练手五类结构的时间复杂度判断3.1 顺序结构多个循环相叠加取最大阶对于顺序执行的多个独立代码块总时间复杂度等于各自复杂度相加然后取最高阶那一项。比如这段代码// 代码段AO(n) for (int i 0; i n; i) { printf(%d , i); } // 代码段BO(n²) for (int i 0; i n; i) { for (int j 0; j n; j) { printf((%d,%d) , i, j); } }总时间 T(n) O(n) O(n²) O(n²)。原因很简单当 n 足够大时n² 那部分的耗时把 n 那部分完全淹没低阶项直接丢掉即可。这里有个初学者经常犯的错以为“两个循环加起来就是 O(n²)”。如果两个循环是嵌套的关系确实是相乘但如果两个循环是并列的关系就是相加。嵌套对应乘法并列对应加法这两个关系一旦搞混后面分析复杂代码会全盘出错。3.2 嵌套循环次数相乘注意内外层的独立变量嵌套循环是复杂度计算里最容易“翻车”的部分。基本规则是外层循环执行一次内层循环完整执行一轮所以总执行次数 外层循环次数 × 内层循环次数。最简单的双层循环for (int i 0; i n; i) { for (int j 0; j n; j) { // 核心操作 } }这个显然是 O(n²)。但注意内层循环的次数不一定都是 n也可能和 i 有关for (int i 0; i n; i) { for (int j 0; j i; j) { // 核心操作 } }这时代码的总执行次数是 1 2 3 ... (n-1) n(n-1)/2依然是 Θ(n²)。很多人第一次算这个会写成 O(n)其实不对——i 从0到 n-1内层 j 最多到 n总和的最高阶是 n²不要被“好像只跑了一半”骗了。再进一步如果内层循环的初始值和终止条件都跟 i 有关比如中间层次数是外层的一半那就要老老实实写出求和式再化简不要凭感觉。判断嵌套循环复杂度的核心是总次数 Σ(每一轮内层执行的次数)把这个求和式正确地列出来化简到最后就是答案。3.3 对数复杂度的三大来源二分、倍增、递归分治对数复杂度 O(log n) 是让人感觉最“舒服”的复杂度也是面试里出现频率最高的一类。它是怎么来的本质上就是每一轮操作之后问题的规模减半或者缩小到原来的某个固定比例。来源一二分查找。每次比较后搜索范围缩小一半假设 n 个元素最多比较 k 次需要满足 2ᵏ ≥ n所以 k ≈ log₂ n。这就是“查词典”的体验——你翻到中间页比较后扔掉一半再翻剩下的一半的中间几轮就能定位到目标词。来源二倍增过程。比如不断翻倍直到超过 n 的循环int cnt 1; while (cnt n) { cnt cnt * 2; }循环次数是 log₂n 级别因为每乘一次2规模翻倍要翻多少次才到 n答案就是 log₂n。来源三递归分治。比如归并排序每次拆成两半拆分的层级数是 log n每一层合并的总工作量是 O(n)所以总复杂度是 O(n log n)。这个在3.4小节用主定理可以更规范地算出来。提示判断一段代码是不是对数复杂度核心看循环变量是“乘/除一个常数”还是“加/减一个常数”。如果循环变量每轮乘2、除2或者问题的规模每次减半大概率就是 log 级别如果是每轮加1那就是线性级别。3.4 递归时间复杂度的计算套路与主定理递归算法的时间复杂度不好直接数循环次数因为调用自己会层层展开成一棵递归树。处理这类问题的标准工具是主定理Master Theorem。主定理针对的是形如 T(n) aT(n/b) f(n) 的递推式其中 a ≥ 1b 1f(n) 是分解和合并的开销。定理结论可以归纳为三种情况这里省略严谨证明只讲实用结论情况1如果 f(n) O(n^(log_b a - ε))那么 T(n) Θ(n^(log_b a))。简单说当分解合并的开销低于递归叶子节点的规模时总时间由叶子节点主导。情况2如果 f(n) Θ(n^(log_b a))那么 T(n) Θ(n^(log_b a) · log n)。这时候每一层的开销大致相同总开销是层数乘以每层开销。情况3如果 f(n) Ω(n^(log_b a ε))且满足正则条件 af(n/b) ≤ cf(n)那么 T(n) Θ(f(n))。也就是说递归开销被分解合并的开销主导。实际使用中先把递归式写清楚再比较 f(n) 和 n^(log_b a) 的大小关系套对应情况即可。举三个高频的例子。归并排序的递推式 T(n) 2T(n/2) n这里 a2, b2, n^(log_b a) nf(n)n 恰好等于中间情况所以 T(n) Θ(n log n)。二分查找的递推式 T(n) T(n/2) 1a1, b2, n^(log_b a) n⁰ 1f(n)1 等于 n⁰同样落在情况2得到 T(n) Θ(log n)。遍历二叉树的递推式 T(n) 2T(n/2) 1a2, b2, n^(log_b a) nf(n)1 小于 n属于情况1T(n) Θ(n)这也符合直觉树的每个节点访问一次总代价是 n。主定理不能处理所有递归式比如 T(n) T(n-1) n 就不符合形如 aT(n/b) 的格式这种一般用递推展开求和来做。以后再遇到复杂的递归式先判断能不能套主定理不能套再老老实实展开。4. 排序法时间复杂度怎么算——高频考点和面试问法4.1 十大排序复杂度对照表“排序法时间复杂度怎么算”是搜索榜上长期霸占前排的问题因为无论是数据结构期末考试、考研408、还是技术面试排序都是最常考的复杂度场景。我直接把最常用的排序算法复杂度整理成一张表建议存一下随时翻排序算法平均时间复杂度最坏时间复杂度最好时间复杂度空间复杂度稳定性冒泡排序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²)O(n)O(1)不稳定归并排序O(n log n)O(n log n)O(n log n)O(n)稳定快速排序O(n log n)O(n²)O(n log n)O(log n)递归栈不稳定堆排序O(n log n)O(n log n)O(n log n)O(1)不稳定计数排序O(nk)O(nk)O(nk)O(k)稳定桶排序O(nk)O(n²)O(n)O(nk)稳定基数排序O(d(nk))O(d(nk))O(d(nk))O(nk)稳定几个需要额外解释的细节。插入排序的最好情况 O(n) 是指输入已经完全有序每次新元素直接放在末尾总共只比较 n-1 次。冒泡排序同理优化版在检测到没有交换时提前退出才能达到 O(n)。快速排序的最坏 O(n²) 是在每次选 pivot 都选到最大或最小值时出现的比如对一个已经有序的数组固定选第一个元素当 pivot退化非常严重这也是为什么工程实现里会用三数取中法或者随机选 pivot 来规避。计数、桶、基数这三种非比较排序能突破 O(n log n) 的下界原因是它们根本不做元素之间的比较而是借助额外空间和数据分布特征。表里出现的 k 是数据范围或桶的个数使用时需要格外注意空间开销和数据范围数据范围一旦巨大这三个算法的空间优势可能变成灾难。4.2 从比较排序的下界看 Ω(n log n) 的真正含义这里正好把前面2.2小节埋的伏笔收回来。为什么比较排序的下界是 Ω(n log n)这个结论在4.1的表里已经体现了一部分——所有基于比较的排序算法平均时间都在 O(n log n) 或更差没有一个能突破 n log n。简单证明思路是这样的n 个互不相同的元素全排列有 n! 种排序算法通过比较大小来区分这些排列。每次比较只有“大于”和“小于”两种结果所以 k 次比较最多能区分 2ᵏ 种排列。要能区分 n! 种排列必须满足 2ᵏ ≥ n!所以 k ≥ log₂(n!)。利用斯特林公式log₂(n!) ≈ n log₂n - 1.44n所以 k Ω(n log n)。这意味着什么意味着归并排序、堆排序的 Θ(n log n) 已经站在比较排序的天花板上了。如果有人宣称“我发明了一个所有情况下都比 n log n 快的比较排序”你可以直接判断要么他比较排序的前提不成立比如利用了额外性质要么理论错了没有第三种可能。这也是为什么快速排序平均性能再好理论最坏仍是 O(n²)——它没有真正突破下界只是平均情况下“碰巧”达到了 n log n。4.3 实战如何快速判断一段代码是哪个数量级到了这里给一个可以“抄作业”的快速判断流程。拿到一段代码按下面四步走第一步先找最高频执行的操作通常是循环体里的赋值、比较、算术运算其他一次性操作直接忽略。第二步数这个操作被执行的次数。如果是单层循环看循环变量范围从几到几、每次加几如果是嵌套循环把每一层的执行次数相乘如果内外层变量有关联就写成求和式再化简。第三步看循环变量的变化方式。是每次加1还是每次乘2前者是线性的次数后者是对数级别。如果循环里还调用了递归函数那就要写出递推式套主定理或逐层展开。第四步把结果化到标准形式。去掉常数因子去掉低阶项保留最高阶项最后按阶数从低到高查找O(1) O(log n) O(√n) O(n) O(n log n) O(n²) O(n³) O(2ⁿ) O(n!)。这里有个容易被忽视的点O(√n) 的出现。它常见于某些数论算法和矩阵相关的优化中比如判断一个数是不是质数只需要试除到 √n因为如果一个数有因子必然有一对因子分布在 √n 两侧。5. 常见错误与排查技巧实录5.1 五个最容易踩的坑这些年不管是带实习生还是看读者留言复杂度分析上翻车的场景基本集中在五个地方每个都很典型值得单独列出来。第一个坑把“并列循环”算成“相乘”。两个独立的单层循环总次数是 n n 2n级别是 O(n)不是 O(n²)。只有嵌套循环才是 n × n。怎么确认看两层循环之间有没有“套住”的关系套住了才相乘没套住就是相加。第二个坑二分查找写成 O(n)。有些同学看到 while (left right) 就慌了觉得里面每次都要算 mid、比较是不是 O(n)其实循环里每次把区间减半n 个元素的区间需要约 log₂n 次就能缩小到空所以是 O(log n)。判断标准是区间缩小的方式减半是 log减少一个固定值才是 n。第三个坑递归深度搞错。空间复杂度分析时递归调用栈的深度也要算进去。比如递归实现斐波那契数列时间复杂度大约是 O(2ⁿ)但空间复杂度只有 O(n)因为调用栈最大深度是 n而不是 2ⁿ。很多人写出递归就默认空间是“很大”其实要看最大同时存活的栈帧数量不是调用总次数。第四个坑忽略输入规模的定义。复杂度表达式里的 n 到底指什么有时候是数组长度有时候是矩阵边长有时候是数值本身的位数。描述复杂度前先明确 n 的定义否则一个判断质数的算法如果 n 是数值本身那确实是 O(√n)但如果 n 是输入二进制数的位数实际就成了 O(2^(n/2))复杂度瞬间爆表。第五个坑把最好情况和平均情况混为一谈。“快速排序是O(n log n)”这句常见说法其实是平均情况最坏情况是 O(n²)。同样插入排序“最好O(n)”只有在数组接近有序时才成立。回答复杂度问题时先说是哪个情况再报数值这样才严谨。5.2 均摊分析与摊还代价动态数组扩容为什么是 O(1)还有一个容易被忽略但面试极其爱考的场景数据结构操作里面的分摊复杂度。最典型的就是动态数组比如 Java 的 ArrayList、C 的 vector、Python 的 list的 push_back 操作。单次 push_back 最坏是 O(n)——因为容量满时需要申请更大的内存并逐个拷贝元素。但如果用均摊分析把所有 push_back 操作的总代价平均到每一次单次操作仍然是 O(1) 均摊。原因是扩容策略通常是“容量翻倍”。假设从容量1开始依次扩容到2、4、8、16……累计拷贝的元素总数是 1 2 4 8 ... 2ᵏ ≈ 2ᵏ⁺¹而插入 n 个元素本身的代价是 n。当 n 接近 2ᵏ 时总代价约等于 3n均摊到每次插入就是 O(1)。一个偶尔昂贵、大部分时间便宜的操作整体看还是便宜的这就是摊还分析的精髓。这个思想非常实用。实际工程中凡是涉及“偶尔重操作、大部分时间轻操作”的数据结构都值得用均摊分析重新评估复杂度。比如哈希表的 rehash、优先队列某些操作的批量处理背后都是同一个套路。面试时提到“摊还”两个字面试官一般都会觉得你是真的理解复杂度而不是只会背大O。5.3 技术面试中时间复杂度的标准回答模板最后给准备面试的同学一点实操建议。面试官问“这段代码的时间复杂度是多少”时不要只甩一个答案而是按“三明治”结构回答先说出结论比如“这段代码的时间复杂度是 O(n²)”然后给出理由比如“外层循环执行 n 次内层循环平均执行 n/2 次相乘后取最高阶得到 O(n²)”最后补充说明比如“如果是已经排序好的输入内层有提前退出的优化最好情况可以到 O(n)”。这样做的好处是结论给得快推理链条清晰还能展示你对最好、平均、最坏情况的理解。如果面试官追问“能不能优化”你还可以顺着刚才的分析指出瓶颈在哪——是内层循环还是递归深度从而引出更优方案。我自己面试别人的时候最怕听到的一句话是“我觉得应该是O(n)因为看起来很快”。复杂度分析不是猜谜它是一套可以严格推导的数学框架。哪怕最终答案不对只要推导过程在我还能给个提示引导完全没有推导过程那基本就是没有系统学过只能按基础薄弱的候选人处理。到这里渐进时间复杂度、渐进上界、渐进下界、以及它们在实际代码分析中的应用算是从头到尾走了一遍。我个人在实际使用中最深的一个体会是复杂度分析不是做数学题而是一种“算法直觉的训练”。当你看到一个双层循环能本能地想到O(n²)看到一个递归分治能立刻联想到n log n看到一个while循环变量不断翻倍能条件反射地想到log n——这个时候你已经不是在背公式而是真的理解了计算的代价是如何随规模增长的。从这个意义上说弄懂渐进分析比背下任何一张复杂度表都更有价值。下次再遇到一段新代码不妨先别看答案自己动手推一遍复杂度这个习惯坚持三个月看算法题的目光都会不一样。