代码改变世界

完整教程:零基础入门C语言之C语言实现数据结构之单链表

2025-12-21 20:47  tlnshuju  阅读(4)  评论(0)    收藏  举报

在阅读本篇文章之前,建议读者优先阅读本专栏内前面部分文章。

目录

前言

一、链表概念及结构

二、单链表的实现

三、链表的分类

总结


前言

本篇文章主要介绍数据结构中与单链表相关的知识。


一、链表概念及结构

我们在前面的文章提到了针对顺序表中间插入和头部插入时效率过于低下、增容时降低运行效率和增容造成空间浪费这些问题,我们可以使用链表来解决这些问题,那么什么是链表呢?

实际上,链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。它也是线性表的一种,在物理结构上它不是线性的,但在逻辑结构上一定是线性的。

链表的结构跟火车车厢相似,淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。车厢是独立存在的,且每节车厢都有车门。想象一下这样的场景,假设每节车厢的车门都是锁上的状态,需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?最简单的做法:每节车厢里都放一把下一节车厢的钥匙。而在链表里,每节 “车厢” 是什么样的呢?

与顺序表不同的是,链表里的每节 “车厢” 都是独立申请下来的空间,我们称之为“结点/节点”节点的组成主要有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)。图中指针变量plist保存的是第一个节点的地址,我们称plist此时 “指向” 第一个节点,如果我们希望plist“指向” 第二个节点时,只需要修改plist保存的内容为0x0012FFA0。但是为什么还需要指针变量来保存下一个节点的位置?这是因为链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间),我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点。结合前面学到的结构体知识,我们可以给出每个节点对应的结构体代码:

struct SListNode
{
    int data;     //节点数据
    struct SListNode* next; //指针变量用保存下一个节点的地址
};

当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存,这个内存不仅要保存整型数据,也需要保存下一个节点的地址(当下一个节点为空时保存的地址为空)。当我们想要从第一个节点走到最后一个节点时,只需要在前一个节点拿上下一个节点的地址(下一个节点的钥匙)就可以了。那么请读者思考一下,给定的链表结构中,如何实现节点从头到尾的打印?我们可以看看下面这个图:

那么接下来我们就可以来尝试实现与链表相关的增删查改等操作了。

二、单链表的实现

我们首先先来试着运行一下上面这个顺序打印的代码:

SList.h:

#pragma once
#include 
#include 
#include 
typedef int SLTDataType;
typedef struct SListNode {
	SLTDataType data;
	struct SListNode* next;
}SLTNode;
void SLTPrint(SLTNode* phead);

SList.c:

#include "SList.h"
void SLTPrint(SLTNode* phead){
	SLTNode* pcur = phead;
	while (pcur) {
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
    printf("NULL\n");
}

test.c:

#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;
}

其运行结果如下:

相信如果你把上面的图片理解清楚之后,再看这个代码你也可以理解地比较清楚了。我们接下来开始常规实现单链表的一些方法,首先是单链表的尾插方法:

//单链表尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);
	//*pphead 就是指向第一个节点的指针
	//空链表和非空链表
	SLTNode* newnode = SLTBuyNode(x);
	if (*pphead == NULL)
	{
		*pphead = newnode;
	}
	else
	{
		//找尾
		SLTNode* ptail = *pphead;
		while (ptail->next)
		{
			ptail = ptail->next;
		}
		//ptail指向的就是尾结点
		ptail->next = newnode;
	}
}

我们插入4个节点试试:

这里需要注意我们传入的参数是二级指针,也就是节点指针的地址,如果说我们只传入一个一级指针的话,我们进行操作时只会改变形参的值,等函数运行结束销毁栈帧时,我们的实参并没有发生改变。那么我们接下来实现一下单链表的头插,相比来说这个更简单一些:

//单链表头插
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	newnode->next = *pphead;
	*pphead = newnode;
}

我们再插入四个节点试试:

实现完插入操作后,我们来尝试实现删除的函数,首先就是尾删函数:

//单链表尾删
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;
		}
		//prev ptail
		free(ptail);
		ptail = NULL;
		prev->next = NULL;
	}
}

我们来测试一下,删除两个节点,其结果如下:

相对来说,头删则会比较简单一些:

//单链表头删
void SLTPopFront(SLTNode** pphead)
{
	//链表不能为空
	assert(pphead && *pphead);
	SLTNode* next = (*pphead)->next; //-> 优先级高于*
	free(*pphead);
	*pphead = next;
}

我们试着用它来删除两个节点试一试,其运行结果如下:

如果说我们想要查找包含特定数据的节点,那么我们该如何去做呢?我给出我的示例代码:

SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* pcur = phead;
	while (pcur)//等价于pcur != NULL
	{
		if (pcur->data == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	//pcur == NULL
	return NULL;
}

我们试着查找一下含有值为3的节点:

接下来我们来介绍一下在指定位置前或后插入和删除数据的函数:

//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
	assert(pphead && *pphead);
	assert(pos);
	SLTNode* newnode = SLTBuyNode(x);
	//若pos == *pphead;说明是头插
	if (pos == *pphead)
	{
		SLTPushFront(pphead, x);
	}
	else {
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		//prev -> newnode -> pos
		newnode->next = pos;
		prev->next = newnode;
	}
}
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
	assert(pos);
	SLTNode* newnode = SLTBuyNode(x);
	//pos -> newnode -> pos->next
	newnode->next = pos->next;
	pos->next = newnode;
}
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead && *pphead);
	assert(pos);
	//pos是头结点/pos不是头结点
	if (pos == *pphead)
	{
		//头删
		SLTPopFront(pphead);
	}
	else {
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		//prev pos pos->next
		prev->next = pos->next;
		free(pos);
		pos = NULL;
	}
}
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
	assert(pos && pos->next);
	SLTNode* del = pos->next;
	//pos del del->next
	pos->next = del->next;
	free(del);
	del = NULL;
}

上述代码读者可自行测试。最后是对于整个链表的销毁:

//销毁链表
void SListDesTroy(SLTNode** pphead)
{
	assert(pphead && *pphead);
	SLTNode* pcur = *pphead;
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	//pcur
	*pphead = NULL;
}

三、链表的分类

链表的种类有很多很多,按照下面的组合排列就出现8种结构:

我们来看下这些链表有什么区别:

虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:单链表和双向带头循环链表。无头单向非循环链表结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。带头双向循环链表结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。


总结

本文详细介绍了单链表的数据结构及其实现方法。主要内容包括:链表概念及结构特点,通过火车车厢类比解释链表的节点组成和指针连接方式;单链表的实现,包含节点定义、遍历打印、头插尾插、头删尾删等基本操作;链表高级操作如查找特定节点、指定位置插入删除等;链表分类概述,指出实际应用中最常用的是无头单向非循环链表和带头双向循环链表。文章通过代码示例详细讲解了各种操作的实现方法,并强调链表相比顺序表在动态内存管理方面的优势。