通过代码示例理解 Big-O:轻松掌握算法复杂度
1. 总概
Big-O 是描述运行时间(或内存)随着输入大小增加而增长速度的方法。本文不用枯燥的数学公式,而是通过真实的 Java 代码走过每一种常见的复杂度等级,并分析哪些代码行引起了性能成本。读完之后,你看一段代码就能立即判断它是 O(1)、O(n)、O(n log n) 还是 O(n²),不用手工计数。
2. Big-O 是什么,为什么需要关注
Big-O 回答的问题是:“如果输入增加一倍,运行时间会增加多少?” 它忽略常数和小因子,只保留 n(输入大小)趋于无穷大时的增长趋势。
n = 1000 -> O(n) 运行约 1000 步
n = 1000000 -> O(n) 运行约 1000000 步(线性增长)
n = 1000000 -> O(n²) 运行约 10^12 步(极速增长)
两段代码可能“看起来一样”,但如果其中一段在函数调用中隐藏着嵌套循环,复杂度会截然不同。理解 Big-O 能帮助你避免写出对 100 条测试记录表现良好但在生产环境中面对 1000 万条记录就“卡住”的代码。
注意: Big-O 描述的是最坏情况(worst case),这是最常见的约定,除非特别说明是最好情况或平均情况。
3. O(1) – 常数时间
运行时间不变,无论输入多大。
public class InventoryCache {
private final Map<String, Integer> stockBySku = new HashMap<>();
public Integer getStock(String sku) {
return stockBySku.get(sku); // HashMap.get 平均情况下是 O(1)
}
}
get() 在 HashMap 上计算 key 的哈希,然后直接跳转到相应的桶,不依赖于 map 中元素的数量。同样,使用 array[i] 或 ArrayList 上的 list.get(i) 也是 O(1),因为只需计算地址偏移。
4. O(n) – 线性时间
运行时间与输入大小成正比增长。
public class OrderValidator {
public boolean hasExpiredItem(List<Order> orders) {
for (Order order : orders) {
if (order.isExpired()) {
return true; // 可能提前返回,但最坏情况下仍需遍历所有
}
}
return false;
}
}
for 循环最多遍历 orders 中的 n 个元素。最坏情况发生在没有任何订单已过期时,必须检查整个列表。这是典型的 O(n) 模式:单个循环,不嵌套。
4.1. 常见陷阱:循环中的 contains()
public class DuplicateChecker {
// O(n²) 因为 List 上的 contains() 是 O(n),循环 n 次
public boolean hasDuplicateSlow(List<String> emails) {
List<String> seen = new ArrayList<>();
for (String email : emails) {
if (seen.contains(email)) {
return true;
}
seen.add(email);
}
return false;
}
}
seen.contains(email) 在 ArrayList 上是 O(n),因为必须线性遍历才能查找。在 for 循环内调用它将整个函数变成了 O(n²) — 当不注意数据结构细节时,这是一个非常常见的错误。
public class DuplicateChecker {
// O(n) 因为 HashSet.add/contains 是 O(1)
public boolean hasDuplicateFast(List<String> emails) {
Set<String> seen = new HashSet<>();
for (String email : emails) {
if (!seen.add(email)) { // 如果已存在,add() 返回 false
return true;
}
}
return false;
}
}
仅仅将 List 改为 HashSet,复杂度就从 O(n²) 降到了 O(n)。这是一个非常实际的例子,展示了选择正确的数据结构比“微观优化”更重要。
5. O(log n) – 对数时间
每一步都排除搜索空间的一半。
public class PriceLookup {
// sortedPrices 必须预先按升序排序
public int findIndex(int[] sortedPrices, int target) {
int low = 0;
int high = sortedPrices.length - 1;
while (low <= high) {
int mid = low + (high - low) / 2;
if (sortedPrices[mid] == target) {
return mid;
} else if (sortedPrices[mid] < target) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
}
二分查找在每个 while 循环中将数组分成两半,所以对于 n = 1000000,只需大约 20 步(log₂ 1000000 ≈ 20)来找到结果,而不是线性遍历所有 100 万个元素。
class PriceLookupTest {
@Test
void shouldFindIndexInSortedArray() {
int[] prices = {10, 20, 30, 40, 50};
PriceLookup lookup = new PriceLookup();
int index = lookup.findIndex(prices, 40);
assertThat(index).isEqualTo(3);
}
@Test
void shouldReturnMinusOneWhenNotFound() {
PriceLookup lookup = new PriceLookup();
int index = lookup.findIndex(new int[] {1, 2, 3}, 99);
assertThat(index).isEqualTo(-1);
}
}
这些测试验证了二分查找的两个关键分支的正确行为:找到时返回索引,未找到时返回 -1。
6. O(n log n) – 线性对数时间
这是高效排序算法(如归并排序、快速排序的平均情况,或 Collections.sort())的复杂度。
public class RevenueReport {
public List<Order> sortByAmountDescending(List<Order> orders) {
List<Order> copy = new ArrayList<>(orders);
copy.sort(Comparator.comparing(Order::amount).reversed()); // O(n log n)
return copy;
}
}
Java 中的 Collections.sort()/List.sort() 使用 Timsort(merge sort 的变体),保证最坏情况下为 O(n log n)。如果你看到 sort() 被调用在一个运行 n 次的循环内,总复杂度会乘以变成 O(n² log n) — 一个需要重构的信号。
7. O(n²) – 平方时间
两个嵌套循环,每个都遍历全部(或几乎全部)输入。
public class PairFinder {
// 找到所有总价等于 target 的商品对
public List<int[]> findPairsSlow(int[] prices, int target) {
List<int[]> result = new ArrayList<>();
for (int i = 0; i < prices.length; i++) {
for (int j = i + 1; j < prices.length; j++) {
if (prices[i] + prices[j] == target) {
result.add(new int[] {prices[i], prices[j]});
}
}
}
return result;
}
}
外循环运行 n 次,内循环对每个外循环迭代最多运行 n 次 — 比较的总数大约是 n²/2,所以复杂度是 O(n²)。对于 n = 10000,这是 5000 万次比较 — 仍然可以运行,但会明显变慢。
public class PairFinder {
// O(n) 因为 HashSet 存储已见的值
public List<int[]> findPairsFast(int[] prices, int target) {
List<int[]> result = new ArrayList<>();
Set<Integer> seen = new HashSet<>();
for (int price : prices) {
int complement = target - price;
if (seen.contains(complement)) {
result.add(new int[] {complement, price});
}
seen.add(price);
}
return result;
}
}
与其比较每对数,我们只需一个单一循环和对 HashSet 的 O(1) 查询。这是“用空间换时间”的常见技巧:额外消耗空间来存储 seen,但将复杂度从 O(n²) 降到 O(n)。
8. O(2^n) – 指数时间
运行时间在输入增加 1 时翻倍 — 通常出现在递归函数中计算所有子集的情况。
public class SubsetGenerator {
public List<List<Integer>> generateAllSubsets(List<Integer> items) {
List<List<Integer>> result = new ArrayList<>();
backtrack(items, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(List<Integer> items, int index,
List<Integer> current, List<List<Integer>> result) {
if (index == items.size()) {
result.add(new ArrayList<>(current));
return;
}
// 分支 1:不选择 item[index]
backtrack(items, index + 1, current, result);
// 分支 2:选择 item[index]
current.add(items.get(index));
backtrack(items, index + 1, current, result);
current.remove(current.size() - 1);
}
}
每个元素创建 2 个递归分支(选择或不选择),所以有 n 个元素会产生 2^n 个子集。对于 n = 20,已经是超过 100 万个子集;n = 40 时没有机器能在合理时间内完成。这是寻找动态规划(dynamic programming)替代方案的信号(如果问题允许的话)。
9. 通过实际基准测试进行快速比较
一个简单的测试示例,让你“看到” O(n) 和 O(n²) 之间的差异:
class DuplicateCheckerBenchmarkTest {
@Test
void fastVersionShouldHandleLargeInputQuickly() {
List<String> emails = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
emails.add("user" + i + "@example.com");
}
DuplicateChecker checker = new DuplicateChecker();
long start = System.nanoTime();
boolean hasDuplicate = checker.hasDuplicateFast(emails);
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
assertThat(hasDuplicate).isFalse();
assertThat(elapsedMs).isLessThan(500); // O(n) 所以即使有 10 万元素也很快
}
}
如果你尝试将 hasDuplicateFast 改为 hasDuplicateSlow 并用同样的 10 万条邮件,运行时间会远远超过 500ms 阈值 — 在真实数据上直观证明了 O(n) 和 O(n²) 之间的差异。
10. 快速总结表和识别规则
| Big-O | 代码中的标志 | 例子 |
|---|---|---|
| O(1) | 没有循环,直接访问(数组索引、HashMap.get) | map.get(key) |
| O(log n) | 每步排除搜索空间的一部分 | 二分查找 |
| O(n) | 单个循环遍历输入 | for 遍历 List |
| O(n log n) | 循环配合排序,或分治 | Collections.sort() |
| O(n²) | 循环嵌套循环,都遍历输入 | 比较每一对 |
| O(2^n) | 递归有 2 个分支,每步都执行,无剪枝 | 生成所有子集 |
快速计数规则:数一下嵌套循环的数量,都遍历输入 — 每个循环层级增加一个“幂次 n”。如果循环内调用库函数(contains、sort、indexOf),先查库函数的复杂度,再加到总和。
注意: Big-O 不会说真实的常数。一个 O(n) 算法如果常数很大(比如每次循环都读一次文件)可能比常数很小的 O(n log n) 算法更慢,至少在
n不够大的情况下。不要盲目优化 Big-O,除非你测量过(profile)真实表现。
11. 结论
Big-O 是预测趋势的工具,不是绝对数字 — 但正是这个趋势决定了你的代码是否能在数据增长时存活或崩溃。最实用的建议是:每次写循环时,问问自己“这个循环里是否调用了 O(n) 的函数?” — 这是现实中大多数意外 O(n²) 错误的根源。掌握上面 6 种复杂度等级足以让你理解和评审你每天遇到的大部分算法代码。