数据结构与算法里面的单向链表,可以说是我见过最容易被低估的一个知识点。很多人觉得它不过就是“用指针串起来的一堆节点”,笔试的时候能写个反转就算过关,可真到需要你自己从零手写一个链表、或者在生产环境下排查因为链表引起的问题时,才发现处处都是坑。这篇文章想把单向链表从结构定义、手写实现、复杂度分析,到实际业务场景和排查技巧完整串一遍,也把我这些年踩过的坑,以及带新同事时反复被问到的典型问题整理出来,帮你把它彻底吃透。
这部分内容适合几类人:正在学数据结构但还没建立“指针思维”的新手,准备面试想快速把链表题练扎实的同学,以及写了很久业务代码、想回头系统补一下基础的人。链表本身不复杂,复杂的是背后那套“用关系组织零散数据”的思路,而这一层想明白了,后面学双向链表、循环链表、跳跃表都会顺很多。
1. 为什么几乎所有数据结构课程,都把链表放在最前面
1.1 链表的本质:把“相邻”从物理位置变成一种约定
数组之所以能快速访问,是因为它在内存里是一块连续的空间,你只要知道起始地址和下标,就能通过地址偏移找到第 n 个元素。这种“连续即相邻”的逻辑非常直观,但也有限制:你必须提前知道一共要多少空间,而且一旦扩容,往往需要整块搬家。
链表的思路完全不同。它不要求数据在内存里连续存放,而是让每个节点记住“下一个节点在哪里”。你可以把链表想像成小时候玩的那种串联小卡片:每张卡片正面写数据,背面写一张小纸条,纸条上画着下一张卡片的存放位置。你想找到第三张卡片,必须先找到第一张,通过它背面的指示找到第二张,再通过第二张找到第三张。这就是单向链表最核心的思想:顺序关系不靠内存地址天然连续,而靠每个节点显式保存的指针。
这种设计带来了一个很关键的特性——链表里的数据可以“东一块西一块”地散落在内存里。只要节点之间通过指针保持连接,它们不管物理位置在哪,逻辑上依然是有序的整体。这个理解一旦建立,你再看链表的所有操作,核心就只有一件事:跟踪并更新这些“连接关系”。
1.2 数组和链表:不是谁替代谁,而是各自解决了不同的问题
很多初学者会纠结一个问题:既然链表插入删除好像更方便,那为什么数组还在大量使用?答案很简单,因为“快”是有前提的,而且数组也有自己的巨大优势。
| 对比维度 | 数组(动态数组) | 单向链表 |
|---|---|---|
| 内存布局 | 连续区间 | 零散节点,靠指针串联 |
| 随机访问 | O(1),直接下标 | O(n),必须从头遍历 |
| 尾部插入 | 均摊 O(1),可能触发扩容 | O(1),只要知道尾指针 |
| 任意位置插入删除 | O(n),需要搬移元素 | O(1),前提是已经知道前一个节点 |
| 额外内存开销 | 基本没有 | 每个节点要存一个 next 指针(常见场景为 8 字节) |
| 缓存友好度 | 高,连续访问利于预取 | 低,节点在内存中不连续,容易缓存未命中 |
所以你会发现,数组擅长“读”,链表擅长“改”。但这里的“擅长”指的都是在最理想的前提下。真正写代码时,链表插入虽然只是改两三个指针,但为了让代码在边界条件下不出错,你需要付出更多心智成本,而这恰恰是学习链表的价值所在。
学习链表,本质上是在训练一种“指针思维”:你得时刻清楚当前站在哪个节点上,清楚每个指针到底指着谁,并且在修改的时候保证不弄丢任何一段连接。这种思维比数组的下标思维抽象一个层次,但它也是后续学习二叉树、图、乃至操作系统里各种链式结构管理的基础。
2. 单向链表的结构拆解:从节点定义到内存布局真相
2.1 一个节点,就是一个数据域加一个指针域
单向链表的最小组成单位是节点。在 C 语言里,一个最经典的节点定义大概长这样:
struct Node { int data; // 数据域:保存用户关心的业务数据 struct Node *next; // 指针域:指向下一个节点的地址 };数据域的类型不一定是 int,实际业务里可能是字符、结构体,甚至是一个指向复杂对象的指针。对链表本身来说,数据域存什么不重要,重要的是 next 指针,它承载了节点之间的逻辑关系。
这里我想强调一个很容易被忽视的点:next 指针保存的,是“下一个节点在内存里的地址”,而不是下一个节点的“值”。它就像一个门牌号,告诉你下一个节点的东西放在哪里。你可以把节点理解成一个集装箱,箱子里装着货物和一张纸条,纸条上写的是下一个集装箱的存放位置。整个链表就是由无数个这样的集装箱靠纸条串联起来的运输线。
也正因为 next 存的是地址,链表在内存中才不需要连续存放。每个节点都可以是单独 malloc 出来的,它们分散在不同地址,但通过 next 保持着逻辑上的先后顺序。
2.2 头指针、尾节点和空链表:所有操作的起点
我们需要一个额外的变量来记录链表从哪里开始,这个变量通常叫 head,也就是头指针。严格来说,头指针不是节点,它只是一个指向“第一个节点”的指针变量。如果链表是空的,那么 head 的值就是 NULL。
这里有一个非常容易搞混的概念:头指针和头节点。头指针是变量,记录链表的入口;头节点则是第一个实际存储数据的节点。有些复杂实现会额外创建一个不存数据的“哑节点”作为头节点,用来统一操作逻辑,但初学者现阶段先不急着接触,先掌握 head 直接指向首个数据节点的写法。
单向链表的最后一个节点比较特殊,它的 next 指针必须设置为 NULL。NULL 是链表的终止标记,它告诉我们:到这里已经没有下一个节点了。遍历链表的经典循环条件 while (p != NULL),正是依赖每个正常链表都有这个终止标记。如果某个节点的 next 没有置空,遍历就会越界继续往前走,轻则行为异常,重则直接踩到非法内存导致崩溃。
2.3 从内存地址重新认识一遍链表
我经常让新手画一张图:假设现在链表里有三个节点,data 分别是 10、20、30。它们的节点地址分别是 0x1000、0x2000、0x3000,三个地址并不连续。
| 节点地址 | data | next 指向 |
|---|---|---|
| 0x1000 | 10 | 0x2000 |
| 0x2000 | 20 | 0x3000 |
| 0x3000 | 30 | NULL |
此时 head 变量里存的是 0x1000。当你执行 p = head 开始遍历时,p 先等于 0x1000,取到数据 10;接着 p = p->next,p 变成 0x2000,取到数据 20;再往下走到 0x3000,取到 30;继续 p = p->next 得到 NULL,循环结束。
这看起来很简单,但它解释了链表的核心优势:即使 0x1000 和 0x2000 之间隔着十万八千里,甚至中间存着完全无关的其他数据,链表也能通过 next 指针精确找到下一个逻辑节点。数组一旦扩容或者插入需要移动元素,代价很大;链表增删节点,只要调整指针即可,整体数据不需要搬动。
3. 手写单向链表:从零开始的完整实现过程
3.1 初始化链表:从一个空链表开始
写链表代码,我强烈建议你先从空链表起步。空链表就是 head 为 NULL,它本身也是一个合法的链表状态,大多数插入、删除操作都要把这个状态考虑进去。
struct Node *head = NULL;有些文章会定义结构体包装整个链表,比如:
struct LinkedList { struct Node *head; int size; };这样封装的好处是能顺便维护链表长度,后面一些需要长度判断的场景会很方便。不过初学者学习阶段,先用裸的 head 指针,更容易看清内存操作的本质。
3.2 头插法:为什么它是最简单的插入方式
头插法就是把新节点插到链表最前面。思路很简单:
- 新节点的 next 指向原来的第一个节点。
- head 改为指向新节点。
void insertAtHead(struct Node **head, int data) { struct Node *newNode = (struct Node *)malloc(sizeof(struct Node)); newNode->data = data; newNode->next = *head; // 关键:先让新节点接住旧链表 *head = newNode; // 再让 head 指向新节点 }我特意用二级指针struct Node **head,是因为要在函数内部修改调用者持有的 head 指针。如果你用一级指针,函数里改的只是 head 的副本,调用方的 head 不会变化,这是很多新手第一次写链表就翻车的点。
头插法有个附加效果值得注意:如果你不断用头插法构造链表,最终链表里的数据顺序和输入顺序是相反的。比如依次插入 1、2、3,链表从 head 往后遍历得到的是 3、2、1。想要逆序输出数据,有时可以巧妙利用头插法,但业务上如果要求保持顺序,就得用尾插法。
3.3 尾插法:把新节点放到链表最后
尾插法比头插法麻烦一点,因为你需要先走到链表的末尾。最简单的实现是先判断链表是不是空的,如果是空的直接让 head 指向新节点;如果不是空的,就遍历到最后一个节点,让它的 next 指向新节点。
void insertAtTail(struct Node **head, int data) { struct Node *newNode = (struct Node *)malloc(sizeof(struct Node)); newNode->data = data; newNode->next = NULL; if (*head == NULL) { *head = newNode; return; } struct Node *cur = *head; while (cur->next != NULL) { cur = cur->next; } cur->next = newNode; }注意循环条件是cur->next != NULL而不是cur != NULL。原因是我们想要的是最后一个节点,最后一个节点的特征是 next 为 NULL。如果写成 while(cur != NULL),循环结束时的 cur 已经变成 NULL,你反而丢失了最后一个节点的地址。这个陷阱非常经典,几乎每个新手都会踩一次。
尾插法时间复杂度是 O(n),因为每次都要从头遍历到尾。如果你频繁在尾部插入,可以额外维护一个 tail 指针,让尾插变成 O(1),但这又带来新的麻烦:每次插入和删除都可能影响到 tail 的更新,算法题中有时会这么优化,工程代码里也很常见。
3.4 在指定位置插入:先把前后节点都锁住,再动指针
假设我们要在第 pos 个位置(从 0 开始)插入一个新节点。第一步一定是从 head 开始找到第 pos 个节点,这个节点将成为插入位置的前驱节点。比如要在下标 2 插入节点,就要找到下标 1 的那个节点 p。如果链表太短或者 pos 超出范围,要做越界处理。
经典的四步如下:
void insertAtPosition(struct Node **head, int pos, int data) { struct Node *newNode = (struct Node *)malloc(sizeof(struct Node)); newNode->data = data; if (pos == 0) { insertAtHead(head, data); return; } struct Node *prev = *head; for (int i = 0; i < pos - 1 && prev != NULL; i++) { prev = prev->next; } if (prev == NULL) { printf("位置越界,插入失败\n"); free(newNode); return; } newNode->next = prev->next; // 新节点先指向后节点 prev->next = newNode; // 前节点的 next 再指向新节点 }这里的顺序极其重要,必须先把 new 的 next 指向“后一个节点”,再把 prev 的 next 改为指向 new。如果顺序颠倒,写成:
prev->next = newNode; newNode->next = prev->next;那么 prev->next 已经被改成了 newNode,prev->next后面再取值得到的还是 newNode 自己,新节点就指向了自己,后面的整段链表直接丢失了。这种错误我用“把下一站指示牌先摘掉”来形容,后边的节点全部失联,链表瞬间断成两截。
3.5 删除指定节点:比插入更需要注意释放内存
删除节点需要找到目标节点的前驱,然后让前驱跨过目标节点指向后面的节点。同时别忘释放目标节点的内存。
void deleteNode(struct Node **head, int data) { if (*head == NULL) return; struct Node *cur = *head; struct Node *prev = NULL; while (cur != NULL && cur->data != data) { prev = cur; cur = cur->next; } if (cur == NULL) { printf("没找到要删除的节点\n"); return; } if (prev == NULL) { // 删除的是头节点 *head = cur->next; } else { prev->next = cur->next; } free(cur); }这个函数同时演示了两个常见边界:链表为空、要删除的是头节点。头节点没有前驱,所以必须特殊处理,直接把 head 指向原来的第二个节点。特别提醒一点:如果你用的是 C++ 的 new/delete 或者其它语言,free 的时机一定要在指针不再使用之后。释放完再访问 cur->next 是未定义行为,可能当时看起来没崩溃,但已经是隐患。
在写删除逻辑时,我习惯先把“目标节点的前后关系”在纸上画出来,先在图上把前后指针连好,再动手写代码。一旦我在图上确定了 prev->next 应该指向 cur->next,代码就只需要照着图翻译。很多同学写链表容易懵,根源就是动手前脑子里没有图。
3.6 遍历与查找:所有链表操作的基础
遍历是链表最基础的操作,没有它,查找、修改、更新都无从谈起。
void printList(struct Node *head) { struct Node *cur = head; while (cur != NULL) { printf("%d -> ", cur->data); cur = cur->next; } printf("NULL\n"); }按值查找的思路和遍历一致,只是多了一个判断:
struct Node *findNode(struct Node *head, int data) { struct Node *cur = head; while (cur != NULL) { if (cur->data == data) { return cur; } cur = cur->next; } return NULL; }这里想强调一个我后来才真正理解透的点:链表的遍历操作看起来只是重复执行cur = cur->next,但执行过程中,cur 会依次经过每一个节点。你可以把它理解成一场只能单向前进的探险:你只知道当前站在哪里,以及下一站在哪里,永远无法回头。这个“单向性”决定了单向链表的大量性质,比如找前驱节点必须从头再来一次。
3.7 链表反转:面试宝典里的常客
反转单向链表是高频题,也是检验指针操作熟练度的试金石。思路是用三个指针:prev 记录已经反转好的部分、cur 表示当前正在处理的节点、next 暂存 cur 原本的下一个节点。
struct Node *reverseList(struct Node *head) { struct Node *prev = NULL; struct Node *cur = head; while (cur != NULL) { struct Node *next = cur->next; // 先把后续链表记住 cur->next = prev; // 反转当前节点指针方向 prev = cur; // prev 前移 cur = next; // cur 前移到原后续节点 } return prev; // 原链表的最后一个节点成为新头 }很多人背得住这段代码,但一旦被问“为什么需要 next 这个临时变量”就答不上来。原因很简单:当你执行cur->next = prev后,cur 原来的 next 指针已经被覆盖了,如果不在覆盖前先把它存起来,你就永远丢失了对后面节点的引用。你可以把 cur->next 理解成一根连接绳子,反转时你先要左手抓住绳子另一头,再把右手这根绳子解开换方向系,如果左手没抓紧,后面的车厢全掉队了。
递归反转对初学者稍难,但值得看一版:
struct Node *reverseListRecursive(struct Node *head) { if (head == NULL || head->next == NULL) { return head; } struct Node *newHead = reverseListRecursive(head->next); head->next->next = head; head->next = NULL; return newHead; }递归版本背后是“假设子链表已经反转好了”的思维,这对理解递归本身也很有帮助。不过工程中如果链表很长,递归深度过大会有栈溢出风险,所以工程上更常用迭代版本。
3.8 快慢指针:找中间节点的经典套路
找出链表的中间节点,最朴素的做法是遍历一遍数出长度 n,再从头走 n/2 步。如果要求只能遍历一次,就要用到快慢指针:快指针每次走两步,慢指针每次走一步,当快指针走到末尾时,慢指针正好停在中间。
struct Node *findMiddle(struct Node *head) { struct Node *slow = head; struct Node *fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; } return slow; }快指针走完时,慢指针到底停在哪个位置,取决于链表长度奇偶和循环条件写法,具体结果稍有不同,但不影响理解。快慢指针的思路本质上利用了“速度差”制造位置差,它也是判断链表是否有环的基础。判断有环时,快指针最终会在环里追上慢指针,这就是经典的 Floyd 判圈算法。新手可以在理解中间节点之后,再用快慢指针试试找“倒数第 k 个节点”,会有惊喜。
4. 复杂度分析:别再说链表“插入很快”
4.1 一张表看清所有操作的时间复杂度
很多人对链表复杂度的记忆是“插入删除 O(1),查找 O(n)”,这个说法过于简化。准确地说,那是在“已经持有目标位置前驱节点指针”的前提下。如果只知道值而必须从 head 开始找,那插入删除依然是 O(n)。
| 操作 | 最坏时间复杂度 | 说明 |
|---|---|---|
| 访问第 k 个元素 | O(n) | 必须从头遍历 |
| 按值查找 | O(n) | 平均也要遍历一半节点 |
| 已知前驱,插入节点 | O(1) | 只需修改两个指针 |
| 按位置插入 | O(n) | 定位到前驱已经耗费 O(n) |
| 已知前驱,删除后继 | O(1) | 指针跨越即可 |
| 按值删除 | O(n) | 先找到目标节点的前驱 |
| 遍历全部节点 | O(n) | 每个节点恰好被访问一次 |
| 头插法建立链表 | O(n) | 每个节点插入 O(1) |
这个表值得反复对照。你会发现,链表在“已知位置”的场景下确实高效,但在随机访问领域几乎毫无优势。它的真正价值不是“什么都快”,而是在数据持续插入删除、规模不可预知时,不需要像数组那样反复搬移和扩容。
4.2 为什么链表的插入在直觉上快,在实际上不一定快
工程实践中,影响性能的远不止时间复杂度。数组是连续内存,遍历时 CPU 缓存友好,内存预取效率高。链表节点散落各地,每跳一个节点都可能是一次缓存未命中,在数据规模大的时候,遍历链表的真实耗时可能比同规模数组慢一个数量级。这也是很多现代系统里,数组型容器反而更受欢迎的原因之一。
另外,每次 malloc 一个节点都会有分配开销。节点如果是固定大小的结构体,频繁分配释放还容易造成内存碎片。所以真正追求性能的系统,往往会使用内存池、预分配固定大小节点等手段来减少分配次数。学习链表时如果能理解这一点,你就不至于在工作中一遇到容器需求就条件反射式地用链表。
4.3 空间复杂度:链表并没有你想象的那么“省”
每个节点除了数据本身,还要额外存一个 next 指针。64 位系统上这个指针占 8 字节。如果你的节点数据就一个 int(4 字节),那一个节点的额外开销超过数据本身的 100%。相比之下,一个 int 型数组几乎没有额外空间开销。
链表省下的不是空间,而是“连续空间”。在内存碎片化严重、找不到足够大的连续区域时,链表可以让很多小碎片物尽其用。所以它从来不是省内存的方案,而是“化整为零”的方案。理解了这一点,就能明白为什么操作系统和数据库的某些底层机制青睐链表式组织,而不是盲目追求大段连续内存。
5. 单向链表在真实世界里的典型应用
5.1 缓存淘汰:从单向链表到双向链表的进阶之路
聊聊 LRU(最近最少使用)缓存淘汰。简单理解,就是当缓存满了,优先淘汰最久没被访问的数据。实现方案之一是链表加哈希表:哈希表负责快速定位,链表负责记录访问顺序。
为什么实际落地时大多用双向链表而不是单向?因为 LRU 中访问一个节点后需要把它移到头部,也就是删除该节点后重新头插。用单向链表删除一个节点,你得先从头遍历找到它的前驱,这个代价是 O(n)。而双向链表每个节点有 prev 指针,可以 O(1) 拿到前驱,删除就变成了纯粹的指针操作。单向链表不是不能做 LRU,但效率上会有明显瓶颈。
这个例子很有代表性:你先把单向链表学会,再把双向链表点开,才会真正明白“某个结构多一个指针到底解决了什么问题”。很多人直接背结论“LRU 用双向链表”,知其然不知其所以然,属于典型的缺课。
5.2 底层系统:当数据规模不可预知时,链表式管理是保底手段
操作系统管理内存空闲块时,需要维护一张“哪些内存块是空闲的”清单。当进程申请内存时,系统从清单里找到合适的块分配出去;释放时再还回去。这种场景下,内存块本身就不连续,也不固定,用链表把空闲块串起来非常自然。类似思路还出现在常见的动态内存分配池、文件的空闲块管理中。它们的共性是:数据个数动态变化,无法预知上限,且每个块往往本身就是零散的。
你可能会说,现在很多高级语言里直接提供容器类型,根本不用你手写链表。确实如此,但底层库、驱动、嵌入式系统、游戏引擎内存管理模块里,链式结构依然大量存在。就算你平时业务不直接写,理解这套思想也能帮你排查很多诡异的内存和性能问题。
5.3 经典算法题里的链表:约瑟夫环与多项式合并
约瑟夫环问题是单向链表应用里的经典教学案例。n 个人围成一圈,从某个人开始报数,报到 m 的人出列,然后从下一个人重新报数,直到最后只剩一个人。如果用循环链表来实现这个“围成一圈”的结构会很自然,依次数到 m 时删除对应节点。这个题会同时锻炼循环链表、删除操作、边界条件控制三件事。
多项式合并也可以用链表。比如两个多项式分别用链表存储每一项的系数和指数,合并时相当于按指数递减的顺序合并两个有序链表,这和“合并两个有序链表”这道算法题是同源的。初学时把这类经典题做一遍,比单纯背代码有用得多,因为你会在多个场景里反复看到同一种指针操作模式,最后形成肌肉记忆。
6. 常见问题与排查技巧实录
6.1 指针丢失导致链表断裂
这是链表出错的头号根因。典型代码是:
prev->next = newNode; newNode->next = prev->next; // 错!此时 prev->next 已经是 newNode这段代码执行后,newNode->next 指向自己,原后续节点全部丢失。除了内存泄漏,链表也会变得不像链表。要避免这种问题,我给自己定了一条铁律:在修改任何指针之前,先确认被覆盖的旧值是否已经被保存。很多情况下,你需要用临时变量存储旧指针。写代码时在心里默念“先接后断,先保存,再修改”,可以有效降低出错概率。
6.2 空指针和野指针导致的崩溃
遍历链表时忘记判空,或者在链表为空时直接访问 head->data,都会触发段错误。野指针则更隐蔽,它在释放节点后没有把指针置为 NULL,之后误用该指针。排查这类问题的方法很务实:先在函数入口检查指针是否为空;每次 free 之后把指针置为 NULL;重要操作前后打印关键节点的地址和 data。
很多人会问,为什么出了 bug 那么难查?因为链表问题往往是“事后才炸”——结构可能已经被破坏过很多步,但崩溃发生在非常后面的地方。这种现场很难直接看出凶手是谁。我的经验是,一旦发现链表遍历结果不对,优先检查是否有指针被意外覆盖,并且把链表从头到尾打印一遍,对比节点地址的连接关系,通常能快速定位到断链位置。
6.3 内存泄漏:释放顺序错一点就出大事
删除整个链表时,一定要保存好下一个节点的地址再释放当前节点:
void freeList(struct Node *head) { struct Node *cur = head; while (cur != NULL) { struct Node *next = cur->next; // 必须先记住下一个 free(cur); cur = next; } }如果写成先 free(cur),再 cur = cur->next,就属于典型的使用已释放内存。第一次可能没事,因为内存还没被改写,但一旦某些被释放的内存被系统回收,cur 指向的就是一块已经无效的空间,再读 cur->next 就是未定义行为。实际开发里的表现时好时坏,特别有迷惑性。
6.4 调试链表的土办法,但真的管用
我调试链表问题,最常用的不是断点,而是“打印大法”:在关键位置打印 head、当前节点地址、当前节点的 data、当前节点的 next。一份清晰打印日志,能直观看出每步操作之后的连接关系。另一个方法是把链表画成箭头图,我甚至会在草稿纸上手画几次。写插入、删除代码前先把箭头图画出来,代码正确率能提高一半以上。
如果手头有测试框架,我强烈建议给链表操作写几组边界测试用例:空链表插入、删一个不存在的节点、删除头节点、删除尾节点、链表只有一个节点时删除。边界条件全测过,代码的稳定性就会大幅提升。
7. 从单向链表延伸出去:接下来你可以继续学什么
7.1 双向链表和循环链表:解决单向链表的天然短板
单向链表最大的短板是“只能向前,不能回头”。想找一个节点的前驱,必须从头开始重新遍历。双向链表给每个节点增加了 prev 指针,代价是每个节点多 8 字节,换来 O(1) 的前驱查找和更容易的逆序遍历。
循环链表则把最后一个节点的 next 重新指向头节点,整个链表变成环。它特别适合处理轮转场景,比如时间片轮转调度、约瑟夫环问题。判断一个链表是否成环,正好用上前面提到的快慢指针。单向链表是这两个结构的基础,学起来几乎是无缝切换。
7.2 跳跃表:让链表也能高效查找
跳跃表是在有序链表基础上增加多级索引,让查找复杂度从 O(n) 降到 O(log n) 级别。核心思想是“空间换时间”,额外维护若干层指针,让查找时可以大步跳跃。它在一部分嵌入式系统和内存数据库中用作有序集合的实现,因为实现比平衡树简单,并且并发控制相对容易。学习跳跃表之前,先吃透单向链表和双向链表的指针玩法,会轻松很多。
7.3 链表是最好的算法思维训练场
我始终觉得链表题是算法基本功训练里性价比很高的一类。它没有复杂的数学推导,却要求你把指针关系和边界条件处理得干干净净。反转链表、合并两个有序链表、找环入口、删除倒数第 n 个节点,这些题覆盖了遍历、双指针、递归、边界判断等多个维度的基本功。只要你把单向链表彻底练明白了,再去碰二叉树、图相关的题,会有一种“手熟了”的踏实感。
最后分享一个我的个人习惯。带过不少新同学之后,我越来越确信一件事:判断一个人是不是真懂链表,就看他拿到题之后是先动键盘还是先画图。我的习惯是,任何链表的操作题,动手写代码前先在草稿纸或者白板上把节点关系画成小箭头图,然后把每一步操作翻译成注释,最后再往注释里填代码。这个小习惯帮我省掉的调试时间,远比想象中多。单向链表只是一个起点,它背后那种用指针和关系去组织零散数据的思想,才是值得你真正带走的东西。