1.1 为什么Python列表跑不快


文档摘要

1.1 为什么Python列表跑不快 本节摘要:用可复现的实验对比 Python 列表与 NumPy 数组在计算速度和内存占用上的差距,并从存储结构解释差距的来源:列表存的是对象地址的散装集合,ndarray 是同类型元素紧密排列的连续内存。读完你会知道性能差距不是玄学,而是内存布局的直接后果。 从一个实验说起 空谈"NumPy 快"没有说服力,我们自己量。任务很简单:把一千万个数每个都乘以 2,分别用列表和数组做,计时看结果。 先确认环境。没装 NumPy 的话,在命令行执行一行即可(推荐用 pip 的国内镜像源,几十秒装完): 然后跑对比实验。下面的代码可以在任何一台普通笔记本上复现: 同一台机器上,循环版约 0.9 秒,向量化版约 0.01 秒,差距接近百倍。

1.1 为什么Python列表跑不快

本节摘要:用可复现的实验对比 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 秒,数据量减半带宽压力减半

同样的元素个数,窄类型数组扫一遍的字节数减半,内存带宽受限的操作直接受益。精度允许时选窄类型,是贯穿全册的优化手段之一。

本节要点回顾

  • 性能差距可量化:一千万元素乘 2,列表循环约 0.9 秒,数组向量化约 0.01 秒,量级差约百倍
  • 列表存指针:每个元素是完整 PyObject,带引用计数与类型信息,对象散落在堆中
  • 数组存裸值:同类型元素连续排列,缓存友好,底层一条 C 循环扫完
  • 内存差三倍起:百万整数,列表约 35 MB,int64 数组约 7.6 MB
  • 判断标准:同质 + 大量,用数组;异构 + 少量,列表更合适

下一节我们把 ndarray 拆开,看它除了数据块本身,还携带了哪三个描述字段——它们是后面所有章节的主角。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U