摘要:线性表是最基础的数据结构,分为顺序存储(顺序表) 与链式存储(链表)。本文梳理线性表核心概念、顺序表、单链表、双向循环链表、高频 OJ/408 真题、顺序表与链表对比、CPU 缓存原理,适合考研 408、校招笔试面试复习。
一、线性表基础概念
- 定义:相同类型 n 个数据元素的有限序列 \(L=(a_1,a_2...a_n)\),\(n=0\)为空表。
- 位序从 1 开始 ;数组下标从 0 开始,做题务必区分。
- 逻辑特征:表头无前驱、表尾无后继;其余结点拥有唯一前驱、唯一后继。
- 两种存储实现
表格
| 存储方式 | 特点 | 产物 |
|---|---|---|
| 顺序存储 | 逻辑相邻,物理内存也连续 | 顺序表 |
| 链式存储 | 逻辑相邻,物理地址随意,依靠指针维系关系 | 链表 |
- ADT 抽象数据类型:包含初始化、销毁、按位获取、按值查找、插入、删除、判空、求长度等基础操作。
补充:严蔚敏教材出现
&L是C++ 引用,引用是变量别名,不会开辟内存;C 语言中使用指针替代引用完成修改实参。
二、顺序表(数组实现线性表)
2.1 分类:静态顺序表、动态顺序表
-
静态顺序表:使用固定大小数组。优点实现简单;缺点容量写死,容易出现空间浪费或者空间不足。
-
动态顺序表(重点):在堆上 malloc 申请数组,结构体包含 3 个成员
typedef struct{
SqDataType* arr; //动态数组指针
int size; //有效元素个数
int capacity;//总容量
}SqList;
2.2 核心接口
- 初始化:malloc 申请默认空间,size 置 0,记录 capacity。
- 销毁:free 释放堆内存,指针置 NULL,size、capacity 清零,避免内存泄漏。
- 插入
SqListInsert(ps,i,x)- 下标合法范围:
0 ≤ i ≤ size;i 等于 size 等价于尾插。 - 数组存满调用
realloc扩容(常见策略 2 倍扩容);realloc 分为原地扩容、异地扩容。 - i 及之后元素整体向后挪动,必须从后往前挪!防止数据被覆盖。
- 下标合法范围:
- 删除
SqListDelete(ps,i)- 下标合法范围:
0 ≤ i < size。 - 将 i 之后元素向前覆盖,size 自减。
- 下标合法范围:
- 查找
- 按下标
GetElem:随机访问 O (1),依靠连续内存做地址偏移。 - 按值
LocateElem:逐个遍历比较,平均时间复杂度 O (n)。
- 按下标
2.3 时间复杂度汇总
表格
| 操作 | 最好 | 最坏 | 平均 |
|---|---|---|---|
| 插入 | 尾插 O (1) | 头插 O (n) | O(n) |
| 删除 | 尾删 O (1) | 头删 O (n) | O(n) |
| 下标访问 | O(1) | O(1) | O(1) |
| 按值查找 | O(1) | O(n) | O(n) |
408 真题考点:获取第 i 个元素平均复杂度 O (1);插入、删除、按值查找平均 O (n)。
2.4 力扣经典顺序表习题
- 27. 移除元素 :双指针
src/dst原地覆盖,不等于 val 就保留,时间 O (N),空间 O (1)。 - 26. 删除有序数组重复项:快慢双指针,保留不重复元素。
顺序表痛点:中间位置插入删除需要大量搬移元素。
三、链表
3.1 链表总览
链表结点由数据域 + 指针域 组成,按需 malloc 结点,不需要整体扩容;插入删除只修改指针,不需要移动数据。
链表 8 种组合分类:
- 方向:单向链表、双向链表
- 是否带哨兵:带头结点、不带头结点
- 是否循环:循环链表、非循环链表
力扣 OJ 绝大多数题目:不带头结点单链表 ;严蔚敏 408 教材:带头结点链表。
3.2 带头结点单链表(重点)
结点定义:
typedef struct ListNode{
LDataType data;
struct ListNode* next;
}LNode,*LinkList;
- BuyListNode:堆上创建新结点,禁止使用局部栈变量。
- 初始化:创建哨兵头结点,头结点不存储有效数据。
- 遍历打印:
cur = L->next;循环终止条件cur != NULL。 - 查找:
- 按值查找:遍历链表,找到返回结点地址,找不到返回 NULL。
- 按下标查找:循环计数遍历找第 i 个结点,不支持随机访问,O (n)。
- 插入 ListInsert (L,i,x)
⚠️两句代码顺序绝对不能颠倒!
newNode->next = i_1Node->next;
i_1Node->next = newNode;
先保存后继结点,再修改前驱 next,否则丢失后续整条链表。 插入前需要先找到i‑1位置结点。
-
删除 ListDelete (L,i)
LNode* iNode = i_1Node->next; //保存待删结点
i_1Node->next = iNode->next;
free(iNode);
必须保存待删结点指针,否则 free 无法释放内存。
- 销毁:循环先保存 next 指针,再 free 当前结点,不能 free 之后再访问 next。
3.3 单链表高频力扣算法题
- 876. 链表中间结点:快慢指针,slow 走一步、fast 走两步。奇数结点 slow 指向正中间;偶数结点 slow 指向靠后结点。
- 面试题 02.02 倒数第 k 个结点:fast 先走 k 步;fast、slow 同步走,fast 走到 NULL,slow 即为倒数第 k 结点。
- 203 移除链表元素 :使用虚拟哨兵头结点,规避删除头结点的特殊判断。
- 206 反转链表:头插法,新建空链表,依次将原链表结点头插到新链表。
- 21 合并两个有序链表:哨兵头结点做归并,规避处理第一个结点的特殊判断。
- 160 相交链表:求取两个链表长度,长链表先走长度差值步,之后同步遍历找相等结点;尾结点不相等则链表不相交。
- 141 环形链表:快慢指针,fast 两步 slow 一步;相遇代表有环,fast 走到 NULL 代表无环。
- 142 环形链表 II:快慢指针相遇后,一个指针从 head 出发、一个从相遇点出发,每次走一步,相遇点就是环入口。
拓展:fast 一次走 3 步不一定会相遇,取决于环周长与追击距离。
四、带头双向循环链表(STL::list 底层实现)
每个结点:prev(前驱指针) + data + next(后继指针)
- 空链表状态:头结点
L->next = L,L->prev = L自己指向自己。 - 优势:
- 通过
L->prev直接拿到尾结点;头插、头删、尾插、尾删全部 O (1)。 - 给定 pos 结点,可以 O (1) 完成 pos 之后插入、删除 pos 结点,不需要从头遍历寻找前驱。
- 通过
核心操作
-
pos 后插入新结点 newNode(四句顺序)
newNode->next = pos->next;
newNode->prev = pos;
pos->next->prev = newNode;
pos->next = newNode; -
删除 pos 结点
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
408 高频选择题:双向链表插入删除指针改写顺序,做题一定要画图。
五、顺序表 vs 链表对比
表格
| 对比维度 | 顺序表(动态数组) | 单链表 | 双向循环链表 |
|---|---|---|---|
| 物理内存 | 连续 | 内存分散 | 内存分散 |
| 随机访问 | ✅O(1) | ❌O(n) | ❌O(n) |
| 头部插入删除 | O (n) 大量搬移 | O(1) | O(1) |
| 尾部插入删除 | O (1)(不计扩容) | O (n) 需要找尾 | ✅O(1) |
| 已知位置 pos 中间插入删除 | O (n) 搬移 | O (n) 需要先查找 pos | O(1) |
| CPU 缓存友好 | ✅空间局部性,Cache 命中率高 | ❌内存离散,缓存失效 | ❌缓存不友好 |
| 内存碎片 | 几乎无碎片 | 频繁 malloc/free 产生碎片 | 产生碎片 |
| 额外开销 | 少量 capacity 预留空间 | 每个结点存储 next 指针 | 每个结点存储 prev+next 两个指针 |
使用场景建议:
- 大量随机访问、二分查找:优先选顺序表。
- 频繁头尾增删、已知结点位置频繁插入删除:优先双向循环链表。
- 工程开发很少直接使用普通单链表。
六、补充:CPU 缓存与局部性原理
- Cache Line:CPU 读取内存最小单位,一般 64 字节。读取一个数据会把相邻一批数据载入缓存。
- 空间局部性:访问某块内存,它相邻内存大概率马上访问。顺序表连续内存,遍历缓存命中率极高;链表结点内存散乱,每次都要访问内存,性能差很多。
- 时间局部性:访问过的数据短时间可能再次访问。
面试考点:为什么数组遍历比链表快?核心就是 CPU 缓存。
七、考研 / 面试踩坑提醒
- 位序 vs 下标:教材位序从 1;代码下标从 0,读题先确认描述。
- 链表做题优先画图,指针改写顺序写错直接内存崩溃。
- 链表注意内存泄漏:malloc 对应 free;OJ 平台自动回收内存,实际工程必须手动释放。
- realloc 扩容分为原地 / 异地扩容,异地会拷贝旧数据,旧地址直接失效。
- 哨兵头结点(虚拟头):减少大量 if 特殊分支判断,做题高频技巧。