CSDN小白成长的第五天——链表世界(1),由浅入深,注重每个细节,原创动画,万字博客,详细解说

链表的秘密(1)第一、二章

链表专题目录
第一章 链表和链表的分类
第二章 单链表的实现
第三章 链表OJ题
第四章 双向带头循环链表的实现
第五章 总结和对比链表和顺序表
在这里插入图片描述


前情提要:

我们学习完顺序表之后,难免发现其有一些问题,比如:
1.中间/头部的插入删除,时间复杂度为O(N)
2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到
200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
那么我们自然而然回去思考:如何解决以上问题呢?
下面给出了链表的结构来看看。

一、链表和链表的分类

1.链表

百度百科上的解释:

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

光看文字可能略感觉有点抽象,那么我们可以结合我们生活实际的东西去理解它
在这里插入图片描述
这是我们熟悉的火车结构有一节一节的车厢,火车头它们之间用链条链起彼此,到末尾只有一个空链条。
那么,我们便可以联想出我们大厂必备的数据结构之一,链表。
链条其实对应我们链表的指针,而车厢对应我们链表中一个又一个节点
基于上面的火车,我们来引出链表的结构:
在这里插入图片描述
对于一个节点来说,链表中存着它要存储的数据和一个指向下一个节点的指针,方便能找到一个链表结构。
在这里插入图片描述
而对于所有的火车车厢组合而成的链表结构,就长成下面这样:
在这里插入图片描述
更抽象一点,在计算机中是这样的:
在这里插入图片描述
观察发现,链表内存的指针指向的是下一个链表节点的地址,每一个链表节点亦如此。plist是指向整个链表的指针,维护整个链表结构形式上指向头结点。尾结点没有指向下一个节点,所以我们自然而然地将其置为空
所以,理解了链表,我们再回过头看上面说的物理结构不连续和逻辑结构连续:逻辑结构连续是因为彼此之间有指针链接,可以通过头结点顺着找下去遍历和找到每一个节点。而物理结构不连续,浅显看来,就是上面每个不连续的地址,在计算机世界中其实是这样的:
在这里插入图片描述
相比于数组,它在同一块空间的不同位置创建节点,位置,地址是由系统分配和决定的。彼此之间是离散状态,不像数组那样连续开辟,所以说他是物理结构不连续。

2.链表的分类

实际中链表的结构非常多样,以下情况组合起来就有222=8种链表结构:

  1. 单向或者双向
    在这里插入图片描述
    如果是双向链表,那么一个节点就会存储两个指针,分别指向前一个节点和后一个节点。相比于单向链表,他可以从后往前遍历,找到前面的链表节点。第一个节点的前驱指针和末尾节点的next指针都会指向空。
  2. 带头或者不带头
    在这里插入图片描述
    我们上面看到的这个链表,就是一个带头链表,上面有一个带head指针的节点没有存数据,head指针指向下一个带有数据的节点,这个带head的节点就叫做哨兵位,本身不存储有效的数据,在某些特定场合是有奇效!!
  3. 循环或者非循环
    在这里插入图片描述
    循环的链表尾结点的指针指向头节点,可以在遍历完链表后从尾结点回到头结点。

虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:
1.单向无头不循环链表
在这里插入图片描述
2.双向循环带头链表
在这里插入图片描述
那么它们为什么会是我们具体学习的重点呢?
事实上:
无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
带头双向循环链表:结构最复杂,一般用在单独存储数据。**实际中使用的链表数据结构,都是带头双向循环链表。**另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
那么好,我们就来正式地整代码

在这里插入图片描述

二、单链表的实现

1.单链表的结构和要实现的功能

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

实现的功能:
和顺序表一样,链表也有自己的增删查改和销毁,也是我们重点的研究和实现内容,链表的初始化,是直接用链表的插入代替的,因为插入是需要开辟空间,存放数据和指针的。就相当于初始化的过程了。

所以,我们先设计一个头文件,再去逐一测试和实现函数:
SList.h

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

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

//链表打印
void SLTPrint(SLTNode* phead);

//链表尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x);

//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);

//尾删
void SLTPopBack(SLTNode** pphead);

//头删
void SLTPopFront(SLTNode** pphead);

//节点查找
SLTNode* SLTFind(SLTNode** pphead, SLTDataType x);

//任意位置之前插入
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);

//指定位置之后插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x);

//删除任意位置之前节点
void SLTErase(SLTNode** pphead, SLTNode* pos);

//删除指定位置之后的节点
void SLTEraseAfter(SLTNode* pos);

//销毁链表
void SLTDestroy(SLTNode** pphead);

2.链表的准备,打印和销毁

ps:头文件在上文中已经有了,测试调用函数的代码是放在测试文件Test.c中,而函数实现的代码是放在SList.c文件中的。

2.0准备

在文件Test.c中:

void SListTest02() {
SLTNode* plist = NULL;
}
int main()
{
	SListTest02();
	return 0;
}

2.1打印

为了方便测试和研究,我们先设计一个链表打印函数:
在文件SList.c中:

void SLTPrint(SLTNode* phead) {                           //打印
	SLTNode* pcur = phead;
	while (pcur)
	{
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("NULL\n");
}

这一段是Test.c的main函数中调用函数的代码:

SLTPrint(plist);

定义pcur的目的是为了不改变原来的head的指针,pcur->data是结构体指针访问成员的写法,C语言阶段打下的基础正是在数据结构和以后C++阶段发挥的作用。
上面相当于是一个链表的遍历,每打印一个链表节点存储的数据,链表指针pcur就移动到下一个节点。以此往复,只到pcur指向空为止。
下面给大家看一个自制的GIF方便大家理解:
在这里插入图片描述

2.2 销毁

void SLTDestroy(SLTNode** pphead)
{
	assert(pphead && *pphead);
	SLTNode* pcur = *pphead;
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	*pphead = NULL;
}

这里就有小伙伴会疑惑了,看不懂为什么突然来了一个传二级指针?

这也是我们在链表这一块遇到的第一个拦路虎,是一个不太好理解的难点。但是只要C语言基础够扎实,还是能够弄明白原因的。

SLTDestroy(&plist);

对比我们main函数中传参的代码,可以尝试先理解理解。(* ̄︶ ̄)
在讲这个问题之前,我们先来回顾指针中的知识:

二级指针

A(即B的地址)是指向指针的指针,称为二级指针,用于存放二级指针的变量称为二级指针变量.根据B的不同情况,二级指针又分为指向指针变量的指针和指向数组的指针。——百度百科

我们知道,指针存放变量的地址**,地址本身也可被修改,改变。所以说,指针(即地址)它本身也是一种变量**,既然是变量,也就有对应的地址有对应的指针指向他。而指向指针的指针我们就叫它二级指针,它指向的是一级指针的地址
结合这个图,我们再谈一谈变量,一级指针,二级指针之间的关系:
在这里插入图片描述
上面这个图描述的是指针指向的内容和各级指针的指向关系
在这里插入图片描述
二级指针存的是一级指针的地址0x0012ff48,一级指针存储的是变量a的地址0x0012ff50.小结一下,就是二级指针存放一级指针的地址,指向一级指针。一级指针的存放变量的地址,指向变量。一级指针解引用是拿到变量,二级指针解引用拿到的是一级指针。

但是光知道二级指针其实是不够的,我们还要知道函数传参的本质,函数传参并不是把参数真正地传给函数让其修改。实际上,形参是实参的一份临时拷贝函数修改的其实是形式参数而不是实际参数。换句话说,形参的改变不影响实参的结果

回到上面传二级指针的问题,如果我们不用pphead接收而是用*phead接收,那根据函数传参的特点,*phead是plist地址的一份临时拷贝,*phead的改变不影响plist,不能达到我们想要的效果。而函数传值调用是拷贝实参,不能解决问题,函数传址调用是借助指针访问,实参会发生改变。plist指向头结点。我们的目的正是改变plist,所以我们要传plist指针的地址,也就是二级指针/**pphead。

void SLTDestroy(SLTNode** pphead)
{
	assert(pphead && *pphead);
	SLTNode* pcur = *pphead;
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	*pphead = NULL;

再来分析这个代码,我们先保证二级指针和一级指针都不为空,再实现遍历式的销毁。这里还是有一个要点我们需要在销毁之前用一个临时指针next把pcur的下一个节点存储起来,不然,我们free掉pcur后,就找不到pcur->next,pcur的下一个节点了。最后全部free完会剩下一个*pphead没有置空,把它置空避免内存泄漏就可以了,我们后面常常要用到类似这样的操作,避免空访问指针造成报错。

3.链表的增删查改

3.1尾插和头插

既然是插入,那要开辟空间创建节点,所以要现有SLTBuyNode函数

SLTNode* SLTBuyNode(SLTDataType x) {                      //创建节点
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	newnode->data = x;
	newnode->next = NULL;
	return newnode;
}

在尾插时分为两种情况,链表为空和链表不为空

void SLTPushBack(SLTNode** pphead, SLTDataType x) {       //尾插
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	//链表为空,新节点作为phead
	if (*pphead == NULL)
	{
		*pphead = newnode;
		return;
	}
	SLTNode* ptail = *pphead;
	while (ptail->next)
	{
		ptail = ptail->next;
	}
	//链表不为空,循环找尾,新节点作为尾结点
	ptail->next = newnode;
}

结合动图演示理解代码
链表为空:
在这里插入图片描述

链表不为空:
在这里插入图片描述

头插也是类似的:

void SLTPushFront(SLTNode** pphead, SLTDataType x) {      //头插
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);

	//newnode *pphead
	newnode->next = *pphead;
	*pphead = newnode;
}

动画:
在这里插入图片描述

3.2头删和尾删

尾删:

void SLTPopBack(SLTNode** pphead) {                       //尾删
	//链表不能为空
	assert(*pphead && pphead);
	//分链表只有一个节点和多个节点的情况
	if ((*pphead)->next == NULL) {
		free(*pphead);
		*pphead = NULL;
		return;
	}
	SLTNode* ptail = *pphead;
	SLTNode* prev = NULL;
	while (ptail->next)
	{
		prev = ptail;
		ptail = ptail->next;
	}
	prev->next = NULL;//尾删,最后一个节点置空
	//销毁尾结点
	free(ptail);
	ptail = NULL;
}

在这里插入图片描述

头删:

void SLTPopFront(SLTNode** pphead) {
	assert(pphead && *pphead);
	SLTNode* next = (*pphead)->next;
	free(*pphead);
	*pphead = next;
}

在这里插入图片描述

3.3节点查找

Test.c

	SLTNode* FindRet = SLTFind(&plist, 2);//
	if (FindRet) {
		printf("找到了!\n");
	}
	else {
		printf("未找到!\n");
	}

SList.c

SLTNode* SLTFind(SLTNode** pphead, SLTDataType x) {
	assert(pphead);

	SLTNode* pcur = *pphead;
	while (pcur) {
		if (pcur->data == x) {
			return pcur;
		}
		pcur = pcur->next;
	}
	return NULL;

}

这里设置成返回指针,是为了给下文一些指定位置相关的问题做铺垫。这个函数的返回判断单独拎出来到Test.c中,便于自己测试这个函数写的对不对。

3.4任意位置之前插入

void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
	assert(pphead && pos && *pphead);

	SLTNode* newnode = SLTBuyNode(x);
	//分类,pos刚好是头节点和pos不是头节点的情况
	if (pos == *pphead) {
		//头插
		SLTPushFront(pphead, x);
		return;
	}
	SLTNode* prev = *pphead;
	while (prev->next != pos)
	{
		prev = prev->next;
	}
	prev->next = newnode;
	newnode->next = pos;

}

有上面的经验,我们分情况去实现
头结点为空,那就是直接头插,直接调用上面写过的函数
头结点不为空,那就遍历找前驱节点借助前驱节点去插入
下面的演示例子,目标:pos指向d3节点,要在d3节点之前插入d5节点,d5是newnode的节点
在这里插入图片描述

3.5任意位置之后插入

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

过程和指定位置前插入相比,少了遍历找前驱节点这一步。但是这里有一点容易错要注意,不能先写pos->next=newnode,否则,你会找不到pos的next节点,除非你用一个临时指针把pos->next存起来。但在这里,我们最好先处理newnode的next,以免破坏pos->next,导致找不到。
在这里插入图片描述
所以,为什么我们任意位置的插入和删除要分前和后呢?其实它们的思路看似相同,但在细节上差异较大,一不小心就有可能弄出bug。这和链表本身的特性有很大的关联,因为单链表的单向性可以往前不能往后不像数组那样可以随意访问数据,所以遍历就成了链表中常常做的事情,这也是**链表的一大缺陷所在,会增加程序的运行和消耗。**有时代码的顺序像上面会影响程序的运行,小心驶得万年船,链表中随时可能找不到我们本来能访问到的节点,这一点,在我们后面学习的双向链表中也是很常见的。

3.6任意位置之前删除

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

不管是增还是删,都要考虑周全,要留个心眼,防止链表为空。链表为空无法删除,要及时用assert断言拦下,pos超出链表的个数,也要拦下。不只是pphead不为空。
只有一个节点的时候,相当于头删功能,当不止一个节点的时候,如下演示:
在这里插入图片描述

3.7任意位置之后删除

void SLTEraseAfter(SLTNode* pos)
{
	assert(pos && pos->next);
	SLTNode* del = pos->next;
	pos->next = pos->next->next;
	free(del);
	del = NULL;
}

同样,这里也是要记住pos->next的。以免找不到。
在这里插入图片描述

全套代码(含测试)

SList.h

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

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

//链表打印
void SLTPrint(SLTNode* phead);

//链表尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x);

//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);

//尾删
void SLTPopBack(SLTNode** pphead);

//头删
void SLTPopFront(SLTNode** pphead);

//节点查找
SLTNode* SLTFind(SLTNode** pphead, SLTDataType x);

//任意位置插入
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);

//指定位置插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x);

//删除任意节点
void SLTErase(SLTNode** pphead, SLTNode* pos);

//删除指定位置之后的节点
void SLTEraseAfter(SLTNode* pos);

//销毁链表
void SLTDestroy(SLTNode** pphead);

SList.c

#define _CRT_SECURE_NO_WARNINGS
#include "SList.h"

void SLTPrint(SLTNode* phead) {                           //打印
	SLTNode* pcur = phead;
	while (pcur)
	{
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("NULL\n");
}
SLTNode* SLTBuyNode(SLTDataType x) {                      //创建节点
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	newnode->data = x;
	newnode->next = NULL;
	return newnode;
}
void SLTPushBack(SLTNode** pphead, SLTDataType x) {       //尾插
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	//链表为空,新节点作为phead
	if (*pphead == NULL)
	{
		*pphead = newnode;
		return;
	}
	SLTNode* ptail = *pphead;
	while (ptail->next)
	{
		ptail = ptail->next;
	}
	//链表不为空,循环找尾,新节点作为尾结点
	ptail->next = newnode;
}

void SLTPushFront(SLTNode** pphead, SLTDataType x) {      //头插
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);

	//newnode *pphead
	newnode->next = *pphead;
	*pphead = newnode;
}
void SLTPopBack(SLTNode** pphead) {                       //尾删
	//链表不能为空
	assert(*pphead && pphead);
	//分链表只有一个节点和多个节点的情况
	if ((*pphead)->next == NULL) {
		free(*pphead);
		*pphead = NULL;
		return;
	}
	SLTNode* ptail = *pphead;
	SLTNode* prev = NULL;
	while (ptail->next)
	{
		prev = ptail;
		ptail = ptail->next;
	}
	prev->next = NULL;//尾删,最后一个节点置空
	//销毁尾结点
	free(ptail);
	ptail = NULL;
}
void SLTPopFront(SLTNode** pphead) {
	assert(pphead && *pphead);
	SLTNode* next = (*pphead)->next;
	free(*pphead);
	*pphead = next;
}
SLTNode* SLTFind(SLTNode** pphead, SLTDataType x) {
	assert(pphead);

	SLTNode* pcur = *pphead;
	while (pcur) {
		if (pcur->data == x) {
			return pcur;
		}
		pcur = pcur->next;
	}
	return NULL;

}
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
	assert(pphead && pos && *pphead);

	SLTNode* newnode = SLTBuyNode(x);
	//分类,pos刚好是头节点和pos不是头节点的情况
	if (pos == *pphead) {
		//头插
		SLTPushFront(pphead, x);
		return;
	}
	SLTNode* prev = *pphead;
	while (prev->next != pos)
	{
		prev = prev->next;
	}
	prev->next = newnode;
	newnode->next = pos;

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

void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead && *pphead && pos);
	if (*pphead == pos)
	{
		SLTPopFront(pphead);
		return;
	}
	SLTNode* prev = *pphead;
	while (prev->next != pos) {
		prev = prev->next;
	}
	prev->next = pos->next;
	free(pos);
	pos = NULL;
}
void SLTEraseAfter(SLTNode* pos)
{
	assert(pos && pos->next);
	SLTNode* del = pos->next;
	pos->next = pos->next->next;
	free(del);
	del = NULL;
}
void SLTDestroy(SLTNode** pphead)
{
	assert(pphead && *pphead);
	SLTNode* pcur = *pphead;
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	*pphead = NULL;
}

Test.c

#define _CRT_SECURE_NO_WARNINGS
#include "SList.h"
void SListTest02() {
	//初始化
	SLTNode* plist = NULL;//定义链表指针
	//---------------------------------------------------------尾插
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);
	/*SLTPrint(plist);*/
	//---------------------------------------------------------头插
	//SLTPushFront(&plist, 7);
	//SLTPushFront(&plist, 6);
	//SLTPushFront(&plist, 5);
	/*SLTPrint(plist);*/
	//---------------------------------------------------------尾删
	//SLTPopBack(&plist);
	//SLTPrint(plist);

	//SLTPopBack(&plist);
	//SLTPrint(plist);

	//SLTPopBack(&plist);
	//SLTPrint(plist);
	
	//SLTPopBack(&plist);
	//SLTPrint(plist);

	//SLTPopBack(&plist);
	//SLTPrint(plist);
	//-------------------------------------------------------头删
	//SLTPopFront(&plist);
	//SLTPrint(plist);
	//------------------------------------------------- - 查找
	SLTNode* FindRet = SLTFind(&plist, 2);//
	if (FindRet) {
		printf("找到了!\n");
	}
	else {
		printf("未找到!\n");
	}
	//在指定位置前,后插入数据
	/*SLTInsert(&plist, FindRet, 100);
	SLTPrint(plist);*/

	/*SLTInsertAfter( FindRet, 100);
	SLTPrint(plist);*/

	---------------------------------------------------删除pos节点
	//SLTErase(&plist, FindRet);
	//SLTPrint(plist); 

	---------------------------------------------------删除pos之后的节点
	SLTEraseAfter(FindRet);
	SLTPrint(plist);

	//---------------------------------------------------销毁链表
	SLTDestroy(&plist);
}
int main()
{
	SListTest02();
	return 0;
}

小结

今天我们具体学习了链表的前两章,本篇文章除了图片,本人尝试着使用动画的形式代替了一部分文字和静图,方便大家更好地理解链表和程序运行的过程。当然,链表世界不止于此,链表还有各种OJ中的应用和操作,还有双向带头循环链表,以及各种算法中,其他数据结构中,C++中我们还会再深入研究链表结构的。欲知后事如何,且听下回分解,我们下期再见!
创作不易,还请各位多多支持,求求三连谢谢

在这里插入图片描述

  • 41
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值