Cyber骇客的数据链路重构 ——【初阶数据结构与算法】线性表之单链表


点击下面查看作者专栏 🔥🔥C语言专栏🔥🔥 🌊🌊编程百度🌊🌊 🌠🌠如何获取自己的代码仓库🌠🌠

索引与导读

本章介绍顺序表的链表中的单链表

线性表 链表 单链表 顺序表 双链表

一、顺序表的问题及思考

  • 中间/头部的插入删除,时间复杂度为O(N)
  • 增容需要申请新空间,拷贝数据,释放旧空间 , 会有不小的消耗
  • 增容一般是呈2倍的增长,势必会有一定的空间浪费
    例如: 当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间

思考:如何解决以上问题呢?


二、何为链表?

针对顺序表:中间、头部插入效率低下、增容降低运行效率、容易造成空间浪费,引入链表的数据结构

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

你可以把链表想象成一列火车:

  • 节点: 每一节车厢就是一个"节点"。
  • 数据: 车厢里装载的货物,就是节点中存储的实际数据。
  • 指针: 连接车厢之间的挂钩,就是节点中的"指针"。这个指针指向下一个节点的内存地址

思考一下:
链表的结构跟火车厢相似,淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。
车厢是独立存在的,且每节车厢都有车门。想象一下这样的场景,假设每节车厢的车门都是锁上的状态,需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?
最简单的做法:每节车厢里都放一把下一节车厢的钥匙


三、链表的结构

在链表里,每节车厢 是什么样的呢?


  • 与顺序表不同的是,链表里的每节车厢 都是独立申请下来的空间 ,我们称之为结点/节点

  • 为什么还需要指针变量来保存下一个节点的位置?
    链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间),我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点


3.1)单链表结点由哪些组成部分?

  • 组成部分:
    数据域 (Data Field): 存放元素值
    后继指针域 (Next Pointer): 存放下一个结点的地址
  • 特点:
    只能从头走到尾,无法直接回溯(除非从头重新遍历)
Plaintext 复制代码
[ 数据 | next ]  --->  [ 下一个结点 ]

3.2)单链表的结构体代码

c 复制代码
Struct SListNode {
	int data;						// 数据域
	struct SListNode* next;			//指针域:存储下一个结点的地址
};

💥这边会涉及一个知识点:自引用结构体,不清楚的读者可以进入下面的传送门修炼💪💪💪💪

传送卷轴:自定义结构体


在实际写代码时,为了避免每次都写 struct SListNode 这么长,通常会配合 typedef 使用:

c 复制代码
typedef struct SListNode {
    int data;
    struct SListNode* next;
} Node;  // 以后直接用 Node 就可以代替 struct SListNode

3.3)单链表的几个重要概念

🚩头结点

图中的plist就是头结点,位于链表的起始位置,但不存储实际的有效数据 (有效数据指的是结构体的内的数据,而不是结构体地址),主要作用是作为链表的入口通过它的指针域来指向链表中的第一个实际存储数据的节点

🚩首节点

首节点 就是跟在头节点后的链表中第一个存储实际有效数据的节点

🚩哨兵位

哨兵位和头结点类似,通常不存储实际数据,存储地址,哨兵位可以看成一个灵活的节点,可以在链表任何位置方便进行增删查改操作


四、单链表的实现

分文件编写

我们先分清各个文件的职责

SList.c

这里是逻辑的核心,记得第一行必须包含头文件

1)给链表申请一个新节点

c 复制代码
/*申请一个新的结点*/
SLTNode* SLTBuyNode(SLTDataType x) {
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	//申请结点有成功和失败的情况
	if (newnode == NULL) {
		perror("malloc fail!!");
		exit(1);				//正常退出是0,非正常退出是1
	}
	newnode->data = x;
	newnode->next = NULL;

	return newnode;
}

🔥🔥🔥🔥讲解代码要点:
SLTNode* SLTBuyNode(SLTDataType x)

  • 返回值: SLTNode* 返回指向新创建节点的指针
  • 参数: SLTDataType x 要存储在新节点中的数据

SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));

  • malloc(sizeof(SLTNode)): 动态分配内存 ,大小为SLTNode结构体的大小
    动态内存分配: 在堆上分配内存,生命周期由程序员控制
  • (SLTNode*): 显式类型转换 ,将void*转换为SLTNode*

perror("malloc fail")

  • 打印错误信息到标准错误流

exit(-1)

  • 程序异常终止,参数-1表示异常退出状态
  • 参数0表示正常退出

newnode->next = NULL

  • next指针设为NULL,表示这是链表的尾节点

return newnode;

  • 返回新创建节点的地址,供调用者使用

🔥顺序表是对它底层的数组进行二倍扩容,而节点申请函数一次性只能申请一个节点,用一个给一个,间接避免了空间浪费,注意返回的是新节点的地址


2)链表打印函数

c 复制代码
void SLTPrint(SLTNode* phead) {
	SLTNode* pcur = phead;	//用pcur来遍历链表
	while (pcur != NULL) {
		printf("%d -> ", pcur->data);
		pcur = pcur->next;	//指向下一个节点
	}
	printf("\n");
}

3)链表的尾插

c 复制代码
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
    assert(pphead); // 确保传入的地址有效

    SLTNode* newnode = SLTBuyNode(x);

    // 情况1:链表为空,直接让头指针指向新节点
    if (*pphead == NULL)
    {
        *pphead = newnode;
    }
    else
{
        // 情况2:链表不为空,找尾节点
        // 初始:tail指向头节点
	SLTNode* tail = *pphead;

	// 遍历直到找到最后一个节点
	while (tail->next != NULL) {
	    tail = tail->next;  // 移动到下一个节点
	}

	// 此时tail->next为NULL,tail就是尾节点
	tail->next = newnode;  // 连接新节点
    }
}

🔥🔥🔥🔥讲解代码要点:
SLTNode** pphead
为什么需要二级指针?

  • 函数有传值调用和传址调用,其中传址调用才能改变实参的值

不懂的同学可以看这里

  • 对于非指针的数据类型传参,我们用对应的类型指针去接收它
    对于指针传参,我们需要用二级指针去接收它
  • 第一个节点*plist
  • 指向第一个节点的指针plist
  • 指向第一个节点的指针的地址&plist
  • 指针和指针的地址是两种东西❗❗❗❗❗❗

指针和指针的地址的区别不熟悉的可以看这里

text 复制代码
+------+      +------+      +-------+
|  pp  | ---> |  p   | ---> |   a   |
+------+      +------+      +-------+
(二级指针)    (一级指针)    (普通变量)

SLTNode* newnode = SLTBuyNode(x);

  • 调用之前分析的节点创建函数
  • 新节点的data = xnext = NULL

*pphead == NULL

  • 假设有这么一个整型head,我们用一个指针phead去接收这个整型数据
  • phead存储的是head的地址,但是指针本身也是一个值,也需要一个指针去接收它**
  • 这时候就有 二级指针pphead 的出现了
  • 我们对SLTPushBack传入&plist,表明传入了节点的地址
  • 所以对二级指针的一级解引用 ,得到的就是plist的地址

*pphead = newnode;

  • 直接让头指针指向新节点

SLTNode* tail = *pphead;

  • 初始tail指向头节点

while (tail->next != NULL) { tail = tail->next; }

  • 遍历直到找到最后一个节点,移动到下一个节点

4)链表的尾删(双指针法)

🚩🚩🚩链表的尾删我们需要分单节点双节点来分类讨论

  • why?
    出于对链表头指针的特殊处理需求

🌠单节点链表 - 操作的是入口
关键观察:删除后 链表从"有一个入口指向一个节点"变成了"入口指向空"

text 复制代码
删除前:
头指针 → [节点A] → NULL
 ↑
入口点

删除后:
头指针 → NULL
 ↑
入口点

🌠多节点链表 - 操作的是节点关系
关键观察:删除后,入口保持不变,只是修改了内部节点的连接关系

text 复制代码
删除前:
头指针 → [A] → [B] → [C] → NULL
             ↑     ↑
             prev  curr

删除后:
头指针 → [A] → [B] → NULL
             ↑
             prev

4.1)单节点的尾删
c 复制代码
if ((*pphead)->next == NULL)
    {
        free(*pphead);
        *pphead = NULL;
    }

🔥🔥🔥🔥讲解代码要点:

  • if ((*pphead)->next == NULL);
    *pphead:解引用二级指针,得到头指针(指向第一个节点)

  • free(*pphead);
    告诉操作系统:"这块内存我不再使用了,你可以回收"

  • free不会改变指针的值!指针仍然指向原来的地址


不明白悬空指针的可以看下面

Lucy的空间骇客裂缝:realloc解析


  • *pphead = NULL;

如果没有这行代码:

c 复制代码
free(*pphead);  // 释放内存
// *pphead仍然指向已释放的内存地址
// 后续如果有人误用这个指针:
SLTPushBack(pphead, 20);  // 在已释放内存上操作 → 崩溃!

// 有这行代码:
free(*pphead);
*pphead = NULL;  // 明确标记链表为空
// 后续操作:
SLTPushBack(pphead, 20);  // 检测到*pphead为NULL,正常创建新节点

4.2)多节点的尾删
c 复制代码
SLTNode* prev = NULL;
SLTNode* tail = *pphead;
        while (tail->next != NULL)
        {
            prev = tail;
            tail = tail->next;
        }
        free(tail);
        tail = NULL;
        prev->next = NULL; // 倒数第二个节点变成新的尾节点

🔥🔥🔥🔥讲解代码要点: 找倒数第二个节点

  • SLTNode* prev = NULL;
    SLTNode* tail = *pphead;

    prev前驱指针 ,确保了删除操作后链表仍然是连通的、有效的数据结构
    tail当前指针 ,用于遍历链表,最终指向要删除的尾节点

    此时的问题: 我们知道要删除C,但删除后需要让B指向NULL,问题是我们不知道B在哪里
    所以prev的作用就是记录链表的数据

  • free(tail);

    释放尾节点

  • tail = NULL;

    将释放内存后的变量置空(程序员的习惯)

  • prev->next = NULL;

    倒数第二个节点变成新的尾节点

完整代码
c 复制代码
/*链表的尾删(双指针法)*/
void SLTPopBack(SLTNode** pphead)
{
	assert(pphead);
	assert(*pphead); // 链表不能为空

	// 情况1:只有一个节点
	if ((*pphead)->next == NULL)
	{
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
		// 情况2:有多个节点,找倒数第二个节点
		SLTNode* prev = NULL;
		SLTNode* tail = *pphead;
		while (tail->next != NULL)
		{
			prev = tail;
			tail = tail->next;
		}
		free(tail);
		tail = NULL;
		prev->next = NULL; // 倒数第二个节点变成新的尾节点
	}

5)链表的头插

c 复制代码
/*链表的头插*/
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
	assert(pphead);

	SLTNode* newnode = SLTBuyNode(x);
	//新节点的下一个节点指向原来的头节点
	newnode->next = *pphead;
	*pphead = newnode;
}

newnode->next = *pphead;
next *pphead头节点 B newnode

*pphead = newnode;
*pphead头节点
指针为新节点的指针 A
指针为next B


6)链表的头删

c 复制代码
/*链表的头删*/
void SLTPopFront(SLTNode** pphead)
{
	assert(pphead);
	assert(*pphead); // 链表不能为空

	// 保存第二个节点的地址
	SLTNode* next = (*pphead)->next;
	free(*pphead);		//释放头节点占用的内存
	*pphead = next; 	// 头指针指向原来的第二个节点
}

🔥🔥🔥🔥讲解代码要点:

  • assert(pphead);

    确保传入的二级指针不为NULL

  • assert(*pphead);

    确保链表不为空

  • SLTNode* next = (*pphead)->next;
    保存第二个节点地址

  • free(*pphead);

    释放头节点占用的内存

  • *pphead = next;

    使头指针指向新的首节点 ,同时也避免悬空指针


7)链表的查找

c 复制代码
// 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
    SLTNode* cur = phead;
    while (cur)
    {
        if (cur->data == x)
        {
            return cur;
        }
        cur = cur->next;
    }
    return NULL;
}

🔥🔥🔥🔥讲解代码要点:

  • SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
    为何这里phead不用二级指针?

需要二级指针的操作(会修改头指针)

c 复制代码
void SLTPopFront(SLTNode** pphead)  // 修改头指针
void SLTPushFront(SLTNode** pphead) // 修改头指针

不需要二级指针的操作(只读取,不修改)

c 复制代码
SLTNode* SLTFind(SLTNode* phead)    // 只遍历,不修改头指针

查找操作的本质

查找函数只需要遍历访问链表,不需要修改头指针:

c 复制代码
// 查找过程只是读取操作:
cur->data     // 读取数据
cur->next     // 读取指针
// 没有任何修改头指针的操作!
  • SLTNode* cur = phead;

    创建临时指针用于遍历链表,不直接使用phead

  • if (cur->data == x) { return cur; }
    cur = cur->next;

    检查当前节点的data是否等于目标值x
    如果匹配 ,立即返回当前节点指针
    如果不匹配,移动到下一个节点继续查找

  • return NULL;
    触发条件: 遍历完整个链表都没有找到匹配的节点


8)在指定的位置之前插入数据

c 复制代码
/*在指定的位置之前插入数据*/
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
	assert(pphead);				//确保传入的二级指针不为NULL
	assert(pos);				//确保要插入的位置节点pos不为NULL

	//如果pos是头节点,那就是头插
	if (pos == *pphead) {
		SLTPushFront(pphead, x);
	}
	else {
	// 找到pos的前一个节点 prev
		SLTNode* prev = *pphead;
		while (prev->next != pos) {
			prev = prev->next;

			// 防止pos不在链表中导致死循环(实际使用需保证pos有效)
			assert(prev);
		}
		SLTNode* newnode = SLTBuyNode(x);
		prev->next = newnode;
		newnode->next = pos;
	}
}

🔥🔥🔥🔥讲解代码要点:

  • assert(pphead);
    确保传入的二级指针不为NULL
  • assert(pos);
    确保要插入的位置节点pos不为NULL
  • SLTNode* prev = *pphead;
    while (prev->next != pos){rev = prev->next;assert(prev); }
    实现链表遍历读取
  • SLTNode* newnode = SLTBuyNode(x);
    prev->next = newnode;
    newnode->next = pos;
    串联链表

9)删除pos节点

c 复制代码
/*删除pos节点*/
void SLTErase(SLTNode** pphead, SLTNode* pos) {
	assert(pphead);
	assert(*pphead);	//不允许删除空链表		
	assert(pos);

	// 如果pos是头节点,那就是头删
	if (*pphead == pos) {
		SLTPopFront(pphead);
	}
	else {
		SLTNode* prev = pphead;
		while (prev->next != pos) {		//遍历
			prev = prev->next;
			assert(prev);
		}
		prev->next = pos->next;
		free(pos);
		pos = NULL;						//此时形参pos置空
	}
}

🔥🔥🔥🔥讲解代码要点:

  • assert(*pphead);
    不允许对空链表进行删除操作

10)在指定位置之后插入数据

c 复制代码
/*在指定位置之后插入数据*/

// 注意:这里不需要二级指针,因为不会改变头节点
void SLTInsertAfter(SLTNode* pos, SLTDataType x) {
	assert(pos);

	SLTNode* newnode = SLTBuyNode(x);
	//核心逻辑:先把新节点连上pos的后一个,再把pos连上新节点
	newnode->next = pos->next;
	pos->next = newnode;
}
/*删除pos之后的节点*/

🔥🔥🔥🔥讲解代码要点:

  • 核心逻辑:先把新节点连上pos的后一个,再把pos连上新节点

等于 等于 newnode->next pos->next pos->next newnode


11)删除pos之后的节点

c 复制代码
/*删除pos之后的节点*/
void SLTEraseAfter(SLTNode* pos) {
	assert(pos);
	assert(pos->next);			//pos后面必须有节点才能删除

	SLTNode* del = pos->next;
	pos->next = del->next;
	free(del);
	del = NULL;
}

这里没什么好讲的,主要注意assert的使用❗


12)销毁链表

c 复制代码
/*销毁链表*/
void SListDestroy(SLTNode** pphead) {
	assert(pphead);

	SLTNode* cur = *pphead;		//注意:是指向头指针,即:节点
	while (cur) {
		SLTNode* next = cur->next;
		free(cur);
		cur = next;
	}
	*pphead = NULL;				//销毁后指针置空!!!
}

🔥🔥🔥🔥讲解代码要点:

  • SLTNode** pphead
    二级指针,用于修改头指针本身
  • assert(pphead);
    如果ppheadNULL,解引用*pphead会导致程序崩溃

为什么不assert(*pphead)

销毁空链表是合法操作

而解引用*pphead到空指针会导致系统崩溃❗❗❗❗❗❗

  • *pphead = NULL;
c 复制代码
// 如果不置空:
销毁前:head → 节点1 → 节点2 → 节点3 → NULL
销毁后:head → 已释放内存 (悬空指针!)

// 置空后:
销毁后:head → NULL (安全)

分析每个函数是否允许空链表

函数名 参数允许空链表 (*pphead == NULL)? 备注
SLTPrint 打印空内容
SLTPushBack 空变非空
SLTPushFront 空变非空
SLTPopBack Assert报错,无法删空
SLTPopFront Assert报错,无法删空
SLTFind 返回 NULL
SLTInsert 空链表无法提供合法的 pos
SLTErase 同上
SLTInsertAfter 同上
SLTEraseAfter 同上
SListDesTroy 只是什么都不做

Test.c

程序的入口

c 复制代码
#include "SList.h"

void TestSList1()
{
    printf("=== 测试尾插与打印 ===\n");
    SLTNode* plist = NULL; // 链表头指针初始必须为空
    SLTPushBack(&plist, 1);
    SLTPushBack(&plist, 2);
    SLTPushBack(&plist, 3);
    SLTPrint(plist); // 1 -> 2 -> 3 -> NULL

    printf("=== 测试头插 ===\n");
    SLTPushFront(&plist, 10);
    SLTPushFront(&plist, 20);
    SLTPrint(plist); // 20 -> 10 -> 1 -> 2 -> 3 -> NULL

    printf("=== 测试尾删 ===\n");
    SLTPopBack(&plist);
    SLTPrint(plist); // 20 -> 10 -> 1 -> 2 -> NULL

    printf("=== 测试头删 ===\n");
    SLTPopFront(&plist);
    SLTPrint(plist); // 10 -> 1 -> 2 -> NULL

    // 销毁
    SListDestroy(&plist);
}

void TestSList2()
{
    printf("\n=== 测试查找与任意位置操作 ===\n");
    SLTNode* plist = NULL;
    SLTPushBack(&plist, 1);
    SLTPushBack(&plist, 2);
    SLTPushBack(&plist, 3);
    SLTPushBack(&plist, 4);
    SLTPrint(plist);

    // 查找数字3
    SLTNode* pos = SLTFind(plist, 3);
    if (pos)
    {
        printf("找到了3,在它前面插入30\n");
        // 在3之前插入30
        SLTInsert(&plist, pos, 30);
        SLTPrint(plist); // 1 -> 2 -> 30 -> 3 -> 4 -> NULL

        printf("在3之后插入300\n");
        // 在3之后插入300
        SLTInsertAfter(pos, 300);
        SLTPrint(plist); // 1 -> 2 -> 30 -> 3 -> 300 -> 4 -> NULL

        printf("删除3这个节点\n");
        // 删除3
        SLTErase(&plist, pos);
        SLTPrint(plist); // 1 -> 2 -> 30 -> 300 -> 4 -> NULL
    }

    // 重新查找30
    pos = SLTFind(plist, 30);
    if (pos)
    {
        printf("删除30后面的节点(300)\n");
        SLTEraseAfter(pos);
        SLTPrint(plist); // 1 -> 2 -> 30 -> 4 -> NULL
    }

    SListDestroy(&plist);
}

int main()
{
    TestSList1();
    TestSList2();
    return 0;
}

SList.h

头文件是模块的接口。这里我们需要定义链表的节点结构,并列出我们要实现的功能(增删查改)

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

typedef int SLTDataType;

typedef struct SListNode
{
    SLTDataType 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);
// 删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos);

// 在指定位置之后插⼊数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
// 删除pos之后的节点
void SLTEraseAfter(SLTNode* pos);

// 销毁链表
void SListDesTroy(SLTNode** pphead);

SLTNode* SLTBuyNode(SLTDataType x);

五、链表的笔试题实战

六、链表的输出效果

希望读者多多三连

给小编一些动力

蟹蟹啦!

相关推荐
Want59543 分钟前
C/C++跳动的爱心③
java·c语言·c++
Violet_YSWY43 分钟前
git清理缓存
git·elasticsearch·缓存
弱冠少年43 分钟前
xiaozhi任务管理分析(基于ESP32)
c语言
星轨初途1 小时前
C++的条件判断与循环及数组(算法竞赛类)
开发语言·c++·经验分享·笔记·算法
stay night481 小时前
F4 状态机模型
c语言
freedom_1024_1 小时前
C++运算符重载:从本质到实践
开发语言·c++
GUET_一路向前1 小时前
【C语言无符号常量好处】`4U` 表示一个无符号整数常量 4
c语言·开发语言·无符号常量
元亓亓亓1 小时前
LeetCode热题100--34. 在排序数组中查找元素的第一个和最后一个位置--中等
数据结构·算法·leetcode