[Algo-1]单链表双指针技巧总结:链表题其实就这几类套路

156 阅读15分钟

大家好,我是程序员牛奶,链表题是算法面试里的高频考点,尤其是结合双指针出的题目,下面我总结了我做过的题目以及一些常见套路,帮助大家迅速理解双指针链表题。

它不像数组那样可以通过下标随机访问,也不像哈希表那样可以直接查找元素。链表的特点是:只能顺着 next 指针一步一步往后走

也正因为这个限制,链表题非常考验指针操作能力。

不过,链表题并不是毫无规律。很多经典题目,本质上都可以归纳到几种固定技巧里,尤其是 双指针技巧

读完本文,你不仅可以掌握单链表的常见算法套路,还可以顺便解决这些题目:

LeetCode力扣核心技巧
21. Merge Two Sorted Lists21. 合并两个有序链表虚拟头结点、双指针
86. Partition List86. 分隔链表虚拟头结点、链表拆分
23. Merge k Sorted Lists23. 合并 K 个升序链表优先队列、虚拟头结点
19. Remove Nth Node From End of List19. 删除链表倒数第 N 个结点快慢指针
876. Middle of the Linked List876. 链表的中间结点快慢指针
141. Linked List Cycle141. 环形链表快慢指针
142. Linked List Cycle II142. 环形链表 II快慢指针、环起点
160. Intersection of Two Linked Lists160. 相交链表双指针换路
LCR 140. 训练计划 IILCR 140. 训练计划 II链表基础操作

一、链表题的核心思维

链表题最重要的不是背代码,而是理解指针的角色。

在大多数链表题里,常见的指针有几类:

  1. 遍历指针
    用来从头到尾扫描链表,比如 pcur

  2. 结果链表指针
    用来构造新链表,比如 dummytail

  3. 快慢指针
    一个走得快,一个走得慢,用来找中点、判断环、找倒数第 k 个节点。

  4. 双链表指针
    分别遍历两条链表,比如合并两个有序链表、判断两个链表是否相交。

链表题还有一个非常重要的技巧:虚拟头结点 dummy


二、虚拟头结点:链表题里的“安全绳”

很多链表题需要创建一条新链表,或者对原链表进行重组。

这时,如果直接操作头结点,很容易遇到各种边界问题:

  • 原链表为空怎么办?
  • 新链表第一个节点怎么接?
  • 删除的是头结点怎么办?
  • 分解链表后怎么拼接?

为了解决这些麻烦,我们经常创建一个虚拟头结点:

ListNode dummy = new ListNode(-1);
ListNode p = dummy;
return dummy.next;

dummy 本身不存储有效数据,它只是一个占位符。真正的结果链表从 dummy.next 开始。

什么时候适合使用虚拟头结点?

当你需要创建一条新链表,或者可能修改链表头部结构时,就可以考虑使用 dummy

比如:

  • 合并两个有序链表;
  • 分隔链表;
  • 删除倒数第 N 个节点;
  • 合并 K 个升序链表。

01_dummy_node.gif


技巧一:合并两个有序链表

对应题目:

  • LeetCode 21. 合并两个有序链表

题目要求我们把两个升序链表合并成一个新的升序链表。

例如:

输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]

这道题的思路很像“拉拉链”。

两个链表就像拉链两侧的齿轮,我们每次比较两个链表当前节点的值,把较小的节点接到结果链表后面。


解题思路

准备三个指针:

  • p1:遍历链表 l1
  • p2:遍历链表 l2
  • p:负责构造结果链表。

每次比较 p1.valp2.val

  • 谁小,就把谁接到 p.next
  • 对应指针向后移动;
  • p 也向后移动。

当其中一个链表为空后,把另一个链表剩余部分直接接上即可。


代码实现

class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        // 虚拟头结点
        ListNode dummy = new ListNode(-1);
        ListNode p = dummy;

        ListNode p1 = l1;
        ListNode p2 = l2;

        while (p1 != null && p2 != null) {
            if (p1.val <= p2.val) {
                p.next = p1;
                p1 = p1.next;
            } else {
                p.next = p2;
                p2 = p2.next;
            }

            p = p.next;
        }

        if (p1 != null) {
            p.next = p1;
        }

        if (p2 != null) {
            p.next = p2;
        }

        return dummy.next;
    }
}

02_merge_two_lists.gif

技巧二:链表分解

对应题目:

  • LeetCode 86. 分隔链表

题目要求:

给定链表 head 和整数 x,把链表分成两部分:

  • 小于 x 的节点排在前面;
  • 大于等于 x 的节点排在后面;
  • 保持每个分区内部原来的相对顺序。

例如:

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

解题思路

这道题可以理解为:把一条链表拆成两条链表。

我们创建两条新链表:

  • small 链表:存放小于 x 的节点;
  • large 链表:存放大于等于 x 的节点。

遍历原链表时:

  • 如果当前节点值 < x,接到 small 后面;
  • 否则,接到 large 后面。

最后把 smalllarge 拼起来。


关键细节:为什么要断开原链表?

在把原链表节点接到新链表时,最好断开它原来的 next 指针。

也就是:

ListNode temp = p.next;
p.next = null;
p = temp;

如果不断开,原链表中的旧连接可能残留,最终结果链表可能出现错误,甚至形成环。

这是链表重组题中非常容易踩坑的地方。


代码实现

class Solution {
    public ListNode partition(ListNode head, int x) {
        // 存放小于 x 的链表
        ListNode dummy1 = new ListNode(-1);
        // 存放大于等于 x 的链表
        ListNode dummy2 = new ListNode(-1);

        ListNode p1 = dummy1;
        ListNode p2 = dummy2;

        ListNode p = head;

        while (p != null) {
            if (p.val < x) {
                p1.next = p;
                p1 = p1.next;
            } else {
                p2.next = p;
                p2 = p2.next;
            }

            // 断开当前节点和原链表的连接
            ListNode temp = p.next;
            p.next = null;
            p = temp;
        }

        // 拼接两个链表
        p1.next = dummy2.next;

        return dummy1.next;
    }
}

03_partition_list.gif

技巧三:合并 K 个有序链表

对应题目:

  • LeetCode 23. 合并 K 个升序链表

这道题是合并两个有序链表的升级版。

现在不是两条链表,而是 k 条有序链表。

问题在于:

每次应该从 k 个链表当前节点中选出最小的那个节点。

如果每次都遍历 k 个头节点找最小值,效率会比较低。

更好的办法是使用 优先级队列,也就是最小堆


解题思路

  1. 创建一个最小堆;
  2. 把所有非空链表的头结点放入堆中;
  3. 每次从堆中弹出当前最小节点;
  4. 把这个节点接到结果链表后面;
  5. 如果这个节点还有下一个节点,就把下一个节点加入堆;
  6. 重复直到堆为空。

代码实现

import java.util.PriorityQueue;

class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists.length == 0) {
            return null;
        }

        ListNode dummy = new ListNode(-1);
        ListNode p = dummy;

        PriorityQueue<ListNode> pq = new PriorityQueue<>(
            lists.length,
            (a, b) -> a.val - b.val
        );

        for (ListNode head : lists) {
            if (head != null) {
                pq.add(head);
            }
        }

        while (!pq.isEmpty()) {
            ListNode node = pq.poll();

            p.next = node;

            if (node.next != null) {
                pq.add(node.next);
            }

            p = p.next;
        }

        return dummy.next;
    }
}

复杂度分析

假设:

  • 一共有 k 条链表;
  • 所有链表节点总数为 N

最小堆中最多有 k 个节点,每次插入或删除的复杂度是 O(log k)

每个节点都会进堆、出堆一次。

所以总时间复杂度是:

O(N log k)

空间复杂度是:

O(k)


技巧四:寻找倒数第 K 个节点

对应题目:

  • LeetCode 19. 删除链表的倒数第 N 个结点

如果让你找链表的正数第 k 个节点,很简单,从头走 k - 1 步即可。

但如果要找倒数第 k 个节点,就不能直接从尾部往前走,因为单链表没有前驱指针。

最直观的方法是:

  1. 先遍历一遍链表,得到长度 n
  2. 再找到正数第 n - k + 1 个节点。

但这样需要遍历两次。

更优雅的方法是:快慢指针


解题思路

使用两个指针:

  • p1 先走 k 步;
  • p2 从头开始;
  • 然后 p1p2 同时走;
  • p1 走到 null 时,p2 正好指向倒数第 k 个节点。

为什么?

因为 p1p2 之间始终保持 k 个节点的距离。


查找倒数第 K 个节点代码

class Solution {
    ListNode findFromEnd(ListNode head, int k) {
        ListNode p1 = head;

        for (int i = 0; i < k; i++) {
            p1 = p1.next;
        }

        ListNode p2 = head;

        while (p1 != null) {
            p1 = p1.next;
            p2 = p2.next;
        }

        return p2;
    }
}

删除倒数第 N 个节点

要删除倒数第 n 个节点,需要先找到倒数第 n + 1 个节点。

因为单链表删除节点时,需要知道它的前一个节点。

这里也要使用虚拟头结点,避免删除头结点时出错。


代码实现

class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode dummy = new ListNode(-1);
        dummy.next = head;

        // 找到倒数第 n + 1 个节点
        ListNode x = findFromEnd(dummy, n + 1);

        // 删除倒数第 n 个节点
        x.next = x.next.next;

        return dummy.next;
    }

    private ListNode findFromEnd(ListNode head, int k) {
        ListNode p1 = head;

        for (int i = 0; i < k; i++) {
            p1 = p1.next;
        }

        ListNode p2 = head;

        while (p1 != null) {
            p1 = p1.next;
            p2 = p2.next;
        }

        return p2;
    }
}

04_find_from_end.gif



技巧五:寻找链表中点

对应题目:

  • LeetCode 876. 链表的中间结点

找链表中点也可以先计算链表长度,再走到中间位置。

但更常见的做法是使用快慢指针:

  • slow 每次走一步;
  • fast 每次走两步;
  • fast 到达链表末尾时,slow 正好在中间。

代码实现

class Solution {
    public ListNode middleNode(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;

        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }

        return slow;
    }
}

如果链表长度是偶数,这种写法返回的是靠后的那个中间节点。

比如:

链表:1 -> 2 -> 3 -> 4
中间节点有两个:2 和 3
上述代码返回:3

技巧六:判断链表是否有环

对应题目:

  • LeetCode 141. 环形链表

判断链表是否有环,也可以使用快慢指针。

思路很直观:

  • slow 每次走一步;
  • fast 每次走两步;
  • 如果链表没有环,fast 最终会走到 null
  • 如果链表有环,fast 会在环里不断绕圈,最终追上 slow

这就像操场跑步:

如果两个人在环形跑道上,一个跑得快,一个跑得慢,那么快的人迟早会追上慢的人。


代码实现

public class Solution {
    public boolean hasCycle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;

        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;

            if (slow == fast) {
                return true;
            }
        }

        return false;
    }
}

05_has_cycle.gif


技巧七:找到环的起点

对应题目:

  • LeetCode 142. 环形链表 II

判断是否有环只是第一步,更进一步的问题是:

如果链表有环,环的入口节点在哪里?

这道题依然使用快慢指针。


解题思路

分两步:

第一步:判断是否有环

slow 每次走一步,fast 每次走两步。

如果两者相遇,说明有环。

如果 fast 走到 null,说明无环。

第二步:寻找环入口

slowfast 相遇后:

  1. 让其中一个指针回到 head
  2. 两个指针都每次走一步;
  3. 它们再次相遇的位置,就是环的入口。

代码实现

public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;

        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;

            if (slow == fast) {
                break;
            }
        }

        if (fast == null || fast.next == null) {
            return null;
        }

        slow = head;

        while (slow != fast) {
            slow = slow.next;
            fast = fast.next;
        }

        return slow;
    }
}

原理简单解释

假设:

  • 从头结点到环入口的距离是 a
  • 环入口到相遇点的距离是 b
  • 相遇点再走到环入口的距离是 c

慢指针走过的距离是:

a + b

快指针走过的距离是:

a + b + c + b

因为快指针速度是慢指针的两倍,所以快指针走的距离是慢指针的两倍。

最终可以推出:

a = c

也就是说:

从头结点走到环入口的距离,等于从相遇点继续走到环入口的距离。

所以,让一个指针回到头结点,另一个留在相遇点,然后一起走,它们会在环入口相遇。


06_detect_cycle_entry.gif


技巧八:判断两个链表是否相交

对应题目:

  • LeetCode 160. 相交链表

题目要求:

给定两个链表 headAheadB,判断它们是否相交。

如果相交,返回相交节点;如果不相交,返回 null

注意,这里的“相交”不是值相等,而是两个链表从某个节点开始共用同一段节点。

例如:

A: a1 -> a2
             \
              c1 -> c2 -> c3
             /
B: b1 -> b2 -> b3

这里相交节点是 c1


难点在哪里?

两条链表长度可能不同。

如果让两个指针分别从 headAheadB 同时出发,它们不一定会同时到达交点。

比如:

A 链表更短:
a1 -> a2 -> c1 -> c2

B 链表更长:
b1 -> b2 -> b3 -> c1 -> c2

两个指针同时走,无法对齐公共部分。


解题思路:双指针换路

我们可以让:

  • p1 从链表 A 出发,走完 A 后再走 B;
  • p2 从链表 B 出发,走完 B 后再走 A。

这样两个指针走过的总长度相同:

p1 走过:A + B
p2 走过:B + A

如果两个链表相交,它们会在交点相遇。

如果不相交,它们最终会同时走到 null


代码实现

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        ListNode p1 = headA;
        ListNode p2 = headB;

        while (p1 != p2) {
            if (p1 == null) {
                p1 = headB;
            } else {
                p1 = p1.next;
            }

            if (p2 == null) {
                p2 = headA;
            } else {
                p2 = p2.next;
            }
        }

        return p1;
    }
}

07_intersection_lists.gif


九、链表双指针技巧总表

问题类型常用技巧关键点
合并两个有序链表双指针 + 虚拟头结点每次接较小节点
分隔链表双虚拟头结点拆成两条链表再拼接
合并 K 个有序链表最小堆 + 虚拟头结点每次取 k 个头节点中最小值
找倒数第 K 个节点快慢指针快指针先走 K 步
删除倒数第 N 个节点快慢指针 + dummy找倒数第 N+1 个节点
找链表中点快慢指针快走两步,慢走一步
判断链表是否有环快慢指针有环则快慢指针必相遇
找环入口快慢指针相遇后一个回 head,同速前进
找两个链表交点双指针换路A+B 与 B+A 路径等长

十、常见链表题易错点

1. 忘记处理空链表

比如:

head == null

很多题目都可能输入空链表,代码中要注意判断。


2. 删除头结点时出错

删除头结点是链表题最常见的边界问题。

所以删除类题目经常使用:

ListNode dummy = new ListNode(-1);
dummy.next = head;

最后返回:

return dummy.next;

3. 重组链表时没有断开原连接

比如分隔链表时,如果你把原节点接到新链表后面,但没有断开它原来的 next,可能会导致结果链表结构混乱。

推荐写法:

ListNode temp = p.next;
p.next = null;
p = temp;

4. 把节点值相等误认为节点相交

相交链表判断的是节点引用是否相同,不是节点值是否相等。

也就是说:

p1 == p2        // 正确,判断是不是同一个节点
p1.val == p2.val // 错误,只是值相等

5. 快慢指针循环条件写错

找中点、判断环时,常用循环条件是:

while (fast != null && fast.next != null)

这样可以避免访问 fast.next.next 时出现空指针异常。


十一、练习题推荐

如果你刚学完链表双指针,建议按照下面顺序刷题。


基础入门

1. LeetCode 21. 合并两个有序链表

练习重点:

  • 虚拟头结点;
  • 双指针遍历;
  • 链表拼接。

这是链表题最基础的模板题,建议反复写到熟练。


2. LeetCode 86. 分隔链表

练习重点:

  • 链表拆分;
  • 双 dummy 节点;
  • 断开原链表连接。

这道题非常适合训练链表重组能力。


进阶合并

3. LeetCode 23. 合并 K 个升序链表

练习重点:

  • 最小堆;
  • 虚拟头结点;
  • 多链表合并。

如果你已经掌握了合并两个有序链表,这道题就是自然升级版。


快慢指针

4. LeetCode 19. 删除链表的倒数第 N 个结点

练习重点:

  • 快指针先走;
  • 删除节点需要找到前驱;
  • 使用 dummy 处理删除头结点。

5. LeetCode 876. 链表的中间结点

练习重点:

  • 快慢指针;
  • 偶数长度时返回后一个中点。

6. LeetCode 141. 环形链表

练习重点:

  • 快慢指针判断是否相遇;
  • 理解有环必相遇的原因。

7. LeetCode 142. 环形链表 II

练习重点:

  • 快慢指针相遇;
  • 找环入口;
  • 理解“一个回头,一个留在相遇点”的原理。

双链表相交

8. LeetCode 160. 相交链表

练习重点:

  • 双指针换路;
  • 判断节点引用而不是节点值;
  • 理解 A + BB + A 路径等长。

十二、最后总结

单链表题看起来花样很多,但核心套路其实很集中。

如果要用一句话概括:

链表题的本质,就是通过指针控制节点之间的连接关系。

其中最常用的技巧有:

  1. 虚拟头结点
    用来简化新链表构造、删除头结点等边界情况。

  2. 双指针合并
    用于合并两个有序链表、分隔链表等问题。

  3. 快慢指针
    用于寻找中点、倒数第 K 个节点、判断环、寻找环入口。

  4. 双指针换路
    用于判断两个链表是否相交。

  5. 优先队列
    用于合并 K 个有序链表。

链表题最怕的不是思路难,而是指针乱。

所以写链表题时,建议你养成几个习惯:

  • 先画图,再写代码;
  • 多用 dummy 简化边界;
  • 修改 next 前先保存后继节点;
  • 判断相交时比较节点引用;
  • 快慢指针注意循环条件;
  • 写完后用 2 到 3 个小例子手动模拟。

只要这些基本功扎实,链表题就不会再是一团乱麻。

十三,练习题

链表的分解

    1. 删除排序链表中的重复元素 II

链表的合并

    1. 有序矩阵中第 K 小的元素
    1. 查找和最小的 K 对数字

链表运算题

    1. 两数相加
    1. 两数相加 II

链表环检测

    1. 寻找重复数