本篇博客主要会介绍链表这个数据结构的操作与应用,比较适合初学数据结构的小白,内容较为基础,但也涵盖了链表的许多常用操作,如果你还是数据结构萌新,可以进来看看哦!
这里我衔接一下上一篇博客的内容(顺序表的操作),不知道同学们在学完顺序表以后,心中有没有这样一个疑问,如果我要使用顺序表这个数据结构,那我每次使用都要申请空间,拷贝数据,释放旧空间,会有不小的损耗;其次,在中间和头部插入新数据的时候时间复杂度为O(N);还有,增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200, 我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。既然顺序表依然存在一些这样的缺点,那么有没有一种更优的数据结构呢,当然有~,它就是我们今天的主角——链表。
单链表
概念
链表是⼀种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
逻辑结构类似于上面的小火车,一节车厢连着另一节。
实际上在计算机的存储中,链表的结构是这样的,每一个大方块都是一个结点,每一个结点中都含有该结点所要存储的数据以及指向下一个结点的指针。
结点
与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,我们称之为“结点/结点” 结点的组成主要有两个部分:当前结点要保存的数据和保存下一个结点的地址(指针变量)。
链表中每个结点都是独立申请的(即需要插入数据时才去申请一块结点的空间),我们需要通过指针变量来保存下一个结点位置才能从当前结点找到下一个结点。
链表的性质
- 链式机构在逻辑上是连续的,在物理结构上不一定连续
- 结点一般是从堆上申请的
- 从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续
有了前面顺序表的基础,我们现在可以搭建一个结点:
//创建一个链表结点
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;//存储的数据
struct SListNode* next;//指向下一个结点,注意这里不可以写SLT* next,
//因为c语言是从上往下编译的
}SLTNode;
两个typedef的作用和上一篇顺序表的作用大致是一样的,这里不过多赘述。
所以,当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存,这个内存不仅要保存整型数据,也需要保存下一个结点的地址(当下一个结点为空时保存的地址为空)。当我们想要从第一个结点走到最后一个结点时,只需要在当前结点拿上下一个结点的地址就可以了。
链表的基础操作
这里会讲一些链表的基础操作,包括打印、尾插、尾删、头插、头删这些操作。
链表的打印
这里我先上代码,之后再给大家讲解
void SLTPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur)
{
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
这里 phead 是一个链表的头结点,cur 代表当前位置的结点,先让 cur 指向头结点,依次通过 next 指针找到下一个结点,在此之前,先打印当前结点的数据,当 cur 为空的时候跳出循环,这样就实现了链表的打印。
我们先来测试一下这个代码:
void test1()
{
//手动构建一个链表,先创建一个一个结点
SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* node2 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* node3 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* node4 = (SLTNode*)malloc(sizeof(SLTNode));
node1->data = 1;
node2->data = 2;
node3->data = 3;
node4->data = 4;
node1->next = node2;
node2->next = node3;
node3->next = node4;
node4->next = NULL;
SLTNode* plist = node1;
SLTPrint(plist);
}
这是运行后的结果,大家的运行结果是不是一样呢?
尾插
同样也是先上代码:
SLTNode* SLTBuyNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc");
return 1;
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
//链表为空
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* ptail = *pphead;
while (ptail->next)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
}
这里为什么用 SLTNode** pphead 二级指针来实现呢,我们知道在这里形参使用二级指针,那么实参应该传过来的是一级指针的指针,那么就是 &plist,要进行传址调用,通过形参要去改变实参。接着我们来理一下思路:既然要进行尾插,就是向末尾插入一个元素,那我们要先找到最后一个元素啊,怎么找呢?这里我们发现最后一个元素有一个特点:他的 next 指向为 NULL 那么我们就可以通过循环遍历每一个结点,当某一个结点指向的下一个结点为 NULL 时就跳出循环,然后把要插入的元素放到最后一个元素的下一个元素即可。再次之前我们要先把要插入的元素保存下来,所以我们创建了一个新结点 newnode ,自定义了一个函数SLTBuyNode 来申请空间和插入元素。
这里还有一个小细节需要注意:就是当链表为空时该怎么处理,其实很简单,当链表为空的时候我们直接插入一个元素就好了,插入的那个元素就是我们链表的第一个元素。
我们来测试一下这串代码:
void test2()
{
SLTNode* plist = NULL;
SLTPrint(plist);
SLTPushBack(&plist, 1);
SLTPrint(plist);
SLTPushBack(&plist, 2);
SLTPrint(plist);
SLTPushBack(&plist, 3);
SLTPrint(plist);
SLTPushBack(&plist, 4);
SLTPrint(plist);
}
这是测试后运行的结果,符合预期。
头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
有了之前的尾插,头插的思路就更容易想到:我们既然要向首位置插入一个结点,那么我们直接然让这个结点的 next 指针指向原链表的第一个结点不就好了吗,然后插入完成之后,链表的第一个结点就变成了我们插入的这个结点呀。
同样我们也来测试一下:
void test
{
SLTPushFront(&plist, 3);
SLTPrint(plist);
SLTPushFront(&plist, 2);
SLTPrint(plist);
SLTPushFront(&plist, 1);
SLTPrint(plist);
}
运行结果如上,同样符合预期。
尾删
先上代码
void SLTPopBack(SLTNode** pphead)
{
assert(pphead && *pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* ptail = *pphead;
SLTNode* prev = NULL;
while (ptail->next)
{
prev = ptail;
ptail = ptail->next;
}
prev->next = NULL;
free(ptail);
ptail = NULL;
}
}
我们来理一下思路:我们要把最后一个结点删除,那么我们就要让最后一个结点的上一个结点的 next 指针指向 NULL ,所以我们既要找到最后一个结点也要找到倒数第二个结点,所以我们创建了两个指针变量:ptail 和 prev,找最后一个结点的方法和之前是一样的,只是在每一次的循环之前先让 prev 走到 ptail 当前的位置,这样当 ptail 走到最后一个结点的时候,prev 指向的刚好就是倒数第二个结点。
同样这里也有一些小细节:当传过来的链表为空时:那我们就没有东西可以删了,所以我们直接释放掉 *pphead ,再将 *pphead 置为 NULL。
同样,这里我们也来测试一下:
void test
{
SLTNode* plist = NULL;
SLTPrint(plist);
SLTPushBack(&plist, 1);
SLTPrint(plist);
SLTPushBack(&plist, 2);
SLTPrint(plist);
SLTPushBack(&plist, 3);
SLTPrint(plist);
SLTPushBack(&plist, 4);
SLTPrint(plist);
for (int i = 0; i < 5; i++)
{
SLTPopBack(&plist);
SLTPrint(plist);
}
}
这里可以看到所有结果均符合预期。
头删
void SLTPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
这里的思路很简单,就是先创建一个新的结点 next ,让这个结点指向原链表的第二个结点,再把新链表的第一个结点置为 next 就行了。
测试一下:
void test
{
for (int i = 0; i < 4; i++)
{
SLTPrint(plist);
SLTPushBack(&plist, i);
}
for (int j = 0; j < 4; j++)
{
SLTPopFront(&plist);
SLTPrint(plist);
}
}
结尾
好了,以上就是本篇博客的所有内容了,喜欢的话记得点点赞哦~