ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python数组选型指南:list、array与numpy性能对比与避坑

Python数组选型指南:list、array与numpy性能对比与避坑

很多刚接触 Python 的朋友,第一次听到“数组”这个词,脑子里浮现的往往是 C 语言里那种定长、连续内存、类型固定的结构,结果一上手 Python 就懵了:一会儿是list,一会儿是array,还有numpy.array、tuple、bytearray,到底哪个才算“数组”?我在带新人的时候被问过无数次这个问题,也见过不少人因为选错了容器,程序跑得慢得离谱,或者莫名其妙踩了类型混用的坑。这篇内容就把 Python 里“数组”这件事从头到尾捋一遍,从最常用的列表讲起,到标准库的array模块,再到数值计算场景下的numpy数组,把定义方式、使用技巧、性能差异和常见坑都摊开说清楚。不管你是刚学 Python 想搞明白基础容器,还是已经写过一些代码但想弄清楚“什么时候该用哪个”的进阶读者,看完都能直接拿去用。

1. 先搞清楚 Python 里“数组”到底指什么

1.1 为什么 Python 没有 C 那种原生数组

Python 是一门动态类型语言,变量本质上是对对象的引用,容器里存的也是引用而不是值本身。这个设计决定了它没法像 C 那样在编译期就确定一块连续内存、规定每个元素占几个字节。所以严格意义上,Python 内置类型里并没有 C 语言意义上的“数组”。我们平时口语说的“Python 数组”,其实是一个泛指,涵盖了列表、array模块、numpy数组等好几种东西。

这个背景很重要,因为它直接解释了后面所有的选型逻辑。列表灵活但每个元素都是独立对象,有额外的对象头开销;array模块牺牲了灵活性换来紧凑存储;numpy数组则是在紧凑存储的基础上加上了向量化运算能力。你选哪个,取决于你要的是“灵活”还是“省内存”还是“算得快”。

我一般会跟新人这么打比方:列表像是一个可以装任何东西的收纳箱,每个格子都能塞不同形状的物品;array像是一排规格统一的抽屉,只能放同一种规格的东西,但排列得特别紧凑;numpy数组则像是一整块集成电路,不光排列紧凑,还能整块一起做运算。理解了这个类比,后面的选择就不难了。

1.2 四种常见“数组”容器速览

在动手写代码之前,先把这几个候选摆出来对比一下,心里有个谱。

容器类型是否内置元素类型要求内存效率运算能力典型场景
list内置任意类型混用较低(对象引用+对象头)需手写循环通用数据组织、增删频繁
tuple内置任意类型混用较低需手写循环不可变数据、函数返回多值
array.array标准库单一数值类型高(连续存储)需手写循环大量同类型数值、省内存
numpy.ndarray第三方单一类型(可多维)高向量化运算科学计算、矩阵运算、数据分析

这张表建议先记住结论:日常写业务逻辑用list,需要不可变用tuple,纯粹存大量数值又想省内存用array,要做数值计算用numpy。下面逐个展开。

1.3 一个容易混淆的点:列表不是数组

很多人把list直接叫数组,这在口语里没问题,但在讨论性能和内存时一定要区分开。列表的每个元素都是一个 PyObject 指针,指向堆上的对象。也就是说,一个存了 100 万个整数的列表,实际占用的内存远不止 100 万个整数本身,还包括 100 万个指针(每个 8 字节)加上每个整数对象自身的开销(小整数有缓存,大整数则是独立对象)。

而array.array('i', ...)存 100 万个 32 位整数,就是实打实的约 4MB 连续内存,没有额外对象开销。这个差异在数据量大的时候非常明显,我实测过一个存 500 万个整数的场景,列表占用接近 200MB,而array只用了约 20MB,差了将近十倍。所以如果你的场景是“大量同类型数值”,别犹豫,往array或numpy走。

2. 列表的定义与日常使用要点

2.1 五种定义方式与各自适用场景

列表的定义方式看着简单,但不同写法背后其实有适用场景的差异,用错了容易出问题。

# 方式一:字面量,最常用 nums = [1, 2, 3, 4, 5] # 方式二:空列表,后续追加 result = [] result.append(10) # 方式三:list() 构造函数,从可迭代对象转换 chars = list("hello") # ['h', 'e', 'l', 'l', 'o'] ranges = list(range(5)) # [0, 1, 2, 3, 4] # 方式四:列表推导式,带条件生成 squares = [x * x for x in range(10) if x % 2 == 0] # 方式五:乘法快速初始化(注意陷阱,见 2.2) grid = [0] * 10

字面量方式最直观,适合元素已知且数量不多的情况。list()构造函数适合从其他可迭代对象转换,比如字符串、range、生成器。列表推导式是 Python 的招牌特性,既简洁又通常比手写循环快,因为推导式的循环在 C 层面执行。乘法初始化适合快速造一个固定长度的占位列表,但这里有个大坑,下一节专门说。

我个人的习惯是:能用推导式就用推导式,可读性和性能都占优;需要动态追加时先建空列表再append;只有在明确知道所有元素时才用字面量。这个习惯坚持下来,代码会干净很多。

2.2 乘法初始化的经典陷阱

[0] * 10这种写法对不可变元素(数字、字符串、元组)是安全的,但对可变元素就是灾难。看这段代码:

# 错误示范:二维列表不能用乘法初始化 bad = [[0] * 3] * 3 bad[0][0] = 1 print(bad) # 输出:[[1, 0, 0], [1, 0, 0], [1, 0, 0]]

你以为创建了三行独立的列表,实际上三行指向的是同一个列表对象。改一个,全变。这个坑我见过太多人踩,尤其是写矩阵、棋盘、表格类逻辑的时候。

正确做法是用推导式,每轮循环都新建一个独立对象:

# 正确示范 good = [[0] * 3 for _ in range(3)] good[0][0] = 1 print(good) # 输出:[[1, 0, 0], [0, 0, 0], [0, 0, 0]]

注意:判断一个初始化写法是否安全,就看“被复制的元素是不是可变对象”。不可变对象随便乘,可变对象必须用推导式逐个创建。

2.3 增删改查的高频操作与性能直觉

列表的常用操作不难,但要知道哪些快哪些慢,写代码时才有性能直觉。

nums = [3, 1, 4, 1, 5] nums.append(9) # 尾部追加,O(1) 均摊 nums.insert(0, 0) # 头部插入,O(n),要挪动所有元素 nums.pop() # 尾部弹出,O(1) nums.pop(0) # 头部弹出,O(n) nums.remove(1) # 按值删除第一个匹配项,O(n) nums.index(4) # 查找索引,O(n) nums[2] # 随机访问,O(1)

关键结论:尾部操作快,头部和中间操作慢。如果你需要频繁在两端操作,用collections.deque而不是列表。如果频繁按值查找,考虑换成set或dict。这些替换在数据量大时能带来数量级的提升。

我踩过的一个真实坑:早期写一个任务队列,用列表的pop(0)取任务,数据量小的时候没感觉,任务堆到几万个之后明显卡顿。后来换成deque的popleft(),问题立刻消失。所以别小看这些复杂度差异,量变会引起质变。

2.4 切片:列表最被低估的能力

切片是列表里性价比最高的特性之一,用好了能省掉大量循环代码。

data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] data[2:5] # [2, 3, 4],左闭右开 data[:3] # [0, 1, 2],省略起点 data[7:] # [7, 8, 9],省略终点 data[::2] # [0, 2, 4, 6, 8],步长为 2 data[::-1] # 反转整个列表 data[8:2:-2] # [8, 6, 4],负步长倒着取

切片返回的是新列表,原列表不受影响,这一点在需要“复制一份再改”的场景特别有用。但要注意浅拷贝的问题:如果列表元素是可变对象,切片出来的新列表里,元素还是同一批对象引用,改元素内部状态会互相影响。需要深拷贝时用copy.deepcopy()。

另一个实用技巧是用切片做原地替换,比如data[1:3] = [100, 200, 300],这会把索引 1 到 2 的元素替换成三个新元素,列表长度随之变化。这个操作在批量修改时很顺手,但长度变化容易算错,建议改完立刻打印确认。

3. array 模块:省内存的紧凑数组

3.1 什么时候该从列表切换到 array

列表用着舒服,但内存开销大。当你满足下面几个条件时,就该考虑array模块了:元素全是同一种数值类型(整数或浮点数)、数据量比较大(几万以上)、不需要存储字符串或自定义对象、对内存敏感。

array的核心价值就是紧凑。它用类型码(typecode)声明元素类型,底层是连续的 C 数组,没有 Python 对象头开销。常用类型码如下:

类型码C 类型字节数取值范围/说明
'b'signed char1-128 到 127
'B'unsigned char10 到 255
'h'signed short2-32768 到 32767
'i'signed int2 或 4平台相关,通常 4 字节
'l'signed long4 或 8平台相关
'f'float4单精度浮点
'd'double8双精度浮点

选类型码的原则是“够用就好”。比如存 0 到 255 的像素值,用'B'就比'i'省四分之三内存。存金额这种需要精度的,别用浮点类型码,老老实实用整数存“分”,或者用decimal。

3.2 定义与基本操作实操

from array import array # 定义:类型码 + 初始数据 a = array('i', [1, 2, 3, 4, 5]) b = array('d', [1.5, 2.5, 3.5]) c = array('B', [255, 0, 128]) # 从字节流构造(处理二进制数据时常用) raw = bytes([1, 2, 3, 4]) d = array('B') d.frombytes(raw) # 基本操作和列表类似 a.append(6) a.extend([7, 8]) print(a[0], a[-1]) print(len(a)) # 转回列表 as_list = a.tolist() # 转成字节流(写文件、网络传输时用) as_bytes = a.tobytes()

array的接口刻意做得和列表很像,所以从列表迁移过来成本很低。但有两个限制要记住:一是不能存混合类型,array('i', [1, 'a'])会直接抛TypeError;二是不能存None,需要空值时得用哨兵值(比如 -1 或特定大数)代替。

3.3 类型码选错会怎样:一个真实排查案例

类型码选错是array使用中最隐蔽的问题,因为它在赋值时才报错,而且报错信息不一定直观。

from array import array a = array('b', [100, 120]) try: a.append(200) # 'b' 最大 127,溢出 except OverflowError as e: print(f"溢出错误:{e}")

上面这段会抛OverflowError。更麻烦的是浮点类型码存整数时的精度问题:array('f', [0.1])存的是单精度,取出来可能是0.10000000149011612。如果你的业务对精度敏感(比如金额、坐标),一定要用'd'双精度,或者干脆别用array。

我遇到过一个案例:某同学用'f'存传感器读数,累加几千次之后误差肉眼可见,排查了半天以为是算法问题,最后发现是单精度浮点的累积误差。换成'd'之后立刻正常。所以类型码这件事,宁可多花一分钟确认,也别事后返工。

3.4 array 与列表互转的性能考量

tolist()和array(typecode, list)这两个转换操作都是 O(n),会创建新对象。频繁互转会让array省内存的优势荡然无存,因为转换过程中两份数据同时存在。

正确的用法是:在数据入口处一次性转成array,中间处理全程用array,出口处再转回需要的格式。别在循环里反复转。如果确实需要频繁在两种表示间切换,说明你的场景可能不适合array,老老实实用列表或者上numpy。

4. numpy 数组:数值计算的主力

4.1 从列表到 ndarray 的思维转变

numpy数组和列表最大的区别不是存储方式,而是运算模型。列表做加法是拼接,numpy数组做加法是逐元素相加。这个差异决定了写法的根本不同。

import numpy as np # 列表:加法是拼接 lst = [1, 2, 3] + [4, 5, 6] # [1, 2, 3, 4, 5, 6] # numpy:加法是逐元素 arr = np.array([1, 2, 3]) + np.array([4, 5, 6]) # [5, 7, 9] # 标量广播 arr2 = np.array([1, 2, 3]) * 10 # [10, 20, 30]

这个“向量化”思维是用好numpy的关键。一旦你习惯了“整个数组一起算”,就会发现以前写的很多 for 循环都是多余的,而且慢。numpy的运算在 C 层面执行,比 Python 循环快一到两个数量级是常态。

4.2 创建数组的常用方法

import numpy as np # 从列表创建 a = np.array([1, 2, 3, 4]) # 指定数据类型 b = np.array([1, 2, 3], dtype=np.float64) # 全零、全一 c = np.zeros((3, 4)) # 3 行 4 列,浮点 d = np.ones((2, 3), dtype=int) # 等差序列 e = np.arange(0, 10, 2) # [0, 2, 4, 6, 8] f = np.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1.0],含终点 # 随机数组 g = np.random.rand(3, 3) # 0 到 1 均匀分布 h = np.random.randn(100) # 标准正态分布 # 单位矩阵 i = np.eye(3)

arange和linspace的区别值得单独说:arange按步长生成,不含终点(除非步长算得刚好);linspace按数量生成,默认含终点。做绘图、采样时用linspace更省心,因为你能精确控制点数。

4.3 形状操作:reshape、转置与广播

形状操作是numpy的高频需求,也是新手最容易晕的地方。

a = np.arange(12) # [0, 1, ..., 11] b = a.reshape(3, 4) # 3 行 4 列 c = a.reshape(4, -1) # -1 表示自动推算,这里是 3 列 d = b.T # 转置,4 行 3 列 e = b.flatten() # 拉平成一维,返回副本 f = b.ravel() # 拉平,尽量返回视图

reshape要求元素总数匹配,否则报错。-1是个好用的占位符,让numpy自己算那一维。flatten和ravel的区别在于是否复制:flatten一定复制,ravel尽量返回视图。改视图会影响原数组,这个细节在调试时经常让人困惑,建议不确定时用flatten求稳。

广播机制是numpy的精髓,简单说就是“形状不同的数组在运算时自动对齐”。规则是从尾部维度开始比较,要么相等,要么其中一个是 1,要么其中一个缺失。比如(3, 4)和(4,)可以相加,后者被广播成(3, 4)。理解广播能让你少写很多显式循环,但也要小心意外广播导致的形状膨胀,算完记得检查结果形状。

4.4 索引与切片的进阶用法

numpy的索引比列表强大得多,布尔索引和花式索引是两个杀手锏。

a = np.array([10, 20, 30, 40, 50]) # 布尔索引:按条件筛选 mask = a > 25 print(a[mask]) # [30, 40, 50] # 花式索引:按索引数组取值 idx = [0, 2, 4] print(a[idx]) # [10, 30, 50] # 二维数组的行列切片 b = np.arange(12).reshape(3, 4) print(b[1, :]) # 第二行 print(b[:, 2]) # 第三列 print(b[0:2, 1:3]) # 子矩阵 # 条件赋值 a[a > 25] = 0 print(a) # [10, 20, 0, 0, 0]

布尔索引在数据清洗里用得极多,比如“把异常值置零”“筛选满足条件的样本”。花式索引适合按任意顺序取子集。这两个特性配合起来,很多原本要写循环的逻辑都能一行搞定。

注意:numpy的切片返回的是视图而不是副本,改切片会影响原数组。需要独立副本时用.copy()。这个和列表的行为不同,从列表转过来的人特别容易在这里翻车。

5. 选型决策与性能实测对比

5.1 一张决策流程图(文字版)

选哪个容器,按下面的顺序问自己:

  1. 元素类型是否统一且是数值?否,用list。
  2. 是否需要做数学运算(加减乘除、矩阵、统计)?是,用numpy。
  3. 数据量是否很大且内存敏感?是,用array。
  4. 是否需要频繁增删?是,用list或deque。
  5. 数据是否不可变?是,用tuple。

大部分业务代码停在第一步就够了,list能解决八成问题。真正需要array或numpy的场景,往往是数据量上来了、或者要做数值计算了,这时候再切换也不迟。别为了“显得专业”一上来就numpy,小数据量下numpy的创建开销反而更大。

5.2 内存与速度的实测数据

我在一台普通开发机上做过一组对比,存 100 万个整数,结果如下(数值为大致量级,不同环境会有浮动):

操作listarray('i')numpy int64
内存占用约 36MB约 4MB约 8MB
创建耗时约 60ms约 25ms约 5ms
全量求和约 15ms约 12ms约 1ms
逐元素乘 2约 80ms约 70ms约 2ms

结论很清晰:内存上array最省,速度上numpy碾压。列表在两者之间都不占优,但胜在灵活。这组数据也解释了为什么数据处理场景几乎清一色用numpy——向量化运算带来的差距是数量级的,不是靠优化 Python 代码能追上的。

5.3 混合场景的折中策略

实际项目里经常遇到“既要灵活又要性能”的情况,我的经验是分层处理:数据入口和出口用列表(方便对接各种接口),中间的计算密集段转成numpy或array,算完再转回来。转换成本是一次性的,换来的是中间段的性能提升,整体算下来划算。

另一个折中是“按需转换”:只有当数据量超过某个阈值(比如一万)时才切换到紧凑容器,小数据量直接用列表。这样既避免了小数据量的转换开销,又保证大数据量不拖垮性能。这个阈值没有标准答案,得根据你的实际压测结果定。

6. 常见问题与排查实录

6.1 类型混用导致的报错速查

现象可能原因解决方向
TypeError: an integer is requiredarray类型码与数据不匹配检查 typecode 和元素类型
OverflowError数值超出类型码范围换更大的类型码,如'b'换'i'
ValueError: cannot reshape元素总数与目标形状不符用-1让 numpy 推算,或核对总数
修改切片后原数组变了numpy 切片是视图需要独立副本时用.copy()
二维列表改一个全变乘法初始化共享引用改用推导式逐行创建
浮点结果有微小误差单精度或浮点累积误差换'd'或float64,金额用整数分

这张表建议收藏,遇到报错先对号入座,能省不少排查时间。

6.2 三个我踩过的坑

第一个坑是numpy的整数溢出。numpy默认的整数类型在 64 位平台上是int64,但如果显式指定了int8或int32,运算结果超出范围会静默回绕,不报错。我曾经用int32存时间戳(秒级),跑到 2038 年附近就会溢出,虽然当时没到那个点,但这是个定时炸弹。后来统一改成int64,踏实了。

第二个坑是array的frombytes字节序问题。从文件或网络读来的二进制数据,如果字节序和本机不一致,frombytes出来的数值全是错的,而且不报错。排查时用sys.byteorder确认本机字节序,必要时用byteswap()转换。这个坑在处理跨平台数据时特别常见。

第三个坑是列表推导式里的变量作用域。Python 3 里推导式的循环变量不会泄漏到外层,这本来是好事,但如果你在推导式里引用了外部同名变量,容易看花眼。我的习惯是推导式里的临时变量用短名字(如x、i),外部变量用有意义的长名字,从命名上就区分开。

6.3 调试小技巧:打印形状和类型

处理数组类问题时,我养成了一个习惯:在每个关键步骤后打印形状和类型。

def debug_arr(name, arr): print(f"{name}: type={type(arr).__name__}, " f"shape={getattr(arr, 'shape', 'N/A')}, " f"dtype={getattr(arr, 'dtype', 'N/A')}, " f"len={len(arr)}")

这个函数在排查形状不匹配、类型意外转换时特别管用。很多numpy的报错信息只说“形状不匹配”,但不告诉你具体是哪一步变的,自己打印一下立刻定位。养成这个习惯之后,我调试数组问题的时间至少缩短了一半。

7. 一些让代码更稳的实操习惯

写数组相关代码,有几个习惯坚持下来能少踩很多坑。第一,初始化可变容器一律用推导式,不用乘法,这条前面说过,但值得再强调一遍,因为它是最常见的低级错误来源。第二,numpy里凡是需要独立副本的地方,显式写.copy(),别依赖“应该不会改到原数组”的侥幸心理。第三,类型码和dtype在定义时就写明确,别依赖默认推断,默认值在不同平台、不同版本上可能有差异。

还有一个习惯是“先小数据验证再上量”。数组类的问题往往在小数据量下不暴露,一上量就崩。我的做法是先用几十条数据跑通逻辑,确认形状、类型、边界都对,再换成真实数据量跑。这样即使出问题,排查范围也小得多。

最后分享一个我常用的技巧:需要频繁在列表和numpy之间转换时,写一对辅助函数封装转换逻辑,把类型检查和形状校验放进去。这样业务代码里只调辅助函数,转换细节集中管理,改起来也方便。这个封装看着多写了几行,但在多人协作的项目里,能避免大量“这个变量到底是列表还是数组”的沟通成本。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进