如果你写过一阵子Java,大概率在面试题或者课程设计里碰上过这么一个问题:手写一个链表。说实话,我第一次被问到“用Java实现链表”的时候,心里是有点懵的——毕竟日常开发里直接ArrayList和LinkedList拿来就用,真没自己从头撸过。但恰恰是那次之后,我发现能把链表手写明白的人,对引用(或者说指针)的理解会比只会调API的人深一层,排查线上问题、读框架源码的时候也明显更顺。这篇文章就从一个实际编码的角度,掰开揉碎讲一讲Java链表数据结构的实现思路、边界情况,还有我踩过的一些坑。
很多教程喜欢一上来就甩代码,然后说“你看,很简单”。但我不太认同这个方式。链表这东西,难点从来不是代码本身,而是你脑子里有没有那张“节点+引用”的图。你只要图想清楚了,代码就是照着图翻译一遍;图没想清楚,代码写得再漂亮也是虚的。所以这篇文章我会先讲清楚链表的底层逻辑,再给实现,再讲为什么这么写,最后聊聊那些真正让人头皮发麻的边界问题。
1. 从数组的短板说起:链表到底解决了什么问题
1.1 数组的“连续内存”困局
先说说为什么需要链表。数组在内存里是一段连续空间,这既是它的优势,也是它最大的限制。连续意味着可以通过下标直接计算地址,所以随机访问是O(1)的;但也正因为连续,你在中间插入一个元素,必须把后面的元素全部往后挪,删除也一样。这个挪动操作在数据量小的时候无所谓,十万级、百万级数据时就非常肉疼了。
还有一个潜在问题:扩容。ArrayList底层是数组,当容量不够时,它会新开一个更大的数组,然后把旧数据拷贝过去。这个拷贝是O(n)的,虽然均摊下来还能接受,但在某些低延迟场景里,那一次扩容的卡顿是真的明显。链表就没有这个问题,它不需要一段连续内存,每个节点独立存在,用引用串起来就行,理论上只要有零散内存就能用。
1.2 链表的本质:用引用串起来的离散节点
链表的核心元素就两个:节点和引用。每个节点保存两个东西——数据本身,以及下一个节点的地址。在Java里,这个“地址”表现为引用,也就是我们常说的指针。
public class ListNode { int val; ListNode next; ListNode(int val) { this.val = val; } }你看,就这么简单。一个val存数据,一个next指向下一个节点。当next为null的时候,说明这是链表的最后一个节点。链表的头节点是head,它就像一列火车的车头,通过next一级一级串过去,就能访问到所有节点。
理解链表的关键,就是理解“引用”这层间接关系。每个节点只知道自己下一个节点是谁,它不知道后面还有多少个节点,也不知道链表的长度。想知道长度?从头遍历一遍。想访问第5个节点?从头走5步。这就是链表与数组最本质的差异:数组是“按索引直达”,链表是“按引用顺藤摸瓜”。
2. 手写一个单链表:从节点类到可用Demo的完整过程
2.1 类的骨架设计
我们不直接复制JDK的LinkedList,而是从一个精简版开始。先定义一个链表类,内部维护头节点head和大小size。
public class MyLinkedList { private ListNode head; private int size; public MyLinkedList() { head = null; size = 0; } }head表示第一个节点,size表示当前节点数量。可能有同学会问:为什么不用虚拟头节点?这里先不引入,先让逻辑更直白一点。等会讲边界问题的时候,我们再聊虚拟头节点的好处。
2.2 从头部插入:理解“换头”操作
头部插入是最直观的操作。新节点来了,它的next指向原来的head,然后链表的新head换成这个新节点。这个操作的时间复杂度只有O(1),非常快。
public void addAtHead(int val) { ListNode newNode = new ListNode(val); newNode.next = head; head = newNode; size++; }这里有一个容易被忽略的细节:顺序不能反。如果先把head换成了新节点,再让新节点的next指向它,请问这时候next指向谁?指向了它自己,形成了一个环,原来的链表就丢了。所以必须先让新节点连接旧链表,再更新head。
2.3 尾部插入:先走到最后一个节点
尾部插入就稍微绕一点了。因为链表没有保存尾节点的引用,你得从头遍历,找到最后一个节点,然后把next指向新节点。如果链表本来就是空的呢?那尾部插入和头部插入就没什么区别了,直接让head等于新节点即可。
public void addAtTail(int val) { ListNode newNode = new ListNode(val); if (head == null) { head = newNode; } else { ListNode cur = head; while (cur.next != null) { cur = cur.next; } cur.next = newNode; } size++; }这段代码里,cur.next != null这个条件是最容易写错的地方。有人会写成cur != null,结果呢,循环结束后cur已经是null了,你根本没法给null.next赋值,直接空指针。正确理解是:我们想找的是“最后一个节点”,它满足的条件是“自己没有下一个节点”,也就是cur.next == null。
2.4 遍历与查找:别把head弄丢了
查找某个下标的节点,或者查找某个值,都是遍历问题。遍历的时候最忌讳的一件事,就是直接用head去移动。比如这样:
public int get(int index) { while (index-- > 0) { head = head.next; } return head.val; }代码是能跑,但跑完之后你的链表头没了!链表是单链表,一旦head丢失,你没有任何办法找回前面的节点。正确做法是引入一个临时变量cur来移动,head始终保持不动。
public int get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("Index: " + index); } ListNode cur = head; for (int i = 0; i < index; i++) { cur = cur.next; } return cur.val; }这是我见过的新手最容易犯的错,没有之一。很多同学写了半天,测试的时候发现链表越遍历越短,就是因为在查找函数里“动”了head。
3. 插入和删除的指针游戏:边界条件与常见Bug
3.1 在任意位置插入:先找前驱节点
假如要在下标index处插入节点,下标从0开始。核心思路是找到“新节点的前驱”,也就是原来下标index - 1的节点,然后调整两个引用。
我画一张逻辑图在脑子里:新节点newNode的next指向原index节点,前驱节点的next指向newNode。
public void addAtIndex(int index, int val) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("Index: " + index); } if (index == 0) { addAtHead(val); return; } // 找到 index 位置的前驱节点 ListNode cur = head; for (int i = 0; i < index - 1; i++) { cur = cur.next; } ListNode newNode = new ListNode(val); newNode.next = cur.next; cur.next = newNode; size++; }这里有一个经典顺序:先让新节点指向后驱,再让前驱指向新节点。如果反过来,先让前驱指向新节点,那么原来的后驱节点就“断连”了,你手里又没有它的引用,后面就找不回来了。
3.2 删除节点:别拿“假删除”骗自己
删除操作指的是把某个节点从链表中摘掉。对于删除头节点,直接head = head.next即可。但对于删除中间节点,同样需要找到前驱。
public void deleteAtIndex(int index) { if (index < 0 || index >= size) { return; } if (index == 0) { head = head.next; } else { ListNode prev = head; for (int i = 0; i < index - 1; i++) { prev = prev.next; } prev.next = prev.next.next; } size--; }注意,prev.next = prev.next.next这行,逻辑上是把前驱的next直接跨过被删除节点,指向它的下一个。被删除的节点就成了“孤儿”。Java里有垃圾回收机制,过一会儿它会被自动回收,不需要你手动释放。但在C/C++里,你还得free或者delete一下,否则就内存泄漏了。
3.3 哨兵节点:让你少写一半if
上面的写法虽然能跑,但回头看,处理头节点和中间节点用了完全不同的逻辑,每个方法都有一堆if (index == 0)判断。有没有办法统一?有,引入虚拟头节点,也叫哨兵节点。
哨兵节点是一个不存真实数据的节点,永远固定在链表头前面。它的next指向真正的头节点。这样头节点也是“有前驱”的节点了,插入、删除的逻辑就统一了:永远去找前驱节点,前驱的next永远存在。
public class MyLinkedList { private ListNode dummyHead; private int size; public MyLinkedList() { dummyHead = new ListNode(-1); size = 0; } public void addAtIndex(int index, int val) { if (index < 0 || index > size) throw ...; ListNode prev = dummyHead; for (int i = 0; i < index; i++) { prev = prev.next; } ListNode newNode = new ListNode(val); newNode.next = prev.next; prev.next = newNode; size++; } public void deleteAtIndex(int index) { if (index < 0 || index >= size) return; ListNode prev = dummyHead; for (int i = 0; i < index; i++) { prev = prev.next; } prev.next = prev.next.next; size--; } }你看,加了哨兵之后,头节点和普通节点一视同仁,index == 0的特判完全消失了。这可能是我在实现链表时最推荐的一个设计思路:与其到处写边界判断,不如把数据结构本身改造得不需要边界判断。
4. 链表反转:一个经典问题的四种写法
4.1 迭代反转:三根指针逐步“掉头”
链表反转是绕不开的经典题。逻辑上其实很简单:把每个节点的next从指向后改为指向前。但要实现这一步,你得同时记录三个节点:前驱prev、当前curr、后继next。
public ListNode reverse(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode next = curr.next; // 保存后继,防止断链后丢失 curr.next = prev; // 当前节点指向前驱 prev = curr; // 前驱右移 curr = next; // 当前右移 } return prev; // 反转后,prev就是新链表的头 }第一次实现的时候,建议你在纸上画一下每轮循环三个引用的变化。我当年学的时候,自己画了三轮循环才彻底看懂:curr.next = prev就是把箭头掉了个方向;prev = curr和curr = next就是整体右移。一共三步,缺一不可,顺序不能乱。
4.2 递归反转:代码很短,理解很难
递归反转的长相和迭代完全不同,代码非常短:
public ListNode reverse(ListNode head) { if (head == null || head.next == null) { return head; } ListNode newHead = reverse(head.next); head.next.next = head; head.next = null; return newHead; }很多初学者背下这段代码,但过两天就忘了。关键在于理解递归的“信任”:reverse(head.next)被调用后,它会返回一个已经反转好的链表,且新链表的头就是原链表第二个节点。这时候,只需要做两件事:第一,让原来第二个节点(现在是新链表的尾)指向head;第二,让head.next置空。
结合代码看,head.next.next = head就是“把下一个节点的next指回当前节点”;head.next = null是把当前节点的next断掉,避免形成环。递归解法有一种“从后往前”的加工顺序,打印一下调用栈就能看得很清楚。
不过我得提醒一句:递归反转在数据量大的时候有风险。链表节点几十万的时候,递归深度极大,很容易栈溢出。生产环境我一般不推荐递归反转,但理解它对于夯实递归思维很有帮助。
4.3 反转的实际意义:不只是面试题
有人会问,链表反转看起来花里胡哨的,实际工作中用得着吗?其实挺常见的。比如某些数据结构的实现需要逆序访问,比如实现一个支持“从尾部追加”场景下的栈,或者在做大整数运算、LRU缓存淘汰等场景时,反向遍历是基础操作。还有一个很实际的场景:单向链表的特性决定了你只能往前走,当你需要从后往前处理时,反转就是一种思路。
无论是LeetCode还是公司面试,链表反转都只是外层包装,真正考察的是你对引用关系是否足够敏感:谁会丢失引用,哪里会形成环,怎么保证每个节点在操作后仍然可达。这三个问题想明白了,反转只是顺手的事。
5. 双向链表和循环链表:更复杂结构的实现逻辑
5.1 双向链表的节点设计与删除优势
单链表最烦的一点:只能从头往后走,想删除某个节点还得先找它的前驱,代价是O(n)。双向链表就是为了解决这个问题——每个节点不仅知道下一个,还知道上一个。
class DoublyListNode { int val; DoublyListNode prev; DoublyListNode next; DoublyListNode(int val) { this.val = val; } }有了prev引用,删除一个已知节点就变成了O(1)操作:让它的前驱节点的next指向它的后继,让它的后继节点的prev指向它的前驱,然后节点自己就算“脱离”了。
public void removeNode(DoublyListNode node) { node.prev.next = node.next; if (node.next != null) { node.next.prev = node.prev; } }注意,这里要判断node.next是否为null,因为如果是尾节点,node.next.prev就不存在了。这在实现时是个很典型的坑。双向链表的代价也很明显:每个节点多了一个引用,内存占用大约增加8字节(64位JVM上),而且插入和删除操作需要维护两个方向的引用,代码量几乎翻倍。
5.2 循环链表的实现与应用场景
循环链表就是把尾节点的next指回头节点,形成一个环。判断结束的条件从cur == null变成了cur == head,这个变化看似简单,实际实现时很容易写错。如果你不小心让循环链表变成了“死循环”的环而不是“有终点的环”,遍历可能永远停不下来。
一个更常见的变体是双向循环链表,也就是尾节点的next指向头节点,头节点的prev指向尾节点。JDK里的LinkedList本质上就是这种结构。循环链表用在哪?经典场景是操作系统的进程调度、任务轮询等“边遍历边循环处理”的机制。我早期做一个小型消息队列时,就用过循环链表来轮询一批后端服务节点,保证每次从上次断点继续往下轮,而不是每次从头开始。
说实话,业务代码里自己手写循环链表的机会很少,但手写一遍之后,你对JDK中LinkedList的很多设计选型会理解得更透——它为什么要用双向的哨兵节点?因为环形结构配合哨兵,可以省掉极多的null判断。
6. 链表与ArrayList的实测对比:别再背“数组查找快、链表插入快”了
6.1 理论时间复杂度 vs 真实内存开销
教科书上通常画这么一张对比表:
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 随机访问 | O(1) | O(n) |
| 头部插入 | O(n) | O(1) |
| 尾部插入 | O(1)均摊 | O(1) |
| 中间插入 | O(n) | O(n) |
| 内存占用 | 连续数组 | 节点+引用 |
这张表本身没错,但它只说了一半。另一半是计算机组成原理的东西:数组在内存里连续存储,CPU访问时可以利用缓存行预读;链表节点是离散的,跳来跳去很容易缓存不命中。所以现实中的差距往往比理论更大。
我做过一个朴素的实验:往ArrayList和LinkedList中轮流添加100万个随机数,然后从头到尾遍历一遍。结果ArrayList的遍历速度大约是LinkedList的3到5倍,原因就是缓存局部性。这个数字在不同机器上会有差异,但结论方向基本一致:链表的O(1)插入优势,经常被它的缓存不友好抵消掉。
6.2 什么情况下真的该用链表?
那链表是不是就没用了?当然不是。有这么几类场景,链表依然是更合理的方案:
- 头部插入或删除特别频繁,而且数据量很大。此时
ArrayList每次都是O(n)的数组移动,链表确实更强。 - 实现LRU缓存等需要频繁增删节点的数据结构。
LinkedHashMap内部就是哈希表+双向链表的组合。 - 内存不连续的大对象场景。你没法提前预估容量,而且剩余内存碎片化严重。
- 需要常数时间的合并、拆分操作。比如把两个链表拼接起来,只要调整几个引用就行,数组做不到。
我的建议是:业务代码里能不用手写链表就不用,JDK的LinkedList以及各种并发容器已经足够好。但当你确定要做“高频头部操作”时,链表是结构上最优雅的选择,这时候该上就上。
7. 我在真实项目中总结的链表使用经验与避坑清单
7.1 最常见的五个坑
手写链表的过程中,有几个坑我几乎每次都会看到学员或者同事踩到,这里一次性列清楚:
- 弄丢头节点:任何遍历都不应该直接移动
head。想移动先赋值给局部变量。 - 插入顺序颠倒:新节点先连接后驱,再更新前驱的
next。顺序反了必断链。 - 循环条件写错:
while (cur != null)和while (cur.next != null)意义完全不同。前者是“访问每个节点”,后者是“停在最后一个节点”。 - 删除后
size忘记减:这个看起来低级,但真的很常见。特别是多个分支里都有size--时,容易漏掉其中一个。 - 反转时丢引用:反转一定要先保存
next,否则你改完当前节点,下一个节点就找不到了。
7.2 一个容易忽视的工程细节:调试时如何打印链表
链表出问题时,肉眼检查引用关系基本没戏。我强烈建议写一个printList()工具方法:
public void printList() { ListNode cur = head; while (cur != null) { System.out.print(cur.val + " -> "); cur = cur.next; } System.out.println("null"); }在每一步操作后都打印一次,尤其是反转和插入操作。打印结果一出来,很多逻辑问题就顿时清晰了。这个方法虽然简单,但真的是调试链表的神器,比你在IDE里断点单步盯着看要高效得多。
7.3 我的使用心得
以我这些年写Java的经验,链表的实现难度不在于“写出来”,而在于“想清楚边界”。如果你能把单链表的基本操作、反转、以及为什么用哨兵节点更简洁,都用自己的话讲给别人听,那你对Java引用的理解就已经超过大多数同行了。
我个人的习惯是,学任何数据结构都先动手手写一遍,写的时候别开IDE的代码提示,就靠脑子里的那张引用图。写完单链表再去写双向链表、循环链表,最后再对比JDK源码里LinkedList的设计思路。这种“先自己思考、再看大师解法”的学习路径,比直接背源码要扎实得多。
最后分享一个小技巧:如果你在面试里遇到“用Java实现链表”这种题,别急着写代码,先和面试官确认清楚——是只要单链表的基本功能,还是要带反转、哨兵、双向等优化?明确需求再动手,往往会让面试官觉得你思路清晰。这个习惯在真实项目里也一样适用:先把边界和约束聊明白,再坐下去写代码。