目录
介绍
单链表是一种链式存取的数据结构,用一组地址任意的存储单元存放线性表中的数据元素。链表中的数据是以结点来表示的,每个结点的构成:元素(数据元素的映象) + 指针(指示后继元素存储位置),元素就是存储数据的存储单元,指针就是连接每个结点的地址数据。
对于单链表来说,有一个类似的概念叫做顺序表,他们都能存储数据向后遍历,但是顺序表与链表不同,他们各自有自己的优缺点
顺序表缺点:
头部或中间的插入删除的时间复杂度为O(N)
增容是异地扩容,消耗大
扩容一般为2倍增长,造成浪费
解决不扩容,按需申请释放,头部中间插入删除需要挪动数据的问题
造成这些问题的原因:连续的物理空间
空间的释放不能分期,申请多少释放多少
单链表最后一个节点的指针指向NULL
顺序表优点:
空间连续、支持随机访问
链表优点:
任意位置插入删除时间复杂度为O(1)
没有增容问题,插入一个开辟一个空间
缺点:
不支持随机访问
在日常使用中,如果我们知道空间大小,或需要随机访问,就可以使用顺序表,这里我们介绍单链表的使用方法
单链表的增删查改
在写单链表的增删查改之前,我们首先需要创建一个单链表,也就是说,我们要写一个初始化函数
我们先新建一个头文件和两个.c源文件,test.c和Slist.c在头文件中创建单链表结构体,而要想创建链表,我们需要知道链表的基本原理,链表是通过结构体和结构体指针链接在一块的,每一个节点都是一个空间,而这个空间里不仅有存储的数据,还有指向下一个空间的指针,即结构体嵌套,这样一个接一个的链接在一起,就成为了链表
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
//一个结构体的地址
//结构体嵌套,用结构体指针,结构体不能直接嵌套结构体
}SLTNode;
void SLTPrint(SLTNode* phead);
void SLPushFront(SLTNode** pphead, SLTDataType x);
void SLPushBack(SLTNode** pphead, SLTDataType x);
void SLPopFront(SLTNode** pphead);
void SLPopBack(SLTNode** pphead);
SLTNode* STFind(SLTNode* phead, SLTDataType x);
//在pos之前插入
void SLInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
//删除pos位置的值
void SLErase(SLTNode** pphead, SLTNode* pos);
void SLDestroy(SLTNode** pphead);
创建好后如上图,对于链表中存储数据的类型,我们可以重新定义为SLTDataType,让后面的数据类型都称作SLTDataType,这样如果我们需要储存不同类型的数据的话,只需要在头文件中修改相应的类型即可,之后,我们用pphead在函数中表示这个链表,在test.c文件中创建一个plist指针用来表示我们创建的链表,这也能解释为什么我们的函数都是二级指针,即改变一级指针需要用二级指针,改变结构体指针,就需要传结构体指针的地址,这里可能有同学有疑问:那我们能不能不用二级指针呢?当然可以,如果我们将void类型改为结构体类型,让每个函数都有返回值,再让plist去接受每个函数的返回值,那么也能达到我们的目的,但显然这样的方法不如二级指针便利。
增
在链表中增加一个数据,看起来十分简单,也确实十分简单,我们需要创建一个函数,用来malloc一个新的空间,这样才能做到对链表中的数据进行添加,增加数据有头插和尾插
SLTNode* BuyLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail");
return;
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
这是创建节点的函数,要注意创建节点后需要对新节点进行赋值,避免出现野指针。
头插:
我们只需要将buyltnode函数创建好的空间链接到链表中即可,也就是说,用指针将他们连接起来,当链表为空的时候怎么办呢?显然,这是头插,是从链表的表头开始插入,与链表是否为空无关,他都可以正常插入,不需要分情况讨论。
如上图,连接节点使用next指针,即newnode->next = *pphead,连接后,我们知道,pphead是链表的头,newnode不过是临时存储新空间的变量,我们不可能用newnode来作为新表头进行操作,所以需要将*pphead = newnode,将pphead的位置移动到newnode处,这样一来,头插就实现了
void SLPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
//链表为空是*pphead为空,pphead与*pphead不同,pphead为空解引用就出现空指针
//他是头指针plist的地址
SLTNode* newnode = BuyLTNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
尾插:
尾插的逻辑与头插大致相同,但是,我们需要判断一下链表是否为空,否则就会出现这样的错误写法
void SLPushBack(SLTNode* phead, SLTDataType x)
{
SLTNode* tail = phead;
while (tail != NULL)
{
tail = tail->next;
}
SLTNode* newnode = BuyLTNode(x);
tail = newnode;
}
这个函数错误很多,首先没有用二级指针,这导致他无法对plist进行修改,都是形参,其次,当链表为空时,tail会直接脱离链表,即便传值成功,也不会连接到链表中,且tail是局部变量,出了这个函数后,会自动销毁
所以,我们应该这样写:
void SLPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuyLTNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
//两种情况:空链表和非空链表
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
在尾插前,先判断该链表是否为空,为空则让头结点pphead接收第一个尾插节点,然后创建局部变量tail,让他遍历到链表的尾部,然后让tail->next = newnode,将节点与链表连接起来。这里可能有几个疑问,比如:while遍历时可不可以用tail!=NULL?这里的tail出了函数后也会销毁,为什么数据能正确传入?对于第一个问题,当然不可以,如果是tail!=NULL的话,while会遍历到tail==NULL时停止,此时tail指向NULL,脱离了链表,指向了空,再传值给他就没有了意义,同理,对于第二个问题,tail是在pphead不为空的前提下创建出来的一个变量,用来遍历这个链表,他始终指向的是链表的各个节点,那么传值给他,改变的也是链表的节点
这里再强调一下指针的问题:
这里要做的不是把newnode给phead,而是给外部传进来的plist
在TestSList1()中的SLPushFront(plist, 1)是把plist实参传给了形参//TestSList1()即测试用函数,存在于test.c文件中
SLPushFront中的phead是形参,即phead的改变不会影响到plist
比如,要改变的是int型的的数据,那么要传int型的指针
在函数中,要解引用,即改变的是*p1,*p2,改变的是指针指向的内容
同理,要改变一个指针变量的内容,比如
int a = 0 int* px = &a
那么就用int** pp1 传&px进去。
所以,要改变结构体指针,就要传结构体指针的地址
尾插的本质是上一个节点连接到下一个节点,要让tail找next指向为空的节点
不传地址无法改变plist,即便尾插成功也无法打印出来
要改变什么要用他的地址,尾插只有第一个节点要用二级指针,让plist指向
即改变结构的指针plist,但是一个函数不能用二级指针和一级指针
所以直接用二级指针就行了,解引用就是一级
删
删除数据与插入一样,也有头删和尾删,在写删除的时候要注意,有数据的空间才可以删除,如果一个链表为空,那他就不能进行删除,否则会导致内存泄漏,因此,我们需要在函数中加入断言,来判断链表是否为空:assert(*pphead),在这里讲一下前面的函数中的断言assert(pphead),可能有同学会问,这不就是防止链表为空的断言吗?其实不是,pphead与*pphead有很大差异,*pphead是一个指针,指向的是链表,而pphead不是,pphead为空解引用就出现空指针。
头删:
头删其实很简单,当我们写入断言后,我们甚至不需要判断他的下一个数据是否为空,我们只需要写一个”标记节点“,将pphead此时的位置存入这个标记节点,然后让*pphead = (*pphead)->next;
void SLPopFront(SLTNode** pphead)
{
assert(*pphead);
assert(pphead);
/*if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* del = *pphead;
//*pphead = del->next;
*pphead = (*pphead)->next;
free(del);
}*/
SLTNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
}
注释中的函数为没有断言*pphead的写法。
尾删:
尾删的大致逻辑与头删一样,不同的是,他不能像头删一样一边遍历一边删除,尾删还是需要通过局部变脸遍历链表找到尾结点进行删除,也就是说,在写入断言的同时,我们仍然需要对表头的下一个数据进行判断是否为空,防止局部变量tail指向空导致出错。
void SLPopBack(SLTNode** pphead)
{
assert(*pphead);
//一个节点或多个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
/*SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next)
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;*/
SLTNode* tail = *pphead;
while (tail->next->next)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
注释中的方法运用了“标记节点”,将数据存入prev然后再让tail进行遍历,与头插的方法一致,但效率不高代码繁琐,不如下面的方法。
查
如果上面的内容都看懂了的话,查找函数就太简单了,创建一个cur变量来遍历链表,当找到与要查找的数据相同的节点时返回。
SLTNode* STFind(SLTNode* phead, SLTDataType x)
{
//assert(phead);
//没有数据就不能查是吧
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
当然这里不用断言,理由很简单,没有数据也能使用查找,不会影响链表
改
修改节点内容可以通过查找函数结合来实现,先用查找函数找到该节点,然后再进行修改
void SLModify(SLTNode** pphead, SLTDataType x)
{
SLTNode* cur = STFind(*pphead, x);
SLTDataType m;
scanf("%d", &m);
cur->data = m;
}
销毁函数
在链表使用完毕后,我们可以用一个销毁函数来销毁我们所使用的链表,要注意的是,链表是由各个开辟的空间连接起来的表,因此销毁函数需要我们创建一个局部变量来进行遍历并销毁。
void SLDestroy(SLTNode** pphead)
{
assert(pphead);
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
最后不要忘了将*pphead进行置空,避免野指针。
总结
单链表是学习数据结构不可或缺的一环,如果单链表能够学会,这证明我们对于指针等方面的理解进一步深化,也为后面的学习打下了基础,希望各位多多点赞,多多努力!:D