跳转至

4.4   小结

1.   重点回顾

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

2.   Q & A

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

存储在栈上和堆上的数组都被存储在连续内存空间内,数据操作效率是基本一致的。然而,栈和堆具有各自的特点,从而导致以下不同点。

  1. 分配和释放效率:栈是一块较小的内存,分配由编译器自动完成;而堆内存相对更大,可以在代码中动态分配,更容易碎片化。因此,堆上的分配和释放操作通常比栈上的慢。
  2. 大小限制:栈内存相对较小,堆的大小一般受限于可用内存。因此堆更加适合存储大型数组。
  3. 灵活性:栈上的数组的大小需要在编译时确定,而堆上的数组的大小可以在运行时动态确定。

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

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

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

# 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引

删除节点后,是否需要把 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 ,并且这些数字的内存地址是无须连续的。

C++ STL 里面的 std::list 已经实现了双向链表,但好像一些算法的书上都不怎么直接用这个,是不是有什么局限性呢?

一方面,我们往往更青睐使用数组实现算法,而只有在必要时才使用链表,主要有两个原因。

  • 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 std::list 通常比 std::vector 更占用空间。
  • 缓存不友好:由于数据不是连续存放的,std::list 对缓存的利用率较低。一般情况下,std::vector 的性能会更好。

另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 stackqueue ,而非链表。

欢迎在评论区留下你的见解、疑惑或建议