目录
4、双向链表(带头双向循环链表)的实现:
其结构如下图所示:
注意:
若结构为不带头双向循环链表的话,则链表中最后一个节点中的后继指针指向该链表中第一个有效节点的地址,而该链表中第一个有效节点中的
前驱指针则指向该链表中最后一个节点的地址、

下面我们将对带头双向循环结构的链表进行实现:
4.1、test.c源文件:
#define _CRT_SECURE_NO_WARNINGS 1
#include"List.h"
void TestList1()
{
//由于链表为带头链表,所以即使链表中一个有效节点都没有时,其中肯定也会有一个哨兵位的节点,而一个节点又是一个结构体,像这种不管链表中是否存在
//有效节点,而哨兵位的头节点都一直存在,就和写通讯录创建的结构体一样,在没有联系人的情况下,该结构体仍存在,和单链表具有一定的区别,此时就需要对
//该结构体即此处的哨兵位的头节点进行初始化、
//LTNode* plist=NULL;
//之前所写的通讯录中结构体没有进行初始化是因为它是结构体变量,而此处的plist是指针变量,所以通常要对其进行初始化为空指针NULL,由于后面有初始化
//函数对其进行初始化,所以在此plist即使不赋值为NULL也是可以的,但是通常对于这些指针变量要赋值为NULL,否则后面的初始化调用函数中就不方便进行
//断言,因此,对于指针变量来说,即使后面有初始化函数,一般也要对指针变量进行初始化为NULL,而对于结构体变量,如果后面有初始化函数,则可以不对其进行初始化、
初始化、
//ListInit(&plist);//传址调用、
优化初始化函数至 传值 调用、
//plist = ListInit1(plist);//传值调用、
//再次优化初始化函数为不传参、
LTNode* plist = ListInit2();
//尾插
//传值调用、
ListPushBack(plist, 1);
//尾插
ListPushBack(plist, 2);
//尾插
ListPushBack(plist, 3);
//尾插
ListPushBack(plist, 4);
//打印
ListPrint(plist);
尾删
//ListPopBack(plist);
尾删
//ListPopBack(plist);
尾删
//ListPopBack(plist);
尾删
//ListPopBack(plist);
打印
//ListPrint(plist);
//查找附加修改、
LTNode* pos = ListFind(plist, 50);
if (pos == NULL)
{
printf("没找到\n");
}
else
{
printf("找到了,其地址为:%p\n", pos);
//修改、
//pos->data = 30;
//修改后进行打印、
//printf("修改完之后进行打印为:\n");
//打印
//ListPrint(plist);
在pos所在位置之前插入数据30、
//ListInsert(pos, 30);
打印
//ListPrint(plist);
//删除pos当前所在位置的节点、
ListErase(pos);
//手动置空、
pos=NULL;
//打印
ListPrint(plist);
}
//尾删
ListPopBack(plist);
//打印
ListPrint(plist);
//头删
ListPopFront(plist);
//打印
ListPrint(plist);
//头删
ListPopFront(plist);
//打印
ListPrint(plist);
//头删
ListPopFront(plist);
//打印
ListPrint(plist);
尾删
//ListPopBack(plist);
尾删
//ListPopBack(plist);
尾删
//ListPopBack(plist);
//打印
ListPrint(plist);
//尾删
ListPopBack(plist);
//打印
ListPrint(plist);
头插
//ListPushFront(plist,10);
打印
//ListPrint(plist);
头插
//ListPushFront(plist, 20);
打印
//ListPrint(plist);
尾插
//ListPushBack(plist, 60);
打印
//ListPrint(plist);
头插
//ListPushFront(plist, 30);
打印
//ListPrint(plist);
头删
//ListPopFront(plist);
//ListPopFront(plist);
//ListPopFront(plist);
//ListPopFront(plist);
//ListPopFront(plist);
//ListPopFront(plist);
//ListPopFront(plist);
//ListPopFront(plist);
//ListPopFront(plist);
打印
//ListPrint(plist);
//尾插
//传值调用、
ListPushBack(plist, 1);
//尾插
ListPushBack(plist, 2);
//尾插
ListPushBack(plist, 3);
//尾插
ListPushBack(plist, 4);
//打印
ListPrint(plist);
//判断链表是否为空链表、
int ret = ListEmpty(plist);
if (ret == 1)
{
printf("链表为空链表\n");
}
else
{
printf("链表为非空链表\n");
}
//计算链表的长度、
printf("链表的长度为:%d\n", ListSize(plist));
销毁双链表
//ListDestroy(&plist);//传址调用、
//printf("销毁成功\n");
//销毁双链表
//上面的销毁双链表采用的是传址调用,若采用传值调用也是可以的,只不过在调用销毁双链表函数执行完毕后在此处后面手动将plist置为空指针NULL,
//即手动的把实参plist置为空指针NULL、
ListDestroy1(plist);//传值调用、
//手动把实参中的plist置为空指针NULL、
plist = NULL;
printf("销毁成功\n");
}
int main()
{
TestList1();
return 0;
}
//常用的:
//对于函数名和类型的书写有两种方法:
//1、单词与单词之间首字母大写,其他字母小写,即,驼峰法,,,PushBack、
//2、全部小写,单词与单词之间使用_分隔、push_back、
//对于变量名的书写:
//第一个单词全部小写,其余单词只大写首字母、比如:newNode
//把某一个空间释放free后,则之前存储在该空间中的内容都会被置为随机值,这是在VS下的,在其他编译器下不一定会被置为随机值、
4.2、List.c源文件:
#define _CRT_SECURE_NO_WARNINGS 1
#include"List.h"
//创建新节点
LTNode* BuyLTNode(LTDataType x)
{
//在堆区上按需索取空间、
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
//在此直接对node断言也是可以的,因为node不能为空指针NULL,断言也相当于是检查,在VS2019下,只要给了一个指针变量,就必须要对该指针进行检查,不检查的话就会报错、
if (node == NULL)
{
//动态开辟空间失败、
printf("malloc fail\n");
return NULL;
}
else
{
//动态开辟内存空间成功、
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
}
//尾插
void ListPushBack(LTNode* plist, LTDataType x)
{
//由于是带头双向循环链表,当初始化调用函数执行结束之后,则头指针plist就指向了哨兵位的头节点,并且每一次尾插时,该头指针plist都不会发生改变,所以在此,直接
//传值调用即可,不需要传址调用,也是能够满足要求的,当然,使用传址调用也是可以的,这是哨兵位的功劳,所以对于单向带头不循环链表而言,也是存在哨兵位节点的,所以
//也可以直接进行传值调用,只要存在哨兵位的节点即可,默认情况下,单链表和哈希表一般都是不带哨兵位的、
//在调用函数内部没有改变plist的值,则只需要 传值 调用即可、
//在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//对于无头单向非循环链表,如果是非空链表进行尾插时,则是需要找尾的,而如果是空链表的话,则不需要找尾,直接使用即可,但是对于此时的双向循环带头链表而言,就不再
//分类讨论了,这是因为带头双向循环链表不可能为空链表,当尾插时,直接把要尾插的节点链接到前面的节点上即可,此时也不需要找尾,由双向循环带头链表的结构可知,
//尾节点的后继指针则指向哨兵位节点的地址,而哨兵位节点中的前驱指针则指向链表中尾节点的地址,所以此时不需要专门去找尾,直接使用哨兵位节点中的前驱指针
//就可以每一次都找到尾节点了,,但是,像双向不循环的链表是没办法通过该方法来找尾的,只有双向循环链表才可以、
//由于在无头单向非循环链表中进行尾插时,若链表不是空链表的话,则需要找尾,找尾就需要遍历链表,时间复杂度是:O(N),而此时的带头双向循环链表进行尾插时
//不需要判断链表是否为空链表,因为带头的话就一定不是空链表,其次也不需要每一次尾插的时候进行找尾,再次就是其时间复杂度是:O(1),不需要遍历链表、
//
记录尾节点的地址、
//LTNode* tail = plist->prev;
创建新节点、
//LTNode* newnode = BuyLTNode(x);
//tail->next = newnode;
//newnode->prev = tail;
//newnode->next = plist;
//plist->prev = newnode;
使用上述代码尾插时,即使链表中没有一个有效节点,也是可以的、
ListInsert(plist, x);
}
初始化、
//void ListInit(LTNode** pplist)
//{
// //即使结构体指针变量plist为空指针NULL,但是结构体指针变量plist的 地址 一定不是空指针NULL,并且该结构体指针变量
// //plist的地址一定不能是空指针NULL,若为空指针NULL,下面对其解引用,即*pplist相当于对空指针NULL进行解引用,会出现错误、
// assert(pplist);
//
// //此处不可以对*pplist进行断言,因为在最初时已经将plist初始化为了空指针NULL,当plist为NULL时,仍要进行下面的操作,不需要报错,即plist可以等于NULL、
//
// //理论上,哨兵位的头节点不存储有效数据,但是对于创建新节点的函数BuySLTNode,则必须要传过去一个值,所以就随便给一个值即可,假设该值为0、
//
// //在调用函数内部改变了*pplist的值,所以选用 传址 调用、
// *pplist = BuyLTNode(0);
// //由于链表为双向带头循环链表,在该链表中,即使一个有效节点也没有的话,也会存在一个哨兵位的头节点,而即使只有一个哨兵位的头节点也要满足双向循环带头的结构、
// (*pplist)->next = *pplist;
// (*pplist)->prev = *pplist;
//}
//优化初始化函数至 传值 调用、
//LTNode* ListInit1(LTNode* plist)
//{
// //若想要实参部分中的头指针随着形参部分中的头指针的改变而改变的话,则有两种方法:
// //1、传址调用、
// //2、传值调用,但是要把形参部分中的头指针返回出去、
//
// //此处不可以对plist进行断言,因为在最初时已经将plist初始化为了空指针NULL,当plist为NULL时,仍要进行下面的操作,不需要报错,即plist可以等于NULL、
//
// //理论上,哨兵位的头节点不存储有效数据,但是对于创建新节点的函数BuySLTNode,则必须要传过去一个值,所以就随便给一个值即可,假设该值为0、
//
// //在调用函数内部,虽然形参中的头指针plist发生了改变,但是由于返回值是形参中的头指针,所以选择 传值 调用也是可以的、
// plist = BuyLTNode(0);
// //由于链表为双向带头循环链表,在该链表中,即使一个有效节点也没有的话,也会存在一个哨兵位的头节点,而即使只有一个哨兵位的头节点也要满足双向循环带头的结构、
// plist->next = plist;
// plist->prev = plist;
// return plist;
//}
//再次优化初始化函数至不传参、
LTNode* ListInit2()//不需要传参、
{
//理论上,哨兵位的头节点不存储有效数据,但是对于创建新节点的函数BuySLTNode,则必须要传过去一个值,所以就随便给一个值即可,假设该值为0、
LTNode* plist = BuyLTNode(0);
//由于链表为双向带头循环链表,在该链表中,即使一个有效节点也没有的话,也会存在一个哨兵位的头节点,而即使只有一个哨兵位的头节点也要满足双向循环带头的结构、
plist->next = plist;
plist->prev = plist;
return plist;
}
//尾删
void ListPopBack(LTNode* plist)
{
//暴力检查,当链表中只有哨兵位额头节点时,不可以再进行尾删操作,若再进行该操作,则直接报错、
//assert(plist->prev != plist);
//assert(plist->next != plist);
//温柔检查,即当链表中已经没有了有效节点,此时如果打印链表,打印出来的是没有任何内容的,如果再进行尾删的话,不让程序直接报错,可以进行尾删,只不过没有删除掉任何东西,
//当再打印链表时,打印出来的仍是没有任何内容、
if (plist->next == plist)
{
//说明链表中只有哨兵位的头节点这一个节点,而没有有效节点的存在、
return;
}
else
{
说明链表中除了哨兵位的头节点外还存在有效节点、
由于是带头双向循环链表,当初始化调用函数执行结束之后,则头指针plist就指向了哨兵位的头节点,并且每一次尾删时,该头指针plist都不会发生改变,
即使链表中有效节点全部删完,此时链表中还有哨兵位的头节点,头节点不会被删除,所以指针变量plist并没有发生改变,所以使用 传值 调用是可以的,
当然, 使用 传址 调用也是可以的, 这是哨兵位的功劳, 所以对于单向带头不循环链表而言, 也是存在哨兵位节点的, 所以也可以直接进行 传值 调用,
只要存在哨兵位的节点即可,默认情况下,单链表和哈希表一般都是不带哨兵位的、
在调用函数内部没有改变plist的值,则只需要 传值 调用即可、
在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
//assert(plist);
记录尾节点的地址、
不需要担心plist为空指针NULL,因为,在这种链表中,plist不可能为空指针NULL、
//LTNode* tail = plist->prev;
记录尾节点的前一个节点的地址、
//LTNode* tailprev = tail->prev;
释放尾节点的空间、
//free(tail);
//tail = NULL;
//tailprev->next = plist;
//plist->prev = tailprev;
由于在无头单向非循环链表中进行尾删时,如果链表中还存在多个有效节点时,就需要遍历链表找到链表中尾节点的前一个节点,所以,时间复杂度是:O(N),而此时的带头双向循环链表进行尾删时
当链表中还存在有效节点时,不需要每一次尾删都去找尾节点的前一个节点,所以不需要进行遍历链表,则其时间复杂度是:O(1)、
//复用删除pos所在位置的节点代码、
ListErase(plist->prev);
}
}
//打印
void ListPrint(LTNode* plist)
{
//在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//在调用函数内部没有改变plist的值,则只需要 传值 调用即可、
//打印链表时,主要是打印链表中每个有效节点的val值,对于哨兵位的头节点虽然它存储了一个数值,但是我们要认为哨兵位的头节点内不存储有效数据,
//所以在此只把有效节点中的val值打印出来即可,所以要从第一个有效节点开始打印其中的val值,由于是带头双向循环链表,所以每个节点,包括哨兵位的头节点
//他们其中的两个指针都不可能指向空指针NULL,定义指针变量cur,使之从第一个有效节点开始,依次往后遍历,直到指针变量cur等于头指针plist时,停止打印
//即,打印带头双向循环链表的val值时,只把一次循环中的有效值打印出来即可,不包括哨兵位的头节点中的val值、
LTNode* cur = plist->next;
while (cur != plist)
{
printf("%d ", cur->data);
cur = cur->next;
//虽然是双向链表,但是打印时的遍历过程中没有用到前驱指针prev、
}
printf("\n");
//当链表中没有有效节点存在的话,即只有一个哨兵位的头节点时,所以链表中就没有有效的val值,此时打印的话就不打印任何东西、
}
//查找附加修改、
LTNode* ListFind(LTNode* plist, LTDataType x)
{
//对于查找功能而言,如果找到了则就返回该位置的地址,如果找不到则就返回空指针NULL、
//在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//在调用函数内部没有改变指针变量plist的值,所以直接 传值 调用即可、
//查找也是在有效数据中进行查找,而且只要在一次循环中进行查找即可,所以和打印的条件类似、
LTNode* cur = plist->next;
while (cur != plist)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
//虽然是双向链表,但是查找时的遍历过程中没有用到前驱指针prev、
}
return NULL;
}
//在pos所在位置之 前 进行插入x、
void ListInsert(LTNode* pos, LTDataType x)
{
//不需要通过在调用函数内部改变pos的值从而改变外面的pos的值,所以直接 传值 调用即可、
//断言,保证pos位置的地址是有效的、
assert(pos);
//个人感觉这一步也可以省略是因为,如果程序能够执行到该调用函数的话,说明了pos一定不等于空指针NULL,若等于空指针根本就不能执行该调用函数,所以这一步省略也是可以的,加上也不影响、
//这是鉴于在main函数里已经把关系捋清楚之后的情况,即把ListErase,ListInsert函数的调用放在找到pos的模块内,即pos不等于NULL模块内是可以省略pos的断言的,
//但是如果在main函数内没有把这些调用函数放在其中的话,就需要断言一下pos防止出现问题,所以,我们在这里最好要进行断言一下才会更加安全、
//创建新节点、
LTNode* newnode = BuyLTNode(x);
方法一:
//pos->prev->next = newnode;
//newnode->prev = pos->prev;
//newnode->next = pos;
//pos->prev = newnode;
//方法二:
LTNode* posPrev = pos->prev;
pos->prev = newnode;
newnode->next = pos;
posPrev->next = newnode;
newnode->prev = posPrev;
}
//在此,不管pos所在位置在哪里,那么pos所在位置之前肯定会有节点的存在,所以头指针plist都不会发生改变、
//头插
void ListPushFront(LTNode* plist, LTDataType x)
{
//在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//由于是带头双向循环链表,当初始化调用函数执行结束之后,则头指针plist就指向了哨兵位的头节点,并且每一次头插时,该头指针plist都不会发生改变,所以在此,直接
//传值调用即可,不需要传址调用,也是能够满足要求的,当然,使用传址调用也是可以的,这是哨兵位的功劳,所以对于单向带头不循环链表而言,也是存在哨兵位节点的,所以
//也可以直接进行传值调用,只要存在哨兵位的节点即可,默认情况下,单链表和哈希表一般都是不带哨兵位的、
//在调用函数内部没有改变plist的值,则只需要 传值 调用即可、
//创建新节点、
LTNode* newnode = BuyLTNode(x);
方法一:
//LTNode* frist = plist->next;
//plist->next = newnode;
//newnode->prev = plist;
//newnode->next = frist;
//frist->prev = newnode;
方法二:
//plist->next->prev = newnode;
//newnode->next = plist->next;
//plist->next = newnode;
//newnode->prev = plist;
ListInsert(plist->next, x);
}
//删除pos当前所在位置的节点、
void ListErase(LTNode* pos)
{
//不需要通过在调用函数内部改变pos的值从而改变外面的pos的值,所以直接 传值 调用即可、
//断言,保证pos位置的地址是有效的、
assert(pos);
//个人感觉这一步也可以省略是因为,如果程序能够执行到该调用函数的话,说明了pos一定不等于空指针NULL,此时pos所在位置的节点一定是有效节点而不是哨兵位的头节点,因此可以直接对其进行删除,而不需要判断,
//若等于空指针根本就不能执行该调用函数,所以这一步断言省略也是可以的,加上也不影响、
//这是鉴于在main函数里已经把关系捋清楚之后的情况,即把ListErase,ListInsert函数的调用放在找到pos的模块内,即pos不等于NULL的模块内是可以省略pos的断言的,
//但是如果在main函数内没有把这些调用函数放在其中的话,就需要断言一下pos防止出现问题,所以,我们在这里最好要进行断言一下才会更加安全、
//由于在外面已经把ListErase函数的调用放在找到pos的模块内,所以当程序执行到该调用函数时,pos一定不为空指针NULL,即链表中肯定还存在有效节点,现在pos所在位置一定是
//有效节点,所以该节点是可以进行删除的,所以不需要加if语句进行判断,但是这前提是在调用函数外面已经把关系捋清楚之后的情况,若关系不清楚的话,pos是有可能指向空指针NULL的
//此时就需要对pos进行断言,而下面就不需要加上if语句进行判断了,因为只要pos进行了断言,那么当pos为空指针NULL时,一定不会执行下面的语句,而是直接报错、
LTNode* prev = pos->prev;
LTNode* next = pos->next;
free(pos);
pos = NULL;
//在此处的置空是不起作用的,因为是传值调用,此处只是把形参pos置为了空指针NULL,实参中的pos并没有发生改变,要想实参随着形参的改变而改变,1、可以传址调用即可,2、也可以传值调用但是最后再把实参中的pos手动置为空指针NULL、
prev->next = next;
next->prev = prev;
}
//头删
void ListPopFront(LTNode* plist)
{
//在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//由于是带头双向循环链表,当初始化调用函数执行结束之后,则头指针plist就指向了哨兵位的头节点,并且每一次头删时,该头指针plist都不会发生改变,
//即使链表中有效节点全部删完,此时链表中还有哨兵位的头节点,头节点不会被删除,所以指针变量plist并没有发生改变,所以使用 传值 调用是可以的,
//当然, 使用 传址 调用也是可以的, 这是哨兵位的功劳, 所以对于单向带头不循环链表而言, 也是存在哨兵位节点的, 所以也可以直接进行 传值 调用,
//只要存在哨兵位的节点即可,默认情况下,单链表和哈希表一般都是不带哨兵位的、
//在调用函数内部没有改变plist的值,则只需要 传值 调用即可、
//暴力检查,当链表中只有哨兵位额头节点时,不可以再进行头删操作,若再进行该操作,则直接报错、
//assert(plist->prev != plist);
//assert(plist->next != plist);
//温柔检查,即当链表中已经没有了有效节点,此时如果打印链表,打印出来的是没有任何内容的,如果再进行头删的话,不让程序直接报错,可以进行头删,只不过没有删除掉任何东西,
//当再打印链表时,打印出来的仍是没有任何内容、
if (plist->next == plist)
{
//说明链表中只有哨兵位的头节点这一个节点,而没有有效节点的存在、
return;
}
else
{
说明链表中除了哨兵位的头节点外还存在有效节点、
方法一:
//LTNode* frist = plist->next;
//plist->next = frist->next;
//frist->next->prev = plist;
//free(frist);
//frist = NULL;
方法二:
LTNode* frist = plist->next;
LTNode* second = frist->next;
free(frist);
frist = NULL;
plist->next = second;
second->prev = plist;
//复用删除pos所在位置的节点、
ListErase(plist->next);
}
}
//判断链表是否为空链表、
//所谓空链表即指链表中只有哨兵位的头节点而没有有效节点,则为空链表,若为空链表,则返回1,1为真
//若该链表中除了哨兵位的头节点外还有有效节点,则不是空链表,若为非空链表,则返回0,0是假、
int ListEmpty(LTNode* plist)
{
//在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//在调用函数内部没有改变plist的值,则只需要 传值 调用即可、
return plist->next == plist ? 1 : 0;
}
//判断链表的长度、
//所谓链表的长度即指 有效节点 的个数,不包括哨兵位的头节点,返回有效节点的个数、
int ListSize(LTNode* plist)
{
//在此可以对plist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//在调用函数内部没有改变plist的值,则只需要 传值 调用即可、
//该过程也是在 有效数据中 进行记录,而且只要在一次循环中进行即可、
int sz = 0;//用来记录有效节点的个数、
LTNode* cur = plist->next;
while (cur != plist)
{
sz++;
cur = cur->next;
//虽然是双向链表,但是该过程中的遍历过程中没有用到前驱指针prev、
}
return sz;
}
//销毁双链表
void ListDestroy(LTNode** pplist)
{
//在此可以对*pplist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(*pplist);
//plist的地址一定不能是空指针NULL,若为空指针NULL,下面对其解引用,即*pplist相当于对空指针NULL进行解引用,会出现错误、
assert(pplist);
//在调用函数内部销毁 有效节点 所占空间的时候并没有改变plist的值,但是在销毁 哨兵位的头节点 所占内存空间时要改变plist的值,所以需要 传址 调用、
//所谓销毁双链表即指销毁双链表中为 有效节点 和 为哨兵位节点而开辟的内存空间、
//即销毁链表中所有节点的空间,但是要先把为有效节点开辟的空间全部销毁完毕之后再在最后销毁为哨兵位的头节点所开辟的内存空间。
LTNode* cur = (*pplist)->next;
while (cur != *pplist)
{
//如果直接销毁cur所指的空间,那么cur所在位置的下一个节点的地址就找不到了,所以在销毁之前要先进行记录、
//记录指针变量cur所在位置的下一个有效节点的地址、
LTNode* next = cur->next;
free(cur);
cur = next;
//虽然是双向链表,但是该过程中的遍历过程中没有用到前驱指针prev、
}
//到此为止就把所有为有效节点开辟的内存空间全部销毁完毕,再进行为哨兵位节点所开辟的内存空间的销毁、
free(cur);
cur = NULL;
//此时把哨兵位的节点所占空间进行销毁,那么指针变量plist就变成了野指针,如果采用的是 传值 调用,如果在此直接把plist置为空指针,则
//实参中的指针变量plist不会变成空指针,所以在此可以采用传址调用、
//再把*pplist置为空指针NULL,防止出现野指针、
*pplist = NULL;
}
//销毁双链表-传值调用、
void ListDestroy1(LTNode* plist)
{
//在此可以对*pplist进行断言,因为对于带头双向循环链表而言,带有哨兵位的头节点,所以头指针plist不可能为空指针NULL,若真为NULL,则一定是错误的、
assert(plist);
//在调用函数内部销毁 有效节点 所占空间的时候并没有改变plist的值,但是在销毁 哨兵位的头节点 所占内存空间时要改变plist的值,而此处只考虑 传值 调用,虽然在调用函数
//内部不能通过改变形参中的plist将实参中的plist置为NULL,但是可以在调用函数外部直接手动将实参plist置为空指针NULL、
//所谓销毁双链表即指销毁双链表中为 有效节点 和 为哨兵位节点而开辟的内存空间、
//即销毁链表中所有节点的空间,但是要先把为有效节点开辟的空间全部销毁完毕之后再在最后销毁为哨兵位的头节点所开辟的内存空间。
LTNode* cur = plist->next;
while (cur != plist)
{
//如果直接销毁cur所指的空间,那么cur所在位置的下一个节点的地址就找不到了,所以在销毁之前要先进行记录、
//记录指针变量cur所在位置的下一个有效节点的地址、
LTNode* next = cur->next;
//ListErase(cur);
//直接使用free并没有把前后节点再链接起来,也不需要链接,因为整个链表都将要销毁了,而如果复用ListErase的话会把前后两个节点再链接起来,其实没啥必要、
free(cur);
cur = next;
//虽然是双向链表,但是该过程中的遍历过程中没有用到前驱指针prev、
}
//到此为止就把所有为有效节点开辟的内存空间全部销毁完毕,再进行为哨兵位节点所开辟的内存空间的销毁、
free(cur);
cur = NULL;
//此时把哨兵位的节点所占空间进行销毁,那么指针变量plist就变成了野指针,如果采用的是 传值 调用,如果在此直接把plist置为空指针,则
//实参中的指针变量plist不会变成空指针、
plist = NULL;
//上面这一步只是把形参中的plist置为了空指针NULL,但是实参中的plist仍是野指针,可以在调用函数外部手动把实参中的plist置为空指针NULL、
}
4.3、List.h头文件:
#pragma once //防止头文件被重复包含、
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
//哨兵位的头节点中不存储有效的data值,最好不要用其来存储有效节点的个数,因为,如果节点中的data的类型
//是char,带符号的char类型的话,已知其取值范围是:-127-128,而哨兵位的头节点中的data值的类型和有效节点中的data
//值的类型是一样的,所以哨兵位的头节点中的data值的类型也是char类型,而如果链表中存储的有效节点的个数超过了
//128个的话,char类型的数据就会发生截断,所以会导致哨兵位的头节点中的data值进入循环,再次从1开始,这样的话,
//就失去了它的意义,如果节点中的data的类型是double的话,那么哨兵位的头节点中的data值也是double类型,但是对于
//有效节点的个数来说的话,不可能为小数,必须使用整除来存储,比如,有效节点的个数为5个,若data的类型是double的话
//则显示的是5.0,,而对于有效节点的个数来说的话,5.0是没有意义的,或者,再如果节点中的data类型为int*的话,此时如果
//使用哨兵位的头节点中的data值来记录有效节点的个数的话就更加没有意义了,所以,对于上述所说的,即哨兵位的头节点
//中的data值用来存储有效节点的个数的话,只适用于节点中的data类型为int,long,long long 类型的data值,并且还要
//保证存储的有效节点的个数不能超过他们的范围才是可以的,这种说法不够通用,所以,就默认哨兵位的头节点中不存储
//有效数据即可、
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
//定义前驱指针、
struct ListNode* prev;
//定义后继指针、
struct ListNode* next; //不可以使用LTNode*来代替struct ListNode*、
}LTNode;
初始化函数,传址调用、
//void ListInit(LTNode** pplist);
//优化初始化函数至 传值 调用、
LTNode* ListInit1(LTNode* plist);
//再次优化初始化函数至不传参、
LTNode* ListInit2();//不需要传参、
//尾插
void ListPushBack(LTNode* plist, LTDataType x);
//尾删
void ListPopBack(LTNode* plist);
//打印
void ListPrint(LTNode* plist);
//头插
void ListPushFront(LTNode* plist, LTDataType x);
//头删
void ListPopFront(LTNode* plist);
//查找附加修改、
LTNode* ListFind(LTNode* plist, LTDataType x);
//在pos当前所在位置之 前 进行插入x、
void ListInsert(LTNode* pos, LTDataType x);
//删除pos当前所在位置的节点、
void ListErase(LTNode* pos);
//判断链表是否为空链表、
//所谓空链表即指链表中只有哨兵位的头节点而没有有效节点,则为空链表,若为空链表,则返回1,1为真
//若该链表中除了哨兵位的头节点外还有有效节点,则不是空链表,若为非空链表,则返回0,0是假、
int ListEmpty(LTNode* plist);
//判断链表的长度、
//所谓链表的长度即指有效节点的个数,不包括哨兵位的头节点,返回有效节点的个数、
int ListSize(LTNode* plist);
//销毁双链表-传址调用、
void ListDestroy(LTNode** pplist);
//销毁双链表-传值调用、
void ListDestroy1(LTNode* plist);
5、顺序表和链表的区别:
链表主要指的是双向带头循环链表、
顺序表优点:
1、
按下标进行随机访问,比如可以进行二分查找,顺序表可以随机访问,直接通过下标算出中间位置,复杂度O(1)就能访问,但是对于链表而
言,想要找到中间的位置,则需要遍历链表才可以,那么时间复杂度就是O(N),除此之外,包括排序等,最好使用顺序表进行排序,比较方便、
2、
顺序表的CPU高速缓存命中率比较高,硬件体系主要包括:CPU,进行运算,内存,用来存储数据,硬盘,硬盘分两个,分别为:ssd和机械
盘,机械盘稍微慢一些,ssd则快一些、远端存储,比如云存储、内存一般是带电存储,而硬盘一般是不带电存储,不带电存储即永久性存储,
云存储一般是在其他机器上通过网络进行存储,而平时所写的顺序表和链表一般都是存储在内存中的,内存又是带电存储,如果想要做到存储持
久化则还需要存储到硬盘中去,一般都是通过文件或者是数据库的形式将其存储到硬盘中,当分别遍历顺序表和链表时,会发现遍历顺序表比遍
历链表的速度会更高一些,这是因为,在顺序表中,CPU高速缓存的命中率较高,CPU的速度较高,内存的速度跟不上CPU的速度,所以在电脑
中会采用一些更快的材料,其排名为:寄存器,是最快的存储设备,一般都是4-8byte,比较小,常见的寄存器有:eax,ebx,ecx,edx,以及
一些常见的栈帧寄存器:exp,edp,esp等,寄存器虽然快,但是太小了,所以一般的电脑又会设计一个三级缓存,即L1Cache, L2 Cache,
L3 Cache,虽然一级比一级所占内存空间小,但是一级比一级速度更快,即,1的速度最快,所占空间最小,价格贵,,写出来的顺序表和链表
的代码都是CPU去执行,如果打印顺序表和链表就需要去内存中取数据,编译链接将代码转换成二进制指令,编译链接后生成可执行程序,CPU
执行该程序,CPU要去访问内存,但是,CPU的速度比内存的速度要快,内存相对于硬盘而言,速度很快,但是相对与CPU而言还是慢的,如果
CPU每一次去内存中取一个数据,然后打印,然后再取,再打印,就会导致,CPU就会一直等待内存,打印的效率较低,为了解决内存跟不上的
问题,就加了三级缓存和寄存器,这两者中,寄存器速度快于三级缓存,但是都比内存的速度快的多,所以CPU不会直接到内存中去访问取数
据,它会把数据从内存中加载到三级缓存或者寄存器中,如果数据所占空间比较小,比如4-8byte的话,就会直接加载到寄存器,如果数据所占空
间比较大,通常会加载到缓存中,先从三级开始,然后到二级,最后到一级,直接在一级中去交互,寄存器和三级缓存是同一级别的,寄存器速
度更快,但是数量有限,如果顺序表和链表中的val值都有4个数据,即,1,2,3,4,,此时要进行打印,比如打印顺序表中的数据1,CPU就
会去高速缓存中查找数据1所在的地址,由于在此之前没有遍历过顺序表,所以,在高速缓存中是找不到数据val值为1的地址,所以就会造成未命
中,则需要把数据1所占的字节加载到高速缓存中,但是在加载过程中为了提高效率,一般都是要进行预加载,而不会每次都4个byte的去加载,
不是数据所占空间有多大就加载几个字节进去,而是会加载多个字节进去,具体加载多少个byte则取决于机器(硬件),取决于CPU的字长, 访问
数据1的时候,数据1会有一个地址,假设为0x10ff4f60,而cpu高速缓存的命中率即指,高速缓存一般指的是L1,L2,L3这三级缓存,也会包括寄存
器,cpu去访问0x10ff4f60地址处的数据,它会去高速缓存中看,如果该地址在高速缓存中,则直接访问,就称为命中,不在就称为不命中,不命
中的话,cpu才会去内存中访问,同时也不会一次读取一个数据,假设一次加载16byte,,就会直接把这16byte都加载到高速缓存汇中去,由于
顺序表的物理结构是连续的,就会把数据1,2,3,4都加载到高速缓存中去,此时当遍历到val值为2的时候,CPU去高速缓存中找val值为2所在
的地址,就能找到,则就能命中,同理,val值为3和4时,都能命中,这是由于顺序表的物理结构是连续的结果,所以,连续的物理结构会提高命
中率,当然命中率越高,效率就越高、如果现在要访问链表,对于链表中的val值所在的地址假设为:0x30,0x90,0x60,0x40,,如果在之前
没有访问过链表的话,那么这四个地址都不在高速缓存中,所以cpu去高速缓存中找地址0x30的话,一定是不命中的,此时还会进行预加载,假
设一次加载16byte,,则会加载到0x40,但是加载进去的16byte只有前4byte是有用的,后面12个byte加载进去也是不起作用的,这是因为,当
链表遍历到val值为2的时候,即地址为0x90时,由于上一次加载过程中只加载到了0x40,所以0x90是不在高速缓存中的,当0x90不在高速缓存
中时,则0x90的也是命中失败的,此时,还要进行预加载,即从0x90起再加载16byte,由于高速缓存所占内存空间是有限的,所以会把上一次预
加载中的没有意义的空间通过算法Liu再此把这些空间从高速缓存中清除出去,即把第一次预加载中的后12byte都清除了出去,此时,从0x90加
载16byte到0xa0,当遍历到val值为3,即地址为0x60时,则高速缓存中仍然没有0x60,所以0x60也是命中失败,此时还要进行预加载,即从
0x60起加载到0x70,同时把第二次预加载中的后12个byte清除出去,当遍历到val值为4,即地址为0x40时,高速缓存中仍没有其地址,所以还是
命中失败,然后再进行预加载,所以遍历链表中的四个数据时,都是不命中的,所以当遍历链表中的这4个val值是,不算清除的话,一共进入了
高速缓存中64个byte,但是有效的只有16byte,所以还剩下48byte没有被使用,但是这48个byte都进入过高速缓存中,就会对高速缓存造成缓存
污染,所以,当对链表进行访问时,不仅命中率低,只是命中率比较低,并不是一定不会命中,还会造成缓存污染,这一切都是由于链表的物理
结构不是连续而导致的,链表的物理结构有可能是连续的,只不过概率比较低、
3、
顺序表的空间利用率更高,只需要存储数据即可,但是链表还要存储指针,所以使用的空间要更多一些,堆的排序必须作用到数组上面、
顺序表缺点:
1、
空间不够则需要增容,增容又分为原地增容和异地增容,当后面的空间足够大时就可以进行原地增容,原地增容会有一小部分的性能消耗,
当后面的空间不够大时就要进行异地增容,首先要开辟一块新的连续的内存空间,拷贝数据,释放原空间,返回新空间的地址,这需要很大的性
能消耗,所以当空间不够而进行增容时,不管是原地增容还是异地增容都会存在一定程度上的性能消耗、同时可能也会存在一定的空间浪费、
2、
在顺序表的头部或者中间插入或删除数据时,需要挪动数据,挪动数据则需要进行遍历顺序表,所以时间复杂度就是:O(N),效率较低、
链表(以双向带头循环链表为例):
优点:
链表的优点对应的是顺序表的缺点、
1、
链表插入数据没有增容的概念,按需索取和释放空间,需要存储一个数据,就申请一块内存空间,所以不需要增容,则不存在增容带来的性能消
耗,也不存在空间浪费、
2、
在任意位置插入或删除数据都不需要挪动数据,则时间复杂度都是:O(1),即可以在任意位置以时间复杂度O(1)内插入或删除数据、
缺点:
链表的缺点对应的就是顺序表的优点、
不支持通过下标进行随机访问,要想访问链表则需要从头开始进行遍历,效率较低、有些算法不适合使用链表来实现,如:二分查找,排序等、
总结:顺序表和链表是相辅相成的,互相弥补着对方的缺点,根据不同的场景来选择到底使用链表还是顺序表、

操作系统,软件的运行,时实需要的存储,都是通过内存进行存储的,内存速度快,但是价格较贵,内存是带电存储,本地磁(硬)盘的速度
慢,但是价格便宜,不带电存储,永久性存储、只有硬盘是不带电存储,其他的都是带电存储、
关于顺序表和链表的知识点已经分享完毕,谢谢大家!