跳到主要内容数据结构:单链表与双链表的操作详解 | 极客日志C算法
数据结构:单链表与双链表的操作详解
讲解链表数据结构原理,涵盖单链表与带头双向循环链表的定义、初始化、打印、插入、删除、查找及销毁操作。通过 C 语言代码示例展示节点结构体设计与指针操作细节,分析空链表、头尾节点等边界条件处理,帮助理解链表动态存储机制。
DockerOne19 浏览 前言
1. 链表
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
链表(linked list)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过'引用'相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。即是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表的结构跟火车车厢相似,淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。
车厢是独立存在的,且每节车厢都有车门。想象一下这样的场景,假设每节车厢的车门都是锁上的状态,需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?
最简单的做法:每节车厢里都放一把下一节车厢的钥匙。
在链表里,每节'车厢'是什么样的呢?
与顺序表不同的是,链表里的每节'车厢'都是独立申请下来的空间,我们称之为'结点/节点'。
节点的组成主要有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)。图中指针变量 plist 保存的是第一个节点的地址,我们称 plist 此时'指向'第一个节点,如果我们希望 plist'指向'第二个节点时,只需要修改 plist 保存的内容。
为什么还需要指针变量来保存下一个节点的位置?
链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间),我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点。
结合前面学到的结构体知识,我们可以给出每个节点对应的结构体代码,假设当前保存的节点为整型:
struct SListNode {
int data;
struct SListNode* next;
};
当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存,这个内存不仅要保存整型数据,也需要保存下一个节点的地址(当下一个节点为空时保存的地址为空)。
当我们想要从第一个节点走到最后一个节点时,只需要在前一个节点拿上下一个节点的地址(下一个节点的钥匙)就可以了。
注意:
1、链式结构在逻辑上是连续的,在物理结构上不一定连续
2、节点一般是从堆上申请的
3、从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续
2. 链表的类型
链表的结构非常多样,以下情况组合起来就有 8 种链表结构:
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:单链表和双向带头循环链表。
- 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
- 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
3. 单链表
3.1 定义单链表结构
注意:这里模拟在工程里面的使用,所以分成多个文件
创建三个文件,分别是:SList.h、、
SList.c
test.c
SList.h:单链表结构声明单链表的方法
SList.c:实现单链表的方法
test.c:测试文件
#pragma once
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
typedef int SLDataType;
typedef struct SListNode {
SLDataType data;
struct SListNode* next;
} SLTNode;
typedef int SLDataType; 之所以这样重命名定义是为了后期有大量代码时,此时输入的不再是 int 类型,可能是 char、double 类型,方便修改。
typedef struct SListNode { }SLTNode; 这样是为了更方便的进行调用。
3.2 单链表的打印
首先在头文件定义一下打印的声明,使其指向链表的第一个节点。
void SLTPrint(SLTNode* phead);
然后根据定义来实现打印效果。先定义一个指针指向传来的头结点,然后我们知道只要链表不指向空那么这个链表中就有相关的元素,此时我们就可以打印存在该节点中的值,然后再让其指向下一个节点。
#include "SList.h"
void SLTPrint(SLTNode* phead) {
SLTNode* pcur = phead;
while (pcur) {
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
因为我们并没有在定义中构造节点,所以我们需要先开空间创造链表,之后用 next 将每个节点连一起,最后调用打印函数即可。
#include "SList.h"
void SListTest01() {
SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));
node1->data = 1;
SLTNode* node2 = (SLTNode*)malloc(sizeof(SLTNode));
node2->data = 2;
SLTNode* node3 = (SLTNode*)malloc(sizeof(SLTNode));
node3->data = 3;
SLTNode* node4 = (SLTNode*)malloc(sizeof(SLTNode));
node4->data = 4;
node1->next = node2;
node2->next = node3;
node3->next = node4;
node4->next = NULL;
SLTNode* plist = node1;
SLTPrint(plist);
}
int main() {
SListTest01();
return 0;
}
实际上我们并不会这样手动一个一个节点的创建,我们是用空链表通过插入的方式让链表里面增加数据。
3.3 插入节点
因为我们在插入链表时创建新结点,所以先开创一块新空间,让开创的空间的数据存储传值 x,然后链表的 next 指针指向 NULL。
SLTNode* SLTBuyNode(SLDataType x) {
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL) {
perror("malloc fail!");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
}
3.3.1 尾插
首先定义头文件,指明要插入的头节点位置以及要插入的值。
void SLTPushBack(SLTNode** pphead, SLDataType x);
然后定义尾插相关的操作。如果是空链表则指向新节点,当链表不为空时,则寻找最后一个节点,即当 ptail->next = NULL 时便是最后的节点,然后让其指向新节点。
void SLTPushBack(SLTNode** pphead, SLDataType x) {
SLTNode* newnode = SLTBuyNode(x);
if (*pphead == NULL) {
*pphead = newnode;
} else {
SLTNode* ptail = *pphead;
while (ptail->next) {
ptail = ptail->next;
}
ptail->next = newnode;
}
}
void SListTest02() {
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPrint(plist);
}
测试结果发现形参变了实参没变,相关的值并没有传上,说明传值操作并不符合。因此想让形参改变实参就应改为传址操作。之所以这样操作是因为头指针可能随操作变化,所以需要二级指针来修改它。
void SLTPushBack(SLTNode** pphead, SLDataType x);
void SLTPushBack(SLTNode** pphead, SLDataType x) {
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
if (*pphead == NULL) {
*pphead = newnode;
} else {
SLTNode* ptail = *pphead;
while (ptail->next) {
ptail = ptail->next;
}
ptail->next = newnode;
}
}
pphead 为空则就不能对其解引用
*pphead 可以为空,说明当前第一个节点地址为空,表示空链表,可以为空
3.3.2 头插
首先定义头文件,指明要插入的头节点位置以及要插入的值。
void SLTPushFront(SLTNode** pphead, SLDataType x);
接着写出头插的实现函数,先创建一个新的节点,然后让新节点的下一位指向新节点,最后新节点变成头节点。因为头插的过程中空链表和非空链表的实现一样,所以无需分开来写。
void SLTPushFront(SLTNode** pphead, SLDataType x) {
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
3.4 删除节点
3.4.1 尾删
首先定义头文件,指明要删除的链表头节点在哪,又因为可能会影响头节点,则需要用二级指针。
void SLTPopBack(SLTNode** pphead);
尾删的过程中因为想要删除那么链表不能为空,所以需要用 assert 断言一下。然后链表分为两种情况一种是只有一个节点,另一种是有多个节点。
- 当只有一个节点时,释放这个头节点,然后置为空
- 当有多个节点时,寻找尾节点,然后释放掉,同时使尾节点的前一个节点置为空防止变成野指针
void SLTPopBack(SLTNode** pphead) {
assert(pphead && *pphead);
if ((*pphead)->next == NULL) {
free(*pphead);
*pphead = NULL;
}
else {
SLTNode* prev = *pphead;
SLTNode* ptail = *pphead;
while (ptail->next) {
prev = ptail;
ptail = ptail->next;
}
free(ptail);
ptail = NULL;
prev->next = NULL;
}
}
3.4.2 头删
首先定义头文件,指明要删除的链表头节点在哪,又因为可能会影响头节点,则需要用二级指针。
void SLTPopFront(SLTNode** pphead);
接着写出头删的实现函数,首先链表不能为空,然后先找到第二个节点存储起来防止找不到第二个节点,最后释放头节点并让第二个节点为新的头节点。因为头删的过程中空链表和非空链表的实现一样,所以无需分开来写。
void SLTPopFront(SLTNode** pphead) {
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
3.5 查找
首先定义头文件,指明要查找的链表头节点在哪以及要查找的元素,因为不会影响头节点,则就不需要用二级指针。
SLTNode* SLTFind(SLTNode* phead, SLDataType x);
单链表只能从头开始查找(顺序查找),所以从头节点开始寻找有没有要查找的元素,直至头节点指向空为止。但是经过循环 phead == NULL,如果再遍历原链表则找不到链表的第一个结点。
SLTNode* SLTFind(SLTNode* phead, SLDataType x) {
SLTNode* pcur = phead;
while (pcur) {
if (pcur->data == x) {
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
3.6 指定位置插入/删除
3.6.1 指定位置之前插入数据
首先定义头文件,指明要插入位置之前在哪以及插入的是什么元素,又因为可能会影响头节点,则需要用二级指针。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLDataType x);
接着实现指定位置之前插入的函数,首先要断言传指针不能为空以及链表不能为空,以及 pos 也不能为空。接着创建新节点,然后遍历链表寻找 pos 节点之前的节点。最后让 pos 前一个节点的 next 指针指向新节点,新节点的 next 指针指向 pos 的节点。
还要注意如果 pos == *pphead,则为头插,直接调用头插操作即可。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLDataType x) {
assert(pphead && *pphead);
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
if (pos == *pphead) {
SLTPushFront(pphead, x);
} else {
SLTNode* prev = *pphead;
while (prev->next != pos) {
prev = prev->next;
}
newnode->next = pos;
prev->next = newnode;
}
}
3.6.2 在指定位置之后插入数据
首先定义头文件,指明要查找的链表指定位置的节点在哪以及要查找的元素,因为不需要头节点,故不会影响头节点,则就不需要用二级指针。
void SLTInsertAfter(SLTNode* pos, SLDataType x);
接着实现指定位置之后插入的函数,首先 pos 不能为空,接着创建新节点。创建的新节点要先指向 pos->next,然后在使 pos 的下一个节点指向新节点。如果这两种顺序搞反了会造成 pos 的下一个指针丢失。
void SLTInsertAfter(SLTNode* pos, SLDataType x) {
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
3.6.3 删除 pos 节点
首先定义头文件,指明要删除的指定位置的元素。因为指定的位置可能会是头节点,所以需要传头节点和指定位置。因为可能会影响头节点,则需要用二级指针。
void SLTErase(SLTNode** pphead, SLTNode* pos);
因为传了头节点时,所以需要断言传的地址不能为空以及链表不能为空。pos 的位置有两种情况,一种是在头节点,另一种不是头节点。
- 在头节点上:相当于头删
- 不再头节点上时:需要找 pos 前后两个位置的节点,让 pos 前的节点指向 pos 的下一个节点,然后释放 pos 节点并置为空。
void SLTErase(SLTNode** pphead, SLTNode* pos) {
assert(pphead && *pphead);
assert(pos);
if (pos == *pphead) {
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
else {
SLTNode* prev = *pphead;
while (prev->next != pos) {
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
3.6.4 删除 pos 之后的节点
首先定义头文件,指明指定的位置的元素,因为不需要头节点,故不会影响头节点,则就不需要用二级指针。
void SLTEraseAfter(SLTNode* pos);
删除指定位置之后的节点,只需要删除下一个节点的位置,并让指定位置指向下一个的下一个节点。
void SLTEraseAfter(SLTNode* pos) {
assert(pos && pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
3.7 销毁链表
首先定义头文件,指明要销毁链表的头节点。因为销毁的是从头节点开始,头节点地址会变,所以需要传头节点和指定位置。因为会影响头节点,则需要用二级指针。
void SListDestroy(SLTNode** pphead);
接着实现销毁链表的函数,销毁的链表不能为空。销毁链表从头节点开始一点点释放,直至销毁完,销毁完后头节点要置为空。
void SListDestroy(SLTNode** pphead) {
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
while (pcur) {
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
4. 双链表
由图可知,带头双向链表需要当前位置的元素,下一个节点的位置和上一个节点的位置,所以定义的时候就需要比单链表多定义一个前一个结点。
4.1 定义带头双链表
因为双链表比单链表多一个前节点,所以定义的时候需要多定义一个前节点。同时因为需要带头,所以初始化中需要一个头节点,还需要初始化一下哨兵位。
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int LTDataType;
typedef struct ListNode {
LTDataType data;
struct ListNode* next;
struct ListNode* prev;
} LTNode;
void LTInit(LTNode** pphead);
因为初始化头节点时需要开创一个新节点,所以先定义创建新节点的实现函数。因为创建的是循环双链表,所以前一个节点和后一个节点的指针要指向自己。
哨兵位是不需要有效的值,但定义申请节点必须传值,所以我们传 -1,表示不是有效位置。
#include "List.h"
LTNode* LTBuyNode(LTDataType x) {
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL) {
perror("malloc fail!");
exit(1);
}
node->data = x;
node->next = node->prev = node;
return node;
}
void LTInit(LTNode** pphead) {
*pphead = LTBuyNode(-1);
}
#include "List.h"
void ListTest01() {
LTNode* plist = NULL;
LTInit(&plist);
}
int main() {
ListTest01();
return 0;
}
void LTPrint(LTNode* phead);
void LTPrint(LTNode* phead) {
LTNode* pcur = phead->next;
while (pcur != phead) {
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
4.2 插入节点
4.2.1 尾插
首先定义一下尾插的声明,因为哨兵位节点相当于头节点,哨兵位节点不能删除并且节点的地址也不发生改变,所以我们仅需传一级指针。因此需要链表头节点的位置和插入的元素是什么。
void LTPushBack(LTNode* phead, LTDataType x);
接着实现尾插操作。因为有哨兵位节点,所以当为空链表时就不需要考虑头节点为空的情况。我们需要申请一个新节点,然后让新节点的前一个结点指向原来的尾节点,尾节点的下一个节点指向新节点,新节点的下一个节点指向头节点,头节点的前一个节点指向新节点。
void LTPushBack(LTNode* phead, LTDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->prev = phead->prev;
newnode->next = phead;
phead->prev->next = newnode;
phead->prev = newnode;
}
4.2.2 头插
void LTPushFront(LTNode* phead, LTDataType x);
void LTPushFront(LTNode* phead, LTDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
双向链表为空,此时链表只有一个头结点,即只有一个哨兵位。
头结点为空 phead = NULL; 则这不是一个有效的双向链表
4.3 删除节点
4.3.1 尾删
首先定义一下尾删的声明,因为哨兵位节点相当于头节点,哨兵位节点不能删除并且节点的地址也不发生改变,所以我们仅需传一级指针。因此我们只需要链表头节点的位置即可。
void LTPopBack(LTNode* phead);
尾删的实现,首先链表不能为空且有效,然后根据头节点的前一个指针找到尾节点,使头节点的前一个指针指向倒数第二个节点,倒数第二个节点的后一个指针指向头节点。然后释放尾节点并指向空防止成为野指针。
void LTPopBack(LTNode* phead) {
assert(phead && phead->next != phead);
LTNode* del = phead->prev;
del->prev->next = phead;
phead->prev = del->prev;
free(del);
del = NULL;
}
4.3.2 头删
void LTPopFront(LTNode* phead);
void LTPopFront(LTNode* phead) {
assert(phead && phead->next != phead);
LTNode* del = phead->next;
phead->next = del->next;
del->next->prev = phead;
free(del);
del = NULL;
}
4.4 查找
查找时需要从第一个实际节点开始遍历,以及要查找的数据值,故查找声明写法如下。
LTNode* LTFind(LTNode* phead, LTDataType x);
从 phead->next 开始,跳过不存储数据的头节点,提高效率。循环条件 pcur != phead,双向循环链表的特性是最后一个节点的 next 指向头节点。当遍历回到头节点时,说明已遍历所有实际节点,循环结束。逐步移动指针,通过 pcur = pcur->next 遍历每个节点,直到找到匹配值或遍历完成。
LTNode* LTFind(LTNode* phead, LTDataType x) {
LTNode* pcur = phead->next;
while (pcur != phead) {
if (pcur->data == x) {
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
4.5 在指定位置插入/删除
4.5.1 在 pos 位置之后插入数据
在指定位置插入时需要从第一个实际节点开始遍历,以及要插入的数据值,故查找声明写法如下。
void LTInsert(LTNode* pos, LTDataType x);
void LTInsert(LTNode* pos, LTDataType x) {
assert(pos);
LTNode* newnode = LTBuyNode(x);
newnode->next = pos->next;
newnode->prev = pos;
pos->next->prev = newnode;
pos->next = newnode;
}
4.5.2 删除 pos 结点
void LTErase(LTNode* pos);
void LTErase(LTNode* pos) {
assert(pos);
pos->next->prev = pos->prev;
pos->prev->next = pos->next;
free(pos);
pos = NULL;
}
LTErase(find);
find = NULL;
LTPrint(plist);
LTNode* LTInit() {
LTNode* phead = LTBuyNode(-1);
return phead;
}
LTNode* plist = LTInit();
4.6 销毁链表
void LTDestroy(LTNode* phead);
使用 assert 确保传入的指针非空,避免对空指针进行操作导致程序崩溃。从 phead->next 开始:头节点是哨兵节点,不存储实际数据,所以从第一个数据节点开始释放。循环条件 pcur != phead,双向循环链表中,最后一个节点的 next 指向头节点。当 pcur 回到头节点时,说明所有数据节点已遍历完毕。保存下一个节点地址,在释放当前节点前,必须提前保存其 next 指针,否则释放后无法访问下一个节点,导致内存泄漏。
void LTDestroy(LTNode* phead) {
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead) {
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
free(phead);
phead = NULL;
}
phead 是函数的形参,是外部 plist 的副本。函数内 phead = NULL 只影响这个形参。函数返回后,外部的 plist 仍然指向已释放的内存地址,故需要手动置空。
LTDestroy(plist);
plist = NULL;
LTPrint(plist);
结语
链表的核心在于指针操作,单链表适合简单场景,双链表以空间换时间提升操作效率。实际应用中需注意内存管理和边界条件(如空链表、头尾节点)。通过反复练习插入、删除等操作,可深入理解链表的动态特性与指针灵活性。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online