Bootstrap

【数据结构】链表

对于顺序表,有查找,修改方便的优点,但是在插入、删除数据时略显麻烦,并且增容需要申请空间,拷贝数据,释放旧空间时会有不小的损耗,还有可能造成空间的浪费等的缺点,于是有了链表的提出.....

一、链表的基本概念与分类

1.1 概念

        链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。即它由一系列节点组成,每个节点存储数据并指向下一个节点。

1.2 基本结构

        ①节点

                每个节点包含两个部分:数据域指针域

        ②头节点

                链表的第一个节点。但是给v不会有经常为了方便链表可能会带一个哨兵位,它不存储有效数据,指向第一个存储有效数据的节点。

        ③尾节点

                链表的最后一个节点,它的指针域为空。

它的逻辑结构是这样的👇

一个节点指向下一个节点,看似是连续的,其实他们的存储位置可能十分分散,只是通过存储和寻找地址链接彼此。

1.3 分类

         链表通常被分为单向/双向链表循环/不循环链表带头/不带头链表。看似简单的三种分类,可以随意组合成八种情况:单向不循环不带头链表,双向循环带头链表,双向不循环带头链表.....

看似总类繁多,但是只要结合结构图了解大的分类即可掌握链表。

这其中看似最高级的双向带头循环链表最复杂,其实不然。单向不带头不循环链表在实现上会有更多需要考虑的细节,其他类型都是在此基础上的偷懒偷懒再偷懒。

所以接下来我会从零一步步实现单链表的增删查改以此对链表进行更深入的学习。

二、单链表的实现(增删查改...)

2.1 创建节点

         链表的节点包括数据域和指针域。所以包含datanext两个变量。

typedef int SLTDataType;

typedef struct SListNode
{
	SLTDataType data;
	struct SListNode* next;
}SLTNode;

2.2 打印链表 SLTPrtint

         在进行实现对链表的操作的函数之前,我们可以先手动构建一个链表来了解👇(有时候做题需要速度时,也可以手动构建节点,不需要写出完整的链表结构)malloc要包含stdio.h的头文件哦。

SLTNode* n1 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* n2 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* n3 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* n4 = (SLTNode*)malloc(sizeof(SLTNode));
n1->data = 1;
n2->data = 2;
n3->data = 3;
n4->data = 4;
n1->next = n2;
n2->next = n3;
n3->next = n4;
n4->next = NULL;

        此时已经构建出四个节点,我们就可以利用一个打印链表的函数,将这个链表显示在屏幕上。

void SLTPrint(SLTNode* phead)
{
	SLTNode* cur = phead;
	while (cur != NULL)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}
	printf("null\n");
}

 

2.3 尾插数据 SLTPushBack & 构建新节点 BuySLTNode

2.3.1 构建新节点 BuySLTNode

        在插入类型的操作函数中每次都需要构建一个节点,并检查是否创建成功,我们索性就写成一个函数来使用。

SLTNode* BuySLTNode(SLTDataType x)
{
	SLTNode* node = (SLTNode*)malloc(sizeof(SLTNode));
	if (node == NULL)
	{
		perror("malloc failed");
		exit(-1);
	}
	node->data = x;
	node->next = NULL;

	return node;
}

2.3.2 尾插数据 SLTPushBack

   🚦注意🚦

①phead要传入二级指针,因为当这个链表为空的时候,我们需要改变结构体的指针,改变结构体的指针就需要传入结构体的指针的指针!!(如果我们为这个链表设置了哨兵位那是不需要传入二级指针的,因为那就和链表本来有数据的情况是一样的,即改变的是链接关系,而不是整个链表)

phead它本身就是一个结构体的指针,传入一级指针就相当于里面的变量对这个链表的头指针进行了一个复制,形参的改变不会改变实参。但是为什么有数据时,就能对链表的链接关系进行改变呢?因为改变链表的链接关系实际上是改变结构体,一个个next指针里存的就是结构体的指针(tail->next = newnode(这句话是要改变结构体的内容,所以要用结构体的指针,tail就是结构体的指针))。总而言之,想要改变什么就传入什么的指针!!

②pphead是指向链表头指针的指针,即使这个链表为空,pphead也不会为空。这里的* pphead就不需要断言

void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);
	//创建新节点
	SLTNode* newnode = BuySLTNode(x);
	if (*pphead == NULL)
	{
		*pphead = newnode;
	}
	else
	{
		SLTNode* tail = *pphead;
		while (tail->next != NULL)
		{
			tail = tail->next;
		}
		tail->next = newnode;
	}
}

 

2.4 头插  SLTPushFront

void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
	SLTNode* newnode = BuySListNode(x);
	newnode->next = *pphead;
	*pphead = newnode;
}

 

 2.5 尾删 SLTPopBack

void SLTPopBack(SLTNode** pphead)
{
	assert(pphead);
	//链表不能为空
	assert(*pphead);

	SLTNode* tail = *pphead;
	if (tail->next == NULL)
	{
		*pphead = NULL;
	}
	else
	{
		while (tail->next->next != NULL)
		{
			tail = tail->next;
		}
		free(tail->next);
		tail->next = NULL;
	}
}

 

2.6 查找 SLTFind

        实现查找某个值,返回该节点的指针的功能。

SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* cur = phead;
	while (cur)
	{
		if (cur->data == x)
		{
			return cur;
		}
		cur = cur->next;
	}
}

 

2.7 在指定位置处插入数据 SLTInsert

        找到pos位置的前驱,使pos位置的前驱指向新的节点,新节点指向pos节点。 

void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
	assert(pphead);
	assert(pos);
	if (pos == *pphead)
	{
		SLTPushFront(pphead, x);
	}
	else
	{
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		SLTNode* newnode = BuySLTNode(x);
		prev->next = newnode;
		newnode->next = pos;
	}
}

 

2.8 在指定位置后插入 SLTInsertAfter

        不会是头插,所以不需要传入头节点。

void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
	assert(pos);
	SLTNode* newnode = BuySLTNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

 

2.9 删除指定位置数据 SLTErase

void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pos);
	if (pos == *pphead)
	{
		SLTPopFront(pphead);
	}
	else
	{
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		prev->next = pos->next;
		free(pos);
	}
}

        free(pos);  释放的是pos指向的那个节点,之后不用pos = NULL置空,pos虽然是野指针,置空了外面的pos也不会改变,但是传二级指针过去有点得不偿失,所以之后用注意就行。可以在外面使用完了之后置空。

pos = SLTFind(head, 5);
if (pos)
{
	SLTErase(&head, pos);
	pos = NULL;
	//外面使用时将pos置空,函数里不需要
}
SLTPrint(head);

 

2.10 删除指定位置之后的数据 SLTEraseAfter

        ①改变链接关系 ②free被删的节点

void SLTEraseAfter(SLTNode* pos)
{
	assert(pos);
	//pos如果是尾节点,后删没有意义
	assert(pos->next);

	SLTNode* posNext = pos->next;
	pos->next = posNext->next;

	free(posNext);
	posNext = NULL;
}

 

2.11 销毁 SLTDestroy

void SLTDestroy(SLTNode** pphead)
{
	assert(pphead);

	SLTNode* cur = *pphead;
	while (cur)
	{
		SLTNode* next = cur->next;
		free(cur);
		cur = next;
	}
	*pphead = NULL;
}

 

2.12 一些注意事项

Q:假设现在有一个单链表,给一个pos但是不给头指针,怎么删除这个pos??
A:替换法删除。把下一个节点的值给pos,然后删除下一个节点。缺陷:无法删除尾结点(尾节点置空不行吗)

pphead是phead的地址,并不会为空,所以都需要断言。有些要保证链表不为空,就要增加关于`*pphead`的断言检查。要不要断言取决于它合不合理。

单链表的完整代码请查看👉数据结构: 手搓数据结构 - Gitee.com👈

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

        带头双向循环链表就是在单链表的基础上增加了头节点,并且有两个指针域,一个指向前一个节点,另一个指向后一节点。不再考虑传二级指针,插入删除时只要考虑清楚链接关系就会很容易实现,具体操作可查看:

 👉数据结构: 手搓数据结构 - Gitee.com👈

四、链表VS顺序表

不同点顺序表链表
存储方式一块连续的内存空间不连续的内存空间
访问效率支持随机访问,O(1)不支持随机访问,O(N)
插入、删除元素效率可能需要挪动元素,效率低只需要修改指针指向,兄率很高
内存管理动态顺序表,预先分配空间,空间不够时扩容,扩容时拷贝申请新空间,拷贝原数据,代价高内存利用率高,每个节点有指针域,存储开销较大
应用场景需要频繁访问删除的场景或可直接用数组实现时需要频繁插入、删除的场景
缓存利用率

在缓存方面,打印,遍历时顺序表的效率比链表的高,访问数据他会先看数据在不在缓存,如果不在就去内存里访问,但是他要访问4个字节,他不会只加载四个字节,会加载一长段,在这种情况下,链表不仅有点麻烦,还有可能造成缓存的污染。

-THE END-

;