
文章目录
上一次我们讲完了顺序表的概念、实现以及3道顺序表相关的习题,顺序表是一种非常常用的数据结构,但是也存在相应的问题:
- 中间/头部的插入删除,时间复杂度为O(N)
- 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
- 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
那有没有一种数据结构,头部插入删除的时间复杂度为O(1),同时不需要增容,这样就不需要申请新空间,拷贝旧数据,释放旧空间,还要不存在空间的浪费?有的,兄弟,有的,那就是我们今天准备讲到的链表。
一、链表
1.1概念与结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

链表就像是一列火车,淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉加上,不会影响其他车厢,每节车厢都是独立存在的。链表就是由这样有一节一节的"车厢"组成的,具体结构如下:

1.2结点
与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,我们称之为"结点/节点",一个结点中存在2个空间,也叫做域,分别是数据域和指针域,数据域保存当前结点的数据,指针域存放下一个结点的地址。
图中指针变量 plist保存的是第一个结点的地址,我们称plist此时"指向"第一个结点,如果我们希望plist"指向"第二个结点时,只需要修改plist保存的内容为0x0012FFA0。
plist节点就像火车的车头,车头不用做乘客,所有存储的数据为空,但是需要链接下一列车厢,而车头与车厢,车厢与车厢之间链接的部分就是我们这里的指针。
链表中每个结点都是独立申请的(即需要插入数据时才去申请一块结点的空间),我们需要通过指针变量来保存下一个结点位置才能从当前结点找到下一个结点。
1.3链表的性质
- 链式结构在逻辑上是连续的,在物理结构上不一定连续
- 结点一般是从堆上申请的
- 从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续
二、单链表
单链表是最常见的链表,我们先来讲解单链表的相关知识。
2.1单链表的创建
cpp
typedef int Datatype;
struct SListNode//S:single 单
{
Datatype data;
struct SListNode* next;//指向下一个节点的地址
};
typedef struct SListNode SLTNode;
我们创建单链表SListNode这个结构体需要一个数据域data存放数据,需要一个指向下一个结点的指针域next,所以这个next指针的数据类型是struct SListNode* ,然后将int重命名为Datatype方便如果需要修改类型时方便修改,重定义结构体类型为SLTNode方便写。
2.2单链表初始化
在顺序表中,我们实现了一个对顺序表初始化的一个函数,那是因为顺序表中如果没有数据时,我们会将它空间内的值都置为1,但是对于链表来说,就没有必要实现初始化函数了,因为链表始终会又一个plist指针指向头节点,如果链表为空,则直接将头指针置为NULL就行。
2.3打印单链表
我们想要打印单链表的前提是我们得先有单链表,所以我们就在test01测试函数中创建节点。创建节点会用到的是malloc函数,而不是我们之前在顺序表中用到的realloc函数,这是因为之前我们顺序表可能会存在已经存入数据的情况,这时候我们使用realloc函数就可以在原有的基础上创建一段连续的空间。而我们的单链表不要求内存空间连续,所以使用malloc函数。
cpp
void test01()
{
//创建单链表
//开辟节点空间
SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* node2 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* node3 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* node4 = (SLTNode*)malloc(sizeof(SLTNode));
//初始化
node1->data = 1; node1->next = node2;
node2->data = 2; node2->next = node3;
node3->data = 3; node3->next = node4;
node4->data = 4; node4->next = NULL;
}

接着我们来定义打印单链表函数,其代码如下:
cpp
// 打印单链表函数
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur != NULL)
{
printf("%d ", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
我们根据我们创建节点的结构图来理解这个代码是怎么实现的,对于这个打印函数,我们需要先知道打印的是哪一个单链表,因此给该函数传入一个链表的头节点的指针简称头指针phead,接着用头指针遍历整个单链表,但是如果直接使用phead指针,则遍历之后我们初始传入的头节点的位置就很难再次找到了,因此需要定义一个工作指针pcur来存放头节点的位置,接着循环遍历,打印每个链表节点数据域data的值,接着pcur工作指针通过当前节点的next指针向后移动,循环遍历结束的条件为pcur指向空,那么说明该位置不是单链表内部,我们越界了,想要告诉用户该节点为空只能在while循环之外告知,因为现在这个位置是空指针,我们无法对空指针解引用。
2.4申请节点函数
想要插入数据,我们就得先使用malloc函数创建一个节点,而且这个创建节点的操作无论是在头插还是在尾插中,都会需要,因此我们可以将它封装成一个函数,具体实现如下:
cpp
// SList.c实现文件
//申请节点空间
SLTNode* SLTBuyNode(SLTDatatype x)
{
SLTNode* newNode = (SLTNode*)malloc(sizeof(SLTNode));
if (newNode == NULL)
{
perror("malloc fail!");
exit(1);
}
newNode->data = x;
newNode->next = NULL;
return newNode;
}
这个申请节点函数,我们需要传入想要插入的值x,接着使用malloc函数申请空间,如果申请失败就报错异常退出,申请成果则将新节点newNode的数据域存入要插入的值x,指针域指向空指针NULL。
2.5尾插函数

单链表尾部插入数据,具体代码如下:
cpp
// SList.c实现文件
//尾插函数
void SLTPushBack(SLTNode** pphead, SLTDatatype x)
{
//断言防止传入空指针
assert(pphead);
//申请节点
SLTNode* newNode = SLTBuyNode(x);
//phead为空
if (*pphead== NULL)
{
*pphead= newNode;
}
//phead不为空
SLTNode* ptail = *pphead;
while (ptail->next != NULL)
{
ptail = ptail->next;
}
ptail->next = newNode;
newNode->data = x;
newNode->next = NULL;
}
这里必须要注意的是传入的头指针为二级指针,因为涉及了对单链表数据进行操作。phead是一个头指针,它存储的是下一个节点的地址,如果直接传入phead就相当于拷贝了一份头指针指向的内容,但是这不是头指针的地址,这就变成了一个传值调用,但是要想直接修改单链表就必须得用传址调用,传入二级指针。我们就可以用尾插函数实现单链表的创建,测试如下:
cpp
void test02()
{
//创建空链表
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPrint(plist);
}

2.6头插函数
cpp
//头插函数
void SLTPushFront(SLTNode** pphead, SLTDatatype x)
{
//断言防止传入空指针
assert(pphead);
//申请节点
SLTNode* newNode = SLTBuyNode(x);
if (*pphead == NULL)
{
*pphead = newNode;
}
else
{
newNode->next = *pphead;
*pphead = newNode;
}
}
我们用图像来理解头插函数是怎么实现的:

头插函数只需要先将头部新插入的newNode节点的下一个节点指向当前的*pphead头节点,然后新的newNode节点就变成了新的头节点。测试如下:
cpp
//创建空链表
SLTNode* plist = NULL;
SLTPushFront(&plist, 1);
SLTPushFront(&plist, 2);
SLTPushFront(&plist, 3);
SLTPushFront(&plist, 4);
SLTPrint(plist);

2.7尾删函数

cpp
//尾删函数
void SLTPopBack(SLTNode** pphead)
{
assert(pphead&&*pphead);//都不能为空,pphead为空就传入了空指针,*pphead为空就说明单链表中没有节点
if ((*pphead)->next == NULL)//只有一个节点时
{
free(*pphead);
pphead = NULL;
}
else
{
SLTNode* ptail = *pphead;
SLTNode* prev = NULL;//找尾节点的前一个节点
while (ptail->next != NULL)
{
prev = ptail;//先让prev指向ptail的节点
ptail = ptail->next;//接着ptail节点再向后移
}
//删除节点
prev->next = NULL;//断开链接
free(ptail);//释放尾节点
ptail = NULL;
}
}
如何尾删,这里需要处理的有2部分,一个是要删除尾节点,释放尾节点空间,一个是要将删除之后的新尾节点的next指针域置为NULL空指针。所以我们使用ptail找尾节点,使用prev找尾节点的上一个节点。当单链表中只有一个节点时需要特殊处理,直接将这个节点释放,然后将pphead置为空指针。
2.8头删函数

cpp
//头删函数
//先存头节点的下一个节点
void STLPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
这里实现头删函数,我们可以事先将头节点的下一个节点的地址存起来,释放完头节点之后,让头节点指针指向我们存的地址,这样原本第2个节点就成了头节点。
2.9查找函数
cpp
//查找函数
SLTNode* SLTFind(SLTNode* phead, SLTDatatype x)
{
SLTNode* ptail = phead;
while (ptail)//ptail不为空
{
if (ptail->data == x)
{
return ptail;
}
ptail = ptail->next;
}
return NULL;
}
这里查找函数就是我们用一个工作指针ptail遍历整个链表,只要工作指针ptail不为空,就查找下一个节点,查找到了就返回ptail,未查找到就返回NULL。
2.10指定位置之前插入节点

cpp
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDatatype x)
{
assert(pphead && pos);
//pos指向头节点
if (pos == *pphead)
{
//头插
SLTPushFront(pphead, x);
}
SLTNode* newNode = SLTBuyNode(x);
// 找pos的前一个节点
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
//找到之后完成牵手
newNode->next = prev->next;
prev->next = newNode;
}
这里就是我们循环找pos的前一个节点,找到之后完成牵手,需要注意的是,如果我们的pos就是我们现在的头节点,那么就相当于头插,需要单独处理。
2.11指定位置之后插入节点

cpp
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDatatype x)
{
assert(pos);
SLTNode* newNode = SLTBuyNode(x);
newNode->next = pos->next;
pos->next = newNode;
}
这里和在指定位置之前插入节点不同的是,在指定位置之后插入节点不需要传头节点的执政,因为指定位置pos之后的节点就是pos->next指针指向的位置,之前我们需要头节点是因为我们的pos指针找不到pos前面的节点,只能用头节点遍历才行。
2.12删除pos节点

cpp
//删除pos结点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && pos&& *pphead);
SLTNode* prev = *pphead;
if (pos == *pphead)
{
//头删
SLTPopFront(pphead);
}
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
这里我们删除pos节点之后是不是还需要将pos之前的节点与pos之后的节点连接起来,因此需要找pos之前的节点,就要用头指针循环遍历,最后用指针prev来指向pos之前节点的位置,接着完成牵手后,删除pos节点。需要注意的是,如果我们要删除的节点刚好就是我们的头节点,即为头删,需要单独处理。
2.13删除pos之后的节点

cpp
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
assert(pos&&pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
这里删除pos之后的节点,可以直接通过pos找到,然后将pos和pos之后的之后的节点连接起来就行。同时要注意pos和pos之后的节点都不能未空。
2.14销毁单链表

cpp
//销毁链表
void SListDestroy(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
while (pcur)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
这里我们要销毁单链表就需要从头节点开始,要先用next指针记下头节点下一个节点的指针,之后释放pcur指向的空间,接着pcur指向next的空间,next指向pcur的下一个节点的空间。最后因为头节点的空间已经还给了操作系统,*pphead变成了野指针,所以将*pphead置为空。