HOWTO · Java
Java 中的双向链表
在本教程中,我们将讨论 Java 中的双向链表
本页内容
双向链表中的每个节点都连接到前一个和后一个节点,因此可以向两个方向遍历。如果代码已经持有某个节点,也可以通过更新相邻引用将其从链表中移除。Java 的 LinkedList 提供了现成的实现;基于 Node 的自定义链表,则适合用来学习这些链接的工作方式,或在项目需要节点级行为时使用。
什么是双向链表?
每个节点存储一个值和两个引用:next 与 previous(通常也写作 prev)。链表会记录第一个节点(头节点),通常也会记录最后一个节点(尾节点)。头节点没有前驱节点,尾节点没有后继节点。Node 通过引用连接,不必存放在相邻的内存位置。
对于相邻节点 a 和 b,如果 a.next == b 且 b.prev == a,两者的链接就相互对应。从头节点沿 next 遍历可以正向移动;从尾节点沿 prev 遍历可以反向移动。单向链表只存储指向后续节点的引用,因此无法从已知节点直接移到它的前驱节点。
使用 Java 的 LinkedList 类
对于一般的应用程序代码,可以先使用 java.util.LinkedList<E>;它同时实现了 List<E> 和 Deque<E>。它支持 addFirst、addLast、get、set、remove、peekFirst 和 peekLast 等操作。ListIterator<E> 可以通过 next() 向前移动,也可以通过 previous() 向后移动。
例如,创建一个 LinkedList<String>,在两端添加值,再用 listIterator() 按顺序读取。若要反向遍历,先通过 while (it.hasNext()) it.next(); 将迭代器移到末尾,然后在 it.hasPrevious() 为真时调用 previous()。Java 集合负责管理内部节点;调用者应使用公开方法,而不是直接访问节点引用。
不要假设所有操作都是常数时间。在任一端添加或移除元素需要常数时间。按索引查找元素,或通过搜索查找某个值,所需时间与遍历距离成正比。通过 ListIterator 插入或移除元素时,如果迭代器已经位于目标位置,操作需要常数时间;但移动到该位置可能需要线性时间。按索引调用 set 也必须先定位到该索引。
在 Java 中实现双向链表
下面的独立程序同时维护 head 和 tail。它通过 linkBetween 辅助方法集中处理插入时常见的引用更新,并由 unlink 重新连接被移除节点两侧的节点。checkLinks 方法会检查两个方向的链接是否一致,遍历方法则展示节点顺序。将程序保存为 Main.java,即可使用 Java 编译器编译并运行。
运行这个独立示例:
本节中的代码以及显示的标签或输出仍为英文。
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.ListIterator;
public class Main {
static final class Node {
final int value;
Node previous;
Node next;
Node(int value) {
this.value = value;
}
}
static final class DoublyLinkedList {
private Node head;
private Node tail;
private int size;
void addLast(int value) {
linkBetween(new Node(value), tail, null);
}
void addFirst(int value) {
linkBetween(new Node(value), null, head);
}
void insertAt(int index, int value) {
if (index < 0 || index > size) {
return;
}
if (index == size) {
addLast(value);
} else if (index == 0) {
addFirst(value);
} else {
Node right = nodeAt(index);
linkBetween(new Node(value), right.previous, right);
}
}
int removeAt(int index) {
if (index < 0 || index >= size) return -1;
Node target = nodeAt(index);
int value = target.value;
unlink(target);
return value;
}
private Node nodeAt(int index) {
if (index < 0 || index >= size) return null;
if (index < size / 2) {
Node current = head;
for (int i = 0; i < index; i++) current = current.next;
return current;
}
Node current = tail;
for (int i = size - 1; i > index; i--) current = current.previous;
return current;
}
private void linkBetween(Node node, Node left, Node right) {
node.previous = left;
node.next = right;
if (left == null) head = node;
else left.next = node;
if (right == null) tail = node;
else right.previous = node;
size++;
}
private void unlink(Node node) {
if (node.previous == null) head = node.next;
else node.previous.next = node.next;
if (node.next == null) tail = node.previous;
else node.next.previous = node.previous;
node.previous = null;
node.next = null;
size--;
}
String forward() {
StringBuilder result = new StringBuilder();
for (Node node = head; node != null; node = node.next) {
if (result.length() > 0) result.append(" <-> ");
result.append(node.value);
}
return result.toString();
}
String backward() {
StringBuilder result = new StringBuilder();
for (Node node = tail; node != null; node = node.previous) {
if (result.length() > 0) result.append(" <-> ");
result.append(node.value);
}
return result.toString();
}
boolean checkLinks() {
int count = 0;
Node previous = null;
for (Node node = head; node != null; node = node.next) {
if (node.previous != previous) return false;
previous = node;
count++;
}
if (previous != tail || count != size) return false;
int reverseCount = 0;
Node next = null;
for (Node node = tail; node != null; node = node.previous) {
if (node.next != next) return false;
next = node;
reverseCount++;
}
return next == head && reverseCount == size;
}
}
static void verify(String step, DoublyLinkedList values,
String expectedForward, String expectedBackward) {
String forward = values.forward();
String backward = values.backward();
boolean linksAgree = values.checkLinks();
if (!forward.equals(expectedForward)
|| !backward.equals(expectedBackward)
|| !linksAgree) {
throw new IllegalStateException("Check failed after " + step);
}
System.out.println("After " + step + ": forward=[" + forward
+ "], backward=[" + backward + "], links consistent=" + linksAgree);
}
public static void main(String[] args) {
DoublyLinkedList values = new DoublyLinkedList();
values.addFirst(10);
verify("insert at front", values, "10", "10");
values.addLast(30);
verify("insert at end", values, "10 <-> 30", "30 <-> 10");
values.insertAt(1, 20);
verify("insert in middle", values, "10 <-> 20 <-> 30", "30 <-> 20 <-> 10");
values.addFirst(5);
verify("another front insertion", values, "5 <-> 10 <-> 20 <-> 30", "30 <-> 20 <-> 10 <-> 5");
values.addLast(40);
verify("another end insertion", values, "5 <-> 10 <-> 20 <-> 30 <-> 40", "40 <-> 30 <-> 20 <-> 10 <-> 5");
values.removeAt(0);
verify("remove at front", values, "10 <-> 20 <-> 30 <-> 40", "40 <-> 30 <-> 20 <-> 10");
values.removeAt(1);
verify("remove in middle", values, "10 <-> 30 <-> 40", "40 <-> 30 <-> 10");
values.removeAt(2);
verify("remove at end", values, "10 <-> 30", "30 <-> 10");
values.removeAt(0);
verify("remove at front", values, "30", "30");
values.removeAt(0);
verify("remove final node", values, "", "");
LinkedList<String> names = new LinkedList<>();
names.add("Ada");
names.add("Linus");
ListIterator<String> iterator = names.listIterator(names.size());
ArrayList<String> reverse = new ArrayList<>();
while (iterator.hasPrevious()) reverse.add(iterator.previous());
System.out.println("Java LinkedList backward: " + reverse);
}
}
每次执行自定义链表操作后,verify 都会将预期的正向和反向序列与实际结果比较,并检查链接是否一致,然后打印当前状态。操作序列涵盖在开头、中间和末尾插入及移除节点,也包括链表从空变为只有一个节点,再恢复为空的过程。Node 类实现了自定义链表;Java 中单独的 LinkedList 示例则使用标准集合 API。代码块中的输出标签保留英文,打印内容包括正向和反向遍历结果以及链接一致性检查结果。
插入和删除 Node 节点
linkBetween 会指定新节点两侧的节点,并在插入发生于链表开头或末尾时更新头节点或尾节点。在链表中间插入时,两侧节点也都必须正确连接到新节点。如果只更新 next,反向遍历就可能出错。
删除时则反向执行相同的操作:将目标节点的前驱节点与后继节点连接起来;如果目标位于链表的一端,就更新 head 或 tail;最后清除被移除节点的引用。此实现中的 removeAt 会先查找索引,再移除已找到的节点。这一区别会影响性能:移除已知节点需要常数时间,而查找索引需要线性时间。nodeAt 会从距离较近的一端开始遍历,从而缩短遍历距离,但最坏情况下仍是线性时间。
在这个精简示例中,如果插入位置超出允许范围,insertAt 不会改变链表;如果无法移除节点,removeAt 会返回 -1。生产环境中的 API 应选择并说明适合调用者的错误报告策略。此版本允许在等于当前大小的位置插入,也就是追加元素。如果调用者需要按值移除节点,或移除已持有的节点,也可以添加相应方法或返回节点句柄。
遍历和测试链表
从 head 开始沿 next 正向遍历;从 tail 开始沿 previous 反向遍历。程序中的 forward 和 backward 方法分别格式化这两个方向的结果。checkLinks 会检查每一对相邻节点是否在两个方向上都正确连接,并确认访问到的节点数量与 size 相符。verify 会在每次修改后运行这些检查,并将两个方向的遍历结果与预期序列比较,从而发现过期的头节点或尾节点引用,以及未完成的链接更新。
示例的 main 方法演示了在开头、中间和末尾插入及移除节点。它先插入第一个节点,然后逐步移除节点,直到最后一个节点也被移除。每一步都会检查正向顺序、反向顺序和链接一致性,因此既测试了中间位置的修改,也测试了链表两端状态的变化。
何时使用双向链表
如果代码经常需要双向移动,或需要在已经找到目标节点后插入或移除节点,可以考虑使用双向链表。每个节点需要两个引用,因此比单向链表占用更多内存;按索引读取也比数组支持的列表慢,因为必须逐个遍历节点。若只需单向遍历,单向链表可以减少每个节点的存储开销;若经常按索引访问并希望节省空间,数组支持的 ArrayList 通常更合适。
如果 Java 的 LinkedList 提供的双端队列或列表操作符合需求,就使用它。若要学习指针关系,或需要集合未公开的节点级行为,则可以自行实现。无论采用哪种方式,都应根据目标位置是否已知来判断插入和删除的成本:更新少量链接开销较低,但查找位置仍需要与遍历距离成正比的时间。