ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

单链表核心原理与Java实现:从节点设计到常见错误排查

2026/9/9 13:24:28 拓冰建站 浏览量
单链表核心原理与Java实现:从节点设计到常见错误排查 在实际项目里单链表是结构最简单、也最容易写错的链式存储结构。单链表通过一组地址不连续的节点存储数据每个节点除了保存元素本身还保存指向下一个节点的引用从而把离散的内存单元串成一条逻辑上连续的线性表。学习数据结构时单链表往往是第一个要求同时理解指针或引用、内存分配和边界判断的数据结构在课程实验、面试手写代码、底层系统内部实现以及高级语言标准库的节点类设计里单链表的思想都反复出现。这篇文章从顺序表的问题出发逐步推导出单链表节点、头节点、插入和删除的设计逻辑再给出一份基于 Java 的最小可运行实现包含插入、删除、查找、遍历、逆置等核心方法并补充运行验证、边界测试、常见问题排查和工程化建议。学完以后既能手写单链表也能在真实项目中判断何时该用它、哪些地方最容易出错。1. 先理解单链表为什么存在1.1 顺序表的连续存储问题顺序表的典型实现是数组在 Java 里对应ArrayList。顺序表要求元素存储在一块连续的内存空间中通过下标可以直接定位元素所以随机访问是 O(1) 的。但连续存储也带来了一个明显问题在中间插入或删除元素时必须移动大量后续元素。假设要在长度为 n 的顺序表头部插入一个元素要把原来的 n 个元素全部向后移动一格再写入新元素删除头部元素同理需要把后面的 n-1 个元素整体前移。也就是说顺序表在头部或任意位置插入、删除的平均时间复杂度是 O(n)。如果程序的主要操作是频繁在中间插入删除顺序表就会把大量时间花在内存拷贝上。单链表的思路是换一种存储方式不要求节点在内存中连续只要求每个节点记录下一个节点的地址。插入新节点时只需要修改相邻两个节点之间的引用不需要搬动任何数据。这样做换来了 O(1) 的局部插入复杂度前提是已经找到插入位置的前驱节点代价是按下标访问某个元素时必须从头开始逐个遍历随机访问变成 O(n)。单链表并不是比顺序表更高级而是用牺牲随机访问来换取增删灵活性。选型时不能只看“时间复杂度”三个字母要看具体业务是更依赖随机访问还是更依赖频繁增删。1.2 单链表节点数据域加指针域单链表的节点由两部分组成数据域保存真正的业务值。指针域保存下一个节点的引用Java 中称为引用C 语言中称为指针。从头节点出发通过每个节点的 next 引用不断向后访问就能遍历整条链表。链表结构可以用下面的示意图表示head - [10 | next] - [20 | next] - [30 | null]最后一个节点的 next 为空表示链表结束。因为这种结构只能从头部向尾部单向移动不能回头所以叫单链表。C 语言定义节点时使用结构体和指针struct Node { int data; struct Node *next; };在 Java 中引用本身也是一种“安全指针”节点类可以这样设计public class ListNodeT { public T value; public ListNodeT next; public ListNode(T value) { this(value, null); } public ListNode(T value, ListNodeT next) { this.value value; this.next next; } }这里把value和next简单设为 public是为了让示例代码更直观。真实项目中通常会把字段设为 private通过方法访问避免外部直接改写链表结构。1.3 头节点、头指针和首元节点的区别在学习单链表时有三个名字容易混淆头指针、头节点、首元节点。头指针指向链表第一个节点的指针变量。以“链表对象”这个维度看它代表整张链表。头节点紧跟在头指针后的一个附加节点数据域一般不用来存有效数据。首元节点真正存储第一个有效数据的节点。带头节点和带头指针的写法有本质区别。带头节点时头节点的 next 才指向首元节点空链表的判断条件是head.next null。不带头节点时head 本身就直接指向首元节点空链表时 head 为 null。对比项带头节点不带头节点首元节点定位head.nexthead空链表判断head.next nullhead null删除首元节点统一通过前驱节点修改需要单独判断是否修改 head插入首元节点统一走前驱插入逻辑需要单独判断头指针是否为空代码边界分支较少较多带头节点最大的好处是统一了插入和删除逻辑。删除首元节点时可以把 head 当作首元节点的前驱执行head.next head.next.next而不需要判断“要删除的是不是第一个节点”也不需要修改 head 指针本身。本文下面的实现采用带头节点的写法这也是课程实验和工程代码中更常见的风格。2. 环境准备与接口设计2.1 开发环境与前置知识本文示例使用 Java 8 以上版本利用泛型演示单链表如何存储任意类型。开发环境可以是 IntelliJ IDEA、Eclipse也可以直接用文本编辑器加javac命令编译运行。如果读者正在学习 C 语言可以把 Java 类中的节点替换成struct Node把对象引用替换成指针。Java 和 C 在单链表上的核心逻辑完全一致遍历靠 next修改连接靠重新赋值 next。区别主要在于 Java 不需要手动释放节点内存而 C 语言删除节点后需要手动free。学习环境只要能把代码编译运行、打印结果即可。生产环境还需要考虑包管理、单元测试、代码规范和调用方是否会直接操作内部节点。2.2 项目文件结构为了便于演示代码拆成三个文件src/main/java/com/company/ds/ ListNode.java SingleLinkedList.java SingleLinkedListDemo.javaListNode节点定义。SingleLinkedList单链表核心实现。SingleLinkedListDemo测试入口用来验证功能。实际项目中ListNode可以设计成SingleLinkedList的私有内部类避免对外暴露节点细节。这里单独成类是为了阅读方便。2.3 核心方法一览在动手写代码前先明确单链表需要提供的操作方法功能时间复杂度size()返回当前元素个数O(1)isEmpty()判断链表是否为空O(1)addFirst(T value)在链表头部添加元素O(1)addLast(T value)在链表尾部添加元素O(n)因为没有尾指针add(int index, T value)在指定下标插入元素O(n)get(int index)按下标获取元素O(n)indexOf(T value)查找元素第一次出现的位置O(n)contains(T value)判断元素是否存在O(n)remove(int index)删除指定下标的元素O(n)removeValue(T value)删除第一个匹配的元素O(n)clear()清空链表O(n)reverse()反转链表O(n)display()打印链表内容O(n)hasLoop()判断链表是否有环O(n)addLast是 O(n) 是因为当前实现只有head指针。如果程序需要频繁在尾部追加元素可以在类里额外维护一个tail指针让尾部插入变成 O(1)。但删除尾节点时仍然需要找到倒数第二个节点所以不能期望所有操作都通过加尾指针变成 O(1)。2.4 节点类定义ListNode是最基础的定义public class ListNodeT { public T value; public ListNodeT next; public ListNode(T value) { this(value, null); } public ListNode(T value, ListNodeT next) { this.value value; this.next next; } }next是串联整条链表的关键。创建新节点时可以让构造器一次性完成“保存数据 指定后继节点”的操作例如new ListNode(value, prev.next)这样代码更紧凑。3. 核心操作实现与设计理由3.1 链表骨架、头节点与 size 字段SingleLinkedList类有两个核心属性head和size。head永远指向头节点size记录有效元素个数。维护size有什么价值如果没有size每次计算链表长度都要从头遍历时间复杂度是 O(n)。这会影响两层需求一是size()方法本身变慢二是在add(int index)和remove(int index)做边界检查时需要遍历才能知道链表长度代码会复杂很多。维护size后这两个操作都能在 O(1) 时间内判断参数是否越界。public class SingleLinkedListT { private final ListNodeT head; private int size; public SingleLinkedList() { head new ListNode(null); size 0; } public int size() { return size; } public boolean isEmpty() { return size 0; } }注意head使用了final。带头节点的写法中head本身不需要被替换只修改head.next即可。把head设为 final 能避免代码里误把head重新赋值成其他节点。3.2 插入先连后继再改前驱向单链表的第index个位置插入节点本质上是在下标为index - 1的节点后面挂一个新节点。从这个角度看“插入到第 0 个位置”等价于“在头节点后面插入”所以头节点让首元节点插入变得和普通位置插入完全一致。实现代码public void add(int index, T value) { if (index 0 || index size) { throw new IndexOutOfBoundsException(index index , size size); } ListNodeT prev head; for (int i 0; i index; i) { prev prev.next; } ListNodeT newNode new ListNode(value, prev.next); prev.next newNode; size; }循环结束后prev就是插入位置的前驱节点。new ListNode(value, prev.next)这一步有两个作用新节点的 value 保存业务数据。新节点的 next 指向原来的后继节点避免链表从插入点断开。最后再执行prev.next newNode让前驱节点指向新节点插入完成。很多人第一次写时会写成相反顺序prev.next newNode; newNode.next prev.next;这个错误很隐蔽。执行第一句后prev.next已经变成newNode第二句再取prev.next得到的也是newNode结果就是newNode.next newNode新节点指向了自己。这种情况在显示链表时会出现死循环或链表只剩部分节点。正确做法是新节点先指向旧后继前驱再指向新节点。addFirst和addLast可以复用addpublic void addFirst(T value) { add(0, value); } public void addLast(T value) { add(size, value); }addLast会从 head 开始走 size 步时间复杂度是 O(n)。如果程序需要反复在尾部追加数据建议额外维护tail指针。但维护tail后删除尾节点仍需要从头找前驱所以单链表在“同时要求头尾高效插入删除”时不如双向链表方便。3.3 删除通过前驱节点绕过目标节点删除下标为index的节点也需要先找到它的前驱节点。找到后让前驱的 next 指向被删节点的下一个节点目标节点就从链表中被“跳过”了。public T remove(int index) { if (index 0 || index size) { throw new IndexOutOfBoundsException(index index , size size); } ListNodeT prev head; for (int i 0; i index; i) { prev prev.next; } ListNodeT deleted prev.next; prev.next deleted.next; deleted.next null; size--; return deleted.value; }删除后把deleted.next置为 null是一个值得养成的习惯。Java 的垃圾回收器会从 GC Root 出发标记可达对象被删除的节点如果还持有对后续节点的引用虽然不会影响正确性但会让“删除后应该不可达的对象”仍然通过旧引用保持关联。置空 next 可以在一定程度上帮助 GC 识别。按值删除的逻辑类似只是需要遍历链表找到第一个值和目标值相等的节点public boolean removeValue(T value) { ListNodeT prev head; while (prev.next ! null) { if (value null ? prev.next.value null : value.equals(prev.next.value)) { ListNodeT deleted prev.next; prev.next deleted.next; deleted.next null; size--; return true; } prev prev.next; } return false; }这里没有直接写value.equals(prev.next.value)是因为如果value传入的是 null调用equals会触发空指针异常。使用三目表达式后允许调用方删除 null 值。实际业务中更建议避免向链表中插入 null这样查找和删除逻辑都可以简化。但作为通用数据结构实现需要兼顾这种边界。3.4 查找和遍历从 head.next 开始按下标获取元素public T get(int index) { if (index 0 || index size) { throw new IndexOutOfBoundsException(index index , size size); } ListNodeT cur head.next; for (int i 0; i index; i) { cur cur.next; } return cur.value; }遍历时要从head.next开始而不是head开始。如果从 head 开始会读到头节点中的 null 数据而且后续所有节点下标都会偏移一位。查找元素下标时需要用equals而不是。对引用类型来说比较的是内存地址两个内容相同的字符串或封装对象用比较通常为 falseInteger在 -128 到 127 范围内有缓存小整数可能碰巧相等超出缓存范围就会表现不一致这是典型的隐蔽问题。public int indexOf(T value) { int index 0; ListNodeT cur head.next; while (cur ! null) { if (value null ? cur.value null : value.equals(cur.value)) { return index; } cur cur.next; index; } return -1; }打印链表时同样从首元节点开始public void display() { StringBuilder sb new StringBuilder([); ListNodeT cur head.next; while (cur ! null) { sb.append(cur.value); if (cur.next ! null) { sb.append(, ); } cur cur.next; } sb.append(]); System.out.println(sb); }遍历的判断条件cur ! null意味着当前节点存在可以安全访问当前节点的 value。如果使用cur.next ! null会在访问最后一个节点时提前终止遗漏尾部数据。3.5 单链表逆置迭代法链表逆置是高频面试题也是检验是否理解引用修改的经典练习。迭代法调整每个节点的 next 方向让链表从“从前往后指”变成“从后往前指”最后再把头节点指向新的首元节点。public void reverse() { ListNodeT prev null; ListNodeT cur head.next; while (cur ! null) { ListNodeT nextTmp cur.next; cur.next prev; prev cur; cur nextTmp; } head.next prev; }解释几个关键变量的作用cur是当前正在处理的节点。nextTmp保存当前节点的旧后继因为修改cur.next后再想通过cur.next走到下一个节点已经不可能。prev是已经逆置完成的部分链表的头节点。循环结束时prev指向原链表的最后一个节点也就是逆置后的首元节点所以让head.next prev。如果漏写nextTmp或者把三个临时变量赋值顺序写错链表很容易成环。调用reverse()后如果display()不停打印基本可以断定链表成环了。3.6 完整实现代码把上面方法合并到一起得到一份可运行的SingleLinkedList类public class SingleLinkedListT { private final ListNodeT head; private int size; public SingleLinkedList() { head new ListNode(null); size 0; } public int size() { return size; } public boolean isEmpty() { return size 0; } public void addFirst(T value) { add(0, value); } public void addLast(T value) { add(size, value); } public void add(int index, T value) { if (index 0 || index size) { throw new IndexOutOfBoundsException(index index , size size); } ListNodeT prev head; for (int i 0; i index; i) { prev prev.next; } ListNodeT newNode new ListNode(value, prev.next); prev.next newNode; size; } public T get(int index) { if (index 0 || index size) { throw new IndexOutOfBoundsException(index index , size size); } ListNodeT cur head.next; for (int i 0; i index; i) { cur cur.next; } return cur.value; } public int indexOf(T value) { int index 0; ListNodeT cur head.next; while (cur ! null) { if (value null ? cur.value null : value.equals(cur.value)) { return index; } cur cur.next; index; } return -1; } public boolean contains(T value) { return indexOf(value) 0; } public T remove(int index) { if (index 0 || index size) { throw new IndexOutOfBoundsException(index index , size size); } ListNodeT prev head; for (int i 0; i index; i) { prev prev.next; } ListNodeT deleted prev.next; prev.next deleted.next; deleted.next null; size--; return deleted.value; } public boolean removeValue(T value) { ListNodeT prev head; while (prev.next ! null) { if (value null ? prev.next.value null : value.equals(prev.next.value)) { ListNodeT deleted prev.next; prev.next deleted.next; deleted.next null; size--; return true; } prev prev.next; } return false; } public void clear() { ListNodeT cur head.next; head.next null; while (cur ! null) { ListNodeT next cur.next; cur.next null; cur next; } size 0; } public void reverse() { ListNodeT prev null; ListNodeT cur head.next; while (cur ! null) { ListNodeT nextTmp cur.next; cur.next prev; prev cur; cur nextTmp; } head.next prev; } public boolean hasLoop() { ListNodeT slow head.next; ListNodeT fast head.next; while (fast ! null fast.next ! null) { slow slow.next; fast fast.next.next; if (slow fast) { return true; } } return false; } public void display() { StringBuilder sb new StringBuilder([); ListNodeT cur head.next; while (cur ! null) { sb.append(cur.value); if (cur.next ! null) { sb.append(, ); } cur cur.next; } sb.append(]); System.out.println(sb); } }hasLoop方法用快慢指针判断链表是否成环。快指针每次走两步慢指针每次走一步如果链表有环两者最终会相遇。这个方法在调试 reverse、add 等操作后非常实用因为链表一旦成环display()会陷入死循环此时先调用hasLoop()可以快速定位问题。4. 运行验证与结果解析4.1 测试场景设计写一个SingleLinkedListDemo类覆盖头部插入、尾部插入、中间插入、按值删除、查找、反转等核心操作public class SingleLinkedListDemo { public static void main(String[] args) { SingleLinkedListInteger list new SingleLinkedList(); list.addLast(10); list.addLast(20); list.addLast(30); list.addFirst(5); list.add(2, 15); list.display(); System.out.println(size list.size()); System.out.println(contains 20 list.contains(20)); System.out.println(indexOf 15 list.indexOf(15)); System.out.println(remove 20 list.removeValue(20)); list.display(); list.reverse(); System.out.println(after reverse:); list.display(); } }这个测试用例故意把元素顺序打乱模拟常见操作路径先 append 10、20、30链表是[10, 20, 30]。再 addFirst(5)链表是[5, 10, 20, 30]。再 add(2, 15)下标 2 的位置插入 15链表是[5, 10, 15, 20, 30]。删除 20 后链表是[5, 10, 15, 30]。反转后链表是[30, 15, 10, 5]。4.2 预期输出编译运行后控制台输出如下[5, 10, 15, 20, 30] size 5 contains 20 true indexOf 15 2 remove 20 true [5, 10, 15, 30] after reverse: [30, 15, 10, 5]如果输出和预期一致说明链表的基本增删改查、遍历和反转逻辑没有原则性问题。但一次通过不代表代码没有边界问题还需要看边界用例。4.3 边界条件与回归用例单链表最容易漏测的几种情况是空链表、单节点链表、头部插入删除、尾部插入删除、下标越界和查找不存在的元素。建议把这些场景整理成固定的回归用例用例操作预期结果验证点空链表打印新建链表后 display[]遍历条件不能访问空节点第一个位置插入add(0, 100)[100]头节点作为前驱是否生效最后一个位置插入add(size, 200)[100, 200]index size 时允许追加越界插入add(-1, 100)抛 IndexOutOfBoundsException下标校验是否完整越界读取get(size)抛 IndexOutOfBoundsException有效下标是 0 到 size-1删除不存在的值removeValue(999)false链表不变按值删除找不到应返回 false删除唯一节点删除链表中唯一元素链表为空isEmpty 为 true删除后 head.next 是否正确为 null反转单节点链表反转只含一个节点的链表内容不变反转循环在首元节点处终止反转空链表反转空链表仍为空链表reverse 对 head.next 为 null 的情况安全删除后继续遍历删除头节点后再 display首元节点变为原第二个节点删除第一个有效数据时头节点未被破坏边界测试是数据结构代码质量的分水岭。很多代码在普通用例上正常一旦传入空链表、越界下标或单个节点的链表就会出现空指针、越界异常或死循环。5. 常见问题与排查路径5.1 插入后链表断裂或成环现象调用display()时只打印出前半段或者程序一直运行不结束。最常见原因是插入时 next 指向顺序写反// 错误写法 prev.next newNode; newNode.next prev.next;第二行执行时prev.next已经是newNode所以newNode.next指向自己。这时链表成环任何遍历都不会结束。排查方式先在测试数据很小的情况下运行比如只有两三个节点或者调用hasLoop()判断是否有环。修复思路是调整插入顺序先让新节点指向旧后继再让前驱指向新节点。检查代码时优先看new ListNode(value, prev.next)和prev.next newNode的顺序。5.2 出现空指针异常现象遍历链表、查找元素、打印链表时抛出NullPointerException。多数原因是遍历条件写错。while (cur.next ! null) { System.out.println(cur.next.value); cur cur.next; }这个写法在cur为 null 时第一行就会空指针。即使cur不为 null如果本意是打印当前节点拿到的也会一直比预期晚一个节点。到底用cur ! null还是cur.next ! null取决于目标如果要访问当前节点的 value用cur ! null。如果要判断“当前节点的下一个节点是否存在”用cur.next ! null。排查时先打印cur或cur.value确认遍历到哪个位置出问题再对照循环条件修正。5.3 删除后 size 与实际不一致现象isEmpty()判断错误或者add(index)明明链表有元素却报越界。通常是因为 add 里写了sizeremove 里忘了size--或者 clear 后没有把 size 清 0。排查方式在display()后打印size()目测两者是否一致。最好在单元测试里覆盖“连续插入 3 个再删除 3 个”的场景删除结束后断言isEmpty()为 true。维护 size 的类必须保证所有修改链表结构的入口都同步更新 size。这类问题不需要高级调试工具靠边界测试就能抓出来。5.4 equals 与 混用导致查找失败现象contains(128)返回 false但链表里明明有 128contains(100)又返回 true。这就是Integer缓存导致的典型问题。直接用比较两个 Integer值在 -128 到 127 之间时可能命中缓存而相等超过范围后比较的是引用地址结果不确定。修复方式统一使用equals。如果链表保存自定义对象还需要确保该对象正确重写equals方法。否则两个字段完全相同的对象equals仍可能返回 false。5.5 链表成环的检测思路除了reverse和插入写错会导致环调试时也可能因为查看引用关系而意外构造出环。环的危害是让所有依赖 null 作为终止条件的操作失效所以hasLoop()应该是链表类里的常备方法。快慢指针实现已经在完整代码中提供。使用顺序是先调用hasLoop()确认无环后再执行display()。如果hasLoop()返回 true优先检查所有修改 next 的操作尤其是 reverse 和 add 方法。下面是单链表排错速查表现象常见原因检查方式处理建议遍历死循环节点 next 成环调用 hasLoop检查插入和反转顺序先保存旧后继再修改 next空指针异常遍历条件与访问方式不匹配打印当前节点和下一节点明确用 cur ! null 还是 cur.next ! nullsize 与链表长度不一致增删时未同步 size打印 display 和 size所有修改链表结构的入口统一维护 size查找不到元素使用 比较引用检查查找方法使用 equals涉及时重写 hashCode首元节点丢失从 head 开始遍历或删除 head 本身打印 head.value 和 head.next.value遍历从 head.next 开始删除通过前驱删除后内存不释放被删节点 next 仍指向后续节点观察被删节点引用删除后置 deleted.next null6. 从练习到实战选型与最佳实践6.1 什么场景才适合用单链表单链表的优势是局部插入删除快不需要搬动大块数据。适合用单链表的场景包括实现队列、栈等受限线性结构尤其是需要频繁在头部操作的场景。实现 LRU 缓存链表方便把命中的节点移动到头部。内存池的空闲块管理通过指针把不连续的空闲内存串起来。面试题、算法题、数据结构课程实验。不适合用单链表的场景是频繁按下标随机访问。比如一个业务页面要反复执行get(index)单链表的 O(n) 访问成本会拖慢整体性能。Java 工程里90% 的 CRUD 场景直接用ArrayList更合适因为数组连续性带来的缓存命中率远高于链表节点访问。当需要双向遍历时单链表也明显不够用此时应该考虑双向链表或 Java 自带的LinkedList。LinkedList内部是双向链表结构理解单链表可以帮助理解它的节点设计、头尾指针和插入删除逻辑但不能直接把单链表当作生产环境的高性能容器。6.2 生产环境使用单链表前要确认的事把单链表从课程练习搬到真实项目之前至少要确认下面几件事第一不要对外暴露内部节点。ListNode应该作为链表类的私有内部类外部调用方只能通过add、remove、get等方法来操作不能拿到next引用自行修改链表结构。否则一旦外部把两个节点相互连接链表会随时成环。第二明确线程安全需求。单链表在没有外部加锁的情况下不是线程安全的。多个线程同时调用add或remove可能破坏 next 引用关系。生产环境可以选择加synchronized或ReentrantLock也可以改用并发容器比如ConcurrentLinkedDeque。这里需要注意的是标准并发容器并不等于“拿单链表改造一下就能用”并发场景必须先选对容器再决定是否需要自定义链表。第三评估节点内存开销。每个节点除了保存 value还要保存一个 next 引用。如果链表中存储的是大量很小对象比如 Integer 或短字符串额外的引用开销会非常明显。对于海量数据数组或 ArrayList 通常内存更紧凑。第四长链表遍历会慢。链表节点在内存中不连续遍历时 CPU 缓存命中率低。尽量避免在循环内对链表调用indexOf或contains否则一次嵌套就可能把复杂度放大成 O(n^2)。6.3 单链表实现检查清单写完一个单链表类之后可以用下面的清单做自检头节点是否固定存在head是否永远指向头节点而不是首元节点。遍历是否从head.next开始避免把头节点的空数据处理成业务数据。插入时是否先让新节点指向后继再修改前驱节点的 next。删除时是否把被删除节点的 next 置为 null帮助 GC 识别。size是否与链表真实长度一致每次增删都同步更新。是否处理了下标边界add允许index sizeremove和get不允许index size。查找方法是否使用equals而不是并且对空值做了保护。是否测试了空链表、单节点链表、头部插入删除、尾部插入删除、中间插入删除、反转等场景。是否包含hasLoop或至少成环后能快速定位问题。是否把节点类型设计成内部类避免外部直接操作 next。这份清单同时适用于代码审查。别人提交一个链表实现时按这些点逐项检查大部分常见问题都能在 review 阶段发现。6.4 扩展方向单链表学完之后可以继续做几个经典练习难度逐渐增加递归版链表反转。迭代法用三个变量循环递归版则利用函数调用栈保存后继能帮助理解递归过程。合并两个升序单链表。思路是创建一个新头节点作为占位节点两个指针分别指向两条链表每次取较小值挂到新链表尾部。新头节点和本文头节点作用完全一致都是为了统一边界逻辑。删除链表倒数第 K 个节点。通常用双指针先让一个指针走 K 步再让两个指针同步前进。判断链表是否有环并找出入环点。把单链表改造成双向链表或循环链表比较实现复杂度变化。用链表实现 LRU 缓存结合哈希表把查找降到 O(1)。单链表看起来代码量不大但它把“引用修改”“边界判断”“空指针防治”这几个基础功都压缩在几百行代码里。把这组操作真正写明白后面看双向链表、跳表、并发队列时会顺畅很多。