Chapter 01

复杂度与约束反推

起点页给了"把刷题变成认套路"的全局地图——这一章建立第一块、也是最先用到的地基:用复杂度给解法空间画边界。

本章你将建立的 schema

  • 用 Big-O 描述增长量级,而非绝对运行时间。
  • 看约束 n 的规模,反推出目标复杂度。
  • 用复杂度提前排除不可行的套路。
  • 权衡时间与空间——用空间换时间是高频手段。

§1.1Big-O 是什么

Big-O 描述运行时间随输入规模增长的量级,忽略常数与低阶项。

为什么需要它

同一段代码在不同机器上跑出的秒数不同,无法用来比较算法优劣。Big-O 把"硬件、语言、编译器"这些噪声全部抹掉,只保留"输入翻倍时运行时间怎么变"这一条机器无关的本质。一个 O(n) 算法在慢机器上也终将打败快机器上的 O(n²) 算法——只要 n 足够大。

为何丢弃常数与低阶项

设一段代码的精确步数是 3n² + 5n + 100。当 n→∞,3n² 这一最高阶项完全主导:n=1000 时 3n²=3,000,000,而 5n+100=5100,后者占比不到 0.2%。低阶项与常数系数对增长趋势没有影响,因此 Big-O 把它丢掉,记作 O(n²)。

但工程与面试里常数有时要命。两次扫描数组(2n 次操作)与一次扫描(n 次)在 Big-O 下都是 O(n),可实测前者慢一倍。当 n 巨大、或代码在热路径上反复调用时,这一倍的差距是真金白银。Big-O 告诉你"量级对不对",常数告诉你"同量级里谁更快"——两者都要看。

实例:判断数组是否有重复元素

同一道题,两种量级。先看 O(n²) 的朴素双循环:对每个元素,向后比较所有其余元素。

dup_n2.pyPython
def has_duplicate_n2(nums: list[int]) -> bool:
    n = len(nums)
    for i in range(n):                 # 外层:选定一个元素 nums[i]
        for j in range(i + 1, n):      # 内层:和它后面每个元素比较
            if nums[i] == nums[j]:     # 撞上相等 = 找到重复
                return True
    return False                       # 全部比完没撞上 = 无重复


# 比较次数约为 n*(n-1)/2,量级 O(n^2)
print(has_duplicate_n2([1, 2, 3, 1]))   # True  (1 出现两次)
print(has_duplicate_n2([1, 2, 3, 4]))   # False (全不同)

外层 × 内层两层嵌套循环各跑约 n 次,总比较次数与 n² 成正比,故复杂度 O(n²)。空间只用了几个变量,O(1)。

再看 O(n) 版本:用一个 set 记住"见过的元素",每个新元素查一次集合即可。

dup_n.pyPython
def has_duplicate_n(nums: list[int]) -> bool:
    seen: set[int] = set()             # 记录已出现过的元素
    for x in nums:                     # 只扫一遍数组
        if x in seen:                  # set 查找平均 O(1)
            return True                # 这个值之前见过 = 重复
        seen.add(x)                    # 没见过,记下来
    return False                       # 扫完没撞上 = 无重复


# 扫描 n 次、每次 O(1) 查找,量级 O(n);额外用 O(n) 空间存 set
print(has_duplicate_n([1, 2, 3, 1]))    # True
print(has_duplicate_n([1, 2, 3, 4]))    # False

单次扫描循环执行 n 次,每次 in 与 add 在哈希集合上平均 O(1),总复杂度 O(n)。代价是 O(n) 的额外空间——这正是"用空间换时间",§1.4 再展开。

想一想

n=10000 时,两版的操作次数差多少个数量级?先估,再展开核对。

展开核对

O(n²) 版约 n²/2 = 10000²/2 = 5×10⁷ 次比较;O(n) 版约 10⁴ 次操作。两者相差约 5000 倍,即 3–4 个数量级。n 再翻 10 倍到 10⁵,差距扩大到约 5 万倍——这就是量级差异的可怕之处:它随 n 越拉越大。

§1.2复杂度阶梯

常见复杂度从快到慢排成一条阶梯,记住这条顺序,是后面"反推"的前提:

O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)

输入规模 n 操作次数 O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ) O(n!)
图 1.1七条曲线越靠左上越陡:左上的 O(2ⁿ)、O(n!)(红)在 n 很小时就冲顶,右下的 O(1)、O(log n) 几乎贴着横轴。注意:红色曲线(指数、阶乘)只在极小的 n 上可行,这正是 §1.3 反推的依据。
每阶一个典型算法

O(1) 数组按下标取值、哈希表查找;O(log n) 有序数组二分查找;O(n) 遍历数组求和、单次扫描;O(n log n) 归并 / 快速排序;O(n²) 朴素双循环、冒泡排序;O(2ⁿ) 枚举集合的所有子集;O(n!) 枚举所有排列(如朴素求解旅行商)。

§1.3约束反推目标复杂度

这是本章的核心技能,也是整份教程判别链的第一步:题目给出的数据规模约束,直接框定了可行的复杂度,从而排除掉大半套路。

为什么能反推:按 10⁸ 次/秒估算

主流评测机一秒大约能执行 10⁸(一亿)次基本操作,多数题目时限 1–2 秒。把"可执行操作总数 ≈ 10⁸"当成预算,再代入复杂度公式解出 n 能取多大,反过来就得到"给定 n 时复杂度的上限"。例如 n=10⁶ 时,O(n²) 需要 10¹² 次操作 ≈ 超时一万倍,必然不可行;只有 O(n) 或 O(n log n) 落在预算内。

小 n 大 n → 规模递增 n≤12 n≤500 n≤5000 n≤10⁶ n≥10⁸ O(2ⁿ) / O(n!) 回溯枚举 O(n³) 三重循环DP O(n²) 二维DP O(n) / O(n log n) 滑窗·排序 O(log n) / O(1) 二分·数学 n 越大 ⇒ 可接受复杂度越低 ⇒ 候选套路越少
图 1.2同一根 n 轴向右规模递增,下方对应的可行复杂度逐级降低。注意:最左端 n≤12 才容得下指数级枚举(红),那是回溯类题的强信号。
表1.1 由约束反推目标复杂度
输入规模 n可接受复杂度暗示的套路
n ≤ 10–12O(n!) / O(2ⁿ)回溯、全排列 / 全枚举
n ≤ 20O(2ⁿ)状态压缩、子集枚举
n ≤ 500O(n³)三重循环、区间 DP
n ≤ 5000O(n²)二维 DP、朴素双循环
n ≤ 10⁶O(n) / O(n log n)双指针、滑动窗口、排序、二分
n ≥ 10⁸O(log n) / O(1)二分、数学公式直接算

读题第一眼看 n 的上界,立刻在表里定位行,候选套路就缩到一两类。这把"无从下手"变成"在两三个套路里挑"。

想一想

n≤10⁵ 的题,若硬上 O(n²) 解法,会发生什么?

展开核对

O(n²) = (10⁵)² = 10¹⁰ 次操作。按 10⁸ 次/秒算需约 100 秒,远超 1–2 秒时限,判定超时(TLE)。n≤10⁵ 落在表 1.1 的 O(n)/O(n log n) 行,正确做法是双指针、滑窗或先排序再二分。

§1.4空间复杂度与递归栈

空间复杂度描述额外内存随输入规模增长的量级;递归的隐藏开销是调用栈。

底层机制

每次函数调用都会在调用栈上压入一个栈帧(保存局部变量、返回地址)。递归未返回时这些栈帧不会弹出,因此递归的空间开销是 O(最大递归深度),即便函数体本身只用常数空间。Python 默认递归深度上限约 1000,且不做尾递归优化——再"尾"的递归也照样吃栈。深度过大会抛 RecursionError。

实例:递归求和的栈深度问题

sum_stack.pyPython
import sys


def sum_recursive(nums: list[int], i: int = 0) -> int:
    # 每深一层就压一个栈帧,递归深度 = len(nums),空间 O(n)
    if i == len(nums):
        return 0
    return nums[i] + sum_recursive(nums, i + 1)


def sum_iterative(nums: list[int]) -> int:
    # 只用一个累加变量,无递归栈,空间 O(1)
    total = 0
    for x in nums:
        total += x
    return total


print(sum_iterative(list(range(100000))))   # 4999950000 一次跑通

try:
    print(sum_recursive(list(range(100000))))  # 深度 10 万,远超默认上限
except RecursionError as e:
    print("RecursionError:", e)              # 触发栈溢出保护

# 若坚持用递归,需手动抬高上限(治标不治本,仍占 O(n) 栈空间)
sys.setrecursionlimit(200000)

递归 vs 迭代两版时间都是 O(n),但 sum_recursive 空间 O(n)(栈深度随 n 线性增长,n=10⁵ 直接 RecursionError),sum_iterative 空间 O(1)。深度可能很大时,优先改写成迭代。

跨概念综合 · 用空间换时间

§1.1 的去重题已经示范:哈希表(set / dict)额外吃 O(n) 空间,换来 O(1) 的平均查找,把 O(n²) 压到 O(n)。这是面试最高频的权衡手段——当时间预算(表 1.1)逼你降一个量级,第一反应就是"能不能拿空间换"。哈希表为何能 O(1) 查找、它的物理布局如何决定这一点,留到第 03 章展开。

§自测

  1. O(n) 与 O(n log n) 两段代码先后顺序执行,总复杂度是多少?
    答案

    O(n log n)。顺序执行取各段中量级最高者:O(n + n log n) = O(n log n),低阶的 O(n) 被吸收。

  2. 一道题 n≤25,要求枚举所有子集(2ⁿ 个),可行吗?为什么?
    答案

    勉强可行但接近边界。2²⁵ ≈ 3.3×10⁷,落在 10⁸ 预算内。但 n≤30 时 2³⁰≈10⁹ 就超了——这类题目的 n 通常卡在 20 上下,正是子集枚举 / 状压的信号。

  3. Big-O 忽略常数,为什么工程里仍要关心常数?
    答案

    Big-O 只比较"量级",同量级内常数决定实测快慢:两次扫描(2n)比一次扫描(n)慢一倍。在热路径、大 n 或卡常题里,这一倍是真实代价。Big-O 选对量级,常数决定同量级里谁更优。

  4. 一个递归函数体只用常数变量,它的空间复杂度一定是 O(1) 吗?
    答案

    不一定。还要加上调用栈:空间 = O(最大递归深度)。深度为 n 的递归即便函数体 O(1),整体空间也是 O(n)。

Challenge · 约束反推

n≤40 的子集和

给一个长度 n≤40 的整数数组与目标值 target,问是否存在某个子集的元素和恰好等于 target。朴素做法枚举全部 2ⁿ 个子集——n=40 时 2⁴⁰ ≈ 10¹²,按 10⁸ 次/秒约需 三小时,必然超时。这个 n 既大于纯枚举的上限,又小于 DP 友好的范围,怎么破?

提示

折半搜索(meet-in-the-middle):把 40 个数拆成两半,各 20 个。左半枚举全部 2²⁰≈10⁶ 个子集和、存进有序数组或哈希;右半同样枚举 2²⁰ 个,对每个和 s 去左半里查是否存在 target − s。总量级从 O(2⁴⁰) 降到 O(2²⁰ × 20)≈10⁷,落回预算内。核心思路:指数被"对半砍"在指数位上,等于开平方。

参考资料

  • Big-O Cheat Sheet(常见数据结构与算法复杂度速查)
  • 《算法导论》(CLRS) 第 3 章 Growth of Functions(渐进记号的形式定义与证明)
  • LeetCode 复杂度专题(时间 / 空间复杂度分析与练习)