当前位置: 首页 > news >正文

数据结构篇:线性表的另一表达—链表之单链表(上篇)

目录

1.链表的引入

1.1 链表的概念

1.2 next的意义

2.链表的分类

3.单链表的实现

3.1 单链表实现接口        

3.1.1 插入节点函数封装     

3.1.2 尾插

3.1.3 头插

3.1.4 报错的根本问题

3.1.5 头删

3.1.6 尾删

4.小结        


1.链表的引入

        根据顺序表的一些缺陷,引出一个线性表的另外一种结构——线性表,可以弥补顺序表如下的不足:

  1. 插入和删除数据,要多次移动元素。
  2. 空间上消耗比较大,由于是增容性质,每次扩容需要开辟新的空间并且拷贝数据,内存消耗大,且难以避免空间浪费。

        那么我们就会引入链表,链表相较于顺序表到底有哪些优势?又有哪些劣势,本次blog带你一探究竟,但是可以说,链表和顺序表没有那个更好,只是在实际应用中由于特性不同,所以使用场景不同。试想一下,如果这两个有哪个更好的说法,那不是另外一个要被替代了,很显然不是。

1.1 链表的概念

        顺序表的物理结构是连续的,这个我已经在上次顺序表篇讲过且实机演示过了。那么链表呢?其实链表在物理结构空间上是不连续的,非顺序的,但是为了方便构思,链表在逻辑结构上是连续的,是通过链表中的指针连接实现的。这是自己画的逻辑图和物理结构图:

链表物理结构和逻辑结构

        从上图可以看出

  1. 链式结构的物理地址是不一定连续的,虽然逻辑结构上是连续的
  2. 链式结构的节点是从堆上申请的空间
  3. 堆上的空间,是按照动态内存管理分配的,所以根据其动态性质,再次申请的空间,通常情况下也是非连续的

可能不是那么好理解,大概率是要调试后才明白,那就来调试一下链表的物理结构到底连不连续,看数据说话:

单链表元素1的地址
单链表元素2的地址
单链表元素3的地址

         那么从前面三个数据1,2,3的物理地址可以看出来,0x00b06300,0x00b06338,0x00b040d0

        不符合一个整形4个字节的内存分布,是不连续的,是无序的,而且差距有的小,有的大。(这里是在监视窗口看的,也可以在内存中看,不过要F11进入函数内部然后看你对应数据的变量,&xxx->data,回车查看,一定要取地址),比如博主的节点数据定义是newnode。

        而逻辑结构就比较清晰,是连续的。但是本文的重中之重来了,有没有发现物理结构中,数据都是分上下两层


        1下面的物理地址和2上面物理地址一样,2下面的地址和3上面的一样。


并且逻辑结构中A后面有个小空间是空的,是next,next就是本文的重点了,理解了next,链表的基本结构就是了解了。博主一开始也是一知半解,看了其他博主的相关博客和写了一些牛客力扣题后才逐渐明白并且理解运用。

1.2 next的意义

        next是下一个的意思,简单的理解为在上一个节点/尾节点存放下一个/新尾节点的地址。记住,next是在原来节点的。再次用一下上表的调试图

        图中的data=1中的next是不是6338。而next对应的数字是data=2。data=2时,确实物理地址也是6338。


        第二个next是49d0,对应的数字是data=3,3的物理地址就是40d0。这也很好的解释了原节点next存放的是下一个data=2的地址。这样子讲应该可以理解吧。这里是本文的第一个重点!是全文的铺垫内容。

2.链表的分类

        链表从有头,无头;双向,单向;有无循环分为8种,分别是有头双向循环,有头双向无循环;有头单向循环,有头单向无循环;无头双向循环,无头双向无循环;无头单向循环,无头单向无循环。

有无头节点,单双向
是否循环

        但是常见的是两类,不带头单向非循环:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构。

不带头单向非循环

        带头双向循环: 结构复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。


另外这个结构虽然结构看着复杂,但是知道单链表怎么运用,手撕双链表代码会发现结构会带来很多优势,反而简单了,后面我们代码实现了就知道了。所以先看单链表。

带头双向循环

3.单链表的实现

        无头单向非循环链表,也叫单链表,单链表的实现的形式也是增删查改。那么单链表结构体中需要有数据,还有下一个节点。所以需要去定义。

3.1 单链表的定义

        为了方便和顺序表的int区别开,int重命名为Sltdatatype,然后定义一个data和下一个节点。

typedef int Sltdatatype;
typedef struct SListnode
{
	Sltdatatype data;
	struct SListnode* next;
}SLTnode;

3.1 单链表实现接口        

        之后便是我们熟悉的给节点动态申请空间(一个节点申请一个空间)增删查改,打印,销毁,那么自然少不了头插尾插,头删尾删和中间位置删除和插入。 这里没有初始化的原因是因为单链表就一个data值,不像顺序表那样有多个值,所以不需要初始化。

void SLTprint(SLTnode *phead);

SLTnode* insertSLTnode(Sltdatatype x);

void SLTpushback(SLTnode* pphead, Sltdatatype x);

void SLTpopback(SLTnode* pphead);

void SLTpushfront(SLTnode* pphead, Sltdatatype x);

void SLTpopfront(SLTnode* pphead);

SLTnode* SLTfind(SLTnode* pphead, Sltdatatype x);
//pos之前插入
void SLTinsert(SLTnode *pphead,SLTnode*pos,Sltdatatype x);

//pos位置删除
void SLTErase(SLTnode* pphead, SLTnode* pos);

//pos后面插入
void SLTinsert_afterpos(SLTnode*phead,SLTnode* pos,Sltdatatype x);

//pos后面删除
void SLTerase_afterpos(SLTnode* pphead, SLTnode* pos);
//链表销毁
void SLTdestory(plist);

3.1.1 插入节点函数封装     

        和之前一样,插入的地方都要用到插入的函数,那干脆就单独封装成一个函数,好处就不再强调了。插入函数,就是申请一块结构体大小的空间给新节点,这个结构体的类型是SLTnode。申请成功的话就把要插入的数赋值给新节点的data,然后新节点存放下一个地址的next置空。

SLTnode* insertSLTnode(Sltdatatype x)
{
	//申请一块空间给新的节点
	SLTnode* newnode = (SLTnode*)malloc(sizeof(SLTnode));
	if (newnode == NULL)
	{
		perror("fail malloc");
		return 0;
	}
	newnode->data = x;
	newnode->next = NULL;
	return newnode;
}

        完成了这个插入函数的撰写,那么就可以进行尾插头插等工序了。

3.1.2 尾插

        要知道,一开始肯定是链表肯定是没有数据,是空的。所以当链表是空的时候,就让pphead指向新节点。之后每次就找最后一个节点的位置,也称为尾节点,尾节点怎么找呢,可以想象一下这个节点的下一个为空,那么该节点就是最后一个节点,所以用循环。不为空就表示这个节点下面最少还有一个,如此即可。这里我画了几个接口实现的逻辑图,便于自己理解,先画图再敲代码

单链表各部分操作逻辑图

        找到后进行关键一步的操作,也就是next的链接作用。找到尾节点,然后尾节点的next指向新节点。按照上面的说法,也可以叫尾节点的next存放新节点的地址。

void SLTpushback(SLTnode *pphead,Sltdatatype x)
{
	assert(pphead);
	SLTnode* newnode = insertSLTnode(x);
	if (pphead==NULL)
	{
		pphead = newnode;
	}
	//找尾
	else
	{
		//定义尾变量
		SLTnode* tail = pphead;
		while (tail->next!=NULL)
		{
			tail = tail->next;
			
		}
		//tail->next这个指针指向新节点
		tail->next= newnode;
	}
	
}

3.1.3 头插

        同尾插一样是,一开始链表没有数据的情况下,也可以不单独判断,因为即使空指针,新节点的next指向*pphead,这个*pphead目前是NULL啊。然后plist指向newnode,或者说newnode赋值给*pphead,变成新头了。始终记住*pphead是plist的值


        之后就要把newnode新节点给传过来的指针之后每次在节点之前进行头插。按照逻辑图的思路,是newnode的下一个也就是next指向目前plist的第一个值。然后plist指向新节点,从而使新节点变成新的头部。

void SLTpushfront(SLTnode* pphead, Sltdatatype x)
{
	assert(pphead);
	SLTnode* newnode = insertSLTnode(x);
	//把newnode的下一个给pilst
	newnode->next = pphead;
	//链表指针指向新节点,变成新的头部
	pphead =newnode;
}

         接下来,我们来局部测试一下看看是否正确,写一个测试用例


void test()
{
	SLTnode* plist = NULL;

	SLTpushback(plist, 1);
	SLTpushback(plist, 2);
	SLTpushback(plist, 3);
	SLTpushback(plist, 4);
	SLTpushfront (plist, 5);
	SLTprint(plist);
int main()
{
test();
return 0;
}

        断言表示,错误在Slist.c第24行,让我们看看错误。这是为什么呢?        调试可知,传过来的pphead是NULL的,空的报错也无可厚非了。其实当时的我遇到这个问题也是百思不得其解。感觉我操作的没问题,起码思路上面没有任何毛病。这让我很头疼,大概过了两三天,依旧是搜索了其他博主关于线性表,链表实现头插尾插的各种细节,包括头文件声明,测试部分和具体实现文件上,也问了AI,最后得以找到了我的问题所在,以便让我再次对指针有了新的认知。

3.1.4 报错的根本问题

         错误的原因同样也是这篇文章的第二个关键点,我在这里犯了一个认知型错误,究其原因就是对指针的了解还不够。


首先形参是实参的拷贝,形参的改变不影响实参


而且形参出了作用域就销毁了。先讲个样例展示我的错误。

void fun(int y )
{
    y=10;
}
int main()
{
    int x =2;
    fun(x);
    return 0; 
}

y的改变肯定是不会改变x的。要修正

void fun(int *p )
{
    *p=10;
}
int main()
{
    int x =2;
    fun(&x);
    return 0; 
}

这样子才能改变,因为改变int要用int *指针,画个图说明就是

传址

        p不是x的值的拷贝,而是x的地址的拷贝,是把x的地址存放在了p指针里。使用*解引用,找到x,并且改掉。把这个写法换到我们这次的错误上并简化就是:

void Func(int *ptr)
{
    ptr=(int*)malloc(sizeof(int));
}
int main()
{
    int *p =NULL;
    Func(p);
    return 0;
}

        这是把p的值传给了ptr,然后ptr申请了一块空间,指针ptr指向空间,但是ptr的改变没有影响到px,出了作用域销毁了。

传值

        真相就是改变指针变量的形参,同样不会影响实参。改指针的时候给一个函数传指针本身是无效的,需要传的是指针的指针,即二级指针。通俗讲,改变int,要传int*指针,改变int*,传int**指针。改正之后就是如下代码:

void Func(int **ptr)
{
    *ptr=(int*)malloc(sizeof(int));
}
int main()
{
    int *p =NULL;
    Func(&p);
    free(p)
    return 0;
}

       把p的地址传过去,ptr存放p的地址,也就是指针的地址,二级指针,*ptr就是p的内容,从而达到在函数内部改变实参p的效果:p指向申请的空间。总结改变int*,传int*的地址,用int**。


其实自己犯的错误就是错误使用指针了。所以上文的错误就是我要改变的plist是一个一级指针,所以一开始传的plist本身没有任何效果,需要传二级指针,实参部分要取plist的地址,存放在SLTnode* *pphead里。*pphead的值就是plist的值。

         那么修正好我们的头插尾插的代码后就是

void test()
{
	SLTnode* plist = NULL;
	SLTpushback(&plist, 1);
	SLTpushback(&plist, 2);
	SLTpushback(&plist, 3);
	SLTpushback(&plist, 4);
	SLTpushfront(&plist, 5);
	SLTprint(plist);
}
int main()
{
	
	test();
	return 0;
}
//尾插
void SLTpushback(SLTnode **pphead,Sltdatatype x)
{
	assert(pphead);
	SLTnode* newnode = insertSLTnode(x);
	if (*pphead==NULL)
	{
		*pphead = newnode;
	}
	//找尾
	else
	{
		//定义尾变量
		SLTnode* tail = *pphead;
		while (tail->next!=NULL)
		{
			tail = tail->next;
			
		}
		//tail->next这个指针指向新节点
		tail->next= newnode;
	}
	
}
void SLTpushfront(SLTnode** pphead, Sltdatatype x)
{
	assert(pphead);
	SLTnode* newnode = insertSLTnode(x);
	//把newnode的下一个给pilst
	newnode->next = *pphead;
	//链表指针指向新节点,变成新的头部
	*pphead =newnode;
}

        运行结果,运行成功!

         这个问题如果对指针的用法不是很掌握的话,就会和我一样,踩这个大坑。所以这个传指针也是个坑,而且后面还会考到就是二级指针的用法。

3.1.5 头删

        清楚了头插的错误并且改正,那么头删的部分就比较好理解了,首先要改变plist指针的内容,改变int*用int**二级指针。并且进行断言。因为pphead是plist的地址,一定不会为空,为空就像我犯的错误一样,传错了。这个断言问题后面会单独介绍,因为同样重要

        看上文的思路图,把*pphead就是plist的值给first指针,在我的理解就是让first代替plist操作,此时first指向plist的第一个节点。之后让*pphead指向first下一个节点,就是第二个节点。意味着plist指向第二个了。如果只有一个节点,就直接释放掉。

        代码如下:

void SLTpopfront(SLTnode** pphead)
{
	assert(pphead);
	assert(*pphead);

	if ((*pphead)->next == NULL)
	{
		free(*pphead);
		*pphead = NULL;
	}
	
	//第一个节点的内容,给到first这个指针
		SLTnode* first = *pphead;
		//plist指向first的下一个地址,就是第二个节点
		*pphead = first->next;
		free(first);
		//删除第一个
		first = NULL;
}

3.1.6 尾删

        根据最上面的思路图可以知道,尾删需要释放掉最后一个节点,需要定义一个前指针保存尾巴的前一个,用来更新指针位置。这里依旧需要断言,而且不止检查是否穿错指针,要检查plist的内容,考虑如果是空链表就不能删了,空的删了不就出错了吗。只有一个节点的话直接释放掉就可以 了。

void SLTpopback(SLTnode** pphead)
{
	//检查
	assert(pphead);
	assert(*pphead);
	//一个节点
	if ((*pphead)->next==NULL)
	{
		free(*pphead);
		*pphead = NULL;
	}
	//多个节点
	else
	{
		// 找尾
		SLTnode* former = NULL;
		SLTnode* tail = *pphead;
		while (tail->next != NULL)
		{
			former = tail;
			tail = tail->next;
		}

		free(tail);
		
		tail = NULL;
		former->next = NULL;

	}
}

4.小结        

        讲到这里就先停了,本文构思了很久,消化一下。头插尾插和头删尾删讲完,最重要的两个部分是链表中对于next的理解和一级指针,二级指针在传参时候的区别。接着引入链表的优势答案在这自然也就能体会到了,链表的修改元素比顺序要方便很多,不需要遍历,只需要改变指针指向,并且它是没有顺序表所谓的空间容量概念,不需要空间,避免了空间浪费。

        然后后面的函数:中间某个位置之前插入/某个位置删除/某个位置之后插入/某个位置后面删除逻辑是差不多的。而且特殊位置的话和头删尾删/头插尾插效果是一样的,可以复用。

        该位置是第一个的话,位置之前插入,不就等于头插吗?位置删除就等于头删

        该位置是最后一个的话,某个位置之后插入,就是尾插;倒数第二个位置后面删除就等于尾删。那么想要实现以上的函数可以用这几个函数复用,简洁了许多不是吗?关于其他的销毁函数,查找函数和断言问题(有点绕,要了解一级二级指针的情况下结合实际情况实际考虑)。下文继续~~,感谢观看。

相关文章:

  • 地理数据输出
  • 解决缓存穿透的布隆过滤器与布谷鸟过滤器:谁更适合你的应用场景?
  • dify文本生成图片
  • 在Ubuntu系统如何让MySQL服务器支持远程连接
  • Elucidating the Design Space of Diffusion-Based Generative Models
  • 【Pandas】pandas DataFrame bool
  • 人工智能与认知科学的交汇:机器是否能“理解”?
  • 【Springboot知识】Springboot进阶-Micrometer指标监控深入解析
  • wsl中迁移ubuntu24.04后docker后无法启动的问题
  • 聊一聊接口测试时需要注意哪些
  • FPAG_BUFFER学习
  • Elasticsearch入门指南(一)
  • zsh: command not found - 鸿蒙 HarmonyOS Next
  • Java 中 JSON 处理库将对象含二进制数组转换为 JSON 字符串
  • ROS2——foxy apt打包离线安装deb包
  • YOLOv11训练中精准率召回率与mAP@0.5的动态变化分析
  • 配置与管理代理服务器
  • openEuler-22.03-LTS-SP3 编译安装 Greenplum-db 6.20.0
  • 【检测手机有无网络】
  • 自动化测试——selenium
  • 计算机专业网站开发方向/百度最新推广产品
  • 免费做标签格式网站/鸿星尔克网络营销案例分析
  • 国内免费可商用图片素材网站/seo优化设计
  • app开发需要多久/seo优化推广教程
  • 南昌网站优化方案/商丘seo教程
  • 网站制作软件工程师/武汉大学人民医院洪山院区