07、数据结构与算法 - 基础:链表
一、为什么需要链表
在计算机内存中,数据的组织方式直接决定了操作效率。数组(Array)是我们最早接触的线性结构,它凭借连续内存分配实现了 O(1) 的随机访问。但数组也有先天不足:创建时必须预先声明容量,扩容需要整体拷贝;中间插入或删除元素时,需要移动大量数据。这些场景下,数组的 O(N) 时间复杂度就显得捉襟见肘。
链表(Linked List)采用截然不同的设计思路:每个数据单元(节点)在内存中离散分布,通过指针(引用)串联形成逻辑上的线性序列。这种"以空间换灵活"的策略,使得插入和删除操作可以在 O(1) 时间内完成,代价是丧失了随机访问能力。
下表直观对比了两种结构的核心差异:
| 操作 | 数组 | 链表 |
|---|---|---|
| 随机访问 | O(1) | O(N) |
| 头部插入 | O(N) | O(1) |
| 尾部插入(有尾指针) | O(1) | O(1) |
| 中间插入 | O(N) | O(N)(查找耗时) |
| 删除 | O(N) | O(1)(已知位置) |
| 内存占用 | 纯数据 | 数据 + 指针开销 |
| 缓存友好 | 高 | 低 |
二、单向链表的完整设计与实现
单向链表(Singly Linked List)是最基础的链表形态,每个节点持有数据域和指向后继节点的引用。下面给出一个支持泛型、包含完整增删改查功能的工业级实现。
2.1 节点定义与链表骨架
java
/**
* 单向链表 - 泛型实现
* 支持头部插入、尾部插入、指定位置插入、指定位置删除、查找、反转、判空等操作
*/
public class SinglyLinkedList<T> {
/* ========== 节点内部类 ========== */
private static class Node<T> {
T data; // 节点存储的数据
Node<T> next; // 指向后继节点的引用
Node(T data) {
this.data = data;
this.next = null;
}
}
private Node<T> head; // 链表头指针
private int size; // 链表元素个数
public SinglyLinkedList() {
this.head = null;
this.size = 0;
}
/* ==================== 基本操作 ==================== */
/** 获取链表长度 */
public int size() {
return size;
}
/** 判断链表是否为空 */
public boolean isEmpty() {
return size == 0;
}
/** 头插法:将新节点插入链表头部 */
public void addFirst(T data) {
Node<T> newNode = new Node<>(data);
newNode.next = head;
head = newNode;
size++;
}
/** 尾插法:将新节点插入链表尾部 */
public void addLast(T data) {
Node<T> newNode = new Node<>(data);
if (head == null) {
head = newNode;
} else {
Node<T> cur = head;
while (cur.next != null) {
cur = cur.next;
}
cur.next = newNode;
}
size++;
}
/** 在指定索引处插入元素(索引从 0 开始) */
public void add(int index, T data) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("索引越界: " + index);
}
if (index == 0) {
addFirst(data);
return;
}
Node<T> newNode = new Node<>(data);
Node<T> prev = head;
for (int i = 0; i < index - 1; i++) {
prev = prev.next;
}
newNode.next = prev.next;
prev.next = newNode;
size++;
}
/** 删除头部节点并返回其数据 */
public T removeFirst() {
if (head == null) {
throw new RuntimeException("链表为空,无法删除");
}
T removed = head.data;
head = head.next;
size--;
return removed;
}
/** 删除指定索引处的节点 */
public T remove(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("索引越界: " + index);
}
if (index == 0) {
return removeFirst();
}
Node<T> prev = head;
for (int i = 0; i < index - 1; i++) {
prev = prev.next;
}
T removed = prev.next.data;
prev.next = prev.next.next;
size--;
return removed;
}
/** 按值查找,返回首次出现的索引,未找到返回 -1 */
public int indexOf(T data) {
Node<T> cur = head;
int idx = 0;
while (cur != null) {
if (cur.data == null ? data == null : cur.data.equals(data)) {
return idx;
}
cur = cur.next;
idx++;
}
return -1;
}
/** 获取指定索引的元素 */
public T get(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("索引越界: " + index);
}
Node<T> cur = head;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
return cur.data;
}
/** 打印链表内容 */
@Override
public String toString() {
if (head == null) return "[]";
StringBuilder sb = new StringBuilder("[");
Node<T> cur = head;
while (cur != null) {
sb.append(cur.data);
if (cur.next != null) sb.append(" -> ");
cur = cur.next;
}
sb.append("]");
return sb.toString();
}
// ========== main 方法用于验证 ==========
public static void main(String[] args) {
SinglyLinkedList<Integer> list = new SinglyLinkedList<>();
// 测试尾部插入
list.addLast(10);
list.addLast(20);
list.addLast(30);
System.out.println("尾插后: " + list); // [10 -> 20 -> 30]
// 测试头部插入
list.addFirst(5);
System.out.println("头插后: " + list); // [5 -> 10 -> 20 -> 30]
// 测试指定位置插入
list.add(2, 15);
System.out.println("索引2插入15后: " + list); // [5 -> 10 -> 15 -> 20 -> 30]
// 测试删除
list.removeFirst();
System.out.println("删头后: " + list); // [10 -> 15 -> 20 -> 30]
list.remove(1);
System.out.println("删索引1后: " + list); // [10 -> 20 -> 30]
// 测试查找
System.out.println("20的索引: " + list.indexOf(20)); // 1
System.out.println("100的索引: " + list.indexOf(100)); // -1
// 测试获取
System.out.println("索引0元素: " + list.get(0)); // 10
System.out.println("链表大小: " + list.size()); // 3
}
}
2.2 时间复杂度分析
addFirst:O(1),直接修改头指针。addLast:O(N),需要遍历到尾部。若维护尾指针可优化至 O(1)。add(index):O(N),查找目标位置的前驱。removeFirst:O(1)。remove(index):O(N)。indexOf/get:O(N),无法随机访问。
三、链表反转算法
链表反转是面试中的高频考点,存在迭代法和递归法两种经典解法。
3.1 迭代法反转
迭代法的核心思想是"三指针原地翻转":维护 prev(已翻转部分的头)、cur(当前待处理节点)、next(暂存下一个节点),逐步将每个节点的 next 指针反向。
java
/**
* 单向链表反转 - 迭代实现
* 时间复杂度 O(N),空间复杂度 O(1)
*/
public void reverseIterative() {
Node<T> prev = null;
Node<T> cur = head;
while (cur != null) {
Node<T> next = cur.next; // 暂存后继
cur.next = prev; // 翻转指针
prev = cur; // prev 前进
cur = next; // cur 前进
}
head = prev; // 新头节点
}
执行过程模拟(链表 1→2→3→null):
| 轮次 | prev | cur | next | 操作后链表状态 |
|---|---|---|---|---|
| 初始 | null | 1 | - | 1→2→3→null |
| 第1轮 | 1 | 2 | 2 | null←1 2→3→null |
| 第2轮 | 2 | 3 | 3 | null←1←2 3→null |
| 第3轮 | 3 | null | null | null←1←2←3 |
3.2 递归法反转
递归法的本质是利用函数调用栈保存上下文,先递归到最后一个节点,然后在回溯过程中逐层翻转指针。
java
/**
* 单向链表反转 - 递归实现
* 时间复杂度 O(N),空间复杂度 O(N)(递归栈深度)
*/
public void reverseRecursive() {
head = reverseRecursiveHelper(head);
}
private Node<T> reverseRecursiveHelper(Node<T> node) {
if (node == null || node.next == null) {
return node; // 到达尾节点,尾节点将成为新头
}
Node<T> newHead = reverseRecursiveHelper(node.next);
node.next.next = node; // 后继节点指回当前节点
node.next = null; // 断开当前节点的原指向
return newHead; // 始终返回新头节点
}
四、双向链表
单向链表只能正序遍历,无法回退。双向链表在每个节点中额外存储前驱指针,使得双方向遍历成为可能,同时使得尾部删除从 O(N) 降至 O(1)。
java
/**
* 双向链表 - 泛型实现
* 支持头尾双向操作、双向遍历
*/
public class DoublyLinkedList<T> {
private static class Node<T> {
T data;
Node<T> prev; // 前驱指针
Node<T> next; // 后继指针
Node(T data) {
this.data = data;
}
}
private Node<T> head; // 头哨兵(简化边界处理)
private Node<T> tail; // 尾哨兵
private int size;
public DoublyLinkedList() {
head = new Node<>(null); // 头哨兵不存数据
tail = new Node<>(null); // 尾哨兵不存数据
head.next = tail;
tail.prev = head;
size = 0;
}
public int size() { return size; }
public boolean isEmpty() { return size == 0; }
/** 头部插入 */
public void addFirst(T data) {
Node<T> newNode = new Node<>(data);
Node<T> first = head.next;
newNode.prev = head;
newNode.next = first;
head.next = newNode;
first.prev = newNode;
size++;
}
/** 尾部插入 */
public void addLast(T data) {
Node<T> newNode = new Node<>(data);
Node<T> last = tail.prev;
newNode.prev = last;
newNode.next = tail;
last.next = newNode;
tail.prev = newNode;
size++;
}
/** 头部删除 */
public T removeFirst() {
if (isEmpty()) throw new RuntimeException("链表为空");
Node<T> first = head.next;
head.next = first.next;
first.next.prev = head;
size--;
return first.data;
}
/** 尾部删除 */
public T removeLast() {
if (isEmpty()) throw new RuntimeException("链表为空");
Node<T> last = tail.prev;
tail.prev = last.prev;
last.prev.next = tail;
size--;
return last.data;
}
/** 正向遍历打印 */
public String forwardString() {
StringBuilder sb = new StringBuilder("[");
Node<T> cur = head.next;
while (cur != tail) {
sb.append(cur.data);
if (cur.next != tail) sb.append(" <-> ");
cur = cur.next;
}
sb.append("]");
return sb.toString();
}
/** 反向遍历打印 */
public String backwardString() {
StringBuilder sb = new StringBuilder("[");
Node<T> cur = tail.prev;
while (cur != head) {
sb.append(cur.data);
if (cur.prev != head) sb.append(" <-> ");
cur = cur.prev;
}
sb.append("]");
return sb.toString();
}
public static void main(String[] args) {
DoublyLinkedList<String> dll = new DoublyLinkedList<>();
dll.addLast("A");
dll.addLast("B");
dll.addLast("C");
dll.addFirst("0");
System.out.println("正向: " + dll.forwardString()); // [0 <-> A <-> B <-> C]
System.out.println("反向: " + dll.backwardString()); // [C <-> B <-> A <-> 0]
dll.removeFirst();
dll.removeLast();
System.out.println("删头删尾后: " + dll.forwardString()); // [A <-> B]
System.out.println("大小: " + dll.size()); // 2
}
}
五、合并两个有序链表
这是 LeetCode 经典题目(#21),要求将两个升序链表合并为一个新的升序链表。核心思路是双指针比较,逐个连接较小的节点。
java
/**
* 合并两个有序单向链表(升序)
* 时间复杂度 O(M+N),空间复杂度 O(1)
*/
public static SinglyLinkedList<Integer> mergeSorted(
SinglyLinkedList<Integer> list1,
SinglyLinkedList<Integer> list2) {
// 使用内部节点直接操作,这里简化:先将两个链表数据取出再构建
// 实际工程中应操作 Node 引用
return null; // 此处仅说明算法思路
}
// ---- 更工业级的独立工具类 ----
public class MergeSortedLists {
static class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
}
/**
* 迭代法合并两个有序链表
* 使用虚拟头节点简化边界处理
*/
public static ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1); // 虚拟头节点
ListNode tail = dummy;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
tail.next = l1;
l1 = l1.next;
} else {
tail.next = l2;
l2 = l2.next;
}
tail = tail.next;
}
// 连接剩余部分
tail.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
/** 递归法合并(更简洁但可能有栈溢出风险) */
public static ListNode mergeTwoListsRecursive(ListNode l1, ListNode l2) {
if (l1 == null) return l2;
if (l2 == null) return l1;
if (l1.val <= l2.val) {
l1.next = mergeTwoListsRecursive(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoListsRecursive(l1, l2.next);
return l2;
}
}
/** 辅助方法:从数组构建链表 */
public static ListNode buildList(int[] arr) {
ListNode dummy = new ListNode(-1);
ListNode tail = dummy;
for (int v : arr) {
tail.next = new ListNode(v);
tail = tail.next;
}
return dummy.next;
}
/** 辅助方法:打印链表 */
public static void printList(ListNode head) {
StringBuilder sb = new StringBuilder("[");
while (head != null) {
sb.append(head.val);
if (head.next != null) sb.append(" -> ");
head = head.next;
}
sb.append("]");
System.out.println(sb);
}
public static void main(String[] args) {
ListNode l1 = buildList(new int[]{1, 3, 5, 7});
ListNode l2 = buildList(new int[]{2, 4, 6, 8});
System.out.print("链表1: "); printList(l1);
System.out.print("链表2: "); printList(l2);
// 迭代法合并
ListNode merged = mergeTwoLists(buildList(new int[]{1,3,5,7}),
buildList(new int[]{2,4,6,8}));
System.out.print("迭代合并: "); printList(merged);
// 递归法合并
ListNode merged2 = mergeTwoListsRecursive(buildList(new int[]{1,3,5,7}),
buildList(new int[]{2,4,6,8}));
System.out.print("递归合并: "); printList(merged2);
// 边界测试:空链表
ListNode emptyMerged = mergeTwoLists(null, new ListNode(42));
System.out.print("合并空链表: "); printList(emptyMerged);
}
}
六、环形链表检测(Floyd 判圈算法)
环形链表检测是另一道高频考题(LeetCode #141、#142)。Floyd 算法(龟兔赛跑算法)使用快慢两个指针,快指针每次走两步,慢指针每次走一步。若无环,快指针先到达 null;若有环,两指针最终会在环内相遇。
java
/**
* 环形链表检测与环入口查找
*/
public class CycleDetection {
static class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
}
/**
* 判断链表是否有环
* 快慢指针法,时间复杂度 O(N),空间复杂度 O(1)
*/
public static boolean hasCycle(ListNode head) {
if (head == null || head.next == null) return false;
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针每次走 1 步
fast = fast.next.next; // 快指针每次走 2 步
if (slow == fast) {
return true; // 相遇说明有环
}
}
return false; // 快指针到达终点,无环
}
/**
* 找出环的入口节点(LeetCode #142)
* 原理:相遇点到环入口的距离 = 头节点到环入口的距离
*/
public static ListNode detectCycle(ListNode head) {
if (head == null || head.next == null) return null;
ListNode slow = head;
ListNode fast = head;
// 第一步:确认有环并找到相遇点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
// 第二步:从头节点和相遇点同时出发,再次相遇处即为环入口
ListNode ptr = head;
while (ptr != slow) {
ptr = ptr.next;
slow = slow.next;
}
return ptr;
}
}
return null;
}
public static void main(String[] args) {
// 构建带环链表: 1 -> 2 -> 3 -> 4 -> 5
// ^ |
// |_________|
ListNode n1 = new ListNode(1);
ListNode n2 = new ListNode(2);
ListNode n3 = new ListNode(3);
ListNode n4 = new ListNode(4);
ListNode n5 = new ListNode(5);
n1.next = n2; n2.next = n3; n3.next = n4; n4.next = n5;
n5.next = n3; // 形成环,入口在 n3
System.out.println("是否有环: " + hasCycle(n1)); // true
ListNode entry = detectCycle(n1);
System.out.println("环入口值: " + (entry != null ? entry.val : "null")); // 3
// 测试无环链表
ListNode noCycle = new ListNode(10);
noCycle.next = new ListNode(20);
noCycle.next.next = new ListNode(30);
System.out.println("无环链表检测: " + hasCycle(noCycle)); // false
System.out.println("无环链表入口: " + detectCycle(noCycle)); // null
}
}
Floyd 算法数学原理简述:
设链表头到环入口距离为 a,环入口到相遇点距离为 b,相遇点到环入口距离为 c(环周长 = b + c)。相遇时:慢指针走了 a + b 步,快指针走了 a + b + k(b + c) 步。由于快指针速度是慢指针的两倍,有 2(a+b) = a+b+k(b+c),化简得 a = (k-1)(b+c) + c,说明从头节点到环入口的距离 a,等于从相遇点沿环走 (k-1) 圈再加 c 的距离。当 k=1 时,a = c,即从头节点和相遇点同时出发,刚好在环入口相遇。
七、单向循环链表
循环链表的尾节点不指向 null,而是指向头节点,形成闭环。适合处理环形调度问题(如约瑟夫环)。
| 链表类型 | 尾节点指向 | 典型场景 |
|---|---|---|
| 普通单链表 | null | 栈、队列基础结构 |
| 带头尾指针的双向链表 | null(哨兵模式) | Java LinkedList |
| 单向循环链表 | 头节点 | 约瑟夫问题、轮询调度 |
| 双向循环链表 | 头节点 | LRU 缓存、音乐播放器 |
八、常见面试题与解题策略
| 问题 | 核心思路 | 难度 |
|---|---|---|
| 反转链表 | 三指针迭代 / 递归 | ★★☆ |
| 合并有序链表 | 双指针逐个比较 + 虚拟头 | ★★☆ |
| 环形链表检测 | Floyd 快慢指针 | ★★☆ |
| 查找倒数第 K 个节点 | 双指针间隔 K 步同步移动 | ★★☆ |
| 求两个链表的交点 | 消除长度差后同步遍历 | ★★★ |
| 回文链表判断 | 快慢指针找中点 + 反转后半段 | ★★★ |
| K 个一组反转链表 | 分段处理 + 递归衔接 | ★★★★ |
九、总结
链表通过"指针串联"的方式换取了插入删除的 O(1) 效率,代价是无法随机访问。在实际工程中,Java 的 LinkedList 底层即基于双向链表实现;Linux 内核大量使用双向循环链表管理进程、内存等资源;Redis 的列表(List)底层采用双向链表;LRU 缓存淘汰算法也以双向链表 + 哈希表作为核心结构。理解链表的节点模型、指针操作和边界处理,是掌握更高级数据结构(如图、树)的前提。