【数据结构】带你从零到轻松拿捏单链表!(附详细代码和习题)

一、链表概述

之前的文章,我们介绍了线性表中的一种类型——顺序表,而顺序表存在着许多缺陷,针对这些缺陷,因此诞生出了另一种线性表——链表。

在数据结构中,链表(Linked List)是一种由节点构成的线性结构。每个节点包含数据域和指向下一个节点的指针。

在这里插入图片描述

对于顺序表而言,由于数据是连续存储的,所以我只需要知道一个元素的下标,我就可以轻松地访问这个数组地所有元素。而对于链表而言,数据是不连续存储的,那么我们如何找到彼此呢?链表的每个 “元素” 被称为一个 ”节点“ ,而一个节点里存放了数据(数据域)和下一个节点的指针(指针域),这样一来,我们只需要知道第一个节点的地址,这样我们就可以依次地访问到每一个节点了。

与顺序表相比,单链表在插入与删除操作上具有明显优势,因为它们无需移动大量元素,只需改变指针的指向即可。但是单链表不支持高效的随机访问,每次访问都需要从头开始遍历,此外每个节点需要额外的指针空间。

二、链表的类型

链表的种类非常多,以下情况组合起来就有 8 种链表结构:

  • 单向和双向

img

  • 带头和不带头
    在这里插入图片描述

  • 循环和不循环

在这里插入图片描述

虽然说我们有那么多种类型,但是也不需要每种都挨个学,在实际应用中最常用的就两种类型:

在这里插入图片描述

那么本篇重点讲解第一种——无头单向非循环链表,很多 OJ 题也是基于这种结构的链表来进行考察的。

(想要学习双向带头循环链表可以看这里——双向带头循环链表

三、单链表的实现

为了实现单链表的各种操作,我们将创建以下文件:

SList.h 声明单链表节点的结构及各个操作函数的接口
SList.c 提供单链表各个函数的具体实现
test.c 通过调用各个接口验证链表的正确性

1. 头文件:SList.h

在头文件中,我们首先定义了单链表节点的数据类型和结构体,并声明了所有相关操作的函数接口。

#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>  // 之后实现函数所要用到的头文件

typedef int SLTDataType;  // 方便修改节点内所存储的数据的数据类型

typedef struct SListNode {
   
	SLTDataType data;  // 节点内的数据(数据域)
	struct SListNode* next;  // 指向下一个节点的指针 (指针域)
} SListNode;

// 创建一个新的链表节点,数据域为 x
SListNode* CreatSListNode(SLTDataType x);

// 打印整个链表(从头节点开始)
void SListPrint(SListNode* phead);

// 尾部插入节点
void SListPushBack(SListNode** pphead, SLTDataType x);

// 头部插入节点
void SListPushFront(SListNode** pphead, SLTDataType x);

// 尾部删除节点
void SListPopBack(SListNode** pphead);

// 头部删除节点
void SListPopFront(SListNode** pphead);

// 查找链表中值为 x 的节点,找到返回该节点,否则返回 NULL
SListNode* SListFind(SListNode* phead, SLTDataType x);

// 在 pos 节点之前插入一个新节点(注意:pos 一般是通过 SListFind 得到的)
void SListInsert(SListNode** pphead, SListNode* pos, SLTDataType x);
// void SListInsert(SListNode* phead, int pos, SLTDataType x);  // 另一种方式

// 在 pos 节点之后插入一个新节点(对单链表来说这种操作更简单)
void SListInsertAfter(SListNode** pphead, SListNode* pos, SLTDataType x);

// 删除指定的 pos 节点
void SListErase(SListNode** pphead, SListNode* pos);

// 删除 pos 节点之后的节点
void SListEraseAfter(SListNode** pphead, SListNode* pos);

// 销毁整个链表,释放所有节点的内存
void SListDestroy(SListNode** pphead);

2. 实现文件:SList.c

在实现文件中,我们对 SList.h 中声明的各个接口逐一进行实现。

需要注意的点:

创建并初始化链表节点

CreatSListNode:分配内存,创建一个新节点并初始化数据。

#include "SList.h"  // 注意 SList.c 文件开头要包含你写的头文件

SListNode* CreatSListNode(SLTDataType x) {
   
	SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));  // 开辟一块新空间
	newnode->data = x;  // 初始化数据
	newnode->next = NULL;
	return newnode;
}

打印整个链表的数据

SListPrint:如果你想查看数据域的内容,可以从头节点开始遍历,依次打印每个节点的数据。

void SListPrint(SListNode* phead) {
   
	SListNode* cur = phead;
	while (cur) {
     // 依次遍历链表
		printf("%d->", cur->data);
		cur = cur->next;
	}
}

尾部插入节点

SListPushBack:尾插,注意需要区分链表为空和非空的情况

void SListPushBack(SListNode** pphead, SLTDataType x) {
   
	SListNode* newnode = CreatSListNode(x);  // 创建新节点
	if (*pphead == NULL) {
   
		*pphead = newnode;  // 修改了一级指针的地址,所以用二级指针
	} else {
   
		SListNode* tail = *pphead;  // 定义一个指针让它指向链表尾部
		while (tail->next != NULL) {
   
			tail = tail->next;
		}
		tail->next = newnode;  // 连接新节点
	}
}
  • 理解:使用二级指针

我们发现在这个函数的传参时使用了二级指针。为什么要使用二级指针?一级不行吗?打个比方,如果在有多个节点的链表中尾删一个节点,使用一级指针没问题,但是如果这个链表只有一个节点呢?你删完之后还需要把这个指向头节点的指针置为空对吧,而置空的这个操作就是在改变这个指针的值(注意不是改变指针所指向的内容的值),你传过来的指针是一个一级指针的话,你又要改变一个一级指针的值,那这就要用二级指针来实现。

所以观察每一个使用了二级指针的函数(包括下面要实现的头插等函数)它们的函数体内部都有可能涉及到改变这个指针的值的操作。而想打印链表 SListPrint 这种函数则不需要用二级指针来实现,因为没有改变指针的值。

需要补充的是,不止可以通过二级指针来实现这种操作,也可以通过其他的方式来实现,比如返回值等。

头部插入节点

SListPushFront:头插,直接将新节点指向当前头节点,然后更新头指针。

void SListPushFront(SListNode** pphead, SLTDataType x) {
   
	SListNode* newnode = CreatSListNode(x);
	newnode->next = *pphead;
	*pphead = newnode;
}

尾部删除节点

SListPopBack:尾删,若只有一个节点,则删除后头指针需置为空。

void SListPopBack(SListNode** pphead) {
   
	// 如果链表为空,就不能再删了,直接断言
	assert(*pphead != NULL);
	if ((*pphead)->next == NULL) {
     // 链表只有一个节点的情况
		free(*pphead);
		*pphead = NULL;
	} else {
     // 一个以上节点
		SListNode* tail = *pphead;
		while (tail->next->next) {
   
			tail = tail->next;
		}
		free(tail->next);
		tail->next = NULL;
	}
}

头部删除节点

SListPopFront:头删,直接调整头指针。

void SListPopFront(SListNode** pphead) {
   
	assert(*pphead != NULL);  // 链表为空直接断言
	SListNode* next = (*pphead)->next;  // 先保存下一个节点的地址
	free(*pphead);  // 再来释放头节点
	*pphead = next;
}

如果先就让头指针指向下一个节点的话,那么你将无法再访问到上一个节点了,也就无法释放该节点的内存。

在指定位置之后插入

SListInsertAfter:在指定的 pos 节点之后插入一个节点。

void SListInsertAfter(SListNode** pphead, SListNode* pos, SLTDataType x) {
   
	SListNode* newnode = CreatSListNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

在指定位置之前插入

SListInsert:在指定位置之前插入一个节点,如果 pos 为头节点,则相当于头插

void SListInsert(SListNode** pphead, SListNode* pos, SLTDataType x) {
   
	if (*pphead == pos) {
   
		SListPushFront(pphead, x);
	} else {
   
		SListNode* newnode = CreatSListNode(x);
		SListNode* posPrev = *pphead;
		while (posPrev->next != pos) {
     // 从头遍历直到找到pos的前一个节点
			posPrev = posPrev->next;
		}
		posPrev->next = newnode;  // 连接新节点
		newnode->next = pos;
	}
}

在这里插入图片描述

如果想要在单链表内实现在指定位置之前插入,那么就必须获取到前一个结点,这对单链表来说不太友好,只能从头开始遍历,直到找到 pos 的前一个位置。但是接下来的在指定位置之后插入就方便很多。

在指定位置之后插入

SListInsertAfter:在指定节点之后插入一个节点,更适合单链表的特点。

void SListInsertAfter(SListNode** pphead, SListNode* pos, SLTDataType x) {
   
	SListNode* newnode = CreatSListNode(x);
	newnode->next = pos->next;
	pos
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值