上一节我们进行了数据结构中的顺序表的模拟式现,今天我们来实现一下另外一个数据结构:单链表。
我们在实现顺序表之后一定会引发一些问题和思考:
1.顺序表在头部和中间插入数据会用到循环,时间复杂O(N)
2.顺序表增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗
3.顺序表增容一般是成二倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了之后增 容到200,再继续插入五个数据,后面没有数据插入了,那么就浪费了95个数据空间。
每一种数据结构在实际应用中都有自己的优势和劣势
思考:如何解决上述问题呢?
我们引入另外一种数据结构:单链表。
一.单链表的结构和概念
概念:逻辑结构:连续 物理结构:非连续。
结构:
1.结点
与顺序表不同的是,链表里的每个数据都是存储在独立申请下来的空间中(结点)的。
结点主要由两部分组成:当前结点要保存的数据和保存下一个结点的地址(指针变量)
图中指针变量plist保存的是第一个结点的地址,我们称plist此时指向第一个结点,如果我们希望plist指向第二个结点时,只需要修改plist保存的内容为0x0012FFA0.
链表中的每个结点都是独立申请的(即需要插入数据时才去申请一块结点的空间),我们需要通过指针变量来保存下一个结点位置才能从当前结点找到下一个结点。(构建每个结点之间的联系)
2.链表的性质
1.链表在逻辑结构(想象)上是连续的,在物理结构(实际)上不一定连续。
2.结点的申请一般使用malloc函数,实在堆空间上申请的。
3.从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,也可能不连续。
结合C语言阶段的知识,我们可以给出每个结点对应的结构体代码:
假设当前保留的结点中的数据为整型:
cpp
struct SListNode
{
int data;//结点数据
struct SListNode* next;//指针变量用于保存下一个结点的地址
};
当我们想要保存一个整型数据的时候,实际上是向操作系统malloc申请了一块内存,这个内存不仅要保存整型数据,也需要保存下一个结点的地址(当下一个结点为空时保存的地址为空)。
当我们想要从第一个结点走到最后一个结点时,只需要在当前结点拿到下一个结点的地址就可以了。
二:模拟实现单链表
0.定义链表的结构
链表的核心是结点,定义链表的结构就是定义结点的结构。
至于之所以要将int进行typedef重命名,参考上一篇博客------------顺序表。
1.链表内容的打印
打印链表内容之前,先来构建一个链表。
这一步的思路是将链表的首结点(火车头)作为函数参数传给函数,接着遍历链表依次打印,直到打印完所有结点数据为止。
2.创建一个新结点
3.尾插
(单链表的尾部插入结点)
在插入结点之前首先你要创建一个结点,在判断链表是否为空,如果为空,插入到第一个位置(直接赋给*pphead)就可以了。如果此时单链表已有结点,就循环找到单链表的最后一个结点,在将申请带来的新结点插入到尾部(构建尾结点和新结点的关系)
这里需要注意:
1.函数传参时一定传的是phead的地址,用二级指针接收,否则无法构建新的指向关系(传值调用VS传址调用)(传值的话形参是实参的临时拷贝,出了函数作用域后形参销毁,构建不起实参和新结点的指向关系)(只能构建起形参与新结点的指向关系)。
2.遍历链表时尽量创建一个新的指针去遍历,而不是使用*pphead去遍历,让*pphead始终指向首结点,否则就不容易再次找到头节点了。
4.头插
在单链表的头部插入数据(将申请来的新结点作为单链表的首结点)
头插的话就不用考虑链表内容是否为空了,因为即使链表内容为空,*pphead==NULL,上面程序的逻辑也能解决问题。
单链表头插结点的时间复杂度为O(1).
5.尾删
删除单链表的尾结点
与头删不同的是:
1.尾删首先是要有结点可删,assert还要断言一下*pphead.
2.如果单链表中只有一个结点,直接释放就可以了。如果单链表中不仅仅有一个结点,不能直接释放。因为如果直接free(ptail->next);的话,尾结点就彻底找不到了,应该先保存尾结点的位置。
6.头删
删除单链表的头结点(首结点)
删除头结点不能之间将*pphead释放,否则后面的结点就找不到了。
因该先保存一下第二个结点,再去释放第一个结点。最后改变*pphead的指向。
注意:请看上图的89行,结构体和指针这两部分操作符的优先级问题比较复杂,在写程序时尽量加上括号,以防出现问题。
7.查找
与前面单链表不同的是,单链表中查找数据这一操作只是涉及到查找,不会涉及到改变phead的指向,因此采用传值调用即可。
如果找到了会返回指向带有x数据结点的指针,要是没找到,返回空指针。
查找这一操作经常与后续指定位置的操作结合使用。
8.在指定位置之后插入数据
凡是涉及到插入结点的操作,都要先创建一个新结点。
注意:在指定位置之后插入数据这一操作中上图116行和117行的操作不能交换顺序。
9.在指定位置之前插入数据
注意:
在指定位置之前插入数据首先要找到指定位置的前一个结点,再构建该结点和指定位置结点与新结点之间的关系。
这个操作要先检查一下是否pos就是头结点,如果是的话直接调用头插。
本操作137行和138行的内容是可以互换的。
10.删除pos位置的结点
要想删除pos位置的结点,要先找到pos位置前一个结点,构建完pos位置前一个结点和pos位置后面的一个结点的位置关系之后,再去释放pos位置的结点。
11.删除pos位置之后的一个结点
和10一样,删除结点(free释放结点动态申请的内存)之前要先构建pos位置结点和pos之后数两个位置的结点(要删除的结点的后面一个结点)之间的关系。
12.销毁链表
当链表使用完毕之后一定不要忘记将链表结点申请在堆空间上的动态内存回收
注意:
1.回收的方式是从前向后(因为从后向前的话不好弄,本节讨论的是单链表,是不能从后往前访问的)
2.不能直接释放首结点(否则后面就找不到了)。
3.释放每个结点之前都要保存一下下一个结点,否则就找不到了(释放不完全,没释放的也找不到了)。
三:单链表总结
相较于顺序表
单链表在中间或者头部插入或者删除数据时间复杂度为O(1),而顺序表为O(n).
单链表在尾部插入或者删除数据时间复杂度为O(n),而顺序表为O(1)。
不同的数据结构都有各自独特的优势(当然也有劣势)
没有哪个好哪个坏这一说法
看实际应用的场景,不同的场景要使用不同的数据结构!
完: