携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第18天,点击查看活动详情
前言
在介绍完复杂度以后,接下来就是数据结构的学习了,先从最简单的线性表入手,本文是基于C语言实现的。
本文就来分享一波作者对数据结构单链表的学习心得与见解。本篇属于第二篇,主要介绍线性表的单链表的一些内容,一篇还讲不完,下篇接着讲。
笔者水平有限,难免存在纰漏,欢迎指正交流。
链表简介
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
实际中链表的结构非常多样:
-
单向或者双向
-
带头或者不带头
-
循环或者非循环
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构 :
- 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结 构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
- 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都 是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带 来很多优势,实现反而简单了。
单链表
单链表顾名思义就是单向的链表,链表与顺序表不同,链表在物理结构上是松散无序的,链表的基本组成单元我们称为结点,一个结点包括了数据和下一个结点的地址,通过指针就能把各个结点串联起来了。
结点的声明
声明一个结构体作为结点模板,包括数据和下一个结点指针。这里的命名仅供参考,个人风格明显,实际上能够准确表达意思即可。
typedef int SLLDataType;
typedef struct SLinkListNode
{
SLLDataType data;
struct SLinkListNode* next;
}SLLNode;
单链表的初始状态就是一个指向NULL的头指针,还未链接任何结点,所以压根就不需要所谓的初始化函数,不过销毁函数还是需要的,毕竟有可能链接了结点,而结点是动态开辟的,需要释放。
创建结点
我们要创建链表并进行各种操作,首先得创建结点,因为结点是链表的基本组成单位。由于创建结点这一行为在各种操作中可能会被广泛使用,我们不妨封装成一个函数。那好,结点能是临时的吗?不能,所以我们要把结点创建在堆区上,使用malloc开辟动态内存,把地址交给一个指针,再把这个指针保管的地址返回,返回这一步很重要!注意判断结点是否开辟成功,不成功的话也就搞不下去了,直接exit退出程序。在返回结点指针之前,先通过指针把结点初始化,其中的next指针得置为NULL。
SLLNode* CreateSLLNode(SLLDataType data)
{
SLLNode* node = (SLLNode*)malloc(sizeof(SLLNode));
if (node == NULL)
{
perror("malloc fail");
exit(-1);
}
node->data = data;
node->next = NULL;
return node;
}
单链表头插
与顺序表相比,单链表又是如何头插的呢?需要移动后面的数据吗?不需要,前面说过,链表的逻辑顺序是由指针链接实现的,也就是说,要把结点头插入链表,只需要改变指针链接的关系即可。
我们先来看一个问题:头插函数的形参应该如何设计?是传入SLLNode*类型的吗?我们创建单链表,首先会定义一个指向SLLNode类型的指针pList也就是头指针,然后根据需要传入这个pList的值或地址。地址?为什么要传指针的地址呢?
不知道读者的函数章节学的如何,我们知道,在函数传参时,形参是实参的一份临时拷贝,仅改变形参的值并不会影响实参,那如何通过形参来修改实参呢?那就把传值调用改成传址调用,把实参的地址传给形参,然后解引用形参就可以得到并改变实参的值。
现在知道为什么要传入指针的地址了吗?因为我们要获取并修改这个指针的内容。那形参就要设计一个指针的指针类型也就是二级指针——SLLNode**。还有一件事,这个二级指针能不能为NULL?不能,因为它要接收的是头指针的地址,那么正常情况下传入头指针的地址会是NULL吗?绝对不会,那要是传入NULL了怎么说?那肯定就是传入出错,所以要及时检测出来,就用assert(ppHead) 来检测。
为什么说要修改这个指针的内容呢?你想啊,要在头部插入一个结点,而头指针就是指向第一个结点的,插入就要改变指向关系,让头指针pList指向新插入的结点,所以要改变头指针。
那好了,讲了这么一大堆,到底怎么实现函数呢?
先创建新结点,这一步可以直接复用CreateSLLNode函数,再改变指向关系,那头指针就肯定得指向新结点嘛,设新结点指针为newNode,接收pList的二级指针为ppHead,那不就是*ppHead = newNode; 了嘛,搞定~......个屁啊!!哪里搞定了呀?如果原来链表为空倒还好,但是链表不为空的话这样做完全有问题。
问题在哪?这样做只是把新结点的地址给了头指针pList,那原来头指针指向的结点不就“断了线”吗?单链表具有严格的单向关系,只有头指针指向头结点,把头指针内容一改,不再指向原来的头结点,那不就没有方法再找到原来的头结点了吗?就好像警察局派遣卧底到黑帮,由于是绝密的,所以只有上一级唯一知道下一级是卧底,其他人都不知道,那如果上一级完蛋了,不就没有人知道卧底其实是警察而不是黑帮了吗?
如何修改?先把头指针的内容拷贝给新结点的next指针,这样新结点就指向了原来的头结点,再把新结点的地址拷贝给头指针,这样头指针就指向了新结点,同时还断开了头指针和原来的头结点的关系,这不就插好了嘛。
void PushFrontSLList(SLLNode** ppHead, SLLDataType tar)
{
assert(ppHead);
SLLNode* newNode = CreateSLLNode(tar);
newNode->next = *ppHead;
*ppHead = newNode;
}
单链表尾插
尾插函数的形参也要是二级指针的,为啥?你尾插又不关头指针的事,需要吗?需要,如果链表为空,是不是就变成了和头插类似?只需要把newNode的值给头指针pList,让头指针指向新结点即可。
当链表不为空时,先创建新结点,要从尾部插入结点,关键在于让尾结点的next指针指向新结点,那是不是要先找到尾结点啊,怎么找?还能怎么找,遍历过去呗。创建指针tail找尾,如果指向的结点的next指针不为NULL,就说明指向的结点还不是尾结点,那就继续向后找tail = tail->next; ,直到找到尾结点为止。
void PushBackSLList(SLLNode** ppHead, SSLDataType tar)
{
assert(ppHead);
SLLNode* newNode = CreateSLLNode(tar);
if(*ppHead = NULL)
*ppHead = newNode;
else
{
SLLNode* tail = *PPHead;
while(tail->next != NULL)
{
tail = tail->next;
}
tail->next = newNode;
}
}
拓展思考:
是不是觉得单链表尾插比较麻烦?毕竟要遍历链表,时间复杂度为O(n)。那有没有办法让时间复杂度降为O(1)呢?有的,可以给单链表像带着头指针一样时刻带着尾指针,让尾指针一直指向链表尾结点或NULL,每次插入或删除都要改变这个尾指针,这样的话尾插就不需要再遍历链表啦,直接通过尾指针来进行操作。不过要注意,即使带了尾指针,尾删也不会有所改进,为啥?因为尾删需要拿到尾结点前一个结点,终究还是要遍历找尾结点的前一个结点。
单链表头删
有人可能会说,这我知道,这还不简单吗,直接把第二个结点的地址给头指针pList,这样头指针就不再指向原来的头结点了,转而指向原来的第二个结点,这不就搞定了吗?NO!其实少了重要的一个步骤——释放内存!
在研究顺序表的时候,我们需不需要每删除一个元素就释放一次内存?完全不需要嘛,顺序表要释放是一起释放的,因为它们是一块儿申请的。但是啊,对于链表各结点,它们可是分别独立地创建的,而且是在堆区创建的,要删除的话就得把它的内存也一块儿释放掉,不然一旦删除了链接关系而没有释放内存,就再也无法释放该块内存从而造成内存泄漏。
所以要先备份pList的值,再把pList改成指向第二个结点,最后用备份的pList释放掉删除的结点。
如果链表已经为空还能再删吗?不能,这样做会对NULL指针解引用,程序会崩溃。所以我们还得检测一下*ppHead是不是NULL。
void PopFrontSLList(SLLNode** ppHead)
{
assert(ppHead);
assert(*ppHead);
SLLNode* del = *ppHead;
*ppHead = (*pphead)->next;
free(del);
}
单链表尾删
首先明确一个问题,链表为空能不能再删?不能,理由在讲头删时讲过了。
要尾删,是不是要先找到尾,那就是要先找尾,但是,这里的找尾和尾插时的找尾可不太一样。如果直接找到尾,然后释放掉,这时候会有什么问题?对啦,会出现野指针!因为尾结点前一个结点的next指针还保留着尾结点的地址,尾结点释放后,这个指针就是指向未知内存的野指针了。所以在这里,找尾并不是要真的找到尾,只需要找到尾的前一个结点就行了,可以怎么找?设指针tail,tail->next->next 就是下个结点的next指针,只要它不为NULL就tail = tail->next 向后移动,直到下个结点的next指针为NULL就说明下个结点就是尾结点,此时tail指向的就是尾结点的前一个结点,然后就释放掉尾结点并且把tail指向的结点的next指针置为NULL即可。
上面讲的是链表不只有一个结点的情况,和尾插类同,在遇到链表只有一个结点的情况时都要分情况讨论。
void PopBackSLList(SLLNode** ppHead)
{
assert(ppHead);
assert(*ppHead);
if((*ppHead)->next = NULL)
PopFrontSLList(ppHead);
else
{
SLLNode* tail = *ppHead;
while(tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
以上就是本文的全部内容了,感谢观看,你的支持就是对我最大的鼓励~