目录
前言:
最好与上一节一起学习
上一节,我们分析了顺序表的底层逻辑,并基于此开发了则增加、删除、查找等的功能但顺序表也有很多缺点:中间和头部插入效率低下,增容降低运行效率,增容造成空间浪费......
为了解决这些问题就需要我们这次探讨的内容:链表
一、链表的分类
链表的结构非常多样,以下情况组合起来就有8种

带头:指的是链表中有哨兵位的节点,该哨兵位就的头节点(在下面单链表的实现中我们提到的头节点实际上是第一个有效节点,这样错误的称呼为了方便理解)

单向只能从一个方向遍历,双向可以从两个方向遍历

循环尾节点不为空

单链表是单向不带头不循环链表,双向链表是带头双向循环链表
二、单链表
链表也是线性表的一种,它们三个在物理和逻辑结构上是否线性如下
|-----|-----------|-----------|
| | 物理结构上是否线性 | 逻辑结构上是否线性 |
| 线性表 | 不一定 | 是 |
| 顺序表 | 是 | 是 |
| 链表 | 否 | 是 |
我们可以把链表形象的看作一个火车,它是由一节一节车厢组成的,还可以根据需要增加或减少车厢。链表是由一个一个的节点组成,我们想往链表里储存数据就只需要申请一个一个空间然后加在链表里就好了。
链表的节点是由两部分组成,存储的数据和指向下一个节点的指针。

三、单链表的功能实现
在顺序表中强调的部分将不再过多赘述
3.1单链表的初始化
我们想要定义单链表就是要定义链表的节点的结构,定义好结构就可以让它们像手拉手一样连接起来。节点的定义类型是SListNode所以指向下一个节点的指针的类型是SListNode*。
//定义节点的结构
//数据+指向下一个节点的指针
typedef int SLTDataType;
typedef struct SListNode
{
int data;
struct SListType* next;
}SLTNode;
申请节点需要用到malloc函数,记得注意申请失败的情况,而且要定义节点的内容(我们还不知道申请的这个节点的下一个节点是什么,所以NULL)
//申请新节点
SLTNode* SLTBuyNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail!");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
}
3.2单链表的打印
只要pcur不为NULL,while会一直循环,在while循环里打印pcur->next的值,再通过pcur = pcur->next(下一个节点的指针)使pcur移动到下一个节点,直到pcur移动到最后一个节点就退出循环,最后我们手动加上链表最后的空指针NULL。
//打印
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur)//pcur != NULL;
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}

3.3单链表的尾插
这里我们实现尾插功能时传递数据要用到二级指针,这里是传值和传地址的区别:如果传值,函数接收的是参数的副本,修改副本不会影响原变量,如同"抄作业",改抄本不改变原本。但传地址(传引用),函数接收的是参数的内存地址,修改时直接操作原变量,如同"给钥匙",对方能直接改动你家的东西。所以传值适用于无需修改原变量的场景,如计算两数之和,安全但会额外占用内存。传地址适用于需要修改原变量的场景,如交换两个变量的值,高效且不占用额外内存,但需谨慎操作避免意外修改。
总结来说,如果我们的代码会改变参数就传地址,不改变就传值。

我们的尾插逻辑是先找到尾节点再将尾节点和新节点连接起来。但 我们先要判断链表存在才能尾插,然后链表存在还有判断链表里有没有数据,没有就直接插入,有的话就先找到尾节点再将尾节点和新节点连接起来
//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
//SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* newnode = SLTBuyNode(x);
//空链表和非空链表
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
//找到尾节点
SLTNode* ptail = *pphead;
while (ptail->next != NULL)
{
ptail = ptail->next;
}
//ptail指向的是尾节点
//尾节点和新节点相连
ptail->next = newnode;
}
}

3.4单链表的头插
首先要申请一个新节点,然后要把新节点与原来链表的头节点链接在一起。我们先要判断链表存在才能头插,头插时链表里有没有数据对插入没有影响。
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
//newnode *pphead
newnode->next = *pphead;
*pphead = newnode;
}
3.5单链表的尾删
尾删就是先找尾节点,把尾节点释放的同时还要把尾节点前一个节点的next指向NULL,我们先要判断链表存在并且链表不为空(为空的话删谁呢)才能尾删。如果链表只有一个节点就更特殊,直接释放那一个节点,然后置为NULL。
//尾删
void SLTPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
//链表不能为空
//链表只有一个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
//链表有多个节点
else
{
SLTNode* prev = *pphead;
SLTNode* ptail = *pphead;
while (ptail->next)
{
prev = ptail;
ptail = ptail->next;
}
//ptail 找到尾节点
free(ptail);
ptail = NULL;
prev->next = NULL;
}
}
3.6单链表的头删
头删要先把第二个节点的地址存储起来再释放头节点最后把第二个节点定义为头节点,我们先要判断链表存在并且链表不为空(为空的话删谁呢)才能头删。
//头删
void SLTPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
3.7单链表的查找
查找就是遍历链表,让节点中储存的data和x进行比较
//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* pcur = phead;
while (pcur)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;
}
//pcur == NULL
return NULL;
}
3.8在指定位置之前插入数据
我们先要找到pos前一个节点,让前一个节点指向新节点,再让新节点指向pos节点。我们要先判断链表必须存在,并且不能为空链表,否则pos根本就不存在。但还要一个特别情况就是只有一个节点,pos就是头节点,所以这就相当于头插。
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(*pphead);
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
//若pos == *pphead,说明是头插
if (pos == *pphead)
{
SLTPushFront(pphead, x);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
newnode->next = pos;
prev->next = newnode;
}
}
3.9在指定位置之后插入数据
我们直接让新节点指向pos的下一个节点,让pos指向新节点。这里与上一个相比不需要头节点是因为用不到,在指定位置之前插入数据我们需要找到pos之前的节点,但在指定位置之后插入数据我们要找的是pos之后的节点,可以直接用pos找到。
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
3.10删除pos节点
我们要先找到pos前一个节点,让pos前一个节点指向pos后一个节点,最后释放pos节点。我们要先判断链表必须存在,并且不能为空链表,否则pos根本就不存在。但还要一个特别情况就是只有一个节点,pos就是头节点,所以这就相当于头删。
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
//pos是头节点
if (pos == *pphead)
{
//头删
SLTPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
3.11删除pos之后的节点
我们需要不断释放pos之后的节点。这里与上一个相比不需要头节点是因为用不到,删除pos节点需要找到pos前一个和后一个节点,但删除pos之后的节点需要找到pos之后的节点和pos之后节点的之后节点。
//删除pos之后节点
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
assert(pos->next);
SLTNode* del = pos->next;
//pos del del->next
pos->next = del->next;
free(del);
del = NULL;
}

3.12单链表的销毁
从前往后释放节点,最后别忘记把头节点置为NULL。我们要先判断链表必须存在,并且不能为空链表,否则没东西释放(释放NULL报错)。
//销毁链表
void SListDesTroy(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* pcur = *pphead;
while (pcur)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
四、单链表(通讯录)
上一节我们用顺序表实现了通讯录,这次我们也可以用单链表实现。具体每个功能的实现逻辑和顺序表相同,所以这次也不再过多赘述。完整代码大家也可以到我的仓库里自行查看。链接 :单链表(通讯录) · 15be83d · 陈陈陈陈/数据结构 - Gitee.com
五、双向链表

5.1双向链表的初始化
我们想要定义双向链表就是要定义双向链表的节点的结构,定义好结构就可以让它们像手拉手一样连接起来。
typedef int LTDataType;
//定义双向链表的结构
typedef struct ListNode
{
LTDataType data;
struct ListNode* next;
struct ListNode* prev;
}LTNode;
我们需要注意的是单链表在最开始通常是一个空链表(phead = NULL),但双向链表必须有一个哨兵位的存在,因此在插入数据前链表中必须初始化到只有一个头指针的情况。申请节点需要用到malloc函数,记得注意申请失败的情况,而且要定义节点的内容(我们还不知道申请的这个节点的下一个节点和上一个节点是什么,但不能指向NULL,因为一旦指向NULL链表的情况就如下,不符合我们对双链表的定义,所以为了让它保持循环状态,就需要让next和prev指向自己)后面申请的节点初始状态都是一个类似于自循环的状态。

//申请节点
LTNode* LTBuyNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail!");
exit(1);
}
node->data = x;
node->next = node->prev = node;
return node;
}
// 初始化
void LTInit(LTNode** pphead)
{
//创建一个哨兵位
*pphead = LTBuyNode(-1);
}
5.2双向链表的打印
打印要遍历双向链表,由于双向链表是循环的,所以我们以头节点作为跳出循环的条件。
void LTPrint(LTNode* phead)
{
LTNode* pcur = phead->next;
while (pcur != phead)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
5.3双向链表的尾插
我们要注意哨兵位节点不能被删除,而且节点的地址不能发生改变。所以我们在进行尾插或者头插时使用一级指针就可以了。这也是因为一级指针足以直接操作尾节点的内部成员,无需二级指针来修改指针本身的指向。
如果不能理解的话我们可以举个例子帮助理解
简单说:双向链表尾插用一级指针,是因为我们只需要改"节点里的指针",不用改"指向节点的指针本身",一级指针就够了。
你可以把链表想象成一串手拉手的人:每个人(节点)有两只手,一只拉前面的人(next指针),一只拉后面的人(prev指针)。tail指针就像你手指着最后一个人,是"指向人的指针"。尾插新节点,就像在最后一个人后面加个新朋友:
-
新朋友先伸手拉住最后一个人(新节点的prev指向tail)。
-
你通过tail(一级指针),让最后一个人伸手拉住新朋友(tail->next指向新节点)。
-
最后把你手指的方向挪到新朋友身上(更新tail指向新节点)。
全程你只需要"指挥最后一个人伸手"(改节点里的指针),和"挪自己手指"(改tail的值),这两件事用一级指针都能做到,根本不用更复杂的二级指针。
尾插的过程就是先让新指针连上哨兵位(phead)和原链表的最后一个节点(phead->prev),再让原链表的最后一个节点(phead->prev)指向新节点,最后让哨兵位(phead)指向新节点。

//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->prev = phead->prev;
newnode->next = phead;
phead->prev->next = newnode;
phead->prev = newnode;
}
5.4双向链表的头插
头插是往链表里第一个有效的节点前插入,头插的过程先让新指针连上哨兵位(phead)和原链表的第一个有效节点(phead->next),再让原链表的第一个有效节点(phead->next)指向新节点,最后让哨兵位(phead)指向新节点。

//头插
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->prev = phead;
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
}
5.5双向链表的尾删
尾删链表必须有效而且不能为空,尾删的过程要让原链表的最后一个节点的前一个节点(del->prev)指向哨兵位,再让哨兵位指向它。

//尾删
void LTPopBack(LTNode* phead)
{
//链表必须有效且不能为空
assert(phead && phead->next != phead);
LTNode* del = phead->prev;
del->prev->next = phead;
phead->prev = del->prev;
free(del);
del = NULL;
}
5.6双向链表的头删
头删链表必须有效而且不能为空,尾删的过程要让原链表的第一个有节点的后一个节点(del->next)指向哨兵位,再让哨兵位指向它。
//头删
void LTPopFront(LTNode* phead)
{
assert(phead && phead->next != phead);
LTNode* del = phead->next;
phead->next = del->next;
del->next->prev = phead;
free(del);
del = NULL;
}
5.7双向链表的查找
就是一个遍历的过程
LTNode* LTFind(LTNode* phead, LTDataType x)
{
LTNode* pcur = phead->next;
while (pcur != phead)
{
if (pcur->data == x)
{
return pcur;
}
}
return NULL;
}
5.8在pos节点前插入数据
插入过程要先让新节点指向pos节点和pos前一个节点(pos->perv),再让它们指向新节点。

//在pos之后插入数据
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = LTBuyNode(x);
newnode->prev = pos;
newnode->next = pos->next;
pos->next->prev = newnode;
pos->next = newnode;
}
5.9删除pos节点
删除过程就是让pos节点的前后两个节点相连。

//删除pos节点
void LTErase(LTNode* pos)
{
assert(pos);
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
pos = NULL;
}
5.10销毁双向链表
也是一个遍历的过程。
//销毁链表
void LTDesTroy(LTNode* phead)
{
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead)
{
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
free(phead);
phead = NULL;
}
六、接口一致性
其实我们可以注意到在5.9删除pos节点中我们改变了pos的地址,把其置为了NULL,所以理论上应该用二级指针才对。这里还是用的一级指针,一方面是这里pos不影响后续代码,还有一方面是为了保持接口一致性。
保持接口一致性的核心目的是降低使用成本、减少出错概率、提升代码的可维护性,具体体现在这几点:
- 降低使用者的记忆/学习成本
如果有的接口传一级指针、有的传二级指针,使用者需要额外区分"什么时候该传一级、什么时候传二级",增加了理解和使用的复杂度。
比如代码里所有操作都传一级指针,使用者只需要记住"链表接口都传节点的一级指针",不用额外判断,用起来更顺手。
- 避免调用时的指针类型错误
如果接口参数类型不统一,调用时容易误传指针类型(比如该传二级指针却传了一级),导致编译错误或运行时崩溃。
统一用一级指针,能减少这类低级错误,让代码更健壮。
- 提升代码的可维护性
当后续需要修改或扩展接口时,统一的参数类型(一级指针)能减少适配成本。比如要新增一个"在pos之前插入"的接口,直接复用"传一级指针"的模式即可,不用重新设计参数规则。
- 匹配"有哨兵位链表"的特性
代码里的链表是**带哨兵位(固定头节点)**的结构,所有操作都不需要修改phead本身的指向(因为头节点地址固定),天然适合用一级指针。统一传一级指针,也能体现出"哨兵位链表"的设计特点,让接口和数据结构的特性保持一致。
简单说:统一用一级指针,是为了让接口更好用、更少错、更容易维护,同时也匹配了当前链表(带哨兵位)的结构特性。