提到链表,很多初学者都会被 "指针操作""边界条件" 搞得头疼:为什么删除头节点要单独处理?双指针怎么找倒数第 k 个节点?其实链表没那么难,它的核心是 "通过指针串联节点",掌握了指针的修改逻辑,再复杂的题也能拆解成基础操作。
什么是链表?从 "节点" 到 "串联" 的本质
链表是一种动态数据结构,由一个个 "节点" 通过指针串联而成,不需要连续的内存空间。就像一串珠子,每个珠子(节点)都有一个孔(指针),指向后面的珠子。
(1)链表的基本构成:节点(Node)
每个节点包含两部分:
val
:存储的数据(数字、字符串等);next
:指向后一个节点的指针(引用),最后一个节点的next
为null
(表示没有下一个节点)。
在 JS 中,我们用对象模拟节点:
javascript
// 节点构造函数
function ListNode(val, next) {
this.val = val === undefined ? 0 : val; // 节点值
this.next = next === undefined ? null : next; // 指向下一个节点的指针
}
比如,创建一个简单的链表(1→2→3):
javascript
// 创建节点
const node1 = new ListNode(1);
const node2 = new ListNode(2);
const node3 = new ListNode(3);
// 串联节点
node1.next = node2;
node2.next = node3;
// 此时链表为:node1 → node2 → node3 → null
(2)链表 vs 数组:为什么需要链表?
很多人会问:数组也能存数据,为什么要用链表?这要从两者的内存和操作特性说起:
特性 | 数组 | 链表 |
---|---|---|
内存 | 连续空间,需预先分配 | 非连续,动态分配 |
访问效率 | 索引直接访问(O (1)) | 从头遍历(O (n)) |
插入 / 删除效率 | 需移动元素(O (n)) | 改指针即可(O (1),找到节点后) |
灵活性 | 固定长度,扩容成本高 | 长度动态变化,无需扩容 |
简单说:数组适合 "频繁访问、少修改" 的场景;链表适合 "频繁插入删除、长度不确定" 的场景。比如实现队列、哈希表的链地址法,都离不开链表。
链表的核心操作:从 "增删查" 到模板化
链表的所有操作,本质都是 "指针的修改"。掌握以下基础操作的模板,遇到复杂题也能拆解成这些步骤。
遍历链表:从头走到尾的基本逻辑
遍历是所有操作的基础,核心是 "用一个指针从头节点开始,不断往后移,直到指向 null"。
javascript
// 遍历链表,打印所有节点值
function traverse(head) {
let cur = head; // 用cur指针遍历
while (cur !== null) { // 只要cur不是null,就继续
console.log(cur.val); // 访问当前节点值
cur = cur.next; // 指针后移
}
}
关键 :遍历过程中,cur
从head
开始,每次移动都要判断cur !== null
,避免访问null.next
报错。
插入节点:头插、尾插、中间插的统一逻辑
插入节点的核心是 "先连后断":先让新节点的指针指向正确位置,再修改原有指针,避免节点丢失。
① 头插法(在链表头部插入)
javascript
// 在head前插入新节点,返回新头节点
function insertAtHead(head, val) {
const newNode = new ListNode(val); // 创建新节点
newNode.next = head; // 新节点指向原头节点
return newNode; // 新节点成为新头
}
② 尾插法(在链表尾部插入)
javascript
// 在链表尾部插入新节点
function insertAtTail(head, val) {
const newNode = new ListNode(val);
if (head === null) { // 若链表为空,新节点就是头节点
return newNode;
}
let cur = head;
while (cur.next !== null) { // 找到尾节点(cur.next为null)
cur = cur.next;
}
cur.next = newNode; // 尾节点指向新节点
return head;
}
③ 中间插入(在指定节点后插入)
javascript
// 在index(0开始)位置前插入新节点
function insertAtIndex(head, index, val) {
// 用虚拟头节点统一逻辑(无需单独处理头节点)
const dummyHead = new ListNode(0);
dummyHead.next = head;
let cur = dummyHead;
// 找到index的前一个节点
for (let i = 0; i < index; i++) {
if (cur === null) return head; // 索引无效,直接返回
cur = cur.next;
}
const newNode = new ListNode(val);
newNode.next = cur.next; // 新节点指向cur的下一个
cur.next = newNode; // cur指向新节点
return dummyHead.next; // 虚拟头节点的next是新头
}
技巧 :插入操作中,用虚拟头节点(dummyHead) 可以避免 "插入头节点" 的特殊处理,让所有位置的插入逻辑统一。
删除节点:避免 "断链" 的关键
删除节点的核心是 "找到目标节点的前一个节点,修改它的指针,跳过目标节点"。
javascript
// 删除值为val的所有节点
function removeElements(head, val) {
const dummyHead = new ListNode(0);
dummyHead.next = head;
let cur = dummyHead;
while (cur.next !== null) { // 遍历判断下一个节点是否要删
if (cur.next.val === val) {
cur.next = cur.next.next; // 跳过目标节点(删除)
} else {
cur = cur.next; // 不删则后移
}
}
return dummyHead.next;
}
关键 :删除时,cur
始终指向 "目标节点的前一个节点",通过cur.next = cur.next.next
跳过目标节点,避免直接操作目标节点导致的断链。
面试高频:双指针技巧,让遍历效率翻倍
很多链表题用暴力解法(两次遍历)能做,但面试官更想看 "一次遍历" 的优化方案,双指针就是关键。
(1)找链表的中间节点:快慢指针同步走
快指针每次走 2 步,慢指针每次走 1 步,当快指针到尾时,慢指针刚好在中间。
javascript
function findMiddle(head) {
let fast = head;
let slow = head;
while (fast !== null && fast.next !== null) {
fast = fast.next.next; // 快指针走2步
slow = slow.next; // 慢指针走1步
}
return slow; // 慢指针就是中间节点
}
场景:判断回文链表、链表分半等场景常用。
(2)删除倒数第 n 个节点:让快指针先走 n 步
暴力解法需要先算长度,双指针只需一次遍历。看原题:

解题思路:双指针(快慢指针)
暴力解法需要先遍历链表获取长度(O (n)),再遍历到目标节点的前一个(O (n)),总时间 O (2n)。双指针可优化为 O (n):
- 快指针
fast
先出发,比慢指针slow
多走 n 步; - 两指针同时后移,当
fast
到达尾节点时,slow
刚好指向 "倒数第 n+1 个节点"; - 通过
slow.next = slow.next.next
删除目标节点。
javascript
var removeNthFromEnd = function(head, n) {
// 步骤1:创建虚拟头节点(处理头节点被删除的情况)
const dummyHead = new ListNode(0);
dummyHead.next = head;
// 步骤2:初始化快慢指针,都指向虚拟头节点
let fast = dummyHead;
let slow = dummyHead;
// 步骤3:快指针先走n步
for (let i = 0; i < n; i++) {
// 若n超过链表长度,直接返回原头节点(无效输入)
if (fast.next === null) return head;
fast = fast.next;
}
// 步骤4:快慢指针同时后移,直到快指针到尾节点
while (fast.next !== null) {
fast = fast.next;
slow = slow.next;
}
// 步骤5:删除slow的下一个节点(倒数第n个)
slow.next = slow.next.next;
return dummyHead.next;
};
双指针的优势 :
只需一次遍历即可定位目标节点,时间复杂度 O (n),比暴力解法更高效,是链表中 "倒数第 k 个节点" 类问题的标准解法。
避坑指南:这些边界条件必须考虑
链表题容易出错,很多时候是没处理好边界情况,记住这几种场景:
- 空链表(head === null) :插入、删除前先判断,避免操作 null 指针;
- 单节点链表(head.next === null) :删除后会变成空链表,返回 null;
- 头节点操作:优先用虚拟头节点统一逻辑,减少特殊处理;
- 指针后移的条件 :遍历中
cur = cur.next
前,必须确保cur !== null
。