深入解析数据结构之单链表
目录
- 一、单链表
- 1.1 概念与结构
- 1.1.1 节点/结点
- 1.1.2 链表的性质
- 1.1.3 链表的打印
- 1.2 单链表的实现
- 1.2.1 尾插
- 1.2.2 头插
- 1.2.3 尾删
- 1.2.4 头删
- 1.2.5 查找
- 1.2.6 在指定位置之前插入数据
- 1.2.7 在指定位置之后插入数据
- 1.2.8 删除pos节点
- 1.2.9 删除pos之后的节点
- 1.2.10 销毁链表
- 总结
前言
在上一篇文章中深入解析数据结构之顺序表中
顺序表的特点有以下几点:
- 中间/头部的插入删除,时间复杂度为O(N)
- 增容需要申请新空间,拷贝数据,释放旧空间。这其中有不小的消耗
- 增容一般是呈2倍的增长,势必会有一定空间的浪费。例如当前容量为100,满了以后增容到200,再继续插入5个数据,后面没有数据插入了,就浪费95个数据空间。
接下来写的该数据结构就可以很好的解决这些缺陷了:
- 其头部插入删除,时间复杂度为O(1)
- 不需要增容
- 不存在空间浪费
一、单链表
1.1 概念与结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
即其逻辑结构:是线性的
物理结构:不一定是线性的
在我们日常生活中的火车在淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。
1.1.1 节点/结点
在链表中,也存在这样的“车厢”
内存中的图示大概如下:
与顺序表不同的是,链表中每节车厢都是独立申请下来的空间,称为节点/结点
节点的组成有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)
图中指针变量plist保存的是第一个节点的地址,称plist此时指向第一个节点,如果希望plist指向第二个节点,只要修改plist保存的地址内容即可
//空节点
int* plist = NULL;
链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间),需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点
1.1.2 链表的性质
- 链式结构在逻辑上时连续的,在物理上不一定连续
- 节点一般是从堆上申请的
- 从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续
这里定义一下链表的结构,由于链表是由一个个节点组成,所以定义链表的结构即定义节点的结构
SList.h
1.1.3 链表的打印
函数声明:
//打印单链表
void SLTPrint(SLTNode* phead);
SList.c
test.c
1.2 单链表的实现
1.2.1 尾插
尾插操作分两步
- 申请一个新节点把数据放入
- 将原来的尾节点和新申请的节点连接起来
函数声明:
//尾插
void SLTPushBack(SLTNode* phead, SLTDataType x);
因为插入操作首先都要申请一个节点把数据放进去,所以这里单独设计一个函数来完成该操作,该函数要返回一个指向新节点的指针
SList.c
test.c
但是这里问题来了,可以看出,并不是所预测的1 -> 2 -> 3 -> 4 -> NULL,这里调试一下
可以发现形参的改变没有影响实参,是传值调用。然而有兄弟就说了这里plist指针里存的是地址呀,怎么会是传值呢
如图有两个变量a和pa,pa变量是一个指针,存放的是a变量的地址,而pa也有自己的地址,pa变量的创建也是向内存申请一块空间,这块空间有自己的地址,这里指针变量存的是a的地址,而不是自己的地址。
这里要形参的改变影响实参,应该把plist的地址传过去,而不是把其保存的地址传过去
传一级指针的地址,形参要用二级指针接收,更改后代码如下:
//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x);
注意:pphead是一级指针的地址,对其解引用一次就是一级指针,也就是plist,ptail是一级指针,其指向* pphead
补充一下:pphead是一级指针的地址,如果pphead为空,一级指针都无法解引用,而* pphead是指向第一个节点的地址,第一个节点可以为空,所以断言的时候只要pphead不能为空就可
1.2.2 头插
补充:这里phead就是* pphead
函数声明:
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);
pphead始终指向第一个节点,头部插入节点,第一个节点始终会变为新的节点,因为头节点会发生改变,所以这里使用二级指针
函数定义:
测试函数:
1.2.3 尾删
在尾删中,最后一个节点(此时也是头节点)也能被删除,也就是头节点变为空,头节点会发生改变,函数声明时候也就是** pphead
函数声明
//尾删
void SLTPopBack(SLTNode** pphead);
函数定义
这串代码看似是没有问题的,但是运行之后却有问题
这里返回码不仅为负数,第四次删除的结果应该为NULL才对
这就是因为在只有一个节点的情况下,prev->next是对空指针进行解引用了。这种情况下直接讲头节点删除就为空了,不需要再找尾节点的前一个节点了
改正后代码如下:
注意:在链表中每个节点都是独立的,可以单独申请或释放一个节点
1.2.4 头删
函数声明:头删即头节点不断地在改变,这里依旧使用二级指针
//头删
void SLTPopFront(SLTNode** pphead);
简单总结一下:
单链表与顺序表的特点相反,链表中尾部的插入删除时间复杂度为O(N),头部的插入删除时间复杂度为O(1),所以没有完美的数据结构,其具体的使用要根据当下的场景
1.2.5 查找
函数声明:
//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
函数定义:
没有找到返回NULL,找到了返回一个指向当前节点的指针
测试函数:
1.2.6 在指定位置之前插入数据
函数声明:
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
因为有可能在头节点之前插入数据,所以这里函数声明中依旧使用二级指针,这样才能通过传址操作使得头节点的操作在函数外部生效
看似没有问题,然而出现了报错,这是因为若pos为第一个节点,此时prev也是第一个节点
持续循环,此时prev为空了,再向下推在while()判断条件里,就是对空指针进行解引用了
若pos为第一个节点,就不需要找pos的前一个节点,而是在头节点的前面直接插入一个新的节点
1.2.7 在指定位置之后插入数据
函数声明:
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
注意,这里如果要在3和4之间插入一个新的节点,不可以从前向后依次连
pos->next = newnode;
newnode->next = pos->next;//pos->next已修改,找不到4的节点了
所以先连newnode和4,再连3和newnode
1.2.8 删除pos节点
函数声明:
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos);
基于上面代码就可以发现链表的特点就是pos前的节点无法直接找到,要通过头节点遍历寻找,pos之后的节点很好找
注意:这里不能先释放3这个节点,否则无法通过pos找4这个节点,此时pos就是野指针了,不能对野指针解引用找下一个节点
可以看出这里的返回码依旧有问题
参考前面例子,pos为头节点需要特殊处理
1.2.9 删除pos之后的节点
函数声明:
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos);
这里依旧是先将2和4牵起来,再将3释放掉,不可以先释放3
1.2.10 销毁链表
函数声明:
//销毁链表
void SListDestroy(SLTNode** pphead);
因为销毁链表需要把所有的节点销毁掉,包括头节点,所以依旧传二级指针
这里用next存要销毁节点的下一个节点,当pcur的值不为空,就销毁,接下来pcur走到2这个节点,此节点不为空,在释放第二个节点之前,让next走向3这个节点,释放完成,pcur指向下一个节点,以此类推
最后pcur为空,循环结束
销毁前调试结果如预期所想
继续调试下去销毁操作也是没有问题的,我这里就不展示了
循环结束后,* pphead始终保存第一个节点的地址,但是前面的空间已经还给操作系统了,所以此时* pphead是个野指针,最后将其置为NULL
这里的pcur和next没有置为空的原因是next是在while循环内部定义的变量,跳出循环后就失效了,pcur则是在函数内部,同理,置不置为空都可以,如果以养成好习惯为目的,我还是建议置为空的。
总结
以上就是数据结构之单链表的全部内容了,喜欢的兄弟们不要忘记一键三连给予支持哦~,也是盼着盼着终于等到开学了,主播在家自律性真是太差了!