复杂度分析与算法思维 — 从零精通算法与数据结构——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 文档会告诉你

循环不变量:证明循环正确

直觉上你知道一个循环"该做什么",但怎么写出来?循环不变量是一个断言——在每次循环迭代开始前,它都为真

三个步骤:

  1. 初始化: 循环开始前不变量成立
  2. 保持: 如果某次迭代前成立,迭代后依然成立
  3. 终止: 循环结束时,不变量 + 终止条件 ⇒ 结论成立

举个插入排序的例子:

// 不变量: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²) 则是平方增长——这比任何教科书定义都直观。


本章小结

  1. Big-O 描述算法增长的上界,当输入足够大时,只关注增长趋势,忽略常数
  2. Big-O(上界)、Big-Ω(下界)、Big-Θ(紧确界)三者有分工——面试中主要用 O
  3. 分析复杂度时区分最好、平均、最坏情况
  4. RAM 计算模型将每条基本指令视为 1 时间单位——它是分析的"游戏规则"
  5. 循环不变量是推理循环正确性的工具——初始化 → 保持 → 终止
  6. std::chrono::high_resolution_clock 是 C++ 中测量代码运行时间的标准方式
  7. 本章搭建的 algo_toolkit 项目骨架是后续所有章节代码的容器

关键术语

术语 释义
Big-O / O 渐进上界,描述算法最多不会超过的增长速度
Big-Ω 渐进下界,描述算法至少需要的增长速度
Big-Θ 渐进紧确界,上下界相同
RAM 模型 单处理器、顺序执行、常数时间内存访问的理想计算模型
循环不变量 每次迭代前为真的断言,用于证明循环正确性
最坏/平均/最好情况 分析的是输入实例的性质,不是 Big-O 的性质
渐进分析 研究 n→∞ 时函数行为的数学方法
posted @ 2026-06-22 01:09  Yobeeo  阅读(12)  评论(0)    收藏  举报