线性表存储结构:顺序存储与链式存储
TL;DR:线性表有两种基本存储方式。顺序存储把元素放在地址连续的存储区里,元素的逻辑相邻关系由物理地址相邻直接体现,按位查找
O(1),代价是插入删除需要整体移动后续元素;链式存储把元素放在任意地址,用指针域建立逻辑相邻关系,已知结点位置时插入删除仅需改动常数个指针域,代价是每结点额外占用指针空间且随机访问性能差。链式存储又按指针方向与首尾关系细分为单链表、单循环链表、双向非循环链表与双向循环链表四种基本形态,循环消除了首尾边界判断,双向使结点可直接定位前驱。
目录
一、概述
1.1 线性表要表达的核心关系
线性表是 n 个数据元素的有限序列,元素之间只有一种关系:除第一个元素与最后一个元素外,每个元素有唯一的直接前驱与唯一的直接后继。这一关系称为线性关系。
存储结构要回答的问题是:如何在计算机内存中组织这些元素,使得"前驱"与"后继"这两个概念可以被高效地找到。答案是两条路径:
- 让物理位置相邻来暗示逻辑相邻。元素放在连续地址上,第
i个元素的位置就是base + i × sizeof(ElemType),无需任何额外信息即可计算。 - 让指针记录相邻关系。元素可以散布在任意地址,每个结点额外保存一个或两个指针域,指向逻辑上的邻居。
两者都正确,差别体现在操作代价、常数开销与内存布局上,而不是抽象数据结构本身的表达能力。
1.2 关键性质速览
| 性质 | 顺序存储 | 链式存储 |
|---|---|---|
| 相邻关系的实现 | 物理地址相邻 | 指针域显式记录 |
| 按位查找 | O(1),地址直接算出 |
O(n),须逐结点沿指针前进 |
| 按值查找 | O(n),需逐个比较 |
O(n),需逐结点解引用并比较 |
| 已知位置时的插入删除 | O(n),须整体移动后续元素 |
O(1),仅改动常数个指针域 |
| 求表长 | O(1),长度是表的成员 |
O(n),须遍历(无循环结构时) |
| 空间条件 | 须预留一块地址连续的空间 | 无需连续空间,结点可散布 |
| 空间开销 | 固定容量或动态扩容 | 每结点附加一个或两个指针域 |
| 内存布局 | 连续,易于预取 | 离散,存在指针追逐开销 |
| 扩容代价 | 需重新申请更大空间并复制全部元素 | 逐结点申请,无整体复制 |
| 适用前提 | 规模可预估、访问以顺序为主 | 规模不确定、频繁在中间插删 |
需要强调的一条:链式存储的 O(1) 插入删除以"已知结点位置"为前提。若只知道待插入元素的逻辑序号,仍需 O(n) 的遍历才能定位到前驱结点。链式存储分离了"查找"与"修改"的代价,顺序表则要求两者同时付出。
1.3 存储方式全景
图中前两个分支是基本存储结构,也是线性表的标准定义范围;后两个分支属于广义的存储结构分类,见 4.7。链式存储下的六个分支彼此可以组合,其中四个是考试与工程中最常出现的形态。
二、问题形式化
2.1 逻辑结构到存储结构的映射
线性表的抽象定义只约束逻辑关系:
a1 <-> a2 <-> a3 <-> ... <-> an
存储结构则是该逻辑关系在内存中的一种具体实现。二者的对应关系由一组不变式保证:
- 顺序存储不变式:占据下标
0至length - 1的存储单元中,每个单元存放一个数据元素,且元素的下标即其逻辑序号(可减去一得到教材中常用的从 1 开始的序号)。 - 链式存储不变式:存在唯一表头
L;从L出发,沿指针域依次访问到的数据元素序列,与逻辑序列完全一致;每个数据结点恰好有一个直接后继。
任何一种存储方式上的插入、删除、查找操作,本质上都是在保持上述不变式的前提下重建映射关系。
2.2 顺序存储的定义
设线性表为 L = (a1, a2, ..., an),用一组地址连续的存储单元依次存放这些数据元素。若每个元素占 c 个存储单元,则
第 i 个元素的存储地址 = LOC(ai) = LOC(a1) + (i - 1) × c
这是顺序存储的直接寻址公式,也是顺序表按位查找能做到 O(1) 的全部依据。
顺带引入两个概念:
- 物理位置:元素在存储区中的实际地址。
- 逻辑位置:元素在序列中的序号。
顺序存储中二者一一对应,不需要任何中间信息。
2.3 链式存储的定义
链式存储不要求地址连续。每个结点由两部分组成:
┌───────────────┬───────────────┐
│ data 域 │ 指针域 │
│ 存放数据 │ 指向相邻结点 │
└───────────────┴───────────────┘
指针域保存的是下一个结点的地址。逻辑相邻的两个元素,其地址可以相差任意距离,物理距离由指针记录。指针域的类型可以是指向同类结点的指针,也可以是一个整数下标(静态链表,见 4.5)。
链式存储的三个要素:
- 表头:指向第一个数据结点的指针,是访问链表的唯一入口。
- 结点:数据域与指针域的组合。
- 空表表示:
NULL指针,或者循环结构下的表头自指。
2.4 存储结构与算法复杂度的对应关系
存储方式不直接决定复杂度,而是决定了某一类操作中"定位元素"需要付出多少代价。定位代价与修改代价之和才是最终复杂度。
| 操作 | 定位元素所需步骤 | 修改元素所需步骤 | 合计 |
|---|---|---|---|
| 顺序表按位查找 | 由地址公式直接算出,1 步 | 无 | O(1) |
| 顺序表插入 | 由下标直接算出,1 步 | 移动 n - i + 1 个元素 |
O(n) |
| 单链表按位查找 | 沿 next 前进 i - 1 次 |
无 | O(n) |
| 单链表插入 | 沿 next 前进 i - 1 次 |
改动 2 个指针域 | O(n) |
| 双向循环链表按位查找 | 沿 next 或 prior 前进 |
无 | O(n) |
双向循环链表在已知 p 后插入 |
无需定位 | 改动 4 个指针域 | O(1) |
双向循环链表删除已知 p |
无需定位 | 改动 2 个指针域并释放结点 | O(1) |
最后两行是链式存储唯一取得复杂度优势的场景:调用方已经持有结点指针。这在实际工程中很常见,例如编译器符号表维护、内存空闲块管理、任务的就绪队列,这些场景中结点往往由回调或前一次遍历直接给出,不必重新定位。
三、核心思想
3.1 逻辑相邻的两条实现路径
两条路径的成本结构恰好相反:顺序存储把代价压在修改上,链式存储把代价压在定位上。因此在设计具体算法时,先确认约束落在"定位多"还是"修改多",再选择存储方式。
3.2 顺序存储:由地址算位置
偏移量 i × sizeof(ElemType) 由编译器在编译期确定,运行时只需一次加法。这条路径不依赖任何数据结构中的辅助字段,是线性表中最短的查找路径。
3.3 链式存储:由指针找位置
每前进一次都是一次指针解引用,而解引用的地址在编译期无法确定,必须在运行时读取结点的指针域才能确定下一次要访问的地址。这一步称为指针追逐,其代价远高于顺序表的地址加法,详见 7.3。
3.4 链表形态的三个独立维度
四种基本链表的差异由三个彼此独立的属性组合而成,三个属性各取两值,共 2³ = 8 种组合,其中四种常用:
| 属性 | 取值 | 影响 |
|---|---|---|
| 指针方向 | 单向 / 双向 | 双向可由结点直接定位前驱,每结点多一个指针域 |
| 首尾关系 | 非循环 / 循环 | 循环时尾结点的指针指向表头,不存在边界特判 |
| 结点来源 | 动态申请 / 数组游标 | 静态实现不支持运行时扩充容量 |
三种常用组合及其实例:
| 形态 | 指针方向 | 首尾关系 | 结点来源 | 典型实例 |
|---|---|---|---|---|
| 单链表 | 单向 | 非循环 | 动态申请 | 教材默认形态、C 的 next 字段 |
| 单循环链表 | 单向 | 循环 | 动态申请 | 循环队列的链表实现 |
| 双向循环链表 | 双向 | 循环 | 动态申请 | Linux 内核进程链表、C++ std::list |
| 静态链表 | 单向 | 非循环 | 数组游标 | 仅支持静态分配的嵌入式场景 |
上表中四种常用形态的指针连接结构如下:
循环带来的直接收益是消除边界判断。以"在 p 之后插入 s"为例:非循环结构中若 p 是尾结点,p->next 为 NULL,必须写 if (p->next != NULL) p->next->prior = s;;循环结构中 p->next 自动指向表头,该句照常执行。这正是考察题偏好循环结构的原因。
双向带来的收益是删除时无需前驱查找。单链表中删除 p 必须先遍历到 p 的前驱再改指针,耗时 O(n);双向链表中 p->prior 直接给出前驱,耗时 O(1)。
四、存储方式详解
4.1 顺序表
顺序表用一段地址连续的存储区存放元素,通常配一个 length 成员记录当前元素个数。
静态分配:
#define MAXSIZE 100
typedef int ElemType;
typedef struct {
ElemType data[MAXSIZE];
int length;
} SqList;
动态分配:
typedef struct {
ElemType *data;
int length;
int capacity;
} SqList;
静态分配在编译期确定容量上限,超出即溢出;动态分配在运行时申请与扩容,是工程实现中的通行做法。
顺序表的技术要点:
- 元素个数与容量是两个不同的量。
length是已有元素个数,capacity是已分配空间可容纳的元素个数。二者不可混用,length == capacity时插入会触发扩容。 - 插入必须从后向前移动。按下标
i插入时,要把[i, n-1]的元素整体后移一位。若从后向前移动,每次复制的数据都尚未被覆盖;从前向后移动则会覆盖尚未搬走的数据。 - 扩容代价是一次
O(n)拷贝。常见的"按 1.5 倍或 2 倍增长"策略把连续扩容的均摊代价压到O(1),但单次扩容仍是O(n)且需要一块更大的连续空间。 - 无法在表中间"就地"插入。任何插入都会破坏已有元素的物理相邻关系,必须重新排布。
顺序表的关键优势是内存布局与逻辑顺序完全一致带来的三项收益:按位查找 O(1)、求表长 O(1)、顺序遍历具有最好的缓存局部性。
4.2 单链表
typedef int ElemType;
typedef struct LNode {
ElemType data;
struct LNode *next;
} LNode, *LinkList;
* 与 typedef 连用定义结点类型与表头指针类型,是数据结构教材的标准写法:LinkList L 是指向结点的指针,LNode * 是同一个类型的另一种写法。
单链表有带头结点与不带头结点两种约定,两者不可混用:
| 约定 | 空表状态 | 首元结点位置 | 插入首元素的特殊处理 |
|---|---|---|---|
| 不带头结点 | L == NULL |
L 即首元结点 |
需单独处理 |
| 带头结点 | L->next == NULL |
L->next 指向首元结点 |
与其他位置一致 |
带头结点的好处是空表与非空表的插入、删除代码完全一致:插入首元素与插入任意中间元素执行同一段语句,无需分支。工程实践中一律带头结点。
单链表的两个固有约束:
- 只能沿
next单向遍历。已知结点p无法直接取得其前驱,删除p必须先定位前驱,整体O(n)。 - 尾结点的
next为NULL。任何可能取到p->next的语句都必须判空。
4.3 单循环链表
把尾结点的 next 由 NULL 改为指向表头,即得单循环链表。
循环结构带来的三项变化:
- 求表长不再需要判空。带头结点的单链表求表长须写
while (p != NULL) p = p->next;;单循环链表写成while (p->next != L) p = p->next;,空表时条件立即为假,无需特判。 - 插入尾结点无需分支。在
p之后插入s的两句赋值s->next = p->next; p->next = s;在p为尾结点时照常执行,因为p->next此时就是表头。 - 无法从尾结点沿
next判定结束。遍历条件必须与表头比较而非与NULL比较,写错即死循环。
单循环链表仍不具备反向定位能力,因此尾结点的前驱仍需遍历才能得到。
4.4 双向循环链表
在单循环链表的基础上,每个结点再增加一个 prior 指针,指向前驱;表头的 prior 指向尾结点,尾结点的 next 指向表头,首尾双向相接。
typedef int ElemType;
typedef struct DuLNode {
ElemType data;
struct DuLNode *prior;
struct DuLNode *next;
} DuLNode, *DuLinkList;
它是四种基本形态中表达能力最强的一种,各类定位与修改操作全部压缩到常数步:
| 操作 | 需改动的指针域个数 | 是否需定位前驱 | 是否需判空 |
|---|---|---|---|
在 p 之后插入 s |
4 | 否 | 否 |
在 p 之前插入 s |
4 | 否 | 否 |
删除 p |
2 | 否 | 否 |
| 求前驱 | 0 | 否,直接取 p->prior |
否 |
| 求后继 | 0 | 否,直接取 p->next |
否 |
代价是每结点多占一个指针域。在 64 位地址下一个指针占 8 字节,双向循环链表每结点的固定开销是单链表的两倍。若结点本身只有一个 4 字节的 int 数据域,双向循环链表中指针域占结点总大小的三分之二。这一比例关系决定了链表在嵌入式与缓存敏感场景的实际可用性。
以 p 之前插入 s 与 p 之后插入 s 的对照可以看出双向结构的对称性:
/* 在 p 之后插入 s */
s->next = p->next;
p->next->prior = s;
s->prior = p;
p->next = s;
/* 在 p 之前插入 s */
s->next = p;
p->prior->next = s;
s->prior = p->prior;
p->prior = s;
两段代码互为镜像,差别仅在 next 与 prior 的角色互换。次序约束随镜像一并翻转:在 p 之后插入时必须最后执行 p->next = s;,在 p 之前插入时必须最后执行 p->prior = s;——凡是读取 p 某个指针域旧值的语句,都要排在改写该指针域的语句之前。考察题中常把两者混合给出选项,判断依据正是"哪一条语句最后改写 p"。
4.5 静态链表
静态链表用数组下标代替指针。在不支持动态内存分配的语言或环境中(例如某些嵌入式 C 环境),可用这种方式实现链式存储。
#define MAXSIZE 100
typedef struct {
ElemType data;
int cur; /* 游标:指向下一个结点的下标 */
} Component, SLinkList[MAXSIZE];
实现要点:
- 下标 0 固定作表头,其
cur指向首元结点。 - 空表:
list[0].cur == 0。 - 所有空闲结点串成一条备用链,表头之外用
cur串联,插入时从备用链取一个下标,删除时归还。 - 插入位置 i 的下标计算需要遍历游标链,查找本身是
O(n),比真实指针多一次数组下标读取。
静态链表的全部结点在编译期一次性分配,不支持运行时扩充容量,MAXSIZE 即硬上限。它在教材中的主要作用是说明"指针的本质是一个地址,而地址可以由下标表达"这一事实。
4.6 跳表
跳表在有序链表之上叠加多层索引,使查找、插入、删除的期望复杂度降到 O(log n),且不依赖树的旋转或重平衡。
构成跳表的三个要素:
- 多级索引:每层都是上一层的一个子集,查找时从最高层开始,能右跳则右跳,落到目标区间后下沉一层。
- 随机层数:插入时以概率
p决定新结点是否升到上一层。p取1/2时层数期望为log₂ n,p取1/4时索引层更矮,查找跳数更少但更新向上传播的次数更多。 - 跨度概念:每个结点记录自己到最底层后继的距离,用于判断能否右跳。
跳表用随机化换取了与平衡树同阶的复杂度,且实现只需指针操作,不需要内存布局的严格对齐。Redis 的 ZSet、LevelDB 的 MemTable、Lucene 的倒排索引块均采用跳表。
跳表与平衡 B 树的取舍:跳表全部在内存中,缓存友好,实现简单;B 树把分支因子压到数百,节点可与磁盘页对齐,适合外存索引。
4.7 索引存储与散列存储
这两类属于广义的存储结构,不属于线性表的基本定义,但在讨论"线性表还有哪些存储方式"时需一并说明。
索引存储在数据之外额外建立一张索引表,索引项由关键字与地址构成。线性表的索引须是有序的(稠密索引按关键码排序,稀疏索引只对一部分分块建立),否则二分查找失效。
| 类型 | 特点 | 代价 |
|---|---|---|
| 稠密索引 | 每个记录一个索引项 | 索引与数据等量,维护成本高 |
| 稀疏索引 | 每个分块一个索引项,块内有序 | 定位到块后仍需顺序查找 |
| 多级索引 | 索引本身再建立索引 | 树高降到 O(log_m n),m 为分支因子 |
数据库的 B 树与 B+ 树是多级索引的典型实现:B+ 树的数据只存于叶结点并用链表串起来,叶结点内部有序,这使得范围查询可以直接沿叶链推进。
散列存储按关键字经散列函数直接算出存储地址,把关键字空间映射到地址空间。它放弃了顺序性,因而不支持范围查询与按序遍历,只能做等值查找。线性表在此场景下的实际意义有限:散列表本身是一张独立的表结构而非线性表,其价值在于把等值查找降到期望 O(1)。
五、基本操作
5.1 结点与变量约定
全文统一使用下列符号,代码与图示中的命名保持一致。
| 符号 | 类型 | 含义 |
|---|---|---|
L |
表头指针 | 指向表头结点的指针,带头结点时表头本身不含数据 |
p |
结点指针 | 指向当前操作的基准结点 |
s |
结点指针 | 指向待插入的结点 |
q |
结点指针 | 指向 p 的后继,即插入位置的原占用者 |
i |
整数 | 逻辑序号,从 1 开始的教材约定;数组下标从 0 开始 |
length |
整数 | 当前元素个数 |
在双向循环链表中,四者的关系恒为:
prior next
头结点 <--> A <--> B <--> C
^ |
+-------------------------+
其中 p = A,q = B,s 待插入。插入的目标是让 p 与 s 双向相连,s 与 q 双向相连。
5.2 插入操作的语句对照
以"在 p 之后插入 s"为例,在 p 之前插入只需把 next 与 prior 的角色互换。
| 存储方式 | 操作语句序列 | 需改动指针域 | 是否需判空 |
|---|---|---|---|
| 顺序表 | 后移 i 至 n 的元素,再赋值 |
无 | 需判容量 |
| 单链表 | 2 句 | 2 | 需判 p 为空 |
| 单循环链表 | 与单链表相同 | 2 | 需判 p 为空 |
| 双向非循环链表 | 4 句 | 4 | 需判 p 为空 |
| 双向循环链表 | 与双向非循环链表相同 | 4 | 需判 p 为空 |
"在 p 之前插入 s"的对照:
| 存储方式 | 操作语句序列 | 需改动指针域 |
|---|---|---|
| 单链表 | 2 句 | 2 |
| 单循环链表 | 与单链表相同 | 2 |
| 双向非循环链表 | 4 句 | 4 |
| 双向循环链表 | 与双向非循环链表相同 | 4 |
两侧的差别有两处,性质完全不同:
- 单向链表没有"仅凭
p在其之前插入"这一操作。p的前驱无法由p取得,必须先沿next定位到前驱q,再执行与"在q之后插入"完全相同的两句。双向循环链表把这一前置查找压缩到常数步,这正是prior域的收益所在。 - 双向结构两侧互为镜像,差别仅在
next与prior的角色互换。以p为基准,在p之后插入的末句是p->next = s;,在p之前插入的末句相应为p->prior = s;;其余两句取自p的另一侧。完整的镜像写法见 4.4。
四种链表形态的插入语句对比:
/* 单链表:两句 */
s->next = p->next;
p->next = s;
/* 双向循环链表:四句 */
s->next = p->next;
p->next->prior = s;
s->prior = p;
p->next = s;
四句之间的次序约束是本节的核心内容。逐句分析每条语句读取了哪些值:
| 语句 | 读取 | 写入 | 必须早于 |
|---|---|---|---|
s->next = p->next; |
p->next 的旧值 |
s->next |
第 4 句 |
p->next->prior = s; |
p->next 的旧值 |
该结点的 prior |
第 4 句 |
s->prior = p; |
无旧值依赖 | s->prior |
无 |
p->next = s; |
无旧值依赖 | p->next |
须最后 |
由此得到两条硬约束:
- 第 1 句必须早于第 4 句。若
p->next已被改写为s,第 1 句等价于s->next = s,s形成自环。 - 第 2 句必须早于第 4 句。若
p->next已是s,第 2 句等价于s->prior = s,s形成自环。
第 3 句读取的只有 p 本身,不依赖任何被改写的值,因此可放在任意位置。综合结论是:凡是把 p->next 写入 s 的语句,都必须排在第 4 句之前;第 4 句只应出现在需要改写 p 的语句的最后。
这一约束可以推广为链表插入的通用准则:先修改新结点 s 的指针,再修改原结点的指针;凡是读取某个字段旧值的语句,都排在写入该字段的语句之前。
5.3 删除操作的语句对照
删除结点 p 的通用步骤是"绕过 p,再释放 p"。所需语句数由前驱是否可达决定:
| 存储方式 | 操作语句序列 | 需改动指针域 | 实际复杂度 |
|---|---|---|---|
| 顺序表 | 后移 i 至 n - 1 的元素,length 减一 |
无 | O(n) |
| 单链表 | q->next = p->next; 换行 free(p);,其中 q 为 p 的前驱 |
1 | O(n),前驱须遍历 |
| 单循环链表 | 与单链表相同 | 1 | O(n),前驱须遍历 |
| 双向循环链表 | p->prior->next = p->next; 换行 p->next->prior = p->prior; 换行 free(p); |
2 | O(1) |
/* 双向循环链表删除 p */
p->prior->next = p->next; /* 前驱绕开 p */
p->next->prior = p->prior; /* 后继绕开 p */
free(p); /* 释放 p */
删除的次序约束比插入宽松得多:p->prior->next = p->next; 写入的是 p->prior 的 next 域,不影响 p->next;p->next->prior = p->prior; 写入的是 p->next 的 prior 域,同样不影响 p->next。两条语句读取的都是 p 自身的字段,互不干扰,因此可以任意顺序执行。free(p) 必须放在最后。
值得注意的是:删除时 p 的前驱与后继都可直接取得,循环与非循环结构在这里没有区别,同样不需要边界判断,因为 p->prior->next 与 p->next->prior 在两种结构下都指向有效结点。循环结构在删除上的收益体现为不需要额外判空,而非语句数量。
5.4 查找、遍历与求长度
| 存储方式 | 按位查找语句 | 求表长 | 按值查找 |
|---|---|---|---|
| 顺序表 | return L->data[i - 1]; |
return L->length; |
循环比较 n 次 |
| 单链表 | 沿 next 前进 i - 1 次 |
while (p != NULL) p = p->next; |
边前进边比较 |
| 单循环链表 | 同上 | while (p->next != L) p = p->next; |
边前进边比较 |
| 双向循环链表 | 沿较短方向前进 | while (p->next != L) p = p->next; |
边前进边比较 |
按位查找时双向循环链表有一项额外优化:从表头出发,若 i > length / 2,改为沿 prior 从尾结点前进。定位步数从最多 n - 1 步降到最多 n / 2 步。
各结构的遍历终止条件必须与结构匹配,写错即死循环:
| 结构 | 终止条件 | 写错的后果 |
|---|---|---|
| 顺序表 | i < L->length |
越界访问 |
| 单链表 | p != NULL |
对 NULL 解引用,进程崩溃 |
| 单循环链表 | p->next != L |
尾结点 next 指向表头,条件恒真,死循环 |
| 双向循环链表 | p->next != L |
同上 |
六、演算示例
6.1 双向循环链表插入的四句操作
设双向循环链表带头结点,当前结构为 头结点 ↔ p ↔ q ↔ C(首尾相接),要把 s 插入到 p 之后。
图中橙色箭头表示该句新设立的指针,灰色虚线表示已被取代的旧指针。四句执行完毕后,p 与 s、s 与 q 之间的双向关系全部建立,链表恢复为合法的双向循环结构。
6.2 语句次序错误的后果
把第 4 句 p->next = s; 提前到第 2 句 p->next->prior = s; 之前,会发生什么:
执行前: p ↔ q s(独立)
第 4 句: p → s p 的后继已被改写为 s
s ↔ q
第 2 句: s → s p->next 现在是 s,该句等价于 s->prior = s
结果是 s 的 prior 与 next 都指向自身,s 形成长度为 1 的环。后果有两层:
- 直接可见:沿
next从s出发会无限停留在s自身,求表长、遍历、释放结点全部出错。 - 间接可见:链表的有效长度少了 1,且泄漏。若结点的
data域含资源描述符(文件句柄、内存块),free(s)后释放逻辑会误判该结点自指而重复释放。
同类错误还有一种对称形式:把 s->next = p->next; 放在第 4 句之后,同样得到 s->next = s。两者的共同特征都是"读取 p->next 旧值的语句排在了改写 p->next 的语句之后"。
6.3 顺序表插入的元素移动
对比链式存储,顺序表在下标 2 处插入元素 25 时,不需要任何指针操作,但必须把下标 2 及其之后的全部元素后移一位。
后移的元素个数为 n - i + 1,最坏情况(i = 1)需要移动全部 n 个元素。移动必须从后向前执行:若从下标 n - 1 向 i 递增搬运,则 data[j] = data[j - 1] 会在 data[j - 1] 尚未被搬走之前覆盖它,导致多处元素被写成重复值。
七、复杂度分析
7.1 复杂度对照表
以 n 为元素个数、i 为操作位置:
| 操作 | 顺序表 | 单链表 | 单循环链表 | 双向非循环链表 | 双向循环链表 |
|---|---|---|---|---|---|
| 按位查找 | O(1) |
O(n) |
O(n) |
O(n) |
O(n) |
| 按值查找 | O(n) |
O(n) |
O(n) |
O(n) |
O(n) |
| 插入:需先定位 | O(n) |
O(n) |
O(n) |
O(n) |
O(n) |
| 插入:已知位置 | O(n) |
O(1) |
O(1) |
O(1) |
O(1) |
| 删除:需先定位 | O(n) |
O(n) |
O(n) |
O(n) |
O(n) |
| 删除:已知结点 | O(n) |
O(1) |
O(1) |
O(1) |
O(1) |
| 求表长 | O(1) |
O(n) |
O(n) |
O(n) |
O(n) |
| 空间 | 连续 n 个单元 |
n 个指针域 |
n 个指针域 |
2n 个指针域 |
2n 个指针域 |
| 扩容 | 重新分配并复制 | 无需扩容 | 无需扩容 | 无需扩容 | 无需扩容 |
"需先定位"与"已知位置"两行的差异是本章最重要的结论:只有"已知结点指针"这一条件下链式存储才有复杂度优势。而按位查找一行始终为 O(n),说明链式存储并未改善随机访问。
7.2 空间开销
以 64 位平台、结点只含一个 4 字节 int 数据域为例:
| 结构 | 数据域 | 指针域 | 结点总大小 | 指针占比 |
|---|---|---|---|---|
| 顺序表元素槽 | 4 字节 | 0 | 4 字节 | 0 |
| 单链表结点 | 4 字节 | 8 字节 | 16 字节(含对齐) | 约 50% |
| 双向循环链表结点 | 4 字节 | 16 字节 | 24 字节(含对齐) | 约 67% |
当数据域较大(例如一个含多个字段的记录)时,指针占比会显著下降,链式存储的空间劣势自然缓解。这也是结构体中把结点按字段内联在同一分配块内(而非单独 malloc 数据与指针)可以避免额外对齐开销的原因。
双向循环链表的额外代价还有一项:每次插入与删除要写 4 个而非 2 个指针域。在多核环境下这意味着更多的缓存行失效。
7.3 缓存局部性与指针追逐
复杂度表把常数因子抽象掉了,但常数因子的差距在现代硬件上比渐近复杂度更重要。
| 维度 | 顺序表 | 链表 |
|---|---|---|
| 内存布局 | 连续,硬件预取器可提前加载 | 离散,地址须运行时解引用 |
| 每结点的缓存行利用率 | 一个缓存行装约 8 个 int |
一个 24 字节结点独占约一条缓存行的一半 |
| 遍历 n 个元素的访存次数 | 约 n / 16 次 |
n 次,每次可能一次 cache miss |
| 顺序扫描 | 接近内存带宽上限 | 受随机访存延迟支配 |
指针追逐(pointer chasing)指遍历链表时,每次前进都需要先从当前结点读出指针域,才能确定下一次访问的地址。CPU 无法提前发起这些访存,因为地址依赖前一次的结果。在典型的 100 ns 级内存延迟下,遍历百万级链表与遍历同规模的数组可能相差两个数量级。
实践含义:链式存储的 O(1) 插入删除优势只有在数据已在缓存中、且操作以插入删除为主时才成立。一旦涉及大规模顺序扫描或随机访问,顺序表通常更快。这也是各语言标准库普遍把动态数组而非链表作为默认序列容器的原因。
八、参考实现
8.1 C 顺序表
#include <stdio.h>
#include <stdlib.h>
typedef int ElemType;
typedef struct {
ElemType *data;
int length;
int capacity;
} SqList;
SqList *sq_list_new(int capacity) {
SqList *L = malloc(sizeof(SqList));
if (!L) return NULL;
L->data = malloc(sizeof(ElemType) * capacity);
if (!L->data) { free(L); return NULL; }
L->length = 0;
L->capacity = capacity;
return L;
}
void sq_list_free(SqList *L) {
free(L->data);
free(L);
}
/* 在逻辑序号 i 处插入元素 x,i 取 1 到 length 加 1 */
int sq_list_insert(SqList *L, int i, ElemType x) {
if (i < 1 || i > L->length + 1) return 0;
if (L->length == L->capacity) {
int cap = L->capacity * 2;
ElemType *nd = realloc(L->data, sizeof(ElemType) * cap);
if (!nd) return 0;
L->data = nd;
L->capacity = cap;
}
/* 必须从后向前移动,否则会覆盖尚未搬走的数据 */
for (int j = L->length; j >= i; j--) {
L->data[j] = L->data[j - 1];
}
L->data[i - 1] = x;
L->length++;
return 1;
}
int sq_list_delete(SqList *L, int i, ElemType *out) {
if (i < 1 || i > L->length) return 0;
*out = L->data[i - 1];
for (int j = i; j < L->length; j++) {
L->data[j - 1] = L->data[j];
}
L->length--;
return 1;
}
void sq_list_print(const SqList *L) {
for (int i = 0; i < L->length; i++) {
printf("%d ", L->data[i]);
}
printf("\n");
}
8.2 Python 单链表
class Node:
"""单链表结点。__slots__ 避免为每个结点额外建立实例字典。"""
__slots__ = ("data", "next")
def __init__(self, data=None, nxt=None):
self.data = data
self.next = nxt
class LinkedList:
"""带头结点的单链表。表头 L 本身不含数据,L.next 指向首元结点。"""
def __init__(self):
self._head = Node()
self._size = 0
def __len__(self):
return self._size
def is_empty(self):
return self._size == 0
def _locate(self, index):
"""返回逻辑序号 index 对应的结点前驱,index 从 0 开始。"""
if index < 0 or index >= self._size:
raise IndexError("index out of range")
prev = self._head
for _ in range(index):
prev = prev.next
return prev
def insert(self, index, value):
"""在逻辑序号 index 处插入 value,index 从 0 开始。"""
if index < 0 or index > self._size:
raise IndexError("index out of range")
prev = self._locate(index) if index < self._size else self._node_at(self._size)
node = Node(value, prev.next)
prev.next = node
self._size += 1
def _node_at(self, index):
node = self._head
for _ in range(index + 1):
node = node.next
return node
def remove(self, index):
if index < 0 or index >= self._size:
raise IndexError("index out of range")
prev = self._locate(index)
node = prev.next
prev.next = node.next
self._size -= 1
return node.data
def __iter__(self):
node = self._head.next
while node is not None:
yield node.data
node = node.next
8.3 C 双向循环链表
#include <stdio.h>
#include <stdlib.h>
typedef int ElemType;
typedef struct DuLNode {
ElemType data;
struct DuLNode *prior;
struct DuLNode *next;
} DuLNode, *DuLinkList;
/* 建表:返回带头结点的双向循环链表头指针,空表时头结点自指 */
DuLinkList list_init(void) {
DuLinkList L = malloc(sizeof(DuLNode));
if (!L) return NULL;
L->next = L;
L->prior = L;
return L;
}
/* 在 p 之后插入 s:四句的次序不可交换 */
int list_insert_after(DuLNode *p, ElemType x) {
DuLNode *s = malloc(sizeof(DuLNode));
if (!s) return 0;
s->data = x;
s->next = p->next; /* 1. s 接住 p 的原后继 */
p->next->prior = s; /* 2. 原后继的前驱改为 s,须早于第 4 句 */
s->prior = p; /* 3. s 的前驱指向 p,位置任意 */
p->next = s; /* 4. p 的后继改为 s,必须最后 */
return 1;
}
/* 在 p 之前插入 s:与上面的语句互为镜像 */
int list_insert_before(DuLNode *p, ElemType x) {
DuLNode *s = malloc(sizeof(DuLNode));
if (!s) return 0;
s->data = x;
s->next = p;
p->prior->next = s;
s->prior = p->prior;
p->prior = s;
return 1;
}
/* 删除 p:两条改写语句次序可交换,释放必须最后 */
int list_delete(DuLNode *p) {
p->prior->next = p->next;
p->next->prior = p->prior;
free(p);
return 1;
}
/* 遍历:从 L->next 起,遇到回到 L 时终止 */
void list_print(DuLinkList L) {
DuLNode *p = L->next;
while (p != L) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
/* 表长:统计数据结点的个数,头结点不计入 */
int list_length(DuLinkList L) {
int n = 0;
DuLNode *p = L->next;
while (p != L) { n++; p = p->next; }
return n;
}
/* 按位查找:i 大于表长一半时从尾结点反向前进 */
DuLNode *list_locate(DuLinkList L, int i) {
int n = list_length(L);
if (i < 1 || i > n) return NULL;
DuLNode *p = L;
if (i <= n / 2) {
for (int k = 0; k < i; k++) p = p->next;
} else {
for (int k = 0; k < n - i + 1; k++) p = p->prior;
}
return p;
}
void list_destroy(DuLinkList L) {
DuLNode *p = L->next;
while (p != L) {
DuLNode *q = p->next;
free(p);
p = q;
}
free(L);
}
8.4 运行结果
双向循环链表的建表、插入、删除与遍历:
初始:空表,头结点自指,遍历一次即停
插入 10 20 30 40 50 后遍历:10 20 30 40 50
在结点 30 之后插入 35 后遍历:10 20 30 35 40 50
删除结点 35 后遍历:10 20 30 40 50
表长:5
五次插入均以"在尾结点之后插入"的方式进行。空表时头结点自身即尾结点,首次插入的 10 落在头结点与环之间;此后每次从当前尾结点后追加,插入次序与遍历次序一致。若改为在首元结点之前插入(list_insert_before),则每次都插到表头之后,遍历结果将是 50 40 30 20 10。
按 6.1 的示意结构,在 p = 30 之后插入 35(p 的原后继为 40)的四句执行过程:
| 语句 | 执行前 | 执行后 |
|---|---|---|
| 无 | 30 ↔ 40,35 独立 |
不变 |
s->next = p->next; |
30 → 40 |
35 → 40 |
p->next->prior = s; |
40.prior = 30 |
40.prior = 35 |
s->prior = p; |
无 | 35.prior = 30 |
p->next = s; |
30 → 40 |
30 → 35 |
结果为 30 ↔ 35 ↔ 40,其中 35 的 prior 指向 30、next 指向 40,双向关系完整。表长由 5 变为 6,再删除 35 后回到 5。
九、易错点与边界
-
双向循环链表插入四句的次序。
p->next->prior = s;必须早于p->next = s;,s->next = p->next;同样必须早于第 4 句。违反任一条都会得到s的自环。这是考察题最高频的陷阱。 -
删除语句的次序宽松,不代表插入语句也宽松。删除的两条语句写入不同的字段、读取相同的来源,可以交换;插入的四句中后两条存在读写冲突,不可交换。两者的结论不同,混用是常见错误。
-
循环结构的遍历终止条件。写成
while (p != NULL)在循环链表中永不成立,因为尾结点的next指向表头而不是NULL。必须写成与表头L比较。 -
空表与非空表的插入差异。不带头结点时,向空表插入首元素需要单独分支:
if (L == NULL) { L = s; s->next = NULL; }。带头结点可完全避免该分支,编写代码时应统一采用带头结点。 -
顺序表插入必须从后向前移动。从前向后移动会覆盖尚未搬走的数据,导致部分元素被写成重复值。
-
顺序表的下标约定。教材用逻辑序号从 1 开始,数组下标从 0 开始,两者相差一。混用会在边界位置(
i = 1或i = length + 1)出错。 -
length与capacity不可混用。插入前应判length == capacity并扩容,删除后不必立刻释放空间,length减一即可。 -
单链表删除的返回值时机。若
p是待删除结点的唯一引用,须在释放前把p->data取出返回,否则返回的是已释放内存中的值。 -
链表的
O(1)是有前提的。若只知道逻辑序号仍需O(n)遍历。工程中选择链表前需确认调用方能否直接给出结点指针。 -
free(p)必须最后执行。删除时若先释放再改写其他结点的指针,会读取已释放内存,属于未定义行为。 -
缓存局部性未被复杂度表反映。小规模、缓存友好的场景下双向循环链表确有优势;规模上万且以遍历为主时,顺序表通常更快。
-
静态链表不支持运行时扩充。
MAXSIZE是硬上限,备用链耗尽后插入失败,需在插入前判断备用链是否为空。
十、拓展与应用
10.1 标准库中的对应结构
| 语言与容器 | 对应的存储方式 | 关键特征 |
|---|---|---|
C++ std::vector |
顺序表 | 连续存储,扩容按倍数增长,均摊 O(1) 追加 |
C++ std::list |
双向循环链表 | 已知位置插入删除 O(1),实现了 std::swap |
C++ std::forward_list |
单链表 | 每结点一个指针,无 size(),为省内存而牺牲能力 |
Java ArrayList |
顺序表 | 内部持有数组,按下标访问 O(1) |
Java LinkedList |
双向循环链表 | 实现了 Deque 接口,未实现 RandomAccess |
Python list |
顺序表 | 元素为对象引用,append 均摊 O(1) |
Go container/list |
双向循环链表 | 侵入式设计,结点自行携带链接字段 |
C++ std::forward_list::insert_after |
单链表 | 与本节 5.2 的两句写法完全一致 |
Java 的 LinkedList 未实现 RandomAccess 接口,Collections.sort 遇到它会先转成数组排序再写回,这一设计直接源于链式存储无法随机访问的固有代价。
C++ 标准库的实现者普遍建议默认使用 vector 而非 list,理由即 8.3 所述:链表只在特定访问模式下才具备实际优势,而顺序表的通用性与缓存友好性在绝大多数场景下胜出。
10.2 侵入式链表
侵入式链表(intrusive linked list)把链接字段直接嵌入数据结点本身,不再单独为每个元素分配一个链接结点:
typedef struct Task {
int id;
struct Task *next; /* 链接字段内嵌在数据结点中 */
struct Task *prev;
} Task;
对比非侵入式实现中每个数据需要两次分配(数据块 + 链接块),侵入式实现只需一次分配,且链接字段与数据字段在同一缓存行内,对遍历友好。
Linux 内核的 list_head 是这一模式的经典实现:list_head 只是一个含两个指针的结构体,通过 container_of 宏反推出包含它的宿主结构体指针。这使得任意类型的结构体都能接入统一的双向循环链表。
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
C++ 的侵入式容器库(如 Boost.Intrusive)把这一模式做成模板化的成员函数钩子,避免了继承带来的布局开销。
10.3 无锁链表
在高并发场景下,用互斥锁保护链表的读写会成为瓶颈,无锁数据结构允许线程在不加锁的前提下并发操作。
Harris 算法在每个结点上用一个额外的标记位(tag bit)标注该结点是否被逻辑删除,删除操作把后继指针原子的"取出"与"比较并置位"合并为一次 CAS:
Node *get_next(Node *node) {
Node *next = node->next; /* 原子读取,含标记位 */
if (has_tag(next)) { /* 结点已被逻辑删除 */
node->next = next; /* 协助删除并前移 */
return get_next(next);
}
return untag(next);
}
无锁链表避免了阻塞与优先级反转,但引入了 ABA 问题(指针值被复用导致 CAS 误判)与内存回收难题(被删除结点的内存何时释放)。工程实现通常配合版本号或 hazard pointer 解决。
10.4 XOR 链表与空间压缩
XOR 链表把前驱地址与后继地址按位异或后存入一个字段,遍历时由当前地址与下一地址异或反算出前驱地址:
node->link = prev_addr ^ next_addr
遍历时已知头结点地址与链表长度,用 prev ^ node->link 即可依次推出每个结点的地址。单链表因此只需一个指针域,省下一个字段。
这一结构的实践价值有限,原因有三:
- 无法按值查找。从一个结点出发无法判断相邻结点的数据值,只能顺序遍历并记录上一地址的候选;而按值查找正是线性表的基本操作之一。
- 整表异或校验。整条链表的
link字段异或结果为 0,只能验证完整性,无法定位出错的结点。 - 实现复杂且缺乏标准支持。
XOR 链表的主要用途是密码学场景中隐藏内存中的指针值,避免地址被直接读取。通用实现中不推荐使用。
附录
术语中英文对照
| 中文术语 | 英文名称 | 说明 |
|---|---|---|
| 线性表 | linear list | 由 n 个数据元素组成的有限序列 |
| 存储结构 | storage structure | 逻辑关系在计算机内的具体实现 |
| 顺序存储 | sequential storage | 元素存于地址连续的存储区 |
| 链式存储 | linked storage | 元素存于任意地址,由指针建立关系 |
| 顺序表 | sequential list | 顺序存储的线性表 |
| 单链表 | singly linked list | 仅含 next 指针的链表 |
| 循环链表 | circular linked list | 尾结点的指针指回表头 |
| 双向链表 | doubly linked list | 同时含 prior 与 next 的链表 |
| 头结点 | head node | 位于首元结点之前、不含数据的辅助结点 |
| 首元结点 | first node | 线性表中第一个数据元素所在的结点 |
| 尾结点 | last node | 线性表中最后一个数据元素所在的结点 |
| 结点 | node | 数据域与指针域的组合 |
| 数据域 | data field | 存放数据元素的字段 |
| 指针域 | pointer field | 存放相邻结点地址的字段 |
| 直接前驱 | immediate predecessor | 与某结点在逻辑上紧邻的前一个元素 |
| 直接后继 | immediate successor | 与某结点在逻辑上紧邻的后一个元素 |
| 按位查找 | positional search | 按逻辑序号查找元素 |
| 按值查找 | keyword search | 按数据值查找元素 |
| 静态链表 | static linked list | 用数组下标代替指针的链表实现 |
| 游标 | cursor | 静态链表中存放下一个结点下标的字段 |
| 跳表 | skip list | 多层索引的有序链表 |
| 侵入式链表 | intrusive list | 链接字段内嵌于数据结点的链表 |
| 索引存储 | indexed storage | 附加索引表记录地址的存储方式 |
| 散列存储 | hashed storage | 按关键字函数直接计算地址的存储方式 |
| 指针追逐 | pointer chasing | 遍历链表时逐次解引用取址的过程 |

浙公网安备 33010602011771号