惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
The Last Watchdog
The Last Watchdog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
T
Troy Hunt's Blog
L
LINUX DO - 最新话题
C
Check Point Blog
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
罗磊的独立博客
V
Vulnerabilities – Threatpost
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
J
Java Code Geeks
Apple Machine Learning Research
Apple Machine Learning Research
大猫的无限游戏
大猫的无限游戏
S
Security @ Cisco Blogs
IT之家
IT之家
T
The Exploit Database - CXSecurity.com
The GitHub Blog
The GitHub Blog
D
Docker
Engineering at Meta
Engineering at Meta
AWS News Blog
AWS News Blog
S
Security Affairs
U
Unit 42
P
Palo Alto Networks Blog
V
Visual Studio Blog
Y
Y Combinator Blog
D
DataBreaches.Net
Forbes - Security
Forbes - Security
阮一峰的网络日志
阮一峰的网络日志
美团技术团队
Security Latest
Security Latest
aimingoo的专栏
aimingoo的专栏
Simon Willison's Weblog
Simon Willison's Weblog
A
Arctic Wolf
博客园_首页
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
H
Hacker News: Front Page
博客园 - 司徒正美
博客园 - Franky
宝玉的分享
宝玉的分享
TaoSecurity Blog
TaoSecurity Blog
Latest news
Latest news
Scott Helme
Scott Helme
MongoDB | Blog
MongoDB | Blog
量子位
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
C
Cisco Blogs
P
Privacy International News Feed
Application and Cybersecurity Blog
Application and Cybersecurity Blog

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
复杂度的均摊分析法
Ofnoname · 2026-04-25 · via 博客园_首页

动态数组扩容问题是均摊复杂度分析最经典的应用:

  • 动态数组的尾插 push_back,有时会触发扩容;
  • 一旦扩容,就要申请更大的内存、搬运旧元素、再插入新元素。某一次操作的代价完全可能是 \(O(n)\)
  • 但是,动态数组尾插的复杂度是均摊 \(O(1)\)

类似的现象其实非常多:单看某一次操作,它们都可能很贵;但把它们放到足够长的操作序列里,平均到每一步,复杂度却仍然很低。均摊分析研究的正是这种现象。

平均情况分析 vs 均摊分析

均摊分析和平均情况分析(average-case analysis)不是同一个概念。

平均情况分析通常要假设输入服从某种概率分布,然后计算期望成本。比如快速排序的平均复杂度分析,核心就在于对输入或主元分布做假设。例如,快速排序平均是 \(O(n\log n)\) 的,但最坏可能是平方复杂度。

均摊分析则不同。它不依赖概率和输入,一个 vector 无论怎样尾插,均摊复杂度也是 \(O(1)\)

我们可以定义:对于一个数据结构的一段操作序列

\[\sigma = (o_1,o_2,\dots,o_m), \]

如果总实际成本是

\[C(\sigma)=\sum_{i=1}^{m} c_i, \]

能否证明存在某个函数 \(f(m)\),使得

\[C(\sigma)\le m\cdot f(m)。 \]

如果可以,那么就说这类操作的均摊复杂度是 \(f(m)\)。可以理解为,均摊分析给出的是“整段序列的总账不贵”的承诺。于是,均摊 \(O(1)\) 并不意味着每次操作都只花常数时间,它真正的含义是:在任意长的合法操作序列里,总成本相比操作次数的复杂度没有那么高。

经典例子:动态数组

动态数组是本文最好的主线。假设一个动态数组有两个状态量:

  • size:当前元素个数
  • capacity:当前容量

执行 push_back(x) 时:

  • size < capacity,直接写入,成本记为 \(1\)
  • size = capacity,则申请一个两倍大的新数组,把旧元素全部搬过去,再插入新元素。若旧容量为 \(k\),那么这次成本记为 \(k+1\)

单次最坏显然是线性的;但长期看却是均摊常数。下面分别用三种方法来理解它。

聚合法:直接算前 \(n\) 次操作的总成本

聚合法直接计算多步的总成本。由于每次扩容是两倍,数组容量会按 \(1,2,4,8,\dots\) 这样翻倍增长。做完前 \(n\) 次插入时,普通写入本身一共发生了 \(n\) 次,成本是 \(n\)。除此之外,还会发生若干次扩容搬运,搬运量依次是 \( 1+2+4+\cdots+2^{k-1}, \),其中 \(2^{k-1}<n\le 2^k\) 。于是

\[1+2+4+\cdots+2^{k-1}<2^k\le 2n。 \]

所以总成本满足

\[C(n)\le n + 2n = 3n。 \]

也就是说,前 \(n\)push_back 的总成本是 \(O(n)\),因此平均到每次操作,复杂度就是 \(O(1)\)

这个证明非常重要,因为它第一次把“偶尔很贵”和“长期很便宜”这两个看似矛盾的结论接起来了。扩容确实贵,但扩容发生得足够稀疏,所以总账仍然可控。

记账法:平时多收一点,留着给扩容买单

记账法比聚合法更有“预算”的味道。我们不直接算总账,而是假装给每次操作都规定一个均摊收费标准。

还是看动态数组尾插。设每次 push_back 都收费常数代价 \(3\)

  • 如果这次没有扩容,真实成本只有 \(1\),那就剩下 \(2\) 存起来;
  • 如果这次触发扩容,就从之前存下来的余额里支付搬运成本。

为什么这个方案可行?考虑一次从容量 \(m\) 扩到 \(2m\) 的扩容。在这次扩容发生前,数组已经经历了从半满到装满的一系列普通插入。也就是说,每次扩容时,前面都有发生了足够多(大约 \(m/2\) )的便宜插入,每次都能攒出足够余额,总共可以积累出足以覆盖这次搬运的资金。

势能法

从记账的思想更进一步,我们就可以引出势能法。它的思想是:给数据结构状态 \(D_i\) 定义一个势能函数 \(\Phi(D_i)\),然后把第 \(i\) 次操作的均摊成本定义为

\[\hat c_i = c_i + \Phi(D_i)-\Phi(D_{i-1})。 \]

这里:

  • \(c_i\) 是第 \(i\) 次操作的真实成本;
  • \(\hat c_i\) 是我们定义出来的均摊成本;
  • \(\Phi(D_i)-\Phi(D_{i-1})\) 表示状态势能的变化。

这样一次操作的均摊成本就是他的"实际成本 + 势能变化"

如果我们能选取出合适的势能函数表示当前状态(要保证势能始终非负,并且初始势能为 \(0\)),那么对前 \(m\) 次操作有

\[\sum_{i=1}^{m} \hat c_i = \sum_{i=1}^{m} c_i + \Phi(D_m)-\Phi(D_0) \ge \sum_{i=1}^{m} c_i。 \]

所以,只要每一步的均摊成本都有常数上界,那么真实总代价也就有同阶上界。

对动态数组,一个经典势能函数是

\[\Phi = 2\cdot \text{size} - \text{capacity}。 \]

这个定义的直觉是:数组越接近装满,也就越接近下一次扩容,势能越大。

对于不扩容的普通插入。真实成本 1,势能变化 2(因为 size 增加 1,而 capacity 不变),均摊代价 3

对于触发扩容的插入。插入前 size = mcapacity = m,势能是 \(\Phi_{\text{old}}=m\);插入后 size = m+1capacity = 2m,势能变成

\[\Phi_{\text{new}} = 2(m+1)-2m=2。 \]

这次真实成本是搬运 \(m\) 个旧元素再插入新元素(共 \(m + 1\)),势能变化是 \( \Delta \Phi = 2-m。 \)。所以均摊成本为 \(3\)

不扩容时是 \(3\),扩容时还是 \(3\)。因此动态数组尾插的均摊复杂度就是 \(O(1)\)

f120d24b-1fb1-4272-8512-320fc42faeb1

其他常见例子

栈的 MULTIPOP

考虑一个支持三种操作的栈:

  • PUSH(x):压栈,成本为 \(1\)
  • POP():弹栈,成本为 \(1\)
  • MULTIPOP(k):连续弹出最多 \(k\) 个元素,成本等于实际弹出的元素数。

单看一次 MULTIPOP(k),它当然可能要弹很多元素,代价不是常数。但如果看一整段操作序列,总弹出次数其实不可能超过总压栈次数。因为每个元素最多只会被压入一次、弹出一次。

因此,如果总共有 \(m\) 次操作,那么所有弹栈动作加起来不会超过线性级别,于是总成本是 \(O(m)\),均摊每次仍是 \(O(1)\)

这个例子特别适合作为均摊分析的第一个训练:只要能发现“每个元素最多被贵处理一次”,总成本往往就很好控制。

二进制计数器的自增

再看一个更经典的例子。一个二进制计数器不断执行 INCREMENT,一次操作的成本定义为被翻转的位数。

例如:

  • 0111 \to 1000 的成本是 \(4\)
  • 1010 \to 1011 的成本是 \(1\)

单次最坏显然不是常数,但连续执行 \(n\) 次自增时,第 \(0\) 位每次都翻,第 \(1\) 位每两次翻一次,第 \(2\) 位每四次翻一次,一般地,第 \(i\) 位每 \(2^i\) 次翻一次。

所以总翻转次数满足

\[n + \left\lfloor \frac{n}{2} \right\rfloor + \left\lfloor \frac{n}{4} \right\rfloor + \cdots < 2n。 \]

因此,\(n\) 次自增的总成本是 \(O(n)\),均摊下来每次仍是 \(O(1)\)。这个例子也很有代表性:某次昂贵进位之所以会发生,是建立在之前很多较低位状态积累的基础上;他发生的足够少,所以总成本并不会爆炸。

另一个视角是,规定每次 INCREMENT 收费 \(2\)。当某一位从 0 变成 1 时,真实成本是 \(1\),剩下的 \(1\) 单位信用就附着在这个新的 1 上。以后当它从 1 变回 0 时,就用这份信用支付那次翻转。

于是每个 1 的“出生”和“死亡”在它变成 1 的那一刻就已经付清了。虽然某次自增可能会消灭一长串连续的 1,但这些 1 各自都携带了自己的预算,所以集中翻转时并不会额外欠债。

单调队列

单调队列(monotonic queue)是滑动窗口问题里的常用工具。以滑动窗口最大值为例:给定数组 \(a_1,a_2,\dots,a_n\) 和窗口大小 \(k\),要求每个长度为 \(k\) 的窗口最大值。

标准做法是维护一个双端队列,队列里存下标,并保持这些下标对应的值单调递减。处理位置 \(i\) 时,通常做三件事:

  • 把队首已经滑出窗口的下标弹出;
  • 当队尾对应的值小于等于 \(a_i\) 时,不断从队尾弹出;
  • 把当前下标 \(i\) 压入队尾。

看起来最可疑的是第二步。一次插入新元素时,完全可能触发很多次队尾弹出。比如遇到一个很大的新元素,它会把队尾一串更小的元素全部清掉。单次看,这一步显然不是常数。

但从聚合法的角度看,整个算法仍然是线性的。因为:

  • 每个元素在被扫描到时,只会压入队尾一次;
  • 它之后要么因为滑出窗口从队首弹出,么因为遇到更大的元素从队尾弹出;不管是哪一种,被弹出以后它都不会再回来。

因此,在整个过程中,所有入队次数总共是 \(n\),所有出队次数总共也是 \(O(n)\)。虽然某一步的 while 循环可能连续弹很多次,但把所有这些弹出加起来,最多不过线性数量。

看到循环里面还有循环,很多人会本能地把它看成平方复杂度;但只要循环内部“消耗掉的对象”一旦离开就不再回来,均摊分析往往能把复杂度压回线性。

两个栈实现队列

队列可以用两个栈来实现。考虑用两个栈 S_inS_out 实现队列:

  • enqueue(x):把元素压入 S_in
  • dequeue():如果 S_out 非空,直接弹出;如果 S_out 为空,就把 S_in 中所有元素逐个倒入 S_out,再弹出队首。

某次 dequeue() 看上去可能很贵,因为它会在那一刻做整批搬运。但如果从元素的一生看,这个结构其实非常简单。

一个元素从入队到出队,最多只会经历四个基本动作:

  1. 压入 S_in
  2. S_in 弹出
  3. 压入 S_out
  4. S_out 弹出

于是我们可以规定:每个元素在 enqueue 时统一收取常数费用 \(4\),分别用于支付它未来的一生。这样一来,后面即使某一次 dequeue() 触发了整批倒栈,那也不过是在集中兑现许多元素早已缴纳好的费用而已。因此,入队和出队的均摊复杂度都是 \(O(1)\)

从势能的角度看,可以定义势能为

\[\Phi = 2|S_{\text{in}}|。 \]

这个定义的直觉是:S_in 里的每个元素将来都至少还要经历两次动作——从 S_in 弹出,再压入 S_out。所以它们的存在本身就意味着未来还藏着一些尚未支付的成本。对一次 enqueue(x) 来说,真实成本是 \(1\),势能增加 \(2\),于是均摊成本为 \(3\)

对一次不触发倒栈的 dequeue(),真实成本是 \(1\),势能不变,因此均摊成本是 \(1\)

对一次触发倒栈的 dequeue(),设此时 S_in 中有 \(k\) 个元素。真实成本是把这 \(k\) 个元素逐个倒到 S_out\(2k\),再弹出队首的 \(1\),总共 \( 2k+1。 \)。而与此同时,S_in 变空,势能减少 \( \Delta \Phi = -2k。 \)。均摊成本为 \( 1 \)

哈希表扩容

哈希表(hash table)里的扩容和动态数组非常相似。平时插入一个键值对,通常只需常数时间;但一旦装载因子过高,就可能触发 rehash,把整张表里的元素重新分布到更大的桶数组里。

单次 rehash 的最坏代价显然是线性的,但它不会频繁发生。只要扩容策略是按常数倍增长,那么在一长串插入操作里,总搬运量仍然是线性的,推导过程和变长数组完全相同。因此插入的均摊复杂度仍为 \(O(1)\)

动态数组扩展

前面一直只讨论 push_back,但实际动态数组通常还会支持 pop_back。这时问题会变得更微妙一些:如果我们在数组刚满时就扩容、刚删掉一个元素就立刻缩容,那么在边界附近反复执行 push_backpop_back,就可能出现“来回抖动(thrashing)”:次扩完马上缩,一次缩完马上又扩,这一串合法操作每次都是 \(O(n)\) 的,复杂度就炸了。

解决办法是把扩容阈值和缩容阈值分开:例如满了才扩到 2 倍,而只有当 size 下降到 capacity / 4 时才缩到 capacity / 2。这样就留出了一个缓冲区,避免在同一条边界附近频繁震荡;进一步做势能分析或记账分析,仍然可以证明 push_back / pop_back 的均摊成本是 \(O(1)\)

另外,虽然我们都用"2 倍扩容"举例子,但可以证明,只要容量按常数倍增长,即从 \(m\) 扩到 \(\alpha m\),其中 \(\alpha > 1\) 是常数,那么总搬运成本仍然是一个等比级数:

\[1 + \alpha + \alpha^2 + \cdots + \alpha^k = O(\alpha^k), \]

因此均摊插入成本依然是 \(O(1)\);相反,如果每次只固定增加一个常数,比如每次只多开 100 个位置,那么扩容会发生得越来越频繁,总搬运成本会累积成平方级,总体就不再是均摊常数。工程上通常选 2 倍扩容,主要是因为它在“减少扩容次数”和“控制空闲空间浪费”之间给出了一个很均衡的折中:倍数太小,会导致扩容过于频繁;倍数太大,虽然扩容次数更少,但会浪费更多未使用容量。

更高难度数据结构的均摊分析例子

很多真正强力的数据结构,复杂度结论本身就建立在均摊分析上。这里只列举,不再细证。

并查集

带路径压缩(path compression)和按秩合并(union by rank)的并查集,是均摊分析最经典的高级案例之一。

单次 find(x) 可能要沿着父指针一直爬到根,看起来并不便宜;但路径压缩会在返回过程中把整条路径上的节点直接挂到根附近。于是,那些这次被访问到的节点,以后再查找时就会更快。

最终可以证明:对 \(n\) 个元素执行 \(m\) 次操作,总成本是

\[O(m\,\alpha(n)), \]

其中 \(\alpha(n)\) 是反阿克曼函数(inverse Ackermann function),增长极慢,在实际规模下几乎可以视为常数。

这个结论本质上就是均摊分析:某次 find 可能很贵,但那次昂贵访问顺便把未来很多次查找都优化掉了。

伸展树

Splay Tree 的每次访问都会通过一系列旋转把目标节点伸展到根。单次访问的最坏复杂度并不漂亮,但整个访问序列的总代价却很优雅。

经典分析会定义某种与子树规模相关的势能,例如

\[\Phi(T)=\sum_{x\in T}\log s(x), \]

其中 \(s(x)\) 是以节点 \(x\) 为根的子树大小。通过一套势能分析,可以得到访问的均摊复杂度为 \(O(\log n)\)。这个例子说明,势能法的用途远不止“为扩容攒点钱”那么简单。它还可以用来衡量一棵树当前有多“失衡”,而单次昂贵的重构恰恰是在降低这种失衡。

斐波那契堆

Fibonacci Heap 是另一个典型案例。它的一些操作,比如 insertmelddecrease-key,都能达到非常低的均摊复杂度,而代价被集中到少数整理操作上。只要一个数据结构允许“延迟维护(lazy maintenance)”,均摊分析几乎就会自然出现。