跳到内容

第 2 卷 / 第 1 章 / 第 1 课

1.1 复杂度是什么 ​

先约定输入规模与计数单位,再看数据变大时需要多做多少工作。本页用 Python 3 的短程序区分增长阶、实际步骤与运行时间。

先把三个词分开 ​

复杂度入门最容易把下面三件事混在一起:

词本章怎样理解例子
输入规模用一个或多个变量描述问题有多大数组有 n 个元素
基本操作选定一种有代表性的工作来计数比较一次、访问一个元素
增长阶输入变大时,操作次数怎样增长Θ(n)、O(n²)

分析 for value in values 之前,先说 n = len(values)。分析两个数组时,应分别用 n 和 m;除非题目说明两者一样长,否则不能擅自把它们都叫作 n。

我们通常采用一个简化的“单位成本模型”:数组取值、整数加法、比较等基本操作各算一步。它适合观察增长趋势,却不是现实机器的完整画像。长字符串比较、哈希、大整数运算、内存分配和 I/O 的成本可能并非常数。

你遇到的第一个谜题 ​

输入是含 n 个整数编号的数组。任务是统计有多少个无序位置对满足编号数值不相邻;例如 1 与 2 不合法,1 与 3 合法。先写清输入规模和基本操作,再比较枚举与优化方案。

python
def count_allowed_pairs(resources):
    count = 0
    for left in range(len(resources)):
        for right in range(left + 1, len(resources)):
            if abs(resources[left] - resources[right]) != 1:
                count += 1
    return count

程序检查了 n(n - 1)/2 个位置对,所以时间按 Θ(n²) 增长,辅助空间是 Θ(1)。相同编号也可以配对,因为它们的差是 0;重复元素按位置分别计数。这里只返回数量,因此可以先算全部位置对,再减去数值相邻的组合:

python
from collections import Counter

def count_allowed_pairs_fast(resources):
    frequencies = Counter(resources)
    total = len(resources) * (len(resources) - 1) // 2
    adjacent = sum(count * frequencies.get(value + 1, 0)
                   for value, count in frequencies.items())
    return total - adjacent

assert count_allowed_pairs_fast([-1, 0, 0, 2]) == 4
assert count_allowed_pairs_fast([]) == 0

每组相邻值只沿 value → value + 1 计算一次。令 u 为不同编号的数量,在常数成本整数运算和通常的哈希假设下,期望时间为 Θ(n),辅助空间为 Θ(u)。若要实际列出全部配对,输出成本仍须另算。

大 O 到底说了什么 ​

大 O 描述的是一个渐近上界。直觉上,若输入足够大以后,算法的工作量始终不会超过某个常数倍的 g(n),就可以写成 O(g(n))。

例如,某段代码执行 3n + 7 次模型操作:

  • 它是 O(n),因为输入足够大后,3n + 7 能被某个常数倍的 n 盖住;
  • 它也是较松的 O(n²);
  • 若要表达“增长速度确实与 n 同阶”,更准确的写法是 Θ(n)。

日常讨论常把“Big O”当作复杂度统称,但不要把它背成“最坏情况”。最坏情况是在选择哪一组输入;大 O 是给选定的成本函数加上界。完成常见代码推导后,第三篇会把 O、Ω、Θ 和最好、最坏、平均情况正式拆开。

为什么可以丢掉常数和低阶项 ​

假设两个方案的模型工作量分别是:

text
A(n) = 3n + 7
B(n) = n² + 2n + 1

当 n 越来越大时,3n + 7 的主导部分是 n,所以写作 Θ(n);n² + 2n + 1 的主导部分是 n²,所以写作 Θ(n²)。

这一步只是在归类增长趋势。它不代表常数永远没用,也不代表两个同为 Θ(n) 的程序实际耗时相同。数据规模较小、常数差异很大或缓存行为不同时,工程结果仍要靠测量确认。

七类常见增长速度 ​

下表里的代表式是为了比较增长,不是承诺程序一定执行这么多条指令。

增长类常见名称代表式常见场景
Θ(1)常数1按下标读取数组元素
Θ(log n)对数⌊log₂ n⌋每轮把搜索区间减半
Θ(n)线性n扫描全部元素
Θ(n log n)线性对数n log₂ n归并排序
Θ(n²)平方n²检查所有有序元素对
Θ(2ⁿ)指数2ⁿ访问所有子集,不复制成员
Θ(n!)阶乘n!访问所有排列,不复制成员

如果把每个子集复制为列表,全部输出共包含 n·2^(n-1) 个元素;把每个排列复制为列表则有 n·n! 个元素。此时输出时间和空间分别达到 Θ(n·2ⁿ) 与 Θ(n·n!),不能只数叶子数量。

几个有用的读法:

  • 常数时间不是“只执行一条语句”,而是操作数不随 n 增长。
  • 对数时间来自反复按固定比例缩小问题。固定底数只带来常数倍差异,所以渐近记号里通常省略底数。
  • n log n 比 n 多一个会缓慢增长的因子,不能笼统说成“几乎一样快”。
  • 指数和阶乘增长很快,但小输入仍可能可用。是否可用要结合真实约束,而不是看见符号就下结论。

把增长变成可检查的步骤 ​

下面的程序不计时。它在 n = 8 时,明确执行并统计五种循环模型。这样可以验证“为什么是这个增长阶”,又不会把某台机器的偶然速度写成规律。

python
# growth_demo.py

def require_positive(n: int) -> None:
    if n <= 0:
        raise ValueError("n must be positive")

def constant_steps(n: int) -> int:
    require_positive(n)
    return 1

def logarithmic_steps(n: int) -> int:
    require_positive(n)
    steps = 0
    remaining = n
    while remaining > 1:
        remaining //= 2
        steps += 1
    return steps

def linear_steps(n: int) -> int:
    require_positive(n)
    steps = 0
    for _ in range(n):
        steps += 1
    return steps

def linearithmic_steps(n: int) -> int:
    require_positive(n)
    steps = 0
    for _ in range(n):
        remaining = n
        while remaining > 1:
            remaining //= 2
            steps += 1
    return steps

def quadratic_steps(n: int) -> int:
    require_positive(n)
    steps = 0
    for _ in range(n):
        for _ in range(n):
            steps += 1
    return steps

def main() -> None:
    n = 8
    counts = {
        "constant": constant_steps(n),
        "logarithmic": logarithmic_steps(n),
        "linear": linear_steps(n),
        "linearithmic": linearithmic_steps(n),
        "quadratic": quadratic_steps(n),
    }
    assert counts == {
        "constant": 1,
        "logarithmic": 3,
        "linear": 8,
        "linearithmic": 24,
        "quadratic": 64,
    }

    print(f"n:{n}")
    print(f"O(1) 模型步骤:{counts['constant']}")
    print(f"O(log n) 模型步骤:{counts['logarithmic']}")
    print(f"O(n) 模型步骤:{counts['linear']}")
    print(f"O(n log n) 模型步骤:{counts['linearithmic']}")
    print(f"O(n²) 模型步骤:{counts['quadratic']}")
    print("增长检查通过")

if __name__ == "__main__":
    main()

运行:

bash
python3 growth_demo.py

输出:

text
n:8
O(1) 模型步骤:1
O(log n) 模型步骤:3
O(n) 模型步骤:8
O(n log n) 模型步骤:24
O(n²) 模型步骤:64
增长检查通过

观察对数模型:8 → 4 → 2 → 1,共缩小三次。若 n 不是 2 的整数次幂,具体轮数与循环边界有关;复杂度仍是 Θ(log n)。

从代码认出增长模式 ​

常数:工作量不依赖输入长度 ​

python
def first(values):
    if not values:
        return None
    return values[0]

这里把数组下标访问视为常数成本,因此时间是 Θ(1)。函数是否先检查空数组不改变增长阶。

线性:每个元素处理一次 ​

python
def contains(values, target):
    for value in values:
        if value == target:
            return True
    return False

若目标在第一个位置,最好情况是 Θ(1);若目标不存在,需要检查 n 个元素,最坏情况是 Θ(n)。这也说明“大 O”和“最坏情况”是两个不同维度。

平方:完整的双重范围 ​

python
def all_ordered_pairs(values):
    pairs = []
    for left in values:
        for right in values:
            pairs.append((left, right))
    return pairs

外层 n 次,每次内层又执行 n 次,共产生 n² 对,所以时间和输出空间都是 Θ(n²)。空间分析必须说明这里计入了返回结果。

三个常见误会 ​

“O(n) 就是正好执行 n 步” ​

不是。O(n) 是上界类别。5n + 100、n/2 和 n 都可以是线性增长;若上下界都为线性,可以写成 Θ(n)。

“数据翻倍,程序一定按表格倍数变慢” ​

复杂度说的是模型工作量的长期趋势。实际时间还受缓存、编译器、解释器、内存分配和输入内容影响。应把复杂度分析和基准测试配合使用。

“只要看见两层循环就是 O(n²)” ​

还要看每层循环的范围。若外层遍历 n 个元素,内层遍历另一个长度为 m 的数组,时间是 Θ(nm);若两层循环先后执行,则要相加。下一页会用可运行代码逐项推导。

你的第一次分析 ​

为下面三个函数分别写出:输入规模、基本操作、最好情况和最坏情况。

python
def last(values):
    return values[-1]

def count_positive(values):
    count = 0
    for value in values:
        if value > 0:
            count += 1
    return count

def find_duplicate(values):
    for left in range(len(values)):
        for right in range(left + 1, len(values)):
            if values[left] == values[right]:
                return True
    return False

参考结论:

  • last 在数组支持常数时间下标访问的前提下是 Θ(1);空列表会抛 IndexError,因此“返回最后一项”的契约还要求输入非空。
  • count_positive 无论正数多少都扫描全部元素,时间是 Θ(n),辅助空间是 Θ(1)。
  • find_duplicate 找到开头的一对重复值时可以很快返回;没有重复值时检查 n(n - 1)/2 对,最坏时间是 Θ(n²),辅助空间是 Θ(1)。

用三行记录第一次分析 ​

面对一段代码,先写 n 代表什么,再选一种模型操作,最后写出操作次数如何随 n 增长。对 last、count_positive 和 find_duplicate 分别完成这三行,并单独报告最好与最坏情况。

把 n = 8 改成 16,核对线性、线性对数和平方模型分别变成 16、64 和 256。这个实验验证的是模型步骤,不是墙上时钟;实际运行时间还要受解释器、缓存和输入内容影响。

每次分析前先写下 n 的含义和所数的操作;同一程序换一种成本口径,结论也要跟着调整。

下一步:把代码形状算成表达式 ​

下一篇会处理顺序代码、依赖范围的嵌套循环、二分过程、多个输入、输出空间和隐藏的库操作成本。

继续学习常见复杂度分析

Built with VitePress | Software Systems Atlas