算法刷题系列(十一):链表——节点引用与指针重连
算法刷题系列(十一):链表——节点引用与指针重连
前面的栈和队列说明了如何管理“下一步要处理谁”;链表关注的是另一件事:节点之间如何通过引用连接。数组靠连续下标访问元素,链表则从一个节点沿着 next 引用逐个走到后续节点。
本篇从单向链表的结构开始,用“删除所有指定值的节点”和“反转链表”两个原创例子练习引用重连,并介绍虚拟头节点如何统一处理头节点边界。Java 与 Python 示例都包含可运行的节点定义和测试。
一、数组与链表的区别
数组按下标访问元素:
1
2
index: 0 1 2 3
value: 7 4 9 2
单向链表由节点和引用组成:
1
2
3
4
head
│
▼
[7] → [4] → [9] → [2] → null
每个节点保存两部分:
value:节点的数据;next:指向下一个节点的引用;尾节点的next为null。
两种结构的主要差别:
| 操作 | 数组 | 单向链表 |
|---|---|---|
| 按下标读取 | O(1) | 从头遍历,O(n) |
| 搜索某个值 | O(n) | O(n) |
| 已知前驱后插入/删除 | 通常要移动元素,O(n) | 修改少量引用,O(1) |
| 额外结构成本 | 较低 | 每个节点还要存 next 引用 |
注意,“链表插入和删除是 O(1)”有前提:必须已经拿到待操作位置的前驱节点。若还要先从头查找该节点,查找本身仍然需要 O(n)。
二、定义单向链表节点
一个简单的节点结构可以写成:
1
2
3
Node:
value
next
head 指向首节点。空链表中 head 为 null/None。如果操作会删除或替换头节点,通常需要单独处理头部,或者增加一个虚拟头节点。
三、例子一:删除链表中所有指定值
给定链表:
1
4 → 2 → 4 → 3 → 4 → null
删除所有值为 4 的节点后,结果为:
1
2 → 3 → null
1. 为什么头节点容易成为边界问题?
如果头节点的值正好是待删除值,就需要把 head 移到下一个节点。若连续多个头节点都要删除,还要反复更新 head。
例如:
1
4 → 4 → 2 → null
一种处理方式是先用循环跳过所有待删除头节点,再处理其余部分。另一种更统一的方式是建立虚拟头节点:
1
dummy → 4 → 2 → 4 → 3 → 4 → null
这样,原头节点也变成了普通节点 dummy.next。每次只看当前节点的后继:
- 若
current.next.value等于目标值,把current.next跳过; - 否则将
current前进到current.next。
删除时 current 不前进,因为新接到它后面的节点也可能需要删除。
四、Java 实现:删除指定值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class LinkedListAlgorithms {
static class ListNode {
int value;
ListNode next;
ListNode(int value) {
this.value = value;
}
}
public static ListNode removeValue(ListNode head, int target) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode current = dummy;
while (current.next != null) {
if (current.next.value == target) {
current.next = current.next.next;
} else {
current = current.next;
}
}
return dummy.next;
}
public static ListNode reverse(ListNode head) {
ListNode previous = null;
ListNode current = head;
while (current != null) {
ListNode nextNode = current.next;
current.next = previous;
previous = current;
current = nextNode;
}
return previous;
}
public static ListNode fromArray(int... values) {
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
for (int value : values) {
tail.next = new ListNode(value);
tail = tail.next;
}
return dummy.next;
}
public static String toString(ListNode head) {
StringBuilder result = new StringBuilder();
for (ListNode current = head; current != null; current = current.next) {
if (result.length() > 0) {
result.append(" -> ");
}
result.append(current.value);
}
return result.toString();
}
public static void main(String[] args) {
ListNode head = fromArray(4, 2, 4, 3, 4);
head = removeValue(head, 4);
System.out.println(toString(head)); // 2 -> 3
System.out.println(toString(reverse(head))); // 3 -> 2
}
}
删除操作只改变 next 引用,不需要搬动后续节点的值。被跳过的节点不再从返回链表可达;在 Java 中,若没有其他引用指向它,后续由垃圾回收器处理。
五、例子二:反转单向链表
反转前:
1
1 → 2 → 3 → null
反转后:
1
3 → 2 → 1 → null
在遍历时,维护三个引用:
previous:已经反转好的前半段头节点;current:当前正要处理的节点;nextNode:提前保存当前节点原来的后继。
每一步的顺序必须是:
- 先保存
current.next; - 将
current.next指向previous; - 将
previous前进到current; - 将
current前进到之前保存的nextNode。
如果在保存 nextNode 前就改写 current.next,原链表后半段就失去入口,无法继续遍历。
六、Python 实现:删除与反转
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from __future__ import annotations
class ListNode:
def __init__(self, value: int, next_node: "ListNode | None" = None):
self.value = value
self.next = next_node
def remove_value(head: ListNode | None, target: int) -> ListNode | None:
dummy = ListNode(0, head)
current = dummy
while current.next is not None:
if current.next.value == target:
current.next = current.next.next
else:
current = current.next
return dummy.next
def reverse(head: ListNode | None) -> ListNode | None:
previous = None
current = head
while current is not None:
next_node = current.next
current.next = previous
previous = current
current = next_node
return previous
def from_values(values: list[int]) -> ListNode | None:
dummy = ListNode(0)
tail = dummy
for value in values:
tail.next = ListNode(value)
tail = tail.next
return dummy.next
def to_values(head: ListNode | None) -> list[int]:
values = []
current = head
while current is not None:
values.append(current.value)
current = current.next
return values
head = from_values([4, 2, 4, 3, 4])
head = remove_value(head, 4)
assert to_values(head) == [2, 3]
assert to_values(reverse(head)) == [3, 2]
assert to_values(remove_value(from_values([4, 4]), 4)) == []
assert to_values(reverse(from_values([]))) == []
assert to_values(reverse(from_values([7]))) == [7]
七、复杂度与正确性
删除指定值时,每个节点最多检查一次:
- 时间复杂度:
O(n); - 额外空间复杂度:
O(1)。
反转链表也只遍历一次,并原地改写每个节点的 next:
- 时间复杂度:
O(n); - 额外空间复杂度:
O(1)。
反转过程中可以维持一个不变量:每轮循环开始时,previous 指向的链表已经是原链表已处理前缀的反转结果,current 指向尚未处理部分的首节点。循环结束时 current 为空,previous 就是新链表的头节点。
八、常见错误
- 删除头节点时忘记更新 head:使用虚拟头节点可以统一删除逻辑。
- 删除后仍推进 current:连续重复值可能被跳过;删除后应留在前驱位置继续检查。
- 反转前没有保存 next:改写引用后可能丢失未处理部分。
- 返回旧头节点:反转后旧头节点变成尾节点,新头节点是
previous。 - 把节点值交换当作链表反转:题目通常要求调整节点连接;若节点还有身份或外部引用,交换值并不等价。
- 认为任意链表插入都为 O(1):只有已知插入位置的前驱节点时,重连引用才是常数时间。
- 遍历时意外形成环:修改
next前应先确认原后继已保存,调试时可用访问计数防止无限循环。
九、数组、链表与队列的联系
队列可以用链表实现:队尾插入、队头删除,只要同时保存头尾引用,通常都能在 O(1) 完成。数组实现则需要循环数组来避免出队时整体搬移。
但是链表并不会自动优于数组:
- 需要频繁按下标访问时,数组更合适;
- 需要在已知节点附近频繁插入/删除时,链表更方便;
- 链表每个节点有引用开销,且遍历时缓存局部性通常不如连续数组。
选择结构时应根据操作模式,而不是只看单个操作的复杂度。
十、小结
单向链表的核心是节点引用,而链表题的核心常常是安全地重连引用。删除节点时要想清楚前驱如何接到后继;反转时要先保存后续节点再改写方向。虚拟头节点能简化头部删除,三个引用则足以原地反转整条链表。
下一篇将学习双指针:两个索引或节点如何按特定规则协同移动,从而减少重复遍历。