【数据结构初阶】C语言实现单链表,超详细!

对单链表太混乱,似懂非懂,别犹豫,往下看!

主要讲解如何创建单链表、与单链表的增删查改的相关函数的定义

以初学者的视角带你攻克难点。

一.单链表的构成

单链表是由一个一个的节点构成的,每个节点内存的是当前节点的数据指向下一个节点的指针(地址)

而一个个的节点可由结构体变量写得——

typedef int SLTDatatype;
//链表中不止能放int,所以重定义,便于后续更改
typedef struct SListNode
{
	SLTDatatype data;
    //当前节点的数据
	struct SListNode* next;
    //指向下一个节点的指针,类型的节点类型指针
    //也就是struct SListNode*
    //自引用
}SLTNode;
//结构体名字太长,我们也重定义一下,便于后续书写

二.单链表实现与增删查改相关函数

(1)链表的实现

单链表由一个个的节点链接而成,那最清晰明了的思路就是创建一个个的节点,在让它们首尾相连,没错,我们来试试。

问一个问题:我们是创建结构体节点还是创建结构体节点指针?

答案是:创建结构体节点指针

单链表是通过malloc(存储空间可灵活调整)来动态开辟内存的(堆),我们创建结构体节点指针变量是为了来接收malloc的返回值(节点指针),以便于可以通过节点指针来访问内存在堆上的结构体节点。

如果要是直接创建结构体节点,那已经定死了存储空间并且内存在栈上,后续的一切都难以推动。

代码实现:

void SListTest01()
{

  //创建多个节点
  SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));
  //注意:我们开辟的是节点,所以是sizeof(SLTNode)
  //而不是sizeof(SLTDatatype)
  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;
  //创建好后,还要首尾相连
  SLTNode* plist = node1;
  node1->next = node2;
  node2->next = node3;
  node3->next = node4;
  node4->next = NULL;

int main()
{

   SListTest01();
   return 0;
}

便于观察,我先来讲打印链表函数。

(2)SLTPint  单链表打印函数

代码实现:

//定义
void SLTprint(SLTNode* phead)
{
    SLTNode* pcur = phead;
    //一般不改变原先的头指针,而是额外定义一个临时变量
	while (pcur != NULL)
	{
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("NULL\n");
}

//调用
SLTprint(plist);

代码分析:

SLTPint函数接收头指针,也就是指向第一个节点的指针,那我们需要做的就是遍历单链表,每到一个节点,就进行打印。

最重要的是如何实现单链表的遍历:

其实循环条件挺好理解的,如果当下的节点不为空,那就说明是需要进入循环被打印的,而打印用printf,打印后,指针要往下走,那就要将下一个节点的指针赋给用于遍历的指针pcur,跳出循环就意味着遍历结束,而最后的NULL是没有被打印的,所以我们再加一条打印空指针。

加上上面的单链表,我们来看看打印的结果会是怎样的?

那说明实现单链表是没问题的,但是我们每次创建节点、创建单链表都要写一大堆相同的代码再写额外的代码,将它们首尾相连是不是太麻烦了?

的确麻烦,所以我们来精进实现单链表的方式。

先讲三个函数:单个节点的创建、头插和尾插。

(3)SLTBuynode 单个节点的创建

代码实现:

//定义
SLTNode* SLTBuynode(SLTDatatype x)
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	//创立的大小是节点,而不是节点里的数据
    //所以是malloc(sizeof(SLTNode)
    //而不是malloc(sizeof(SLTDatatype)
	if (NULL == newnode)
	{
		return 1;
	}
	newnode->data = x;
	newnode->next = NULL;
    //不要忘记返回值类型是指针,不能返回其他类型,也不能不返回
	return newnode;
}

//调用
 SLTNode* newnode = SLTBuynode(1);

返回值的类型是创建的新节点的指针,参数是创建的新节点的数据的值。

详细分析:

首先,创建新节点就是向堆申请一块动态内存(malloc),只要是动态内存分配空间都需要检查是否分配成功,开辟成功后,通过新节点指针newnode访问data成员变量进行赋值,因为是新节点,可以看做是单节点链表,那它存的下一个节点指针就是NULL,最后记得返回newnode指针。

(4)SLTPushfront 头插

头插的意思是将新节点插入到单链表的起始位置。

这次我们先分析,再代码实现:

首先要明白,既然是插入新节点,那么就允许原先的链表为空链表

那新节点如何出现呢?当然不可能是凭空出现,需要被创建,而这里就能用到我们的SLTBuynode单节点创建函数(调用)。

我们先讨论非空链表:

就是将一些指针的指向改动一下,具体看图。

图中的改动步骤适不适合空链表呢?代入试试,是行的。

接下来代码实现:

//定义
void SLTPushfront(SLTNode* phead,SLTDatatype x)
{
   SLTNode* newnode = SLTBuynode(x);
   //节点创建函数的调用
   newcode->next = phead;
   phead = newnode;
}

//调用
SLTPushfront(plist,1);

代码有问题吗?

明确说,上述实现头插的代码是错误的。

关键点在于头插函数传参不能用传值调用,而应该用传址调用

下面来具体分析:

先以最简单的int a为例子,分析一下一级指针、二级指针视角下传值调用与传址调用。

而上述代码出错就是我们想对原先的头指针plist/phead进行改动(phead=newnode),对一级指针进行改动,仅仅只传一个一级指针,是不能对它同级的一级指针产生影响的,如果想改动一级指针本身的数据内容,就必须传二级指针。

那么在之后遇到传参时,都可以问问自己,是否只是需要值?如果还需要对实参进行变动的话,就必须传比它高一级的数据类型(整型要传整型指针,一级指针要传二级指针)。

代码修改:

//定义
void SLTPushfront(SLTNode** pphead,SLTDatatype x)
{
   SLTNode* newnode = SLTBuynode(x);
   //节点创建函数的调用
   newcode->next = *pphead;
   *pphead = newnode;
}

//调用
SLTPushfront(&plist,1);

修改后已经大差不差了,但能做的更好。

因为我们对二级指针pphead进行了解引用(为了获得节点的指针),那如果有人不小心传了空指针时,我们要能做出判断,此处加一个断言。

//定义
void SLTPushfront(SLTNode** pphead,SLTDatatype x)
{
   assert(pphead);
   //使用断言需要包含头文件#include<assert.h>
   SLTNode* newnode = SLTBuynode(x);
   //节点创建函数的调用
   newnode->next = *pphead;
   *pphead = newnode;
}

//调用
SLTPushfront(&plist,1);
//plist是需要提前被定义的,代表头指针(实参)

(5)SLTPushback 尾插

尾插是在最后再插入一个新节点newnode,同样需要调用SLTBuynode函数创建新节点,也是允许链表为空的。

在头插时,我说其实就是改动一些指针的指向,在尾插中,也是一样的,将原先的最后一个节点存的指针指向newnode,再让newnode->next指向NULL。

关键是最后一个节点怎么找?遍历就行。

看下代码:(先分析非空链表)

//定义
void SLTPushback(SLTNode** pphead, SLTDatatype x)
{
  assert(pphead);
  //非空链表
  //创建新节点
  SLTNode* newnode = SLTBuynode(x);
  SLTNode* ptail = *pphead;
  //定义一个临时指针,用来找尾结点 
  while(ptail->next != NULL)
    {
      ptail = ptail->next;
    }
    //出循环时,代表已经找到ptail尾结点了
    //指针的指向改变
    newnode->next = NULL;
    //这步可以不写,在节点创建时就已经有newnode->next = NULL;
    ptail->next = newnode;
}

//调用
SLTPushback(&plist , 1);

既然我们说非空链表是可以进行插入操作的,那这段代码能否实现空链表的尾插呢?

不行

如果是空链表,那么*pphead=NULL,pcur和ptail都为空,不会进入循环,在指针指向改变时,ptail->next是没有意义的,所以需要另外考虑空链表的情况。

代码完善:

//定义
void SLTPushback(SLTNode** pphead, SLTDatatype x)
{
  assert(pphead);
  //非空链表
  //创建新节点
  SLTNode* newnode = SLTBuynode(x);
  if(NULL!= *pphead)
{
  SLTNode* ptail = *pphead;
  //定义一个临时指针,用来找尾结点 
   while(ptail->next != NULL)
    {
      ptail = ptail->next;
    }
    //出循环时,代表已经找到ptail尾结点了
    //指针的指向改变
    ptail->next = newnode;
 }
 else
 //空链表
 {
   *pphead = newnode;
 }
}

//调用
SLTPushback(&plist , 1);

讲完了前情提要,我们再回到单链表的实现中,不再用最原始的方法创建一个个的节点后再手动链接,而是借助SLTBuynode/SLTPushfront/SLTPushback来完成单链表的实现

(6)借助SLTBuynode/SLTPushfront/SLTPushback来完成单链表的实现

void SListTest02()
{
   SLTNode* plist = NULL;
   SLTPushback(&plist,3);
   SLTPushback(&plist,2);
   SLTPushback(&plist,1);
   SLTPint(plist);

   SLTPushfront(&plist,4);
   SLTPushfront(&plist,5);
   SLTPrint(plist);
}

int main()
{

  SListTest02();
  return 0;
}

运行结果如上图,说明单链表实现成功了。

但你是否有疑惑,为什么只是调用几次头插。尾插就能实现单链表了呢?就算节点确实能被创建,可链接的动作去哪了?

红框内的就是链接动作,藏在函数里,我们一时看不到所以就忘记了。

(7)SLTPopfront 头删

删除节点,那肯定不能在空链表中删除,所以讨论的只有非空链表。

其实也只是指针的指向发生变化而已,再加个动态内存的回收释放。

代码实现:

//定义
void SLTPopfront (SLTNode** pphead)
{
  assert(pphead && *pphead);
 //pphead断言是因为需要解引用,以防为空
 //*pphead断言是为了防止空链表
 *pphead = (*pphead)->next;
  free(*pphead);
  *pphead = NULL;
}

//调用
SLTPopfront(&plist);

此处同样是传递二级指针,头删,需要对一级指针本身存储的数据进行改动,仅仅只传递一级指针是没用的。

上述代码的视线有误吗?

有误。

试问free(*pphead);时*pphead的值还是原先的值吗?并不是,*pphead的值已经被改动了,变成了原先的(*pphead)->next,所以这里的空间释放回收就变成了对第二个节点的操作,而不是首节点。

其实想解决这个问题也简单,既然是数据会被改动却又需要原先的数据,那就创建一个临时变量将原先数据的值保留下来

代码修改:

//定义
void SLTPopfront (SLTNode** pphead)
{
  assert(pphead && *pphead);
 //pphead断言是因为需要解引用,以防为空
 //*pphead断言是为了防止空链表
  SLTNode* tmp_del = *pphead;
 //创建临时变量将头指针的原始数据保留下来
  *pphead = (*pphead)->next;
  free(tmp_del);
  tmp_del = NULL;
}

//调用
SLTPopfront(&plist);

那还有没有遗漏的点呢?为了使程序能包含所有情况,应当多试试边界线,虽然考虑到了空链表,但非空链表的单节点情况是否也能满足呢?

将单节点情况代入,也是能满足的,说明代码确实很完备。

运行结果如下:

(8)SLTPopAfter 尾删

同样,删除节点,避开空链表。

依旧是很多指针的指向发生变化,让尾结点前一个节点的next指针指向尾结点的next指针,再将尾结点释放回收,那尾结点前一个节点、尾结点如何找?如果遇到数据丢失的问题如何避免?

遍历加创建临时变量就行。

代码实现:


//定义尾删

void SLTPopAfter(SLTNode** pphead)
{
	//避开空链表和解引用二级指针
	assert(*pphead && pphead);
	if ((*pphead)->next == NULL)
	 {
		//单节点
		free(*pphead);
		*pphead = NULL;
	 }
	else
	 { 
		//多节点
		SLTNode* prev = *pphead;
		//尾指针的前一个指针
		SLTNode* ptail = *pphead;
		//尾指针
		 //找两个指针
		while (ptail->next)
		{
			prev = ptail;
			ptail = ptail->next;
		}
		//找到了
		//数据会丢失吗?试过,不会
		//prev->next = ptail->next;
		//这一步也可以写成直接置空
		prev->next = NULL;
		free(ptail);
		ptail = NULL;
	 }
	}

//调用尾删
SLTPopAfter(&plist);

在头删中我们考虑了单节点和多节点情况,在尾删中我们同样考虑,如果按一开始的分析思路,我们的代码实现是else中的内容,将单节点的情况代入else,那么不会进入while,确实能释放*pphead(ptail),但是无法对*pphead实现置空,所以需要额外再写一种单节点的情况(if)。

(9)SLTFind 查找

就像查找数组中的某个元素采用遍历方法一样,SLTFind查找链表中的某个元素同样用遍历(定义一个临时指针变量pcur)。

//定义查找
SLTNode* SLTFind(SLTNode* phead, SLTDatatype x)
{
	//能够返回要找数据的节点指针
	//参数要明确要找什么数据的节点
	assert(phead);
	//不允许对空指针结构体访问
	//但是由于如果为空首节点,那就不会进入循环,也不会到结构体访问的那一步
	//就会直接返回NULL,没找到,所以断不断言,无所谓
	SLTNode* pcur = phead;
	//临时变量-用来遍历
	while (pcur)
	{
		//判断是否为需要找的数据节点
		if (pcur->data == x)
			return pcur;
		else
			pcur = pcur->next;
	}
	//出循环意味着没找到
	return NULL;
	//在有非空返回值的前提下,要保证每种情况都有合法返回值
}

//调用
SLTNode* ret = SLTFind(plist, 4);
if (ret)
	printf("找到了!\n");
else
	printf("没找到!\n");

SLTNode* find = SLTFind(plist, 5);
if (find)
	printf("找到了!\n");
else
	printf("没找到!\n");

为什么在SLTFind函数中,参数只需要节点的一级指针,不需要二级了呢?

因为我们只需要对比节点的数据是否相同,不需要变动节点的数据,一级指针完全够了。

错误分析:

//定义查找
SLTNode* SLTFind(SLTNode* phead, SLTDatatype x)
{
	assert(phead);
	SLTNode* pcur = phead;
	while (pcur->next)
	{
		
		if (pcur->data == x)
			return pcur;
		else
			pcur = pcur->next;
	}
	return NULL;
}

//调用
SLTNode* ret = SLTFind(plist, 2);
if (ret)
	printf("找到了!\n");
else
	printf("没找到!\n");

起初,我写的是这段代码,但显然打印结果有误,代码出了问题,问题在哪?

while(pcur->next)
//循环条件有误

代码的执行逻辑是如果pcur的下一个节点不为空则进入循环,如果下一个节点为空,则跳出循环

假如此时pcur正是尾结点,尾结点的next指针为NULL,就会跳出循环,不会再判断是否为需要
找寻的数据x,也就是说在判断数据时,我们漏掉了对尾指针的检验

调用时我传的x=2,2刚好是尾指针的数据,本来应该是找到了,可提前跳出了循环,返回NULL,所以显示没找到。

不妨来总结一下:

当仅仅只是找到尾节点指针ptail的情况,循环遍历的条件判断是ptail->next,而在需要对尾节点内的数据进行查看、改动的情况,循环遍历的条件是ptail(pcur)。

(10)SLTIsert 在指定位置之前插入数据

根据图片,既然是插入节点,那首先要先创建一个新节点SLTBuynode,再让一些节点指针的指向发生改变就行。

既然是指定节点前插入新节点,那指定节点不能为空节点,传一个NULL,去哪里找?指定节点来自于链表,要是链表为空,那指定节点必为空,所以头结点指针也不能为空。(三个断言)

多节点是一般情况,排除了空链表的情况,只差单节点的情况,代入if语句内的内容,并不适合单节点的指定节点的插入,所以还需要再写else。

代码实现:

//在指定位置pos之前插入数据x
void SLTInsert(SLTNode** pphead, SLTNode* pos,SLTDatatype x)
{
	//传头指针是因为要找到pos之前的节点
	//pos就是指定节点指针
	assert(pphead&&*pphead);
	assert(pos);
	//指定位置为空,去哪里找?
	//创建新节点
	SLTNode* newnode = SLTBuynode(x);
	//多节点
	if ((*pphead)->next != NULL)
	{
		SLTNode* prev = *pphead;
		//找pos前的节点
		while ((prev->next) != pos)
		{
			prev = prev->next;
		}
		//找到了prev
		newnode->next = pos;
		prev->next = newnode;
	}
	else
	{
		//单节点-不用再找prev
		newnode->next = pos;
		*pphead = newnode;
		//在单节点前插入,其实就是头插
		//SLTPushfront(pphead, x);

	}

}

//调用
SLTInsert(&plist , ret , 99);
//ret是找2时的节点指针

(11)SLTInsertAfter 在指定位置之后插入数据

//在指定位置pos之后插入x
void SLTInsertAfter(SLTNode* pos, SLTDatatype x)
{
	//不需要传头结点指针
	//不需要改变头指针,也不需要通过头指针来找某个节点指针
	assert(pos);
	SLTNode* newnode = SLTBuynode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

//调用
SLTInsertAfter(ret , 100);

为什么不需要传递头指针?

在指定位置之前的插入函数中,我们传递头指针是因为想让链表首尾能顺利连接,要找到pos前的节点指针,只能通过定义一个临时节点prev(起始prev=*pphead)来遍历寻找。

如果用指定位置之前的插入函数思想来想指定位置之后的插入函数,其实我们要寻找的prev就是pos,所以不用再传头节点指针。

(12)SLTErase 删除指定位置的节点

能够知道哪些量需要断言、分析出需要定义prev、理清一些指针的指向以及知道分几种情况讨论并写好分支语句,SLTErase函数就不难了。

//删除某个节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead && *pphead);
	assert(pos);
	//删除不可能为空链表
	// 指定节点pos不能为空
	//多节点
	if ((*pphead)->next != NULL)
	{
		SLTNode* prev = *pphead;
		//需要找前一个节点指针
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		//找到了prev
		prev->next = pos->next;
		free(pos);
		pos = NULL;
	}
	else
	//单节点
	{
		free(*pphead);
		*pphead = NULL;
		//单节点的删除相当于头删
		//为什么不是尾删?因为尾删还要找尾结点
		//SLTPopfront(pphead);
	}
}

//调用
SLTErase(&plist,ret);

在前面几次函数实现中,会出现某一分支中调用先前已经定义好了的函数(头插、尾删),这也减轻了我们的一些代码书写量。

(13)SLTEraseAfter 删除指定位置之后的节点

这样一写就能发现问题,当free(pos->next)时,pos->next的值已经不再是原先的值了,而是改动后的pos->next->next,所以我们需要先将pos->next的初始数据保存下来(del)。

//定义指定位置之后的节点
void SLTEraseAfter(SLTNode* pos)
{
	assert(pos && pos->next);
    //不能说想删除的节点为空吧
	//定义临时变量
	SLTNode* del = pos->next;
	pos->next = del->next;
	free(del);
	del = NULL;
}
//调用
SLTNode* find = SLTFind(plist, 99);
SLTEraseAfter(find);
SLTPrint(plist);

(14)SListDestory 销毁链表

销毁链表,其实就是让malloc动态申请的内存被回收释放,也就是free每个节点。

但我们不可能一上来就是free(*pphead),把头结点指针给释放了,后面的节点就不能再靠头结点指针去寻找了,写到这,我们也能看出,还是要保留原来的数据——创建临时变量。

//定义销毁函数
void SListDestory(SLTNode** pphead)
{
	assert(pphead);
	SLTNode* pcur = *pphead;
    //既能进行遍历又不改变*pphead的临时变量
	while (pcur)
	{
		SLTNode* next = pcur->next;
	    //临时变量
		free(pcur);
		pcur = next;
	}
	//pcur = NULL;
	//出循环的截止条件就是pcur=NULL,所以无需再额外置空
	*pphead = NULL;
}

//调用
SListDestory(&plist);

逻辑是:永远在释放某一节点(pcur)时,提前得到下一节点的指针(next),以免找不到了

总结提升:

①讨论的情况

(1)空链表与非空链表

  • 头插:一种写法同时适合空链表与非空链表;
  • 尾插:两种写法才能适合空链表与非空链表(找尾);
  • 指定位置之前插入节点数据:两种写法才能适合空链表与非空链表(找prev);
  • 指定位置之后插入节点数据:一种写法同时适合空链表与非空链表;

(2)多节点与单节点

  • 头删:一种写法同时适合多节点与单节点(数据会丢失,创建了del临时变量);
  • 尾删:两种写法才能适合多节点与单节点(找尾);
  • 删除指定位置节点:两种写法才能适合多节点与单节点(找prev);
  • 删除指定位置之后的节点:一种写法(数据会丢失,创建了del临时变量)

能发现,需要找ptail、prev的函数实现都是if else两种情况才能满足

②del的创建
  • 头删:一种写法同时适合多节点与单节点(数据会丢失,创建了del临时变量);
  • 删除指定位置之后的节点:一种写法(数据会丢失,创建了del临时变量)。

——end——

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值