在上一章,我们阐述了单链表的基本应用,单链表即单向不带头不循环链表,是最基本的链表,
而这节我们要讨论的则是单链表的相反链表,双向带头循环链表。
双向链表顾名思义即拥有两个指向,概念模型如图所示
双向链表节点:next指向下一节点,prev指向上一节点,data存储数据,如图所示:
typedef struct ListNode
{
int data;
struct ListNode* next;
struct ListNode* prev;
}ListNode;
创建节点的方法和单链表大同小异,原理基本一致,要注意利用malloc开辟动态内存,能够最大限度利用内存,这也是链表的优点之一,但也要考虑开辟内存失败的情况更加严谨,直接上代码:
ListNode* CreateNewNode(int x)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
if (newnode == NULL)
{
perror("CreateNewNode::");
return NULL;
}
newnode->data = x;
newnode->next = newnode;
newnode->prev = newnode;
return newnode;
}
在具体阐述双向链表之前,我们先解释一下带头的意思,带头即带有头节点,即哨兵位,顾名思义,哨兵位即一个哨兵,不存储数据,只作为一个头节点,哨兵位的存在可以保证我们在写其他代码时,不用检测是否具有头节点,从而解释代码量,使逻辑更加清晰,即概念模型中所写的head节点。
哨兵位的prev指针指向尾节点,使我们不必再使用像单链表中的while循环寻找尾节点
创建哨兵位尽量使用不会出现的数据,我在这里设置哨兵位的数据为-1
哨兵位,初始化时,next和prev不能指向NULL,要指向自己,即自循环
void LTInit(ListNode** phead)
{
*phead = CreateNewNode(-1);
}
接下来我们介绍双向链表的基本应用,增,删,查,改
相比于单链表来说,双向链表由于具有两个指针,指向前和后,能够更加方便的完成基本操作,不必再新创建一个新的prev指针来保存cur的前一个位置
话不多说,我们开始
1.增
- 头插
- 尾插
- 中间插入
头插: 这里的头插,并非是作为头节点插入,而是插入哨兵位后的第一个节点,由于我们具有哨兵位,首先需要让新节点的prev指向哨兵位phead,而next指向哨兵位的next节点,其次只需要将哨兵位的next节点的prev指向新节点,再让哨兵位的nex指针指向新节点即可。
tip:我们不用像单链表中考虑是否存在头节点的原因是,具有哨兵位作为头节点,而哨兵位一直存在直到链表销毁,所以其他的节点都不会作为头节点
文字有点抽象,大家看一下代码理解一下:
void ListPushFront(ListNode* phead, int x)
{
assert(phead);
ListNode* newnode = CreateNewNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
注意后行的代码不能交换位置,否则会使phead的next节点发生变化,无法正确修改节点
尾插:
尾插和头插逻辑类似,因为有哨兵位,不需要考虑头节点的有无,只需要修改新节点的next和prev指针和其前后节点的指针。
首先需要将新节点的next指向哨兵位phead,prev指向哨兵位的prev节点。
再将尾节点的next指向新节点,将哨兵位的next指向新节点,使新节点成为新的尾节点
代码如下:
void ListPushBack(ListNode* phead, int x)
{
assert(phead);
ListNode* newnode = CreateNewNode(x);
newnode->next = phead;
newnode->prev = phead->prev;
//以下代码不能交换位置!!!
phead->prev->next = newnode;
phead->prev = newnode;
}
中间插入:
中间插入和尾插,头插逻辑类似,只需要将插入的位置传入,在其后面插入新节点,在修改自身以及自身前后的prev和next节点即可。
void ListInsert(ListNode* pos, int x)
{
assert(pos);
ListNode* newnode = CreateNewNode(x);
newnode->next = pos->next;
newnode->prev = pos;
pos->next->prev = newnode;
pos->prev->next = newnode;
}
2.删
头删:
在双向链表中,我们默认哨兵位不作为有效节点,则删除节点不能删除哨兵位,只能在销毁部分销毁哨兵位。
头删我们需要新建一个del的节点,来记录删除的节点,否则在调整完链表的连接结构后,找不到删除的节点,无法销毁,造成内存泄漏。
我们的思路是先调整链表的连接顺序,再销毁节点,将哨兵位的next指向del的next,del的next的prev指针指向哨兵位,作为新的“头节点”。最后free掉del节点。
void ListPopFront(ListNode* phead)
{
assert(phead&&phead->next != phead);
ListNode* del = phead->next;
phead->next = del->next;
del->next->prev = phead;
free(del);
del = NULL;
}
尾删:
尾删思路与头插一直,创建del的节点,调整链表的连接顺序
将del的next指向哨兵位,哨兵位的prev指向del->prev节点
最后free掉del节点
void ListPopBack(ListNode* phead)
{
//链表必须有效,且不能为空(只有一个哨兵位)
assert(phead&&phead->next != phead);
ListNode* del = phead->prev;
del->prev->next = phead;
phead->prev = del->prev;
free(del);
del = NULL;
}
中间删除:
思路与头删尾删一致,不在解释,大家自己看一下代码:
void ListErase(ListNode* pos)
{
//理论上pos不包含phead,但是没有phead,无法增加校验
assert(pos);
pos->next->prev = pos->prev;
pos->prev->next = pos->next;
free(pos);
pos = NULL;
}
3.查
查找节点的思路与单链表一致,用while循环遍历整个链表,来查找数据一一比对,但也有区别,由于双向链表是循环链表,所以我们创建cur节点,令其等于哨兵位的next,循环条件里写
cur节点!=phead(哨兵位),这样能够使cur完全遍历链表。
找到即返回该节点,找不到即返回NULL
ListNode* ListFind(ListNode* phead,int x)
{
ListNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
4.改:
修改数据不复杂,只需要用find函数找到该节点,再修改其数据即可
void ListModify(ListNode* phead, int x,int y)
{
ListNode* cur = ListFind(phead, x);
if (cur == NULL)
{
return;
}
else
{
cur->data = y;
}
}
5.打印:
打印一个链表可以让我们更加直接的观察链表结构,而逻辑也并不复杂,和Find函数中的循环结构一样,只需要利用while循环,限制条件为cur!=phead(哨兵位),依次打印数据即可。
void ListPrint(ListNode* phead)
{
ListNode* cur = phead->next;
while (cur != phead)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("\n");
}
打印效果如下:
6.销毁
销毁链表的思路是,先以循环依次销毁掉哨兵位后的节点,最后再销毁哨兵位
我们创建一个指针cur指向哨兵位的next,再用while循环遍历链表,在循环中创建next,存储销毁的下一个节点,然后free当前cur节点,再将cur赋值为next继续进行循环。
在循环结束后,即只存在哨兵位,我们需要将哨兵位释放。代码如下:
void ListDestroy(ListNode* phead)
{
ListNode* cur = phead->next;
while (cur != phead)
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
phead = NULL;
}
注意:可以看到我们在释放掉哨兵位后,给phead置空,但由于我们函数传入的是一级指针,函数中只是形参,是实参的临时拷贝,对形参的赋值不会影响实参,所以置空只是一个习惯问题,可加可不加,但需要注意,在函数外的哨兵位成为了野指针,不要随意使用,也可以直接在函数结束后,手动置空。
有的人可能会问为什么不传入二级指针,这样置空不就会影响实参了吗,我们这样做的原因是保持接口变量一致,都作为一级指针,这样他人使用时,可以有更好的体验,不用这个函数用一级指针,那个函数用二级指针,效果不好。
7.关于函数参数问题:
我们的函数参数都为一级指针,因为哨兵位的存在,我们不需要改变头节点,所以传入二级指针,没有什么实质用处,反而可能会改变哨兵位,导致链表结构错误,因此我们使用一级指针。
总结:
二级指针的基本用法就已经讲完了,相比于单链表,他的结构更加复杂,但使用也变得更加方便,同时,哨兵位的使用也是我们新接触的东西,他的存在使我们的代码量大大减少,希望大家好好理解,有所收获。