数据结构-线性数据结构

概述

数据结构(Data Structure)是组织和存储数据的方式,其核心目标是提高数据访问和处理的效率。

常见的数据结构

线性数据结构

数据结构 核心特点 访问/增删效率 典型应用场景
数组 (Array) 内存连续,支持随机访问,大小通常固定 查找 O(1) 增删 O(n) 存储固定列表,如学生成绩、像素数据
链表 (Linked List) 内存不连续,通过指针连接,动态大小 查找 O(n) 增删 O(1) 频繁插入/删除的场景,如音乐播放列表
栈 (Stack) 后进先出 (LIFO),仅在一端操作 操作 O(1) 函数调用栈、浏览器"后退"按钮、撤销操作
队列 (Queue) 先进先出 (FIFO),两端操作 操作 O(1) 打印任务排队、消息队列、线程池任务

数组(Array)

数组是一种线性数据结构 ,用于存储一组相同类型的数据元素。它的核心特征可以概括为以下三点:

同质性 :数组中的所有元素必须是同一种数据类型(例如全是整数或全是字符串)。在强类型语言(如 Java, C++)中这是强制的;在动态语言(如 Python)中虽然看似可以混合,但底层实现通常也是同质的。
连续内存 :数组在内存中占据一块连续 的存储空间。这意味着元素之间紧密排列,没有空隙。
随机访问 :通过索引 (下标),可以直接访问任意位置的元素。在大多数编程语言中,索引从 0 开始。

存储原理

理解数组的关键在于理解它的内存布局。正是因为元素在内存中是连续存储的,计算机才能通过简单的数学公式瞬间定位到任意元素,而不需要遍历。

假设有一个整型数组 int arr[5],且 int 占用 4 字节。如果数组的起始地址(基地址)是 0x1000,那么内存布局如下:

内存地址 存储元素 计算逻辑
0x1000 arr[0] 基地址
0x1004 arr[1] 基地址 + 1 × 4字节
0x1008 arr[2] 基地址 + 2 × 4字节
0x100C arr[3] 基地址 + 3 × 4字节
0x1010 arr[4] 基地址 + 4 × 4字节

寻址公式:

当你要访问 arr[i] 时,CPU 会直接计算实际内存地址:实际地址 = 基地址 + (索引 i × 单个元素的大小)

常见操作和时间复杂度
操作 描述 时间复杂度 说明
访问 (Access) 读取或修改指定索引的元素 O(1) 通过公式直接定位,速度最快。
搜索 (Search) 查找某个值的位置 O(n) 无序数组需从头遍历(线性查找)。
插入 (Insert) 在指定位置添加元素 O(n) 在中间/开头插入需移动后续所有元素。
删除 (Delete) 移除指定位置元素 O(n) 删除后需移动后续元素填补空缺。
遍历 (Traverse) 访问数组中每个元素 O(n) 需要按顺序访问所有 n 个元素。

注意 :在数组末尾进行插入或删除操作通常较快,但在中间或开头操作非常耗时,因为需要移动大量数据。

优缺点
优点
  • 查找速度极快:凭借 O(1) 的随机访问能力,它是读取数据最快的方式之一。
  • 内存效率高:由于没有额外的指针或元数据(如链表中的节点指针),数组在存储大量同类型数据时非常节省内存。
  • CPU 缓存友好:因为内存连续,CPU 在读取一个元素时,往往会将附近的元素也加载到缓存中(局部性原理),这使得遍历数组非常快。
缺点
  • 插入删除慢:在中间插入或删除元素需要移动大量数据,效率低下。
  • 大小限制:静态数组大小固定,难以应对数据量变化的情况;即使是动态数组,扩容也需要消耗资源。
  • 类型单一:只能存储同一种类型的数据,灵活性较差。
常见应用
  • 基础数据存储:存储固定列表,如学生成绩、一周气温、图片像素矩阵。
  • 实现其他数据结构:栈、队列、哈希表、堆等复杂数据结构,底层通常都是基于数组实现的。
  • 算法基础:排序算法(如快速排序、归并排序)和查找算法(如二分查找)主要操作的对象就是数组。
  • 多维数组(矩阵):用于表示表格、地图、神经网络中的张量等。
注意事项
  • 索引越界 :尝试访问超出数组范围的索引(例如访问长度为 5 的数组的第 6 个元素)。这会导致程序崩溃或不可预知的错误(如 ArrayIndexOutOfBoundsException)。
  • 空指针异常:在某些语言(如 Java)中,如果数组对象未初始化(为 null)就尝试访问,会抛出空指针异常。

链表 (Linked List)

链表是一种在物理存储单元上非连续、非顺序 的存储结构。数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

链表由一系列节点(Node)组成,每个节点包含两个部分:

  • 数据域(Data Field):存储当前节点的实际数据(如整数、字符串或对象)。
  • 指针域(Pointer Field):存储下一个节点的内存地址,通过指针将分散的节点"链"起来,形成逻辑上的线性结构。
存储原理

与数组需要预先分配一块连续的内存空间不同,链表的节点可以在运行时动态生成,分散在内存的任意位置。

  • 逻辑连续,物理离散:虽然节点在内存中可能相隔甚远,但通过指针的指向,它们在逻辑上构成了一个有序的整体。
  • 动态内存分配:链表不需要预先知道数据的大小,只要有空闲内存,就可以随时创建新节点并加入链表中。
常见类型
类型 结构特点 遍历方式
单向链表 每个节点只有一个指针域,指向后继节点,尾节点指针为 NULL。 只能从头到尾单向遍历。
双向链表 每个节点有两个指针域,分别指向前驱和后继节点。 支持双向遍历(向前和向后)。
循环链表 尾节点的指针域不指向 NULL,而是指向头节点,形成一个环。 可以从任意节点出发遍历整个链表。

常见操作与时间复杂度

操作 描述 时间复杂度 说明
访问 (Access) 访问指定索引的元素 O(n) 不支持随机访问,必须从头节点开始逐个遍历。
搜索 (Search) 查找某个值的位置 O(n) 需要遍历链表直到找到目标值。
插入 (Insert) 在指定位置添加节点 O(1) 前提是已知插入位置的节点。只需修改指针指向,无需移动元素。
删除 (Delete) 移除指定位置的节点 O(1) 前提是已知删除位置的节点。只需修改前驱节点的指针,无需移动元素。

注意:虽然插入和删除操作本身是 O(1),但如果是在链表的中间位置进行操作,首先需要花费 O(n) 的时间遍历找到该位置。因此,在未知位置的情况下,中间插入/删除的整体时间复杂度仍为 O(n)。而在链表头部或尾部的插入/删除操作则非常高效。

优缺点
优点:
  • 插入/删除效率高:在已知位置进行插入或删除时,只需修改指针,无需像数组那样移动大量元素。
  • 动态扩容:无需预先分配固定大小的内存,可以根据需要动态地增加或减少节点,内存利用率高。
  • 内存利用率高:能够充分利用内存中的碎片空间,只要有可用内存就能分配节点。
缺点:
  • 不支持随机访问:查找或访问特定位置的元素效率低,必须从头遍历。
  • 额外的内存开销:每个节点除了存储数据,还需要额外的空间来存储指针,当数据域较小时,指针的开销占比会很高。
  • CPU 缓存不友好:由于节点在物理内存中不连续,无法利用 CPU 缓存的"空间局部性"原理,遍历效率可能低于数组。
常见应用
  • 实现其他数据结构:栈、队列、哈希表(解决冲突的链地址法)、图和树的底层实现。
  • 动态数据管理:适用于数据量不固定、需要频繁插入和删除的场景,如任务队列、消息队列、LRU 缓存淘汰算法。
  • 操作系统:用于内存管理(跟踪空闲和已分配的内存块)和进程调度。
  • 浏览器功能:浏览器的"前进"和"后退"功能通常使用双向链表来存储访问过的页面历史。
  • 底层技术:是区块链等技术的底层数据结构思想,通过指针(哈希值)将数据块链接起来。

栈 (Stack)

栈是一种遵循"后进先出"(LIFO, Last In First Out)原则的线性表。

存储原理

栈的操作被严格限制在表的一端,这一端被称为栈顶(Top) ,而另一端则是固定的,被称为栈底(Bottom) 。不含任何元素的栈称为空栈

栈主要有两种物理实现方式:

1. 顺序栈(基于数组)
  • 原理:使用一块连续的内存空间(如数组)来存储栈内元素。

  • 实现 :使用一个变量 top 作为栈顶指针,记录当前栈顶元素在数组中的下标。

    • 初始化时,top = -1 表示空栈。
    • 入栈 时,top 加 1,然后将新元素放入 data[top] 的位置。
    • 出栈 时,直接取出 data[top] 的元素,然后 top 减 1。
  • 特点:实现简单,内存连续,访问效率高。但需要预先分配固定大小的内存,存在"栈满"溢出的风险。

2. 链栈(基于链表)
  • 原理:使用链表来存储栈内元素,栈顶就是链表的头节点。

  • 实现:所有操作(入栈、出栈)都在链表的头部进行。

    • 入栈 :创建一个新节点,将其 next 指针指向当前的头节点,然后将它设为新的头节点。
    • 出栈:保存当前头节点的数据,将头节点指向下一个节点,然后释放原头节点的内存。
  • 特点:内存动态分配,不存在"栈满"问题,理论上只要内存足够就不会溢出。但每个节点需要额外的空间存储指针。

核心操作与时间复杂度
操作 描述 时间复杂度 说明
入栈 (Push) 将一个新元素添加到栈顶。 O(1) 无论是顺序栈还是链栈,都只涉及一步操作。
出栈 (Pop) 移除并返回栈顶元素。 O(1) 直接操作栈顶,效率恒定。
查看栈顶 (Peek) 返回栈顶元素的值,但不移除它。 O(1) 仅读取,不修改栈的状态。
判空 (isEmpty) 检查栈是否为空。 O(1) 检查 top 指针或头节点是否为空。
优缺点
优点
  • 操作高效:所有核心操作(Push, Pop, Peek)的时间复杂度都是 O(1),性能非常稳定。
  • 实现简单:逻辑清晰,无论是用数组还是链表实现,代码都非常简洁。
  • 管理方便:LIFO 的特性天然适合处理具有"倒序"或"回溯"需求的问题。
缺点
  • 访问受限:只能访问栈顶元素,无法随机访问栈内其他元素,灵活性差。
  • 顺序栈的局限:基于数组的顺序栈需要预先设定容量,存在空间浪费或栈满溢出的问题。
  • 链栈的开销:基于链表的链栈虽然解决了扩容问题,但每个节点需要额外的指针空间,内存开销稍大。
常见应用
  • 函数调用栈 (Call Stack):这是栈最重要的应用。每当一个函数被调用时,其局部变量、参数和返回地址等信息会被打包成一个"栈帧"并压入栈中。函数执行完毕后,其栈帧从栈顶弹出,程序返回到调用点继续执行。递归的本质就是函数不断调用自身,导致栈帧层层压入。
  • 表达式求值 :编译器使用栈来处理数学表达式,例如将中缀表达式(3 + 4 * 5)转换为后缀表达式,并进行计算。栈可以很好地处理运算符的优先级和括号的匹配。
  • 撤销/重做 (Undo/Redo):文本编辑器、绘图软件中的"撤销"功能,本质上就是将用户的每一步操作作为一个命令对象压入一个"命令栈"。当用户点击撤销时,就从栈顶弹出一个命令并执行其"撤销"操作。
  • 括号匹配 :检查代码或字符串中的括号(如 (), [], {})是否正确配对和嵌套,是栈的经典应用。遇到左括号就入栈,遇到右括号就与栈顶的左括号进行匹配。
  • 浏览器前进/后退:浏览器的历史记录通常使用两个栈来实现。一个栈存储"后退"历史,另一个栈存储"前进"历史。当你访问新页面时,它被压入"后退"栈,同时清空"前进"栈。点击"后退"时,当前页面从"后退"栈弹出并压入"前进"栈。
  • 深度优先搜索 (DFS):在遍历图或树时,DFS 算法可以使用栈来记录访问路径。当到达一个死胡同时,就从栈中弹出节点,回溯到上一个分叉口继续探索。

队列 (Queue)

队列是一种遵循**"先进先出"(FIFO, First In First Out)**原则的线性表。

存储原理

队列的操作被限制在两端:

  • 队尾(Rear/Tail):允许进行插入操作(入队)的一端。
  • 队头(Front/Head):允许进行删除操作(出队)的一端。
  • 不含任何元素的队列称为空队列

队列主要有两种物理实现方式,其中循环队列是数组实现中解决"假溢出"问题的关键优化。

1. 顺序队列与循环队列(基于数组)
  • 原理 :使用一块连续的内存空间(如数组)来存储队列元素,并用 frontrear 两个指针(或索引)分别指向队头和队尾。

  • 简单顺序队列的问题 :在普通的顺序队列中,随着元素的不断入队和出队,frontrear 指针会不断向后移动。当 front 指针移出了一些元素后,它前面的空间就永远无法被再次利用,即使这些空间是空闲的。这种现象被称为**"假溢出"**------队列实际未满,但已无法插入新元素。

  • 循环队列的解决方案 :为了解决"假溢出",我们将数组的存储空间在逻辑上首尾相连,想象成一个环形。当 rear 指针到达数组末尾时,如果数组开头有空闲空间,它就"绕回"到开头继续存储。这通过取模运算%)来实现。

    • 入队rear = (rear + 1) % 数组容量
    • 出队front = (front + 1) % 数组容量
  • 判满与判空

    • 判空front == rear
    • 判满 :为了区分队列是空还是满(因为两种情况下 frontrear 都可能相等),通常会浪费一个存储单元 。当 (rear + 1) % 数组容量 == front 时,认为队列已满。
2. 链式队列(基于链表)
  • 原理 :使用链表来存储队列元素。front 指针指向链表的头节点,rear 指针指向链表的尾节点。

  • 实现

    • 入队 :在链表的尾部添加一个新节点,并更新 rear 指针。
    • 出队 :移除链表的头节点,并更新 front 指针。
  • 特点:内存动态分配,不存在"队列满"的问题,理论上只要内存足够就不会溢出。但每个节点需要额外的空间存储指针。

核心操作与时间复杂度
操作 描述 时间复杂度 说明
入队 (Enqueue/Offer) 将一个新元素添加到队尾。 O(1) 无论是循环队列还是链式队列,都只涉及常数时间的操作。
出队 (Dequeue/Poll) 移除并返回队头元素。 O(1) 直接操作队头,效率恒定。
查看队头 (Peek) 返回队头元素的值,但不移除它。 O(1) 仅读取,不修改队列的状态。
判空 (isEmpty) 检查队列是否为空。 O(1) 检查 frontrear 指针的关系。
优缺点
优点
  • 操作高效:所有核心操作(入队、出队、查看)的时间复杂度都是 O(1),性能非常稳定。
  • 公平性:严格遵循 FIFO 原则,保证了任务或数据被处理的先后顺序,非常公平。
  • 缓冲作用:能够有效协调生产者和消费者之间速度的不匹配,起到缓冲和削峰填谷的作用。
缺点
  • 访问受限:只能访问队头元素,无法随机访问队列中的其他元素,灵活性差。
  • 循环队列的局限:基于数组的循环队列需要预先设定容量,存在空间浪费(需预留一个空位)或队列满溢出的问题。
  • 链式队列的开销:基于链表的链式队列虽然解决了扩容问题,但每个节点需要额外的指针空间,内存开销稍大。
应用
  • 任务调度:操作系统使用队列来管理等待 CPU 执行的进程或线程。打印机的打印任务池也是一个典型的队列应用,先发送的打印任务会先被执行。
  • 消息队列(MQ):在分布式系统中,消息队列(如 RabbitMQ, Kafka)被广泛用于服务间的异步通信和解耦。生产者将消息发送到队列,消费者从队列中取出并处理,保证了消息的顺序性和可靠性。
  • 广度优先搜索(BFS):在遍历图或树的层级结构时,BFS 算法使用队列来记录待访问的节点。它先将起始节点入队,然后不断从队头取出节点,并将其未访问过的邻居节点加入队尾,从而实现按层级遍历。
  • 数据流缓冲:在网络数据传输中,路由器使用队列来缓存接收到的数据包,然后按顺序转发,以应对网络拥塞。键盘输入缓冲区也使用队列来暂存用户的按键信息,等待程序读取。
  • 线程池:线程池中维护一个任务队列。当有新的任务提交时,它被放入队列中等待。线程池中的空闲线程会从队列中取出任务并执行,这可以有效控制并发数量,避免资源耗尽。
相关推荐
小陈工4 小时前
Python安全编程实践:常见漏洞与防护措施
运维·开发语言·人工智能·python·安全·django·开源
majingming1239 小时前
FUNCTION
java·前端·javascript
zopple9 小时前
常见的 Spring 项目目录结构
java·后端·spring
是娇娇公主~10 小时前
C++ 中 std::deque 的原理?它内部是如何实现的?
开发语言·c++·stl
SuperEugene10 小时前
Axios 接口请求规范实战:请求参数 / 响应处理 / 异常兜底,避坑中后台 API 调用混乱|API 与异步请求规范篇
开发语言·前端·javascript·vue.js·前端框架·axios
xuxie9911 小时前
N11 ARM-irq
java·开发语言
cjy00011111 小时前
springboot的 nacos 配置获取不到导致启动失败及日志不输出问题
java·spring boot·后端
wefly201712 小时前
从使用到原理,深度解析m3u8live.cn—— 基于 HLS.js 的 M3U8 在线播放器实现
java·开发语言·前端·javascript·ecmascript·php·m3u8
zhenxin012212 小时前
Spring Boot实现定时任务
java