选对容器通常比把一行代码写短更有用。先问:我要查找、计数、按顺序处理,还是不断取出最小值?

1. 复杂度不是数 for 的个数

for 里套 while,不一定是 O(n²)。如果两个指针都只往前走,每个最多移动 n 次,总共仍是 O(n)。反过来,一个 for 每次都做 values[:index],复制长度从 0 到 n−1,累加就是 O(n²)。

这里默认整数运算、单次比较是 O(1);遇到很大的 Python 整数、长字符串或 tuple key,要另算运算、比较或哈希的长度成本。

常见形状 时间 为什么
扫一遍 O(n) 每个元素处理固定次数
每次砍半 O(log n) 区间长度 n → n/2 → n/4
排序 O(n log n) 最坏 不是“调用一个函数所以 O(1)”
所有元素两两配对 O(n²) 大约 n(n−1)/2 对
枚举子集并复制答案 O(n·2ⁿ) 2ⁿ 个输出,总长度也要算

2. average 和 amortized 别混着说

  • Average / expected:dict 查找通常 O(1),依赖哈希分布等条件;极端碰撞可到 O(n)。
  • Amortized:list.append 偶尔扩容很贵,但一长串追加摊下来,每次 O(1)。不是说每次追加都只花固定时间。

拿一个简化的动态数组来看:空间不够时,容量从 1 翻到 2,再翻到 4、8。每写入一个新元素算 1 次操作,扩容时每搬一个旧元素也算 1 次。

第几次追加 追加后的容量 搬了几个旧元素 本次成本
1 1 0 1
2 2 1 2
3 4 2 3
4 4 0 1
5 8 4 5
6–8 8 0 每次 1
动画 摊还:偶尔很贵,平均很便宜

这张图需要启用 JavaScript。

第 5 次明显更贵,但 8 次追加一共只做了 15 次操作。继续追加,搬运次数是 $1+2+4+\cdots$,加上新元素的写入,总成本仍是 O(n)。摊还分析(amortized analysis)不要求输入随机,也不是取多次测速的平均值。 这里的容量翻倍是教学模型,不是 CPython 的实际扩容序列。

用几行代码核对这笔账

count 是非负整数;这段只数操作,不测 Python 的运行时间。

def doubling_append_costs(count):
    capacity = 0
    costs = []
    for size in range(count):
        cost = 1
        if size == capacity:
            cost += size
            capacity = max(1, capacity * 2)
        costs.append(cost)
    return costs


assert doubling_append_costs(8) == [1, 2, 3, 1, 5, 1, 1, 1]
assert sum(doubling_append_costs(8)) == 15

对任意 n > 0,已经搬过的元素总数小于 2n,加上 n 次写入,总成本小于 3n。某一次追加仍可能搬 O(n) 个元素。

哈希表(hash table)的“通常 O(1)”依赖的则是哈希分布等条件:如果大量 key 碰撞,查找就可能慢下来。表扩容也有摊还成本,但那是另一层分析,不能用来保证每次查找都快。

输入通常不计入辅助空间(auxiliary space);新建的字典、递归栈(recursion stack)要算;返回全部答案的空间单独说,不要藏起来。

3. 容器速查

工具 操作 成本与限制
list append / pop() / [-1] 末尾追加、删除摊还 O(1);索引 O(1)
list pop(0) / insert(0, x) / x in values O(n);不要拿它当 BFS 队列
dict / set 查找、插入、删除 通常平均 O(1);set 不保留频次
deque append / popleft 两端操作 O(1),中间索引不是 O(1)
heapq heappush / heappop O(log n);heap[0] 取最小 O(1)
heapq heapify O(n),不是逐个 push 的 O(n log n)
bisect bisect_left / bisect_right 查位置 O(log n);插进 list 仍需 O(n)

成本针对常见 Python 实现和上面的基本操作假设。接口见官方 dict / set、deque / Counter、heapq、bisect。

末尾 pop() 不用把后面的元素往前搬,但偶尔仍要收缩底层数组,所以这里也写“摊还”。可以对照 CPython 3.12 的 list_pop_impl 与 list_resize:常见的一步很便宜,不等于每一步都有固定耗时。

heapify 为什么是 O(n),不是 O(n log n)?

自底向上建堆时,大多数节点都在底部,根本走不了 log n 层。一棵 15 个节点的满二叉树里,8 个叶子不用下沉;4 个节点最多下沉 1 层,2 个最多 2 层,根最多 3 层。总共最多 $4\times1+2\times2+1\times3=11$ 次层级移动,每层做常数次比较。

推广后,总工作量受 $n\sum_{h\geq1}h/2^{h+1}$ 约束,是 O(n)。逐个 heappush 则是另一个过程:每次新元素都从底部往上走,最坏总共 O(n log n)。能先拿到全部元素,就用 heapify;数据一个个到来,再逐个 push。

4. 几个常用函数

from collections import Counter, defaultdict, deque
from bisect import bisect_left, bisect_right
import heapq

counts = Counter("banana")
groups = defaultdict(list)
groups["fruit"].append("pear")
queue = deque(["start"])
queue.append("next")
first = queue.popleft()
values = [1, 2, 2, 5]
left = bisect_left(values, 2)
right = bisect_right(values, 2)
heap = [5, 1, 3]
heapq.heapify(heap)
smallest = heapq.heappop(heap)

结果:counts['a'] == 3;first == 'start';left == 1、right == 3,所以 2 出现了 right-left == 2 次;smallest == 1。

enumerate(values) 给下标和值;zip(first, second) 默认在短的那边结束;sorted(values, key=...) 返回新列表;values.sort() 原地改、返回 None。最大堆的负数法兼容较老 Python;Python 3.14 起也有公开的 heapify_max、heappush_max、heappop_max,先确认运行版本。

5. 递归、切片和输出都要算空间

一棵长成链的树:没有显式列表,递归(recursion)深度仍可达 n,调用栈(call stack)就是 O(n)。平衡二叉树才保证高度是 O(log n)。

values[left:right] 会复制这一段;''.join(parts) 也要创建输出字符串。原地修改不一定代表 O(1) 空间,例如排序内部还可能使用辅助缓冲。

6. 合上笔记,试一下

两个指针只增加,循环看起来嵌套,为什么可能还是 O(n)?

数整个过程的移动次数,不是把循环机械相乘。两个指针各最多增加 n 次,且每步更新 O(1),总时间 O(n)。如果每步又复制窗口或重新求和,要加上那部分成本。

bisect 找位置只要 O(log n),为什么维护有序 list 不是每次 O(log n)?

找位置和搬元素是两件事。把元素插到中间,后面的元素需要移动,最坏 O(n)。

下一步:Hash map:到底要记住什么。

本页术语 6
摊还分析 amortized analysis
把一串操作的总成本分摊到各次操作,不是对随机输入取平均
哈希表 hash table
用键的哈希值定位存储位置,冲突还需处理;查找并非每次都保证常数时间
辅助空间 auxiliary space
除输入和输出外,算法执行时额外使用的空间,包括调用栈
递归栈 recursion stack
递归过程中尚未返回的调用所占的栈空间
递归 recursion
函数直接或间接调用自身,把问题交给更小的子问题;要能走到终止条件
调用栈 call stack
保存尚未返回的函数调用,以及返回后继续执行所需的信息