☰
手写Java双向链表:从节点结构到插入删除与反转完整实现
2026/10/7 3:20:50 网站建设 项目流程

1. 先搞清楚:双向链表到底比单向链表强在哪

先说个最直观的感受。很多初学Java的朋友在学到链表时,第一反应往往是“这不就是数组的替代品吗”。真不是,链表在内存里根本不是连续存放的,每个节点像一颗颗珠子一样用引用串起来。单向链表每个珠子只知道自己后面是谁,往前走就抓瞎;双向链表每个珠子既知道后面是谁,也知道前面是谁,所以它能从尾到头遍历,能在任意位置快速插入删除,这也是JDK里LinkedList底层真正的实现结构。

我在面试候选人的时候经常问一句话:“你什么时候会选链表而不是数组?”标准答案是:频繁在中间做插入、删除操作、对内存连续性没有硬性要求的场景。为什么?因为数组插入一个元素,从插入点往后所有的元素都要搬家,平均O(n)的移动成本;链表只要你定位到了那个节点,改几个引用就完事,真正的插入动作是O(1)。双向链表在单向链表基础上多了一个前驱指针,代价是每个节点多占一个引用空间(通常8字节),换来的是向前遍历的能力和删除节点时不用再从头找前驱。

从实现角度来看,双向链表也是很多高级数据结构的基石——比如LRU缓存淘汰算法的底层容器,比如Java的LinkedHashMap就是“哈希表+双向链表”的经典组合,再比如操作系统里的进程调度也用类似结构。如果你能自己徒手写一个双向链表,后面看这些源码会轻松非常多。

这篇教程我会用最通俗的方式,手把手带你拆解节点结构、插入操作的完整实现,顺带解答几个面试高频题。不管你是在校学生、准备跳槽的Java工程师,还是纯粹想补数据结构的底子,这套内容都适用。全程使用纯Java代码,不依赖任何第三方库,复制就能跑。

2. 节点结构拆解:一个节点到底长什么样

2.1 data、prev、next三个核心字段

双向链表的节点,本质上就是一个“数据+两个指针”的复合体。在Java里没有指针这个概念,引用的作用等价于指针。所以一个节点类至少要包含三个字段:

public class ListNode { public int data; // 存储的数据 public ListNode prev; // 指向前一个节点 public ListNode next; // 指向后一个节点 }

这里我先用了最简单的情况——数据是int。现实中你大概率会存对象,那只要把泛型加上就行:

public class ListNode<T> { public T data; public ListNode<T> prev; public ListNode<T> next; }

注意一个关键点:prev和next默认值是null。Java中引用类型的默认值就是null,所以你不初始化它们也不会报错,但如果直接去访问node.prev.next,就会抛出经典的NullPointerException。这个坑我后面在讲插入操作时会反复提到。

有没有必要把字段设置为private然后写getter/setter?如果你写的是工程代码,封装性是基本素养,必须设置private并提供访问方法。但如果是算法题、刷题场景或者快速验证,直接设public反而效率更高。我自己的习惯是分场景:项目里用私有字段+构造方法,算法练习里直接public一把梭。面试时只要你能说清楚两种做法的利弊,都不会扣分。

2.2 构造方法的两种设计

节点类的构造方法通常有两种设计思路,各有适用场景。

第一种是让调用者自己设置prev和next,也就是全参构造:

public ListNode(int data, ListNode prev, ListNode next) { this.data = data; this.prev = prev; this.next = next; }

第二种是只传数据,prev和next默认null,也就是半参构造:

public ListNode(int data) { this.data = data; }

我推荐在实现链表时,对外暴露半参构造就够了。为什么?因为插入操作的逻辑本身就是要让链表来维护节点之间的连接关系,不应该让外部手动乱指。你让用户自己传prev和next,他很可能把一个节点同时挂到两条链表上,整个结构就乱了。链表自己管理引用,才能保证一致性。

注意:单纯一个“节点”不需要实现Comparable或者重写equals/hashCode,除非你的链表需要支持按值查找或去重。别一上来就堆一堆用不到的方法,保持节点类尽量精简,这是一个很实际的工程经验。

2.3 节点结构的内存模型

要真正理解双向链表,脑子里得有一幅图。我习惯这样描述:内存里有一排并不连续的小格子,每个格子里放着三样东西——数据本身、指向上一个格子的箭头、指向下一个格子的箭头。

头节点的prev是null,尾节点的next是null。这两个null是整个链表遍历的终止条件。你要是把哪个节点的prev或next错误地指向了null,遍历时多半会出空指针;要是形成了环(A的next指向B,B的next指向A),那遍历就直接死循环了。这两种故障我调试过太多次,后面小节专门说排查方法。

从内存开销的角度算笔账:Java对象头在64位JVM下通常12字节,数据int占4字节,两个引用各占8字节(压缩指针时也可能4字节),对齐后大概40字节左右。看起来比数组单个元素多不少,但这就是灵活性的代价。理解了这层,你就能回答“双向链表为什么比数组浪费空间”这种基础面试题了。

3. 手写一个双向链表骨架:先搭好容器类

有了节点,还得有个容器类来管理整条链。很多教程上来就贴一个完整的200行代码让你读,新手看两分钟直接劝退。我换个方式,像盖房子一样一层一层往上搭,你先有一个能跑的壳,然后逐步往里面加功能。

3.1 基础字段:head、tail、size

双向链表容器类至少要有三个字段:

public class DoublyLinkedList { private ListNode head; // 头节点 private ListNode tail; // 尾节点 private int size; // 节点个数 public DoublyLinkedList() { this.head = null; this.tail = null; this.size = 0; } }

这里有个新手最容易犯的错:以为head和tail一开始就该指向一个“空节点”。其实不需要,初始状态下链表为空,head和tail都是null,size是0,这就对了。只有在你设计“带哨兵节点的链表”时,才需要提前new一个哑节点当head。

哨兵节点这个概念值得展开说说。很多经典教材和源码里,会在链表首尾各放一个不存数据的哨兵节点,目的是让边界操作和普通操作统一化——插入头节点时不需要特判head为null,删除尾部节点时也不需要特判tail为null。JDK的LinkedList并没有用哨兵,它的做法是让边界判断分散在具体方法里。两种方案各有拥趸,我的建议是:你写原理型代码时用最直观的null边界判断,你写框架级代码时考虑哨兵节点。因为哨兵节点能让代码逻辑减少一堆if分支,但代价是理解成本变高,面试时如果你能主动聊出这两种方案的取舍,绝对是加分项。

3.2 基础方法:isEmpty、size、getFirst、getLast

骨架阶段,先实现几个最简单的读取方法,方便后续调试:

public boolean isEmpty() { return size == 0; } public int size() { return size; } public int getFirst() { if (isEmpty()) { throw new NoSuchElementException("链表为空"); } return head.data; } public int getLast() { if (isEmpty()) { throw new NoSuchElementException("链表为空"); } return tail.data; }

这几个方法的实现本身不难,但注意我抛异常的选择。如果链表为空时返回-1或者0,很容易跟真实数据的值混淆,这是很多老代码里埋下的隐性bug。Java标准做法就是抛NoSuchElementException,跟LinkedList的行为保持一致。你调用别人写的API时,也要有意识地查阅它空集合时到底是抛异常还是返回默认值,这决定了你调用侧需不需要提前判空。

骨架搭好之后,从哪个方法开始加?我强烈建议下一步先实现头插法。因为头插入的逻辑最简单、变量最少,也是后续所有插入操作的底层基石。你先把最简单的跑通,再逐步加尾插、任意位置插入,心理压力小很多。

4. 插入操作全解:addFirst、addLast、add(int index, int data)

4.1 头插法 addFirst:新节点变成新的head

头插法的核心思想是“新节点插入到链表最前面,成为新的头”。实现分两种情况:链表是空的,以及链表非空。为什么必须分?因为空链表插入后,head和tail要同时指向这唯一的一个节点;非空的情况下tail不用动。

先看空表的插入:

public void addFirst(int data) { ListNode newNode = new ListNode(data); if (isEmpty()) { head = newNode; tail = newNode; } else { newNode.next = head; head.prev = newNode; head = newNode; } size++; }

请你务必逐行核对顺序。重点在两行:newNode.next = head与head.prev = newNode。如果顺序反了,先执行head.prev = newNode,此时head还没变,逻辑上虽然也能转,但如果你接着执行head = newNode,新节点就成了头,老头的prev已经指向新节点,引用关系绕成了一个环——单看这个操作好像没错,但后续遍历时很容易出现重复访问节点。所以我的铁律是:先改新节点的next,再改老节点的prev,最后移动head指针。这个顺序在任何插入操作里都通用。

那tail在头插法里什么时候需要改?只有空表插入时需要。因为空表插入,新节点既是头也是尾;非空表插入,tail保持不变。很多初学者会把tail也顺带改了,这是多余的,不影响正确性但说明你没想清楚指针的职责。

4.2 尾插法 addLast:在尾部追加最省事

尾插法和头插法完全镜像,代码写出来是这样的:

public void addLast(int data) { ListNode newNode = new ListNode(data); if (isEmpty()) { head = newNode; tail = newNode; } else { newNode.prev = tail; tail.next = newNode; tail = newNode; } size++; }

你发现没有,头插法和尾插法在空表时的处理一模一样的四行代码。这种重复代码很多人会单独抽一个addFirstNode(newNode)私有方法,但我觉得在这两个方法里保持复制也行,因为逻辑短,抽出来的收益有限。工程判断的优先级是:代码可读性大于避免少量重复。

尾插法的时间复杂度是O(1),因为你有tail指针。这里有个很容易被忽略的优点:单向链表如果只有head指针,尾插必须是O(n)——你得从头遍历到尾才能找到最后一个节点。双向链表引入tail指针后,尾部操作变得极其便宜。这也是为什么LinkedList的add(E e)默认就是尾插,而且它很快。

4.3 指定位置插入 add(int index, int data):重头戏

真正让新手头皮发麻的通常是这个:在第index个位置插入一个节点,index从0开始。比如add(0, x)等价于头插,add(size, x)等价于尾插,add(2, x)就是在目前下标为1的节点后面、下标为2的节点前面插入。

首先,要做边界检查:

public void add(int index, int data) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("下标越界: " + index); } // ... }

注意这里允许index == size,因为插在末尾是合法的。如果你写成index >= size就漏掉了尾部插入的场景,这是一个非常典型的边界错。

接着分三类处理:

public void add(int index, int data) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("下标越界: " + index); } if (index == 0) { addFirst(data); return; } if (index == size) { addLast(data); return; } // 核心:找到当前位于index位置的节点 ListNode currentNode = getNode(index); ListNode newNode = new ListNode(data); ListNode prevNode = currentNode.prev; newNode.prev = prevNode; newNode.next = currentNode; prevNode.next = newNode; currentNode.prev = newNode; size++; }

这里我需要重点解释下“找到当前位于index位置的节点”。我们有head和tail两个入口,所以可以做一个优化——判断index离头部近还是离尾部近。为什么能这么判断?因为双向链表可以双向走。来,看这个查找方法:

private ListNode getNode(int index) { if (index < size / 2) { // 从头部往后找 ListNode current = head; for (int i = 0; i < index; i++) { current = current.next; } return current; } else { // 从尾部往前找 ListNode current = tail; for (int i = size - 1; i > index; i--) { current = current.prev; } return current; } }

这个二分法查找是双向链表特有的优势。在LinkedList源码里就有这个优化,单链表只能干瞪眼。平均下来,查找任意位置的复杂度是O(n/2)=O(n),常数因子比单链表省一半,虽然复杂度级别没变,但在大数据量下差别很可观。

回到插入的连线部分,这是全篇最值得反复看的四行:

newNode.prev = prevNode; newNode.next = currentNode; prevNode.next = newNode; currentNode.prev = newNode;

我建议你记住一个原则:先把新节点的两个箭头都挂好,再断开旧连接。这四行你甚至可以背下来,以后写任何链表插入都是这个套路。为什么顺序很重要?如果你先执行prevNode.next = newNode,这时候新节点和prevNode已经连上了,但newNode自己的prev还没设置,管子是半通的;如果你在这中间发生异常,链表就处于损坏状态。先挂新节点,再动旧节点,可以保证断线操作前所有的“准备信号”都已就位。虽然单线程下不涉及原子性问题,但好习惯能让你少写出那种难以定位的诡异bug。

4.4 头插尾插在空表时的等价性验证

理论上说,在一个空链表上执行addFirst(10)和addLast(10),得到的结果完全一样:链表只有一个节点10,head和tail都指向它。我用这个作为自检用例。很多同学写了头插又写了尾插,但从来没有在空链表上测试过尾插,结果空表时head没设置对,一跑就空指针。

我来手写两段测试用例给你看看:

public static void main(String[] args) { DoublyLinkedList list1 = new DoublyLinkedList(); list1.addFirst(10); list1.addLast(20); list1.addLast(30); // 期望顺序:10 -> 20 -> 30 System.out.println(list1.getFirst()); // 10 System.out.println(list1.getLast()); // 30 DoublyLinkedList list2 = new DoublyLinkedList(); list2.addLast(10); list2.addLast(20); list2.addFirst(5); // 期望顺序:5 -> 10 -> 20 System.out.println(list2.getFirst()); // 5 System.out.println(list2.getLast()); // 20 }

这种“功能自测”建议养成习惯。你不可能每次写完代码都提测然后等结果,自己写main方法做冒烟测试是基本的职业素养。也别光测正常流程,空表插入、单节点表插入、边界下标插入这三类异常场景,你至少要各测一遍。

5. 常用工具方法:遍历、查找、删除、反转

5.1 正向和反向遍历的打印方法

遍历逻辑不算难,但它能立刻验证你的prev/next指针到底对不对。给链表加两个打印方法:

public void printForward() { StringBuilder sb = new StringBuilder(); sb.append("正向: "); ListNode current = head; while (current != null) { sb.append(current.data); if (current.next != null) { sb.append(" <-> "); } current = current.next; } System.out.println(sb.toString()); } public void printBackward() { StringBuilder sb = new StringBuilder(); sb.append("反向: "); ListNode current = tail; while (current != null) { sb.append(current.data); if (current.prev != null) { sb.append(" <-> "); } current = current.prev; } System.out.println(sb.toString()); }

正向遍历从头节点开始,current = current.next一路走到null;反向遍历从尾节点开始,current = current.prev一路走到null。如果你打印出来的顺序反了,或者中间断了,第一反应应该是:插入操作中某个prev或next没接对。

有一种情况我提醒你注意:遍历循环里如果出现空指针,不要急着在打印方法里加if判空。你先要问自己:是插入逻辑坏了,还是链表本来就是空的?空链表时,正向遍历应该什么都不打印,打印方法里用current != null控制循环就够了。如果你在一堆方法里到处防NPE,结果只会掩盖真正的指针乱指问题。

5.2 按值查找和按下标查找

查找有两个维度:按数据值、按下标。按下标我们已经写过了getNode(int index),不过那是私有方法,对外通常还要提供一个get(int index),它内部调用getNode并返回数据:

public int get(int index) { ListNode node = getNode(index); return node.data; }

按值查找的典型应用是确认某个元素是否存在:

public boolean contains(int target) { ListNode current = head; while (current != null) { if (current.data == target) { return true; } current = current.next; } return false; }

对于基础int类型,用==比较没问题;如果你把泛型接进来,就涉及到equals方法和null判断,别再直接==了。泛型版本的contains逻辑可以写成target == null ? current.data == null : target.equals(current.data),这是JDK里面常见的写法。

5.3 删除操作中prev和next怎么解绑

删除操作的最经典场景是把一个节点摘掉。这属于插入的核心对称操作,而且面试时问得很多。先看删除头节点和尾节点:

public int removeFirst() { if (isEmpty()) { throw new NoSuchElementException("链表为空"); } int value = head.data; if (size == 1) { head = null; tail = null; } else { head = head.next; head.prev = null; } size--; return value; } public int removeLast() { if (isEmpty()) { throw new NoSuchElementException("链表为空"); } int value = tail.data; if (size == 1) { head = null; tail = null; } else { tail = tail.prev; tail.next = null; } size--; return value; }

注意删除操作里我先把value存下来,再改引用,最后才size--。顺序不能反,因为一旦head被移动了,原头节点的data就访问不到了(除非你保留局部引用),所以先保存返回值是基本操作。

通用删除任意位置的核心逻辑是:

public void remove(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } ListNode current = getNode(index); ListNode prevNode = current.prev; ListNode nextNode = current.next; if (prevNode == null) { // 删除的是头 head = nextNode; } else { prevNode.next = nextNode; } if (nextNode == null) { // 删除的是尾 tail = prevNode; } else { nextNode.prev = prevNode; } // 帮助GC回收,清除残留引用 current.prev = null; current.next = null; size--; }

删除时有个程序员比较喜欢的细节:被删除节点最好把prev和next都置空。这不会影响逻辑正确性,但它能帮助JVM垃圾回收——如果这个节点还被某个局部变量或旧引用持有,清除它的内部引用能避免它意外牵住整条链表。这是和C++手动管理内存不同的Java习惯,知道的人不少,但每次都会写的人不多。

5.4 反转双向链表的两种思路

反转一个双向链表是面试经典题。方案一是交换每个节点的prev和next,然后交换head和tail:

public void reverse() { ListNode current = head; ListNode temp = null; while (current != null) { temp = current.prev; current.prev = current.next; current.next = temp; current = current.prev; // 此时current.prev已经指向原next } temp = head; head = tail; tail = temp; }

这个解法关键在于遍历时的步进:因为交换完prev和next后,原节点的next已经指向了原来的上一个节点,所以我们只能通过current.prev来走到原来的下一个节点。这是整段代码里最容易看晕的两行:

current.next = temp; // temp是原来的prev current = current.prev; // current.prev现在是原来的next

方案二是新建一个链表,把原链表的节点依次头插进去,最后得到的就是反转后的链表。这个方案代码直观但浪费空间,不是最优。面试时你如果能直接给出方案一并解释清楚步进原理,跟你只是背答案完全是两个层次。

6. 与Java集合框架的关系:面试和工程里的双向链表

6.1 LinkedList底层就是双向链表

Java标准库里的LinkedList,它的内部实现就是双向链表。你可以去翻JDK源码,类里面有first和last两个节点指针,节点类是Node<E>,结构跟你前面自己写的几乎一模一样:

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; } }

看到了吗?你手写的那个节点类,和JDK源码里的私有静态内部类,本质上没有任何区别。所以通过这篇教程养成的手感,会在你看JDK源码时直接生效。LinkedList的add(int index, E element)也是先做下标检查,再判断边界插入,最后走中间节点插入,和你上面写的逻辑一个模子。

一个有价值的点是:LinkedList同时实现了List和Deque两个接口。所以它能当队列用、当双端队列用、当栈用。面试常问的“LinkedList和ArrayList怎么选”,本质就是在问你“数据结构的物理布局和操作模式是否匹配”。如果你把这一篇的插入删除复杂度吃透了,面试时就能脱口而出:ArrayList在尾部增删O(1),在中间add/remove需要数组拷贝O(n);LinkedList在头尾增删O(1),在指定位置需要先遍历定位O(n)。看起来LinkedList不应该更香吗?但真实工程里恰好相反——绝大多数场景是尾部写入、按下标随机读取,这两个ArrayList都是王者。这就是我常说的,数据结构没有绝对的优劣,只有匹配度。

6.2 LinkedHashMap里的“链表+哈希表”组合

另一个不可错过的例子是LinkedHashMap。它维护了一个双向链表来记录插入顺序或访问顺序,从而让Map具备了“按插入顺序遍历”的能力。如果你用过这种Map的构造器传accessOrder=true,它就会自动把每次访问的节点挪到链表尾部,这样遍历时得到的是最近最少使用的顺序——LRU缓存就是这么实现的。

这种“哈希表+双向链表”的组合,在面试系统设计题时是个高频答案。比如设计一个LRU Cache,最常见的解法就是HashMap提供O(1)查找,双向链表提供O(1)的移动和删除。链表在这里的价值不在于“能存多少数据”,而在于节点移动的成本极低——你不需要像数组那样搬动大量元素,只需要摘节点、挂节点。

6.3 多级菜单与双向链表

再聊一个偏实际应用的场景:后端管理系统的多级菜单。很多系统的菜单表用parentId来表示层级关系,查询时就需要递归组装成树。有一些老系统的菜单结构是平铺在一个双向链表里的,每个菜单节点有prev和next指向它的兄弟节点,有child指针指向第一个子节点。这样在菜单渲染时就能线性地遍历同一级菜单,而不是每次从数据库里按条件重新查。

这种设计在今天的互联网项目中不算主流,但算法题“双向链表多级菜单扁平化”经常被拿来当面试题——把多级双向链表按顺序拍平成一个双向链表。它的核心操作本质上就是我前面讲的插入操作:遍历时遇到有子链表的节点,把子链表整体插入到当前节点和下一个节点之间,然后把子链表的头尾引用接到主链上。

所以别觉得手写双向链表只是应付面试,它在真实系统中总会在意想不到的地方冒出来。

7. 基础知识测绘:链表操作的复杂度分析与选择

7.1 各种操作的时间复杂度速查表

我自己做面试官时,特别喜欢让候选人口算复杂度。下面这张表你最好能熟练到闭眼默写:

操作双向链表单向链表动态数组
头部插入O(1)O(1)O(n)
尾部插入O(1)O(n)O(1)均摊
任意位置插入O(n)定位+O(1)插入O(n)定位+O(1)插入O(n)
头部删除O(1)O(1)O(n)
尾部删除O(1)O(n)O(1)
按下标访问O(n)O(n)O(1)
按值查找O(n)O(n)O(n)

你会发现一个有意思的事:单向链表在尾部插入是O(n),双向链表因为多了tail指针,尾部插入变成O(1)。删除指定节点时,单链表得从头找到待删节点的前驱,所以O(n);双向链表直接用待删节点的prev就能拿到前驱,O(1)。这两个差别是面试题里的高频考点,你不仅要会算,还要能一句话解释清楚“为什么多了一个prev引用,复杂度就有了质的差别”。

7.2 空间复杂度和内存开销对比

空间复杂度方面,数组的额外开销极小,链表每个节点都要多存一两个引用。如果考虑引用压缩,JVM的默认选项通常是压缩指针的,引用占4字节;未压缩时占8字节。双向链表比单向链表每节点多一个引用,一个存储int的节点大概多占4到8字节。这个数字乘上几百万个节点,就是几十MB的差距。所以当系统内存吃紧时,请不要随手用LinkedList替代ArrayList,这是新手常犯的错——以为链表“插入快”就天下无敌,结果内存和随机访问双双崩盘。

如果你要操作的元素特别小,比如存一个int,用链表几乎是最浪费的选择。一个int只有4字节,光节点对象头的对齐损耗就可能是它的好几倍。工程上如果要存大量基础类型数据且频繁插入删除,第一选择是ArrayList配合批量操作,第二选择是ArrayDeque,只有在确实需要频繁在任意位置插入时才考虑链表。

7.3 一图流理解指针维护顺序

说真的,学链表最关键的是把自己变成“指针调试器”。我总结了一个万能口诀,先给新节点“拜码头”,再让旧节点“让位置”:

  1. 确定前驱节点prevNode和后继节点nextNode
  2. 新节点的prev先指向prevNode,新节点的next先指向nextNode
  3. 让prevNode的next指向新节点
  4. 让nextNode的prev指向新节点
  5. 别忘了维护size和tail/head的指向

任何插入场景,不管你是头插、尾插、中间插、还是接在子链表的某个位置,都是这五步的变体。你把它当作肌肉记忆刻下去,写链表代码根本不需要背。

8. 常见错误与调试技巧:连踩三坑之后的经验总结

8.1 空链表上执行插入/删除导致空指针

这是我见过最多的初学者bug。空链表上head是null,如果你执行addLast时忘了判断isEmpty()而直接写tail.next = newNode,运行结果一定是空指针。解决办法很粗暴:写插入删除方法时第一个分支永远是if (isEmpty()),这应该成为条件反射。推荐在逻辑实现完成后用空链表测一遍每一个插入删除方法,把“空表安全”当作验收标准之一。

8.2 遍历时指针指向混乱形成死循环

这种bug最凶险。发生在哪里?最常见的场景是反转链表方法里步进写错:比如你写current = current.next但current.next已经被改成了prev,于是你在链表里原地绕圈。解决办法是:每次修改节点引用时,先想清楚“修改之后这个节点的next和prev各指向谁”。实在转不过来,就在关键步骤临时打印:打印当前节点、它的prev、它的next。自己能写一个debugPrint()方法吗?我给你一个快速版:

public void debugPrint() { ListNode current = head; int count = 0; while (current != null) { System.out.printf("node[%d]=%d, prev=%s, next=%s%n", count++, current.data, current.prev == null ? "null" : String.valueOf(current.prev.data), current.next == null ? "null" : String.valueOf(current.next.data)); current = current.next; if (count > size + 1) { throw new IllegalStateException("检测到循环,强制终止"); } } }

那个if (count > size + 1)就是防死循环的保险丝。你遍历的次数不可能超过size+1,一旦超过就说明链表结构已经坏了。带上这招,排查环结构会快很多。

8.3 忘记维护size、head、tail导致数据不一致

size忘了加1或者忘了减1,在不涉及遍历的场景里不一定立刻报错,但一旦你走getNode(index),就会越界或者取到错误元素。head和tail维护失败则更严重——你尾插一个节点但忘记了tail指向它,下次尾插时新节点就接在了旧尾巴后面,旧尾巴反而变成了倒数第二个节点,整条链依旧是对的,但tail指针指向错了。这种bug特别难发现,因为打印正向链表时完全正常,你只有从头遍历或调用getLast()才发现马脚。

我强烈建议你维护一个checkInvariant()方法:

public void checkInvariant() { if (size == 0) { if (head != null || tail != null) { throw new IllegalStateException("size为0但head/tail非空"); } return; } if (head.prev != null) { throw new IllegalStateException("head.prev必须为null"); } if (tail.next != null) { throw new IllegalStateException("tail.next必须为null"); } int count = 0; ListNode current = head; while (current != null) { count++; current = current.next; } if (count != size) { throw new IllegalStateException("size不匹配,实际节点数=" + count); } }

每次测试插入删除后都调用一次这个方法,如果哪里写错了,测试会立刻告诉你。这是我写数据结构代码时最常用的“守护神”,别嫌麻烦,真能帮你省下好几个小时的抓狂时间。

8.4 删除节点后忘了让JVM可回收

老生长谈但值得再强调一遍:删除节点后最好把该节点的prev和next置为null。理由有两个:一是避免该节点因为某个局部变量而被引用时,意外保持整条链;二是在调试时,如果你打印这个节点,不会看到一堆莫名其妙的关联信息。这在Java里不是强制的(GC会全盘扫描可达性),但写一手“干净整洁”的代码是成熟工程师的自我要求。

9. 面试高频问答:双向链表如何回答才加分

9.1 “讲讲双向链表和单向链表的区别”

这个问题不能只答“双向多了个prev引用”。你要分三个层次回答:

  • 结构层面:双向链表节点有prev + data + next,单向链表只有data + next。
  • 操作层面:双向链表可以双向遍历,删除指定节点时能直接拿到前驱,尾部操作在有tail指针时是O(1);单向链表删除时必须从头找前驱。
  • 空间层面:双向链表每个节点多存一个引用,空间开销更大。

说完这些,再补一句“所以在JDK的LinkedList底层用的就是双向链表”作为实战背书,这个回答基本就是满分模板。

9.2 “在指定位置插入节点时,代码为什么那样写”

面试官如果追问到这段代码,你要能画出引用变更图。建议的回答路径是:先描述边界情况(空表、插头部、插尾部),再描述中间插入的核心四步连接,最后说明为什么这么排序——先新节点、再旧连接,以免打破链表结构。如果能再补一句“我一般会配合一个检查头尾和size不变式的方法来辅助验证”,那就显得非常有工程素养了。

9.3 “如何反转双向链表”

这块我在前面已经给了代码。面试官想看的是两件事:能不能发现“交换每个节点的prev和next”这个核心思路,以及能不能处理遍历步进的坑。如果再加上对head和tail交换的处理,这道题就稳了。建议你背代码的同时,把示例画在一张纸上,面试时能徒手画图说明绝对加分。

9.4 “LinkedList的add(1, element)是怎么实现的”

这个问题考察你对源码的熟悉程度。合理回答:先检查下标,再判断是否插在头部或尾部,如果都不是,定位到原来位于该下标位置的节点,然后用一段类似我前面写的四行代码把新节点接进去。如果面试官继续问“为什么定位下标1的节点,而不是下标index-1的节点”,这时候你如果能答出“因为这样能直接拿到后继节点,只需要再通过prev拿到前驱,双向链表做这个操作的成本很低”,那说明你是真懂,不是背答案。

10. 完整源码与后续扩展建议

10.1 汇总一份可直接运行的完整类代码

把上面所有方法拼在一起,就是一个能跑的基础双向链表。我整理了一份包含插入、删除、遍历、反转、查找版本的代码。你直接复制到DoublyLinkedList.java文件里就能编译运行:

import java.util.NoSuchElementException; public class DoublyLinkedList { private ListNode head; private ListNode tail; private int size; private static class ListNode { int data; ListNode prev; ListNode next; ListNode(int data) { this.data = data; } } public DoublyLinkedList() { head = null; tail = null; size = 0; } public boolean isEmpty() { return size == 0; } public int size() { return size; } public int getFirst() { if (isEmpty()) throw new NoSuchElementException(); return head.data; } public int getLast() { if (isEmpty()) throw new NoSuchElementException(); return tail.data; } public void addFirst(int data) { ListNode newNode = new ListNode(data); if (isEmpty()) { head = newNode; tail = newNode; } else { newNode.next = head; head.prev = newNode; head = newNode; } size++; } public void addLast(int data) { ListNode newNode = new ListNode(data); if (isEmpty()) { head = newNode; tail = newNode; } else { newNode.prev = tail; tail.next = newNode; tail = newNode; } size++; } public void add(int index, int data) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("下标越界: " + index); } if (index == 0) { addFirst(data); return; } if (index == size) { addLast(data); return; } ListNode currentNode = getNode(index); ListNode prevNode = currentNode.prev; ListNode newNode = new ListNode(data); newNode.prev = prevNode; newNode.next = currentNode; prevNode.next = newNode; currentNode.prev = newNode; size++; } public int get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } return getNode(index).data; } public boolean contains(int target) { ListNode current = head; while (current != null) { if (current.data == target) return true; current = current.next; } return false; } private ListNode getNode(int index) { if (index < size / 2) { ListNode current = head; for (int i = 0; i < index; i++) { current = current.next; } return current; } else { ListNode current = tail; for (int i = size - 1; i > index; i--) { current = current.prev; } return current; } } public int removeFirst() { if (isEmpty()) throw new NoSuchElementException(); int value = head.data; if (size == 1) { head = null; tail = null; } else { head = head.next; head.prev = null; } size--; return value; } public int removeLast() { if (isEmpty()) throw new NoSuchElementException(); int value = tail.data; if (size == 1) { head = null; tail = null; } else { tail = tail.prev; tail.next = null; } size--; return value; } public int remove(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } ListNode current = getNode(index); int value = current.data; ListNode prevNode = current.prev; ListNode nextNode = current.next; if (prevNode == null) { head = nextNode; } else { prevNode.next = nextNode; } if (nextNode == null) { tail = prevNode; } else { nextNode.prev = prevNode; } current.prev = null; current.next = null; size--; return value; } public void reverse() { ListNode current = head; ListNode temp = null; while (current != null) { temp = current.prev; current.prev = current.next; current.next = temp; current = current.prev; } temp = head; head = tail; tail = temp; } public void printForward() { StringBuilder sb = new StringBuilder("正向: "); ListNode current = head; while (current != null) { sb.append(current.data); if (current.next != null) sb.append(" <-> "); current = current.next; } System.out.println(sb.toString()); } public void printBackward() { StringBuilder sb = new StringBuilder("反向: "); ListNode current = tail; while (current != null) { sb.append(current.data); if (current.prev != null) sb.append(" <-> "); current = current.prev; } System.out.println(sb.toString()); } public void checkInvariant() { if (size == 0) { if (head != null || tail != null) { throw new IllegalStateException("size为0但head/tail非空"); } return; } if (head.prev != null) throw new IllegalStateException("head.prev必须为null"); if (tail.next != null) throw new IllegalStateException("tail.next必须为null"); int count = 0; ListNode current = head; while (current != null) { count++; current = current.next; } if (count != size) { throw new IllegalStateException("size不匹配,实际节点数=" + count); } } }

注意我把节点类设成了静态内部类。为什么是static?因为它不依赖外部类的实例字段。Java里非静态内部类会隐式持有外部类的引用,白白增加内存占用,还可能导致你无意中构造出对外部类的强引用。静态内部类没这个问题。这是一个很多人不注意但面试官很喜欢的细节。

10.2 怎么扩展成泛型版本和迭代器

如果要把int换成泛型T,改动点其实就几个:

  • DoublyLinkedList<T>
  • 字段类型改成ListNode<T>
  • ListNode类改成private static class ListNode<T>,data字段类型为T
  • contains方法里的比较要用equals
  • 对外方法签名全部加T

再加一个迭代器会让链表用起来非常顺手:

public java.util.Iterator<T> iterator() { return new Iterator<T>() { private ListNode<T> current = head; @Override public boolean hasNext() { return current != null; } @Override public T next() { if (current == null) { throw new NoSuchElementException(); } T value = current.data; current = current.next; return value; } }; }

迭代器的作用不只是让你能在foreach里遍历,更关键的是它能隐藏内部结构、对外提供统一的访问接口。你在框架代码里用迭代器,替换底层数据结构时调用方几乎无感知——这就是面向接口编程的威力。

10.3 后续功能扩展方向

当前这个版本已经具备双向链表的核心功能,但如果你想让代码更贴近真实工程,可以继续加上:

  • 支持哨兵节点的版本,简化边界判断
  • 支持从尾部向头部遍历的迭代器
  • 支持remove(Object o)这种按值匹配删除
  • 支持批量构建:传入一个数组或者集合直接建链表
  • 支持toArray()转换,和数组互操作
  • 支持线程安全版本,比如在关键方法上synchronized,或者使用ReentrantReadWriteLock做读写分离

我个人最推荐你优先做泛型和迭代器扩展——这两个扩展开完后,你的代码就已经能对标一个简易版的LinkedList了。之后的进阶方向就是去看LinkedList源码,对比它的linkFirst、linkLast方法跟你写的有什么区别。看完你会发现,JDK源码其实也是这五步操作,只是它用了一个统一的linkBefore(E e, Node<E> succ)私有方法,把所有插入场景归一化。等你理解了为什么它能这么归一化,你的数据结构功力就又上了一个台阶。

11. 总结一点个人建议:学链表最好的方式就是动手画图再动手敲码

我在线下带过不少同学学数据结构,发现一个共性:代码写得特别快,但你要是让他拿笔画一下“我在链表的第2个位置插入一个值为5的节点,ptr分别怎么变”,十个里有八个当场卡壳。这不是说他们逻辑不行,而是缺了“先可视化、再编码”的习惯。

我自己的方法论是这样:拿到链表题,先在白纸上画一条三五个节点的链,标好head和tail的位置,然后用不同颜色的笔画出新增节点、标出每一步要改的引用,等把图画通了再动手写代码。写完代码别急着走,再用那张图去对,每个赋值语句都能在图上找到一个对应的箭头变化,这才算真正完成了一道题。

这套方法论不只是适合新手,我工作多年之后遇到复杂链表操作也一样先用纸笔推演。比如实现一个带子链表的扁平化算法时,纸上画三分钟,比在IDE里debug一小时还有效。

你如果顺着这篇文章把代码敲了一遍,再把所有边界条件都测过一遍,应该已经能明显感觉到:双向链表真的不难,难的是动手前脑子里有没有图。

最后分享一个小技巧:你可以把checkInvariant()每次插入删除后都调用一次,跑一遍完整测试。如果哪次抛异常了,说明这次插入或删除破坏了链表结构。用二分法定位到具体是哪一步,再对照我前面说的五步指针操作找问题,基本五分钟之内能定位。这算是我的独门调试法,分享给你,希望你以后别再被链表bug折磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询