前言
在上一节中我们提到了顺序表有如下缺陷:
在头部/中间的插入与删除需要挪动数据,时间复杂度为O(N),效率低;
增容需要申请新空间,可能会拷贝数据,释放旧空间,会有不小的消耗;
增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到 200,如果我们再继续插入了5个数据,后面没有数据插入了,那么会浪费95个数据空间;
基于顺序表的这些不足,我们设计出了链表。
一、链表
1、链表的概念及结构
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
链表和顺序表的不同之处在于:顺序表不仅要求逻辑结构上也连续,还要求物理结构上连续;而链表只要求逻辑结构上连续,物理结构上可以不连续;
链表的结构图示如下:
从上面的图中我们也可以看出:链表在逻辑结构上连续指的是链表的每一个节点都记录着下一个节点的地址,我们可以根据此地址来找到链表的下一个节点,就好像它们被一根线连起来了一样;
而实际上链表的每一个节点都是在堆区上随机申请的,前一个节点的地址可能比后一个节点大,也可能比后一个节点小,二者之前其实并没有物理结构上的关系。
2、链表的分类
在实际应用中,链表根据带头/不带头、循环/不循环、双向/单向这三种选择一共可以组合出8种结构。
单向或者双向:双向链表对比单向链表来说,其结构体中会多一个结构体指针变量,用来指向前一个节点的地址。
带头或者不带头:带头与不带头其实区别就是链表最开始的时候会有一个节点,这个节点不用来存储数据,仅仅作为链表的头部使用,还是一个节点都没有。
循环或者非循环:非循环链表的最后一个节点的next指向NULL,而循环链表的最后一个节点的next指向链表的第一个节点。
3、最常用的两种链表
虽然链表有这么多中结构,但是我们实际中最常用还是以下两种结构:无头单向非循环链表和双向带头循环链表。
无头单向非循环链表
无头单向非循环链表结构最简单,一般不会单独用来存数据,实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等;另外这种结构在笔试面试中出现很多;其实如果不做特殊声明,一般情况下无头单向非循环链表指的就是我们的单链表;
带头双向循环链表
带头双向循环链表结构最复杂,一般用于单独存储数据;实际中我们使用的链表数据结构,都是带头双向循环链表;另外它虽然结构复杂,但是使用代码实现后会有很多优势,所以反而是链表中使用起来最简单的。
二、单链表的实现
由于单链表是其他结构链表学习的基础,且经常被用做其他数据结构的子结构,在笔试题中也最常被考到,所以下面我们用C原因来手动实现一个单链表,以此来加强我们对单链表的理解。
1、结构的定义(内部有元素数据+一个结构体指针)
实现,与顺序表一样,单链表也需要一个变量来data来记录数据,且我们应该对data的类型重命名,使得我们的链表可以管理不同类型的数据;其次,由于单链表中需要存储下一个节点的地址,所以我们应该有一个指向结构体的指针。
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;//存放下一个节点的地址
}SLTNode;
2、创建新节点
由于单链表的每一个节点都需要单独开辟,所以我们可以把创建节点封装成一个函数,避免在头插、尾插、任意位置插入这些位置重复实现。
需要注意的是,由于我们这里实现的单链表是不带头的,即单链表一开始就是空的,所以我们并不需要对其进行初始化操作,只需要定义一个指向NULL的节点指针 plist 即可。
SLTNode* BuySLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
3、在头部插入数据
特别注意:不管我们在什么地方插入数据,我们都需要传递二级指针,因为链表一开始是空的,所以我们在插入第一个数据的时候需要让 plist 指向我们新开辟的这一个节点,即头结点;而我们知道,要改变 int,需要传递 int*,要改变 int*,需要传递 int**,类比过来,这里的 plist 是一个结构体指针变量,我们想要改变它,让它从 NULL 变为第一个节点的地址,就需要传递结构体指针的地址,即二级指针才能实现。
也就是说:由于形参和实参建立联系了,但是形参想要真正的改变实参,那么必须比实参高一级才可以,比如我们的swap交换的时候,想要交换int类型的数据,那么函数参数就得是一级的
其次,我们在改变节点中的next指针的时候使用的是结构体指针,即一级指针,而并没有用到二级指针,这是因为我们修改节点中的next是对结构体进行操作,而要改变结构体我们只需要使用结构体指针即可,而不用像上面修改结构体指针一样使用二级指针。
如果我们使用带头节点的单链表就不需要传递二级指针,因为不管我们如何对链表进行操作,头结点都始终不会改变。但是我们一般不用这个结构,因为对于做题的角度而言,这个结构将来会带来一些不必要的麻烦
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
4、在尾部插入数据
在尾部插入数据我们需要先找到的尾结点的前一个节点,因为我们需要让前一个节点的next指针指向新开辟的节点,然后让新开辟的节点的next指向尾结点,这样才能让我们的链表链接起来。
而我们的单链表只能找到下一个节点的地址,想要找到前一个节点需要从头开始遍历,所以单链表尾插的效率是比较低的,时间复杂度为O(N),我们可以通过设计双向链表来解决这个问题。
void SListPopBack(SLTNode** pphead)
{
assert(pphead);
// 温柔的检查
if (*pphead == NULL)
{
return;
}
// 暴力检查
//assert(*pphead != NULL);
// 1、一个节点
// 2、多个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
// 找尾
/*SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
prev->next = NULL;
free(tail);
tail = NULL;*/
SLTNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
5、查找数据
查找数据不会改变头结点,所以我们只需要传递一级指针。
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
6、在pos位置前插入数据
和尾插一样,我们需要从头遍历链表,找到 pos 节点的前一个节点,让该节点的next指向新开辟的节点,使得链表成功链接。
// 在pos之前插入
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(pos);
if (pos == *pphead)
{
SListPushFront(pphead, x);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
// 暴力检查,pos不在链表中.prev为空,还没有找到pos,说明pos传错了
assert(prev);
}
SLTNode* newnode = BuySLTNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
7、在pos位置后插入数据
由于单链表在某一节点的前面插入数据时需要从头遍历寻找该节点的前一个节点,导致时间复杂度为O(N),所以人们为了提高单链表的效率,为单链表单独设计了在pos位置后插入数据的函数;除了单链表,其他数据结构插入数据都是在前面插入。
void SListInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
8、在头部删除数据
特别注意: 和插入数据一样,因为我们删除的可能是链表中的最后一个数据,即可能会改变 plist 的指向 (让 plist 重新指向 NULL),所以不管我们在什么地方删除数据,都需要传递二级指针。
其次,由于我们这里是删除数据,所以函数调用者需要保证调用此函数时链表中至少是含有一个数据的;所以我们对 *pphead (等价于 plist) 进行断言,当调用者错误使用此函数时,我们直接报错并介绍程序。
void SListPopFront(SLTNode** pphead)
{
assert(pphead);
// 温柔的检查
if (*pphead == NULL)
{
return;
}
// 暴力检查
//assert(*pphead != NULL);
SLTNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
del = NULL;
}
9、在尾部删除数据
在尾部删除数据面临着和尾插一样的问题,需要改变前一个节点的next指针,所以时间复杂度也为O(N)。
void SListPopBack(SLTNode** pphead)
{
assert(pphead);
// 温柔的检查
if (*pphead == NULL)
{
return;
}
// 暴力检查
//assert(*pphead != NULL);
// 1、一个节点
// 2、多个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
// 找尾
/*SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
prev->next = NULL;
free(tail);
tail = NULL;*/
SLTNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
10、删除pos位置当前的数据
// 删除pos位置
void SListErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(pos);
if (*pphead == pos)
{
SListPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
// 检查pos不是链表中节点,参数传错了
assert(prev);
}
prev->next = pos->next;
free(pos);
//pos = NULL;
}
}
11、删除pos位置后的数据
和在pos位置后插入数据一样,为了提高效率,人们也设计了一个在pos位置后删除数据的函数。
// 删除pos后面位置
void SListEraseAfter(SLTNode* pos)
{
assert(pos);
if (pos->next == NULL)
{
return;
}
else
{
SLTNode* next = pos->next;
pos->next = next->next;
free(next);
}
}
12、打印链表中的数据
打印数据也不会改变头指针,所以这里传一级指针;但是这里和修改数据不一样的地方是,当链表为空的时候我们打印的逻辑也是正常的,只是说调用此函数什么都不打印而已,但是我们不能对其断言让其为空时报错。
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
13、销毁链表
销毁链表需要将 plist 值为空,所以这里我们传递二级指针。
void SListDestory(SLTNode** pphead)
{
assert(pphead);
SLTNode* cur = *pphead;
//while (cur != NULL)
while (cur)
{
SLTNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
三、函数接口Slist.c完整代码
大家也可以去我的 Gitee 仓库中获取完整代码:SList/SList · 李正阳/日常学习 - 码云 - 开源中国 (gitee.com)
四、 最后大总结之各个函数需要注意什么(保持链表结构+完成函数需要达成的目的)
1、结构体内部包含的元素
有一个结构体指针是非常需要关注和注意的
2、关于使用二级指针的说明与总结
3、创建一个新的节点
注意不仅要将newnode的data修改成x ; 还需要将next修改成NULL
4、打印
一般不动phead 因为可能还要遍历别的东西,所以我们找了一个cur变量来代替pphead的位置
5、pushfront
1.newnode必须malloc节点,因为普通创建的除了函数作用域就会销毁,无法真正的将链表节点改变
2.newnode的下一个位置指向phead
3.更新一下头结点的位置,这个时候已经不是*pphead了
6、pushback
关于为什么尾插需要考虑到空节点和非空节点,我们需要了解的是:关于为什么参数需要使用二级指针,就是因为想要修改一级的,就必须升一级
但是对于尾插而言,和头节点的位置没有关系鸭,我往后面插入,头结点又不需要改变
这个时候问题就来了,我们的尾插为什么还是写成了二级指针,就是因为当节点为空的时候,就涉及到了头结点的改变,所以得分为俩种情况
1.头结点为空
*pphead=newnode
2.头结点不为空
需要利用tail指针找到最后一个元素的位置,但是需要注意的是,我们的tail可不可以去最后一个节点的下一个位置,因为我们想要真正的链接起来,需要用tail->next=newnode这样操作;而不是tail=newnode,这样是不可以的,所以我们的循环条件就得格外注意:while(tail->next!=nullptr) 我们必须写的是tail->next
7、popfront
链表的删除必须free,因为节点都是单程票,并不像是顺序表一样买的团购票,所以如果没有free的话,就会造成内存泄漏
1.检查一下没有删除过头吧,if(*pphead==nullptr)就得断言一下;STL的list底层也是这么写的
2.将*pphead的赋给一个tmp指针,然后头指针->next;并且将free(tmp),这才可以基本上大功告成(最后tmp指向空更规范一点)
8、popback
不能直接找到尾,直接将tail释放掉
那么就是经典的野指针问题(前一个节点的next指向出错)
9、Destroy
从前往后一个一个删除,先跳到next,再删除原先的cur,为了保证可以循环下去,还将cur->next的值保存为next指针,然后删除完后cur再去到next的位置,一直循环下去
10、Find
不用修改头结点,参数传递一级指针即可
如果找到了就返回,否则继续往下找
函数的返回值是节点,因为Find函数往往充当修改和插入的子集函数
11、Insert(pos的前一个位置)
1.如果位置是头的话,那么复用一下头插函数即可
2.我们需要找到pos的位置(用Find函数),我们也需要找到pos的前一个节点的位置,那么就需要定义while(prev->next!=pos),这样才可以找到;接着prev->next链接newnode,newnode->next链接pos即可
12、Insert(pos的后一个位置)
往后面插入要稍微简单一点,但是注意语句的次序!
pos->next=newnode;
newnode->next=pos->next
这样分析下来发现是不对的:
所以我们的语句应该颠倒一下!!!
13、Erase(pos位置本身)
关键核心在于找到pos的前一个位置,并且prev->next=pos->next;这一步是为了帮助链表结构的不被破坏性
free(pos);这一步才真正的删除了节点
14、Erase(pos的后一个位置)
拓展之!优化!在pos之前插入(时间复杂度是O(1))
这个优化方法不用写循环,可以堪称jio美~