跳到主要内容数据结构:单链表详解与 C 语言实现 | 极客日志C算法
数据结构:单链表详解与 C 语言实现
单链表是线性表的链式存储结构,通过指针链接逻辑顺序。本文详细解析了单链表节点定义、内存申请及核心操作实现,包括头尾插删、查找插入删除等,并对比了不同链表结构的适用场景。重点讲解了二级指针在修改头结点时的必要性,以及边界条件的处理策略,适合希望深入理解底层内存管理的开发者参考。
概念与结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
逻辑结构:线性
物理结构(存储结构):不一定是线性的
可以把链表想象成一列火车,车头是哨兵位(可有可无),车厢就是节点。将某节车厢去掉或加上,不会影响其他车厢,每节车厢都是独立存在的。
在链表里,每节'车厢'是什么样的呢?
结点
与顺序表不同的是,链表里的每节'车厢'都是独立申请下来的空间,我们称之为'结点'。结点的组成主要有两个部分:当前结点要保存的数据和保存下一个结点的地址(指针变量)。
链表中每个结点都是独立申请的(即需要插入数据时才去申请一块结点的空间),我们需要通过指针变量来保存下一个结点位置才能从当前结点找到下一个结点。
链表的性质
- 链式机构在逻辑上是连续的,在物理结构上不一定连续。
- 结点一般是从堆上申请的。
- 从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续。
结合前面学到的结构体知识,我们可以给出每个结点对应的结构体代码。假设当前保存的结点为整型:
struct SListNode {
int data;
struct SListNode* next;
};
当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存,这块内存不仅要保存整型数据,也需要保存下一个结点的地址(当下一个结点为空时保存的地址为空)。当我们想要从第一个结点走到最后一个结点时,只需要在当前结点拿取下一个结点的地址就可以了。
实现单链表
头文件 (SList.h)
这里定义了链表的数据类型、节点结构以及所有操作的函数声明。注意 SLTNode 是 struct SListNode 的别名,方便后续使用。
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType;
typedef struct SListNode {
SLTDataType data;
struct SListNode* ;
} SLTNode;
;
;
;
;
SLTNode* ;
;
;
;
;
;
;
next
void
SLTPushBack
(SLTNode** pphead, SLTDataType x)
void
SLTPushFront
(SLTNode** pphead, SLTDataType x)
void
SLTPopBack
(SLTNode** pphead)
void
SLTPopFront
(SLTNode** pphead)
SLTFind
(SLTNode* phead, SLTDataType x)
void
SLTInsert
(SLTNode** pphead, SLTNode* pos, SLTDataType x)
void
SLTInsertAfter
(SLTNode* pos, SLTDataType x)
void
SLTErase
(SLTNode** pphead, SLTNode* pos)
void
SLTEraseAfter
(SLTNode* pos)
void
SListDestroy
(SLTNode** pphead)
void
SLTPrint
(SLTNode* phead)
源文件 (SList.c)
单链表的打印
void SLTPrint(SLTNode* phead) {
SLTNode* pcur = phead;
while (pcur != NULL) {
printf("%d -> ", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
单链表申请新节点内存
这是创建节点的基础函数,负责从堆上分配内存并初始化。
SLTNode* SLTBuyNode(SLTDataType x) {
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL) {
perror("malloc fail!");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
这里是申请新节点,并且把对应的数据存入节点中,所以接下来只需要将各个节点连接起来(链表中的指针)。
尾插
在链表尾部添加新节点。如果链表为空,新节点直接成为头结点;否则需要遍历到尾端再连接。
void SLTPushBack(SLTNode** pphead, SLTDataType x) {
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
if (*pphead == NULL) {
*pphead = newnode;
} else {
SLTNode* ptail = *pphead;
while (ptail->next != NULL) {
ptail = ptail->next;
}
ptail->next = newnode;
}
}
传址调用,使用 SLTNode** pphead,不然无法修改 SLTNode* phead 结构体指针。需要做出特殊判断,当链表为空的时候。
头插
在链表头部添加新节点。新节点的 next 指向原头结点,然后更新头指针。
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
尾删
删除链表尾部的节点。需要找到倒数第二个节点,将其 next 置为 NULL,并释放原尾节点。注意处理只有一个节点的情况。
void SLTPopBack(SLTNode** pphead) {
assert(pphead && *pphead);
if ((*pphead)->next == NULL) {
free(*pphead);
*pphead = NULL;
} else {
SLTNode* prev = NULL;
SLTNode* ptail = *pphead;
while (ptail->next) {
prev = ptail;
ptail = ptail->next;
}
prev->next = NULL;
free(ptail);
ptail = NULL;
}
}
处理特殊情况,链表只有一个节点的情况。链表为空不能删除。
头删
删除链表头部的节点。先保存下一个节点地址,释放当前头结点,然后更新头指针。
void SLTPopFront(SLTNode** pphead) {
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
查找
遍历链表寻找值为 x 的节点。不需要传二级指针,因为只读不写头指针。
SLTNode* SLTFind(SLTNode* phead, SLTDataType x) {
SLTNode* pcur = phead;
while (pcur) {
if (pcur->data == x) {
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
在指定位置之前插入数据
在 pos 节点之前插入新节点。如果 pos 是头结点,则退化为头插操作;否则需要找到 pos 的前驱节点。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
assert(pphead && pos);
SLTNode* newnode = SLTBuyNode(x);
if (pos == *pphead) {
SLTPushFront(pphead, x);
} else {
SLTNode* prev = *pphead;
while (prev->next != pos) {
prev = prev->next;
}
prev->next = newnode;
newnode->next = pos;
}
}
pos 不能为空指针。特殊情况,pos 指向头结点。
在指定位置之后插入数据
在 pos 节点之后插入新节点。逻辑相对简单,只需调整指针指向。
void SLTInsertAfter(SLTNode* pos, SLTDataType x) {
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
删除 pos 结点
删除指定的 pos 节点。如果 pos 是头结点,执行头删;否则找到前驱节点,断开连接并释放内存。
void SLTErase(SLTNode** pphead, SLTNode* pos) {
assert(pphead && pos);
if (pos == *pphead) {
SLTPopFront(pphead);
} else {
SLTNode* prev = *pphead;
while (prev->next != pos) {
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
pos 不能为空指针。特殊情况,pos 刚好就是头结点—头删。
删除 pos 之后的结点
删除 pos 节点的下一个节点。需要确保 pos 和 pos->next 都不为空。
void SLTEraseAfter(SLTNode* pos) {
assert(pos && pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
销毁链表
遍历整个链表,逐个释放节点内存,最后将头指针置空。
void SListDestroy(SLTNode** pphead) {
assert(pphead);
SLTNode* pcur = *pphead;
while (pcur) {
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
链表的分类
链表的结构非常多样,以下情况组合起来就有 8 种(2 x 2 x 2)链表结构:有无头结点、单向/双向、循环/非循环。
虽然有这么多的链表结构,但是我们实际中最常用还是两种结构:
- 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
- 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了。
在实际开发中,理解单链表是基础,掌握其内存模型和指针操作对于深入理解 C/C++ 至关重要。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,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
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online