双向链表的基本应用

在上一章,我们阐述了单链表的基本应用,单链表即单向不带头不循环链表,是最基本的链表,

而这节我们要讨论的则是单链表的相反链表,双向带头循环链表。

双向链表顾名思义即拥有两个指向,概念模型如图所示

双向链表节点:next指向下一节点,prev指向上一节点,data存储数据,如图所示:

typedef struct ListNode
{
	int data;
	struct ListNode* next;
	struct ListNode* prev;

}ListNode;

创建节点的方法和单链表大同小异,原理基本一致,要注意利用malloc开辟动态内存,能够最大限度利用内存,这也是链表的优点之一,但也要考虑开辟内存失败的情况更加严谨,直接上代码:

ListNode* CreateNewNode(int x)
{
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	if (newnode == NULL)
	{
		perror("CreateNewNode::");
		return NULL;
	}
	newnode->data = x;
	newnode->next = newnode;
	newnode->prev = newnode;
	return newnode;
}

在具体阐述双向链表之前,我们先解释一下带头的意思,带头即带有头节点,即哨兵位,顾名思义,哨兵位即一个哨兵,不存储数据,只作为一个头节点,哨兵位的存在可以保证我们在写其他代码时,不用检测是否具有头节点,从而解释代码量,使逻辑更加清晰,即概念模型中所写的head节点。

哨兵位的prev指针指向尾节点,使我们不必再使用像单链表中的while循环寻找尾节点

创建哨兵位尽量使用不会出现的数据,我在这里设置哨兵位的数据为-1

哨兵位,初始化时,next和prev不能指向NULL,要指向自己,即自循环

void   LTInit(ListNode** phead)
{
	*phead = CreateNewNode(-1);
}

接下来我们介绍双向链表的基本应用,增,删,查,改

相比于单链表来说,双向链表由于具有两个指针,指向前和后,能够更加方便的完成基本操作,不必再新创建一个新的prev指针来保存cur的前一个位置

话不多说,我们开始

   1.增
  • 头插
  • 尾插
  • 中间插入

        头插: 这里的头插,并非是作为头节点插入,而是插入哨兵位后的第一个节点,由于我们具有哨兵位,首先需要让新节点的prev指向哨兵位phead,而next指向哨兵位的next节点,其次只需要将哨兵位的next节点的prev指向新节点,再让哨兵位的nex指针指向新节点即可。

tip:我们不用像单链表中考虑是否存在头节点的原因是,具有哨兵位作为头节点,而哨兵位一直存在直到链表销毁,所以其他的节点都不会作为头节点

文字有点抽象,大家看一下代码理解一下:

void ListPushFront(ListNode* phead, int x)
{
	assert(phead);
	ListNode* newnode = CreateNewNode(x);
	newnode->next = phead->next;
	newnode->prev = phead;
	phead->next->prev = newnode;
	phead->next = newnode;
}

        注意后行的代码不能交换位置,否则会使phead的next节点发生变化,无法正确修改节点

        尾插:

尾插和头插逻辑类似,因为有哨兵位,不需要考虑头节点的有无,只需要修改新节点的next和prev指针和其前后节点的指针。

首先需要将新节点的next指向哨兵位phead,prev指向哨兵位的prev节点。

再将尾节点的next指向新节点,将哨兵位的next指向新节点,使新节点成为新的尾节点

代码如下:

void ListPushBack(ListNode* phead, int x)
{
	assert(phead);
	ListNode* newnode = CreateNewNode(x);
	newnode->next = phead;
	newnode->prev = phead->prev;

	//以下代码不能交换位置!!!
	phead->prev->next = newnode;
	phead->prev = newnode;
}

中间插入:

中间插入和尾插,头插逻辑类似,只需要将插入的位置传入,在其后面插入新节点,在修改自身以及自身前后的prev和next节点即可。

void ListInsert(ListNode* pos, int x)
{
	assert(pos);
	ListNode* newnode = CreateNewNode(x);
	newnode->next = pos->next;
	newnode->prev = pos;
	pos->next->prev = newnode;
	pos->prev->next = newnode;
}
2.删
头删:

在双向链表中,我们默认哨兵位不作为有效节点,则删除节点不能删除哨兵位,只能在销毁部分销毁哨兵位。

头删我们需要新建一个del的节点,来记录删除的节点,否则在调整完链表的连接结构后,找不到删除的节点,无法销毁,造成内存泄漏。

我们的思路是先调整链表的连接顺序,再销毁节点,将哨兵位的next指向del的next,del的next的prev指针指向哨兵位,作为新的“头节点”。最后free掉del节点。

void ListPopFront(ListNode* phead)
{
	assert(phead&&phead->next != phead);
	ListNode* del = phead->next;
	phead->next = del->next;
	del->next->prev = phead;
	free(del);
	del = NULL;
}
尾删:

尾删思路与头插一直,创建del的节点,调整链表的连接顺序

将del的next指向哨兵位,哨兵位的prev指向del->prev节点

最后free掉del节点

void ListPopBack(ListNode* phead)
{
	//链表必须有效,且不能为空(只有一个哨兵位)
	assert(phead&&phead->next != phead);
	ListNode* del = phead->prev;
	del->prev->next = phead;
	phead->prev = del->prev;
	free(del);
	del = NULL;
}
中间删除:

思路与头删尾删一致,不在解释,大家自己看一下代码:

void ListErase(ListNode* pos)
{
	//理论上pos不包含phead,但是没有phead,无法增加校验
	assert(pos);
	pos->next->prev = pos->prev;
	pos->prev->next = pos->next;
	free(pos);
	pos = NULL;
}

3.查

查找节点的思路与单链表一致,用while循环遍历整个链表,来查找数据一一比对,但也有区别,由于双向链表是循环链表,所以我们创建cur节点,令其等于哨兵位的next,循环条件里写

cur节点!=phead(哨兵位),这样能够使cur完全遍历链表。

找到即返回该节点,找不到即返回NULL

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

4.改:

修改数据不复杂,只需要用find函数找到该节点,再修改其数据即可

void ListModify(ListNode* phead, int x,int y)
{
	ListNode* cur = ListFind(phead, x);
	if (cur == NULL)
	{
		return;
	}
	else
	{
		cur->data = y;
	}
}

5.打印:

打印一个链表可以让我们更加直接的观察链表结构,而逻辑也并不复杂,和Find函数中的循环结构一样,只需要利用while循环,限制条件为cur!=phead(哨兵位),依次打印数据即可。

void ListPrint(ListNode* phead)
{
	ListNode* cur = phead->next;
	while (cur != phead)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}
	printf("\n");
}

打印效果如下:

6.销毁

销毁链表的思路是,先以循环依次销毁掉哨兵位后的节点,最后再销毁哨兵位

我们创建一个指针cur指向哨兵位的next,再用while循环遍历链表,在循环中创建next,存储销毁的下一个节点,然后free当前cur节点,再将cur赋值为next继续进行循环。

在循环结束后,即只存在哨兵位,我们需要将哨兵位释放。代码如下:

void ListDestroy(ListNode* phead)
{
	ListNode* cur = phead->next;
	while (cur != phead)
	{
		ListNode* next = cur->next;
		free(cur);
		cur = next;
	}
	free(phead);
	phead = NULL;

}

注意:可以看到我们在释放掉哨兵位后,给phead置空,但由于我们函数传入的是一级指针,函数中只是形参,是实参的临时拷贝,对形参的赋值不会影响实参,所以置空只是一个习惯问题,可加可不加,但需要注意,在函数外的哨兵位成为了野指针,不要随意使用,也可以直接在函数结束后,手动置空。

有的人可能会问为什么不传入二级指针,这样置空不就会影响实参了吗,我们这样做的原因是保持接口变量一致,都作为一级指针,这样他人使用时,可以有更好的体验,不用这个函数用一级指针,那个函数用二级指针,效果不好。

7.关于函数参数问题:

我们的函数参数都为一级指针,因为哨兵位的存在,我们不需要改变头节点,所以传入二级指针,没有什么实质用处,反而可能会改变哨兵位,导致链表结构错误,因此我们使用一级指针。

总结:

二级指针的基本用法就已经讲完了,相比于单链表,他的结构更加复杂,但使用也变得更加方便,同时,哨兵位的使用也是我们新接触的东西,他的存在使我们的代码量大大减少,希望大家好好理解,有所收获。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值