很多初学Java的人,一开始接触集合框架的时候,总觉得LinkedList是个多余类——明明ArrayList能干的活,干嘛还要多记一堆方法。等真正开始刷题、做项目、甚至准备面试的时候才会发现,链表这个数据结构如果只停留在“会用API”的层面,遇到反转、合并、环检测这类问题时,根本顶不上去。
链表是Java数据结构基础里最适合“手写一遍”的内容。它不像数组那样依赖连续内存,也不像树和图那样抽象绕人,本质上就是靠“节点+引用”把散落的元素串成一条线。这篇文章就从最核心的问题讲起:链表到底解决了什么问题,节点和引用应该怎么理解,手写一个单链表要经历哪些基础操作,以及面试、期末复习里常考的变形和坑。
这篇文章适合三类人:刚学完Java语法,想补数据结构基础的人;准备Java开发岗位面试,担心链表题翻车的人;期末要考数据结构、需要快速搞懂单链表基本操作的在校生——尤其是要写“单链表的基本操作实验”这类报告的同学。只要你动手写过一遍,后面不管你用不用得上,链表这关都算过去了。
1. 学了ArrayList还不够,链表才是真正考验动手能力的数据结构
1.1 链表到底解决了什么问题
先回到最原始的场景。数组是我们接触的第一种线性结构,它的最大特点是内存连续、下标定位快。但是数组的插入和删除代价很高——为了维持连续性,在中间插入一个元素,后面所有元素都要往后挪,删除同理,数据量一大,性能就很难看。
链表的设计思路完全不同。它的每个元素不是存在一块连续区域里,而是散在内存各处,元素之间通过“存下一个节点的地址”来串联。有个很形象的类比:一条寻宝链,每个宝箱里除了放着数据,还贴着一张纸条,写着“下一个宝箱在哪个坐标”。只要跟着坐标走,就能把所有散落的宝箱找齐。
这个设计换来了两个核心能力:
- 任意位置插入和删除,理论复杂度都是O(1),前提是你已经站在了那个节点上;
- 不需要预分配容量,需要几个元素就创建几个节点,天然动态扩容。
代价同样实在:随机访问退化成O(n),想拿第5个元素,必须从头一个个跳过去。另一个容易被忽略的成本是内存——每个节点都要多存一个引用(在Java里至少占4到8字节),节点数量一大,额外开销非常明显。
所以链表不是用来替代数组的,它解决的是“频繁在中间增删元素、且不要求随机访问”的一类问题。理解了这个背景,你再看LinkedList存在的意义,就不会觉得它多余了。
1.2 “节点和引用”这个概念,别只在脑子里过,要在纸上画
很多人链表学不好,问题不是概念不懂,而是没有在纸上画出来。我第一次学链表的时候也觉得很简单,等真去手写插入逻辑,却总是丢链、死循环,排查半天才发现是指针指向被覆盖了。
建议你这样画一个节点链:
code复制Node1 -> Node2 -> Node3 -> null
每个Node里面有两个字段:一个是数据,一个是next引用。画图的时候把next箭头画得明显一点,尤其是做插入、删除、反转这类操作时,先画图再写代码,出错概率能降一大半。几乎所有链表bug都出在“引用指向被覆盖”上,而画图能直接暴露出这个问题。
在Java里,节点长这样:
java复制public class LinkNode {
public int data; // 数据域,这里先用int简化演示
public LinkNode next; // 指针域,指向下一个节点
public LinkNode(int data) {
this.data = data;
this.next = null;
}
}
这叫单向链表节点。每个节点只知道自己下一个节点是谁,不知道前一个是谁,所以只能从前往后遍历。这个“只能往前走”的限制,是后面很多算法题的难度来源,也是为什么会有双向链表、循环链表这些变体。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Java里现成的LinkedList也能用,但自己造一遍才有真感觉
2.1 JDK的LinkedList是怎么设计的
先看一眼标准库的接口。Java里的LinkedList是一个双向链表,实现了List和Deque两个接口,所以既能当列表用,也能当队列或者栈用。它内部节点长这样:
java复制private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
每个节点带前驱和后继两个引用,所以支持从前往后和从后往前双向遍历。头部和尾部各维护一个关键引用(first和last),在头部或尾部插入删除时非常快,只需要改相邻几个节点的引用,不需要移动元素。从使用者角度看,你通常只需要:
java复制LinkedList<String> list = new LinkedList<>();
list.add("A");
list.addFirst("头");
list.addLast("尾");
String s = list.get(2);
list.remove();
问题在于,如果只会调用现成方法,遇到“反转链表”“找中间节点”“判断是否有环”这类面试题,依然写不出来。因为这些题的难点全在手动处理节点引用上,而JDK把这些细节封装掉了。我的建议很直接:用归用,但一定要自己动手写一遍单链表,把底层逻辑走通。
2.2 手写一个单链表:先把框架搭起来
为了避免多指针把自己绕晕,我们把基础模型做简单——单向链表,每个节点只存data和next,不搞prev。
除了节点类,还需要一个管理类,专门维护头节点和链表长度。这个封装有个好处:size的维护统一管理,不用每次求长度都从头遍历一遍。
java复制public class SimpleLinkedList {
private LinkNode head; // 头节点,链表的起点
private int size; // 节点数量
public SimpleLinkedList() {
this.head = null;
this.size = 0;
}
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
}
为什么这里要专门用一个head字段,而不是把节点直接暴露在外面让使用者自己连?因为后续所有操作都需要“从头开始找位置”,如果没有统一入口,代码就会散得到处都是,size也很难维护。做实验报告的时候,你可以把size去掉、每次都遍历求长度,更“原生态”,但工程上不建议。
3. 单链表核心操作:遍历、插入、删除,每一步都要小心指向
3.1 遍历:读链表的唯一方式,也是所有高级操作的地基
遍历的目标很简单:从头节点开始,逐个访问节点,直到遇到null。
java复制public void printList() {
LinkNode cur = head;
while (cur != null) {
System.out.print(cur.data + " -> ");
cur = cur.next;
}
System.out.println("null");
}
这里必须说一个最基础也最容易犯的错:遍历时要先定义一个cur保存当前节点,然后用cur = cur.next推进。绝对不能直接操作head,否则链表头一丢,整个链表就再也找不回来了。这个习惯一定要从第一天就养成。
如果要按位置找节点,逻辑也是一样的走法:
java复制public LinkNode getNodeByIndex(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("下标越界: " + index);
}
LinkNode cur = head;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
return cur;
}
getNodeByIndex是后续插入、删除都要用的基础方法,相当于给链表加了个“按下标寻址”的能力。注意,这个方法的复杂度是O(n),所以如果你频繁按下标访问,链表并不是好选择。
3.2 头插、尾插、中间插:插入的黄金顺序“先接后断”
插入是链表初学者第一个容易翻车的地方。核心步骤就一句话:先让新节点的next指向目标位置的下一个节点,再让前一个节点的next指向新节点。顺序不能反过来。
先看最简单的头插:
java复制public void addFirst(int value) {
LinkNode newNode = new LinkNode(value);
newNode.next = head; // 新节点先指向当前头
head = newNode; // 更新头为新节点
size++;
}
再看尾插。如果链表为空,新节点直接成为头节点;否则要一路走到最后一个节点,再把它的next指向新节点:
java复制public void addLast(int value) {
LinkNode newNode = new LinkNode(value);
if (head == null) {
head = newNode;
} else {
LinkNode cur = head;
while (cur.next != null) {
cur = cur.next;
}
cur.next = newNode;
}
size++;
}
指定位置插入稍绕一些,因为在中间插入时要先定位“前一个节点”。如果插入位置正好是0,就复用头插;否则找到index-1位置的节点作为prev,再执行“先接后断”:
java复制public void add(int index, int value) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("插入位置越界: " + index);
}
if (index == 0) {
addFirst(value);
return;
}
LinkNode prev = getNodeByIndex(index - 1);
LinkNode newNode = new LinkNode(value);
newNode.next = prev.next; // 第一步:新节点先连上原有的后继
prev.next = newNode; // 第二步:前一个节点改指向新节点
size++;
}
画一下图就清楚了。原有链路是A->B->C,要在B之前插入X:
- X.next = B,这一步X先挂到B前面;
- A.next = X,这一步A再挂到X前面。
如果顺序反了,先执行A.next = X,那原本存在A后面的B就会因为没有引用而“断链”丢失。口诀就是四个字:先接后断。
3.3 删除节点:改一个引用,跳过目标
删除比插入简单一些,核心思路是让前一个节点的next直接指向被删节点的下一个节点,相当于把被删节点从链路上“跳过”:
java复制public void remove(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("删除位置越界: " + index);
}
if (index == 0) {
head = head.next; // 删头节点,直接把头后移
} else {
LinkNode prev = getNodeByIndex(index - 1);
prev.next = prev.next.next; // 跳过被删节点
}
size--;
}
删除位置是0的时候,没有前驱节点,只能更新head。删除中间节点时,被删节点自身持有的next其实还指向后面的节点。如果你追求严谨,可以把它置为null:
java复制LinkNode toDelete = prev.next;
prev.next = toDelete.next;
toDelete.next = null; // 彻底断开引用,方便GC回收
这在面试里会是一个加分的细节,说明你考虑到了对象生命周期,而不仅仅是把逻辑跑通。
3.4 一个绕不开的细节:维护size和越界处理
手写链表时,size的管理很容易被忽略。有人觉得每次add、remove用手动size++、size--就行,但一旦漏掉某个分支,size和实际节点数就会不一致,后续所有按index操作都会错位。
我建议在类内部写一个统一的私有方法处理所有“按位置找节点”的逻辑,所有插入、删除都走它。这样size的变化就只集中在几个public方法里,排查问题时会清晰很多。越界判断也很有讲究:插入时index可以等于size(表示在末尾追加),但删除和获取时index最多只能到size-1。这个差异很容易写错,特别是刚开始手写的时候。
4. 进阶玩法:反转、合并有序链表、检测环,面试高频三件套
接下来这部分,是真正让链表这个知识点分出水深的地方。面试题里大量出现,期末复习、考研数据结构也常考。掌握了这几个操作,你才算是“会用链表”,而不是“认识链表”。
4.1 反转单链表:迭代和递归各来一遍
反转的目标:把A->B->C->null变成C->B->A->null。这是面试中出现频率最高的链表题,没有之一。
迭代法核心是三指针:prev、cur、next。
java复制public LinkNode reverse(LinkNode head) {
LinkNode prev = null;
LinkNode cur = head;
while (cur != null) {
LinkNode next = cur.next; // 先保存后继节点
cur.next = prev; // 当前节点掉头指向prev
prev = cur; // prev前进
cur = next; // cur前进
}
return prev; // 最后prev就是新的头节点
}
为什么需要临时变量next?因为cur.next一旦被改成prev,原来cur后面的节点信息就丢了。先存下来,cur才能顺利地往后跳。这是反转链表最容易写错的地方——很多新手直接cur.next = prev,然后cur = cur.next,结果cur跟着prev跑了,变成死循环。
递归版本也很好理解,很多教材喜欢用:
java复制public LinkNode reverseRecursive(LinkNode head) {
if (head == null || head.next == null) {
return head; // 空链表或到达尾节点,直接返回
}
LinkNode newHead = reverseRecursive(head.next);
head.next.next = head; // 让head的后继反过来指向head
head.next = null; // 断开原方向
return newHead;
}
递归的核心思想是“假设后面的已经反转好了”,然后只处理当前节点和它后继的关系。面试时建议两种都掌握,至少熟练一种,另一种能讲清楚思路即可。
4.2 合并两个有序链表:用dummy节点省掉很多if
两个已经按升序排好的单链表,要合并成一个仍然升序的链表。常规写法要不停比较两个链表头部数据的大小,还要单独处理其中一个链表先跑完的情况。最优雅的做法是引入一个dummy节点,让结果链表先挂在这个哑节点后面:
java复制public LinkNode mergeTwoLists(LinkNode l1, LinkNode l2) {
LinkNode dummy = new LinkNode(-1); // 占位节点
LinkNode tail = dummy;
while (l1 != null && l2 != null) {
if (l1.data <= l2.data) {
tail.next = l1;
l1 = l1.next;
} else {
tail.next = l2;
l2 = l2.next;
}
tail = tail.next;
}
if (l1 != null) tail.next = l1;
if (l2 != null) tail.next = l2;
return dummy.next; // dummy本身不是结果
}
dummy节点最大的好处是:把“结果链表最开始有没有头节点”这个边界问题消除了。不用在循环里反复判断tail是不是null,代码清爽很多。注意最后返回的必须是dummy.next,而不是dummy,否则你会把占位节点一起返回。
很多教科书还要求“合并两个有序链表”只能用原节点、不能新建节点。上面这个写法已经满足要求,因为它只是改变节点的next指向,没有new出额外的节点,dummy只是一次性辅助节点,不算在结果里。
4.3 检测链表是否有环,以及找到环入口
判断链表里有没有形成环,经典做法是快慢指针:慢指针每次走一步,快指针每次走两步。有环的话,快指针最后一定会追上慢指针;没环的话,快指针会先走到null:
java复制public boolean hasCycle(LinkNode head) {
LinkNode slow = head;
LinkNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
return true;
}
}
return false;
}
为什么步长选2而不选3、4?因为步长为2时,快慢指针之间的距离每次循环减少1,所以它们之间的间隙必然能来到0,也就是必然相遇。如果步长为3,有可能出现“跨越”现象——距离从2变成-1,直接跳过对方;虽然代码上还能额外加判断,但复杂度完全不必要。
如果题目进一步要求“找到环的入口节点”,则有一个结论:快慢指针第一次相遇后,让其中一个指针回到head,另一个留在相遇点,然后两个指针都每次走一步,第二次相遇的位置就是环入口。这个结论在LeetCode第142题里有完整推导,建议自己推一遍,会加深理解。
4.4 找中间节点、删除倒数第n个节点:双指针的延伸
快慢指针不止能用来判环。找链表中间节点时,同样一个快指针一步走两个节点、慢指针一步走一个节点,当快指针走到结尾时,慢指针刚好停在中间。
删除倒数第n个节点,经典做法是:先用一个指针往前走n步,然后两个指针一起走,当前面的指针到达尾部时,后面的指针正好停在待删节点的前一个位置。面试里这些题都是同一种思路的变形,掌握双指针之后,做题效率会明显提升。
5. 循环链表和双向链表:考试和面试里反复出现的变形
5.1 单循环链表与约瑟夫问题
循环链表就是把单链表尾节点的next从null改成指向head。这样从任意一个节点出发,都能走完整条链表。它最大的特点是“没有终点”,遍历终止条件要改成“回到起点”。
实现上,只需要在建链时让尾节点指回头节点即可。遍历代码要小心使用do-while而不是while:
java复制public void printCircular(LinkNode start) {
LinkNode cur = start;
do {
System.out.print(cur.data + " -> ");
cur = cur.next;
} while (cur != start);
}
为什么这里要用do-while?因为循环链表的头节点也是有效节点,你先判断再打印,头节点就会被漏掉。这个细节在实验报告里很常见,也很容易扣分。
循环链表最典型的应用是约瑟夫问题:一群人围成一圈报数,每次数到m就淘汰一个人,直到剩下最后一个人。这个问题用循环链表模拟非常自然,因为“围成一圈”本来就是循环链表的结构。核心步骤是:从当前节点开始报数,报数到m时,删除这个节点,再从它的后继节点继续报数。删除逻辑和单链表一致,只是尾节点不再指向null,而是继续指向头。
5.2 双向链表的基本操作
双向链表每个节点不光有next,还有prev。JDK的LinkedList就是双向链表。好处是双向遍历、删除当前节点时不需要再额外找前驱;代价是每个节点多出一个引用字段,内存开销变大,而且插入、删除操作要同时维护两个方向的引用,出错概率随之增加。
在节点p后面插入一个新节点x,核心代码分四步:
java复制x.next = p.next; // 1. x指向p的后继
if (p.next != null) {
p.next.prev = x; // 2. 原后继的prev改指向x
}
p.next = x; // 3. p的next改指向x
x.prev = p; // 4. x的prev改指向p
这四行顺序错一步都会出问题。我的建议还是那句:先在纸上画箭头,再写代码。双向链表画图时要把箭头分成两行画,一行画next方向,一行画prev方向,不然很容易看一眼就头晕。
如果你在实验中或面试题中看到“双向链表的插入遍历删除”题目,本质上就是在考你能不能同时维护好两个引用链。只要单链表基础扎实,双向链表只是多一个“回头路”而已。
5.3 从Java视角看其他语言的链表
很多初学者问:我在Java里用的链表,和C语言的链表、Python的链表到底什么关系?其实原理完全一致,只是语法不同。
C语言里用struct定义节点,里面存数据和指向结构体的指针;Python里用类定义节点,next保存下一个节点的引用;Java里同样是用类字段保存引用。理解了“节点+引用”这个本质,你在C结构体链表、Python单链表逆序这些题目之间迁移会非常快。
搜索热词里出现的“c++结构体链表基本语法”“python单链表逆序”,其实都在讲同一个东西:节点定义、遍历、插入、删除、反转。语言包装不同而已。所以如果你想把一个技术学扎实,最好的办法是用Java把这个基础模型做透,之后再遇到任何语言的链表题,都能一眼看懂核心逻辑。
6. 实战踩坑与工程建议:从“能跑”到“不丢链”的差距
6.1 边界条件:链表bug的重灾区
链表题有一个普遍规律——绝大多数bug都出在边界:空链表、只有一个节点、操作位置是0、操作位置是size-1。我在实际写代码时有个习惯,写完核心逻辑之后,立刻在脑子里过四张测试用例:
- 空链表:head为null,插入、遍历、删除分别应该是什么表现?
- 单节点链表:删除这个节点后,head应该变成null。
- 头节点操作:addFirst和remove(0)有没有正确更新head?
- 尾节点操作:prev.next.next在删除最后一个节点时是否为null?
这四张用例比多写几百行代码更有用。期末实验报告和面试里,边界处理就是最核心的加分点。很多人明明算法思路对,却因为没处理空链表而直接空指针异常,这是最可惜的。
另一个高频踩坑位置在递归反转和合并有序链表。递归方法一定要设置好终止条件“head == null || head.next == null”,否则会无限递归到栈溢出。合并有序链表时,循环结束后要记得把剩余链表接上去,漏掉这一个判断,结果就会少一大截。
6.2 内存、并发与JDK细节,面试可能追问的知识点
用Java写链表,节点对象的创建和回收都依赖JVM。每个节点多一个引用字段,加上对象头等开销,其实并不便宜。这也是为什么很多工程场景里,如果主要按下标访问,ArrayList通常更合适;只有当频繁在中间插入删除、且随机访问要求不高时,LinkedList才有明显优势。
JDK LinkedList还有几个值得知道的特性:
- 它不是线程安全的,多线程环境下需要外部加锁,或者用Collections.synchronizedList包装。
- 它的迭代器是fail-fast的,遍历过程中若检测到结构性修改,会抛出ConcurrentModificationException。
- 它在get(int index)时做了一个小优化:先判断index离头部近还是离尾部近,然后选择从近的一端开始遍历,所以它的实际随机访问成本比从头遍历略低一点点,但量级还是O(n)。
这些细节不一定会被问到,但知道了以后,阅读JDK源码时会顺畅很多。比如你看到LinkedList的get方法里有“如果index小于size的一半就从first开始找,否则从last往前找”的逻辑,就明白为什么它叫双向链表了。
6.3 一晚上吃透链表的练习路线
最后分享一下我自己的练习路径。如果你打算用一晚上把链表这部分彻底吃透,可以直接照着下面这套路线走:
- 手写单链表,包含头插、尾插、指定位置插入、指定位置删除、遍历打印。
- 在单链表基础上写反转,迭代和递归各写一遍。
- 给链表加一个“返回倒数第k个节点”的方法,或者“找中间节点”的方法。
- 用快慢指针判断是否有环,再尝试找出环的入口节点。
- 合并两个有序链表,用dummy节点实现一遍。
- 把单链表改成双向链表,理解prev引用的维护方式。
每一步都要先画图再写代码,写完以后用边界用例自测。做完这一套,你再看任何链表题,思路都会清晰很多。很多基础不扎实的人,其实不是不会写代码,而是没有一个足够牢固的“节点+引用”心智模型,遇到变形题就慌。模型建立起来以后,题目怎么换都跑不出这几个操作。
我个人在实际操作中的体会是:链表是最适合“练手感”的数据结构,因为它的操作逻辑直观,调试起来也方便——打一个遍历打印就能看到问题在哪。不要只盯着屏幕看,把图画出来,把代码敲进去,把断点打上,一个晚上就能把这块地基打得很牢。后面学树、图的时候,你会发现很多遍历思路其实都来自链表的那一套,到时候你会感谢今天认真写过链表的自己。
