单链表相关知识

本文详细介绍了链表的基本概念,包括单链表的结构和结点的动态创建。通过malloc申请内存创建结点,确保了结点在函数作用域之外依然存在。同时,讲解了如何实现尾插、尾删、头插和头删等基本操作,并针对特殊情况如链表为空或只有一个结点时进行了讨论。此外,还提供了一个打印链表数据的函数。整个过程深入浅出,帮助读者理解链表操作的实现细节。
摘要由CSDN通过智能技术生成

一、对单链表的结构的认识

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

在这里插入图片描述
如上图,链表就是由一个个结点构成的(每个结点的类型都是一样的),每个结点中都包含两个数据(一个是存储进这个结点里面的数值,另一个是存储着下一个结点的地址的指针)。
注:1.这一个个结点要用结构体类型来实现,因为每个结点里面都包含一个数值域、一个指针域,对于这种包含多个部分的结构,我们就用结构体类型来实现。
2.结点中的指针类型应该是结构体指针类型,因为它需要存储下一个结点的地址,下一个结点的地址不就是结构体指针类型嘛(每个结点的类型都是一样的,都是结构体类型)。

结点具体实现代码如下:

//SList.h里面
#pragma once
#include <stdio.h>
typedef int SLTDataType;

typedef struct SListNode
{
	SLTDataType data;//data是存储进这个结点里面的数值
	struct SListNode* next;//next用来存储下一个结点的地址
}SLTNode;

注:为了规范一点,结构体的定义和函数的声明放在头文件SList.h里面
1.防止被重复包含,会在SList.h里面加一个#pragma once
2.因为存进结点里面的数值不一定一直是某一种固定的数据类型,可能有时候是整型,有时候是字符,但在大量的代码里面每次找到结构体然后再改数据类型又太麻烦,所以将数据类型typedef成SLTDataType,这样如果想改数据类型,找到typedef进行修改就行。
3.因为结构体struct SListNode变量名太长,所以将它typedef成SLTNode,
但在typedef之前你不能使用SLTNode,也就是你不能把结构体中的next的类型写成SLTNode*,因为你还未把struct SListNode, typedef成SLTNode,你就要使用它,这种做法是不可以的。 其实对这个结构体typedef,说到底它是对结构体变量名typedef,即typedef struct SListNode,在上面的代码中不过是把typedef嵌套到结构体中的定义中去了,容易对初学者产生误导。

1.为什么在具体定义链表的结点时要用malloc申请一份空间,而不是直接创建一个结构体变量呢?

我们用来定义结点的空间要满足两个条件,第一个条件:出了函数作用域结点不能自动销毁,第二个条件:如果不想要定义结点的这份空间可以手动销毁。

1.不能用结构体变量定义结点的原因如下:
一般我们会专门写一个定义链表结点的函数,我们写链表的目的就是要把数据存储到链表的结点中,如果我们把数据存储到用结构体变量定义的结点中,结构体变量是在栈帧上开辟空间来供它使用,在这个函数内部这个结构体变量是存在的,也就是这个结点是存在的,但出了这个函数,栈帧就会销毁,这个用结构体变量创建的结点也就销毁了,我们存储在结点里面的数据也就销毁了,我们就是要用链表来存储数据的,结果存储的数据还丢了,这个结果不是我们想要的,所以不能用结构体变量来定义结点。

2.不能用全局变量,静态变量定义结点的原因:
基于第一点,所以我们要找一块出了函数的作用域之后还存在的空间,这样我们存储在里面的数据出了函数作用域也会存在,这时候我们可能会想到全局变量,静态变量(全局变量和静态变量出了函数作用域之后依然存在),但用全局变量或静态变量定义的结点,如果我们不想要这个结点,我们无法手动释放这份空间,所以也排除全局变量和静态变量来定义结点。

最后基于以上两点原因,只有堆这块空间满足我们定义结点的要求,存在堆上的数值出了函数作用域依然存在,如果不想要这份空间需要我们手动释放(我们可以使用malloc来申请堆上的空间,当我们不想要这份空间时,我们使用free来释放掉这份空间)。

2.实现一个专门用来定义结点的函数

我们先一步一步来,
首先我们先写一个函数来创建一个结点并且将数据存储到结点里面。
具体代码实现如下:

SLTNode* BuySListNode(SLTDataType x)
{
	//用malloc申请一份空间,这份空间存储的就是结点,并将结点的地址赋值给newnode
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	//如果malloc申请失败就会返回空指针,那么程序到此结束就行。
	if (newnode == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}
	//走到这里说明malloc申请空间成功即创建结点成功,然后通过newnode里面存储的地址找到创建好的
	//结点里面的data,将x赋值给data(此时数据存储成功),因为next是用来存储下一个结点的地址的
	//但我们只单独创建了一个结点,没有与其他结点链接,所以我们先将next初始化成NULL。
	newnode->data = x;
	newnode->next = NULL;
	//返回这个结点的地址
	return newnode;
}

然后再写一个函数来调用我们用来创建结点且存储数据的函数

void TestSList1()
{
	SLTNode* n1 = BuySListNode(1);
	SLTNode* n2 = BuySListNode(2);
	SLTNode* n3 = BuySListNode(3);
	SLTNode* n4 = BuySListNode(4);

}

我们用TestSList1函数创建了四个结点并将数据存储到了结点里,接下来我们要把这四个结点链接起来,链接的目的就是我们只需要知道第一个结点的地址,我们就能把其余三个结点依次找到,要想实现这个目的,核心思想就是上一个结点中的next存储下一个结点的地址,这样子一个单链表就出来了。
具体代码实现如下:

void TestSList1()
{
	SLTNode* n1 = BuySListNode(1);
	SLTNode* n2 = BuySListNode(2);
	SLTNode* n3 = BuySListNode(3);
	SLTNode* n4 = BuySListNode(4);
	n1->next = n2;
	n2->next = n3;
	n3->next = n4;
	n4->next = NULL;//该链表结束标志,看到NULL,就代表该结点是最后一个结点
}

上述代码物理逻辑图如下:
在这里插入图片描述

但像这种创建链表的方式是不够好的,更多的情况是要求你创建有n个结点的链表,接下来我们想想这种情况该怎么解决。
当然还是创建一个函数来实现。
首先我们得用for循环来创建n个结点(有n个结点那就循环n次),然后再创建一个phead指针来存储第一个结点的地址,
代码实现如下:

SLTNode* CreateSList(int n)
{
	SLTNode* phead = NULL;
	int i = 0;
	for (i = 0; i < n; i++)
	{
		SLTNode* newnode = BuySListNode(i);
		if (phead == NULL)
		{
			phead = newnode;
		}
	}
}

但如果我们要创建第2个结点,第三个结点等等,光有一个phead那就显然不行了(因为如果创建了第二个结点,第一个结点想要与第二个结点实现链接,就得通过phead里面所存的地址找到第一个结点里面的next,然后将第二个结点的地址赋值给第一个结点的next,再将第二个结点的地址赋值给phead,这样phead就指向第二个结点了。但是再仔细想想,在只有一个phead的前提下,后一个结点的地址赋值给前一个结点的next,再将后一个结点的地址赋值给phead,phead就指向后一个结点,这样phead一直往下走,但是第一个结点的地址是多少就不知道了,这样我们链接的目的就达不到了)。
鉴于以上原因我们还得创建一个ptail指针(让ptail指针指向后一个结点,一直往下走),让phead保存第一个结点的地址,这样我们通过phead就能找到所有的结点,我们链接的目的就达到了。
代码实现如下:

SLTNode* CreateSList(int n)
{
	SLTNode* phead = NULL;
	SLTNode* ptail = NULL;
	int i = 0;
	for (i = 0; i < n; i++)
	{
		SLTNode* newnode = BuySListNode(i);
		if (phead == NULL)
		{
			ptail = phead = newnode;
		}
		else
		{
			ptail->next = newnode;
			ptail = newnode;
		}
	}
	return phead;
}

以上代码就实现了创建n个结点的链表的需求,比如我们想要创建是个结点的链表就可以这样写:STList* plist = CreateSList (10);

3.实现一个打印链表中所存数据的函数
void SLTPrint(SLTNode* phead)
{
	SLTNode* cur = phead;
	while (cur != NULL)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}
	printf("NULL");
}

代码理解:
在目录三中创建n个结点的函数中,最后这个函数返回了第一个结点的地址,
那我们就要利用这第一个结点的地址来依次访问所有的结点,从而打印所有结点中的数据。
具体想法是,我们通过传进来的第一个结点的地址,我们可以首先找到第一个结点中的数据data并且打印出来,然后再找到第一个结点的next(第一个结点的next存储的是第二个结点的地址)并将next赋值给phead,这样phead又可以找到第二个结点的数据data并且打印出来,然后再找到第二个结点的next(第二个结点的next存储的是第三个结点的地址),并将next赋值给phead,再往下,其实这是一个循环,循环的终止条件是phead不等于NULL(因为在BuySListNode函数中,创建每个结点的时候,里面的next被赋值成了NULL,但在CreateSList函数中将每个结点链接起来时,把每个结点中的next赋值成了下一个结点的地址,但在最后一个结点中的next,它还是NULL,因为最后一个结点已经没有下一个结点了),phead == NULL就说明已经找到了最后一个结点了,没有下一个结点了,把它里面的数据打印出来,这个函数的使命就完成了,当然循环结束后还可以打印一个NULL,表示结点打印完了。
但代码走下来会发现,phead被赋值成最后一个结点的next中的NULL了,我们把传进来的第一个结点的地址改成NULL了,那我们如何找到第一个结点的地址呢?所以我们用一个cur指针来代替phead,这样就不会改变传进来的地址了而且还完成了打印。
注:打印单链表的函数是不需要断言的,因为链表中一个结点都没有时,也是可以打印的,只不过什么都打印不出来而已。

二、单链表—尾插尾删头插头删

1.尾插

注意:尾插并不是简简单单在单链表后面创建一个新结点这么简单,创建的新结点还必须与原单链表构成链接,即原单链表的最后一个结点中的next中要存储新创建的结点的地址。如何找到原单链表的最后一个结点的next就是实现尾插的核心。
代码实现如下:

void SLTPushBack(SLTNode* phead, SLTDataType x)
{
	//先创建一个新结点
	SLTNode* newnode = BuySListNode(x);
	//用tail来代替phead,防止找不到头结点
	SLTNode* tail = phead;
	//找原来创建好的单链表的最后一个结点的next,然后将新结点的地址赋值给它,就完成了尾插。
	while (tail->next)
	{
		tail = tail->next;
	}
	tail->next = newnode;
}

代码理解:单链表尾插的时候需要两个参数,一个是原来创建好的单链表的头结点的地址,一个是数据(这个数据就是你创建一个新的结点里面要存的值)。
第一步:先创建一个新结点。
第二步:用tail来代替phead,防止找不到头结点。
第三步:找原来创建好的单链表的最后一个结点的next,然后将新结点的地址赋值给它,就完成了尾插。
但上面的代码还有缺陷:我们实现的尾插还得满足一个需求,就是如果是一个空的单链表我们也要能插入结点(单链表为空就是单链表里面一个结点都没有,体现在代码上就是指向第一个结点的指针被赋值成空指针NULL即SLTNode* slist = NULL),但上面的代码当我们把为空指针slist传给phead时,ptail变成空指针,空指针是不能解引用的,这个函数就不能正常运行了。
进一步优化一下就会出现以下代码:

void SLTPushBack(SLTNode* phead, SLTDataType x)
{
	//先创建一个新结点
	SLTNode* newnode = BuySListNode(x);
	if (phead == NULL)
	{
		phead = newnode;
	}
	else
	{
		//用tail来代替phead,防止找不到头结点
		SLTNode* tail = phead;
		//找原来创建好的单链表的最后一个结点的next,然后将新结点的地址赋值给它,就完成了尾插。
		while (tail->next)
		{
			tail = tail->next;
		}
		tail->next = newnode;
	}
}

代码理解:
该函数想要达成的目标就是如果slist为空指针,就将新创建的结点的地址赋值给phead,进而使得slist指向第一个结点,接下来调用SLTPushBack函数实现尾插。
但这种实现方式其实是不能达到我们的需求的,原因就是slist是实参,phead是形参,形参是实参的一份临时拷贝,对形参phead的改变是不会影响实参slist的,所以slist依然还是空指针。

那我们如何才能通过改变形参来改变实参呢?
方法是把实参slist的地址传给形参phead,这样形参通过解引用就可以找到实参,从而修改形参。
代码实现如下:

void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
	//先创建一个新结点
	SLTNode* newnode = BuySListNode(x);
	if (*pphead == NULL)
	{
		*pphead = newnode;
	}
	else
	{
		//用tail来代替phead,防止找不到头结点
		SLTNode* tail = *pphead;
		//找原来创建好的单链表的最后一个结点的next,然后将新结点的地址赋值给它,就完成了尾插。
		while (tail->next)
		{
			tail = tail->next;
		}
		tail->next = newnode;
	}
}

上面的代码才是一个单链表的尾插。
注:单链表的尾插是不需要断言的,因为当链表中一个结点都没有时也是可以尾插的。

2.尾删

实现尾删的方法
1.找到尾结点。
2.把尾结点的空间释放掉。
3.把尾结点的前一个结点里面的next赋值成NULL。

我们先一步步来:
代码如下:

void SLTPopBack(SLTNode* phead)
{
	SLTNode* tail = phead;
	//找尾
	while (tail->next)
	{
		tail = tail->next;
	}
	free(tail);
	tail = NULL;
}

代码理解:
但是上面的代码只完成了第一步和第二步,第三步还未完成(把tail赋值成NULL,并没有完成第三步,因为tail是一个指向某一个结点的指针变量,把tail赋值成NULL,它只是变成一个空指针,不指向某一个结点了,但是尾结点的前一个结点里面的next仍然存着尾结点的地址,但尾结点的空间已经被释放了,那next里面存着一个被释放的地址,这就是很严重的野指针问题了,tail赋不赋值成NULL,是无所谓的,它只是一个局部变量,出了函数的作用域,它的空间自然就释放了)。

进一步优化:

void SLTPopBack(SLTNode* phead)
{
	SLTNode* prev = NULL;
	SLTNode* tail = phead;
	//找尾
	while (tail->next)
	{
		prev = tail;
		tail = tail->next;
	}
	free(tail);
	prev->next = NULL;
}

代码理解:
我们再创建一个新指针prev,在tail指向下一个结点之前,将tail赋值给prev,当tail指向尾结点的时候,prev恰好指向尾结点的前一个结点,把尾结点的空间释放之后,我们又可以通过prev里面所存的地址来找到尾结点的前一个结点,进而把里面的next赋值为NULL,这样三步都完成了,尾删完成实现。

还有一种优化方法:

void SLTPopBack(SLTNode* phead)
{
	SLTNode* tail = phead;
	//找尾
	while (tail->next->next)
	{
		tail = tail->next;
	}
	free(tail->next);
	tail->next = NULL;
}

代码理解:
tail存着某一个结点的地址,那我们可以使用->解引用找到该结点里面的next,该结点里面的next存着下一个结点的地址,然后再使用->解引用找到下一个结点里面的next。
如果下一个结点里面的next为空,就将下一个结点的地址tail->next传给free,然后free释放掉,再将tail->next赋值成NULL

但上面这两种优化方法还存在一个缺陷,就是如果链表中只有一个结点时,用这两种代码尾删会出现错误。
错误原因如下:
第一个代码:只有一个结点时,那第一个结点的next为空指针,不会进入while循环,就将第一个结点的空间释放掉,(接下来的错误就要出现了),然后通过解引用第一个结点的地址,找到它里面的next,并将它赋值成NULL。
我们已经将第一个结点的空间释放了,然后又要解引用第一个结点的地址,我们要访问一个已经不属于我们的空间,这就是野指针问题了,当然会出现错误。
第二个代码:只有一个结点时,那第一个结点里面的next为空指针,空指针那我们就不能对它解引用,在while循环的条件那里就出错了。

再进一步优化:

void SLTPopBack(SLTNode* phead)
{
	if (phead->next == NULL)
	{
		free(phead);
		phead = NULL;
	}
	else
	{
		SLTNode* tail = phead;
		//找尾
		while (tail->next->next)
		{
			tail = tail->next;
		}
		free(tail->next);
		tail->next = NULL;
	}
}

如果链表中只有一个结点进行尾删,我们如何处理这种情况呢?
方法:如果链表中只有一个结点,先将这个结点的空间释放掉,然后通过传进来的指向首结点的指针变量slist的地址将slist赋值成NULL。
但上面这个代码再次犯了经典错误,上面的函数的形参phead和实参slist的类型是相同的,数据类型是相同的话,那phead只是slist的一份临时拷贝,改变phead不会影响slist。我们只有将指针变量slist的地址传给形参,函数才能通过解引用来改变slist。(总结:想要在函数里面改变函数外面某一个变量的值就将这个变量的地址传给这个函数的形参,这个函数才能通过解引用来改变这个变量的值)。

所以走到这里,我们会发现我们实现的尾删要满足两种情形,第一种情形是当链表中的结点大于一个时,我们只需要三步:1.找到尾结点。2.把尾结点的空间释放掉。3.把尾结点的前一个结点里面的next赋值成NULL。第二种情形:链表中的结点只有一个时,尾删需要两步:1.将该结点的空间释放掉。2.将尾删函数外面的指向首结点的指针变量slist赋值成NULL。(第二种情形下必须有第二步,否则打印链表时,会出现一些乱数字,这些乱数字就是你打印了不属于你的空间上的东西,正常打印情况下是打印不出啥的,因为这个链表只有一个结点,你把这唯一 一个结点的空间都释放了,你还拿着这个结点的地址去打印这个空间上的东西,这就是野指针问题了)

这两种情形,分别需要的函数参数类型也不同,想要实现第一种情形,只需要传入slist的值就行,那函数参数类型就是SLTNode*,第二种情形下,因为需要改变指针变量slist的值,所以要将指针变量slist的地址传给形参,那形参的类型就是SLTNode**
但尾删函数这两种情形都要满足,那我们就要找找哪种类型的参数把这两种情形都能实现?
我们发现参数的类型是SLTNode时即把指针变量slist的地址传给形参,它就可以满足这两种需求,因为第一种需求它只需要指针变量slist中的数据,那我们对SLTNode phead解引用不就可以直接找到slist,并且拿到slist里面的数据了吗,第二种需求它需要通过指针变量slist的地址将slist赋值成NULL,那我们传给形参的不就是指针变量slist的地址吗,直接解引用修改slist就行。
代码实现如下:

void SLTPopBack(SLTNode** pphead)
{
	assert(*pphead);
	if ((*pphead)->next == NULL)
	{
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
		SLTNode* tail = *pphead;
		//找尾
		while (tail->next->next)
		{
			tail = tail->next;
		}
		free(tail->next);
		tail->next = NULL;
	}
}

注:单链表的尾删是需要断言的,因为链表中一个结点都没了,就不能再尾删了,具体说就是指向首结点的指针变量为空指针NULL时,就不能再尾删了。

3.头插

头插的具体思路:首先我们先创建一个新的结点,然后把新的结点与原来链表的头结点链接起来(把原来头结点的地址赋值给新结点中的next),然后再让slist指向新的结点。
代码如下:

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

因为头插需要改变slist的值所以形参要用二级指针。
注:头插不需要assert,因为链表中一个结点也没有时,也可以头插呀。另外当链表中一个结点也没有时,上面的代码也可以完成头插,不用像尾插那样进行分情形处理。

4.头删

实现头删的具体思路:首先把第二个结点的地址保存起来,再释放掉第一个结点的空间,然后把保存的第二个结点的地址赋值给*pphead
代码如下:

void SLTPopFront(SLTNode** pphead)
{
	assert(*pphead);
	SLTNode* next = (*pphead)->next;
	free(*pphead);
	*pphead = next;
}

因为头删也要改变slist的值所以形参要用二级指针。
注:头删需要assert,因为链表中一个结点都没了,就不能再头删了。另外当只有一个结点时,上面的代码也可以完成头删,不用像尾删那样进行分情形处理。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值