跳转至

4.4.   小结

  • 数组和链表是两种基本数据结构,分别代表数据在计算机内存中的连续空间存储和离散空间存储方式。两者的优缺点呈现出互补的特性。
  • 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
  • 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、循环链表、双向链表。
  • 动态数组,又称列表,是基于数组实现的一种数据结构。它保留了数组的优势,同时可以灵活调整长度。列表的出现极大地提高了数组的易用性,但可能导致部分内存空间浪费。
  • 下表总结并对比了数组与链表的各项特性与操作效率。
数组 链表
存储方式 连续内存空间 离散内存空间
数据结构长度 长度不可变 长度可变
内存使用率 占用内存少、缓存局部性好 占用内存多
优势操作 随机访问 插入、删除
访问元素 \(O(1)\) \(O(N)\)
添加元素 \(O(N)\) \(O(1)\)
删除元素 \(O(N)\) \(O(1)\)

缓存局部性

在计算机中,数据读写速度排序是“硬盘 < 内存 < CPU 缓存”。当我们访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。链表则不然,计算机只能挨个地缓存各个节点,这样的多次“搬运”降低了整体效率。

4.4.1.   Q & A

数组存储在栈上和存储在堆上,对时间效率和空间效率是否有影响?

栈内存分配由编译器自动完成,而堆内存由程序员在代码中分配(注意,这里的栈和堆和数据结构中的栈和堆不是同一概念)。

  1. 栈不灵活,分配的内存大小不可更改;堆相对灵活,可以动态分配内存;
  2. 栈是一块比较小的内存,容易出现内存不足;堆内存很大,但是由于是动态分配,容易碎片化,管理堆内存的难度更大、成本更高;
  3. 访问栈比访问堆更快,因为栈内存较小、对缓存友好,堆帧分散在很大的空间内,会出现更多的缓存未命中;

为什么数组会强调要求相同类型的元素,而在链表中却没有强调同类型呢?

链表由结点组成,结点之间通过引用(指针)连接,各个结点可以存储不同类型的数据,例如 int, double, string, object 等。

相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,如果数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes ,那么此时就不能用以下公式计算偏移量了,因为数组中包含了两种 elementLength

// 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引
elementAddr = firtstElementAddr + elementLength * elementIndex

删除节点后,是否需要把 P.next 设为 \(\text{None}\) 呢?

不修改 P.next 也可以。从该链表的角度看,从头结点遍历到尾结点已经遇不到 P 了。这意味着结点 P 已经从链表中删除了,此时结点 P 指向哪里都不会对这条链表产生影响了。

从垃圾回收的角度看,对于 Java, Python, Go 等拥有自动垃圾回收的语言来说,节点 P 是否被回收取决于是否有仍存在指向它的引用,而不是 P.next 的值。在 C, C++ 等语言中,我们需要手动释放节点内存。

在链表中插入和删除操作的时间复杂度是 \(O(1)\) 。但是增删之前都需要 \(O(n)\) 查找元素,那为什么时间复杂度不是 \(O(n)\) 呢?

如果是先查找元素、再删除元素,确实是 \(O(n)\) 。然而,链表的 \(O(1)\) 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头结点、尾结点,每次插入与删除操作都是 \(O(1)\)

图片“链表定义与存储方式”中,浅蓝色的存储结点指针是占用一块内存地址吗?还是和结点值各占一半呢?

文中只是一个示意图,只是定性表示。定量的话需要根据具体情况分析:

  • 不同类型的结点值占用的空间是不同的,比如 int, long, double, 或者是类的实例等等。
  • 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。

在列表末尾添加元素是否时时刻刻都为 \(O(1)\)

如果添加元素时超出列表长度,则需要先扩容列表再添加。系统会申请一块新的内存,并将原列表的所有元素搬运过去,这时候时间复杂度就会是 \(O(n)\)

“列表的出现大大提升了数组的实用性,但副作用是会造成部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?

这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多。另一方面,为了防止频繁扩容,扩容一般都会乘以一个系数,比如 \(\times 1.5\) 。这样一来,也会出现很多空位,我们通常不能完全填满它们。

在 Python 中初始化 n = [1, 2, 3] 后,这 3 个元素的地址是相连的,但是初始化 m = [2, 1, 3] 会发现它们每个元素的 id 并不是连续的,而是分别跟 n 中的相同。这些元素地址不连续,那么 m 还是数组吗?

假如把列表元素换成链表节点 n = [n1, n2, n3, n4, n5] ,通常情况下这五个节点对象也是被分散存储在内存各处的。然而,给定一个列表索引,我们仍然可以在 \(O(1)\) 时间内获取到节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。

与许多语言不同的是,在 Python 中数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址是无需连续的。

评论