数据结构:单链表(C语言)

一,什么是链表

1、链表的概念

和顺序表一样,链表的逻辑结构也是线性的,不一样的是链表的物理结构是分散的,堆区中一个又一个分散的节点构成了单链表的结构,而每个节点在内存中是分散分布的,他们是怎么连接起来的呢?

我们可以发现,每个节点中存放着两个数据类型------数据本身和下一个节点的指针,上一个节点记录下一个节点的地址,一个接一个,这样链表就连接起来了,那如何找到链表中的第一个元素呢?答案是通过一个指针变量(phead)记录下第一个节点的地址。f

2、链表的分类

链表的类型有多种,他们取决于三个维度 :单向和双向,不闭环和闭环,带哨兵位和不带哨兵位

我们一个一个来看:

  • 单向和双向:在单向链表中,只用记录下一个节点地址,而在双向链表中。需要同时记录下上一个节点和下一个节点的地址,这样做虽然有点麻烦,但好处也是很明显的,双向链表不仅可以往后遍历,还可以往前遍历,可以很轻易的找到上一个节点 ,无需像单链表重新遍历。
  • 不闭环和闭环:闭环不一定是头和尾的闭环,也可以是在中间的某个节点闭环,那么这个时候就会有个问题,闭环的链表没有停止的信号,普通列表遍历到空指针就停下了,闭环链表没有停止的信号 ,我们怎么知道链表什么时候遍历完,什么时候停下呢?这个时候就要引入哨兵位了
  • 带哨兵位和不带哨兵位:有哨兵位的情况下,头指针指向哨兵位,哨兵位本身也是一个节点,但是其中不存放有效数据,哨兵位的指针指向第一个有效节点,前面说了,哨兵位可以在环形链表中作为停止的信号 ;而哨兵位的好处不止如此,如果没有哨兵位,头插和头删的时候就需要分类讨论 。一般情况下插入和删除需要改变前一个节点和后一个节点的指向,在没有哨兵位的情况下,头插和头删就需改变头指针的指向了。

综上:链表类型本质是3个维度的排列组合,如下:

其中,用实线连起来的是常用的,虚线连起来的用的比较少

二,单链表的创建与维护

前面提到带哨兵位的链表无需分类讨论,实现起来更方便,这里讲的是单向不闭环带哨兵位的链表。维护工作主要通过下标实现。

0,参数名词介绍

SList:Single List(单链表)

SListDataType:Single List Data Type(单链表数据类型)

phead:Position Head(头指针,即链表中第一个数据的指针)

pnew:Position New(新开辟的空间或重新开辟空间)

pcur:Position Current(当前位置)

prev:Previous(要插入或删除的前一个节点指针,可以称为前驱指针)

next:(下一个节点的指针)

i:(下标序号)

x:(操作的数据)

1,准备工作

这里创建三个文件

头文件:

SList.h:定义链表的结构体,维护链表的函数声明

源文件:

SList.c:维护列表函数的实现

text.c:链表结构维护测试

下边是头文件(SList.h)的内容:

cs 复制代码
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

//定义节点的结构体
typedef int SListDataType;//数据类型
typedef struct SingleNode
{
	SListDataType data;       //当前节点存放的数据
	struct SingleNode* next;  //指向下一个节点的指针
}Node;

////////////////////////////////////////////////////////////////////
下面是函数申明

//初始化链表
Node* SListInit();

//销毁链表
void SListDistory(Node* phead);

//打印链表
void SListPrint(Node* phead);

//按数据查找(返回下标)
int SListIndex(Node* phead, SListDataType x);

//数据个数
int SListSize(Node* phead);

//指定i位置插入
void SListInsert(Node* phead, int i, SListDataType x);
//指定位置删除
void SListDel(Node* phead, int i);

//头插
void SListInsertHead(Node* phead, SListDataType x);
//尾插
void SListInsertBack(Node* phead, SListDataType x);
//头删
void SListPopHead(Node* phead);
//尾删
void SListPopBack(Node* phead);

记得在源文件中包含SList.h。

2,链表初始化

cs 复制代码
//初始化链表
Node* SListInit();
//返回值:指向哨兵位的头指针

向内存申请一个节点的空间作哨兵位,注意把节点的指向置为空,然后在里面随便放一个无效数据就行了,然后返回这个节点(哨兵位)的地址。

cs 复制代码
//申请存放节点的空间
Node* BuyNode(SListDataType x)
{
	//开辟空间和检查是否开辟成功
	Node* pnew = (Node*)malloc(sizeof(Node));
	if (!pnew)
	{
		perror("BuyNode::malloc");
		exit(1);
	}

	//初始化新生成节点的数据
	pnew->data = x;
	pnew->next = NULL;

	//返回新生成节点的地址
	return pnew;
}

//初始化链表
Node* SListInit()
{
	//生成哨兵位,并将数据置为-1
	Node* pnew = BuyNode(-1);
    //由于申请空间经常用到,这里专门定义了一个函数开辟空间

	//返回指向哨兵位的头指针
	return pnew;
}

3,链表打印

cs 复制代码
//打印链表
void SListPrint(Node* phead);
//参数:
//phead:指向哨兵位的头指针

遍历链表,并打印,说起来很简单。让我们看看其中的细节:

  • 假如说我们遍历链表的指针是pcur,开始时我们要让pcur第一个有效数据(即哨兵位的下一个数据);
  • 如何得到下一个节点的地址呢?只需pcur->next即可,我们可以这样操作:pcur=pcur->next,这样执行一次pcur就是向下一个节点了;
  • 我们想想应该什么时候停止呢?如果遍历停止的条件是pcur->next!=NULL(此时pcur指向最后一个节点) ,但是进入遍历循环(while)了吗,没有!那就漏了最后一个节点 。假如说pcur!=NULL呢 。我们发现在这种情况下,pcur==NULL时不会进入循环(不会对空指针解引用) 。所以循环条件应该是:pcur!=NULL。
cs 复制代码
//打印链表
void SListPrint(Node* phead)
{
	assert(phead);//检查空指针
	
	//遍历链表,打印数值
	Node* pcur = phead->next;
	while (pcur)
	{
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("NULL\n");//打印链表末尾的空指针
}

打印效果:2->3->1->NULL

4,按下标查找

cs 复制代码
//按数据查找(返回下标)
int SListIndex(Node* phead, SListDataType x);
//参数:
//phead:指向哨兵位的头指针
//x:需要查找的数据
//返回值:该数据的下标
//       如果没找到则返回无效数据-1

这里和数组的遍历是类似的,循环条件我就不再过多赘述了。

cs 复制代码
//(返回下标)
int SListIndex(Node* phead, SListDataType x)
{
	assert(phead);//检查空指针

	//遍历链表,寻找数据
	Node* pcur = phead->next;
	int i = 0;
	while (pcur)
	{
		if (pcur->data == x)//如果相等
			return i;    //返回当前下标

		pcur = pcur->next;
		i++;
	}
    
    //跳出循环说明没找到
	return -1;//返回无效数据-1
}

6,计算数据个数

cs 复制代码
//统计数据个数
int SListSize(Node* phead);
//phead:指向哨兵位的头指针
//返回值:数据个数

定义一个计数器(count),初始值为0,每向前走一个节点就count++,从第一个有效数据(phead->next)开始。

cs 复制代码
//数据个数
int SListSize(Node* phead)
{
	assert(phead);//检查空指针

	//遍历链表,计算个数
	int count = 0;
	Node* pcur = phead->next;
	while (pcur)
	{
		pcur = pcur->next;
		count++;
	}

	return count;
}

7,指定位置插入

cs 复制代码
//指定i位置插入
void SListInsert(Node* phead, int i, SListDataType x);
//参数:
//phead:指向哨兵位的头指针
//i:要插入的位置对应的下标
//x:插入的数据

插入不仅要开辟空间,还要注意遍历寻找前驱节点的方法,注意修改指针的顺序,这里我们来详细讲一下:

1,找到i-1节点(前驱节点):有人可能会说,so easy,遍历链表,将循环条件设为i-1不就行啦。但这其中有个大雷 🥵。如果i为0(头插),要找到下标-1(哨兵位) ,阁下又该如何应对?所以我们索性从-1(哨兵位)开始遍历。这样即使prev为哨兵位也是可行的。

2,修改节点指向的顺序:这里是最容易犯迷糊的,但是我有一个秘法(后面再讲)。我们先来看看,可以先改变prev节点的指向,再改变pnew节点的指向吗?

很明显不行,因为。如果先改变了prev节点的指向,让其指向pnew,后面节点数据就丢失了,pnew无法连接上后面的节点 。所以说正确的方法应该是,先改变pnew节点的指向,再改变prev。

悄悄告诉你我的秘法:无论如何,先改变新生成节点的指向,再动原链表。因为新生成的节点无论你怎么改,都不会影响原链表。

动画演示:在下标为3的位置插入28

遍历链表,找到下标为3-1=2的节点,开辟空间并完成赋值,将新开辟的节点指向原链表中的3节点,再将2节点的下一个节点置为新开辟的节点。

cs 复制代码
//指定i位置插入
void SListInsert(Node* phead, int n, SListDataType x)
{
	assert(phead);//检查空指针
	assert(n >= 0);//检查i是否合法

	//遍历链表,找到第i-1个节点(prev)
	Node* prev = phead;
	for (int j = -1; j < n - 1 && prev; j++)//i=n-1时跳出循环
	{
		prev = prev->next;
	}

	assert(prev);//检查prev是否走到NULL
	             //如果走到NULL说明i超出了最大节点下标

	//申请节点
	Node* pnew = BuyNode(x);

	//先修改新节点的下一个指针(next)
	pnew->next = prev->next;
	//在修改前驱节点(prev)的下一个指针(next)
	prev->next = pnew;
}

8,指定位置删除

cs 复制代码
//指定位置删除
void SListDel(Node* phead, int i);
//参数:
//phead:指向哨兵位的头指针
//i:要删除的数据对应的下标

找到前驱指针,修改指针指向,和前面一样。需要注意的是释放节点,要先保存要删除的节点,修改指向,再释放。否则找不到后面的节点了 ;同时还要防止下标过大越界访问(注释写的很详细)。

动画演示:删除下标为3的元素

遍历链表,找到下标为3-1=2的节点(用prev保存地址),用del保存要删除的3节点的地址,将prev节点的指针改为del的next,然后释放del节点。

cs 复制代码
//指定位置删除
void SListDel(Node* phead, int n)
{
	assert(phead);//检查空指针
	assert(phead->next);//检查链表是否为空
	assert(n >= 0);//检查下标n是否合法

	//遍历链表找到第n-1个节点(prev)
	Node* prev = phead;
	for (int i = -1; i < n - 1 && prev->next; i++)//i=n-1时跳出循环
	{                                             //走到最后一个节点也跳出循环
		prev = prev->next;                      //如果走到走到最后一个节点说明i超出了最大节点下标
	}

	assert(prev->next);//前一个节点(prev)的下一个指针(next)是否为NULL
	                   //如果是,则说明该节点(prev)是最后一个节点,
	                   //则要删除的下一个节点不存在,报错

	//保存要删去的节点,防止地址丢失
	Node* del = prev->next;
	//连接del前后的两个节点
	prev->next = del->next;
	//释放del节点
	free(del);
	del = NULL;
}

9,头尾插入删除

cs 复制代码
//头插
void SListInsertHead(Node* phead, SListDataType x);
//尾插
void SListInsertBack(Node* phead, SListDataType x);
//头删
void SListPopHead(Node* phead);
//尾删
void SListPopBack(Node* phead);

直接使用之前写好的接口吐舌头

cs 复制代码
//头插
void SListInsertHead(Node* phead, SListDataType x)
{
	assert(phead);//检查空指针

	SListInsert(phead, 0, x);
}
//尾插
void SListInsertBack(Node* phead, SListDataType x)
{
	assert(phead);//检查空指针

	int i = SListSize(phead);
	SListInsert(phead, i, x);
}
//头删
void SListPopHead(Node* phead)
{
	assert(phead);//检查空指针
	assert(phead->next);//检查链表中是否存在有效数据

	SListDel(phead, 0);
}
//尾删
void SListPopBack(Node* phead)
{
	assert(phead);//检查空指针
	assert(phead->next);//检查链表中是否存在有效数据

	int i = SListSize(phead);//获取数据个数
	SListDel(phead, i - 1);  //删除最后一个数据
}

10,链表销毁

cs 复制代码
//销毁链表
void SListDistory(Node* phead);
//要销毁的链表的头指针

释放一个节点后,下一个节点就找不到了 。所以这里定义两个指针,del用来释放节点,next用来记录下一个节点的地址。

动画演示:

cs 复制代码
//销毁链表
void SListDistory(Node* phead)
{
	assert(phead);//检查空指针

	//遍历链表,释放节点
	Node* del = phead;//del用于释放节点
	Node* next = phead;//用于记录del节点的下一个位置,防止下一个节点地址丢失
	while (del)        
	{
		next = next->next;//先让next往后,记录del下一个节点的位置
		free(del);        //释放del节点,此时该接点的next已经丢失
		del = next;       //将下一个节点的地址(next)赋给del
	}
}

三,链表的特点和局限性

主要优点:

动态大小,按需分配:可以根据需要动态地创建和释放节点。

插入和删除效率高:在已知前驱节点的情况下,插入或删除一个节点的时间复杂度是 O(1)。只需要修改相邻节点的指针指向即可,不会像顺序表那样移动大量元素

内存利用率高:链表是按需分配内存 ,不会出现顺序表那种"分配了10000个空间但只用了5001个"的浪费情况。同时,它能充分利用内存中的碎片空间。但会产生大量的空间碎片。

主要局限性:

随机访问性能差:要访问第 i 个元素,必须从头到尾逐个遍历,时间复杂度为 O(n)。而顺序表通过下标访问是 O(1)

额外的内存开销:每个节点除了存储数据本身,还需要额外存储指向下一个节点的指针。而且会产生大量的内存碎片

CPU缓存命中率低:由于节点在内存中是分散存储的,CPU缓存很难连续命中链表的数据,CPU每次读取都要从新扫描一次内存,导致访问速度比连续存储的顺序表要慢得多。

反向遍历困难:单链表只能从头到尾单向遍历。可以使用双向链表改善,而这又会进一步增加每个节点的指针内存开销(需要 prev 和 next 两个指针)。

不支持部分算法:有些依赖随机访问的高效算法(如二分查找、快速排序之类的)在链表上不好实现,但也有办法,先转换为数组,再插入链表,比较麻烦。


终于写完啦,爽!

下一节写双向链表以及链表与顺序表的对比,下面是博客封面:侵删

------青空------

小红车:@白川shiro

相关推荐
java_nnnn1 小时前
Linux 学习第二篇:从零认识 Linux 文件系统、目录结构与基本命令
linux·运维·学习·1024程序员节
重生之我是Java开发战士1 小时前
【Redis】Redis五大数据类型
1024程序员节
一尺之棰1 小时前
DSP-定时器原理
1024程序员节
全栈小51 小时前
【C#】体验和使用墨衍MoGrow平台写一篇技术总结文章,提供C#,Quart.Net,统一捕获异常,定时任务关键词,看看出来效果如何
1024程序员节
蹊黎1 小时前
C++中stack、queue、priority queue类的部分简单实现
数据结构·c++·visualstudio·1024程序员节
hixiong1231 小时前
纯C# Paddle2Onnx 转换器
1024程序员节
愤怒火龙果1 小时前
AIShield 靶场 WP
人工智能·网络安全·1024程序员节
软件派1 小时前
Eden AI 保姆级介绍:一站式 AI 聚合网关,一个 API 调用上百种大模型与视觉语音能力
1024程序员节·统一 api·多模态 api
ai_coder_ai1 小时前
论平台工程(平台化架构)在云原生系统中的设计与实践
1024程序员节