ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

链表核心知识拆解:从结构遍历到逆序、循环链表与嵌入式应用

链表核心知识拆解:从结构遍历到逆序、循环链表与嵌入式应用 从用数组写代码到第一次被链表题吊打几乎是每个学编程的人都要经历的一道坎。记得我刚开始刷题的时候单链表逆序这一个题目就折腾了整整一个晚上指针指来指去最后把自己绕晕了程序一运行直接段错误。后来做了几年工程回头看链表这东西其实核心不是语法而是心智模型——你得在脑子里看清楚每个节点的 next 到底应该指向谁哪里会断哪里会成环。这篇内容就是围绕链表学习中最常见、也最容易被卡住的知识点展开的包括基础结构、遍历、插入、逆序、循环单链表、链表集合差集以及嵌入式场景里链表的真实用法。无论是刚学数据结构的新手还是准备面试、想补基础的老手都可以照着这份思路一步步捋清楚把链表的思维真正变成自己的东西。1. 为什么学了数组还要学链表从内存布局谈起很多人对链表的第一个困惑是数组用得好好的下标一访问多方便为什么非要搞个链表出来这个问题的答案不在代码里而在内存布局上。数组在内存里是一段连续的空间。声明一个int arr[10]编译器帮你划出 40 个字节的连续区域每个元素紧挨着前一个。正因为连续所以arr[i]可以按首地址 i * 4直接算出来这就是所谓的随机访问——想跳哪就跳哪时间复杂度 O(1)。但代价也在这里想在数组中间插入一个元素你得把后面的元素全部往后挪删除同理得全往前挪。如果一个数组存的是几万个元素插一次就挪几万个代价相当大。而且数组的容量是定死的声明了 10 个就最多用 10 个想扩容只能重新申请一块更大的内存再把老数据拷贝过去。链表则完全不同。它的每个节点是单独分配的散落在内存各个角落节点之间靠指针“牵线”连起来。这种非连续布局带来两个直接好处。第一插入和删除只需要改指针不需要挪动其他数据理论上的时间复杂度是 O(1)——前提是你已经站在目标节点的位置上。第二链表天然支持动态扩容来一个数据就分配一个节点不用预知总量。你要是写一个日志系统每秒进几百条不定长的记录数组很容易爆链表配合动态内存管理就从容得多。这里可以打一个比方。数组就像电影院里的连排座位座位号固定、一个挨一个你坐第 5 排第 6 座检票员看一眼票就能告诉你往哪走。链表则像一场游园会里手拉手排队的游客每个人只知道自己后面跟着谁想找第 100 个人只能从队头一个一个人问过去。所以链表的“查找”相对慢随机访问只能从头部开始逐个走时间复杂度是 O(n)。实际工程里的一个常见场景更直观。嵌入式设备或者游戏服务器里玩家上线、掉线、切换房间非常频繁如果用数组管理在线玩家列表每次掉线都要把后面的人整体前移用双向链表的话掉线只是把前后两个邻居的指针互相一接这个节点就摘出去了两步操作干净利落。理解了数组和链表在内存布局上的本质差异后面所有操作——遍历、插入、删除、逆序——你就知道为什么要那么写了。数组的痛点是“移动”链表的痛点是“查找”两者反过来正好互补。2. 链表的基石节点结构、头指针与头节点三个最容易混淆的概念刷链表题目也好看开源代码也好很多人一开始就被几个词搞晕节点、头指针、头节点、首元节点。这四个东西到底什么关系我见过不少面试者能背出结构体定义但一画图就乱。先把这块彻底掰开揉碎。2.1 节点的本质数据域 指针域链表的基本单元是节点任何语言的实现都逃不开两个部分存的“数据”和指向“下一个”的指针/引用。C 语言里最常见的定义是这样struct Node { int data; // 数据域这个节点存的值 struct Node *next; // 指针域指向下一个节点 };Python 里没有指针的概念但“引用”本质上干的是同一件事class Node: def __init__(self, data): self.data data self.next None # 初始化为 None表示暂时不指向任何节点Java 则是显式的对象引用class ListNode { int val; ListNode next; ListNode(int val) { this.val val; } }三种语言一个模型。每个节点就像一个“手拉手的积木块”积木块上刻着数据伸出一只手抓住下一个积木块。最后一个节点的 next 指向空NULL / None / null表示队伍到此为止。2.2 头指针与头节点一字之差写法完全不一样很多教材反复强调“头指针”和“头节点”初学者很容易当成一回事其实它们是两个概念。头指针head pointer)指向链表第一个节点的指针变量。它本身就是那个“入口地址”。哪怕链表为空头指针也得有个值——要么是 NULL表示一个空表要么指向头节点。头节点header node / dummy node在真正的第一个数据节点之前额外附加的一个节点。它的 data 域通常不存数据可以用作哨兵。为什么要引入头节点核心原因是让空链表的处理和其他情况统一。举个例子单链表实现“在头部插入元素”如果没有头节点每次都得单独判断 head 是不是 NULL插入后还要把 head 更新为新节点有头节点的话新节点永远插在 head 之后逻辑就变成完全一致的循环操作代码简洁也不容易漏写分支。面试中常说的 dummy node 技巧本质就是这里的头节点思想的延伸——很多涉及“删除当前节点”或“逆序后返回新头”的题目加一个 dummy 节点可以省掉大量对边界的 if 判断。有一点必须注意有头节点 ≠ 链表非空。判断链表是否为空的正确姿势是看head-next NULL而不是看head NULL。这点如果搞混了遍历时很容易就出现空指针访问。我用 C 写链表时被这种问题坑过不止一次后来养成的习惯是每次操作前先画一遍状态图再动代码。3. 高频操作逐个拆解遍历、插入、删除的正确打开方式链表也就那么几个基本操作但每一个都藏着边界条件和细节。这些操作写顺了后面的逆序、合并、求差集才有底气。3.1 遍历从 head 出发一路 next 到 NULL遍历是所有操作的基础也是最简单的环节。C 语言里一个标准遍历长这样void traverse(struct Node *head) { struct Node *p head; // 从头开始 while (p ! NULL) { // 只要还没到链表末尾 printf(%d , p-data); // 访问当前节点 p p-next; // 移动到下一个节点 } }两个容易犯的错不要让原来的 head 指针乱跑。有些人图省事直接while (head)遍历head 走完了链表入口地址也丢了后面再想操作找不到头。正确做法是用临时指针 p 去遍历。循环条件写成p-next ! NULL会漏掉最后一个节点。这种写法会在你打印到倒数第二个节点时停下来因为最后一个节点的 next 是 NULL但节点本身还没被访问。我见过很多刚学的人在这个地方输出少一个数反复检查代码也看不出问题其实就是 while 的条件差了一点点。Python 版本的遍历逻辑完全一致只是写法更像“日常操作”def traverse(head): p head while p is not None: print(p.data, end ) p p.next3.2 插入头插法、尾插法、指定位置插入插入分几种常见情况最基础的是头插法和尾插法。头插法是把新节点放在链表的开头适用于“反转前的构造”、栈结构模拟等场景。没有头节点时头插的代码是struct Node* insertAtHead(struct Node *head, int val) { struct Node *newNode (struct Node*)malloc(sizeof(struct Node)); newNode-data val; newNode-next head; // 新节点指向原来的头 return newNode; // 新节点成为新的头 }注意这里要返回新的 head因为链表的入口变了。函数内部直接改head是改不出去的只能靠返回值或者struct Node **二级指针。尾插法则要先找到当前的最后一个节点再让它的 next 指向新节点void insertAtTail(struct Node *head, int val) { struct Node *newNode (struct Node*)malloc(sizeof(struct Node)); newNode-data val; newNode-next NULL; if (head NULL) { head newNode; // 空表直接作为头 return; } struct Node *p head; while (p-next ! NULL) { // 注意找尾节点看 p-next不是看 p p p-next; } p-next newNode; }这里有个细节值得强调找最后一个节点的循环条件是p-next ! NULL而不是p ! NULL。因为如果 p 已经走到 NULL说明已经跳出链表了你反而找不到“最后一个节点”在哪里。这个和遍历打印的边界条件是相反的虽然只是几个字符的差别但意义完全不同。很多报错“段错误”或者“插入的元素根本没接上”都是因为这个条件写反了。指定位置插入需要同时记录前一个节点 pre 和当前节点 cur。插入的本质就是新节点先指向 cur再让 pre 的 next 指向新节点。这个顺序不能反如果先改了 pre-nextcur 就找不到了链表也就断了。口诀就是“先连后断”——新节点先把路接出去再改前驱的指针。3.3 删除先接旁路再摘节点C 语言还得多一步 free删除一个节点的核心思路是让前一个节点的 next跳过当前节点直接指向当前节点的 next。示意图写出来就是pre-next cur-next。这个操作本身类似两个邻居手拉手中间的人退出队伍。但不同语言处理的收尾工作不一样。Python、Java 有垃圾回收节点没人引用了会自动回收C 语言必须手动释放内存struct Node *temp cur-next; pre-next cur-next; // 先把节点从链上摘下来 free(cur); // 再释放节点占用的内存顺序上先把指针接好再 free这是硬性要求否则你用到的cur已经是一块被释放的野指针内存了。删除操作里我喜欢加一个 dummy 头节点技巧。比如“删除链表中所有值等于目标值的节点”带头节点的写法可以统一处理头部节点和其他位置节点的情况不用单独为了“如果头节点就是要删的”写一遍特判。这也是刷题时最高频、最实用的套路。4. 逆序与循环单链表两个最容易翻车的进阶考点如果说遍历、插入、删除是基本功那逆序逆置和循环单链表就是第一次分水岭。这两个知识点在笔试、面试里出现频率极高而且热词里同时出现了“python单链表逆序”“逆置链表”“循环单链表”“单循环链表”说明大家普遍在这个位置卡壳。4.1 单链表逆序三个指针从头走到尾单链表是单向的一个节点只能知道自己后面是谁不知道前面是谁。所以逆序的过程只能通过“边走边改方向”来完成。经典做法是三个指针pre前驱、cur当前、next后继。以 C 语言为例struct Node* reverse(struct Node *head) { struct Node *pre NULL; struct Node *cur head; while (cur ! NULL) { struct Node *next cur-next; // 1. 先保存后继防止断链 cur-next pre; // 2. 把当前节点的指针反向 pre cur; // 3. pre 前移 cur next; // 4. cur 前移 } return pre; // 循环结束时 pre 是新链表的头 }每一步都像是在“拆链子重接”。尤其第 1 步如果没先保存cur-next一旦执行第 2 步原来的后继就丢了整条链直接裂开。很多人的代码在 while 的第二轮就卡死或者段错误基本都是因为这个。Python 实现思路一模一样只是变量名和语法换了一下def reverse(head): pre None cur head while cur is not None: nxt cur.next # 先保住后路 cur.next pre # 指针翻转 pre cur # pre 往前走 cur nxt # cur 也往前走 return pre # 新的头递归写法也值得掌握递归的思考角度是“先把从第二个节点开始的子链表逆序再把头节点接到逆序结果的尾部。”但递归要小心链表很长时栈溢出的问题工程上更推荐迭代式。4.2 逆置链表的一个常见陷阱新头到底是谁逆序结束后原来的 head 变成了链表尾巴它的 next 被改成 NULL表示终点原来的尾节点变成了新 head。很多人在调用 reverse 后还用旧的 head 指针去遍历结果什么也遍历不出来——因为旧的 head 已经成了最后一个节点head-next NULL打印一个节点就结束了。务必记住reverse 一定要接收返回值这个返回值才是新链表的入口。面试里还喜欢在这个基础上出变体比如“逆置链表的前 K 个节点”“每 K 个一组逆置”。这些不过是在三指针基础上加了区间控制逻辑核心模型没变先把基础版本练到不看代码能画出来再研究变体。4.3 循环单链表尾节点不指向 NULL而是指向头循环单链表和普通单链表的唯一区别在于最后一个节点的 next 不再指向 NULL而是指回链表的头节点形成一个环。这个结构有什么用最典型的场景是约瑟夫环问题——一群人围成一圈报数数到某个数字的人出列继续从下一个人开始数直到剩下最后一个。这种问题天然适合循环链表因为“一圈”是一个不断轮转的结构用线性链表每次都得重置遍历起点用循环链表只要一直往下走就行。循环链表的遍历终止条件变了这也是最容易被坑的地方。普通链表判断p NULL结束循环链表永远不会有 NULL你得改成判断p-next head或者记录起始点比如struct Node *p head; do { printf(%d , p-data); p p-next; } while (p ! head); // 回到 head说明一圈走完了这里特意用了 do-while 而不是 while是因为循环链表里 head 本身也要被访问一次。如果用 while 先判断再访问head 会被跳过。循环链表的插入和删除同样要额外小心“接环”的问题。尤其在中间位置删除节点时要注意被删节点是否是 head 本身——删除后 head 要不要更新取决于你的循环链表是否持续以某个节点为“逻辑入口”。实际工程里循环链表常用于轮询调度、环形缓冲、定时器管理这类“从头到尾再从头”的场景管理得当的话比普通链表省掉了大量边界判断。5. 从刷题到落地链表集合差集与嵌入式场景里的真实应用很多人学完链表总觉得这东西只活在题目里实际项目根本用不上。其实恰恰相反链表在工程中的应用非常广泛但形态和你刷题时写的struct Node不完全一样。这里拿两个热词展开说基于链表的两个集合差集以及嵌入式链表代码示例。5.1 基于链表的两个集合差集思路比代码更重要题目通常长这样有两个单链表 A 和 B每个链表中的元素互不重复求 A 中存在但 B 中不存在的元素输出一个新的链表。第一个反应可能是暴力解法——对 A 的每个节点遍历一遍 B 去查重时间复杂度 O(n*m)。这样写没问题但不够好。面试官更希望你想到“哈希辅助”的思路遍历链表 B把 B 中的每个元素放进一个哈希集合Python 的 setJava 的 HashSet。再遍历链表 A逐个判断元素是否在哈希集合里。不在的就插入到结果链表。这样查找的时间复杂度降到了 O(1) 平均整体 O(n m)链表本身的遍历成本不变但比较成本被哈希抹平了。Python 写出来非常短def difference(listA, listB): setB set() p listB while p is not None: setB.add(p.data) p p.next dummy Node(0) # 头节点猛得很统一头插逻辑 tail dummy p listA while p is not None: if p.data not in setB: tail.next Node(p.data) tail tail.next p p.next return dummy.next # 跳过 dummy返回真正的结果链表这里又用到了 dummy 节点这正是前面第 2 节反复强调的“头节点思想”。我在实际写这段代码时习惯让自己只操作 dummy 和 tail尽量避免特判“结果链表的第一个节点为空”的情况思路会清爽很多。注意一个容易犯的逻辑错误集合差集要求“元素去重”如果题目没有明确说明链表内没有重复元素你得先对 A 做一次去重或者用集合来维护“已经在结果里出现过的元素”。否则 A 里有两个相同的 5 B 里没有 5你的结果链表可能会输出两个 5这就违背了“集合”的定义。5.2 嵌入式链表侵入式节点设计才是工程主流嵌入式里谈链表和教科书上写的链表有一个重要区别教科书通常是“结构体里有 next 指针”嵌入式工程则常用“next 指针嵌在结构体里”也就是所谓的侵入式链表。课本题材是“数据结构里放一个指针”工程里是“指针结构里放数据”——很多刚转嵌入式开发的人第一次看到内核代码会怀疑这到底是不是链表因为压根看不到next和data并排出现。Linux 内核里的list_head就是典型代表。定义一个链表节点的时候你只需要struct list_head { struct list_head *next, *prev; // 双向循环链表 };然后把这个节点嵌到任何你关心的结构体里struct task_struct { // ...各种字段... struct list_head tasks; // 把自己挂入任务链表 };这样同一个list_head节点可以同时挂在多个链表上而且一个内嵌节点就能连起一整个“任务列表”不需要单独定义“任务节点”这种中间层。内核通过container_of宏从list_head的地址反推出整个task_struct的地址这种玩法在应用层开发里几乎见不到但极其高效。嵌入式场景里链表还有一个常见用途内存池的空闲块管理。系统启动时把一大块内存切成很多固定大小的块用一个单向链表串起来随时分配和回收。我维护过一个简单内存池空闲链表只需要记录“下一个空闲块在哪”分配时从头取一个释放时把头指针指向释放的块、释放块再指向原来的头两步操作完成O(1) 时间代价极低。在这种场景下链表就是最匹配的伙伴——不用像数组那样担心空洞和碎片也不用实现复杂的内存分配算法。5.3 不同语言里的链表C 结构体写法与 Java 类的取舍热词里有“c结构体链表基本语法”和“java链表”。C 里最常见的写法有两种结构体风格和类风格。结构体风格基本沿袭 C但更现代的做法是用构造函数简化初始化struct Node { int data; Node* next; Node(int val) : data(val), next(nullptr) {} };这里next(nullptr)是关键把“新节点默认不指向任何人”这个语义直接写进构造函数里省得每次 malloc 完还要手动置空。Java 里实现链表时面试和工作中几乎不用LinkedList内置类去“写算法”因为题目考察的是你怎么组织节点的引用关系。所以标准做法是内部静态类和显式 next 操作。多写几个完整的节点类你就理解了Java 的引用本质上就是 C 的指针只是不能用算术运算反而更安全不容易出现越界访问的段错误。6. 写在之后链表真正的价值是逼你想清楚“引用到底指向谁”学链表最折磨人的地方恰恰是它最值钱的地方。数组是不需要“指针思维”的——下标就是一切你不需要关心数据之间的物理联系。但链表强迫你回答一个问题此刻这个引用指向哪里操作之后它又该指向哪里这个想明白了逆序不是问题循环链表不是问题嵌入式里的侵入式链表也能顺藤摸瓜看懂。我给你一个我一直在用的学习路径建议拿到任何一道链表题先不要急着写代码拿纸笔画 5 个节点的链表把每一步指针变化的箭头重新画一遍直到你能闭着眼说出 pre、cur、next 三者每一步的位置。这个过程看着笨却是最快的内化方式。代码可以忘画图这个基本功不会忘它解决的是你脑子里有没有链表这个模型的问题。另外聊一个容易被忽视的细节写链表代码尤其是 C/C 时时刻问自己三个问题——我访问的是不是野指针我的循环退出之后边界节点有没有被正确处理我修改过的节点还有没有其他指针指向它这三个问题覆盖了绝大多数链表 bug我排查过的线上内存问题里有一大半最后都能归到这几类。希望这篇内容能帮你少走点弯路把链表从“背代码”变成“真理解”。
返回列表