在上一节我们学习了线性表中的顺序表,今天我们来学习一下线性表中的另一种结构——单链表
前言
我们在之前已经初步了解了数据结构中的两种逻辑结构,但线性结构中并非只有顺序表一种,它还有不少兄弟姐妹,今天我们再来学习一下单链表。
一、单链表是什么?
单链表是一种基础的数据结构,用于存储一系列元素。它由一系列节点(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进行测试一下,咱们一部分一部分地测试,最后咱们就能写好一个单链表了。
总结
我们这节学习的单链表与上一节学习的顺序表有着不少相似之处,但是二者的区别也是很大的,希望大家能够熟练掌握这两种数据结构的实现。最后告诉大家:孰能生巧!