qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

数组一半空了就缩容,为什么反而不停搬家?

容量从4扩到8,删一项又缩回4,两次操作就搬两遍。明确缩容时机、最小容量与复制成本,再用势函数证明为什么四分之一阈值能约束整段操作。

展开本文目录
  1. 下载完整练习包
  2. 刚扩完就缩回去,两个动作都可能搬整批元素
  3. 等到四分之一再缩,给两次搬家留出间隔
  4. 先删还是先判断,≤还是<,都会改变记录
  5. 把未来搬家的信用写成势能,再逐种检查push
  6. 缩容复制的是删完以后剩下的q项
  7. 所有步骤相加,前后的势能会一项项抵消
  8. 运行完整模型,把内容正确和成本记录分开核对
  9. 再用Fraction逐分支核对公式与实现
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了

数组容量不够时翻倍,可以让很多次追加分摊偶尔的大搬家。但加入删除以后,光知道“扩容翻倍”还不够:如果刚刚有一半位置空出来就立刻缩容,下一次追加可能又把整批元素搬回去。

本文比较两个明确的教学策略,按活元素复制次数记账。程序确实运行了这些模型,但没有测量毫秒,也不声称Python列表或Java ArrayList使用同样的缩容规则。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本;2026-10-18 实测 Windows / Python 3.14.0,3.10 仅做语法检查。仅用标准库,无第三方依赖。仅使用标准库。实际运行日期、环境和范围见随包核验记录。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 7 个文件
包里有什么
  • array_shrink_threshold.py:教学源码
  • array_shrink_threshold.observed.stdout.txt:说明或核验记录
  • amortized-proof.py:教学源码
  • amortized-proof.observed.stdout.txt:说明或核验记录
  • README.md:说明或核验记录
  • verification.json:说明或核验记录
  • SHA256SUMS.txt:说明或核验记录

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B array_shrink_threshold.py
python -B amortized-proof.py

解压到独立练习文件夹,在包含源码的目录打开终端,按下方顺序运行。两个 Python 文件必须放在同一目录;amortized-proof.py 会导入 array_shrink_threshold.py。示例运行记录不代表独立练习已完成;修改代码后需重新验证。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

刚扩完就缩回去,两个动作都可能搬整批元素

从容量1的空数组开始追加4项,此时size=4、capacity=4。再push一项前,旧数组已满,需要扩到8并复制原来的4项。随后pop删掉这一项,size回到4;若规则是size不超过容量一半就缩容,马上又复制4项缩回容量4。

这一来一回保持原来4个值不变,却复制了8项。重复8轮,16次push/pop累计复制64项。把每次push/pop本身计1,循环阶段成本就是80。这里说的是这段循环,先前填入4项还另有4次操作和3次复制,完整成本为87。

所谓“省空间”,是缩容后空位更少;它并不保证总工作量更少。将触发扩容和触发缩容的边界挤得太近,就会让少数增删不断跨过两边。

等到四分之一再缩,给两次搬家留出间隔

第二个策略仍在满时翻倍,但删除后只有size≤capacity/4才把容量减半。上例从4扩到8后,pop回size4,尚未达到阈值2,所以不缩。后续8轮中的其余操作都能在容量8中完成。循环阶段只复制第一次扩容的4项,成本20;包含预填阶段为27。

以满容量m=2的整数次幂开始、重复k≥1轮同样的push/pop:一半阈值的循环复制数为2mk,四分之一阈值为m;两者都有2k次基本操作。初始m项预填还需要m次操作与m−1次复制。这个精确式依赖“初始已满”和指定触发规则,不适用于任意起点。

实际另测初始128项、16轮循环,两个阈值分别复制4096与128项。把初始数量改为3或5时,第一次追加还没越过预填所得容量,短循环可以完全不复制;k=0当然也没有循环成本。四分之一策略用较多空位换操作间隔,不消除单次扩容的线性工作。

先删还是先判断,≤还是<,都会改变记录

代码的顺序是:push先看旧数组是否满,必要时扩容,再写入;pop先取值并让size减一,再判断缩容。比较用整数size×denominator≤capacity,避免用浮点占用率决定边界。只在容量大于1时减半,因此容量始终是至少1的2次幂。

如果把一半阈值的≤换成<,或改为减少size之前检查,上面的8轮循环复制数都会从64变4。但不能由此宣布严格小于就能解决所有反复扩缩:从size7、capacity8出发,重复三次push再三次pop,严格小于一半的策略仍会往返;实测4轮复制60项。

容量1要单独保护。排空8项后,两种策略都停在size0、capacity1;空pop抛出ValueError且不改状态。若缩到容量0,之后只做“容量乘2”仍是0。本文计每次成功push/pop为1、每个活元素复制为1;分配、清零、释放、GC与缓存成本没有包含在这个模型里。

把未来搬家的信用写成势能,再逐种检查push

只看两个循环还不能证明任意序列。令n是当前元素数、m是容量,定义Φ(n,m):当n≥m/2时为2n−m,否则为m/2−n。两式在半满处都是0,所有合法状态势能都非负。一次操作的摊还量定义为ĉ=c+Φ_after−Φ_before,这个记账量不是实际耗时。

满时push:原n=m,势能为m;复制m项再写1项,c=m+1。新状态为n=m+1、容量2m,势能为2,所以ĉ=m+1+2−m=3。

不扩容且m≥2时,若原来至少半满,push使势能增加2,ĉ=3;若原来不到半满,因为m为偶数、n为整数,增加1后最多恰好半满,势能减少1,ĉ=0。m=1的空数组跨过半满边界要单算:Φ从1/2到1,c=1,因此ĉ=3/2。全部push分支都不超过3。

缩容复制的是删完以后剩下的q项

设pop后剩下q=n−1项。触发缩容时m≥2且q≤m/4,新容量为m/2。旧势能可写成m/2−q−1,新势能为m/4−q;m=2时q只能为0,这两个式子仍成立。势能变化是1−m/4。

这一步基本成本1,复制剩余q项,所以c=1+q。于是ĉ=1+q+1−m/4=q+2−m/4≤2。不能误把复制数写成删除前的n项,否则精确常数与源码就不一致。

没有缩容且m≥2时:若删除前n>m/2,删除后仍至少半满,势能减少2,ĉ=−1;若删除前n≤m/2,旧状态在边界也可用低分支式,势能增加1,ĉ=2。容量1从n1删到0时,势能从1变为1/2,ĉ=1/2。全部pop分支都不超过2。负记账量只表示信用减少,不表示时间倒流。

所有步骤相加,前后的势能会一项项抵消

从size0、capacity1开始,初始势能是1/2,不是0。对任意合法序列,若有P次push、Q次pop,真实模型成本总和为C,则Σĉ=C+Φ_final−1/2。由上面所有分支的界,有C=Σĉ+1/2−Φ_final≤3P+2Q+1/2。初始的半个单位只添常数,因此总成本为O(P+Q)。

这个结论来自分支代数、非负势能和求和抵消,对任意合法序列成立,不需要假设输入随机。有限状态检查可以发现实现或推导的笔误,却不能替代这份一般证明。一次操作仍可能很贵:满容量1024时一次push的模型成本是1025,摊还量却只有3。

如果直接从非空数组开始,界里必须带上那个初始状态的势能,不能固定加1/2。上述满容量1024的初始势能就是1024;预填阶段提供的信用和成本不能凭空忽略。若把分配或清零也计费,仍需另行分析,不应继续照搬常数3与2。

运行完整模型,把内容正确和成本记录分开核对

保存为array_shrink_threshold.py并执行python -B array_shrink_threshold.py。它把数组状态明确放在slots与size里,每次搬动一个活元素才增加copy计数。Python列表自身的扩容不是统计对象。输出的两组固定循环有独立算术式核对。

另外枚举长度0到12的1912个合法push/pop序列,对两策略逐步核对有效元素和pop返回值,参考为普通栈。这个部分验证状态保持,没有用1912这个数来证明渐近上界。报告保存每个固定循环的容量轨迹、复制数以及包含预填的总成本。

源码已在Python 3.14.0实际运行;Python 3.10语法检查通过,未实际运行3.10解释器。这里只处理尾部push/pop,没有中间插入、并发修改、真实内存分配或时间基准。

展开收起代码 · 小实验python · 127 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Research prototype: explicit unit-copy model for grow/shrink thresholds.

One unit per push/pop plus one per live element copied during resize.
Allocation/zero fill/free costs and real timing are deliberately not modeled.
Python lists store the model state; their own allocation strategy is irrelevant.
"""
import json
from pathlib import Path
import platform


class ModelArray:
    def __init__(self, shrink_denominator):
        if shrink_denominator not in (2, 4):
            raise ValueError("use either one-half or one-quarter threshold")
        self.denominator = shrink_denominator
        self.slots = [None]
        self.size = 0
        self.copies = 0
        self.operations = 0
        self.trace = []

    def resize(self, capacity):
        replacement = [None] * capacity
        for i in range(self.size):
            replacement[i] = self.slots[i]
            self.copies += 1
        self.slots = replacement

    def record(self, operation, old_capacity, old_copies):
        moved = self.copies - old_copies
        self.operations += 1
        self.trace.append({"operation": operation, "size": self.size,
                           "old_capacity": old_capacity, "capacity": len(self.slots),
                           "copied": moved, "modeled_cost": 1 + moved})
        assert 0 <= self.size <= len(self.slots)

    def push(self, value):
        old_capacity, old_copies = len(self.slots), self.copies
        if self.size == len(self.slots):
            self.resize(2 * len(self.slots))
        self.slots[self.size] = value
        self.size += 1
        self.record('push', old_capacity, old_copies)

    def pop(self):
        if self.size == 0:
            raise ValueError("cannot pop an empty model")
        old_capacity, old_copies = len(self.slots), self.copies
        self.size -= 1
        value = self.slots[self.size]
        self.slots[self.size] = None
        if len(self.slots) > 1 and self.size * self.denominator <= len(self.slots):
            self.resize(max(1, len(self.slots) // 2))
        self.record('pop', old_capacity, old_copies)
        return value


def alternating_experiment(initial_size, cycles, denominator):
    model = ModelArray(denominator)
    for i in range(initial_size):
        model.push(i)
    prefill_copies = model.copies
    start = model.operations
    for i in range(cycles):
        model.push(1000 + i)
        assert model.pop() == 1000 + i
        assert model.slots[:model.size] == list(range(initial_size))
    return {
        "initial_size": initial_size, "cycles": cycles,
        "shrink_denominator": denominator,
        "alternating_operations": model.operations - start,
        "alternating_copies": model.copies - prefill_copies,
        "alternating_cost": model.operations - start + model.copies - prefill_copies,
        "total_operations_with_prefill": model.operations,
        "total_cost_with_prefill": model.operations + model.copies,
        "trace": model.trace[start:]
    }


def legal_operation_sequences(length, prefix='', size=0):
    if len(prefix) == length:
        yield prefix
        return
    yield from legal_operation_sequences(length, prefix + '+', size + 1)
    if size:
        yield from legal_operation_sequences(length, prefix + '-', size - 1)


def main():
    small = [alternating_experiment(4, 8, d) for d in (2, 4)]
    large = [alternating_experiment(128, 16, d) for d in (2, 4)]
    # Independent arithmetic for this fixed alternating family, not a second simulation.
    for pair, initial, cycles in [(small, 4, 8), (large, 128, 16)]:
        assert pair[0]['alternating_copies'] == 2 * initial * cycles
        assert pair[1]['alternating_copies'] == initial
    sequences = 0
    for length in range(13):
        for ops in legal_operation_sequences(length):
            for denominator in (2, 4):
                model, plain = ModelArray(denominator), []
                for index, operation in enumerate(ops):
                    if operation == '+':
                        model.push(index)
                        plain.append(index)
                    else:
                        assert model.pop() == plain.pop()
                    assert model.slots[:model.size] == plain
            sequences += 1
    report = {
        "status": "PASS", "python_version": platform.python_version(),
        "small_example": small, "larger_example": large,
        "legal_operation_sequences_checked": sequences, "max_sequence_length": 12,
        "sequence_check_purpose": "State/value preservation against an ordinary stack for both policies; this finite enumeration is not an asymptotic proof.",
        "cost_model": "one per push/pop, plus one per live element copied; excludes allocation, initialization, free, garbage collection, caching and elapsed time",
        "scope": "Synthetic tail push/pop model; no claim about CPython list, Java ArrayList or real latency. General quarter-threshold amortized bound needs the cited mathematical argument."
    }
    Path(__file__).with_suffix('.report.json').write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    for pair in [small, large]:
        for row in pair:
            print(f"initial={row['initial_size']} cycles={row['cycles']} threshold=1/{row['shrink_denominator']} alternating_copies={row['alternating_copies']} alternating_cost={row['alternating_cost']}")
    print(f"PASS; legal_sequences={sequences}; lengths=0..12; state checks only")


if __name__ == '__main__':
    main()

怎样运行:保存为array_shrink_threshold.py,执行python -B array_shrink_threshold.py;只用标准库,写出array_shrink_threshold.report.json。

运行后对照结果
initial=4 cycles=8 threshold=1/2 alternating_copies=64 alternating_cost=80
initial=4 cycles=8 threshold=1/4 alternating_copies=4 alternating_cost=20
initial=128 cycles=16 threshold=1/2 alternating_copies=4096 alternating_cost=4128
initial=128 cycles=16 threshold=1/4 alternating_copies=128 alternating_cost=160
PASS; legal_sequences=1912; lengths=0..12; state checks only

再用Fraction逐分支核对公式与实现

把下列辅助程序保存为amortized-proof.py,与上面的array_shrink_threshold.py放在同一目录,再执行python -B amortized-proof.py。它调用同一个quarter模型,但用Fraction精确计算势能,逐一核对手推分支的等式,不用浮点容差掩盖边界。

本次遍历操作前容量1、2、4直到1024的2次幂,并遍历每个容量下整数n=0..m,共2058个合法状态;push都检查,pop只在n≥1时检查,共4105次转换。操作后最大容量会到2048。这些状态包含从空数组正常操作未必能到达的状态,因此检查范围也明确记在报告里。

观察到push摊还最大3、pop最大2;另用一段混合增删轨迹核对势能求和,模型总成本与摊还总和均为132,起点终点势能均为1/2。完整一般证明在前面的势能分支与求和部分;这个有限核对只是旁证。下面练习另选输入和阈值边界,先预测再运行,保留每一步的状态与计数。

展开收起代码 · 小实验python · 140 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Fraction check of the written potential proof for the quarter policy.

This checks branch algebra on finitely many valid states, including states not
reachable from an empty array. The general proof is written in amortized-proof.json.
Neither the earlier graph enumeration nor the push/pop sequence suite is rerun.
"""
from collections import Counter
from fractions import Fraction
import json
from pathlib import Path
import platform

from array_shrink_threshold import ModelArray


def potential(n, m):
    if (type(n) is not int or type(m) is not int or m < 1
            or m & (m - 1) or not 0 <= n <= m):
        raise ValueError('valid state needs power-of-two capacity >=1 and 0<=n<=m')
    return Fraction(2 * n - m) if 2 * n >= m else Fraction(m, 2) - n


def branch_formula(n, m, operation):
    """Exact one-step equations, separately derived in the companion proof."""
    if operation == 'push':
        if n == m:
            return 'push-grow', n + 1, 2 * m, m + 1, Fraction(3)
        if m == 1:
            return 'push-minimum-crossing', 1, 1, 1, Fraction(3, 2)
        if 2 * n >= m:
            return 'push-no-resize-high', n + 1, m, 1, Fraction(3)
        return 'push-no-resize-low', n + 1, m, 1, Fraction(0)
    if operation != 'pop' or n == 0:
        raise ValueError('pop requires n>=1')
    q = n - 1
    if m > 1 and 4 * q <= m:
        return 'pop-shrink', q, m // 2, 1 + q, Fraction(q + 2) - Fraction(m, 4)
    if m == 1:
        return 'pop-minimum-crossing', 0, 1, 1, Fraction(1, 2)
    if 2 * n > m:
        return 'pop-no-resize-high', q, m, 1, Fraction(-1)
    return 'pop-no-resize-low', q, m, 1, Fraction(2)


def run_transition(n, m, operation):
    model = ModelArray(4)
    model.slots = list(range(n)) + [None] * (m - n)
    model.size = n
    before = potential(n, m)
    if operation == 'push':
        model.push(-1)
        expected_values = list(range(n)) + [-1]
    else:
        assert model.pop() == n - 1
        expected_values = list(range(n - 1))
    after = potential(model.size, len(model.slots))
    assert model.slots[:model.size] == expected_values
    cost = model.trace[-1]['modeled_cost']
    amortized = Fraction(cost) + after - before
    name, nn, mm, predicted_cost, predicted_amortized = branch_formula(n, m, operation)
    assert (model.size, len(model.slots), cost) == (nn, mm, predicted_cost)
    assert amortized == predicted_amortized
    assert amortized <= (3 if operation == 'push' else 2)
    assert before >= 0 and after >= 0
    return name, {
        'operation': operation, 'before': {'n': n, 'm': m, 'phi': str(before)},
        'after': {'n': nn, 'm': mm, 'phi': str(after)}, 'copied': model.copies,
        'actual_modeled_cost': cost, 'amortized_cost': str(amortized)
    }


def check_whole_sequence(operations):
    model = ModelArray(4)
    initial_phi = potential(0, 1)
    total_amortized = Fraction(0)
    pushes = pops = 0
    for i, operation in enumerate(operations):
        before = potential(model.size, len(model.slots))
        if operation == '+':
            model.push(i)
            pushes += 1
        else:
            model.pop()
            pops += 1
        after = potential(model.size, len(model.slots))
        total_amortized += model.trace[-1]['modeled_cost'] + after - before
    actual = model.operations + model.copies
    final_phi = potential(model.size, len(model.slots))
    assert actual == total_amortized + initial_phi - final_phi
    assert actual <= 3 * pushes + 2 * pops + initial_phi
    return {'pushes': pushes, 'pops': pops, 'modeled_cost': actual,
            'summed_amortized_cost': str(total_amortized),
            'initial_phi': str(initial_phi), 'final_phi': str(final_phi),
            'upper_bound': str(3 * pushes + 2 * pops + initial_phi)}


def main():
    counts, examples, maxima = Counter(), {}, {'push': None, 'pop': None}
    states = 0
    for exponent in range(11):
        m = 1 << exponent
        for n in range(m + 1):
            states += 1
            for operation in (['push', 'pop'] if n else ['push']):
                branch, record = run_transition(n, m, operation)
                counts[branch] += 1
                examples.setdefault(branch, record)
                measured = Fraction(record['amortized_cost'])
                if maxima[operation] is None or measured > maxima[operation]:
                    maxima[operation] = measured
    # This is one additional trace to check telescoping, not another enumeration.
    sequence = '+' * 32 + '-' * 24 + '+-' * 11 + '-' * 8
    telescoping = check_whole_sequence(sequence)
    # Beginning in a nonempty state requires its initial potential, not 1/2.
    _, arbitrary_start = run_transition(1024, 1024, 'push')
    assert arbitrary_start['actual_modeled_cost'] == 1025
    assert arbitrary_start['amortized_cost'] == '3'
    report = {
        'status': 'PASS', 'python_version': platform.python_version(),
        'states_checked': states, 'transitions_checked': sum(counts.values()),
        'capacity_domain_before_transition': '1,2,4,...,1024',
        'largest_capacity_after_a_checked_transition': 2048,
        'state_domain': 'every integer n in 0..m; includes valid but unreachable states',
        'branch_counts': dict(counts), 'one_observed_example_per_branch': examples,
        'maximum_amortized_push': str(maxima['push']),
        'maximum_amortized_pop': str(maxima['pop']),
        'telescoping_trace': telescoping,
        'nonempty_initial_state_counterexample': arbitrary_start,
        'boundary': 'Finite exact-arithmetic verification of the separately written branch proof and the model implementation. This enumeration is not the general proof, not a timing benchmark, and says nothing about allocator/zero-fill/free costs.'
    }
    Path(__file__).with_suffix('.report.json').write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    print(f"states={states}; transitions={sum(counts.values())}; exact Fraction branch checks PASS")
    print(f"max amortized push={maxima['push']}; max amortized pop={maxima['pop']}; initial potential=1/2")
    print(f"telescoping: actual={telescoping['modeled_cost']}; amortized={telescoping['summed_amortized_cost']}; final_phi={telescoping['final_phi']}")
    print('nonempty initial state: one push from n=m=1024 costs 1025, amortized 3; initial credit must be included')


if __name__ == '__main__':
    main()

怎样运行:将amortized-proof.py与array_shrink_threshold.py保存到同一目录,再执行python -B amortized-proof.py;写出amortized-proof.report.json,不重跑此前1912序列。

运行后对照结果
states=2058; transitions=4105; exact Fraction branch checks PASS
max amortized push=3; max amortized pop=2; initial potential=1/2
telescoping: actual=132; amortized=132; final_phi=1/2
nonempty initial state: one push from n=m=1024 costs 1025, amortized 3; initial credit must be included

记住这些要点

  • 缩容阈值紧贴扩容边界,会让少量增删反复复制整批元素。
  • 精确计数必须说清初始是否已满、≤或<、先删后判及最小容量。
  • 四分之一策略的一般序列界由分支势函数证明给出,不由有限枚举推出。
  • 摊还量是记账工具;单次resize仍可能线性耗时,模型常数也不是毫秒。

别把例子用过头

  • 把循环阶段成本当成含预填的完整成本。
  • 把单个反例改好就当成所有操作序列都不会抖动。
  • 容量降到0,或用不明确的浮点比例与取整判断阈值。
  • 忽略初始势能,或把负摊还量当成实际负耗时。

自己试一小步

换一个初始元素数与增删次序,你能完整核对容量和信用账本吗?

  1. 自选一个不是2的整数次幂的初始元素数,先画出预填后的size与capacity。
  2. 构造一段会经过缩容阈值前后位置的合法增删序列,手算每步复制数。
  3. 对四分之一策略逐步记录Φ、c与ĉ,运行模型后核对内容、容量及求和等式。

做到这些,就算完成

  • 每一步保持0≤size≤capacity,容量至少1;pop返回值与普通栈一致。
  • 明确采用≤还是<以及检查发生在减size前还是后,不把两种约定的数字混用。
  • 完整成本包含预填;若初始状态并非空数组,使用其真实初始势能。
  • 说明自己的有限轨迹验证了什么,以及一般界仍依靠哪些代数分支。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • 6.046J Lecture 5: Amortization (新标签页)↗MIT OpenCourseWare · 英文

    满时翻倍,降到1/4时减半,让每次调整后回到约半满,以操作间隔支付复制成本。

    2026-10-18 已实际核读:第1页table doubling、第2页accounting及第3页table doubling and halving;另读第4页势函数定义。本题采用其1/4策略;有限实验不代替一般序列的摊还论证。

  • Open Data Structures 2.1 ArrayStack (新标签页)↗Pat Morin, author of Open Data Structures · 英文

    区分元素数n和容量、分析相邻resize之间积累的操作数,并指出中间插入移动成本另算。

    2026-10-18 已实际核读:2.1.1删除后检查容量、2.1.2 Growing and Shrinking与Lemma 2.1证明(约27–54行)。ODS用capacity≥3n时resize到2n,并非本原型的1/4减半策略;用于对照“阈值留间隔”的设计,不混称同一实现。

  • CS 3110 Lecture 22: Amortized Analysis (新标签页)↗Cornell University · 英文

    摊还针对整段操作序列,记账成本不是实际单次耗时;本题不能把低平均复制数当作每次操作的延迟保证。

    2026-10-18 已实际核读:Amortized Analysis定义、扩容操作表、accounting与potential讨论(10–116行)。该页主体是仅追加的翻倍分析,未把它误引为1/4缩容阈值出处。