1.1 为什么Python列表跑不快 本节摘要:用可复现的实验对比 Python 列表与 NumPy 数组在计算速度和内存占用上的差距,并从存储结构解释差距的来源:列表存的是对象地址的散装集合,ndarray 是同类型元素紧密排列的连续内存。读完你会知道性能差距不是玄学,而是内存布局的直接后果。 从一个实验说起 空谈"NumPy 快"没有说服力,我们自己量。任务很简单:把一千万个数每个都乘以 2,分别用列表和数组做,计时看结果。 先确认环境。没装 NumPy 的话,在命令行执行一行即可(推荐用 pip 的国内镜像源,几十秒装完): 然后跑对比实验。下面的代码可以在任何一台普通笔记本上复现: 同一台机器上,循环版约 0.9 秒,向量化版约 0.01 秒,差距接近百倍。
本节摘要:用可复现的实验对比 Python 列表与 NumPy 数组在计算速度和内存占用上的差距,并从存储结构解释差距的来源:列表存的是对象地址的散装集合,ndarray 是同类型元素紧密排列的连续内存。读完你会知道性能差距不是玄学,而是内存布局的直接后果。
空谈"NumPy 快"没有说服力,我们自己量。任务很简单:把一千万个数每个都乘以 2,分别用列表和数组做,计时看结果。
先确认环境。没装 NumPy 的话,在命令行执行一行即可(推荐用 pip 的国内镜像源,几十秒装完):
# 安装 NumPy(已装可跳过) pip install numpy # 验证安装 python -c "import numpy; print(numpy.__version__)" # 输出示例:2.1.0
然后跑对比实验。下面的代码可以在任何一台普通笔记本上复现:
import time import numpy as np n = 10_000_000 # 一千万个元素 # 方式一:纯 Python 列表 + 循环 lst = list(range(n)) t0 = time.perf_counter() for i in range(n): lst[i] = lst[i] * 2 t1 = time.perf_counter() print("列表循环耗时:", round(t1 - t0, 3), "秒") # 输出示例:列表循环耗时: 0.912 秒 # 方式二:NumPy 数组 + 向量化 arr = np.arange(n) t0 = time.perf_counter() arr = arr * 2 t1 = time.perf_counter() print("数组向量化耗时:", round(t1 - t0, 3), "秒") # 输出示例:数组向量化耗时: 0.011 秒
同一台机器上,循环版约 0.9 秒,向量化版约 0.01 秒,差距接近百倍。数据量越大、操作越复杂,差距通常还会拉大。
速度差不是"NumPy 偷偷用了什么黑科技",而是两种存储结构的本质区别。
Python 列表里放的不是数字本身,而是指向 Python 对象的指针。每个整数都是一个完整的 PyObject:除了数值,还带着引用计数、类型指针等管理信息。更要命的是,这些对象在堆内存里东一个西一个,列表本身只存一串地址。

内存占用同样是布局决定的。来量一下:
import sys import numpy as np lst = list(range(1_000_000)) arr = np.arange(1_000_000) print("列表总内存约:", sys.getsizeof(lst) / 1024 / 1024, "MB(仅指针数组)") # 输出示例:列表总内存约: 8.09 MB(仅指针数组) print("每个 PyLong 对象:", sys.getsizeof(lst[0]), "字节,百万个约", sys.getsizeof(lst[0]) * len(lst) / 1024 / 1024, "MB") # 输出示例:每个 PyLong 对象: 28 字节,百万个约 26.7 MB print("数组总内存:", arr.nbytes / 1024 / 1024, "MB") # 输出示例:数组总内存: 7.63 MB
列表光指针就 8 MB,加上每个 28 字节的整数对象,实际开销超过 30 MB;数组只要 7.6 MB。三倍多的内存差,加上缓存不友好的跳转访问,就是速度差距的两大来源。
ndarray 不是万能替代。元素类型不统一(混着字符串、字典、None)时,数组只能退化为 object 类型,性能优势荡然无存;数据量只有几十个时,两者差异可忽略,用列表更自然。我的经验线是:元素同质、数量上千,就换数组;否则别折腾。
⚠️ 常见坑:在 object dtype 数组上做向量化运算,会退回逐个调用 Python 层逻辑,可能比列表还慢。判断一个数组是否"真数组",先看 dtype 是不是数值类型。
补一段安装与验证的实操细节。命令行执行安装后,建议顺手确认版本号,因为不同版本的默认 dtype 与随机接口有差异:
import numpy as np import sys print("Python:", sys.version.split()[0]) # Python: 3.11.4 print("NumPy :", np.__version__) # NumPy : 2.1.0 # 官方发行版 Anaconda 已内置 NumPy,无需再装 # 轻量方案用 pip 安装即可,两者选一,别混着反复装
还有一个新手常问的问题:数组从哪来?三种常见入口——从列表转换(本节演示)、从文件读入(第 8 章)、由函数生成(第 3 章)。无论哪种入口,落进 ndarray 之后内存模型完全一致,这正是"学一次布局、通吃所有数据来源"的意义。
第 3 章会系统讲 dtype,这里先埋一个钩子——元素宽度直接影响搬运速度:
import numpy as np import time n = 50_000_000 f64 = np.random.rand(n) # float64,8字节 f32 = f64.astype(np.float32) # float32,4字节,体积减半 t0 = time.perf_counter(); f64.sum(); t1 = time.perf_counter() print("float64 求和:", round(t1 - t0, 3), "秒") # 约 0.05 秒 t0 = time.perf_counter(); f32.sum(); t1 = time.perf_counter() print("float32 求和:", round(t1 - t0, 3), "秒") # 约 0.03 秒,数据量减半带宽压力减半
同样的元素个数,窄类型数组扫一遍的字节数减半,内存带宽受限的操作直接受益。精度允许时选窄类型,是贯穿全册的优化手段之一。
下一节我们把 ndarray 拆开,看它除了数据块本身,还携带了哪三个描述字段——它们是后面所有章节的主角。