算法面试突击手册 —— 链表


一、概念与特点

大白话 + 类比

链表 = 一列人手拉手

每个人只知道前面是谁(单链表)或前面+后面都知道(双向链表)。要插入一个人,只需让旁边两人重新拉手即可(O(1));但要找到第 100 个人,必须从第 1 个人开始数(O(n))。

链表题目不像数组那样靠下标硬算,而是靠指针的灵活操控。核心能力就是”画图 + 把指针指来指去不出错”。

核心特征(什么时候该想到用它)

特征说明
不需要随机访问只关心顺序,不关心下标
频繁插入/删除O(1) 操作,只需改指针
LRU / 缓存HashMap + 双向链表
翻转、合并、找环链表经典题型

识别信号词

  • “链表”、“结点” → 直接是链表题
  • “O(1) 空间”、“原地” → 用指针操作而非额外数组
  • “反转”、“翻转” → pre/cur/nxt 三指针
  • “环”、“是否循环” → 快慢指针
  • “倒数第 N 个” → 双指针间隔 N
  • “合并”、“K 个一组” → dummy 节点 + 模拟

链表题必备技巧

技巧用途
dummy 节点简化头节点操作,统一逻辑
快慢指针找中点、判圈、倒数第 K
pre/cur/nxt 三指针反转链表
画图所有链表题开始写代码前先画图

二、应用场景

链表节点定义(假设题目给的)

public class ListNode {
    int val;
    ListNode next;
    ListNode() {}
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}

三、Hot 100 精讲题目


206. Reverse Linked List 反转链表

📌 反转一个单链表。如 1→2→3→4→55→4→3→2→1

🔍 解题分析

核心思路:三指针 pre(前一个)、cur(当前)、nxt(下一个)。每一步让 cur 指向 pre,然后三人组集体右移。

✅ 完整 Java 实现

public ListNode reverseList(ListNode head) {
    ListNode pre = null;  // 前一个节点(反转后 cur 要指向的人)
    ListNode cur = head;  // 当前正在反转的节点

    while (cur != null) {
        ListNode nxt = cur.next; // 先记住下一个,免得断链后找不到了
        cur.next = pre;          // 反转:当前节点指向前一个
        pre = cur;               // 三人组右移
        cur = nxt;
    }

    return pre; // 反转后 pre 就是新的头节点
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“pre cur nxt 三人组,cur 指 pre,然后全右移”


160. Intersection of Two Linked Lists 相交链表

📌 找到两个单链表相交的起始节点。不相交返回 null。

🔍 解题分析

核心思路(浪漫相遇):双指针 pA 和 pB 分别从两个头出发。pA 走到 A 的末尾后跳到 B 的头继续走;pB 同理。两人走过的总路程相等(A 独有 + B 独有 + 公共部分),必在相交点相遇。

类比:两个人走不同的路,但最终都走了相同的总路程,一定同时到达相遇点。

✅ 完整 Java 实现

public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
    if (headA == null || headB == null) return null;

    ListNode pA = headA, pB = headB;

    // pA 和 pB 走同样的总路程,要么在交点相遇,要么同时为 null
    while (pA != pB) {
        pA = (pA == null) ? headB : pA.next;
        pB = (pB == null) ? headA : pB.next;
    }

    return pA; // 可能是交点,也可能是 null
}

⏱ 复杂度分析

  • 时间复杂度:O(m + n)
  • 空间复杂度:O(1)

💡 记忆口诀

“走完自己的路,去走对方的路,终会相遇”


234. Palindrome Linked List 回文链表

📌 判断链表是否为回文链表。要求 O(n) 时间,O(1) 空间。

🔍 解题分析

核心思路:快慢指针找中点 → 反转后半段 → 前后对比 → (可选)恢复链表。

步骤

  1. 快慢指针找中点(偶数个时慢指针停在左中点)
  2. 反转后半段链表
  3. 前后双指针同时走,逐个比较
  4. 比较完后可恢复链表(反转回来)

✅ 完整 Java 实现

public boolean isPalindrome(ListNode head) {
    if (head == null || head.next == null) return true;

    // 1. 快慢指针找中点
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    // slow 现在指向左中点

    // 2. 反转后半段
    ListNode secondHalf = reverseList(slow.next);

    // 3. 比较前后两半
    ListNode p1 = head, p2 = secondHalf;
    boolean isPalindrome = true;
    while (p2 != null) {
        if (p1.val != p2.val) {
            isPalindrome = false;
            break;
        }
        p1 = p1.next;
        p2 = p2.next;
    }

    // 4. 恢复链表(可选,但面试建议做)
    slow.next = reverseList(secondHalf);

    return isPalindrome;
}

// 复用 206 题的反转函数
private ListNode reverseList(ListNode head) {
    ListNode pre = null, cur = head;
    while (cur != null) {
        ListNode nxt = cur.next;
        cur.next = pre;
        pre = cur;
        cur = nxt;
    }
    return pre;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“中点切开,后半反转,同步对比”


141. Linked List Cycle 环形链表

📌 判断链表是否有环。

🔍 解题分析

快慢指针(Floyd 判圈):快指针每次走 2 步,慢指针每次走 1 步。如果有环,快慢一定会相遇;如果没环,快指针会走到 null。

类比:就像操场跑步,跑得快的迟早会套圈跑得慢的。

✅ 完整 Java 实现

public boolean hasCycle(ListNode head) {
    if (head == null || head.next == null) return false;

    ListNode slow = head, fast = head;

    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true; // 相遇了,有环
    }

    return false; // fast 走到 null,没环
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“一快一慢,相遇就有环”


142. Linked List Cycle II 环形链表 II

📌 找到环的入口节点。无环返回 null。

🔍 解题分析

核心思路(数学推导)

  1. 快慢指针相遇(同 141 题)
  2. 相遇后,一个指针回到 head,两个指针同速前进
  3. 再次相遇的点就是环的入口

为什么:设 head 到入口距离 = a,入口到相遇点距离 = b,环剩余长度 = c。相遇时 slow 走了 a+b,fast 走了 a+b+n(b+c)。因为 fast 速度是 slow 两倍:2(a+b) = a+b+n(b+c)a = (n-1)(b+c) + c。所以从 head 走的和从相遇点走的,各走 a 步后必在入口相遇。

✅ 完整 Java 实现

public ListNode detectCycle(ListNode head) {
    if (head == null || head.next == null) return null;

    // 1. 快慢指针找相遇点
    ListNode slow = head, fast = head;
    ListNode meetPoint = null;

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

    if (meetPoint == null) return null; // 无环

    // 2. head 和相遇点同速前进,再次相遇即入口
    ListNode p1 = head, p2 = meetPoint;
    while (p1 != p2) {
        p1 = p1.next;
        p2 = p2.next;
    }

    return p1;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“相遇后一人回起点,两人同速走到相遇就是入口”


21. Merge Two Sorted Lists 合并两个有序链表

📌 合并两个升序链表为一个升序链表。

🔍 解题分析

核心思路:归并的链表版。dummy 节点统一逻辑,cur 尾插。每次取两个头中较小的接到 cur 后面。

✅ 完整 Java 实现

public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
    ListNode dummy = new ListNode(0); // 哨兵节点,简化头操作
    ListNode cur = dummy;

    while (list1 != null && list2 != null) {
        if (list1.val <= list2.val) {
            cur.next = list1;
            list1 = list1.next;
        } else {
            cur.next = list2;
            list2 = list2.next;
        }
        cur = cur.next;
    }

    // 剩下的直接接上去
    cur.next = (list1 != null) ? list1 : list2;

    return dummy.next;
}

⏱ 复杂度分析

  • 时间复杂度:O(m + n)
  • 空间复杂度:O(1)

💡 记忆口诀

“dummy 坐镇,谁小接谁,剩下的直接挂”


2. Add Two Numbers 两数相加

📌 两个非空链表表示两个逆序存储的数字(如 2→4→3 表示 342),求和并返回同样格式的链表。

🔍 解题分析

核心思路:模拟竖式加法。逐位相加,维护进位 carry。注意处理最后可能的进位。

✅ 完整 Java 实现

public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(0);
    ListNode cur = dummy;
    int carry = 0; // 进位

    while (l1 != null || l2 != null || carry != 0) {
        int sum = carry;

        if (l1 != null) {
            sum += l1.val;
            l1 = l1.next;
        }
        if (l2 != null) {
            sum += l2.val;
            l2 = l2.next;
        }

        cur.next = new ListNode(sum % 10); // 当前位
        carry = sum / 10;                  // 进位
        cur = cur.next;
    }

    return dummy.next;
}

⏱ 复杂度分析

  • 时间复杂度:O(max(m, n))
  • 空间复杂度:O(max(m, n)),新链表

💡 记忆口诀

“进位 carry 随身带,逐位相加不用怕”


19. Remove Nth Node From End of List 删除链表倒数第 N 个结点

📌 删除链表倒数第 n 个节点。如 [1,2,3,4,5], n=2[1,2,3,5]

🔍 解题分析

核心思路:双指针间隔 n。fast 先走 n 步,然后 fast 和 slow 一起走。fast 到末尾时,slow 正好在倒数第 n 个节点的前一个位置,方便删除。

✅ 完整 Java 实现

public ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode fast = dummy, slow = dummy;

    // fast 先走 n+1 步(让 slow 停在待删节点的前一个)
    for (int i = 0; i <= n; i++) {
        fast = fast.next;
    }

    // fast 和 slow 一起走到 fast 到末尾
    while (fast != null) {
        fast = fast.next;
        slow = slow.next;
    }

    // slow.next 就是要删的节点
    slow.next = slow.next.next;

    return dummy.next;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“fast 先跑 n 步,两人再同跑,slow 就到了要删的前一个”


24. Swap Nodes in Pairs 两两交换链表中的节点

📌 每两个相邻节点交换。如 1→2→3→42→1→4→3。不能直接改值。

🔍 解题分析

核心思路:三个指针 pre → node1 → node2。交换 node1 和 node2,然后 pre 前进两步。

✅ 完整 Java 实现

public ListNode swapPairs(ListNode head) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode pre = dummy; // pre 指向每对的前一个节点

    while (pre.next != null && pre.next.next != null) {
        ListNode node1 = pre.next;
        ListNode node2 = node1.next;

        // 交换:pre → node2 → node1 → node2.next
        pre.next = node2;
        node1.next = node2.next;
        node2.next = node1;

        // pre 前进到下一对的前一个位置
        pre = node1;
    }

    return dummy.next;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“pre 坐镇看 node1 和 node2 交换,换完 pre 跳两步”


25. Reverse Nodes in k-Group K 个一组翻转链表

📌 每 k 个节点一组进行翻转。如 [1,2,3,4,5], k=2[2,1,4,3,5](不足 k 个不翻转)

🔍 解题分析

核心思路

  1. 先检查剩余节点是否够 k 个,不够就不翻转
  2. 够的话,反转当前组内的 k 个节点
  3. 处理好前一组尾 → 当前组头,当前组尾 → 下一组头的连接

类比:就像翻扑克牌,每 k 张翻一次。翻之前先数清楚有没有 k 张。

✅ 完整 Java 实现

public ListNode reverseKGroup(ListNode head, int k) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode pre = dummy;   // 上一组的尾节点

    while (head != null) {
        // 1. 检查剩余节点是否够 k 个
        ListNode tail = pre;
        for (int i = 0; i < k; i++) {
            tail = tail.next;
            if (tail == null) return dummy.next; // 不够 k 个,结束
        }

        // 2. 翻转当前组 [head, tail]
        ListNode nextGroupHead = tail.next; // 下一组的头
        ListNode[] reversed = reverseRange(head, tail);

        // 3. 连接:pre → 新头,新尾 → 下一组头
        pre.next = reversed[0];
        reversed[1].next = nextGroupHead;

        // 4. pre 和 head 前进到下一组
        pre = reversed[1];
        head = nextGroupHead;
    }

    return dummy.next;
}

// 翻转从 head 到 tail 的链表段,返回 [新头, 新尾]
private ListNode[] reverseRange(ListNode head, ListNode tail) {
    ListNode pre = tail.next; // 反转后 head 要指向 tail.next
    ListNode cur = head;

    while (pre != tail) {
        ListNode nxt = cur.next;
        cur.next = pre;
        pre = cur;
        cur = nxt;
    }

    return new ListNode[]{tail, head}; // 反转后 tail 是新头,head 是新尾
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“先数够 K 个,再翻这一段,首尾接好继续走”


138. Copy List with Random Pointer 随机链表的复制

📌 链表节点除 next 指针外,还有一个 random 指针指向任意节点或 null。深拷贝这个链表。

🔍 解题分析

方法一:HashMap 映射(推荐面试先讲)

  • 第一遍:创建新节点,建立 oldNode → newNode 的映射
  • 第二遍:根据映射设置新节点的 next 和 random

方法二:节点插入法(O(1) 空间)

  • 在每个原节点后面插入新节点(A→A’→B→B’→C→C’)
  • 设置 random:A'.random = A.random.next
  • 拆分链表

✅ 完整 Java 实现

// 方法一:HashMap(清晰易懂,面试推荐)
public Node copyRandomList(Node head) {
    if (head == null) return null;

    // 第一遍:创建新节点 + 建立映射
    Map<Node, Node> map = new HashMap<>();
    Node cur = head;
    while (cur != null) {
        map.put(cur, new Node(cur.val));
        cur = cur.next;
    }

    // 第二遍:设置 next 和 random
    cur = head;
    while (cur != null) {
        Node newNode = map.get(cur);
        newNode.next = map.get(cur.next);     // cur.next 可能为 null,map.get(null)=null
        newNode.random = map.get(cur.random); // cur.random 可能为 null
        cur = cur.next;
    }

    return map.get(head);
}

// 方法二:O(1) 空间
public Node copyRandomList_O1(Node head) {
    if (head == null) return null;

    // 1. 插入新节点:A → A' → B → B' → C → C'
    Node cur = head;
    while (cur != null) {
        Node newNode = new Node(cur.val);
        newNode.next = cur.next;
        cur.next = newNode;
        cur = newNode.next;
    }

    // 2. 设置 random 指针
    cur = head;
    while (cur != null) {
        if (cur.random != null) {
            cur.next.random = cur.random.next;
        }
        cur = cur.next.next;
    }

    // 3. 拆分链表
    Node dummy = new Node(0);
    Node copyCur = dummy;
    cur = head;
    while (cur != null) {
        copyCur.next = cur.next;    // A'
        copyCur = copyCur.next;
        cur.next = cur.next.next;   // 恢复原链表 A → B
        cur = cur.next;
    }

    return dummy.next;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:HashMap 方法 O(n),节点插入法 O(1)

💡 记忆口诀

“先映射新旧,再穿针引线”


23. Merge k Sorted Lists 合并 K 个升序链表

📌 合并 k 个有序链表为一个。如 [[1,4,5],[1,3,4],[2,6]][1,1,2,3,4,4,5,6]

🔍 解题分析

方法一:归并法(推荐)

  • 两两合并,使用 21 题的 mergeTwoLists
  • 分治思想:第一轮 k/2 对,第二轮 k/4 对……

方法二:堆(见 14-堆

✅ 完整 Java 实现

// 归并法
public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) return null;
    return mergeSort(lists, 0, lists.length - 1);
}

// 分治合并 lists[left..right]
private ListNode mergeSort(ListNode[] lists, int left, int right) {
    if (left == right) return lists[left];
    int mid = left + (right - left) / 2;
    ListNode l1 = mergeSort(lists, left, mid);
    ListNode l2 = mergeSort(lists, mid + 1, right);
    return mergeTwoLists(l1, l2);
}

// 复用 21 题
private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(0);
    ListNode cur = dummy;
    while (l1 != null && l2 != null) {
        if (l1.val <= l2.val) {
            cur.next = l1;
            l1 = l1.next;
        } else {
            cur.next = l2;
            l2 = l2.next;
        }
        cur = cur.next;
    }
    cur.next = (l1 != null) ? l1 : l2;
    return dummy.next;
}

⏱ 复杂度分析

  • 时间复杂度:O(N log k),N 为总节点数,k 为链表数
  • 空间复杂度:O(log k)(递归栈)

💡 记忆口诀

“两两合并用分治,logK 层每层 N”


146. LRU Cache LRU 缓存

📌 设计并实现 LRU(最近最少使用)缓存。get 和 put 需 O(1)。

🔍 解题分析

核心数据结构:HashMap + 双向链表。

  • HashMap:key → 链表节点,实现 O(1) 查找
  • 双向链表:维护访问顺序,头部是最近使用的,尾部是最久未使用的

操作

  • get(key):HashMap 查节点 → 把节点移到链表头 → 返回值
  • put(key, value)
    • 如果 key 存在:更新值,移到链表头
    • 如果 key 不存在:新建节点放链表头;若超容量,删掉链表尾节点

类比:就像手机后台应用。最近用的排在最前面;内存不够时,杀最后面的进程。

✅ 完整 Java 实现

class LRUCache {
    // 双向链表节点
    private static class Node {
        int key, value;
        Node prev, next;
        Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    private final int capacity;
    private final Map<Integer, Node> map;
    private final Node head; // 哨兵头(最近使用)
    private final Node tail; // 哨兵尾(最久未使用)

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<>();

        // 初始化哨兵节点
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        if (!map.containsKey(key)) return -1;
        Node node = map.get(key);
        moveToHead(node);   // 刚访问过,移到队头
        return node.value;
    }

    public void put(int key, int value) {
        if (map.containsKey(key)) {
            Node node = map.get(key);
            node.value = value;
            moveToHead(node);
        } else {
            Node node = new Node(key, value);
            map.put(key, node);
            addToHead(node);

            if (map.size() > capacity) {
                // 移除最久未使用的(队尾前一个)
                Node removed = removeTail();
                map.remove(removed.key);
            }
        }
    }

    // ---- 双向链表基本操作 ----

    private void addToHead(Node node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    private Node removeTail() {
        Node last = tail.prev;
        removeNode(last);
        return last;
    }
}

⏱ 复杂度分析

  • 时间复杂度:get 和 put 均为 O(1)
  • 空间复杂度:O(capacity)

💡 记忆口诀

“HashMap 管查找,双向链表管顺序,用了就放最前面,满了就删最后面”


🔜 上一章:07-排序 | 下一章:09-二叉树