数据结构入门:深入理解顺序表与链表
在计算机科学的世界里,数据结构是构建高效算法的基石,而线性表作为最基础、最常用的数据结构之一,更是每个程序员必须掌握的核心知识点。线性表看似简单,却衍生出了顺序表、链表、栈、队列等多种实用结构,其中顺序表与链表更是线性表的 “左右护法”—— 它们分别代表了 “连续存储” 与 “离散存储” 两种截然不同的设计思想,在实际开发中各有千秋。今天,我们就从线性表的基本概念出发,一步步拆解顺序表与链表的实现逻辑、核心差异,并结合面试高频考点,带你真正吃透这两种基础数据结构。
一、线性表:一切的起点
在正式讲解顺序表与链表之前,我们必须先搞清楚一个前提概念:线性表(Linear List)。
线性表的定义很明确:它是由n个(n≥0)具有相同特性的数据元素构成的有限序列。这里的 “序列” 意味着元素之间存在明确的逻辑顺序 —— 除了第一个元素,每个元素都有唯一的前驱;除了最后一个元素,每个元素都有唯一的后继。简单来说,线性表在逻辑上就像一条 “直线”,元素一个挨着一个排列。
举个生活中的例子:排队买咖啡的队伍、通讯录里按顺序存储的联系人、数组里的一串整数,这些都是线性表的实际体现。而在计算机中,我们常见的顺序表、链表、栈、队列、字符串,本质上都是线性表的 “变体”—— 它们都遵循线性表的逻辑结构,只是在物理存储和操作规则上有所差异。
需要特别注意的是:线性表的 “逻辑连续” 不等于 “物理连续”。
- 逻辑结构:指元素之间的逻辑关系(比如 “第一个元素后面是第二个元素”),这是线性表的核心特征,所有线性表都满足。
- 物理结构:指元素在计算机内存中的实际存储位置。线性表的物理存储有两种常见形式:
- 连续存储:比如数组,元素在内存中占用连续的地址空间。
- 离散存储:比如链表,元素在内存中可能分散存储,通过指针 / 引用关联起来。
这两种物理存储形式,正是我们接下来要重点讨论的 “顺序表” 与 “链表” 的核心区别。
二、顺序表:数组的 “升级版”
顺序表是线性表的一种连续存储实现—— 它用一段物理地址连续的存储单元(通常是数组)来依次存储数据元素,并用一个变量记录有效数据的个数。可以说,顺序表就是对数组的 “封装”,让数组的操作(增删查改)更符合线性表的逻辑。
2.1 顺序表的两种结构
根据数组的存储方式,顺序表分为 “静态” 和 “动态” 两种,它们的核心区别在于 “容量是否可扩展”。
1. 静态顺序表:固定容量的 “简易版”
静态顺序表使用定长数组存储元素,容量在定义时就固定不变。它的结构非常简单,适合已知数据量的场景。
用 C 语言代码定义如下(注意代码中的语法修正,原文档存在笔误):
// 定义数据类型(方便后续修改,比如改成char、float)
#define SLDataType int
// 静态顺序表的固定容量
#define N 7typedef struct SeqList {SLDataType array[N]; // 存储元素的定长数组size_t size; // 有效数据的个数(初始为0)
} SeqList;静态顺序表的缺点很明显:
- 如果
N定义得太大,会浪费内存空间(比如只存 3 个元素,却占用了 7 个元素的空间); - 如果
N定义得太小,后续想新增元素时会 “空间不足”,无法扩展。
因此,静态顺序表仅适用于数据量完全确定的场景,实际开发中很少使用。
2. 动态顺序表:可灵活扩容的 “实用版”
动态顺序表解决了静态顺序表的痛点 —— 它使用动态开辟的数组(比如 C 语言中的malloc/realloc分配的内存)存储元素,容量可以根据需要动态扩展。
用 C 语言代码定义如下:
#define SLDataType inttypedef struct SeqList {SLDataType* array; // 指向动态开辟数组的指针(初始为NULL)size_t size; // 有效数据的个数(初始为0)size_t capacity; // 当前数组的容量(初始为0,记录能存多少元素)
} SeqList;动态顺序表的核心优势:
- 容量按需扩展:当
size == capacity(元素存满)时,通过realloc重新分配更大的内存空间(通常是原容量的 2 倍),避免空间浪费; - 内存利用率更高:初始时可以不分配空间,或分配较小的空间,后续根据数据量动态调整。
2.2 动态顺序表的核心接口实现
顺序表的核心操作是 “增删查改”,我们需要为动态顺序表实现一套完整的接口函数。以下是关键接口的逻辑解析(附简化代码):
1. 初始化(SeqListInit)
初始化的目的是让顺序表处于 “可用状态”—— 初始时array为NULL,size和capacity都为 0。
void SeqListInit(SeqList* psl) {assert(psl != NULL); // 防止传入空指针,避免崩溃psl->array = NULL;psl->size = 0;psl->capacity = 0;
}
2. 容量检查与扩容(2. 容量检查与扩容(CheckCapacity)
这是动态顺序表的 “灵魂接口”—— 每次新增元素前,先检查当前容量是否足够;如果不足,则进行扩容(通常扩为原容量的 2 倍,若原容量为 0 则先扩为 4)。
void CheckCapacity(SeqList* psl) {assert(psl != NULL);// 当有效数据个数等于容量时,需要扩容if (psl->size == psl->capacity) {// 计算新容量:原容量为0则扩为4,否则扩为2倍size_t newCapacity = (psl->capacity == 0) ? 4 : psl->capacity * 2;// 重新分配内存(realloc会保留原数组的元素)SLDataType* newArray = (SLDataType*)realloc(psl->array, newCapacity * sizeof(SLDataType));assert(newArray != NULL); // 防止内存分配失败// 更新指针和容量psl->array = newArray;psl->capacity = newCapacity;}
}3. 尾插(SeqListPushBack)
在顺序表的末尾添加一个元素,步骤如下:
检查容量(调用CheckCapacity);将元素存入array[size]的位置;size加 1(有效数据个数增加)。
void SeqListPushBack(SeqList* psl, SLDataType x) {assert(psl != NULL);CheckCapacity(psl); // 先确保容量足够psl->array[psl->size] = x; // 存入元素psl->size++; // 有效个数+1
}4. 尾删(SeqListPopBack)
删除顺序表末尾的元素,步骤如下:
- 检查顺序表是否为空(
size == 0时无法删除); size减 1(无需实际 “删除” 元素,只需让后续操作忽略该位置即可)
void SeqListPopBack(SeqList* psl) {assert(psl != NULL);assert(psl->size > 0); // 空表不能删psl->size--;
}5. 头插(SeqListPushFront)
在顺序表的开头添加一个元素,步骤如下:
- 检查容量;
- 将所有元素从后往前依次后移 1 位(避免覆盖前面的元素);
- 将新元素存入
array[0]的位置; size加 1。
void SeqListPushFront(SeqList* psl, SLDataType x) {assert(psl != NULL);CheckCapacity(psl);// 元素后移:从最后一个元素(size-1)移到size的位置for (size_t i = psl->size; i > 0; i--) {psl->array[i] = psl->array[i - 1];}psl->array[0] = x;psl->size++;
}6. 头删(SeqListPopFront)
删除顺序表开头的元素,步骤如下:
- 检查顺序表是否为空;
- 将所有元素从前往后依次前移 1 位(覆盖第一个元素);
size减 1。
void SeqListPopFront(SeqList* psl) {assert(psl != NULL);assert(psl->size > 0);// 元素前移:从第二个元素(1)移到0的位置for (size_t i = 0; i < psl->size - 1; i++) {psl->array[i] = psl->array[i + 1];}psl->size--;
}7. 查找(SeqListFind)
查找指定元素x在顺序表中的位置,返回其下标(若未找到则返回 - 1)。
int SeqListFind(SeqList* psl, SLDataType x) {assert(psl != NULL);for (size_t i = 0; i < psl->size; i++) {if (psl->array[i] == x) {return i; // 找到,返回下标}}return -1; // 未找到
}8. 销毁(SeqListDestroy)
动态顺序表使用了堆内存(malloc分配),必须手动释放,否则会导致内存泄漏。销毁的步骤如下:
- 释放
array指向的内存; - 将
array置为NULL,size和capacity置为 0
void SeqListDestroy(SeqList* psl) {assert(psl != NULL);free(psl->array); // 释放动态内存psl->array = NULL; // 避免野指针psl->size = 0;psl->capacity = 0;
}2.3 顺序表的问题与思考
虽然顺序表实现简单、支持随机访问,但它也存在明显的局限性,这些局限性正是 “链表” 出现的原因:
1. 中间 / 头部插入删除效率低(时间复杂度 O (N))
无论是头插、头删,还是中间插入、中间删除,都需要移动大量元素(比如头插需要移动所有元素后移,头删需要移动所有元素前移)。数据量越大,移动的元素越多,效率越低。
比如一个有 10000 个元素的顺序表,头插一个元素需要移动 10000 个元素,这显然是低效的。
2. 扩容存在额外开销
动态顺序表的扩容(realloc)需要做三件事:
- 申请一块新的更大的内存;
- 将原数组的元素拷贝到新内存;
- 释放原内存。
这个过程会消耗额外的时间,尤其是当数据量很大时,拷贝元素的开销会非常明显。
3. 扩容会导致空间浪费
为了减少扩容的频率,我们通常会按 “原容量的 2 倍” 扩容。这就意味着:如果当前容量是 100,满了之后会扩到 200,但如果后续只新增了 5 个元素,就会浪费 95 个元素的空间。
这些问题让我们思考:有没有一种数据结构,能避免 “移动元素” 和 “扩容浪费”?答案就是 —— 链表。
三、链表:离散存储的 “灵活派”
链表是线性表的另一种实现 —— 它采用离散存储的方式,元素(称为 “结点”)在内存中可以不连续,通过指针(或引用)将各个结点串联起来,形成逻辑上的线性结构。
如果说顺序表像 “一排连续的座位”(每个座位紧挨着),那么链表就像 “一串散落的珠子”(珠子之间用线连起来)—— 珠子(结点)可以放在不同的位置,线(指针)负责维系它们的顺序。
3.1 链表的基本结构
链表的核心是 “结点(Node)”,每个结点包含两部分:
- 数据域(data):存储元素的值;
- 指针域(next):存储下一个结点的地址(或引用),用于连接下一个结点。
以单链表为例,一个包含 4 个元素(1、2、3、4)的链表在内存中的结构如下:
| 内存地址 | 数据域(data) | 指针域(next) |
|---|---|---|
| 0x0012FFA0 | 1 | 0x0012FFB0 |
| 0x0012FFB0 | 2 | 0x0012FFC0 |
| 0x0012FFC0 | 3 | 0x0012FFD0 |
| 0x0012FFD0 | 4 | NULL |
- 第一个结点(头结点)的地址是
0x0012FFA0,通过它的next可以找到第二个结点; - 最后一个结点的
next为NULL,表示链表的末尾。
需要注意的是:
- 链表的结点通常从 “堆内存” 中申请(比如 C 语言的
malloc),两次申请的内存可能连续,也可能不连续(由操作系统的内存分配策略决定); - 链表的逻辑顺序由指针域决定,与物理地址无关 —— 即使结点在内存中是 “乱序” 的,只要指针指向正确,逻辑上就是线性的。
3.2 链表的分类:8 种组合与 2 种常用结构
链表的结构非常灵活,根据 “指针方向”“是否带头结点”“是否循环” 三个维度,可以组合出 8 种不同的链表结构:
- 指针方向:单向(只有
next指针)、双向(有next和prev指针); - 头结点:带头(有一个不存数据的 “哨兵结点”)、不带头(第一个结点就是数据结点);
- 循环:循环(最后一个结点的
next指向头结点)、非循环(最后一个结点的next为NULL)。
虽然组合很多,但实际开发中最常用的只有两种:
1. 无头单向非循环链表
- 结构:没有头结点,第一个结点就是数据结点;只有
next指针,不循环; - 特点:结构最简单,实现代码少,但操作(尤其是尾删、中间删除)效率较低(需要遍历找到前驱结点);
- 用途:很少单独用来存储数据,更多作为其他数据结构的 “子结构”,比如哈希表的哈希桶、图的邻接表;同时也是笔试面试的高频考点(比如反转链表、找中间结点)。
用 C 语言定义结点结构:
#define SLTDateType int// 单链表结点结构
typedef struct SListNode {SLTDateType data; // 数据域struct SListNode* next; // 指针域:指向 next 结点
} SListNode;四、顺序表与链表的核心区别:一张表看懂
顺序表与链表是线性表的两种极端实现 —— 一个追求 “连续存储” 的高效访问,一个追求 “离散存储” 的灵活插入。它们的核心区别可以通过下表清晰对比:
| 对比维度 | 顺序表(SeqList) | 链表(LinkedList) |
|---|---|---|
| 存储空间 | 物理地址必须连续(数组) | 逻辑连续,物理地址不一定连续(结点 + 指针) |
| 随机访问 | 支持(通过下标访问,时间复杂度 O (1)) | 不支持(需从表头遍历,时间复杂度 O (N)) |
| 插入 / 删除效率 | 中间 / 头部:需移动元素,O (N);尾部:O (1) | 已知前驱结点时:O (1);未知时需遍历,O (N) |
| 容量管理 | 动态顺序表需扩容(有额外开销,可能浪费空间) | 无容量概念,按需申请结点(无浪费) |
| 缓存利用率 | 高(数组连续存储,符合 CPU 缓存的 “局部性原理”) | 低(结点离散存储,缓存命中率低) |
| 实现复杂度 | 简单(基于数组,操作直观) | 复杂(需管理指针,避免断链、野指针) |
| 应用场景 | 频繁访问(读多写少),如数据查询、排序 | 频繁插入删除(写多读少),如消息队列、购物车 |
