前端面试之 链表三板斧:双指针、虚拟头节点与反转套路全解析

74 阅读15分钟

链表(Linked List) 是一种常见的线性数据结构,它通过一系列任意地址的存储单元(这些单元在内存中可以连续,也可以不连续)来存储相同类型的数据元素。

简而言之,链表是线性表的链式存储形式。与数组不同,链表中的每个元素(称为“节点”)不仅包含数据本身,还包含一个指向下一个节点的引用(即“指针”),从而通过这种链接关系维护元素之间的逻辑顺序。

image.png 在 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 指针反向指向其前驱


迭代法(推荐:直观、高效、无栈溢出风险)

三指针模板prevcurrnext

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;
};

image.png

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,就说明链表中存在环;
若快指针先到达终点(fastfast.nextnull),则无环。

这就是著名的 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;
};

image.png

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. 回文链表

要判断一个链表是否是回文,可以把它“从中间对折”,看前后两半是否一一相等。

具体怎么做?分三步:

  1. 找中点:用快慢指针(想象两人同跑,快者两步、慢者一步),当快指针到终点时,慢指针正好在链表中间——就像一把自动对半折叠的尺子。
  2. 反转后半段:从中点开始,把后半部分链表原地翻转,这样就能从“尾部”向前遍历,而无需额外空间。
  3. 逐个比对:从原头节点和反转后的头节点(即原链表尾)同步向前走,只要有一个值不等,就不是回文;若全部匹配,则是回文。

💡 这种方法只用 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
};

image.png

总结

链表虽小,套路却深。

掌握双指针的节奏、虚拟头节点的巧思、反转操作的灵活组合,就能以不变应万变,轻松拆解大多数链表难题。

不必死记代码,重在理解指针如何“穿针引线”。
多画图、多模拟、多练习——下一次面试手撕链表时,你就是那个稳稳写出 clean code 的人