数据结构——链表及相关详细功能实现(万字整理)

⭐链表

链表的存在是为了弥补顺序表的缺点,从前几节学习顺序表可以看出顺序表因为其本质是使用数组进行数据的顺序存储,占用连续的内存空间来存放数据,这使得用户使用顺序表存储数据时需要不断开辟新的内存空间以扩容来在连续的存储单元顺序地存放数据。但是使用顺序表存储的最大弊端是如果对一块连续存储的数据进行头插,头删,中间插入或删除等操作时需要整个移动内存单元中存储的各个数值,造成了时间复杂度过高而导致的存储效率下降。使用链表可以解决这个问题。


✨链表的分类

🍥单链表

单链表是所有链表结构中最简单的一种结构,也是后续很多复杂数据结构的分支结构。它仅有一个存储数据的数值域和一个指向后继结点地址的指针域。

🍥双链表

在原有链表的基础上新增了前驱指针,方便了特定结点寻找前驱结点地址和执行特定功能。同时可以使用二分法来简化某个结点的寻找过程,比单链表从头遍历到尾的时间复杂度更低,效率更优,但结构更复杂。

🍥单向循环链表

在单链表的基础上,末节点的后继不再指向空值NULL,而是指向头结点处,达成循环目的。

🍥双向带头循环链表

双向循环链表是链表的最优数据存储结构,它不仅有前驱和后继指针方便查找前后结点,通过头结点可以直接找到末节点,也可以通过末节点循环回头结点。该结构的头结点是伪结点,是一个不存储数据的哨兵位头结点,所谓哨兵位或伪头结点在链表中的作用主要是为了方便数据的插入和删除。


✨链表基本结构

链表的基本结构是由一个数值域data和一个指针域next构成的:

🍕数值域data——定义在链表一个结点的结构体中,专门用于存放指定类型的数据,因为数值类型是多样性的,所以使用typedef给类型重命名方便后续对数据进行及时修改。

🍕指针域next——定义在单链表结点结构体中,用于指向下一个逻辑上具有连接关系且内存中非连续的内存块的结点数值域。

链表正是因为存在指针域,存储在不同内存单元中的数据才能相互串联贯通起来形成可以互相访问的线性存储空间,比如若存储1,2,3,4四个数据,使用链表和顺序表差异如下图所示

在这里插入图片描述

观察内存中两者内存存储规律可知:

在这里插入图片描述

🍥观察内存和监视窗口可以更清晰的了解到顺序表和链表之间的差异和联系,它们最本质的区别就在于存储数据在内存单元中的规则不同,数组空间是由系统隐式开辟的,而链表的结构体结点空间是由用户显式开辟出来,且需要在使用完毕后手动释放。


✨链表基本定义

链表的结构体需要定义两大部分最基本的内容,存放类型数据的数值域和存储下一随机结点内存地址的指针域用于连接数据。

typedef int SGLElemType;						//将链表数值域的数据类型重命名
typedef struct SinglyLinkedList					//链表的结构体定义,重命名类型为SgLNode,结构体名称为SingleList
{
	SGLElemType data;							//链表数值域
	struct SinglyLinkedList* next;				//链表结点中指向非连续内存空间的后续结点结构体地址
}SgLNode;
  1. 将数据类型使用typedef重命名的好处已经在顺序表中强调过重要性,这样做的好处是方便更新和修改链表需要存储的数值类型,本例中存储的链表数据以整形数值int为例,所以将int类型重命名为SGLElemType,其代表的意思为链表数据类型Singly-linked Element Type。
  2. 定义链表结构体SinglyLinkedList,其中包含用于存储带存储数值的已重命名类型的数据变量成员data,以及用于指向下一个逻辑连续结点的指针next,该指针类型为结构体指针。
  3. 因为结构体名称过长,为了方便后续操作和引用,将整个结构体类型重命名typedef为更为简洁且易懂的SgLNode,意味Singly-Linked Node,即单链表结点结构体。

✨链表函数接口

//通用函数接口函数
void SGLPrint(SgLNode* Head);								//打印链表数值		
SgLNode* BuyListNode(SGLElemType x);						//新节点申请开辟函数

//链表功能函数接口
void SGLPushBack(SgLNode** Head, SGLElemType x);			//链表尾插,二级指针变量接收链表首节点地址,用以接收当需要对空链表的首节点地址进行修改的情况
void SGLPushFront(SgLNode** Head, SGLElemType x);			//链表头插,传首节点的结构体地址,用二级指针接收
void SGLPopFront(SgLNode** Head);							//链表头删
void SGLPopBack(SgLNode** Head);							//链表尾删
SgLNode* SGLFind(SgLNode* Head, SGLElemType x);				//链表查找值的结点地址,并返回
void SGLInsert(SgLNode** Head, SgLNode* pos, SGLElemType x);//链表中间插入(某结点前方)
void SGLInsertAfter(SgLNode* pos, SGLElemType x);			//链表中间插入(某结点后方)
void Erase(SgLNode** Head, SgLNode* pos);					//链表结点擦除
void SGLDestroy(SGLNode** Head);							//链表销毁

🍧通用链表函数


🌠打印链表
void SGLPrint(SgLNode* Head)			//链表从头结点到尾结点打印函数(不需要断言,空链表直接打印NULL,表示链表为空)
{
    assert(Head);						//断言传入的单链表
	SgLNode* Cur = Head;				//临时结构体指针变量,用于对链表的遍历
	while (Cur)							//当当前结点地址为空时,结束循环打印
	{
		printf("%d->", Cur->data);		//打印当前结点的数值域值
		Cur = Cur->next;				//使当前结点指向的下一结点的结构体地址覆盖当前结点地址
	}
	printf("NULL\n");
}
  1. 该函数的作用是将单链表中的所有数值域上存储的数据打印到控制台里方便观察,虽然不用该函数也可直接通过程序调试去监视窗口查看已被存储的值,但使用该函数可以省去调试的麻烦,更加方便的观察对单链表增删改值的变化。

  2. 先对传入该打印函数的结点结构体地址进行断言判断,若地址为空或无效则会直接在控制台中报错并指出断言错误所在行。

  3. 打印一个链表的原理与打印顺序表相同,都是通过对于一个线性结构的遍历同时打印数据,所以此处定义一个临时的形参结构体指针Cur,使其指向待遍历打印的单链表,通过访问结构体中的数值域(结构体指针访问操作符->)以此获取存储在其中的数据。

  4. 为了让遍历所需的结构体指针不断在单链表中向后移动访问,根据链表的性质可知其在内存中不是连续存放数据,而在物理结构上是随机的内存单元,所以不能像顺序表那样通过循环变量i自增(i++)就可以访问到连续的内存单元中的数据。链表中的处理方法是通过指针域链接到下一个结点地址,所以我们可以通过让指针赋值next中指向的下一个结点的地址就可以达到指针在单链表中不断向后访问并打印数据的目的了。

  5. 当指针移动到末结点的后继结点,即末节点的next指针域地址为NULL赋值给Cur指针时,此时再循环上去,while判断Cur指针此时的值为空,条件为假则结束循环,Cur将整个单链表遍历完整一遍并自动置空。此时为了方便观察指针已经循环结束,将每个数据间加上一个"->"符号以表示各个数据之间是有逻辑关联的,并在结束循环后再写上一个NULL,表示链表到此处结束,末节点后为空值。

  6. 打印链表一般与其他单链表功能函数接口一起使用,如果没有链表的存在,打印功能函数本身的存在是没有意义的。


🌠创建新结点
SgLNode* BuyListNode(SGLElemType x)								//创建新结点结构体函数
{
	SgLNode* NewNode = (SgLNode*)malloc(sizeof(SgLNode));		//开辟新节点结构体地址
	assert(NewNode);
	NewNode->data = x;											//新节点数值域由外部手动传入的x赋值
	NewNode->next = NULL;										//默认新节点指向的下一结点地址置空
	return NewNode;												//将新节点的结构体地址返回
}
  1. 与顺序表容量capacity满了之后需要扩容不同,单链表每插入一个数据就需要寻找一个新随机地址的内存空间用于存放结构体信息,所以每次新插入值都需要创建一个新的结点,并等待原单链表对其进行手动连接。

  2. 定义该结点创建函数的返回值为结构体指针,即创建好的新结点会为原调用函数返回一个新结点的地址。参数定义为需要存储的类型数据,因为一个新结点在该函数中的定义,在连接之前虽然仍不是原链表的一部分,但待存储的值是可以提前存入其中的。

  3. 观察函数,定义一个形参结构体指针指向主动显性malloc开辟的内存随机地址,开辟的字节大小刚好是一个结构体SgLNode类型,即其中成员变量和对其之后的字节大小:整形数值data的4字节+32位下指针的4字节共8个字节。

    image-20220616161417556

  4. 断言该动态申请的内存空间没有返回赋值空值后,就需要将函数传参而来的数值x赋值给该结点中的数值域data了,通过结构体指针访问该结点中data并赋值,再将其指针域默认后继指针指向空值,即完成了新结点的创建。

  5. 使用该方法,分别创建1,2,3,4四个新结点,并手动将它们连接起来,就可以形成一个简单的单链表了:

    SgLNode* n1 = BuyListNode(1);			//每个结点的结构体空间开辟
    SgLNode* n2 = BuyListNode(2);
    SgLNode* n3 = BuyListNode(3);
    SgLNode* n4 = BuyListNode(4);
    
    n1->next = n2;							//结构体中的指针域由指向的下一结点结构体地址赋值					
    n2->next = n3;
    n3->next = n4;
    
    //打印链表
    SGLPrint(n1);							//从首节点到尾结点遍历打印数值域的值
    

    结果展示:

    1->2->3->4->NULL
    

🍥手动链接链表时注意到n4没有向后链接,因为开辟结点函数BuyListNode时已经默认每个结点的后继指针置空,所以除了前三个结点的后继指针分别链向需要连接的后一个结点之外,末节点n4不作多余处理。

🍧链表功能函数


🌠链表尾插
void SGLPushBack(SgLNode** Head, SGLElemType x)				//传入链表头结点地址的指针,以及需要插入的数值
{
	assert(Head);
	SgLNode* NewNode = BuyListNode(x);
	if (*Head == NULL)										//当链表首节点实参地址为空,即为空链表时
	{
		*Head = NewNode;									//尾插相当于头插或初始化链表首节点地址
	}
	else													//当首节点不为空,即不是空链表情况
															//寻找尾结点,并让尾结点指向的下一地址为新节点结构体地址
	{
		SgLNode* Tail = *Head;								//旧尾结点结构体指针变量定义,初始化为头结点地址	
		while (Tail->next != NULL)							//当尾结点指向的下一结构体地址为空时,找到尾结点
		{
			Tail = Tail->next;								//使当前结点指向的下一结点的结构体地址覆盖当前结点地址
		}
		Tail->next = NewNode;								//让旧的尾结点指向下一个结点的地址赋值为尾插的新节点地址
	}
}
  1. 链表尾插就是对原有链表的末结点后,继续新增结点以存储新值,因为每次新存储值都伴随着内存空间的开辟,某种意义上也就省去了顺序表中对数组容量检查CapacityCheck的步骤。或原先的链表为空,头结点已经为空值,此时尾插与头插作用相同,即在头结点位置处创建一个新的结点。

  2. 尾插函数无返回值,其仅仅完成新结点的开辟和数值的存入,以及原末节点对于该新结点的链接。值得注意的是,观察参数,我们传入了一个二级结构体指针Head和一个待插入数值x,这里将原先定义的结构体指针再取地址传入二级地址的原因是,如果仅把结构体地址传入,将这个地址赋值给形参指针Head,对Head进行赋值修改后因为没有返回值,所以对于形参的修改不会影响尾插函数外传入地址的实参,看下例:

    SgLNode* n1 = NULL;
    SGLPushBack(n1, 1);
    SGLPrint(n1);
    

    定义一个结构体指针变量并将其地址置空,目的是为了将该空值结点地址作为空链表的头结点

    void SGLPushBack(SgLNode* Head, SGLElemType x)			//错误的参数传入
    {
    	SgLNode* NewNode = BuyListNode(x);
    	if (Head == NULL)										
    	{
    		Head = NewNode;										
    	}
    }
    

    如果同上所述将n1结点的结构体地址本身传入该尾插函数中作为形参使用,打印得到如下结果:

在这里插入图片描述

☣️观察到,结点n1和其数值域的值1并没有正确尾插到链表头结点中,仅有链表打印函数Print自带的NULL,可见形参的赋值修改不会影响实参n1的地址值变化,如果要对其结点地址值进行修改,则需要将结点地址取地址,即使用二级指针指向该n1地址传入函数形参并解引用修改才有效果。使用最开头给出的二级指针传参的尾插函数,将结构体指针n1取地址传参观察结果:

image-20220617105320705

🍥由此可见,一个实参在一个函数中的修改需要通过传入其地址实现,若要对一个整型变量int x实参去函数中赋值修改,需要传入该整型的地址&x;同理,如果要对一个整型指针变量int* y去函数中进行赋值修改,也需要传入指针的地址&y,即二级指针才能正确完成地址值的更新操作。

尾插函数的动态开辟内存过程如下所示:

链表尾插


🌠链表尾删
void SGLPopBack(SgLNode** Head)									//链表尾删
{
	assert(Head);
    if((*Head) != NULL)											//如果链表不为空才可尾删
    {
		if ((*Head)->next == NULL)
        {
            free(*Head);										//先通过指向该结点的结构体指针释放该结点的空间
            *Head = NULL;
            //谨记将指向结点的指针置空前,必须先释放该结点malloc的内存空间,否则无法通过该指针找到对应空间释放内存
                                                                //对于尾删结点,先释放,再置空
        }
        else
        {
            SgLNode* Tail = *Head;
            while (Tail->next->next != NULL)					//找倒数第二个结点Tail
            {
                Tail = Tail->next;
            }
            free(Tail->next);									//释放尾结点
            Tail->next = NULL;									//并使倒数第二个结点的后续指针置空
        }
    }
}
  1. 尾删的目的是把链表中末节点删除,与顺序表直接移动Size下标指向前一元素使数组访问不到原末下标元素达到的伪删除原理不同,因为链表的每个结点都是用户"Buy"来的,即用户显性动态申请内存空间开辟而来的,如果仅仅将末结点的前一个结点的后继指针next直接指向NULL而不释放末节点元素,因为开辟的空间没有手动释放,如果数据量过大可能会造成内存溢出,造成严重后果。
  2. 所以链表尾删必须要先释放末结点结构体元素,再将倒数第二个元素作为末元素并让其指向NULL。尾删需要考虑三种链表情况。

🥝Case 1:正常尾删——若链表本身存在多个元素,对末元素尾删需要先定义一个末元素指示指针Tail,注意,该指针指向的是末元素结点的前一个结点,即通过该倒数第二个结点的后继指针next可以访问到末元素结点地址,来达到通过next释放末元素,并将后继指针置空来“代替”末元素的位置,原理图如下:

链表尾删

🌈测试用例

//创建4个结点并连接
SgLNode* n1 = BuyListNode(1);	
SgLNode* n2 = BuyListNode(2);
SgLNode* n3 = BuyListNode(3);
SgLNode* n4 = BuyListNode(4);
n1->next = n2;															
n2->next = n3;
n3->next = n4;
//尾删
SGLPopBack(&n1);
SGLPopBack(&n1);
//打印链表
SGLPrint(n1);

🌈观察结果

1->2->NULL

🥝Case 2:链表尾删完全——如果就着上例继续将链表继续进行尾删操作,则剩下的两个结点1和2将会在两次尾删后被删掉,此时链表为空,而链表为空则头结点地址为空,需要对头结点的地址进行修改,即对传入参数的实参值进行修改,同样需要传入二级指针来实现。还需要注意的一点是,如果链表为空,则不能再继续进行尾删操作,因为对一个没有结点结构体的后继访问是非法操作,在上例代码中给出了解决方案:

//对上例余下的2个结点(一个头结点1,一个尾结点2)进行尾删操作
SGLPopBack(&n1);
SGLPopBack(&n1);
//再多进行以此尾删,而此时链表为空
SGLPopBack(&n1);

观察运行结果:

NULL

由此可知,无论是恰好完全删除链表中的所有结点或是额外多次穿入实参空结点地址并调用尾删函数,不会使程序因为多余的删除操作而报错,因为存在链表为空判断if(*(Head) != NULL,仅有在链表不为空的情况下才能进行尾删操作。原理图如下:

在这里插入图片描述


🌠链表头插
void SGLPushFront(SgLNode** Head, SGLElemType x)//链表头插,传首节点的结构体地址,用二级指针接收
{
	assert(Head);
	SgLNode* NewNode = BuyListNode(x);
	NewNode->next = *Head;						//原来的旧头结点地址被新结点的后续结构体指针所指向
	*Head = NewNode;							//将新头结点地址覆盖原来结构体指针指向的头结点实参地址值,成为新的头结点
}
  1. 头插函数对于顺序表而言是时间复杂度最复杂的函数之一,因为需要整块向后挪动数据。而对于链表来说,其因为本身就是在内存中随机存储,所以头部插入只需要将头结点的地址修改并传回给实参即可以将头结点转移至新的地址上即可,本例采用传实参二级指针Head来修改头结点地址。
  2. 函数无返回值,假设已经存在链表1,2,3,4四个结点,现在要在结点1之前头插新的结点0,则结点n1所指向的地址就不再是1而是0。

🌈测试用例

//在原有4个结点前插入一个新的头结点0
SGLPushFront(&n1, 0);
SGLPrint(n1);

🌈观察结果

0->1->2->3->4->NULL

对比n1头插前后地址值:

image-20220617162607863

  1. 头插函数始终改变了传入实参n1的地址值,所以必须采用二级指针的传参方法。对于尾插或尾删函数而言,如果不涉及到头结点地址即实参值的修改,某种程度而言传入一级指针也可以达到相同目的,但为了保险起见,也就是可能遇到空链表插入删除等对于头结点地址的实参值有可能更新的情况,传入实参指针的地址的二级指针方法始终是一个很好的手段。

  2. 换种思路而言,如果不传入二级指针,也可以通过传一级指针和带返回值的方式来改变实参值,见如下代码所示:

SgLNode* SGLPushFront(SgLNode* Head, SGLElemType x)	//链表头插,传一级指针的带返回值方法
{
	SgLNode* NewNode = BuyListNode(x);
	NewNode->next = Head;						//原来的旧头结点地址被新结点的后续结构体指针所指向
	Head = NewNode;								//将新头结点地址覆盖原来结构体指针指向的头结点实参地址值,成为新的头结点
	return Head;								//将新头结点地址值返回给实参,等同于二级指针修改实参值效果
}

🌈测试用例2:对一个空链表头插4个数

//定义一个新的空结构体结点
SgLNode* n1 = NULL;
//调用函数依次头插1,2,3,4四个数值
n1 = SGLPushFront(n1, 1);
n1 = SGLPushFront(n1, 2);
n1 = SGLPushFront(n1, 3);
n1 = SGLPushFront(n1, 4);
//观察结果
SGLPrint(n1);

🌈观察空链表头插结果

4->3->2->1->NULL

👑总结:

n1每调用一次头插函数就得到一个新的头结点地址值,因为该方法采用的是传入n1的地址并让n1接受调用头插函数后新的头结点地址值,观察下图:

image-20220617170539627

原理图如下:

在这里插入图片描述


🌠链表头删
void SGLPopFront(SgLNode** Head)
{
	assert(Head);						//对二级指针断言,判断有没有链表
	if ((*Head) != NULL)				//如果链表为空则不进入头删函数,不为空则进入执行头删操作
	{
        //对指向原头结点地址的指针解引用并找到其指向的后续结点的结构体地址,将其赋值给新的结构体指针指向
        SgLNode* Next = (*Head)->next;
        //将原先指向头结点地址的指针解引用拿到头结点实参地址,并释放其malloc在堆的空间
        free(*Head);			
        //因为free前已经将原结点后续指向结点地址赋值给指针Next了,所以直接将该地址覆盖给指向空指针的*Head即完成头结点的转移
        *Head = Next;		
        //先释放结点,再将备份好的后续结点地址值覆盖结点指针指向;若操作顺序相反则无法释放头结点内存
	}
	
}
  1. 该函数考虑到了链表有多个结点和没有结点两种情况执行的头删操作,通过传入Head二级指针将实参n1地址再取地址传入来进行头结点地址的更新修改,断言Head确定是否有链表(而不是判断链表中有无结点)后,加入一句判断if ((*Head) != NULL),该语句可以判断链表中有无结点,有头结点则表明链表不为空,没有头结点则表示链表已经为空,不需要执行头删操作。
  2. 如果链表中存在头结点,即非空链表,则将该头结点的后继指针暂时备份给临时定义的结构体指针Next,将该头结点释放再将新的头结点地址由暂时备份的Next指针赋值,就可以得到新的头结点地址。
  3. 如果链表经过多次头删或尾删操作后已经变成了空链表,即头结点也被删除殆尽,或者传入头删或尾删函数中的n1取地址所代表的链表也为空,则判断if判断链表为空,不执行多余的删除功能,所以即使调用次数比链表本身结点还多也没问题,因为不会进行多余的删除操作,利用这一点可以创建定义链表销毁函数SGLDestroy,用来整个释放删除链表中的所有结点还给内存。

🌈测试用例

//对于1,2,3,4四个结点的链表头删2次或多次
SgLNode* n1 = BuyListNode(1);
SgLNode* n2 = BuyListNode(2);
SgLNode* n3 = BuyListNode(3);
SgLNode* n4 = BuyListNode(4);
n1->next = n2;
n2->next = n3;
n3->next = n4;
//头删2次留下后两个结点
SGLPopFront(&n1);
SGLPopFront(&n1);
SGLPrint(n1);
//对余下两个结点进行多次头删观察是否报错或失败
SGLPopFront(&n1);
SGLPopFront(&n1);		//调用头删到此处,链表已经为空
SGLPopFront(&n1);
SGLPopFront(&n1);
SGLPopFront(&n1);		//多次再调用头删,对结果不会产生影响,即多余调用的头删函数不执行
SGLPrint(n1);

🌈观察结果

//头删两次
3->4->NULL
//多次头删
NULL

🍥可以看到对拥有4个结点的单链表而言,头删两次释放了前两个结点,并将头结点地址更新到了n3的地址上,即将n3结点的地址赋值给了n1,观察调试可验证此观点:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UhD6OAQi-1659583052546)(https://cdn.jsdelivr.net/gh/VSKVSKVSK/PICTURE/202206192335154.png)]

之后再次调用了更多次比结点数量多的头删函数,但从结果上观察可知多次的头删函数调用并不会影响链表为空结结果,此时因为是空链表,头结点n1的地址为NULL。

头删原理图

链表头删


🌠链表查找(特定值的结点地址查找)
SgLNode* SGLFind(SgLNode* Head, SGLElemType x)					//链表查找
{
	assert(Head);
	SgLNode* Cur = Head;										//定义结构体指针并用首结点地址赋值
	while (Cur)													//当指针没遍历到末结点后的空值NULL时才继续循环
	{
		if (Cur->data == x)										//与传入的待查找值X和数值域data值对比
		{
			return Cur;											//满足相等则返回该结点地址
		}
		Cur = Cur->next;										//遍历循环控制条件
	}
	return NULL;												//找不到则返回空值
}

与在顺序表中查找一个所需值一样,需要对数组整个进行遍历和逐个比较值得大小相等关系,链表中也是通过结点结构体指针对单链表的不断后移,并访问数值域中的值来进行比较,如果找到与所需值对应的结点地址,则返回该地址即可;如果找不到则返回空值。

查找函数功能一般与链表中间插入函数,中间擦除函数一同使用,便于查找指定值并对该值或该值后进行增删改操作。


🌠链表中间插入(某结点前方)

在一个顺序表的中间某个下标插入数据,或在某个下标的前一或后一下标难免会产生整体移动插入位置后方整体数据位置的麻烦,只有尾插数据可以避免这一问题。而在链表中,根据之前学到的链表头插来看也不用整体向链表后方挪动数据,中间插入亦是同理,不管在某个结点地址的前方或是后方插入一个新的带值或空值结点也都不会产生数据挪动的问题,这是链表的性质所决定的。

🍥链表某结点前方插入数据

void SGLInsert(SgLNode** Head, SgLNode* pos, SGLElemType x)		//链表中间插入
{
	assert(Head);												//二级指针断言声明,防止链表无效
	if (*Head == NULL && pos == *Head)							//如果链表为空以及插入位置也为空,执行头插
	{
		SGLPushFront(Head, x);
	}
	else if (*Head == NULL && pos != *Head)						//如果链表为空但插入位置不为空,报错
	{
		printf("结点插入位置不合法\n");
		return;
	}
	else														//如果链表不为空
	{
		SgLNode* Prepos = *Head;							//定义找出待插入结点前一结点的临时指针,以头结点地址赋值
		SgLNode* InsertNode = BuyListNode(x);				//将待插入值放到开辟的新节点数值域中
		if (*Head == pos)									//如果前方插入的结点为头结点,则执行头插函数
		{
			SGLPushFront(Head, x);
		}
		else
		{
			while (Prepos->next != pos)						//如果不是头结点,则循环找到插入结点的前一个结点
			{
				Prepos = Prepos->next;
			}
			Prepos->next = InsertNode;						//将前一个结点后插入新结点并连接起来
			InsertNode->next = pos;
		}
	}
}
  1. 前方插入函数新增了一个指示目标结点地址的参数指针pos,该前方插入函数的目的就是在目标结点的前方链接一个新的结点到链表中,新的结点不仅要与目标pos结点的前方的原结点做好交接工作,其后继指针也要完整继承前原结点的后继地址,即pos结点的地址。总体而言,前方插入数据就是对原有链表的“插队”问题。
  2. 同其他链表功能函数一样,对链表的修改都要考虑空链表问题。观察函数参数,此处仅断言了传入的实参二级地址而并没有对目标结点地址断言,因为如果链表为空,此时的中间插入应当被当做是头插函数执行,这久需要目标结点地址也为空,如果断言为空则该函数没有意义,所以随后马上判断:if (*Head == NULL && pos == *Head),该语句说明如果当前链表是没有任何结点的空链表,且待插入的目标结点地址也为空,则执行头插功能。

🌈测试用例

//定义空链表
SgLNode* n1 = NULL;
//空链表前方插入
SGLInsert(&n1, n1, 1);
//打印链表
SGLPrint(n1);

🌈观察结果

1->NULL

由此可见,该中间插入功能在链表为空的情况下等同于尾插或头插函数,可实现相同效果。

🌈同上例,如果链表为空的时候输入的目标结点地址不合法,即不为空的情况下,此时中间插入找不到目标位置,则插入失败,看下例:

//定义空链表
SgLNode* n1 = NULL;
//定义带值结点
SgLNode* n2 = BuyListNode(2);
//在链表n1的基础上将新结点插入到n2的前方
SGLInsert(&n1, n2, 1);
//打印链表
SGLPrint(n1);

🌈观察结果
在这里插入图片描述
所以对于空链表的处理,只有在参数的目标结点地址与空链表的实参值相等且都为空值时,才能进行插入操作,且该操作等效于头插尾插。

对于已经存在结点的非空链表情况,观察函数,临时定义Prepos结构体指针的目的是寻找目标结点pos的前一个结点,使该前一个结点的后继等待新结点插入,同时创建一个新的结点InsertNode等待在pos的前方,原前一结点的后方插入。

定义存在5个结点的链表,分别进行中间插入,头部插入,尾部前方插入,原理图如下所示。

在这里插入图片描述

🌈更多中间前方插入和用例

SgLNode* n1 = BuyListNode(1);
SgLNode* n2 = BuyListNode(2);
SgLNode* n3 = BuyListNode(3);
SgLNode* n4 = BuyListNode(4);
SgLNode* n5 = BuyListNode(5);
n1->next = n2;
n2->next = n3;
n3->next = n4;
n4->next = n5;
printf("原链表结构:");
SGLPrint(n1);

//某结点位置前插入一个值
SGLInsert(&n1, n3, 8);					//n3结点前插入一个结点,其中新节点数据域为8
printf("n3前插一个8:");
SGLPrint(n1);

SGLInsert(&n1, n5, 10);					//n5结点前插入一个结点,其中新节点数据域为10
printf("n5前插一个10:");
SGLPrint(n1);

printf("新增结点10前插一个9:");			  //无名结点前插入值
SGLInsert(&n1, n4->next, 9);
SGLPrint(n1);

SGLInsert(&n1, n1, 0);					//头结点前插入值
printf("头结点前插一个0:");
SGLPrint(n1);

SGLInsert(&n1, n5, 77);					//末节点前插入值
printf("末结点前插一个77:");
SGLPrint(n1);

🌈观察结果

原链表结构:1->2->3->4->5->NULL
n3前插一个81->2->8->3->4->5->NULL
n5前插一个101->2->8->3->4->10->5->NULL
新增结点10前插一个91->2->8->3->4->9->10->5->NULL
头结点前插一个00->1->2->8->3->4->9->10->5->NULL
末结点前插一个770->1->2->8->3->4->9->10->77->5->NULL

🍥其中,在头结点前插入值会更新实参n1的地址值,这点在前例反复强调过二级指针或一级带返回值的区别和实参改变重要性。还需要注意的一点是,利用该中间插入函数新增的结点是没有变量名称的,需要通过前一个结点的后继指向所替代命名,比如n5前新插入的结点称为n4->next,如果多次在n5前插入,就需要明确n4后的结点所代表值到底是n4->next,n4->next->next或更多。但是对于头结点前插入而言,n1会随着头插地址的变化而变化而原来的结点则变成了新插入结点的n1->next或多次插入的n1->next->next->next…。


🌠链表中间插入(某结点后方)

后方插入对比前方插入优化了循环查找Prepos前一结点的过程,因为一个结点的后继是定义在结构里的,所以不需要我们再次对目标结点pos进行循环查找定位。

void SGLInsertAfter(SgLNode* pos, SGLElemType x)				//链表中间插入(某结点后方)
{
	assert(pos);												//判断pos结点是否存在
	SgLNode* NewNode = BuyListNode(x);							//等待在pos之后插入的新结点
    NewNode->next = pos->next;									//原pos后继改为新结点后继
    pos->next = NewNode;										//pos后继直接指向新结点,完成链接
}
  1. 后方插入与前方插入最大的一点不同是,其仅能够对现有结点的任意链表进行后方插入,而不能对空链表进行后方插入,因为它的插入是建立在pos结点之上的,如果连pos都不存在,也就不可能访问到pos->next,否则报错。

  2. 该函数的最大优点是省去了前方插入函数循环查找pos的前一结点Prepos结点所在,从而大幅度优化了时间复杂度,只要能提供合法的pos结点地址,就可以直接在其后链接新的带值或空值结点,并让新结点链接原pos的后继结点,完成“插队”操作。

  3. 如果后方插入位置在现有非空链表的末节点之后,则其功能相当于尾插PushBack,后继指针默认置空。

  4. 需要注意的是,最后两个地址的赋值转移语句顺序不能颠倒,否则原pos后继地址丢失,无法被新结点后继所指向。

    用例如下:

SgLNode* n1 = BuyListNode(5);
SgLNode* n2 = BuyListNode(3);
SgLNode* n3 = BuyListNode(2);
SgLNode* n4 = BuyListNode(1);
SgLNode* n5 = BuyListNode(4);
n4->next = n3;
n3->next = n2;
n2->next = n5;
n5->next = n1;
printf("原链表结构:");
SGLPrint(n4);

//某位置后方插入
printf("在3后插一个10:");
SGLInsertAfter(n2, 10);
SGLPrint(n4);

printf("在尾结点5后插一个20:");
SGLInsertAfter(n1, 20);
SGLPrint(n4);

观察结果

原链表结构:1->2->3->4->5->NULL3后插一个101->2->3->10->4->5->NULL
在尾结点5后插一个201->2->3->10->4->5->20->NULL

🍥值得注意的是,定义链表并没有按照n1->n5的顺序让其数值域为顺序1,2,3,4,5,但是打印结果仍未1->2->3->4->5->NULL,这是因为在链接阶段按照特定顺序链接起来了。承上例,如果后续在新结点上要调用中间后方插入函数就需要注意结点名称问题:

printf("在数值域为10的结点后方插入30:");
SGLInsertAfter(n2->next, 30);
SGLPrint(n4);

printf("在数值域为30的结点后方插入33:");
SGLInsertAfter(n2->next->next, 33);
SGLPrint(n4);

printf("在数值域为20的结点后方插入90:");
SGLInsertAfter(n1->next, 90);
SGLPrint(n4);

观察结果

在数值域为10的结点后方插入301->2->3->10->30->4->5->20->NULL
在数值域为30的结点后方插入331->2->3->10->30->33->4->5->20->NULL
在数值域为20的结点后方插入901->2->3->10->30->33->4->5->20->90->NULL

🌠链表擦除(特定结点)

擦除函数是链表中间插入函数的反过程,对于拥有结点的链表可以给定需要擦除的结点地址值进行释放和剩余结点的相互连接,如果给出的结点地址不在该链表中,则必定无法擦除。而对于空链表而言,擦除函数没有意义。

void Erase(SgLNode** Head, SgLNode* pos)
{
	assert(Head);									//判断链表是否存在
	if (*Head != NULL)								//判断链表是否为空
	{
		if (pos == *Head)							//如果擦除目标结点为头结点,效果等同于头删函数
		{
			*Head = pos->next;
			free(pos);
			return;
		}
		SgLNode* Prepos = *Head;					//定义寻找待擦除结点前一结点指针,作用于中间插入指针类似
		while (Prepos->next != pos && Prepos->next)	//查找目标前一结点
		{
			Prepos = Prepos->next;
		}
		if (Prepos->next == NULL)					//如果将链表遍历结束,指针置空则表示没有找到目标结点和前一结点
		{
			printf("没有此结点\n");
		}
		else										
		{
			SgLNode* tmp = pos->next;				//备份目标结点后一节点
			free(pos);								//擦除目标结点
			Prepos->next = tmp;						//将目标结点的前一结点指向目标结点的后一结点
		}
	}
}
  1. 擦除链表函数可看做是中间插入函数的逆过程,该例演示的是根据目标结点值擦除特定结点位置并连接邻接点,如果参照ADT还可以有根据数值域擦除链表结点,擦除目标节点前一结点或后一结点等等。
  2. 该函数中同样需要定义寻找目标结点前一结点的临时指针Prepos,因为如果仅仅寻找目标结点pos在链表中的位置,即使找到并擦出了,无法将其后继结点与前驱结点链接起来,链表断开就无法达到擦除链接的目的。寻找前一结点后通过临时结构体指针变量tmp来备份pos后继结点地址,再释放目标结点pos,最后让前一结点的后继指针Prepos->next指向备份好的目标节点后继地址tmp即可达成既擦除又链接链表的效果。

🌈测试用例

SgLNode* n1 = BuyListNode(1);
SgLNode* n2 = BuyListNode(2);
SgLNode* n3 = BuyListNode(3);
SgLNode* n4 = BuyListNode(4);
SgLNode* n5 = BuyListNode(5);
n1->next = n2;
n2->next = n3;
n3->next = n4;
n4->next = n5;
printf("原链表结构:");
SGLPrint(n1);

Erase(&n1, n4);
SGLPrint(n1);
Erase(&n1, n3);
SGLPrint(n1);
Erase(&n1, n1);
SGLPrint(n1);

🌈观察结果

原链表结构:1->2->3->4->5->NULL
1->2->3->5->NULL
1->2->5->NULL
2->5->NULL

🍥可以看到三次调用擦除函数分别依次擦除了4,3,1三个结点,剩下的结点正常链接起来成为新的链表,并且现在链表的头结点地址值n1 = n2 = *Head。

  1. 擦除函数也可以当做特殊的头删函数,就像中间后方插入函数可以当做特殊的链表尾插函数,看下例:

在这里插入图片描述

🍥这里需要注意,只有实参传入的头结点(该例为n1)可以反复调用擦除函数,如果对于非头结点地址反复调用传参,则无法达成如传入头结点般的头删效果,看下例:

在这里插入图片描述


🌠释放链表
void SGLDestroy(SGLNode** Head)
{
    assert(Head);
    while(*Head)
    {
        PopBack(Head);
    }
}
  1. 同顺序表动态开辟的数组空间arr一样,链表的每个结点因为都是用户每次向系统申请显性开辟的内存空间,当不再使用该单链表的时候自然应该销毁和释放之前申请的内存空间。
  2. 使用尾删函数或头删函数并置于循环中都是可行的,当链表不为空时(判断依据为链表的头结点不为空NULL),就不断执行尾删操作,直到在尾删函数中将头结点置空,则外部循环条件判断为假,循环结束,此时链表结点全部为尾删完全,全部释放并置空。

✨总结

单链表只是所有链表结构中最简单的一种数据结构,它以其结构定义的简洁性和内存占用小等优势广泛应用于其他更复杂的数据结构比如树和图当中。单链表本身并不适合单独地存储数据,相比于顺序表它的优势并没有那么明显,因为顺序表虽然在头插,头删等方面数据挪动的时间复杂度更高,但其在随机访问读取数据和内存空间利用率上相比于链表更有优势。链表虽然在插入删除数据等方面只需要新增或释放结点,但因为其数据是随机存储在内存当中,对于特定结点值的访问和修改就变得较为棘手,最坏的情况需要将整个链表都遍历完全才能或仍然不能读取访问到该数据。总而言之,链表和顺序表在结构和数据访问上是相辅相成,互有优劣的,具体的应用需要根据需求而选择,不能将某种数据结构一概而论,应用到所有场合,而是需要按需所给。


⭐后话

  1. 博客项目代码开源,获取地址请点击本链接:链表云代码仓
  2. 若阅读中存在疑问或不同看法欢迎在博客下方或码云中留下评论。
  3. 欢迎访问我的Gitee码云,如果对您有所帮助还可以一键三连,获取更多学习资料请关注我,您的支持是我分享的动力~
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值