一、引言:什么是链表?
链表(Linked List)是一种常见的基础数据结构,用于表示一系列按顺序排列的元素。与数组不同,链表中的元素在内存中不是连续存储的,而是通过每个元素(节点)中存储的指向下一个元素的引用(指针或链接)来连接在一起。这种结构使得链表在插入和删除操作上具有更高的灵活性,尤其是在数据量动态变化或不需要随机访问的场景中。
链表是许多高级数据结构和算法(如栈、队列、图、哈希表冲突解决等)的基础,深入理解链表对于掌握计算机科学核心概念至关重要。
二、链表的基本结构与类型
2.1 节点(Node)结构
链表的基本组成单元是节点(Node)。一个典型的单向链表节点包含两个部分:
- 数据域(Data):存储该节点的实际值或对象。
- 指针域(Next):存储对下一个节点的引用(在单向链表中)或对前一个及下一个节点的引用(在双向链表中)。
在Java中,一个简单的节点类可以这样定义:
java
// 单向链表节点定义
class ListNode {
int val; // 数据域
ListNode next; // 指向下一个节点的指针
ListNode(int x) { // 构造函数
val = x;
next = null;
}
}
2.2 链表的常见类型
- 单向链表(Singly Linked List):每个节点只包含一个指向后继节点的指针。只能从头到尾单向遍历。
- 双向链表(Doubly Linked List):每个节点包含两个指针,分别指向前驱节点和后继节点。可以从头到尾或从尾到头双向遍历,但需要更多的存储空间。
- 循环链表(Circular Linked List):尾节点的指针指向头节点(在单向循环链表中)或头节点的前驱指向尾节点(在双向循环链表中),形成一个环。适用于需要循环访问的场景,如轮询调度。
- 带头节点的链表(Linked List with Dummy Head):在链表头部增加一个不存储实际数据的"哨兵"节点(头节点),可以简化插入和删除操作,尤其是对链表第一个节点的操作。
三、链表的核心操作与时间复杂度
3.1 访问(Access)
链表不支持像数组那样的随机访问(通过索引直接访问)。要访问第 i 个元素,必须从头节点开始,沿着 next 指针逐个遍历,直到第 i 个节点。因此,访问操作的平均时间复杂度为 O(n)。
java
// 访问链表中第 index 个节点(index从0开始)
public ListNode getNode(ListNode head, int index) {
ListNode current = head;
int count = 0;
while (current != null) {
if (count == index) {
return current; // 找到目标节点
}
count++;
current = current.next;
}
return null; // 索引超出链表长度
}
3.2 查找(Search)
查找一个特定值的节点同样需要从头遍历,直到找到匹配的节点或到达链表末尾。平均时间复杂度为 O(n)。
3.3 插入(Insertion)
链表的优势在于插入操作的高效性,尤其是在已知插入位置的前驱节点时。
- 在头部插入:创建一个新节点,将其 next 指向原头节点,然后更新头指针指向新节点。时间复杂度 O(1)。
- 在尾部插入:需要遍历到尾部节点,然后将其 next 指向新节点。时间复杂度 O(n)。如果维护一个尾指针,则可以在 O(1) 时间内完成。
- 在中间插入:在给定节点之后插入,只需改变相关节点的指针指向,时间复杂度 O(1)。但找到这个"给定节点"可能需要 O(n) 时间。
java
// 在链表头部插入一个新节点
public ListNode insertAtHead(ListNode head, int val) {
ListNode newNode = new ListNode(val);
newNode.next = head;
return newNode; // 新节点成为新的头节点
}
// 在给定节点之后插入一个新节点
public void insertAfter(ListNode prevNode, int val) {
if (prevNode == null) {
return;
}
ListNode newNode = new ListNode(val);
newNode.next = prevNode.next;
prevNode.next = newNode;
}
3.4 删除(Deletion)
删除操作同样高效,前提是知道待删除节点的前驱节点。
- 删除头节点:将头指针指向原头节点的下一个节点。时间复杂度 O(1)。
- 删除中间或尾部节点:需要修改其前驱节点的 next 指针,使其跳过待删除节点。时间复杂度 O(1)(已知前驱节点),但查找前驱节点可能需要 O(n)。
java
// 删除链表中的第一个值为 val 的节点
public ListNode deleteNode(ListNode head, int val) {
// 处理头节点即为目标节点的情况
while (head != null && head.val == val) {
head = head.next;
}
if (head == null) return null;
ListNode current = head;
while (current.next != null) {
if (current.next.val == val) {
current.next = current.next.next; // 跳过待删除节点
} else {
current = current.next;
}
}
return head;
}
四、链表与数组的对比
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存分配 | 连续内存块,大小固定(静态数组)或可动态调整(动态数组,但涉及复制) | 非连续内存,节点动态分配,无需预先知道总大小 |
| 访问元素 | O(1) 随机访问(通过索引) | O(n) 顺序访问 |
| 在头部插入/删除 | O(n),需要移动后续所有元素 | O(1) |
| 在尾部插入/删除 | O(1)(如果知道尾部位置且容量足够) / O(n)(如果涉及扩容) | O(n)(需遍历到尾)/ O(1)(如果维护尾指针) |
| 在中间插入/删除 | O(n),需要移动部分元素 | O(1)(已知前驱节点),但查找前驱节点需 O(n) |
| 内存开销 | 较小,仅存储数据 | 较大,每个节点需额外存储指针 |
| 缓存友好性 | 高,数据连续存储,利于CPU缓存预取 | 低,数据分散在内存各处 |
选择建议:需要频繁随机访问时用数组;需要频繁在任意位置插入/删除、数据量动态变化且访问模式主要是顺序访问时,链表更有优势。
五、链表的经典应用场景
5.1 实现栈(Stack)和队列(Queue)
链表可以高效地实现栈(后进先出)和队列(先进先出)这两种抽象数据类型。
- 栈:使用单向链表,在头部进行 push(插入)和 pop(删除)操作,时间复杂度均为 O(1)。
- 队列:使用单向链表并维护头尾两个指针,在尾部入队(enqueue),在头部出队(dequeue),时间复杂度均为 O(1)。
5.2 哈希表冲突解决(链地址法)
在哈希表中,当多个键映射到同一个桶(bucket)时,会发生哈希冲突。链地址法(Separate Chaining)使用链表来存储同一个桶中的所有元素,是一种简单有效的冲突解决策略。
5.3 图的邻接表表示
在表示图(Graph)时,邻接表是一种常用方法。对于图中的每个顶点,使用一个链表来存储所有与其相邻的顶点。这种方式在表示稀疏图时非常节省空间。
5.4 大整数运算
计算机的基本数据类型(如 int, long)有位数限制。链表可以用来表示任意长度的大整数,每个节点存储数字的一位(或几位),然后通过模拟手工计算的方式实现大整数的加、减、乘、除。
六、常见算法题与解题技巧
6.1 反转链表
反转链表是面试中最经典的链表问题之一。迭代法和递归法是两种主要解法。
java
// 迭代法反转链表
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode nextTemp = curr.next; // 暂存下一个节点
curr.next = prev; // 反转指针
prev = curr; // prev 前移
curr = nextTemp; // curr 前移
}
return prev; // prev 最终成为新的头节点
}
// 递归法反转链表
public ListNode reverseListRecursive(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = reverseListRecursive(head.next);
head.next.next = head; // 反转指向
head.next = null; // 断开原指向
return newHead;
}
6.2 检测链表中是否有环(Floyd判圈算法)
使用快慢指针(龟兔赛跑算法),快指针每次走两步,慢指针每次走一步。如果链表中有环,快慢指针最终会相遇;如果快指针到达链表末尾(null),则说明链表无环。
java
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head.next;
while (slow != fast) {
if (fast == null || fast.next == null) {
return false; // 快指针到达末尾,无环
}
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
}
return true; // 相遇,有环
}
6.3 合并两个有序链表
创建一个哨兵节点(dummy head)可以简化边界条件处理。
java
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0); // 哨兵节点
ListNode current = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
current.next = l1;
l1 = l1.next;
} else {
current.next = l2;
l2 = l2.next;
}
current = current.next;
}
// 连接剩余部分
current.next = (l1 != null) ? l1 : l2;
return dummy.next; // 返回合并后链表的真实头节点
}
6.4 删除链表的倒数第 N 个节点
使用双指针技巧,让一个指针先走 N 步,然后两个指针同时前进。当先走的指针到达末尾时,后走的指针正好指向倒数第 N 个节点的前驱。
java
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode first = dummy;
ListNode second = dummy;
// 先让 first 指针先走 n+1 步
for (int i = 0; i <= n; i++) {
first = first.next;
}
// 然后两个指针同时前进,直到 first 到达末尾
while (first != null) {
first = first.next;
second = second.next;
}
// second 现在指向倒数第 n+1 个节点,即待删除节点的前驱
second.next = second.next.next;
return dummy.next;
}
七、总结
链表作为一种基础且重要的数据结构,其核心价值在于动态内存管理和高效的插入/删除操作。尽管在随机访问上不如数组高效,但它在实现栈、队列、图、哈希表以及解决许多特定算法问题(如反转、检测环、合并、删除倒数节点等)时不可或缺。掌握链表的原理、各种变体(单向、双向、循环)以及相关的算法技巧,是每一位程序员和计算机科学学习者的基本功。在实际开发中,应根据具体需求(访问模式、数据变动频率、内存限制等)在数组和链表之间做出合理选择。