数据结构与算法(3)--- 单链表

一、单链表的概念

单链表是属于线性结构中的一员,其表现在逻辑结构上相邻,物理结构上不一定相邻(通常不相邻)。其底层是由指针来维护线性关系。
形如:
单链表
上图就是单链表的结构,理解起来和顺序表相比,较为抽象,为了更好的理解此结构,下面我举一个生动形象的例子用来理解此结构:
火车与单链表
火车主要由两部分组成,一个是车头,一个是一节一节的车厢,车厢之间又由一个一个车钩进行连接。

通常我们见到的火车车厢与车厢之间并不是固定死的,而是一个个独立的空间,每当客流量小的时候,就会卸下几节车厢,以免造成空间浪费,客流量大的时候,又会多增加几节车厢,来满足客运需要。

其实上述描述的场景就和单链表的定义,一些功能的实现就极为相似了。

(1)“车头”— 在单链表里表示头指针(这个可要可不要)
(2)“车厢”—在单链表里表示一个一个独立的节点
(3)“车钩”— 在单链表里表示指针,这个指针的含义下文再解释

类比过来,单链表就是这样一个结构:
在这里插入图片描述
类比上面的加粗斜体字:

这里plist就是头指针— 既然是指针变量,那他就存储的是地址,那又是谁的地址呢?
经观察,它的地址和下一个结点的地址相同。(这里可不敢肯定这里一定存放的是下一个结点它的地址哦,万一是巧合呢)
再观察,这里的“车厢“也就是结点分为两个部分,一个是数据,一个是指针,而这个指针,存放的也是它的下一个节点的地址。(还是不敢肯定存放的是下一个节点的地址)
再往后观察,发现每一个结点的指针都存放的是其下一个结点它的地址,这时就敢肯定这个地址它存储的就是下一个节点的地址。

观察完毕,这时就可以给这三个结构(头指针,节点,地址)确定详细的定义了:

1)头指针就是指向第一个结点地址的一个指针变量
2)节点分为两个部分,一个用来存储数据的数据域,一个用来存储地址的指针域。
3)点2中的地址存放的是下一个结点它的地址。

通过上面3点定义,再加上单链表在物理结构上它是不相邻的,所以就可以推断出单链表在内存中的存储形式通常是什么样的:
内存空间中的单链表存储形式

为什么是通常为上图这种形式呢?
因为如果链表申请的空间是一块连续的空间,那每个节点指向的下一个节点地址就是连续的,所以这里为了严谨,描述成通常为上图这种形式。

二、单链表的操作

1.创建单链表结构

单链表是由一个一个节点所构成,所以定义单链表这种结构就是定义节点的结构。

//定义单链表结构 --- 也就是定义节点结构
typedef int SLNDataType;          //数据域存储的数据类型重命名
typedef struct SingleListNode
{
	SLNDataType data;             //数据域 -- 用于存储数据
	struct SingleListNode* node;  //指针域 -- 用于存储下一个节点的地址
}SLN;

注意这里的指针域的数据类型可不能使用重命名之后的SLN,因为当程序执行到指针域这里的代码时,typedef重命名代码还没执行,所以不可以使用SLN来定义。

2.创建节点

单链表这种数据结构可以不进行初始化,由于每个节点都是单独开辟空间,直接在创建时进行“初始化”即可。

//创建节点
SLN* SLNBuyNode(SLNDataType x)
{
	//每创建一个节点就开辟一个节点大小的空间
	SLN* newnode = (SLN*)malloc(sizeof(SLN));      
	if (newnode == NULL)
	{
		perror("malloc");
		exit(1);
	}
	//初始赋值
	newnode->data = x;          //数据域赋值为x
	newnode->node = NULL;       //指针域赋值为NULL
	return newnode;
}

3.打印链表

//打印链表
void SLNPrint(SLN** pphead)
{
	SLN* pcur = *pphead;
	while (pcur)               //pcur!=NULL
	{
		printf("%d-", pcur->data);
		pcur = pcur->next;     //找到下一节点
	}
	printf("NULL\n");
}

4.查找节点

//查找节点
SLN* SLNFInd(SLN** pphead, SLNDataType x)
{
	assert(pphead);         //pphead不能传空

	SLN* pcur = *pphead;
	while (pcur)
	{
		if (pcur->data == x)
		{
			return pcur;
		}
		pcur = pcur->next;  //找向下一个节点
	}
	return NULL;
}

查找首节点,测试运行:

在这里插入图片描述

没有查找到节点,测试运行:

在这里插入图片描述

5.尾插

尾插,也就是尾部插入。直接将节点指向NULL的指针改为指向插入的节点地址即可。
当链表为空时,插入的节点即为首节点;
当链表不为空时,要先找到链尾,再将最后一个节点的指针由NULL,指向插入节点的地址。

//尾插
void SLNPush_Back(SLN** pphead, SLNDataType x)
{
	//这里不用assert断言,因为空链表也是可以去插入的

	//先创建一个节点
	SLN* newnode = SLNBuyNode(x);

	if (*pphead == NULL)
	{
		//链表为空
		*pphead=newnode;
	}
	else 
	{
		//链表不为空
		//找尾
		SLN* ptail = *pphead;
		while (ptail->next)         //ptail->next!=NULL
		{
			ptail = ptail->next;
		}
		//找到了
		ptail->next = newnode;
	}

时间复杂度:O(N),空间复杂度:O(1)

这里尾插了4个节点:
尾插4个节点
调试观察:

尾插

测试运行:

尾插

6.头插

头插,即头部插入。
不管链表是否为空,都将该插入的节点视为首节点。

//头插
void SLNPush_Front(SLN** pphead, SLNDataType x)
{
	//这里同样不用assert断言,因为空链表也是可以去插入的

	//创建新节点
	SLN* newnode = SLNBuyNode(x);

	//将插入的节点作为首节点
	newnode->next = *pphead;
	*pphead = newnode;
}

时间复杂度:O(1),空间复杂度:O(1)

这里头插了4个节点:
头插

调试观察:

头插调试

测试运行:

头插

7.尾删

尾删,即删除尾部的节点。
当链表只有一个节点时,尾删操作就是直接把此节点销毁掉;
当链表有多个节点时,要先将最后一个节点的前一个节点的指针置为空,再销毁尾节点,如果反过来操作,会导致尾节点的前一个节点指向一个被释放掉的空间,变成野指针。

//尾删
void SLNPop_Back(SLN** pphead)
{
	//防止*pphead开始传为空
	//链表为空的时候不能再进行删除操作
	assert(pphead && (*pphead)!=NULL);   

	SLN* ptail = *pphead;    //找尾节点的指针
	SLN* prev = NULL;        //保存尾节点的前一个节点

	if ((*pphead)->next == NULL)
	{
		//链表就只有首节点
		free(*pphead);
		(*pphead) = NULL;
	}
	else 
	{
		//链表有多个节点
		//找尾
		while (ptail->next)
		{
			prev = ptail;         //保存上一个节点的地址
			ptail = ptail->next;  //找下一个节点
		}
		//找到了
		prev->next = NULL;        //先将尾节点的前一个节点置为空
								  //防止先销毁后,指针指向一个被销毁的空间,变成野指针

		free(ptail);              //再销毁
		ptail = NULL;
	}
}

时间复杂度:O(N),空间复杂度:O(1)

这里删除之前尾插的4个节点:
尾删

测试运行:

在这里插入图片描述

再尾删一次,测试运行:

当链表为空时,再删除

8.头删

头删,即头部删除,这里不分链表中有几个节点,当然不能为空,直接将首节点销毁掉,之后再将首节点的下一个节点作为首节点即可。

//头删
void SLNPop_Front(SLN** pphead)
{
	//防止*pphead开始传为空
	//链表为空的时候不能再进行删除操作
	assert(pphead && (*pphead) != NULL);

	SLN* next = (*pphead)->next;     //将首节点的下一个节点保存起来
	                                 //防止在释放掉首节点的空间后,找不到该节点
	free(*pphead);
	(*pphead) = NULL;
	*pphead = next;
}

时间复杂度:O(1),空间复杂度:O(1)

这里删除之前尾插的4个节点:
头删

测试运行:

头删结果

再尾删一次,测试运行:

当链表为空时,再删除会报错

9.任意pos位置插入节点

1)pos位置之前插入数据

当pos在首节点位置时,那么在pos位置之前插入数据就是头插操作;
当pos在非首节点位置时,即下面这种情况:

在这里插入图片描述

和尾删一样,不仅要找到尾节点,而且也要找到尾节点的前一个节点;在改变指针指向的时候要严格按照图中顺序进行,若顺序颠倒,则会找不到pos位置的节点。

//在任意pos位置之前插入节点
void SLNInsert(SLN** pphead, SLN* pos, SLNDataType x)
{
	//防止*pphead开始传为空
	//不能在空位置之前插入数据
	assert(pphead && pos);

	if (*pphead == pos)
	{
		//pos位置在首节点 --- 进行头插操作
		SLNPush_Front(pphead, x);
	}
	else
	{
		//pos位置为非首节点位置

		//创建节点
		SLN* newnode = SLNBuyNode(x);

		SLN* prev = *pphead;     //定义pos位置节点的前一个节点

		//找到pos位置
		while (prev->next != pos)
		{
			prev = prev->next;   //找向下一个节点
		}
		//找到了
		newnode->next = pos;
		prev->next = newnode;
	}
}

测试运行:

(1)pos在首节点位置

在这里插入图片描述

(2)pos在非首节点位置

在这里插入图片描述

2)pos位置之后插入数据

pos位置之后插入节点不用管pos在哪个位置(当然pos不能为空),都是如下图的插入方式:

在这里插入图片描述

//在任意pos位置之后插入节点
void SLNInsertAfter(SLN** pphead, SLN* pos, SLNDataType x)
{
	//防止*pphead开始传为空
	//不能在空位置之后插入数据
	assert(pphead && pos);

	//创建节点
	SLN* newnode = SLNBuyNode(x);
	
	//不管pos在哪个位置都是这样改变指针指向
	newnode->next = pos->next;
	pos->next = newnode;
}

测试运行:

(1)pos在首节点位置插入节点:
在这里插入图片描述

(2)pos在尾节点之后插入节点:
在这里插入图片描述

10.任意pos位置删除节点

当pos在首节点位置时,删除操作相当于头删;
当pos为非首节点位置时,需要找到pos位置节点的前一个节点,将它的指针指向pos指向的节点地址,再将pos节点销毁。

//在任意pos位置删除节点
void SLNErase(SLN** pphead, SLN* pos)
{
	//防止*pphead开始传为空
	//不能在空位置删除数据
	assert(pphead && pos);

	if (pos == *pphead)
	{
		//pos在首节点位置 --- 头删
		SLNPop_Front(pphead);
	}
	else
	{
		//pos在非首节点位置

		//定义pos位置的前一个节点指针
		SLN* prev = *pphead;

		//找pos的前一个节点
		while (prev->next != pos)
		{
			prev = prev->next;  //找向下一个节点
		}
		//找到了
		prev->next = pos->next;
		free(pos);
		pos = NULL;
	}
}

测试运行:

(1)当pos在首节点位置时:
在这里插入图片描述

(2)当pos为非首节点位置时:
在这里插入图片描述

11.任意pos位置之后删除节点

这种情况下,函数参数可以不用传首节点的地址,pos能直接锁定其位置,直接销毁掉pos位置之后的节点即可。

//在任意pos位置之后删除节点
void SLNEraseAfter(SLN* pos)
{
	//不能在空位置删除节点
	//链表为空时,不能再删除
	assert(pos && pos->next);

	//定义pos的下一个节点
	SLN* del = pos->next;

	pos->next = del->next;
	free(del);
	del = NULL;
}

测试运行:

删除首节点位置之后的节点:

在这里插入图片描述

12.销毁链表

由于对链表进行操作是一个一个申请节点进行操作的,所以要将每个节点的空间都释放掉。
遍历链表直接释放节点会导致找不到被释放节点的下一个节点地址,所以在释放空间前要先保存下一个空间的节点,再去释放节点空间。

//销毁链表
void SLNDestroy(SLN** pphead)
{
	//链表不能传空
	assert(pphead);
	
	//定义pcur指针代替*pphead
	SLN* pcur = *pphead;

	while (pcur->next)
	{
		//先保存要释放的节点的下一个节点
		SLN* next = pcur->next;
		free(pcur);
		pcur = NULL;
		pcur = next;
	}
	*pphead = NULL;   //最后再将链表手动置空。
}

三、单链表与顺序表的区别

顺序表底层是数组,它的逻辑结构是相邻的,物理结构也是相邻的。在进行插入删除操作时,尾插,尾删操作比头删,头插操作时间复杂度更低,效率更高。

链表底层是指针,它的逻辑结构是相邻的,物理结构通常不相邻。在进行插入删除操作时,头插,头删操作比尾删,尾插操作时间复杂度更低,效率更高。
所以以上两种数据结构没有优劣之分,在适合的操作下选择适合的数据结构即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值