T(n)与O(n):从时间复杂度推导到工程性能优化
2026/9/17 13:58:30 网站建设 项目流程

1. 一段“看起来一样快”的代码,为什么换了数据量就趴下

线上有个接口,本地拿 1000 条测试数据跑,20 毫秒出结果,加个日志、写个单测,一切正常。上线之后真实数据涨到 10 万条,同一个接口的耗时变成了 40 分钟。注意这个倍数:数据量翻 100 倍,耗时翻了 12 万倍。如果你只看本地测试的结论,会觉得这代码“没问题”;如果你懂时间复杂度渐进时间复杂度,会在一开始就知道这段代码迟早要炸。T(n) 和 O(n) 这一对概念,真正的价值不在于面试时能报出几个符号,而在于它能让你在写代码的那一刻,对“数据量涨上去会怎样”有一个提前的预判。

很多人学这块的时候卡在同一个地方:书上先讲 T(n),再讲 O(n),两个东西都带个 n,看着像双胞胎,到底谁是谁、为什么需要两个,没人说清楚。我当年也是背了一堆“O(1)、O(log n)、O(n)、O(n log n)、O(n²)”的排序表,做题能对,但真到了项目里,看到一个嵌套循环套着一个列表推导,还是判断不出真实开销。后来想明白了:T(n) 和 O(n) 分工完全不同,一个是“算账”,一个是“看趋势”。算账要精确,看趋势要模糊。把这两个心态分清楚,后面的一切都顺了。

这篇内容适合三类人:正在准备技术面试、需要把复杂度分析讲明白的人;写了几年业务代码、但从来没系统推过一次复杂度的人;以及那些被线上性能问题教育过、想搞明白“为什么慢”的人。下面我会从怎么把代码写成 T(n) 这个函数开始,一路推到 O(n) 的严格定义、循环和递归的拆解套路、两个堆求中位数这种经典场景的完整复杂度推导,最后落到排序算法的全景对比和几个我踩过的坑。所有推导我都会把中间过程写出来,你不需要数学很好,只需要会数数。

1.1 先把最容易混淆的一句话钉死

T(n) 是运行时间函数:输入规模为 n 时,这段代码大概要执行多少条基本操作,或者准确地说,需要多少时间单位。它是一个具体的表达式,比如 T(n) = 3n² + 5n + 7,带系数、带低阶项,甚至可以带常数 7,因为常数项代表了那段跟 n 无关的固定开销。

O(n) 是渐进上界,它描述的是当 n 趋向无穷大时,T(n) 的增长量级被什么东西“罩住”了。3n² + 5n + 7 的渐进复杂度是 O(n²),因为 n 大了以后,n² 这一项说了算。

关键区别在这:T(n) 关心“现在这段代码具体要花多少”,O(n) 关心“数据量涨十倍,开销大概涨几倍”。前者是工程上的精确核算,后者是架构上的趋势判断。做性能优化,两个都要用:先用 O(n) 判断这个模块能不能扛住未来的量,如果结论是“不能”,再用 T(n) 去抠常数、抠那一层循环,把实际耗时压下来。

1.2 一个反直觉的实测:O(n) 更差的算法跑得反而更快

这是最容易被忽略的一点,我用一个真实经历说明。早期写一个字符串匹配的小工具,输入是几万条短文本,我一开始用了一个 O(n²) 的朴素双重循环,实测 3000 条数据 8 毫秒。后来为了“优化”,换成了一个理论上 O(n) 的复杂哈希方案,结果同样的数据跑了 45 毫秒。

原因很朴素:O(n²) 那段的实际操作是数组下标比较,两个机器指令;O(n) 那个方案每次都要算哈希、处理冲突、访问散列桶,单次操作的成本高了二三十倍。在 n 只有几千的时候,n² 才几百万次廉价操作,而“O(n)”方案是几千次昂贵操作乘以一个巨大的常数。渐进复杂度只在 n 足够大的时候才统治一切。所以你在做技术选型时,如果数据规模有明确上界(比如“配置表最多 50 条”),直接测,别迷信大 O;如果没有上界,老老实实按 O(n) 选。

2. T(n) 到底怎么算:把代码翻译成一个函数

T(n) 的构造过程其实非常机械,就是一个“数操作”的过程。你只需要做三件事:找到输入规模 n 指的是什么、数出代码执行了多少次基本操作、把这些次数写成 n 的表达式。基本操作的定义很宽松,一次赋值、一次比较、一次算术运算、一次数组访问,都可以算作一次。选择哪个作为“基本单位”不影响最终结论,只要保持一致就行。

2.1 从最简单的顺序代码开始

看这段代码:

def add_one(nums): total = 0 # 1 次赋值 for x in nums: # 循环 n 次 total += x # 每次 1 次加法 + 1 次赋值 return total # 1 次返回

输入规模 n = len(nums)。逐行数:第 1 行执行 1 次;循环变量迭代 n 次,每次判断一次是否结束、取一次元素,保守算 2 次,共 2n;循环体里加法加赋值算 2 次,共 2n;最后 return 1 次。于是:

T(n) = 1 + 2n + 2n + 1 = 4n + 2

你可能会说,循环判断的次数其实是 n+1 次(最后一次判断失败退出),那应该是 3n+3 之类的。完全没问题,因为这些差异最后都会被 O(n) 吞掉。T(n) 允许你不精确,只要量级对、系数别差一个数量级,就是一个可用的 T(n)。

2.2 嵌套循环:乘法关系是怎么来的

def count_pairs(nums): cnt = 0 for i in range(len(nums)): # 外层 n 次 for j in range(len(nums)): # 内层每次都跑 n 次 if nums[i] < nums[j]: cnt += 1 return cnt

外层每执行一次,内层完整跑一遍 n 次,所以内层循环体的总执行次数是 n × n。T(n) = c₁n² + c₂n + c₃,其中 c₁n² 来自内层比较和自增。这里有一个判断技巧:嵌套循环看乘法,并列循环看加法。两个 for 是嵌套关系,就是乘;两个 for 是先后关系,就是加。这个规则能覆盖 80% 的业务代码。

2.3 常数系数为什么不能丢:一个真实的优化案例

假设一个接口的 T(n) = 1000n + 500,另一个方案是 T(n) = 3n² + 10n。单看 O(n),第一个是 O(n),第二个是 O(n²),闭着眼睛选第一个。但如果你告诉我 n 最大只有 20,那第一个是 20500,第二个是 1400,第二个快 14 倍。常数系数在 n 小的区间里就是决定因素

我在做批量数据清洗的时候遇到过一模一样的情况。一个 O(n²) 的两两比较去重,n 是每条记录的字段数,通常不超过 15;而我一开始为了“性能”引入了一个基于哈希表的方案,每次要构造元组、算哈希、比对冲突。实测下来,字段数 15 以内时,双重循环版本稳定快 2 到 3 倍。后来我把阈值写成常量:字段数 ≤ 20 走双重循环,> 20 走哈希。这个“混合策略”在工程里非常常见,Python 的 Timsort 内部对小数组用插入排序,就是这个思路。

真正要用 T(n) 做决策的时候,别忘了加上那句灵魂拷问:n 的真实上界是多少。如果上界很小,常数项才是战场;如果上界很大或者不可控,那就让 O(n) 说话。

3. O(n) 的严格定义:三句话说清渐进复杂度

渐进复杂度的严谨定义,很多教材写得像绕口令,我用尽量直白的方式重述一遍,然后告诉你工程上怎么用。

3.1 上界的定义:存在两个常数就够了

O(g(n)) 的定义是:存在正常数 c 和 n₀,使得对所有 n ≥ n₀,都有 T(n) ≤ c·g(n)。

翻译成人话:从某个规模开始,T(n) 永远被 g(n) 的某个倍数压住。3n² + 5n + 7 是 O(n²),取 c = 4、n₀ = 10 就能验证:n ≥ 10 时,3n² + 5n + 7 ≤ 4n²(因为 4n² - 3n² - 5n - 7 = n² - 5n - 7,n=10 时是 43 > 0,之后单调递增)。你也可以取 c = 15、n₀ = 1,同样成立。常数 c 取多少不重要,存在就行,这就是“渐进”二字的分量。

顺便把两个经常一起出现但很少被讲清的符号带上:Ω 是下界,意思是“至少这么多”;Θ 是紧确界,上界下界同阶。说“快排的平均复杂度是 Θ(n log n)”比说 O(n log n) 更准确,因为 O 只保证不更差,Θ 才说明它就是这样。

3.2 化简三规则:丢系数、丢低阶、只看最高次

从 T(n) 推到 O(n),只有三步:

第一,丢掉所有常数系数。4n + 2 变成 n,3n² + 5n + 7 里的 3 丢掉,变成 n² + n。

第二,丢掉所有低阶项。n² + n 只留 n²,因为 n 趋向无穷时 n 的影响可以忽略。你可以验证:n = 1000 时,n² = 1,000,000,n = 1000,后者占比 0.1%。

第三,只保留最高次项,写进括号里。结果是 O(n²)。

有个小细节值得注意:如果 T(n) 里有多个不同底的指数,比如 2ⁿ + n¹⁰⁰,答案是 O(2ⁿ),因为指数增长最终碾压任何多项式。同样地,n log n 比 n 高、比 n² 低,它不满足任何“只剩一项”的直觉,要单独记住。

3.3 增长速度对照:把抽象函数变成可感知的数字

光看符号没有体感,把 n 代进去算一遍就清楚了。假设单次基本操作耗时 1 纳秒(这是乐观估计,实际访问内存大约 100 纳秒量级),不同复杂度在不同规模下的耗时大致如下:

复杂度n = 100n = 10,000n = 1,000,000典型场景
O(1)1 ns1 ns1 ns哈希表查找、数组下标
O(log n)约 7 ns约 14 ns约 20 ns二分查找、平衡树操作
O(n)100 ns10 μs1 ms一次遍历、求和
O(n log n)约 700 ns约 140 μs约 20 ms归并排序、堆排序
O(n²)10 μs100 ms约 11.6 天双重循环、朴素去重
O(2ⁿ)天文数字天文数字天文数字暴力枚举子集

最后一行不用算,n = 60 的时候 2⁶⁰ 就已经超过 10¹⁸ 次操作,按每秒十亿次计算也要三十多年。我第一次看到这张表时的震撼在于:O(n²) 和 O(n log n) 在 n = 100 时只差一个数量级,在 n = 1,000,000 时差了五个数量级。这解释了为什么“本地跑得挺快”的代码上线就崩——你的测试数据量还没到分水岭。

提示:记忆这张表的时候,抓住三条线就够了——log n 约等于“把 n 反复减半需要几次”,n log n 约等于“每个元素都要参与 log n 次操作”,n² 是“任意两个元素两两碰面”。

4. 代码逐段拆解:循环、递归、均摊三种套路

有了前面的规则,接下来是实操。大部分复杂度分析可以归到三类结构:循环、递归、均摊。每类有固定的拆解姿势,熟练之后看代码几乎能条件反射。

4.1 循环变量在跳:对数复杂度的来源

def halve_count(n): steps = 0 while n > 1: n = n // 2 steps += 1 return steps

这个循环跑多少次?n 每次减半,从 n 到 1 需要 log₂n 次,所以 T(n) = log₂n + 1,复杂度 O(log n)。换成 n = n // 3 呢?底数变了,但 O(log n) 不变,因为换底公式 log₃n = log₂n / log₂3,差的是一个常数因子,被丢掉了。所有“每次把问题规模按固定比例缩小”的代码,都是 O(log n),二分查找、平衡树的查找、快速幂都是这个模式。

再看一个容易看错的:

def tricky(n): i = 1 total = 0 while i < n: j = 0 while j < i: total += 1 j += 1 i *= 2 return total

外层 i 按 1, 2, 4, 8... 增长,到 n 需要 log n 次。内层跑 i 次,累计是 1 + 2 + 4 + ... + n/2,这是一个等比数列,和小于 2n。所以整体是 O(n),不是 O(n log n)。内层规模随外层变化时,要算求和,不能简单相乘。这个坑我踩过,当时把一段这样的代码标成了 O(n log n),被同事纠正后才养成“看内层上界是否依赖外层变量”的习惯。

4.2 递归式的拆解:画递归树比背公式管用

递归代码的复杂度不能直接数循环,得写出递归式。以归并排序为例:

T(n) = 2T(n/2) + O(n)

含义是:把问题分成两个规模为 n/2 的子问题(对应 2T(n/2)),合并两个有序数组需要 O(n)。

拆解方法一,递归树:第一层合并代价 n,第二层两个子问题各 n/2,合计仍是 n,第三层合计 n……一共 log₂n 层,每层 n,总代价 O(n log n)。这个方法形象,我强烈建议先学它,因为主定理(Master Theorem)用错了很麻烦,而递归树很少出错。

拆解方法二,主定理:对于 T(n) = aT(n/b) + f(n),比较 f(n) 和 n^(log_b a):

情况条件结论
情况一f(n) 明显小于 n^(log_b a)T(n) = Θ(n^(log_b a))
情况二f(n) 与 n^(log_b a) 同阶T(n) = Θ(n^(log_b a) · log n)
情况三f(n) 明显大于 n^(log_b a) 且满足正则条件T(n) = Θ(f(n))

拿归并排序套一遍:a = 2,b = 2,n^(log₂2) = n,f(n) = n,同阶,落入情况二,答案是 Θ(n log n),和递归树一致。二分查找是 T(n) = T(n/2) + O(1),a = 1,n^(log₂1) = n⁰ = 1,f(n) = 1,同阶,情况二给出 Θ(log n),也对得上。

必须提醒一个陷阱:主定理只适用于a ≥ 1、b > 1、且子问题规模严格按 n/b 缩小的形式。像 T(n) = T(n-1) + O(n) 这种每次只减一个的,主定理不适用,得展开成等差数列 n + (n-1) + ... + 1 = O(n²)。我看到过有人硬套主定理算出 O(n),结果面试挂掉——判断递归式是不是“分治型”,是套公式之前的第一步。

4.3 均摊复杂度:动态数组扩容为什么还是 O(1)

Python 的 list.append 平均复杂度是 O(1),但某些单次调用会明显变慢,因为底层数组满了要扩容并整体拷贝。这是不是矛盾?不矛盾,这就要引入均摊复杂度

假设每次扩容翻倍,从容量 1 开始连续 append n 次。拷贝发生的时刻是容量 1、2、4、8……拷贝的总代价是 1 + 2 + 4 + ... + n ≈ 2n。把这 2n 次拷贝操作分摊到 n 次 append 上,每次平均 2 次操作,所以均摊复杂度是 O(1)。翻倍策略是精髓所在:如果每次只扩容固定大小(比如每次加 10),拷贝总代价会变成 O(n²/10) = O(n²),均摊就是 O(n)。这就是为什么几乎所有语言的标准库动态数组都用翻倍而不是线性扩容

这个例子的意义在于,复杂度分析的最终目的不是给每一行代码定罪,而是给你一个在长周期上成立的期望值。写高并发场景时,我更关心 P99 延迟,而均摊分析恰好会掩盖掉偶发的长尾——所以扩容那一刻的停顿,在设计实时系统时要单独考虑,比如预分配容量或者改用分块结构。

5. 经典实战:用两个堆维护中位数,插入 O(log n) 是怎么推出来的

数据流的中位数问题特别适合用来练复杂度分析。需求是:不断有数字进来,随时能查询当前所有数字的中位数,数据量可能到几百万。朴素做法是每次查询前排序,或者维护一个有序数组每次插入用二分找到位置再搬移元素。下面把三种方案的复杂度全部算清楚,你会看到 O(log n) 是怎么被逼出来的。

5.1 朴素方案的复杂度账本

方案一,每次查询都全量排序:排序是 O(n log n),如果查询 q 次,总共 O(qn log n)。数据量一万、查询一万次就是 10⁸ 量级的操作,卡。

方案二,维护有序数组,插入时二分查找位置 O(log n),但插入本身要移动后面所有元素,最坏 O(n)。查询时直接取下标 O(1)。所以插入 O(n)、查询 O(1)。整体 n 次插入是 O(n²)。

方案三,两个堆。插入 O(log n),查询 O(1)。这才是有工程价值的方案。

5.2 两个堆的分工:大顶堆放小半,小顶堆放大半

核心思想是把数据劈成两半,用一个分界线左右各管一边:

  • 大顶堆(max-heap)存较小的一半数据,堆顶是这半边的最大值,也就是“靠近中位数的左边那个”。
  • 小顶堆(min-heap)存较大的一半数据,堆顶是这半边的最小值,也就是“靠近中位数的右边那个”。

维持两条不变式:

  1. 大顶堆里所有元素 ≤ 小顶堆里所有元素。
  2. 两个堆的大小差不超过 1,且约定大顶堆的大小 ≥ 小顶堆的大小。

只要这两条成立,中位数就只跟堆顶有关:如果两个堆大小相等,中位数是两个堆顶的平均值;如果大顶堆多一个,中位数就是大顶堆的堆顶。查询不需要遍历任何数据,O(1) 拿下。

5.3 插入流程与 O(log n) 的推导

插入一个数 x,标准流程分三步:

import heapq class MedianFinder: def __init__(self): self.small = [] # 大顶堆,存负值 self.large = [] # 小顶堆 def add(self, x): # 第一步:先塞进大顶堆 heapq.heappush(self.small, -x) # 第二步:把大顶堆的最大值挪到小顶堆,保证左右有序 heapq.heappush(self.large, -heapq.heappop(self.small)) # 第三步:如果小顶堆反超了,挪回来,维持大小不变式 if len(self.large) > len(self.small): heapq.heappush(self.small, -heapq.heappop(self.large)) def median(self): if len(self.small) > len(self.large): return -self.small[0] return (-self.small[0] + self.large[0]) / 2

复杂度怎么算:整个插入过程最多执行 3 次堆操作(1 次 push、1 次 pop 加 push、可能再来 1 次 pop 加 push),每次堆操作是 O(log n)。常数次乘对数,结果是 O(log n)。空间上两个堆一共存 n 个数,O(n)。

这里是全局最值得记住的一句话:常数个 O(log n) 操作相加,还是 O(log n)。很多人推复杂度时会纠结到底做了 2 次还是 3 次堆操作,答案是不用纠结,数量的常数倍不会改变量级。

注意:Python 只有小顶堆,模拟大顶堆要存负数。别小看这个细节,负号在插入和取值时都要成对出现,漏一个取出来就是反的,我调试过一个小时才发现。

5.4 边界条件与几个容易翻车的地方

第一,堆大小差的约束方向要固定。上面代码约定大顶堆不小于小顶堆,那么中位数在元素个数为奇数时一定在大顶堆顶。如果你允许小顶堆多一个,median 函数里判断奇偶的分支就得反过来,写反了会返回错误结果且不报错。

第二,空数据流。如果没有元素,两个堆都空,直接取堆顶会抛异常。工程代码里要么返回 None,要么在调用方保证非空,别让它静默崩掉。

第三,多线程。这个结构不是线程安全的,两个堆的读写必须加锁,或者用队列把插入操作串行化。加锁之后单次插入从 O(log n) 变成 O(log n) 加上锁开销,量级没变,但常数会涨,高并发下要实测。

第四,数值类型。如果数据是浮点数,两个堆顶取平均值没有精度问题;如果是大整数,注意 Python 的整数运算不会溢出,其他语言要留意。

最后对比一下三种方案的实际表现。按 n = 100 万、插入和查询各 100 万次估算:方案二的大规模搬移约 10¹² 次内存操作,基本不可接受;方案三的堆操作约 100 万 × 20 × 3 = 6 × 10⁷ 次,加上每次的堆调整常数,秒级能跑完。这就是渐进复杂度从 O(n) 降到 O(log n) 带来的实际差别,量级上的降维打击。

6. 排序算法的复杂度全景:为什么工程上不总选最快的那个

排序是复杂度分析最好的练习场,因为同一个问题有五六种解法,复杂度各不相同,而且工程选型时的取舍逻辑特别典型。

6.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 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(n + k)O(n + k)O(n + k)O(k)稳定只适合小范围整数,k 是取值范围
基数排序O(d(n + k))O(d(n + k))O(d(n + k))O(n + k)稳定d 是位数,适合定长整数或字符串
桶排序O(n + k)O(n²)O(n)O(n + k)稳定数据分布均匀时接近线性

6.2 比较排序的下界:为什么 O(n log n) 是天花板

你可能会想,有没有 O(n) 的通用排序?没有。用决策树可以证明这一点:n 个元素共有 n! 种排列,排序算法必须能区分所有情况,对应的决策树至少有 n! 个叶子节点。一棵二叉树高度为 h 时最多有 2ʰ 个叶子,所以 2ʰ ≥ n!,取对数得到 h ≥ log₂(n!) 。用斯特林近似,log₂(n!) ≈ n log₂n - 1.44n,也就是 Θ(n log n)。任何基于两两比较的排序,最坏情况都不可能低于这个量级

这解释了两件事:第一,为什么快排、归并、堆排序的平均复杂度都停在 O(n log n),它们已经摸到理论天花板,再挤只能挤常数;第二,为什么计数排序能突破到 O(n + k)——因为它不比较,而是直接利用数值本身作为下标,绕过了决策树的前提。

6.3 快排最坏 O(n²),为什么标准库还是用它

这是个高频疑问。快排最坏情况确实是 O(n²),发生在每次选的基准都是当前区间的最值时,比如对已经有序的数组用固定取首元素的策略。但标准库的实现做了两件事规避它:随机化选基准(或者三数取中),以及小区间切换插入排序。随机化之后,出现最坏情况的概率极低,而且平均复杂度稳定在 O(n log n),常数因子还特别小。

对比堆排序:堆排序最坏也是 O(n log n),听起来更稳妥,但实际跑起来通常比快排慢 2 到 3 倍,原因是堆的操作在内存里是“跳着访问”的,缓存命中率差。而快排是顺序扫描,和 CPU 缓存、分支预测配合得很好。渐进复杂度相同的两个算法,实际的 T(n) 常数可以差好几倍,这就是为什么大厂面试问你复杂度,而工程上还得看基准测试。

顺带说一句稳定性。业务里排序对象经常是结构体,要求“按分数排序,分数相同的保持原顺序”,那就必须用稳定排序。快排和堆排序不稳定,归并和插入稳定。Python 的 sorted 用的是 Timsort,实际是归并加插入的混合体,稳定且对部分有序数据接近 O(n),这也是它比纯快排更适合业务代码的原因。

7. 空间复杂度、递归栈和那些让人栽跟头的分析误区

复杂度分析做多了,你会发现错误很少出在数学上,基本都是“假设没想清楚”。下面这几条是我和身边人真实踩过的。

7.1 递归函数的空间复杂度必须算调用栈

写递归的时候,很多人只算函数里声明的变量,忘了每一层递归都会在栈上占一份空间。二分查找的递归版本空间复杂度不是 O(1),而是 O(log n),因为有 log n 层调用同时存在。快速排序的递归实现,虽然原地交换不额外申请数组,但递归栈深度在平均情况下是 O(log n),最坏是 O(n)。所以“原地快排空间 O(1)”这个说法是不严谨的。

反过来,递归改成循环通常能把空间降下来。我把一个深度可能上万层的树遍历从递归改成显式栈之后,不仅空间可控,还避免了某些语言栈溢出导致进程直接挂掉的风险。空间复杂度的实战意义往往比时间复杂度更直接,因为它决定了你的服务会不会崩

7.2 平均、最坏、均摊,三个词对应三种承诺

这三个词经常被混用,但语义完全不同:

  • 最坏复杂度:对任何输入都成立的上界。适合实时系统、对延迟敏感的场景。
  • 平均复杂度:假设输入按某种分布随机。快排的 O(n log n) 属于这一类,前提是基准随机。
  • 均摊复杂度:一系列操作的总代价除以操作次数。适合长期运行的累积场景。

我见过最典型的误用是:拿哈希表的“平均 O(1)”去支撑一个对抗性输入的场景。如果攻击者能构造大量冲突的键,哈希表会退化成链表,查询变成 O(n)。所以线上服务如果用哈希结构处理用户可控的输入,要么用随机化的哈希种子,要么准备好在冲突时的退化路径。

7.3 实测验证:让纸上推导落地

推导完了,最好用实测对齐一次,方法很简单:把规模翻倍,看耗时涨多少倍

  • 耗时基本不变,O(1);
  • 耗时增加约 1 个单位,O(log n);
  • 耗时翻倍,O(n);
  • 耗时增加略多于 2 倍(比如 2.2 倍),O(n log n);
  • 耗时涨 4 倍,O(n²)。

我做过一个调试技巧:给函数加个计数器,统计基本操作执行了多少次,直接把计数值和理论公式比对。这个手段在排查“复杂度分析对了但实际很慢”的问题时特别有效——如果计数值符合 O(n log n),但墙钟时间远超预期,那问题就在常数上,可能是内存分配、缓存不友好,或者某个隐藏的昂贵操作,比如字符串拼接、隐式的列表拷贝。

8. 几个高频问题的快速对照

把平时被问得最多的几个问题整理成对照表,遇到卡壳的时候可以直接查。

疑问结论说明
for 循环里面有个 in 判断,算 O(n) 还是 O(n²)看 in 的实现列表的 in 是 O(n),整体 O(n²);集合的 in 是 O(1),整体 O(n)
字符串拼接为什么这么慢每次拼接都新建对象循环里 += 是 O(n²),改用列表收集再 join 变 O(n)
字典取值是不是 O(1)平均是,最坏 O(n)哈希冲突严重时退化
递归和迭代复杂度一样吗时间通常一样,空间不同递归多了调用栈开销
两个嵌套循环一定是 O(n²) 吗不一定如果内层上界是常数,或上界随外层变化且求和后收敛,结果可能更低
复杂度相同的算法怎么选看常数、缓存行为和实测例如快排 vs 堆排

关于字符串拼接这条,我印象很深。早年写日志拼接,循环里用s += line,处理十万行数据花了十几秒。改成parts.append(line)最后''.join(parts)之后,降到 0.2 秒以内。代码逻辑一行没改,复杂度从 O(n²) 变成 O(n),这就是分析能力直接换成性能的地方。

再补一个很多人忽略的点:Python 里 list 的 insert(0, x) 是 O(n),因为它要把后面所有元素往后挪。如果需要频繁在头部插入,用 collections.deque,它的 appendleft 是 O(1)。这类 API 的复杂度差异不会在代码里写出来,但会实实在在地影响你的程序。我的习惯是,用到一个不熟悉的方法时,先去文档确认它的复杂度,尤其是那些看起来“应该很快”的操作。

回到最开始那个 20 毫秒变 40 分钟的例子,事后复盘,罪魁祸首是一个嵌套循环里对列表做了 in 判断,输入规模从 1000 涨到 10 万,正好撞在 O(n²) 的墙上。改法很简单,把那个列表换成集合,T(n) 从 c₁n² + c₂n 直接掉到 c₃n,耗时回到几十毫秒。整个过程最有价值的部分不是修复本身,而是养成一个习惯:写完一段带循环的代码,先问自己两句话——输入规模 n 是什么,这段代码对 n 是几次方。这两句话问下去,大部分性能事故都能在提交代码之前拦下来。至于那些边界情况、常数陷阱和均摊假设,都是在实际项目里被咬过几次之后才慢慢长出来的直觉,光看公式是长不出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询