第 2 章

数组:连续内存的力量与代价

第二章:数组 — 连续内存的力量与代价

在所有数据结构中,数组是最简单的,也是最被低估的。它没有花哨的指针操作,没有复杂的平衡条件,只有一块连续的内存和一个偏移量计算公式。但正是这种极致的简单,让它在现代 CPU 架构上拥有无与伦比的性能优势。

理解数组,不是理解"一排格子",而是理解计算机硬件如何与软件协作——连续内存让 CPU 的缓存预取机制满负荷运转,让每一次内存访问都有极高的命中率。这一章,我们从最基础的定义出发,深入到 CPython 源码、CPU 缓存架构、均摊分析,最终在面试高频题中将这些知识融会贯通。


Level 1 · 你需要知道的

1.1 数组的本质:连续内存 + 随机访问

数组的核心思想极其简单:在内存中分配一块连续的空间,每个元素占据相同大小的位置,通过起始地址 + 偏移量直接定位任意元素。

地址计算公式:
element_address = base_address + index × element_size

这个公式意味着什么?意味着无论数组有 10 个元素还是 10 亿个元素,访问第 i 个元素的时间都是恒定的——O(1)。你不需要从头遍历,不需要跟随指针跳转,一次乘法加一次加法,CPU 一个时钟周期就能算出目标地址。

这就是随机访问(Random Access)——数组得名的原因。RAM(Random Access Memory)这个词中的 Random Access,说的就是这件事。

1.2 Python list 不是传统数组

如果你的第一门语言是 Python,你可能认为数组就是 list。但 Python 的 list 和 C 语言的数组有本质区别:

特性 C 数组 Python list
内存布局 值直接排列在连续内存 指针(引用)排列在连续内存
元素类型 必须相同 可以混合
长度 创建时固定 可动态增长
内存效率 极高(无额外开销) 较低(每个元素多一层间接引用)

Python list 的底层是一个指针数组:连续内存中存储的不是数据本身,而是指向各个 Python 对象的指针(在 64 位系统上,每个指针 8 字节)。

# 这不是 [1, 2, 3] 紧密排列在一起
# 而是 [ptr_to_1, ptr_to_2, ptr_to_3] 紧密排列
# 每个 ptr 指向堆上的一个 int 对象
my_list = [1, 2, 3]

这意味着:

  1. 类型灵活[1, "hello", 3.14, None] 完全合法,因为指针不关心指向什么类型
  2. 额外开销:每个元素至少多消耗 8 字节(指针)+ 对象头部(28 字节 for int)
  3. 缓存不友好:遍历 list 时,CPU 需要先读指针,再跟着指针跳到对象位置,内存访问模式不连续

1.3 常见操作的时间复杂度

每个 Python 开发者都应该把这张表刻在脑子里:

操作 时间复杂度 原因
lst[i] O(1) 地址计算,一步到位
lst[i] = x O(1) 同上
lst.append(x) 均摊 O(1) 大多数时候直接放末尾,偶尔需要扩容
lst.pop() O(1) 移除末尾元素,无需移动
lst.pop(0) O(n) 移除头部后,所有元素左移一位
lst.insert(0, x) O(n) 头部插入,所有元素右移一位
lst.insert(i, x) O(n-i) 从 i 开始后面的元素右移
x in lst O(n) 最坏情况遍历整个列表
lst.index(x) O(n) 线性查找
lst[a:b] O(b-a) 需要复制切片中的引用
lst.extend(other) O(len(other)) 逐个复制引用
lst.sort() O(n log n) Timsort
len(lst) O(1) 长度是预存储的属性

关键洞察append 是均摊 O(1) 而不是严格 O(1)。大多数时候,列表末尾有预留空间,直接写入即可。但当预留空间用完时,Python 必须分配一块更大的内存,把所有元素搬过去。这个扩容操作是 O(n) 的,但因为发生频率很低,分摊到每次 append 上仍然是 O(1)。我们会在 Level 2 详细分析这个过程。

1.4 二维数组:行优先存储

当我们需要表示矩阵或网格时,就需要二维数组。在 Python 中,二维数组通常用嵌套列表表示:

# 3×4 的矩阵
matrix = [
    [1,  2,  3,  4],
    [5,  6,  7,  8],
    [9, 10, 11, 12]
]

# 访问第 i 行第 j 列
element = matrix[i][j]

在底层内存中,这些数据是如何排列的?绝大多数语言(C、C++、Java、Python)使用行优先存储(Row-Major Order):先存第一行的所有元素,再存第二行,依此类推。

内存中的实际排列(行优先):
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
 ←—第0行—→  ←—第1行—→  ←——第2行——→

这意味着:按行遍历比按列遍历快得多。按行遍历时,连续访问的元素在内存中也是连续的,CPU 缓存可以一次性加载一整行;按列遍历时,相邻访问的元素在内存中相隔一整行的距离,缓存命中率大幅下降。

import time

n = 5000
matrix = [[0] * n for _ in range(n)]

# 按行遍历 — 快
start = time.time()
for i in range(n):
    for j in range(n):
        matrix[i][j] = 1
row_time = time.time() - start

# 按列遍历 — 慢
start = time.time()
for j in range(n):
    for i in range(n):
        matrix[i][j] = 2
col_time = time.time() - start

print(f"按行遍历: {row_time:.3f}s")
print(f"按列遍历: {col_time:.3f}s")
print(f"列遍历/行遍历 = {col_time/row_time:.1f}x")
# 典型输出:列遍历比行遍历慢 2-5 倍

1.5 常见错误:浅拷贝陷阱

这是 Python 中最经典的数组陷阱,几乎每个初学者都会踩:

# ❌ 错误的二维数组初始化
matrix = [[0] * 3] * 3
matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]]  ← 三行全变了!

# 为什么?因为 * 3 创建的是三个指向同一列表的引用
# matrix[0], matrix[1], matrix[2] 指向同一个 [0, 0, 0]
# ✅ 正确的二维数组初始化
matrix = [[0] * 3 for _ in range(3)]
matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [0, 0, 0], [0, 0, 0]]  ← 只有第一行变了

为什么 [0] * 3 没问题但 [[0]*3] * 3 有问题?

[0] * 3 创建的是 [0, 0, 0],里面的 0 是不可变对象(int),即使三个位置"共享"同一个 0 对象,你也无法修改 0 本身——赋值 lst[0] = 1 只是让 lst[0] 指向新的对象 1。

[[0]*3] * 3 里面的元素是列表(可变对象),三行共享同一个列表对象。当你修改 matrix[0][0] 时,你修改的是那个共享的列表对象,所以三行都变了。

记住这个规则* 运算符复制的是引用,不是对象。对于不可变类型(int、str、tuple),这无所谓;对于可变类型(list、dict),这就是灾难。

1.6 越界访问

不同语言对越界访问的处理方式天差地别:

# Python — 抛出 IndexError,安全但有性能开销
lst = [1, 2, 3]
lst[5]  # IndexError: list index out of range

# Python 支持负索引
lst[-1]  # 3 (最后一个元素)
lst[-2]  # 2 (倒数第二个)
// C — 未定义行为,不检查边界
int arr[3] = {1, 2, 3};
int x = arr[5];  // 未定义行为!可能读到垃圾值,可能段错误
                 // 更可怕的是:可能看起来"正常工作"

Python 的负索引是一个优雅的设计。lst[-k] 等价于 lst[len(lst) - k]。这让从末尾开始的操作非常方便:

lst = [10, 20, 30, 40, 50]
lst[-1]    # 50  — 最后一个
lst[-2:]   # [40, 50]  — 最后两个
lst[:-1]   # [10, 20, 30, 40]  — 除了最后一个

Level 2 · 它是怎么运行的

2.1 动态数组的扩容策略

Python list 是一个动态数组(Dynamic Array):当你不断 append 元素时,它会自动增长。但"自动增长"不是免费的——每次扩容都意味着分配新内存、复制所有元素、释放旧内存。

CPython(Python 的官方实现)的扩容逻辑位于 Objects/listobject.c 中的 list_resize 函数。其核心公式是:

/* CPython 3.12 Objects/listobject.c */
static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{
    /* ... */
    new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;
    /* ... */
}

翻译成数学公式:

new_allocated = (newsize + newsize // 8 + 6) & ~3
             ≈ newsize × 1.125 + 6(然后向上对齐到 4 的倍数)

让我们用 Python 来观察这个增长过程:

import sys

lst = []
prev_size = sys.getsizeof(lst)
print(f"{'元素数':>6} {'字节数':>8} {'实际容量':>8} {'触发扩容':>8}")

for i in range(100):
    lst.append(i)
    curr_size = sys.getsizeof(lst)
    # 每个指针 8 字节,空 list 的基础大小是 56 字节(64位系统)
    capacity = (curr_size - 56) // 8
    if curr_size != prev_size:
        print(f"{i+1:>6} {curr_size:>8} {capacity:>8} {'←':>8}")
        prev_size = curr_size

典型输出(CPython 3.12,64 位系统):

元素数     字节数   实际容量   触发扩容
     1       88        4        ←
     5      120        8        ←
     9      184       16        ←
    17      248       24        ←
    25      312       32        ←
    33      408       44        ←
    45      504       56        ←
    57      632       72        ←
    73      792       92        ←
    93      984      116        ←

观察规律:容量从 4 → 8 → 16 → 24 → 32 → 44 → 56 → 72 → 92 → 116...

增长因子大约是 1.125(即 9/8),而不是常见教科书上说的 2。这是 CPython 团队精心选择的结果。

2.2 为什么扩容因子是 ~1.125 而不是 2

扩容因子的选择是一个经典的工程权衡问题。让我们分析不同因子的利弊:

因子 = 2(Java ArrayList、C++ std::vector 的一种实现)

优点:均摊分析最简单,每次扩容后有 50% 的剩余空间 缺点:

因子 = 1.5(C++ MSVC std::vector、Java ArrayList)

优点:

缺点:均摊常数稍大

因子 ≈ 1.125(CPython list)

优点:

缺点:

CPython 团队的设计哲学:Python 是内存密集型语言(每个对象都有引用计数、类型指针等开销),所以在可以省内存的地方就省。动态数组的扩容成本本身就被解释器的循环开销掩盖了,选择小因子几乎不影响整体性能,但能显著减少内存占用。

这个设计决策由 Raymond Hettinger 和 Tim Peters 等 CPython 核心开发者在 2004 年前后确定,后续微调了常数项。Tim Peters 在 Python 邮件列表中解释道:对于 Python 这种每个整数都是堆上的对象的语言,list 本身的内存开销相对于其元素的开销来说很小,所以用更保守的增长策略不会造成显著的性能下降。

2.3 Cache 局部性:为什么数组遍历比链表快 10 倍

理论上,遍历一个包含 n 个元素的数组和遍历一个包含 n 个节点的链表,时间复杂度都是 O(n)。但在实际硬件上,数组遍历可以比链表快 10-100 倍。这个差距完全来自 CPU 缓存

CPU 缓存的工作原理

现代 CPU 不直接从主内存读数据。在 CPU 和主内存之间,有多级缓存:

CPU 寄存器 → L1 Cache (32KB, ~1ns) → L2 Cache (256KB, ~4ns) 
          → L3 Cache (8-32MB, ~12ns) → 主内存 (GB级, ~100ns)

当 CPU 需要读取地址 X 处的数据时,它不只读 X 这一个字节,而是把 X 所在的整个 Cache Line(通常 64 字节)一次性加载到缓存中。这是基于空间局部性(Spatial Locality)原则的硬件优化:如果你访问了地址 X,你很可能马上会访问 X+1、X+2...

数组的情况

Cache Line (64字节):
[elem0][elem1][elem2][elem3][elem4][elem5][elem6][elem7]
← 一次加载,连续8个int(8字节)全部就位 →

遍历过程:
读 elem0 → Cache Miss(去主存取,100ns)
读 elem1 → Cache Hit(已在缓存中,1ns)
读 elem2 → Cache Hit
...
读 elem7 → Cache Hit
读 elem8 → Cache Miss(新的 Cache Line)

每 8 个元素只有 1 次 Cache Miss,命中率 7/8 = 87.5%。加上硬件预取器(Prefetcher)会检测到顺序访问模式,提前加载下一个 Cache Line,实际命中率接近 100%。

链表的情况

节点随机散布在堆内存中:
[node_A @0x1000] → [node_B @0x5830] → [node_C @0x2F10] → ...

遍历过程:
读 node_A → Cache Miss(100ns)
读 node_B → Cache Miss(地址跳跃,不在同一 Cache Line)
读 node_C → Cache Miss
... 几乎每次都 Miss

每个节点都可能在不同的 Cache Line 中(甚至不同的内存页中),几乎每次访问都是 Cache Miss。

实验验证

import time
import array

# 方法1:数组顺序遍历
n = 10_000_000
arr = array.array('l', range(n))  # 紧凑整数数组

start = time.perf_counter()
total = 0
for x in arr:
    total += x
array_time = time.perf_counter() - start

# 方法2:模拟链表的随机访问模式
import random
indices = list(range(n))
random.shuffle(indices)

start = time.perf_counter()
total = 0
for idx in indices:
    total += arr[idx]
random_time = time.perf_counter() - start

print(f"顺序遍历: {array_time:.3f}s")
print(f"随机遍历: {random_time:.3f}s")
print(f"随机/顺序 = {random_time/array_time:.1f}x")
# 典型输出:随机遍历慢 5-15 倍

为什么差距这么大? 主内存延迟约 100ns,而 L1 缓存延迟约 1ns,差了 100 倍。即使考虑到 L2/L3 的中间层,随机访问的平均延迟也比顺序访问高一个数量级。这就是为什么在实际工程中,数据结构的选择不能只看大 O,还要看常数因子和缓存友好性

2.4 array 模块 vs numpy vs list

Python 提供了三种"数组",它们的设计目标和性能特征完全不同:

list — 通用动态数组

lst = [1, 2, 3, 4, 5]
# 底层:[ptr, ptr, ptr, ptr, ptr] → 每个 ptr 指向堆上的 int 对象
# 每个元素开销:8(指针) + 28(int对象) = 36 字节
# 5个int的实际内存:56(list头) + 40(指针数组) + 140(int对象) ≈ 236 字节

array.array — 紧凑类型数组(标准库)

import array
arr = array.array('l', [1, 2, 3, 4, 5])  # 'l' = signed long (8字节)
# 底层:[1, 2, 3, 4, 5] 直接存值,无指针间接层
# 每个元素开销:8 字节(仅数值本身)
# 5个long的实际内存:64(array头) + 40(数据) ≈ 104 字节

array.array 和 C 数组一样,直接在连续内存中存储原始值。但它的 Python 层循环仍然很慢——每次访问元素时,Python 需要把原始字节装箱(boxing)成 Python int 对象。

numpy.ndarray — 高性能数值计算数组

import numpy as np
arr = np.array([1, 2, 3, 4, 5], dtype=np.int64)
# 底层:和 array.array 一样紧凑存储
# 优势:向量化操作在 C 层循环,避免 Python 循环和装箱开销
import numpy as np
import time

n = 10_000_000

# Python list 求和
lst = list(range(n))
start = time.perf_counter()
s1 = sum(lst)
list_time = time.perf_counter() - start

# numpy 求和
arr = np.arange(n, dtype=np.int64)
start = time.perf_counter()
s2 = arr.sum()
numpy_time = time.perf_counter() - start

print(f"list sum:  {list_time:.4f}s")
print(f"numpy sum: {numpy_time:.4f}s")
print(f"numpy 快 {list_time/numpy_time:.0f} 倍")
# 典型输出:numpy 快 50-100 倍

numpy 快的原因是三重加持:

  1. 无装箱开销:直接操作原始字节
  2. C 层循环:避免 Python 字节码解释器的循环开销
  3. SIMD 指令:利用 CPU 的向量指令一次处理多个数据
特性 list array.array numpy.ndarray
元素类型 任意 单一基本类型 单一数值类型
存储方式 存引用 存值 存值
动态增长 否(固定大小)
Python循环性能 慢(装箱) 慢(装箱)
向量化操作 极快
使用场景 通用编程 紧凑存储/IO 数值计算

2.5 内存对齐与 struct 模块

当我们需要和 C 代码交互,或者处理二进制文件格式时,必须理解内存对齐(Memory Alignment)。

CPU 访问内存时有对齐要求:一个 4 字节的 int 最好放在 4 的倍数地址上,一个 8 字节的 double 最好放在 8 的倍数地址上。不对齐的访问在某些 CPU 上会导致性能下降(x86),在另一些上会直接崩溃(ARM)。

import struct

# 假设我们要表示一个 C 结构体:
# struct Point {
#     char  label;    // 1 字节
#     int   x;       // 4 字节
#     int   y;       // 4 字节
# };

# 不考虑对齐,紧凑排列
packed = struct.pack('=bii', ord('A'), 100, 200)
print(f"紧凑大小: {len(packed)} 字节")  # 9 字节

# 考虑 C 的默认对齐规则
aligned = struct.pack('bii', ord('A'), 100, 200)
print(f"对齐大小: {len(aligned)} 字节")  # 12 字节(label 后有 3 字节填充)

# 内存布局:
# [label][pad][pad][pad][  x (4字节)  ][  y (4字节)  ]
#  0      1    2    3    4   5   6   7   8   9  10  11

struct 模块的格式字符串前缀控制字节序和对齐:

前缀 字节序 对齐
@ 本地 本地对齐
= 本地 无对齐(紧凑)
< 小端 无对齐
> 大端 无对齐
! 网络序(大端) 无对齐

这在处理网络协议、文件格式、硬件寄存器映射时至关重要。numpy 的 dtype 也遵循类似的对齐规则,这直接影响了数组的内存布局和 SIMD 操作的效率。


Level 3 · 规范怎么定义的

3.1 动态数组的均摊分析

在第一章中,我们介绍了 Tarjan(1985)提出的均摊分析框架。现在我们用势能法(Potential Method)来严格证明动态数组 append 操作的均摊复杂度为 O(1)。

定义势能函数

设当前数组有 n 个元素,容量为 c。定义势能函数:

Φ(n, c) = 2n - c

当数组刚扩容完毕时(n ≈ c/α,其中 α 是扩容因子),势能较低。随着元素不断添加,势能逐渐升高,直到触发下一次扩容。

情况一:append 不触发扩容

实际代价 cᵢ = 1(直接写入一个元素) 势能变化 ΔΦ = 2(n+1) - c - (2n - c) = 2 均摊代价 ĉᵢ = cᵢ + ΔΦ = 1 + 2 = 3

情况二:append 触发扩容(以因子 2 为例)

设扩容前 n = c(数组已满),新容量 c' = 2c。 实际代价 cᵢ = n + 1(复制 n 个元素 + 写入新元素) 势能变化 ΔΦ = [2(n+1) - 2n] - [2n - n] = 2 - n + 2 = 4 - n 均摊代价 ĉᵢ = (n + 1) + (4 - n) = 5

结论:无论是否触发扩容,均摊代价都是 O(1) 常数。势能法的精妙之处在于:非扩容操作的"多付"的势能,恰好覆盖了扩容操作的"欠付"。

对于 CPython 的 1.125 因子,分析过程类似,只是常数不同。设 α = 9/8:

扩容后:n ≈ (8/9)c,Φ = 2n - c ≈ (16/9 - 1)c = (7/9)c
扩容前:n = c,Φ = 2c - c = c

均摊代价仍然是 O(1),但常数比因子 2 的情况稍大(约 18 vs 5),这就是更保守增长策略的代价。

3.2 数组在不同语言中的实现

理解同一个概念在不同语言中的实现差异,能帮助你深入理解设计权衡。

C 语言:栈数组 vs 堆数组

// 栈上数组:编译期确定大小,函数返回即销毁
int arr[100];  // 不初始化,内容未定义

// 堆上数组:运行时确定大小,需要手动释放
int *arr = malloc(n * sizeof(int));
// ... 使用 arr ...
free(arr);  // 忘记 free = 内存泄漏

// C99 VLA (Variable Length Array):栈上变长数组
int n = get_size();
int arr[n];  // 大小运行时确定,但在栈上分配
             // 危险:如果 n 很大会栈溢出

C 数组的特点:无越界检查、无长度信息(只有指针)、不可动态增长。这既是性能优势(零开销抽象),也是安全隐患(缓冲区溢出漏洞的根源)。

Java:ArrayList

// Java 的 ArrayList 类似 Python list,但有类型约束
ArrayList<Integer> list = new ArrayList<>();
// 默认初始容量:10
// 扩容因子:1.5(newCapacity = oldCapacity + (oldCapacity >> 1))
// JDK 源码 java.util.ArrayList#grow:
//   int newCapacity = oldCapacity + (oldCapacity >> 1);

Java 选择 1.5 的原因:介于 CPython 的 1.125(太保守)和 2(太浪费)之间,是一个平衡点。Java 的 ArrayList 不需要像 Python 那样极端地省内存,因为 Java 的 int 可以是栈上的原始类型(无装箱开销),内存效率本身就比 Python 高得多。

Rust:Vec

let mut v: Vec<i32> = Vec::new();
// 初始容量:0(不分配内存,直到第一次 push)
// 扩容因子:2
// Rust 的 Vec 选择因子 2 的理由:
// 1. 使用 jemalloc/system allocator,善于处理 2 的幂大小的分配
// 2. Rust 强调零成本抽象,2 倍扩容的均摊常数最小
// 3. 有 shrink_to_fit() 可以手动回收多余内存
语言 类型 初始容量 扩容因子 设计哲学
CPython list 0 ~1.125 省内存优先
Java ArrayList 10 1.5 均衡
C++ (GCC) vector 0 2 性能优先
C++ (MSVC) vector 0 1.5 内存复用
Rust Vec 0 2 零成本抽象
Go slice 0 1.25~2 根据大小自适应

3.3 CPU Cache 架构详解

(此节与《计算机是怎样工作的》第 11 章互为补充。第 11 章从硬件设计者的角度讲述 Cache 的电路实现,本节从程序员的角度讲如何利用 Cache。)

Cache 层次结构

现代 CPU(以 Apple M2 为例)的缓存层次:

层级 容量 延迟 带宽 共享
L1 Data 128KB/核 ~3 cycles (~1ns) ~200 GB/s 核私有
L1 Instruction 192KB/核 ~3 cycles - 核私有
L2 16MB (共享) ~12 cycles (~4ns) ~100 GB/s P-core 共享
SLC (System Level Cache) 32MB ~30 cycles (~10ns) ~80 GB/s 所有核共享
主内存 (LPDDR5) 8-24GB ~120 cycles (~40ns) ~100 GB/s 全局

以 Intel i7-12700K 为例:

层级 容量 延迟 带宽
L1D 48KB/核 4 cycles (~1.2ns) ~1500 GB/s
L2 1.25MB/核 12 cycles (~3.6ns) ~700 GB/s
L3 25MB (共享) 42 cycles (~12.7ns) ~400 GB/s
DDR5 32-128GB ~200+ cycles (~60ns) ~50 GB/s

Cache Line 是传输的最小单位

Cache 不是一字节一字节地搬运数据,而是以 Cache Line 为单位(绝大多数现代处理器为 64 字节)。这意味着:

# 假设 int 为 8 字节,一个 Cache Line 容纳 8 个 int
# 访问 arr[0] 时,arr[0] ~ arr[7] 全部被加载到缓存

arr = [0] * 1000  # 假设紧凑存储
# 访问 arr[0] → Cache Miss,加载 64 字节 = 8 个 int
# 访问 arr[1] ~ arr[7] → 全部 Cache Hit
# 访问 arr[8] → Cache Miss,加载下一个 Cache Line

硬件预取器(Hardware Prefetcher)

现代 CPU 内置了硬件预取器,可以检测内存访问模式:

这就是为什么数组遍历在现代 CPU 上几乎达到内存带宽的理论极限——硬件预取器让几乎所有 Cache Miss 都变成了"隐藏延迟"(数据在你需要之前就已经到了)。

3.4 行优先 vs 列优先存储

行优先(Row-Major Order)— C、C++、Java、Python

二维数组 A[M][N] 在内存中按行展开:

A[0][0], A[0][1], ..., A[0][N-1], A[1][0], A[1][1], ..., A[M-1][N-1]

地址计算:A[i][j] = base + (i × N + j) × element_size

列优先(Column-Major Order)— Fortran、MATLAB、R、Julia

同一个数组按列展开:

A[0][0], A[1][0], ..., A[M-1][0], A[0][1], A[1][1], ..., A[M-1][N-1]

地址计算:A[i][j] = base + (j × M + i) × element_size

为什么有两种标准?

这要追溯到 1957 年 Fortran 语言的诞生。Fortran 的设计者 John Backus 选择列优先存储,因为在数学和物理中,矩阵运算(如线性代数的列向量操作)更常按列访问。后来 C 语言选择了行优先,因为它更符合"先写行索引后写列索引"的思维习惯。两种标准各有优势,但关键是你必须知道当前使用的是哪种,否则会导致严重的性能问题。

numpy 的选择:两者都支持

import numpy as np

# C 顺序(行优先,默认)
a = np.array([[1, 2, 3], [4, 5, 6]], order='C')
print(a.strides)  # (24, 8) — 跳一行需要 24 字节,跳一列需要 8 字节

# Fortran 顺序(列优先)
b = np.array([[1, 2, 3], [4, 5, 6]], order='F')
print(b.strides)  # (8, 16) — 跳一行需要 8 字节,跳一列需要 16 字节

性能影响

import numpy as np
import time

n = 3000
# 行优先矩阵
A_c = np.random.rand(n, n).astype(np.float64)  # order='C'

# 列优先矩阵
A_f = np.asfortranarray(A_c)  # order='F'

# 按行求和(行优先更快)
start = time.perf_counter()
for _ in range(100):
    A_c.sum(axis=1)
c_row_time = time.perf_counter() - start

start = time.perf_counter()
for _ in range(100):
    A_f.sum(axis=1)
f_row_time = time.perf_counter() - start

# 按列求和(列优先更快)
start = time.perf_counter()
for _ in range(100):
    A_c.sum(axis=0)
c_col_time = time.perf_counter() - start

start = time.perf_counter()
for _ in range(100):
    A_f.sum(axis=0)
f_col_time = time.perf_counter() - start

print(f"按行求和: C-order {c_row_time:.3f}s, F-order {f_row_time:.3f}s")
print(f"按列求和: C-order {c_col_time:.3f}s, F-order {f_col_time:.3f}s")

实际工程中的影响


Level 4 · 边界与陷阱

4.1 Python list 的隐藏性能陷阱

陷阱一:list.insert(0, x) — O(n) 的头部插入

import time
from collections import deque

n = 100_000

# ❌ 用 list 做头部插入
lst = []
start = time.perf_counter()
for i in range(n):
    lst.insert(0, i)
list_time = time.perf_counter() - start

# ✅ 用 deque 做头部插入
dq = deque()
start = time.perf_counter()
for i in range(n):
    dq.appendleft(i)
deque_time = time.perf_counter() - start

print(f"list.insert(0): {list_time:.3f}s")
print(f"deque.appendleft: {deque_time:.3f}s")
print(f"list 慢 {list_time/deque_time:.0f} 倍")
# 典型输出:list 慢 100-500 倍

为什么 list.insert(0, x) 这么慢? 每次插入都需要把所有现有元素右移一位。对于 n 次操作,总移动次数是 0+1+2+...+(n-1) = O(n²)。

collections.deque 底层是一个双端队列(由固定大小的块组成的双向链表),头部和尾部的插入/删除都是 O(1)。规则:如果你需要频繁在头部操作,用 deque 代替 list。

陷阱二:list + list — 创建新列表的隐藏拷贝

# ❌ 在循环中用 + 拼接列表
result = []
for chunk in data_chunks:
    result = result + chunk  # 每次都创建新列表并复制所有元素!
# 总时间复杂度:O(n × k),其中 n 是 chunk 数,k 是平均 chunk 大小
# 如果所有 chunk 总共 N 个元素,实际是 O(N²)

# ✅ 用 extend 或 +=
result = []
for chunk in data_chunks:
    result.extend(chunk)  # 原地扩展,O(总元素数)
# 或者更简洁
result = []
for chunk in data_chunks:
    result += chunk  # += 对 list 等价于 extend,是原地操作!

注意result = result + chunkresult += chunk 的语义完全不同!前者调用 __add__(创建新列表),后者调用 __iadd__(原地扩展)。这是 Python 中一个容易被忽视的坑。

陷阱三:list * n 的引用共享

# 表面无害的初始化
board = [[None] * 8] * 8  # 8×8 棋盘

# 实际上 8 行都是同一个列表
board[0][0] = "King"
print(board[3][0])  # "King" — 所有行都被影响了

# 正确写法
board = [[None] * 8 for _ in range(8)]

这个陷阱我们在 Level 1 已经提过,但这里要强调的是:即使是经验丰富的程序员,在更复杂的场景中也会犯这个错误:

# 更隐蔽的变体
cache = [{}] * 100  # 100 个"独立"的字典?不,是同一个!
cache[0]["key"] = "value"
print(cache[99]["key"])  # "value" — 100 个位置共享一个字典

# 正确
cache = [{} for _ in range(100)]

陷阱四:in 操作符的线性搜索

# ❌ 对 list 使用 in(O(n))
blacklist = [...]  # 10万个元素
if user_id in blacklist:  # 每次检查遍历整个列表
    block()

# ✅ 转成 set(O(1) 查找)
blacklist_set = set(blacklist)  # 一次性 O(n) 转换
if user_id in blacklist_set:  # O(1) 哈希查找
    block()

4.2 大数组的内存估算

在生产环境中,你需要准确估算数据结构的内存占用。Python 的 sys.getsizeof 有一个重大限制:它只计算对象本身的大小,不递归计算引用对象

import sys

# sys.getsizeof 的误导性
lst = [1, 2, 3]
print(sys.getsizeof(lst))  # 88 字节 — 只有 list 对象本身!
# 不包含 1, 2, 3 这三个 int 对象的大小

# 单个 int 的大小
print(sys.getsizeof(1))    # 28 字节
print(sys.getsizeof(0))    # 28 字节
print(sys.getsizeof(2**30))  # 32 字节(大整数需要更多空间)

# 真实总内存:88 + 3 × 28 = 172 字节(存3个小整数)
# 但 CPython 对小整数 (-5 ~ 256) 有缓存池,所以不会重复分配

递归计算真实内存占用

import sys
from collections import deque

def deep_getsizeof(obj, seen=None):
    """递归计算对象及其所有引用对象的总内存"""
    if seen is None:
        seen = set()
    obj_id = id(obj)
    if obj_id in seen:
        return 0
    seen.add(obj_id)
    
    size = sys.getsizeof(obj)
    
    if isinstance(obj, dict):
        size += sum(deep_getsizeof(k, seen) + deep_getsizeof(v, seen) 
                   for k, v in obj.items())
    elif isinstance(obj, (list, tuple, set, frozenset, deque)):
        size += sum(deep_getsizeof(item, seen) for item in obj)
    
    return size

# 示例
lst = list(range(1000))
print(f"sys.getsizeof: {sys.getsizeof(lst):,} 字节")
print(f"deep_getsizeof: {deep_getsizeof(lst):,} 字节")
# sys.getsizeof: ~8,056 字节(只有指针数组)
# deep_getsizeof: ~36,056 字节(包含所有 int 对象)

大规模数据的内存估算公式

数据类型 每元素内存(Python list) 每元素内存(numpy)
int (≤256) 8 字节(指针,int 被缓存) 8 字节(int64)
int (>256) 8+28 = 36 字节 8 字节
float 8+24 = 32 字节 8 字节(float64)
str (短) 8+50~100 字节 不适用
None 8 字节(指针,单例) 不适用
# 实际工程估算示例
# 100万个浮点数
n = 1_000_000

# Python list
# 指针数组:56 + 8×n ≈ 8 MB
# float 对象:24×n ≈ 24 MB
# 总计:约 32 MB

# numpy array
# 8×n = 8 MB
# 节省 75% 内存!

import numpy as np
lst = [float(i) for i in range(n)]
arr = np.arange(n, dtype=np.float64)

print(f"list 实际内存: ~{sys.getsizeof(lst)/1024/1024:.1f} MB (不含float对象)")
print(f"numpy 实际内存: {arr.nbytes/1024/1024:.1f} MB")

4.3 面试高频题精讲

数组是面试中最高频的考点之一。以下是四道经典题目的深度解析。

题目一:旋转数组 — LeetCode #189

给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置。

# 方法1:切片(简洁但创建新数组,空间 O(n))
def rotate_v1(nums, k):
    n = len(nums)
    k = k % n  # 处理 k > n 的情况
    nums[:] = nums[-k:] + nums[:-k]
    # 注意:nums[:] = ... 是原地修改,nums = ... 是重新绑定变量

# 方法2:三次翻转(原地,空间 O(1))
def rotate_v2(nums, k):
    n = len(nums)
    k = k % n
    
    def reverse(arr, left, right):
        while left < right:
            arr[left], arr[right] = arr[right], arr[left]
            left += 1
            right -= 1
    
    # 整体翻转 → 前 k 个翻转 → 后 n-k 个翻转
    # 原理:[1,2,3,4,5,6,7], k=3
    # 整体翻转:[7,6,5,4,3,2,1]
    # 前3翻转:[5,6,7,4,3,2,1]
    # 后4翻转:[5,6,7,1,2,3,4] ✓
    reverse(nums, 0, n - 1)
    reverse(nums, 0, k - 1)
    reverse(nums, k, n - 1)

# 方法3:环形替换(原地,空间 O(1),更难理解)
def rotate_v3(nums, k):
    n = len(nums)
    k = k % n
    if k == 0:
        return
    
    count = 0  # 已放置的元素数
    start = 0
    while count < n:
        current = start
        prev = nums[start]
        while True:
            next_idx = (current + k) % n
            nums[next_idx], prev = prev, nums[next_idx]
            current = next_idx
            count += 1
            if current == start:
                break
        start += 1

三次翻转法的数学证明

设原数组为 a₀, a₁, ..., aₙ₋₁,右旋 k 位后应为 aₙ₋ₖ, ..., aₙ₋₁, a₀, ..., aₙ₋ₖ₋₁

rev(A) 表示翻转序列 A。设 A = a₀...aₙ₋ₖ₋₁,B = aₙ₋ₖ...aₙ₋₁

原序列为 AB,目标为 BA。

rev(rev(A)·rev(B)) = rev(rev(B))·rev(rev(A)) = BA

所以:先翻转整体 rev(AB),再分别翻转前后两段,等价于将 AB 变为 BA。

题目二:合并两个有序数组 — LeetCode #88

给你两个有序整数数组 nums1 和 nums2,将 nums2 合并到 nums1 中。nums1 后面有足够的空间。

def merge(nums1, m, nums2, n):
    """
    关键技巧:从后往前填充,避免覆盖未处理的元素
    
    为什么从后往前?如果从前往前,nums1 前面的元素会被覆盖,
    需要额外空间保存。从后往前填充,利用 nums1 尾部的空位。
    """
    p1, p2, p = m - 1, n - 1, m + n - 1
    
    while p1 >= 0 and p2 >= 0:
        if nums1[p1] >= nums2[p2]:
            nums1[p] = nums1[p1]
            p1 -= 1
        else:
            nums1[p] = nums2[p2]
            p2 -= 1
        p -= 1
    
    # 如果 nums2 还有剩余,复制过去
    # 如果 nums1 还有剩余,不需要动(已经在正确位置)
    nums1[:p2 + 1] = nums2[:p2 + 1]

时间复杂度:O(m + n),每个元素恰好被处理一次。 空间复杂度:O(1),原地操作。

题目三:移除元素 — LeetCode #27

原地移除数组中等于 val 的所有元素,返回新长度。

def removeElement(nums, val):
    """
    双指针法:slow 指向下一个要写入的位置,fast 扫描整个数组
    """
    slow = 0
    for fast in range(len(nums)):
        if nums[fast] != val:
            nums[slow] = nums[fast]
            slow += 1
    return slow

# 优化版本:当要删除的元素很少时
def removeElement_v2(nums, val):
    """
    与末尾元素交换,避免不必要的移动
    适用于要删除的元素很少的情况
    """
    n = len(nums)
    i = 0
    while i < n:
        if nums[i] == val:
            nums[i] = nums[n - 1]
            n -= 1
            # 不增加 i,因为交换来的元素还需要检查
        else:
            i += 1
    return n

两种方法的对比

题目四:螺旋矩阵 — LeetCode #54

给你一个 m×n 矩阵,按照顺时针螺旋顺序返回所有元素。

def spiralOrder(matrix):
    """
    模拟法:维护四个边界,每遍历完一条边就收缩对应边界
    
    top →→→→→→→→→ right
      ↑              ↓
      ↑              ↓
      ↑              ↓
    left ←←←←←←←← bottom
    """
    if not matrix or not matrix[0]:
        return []
    
    result = []
    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1
    
    while top <= bottom and left <= right:
        # 从左到右遍历顶行
        for col in range(left, right + 1):
            result.append(matrix[top][col])
        top += 1
        
        # 从上到下遍历右列
        for row in range(top, bottom + 1):
            result.append(matrix[row][right])
        right -= 1
        
        # 从右到左遍历底行(需要检查是否还有底行)
        if top <= bottom:
            for col in range(right, left - 1, -1):
                result.append(matrix[bottom][col])
            bottom -= 1
        
        # 从下到上遍历左列(需要检查是否还有左列)
        if left <= right:
            for row in range(bottom, top - 1, -1):
                result.append(matrix[row][left])
            left += 1
    
    return result

为什么需要 if top <= bottomif left <= right 的检查?

考虑一个 1×4 的矩阵 [[1,2,3,4]]。在第一轮循环中:

  1. 从左到右遍历顶行 → [1,2,3,4],top 变为 1
  2. 从上到下遍历右列 → 无元素(top > bottom),right 变为 2
  3. 如果不检查 top <= bottom,会从右到左遍历"底行"(其实就是已经遍历过的第一行),导致重复

螺旋矩阵的变体思路

# 巧妙的递归方法:每次取第一行,然后逆时针旋转矩阵
def spiralOrder_recursive(matrix):
    if not matrix:
        return []
    # zip(*matrix[1:]) 转置剩余部分
    # [::-1] 翻转行 = 逆时针旋转90°
    return list(matrix[0]) + spiralOrder_recursive(
        [list(row) for row in zip(*matrix[1:])][::-1]
    )

4.4 什么时候不该用数组

数组不是万能的。以下场景中,其他数据结构是更好的选择:

场景一:频繁头部插入/删除 → deque

from collections import deque

# 滑动窗口、BFS 队列、撤销/重做栈
dq = deque(maxlen=1000)  # 固定大小的环形缓冲区
dq.appendleft(item)  # O(1)
dq.popleft()         # O(1)

deque 底层是一个由固定大小块(每块 64 个元素)组成的双向链表。头部和尾部操作是 O(1),但随机访问是 O(n)(需要定位到正确的块)。在 CPython 实现中,deque 的块大小为 64(BLOCKLEN = 64,定义在 Modules/_collectionsmodule.c 中),每个块是一个紧凑数组,兼顾了缓存友好性和双端操作效率。

场景二:频繁查找/判重 → set 或 dict

# 查找 O(1) vs O(n)
seen = set()
for item in stream:
    if item in seen:  # O(1) 平均
        handle_duplicate(item)
    seen.add(item)

场景三:需要有序且频繁插入/删除 → 平衡二叉树或跳表

Python 标准库没有内置平衡树,但 sortedcontainers.SortedList 提供了类似功能:

from sortedcontainers import SortedList

sl = SortedList()
sl.add(5)       # O(log n)
sl.add(3)
sl.add(7)
sl.remove(5)    # O(log n)
print(sl[0])    # O(log n) — 支持索引访问

场景四:稀疏数据 → 字典或稀疏矩阵

# ❌ 用数组存储稀疏数据
# 99% 的位置是 0,浪费大量内存
grid = [[0] * 10000 for _ in range(10000)]  # 800 MB 内存!
grid[5][3] = 1
grid[9999][9999] = 2

# ✅ 用字典
sparse = {}
sparse[(5, 3)] = 1
sparse[(9999, 9999)] = 2
# 只占用非零元素的内存

# 科学计算用 scipy 稀疏矩阵
from scipy import sparse
mat = sparse.csr_matrix((10000, 10000))
mat[5, 3] = 1

决策树

需要什么操作?
├── 随机访问为主 → 数组 (list / numpy)
├── 头/尾插入删除为主 → deque
├── 查找/判重为主 → set / dict
├── 有序 + 插入删除 → SortedList / 堆
├── 稀疏数据 → dict / sparse matrix
└── 数值计算(批量操作) → numpy / pandas

4.5 数组的工程实践总结

在实际工程中,数组相关的性能问题比任何其他数据结构都多,因为它太常用了,以至于人们忘了思考。以下是几条铁律:

铁律一:预分配比动态增长快

# ❌ 动态增长(多次扩容)
result = []
for i in range(1_000_000):
    result.append(compute(i))

# ✅ 列表推导(CPython 对推导有内部优化,能预估大小)
result = [compute(i) for i in range(1_000_000)]

# ✅ 如果知道确切大小,用 numpy 预分配
result = np.empty(1_000_000, dtype=np.float64)
for i in range(1_000_000):
    result[i] = compute(i)

列表推导不仅语法更简洁,在 CPython 实现中还有专门的字节码优化(LIST_APPEND 指令),比等价的 for 循环 + append 快约 30%。

铁律二:避免在循环中构建字符串

# ❌ O(n²) — 每次 += 创建新字符串(字符串不可变)
s = ""
for word in words:
    s += word + " "

# ✅ O(n) — join 一次性分配
s = " ".join(words)

虽然这是字符串的例子,但本质和数组扩容一样:不可变序列的拼接 = 每次都完整复制。

铁律三:用 numpy 替代 Python 循环做数值计算

# ❌ Python 循环 — 慢
def normalize_python(data):
    mean = sum(data) / len(data)
    std = (sum((x - mean) ** 2 for x in data) / len(data)) ** 0.5
    return [(x - mean) / std for x in data]

# ✅ numpy 向量化 — 快 100 倍
def normalize_numpy(data):
    arr = np.array(data)
    return (arr - arr.mean()) / arr.std()

铁律四:理解你的访问模式,选择正确的内存布局

# 如果你的操作是逐行处理(如图像滤波)
# → 使用 C-order (行优先)
image = np.zeros((height, width, 3), dtype=np.uint8, order='C')

# 如果你的操作是逐列处理(如时间序列的特征列)
# → 使用 F-order (列优先) 或 pandas DataFrame
features = np.zeros((n_samples, n_features), order='F')

本章小结

数组是最简单的数据结构,但"简单"不意味着"简陋"。这一章我们看到了:

  1. 连续内存是数组的灵魂:它赋予了 O(1) 随机访问的能力,更重要的是让 CPU 缓存和预取机制充分发挥作用。

  2. 动态数组是精巧的工程权衡:CPython 选择 1.125 的扩容因子,Java 选择 1.5,C++ 选择 2——每个选择背后都有充分的理由,反映了语言的设计哲学。

  3. 理解硬件才能写出快代码:Cache Line、预取器、内存对齐——这些硬件特性决定了同为 O(n) 的两段代码,实际性能可以差 100 倍。

  4. 知道什么时候不用数组:频繁头部操作用 deque,查找用 set,稀疏数据用字典。选择正确的数据结构,比优化错误的数据结构重要一万倍。

下一章,我们将学习链表——数组的"对立面"。链表牺牲了随机访问和缓存友好性,换来了 O(1) 的任意位置插入删除。理解了数组的优势和局限,你才能真正理解为什么链表是必要的。


参考资料

本章评分
4.6  / 5  (108 评分)

💬 留言讨论