线性表之单链表

在上一节我们学习了线性表中的顺序表,今天我们来学习一下线性表中的另一种结构——单链表


前言

我们在之前已经初步了解了数据结构中的两种逻辑结构,但线性结构中并非只有顺序表一种,它还有不少兄弟姐妹,今天我们再来学习一下单链表。

一、单链表是什么?

单链表是一种基础的数据结构,用于存储一系列元素。它由一系列节点(node)组成,每个节点包含两个部分:一个数据部分和一个指向下一个节点的指针部分。它与我们上一节学习的顺序表有所不同,仅能根据数组的下标来确定位置,单链表它能够根据它结点中的指针来找寻下一个结点,这样无形之中就能提高存取数据的灵活性了。

与顺序表不同的是,链表中的元素的存储空间都是独立申请下来的,我们称之为“结点”,结点一般都是从堆上申请下来的(一般通过malloc,realloc等动态内存分配函数来进行申请空间的),这些从堆上申请的空间,是按照一定的策略分配出来的,每次申请的空间可能连续,也可能不连续。这就是它在物理结构上不连续的一个原因了。

结点中包含两个域:其中存储元素信息的域称为数据域;存储直接后继存储位置的域称为指针域

对于这个结点,还有不少学问呢!接下来我们来对首元结点,头结点,头指针3个容易混淆的概念进行说明。

(1)首元结点是用来存储链表中第一个数据元素的结点。

(2)头结点是在首元结点之前的一个结点,它的指针域指向首元结点的位置,头结点的数据域中可以不存放任何信息,也可以存储与数据元素类型相同的其他附加信息。例如当数据元素为整型时,头结点的数据域可以存放该链表的长度(因为长度一般是整型类型)。

(3)头指针是指向链表中第一个结点的指针,若链表中设有头结点,那么头指针就指向头结点,若没有设头结点,头指针就直接指向首元结点。

这时候,就会有人要问了:既然已经有了首元结点了,为啥还要设置一个头结点呢?这不是画蛇添足嘛。现在,我来给你们介绍一下有头结点的好处:

1)便于对首元结点的处理:有了头结点之后,我们能够更好地处理有关首元结点的操作,比如插入删除等操作,我们也不必为它特意写个函数来实现这些功能了,我们使用正常的插入删除操作就能够实现它了。

2)便于对空表与非空表的统一处理:当链表不设置头结点时,假设L为该链表的头指针,它应该指向首元结点,当单链表的长度为0时的空表时,L指针为空(判定链表为空表的条件就是:L==NULL)我们要知道,我们一般都是将结点的指针域指向NULL的,现在咱们将头指针指向NULL,那么我们就会造成一个误解:这个头指针是个NULL。当我们增加头结点之后,无论链表是否为空,头指针都是指向首元结点的非空指针。(判定链表为空表的条件就是:L->next==NULL)下图是设有头结点的单链表

二、单链表与顺序表的比较

首先我们先对它们的概念进行一下对比:

单链表:单链表是逻辑结构上连续,物理结构上不连续的数据结构,数据元素中的逻辑顺序是通过链表中的指针链接次序实现的。

顺序表:顺序表是逻辑结构上连续,物理结构上也连续的数据结构,数据元素的逻辑顺序是通过数组下标进行实现的。

空间性能的比较

(1)存储空间的分配:顺序表的存储空间是必须预先分配的,元素个数具有一定限制,容易造成空间浪费或者空间溢出的情况;而链表可以根据数据元素来进行分配空间,只要内存空间允许,链表中的元素个数就没有限制,可以说有几个元素给几个结点空间。

(2)存储密度的大小:由于链表中除了设置了数据域还设置了指针域,用来存储元素之间逻辑关系的指针。从存储密度上来说,这是不经济的。所谓存储密度就是存放数据的空间占据结点空间的比例

当存储密度越高,那么存储空间的利用率就越高。由此我们可以知道,顺序表的存储利用率是100%,因为它整个结点都存放着数据,而链表中由于存放了指针域,那么它的存储利用率就小于100%。

由上面的两个比较,我们可以得出一个结论:当线性表的长度较大且难以估测存储规模时,宜采用链表作为存储结构;当线性表的长度不大且我们事先已经知道其具体大小时,为了节约存储空间,宜采用顺序表来作为存储结构。

时间性能的比较

(1)存取元素的效率:由于上面两种的物理结构有所不同,它们的存取方式也有所不同。其中,顺序表是随机存取(因为它的底层基础是数组,数组具有下标,当我们想要查找某个元素时,可以直接根据数组下标来查找相应的元素),链表是顺序存取(因为链表是由一个个结点通过结点中的指针域链接而成的,因此我们每次在查找某个元素时,只能够通过指针从首元结点开始逐个遍历来找到相应的元素)这里它们两个的时间复杂度也不同,前者是O(1),后者是O(N)。

(2)插入与删除操作的效率:对于链表已经确定的元素插入删除的位置后,插入删除操作无须移动数据,只要修改指针即可,时间复杂度为O(1)。而对于顺序表,即使已经知道要插入删除的位置之后,我们在进行插入删除操作时,仍要进行大量元素的移动来实现,时间复杂度为O(N)。而且当每个结点的信息量较大时,移动结点的时间开销就很多了。因此对于频繁进行插入删除操作的线性表,宜采用链表作为存储结构。

三、单链表的实现

接下来我们来介绍一下如何来实现一个单链表,接下来我将我写的源代码与一些注释附上。与顺序表一样,我们也将单链表分为三个文件:SList.h    ,SList.c,    test.c。

SList.h

#pragma once
typedef int SLTDatatype; //定义一个数据变量,方便后面一键替换数据类型

//定义一个单链表结点
typedef struct SListNode
{
	SLTDatatype data;//存放数据
	struct SListNode* next;//指向下一个结点的地址
}SLTNode;

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


//插入
//插入新结点(每次插入前要申请一个新的结点)
//由于头插,尾插都有可能涉及到头指针,因此形参我们要使用二级指针,实参要传递的是一级指针的地址
void SLTPushBack(SLTDatatype**pphead,SLTDatatype x);
void SLTPushFront(SLTDatatype**pphead, SLTDatatype x);

//删除
void SLTPopBack(SLTNode**pphead);
void SLTPopFront(SLTNode**pphead);

//查找
SLTNode* SLFind(SLTNode* phead,SLTDatatype x);

//在指定位置之前插入数据
void SLTInsert(SLTDatatype** pphead, SLTNode* pos, SLTDatatype x);

//在指定位置之后插入数据
void SLTInsertAfter(SLTDatatype** pphead, SLTNode* pos, SLTDatatype x);

//删除pos结点
void SLTErase(SLTNode** pphead, SLTNode* pos);

//删除pos之后的结点
void SLTEraseAfter(SLTNode* pos);

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

由上面的代码,我们可以看出,我们在定义一个结点的时候,在结构体中只有两个成员元素:数据域,指针域。另外,这里函数里面传递的参数我们也要注意一下:与之前的顺序表不同,之前的顺序表我们是由数组来实现的,因此我们传递参数时,直接就传递了链表的指针变量即可,但是现在我们在链表中,我们本身就是通过指针来找寻下一个结点的位置,因此我们在传递参数的时候我们要传递的是链表指针的地址,我们在之前学过:存放一级指针的地址的指针叫做二级指针。于是我们的参数传递的就是一个二级指针。(注意:我们传递二级指针作为参数的一定是在那个函数中,我们需要对那个头指针进行相关解引用操作)

SList.c

#define  _CRT_SECURE_NO_WARNINGS
#include"SList.h"
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

//申请一个新结点
SLTNode* SLTBuyNode(SLTDatatype x)
{
	SLTNode* node = (SLTNode*)malloc(sizeof(SLTDatatype));
	if (node  == NULL)
	{
		perror("malloc");
		exit(1);
	}
	//将结点中的数据内容初始化
	node->data = x;
	node->next = NULL;

	return node;
}


//打印链表
void SLTPrint(SLTNode* phead)
{
	//定义一个指针,后面来遍历链表。初始位置指向phead头指针
	SLTNode* pcur = phead;
	while (pcur)    //pcur!=NULL
	{
		printf("%d->", pcur->data);
		pcur = pcur->next;   //使指针pcur不断向后移动
	}
	printf("NULL\n");//链表的最后一个结点的指针域指向NULL的
}

//尾插
void SLTPushBack(SLTDatatype** pphead, SLTDatatype x)
{
	assert(pphead);//防止头指针的地址找不到

	//pphead这是二级指针,即pphead==&phead
	//*pphead==phead(这就是头指针的指针,存放着头指针的地址),**pphead==*phead即头指针地址指向的那个结点

	SLTNode* newnode = SLTBuyNode(x);//定义一个新结点,等会进行插

	if (*pphead == NULL)
	{
		//这种情况是空链表,因此头指针指向NULL,然后直接加入新节点
		*pphead = newnode;
	}
	else
	{
		//这种情况是不是空链表,因此我们要找插入的位置
		//首先找一个尾结点,然后在尾结点后面进行插入新结点,即尾插
		SLTNode* pcur = *pphead;  //这里定义一个新的指针是为了后面方便找尾结点的,必须将其初始化为头指针,否则它不是从头指针开始遍历查找
		while (pcur->next ) //这里的条件是为了找尾结点,只要这个结点的下一个结点是一个NULL。那么就可以确认了这是一个尾结点
		{
			pcur = pcur->next;
		}//pcur nownode
		pcur->next = newnode;//将新节点的地址传递给尾结点的指针域,那么newnoda就变成尾结点了
	}
}

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

	newnode->next = *pphead;// 我们将新插入的新节点的指针域指向头指针,注意:我们要赋值的是头指针(已经是一个地址了,如果我们直接写pphead,这是头指针的地址)
	*pphead = newnode;      //然后将新节点的地址赋给头指针,即新节点作为头指针

}

//头删
void SLTPopFront(SLTNode**pphead)
{
	assert(pphead && *pphead);//判断不是一个空链表且头指针的地址要存在

	SLTNode* next = (*pphead)->next;  //在删除之前,咱们可以先用一个next结点将头指针下一个结点的地址保存下来
	free(*pphead);       //将头指针删除
	*pphead = next;      //再将下一个结点作为头指针

}


//尾删
void SLTPopBack(SLTNode** pphead)
{
	assert(pphead && *pphead);
	//尾删分两种情况:一种:只有一个结点,直接删除释放;一种:有好几个结点,我们要先找到最后一个结点,然后再进行删除
	if ((*pphead)->next == NULL)//如果下一个结点是一个NULL,那么这只有一个结点
	{
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
		//这种情况我们要找到最后一个结点并且将它删掉,因此我们还要找到最后一个结点的前一个结点
		SLTNode* ptail = *pphead;  //最后一个结点
		SLTNode* prev = NULL;      //最后一个结点的前一个结点
		while (ptail->next )
		{
			prev = ptail;          //最后一个结点的位置给它上一个结点
			ptail = ptail->next;   //结点不断向后移动

		}
		prev->next = NULL;   //将上一个结点的指向的内容设为NULL,因为此时已经是尾结点了
		free(ptail);
		ptail = NULL;
	}
}

//查找
SLTNode* SLFind(SLTNode* phead,SLTDatatype x)
{
	assert(phead);   //判断链表要不为空
	SLTNode* pcur = phead;
	while (pcur)
	{
		if (pcur->data == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	return NULL;
}


//在指定位置之前插入数据
void SLTInsert(SLTDatatype** pphead, SLTNode* pos, SLTDatatype x)
{
	assert(pphead && pos);
	if (pos == *pphead)
	{
		SLTPopFront(**pphead);
	}
	else
	{
		//先创建一个新节点
		SLTNode* newnode = SLTBuyNode(x);
		SLTNode* prev = *pphead;
		while (prev->next=pos )
		{
			prev = prev->next;
		}
		newnode->next = pos;
		prev->next = newnode;
	}
}

//在指定位置之后插入数据
void SLTInsertAfter(SLTDatatype** pphead, SLTNode* pos, SLTDatatype x)
{
	assert(pphead && pos);
	SLTNode* newnode = SLTBuyNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

//删除pos结点
void SLTErase(SLTNode**pphead, SLTNode* pos)
{
	assert(pphead && pos &&*pphead );
	if (pos==*pphead)
	{
		SLTPopFront(pphead);
	}
	else
	{
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		//prev pos pos->next
		prev->next  = pos->next;
		free(pos);
		pos = NULL;
	}
}

//删除pos之后的结点
void SLTEraseAfter(SLTNode* pos)
{
	assert(pos && pos->next );
	SLTNode* del = pos->next;
	pos->next = pos->next->next;
	free(del);
	del = NULL;
}

//销毁链表
void SLDestroy(SLTNode**pphead)
{
	assert(*pphead && pphead);
	SLTNode* pcur = *pphead;
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	*pphead = NULL;
}

在这个文件中,我们所要实现的就是单链表。在这里面有些我重点拿出来讲讲(其实在上面的源代码中的注释已经很详细了)

(1)我们要有一个创建一个新结点的操作,因为在后续操作中,插入首先都是创建一个新结点,然后再进行插入;

(2)我们如果想要查找某个元素或者在某个位置插入删除,在此之前,我们要新定义一个新的指针来遍历链表,找到自己想要的位置,我们在定义的时候一般都是将该指针指向头指针的位置;

(3)我们在进行删除操作的时候,有时候我们如果想要释放某个结点空间的时候,我们在移动链表之前,一定要定义一个新的指针来存放那个将要删除的结点地址,因为我们要知道一旦我们移动链表,如果将那个结点覆盖掉了就没了,我们因此就无法释放那个空间了;

test.c

#define  _CRT_SECURE_NO_WARNINGS
#include"SList.h"
#include<stdio.h>
#include<stdlib.h>


void SLTtest()
{
	SLTNode* plist = NULL;//定义初始化一个头指针

	//尾插
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);
	SLTNode*find= SLFind(plist, 33);
	if (find == NULL)
	{
		printf("没找到\n");
	}
	else 
	{
		printf("找到了\n");
	}
	SLDestroy(&plist);
	SLTPrint(plist);

	//头插
	SLTPushFront(&plist, 9);
	SLTPushFront(&plist, 8);
	SLTPushFront(&plist, 7);
	SLTPushFront(&plist, 6);
	SLTPrint(plist);
	SLTPopBack(&plist);
	SLTPrint(plist);
	SLTPopBack(&plist);
	SLTPrint(plist);
	SLTPopBack(&plist);
	SLTPrint(plist);
	SLTPopBack(&plist);
	SLTPrint(plist);

}

int main()
{
	SLTtest();
	return 0;
}

上面这个文件,我们是用来进行测试,我们在写好某个功能之后,可以到这个test.c进行测试一下,咱们一部分一部分地测试,最后咱们就能写好一个单链表了。

总结

我们这节学习的单链表与上一节学习的顺序表有着不少相似之处,但是二者的区别也是很大的,希望大家能够熟练掌握这两种数据结构的实现。最后告诉大家:孰能生巧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值