小编主页详情<-请点击
小编gitee代码仓库<-请点击


本文主要介绍了数据结构的单链表,内容全由作者原创(无AI),同时深度解析了单链表增删查改等功能,并带有配图帮助博友们更好的理解,点个关注不迷路,下面进入正文~~


前言:

学习了顺序表之后,我们发现顺序表存在以下问题
1.中间/头部插入效率低下
2.增容降低运行效率
3.增容造成空间浪费

那么有什么办法可以解决这些问题?下面我们来介绍链表的概念

1.链表的概念及结构

概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的顺序结构是通过链表中的指针链接次序实现的。

链表也是线性表的一种~
物理结构:不是线性的
逻辑结构:一定是线性的

那么链表具体是什么东西呢?
举一个具体的例子:

链表的结构跟火车很类似,如果是旺季,我们可以增加车厢;如果是淡季,我们可以减少车厢,每一节车厢都是独立的,添加或减少车厢不会影响其他车厢。
想象一下,假如每节车厢的车门都是上锁的,并且需要用不同的钥匙打开,每次只能携带一把钥匙的情况下如何从车头走到车尾?
最简单的做法,在每一节车厢都存放下一届节车厢的钥匙

在链表里,每一节车厢是长什么样的?

每个车厢,我们称之为节点/结点
节点的组成主要有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)。
图中的指针变量plist保存的是第一个节点的地址,我们称plist此时指向第一个节点。如果我们希望plist指向第二个节点时,只需修改plist保存的内容为0x0012FFA0。

2.单链表的具体功能的实现

2.1单链表需要实现的功能

//打印链表
void SLTPrint(SLTNode* phead);

//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x);
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);
//尾删
void SLTPopBack(SLTNode** pphead);
//头删
void SLTPopFront(SLTNode** pphead);

//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);

//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);

//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos);
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos);

//销毁链表
void SListDesTroy(SLTNode** pphead);

2.2打印链表

通过phead(第一个节点的指针)先访问数据,再移动到head->next(下一个节点的指针),知道phead为空才停止访问,最后打印NULL收尾。
我们习惯用临时变量pcur保存第一个节点的地址,这样做是为了方便我们后续找到第一个节点的地址。

void SLTPrint(SLTNode* phead)
{
	SLTNode* pcur = phead;
	while (pcur)//pcur != NULL
	{
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("NULL\n");
}

2.3链表的的尾插

在实现尾插之前,我们先画个图帮助我们理解
思路:
1.创建新节点
为了方便后续创建新节点的使用,我们将创建新节点这一步骤定义为一个函数SLTBuyNode

SLTNode* SLTBuyNode(SLTDataType x)
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	if (newnode == NULL)
	{
		perror("malloc");
		exit(1);
	}
	newnode->data = x;
	newnode->next = NULL;
	return newnode;
}

2.创建尾节点的指针
我们要尾插一个节点,因此我们要找到尾节点的指针ptail,然后将ptail->next改为新创建节点的地址,因此找到尾节点是必要的。
我们可以先将ptail指向第一个节点,当ptail->nex不等于NULL时将ptail = ptail->next,直到ptail->next为NULL,此时就说明了我们找到尾节点的指针了。

SLTNode* ptail = *pphead;
while (ptail->next)
{
	ptail = ptail->next;
}

3.将ptail->next指向新节点

ptail->next = newnode

那我们不加思考地写出完整的代码

void SLTPushBack(SLTNode* phead, SLTDataType x)
{
	SLTNode* newnode = SLTBuyNode(x);
	SLTNode* ptail = phead;
	while (ptail->next)
	{
		ptail = ptail->next;
	}
	ptail->next = newnode;
}

我们发现程序并没有像我们如期的一样打印1->NULL,这是为什么呢?

我们想想,如果我们的链表一开始为空链表,及plist=phead=ptail=NULL,那我们还能进行ptail->next吗?很显然不行,程序会报错。因此我们需要判断传入的phead是否为NULL,如果是,直接让phead指向新节点;如果不是,再进行后面的操作,新代码如下

void SLTPushBack(SLTNode* phead, SLTDataType x)
{
	//assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	if (phead == NULL)
	{
		phead = newnode;
	}
	else
	{
		SLTNode* ptail = phead;
		while (ptail->next)
		{
			ptail = ptail->next;
		}
		ptail->next = newnode;
	}
}

我们再运行一下

我们发现这次程序成功输出了,但却只输出了NULL,并没有输出我们预想的1->NULL,这又是为什么呢?别急,我们调试一下

我们发现plist并没有指向新的节点,而是NULL,说明plist的值并没有发生改变
这是因为我们在函数传参的时候使用的是传值调用,形参的改变要影响实参,必须要传地址

第一个节点                                   *plist<------>**pphead
指向第一个节点的指针                   plist<------->*pphead
指向第一个节点的指针的地址        &plist<------>pphead

那么我们要改变指向第一个节点的指针,就要传入指向第一个节点的指针的地址   &plist  
同时不要断言pphead,assert(pphead),防止传入了一个空指针NULL,下面是修改后正确的代码

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->next = newnode;
	}
}

我们运行一下代码

结果是1->NULL,结果没有任何问题
通过这个头插的代码,给了我们两个启发:

1.写代码的时候,要着重思考头和尾的特殊情况
2.当我们需要改变函数的实参,必须要传入实参的地址

2.4链表的头插

思路:
1.创建新节点
2.newnode->next=*pphead
3.*pphead=newnode

void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	newnode->next = *pphead;
	*pphead = newnode;
}

我们检查一下,当链表为空链表时,*pphead为NULL,newnode->next为NULL,*pphead被赋值为新创建节点的地址,没有任何问题

2.5链表的尾删

思路:
1.创建ptail找尾节点的地址
2.创建prev找到尾节点前一个节点的地址
3.将ptail指向的节点的空间释放掉,并置为NULL
4.prev->next=NULL;
5.断言*pphead和pphead,如果链表为空那就没有节点可以删除

根据这个思路,我们写出如下代码:

void SLTPopBack(SLTNode** pphead)
{
	assert(pphead&&*pphead);
	SLTNode* ptail = *pphead;
	SLTNode* prev = *pphead;
	while (ptail->next)
	{
		prev = ptail;
		ptail = ptail->next;
	}
	free(ptail);
	ptail = NULL;
	prev->next = NULL;
}

同样的,我们来思考一下,如果链表只有一个节点,代码可不可行?
ptail和prev都可以正常创建,ptail->next=NULL,不会进入循环,ptail指向的空间被释放掉,注意,ptail和prev指向的是同一块空间,因此prev此时是野指针,对野指针使用箭头操作符访问成员元素,编译器会直接报错,因此,如果链表只有一个节点,代码不可行

当只有一个节点时,不用创建prev和ptail变量,只需将*pphead指向的空间释放掉并置为NULL即可,更正后的代码如下:

void SLTPopBack(SLTNode** pphead)
{
	//链表不能为空
	assert(pphead&&*pphead);
	//链表只有一个节点
	if ((*pphead)->next == NULL)//->优先级高于*
	{
		free(*pphead);
		*pphead = NULL;
	}
	//链表有多个节点
	else
	{
		SLTNode* ptail = *pphead;
		SLTNode* prev = *pphead;
		while (ptail->next)
		{
			prev = ptail;
			ptail = ptail->next;
		}
		free(ptail);
		ptail = NULL;
		prev->next = NULL;
	}
}

这里需要注意,->优先级高于*,因此需要用( )先对pphead进行解引用操作

2.6链表的头删

思路:
1.链表不能为空
2.创建next变量存储(*pphead)->next
3.free(*pphead)
4.*pphead=next

void SLTPopFront(SLTNode** pphead)
{
	assert(pphead && *pphead);
	SLTNode* next = (*pphead)->next;//-> 优先级高于*
	free(*pphead);
	*pphead = next;
}

2.7链表的查找

如果找到了数据为x的节点,返回此节点的地址;如果没找到,返回空指针NULL

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

2.8在指定位置之前插入数据

思路:
1.创建新节点
2.找到pos的前一个节点prev
3.prev->next=newnode
4.newnode->next=pos

根据思路我们写出如下代码:

void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
	assert(pphead && *pphead);
	SLTNode* newnode = SLTBuyNode(x);
	SLTNode* prev = *pphead;
	while (prev->next != pos)
	{
		prev = prev->next;
	}
	prev->next = newnode;
	newnode->next = pos;
}

那么,当链表只有一个节点的时候,还能正常头插吗?
当链表只有一个节点的时候,*pphead=pos=prev,prev->next为NULL,prev->next!=pos条件为真,进入循环,prev被赋值为NULL。对为NULL的prev进行箭头访问数据时,编译器会报错,不能正常头插。

因此当*pphead=pos时,直接使用链表头插函数,正确的代码如下

void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
	assert(pphead && *pphead);
	assert(pos);
	//若pos == *pphead;说明是头插
	if (*pphead == pos)
	{
		SLTPushFront(pphead, x);
	}
	else
	{
		SLTNode* newnode = SLTBuyNode(x);
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		prev->next = newnode;
		newnode->next = pos;
	}
}

2.9在指定位置之后插入数据

问一个问题:先红色后绿色可行吗?
答案:不可行!
当pos->next=newnode时,就无法通过pos->nest找到下一个节点了

正确代码如下

void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
	assert(pos);
	SLTNode* newnode = SLTBuyNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

2.10删除pos节点

思路:
1.创建变量prev指向pos前一个节点
2.prev->next=pos->next
3.释放pos并置NULL

根据这个思路我们写出下面代码

void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead && *pphead);
	assert(pos);
	SLTNode* prev = *pphead;
	while (prev->next != pos)
	{
		prev = prev->next;
	}
	prev->next = pos->next;
	free(pos);
	pos = NULL;
}

如果链表中pos为头节点,还能正常删除吗?我们推测一下
如果pos为头节点,那么刚开始的时候*pphead=pos=prev,prev->next为NULL,prev->next!=pos条件为真,进入循环,prev被赋值为NULL。对为NULL的prev进行箭头访问数据时,编译器会报错,不能正常删除。

因此当*pphead=pos时,直接使用链表头删函数,正确的代码如下

void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead && *pphead);
	assert(pos);
	if (*pphead == pos)
	{
		SLTPopFront(pphead);
	}
	else
	{
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		prev->next = pos->next;
		free(pos);
		pos = NULL;
	}
}

2.11删除pos之后的节点

思路:
1.如果先将pos->next指向pos->next->next,那么将无法找到需要删除的数据并无法将其释放
   如果先将pos->next释放掉,那么将无法通过pos->next找到pos->next->next
   因此我们要先定义一个临时变量del记录pos->next
2.将pos->next指向pos->next->next
3.释放del并置零

需要注意pos不能为最后一个节点,所以需要对pos->next断言,下面为正确的代码

void SLTEraseAfter(SLTNode* pos)
{
	assert(pos && pos->next);
	SLTNode* del = pos->next;
	pos->next = pos->next->next;
	free(del);
	del = NULL;
}

2.12销毁链表

思路:通过pcur遍历整个链表,用next记录pcur->nest即pcur的下一个节点的地址,将pcur所指向的空间释放掉后,再将pcur赋值为next。当pcur为NULL时停止循环。正确代码如下:

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

结语:

这篇文章全文由作者手写,图片由画图软件所制,无AI制作,希望各位博友能有所收获
,欢迎各位博友的讨论,觉得不错的小伙伴,别忘了点赞关注哦~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐