C语言进阶——数据结构之链表

前言

hello,大家好呀,我是Humble  在之前的两篇博客,我们学完了数据结构中的顺序表,还对它进行了一个应用,做了一个通讯录的小项目

那今天我们再来学习一个新的数据结构——链表

b90abe6962934bd1880f3d53f5b63113.jpg

引入

我们来回忆一下顺序表

对于顺序表,我们发现它有下面的这些问题

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

思考:如何解决以上问题呢?有没有以一种数据结构,它可以解决顺序表的这些问题呢?

这就是我们今天要讲的链表
 

链表的概念及结构

链表在物理存储结构上是非连续、非顺序的存储的、

其数据元素的逻辑顺序是通过链表中的指针链接次序实现的

而与顺序表不同的是,链表是由节点组成的
节点的组成主要有两个部分:

1.当前节点要保存的数据

2.保存下一个节点的地址(指针变量)
 

变量来保存下一个节点位置才能从当前节点找到下一个节点


结合结构体的知识,我们可以给出每个节点对应的结构体代码:
 

struct SListNode
{
int data; //节点数据,我们假设当前保存的节点为整型
struct SListNode* next; //指针变量⽤保存下⼀个节点的地址
};

当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存,这个内存不仅要保存整型数
据,也需要保存下一个节点的地址


所以,当我们想要从第一个节点走到最后一个节点时,只需要在前一个节点拿上下一 个节点的地址就可以了(有点绕,请耐心理解哦)

那么,给定的链表结构中,我们来实现一下节点从头到尾的打印吧~

我们在创建一个SList 的工程表示单链表

然后创建3个文件,分别是我们的SList.h 头文件 ,SList.c源文件以及测试文件test.c

(这个大家应该已经很熟悉了吧)

在三个文件中,我们分别去实现各自的职能

SList.h

#pragma once


typedef int SLTDataType;

typedef struct SListNode
{
	SLTDataType data;
	struct SListNode* next;
}SLTNode;


void SLTPrint(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");
}

test.c

#include "SList.h"


void SlistTest01() {
	//一般我们不会这样去创建链表,这里只是为了给大家展示链表的打印
	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;  

	SLTNode* plist = node1;
	SLTPrint(plist);  //打印1->2->3->4->NULL
}

int main()
{


    SlistTest01();
	return 0;
}

我们来测试一下,按照我们的想法,应该打印1->2->3->4->NULL

运行结果:


 

单链表的实现

找到了链表的打印,我们就来实现链表的各个功能吧

链表的尾插

这要分两种情况来讨论

1.链表不为空

2.链表为空

先画张图来辅助理解一下:

假设我们要在链表插入 元素4

下面我们来写尾插STLPushBack的代码:

void SLTPushBack(SLTNode** pphead, SLTDataType x) //注意这里pphead是二级指针,用**
{
	assert(pphead);

    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    newnode->data = x;
    newnode->next = NULL;

	//链表为空,新节点作为phead
	if (*pphead == NULL) {
		*pphead = newnode;
		return;
	}
	//链表不为空,找尾节点
	SLTNode* ptail = *pphead;
	while ((ptail->next) != NULL) //遍历
	{
		ptail = ptail->next;
	}
	//遍历完之后ptail就是尾节点
	ptail->next = newnode; //完成尾插
}

下面我们来测试一下

我们在test.c中这样写:

void SlistTest02()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1); //我们要把plist指针的地址传过去,这个很重要!
	
	SLTPrint(plist); //预计结果1->NULL
}

int main()
{


	SlistTest02();
		

	return 0;
}

运行一下:

当然,因为我们下面的操作都要设计申请节点,每次都要写:

   SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    newnode->data = x;
    newnode->next = NULL;

我们干脆就再写一个函数,之后直接调用就行

这样代码就会变成这样

SLTNode* SLTBuyNode(SLTDataType x) //申请新节点
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	newnode->data = x;
	newnode->next = NULL;

	return newnode;
	
}


void SLTPushBack(SLTNode** pphead, SLTDataType x) 
{
	assert(pphead);

	SLTNode* newnode = SLTBuyNode(x);

	//链表为空,新节点作为phead
	if (*pphead == NULL) {
		*pphead = newnode;
		return;
	}
	//链表不为空,找尾节点
	SLTNode* ptail = *pphead;
	while (ptail->next)
	{
		ptail = ptail->next;
	}
	//ptail就是尾节点
	ptail->next = newnode;
}

接下来我们来看一下头插SLTPushFront:

它同样分2种情况,但它们的代码是一样的,所以就不用分了

void SLTPushFront(SLTNode** pphead, SLTDataType x) 
{
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);

	
	newnode->next = *pphead;
	*pphead = newnode;
}

测试一下:
 

void SlistTest02()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);
	
	

	SLTPushFront(&plist, 5);          
	SLTPushFront(&plist, 6);        
	SLTPushFront(&plist, 7);
	SLTPrint(plist);         //期望结果为:7->6->5->1->2->3->4->NULL
}

int main()
{


	SlistTest02();
		

	return 0;
}

运行结果如下:

接下来看一下尾部删除SLTPopBack吧~

既然要删除,我们要保证链表不为空,所以相比前面的这几种操作,它还要加上

assert(*pphead);//表示链表不能为空

此外,要分链表是否只有一个节点,即是否有前驱节点这2中情况
 

void SLTPopBack(SLTNode** pphead) 
{
	assert(pphead);
	
	assert(*pphead);//保证链表不能为空

	
	//链表只有一个节点
	if ((*pphead)->next == NULL) 
	{
		free(*pphead);
		*pphead = NULL;
		return;
	}
     //链表有多个节点
	SLTNode* ptail = *pphead;
	SLTNode* prev = NULL;
	while ((ptail->next)!=NULL)
	{
		prev = ptail;
		ptail = ptail->next;
	}

	prev->next = NULL;
	//销毁尾结点
	free(ptail);
	ptail = NULL;
}

我们也来测试一下:

void SlistTest02()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);
	
	


	SLTPopBack(&plist);
	SLTPrint(plist);  //预期结果为1->2->3->NULL
	
}


int main()
{


	SlistTest02();
		

	return 0;
}

运行结果如下:

接下来看一下头部删除SLTPopFront吧~

这个也很简单,我们直接上代码~

//头删
void SLTPopFront(SLTNode** pphead) 
{
	assert(pphead);
	//链表不能为空
	assert(*pphead);

	//让第二个节点成为新的头
	//把旧的头结点释放掉
	SLTNode* next = (*pphead)->next;
	free(*pphead);
	*pphead = next;
}

接下来我们也是测试一下

void SlistTest03()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);

		//头删
	SLTPopFront(&plist);
	SLTPrint(plist);  //2->3->4->NULL
	SLTPopFront(&plist);
	SLTPrint(plist);  //3->4->NULL
}


int main()
{
	SlistTest03();
	return 0;
}

运行结果:
 

 

好,我们已经实现了头部和尾部的插入和删除的操作,接下来我们来实现一下查找的操作~

//查找
SLTNode* SLTFind(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);

	//遍历链表
	SLTNode* pcur = *pphead;
	while (pcur) //等价于pcur != NULL
	{
		if (pcur->data == x) {
			return pcur;
		}
		pcur = pcur->next;
	}

	//没有找到
	return NULL;

}

接下来测试一下:
 

void SlistTest03()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);

	SLTNode* FindRet = SLTFind(&plist,1); //1 在链表中,可以找到

	if (FindRet) {
		printf("找到了!\n");
	}
	else {
		printf("未找到!\n");
	}

}



int main()
{

	SlistTest03();

	
	return 0;
}

运行结果:

接下来我们看一下在指定位置插入数据~

它分为2种,在指定位置之前插入和在指定位置之后插入数据

先看在指定位置之前插入数据

它要分要插入的位置是头节点和不是头节点2种情况讨论哦

实现代码如下:
 

//在指定位置之前插入数据

void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{

	assert(pphead);
	assert(*pphead);//链表 不能为空!
	assert(pos);
	

	SLTNode* newnode = SLTBuyNode(x);

	//pos刚好是头结点
	if (pos == *pphead) 
	{
		//头插
		SLTPushFront(pphead, x);
		return;
	}

	//pos不是头结点的情况
	SLTNode* prev = *pphead;
	while (prev->next != pos)
	{
		prev = prev->next;
	}
	
	prev->next = newnode;
	newnode->next = pos;

}

好,我们来测试一下~

void SlistTest03()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);

	SLTNode* FindRet = SLTFind(&plist,1);

	SLTInsert(&plist, FindRet, 100); 
	SLTPrint(plist);//预期是100->1->2->3->4->NULL


}

int main()
{

	SlistTest03();

	
	return 0;
}

运行结果:
 

接下来我们再看一下在指定位置之后插入数据SLTInsertAfter吧~

这个实现起来要比在指定位置之前插入要简单

我们看代码:
 

//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{

	assert(pos);
	SLTNode* newnode = SLTBuyNode(x);

	
	newnode->next = pos->next;  //特别注意一下这里的顺序哦~
	pos->next = newnode;


}

写完后也测试一下:
 


void SlistTest03()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);

	SLTNode* FindRet = SLTFind(&plist, 1);

	SLTInsertAfter(FindRet, 100); 
	SLTPrint(plist);//预期是1->100->2->3->4->NULL


}

int main()
{

	SlistTest03();

	
	return 0;
}

测试一下:

那么,插入讲完了,我们接下来再看一下删除操作

分别是删除pos节点以及删除pos之后的节点

先看一下删除pos节点  的情况吧~

//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead);
	assert(*pphead);
	assert(pos);

	//pos刚好是头结点,没有前驱节点,执行头删
	if (*pphead == pos) {
		//头删
		SLTPopFront(pphead);
		return;
	}

	//pos不是头结点
	SLTNode* prev = *pphead;
	while (prev->next != pos)
	{
		prev = prev->next;
	}
	
	prev->next = pos->next;
	free(pos);
	pos = NULL;


}

下面来测试一下:

void SlistTest03()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);

	SLTNode* FindRet = SLTFind(&plist, 4);

	SLTErase(&plist, FindRet);
	SLTPrint(plist);//预期是1->2->3->NULL


}

int main()
{

	SlistTest03();

	
	return 0;
}


 

运行结果:

再看一下删除pos之后的节点吧~

下面是实现的代码~



//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
	assert(pos);
	//pos->next不能为空
	assert(pos->next);

	SLTNode* del = pos->next;  //定义一个中间的变量用来保存
	pos->next = pos->next->next;
	free(del);
	del = NULL;



}

下面进行测试:
 

void SlistTest03()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);

	SLTNode* FindRet = SLTFind(&plist, 2);

	SLTEraseAfter(FindRet);
	SLTPrint(plist);//预期是1->2->4->NULL


}

int main()
{

	SlistTest03();

	
	return 0;
}

好,最后我们来看一下链表的销毁操作吧~

//销毁链表
void SListDesTroy(SLTNode** pphead)
{

	assert(pphead);
	assert(*pphead);

	SLTNode* pcur = *pphead; //pur依旧是作为临时变量,用于保存~
    
   while (pcur)
    {
	SLTNode* next = pcur->next;
	free(pcur);
	pcur = next;
    }

		
	*pphead = NULL;

}

关于链表的销毁,我们可以通过调试来观察,这里就不再演示了,大家可以自己测试一下~

好,到这,我们就把单链表的实现给讲完了~(鼓掌鼓掌)

好,那么这里又出现了一个新的问题,我们在这里花了这么多精力说了单链表的各种操作,那么链表究竟有多少种类呢?它与单链表又是什么关系呢?

接下来,我们就来说说链表的分类

链表的分类

不知道大家有没有想过为什么我创建的这个工程名为SList?

其实它是Single Linked list 的简写,也就是单链表的意思

我们上面的对链表的各种插入,删除都是对单链表进行操作的

那其实 链表的种类有很多,单链表的全称就是不带头单向不循环链表

我们在平时为了方便就称为单链表了~

既然有不带头就有带头的,由单向也就有双向的,有不循环的也就有循环的

如此这般三三组合,其实就可以推出链表的种类有2*2*2=8种

各个种类的关系如图:

看到这么多种类的链表,大家也不要太焦虑,去想单单一种类型的单链表就学了这么久,更何况还有7种.....

其实,我们实际中最常用只有两种结构:单链表带头双向循环链表(简称双向链表),后者我们会在之后的博客中进行介绍与分享的~

最后我们在来看一下单链表双向链表各自的一些特点吧~
1.单链表(不带头单向不循环链表):结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等

这种结构也是在笔试面试中出现很多


2.双向链表(带头双向循环链表):结构最复杂,一般用在单独存储数据

实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,所以实现反而简单了,这个我们代码实现了就知道了,这里只要先大致有一个印象就行,不必担心~

结语

好了,今天关于链表的分享就到这里了

在学习编程的道路上Humble与各位同行,加油吧各位!

最后希望大家点个免费的赞或者关注吧(感谢感谢),也欢迎大家订阅我的专栏

让我们在接下来的时间里一起成长,一起进步吧!

1d8bd2383fe54a7aa576bdd8d41dc462.png

  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不吃肉的Humble

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值