数据结构笔记浅记(九)存储设备

物理结构在很大程度上决定了程序对内存和缓存的使用效率,进而影响算法程序的整体性能。

由于存储数据的需要长久保存,并且内存的价格比硬盘贵太多,因此内存无法取代硬盘。

缓存的大容量和高速度难以兼得。随着 L1、L2、L3 缓存的容量逐步增大,其物理尺寸会变大,与 CPU 核心之间的物理距离会变远,从而导致数据传输时间增加,元素访问延迟变高。

在程序运行时,数据会从硬盘中被读取到内存中,供 CPU 计算使用。缓存可以看作 CPU 的 一部分,它通过智能地从内存加载数据,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少 对较慢的内存的依赖。

在内存空间利用方面,数组和链表各自具有优势和局限性

         一方面,内存是有限的,且同一块内存不能被多个程序共享,因此我们希望数据结构能够尽可能高效地利用 空间。数组的元素紧密排列,不需要额外的空间来存储链表节点间的引用(指针),因此空间效率更高。然而, 数组需要一次性分配足够的连续内存空间,这可能导致内存浪费,数组扩容也需要额外的时间和空间成本。 相比之下,链表以“节点”为单位进行动态内存分配和回收,提供了更大的灵活性。

         另一方面,在程序运行时,随着反复申请与释放内存,空闲内存的碎片化程度会越来越高,从而导致内存的 利用效率降低。数组由于其连续的存储方式,相对不容易导致内存碎片化。相反,链表的元素是分散存储的, 在频繁的插入与删除操作中,更容易导致内存碎片化。

 

缓存会采取以下数据加载机制

        ‧ 缓存行:缓存不是单个字节地存储与加载数据,而是以缓存行为单位。相比於单个字节的传输,缓存行 的传输形式更加高效。

        ‧ 预取机制:处理器会尝试预测数据访问模式(例如顺序访问、固定步长跳跃访问等),并根据特定模式 将数据加载至缓存之中,从而提升命中率。 ‧ 空间局部性:如果一个数据被访问,那么它附近的数据可能近期也会被访问。因此,缓存在加载某一数 据时,也会加载其附近的数据,以提高命中率。

         ‧ 时间局部性:如果一个数据被访问,那么它在不久的将来很可能再次被访问。缓存利用这一原理,通过 保留最近访问过的数据来提高命中率。

实际上,数组和链表对缓存的利用效率是不同的,主要体现在以下几个方面。

        ‧ 占用空间:链表元素比数组元素占用空间更多,导致缓存中容纳的有效数据量更少。

        ‧ 缓存行:链表数据分散在内存各处,而缓存是“按行加载”的,因此加载到无效数据的比例更高。

        ‧ 预取机制:数组比链表的数据访问模式更具“可预测性”,即系统更容易猜出即将被加载的数据。

        ‧ 空间局部性:数组被存储在集中的内存空间中,因此被加载数据附近的数据更有可能即将被访问。

总体而言,数组具有更高的缓存命中率,因此它在操作效率上通常优于链表。这使得在解决算法问题时,基 于数组实现的数据结构往往更受欢迎。 需要注意的是,高缓存效率并不意味着数组在所有情况下都优于链表。实际应用中选择哪种数据结构,应根据具体需求来决定

 

 

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

         存储在栈上和堆上的数组都被存储在连续内存空间内,数据操作效率基本一致。然而,栈和堆具有各自的特 点,从而导致以下不同点。 1. 分配和释放效率:栈是一块较小的内存,分配由编译器自动完成;而堆内存相对更大,可以在代码中动 态分配,更容易碎片化。因此,堆上的分配和释放操作通常比栈上的慢。 2. 大小限制:栈内存相对较小,堆的大小一般受限于可用内存。因此堆更加适合存储大型数组。 3. 灵活性:栈上的数组的大小需要在编译时确定,而堆上的数组的大小可以在运行时动态确定。

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

        链表由节点组成,节点之间通过引用(指针)连接,各个节点可以存储不同类型的数据,例如 int、double、 string、object 等。 相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,数组同时包 含 int 和 long 两种类型,单个元素分别占用 4 字节 和 8 字节,此时就不能用以下公式计算偏移量了,因为 数组中包含了两种“元素长度”。 # 元素内存地址 = 数组内存地址(首元素内存地址) + 元素长度 * 元素索引

Q:删除节点后,是否需要把 P.next 设为 None 呢?

         不修改 P.next 也可以。从该链表的角度看,从头节点遍历到尾节点已经不会遇到 P 了。这意味着节点 P 已经 从链表中删除了,此时节点 P 指向哪里都不会对该链表产生影响。 从垃圾回收的角度看,对于 Java、Python、Go 等拥有自动垃圾回收机制的语言来说,节点 P 是否被回收取 决于是否仍存在指向它的引用,而不是 P.next 的值。在 C 和 C++ 等语言中,我们需要手动释放节点内存。

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

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

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

         该示意图只是定性表示,定量表示需要根据具体情况进行分析。 ‧ 不同类型的节点值占用的空间是不同的,比如 int、long、double 和实例对象等。 ‧ 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。

Q:在列表末尾添加元素是否时时刻刻都为 𝑂(1) ?

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

Q:“列表的出现极大地提高了数组的实用性,但可能导致部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?

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

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

        假如把列表元素换成链表节点 n = [n1, n2, n3, n4, n5] ,通常情况下这 5 个节点对象也分散存储在内存 各处。然而,给定一个列表索引,我们仍然可以在 𝑂(1) 时间内获取节点内存地址,从而访问到对应的节点。 这是因为数组中存储的是节点的引用,而非节点本身。 与许多语言不同,Python 中的数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因 此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址无须连续。

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

         一方面,我们往往更青睐使用数组实现算法,而只在必要时才使用链表,主要有两个原因。 ‧ 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 std::list 通常比 std::vector 更占用空间。 ‧ 缓存不友好:由于数据不是连续存放的,因此 std::list 对缓存的利用率较低。一般情况下,std::vector 的性能会更好。 另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 stack 和 queue , 而非链表。

Q:初始化列表 res = [0] * self.size() 操作,会导致 res 的每个元素引用相同的地址吗?

        不会。但二维数组会有这个问题,例如初始化二维列表 res = [[0] * self.size()] ,则多次引用了同一个 列表 [0] 。

Q:在删除节点中,需要断开该节点与其后继节点之间的引用指向吗?

        从数据结构与算法(做题)的角度看,不断开没有关系,只要保证程序的逻辑是正确的就行。从标准库的角度看,断开更加安全、逻辑更加清晰。如果不断开,假设被删除节点未被正常回收,那么它那么它会影响后继节点的内存回收

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章