相关笔记

概览

动态表(Dynamic Table)是一种支持插入和删除操作、能自动调整大小的数组。本节将势能方法应用于动态表的分析,证明在仅插入场景和插入+删除场景下,每次操作的摊还代价均为

  • 负载因子,空表定义为
  • 表扩张:当 时,分配 2 倍大小的新表
  • 表缩容:当 时,分配 1/2 大小的新表
  • 关键设计:缩容阈值为 而非 ,避免”抖动”(thrashing)
  • 核心结论:TABLE-INSERT 和 TABLE-DELETE 的摊还代价均为

知识结构总览

flowchart TD
    A["动态表<br/>Dynamic Table"] --> B["基本概念"]
    B --> B1["T.table: 数组"]
    B --> B2["T.num: 已存储元素数"]
    B --> B3["T.size: 分配的总大小"]
    B --> B4["负载因子 α = T.num / T.size"]

    A --> C["表扩张<br/>Table Expansion"]
    C --> C1["触发条件: α = 1"]
    C --> C2["策略: 分配 2×T.size 的新表"]
    C --> C3["代价: 复制所有元素 O(num)"]

    A --> D["表缩容<br/>Table Contraction"]
    D --> D1["触发条件: α < 1/4"]
    D --> D2["策略: 分配 T.size/2 的新表"]
    D --> D3["代价: 复制所有元素 O(num)"]
    D --> D4["防抖动: 阈值 1/4 而非 1/2"]

    A --> E["摊还分析"]
    E --> E1["仅插入场景<br/>Φ = 2·num - size"]
    E --> E2["插入+删除场景<br/>Φ 分段定义"]
    E --> E3["结论: INSERT/DELETE 均 O(1)"]

    A --> F["工程实现"]
    F --> F1["Python list: factor ≈ 1.125"]
    F --> F2["Java ArrayList: factor = 1.5"]
    F --> F3["C++ vector: factor = 2"]
    F --> F4["Go slice: factor = 2"]

核心思想

核心思路

动态表的核心思路是:当数组空间不足时自动扩容,当空间利用率过低时自动缩容,使得用户无需预先知道数据规模。扩容和缩容的代价(复制所有元素)虽然单次为 ,但通过合理的扩容/缩容策略和势能分析,可以证明每次操作的摊还代价为 。关键设计在于扩容因子和缩容阈值之间的”缓冲区”,它保证了连续的扩容/缩容之间有足够的操作间隔,避免了反复扩容缩容的”抖动”现象。

基本概念

动态表(Dynamic Table)

动态表是一种支持插入和删除操作、能自动调整大小的数组。它维护以下属性:

  • T.table:存储元素的实际数组
  • T.num:当前已存储的元素个数
  • T.size:当前分配的总槽位数(

负载因子定义为 。对于空表(),约定

TABLE-INSERT 伪代码

TABLE-INSERT(T, x)
1  if T.size == 0
2      allocate T.table with 1 slot
3      T.size = 1
4  if T.num == T.size
5      allocate new-table with 2 * T.size slots
6      insert all items in T.table into new-table
7      free T.table
8      T.table = new-table
9      T.size = 2 * T.size
10 insert x into T.table
11 T.num = T.num + 1

算法解释

  • 第1-3行:处理空表的特殊情况,分配一个槽位
  • 第4-9行:如果表已满(,即 ),执行表扩张:分配一个 2 倍大小的新表,将所有元素复制到新表,释放旧表
  • 第10-11行:将新元素插入表尾,元素计数加 1

仅插入的动态表分析

考虑只有 TABLE-INSERT 操作的场景。

势能函数

验证约束条件

  • 初始时
  • 需要验证
    • 时(即 ),,但需要 ,即 ,即 。当 时,

【势能函数验证(仅插入场景中 α≥1/2 恒成立,故 Φ=2num-size≥0)】 这里需要更仔细的分析。实际上,在仅插入的场景中,负载因子 始终 (除了初始空表)。原因如下:

  • 初始时 (约定)
  • 每次 INSERT 后,如果没触发扩张, 增加
  • 如果触发扩张, 翻倍, 加 1,新的

因此,在仅插入场景中, 恒成立, 恒成立。

【势能法(普通插入:Φ增2,摊还代价=1+2=3)】 情况一:普通插入(不触发扩张,

实际代价 (仅插入一个元素)。

【势能法(扩张插入:Φ从T.size降至2,势能释放支付复制代价,摊还=3)】 情况二:触发扩张的插入(,扩张后

实际代价 (复制 个旧元素 + 插入 1 个新元素)。

扩张前:

扩张后:

结论:无论是否触发扩张,TABLE-INSERT 的摊还代价均为 3 =

插入和删除的动态表分析

当动态表同时支持插入和删除时,需要引入表缩容机制。

TABLE-DELETE 伪代码

TABLE-DELETE(T, x)
1  if T.num == 0
2      error "underflow"
3  x = T.table[T.num]
4  T.num = T.num - 1
5  if T.num < T.size / 4
6      if T.size >= 1
7          allocate new-table with max(T.size / 2, 1) slots
8          insert all items in T.table into new-table
9          free T.table
10         T.table = new-table
11         T.size = max(T.size / 2, 1)

算法解释

  • 第1-2行:检查下溢(空表不能删除)
  • 第3-4行:删除最后一个元素(或指定元素),元素计数减 1
  • 第5-11行:如果负载因子低于 ,执行表缩容:分配一个 大小的新表(最小为 1),复制所有剩余元素

分段势能函数

【分段势能函数验证(num≥size/2时Φ=2num-size≥0,num<size/2时Φ=size/2-num>0)】 验证约束条件

  • 初始时
  • 时:
  • 时:

TABLE-INSERT 分析(插入+删除场景)

【势能法分段分析(INSERT四种情况:α≥1/2不扩张、α≥1/2扩张、α<1/2不扩张、α<1/2跨阈值)】 分四种情况讨论:

情况一,不触发扩张(

情况二,触发扩张(

扩张前 ,扩张后 ,所以

情况三,不触发扩张(

情况四,不触发扩张但

由于

TABLE-DELETE 分析

【势能法分段分析(DELETE四种情况:α≥1/2不缩容、α≥1/2缩容、α<1/2不缩容、α<1/2缩容)】 分四种情况讨论:

情况一,不触发缩容(

情况二,触发缩容(

缩容前 ,缩容后

由于 ,有 ,所以

化简后

情况三,不触发缩容(

情况四,触发缩容(

缩容前 ,缩容后

由于

化简后

结论:在所有情况下,TABLE-INSERT 和 TABLE-DELETE 的摊还代价均为

防抖动设计

抖动(Thrashing)

如果扩容因子和缩容因子相同(例如都是 2 倍),在某个负载因子附近反复插入和删除会导致反复扩容和缩容,每次操作都触发 的元素复制,使得摊还代价退化为

【缓冲区论证(扩容阈值1与缩容阈值1/4之间有Ω(n)次操作间隔)】 CLRS 的解决方案是设置不对称的阈值:

  • 扩容:当 时触发(表满时)
  • 缩容:当 时触发

这保证了在扩容和缩容之间有一个”缓冲区”(),任何连续的扩张/缩容之间至少有 次操作。


补充理解与拓展

动态数组在主流编程语言中的实现对比

动态数组是现代编程语言中最基础的数据结构之一,几乎所有语言的标准库都提供了实现。不同语言的增长因子(growth factor)选择各不相同,反映了在时间效率内存效率之间的不同权衡:

语言/库增长因子策略说明
Python list较保守的增长策略,优先节省内存。源码中 newsize = (newsize >> 3) + (newsize < 9 ? 3 : 6) + newsize
Java ArrayList平衡策略。源码中 int newCapacity = oldCapacity + (oldCapacity >> 1)
C++ std::vector与 CLRS 教材一致,时间效率最优但内存浪费较大
Go slice与 C++ 一致,Go runtime 中 growslice 函数实现

增长因子越大:扩容次数越少,时间效率越高,但内存浪费越大(最坏情况下浪费约 的已分配空间)。

增长因子越小:内存利用率越高,但扩容次数增加,虽然摊还代价仍为 ,但常数因子增大。

Python 选择 1.125 的原因是:Python 的 list 对象本身就有较大的固定开销(对象头、指针数组等),较小的增长因子可以减少内存碎片。此外,Python 的内存分配器对频繁的小幅扩容有较好的优化。

所有上述实现均保证 append 操作的摊还时间复杂度为

来源:CPython 源码 Objects/listobject.c;OpenJDK ArrayList.java;C++ 标准规范 ISO/IEC 14882;Go runtime slice.go

动态表缩容的"抖动"问题与解决方案

抖动(Thrashing)是动态表设计中最容易被忽视的问题。考虑以下场景:

假设扩容和缩容都使用 2 倍因子,且缩容阈值为 。当表的负载因子恰好在 附近时:

  1. 插入一个元素, 变为略大于 ,不触发扩容
  2. 删除一个元素, 变为略小于 ,触发缩容
  3. 缩容后 变为约 (因为 减半)
  4. 再删除一个元素, 变为约 ,不触发缩容
  5. 再删除一个元素, 变为约 ,触发缩容
  6. 缩容后 变为约
  7. 插入一个元素, 变为略大于 ,不触发扩容
  8. 再插入一个元素, 变为约 ,触发扩容
  9. 扩容后 变为约

这样就在扩容和缩容之间反复振荡,每次都触发 的复制操作。

CLRS 的解决方案:扩容在 时触发,缩容在 时触发。这保证了:

  • 扩容后 ,需要至少 次删除才能触发缩容
  • 缩容后 ,需要至少 次插入才能触发扩容
  • 因此,任何连续的扩张/缩容之间至少有 次操作
  • 每次扩张/缩容的代价 次操作分摊,摊还代价仍为

来源:CLRS Chapter 16; Bilkent University CS473 Lecture Notes “Dynamic Tables”


易混淆点与辨析

误区辨析

误区一:动态表的 append 操作最坏情况为

这是错误的。动态表的 append 操作在最坏情况下为 (当触发扩容时需要复制所有元素)。正确的说法是:append 操作的摊还代价为 。摊还分析不改变单次操作的最坏情况复杂度,而是保证一系列操作的总代价较低。

误区二:增长因子越大越好

虽然较大的增长因子(如 2)减少了扩容次数,但也导致了更大的内存浪费。在最坏情况下,数组可能有近一半的空间是空闲的。Python 选择 1.125 的增长因子就是为了减少内存浪费。此外,有理论研究表明,增长因子不应超过 2,否则可能导致内存分配失败(因为新分配的内存必须与旧内存同时存在,总需求为 ,当 时总需求超过 ,可能在内存紧张时失败)。

误区三:缩容阈值设为 就足够了

如果缩容阈值设为 ,而扩容阈值为 (满时扩容),则在负载因子 附近会出现抖动。例如:扩容后 ,删除一个元素后 降到 以下,触发缩容,缩容后 回到 。这种反复扩容缩容的行为使得摊还分析失效。CLRS 将缩容阈值设为 正是为了避免这个问题。

误区四:势能方法只能分析仅插入的场景

势能方法完全可以分析插入+删除的场景,但需要使用分段定义的势能函数。仅插入场景的势能函数 时可能为负,因此不适用于有删除的场景。插入+删除场景的分段势能函数通过在 时切换为 来保证非负性。


习题精选

题号题目描述难度核心考点
16.4-1假设 TABLE-INSERT 扩容为 3 倍而非 2 倍,用势能方法分析摊还代价★★不同增长因子的影响
16.4-2证明在仅插入场景中,如果增长因子为 ,则 TABLE-INSERT 的摊还代价为 ★★★一般化增长因子的分析
16.4-3假设 TABLE-DELETE 在 时缩容为 ,给出一个操作序列使得 n 次操作的总时间为 ★★★★抖动问题的构造
16.4-4给出 TABLE-INSERT 和 TABLE-DELETE 的完整伪代码,支持在任意位置插入和删除★★★动态表的完整实现
16.4-5证明在插入+删除场景中,如果缩容阈值为 ,则存在操作序列使得 n 次操作的总时间为 ★★★★抖动的严格证明

视频学习指南

资源链接说明
MIT 6.006 Lecture 11YouTube摊还分析导论,包含动态表
Abdul Bari - Amortized AnalysisYouTube动态数组扩容的直观讲解
Josh Hug - ArrayListYouTubeJava ArrayList 扩容机制详解
GeeksforGeeks Dynamic ArraysGFG动态数组工作原理图文详解

教材原文

CLRS 第4版 16.4节原文

16.4 动态表

在某些应用中,我们不知道在给定的表中将存储多少个对象。可能是我们不知道将有多少项最终被存储,或者是我们不知道有多少项将在任何给定的时间被存储。在这种情况下,我们可能使用一种能够根据需要增长和收缩的表。这种表称为动态表。

我们不详细说明动态表的实现,而是关注表扩张和表收缩的摊还代价。我们假设动态表支持两种操作:

  • TABLE-INSERT:将一个元素插入到表中,如果必要则扩张表。
  • TABLE-DELETE:从表中删除一个元素,如果必要则收缩表。

我们用以下属性来刻画动态表:

  • T.table:指向表的指针
  • T.num:当前存储在表中的元素个数
  • T.size:当前分配给表的存储槽位数

在某些情况下,表可能包含未使用的槽位。表的负载因子定义为 。空表的负载因子定义为 1。当表的负载因子为 1 时,表是满的。

表扩张

当一个元素被插入到一个满表中时,我们必须扩张表。为此,我们分配一个新的表,其槽位数是旧表的两倍,然后将所有元素从旧表复制到新表中,最后释放旧表。表扩张的代价与表的大小成正比。

表收缩

当从一个表中删除一个元素导致表的负载因子变得太小时,我们可能希望收缩表。为此,我们分配一个新的表,其槽位数是旧表的一半,然后将所有元素从旧表复制到新表中,最后释放旧表。为了避免在负载因子接近某个阈值时反复扩张和收缩表(这种现象称为抖动),我们选择在负载因子降到低于 1/4 时才收缩表,而不是在降到低于 1/2 时就收缩。因此,在收缩之前,负载因子至少为 1/4,收缩后负载因子变为 1/2。

通过使用势能方法,我们可以证明 TABLE-INSERT 和 TABLE-DELETE 的摊还代价都是 。对于仅插入的场景,势能函数为 。对于同时支持插入和删除的场景,势能函数为分段定义的:当 ,当


参见Wiki

  • 动态表 — 势能方法的典型应用案例

第16章-摊还分析 动态表