选对容器通常比把一行代码写短更有用。先问:我要查找、计数、按顺序处理,还是不断取出最小值?
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 |
第 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
- 保存尚未返回的函数调用,以及返回后继续执行所需的信息