引言:为什么链表仍是程序员的必修课?
在数组、哈希表、树等数据结构大行其道的今天,链表(Linked List)似乎显得“古老”而“低效”。然而,翻开 LeetCode、Codeforces 或大厂面试题库,链表相关题目依然高频出现——206. 反转链表、141. 环形链表、160. 相交链表、234. 回文链表…… 这些经典问题不仅考察编码能力,更检验对指针操作、空间优化与算法思想的理解。
本文将结合你提供的完整代码实现(已通过 LeetCode 测试),系统讲解链表的核心概念与八大高频操作。我们将跳过冗余语法,直击算法本质,用清晰的逻辑和图解思维,助你彻底掌握链表这一“简单却不平凡”的数据结构。
一、链表是什么?——动态结构的优雅
1.1 定义与内存模型
链表是一种线性但非连续存储的数据结构。它由一系列节点(Node)组成,每个节点包含:
- 数据域(
val):存储实际值; - 指针域(
next):指向下一个节点的内存地址。
function ListNode(val, next) {
this.val = (val === undefined ? 0 : val);
this.next = (next === undefined ? null : next);
}
✅ 关键特性:
- 动态扩容:无需预先分配固定内存;
- 插入/删除 O(1) :只需修改相邻节点指针;
- 不支持随机访问:查找需从头遍历,O(n)。
1.2 与数组的对比
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续 | 非连续(堆中分散) |
| 访问元素 | O(1)(索引) | O(n)(遍历) |
| 插入/删除 | O(n)(需移动) | O(1)(已知位置) |
| 空间开销 | 仅数据 | 数据 + 指针(8字节/节点) |
💡 适用场景:频繁插入删除、未知数据规模、内存碎片化环境。
二、核心思想:双指针与虚拟头节点
在深入具体问题前,先掌握两大通用技巧:
2.1 双指针(Two Pointers)
- 快慢指针:
fast每次走两步,slow走一步 → 用于找中点、判环。 - 同步指针:两指针以相同速度遍历 → 用于合并、比较。
2.2 虚拟头节点(Dummy Head)
- 创建一个
dummy = new ListNode(0),dummy.next = head。 - 避免边界判断(如删除头节点),最终返回
dummy.next。
三、八大高频链表操作详解
以下按本人做题顺序展开,每题包含:问题描述 → 核心思想 → 代码解析 → 复杂度分析。
1. 反转链表(LeetCode 206)
🔍 问题
输入:1→2→3→4→5→NULL
输出:5→4→3→2→1→NULL
💡 思想:迭代反转指针方向
- 维护
prev(已反转部分)、cur(当前节点)、next(暂存下一节点)。 - 每次将
cur.next指向prev,然后整体右移。
🧠 代码解析
var reverseList = function(head) {
let prev = null; //定义一个空指针
let cur = head; //定义一个指针指向头节点位置
while (cur) {
const next = cur.next; // 保存当前cur指针指向的下一节点
cur.next = prev; // 反转指针
prev = cur; // prev 前进
cur = next; // cur 前进
}
return prev; // 新头节点
};
⏱️ 复杂度
- 时间:O(n),遍历一次
- 空间:O(1),仅用常数变量
2. 回文链表(LeetCode 234)
🔍 问题
判断链表是否为回文:1→2→2→1 → true
💡 思想:快慢指针 + 后半段反转
- 快慢指针找中点:
fast走两步,slow走一步 →slow停在前半段尾。 - 反转后半段:从
slow.next开始反转。 - 双指针比较:
p1从头,p2从反转后头,逐值对比。 - 恢复链表(可选):再次反转后半段,保持原结构。
🧠 代码解析
// 找前半段尾节点
const endOfFirstHalf = (head) => {
let fast = head, slow = head; //起初位置均为head
while (fast.next && fast.next.next) {
//while判断条件,fast.next必须放前面,不然会引发错误
fast = fast.next.next;
slow = slow.next;
}
return slow;
};
var isPalindrome = function(head) {
if (!head) return true;
// 反转后半段
const firstHalfEnd = endOfFirstHalf(head); //拿到前半段的末尾
const secondHalfStart = reverseList(firstHalfEnd.next); //通过对后半段的head进行reverse操作
// 比较
let p1 = head, p2 = secondHalfStart;
let result = true; //判断回文
while (result && p2) {
if (p1.val !== p2.val) result = false; //一旦不同就变为false并跳出循环
p1 = p1.next; //p1右移
p2 = p2.next; //p2右移
}
// 恢复链表
firstHalfEnd.next = reverseList(secondHalfStart); //恢复链表避免错误
return result;
};
⏱️ 复杂度
- 时间:O(n)
- 空间:O(1)
✅ 优势:无需额外数组,空间最优。
3. 环形链表(LeetCode 141)
🔍 问题
判断链表是否有环:1→2→3→4→5→2(5 指回 2)→ true
💡 思想:Floyd 判圈算法(龟兔赛跑)
slow每次走 1 步,fast走 2 步。- 若有环,
fast必会追上slow;若无环,fast先到null。
🧠 代码解析
var hasCycle = function(head) {
let fast = head, slow = head;
while (fast && fast.next) {
fast = fast.next.next; // 快指针
slow = slow.next; // 慢指针
if (fast === slow) return true; // 相遇即有环
}
return false;
};
⏱️ 复杂度
- 时间:O(n)
- 空间:O(1)
📌 数学证明:若环长为 L,当
slow入环时,fast已在环内某处。二者相对速度为 1,最多 L 步必相遇。
4. 相交链表(LeetCode 160)
🔍 问题
找两个链表的相交节点:
listA = [4,1,8,4,5], listB = [5,0,1,8,4,5] → 交于 8
💡 思想:双指针消除长度差
pA遍历完 A 后跳到 B 头,pB遍历完 B 后跳到 A 头。- 二者路径长度均为
lenA + lenB,若相交必在交点相遇;否则同时为null。
🧠 代码解析
var getIntersectionNode = function(headA, headB) {
let pA = headA, pB = headB;
while (pA !== pB) {
pA = pA === null ? headB : pA.next;
pB = pB === null ? headA : pB.next;
}
return pA; // 相交节点或 null
};
⏱️ 复杂度
- 时间:O(m + n)
- 空间:O(1)
✨ 巧妙之处:无需计算长度,自动对齐。
5. 排序链表(LeetCode 148)
🔍 问题
对链表排序:4→2→1→3 → 1→2→3→4
💡 思想:扔进数组排序后返回
- 将链表之间的关系全部打碎后扔进数组中
- 将数组利用sort排序
- 遍历一遍数组,还原为有序链表
🧠 代码补充
var sortList = function(head) {
if(head===null)return null; //判断链表是否为空
const arr=[]; //初始化数组
while(head)
{
arr.push(head) //将该节点扔进数组
const next=head.next; //保存下一个节点
head.next=null; //解除该节点与下一个节点间的指针
head=next; //头节点变为下一个节点
}
arr.sort((a,b)=>a.val-b.val); //sort排序
for(let i=0;i<arr.length-1;i++) // 还原链表
{
arr[i].next=arr[i+1];
}
return arr[0]; //返回头节点
};
⏱️ 复杂度
- 时间:O(n log n)
- 空间:O(n)
✅ 优势:可根据长度利用sort选择最快的排序方法。
6. 合并两个有序链表(LeetCode 21)
🔍 问题
合并:1→2→4 + 1→3→4 → 1→1→2→3→4→4
💡 思想:双指针 + 虚拟头
- 比较两链表当前节点,小者接入结果链。
- 使用
dummy简化头节点处理。
🧠 代码解析
var mergeTwoLists = function(list1, list2) {
const dummy = new ListNode(0); //哨兵节点
let cur = dummy;
while (list1 && list2) {
if (list1.val <= list2.val) {
cur.next = list1; //指向小的
list1 = list1.next; //链表1头节点变成原先的下一个节点
} else {
cur.next = list2;
list2 = list2.next;
}
cur = cur.next; //指针右移
}
// 接上剩余部分
cur.next = list1 ?? list2; // ??运算符表示取非空的
return dummy.next;
};
⏱️ 复杂度
- 时间:O(m + n)
- 空间:O(1)
7. 合并 K 个有序链表(LeetCode 23)
🔍 问题
合并:[1→4→5, 1→3→4, 2→6] → 1→1→2→3→4→4→5→6
💡 思想:分治归并
- 将 K 个链表两两合并,每轮数量减半,共 log K 轮。
- 每轮总操作数为 O(N)(N 为总节点数)。
🧠 代码解析
var mergeTwoLists=function(list1,list2){
const dummy=new ListNode();
let cur=dummy;
while(list1&&list2)
{
if(list1.val>list2.val)
{
cur.next=list2;
list2=list2.next;
}
else
{
cur.next=list1;
list1=list1.next;
}
cur=cur.next;
}
cur.next=list1??list2
return dummy.next;
}
var mergeKLists = function(lists) {
if (lists.length === 0) return null;
// 分治:step=1,2,4... 两两合并
for (let step = 1; step < lists.length; step *= 2) {
for (let i = 0; i < lists.length - step; i += step * 2) {
lists[i] = mergeTwoLists(lists[i], lists[i + step]);
//根据步长合并为一个链表,放在list[0]位置
}
}
return lists[0];
};
⏱️ 复杂度
- 时间:O(N log K)
- 空间:O(1)
🔁 替代方案:最小堆(优先队列),时间同为 O(N log K),但需 O(K) 空间。
8. K 个一组翻转链表(LeetCode 25)
🔍 问题
每 K 个节点反转:1→2→3→4→5, K=3 → 3→2→1→4→5
💡 思想:局部反转 + 链接
- 检查剩余节点 ≥ K。
- 反转子链
[head, tail]。 - 将反转后的子链接回原链。
🧠 代码解析
const myReverse = (head, tail) => {
// 反转后,原 tail 的 next 应该指向原 tail.next(即子链表之后的部分)
// 所以把 prev 初始化为 tail.next,作为反转后的“终止指针”
let prev = tail.next;
let p = head; // 当前处理的节点
// 循环条件:当 prev 指向 tail 时,说明 head 到 tail 的所有节点都已反转完毕
// (因为每次循环 prev 都会前移,最终 prev 会变成原 tail)
while (prev !== tail) {
const nex = p.next; // 保存下一个节点,防止断链
p.next = prev; // 将当前节点的 next 指向前一个节点(反转指针)
prev = p; // prev 前移:当前节点成为下一轮的“前一个节点”
p = nex; // p 前移:处理下一个节点
}
// 反转完成后:
// - 原 tail 成为新头
// - 原 head 成为新尾
return [tail, head];
};
var reverseKGroup = function(head, k) {
// 创建虚拟头节点(hair),统一处理头节点可能被改变的情况
const hair = new ListNode(0);
hair.next = head;
// pre 指向当前待处理子链表的前一个节点(即上一组的尾节点)
let pre = hair;
// 只要还有待处理的节点(head 不为空),就继续循环
while (head) {
// 初始化 tail 为 pre,准备向后走 k 步找到当前组的尾节点
let tail = pre;
// 向后移动 k 次,检查是否还有至少 k 个节点
for (let i = 0; i < k; ++i) {
tail = tail.next;
// 如果中途遇到 null,说明剩余节点不足 k 个,直接返回结果
if (!tail) {
return hair.next;
}
}
// 保存下一组的起始节点(即当前组 tail 的下一个节点)
const nex = tail.next;
// 对 [head, tail] 这一段子链表进行反转
// 反转后,head 变成新尾,tail 变成新头
[head, tail] = myReverse(head, tail);
// 将反转后的子链表重新接回主链表:
// 1. 上一组的尾节点(pre)指向新头(原 tail)
pre.next = head;
// 2. 新尾(原 head)指向下一组的起始节点(nex)
tail.next = nex;
// 更新 pre 和 head,准备处理下一组
pre = tail; // pre 移动到当前组的尾部(即新 tail)
head = tail.next; // head 指向下一组的开头
}
// 返回真实头节点(跳过虚拟头)
return hair.next;
};
⏱️ 复杂度
- 时间:O(n)
- 空间:O(1)
四、总结:链表问题的解题心法
| 问题类型 | 核心技巧 | 代表题目 |
|---|---|---|
| 单链操作 | 迭代反转、虚拟头 | 反转链表、K组反转 |
| 双链协作 | 双指针对齐、合并 | 相交链表、合并链表 |
| 结构探测 | 快慢指针 | 环检测、找中点 |
| 分治策略 | 归并、分组 | 排序、合并K链 |
🌟 终极建议:
- 画图! 链表问题务必手动画指针变化;
- 边界先行:空链表、单节点、K=1 等;
- 善用 dummy:避免头节点特殊处理;
- 流式思维:把链表看作“数据流”,指针是“游标”。
结语
链表虽“简单”,却是理解指针操作、内存管理与算法设计的绝佳载体。掌握上述八大模式,不仅能攻克面试,更能培养出对数据流动的直觉。正如《算法导论》所言:“抽象数据类型的威力,在于其操作的纯粹性。 ”