前言
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。 相比于线性表顺序结构,操作复杂。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,效率比较高,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,效率一般。本章我们就使用C语言来实现一个单向链表。
一、链表的分类
在实现链表之前,我们先讲解一下链表的分类。首先链表分为带哨兵位的头结点的链表和不带哨兵位的头结点的链表。这里说明一下,在链表中使用哨兵位的头结点的做法主要是为了简化链表的操作,尤其是在处理空链表和边界条件时。哨兵位的头结点本身并不存储数据,而是作为一个标志,帮助我们在执行插入、删除等操作时,不必额外检查是否为空链表或者其他特殊情况。其次链表可以根据节点之间的连接方式可以分为单向链表,双向链表。最后还可以根据尾结点是否和头结点相连看是否为循环链表。
我们将这几种分类列出来如下:
带头 不带头
单向 双向
循环 不循环
任意组合即可组成常见的8种链表分类。不过这里需要说明一下,虽然可以排列组合出这么多种分类的链表,但最常用的其实只有两种–不带头单向不循环链表和带头双向循环链表,而我们平常所说的单向链表和双向链表一般也就分别特指这两种类型的链表。本章我们要实现的单向链表即是不带头单向不循环链表。
二、单向链表结构
//单向链表
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
在C语言中我们使用一个结构体来代表链表的每一个结点。这里我们定义了一个SListNode的结构体来代表链表的结点并将其重命名为SLTNode,在这个结构体中我们定义一个存储数据的变量data,再定义一个指向下一个结点的指针next。在这里需要注意两点,第一,在这里和上一章的顺序表类似,我们将int重命名为SLTDataType,这样便于以后我们更改链表存储数据的类型;第二,我们在定义指向下一个结点的指针时不能写成SLTNode* next,因为我们在定义该结点时还未进行重命名,所以在定义的结构体里面只能老老实实地写全struct SListNode* next。
三、单向链表实现
1.打印
void SLTPrint(SLTNode* phead)
{
assert(phead);
while (phead)
{
printf("%d->", phead->data);
phead = phead->next;
}
printf("NULL\n");
}
首先我们写一个打印链表的函数,这样便于我们后面进行测试的时候进行观察。打印函数即是利用while循环对链表进行遍历,然后依次进行打印操作,当结点为空即跳出循环完成遍历,也就完成了打印链表的操作。
2.尾插和尾删
//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* new = (SLTNode*)malloc((sizeof(SLTNode)));
if (new == NULL)
{
perror("malloc");
}
new->data = x;
new->next = NULL;
if (*pphead == NULL)
{
*pphead = new;
}
else
{
SLTNode* cur = *pphead;
while (cur->next)
{
cur = cur->next;
}
cur->next = new;
}
}
尾插函数,首先我们和之前实现顺序表一样,在每个函数前面加上一句assert断言,避免传入空指针的情况。这里说明一下,我们避免传入的是空指针是避免传入的pphead二级指针是空指针,而pphead一级指针是可以为空的,因为我们实现的不带哨兵位头结点的链表,所以当链表为空时,pphead一级指针即为空指针。在尾插函数中,我们先使用malloc函数对新结点进行动态开辟,并将插入的数据赋值给新结点的data,然后将其指向下一个结点的指针赋值为空。紧接着我们就要进行分类讨论,即当前链表是否为空。当当前链表为空,即 pphead == NULL,我们就直接将新结点赋值给 *pphead。若当前链表不为空**,我们就对该链表进行遍历找到链表的尾结点,然后对其进行链接,即让尾结点的next指针指向新结点。
//尾删
void SLTPopBack(SLTNode** pphead)
{
assert(pphead && *pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
return;
}
SLTNode* prev = *pphead;
SLTNode* cur = *pphead;
while (cur->next)
{
prev = cur;
cur = cur->next;
}
prev->next = NULL;
free(cur);
cur = NULL;
}
尾删函数,也是上来先给一个assert断言,不过由于是删除函数,所以不仅要防止传入的是空指针,还要避免空链表即*pphead也为空的情况。后面也是进行分类讨论,如果只剩一个结点,即( pphead)->next == NULL,那么我们就释放该结点并将头结点 *pphead置为空。如果不止一个结点,同样也是对链表进行遍历,这里需要用一个prev指针来记录cur的前一个结点,这样当我们的cur遍历到尾结点时,prev就指向尾结点的前一个结点,这样我们才能让倒数第二个结点的next为空(prev->next = NULL),最后就是用free函数释放尾结点。
3.头插和头删
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* new = (SLTNode*)malloc((sizeof(SLTNode)));
if (new == NULL)
{
perror("malloc");
}
new->data = x;
new->next = NULL;
if (*pphead == NULL)
{
*pphead = new;
}
else
{
new->next = *pphead;
*pphead = new;
}
}
头插函数,和尾插函数类型,我们利用malloc函数进行动态内存开辟新结点,然后进行分类讨论,如果当前链表为空,直接将新结点赋值给*pphead,如果不为空,则进行一下链接,将新结点的next指针指向当前的头结点 *pphead,然后让新结点成为头结点 *pphead完成头插操作。
//头删
void SLTPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
return;
}
SLTNode* cur = *pphead;
(*pphead) = cur->next;
free(cur);
cur = NULL;
}
头删函数,同理也是和尾删函数类似,只是少了一个遍历链表寻找尾结点的过程,其余和尾删函数基本一致。
4.查找
//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
assert(phead);
while (phead)
{
if (phead->data == x)
{
return phead;
}
phead = phead->next;
}
return NULL;
}
查找函数,逻辑比较简单,就是使用一个while循环遍历链表,然后寻找要查找的值,如果找到了就返回该结点的指针,如果没有就返回空指针。
5.在指定位置前后插入删除
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead && *pphead);
if (*pphead == pos)
{
SLTPushFront(pphead, x);
return;
}
SLTNode* cur = *pphead;
while (cur)
{
if (cur->next == pos)
{
SLTNode* new = (SLTNode*)malloc((sizeof(SLTNode)));
if (new == NULL)
{
perror("malloc");
}
new->data = x;
new->next = NULL;
cur->next = new;
new->next = pos;
return;
}
cur = cur->next;
}
}
在指定位置pos之前插入数据,首先也是一个断言防止空指针的情况。然后我们先进行一个判断,如果指定位置pos等于头结点*pphead,那么我们就可以直接复用头插函数。否则我们就开始遍历链表,直到遍历到目标结点的前一个结点或者直到结束。当我们的cur为目标结点的前一个结点时,我们就开始利用malloc函数动态开辟一个新结点,然后进行链接操作。我们先让cur->next = new再让new->next = pos即可完成链接操作。这里说明一下,当传入的pos为NULL时,我们则理解为尾插。
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && *pphead && pos);
if (*pphead == pos)
{
SLTPopFront(pphead);
return;
}
SLTNode* cur = *pphead;
SLTNode* prev = *pphead;
while (cur)
{
prev = cur;
cur = cur->next;
if (cur == pos)
{
prev->next = cur->next;
free(cur);
cur = NULL;
return;
}
}
}
删除pos结点,其实跟在pos位置之前插入数据的逻辑大差不差。如果pos等于头结点*pphead,我们可以直接调用头删函数,否则我们还是需要遍历链表寻找pos的前一个结点,寻找到后使用prev->next = cur->next;对链表进行链接,最后释放目标结点。
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* new = (SLTNode*)malloc((sizeof(SLTNode)));
if (new == NULL)
{
perror("malloc");
}
new->data = x;
new->next = NULL;
SLTNode* next = pos->next;
pos->next = new;
new->next = next;
}
在指定位置pos之后插入数据,这个就比在pos位置前面插入数据简单了,因为我们不需要去找pos的前一个结点,所以我们可以直接跳到创建新结点然后进行链接的操作。这部分就跟在pos之前插入数据的逻辑是一样的了。
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
assert(pos && pos->next);
SLTNode* next = pos->next;
pos->next = next->next;
free(next);
next = NULL;
}
删除pos之后的节点,同理也可知,除了不需要寻找pos位置的前一个结点外,剩下的部分也应该是和删除pos位置的函数的逻辑是一样的。
6.销毁链表
销毁链表的函数,当我们不再使用链表时,我们要记得调用这个函数对链表进行销毁,防止造成内存泄露。
//销毁链表
void SListDesTroy(SLTNode** pphead)
{
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* del = cur;
cur = cur->next;
free(del);
del = NULL;
}
}
销毁链表函数的逻辑比较简单,即对链表进行遍历,然后依次删除每一个结点,这里我们也尽量养成好习惯,只要使用free函数释放了空间,就将指向该空间的指针赋值为空,避免野指针等问题。
7.测试
进行测试的main函数,用来测试我们写的单向链表逻辑是否存在问题。
int main()
{
SLTNode* phead = NULL;
SLTPushBack(&phead, 1);
SLTPushBack(&phead, 2);
SLTPushBack(&phead, 3);
SLTPushBack(&phead, 4);
SLTPrint(phead);
SLTPopBack(&phead);
SLTPrint(phead);
SLTPopBack(&phead);
SLTPrint(phead);
SLTPopBack(&phead);
SLTPrint(phead);
SLTPopBack(&phead);
SLTPushFront(&phead, 1);
SLTPushFront(&phead, 2);
SLTPushFront(&phead, 3);
SLTPushFront(&phead, 4);
SLTPrint(phead);
SLTPopFront(&phead);
SLTPrint(phead);
SLTPopFront(&phead);
SLTPrint(phead);
SLTPopFront(&phead);
SLTPrint(phead);
SLTPopFront(&phead);
SLTPushBack(&phead, 1);
SLTPushBack(&phead, 2);
SLTPushBack(&phead, 3);
SLTPushBack(&phead, 4);
SLTPrint(phead);
SLTNode* pos = SLTFind(phead, 3);
SLTInsert(&phead, pos, 5);
SLTPrint(phead);
SLTInsertAfter(pos, 6);
SLTPrint(phead);
SLTEraseAfter(pos);
SLTPrint(phead);
SLTErase(&phead, pos);
SLTPrint(phead);
SLTErase(&phead, phead);
SLTPrint(phead);
SLTInsert(&phead, phead, 5);
SLTPrint(phead);
SLTInsert(&phead, NULL, 3);//尾插
SLTPrint(phead);
pos = SLTFind(phead, 4);
SLTInsertAfter(pos, 8);
SLTPrint(phead);
pos = SLTFind(phead, 8);
SLTErase(&phead, pos);
SLTPrint(phead);
SListDesTroy(&phead);
return 0;
}
总结
本次单向链表的实现到这里就已经完成了。不过其实我们很容易发现它存在的一些缺点,比如在尾插等操作时我们总是需要先遍历链表去寻找目标结点的前一个结点,插入的时候需要对链表是否为空进行分类讨论,遍历只能单向遍历等。所以我们在实际存储数据的时候基本上不会使用单向链表,而是使用双向链表,但我们实现这个单向链表也并不是没有意义,因为我们不仅可以提高编写代码能力,还可以加深对链表这种数据结构的理解,当然最重要的是我们虽然不太会直接用单向链表来存储数据,但它可以作为一些其他高级数据结构的子结构,比如哈希表,所以单向链表还是值得我们去亲自实现一下的。
如需源码,可在我的gitee上找到,下面是链接。
单向链表源码
如对您有所帮助,可以来个三连,感谢大家的支持。
每文推荐
张韶涵–梦里花
林俊杰–无尽的思念
韦礼安–如果可以
学技术学累了时可以听歌放松一下。