双向链表
前言
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。链表可以有很多种分类,我们平常说的单向链表一般指的是不带头单向不循环链表,双向链表一般指的是带头双向循环链表。我们在上一章已经介绍了链表的分类和单向链表的实现,如果没有看过的可以先去看一下单向链表的章节,下面是链接。
单向链表
而这一章我们就用C语言来实现一个双向链表。
一、双向链表结构
//双向链表
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int LTDataType;
typedef struct ListNode
{
//。。。。
LTDataType data;
struct ListNode* prev;
struct ListNode* next;
}LTNode;
在C语言中我们使用一个结构体来代表链表的每一个结点。这里我们定义了一个ListNode的结构体来代表链表的结点并将其重命名为LTNode,在这个结构体中我们定义一个存储数据的变量data,再定义一个指向上一个结点的指针prev和一个指向下一个结点的指针next。在这里需要注意两点,第一,在这里和之前的顺序表和单向链表类似,我们将int重命名为SLTDataType,这样便于以后我们更改链表存储数据的类型;第二,我们在定义指向下一个结点的指针时不能写成LTNode* next,因为我们在定义该结点时还未进行重命名,所以在定义的结构体里面只能老老实实地写struct SListNode* prev和struct SListNode* next来定义这两个指针。
二、双向链表实现
1.申请新结点
//申请新结点
LTNode* BuyNode(LTDataType x)
{
LTNode* pnew = (LTNode*)malloc(sizeof(LTNode));
if (pnew == NULL)
{
perror("malloc");
exit(-1);
}
pnew->data = x;
pnew->next = NULL;
pnew->prev = NULL;
return pnew;
}
考虑到我们在插入链表时都需要进行动态开辟新结点的操作,所以我们在这里先单独写一个函数来进行申请新结点的操作,这样后面的插入函数里面就可以直接调用该函数申请新结点,避免重复写这段逻辑。在这个申请新结点的函数里面逻辑比较简单,就是利用malloc函数在堆上动态申请一个新结点,然后将插入的x的值赋值给data,然后将prev指针和next直接默认初始化为空指针,最后将该新结点作为返回值进行返回。
2.初始化和销毁
//初始化
//void LTInit(LTNode** pphead);
LTNode* LTInit()
{
LTNode* phead = (LTNode*)malloc(sizeof(LTNode));
if (phead == NULL)
{
perror("malloc");
exit(-1);
}
phead->next = phead;
phead->prev = phead;
return phead;
}
初始化链表的函数,因为我们实现的是带哨兵位头结点的链表,所以我们在使用前需要先申请一个新结点来作为哨兵位的头结点,然后又由于我们是一个循环的链表,即首尾相连的链表,所以我们在这里让头结点的prev指针和next指针均指向自己,这样就完成了初始化,最后我们将头结点作为返回值返回。这里顺便提一下,这个初始化函数可以写成void LTInit(LTNode ** pphead),如果这样写在调用该函数时就需要传入一个指针的地址,即二级指针pphead,不过这样写的话就可以不通过传返回值来改变函数外面的指针,只需对二级指针pphead解引用即可改变函数外面的指针。这两种写法可以根据自己的喜好来进行选择实现。
//销毁
void LTDestroy(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* del = cur;
cur = cur->next;
free(del);
del = NULL;
}
}
销毁链表的函数,这个跟我们之前写的单向链表的销毁基本没啥区别,只是将循环的结束条件判断从cur!=NULL变成了cur!=phead。当然我们为了保持一个好习惯,调用free函数释放空间后记得将指向该空间的指针置为空,避免野指针等问题。
3.判空和打印
//判空
bool LTEmpty(LTNode* phead)
{
if (phead->next != phead)
{
return true;
}
return false;
//return phead->next != phead;
}
对链表进行判空操作的函数,如果头结点的next指针指向自己,则说明只存在哨兵位的头结点,即链表为空,为空返回false,不为空返回true,不过这里使用bool类型需要包含stdbool.h的头文件。顺带一提,这个函数其实可以直接写一句return phead->next != phead节省行数,而这一句语句其实就和我们写的if语句是一个意思。
//打印
void LTPrint(LTNode* phead)
{
if (!LTEmpty(phead))
{
printf("NULL\n");
return;
}
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("\n");
}
打印链表的函数,在这个函数里面我们先调用LTEmpty函数判断链表是否为空,如果为空就打印NULL并直接返回。如果不为空再遍历链表然后对链表进行打印操作。
4.尾插和尾删
//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* pnew = BuyNode(x);
/*LTNode* tail = phead->prev;
pnew->next = phead;
pnew->prev = tail;
tail->next = pnew;
phead->prev = pnew;*/
pnew->next = phead;
pnew->prev = phead->prev;
phead->prev->next = pnew;
phead->prev = pnew;
}
尾插函数,既然是插入函数,所以我们一开始就调用BuyNode函数来申请一个新结点。之后就是对链表进行链接操作,这也是实现双向链表的难点。
这里的尾插操作即是将新结点插入作为尾结点的下一个结点,头结点的上一个结点,所涉及到的结点有头结点,新结点,尾结点三个结点,而这里需要改变的指针有新结点的prev指针及next指针,头结点的prev指针,尾结点的next指针这四个指针。我们需要将新结点的prev指针指向尾结点,next指针指向头结点,然后头结点的prev指针需要指向新结点,尾结点的next指针也需要指向新结点。这里如果搞不清这段逻辑的,建议可以通过画图的方式来先模拟实现一下。最后将这段逻辑转换为代码若下:
pnew->next = phead;
pnew->prev = phead->prev;
phead->prev->next = pnew;
phead->prev = pnew;
需要注意的是,这段代码其实不建议初学者直接这么写,因为这段代码的关联性是很强的,调换一下顺序啥的都会出错。所以我们建议初学者这样写:
LTNode* tail = phead->prev;
pnew->next = phead;
pnew->prev = tail;
tail->next = pnew;
phead->prev = pnew;
我们先用一个tail变量来记录尾结点,这样我们进行插入操作所涉及到的三个结点分别都有phead,pnew,tail三个变量记录,这样我们进行链接操作时就不用担心因为链接顺序错误而找不到其中某一个结点的问题了。这也是我们编写代码时的一个好习惯,当我们遇到一些逻辑比较复杂,关联性比较强的代码时,我们可以像这样多定义一些变量来帮助我们更好地理清思路然后再进行代码的编写。
//尾删
void LTPopBack(LTNode* phead)
{
assert(phead);
if (!LTEmpty(phead))
{
printf("无结点\n");
return;
}
LTNode* del = phead->prev;
phead->prev = del->prev;
del->prev->next = phead;
free(del);
del = NULL;
}
尾删函数,删除函数相比于插入函数就要简单不少,因为链接的部分简单很多。对于删除函数,我们上来应该先调用LTEmpty函数来进行判空,如果链表为空,说明没有元素能给我们进行删除了,那就应该直接提前返回。当然,如果不为空,我们就先用一个变量del来记录要删除的尾结点,然后进行链接操作。这里我们涉及到的结点就是头结点以及尾结点的前一个结点,我们在这里只需让头结点的prev指针指向尾结点的前一个结点,然后让尾结点的前一个结点的next指针指向头结点,这样就完成了链接操作。最后我们就只需使用free函数释放del结点,然后顺手将其置空。
5.头插和头删
//头插
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* pnew = BuyNode(x);
/*LTNode* next = phead->next;
pnew->next = next;
pnew->prev = phead;
next->prev = pnew;
phead->next = pnew;*/
pnew->next = phead->next;
pnew->prev = phead;
phead->next->prev = pnew;
phead->next = pnew;
}
头插函数,这个函数和尾插函数的逻辑非常相似,只是插入位置从头结点之前和变为头结点之后,如果你能独立地写完一个尾插函数,那么这个头插函数对于你来说应该就不成问题。
//头删
void LTPopFront(LTNode* phead)
{
assert(phead);
if (!LTEmpty(phead))
{
printf("无结点\n");
return;
}
LTNode* del = phead->next;
phead->next = del->next;
del->next->prev = phead;
free(del);
del = NULL;
}
头删函数,同理也和尾删函数的逻辑基本一致。
6.在pos位置插入和删除数据
//在pos位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x)
{
LTPushFront(pos, x);
}
在pos位置之后插入数据,这里我们就可以采用复用的思想。在pos位置之后插入数据,其实不就和在头结点之后插入数据一样嘛,而在头结点之后插入数据,不就是头插嘛,所以我们这里直接复用头插函数,我们传入pos来让其当头结点,这里就可以直接复用头插函数来实现在pos位置之后插入数据。
//删除pos位置数据
void LTErase(LTNode* pos)
{
assert(pos);
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
pos = NULL;
}
删除pos位置数据,这个函数的逻辑其实和前面的尾删和头删函数的逻辑其实也是基本一致的。这里我们涉及到的三个结点如下:
pos->prev pos pos->next
所以我们就只需对pos->prev结点和pos->next结点进行链接,我们只需让pos->prev结点的next指针指向pos->next结点,然后让pos->next结点的prev指针指向pos->prev结点即可完成链接。最后使用free释放pos结点并将pos指针置为空。
7.查找
//查找
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
查找函数,通过遍历链表进行查找操作,如果查找到就返回该结点的指针,如果没有找到就返回空指针NULL。
8.测试
进行测试的main函数,用来测试我们写的双向链表逻辑是否存在问题。
int main()
{
LTNode* phead = LTInit();
LTPushBack(phead, 1);
LTPushBack(phead, 2);
LTPushBack(phead, 3);
LTPushBack(phead, 4);
LTPrint(phead);
LTPopBack(phead);
LTPrint(phead);
LTPopBack(phead);
LTPrint(phead);
LTPopBack(phead);
LTPrint(phead);
LTPopBack(phead);
LTPrint(phead);
LTPopBack(phead);
LTPrint(phead);
LTPushFront(phead, 1);
LTPushFront(phead, 2);
LTPushFront(phead, 3);
LTPushFront(phead, 4);
LTPrint(phead);
LTPopFront(phead);
LTPrint(phead);
LTPopFront(phead);
LTPrint(phead);
LTPopFront(phead);
LTPrint(phead);
LTPopFront(phead);
LTPrint(phead);
LTPopFront(phead);
LTPrint(phead);
LTPushBack(phead, 1);
LTPushBack(phead, 2);
LTPushBack(phead, 3);
LTPushBack(phead, 4);
LTPrint(phead);
LTNode* pos = LTFind(phead, 3);
LTInsert(pos, 5);
LTPrint(phead);
LTErase(pos);
LTPrint(phead);
free(phead);
phead = NULL;
return 0;
}
三、优点和缺点
优点
动态大小:链表的大小可以在运行时动态改变,这使得它非常适合处理不知道确切大小的数据集合。
插入和删除操作效率高:相比于数组,插入和删除元素时不需要大量的数据移动,只需要更新相关的指针即可完成操作。插入和删除元素的时间负责度可达到O(1)。
内存利用率高:链表可以根据需要分配和释放内存,使得它的内存利用率很高,避免了不必要的内存浪费。
缺点
不支持随机访问:链表不支持随机访问,要访问某个特定位置的元素,通常需要从头节点开始依次遍历,直到找到目标节点,时间复杂度为O(N)。
额外的空间开销:每个结点除了存储数据外,还需要存储指向下一个结点或上一个结点的指针,这增加了额外的空间开销。
遍历速度较慢:对于单向链表来说,只能从前向后遍历;对于双向链表虽然可以双向遍历,但如果链表很长,遍历的速度仍然不如数组快。
CPU缓存利用率低:由于链表在内存中不是连续存储的,所以CPU的缓存命中率不高,访问数据的速度也就不高。
总结
本章我们双向链表的实现到这里就完成了,我们实现的这个带头双向循环链表在实践中可以说是最常用的链表结构了,我们在平常说的链表其实一般也就特指这种带头双向循环链表,因为它的插入和删除效率都非常高,而且还可以双向遍历,所以对于处理需要大量插入或删除的数据时使用链表这种数据结构来存储是非常好的。虽然双向链表的实现对于数据结构的初学者来说的话确实有一定难度,但还是值得我们亲自去实现它,这样才能有更加深刻的印象。
如需源码,可在我的gitee上找到,下面是链接。
双向链表源码
如对您有所帮助,可以来个三连,感谢大家的支持。
每文推荐
毛不易–牧马城市
丁当–猜不透
王蓝茵–恶作剧
学技术学累了时可以听歌放松一下。