1.2 《ndarray解剖课:多维数组的底层实现》
内容介绍
NumPy 的 ndarray
是其核心数据结构,用于高效处理多维数组。在这篇文章中,我们将深入解析 ndarray
的底层实现,探讨其内存结构、维度、数据类型、步长等关键概念,并通过实验验证这些概念的实际应用。
1.2.1 ndarray与Python列表的核心差异
ndarray
和 Python 列表是两种不同的数据结构,它们在内存布局和性能上有显著的差异。下面是 ndarray
和 Python 列表的核心差异对比表:
特性 | ndarray | Python 列表 |
---|---|---|
内存布局 | 连续的内存块(固定大小) | 动态分配的内存(指向对象的指针) |
数据类型 | 统一的数据类型(dtype) | 混合的数据类型(可以包含任意类型的对象) |
访问速度 | 高效的向量化操作 | 较慢的迭代访问 |
修改成本 | 低(视图和副本) | 高(需要重新分配内存) |
支持的运算 | 广泛的数学和科学计算功能 | 有限的列表操作 |
数据对齐 | 自动对齐(通过步长) | 无对齐 |
计算性能 | 高(利用C/C++实现) | 低(纯Python实现) |
文件读写 | 支持 .npy 和 .npz 文件格式 | 不支持二进制文件格式,需要额外的库支持 |
集成性 | 与 Pandas、Scikit-learn 等科学计算库高度集成 | 与标准库高度集成,但与其他科学计算库集成度较低 |
1.2.2 ndarray内存结构3D示意图
为了更好地理解 ndarray
的内存结构,我们绘制一个 3D 示意图,展示 ndarray
如何在内存中存储多维数组。
内存布局示意图(三维数组示例)
内存地址 | 0x1000 | 0x1004 | 0x1008 | 0x100C | 0x1010 | 0x1014 | … |
---|---|---|---|---|---|---|---|
三维索引 | [0,0,0] | [0,0,1] | [0,1,0] | [0,1,1] | [1,0,0] | [1,0,1] | … |
二维展开 | [0,0] | [0,1] | [1,0] | [1,1] | [2,0] | [2,1] | … |
一维展开 | 0 | 1 | 2 | 3 | 4 | 5 | … |
内存布局验证实验
import numpy as np
# 创建基础数组
base_arr = np.arange(6, dtype=np.int32)
print(f"原始数组ID: {id(base_arr)}") # 输出原始数组内存地址
# 创建视图
view_arr = base_arr[::2] # 步长切片创建视图
print(f"视图数组ID: {id(view_arr)}") # 地址不同但共享数据
# 创建副本
copy_arr = base_arr.copy() # 完整内存复制
print(f"副本数组ID: {id(copy_arr)}") # 全新内存地址
# 修改视图影响原始数组
view_arr[0] = 100
print("修改视图后的原始数组:", base_arr) # 输出[100 1 2 3 4 5]
1.2.3 维度(shape)、数据类型(dtype)、步长(strides)的关联关系
ndarray
的三个关键属性是 shape
(维度)、dtype
(数据类型)和 strides
(步长)。它们之间的关系如下:
- shape:表示数组的形状,即每个维度的大小。例如,
shape=(3, 3)
表示一个 3x3 的二维数组。 - dtype:表示数组中每个元素的数据类型。例如,
dtype=np.float64
表示数组中的元素是 64 位浮点数。 - strides:表示在内存中从一个元素移动到下一个元素所需的字节数。例如,在一个
shape=(3, 3)
、dtype=np.float64
的数组中,步长strides=(24, 8)
表示从一个行到下一个行需要移动 24 个字节,从一个列到下一个列需要移动 8 个字节。
步长计算公式推导:
对于形状为
(
d
1
,
d
2
,
.
.
.
,
d
n
)
(d_1,d_2,...,d_n)
(d1,d2,...,dn)的数组,第
k
k
k维步长:
s
t
r
i
d
e
k
=
(
∏
i
=
k
+
1
n
d
i
)
×
i
t
e
m
s
i
z
e
stride_k = \left( \prod_{i=k+1}^{n} d_i \right) \times itemsize
stridek=(i=k+1∏ndi)×itemsize
示例:三维数组(2,3,4),数据类型int32(4字节)
axis0_stride = 3*4*4 = 48 字节
axis1_stride = 4*4 = 16 字节
axis2_stride = 4 字节
1.2.4 不同初始化方式的内存分配对比(zeros vs empty)
NumPy 提供了多种初始化数组的方法,其中 np.zeros
和 np.empty
是两个常用的方法。我们将通过实验对比它们的内存分配方式。
import numpy as np
# 创建一个 3x3 的零数组
zeros_array = np.zeros((3, 3), dtype=np.float64)
print("零数组:")
print(zeros_array)
# 创建一个 3x3 的未初始化数组
empty_array = np.empty((3, 3), dtype=np.float64)
print("未初始化数组:")
print(empty_array)
# 验证两个数组的内存地址
print("零数组的内存地址:", id(zeros_array))
print("未初始化数组的内存地址:", id(empty_array))
# 验证两个数组的相同元素是否共享内存
a = zeros_array[0, 0]
b = empty_array[0, 0]
print("零数组的首元素内存地址:", id(a))
print("未初始化数组的首元素内存地址:", id(b))
注释:
# 导入 NumPy 库,并将其别名为 np
import numpy as np
# 创建一个 3x3 的零数组
# np.zeros 是 NumPy 中用于创建全零数组的函数
# 传入数组的形状和数据类型作为参数
zeros_array = np.zeros((3, 3), dtype=np.float64)
print("零数组:") # 打印零数组
print(zeros_array)
# 创建一个 3x3 的未初始化数组
# np.empty 是 NumPy 中用于创建未初始化数组的函数
# 传入数组的形状和数据类型作为参数
empty_array = np.empty((3, 3), dtype=np.float64)
print("未初始化数组:") # 打印未初始化数组
print(empty_array)
# 验证两个数组的内存地址
# id() 函数用于获取对象的内存地址
print("零数组的内存地址:", id(zeros_array))
print("未初始化数组的内存地址:", id(empty_array))
# 验证两个数组的相同元素是否共享内存
# 获取零数组和未初始化数组的首元素
a = zeros_array[0, 0]
b = empty_array[0, 0]
print("零数组的首元素内存地址:", id(a))
print("未初始化数组的首元素内存地址:", id(b))
1.2.5 数组元属性操作实验(shape修改的边界条件)
ndarray
的 shape
属性可以动态修改,但有一些边界条件需要遵守。我们将通过实验验证这些边界条件。
import numpy as np
# 创建一个 3x3 的数组
array = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=np.float64)
print("原始数组:")
print(array)
# 修改数组的形状为 1x9
array.shape = (1, 9)
print("修改后的数组(1x9):")
print(array)
# 修改数组的形状为 9x1
array.shape = (9, 1)
print("修改后的数组(9x1):")
print(array)
# 尝试修改数组的形状为 4x3
try:
array.shape = (4, 3)
except ValueError as e:
print("尝试修改形状为 4x3 时的错误:", e)
# 尝试修改数组的形状为 3x3x3
try:
array.shape = (3, 3, 3)
except ValueError as e:
print("尝试修改形状为 3x3x3 时的错误:", e)
# 修改数组的形状为 3x3
array.shape = (3, 3)
print("恢复数组形状为 3x3:")
print(array)
注释:
# 导入 NumPy 库,并将其别名为 np
import numpy as np
# 创建一个 3x3 的数组
# np.array 是 NumPy 中用于创建数组的函数
# 传入二维列表,每个子列表代表数组的一行,指定数据类型为 64 位浮点数
array = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=np.float64)
print("原始数组:") # 打印原始数组
print(array)
# 修改数组的形状为 1x9
# .shape 属性用于获取或设置数组的形状
array.shape = (1, 9)
print("修改后的数组(1x9):") # 打印修改后的数组
print(array)
# 修改数组的形状为 9x1
array.shape = (9, 1)
print("修改后的数组(9x1):") # 打印修改后的数组
print(array)
# 尝试修改数组的形状为 4x3
# 这将导致 ValueError,因为数组的总元素数(9)不等于目标形状的总元素数(12)
try:
array.shape = (4, 3)
except ValueError as e:
print("尝试修改形状为 4x3 时的错误:", e)
# 尝试修改数组的形状为 3x3x3
# 这将导致 ValueError,因为数组的总元素数(9)不等于目标形状的总元素数(27)
try:
array.shape = (3, 3, 3)
except ValueError as e:
print("尝试修改形状为 3x3x3 时的错误:", e)
# 修改数组的形状为 3x3
# 成功修改回原形状
array.shape = (3, 3)
print("恢复数组形状为 3x3:") # 打印恢复后的数组
print(array)
总结
通过这篇文章,我们深入解析了 NumPy 的 ndarray
的底层实现,探讨了其内存结构、维度、数据类型、步长等关键概念,并通过实验验证了这些概念的实际应用。希望这些内容能帮助你更好地理解和使用 NumPy。
参考文献或资料
参考资料名称 | 链接 |
---|---|
NumPy 官方文档 | https://numpy.org/doc/ |
Python 官方文档 | https://docs.python.org/3/ |
NumPy 入门指南 | https://numpy.org/devdocs/user/quickstart.html |
NumPy 源码分析 | https://github.com/numpy/numpy |
NumPy 速查表 | https://www.kaggle.com/learn/overview |
NumPy 实战案例 | https://www.tensorflow.org/tutorials/quickstart/beginner |
NumPy 书籍推荐 | https://www.springer.com/gp/book/9781484242452 |
NumPy 视频教程 | https://www.youtube.com/watch?v=QUT1VHiLmmI |
NumPy 交互式学习 | https://colab.research.google.com/ |
Python 内存管理 | https://docs.python.org/3/c-api/memory.html |
C 语言内存管理 | https://en.wikipedia.org/wiki/C_memory_allocation |
数据结构与算法 | https://www.geeksforgeeks.org/ |
深度学习中的数组操作 | https://pytorch.org/tutorials/beginner/basics/tensorqs_tutorial.html |
科学计算库对比 | https://www.tensorflow.org/compare |
高效计算技术 | https://en.wikipedia.org/wiki/High-performance_computing |
编程社区讨论 | https://stackoverflow.com/questions/tagged/numpy |
希望这篇文章能帮助你在 NumPy 的学习和使用中更进一步。如果有任何问题或需要进一步的帮助,欢迎留言讨论!