上一篇博客,我们了解并实现了单向+不带头+不循环链表,而本篇博客会讲解链表中的王者:双向+带头+循环链表。
概述
双向+带头+循环链表的特点是:
- 每个结点内部,既有指向上一个结点的前驱指针prev,也有指向下一个结点的后继指针next。
- 第一个结点,是哨兵位的头结点,不存储有效数据。从第二个结点开始存储有效数据。
- 最后一个结点的后继指针指向第一个结点,第一个结点的前驱指针指向最后一个结点。
这个结构,感觉上就是单向+不带头+不循环链表完全反过来!那么这么设计有什么优点呢?看过我的上一篇博客的朋友应该都能感觉到,单链表的实现相当麻烦,看起来结构简单,但是实现起来复杂、逻辑复杂、效率低。事实上,本篇博客讲解的双链表看起来结构复杂,但是实现起来逻辑简单、效率高。具体等实现之后大家体会更深。
双链表结点的声明如下:
// 链表存储的数据类型
typedef int LTDataType;
// 带头+双向+循环链表
typedef struct ListNode
{
LTDataType data; // 存储数据
struct ListNode* next; // 指向下一个结点
struct ListNode* prev; // 指向上一个结点
}ListNode;
申请结点
先写一个函数,向堆区申请一个结点,函数的声明如下:
ListNode* BuyListNode(LTDataType x);
使用malloc函数申请一个结点,并对data、next、prev进行初始化即可。
ListNode* BuyListNode(LTDataType x)
{
// 创建新结点
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
// 判断是否创建成功
if (newnode == NULL)
{
perror("malloc fail");
return NULL;
}
// 初始化
newnode->data = x;
newnode->prev = NULL;
newnode->next = NULL;
return newnode;
}
初始化
接下来写一个函数来创建一个链表,并返回哨兵位的头结点。函数的声明如下:
ListNode* ListCreate();
根据双向+带头+循环链表的定义,我们需要开辟一个哨兵位的头结点,这个结点不存储有效的数据。根据“循环”的特点,当只有一个结点时,它的prev和next都是它自己。
ListNode* ListCreate()
{
// 创建哨兵位
ListNode* newnode = BuyListNode(0);
// 链接
newnode->next = newnode;
newnode->prev = newnode;
return newnode;
}
销毁
接下来写一个函数来销毁链表,函数声明如下:
void ListDestroy(ListNode* phead);
遍历双向链表,并依次销毁即可。遍历时需要注意:从phead->next开始,等于phead时结束。销毁之前要保存下一个结点,否则就找不到下一个了。把其他结点都销毁完后,再销毁哨兵位。
注意需要检查phead指针的有效性,因为哪怕链表为空,也有哨兵位,phead一定不为空,后面的函数同理。
void ListDestroy(ListNode* phead)
{
assert(phead);
ListNode* del = phead->next;
// 遍历+删除
while (del != phead)
{
ListNode* next = del->next;
// 删除
free(del);
// 迭代
del = next;
}
// 释放哨兵位
free(phead);
phead = NULL;
}
打印
下面写一个函数来打印链表中的数据,方便后面的测试。这是函数声明:
void ListPrint(ListNode* phead);
和上一个函数类似,也是从phead->next开始遍历,到phead结束。
void ListPrint(ListNode* phead)
{
assert(phead);
ListNode* cur = phead->next;
printf("哨兵位");
// 遍历+打印
while (cur != phead)
{
printf("<==>%d", cur->data);
// 迭代
cur = cur->next;
}
printf("\n");
}
判空
接下来写一个函数,来判断链表是否为空链表。这是函数声明:
bool ListEmpty(ListNode* phead);
链表什么时候为空呢?当链表只有一个哨兵位的头结点时,链表中是没有有效数据的,此时我们认为链表为空。具体的判断就只需要判断phead->next和phead是否相等即可。
bool ListEmpty(ListNode* phead)
{
assert(phead);
return phead->next == phead;
}
查找
再来个简单的函数,在链表中查找数据。函数的声明如下:
ListNode* ListFind(ListNode* phead, LTDataType x);
和前面的遍历完全一样。从phead->next开始遍历数据,遇到phead结束。
ListNode* ListFind(ListNode* phead, LTDataType x)
{
assert(phead);
ListNode* cur = phead->next;
// 遍历+查找
while (cur != phead)
{
if (cur->data == x)
{
// 找到了
return cur;
}
// 迭代
cur = cur->next;
}
// 没找到
return NULL;
}
插入
下面我们来实现一个函数,在链表中的指定结点前面插入一个新的结点。函数的声明如下:
void ListInsert(ListNode* pos, LTDataType x);
由于双向链表的特性,我们知道了pos,就可以找到pos的前一个结点prev,然后在prev和pos中间插入新结点即可。
void ListInsert(ListNode* pos, LTDataType x)
{
assert(pos);
ListNode* prev = pos->prev;
ListNode* newnode = BuyListNode(x);
// 链接prev<==>newnode<==>pos
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
有了Insert函数,我们就可以实现尾插和头插了。尾插就是在哨兵位的头结点前面插入,头插就是在phead->next前面插入。
void ListPushBack(ListNode* phead, LTDataType x)
{
assert(phead);
ListInsert(phead, x);
}
void ListPushFront(ListNode* phead, LTDataType x)
{
assert(phead);
ListInsert(phead->next, x);
}
删除
最后实现一个函数,删除链表中的指定结点,函数的声明如下:
void ListErase(ListNode* pos);
找到pos的前一个结点prev和后一个结点next,删除pos结点,链接prev和next即可。
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* prev = pos->prev;
ListNode* next = pos->next;
// 释放pos
free(pos);
pos = NULL;
// 链接
prev->next = next;
next->prev = prev;
}
有了Erase函数,就可以轻松实现尾删和头删了。尾删就是删除phead->prev,头删就是删除phead->next。
严谨起见,最好先断言一下链表非空。
void ListPopBack(ListNode* phead)
{
assert(phead);
// 断言链表非空
assert(!ListEmpty(phead));
ListErase(phead->prev);
}
void ListPopFront(ListNode* phead)
{
assert(phead);
// 断言链表非空
assert(!ListEmpty(phead));
ListErase(phead->next);
}
总结
呼,这就搞定了!大家可以对比一下上一篇博客中的单链表,那叫一个天上地下!真是没有对比就没有伤害。双向链表的结构确实比单链表的结构要复杂,但是由于其结构特点,没有死角,能够实现以O(1)的时间复杂度在任意位置插入删除数据,非常强大。不过链表还是有缺点的,它在内存中不是连续存放的,这就导致了其无法实现下标的随机访问,并且缓存命中率较低,存在缓存污染。
感谢大家的阅读!