算法刷题笔记:链表题不会双指针?那真得练练了

13 阅读16分钟

算法刷题笔记:链表题不会双指针?那真得练练了

第 3 篇:链表

这是我的刷题笔记第 3 篇,整理了链表相关的经典题目。链表题套路比较明显,双指针是灵魂。话不多说,直接开整!

1. 相交链表

题目

给你两个单链表的 headA 和 headB,请找出它们相交的起始节点。如果没有相交,返回 null。

思路过程

第一次尝试:暴力枚举

最直观的想法:遍历 A 的每个节点,对于每个节点都去遍历 B 看有没有相同的。

// 伪代码
for (Node a = headA; a != null; a = a.next) {
    for (Node b = headB; b != null; b = b.next) {
        if (a == b) return a;
    }
}

问题:时间复杂度 O(mn),太慢了。

优化:哈希表

把 A 的所有节点存入 Set,遍历 B 时检查是否存在。

Set<Node> set = new HashSet<>();
for (Node a = headA; a != null; a = a.next) {
    set.add(a);
}
for (Node b = headB; b != null; b = b.next) {
    if (set.contains(b)) return b;
}

问题:需要 O(m) 额外空间。有没有办法不用额外空间?

最终解法:双指针交叉走 ✨

关键洞察:如果两个链表长度不同,直接对比肯定错过。

但如果我们让两个指针分别走完对方的长度呢?

A: a1 -> a2 -> c1 -> c2 (长度为 m + c)
B: b1 -> b2 -> b3 -> c1 -> c2 (长度为 n + c)

指针1走:A + B = a1->a2->c1->c2->b1->b2->b3->c1
指针2走:B + A = b1->b2->b3->c1->c2->a1->a2->c1

看!它们会在 c1 相遇!原理就是:两个指针都走了 m+n+c 的路程,最后那段公共部分会对齐。

代码

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        // 边界情况
        if (headA == null || headB == null) {
            return null;
        }
        
        ListNode pA = headA;
        ListNode pB = headB;
        
        // 当两个指针不相同时,继续走
        // 走完自己的链表后,切换到对方的链表
        while (pA != pB) {
            // pA 走完 A 走 B
            pA = (pA == null) ? headB : pA.next;
            // pB 走完 B 走 A
            pB = (pB == null) ? headA : pB.next;
        }
        
        // 相交返回交点,没相交返回 null(两者都是 null)
        return pA;
    }
}

复杂度分析

  • 时间复杂度:O(m + n),每个指针最多走 m + n 步
  • 空间复杂度:O(1),只用了两个指针

一句话总结

让两个指针分别走完自己的链表再走对方的链表,路程对齐后,相交点自然会重合。

2. 反转链表

题目

给你单链表的头节点 head,请你反转链表并返回反转后的头节点。

思路过程

第一次尝试:递归

递归的想法很自然:反转后面的链表,然后把当前节点接到后面。

public ListNode reverseList(ListNode head) {
    if (head == null || head.next == null) {
        return head;
    }
    ListNode newHead = reverseList(head.next);
    head.next.next = head;  // 让后一个节点指向自己
    head.next = null;       // 断开原来的连接
    return newHead;
}

问题:递归深度太大时可能栈溢出,而且面试官可能想看迭代解法。

最终解法:迭代(双指针)✨

核心思想:逐个节点反转方向。

就像一根链条,我们从头部开始,一个一个把指针方向反过来。

初始: 1 -> 2 -> 3 -> null
      ↑
    prev  cur

第一步: null <- 1    2 -> 3 -> null
               ↑   ↑
              prev cur

第二步: null <- 1 <- 2    3 -> null
                    ↑   ↑
                   prev cur

第三步: null <- 1 <- 2 <- 3
                         ↑   ↑
                        prev cur(null)

代码

public class Solution {
    public ListNode reverseList(ListNode head) {
        ListNode prev = null;      // 上一个节点,初始为 null
        ListNode curr = head;       // 当前节点
        
        while (curr != null) {
            ListNode nextTemp = curr.next;  // 先保存下一个节点
            curr.next = prev;               // 反转当前节点的指向
            prev = curr;                    // prev 前进
            curr = nextTemp;                 // curr 前进
        }
        
        // 循环结束后,prev 指向新的头节点
        return prev;
    }
}

复杂度分析

  • 时间复杂度:O(n),遍历一次链表
  • 空间复杂度:O(1),只用了几个指针变量

一句话总结

用 prev 和 curr 双指针,一边遍历一边把指针方向反过来,最后 prev 就是新的头。

3. 回文链表

题目

给你一个单链表,请判断它是否是回文链表(即正着读和反着读是一样的)。

思路过程

第一次尝试:存到数组里

把链表的值复制到数组,然后用双指针从两端往中间比较。

问题:需要 O(n) 额外空间。有没有办法 O(1) 空间搞定?

最终解法:快慢指针 + 反转 ✨

思路清晰,分三步走:

  1. 找中点:快指针一次走两步,慢指针一次走一步,慢指针到达中点
  2. 反转后半段:从中点开始反转后半段链表
  3. 比较:前半段和后半段(已反转)逐节点比较
原链表: 1 -> 2 -> 2 -> 1

第一步找中点:
快:1 -> 2 -> null
慢:1 -> 2 -> 2  (奇数个停在中间前,偶数个停在后半段起点前)

第二步反转后半段:
1 -> 2 -> null
     <-
1 -> 2

第三步比较:
1 == 12 == 2 ✓
相同,是回文!

代码

public class Solution {
    
    public boolean isPalindrome(ListNode head) {
        if (head == null || head.next == null) {
            return true;  // 空链表或单节点都是回文
        }
        
        // 第一步:找中点(偶数个时找的是前半段最后一个)
        ListNode slow = head;
        ListNode fast = head;
        while (fast.next != null && fast.next.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        
        // 此时 slow 指向中点(偶数)或中间前一个(奇数)
        // slow.next 就是后半段的头
        
        // 第二步:反转后半段
        ListNode secondHalfHead = reverseList(slow.next);
        
        // 第三步:比较前半段和后半段
        ListNode p1 = head;              // 前半段从头部开始
        ListNode p2 = secondHalfHead;    // 后半段从头(已反转)开始
        boolean result = true;
        
        while (p2 != null) {  // 只需要比较后半段的长度
            if (p1.val != p2.val) {
                result = false;
                break;
            }
            p1 = p1.next;
            p2 = p2.next;
        }
        
        // 可选:恢复链表原状(面试时不恢复也行)
        slow.next = reverseList(secondHalfHead);
        
        return result;
    }
    
    // 反转链表的辅助方法
    private ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode curr = head;
        while (curr != null) {
            ListNode nextTemp = curr.next;
            curr.next = prev;
            prev = curr;
            curr = nextTemp;
        }
        return prev;
    }
}

复杂度分析

  • 时间复杂度:O(n),遍历了链表常数次
  • 空间复杂度:O(1),只用了几个指针,没有额外数组

一句话总结

快慢指针找中点,反转后半段,然后从两端往中间比较,轻松判断回文。

4. 环形链表

题目

给你一个链表的头节点 head,判断链表中是否有环。如果有环返回 true,否则返回 false。

思路过程

第一次尝试:哈希表记录

遍历链表,把每个节点存入 Set,如果遇到已存在的节点,说明有环。

问题:需要 O(n) 额外空间。

最终解法:快慢指针

两个人在操场上跑圈,一个跑得快一个跑得慢,如果有环,跑得快的人迟早会追上跑得慢的人。

就像运动会的套圈现象!

slow: 每次走1步
fast: 每次走2步

如果有环:
第1圈: slow=1, fast=3
第2圈: slow=2, fast=5
第3圈: slow=3, fast=7(追上slow了!)

代码

public class Solution {
    public boolean hasCycle(ListNode head) {
        if (head == null || head.next == null) {
            return false;  // 空链表或单节点不可能有环
        }
        
        ListNode slow = head;   // 慢指针,一次走一步
        ListNode fast = head;    // 快指针,一次走两步
        
        while (fast != null && fast.next != null) {
            slow = slow.next;          // 慢指针走一步
            fast = fast.next.next;     // 快指针走两步
            
            // 如果相遇,说明有环
            if (slow == fast) {
                return true;
            }
        }
        
        // fast 走到了 null,说明没环
        return false;
    }
}

复杂度分析

  • 时间复杂度:O(n),最坏情况走完整个链表
  • 空间复杂度:O(1),只用了两个指针

一句话总结

快慢指针一起跑,有环必相遇,没环 fast 先跑丢。

5. 合并两个有序链表

题目

将两个升序链表合并成一个升序链表,返回合并后的链表。

思路过程

第一次尝试:直接比较插入

同时遍历两个链表,每次比较当前节点的值,把较小的加入结果。

这思路没问题,但代码容易写乱。

最终解法:虚拟头节点 + 迭代 ✨

引入一个 dummy(虚拟头节点),用它来串联结果。

list1: 1 -> 3 -> 5 -> null
list2: 2 -> 4 -> 6 -> null

初始化:
dummy -> null
tail = dummy

第一次: 1 < 2, tail.next = 1, tail = 1
结果: dummy -> 1 -> null

第二次: 2 < 3, tail.next = 2, tail = 2
结果: dummy -> 1 -> 2 -> null

...依次类推

代码

public class Solution {
    public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
        // 创建虚拟头节点,简化边界处理
        ListNode dummy = new ListNode(-1);
        ListNode tail = dummy;  // tail 指向已排好序链表的最后一个节点
        
        // 同时遍历两个链表
        while (list1 != null && list2 != null) {
            if (list1.val <= list2.val) {
                tail.next = list1;  // 把较小的节点接到结果后面
                list1 = list1.next;
            } else {
                tail.next = list2;
                list2 = list2.next;
            }
            tail = tail.next;  // tail 前移
        }
        
        // 循环结束后,把剩余的节点直接接上
        // 只需要处理一个链表,另一个已经是 null 了
        tail.next = (list1 != null) ? list1 : list2;
        
        return dummy.next;  // 虚拟头节点的下一个才是真正的头
    }
}

复杂度分析

  • 时间复杂度:O(m + n),每个节点最多遍历一次
  • 空间复杂度:O(1),只用了几个指针

一句话总结

用虚拟头节点串起来,每次挑小的接上去,剩下的直接全接。

6. 两数相加

题目

给你两个非空链表,表示两个非负整数。数字逆序存储,每个节点是一位数字。把两个数相加返回一个新链表。

输入: l1 = [2,4,3], l2 = [5,6,4]
输出: [7,0,8]
解释: 342 + 465 = 807

思路过程

第一次尝试:转成数字再相加

把两个链表转成整数,相加后再转成链表。

问题:数字可能非常长(比如 100 位),超过 long 能表示的范围,会溢出!

最终解法:逐位模拟加法 ✨

和小学竖式加法一样,从低位开始逐位相加,记得进位。

    3 4 2
  + 5 6 4
  ------
    7 0 8
    
逐位:
2 + 4 = 6, 进位 0
4 + 6 = 10, 结果0, 进位1
3 + 5 + 1(进位) = 9, 结果9, 进位0

代码

public class Solution {
    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(0);  // 虚拟头节点
        ListNode curr = dummy;             // 当前节点
        int carry = 0;                     // 进位,0 或 1
        
        while (l1 != null || l2 != null || carry != 0) {
            // 获取当前位的值,没有就取0
            int x = (l1 != null) ? l1.val : 0;
            int y = (l2 != null) ? l2.val : 0;
            
            // 相加,记得加上进位
            int sum = x + y + carry;
            carry = sum / 10;              // 新的进位
            curr.next = new ListNode(sum % 10);  // 当前位的值
            
            // 移动指针
            curr = curr.next;
            if (l1 != null) l1 = l1.next;
            if (l2 != null) l2 = l2.next;
        }
        
        return dummy.next;
    }
}

复杂度分析

  • 时间复杂度:O(max(m, n)),m 和 n 是两个链表的长度
  • 空间复杂度:O(max(m, n)),结果链表的长度

一句话总结

竖式加法从低位算起,注意进位不要丢,链表短了当 0 来算。

7. 删除链表的倒数第N个结点

题目

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

思路过程

第一次尝试:先求长度

先遍历一遍求出链表长度 L,再遍历 L-n 次找到要删除的节点。

问题:需要遍历两遍,能不能一遍搞定?

最终解法:双指针(间隔 n)✨

让一个指针先走 n 步,然后两个指针一起走。当先走的指针到达末尾时,后面的指针正好指向待删除节点的前一个。

删除倒数第2个:
链表: 1 -> 2 -> 3 -> 4 -> 5

第一步: first 先走2步
first: 3 -> 4 -> 5
second: 1 -> 2 -> 3 -> 4 -> 5

第二步: 一起走
first: 5(null)
second: 4 -> 5

此时 second.next 指向待删除的节点(5的前一个4)

代码

public class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode dummy = new ListNode(0);
        dummy.next = head;
        
        ListNode first = dummy;  // 先走的指针
        ListNode second = dummy; // 后走的指针
        
        // 第一步:first 先走 n + 1 步
        // 这样 second 就会停在待删除节点的前一个位置
        for (int i = 0; i <= n; i++) {
            first = first.next;
        }
        
        // 第二步:两个指针一起走,直到 first 到达末尾
        while (first != null) {
            second = second.next;
            first = first.next;
        }
        
        // 此时 second 指向待删除节点的前一个
        second.next = second.next.next;  // 跳过待删除节点
        
        return dummy.next;
    }
}

复杂度分析

  • 时间复杂度:O(L),L 是链表长度,只遍历一次
  • 空间复杂度:O(1),只用了两个指针

一句话总结

快慢指针拉开 n 步距离,一起走到终点,慢指针就停在待删节点前一位。

8. 排序链表

题目

给你链表的头节点,请按升序排列并返回排序后的链表。要求时间复杂度 O(n log n)。

思路过程

第一次尝试:转换成数组排序

把链表转成数组,用 Arrays.sort() 排序,再转回链表。

问题:时间复杂度 O(n log n) 满足,但面试官可能想考察链表归并排序。

最终解法:归并排序(链表版)✨

归并排序的核心是分治:

  1. 找中点:用快慢指针把链表分成两半
  2. 递归排序:分别对两半递归排序
  3. 合并:把两个有序链表合并
原链表: 4 -> 2 -> 1 -> 3

分解:
4 -> 2    |    1 -> 3
  ↓            ↓
排序: 2 -> 4    |    1 -> 3
合并:      1 -> 2 -> 3 -> 4

代码

public class Solution {
    public ListNode sortList(ListNode head) {
        // 递归终止条件:空链表或单节点
        if (head == null || head.next == null) {
            return head;
        }
        
        // 第一步:找链表中点,把链表分成两半
        ListNode mid = getMid(head);
        ListNode left = head;
        ListNode right = mid.next;
        mid.next = null;  // 断开链表
        
        // 第二步:递归排序左右两半
        ListNode sortedLeft = sortList(left);
        ListNode sortedRight = sortList(right);
        
        // 第三步:合并两个有序链表
        return merge(sortedLeft, sortedRight);
    }
    
    // 找链表中点(偶数个返回下中点)
    private ListNode getMid(ListNode head) {
        ListNode slow = head;
        ListNode fast = head.next;  // 注意是 next,这样奇数个停在真正的中点
        
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        return slow;
    }
    
    // 合并两个有序链表
    private ListNode merge(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(0);
        ListNode curr = dummy;
        
        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                curr.next = l1;
                l1 = l1.next;
            } else {
                curr.next = l2;
                l2 = l2.next;
            }
            curr = curr.next;
        }
        
        curr.next = (l1 != null) ? l1 : l2;
        return dummy.next;
    }
}

复杂度分析

  • 时间复杂度:O(n log n),每次二分 O(log n),每层合并 O(n)
  • 空间复杂度:O(log n),递归栈的深度

一句话总结

快慢指针找中点切分,递归排序左右两半,最后合并成一个完整有序链表。

9. 合并K个升序链表

题目

给你一个链表数组,每个链表都是升序排列的。将所有链表合并成一个升序链表返回。

思路过程

第一次尝试:两两合并

依次合并两个链表,最终合并完所有链表。

问题:时间复杂度 O(kN),k 个链表,每个都要遍历多次,太慢了。

方法二:分治合并

两两合并太慢,用分治思想优化!

[1->4->5, 1->3->4, 2->6]

第一轮两两合并:
1->4->5 + 1->3->4 = 1->1->3->4->4->5
2->6 单独
第二轮合并:
1->1->3->4->4->5 + 2->6 = 1->1->2->3->4->4->5->6
方法三:优先队列(推荐)✨

把每个链表的当前节点都放进小根堆,每次取出最小的,把该链表的下一个节点再入堆。

PriorityQueue<ListNode> heap = new PriorityQueue<>(
    (a, b) -> a.val - b.val
);

代码

public class Solution {
    
    // 方法一:分治合并
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists == null || lists.length == 0) {
            return null;
        }
        return mergeSort(lists, 0, lists.length - 1);
    }
    
    private ListNode mergeSort(ListNode[] lists, int left, int right) {
        if (left == right) {
            return lists[left];
        }
        
        int mid = left + (right - left) / 2;
        ListNode leftHead = mergeSort(lists, left, mid);
        ListNode rightHead = mergeSort(lists, mid + 1, right);
        
        return mergeTwoLists(leftHead, rightHead);
    }
    
    // 合并两个有序链表
    private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(0);
        ListNode curr = dummy;
        
        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                curr.next = l1;
                l1 = l1.next;
            } else {
                curr.next = l2;
                l2 = l2.next;
            }
            curr = curr.next;
        }
        
        curr.next = (l1 != null) ? l1 : l2;
        return dummy.next;
    }
    
    
    // 方法二:优先队列
    public ListNode mergeKLists2(ListNode[] lists) {
        if (lists == null || lists.length == 0) {
            return null;
        }
        
        // 小根堆,按节点值排序
        PriorityQueue<ListNode> heap = new PriorityQueue<>(
            (a, b) -> a.val - b.val
        );
        
        // 把每个链表的第一个节点加入堆
        for (ListNode node : lists) {
            if (node != null) {
                heap.offer(node);
            }
        }
        
        ListNode dummy = new ListNode(0);
        ListNode curr = dummy;
        
        // 不断取出堆中最小的节点
        while (!heap.isEmpty()) {
            ListNode smallest = heap.poll();
            curr.next = smallest;
            curr = curr.next;
            
            // 把该节点的下一个节点加入堆
            if (smallest.next != null) {
                heap.offer(smallest.next);
            }
        }
        
        return dummy.next;
    }
}

复杂度分析

  • 时间复杂度:O(N log k),N 是总节点数,k 是链表数

  • 空间复杂度

    • 分治:O(log k) 递归栈
    • 优先队列:O(k) 堆的大小

一句话总结

分治两两合并或用小根堆逐个取最小,都是把「多路归并」问题简化成「二路归并」或「单点选择」。

10. LRU缓存

题目

设计和实现一个 LRU(最近最少使用)缓存机制。

要求:

  • get(key):如果 key 存在返回 value,否则返回 -1
  • put(key, value):如果 key 存在更新 value,如果不存在插入;如果缓存满,淘汰最久未使用的

实现 LRUCache 类,容量为 capacity。

思路过程

第一次尝试:HashMap + ArrayList

用 HashMap 存 key-value,ArrayList 按顺序存访问历史。

问题:ArrayList 删除是 O(n),而且不方便维护「最近使用」的顺序。

最终解法:HashMap + 双向链表

这是经典的 LRU 实现方式:

  • HashMap:O(1) 查找
  • 双向链表:维护访问顺序,头部是最新的,尾部是最久未使用的
缓存结构:
head <-> [最近使用] <-> ... <-> [最久未用] <-> tail

每次访问/插入:
1. 从 HashMap 找到节点
2. 把节点移到链表头部(表示最新使用)

淘汰时:
删除链表尾部节点(最久未用)

代码

public class LRUCache {
    
    // 双向链表节点
    class DLinkedNode {
        int key;
        int value;
        DLinkedNode prev;
        DLinkedNode next;
        
        public DLinkedNode() {}
        public DLinkedNode(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }
    
    private Map<Integer, DLinkedNode> cache;  // HashMap 存数据
    private int size;                          // 当前缓存大小
    private int capacity;                      // 缓存容量
    private DLinkedNode head, tail;            // 虚拟头尾节点
    
    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        this.cache = new HashMap<>();
        
        // 创建虚拟头节点和尾节点,简化边界处理
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head.next = tail;
        tail.prev = head;
    }
    
    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;  // 不存在
        }
        
        // 存在,移动到链表头部(最近使用)
        moveToHead(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        
        if (node == null) {
            // 新节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            cache.put(key, newNode);
            addToHead(newNode);
            size++;
            
            // 如果超出容量,删除最久未用的
            if (size > capacity) {
                DLinkedNode removed = removeTail();
                cache.remove(removed.key);
                size--;
            }
        } else {
            // 已存在,更新值并移到头部
            node.value = value;
            moveToHead(node);
        }
    }
    
    // 添加到链表头部
    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }
    
    // 删除节点
    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    // 移动到头部(先删除,再添加到头部)
    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }
    
    // 删除链表尾部(最久未用的)
    private DLinkedNode removeTail() {
        DLinkedNode removed = tail.prev;
        removeNode(removed);
        return removed;
    }
}

复杂度分析

  • 时间复杂度:O(1),HashMap 查找 O(1),双向链表移动/删除 O(1)
  • 空间复杂度:O(capacity),最多存 capacity 个节点

一句话总结

HashMap 负责快速查找,双向链表负责维护使用顺序,头部最新,尾部最旧,满了就淘汰尾巴。

总结

这些链表题,套路真的很明显:

表格

技巧适用题目
双指针(快慢)环形链表、回文判断、找中点
双指针(间距)删除倒数第 N 个节点
虚拟头节点合并链表、删除节点
反转链表反转、回文判断
归并排序排序链表、合并 K 个链表
哈希 + 双向链表LRU 缓存

链表题最重要的是画图!画图!画图!重要的事情说三遍。纸上画一画,指针怎么走的就清楚了。

下一篇文章我们继续刷「树」相关的题目,敬请期待!

如果你觉得这篇笔记有帮助,欢迎点赞收藏!有问题可以在评论区交流。