复杂度分析与算法思维 — 从零精通算法与数据结构——Google 面试系统备战 第1篇
第1章:复杂度分析与算法思维
本章目标
读完本章你会:
- 用 Big-O/Ω/Θ 精确描述任何算法的时间与空间复杂度
- 区分最好、平均、最坏情况,并说明各自的意义
- 理解 RAM 计算模型,能估算代码的实际运行开销
- 用循环不变量推理循环的正确性
- 搭建 Google C++ Style 的算法项目骨架——
algo_toolkit
知识讲解
从一个生活例子开始
假设你要在一本 1000 页的英语词典里找一个单词。
策略 A(线性扫描): 从第 1 页开始,一页一页翻,直到找到为止。
- 运气最好时:第 1 页就找到了 → 1 次操作
- 运气最坏时:单词在第 1000 页 → 1000 次操作
- 平均情况:约 500 次操作
策略 B(二分查找): 翻到中间,看单词在左边还是右边,每次扔掉一半。
- 1000 页 → 500 → 250 → 125 → 63 → 32 → 16 → 8 → 4 → 2 → 1
- 最多只需要 10 次翻页
这就是算法分析的起点——策略 B 的增长速度远远慢于策略 A。1000 页只差 100 倍(1000 vs 10),但当词典变成 100 万页时,策略 A 需要 100 万次操作,策略 B 只需要约 20 次。差距不再是一个常数,而是一个数量级。
思考一下: 如果词典有 10 亿页,策略 B 需要多少次翻页?策略 A 呢?
工作原理
Big-O 表示法:关注增长趋势
当输入规模 n 趋向无穷大时,我们关心的不是常数系数(2n 还是 10n),而是增长速度本身(线性还是平方)。
Big-O 的严格定义:
f(n) = O(g(n)) 当且仅当存在正数 c 和 n₀,使得对所有 n ≥ n₀,有 0 ≤ f(n) ≤ c·g(n)。
用人话说:当 n 足够大时,f(n) 的增长不超过 g(n) 乘以某个常数。
直观理解:
- O(n):输入翻倍,时间大约翻倍(线性增长)
- O(n²):输入翻倍,时间大约翻 4 倍(平方增长)
- O(log n):输入翻倍,时间只增加一个常数(对数增长)
- O(1):输入翻倍,时间不变(常数时间)
类比: 想象三种交通工具:
- O(1) = 在你家小区里找车位——不管小区住了 100 人还是 10000 人,你只需要找你的车位
- O(n) = 快递员送快递——小区住户翻倍,快递多送一倍
- O(n²) = 小区每户人家相互串门——300 户 × 299 次串门 ≈ 90000 次
三个兄弟:O、Ω、Θ
| 符号 | 含义 | 类比 |
|---|---|---|
| O (Big-O) | 上界——"最多不会超过……" | "这趟航班最多飞 3 小时" |
| Ω (Big-Omega) | 下界——"至少需要……" | "这趟航班至少飞 2.5 小时" |
| Θ (Big-Theta) | 紧确界——"精确就是……" | "这趟航班飞 2 小时 45 分 ± 5 分" |
面试中 80% 的情况下你说 Big-O 就行。但理解三个符号的差异让你在面试官追问"那下界呢"时不会卡壳。
最好、平均、最坏:分析的是"什么情况"
很多人把 O 等价于最坏情况——这是错的。每种情况都可以有自己的 Big-O:
用前面词典的例子:
| 情况 | 策略 A | 策略 B |
|---|---|---|
| 最好 | O(1),第一页就是 | O(1),刚好在中间 |
| 最坏 | O(n),翻完才找到 | O(log n) |
| 平均 | O(n),约 n/2 次 | O(log n) |
面试关键点: 当你分析复杂度时,明确指出你说的是"最坏情况 O(n log n)"还是"平均情况 O(n²)"。面试官期待这种精确性。
RAM 计算模型
算法分析建立在 RAM(Random-Access Machine)模型上:
- 每条简单指令(算术、比较、赋值、内存访问)耗时 1 单位
- 内存无限大
- 无并发、无缓存效应
实践中要知道的:
int x = a + b; // 1 步(实际上可能是 2-3 条 CPU 指令)
for (int i = 0; i < n; ++i) // n 次循环,每次循环 O(1) → 总共 O(n)
std::sort(v.begin(), v.end()); // O(n log n)——STL 文档会告诉你
循环不变量:证明循环正确
直觉上你知道一个循环"该做什么",但怎么写出来?循环不变量是一个断言——在每次循环迭代开始前,它都为真。
三个步骤:
- 初始化: 循环开始前不变量成立
- 保持: 如果某次迭代前成立,迭代后依然成立
- 终止: 循环结束时,不变量 + 终止条件 ⇒ 结论成立
举个插入排序的例子:
// 不变量:A[0..i-1] 始终保持有序
for (int i = 1; i < n; ++i) {
int key = A[i];
int j = i - 1;
while (j >= 0 && A[j] > key) {
A[j + 1] = A[j];
--j;
}
A[j + 1] = key;
}
- 初始化:i=1 时,A[0..0] 只有一个元素,自然是排好的
- 保持:内层 while 把 key 插入到正确位置,A[0..i] 变成有序
- 终止:i=n 时,A[0..n-1] 全有序 ✓
代码实战
搭建 algo_toolkit 项目
我们的主线项目是一个 C++ 算法库。按 Google C++ Style Guide 组织:
algo_toolkit/
├── include/algo/ # 头文件(算法声明)
│ ├── algo.h # 总入口
│ ├── sorting.h
│ └── searching.h
├── src/ # 实现文件
│ ├── sorting.cc
│ └── searching.cc
├── test/ # 单元测试
│ └── test_sorting.cc
├── CMakeLists.txt
└── .clang-format
本章先搭骨架——创建一个能编译的最小项目。
CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(algo_toolkit VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Google-style warning flags
if(MSVC)
add_compile_options(/W4 /utf-8)
else()
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
add_library(algo INTERFACE)
target_include_directories(algo INTERFACE include)
# Tests
enable_testing()
add_subdirectory(test)
include/algo/algo.h:
#ifndef ALGO_ALGO_H_
#define ALGO_ALGO_H_
// algo_toolkit: 个人算法工具库
// 每一章往这里添加新的算法实现
namespace algo {
// 本章:复杂度分析工具——测量函数运行时间
// 用法:auto elapsed = MeasureTime([](){ MySort(data); });
template <typename Func>
double MeasureTime(Func&& func, int repetitions = 10);
} // namespace algo
#include "algo/algo_impl.h"
#endif // ALGO_ALGO_H_
include/algo/algo_impl.h(模板实现):
#ifndef ALGO_ALGO_IMPL_H_
#define ALGO_ALGO_IMPL_H_
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
namespace algo {
template <typename Func>
double MeasureTime(Func&& func, int repetitions) {
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < repetitions; ++i) {
func();
}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> elapsed = end - start;
return elapsed.count() / repetitions;
}
} // namespace algo
#endif // ALGO_ALGO_IMPL_H_
逐行解释:
#ifndef/#define/#endif是 include guard,防止头文件被重复包含——Google Style 用全大写 + 下划线 + 尾随下划线namespace algo用 snake_case——Google 的命名空间规范MeasureTime是一个函数模板,接受任何可调用对象Func&&(转发引用),重复运行repetitions次后返回平均耗时std::chrono::high_resolution_clock是高精度计时器(Windows 上通常是QueryPerformanceCounter的封装)!函数名用 PascalCase,变量名用 snake_case——Google Style
编写一个基准测试验证复杂度:
test/test_complexity.cc:
#include "algo/algo.h"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <iostream>
#include <numeric>
#include <random>
#include <vector>
namespace {
// 待测函数:线性扫描求和 → O(n)
int64_t LinearSum(const std::vector<int>& data) {
int64_t total = 0;
for (int x : data) {
total += x;
}
return total;
}
// 待测函数:嵌套循环 → O(n²)
int64_t NestedPairCount(const std::vector<int>& data) {
int64_t count = 0;
for (std::size_t i = 0; i < data.size(); ++i) {
for (std::size_t j = i + 1; j < data.size(); ++j) {
if (data[i] < data[j]) ++count;
}
}
return count;
}
// 验证:当 n 翻倍时,观察运行时间如何变化
void VerifyComplexity() {
std::cout << "=== 复杂度验证实验 ===\n\n";
for (int n : {1000, 2000, 4000, 8000, 16000}) {
std::vector<int> data(n);
std::iota(data.begin(), data.end(), 1); // 填 1, 2, 3, ...
std::shuffle(data.begin(), data.end(),
std::mt19937{std::random_device{}()}); // C++17+ 标准随机打乱
double linear_time = algo::MeasureTime([&]() { LinearSum(data); }, 100);
double quadratic_time = algo::MeasureTime([&]() { NestedPairCount(data); }, 5);
std::cout << "n = " << n
<< " | O(n): " << linear_time * 1e6 << " μs"
<< " | O(n²): " << quadratic_time * 1e6 << " μs\n";
}
}
} // namespace
int main() {
VerifyComplexity();
std::cout << "\n预期观察:n 翻倍时,O(n) 时间约翻倍,O(n²) 时间约翻 4 倍\n";
return 0;
}
test/CMakeLists.txt:
add_executable(test_complexity test_complexity.cc)
target_link_libraries(test_complexity PRIVATE algo)
add_test(NAME test_complexity COMMAND test_complexity)
⚠ 编译运行: 在工程根目录执行:
mkdir -Force test # 确保 test/ 目录存在 mkdir build; cd build; cmake ..; cmake --build .; ctest确保
test/CMakeLists.txt已创建(见下方),否则 cmake 会报add_subdirectory找不到目录。运行
test_complexity观察 n 翻倍时运行时间的变化。你会发现 O(n) 时间大致线性增长,O(n²) 则是平方增长——这比任何教科书定义都直观。
本章小结
- Big-O 描述算法增长的上界,当输入足够大时,只关注增长趋势,忽略常数
- Big-O(上界)、Big-Ω(下界)、Big-Θ(紧确界)三者有分工——面试中主要用 O
- 分析复杂度时区分最好、平均、最坏情况
- RAM 计算模型将每条基本指令视为 1 时间单位——它是分析的"游戏规则"
- 循环不变量是推理循环正确性的工具——初始化 → 保持 → 终止
std::chrono::high_resolution_clock是 C++ 中测量代码运行时间的标准方式- 本章搭建的
algo_toolkit项目骨架是后续所有章节代码的容器
关键术语
| 术语 | 释义 |
|---|---|
| Big-O / O | 渐进上界,描述算法最多不会超过的增长速度 |
| Big-Ω | 渐进下界,描述算法至少需要的增长速度 |
| Big-Θ | 渐进紧确界,上下界相同 |
| RAM 模型 | 单处理器、顺序执行、常数时间内存访问的理想计算模型 |
| 循环不变量 | 每次迭代前为真的断言,用于证明循环正确性 |
| 最坏/平均/最好情况 | 分析的是输入实例的性质,不是 Big-O 的性质 |
| 渐进分析 | 研究 n→∞ 时函数行为的数学方法 |

浙公网安备 33010602011771号