简介
双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。这里,我们介绍一下带头结点的双向链表的创建和使用。
双链表实现
声明
所有链表都一样,我们需要进行结构体的声明,才可以进行创建,所以,我们得到下面的头文件:
#pragma once
#define _CRT_SCURE_NO_WARNINGS 1
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include<assert.h>
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}LTNode;
LTNode* LTInit();
bool LTEmpty(LTNode* phead);
void LTPrint(LTNode* phead);
void LTPushBack(LTNode* phead, LTDataType x);
void LTPopBack(LTNode* phead);
void LTPushFront(LTNode* phead, LTDataType x);
void LTPopFront(LTNode* phead);
LTNode* LTFind(LTNode* phead, LTDataType x);
//在pos之前插入
void LTInsert(LTNode* pos, LTDataType x);
//删除pos位置的值
void LTErase(LTNode* pos);
void LTDestroy(LTNode* phead);
创建链表
接下来,我们就可以进行双链表的创建
LTNode* BuyLTNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc fail");
return;
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
LTNode* LTInit()
{
LTNode* phead = BuyLTNode(-1);
phead->next = phead;
phead->prev = phead;
}
我们在这里介绍的带头结点的双向链表的头结点,实际上就是哨兵位,因为有了哨兵位的设置,使我们的结构整体上的增删查改简单了很多,同时,哨兵位不需要进行给值,也就是说phead的data并不重要,可以随便给值,当然,如果你的链表类型是int,也可以用phead的data进行计数,不过并不推荐,我们可以写一个计数函数。
链表的功能
创建链表后,我们就可以开始写这个双链表的增删查改了,我们可以先编译一下判断是否为空和打印的函数,将他们写在前面,方便后面的函数使用。
这两个函数的实现很简单,判空函数只需要使用布尔类型,返回头结点的下一位即可,如果不为空则为TRUE,为空为FALSE,打印函数我们可以设置一个变量cur进行链表的遍历,通过循环打印每个节点的数据。
bool LTEmpty(LTNode* phead)
{
assert(phead);//记得断言
return phead->next == phead;
}
void LTPrint(LTNode* phead)
{
assert(phead);
printf("烧饼<==>");
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d<==>", cur->data);
cur = cur->next;
}
printf("\n");
}
关于断言的问题,我在上一篇文章 ☞ 单链表 ☜ 中做了一些解释,感兴趣可以去看看 :D
好,接下来就可以开始写增删查改了,首先是插入功能,链表都会有头插和尾插的功能,头插,是将新节点插入到哨兵位后面,也就是phead->next,要实现这个功能,我们要理清逻辑关系,如图所示
想要插入这个newnode,就要将它对应的next和prev通通连接上,形成一个链式关系,我们最开始能想到的思路是:直接将phead->next指向newnode,然后让newnode->next指向phead->next,这种思路乍一看好像没什么问题,但我们细品,当我们用phead->next指向newnode时,头结点的指向就改变了,也就是说,本来的phead->next已经找不到了,整个链表就剩下了phead和newnode两个节点,这与我们想要实现的功能不符,因此,为了避免这种情况,我们有两种解决办法:
1.标记法
顾名思义,我们可以创建一个变量point用来存储phead->next节点,这样我们就不用担心将phead->next指向newnode后找不到后面的节点:
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyLTNode(x);
LTNode* point = phead->next;
phead->next = newnode;
newnode->prev = phead;
newnode->next = point;
point->prev = newnode;
}
2.后入法
这个名字是我自己取的,我也不知道该叫他什么XD,总之,这个方法,就是让newnode放弃对phead的想法,先去追求phead->next,链接上phead->next后,再去追求phead,因为哨兵位始终存在,所以我们可以把它放到最后去链接,这样也避免了找不到节点的问题。
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyLTNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
newnode->prev = phead;
}
接下来是尾插,尾插就要简单许多,同时,设计尾插时,我们可以看到本次介绍的链表的优越性:我们知道,单链表的尾插是需要进行遍历找到尾结点进行插入的,而这个循环双向链表,巧妙地解决了这个问题,phead->prev就指向了这个链表的最后一个节点,这使得我们省去了很多代码和时间,直接用变量存值,然后对他进行逻辑变换即可
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* tail = phead->prev;
LTNode* newnode = BuyLTNode(x);
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
有头插尾插就有头删尾删,删除函数的思路其实跟插入的思路差别不大,不过我们要注意的是,删除函数需要多一个断言:判断链表是否为空。这很好理解,如果链表为空,进行删除会导致出错,毕竟,我们的删除本质上是释放这个节点。关于头删,基本思路是将phead->next节点进行free,那我们可以将他先踢出这个链表,再进行free,即让phead->next->next节点与phead链接,这样说可能有些抽象,为了方便理解,我们可以创建一个变量tail和tailPrev分别存放phead->next和phead->next->next节点,链接好后,再free掉tail,可以结合代码图片理解:
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* tail = phead->next;
LTNode* tailPrev = tail->next;
phead->next = tailPrev;
tailPrev->prev = phead;
free(tail);
}
理解了头删后,尾删就变得so easy了,仍然可以通过标记的方法,达到我们的目的,废话不多说,上图上代码!
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* tail = phead->prev;
LTNode* tailPrev = tail->prev;
tailPrev->next = phead;
phead->prev = tailPrev;
free(tail);
}
到这里,插入和删除功能基本上介绍完了,接下来我们看一下这个双链表的查找和修改功能。查找函数主要靠的是遍历这个链表,将遍历到的值与要查找的值比较,相等则返回,因此很容易实现
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;
}
实现了查找函数后,我们准备实现修改功能。。先等等,其实我们已经实现了这个功能不是吗,如果想要修改某个节点,只需要调用插入函数和删除函数就行了,但是我们只实现了头尾的插入删除,对于中间的节点我们该怎么办?于是,我们可以写一个在pos之前插入和删除pos位置的函数
基本思路仍然相同,理清逻辑关系就好,在pos之前插入,我们可以用变量表示节点的位置,方便理解,如图
由于左右两边的节点都有变量存储表示,所以不管先链接哪个都不会出现错误。
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* newnode = BuyLTNode(x);
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
同理,删除pos节点,只需要将pos位置的节点赶出链表,让他的prev和next的节点相互链接,最后释放pos节点
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* posPrev = pos->prev;
LTNode* posNext = pos->next;
posPrev->next = posNext;
posNext->prev = posPrev;
free(pos);
}
完成这两个函数后,可以发现,似乎这两个函数可以应用到我们的头尾插入删除功能中,头插和尾插就是在pos位置之前插入嘛,而头删和尾删不就是删除pos位置的值吗?因此,我们可以简化代码,直接调用我们的LTInsert和LTErase函数。
最后,我们来实现一下销毁函数。销毁函数就是将使用完毕的链表进行销毁,其本质也是通过循环遍历节点进行释放,这里就不上图了,直接看代码
void LTDestroy(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = cur->next;
}
free(phead);
}
可能有同学会问,在释放掉这个节点时不用置空吗?答案是不用,至少在这个函数中不用,因为形参的改变不影响实参,在这个函数里你无法改变这个节点的值,如果真的想要严谨一点,我们可以在调用它后对其进行置空。
到此为止,一个较为完整的,带头结点的双向链表就完成了。我们可以在另一个源文件中进行测试,这里是一点测试样例
void test1()
{
LTNode* plist = LTInit();
LTPushFront(plist, 1);
LTPushBack(plist, 2);
LTPushBack(plist, 3);
LTPushBack(plist, 4);
LTPrint(plist);
LTDestroy(plist);
plist = NULL;
}
int main()
{
test1();
return 0;
}
总结
u1s1,链表真的很重要,对于刚开始进行学习的同学可能会感觉很抽象,但是只要我们不懈的坚持,多看多打代码,到最后我们会发现,原来链表如此简单!
最后,创作不易,还请多多支持!