单链表
链表的概念及结构
概念:链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
可以把链表比作一列火车,火车是通过一节一节车厢组成的,而链表是通过一个一个的节(结)点组成起来的,节(结)点跟火车车厢一样每一个既是独立的,又有着链接彼此的部分。让我们想象一下,假设火车的每节车厢都是上锁的,我们想进入下一节车厢就一定要在上一节车厢中找到下一节车厢的钥匙。链表也是如此。
一个节(结)点的组成中会有下一个节(结)点的“钥匙”,体现在链表中就是下一个节(结)点的地址。链表的节(结)点跟顺序表一样是结构体组成的,这样可以存储我们自己定义的数据类型。
体现在代码上如下。
//一个简单的链表的节(结)点
struct Node {
int value; //存储数据
Node *next; //链接下一个节(结)点
};
这么说可能对初学者来说还是有点抽象我们看一张图。
这张图可以很清晰的看出链表的结构和每个节(结)点之间的关系:
1.有一个头指针,也是这个链表的初始指针,头指针指向链表的第一个节(结)点。
2.每一个节(结)点都会有个存储下一个节(结)点的指针的数据成员。
3.最后一个节(结)点的指向下一个节(结)点的指针为空指针(NULL)。
这样一个有头有尾的单链表就出生了。
-
说明
- 1、链表不仅可以存储整型(int),还可以存储各种类型如浮点型(float),结构体(struct)等。
- 2、链表这种数据结构是逻辑上的连续,在物理结构上不一定连续。
- 3、节(结)点一般是从堆上申请的,所以从堆上申请来的空间,是按照⼀定策略分配出来的,每次申请的空间可能连续,可能不连续。
如何实现单链表
既然我们已经了解了链表的组成和概念,那我们该怎么实现一个链表呢?和顺序表一样我们从以下几个方面实现它。
第一步:链表节(结)点的创建
typedef int SLTDataType; //链表可以储存的数据类型不一定只有整型(int)
typedef struct SListNode //定义链表的名字,使在使用链表数据类型时可以简单点。
{
SLTDataType data; //节点数据
struct SListNode* next; //指针保存下⼀个节点的地址
}SLTNode;
第二步:链表的初始化
因为链表的每个节(结)点是彼此独立的所以初始化一个链表就是给出这个链表的头指针(SLTNode* head)所以初始化一个链表定义一个空的节(结)点就可以
SLTNode* head = NULL;
第三步:给节(结)点申请空间
我们想要创建结点就必须动态的给每个节(结)点申请到合适和空间,所以我们实现函数
//用来专门给新节(结)点申请空间
SLTNode* SLTBuyNode(SLTDataType x);
可以看到这个函数有一个参数是我们要存储在链表中的数据
了解之后,下面我们来实现它
SLTNode* SLTBuyNode(SLTDataType x)
{
//用malloc函数给新节(结)点(newnode)申请节(结)点大小的空间
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
//下面来判断是否申请成功,申请失败则报错退出。
if(newnode == NULL)
{
perror("SLTBuyNode error!");
exit(1);
}
//将要存储的数据存入新节(结)点(newnode)
newnode->data = x;
//将新节(结)点(newnode)的指向下一个节(结)点的指针(nownode->next)指向空(NULL)
newnode->next = NULL;
//返回新节(结)点(newnode)的指针
return newnode;
}
这样我们给节(结)点申请空间的函数就实现了,那我们为什么要把它单独写出来呢?因为下面我们会重复使用这个操作很多次为了避免代码重复冗长,所以把它单独写出来
第四步:链表的简单插入
链表的简单插入可以分为在头部插入和在尾部插入两种
//链表头部插入(在链表的最前端插入节(结)点)
void SLTPushFront(SLTNode** pphead, SLTDataType x);
//链表尾部插入(在链表的最后端插入节(结)点)
void SLTPushBack(SLTNode** pphead, SLTDataType x);
可以看到链表的简单插入有两个参数一个是链表中要存储的数据x还有一个是一个二级指针有人可能很好奇为什么用的是二级指针呢?下面我们就花一点口舌解释一下
Q:如果我们传入的是一个一级指针会怎么样的?
我们先用一级指针完成一下代码
void SLTPushFront(SLTNode* phead, SLTDataType x)
{
//建立新节(结)点(newnode),并存入要存储的数据
SLTNode* newnode = SLTBuyNode(x);
//插入已有链表的头部(phead)的前面
newnode->next = phead;
//最后把插入后的头部(newnode)的指针赋给我们的头指针(phead)
phead = newnode;
}
写完之后我们只看代码可能并不能发现问题,但是如果你运行代码或者调试代码会发现我们想插入的新节(结)点并没有插入
Q:这是为什么呢?
原来我们想改变的头指针(head)也是一个变量:指针变量,存储了一个指针,我们知道在函数中想要通过形参改变实参的方法在C中只有通过指针来实现,所以我们要传入头指针(head)的指针也就是一个二级指针(&head)这样我们就可以通过形参来改变头指针(head)的值了。以下是改进后的代码
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
//断言,判断pphead不能为空
assert(pphead);
//建立新节(结)点(newnode),并存入要存储的数据
SLTNode* newnode = SLTBuyNode(x);
//插入已有链表的头部(phead)的前面
newnode->next = *pphead;
//最后把插入后的头部(newnode)的指针赋给我们的头指针(phead)
*pphead = newnode;
}
看到这段代码之后可能又有人要问了
Q:为什么这次要断言一下呢?
这是因为二级指针和一级指针所可以代表的东西不一样,可以看下面的表格
一级指针 | 二级指针 |
---|---|
*phead | **pphead |
phead | *pphead |
&phead | pphead |
在用一级指针做形参时,一级指针为空(NULL)代表的是头指针(head)中存的链表为空链表(NULL),但是二级指针做形参时,如果二级指针为空(NULL)代表的是头指针的地址(&head)为空(NULL),而空指针(NULL)不能存储其它指针,程序就会错误。所以pphead不能为空(NULL),所以加断言。
有了前面的铺垫我们可以很轻松的写出链表尾部插入(在链表的最后端插入节(结)点)的代码
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
//断言,判断pphead不能为空
assert(pphead);
//建立新节(结)点(newnode),并存入要存储的数据
SLTNode* newnode = SLTBuyNode(x);
//如果是空链表直接添加
if(*pphead == NULL);
{
*pphead = newnode;
}
else
{
//建立一个临时变量找链表的尾节(结)点
SLTNode* ptail = *pphead;
while(ptail->next)
{
ptail = ptail->next
}
//在尾部插入新节(结)点
ptail->next = newnode;
}
}
可能有人会问
Q:在找链表尾节(结)点时为什么要创建临时变量呢?
因为防止头指针(head)的位置改变,不在指向头节(结)点,如果用临时变量可以避免这个问题
第五步:链表的简单删除
和简单的插入一样,简单的删除也分为头部删除和尾部删除
//链表的头部删除(删除链表最前端的节(结)点)
void SLTPopFront(SLTNode** pphead);
//链表的尾部删除(删除链表最后端的节(结)点)
void SLTPopBack(SLTNode** pphead);
这两个函数的参数只有一个,因为不用存入新的数据,我们来看一下它们的实现
void SLTPopFront(SLTNode** pphead)
{
//断言pphead和*pphead不能为空
assert(pphead && *pphead);
//用一个临时变量记录头删后新的头指针
SLTNode* newhead = (*pphead)->next;
//释放原来最前面的节(结)点的空间
free(*pphead);
//最后将临时新的头指针(newnode)赋给,*pphead改变头指针(head)的指向
*pphead = newhead;
}
如果这个链表是一个空的链表我们就没有必要删除所以*pphead也进入断言
void SLTPopBack(SLTNode** pphead)
{
//断言pphead和*pphead不能为空
assert(pphead && *pphead);
//当只有一个节(结)点时直接删除
if((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
//定义两个临时变量,一个找尾节(结)点,用来删除;一个找尾节(结)点的前一个节(结)点,用来结束链表
SLTNode* ptail = *pphead;
SLTNode* prev = *pphead;
//找的过程
while(ptail->next)
{
prev = ptail;
ptail = ptail->next;
}
//释放尾节(结)点的空间
free(ptail)
//防止野指针
ptail = NULL;
//结束链表
prev->next = NULL;
}
我们来单独看一下找尾节(结)点和尾节(结)点的前一个节(结)点的过程
第六步:根据数据查找节(结)点的位置
我们先给出声明
//根据数据找出该节(结)点的地址
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
肯定有人有这样的疑问
Q:我们找到是第几个节(结)点不就好了吗,为什么要找到它的地址呢?
答案就是后面我们的学习和后面的算法要使用这种查找,我们看一下这个函数的返回值和参数,返回值和上面的都不太一样这个函数的返回值是SLTNode*因为这个函数要返回一个节(结)点的地址,参数是根据的数据x和我们的头指针phead因为不改变头指针的指向所以是一级指针,下面我们来实现一下
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
//定义一个临时变量,防止头指针(head)改变
SLTNode* find = phead;
//通过指定的数据找到节(结)点
while(find)
{
if(find->data == x)
return find //找到就返回该指针
find = find->next;
}
//找不到就返回一个空指针(NULL)
return NULL;
}
第七步:链表的复杂插入
结合上一步实现的查找节(结)点位置,这一步我们可以在指定位置之前或之后插入节(结)点了
//在指定位置之前插入节(结)点
void SLTInsertFront(SLTNode** pphead, SLTNode* pos, SLTDataType x);
//在指定位置之后插入节(结)点
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
SLTInsertFront函数有三个参数分别是我们指定的位置pos要插入的数据x和我们的头指针的地址pphead,这是因为如果我们要插入的位置是第一个节(结)点时我们要改变头指针(head)的位置,而SLTInsertAfter函数只有两个参数分别是我们指定的位置pos要插入的数据x,因为时往后面插入不存在改变头指针(head)的情况,现在我们来实现它们
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
//断言pphead和*pphead不能为空
assert(pphead && *pphead);
//创建新节(结)点
SLTNode* newnode = SLTBuyNode(x);
//记录指定位置(pos)的前一个节(结点)点
SLTNode* prev = *pphead;
//如果指定位置(pos)就是头指针(head)就直接调用前插函数
if (pos == *pphead)
{
SLTPushFront(pphead, x);
return;
}
//找到指定位置(pos)的前一个节(结)点
while (prev->next != pos)
{
prev = prev->next;
}
//插入新节(结)点
prev->next = newnode;
newnode->next = pos;
}
我们为什么要找到前一个节(结)点呢,因为只有知道指定位置前一个节(结)点我们才能插入,如图指定位置是2那我们只有知道位置1的地址才能将新节(结)点插入到指定位置前
现在我们来实现在指定位置后插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
//断言pos不能为空,因为我们无法对空指针(NULL)进行解引用
assert(pos);
//创建新节(结)点(newnode)
SLTNode* newnode = SLTBuyNode(x);
//记录指定位置(pos)之后的节(结)点
SLTNode* pcur = pos->next;
//插入新节(结)点(newnode)
pos->next = newnode;
newnode->next = pcur;
}
这里插入新节(结)点的操作顺序自己一定要画画图想明白
第八步:链表的复杂删除
我们可以在指定位置之后删除,或者删除指定位置的节 (结)点
//删除指定位置(pos)的节(结)点
void SLTErase(SLTNode** pphead, SLTNode* pos);
//删除指定位置(pos)之后的节(结)点
void SLTEraseAfter(SLTNode* pos);
我们先看一下这两个函数的参数SLTErase函数有两个参数一个是头指针的地址phead一个是指定位置pos,因为删除pos位置的节(结)点我们要找到pos的前一个和后一个把它们串起来,所以有头指针的地址pphead的参数,那为什么是二级指针呢?是因为可能我们要删的就是第一个节(结)点这时我们要改变头指针(head)的指向,所以用二级指针,而SLTEraseAfter函数只有一个参数指定位置(pos),因为它要删除的是指定位置(pos)之后的节(结)点,现在我们来实现一下它们
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
//断言,pphead,*pphead和pos都不能为空指针(NULL)
assert(pphead && *pphead);
assert(pos);
//如果指定位置(pos)就是头指针(head)的位置直接调用头删(SLTPopFront)函数
if (pos == *pphead)
{
SLTPopFront(pphead);
}
else
{
//创建临时变量,记录指定位置(pos)之前的节(结)点
SLTNode* prev = *pphead;
//找指定位置之前的节(结)点
while (prev->next != pos)
{
prev = prev->next;
}
//把指定位置(pos)的前一个节(结)点和后一个串起来
prev->next = pos->next;
//释放空间
free(pos);
//防止野指针
pos = NULL;
}
}
如图我们在删除指定位置2之后要将前后串起来所以我们要提前找到前一个和后一个节(结)点
现在我们实现在指定位置后删除节(结)点
void SLTEraseAfter(SLTNode* pos)
{
//断言pos和pos->next不能为空指针(NULL)
assert(pos && pos->next);
//创建两个临时变量next用来存放要删除的节(结)点之后的节(结)点的地址
SLTNode* next = pos->next->next;
//del用来存放要删除节(结)点的地址
SLTNode* del = pos->next;
//释放空间
free(del);
//防止野指针
del = NULL;
//将指定位置(pos)的节(结)点与删除的节(结)点(del)之后的节(结)点相连
pos->next = next;
}
虽然代码看起来都很简单,但是还是要自己画一画图理解一下哦
第九步:链表的销毁
这也是我们实现一个链表的最后一步
//销毁链表
void SListDesTroy(SLTNode** pphead);
这个函数的参数只有一个,就是我们的头指针的地址pphead,我们来实现一下它
void SListDesTroy(SLTNode** pphead)
{
//创建一个临时变量用来遍历链表
SLTNode* ptmp = *pphead;
//进入循环一个一个节(结)点释放空间
while(ptmp) //当ptmp为空指针(NULL)时,证明链表已经遍历完了
{
//创建一个临时变量记录下一个节(结)点的地址
SLTNode* next = ptmp->next;
//释放当前节(结)点
free(ptmp);
//防止野指针
ptmp = NULL;
//开始下一个节(结)点
ptmp = next;
}
//防止野指针
*pphead = NULL;
}
为什么要创建两个临时变量呢,第一个(ptmp)是为了遍历链表,并且保存头指针(head)的指向,第二个临时变量(next)是因为当前一个节(结)点释放后如果不提前保留下一个节(结)点的地址我们就再也找不到下一个节(结)点的地址了
链表的分类
链表作为最基本的数据结构,也分为很多类型,基本的类型有 (不)带头链表、单向链表、双向链表、(不)循环链表。 而他们排列组合一下就会有23那么多种
这里我们只实现了单链表,其它类型的链表我们会在后面介绍。
1.单向或双向
双向链表就像高铁一样,两边的节(结)点都既可以做头(head)又可以做尾(tail)
2.带头或不带头(哨兵位)
带头链表就是带了一个只有内存空间但是不存储东西的节(结)点,这个节(结)点在链表的头就像一个哨兵,所以也叫哨兵位
3.循环或不循环
循环链表就是在单链表的基础上将最后一个节(结)点的next指针指向了头指针(head),使链表构成了一个循环
-
虽然有这么多的链表的结构,但是我们实际中最常⽤还是两种结构:单链表和双向带头循环链表
- 1.⽆头单向⾮循环链表:结构简单,⼀般不会单独⽤来存数据。实际中更多是作为其他数据结构的⼦结构,如哈希桶、图的邻接表等等。
- 2.带头双向循环链表:结构最复杂,⼀般⽤在单独存储数据。实际中使⽤的链表数据结构,都是带头双向循环链表。