【算法学习 · 01】深入理解大 O 表示法:从「能跑就行」到「专业评估」算法性能

系列文章第 1 篇。本篇是整个算法系列的地基:我们会从「为什么需要复杂度分析」讲起,推导常见复杂度的来源,给出可直接套用的分析法则与实战案例,并在文末附上面试高频问答。建议先收藏再阅读。

一、前言

很多刚接触算法的同学写代码只关心两件事:功能对不对、报错有没有。至于程序处理 10 条数据和 1000 万条数据的表现差异,往往跑起来卡了才意识到问题。

测量程序耗时当然可行,但计时法有致命缺陷:结果依赖机器性能、编程语言、编译器优化、甚至当天的 CPU 负载。同一个冒泡排序,在你的笔记本和服务器上运行时间可能差 50 倍------但你不能因此说它们是「两个不同的算法」。

我们需要一种独立于机器、只描述算法本身性质 的度量方式,这就是复杂度分析 (Complexity Analysis),而它的数学表达工具就是大 O 表示法(Big O Notation)。

本文你将学到:

  1. 大 O 到底在度量什么,为什么可以「忽略常数」;

  2. 七种常见复杂度的来源与量级感受;

  3. 三条通用分析法则 + 四段实战代码的完整推导;

  4. 均摊复杂度、递归复杂度两个进阶话题;

  5. 面试中关于复杂度的典型追问及标准答法。

二、大 O 到底在度量什么

2.1 一个直观的例子

假设我们要在长度为 n 的无序数组中查找某个值,有两种写法:

复制代码
# 方案 A:线性扫描
def linear_search(nums, target):
    for i, x in enumerate(nums):
        if x == target:
            return i
    return -1
​
# 方案 B:二分查找(前提是数组有序)
def binary_search(nums, target):
    l, r = 0, len(nums) - 1
    while l <= r:
        mid = (l + r) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            l = mid + 1
        else:
            r = mid - 1
    return -1

当 n = 100 万时:

  • 方案 A 最多需要 100 万次比较;

  • 方案 B 最多需要 约 20 次比较(2²⁰ ≈ 100 万)。

即便方案 A 的单次比较比方案 B 快 100 倍,数据量一大,方案 B 依然碾压。这说明:决定算法可扩展性的是「操作次数随数据规模 n 的增长曲线」,而不是单次操作的速度。大 O 表示法描述的正是这条曲线。

2.2 数学定义(降维理解版)

严格定义:若存在常数 c 和 n₀,使得当 n ≥ n₀ 时,f(n) ≤ c·g(n) 恒成立,则称 f(n) = O(g(n))。

不需要背定义,抓住三个操作要点即可:

  1. 只保留最高阶项 :3n² + 5n + 2 → 保留 3n²。因为当 n 足够大时,n² 项远远甩开其余项;

  2. 去掉常数系数 :3n² → n²。因为系数是 3 还是 300,只影响「差几倍」,不影响「增长趋势」;倍数差距可以用更快的硬件抹平,而 n² 与 n 的差距永远抹不平;

  3. 结论只描述上界与趋势:大 O 回答的问题是「当 n 翻倍时,开销大约变成几倍」。

2.3 n 翻倍,开销变几倍?(速查表)

复杂度 名称 n 翻倍后的开销 典型代表
O(1) 常数 不变 数组按下标取值、哈希查找
O(log n) 对数 +1 次基本操作 二分查找
O(n) 线性 ×2 单层循环遍历
O(n log n) 线性对数 ×2 略多一点 归并排序、快速排序(平均)
O(n²) 平方 ×4 双层循环、冒泡排序
O(2ⁿ) 指数 平方倍 无记忆化的子集/组合枚举
O(n!) 阶乘 爆炸 全排列枚举

记住最后一列的「感觉」比记住公式更重要:面试拿到一道题,先看数据规模 n 的范围,就能反推出题人期望的复杂度------这是刷题的重要直觉:

n 的规模 可接受的最大复杂度
n ≤ 20 O(2ⁿ) 也能过
n ≤ 500 O(n³)
n ≤ 5000 O(n²)
n ≤ 10⁵ ~ 10⁶ O(n log n) 或 O(n)
n ≥ 10⁷ O(n),且要小心常数因子

为什么 O(log n) 中的对数底数无所谓?因为换底公式 log₂n = log₁₀n / log₁₀2,换底只改变常数倍------而大 O 恰好忽略常数。所以工程上说「对数复杂度」从不指明底数。

三、三条通用分析法则

法则一:顺序代码,取最大项

复制代码
def process(nums):
    total = sum(nums)        # O(n)
    peak = max(nums)         # O(n)
    nums.sort()              # O(n log n)

总复杂度 O(n + n + n log n) = O(n log n)。加法取最大阶。

法则二:嵌套代码,相乘

复制代码
def match(a, b):
    for x in a:              # O(m)
        for y in b:          # O(n)
            if x == y:
                return True

总复杂度 O(m·n)。乘法规则------这也是为什么「循环里调用函数」必须看清函数自身的复杂度再乘。

法则三:变量依赖时,先求和再定阶(高频陷阱)

复制代码
def f(n):
    for i in range(n):
        for j in range(i):   # 内层次数不是 n,是 i!
            pass

内层总执行次数 = 1 + 2 + 3 + ... + (n-1) = n(n-1)/2,是 n² 量级,所以整体是 O(n²) 而不是「不知道多少」。遇到不规则嵌套,把总操作次数显式求和,再看量级。

四、实战推导:四段代码的完整分析

案例 1:对数从哪里来

复制代码
def f(n):
    j = 1
    count = 0
    while j < n:
        j *= 2
        count += 1
    return count

变量 j 的轨迹是 1, 2, 4, 8, ..., 2^k。循环在 j ≥ n 时停止,即 2^k ≥ n,解得 k = log₂n。所以循环体执行 O(log n) 次。

凡是「每一步把问题规模乘 2(或除 2)」的代码,复杂度都是对数级。二分查找、倍增、快速幂都符合这个特征。

案例 2:对数藏在内层

复制代码
def f(n):
    for i in range(n):        # n 次
        j = 1
        while j < n:          # 每次 log n 次
            j *= 2

外层 n × 内层 log n = O(n log n)。这恰好是归并排序、堆排序的复杂度来源。

案例 3:递归的复杂度 ------ 调用次数 × 单次开销

复制代码
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

画出调用树:fib(n) 分裂成 fib(n-1) 和 fib(n-2),层层分裂,树的节点数是 O(2ⁿ) 量级(严格说是 O(φⁿ),φ≈1.618,黄金分割率,同为指数级)。单次调用开销 O(1),总复杂度 O(2ⁿ)。

记忆化改造(把算过的结果存起来):

复制代码
from functools import lru_cache
​
@lru_cache(maxsize=None)
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

每个 n 只真正计算一次,复杂度骤降为 O(n)。这一个例子同时预演了两件事:递归树分析法、以及第 9 篇动态规划的核心思想。

案例 4:字符串拼接的隐藏平方

复制代码
# 危险写法
s = ""
for word in words:        # n 个词
    s = s + word          # 每次拼接都要复制整个已有字符串

第 i 次拼接要复制长度约 i·L 的字符串(L 为词长),总复制量 ≈ L·n²/2,整体 O(n²)。

复制代码
# 正确写法
s = "".join(words)        # 只复制一遍,O(n)

Java 同理:循环中用 String 拼接应换成 StringBuilder.append()。这类「单步 O(n) 藏在循环里」的问题在真实业务代码(比如拼 SQL、拼 JSON)中非常常见。

五、进阶话题

5.1 最好、最坏与均摊复杂度

以 Python 的 list.append 为例:底层是定长数组,未满时追加是 O(1);数组满时触发扩容------申请一块约 2 倍大的新内存,把所有元素搬过去,单次代价 O(n)。

那 append 到底是 O(1) 还是 O(n)?用**均摊分析(Amortized Analysis)**回答:

从空数组开始连续 append n 次,扩容发生在容量达到 1, 2, 4, 8, ... 时,搬运总次数为 1 + 2 + 4 + ... + n ≤ 2n。加上 n 次本身的写入,总开销 ≤ 3n,均摊到每次操作是 O(1)。

「均摊 O(1)」的含义:偶尔一次贵,但连续操作序列的平均代价是常数。这是动态数组、哈希表扩容分析的标准方法,面试中「HashMap 的 put 为什么说它是 O(1)」考的就是这个。

5.2 空间复杂度同样重要

  • 额外变量、辅助数组、递归调用栈都计入空间复杂度;

  • 递归深度为 d、每层用 O(1) 空间,则空间复杂度 O(d)------链表用递归遍历,空间就是 O(n),不是 O(1);

  • 经典权衡:哈希表用 O(n) 空间把查找从 O(n) 降到 O(1)(第 4 篇详述);归并排序 O(n) 空间 vs 堆排序 O(1) 空间。

5.3 大 O、大 Ω、大 Θ 的区别(面试加分项)

  • O(上界) :算法不会比 g(n) 更慢。「快排最坏 O(n²)」✅

  • Ω(下界) :算法至少需要 f(n)。「快排最好 Ω(n log n)」

  • Θ(紧确界):上下界重合。「归并排序是 Θ(n log n)」

面试默认语境是「说最坏情况的 O」;能主动区分三者,是超出大多数候选人的细节。

六、面试高频问答

Q1:O(2n) 和 O(n) 有区别吗?O(n) 和 O(n²) 呢? 前者无本质区别(常数倍,大 O 忽略);后者有本质区别(增长趋势不同,n 大到一定程度差距不可弥补)。

Q2:为什么快排平均 O(n log n) 却说最坏 O(n²)?什么时候触发? 每次划分如果极不平衡(如已有序数组固定取首元素为基准),递归深度退化为 n,整体 O(n²)。工程实现用随机选取基准或三数取中规避。

Q3:某算法时间 O(n log n) 空间 O(1),另一个 O(n) 空间 O(n),怎么选? 看约束:数据量巨大或内存受限选前者;反之 O(n) 时间通常更快。体现「时空权衡」意识即可。

Q4:怎么分析一段没见过的代码? 套路:① 找出所有循环与递归;② 顺序取最大、嵌套相乘、依赖求和;③ 递归画调用树:复杂度 = 节点数 × 单节点开销;④ 有扩容/搬家的操作改用均摊分析。

七、总结

  • 大 O 描述操作次数随数据规模 n 的增长趋势,忽略常数与低阶项;

  • 三条法则:顺序取最大、嵌套相乘、依赖求和;

  • 「规模每步乘/除 2」→ 对数级;「调用树每层分裂」→ 递归画树分析;

  • 均摊分析解释动态数组/哈希表扩容为什么不影响整体 O(1);

  • 看到 n 的范围能反推出题人期望的复杂度,是刷题的第一直觉。

复杂度分析是「看懂算法」的尺子,下一篇开始正式造轮子:数组与链表------最底层的两种存储形态,以及面试 Top 3 的链表反转模板。

如果本文对你有帮助,欢迎点赞、收藏、评论区交流。系列目录见首篇文章,更新不掉队。

相关推荐
梦帮科技1 小时前
脑机接口(BCI)与神经系统安全:EEG 神经电信号欺骗注入、神经闭环解码算法与生物黑客反制
算法·安全
老徐聊GEO1 小时前
把“安徽“写进题面之后:多平台AI推荐名单的区域扰动测量(2026)
前端·人工智能·算法·geo优化
人工智能培训1 小时前
具身智能:让人工智能拥有物理世界感知能力
大数据·人工智能·学习·算法·机器学习
果壳science1 小时前
张祥前最新研究成果登上SCI期刊!
笔记·嵌入式硬件·算法·硬件工程·几何学
Rabitebla1 小时前
【Linux 底层原理】工具篇(一):掌握 Ubuntu apt,让软件安装与管理更简单
linux·笔记·算法·ubuntu
碧海银沙音频科技研究院1 小时前
基于炬芯ATS308X的DNN全连接神经网络手写数字识别系统设计
人工智能·深度学习·算法
stolentime2 小时前
SOS DP学习笔记
c++·笔记·学习·算法·贪心算法·动态规划·二进制
水母量化2 小时前
量化收益率计算别混用:算术均值、几何均值与对数收益
算法·均值算法·量化交易
xunberg2 小时前
【生命科学 Agent】推理平台(三)
算法·程序员·架构