对单链表太混乱,似懂非懂,别犹豫,往下看!
主要讲解如何创建单链表、与单链表的增删查改的相关函数的定义。
以初学者的视角带你攻克难点。
一.单链表的构成
单链表是由一个一个的节点构成的,每个节点内存的是当前节点的数据和指向下一个节点的指针(地址)。

而一个个的节点可由结构体变量写得——
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——

1万+

被折叠的 条评论
为什么被折叠?



