扩缩容(Resize)

动态数组在元素数量超出容量时扩容、在元素过少时缩容的机制,用于平衡时间效率与空间利用率。

定义

扩容(Expand):当动态数组的元素个数达到当前容量上限时,申请一个更大的底层数组,并将所有元素复制到新数组中。

缩容(Shrink):当动态数组的元素个数减少到一定程度时,申请一个更小的底层数组,释放多余的空间。

为什么需要扩缩容?

静态数组的限制

静态数组的容量在创建时就固定了:

  • 无法在原有内存后面"追加"空间(后面的内存可能已被占用)
  • 只能重新申请更大的连续内存,再复制所有数据

空间利用率问题

如果从不缩容:

  • 假设初始容量 1000,但只存储了 10 个元素 → 990 个空间被浪费
  • 长期运行的应用程序可能积累大量无用内存

扩缩容策略

本文实现的策略(Java MyArrayList):

操作 触发条件 新容量计算 时间复杂度
扩容 size == capacity newCap = 2 * oldCap O(n)
缩容 size == capacity / 4 newCap = oldCap / 2 O(n)

为什么扩容是 2 倍?

常见选择:1.5 倍或 2 倍

  • 2 倍扩容:实现简单,扩容次数少,但可能浪费更多空间
  • 1.5 倍扩容:空间利用率更高,但扩容次数稍多

时间效率:使用乘法扩容(而非加法),保证均摊时间复杂度为 O(1)。

为什么缩容条件是 1/4 而不是 1/2?

防止抖动(Thrashing)

假设缩容条件是 1/2:
1. 数组容量 100,元素 50(刚好一半)
2. 添加一个元素 → 触发扩容 → 容量 200
3. 删除一个元素 → 剩 50 个 → 触发缩容 → 容量 100
4. 反复操作 → 频繁扩缩容,性能严重下降

使用 1/4 作为阈值:

  • 当元素降到 1/4 时才缩容
  • 留出缓冲空间,避免临界点反复横跳

均摊时间复杂度

虽然单次扩缩容需要 O(n),但均摊下来每次操作的成本是 O(1)。

直观理解

  • 假设初始容量 1,依次添加 n 个元素
  • 扩容发生在:1→2, 2→4, 4→8, ... , 2^k → 2^(k+1)
  • 每个元素被复制的次数:最多 log₂(n) 次
  • 均摊到 n 次操作:O(log n) / O(1) ?实际是 O(1)

精确的均摊分析

  • 使用记账方法势能分析
  • 每次插入操作"预付"一些成本用于未来的扩容
  • 最终证明:n 次插入的总成本 = O(n),均摊 = O(1)

实现示例

private void resize(int newCap) {
    E[] temp = (E[]) new Object[newCap];
    for (int i = 0; i < size; i++) {
        temp[i] = data[i];
    }
    data = temp;
}

缩容触发

public E removeLast() {
    // ...
    if (size == cap / 4) {
        resize(cap / 2);
    }
    // ...
}

相关概念

  • 动态数组:使用扩缩容的数据结构
  • 时间复杂度:均摊分析
  • 抖动(Thrashing):频繁扩缩容导致的性能问题

总结

扩缩容是动态数组的核心机制。设计良好的策略(2倍扩容、1/4缩容)能够在时间效率和空间利用率之间取得平衡,并通过均摊分析保证长期性能。


Interactive Graph