数据结构-双向带头循环链表

1.1

概念:

  • 双向带头循环链表是一种复杂但功能强大的数据结构。它是在双向链表的基础上,增加了头节点并且使链表形成循环结构。
  • 头节点是一个特殊的节点,它不存储实际的数据元素(当然也可以存储一些如链表长度等附加信息),主要起到标识链表开始位置的作用。
  • 循环结构意味着链表的最后一个节点的下一个指针指向头节点,同时头节点的前一个指针指向链表的最后一个节点,这样就形成了一个环形的链表结构。
  • 每个节点(除头节点外,如果头节点不存储数据)都存储一个数据元素,并且有两个指针,一个指向前一个节点(prev 指针),一个指向后一个节点(next 指针)。

在讲解这个链表之前我们要先了解一下链表的分类。

数据结构-双向带头循环链表_第1张图片

就像这张图片显示的这样,可以发现,链表总共有八种。而双向带头循环链表是其中最复杂的,单链表是最简单的。下面我把其中三个特征拆分下来,便于更直观的理解。

数据结构-双向带头循环链表_第2张图片

很明显,带头的比不带头的多了一个head结点,这个结点我们称为“哨兵位”。这个结点存储的数据不重要(单单指数据,不指prev和next),主要起到一个占位作用。

数据结构-双向带头循环链表_第3张图片

循环和不循环的区别就是尾结点不指向NULL,而是指向头结点。

数据结构-双向带头循环链表_第4张图片

单向和双向的区别就是每个结点会多一个变量来记录前驱结点的地址,一般取名为prev,剩下的就和单链表一样,存储数据和下个结点的地址next。

1.2代码示例

typedef int LNDateType;
typedef struct ListNode
{
	LNDateType val;
	struct ListNode* next;//后继结点
	struct ListNode* prev;//前驱结点
}ListNode;

结点的结构

这里主要就是比单链表多了一个prev变量来记录结点的前驱节点,剩下的没区别。

ListNode* BuyNode(LNDateType x)
{
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
		exit(1);
	}

	newnode->val = x;
	newnode->next = newnode;
	newnode->prev = newnode;

	return newnode;
}

构造创建结点的函数

因为下面很多功能需要创建新的结点,故把这个功能封装成一个函数,便于调用,减少代码量。

注意:因为我们实现的是双向循环的链表,所以我们创建的单个结点也要让结点的next和prev指向自己。

ListNode* LNInit()
{
	ListNode* phead = BuyNode(-1);
	return phead;
}

初始化链表

这里我们创建的是哨兵位,括号里面的值可以是任意值。

注意:哨兵位并不在有效链表当中,也就是此时的链表还是空链表,并无有效结点。

void LNPushBack(ListNode* phead, LNDateType x)
{
	assert(phead);

	ListNode* newnode = BuyNode(x);
	newnode->prev = phead->prev;
	newnode->next = phead;

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

尾删

在解释这个功能之前,肯定有人和小编才学的时候一样,对于传参为什么传的是一级指针而疑惑,这里我来解释一下。

正解:我们知道,如果要让实参发生改变,传入的形参要是数据的地址。单链表在初始化的时候,phead是指向NULL的,但是我们在实现功能的时候,会让初始化的phead指向新创建的结点,此时phead所指向的内容发生了改变,故要用二级指针。而双向带头循环链表我们初始化的是哨兵位,本身phead指的就是哨兵位的地址,后续的功能并不需要让phead发生改变,就比如让phead移动到第一个节点的位置,并没有,只需要让phead的next和prev发生改变就行。所以不需要用到二级指针。

这里还可能有人疑惑next和prev所指向的内容不时发生改变了吗?

对,确实会发生改变。对于这个问题,在小编看来next和prev是phead里面的值,它们的改变并不会影响phead所指向的内容,并且这两个又没有作为参数,所以用不到二级指针。

回归正题:

1.判断传入的phead是否为假

2.创建新的结点

3.尾插影响的是当前的尾结点和哨兵位,故它们的某些值只要发生改变。哨兵位的prev指的是尾结点,尾结点的next指的是哨兵位,而新创建的节点的prev要指向原来的尾结点,next要指向哨兵位。

注意:实现插入功能时(不单单指尾插),尽量按照先改变新创建的结点的next和prev,在改变其他受影响的结点,最后在改变哨兵位。这样做是为了避免如果先改变哨兵位,会导致另一个受影响的结点的指向发生了改变,就不能通过哨兵位来找到它,要通过新创建的结点来找,容易出错

void LNPushFront(ListNode* phead, LNDateType x)
{
	assert(phead);
	ListNode* newnode = BuyNode(x);

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

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

头插

1.判断传入的phead是否为假

2.创建新的结点

3.头插改变的是哨兵位和当前第一个有效结点的指向。哨兵位的next,当前第一结点的prev,新结点的next和prev都发生了改变。哨兵位的next要指向新结点,当前第一结点的prev要指向新结点,新节点的prev要指向哨兵位,next要指向当前第一结点

void LNPrint(ListNode* phead)
{
	assert(phead);
	ListNode* pcur = phead->next;
	while (pcur != phead)
	{
		printf("%d -> ", pcur->val);
		pcur = pcur->next;
	}
	printf("\n");
}

打印链表

1.判断传入的phead是否为假

2.创建临时变量来记录第一个有效结点

3.遍历链表打印数据

注意:因为我们不需要打印出哨兵位的数据,故从第一个有效结点开始,并且链表是循环的,要到哨兵位的时候停止。打印出数据,并且改变pcur的位置

bool LNIsEmpty(ListNode* phead)
{
	assert(phead);
	return phead->next == phead;
}

判断链表是否为空

因为下面的删除操作都要先判断链表是否空,故把这个功能封装成一个函数

1.判断传入的phead是否为假

2.返回phead的next是否为phead,如果为true,说明是空链表,反之则不是空链表(也可以用prev来检测)

void LNDelBack(ListNode* phead)
{
	assert(!LNIsEmpty(phead));
	ListNode* tmp = phead->prev;
	tmp->prev->next = phead;
	phead->prev = tmp->prev;

	free(tmp);
	tmp = NULL;
}

尾删

1.判断链表是否为空(因为上面我们用的是==,所以如果链表为空,返回的是true,所以我们要用!来改变返回的值,这样不为空才能接着往下走)

2.尾删影响的是哨兵位和尾结点的前驱节点。哨兵位的prev,尾结点前驱节点的next发生改变。哨兵位的prev要指向尾结点的前驱节点,尾结点前驱节点的next要指向哨兵位

3.free掉尾结点,并置为NULL

void LNDelFront(ListNode* phead)
{
	assert(!LNIsEmpty(phead));
	ListNode* tmp = phead->next;
	tmp->next->prev = phead;
	phead->next = tmp->next;

	free(tmp);
	tmp = NULL;
}

头删

1.判断链表是否为空

2.头删影响的是哨兵位和第一个有效结点的后继节点(第一个有效结点->next)。哨兵位的next,第一个有效结点的后继节点的prev发生改变。哨兵位的next要指向第一个有效结点的后继节点,第一个有效结点的后继节点的prev要指向哨兵位

3.free掉第一个有效结点,并置为NULL

ListNode* LNFind(ListNode* phead, LNDateType x)
{
	assert(phead);
	ListNode* pcur = phead->next;
	while (pcur != phead)
	{
		if (pcur->val == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	return NULL;
}

查找链表

因为下面要实现的指定位置pos插入和删除要用到,故封装成一个函数。

1.判断传入的phead是否为假

2.创建临时变量来记录第一个有效结点

3.遍历链表查找数据(思路和打印功能是一样的),找到相应结点,返回结点的地址

void LNInsert(ListNode* pos, LNDateType x)
{
	assert(pos);
	ListNode* newnode = BuyNode(x);
	newnode->prev = pos;
	newnode->next = pos->next;

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

指定位置pos后插入数据

1.判断传入的pos是否为假

2.创建新的结点

3.该功能影响的是pos和pos的后继节点。pos的next,后继节点的prev,新节点的next和prev发生了改变。pos的next指向新结点,后继节点的prev指向新,新结点的prev指向pos,next指向后继节点

注意:要检验这个功能(包括指定位置删除数据)要和查找链表配合起来,找到之后才能实现此功能

因指定位置pos之前插入数据和该功能思路一样,故只展示指定位置pos后插入数据。

void LNErase(ListNode* pos)
{
	assert(pos);
	pos->prev->next = pos->next;
	pos->next->prev = pos->prev;
}

指定位置pos删除数据

1.判断传入的pos是否为假

2.该功能影响了pos的前驱节点和后继节点。前驱结点的next,后继节点的prev发生了改变。前驱结点的next指向后继节点,后继节点的prev指向前驱节点

 

void LNDestory(ListNode* phead)
{
	assert(phead);
	ListNode* pcur = phead->next;
	while (pcur != phead)
	{
		ListNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	free(phead);
	phead = NULL;
}

销毁链表

1.判断传入的phead是否为假

2.创建临时变量pcur记录第一个有效结点

3.创建临时变量next记录pcur的后继节点,然后遍历链表,free掉结点。跳出循环时,pcur在哨兵位的位置,在进行销毁即可

注意:这里为了保持代码的一致性,才用的一级指针,实际要想销毁哨兵位要传二级指针,因为此时的phead发生了改变。而不传二级指针的话,要手动销毁哨兵位,例如:

LNDestory(plist);
plist = NULL;

此时也完成了销毁操作

1.3总结

双向循环带头链表是链表中最为复杂的,但是实现起来是最简单的,很多功能的时间复杂度都为O(1)。注意改变了当前结点会影响哪些结点,还有实现一个功能就检验一个功能。

 

你可能感兴趣的:(数据结构,链表)