文章目录
前言
在上一节中我们学习了单链表,但是我们发现单链表有如下缺陷:
-
在尾部插入、删除数据时间复杂度为O(N),效率低;
-
在pos位置前插入、删除数据时间复杂度为O(N),效率低;
-
进行插入、删除数据时因为有可能改变头节点,所以需要传递二级指针,不好理解;
基于单链表的这些缺陷,我们设计出了带头双向循环链表,带头循环实现链表能够完美的解决顺序表所存在的缺陷。
一. 什么是带头双向循环链表
在单链表部分我们已经介绍了链表的几种结构:
带头/不带头 – 是否具有哨兵位头结点,该节点不用于存储有效数据,对链表进行插入删除操作时也不会影响该节点;
双向/单向 – 链表的节点中是否增加了一个节点指针,该指针存储的是前一个节点的地址;
循环/不循环 – 链表的尾结点是否存储了头结点的地址,链表的头结点是否存储了尾结点的地址 ;
所以带头双向链表是指:具有哨兵位头结点、每个节点中都存储了后一个节点和前一个节点的地址、头结点存储了尾结点的地址、尾结点存储了头结点地址,这样的一种结构的链表。
可以看出,带头双向循环链表是结构最复杂的一种链表,但是它复杂的结构所带来的优势就是它管理数据非常简单,效率非常高;下面我们用C语言实现一个带头双向循环链表,以此来感受它的无穷魅力。
二. 实现带头双向循环链表
1.结构的定义
与单链表相比,带头双向循环链表的结构中需要增加一个结构体指针prev,用来存放前一个节点的地址。
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data; //用于存放数据
struct ListNode* prev; //用于存放下一个节点的地址
struct ListNode* next; //用于存放上一个节点的地址
}LTNode;
2.链表的初始化
和单链表不同,由于单链表最开始是没有节点的,所以我们定义一个指向NULL的节点指针即可;但是带头链表不同,我们需要在初始化函数中开辟一个哨兵位头结点,此节点不用于存储有效数据;
另外,由于我们的链表是循环的,所以最开始我们需要让头结点的prev和next指向自己;
最后,为了不使用二级指针,我们把 Init 函数的返回值设置为结构体指针类型。
LTNode* ListInit()
{
LTNode* guard = (LTNode*)malloc(sizeof(LTNode));
if (guard == NULL)
{
perror("malloc fail");
exit(-1);
//return NULL;
}
//使双链表具有双向循环结构
guard->prev = guard;
guard->next = guard;
return guard;
}
3.动态开辟一个新节点
LTNode* CreatListNode(LTDataType x)
{
LTNode* newNode = (LTNode*)malloc(sizeof(LTNode));
if (newNode == NULL)
{
perror("malloc fail");
exit(-1);
}
newNode->next = NULL;
newNode->prev = NULL;
newNode->data = x;
return newNode;
}
4.在头部插入数据
由于我们的链表是带头的,插入数据始终都不会改变头结点,所以这里我们传递一级指针即可;同时,phead 不可能为空,所以这里我们断言一下。
void ListPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
//法一
// 先链接newnode 和 phead->next节点之间的关系
/*LTNode* newnode = BuyListNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
newnode->prev = phead;*/
//不关心顺序
LTNode* newNode = CreatListNode(x);
LTNode* first = phead->next; // 找头节点
phead->next = newNode;
newNode->prev = phead;
newNode->next = first;
first->prev = newNode;
//法三
//ListInsertBefore(phead->next, x);
}
5.在尾部插入数据
在这里我们双向循环链表的优势就体现出来了,对于单链表来说,它只能通过遍历链表来找到链表的尾,然后把新节点链接在链表的尾部。
而对于我们的双向循环链表来说,我们可以直接通过 phead->prev 找到尾,然后链接新节点,把时间效率提高到了 O(1)。
void ListPopBack(LTNode* phead)
{
assert(phead);
//删空时,继续删除节点报错
assert(!isListEmpty(phead));
LTNode* prev = phead->prev->prev;
LTNode* del = phead->prev;
prev->next = phead;
phead->prev = prev;
free(del);
del = NULL;
//LTNode* tail = phead->prev;
//LTNode* prev = tail->prev;
//prev->next = phead;
//phead->prev = prev;
//free(tail);
//tail = NULL;
//法二
//ListErase(phead->prev);
}
6.在头部删除数据
这里我们需要判断链表是否为空,如果为空的话,继续删除元素会报错。
void ListPopFront(LTNode* phead)
{
assert(phead);
assert(!isListEmpty(phead));
LTNode* prev = phead->next->next;
LTNode* del = phead->next;
phead->next = prev;
prev->prev = phead;
free(del);
/*LTNode* first = phead->next;
LTNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
first = NULL;*/
//法二
//ListErase(phead->next);
}
7.在尾部删除数据
void ListPopBack(LTNode* phead)
{
assert(phead);
//删空时,继续删除节点报错
assert(!isListEmpty(phead));
LTNode* prev = phead->prev->prev;
LTNode* del = phead->prev;
prev->next = phead;
phead->prev = prev;
free(del);
del = NULL;
//LTNode* tail = phead->prev;
//LTNode* prev = tail->prev;
//prev->next = phead;
//phead->prev = prev;
//free(tail);
//tail = NULL;
//法二
//ListErase(phead->prev);
}
8.判断链表是否为空
bool isListEmpty(LTNode* phead)
{
assert(phead);
/*if (phead->next == phead)
return true;
else
return false;*/
//相等为真,相异为假
return phead->next == phead;
}
9.返回链表长度
size_t ListSize(LTNode* phead)
{
assert(phead);
size_t len = 0;
LTNode* cur = phead->next;
while (cur != phead)
{
len++;
cur = cur->next;
}
return len;
}
10.查找指定数据
LTNode* ListFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
//遍历链表,找到返回数据所在节点的地址
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
//没找到返回空
return NULL;
}
11.在指定节点(pos)之前插入数据
由于我们的链表是双向的,我们可以直接通过 pos->prev 来找到前一个节点,然后把新节点链接到前一个节点的后面,时间复杂度从单链表的O(N)提高到了 O(1);
同时,我们的头插和尾插函数还可以直接调用 Insert 函数,不需要单独实现,因为在头部插入数据相当于第一个节点前面插入元素,在尾部插入数据相当于头结点前面插入元素。
void ListInsertBefore(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* newNode = CreatListNode(x);
prev->next = newNode;
newNode->prev = prev;
newNode->next = pos;
pos->prev = newNode;
}
头插
void ListPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
//在第一个节点前面插入数据
ListInsertBefore(phead->next, x);
}
尾插
void ListPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
//在头结点前面插入数据
ListInsertBefore(phead, x);
}
12.在指定节点(pos)位置删除数据
和pos位置之前插入数据类似,这里我们的时间复杂度也为O(1),并且我们也可以通过调用此函数来完成头删和尾删的功能。
但是这里有一个问题,那就是pos不能是第一个节点的地址,因为我们不可能把哨兵位头结点给删除了,但是如果要避免这种情况出现,我们 Erase 函数就需要接受头结点的地址;
但是其实这个问题不应该由函数的实现者来注意,而是应该有函数的调用者来避免,另外感觉为了仅仅为了检查它把头结点传过来又没必要,所以我这里就没对其进行检查;大家可以根据自己的想法来实现这个函数。
void ListErase(LTNode* pos)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
pos = NULL;//没作用
}
头删
void ListPopFront(LTNode* phead)
{
assert(phead);
assert(!isListEmpty(phead));
//删除第二个节点前的数据
ListErase(phead->next->next);
}
尾删
void ListPopFront(LTNode* phead)
{
assert(phead);
assert(!isListEmpty(phead));
//删除头结点前的数据
ListErase(phead);
}
13.修改指定节点(pos)位置的数据
void ListModify(LTNode* pos, LTDataType x)
{
assert(pos);
pos->data = x;
}
14.打印链表数据
void ListPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
printf("phead=>");
while (cur != phead)
//while(cur != NULL) error
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("\n");
}
15.销毁链表
和 Init 函数相反,销毁链表需要同时销毁哨兵位头结点,也就是说我们需要改变头结点;要改变头结点有两种方法:
1、传递二级指针:考虑到接口的一致性,我们不使用此方法;
2、把函数返回值改为结构体指针:在销毁链表时我们还要去接受链表的返回值,感觉很别扭,所以我们也不用;
基于上面这两点:头结点置空的操作需要函数调用者在函数外来执行。
// 可以传二级,内部置空头结点
// 建议:也可以考虑用一级指针,让调用ListDestory的人置空 (保持接口一致性)
void ListDestory(LTNode* phead)
{
assert(phead);
//error
//LTNode* cur = phead;
//while (cur->next != phead)
//{
// //不能直接free当前节点,会找不到下一节点
// //先保存下一节点,再free当前节点
// LTNode* next = cur->next;
// free(cur);
// cur = next;
//}
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
//phead = NULL;
}
三.完整代码
List.h
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
struct ListNode* next;
struct ListNode* prev;
}LTNode;
//链表的初始化
LTNode* ListInit();
//void ListInit(LTNode** pphead);
//链表打印
void ListPrint(LTNode* phead);
//动态申请一个节点
LTNode* CreatListNode(LTDataType x);
//链表销毁
void ListDestory(LTNode* phead);
//链表头插
void ListPushBack(LTNode* phead, LTDataType x);
//链表尾插
void ListPushBack(LTNode* phead, LTDataType x);
//链表头删
void ListPopFront(LTNode* phead);
//链表尾删
void ListPopBack(LTNode* phead);
//链表是否满
bool isListEmpty(LTNode* phead);
//链表的长度
size_t ListSize(LTNode* phead);
//链表查找
LTNode* ListFind(LTNode* phead, LTDataType x);
//链表在指定节点(pos)之前插入
void ListInsertBefore(LTNode* pos, LTDataType x);
//链表修改指定节点(pos)位置的数据
void ListModify(LTNode* pos, LTDataType x);
//链表删除指定节点(pos)位置的数据
void ListErase(LTNode* pos);
List.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "List.h"
LTNode* ListInit()
{
LTNode* guard = (LTNode*)malloc(sizeof(LTNode));
if (guard == NULL)
{
perror("malloc fail");
exit(-1);
//return NULL;
}
//使双链表具有双向循环结构
guard->prev = guard;
guard->next = guard;
return guard;
}
void ListPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
printf("phead=>");
while (cur != phead)
//while(cur != NULL) error
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("\n");
}
LTNode* CreatListNode(LTDataType x)
{
LTNode* newNode = (LTNode*)malloc(sizeof(LTNode));
if (newNode == NULL)
{
perror("malloc fail");
exit(-1);
}
newNode->next = NULL;
newNode->prev = NULL;
newNode->data = x;
return newNode;
}
void ListPushBack(LTNode* phead, LTDataType x)
{
//链表带头,phead不可能为空
assert(phead);
LTNode* newNode = CreatListNode(x);
//找尾节点
LTNode* tail = phead->prev;
tail->next = newNode;
newNode->prev = tail;
newNode->next = phead;
phead->prev = newNode;
//法二:
//ListInsertBefore(phead, x);
}
void ListPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
//法一
// 先链接newnode 和 phead->next节点之间的关系
/*LTNode* newnode = BuyListNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
newnode->prev = phead;*/
//不关心顺序
LTNode* newNode = CreatListNode(x);
LTNode* first = phead->next; // 找头节点
phead->next = newNode;
newNode->prev = phead;
newNode->next = first;
first->prev = newNode;
//法三
//ListInsertBefore(phead->next, x);
}
void ListPopBack(LTNode* phead)
{
assert(phead);
//删空时,继续删除节点报错
assert(!isListEmpty(phead));
LTNode* prev = phead->prev->prev;
LTNode* del = phead->prev;
prev->next = phead;
phead->prev = prev;
free(del);
del = NULL;
//LTNode* tail = phead->prev;
//LTNode* prev = tail->prev;
//prev->next = phead;
//phead->prev = prev;
//free(tail);
//tail = NULL;
//法二
//ListErase(phead->prev);
}
void ListPopFront(LTNode* phead)
{
assert(phead);
assert(!isListEmpty(phead));
LTNode* prev = phead->next->next;
LTNode* del = phead->next;
phead->next = prev;
prev->prev = phead;
free(del);
/*LTNode* first = phead->next;
LTNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
first = NULL;*/
//法二
//ListErase(phead->next);
}
// 可以传二级,内部置空头结点
// 建议:也可以考虑用一级指针,让调用ListDestory的人置空 (保持接口一致性)
void ListDestory(LTNode* phead)
{
assert(phead);
//LTNode* cur = phead;
//while (cur->next != phead)
//{
// //不能直接free当前节点,会找不到下一节点
// //先保存下一节点,再free当前节点
// LTNode* next = cur->next;
// free(cur);
// cur = next;
//}
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
//phead = NULL;
}
bool isListEmpty(LTNode* phead)
{
assert(phead);
/*if (phead->next == phead)
return true;
else
return false;*/
//相等为真,相异为假
return phead->next == phead;
}
size_t ListSize(LTNode* phead)
{
assert(phead);
size_t len = 0;
LTNode* cur = phead->next;
while (cur != phead)
{
len++;
cur = cur->next;
}
return len;
}
LTNode* ListFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
//遍历链表,找到返回数据所在节点的地址
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
//没找到返回空
return NULL;
}
void ListInsertBefore(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* newNode = CreatListNode(x);
prev->next = newNode;
newNode->prev = prev;
newNode->next = pos;
pos->prev = newNode;
}
void ListModify(LTNode* pos, LTDataType x)
{
assert(pos);
pos->data = x;
}
void ListErase(LTNode* pos)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
pos = NULL;//没作用
}
Test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "List.h"
void ListTest1()
{
LTNode* plist = NULL;
plist = ListInit();
//头插
ListPushFront(plist, 1);
ListPushFront(plist, 2);
ListPushFront(plist, 3);
ListPrint(plist);
//尾插
ListPushBack(plist, 4);
ListPushBack(plist, 5);
ListPushBack(plist, 6);
ListPrint(plist);
//头删
ListPopFront(plist);
ListPopFront(plist);
ListPrint(plist);
//尾删
ListPopBack(plist);
ListPopBack(plist);
ListPrint(plist);
//销毁链表
ListDestory(plist);
}
void ListTest2()
{
LTNode* plist = ListInit();
//尾插
ListPushBack(plist, 1);
ListPushBack(plist, 2);
ListPushBack(plist, 3);
ListPushBack(plist, 4);
ListPushBack(plist, 5);
ListPushBack(plist, 6);
ListPrint(plist);
//计算链表的长度
size_t len = ListSize(plist);
printf("len = %u\n", len);
//插入pos位置之前的数据
LTNode* pos = ListFind(plist, 2);
if (pos)
{
ListInsertBefore(pos, 20);
}
ListPrint(plist);
pos = ListFind(plist, 6);
if (pos)
{
ListInsertBefore(pos, 60);
}
ListPrint(plist);
len = ListSize(plist);
printf("len = %u\n", len);
ListDestory(plist);
plist = NULL;
}
void ListTest3()
{
LTNode* plist = ListInit();
//尾插
ListPushBack(plist, 1);
ListPushBack(plist, 2);
ListPushBack(plist, 3);
ListPushBack(plist, 4);
ListPushBack(plist, 5);
ListPushBack(plist, 6);
ListPrint(plist);
//删除pos位置的数据
LTNode* pos = ListFind(plist, 3);
if (pos)
{
ListErase(pos);
}
ListPrint(plist);
pos = ListFind(plist, 6);
if (pos)
{
ListErase(pos);
}
ListPrint(plist);
//修改pos位置的数据
pos = ListFind(plist, 2);
if (pos)
{
ListModify(pos, 20);
}
ListPrint(plist);
pos = ListFind(plist, 4);
if (pos)
{
ListModify(pos, 40);
}
ListPrint(plist);
plist = NULL;
}
int main()
{
//ListTest1();
//ListTest2();
ListTest3();
return 0;
}
四. 顺序表和链表(带头双向循环)的区别
不同点 | 顺序表 | 链表 |
---|---|---|
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定 连续 |
随机访问 | 支持O(1) | 不支持:O(N) |
任意位置插入或者删除 元素 | 可能需要搬移元素,效率低 O(N) | 只需修改指针指向 |
插入 | 动态顺序表,空间不够时需要 扩容 | 没有容量的概念 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |
1.顺序表的优点
-
尾插尾删效率高
-
支持随机访问 (下标访问)
-
相比于链表结构,CPU 高速缓存命中率更高
2.顺序表的缺点
-
在头部和中部的插入删除效率低
-
扩容存在内存消耗和空间浪费
3.带头双向循环链表的优点
-
任意位置插入删除效率都很高
-
空间按需申请释放,不会造成空间浪费
4.带头双向循环链表的缺点
-
由于需要频繁 malloc,所以和顺序表的内存消耗其实差不多
-
不支持随机访问 (下标访问)
-
相比于顺序表结构,CPU 高速缓存命中率更低
五. 存储器相关知识
从程序环境那一节的学习中我们知道,一个程序经过编译链接后被翻译成二进制指令,这些二进制指令由由 CPU 来执行;
但是,CPU 执行指令时,并不会直接去访问内存中的数据,而是会看数据是否存在于三级缓存中,如果在,就代表命中;如果不在,就代表未命中,未命中情况下数据会被先加载到三级缓存中,然后再次进行访问;
同时,计算机领域有一个局部性原理:当我们访问一个数据时,我们极有可能也会访问其周边的数据;所以在将数据加载到缓存时,我们并不是只将我们要访问的那个数据加载到缓存中,而是会将该数据所在的一长段内存空间的数据都加载到缓存中去,具体加载多少个字节取决于硬件;
对于我们的顺序表来说,它其中的数据是连续存放的,所以我们访问其中的数据不需要每次访问都去加载数据,可能我们第一次访问加载数据之后,我们第十次访问才再次加载数据;
而对于我们的链表来说,链表的每个节点的地址是不具有关联性的,所以在多数情况下我们加载一个数据所在的一长段内存空间时,该内存空间并不包含该节点后面的节点;从而使得我们的 CPU 在访问数据时需要去频繁的去加载数据,导致效率降低;
另外,链表加载数据时还会造成缓存污染,因为我们会将一个数据所在的一长段内存空间的数据都加载到缓存中去,而由于其后面的空间中可能并不包含链表的其他节点,即我们将无效的数据加载进了缓存中,操作系统会把一些无用的数据(就是一些很久没有用过的旧数据)换出去,就会造成缓存污染。