十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Hello 算法:内存与缓存视角下的数据结构选型——为什么数组的缓存效率高于链表

Hello 算法:内存与缓存视角下的数据结构选型——为什么数组的缓存效率高于链表 Hello 算法内存与缓存视角下的数据结构选型——为什么数组的缓存效率高于链表【免费下载链接】hello-algo《Hello 算法》动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語提供 Python, Java, C, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo本文基于《Hello 算法》hello-algo数组和链表一章中的《内存与缓存》一节展开系统讲解硬盘、内存、缓存三级存储设备的特点与协作关系并结合仓库中 Python 与 C 语言的数组、链表源码实现深入分析两种基础数据结构的内存利用率与缓存命中率差异。读完后你将能够理解物理存储结构如何影响程序性能并在实际算法题与工程实现中做出数组与链表之间的合理选型。一、计算机存储设备金字塔式的三层结构在《Hello 算法》的叙述中数组和链表分别代表了连续存储与分散存储两种物理结构。而物理结构在很大程度上决定了程序对内存和缓存的使用效率进而影响算法程序的整体性能。要理解这一点首先要认识计算机的三类存储设备硬盘hard disk、内存random-access memory, RAM、缓存cache memory。原文档给出的三者特性对比如下硬盘内存缓存用途长期存储数据包括操作系统、程序、文件等临时存储当前运行的程序和正在处理的数据存储经常访问的数据和指令减少 CPU 访问内存的次数易失性断电后数据不会丢失断电后数据会丢失断电后数据会丢失容量较大TB 级别较小GB 级别非常小MB 级别速度较慢几百到几千 MB/s较快几十 GB/s非常快几十到几百 GB/s价格人民币较便宜几毛到几元 / GB较贵几十到几百元 / GB非常贵随 CPU 打包计价可以把整个存储系统想象为上图所示的金字塔越靠近顶端速度越快、容量越小、成本越高。这种层级设计并非偶然而是深思熟虑的权衡结果原文档点出了两个关键约束硬盘难以被内存取代一方面内存易失断电后数据丢失不适合长期存储另一方面内存的成本是硬盘的几十倍难以在消费者市场普及。缓存的容量与速度难以兼得随着 L1、L2、L3 缓存容量逐步增大其物理尺寸变大与 CPU 核心的物理距离变远数据传输时间与访问延迟随之增加。在当前技术下多层级缓存结构是容量、速度、成本三者之间的平衡点。在程序运行时数据从硬盘读取到内存供 CPU 计算使用缓存可以看作 CPU 的一部分它通过智能地从内存加载数据为 CPU 提供高速读取能力从而减少对较慢内存的依赖显著提升程序执行效率。三者分工明确硬盘长期存储大量数据内存临时存储正在处理的数据缓存存储经常访问的数据和指令。二、数据结构的内存效率数组 vs 链表原文档指出在内存空间利用方面数组和链表各有优势与局限可以从两个维度来看。1. 空间占用与分配方式内存是有限的且同一块内存不能被多个程序共享因此我们希望数据结构尽可能高效地利用空间数组元素紧密排列不需要额外的空间存储节点间引用指针空间效率更高。但数组需要一次性分配足够的连续内存空间可能导致内存浪费且扩容需要额外的时间和空间成本。链表以节点为单位动态分配和回收内存提供了更大的灵活性。这一点可以在仓库源码中直接印证。Python 版的链表节点定义见 list_node.pyclass ListNode: 链表节点类 def __init__(self, val: int): self.val: int val # 节点值 self.next: ListNode | None None # 后继节点引用每个节点除数值val外还维护一个next引用在 C 语言版中这一引用对应链表节点结构体的指针字段。也就是说链表元素比数组元素天然多占一份指针/引用的空间。再看扩容成本。Python 版的 array.py 中extend函数模拟了长度不可变数组的扩展过程def extend(nums: list[int], enlarge: int) - list[int]: 扩展数组长度 # 初始化一个扩展长度后的数组 res [0] * (len(nums) enlarge) # 将原数组中的所有元素复制到新数组 for i in range(len(nums)): res[i] nums[i] # 返回扩展后的新数组 return res需要为扩展后的新长度申请一整块连续空间并把原元素逐个复制过去这就是原文档所说扩容需要额外的时间和空间成本的具体含义。C 语言版的 array.c 中extend函数则更直观地展示了这一过程/* 扩展数组长度 */ int *extend(int *nums, int size, int enlarge) { // 初始化一个扩展长度后的数组 int *res (int *)malloc(sizeof(int) * (size enlarge)); // 将原数组中的所有元素复制到新数组 for (int i 0; i size; i) { res[i] nums[i]; } ... return res; }malloc一次申请更大的连续块、逐元素拷贝——时间与空间开销一目了然。而链表侧C 语言版 linked_list.c 的插入与删除操作以单个节点为单位申请、释放内存/* 在链表的节点 n0 之后插入节点 P */ void insert(ListNode *n0, ListNode *P) { ListNode *n1 n0-next; P-next n1; n0-next P; } /* 删除链表的节点 n0 之后的首个节点 */ void removeItem(ListNode *n0) { if (!n0-next) return; // n0 - P - n1 ListNode *P n0-next; ListNode *n1 P-next; n0-next n1; // 释放内存 free(P); }每次newListNode/free都是对堆上单个节点的独立分配与回收这正是链表以节点为单位动态分配的实现方式也是下一节讨论内存碎片化的根源。2. 内存碎片化另一个维度是随着反复申请与释放内存空闲内存的碎片化程度会越来越高导致内存利用效率降低。数组由于其连续存储方式相对不容易导致内存碎片化链表的元素分散存储在内存各处频繁的插入与删除如上面 C 代码中反复出现的malloc/free单节点操作更容易加剧碎片化。三、数据结构的缓存效率命中率从何而来缓存的容量远小于内存但速度比内存快得多在程序执行速度上起至关重要的作用。由于缓存容量有限只能容纳一小部分频繁访问的数据因此当 CPU 尝试访问的数据不在缓存中时就会发生缓存未命中cache miss此时 CPU 不得不从较慢的内存中加载数据。显然缓存未命中越少CPU 读写数据的效率越高。CPU 从缓存中成功获取数据的比例称为缓存命中率cache hit rate这是衡量缓存效率的核心指标。为了尽量提高命中率缓存采用了一组数据加载机制缓存行cache line缓存不是按单个字节存储与加载数据而是以缓存行为单位批量传输相比单字节传输更加高效。预取机制prefetching处理器会尝试预测数据访问模式如顺序访问、固定步长跳跃访问等并提前将数据加载至缓存。空间局部性如果一个数据被访问它附近的数据近期很可能也会被访问因此缓存在加载某一数据时会一并加载其附近的数据。时间局部性如果一个数据被访问它在不久的将来很可能再次被访问缓存通过保留最近访问过的数据来提高命中率。数组与链表在缓存利用上的四个差异对照上述机制原文档总结了数组比链表缓存利用率更高的四个原因占用空间链表元素比数组元素占用空间更多多出的next引用参见 list_node.py缓存中容纳的有效数据量更少缓存行链表数据分散在内存各处而缓存按行加载因此加载到的无效数据其他链表节点的碎片、不相关数据比例更高预取机制数组的访问模式更具可预测性——如 array.py 中traverse的连续索引遍历地址线性递增硬件预取器更容易猜出即将被加载的数据而链表遍历如 linked_list.py 中access的逐节点head head.next跳转每次都要跟随一个指针到内存中的随机位置模式不可预测空间局部性数组存储在集中的连续内存空间中被加载数据附近的数据更有可能即将被访问链表节点之间几乎没有位置上的关联。总体结论数组具有更高的缓存命中率因此在操作效率上通常优于链表。这也解释了为何在算法题中基于数组实现的数据结构往往更受欢迎。四、实战选型以栈为例需要强调的是高缓存效率并不意味着数组在所有情况下都优于链表实际选型应根据具体需求决定。原文档以栈为例给出了决策依据栈的完整实现在下一章的 chapter_stack_and_queue 中讲解仓库中对应两种实现倾向于选数组栈算法题场景下数组栈提供了更高的操作效率和随机访问能力代价仅是需要预先分配一定的内存空间。参考实现 array_stack.py 直接基于 Python 的listpush即append、pop即pop()、peek即访问self._stack[-1]全部落在连续内存上def push(self, item: int): 入栈 self._stack.append(item) def pop(self) - int: 出栈 if self.is_empty(): raise IndexError(栈为空) return self._stack.pop()倾向于选链表栈当数据量非常大、动态性很高、栈的预期大小难以估计时链表栈更合适。它能将大量数据分散存储于内存的不同部分并避免数组扩容带来的额外开销。参考实现 linkedlist_stack.py 每次push只新建一个节点并挂到栈顶引用前无需任何扩容—复制过程def push(self, val: int): 入栈 node ListNode(val) node.next self._peek self._peek node self._size 1这种按需单节点分配的特点与第二节 C 版 linked_list.c 中insert/removeItem的行为一致灵活性高但空间局部性与缓存友好性较低。五、小结本文沿《Hello 算法》ram_and_cache.md 的脉络可以把核心结论浓缩为三条存储层级是权衡的产物硬盘、内存、缓存在容量、速度、成本之间构成金字塔式平衡缓存通过缓存行、预取、空间/时间局部性四类机制为 CPU 提供高速数据供给物理结构决定效率数组的连续存储带来更高的内存利用率和缓存命中率少占指针空间、缓存行有效数据多、访问模式可预测、空间局部性好链表的节点式分配则带来灵活性但代价是更多空间占用、更低的命中率和更高的碎片化风险选型看场景算法题与可预估规模的数据结构优先选数组实现数据量大、动态性强、规模难以估计时链表实现更合适——仓库中 array_stack.py 与 linkedlist_stack.py 这对对照实现就是这条原则最直接的代码体现。【免费下载链接】hello-algo《Hello 算法》动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語提供 Python, Java, C, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表