概述
数据结构(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. 顺序队列与循环队列(基于数组)
-
原理 :使用一块连续的内存空间(如数组)来存储队列元素,并用
front和rear两个指针(或索引)分别指向队头和队尾。 -
简单顺序队列的问题 :在普通的顺序队列中,随着元素的不断入队和出队,
front和rear指针会不断向后移动。当front指针移出了一些元素后,它前面的空间就永远无法被再次利用,即使这些空间是空闲的。这种现象被称为**"假溢出"**------队列实际未满,但已无法插入新元素。 -
循环队列的解决方案 :为了解决"假溢出",我们将数组的存储空间在逻辑上首尾相连,想象成一个环形。当
rear指针到达数组末尾时,如果数组开头有空闲空间,它就"绕回"到开头继续存储。这通过取模运算 (%)来实现。- 入队 :
rear = (rear + 1) % 数组容量 - 出队 :
front = (front + 1) % 数组容量
- 入队 :
-
判满与判空:
- 判空 :
front == rear - 判满 :为了区分队列是空还是满(因为两种情况下
front和rear都可能相等),通常会浪费一个存储单元 。当(rear + 1) % 数组容量 == front时,认为队列已满。
- 判空 :
2. 链式队列(基于链表)
-
原理 :使用链表来存储队列元素。
front指针指向链表的头节点,rear指针指向链表的尾节点。 -
实现:
- 入队 :在链表的尾部添加一个新节点,并更新
rear指针。 - 出队 :移除链表的头节点,并更新
front指针。
- 入队 :在链表的尾部添加一个新节点,并更新
-
特点:内存动态分配,不存在"队列满"的问题,理论上只要内存足够就不会溢出。但每个节点需要额外的空间存储指针。
核心操作与时间复杂度
| 操作 | 描述 | 时间复杂度 | 说明 |
|---|---|---|---|
| 入队 (Enqueue/Offer) | 将一个新元素添加到队尾。 | O(1) | 无论是循环队列还是链式队列,都只涉及常数时间的操作。 |
| 出队 (Dequeue/Poll) | 移除并返回队头元素。 | O(1) | 直接操作队头,效率恒定。 |
| 查看队头 (Peek) | 返回队头元素的值,但不移除它。 | O(1) | 仅读取,不修改队列的状态。 |
| 判空 (isEmpty) | 检查队列是否为空。 | O(1) | 检查 front 和 rear 指针的关系。 |
优缺点
优点
- 操作高效:所有核心操作(入队、出队、查看)的时间复杂度都是 O(1),性能非常稳定。
- 公平性:严格遵循 FIFO 原则,保证了任务或数据被处理的先后顺序,非常公平。
- 缓冲作用:能够有效协调生产者和消费者之间速度的不匹配,起到缓冲和削峰填谷的作用。
缺点
- 访问受限:只能访问队头元素,无法随机访问队列中的其他元素,灵活性差。
- 循环队列的局限:基于数组的循环队列需要预先设定容量,存在空间浪费(需预留一个空位)或队列满溢出的问题。
- 链式队列的开销:基于链表的链式队列虽然解决了扩容问题,但每个节点需要额外的指针空间,内存开销稍大。
应用
- 任务调度:操作系统使用队列来管理等待 CPU 执行的进程或线程。打印机的打印任务池也是一个典型的队列应用,先发送的打印任务会先被执行。
- 消息队列(MQ):在分布式系统中,消息队列(如 RabbitMQ, Kafka)被广泛用于服务间的异步通信和解耦。生产者将消息发送到队列,消费者从队列中取出并处理,保证了消息的顺序性和可靠性。
- 广度优先搜索(BFS):在遍历图或树的层级结构时,BFS 算法使用队列来记录待访问的节点。它先将起始节点入队,然后不断从队头取出节点,并将其未访问过的邻居节点加入队尾,从而实现按层级遍历。
- 数据流缓冲:在网络数据传输中,路由器使用队列来缓存接收到的数据包,然后按顺序转发,以应对网络拥塞。键盘输入缓冲区也使用队列来暂存用户的按键信息,等待程序读取。
- 线程池:线程池中维护一个任务队列。当有新的任务提交时,它被放入队列中等待。线程池中的空闲线程会从队列中取出任务并执行,这可以有效控制并发数量,避免资源耗尽。