【链表】单链表的介绍和基本操作(C语言实现)【保姆级别详细教学】

单链表

在这里插入图片描述
在这里插入图片描述

前言

先赞后看好习惯 打字不容易,这都是很用心做的,希望得到支持你 大家的点赞和支持对于我来说是一种非常重要的动力 看完之后别忘记关注我哦!️️️

作者: @小小Programmer
这是我的主页:@小小Programmer
在食用这篇博客之前,博主在这里介绍一下其它高质量的编程学习栏目:
数据结构专栏:数据结构 这里包含了博主很多的数据结构学习上的总结,每一篇都是超级用心编写的,有兴趣的伙伴们都支持一下吧!
算法专栏:算法 这里可以说是博主的刷题历程,里面总结了一些经典的力扣上的题目,和算法实现的总结,对考试和竞赛都是很有帮助的!
力扣刷题专栏Leetcode想要冲击ACM、蓝桥杯或者大学生程序设计竞赛的伙伴,这里面都是博主的刷题记录,希望对你们有帮助!

单链表基本介绍

对于数据结构这个模块,我们必须要对C语言中的结构体和指针这两部分的内容有很好的掌握,并且要可以灵活地运用。

基本结构

链表:链表是一种在物理储存上非连续,非顺序的储存结构。
链表,顾名思义:就是数据像链条一样串联在一起。
单链表:即单向的链表,即链表是有方向的。

而本篇所要介绍的,是有头有尾的单链表。
在这里插入图片描述

以上就是单链表的基本结构,由图我们可以知道,在一个结构里面,我们需要一部分来储存数据,一部分来储存下一个结构体的地址。也就是说,一个结构被划分为数据域指针域
在数据结构这一部分内容中,我们习惯将这样的一个结构体称为结点
同时:我们需要注意的是:在计算机的内存当中,我们的结点并不是像图里那样按照顺序排好的,有可能第一个结点的地址高低一点,第二个低一点,第三个又高一点,但是,这些我们都不关心,因为我们每个结点都有指向下一个结点的指针,相当于是串联起来了,无论结点放在低地址还是高地址,我们都可以找到。

与顺序表的区别以及学习单链表的必要性

顺序表就好像数组一样,每个数据都是按照顺序排列在一起的。而链表不同,链表在空间上是不一定有序的。
于是乎顺序表就显示出了它的一些不便之处。
比如说:我们要在顺序表中某一个位置插入一个数据,我们就要先把后面数据向后挪动一个位置,才能插入…
虽然单链表也有很多缺点,但是,它是很多复杂数据结构的子结构,比如哈希表下面吊着的就是单链表。
因此,我们学习单链表是非常有意义,而且是必要的。

单链表的实现

实现单链表,我们需要三个文件
SList.h,SList.c,test.c

test.c:测试单链表的功能
SList.h:声明实现单链表的相关函数
SList.c:实现单链表相关函数

结点的定义以及头指针的创建

结点的定义非常的简单,我们就简单点,一个数据,一个指针

//SList.h里
typedef int SListDataType;//将我们要存储的数据重新定义一下
//结点
typedef struct SListNode {
	SListDataType data;//数据域
	struct SListNode* next;//指针域
}SListNode;

并且与此同时,我们要在test.c里面创建一个头指针


void TestSList1() {
	//头指针
	SListNode* pList = NULL;//pList永远指着第一个
	//...
	//下面是各种调试函数的测试
}
int main()
{
	TestSList1();
	return 0;
}

单链表的遍历(打印接口的实现)【重点】

要想实现单链表:学会如何遍历是最最最最核心的。 在顺序表中,或者在数组中,我们想要遍历顺序表或者数组,指针++就可以找到下一个数据,但是在单链表中这样可以吗?
很明显是不行的,在链表里面数据根本就不是连续存放的,我们要通过next指针来找下一个结点

void SListPrint(SListNode* phead) {
	//不用assert
	//因为它完全就是有可能为空的
	SListNode* cur = phead;
	while (cur != NULL) {
		printf("%d-->", cur->data);
		cur = cur->next;//这里的next是下一个节点的地址
		//这里不能够是cur++;因为链表结点地址不连续
		//但是我们的指针域里面存了下一个结点的地址,我们直接
		//cur=cur->next;
	}
	//以前顺序表其实就是数组,我们挨个挨个,用循环就可以打印了
	//但是这里不一样,这里是链表,串起来的
	//地址不一定连续
	printf("NULL\n");//这样可以美化一下
}

在这里伙伴们一定要好好地理解cur=cur->next;的含义,这是遍历链表的核心所在。
另外:需要注意的是,SListPrint()这个接口只需要传一级指针就可以了,因为我们只是打印而已,不需要对修改头指针。
这是非常基础的C语言知识了:改谁,就传谁的地址,要传址调用。下面尾插尾删头插头删函数都是可能要修改头指针的,所以我们到时候要穿头指针的地址,也就是一个二级指针。

养成养好习惯,写完一个立马测试,但是现在是个空链表,啥都没有,没得打印,所以我们赶紧把后面的函数写了,顺便再来测试打印函数。
其实理解了链表的遍历原理,细心地写一般来说是不会写bug的。

开辟结点接口

考虑到后面我们要实现各种功能的时候,都有可能要开辟新结点,所以我们直接写一个开辟节点的函数。

SListNode* BuySListNode(SListDataType x) {
	SListNode* newNode = (SListNode*)malloc(sizeof(SListNode));//开一个结点
	if (newNode == NULL) {
		printf("申请结点失败\n");
		exit(-1);
	}
	newNode->data = x;
	newNode->next = NULL;//一开始我们先把新结点的next置空
	                     //但如果是中间插入或者头插的话,在接口外头我们还要把这个next指向后面的结点
	return newNode;
}

尾插接口

尾插:在链表末尾插入数据

在这里我们要好好分析了
尾插的时候有三种情况:1.原来链表为空链表 2.原来的链表已经有数据了
刚开始学的小伙伴写尾插的时候,其实都比较难想到要分情况。如果不分这两种情况,会发生什么呢?
我们开始:想在最后插入位置插入数据,也就是先创建一个结点,让原本最后一个结点的next指向新结点newNode,怎么找到最后一个结点?
创建指针tail,如果tail->next=NULL;的时候不就是最后一个结点了吗,那有些小伙伴就开始写了–循环while (tail->next != NULL) {}找尾。
如果这样写,我们测试的时候会发现,程序根本跑不过去,为什么?
我们仔细想想,当链表是空链表的时候存在tail->next这玩意儿吗?->是什么操作符?它是一种解引用啊兄弟们,没东西怎么解引用,这不就变成非法解引用了吗,所以,我们要分情况。

即使我们第一次写没有想到,但是这个是我们通过调试代码可以修改出来的。

void SListPushBack(SListNode** pphead, SListDataType x) {
	//这里怎么尾插
	//我们要和顺序表区分开来
	//我们是直接把data插进去吗,插到哪里去,有空间插吗
	//链表的特点是按需索取
	//我们是要先开辟一个空间,放好data,把它放到最后一个结点的后面
	//无论链表为空还是不为空,一上来都要创建一个结点
	SListNode* newNode = BuySListNode(x);

	if (*pphead == NULL) {
		//这种情况,我们要先开一个结点
		//所以我们开结点的过程直接定义成一个接口
		*pphead = newNode;
	}
	else {
		//找尾---遍历
		SListNode* tail = *pphead;
		while (tail->next != NULL) {
			tail = tail->next;//不为空继续往下走
		}
		//对于链表而言
		//不需要扩容这些东西
		//按需索取
		//对于申请内存来说,顺序表比链表好
		tail->next = newNode;//把新结点地址给tail->next即可
	}
}

有伙伴问,类似tail这些指针不用free()吗,不用,这些只是临时的指针变量,只是帮助我们找尾的而已,结构走完程序自动free()掉他们了,不用我们操心。

写完–测试一下吧

void TestSList1() {
	//头指针
	SListNode* pList = NULL;//pList永远指着第一个
	//...
	//下面是各种调试函数的测试
	SListPushBack(&pList, 1);
	SListPushBack(&pList, 2);
	SListPushBack(&pList, 3);
	SListPushBack(&pList, 4);
	SListPushBack(&pList, 5);
	SListPushBack(&pList, 6);
	SListPrint(pList);
}
int main()
{
	TestSList1();
	return 0;
}

在这里插入图片描述

尾删接口

尾删:删除最后面的结点

在这里就不做作解释:思路非常简单,也是要分情况,注意第三种情况:一个以上,我们还需要一个指针prev来记录前一个结点位置

void SListPopBack(SListNode** pphead) {
	//1.空
	//2.一个
	//3.一个以上
	//1.
	if (*pphead == NULL) {
		return;
	}
	//2.
	else if ((*pphead)->next == NULL) {
		free(*pphead);
		*pphead = NULL;//记得置空
	}
	//3.
	else {
		//关键:把前一个的next置空
		SListNode* tail = *pphead;
		//prev:前一个结点
		SListNode* prev = NULL;
		while (tail->next != NULL) {
			prev = tail;
			tail = tail->next;
		}
		free(tail);
		prev->next = NULL;//不要忘记将前一个结点的next置空
	}
}

在这里就不展示测试了,但是希望伙伴们写的时候要记得去测试一下,一分钟的事情。

头插接口

头插:在链表头插入一个结点
单链表的头插就比顺序表要简单很多了,不用挪动数据,思路也非常的简单,这里也不多做解释

void SListPushFront(SListNode** pphead, SListDataType x) {
	SListNode* newnode = BuySListNode(x);
	newnode->next = *pphead;
	*pphead = newnode;//记得更改头指针的位置
}

头删接口

头删:删除链表头的结点
同样:要分情况讨论
这里还需要一个临时记录位置的结点,防止我们更改头指针的时候找不到前面的结点了。

void SListPopFront(SListNode** pphead) {
	//1.空
	//2.一个结点
	//3.一个以上的结点
	if (*pphead == NULL) {
		return;
	}
	else {
		//怎么删呢?
		//1.先free掉第一个-->不行:找不到下一个了
		//2.先让pList指向下一个--->不行:找不到我们要free的那个了
		//正确的做法:先让一个临时指针充当next的作用,free掉,再让pList指向临时next的位置
		//或者用临时指针保存我们要free的,pList先走也可以

		//此时我们发现,我们这个代码也可以处理只有一个结点的情况,所以第二种第三种情况合并
		SListNode* next = (*pphead)->next;
		free(*pphead);
		*pphead = next;
	}
}

查找接口

查找结点这个接口同时也可以完成改动结点数据的任务,因为该接口可以返回一个结点的地址。

SListNode* SListFind(SListNode* phead, SListDataType x) {
	SListNode* cur = phead;
	while (cur != NULL) {//遍历过程
		if (cur->data == x) {
			return cur;
		}
		cur = cur->next;
	}
	return NULL;//循环走完找不到,返回空指针
}

我们测试的时候顺便改一下

//test.c里
	SListNode* pos = SListFind(pList, 3);
	if (pos) {//如果查找接口返回的不是NULL,则修改
		pos->data = 30;//找到了我们自然就可以修改了
	}
	SListPrint(pList);

测试一下

void TestSList1() {
	//头指针
	SListNode* pList = NULL;//pList永远指着第一个
	//...
	//下面是各种调试函数的测试
	SListPushBack(&pList, 1);
	SListPushBack(&pList, 2);
	SListPushBack(&pList, 3);
	SListPushBack(&pList, 4);
	SListPushBack(&pList, 5);
	SListPushBack(&pList, 6);
	SListPrint(pList);
	SListNode* pos = SListFind(pList, 3);
	if (pos) {//如果查找接口返回的不是NULL,则修改
		pos->data = 30;
	}
	SListPrint(pList);
}
int main()
{
	TestSList1();
	return 0;
}

在这里插入图片描述

在pos位置后插入结点

思路也非常的简单,这里也不多做解释,代码也很容易明白

void SListInsertAfter(SListNode* pos, SListDataType x) {
	assert(pos);//这里是要断言的,因为给的pos不能为空
	SListNode* newnode = BuySListNode(x);
	newnode->next = pos->next;//先把后面的地址给新结点,再将前一个的next改成新结点地址
	pos->next = newnode;
}

删除pos位置后的结点

注意:此处最好定义临时指针,防止丢失内存

void SListEraseAfter(SListNode* pos) {
	assert(pos);
	if (pos->next) {
		//pos->next=pos->next->next;
		//不推荐这种写法,这样一些我们原来的pos->next就不见了
		SListNode* next = pos->next;
		SListNode* next_next = next->next;
		pos->next = next_next;
		free(next);
	}
}

思考

写到这里,我们单链表的基本操作的各种接口已经实现完成了。
我们要思考一个问题,为什么最后两个接口是要操作pos位置后的结点,而不是pos,而不是pos之前的?
插在当前位置可以吗,插在前面可以吗?
都可以,但是为什么不好?
我们在前面插入,在当前位置插入,需要的是前面的前面的next,或者前面的next
怎么获得?遍历单链表,所以我们还得传头指针过去。
如果我们pos恰好是第一个位置,变成头插了,改变头指针位置,我们就还得传pphead
所以,最好的接口,就是插在后面,这也是单链表的缺陷
如果想要想在哪修改就在哪修改—>用双链表就可以了

测试代码和头文件代码完整展示

test.c

void TestSList1() {
	//头指针
	SListNode* pList = NULL;//pList永远指着第一个
	//...
	SListPushBack(&pList, 1);
	SListPushBack(&pList, 2);
	SListPushBack(&pList, 3);
	SListPushBack(&pList, 4);
	SListPushBack(&pList, 5);
	SListPushBack(&pList, 6);
	//这里要传地址,所以SList.c那边要用二级指针来接受
	SListPrint(pList);
	//打印是不需要传地址的,因为不需要改变内容,跟以前C语言学的是一样的道理

	SListPopBack(&pList);
	SListPopBack(&pList);
	SListPrint(pList);


	SListPushFront(&pList, 0);
	SListPushFront(&pList, -1);
	SListPushFront(&pList, -2);
	SListPrint(pList);

	SListPopFront(&pList);
	SListPopFront(&pList);
	SListPopFront(&pList);
	SListPrint(pList);
}
void TestSList2() {
	//头指针
	SListNode* pList = NULL;//pList永远指着第一个
	//...
	SListPushBack(&pList, 1);
	SListPushBack(&pList, 2);
	SListPushBack(&pList, 3);
	SListPushBack(&pList, 4);
	SListPushBack(&pList, 5);
	SListPushBack(&pList, 6);
	SListPrint(pList);
	//测试查找,顺便修改一下
	SListNode* pos = SListFind(pList, 3);
	if (pos) {//如果查找接口返回的不是NULL,则修改
		pos->data = 30;
	}
	SListPrint(pList);
}
int main() {
	TestSList1();
	TestSList2();
	return 0;
}

SList.h

#pragma once

#include<stdio.h>
#include<assert.h>
typedef int SListDataType;
//结点
typedef struct SListNode {
	SListDataType data;//数据域
	struct SListNode* next;//指针域
}SListNode;

//尾插
void SListPushBack(SListNode** pphead, SListDataType x);
//尾删
void SListPopBack(SListNode** pphead);
//头插
void SListPushFront(SListNode** pphead, SListDataType x);
//头删
void SListPopFront(SListNode** pphead);
//查找
SListNode* SListFind(SListNode*phead, SListDataType x);
//pos位置后插入
void SListInsertAfter(SListNode*pos, SListDataType x);
//删除pos位置后的值
void SListEraseAfter(SListNode* pos);
//打印
void SListPrint(SListNode* phead);

尾声

看到这里,相信伙伴们已经对单链表已经有了基本了解,掌握了基本的操作接口实现方法。其实单链表在以后的学习工作中,并不是特别的实用,因为单链表也有很多缺陷。但是,掌握单链表是我们以后学习复杂数据结构的必须要的。
如果看到这里的你感觉这篇博客对你有帮助,不要忘了收藏,点赞,转发,关注哦。

posted @ 2021-11-21 16:54  背包Yu  阅读(59)  评论(0编辑  收藏  举报  来源