-
看本文章至少了解C语言基础:一直要学到指针,结构体才能算完
这部分请自行学习,推荐视频:郝斌C语言 网上也有同学推荐翁凯C,但我没有看过。 免责声明(免骂声明): 该文章仅用于启发基础不好的同学,不能替代教材, 也无法与好文章和好老师相提并论。对于学霸和基础很牢的同学,这篇文章可能没有价值,甚至可能误人子弟。 文章中存在很多疏漏和不足之处,能力有限,欢迎学霸提供修改意见,定从善如流。 本文仅供参考。 文章中展示的代码只是我学习时写下的笔记,并非精炼的算法, 可能存在冗余和不够聪明的操作。这些代码仅供参考,不能经受深入的推敲。 全文仅作为抛砖引玉之用,希望读者在阅读后能更好地理解课本和其他教学视频。 本章主要讲链表的知识。包括链表的创建,以及增删改查。
先来展示代码
我是用C语言写的,C与C++一脉相承,一法通百法通,大家可以自行尝试用C++写。
推荐大家直接复制到软件中看,文章中虽然用代码块框起来了,
但仍旧是难以入目,而且这也不能运行,不如复制到软件中。
我使用的是VS2022,但VScode,clion,甚至VC++什么的都可以,
只要方便大家看代码就行,君子不器,没必要纠结工具。
c
#include <stdio.h>
#include<malloc.h>
typedef struct List
{
int data;//数据域
struct List* pNext; //指针域
}LT, * PLT;
//第8行基于typedef。
//ST表示 struct List
//PST 表示 struct List *。
PLT Create(PLT pHead);
void Travel(PLT pHead);
bool Insert(PLT pHead, int val);
bool Delete(PLT pHead, int pos,int* pVal);
bool Change(PLT pHead, int pos, int val);
bool Search(PLT pHead, int pos, int*val);
//pos position:位置 val:值
int main(void)
{
PLT pHead = NULL;
pHead = Create(pHead);
int val = 0;
for (int i = 1;i <= 5; i++)//增加五个节点
{
Insert(pHead, i);
val++;
}
Delete(pHead, 1, &val);
//此处删除1号节点测试
printf("%d\n", val);
Travel(pHead);
Change(pHead, 2, 999);
Search(pHead, 4, &val);
printf("%d\n", val);
Travel(pHead);
return 0;
}
void Travel(PLT pHead)
{
PLT p = pHead->pNext;//头节点的指针域,指向第一个节点的指针(地址)
while (NULL != p)//最后一个节点指针域为 NULL
{
printf("%d ", p->data);//节点数据域中的数据
p = p->pNext;//下一个节点的指针域。
}
printf("\n");
return;
}
/*
头节点:
首节点:第一个有效信息节点
尾节点:最后一个有效节点
头指针:指向头节点的指针变量
尾指针:指向尾节点的指针变量
如果希望通过一个函数来对链表进行处理,我们至少需要接收链表的哪些参数。
答案:只要头指针就行了
只要头指针就可以推算出链表中其他的所有参数
节点:数据|指向下一个节点的指针。
*/
/*
分类:
单链表
双链表:每个节点有两个指针域。
循环链表:能通过任意一个节点找到其他所有的节点。
非循环链表
*/
/*
增删改查
*/
PLT Create(PLT pHead)
{
pHead = (PLT)malloc(sizeof(LT));
if(pHead!=NULL)
{
pHead->pNext = NULL;
return pHead;
}
else
return NULL;
}
bool Insert(PLT pHead, int val)
{
PLT pNew = (PLT)malloc(sizeof(LT));
if (pNew == NULL)
{
// 处理内存分配失败的情况
return false;
}
PLT pTail = pHead;
while (pTail->pNext != NULL)
{
pTail = pTail->pNext;
}
pNew->data = val;
pTail->pNext = pNew;
pNew->pNext = NULL;
//pTail = pNew;
return true;
}
bool Delete(PLT pHead, int pos ,int* pVal)
//在第pos个节点删除一个节点!pos从1开始
{
int i = 1;
int flag = 0;
PLT q= pHead;
PLT p =pHead->pNext ;
while (p != NULL)
{
if (pos == i)
{
q->pNext = p->pNext;
*pVal = p->data;
flag = 1;
free(p);
break;
}
p = p->pNext;
q = q->pNext;
i++;
}
if (flag == 1)
return true;
else
return false;
}
bool Change(PLT pHead, int pos, int Val)
{
int flag = 0;//判定是否实现了操作
int i = 1;//用这个判断是否找到了节点
PLT p = pHead;
while (p->pNext != NULL)
{
if (i == pos)
{
p->pNext->data = Val;
flag = 1;
break;
}
i++;
p = p->pNext;
}
if (flag == 1)
return true;
else
return false;
}
bool Search(PLT pHead, int pos, int*pVal)
{
int flag = 0;//判定是否实现了操作
int i = 1;
PLT p;
p = pHead->pNext;
while (p != NULL)
{
if (i == pos)
{
*pVal=p->data;
flag = 1;
break;
}
i++;
p = p->pNext;
}
if (flag == 1)
return true;
else
return false;
}
下面开始讲解 分为链表略讲,结构体,主函数,以及增删改查函数几个分块。
链表略讲(单链表为主)
什么是链表?
markdown
链表一种动态数据存储方式,很方便后序增删。
重点术语:头节点(头指针),首元节点,前驱,后继,尾节点。
想象一下链表就像是**火车**,每个车厢(**节点**)里都装着一段货物(**数据**),
而每个车厢里都有一扇门(**指针**),这扇门通向下一个车厢(**节点**)。
火车头就像是链表的**头节点**,从火车头开始,
我们可以一个接一个地打开每节车厢的门,逐个查看或修改里面的货物。
第一节真正装有有效货物的车厢就是**首元节点(第一个储存了有效数据的节点)。
而通过门连接着两节车厢,前者是**后者的前驱**,后者是**前者的后继。**
这样的结构允许我们在不需要调整一整列车厢的情况下, 方便地在两节车厢之间插入或删除车厢。这灵活性就是链表相较于数组的优势。
markdown
而如果我们要在尾端插入一个节点(车厢)我们有两种方法<br /> **一个是**从车头开始看起,一节节车厢开门,直到有节车厢开了门后发现后面没有车厢了,那让新的车厢直接连接到这节就行了。但这样做每次都要一个个看过去。<br /> **所以有了第二个方法**,尾指针。我们只要有第二个标志物:**车尾**。在车厢一节节增加的过程中,不断标志最尾端的车厢就行了,当所有车厢连起来后摇,它自然指向了尾节点。当要加入新车厢时,只要把它连接到车尾(**尾指针**)就行了。
(单链表通俗意义上讲就是每个节点只含一个指针。自然我们有双链表等等链表,但一法通百法通,dddd。)
链表好处
xml
诸如数组之类的存储结构都需要预先给定存储个数,所开辟的空间是连续的。而且后续要增加或者删除数据很麻烦。<br />
如果我有一堆占用空间极大的并且不确定个数的同类型数据要存储,且后续增删操作很频繁。那么数组就不是那么好用了,因为我的计算机不一定正好有那么多连续的空间给这堆数据。<br />
链表允许这些数据存储在不连续的空间中,通过指针为引,构成一个完整的存储体系。<br />
就如小朋友瞎站,只要他们好好地一个拉一个,那就是可以把所有的小朋友一个不差地点过去。<br />
而链表要插入那就只要前一个节点的指针重新指向要插入的指针,
要插入的节点的指针指向原先的后一个指针就行了。完全不需要挪移地址。
正如插队,让小朋友把手重新拉就行了。不要小朋友变换位置。
删除也是同理,使前一个节点指向删除节点的指针,让它指向原先由删除节点指针指向的节点,再把删除节点的指针指定为空就行了。
缺点(以单链表为例)
css
数组中只要你知道它是第x个你就可以直接接用a[x-1]找到它了。而单链表中你必须从头开始遍历才行。
结构体的定义
c
typedef struct List
{
int data;//数据域
struct List* pNext; //指针域
}LT, * PLT;
如果有朋友不知道结构体是什么的,可以先去看郝斌C语言的相关课程,这里只略讲。
- 夏姬八讲:结构体就是把几种不同的数据类型整合到一起,方便后续定义。比如学生结构体可以包含姓名(string或者char[]),年龄(int),班级(int)等等。
- 定义方法: struct xxx{ };大括号里面写你要的数据类型 以及名字。还有几种定义方法但此处不讲。
markdown
而用于链表的结构体就要如代码块中所示,需要一个**数据域(代码块中int data)**<br /> 和一个**指针域(代码块中structStudent*pNext)**。
markdown数据域用于存储自己要用的数据,指针域则用于连接一个个节点。
** 指针的本质是存储地址的变量**,正因为每个节点存储了下一个节点的地址我们才能顺藤摸瓜,找到所有节点。
arduino
初学的时候我就很好奇,怎么就能在结构体里用它自己定义一个指针呢,这尼玛不是套娃吗。但是C语言允许这种规则。而且指针本质是存储地址的。结构体自己里面存储一个同类型的节点的地址很合适吧。(有文章讲解这个问题的,但我这里就不多赘述)<br /> 另外注意正经算法中,数据域也应该是结构体类型的,一搬会定义一个**struct Data{};**里面存放想要的数据域内容。然后在为链表服务的结构体中这样定义。
c
struct Student
{
struct Data data ;
struct Student * pNext;
};
PS:本人喜欢在指针前加个p,以区分变量,因为指针是ptr......
为便于理解我就直接定义为int。
而我代码中typedef 作用就是让我们可以偷懒,用typedef修饰后,比如 如下代码。
c
typedef struct Student
{
......
}ST,*PST;
xml
我们就可以用<br />
**ST**表示 struct Student。<br />**PST** 表示struct Student *。<br />
也可以只定义一个PST,那就去掉逗号和ST就行。<br />
如果是要指针,那就要加* 。<br />
typedef具体用法与其可以这么做的原因,这里就不讲了。工具会用就行。<br />
另外c++中不用**typedef**就可以直接使用结构体名定义对象。
cpp
struct Student
{
......
};
int main()
{
Student st1;
Student* st2;//允许如此
}
arduino
但你如果想要简写比如 ST,PST,
那你还是要用**typedef**。<br />
至此为链表服务的结构体就讲完了。
观察主函数
c
PLT Create(PLT pHead);
void Travel(PLT pHead);
bool Insert(PLT pHead, int val);
bool Delete(PLT pHead, int pos,int* pVal);
bool Change(PLT pHead, int pos, int val);
bool Search(PLT pHead, int pos, int*val);
//pos position:位置 val:值
int main(void)
{
PLT pHead = NULL;
pHead = Create(pHead);//创建
int val = 0;
for (int i = 1;i <= 5; i++)//增加五个节点
{
Insert(pHead, i);//增加
val++;
}
Delete(pHead, 1, &val);//删除
//此处删除1号节点测试
printf("%d\n", val);
Travel(pHead);//遍历
Change(pHead, 2, 999);//修改
Search(pHead, 4, &val);//查找
printf("%d\n", val);
Travel(pHead);
return 0;
}
乍一看,主函数简直一坨,无法入目。所以我们化繁为简。
c
pHead = Create(pHead);//创建
Insert(pHead, i);//增加
Delete(pHead, 1, &val);//删除
Change(pHead, 2, 999);//修改
Search(pHead, 4, &val);//查找
Travel(pHead);//遍历
txt
删掉细枝末节后,我们可以看到留下来的是几个现在看起来莫名其妙的函数。
但是细看下来我们又可以发现,这些都是我们需要的内容,其中的**核心无疑是创建与增删以及遍历**。
PS: 如果你现在看不懂形参,那你就别看,思而不学则惘,有时候学不会就是想的太多了。
**我们写程序的时候,可以先搞清楚自己要哪些功能(函数),现在主函数里确定这些函数的名称,无须去管形参与返回什么的,这些东西可以在之后具体的函数构建中慢慢确定。
先把骨架搭配后才能更好的填补内容和外皮。
写好主函数后,我们差不多可以确定我们要如下这些函数:
**create,insert,delete,change,search,travel。
**以对应**创建,添加,删除,修改,查找,遍历。**
函数分讲
Create函数 创建
xml
**链表到底要怎么创建呢?**<br />
初学之时我的大脑是糊的,我总是把数组的思维代进链表,<br />
我总是想:我怎么知道到底要几个指针,到底有几个节点,这些我都不知道,我怎么创建链表啊?<br />
如果你和我一样纠结这些goushi,那你钻牛角尖了,链表的优点就是不用给出具体个数,随时添加与删除。<br />
为什么呢?<br />
因为地址,<br />
只要我头节点里有首元节点的地址,首元节点里存储下一个节点地址,一个个这样下去。我就可以通过头结点将整条链表遍历。
就像火车,只要有火车头,并且火车头有门,那我们就可以依靠一扇扇门走过所有车箱。
根本不用管火车有几个车厢,几个门,你要多少后面接多少车厢就行了。
markdown
**看过托马斯小火车的人都知道,决定一辆火车的是它的头。** 所以我们只需要创建火车头(头节点)那链表就创建成功了!<br />
**来看代码:**
c
PLT Create(PLT pHead)
{
pHead = (PLT)malloc(sizeof(LT));
if(pHead!=NULL)
{
pHead->pNext = NULL;
return pHead;
}
else
return NULL;
}
sql
观察这个Create函数的形参和返回类型,分别是PLT pHead,和PLT 这说明我们要传入一个链表的指针,传回一个链表的指针。而pHead 就是我们了解过的头指针。
所以就是 这个函数就是传入一个叫头指针的指针,传回一个处理好的真正的头指针。
ini
只要把这个头指针建立好,那么链表就创建完毕。
在主函数中,我们定义好了**pHead**: PLT pHead = NULL;
这时pHead时一个空指针。
此时它只是叫头指针,但它实则上平平无奇,啥也不是。就如大学生,叫是叫大学生,但只有被大学函数教好了,他才真是一个大学生,否则
把它定义为NULL是为了初始化,并避免它成为野指针。
接着主函数中 会用**pHead**来接收**Create**函数处理好的头指针: pHead =Create(pHead);
接收了返回值的头指针才是真的头指针。
接下来我们就要看Create函数到底做了什么:
c
pHead = (PLT)malloc(sizeof(LT));
if(pHead!=NULL)
{
pHead->pNext = NULL;
return pHead;
}
else
{
return NULL;
}
首先,Create 函数会用malloc 为pHead 开辟空间。动态开辟后头指针就会一直存在,直到整个程序终结。
正式因为 为它开辟了空间,才使得它能灵活的增删。
我相信大部分人会有疑惑,为什么要动态开辟啊,我不开辟可不可以啊。
静态链表也不是不可以定义,大家都可以定义了试试,但是,这样子它所有的优势荡然无存。
动态开辟保证了,我们只要想增加节点就增加节点,十分灵活。而且我们再创建链表的时候,通常时无法确定我们到底要有几个节点的,所以要动态 开辟。
再问就不礼貌了,有时候问太多为什么要,那你就永远学不会,因为你无法每次都问到答案,一旦问不到,你就给自己设置门槛了!但是我们好像无法抑制这种求知欲。 所以呢,我们可以反其道而行之,多问问为什么不要,你自然回答不上来,那你就会坦然接受了。
PS:大家可以试试Create里面不开辟,那马上编译器就会报错!
接着,我们要判断pHead还是不是空的,因为正常开辟空间后它不会是空的,这一步是检查有没有正常开辟空间,有些人认为这一步可有可无,但这能让程序更安全与更可读。而且有些编译器会因为你没写这一步而报错。 写这一步也是一个良好的习惯! 如果开辟成功,那就会让pHead 的指针域指针指向空:pHead->pNext = NULL;,避免其成为野指针。 最后把实至名归的头指针传回去。
else
return NULL;
但是如果没有正常开辟,那就会返回NULL,是pHead还是原来那个空指针。后续的操作必会报错。
Insert函数 增加
之所以第二个讲这个,是因为里面也涉及到了动态开辟。
代码如下:
c
bool Insert(PLT pHead, int val)
{
PLT pNew = (PLT)malloc(sizeof(LT));
if (pNew == NULL)
{
// 处理内存分配失败的情况
return false;
}
PLT pTail = pHead;
while (pTail->pNext != NULL)
{
pTail = pTail->pNext;
}
pNew->data = val;
pTail->pNext = pNew;
pNew->pNext = NULL;
pTail = pNew;
return true;
}
首先,它的返回值是一个bool 值,只有true 和false 两种状态,因为增加节点,我们只需要知道它成功了没有,不用返回什么其他东西。当然你把增加后新的pHead 返回也可以,具体情况具体定义。
形参则是:pHead 头节点与Val值,也就是说传入一个链表与要插入的节点的值。这好像没什么好讲的,正如连接车厢,你自然要告诉工人是哪辆火车,以及这节车厢里要装什么。
当然如果你再主函数里,就把节点定义好了。那你直接把节点传进去也行。
开门见山就是一个动态开辟,pNew被开辟了空间,pNew在这里也是工具指针,代指每次的新节点。这里动态开辟就更必要了,你不动态开辟空间,函数运行完后,就直接把pNew给自动释放了,程序直接出错。动态开辟也涉及到变量的生命周期问题,一般变量会在其作用域结束时被释放。动态开辟会给他们续命。
** if (pNew == NULL) { // 处理内存分配失败的情况**
** return false; }**
这几行又是用来检查开辟是否成功的,不多言了。
c
PLT pTail = pHead;
while (pTail->pNext != NULL)
{
pTail = pTail->pNext;
}
pNew->data = val;
pTail->pNext = pNew;
pNew->pNext = NULL;
//pTail = pNew;
这几行就是核心操作了,我们会先定义一个尾节点使它先指向头结点。然后经过while 循环,找到那个pNext 指向空的节点,让pTail 指向它。这时尾节点才是真正的尾节点。
PS:这里可能有同学看不懂p=p->pNext ,p->pNext就是p的后一个节点。将它赋给p,就可以让工具节点在链表中一个个后移,就像人通过火车门把车厢一个个走过去一样。这是增删改查和遍历中最核心的操作。
每次进到这个函数种时它都会进入while 循环遍历链表,然后才指向尾端。有想法的同志可以试试第二种方法,那种在C++种用类 更好实现。
找到尾指针了后,有pNew->data = val;这是把货物装上车厢。
然后就是连接操作了。
首先是让尾指针的指针链接到新节点:pTail->pNext = pNew;
然后再把新节点的指针域指针设置为空:pNew->pNext = NULL; 防止它变为野指针。
** **本来应该有pTail指向pNew的操作,意图是让pTail永远指向尾端,但我们实际上用的是第一种插入法,所以这步冗余了。
Travel函数 遍历
p->pNext就是p的后一个节点。将它赋给p,就可以让工具节点在链表中一个个后移,就像人通过火车门把车厢一个个走过去一样。这是增删改查和遍历中最核心的操作。
xml
我直接引用我自己的话,
这也是遍历的核心。
就是让人通过火车门将车厢全部走一遍,清点其中的货物。<br />
c
void Travel(PLT pHead)
{
PLT p = pHead->pNext;//头节点的指针域,指向第一个节点的指针(地址)
while (NULL != p)//最后一个节点指针域为 NULL
{
printf("%d ", p->data);//节点数据域中的数据
p = p->pNext;//下一个节点的指针域。
}
printf("\n");
return;
}
因为内置了输出,所以这个函数不需要返回值。
首先函数中定义了一个工具指针,直接让它等于pHead->pNext;因为遍历是要遍历真正存储了数据的有效节点,所以把它设置为头指针没什么意义,但也不是不可以。
接着就是while 循环,只要没有到末尾,也就是p为空的情况,那就以会进行循环。每次都会 **printf("%d ", p->data);**输出节点的数据域的值,然后将指针挪到下一节点:p = p->pNext;
最后用return结束这个函数。也宣告遍历结束。实际算法中遍历其实十分重要,即使遍历,通过对输出的观察,可以让我们更容易发现自己的错误。
Delete Change Search删改查 函数
sql
之所以放在一起讲,
是因为它们大抵是Insert和Travel的换皮。
如果你已经明白了Insert和Travel,那你很容易模仿出来这三个函数。
c
bool Delete(PLT pHead, int pos ,int* pVal)
//在第pos个节点删除一个节点!pos从1开始
{
int i = 1;
int flag = 0;
PLT q= pHead;
PLT p =pHead->pNext ;
while (p != NULL)
{
if (pos == i)
{
q->pNext = p->pNext;
*pVal = p->data;
flag = 1;
free(p);
break;
}
p = p->pNext;
q = q->pNext;
i++;
}
if (flag == 1)
return true;
else
return false;
}
c
bool Change(PLT pHead, int pos, int Val)
{
int flag = 0;//判定是否实现了操作
int i = 1;//用这个判断是否找到了节点
PLT p = pHead;
while (p->pNext != NULL)
{
if (i == pos)
{
p->pNext->data = Val;
flag = 1;
break;
}
i++;
p = p->pNext;
}
if (flag == 1)
return true;
else
return false;
}
c
bool Search(PLT pHead, int pos, int*pVal)
{
int flag = 0;//判定是否实现了操作
int i = 1;
PLT p;
p = pHead->pNext;
while (p != NULL)
{
if (i == pos)
{
*pVal=p->data;
flag = 1;
break;
}
i++;
p = p->pNext;
}
if (flag == 1)
return true;
else
return false;
}
它们都会接收链表pHead ,以及要操作节点的位置pos ,以及一个Val 或者pVal 。
Change 用val 一个整形变量,是因为它只要用这个Val 替换掉pos 位置的节点的数据域。
而Delete 和Search 用pVal ,一个指针,是因为在主函数中要接收,这两个函数所操作的那个节点的指针域,如果不用指针,那主函数的实参是不会随着函数的形参变化的。
三个函数都有i 和flag ,前者是用来和pos 对比的,它是while 循环结束的一个隐含的条件,一旦有匹配的i 和pos ,那就表明找到了要操作的节点,于是会进入if分支进行核心操作:删除,更改,或者查询。然后break跳出循环。而flag 则是一个成功操作与否的工具变量 ,可以指示是否完成了操作,之后,可以根据它的值来确定返回什么。
这三个函数的核心代码咯留给大家自己慢慢看。
至此,链表我们就讲完了。
如果你觉得有用,请给我留言。
如果你还有疑问,请在评论区打出。
如果你有更好的建议,也请留言。