前言
在上一节中我们提到了顺序表有如下缺陷:
在头部/中间的插入和删除数据都需要整体挪动,时间复杂度为O(N),效率低;
增容需要重新申请空间,可能需要拷贝数据,释放旧空间,会有不少消耗
增容一般设置2倍扩容,有可能会导致空间的浪费。例如当前容量为100,满了之后我们扩容到了200,但是我们只需要再插入5个数据即可,这样就会导致浪费了95个数据空间
基于顺序表的这些不足,我们设计出了链表
一、链表
1.链表的概念及结构
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表
中的指针链接次序实现的 。
顺序表的结构设计是一个连续的空间,连续的空间势必会导致顺序表的那些缺陷。所以链表打破了这样的结构设计。我们需要存储数据再当即开辟一块内存空间进行存储,但是这样我们要怎么将彼此之间联系起来呢?
如图,我们每一个节点中不仅有此节点所存储的数据,还存储了下一个节点的地址,我们可以根据此地址来找到链表的下一个节点,就好像他们被一根链子串起来一样。
从上图可以看出,链式结构在逻辑上是连续的,但是在物理上不一定连续,现实中的节点一般是从堆上随机申请出来的,每个节点在内存中的位置不一定相同,也就是说在内存中大概率不是连续存储的。
2.链表的分类
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
1.单向或者双向
2.带头或者不带头
3.循环或者不循环
虽然有这么多的链表的结构,但是我们实际中最常用的还是两种结构
1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结
构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
2. 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都
是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带
来很多优势,实现反而简单了,后面我们代码实现了就知道了。
二、单向链表的实现
由于单链表在其他许多数据机构中用于子结构去实现某些功能,另外在笔试面试许多题都是利用单向链表考察的,所以我们今天用C语言来实现单向链表,加深对单向链表结构和功能的理解
// 1 、无头 + 单向 + 非循环链表增删查改实现typedef int SLTDateType ;typedef struct SListNode{SLTDateType data ;struct SListNode * next ;} SListNode ;// 动态申请一个结点SListNode * BuySListNode ( SLTDateType x );// 单链表打印void SListPrint ( SListNode * plist );// 单链表尾插void SListPushBack ( SListNode ** pplist , SLTDateType x );// 单链表的头插void SListPushFront ( SListNode ** pplist , SLTDateType x );// 单链表的尾删void SListPopBack ( SListNode ** pplist );// 单链表头删void SListPopFront ( SListNode ** pplist );// 单链表销毁void SListDestory ( SListNode ** pplist );// 单链表查找SListNode * SListFind ( SListNode * plist , SLTDateType x );// 单链表在 pos 位置之后插入 xvoid SListInsertAfter ( SListNode * pos , SLTDateType x );// 单链表删除 pos 位置之后的值void SListEraseAfter ( SListNode * pos );
1.结构设计
typedef int SLTDataType;//重定义数据类型
typedef struct SListNode//链表每个节点的结构
{
SLTDataType data;
struct SListNode* next;//存放下一个节点的位置
}SLTNode;
链表的结构单位称为节点,每个节点中存放了需要存储的数据以及下一个节点的地址(指针),而我们说过,只要需要存储多种数据就需要利用结构体。所以链表每个节点的结构也不复杂,由一个我们自己定义的数据类型的变量data,和一个指向下一个节点的结构体指针next组成。
2.动态创建新节点
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.单链表头插
在头插之前我们需要注意的是,从上面单向链表的结构图我们可以发现,链表的最后一个节点是指向NULL的,而因为我们这里实现的单链表是不带头的,即单链表一开始就是空的,然后再通过我们插入数据从而存储数据。所以我们对单链表是不需要初始化,只需要直接定义一个单链表的结构体指针plist指向NULL即可
SLTNode* plist = NULL;
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
我们来理解这个代码
首先我们需要知道头插的这个过程
顾名思义就是创建了一个新节点在链表的前面插入。而我们先再次回顾一下单链表的结构,就像一条链子,总有头和尾,才便于使用。所以在单链表中我们是有一个结构体指针(自命名为plist)作为单链表的头;同时单链表的最后一个节点中的结构体指针next指向NULL,这就是结构体的尾。
而头插的过程就是,创建一个新节点在链表的头(前面)插入,所以我们需要将新节点中存放下一个节点地址的结构体指针next指向原链表的头,然后再将链表的链头(plist)指向新节点
到这我们会遇到一个难点:为何使用了二级指针?
我们知道也使用过:要通过函数改变变量int,我们需要使用址传递而不是值传递,即需要传递int的地址 int*,而要改变int*,需要传递int**。同理类比,现在plist是一个结构体指针,为你想要改变它,使其指向新节点,就需要传递结构体指针的地址,即是二级指针。
同时,结构体指针的地址是一定不为空的,plist指向NULL时,&plist肯定也不为空,所以我们需要对pphead进行断言保证代码可行性。
4.单链表尾插
尾插即是在单链表的尾巴插入新节点,所以我们第一步要干嘛?找尾巴
找到尾巴之后将其结构体中的结构体指针指向新创建的节点
而这个时候我们会发现,尾插时会有两种情况:1.链表为空 2.链表不为空。而两种情况的处理也不一样
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
//1.空
//2.非空
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
//找尾
SLTNode* tail = *pphead;
while (tail->next)
{
tail = tail->next;
}
tail->next = newnode;
}
}
到这有小伙伴又疑惑了,这tm怎么两种情况有一种要用二级指针有一种不用啊?
和上文的解释同理,你得看你要修改什么🚩当链表为空要尾插的时候,我们是要改变plist的指向,而plist的类型是SListNode*,是指针。所以要改指针就需要用指针的指针-->二级指针
当链表不为空的时候,我们改变的时候最后一个节点,也就是改变结构体,那要改结构体就用结构体指针咯✔️
5.单链表头删
单链表头删分为两个步骤:1.将链头指向第二个节点 2.释放第一个节点
这个时候有人就会想到我们上一篇的顺序表,顺序表删除之后没有释放空间,而为什么现在链表就要呢?我们知道,动态内存开辟出来的内存,你开辟一块,要释放的时候也只能释放一块,不能说释放其中的一部分
你可以理解为团购,你这个团购套餐买下来之后不能说其中一道菜不要然后退一点钱吧💤而我们链表的每一个节点都是单独malloc出来的,所以我们删除后要记得释放内存空间,不然会导致内存泄漏。
void SListPopFront(SLTNode** pphead)
{
assert(*pphead != NULL);
SLTNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
del = NULL;
}
看完以上代码,难道以为完了?NONONO
首先解释一个点,为什么还得重新创建一个变量del呢??这就涉及到单链表的一个缺陷,单链表顾名思义就是单向的,所以我们没办法通过后面的节点往前找前面的节点。所以在我们将链头指向第二个节点前,要先将第一个节点用一个临时变量存储下来,不然我们将再也无法访问第一个节点这一块的内存空间
按照我们的思路步骤的确就写出了以上的代码,运行起来看似能运行,但是我们忽略了一个单链表里面经常要考虑的情况,就是当链表删到为空呢?
当我们的链表删空了之后,再进行头删程序是会崩溃的,因为会出现空指针的问题,所以我们要进行判断
void SListPopFront(SLTNode** pphead)
{
//温柔检查
if (*pphead == NULL)
{
return;
}
//暴力检查
assert(*pphead != NULL);
SLTNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
del = NULL;
}
而对于检查,这里提供了两种方法,温柔和暴力🐶🐶温柔的检查我们挺常见,就结束程序,而为啥另外一个方法很暴力呢💥如图
6.单链表尾删
尾删和头删类似咯,就让链尾指向倒数第二个节点然后再释放最后一个节点即可
而我们单链表链尾的设计就是让最后一个节点中的结构体指针指向NULL,所以我们不仅要找到链表的最后一个节点释放它,还得找到链表的倒数第二个节点使其结构体指针指向NULL
void SListPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead != NULL);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
//找尾
SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next)
{
prev = tail;
tail = tail->next;
}
prev->next = NULL;
free(tail);
tail = NULL;
}
}
需要注意的是,我们应该将尾删分为两种情况:1.一个节点 2.多个节点
因为当你只有一个节点时也会出现空指针的问题
7.单链表销毁
void SListDestory(SLTNode** pphead)
{
assert(pphead);
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
我们的思路就是遍历咯,一个一个释放。而同样需要创建一个临时变量,不然你这个节点销毁完你就找不到下一个节点咯,销毁到最后不要忘记将链头指向NULL噢🎈
8.单链表查找
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
查找的代码没有什么复杂的地方,而单链表查找这个功能用处可大了。我们在单链表的修改、插入都需要用到单链表查找的这一功能。
甚至都不用单独写出修改这一函数,直接利用单链表查找就可以了
SLTNode* pos = SListFind(plist, 3);
if (pos)
{
//修改
pos->data *= 10;
}
else
{
printf("没有找到\n");
}
9.单链表在pos位置之后插入x
void SListInsert(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
代码如上所示,而我们选择实现在pos位置之后插入也是因为单链表的缺陷,就是单向,如果我们要写成在pos之前插入的话,就得需要找出pos的前一个节点,无法直接使用pos
三、双向链表的实现
单向链表实现之后会发现仍然会有许多不方便,比如尾插尾删你都需要遍历找尾再进行操作,除此之外每个接口实现的时候你都得需要考虑空链表时是否出现空指针的情况💤💤
所以为了解决这些缺陷我们还有一种更加实用的链表结构:带头双向循环链表
而对于这个双向链表我们同样也要最增删查改进行代码实现
// 2 、带头 + 双向 + 循环链表增删查改实现typedef int LTDataType ;typedef struct ListNode{LTDataType _data ;struct ListNode * next ;struct ListNode * prev ;} ListNode ;// 创建返回链表的头结点 .ListNode * ListCreate ();// 双向链表销毁void ListDestory ( ListNode * plist );// 双向链表打印void ListPrint ( ListNode * plist );// 双向链表尾插void ListPushBack ( ListNode * plist , LTDataType x );// 双向链表尾删void ListPopBack ( ListNode * plist );// 双向链表头插void ListPushFront ( ListNode * plist , LTDataType x );// 双向链表头删void ListPopFront ( ListNode * plist );// 双向链表查找ListNode * ListFind ( ListNode * plist , LTDataType x );// 双向链表在 pos 的前面进行插入void ListInsert ( ListNode * pos , LTDataType x );// 双向链表删除 pos 位置的结点void ListErase ( ListNode * pos );
1.结构介绍
此双向链表的结构看上去好像十分复杂,但是其实还好,并且得益于结构比较复杂,反而在代码实现时会发现结构所带来了许多优势。
何为带头?
如图我们会看到有一个节点名为“head”在链表的链头,我们也称之为”哨兵头“因为我们对链表的各种增删查改都不会去改变这个头节点,它就像哨兵一样一直位于链表的链头,而它的作用我们下文具体介绍
关于双向循环
看图就能很清楚的发现链表每个节点之间都前后链接起来(双向),而链表的尾节点和头节点也相互链接起来(循环)关于这两个特别的结构在下文代码实现的时候你就能感受到它的魅力了
2.创建链表
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}LTNode;
和上文的单链表同理,利用结构体创建链表结构,而因为结构不同所以代码也有所不同。双向链表的结构体中多了一个”prev“的结构体指针,用于实现双向,指向节点的前一个节点。
定义完结构后我们就要创建链表了(链表初始化),那就先从链头开始 ——> 哨兵位
LTNode* ListInit()
{
LTNode* guard = (LTNode*)malloc(sizeof(LTNode));
if (guard == NULL)
{
perror("malloc fail");
exit(-1);
}
guard->next = guard;
guard->prev = guard;
return guard;
}
哨兵位(链头)我们是直接malloc了一个节点(不存放数据)用于链头,而因为是带头双向循环链表,所以此时我们创建的链头结构是这样的
对于这个结构等接下来进一步代码实现便能理解
而关于带头(哨兵位) 的好处?
像单向链表,我们初始化之后进行插入是需要使用二级指针(原因如上文)而当我们有了头之后,插入节点便能直接插入了,因为你使头节点(结构体)中的next结构体指针指向你新创建的节点即可
3.双向链表尾插
void ListPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyListNode(x);
LTNode* tail = phead->prev;
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
我们当初在使用单向链表插入节点时,还需要考虑2种情况:此时为空链表or不为空链表。而现在并不需要,这便是带头双向循环链表的优势之一
看完图你会发现两种情况上面的尾插代码同样适用(带头的好处),而到这你也能理解为什么创建头节点时要设置那样的结构了。 而得益于双向循环,我们尾插时也不需要像单向链表一样去遍历找尾之后再尾插,而是能够直接找到链尾(双向循环的好处)
关于带头双向循环对于代码实现带来的好处下面就不再一一举出,老铁们用代码实现的过程中就能充分感受到✈️✈️
4.双向链表头插
void ListPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyListNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
newnode->prev = phead;
}
5.双向链表打印
void ListPrint(LTNode* phead)
{
assert(phead);
printf("guard<=>");
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("\n");
}
6.双向链表尾删
void ListPopBack(LTNode* phead)
{
assert(phead);
assert(!ListEmpty(phead));
LTNode* tail = phead->prev;
LTNode* prev = tail->prev;
prev->next = phead;
phead->prev = prev;
free(tail);
tail = NULL;
ListErase(phead->prev);
}
删除的话,我们应该考虑到链表为空需要停止删除的情况,而因为头删也需要判断,所以在此我便封装了一个判断链表是否为空的函数,然后直接进行断言
bool ListEmpty(LTNode* phead)
{
assert(phead);
//if (phead->next == phead)
// return true;
//else
// return false;
return phead->next == phead;
}
7.双向链表头删
void ListPopFront(LTNode* phead)
{
assert(phead);
assert(!ListEmpty(phead));
LTNode* tail = phead->next;
phead->next = tail->next;
phead->next->prev = phead;
free(tail);
tail = NULL;
}
8.双向链表销毁
void ListDestoty(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur!=phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
9.双向链表查找
LTNode* ListFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
链表中的查找功能其实挺重要,因为后面实现的接口都得先找出那个pos再进行操作,而其实上也包括了增删查改中的”改“你若想改变链表中的某个数据就先调用查找将他查找出来然后直接修改即可✔️✔️
🎈🎈🎈🎈🎈🎈🎈🎈🎈
关于剩下的”指定位置插入“和”指定位置删除“两个接口的实现便是重头戏了,不信你看
10. 在pos的前面进行插入
void ListInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = BuyListNode(x);
LTNode* prev = pos->prev;
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
代码实现的逻辑如图,但是!!如果我这样调用呢:ListInsert(head->next,x)
哦??在头节点指向的下一个节点的前面插入?不就是相当于在头节点后插入新节点吗?那这不就是头插吗?所以我们头插的代码可以直接改成 ListInsert(head->next,x)
那?如果我这样写呢:ListInsert(head,x) ??如图所示
这不变成尾插啦??因为我们遍历双向链表是从哨兵位指向的节点开始,到哨兵位的前一个节点就结束,所以如图,这个逻辑结构便就是尾插。所以我们是不是可以直接将尾插的代码改成 ListInsert(head,x)? coooool!!!
11.删除pos位置的节点
void ListErase(LTNode* pos)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
pos = NULL;
}
道理同上,利用带头双向循环这一结构的优势,我们可以将头删的代码改成:ListErase(phead->next); 将尾删的代码改成:ListErase(phead->prev);
四、双向链表代码总展示
头文件.h
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}LTNode;
LTNode* ListInit();
LTNode* BuyListNode(LTDataType x);
void ListPushBack(LTNode* phead, LTDataType x);
void ListPrint(LTNode* phead);
void ListPushFront(LTNode* phead, LTDataType x);
bool ListEmpty(LTNode* phead);
void ListPopBack(LTNode* phead);
void ListPopFront(LTNode* phead);
size_t ListSize(LTNode* phead);
LTNode* ListFind(LTNode* phead, LTDataType x);
//在pos之前插入
void ListInsert(LTNode* pos, LTDataType x);
//删除pos位置
void ListErase(LTNode* pos);
void ListDestoty(LTNode* phead);
源文件.c
LTNode* ListInit()
{
LTNode* guard = (LTNode*)malloc(sizeof(LTNode));
if (guard == NULL)
{
perror("malloc fail");
exit(-1);
}
guard->next = guard;
guard->prev = guard;
return guard;
}
LTNode* BuyListNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
void ListPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
//LTNode* newnode = BuyListNode(x);
//LTNode* tail = phead->prev;
//tail->next = newnode;
//newnode->prev = tail;
//newnode->next = phead;
//phead->prev = newnode;
ListInsert(phead, x);
}
void ListPrint(LTNode* phead)
{
assert(phead);
printf("guard<=>");
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("\n");
}
void ListPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
//LTNode* newnode = BuyListNode(x);
//newnode->next = phead->next;
//phead->next->prev = newnode;
//phead->next = newnode;
//newnode->prev = phead;
ListInsert(phead->next, x);
}
bool ListEmpty(LTNode* phead)
{
assert(phead);
//if (phead->next == phead)
// return true;
//else
// return false;
return phead->next == phead;
}
void ListPopBack(LTNode* phead)
{
assert(phead);
assert(!ListEmpty(phead));
//LTNode* tail = phead->prev;
//LTNode* prev = tail->prev;
//prev->next = phead;
//phead->prev = prev;
//free(tail);
//tail = NULL;
ListErase(phead->prev);
}
void ListPopFront(LTNode* phead)
{
assert(phead);
assert(!ListEmpty(phead));
//LTNode* tail = phead->next;
//phead->next = tail->next;
//phead->next->prev = phead;
//free(tail);
//tail = NULL;
ListErase(phead->next);
}
size_t ListSize(LTNode* phead)
{
assert(phead);
size_t n = 0;
LTNode* cur = phead->next;
while (cur != phead);
{
++n;
cur = cur->next;
}
return n;
}
LTNode* ListFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
void ListInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = BuyListNode(x);
LTNode* prev = pos->prev;
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
void ListErase(LTNode* pos)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
pos = NULL;
}
void ListDestoty(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur!=phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}