复杂度、资源成本与实测

本节目标

用 Big O 描述输入变大时资源成本怎样增长,区分最坏、平均与摊还复杂度,并通过可复现实测验证趋势而不是制造通用性能数字。

复杂度属于操作,不属于容器名字

回到 ADT 与不变量 写出的操作画像。选择结构前先问:

  • 需要按位置访问,还是按唯一 ID 查找?
  • 需要保持顺序、允许重复吗?
  • 最常见的是尾部追加、头部取出,还是任意位置插入?
  • 是否需要优先取出“最紧急”的元素?
  • 数据之间是否有父子或依赖关系?

“使用列表”不能单独得到一个复杂度;items[i]、value in items、append 和 pop(0) 是不同操作。分析还必须说明输入规模 代表任务数、节点数、边数还是文本长度。

Big O 是增长趋势,不是秒表

设输入规模为 :

  • :成本不随 成比例增长,例如按列表下标读取。
  • :每一步大幅缩小问题,例如平衡搜索结构或堆操作的层数。
  • :元素增加一倍,工作量大致也增加一倍,例如扫描列表。
  • :常见高效比较排序的量级。
  • :两层遍历所有元素对,数据大时增长很快。

Big O 忽略常数和较低阶项,用于比较规模扩大后的趋势。它不代表实际时间,也不说明网络、磁盘、内存和实现常数。小数据上,清晰正确通常比微小性能差异重要。

最坏、平均与摊还

  • 最坏情况:在允许输入中成本最高的一种;
  • 平均情况:需要明确输入分布或实现假设,不能把“通常”当证明;
  • 摊还成本:一串操作的总成本平均到每次,例如动态数组偶尔扩容,但连续尾部追加通常为摊还 。

字典/集合成员测试常按平均 建模;这不是最坏情况或安全保证。若输入可被攻击者控制,还要考虑哈希碰撞、资源上限和实现防护。

同一需求的两种写法

检查一批 run_id 是否已处理:

processed_list = ["r1", "r2", "r3"]
print("r3" in processed_list)  # 最坏要扫描列表,O(n)
 
processed_set = {"r1", "r2", "r3"}
print("r3" in processed_set)   # 平均 O(1)

集合更适合频繁成员测试,但它不是“有顺序的历史记录”。如果既要保留处理顺序又要快速去重,可以同时维护列表和集合,但必须封装更新,避免两者不一致。

常见 Python 操作的直觉成本

操作常见结构典型复杂度原因直觉
按下标读取list可直接定位槽位
在列表中找值list可能逐个比较
尾部追加list.append摊还 偶尔扩容,平均到多次操作
头部删除list.pop(0)后续元素需要移动
队首弹出deque.popleft两端操作为设计目标
按键查找dict平均 使用哈希定位
集合成员测试set平均 同样基于哈希
堆压入/弹出heapq沿树高恢复堆序

“平均”不等于无条件保证;哈希冲突、内存压力和恶意输入都会影响实际表现。

这张表描述 Python/常见 CPython 实现的实用成本模型,并由官方文档和算法资料支持;它不是所有 Python 实现必须逐项满足的语言级时间保证。版本、数据类型和硬件变化时应重新测量。

空间成本与峰值内存

时间快不代表内存小。考虑:

  • 输入本身、辅助索引和输出各占多少随规模增长的空间;
  • 容器通常保存对象引用,浅拷贝不会递归复制引用对象;
  • 生成器若被立即 list(...),仍会物化全部结果;
  • 同时保留 list + set 可换取更快成员测试,但增加 辅助空间和一致性不变量。

sys.getsizeof() 只报告对象直接占用,不递归统计它引用的对象。测一段真实 Python 分配可用 tracemalloc,但结果仍与版本和运行环境相关。

用 timeit 验证趋势

下面只比较同一台机器、同一解释器上的增长趋势:

from timeit import timeit
 
 
def compare_membership(size: int) -> tuple[float, float]:
    values = list(range(size))
    value_set = set(values)
    target = size - 1
    list_seconds = timeit(lambda: target in values, number=2_000)
    set_seconds = timeit(lambda: target in value_set, number=2_000)
    return list_seconds, set_seconds
 
 
for n in (100, 1_000, 10_000):
    print(n, compare_membership(n))

记录 Python 版本、数据规模、重复次数和数据分布。不要把一次笔记本结果写成“列表总是慢 X 倍”;测量可能受缓存、启动、后台负载和被测对象哈希成本影响。

最小内存测量:

import tracemalloc
 
tracemalloc.start()
values = [str(i) for i in range(10_000)]
current, peak = tracemalloc.get_traced_memory()
print({"current_bytes": current, "peak_bytes": peak})
tracemalloc.stop()

这只观察跟踪期间 Python 分配,不等于进程全部内存或跨版本固定结果。tracemalloc 本身也有 CPU/内存开销;比较前后要使用相同配置和工作负载。

Agent 工程中的选择

  • 消息按时间追加与遍历:list。
  • 最近 N 条事件:deque(maxlen=N)。
  • tool_name → tool_definition:dict。
  • 已处理事件 ID:set。
  • 下一项按优先级取出:heapq。
  • 步骤依赖:图的邻接表 dict[str, set[str]]。

结构不能替代业务规则。集合能发现重复 ID,但不能判断两次请求在业务上是否等价;这需要幂等键和明确语义。

练习

为以下需求选择结构并解释主要操作:

  1. 保存对话消息并按原顺序发送给模型。
  2. 只保留最近 100 条延迟样本。
  3. 按工具名快速获取参数 schema。
  4. 每次取出最高优先级、最早进入的任务。
  5. 判断任务之间是否存在循环依赖。

再运行上述 timeit,只回答:随着 从 100 增到 10,000,两条曲线的增长趋势是否符合模型?保留原始输出、解释器版本和任何反常现象。

自测

  1. 是否表示“只需一条机器指令”?
  2. 为什么 list.pop(0) 不适合大队列?
  3. “摊还 ”中的摊还是什么意思?
  4. 为什么复杂度相同的两个方案仍可能有明显速度差异?
  5. sys.getsizeof(container) 为什么不等于整棵嵌套对象的总内存?
  6. “字典查找是 O(1)”还缺少哪个情况限定?

参考答案: 只表示成本不按输入规模增长;列表头部删除要移动后续元素;偶发高成本被分摊到一系列操作;实现常数、缓存、内存、I/O 和数据分布都会影响实测;getsizeof 不递归统计被引用对象;字典成员测试通常说的是平均成本且依赖实现/输入假设。

下一步与相关概念

下一节:顺序、链式与二分查找。

参考资料

获取日期:2026-07-14。