携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第20天,点击查看活动详情
前言
在介绍完复杂度以后,接下来就是数据结构的学习了,先从最简单的线性表入手,本文是基于C语言实现的。
本文就来分享一波作者对数据结构双链表的学习心得与见解。本篇属于第四篇,介绍线性表的双链表的一些内容,建议阅读本文之前先把前面介绍单链表的文章看看。
笔者水平有限,难免存在纰漏,欢迎指正交流。
带头双向循环链表
我们前面学习的就是最最简单的链表结构——无头单向非循环链表,接下来就要学习最复杂的一种链表结构,虽说结构比较复杂,但是使用起来要比单链表更有优势。
这种结构可以完美解决顺序表的缺陷,具体的优势在逐步实现的时候就能慢慢体会到了。
结点的声明
相比于单链表,这里就只是多了个指向前面结点的指针。
typedef int DLLDataType;
typedef struct DLinkListNode
{
DLLDataType data;
struct DLinkListNode* prev;
struct DLinkListNode* next;
}DLLNode;
初始化链表
由于是带头链表,我们初始化时要创建哨兵头结点,然后把两个指针都指向自己,这就是带头双向循环链表的初始状态,此时链表为空时(这里指的是有效结点为空,哨兵头结点算是辅助结点),函数返回哨兵头结点的地址,外面再用一个头指针接收,DLLNode* pList = InitDLList(); 。
DLLNode* InitDLList()
{
DLLNode* guard = (DLLNode*)malloc(sizeof(DLLNode));
if (guard == NULL)
{
perror("malloc fail");
exit(-1);
}
guard->prev = guard;
guard->next = guard;
return guard;
}
创建结点
类同与单链表那里讲的,只需要动态开辟结点并初始化值即可,没什么好讲的。
DLLNode* CreateDLLNode(DLLDataType tar)
{
DLLNode* newNode = (DLLNode*)malloc(sizeof(DLLNode));
if (newNode == NULL)
{
perror("malloc fail");
exit(-1);
}
newNode->data = tar;
newNode->prev = NULL;
newNode->next = NULL;
return newNode;
}
尾插结点
由于带上了哨兵结点,所以各个函数都可以不用传二级指针了,因为不会再修改头指针了。同时链表也不可能为空,所以传入指针为空的情况是异常情况,需要检测。
尾插很容易,单链表的话还需要特意遍历找尾结点,但是带头双向循环链表就不需要这么麻烦了,要找到尾结点,只需要哨兵头结点的prev指针就行,因为头尾是通过两个指针连在一起了的。让要插入的结点先和原来的尾结点互相链接,此时循环链接断开,新插入的结点作为新的尾结点,再把循环链接补上。
即使是链表为空(这里指的是有效结点为空,哨兵头结点算是辅助结点)也是相同的做法,不需要额外分情况。
代码实现
void PushBackDLList(DLLNode* pHead, DLLDataType tar)
{
assert(pHead);
DLLNode* newNode = (DLLNode*)malloc(sizeof(DLLNode));
pHead->prev->next = newNode;
newNode->prev = pHead->prev;
newNode->next = pHead;
pHead->prev = newNode;
}
头插结点
很简单,但是要注意一下链接的顺序,应该先改变新结点和后面结点的关系,然后再改变新结点与哨兵头结点的关系。
void PushFrontDLList(DLLNode* pHead, DLLDataType tar)
{
assert(pHead);
DLLNode* newNode = (DLLNode*)malloc(sizeof(DLLNode));
pHead->next->prev = newNode;
newNode->next = pHead->next;
newNode->prev = pHead;
pHead->next = newNode;
}
尾删结点
只要是删除结点就要先判断一下链表是否为空,我们这里直接封装一个函数,链表为空返回真,不为空返回假。实现逻辑很简单,要是链表为空(这里指的是有效结点为空,哨兵头结点算是辅助结点)的话哨兵头结点的prev和next指针就会指向它自己。
bool DLListEmpty(DLLNode* pHead)
{
assert(pHead);
return pHead->next == pHead;
}
尾结点很容易通过哨兵头结点找到,找到尾结点的话它前面一个结点也能通过prev指针找到,让它前面一个结点作为新的尾结点,改变循环链接关系,再把原来的尾结点free掉即可。
void PopBackDLList(DLLNode* pHead)
{
assert(pHead);
assert(!DLListEmpty(pHead));
DLLNode* tail = pHead->prev;
DLLNode* tail_prev = tail->prev;
pHead->prev = tail_prev;
tail_prev->next = pHead;
free(tail);
}
头删结点
头删也很简单就能实现,而且不需要分情况考虑,因为即使是删掉第一个有效结点后链表为空(这里指的是有效结点为空,哨兵头结点算是辅助结点)也只是变回了初始状态,无论链表状态如何都用同样的思路进行头删。
设置两个指针,指针first指向第一个有效结点,second指向第二个有效结点,改变链接关系,让哨兵头结点直接和second指向结点链接,释放掉first指向结点。
void PopFrontDLList(DLLNode* pHead)
{
assert(pHead);
assert(!DLListEmpty(pHead));
DLLNode* first = pHead->next;
DLLNode* second = first->next;
pHead->next = second;
second->prev = pHead;
free(first);
}
求取链表长度
这个一般都直接遍历用计数器计数即可。
size_t LengthOfDLList(DLLNode* pHead)
{
assert(pHead);
size_t cnt = 0;
DLLNode* cur = pHead->next;
while(cur != pHead)
{
cnt++;
cur = cur->next;
}
return cnt;
}
查找结点
这个也很简单,直接遍历查找即可,在查找以后如果有修改需求可以直接修改,就不需要额外封装一个修改函数了。
DLLNode* FindDLList(DLLNode* pHead, DLLDataType tar)
{
assert(pHead);
DLLNode* cur = pHead->next;
while(cur != pHead)
{
if(cur->data == tar)
return cur;
cur = cur->next;
}
return NULL;
}
插入结点
由于是双向循环链表,实际上插入可以统一操作思路,头插尾插可以复用插入函数,一般来说插入函数是在目标位置pos前面插入结点,我们就再创建一个指针prev指向pos前一结点,要插入新结点的话直接修改链接关系即可。
void InsertDLList(DLLNode* pos, DLLDataType tar)
{
assert(pos);
DLLNode* prev = pos->prev;
DLLNode* newNode = CreateDLLNode(tar);
prev->next = newNode;
newNode->prev = prev;
newNode->next = pos;
pos->prev = newNode;
}
头插函数可以改为:
void PushFrontDLList(DLLNode* pHead, DLLDataType tar)
{
assert(pHead);
InsertDLList(pHead->next, tar);
}
尾插函数可以改为:
void PushBackDLList(DLLNode* pHead, DLLDataType tar)
{
assert(pHead);
InsertDLList(pHead, tar);
}
删除结点
由于是双向循环链表,实际上删除也可以统一操作思路,头删尾删可以复用删除函数,一般来说删除函数是删除目标位置pos的结点,我们创建两个指针一前一后,也就是将pos前后的结点直接链接起来,再释放掉pos位置的结点。
void EraseDLList(DLLNode* pos)
{
assert(pos);
DLLNode* prev = pos->prev;
DLLNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
}
头删函数可以改为:
void PopFrontDLList(DLLNode* pHead)
{
assert(pHead);
EraseDLList(pHead->next);
}
尾删函数可以改为:
void PopBackDLList(DLLNode* pHead)
{
assert(pHead);
EraseDLList(pHead->prev);
}
打印链表
打印链表是不是得遍历链表呀,不过没有NULL指针怎么让它停下来呢?既然是从头开始的,那就也从头结束,就是说从头结点开始遍历直到再次回到头结点就结束。
void PrintDLList(DLLNode* pHead)
{
asssert(pHead);
printf("pHead<=>");
DLLNode* cur = pHead->next;
while(cur != pHead)
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("\n");
}
销毁链表
和单链表差不多的思路,遍历链表一个一个结点释放呗,要记得先把下一个结点地址暂存一下,在释放掉当前结点后更新指针,哨兵头结点要最后释放。其实这样销毁以后还要求用户自己把头指针置为NULL,不然就是野指针。
void DestoryDLList(DLLNode* pHead)
{
assert(pHead);
DLLNode* cur = pHead->next;
while(cur != pHead)
{
DLLNode* next = cur->next;
free(cur);
cur = next;
}
free(pHead);
}
线性表总结
顺序表和链表的区别
| 不同点 | 顺序表 | 链表(带头双向链表) |
|---|---|---|
| 存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定 连续 |
| 随机访问 | 支持O(1) | 不支持:O(N) |
| 任意位置插入或者删除 元素 | 可能需要搬移元素,效率低 O(N) | 只需修改指针指向 |
| 插入 | 动态顺序表,空间不够时需要 扩容 | 没有容量的概念 |
| 应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
| 缓存利用率 | 高 | 低 |
顺序表的优势
- 尾插尾删效率高
- 支持随机访问(下标访问)
- cpu高速缓存命中率更高
顺序表的缺陷
- 头部和中间的插入效率低——O(n)
- 扩容时可能:性能消耗+空间浪费
链表优势
- 任意位置插入删除效率很高——O(1)
- 按需申请和释放+不存在空间浪费
链表缺陷
不支持随机访问
关于“cpu高速缓存命中率更高”的简单解释:
cpu执行指令,不会直接访问内存。
- 先看数据在不在三级缓存,如果在就是命中,直接访问缓存
- 如果不在就是不命中,先把数据加载到换存,再访问缓存
而且数据加载到缓存是一次一块的,如果需要的数据在物理结构上临近的话直接就把一整块加载到缓存了,顺序表就是这样的,命中率就更高,而链表的各个结点在物理结构上是松散无关联的,命中率就更低。
以上就是本文全部内容了,感谢观看,你的支持就是对我最大的鼓励~