Lecture 5:线性表的链式实现
408 学习笔记:Lecture 5:线性表的链式实现。保留原有知识内容、公式与图示;有对应原件时提供查看和下载入口。
本页目录
5.1 单链表的实现
NOTE · 代码口径 本文沿用 408 教材常见的 C++ 接口写法(
bool、引用参数&L),并保留malloc/free以便与课件对照。实际工程中应包含<cstdlib>并保证malloc与free成对,或统一改用new/delete、智能指针和容器。若写纯 C,应把引用参数改成相应的指针或二级指针接口。
5.1.1 顺序表和单链表对比
顺序表
- 优点:
- 可以随机存取;
- 存储密度高。
- 缺点:
- 要求大片连续空间;
- 改变容量不方便;
- 插入、删除需要移动大量元素。
单链表
- 优点:
- 不要求大片连续空间;
- 改变容量比较方便;
- 插入、删除时不需要整体移动元素。
- 缺点:
- 不可随机存取;
- 需要额外空间存放指针。
理解
- 顺序表靠“地址连续”体现逻辑关系。
- 单链表靠“指针”体现逻辑关系。
- 顺序表适合按位查找,单链表适合频繁插入删除。
5.1.2 单链表结点定义
定义:#单链表结点
描述: 单链表的每个结点通常由数据域和指针域组成,数据域存放元素值,指针域指向下一个结点。
代码
struct LNode {
ElemType data; // 数据域:存放一个数据元素
struct LNode *next; // 指针域:指向下一个结点
};
解释
data保存当前结点的数据。next保存下一个结点的地址。- 如果当前结点是最后一个结点,则:
p->next == NULL
5.1.3 typedef 写法
重命名语法
typedef <数据类型> <别名>;
教材常用写法
typedef struct LNode {
ElemType data;
struct LNode *next;
} LNode, *LinkList;
解释
LNode表示结点类型。LinkList表示指向结点的指针类型。- 也就是说:
LinkList L;
等价于:
LNode *L;
5.1.4 申请一个新结点
代码
struct LNode *p = (struct LNode *)malloc(sizeof(struct LNode));
解释
malloc在内存中申请一个结点大小的空间。p指向新申请的结点。- 若申请失败,
p可能为NULL,实际写代码时应判断。
5.2 不带头结点的单链表
5.2.1 初始化
定义:#不带头结点
描述: 不带头结点的单链表中,头指针直接指向第一个数据结点;若链表为空,则头指针为
NULL。
代码
typedef struct LNode {
ElemType data;
struct LNode *next;
} LNode, *LinkList;
bool InitList(LinkList &L) {
L = NULL;
return true;
}
解释
- 空表没有任何结点,所以头指针为
NULL。 - 第一个数据结点就是头指针指向的结点。
5.2.2 判空
写法 1
bool Empty(LinkList L) {
if (L == NULL) {
return true;
} else {
return false;
}
}
写法 2
bool Empty(LinkList L) {
return (L == NULL);
}
解释
- 不带头结点时: $$ L=NULL $$ 表示空表。
5.3 带头结点的单链表
5.3.1 头指针和头结点
定义:#头指针
描述: 头指针是指向链表第一个结点的指针,是链表的必要元素。
定义:#头结点
描述: 头结点是放在第一个数据结点之前的附加结点,通常不存储有效数据,用于统一插入和删除操作。
区别
- 头指针:
- 必须存在;
- 用来标识整条链表;
- 链表为空时也存在。
- 头结点:
- 可有可无;
- 位于第一个数据结点之前;
- 数据域可以不存储任何信息;
- 主要作用是让表头插入和删除更方便。
带头结点空表
- 无论链表是否为空,头指针都指向头结点。
- 空表中,头结点的指针域为空:
L->next == NULL
为什么推荐带头结点
- 不带头结点时,插入第一个结点和插入其他结点的代码不同。
- 带头结点后,空表和非空表、表头和非表头位置的处理更统一。
5.4 单链表的插入
5.4.1 按位序插入:带头结点
定义:#按位序插入
描述:
ListInsert(&L,i,e)表示在链表 $L$ 的第 $i$ 个位置插入元素 $e$。
核心思路
- 找到第 $i-1$ 个结点。
- 将新结点插入到第 $i-1$ 个结点之后。
代码
bool ListInsert(LinkList &L, int i, ElemType e) {
if (i < 1) {
return false;
}
LNode *p = L; // p 指向头结点
int j = 0; // 头结点是第 0 个结点
while (p != NULL && j < i - 1) {
p = p->next;
j++;
}
if (p == NULL) {
return false;
}
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) {
return false;
}
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
解释
p=L:从头结点开始扫描。j=0:头结点视为第 0 个结点。- 循环结束时,若合法,则
p指向第 $i-1$ 个结点。 - 插入的关键两步:
s->next = p->next;
p->next = s;
注意
- 两行顺序不能颠倒。
- 如果先写:
p->next = s;
则原来 p 后面的链会断掉。
图示解读
- 图中先找到第 $i-1$ 个结点 $p$,新结点 $s$ 保存元素 $e$。
- 先令
s->next=p->next保存原后继,再令p->next=s把新结点接入链中;图中的两条新指向正对应这两步。
5.4.2 按位序插入:不带头结点
特点
- 不带头结点时,插入第 1 个结点需要特殊处理。
- 因为插入第 1 个结点会改变头指针。
代码
bool ListInsert(LinkList &L, int i, ElemType e) {
if (i < 1) {
return false;
}
if (i == 1) {
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) {
return false;
}
s->data = e;
s->next = L;
L = s;
return true;
}
LNode *p = L;
int j = 1;
while (p != NULL && j < i - 1) {
p = p->next;
j++;
}
if (p == NULL) {
return false;
}
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) {
return false;
}
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
结论
- 不带头结点的代码更麻烦。
- 推荐使用带头结点的单链表。
5.4.3 后插操作
定义:#后插操作
描述: 后插操作是在已知结点 $p$ 后面插入新结点 $s$。
代码
bool InsertNextNode(LNode *p, ElemType e) {
if (p == NULL) {
return false;
}
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) {
return false;
}
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
时间复杂度
- 已知结点 $p$,只需修改指针: $$ O(1) $$
5.4.4 前插操作
定义:#前插操作
描述: 前插操作是在已知结点 $p$ 前面插入元素 $e$。
普通思路
- 单链表无法直接找到 $p$ 的前驱。
- 若从头开始找 $p$ 的前驱,时间复杂度为: $$ O(n) $$
巧妙写法:后插 + 交换数据
- 在 $p$ 后面插入新结点 $s$;
- 把 $p$ 的数据复制到 $s$;
- 把 $e$ 放入 $p$ 的数据域;
- 逻辑效果等价于在 $p$ 前面插入 $e$。
代码
bool InsertPriorNode(LNode *p, ElemType e) {
if (p == NULL) {
return false;
}
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) {
return false;
}
s->next = p->next;
p->next = s;
s->data = p->data;
p->data = e;
return true;
}
时间复杂度
- 已知结点 $p$,用“后插 + 交换数据”实现前插: $$ O(1) $$
注意
- 这种方法改变的是结点中的数据内容,而不是实际把新结点放到 $p$ 的物理前面。
- 在只关心线性表逻辑顺序时,效果是正确的。
5.5 单链表的删除
5.5.1 按位序删除:带头结点
定义:#按位序删除
描述:
ListDelete(&L,i,&e)表示删除表 $L$ 中第 $i$ 个位置的元素,并用 $e$ 返回被删除元素的值。
核心思路
- 找到第 $i-1$ 个结点 $p$。
- 令 $q=p->next$ 指向被删除结点。
- 让 $p$ 跳过 $q$。
- 释放 $q$。
代码
bool ListDelete(LinkList &L, int i, ElemType &e) {
if (i < 1) {
return false;
}
LNode *p = L;
int j = 0;
while (p != NULL && j < i - 1) {
p = p->next;
j++;
}
if (p == NULL || p->next == NULL) {
return false;
}
LNode *q = p->next;
e = q->data;
p->next = q->next;
free(q);
return true;
}
解释
p指向第 $i-1$ 个结点。q指向第 $i$ 个结点,也就是要删除的结点。p->next=q->next将 $q$ 从链中断开。free(q)释放被删除结点。
5.5.2 删除指定结点 p
普通思路
- 单链表删除结点 $p$,需要找到 $p$ 的前驱。
- 如果 $p$ 是最后一个结点,只能从表头开始查找前驱。
- 时间复杂度: $$ O(n) $$
特殊技巧:删除 p 的后继并交换数据
- 如果 $p$ 不是最后一个结点,可以将后继结点的数据复制到 $p$ 中,再删除后继结点。
代码
bool DeleteNode(LNode *p) {
if (p == NULL || p->next == NULL) {
return false;
}
LNode *q = p->next;
p->data = q->data;
p->next = q->next;
free(q);
return true;
}
时间复杂度
- 若 $p$ 不是最后一个结点: $$ O(1) $$
- 若 $p$ 是最后一个结点,必须找前驱: $$ O(n) $$
注意
- 这个技巧不能直接删除最后一个结点,因为最后一个结点没有后继。
5.6 单链表的查找
5.6.1 按位查找
定义:#GetElem
描述: 按位查找是查找链表中第 $i$ 个结点。
带头结点代码
LNode *GetElem(LinkList L, int i) {
if (i < 0) {
return NULL;
}
LNode *p = L;
int j = 0;
while (p != NULL && j < i) {
p = p->next;
j++;
}
return p;
}
解释
- 当 $i=0$ 时,返回头结点。
- 当 $i=1$ 时,返回第一个数据结点。
- 单链表不能随机访问,只能从头开始顺序查找。
时间复杂度 $$ O(n) $$
5.6.2 按值查找
定义:#LocateElem
描述: 按值查找是在链表中查找数据域等于 $e$ 的结点。
代码
LNode *LocateElem(LinkList L, ElemType e) {
LNode *p = L->next;
while (p != NULL && p->data != e) {
p = p->next;
}
return p;
}
解释
- 从第一个数据结点开始查找。
- 找到则返回该结点指针。
- 找不到则返回
NULL。
时间复杂度 $$ O(n) $$
5.6.3 求表长
定义:#Length
描述: 求表长是统计单链表中数据结点的个数。
代码
int Length(LinkList L) {
int len = 0;
LNode *p = L;
while (p->next != NULL) {
p = p->next;
len++;
}
return len;
}
解释
p=L从头结点开始。- 每经过一个数据结点,
len++。 - 当
p->next==NULL时,说明已经到达尾结点。
时间复杂度 $$ O(n) $$
5.7 单链表的建立
5.7.1 尾插法
定义:#尾插法
描述: 尾插法每次把新结点插入到链表尾部,可以正向建立单链表。
代码
LinkList List_TailInsert(LinkList &L) {
int x;
L = (LinkList)malloc(sizeof(LNode));
LNode *s;
LNode *r = L; // r 始终指向表尾结点
scanf("%d", &x);
while (x != 9999) {
s = (LNode *)malloc(sizeof(LNode));
s->data = x;
r->next = s;
r = s;
scanf("%d", &x);
}
r->next = NULL;
return L;
}
解释
r是表尾指针。- 每输入一个新元素,就把新结点接到
r后面。 - 然后令
r=s,让r继续指向新的尾结点。 - 最后尾结点指针域置空:
r->next = NULL;
特点
- 建立出来的链表顺序与输入顺序一致。
- 时间复杂度: $$ O(n) $$
5.7.2 头插法
定义:#头插法
描述: 头插法每次把新结点插入到头结点之后,因此建立出来的链表顺序与输入顺序相反。
代码
LinkList List_HeadInsert(LinkList &L) {
int x;
LNode *s;
L = (LinkList)malloc(sizeof(LNode));
L->next = NULL;
scanf("%d", &x);
while (x != 9999) {
s = (LNode *)malloc(sizeof(LNode));
s->data = x;
s->next = L->next;
L->next = s;
scanf("%d", &x);
}
return L;
}
解释
- 新结点总是插在头结点后面。
- 后输入的结点在前面。
- 因此头插法可以用于链表逆置。
时间复杂度 $$ O(n) $$
注意
- PDF 中强调:头插法得到的是链表的逆置。
5.8 双链表
5.8.1 双链表结点定义
定义:#双链表
描述: 双链表的每个结点有两个指针域,分别指向前驱结点和后继结点。
代码
typedef struct DNode {
ElemType data;
struct DNode *prior;
struct DNode *next;
} DNode, *DLinkList;
解释
prior指向前驱结点。next指向后继结点。- 双链表可以向后遍历,也可以向前遍历。
5.8.2 双链表初始化:带头结点
代码
bool InitDLinkList(DLinkList &L) {
L = (DNode *)malloc(sizeof(DNode));
if (L == NULL) {
return false;
}
L->prior = NULL;
L->next = NULL;
return true;
}
解释
- 带头结点的空双链表中,头结点存在。
- 头结点没有前驱。
- 头结点后面暂时没有数据结点。
5.8.3 双链表插入
定义:#双链表插入
描述: 在双链表中,可以在已知结点 $p$ 后面插入结点 $s$。
图示解读
- 插入前,$p$ 的后继为 $c$;插入后应形成 $p\leftrightarrow s\leftrightarrow c$。
- 必须维护四个方向关系:
s->next指向 $c$,$c$ 的prior指向 $s$,s->prior指向 $p$,$p$ 的next指向 $s$。
代码
bool InsertNextDNode(DNode *p, DNode *s) {
if (p == NULL || s == NULL) {
return false;
}
s->next = p->next;
if (p->next != NULL) {
p->next->prior = s;
}
s->prior = p;
p->next = s;
return true;
}
解释
- 如果 $p$ 有后继,则需要修改后继结点的
prior。 - 插入时要同时维护:
- 前驱方向;
- 后继方向。
- 代码顺序很重要,避免断链。
5.8.4 双链表删除后继结点
图示解读
- 删除 $p$ 的后继 $q$ 后,应让 $p$ 与 $q$ 的后继 $c$ 重新双向相连:
p->next=c,且当 $c$ 存在时令c->prior=p。 - 两条实线表示删除前的相邻关系,虚线表示绕过 $q$ 后需要建立的新关系。
代码
bool DeleteNextDNode(DNode *p) {
if (p == NULL) {
return false;
}
DNode *q = p->next;
if (q == NULL) {
return false;
}
p->next = q->next;
if (q->next != NULL) {
q->next->prior = p;
}
free(q);
return true;
}
解释
- 删除 $p$ 的后继结点 $q$。
- 如果 $q$ 后面还有结点,则要让后继结点的
prior指回 $p$。
5.8.5 销毁双链表
代码
void DestroyList(DLinkList &L) {
while (L->next != NULL) {
DeleteNextDNode(L);
}
free(L);
L = NULL;
}
解释
- 循环释放各个数据结点。
- 最后释放头结点。
- 头指针置为
NULL。
5.8.6 双链表遍历
后向遍历
while (p != NULL) {
// 对结点 p 做相应处理
p = p->next;
}
前向遍历
while (p != NULL) {
// 对结点 p 做相应处理
p = p->prior;
}
前向遍历:跳过头结点
while (p->prior != NULL) {
p = p->prior;
}
PDF 笔误修正
- PDF 中出现
while(p!==NULL),C/C++ 中应写为:
while (p != NULL)
5.9 循环单链表
5.9.1 循环单链表的定义
定义:#循环单链表
描述: 循环单链表的尾结点
next指针不再指向NULL,而是指向头结点。
初始化
typedef struct LNode {
ElemType data;
struct LNode *next;
} LNode, *LinkList;
bool InitList(LinkList &L) {
L = (LNode *)malloc(sizeof(LNode));
if (L == NULL) {
return false;
}
L->next = L;
return true;
}
判空
bool Empty(LinkList L) {
if (L->next == L) {
return true;
} else {
return false;
}
}
解释
- 普通单链表空表:
L->next == NULL
- 循环单链表空表:
L->next == L
5.9.2 判断尾结点
代码
bool isTail(LinkList L, LNode *p) {
if (L != NULL && p != NULL && p != L && p->next == L) {
return true;
} else {
return false;
}
}
解释
- 带头结点的循环单链表中,非空表尾数据结点的后继是头结点;空表只有头结点,不能把它误判为数据尾结点。
- 因此,只有
p != L && p->next == L时,$p$ 才是数据尾结点。
5.10 循环双链表
5.10.1 循环双链表的定义
定义:#循环双链表
描述: 循环双链表中,头结点的
prior指向尾结点,尾结点的next指向头结点。
结点定义
typedef struct DNode {
ElemType data;
struct DNode *prior;
struct DNode *next;
} DNode, *DLinkList;
初始化
bool InitDLinkList(DLinkList &L) {
L = (DNode *)malloc(sizeof(DNode));
if (L == NULL) {
return false;
}
L->prior = L;
L->next = L;
return true;
}
判空
bool Empty(DLinkList L) {
if (L->next == L) {
return true;
} else {
return false;
}
}
解释
- 空的循环双链表中,头结点的前驱和后继都指向自己。
- 即: $$ L->prior=L,\quad L->next=L $$
图示解读
- 普通双链表两端以
NULL为边界:头结点的prior=NULL,尾结点的next=NULL。 - 循环双链表没有
NULL端点:头结点的prior指向尾结点,尾结点的next指向头结点;空表则退化为头结点的两个指针都指向自身。
5.10.2 循环双链表插入
代码
bool InsertNextDNode(DNode *p, DNode *s) {
if (p == NULL || s == NULL) {
return false;
}
s->next = p->next;
p->next->prior = s;
s->prior = p;
p->next = s;
return true;
}
解释
- 循环双链表中,$p$ 的后继一定存在。
- 因此通常不需要判断
p->next != NULL。
5.10.3 循环双链表删除
代码
bool DeleteNextDNode(DLinkList L, DNode *p) {
if (L == NULL || p == NULL || p->next == L) {
return false;
}
DNode *q = p->next;
p->next = q->next;
q->next->prior = p;
free(q);
return true;
}
注意
- 这里额外传入头指针
L,以便在空表或p为尾结点时拒绝删除p->next指向的头结点;若沿用原 PDF 只传p的代码,必须由调用者保证后继不是头结点,否则会释放哨兵并留下悬空指针。 - 上述插入、删除代码都假定
p是本表内有效结点;插入时s还应是未链接到任何链表的独立新结点。 - 做题时要关注:
- 如何判断链表是否为空;
- 如何判断结点是否为头结点;
- 如何判断结点是否为尾结点;
- 如何在表头、表尾、中间进行插入和删除。
5.11 静态链表
5.11.1 静态链表的定义
定义:#静态链表
描述: 静态链表是用数组来模拟链表的一种存储结构,结点中用游标
next表示下一个元素的数组下标。
代码
#define MaxSize 10
struct Node {
ElemType data;
int next;
};
typedef struct Node SLinkList[MaxSize];
解释
data存储数据元素。next存储下一个元素的数组下标。SLinkList相当于一个长度为MaxSize的Node型数组。
5.11.2 静态链表的特点
优点
- 不需要指针。
- 适用于不支持指针的低级语言。
- 可以用数组模拟链式结构。
缺点
- 不能随机存取。
- 只能从头结点开始依次往后查找。
- 容量固定不变。
适用场景
- 不支持指针的低级语言。
- 最大容量预先确定、但仍希望在数组内部灵活插入和删除的场景。
- 操作系统中的文件分配表 FAT 可作为理解例子。
注意
- 静态链表要同时约定“链尾”和“空闲单元”的游标标记,具体取值由实现决定,不能把普通数组下标当作标记值。
- 例如可约定
-1表示链尾、-2表示空闲单元: $$ \text{next}=-1\Rightarrow\text{链尾},\qquad \text{next}=-2\Rightarrow\text{空闲} $$ - 插入时从空闲单元中取一个结点并修改游标;删除后把该数组位置重新标为空闲。若实现维护备用链表,也可以用另一套游标规则。
图示解读
- 静态链表的结点在数组中物理连续,但逻辑次序由
next游标决定。图中逻辑链为 $0\to2\to1\to6\to3$,并不等于数组下标顺序。 - 游标保存的是“下一个结点的数组下标”,作用类似普通链表中的地址指针,因此静态链表仍不能按逻辑位序随机存取。
相关知识
- 科目导航:数据结构导航
- 阅读顺序:上一篇:线性表的顺序实现 · 下一篇:顺序表和链表对比
- 关联专题:链表代码
5.12 本节复盘
必须掌握
- 单链表结点由数据域和指针域组成。
- 带头结点可以统一表头和非表头位置的插入删除。
- 单链表按位查找、按值查找、求表长的时间复杂度都是: $$ O(n) $$
- 已知结点 $p$ 后插,时间复杂度是: $$ O(1) $$
- 双链表比单链表多一个
prior指针,可以向前查找。 - 循环链表的尾结点不指向
NULL,而是指回头结点。 - 静态链表用数组下标模拟指针。
易错点
- 头指针必须存在,头结点可有可无。
- 不带头结点时,插入第一个结点要单独处理。
- 单链表插入时:
s->next = p->next;
p->next = s;
顺序不能反。
- 删除结点后要释放空间:
free(q);
- 单链表不能随机存取。
- 循环单链表判空不是
L->next == NULL,而是:
L->next == L
代码题抓手
- 单链表按位插入:先找第 $i-1$ 个结点。
- 单链表按位删除:先找第 $i-1$ 个结点,再删它的后继。
- 尾插法:正向建表,关键是维护尾指针
r。 - 头插法:逆向建表,可以用于链表逆置。
- 双链表插入删除:必须同时维护
prior和next。




