双链表的一二三点


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

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

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

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



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


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

在单链表部分我们已经介绍了链表的几种结构:

带头/不带头 – 是否具有哨兵位头结点,该节点不用于存储有效数据,对链表进行插入删除操作时也不会影响该节点;

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

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

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

可以看出,带头双向循环链表是结构最复杂的一种链表,但是它复杂的结构所带来的优势就是它管理数据非常简单,效率非常高;下面我们用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、在头部删除数据\n这里我们需要判断链表是否为空,如果为空继续删除元素就报错。
 

//在头部删除数据
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);
}



 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值