跳到主要内容

复杂度、约束与 Java 成本模型

算法复杂度描述输入规模增长时,时间和额外空间怎样增长。面试和工程实现都应先从输入约束排除不可行方案,再把 Java 中的对象、装箱、哈希和递归成本放回分析。

1. 先定义输入规模

复杂度中的 n 必须对应具体数量:

  • 数组题:元素个数。
  • 字符串题:UTF-16 code unit 数、Unicode code point 数或字节数。
  • 图问题:顶点数 V 与边数 E
  • 矩阵问题:行数 m 与列数 n
  • 排序多个字符串:字符串数量与总字符数。

只写 O(n) 而不说明 n 是什么,无法判断真实资源。遍历一个有一百万个短字符串的列表,与遍历一个总长度一百万的单字符串,内存访问和对象数量都不同。

2. 约束先决定可接受数量级

假设单机算法时间上限大致固定,可以用数量级快速排除:

输入规模通常优先考虑
n <= 20子集枚举、指数搜索加剪枝
n <= 1,000O(n²) 可能可行
n <= 100,000O(n log n) 或 O(n)
n 达到千万单次线性扫描也要关注常数、I/O 与内存

这不是运行时承诺。元素操作成本、时间限制、语言和硬件都会改变边界。它的作用是帮助提问:数据能否放进内存、是否已排序、值域多大、允许近似吗、需要在线处理吗。

3. 最坏、平均与摊销复杂度

三种描述回答不同问题:

  • 最坏复杂度:任意合法输入的上界。
  • 平均复杂度:依赖输入分布假设。
  • 摊销复杂度:一串操作的总成本平均到每次操作。

HashMap 查找平均接近 O(1),但冲突分布决定桶内路径;ArrayList 尾部追加摊销 O(1),某次扩容仍需 O(n) 复制。

面试中需要说明使用哪一种。如果系统面对恶意输入,平均复杂度的分布假设可能不成立,应同时考虑最坏路径与输入限额。

4. 时间复杂度从执行次数推导

4.1 连续循环相加,嵌套循环相乘

for (int value : values) {
inspect(value);
}

for (int value : values) {
persist(value);
}

总次数是 n + n,忽略常数后为 O(n)。

for (int left : values) {
for (int right : values) {
compare(left, right);
}
}

每个外层元素都遍历 n 个内层元素,总次数约 n²。

4.2 双重语法不一定是 O(n²)

双指针中,每个指针都只向前移动:

while (right < values.length) {
add(values[right++]);
while (windowInvalid()) {
remove(values[left++]);
}
}

虽然循环嵌套,left 和 right 各最多移动 n 次,总成本仍为 O(n)。分析应计算状态总共变化多少次,不能只数大括号层数。

4.3 递归看调用树

二分查找每次把规模减半,深度 O(log n)。归并排序每层处理 O(n),共有 O(log n) 层,所以时间 O(n log n)。有两个递归调用不自动等于 O(2ⁿ),还要看子问题规模和是否重复计算。

5. 空间复杂度看同时存活的额外状态

空间复杂度通常统计除输入和必要输出之外的峰值额外空间:

  • 迭代变量 O(1)。
  • 长度 n 的辅助数组 O(n)。
  • 递归深度 h 的调用栈 O(h)。
  • BFS 队列最宽可能 O(V)。

归并排序每层都创建临时数组时,不应机械把所有递归层的数组大小相加。要看它们是否同时存活、何时可回收。Java 中对象“可以被 GC”不等于已经立即释放,实际峰值还受分配时机和实现方式影响。

原地算法也可能使用递归栈,因此“没有 new 数组”不一定是 O(1) 空间。

6. Big-O 相同,Java 成本仍可能不同

6.1 基本类型与装箱

int[] 连续保存数值,List<Integer> 保存引用并可能产生 Integer 对象。两者都能 O(n) 遍历,后者的内存、间接访问和装箱成本通常更高。

long sum = Arrays.stream(values).asLongStream().sum();

数值密集路径使用基本类型数组和 IntStreamLongStream 可以减少装箱。

6.2 连续内存与节点对象

ArrayList 和 LinkedList 遍历都是 O(n),引用数组通常有更好的缓存局部性;链表为每个元素增加节点和链接。

6.3 哈希与比较函数

HashMap 操作的平均桶定位接近 O(1),但 key 的 hashCode 可能本身是 O(k)。排序 O(n log n) 次比较,如果 Comparator 扫描长字符串,总成本还要乘上比较代价。

6.4 分配与 GC

循环中创建大量短命对象不会改变渐进复杂度,却可能增加分配速率和 GC。先用剖析或 JMH 证明热点,再决定是否复用缓冲、改用基本类型或调整数据布局。

7. 数值范围也是算法约束

int middle = (left + right) / 2;

left + right 可能溢出。二分中写:

int middle = left + (right - left) / 2;

计数、距离和乘积也要估算最大值。两个 int 相乘后再赋给 long,溢出已经发生:

long area = (long) width * height;

Comparator 不要写 left.score() - right.score(),差值可能溢出;使用 Integer.compare

8. 常见问题

8.1 O(1) 是否表示只执行一步

不是。它表示执行次数不随输入规模增长,可以包含固定数量的多步操作。常数仍会影响实际延迟,只是在增长分析中省略。

8.2 O(n log n) 一定比 O(n²) 快吗

当 n 足够大且基本操作可比较时,增长更慢;小输入、昂贵常数和缓存行为可能让结果相反。复杂度用于判断扩展趋势,基准用于判断具体实现。

8.3 输出本身有 O(n) 大小,空间复杂度怎么算

要说明口径。通常把必要输出空间单独列出,再报告算法额外空间。例如返回 n 个元素的列表需要 O(n) 输出,算法除输出外可能只用 O(1) 辅助状态。

8.4 Stream 会改变复杂度吗

同样的遍历和数据结构通常保持相同渐进复杂度,但中间有状态操作、装箱、对象分配和并行调度会改变常数甚至算法路径。应分析实际操作链,不能按语法风格下结论。

9. 面试题

9.1 归并排序的时间和空间复杂度怎样推导

出现公司:字节跳动

考察重点

  • 递归层数与每层合并工作的乘积。
  • 同时存活空间与累计分配量的区别。
  • Java GC 不会改变算法峰值空间口径。

相关内容:第 4 节“时间复杂度从执行次数推导”、第 5 节“空间复杂度看同时存活的额外状态”。

参考回答

归并排序把数组不断对半分,共 O(log n) 层;每层合并总共处理 n 个元素,所以时间是 O(n log n)。典型数组实现需要长度 O(n) 的辅助存储,递归栈是 O(log n),峰值额外空间由 O(n) 主导。

不能因为每层都执行过合并就把临时空间简单累加成 O(n log n),空间复杂度看同时存活的峰值;也不能因为 Java 最终会 GC 就忽略仍在当前调用链中可达的缓冲。

9.2 为什么两个 O(n) 的 Java 实现性能可能差很多

出现公司:腾讯、阿里巴巴、美团、快手、字节跳动

考察重点

  • Big-O 忽略常数与数据布局。
  • 装箱、对象分配、缓存局部性和函数成本。
  • 怎样用剖析和基准验证。

相关内容:第 6 节“Big-O 相同,Java 成本仍可能不同”。

参考回答

O(n) 只说明工作量随 n 线性增长,不说明每一步成本。int[]List<Integer> 都是线性遍历,但后者包含引用访问和可能的装箱对象;连续数组与链表节点的缓存局部性也不同。

此外,哈希和比较函数、临时对象分配、分支预测和 GC 都会改变常数。先用复杂度排除数量级不合适的方案,再用 JMH 或端到端剖析比较目标负载,不能用一次墙钟计时代替分析。