对于数据结构:链表的超详细保姆级解析
开篇介绍:
hello 大家,前阵子我们完成了对于牛客网中的语言学习篇—编程初学者入门训练的大部分题目解析,相信大家经过练习之后,对于c语言的了解以及熟练定是节节高,那么,在我们掌握了c语言的基础知识之后,我们就要开始此向着编程的下一城:数据结构进军了,
这绝非一段普通的旅程,而是从 “会写代码” 到 “能写好代码” 的关键一跃,更是从 “实现功能” 到 “优化性能” 的核心跨越。
数据结构之所以重要,在于它是程序的 “骨架” 与 “基石”。当我们面对简单问题时,或许随便写几行逻辑就能解决;但当问题复杂度攀升 —— 比如处理百万级数据的查询、构建高并发的系统、实现高效的算法时,数据结构的选择就成了决定程序性能的关键。
想象一下:同样是存储一组数据,用数组还是链表?前者随机访问更快,后者插入删除更灵活;同样是实现查找功能,用哈希表还是二叉树?前者平均时间复杂度能到 O (1),后者却能保证有序性;同样是处理依赖关系,用栈还是队列?前者对应 “后进先出” 的场景(如函数调用栈),后者对应 “先进先出” 的需求(如任务调度)。
数据结构的意义,远不止 “存储数据” 这么简单。它直接影响着代码的效率 —— 时间复杂度与空间复杂度的优化,往往始于对数据结构的深刻理解。一个糟糕的数据结构选择,可能让原本能在毫秒级完成的操作拖慢到秒级;而一个合适的选择,却能让复杂问题迎刃而解。
更重要的是,数据结构是算法的载体。再精妙的算法,也需要依托具体的数据结构才能落地。无论是排序、搜索,还是图论中的路径规划、动态规划中的状态存储,背后都离不开数组、链表、树、图等数据结构的支撑。掌握了数据结构,我们才能真正看懂算法的设计思路,才能在面对实际问题时,找到最优的解决方案。
从今天起,我们将亲手拆解这些 “数据的组织方式”,理解它们的特性、优劣与适用场景。这不仅是为了应对面试中的算法题,更是为了在未来的编程之路上,拥有 “看透问题本质” 的能力 —— 当别人还在纠结代码细节时,你已能从数据结构的层面,规划出最高效的实现路径。
于是乎,在这一篇内容里,我将为大家铺开数据结构中 “链表” 的全景画卷,用最细致入微的保姆级解析,带大家从原理到实践层层拆解。相信当你读完这篇内容,不仅能对链表的精髓了如指掌,更能让对数据结构的认知实现一次清晰的跃升。最后,还是那句话,诸君共勉!!!
一、为什么先学链表?—— 数据结构的 “入门钥匙”
在正式开启链表的学习前,我们不妨先思考一个问题:为什么要把链表作为数据结构的 “第一站”?
回想我们在 C 语言基础中最常用的数据存储方式 —— 数组。它是一块连续的内存空间,能通过下标快速访问元素,但缺点也很明显:固定大小难以扩展(声明后长度无法动态改变)、插入删除效率低(中间插入元素需移动后续所有数据)。
而链表,恰恰是为解决这些痛点而生的动态数据结构。它不需要连续的内存空间,通过指针将零散的节点串联成链,既能灵活调整长度,又能高效地进行插入删除操作。更重要的是,链表的设计思想 ——“通过指针连接离散节点”,是理解后续更复杂数据结构(如树、图)的基础。
可以说,学好链表,就掌握了数据结构中 “动态存储” 与 “指针操作” 的核心逻辑,为后续学习铺就坚实的基石。
链表的概念:
链表,作为计算机科学中最基础也最核心的线性数据结构之一,其设计思想深刻体现了 “以连接代连续” 的智慧。它并非像数组那样依赖一块完整连续的内存空间来存储数据,而是通过指针(在某些语言中称为引用) 这一桥梁,将内存中那些分散、孤立的节点(即数据存储单元)有序地串联起来,最终形成一个逻辑上连续的序列,从而实现对数据的高效存储与访问。
与数组的 “连续存储” 特性形成鲜明对比的是,链表中各个节点的物理存储位置可以是完全离散的 —— 它们可能分布在内存的不同区域,彼此之间没有物理上的紧邻关系。但正是这种 “非连续” 的特性,赋予了链表极大的灵活性:每个节点只需承担两项核心职责,就能让整个结构保持有序性:
- 数据域:这是节点的 “核心内容区”,用于存储具体的业务数据,例如一个整数、一个字符、一组结构体数据,甚至是更复杂的对象。数据域是节点存在的根本意义,直接决定了链表所能承载的信息类型。
- 指针域:这是链表实现 “链式连接” 的关键,它本质上是一个存储了内存地址的变量。通过指针域,当前节点可以 “指向” 其相邻的节点(对于单链表而言,通常是下一个节点;对于双向链表,则同时包含上一个和下一个节点的地址)。正是这些指针的相互指向,将原本零散的节点编织成一个有机的整体,使得我们能够从第一个节点出发,顺着指针依次访问到后续所有节点,形成逻辑上的线性序列。
简单来说,若把数组比作 “一整块连续的地皮,每个房间紧挨着排列”,那么链表就像是 “散落在城市各处的独立房屋,通过一条条路径(指针)相互连接”。前者依赖物理位置的连续性保证顺序,后者则依靠逻辑上的指针关联维持秩序,这正是链表与数组最本质的区别,也奠定了它在动态数据处理场景中的独特价值。
正如上图,1、2、3、4便是链表中的数据,而1、2、3、4下面的地址,就是指针域,通过这些指针(也就是地址),我们才能将一个个数据像链子一样串联起来。
链表的种类:
根据节点的指针设计、连接方式及功能特性,链表可分为多种类型,每种类型都针对特定场景优化了操作效率,以下是详细解析:
1. 单链表(Singly Linked List)
结构核心:
每个节点仅包含 一个指针域(next
),用于指向下一个节点;数据域存储具体信息。链表以 “头节点” 为起点,尾节点的next
指针固定指向NULL
(空指针),作为链表结束的标志。
直观示例:
头节点 → A(数据)→ B(数据)→ C(数据)→ D(数据)→ NULL(尾节点的next)
特性解析:
- 遍历方向:只能从前往后单向遍历(依赖
next
指针),若需访问前一个节点,必须从头重新开始。 - 操作特点:
- 插入节点:若在已知前驱节点后插入,只需修改前驱节点的
next
指针和新节点的next
指针(时间复杂度 O (1));若插入位置未知,需先遍历查找(O (n))。 - 删除节点:需先找到目标节点的前驱节点,再修改前驱节点的
next
指针(O (n),因查找前驱耗时)。
- 插入节点:若在已知前驱节点后插入,只需修改前驱节点的
- 优劣势:
- 优势:结构最简单,每个节点仅需一个指针,内存占用最少,适合数据量小、单向访问的场景。
- 劣势:反向访问困难,删除节点时需额外查找前驱,效率较低。
典型应用:实现栈(仅需在头部操作)、简单队列(尾插首删)、单方向的任务调度列表等。
2. 双向链表(Doubly Linked List)
结构核心:
每个节点包含 两个指针域:next
指针指向下一个节点,prev
指针指向上一个节点;数据域存储信息。头节点的prev
指针和尾节点的next
指针均指向NULL
。
直观示例:
NULL ←(prev)A(数据)→(next)B(数据)←(prev)→(next)C(数据)←(prev)→(next)D(数据)→(next)NULL
特性解析:
- 遍历方向:支持双向遍历(通过
next
向前、prev
向后),可直接获取任意节点的前驱和后继。 - 操作特点:
- 插入节点:需同时修改新节点的
prev
(指向前驱)、next
(指向后继),以及前驱节点的next
和后继节点的prev
(四步操作,O (1),前提是已知插入位置)。 - 删除节点:无需查找前驱(通过
prev
直接获取),仅需修改前驱的next
和后继的prev
(O(1))。
- 插入节点:需同时修改新节点的
- 优劣势:
- 优势:双向访问灵活,删除 / 查找前驱节点效率极高,适合频繁双向操作的场景。
- 劣势:每个节点多一个指针,内存占用比单链表高(约增加 50%),插入 / 删除时指针修改步骤更繁琐。
典型应用:浏览器历史记录(向前 / 向后翻页)、文本编辑器的光标移动(前后定位)、双向队列等。
3. 循环链表(Circular Linked List)
结构核心:
基于单链表扩展,尾节点的next
指针不指向NULL
,而是指向头节点,形成闭合的环形结构;无明确 “首尾” 之分,从任意节点出发均可遍历所有节点。
直观示例:
A(数据)→(next)B(数据)→(next)C(数据)→(next)D(数据)→(next)A(形成循环)
特性解析:
- 遍历特点:遍历需手动控制结束条件(如记录遍历次数,避免无限循环),否则会一直绕环重复访问。
- 操作特点:
- 插入 / 删除节点:逻辑与单链表类似,但需注意尾节点的
next
指针需始终指向头节点(插入 / 删除头节点或尾节点时需特殊处理)。
- 插入 / 删除节点:逻辑与单链表类似,但需注意尾节点的
- 优劣势:
- 优势:适合循环处理场景,从任意节点均可访问全链表,无需判断 “是否到达尾部”。
- 劣势:结构稍复杂,易因指针处理不当导致死循环,反向访问仍需从头遍历。
典型应用:操作系统的进程调度(多个进程轮流占用 CPU,形成循环)、约瑟夫环问题(环形报数淘汰逻辑)、循环缓冲区(数据循环覆盖存储)。
4. 双向循环链表(Doubly Circular Linked List)
结构核心:
结合双向链表和循环链表的特性:每个节点有next
(指向下一个)和prev
(指向上一个)两个指针;尾节点的next
指向头节点,头节点的prev
指向尾节点,形成双向闭合环。
直观示例:
A(数据)←(prev)→(next)B(数据)←(prev)→(next)C(数据)←(prev)→(next)A(双向循环)
特性解析:
- 遍历方向:支持双向循环遍历(顺时针通过
next
,逆时针通过prev
),从任意节点出发,可快速到达头节点或尾节点(如从 B 通过prev
到 A,通过next
到 C,再到 A 形成循环)。 - 操作特点:
- 插入 / 删除节点:需同时维护
next
和prev
的双向指针,确保环的闭合性(插入新节点后,需更新前后节点的双向指向)。
- 插入 / 删除节点:需同时维护
- 优劣势:
- 优势:灵活性最高,支持任意方向的循环访问,适合需要频繁在首尾节点间切换的场景。
- 劣势:结构最复杂,指针维护成本高(每个操作需处理 4 个指针),内存占用最大。
典型应用:复杂的缓存管理(双向循环查找并淘汰过期数据)、高级日程表(循环显示每周 / 每月计划,支持前后切换)。
5. 特殊变种链表
静态链表:
用数组模拟链表结构,节点的 “指针域” 存储数组下标(而非内存地址),通过下标索引实现节点间的 “连接”。适用于不支持指针的编程语言(如早期 Basic),或需要固定内存分配的场景。
例如:数组nodes
中,nodes[i].next
存储的是下一个节点在数组中的索引j
,即nodes[i]
的下一个节点是nodes[j]
。带头节点(哨兵节点)的链表:
在实际数据节点前增加一个 “头节点”(不存储有效数据),其next
指针指向第一个数据节点。目的是简化操作:例如删除首节点时,无需特殊处理空链表的情况(直接修改头节点的next
即可),统一了所有位置的插入 / 删除逻辑。
总结:
不同种类的链表本质是通过调整指针数量(1 个 / 2 个)和指向规则(指向NULL
/ 指向头节点),在 “内存占用”“操作效率” 和 “灵活性” 之间做权衡。选择时需结合场景:单向访问选单链表,双向频繁操作选双向链表,循环处理选循环链表 —— 理解这些差异,才能在实际开发中做出最优选择。
链表的形象化解释:
光看上文的学术性解释,大家可能无法准确理解到链表的概念,那么,我们将链表比作生活中的事物,帮助大家理解,
咱们不妨把链表的结构,想象成一列穿梭在广袤大地上的 “魔法列车”。这列车子最神奇的地方,在于它的 “车厢” 能像变形金刚一样灵活增减 —— 春天播种时,农民需要运输大量种子和农具,调度员就会给列车加挂 5 节货运车厢,每节车厢上都喷着 “春耕专用” 的字样;秋天收割后,物资运输需求减少,又会拆掉 3 节,只留下 2 节客运车厢维持基本通行,车厢门口还贴着 “秋收便民线” 的标识。整个过程中,工人既不用挪动其他车厢的位置,也不用重新规划铁轨,只需要调整要增删的车厢前后的连接装置就行:加挂时,把新车厢的前钩挂上前面车厢的后钩,再把后面车厢的前钩挂上新车厢的后钩;拆除时,只需松开目标车厢前后的挂钩,再把前后的车厢重新连起来。这就像链表的 “动态特性”:每个节点(车厢)都是独立的个体,当我们需要添加或删除节点时,只需修改节点间的指针(连接装置),不用像数组那样在连续的内存空间里 “搬家”—— 数组就像一列固定焊接的火车,要加车厢就得把后面的车厢全拆开重焊,效率自然差了一大截。
再仔细观察每节车厢,会发现它们都有两个 “灵魂部件”:
- 车厢内部的 “核心内容”:可能是坐满了带着草帽的农民,他们正聊着今年的收成;可能是堆着印着 “良种” 字样的麻袋,袋子上还标着 “抗倒伏小麦”“高产玉米” 的品种;也可能是码得整整齐齐的镰刀和锄头,木柄上还留着使用者的手温 —— 这就是链表节点里的 “数据域”,是节点存在的根本意义,用来存储我们真正需要处理的信息。没有数据域,节点就成了空壳子,就像一节没有任何东西的空车厢,对整个列车来说毫无价值。
- 车厢尾部的 “魔法钩子”:这钩子看着普通,是个锈迹斑斑的金属环,却藏着连接的秘密。它不像普通火车的挂钩那样只能固定连接,而是能 “记住” 下一节车厢的位置 —— 哪怕下一节车厢在铁轨的拐角处,甚至暂时停在旁边的侧线里,只要钩子指向那里,就能在列车启动时自动 “牵引” 着下一节车厢跟上。这钩子就相当于链表节点里的 “指针域”,专门用来 “指向” 下一个节点的地址,把原本零散的节点串成一条有序的链。哪怕两节车厢在物理位置上隔了十米远,只要钩子的 “指向” 没错,它们在逻辑上就是紧紧相连的。
为了让你更直观地理解链表的 “遍历逻辑”,咱们来设计一个 “探险任务”:假设这列魔法列车的每节车厢都装了一道 “魔法门”,门是用千年铁木做的,上面刻着复杂的符文,每道门的符文都不一样,对应的开门咒语也各不相同。更特别的是,你作为探险者,手里一开始只有车头第一节车厢的咒语 —— 那是一张泛黄的兽皮卷,上面用古老的文字写着 “开门咒・首节”,这就像我们操作链表时,只能拿到 “头节点” 的地址,其他节点的位置全靠指针指引,就像在迷宫里只能跟着眼前的路标走,看不到全局地图。
那么,你该怎么从车头走到车尾,完成这场探险呢?
别担心,每节车厢里都藏着 “通关线索”。当你用兽皮卷上的咒语打开第一节车厢的门,门 “吱呀” 一声缓缓打开,一股混合着松木和泥土的气味扑面而来。走进去后,你会在车厢墙壁的暗格里发现一张新的羊皮纸,上面用朱砂写着一串咒语 ——“开门咒・次节”,旁边还画着一个小小的箭头,指向第二节车厢的方向,这正是第二节车厢的开门咒。于是你小心翼翼地把兽皮卷留在第一节车厢(反正已经用不上了),拿着羊皮纸走到两节车厢的连接处,对着第二节车厢的门念出咒语,符文瞬间亮起,门 “咔哒” 一声就开了。刚踏进第二节车厢,你又在同样的暗格里找到了第三节车厢的咒语,这张羊皮纸的边缘还沾着些许麦糠,显然是从粮堆里找出来的…… 就这样一节一节地往前探:第三节车厢的暗格里有第四节的咒语,纸是用竹简做的,刻着 “开门咒・末三”;第四节车厢的暗格里躺着第五节的咒语,写在一张丝绸上,绣着 “开门咒・末二”…… 每打开一扇门,就能拿到下一扇门的钥匙,这个过程就像解开一个个连环谜题,每一步都依赖上一步的结果。
直到你走进最后一节车厢,这节车厢里堆着满满的棉花,像是刚从棉田里收割回来。你打开暗格一看,里面没有新的羊皮纸,只有一张粗糙的麻纸,上面用炭笔写着:“前方已无车厢,探险结束。”—— 这就像链表的 “尾节点”,它的指针域指向 “NULL”,就像这张麻纸,清清楚楚地告诉你:“遍历结束,没有下一个节点了。” 到这儿,你就成功从车头走到了车尾,完成了一次完整的 “遍历”。就像你在图书馆里找一套丛书,第一本书的最后一页夹着第二本书的位置条,第二本书里夹着第三本的位置条,直到最后一本书里没有位置条,你就知道这套书全找齐了。
你发现了吗?这个过程和链表的 “遍历” 完全一致:我们从 “头节点”(第一节车厢)出发,通过每个节点的 “指针域”(咒语)找到下一个节点(下一节车厢),直到遇到 “NULL”(空暗格),就知道走到了链表的尽头。哪怕这列火车有 1000 节车厢,每节车厢里的咒语都藏在不同的地方 —— 有的在座位底下,有的在行李架上,有的在货物堆里 —— 只要你有头节点的地址(初始咒语),就能顺着指针(咒语)一直走下去,绝不会迷失方向。这就像一条长长的项链,每个吊坠(节点)上都系着指向 next 个吊坠的绳子(指针),只要握住第一个吊坠,就能顺着绳子摸到最后一个。
更妙的是,这列魔法列车的每节车厢都很 “健忘”—— 它们只需要记住 “下一节车厢的咒语”,不用管前一节车厢在哪儿,也不用管整列火车一共有多少节。第一节车厢不知道自己是 “头”,最后一节车厢也不知道自己是 “尾”,每节车厢都只专注于 “连接下一节” 这个简单的任务。就像链表的每个节点,只需要存储 “下一个节点的地址”,不用关心整个链表的长度。这种 “轻记忆” 的特性,让链表在内存使用上极其灵活,尤其适合那些数据总量不确定的场景 —— 比如电商平台的订单列表,你永远不知道下一秒会新增多少订单,可能是 10 单,也可能是 1000 单,用链表来存储就再合适不过,新增订单时只需 “加挂” 一个节点,完全不用提前预留空间。
咱们再说说 “加挂车厢” 的操作。假设现在要在第二节和第三节车厢之间加一节 “补给车厢”,让探险者能在中途补充水和食物,车厢里放着水桶、面包和应急药品。该怎么做呢?
- 第一步,你得先找到第二节车厢的暗格,把里面原本写着 “第三节车厢咒语” 的羊皮纸拿出来,换成一张新的 —— 上面写着 “开门咒・补给”,这就相当于修改第二节节点的指针域,让它指向新节点(补给车厢)。
- 第二步,在补给车厢的暗格里放上那张原本属于第三节车厢的羊皮纸,让走进补给车厢的人能继续前往第三节车厢 —— 也就是让新节点的指针域指向原来的第三节节点。
这么一来,当你从第二节车厢出发,会先走进补给车厢,拿起一瓶水和一块面包,再用暗格里的咒语打开第三节车厢的门,整个顺序丝毫不乱。这就是链表的 “插入操作”:只需要修改两个指针的指向,新节点就能稳稳地 “嵌” 进链表,其他车厢完全不用动。相比之下,数组要在中间插入元素,得把后面所有元素都往后挪,就像把第三节车厢及以后的车厢都往后推一节,还得重新铺铁轨,多费劲啊!
反过来,如果要 “拆除车厢”,比如把第三节车厢拆掉 —— 因为它的轮子出了故障,摇摇晃晃的很危险,需要送去维修。步骤也很简单:
- 你先打开第二节车厢的暗格,把里面的咒语换成 “第三节车厢暗格里的咒语”(也就是第四节车厢的咒语)—— 相当于修改第二节节点的指针域,让它直接指向第四节节点。
- 然后把第三节车厢的暗格清空,把里面的咒语烧了(避免被误用),再松开它和前后车厢的连接钩 —— 也就是释放节点占用的内存,让它彻底脱离链表,被调去维修厂。
从此以后,从第二节车厢出发,会直接走到第四节车厢,就像第三节车厢从未存在过一样。这就是链表的 “删除操作”:只需要修改一个指针的指向,就能把目标节点 “摘” 下来,效率比数组高太多。数组删除元素就像把第三节车厢拆了,却发现后面的车厢都悬在半空,必须一节节往前挪才能重新落地,光是想想就觉得麻烦。
这列魔法列车还能根据需求 “升级改造”,衍生出各种 “变种车型”:
- 如果每节车厢的暗格里,不仅有下一节的咒语,还有上一节的咒语(相当于节点同时有 “前指针” 和 “后指针”),那它就成了 “双向链表”。这种链表就像带了 “倒车功能”,你既能从车头走到车尾,也能从车尾倒着走回车头 —— 走到第五节车厢时突然想起第三节车厢有件东西没拿,不用从头再走一遍,直接用第四节的 “前指针咒语” 退回第四节,再用第三节的 “前指针咒语” 退回第三节,就像我们在手机里翻聊天记录,既能往下滑看新消息,也能往上滑看旧消息,非常方便。
- 如果最后一节车厢的暗格里,放的不是 “没有下一节” 的便签,而是第一节车厢的咒语(尾节点指针指向头节点),那它就成了 “循环链表”。这种链表没有起点也没有终点,就像一个环形跑道,你从第一节车厢出发,一节节走下去,最后又会回到第一节车厢,永远走不完。这在需要 “循环处理” 的场景中特别有用,比如操作系统的进程调度:每个进程就像一节车厢,CPU 轮流处理各个进程,处理完最后一个进程后,自动回到第一个进程继续处理,就像列车绕着环形铁轨不断循环。
- 还有一种 “双向循环链表”,它既有双向链表的 “前后指针”,又有循环链表的 “首尾相连”,就像一列能双向行驶的环形列车。你既能从车头顺行到车尾,也能从车尾逆行到车头,走到最后一节车厢时,既能往前回到第一节,也能往后退回倒数第二节,灵活性更高,但也更复杂一些 —— 就像一座环形的天桥,既能顺时针走,也能逆时针走,每个入口都能通往其他所有入口,但需要记的路线也更多。
你看,这列 “魔法列车” 的设计,把链表的核心逻辑讲得明明白白:用 “节点”(车厢)存数据,用 “指针”(咒语 / 挂钩)连关系,既能灵活增删,又能高效遍历。理解了这列火车的运作方式,你就抓住了链表的 “本质”—— 它不是简单的 “数据堆”,而是 “数据 + 连接” 的有机整体,连接的逻辑甚至比数据本身更重要。
创建链表:
知道了链表之后,我们就要先创建链表,经过上文,我们知道,链表由数据域和指针域组成,那么什么类型的数据能把这两个都包含呢?很显然,那就是结构体,那么我们要怎么创建呢?,我们直接看下面这个代码:
typedef int name1;
//将int重命名为name1,方便后面换成其它类型数据时直接修改//链表是一种物理存储结构上非连续、非顺序的存储结构,
//数据元素的逻辑顺序是通过链表中的指针链接次序实现的
//链表的结构跟火车车厢相似,淡季时车次的车厢会相应减少,
//旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉 / 加上,
//不会影响其他车厢,每节车厢都是独立存在的。
//车厢是独立存在的,且每节车厢都有车门。
//想象一下这样的场景,假设每节车厢的车门都是锁上的状态,
//需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?//最简单的做法:每节车厢里都放一把下一节车厢的钥匙。//与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,
//我们称之为“结点 / 节点”
//节点的组成主要有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)。
//图中指针变量 plist保存的是第一个节点的地址,
//我们称plist此时“指向”第一个节点,如果我们希望plist“指向”第二个节点时,
//只需要修改plist保存的内容为0x0012FFA0。
//为什么还需要指针变量来保存下一个节点的位置?
//链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间)
//,我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点。
//结合前面学到的结构体知识,我们可以给出每个节点对应的结构体代码://链表是由一个个节点组成的,而这个节点就是结构体//在链表中,如下我们所设置的结构体变量,其实它就相当于一个节点,
//对于这个结构体变量,我们要在里面设置两个成员变量,
//第一个成员变量,我们叫它data,它的作用就是存储链表中的数据内容,
//比如整型、结构体等等
//通俗易懂一点,它就类似我们之前所学的顺序表中的arr数组,用来存储内容
//而第二个成员变量,那么就是指针变量了,这个指针变量的作用是什么呢?
//因为我们上文有提到,我们要在每个车厢中放下一个车厢的钥匙,这个样子才更加方便
//那么我们所设置的这个成员指针变量,就相当于是那把钥匙,
//经过上面的类比,我们可以知道车厢相当于节点(也就是我们所设置的结构体变量)
//而钥匙相当于我们设置的指针变量,第一个成员变量相当于车厢内的乘客
//而我们都知道,在c语言中,想要快速找到(定位)到一个变量,那毋庸置疑的就是使用地址
//地址就是我们的一个利器,因此,打开下一个车厢的钥匙,就是下一个节点的地址
//于是,为了能够实现在上一个节点(车厢)就能有下一个节点(车厢)的钥匙(地址)
//我们就必须得在上一个结构体变量(节点)中拥有下一个结构体变量(节点)的地址
//而这,就需要我们的第二个成员变量,同时也是结构体的自引用
//于是,我们的这第二个成员变量,就得是我们设置的结构体变量(节点)类型指针
//我们得取这个结构体变量(节点)类型的指针(地址),因为它就相当于是一个节点的地址(钥匙)
//我们用这个变量去等于下一个节点的地址,这么一来,两个节点就建立了联系,也就是链表
struct SList
{name1 data;//节点数据struct SList* next;//指针变量用保存下一个节点的地址
};typedef struct SList slistnode;
//将该结构体变量(节点)重命名
在上面的代码中,struct SList
结构体包含了一个 int
类型的数据域 data
和一个指向 struct SList
类型的指针域 next
。next
指针用于指向下一个节点,从而将多个节点串联起来形成链表。通过这种方式,我们就可以利用结构体来创建链表的节点,进而构建整个链表。
创建新节点:
知道了如何创建链表之后,我们也知道了,其实结构体就是节点,而链表中肯定不止一个节点,那这也就意味着这我们要创建多个节点,那么,我们要如何创建新节点呢?重复写结构体?肯定不行,下面,就由我来揭晓:在 C 语言中,我们可以通过动态内存分配来创建新节点,而不是重复写结构体。
要创建链表的新节点,核心思路围绕动态内存分配与节点初始化展开,步骤清晰且每一步都有明确目的:
1. 动态申请内存
链表的节点需要在程序运行时 “按需创建”,因此借助 C 语言的 malloc
函数,向系统请求一块 ** 大小等于一个链表节点(结构体)** 的内存空间。这样做的原因是,节点的创建不是预先静态定义好的,而是根据实际需要动态生成,malloc
能灵活地从内存的 “堆区” 分配出合适的空间。如果内存分配成功,malloc
会返回这块空间的起始地址,我们用指针(如 newnode
)来接收这个地址,后续就通过该指针操作新节点;若分配失败(比如内存不足),malloc
返回 NULL
,不过通常在简单示例中会先假设分配成功。
2. 初始化数据域
新节点的核心作用是存储数据,所以要把需要存入链表的数据(这里用 x
表示,它可以是整数、字符等各种类型,取决于链表设计)赋值给节点的数据域(比如代码里的 newnode->data
)。这一步让新节点有了 “实际内容”,成为链表中承载业务数据的基本单元。
3. 初始化指针域
节点的指针域(比如 newnode->next
)用于指向链表中的下一个节点,从而形成 “链式” 结构。在创建新节点时,先将其指针域初始化为 NULL
,这是一种规范且实用的做法:
- 若后续要进行尾插操作,新节点会成为链表的最后一个节点,此时它的
next
指针自然应该指向NULL
,表示没有后续节点; - 若后续要进行头插操作,再将这个
NULL
替换为原来头节点的地址即可,这样的初始化方式为不同插入场景提供了统一的起点,避免了指针未初始化导致的不可预测错误。
4. 返回新节点地址
最后,将新节点的地址(即 malloc
返回的地址,由 newnode
指向)返回。这样,在链表的其他操作(比如插入节点到链表中、构建链表等)中,就能通过这个返回的指针来找到并操作这个新创建的节点,让新节点能顺利融入到整个链表的结构中。
下面是详细代码:
//实现创建一个新节点的函数
slistnode* newslist(name1 x)
{//创建一个新节点,最后也是直接返回这个节点的地址(指针),slistnode* newnode = (slistnode*)malloc(sizeof(slistnode));//同样要申请的空间大小为一个节点所占的空间节点//将我们要插入的数据传进我们新创建的节点中newnode->data = x;//要记得把这个新节点中的钥匙(下一个节点的地址)赋值为NULLnewnode->next = NULL;//这一步是为了尾插准备的,也是一个良好的编程习惯//但是在头插之中,我们就得对newnode->next进行重新赋值,//赋值为原先第一个节点的地址//不要忘记把这个新节点地址返回return newnode;}
由此,我们便解决了创建新节点的小问题。
对于链表的尾部插入:
创建了链表并知道怎么创建新节点之后,我们就可以对链表进行数据插入了,插入方式有很多种,本部分,我们来讲尾部插入法:
现链表的尾插操作,本质是将新数据对应的节点添加到链表的最后位置,让其成为新的尾节点。整个过程需要兼顾 “空链表” 和 “非空链表” 两种情况,同时确保操作能真正影响到原链表,具体细节如下:
1. 前置检查:确保操作的基础有效
尾插操作需要通过一个特殊的参数来关联原链表的头部(以便在必要时修改头部),这个参数必须是有效的 —— 如果这个参数本身无效(比如为空),后续所有操作都无从谈起。因此,第一步要先确认这个用于关联链表头部的参数是合法的,避免后续操作出现不可预知的错误。
2. 准备新节点:创建待插入的 “数据载体”
链表的每个节点都是独立的 “数据单元”,尾插的核心是添加一个新的单元。因此需要先创建一个新节点:
- 这个节点要存储待插入的数据(即我们想要添加的值),使其成为有实际意义的节点;
- 同时,这个新节点在刚创建时,暂时没有后续节点,所以要明确标记它 “后面没有节点”(这是尾节点的特征,也为后续连接做准备)。
3. 分情况处理:空链表与非空链表的不同逻辑
由于链表可能处于 “空”(没有任何节点)或 “非空”(已有节点)两种状态,尾插的具体操作会不同,必须分别处理:
情况一:链表为空(没有任何节点)
此时链表的 “尾部” 就是 “头部”(因为不存在任何节点),新节点会同时成为链表的第一个节点和最后一个节点。
- 操作逻辑:直接让链表的头部指向这个新节点。这样一来,原本空的链表就有了第一个节点,且这个节点自然是尾节点(因为它后面没有其他节点)。
- 关键:这里必须通过前面提到的 “特殊参数” 来修改链表的头部,否则无法真正改变原链表(因为普通的参数传递无法影响外部的链表头部)。
情况二:链表非空(已有节点)
此时需要先找到当前链表的最后一个节点,再把新节点接在它后面:
- 寻找尾节点:从链表的头部开始,顺着节点之间的连接依次向后 “遍历”(即从第一个节点开始,依次找到下一个节点),直到找到那个 “后面没有节点” 的节点(这就是当前的尾节点)。
- 遍历过程中,要使用一个临时的 “指针” 来移动,不能直接移动链表的头部(否则会丢失整个链表的起始位置)。
- 连接新节点:让找到的尾节点与新节点建立连接(即让原尾节点 “指向” 新节点)。这样,新节点就成了新的尾节点,原尾节点则变成了新尾节点的前一个节点。
如下是实现尾插的详细代码:
//实现对链表的尾插的函数
void slistpushback(slistnode** pphead, name1 x)
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//C 语言函数参数传递是 “值传递”——phead是头指针的副本(拷贝)。//当链表为空时(phead原本是NULL),函数内修改phead = newnode,//只会改变这个副本的值,不会影响外部真正的头指针(外部头指针依然是NULL),//导致尾插失败。//二级指针pphead的类型是slistnode * *,//它存储的是 “头指针变量的地址”。通过它可以直接修改头指针本身的值//pphead等价于外部的头指针(如plist),//修改* pphead就是直接修改外部头指针的值。//当链表为空时(* pphead == NULL),//必须通过二级指针才能让外部头指针指向新节点(否则头指针永远是NULL)。//为了统一处理 “空链表” 和 “非空链表” 两种情况,二级指针是最简洁的设计。//slistnode** pphead 相当于是一个变量的地址的指针(即一个变量的指针的指针)//slistnode* pphead 相当于是一个变量的地址(即一个变量的指针)//因为我们进行尾插,那么也就代表链表一定要有一定的空间//链表不像顺序表,我们要对链表插入数据,那我们就必须创建一个新节点//并把该节点加到原本链表的后面,同时让原本最后一个节点的钥匙(next)//指向我们新加入节点的地址//所以我们可以直接再设计一个函数去创建新节点,//同时把我们要输入的数据一并输入到那个新创建的节点中//这么一来就可以在后续的头插等插入直接调用,不用重复书写//但是与顺序表不同,顺序表中的申请新空间的函数是不需要返回值的//因为它是直接就在内部修改了//但是链表的该函数就需要有返回值,要去返回我们新创建的那个节点的地址slistnode* newnode = newslist(x);//我们上述所说的要用二级指针就是用于下面的第一个节点要是为空的情况//因为要是第一个节点就是为空的话,那我们想要对第一个节点变为我们创建的新节点//就得从根本上去改变那第一个节点,也就是将第一个节点的地址改为我们新节点的地址//这样子才算是使用了传址调用//如果第一个节点的地址变为NULL(即第一个节点为空)(空链表)//我们可以类比我们之前学的传值调用和传址调用。if ((*pphead) == NULL){//因为第一个节点为空,那么我们所创建的新节点,便需要赋值给该链表的第一个节点//即让我们创建的新节点变为链表的第一个节点*pphead = newnode;//注意了注意了,因为我们在test.c中是传入我们所开辟链表的第一个节点的地址的地址//所以可以像上述那样直接传址调用修改,这样子到最后第一个节点的地址能被顺利改变}//如果没有进入上面的if语句,进入到下面的else语句(非空链表)//那就代表第一个节点不为空,那我们就可以使用循环去找到链表的最后一个节点,//然后把我们创建的新节点接到那个节点之后else{//设置一个新变量去遍历链表,避免我们的头指针遭到破坏slistnode* temp = *pphead;//这个循环的作用是去找到最后一个节点,判断的前提条件//就是最后一个节点的钥匙(next)的值是为NULLwhile (temp->next != NULL){//如果一直没到上述我们所说的,就一直去往后走,进入下一个节点temp = temp->next;}//最后,我们要把我们所找到的原先链表的最后一个节点中的钥匙(next)//指向我们新创建的节点的地址,此时temp指向的就是新链表的尾节点temp->next = newnode;//还是因为newnode本身就算是一个地址,所以我们不用取地址}
}
下面我们再来详细的解释一下上面的代码,
一、函数参数设计的底层逻辑(为什么是 slistnode**pphead
而不是 slistnode* phead
)
C 语言的 “值传递” 本质:函数调用时,参数会被拷贝一份。假设用一级指针
phead
接收头指针,这个phead
只是外部头指针(比如plist
)的 “复制品”。- 比如外部
plist
原本是NULL
(空链表),在函数里执行phead = newnode
,修改的只是复制品phead
,外部plist
依然是NULL
—— 尾插完链表还是空的,彻底失败。 - 这就像你给朋友寄了一张自己家的照片(复制品),朋友在照片上涂鸦,你家不会真的变脏。要让朋友能修改你家的状态,必须给他你家的 “地址”(二级指针的作用)。
- 比如外部
二级指针的 “穿透性”:
pphead
存储的是 “外部头指针变量的地址”。比如调用slistpushback(&plist, x)
时,pphead
里存的是plist
这个变量在内存中的位置。- 因此,
*pphead
就等价于plist
本身(通过地址找到变量)。修改*pphead = newnode
,就相当于直接修改plist = newnode
,能真正改变外部头指针的指向 —— 这才是 “传址调用” 的核心,确保空链表时头指针能被正确更新。
- 因此,
二、前置检查:assert(pphead)
的必要性
- 假设有人错误调用函数,传入
NULL
作为pphead
(比如slistpushback(NULL, 10)
),后续执行*pphead
就会变成 “对NULL
解引用”,这在 C 语言中是致命错误(程序崩溃)。 assert(pphead)
就像一道安检门,提前拦截这种错误调用,确保函数能在 “有意义的参数” 基础上运行。
三、创建新节点:newnode = newslist(x)
的深层作用
- 尾插的本质是 “增加一个节点到链表末尾”,所以必须先有这个 “新节点”。
newslist(x)
函数会:- 向系统申请一块刚好能放下
slistnode
结构体的内存(通过malloc
); - 把数据
x
存入新节点的数据域(newnode->data = x
); - 关键:把新节点的
next
指针初始化为NULL
。- 这步是为 “尾节点” 身份做准备 —— 链表的最后一个节点,
next
必须指向NULL
才符合链表的结束规则。如果不初始化,next
可能是随机值,后续遍历链表时会找错尾节点(甚至访问非法内存)。
- 这步是为 “尾节点” 身份做准备 —— 链表的最后一个节点,
- 向系统申请一块刚好能放下
四、分情况处理的底层原因(空链表 VS 非空链表)
链表只有两种状态,处理方式完全不同,必须严格区分:
情况 1:空链表(*pphead == NULL
)
- 此时链表中没有任何节点,“尾部” 就是 “头部”(因为根本没有节点)。
- 操作逻辑:让头指针直接指向新节点。
- 为什么用
*pphead = newnode
?因为*pphead
就是外部头指针(如plist
),这样赋值后,外部头指针从NULL
变成指向新节点,链表从空变为有一个节点(新节点既是头也是尾)。 - 举例:外部
plist
原本是NULL
,执行后plist
指向newnode
,newnode->next
是NULL
—— 完美符合链表规则。
- 为什么用
情况 2:非空链表(*pphead != NULL
)
此时链表已有节点,需要先找到 “当前的最后一个节点”,再把新节点接在它后面。
子步骤 1:遍历找尾节点(
temp
指针的作用)- 为什么用
temp
而不是直接用*pphead
遍历?
因为*pphead
是头指针,若直接移动它(*pphead = (*pphead)->next
),会导致头指针丢失,再也找不到链表的起点 ——temp
就像 “探路者”,代替头指针去遍历,保护头指针的原始位置。 - 遍历终止条件:
temp->next != NULL
- 当
temp->next
是NULL
时,说明temp
后面没有节点了,temp
就是当前的尾节点。 - 举例:链表是
A->B->C->NULL
,temp
从A
开始,A->next
是B
(非空)→temp
移到B
;B->next
是C
(非空)→temp
移到C
;C->next
是NULL
→ 停止,temp
指向C
(尾节点)。
- 当
子步骤 2:连接新节点(
temp->next = newnode
)- 尾节点的
next
原本是NULL
,现在让它指向新节点,新节点就成了 “新的尾节点”。 - 新节点的
next
已经是NULL
(创建时初始化的),所以新链表的结尾依然符合NULL
规则(如A->B->C->newnode->NULL
)。
- 为什么用
五、隐藏的细节:为什么不用检查 newnode
是否为 NULL
?
newslist(x)
函数内部用malloc
申请内存,若内存不足,malloc
会返回NULL
,导致newnode
是NULL
。- 实际开发中应该检查(比如
if (newnode == NULL) { /* 处理内存分配失败 */ }
),但示例代码可能简化了这一步。理解时要知道:真实场景必须考虑内存分配失败的情况。
总结:尾插的本质是 “动态延伸链表的尾部”
整个过程围绕两个核心目标:
- 空链表时,让新节点成为第一个节点(通过二级指针修改头指针);
- 非空链表时,找到当前尾部并接上新节点(通过遍历定位尾部,修改其
next
指针)。
每一步设计都在规避 C 语言的 “值传递陷阱” 和链表的 “结构完整性规则”,确保插入后链表依然是逻辑连贯的线性结构。
到这里,我们便知道了如何实现对链表的尾插。
对于链表的头部插入:
知晓了尾插之后,本部分就来讲一下对于链表的头部插入,即头插法:
实现链表的头插操作,核心是让新节点成为链表的第一个节点,同时保持链表的连贯性,具体步骤和逻辑细节如下:
1. 前置检查:确保操作的合法性
头插操作需要通过一个特殊参数关联原链表的头部(用于修改头部指向),第一步必须确认这个参数是有效的。如果参数无效(比如为空),后续操作会失去目标,导致错误,因此这一步是操作的基础保障。
2. 创建新节点:准备待插入的 “新头部”
头插的本质是在链表最前面增加一个节点,因此需要先创建一个新节点:
- 这个节点要存储待插入的数据,使其成为有实际意义的 “数据载体”;
- 新节点创建时,暂时不与其他节点连接(即初始状态下 “后面没有节点”),为后续的连接操作留足空间。
3. 分情况处理:空链表与非空链表的统一逻辑
头插操作需要兼顾链表是否为空的两种情况,但核心目标一致 —— 让新节点成为链表的第一个节点:
情况一:链表为空(没有任何节点)
此时链表原本没有第一个节点,新节点自然成为第一个节点。
- 操作逻辑:直接让链表的头部指向这个新节点。由于链表为空时,头部原本不指向任何节点,更新后头部就唯一指向新节点,新节点成为链表的起点,且因没有后续节点,符合链表的基本结构。
情况二:链表非空(已有节点)
此时链表已有第一个节点,需要让新节点 “挤到前面”,成为新的第一个节点,同时不能破坏原有的链表结构:
- 第一步:让新节点与原第一个节点建立连接。即让新节点 “指向” 原第一个节点,这样原链表的所有节点就都跟在新节点后面,保证原有节点不会丢失。
- 第二步:更新链表的头部信息。让链表的头部从指向原第一个节点,改为指向新节点。这一步是头插的关键 —— 通过更新头部,新节点正式成为链表的第一个节点,而原第一个节点则退为第二个节点,整个链表的顺序被正确调整。
关键逻辑总结
头插的核心是 “让新节点位于最前”,无论链表是否为空,最终都要通过关联头部的参数,将新节点设为链表的起点:
- 空链表时,头部直接指向新节点;
- 非空链表时,先让新节点连接原第一个节点,再让头部指向新节点。
整个过程既保证了新节点成为第一个节点,又通过节点间的连接关系,确保原链表的所有节点都能被新头部 “牵引”,维持链表的完整性。
下面是实现头插的详细代码:
//实现对链表的头插的函数
void slistpushfront(slistnode** pphead, name1 x)
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//其实头插的原理和尾插的原理类似,所以它也得用二级指针(地址)//如果是空链表(第一个节点就为空的话),//那我们就把我们新创建的节点地址赋值给该链表的第一个节点//如果不是空链表,那我们就把我们创建的新节点接到原链表第一个节点的前面//让这个新节点中的钥匙(地址)指向上述的第一个节点slistnode* newnode = newslist(x);//因为我们要对原链表第一个节点直接进行修改,所以我们得直接调用ppheadif (*pphead == NULL)//为空链表{*pphead = newnode;//使用传址调用,直接修改第一个节点地址(被覆盖为新节点地址)}else//不为空链表{//这里也是一个关键点,我们要让我们创建的新节点的钥匙指向原先链表的//第一个节点,意思就是要让那把钥匙存储着第一个节点的地址//由于我们是创建了二级指针,所以可以直接让新节点的next对于第一个节点的地址//也就是如下操作newnode->next = *pphead;//虽然在上面的函数(newslist)中,我们已经给//newnode中的钥匙指向NULL,但是我们还是可以修改//接下来,因为我们是头插,所以肯定得让我们的新节点变为新链表的第一个节点//而这就需要我们让新节点的地址变为*pphead*pphead = newnode;//注意了注意了,因为我们在test.c中是传入我们所开辟链表的第一个节点的地址的地址//所以可以像上述那样直接传址调用修改,这样子到最后第一个节点的地址能被顺利改变}//其实,对于头插,我们不用if判断是不是空链表也行,//因为在那else中,我们有看到新节点地址赋值给链表中第一个节点的地址//这一步其实就是if中的操作,二者等效,不像尾插那样//尾插需要是因为如果第一个节点就是空的话,那那个节点是没有资格//存储我们所创建的新节点的地址的//当然,在这边其实我还是建议用if判断一下,更好理解,//后面写多了,熟练了,就可以不用了
}
下面我们再来详细的解释一下上面的代码:
一、函数参数设计的底层逻辑(为什么是 slistnode** pphead
而不是 slistnode* phead
)
C 语言的 “值传递” 特性决定了:函数参数会被复制一份。若用一级指针 phead
接收头指针,这个 phead
只是外部头指针(如 plist
)的 “复制品”。
- 例如,外部
plist
指向原链表的第一个节点,在函数内执行phead = newnode
,修改的只是复制品phead
,外部plist
仍指向原节点 —— 新节点无法成为新的头节点,头插彻底失败。 - 这就像你给朋友一张家门钥匙的照片(复制品),朋友修改照片上的钥匙形状,你家的真钥匙不会有任何变化。要让朋友能换你家的门锁,必须给他你家的地址(对应二级指针的作用)。
二级指针的 “直接修改能力”:pphead
存储的是 “外部头指针变量的地址”(如 &plist
)。通过 *pphead
能直接操作外部头指针本身 —— 修改 *pphead = newnode
等价于 plist = newnode
,真正改变外部头指针的指向,确保新节点能成为链表的第一个节点。
二、前置检查:assert(pphead)
的必要性
若函数被错误调用(如传入 NULL
作为 pphead
),后续执行 *pphead
会变成 “对 NULL
解引用”,这在 C 语言中是致命错误(程序会崩溃)。
assert(pphead)
如同 “安全闸”,在操作开始前拦截无效参数,确保函数在 “合法的基础” 上运行,避免无意义的错误。
三、创建新节点:newnode = newslist(x)
的深层作用
头插的核心是 “在链表最前增加节点”,因此必须先创建承载数据的新节点。newslist(x)
函数会:
- 向系统申请一块刚好能容纳
slistnode
结构体的内存(通过malloc
); - 将数据
x
存入新节点的数据域,使其成为有实际意义的 “数据单元”; - 关键:将新节点的
next
指针初始化为NULL
—— 这是临时状态,后续会根据链表是否为空,修改为指向原头节点或保持不变,但初始化为NULL
能避免指针指向随机内存导致的混乱。
四、分情况处理的底层原因(空链表 VS 非空链表)
头插需应对两种链表状态,操作逻辑虽有差异,但最终目标一致:让新节点成为链表的第一个节点。
情况 1:空链表(*pphead == NULL
)
此时链表没有任何节点,新节点自然成为第一个节点。
- 操作逻辑:直接让
*pphead = newnode
(即外部头指针指向新节点)。 - 为何有效?因为
*pphead
就是外部头指针(如plist
),赋值后,原本指向NULL
的头指针变为指向新节点,链表从空变为包含一个节点(新节点既是头也是尾),且newnode->next
为NULL
,符合链表规则。
情况 2:非空链表(*pphead != NULL
)
此时链表已有第一个节点,需让新节点 “前置” 并成为新的头节点,同时保留原链表的所有节点。
子步骤 1:让新节点连接原头节点(newnode->next = *pphead
)
- 原头节点是
*pphead
指向的节点(如节点 A),让新节点的next
指向 A,相当于将新节点与原链表 “挂钩”—— 新节点后面紧跟着原链表的所有节点,避免原节点丢失。
子步骤 2:更新头指针指向新节点(*pphead = newnode
)
- 这是头插的 “点睛之笔”:通过修改外部头指针,使其从指向原头节点(A)改为指向新节点。此时新节点成为链表的第一个节点,原头节点(A)退为第二个节点,整个链表顺序被正确调整(新节点→A→B→...)。
五、隐藏的细节:为何两种情况可合并处理?
头插的两种情况(空与非空)本质上可统一:
- 空链表时,
*pphead
是NULL
,执行newnode->next = *pphead
等价于newnode->next = NULL
,再执行*pphead = newnode
依然正确; - 非空链表时,上述两步操作同样成立。
因此,实际代码中可省略if
判断,直接执行 “连接原头节点→更新头指针” 两步,逻辑更简洁 —— 这体现了头插操作的灵活性,与尾插必须区分情况的特性形成对比。
总结:头插的本质是 “重置链表的起点”
整个过程围绕 “让新节点成为链表第一个节点” 展开:
- 通过二级指针确保能修改外部头指针;
- 先让新节点挂钩原链表(空链表时自然挂钩
NULL
),再将头指针指向新节点。
每一步都在规避 “值传递陷阱”,同时确保原链表节点不丢失,最终形成 “新节点→原头节点→...→尾节点” 的完整结构。
到此,对于链表的头部插入大功告成。
对于链表的尾部删除:
知道了对于的链表的尾部插入以及头部插入之后,怎么能少了尾部删除以及头部删除呢?本部分,我们就来讲一下如何实现对于链表的尾部删除:
实现链表的尾删操作,核心是安全移除最后一个节点并维持链表的完整性,需要兼顾 “边界情况” 和 “内存安全”,具体逻辑拆解如下:
一、参数与状态检查:操作的前提保障
尾删的第一步是确保 “有东西可删” 且 “操作能生效”,因此需要双重检查:
- 二级指针有效性检查:通过断言确保传递的二级指针(关联头指针的参数)不为空。若该参数为空,后续修改头指针的操作会直接崩溃,这是基础防护。
- 链表非空检查:通过断言确保链表本身不为空(即头指针指向有效节点)。如果链表已空(头指针为
NULL
),再执行删除操作毫无意义,反而会导致错误(如访问空指针)。
二、分情况处理:节点数量决定操作逻辑
尾删的核心是 “找到最后一个节点并删除”,但节点数量不同(1 个节点 vs 多个节点),操作逻辑差异很大,必须严格区分:
情况 1:链表只有 1 个节点(头节点就是尾节点)
此时头节点的 “下一个节点指针” 为空(因为它是最后一个节点),删除后链表会变为空,操作需两步:
- 释放节点内存:直接释放这个唯一节点的内存(避免内存泄漏)。
- 更新头指针为 NULL:由于链表已空,必须通过二级指针将外部头指针改为
NULL
,否则头指针会变成 “野指针”(指向已释放的内存),后续操作会出问题。
情况 2:链表有多个节点(需定位倒数两个节点)
此时需要找到最后一个节点(待删除)和倒数第二个节点(删除后成为新尾节点),步骤如下:
双指针遍历定位:
- 定义两个临时指针,一个用于跟踪 “当前节点”(最终会指向最后一个节点),另一个用于跟踪 “当前节点的前一个节点”(最终会指向倒数第二个节点)。
- 从链表头部开始遍历:每次让 “前一个节点指针” 先跟上 “当前节点指针” 的位置,再让 “当前节点指针” 向后移动一个节点。
- 遍历终止条件:当 “当前节点指针” 的 “下一个节点指针” 为空时,它就是最后一个节点,而 “前一个节点指针” 自然就是倒数第二个节点。
删除最后一个节点:
- 释放 “当前节点指针” 指向的最后一个节点的内存(彻底移除)。
- 将 “前一个节点指针” 的 “下一个节点指针” 设为
NULL
,使其成为新的尾节点(符合链表 “尾节点指针为空” 的规则)。
三、关键细节:为什么需要跟踪倒数第二个节点?
尾删的核心不仅是 “删掉最后一个节点”,更要 “让倒数第二个节点成为新的尾节点”。如果只找到最后一个节点并删除,倒数第二个节点的 “下一个指针” 依然指向已释放的内存(野指针),会导致后续遍历链表时访问非法内存。
因此,必须通过双指针遍历,提前记录倒数第二个节点的位置,才能在删除最后一个节点后,正确更新其 “下一个指针” 为NULL
,保证链表结构的完整性。
总结:尾删的本质是 “安全剥离尾部并更新链尾”
整个过程围绕两个核心目标:
- 准确找到待删除的尾节点及其实验室(倒数第二个节点);
- 释放尾节点内存后,确保新尾节点的指针符合链表规则(指向
NULL
),同时处理好 “单节点删除后链表为空” 的边界情况。
每一步都兼顾了内存安全(释放内存、避免野指针)和结构完整(更新尾节点指针),最终使链表在删除尾部后依然保持逻辑连贯。
下面是实现尾插的详细代码:
//实现对链表的尾删的函数
void slistpopback(slistnode** pphead)//需要借助循环去找到最后一个节点
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//因为要进行尾删,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,//那还删什么assert(*pphead);//因为我们要进行尾删,其实也就是把链表中的最后一个节点给它删掉//意思就是要把我们曾经为那最后一个节点开辟的空间给释放掉//所以就得用到free和赋值为NULL//但是在这之前,我们得先去找到那最后一个节点//所以就得用到while()循环了//值得一提的是为了不破坏我们的原指针,所以我们得创建新变量去代替我们的原指针//但是呢,还有一个注意点,那就是我们得把那倒数第二个节点中的钥匙赋值为NULL//这样倒数第二个才算是成为了新链表的最后一个节点//那我们要如何实现这个步骤呢?//这就需要我们再创建一个变量,用这个变量可以去指代那倒数第二个节点//注意,我们要考虑到链表中只有一个节点的情况,那我们就要直接释放那一个节点//只有一个节点时if ((*pphead)->next == NULL)//注意操作符优先性,->大于*,所以我们要加上括号{free(*pphead);*pphead = NULL;}//为什么我们要考虑到只有一个节点的情况呢?//其实和下面的pucr有关,因为我们知道,pucr最后是要指向倒二个节点的//那要是链表只有一个节点的话,pucr该何去何从//因此,我们需要考虑到这一点else//代表不是只有一个节点{slistnode* pucr = *pphead;//二级指针要记得解引用为一级指针slistnode* ptail = *pphead;while (ptail->next != NULL){pucr = ptail;ptail = ptail->next;}//这两步就是找到单链表的尾节点,并记录尾节点的前一个节点//当我们的pucr记录到倒数第二个节点的地址时//很明显,ptail就到了最后一个节点中存储的地址(为NULL)//这时候循环结束,pucr为倒数第二个的地址,ptail为最后一个节点的地址//此时已经找到了最后一个节点,为ptailfree(ptail);ptail = NULL;//把那倒数第二个节点中的钥匙赋值为NULLpucr->next = NULL;}
}
下面我们再来详细的解释一下上面的代码:
一、函数参数设计的底层逻辑(为什么是 slistnode** pphead
而不是 slistnode* phead
)
尾删操作的核心是 “可能需要修改链表的头指针”(比如删除唯一节点后链表为空),而 C 语言的 “值传递” 特性决定了必须用二级指针才能实现这一点:
- 若用一级指针
phead
接收头指针,phead
只是外部头指针(如plist
)的 “复制品”。例如,当链表只有一个节点时,执行free(phead)
确实能释放节点内存,但修改phead = NULL
只会改变这个复制品,外部plist
仍指向已释放的内存(野指针),导致后续操作崩溃。 - 二级指针
pphead
存储的是 “外部头指针变量的地址”(如&plist
)。通过*pphead
可以直接操作外部头指针本身:删除唯一节点后,*pphead = NULL
等价于plist = NULL
,能真正将外部头指针置空,避免野指针问题。 - 类比:如果把外部头指针
plist
看作 “家门锁”,一级指针phead
只是 “门锁的照片”,修改照片无法改变真门锁;而二级指针pphead
是 “家门地址”,通过地址能直接换掉门锁(修改plist
本身)。
二、前置检查:双重 assert
的深层意义
尾删操作有两个 “不可突破的前提”,必须用断言严格把控:
assert(pphead)
:拦截无效的二级指针
若函数被错误调用(如slistpopback(NULL)
),pphead
为NULL
,后续执行*pphead
会变成 “对NULL
解引用”,这在 C 语言中是致命错误(程序直接崩溃)。这个断言如同 “第一道防线”,确保操作有一个合法的 “操作对象”。assert(*pphead)
:拦截空链表的删除操作
若链表已空(*pphead == NULL
),此时没有任何节点可删,强行执行删除会导致访问空指针(如(*pphead)->next
)。这个断言如同 “第二道防线”,确保操作 “有意义”—— 只对非空链表执行删除。
三、分情况处理的底层逻辑(单节点 VS 多节点)
尾删的核心是 “删除最后一个节点并维护链表完整性”,但节点数量不同,操作逻辑完全不同,必须精准区分:
情况 1:链表只有 1 个节点((*pphead)->next == NULL
)
此时头节点就是尾节点,删除后链表变为空,操作需满足两个目标:
- 彻底释放内存:直接释放这个唯一节点(
free(*pphead)
),避免内存泄漏。 - 头指针安全置空:通过
*pphead = NULL
将外部头指针设为NULL
。若不做这一步,外部头指针会指向已释放的内存(野指针),后续判断链表是否为空(if (plist == NULL)
)会出错,甚至遍历链表时会访问非法内存。
情况 2:链表有多个节点(需定位倒数两个节点)
此时需要找到 “最后一个节点(待删除)” 和 “倒数第二个节点(新尾节点)”,步骤设计暗藏细节:
双指针遍历的必要性
单链表的节点只能通过next
指针向后访问,无法直接获取前驱节点。因此必须用两个指针配合:ptail
负责 “探路”,最终指向最后一个节点;pucr
负责 “跟拍”,始终记录ptail
的前一个节点(最终成为倒数第二个节点)。
这种 “同步移动” 的逻辑(pucr = ptail; ptail = ptail->next
),能在遍历结束时精准定位两个关键节点。
遍历终止条件的设计(
ptail->next != NULL
)
当ptail->next
为NULL
时,ptail
就是最后一个节点(因为尾节点的next
必须为NULL
)。此时pucr
自然停在倒数第二个节点,为后续修改做好准备。删除后的指针维护
- 释放
ptail
指向的节点(free(ptail)
),并将ptail
置为NULL
(避免它成为野指针)。 - 关键操作:
pucr->next = NULL
。这一步将倒数第二个节点的next
置空,使其正式成为新的尾节点,保证链表的 “结束标志” 正确(尾节点next
为NULL
)。若省略这一步,新尾节点的next
仍指向已释放的内存,后续遍历会出问题。
- 释放
四、隐藏的边界考量:为什么必须单独处理 “单节点” 情况?
若链表只有 1 个节点,使用 “双指针遍历” 会出现逻辑矛盾:
- 初始时
pucr
和ptail
都指向头节点,遍历条件ptail->next != NULL
不成立(因为单节点的next
是NULL
),循环不执行。 - 此时
pucr
和ptail
仍指向同一个节点(头节点),执行free(ptail)
后,pucr
也会变成野指针,无法再通过pucr->next = NULL
维护链表 —— 这就是必须单独处理单节点的根本原因。
总结:尾删的本质是 “安全剥离尾部 + 修复链尾逻辑”
整个过程围绕两个核心目标展开:
- 精准定位待删除节点(尾节点)及其前驱(倒数第二个节点),确保删除对象正确;
- 释放内存后,通过修改指针(头指针或新尾节点的
next
),保证链表结构完整(空链表头指针为NULL
,非空链表尾节点next
为NULL
)。
每一步设计都在规避 “野指针”“内存泄漏” 和 “链表结构断裂” 三大风险,最终实现对链表尾部的安全删除。
到此,我们再下一层,完成了尾删。
对于链表的头部删除:
上一部分,我们知道了尾删,那么在本部分,我们就来了解一些头删:
实现链表的头删操作,核心是安全移除第一个节点并让后续节点成为新的链表起点,需兼顾 “指针更新” 和 “结构完整”,具体逻辑拆解如下:
一、前置检查:操作的合法性保障
头删的第一步是确保 “有节点可删” 且 “操作能真正生效”,需要双重校验:
- 二级指针有效性检查:通过断言确认传递的二级指针(关联头指针的参数)不为空。若该参数为空,后续修改头指针的操作会直接导致程序崩溃,这是基础防护。
- 链表非空检查:通过断言确认链表本身不为空(即头指针指向有效节点)。如果链表已空(头指针为
NULL
),执行头删毫无意义,还会因访问空指针引发错误。
二、核心操作:定位新头节点并更新指针
头删的关键是 “移除原头节点,让原第二个节点成为新头节点”,需分两步完成:
暂存原第二个节点的地址
原头节点中存储着 “下一个节点的地址”(即原第二个节点的位置)。为避免删除原头节点后丢失这个地址,需要用一个临时指针提前记录该地址 —— 这就像搬家前先记下新家的地址,确保不会迷路。更新头指针指向新起点
通过二级指针直接修改外部头指针的指向,让它从原来的第一个节点,改为指向之前暂存的 “原第二个节点”。这一步是头删的核心:原头节点因不再被头指针指向,后续会被释放(或回收),而原第二个节点则正式成为链表的新起点。
三、关键细节:为何必须暂存原第二个节点?
单链表的节点只能通过前一个节点的指针找到下一个节点。如果不提前记录原第二个节点的地址,直接删除原头节点后,就再也无法找到后续节点,整个链表会 “断裂”。临时指针的作用就是 “留存线索”,确保更新头指针时能精准指向原第二个节点,保证链表的连贯性。
四、边界情况:链表只有一个节点时的特殊性
若链表只有一个节点(原头节点的 “下一个指针” 为空),头删后链表会变为空:
- 此时暂存的 “原第二个节点地址” 实际是
NULL
,更新头指针后,外部头指针会指向NULL
,符合 “空链表” 的规则。 - 这种情况下,原头节点被移除后,链表自然为空,无需额外处理,体现了头删逻辑对边界情况的兼容性。
总结:头删的本质是 “重置链表起点”
整个过程围绕两个核心目标:
- 安全移除原头节点,通过暂存地址确保后续节点不丢失;
- 用新起点(原第二个节点)更新头指针,维持链表的链式结构。
每一步都规避了 “指针丢失”“空链表操作” 等风险,最终实现对链表头部的高效删除,同时保证链表逻辑连贯。
下面是详细代码:
//实现对链表的头删的函数
void slistpopfront(slistnode** pphead)//直接就是传入第一个节点
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//因为要进行头删,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,//那还删什么assert(*pphead);//因为要进行头删,顾名思义,就是要把链表中的第一个节点删去//同时还要让那第二个节点变为新链表的第一个节点//这就意味着我们需要把第一个节点的空间free掉//但在此之前,我们还需要考虑一下如何把第二个节点变为新链表的第一个节点//我们知道,我们给这个函数传入的是原链表的第一个节点的地址的地址//而这第一个节点中的钥匙便存储着第二个节点的地址,//于是,为了不让第一个节点删除后,痛失第二个节点的地址//我们就得创建一个新变量去接收那第二个节点的地址(也就是*pphead->next)//在做完这些后,我们就得实现去把那第二个节点变为第一个节点//这边我们知道,在test.c中,我们是设置了一个新变量去等于第一个节点的地址//又因为我们是用的二级指针(传址调用),所以我们可以通过//让*pphead去等于我们第二个节点的地址(这又用到了创建一个新变量去接收那第二个节点的地址),//这么一来,不就相当于实现了把那第二个节点变为第一个节点slistnode* temp = (*pphead)->next;//注意符号优先性//让*pphead去等于我们第二个节点的地址(*pphead) = temp;//实现了把那第二个节点变为第一个节点
}
下面我们依旧详细解释上述代码:
一、函数参数设计的底层逻辑(为什么是 slistnode** pphead
而不是 slistnode* phead
)
头删操作的核心是 “必须修改外部头指针的指向”(让原第二个节点成为新头节点),而 C 语言的 “值传递” 特性决定了只有二级指针能实现这一点:
- 若用一级指针
phead
接收头指针,phead
只是外部头指针(如plist
)的 “副本”。例如,原头节点是A
,执行phead = phead->next
后,副本phead
确实指向了原第二个节点B
,但外部plist
仍指向A
—— 头删后原头节点未被真正移除,链表结构毫无变化,操作彻底失效。 - 二级指针
pphead
存储的是 “外部头指针变量的地址”(如&plist
)。通过*pphead
可以直接操作外部头指针本身:执行*pphead = (*pphead)->next
时,等价于plist = plist->next
,能真正让外部头指针从A
转向B
,完成 “换头” 的核心操作。 - 类比:如果把外部头指针
plist
看作 “公司的法人代表”,一级指针phead
只是 “法人代表的名片”,修改名片上的名字无法更换真正的法人代表;而二级指针pphead
是 “公司的注册地址”,通过地址能直接变更法人代表(修改plist
的指向)。
二、前置检查:双重 assert
的不可替代性
头删操作有两个 “生死攸关” 的前提,必须用断言严格拦截错误:
assert(pphead)
:阻断对无效指针的操作
若函数被错误调用(如slistpopfront(NULL)
),pphead
为NULL
,后续执行*pphead
会变成 “对NULL
解引用”,这在 C 语言中是致命错误(程序会直接崩溃)。这个断言如同 “门禁系统”,提前拦截无效参数,确保操作在合法的基础上进行。assert(*pphead)
:避免对空链表执行删除
若链表已空(*pphead == NULL
),此时没有任何节点可删,强行执行(*pphead)->next
会访问空指针,导致程序异常。这个断言如同 “空操作拦截器”,确保只有非空链表才会执行头删,避免无意义的错误。
三、核心操作的逻辑链:从 “暂存” 到 “换头”
头删的目标是 “移除原头节点,让原第二个节点成为新头节点”,每一步操作都服务于 “不丢失后续节点” 和 “正确更新链表起点”:
暂存原第二个节点的地址:防止链表断裂
原头节点的next
指针存储着原第二个节点的地址(如A->next = &B
)。若不提前记录这个地址,删除原头节点后,就再也无法找到B
及后续节点,整个链表会 “断裂”。
因此,必须用临时指针(如temp
)存储(*pphead)->next
—— 这就像拆积木前先记住下一块积木的位置,确保拆除当前块后能继续搭建。更新头指针指向:确立新的链表起点
完成暂存后,通过*pphead = temp
直接修改外部头指针,让它从指向原头节点(A
)转为指向原第二个节点(B
)。此时:- 原头节点
A
因不再被头指针指向,后续可通过free
释放内存(避免内存泄漏); - 原第二个节点
B
成为新的头节点,链表的链式结构(B->C->D->...
)得以保留,逻辑依然连贯。
- 原头节点
四、边界场景的自然兼容:单节点链表的处理
当链表只有一个节点时(原头节点的 next
为 NULL
),头删操作会自然过渡到空链表:
- 此时
temp
存储的是NULL
(原头节点的next
是NULL
); - 执行
*pphead = temp
后,外部头指针会指向NULL
,符合 “空链表” 的定义; - 这种情况下,无需额外判断或操作,原逻辑就能正确处理 “删除后链表为空” 的场景,体现了设计的简洁性。
总结:头删的本质是 “安全换头 + 结构续接”
整个过程围绕两个核心目标展开:
- 通过暂存地址确保原第二个节点不丢失,避免链表断裂;
- 利用二级指针直接修改外部头指针,让原第二个节点成为新起点,维持链表的连贯性。
每一步都精准规避了 “值传递无法修改外部指针”“节点丢失导致链表断裂”“空链表非法操作” 等风险,最终实现对链表头部的安全、高效删除。
到此,我们又完成了链表中的一个关键环节。
对于链表内容(节点)的查找:
经过上述,我们知道了尾插、头插。尾删。头删,大家不知道会不会想,这几个有点局限了把,万一我们想对链表中的指定一个位置删除或者插入呢?
诶,其实也能实现,不过在这之前,我们得先解决如何查找指定的内容所在的节点这个问题,下面,就由我来给各位解答:
实现链表内容的查找操作,核心是通过逐节点遍历并比对数据,精准定位包含目标值的节点,整个过程需兼顾 “遍历的完整性” 和 “操作的安全性”,具体细节如下:
一、函数参数与返回值的设计逻辑
查找操作的核心是 “读取并比对数据”,无需修改链表结构,因此参数和返回值的设计需贴合这一特性:
- 参数选择一级指针:由于仅需访问链表节点的数据,无需修改头指针的指向或节点间的连接关系,使用一级指针接收链表的头指针已足够。一级指针能从链表起点开始,顺着节点间的关联访问所有节点,满足查找所需的 “遍历权限”。
- 返回值为节点指针:若找到目标值,返回对应节点的地址 —— 这一设计便于后续对该节点进行进一步操作(如修改数据、删除节点等);若未找到,返回空指针(
NULL
),作为 “目标值不存在” 的明确标识,确保调用者能清晰判断查找结果。
二、遍历查找的具体步骤
查找的过程本质是 “按顺序检查每个节点”,需通过有序遍历和精准比对实现,具体分为以下几步:
初始化遍历指针
创建一个临时指针(可理解为 “当前检查指针”),初始时让它指向链表的头节点。这一步的目的是确定遍历的起点,确保从链表的第一个节点开始检查,避免遗漏。循环遍历节点
以 “当前检查指针不为空” 作为循环条件,持续访问每个节点:- 若指针为空,说明已遍历至链表末尾,可终止循环;
- 若指针非空,则对当前节点进行数据比对。
数据比对与结果处理
- 匹配成功:若当前节点存储的数据与目标值相等,立即返回该节点的地址(即当前检查指针的值)。此时无需继续遍历后续节点,既保证结果准确,又提高操作效率。
- 匹配失败:若数据不相等,将当前检查指针移动到下一个节点(通过节点的
next
指针实现),继续循环检查下一个节点,确保遍历的连续性。
遍历结束后的处理
当循环终止(当前检查指针为空),说明已完整遍历所有节点且未找到目标值,此时返回空指针,明确告知调用者 “链表中不存在该目标值”。
三、操作的安全性与特性
整个查找过程具有 “只读不修改” 的特性:
- 遍历仅通过临时指针进行,不会改动原链表的头指针,也不会修改任何节点的
next
指针(即不破坏节点间的连接关系),确保链表的结构在查找后保持原样。 - 仅对节点的数据域进行读取比对,不修改数据内容,保证链表中存储的数据不受查找操作影响。
总结
链表内容的查找操作,通过 “一级指针传参 + 节点遍历 + 数据比对” 的逻辑,实现了对目标值的精准定位:
- 从链表头部开始,逐个节点检查,确保不遗漏任何可能的目标;
- 找到目标后立即返回,未找到则返回空指针,结果明确;
- 全程不修改链表结构和数据,操作安全且无副作用。
这种设计既满足了查找的功能需求,又兼顾了效率和安全性,是链表读取操作的典型实现思路。
下面是详细代码:
//实现对链表内容的查找的函数
slistnode* slistfind(slistnode* phead, name1 x)//因为是查找,所以无需传址
//由于我们是查找,所以我们需要返回那个节点的地址
{//其实这个查找函数就简单多了//我们直接借助循环://当找到了我们要查找的内容,就返回该节点地址//要是没找到的话,就去下一个节点寻找//当然,要是找到最后一个节点都没有的话,就需要返回NULLslistnode* pcur = phead;while (pcur){if (pcur->data == x){return pcur;}pcur = pcur->next;//要是没找到的话,就去下一个节点寻找}return NULL; //要是找到最后一个节点都没有的话,就需要返回NULL
}
老样子,我们再详细解释一下上述代码:
一、函数参数与返回值设计的深层逻辑
查找操作的参数和返回值设计,完全由其 “只读、定位” 的核心特性决定,每一处设计都暗藏对链表操作本质的理解:
- 参数选用一级指针(slistnode* phead)的深层原因
链表的查找操作本质是 “数据读取”,无需改变链表的任何结构 —— 既不需要修改头指针的指向,也不需要调整节点间的连接关系(next指针)。一级指针的权限恰好满足这一需求:它能提供从链表头部开始的访问入口,顺着next指针遍历所有节点,但无法修改外部头指针本身(避免不必要的结构变动)。
类比:这就像博物馆参观,游客(函数)只需知道入口(头指针)就能按顺序参观所有展品(节点),但无需拥有修改展品摆放位置(链表结构)的权限。若误用二级指针,反而会赋予函数不必要的 “修改权限”,增加操作风险(如误改头指针导致链表丢失)。
- 返回值设定为节点指针(slistnode*)的必要性
查找的最终目标是 “定位目标节点”,而节点指针是操作该节点的直接凭证。返回找到的节点地址,能让调用者直接对该节点进行后续操作(如修改数据node->data = new_val、删除节点等),避免二次查找的开销。
若未找到目标值,返回NULL是最明确的 “不存在” 标识 —— 这一设计符合 C 语言的约定俗成(用NULL表示无效指针),让调用者能通过简单的if (result == NULL)判断查找结果,逻辑清晰且易用。
二、核心操作:遍历比对的毫米级流程
查找的过程是 “按顺序检查每个节点” 的精准执行,每一步都需保证 “不重复、不遗漏、高效率”:
- 初始化遍历指针:锁定起点
创建临时指针(如pcur,即 “当前节点指针”),并将其初始化为头指针phead。这一步的核心目的是 “锚定遍历起点”—— 确保从链表的第一个节点开始检查,避免因起点错误导致的节点遗漏(如从第二个节点开始遍历,会漏掉第一个节点)。
细节:临时指针的作用是 “代劳遍历”,避免直接移动头指针phead(若直接移动phead,会导致链表起点丢失,后续无法再访问链表)。
- 循环遍历的终止条件:精准判断终点
循环持续的条件设定为 “pcur != NULL”,这是对 “未到达链表末尾” 的精准描述:
- 当pcur不为NULL时,说明当前指向一个有效节点,需要进行数据比对;
- 当pcur变为NULL时,说明已遍历完最后一个节点(最后一个节点的next为NULL),此时所有节点都已检查完毕,循环终止。
这一条件既保证了所有节点都被检查(不遗漏),又避免了对NULL指针的无效访问(不越界)。
- 数据比对与结果处理:实时响应与高效跳转
- 匹配成功时的即时返回:每访问一个节点,就将其数据域(如pcur->data)与目标值x比对。若相等,立即返回pcur(当前节点地址)—— 这一设计能最大限度减少无效遍历(找到目标后无需继续检查后续节点),平均效率更高。例如,若目标值在第 3 个节点,只需检查前 3 个节点即可返回,无需遍历剩余 100 个节点。
- 匹配失败时的指针移动:若数据不相等,通过pcur = pcur->next将临时指针移动到下一个节点。这一步是遍历的核心动作,通过next指针的 “链式指引”,实现从当前节点到下一个节点的精准跳转,保证遍历的连续性。
- 未找到时的返回值:明确标识 “不存在”
当循环终止(pcur变为NULL),说明所有节点都已检查且无匹配值,此时返回NULL。这一返回值是对 “目标值不在链表中” 的明确宣告,让调用者能清晰区分 “找到(非空指针)” 和 “未找到(NULL)” 两种情况,避免逻辑歧义。
三、关键细节:“只读不修改” 的底层保障
查找操作对链表的 “零侵入性” 是其核心特性,这一特性通过以下细节得以保证:
- 不修改节点数据:整个过程仅读取节点的data域(用于比对),从未执行过pcur->data = new_val之类的赋值操作,确保链表中存储的数据保持原样。
- 不改动连接关系:临时指针pcur的移动仅依赖next指针的指引(pcur = pcur->next),但从未修改任何节点的next指针(如pcur->next = other_node),因此节点间的链式结构不会被破坏。
- 不涉及内存操作:无需调用malloc(创建节点)或free(释放节点),因此不会改变链表的内存布局,也不会引发内存泄漏或野指针问题。
这种 “只读” 特性,使得查找操作可以安全地在多线程环境中执行(无需担心并发修改导致的数据混乱),也不会对后续其他操作(如插入、删除)产生任何副作用。
四、边界情况的全场景适配
查找逻辑对各种极端场景的天然兼容,体现了其设计的鲁棒性:
- 空链表的处理:当链表为空(phead == NULL),临时指针pcur初始化为NULL,循环条件pcur != NULL不成立,循环直接跳过,函数返回NULL。这一过程完美符合 “空链表中无任何节点,自然不存在目标值” 的逻辑,无需额外判断。
- 目标值在第一个节点:此时pcur初始指向头节点,首次比对即发现pcur->data == x,立即返回头节点地址。整个过程仅需 1 次比对,效率达到最高(时间复杂度O(1))。
- 目标值在最后一个节点:遍历需持续到pcur指向最后一个节点(其next为NULL),此时比对成功则返回该节点地址;若最后一个节点仍不匹配,pcur变为NULL,循环终止并返回NULL。两种情况均符合逻辑预期,无歧义。
- 链表中存在重复值:若多个节点包含目标值,函数会返回 “第一个匹配的节点地址”(因遍历从头部开始,找到第一个匹配节点后立即返回)。这一特性明确了查找的 “首次匹配” 语义,符合多数场景的需求(如需所有匹配节点,可基于此函数二次遍历)。
总结:查找操作的本质是 “安全、高效的顺序定位”
整个过程以 “只读不修改” 为原则,通过 “一级指针传参 + 临时指针遍历 + 即时比对返回” 的逻辑链,实现了三大核心目标:
- 完整性:从头部开始遍历所有节点,确保不遗漏任何可能的匹配;
- 高效性:找到目标后立即返回,避免无效遍历;
- 安全性:不修改链表结构和数据,对原链表无任何副作用。
这种设计既贴合链表 “链式存储” 的特性(只能顺序访问),又充分利用了 “只读操作” 的权限优势,是链表数据查询的最优实现方案。
由此,再下一军。
对链表指定位置(节点)之前的插入:
在接下来,我们便可以开始对指定位置进行操作了,本部分便是讲述如何对链表指定位置之前进行插入,
一、函数参数设计的底层逻辑
对链表指定位置之前进行插入,参数设计需满足 “既能修改链表结构,又能定位插入点” 的需求:
- 采用二级指针接收链表头指针的地址,是因为插入操作可能涉及头节点的改变(比如在第一个节点前插入,本质是头插)。二级指针能直接修改外部头指针,确保插入后链表的头部信息正确更新。
- 传入指定位置的节点指针,用于明确插入的具体位置 —— 新节点将被放在该节点的前面,这一参数直接锁定了插入的目标位置。
- 传入待插入的数据,作为新节点存储的核心内容,是插入操作的 “数据载体”。
二、前置检查:确保操作的合法性
插入操作前需通过多重检查拦截无效情况,为后续步骤提供安全基础:
- 检查接收头指针地址的二级指针是否有效(不为空),避免因参数无效导致的后续操作崩溃。
- 检查链表本身是否为空(头指针指向有效节点),若链表为空,则不存在所谓的 “指定位置”,插入操作无意义。
- 检查指定的插入位置是否有效(不为空),确保有明确的插入目标,避免向无效位置插入节点。
三、分情况处理的底层原因
指定位置可能是链表的第一个节点,也可能是中间或尾部节点,两种情况的插入逻辑不同,必须区分处理:
情况 1:指定位置是链表的第一个节点
此时在其之前插入节点,本质上等同于头插操作:
- 新节点需要成为链表新的第一个节点,原第一个节点(即指定位置节点)紧跟其后。
- 可直接复用头插的逻辑,通过二级指针修改头指针,让其指向新节点,同时让新节点指向原第一个节点,快速完成插入。
情况 2:指定位置是链表的非第一个节点
此时需要先定位到指定位置的前一个节点,再完成插入,步骤如下:
- 定位前驱节点:从链表头部开始遍历,通过一个临时指针跟踪节点,直到该指针指向的节点的下一个节点是指定位置节点,此时该临时指针指向的就是指定位置的前一个节点(前驱节点)。
- 创建新节点:生成一个存储待插入数据的新节点,为插入做准备。
- 建立连接关系:
- 让新节点指向指定位置节点,确保新节点与插入点后的链表部分衔接。
- 让前驱节点指向新节点,使新节点融入原链表,成为前驱节点与指定位置节点之间的 “桥梁”。
四、操作的核心逻辑:维持链表的连贯性
整个插入过程围绕 “不破坏原链表结构,同时正确接入新节点” 展开:
- 无论是在第一个节点前插入(头插场景),还是在其他位置前插入,核心都是让新节点正确 “嵌入” 指定位置与前驱节点之间。
- 对于非头节点的插入,定位前驱节点是关键 —— 只有找到前驱节点,才能通过修改其指针,将新节点纳入链表的链式结构中,否则新节点会游离于链表之外,导致插入失败。
总结:指定位置前插入的本质是 “精准嵌入 + 结构续接”
该操作通过二级指针保障头节点可能被修改的场景,通过指定位置节点锁定插入点,分情况处理头节点与非头节点的插入逻辑,最终实现新节点在指定位置前的精准嵌入,同时维持链表的链式连贯性。每一步设计都兼顾了 “结构修改的有效性” 和 “插入位置的准确性”,确保插入后链表依然是逻辑完整的线性结构。
下面是详细代码:
//实现对链表指定位置之前的插入的函数
void slistinsertfront(slistnode** pphead, slistnode* position, name1 x)
{//其实有和下面的实现对链表指定位置之后的插入对比一下的话//大概会很疑惑,为什么这个函数的多了一个第一个节点的二级指针呢?//其实很简单,因为我们是对指定节点之前进行插入,那是不是意味着//我们既需要创建一个新节点,同时这个新节点的钥匙是指向我们指定的位置(节点)的//同时还得让我们指定的节点的前一个节点的钥匙指向我们创建的新节点//这也就意味着,我们需要去找到position的前一个节点//这个时候,pphead的作用就有了,它需要去通过循环去变为指定节点的前一个节点//至于为什么要用二级指针,那是因为我们有可能要进行头插//(即我们指定的节点是链表的第一个节点,那这么一来就得头插)//而头插,是需要二级指针的,我们可以直接调用我们之前写的头插函数//那么问题来了,我们需不需要把头插这个情况单独列出来呢?//答案是需要的,因为我们需要一个新变量去指代position的前一个节点//那要是我们输入的节点是第一个节点的话,那那个新变量不就越界了//会发生啥也找不到的情况//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//因为要进行,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,assert(*pphead);//也可以判断一下我们传入的位置是不是NULLassert(position);//先处理我们指定的节点是第一个节点的情况if (position == (*pphead)){slistpushfront(pphead, x);//直接调用之前所写的头插函数}//我们指定的节点不是第一个节点的情况else{//先创建一个新节点,插入我们想要的数据slistnode* newnode = newslist(x);slistnode* pucr = *pphead; //需要一个新变量去指代position的前一个节点//while的条件限制当一个节点中的钥匙指向我们所指定的节点时//此时就代表是position的前一个节点while (pucr->next != position){pucr = pucr->next;//如果还没到的话,就继续往后找}//出来循环之后,此时的pucr就是position的前一个节点//把这个新节点的钥匙指向我们指定的位置(节点)newnode->next = position;//同时还得让我们指定的节点的前一个节点的钥匙指向我们创建的新节点pucr->next = newnode;}}
下面,我们继续详细解释一下上述代码:
一、函数参数设计的深层逻辑
在链表指定位置前插入节点,参数的设计需同时满足 “结构可修改” 与 “位置可精准定位”,每个参数都有其不可替代的作用:
- 二级指针(slistnode** pphead)的关键作用
当指定位置是链表的第一个节点时,插入操作会产生新的头节点,此时必须修改外部头指针的指向。二级指针存储着外部头指针的地址,通过*pphead能直接操控头指针本身,确保头节点的变更能被外部正确感知。若使用一级指针,函数内部对指针的修改只会作用于副本,外部头指针不会同步更新,新节点无法被识别为新的头节点,导致插入失效。
- 指定位置指针(slistnode* position)的核心价值
该指针明确了插入的 “基准点”,即新节点需要放置在哪个节点的前方。它像一个精准的 “坐标标记”,让插入操作无需盲目搜索整个链表,直接锁定目标区域,大幅提升操作效率并避免位置偏差。
- 待插入数据(name1 x)的基础意义
节点的核心功能是存储数据,待插入数据是新节点存在的根本原因。只有传入数据,新节点才能成为一个有实际内容的功能单元,而非一个无意义的空指针。
二、前置检查:筑牢操作的安全防线
插入操作前的检查是为了排除所有可能导致操作失败的风险,确保每一步都在合法的前提下进行:
- 验证二级指针(pphead)的有效性
若pphead为NULL,后续对*pphead的访问(如判断是否为头节点、更新头指针)会构成对NULL的解引用,这在 C 语言中是致命错误,会直接导致程序崩溃。这一检查如同 “安全门”,提前拦截无效参数,阻止危险操作。
- 确保链表非空(*pphead不为空)
若链表为空(*pphead == NULL),则不存在任何节点,“指定位置” 也就失去了存在的基础。此时执行插入操作不仅没有意义,还可能因访问空指针引发不可预知的错误。这一检查保证了操作对象的存在性。
- 确认指定位置(position)的有效性
若position为NULL,插入操作就失去了明确的目标,变得毫无意义。这一检查如同 “目标确认”,确保插入行为有清晰的指向,避免无的放矢。
三、分情况处理:适配不同场景的操作策略
根据指定位置在链表中的位置(头节点或非头节点),插入逻辑存在显著差异,需分别处理以保证操作的准确性:
情况 1:指定位置是链表的第一个节点(position == *pphead)
此时的插入操作本质上与头插一致,可直接复用头插的成熟逻辑:
- 新节点需要成为链表新的起始节点,原头节点(即position)则紧随新节点之后。
- 调用头插函数(如slistpushfront)的优势在于:头插函数已完整封装了 “创建新节点→新节点指向原头节点→更新头指针” 的流程,复用既能减少代码冗余,又能保证逻辑的一致性。例如,原链表为A→B→C,指定位置为A,插入后链表变为new→A→B→C,与头插的效果完全吻合。
情况 2:指定位置是链表的非第一个节点
此时需要先找到指定位置的前一个节点(前驱节点),再完成新节点的嵌入,具体步骤如下:
- 定位前驱节点(pucr)
前驱节点是指next指针指向position的节点(即pucr->next == position),需通过遍历获取:
- 从链表头部出发,用临时指针pucr依次访问每个节点,每次判断其next指针是否指向position。
- 当判断条件成立时,pucr即指向前驱节点。例如,链表为A→B→C→D,指定位置为C,则pucr最终指向B。
- 创建新节点并构建连接
- 生成存储数据x的新节点,将其next指针指向position,这一步确保新节点能与指定位置的节点衔接,避免插入后链表出现断裂(如new→C)。
- 将前驱节点pucr的next指针指向新节点,使新节点正式融入原链表,形成完整的链式结构(如B→new→C)。
- 最终结果:原链表A→B→C→D转变为A→B→new→C→D,新节点成功插入到指定位置之前。
四、核心逻辑的底层保障:维持链表的完整性
整个插入过程的核心目标是 “在插入新节点的同时,保证链表的链式结构不被破坏”,每一步操作都围绕这一目标展开:
- 对于非头节点的插入,定位前驱节点是必不可少的步骤。由于单链表的节点只能通过next指针向后访问,无法直接获取前驱节点,必须通过遍历才能找到,否则无法将新节点与原链表连接,导致新节点成为孤立节点。
- 新节点与前驱节点、指定位置节点之间的双向连接(pucr→new和new→position)至关重要:前者确保新节点被纳入原链表的整体结构,后者确保指定位置及后续节点不会从链表中脱离,两者共同构成了 “无缝嵌入” 的完整逻辑。
总结:指定位置前插入的本质是 “精准定位与结构融合”
该操作通过二级指针应对头节点可能发生的变更,借助指定位置指针锁定插入目标,针对头节点和非头节点两种情况分别采用适配的插入逻辑:
- 在头节点前插入时,复用头插逻辑,高效且可靠;
- 在非头节点前插入时,通过定位前驱节点,实现新节点与前后节点的有机融合。
每一步设计都以 “不破坏原链表结构、准确插入新节点” 为准则,最终确保插入后链表依然是逻辑连贯的线性结构,新节点稳定地存在于指定位置之前。
到此,再再下一军。
对链表指定位置之后的插入:
本部分,我们便来讲解如何对链表指定位置之后进行插入:
要详细理解链表中指定位置之后的插入操作,可以拆解为更具体的逻辑步骤,结合链表的结构特点来分析:
校验插入位置的有效性
链表的节点通过指针(或引用)相互连接,若指定的位置节点为NULL
(空),则不存在 "该节点之后" 的位置,插入操作失去意义。因此首先需要检查这个位置节点是否合法(非空),通常可通过断言或条件判断实现,若无效则终止操作并提示错误。创建新节点并初始化数据
根据要插入的数据,创建一个新的链表节点。这个节点包含两部分:存储数据的区域和指向后续节点的指针。此时新节点的指针暂时可以设为NULL
,后续会根据插入位置调整。保存指定位置节点的原后续节点
假设指定位置的节点为P
,P
原本指向的下一个节点为Q
(即P->next = Q
)。插入新节点N
后,N
需要接在P
之后,同时N
要指向Q
才能保证原链表中Q
及之后的节点不被 "断开"。因此需要先记录Q
的地址(即暂存P->next
的值)。建立新节点与原后续节点的连接
将新节点N
的指针指向之前保存的Q
(即N->next = Q
),这样N
就正确衔接了原链表中P
之后的节点序列。更新指定位置节点的指针指向新节点
最后修改P
的指针,使其从原本指向Q
改为指向新节点N
(即P->next = N
)。此时链表的结构从P -> Q -> ...
变为P -> N -> Q -> ...
,新节点被成功插入到P
之后。
整个过程的核心是 "先连后断":先确保新节点与后续节点建立连接,再修改原位置节点的指向,避免链表中原有节点的引用丢失,从而保证插入操作后链表的完整性和连续性。这种操作的时间复杂度为O(1)
,因为只需调整有限几个指针的指向,无需遍历整个链表。
下面是详细代码:
//实现对链表指定位置之后的插入的函数
void slistinsertback(slistnode* position, name1 x)
{//这个相对于实现对链表指定位置之前的插入就简单多了//我们只需要创建一个新节点,//然后让这个新节点的钥匙指向指定位置节点的下一个节点//再让我们指定的节点的钥匙指向新节点就行//也可以判断一下我们传入的位置是不是NULLassert(position);//先创建一个新节点,插入我们想要的数据slistnode* newnode = newslist(x);//然后让这个新节点的钥匙指向指定位置节点的下一个节点//先存储一下下一个节点的地址(也就是position->next)slistnode* temp = position->next;newnode->next = temp;//再让我们指定的节点的钥匙指向新节点position->next = newnode;//别忘了newnode本身可是新节点的地址哦
}
我们再给出对上述代码的详细解析:
在链表指定位置之后插入节点的操作,其设计逻辑深层渗透着对链表结构特性的精准把握,每一步都围绕 “低代价嵌入” 与 “结构无断裂” 展开,具体可从参数本质、校验逻辑、操作细节及场景适配四个维度深入解析:
一、参数设计的本质:构建操作的 “坐标系”
函数参数的选择并非随意,而是为了在链表这一动态结构中建立明确的操作基准,确保插入行为 “有章可循”:
指定位置指针(slistnode position)的 “绝对参考” 价值*
单链表的节点通过next
指针单向连接,若要在某节点后插入新节点,必须明确 “以哪个节点为参照”。position
指针的核心作用是提供一个 “物理锚点”—— 它直接指向链表中已存在的某个节点,让新节点的插入位置从抽象的 “某节点之后” 转化为具体的 “position
之后”。
这种设计的优势在于 “零遍历成本”:无需从头节点开始搜索目标位置,直接通过position
定位,将操作的时间复杂度锁定为 O (1)。若缺少这一参数,插入位置将失去明确指向,要么需要额外传入位置索引(增加遍历成本),要么依赖头指针从头搜索(破坏操作效率)。待插入数据(name1 x)的 “功能必要性”
链表的核心价值是存储数据,节点只是数据的 “载体”。x
的传入使新节点从一个仅包含指针的空结构,转变为有实际意义的 “数据单元”。这一参数是新节点存在的根本理由 —— 没有数据的节点,在链表中毫无功能价值,插入操作也失去了实际意义。
二、前置校验的深层逻辑:阻断 “结构性风险”
插入操作前的检查,本质是通过预判潜在风险,避免因非法操作导致链表结构崩溃或程序异常:
验证position
非空的 “防御性设计”
单链表中,节点的next
指针是连接后续节点的唯一通道。若position
为 NULL(空指针),此时访问position->next
(即试图获取 “空节点的下一个节点”)属于对空指针的解引用,这在 C 语言中是未定义行为,会直接导致程序崩溃。
更关键的是,“在空节点之后插入” 本身存在逻辑矛盾 —— 不存在的节点自然没有 “之后” 的位置。因此,通过assert(position)
等方式校验其有效性,实际是在操作执行前设置 “逻辑屏障”:只有当position
指向一个真实存在的节点时,插入操作才有意义,才能继续进行。
三、操作流程的细节:实现 “无断裂嵌入” 的两步法则
插入过程的核心是通过两次指针调整,让新节点 “无缝融入” 原链表,其逻辑设计完全贴合单链表 “单向连接” 的特性:
“先接后路”:锁定原后续节点,避免连接丢失
假设原链表中position
的下一个节点为Q
(即position->next = Q
)。新节点N
插入后,必须承接position
对Q
的指向,否则Q
及之后的所有节点将从链表中 “脱离”,造成结构断裂。
因此,第一步需通过临时变量(如temp = position->next
)暂存Q
的地址,再将新节点的next
指针指向Q
(newnode->next = temp
)。这一步的本质是 “为新节点预设后续连接”,确保插入后原链表的后半段(Q
及之后)不会丢失。“再连前路”:更新
position
的指向,纳入新节点
在新节点与Q
建立连接后,需让position
的next
指针从指向Q
改为指向新节点N
(position->next = newnode
)。这一步是将新节点 “接入” 原链表的前半段,使position
与N
形成直接连接。
经过这两步,原链表的position->Q
结构被替换为position->N->Q
,新节点N
既 “承接” 了position
的指向,又 “延续” 了对Q
的连接,实现了 “插入而不断裂” 的核心目标。
四、场景适配的天然优势:规避 “特殊情况处理成本”
与 “指定位置前插入” 相比,“指定位置后插入” 无需区分position
是否为头节点,这种特性源于操作对链表 “起始点” 的无干扰性:
- 若
position
是头节点(链表的第一个节点),插入后新节点位于头节点之后,头指针(指向头节点的指针)无需改变,链表的起始位置不变; - 若
position
是中间节点,插入后仅影响position
与后续节点的连接,头指针同样无需调整。
这种 “无差别处理” 避免了额外的条件判断(如 “是否为头节点” 的检查),简化了逻辑流程,同时降低了因分支判断失误导致的错误风险。这种设计充分利用了单链表 “后续节点不影响前驱” 的特性,最大化操作效率。
总结:操作本质的再理解
指定位置后插入节点的操作,本质是 “以position
为支点,通过两次指针重定向实现新节点的精准嵌入”。其设计逻辑深刻体现了单链表的操作精髓:无需移动节点,仅通过调整指针指向即可完成结构变更。
从参数的 “锚定作用” 到校验的 “风险阻断”,再到操作的 “无缝衔接”,每一步都服务于 “低代价(O (1) 时间)、高可靠(结构完整)” 的核心目标,最终实现新节点在指定位置后的稳定插入,同时保持链表的连续性与功能性。
至此,恭喜大家,又又又下一城。
对于链表指定位置的删除:
知道了对于指定位置的插入之后,我们肯定也需要知道对于链表指定位置的删除,且看下文:
要细致地拆解链表指定位置的删除操作,需从每个步骤的底层逻辑和具体执行细节入手,结合单链表 “单向链接” 的特性,确保每一步都精准维护链表的完整性:
1. 校验参数有效性:构建操作的 “安全边界”
这一步是为了排除所有可能导致操作失败的 “非法前提”,具体需验证三个核心要素:
- 验证操作入口的有效性:用于操作链表的关键指针(如指向头节点的指针容器)必须非空。若该指针为空,意味着无法定位链表的起始位置,后续所有操作都失去了基础 —— 就像找不到一串项链的绳头,自然无法对项链进行修改。
- 验证链表非空:需确认链表中至少存在一个节点(即头节点非空)。若链表为空,“删除指定位置节点” 就成了无的放矢,因为根本没有节点可供删除,强行操作会导致对空指针的无效访问。
- 验证目标节点的有效性:指定要删除的节点(position)必须真实存在(非空)。若该节点为空,“删除” 行为就失去了具体对象,且后续访问该节点的指针(如获取其下一个节点)会触发程序崩溃 —— 就像试图从不存在的珠子后面取下珠子,逻辑上完全不成立。
2. 处理头节点删除情况:特殊场景的 “直接替换”
若指定删除的节点是链表的第一个节点(头节点),需单独处理,原因是头节点是链表的 “起始标识”,其删除会直接改变链表的入口:
- 确认头节点身份:通过判断 “指定节点是否与头节点地址相同”,确定是否为头节点。
- 更新头指针指向:将原本指向头节点的指针,改为指向头节点的下一个节点。这一步是让链表的 “新起点” 变为原头节点的后一个节点,确保链表后续节点仍可被访问 —— 就像取下项链的第一个珠子后,绳头要重新系在第二个珠子上。
- 释放原头节点内存:将被删除的头节点占用的内存归还给系统,避免内存泄漏。这一步是彻底 “移除” 该节点,而非仅从链表中隐藏它。
3. 定位待删节点的前驱节点:单链表的 “逆向溯源”
对于非头节点的删除,由于单链表只能通过节点的next
指针向后访问,无法直接获取前一个节点,必须通过遍历找到前驱节点(即待删节点的前一个节点):
- 初始化遍历指针:从链表的头节点开始,用一个临时指针(如 “当前指针”)标记遍历的起点。
- 遍历寻找前驱:让临时指针依次向后移动(每次指向当前节点的下一个节点),同时判断 “当前节点的下一个节点是否为待删节点”。例如,若待删节点是
B
,则当临时指针指向的节点A
满足A->next == B
时,A
就是B
的前驱节点。 - 终止遍历条件:当找到前驱节点后停止遍历。这一步的本质是通过 “顺藤摸瓜”,从链表头部一步步定位到待删节点的前一个 “连接点”。
4. 重新建立链表连接并释放节点:“断旧连新” 的完整操作
找到前驱节点后,需通过指针调整将待删节点从链表中移除,并释放其内存:
- 断开待删节点的前向连接:将前驱节点的
next
指针,从原本指向待删节点,改为指向待删节点的下一个节点。例如,前驱节点A
原本指向待删节点B
(A->next = B
),修改后A->next = B->next
,这样A
就直接跳过B
连接到了B
的下一个节点C
,B
被从链表中 “孤立” 出来。 - 释放待删节点内存:调用内存释放函数,回收待删节点占用的内存,彻底删除该节点。
- 避免野指针:将指向待删节点的指针置为空,防止后续操作误访问已释放的内存(野指针可能导致程序崩溃)。
通过这四个步骤,既能确保删除操作精准执行,又能维持链表的连续性和内存安全性,最终实现 “从链表中彻底移除指定节点,且不影响其他节点连接” 的目标。
下面是详细代码:
//实现对链表指定位置的删除的函数
void slisterase(slistnode** pphead, slistnode* position)
{//得先判断传来的二级指针是否为空,如果为空的话,谈何删除assert(pphead);//因为要进行,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,谈何删除assert(*pphead);//也可以判断一下我们传入的位置是不是NULLassert(position);//我们来分析一下如何实现对链表指定位置的删除//意思就是我们要把指定位置的那个节点释放掉//但是为了链表的连续性,//这就需要我们把该节点的前一个节点的钥匙指向该节点的下一个节点//这也就意味着,我们需要去找到position的前一个节点//这个时候,pphead的作用就有了,它需要去通过循环去变为指定节点的前一个节点//至于为什么要用二级指针,那是因为我们有可能要进行头删//(即我们指定的节点是链表的第一个节点,那这么一来就得头删)//而头删,是需要二级指针的,我们可以直接调用我们之前写的头删函数//那么问题来了,我们需不需要把头删这个情况单独列出来呢?//答案是需要的,因为我们需要一个新变量去指代position的前一个节点//那要是我们输入的节点是第一个节点的话,那那个新变量不就越界了//会发生啥也找不到的情况//先处理我们指定的节点是第一个节点的情况if (position == (*pphead)){slistpopfront(pphead);//直接调用之前所写的头删函数}//我们指定的节点不是第一个节点的情况else{slistnode* pucr = *pphead;while (pucr->next != position)//当pucr没有到指定位置的前一个节点{pucr = pucr->next;//如果还没到的话,就继续往后找}//出来循环之后,此时的pucr就是position的前一个节点//接着我们就得把pucr的钥匙指向position的下一个节点pucr->next = position->next;//然后我们开始释放positionfree(position);position = NULL;//要理解 “为什么释放一级指针position就能删除对应的节点”,核心在于指针与内存的关系//指针本质是 “地址的容器”,而free函数操作的是指针指向的内存,而非指针本身。//1. 先明确:position是什么?//position是一个一级指针变量,它的作用是存储 “要删除的节点” 在内存中的地址。//比如,假设链表中有一个节点 A,其内存地址是0x123456,那么position变量中就存储着0x123456,即position = &节点A。//2. free(position)的真正含义//free函数的功能是:释放指针所指向的内存块,而不是 “删除指针变量本身”。//当执行free(position)时://函数会找到position中存储的地址(比如0x123456),将这块内存归还给操作系统,//使其成为 “无效内存”(后续无法再访问)。//这意味着,“要删除的节点” 所占用的内存被彻底释放,该节点从内存中消失。//3. 为什么不需要 “二级指针” 来释放节点?//二级指针(如pphead)的作用是修改指针变量本身的值(比如修改头指针的指向)。//而释放节点的核心是回收节点占用的内存,只需要知道 “节点在内存中的地址” //即可 ——position已经存储了这个地址,所以用一级指针足够。}
}
再给出对于代码的详细解释:
要将链表指定位置删除的操作逻辑解析到最细致的层面,需要深入到每个步骤的执行原理、依赖条件和潜在问题的规避方案,结合单链表 “指针串联、内存独立” 的底层特性,逐帧拆解操作的每一个细节:
一、参数校验:三层防御网的 “原子级” 必要性
参数校验的每一步都对应着单链表操作的 “致命风险点”,其必要性需从计算机内存访问的底层逻辑理解:
校验操作入口指针(如
pphead
非空)pphead
的本质是 “头指针的地址容器”,通过*pphead
可直接修改头指针的值(如头节点删除时更新头指针)。- 若
pphead
为NULL
,执行*pphead
会触发 “空指针解引用”—— 这在计算机底层是对地址0x0
的非法访问,操作系统会直接终止程序(核心转储)。 - 这一校验的本质是:确保操作有一个合法的 “控制入口”,能够修改链表的起始标识。
校验链表非空(
*pphead
非空)- 若链表为空(
*pphead == NULL
),说明链表中没有任何节点,“删除指定位置节点” 的操作对象不存在。 - 此时若继续执行(如遍历或访问节点成员),会导致对
NULL
的next
指针访问(如(*pphead)->next
),触发与上述相同的崩溃。 - 这一校验的核心是:确保操作对象(链表)真实存在,避免 “无的放矢” 的无效操作。
- 若链表为空(
校验目标节点(
position
非空)position
存储着待删节点的内存地址,若其为NULL
,则:- 无法确定要删除的节点(操作失去具体目标);
- 后续访问
position->next
(获取待删节点的下一个节点)会直接访问NULL
的成员,触发崩溃。
- 更关键的是:
position
必须是链表中真实存在的节点(需隐含校验,通常由调用者保证),否则后续遍历找前驱会出现无限循环或越界。
二、头节点删除:“入口迁移” 的完整执行链
头节点作为链表的唯一入口,其删除涉及 “逻辑入口更新” 和 “物理内存释放” 的完整流程,每个细节都影响链表的可用性:
头节点身份的精准判定(
position == *pphead
)- 判定依据是 “地址是否完全一致”:只有当
position
和*pphead
指向同一块内存(即存储相同的节点地址)时,才确认是头节点。 - 这一判定的必要性:头节点的删除会改变链表的起始位置,而其他节点的删除仅影响局部连接,逻辑完全不同。
- 判定依据是 “地址是否完全一致”:只有当
头指针的 “无缝迁移”
- 原头节点的下一个节点(
(*pphead)->next
)是新的头节点候选,需将*pphead
(头指针)直接修改为该地址 —— 例如原头节点是A
,A->next = B
,修改后*pphead = B
,链表入口从A
变为B
。 - 若原头节点是链表的唯一节点(
(*pphead)->next == NULL
),则修改后*pphead
为NULL
,链表变为空,这是合理的 “空链表” 状态。
- 原头节点的下一个节点(
原头节点的内存释放细节
- 释放前需确认
*pphead
(原头节点地址)有效,调用free(*pphead)
将该内存块标记为 “可用”,归还给操作系统的内存管理器。 - 释放后建议将原头指针(
*pphead
)置空(虽然此时*pphead
已指向新节点或NULL
,但针对原地址的临时变量需清理),避免后续误操作访问已释放内存(野指针风险)。
- 释放前需确认
三、前驱节点定位:遍历过程的 “微观执行步骤”
非头节点的删除依赖前驱节点,而单链表的单向性决定了必须通过遍历 “顺藤摸瓜”,每一步遍历都有明确的内存访问逻辑:
遍历指针的初始化与作用
- 临时指针
pucr
初始化为*pphead
(头节点地址),其作用是 “逐个扫描节点,寻找前驱”。 pucr
的类型是struct slistnode*
,每次移动(pucr = pucr->next
)本质是 “从当前节点的next
成员中读取下一个节点的地址,并存入pucr
”。
- 临时指针
循环遍历的 “条件判断与执行”
- 循环条件
pucr->next != position
的含义:当前节点的下一个节点不是待删节点时,继续遍历。 - 执行过程:
- 读取
pucr
指向节点的next
成员(即pucr->next
),获取下一个节点的地址; - 比较该地址与
position
存储的地址是否一致; - 若不一致,将
pucr
更新为下一个节点的地址(pucr = pucr->next
),重复上述步骤。
- 读取
- 循环条件
遍历的 “必然终止性”
- 由于
position
是链表中真实存在的节点(已通过隐含校验),遍历过程中pucr
绝不会指向NULL
(否则position
不在链表中),因此无需额外判断pucr
是否为空。 - 最终
pucr
必然指向position
的前驱节点,此时pucr->next == position
,循环终止。
- 由于
四、连接重建:指针重定向的 “原子操作” 解析
连接重建是删除操作的核心,通过两个指针的原子级修改(不可分割的操作)实现节点剥离,确保链表连续性:
待删节点后续连接的 “保存逻辑”
position->next
存储着待删节点的下一个节点地址(如D
),这是链表后半段的 “关键线索”。- 即使不使用临时变量存储,直接通过
position->next
访问也可完成连接(如pucr->next = position->next
),因为position
是有效节点,其next
成员必然有效(可为NULL
,表示待删节点是尾节点)。
前驱节点指针的 “跨接修改”
- 操作
pucr->next = position->next
的本质是:将前驱节点的next
成员(存储下一个节点地址的内存单元)修改为position->next
的值。 - 例如:
- 前驱节点
B
的next
原本存储C
的地址(B->next = &C
); - 待删节点
C
的next
存储D
的地址(C->next = &D
); - 修改后
B->next = &D
,B
直接指向D
,C
被从链条中 “跳过”。
- 前驱节点
- 操作
五、内存释放:从 “逻辑删除” 到 “物理回收” 的闭环
连接重建后,待删节点已与链表逻辑隔离,但物理内存仍被占用,需通过标准库函数完成最终清理:
free(position)
的底层作用free
函数接收position
存储的地址(待删节点的内存起始地址),通知操作系统该内存块不再使用。- 操作系统会将该内存块标记为 “空闲”,纳入内存池,供后续
malloc
等函数分配使用,避免内存泄漏(长期占用导致可用内存减少)。
野指针的 “清除机制”
- 释放后
position
变量本身仍存储着原节点的地址(free
不修改指针变量的值),此时该地址已成为 “无效地址”(内存可能被重新分配)。 - 将
position
置为NULL
(position = NULL
),可确保后续在函数内若误操作position
(如访问position->next
),会触发明确的 “空指针错误”(便于调试),而非难以排查的 “内存访问越界”。
- 释放后
总结:删除操作的 “逻辑 - 物理” 双层闭环
单链表指定位置的删除,是逻辑上切断节点连接与物理上回收内存资源的完整闭环:
- 逻辑层面:通过头指针更新(头节点删除)或前驱指针跨接(非头节点删除),确保链表剩余节点形成连续的链式结构,维持 “前节点指向后节点” 的核心特性;
- 物理层面:通过
free
释放待删节点的内存,将资源归还给系统,避免长期占用导致的内存浪费。
整个过程受限于单链表 “只能向后访问” 的固有特性,非头节点删除需遍历找前驱(时间成本随链表长度增加),但操作本身仅涉及常数个指针修改(空间成本固定)。每一个细节的设计,都是为了适配单链表的结构约束,最终实现 “安全、完整、高效” 的节点删除。
至此,离成功又进一步了各位,大家加油!!!
实现对链表指定位置之后节点的删除:
本部分,我们便来讲解对链表指定位置之后节点的删除:
要细致地拆解链表中指定位置之后节点的删除操作,需结合单链表 “指针串联” 的特性,从每个步骤的执行逻辑、潜在风险及解决细节入手,确保操作精准且安全:
1. 校验指定位置的有效性:筑牢操作的 “逻辑基石”
这一步是为了确保 “删除指定位置之后的节点” 这一行为有合理的前提,需从两个维度验证:
- 节点存在性校验:指定的位置节点(即作为参考点的节点)必须非空。若该节点为
NULL
,则 “其之后的节点” 从逻辑上就不存在 —— 就像试图删除 “不存在的路标后面的物体”,操作本身失去意义。 - 避免空指针访问风险:若指定位置节点为空,后续访问其
next
指针(用于定位待删节点)会触发空指针解引用错误,直接导致程序崩溃。因此,这一校验是操作的 “第一道安全闸”,必须优先执行。
2. 定位待删除的节点:锁定 “目标对象”
待删节点是指定位置节点的直接后继(下一个节点),定位过程需明确其地址,具体细节如下:
- 通过指定位置节点的
next
指针获取地址:指定位置节点的next
成员存储着其下一个节点的地址,这就是待删节点的 “物理位置”。例如,若指定位置节点是A
,A->next
指向B
,则B
就是要删除的节点。 - 明确待删节点的 “身份”:这一步的核心是确认 “要删除的是哪个节点”,为后续的连接调整和内存释放提供具体的操作对象。即使待删节点为空(即指定位置节点是尾节点),也需明确这一状态(后续步骤会自然处理)。
3. 保存待删节点的后续连接:留存 “链条延续线索”
待删节点的next
指针记录着链表后半段的连接信息,若丢失这一信息,删除后链表会出现断裂,因此需重点处理:
- 获取待删节点的后继地址:待删节点的
next
成员指向其下一个节点(即指定位置节点的 “下下个节点”)。例如,待删节点B
的next
指向C
,则C
的地址就是需要留存的 “延续线索”。 - 为何必须保存:单链表的节点仅通过
next
指针连接,若不记录C
的地址,当B
被删除后,A
的next
指针将无法正确指向C
,导致C
及之后的节点从链表中 “消失”。保存这一线索是维持链表完整性的关键。
4. 断开待删节点与原链表的连接:实现 “逻辑剥离”
通过调整指定位置节点的next
指针,将待删节点从链表中 “移除”,具体操作包含两层逻辑:
- 修改指定位置节点的指向:让指定位置节点的
next
指针跳过待删节点,直接指向待删节点的后继节点。例如,原连接为A->B->C
,修改后A->next
指向C
,此时A
与C
直接相连,B
被 “跳过”。 - 完成 “逻辑隔离”:这一步后,待删节点不再被链表中的任何节点指向,从逻辑上脱离了链表,但物理内存仍被其占用(需后续释放)。无论待删节点是否为尾节点(即其
next
为NULL
),这一操作都适用 —— 若待删节点是尾节点,修改后指定位置节点的next
会指向NULL
,成为新的尾节点,逻辑依然完整。
5. 释放待删节点的内存:完成 “物理清除”
待删节点从逻辑上脱离链表后,需释放其占用的内存,避免资源浪费,具体步骤如下:
- 调用内存释放函数:通过待删节点的地址(如之前定位的
B
的地址),调用free
函数将其内存归还给操作系统。这一步是 “物理删除” 的核心,确保该内存可被后续程序重新分配使用。 - 清除野指针:释放后,将指向待删节点的临时指针置为
NULL
。因为该指针原本存储待删节点的地址,释放后该地址已无效,置空可避免后续误操作访问已释放的内存(野指针可能导致程序崩溃或数据错乱)。
通过以上五个步骤,既能精准删除指定位置之后的节点,又能确保剩余链表的连接连续性,同时妥善回收内存资源,实现 “逻辑正确、物理安全” 的删除操作。整个过程的时间复杂度为O(1)
(无需遍历,仅需固定次数的指针操作),体现了单链表在特定位置操作上的高效性。
下面是详细代码:
//实现对链表指定位置之后节点的删除的函数
void slisteraseafter(slistnode* position)
{//判断一下我们传入的位置是不是NULLassert(position);//让我们来分析一下如何实现对链表指定位置之后节点的删除//注意这里是要对指定位置的节点的下一个节点进行删除//所以不需要把指定位置节点的上一个节点的钥匙指向指定位置节点的下一个节点//这里只需要让position这个节点的钥匙指向下下个节点就行了//然后把下一个节点给释放了//但是也会有问题存在,那就是我们必须得创建一个变量去指代//指定节点下下一个节点的地址,不难一旦指定节点下一个节点被释放了//那去哪里找下下一个节点,不仅如此我们还得设置一个变量去指定节点的下一个节点//然后把我们所设置的这个节点给释放掉slistnode* temp = position->next;//此时temp指向指定节点的下一个节点//我们让指定节点的钥匙指向下下个节点position->next = temp->next;//temp->next指向指定节点的下一个节点的下一个节点//然后我们就可以把指定节点的下一个节点给释放掉了free(temp);temp = NULL;//为什么释放临时变量temp就能删除指定节点的下一个节点?//position->next 原本就指向 “要删除的节点”(即position的下一个节点)的内存地址。//把position->next的值赋给temp后,temp和position->next指向同一块内存// (都是 “要删除的节点” 的地址)。//free函数的作用是释放指针所指向的内存,而不是 “释放指针本身”。//这里temp指向的是 “position的下一个节点” 的内存,//所以free(temp)实际上是把这块内存归还给系统,使其不可再访问。//即使temp和position->next原本指向同一块内存//只要用其中一个指针释放了这块内存,这块内存就被回收了 //无论其他指针是否还记录着这个地址,都不能再使用该内存(否则会导致 “野指针访问” 错误)。
}
下面我们再来看对代码的详细解释:
一、参数校验:从底层保障操作可行性
参数校验是单链表指定位置删除操作的 “第一道防线”,每一项校验都对应着单链表操作时可能触发的核心崩溃场景,需从计算机内存访问的底层逻辑来理解其必要性:
- 校验操作入口指针:操作入口指针(比如用于修改头指针的指针)是操作链表的 “控制枢纽”。它的作用是让我们能对链表的关键标识(如头指针)进行修改。若该指针无效(为
NULL
),后续试图通过它去修改头指针等操作,会触发 “空指针解引用” 错误。在计算机底层,这相当于对地址0x0
进行非法访问,操作系统会直接终止程序,导致操作彻底失败。所以这一校验是为了确保有一个合法的 “操作入口”,能对链表的核心标识进行控制。 - 校验链表非空:若链表为空(即表示头指针的变量值为
NULL
),意味着链表中没有任何节点。此时进行 “删除指定位置节点” 的操作,就没有实际的操作对象,是无意义的。如果强行继续操作,比如去遍历链表或者访问节点的next
指针,会因为访问NULL
的next
指针而触发崩溃,错误类型和空指针解引用一致。这一校验的核心是确保操作对象(链表)真实存在,避免 “无的放矢” 的无效操作。 - 校验目标节点:指定要删除的节点(通常用指针表示,如
position
)必须有效。如果该节点无效(为NULL
),一方面我们无法确定具体要删除哪个节点,操作失去了明确的目标;另一方面,后续若要访问该节点的next
指针(比如为了找到待删节点的下一个节点等操作),会直接访问NULL
的成员,从而触发崩溃。而且,position
必须是链表中真实存在的节点(这一点通常由调用该删除操作的代码保证),否则后续在寻找前驱节点等操作时,可能会出现无限循环或者越界访问等问题,导致程序运行异常。
二、头节点删除:特殊场景的完整流程
头节点是链表的起始节点,它的删除涉及 “逻辑上更新链表入口” 和 “物理上释放内存” 的完整流程,每个步骤都对链表的可用性至关重要:
- 头节点的判定:通过地址比对的方式来确定待删除节点是否为头节点。具体来说,就是比较表示头节点的指针(如
*pphead
)和待删除节点的指针(如position
)是否指向同一块内存(即存储的地址是否相同)。只有当两者地址完全一致时,才能判定待删除节点是头节点。这一判定非常必要,因为头节点的删除会改变链表的起始位置,而删除其他节点只是影响局部的连接关系,两者的操作逻辑有明显区别。 - 头指针的更新:当确定要删除的是头节点后,原头节点的下一个节点(即
(*pphead)->next
所指向的节点)将成为新的头节点。此时需要更新表示头指针的变量(如*pphead
),使其指向这个新的头节点。例如,原头节点是节点A
,A->next
指向节点B
,那么修改后*pphead
就指向B
,链表的入口从A
变为B
。如果原头节点是链表的唯一节点(即(*pphead)->next == NULL
),那么修改后*pphead
的值为NULL
,链表变为空链表,这是合理的 “空链表” 状态,后续对空链表的操作也能正常识别。 - 原头节点内存释放:在释放原头节点内存之前,要先确认表示原头节点地址的指针(如
*pphead
)是有效的。然后调用内存释放函数(如free(*pphead)
),将原头节点所占用的内存块标记为 “可用”,归还给操作系统的内存管理器,供后续其他程序或操作分配使用。释放完成后,建议将原头指针(如*pphead
)置空。虽然此时*pphead
可能已经指向新的头节点或者NULL
,但对原头节点地址相关的临时变量进行置空操作,能避免后续误操作访问已经释放的内存,降低 “野指针” 带来的风险。
三、前驱节点定位:单向链表的遍历逻辑
由于单链表的节点只能通过next
指针向后访问,无法直接获取前驱节点(即前一个节点),所以当要删除的节点不是头节点时,必须通过遍历链表的方式来定位前驱节点,每一步遍历都有明确的内存访问逻辑:
- 遍历指针的初始化与作用:使用一个临时指针(如
pucr
),将其初始值设置为表示头节点的指针(如*pphead
)。这个临时指针的作用是 “逐个扫描链表中的节点,寻找待删除节点的前驱节点”。pucr
的类型是链表节点指针类型(如struct slistnode*
),每次移动(执行pucr = pucr->next
)的本质是:从当前节点的next
成员中读取下一个节点的地址,然后将这个地址存储到pucr
中,实现指针的向后移动。 - 循环遍历的条件与执行:循环的条件是
pucr->next != position
,其含义是:只要当前节点(pucr
指向的节点)的下一个节点不是待删除节点,就继续遍历。具体执行过程如下:首先读取pucr
指向节点的next
成员,获取下一个节点的地址;然后将这个地址与待删除节点的指针(position
)存储的地址进行比较;如果两者不一致,就将pucr
更新为下一个节点的地址(执行pucr = pucr->next
),然后重复上述比较步骤。 - 遍历的必然终止性:因为
position
是链表中真实存在的节点(这一点已经通过之前的隐含校验或者调用者保证),所以在遍历过程中,pucr
绝不会指向NULL
(否则说明position
不在链表中)。因此,不需要额外判断pucr
是否为空,遍历最终一定会找到待删除节点的前驱节点,此时pucr->next == position
,循环终止。
四、连接重建:指针操作的核心逻辑
连接重建是删除操作的核心步骤,通过对两个指针的 “原子级” 修改(即不可分割的操作),实现待删除节点从链表中的剥离,同时确保链表的连续性:
- 待删节点后续连接的保存:待删除节点的
next
指针存储着它的下一个节点的地址(比如节点D
的地址),这是链表后半段连接的 “关键线索”。即使不使用临时变量来存储这个地址,直接通过待删除节点的next
指针(如position->next
)进行访问,也能完成后续的连接操作。这是因为position
是有效节点,其next
成员必然是有效的(可以是NULL
,表示待删除节点是尾节点)。保存这一连接线索的目的是,在删除待删除节点后,能将前驱节点与待删节点的下一个节点正确连接,保证链表后半段的连续性。 - 前驱节点指针的跨接修改:执行
pucr->next = position->next
操作的本质是:将前驱节点(pucr
指向的节点)的next
成员(这个成员用于存储下一个节点的地址)的值,修改为待删除节点next
成员的值(即position->next
的值)。例如,前驱节点是B
,B->next
原本存储的是待删除节点C
的地址(即B->next = &C
);待删除节点C
的next
存储的是节点D
的地址(即C->next = &D
);执行修改后,B->next = &D
,这样B
就直接指向D
,C
被从链表的 “链条” 中 “跳过”,实现了待删节点的逻辑剥离。
五、内存释放:物理层面的最终清理
当待删除节点从逻辑上与链表剥离后,其物理内存仍然被占用,需要通过标准库函数完成最终的内存清理:
free
函数的底层作用:free
函数接收待删除节点的指针(如position
)所存储的地址(即待删节点的内存起始地址),然后通知操作系统,该内存块不再被当前程序使用。操作系统会将这块内存块标记为 “空闲”,并将其纳入内存池,供后续的malloc
等内存分配函数使用,从而避免内存泄漏(即内存长期被占用而无法被其他部分使用,导致可用内存逐渐减少)。- 野指针的清除:调用
free
函数释放内存后,position
变量本身仍然存储着原节点的地址(因为free
函数不会修改指针变量的值),但此时这个地址已经是 “无效地址”,对应的内存可能已经被操作系统重新分配给其他用途。为了避免后续在函数内部误操作position
(比如访问position->next
),将position
置为NULL
。这样,若后续误操作,会触发明确的 “空指针错误”,便于开发者调试,而不是引发难以排查的 “内存访问越界” 等隐蔽错误。
总结:删除操作的逻辑与物理协同
单链表指定位置的删除操作,是逻辑层面切断节点连接和物理层面回收内存资源的完整闭环:
- 逻辑层面:通过头指针更新(处理头节点删除场景)或者前驱指针跨接(处理非头节点删除场景),确保链表剩余节点能够形成连续的链式结构,维持单链表 “前一个节点指向后一个节点” 的核心连接特性。
- 物理层面:通过
free
函数释放待删除节点的内存,将占用的资源归还给操作系统,避免内存长期被无效占用而导致的资源浪费。
整个操作过程受到单链表 “只能向后访问” 这一固有特性的限制,当删除非头节点时,需要遍历链表寻找前驱节点,这一过程的时间成本会随着链表长度的增加而增加。但从空间成本来看,操作本身只涉及常数个指针的修改(如临时指针、前驱节点指针等),空间成本是固定的。每一个步骤的设计,都是为了适配单链表的结构约束,最终实现 “安全、完整、高效” 的节点删除操作。
到此,我们离链表的初步收工也就只差几步了。
对链表中内容的打印:
我们前面已经了解了链表的删、增等操作,那么我们肯定也需要一个函数去将我们链表中的内容都打印出来,且看:
要实现链表内容的打印,需围绕 “安全遍历、完整输出、清晰标识” 三个核心目标,结合单链表 “节点单向串联” 的特性,将每个步骤拆解至最细致的执行逻辑:
一、遍历准备:构建 “安全遍历载体”
这一步的核心是保护链表的 “入口标识”,确保遍历操作不影响后续对链表的正常使用:
临时指针的创建与初始化:
定义一个与头指针类型一致的临时指针(例如命名为 “遍历指针”),并将其初始值设置为链表的头指针(即指向链表的第一个节点)。
这一操作的关键逻辑是:用临时指针承担 “移动遍历” 的任务,而头指针始终保持对链表起始节点的指向。若直接使用头指针遍历,会导致头指针在移动过程中丢失初始地址(例如头指针指向第二个节点后,就无法再访问第一个节点),后续对链表的操作(如插入、删除)将失去起始参考点。
临时指针的作用如同 “探路者”,负责深入链表内部探索,而头指针则像 “大本营”,始终锚定链表的起点。空链表的提前兼容:
若链表为空(头指针本身为NULL
),临时指针初始化后也会为NULL
,这一状态会被后续循环正确识别,避免无效操作。
二、循环遍历:逐节点访问与数据输出
循环的设计需精准适配单链表 “节点通过next
指针连接” 的特性,确保每个节点的数据都能被依次打印:
循环条件的设定:
循环持续的条件是 “临时指针不为NULL
”。因为单链表的最后一个节点的next
指针指向NULL
(表示链表结束),所以当临时指针为NULL
时,意味着所有节点已遍历完毕。
例如:链表为A->B->C->NULL
,临时指针初始指向A
(非空),进入循环;打印A
的数据后指向B
(非空),继续循环;直至指向NULL
时,循环终止。当前节点数据的打印:
在循环体内,首先访问临时指针指向节点的 “数据域”(存储实际内容的部分),并按照预设格式(如 “数据 ->”)打印。这一步的本质是 “读取节点存储的有效信息并输出”,是打印操作的核心目标。
需注意:无论节点存储的数据类型(整数、字符串等),都需通过节点的成员变量(如data
)访问,这是单链表节点的标准结构设计。指针移动的细节:
打印完当前节点数据后,将临时指针更新为 “当前节点的next
指针所指向的地址”(即临时指针 = 临时指针->next
)。这一步是遍历的 “推进器”,通过节点的next
指针找到下一个节点,实现从当前节点到下一个节点的 “跳转”。
例如:临时指针指向A
时,A->next
指向B
,更新后临时指针指向B
,为下一次循环打印B
的数据做准备。
三、末尾处理:标识链表的终止边界
当循环结束(临时指针为NULL
)时,需通过特定输出明确标记链表的终点,使打印结果完整且直观:
打印
NULL
的意义:
输出NULL
是对单链表物理结构的直接映射 —— 因为链表的最后一个节点的next
指针必然指向NULL
,这是链表结束的天然标识。打印NULL
能让使用者清晰看到链表的完整结构(如1->2->3->NULL
),明确数据的终止位置。
若省略这一步,打印结果会停留在最后一个数据(如1->2->3
),无法体现链表的终结特性,容易造成 “是否还有后续节点” 的歧义。空链表的特殊处理:
若链表为空(头指针为NULL
),临时指针初始化后即为NULL
,循环不会执行,直接进入末尾处理,打印NULL
。这一结果(仅NULL
)准确反映了 “链表中无任何节点” 的状态,逻辑自洽。
总结:打印操作的本质是 “结构遍历 + 数据输出”
链表打印的全过程,是对单链表结构特性的直接应用:
- 通过临时指针的移动,实现对链表节点的顺序访问(适配
next
指针的单向连接特性); - 通过循环条件控制,确保遍历范围精准(从第一个节点到
NULL
终止); - 通过数据域访问与
NULL
标识,完整输出链表的内容与边界。
整个过程的时间复杂度为O(n)
(n
为节点数量,需逐个访问),空间复杂度为O(1)
(仅使用一个临时指针),既高效又能完整呈现链表的逻辑结构。
下面是详细代码:
//实现对链表中内容的打印的函数
void slistprint(slistnode* phead)//第一个节点
{//使用pcur代替phead进行遍历,避免直接操作头指针导致头指针丢失//(如果直接修改phead,会导致后续无法再访问链表的起始位置)。slistnode* pcur = phead;//当pcur == NULL时,说明已经遍历到链表的最后一个节点的next//(即链表末尾),循环结束。while (pcur != NULL){printf("%d->", pcur->data);pcur = pcur->next;//对pcur不断赋值更新,等于下一个节点的地址,//便于找到下一个节点(通过当前节点的next指针)}printf("NULL\n");
}
再给出详细解释:
要将链表内容打印的逻辑拆解到最细微的层面,需要结合单链表 “节点离散存储、指针单向串联” 的底层特性,从每个操作的内存访问逻辑、结构适配原理和潜在风险规避三个维度,逐帧解析打印过程的完整执行链:
一、遍历准备:临时指针的 “安全代理” 设计
这一步的核心是构建 “遍历操作的安全载体”,既要实现对节点的访问,又要绝对保护链表的起始标识,每个细节都针对单链表 “头指针是唯一入口” 的特性设计:
1. 临时指针的创建逻辑
- 头指针的不可替代性:单链表的头指针(如
phead
)存储着第一个节点的内存地址,是访问整个链表的 “根标识”。若直接使用头指针进行遍历(如执行phead = phead->next
),每一次移动都会覆盖头指针的原始值 —— 当遍历到链表末尾时,头指针会指向NULL
,此时整个链表将彻底 “失联”(无法再从头部开始访问任何节点),后续的插入、删除等操作都将失去基础。 - 临时指针的代理作用:创建一个与头指针同类型的临时指针(如
pcur
),并将其初始值设置为头指针的值(即pcur = phead
)。这一操作的本质是:让pcur
复制头指针的 “访问权限”,但pcur
的移动不会影响头指针。例如,头指针phead
指向节点A
,pcur
初始化后也指向A
;当pcur
移动到节点B
时,phead
仍指向A
,确保链表的起始位置始终可追溯。 - 类型匹配的必要性:临时指针必须与头指针类型一致(均为
struct slistnode*
),否则无法正确存储节点地址或访问节点成员(如data
、next
)。类型不匹配会导致编译器报错,或在运行时出现内存访问错位(如将整数地址当作节点地址解析)。
2. 空链表的预适配处理
- 空链表的判定逻辑:若链表为空(
phead == NULL
),临时指针pcur
初始化后也会为NULL
。这一状态是合理的 —— 空链表中没有任何节点,遍历操作本就无需执行,后续循环会直接跳过,避免对NULL
进行解引用(如pcur->data
)导致的崩溃。 - 后续流程的兼容性:即使
pcur
为NULL
,整个打印流程仍能正常执行(循环不进入,直接打印NULL
),确保函数对 “空链表” 和 “非空链表” 的处理逻辑一致,无需额外的分支判断。
二、循环遍历:基于next
指针的 “链式扫描” 机制
循环遍历是打印操作的核心,其设计需精准适配单链表 “每个节点仅能通过next
指针访问后继” 的特性,通过 “条件判断 - 数据读取 - 指针移动” 的三步闭环,实现对所有节点的顺序访问:
1. 循环条件的底层依据(pcur != NULL
)
NULL
的特殊含义:在单链表中,NULL
是 “链表结束” 的天然标识 —— 最后一个节点的next
指针必然指向NULL
,表示 “没有下一个节点”。因此,pcur != NULL
的本质是 “当前指针指向一个有效节点,仍有数据可打印”。- 循环执行的边界:以链表
A->B->C->NULL
为例:- 初始时
pcur
指向A
(pcur != NULL
),进入循环; - 打印
A
后,pcur
移动到B
(pcur != NULL
),继续循环; - 打印
B
后,pcur
移动到C
(pcur != NULL
),继续循环; - 打印
C
后,pcur
移动到NULL
(pcur == NULL
),循环终止。
这一过程确保所有有效节点(A
、B
、C
)都被遍历,且不会越界访问。
- 初始时
2. 数据打印的内存访问细节
- 节点数据域的读取:在循环体内,通过
pcur->data
访问当前节点存储的数据。单链表节点的典型结构为:struct slistnode {int data; // 数据域:存储有效信息struct slistnode* next; // 指针域:指向后继节点 };
pcur->data
的本质是 “通过pcur
存储的节点地址,访问该地址指向的内存块中data
成员的位置”(偏移量固定),这一操作依赖于编译器对结构体成员内存布局的严格管理。 - 打印格式的结构映射:打印格式(如 “
%d->
”)并非随意设计,而是对链表物理结构的直观反映 ——“->
” 符号模拟了next
指针的连接关系,让输出结果(如 “1->2->3->
”)与链表的链式结构(A->B->C
)形成对应,增强可读性。
3. 指针移动的原子操作(pcur = pcur->next
)
- 移动的底层逻辑:
pcur->next
存储着当前节点的后继节点地址(如A->next
存储B
的地址),将其赋值给pcur
,本质是 “更新临时指针的指向,使其从当前节点跳转到下一个节点”。这一操作是遍历的 “推进器”,每执行一次,pcur
就向后移动一个节点。 - 避免 “断链” 风险:指针移动必须在数据打印之后执行 —— 若先移动指针再打印,会遗漏当前节点的数据(如先执行
pcur = pcur->next
,再打印pcur->data
,会跳过A
直接打印B
)。操作顺序的严格性是确保数据完整输出的关键。
三、末尾处理:NULL
打印的 “结构补全” 作用
当循环终止(pcur == NULL
)时,打印NULL
是对链表结构的 “最后补全”,其必要性体现在对链表物理特性的完整呈现:
1. 终止边界的明确标识
- 与链表结构的一致性:单链表的最后一个节点的
next
指针指向NULL
,这是链表的固有属性。打印NULL
能让输出结果(如 “1->2->3->NULL
”)完整还原这一结构,清晰展示 “数据节点链” 到 “终止符” 的完整链条。 - 消除歧义的关键:若省略
NULL
,输出结果会停留在最后一个数据(如 “1->2->3
”),使用者无法判断 “是否还有未打印的节点”(例如,无法区分链表是1->2->3->NULL
还是1->2->3->4
但未打印完)。NULL
的存在从根本上消除了这种歧义。
2. 空链表的特殊场景适配
- 空链表的直观输出:若链表为空(
phead == NULL
),临时指针pcur
初始即为NULL
,循环不执行,直接打印NULL
。这一输出(仅 “NULL
”)准确反映了 “链表中无任何节点” 的状态,与空链表的物理结构(头指针指向NULL
)完全一致。 - 逻辑自洽性保障:无论链表是否为空,末尾打印
NULL
的操作都能让输出结果与链表的物理结构形成对应(非空链表:数据链->NULL
;空链表:NULL
),确保函数在所有场景下的逻辑一致性。
总结:打印操作的 “结构遍历 + 语义还原” 本质
链表内容的打印过程,是对单链表物理结构的 “精准扫描” 与 “语义化呈现” 的结合:
- 物理层面:通过临时指针的移动,顺着
next
指针的串联关系,逐个访问离散存储的节点,适配单链表 “单向访问” 的结构约束; - 语义层面:通过 “
数据->
” 的格式和末尾NULL
的打印,将内存中抽象的指针连接关系,转化为人类可理解的链式结构表示。
整个过程的时间复杂度为O(n)
(需遍历所有n
个节点),空间复杂度为O(1)
(仅使用一个临时指针),既高效又完整地实现了 “遍历节点 - 读取数据 - 呈现结构” 的核心目标,是单链表基础操作中 “结构适配性” 的典型体现。
距离成功,仅剩一步之遥!!!
实现对链表的销毁:
当我们结束了对链表的使用之后,怎么能忘了销毁链表为服务器节省空间呢?于是,实现对链表的销毁,它来了!
要实现链表的彻底销毁,需围绕 “完整释放所有节点内存、安全修改头指针、避免野指针风险” 三个核心目标,结合单链表 “节点离散存储、依赖指针串联” 的特性,将操作拆解为更细致的执行步骤:
一、准备操作:通过二级指针获得头指针的修改权限
这一步的核心是确保能在函数内部修改链表的起始标识(头指针),为后续 “销毁后重置为空” 提供基础:
- 二级指针的作用:链表的头指针(如
phead
)存储着第一个节点的地址,是访问链表的唯一入口。若要在销毁后将头指针置为NULL
(明确标识链表已不存在),必须通过 “二级指针” 操作 —— 因为函数参数的传递是 “值拷贝”,一级指针无法修改外部头指针的实际值,而二级指针存储着头指针的地址,通过它可直接修改头指针本身。 - 形象理解:假设头指针
phead
是一把 “钥匙”,存放于某个抽屉(内存地址)中。要修改这把钥匙(比如换成NULL
),必须知道抽屉的位置(二级指针存储的地址),才能打开抽屉更换钥匙。二级指针的作用就是提供这个 “抽屉位置”。
二、遍历并释放所有节点:分步骤规避访问已释放内存的风险
由于单链表的节点通过next
指针串联,释放节点时需先保存后续节点的地址,否则会因 “当前节点被释放后无法获取下一个节点” 导致遍历中断,具体步骤如下:
创建临时指针用于遍历
定义一个临时指针(如 “当前指针”),初始时让它指向头指针指向的节点(即第一个节点),负责逐个访问并释放节点。这一步的目的是用临时指针承担 “遍历 + 释放” 的任务,避免直接操作头指针导致的起点丢失(虽然销毁后头指针会被置空,但遍历过程中仍需保持头指针的初始值作为参考)。循环释放节点的具体逻辑
循环持续的条件是 “当前指针不为NULL
”(即仍有节点未释放),每次循环包含两个关键操作:- 提前保存下一个节点的地址:在释放当前节点前,先通过当前节点的
next
指针获取下一个节点的地址,并存储到另一个临时指针(如 “下一个指针”)中。这是因为当前节点被释放后,其next
指针指向的内存会变为无效,若此时再访问当前指针->next
,会导致 “访问已释放内存” 的未定义行为(可能崩溃或数据错乱)。 - 释放当前节点并移动指针:调用内存释放函数释放当前指针指向的节点,将其内存归还给系统;然后将 “当前指针” 更新为之前保存的 “下一个指针”,继续遍历下一个节点。
例如:链表为
A->B->C->NULL
,过程如下:- 初始:当前指针指向
A
,下一个指针保存A->next
(即B
的地址); - 释放
A
,当前指针更新为B
; - 下一个指针保存
B->next
(即C
的地址),释放B
,当前指针更新为C
; - 以此类推,直到当前指针指向
NULL
,所有节点释放完毕。
- 提前保存下一个节点的地址:在释放当前节点前,先通过当前节点的
三、重置头指针为NULL
:明确标识链表已销毁
当所有节点都被释放后,需通过二级指针将外部的头指针置为NULL
,这一步的必要性体现在:
- 消除野指针风险:若不重置,头指针仍存储着原第一个节点的地址,但该节点已被释放,头指针会成为 “野指针”(指向无效内存)。后续若误操作头指针(如访问
phead->data
),会导致不可预测的错误。 - 明确链表状态:头指针为
NULL
是 “链表为空” 的标准标识,销毁后重置为NULL
,能让后续操作(如判断链表是否存在)通过头指针的值直观识别链表状态,避免逻辑错误。
总结:销毁操作的本质是 “遍历释放 + 状态清零”
链表的销毁过程,是对单链表 “节点依赖指针串联” 特性的逆向操作:通过提前保存下一个节点地址,确保遍历不中断;通过二级指针修改头指针,确保销毁后状态明确。整个过程的时间复杂度为O(n)
(需遍历所有n
个节点),空间复杂度为O(1)
(仅使用常数个临时指针),核心目标是 “彻底释放内存 + 避免后续误操作”,实现链表的安全销毁。
下面是详细代码:
//实现对链表的销毁的函数
void slistbreak(slistnode** pphead)
{//来分析这个函数,先说明为什么要用二级指针//因为我们最后要对链表中的第一个节点销毁为NULL//所以需要传址调用//因为是要对整个链表销毁,其实就是销毁一个又一个的节点//换句话来说,就是要free掉每一个节点//那么这就需要借助循环了,一个一个下去的free//我们设置一个新变量去指代第一个节点slistnode* pucr = *pphead;while (pucr){free(pucr);//去释放掉pucr所指向的节点pucr = pucr->next;//接着给pucr赋值到下一个节点}//最后要通过传址调用去把第一个节点变为空指针*pphead = NULL;
}
我们再看详细解释:
要将链表销毁的逻辑解析到最极致的细节,需结合单链表 **“节点离散存储、指针单向串联、内存手动管理”的核心特性,从内存操作权限的底层原理、节点释放的原子性步骤、状态清零的安全机制 ** 三个维度,逐行拆解销毁过程的每一个执行细节与硬件级行为:
一、权限准备:二级指针的 “内存级修改权限” 获取
销毁链表的核心是修改外部头指针的实际值(置为NULL
),这要求函数能直接操作头指针变量的内存,因此必须深入理解一级指针与二级指针在参数传递和内存修改上的本质差异:
1. 一级指针的 “值传递” 局限性(硬件级视角)
当函数参数为一级指针(如void slistbreak(slistnode* phead)
)时:
- 调用者的头指针(假设为
phead_main
,存储地址为0x1000
,值为0x2000
,即第一个节点地址)会被拷贝到函数内的phead
变量中(phead
的地址可能为0x3000
,值为0x2000
)。 - 函数内对
phead
的修改(如phead = NULL
),仅会将0x3000
地址处的值改为NULL
,而0x1000
地址处的phead_main
值仍为0x2000
(已释放节点的地址)。 - 结果:销毁后,外部头指针
phead_main
成为野指针(指向已释放的内存0x2000
),后续若通过phead_main
访问节点(如phead_main->data
),会触发内存访问违规(CPU 的 MMU 会检测到非法地址,引发程序崩溃)。
2. 二级指针的 “地址传递” 穿透性(硬件级视角)
当函数参数为二级指针(如void slistbreak(slistnode** pphead)
)时:
- 调用者的头指针
phead_main
的地址(0x1000
)会被传递到函数内的pphead
变量中(pphead
的地址可能为0x4000
,值为0x1000
)。 - 函数内执行
*pphead = NULL
时,实际是对0x1000
地址处的内存进行写操作,将phead_main
的值直接改为NULL
。 - 结果:外部头指针
phead_main
被彻底置空,后续任何通过phead_main
的操作都会因 “空指针” 被及时检测(避免野指针导致的隐性崩溃)。
3. 形象类比:“变量内存与遥控器”
- 头指针
phead_main
是一个 “变量”,存储在内存地址0x1000
,其值是 “第一个节点的地址”(相当于 “电视节目频道号”)。 - 一级指针
phead
是 “频道号的复印件”,修改复印件无法改变电视实际播放的频道。 - 二级指针
pphead
是 “遥控器”,可以直接修改0x1000
地址处的 “频道号”,让电视彻底 “关机”(头指针置NULL
)。
二、节点释放:遍历释放的 “原子化内存操作链”
单链表的节点通过next
指针单向串联,且内存由malloc/free
手动管理,因此释放节点时必须严格遵循 “保存后继地址 → 释放当前节点 → 移动遍历指针” 的原子步骤,避免内存访问错误:
1. 临时指针的 “遍历代理” 本质(内存视角)
创建临时指针pcur
(如存储在0x5000
地址),初始时赋值为*pphead
(即phead_main
的值,假设为0x2000
,指向第一个节点A
)。
- 作用:
pcur
是 “遍历的代理者”,所有 “移动、释放” 操作都通过pcur
完成,而pphead
仅负责最终置空头指针,确保遍历过程有明确的 “起始节点地址” 可参考。
2. 循环释放的 “三步原子操作”(硬件级执行流程)
假设链表为A(0x2000) → B(0x2100) → C(0x2200) → NULL
,详细执行流程如下:
第一次循环(释放节点A
):
保存后继地址:
- 执行
next_node = pcur->next
:pcur
当前值为0x2000
(节点A
的地址),pcur->next
是节点A
的next
指针,值为0x2100
(节点B
的地址)。因此next_node
被赋值为0x2100
(存储在0x5100
地址)。 - 硬件行为:CPU 从
0x2000
地址读取next
指针的值(0x2100
),并写入0x5100
地址。
- 执行
释放当前节点:
- 执行
free(pcur)
:pcur
的值为0x2000
,free
函数会将0x2000
地址对应的内存块标记为 “空闲”,归还操作系统的内存池。 - 硬件行为:操作系统的内存管理器修改 “内存分配表”,将
0x2000
所在的内存块标记为 “可分配”,后续malloc
可复用该内存。
- 执行
移动遍历指针:
- 执行
pcur = next_node
:将0x5100
地址处的0x2100
(节点B
的地址)赋值给pcur
(0x5000
地址处的值变为0x2100
)。 - 硬件行为:CPU 从
0x5100
读取0x2100
,并写入0x5000
。
- 执行
第二次循环(释放节点B
):
保存后继地址:
pcur
为0x2100
(节点B
),pcur->next
为0x2200
(节点C
),next_node
被赋值为0x2200
。
释放节点
B
:free(0x2100)
,0x2100
内存块被标记为空闲。移动指针:
pcur
被赋值为0x2200
(节点C
的地址)。
第三次循环(释放节点C
):
保存后继地址:
pcur
为0x2200
(节点C
),pcur->next
为NULL
,next_node
被赋值为NULL
。
释放节点
C
:free(0x2200)
,0x2200
内存块被标记为空闲。移动指针:
pcur
被赋值为NULL
,循环条件pcur != NULL
不满足,循环终止。
三、状态清零:头指针置空的 “安全收尾机制”
当所有节点释放后,执行*pphead = NULL
,这一步是销毁操作的 “最终保险”,从内存安全和逻辑一致性两个层面保障系统稳定:
1. 内存安全:消除野指针(硬件级保障)
- 若不置空,
phead_main
(地址0x1000
)的值仍为0x2000
(已释放节点A
的地址)。后续若执行if (phead_main != NULL)
,会错误认为链表有效,进而执行phead_main->data
,触发非法内存访问(CPU 尝试从0x2000
读取数据,但该内存已被标记为空闲,可能存储其他数据或触发段错误)。 - 置空后,
phead_main
为NULL
,后续任何通过phead_main
的解引用操作(如phead_main->xxx
)会立即触发 “空指针异常”,该异常在大多数系统中会被捕获(如 C 语言的 segmentation fault),便于开发者快速定位错误,避免隐性崩溃。
2. 逻辑一致性:明确链表状态(软件设计层面)
NULL
是单链表 “空链表” 的标准语义标识。销毁后置空头指针,让后续操作(如 “初始化链表”“判断链表是否为空”)能通过phead == NULL
快速识别链表状态,确保代码逻辑的自洽性。- 例如,初始化函数可直接判断:
if (phead == NULL) { /* 初始化新链表 */ }
,无需额外逻辑区分 “未初始化” 和 “已销毁”。
总结:销毁操作的 “硬件 - 软件协同” 本质
链表销毁是硬件级内存管理与软件级结构设计的协同过程:
- 硬件层面:通过
free
释放物理内存,通过二级指针修改头指针的内存值,确保内存回收与野指针消除; - 软件层面:通过 “保存后继→释放当前→移动指针” 的原子流程,适配单链表 “单向串联” 的结构,通过
NULL
标识确保逻辑一致性。
整个过程的时间复杂度为O(n)
(需遍历n
个节点),空间复杂度为O(1)
(仅用pcur
和next_node
两个临时指针),是 C 语言中手动内存管理与数据结构操作深度结合的典型案例,体现了 “底层内存控制” 与 “高层逻辑设计” 的统一。
到了这里,我们便把链表的初步了解大功告成了,恭喜大家。
下面我们把实现链表的头文件、源文件、测试文件给大家
实现链表的头文件:
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<assert.h>
#include<ctype.h>
#include<errno.h>
#include<float.h>
#include<iso646.h>
#include<limits.h>
#include<locale.h>
#include<math.h>
#include<stdarg.h>
#include<stddef.h>
#include<stdlib.h>
#include<string.h>
#include<time.h>
#include<complex.h>
#include<stdbool.h>
#include<tgmath.h>
#include<signal.h>
#include<setjmp.h>
#include<inttypes.h>typedef int name1;
//将int重命名为name1,方便后面换成其它类型数据时直接修改//链表是一种物理存储结构上非连续、非顺序的存储结构,
//数据元素的逻辑顺序是通过链表中的指针链接次序实现的
//链表的结构跟火车车厢相似,淡季时车次的车厢会相应减少,
//旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉 / 加上,
//不会影响其他车厢,每节车厢都是独立存在的。
//车厢是独立存在的,且每节车厢都有车门。
//想象一下这样的场景,假设每节车厢的车门都是锁上的状态,
//需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?//最简单的做法:每节车厢里都放一把下一节车厢的钥匙。//与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,
//我们称之为“结点 / 节点”
//节点的组成主要有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)。
//图中指针变量 plist保存的是第一个节点的地址,
//我们称plist此时“指向”第一个节点,如果我们希望plist“指向”第二个节点时,
//只需要修改plist保存的内容为0x0012FFA0。
//为什么还需要指针变量来保存下一个节点的位置?
//链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间)
//,我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点。
//结合前面学到的结构体知识,我们可以给出每个节点对应的结构体代码://链表是由一个个节点组成的,而这个节点就是结构体//在链表中,如下我们所设置的结构体变量,其实它就相当于一个节点,
//对于这个结构体变量,我们要在里面设置两个成员变量,
//第一个成员变量,我们叫它data,它的作用就是存储链表中的数据内容,
//比如整型、结构体等等
//通俗易懂一点,它就类似我们之前所学的顺序表中的arr数组,用来存储内容
//而第二个成员变量,那么就是指针变量了,这个指针变量的作用是什么呢?
//因为我们上文有提到,我们要在每个车厢中放下一个车厢的钥匙,这个样子才更加方便
//那么我们所设置的这个成员指针变量,就相当于是那把钥匙,
//经过上面的类比,我们可以知道车厢相当于节点(也就是我们所设置的结构体变量)
//而钥匙相当于我们设置的指针变量,第一个成员变量相当于车厢内的乘客
//而我们都知道,在c语言中,想要快速找到(定位)到一个变量,那毋庸置疑的就是使用地址
//地址就是我们的一个利器,因此,打开下一个车厢的钥匙,就是下一个节点的地址
//于是,为了能够实现在上一个节点(车厢)就能有下一个节点(车厢)的钥匙(地址)
//我们就必须得在上一个结构体变量(节点)中拥有下一个结构体变量(节点)的地址
//而这,就需要我们的第二个成员变量,同时也是结构体的自引用
//于是,我们的这第二个成员变量,就得是我们设置的结构体变量(节点)类型指针
//我们得取这个结构体变量(节点)类型的指针(地址),因为它就相当于是一个节点的地址(钥匙)
//我们用这个变量去等于下一个节点的地址,这么一来,两个节点就建立了联系,也就是链表
struct SList
{name1 data;//节点数据struct SList* next;//指针变量用保存下一个节点的地址
};typedef struct SList slistnode;
//将该结构体变量(节点)重命名//需要知道,我们下面每个函数的参数,基本都是从最开始一个节点出发,
//然后去不断往后走,进入下一个节点,直到碰到了NULL,停止往后走//实现对链表中内容的打印
void slistprint(slistnode* phead);//要记得加分号//实现对链表的尾插
void slistpushback(slistnode** pphead, name1 x);//需要借助循环去找到最后一个节点//实现对链表的头插
void slistpushfront(slistnode** pphead, name1 x);//直接就是传入第一个节点//实现对链表的尾删
void slistpopback(slistnode** pphead);//需要借助循环去找到最后一个节点//实现对链表的头删
void slistpopfront(slistnode** pphead);//直接就是传入第一个节点//实现对链表内容的查找
slistnode* slistfind(slistnode* phead, name1 x);//因为是查找,所以无需传址
//由于我们是查找,所以我们需要返回那个节点的地址//实现对链表指定位置之前的插入
void slistinsertfront(slistnode** pphead, slistnode* position, name1 x);//实现对链表指定位置之后的插入
void slistinsertback(slistnode* position, name1 x);//实现对链表指定位置的删除
void slisterase(slistnode** phead, slistnode* position);//实现对链表指定位置之后节点的删除
void slisteraseafter(slistnode* position);//实现对链表的销毁
void slistbreak(slistnode** pphead);//总结:其实链表理解透了是要比顺序表简单的,因为顺序表是连续的(数组),而链表却像是火车的车厢一样,一节一节的链接起来
//我们对顺序表的插入、删除等操作,还要记得把数据进行前移或者后移,而链表却不用,它的插入和删除等操作,只需要让每一个节点的钥匙变化就行
//不需要所谓的前移或者后移,会比顺序表方便,所以线性表中不仅要有数据,还要有有效数据个数和空间大小,而链表只有数据和指向下一个节点的钥匙
//但是链表也有它的困难点,比较明显的就是需要二级指针,因为链表需要判断是否为空链表的情况,在插入数据时
//如果是空链表的话,我们就得让我们创建的新节点去代替那原先链表的第一个节点,而这就需要二级指针来进行传址调用
//这算是一个小难点,需要我们多加注意
//还有就是借助循环查找节点的操作,这个也需要理解,这些在slist.c文件中都有进行解释
//总而言之就是,在链表中,我们要注意判断是否为空链表(第一个节点就为空)的情况,这我觉得应该算是最麻烦的一点
//需要注意的是,链表没有进行初始化的函数,因为这其实是需要我们在test.c中自己初始化的
实现链表的源文件:
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<assert.h>
#include<ctype.h>
#include<errno.h>
#include<float.h>
#include<iso646.h>
#include<limits.h>
#include<locale.h>
#include<math.h>
#include<stdarg.h>
#include<stddef.h>
#include<stdlib.h>
#include<string.h>
#include<time.h>
#include<complex.h>
#include<stdbool.h>
#include<tgmath.h>
#include<signal.h>
#include<setjmp.h>
#include<inttypes.h>
#include <cassert>#include "SList.h"//实现对链表中内容的打印的函数
void slistprint(slistnode* phead)//第一个节点
{//使用pcur代替phead进行遍历,避免直接操作头指针导致头指针丢失//(如果直接修改phead,会导致后续无法再访问链表的起始位置)。slistnode* pcur = phead;//当pcur == NULL时,说明已经遍历到链表的最后一个节点的next//(即链表末尾),循环结束。while (pcur != NULL){printf("%d->", pcur->data);pcur = pcur->next;//对pcur不断赋值更新,等于下一个节点的地址,//便于找到下一个节点(通过当前节点的next指针)}printf("NULL\n");
}//实现创建一个新节点的函数
slistnode* newslist(name1 x)
{//创建一个新节点,最后也是直接返回这个节点的地址(指针),slistnode* newnode = (slistnode*)malloc(sizeof(slistnode));//同样要申请的空间大小为一个节点所占的空间节点//将我们要插入的数据传进我们新创建的节点中newnode->data = x;//要记得把这个新节点中的钥匙(下一个节点的地址)赋值为NULLnewnode->next = NULL;//这一步是为了尾插准备的,也是一个良好的编程习惯//但是在头插之中,我们就得对newnode->next进行重新赋值,//赋值为原先第一个节点的地址//不要忘记把这个新节点地址返回return newnode;}//实现对链表的尾插的函数
void slistpushback(slistnode** pphead, name1 x)
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//C 语言函数参数传递是 “值传递”——phead是头指针的副本(拷贝)。//当链表为空时(phead原本是NULL),函数内修改phead = newnode,//只会改变这个副本的值,不会影响外部真正的头指针(外部头指针依然是NULL),//导致尾插失败。//二级指针pphead的类型是slistnode * *,//它存储的是 “头指针变量的地址”。通过它可以直接修改头指针本身的值//pphead等价于外部的头指针(如plist),//修改* pphead就是直接修改外部头指针的值。//当链表为空时(* pphead == NULL),//必须通过二级指针才能让外部头指针指向新节点(否则头指针永远是NULL)。//为了统一处理 “空链表” 和 “非空链表” 两种情况,二级指针是最简洁的设计。//slistnode** pphead 相当于是一个变量的地址的指针(即一个变量的指针的指针)//slistnode* pphead 相当于是一个变量的地址(即一个变量的指针)//因为我们进行尾插,那么也就代表链表一定要有一定的空间//链表不像顺序表,我们要对链表插入数据,那我们就必须创建一个新节点//并把该节点加到原本链表的后面,同时让原本最后一个节点的钥匙(next)//指向我们新加入节点的地址//所以我们可以直接再设计一个函数去创建新节点,//同时把我们要输入的数据一并输入到那个新创建的节点中//这么一来就可以在后续的头插等插入直接调用,不用重复书写//但是与顺序表不同,顺序表中的申请新空间的函数是不需要返回值的//因为它是直接就在内部修改了//但是链表的该函数就需要有返回值,要去返回我们新创建的那个节点的地址slistnode* newnode = newslist(x);//我们上述所说的要用二级指针就是用于下面的第一个节点要是为空的情况//因为要是第一个节点就是为空的话,那我们想要对第一个节点变为我们创建的新节点//就得从根本上去改变那第一个节点,也就是将第一个节点的地址改为我们新节点的地址//这样子才算是使用了传址调用//如果第一个节点的地址变为NULL(即第一个节点为空)(空链表)//我们可以类比我们之前学的传值调用和传址调用。if ((*pphead) == NULL){//因为第一个节点为空,那么我们所创建的新节点,便需要赋值给该链表的第一个节点//即让我们创建的新节点变为链表的第一个节点*pphead = newnode;//注意了注意了,因为我们在test.c中是传入我们所开辟链表的第一个节点的地址的地址//所以可以像上述那样直接传址调用修改,这样子到最后第一个节点的地址能被顺利改变}//如果没有进入上面的if语句,进入到下面的else语句(非空链表)//那就代表第一个节点不为空,那我们就可以使用循环去找到链表的最后一个节点,//然后把我们创建的新节点接到那个节点之后else{//设置一个新变量去遍历链表,避免我们的头指针遭到破坏slistnode* temp = *pphead;//这个循环的作用是去找到最后一个节点,判断的前提条件//就是最后一个节点的钥匙(next)的值是为NULLwhile (temp->next != NULL){//如果一直没到上述我们所说的,就一直去往后走,进入下一个节点temp = temp->next;}//最后,我们要把我们所找到的原先链表的最后一个节点中的钥匙(next)//指向我们新创建的节点的地址,此时temp指向的就是新链表的尾节点temp->next = newnode;//还是因为newnode本身就算是一个地址,所以我们不用取地址}
}//实现对链表的头插的函数
void slistpushfront(slistnode** pphead, name1 x)
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//其实头插的原理和尾插的原理类似,所以它也得用二级指针(地址)//如果是空链表(第一个节点就为空的话),//那我们就把我们新创建的节点地址赋值给该链表的第一个节点//如果不是空链表,那我们就把我们创建的新节点接到原链表第一个节点的前面//让这个新节点中的钥匙(地址)指向上述的第一个节点slistnode* newnode = newslist(x);//因为我们要对原链表第一个节点直接进行修改,所以我们得直接调用ppheadif (*pphead == NULL)//为空链表{*pphead = newnode;//使用传址调用,直接修改第一个节点地址(被覆盖为新节点地址)}else//不为空链表{//这里也是一个关键点,我们要让我们创建的新节点的钥匙指向原先链表的//第一个节点,意思就是要让那把钥匙存储着第一个节点的地址//由于我们是创建了二级指针,所以可以直接让新节点的next对于第一个节点的地址//也就是如下操作newnode->next = *pphead;//虽然在上面的函数(newslist)中,我们已经给//newnode中的钥匙指向NULL,但是我们还是可以修改//接下来,因为我们是头插,所以肯定得让我们的新节点变为新链表的第一个节点//而这就需要我们让新节点的地址变为*pphead*pphead = newnode;//注意了注意了,因为我们在test.c中是传入我们所开辟链表的第一个节点的地址的地址//所以可以像上述那样直接传址调用修改,这样子到最后第一个节点的地址能被顺利改变}//其实,对于头插,我们不用if判断是不是空链表也行,//因为在那else中,我们有看到新节点地址赋值给链表中第一个节点的地址//这一步其实就是if中的操作,二者等效,不像尾插那样//尾插需要是因为如果第一个节点就是空的话,那那个节点是没有资格//存储我们所创建的新节点的地址的//当然,在这边其实我还是建议用if判断一下,更好理解,//后面写多了,熟练了,就可以不用了
}//实现对链表的尾删的函数
void slistpopback(slistnode** pphead)//需要借助循环去找到最后一个节点
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//因为要进行尾删,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,//那还删什么assert(*pphead);//因为我们要进行尾删,其实也就是把链表中的最后一个节点给它删掉//意思就是要把我们曾经为那最后一个节点开辟的空间给释放掉//所以就得用到free和赋值为NULL//但是在这之前,我们得先去找到那最后一个节点//所以就得用到while()循环了//值得一提的是为了不破坏我们的原指针,所以我们得创建新变量去代替我们的原指针//但是呢,还有一个注意点,那就是我们得把那倒数第二个节点中的钥匙赋值为NULL//这样倒数第二个才算是成为了新链表的最后一个节点//那我们要如何实现这个步骤呢?//这就需要我们再创建一个变量,用这个变量可以去指代那倒数第二个节点//注意,我们要考虑到链表中只有一个节点的情况,那我们就要直接释放那一个节点//只有一个节点时if ((*pphead)->next == NULL)//注意操作符优先性,->大于*,所以我们要加上括号{free(*pphead);*pphead = NULL;}//为什么我们要考虑到只有一个节点的情况呢?//其实和下面的pucr有关,因为我们知道,pucr最后是要指向倒二个节点的//那要是链表只有一个节点的话,pucr该何去何从//因此,我们需要考虑到这一点else//代表不是只有一个节点{slistnode* pucr = *pphead;//二级指针要记得解引用为一级指针slistnode* ptail = *pphead;while (ptail->next != NULL){pucr = ptail;ptail = ptail->next;}//这两步就是找到单链表的尾节点,并记录尾节点的前一个节点//当我们的pucr记录到倒数第二个节点的地址时//很明显,ptail就到了最后一个节点中存储的地址(为NULL)//这时候循环结束,pucr为倒数第二个的地址,ptail为最后一个节点的地址//此时已经找到了最后一个节点,为ptailfree(ptail);ptail = NULL;//把那倒数第二个节点中的钥匙赋值为NULLpucr->next = NULL;}
}//实现对链表的头删的函数
void slistpopfront(slistnode** pphead)//直接就是传入第一个节点
{//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//因为要进行头删,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,//那还删什么assert(*pphead);//因为要进行头删,顾名思义,就是要把链表中的第一个节点删去//同时还要让那第二个节点变为新链表的第一个节点//这就意味着我们需要把第一个节点的空间free掉//但在此之前,我们还需要考虑一下如何把第二个节点变为新链表的第一个节点//我们知道,我们给这个函数传入的是原链表的第一个节点的地址的地址//而这第一个节点中的钥匙便存储着第二个节点的地址,//于是,为了不让第一个节点删除后,痛失第二个节点的地址//我们就得创建一个新变量去接收那第二个节点的地址(也就是*pphead->next)//在做完这些后,我们就得实现去把那第二个节点变为第一个节点//这边我们知道,在test.c中,我们是设置了一个新变量去等于第一个节点的地址//又因为我们是用的二级指针(传址调用),所以我们可以通过//让*pphead去等于我们第二个节点的地址(这又用到了创建一个新变量去接收那第二个节点的地址),//这么一来,不就相当于实现了把那第二个节点变为第一个节点slistnode* temp = (*pphead)->next;//注意符号优先性//让*pphead去等于我们第二个节点的地址(*pphead) = temp;//实现了把那第二个节点变为第一个节点
}//实现对链表内容的查找的函数
slistnode* slistfind(slistnode* phead, name1 x)//因为是查找,所以无需传址
//由于我们是查找,所以我们需要返回那个节点的地址
{//其实这个查找函数就简单多了//我们直接借助循环://当找到了我们要查找的内容,就返回该节点地址//要是没找到的话,就去下一个节点寻找//当然,要是找到最后一个节点都没有的话,就需要返回NULLslistnode* pcur = phead;while (pcur){if (pcur->data == x){return pcur;}pcur = pcur->next;//要是没找到的话,就去下一个节点寻找}return NULL; //要是找到最后一个节点都没有的话,就需要返回NULL
}//实现对链表指定位置之前的插入的函数
void slistinsertfront(slistnode** pphead, slistnode* position, name1 x)
{//其实有和下面的实现对链表指定位置之后的插入对比一下的话//大概会很疑惑,为什么这个函数的多了一个第一个节点的二级指针呢?//其实很简单,因为我们是对指定节点之前进行插入,那是不是意味着//我们既需要创建一个新节点,同时这个新节点的钥匙是指向我们指定的位置(节点)的//同时还得让我们指定的节点的前一个节点的钥匙指向我们创建的新节点//这也就意味着,我们需要去找到position的前一个节点//这个时候,pphead的作用就有了,它需要去通过循环去变为指定节点的前一个节点//至于为什么要用二级指针,那是因为我们有可能要进行头插//(即我们指定的节点是链表的第一个节点,那这么一来就得头插)//而头插,是需要二级指针的,我们可以直接调用我们之前写的头插函数//那么问题来了,我们需不需要把头插这个情况单独列出来呢?//答案是需要的,因为我们需要一个新变量去指代position的前一个节点//那要是我们输入的节点是第一个节点的话,那那个新变量不就越界了//会发生啥也找不到的情况//得先判断传来的二级指针是否为空,如果为空的话,谈何插入assert(pphead);//因为要进行,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,assert(*pphead);//也可以判断一下我们传入的位置是不是NULLassert(position);//先处理我们指定的节点是第一个节点的情况if (position == (*pphead)){slistpushfront(pphead, x);//直接调用之前所写的头插函数}//我们指定的节点不是第一个节点的情况else{//先创建一个新节点,插入我们想要的数据slistnode* newnode = newslist(x);slistnode* pucr = *pphead; //需要一个新变量去指代position的前一个节点//while的条件限制当一个节点中的钥匙指向我们所指定的节点时//此时就代表是position的前一个节点while (pucr->next != position){pucr = pucr->next;//如果还没到的话,就继续往后找}//出来循环之后,此时的pucr就是position的前一个节点//把这个新节点的钥匙指向我们指定的位置(节点)newnode->next = position;//同时还得让我们指定的节点的前一个节点的钥匙指向我们创建的新节点pucr->next = newnode;}}//实现对链表指定位置之后的插入的函数
void slistinsertback(slistnode* position, name1 x)
{//这个相对于实现对链表指定位置之前的插入就简单多了//我们只需要创建一个新节点,//然后让这个新节点的钥匙指向指定位置节点的下一个节点//再让我们指定的节点的钥匙指向新节点就行//也可以判断一下我们传入的位置是不是NULLassert(position);//先创建一个新节点,插入我们想要的数据slistnode* newnode = newslist(x);//然后让这个新节点的钥匙指向指定位置节点的下一个节点//先存储一下下一个节点的地址(也就是position->next)slistnode* temp = position->next;newnode->next = temp;//再让我们指定的节点的钥匙指向新节点position->next = newnode;//别忘了newnode本身可是新节点的地址哦
}//实现对链表指定位置的删除的函数
void slisterase(slistnode** pphead, slistnode* position)
{//得先判断传来的二级指针是否为空,如果为空的话,谈何删除assert(pphead);//因为要进行,所以我们还得多一个断言,毕竟如果第一个节点都没有内容,谈何删除assert(*pphead);//也可以判断一下我们传入的位置是不是NULLassert(position);//我们来分析一下如何实现对链表指定位置的删除//意思就是我们要把指定位置的那个节点释放掉//但是为了链表的连续性,//这就需要我们把该节点的前一个节点的钥匙指向该节点的下一个节点//这也就意味着,我们需要去找到position的前一个节点//这个时候,pphead的作用就有了,它需要去通过循环去变为指定节点的前一个节点//至于为什么要用二级指针,那是因为我们有可能要进行头删//(即我们指定的节点是链表的第一个节点,那这么一来就得头删)//而头删,是需要二级指针的,我们可以直接调用我们之前写的头删函数//那么问题来了,我们需不需要把头删这个情况单独列出来呢?//答案是需要的,因为我们需要一个新变量去指代position的前一个节点//那要是我们输入的节点是第一个节点的话,那那个新变量不就越界了//会发生啥也找不到的情况//先处理我们指定的节点是第一个节点的情况if (position == (*pphead)){slistpopfront(pphead);//直接调用之前所写的头删函数}//我们指定的节点不是第一个节点的情况else{slistnode* pucr = *pphead;while (pucr->next != position)//当pucr没有到指定位置的前一个节点{pucr = pucr->next;//如果还没到的话,就继续往后找}//出来循环之后,此时的pucr就是position的前一个节点//接着我们就得把pucr的钥匙指向position的下一个节点pucr->next = position->next;//然后我们开始释放positionfree(position);position = NULL;//要理解 “为什么释放一级指针position就能删除对应的节点”,核心在于指针与内存的关系//指针本质是 “地址的容器”,而free函数操作的是指针指向的内存,而非指针本身。//1. 先明确:position是什么?//position是一个一级指针变量,它的作用是存储 “要删除的节点” 在内存中的地址。//比如,假设链表中有一个节点 A,其内存地址是0x123456,那么position变量中就存储着0x123456,即position = &节点A。//2. free(position)的真正含义//free函数的功能是:释放指针所指向的内存块,而不是 “删除指针变量本身”。//当执行free(position)时://函数会找到position中存储的地址(比如0x123456),将这块内存归还给操作系统,//使其成为 “无效内存”(后续无法再访问)。//这意味着,“要删除的节点” 所占用的内存被彻底释放,该节点从内存中消失。//3. 为什么不需要 “二级指针” 来释放节点?//二级指针(如pphead)的作用是修改指针变量本身的值(比如修改头指针的指向)。//而释放节点的核心是回收节点占用的内存,只需要知道 “节点在内存中的地址” //即可 ——position已经存储了这个地址,所以用一级指针足够。}
}//实现对链表指定位置之后节点的删除的函数
void slisteraseafter(slistnode* position)
{//判断一下我们传入的位置是不是NULLassert(position);//让我们来分析一下如何实现对链表指定位置之后节点的删除//注意这里是要对指定位置的节点的下一个节点进行删除//所以不需要把指定位置节点的上一个节点的钥匙指向指定位置节点的下一个节点//这里只需要让position这个节点的钥匙指向下下个节点就行了//然后把下一个节点给释放了//但是也会有问题存在,那就是我们必须得创建一个变量去指代//指定节点下下一个节点的地址,不难一旦指定节点下一个节点被释放了//那去哪里找下下一个节点,不仅如此我们还得设置一个变量去指定节点的下一个节点//然后把我们所设置的这个节点给释放掉slistnode* temp = position->next;//此时temp指向指定节点的下一个节点//我们让指定节点的钥匙指向下下个节点position->next = temp->next;//temp->next指向指定节点的下一个节点的下一个节点//然后我们就可以把指定节点的下一个节点给释放掉了free(temp);temp = NULL;//为什么释放临时变量temp就能删除指定节点的下一个节点?//position->next 原本就指向 “要删除的节点”(即position的下一个节点)的内存地址。//把position->next的值赋给temp后,temp和position->next指向同一块内存// (都是 “要删除的节点” 的地址)。//free函数的作用是释放指针所指向的内存,而不是 “释放指针本身”。//这里temp指向的是 “position的下一个节点” 的内存,//所以free(temp)实际上是把这块内存归还给系统,使其不可再访问。//即使temp和position->next原本指向同一块内存//只要用其中一个指针释放了这块内存,这块内存就被回收了 //无论其他指针是否还记录着这个地址,都不能再使用该内存(否则会导致 “野指针访问” 错误)。
}//实现对链表的销毁的函数
void slistbreak(slistnode** pphead)
{//来分析这个函数,先说明为什么要用二级指针//因为我们最后要对链表中的第一个节点销毁为NULL//所以需要传址调用//因为是要对整个链表销毁,其实就是销毁一个又一个的节点//换句话来说,就是要free掉每一个节点//那么这就需要借助循环了,一个一个下去的free//我们设置一个新变量去指代第一个节点slistnode* pucr = *pphead;while (pucr){free(pucr);//去释放掉pucr所指向的节点pucr = pucr->next;//接着给pucr赋值到下一个节点}//最后要通过传址调用去把第一个节点变为空指针*pphead = NULL;
}
链表的测试文件:
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<assert.h>
#include<ctype.h>
#include<errno.h>
#include<float.h>
#include<iso646.h>
#include<limits.h>
#include<locale.h>
#include<math.h>
#include<stdarg.h>
#include<stddef.h>
#include<stdlib.h>
#include<string.h>
#include<time.h>
#include<complex.h>
#include<stdbool.h>
#include<tgmath.h>
#include<signal.h>
#include<setjmp.h>#include "SList.h"// 测试辅助函数:打印测试标题分隔线
void print_test_title(const char* title) {printf("\n=============================================\n");printf("========= 测试: %s =========\n", title);printf("=============================================\n");
}// 测试辅助函数:验证测试结果
void verify_result(int condition, const char* success_msg, const char* fail_msg) {if (condition) {printf("[SUCCESS] %s\n", success_msg);} else {printf("[FAILED] %s\n", fail_msg);}
}// 1. 测试链表初始化与空链表状态
void test_initialization() {print_test_title("链表初始化与空链表状态");slistnode* plist = NULL;printf("空链表打印测试: ");slistprint(plist);verify_result(plist == NULL, "空链表初始化成功", "空链表初始化失败");
}// 2. 测试尾插操作(包括空链表尾插、单节点尾插、多节点尾插)
void test_push_back() {print_test_title("尾插操作");// 测试1: 空链表尾插slistnode* plist1 = NULL;printf("测试1: 空链表尾插1\n");slistpushback(&plist1, 1);slistprint(plist1);verify_result(plist1 != NULL && plist1->data == 1 && plist1->next == NULL, "空链表尾插成功", "空链表尾插失败");// 测试2: 单节点链表尾插printf("\n测试2: 单节点链表尾插2\n");slistpushback(&plist1, 2);slistprint(plist1);verify_result(plist1->next != NULL && plist1->next->data == 2 && plist1->next->next == NULL, "单节点尾插成功", "单节点尾插失败");// 测试3: 多节点链表连续尾插printf("\n测试3: 多节点链表连续尾插3,4,5\n");slistpushback(&plist1, 3);slistpushback(&plist1, 4);slistpushback(&plist1, 5);slistprint(plist1);// 验证尾节点数据slistnode* cur = plist1;while (cur->next != NULL) {cur = cur->next;}verify_result(cur->data == 5, "多节点尾插成功", "多节点尾插失败");// 清理测试数据slistbreak(&plist1);verify_result(plist1 == NULL, "尾插测试后链表销毁成功", "尾插测试后链表销毁失败");
}// 3. 测试头插操作(包括空链表头插、单节点头插、多节点头插)
void test_push_front() {print_test_title("头插操作");// 测试1: 空链表头插slistnode* plist2 = NULL;printf("测试1: 空链表头插10\n");slistpushfront(&plist2, 10);slistprint(plist2);verify_result(plist2 != NULL && plist2->data == 10 && plist2->next == NULL, "空链表头插成功", "空链表头插失败");// 测试2: 单节点链表头插printf("\n测试2: 单节点链表头插20\n");slistpushfront(&plist2, 20);slistprint(plist2);verify_result(plist2->data == 20 && plist2->next != NULL && plist2->next->data == 10, "单节点头插成功", "单节点头插失败");// 测试3: 多节点链表连续头插printf("\n测试3: 多节点链表连续头插30,40\n");slistpushfront(&plist2, 30);slistpushfront(&plist2, 40);slistprint(plist2);verify_result(plist2->data == 40 && plist2->next->data == 30, "多节点头插成功", "多节点头插失败");// 清理测试数据slistbreak(&plist2);verify_result(plist2 == NULL, "头插测试后链表销毁成功", "头插测试后链表销毁失败");
}// 4. 测试尾删操作(包括单节点尾删、多节点尾删)
void test_pop_back() {print_test_title("尾删操作");// 准备测试数据: 1->2->3->4->5->NULLslistnode* plist3 = NULL;slistpushback(&plist3, 1);slistpushback(&plist3, 2);slistpushback(&plist3, 3);slistpushback(&plist3, 4);slistpushback(&plist3, 5);printf("初始链表: ");slistprint(plist3);// 测试1: 多节点尾删printf("\n测试1: 第一次尾删(删除5)\n");slistpopback(&plist3);slistprint(plist3);// 验证尾节点数据为4slistnode* cur = plist3;while (cur->next != NULL) {cur = cur->next;}verify_result(cur->data == 4, "多节点尾删成功", "多节点尾删失败");// 测试2: 继续尾删至只剩一个节点printf("\n测试2: 继续尾删至只剩一个节点\n");slistpopback(&plist3); // 删除4slistpopback(&plist3); // 删除3slistpopback(&plist3); // 删除2slistprint(plist3);verify_result(plist3 != NULL && plist3->data == 1 && plist3->next == NULL, "尾删至单节点成功", "尾删至单节点失败");// 测试3: 单节点尾删printf("\n测试3: 单节点尾删(删除1)\n");slistpopback(&plist3);slistprint(plist3);verify_result(plist3 == NULL, "单节点尾删成功", "单节点尾删失败");// 清理测试数据slistbreak(&plist3);
}// 5. 测试头删操作(包括单节点头删、多节点头删)
void test_pop_front() {print_test_title("头删操作");// 准备测试数据: 10->20->30->40->50->NULLslistnode* plist4 = NULL;slistpushback(&plist4, 10);slistpushback(&plist4, 20);slistpushback(&plist4, 30);slistpushback(&plist4, 40);slistpushback(&plist4, 50);printf("初始链表: ");slistprint(plist4);// 测试1: 多节点头删printf("\n测试1: 第一次头删(删除10)\n");slistpopfront(&plist4);slistprint(plist4);verify_result(plist4->data == 20, "多节点头删成功", "多节点头删失败");// 测试2: 继续头删至只剩一个节点printf("\n测试2: 继续头删至只剩一个节点\n");slistpopfront(&plist4); // 删除20slistpopfront(&plist4); // 删除30slistpopfront(&plist4); // 删除40slistprint(plist4);verify_result(plist4 != NULL && plist4->data == 50 && plist4->next == NULL, "头删至单节点成功", "头删至单节点失败");// 测试3: 单节点头删printf("\n测试3: 单节点头删(删除50)\n");slistpopfront(&plist4);slistprint(plist4);verify_result(plist4 == NULL, "单节点头删成功", "单节点头删失败");// 清理测试数据slistbreak(&plist4);
}// 6. 测试查找操作(包括存在的元素、不存在的元素)
void test_find() {print_test_title("查找操作");// 准备测试数据: 100->200->300->400->500->NULLslistnode* plist5 = NULL;slistpushback(&plist5, 100);slistpushback(&plist5, 200);slistpushback(&plist5, 300);slistpushback(&plist5, 400);slistpushback(&plist5, 500);printf("初始链表: ");slistprint(plist5);// 测试1: 查找存在的元素printf("\n测试1: 查找存在的元素300\n");slistnode* find_node1 = slistfind(plist5, 300);if (find_node1 != NULL) {printf("找到节点,数据为: %d\n", find_node1->data);}verify_result(find_node1 != NULL && find_node1->data == 300, "查找存在元素成功", "查找存在元素失败");// 测试2: 查找不存在的元素printf("\n测试2: 查找不存在的元素600\n");slistnode* find_node2 = slistfind(plist5, 600);if (find_node2 == NULL) {printf("未找到节点\n");}verify_result(find_node2 == NULL, "查找不存在元素处理正确", "查找不存在元素处理错误");// 测试3: 空链表查找printf("\n测试3: 空链表查找元素100\n");slistbreak(&plist5);slistnode* find_node3 = slistfind(plist5, 100);verify_result(find_node3 == NULL, "空链表查找处理正确", "空链表查找处理错误");
}// 7. 测试指定位置前插入操作
void test_insert_front() {print_test_title("指定位置前插入操作");// 准备测试数据: 1->2->3->4->5->NULLslistnode* plist6 = NULL;slistpushback(&plist6, 1);slistpushback(&plist6, 2);slistpushback(&plist6, 3);slistpushback(&plist6, 4);slistpushback(&plist6, 5);printf("初始链表: ");slistprint(plist6);// 测试1: 在中间位置前插入printf("\n测试1: 在3前插入30\n");slistnode* pos1 = slistfind(plist6, 3);slistinsertfront(&plist6, pos1, 30);slistprint(plist6);// 验证插入结果slistnode* check1 = slistfind(plist6, 30);verify_result(check1 != NULL && check1->next == pos1, "中间位置前插入成功", "中间位置前插入失败");// 测试2: 在头节点前插入(相当于头插)printf("\n测试2: 在头节点1前插入10\n");slistnode* pos2 = slistfind(plist6, 1);slistinsertfront(&plist6, pos2, 10);slistprint(plist6);// 验证插入结果verify_result(plist6->data == 10 && plist6->next == pos2, "头节点前插入成功", "头节点前插入失败");// 测试3: 在尾节点前插入printf("\n测试3: 在5前插入50\n");slistnode* pos3 = slistfind(plist6, 5);slistinsertfront(&plist6, pos3, 50);slistprint(plist6);// 验证插入结果slistnode* check3 = slistfind(plist6, 50);verify_result(check3 != NULL && check3->next == pos3, "尾节点前插入成功", "尾节点前插入失败");// 清理测试数据slistbreak(&plist6);
}// 8. 测试指定位置后插入操作
void test_insert_back() {print_test_title("指定位置后插入操作");// 准备测试数据: 1->2->3->4->5->NULLslistnode* plist7 = NULL;slistpushback(&plist7, 1);slistpushback(&plist7, 2);slistpushback(&plist7, 3);slistpushback(&plist7, 4);slistpushback(&plist7, 5);printf("初始链表: ");slistprint(plist7);// 测试1: 在中间位置后插入printf("\n测试1: 在3后插入30\n");slistnode* pos1 = slistfind(plist7, 3);slistinsertback(pos1, 30);slistprint(plist7);// 验证插入结果verify_result(pos1->next != NULL && pos1->next->data == 30, "中间位置后插入成功", "中间位置后插入失败");// 测试2: 在头节点后插入printf("\n测试2: 在1后插入10\n");slistnode* pos2 = slistfind(plist7, 1);slistinsertback(pos2, 10);slistprint(plist7);// 验证插入结果verify_result(pos2->next != NULL && pos2->next->data == 10, "头节点后插入成功", "头节点后插入失败");// 测试3: 在尾节点后插入(相当于尾插)printf("\n测试3: 在5后插入50\n");slistnode* pos3 = slistfind(plist7, 5);slistinsertback(pos3, 50);slistprint(plist7);// 验证插入结果verify_result(pos3->next != NULL && pos3->next->data == 50 && pos3->next->next == NULL, "尾节点后插入成功", "尾节点后插入失败");// 清理测试数据slistbreak(&plist7);
}// 9. 测试指定位置删除操作
void test_erase() {print_test_title("指定位置删除操作");// 准备测试数据: 1->2->3->4->5->NULLslistnode* plist8 = NULL;slistpushback(&plist8, 1);slistpushback(&plist8, 2);slistpushback(&plist8, 3);slistpushback(&plist8, 4);slistpushback(&plist8, 5);printf("初始链表: ");slistprint(plist8);// 测试1: 删除中间节点printf("\n测试1: 删除节点3\n");slistnode* pos1 = slistfind(plist8, 3);slistnode* next_node1 = pos1->next; // 保存删除节点的下一个节点slisterase(&plist8, pos1);slistprint(plist8);// 验证删除结果(原来3的前一个节点2的next应该指向4)slistnode* check1 = slistfind(plist8, 2);verify_result(check1 != NULL && check1->next == next_node1, "中间节点删除成功", "中间节点删除失败");// 测试2: 删除头节点printf("\n测试2: 删除头节点1\n");slistnode* pos2 = slistfind(plist8, 1);slistnode* next_node2 = pos2->next; // 保存删除节点的下一个节点slisterase(&plist8, pos2);slistprint(plist8);// 验证删除结果(头指针应该指向原来的第二个节点2)verify_result(plist8 == next_node2 && plist8->data == 2, "头节点删除成功", "头节点删除失败");// 测试3: 删除尾节点printf("\n测试3: 删除尾节点5\n");slistnode* pos3 = slistfind(plist8, 5);slisterase(&plist8, pos3);slistprint(plist8);// 验证删除结果(原来5的前一个节点4的next应该为NULL)slistnode* check3 = slistfind(plist8, 4);verify_result(check3 != NULL && check3->next == NULL, "尾节点删除成功", "尾节点删除失败");// 清理测试数据slistbreak(&plist8);
}// 10. 测试指定位置后删除操作
void test_erase_after() {print_test_title("指定位置后删除操作");// 准备测试数据: 1->2->3->4->5->NULLslistnode* plist9 = NULL;slistpushback(&plist9, 1);slistpushback(&plist9, 2);slistpushback(&plist9, 3);slistpushback(&plist9, 4);slistpushback(&plist9, 5);printf("初始链表: ");slistprint(plist9);// 测试1: 删除中间节点后的节点printf("\n测试1: 删除节点2后的节点\n");slistnode* pos1 = slistfind(plist9, 2);slistnode* next_node1 = pos1->next->next; // 保存要删除节点的下一个节点slisteraseafter(pos1);slistprint(plist9);// 验证删除结果(2的next应该指向4)verify_result(pos1->next == next_node1 && pos1->next->data == 4, "中间节点后删除成功", "中间节点后删除失败");// 测试2: 删除头节点后的节点printf("\n测试2: 删除头节点1后的节点\n");slistnode* pos2 = slistfind(plist9, 1);slistnode* next_node2 = pos2->next->next; // 保存要删除节点的下一个节点slisteraseafter(pos2);slistprint(plist9);// 验证删除结果(1的next应该指向4)verify_result(pos2->next == next_node2 && pos2->next->data == 4, "头节点后删除成功", "头节点后删除失败");// 测试3: 尝试删除尾节点后的节点(应该失败)printf("\n测试3: 尝试删除尾节点5后的节点\n");slistnode* pos3 = slistfind(plist9, 5);slisteraseafter(pos3); // 应该提示错误slistprint(plist9);// 验证删除结果(5的next仍然为NULL)verify_result(pos3->next == NULL, "尾节点后删除处理正确", "尾节点后删除处理错误");// 清理测试数据slistbreak(&plist9);
}// 11. 测试链表销毁操作
void test_destroy() {print_test_title("链表销毁操作");// 测试1: 销毁多节点链表slistnode* plist10 = NULL;slistpushback(&plist10, 1);slistpushback(&plist10, 2);slistpushback(&plist10, 3);printf("销毁前链表: ");slistprint(plist10);slistbreak(&plist10);printf("销毁后链表: ");slistprint(plist10);verify_result(plist10 == NULL, "多节点链表销毁成功", "多节点链表销毁失败");// 测试2: 销毁空链表(边界测试)printf("\n测试销毁空链表\n");slistnode* plist11 = NULL;slistbreak(&plist11);verify_result(plist11 == NULL, "空链表销毁成功", "空链表销毁失败");// 测试3: 销毁单节点链表printf("\n测试销毁单节点链表\n");slistnode* plist12 = NULL;slistpushback(&plist12, 100);printf("销毁前链表: ");slistprint(plist12);slistbreak(&plist12);printf("销毁后链表: ");slistprint(plist12);verify_result(plist12 == NULL, "单节点链表销毁成功", "单节点链表销毁失败");
}// 12. 综合测试:混合操作测试
void test_comprehensive() {print_test_title("综合操作测试");slistnode* plist = NULL;printf("初始空链表: ");slistprint(plist);// 混合操作序列printf("\n1. 尾插1,2,3\n");slistpushback(&plist, 1);slistpushback(&plist, 2);slistpushback(&plist, 3);slistprint(plist);printf("\n2. 头插0\n");slistpushfront(&plist, 0);slistprint(plist);printf("\n3. 在2前插入20\n");slistnode* pos = slistfind(plist, 2);slistinsertfront(&plist, pos, 20);slistprint(plist);printf("\n4. 在0后插入5\n");pos = slistfind(plist, 0);slistinsertback(pos, 5);slistprint(plist);printf("\n5. 删除节点20\n");pos = slistfind(plist, 20);slisterase(&plist, pos);slistprint(plist);printf("\n6. 删除1后的节点\n");pos = slistfind(plist, 1);slisteraseafter(pos);slistprint(plist);printf("\n7. 头删一次\n");slistpopfront(&plist);slistprint(plist);printf("\n8. 尾删一次\n");slistpopback(&plist);slistprint(plist);// 验证最终结果应为: 5->1->3->NULLint result = 1;if (plist == NULL || plist->data != 5) result = 0;else if (plist->next == NULL || plist->next->data != 1) result = 0;else if (plist->next->next == NULL || plist->next->next->data != 3) result = 0;else if (plist->next->next->next != NULL) result = 0;verify_result(result, "综合操作测试成功", "综合操作测试失败");// 清理测试数据slistbreak(&plist);
}int main() {printf("===== 单向链表全面测试程序 =====\n");// 依次执行所有测试test_initialization();test_push_back();test_push_front();test_pop_back();test_pop_front();test_find();test_insert_front();test_insert_back();test_erase();test_erase_after();test_destroy();test_comprehensive();printf("\n===== 所有测试执行完毕 =====\n");return 0;
}
结语:
在数据结构的浩瀚星空中,单链表恰似一位技艺精湛的 “链环工匠”,它以指针为无形之绳,将散落于内存各处的离散节点巧妙串联,在应对动态变化的数据需求时,展现出独树一帜的灵活性与适应性。从单个节点的创建到整个链表的销毁,从头部、尾部的快速增删到指定位置的精准操作,我们一步步拨开单链表的神秘面纱,逐渐理解其 “链式连接、动态分配” 的核心特性,也在这一过程中,触摸到数据结构世界最基础也最精妙的逻辑脉络。
单链表的构成,看似简单却暗藏玄机。每个节点如同工匠手中的链环,由两部分组成:一部分是用于存储数据的数据域,它承载着链表的核心信息,可能是一个整数、一个字符,或是一个复杂的结构体;另一部分则是指针域,它存储着下一个节点的地址,如同链环上的接口,将当前节点与下一个节点紧密相连。正是这小小的指针,赋予了单链表 “动态” 的灵魂 —— 与数组需要预先分配连续内存空间不同,单链表的节点可以根据需要随时创建,分散在内存的各个角落,只需通过指针域的指引,便能像一串珍珠般被有序访问。这种特性让单链表在处理数据量不确定的场景时极具优势,它不会像数组那样因预先分配过大空间而造成内存浪费,也不会因空间不足而被迫进行繁琐的扩容操作。
节点的创建,是构建单链表的第一步,也是对动态内存管理的初次实践。在 C 语言中,我们通常使用 malloc 函数为节点分配内存空间,这一过程就像工匠为链环打造雏形。malloc 函数会从堆区申请一块与节点大小相符的内存,并返回这块内存的首地址。此时,我们需要对返回的指针进行检查 —— 若指针为 NULL,则意味着内存分配失败,这可能是由于系统内存不足等原因导致,必须进行相应的错误处理,否则后续操作将引发程序崩溃。成功分配内存后,我们为节点的数据域赋值,并将指针域初始化为 NULL,一个独立的节点便宣告创建完成。这一步看似简单,却蕴含着对内存的敬畏:每一块内存的申请都应被谨慎对待,因为它直接关系到程序的稳定性与可靠性。
当多个节点通过指针域连接起来,便形成了单链表。链表的头部,通常由一个头指针标识,它指向链表的第一个节点。若头指针为 NULL,则表示这是一个空链表,如同一位工匠尚未开始锻造链环。与数组通过下标访问元素不同,单链表的访问必须从头部开始,沿着指针域依次遍历,这种 “顺序访问” 的特性是由其链式结构决定的。遍历单链表的过程,就像工匠沿着链环一步步前行:从首节点出发,通过首节点的指针域找到第二个节点,再通过第二个节点的指针域找到第三个节点,直到某个节点的指针域为 NULL,便抵达了链表的尾部。这种遍历方式虽然在访问随机元素时效率不及数组,但在数据的动态增删场景中,却能发挥出独特的优势。
头部插入操作,堪称单链表最具代表性的操作之一,其核心要义在于 “新节点领航”。当我们需要在链表头部插入一个新节点时,首先要确保新节点的指针域指向原链表的头节点 —— 这一步如同让新链环的接口与原有链环的起始端相连。随后,将头指针重新指向新节点,使新节点成为链表的新起点。整个过程无需移动其他节点,仅通过两次指针操作便能完成,时间复杂度为 O (1),充分体现了单链表在头部操作时的高效性。
与头部插入相对应的,是头部删除操作。其原理是先保存头节点的地址,再将头指针指向头节点的下一个节点,最后通过 free 函数释放原头节点的内存空间。这一过程就像将链条的第一个环取下并销毁,操作同样简洁高效。但需要注意的是,在进行删除操作前,必须判断链表是否为空 —— 若头指针为 NULL,则无法进行删除,否则会导致对空指针的操作,引发程序错误。这种边界条件的校验,是编写健壮代码的基本要求,也体现了逻辑思维的严谨性。
尾部操作则展现了单链表的另一番逻辑。尾部插入时,需要先遍历整个链表,找到尾节点 —— 即指针域为 NULL 的节点,然后将尾节点的指针域指向新节点。这一过程的时间复杂度为 O (n),其中 n 为链表的长度,因为遍历操作需要从头至尾访问每个节点。与头部插入相比,尾部插入的效率较低,但在某些场景下,如构建一个按插入顺序排列的链表时,却是必不可少的操作。尾部删除的过程更为复杂:不仅要找到尾节点,还要找到尾节点的前一个节点(前驱节点),然后将前驱节点的指针域设为 NULL,并释放尾节点的内存。为了找到前驱节点,同样需要遍历链表,因此时间复杂度也为 O (n)。在这个过程中,若链表中只有一个节点,删除操作后需要将头指针设为 NULL,以避免出现野指针 —— 这种特殊情况的处理,考验着开发者对链表结构的深刻理解。
指定位置的操作,则是对单链表指针运用的更高层次考验,核心在于 “精准锚定”。无论是在指定位置插入节点,还是删除指定位置的节点,都需要先定位到目标位置的前驱节点。以插入操作为例:假设要在第 i 个节点前插入新节点,首先要找到第 i-1 个节点(前驱节点),然后将新节点的指针域指向第 i 个节点,再将前驱节点的指针域指向新节点。这一系列操作如同在链条的特定位置插入新环,需要精准找到插入点的前后接口,稍有不慎便会导致链表断裂或节点丢失。定位前驱节点的过程同样需要遍历链表,时间复杂度为 O (n),但插入或删除的核心操作仍为 O (1),体现了单链表在局部操作上的高效性。
在单链表的所有操作中,指针的运用是贯穿始终的核心。一级指针通常用于遍历节点,通过不断将指针指向其下一个节点(如 p = p->next),实现对链表的顺序访问。而当需要修改链表的核心标识(如头指针)时,二级指针便派上了用场。在 C 语言中,函数参数的传递遵循 “值传递” 原则,若要在函数内部修改一级指针的值(如头指针),则必须通过二级指针(指针的指针)来实现。例如,在实现头插函数时,若函数参数为一级指针,则函数内部对指针的修改无法影响到外部的头指针,因为传递的只是指针的副本;而使用二级指针作为参数时,函数内部可以通过解引用操作(如 * head = new_node)直接修改外部头指针的值,从而实现对链表结构的有效操控。这种通过地址传递实现间接修改的方式,是 C 语言中处理复杂数据结构的关键技巧,也是理解 “值传递” 限制下如何操作指针的核心要义。
单链表的销毁操作,是对内存管理的终极考验。销毁链表意味着释放所有节点所占用的内存空间,避免内存泄漏。这一过程需要从头部开始,依次释放每个节点:首先保存当前节点的下一个节点地址,然后释放当前节点,再将指针移至下一个节点,重复这一过程直到链表为空。在销毁过程中,若遗漏任何一个节点,都会造成内存泄漏 —— 这是程序开发中难以察觉却危害极大的问题。因此,销毁操作必须严谨细致,确保每一块动态分配的内存都被正确释放。同时,销毁完成后,应将头指针设为 NULL,防止其成为指向已释放内存的野指针,避免后续操作引发不可预知的错误。
除了基本操作,单链表的学习还涉及诸多细节与技巧。断言(assert)的使用便是其中之一,它可以在程序运行时检查某些关键条件是否满足,如指针不为 NULL 等。在调试阶段,断言能够帮助我们快速定位错误,提高代码的可靠性。但需要注意的是,断言在 Release 版本中通常会被忽略,因此不能替代实际的错误处理代码。
单链表的逆置操作,则是对指针操控能力的综合检验。逆置的核心思想是通过改变节点之间的指针指向,使链表的顺序颠倒过来。具体而言,我们可以使用三个指针:prev(指向当前节点的前驱)、curr(指向当前节点)、next(指向当前节点的后继)。通过循环遍历链表,依次将 curr 的指针域指向 prev,然后更新三个指针的位置,直到 curr 为 NULL。最后,将原头指针指向原尾节点(即逆置后的头节点)。这一过程看似简单,实则需要对指针的移动和指向变化有清晰的把握,任何一步的失误都可能导致链表断裂或陷入死循环。
单链表的应用场景也十分广泛。在实现栈和队列时,单链表可以提供高效的插入和删除操作:栈的 “后进先出” 特性可以通过头部插入和头部删除来实现;队列的 “先进先出” 特性则可以通过尾部插入和头部删除来实现。与数组实现的栈和队列相比,链表实现的栈和队列无需预先分配固定大小的空间,能更好地适应数据量的动态变化。
在哈希表的实现中,单链表也扮演着重要角色。当哈希函数出现冲突(即不同的关键字映射到同一哈希地址)时,通常采用链地址法来解决 —— 将哈希地址相同的元素通过单链表串联起来。这种方式能够有效处理冲突,提高哈希表的查询效率。
此外,单链表还可以用于实现多项式的表示与运算。每个节点可以存储多项式的一项(系数和指数),通过链表的形式将各项按指数大小排序,便于进行多项式的加法、乘法等运算。与数组表示相比,链表表示更节省空间,尤其是在处理稀疏多项式(即系数为 0 的项较多)时。
单链表的学习,是数据结构入门的重要一课,它不仅让我们掌握了一种基础的数据组织方式,更培养了我们 “从指针视角看内存” 的思维 —— 这种思维将贯穿于后续栈、队列、树、图等更复杂结构的学习中。树的节点包含指向子节点的指针,图的邻接表本质上是由多个单链表组成的数组,这些复杂结构的操作,都离不开对指针的精准操控和对链式思维的深刻理解。
当我们能够熟练地操控链表的每一个节点,自如地进行增删改查时,便已敲开了数据结构与算法世界的大门。每一次指针的移动,都是对内存地址的精准定位;每一次节点的增删,都是对逻辑结构的巧妙调整。这种对细节的把控能力,将成为我们解决更复杂问题的基础。
或许单链表并非在所有场景下都是最优选择。在需要频繁随机访问元素的场景中,数组的效率远高于单链表;在需要双向遍历的场景中,双向链表更为合适。但单链表所承载的 “链式思维” 与 “指针艺术”,却具有不可替代的价值。它教会我们如何在离散的内存空间中建立有序的联系,如何通过简洁的指针操作实现复杂的数据处理,如何在动态变化的环境中保持数据结构的稳定性。
单链表的学习过程,也是对逻辑思维和编程能力的全面锻炼。从分析问题到设计算法,从编写代码到调试运行,每一个环节都需要严谨的逻辑和清晰的思路。当我们为了解决某个操作中的边界条件而反复推敲时,当我们为了优化算法效率而不断调整指针操作顺序时,我们的逻辑思维能力也在潜移默化中得到提升。
同时,单链表的学习也让我们深刻认识到内存管理的重要性。动态内存的分配与释放,就像资源的获取与归还,需要遵守严格的规则。每一次 malloc 都对应着一次 free,每一次指针的指向变化都要确保不会产生野指针或内存泄漏。这种对资源的敬畏之心,是成为一名优秀程序员的基本素养。
在未来的学习和工作中,我们可能会遇到各种复杂的数据结构和算法,但单链表所教会我们的思维方式和编程技巧,将成为我们探索未知领域的重要基石。当我们面对一个复杂的问题时,或许可以像拆解链表一样,将其分解为一个个简单的子问题,然后通过 “指针” 般的逻辑连接,逐步构建起解决方案的大厦。
单链表的世界,虽小却蕴藏着大智慧。它以最朴素的方式,诠释了数据结构的本质 —— 如何组织数据以高效地满足需求。当我们真正理解了单链表的每一个细节,掌握了指针操作的精髓,便会发现,数据结构的世界不再神秘,算法的设计也不再晦涩。
愿这段探索单链表的旅程,能成为我们数据结构学习之路上的一个坚实起点。让我们带着从单链表中领悟到的链式思维和指针艺术,继续探索栈、队列、树、图等更广阔的领域,在数据的海洋中搭建起属于自己的逻辑大厦,用代码编织出解决问题的精妙方案。无论未来遇到何种复杂的挑战,都能想起这些由指针串联起来的智慧,从容拆解,步步为营,在数据结构与算法的世界中不断前行,绽放光彩。
最后,我想对初学编程的人们说:
亲爱的编程初学者,当你第一次敲下代码,看着屏幕上跳出的 “Hello World”,那份欣喜或许就是你与编程世界的初遇。别担心此刻的迷茫 —— 每个编程大神都曾对着报错信息皱眉,都曾在指针的迷宫里打转,都曾为一个逻辑漏洞熬到深夜。编程的路上,笨拙和犯错从来不是绊脚石,而是必经的阶梯。
你可能会觉得语法规则枯燥,循环嵌套绕晕头脑,数据结构抽象得像空中楼阁。但请相信,那些看似复杂的概念,就像拼图的碎片,今天懂一块,明天拼一片,终有一天会突然连成完整的画面。当你第一次独立写出排序算法,第一次用链表实现增删改查,那种 “原来我也可以” 的成就感,会让所有的挫败都变得值得。
编程的魅力,不在于天赋异禀,而在于 “死磕” 的勇气。遇到 Bug 别逃避,它是在提醒你哪里还有漏洞;看不懂源码别退缩,逐行拆解的过程就是最好的成长。就像学骑自行车,摔过几次跤,才能找到平衡的感觉 —— 编程也是如此,多敲一行代码,就多一分底气;多解决一个问题,就多一分自信。
请珍惜此刻的 “空白”。你眼前的每一个知识点都是新的,每一次尝试都充满可能。别被 “我是新手” 的标签束缚,大胆去试、去错、去探索。编程世界从不拒绝慢行者,只淘汰半途而废的人。
慢慢来,一步一步踩稳。今天的笨拙,会变成明天的熟练;此刻的困惑,终将成为未来的通透。终有一天,你会看着自己写出的代码高效运行,会笑着回忆当初为一个分号纠结的夜晚。那时候你会明白:所有的坚持,都在为发光的瞬间铺路。继续走吧,编程的星辰大海,正在前方等你。
诸君共勉!!!