文章目录
前言
在上一节中我们学习了单链表,但是我们发现单链表有如下缺陷:
1、在尾部插入、删除数据时间复杂度为O(N),效率低;
2、在pos位置前插入、删除数据时间复杂度为O(N),效率低;
3、进行插入、删除数据时因为有可能改变头节点,所以需要传递二级指针,不易理解;
基于单链表的这些缺陷,我们设计出了带头双向循环链表,带头双向循环链表能够完美地解决顺序表所存在的所有缺陷。
一、带头双向循环链表的概念和结构
在单链表部分我们已经介绍了链表的几种结构:
带头/不带头 : 是否具有哨兵位头结点,该节点不用于存储有效数据,对链表进行插入删除操作时也不会影响该节点;
双向/单向 : 链表的节点中是否增加了一个节点指针,该指针存储的是前一个节点的地址;
循环/不循环 : 链表的尾结点是否存储了头结点的地址;
所以带头双向循环链表是指:具有哨兵位头结点、每个节点中都存储了后一个节点和前一个节点的地址、头结点存储了尾结点的地址、尾结点存储了头结点的地址,这样的一种结构的链表。
可以看出,带头双向循环链表是结构最复杂的一种链表,但是它复杂的结构所带来的优势就是它管理数据非常简单,效率非常高;下面我们用C语言实现一个带头双向循环链表,以此来感受它的魅力。
二、带头双向循环链表的实现
1. 结点的定义
相比于单链表,双向链表需要增加一个结构体指针prev,用来存放前一个节点的地址。
typedef int DLDataType;
typedef struct DLNode
{
DLDataType val;
struct DLNode* next;
struct DLNode* prev;
}DLNode;
2. 链表的初始化
和单链表不同,由于单链表最开始是没有节点的,所以我们定义一个指向NULL的节点指针即可;但是带头链表不同,我们需要在初始化函数中开辟一个哨兵位头结点,此节点不用于存储有效数据;另外,由于我们的链表是循环的,所以最开始我们需要让头结点的prev和next指向自己;最后,为了不使用二级指针,我们把 Init 函数的返回值设置为结构体指针类型。
DLNode* BuyDLNode(DLDataType x)
{
DLNode* newnode = (DLNode*)malloc(sizeof(DLNode));
if (newnode == NULL)
{
perror("BuyDLNode");
exit(-1);
}
newnode->next = NULL;
newnode->prev = NULL;
newnode->val = x;
}
DLNode* InitDList()
{
DLNode* phead = BuyDLNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
3. 头插
由于我们的链表是带头的,插入数据始终都不会改变头指针,所以这里我们传递一级指针即可
void DListPushFront(DLNode* phead, DLDataType x)
{
assert(phead);
DLNode* newnode = BuyDLNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
newnode->prev = phead;
phead->next = newnode;
}
4. 尾插
在这里双向循环链表的优势就体现出来了,对于单链表来说,它只能通过遍历链表来找到链表的尾,然后把新节点链接在链表的尾部。
而对于双向循环链表来说,我们可以直接通过 phead->prev 找到尾,然后链接新节点,把时间效率提高到了O(1)。
void DListPushBack(DLNode* phead, DLDataType x)
{
assert(phead);
DLNode* newnode = BuyDLNode(x);
newnode->prev = phead->prev;
phead->prev->next = newnode;
newnode->next = phead;
phead->prev = newnode;
}
5. 头删
void DListPopFront(DLNode* phead)
{
assert(phead);
if (phead->next == phead)
{
printf("双链表为空,删除失败!\n");
return;
}
DLNode* front = phead->next;
phead->next = front->next;
front->next->prev = phead;
free(front);
}
6. 尾删
void DListPopBack(DLNode* phead)
{
assert(phead);
if (phead->next == phead)
{
printf("双链表为空,删除失败!\n");
return;
}
DLNode* tail = phead->prev;
tail->prev->next = phead;
phead->prev = tail->prev;
free(tail);
}
7. 按值查找
DLNode* DListFind(DLNode* phead, DLDataType x)
{
assert(phead);
DLNode* temp = phead->next;
while (temp != phead)
{
if (temp->val == x)
{
return temp;
}
temp = temp->next;
}
return NULL;
}
8. 在pos位置之前插入数据x
由于我们的链表是双向的,我们可以直接通过 pos->prev 来找到前一个节点,然后把新节点链接到前一个节点的后面,时间复杂度从单链表的O(N)提高到了 O(1);
void DListInsert(DLNode* pos, DLDataType x)
{
assert(pos);
DLNode* newnode = BuyDLNode(x);
newnode->prev = pos->prev;
pos->prev->next = newnode;
newnode->next = pos;
pos->prev = newnode;
}
9. 删除pos位置的结点
和在pos位置之前插入数据类似,这里的时间复杂度也为O(1),但是这里有一个问题,那就是pos不能是头指针,因为我们不可能把哨兵位头结点给删除了,如果要避免这种情况出现,Erase 函数就需要接受头指针,方便和pos指针进行比较;
但是其实这个问题不应该由函数的实现者来解决,而是应该由函数的调用者来避免,另外感觉仅仅为了检查头结点而把头指针传过来没什么必要,所以我这里就简单地把pos结点的值和头结点的值进行比较,如果相等就不做任何操作。
void DListErase(DLNode* pos)
{
if (pos == NULL || pos->val == -1)
{
return;
}
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
10. 打印链表
这里我们需要注意循环结束的条件,由于我们的链表是循环的,所以 cur 永远不可能为空,而是当 cur 回到头时代表遍历完成。
void PrintDList(DLNode* phead)
{
assert(phead);
printf("head<->");
DLNode* temp = phead->next;
while (temp != phead)
{
printf("%d<->", temp->val);
temp = temp->next;
}
printf("\n");
}
11. 销毁链表
和 Init 函数相反,销毁链表需要销毁哨兵位头结点,把头指针置空,也就是说我们需要改变头指针;要改变头指针有两种方法:
1、传递二级指针:考虑到接口的一致性,我们不使用此方法;
2、把函数返回值改为结构体指针:在销毁链表时我们还要去接收链表的返回值,感觉很别扭,所以我们也不用;
基于上面这两点:头指针置空的操作需要函数调用者在函数外来执行。
void DestroyDList(DLNode* phead)
{
assert(phead);
while (phead->next != phead)
{
DListErase(phead->next);
}
free(phead);
}
三、完整代码
1. DList.h
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int DLDataType;
typedef struct DLNode
{
DLDataType val;
struct DLNode* next;
struct DLNode* prev;
}DLNode;
//初始化双链表
DLNode* InitDList();
//打印双链表
void PrintDList(DLNode* phead);
// 双向链表尾插
void DListPushBack(DLNode* phead, DLDataType x);
// 双向链表尾删
void DListPopBack(DLNode* phead);
// 双向链表头插
void DListPushFront(DLNode* phead, DLDataType x);
// 双向链表头删
void DListPopFront(DLNode* phead);
// 双向链表按值查找
DLNode* DListFind(DLNode* phead, DLDataType x);
// 双向链表在pos的前面进行插入
void DListInsert(DLNode* pos, DLDataType x);
// 双向链表删除pos位置的节点
void DListErase(DLNode* pos);
//销毁双链表
void DestroyDList(DLNode* phead);
2. DList.c
#include"DList.h"
DLNode* BuyDLNode(DLDataType x)
{
DLNode* newnode = (DLNode*)malloc(sizeof(DLNode));
if (newnode == NULL)
{
perror("BuyDLNode");
exit(-1);
}
newnode->next = NULL;
newnode->prev = NULL;
newnode->val = x;
}
DLNode* InitDList()
{
DLNode* phead = BuyDLNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
void PrintDList(DLNode* phead)
{
assert(phead);
printf("head<->");
DLNode* temp = phead->next;
while (temp != phead)
{
printf("%d<->", temp->val);
temp = temp->next;
}
printf("\n");
}
void DListPushBack(DLNode* phead, DLDataType x)
{
assert(phead);
DLNode* newnode = BuyDLNode(x);
newnode->prev = phead->prev;
phead->prev->next = newnode;
newnode->next = phead;
phead->prev = newnode;
}
void DListPopBack(DLNode* phead)
{
assert(phead);
if (phead->next == phead)
{
printf("双链表为空,删除失败!\n");
return;
}
DLNode* tail = phead->prev;
tail->prev->next = phead;
phead->prev = tail->prev;
free(tail);
}
void DListPushFront(DLNode* phead, DLDataType x)
{
assert(phead);
DLNode* newnode = BuyDLNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
newnode->prev = phead;
phead->next = newnode;
}
void DListPopFront(DLNode* phead)
{
assert(phead);
if (phead->next == phead)
{
printf("双链表为空,删除失败!\n");
return;
}
DLNode* front = phead->next;
phead->next = front->next;
front->next->prev = phead;
free(front);
}
DLNode* DListFind(DLNode* phead, DLDataType x)
{
assert(phead);
DLNode* temp = phead->next;
while (temp != phead)
{
if (temp->val == x)
{
return temp;
}
temp = temp->next;
}
return NULL;
}
void DListInsert(DLNode* pos, DLDataType x)
{
assert(pos);
DLNode* newnode = BuyDLNode(x);
newnode->prev = pos->prev;
pos->prev->next = newnode;
newnode->next = pos;
pos->prev = newnode;
}
void DListErase(DLNode* pos)
{
if (pos == NULL || pos->val == -1)
{
return;
}
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
void DestroyDList(DLNode* phead)
{
assert(phead);
while (phead->next != phead)
{
DListErase(phead->next);
}
free(phead);
}
3. test.c
#include"DList.h"
void TestDList1()
{
DLNode* phead = InitDList();
PrintDList(phead);
DListPushBack(phead, 1);
DListPushBack(phead, 2);
DListPushBack(phead, 3);
DListPushBack(phead, 4);
DListPushBack(phead, 5);
PrintDList(phead);
DListPopBack(phead);
DListPopBack(phead);
DListPopBack(phead);
PrintDList(phead);
}
void TestDList2()
{
DLNode* phead = InitDList();
PrintDList(phead);
DListPushFront(phead, 1);
DListPushFront(phead, 2);
DListPushFront(phead, 3);
DListPushFront(phead, 4);
DListPushFront(phead, 5);
PrintDList(phead);
DListPopFront(phead);
DListPopFront(phead);
DListPopFront(phead);
PrintDList(phead);
}
void TestDList3()
{
DLNode* phead = InitDList();
PrintDList(phead);
DListPushFront(phead, 1);
DListPushFront(phead, 2);
DListPushFront(phead, 3);
DListPushFront(phead, 4);
DListPushFront(phead, 5);
PrintDList(phead);
DListInsert(DListFind(phead, 2), 10);
DListInsert(DListFind(phead, 3), 20);
PrintDList(phead);
DListErase(DListFind(phead, 10));
DListErase(DListFind(phead, 20));
PrintDList(phead);
DestroyDList(phead);
phead = NULL;
}
int main()
{
TestDList1();
//TestDList2();
//TestDList3();
return 0;
}
四、顺序表和链表的区别
不同点 | 顺序表 | 带头双向循环链表 |
---|---|---|
存储空间 | 逻辑物理上都连续 | 逻辑上连续,但物理上不一定连续 |
随机访问 | 支持O(1) | 不支持O(n) |
任意位置插入或者删除元素 | 可能需要搬移元素,效率低 | 只需修改指针指向,效率高 |
容量 | 动态顺序表,空间不够时需要扩容 | 按需申请,没有容量的概念 |
应用场景 | 元素高效存储 + 频繁访问 | 频繁地任意位置插入和删除 |
缓存利用率 | 高 | 低 |
顺序表的优缺点
- 优点
1、尾插尾删效率高;
2、支持随机访问 (下标访问);
3、相比于链表结构,CPU 高速缓存命中率更高;
- 缺点
1、在其他位置的插入删除效率低;
2、扩容存在内存消耗和空间浪费;
链表 (带头双向循环) 的优缺点
- 优点
1、任意位置插入删除效率都很高;
2、空间按需申请,没有空间浪费;
- 缺点
1、由于需要频繁 malloc,所以和顺序表的内存消耗其实差不多;
2、不支持随机访问 (下标访问);
3、相比于顺序表结构,CPU 高速缓存命中率更低;
综合比较顺序表和链表的优缺点,其实在实际生活中,顺序表的应用比链表的应用还要更多一些;其中顺序表支持随机访问是一个重要因素,另外还有顺序表 CPU 高速缓存命中率高和其他原因;下面我们来探讨 CPU 高速缓存命中率的问题。
- CPU 高速缓存命中率
我们知道,为了提高效率与降低成本,计算机是分层存储的,具体的存储体系结构如下图:
从程序环境那一节的学习中我们知道,一个程序经过编译链接后被翻译成二进制指令,这些二进制指令由 CPU 来执行;
但是,CPU 执行指令时,并不会直接去访问内存中的指令,而是会看指令是否存在于三级缓存中,如果找到了,就代表命中;否则,就代表未命中,未命中情况下指令会先被加载到三级缓存中,然后再进行访问;
同时,计算机领域有一个局部性原理:在最近的未来要用到的信息(指令和数据),很可能与现在正在使用的信息在存储空间上是临近的。当我们访问一个数据时,我们极有可能也会访问其周边的数据;所以在将数据加载到缓存时,我们并不是只将我们要访问的那个数据加载到缓存中,而是会将该数据所在的一长段内存空间的数据都加载到缓存中去,具体加载多少个字节取决于硬件;
对于顺序表来说,它其中的数据是连续存放的,当我们第一次访问顺序表时,该顺序表所在的一长段内存空间的数据都会被加载到缓存中去,所以我们访问其中的数据不需要每次都去访问内存。
而对于链表来说,它存储的数据并不符合局部性原理,链表的每个节点的地址都不具有关联性,所以在多数情况下我们加载一个数据所在的一长段内存空间时,该内存空间很可能并没有存储该节点周围的节点;从而使得我们的 CPU 在访问数据时需要去频繁的去访问内存,导致效率降低;
另外,链表加载数据时还会造成缓存污染,因为我们会将一个数据所在的一长段内存空间的数据都加载到缓存中去,而由于这一长串空间中可能并不包含链表的其他节点,即我们将无用的数据加载进了缓存中,就会造成缓存污染。