数据结构:链表

Posted 山舟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构:链表相关的知识,希望对你有一定的参考价值。


一、链表的概念及其结构

概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑结构是通过链表中的指针链接实现的 。

实际中链表的结构非常多样,以下情况组合起来有8种链表结构:
(1)单向、双向(是否支持向前访问)
(2)带头、不带头(在链表开头是否有一个不存放数据的头结点)
(3)循环、非循环(是否能通过链表的尾结点直接访问到链表的第一个结点)

虽然有这么多的链表的结构,但是实际中最常用的还是下面两种结构:

(1)不带头单向非循环链表

结构如下(逻辑结构):
在这里插入图片描述
每个结点都是一个结构体,分为两个部分,前一个部分存放数据(数据域),后一个部分存放指向下一个结点的指针(指针域)。

这种链表结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。


(2)带头循环双向链表

结构如下(逻辑结构):
在这里插入图片描述
每个结点都是一个结构体,分为三个部分,第一个部分存放指向前一个结点的指针,中间部分存放数据,最后一个部分存放指向下一个结点的指针。

这种链表结构最复杂,一般用来单独存储数据。实际中使用的链表数据结构,大多数是带头双向循环链表。这个结构虽然结构复杂,但是使用代码实现以后会带来很多优势,实现起来反而更加简单。


二、单链表的实现

实现不带头单向非循环链表

链表的结构:

typedef int DataType;

typedef struct ListNode
{
	DataType a;
	struct ListNode* next;
}ListNode;

1.单链表的访问

通常我们有链表的第一个结点的地址(plist),那么如何遍历访问每一个结点呢?
只需在每次操作后将plist更新为plist->next,这就让plist向后走了一个结点。

代码如下(示例):

void ListNodePrint(ListNode* plist)
{
	while (plist != NULL)
	{
		printf("%d->", plist->a);
		plist = plist->next;
	}
	printf("NULL\\n");
}

2.单链表头部插入数据

可以很轻松地写出如下代码。

代码如下(示例):

void ListNodePushFront(ListNode* plist, DataType x)
{
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	newnode->a = x;
	newnode->next = plist;
}

但是这段代码执行结束后虽然在链表头部插入了数据为a的新结点,但是plist指向的仍是链表中原来的头结点,plist没有随着头插更新。

注意这里由于需要更新头结点,也就是需要在函数中对一级指针的值进行修改,所以传参时需要传递二级指针(之后的代码同理)。

(由于创建一个值为x的结点的过程需要使用多次,所以这里将其封装为BuyListNode)

代码如下(示例):

//创建一个值为x的新结点并返回其地址
ListNode* BuyListNode(DataType x)
{
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	newnode->a = x;
	newnode->next = NULL;
	return newnode;
}

//头插
void ListNodePushFront(ListNode** pplist, DataType x)
{
	if (*pplist == NULL)
		return;
	ListNode* newnode = BuyListNode(x);
	newnode->next = *pplist;//将newnode与第一个结点连接
	*pplist = newnode;//更新第一个结点
}

3.单链表尾部插入数据

不带头单向非循环链表在找尾结点时需从头遍历,时间复杂度为O(N),这也是它的缺点。

代码如下(示例):

void ListNodePushBack(ListNode** pplist, DataType x)
{
	ListNode* newnode = BuyListNode(a);
	if (*pplist == NULL)//链表为空
	{
		*pplist = newnode;
		return;
 	}
	ListNode* cur = *pplist;//从头开始遍历
	while (cur->next != NULL)//找到最后一个结点,注意循环结束的判断条件
		cur = cur->next;//迭代来让cur指针向后遍历
	cur->next = newnode;
}

4.单链表头部删除数据

由于链表可以直接访问头部,所以对头部的操作比较简单。
这里只需要先保存第一个结点的下一个结点next,释放第一个结点,然后将链表的头变为next。

(注意这里的next与链表结构体中的next无关)

代码如下(示例):

void ListNodePopFront(ListNode** pplist)
{
	//1.链表中没有结点
	if (*pplist == NULL)
		return;

	//2.链表中有多个结点
	else
	{
		ListNode* next = (*pplist)->next;//加小括号保证先进行解引用操作
		free(*pplist);//将第一个结点释放
		*pplist = next;//更新链表的第一个结点
	}
}

5.单链表尾部删除数据

这一步骤情况较多:
(1)链表为空,直接返回
(2)链表中只有一个结点,则删除该节点并将链表的头置为NULL
(3)链表中有多个结点,遍历到尾结点并将其删除

代码如下(示例):

void ListNodePopBack(ListNode** pplist)
{
	//1.没有结点
	if (*pplist == NULL)
		return;
		
	//2.只有一个结点
	else if ((*pplist)->next == NULL)
	{
		free(*pplist);
		*pplist = NULL;
		return;
	}
	
	//3.多个结点
	ListNode* tail = *pplist;	
	ListNode* prev = *pplist;
	//遍历找到尾结点
	//由于需要得到尾结点的前一个结点(这个结点是新的尾结点),这里用prev表示它的前一个结点
	while (tail->next != NULL)
	{
		prev = tail;
		tail = tail->next;
	}
	
	//删除尾结点
	free(tail);
	tail = NULL;
	//将新的尾结点的next置为NULL
	prev->next = NULL;
}

6.查找单链表中值为x的结点

这一功能只需从头遍历函数,当碰到(第一个)值为x的结点,直接返回它的地址,若找不到则返回NULL。

代码如下(示例):

ListNode* ListNodeFind(ListNode* plist, DataType x)
{
	ListNode* cur = plist;
	while (cur != NULL)
	{
		if (cur->a == x)
			return cur;//找到直接返回

		cur = cur->next;
	}
	return NULL;//找不到返回NULL
}

7.在某一结点的后面插入一个结点

这个函数通常和ListNodeFind函数一起用,即用ListNodeFind函数来找到“某一结点”,然后在这一结点后插入一个新的结点。

(这里实现的是在某一结点的后面插入新结点,如果要在其前面插入新结点,由于当前链表结构无法访问前一个结点,实现时会非常麻烦)

代码如下(示例):

//在pos结点的后面插入一个结点
void ListNodeInsertAfter(ListNode* pos, DataType x)
{
	assert(pos);
	ListNode* next = pos->next;
	ListNode* cur = BuyListNode(x);
	//将三个结点连接起来
	pos->next = cur;
	cur->next = next;
}

8.在某一结点的后面删除一个结点

这一函数与ListNodeInsertAfter相似。

代码如下(示例):

void ListNodeEraseAfter(ListNode* pos)
{
	assert(pos);
	if (pos->next == NULL)
		return;
	ListNode* next = pos->next->next;//得到被删除结点的下一个结点的地址
	free(pos->next);
	pos->next = next;//将链表连接起来
}

三、单链表的优劣

单链表的优势:
(1)可在O(1)的时间复杂度内插入和删除数据,操作时只需要改变指针的指向即可。
(2)动态分配内存空间,用多少开辟多少,不会出现内存浪费的情况,内存利用率高。

单链表的劣势:
(1)以结点为单位存储,在对任意位置进行插入和删除操作时的时间复杂度为O(N)。


四、 带头循环双向链表的实现

上述单链表的功能由于其结构简单,有些地方实现起来较麻烦。下面的双向链表虽然结构复杂一些,但是实现其功能时非常方便。下面实现双向链表的基本功能。

双向链表的结构

typedef int DLData;

typedef struct DListNode
{
	struct DListNode* prev;//指向前一个结点的指针
	struct DListNode* next;//指向后一个结点的指针
	DLData val;//存储数据
}DListNode;

1.双向链表的初始化

这里实现两个函数,初始化一个双向链表和创建一个新结点。

代码如下(示例):

//创建一个新结点
DListNode* BuyDListNode(DLData x)
{
	DListNode* newnode = (DListNode*)malloc(sizeof(DListNode));
	newnode->val = x;
	newnode->prev = NULL;
	newnode->next = NULL;

	return newnode;
}

//初始化一个双向链表
DListNode* DListNodeInit()
{
	DListNode* newnode = (DListNode*)malloc(sizeof(DListNode));
	newnode->val = -1;//这里可随意赋值,因为双向链表在访问时不会访问头结点的值
	//初始把头结点的prev和next指针都指向自己
	newnode->next = newnode;
	newnode->prev = newnode;

	return newnode;
}

2.双向链表尾部插入数据

代码如下(示例):

void DListNodePushBack(DListNode* phead, DLData x)
{
	assert(phead);
	DListNode* newnode = BuyDListNode(x);//创建新结点

	DListNode* tail = phead->prev;//找尾,头结点的prev指针就是尾结点

	//连接原来的尾结点和新结点
	tail->next = newnode;//原来的尾结点的next指向新结点
	newnode->prev = tail;//新结点的prev指向原来的尾结点
	
	//连接诶头结点和新结点
	phead->prev = newnode;//头结点的prev指向新结点(也是新的尾结点)
	newnode->next = phead;//新结点的next指向头结点
}

3.双向链表头部插入数据

注意双向链表头部插入数据是指在头结点的下一个结点的位置插入数据。

代码如下(示例):

void DListNodePushFront(DListNode* phead, DLData x)
{
	assert(phead);

	DListNode* newnode = BuyDListNode(x);

	DListNode* first = phead->next;//找到头结点的下一个结点

	//将新结点前后的结点与新结点连接
	first->prev = newnode;
	newnode->next = first;
	newnode->prev = phead;
	phead->next = newnode;
}

4.双向链表尾部删除数据

代码如下(示例):

void DListNodePopBack(DListNode* phead)
{
	assert(phead);
	assert(phead != phead->next);//防止链表中只有一个phead,没有别的结点

	DListNode* tail = phead->prev;//找到尾结点
	DListNode* tailPrev = phead->prev->prev;//找到尾结点的前一个结点(这将是新的尾结点)
	free(tail);

	//连接头结点和新的尾结点
	tailPrev->next = phead;
	phead->prev = tailPrev;
}

5.双向链表头部删除数据

代码如下(示例):

void DListNodePopFront(DListNode* phead)
{
	assert(phead);
	assert(phead != phead->next);

	DListNode* first = phead->next;
	DListNode* firstNext = phead->next->next;//找到新的连接在头结点后的结点
	free(first);

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

6.寻找双向链表中值为x的结点

这里返回的仍是链表从前往后第一个值为x的结点,一旦找到立刻结束。

代码如下(示例):

DListNode* DListNodeFind(DListNode* phead, DLData x)
{
	assert(phead);
	DListNode* cur = phead->next;

	//从头结点的下一个结点开始遍历寻找
	while (cur != phead)
	{
		if (cur->val == x)
			return cur;
		cur = cur->next;
	}

	//找不到返回空
	return NULL;
}

7.在pos位置前插入一个结点

实现单链表时提到,在某一个位置前插入结点很麻烦,因为单链表的结构决定了它很难访问前面的结点。但到了双向链表就可以很轻松地访问前后的结点,所以在某一个位置前插入结点也很容易实现。

代码如下(示例):

void DListNodeInsertBefore(DListNode* pos, DLData x)
{
	assert(pos);

	DListNode* newnode = BuyDListNode(x);
	DListNode* prev = pos->prev;//找到pos位置的前一个结点

	//连接新结点,pos结点,pos原来的前一个结点
	prev->next = newnode;
	newnode->prev = prev;
	newnode->next = pos;
	pos->prev = newnode;
}

8.删除pos结点

代码如下(示例):

void DListNodeErase(DListNode* pos)
{
	assert(pos);

	//找到pos的前后两个结点
	DListNode* prev = pos->prev;
	DListNode* next = pos->next;

	//连接这两个结点
	prev->next = next;
	next->prev = prev;
	free(pos);
}

9.得到链表的结点个数

代码如下(示例):

//判断链表是否为空
int DListNodeEmpty(DListNode* phead)
{
	//如果头结点的下一个还是头结点,说明双向链表中只有这一个结点,认为是空
	return (phead->next == phead);
}

//得到链表的结点个数
int DListNodeSize(DListNode* phead)
{
	assert(phead);

	int size = 0;
	DListNode* cur = phead->next;

	//从第一个结点开始遍历即可
	while (cur != phead)
	{
		size++;
		cur = cur->next;
	}

	return size;
}

感谢阅读,如有错误请批评指正

以上是关于数据结构:链表的主要内容,如果未能解决你的问题,请参考以下文章

NC41 最长无重复子数组/NC133链表的奇偶重排/NC116把数字翻译成字符串/NC135 股票交易的最大收益/NC126换钱的最少货币数/NC45实现二叉树先序,中序和后序遍历(递归)(代码片段

JDK常用数据结构

817. Linked List Components - LeetCode

VSCode自定义代码片段5——HTML元素结构

VSCode自定义代码片段5——HTML元素结构

VSCode自定义代码片段5——HTML元素结构