1955年,Allen Newell、Cliff Shaw和Herbert A. Simon在RAND Corporation和卡内基梅隆大学开发Information Processing Language(IPL)时,创造了链表这一数据结构。这个发明最初是为了支持早期人工智能程序——Logic Theory Machine和General Problem Solver。有趣的是,链表在诞生之初就是为解决复杂问题而生,而今天它却成了面试中最基础却又最容易出错的考题之一。

链表问题看起来简单:无非是改改指针指向。但正是这种"简单",让无数开发者在面试中栽了跟头。一个指针指向错了,整个链表就断了;忘记保存下一个节点,节点就丢了;边界条件没处理好,空指针异常就来了。这篇文章将系统梳理链表的核心操作模式,从虚拟头节点到快慢指针,从反转链表到环检测,配合LeetCode经典题目的完整Java实现,帮助你彻底掌握这一面试必考的数据结构。

链表的内存真相:为什么它比数组更"难搞"

理解链表的第一步是搞清楚它和数组在内存中的根本区别。数组在内存中是连续存储的,访问第i个元素只需计算一个地址:base_address + i * element_size。这种连续性带来了两个重要后果:一是$O(1)$的随机访问,二是极好的缓存局部性——当CPU读取一个元素时,相邻元素很可能已经被一起加载到缓存行中。

链表则完全不同。每个节点独立存储在堆内存的某个位置,节点之间通过指针链接。这种非连续存储意味着:

  • 随机访问不可能:要找第i个节点,必须从头开始一个一个遍历,时间复杂度$O(n)$
  • 缓存不友好:相邻节点在内存中可能相距甚远,每次访问都可能触发缓存未命中
  • 额外空间开销:每个节点除了存储数据,还需要存储指向下一个节点的指针(单链表)或前后两个指针(双向链表)

单链表结构示意图

图片来源: GeeksforGeeks - Linked List Data Structure

但链表也有其独特优势:在任意位置插入或删除节点只需$O(1)$时间(前提是已经持有该位置的指针),而数组需要移动后续所有元素。这就是为什么链表在需要频繁插入删除的场景中仍然有一席之地。

虚拟头节点:消除边界条件的利器

链表操作中最容易出错的就是处理头节点。删除头节点、在头部插入、处理空链表……每种情况都可能需要特殊处理。虚拟头节点(Dummy Node)是一个不存储有效数据的哨兵节点,它的next指针指向真正的头节点。

看看删除链表中所有值为val的节点(LeetCode 203)这个问题。如果不用虚拟头节点:

public ListNode removeElements(ListNode head, int val) {
    // 先处理头节点可能需要删除的情况
    while (head != null && head.val == val) {
        head = head.next;
    }
    if (head == null) return null;
    
    // 再处理中间节点
    ListNode curr = head;
    while (curr.next != null) {
        if (curr.next.val == val) {
            curr.next = curr.next.next;
        } else {
            curr = curr.next;
        }
    }
    return head;
}

使用虚拟头节点后,代码变得简洁:

public ListNode removeElements(ListNode head, int val) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode curr = dummy;
    
    while (curr.next != null) {
        if (curr.next.val == val) {
            curr.next = curr.next.next;
        } else {
            curr = curr.next;
        }
    }
    return dummy.next;
}

虚拟头节点的价值在于统一了所有节点的处理方式——不再有"头节点需要特殊处理"这个概念。这个技巧在合并链表、删除节点等场景同样适用。

反转链表:指针操作的教科书

反转链表(LeetCode 206)是链表操作的基础,也是面试中出现频率最高的题目之一。有两种经典实现方式。

迭代法:三指针轮转

核心思想是用三个指针prevcurrnext,依次将每个节点的next指针反向:

public ListNode reverseList(ListNode head) {
    ListNode prev = null;
    ListNode curr = head;
    
    while (curr != null) {
        ListNode next = curr.next;  // 保存下一个节点
        curr.next = prev;           // 反转指针
        prev = curr;                // prev前进
        curr = next;                // curr前进
    }
    return prev;
}

时间复杂度$O(n)$,空间复杂度$O(1)$。关键点在于先保存curr.next再修改它,否则会丢失后续链表。

递归法:从后往前

递归的思路是先反转后面的链表,再把当前节点接到末尾:

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

递归法的时间复杂度同样是$O(n)$,但空间复杂度为$O(n)$(递归调用栈)。理解递归的关键是:假设reverseList(head.next)已经正确返回了反转后的链表头,现在只需要把head接到这个链表的末尾。

快慢指针:链表的神器

快慢指针是链表算法中最强大的技巧之一。两个指针从同一位置出发,快指针每次走两步,慢指针每次走一步。这个简单的规则可以解决一系列看似复杂的问题。

找链表中点(LeetCode 876)

当快指针到达链表末尾时,慢指针正好在中间:

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

注意循环条件的顺序:fast != null在前,否则fast.next可能抛出空指针异常。当链表长度为偶数时,这个实现返回右中点(即第二个中间节点)。

环检测(LeetCode 141)

如果链表有环,快指针最终会追上慢指针:

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

为什么快指针一定会追上慢指针?想象两个人在环形跑道上跑步,快的人速度是慢的人的两倍。快的人每跑一圈,就会比慢的人多跑一圈,最终一定会从后面追上慢的人。数学上可以证明:如果有环,快指针最多走$O(n)$步就会与慢指针相遇。

找环的入口(LeetCode 142)

这个问题更进一步:不仅要检测环,还要找到环的入口节点。这需要一点数学推导。

设链表头到环入口的距离为$a$,环入口到相遇点的距离为$b$,环的长度为$c$。当快慢指针相遇时:

  • 慢指针走了$a + b$步
  • 快指针走了$a + b + k \cdot c$步(绕了$k$圈)
  • 快指针步数是慢指针的两倍:$a + b + k \cdot c = 2(a + b)$

化简得:$a = k \cdot c - b$,即$a = (k-1) \cdot c + (c - b)$。

这意味着:从链表头到环入口的距离,等于从相遇点绕$(k-1)$圈后再走$(c-b)$步的距离。代码实现:

public ListNode detectCycle(ListNode head) {
    if (head == null) return null;
    
    ListNode slow = head;
    ListNode fast = head;
    boolean hasCycle = false;
    
    // 第一阶段:检测是否有环
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) {
            hasCycle = true;
            break;
        }
    }
    
    if (!hasCycle) return null;
    
    // 第二阶段:找环入口
    slow = head;
    while (slow != fast) {
        slow = slow.next;
        fast = fast.next;
    }
    return slow;
}

双指针技巧:距离的艺术

快慢指针是双指针的一种特殊形式。双指针更广泛的应用是"距离固定"的场景。

删除倒数第N个节点(LeetCode 19)

直接思路是先遍历得到链表长度,再遍历到倒数第$n+1$个节点进行删除。但双指针可以一次遍历完成:

public ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    
    ListNode fast = dummy;
    ListNode slow = dummy;
    
    // fast先走n步
    for (int i = 0; i < n; i++) {
        fast = fast.next;
    }
    
    // 同时移动,fast到末尾时slow正好在倒数第n+1个位置
    while (fast.next != null) {
        fast = fast.next;
        slow = slow.next;
    }
    
    slow.next = slow.next.next;
    return dummy.next;
}

这里虚拟头节点再次发挥作用:当要删除的是头节点时,没有虚拟头节点就需要特殊处理。

相交链表(LeetCode 160)

判断两个链表是否相交的关键观察是:如果两个链表相交,从交点开始到末尾的部分完全相同。设链表A长度为$a$,链表B长度为$b$,公共部分长度为$c$。

技巧是让两个指针分别从A和B出发,走到末尾后跳到另一个链表继续走。每个指针都走$a + b - c$步,如果有交点,它们会在交点相遇:

public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
    if (headA == null || headB == null) return null;
    
    ListNode pA = headA;
    ListNode pB = headB;
    
    while (pA != pB) {
        pA = (pA == null) ? headB : pA.next;
        pB = (pB == null) ? headA : pB.next;
    }
    return pA;
}

如果没有交点,两个指针会同时到达null,循环也会终止。

复合操作:多个技巧的组合

很多链表题目需要组合使用多种技巧。

回文链表(LeetCode 234)

要求$O(n)$时间和$O(1)$空间的解法,需要组合三个步骤:找中点、反转后半部分、比较:

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;
    }
    
    // 第二步:反转后半部分
    ListNode secondHalf = reverseList(slow.next);
    
    // 第三步:比较
    ListNode p1 = head;
    ListNode p2 = secondHalf;
    boolean result = true;
    while (p2 != null) {
        if (p1.val != p2.val) {
            result = false;
            break;
        }
        p1 = p1.next;
        p2 = p2.next;
    }
    
    // 可选:恢复链表
    slow.next = reverseList(secondHalf);
    
    return result;
}

private ListNode reverseList(ListNode head) {
    ListNode prev = null;
    ListNode curr = head;
    while (curr != null) {
        ListNode next = curr.next;
        curr.next = prev;
        prev = curr;
        curr = next;
    }
    return prev;
}

注意找中点时用fast.next != null && fast.next.next != null,这样当链表长度为偶数时,slow停在左中点,方便后续处理。

重排链表(LeetCode 143)

将链表从$L_0 \to L_1 \to \cdots \to L_{n-1} \to L_n$重排为$L_0 \to L_n \to L_1 \to L_{n-1} \to \cdots$:

public void reorderList(ListNode head) {
    if (head == null || head.next == null) return;
    
    // 第一步:找中点
    ListNode slow = head;
    ListNode fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    
    // 第二步:反转后半部分
    ListNode secondHalf = reverseList(slow.next);
    slow.next = null;  // 断开两半
    
    // 第三步:交替合并
    ListNode p1 = head;
    ListNode p2 = secondHalf;
    while (p2 != null) {
        ListNode next1 = p1.next;
        ListNode next2 = p2.next;
        p1.next = p2;
        p2.next = next1;
        p1 = next1;
        p2 = next2;
    }
}

排序链表(LeetCode 148)

要求$O(n \log n)$时间和$O(1)$空间,归并排序是唯一选择。关键在于如何$O(1)$空间找到中点并分割:

public ListNode sortList(ListNode head) {
    if (head == null || head.next == null) return head;
    
    // 找中点并断开
    ListNode slow = head;
    ListNode fast = head.next;  // 让slow停在左中点
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    ListNode mid = slow.next;
    slow.next = null;
    
    // 递归排序两半
    ListNode left = sortList(head);
    ListNode right = sortList(mid);
    
    // 合并
    return merge(left, right);
}

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

K个一组翻转链表(LeetCode 25)

这是反转链表的进阶版本,需要处理剩余不足k个的情况:

public ListNode reverseKGroup(ListNode head, int k) {
    // 检查剩余是否有k个节点
    ListNode curr = head;
    int count = 0;
    while (curr != null && count < k) {
        curr = curr.next;
        count++;
    }
    
    if (count < k) return head;  // 不足k个,不翻转
    
    // 翻转前k个
    ListNode prev = null;
    ListNode curr2 = head;
    for (int i = 0; i < k; i++) {
        ListNode next = curr2.next;
        curr2.next = prev;
        prev = curr2;
        curr2 = next;
    }
    
    // head现在是翻转后的尾节点,递归处理剩余部分
    head.next = reverseKGroup(curr2, k);
    
    return prev;  // prev是翻转后的头节点
}

特殊场景:随机指针与复杂结构

复制带随机指针的链表(LeetCode 138)

这道题的难点在于:复制节点时,随机指针可能指向尚未创建的节点。有两种解法。

哈希表法需要$O(n)$额外空间:

public Node copyRandomList(Node head) {
    if (head == null) return null;
    
    Map<Node, Node> map = new HashMap<>();
    
    // 第一遍:创建所有节点
    Node curr = head;
    while (curr != null) {
        map.put(curr, new Node(curr.val));
        curr = curr.next;
    }
    
    // 第二遍:设置指针
    curr = head;
    while (curr != null) {
        map.get(curr).next = map.get(curr.next);
        map.get(curr).random = map.get(curr.random);
        curr = curr.next;
    }
    
    return map.get(head);
}

$O(1)$空间解法的巧妙之处在于将新节点"穿插"在原链表中:

public Node copyRandomList(Node head) {
    if (head == null) return null;
    
    // 第一步:在每个原节点后插入复制节点
    Node curr = head;
    while (curr != null) {
        Node copy = new Node(curr.val);
        copy.next = curr.next;
        curr.next = copy;
        curr = copy.next;
    }
    
    // 第二步:设置random指针
    curr = head;
    while (curr != null) {
        if (curr.random != null) {
            curr.next.random = curr.random.next;
        }
        curr = curr.next.next;
    }
    
    // 第三步:分离两个链表
    Node copyHead = head.next;
    curr = head;
    while (curr != null) {
        Node copy = curr.next;
        curr.next = copy.next;
        if (copy.next != null) {
            copy.next = copy.next.next;
        }
        curr = curr.next;
    }
    
    return copyHead;
}

链表问题的通用解题思路

  1. 画图:链表操作最容易出错的就是指针丢失。在写代码前,先画出节点和指针的变化过程。

  2. 虚拟头节点:当操作可能影响头节点时,先创建虚拟头节点。

  3. 保存后续节点:修改next指针前,先保存原来的next

  4. 处理边界条件:空链表、单节点链表、双节点链表是测试的重点。

  5. 快慢指针场景识别:中点、环、倒数第n个,都是快慢指针的典型应用。

  6. 组合思维:复杂问题往往是多个基础操作的组合——找中点+反转+合并。

链表算法的核心不是背诵代码模板,而是理解指针操作的底层逻辑。每一步赋值操作都要问自己:这个指针原来的值是什么?新值从哪里来?原来的值还需要吗?养成这种思维习惯,链表问题就不再是难题。


参考题目索引

题号 题目名称 核心技巧
206 反转链表 双指针、递归
141 环形链表 快慢指针
142 环形链表II 快慢指针+数学推导
21 合并两个有序链表 双指针、递归
19 删除链表的倒数第N个节点 双指针、虚拟头节点
876 链表的中间节点 快慢指针
160 相交链表 双指针
234 回文链表 快慢指针+反转
143 重排链表 快慢指针+反转+合并
148 排序链表 归并排序+快慢指针
25 K个一组翻转链表 反转+递归
138 复制带随机指针的链表 哈希表/穿插法
2 两数相加 模拟加法
203 移除链表元素 虚拟头节点
86 分隔链表 双链表法
61 旋转链表 双指针+成环
328 奇偶链表 双指针分离
707 设计链表 综合实现

参考资料

  1. Wikipedia - Linked list: https://en.wikipedia.org/wiki/Linked_list
  2. GeeksforGeeks - Linked List Data Structure: https://www.geeksforgeeks.org/dsa/linked-list-data-structure/
  3. GeeksforGeeks - Reverse a Linked List: https://www.geeksforgeeks.org/dsa/reverse-a-linked-list/
  4. GeeksforGeeks - Floyd’s Cycle Finding Algorithm: https://www.geeksforgeeks.org/dsa/floyds-cycle-finding-algorithm/
  5. LeetCode Discuss - Linked List Study Guide: https://leetcode.com/discuss/post/2725900/linked-list-study-guide-by-sunyingbao-yi2w/
  6. Tech Interview Handbook - Linked List: https://www.techinterviewhandbook.org/algorithms/linked-list/
  7. NeetCode - LinkedList Solutions: https://neetcode.io/
  8. CP-Algorithms - Tortoise and Hare Algorithm: https://cp-algorithms.com/others/tortoise_and_hare.html