目录
前言
链表是数据结构中最基础也是最重要的线性表之一,它克服了数组固定大小的缺点,能够动态地进行内存分配。本文将全面介绍链表的概念、基本操作以及实际应用,帮助C语言初学者掌握这一重要数据结构。
一、链表基础概念
链表是⼀种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表
中的指针链接次序实现的 。
链表的结构跟⽕⻋⻋厢相似,淡季时⻋次的⻋厢会相应减少,旺季时⻋次的⻋厢会额外增加几节。如果将⽕⻋⾥的某节⻋厢去掉/加上,其他⻋厢也不会受到影响,因为每节⻋厢都是独⽴存在的,且每节⻋厢都有⻋⻔。
想象⼀下这样的场景,假设每节⻋厢的⻋⻔都是锁上的状态,需要不同的钥匙才能解锁,每次只能携带⼀把钥匙的情况下如何从⻋头⾛到⻋尾?
最简单的做法:每节车厢里都放一把前往下一节车厢的钥匙!
这也是我们学习链表,理解链表工作原理的主要思想。那么在链表⾥,每节“⻋厢”是什么样的呢?
与顺序表不同的是,链表⾥的每节"⻋厢"都是独⽴申请下来的空间,我们称之为“结点/节点”
节点的组成主要有两个部分:当前节点要保存的数据和保存下⼀个节点的地址(指针变量)。
图中指针变量 plist保存的是第⼀个节点的地址,我们称plist此时“指向”第⼀个节点,如果我们希
望plist“指向”第⼆个节点时,只需要修改plist保存的内容为0x0012FFA0。
为什么还需要指针变量来保存下⼀个节点的位置呢?
链表中每个节点都是独⽴申请的(即需要插⼊数据时才去申请⼀块节点的空间),我们需要通过指针变量来保存下⼀个节点位置才能从当前节点找到下⼀个节点。(这就是保存前往下一节车厢的钥匙)
结合我们学到的结构体知识,我们可以给出每个节点对应的结构体代码:
假设当前保存的节点为整型:
struct LNode
{
int data;//节点数据
struct LNode*next;//指针变量⽤保存下⼀个节点的地址
}
为了我们后续的书写方便,我们可以用typedef关键字来对我们的struct LNode结构体进行重命名
typedef struct LNode
{
int data;//节点数据
struct LNode*next;//指针变量⽤保存下⼀个节点的地址
}LNode,*LinkList;
通过这个代码,我们将一个类型为struct LNode的结构体,重新命名为了LNode,简洁了代码,同时用了一个*LinkList,将类型为struct LNode*的指针,也重新命名为了LinkList。所以大家注意,当我们使用LinkList定义一个变量时,这个变量的类型是一个结构体指针!!
LinkList a;//a是一个结构体指针,指向了一个结构体变量的地址
LinkList *b;//b是一个二级指针,指向了一个结构体指针的地址
当我们想要保存⼀个整型数据时,实际是向操作系统申请了⼀块内存,这个内存不仅要保存整型数
据,也需要保存下⼀个节点的地址(当下⼀个节点为空时保存的地址为空)。
当我们想要从第⼀个节点⾛到最后⼀个节点时,只需要在前⼀个节点拿上下⼀个节点的地址(下⼀个节点的钥匙)就可以了。
二、链表的分类
链表的结构⾮常多样,以下情况组合起来就有8种(2 x 2 x 2)链表结构:
1、带头或者不带头:
带头链表(也称为有头节点的链表)是指在链表的第一个元素节点之前,额外增加的一个特殊节点,这个节点被称为头节点(Header Node)。与之相对的是不带头链表(无头节点的链表),即链表直接从第一个存储实际数据的节点开始。
头节点的最大优势是使空链表和非空链表的操作一致,使得所有实际数据节点都有"前驱节点",避免了首元节点的特殊处理。在有些特殊情况下,头节点也能存储整个链表的信息情况,比如链表长度等。
2、单向或者双向:
单向链表:
-
每个节点包含:
-
数据域(存储数据)
-
一个指针域(指向下一个节点)
-
-
只能单向遍历(从头到尾)
双向链表:
-
每个节点包含:
-
数据域(存储数据)
-
两个指针域:
-
prev
:指向前一个节点 -
next
:指向下一个节点
-
-
-
可以双向遍历(从头到尾或从尾到头)
可以看出,双向链表的每一个节点都比单向链表的节点多了一个指针,所以空间开销会更大,但是实现的功能也更加强大。
3、循环和不循环:
普通(非循环)链表
-
结构特点:尾节点的next指针指向NULL
-
遍历终止条件:
current->next == NULL
循环链表
-
结构特点:尾节点的next指针指向头节点(或第一个数据节点)
-
遍历终止条件:
current->next == head
虽然有这么多的链表的结构,但是我们实际中最常⽤还是两种结构:单链表和双向带头循环链表
单链表经常在我们做题的时候出现,在数据结构中一般作为其他复杂数据结构的子结构,所以我们目前最需要掌握的就是理解单链表,学会使用单链表。
而双向带头循环链表一般用来单独存储数据,在我们做题的时候出现的概率较低,但仍然需要掌握。(当然优先级肯定在单链表之后)
三、单链表的模拟实现
上文说了那么多,不过也只是纸上谈兵,最多带大家简单了解一下链表,但想让大家真正深入理解,还是需要通过手动模拟实现一个链表。希望大家可以跟我一起,多动手,来实现一下单链表的基本逻辑:
1、头节点初始化
链表是由很多个节点连接构成的,所以我们应该一开始先创建节点结构体,如同我们上文提到过的,顺便使用typedef 来进行重命名:
typedef struct PNode
{
int data;
struct PNode* next;
}PNode,*LinkList;
由于我们是带头链表,所以我们接下来可以实现一个初始化函数来进行头节点的初始化。
我们知道,由于带头单链表的头节点的数据域是不带任何数据的,所以我们就直接使用new默认开创出一个PNode的空间,但是不去对他的data进行初始化。由于是头结点的初始化,此时我们是没有任何其他节点的,所以我们的next指针应该指向NULL,随后,把这个指向头节点的指针返回。
代码示例如下:
LinkList ListInit()//构造头节点
{
LinkList ret = new PNode;
ret->next = NULL;
return ret;
}
当然还有另外一种写的模式,就是我们自己传递一个LinkList的指针进去,引用的方式接收,这样就不用返回LinkList了。
void ListInit(LinkList & ret)
{
ret = new PNode;
ret->next = NULL;
}
两种初始化头节点的方法都行。
很好,我们已经创建了一个头节点了,可惜我们的链表中没有数据,那么我们是不是就应该对这个链表进行插入操作呢?
2、头插与尾插
链表作为一个一个串起来的链式结构,他的删除与插入方法(尤其是头删与头插)还是比较高效的。接下来就由我来先为大家介绍一下链表的头插与尾插。
所谓头插与尾插,顾名思义,就是在整个链表的首部或者末尾进行一个插入节点的操作。
在执行插入时,我们可以把想插入的那个值先给存储在一个新节点中,后面再将这个节点插入链表中。
不管是哪种插入操作,我们最主要的就是要寻找插入的位置,如果你连插入的位置都没找到,那整个插入操作就都是错误的。
头插要插入在链表的首部,这里的首不是说的真正的第一个节点,因为我们这个是带头的单链表,而是指的是第一个存储着数据的链表。那我们想一下,这第一个存储数据的链表是不是应该永远被我们的头节点所指向着啊。
所以我们的头节点就是我们想要找的插入位置。插入一个节点,要求你的上一个节点的next指针要指向你,你的next指针要指向上一个节点的next指针原本应该指向的节点。否则就会造成数据丢失。所以在头插的时候,我们还应该找到头节点原本所指向的节点(就算是空节点我们也应该找到)。
所以我们用pnext来指向phead头节点的->next,随后我们就可以把新插入的节点的next指向pnext指针,随后,让phead头节点的->next指向新插入节点,这样我们的头插工作就大功告成了
另外,我们想要在函数中让链表被修改,所以我们的插入函数应该传递的参数应该有头节点,以及想要插入的值
void Push_front(LinkList& phead,int num)//头插
{
LinkList newnode = new PNode;
newnode->data = num;//存储想插入的num值
//进行插入操作
LinkList pnext = phead->next;
newnode->next = pnext;
phead->next = newnode;
}
我们这样的写法并不怕这是个空链表(对于带头链表来说就是链表中只有一个头节点),因为pnext就是指向的NULL,给newnode->next赋值就是指向的NULL,很合理。
大家谨记,我们这里用pnext存储原本节点的下一个节点是为了避免数据丢失,这个pnext并不是必备的。
如果我们的插入顺序是:
newnode->next=phead->next;
phead->next=newnode;
这样也能达到相同的头插效果,但是我害怕同学们把顺序搞反:
phead->next=newnode;
newnode->next=phead->next;
这样就会造成newnode->next指针实际上是指向的自己的后果,因为你在上一行代码中已经更改了phead的next指针所指向的位置。所以为了避免同学们出现这种问题,我还是强烈推荐大家养成记录可能会丢失的节点的位置,因为你已经用pnext记录了,哪怕顺序依旧搞反,我们也能正确连接各个节点:
LinkList pnext=phead->next;
phead->next=newnode;
newnode->next=pnext;
这就是我们头插的主要逻辑,接下来就是尾插。其实尾插也是一样的插入方法,只不过我们要寻找插入的位置,也就是尾节点ptail。
我们可以从头节点开始,定义一个指针ptail,通过while循环不断判断ptail->next是否为NULL,如果是,说明就已经找到尾节点,可以退出循环了,具体代码如下:
void Push_pop(LinkList& phead, int num)
{
LinkList newnode = new PNode;
newnode->data = num;//存储想插入的num值
//寻找尾节点
LinkList ptail = phead;
while (ptail->next)
{
ptail = ptail->next;
}
//找到尾节点了。
//进行插入操作
LinkList pnext = ptail->next;
newnode->next = pnext;
ptail->next = newnode;
}
对于空链表,由于我们ptail一开始就指向phead,所以ptail的next一开始就是空,所以不会进入while循环。
3、头删与尾删
讲完了头插与尾插,接下来我们就讲一下头删与尾删。
删除节点与插入节点不同,插入节点是肯定能成功的,但是删除节点却可能会遇到没有节点可供你删除的情况。(尤其是空链表时)
所以我们在进行头删与尾删时一定要先判断一下是否是空链表,如果是,就没必要删除,显示删除失败。所以,我们的删除函数可以返回一个int类型的数帮助我判断是否删除成功(譬如规定返回1就是删除成功,返回0就是删除失败)。
进行删除操作,仍然要注意数据丢失的问题,我们切记一定要让被删除的节点脱离链表后,再删除。具体的删除步骤是:
int Delete_front(LinkList& phead)
{
LinkList pcur = phead->next;//pcur代表我们要删除的节点
if (pcur == NULL)
{
return 0;//删除失败
}
//将被删除指针从链表中分离
LinkList pnext = pcur->next;
phead->next = pnext;
//释放内存,指针置空
delete pcur;
pcur = NULL;//我们delete的内存,这个pcur指针还是存在的,仍然指向之前的位置。但由于那个位置已经被我们delete释放掉了
//所以如果后续不再使用pcur,一定要将pcur置为空,避免产生野指针
return 1;//代表删除成功
}
尾删也是同理:
int Delete_pop(LinkList& phead)
{
LinkList pcur = phead->next, prew = phead;//pcur需要指向我们要删除的节点,prew我们让它永远在pcur的上一个节点处,方便用来删除尾节点
if (pcur == NULL)//空链表
{
return 0;//删除失败
}
//寻找被删除的节点,当pcur->next为空,就代表为尾节点
while (pcur->next)//我们这里敢这么判断,是因为前面的if语句已经判断了至少第一次时,pcur不可能为空,所以可以使用pcur->next,否则,cpur若为空,你使用pcur->next就会报错
{
pcur = pcur->next;//寻找尾节点
prew = prew->next;
}
//将被删除指针从链表中分离
//因为是尾节点,且还是不循环链表,这里就省略写了,只需要将prew的next置空就行,但其实应该和头删一样操作
prew->next = NULL;
//全部操作:
/*LinkList pnext = pcur->next;
prew->next = pnext;*/
//释放内存,指针置空
delete pcur;
pcur = NULL;//我们delete的内存,这个pcur指针还是存在的,仍然指向之前的位置。但由于那个位置已经被我们delete释放掉了
//所以如果后续不再使用pcur,一定要将pcur置为空,避免产生野指针
return 1;//代表删除成功
}
4、查找元素
链表中,对数据的查找也是非常重要的。包括两种查找:
1、查找是否存储了对应的数据data,并返回第一次出现这个节点的次序或者返回这个节点的指针。不存在则查找失败返回0,或者空指针。
2、查找对应次序的元素,存在则返回对应的指针。失败就返回空指针
面对第一种查找,我们可以遍历链表,通过等于判断是否有相同的data。面对第二种查找,我们也可以遍历链表,只不过遍历的时候要进行计数,如果计数还没达到要求的次序,就返回空指针,说明查找失败。直到次序与计数相同,就能返回对应指针。
我们这里主要实现一下第一种查找函数。
思路已经说过了,具体实现如下:
int Find_data(LinkList& phead, int data)
{
int ret = 1;//这是最后要返回的计数,我们默认从1开始,如果返回值为0就说明查找失败,
//我们仍然用pcur来遍历链表
LinkList pcur = phead->next;
//注意我们的循环条件,使用&&来链接,一定要先判断pcur不为空指针,如果为空指针,&&就保证了后面的pcur->data不会被执行
while (pcur && pcur->data != data)
{
pcur = pcur->next;
ret++;
}
//如果pcur为空,说明直到最后都没有找到匹配的数据
if (!pcur)
{
return 0;//查找失败
}
else
{
//不为空,说明是因为循环找到匹配数据才停止下来的
return ret;//此时ret正好为他的次序,我们也可以返回pcur指针
}
}
知道了查找的基本逻辑,我们就可以运用查找和删除逻辑,轻松实现对特定的数据,特定次序的节点进行删除操作,这里就交给大家去实现,看看大家有没有看懂之前写的代码与逻辑。
5、销毁
最后就是我们函数的销毁,基础逻辑就是遍历删除节点,注意避免产生野指针就行。
void Destroy(LinkList& phead)
{
while (phead)
{
//使用临时指针变量进行删除
LinkList temp = phead;
phead = phead->next;
//删除,避免产生野指针
delete temp;
temp = NULL;
}
}
到此我们的带头单链表的逻辑大致就是这样了,这是最基础的逻辑,其他更复杂的操作大多万变不离其宗。最后我们可以写一个Pirnt函数,在主函数中运行一下是否有错误:
void Print(LinkList& phead)
{
LinkList pcur = phead->next;
while (pcur)
{
std::cout << pcur->data << " ";
pcur = pcur->next;
}
std::cout << std::endl;
}
int main()
{
LinkList head = ListInit();
Push_front(head, 4);
Push_front(head, 3);
Push_front(head, 2);
Push_front(head, 1);
Print(head);//1 2 3 4
Push_pop(head, 4);
Push_pop(head, 3);
Push_pop(head, 2);
Push_pop(head, 1);
Print(head);//1 2 3 4 4 3 2 1
cout << Find_data(head, 1) << endl;//1
Delete_front(head);
Print(head);//2 3 4 4 3 2 1
cout << Find_data(head, 1) << endl;//7
Delete_pop(head);
Delete_pop(head);
Print(head);//2 3 4 4 3
Destroy(head);
return 0;
}
打印结果并没有问题,程序也正常退出。
整体代码如下:
#include<stdio.h>
#include<iostream>
using namespace std;
typedef struct PNode
{
int data;
struct PNode* next;
}PNode,*LinkList;
LinkList ListInit()//构造头节点
{
LinkList ret = new PNode;
ret->next = NULL;
return ret;
}
//void ListInit(LinkList & ret)
//{
// ret = new PNode;
// ret->next = NULL;
//}
void Push_front(LinkList& phead,int num)//头插
{
LinkList newnode = new PNode;
newnode->data = num;//存储想插入的num值
//进行插入操作
LinkList pnext = phead->next;
newnode->next = pnext;
phead->next = newnode;
}
void Push_pop(LinkList& phead, int num)
{
LinkList newnode = new PNode;
newnode->data = num;//存储想插入的num值
//寻找尾节点
LinkList ptail = phead;
while (ptail->next)
{
ptail = ptail->next;
}
//找到尾节点了。
//进行插入操作
LinkList pnext = ptail->next;
newnode->next = pnext;
ptail->next = newnode;
}
int Delete_front(LinkList& phead)
{
LinkList pcur = phead->next;//pcur代表我们要删除的节点
if (pcur == NULL)
{
return 0;//删除失败
}
//将被删除指针从链表中分离
LinkList pnext = pcur->next;
phead->next = pnext;
//释放内存,指针置空
delete pcur;
pcur = NULL;//我们delete的内存,这个pcur指针还是存在的,仍然指向之前的位置。但由于那个位置已经被我们delete释放掉了
//所以如果后续不再使用pcur,一定要将pcur->next置为空,避免产生野指针
return 1;//代表删除成功
}
int Delete_pop(LinkList& phead)
{
LinkList pcur = phead->next, prew = phead;//pcur需要指向我们要删除的节点,prew我们让它永远在pcur的上一个节点处,方便用来删除尾节点
if (pcur == NULL)//空链表
{
return 0;//删除失败
}
//寻找被删除的节点,当pcur->next为空,就代表为尾节点
while (pcur->next)//我们这里敢这么判断,是因为前面的if语句已经判断了至少第一次时,pcur不可能为空,所以可以使用pcur->next,否则,cpur若为空,你使用pcur->next就会报错
{
pcur = pcur->next;//寻找尾节点
prew = prew->next;
}
//将被删除指针从链表中分离
//因为是尾节点,且还是不循环链表,这里就省略写了,只需要将prew的next置空就行,但其实应该和头删一样操作
prew->next = NULL;
//全部操作:
/*LinkList pnext = pcur->next;
prew->next = pnext;*/
//释放内存,指针置空
delete pcur;
pcur = NULL;//我们delete的内存,这个pcur指针还是存在的,仍然指向之前的位置。但由于那个位置已经被我们delete释放掉了
//所以如果后续不再使用pcur,一定要将pcur->next置为空,避免产生野指针
return 1;//代表删除成功
}
int Find_data(LinkList& phead, int data)
{
int ret = 1;//这是最后要返回的计数,我们默认从1开始,如果返回值为0就说明查找失败,
//我们仍然用pcur来遍历链表
LinkList pcur = phead->next;
//注意我们的循环条件,使用&&来链接,一定要先判断pcur不为空指针,如果为空指针,&&就保证了后面的pcur->data不会被执行
while (pcur && pcur->data != data)
{
pcur = pcur->next;
ret++;
}
//如果pcur为空,说明直到最后都没有找到匹配的数据
if (!pcur)
{
return 0;//查找失败
}
else
{
//不为空,说明是因为循环找到匹配数据才停止下来的
return ret;//此时ret正好为他的次序,我们也可以返回pcur指针
}
}
void Destroy(LinkList& phead)
{
while (phead)
{
//使用临时指针变量进行删除
LinkList temp = phead;
phead = phead->next;
//删除,避免产生野指针
delete temp;
temp = NULL;
}
}
void Print(LinkList& phead)
{
LinkList pcur = phead->next;
while (pcur)
{
std::cout << pcur->data << " ";
pcur = pcur->next;
}
std::cout << std::endl;
}
int main()
{
LinkList head = ListInit();
Push_front(head, 4);
Push_front(head, 3);
Push_front(head, 2);
Push_front(head, 1);
Print(head);//1 2 3 4
Push_pop(head, 4);
Push_pop(head, 3);
Push_pop(head, 2);
Push_pop(head, 1);
Print(head);//1 2 3 4 4 3 2 1
cout << Find_data(head, 1) << endl;//1
Delete_front(head);
Print(head);//2 3 4 4 3 2 1
cout << Find_data(head, 1) << endl;//7
Delete_pop(head);
Delete_pop(head);
Print(head);//2 3 4 4 3
Destroy(head);
return 0;
}
最后解释一下,有的同学认为我的将被删除指针置为空没有任何意义,因为那个是临时变量。但实际上这是为了让大家养成习惯,避免野指针的出现,因为野指针在项目中具有十足的危害性!!!大家再学习智能指针之前都要通过良好的代码习惯