首页 > 其他分享 >【数据结构初阶】单链表接口实现超详解

【数据结构初阶】单链表接口实现超详解

时间:2024-10-25 23:18:52浏览次数:9  
标签:结点 单链 cur pos next 初阶 SListNode 数据结构 节点

1. 顺序表问题与思考

上一篇博客中我们介绍了顺序表,那么顺序表有什么缺点呢?

  1. 中间 / 头部的插入删除,时间复杂度为O(N)
  2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
  3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。

如何解决以上问题呢? 我们可以使用单链表

2.单链表

2. 1 概念与结构

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

链表就像一个火车:

【数据结构初阶】单链表接口实现超详解_链表

淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉加上,不会影响其他车厢,每节车厢都是独立存在的。

在链表里,每节“车厢”是什么样的呢?

【数据结构初阶】单链表接口实现超详解_链表_02

2.1.1 结点

与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,我们称之为“结点”。结点的组成主要有两个部分:当前结点要保存的数据保存下一个结点的地址(指针变量)。图中指针变量 plist保存的是第一个结点的地址,我们称plist此时指向第一个结点,如果我们希望plist指向第二个结点时,只需要修改plist保存的内容为0x0012FFA0。链表中每个结点都是独立申请的(即需要插入数据时才去申请一块结点的空间),我们需要通过指针变量来保存下一个结点位置才能从当前结点找到下一个结点

2.1.2 链表的性质

  1. 链式机构在逻辑上是连续的,在物理结构上不一定连续
  2. 结点一般是从上申请的
  3. 从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续

结合前面学到的结构体和顺序表知识,我们可以给出每个结点对应的结构体代码:

typedef int SLTDataType;

typedef struct SListNode
{
	SLTDataType data;		//用于存储数据
	struct SListNode* next;	//指向下一个节点
}SLTNode;

当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存**,这块内存不仅要保存整型数据,也需要保存下一个结点的地址**(当下一个结点为空时保存的地址为空) 当我们想要从第一个结点走到最后一个结点时,只需要在当前结点拿上下一个结点的地址就可以了。

3. 单链表实现

不带头单向不循环链表

记得进行良好的文件管理

【数据结构初阶】单链表接口实现超详解_结点_03

3. 1 单链表初始化

实际上,单链表并不需要一个用于初始化的函数,这是由单链表的性质决定的。在使用单链表时,需要在 main 函数中创建一个 SLTNode* 的变量,再将它的地址传递给其他函数就可以了。

为什么在最开始要创建一个指针变量?这个问题在后面头插函数中解释。

3. 2 单链表的打印

单链表的底层结构不是数组了,那我们应该怎样进行打印呢?或者说,我们应该怎么遍历单链表?

void SListPrint(SListNode* plist);	//phead是在main函数中创建的变量,是单链表的头

我们可以用一个SLTNode*变量 pcur 来遍历数组,在打印了pcur->data之后,让pcur变为pcur->next,直到cur==NULL结束,就可以了。

void SListPrint(SListNode* plist)
{
	SListNode* cur = plist;
	while (cur)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}
	printf("NULL\n");
}

如图所示:

【数据结构初阶】单链表接口实现超详解_结点_04

3. 3 头插

void SListPushFront(SListNode** pplist, SLTDateType x); //注意第一个参数是二级指针

要完成头插,我们需要完成几个步骤:

  1. 创建新节点
  2. 将头结点的next指针指向原来的头结点
  3. 将头结点换成这个新插入的节点

首先第一步创建新节点,这个步骤在其他的插入函数中也会用到,所以我们先跳过,稍后单独封装一个函数BuyListNode方便使用。假设我们得到了新节点newnode,完成第二步就很简单了,只需要newnode->next=*pphead 就可以了。第三步,pphead是指向头结点的指针,因此要修改头结点,只需要修改*pphead就可以了(这也就是为什么要传递二级指针

参考代码:

void SListPushFront(SListNode** pplist, SLTDateType x)
{
	assert(pplist);		//在函数解引用之前断言它不是空指针
	SListNode* newnode = BuySListNode(x);
	if (*pplist == NULL)
		*pplist = newnode;
	else
	{
		newnode->next = *pplist;
		*pplist = newnode;
	}
}

值得注意的是,最后两步的顺序绝对不可以颠倒

3. 4 创建新节点

创建新节点一般是在插入时调用的,因此将存储数据这一步直接放在函数内部会更方便使用。

SLTNode* BuyListNode(SLTDataType x);

这个函数用来动态开辟一个新节点,注意一定要是动态申请的,不然创建的节点会在函数调用结束后随着函数栈帧一起销毁。

SLTNode* BuyListNode(SLTDataType x)//创建新节点
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	if (NewNode == NULL)
	{
		printf("malloc failed");
		exit(-1);			//检查malloc是否成功,若失败则报错退出
	}
	newnode->data = x;
	newnode->next = NULL;	//新的节点的next置为NULL,方便使用
	return newnode;
}

除了上面这样使用返回值传递新节点,你当然也可以使用二级指针来传递新节点,就像这样:

void BuyListNode(SLTNode** ppnewnode,SLTDataType x);
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	if (newnode == NULL)
	{
		printf("malloc failed");
		exit(-1);
	}
	newnode->data = x;
	newnode->next = NULL;
	*ppnewnode = newnode;
}

但是这样的话就要在函数调用之前创建SLTNode*类型的变量,不方便使用,所以不推荐这样写。

3. 5 尾插

void SListPushBack(SListNode** pplist, SLTDateType x);

相较于头插,尾插多了一个步骤:找到单链表现在的尾节点。要完成头插,我们需要完成几个步骤:

  1. 创建新节点
  2. 找到尾节点
  3. 将尾结点的next指针指向新节点

我们重点来看第二步,在单链表中,并没有存储尾节点,但是我们可以通过遍历的方式找到尾节点,就像:

SListNode* cur = *pplist;
while (cur->next)
	cur = cur->next;

最终退出循环时,cur就是尾节点了。

当然,值得注意的是,如果此时单链表为空也就是*plist==NULL的时候就不能按第三步进行了,否则会发生空指针的解引用,要单独处理这一情况,将这个新节点变成头结点。

参考代码:

void SListPushBack(SListNode** pplist, SLTDateType x)
{
	assert(pplist);
	SListNode* newnode = BuySListNode(x);
	if (*pplist == NULL)
		*pplist = newnode;		//如果链表为空,将新节点作为头结点
	else
	{
		SListNode* cur = *pplist;
		while (cur->next)
			cur = cur->next;	//找到尾节点
		cur->next = newnode;
	}
}

3. 6 头删和尾删

头删相对简单,分为两个步骤:

  1. 将原来的头结点存储起来,并将下一个节点作为头结点。
  2. 释放原来的头结点,并将指向原来的头结点的指针置空。

我们来分析一下,要不要对单链表中只有一个节点的情况特殊处理:

【数据结构初阶】单链表接口实现超详解_链表_05

很显然,不需要,因为头结点的下一个节点就是NULL,那么头结点也就变成了空结点。参考代码:

void SListPopFront(SListNode** pplist)
{
	assert(pplist && *pplist);
	SListNode* tmp = *pplist;
	*pplist = (*pplist)->next;
	free(tmp);
	tmp = NULL;
}

再来看尾删,尾删麻烦一点,分为几个步骤:

  1. 找到尾节点和它的上一个节点。
  2. 释放尾节点。
  3. 将现在的尾节点的next指针置为空。

除此之外,我们也来分析一下如果链表中只有一个节点的情况:不需要过多分析我们就能发现:在第一步的时候,就无法找到头结点的上一个节点,因此这种情况显然要单独处理:

if ((*pplist)->next == NULL)
{
	SListNode* tmp = *pplist;
	free(tmp);				//不要忘记释放节点,避免内存泄漏
	tmp = NULL;
	*pplist = NULL;
}

那么尾删的代码也就很容易写出来了:

void SListPopBack(SListNode** pplist)
{
	assert(pplist && *pplist);
	if ((*pplist)->next == NULL)
	{
		SListNode* tmp = *pplist;
		free(tmp);
		tmp = NULL;
		*pplist = NULL;
	}
	else
	{
		SListNode* cur = *pplist;
		SListNode* pre = NULL;
		while (cur->next)	//循环会在cur为尾节点时停止
		{
			pre = cur;
			cur = cur->next;
		}
		free(cur);
		cur = NULL;
		pre->next = NULL;	//如果不进行这一步,尾节点就会指向野指针
	}
}

3. 7 查找

SListNode* SListFind(SListNode* plist, SLTDateType x);
//找到存储了数据x的节点,并返回节点没找到就返回NULL

这个是为了后面的删除插入做前置工作。这个函数只需要遍历一遍链表并一一比较就可以了:

SListNode* SListFind(SListNode* plist, SLTDateType x)
{
	assert(plist);
	SListNode* cur = plist;
	while (cur)
	{
		if (cur->data == x)
			return cur;
		cur = cur->next;
	}
	return NULL;
}

3. 8 在指定位置之后插入或删除

先来看插入

void SListInsertAfter(SListNode* pos, SLTDateType x);

插入有几个步骤:

  1. 申请新节点
  2. 将新节点的next指针指向pos->next
  3. pos->next指向新节点

这个pos来自上面的查找函数,也就是说,如果pos不为空,那么pos是一定在链表中的,不需要额外检查,但需要检查它是否为空。特别要注意的是:第二步和第三步的顺序绝对不能颠倒,不然就会找不到原来的下一个节点

void SListInsertAfter(SListNode* pos, SLTDateType x)
{
	assert(pos);
	SListNode* newnode = BuySListNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

再来看删除

void SListEraseAfter(SListNode* pos)

删除分为几个步骤:

  1. 将pos->next指向pos->next->next
  2. 释放pos->next

当然,pos也需要进行判空操作,pos->next也需要,要调用这个函数,这两者应该都不能为空。

void SListEraseAfter(SListNode* pos)
{
	assert(pos && pos->next);
	SListNode* tmp = pos->next;
	pos->next = tmp->next;
	free(tmp);
	tmp = NULL;
}

3. 9 在指定位置前面插入或删除指定位置

需要注意的是,在C++的STL库(数据结构库)中,并没有这样的两个函数,至于原因会在最后讲。

先来看插入

void SListInsert(SListNode** pphead, SListNode* pos, SLTDateType x);

分为几个步骤:

  1. 遍历单链表,找到pos的上一个节点
  2. 申请新节点
  3. 将新节点按在指定位置之后插入的方式插入到pos的上一个节点后面
void SListInsert(SListNode** pphead, SListNode* pos, SLTDateType x)
{
	assert(pphead && pos);
	SListNode* newnode = BuySListNode(x);
	SListNode* pre = *pphead;
	while (pre->next != pos)
	{
		pre = pre->next;	//遍历找到pos的上一个节点
	}
	pre->next = newnode;	//剩下的便是在指定位置之后插入了
	newnode->next = pos;
}

再来看删除

void SListErase(SListNode** pphead, SListNode* pos)

分为几个步骤:

  1. 遍历单链表,找到pos的上一个节点
  2. 按在指定位置之后删除的方式删除pos

不过值得注意的是:如果pos是头结点的话,还需要将头结点改变一下,这也是为什么这个函数中头结点要传递二级指针。

void SListErase(SListNode** pphead, SListNode* pos)
{
	assert(pphead && pos);
	if (*pphead == pos)			//处理pphead和pos重合的情况
	{
		*pphead = pos->next;
		free(pos);
		pos = NULL;
	}
	else
	{	
		SListNode* pre = *pphead;
		while (pre->next != pos)
		{
			pre = pre->next;	//找到pos的上一个节点
		}
		pre->next = pos->next;	//这后面的就是在指定位置后面删除节点
		free(pos);
		pos = NULL;
	}
}

为什么C++的STL库不提供这两个函数?我们可以计算一下这两个函数以及上面的向后插入删除的函数的时间复杂度,可以发现,向后插入删除的函数的时间复杂度都是O(1),而下面这两个函数的时间复杂度都是O(N),因为单链表不能直接找到节点的上一个节点,所以单链表并不适合向前插入和原地删除的函数,有其他的链表可以更好地完成这样的任务

3. 10 销毁

void SListDestroy(SListNode** pphead)

在单链表使用结束后,一定要记得销毁单链表,不然会发生内存泄漏,可能导致程序崩溃。单链表的销毁比顺序表麻烦一些,因为单链表的空间是一块一块地申请的,所以也要一块一块地释放。 只需要遍历单链表, 逐个释放就可以了。

void SListDestroy(SListNode** pphead)
{
	assert(pphead);
	SListNode* cur = *pphead;
	while (cur)
	{
		SListNode* tmp = cur->next;
		free(cur);
		cur = tmp;		//遍历着往下走
	}
	*pphead = NULL;		//还要记得将头结点置空,不然就是一个野指针
}

谢谢你的阅读,喜欢的话来个点赞收藏评论关注吧!我会持续更新更多优质文章

标签:结点,单链,cur,pos,next,初阶,SListNode,数据结构,节点
From: https://blog.51cto.com/u_17049566/12364302

相关文章

  • 数据结构前置知识
    想要成为一名真正的高手,会写会读代码真的只是起步而已,能明白程序的底层运行逻辑,才是神中神。数据结构正是让我们进入代码逻辑世界的钥匙......
  • 【JavaEE初阶】网络原理(1)
    欢迎关注个人主页:逸狼创造不易,可以点点赞吗~如有错误,欢迎指出~互联网中最主流的时TCP/IP五层协议(应用层,传输层,网络层,数据链路层,物理层),应用层是程序员日常开发中最常用到的一层(可以使用已经开发好的协议,也可以自己定义应用层协议),其他层则操作系统/硬件/......
  • 初阶数据结构【3】--单链表(比顺序表还好的一种数据结构!!!)
    本章概述前情回顾单链表实现单链表彩蛋时刻!!!前情回顾咱们在上一章博客点击:《顺序表》的末尾,提出了一个问题,讲出了顺序表的缺点——有点浪费空间。所以,为了解决这个问题,我们今天就要讲解链表,咱们在讲结构体的时候有提到过链表,链表的最大优点——一点空间也不浪费,用多少......
  • 关系型数据库(1)----MySQL(初阶)
    目录1.mysql2.mysqld3.mysql架构1.连接层2.核心服务层3.存储引擎层4.数据存储层4.SQL分类5.MySQL操作库6.MySQL数据类型1.数值类型2.日期和时间类型3.字符串类型4.空间类型5.JSON数据类型7.MySQL表的约束1.主键约束(PRIMARYKEY)2.非空约束(NOTNULL)3.......
  • 【数据结构和算法】一、算法复杂度:时间复杂度和空间复杂度)
    目录1、算法复杂度1.1概念1.2评价指标1.3时间复杂度1.3.1什么是时间复杂度1.3.2常数阶O(1)1.3.3  线性阶O(n)1.3.4 对数阶O(logN)1.3.5  线性对数阶O(nlogN)1.3.6 平方阶O(n²)1.3.7  立方阶O(n³)、K次方阶O(n^k)1.4 空间复杂度1.4.1 空间复......
  • 数据结构图的最短路径-弗洛伊德算法(有向图+数据结构课本C++代码一比一转C语言+邻接矩
    弗洛伊德算法有向图代码如下:#define_CRT_SECURE_NO_WARNINGS1#include<stdio.h>#include<stdlib.h>#include<limits.h>#defineMaxInt32767#defineMVNum100intPath[MVNum][MVNum];//存放前驱索引的intD[MVNum][MVNum];//存放当前已知的权值//图的邻接......
  • 数据结构——队列和栈
    目录一、栈        1、概念与结构    2、栈的结构与初始化    3、入栈        4、出栈         5、取栈顶元素         6、取栈中有效元素个数          7、栈是否为空 二、队列     1、概念......
  • 数据结构 ——— C语言实现链式队列
    目录队列的概念以及示意图数组队列和链式队列链式队列的结构 实现链式队列的准备工作实现链式队列1.初始化2.打印队列的所有数据3.数据入队列(尾插)4.数据出队列(头删)5.访问队头的数据6.访问队尾的数据7.队列数据总个数8.判断队列是否为空9.释放队列的所......
  • 数据结构 ——— C语言实现数组栈
    目录栈的概念以及示意图链式栈和数组栈链式栈:数组栈:数组栈的结构实现数组栈的准备工作实现数组栈初始化数组栈入栈(尾插)出栈(尾删)访问栈顶数据判断栈是否为空栈数据的总数访问栈的所有数据释放栈Stack.h的所有代码Stack.c的所有代码栈的概念以及示意图栈......
  • 数据结构有哪些
    数据结构分类涉及多方面,主要包括:1、线性结构、2、树形结构、3、图形结构、4、集合结构、5、文件结构。在这些种类中,线性结构是最基本、也是最为广泛使用的一种,它包括数组、链表、栈和队列等数据结构,通过线性的方式组织数据元素。以数组为例,它以连续的内存空间顺序存储数据,通过索引......