链表双指针实战:倒数第 N 个节点、相交链表与回文链表

摘要

链表双指针的核心技巧可以归纳为两类:距离双指针(一个指针先走 K 步,然后两个指针同速移动,利用固定距离差获取信息)和等长对齐(使两个不等长的链表在对齐位置上同步移动)。本文通过三道经典题——删除倒数第 N 个节点、相交链表、回文链表——完整展示这两类技巧,并深入剖析哑节点(Dummy Node)的工程价值。


第 1 章 距离双指针:找倒数第 K 个节点

1.1 核心技巧

“链表中倒数第 个节点”等价于”从头节点出发,第 个节点”( 是链表长度)。但链表不支持随机访问,不能像数组一样直接用 nums[n-K]

如果先遍历一次获取长度 ,再从头走 步,需要两次遍历。能否只遍历一次?

距离双指针的思路

设两个指针 fastslow,让 fast 先走 步,然后 fastslow 同时以速度 1 移动。当 fast 到达链表末尾(null)时,slow 恰好在倒数第 个节点。

为什么?

fast 先走了 步,fastslow 之间的距离固定为 。此后两者同步移动,距离保持不变。当 fast 到达末尾(走了 步)时,slow 走了 步,即从头节点起第 个节点,也就是倒数第 个节点。


第 2 章 LeetCode 19:删除链表的倒数第 N 个节点

2.1 题目

LeetCode 19 - Remove Nth Node From End of List(中等)

给你一个链表,删除链表的倒数第 n 个节点,并且返回链表的头节点。

输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]

输入:head = [1], n = 1
输出:[]

输入:head = [1,2], n = 1
输出:[1]

2.2 为什么需要哑节点(Dummy Node)

直接操作链表节点时,删除操作需要找到被删除节点的前驱节点prev),然后执行 prev.next = prev.next.next

问题:如果被删除的是头节点(倒数第 个节点恰好是第一个节点),头节点没有前驱,代码需要特殊处理 return head.next

哑节点(Dummy Node):在头节点之前插入一个虚拟节点 dummydummy.next = head。这样,所有节点(包括原来的头节点)都有前驱节点(dummy 是原头节点的前驱)。删除操作统一为 prev.next = prev.next.next,不需要特殊处理头节点。

核心概念:哑节点的工程价值

哑节点是链表操作中消除”头节点特殊情况”的标准技巧。几乎所有涉及”可能删除头节点”或”在头节点前插入”的链表题,第一步都应该创建哑节点。它的代价是 的额外空间,换来的是代码的统一性和正确性。

2.3 完整解法

public ListNode removeNthFromEnd(ListNode head, int n) {
    // 创建哑节点,统一头节点操作
    ListNode dummy = new ListNode(0);
    dummy.next = head;
 
    ListNode fast = dummy, slow = dummy;
 
    // fast 先走 n+1 步(不是 n 步)
    // 为什么是 n+1 步?因为我们要找"被删节点的前驱",而不是"被删节点本身"
    for (int i = 0; i < n + 1; i++) {
        fast = fast.next;
    }
 
    // fast 和 slow 同速移动,直到 fast 为 null
    while (fast != null) {
        slow = slow.next;
        fast = fast.next;
    }
 
    // 此时 slow 是被删节点的前驱
    slow.next = slow.next.next;
 
    return dummy.next;
}

2.4 “先走 n+1 步”的推导

要删除倒数第 个节点,需要找到它的前驱(倒数第 个节点)。

用距离双指针找”倒数第 个节点”,需要 fast 先走 步,然后 fast 走到 null 时,slow 在倒数第 个节点。

dummy 出发fastdummy 出发先走 步,此时 fast 在原链表的第 个节点处(因为 dummy 是第 0 个”节点”)。

不,让我们重新数一下:

设链表有 个节点(不含 dummy)。

  • fastdummy 先走 步,fast 指向原链表第 个节点(0-indexed:dummy=0, head=1, ...,所以 步后在节点 ,即原链表从 0 开始的第 个节点)。
  • 然后两者同速移动,当 fast == null(即 fast 走到 位置,超出链表末尾)时,slow 在位置 (即倒数第 个节点)。

slow 在倒数第 个节点,即被删节点的前驱,执行 slow.next = slow.next.next 删除被删节点。正确。

2.5 边界条件

删除头节点n 等于链表长度):

fast 先走 步后,fast = null(已经越过末尾)?不对,fastdummy 出发,走 步后恰好是链表最后一个节点(因为链表有 个节点,dummy 出发走 步到第 个节点,再走一步到 null)。

等等,让我们用例子验证:链表 [1, 2, 3](长度 3),删除倒数第 3 个节点(即头节点 1)。

  • dummy -> 1 -> 2 -> 3 -> null
  • fast 先走 步:dummy(0)1(1)2(2)3(3)null(4)
  • fast == null,退出”先走”循环
  • while (fast != null) 不执行,slow 仍在 dummy
  • slow.next = slow.next.nextdummy.next = dummy.next.next = 2,即删除头节点 1

使用哑节点后,删除头节点的情况被自然处理,无需特判。


第 3 章 LeetCode 160:相交链表

3.1 题目

LeetCode 160 - Intersection of Two Linked Lists(简单)

给你两个单链表的头节点 headAheadB,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null

题目保证:整个链式结构中不存在环。

输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出:Intersected at '8'

输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出:No intersection

3.2 两种解法思路

解法一:哈希表

遍历链表 A,将所有节点存入 HashSet。然后遍历链表 B,如果某个节点在 HashSet 中,则是交点。时间 ,空间

解法二:等长对齐(双指针,空间

核心观察:如果两个链表相交,交点之后的部分是完全相同的(共享节点),交点之前的部分长度可能不同。

设链表 A 的非公共部分长度为 ,链表 B 的非公共部分长度为 ,公共部分长度为

  • A 的总长度:
  • B 的总长度:

如果指针 pA 走完 A 后跳到 B 的头继续走,走了 步到达交点。 如果指针 pB 走完 B 后跳到 A 的头继续走,走了 步到达交点。

,两者到达交点的步数相同!因此,两个指针会同时到达交点。

3.3 完整解法

public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
    ListNode pA = headA, pB = headB;
 
    // 两指针各自遍历,到末尾后跳到另一条链表的头
    while (pA != pB) {
        pA = (pA == null) ? headB : pA.next;
        pB = (pB == null) ? headA : pB.next;
    }
 
    // pA == pB,要么是交点,要么都是 null(无交点)
    return pA;
}

无交点时:两指针各走 步后,同时到达 null(无公共部分时 ),pA == pB == null,返回 null。正确。

3.4 为什么判断 pA == null 而不是 pA.next == null

如果用 pA.next == null,当 pA 到达最后一个节点时跳转,pA 在跳转前等于链表 A 的最后一个节点,跳转后 pA = headB。但此时 pA 已经是最后一个节点,还没有走到 null,逻辑会少走一步。

正确做法:pA == null 时跳转,即 pA 已经走出链表(在 null 位置),此时跳到另一条链表的头节点 headB。这样两指针走的步数是 ,跳转后走 步到交点,合计步数相等。


第 4 章 LeetCode 234:回文链表

4.1 题目

LeetCode 234 - Palindrome Linked List(简单)

给你一个单链表的头节点 head,请你判断该链表是否为回文链表。如果是,返回 true;否则,返回 false

输入:head = [1,2,2,1]
输出:true

输入:head = [1,2]
输出:false

进阶:你能否用 时间复杂度和 空间复杂度解决此题?

4.2 解题思路

回文链表的核心挑战:链表不支持随机访问,不能像数组一样直接用 leftright 指针比较两端。

朴素解法:将链表转为数组,然后用对撞指针判断回文。时间 ,空间

空间解法:分三步:

  1. 用快慢指针找链表中点
  2. 反转后半段链表(原地操作, 空间)
  3. 比较前半段和反转后的后半段是否相同
  4. (可选)恢复链表原始结构

4.3 完整解法

public boolean isPalindrome(ListNode head) {
    if (head == null || head.next == null) return true;
 
    // 步骤一:找链表中点
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    // 循环结束后,slow 是前半段的最后一个节点
    // 奇数:[1,2,3,2,1] → slow = 3(中点)
    // 偶数:[1,2,2,1] → slow = 第一个 2
 
    // 步骤二:反转后半段
    ListNode secondHalfStart = reverseList(slow.next);
 
    // 步骤三:比较前半段和反转后的后半段
    ListNode p1 = head, p2 = secondHalfStart;
    boolean result = true;
    while (p2 != null) {
        if (p1.val != p2.val) {
            result = false;
            break;
        }
        p1 = p1.next;
        p2 = p2.next;
    }
 
    // 步骤四:恢复链表(可选,但工程上推荐)
    slow.next = reverseList(secondHalfStart);
 
    return result;
}
 
// 反转链表(迭代版本)
private ListNode reverseList(ListNode head) {
    ListNode prev = null, cur = head;
    while (cur != null) {
        ListNode next = cur.next;
        cur.next = prev;
        prev = cur;
        cur = next;
    }
    return prev;
}

4.4 找中点时循环条件的精确分析

注意步骤一使用的循环条件是 fast.next != null && fast.next.next != null,而不是前面 LeetCode 876 中的 fast != null && fast.next != null

两者找到的中点不同:

while (fast != null && fast.next != null)(876 题的写法)

  • 奇数 [1,2,3,2,1]slow=3(中点)
  • 偶数 [1,2,2,1]slow=第二个 2(后半段的第一个节点)

while (fast.next != null && fast.next.next != null)(234 题需要的写法)

  • 奇数 [1,2,3,2,1]slow=3(中点)
  • 偶数 [1,2,2,1]slow=第一个 2(前半段的最后一个节点)

回文链表判断中,我们需要在 slow.next 处分割链表(slow 是前半段的最后一个节点,slow.next 是后半段的第一个节点)。所以需要用第二种写法,让 slow 停在前半段末尾,而不是后半段开头。

链表876 写法 slow 位置234 写法 slow 位置
[1,2,3,2,1](奇数 5)节点 3(中点)节点 3(中点)
[1,2,2,1](偶数 4)第二个 2(后半段第一个)第一个 2(前半段最后一个)
[1,2,3](奇数 3)节点 2(中点)节点 2(前半段最后一个?)

对奇数长度链表 [1,2,3],234 写法中 slow=2(第二个节点),slow.next=3(后半段),比较 13——显然不相等,但 [1,2,3] 确实不是回文,所以正确。

生产避坑:两种找中点写法的微妙差异

找中点有两种常用写法,停下来的位置差了一步(偶数长度时)。在用快慢指针找中点后紧接着操作链表(如分割、反转)时,必须搞清楚”slow 停在中点本身”还是”slow 停在中点的前驱”,选择对应的循环条件。选错会导致后续操作在错误位置分割链表。

4.5 为什么要恢复链表结构?

步骤四(恢复链表)在 LeetCode 上不影响判题,但在工程实践中非常重要:

  1. 函数的语义契约:函数接受链表,不应该悄悄修改它(副作用)。调用方不会预期 isPalindrome 会改变链表结构。
  2. 并发安全:多线程场景下,修改共享链表结构会导致竞态条件。
  3. 可调试性:函数执行后链表被破坏,调试时难以复现原始状态。

第 5 章 链表双指针的通用技巧总结

5.1 哑节点的适用场景

场景是否需要哑节点
可能删除头节点需要
在头节点前插入需要
合并两条链表(结果可能从任一链表开始)通常需要
仅遍历或查询,不修改链表结构不需要
只修改节点值,不修改 next 指针不需要

5.2 三类链表双指针操作模式

模式一:距离双指针(先走 K 步)

  • 用途:找倒数第 K 个节点
  • 实现:fast 先走 K 步,然后两指针同速移动

模式二:等长对齐(跳转到另一条链表)

  • 用途:找两条链表的交点
  • 实现:走完本链表后跳到另一条链表头,两指针总步数相等

模式三:快慢指针(步幅不同)

  • 用途:找中点、检测环、找环入口
  • 实现:快指针每步 2,慢指针每步 1

5.3 面试中的”链表四连”

链表题的四个高频考点,每个都有对应的双指针技巧:

  1. 环检测:快慢指针(Floyd 算法)→ LeetCode 141
  2. 环入口:快慢相遇后重置一个到头,同速移动 → LeetCode 142
  3. 倒数第 K 个:距离双指针(先走 K+1 步找前驱)→ LeetCode 19
  4. 两链表交点:等长对齐(走完跳到另一条)→ LeetCode 160

掌握这四个技巧,链表双指针题基本全覆盖。


小结

本文通过三道题覆盖了链表双指针的两大核心变体:

  • 距离双指针(LeetCode 19):fast 先走 步找倒数第 个节点的前驱,配合哑节点统一处理头节点删除
  • 等长对齐(LeetCode 160):两指针各走 步,同时到达交点或同时到达 null
  • 快慢指针组合(LeetCode 234):快慢指针找中点 + 反转后半段 + 对比两半 + 恢复结构

下一篇正式进入滑动窗口领域,从最基础的定长窗口和可变窗口出发,建立统一的”扩张-收缩”状态机框架。