目录
[1. 顺序表问题与思考](#1. 顺序表问题与思考)
[2. 单链表](#2. 单链表)
[2.1 概念与结构](#2.1 概念与结构)
[2.1.1 结点](#2.1.1 结点)
[2.1.2 链表的打印](#2.1.2 链表的打印)
[3. 实现单链表](#3. 实现单链表)
[3.1 第1步:尾插](#3.1 第1步:尾插)
[3.2 第2步:头插](#3.2 第2步:头插)
[3.3 第3步:尾删](#3.3 第3步:尾删)
[3.4 第4步:头删](#3.4 第4步:头删)
[3.5 第5步:查找](#3.5 第5步:查找)
[3.6 第6步:在指定位置之前插入数据](#3.6 第6步:在指定位置之前插入数据)
[3.7 第7步:在指定位置之后插入数据](#3.7 第7步:在指定位置之后插入数据)
[3.8 第8步:在pos位置删除数据](#3.8 第8步:在pos位置删除数据)
[3.9 第9步:在pos位置之后删除数据](#3.9 第9步:在pos位置之后删除数据)
[3.10 第10步:销毁单链表](#3.10 第10步:销毁单链表)
[3.11 全部代码](#3.11 全部代码)
[4. 单链表算法题](#4. 单链表算法题)
[4.1 移除链表元素](#4.1 移除链表元素)
[4.2 反转链表](#4.2 反转链表)
[4.3 链表的中间结点](#4.3 链表的中间结点)
[4.4 合并两个有序的链表](#4.4 合并两个有序的链表)
[4.5 链表分割](#4.5 链表分割)
[4.6 链表的回文结构](#4.6 链表的回文结构)
[4.7 相交链表](#4.7 相交链表)
[4.8 环形链表](#4.8 环形链表)
1. 顺序表问题与思考
- 中间/头部的插入删除,时间复杂度为O(N)
- 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗
- 增容一般是呈2倍的增长,势必会造成空间的浪费。例如当前容量为100,放满后增容到200,我们再继续插入5个数据,后面没有数据的插入了,那么就会浪费95个数据空间了。
对于这些问题我们该如何解决呢? 就是使用链表。我们想把时间复杂度降为O(1),想让空间刚好够,或者浪费少量空间等等。
2. 单链表
2.1 概念与结构
链表是一种物理储存结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
结合着概念以及第一节的思考,我们可以想到现在我们大部分人出远门的方式------火车。淡季的时候我们就会减少车厢的数量,旺季的时候就会增加车厢的数量,尤其是候补的人数够几个车厢就会加几个车厢,这样分配位置合理,且不会造成浪费。

注:该图片由豆包ai生成。
就如上图,火车有4节车厢,不够了我们还可以添加车厢。那么放在和它类似的链表中又是怎么样的呢?

2.1.1 结点
与顺序表不同的是,我们把上面图中的每一块申请的独立空间叫做结点。结点的组成主要有两个部分:当前结点要保存的数据和保存下一个节点的地址(指针变量)。
由上图不难看出,每个结点的空间都是独立的也就是在堆中:

其中箭头是肯定没有的。
所以我们就可以知道链表的性质:
- 在逻辑结构上是连续的,在物理结构上不一定是连续的。
- 结点一般是在堆上申请的。
- 从堆上申请的空间可能是连续的,也可能是不连续的。
2.1.2 链表的打印
我们有了上面知识的铺垫,就可以创建一个链表,然后将它打印出来。

// 打印链表
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur != NULL)
{
printf("%d -> ", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
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;
node2->data = 2;
node3->data = 3;
node4->data = 4;
node1->next = node2;
node2->next = node3;
node3->next = node4;
node4->next = NULL;
// 打印链表
SLTNode* plist = node1;
SLTPrint(plist);
}
这里为什么会循环打印出来呢?我们把下一个结点的地址给了pcur,然后他就会继续识别pcur,直到pcur指向空指针也就是链表的末尾。
3. 实现单链表
我们了解了如何使用,现在我们来实现单链表。
3.1 第1步:尾插

从逻辑上我们写出了尾插的代码,但是会不会有问题呢?这段代码看起来是没有问题的。我们要养成良好的习惯,在写完一段代码后进行测试,我们来测试一下:

这和我们预期的结果不一样,为什么会这样呢?在一些小伙伴看来,plist也是地址,应该是没问题的,这里说明一下原因:因为plist中存储的是一个地址,但是在形参中是不会被改变的,所以在本质上来讲是传值调用,只不过是这个值是一个指针,所以我们这里就得使用二级指针。现在我们来看一下修改后的代码:
在进行测试的时候发现,在申请结点后发现并未让newnode初始化,我们这里给加上。
// SList.h
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
// 创建一个链表结构
typedef int SLTDataType;
typedef struct SListNode
{
int data;
struct SListNode* next; // 指向下一个节点的指针
}SLTNode;
// 打印链表
void SLTPrint(SLTNode* phead);
// 尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x);
// SList.c
#include "SList.h"
// 打印链表
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur != NULL)
{
printf("%d -> ", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
// 申请新结点
SLTNode* SLTBuyNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (malloc == NULL)
{
perror("malloc erro");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
// 尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
// 在插入结点之前,我们需要申请一个新的结点
// 申请新结点
SLTNode* newnode = SLTBuyNode(x);
// phead 为空的时候
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* ptail = *pphead;
while (ptail->next != NULL)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
}
// test.c
void test02()
{
// 创建空链表
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPushBack(&plist, 5);
// 打印链表
SLTPrint(plist);
}

现在程序可以正常运行,也就是尾插代码我们已经写完。
3.2 第2步:头插

从画图中,我们知道了,让newnode的next指针指向第一个结点的地址,然后在让newnode成为第一个结点。现在我们来实现:


头插非常简单,这已经写完了,其中由于担心pphead为空指针,我们来断言一下,保险。
3.3 第3步:尾删
我们仍然通过画图明确逻辑,然后实现代码。

大体的思路我们是有了,但是还是会有缺陷,我们先来实现这段逻辑。


我们前几次的删除都是没有问题的,但是问题就是这最后一次,只有一个结点的时候,prev和ptail指向同一个结点,第一次循环就会跳出循环,所以应该做特殊处理。


这时候尾删我们已经实现。
3.4 第4步:头删



3.5 第5步:查找
// 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
while (phead->next)
{
SLTNode* pcur = phead;
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
这段代码的实现非常简单,遍历链表即可。
3.6 第6步:在指定位置之前插入数据
画图:

有了这个逻辑我们来实现一下:

这里是直接把特殊情况包含了,也就是pos属于头结点。
3.7 第7步:在指定位置之后插入数据


这里逻辑清晰后代码是很好写的。所以我们直接写代码,然后做测试。
3.8 第8步:在pos位置删除数据

我们来写代码:

3.9 第9步:在pos位置之后删除数据


其中,我们删除数据后也不能使pos->next指向空,所以我们需要断言。
3.10 第10步:销毁单链表
怎么销毁,一个一个销毁。

3.11 全部代码
#pragma once
// SList.h
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
// 创建一个链表结构
typedef int SLTDataType;
typedef struct SListNode
{
int data;
struct SListNode* next; // 指向下一个节点的指针
}SLTNode;
// 打印链表
void SLTPrint(SLTNode* phead);
// 尾插
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 SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
// 在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
// 在pos位置删除数据
void SLTErase(SLTNode** pphead, SLTNode* pos);
// 删除pos位置之后的数据
void SLTErasrAfter(SLTNode** pphead, SLTNode* pos);
// 销毁链表
void SLTDestroy(SLTNode** pphead);
#define _CRT_SECURE_NO_WARNINGS 1
// SList.c
#include "SList.h"
// 打印链表
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur != NULL)
{
printf("%d -> ", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
// 申请新结点
SLTNode* SLTBuyNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (malloc == NULL)
{
perror("malloc erro");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
// 尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
// 在插入结点之前,我们需要申请一个新的结点
// 申请新结点
SLTNode* newnode = SLTBuyNode(x);
assert(pphead);
// phead 为空的时候
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* ptail = *pphead;
while (ptail->next != NULL)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
}
// 头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
// 申请新结点
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
// 尾删
void SLTPopBack(SLTNode** pphead)
{
// 首先我们来断言一下,防止传过来的是空链表
assert(pphead && *pphead );
// 只有一个结点的情况
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* prev = NULL;
SLTNode* ptail = *pphead;
while (ptail->next)
{
prev = ptail;
ptail = ptail->next;
}
prev->next = NULL;
free(ptail);
// 养成一个好习惯,释放完空间,置为空
ptail = NULL;
}
}
// 头删
void SLTPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
// 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* pcur = phead;
while (pcur)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
// 在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead && pos);
// 申请一个新结点
SLTNode* newnode = SLTBuyNode(x);
if (pos == *pphead)
{
// 头插
SLTPushFront(pphead, x);
}
// 寻找pos位置的前一个结点
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
// 找到后
prev->next = newnode;
newnode->next = pos;
}
// 在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
// 申请新结点
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
// 在pos位置删除数据
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && pos);
if (pos == *pphead)
{
//头删
SLTPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
// 找到pos位置前的结点
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
// 删除pos位置之后的数据
void SLTErasrAfter(SLTNode* pos)
{
assert(pos && pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
// 销毁链表
void SLTDestroy(SLTNode** pphead)
{
assert(pphead);
SLTNode* pcur = *pphead;
while (pcur != NULL)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
#define _CRT_SECURE_NO_WARNINGS 1
// test.c
#include "SList.h"
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;
node2->data = 2;
node3->data = 3;
node4->data = 4;
node1->next = node2;
node2->next = node3;
node3->next = node4;
node4->next = NULL;
// 打印链表
SLTNode* plist = node1;
SLTPrint(plist);
}
void test02()
{
//// 创建空链表
// 尾插
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
// 打印链表
SLTPrint(plist);
//// 头插
//SLTNode* plist = NULL;
//SLTPushFront(&plist, 1);
//SLTPrint(plist);
//SLTPushFront(&plist, 2);
//SLTPrint(plist);
//SLTPushFront(&plist, 3);
//SLTPrint(plist);
//SLTPushFront(&plist, 4);
//SLTPrint(plist);
//// 尾删
//SLTPopBack(&plist);
//SLTPrint(plist);
//SLTPopBack(&plist);
//SLTPrint(plist);
//SLTPopBack(&plist);
//SLTPrint(plist);
//SLTPopBack(&plist);
//SLTPrint(plist);
//// 头删
//SLTPopFront(&plist);
//SLTPrint(plist);
//SLTPopFront(&plist);
//SLTPrint(plist);
//SLTPopFront(&plist);
//SLTPrint(plist);
//SLTPopFront(&plist);
//SLTPrint(plist);
// 查找
SLTNode* pos = SLTFind(plist, 1);
/*if (pos)
{
printf("zhaodaole");
}
else
{
printf("weizhaodao");
}*/
//// 在指定位置之前插入数据
/*SLTInsert(&plist, pos, 100);
SLTPrint(plist);
SLTInsert(&plist, pos, 100);
SLTPrint(plist);
SLTInsert(&plist, pos, 100);
SLTPrint(plist);*/
/*SLTInsertAfter(pos, 100);
SLTPrint(plist);*/
SLTErase(&plist, pos);
SLTPrint(plist);
}
int main()
{
// Test01();
test02();
return 0;
}
最后链表的销毁没有做测试。
4. 单链表算法题
4.1 移除链表元素
点标题就可以进入题目网址:

我们读完题,并给出了自己的思路,现在我们在服务端完成算法代码:

我们在题库中写完代码并测试且通过了。有了思路写代码就是非常快的。所以我们在以后遇到算法题的时候,一定要先整理思路,然后开始写代码。这样准确率和速度的提升都是非常巨大的。
4.2 反转链表
同上我们进行读题然后寻找思路:

我们来实现思路2的代码:

这么一看是不是特别简单。这道题就到这里了。
4.3 链表的中间结点
我们直接画图。

代码我们实现思路2。

很显然是通过的。但是我们会有一个疑问,如果把while的循环条件位置互换会发生什么事?
报错,原因是空指针的解引用。
4.4 合并两个有序的链表

这里有点着急,没有移动newTail,大家应该可以理解。
我们有了思路后,就可以实现代码了。

我们按照思路写完了代码,但是是不通过的。当然我们在写代码的过程肯定不是一帆风顺的,有错我们应该兴奋起来,这样可以完善我们的短板,如果每次写的代码都是一遍过我们会觉得非常没意思,有错我们将它修改正确这才是满满的成就感。
我们现在来看一下报的错误:

第57行发生空指针的解引用。
也就是俩者都为空的时候,会发生解引用错误,我们应该特殊处理一下,在开头我们判断list1和list2是否为空。
在开头增加了list1和list2的判别自测示例就可以通过。


同时终端也是通过的。

所以这段代码就算是写完了。但是我们会感觉有点冗余,我们现在来优化。

对比可以看出,代码量缩短了,其中使用到了哨兵位,就是不储存数据,只是占一个位置。
这道题的介绍就到这里结束了。
4.5 链表分割
还是老样子我们先画图找思路

有了思路直接写代码。

我们也是成功提交了,主要要注意大链表的尾要指向空。
4.6 链表的回文结构


这样写代码就简单多了。当然,如果题目中没有最后一句话,我们就老老实实的使用第一种思路。
4.7 相交链表


这样我们就实现了判断两链表的相交节点。
4.8 环形链表

我们在平台上实现思路。

代码就是这么简单,我们现在来看看while循环的条件。

通过我们判别不是环链表,我们就可以得出while循环条件。
接下来我们来证明为什么快慢指针在换链表中一定会相遇,同时证明fast走三步可不可以。

其实我们陷入了思维误区,我们把这个问题抽象为物理问题,就可以看出,无论速度快慢 (走多少步),它俩在环中必然会相遇只是时间问题。
链表题还有很多很多,我们这里就不在进行举例,我们该有的思维,在这些例子中都有涉及。