一、链表概述
之前的文章,我们介绍了线性表中的一种类型——顺序表,而顺序表存在着许多缺陷,针对这些缺陷,因此诞生出了另一种线性表——链表。
在数据结构中,链表(Linked List)是一种由节点构成的线性结构。每个节点包含数据域和指向下一个节点的指针。
对于顺序表而言,由于数据是连续存储的,所以我只需要知道一个元素的下标,我就可以轻松地访问这个数组地所有元素。而对于链表而言,数据是不连续存储的,那么我们如何找到彼此呢?链表的每个 “元素” 被称为一个 ”节点“ ,而一个节点里存放了数据(数据域)和下一个节点的指针(指针域),这样一来,我们只需要知道第一个节点的地址,这样我们就可以依次地访问到每一个节点了。
与顺序表相比,单链表在插入与删除操作上具有明显优势,因为它们无需移动大量元素,只需改变指针的指向即可。但是单链表不支持高效的随机访问,每次访问都需要从头开始遍历,此外每个节点需要额外的指针空间。
二、链表的类型
链表的种类非常多,以下情况组合起来就有 8 种链表结构:
- 单向和双向
-
带头和不带头
-
循环和不循环
虽然说我们有那么多种类型,但是也不需要每种都挨个学,在实际应用中最常用的就两种类型:
那么本篇重点讲解第一种——无头单向非循环链表,很多 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