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