链表(Linked List) 是一种常见的线性数据结构,它通过一系列任意地址的存储单元(这些单元在内存中可以连续,也可以不连续)来存储相同类型的数据元素。
简而言之,链表是线性表的链式存储形式。与数组不同,链表中的每个元素(称为“节点”)不仅包含数据本身,还包含一个指向下一个节点的引用(即“指针”),从而通过这种链接关系维护元素之间的逻辑顺序。
在 JavaScript 中,一个简单的链表节点可以这样定义:
function ListNode(val) {
this.val = val;
this.next = null;
}
由于链表依靠指针连接各节点,逻辑上相邻的元素在物理内存中可能相距甚远,其地址分布是随机的。
链表的优缺点
优点:
动态内存分配:无需预先指定容量,可根据需要动态申请内存,避免空间浪费。
高效的插入与删除:在已知位置进行插入或删除操作时,时间复杂度通常为 O(1),优于数组的 O(n)。
缺点:
额外内存开销:每个节点除了存储数据,还需额外空间保存指针,整体内存占用比数组更高。
不支持随机访问:必须从头节点开始逐个遍历,无法像数组那样通过下标直接访问元素。
虽然在日常 JavaScript 开发中我们很少手动实现链表(因为内置的
Array已经高度优化且使用便捷),但深入理解链表的原理对于掌握底层数据结构、解决算法问题,以及理解现代前端框架的内部机制(例如 React 的 Fiber 架构就使用了链表进行任务调度)具有重要意义。
链表算法常见套路 (双指针 && 反转链表 && 虚拟头节点)
1. 双指针(快慢指针)
双指针是链表问题中最常用、最高效的技巧之一,尤其适用于单向链表(无法回溯、不支持随机访问)的场景。其核心思想是:通过两个指针以不同速度或不同起始位置遍历链表,利用它们之间的相对关系来获取所需信息。
常见模式一:快慢同步移动(速度差)
设定:快指针(fast)每次移动 2 步,慢指针(slow)每次移动 1 步。
原理:当快指针到达链表末尾时,慢指针恰好位于链表的中点(或中间偏左/右,取决于链表长度奇偶性)。
用途:
找链表中点(用于分治、归并排序等);
判断链表是否存在环(若存在环,快指针最终会追上慢指针);
在有环的情况下,结合数学推导可进一步定位入环起点。
关键洞察:快慢指针在环中相遇时,从头节点到入环点的距离,等于从相遇点沿环继续走到入环点的距离——这一性质是解决“找入环点”问题的基础。
常见模式二:固定间距移动(距离差)
设定:先让一个指针(如 fast)向前走 K 步,然后 slow 从头开始,两者以相同速度(通常为 1 步/次)同步前进。
原理:两指针始终保持 K 个节点的距离。当 fast 到达链表尾部(即 fast.next === null 或 fast === null)时,slow 恰好指向倒数第 K 个节点的前驱(或倒数第 K 个节点本身,取决于初始化方式)。
用途:
删除或访问倒数第 K 个节点;
获取链表后半部分的起始位置(如 K = 长度的一半)。
总之,快慢指针的本质是利用相对运动和距离关系,在一次遍历中隐式获取链表的结构信息,避免了多次扫描或额外空间开销,是链表算法优化的关键手段。
2. 虚拟头节点(Dummy Head)技巧详解
在链表操作中,头节点常常是边界情况的“麻烦制造者” :插入或删除头节点时,需要特殊判断,逻辑容易出错且代码冗余。为统一处理所有节点(包括头节点),引入一个不存储实际数据的辅助节点——即虚拟头节点(Dummy Head) 。
核心做法:
const dummy = new ListNode(0); // 值任意,通常设为 0 或 null
dummy.next = head; // 将虚拟节点指向原链表头
后续所有操作都从 dummy 开始,最终返回 dummy.next 即为新链表的真正头节点。
为什么有效?
- 虚拟节点自身永远不会被删除或移动,但它“代理”了对原头节点的操作。
- 任何对原链表第一个节点的修改(如删除、替换、插入前驱),现在都变成了对
dummy.next的普通操作,无需单独判断是否涉及头节点。 - 所有节点在逻辑上地位平等,算法流程高度统一。
典型应用场景:
- 删除节点(尤其是可能删除头节点的情况):
例如删除所有重复元素(LeetCode 82),若原头节点是重复值,直接删掉会导致head变化,而用 dummy 可无缝处理。 - 合并链表:
在归并两个有序链表时,结果链表的头节点不确定(可能是 list1 或 list2 的头),用 dummy 可直接追加,最后返回dummy.next。 - 插入操作:
如在链表头部频繁插入,或实现“哨兵”机制简化循环逻辑。
当然可以。以下是对“反转链表”技巧的扩展说明,聚焦于核心思想、两种实现方式的对比、关键细节与通用模式,并保留你原有的简洁技术风格:
3. 反转链表(迭代 or 递归)技巧详解
链表反转是链表操作中最基础、最核心的原语之一。它不仅本身是一类常见问题,更常作为子过程嵌入到更复杂的算法中(如回文判断、区间翻转、K 个一组翻转等)。
核心目标
将原链表 A → B → C → null 变为 C → B → A → null,即每个节点的 next 指针反向指向其前驱。
迭代法(推荐:直观、高效、无栈溢出风险)
三指针模板:prev、curr、next
function reverseList(head) {
let prev = null; // 初始时,新链表尾部为 null
let curr = head; // 从原头开始遍历
while (curr !== null) {
const next = curr.next; // 先保存下一个节点(防止断链)
curr.next = prev; // 反转当前指针
prev = curr; // prev 前进
curr = next; // curr 前进
}
return prev; // 此时 prev 指向原链表的最后一个节点,即新头
}
关键点:
- 顺序不能错:必须先保存
next,再修改curr.next。 - 终止条件:当
curr === null时,prev即为新头。 - 空间复杂度 O(1) ,时间复杂度 O(n),适用于任意长度链表。
递归法(优雅但需理解调用栈)
思想:假设 reverseList(head.next) 已经成功反转了后续部分,只需处理 head 与已反转部分的连接。
function reverseList(head) {
// 基线条件:空或单节点,直接返回
if (!head || !head.next) return head;
// 递归反转 head 之后的部分
const newHead = reverseList(head.next);
// 此时 head.next 是原链表中 head 的后继,现在它是反转后链表的尾节点
head.next.next = head; // 将其 next 指回 head,完成局部反转
head.next = null; // 断开原 head 的正向链接(避免环)
return newHead;
}
关键点:
- 每次递归返回的是整个反转后链表的新头(即原链表的最后一个节点)。
head.next.next = head是精髓:利用已知的head.next节点去反向链接head。- 注意断链:必须设
head.next = null,否则原头节点仍指向第二个节点,形成环。
总结:掌握反转链表的迭代三指针模板是必备技能;理解递归版本有助于提升对链表结构和函数调用栈的认知。两者结合,能灵活应对各类变形题。
掌握这几类套路,刷透对应经典题,链表题基本稳了
JS 链表之双指针
让我们先以两道道经典的力扣hot100 题目开始这一小节:
19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)
想象你在一条链子上放了一根固定长度的“滑杆”:
滑杆的右端一开始对准链表的第 n + 1 个节点,
左端则从链表头部(或更准确地说,从一个虚拟的“哨兵”节点)开始。
然后你同时向右滑动两端,保持滑杆长度不变。
当右端刚好滑到链表最后一个节点之后(即 null) 时,左端就正好停在倒数第 n + 1 个节点——也就是要删除的那个节点的前驱。
为什么是 n + 1?因为我们要删的是倒数第 n 个节点,必须拿到它的前一个节点才能断开链接。
那如果 n 等于链表长度(比如链表有 5 个节点,n = 5,要删头节点)?
这时原本没有“第 n + 1 = 6 个节点”,滑杆右端似乎无处安放。
解决办法:在真实头节点前面加一个哨兵节点(dummy node) ,作为新的起点。
这样一来,无论 n 多大,滑杆总能正确放置,而且删除头节点和其他节点的逻辑完全统一,无需特判。
✅ 记住:哨兵节点是处理链表头变更问题的万能技巧,尤其在删除、插入等操作中能大幅简化代码。
代码如下:
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @param {number} n
* @return {ListNode}
*/
var removeNthFromEnd = function(head, n) {
const dummy = new ListNode(0);
dummy.next = head;
let fast = dummy;
let slow = dummy;
for(let i = 0; i < n; i++){
fast = fast.next;
}
while(fast.next){
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
};
876. 链表的中间结点
想象有两个指针在链表上赛跑:
慢指针(slow) 每次走 1 步,
快指针(fast) 每次走 2 步。
可以把它们看作一把“动态伸缩的尺子”:
初始时,两个指针都站在链表头部;随着快指针不断向前跳跃,这把尺子被拉长。
当快指针刚好走到链表末尾(或跳出链表)时,慢指针恰好走到了链表的中点。
为什么?因为快指针的速度是慢指针的两倍,
所以当快指针跑完全程(长度为 L),慢指针正好跑了 L/2 —— 也就是中间位置。
这个技巧无需提前知道链表长度,一次遍历就能定位中点,常用于分割链表、判断回文等场景。
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var middleNode = function(head) {
let slow = head;
let fast = head;
while(fast && fast.next){
slow = slow.next;
fast = fast.next.next;
}
return slow;
};
环形链表
想象两个人在环形跑道上跑步:
- 慢指针(slow) 每次走 1 步,
- 快指针(fast) 每次走 2 步。
如果跑道是直的(无环) ,快指针会先冲出终点(null),两人永远不会相遇。
但如果跑道有环,快指针迟早会从后面追上慢指针——就像套圈一样。
因此,只要在遍历中发现 slow === fast,就说明链表中存在环;
若快指针先到达终点(fast 或 fast.next 为 null),则无环。
这就是著名的 Floyd 判圈算法,仅用两个指针、一次遍历、O(1) 空间,高效可靠。
下面来看看这道题
141. 环形链表
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function(head) {
let fast = head ;
let slow = head;
while(fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if(slow === fast) {
return true;
}
}
return false;
};
还有升级版的,这道题需要一点数学推导以及一些理解。
142. 环形链表 II
想象快慢指针在跑道上奔跑,一旦相遇,说明跑道有环。
但相遇点不一定是环的入口——那怎么找到真正的“入口”呢?
关键洞察:
从头节点到环入口的距离,等于从相遇点绕环走回到入口的距离。
于是,让一个指针回到起点(head),另一个留在相遇点(slow),
两人以相同速度同步前进,再次相遇的地方,就是环的入口!
这是判圈算法的第二阶段,数学上可严格证明。整个过程仅用 O(1) 空间,一次遍历搞定检测 + 定位,优雅而高效。
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var detectCycle = function(head) {
let slow = head;
let fast = head;
while(fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if(fast === slow) {
while(slow !== head){
slow = slow.next;
head = head.next;
}
return slow;
}
}
return null;
};
JS 链表之虚拟头节点
在开始这小节之前,我们先来回顾下如何删除一个节点: 在没有虚拟头节点的时候我们通常这样做:
function remove(head ,val) {
if(head !== null && head.val === val){
return head.next;
}
let cur = head;
while(cur.next) { // 遍历
if(cur.next.val === val){
cur.next = cur.next.next;
break;
}
cur = cur.next;
}
return head;
}
可以看到我们对头节点做了特殊的处理,也就是说,头节点的逻辑和非头节点的删除逻辑是不一样的,这该如何优化呢?
这就引到虚拟头节点,也就是哨兵节点(dummy)来了:
function remove(head,val) {
const dummy = null;
dummy.next = head;
let cur = dummy;
while(cur.next) {
if(cur.next.val === val) {
cur.next = cur.next.next;
break;
}
cur = cur.next;
}
return dummy.next;
}
例题可以看看反转链表的解法三,下节有
JS 链表之反转链表
206. 反转链表
这道题有多种解法,每道解法都有可能被面试官问道。同时,这道题也是面试官的高频前端算法题。
解法一:双指针解法:
想象你在一条单行道上倒车:
cur 是当前所在的节点,
pre 是你已经反转好的新链表的头
每一步,你先记住前方路口(temp = cur.next),
然后掉头把当前路指向身后(cur.next = pre),
接着整体向前挪一步(pre = cur; cur = temp)。
如此反复,直到走完整条链表。
最后,pre 就站在原链表的尾部,也正好是反转后的新头节点。
✅这种写法无需额外节点,仅用三个指针原地反转,空间 O(1),是链表反转最常用、最高效的模板。
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var reverseList = function(head) {
let pre = null;
let cur = head;
while(cur) {
let temp = cur.next;
cur.next = pre;
pre = cur;
cur = temp;
}
return pre;
};
解法二:递归解法:
想象你站在链表的开头,对后面的人喊:“你们先把自己反转好!”
于是问题一路递归到链表末尾——最后一个节点说:“我就是头了,不用翻。”
然后,每一层开始“收尾”:
- 倒数第二个节点让最后一个节点回头指自己(
head.next.next = head), - 再把自己原本向前的手松开(
head.next = null),防止形成环。
就这样,从后往前,一层层把指针“掰”反,最终整条链表完成反转。
而最初的尾节点,一路被返回,成为新的头。
✅ 递归写法简洁优雅,核心在于:先递归到底,再在回溯时修复指针。注意处理
head.next = null,否则会留下环。
var reverseList = function(head) {
if (!head || !head.next) return head;
// 递归反转后续部分,拿到新的头节点
const newHead = reverseList(head.next);
// 将当前节点的下一个节点指向自己(反转指针)
head.next.next = head;
// 断开当前节点原来的 next 指向,避免环
head.next = null;
return newHead;
};
解法三:虚拟头节点 + 头插法:
这个方法看起来和解法一类似,但是细想还是有不一样的地方
想象你面前有一串珠子(原链表),而你手里拿着一个空挂钩(dummy 节点) 。
你要把珠子一个个摘下来,每次摘下的珠子都挂到挂钩最前面——这就是“头插法”。
具体过程:
- 每次取下当前珠子(
cur),先记住下一个珠子在哪(next = cur.next), - 然后把它挂在
dummy后面已有的反转串的最前端(cur.next = dummy.next), - 再让
dummy直接指向这颗新挂上的珠子(dummy.next = cur)。
如此反复,最先摘的珠子最后被压在底部,最后摘的反而在顶部——整串自然就反转了。
✅ 使用
dummy节点的好处是:无需特判空链表或手动处理头节点变更,逻辑统一、安全可靠。
/**
* 反转链表
* 使用 dummy 哨兵节点 + 头插法
*
*/
function reverseList(head) {
// 它的 next 将始终指向当前已经反转部分的头节点
const dummy = new ListNode(0);
let cur = head;
while(cur) {
const next = cur.next; // 先保存下一个节点
// 头插法
// 原来的 head,反转后指向了空
// 下一轮 指向dummy.next
cur.next = dummy.next;
dummy.next = cur;
cur = next;
}
return dummy.next;
}
234. 回文链表
要判断一个链表是否是回文,可以把它“从中间对折”,看前后两半是否一一相等。
具体怎么做?分三步:
- 找中点:用快慢指针(想象两人同跑,快者两步、慢者一步),当快指针到终点时,慢指针正好在链表中间——就像一把自动对半折叠的尺子。
- 反转后半段:从中点开始,把后半部分链表原地翻转,这样就能从“尾部”向前遍历,而无需额外空间。
- 逐个比对:从原头节点和反转后的头节点(即原链表尾)同步向前走,只要有一个值不等,就不是回文;若全部匹配,则是回文。
💡 这种方法只用 O(1) 额外空间,且只需两次遍历,是链表回文问题的经典解法。“快慢指针 + 反转”这一组合套路极为重要。
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {boolean}
*/
function findMid(head){
let slow = head;
let fast = head;
while(fast && fast.next){
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
function reverseNode(head){
let pre = null;
let cur = head;
let temp = null;
while(cur){
temp = cur.next;
cur.next = pre;
pre = cur;
cur = temp;
}
return pre;
}
var isPalindrome = function(head) {
let mid = findMid(head);
let head2 = reverseNode(mid);
while(head2 !== null){
if(head.val !==head2.val){
return false;
}
head = head.next;
head2 = head2.next;
}
return true
};
总结
链表虽小,套路却深。
掌握双指针的节奏、虚拟头节点的巧思、反转操作的灵活组合,就能以不变应万变,轻松拆解大多数链表难题。
不必死记代码,重在理解指针如何“穿针引线”。
多画图、多模拟、多练习——下一次面试手撕链表时,你就是那个稳稳写出 clean code 的人