【 数据结构 】单链表的实现 - 详解(C语言版)

目录

前言:

顺序表的缺陷:

单链表:(Single Linked List)

概念及结构:

单链表的实现:

头文件:SList.h

malloc函数:

 free函数:

具体函数的实现:SList.c

单链表的打印:

创建一个新的结点:

单链表尾的插:

单链表的头插:

单链表的尾删:

单链表的头删:

单链表的查找:

在单链表pos位置之前插入数据:

在单链表pos位置之后插入数据:

在单链表pos位置删除数据:

在单链表pos后一个位置删除数据:

单链表的销毁:

总结:


前言:

本文用C语言来描述数据结构中的单链表,下文实现的只是简单的无头非循环链表,包括单链表的增加数据,删除数据,查找指定数据,修改指定数据,也就是简单的增删查改等操作。


顺序表的缺陷:

优点:

  1. 点是个连续的物理空间,方便下标随机访问。

缺点:

  1.      插入数据,空间不足要扩容,扩容有性能消耗
  2.      头部或者中间插入删除数据,需要挪动数据,效率较低
  3.      可能存在一定的空间占用,浪费空间。
  4.      不能按需申请和释放空间。

基于顺序表的缺点,于是就设计出了链表结构。


单链表:(Single Linked List)

概念及结构:

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

2.单链表的结构为:

【 数据结构 】单链表的实现 - 详解(C语言版)_第1张图片

    3.无头单向非循环链表:

【 数据结构 】单链表的实现 - 详解(C语言版)_第2张图片 无头单向非循环链表的结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的     子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。


备注:

一般我们写一个项目的时候,将所要包含的头文件,函数的声明,结构体等放在一个头文件.h 里面,一般将函数的定义也就是函数实现的过程放在.c的文件里面,一般将函数的测试也就是主函数写在另一个.c的文件里面也就是test.c.这个文件里面。


单链表的实现:

头文件:SList.h

#include
#include
#include
#include
#include

typedef int SLDataType;


//单链表结构的基本定义
//逻辑结构
typedef struct SListNode
{
	SLDataType data;//val  -   数据域
	struct SListNode* next;//存储下一个结点的地址    -    指针域
}SListNode, SLN;

//打印单链表
void SListPrint(SListNode* phead);
//单链表的尾插
void SListPushBack(SListNode** pphead, SLDataType x);
//单链表的头插
void SListPushFront(SListNode** pphead, SLDataType x);
//单链表的尾删
void SListPopBack(SListNode** pphead);
//单链表的头删
void SListPopFront(SListNode** pphead);
//单链表的查找
SListNode* SListFind(SListNode* phead, SLDataType x);
//在单链表pos位置之前插入数据
void SListInsertBefore(SListNode** pphead, SListNode* pos, SLDataType x);
//在单链表pos位置之后插入数据
void SListInsertAfter(SListNode* pos, SLDataType x);
//在单链表pos位置删除数据
void SListErase(SListNode** pphead, SListNode* pos);
//在单链表pos后一个位置删除数据
void SListEraseAfter(SListNode* pos);
//单链表的销毁
void SListDestroy(SListNode** pphead);

一个struct SListNode类型的结构体又叫做一个结点(节点),包含数据域和指针域,数据域存放的是一个数据,指针域存放的是下一个结点的地址。


malloc函数:

C语言提供了一个动态内存开辟的函数,函数原型如下:

8274600ebffc461da9e8ce941c210bf7.png

这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针:

  • 如果开辟成功,则返回一个指向开辟好空间的指针。
  • 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
  • 返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定,通常情况下要对malloc返回的指针强转成所需要的指针。
  • 如果参数 size为0,malloc的行为是标准是未定义的,取决于编译器。

 free函数:

C语言提供了专门用来做动态内存的释放和回收的函数:函数原型如下:
cfb81dddbe0e4733bec63031cc0be5ac.png
这个函数用来释放动态开辟的内存:

  • 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
  • 如果参数 ptr 是NULL指针,则函数什么事都不做。

同时malloc和free都声明在 stdlib.h 头文件中,malloc函数是在堆区申请空间的。
 

//malloc函数的使用:
int main()
{
	//开辟10个整形的空间
	//int arr[10];
	int* p = (int*)malloc(sizeof(int) * 10);
	if (p == NULL)
	{
		printf("%s\n", strerror(errno));
		return 0;//结束代码
	}
	//使用
	int i = 0;
	for (i = 0; i < 10; i++)
	{
		*(p + i) = i;
	}
	for (i = 0; i < 10; i++)
	{
		printf("%d ", p[i]);
	}
	free(p);//当释放后p就变成野指针了
	p = NULL;

	return 0;
}

malloc函数最好要和free函数配合使用,不然申请空间不释放(虽然在程序结束时申请的内存会被回收)但在程序结束前就可能会造成内存泄漏。free掉空间之后要将空间的首地址置空,在后续操作中该指针可能被用到从而造成非法访问。


具体函数的实现:SList.c

单链表需要头指针来存放头结点的首地址,所以在测试.c文件的文件中要创造一个phead是 

struct SListNode* 类型的头指针用来存放头结点的地址。

即 struct SListNode* phead = NULL;

当链表为空的时候,头指针就为空指针NULL。


单链表的打印:

//单链表的打印
void SListPrint(SListNode* phead)
{
	//assert(phead); - 不需要断言 - 如果为空指针的话就是空链表
	SListNode* cur = phead;
	while (cur != NULL)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}
	printf("NULL\n");

}

phead拿到传过来的链表的头指针,然后由头指针遍历整个链表,将每个结点中的数据打印出来。


创建一个新的结点:

//创建一个新的结点
SListNode* BuySListNode(SLDataType x)
{
	SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
	//对malloc函数返回值的判断
	if (newnode == NULL)
	{
		//printf("malloc fail\n");
		printf("%s\n", strerror(errno)); //报出错误
		exit(-1);  //结束程序
	}
	else
	{
		newnode->data = x;
		newnode->next = NULL;
	}

	return newnode;
}

通过malloc函数在堆区申请一块新的结点,将数据放在结点中,将指针域置空,并返回这块新结点的首地址。


单链表尾的插:

将新创建的结点接到原来链表上去

//单链表的尾插 - 将新创建的结点接到原来链表上去
void SListPushBack(SListNode** pphead, SLDataType x)
{
	assert(pphead);  //pphead - 是头指针的地址

	//创建一个新的结点
	SListNode* newnode = BuySListNode(x);

	if (*pphead == NULL)//空链表的尾插
	{
		*pphead = newnode;
	}
	else              //链接到最后一个结点 遍历(找尾)
	{
		SListNode* tail = *pphead;
		//找尾
		while (tail->next != NULL)
		{
			tail = tail->next;
		}
		tail->next = newnode;
		//将新的头的地址链表最后一个结点里的指针变量。
		//新的结点的头指针和原来链表的尾指针都在堆区。
	}
}

【 数据结构 】单链表的实现 - 详解(C语言版)_第3张图片

【 数据结构 】单链表的实现 - 详解(C语言版)_第4张图片

1.这里用到了二级指针的解释:

正常情况下的尾插:

如果尾插函数第一个参数是SListNode*  phead,那么phead就是SListNode*类型的一级指针,接收的是链表的头指针,这时tail应被赋值成:SListNode* tail = phead,(tail此时指向的是链表的头结点)当链表不为空的时候,tail通过while循环找到链表的尾,将新的结点链接到链表的尾部。

特殊情况下的尾插:(注意)

如果尾插函数第一个参数是SListNode*  phead,当尾插的链表是空链表的时候,这时如果只是单纯的将phead = newnode;这样操作之后,实际上的头结点并没有被改变,因为函数的形参只是实参的一份临时拷贝尾插函数只是将形参的phead的值改成了newnode并没有将真正的头指针改变,同时函数结束时这个phead局部变量会被销毁所以这里要用到二级指针来接收头指针的地址,之前C语言的学习中函数章节中提到,函数的传值和传址的区别,想要改变值就要传地址(指针),同样的道理这里要想改变地址,就得传地址的地址,所以形参用到了二级指针(参考上正确述代码)。

2.测试函数中就该这样写:

struct SListNode* head = NULL;

SListPushBack(&head);

3.注意点:

有些初学者会在找尾的时候出现经典的错误:

【 数据结构 】单链表的实现 - 详解(C语言版)_第5张图片

 同样的道理:tail指针是在函数中创建的是在栈区的局部变量,tail = newnode;的操作只是将这个局部变量的值改成了newnode,并没有改变链表中结点的指针域,当函数结束时tail的内容也会被销毁,同样新结点并没有链接到链表的尾部。

4.同样值得注意的是:

这里是将空链表的情况单独处理的,因为tail是赋值成头指针的,当链表为空的时候,*pphead == NULL;tail也是NULL。当找尾的时候,循环进入条件tail->next;这个操作就可能涉及到了对空指针的引用。就会出现错误,所以就将空链表的情况单独拿出来处理。

5.小结:

只要涉及改变链表头指针的操作,就要传二级指针。


单链表的头插:

//单链表的头插
void SListPushFront(SListNode** pphead, SLDataType x)
{
	//创建一个新的结点
	SListNode* newnode = BuySListNode(x);
	newnode->next = *pphead;
	*pphead = newnode;

}

【 数据结构 】单链表的实现 - 详解(C语言版)_第6张图片

 【 数据结构 】单链表的实现 - 详解(C语言版)_第7张图片

思路:

将新的结点的尾链接到原来链表的头,再将头指针指向新结点的头。

注意:

这里要注意链接的顺序,如果先将头指针指向新结点的头,再将新的结点的尾链接到原来链表的头的话,原来链表的头就找不到了,因为原来链表的头是放在头指针里面的,若先将头指针改了就找不到原来的头了,就链接不上了。


单链表的尾删:

void SListPopBack(SListNode** pphead)
{
	assert(pphead);

	//1.空链表
	//2.一个结点
	//3.多个结点

	//空链表的情况
	
	//暴力检查 - assert(*pphead != NULL);
	if (*pphead == NULL)//温柔检查
	{
		return;
	}
	//只有一个结点的情况
	else if ((*pphead)->next == NULL)//解引用和箭头的优先级一样高这里要带括号
	{
		free(*pphead);
		*pphead = NULL;
	}
	//多个结点的情况
	else
	{
		SListNode* tail = *pphead;
		while (tail->next->next != NULL)
		{
			tail = tail->next;
		}
		free(tail->next);
		tail->next = NULL;
	}
  	
}

因为这里涉及改变链表的头指针所以传的是二级指针。

【 数据结构 】单链表的实现 - 详解(C语言版)_第8张图片

【 数据结构 】单链表的实现 - 详解(C语言版)_第9张图片

思路:

在链表的尾部删除结点通常的想法就是把要删除的结点free掉,然后将要删除的结点的前一个结点的指针域置成空,但是如果遍历链表找到尾结点的话,就不能再找到尾的前一个结点。这时当务之急就是找到要删除的结点的前一个结点,这时就会想到循环判断条件为tail->next->next;这样向后找到尾的前一个结点地址,并且通过这个结点还能找到要删除的结点。

但这样的找法会在极端情况下出错,例如空链表和只有一个结点的情况,所以便有了以下的分析。

这里分为三种情况考虑: 

1.链表为空链表:

   当链表为空链表时,就不存在删除数据的情况,因为没有是数据可删,直接结束函数即可。

2.链表只有一个结点:

   当链表只有一个结点时,tail->next->next;会出现对空指针NULL引用,所以要单独拿出来处理。

3.链表有多个结点 :

   就可以按照通常思路找尾,先将尾结点释放掉,再将指向尾结点的指针即倒数第二个结点的指针域置空(NULL)。

这里要注意置空和释放的顺序,如果先置空的话,就找不到尾结点也就不能free释放掉要删除的结点,这样会造成内存泄露。


单链表的头删:

//单链表的头删
void SListPopFront(SListNode** pphead)
{
	assert(pphead);

	//1.空
	//2.非空
	
	if (*pphead == NULL)
	{
		return;
	}
	else
	{
		SListNode* next = (*pphead)->next;
		free(*pphead);
		*pphead = next;
	}

}

因为这里涉及改变链表的头指针所以传的是二级指针。

【 数据结构 】单链表的实现 - 详解(C语言版)_第10张图片【 数据结构 】单链表的实现 - 详解(C语言版)_第11张图片

思路:

 这里要考虑两种情况,链表为空的时,和链表为非空的时。

1.当链表为空的时:

直接结束函数,因为没有可删除的结点。

2.当链表不为空时:

将链表的头指针释放,再将头指针指向第二个结点。


单链表的查找:

//单链表的查找
SListNode* SListFind(SListNode* phead, SLDataType x)
{
	SListNode* cur = phead;
	while (cur != NULL)
	{
		if (cur->data == x)
		{
			return cur;
		}
		cur = cur->next;
	}

	return NULL;
}

思路:

定义一个cur指针从头到尾依次整个链表,只要找到符合条件的结点,就返回该结点的地址。


在单链表pos位置之前插入数据:

//在单链表pos位置之前插入数据
void SListInsertBefore(SListNode** pphead, SListNode* pos, SLDataType x)
{
	assert(pphead);
	assert(pos);//空链表排除

	//1.pos是第一个结点
	//2.pos不是第一个结点

	if (pos == *pphead)
	{
		SListPushFront(pphead, x);
	}
	else
	{
		SListNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		SListNode* newnode = BuySListNode(x);
		prev->next = newnode;
		newnode->next = pos;
	}
}

这个函数需要配合SListFind函数使用来找到pos位置。

assert断言,将pos和pphead为空指针的情况排除。

因为这里涉及改变链表的头指针所以传的是二级指针。

【 数据结构 】单链表的实现 - 详解(C语言版)_第12张图片

【 数据结构 】单链表的实现 - 详解(C语言版)_第13张图片

思路:

分两种情况:当pos前为空的时,和pos前不为空时。

1.当pos前为空时:

就相当于头插,直接调用头插函数。

 2.当pos前不为空时:

创建一个指针遍历链表找到pos位置前一个结点,再通过创建结点函数创建一个新的结点再将其链接到单链表中。 


在单链表pos位置之后插入数据:

//在单链表pos位置之后插入数据

方法一:(无关顺序)
//void SListInsertAfter(SListNode* pos, SLDataType x)
//{
//	assert(pos);
//	SListNode* next = pos->next;
//	SListNode* newnode = BuySListNode(x);
//	pos->next = newnode;
//	newnode->next = next;
//
//}

//方法二:(注意顺序)
void SListInsertAfter(SListNode* pos, SLDataType x)
{
	assert(pos);
	SListNode* newnode = BuySListNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

这个函数需要配合SListFind函数使用来找到pos位置。

因为这里涉及改变链表的头指针所以传的是二级指针。

【 数据结构 】单链表的实现 - 详解(C语言版)_第14张图片【 数据结构 】单链表的实现 - 详解(C语言版)_第15张图片

思路:

这时已经拿到了pos位置只需要将申请的新结点链接在指定位置便可,同样要注意链接的顺序问题,如果操作不当会造成原链表pos位置后的结点找不到的问题。

这里提供了两种方法:

第一种:创建临时变量来存放原链表pos位置下一个结点地址,这样就不会丢失了。

第二种:就是直接链接但是要注意链接顺序。


在单链表pos位置删除数据:

//在单链表pos位置删除数据
void SListErase(SListNode** pphead, SListNode* pos)
{
	assert(pphead);
	assert(pos);

	//当传头指针时
	if (*pphead == pos)
	{
		SListPopFront(pphead);
	}
	else
	{
		SListNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		prev->next = pos->next;
		free(pos);
		pos = NULL;
	}
	
}

这个函数需要配合SListFind函数使用来找到pos位置。

【 数据结构 】单链表的实现 - 详解(C语言版)_第16张图片

【 数据结构 】单链表的实现 - 详解(C语言版)_第17张图片

思路:

分为两种情况:一种是只有一个结点和多个结点的情况。

1当链表只有一个结点时:

也就相当于头删,直接调用头删函数。

2.当链表有多个结点时:

这里创建了一个prev指针来遍历链表,找到pos位置之前的结点,将pos的下一个位置链接到prev的尾部,再将pos位置free释放掉。

注意:

 这里还是要注意释放和链接的顺序的问题,如果先free(pos)释放pos位置的话,pos->next就找   不到了。

pos = NULL;这一步置空是个好习惯。


在单链表pos后一个位置删除数据:

//在单链表pos后一个位置删除数据(不可能是头删)
void SListEraseAfter(SListNode* pos)
{
	assert(pos);
	SListNode* next = pos->next;
	if (next != NULL)
	{
		pos->next = pos->next->next;
		free(next);
		next = NULL;
	}
}

这个函数需要配合SListFind函数使用来找到pos位置。

assert断言,将pos为空指针的情况排除。

【 数据结构 】单链表的实现 - 详解(C语言版)_第18张图片【 数据结构 】单链表的实现 - 详解(C语言版)_第19张图片

思路:

分三种情况 :一种是pos下一个有结点,一种pos是头,一种是pos下一个是空。

1.pos下一个有结点:

直接创建一个指针next,用来存放pos->next,设置这个next临时变量的作用是防止free释放要删除结点的时候找不到要删除的结点,因为要将pos下下一个结点接到pos->next的位置,但是要删除的结点头是pos->next,如果先链接的话,pos->next就会被改了,被删除的结点就找不到了,就不能free释放掉该结点,有可能会造成内存泄露。

2.pos是头:

这里不可能是头删,因为这里 pos最靠进表头只能传头指针,头结点后一个也不可能是头删。

3.pos下一个是空:

那就没的删只能结束函数。


单链表的销毁:

//单链表的销毁
void SListDestroy(SListNode** pphead)
{
	assert(pphead);

	//一个一个结点释放
	SListNode* cur = *pphead;
	while (cur)
	{
		SListNode* next = cur->next;
		free(cur);
		cur = next;
	}
	*pphead = NULL;

}

思路:

用一个指针(cur)遍历整个链表,同时还需要一个指针(next)用来存放当前指针的下一个结点地址,循环free释放当前cur指向的结点。再继续迭代,cur再指向next的位置,再次进循环,直到链表遍历结束,这样就将整个链表申请的节点空间全部释放了。


总结:

数据结构这方面,考虑问题一定要全面,不能将通常情况当做所有的情况,要考虑到极端的个别情况,并将各个细小的细节处理妥当,当程序出现问题时,应当多思考,多调试用多组数据进行测试,发散思维,有利于能力的提升!

你可能感兴趣的:(数据结构,-,初阶,c语言,数据结构)