单链表详解

目录

[1 · 链表的概念与结构](#1 · 链表的概念与结构)

[2 · 链表的分类](#2 · 链表的分类)

[3 · 单链表的实现](#3 · 单链表的实现)

[3 - 1 · 接口总览与结构定义](#3 - 1 · 接口总览与结构定义)

[3 - 2 · 申请结点,销毁,打印](#3 - 2 · 申请结点,销毁,打印)

[3 - 2 - 1 · 申请一个结点](#3 - 2 - 1 · 申请一个结点)

[3 - 2 - 2 · 销毁](#3 - 2 - 2 · 销毁)

[3 - 2 - 3 · 打印](#3 - 2 - 3 · 打印)

[3 - 2 - 4 · 测试](#3 - 2 - 4 · 测试)

[3 - 3 · 头插,头删](#3 - 3 · 头插,头删)

[3 - 3 - 1 · 头插](#3 - 3 - 1 · 头插)

[3 - 3 - 2 · 头删](#3 - 3 - 2 · 头删)

[3 - 3 - 3 · 测试](#3 - 3 - 3 · 测试)

[3 - 4 · 尾插,尾删](#3 - 4 · 尾插,尾删)

[3 - 4 - 1 · 尾插](#3 - 4 - 1 · 尾插)

[3 - 4 - 2 · 尾删](#3 - 4 - 2 · 尾删)

[3 - 4 - 3 · 测试](#3 - 4 - 3 · 测试)

[3 - 5 · 查找,指定位置之后插入,指定位置之后删除](#3 - 5 · 查找,指定位置之后插入,指定位置之后删除)

[3 - 5 - 1 · 查找](#3 - 5 - 1 · 查找)

[3 - 5 - 2 · 指定位置之后插入](#3 - 5 - 2 · 指定位置之后插入)

[3 - 5 - 3 · 指定位置之后删除](#3 - 5 - 3 · 指定位置之后删除)

[3 - 5 - 4 · 测试](#3 - 5 - 4 · 测试)

[4 · 单链表缺陷](#4 · 单链表缺陷)

总结


1 · 链表的概念与结构

链表也是一种线性表,因此链表在逻辑上是线性结构的。
概念:链表是一种物理存储结构上非连续 、非顺序的存储结构,数据元素的逻辑顺序 是通过链表中的指针链接次序实现的 。

在结构上,链表一般是采用链式结构的,如下图:

其中 Data 是存放的数据, Next 是指向下一个结点的指针。

从形态上来看,链式结构有点类似于火车的一节节车厢。

注意:

  1. 链式结构在逻辑上是连续的,但是在物理结构上不一定连续

  2. 结点一般是从堆上申请出来的

  3. 连续两次从堆上申请空间,这两次申请到的空间可能连续,也可能不连续。


2 · 链表的分类

实际中,链表的结构非常多样。

一个链表有三种选择:

1. 单向或双向

2. 带头或不带头

3. 循环或不循环

每种选择可以随心搭配,这么算下来,链表一共就有8种结构了。

虽然有这么多结构,但最常用的是下面这两种:

  1. 单向 不带头 不循环 链表,也称为 单链表

  2. 双向 带头 循环 链表, 也称为 双向链表

当然,掌握了这两种,掌握剩下六种也不是问题。

单链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结
构,如哈希桶、图的邻接表等等。
双向链表: 结构最复杂 ,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向 循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而 简单了。
本篇介绍的是单链表。


3 · 单链表的实现

3 - 1 · 接口总览与结构定义

如下:

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int SLDataType;

typedef struct SListNode
{
	SLDataType data;
	struct SListNode* next;//指向下一个结点
}SListNode;

//申请一个结点
SListNode* BuyNode(SLDataType x);
//打印
void SListPrint(SListNode* plist);
//头插
void SListPushFront(SListNode** pplist, SLDataType x);
//头删
void SListPopFront(SListNode** pplist);
//尾插
void SListPushBack(SListNode** pplist, SLDataType x);
//尾删
void SListPopBack(SListNode** pplist);
//查找
SListNode* SListFind(SListNode* plist, SLDataType x);
// 对指定位置的后一个位置插入
void SListInsertAfter(SListNode* pos, SLDataType x);
// 对指定位置的后一个位置删除
void SListEraseAfter(SListNode* pos);
//销毁
void SListDestroy(SListNode** ppl);

这里用到了 typedef ,最上面的是方便进行存储类型的修改,在代码实现中用 SLDataType,到时候如果想要修改存储的类型,只需要改这里一处即可。

下面在结构体 这里的 typedef 是方便后续使用,可以少写 struct。

这里结构体当中的 struct SListNode* next 中的struct 是不能省略的,因为typedef 是在结构体定义完成之后再进行重命名的,如果结构体中的这个 struct 省略的话,会导致编译器不认识这个成员的类型。


3 - 2 · 申请结点,销毁,打印

3 - 2 - 1 · 申请一个结点

代码如下:

复制代码
SListNode* BuyNode(SLDataType x)
{
	SListNode* newNode = (SListNode*)malloc(sizeof(SListNode));
	if (newNode == NULL)
	{
		perror("malloc");
		exit(1);
	}
	newNode->data = x;
	newNode->next = NULL;
	return newNode;
}

链表没有最大容量的概念,当需要的时候就申请一个结点。那么就需要使用到有关内存开辟的函数,因此我们拿到的是一个指向这个结点的指针。


3 - 2 - 2 · 销毁

代码如下:

复制代码
void SListDestroy(SListNode** pplist)
{
	assert(pplist);
	//空表不用删
	if (*pplist)
	{
		return;
	}

	SListNode* pcur = *pplist;
	while (pcur != NULL)
	{
		SListNode* pnext = pcur->next;
		free(pcur);
		pcur = pnext;
	}

	*pplist = NULL;
}

我们到时候对链表进行访问的时候,会定义一个指向第一个结点的一级指针变量,相当于火车头,然后由火车头开始一节一节访问车厢。

而如若需要对这个一级指针变量进行修改,那么传参就需要传地址,形参就需要用二级指针来接收。

这里的 pcur 均可换成 *pplist ,不过需要注意的是 ->(结构体成员访问操作符) 的优先级高于 *(解引用操作符),所以如果要换 pcur->next; 应换成 (*pplist)->next;


3 - 2 - 3 · 打印

代码如下:

复制代码
void SListPrint(SListNode* plist)
{
	SListNode* pcur = plist;
	while (pcur)
	{
		printf("%d -> ", pcur->data);
		pcur = pcur->next;
	}
	printf("NULL");
	printf("\n");
}

简单来说,就是一边一个一个结点遍历,一边打印,直到走到表尾,即结点的指针域为空指针。

为了更加直观,所以在打印的时候加上了箭头(->)


3 - 2 - 4 · 测试

测试一下上面的功能:

复制代码
#include "SingleList.h"

void Test1()
{
	SListNode* p = NULL;
	SListNode* node1 = BuyNode(1);
	SListNode* node2 = BuyNode(2);
	SListNode* node3 = BuyNode(3);

	p = node1;
	node1->next = node2;
	node2->next = node3;

	SListPrint(p);
	SListDestroy(&p);
}

int main()
{
	Test1();
	//Test2();
	//Test3();
	//Test4();

	return 0;
}

这里我们手动将结点连接起来了,直接运行试试:

下面我们用调试看看销毁:

销毁前:

销毁后:


3 - 3 · 头插,头删

3 - 3 - 1 · 头插

头插就是在表头进行插入,代码如下:

复制代码
void SListPushFront(SListNode** pplist, SLDataType x)
{
	assert(pplist);

	SListNode* pcur = *pplist;
	SListNode* newNode = BuyNode(x);
	newNode->next = pcur;
	*pplist = newNode;
}

我们画个图方便理解:

其中,红色是我们需要进行的操作。

p是指向单链表第一个元素的指针,方便我们进行访问,因此我们是需要修改这个一级指针的,那么传参时传的是一级指针的地址,形参就需要用二级指针接收。后文的形参使用二级指针均是由于p可能需要修改,后文将不再赘述。

我们也能看出,我们的操作需要按照一定的顺序来进行,需要先进行对 newNode 的指针域的修改,再改变p的指向,否则将找不到p原本指向的结点。


3 - 3 - 2 · 头删

头删就是删除表头的结点,代码如下:

复制代码
void SListPopFront(SListNode** pplist)
{
	//空表不能删
	assert(pplist && *pplist);

	SListNode* pcur = *pplist;
	SListNode* newNext = pcur->next;
	free(pcur);
	*pplist = newNext;
}

我们画个图方便理解:

其中,红色是我们需要进行的操作。

空表是不能进行删除的,空表的判定是 指向第一个结点的指针为空,即p == NULL 。

我们的结点是动态开辟出来的,因此删除时需要使用 free函数。

需要注意的是,当删除一个结点后,结点的指针域也就没了,此时就找不到该结点指向的下一个结点了。因此在删除前,需要记录下将删除结点的指针域。


3 - 3 - 3 · 测试

我们对上面的功能测试一下:

复制代码
void Test2()
{
	SListNode* p = NULL;

	SListPushFront(&p, 1);
	SListPrint(p);

	SListPushFront(&p, 2);
	SListPrint(p);

	SListPopFront(&p);
	SListPrint(p);

	SListPopFront(&p);
	SListPrint(p);

	//SListPopFront(&p);
	//SListPrint(p);

	SListDestroy(&p);
}

int main()
{
	//Test1();
	Test2();
	//Test3();
	//Test4();

	return 0;
}

运行一下:

此时再进行一次头删,便会触发assert断言:


3 - 4 · 尾插,尾删

3 - 4 - 1 · 尾插

尾插就是在表尾进行插入,代码如下:

复制代码
void SListPushBack(SListNode** pplist, SLDataType x)
{
	assert(pplist);

	SListNode* pcur = *pplist;
	SListNode* newNode = BuyNode(x);
	//如果是空表,直接给
	if (pcur == NULL)
	{
		*pplist = newNode;
	}
	//非空表
	else
	{
		//找尾
		while (pcur->next)
		{
			pcur = pcur->next;
		}
		//尾插
		pcur->next = newNode;
	}
}

我们画张图方便理解:

其中 红色是我们需要进行的操作。

由于链式结构在物理结构上极大概率是不连续的,因此无法直接找到表尾,需要一个一个结点走下来才能找到表尾。

因此需要进行对成员变量next进行访问,此时就会产生问题,如果为空表,那么p == NULL ,此时对next 进行访问就发生了对空指针解引用的问题。

由此分出两种情况:空表与非空表。


3 - 4 - 2 · 尾删

尾删就是对表尾进行删除,代码如下:

复制代码
void SListPopBack(SListNode** pplist)
{
	//空表不能删
	assert(pplist && *pplist);

	SListNode* pcur = *pplist;

	//如果只有一个元素
	if (pcur->next == NULL)
	{
		free(pcur);
		*pplist = NULL;
	}
	//表中有多个元素
	else
	{
		//表尾的前一个
		SListNode* prev = pcur;
		//找尾
		while (pcur->next)
		{
			prev = pcur;
			pcur = pcur->next;
		}
		free(pcur);
		prev->next = NULL;
	}
}

方便理解,我们画张图:

其中 红色是我们需要进行的操作。

需要将表尾的结点进行删除,并且将新表尾的指针域置空。

因此需要定义两个指针,一个前一个后,相差一步。

需要注意的是:如果表中只有一个元素的情况,是需要将p 置空的。

因此分出两种情况:表中只有一个元素与表中有多个元素。


3 - 4 - 3 · 测试

我们对上面的功能测试一下:

复制代码
void Test3()
{
	SListNode* p = NULL;

	SListPushBack(&p, 1);
	SListPrint(p);

	SListPushBack(&p, 2);
	SListPrint(p);

	SListPushBack(&p, 3);
	SListPrint(p);

	SListPopBack(&p);
	SListPrint(p);

	SListPopBack(&p);
	SListPrint(p);

	SListPopBack(&p);
	SListPrint(p);

	//SListPopBack(&p);
	//SListPrint(p);

	SListDestroy(&p);
}

int main()
{
	//Test1();
	//Test2();
	Test3();
	//Test4();

	return 0;
}

运行一下:

此时再进行一次尾删,便会触发 assert断言。


3 - 5 · 查找,指定位置之后插入,指定位置之后删除

3 - 5 - 1 · 查找

找到第一个符合所给值的结点,代码如下:

复制代码
SListNode* SListFind(SListNode* plist, SLDataType x)
{
	SListNode* pcur = plist;
	while (pcur)
	{
		if (pcur->data == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	//没找到
	return NULL;
}

简单来说,就是遍历一遍,查找,如果没找到就返回空指针。


3 - 5 - 2 · 指定位置之后插入

在指定的位置的后一个位置进行插入,代码如下:

复制代码
void SListInsertAfter(SListNode* pos, SLDataType x)
{
	assert(pos);

	SListNode* newNode = BuyNode(x);
	newNode->next = pos->next;
	pos->next = newNode;
}

方便理解,我们画张图:

红色是我们需要进行的操作。

可以看到,和头插的操作差不多,当然也有对顺序的要求。

现在你可能会有疑问:为什么没有 指定位置之前插入 这个接口?

如果要在指定位置之前插入,那么就需要先找到 指向这个指定位置的结点,即指定位置的直接前驱结点。我们可以发现,对于单链表,找后继结点容易,可以顺着指针域一个一个找到,但是找前驱结点是难的。

如果要找到指定位置的直接前驱结点,需要加上一个参数:SListNode** pplist , 从表头开始一个一个遍历,才能找到指定位置的直接前驱结点。显然比较麻烦。


3 - 5 - 3 · 指定位置之后删除

对指定位置的后一个位置进行删除,代码如下:

复制代码
void SListEraseAfter(SListNode* pos)
{
	//如果pos在表尾,后一个不能删
	assert(pos && pos->next);

	SListNode* pnext = pos->next;
	pos->next = pnext->next;
	free(pnext);
	pnext = NULL;
}

方便理解,我们画个图:

红色是我们需要进行的操作。

可以看出,与头删类似,需要先保存将删除结点的指针域。

由于是对指定位置的后一个位置删除,自然指定位置是不能为表尾的。

那么这时候你可能会疑惑,为什么没有 删除指定位置 的接口?

原因与没有 指定位置之前插入 这个接口类似,如果删除指定位置,那么就需要更改 指定位置的直接前驱结点 的指针域,在单链表中找直接前驱结点是很麻烦的。


3 - 5 - 4 · 测试

我们对上面的功能进行测试:

复制代码
void Test4()
{
	SListNode* p = NULL;

	SListPushBack(&p, 1);
	SListPushBack(&p, 2);
	SListPushBack(&p, 3);
	SListPushBack(&p, 4);
	SListPrint(p);

	SListNode* find = SListFind(p, 3);
	SListInsertAfter(find, 66);
	SListPrint(p);

	find = SListFind(p, 4);
	SListInsertAfter(find, 77);
	SListPrint(p);

	find = SListFind(p, 1);
	SListEraseAfter(find);
	SListPrint(p);

	SListDestroy(&p);
}

int main()
{
	//Test1();
	//Test2();
	//Test3();
	Test4();

	return 0;
}

运行一下:


4 · 单链表缺陷

我们可以看到,上面的接口其实是不一致的,有的使用一级指针,有的却使用的是二级指针。

并且单链表找前面的结点是很困难的。因此对于 在指定位置之后插入 与 对指定位置进行删除,实现起来是很麻烦的。


总结

以上简单介绍了单链表有关内容,关于数据结构其余内容,请期待后续更新。


以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。

相关推荐
张二娃同学3 小时前
02_C语言数据类型_整型浮点型字符型一次讲清楚
android·java·c语言
鱼子星_3 小时前
【数据结构与算法】数据结构基础——栈和队列
c语言·数据结构
一枝小雨3 小时前
什么是标准C函数:以RISC-V架构下的C函数为例
c语言·risc-v·内核原理
承渊政道3 小时前
【贪心算法】(经典实战应用解析(三):K次取反后最⼤化的数组和、按⾝⾼排序、优势洗牌、最⻓回⽂串、增减字符串匹配)
数据结构·c++·学习·算法·贪心算法·线性回归·哈希算法
三品吉他手会点灯4 小时前
C语言学习笔记 - 34.数据类型 - 编程规范与高效学习方法
c语言·开发语言·笔记·学习
Lucky_ldy4 小时前
C语言学习:动态内存管理(数据结构关键)
c语言·数据结构·学习
JackSparrow4144 小时前
彻底理解Java NIO(二)C语言实现 I/O多路复用+Reactor模式 服务器详解
java·linux·c语言·后端·nio·reactor模式
三品吉他手会点灯4 小时前
C语言学习笔记 - 37.数据类型 - scanf函数的基本用法
c语言·开发语言·笔记·学习
草莓熊Lotso4 小时前
【Linux系统加餐】从原理到实战:System V消息队列全解析 + 基于责任链模式的工业级封装
linux·运维·服务器·c语言·c++·人工智能·责任链模式