02.03-Agent学习记录-算法复杂度与资源开销

上一篇介绍了类型与数据结构。我们可以把一场会议表示成字典,把多场会议放进列表,再编写程序读取和处理。接下来会遇到一个新的问题:同样能得到正确结果的两种写法,数据多起来以后,为什么可能一个很快,一个却越来越慢?

舒翱移动端 / 全栈 / Agent 应用

正文

理解这个问题,需要认识算法复杂度与资源开销。

先从查会议开始。假设程序收到一个会议编号,要从 n 条记录中找出对应会议。这里用 n 表示输入规模,本例就是待查记录的数量。

一种直接的办法,是从第一条开始逐项检查,直到找到匹配记录。目标在开头时,可能很快找到;目标在最后,或者根本不存在,就需要检查全部记录。

如果暂时把每次编号比较看成固定成本,那么这种方法在最坏情况下,需要进行与 n 成比例的检查。记录越多,需要的检查就越多。

时间复杂度关注的是:在明确的计算模型和输入规模下,处理工作量怎样随规模增长。它不是某台电脑实际跑了多少秒。

描述这种增长时,常见写法是 O(n),读作“大 O 的 n”。严格说,大 O 描述渐近上界;入门分析中,通常会尽量给出能反映主要增长方式的上界,省略固定倍数及较低阶项。渐近复杂度与大 O 的含义

这里的“渐近”,可以先理解为关注规模变大后的趋势。某个程序要做大约 3n 次基础操作,另一个要做大约 10n 次,它们都属于线性增长,但实际耗时仍可能不同。

因此,看到两个方法都写成 O(n),不能认定它们一样快。复杂度保留的是增长方式,实际实现、固定开销和硬件条件,还需要另外考察。

输入情况也需要说明。刚才逐项查找的最好情况,只检查一条就找到;最坏情况需要检查 n 条。平均情况则需要假设目标怎样分布,不能把最好的一次运行,当成所有输入的表现。

接着,把问题改成比较会议记录中的所有不同配对。例如,每条记录都需要与后面的记录检查一次,避免同一对重复比较。

可以先只计算需要检查多少对:

def count_pairs(n):
    count = 0
    for i in range(n):
        for j in range(i + 1, n):
            count += 1
    return count

外层依次选一条记录,内层遍历它后面的记录。第一条要与 n−1 条比较,下一条与 n−2 条比较,直到最后没有新的配对。

总数量是 n(n−1)/2。这里的主要增长项与 n² 成比例,所以这段配对计数的时间增长可以写成 O(n²)。

把这个数学关系代入几个规模,会更直观:

记录数量 n 逐项检查最多 n 次 所有不同配对 n(n−1)/2
10 10 45
100 100 4,950
1,000 1,000 499,500

这些是简化模型里的检查次数,不是实测运行时间。实际比较还可能读取文件或调用服务,每次操作的开销也会不同。

这也是为什么“只是多写了一层循环”有时影响很大。但不能反过来认定,所有两层循环都是 O(n²)。如果内层始终只处理固定数量的项目,增长方式就不同。应当计算实际重复量,而不是只数缩进层数。

再回到查会议。如果经常按同一个编号字段查询,可以考虑事先建立编号到记录的索引。

例如,把编号作为字典的键,会议记录作为值。查询时按键查找,就不必每次从列表开头逐项比较。对于常见的哈希表实现,在相应平均情况与键处理成本假设下,查询可以按平均 O(1) 分析。

O(1) 表示随着本例记录数量增加,单次操作的分析成本保持常数量级,并不表示只执行一条机器指令,也不保证每次耗时完全一样。具体语言实现和异常情况仍需分别考虑。CPython 常见容器操作的成本模型

建立索引本身也要读取记录,占用额外空间。如果只查一次,先建立全部索引,未必比直接遍历更划算;如果需要反复查询,前期准备才可能被后续使用摊薄。

索引还需要维护。原记录更新了,索引却没同步,即使查得很快,也可能返回错误资料。因此,效率改进需要和数据正确性一起设计。

另一种常见增长方式是 O(log n)。

假设记录已经按某个可比较的键排序,查找时可以利用中间位置的信息,不断排除不可能匹配的一半范围。这类二分过程所需的步骤,随规模按对数量级增长。

它依赖排序等前提,也需要注意操作对象。找到插入位置可能是 O(log n),但向普通数组式列表中间插入记录,还可能需要移动后续元素,整体插入成本仍为 O(n)。二分定位与列表插入的区别

同一个结构上,不同操作的成本并不相同。讨论“列表快不快”“字典快不快”,最好继续说明是按位置读取、按内容查找、插入,还是遍历。

除了时间,还需要考虑空间复杂度。

空间复杂度描述使用的存储怎样随规模变化。分析时常会特别说明辅助空间,也就是除已有输入以外,为处理过程额外申请的空间。

例如,遍历会议记录,只保留一个计数器,并把每次处理所需的额外空间视为固定,可以按 O(1) 辅助空间分析。但已有记录本身仍然占内存,不能据此宣称整个程序只需要固定大小的内存。

如果为每条记录生成一份新字典并保留全部结果,额外空间会随着记录数增长。若每条记录还有很长的正文,正文长度也应成为分析变量,不能永远把每条记录看成同样小的单位。

这与后面要学习的数据分块处理有关。逐批读取,可能减少某个阶段同时驻留内存的输入;但如果处理后又把全部结果收集到列表,最终仍然可能累积大量数据。

所以,降低峰值内存,需要查看整个数据流,而不只是读取时用了小批次。

Agent 应用还有一些不适合单靠本地运算次数描述的开销。

查询日历会等待网络和服务响应,调用模型会消耗输入、输出以及相应计算资源,保存文件还可能受磁盘或远程存储影响。一次外部调用,也不能简单当成永远相同的常数耗时。

例如,向模型发送一条短记录,与发送全部会议文档,使用的信息量不同。即使代码都只有一行调用,时间、费用与上下文占用也可能差别很大。

反复发送已经取得的相同资料,可能增加开销;适当复用结果可能减少重复工作,但也要处理资料是否过期、权限是否仍然一致。这些变量会在缓存、部署和成本相关编号中继续展开。

讨论整体速度时,还需要区分等待多久与完成多少。一次任务的端到端时延,关注用户从发出要求到得到结果的时间;吞吐则关注单位时间能够完成多少工作。并发安排可能改变两者,但不会自动消除全部计算量。

因此,在真正改进程序之前,可以先明确要改善的目标:单次查询的等待,批量处理总时间,峰值内存,还是外部服务费用。不同目标可能需要不同方法。

复杂度分析能提前发现增长过快的处理方式,实际测量则帮助确认当前开销主要发生在哪里。Python 的 timeit 可以用于重复测量小段代码,但测试数据、运行条件与测量范围都需要说明。小段代码的时间测量

内存也应使用合适的测量口径。例如,tracemalloc 跟踪 Python 的内存分配,并不直接代表全部进程、外部服务或 GPU 的总资源占用。看到一个数值时,先知道它测了哪一部分,才有解释意义。Python 内存分配跟踪的范围

本篇没有给出某种方案在这台电脑上快多少倍的测试结论。前面的数字只来自明确的计数公式,用来帮助理解增长关系。

学习时先养成一个习惯:写清输入规模,数出主要重复工作,检查保留了哪些数据,再考虑实际运行环境。这样既能理解程序为什么变慢,也能避免为了看起来更高级,加入没有收益的复杂结构。

下一篇进入 02.04:HTTP 与 REST API,认识程序怎样通过网络向外部服务提出请求,以及一次接口调用里实际交换了什么。


当前学习编号:02.03 算法复杂度与资源开销。
下一学习编号:02.04 HTTP 与 REST API。后续还有其他学习编号。

关联分支:处理方式对应 02.02 类型系统与数据结构、02.07 异步编程、02.08 并发与并行、21.03 数据库索引与查询执行计划、21.11 文件分块与流式读取、21.12 处理批次与内存批次大小。

系统资源与评价对应 23.17 时延、吞吐、成本与质量权衡、25.04 权重、激活与 KV Cache 的内存估算、25.10 首 Token 时延与端到端时延、25.11 吞吐量与性能测量、25.12 Token 成本与单任务总成本、25.14 CPU、内存、交换空间与磁盘活动监控。

输入规模、最好/最坏/平均情况、渐近上界、摊还分析、辅助空间与实际测量,是本编号可继续下钻的支系知识。

系列阅读

本篇学习编号:02.03 算法复杂度与资源开销。

上一篇: 02.02 类型系统与数据结构 。

后续还有:02.04 HTTP 与 REST API,将在本系列后续发布。

延伸阅读

返回全部文章

搜索项目和文章

输入关键词,查找项目或文章。

使用 Tab 选择结果,按 Esc 关闭。