链表(C语言,单链表的实现)

1.链表的介绍

链表是⼀种物理存储结构上非连续、非顺序的存储结构,它所开辟的空间是非连续的,但是它的逻辑顺序是连续的,也就是数据元素的逻辑顺序是通过链表的指针链接次序实现的。

简单理解就是每个元素的存储空间是独立的,每个空间依靠指针(指针存放的是下一个元素的地址)来找到下一个元素。看下面的图片

白色背景相当于电脑的空间,我们可以看到每个存储数据的空间位置是随机的,他们通过存储下一个元素地址链接起来,所以链表也是一种线性表。

链表每个节点一般都是从堆上申请的,从堆上申请来的空间,是按照⼀定策略分配出来的,每次申请的空间可能连续,可能不连续。

链表的种类有很多 :1.带头和不带头 2.单相和双向 3.循环和不循环。

具体区别和含义在文章模拟实现一个简单的单链表后,大家对链表有具体的理解后,在做解释和说明。

2.单链表的实现

与顺序表实现类似,SList.h文件放函数的声明,SList.c文件放函数的实现,text.c文件则是测试函数是否正常运行。

1.定义链表的节点结构

通过上面介绍,一个节点结构应该具有数据和指向下一个节点的指针。

//链表也能存储多种数据类型,为了方便替换typedef一下
typedef int SLTDataType;
//为了方便使用,给节点也重命名
typedef struct SListNode
{
	SLTDataType data;//链表存储的数据
	struct SListNode* next;//指向下一个数据的地址
}SLTNode;

2.链表的打印

想要打印链表,我们需要有一个链表,所以我们可以在text.c文件中创建一个链表测试我们的链表打印。

我们知道链表是由一个一个节点构成,我们创建几个节点然后链接起来。节点我们要先动态开辟出来,也就需要malloc。

void SListText1()
{
	SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));
	node1->data = 1;
	SLTNode* node2 = (SLTNode*)malloc(sizeof(SLTNode));
	node2->data = 2;
	SLTNode* node3 = (SLTNode*)malloc(sizeof(SLTNode));
	node3->data = 3;
	SLTNode* node4 = (SLTNode*)malloc(sizeof(SLTNode));
	node4->data = 4;
	node1->next = node2;
	node2->next = node3;
	node3->next = node4;
    node4->next = NULL;
}
int main()
{
	SListText1();
	return 0;
}

也就是创建出了这样的链表:

 创建完就开始写打印函数吧。对于第一次接触链表的人来说可能现在没有思路该怎么打印出链表呢?所以直接就上代码,慢慢理解:

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

我们将打印函数放到SLText1()函数里并运行我们可以得到:

那我们来理解一下这段代码是什么意思,是怎么打印出链表里面的内容的。

 我们将第一个节点传到函数里,意味着phead就是链表的第一个节点,我们又定义一个cur指向我们第一个节点,这样可以保持phead位置不动,大多数情况下我们会定义新的指针移动以保持传过来的指针不改变。

我们看循环条件,当cur为空指针时跳出循环。最初cur指向第一个节点,cur不为空,打印出cur里面的数据data,然后cur通过cur=cur->next来让cur指向了下一个节点(cur->next就是下一个节点的指针,我们将cur->next赋值给cur,那么cur就是下一个节点的指针)。cur不为空,打印出cur里面的数据data,cur在指向下一个节点。一直如此循环,直到cur指向空节点时跳出循环。这样链表里面的内容就全部打印出来了。

3.插入

链表的插入有尾插,头插,指定位置之前插入和指定位置后插入。

3.1尾插

我们已经有四个节点,想要尾插第五个节点,是不是只需要将第四个节点的next指针指向第五个节点的地址 ,这样就将第五个节点连接到链表当中。看起来是不是不难但是需要我们完成两个操作,a.我们需要自己创建出节点储存数据然后插入。b.我们尾插需要找到链表最后一个节点。

a.创建一个新的节点newnode。

我们写一个GetNode()的函数来获取新的节点,也就是需要我们传入节点所存的数据,函数返回一个节点。因为我们前面创建过节点,所以对函数实现应该并不陌生。

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

可以发现这里多了一个if的判断,这个是保证代码的健壮。具体内容请看动态内存管理。其实这里也是固定写法,记住即可。

新的节点刚刚创建我们不需要它指向谁,所以先指向NULL。这样一个新的节点创建出来了。

b.我们要找到链表最后一个节点(也就是尾插的基本逻辑)

看链表打印函数我们知道可以通过一个辅助的指针cur来遍历链表,那么意味着我们也可以通过cur找到链表最后一个节点。最后一个节点的特点是next指针指向的是NULL。

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

有了对打印的理解,这段代码逻辑应该很好理解,只不过是上面的cur在这里被换了一个名字ptail,找到链表尾部并连接起来。

但是这个函数有个明显的问题,那就是我们都没有链表,那ptail就是一个空指针,空指针不能解引用操作,所以这里需要特殊处理。如果没有链表,则尾插的节点就是链表的第一个节点。

void SLTPushBack(SLTNode* phead, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	if (phead == NULL)
	{
		phead = newnode;
	}
	else
	{
		SLTNode* ptail = phead;
		while (ptail->next != NULL)
		{
			ptail = ptail->next;
		}
		ptail->next = newnode;
	}
}

代码写完了,我们在text.c运行测试一下。text.c:

#include "SList.h"

void SListText2()
{
	SLTNode* plist = NULL;
	SLTPushBack(plist, 1);
}
int main()
{
	SListText2();
	return 0;
}

调试一下:

让函数执行的时候,函数内部都正常创建和运行,但是函数传过去的参数plist却一直没有被改变。这是什么原因呢?因为我们创建的结构体代表的是一个节点,它需要指向一个地址,所以我们给创建了一个节点指向了NULL,plist的数据类型是SLTNode*类型的。我们传到函数参数的数据类型也是SLTNode*类型的,那么这就是传值调用,就用上了我们熟悉的知识点,形参的改变不会影响实参。这里需要我们传址调用。(这里如果不是很理解,大家可以看一下指针文章,理解指针,这里就不做具体有关指针的说明了)。

需要传址调用我们就需要传plist的地址,那么函数参数类型就是一个二级指针(存放我们传入指针地址)。对二级指针解引用操作就是对plist操作。

修改完代码为:

void SLTPushBack(SLTNode** phead, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	if (*phead == NULL)
	{
		*phead = newnode;
	}
	else
	{
		SLTNode* ptail = *phead;
		while (ptail->next != NULL)
		{
			ptail = ptail->next;
		}
		ptail->next = newnode;
	}
}

在text.c文件中传入函数的参数应该是&plist。

plist成功成为我们链表第一个节点。这是一步很大的跨越。(这里需要说明一下,用二级指针的方法是一个比较难理解的方法,链表有很多种方法实现,但是我们能理解二级指针这个方法,那其他实现链表的方法理解就容易一点了)。

 程序正常运行,尾插我们就写完了。

3.2头插

有了对尾插的学习,我们更一步加深了对于函数传址调用的理解。

思考一下如何头插?

有一个链表,我们想要将newnode头插到链表里面,是不是只需要将newnode连接到phead头结点前面,头结点应该也发生改变,也就是phead应该是指向newnode节点。 

void SLTPushFront(SLTNode** phead, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	newnode->next = *phead;
	*phead = newnode;
}

代码很短,有人就会疑惑,尾插对于空链表需要特殊处理,头插久不需要了吗?看代码,如果是空链表那么*phead就是NULL,但是我们让newnode->next指向NULL,newnode成为新的头结点,并没有违法操作,运行测试一下,头插函数都是能正常运行的。

3.3指定位置插入

在介绍指定位置插入之前我们先理解一个地方,就是链表不像顺序表有0,1,2,3等这样具体的位置,我们的指定位置插入只能是像上面的链表,在3前面插入一个0,或在4后面插入一个0这样在具体哪个节点前后插入。这就需要我们有一个函数找到我们需要的数据的节点。

3.3.1链表的查找

查找逻辑大家也很熟悉了,遍历链表,如果某个节点的数据符合我们给定的数据则反回该节点。

SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* pcur = phead;
	while (pcur)//等价于while(pcur!=NULL)
	{
		//我们拿的是int作为例子,可以使用==比较。
		//其他数据类型应该使用它所合法比较进行更改。
		if (pcur->data == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	return NULL;
}
3.3.2指定位置之前插入

假如我们要在3的前面插入0:

 是不是只需要将2的next指针指向新的节点,新节点的next指针指向3这个节点就可以了。我们实现的是一个单链表,找到3这个节点后没办法找到它前一个节点,我们就需要定义第二个指针找到3前一个节点。直接上代码:

void SLTInsert(SLTNode** phead, SLTNode* pos, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	SLTNode* cur = *phead;
	SLTNode* pcur = *phead;
	while (cur != pos)
	{
		pcur = cur;
		cur = cur->next;
	}
	pcur->next = newnode;
	newnode->next = cur;
}

我们来理解一下这个代码,这个循环的含义是:

先让pucr=cur,然后cur指向下一个节点,当cur指向pos位置跳出循环时,pucr是cur前一个节点。 然后就是将新的节点连接到链表上。

既然是在指定位置之前插入,那我们试试在头结点前插入。结果是

死循环了,为什么呢?原因就是当*phead是头结点时,不进入循环,cur和pcur同时指向头结点按照两句代码的逻辑,新节点next指针指向头结点,头节点next指针指向新节点。这样链表就成了一个循环链表,打印链表就造成了死循环。

怎么解决呢?在头结点之前插入不就是头插吗,当pos位置是头结点是之间调用头插函数就可以解决。

void SLTInsert(SLTNode** phead, SLTNode* pos, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	if (pos == *phead)
	{
		SLTPushFront(phead, x);
	}
	else
	{
		SLTNode* cur = *phead;
		SLTNode* pcur = *phead;
		while (cur != pos)
		{
			pcur = cur;
			cur = cur->next;
		}
		pcur->next = newnode;
		newnode->next = cur;
	}
}

指定位置之前插入就完成了。

3.3.3指定位置之后插入

我们指定位置之前插入需要找到指定节点前一个节点,我们实现的是单链表,所以我们需要从头节点遍历才能找到指定节点和它前一个节点。但是指定位置之后插入就不需要从头节点遍历,我们知道指定位置节点就能找到指定位置下一个节点,就可以插入一个新的节点。

以我们想在3后面插入一个0为例子。

将3的next节点指向0节点,0节点的next指向4节点。

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

 尾插也就结束了,这个没有坑。但是有个需要注意的点。如果这么写代码:

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

这个代码也是正常运行的,但是底下两个代码顺序是不能改变的,因为改变逻辑就错了,大家需要注意。

4删除

有插入就有删除,删除有尾删,头删,删除指定位置指点,删除指定位置之后节点。

4.1尾删

将最后一个节点的前一个节点的next指针指向空,释放最后一个节点空间(动态开辟的空间需要自己释放,具体看C语言动态内存开辟),就完成了尾删。 

void SLTPopBack(SLTNode** phead)
{
	SLTNode* cur = *phead;
	SLTNode* pcur = *phead;
	while (cur->next != NULL)
	{
		pcur = cur;
		cur = cur->next;
	}
	pcur->next = NULL;
	free(cur);
	cur = NULL;
}

在大家理解上面的代码后,这段代码逻辑应该比较容易理解吧,利用cur指针可以找到链表的尾端,利用pcur找到链表尾端前一个节点,这样让pcur的next指针指向NULL就完成了尾删,但是我们需要释放cur节点,并且将该节点指向空,防止成为野指针有隐藏的危害。

 4.3头删

头删就更好完成了,只需要我们将头结点指针移动到链表第二个位置就可以了。但是我们要释放节点所以我们需要定义一个变量等于头结点,然后再让头结点移动。

void SLTPopFront(SLTNode** phead)
{
	SLTNode* cur = *phead;
	*phead = *phead->next;
	free(cur);
	cur = NULL;
}

但是我们写完这段代码后会发现报错,vs下报错是这样的:

表达式必须包含指向结构或联合的指针类型,但它具有类型SLTNode**,可是我们写了*phead,为什么会报错呢?这里发生错误是因为优先级的问题。

 大家也可以自己查一下了解一下常用运算符的优先级。

我们可以看到'->'的优先级是比'*'优先级高的。所以*phead->next,应该是*(phead->next),但是phead是SLTNode**类型的,是存放的是地址,也就访问不到next,就会报错。所以代码应该改为:

void SLTPopFront(SLTNode** phead)
{
	SLTNode* cur = *phead;
	*phead = (*phead)->next;
	free(cur);
	cur = NULL;
}

加个括号就就解决了优先级的问题。

4.4删除pos位置节点。

与指定位置插入相同,我们需要利用链表的查找,才能进行删除。

以链表1->2->3->4删除3节点为例子。

跟上面的逻辑相类似,找到目标节点前一个节点。让目标节点的前一个节点的next指针指向目标节点的下一个指针,释放目标节点,就完成了删除。

void SLTErase(SLTNode** phead, SLTNode* pos)
{
	SLTNode* cur = *phead;
	while (cur->next != pos)
	{
		cur = cur->next;
	}
	SLTNode* next = pos->next;
	cur->next = next;
	free(pos);
	pos = NULL;
}

 这段代码就实现了上面所说的逻辑,应该不用过多的解释了,测试一下能够正常运行,但是这里有一个小错误,我们测试一下删除链表头结点会发现: 

运行出现错误了。我们调试一下找找原因。

我们想删除1这个头结点,但是它一直在往后走走到4,最后走到空。尾为什么呢?

我们在看我们的代码逻辑,原因在于我们的循环条件,我们*phead就是目标节点,我们的cur=*phead。但是判断的是cur->next是否是目标节点,但是我们这个时候cur就是目标节点,所以循环会一直进行,知道cur走到空,程序出现问题。 

这个修改也简单,只需要特殊判断一下,如果要删除的是头节点,只需要调用头删函数就可以解决。

void SLTErase(SLTNode** phead, SLTNode* pos)
{
	if (pos == *phead)
	{
		SLTPopFront(phead);
	}
	else
	{
		SLTNode* cur = *phead;
		while (cur->next != pos)
		{
			cur = cur->next;
		}
		SLTNode* next = pos->next;
		cur->next = next;
		free(pos);
		pos = NULL;
	}
}

这样代码就完成了。

4.5删除指定位置之后节点

还是以上面链表为例子,删除2后面节点3。与上面删除pos位置节点相类似,只不过不需要从头节点开始遍历寻找。

 

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

相信对各位的能力,这段代码不需要过多赘述了,这段代码没有空哟,所有的删除也就完成了。

 5.链表的销毁

我们创建了一个链表,我们就需要销毁它。链表是一个一个节点构成,那么也需要我们一个一个去销毁。

如果我们直接销毁*phead,那么下一个节点我们找不到了就不能销毁了,所以我们需要next指针找到下一个节点。知道链表为空时停止循环。

void SLTDestroy(SLTNode** phead)
{
	SLTNode* pcur = *phead;
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	*phead = NULL;
}

我们的链表的销毁也就完事了。

单链表的一些基础的功能我们都实现出来了,相信大家对于链表有了一个更深的理解,对于链表没有当初的陌生感,上面的代码总体来说不难,但是需要大家动手自己打,靠着自己理解完完全全的写一个链表的代码,当然不需要跟文章一样,每个功能都有很多种实现方式,按照自己的想法走。

我们上面提到了链表的分类,但是当时我们对于链表还比较陌生,我就没在上面解释,但是现在可以给大家介绍一下了。

6.链表的分类

上面我们介绍了链表有1.带头和不带头 2.单相和双向 3.循环和不循环。他们搭配组合起来有8总(2*2*2)。

当然这8总并不是都很常用,我们先了解一下这1,2,3点都有什么不同。

6.1带头和不带头

带头:指的是链表中有哨兵位节点,该哨兵位节点即是头结点。(实现链表时我们为了方便将第一个有效节点叫做头节点,这是不正确的称呼,实际上在链表中,哨兵位才被称作头结点)。

 第二个链表里的head节点就是头节点即哨兵位,有头节点的链表叫做带头链表,上面的链表叫做不带头链表。

6.2单向和双向

我们先看两个链表:

上面的链表可以通过d1找到d2,通过d2找到d3,反之不能。但是第二个链表,我们可以看到,每个节点不仅存储着下一个节点的地址,还存储着上一个节点的地址。这样不仅能找到下一个节点,还能找到上一个节点。

所以第一个链表是单向链表,第二个链表是双向链表(这个在后面的文章会带着大家实现一下)。

6.4循环和不循环

第一个链表是不循环链表,尾节点next指针指向的是空。

第二个链表是循环链表,尾节点 next指针指向的是头结点(第一个节点),构成一个环。

依靠尾节点next指针是否为空来判断链表是循环链表还是不循环链表。

我们上面实现的就是不带头单向不循环链表(简称:单链表)。带头双向循环链表(简称:双链表)。

7.总代码

SList.h文件代码:

#include <stdio.h>
#include <stdlib.h>

//链表也能存储多种数据类型,为了方便替换typedef一下
typedef int SLTDataType;
//为了方便使用,给节点也重命名
typedef struct SListNode
{
	SLTDataType data;//链表存储的数据
	struct SListNode* next;//指向下一个数据的地址
}SLTNode;

void SLTPrint(SLTNode* phead);

SLTNode* GetNode(SLTDataType x);

void SLTPushBack(SLTNode** phead, SLTDataType x);

void SLTPushFront(SLTNode** phad, SLTDataType x);

SLTNode* SLTFind(SLTNode* phead, SLTDataType x);

void SLTInsert(SLTNode** phead, SLTNode* pos, SLTDataType x);

void SLTInsertAfter(SLTNode* pos, SLTDataType x);

void SLTPopBack(SLTNode** phead);

void SLTPopFront(SLTNode** phead);

void SLTErase(SLTNode** phead, SLTNode* pos);

void SLTEraseAfter(SLTNode* pos);

void SLTDestroy(SLTNode** phead);

SList.c文件代码:

 

#include "SList.h"

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

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


SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* pcur = phead;
	while (pcur)//等价于while(pcur!=NULL)
	{
		//我们拿的是int作为例子,可以使用==比较。
		//其他数据类型应该使用它所合法比较进行更改。
		if (pcur->data == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	return NULL;
}

void SLTPushBack(SLTNode** phead, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	if (*phead == NULL)
	{
		*phead = newnode;
	}
	else
	{
		SLTNode* ptail = *phead;
		while (ptail->next != NULL)
		{
			ptail = ptail->next;
		}
		ptail->next = newnode;
	}
}

void SLTPushFront(SLTNode** phead, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	newnode->next = *phead;
	*phead = newnode;
}

void SLTInsert(SLTNode** phead, SLTNode* pos, SLTDataType x)
{
	SLTNode* newnode = GetNode(x);
	if (pos == *phead)
	{
		SLTPushFront(phead, x);
	}
	else
	{
		SLTNode* cur = *phead;
		SLTNode* pcur = *phead;
		while (cur != pos)
		{
			pcur = cur;
			cur = cur->next;
		}
		pcur->next = newnode;
		newnode->next = cur;
	}
}

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

void SLTPopBack(SLTNode** phead)
{
	SLTNode* cur = *phead;
	SLTNode* pcur = *phead;
	while (cur->next != NULL)
	{
		pcur = cur;
		cur = cur->next;
	}
	pcur->next = NULL;
	free(cur);
	cur = NULL;
}

void SLTPopFront(SLTNode** phead)
{
	SLTNode* cur = *phead;
	*phead = (*phead)->next;
	free(cur);
	cur = NULL;
}

void SLTErase(SLTNode** phead, SLTNode* pos)
{
	if (pos == *phead)
	{
		SLTPopFront(phead);
	}
	else
	{
		SLTNode* cur = *phead;
		while (cur->next != pos)
		{
			cur = cur->next;
		}
		SLTNode* next = pos->next;
		cur->next = next;
		free(pos);
		pos = NULL;
	}
}

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

void SLTDestroy(SLTNode** phead)
{
	SLTNode* pcur = *phead;
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	*phead = NULL;
}

结尾:

我们本篇文章介绍链表就结束了,希望看到本篇文章的对大家有所帮助,如果有错误的地方,大家可以评论,我积极修改。最后各位未来的大佬们我们下篇文章再见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值