算法复杂度

用渐近记号估算时间与空间增长,并为实际输入选择可扩展的实现。

难度 入门 时长 标准深度约 15分钟
版本 Node 24
what

算法复杂度描述算法所需时间或额外存储如何随输入增长,不受某一台机器或某一次基准测试限制。

trap

O(n) 表示增长上界,不表示具体时长;n 必须对应真正决定工作量的输入维度。

fix

先定义输入规模与成本模型,再推导相关情形下的时间、空间界限,最后测量代表性极限并选择实现。

是什么,为什么存在

算法复杂度是一种资源增长模型。它把订单数、图的顶点数或字节数等输入规模,与算法所需工作量或额外存储联系起来。模型通常关注输入足够大时的行为,因此使用渐近记号(asymptotic notation)。

时间复杂度统计选定的工作单元,不直接统计秒数。单元可以是比较次数、哈希表操作次数、访问的节点数或解码的字节数。空间复杂度统计随输入规模变化的内存,通常把辅助工作空间与输入本身及必要输出占用的存储分开。

这个模型与基准测试回答不同问题。基准测试说明某个实现在特定数据、硬件、运行时和负载下的表现。复杂度则解释为什么输入翻倍后,工作量可能近乎不变、变为两倍或变为四倍。

这种区别让复杂度结论更持久。处理器速度和运行时优化可以改变常数,却无法挽救操作次数相对目标输入增长过快的实现。分析还能揭示取舍,例如用线性额外空间保存集合,避免二次增长的重复项搜索。

只要集合会增长、循环会嵌套、递归调用会分支,或服务会接收用户可控输入,就会用到复杂度。它能辅助代码审查、容量规划、API 设计、拒绝服务防护和数据结构选择。复杂度本身不能证明正确性、延迟或安全性。

有效的复杂度陈述会写明假设。「在哈希表约定下,包含 n 个条目时查找的期望复杂度为 O(1)」具有可操作性,「这个很快」则没有。陈述还应说明它描述最坏、期望、摊还还是其他情形。

复杂度只是多项要求之一。具有更好界限的实现仍可能出错、泄露数据、改变结果顺序,或超出较小的固定延迟预算。应先保留行为约定,再比较正确候选方案的资源增长。

渐近类别相同的两个实现,实际扩展能力也可能不同。一次线性扫描可能为每个元素分配对象,另一次则连续读取内存。共同的 O(n) 标签是比较的起点,不是结论。

工作原理

计数前先定义输入

第一步是选择输入维度。对于扫描一个数组的函数,n 可以表示数组长度。对于图,通常要同时保留顶点数 V 和边数 E;全部压成一个 n,就会隐藏图是稀疏还是稠密。

多个独立输入应该使用多个变量。把每个新商品与目录中的每个商品比较,需要 O(nm) 时间,其中 nm 分别是两个集合的大小。称其为 O(n²),就暗中假设二者总是一起增长。

输入的数值可能不同于其表示长度。对整数 x 试除到平方根附近,工作量与 √x 有关,但二进制整数只需要大约 log₂ x 位。分析密码与数值算法时,因此要说明规模指值、位数、行数还是字节数。

输入模型也可以包含结构参数。树的高度、字符串编码后的字节长度,或每个节点的最大邻居数,可能比元素总数更能预测成本。如果产品限制或恶意输入能让某个参数独立变化,就应保留它。

选择成本模型

应统计对当前决策足够稳定的操作。基于比较的搜索可以统计比较次数,解析器可以统计检查的字节数。如果把每次库函数调用都算作一步,就可能漏掉 includes()sort()、字符串拼接或序列化内部依赖输入规模的工作。

模型可以有意抽象机器细节。在具体语言和数据表示允许时,通常把数组索引与定长算术视为常数时间。如果数值可以无限增长,对一千位整数做运算就不能再等同于机器字运算。

选定单元后,可以推导出 3n + 7 次比较这样的函数。渐近分析按增长方式归类函数,并忽略常数因子和低阶项,因此 3n + 7 属于 O(n)。被忽略的项对小输入和基准测试仍有影响,只是不改变增长类别。

峰值资源与累计工作是不同指标。依次分配并释放十个大小为 n 字节的缓冲区,峰值辅助空间为 O(n),却会执行十次 O(n) 分配工作。应说清限制的是哪种资源,避免把内存结论误解为分配速率结论。

识别常见增长类别

常见类别构成逐级收紧的容量阶梯。它们描述增长形状,不承诺实际用时。

类别常见来源较大的 n 翻倍后的影响
O(1)按索引直接查找大致不变
O(log n)不断把剩余范围减半大约多一步
O(n)每个元素访问一次工作量大约翻倍
O(n log n)在对数层级中处理每个元素略多于两倍
O(n²)比较每一对元素工作量大约变为四倍
O(2ⁿ)探索每种子集选择n 翻倍后工作量平方增长

「常数时间」不等于瞬间完成,「线性」也不等于缓慢。一次 O(1) 的远程请求,可能比扫描二十个内存值的 O(n) 操作更久。当变化的输入大到足以让增长压过常数时,复杂度才成为决定因素。

多项式类别与指数类别的容量上限差异很大。指数搜索处理二十个选项时可能可行,处理一百个时则无法完成;如果行数硬性限制为五十,二次扫描也可能完全可以接受。批准或拒绝方案之前,要把类别换算到真实输入范围。

说明界限的含义

大 O 记号给出渐近上界:超过某个规模后,增长不会快于乘上常数因子的比较函数。大 Omega 记号给出下界,大 Theta 记号给出相互匹配的上界与下界。工程交流常用「大 O」指紧确增长类别,因此要确认对方是否只在陈述一个上界。

线性扫描的最坏情形是 O(n),因为它可能检查所有元素。第一个元素就匹配时,最好情形是 O(1)。平均或期望界限需要输入分布、随机性假设或数据结构约定;它不是最好与最坏情形的算术平均值。

最坏情形分析给出上限,特别适合延迟预算或对抗性输入。假设与生产环境匹配时,期望分析可能更能预测常规运行。如果最坏情形明显不同并且确实可达,就应同时报告二者。

平均情况结论应说明具体对什么取平均。对象可以是所有输入排列、观测到的生产流量、随机哈希种子,或一系列操作。这些分布不能互换,昨天的流量样本也未必覆盖明天的滥用模式。

根据代码结构组合成本

顺序阶段的成本相加。先做 O(n) 验证,再做 O(n log n) 排序,总成本是 O(n + n log n),可简化为 O(n log n)。简化并不允许删除验证,只是指出 n 增长后哪一项占主导。

嵌套工作往往使成本相乘。两个循环都遍历全部 n 个元素时,会产生 次迭代,但嵌套循环不一定是二次复杂度。如果两个指针在整个运行中只向前移动,总成本仍可能是 O(n),即使一个循环写在另一个内部。

每次按固定比例缩小问题的工作通常是对数复杂度。二分搜索每次比较都把有序范围减半,因此剩余减半次数与 log n 成正比。预先排序不是免费操作:先做 O(n log n) 排序再搜索一次,端到端成本并不是 O(log n)

分析递归代码时,要看它创建多少子问题、子问题规模如何变化,以及递归调用之外还有多少工作。记忆化可以合并重复子问题,但缓存规模随后会进入空间界限。即使每个栈帧只做常数工作,深度为 n 的递归也会消耗 O(n) 栈空间。

分析依赖数据的循环时,可以求和,不要只看缩进。如果第 i 次迭代检查 i 个元素,总工作量就是 1 + 2 + ... + n,即 Θ(n²)。如果每个元素最多只被移除或越过一次,外观相似的嵌套结构也可能只合计为 Θ(n)

统计空间与操作序列

辅助空间指输入与必要输出之外使用的内存。成对比较的重复项检查使用 O(1) 辅助空间,基于集合的检查最多保存 n 个标识符,使用 O(n) 空间。对输出敏感的算法还会单独报告输出空间,因为返回 k 个匹配项必然至少需要 O(k) 存储。

有些操作偶尔昂贵,但放到整个序列中很便宜。按几何倍数增长的数组扩容时有时要复制所有现有元素,不过 n 次追加的总复制量仍是线性。追加成本因此是 摊还 O(1) ,尽管单次追加可能达到 O(n)

摊还分析与平均情况分析不是同义词。摊还分析限制任意相关操作序列的总成本,再把成本分摊到整个序列,不需要概率分布。平均情况分析则依赖输入或操作的分布方式。

空间分析需要生命周期边界,正如时间分析需要操作边界。每个请求使用的集合可以在请求结束后回收,进程级记忆化表则会跨请求累积。二者都可能相对各自的 n 保留 O(n) 个条目,但只有后者会让内存关联整个生命周期内的流量基数。

示例

以下示例统计模型中的操作次数,不对微型程序计时。结果可以复现,并能直接展示增长方式;常数与运行时行为是否适合真实服务,仍要通过后续测量判断。

每个计数器都是示例的一部分,不是通用性能分析器。它只记录相邻分析指定的操作,如果改变工作单元,就必须同时修改插桩与复杂度结论。

比较增长形状

第一个程序把五种复杂度类别变成粗略操作预算。输入采用二的幂,便于检查对数列。

growth_table.js
const estimates = [
  ["O(1)", () => 1],
  ["O(log n)", (n) => Math.ceil(Math.log2(n))],
  ["O(n)", (n) => n],
  ["O(n log n)", (n) => n * Math.ceil(Math.log2(n))],
  ["O(n^2)", (n) => n * n],
];

for (const inputSize of [8, 64, 512]) {
  const row = estimates
    .map(([label, steps]) => `${label}=${steps(inputSize)}`)
    .join(", ");
  console.log(`n=${inputSize}: ${row}`);
}
n=8: O(1)=1, O(log n)=3, O(n)=8, O(n log n)=24, O(n^2)=64
n=64: O(1)=1, O(log n)=6, O(n)=64, O(n log n)=384, O(n^2)=4096
n=512: O(1)=1, O(log n)=9, O(n)=512, O(n log n)=4608, O(n^2)=262144

n 扩大八倍,对数估算值只增加三步。同样的变化会让二次估算值变为六十四倍。这些函数经过刻意简化,不是在预测纳秒数。

n = 0 还说明了定义输入域的重要性。log₂ 0 不是有限的操作次数,因此真实约定需要处理基础情形。复杂度记号描述超过某个阈值后的增长,不能替代边界验证。

表格使用向上取整,因为一次不可分割的减半操作不可能只执行一部分。其他成本模型可能相差一个小常数,但仍属于同一个对数类别。

消除二次增长的重复项搜索

订单导入器需要拒绝重复标识符。第一个实现在不分配集合的情况下比较每一对标识符,第二个实现用集合记住已经见过的标识符。

duplicate_orders.js
function findDuplicatePairwise(orderIds) {
  let operations = 0;
  for (let left = 0; left < orderIds.length; left += 1) {
    for (let right = left + 1; right < orderIds.length; right += 1) {
      operations += 1;
      if (orderIds[left] === orderIds[right]) {
        return { duplicate: orderIds[left], operations };
      }
    }
  }
  return { duplicate: null, operations };
}

function findDuplicateWithSet(orderIds) {
  const seen = new Set();
  let operations = 0;
  for (const orderId of orderIds) {
    operations += 1;
    if (seen.has(orderId)) return { duplicate: orderId, operations };
    seen.add(orderId);
  }
  return { duplicate: null, operations };
}

const orders = ["A-102", "B-205", "C-330", "D-404", "C-330"];
console.log("pairwise:", findDuplicatePairwise(orders));
console.log("set:", findDuplicateWithSet(orders));

for (const size of [10, 100]) {
  const unique = Array.from({ length: size }, (_, index) => `O-${index}`);
  console.log(`unique ${size}: pairwise=${findDuplicatePairwise(unique).operations}, set=${findDuplicateWithSet(unique).operations}`);
}
pairwise: { duplicate: 'C-330', operations: 9 }
set: { duplicate: 'C-330', operations: 5 }
unique 10: pairwise=45, set=10
unique 100: pairwise=4950, set=100

没有重复项时,成对比较版本执行 n(n - 1) / 2 次相等比较,因此最坏时间为 Θ(n²)。在集合的哈希约定下,集合版本为每个标识符做一次成员检查,期望时间为 Θ(n)。它使用 Θ(n) 辅助空间,成对比较版本则使用 Θ(1)

这些计数不能直接等同于 CPU 指令数,因为一次集合操作比一次字符串相等比较做的工作更多。不过输入包含一百个唯一标识符时,两者已经是 4,950 次模型操作与 100 次模型操作的差距。如果内存紧张或输入总是很小,应在预期交叉点附近对两个实现做基准测试。

较早出现重复项会减少两个实现实际执行的工作,却不会改变其最坏情形界限。测试中保留全部唯一的输入,才能迫使两个实现走完建立这些界限的路径。

观察追加操作的摊还成本

这个小型缓冲区暴露动态数组通常隐藏的扩容过程。每当追加时没有空槽,容量就翻倍,所以部分 push 会复制已有元素。

amortized_buffer.js
class OrderBuffer {
  #items = new Array(1);
  #size = 0;

  copiedSlots = 0;

  push(orderId) {
    if (this.#size === this.#items.length) {
      const grown = new Array(this.#items.length * 2);
      for (let index = 0; index < this.#size; index += 1) {
        grown[index] = this.#items[index];
        this.copiedSlots += 1;
      }
      this.#items = grown;
      console.log(`resize: capacity=${this.#items.length}, copied=${this.#size}`);
    }
    this.#items[this.#size] = orderId;
    this.#size += 1;
  }

  snapshot() {
    return this.#items.slice(0, this.#size);
  }
}

const buffer = new OrderBuffer();
for (let id = 1; id <= 8; id += 1) buffer.push(`O-${id}`);

console.log(`pushes=8, total copies=${buffer.copiedSlots}`);
console.log(buffer.snapshot().join(", "));
resize: capacity=2, copied=1
resize: capacity=4, copied=2
resize: capacity=8, copied=4
pushes=8, total copies=7
O-1, O-2, O-3, O-4, O-5, O-6, O-7, O-8

高成本的三次 push 分别复制一、二和四个槽位,八次追加总共复制七个槽位。继续扩展到容量十六时会增加八次复制,几何级数之和仍小于追加次数的两倍。这个总成本论证说明追加具有摊还 O(1) 复杂度。

按几何倍数增长是关键假设。如果容量每次只增加一个槽位,就会复制 1 + 2 + ... + (n - 1) 个元素,使整个追加序列达到 Θ(n²)。缓冲区还会保留未使用容量,因此其 O(n) 空间中的常数因子依然影响内存规划。

示例自行实现缓冲区,只是为了暴露计数过程。生产代码通常应使用运行时提供的集合,并依据它记录的行为,而不是替换成熟实现。

陷阱

把界限当作秒表

修复方法: 先用复杂度排除无法容纳目标输入的增长方式,再用代表性数据对剩余方案做基准测试,并报告单位、百分位、环境和方差。把推导出的操作次数与计时结果一起保留,让测量结果有可检查的解释。

n 选择错误含义

修复方法: 在复杂度之前先写简短输入模型,例如「n 行新数据、m 行目录数据,每个键 b 字节」。先保留独立变量,只有产品限制确实规定二者关系时再合并。

不写假设就声称平均复杂度

修复方法: 写明运行时或数据结构约定、键分布、随机性和冲突处理方式。如果单次慢请求也很重要,就增加最坏情形或百分位预算,并同时测试恶意输入和代表性输入。

隐藏重复的线性工作

修复方法: 展开循环体中每次调用的成本,并统计调用次数。用合适的索引或集合代替重复成员扫描,批量处理远程工作,再增加翻倍测试,记录操作次数或受控计时。

用无界空间换速度

修复方法: 在分析中加入辅助空间,并说明谁拥有这些条目。限制基数或生命周期,定义淘汰与清理方式,测试高唯一性流量;如果正确性不能依赖保留状态,还要提供无缓存路径。

只优化渐近标签

修复方法: 改变设计前,先记录支持的最大规模、当前操作次数和测得的交叉点。如果简单实现仍有充足余量,就接受它;同时设置回归阈值,明确何时需要重新评估取舍。

深入 把复杂度界限变成工程决策

把复杂度界限变成工程决策

界限是带量词的陈述

写出 T(n) = O(g(n)),是在声称存在常数 cn₀,使每个 n ≥ n₀ 都满足 T(n) ≤ c g(n)。阈值允许小输入有不规则表现,常数则允许形状相同的实现做不同数量的工作。只有大 O 记号时,并没有声称 g 是最小的有效上界。

产品上限从技术上可以让所有可接受输入都被一个常数约束,但因此把每个操作都称为 O(1),会让模型失去价值。应按自然变量分析增长,再单独说明强制上限。这样既保留有效比较,也给出具体运行保证。

例如,3n + 7 同时属于 O(n)O(n²),但只有线性描述是紧确的。上下界匹配时,有效的审查会要求给出 Θ(n)。某个实现的下界也不同于底层问题的下界;一个缓慢实现不能证明所有解法都必须缓慢。

输入形状可能比输入数量更重要

两个输入即使 n 相同,也可能走不同路径。提前退出取决于匹配位置,快速排序变体取决于划分是否平衡,图遍历取决于顶点数与边数,哈希表则取决于冲突情况。应报告选择这些路径的参数或情形,不要用平均值抹掉区别。

在信任边界上,对抗性输入需要单独处理。请求正文可能强制触发深层递归、病态匹配、过多哈希冲突或巨量输出,即使普通测试数据成本很低。因此,复杂度审查也是滥用场景分析的一部分,而不只是优化工作。

输出规模本身可以产生下界。如果端点必须返回 k 条匹配记录,发出这些记录至少需要 Ω(k) 时间,线上传输通常也至少需要 Ω(k) 字节。分页可以限制单次响应,但计算总数或保留游标可能只是把工作转移到别处。

稀疏表示与稠密表示说明了多个参数的重要性。邻接表占用 Θ(V + E) 空间,完整矩阵无论存在多少条边都占用 Θ(V²) 空间。如果不知道图的密度和产品所需操作,任何一个标签都不完整。

常数与交叉点仍然真实存在

渐近表现更好的实现,可能在产品允许的所有输入上都落后。构建集合需要分配内存和计算哈希,而扫描小数组只是一段缓存友好的短循环。决策应说明支持的输入范围,并通过可复现测量找出交叉点。

硬件与运行时行为会以有规律的方式改变常数。连续访问可利用缓存,分配可能触发垃圾回收,分支模式会影响处理器,库中的向量化代码可能胜过用户代码中更少的操作次数。这些事实是在细化模型,不会让增长分析失效。

远程调用需要单独的维度和预算。把 n 次本地操作改成 n 次数据库查询,仍可能被写成 O(n),但延迟与服务负载会变得无法接受。除本地 CPU 操作外,还要统计往返次数、字节数、并发量和下游工作。

并发可以改变耗时,却不一定减少总工作量。并行运行 n 个独立请求可能缩短关键路径,同时仍保留 Θ(n) 个下游操作,并增加峰值连接数或内存。并行性主导设计时,应分别报告工作量、关键路径长度和资源限制。

用翻倍测试验证模型

翻倍测试在输入形状和环境稳定时,依次运行规模为 n2n4n 及更大的受控输入。比值接近二通常暗示线性增长,接近四暗示二次增长,缓慢上升的比值可能符合 n log n。这只是实现对已测范围的证据,不是数学证明。

与短时间计时相比,插桩计数通常更容易诊断。先统计比较次数、访问节点数、分配次数、复制字节数、查询数和保留条目数,再用基准测试检查常数与系统效应。如果计数与推导不一致,应先检查隐藏工作或错误的输入模型,再开始调优。

测量需要书面流程。固定运行时与依赖版本,以确定方式生成数据,在适用时分开预热和稳定运行阶段,重复足够样本,并报告分布而不是单次最快结果。还要保留正确性断言,避免某个实现靠跳过工作取得速度优势。

不要用两个有噪声的数据点拟合增长类别。应使用足够多的规模以越过固定启动成本,尽可能检查操作计数,并绘图或制表查看比值。在测试可能耗尽共享内存或压垮外部依赖之前停止增大输入。

把限制换算成预算

应从支持的最大输入开始,而不是只看复杂度记号。把该值代入保守的操作数与内存估算,计入并发请求,再与 CPU、延迟和内存预算比较。如果一次请求就能耗尽全部预算,即使典型输入很小,也要增加准入限制。

最终决策应记录选定实现、输入范围、界限与对应情形、假设、测得的交叉点,以及超过限制时的回退方式。这样后续变化才可审查。当数据形状或服务限制改变时,应重新推导并测量,而不是重复旧标签。

超过限制时必须以可预测方式失败。应尽可能在高成本解析之前拒绝超大请求,对输出分页,限制递归或队列,并为远程工作设置超时。如果运行时保护能执行复杂度界限背后的输入假设,这份书面界限才最有用。

延伸阅读

检查点

5个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?