【数据结构】带头双向循环链表的增删查改(C语言实现)

发布于:2023-01-26 ⋅ 阅读:(22) ⋅ 点赞:(0) ⋅ 评论:(0)

前言

在上一节中我们学习了单链表,但是我们发现单链表有如下缺陷:

1、在尾部插入、删除数据时间复杂度为O(N),效率低;

2、在pos位置前插入、删除数据时间复杂度为O(N),效率低;

3、进行插入、删除数据时因为有可能改变头节点,所以需要传递二级指针,不易理解;

基于单链表的这些缺陷,我们设计出了带头双向循环链表,带头循环实现链表能够完美的解决顺序表所存在的缺陷。


一、什么是带头双向循环链表

在单链表部分我们已经介绍了链表的几种结构:
带头/不带头 – 是否具有哨兵位头结点,该节点不用于存储有效数据,对链表进行插入删除操作时也不会影响该节点;

双向/单向 – 链表的节点中是否增加了一个节点指针,该指针存储的是前一个节点的地址;

循环/不循环 – 链表的尾结点是否存储了头结点的地址,链表的头结点是否存储了尾结点的地址 ;

所以带头双向链表是指:具有哨兵位头结点、每个节点中都存储了后一个节点和前一个节点的地址、头结点存储了尾结点的地址、尾结点存储了头结点地址,这样的一种结构的链表。image-20220804215452131

可以看出,带头双向循环链表是结构最复杂的一种链表,但是它复杂的结构所带来的优势就是它管理数据非常简单,效率非常高;下面我们用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(struct ListNode));
	if (guard == NULL)
	{
		perror("malloc fail");
		return NULL;
	}

	//让双链表具有双向循环结构
	guard->prev = guard;
	guard->next = guard;
	return guard;
}

3、开辟新节点

//开辟新节点
LTNode* BuyLTNode(LTDataType x)
{
	LTNode* newnode = (LTNode*)malloc(sizeof(struct ListNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
		return NULL;
	}
	newnode->data = x;
	newnode->prev = NULL;
	newnode->next = NULL;
	return newnode;
}

4、在头部插入数据

由于我们的链表是带头的,插入数据始终都不会改变头结点,所以这里我们传递一级指针即可;同时,phead 不可能为空,所以这里我们断言一下。

//在头部插入数据
void ListPushFront(LTNode* phead, LTDataType x)
{
	assert(phead);  //因为链表是带头的,所以phead不可能为空
	LTNode* newnode = BuyLTNode(x);
	LTNode* first = phead->next;  //记录第一个节点

	//改变链接关系(当链表中没有节点,即只有一个头时,下面逻辑也正常)
	phead->next = newnode;
	newnode->prev = phead;
	newnode->next = first;
	first->prev = newnode;
}

5、在尾部插入数据

在这里我们双向循环链表的优势就体现出来了,对于单链表来说,它只能通过遍历链表来找到链表的尾,然后把新节点链接在链表的尾部。

而对于我们的双向循环链表来说,我们可以直接通过 phead->prev 找到尾,然后链接新节点,把时间效率提高到了 O(1)。

//在尾部插入数据
void ListPushBack(LTNode* phead, LTDataType x)
{
	LTNode* newnode = BuyLTNode(x);

	//找尾:头结点的prev指向链表的尾
	LTNode* tail = phead->prev;

	//修改链接关系(当链表中没有节点时逻辑也成立)
	phead->prev = newnode;
	newnode->next = phead;
	newnode->prev = tail;
	tail->next = newnode;
}

6、查找数据

//查找数据
LTNode* ListFind(LTNode* phead, LTDataType x)
{
	assert(phead);
	LTNode* cur = phead->next;
	//遍历链表,找到返回数据所在节点的地址
	while (cur != phead)
	{
		if (cur->data == x)
			return cur;
		cur = cur->next;
	}
	//找不到就返回NULL
	return NULL;
}

7、在pos位置之前插入数据

由于我们的链表是双向的,我们可以直接通过 pos->prev 来找到前一个节点,然后把新节点链接到前一个节点的后面,时间复杂度从单链表的O(N)提高到了 O(1);

同时,我们的头插和尾插函数还可以直接调用 Insert 函数,不需要单独实现,因为在头部插入数据相当于第一个节点前面插入元素,在尾部插入数据相当于头结点前面插入元素。

//在pos位置之前插入数据
void ListInsert(LTNode* pos, LTDataType x)
{
	assert(pos);
	LTNode* newnode = BuyLTNode(x);

	//找pos的前一个节点
	LTNode* prev = pos->prev;

	//修改链接关系(当pos为第一个节点/最后一个节点时逻辑也成立)
	//ps:头插和尾插可以通过直接调用此函数来完成
	prev->next = newnode;
	newnode->prev = prev;
	newnode->next = pos;
	pos->prev = newnode;
}
//在头部插入数据
void ListPushFront(LTNode* phead, LTDataType x)
{
	assert(phead);
	ListInsert(phead->next, x);  //相当于第一个节点前面插入元素
}
//在尾部插入数据
void ListPushBack(LTNode* phead, LTDataType x)
{
	assert(phead);
	ListInsert(phead, x);  //相当于头结点前面插入元素
}

8、判断链表是否为空

//判断链表是否为空
bool IsEmpty(LTNode* phead)
{
	assert(phead);
	return phead == phead->next;  //当链表中只剩下头结点时链表为空,返回true
}

9、在头部删除数据

这里我们需要判断链表是否为空,如果为空继续删除元素就报错。

//在头部删除数据
void ListPopFront(LTNode* phead)
{
	assert(phead);
	assert(!IsEmpty(phead));  //删空时继续删除报错
	//记录第一个节点的下一个节点
	LTNode* second = phead->next->next;

	//释放第一个节点
	free(phead->next);

	//修改链接关系
	phead->next = second;
	second->prev = phead;
}

10、在尾部删除数据

//在尾部删除数据
void ListPopBack(LTNode* phead)
{
	assert(phead);
	assert(!IsEmpty(phead));  //删空时继续删除报错
	//记录尾结点的上一个节点
	LTNode* prev = phead->prev->prev;

	//释放尾结点
	free(phead->prev);

	//修改链接关系
	phead->prev = prev;
	prev->next = phead;
}

11、在pos位置之前删除数据

和pos位置之前插入数据类似,这里我们的时间复杂度也为O(1),并且我们也可以通过调用此函数来完成头删和尾删的功能。

但是这里有一个问题,那就是pos不能是第一个节点的地址,因为我们不可能把哨兵位头结点给删除了,但是如果要避免这种情况出现,我们 Erase 函数就需要接受头结点的地址;

但是其实这个问题不应该由函数的实现者来注意,而是应该有函数的调用者来避免,另外感觉为了仅仅为了检查它把头结点传过来又没必要,所以我这里就没对其进行检查;大家可以根据自己的想法来实现这个函数。

//在pos位置之前删除数据
void ListErase(LTNode* pos)
{
	//这里有一个问题:pos不能是第一个节点的地址,因为我们不可能把哨兵位头结点给删除了,需要函数调用者自己注意
	assert(pos);
	//记录pos的前一个节点的前一个节点
	LTNode* prev = pos->prev->prev;

	//free pos前的节点
	free(pos->prev);

	//修改链接关系(当pos为第二个节点/头结点节点时逻辑也成立)
	//ps:头删和尾删可以通过直接调用此函数来完成
	prev->next = pos;
	pos->prev = prev;
}
//在头部删除数据
void ListPopFront(LTNode* phead)
{
	assert(phead);
	assert(!IsEmpty(phead));  //删空时继续删除报错
	ListErase(phead->next->next);  //相当于删除第二个节点前的数据
}

//在尾部删除数据
void ListPopBack(LTNode* phead)
{
	assert(phead);
	assert(!IsEmpty(phead));  //删空时继续删除报错
	ListErase(phead);  //相当于删除头结点前的数据
}

12、修改pos位置处的数据

其实在C++ STL 的 List 中是没有单独的修改函数的,因为 Find 就可以实现修改的功能;Find 函数返回一个数据的地址 pos ,然后我们直接修改 pos->data 即可,但是这里我还是单独实现了一个修改函数。

//修改链表数据
void ListModify(LTNode* pos, LTDataType x)
{
	assert(pos);
	pos->data = x;
}

13、返回链表长度

由于链表长度的计算需要遍历链表,时间复杂度为O(N),而带头双向链表其他接口的时间复杂度都是O(1),所以这里显得有些突兀;

所以有的书上或者学校就用头结点来存储链表元素,反正头结点也不用于存储数据,乍一看这样设计好像没有什么问题,但是当我们存储的数据不是整形,而是其他类型,比如 char 时,这种设计就有问题了;

当 data 的类型是 char时,我们知道 char 最大为127,超过127就会发生截断,这种情况下当链表长度大于127时,头结点中的 data 存储的链表长度就是错误的了;更别说我们用其来存储结构体类型的数据了。

所以说用头结点来存储链表长度这种设计是因小失大、不合理的;如果实在想让计算链表长度的时间复杂度变为O(1),我们可以在结构体中增加一个size变量,专门用于记录链表长度。

//计算链表长度
size_t ListSize(LTNode* phead)
{
	assert(phead);
	size_t size = 0;
	LTNode* cur = phead->next;  //链表长度不包含头结点,因为头结点不存储有效数据
	while (cur != phead)
	{
		size++;
		cur = cur->next;
	}
	return size;
}

14、打印链表数据

这里我们需要注意循环结束的条件,由于我们的链表是循环的,所以 cur 永远不可能为空,而是当 cur 回到头时代表遍历完成。

//打印链表数据
void ListPrint(LTNode* phead)
{
	assert(phead);  //因为链表是带头的,所以phead不可能为空
	LTNode* cur = phead->next;  //从第一个有效元素开始打印
	printf("head<=>");
	while (cur != phead)  //当cur回到头结点时循环结束
	{
		printf("%d<=>", cur->data);
		cur = cur->next;
	}
	printf("\n");
}

15、销毁链表

和 Init 函数相反,销毁链表需要同时销毁哨兵位头结点,也就是说我们需要改变头结点;要改变头结点有两种方法:

1、传递二级指针:考虑到接口的一致性,我们不使用此方法;

2、把函数返回值改为结构体指针:在销毁链表时我们还要去接受链表的返回值,感觉很别扭,所以我们也不用;

基于上面这两点:头结点置空的操作需要函数调用者在函数外来执行。

//销毁链表
void ListDestory(LTNode* phead)
{
	assert(phead);  //链表带头
	LTNode* cur = phead->next;

	//释放掉除头结点以外的其他节点
	while (cur != phead)
	{
		LTNode* next = cur->next;
		free(cur);
		cur = next;
	}

	//释放头结点 (这里需要调用者在函数外部手动把phead置为NULL(要改变phead需要用二级指针或者函数返回值)
	free(phead);
}

三、完整代码

1、List.h

#pragma once  //防止头文件重复包含

//头文件的定义
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

//结构和符号的定义
typedef int LTDataType;
typedef struct ListNode
{
	LTDataType data;          //用于存放数据
	struct ListNode* prev;    //用于存放下一个节点的地址
	struct ListNode* next;    //用于存放上一个节点的地址
}LTNode;

//函数的声明
//初始化双链表
LTNode* ListInit();
//开辟新节点
LTNode* BuyLTNode(LTDataType x);
//打印链表数据
void ListPrint(LTNode* phead);
//销毁链表
void ListDestory(LTNode* phead);
//在头部插入数据
void ListPushFront(LTNode* phead, LTDataType x);
//在尾部插入数据
void ListPushBack(LTNode* phead, LTDataType x);
//查找数据
LTNode* ListFind(LTNode* phead, LTDataType x);
//在pos位置之前插入数据
void ListInsert(LTNode* pos, LTDataType x);
//判断链表是否为空
bool IsEmpty(LTNode* phead);
//在头部删除数据
void ListPopFront(LTNode* phead);
//在尾部删除数据
void ListPopBack(LTNode* phead);
//在pos位置之前删除数据
void ListErase(LTNode* pos);
//计算链表长度
size_t ListSize(LTNode* phead);
//修改链表数据
void ListModify(LTNode* pos, LTDataType x);

2、List.c

#define _CRT_SECURE_NO_WARNINGS 1
#include "LIst.h"

//初始化双链表
LTNode* ListInit()
{
	//创建哨兵位头结点
	LTNode* guard = (LTNode*)malloc(sizeof(struct ListNode));
	if (guard == NULL)
	{
		perror("malloc fail");
		return NULL;
	}

	//让双链表具有双向循环结构
	guard->prev = guard;
	guard->next = guard;
	return guard;
}

//开辟新节点
LTNode* BuyLTNode(LTDataType x)
{
	LTNode* newnode = (LTNode*)malloc(sizeof(struct ListNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
		return NULL;
	}
	newnode->data = x;
	newnode->prev = NULL;
	newnode->next = NULL;
	return newnode;
}

//打印链表数据
void ListPrint(LTNode* phead)
{
	assert(phead);  //因为链表是带头的,所以phead不可能为空
	LTNode* cur = phead->next;  //从第一个有效元素开始打印
	printf("head<=>");
	while (cur != phead)  //当cur回到头结点时循环结束
	{
		printf("%d<=>", cur->data);
		cur = cur->next;
	}
	printf("\n");
}

//销毁链表
void ListDestory(LTNode* phead)
{
	assert(phead);  //链表带头
	LTNode* cur = phead->next;

	//释放掉除头结点以外的其他节点
	while (cur != phead)
	{
		LTNode* next = cur->next;
		free(cur);
		cur = next;
	}

	//释放头结点 (这里需要调用者在函数外部手动把phead置为NULL(要改变phead需要用二级指针或者函数返回值)
	free(phead);
}

//在头部插入数据
void ListPushFront(LTNode* phead, LTDataType x)
{
	assert(phead);
	ListInsert(phead->next, x);  //相当于第一个节点前面插入元素

	//assert(phead);  //因为链表是带头的,所以phead不可能为空
	//LTNode* newnode = BuyLTNode(x);
	//LTNode* first = phead->next;  //记录第一个节点

	改变链接关系(当链表中没有节点,即只有一个头时,下面逻辑也正常)
	//phead->next = newnode;
	//newnode->prev = phead;
	//newnode->next = first;
	//first->prev = newnode;
}

//在尾部插入数据
void ListPushBack(LTNode* phead, LTDataType x)
{
	assert(phead);
	ListInsert(phead, x);  //相当于头结点前面插入元素

	//assert(phead);
	//LTNode* newnode = BuyLTNode(x);

	找尾:头结点的prev指向链表的尾
	//LTNode* tail = phead->prev;

	修改链接关系(当链表中没有节点时逻辑也成立)
	//phead->prev = newnode;
	//newnode->next = phead;
	//newnode->prev = tail;
	//tail->next = newnode;
}

//查找数据
LTNode* ListFind(LTNode* phead, LTDataType x)
{
	assert(phead);
	LTNode* cur = phead->next;
	//遍历链表,找到返回数据所在节点的地址
	while (cur != phead)
	{
		if (cur->data == x)
			return cur;
		cur = cur->next;
	}
	//找不到就返回NULL
	return NULL;
}

//在pos位置之前插入数据
void ListInsert(LTNode* pos, LTDataType x)
{
	assert(pos);
	LTNode* newnode = BuyLTNode(x);

	//找pos的前一个节点
	LTNode* prev = pos->prev;

	//修改链接关系(当pos为第一个节点/最后一个节点时逻辑也成立)
	//ps:头插和尾插可以通过直接调用此函数来完成
	prev->next = newnode;
	newnode->prev = prev;
	newnode->next = pos;
	pos->prev = newnode;
}

//判断链表是否为空
bool IsEmpty(LTNode* phead)
{
	assert(phead);
	return phead == phead->next;  //当链表中只剩下头结点时链表为空,返回true
}

//在头部删除数据
void ListPopFront(LTNode* phead)
{
	assert(phead);
	assert(!IsEmpty(phead));  //删空时继续删除报错
	ListErase(phead->next->next);  //相当于删除第二个节点前的数据

	//assert(phead);
	//assert(!IsEmpty(phead));  //删空时继续删除报错
	记录第一个节点的下一个节点
	//LTNode* second = phead->next->next;

	释放第一个节点
	//free(phead->next);

	修改链接关系
	//phead->next = second;
	//second->prev = phead;
}

//在尾部删除数据
void ListPopBack(LTNode* phead)
{
	assert(phead);
	assert(!IsEmpty(phead));  //删空时继续删除报错
	ListErase(phead);  //相当于删除头结点前的数据

	//assert(phead);
	//assert(!IsEmpty(phead));  //删空时继续删除报错
	记录尾结点的上一个节点
	//LTNode* prev = phead->prev->prev;

	释放尾结点
	//free(phead->prev);

	修改链接关系
	//phead->prev = prev;
	//prev->next = phead;
}

//在pos位置之前删除数据
void ListErase(LTNode* pos)
{
	//这里有一个问题:pos不能是第一个节点的地址,因为我们不可能把哨兵位头结点给删除了,需要函数调用者自己注意
	assert(pos);
	//记录pos的前一个节点的前一个节点
	LTNode* prev = pos->prev->prev;

	//free pos前的节点
	free(pos->prev);

	//修改链接关系(当pos为第二个节点/头结点节点时逻辑也成立)
	//ps:头删和尾删可以通过直接调用此函数来完成
	prev->next = pos;
	pos->prev = prev;
}

//计算链表长度
size_t ListSize(LTNode* phead)
{
	assert(phead);
	size_t size = 0;
	LTNode* cur = phead->next;  //链表长度不包含头结点,因为头结点不存储有效数据
	while (cur != phead)
	{
		size++;
		cur = cur->next;
	}
	return size;
}

//修改链表数据
void ListModify(LTNode* pos, LTDataType x)
{
	assert(pos);
	pos->data = x;
}

3、test.c

#define _CRT_SECURE_NO_WARNINGS 1
#include "LIst.h"

void test1()  //测试插入
{
	//创建并初始化链表
	LTNode* plist = ListInit();

	//在头部插入数据
	ListPushFront(plist, 1);
	ListPushFront(plist, 2);
	ListPushFront(plist, 3);
	ListPrint(plist);

	//在尾部插入数据
	ListPushBack(plist, 4);
	ListPushBack(plist, 5);
	ListPushBack(plist, 6);
	ListPrint(plist);

	//在pos位置之前插入
	LTNode* pos = ListFind(plist, 3);
	if (pos)
	{
		ListInsert(pos, 30);
	}
	pos = ListFind(plist, 6);
	if (pos)
	{
		ListInsert(pos, 60);
	}
	pos = ListFind(plist, 1);
	if (pos)
	{
		ListInsert(pos, 10);
	}
	ListPrint(plist);

	//销毁链表
	ListDestory(plist);
	plist = NULL;  //需要我们手动置空plist
}

void test2()  //测试删除
{
	//创建并初始化链表
	LTNode* plist = ListInit();

	//在头部插入数据
	ListPushFront(plist, 1);
	ListPushFront(plist, 2);
	ListPushFront(plist, 3);
	ListPrint(plist);

	//在头部删除数据
	//ListPopFront(plist);
	//ListPopFront(plist);
	//ListPrint(plist);

	//在尾部删除数据
	//ListPopBack(plist);
	//ListPopBack(plist);
	//ListPopBack(plist);
	//ListPrint(plist);

	//删除pos位置之前的数据
	LTNode* pos = ListFind(plist, 2);
	if (pos)
	{
		ListErase(pos);
	}
	ListPrint(plist);
	pos = ListFind(plist, 1);
	if (pos)
	{
		ListErase(pos);
	}
	ListPrint(plist);

	//销毁链表
	ListDestory(plist);
	plist = NULL;  //需要我们手动改变plist
}

void test3()  //测试其他功能
{
	//创建并初始化链表
	LTNode* plist = ListInit();

	//在头部插入数据
	ListPushFront(plist, 1);
	ListPushFront(plist, 2);
	ListPushFront(plist, 3);
	ListPrint(plist);

	//计算链表长度
	size_t size = ListSize(plist);
	printf("%u\n", size);

	//修改链表数据
	LTNode* pos = ListFind(plist, 1);
	if (pos)
	{
		ListModify(pos, 10);
	}
	pos = ListFind(plist, 2);
	if (pos)
	{
		ListModify(pos, 20);
	}
	ListPrint(plist);

	//销毁链表
	ListDestory(plist);
	plist = NULL;  //需要我们手动改变plist
}

int main()
{
	//test1();  //测试插入
	//test2();  //测试删除
	//test3();  //测试其他功能
	return 0;
}

大家也可以去我的 Gitee 仓库中获取完整代码:List/List · 野猪佩奇/日常学习 - 码云 - 开源中国 (gitee.com)


四、顺序表和链表的区别

不同点- -顺序表 链表 (带头双向循环)
存储空间上 物理上一定连续 逻辑上连续,但物理上不一定连续
随机访问 支持:O(1) 不支持:O(N)
任意位置插入或者删除元素 可能需要搬移元素,效率低 只需修改指针指向,效率高
插入 动态顺序表,空间不够时需要扩容 按需申请,没有容量的概念
应用场景 元素高效存储 + 频繁访问 频繁的任意位置插入和删除
缓存利用率

顺序表的优点

1、尾插尾删效率高;

2、支持随机访问 (下标访问);

3、相比于链表结构,CPU 高速缓存命中率更高;

顺序表缺点

1、在其他位置的插入删除效率低;

2、扩容存在内存消耗和空间浪费;

链表 (带头双向循环) 的优点

1、任意位置插入删除效率都很高;

2、空间按需申请,没有空间浪费;

链表 (带头双向循环) 的缺点

1、由于需要频繁 malloc,所以和顺序表的内存消耗其实差不多;

2、不支持随机访问 (下标访问);

3、相比于顺序表结构,CPU 高速缓存命中率更低;

综合比较顺序表和链表的优缺点,其实在实际生活中,顺序表的应用比链表的应用还要多一些;其中顺序表支持随机访问是一个重要因素,另外还有顺序表 CPU 高速缓存命中率高和其他原因;下面我们来探讨 CPU 高速缓存命中率的问题。

我们知道,为了提高效率与降低成本,我们的计算机是分层存储的,具体的存储体系结构如下图:image-20220804233535966

从程序环境那一节的学习中我们知道,一个程序经过编译链接后被翻译成二进制指令,这些二进制指令由由 CPU 来执行;

但是,CPU 执行指令时,并不会直接去访问内存中的数据,而是会看数据是否存在于三级缓存中,如果在,就代表命中;如果不在,就代表未命中,未命中情况下数据会被先加载到三级缓存中,然后再次进行访问;

同时,计算机领域有一个局部性原理:当我们访问一个数据时,我们极有可能也会访问其周边的数据;所以在将数据加载到缓存时,我们并不是只将我们要访问的那个数据加载到缓存中,而是会将该数据所在的一长段内存空间的数据都加载到缓存中去,具体加载多少个字节取决于硬件;

对于我们的顺序表来说,它其中的数据是连续存放的,所以我们访问其中的数据不需要每次访问都去加载数据,可能我们第一次访问加载数据之后,我们第十次访问才再次加载数据;

而对于我们的链表来说,链表的每个节点的地址是不具有关联性的,所以在多数情况下我们加载一个数据所在的一长段内存空间时,该内存空间并不包含该节点后面的节点;从而使得我们的 CPU 在访问数据时需要去频繁的去加载数据,导致效率降低;

另外,链表加载数据时还会造成缓存污染,因为我们会将一个数据所在的一长段内存空间的数据都加载到缓存中去,而由于其后面的空间中可能并不包含链表的其他节点,即我们将无用的数据加载进了缓存中,就会造成缓存污染;

image-20220805000439546image-20220805000517005

关于缓存的更多知识可以参考下面这篇文章:与程序员相关的CPU缓存知识 | 酷 壳 - CoolShell



网站公告

欢迎关注微信公众号

今日签到

点亮在社区的每一天
签到