目录
[2.1 单向链表](#2.1 单向链表)
[2.1.1 定义结构体成员以及方法](#2.1.1 定义结构体成员以及方法)
[2.1.2 内存申请](#2.1.2 内存申请)
[2.1.3 插入元素](#2.1.3 插入元素)
[2.1.4 删除元素](#2.1.4 删除元素)
[2.1.6 打印元素](#2.1.6 打印元素)
[2.1.7 销毁链表](#2.1.7 销毁链表)
[2.1.8 测试](#2.1.8 测试)
[2.2 双向链表](#2.2 双向链表)
[2.2.1 定义结构体成员以及方法](#2.2.1 定义结构体成员以及方法)
[2.2.2 内存申请](#2.2.2 内存申请)
[2.2.3 初始化结构体成员变量](#2.2.3 初始化结构体成员变量)
[2.2.4 插入元素](#2.2.4 插入元素)
[2.2.5 删除元素](#2.2.5 删除元素)
[2.2.7 打印元素](#2.2.7 打印元素)
[2.2.8 销毁链表](#2.2.8 销毁链表)
[2.2.9 测试](#2.2.9 测试)
一、什么是链表
链表是一种物理存储单元上非连续、非顺序的存储结构,其数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点组成,每个结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
链表有以下优缺点:
- 优点:动态内存分配,可以根据需要随时增删结点,不需要预先知道数据的大小。
- 缺点:随机访问效率低,需要从头开始遍历。
二、常见的链表类型
链表的类型有很多,一共8种,分为单向和双向,带头和不带头、循环和不循环。
单向和双向:
带头和不带头:
循环和不循环:
别看有这么多,但其实我们只需要掌握其中两个,剩下的便都可以掌握,这两个分别是:单向不带头不循环链表(后面简称单向链表 )和双向带头循环链表 (后面简称双向链表)。
2.1 单向链表
**概念:**每个节点包含一个数据域和一个指针域(也就是存放数据的地址)。指针域指向下一个节点,直到最后一个节点指向空指针(Null)
就像图中这样:
接下来,我来转换成代码的形式进行讲解。
2.1.1 定义结构体成员以及方法
cpp
//这里用的是C语言哦
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;//代表数据值,也就是数据域
struct SListNode* next;//代表存放下一个数据的地址,也就是指针域
}SLTNode;
//打印链表
void SLTPrint(SLTNode* phead);
//内存申请
SLTNode* BuySLTNode(SLTDataType x);
//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x);
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);
//尾删
void SLTPopBack(SLTNode** pphead);
//头删
void SLTPopFront(SLTNode** pphead);
//查找元素
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
//在某个元素之前插入
void SLTInsertFront(SLTNode** pphead, SLTNode* pos, int x);
//在某个元素之后插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
//删除指定元素
void SLTErase(SLTNode** pphead, SLTNode* pos);
做完这些基础工作之后,那我们就开始实现这些方法吧。
2.1.2 内存申请
当我们需要将元素插入链表中来,我们需要给这个元素申请一块内存空间才可以进行插入操作。
cpp
SLTNode* BuySLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//确定创建内存的大小
if (newnode == NULL)//如果创建失败,就返回NULL
{
perror("malloc");
return NULL;
}
//成功的话,就进行赋值
newnode->data = x;
//因为它是个新节点,不知道要存放哪个数值的指针域地址,所以就将它的指针域设置为NULL
newnode->next = NULL;
//然后返回这个节点
return newnode;
}
2.1.3 插入元素
插入元素分为:头插、尾插、在某个元素前插入和某个元素之后插入
我们来一一讲解:
- 头插
cpp
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
//确保传入的指针地址有效,防止传来的值为NULL
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
//将新插入节点的指针域指向原来的头节点
newnode->next = *pphead;
//再将头节点的值改为现在新插入的节点
//即可完成头插
*pphead = newnode;
}
注意,这里使用二级指针作为参数是因为:在 main 函数中,头指针 plist 存储的是第一个节点的地址。头插时,新节点要成为新的头节点,所以必须把 plist 的值改为新节点的地址。C语言是值传递,如果只传一级指针 SLTNode*,函数内修改的是形参的副本,外部的 plist 不会改变。传二级指针 SLTNode** 才能通过地址直接修改外部的 plist。但当你使用一级指针 作为返回值时,就可以将二级指针参数改为一级指针参数。
- 尾插
cpp
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
//确保传入的指针地址有效,防止传来的值为NULL
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
//如果头节点为NULL的话,那么第一个插入的节点就作为头节点,之后在这个头节点的后面插入即可
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
//从链表的头节点开始遍历,走到链表的末尾停
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
//走到链表的末尾之后,将当前最后一个节点的指针域指向新插入的节点,
//这样新插入的节点,就变成末尾了。
tail->next = newnode;
}
}
- 在某个元素前插入
cpp
void SLTInsertFront(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
//pphead--代表一整个链表
//pos--代表链表上的某个节点,即插入的位置
//x--代表要插入的值
//确保要插入的位置节点 pos 是有效的,不为空。
assert(pos);
//确保传入的指针地址有效,防止传来的值为NULL
assert(pphead);
//如果给的位置是头节点那里,就直接进行头插
if (pos == *pphead)
{
SLTPushFront(pphead,x);
}
else
{
//不是的话,就进行遍历,遍历到要插入位置的前一个节点
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
SLTNode* newnode = BuySLTNode(x);
//将新节点的指针域指向插入位置的节点
//将插入位置前面的节点的指针域指向新节点
//这样便完成了插入操作
//这一步建议大家画图理解,跟头插、尾插的道理相同
newnode->next = pos;
prev->next = newnode;
}
}
- 某个元素之后插入
cpp
//使用这个函数需要搭配 SLTFind 这个函数
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
2.1.4 删除元素
删除元素分为三种:头删、尾删和指定位置删除
我们来一一讲解:
- 头删
cpp
void SLTPopFront(SLTNode** pphead)
{
//确保传入的二级指针本身不为 NULL
assert(pphead);
//这一步是为确保链表不为空
assert(*pphead);
//将头节点转换到当前头节点的下一个节点
SLTNode* head = *pphead;
*pphead = head->next;
//这两步是为了释放之前头节点的内存,防止变为野指针
free(head);
head->next = NULL;
}
我来讲解下 assert(*pphead) 这步操作:如果链表已经是空的(*pphead == NULL),头删操作就没有节点可以删除。如果没有这个断言,后续 SLTNode* head = *pphead; 会让 head 成为 NULL,然后 *pphead = head->next; 会试图访问 NULL->next,导致程序崩溃。这个断言拦截的是"在空链表上执行删除"这种逻辑错误。
- 尾删
cpp
void SLTPopBack(SLTNode** pphead)
{
//确保传入的二级指针本身不为 NULL
assert(pphead);
//这一步是为确保链表不为空
assert(*pphead);
//这一步跟尾插一样,只不过一个是插入,一个是删除,大家类比记忆就好了
SLTNode* tail = *pphead;
//为什么使用 (*pphead)->next
//是因为用来判断它是否只有一个节点
if((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
//为什么使用tail->next->next
//是因为要记录该链表的倒数第二个节点。
//因为你要删除的元素是倒数第一个节点。
//所以你需要知道倒数第二个元素节点,将倒数第二个节点的指针域指向 NULL
//即可完成删除操作
//建议画图理解
while (tail->next->next)
{
tail = tail->next;
}
//释放节点内存,防止变为野指针
free(tail->next);
tail->next = NULL;
}
}
- 指定位置删除
代码如下:
cpp
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
//确保要插入的位置节点 pos 是有效的,不为空。
assert(pos);
//确保传入的指针地址有效,防止传来的值为NULL
assert(pphead);
if (*pphead == pos)
{
SLTPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
//找到该位置的前一个节点
while (prev->next != pos)
{
prev = prev->next;
}
//进行删除操作
prev->next = pos->next;
//释放节点内存,防止变为野指针
free(pos);
//这里不写 pos = NULL 的理由是:pos 是一个形参(一级指针),它接收的是外部传入的节点地址的拷贝。
//在函数内部将 pos = NULL,只是把形参这个局部变量改为 NULL,完全不影响外部调用者传入的那个指针变量。
}
}
2.1.5查找元素
大家在理解上面的插入的基础上,再来看这些东西就会非常简单。
查找元素非常简单,就是从头节点开始,一直遍历,遍历到 NULL 之后,就可以了。
代码如下:
cpp
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
2.1.6 打印元素
打印元素跟查找元素的逻辑一样,只不过是将遍历的值打印出来而已。
代码如下:
cpp
void SLTPrint(SLTNode* phead)
{
SLTNode* head = phead;
while (head)
{
printf("%d->", head->data);
head = head->next;
}
printf("NULL\n");
}
2.1.7 销毁链表
如果这个链表之后不需要用了,就可以调用该函数。
代码如下:
cpp
void SLTDestroy(SLTNode** pphead)
{
//确保要插入的位置节点 pos 是有效的,不为空。
assert(pphead);
//链表为空就不需要销毁了
assert(*pphead);
SLTNode* cur = *pphead;
while (cur != NULL)
{
//先保存下一个节点的地址
SLTNode* next = cur->next;
//释放当前节点
free(cur);
//移动到下一个节点
cur = next;
}
//最后把头指针置空
*pphead = NULL;
}
2.1.8 测试
写完上面的功能之后,我们来看看测试结果。
-
测试代码:
cpp#include"SList.h" void test1() { SLTNode* plist = NULL; SLTPushBack(&plist, 1); SLTPushBack(&plist, 2); SLTPushBack(&plist, 3); SLTPushBack(&plist, 4); SLTPrint(plist); } void test2() { SLTNode* plist = NULL; SLTPushFront(&plist, 1); SLTPushFront(&plist, 2); SLTPushFront(&plist, 3); SLTPushFront(&plist, 4); SLTPrint(plist); SLTPopBack(&plist); SLTPrint(plist); SLTPopFront(&plist); SLTPrint(plist); } void test3() { SLTNode* plist = NULL; SLTPushBack(&plist, 1); SLTPushBack(&plist, 2); SLTPushBack(&plist, 3); SLTPushBack(&plist, 4); SLTPrint(plist); // 值为2那个节点 *2 SLTNode* ret = SLTFind(plist, 2); ret->data *= 2; SLTPrint(plist); } void test4() { SLTNode* plist = NULL; SLTPushBack(&plist, 1); SLTPushBack(&plist, 2); SLTPushBack(&plist, 3); SLTPushBack(&plist, 4); SLTPrint(plist); SLTNode* ret = SLTFind(plist, 2); SLTInsertFront(&plist, ret, 66); SLTInsertAfter(ret, 666); SLTPrint(plist); SLTErase(&plist, ret); SLTPrint(plist); } int main() { test1(); printf("=====================================================================================\n"); test2(); printf("=====================================================================================\n"); test3(); printf("=====================================================================================\n"); test4(); printf("=====================================================================================\n"); return 0; } -
测试结果:

2.2 双向链表
双向链表跟单链表一样,同样有数据域和指针域。不同之处在于,指针域有左右(或上一个、下一个)之分,用来连接上一个结点、当前结点、下一个结点。
就像图中这样:
这种带哨兵位的头节点有以下好处:
统一空链表和非空链表的操作:无论链表是否为空,都有一个固定的头节点存在,不需要单独判断链表是否为空。
简化插入和删除操作:在头部插入或删除节点时,不需要特殊处理头指针的更新,因为头节点固定不变,操作的总是头节点之后的节点。
避免空指针异常:遍历或操作时,不需要额外判断头指针是否为NULL,因为哨兵位头节点始终存在。
代码更简洁:插入和删除操作的逻辑统一,不需要针对首节点写特殊分支,减少了代码重复和出错概率。
接下来,我来转换成代码的形式便于大家理解。
2.2.1 定义结构体成员以及方法
cpp
#pragma once
#include<stdlib.h>
#include<assert.h>
#include<stdio.h>
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
//记录当前节点的前一个节点的地址
struct ListNode* prev;
//记录当前节点的后一个节点的地址
struct ListNode* next;
}LTNode;
//初始化
LTNode* LTInit();
//内存申请
LTNode* BuyListNode(LTDataType x);
//查找元素
LTNode* LTFind(LTNode* phead, LTDataType x);
//打印元素
void LTPrint(LTNode* phead);
//插入元素
void LTInsert(LTNode* pos, LTDataType x);
//删除元素
void LTErase(LTNode* pos);
//销毁链表
void LTDestory(LTNode* phead);
2.2.2 内存申请
cpp
//这里的内存申请跟单向链表一样,只不过是多了个 prev,这里不再做过多解释
LTNode* BuyListNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc");
return NULL;
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
2.2.3 初始化结构体成员变量
那这里大家就会有疑问,为啥单向链表不需要初始化,而双向链表需要初始化了?
理由如下:
原因:双向带头循环链表有"哨兵头节点",必须初始化才能用
单向不带头不循环链表:用 NULL 表示空链表,不需要额外初始化。
双向带头循环链表:必须有一个始终存在的头节点,且其 prev 和 next 必须指向自己。不初始化就是野指针,操作会崩溃。
本质差异:前者"空"就是没有节点;后者"空"也必须有一个有效的头节点作为操作锚点。
按照上面的解释,我们便可以这样操作:
cpp
LTNode* LTInit()
{
//这里只是为了符合内存申请函数的条件才传的值
//并无任何意义
//这样就定义完哨兵位的节点了
LTNode* phead = BuyListNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
2.2.4 插入元素
cpp
//pos--代表链表上的某个节点,即插入的位置
void LTInsert(LTNode* pos, LTDataType x)
{
//判断你给的这个节点是否为 NULL
assert(pos);
LTNode* prev = pos->prev;
LTNode* newnode = BuyListNode(x);
//跟单向链表的插入原理相同,不再做过多解释
//修改节点内部的成员:
//传入的是节点地址(指针的值),通过这个地址直接访问堆上的节点对象。
//修改的是对象本身的成员,不是指针变量,所以外部能看到变化。
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
这里大家看完代码之后,可能会问,为啥你的单向链表的插入操作传的参数是二级指针,但到了双向链表的插入操作之后传的参数是一级指针?
理由如下:
单向链表:头节点本身可能被修改,一级指针只能改节点内容,改头地址需要二级指针。
双向链表:插入仅修改节点前后的指针,头节点地址不变,只需一级指针操作节点。
传入一级指针
LTNode* pos:函数修改的是pos->prev和pos->next,这两个是节点内部的成员 ,通过指针访问的是原节点本身,不是形参副本。双向链表的外部头指针
plist从来没被修改过,所以不需要二级指针。类比:传地址是为了修改"地址指向的对象",而不仅仅是修改"地址本身"。双向链表插入只修改对象内部的数据,不修改对象指针本身。
如果实在记不住,你就只需要知道,头节点可能会被修改的用二级指针,头节点不管用什么操作都不会受到影响的,就用一级指针。也可以说,如果你想修改结构体里面内部的成员,就可以用一级指针,但如果你想修改整个结构体指针的话,就需要使用二级指针。
2.2.5 删除元素
cpp
//pos--代表链表上的某个节点
void LTErase(LTNode* pos)
{
//判断你给的这个节点是否为 NULL
assert(pos);
//修改节点内部的成员:
//传入的是节点地址(指针的值),通过这个地址直接访问堆上的节点对象。
//修改的是对象本身的成员,不是指针变量,所以外部能看到变化。
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
//这里不把pos置为 NULL 的原因是,形参的改变,不会影响到实参
//到时候在外面把它置为 NULL 就好了
}
2.2.6查找元素
cpp
//pos--代表链表上的某个节点
LTNode* LTFind(LTNode* phead, LTDataType x)
{
//判断你给的这个节点是否为 NULL
assert(phead);
//为什么取phead->next,因为 phead 代表的是哨兵位
//哨兵位的节点不需要打印。
//查找逻辑跟单向链表一样的
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
2.2.7 打印元素
cpp
void LTPrint(LTNode* phead)
{
//判断你给的这个节点是否为 NULL
assert(phead);
//因为不打印哨兵位的头节点,所以在这里直接打印一个 <=head=> 来表示哨兵位
//打印逻辑跟单向链表一样的
printf("<=head=>");
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("\n");
}
2.2.8 销毁链表
cpp
void LTDestory(LTNode* phead)
{
//销毁逻辑跟单向链表一样的
//判断你给的这个节点是否为 NULL
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
//这里不把pos置为 NULL 的原因是,形参的改变,不会影响到实参
//到时候在外面把它置为 NULL 就好了
}
2.2.9 测试
- 测试代码:
cpp
#include"list.h"
void test()
{
LTNode* plist = LTInit();
//自己动手画图理解,理解更深刻
//这种代表尾插
LTInsert(plist, 1);
LTInsert(plist, 2);
LTInsert(plist, 3);
LTInsert(plist, 4);
LTPrint(plist);
printf("==============================================\n");
//这种代表头插
LTInsert(plist->next, 5);
LTInsert(plist->next, 6);
LTInsert(plist->next, 7);
LTInsert(plist->next, 8);
LTPrint(plist);
printf("==============================================\n");
LTNode* ret = LTFind(plist,3);
LTErase(ret);
//这里就是我说的,在外面把它置为 NULL
ret = NULL;
LTPrint(plist);
}
int main()
{
test();
return 0;
}
这里我画张图,方便你理解一下:
假设原来这个链表里面目前只有头节点和1这个节点,当我传的值为 plist 时候,那么此时会变成尾插,为什么是尾插,请看图(2是代表新插入的节点):
理解了这个之后,那你也可以自己动手画一画,看看为啥当我传的值为 plist->next 的时候,会变成头插。
- 测试结果:

三、总结
以上便是我对链表部分的全部理解了。有啥不好的地方也欢迎大家积极指出,希望大家早日被自己喜欢的offer录取。那我们就下一个博客见。


