数据结构入门指南:带头双向循环链表

前言

        链表一共有8种结构,但最常用的就是无头单向链表、和带头双向循环链表。单链表的结构存在着很多的缺陷,但它是许多数据结构的子结构,在刷题中经常见到,而带头双向循环链表弥补了单链表所有的缺陷,可以说是一个完美结构,虽然相对于单链表来说结构更复杂,但它的特性使它的实现逻辑较为简单,今天我就向大家一一介绍。


1.结构与优势

结构

优势

  1. 可以实现快速的插入和删除操作:由于链表的特性,插入和删除节点的时间复杂度为O(1),不需要像数组一样需要移动其他元素。
  2. 可以实现快速的遍历操作:双向链表可以通过前向或后向的指针进行遍历,而不需要像单向链表那样只能从头节点开始遍历。
  3. 可以实现双向遍历:双向链表可以通过前向和后向的指针进行双向遍历,可以方便地从任意一个节点开始向前或向后遍历。
  4. 可以实现循环遍历:由于链表的循环性质,可以通过不断移动指针进行循环遍历,不需要额外的循环条件判断。
  5. 可以实现更多高级功能:带头双向循环链表可以实现更多高级功能,如反转链表、查找倒数第k个节点等。

总结,带头双向循环链表具有灵活性和高效性,适用于需要频繁插入、删除和遍历操作的场景。

2.链表实现      

 2.1 定义链表

        步入正题,带头双向循环链表,首先它是双向的,什么是双向呢?在单链表定义时会有指向下一个节点的指针,而双向链表有两个指针,一个指向下一个节点,一个指向上一个节点,可以实现前后双向遍历。

typedef struct ListNode
{
	int data;
	struct ListNode* next;//指向下一个节点的指针
	struct ListNode* prev;//指向上一个节点的指针
}LN;

 2.2 创建头节点

         和无头单向链表不同,带头双向循环链表它有头节点,所以在开始执行各操作之前,我们先创建一个头节点并初始化。

        创建头节点需要新建一个节点,在其他插入接口中也需要新建节点,那我们就封装一个创建新节点的函数,最后返回新建节点的地址。

LN* BuyListNode(Datatype x)
{
	LN* newnode = (LN*)malloc(sizeof(LN));
	if (newnode == NULL)
	{
		perror("malloc");
		exit(-1);
	}
	newnode->data = x;
	newnode->next = NULL;
	newnode->prev = NULL;
	return newnode;
}

         和无头单链表不同,这里带有头节点,就需要对头节点进行初始化一下,虽然在创建节点时就已经对节点进行了初始化,但头节点的初始化与新建节点初始化需求不同所有这里需要单独进行初始化初始化节点时可以使用双指针,也可以直接返回头节点地址。

LN* InItNode()
{
	LN* phead = BuyListNode(-1);
	phead->next = phead;
	phead->prev = phead;
	return phead;
}

         头节点进行初始化时,只需将两个指针指向自己,然后返回头节点的地址即可。

 2.3 尾插

        建好头节点后要怎么链接节点呢?我们先写一个尾插来体验一下它的便捷。在单链表中想要进行尾插,还需要遍历一遍链表找到尾节点,而带头双向循环链表就不需要,可以通过头节点直接找到尾节点:tail  =  phead -> prev ;头节点的前一个节点就是尾节点。

我们新建一个节点:

         要想插入就需要把尾节点的next改为新节点的地址,新节点的prev改为尾节点tail的地址

        再把新节点的next改为头节点的地址,把头节点的prev改位新节点的地址。

 

 整体逻辑就是这样,具体代码如下:

void PushBack(LN* phead, Datatype x)
{
	assert(phead);
	LN* tail = phead->prev;
	LN* newnode = BuyListNode(x);

	tail->next = newnode;
	newnode->prev = tail;
	newnode->next = phead;
	phead->prev = newnode;
}

2.4 输出链表

        为了便于后续的测试,我们先写一个函数用于输出链表数据。输出函数很简单。

void PrintList(LN* phead)
{
	assert(phead);
	LN* cur = phead->next;//有效节点不包含头节点
	printf("phead <->");
	while (cur != phead)
	{
		printf(" %d <->", cur->data);
		cur = cur->next;
	}
}

         这里需要注意的是遍历链表时的循环条件,由于它是循环链表,所有结束条件有所变化。其次是输出数据时不需要输出头节点的数据,头节点的下一个节点才是有效数据。

我们可以来测试一下:

void test1()
{
	LN* plist = InItNode();
	PushBack(plist, 1);
	PushBack(plist, 2);
	PushBack(plist, 3);
	PushBack(plist, 4);
	PushBack(plist, 5);
	PrintList(plist);
}

int main()
{
	test1();
	return 0;
}

 输出效果如下:

 2.5 尾删

        基本逻辑理解后,可以先自主尝试写一下尾删。思路也是非常的简单,但要注意的是,有效节点为0的情况。把尾节点的前一个节点next改为头节点地址,头节点的prev改为尾节点的前一个节点,最后释放掉尾节点的空间。

void PopBack(LN* phead)
{
	assert(phead);
	assert(phead->next != phead);
	LN* tail = phead->prev;
	LN* tailprev = tail->prev;

	tailprev->next = phead;
	phead->prev = tailprev;
	free(tail);
}

2.6 头插

        接下来我们进行头插操作,我们使用的是带头的形式,所有这里进行头插时,不像单链表那样需要使用二级指针,我们需要改的是头节点中的内容,所有使用一级指针就可以解决。

         此外头插时一定要注意操作的次序,避免后续操作有误。例如:

        如果不提前保存第一个节点的地址, 就会导致新节点链接后续节点时找节点麻烦或出现错误

 正确的顺序如下图:

 代码实现:

void PushFront(LN* phead,Datatype x)
{
	assert(phead);
	LN* newnode = BuyListNode(x);
	newnode->next = phead->next;
	phead->next->prev = newnode;

	newnode->prev = phead;
	phead->next = newnode;

}

         当然新手不建议这样写,这样写很容易把人搞晕,我们可以先保存第一个节点的地址,这样就不会搞错。代码如下:

void PushFront(LN* phead,Datatype x)
{
	assert(phead);
	LN* newnode = BuyListNode(x);
	LN* first = phead->next;

	phead->next = newnode;
	newnode->prev = phead;

	newnode->next = first;
	first->prev = newnode;

}

2.7头删

        这里头删也是非常简单,为了避免出错,我们可以额外创建两个指针,记录第一个节点和第二个节点,逻辑较为简单,就不再画逻辑图,代码如下:

void PopFront(LN* phead)
{
	assert(phead);
	assert(phead->next != phead);

	LN* first = phead->next;
	LN* second = first->next;

	free(first);

	phead->next = second;
	second->prev = phead;

}

需要额外注意链表为空的情况。

2.8 节点个数

        统计节点个数很简单,和输出链表数据一样遍历一下链表统计链表个数代码如下:

int Listsize(LN* phead)
{
	assert(phead);
	int sz = 0;
	LN* cur = phead->next;
	while (cur != phead)
	{
		sz++;
		cur = cur->next;
	}
	return sz;
}

         有人可能会想:用头节点统计不也可以吗?还不用额外的去写一个函数。初始时把头节点的data初始化为0,每次插入data++。这种方式严格来说是不可以的,我们在写链表时每个节点不一定存储的是整形,也可能是字符型,虽然也能计数,但如果节点是1000呢?数据就溢出了,所以不建议那样写。

 2.9 查找

        查找也比较简单,不再多说,代码如下:

LN* ListFind(LN* phead, Datatype x)
{
	assert(phead);
	LN* cur = phead->next;
	while (cur!=phead)
	{
		if (cur->data == x)
			return cur;
		cur = cur->next;
	}
	return NULL;
}

 2.10 位置插入

         带头双向循环链表在位置插入时没有像单链表那样有位置前插入,位置后插入。这里的位置插入都是位置前插入,由于它是循环双向的链表,不像单链表那样不可以向前遍历,双向循环链表可以任意插入,所以位置后插入也并没有什么意义,就统一默认位置前插入。

        位置插入操作和上述的插入操作很类似,推荐额外创建一个指针保存节点信息,就可以避免操作时次序不当造成程序错误,代码如下:

void ListInsert(LN* pos, Datatype x)
{
	assert(pos);
	LN* newnode = BuyListNode(x);
	LN* posprev = pos->prev;

	posprev->next = newnode;
	newnode->prev = posprev;

	newnode->next = pos;
	pos->prev = newnode;

}

2.11 位置删除

        位置删除也一样很简单,我们多创建两个指针变量存储节点信息,就可以有效避免次序不当导致程序错误的问题。代码如下:

void PosErase(LN* pos)
{
	assert(pos);
	LN* posprev = pos->prev;
	LN* posnext = pos->next;

	free(pos);

	posprev->next = posnext;
	posnext->prev = posprev;
}

 2.12 销毁链表

        在链表使用完以后就要销毁,销毁也比较简单,代码如下:

void DestoryList(LN* phead)
{
	assert(phead);
	LN* cur = phead->next;
	while (cur != phead)
	{
		LN* next = cur->next;
		free(cur);
		cur = next;
	}
	free(phead);
}

         这样还需要注意的一点,在销毁链表的这个函数里虽然销毁了头节点,但是在头节点创建之初使用了结构体指针,在后续操作中都是通过这个指针将链表传递给函数,所以最后在调用销毁链表函数之后要将指向头节点的指针置为NULL,避免出现野指针。当然这里也可以使用二级指针在函数里将这个指针置为NULL。

 3. 源码

List.c

#include"List.h"
LN* BuyListNode(Datatype x)
{
	LN* newnode = (LN*)malloc(sizeof(LN));
	if (newnode == NULL)
	{
		perror("malloc");
		exit(-1);
	}
	newnode->data = x;
	newnode->next = NULL;
	newnode->prev = NULL;
	return newnode;
}
LN* InItNode()
{
	LN* phead = BuyListNode(-1);
	phead->next = phead;
	phead->prev = phead;
	return phead;
}
void PrintList(LN* phead)
{
	assert(phead);
	LN* cur = phead->next;
	printf("phead <->");
	while (cur != phead)
	{
		printf(" %d <->", cur->data);
		cur = cur->next;
	}
	printf("\n");
}
void PushBack(LN* phead, Datatype x)
{
	assert(phead);
	LN* tail = phead->prev;
	LN* newnode = BuyListNode(x);

	tail->next = newnode;
	newnode->prev = tail;
	newnode->next = phead;
	phead->prev = newnode;
}
void PopBack(LN* phead)
{
	assert(phead);
	assert(phead->next != phead);
	LN* tail = phead->prev;
	LN* tailprev = tail->prev;

	tailprev->next = phead;
	phead->prev = tailprev;
	free(tail);
}
void PushFront(LN* phead,Datatype x)
{
	assert(phead);
	/*LN* newnode = BuyListNode(x);
	newnode->next = phead->next;
	phead->next->prev = newnode;

	newnode->prev = phead;
	phead->next = newnode;*/
	LN* newnode = BuyListNode(x);
	LN* first = phead->next;
	phead->next = newnode;
	newnode->prev = phead;

	newnode->next = first;
	first->prev = newnode;

}
void PopFront(LN* phead)
{
	assert(phead);
	assert(phead->next != phead);

	LN* first = phead->next;
	LN* second = first->next;

	free(first);

	phead->next = second;
	second->prev = phead;

}
int Listsize(LN* phead)
{
	assert(phead);
	int sz = 0;
	LN* cur = phead->next;
	while (cur != phead)
	{
		sz++;
		cur = cur->next;
	}
	return sz;
}
LN* ListFind(LN* phead, Datatype x)
{
	assert(phead);
	LN* cur = phead->next;
	while (cur!=phead)
	{
		if (cur->data == x)
			return cur;
		cur = cur->next;
	}
	return NULL;
}
void ListInsert(LN* pos, Datatype x)
{
	assert(pos);
	LN* newnode = BuyListNode(x);
	LN* posprev = pos->prev;

	newnode->next = pos;
	pos->prev = newnode;

	newnode->prev = posprev;
	posprev->next = newnode;
}
void PosErase(LN* pos)
{
	assert(pos);
	LN* posprev = pos->prev;
	LN* posnext = pos->next;

	free(pos);

	posprev->next = posnext;
	posnext->prev = posprev;
}
void DestoryList(LN* phead)
{
	assert(phead);
	LN* cur = phead->next;
	while (cur != phead)
	{
		LN* next = cur->next;
		free(cur);
		cur = next;
	}
	free(phead);
}

 List.h

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

typedef int Datatype;
typedef struct ListNode
{
	int data;
	struct ListNode* next;
	struct ListNode* prev;
}LN;

LN* BuyListNode(Datatype x);
LN* InItNode();
void PrintList(LN* phead);
void PushBack(LN* phead,Datatype x);
void PopBack(LN* phead);
void PushFront(LN* phead, Datatype x);
void PopFront(LN* phead);
LN* ListFind(LN* phead, Datatype x);
int Listsize(LN* phead);
void ListInsert(LN* pos, Datatype x);
void PosErase(LN* pos);
void DestoryList(LN* phead);

 test.c

#include"List.h"
void test1()
{
	LN* plist = InItNode();
	PushBack(plist, 1);
	PushBack(plist, 2);
	PushBack(plist, 3);
	PushBack(plist, 4);
	PushBack(plist, 5);
	PushFront(plist, 0);
	PopBack(plist);
	ListInsert(plist, 10);
	LN* pos = ListFind(plist, 10);

	ListInsert(pos, 11);

	PosErase(pos);
	PrintList(plist);
	DestoryList(plist);
	plist = NULL;
}
void test2()
{
	LN* plist = InItNode();
	PushBack(plist, 1);
	PushBack(plist, 2);
	PushBack(plist, 3);
	PushBack(plist, 4);
	PushBack(plist, 5);
	//PushFront(plist, 0);

	PopFront(plist);
	PrintList(plist);
}

int main()
{
	test1();
	return 0;
}


 

总结

        带头双向循环链表作为一种数据结构,在解决问题时展现了其独特的优势。通过快速的插入和删除操作,以及灵活的双向遍历和循环遍历能力,它为我们提供了一种高效、灵活的数据组织方式。在实际应用中,我们可以充分发挥带头双向循环链表的特性,优化算法设计,提高程序的效率和可维护性。通过深入学习和掌握带头双向循环链表,我们可以更好地解决实际问题,提升自己的编程能力。希望本文能够帮助读者对带头双向循环链表有更深入的理解,并在实践中得到应用。最后,感谢阅读!

  • 72
    点赞
  • 61
    收藏
    觉得还不错? 一键收藏
  • 112
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 112
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值