链表综合运用:大数加法、重排链表、随机指针与 LRU 缓存

摘要

本篇是线性表专栏的收官篇。四道题综合运用了链表的所有基础技巧:LeetCode 2(两数相加)是进位模拟;LeetCode 143(重排链表)综合了快慢指针找中点 + 反转链表 + 合并有序思路;LeetCode 138(复制带随机指针的链表)考察深拷贝中的指针重建;LeetCode 146(LRU 缓存)是面试重点题,考察 HashMap + 双向链表 的工程实现,要求 getput 操作均为


第 1 章 LeetCode 2:两数相加

1.1 题目

LeetCode 2 - Add Two Numbers(中等)

给你两个非空的链表,表示两个非负的整数。它们每位数字都是按照逆序方式存储的,并且每个节点只能存储一位数字。请你将两个数相加,并以相同形式返回一个表示和的链表。

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

输入: l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
输出: [8,9,9,9,0,0,0,1](9999999 + 9999 = 10009998)

1.2 进位模拟

关键:链表已按逆序存储(个位在头),直接从头开始相加即是从低位到高位,模拟手工加法。

处理三种情况

  • l1l2 长度不同
  • 最高位相加后有进位(结果链表需要额外节点)
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(-1);
    ListNode curr = dummy;
    int carry = 0; // 进位
    
    while (l1 != null || l2 != null || carry != 0) {
        int a = (l1 != null) ? l1.val : 0;
        int b = (l2 != null) ? l2.val : 0;
        
        int sum = a + b + 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;
}

关键细节:循环条件是 l1 != null || l2 != null || carry != 0,三个条件任意一个满足就继续循环。carry != 0 确保最高位有进位时(如 999 + 1 = 1000)额外生成进位节点。

手动验证[9,9,9,9,9,9,9] + [9,9,9,9]):

位1: 9+9+0=18, carry=1, 节点8
位2: 9+9+1=19, carry=1, 节点9
位3: 9+9+1=19, carry=1, 节点9
位4: 9+9+1=19, carry=1, 节点9
位5: 9+0+1=10, carry=1, 节点0
位6: 9+0+1=10, carry=1, 节点0
位7: 9+0+1=10, carry=1, 节点0
carry=1: 节点1

结果: 8→9→9→9→0→0→0→1 ✓

复杂度:时间 ,空间 (结果链表)。


第 2 章 LeetCode 143:重排链表

2.1 题目

LeetCode 143 - Reorder List(中等)

给定一个单链表 L

请你将其重新排列为:

不能只是单纯地改变节点内部的值,而是需要实际的进行节点交换。

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

2.2 三步法

观察:重排后的链表,是原链表前半段和后半段(倒序)的交叉合并。

策略

  1. 找中点:用快慢指针找到链表中点
  2. 反转后半段:将中点之后的节点反转
  3. 合并:将前半段和反转后的后半段交叉合并
public void reorderList(ListNode head) {
    if (head == null || head.next == null) return;
    
    // 第一步:快慢指针找中点
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    // slow 现在是前半段的最后一个节点
    
    // 第二步:反转后半段
    ListNode secondHalf = slow.next;
    slow.next = null; // 断开前后两段
    secondHalf = reverseList(secondHalf);
    
    // 第三步:交叉合并
    ListNode first = head, second = secondHalf;
    while (second != null) {
        ListNode firstNext = first.next;
        ListNode secondNext = second.next;
        
        first.next = second;
        second.next = firstNext;
        
        first = firstNext;
        second = secondNext;
    }
}
 
private ListNode reverseList(ListNode head) {
    ListNode prev = null, curr = head;
    while (curr != null) {
        ListNode next = curr.next;
        curr.next = prev;
        prev = curr;
        curr = next;
    }
    return prev;
}

手动验证[1,2,3,4,5]):

第一步:找中点
  slow=1, fast=1
  step1: slow=2, fast=3
  step2: slow=3, fast=5(fast.next.next==null,停止)
  slow=3,前半段 1→2→3

第二步:反转后半段
  secondHalf = 4→5
  3.next = null(断开)
  反转 4→5 → 5→4

第三步:合并
  first=1, second=5
  
  iter1: firstNext=2, secondNext=4
    1.next=5, 5.next=2
    first=2, second=4
  
  iter2: firstNext=3, secondNext=null
    2.next=4, 4.next=3
    first=3, second=null
  
  second==null,停止

结果: 1→5→2→4→3 ✓

复杂度:时间 ,空间


第 3 章 LeetCode 138:复制带随机指针的链表

3.1 题目

LeetCode 138 - Copy List with Random Pointer(中等)

给你一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 random,该指针可以指向链表中的任何节点或空节点。

构造这个链表的深拷贝。深拷贝应该由完全新的节点组成,新节点的 valnextrandom 都与原链表中对应节点相同,但 nextrandom 指针不能指向原链表中的节点,而应指向新链表中的节点。

class Node {
    int val;
    Node next;
    Node random; // 可以指向链表中任意节点或 null
}

3.2 方法一:哈希表(直观, 空间)

思路:用哈希表建立”原节点 → 新节点”的映射,两遍扫描:

  • 第一遍:创建所有新节点,建立映射
  • 第二遍:根据映射设置 nextrandom
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;
    }
    
    // 第二遍:设置 next 和 random
    curr = head;
    while (curr != null) {
        map.get(curr).next = map.get(curr.next);     // next 可以是 null
        map.get(curr).random = map.get(curr.random); // random 可以是 null
        curr = curr.next;
    }
    
    return map.get(head);
}

复杂度:时间 ,空间 (哈希表)。

3.3 方法二:节点交织( 空间)

思路:不用哈希表,而是将新节点”穿插”到原节点后面,利用相对位置关系来设置 random,最后分离两条链表。

三步

  1. 交织:在每个原节点后面插入对应的新节点(A → A' → B → B' → ...
  2. 设置 random:此时 A.next 就是 A'A.random.next 就是 A'.random
  3. 分离:还原原链表,提取新链表
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; // 新节点的 next 指向原节点的下一个
        curr.next = copy;       // 原节点的 next 指向新节点
        curr = copy.next;       // 移动到下一个原节点
    }
    // 现在: A→A'→B→B'→C→C'→null
    
    // 第二步:设置新节点的 random
    curr = head;
    while (curr != null) {
        if (curr.random != null) {
            curr.next.random = curr.random.next; // A'.random = A.random.next = B'
        }
        curr = curr.next.next; // 跳到下一个原节点
    }
    
    // 第三步:分离两条链表
    Node newHead = head.next;
    curr = head;
    while (curr != null) {
        Node copy = curr.next;
        curr.next = copy.next; // 还原原链表
        copy.next = (copy.next != null) ? copy.next.next : null; // 设置新链表的 next
        curr = curr.next;
    }
    
    return newHead;
}

关键推导:在交织阶段,原节点 A 和其拷贝 A' 相邻(A.next = A')。设 A.random = B,则 A'.random 应该是 B',而 B' = B.next(因为 B.next = B')。所以 A'.random = A.random.next

复杂度:时间 ,空间 (不计输出)。


第 4 章 LeetCode 146:LRU 缓存

4.1 题目

LeetCode 146 - LRU Cache(中等)

实现 LRUCache 类:

  • LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value;如果不存在,则向缓存中插入该组 key-value。如果插入操作导致关键字数量超过 capacity,则应该逐出最久未使用的关键字

getput 必须以 的平均时间复杂度运行。

4.2 数据结构设计

get:哈希表 key → Node,直接定位节点

put + LRU 淘汰:需要记录”访问顺序”,且能 删除任意节点。

解决方案:双向链表(Doubly Linked List)

  • 链表按访问顺序排列:头部是最近使用的,尾部是最久未使用的
  • 每次访问(get 或 put)时,将对应节点移到链表头部
  • 容量满时,删除链表尾部节点(最久未使用)

为什么必须是双向链表?

删除一个节点需要 时间,必须能快速找到其前驱节点。单向链表找前驱需要 ,双向链表直接通过 prev 指针找到前驱,删除是

HashMap: key → DLinkedNode
双向链表: dummyHead ↔ [最近] ↔ ... ↔ [最久] ↔ dummyTail

4.3 代码实现

class LRUCache {
    
    // 双向链表节点
    private static class DLinkedNode {
        int key, value;
        DLinkedNode prev, next;
        DLinkedNode(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }
    
    private Map<Integer, DLinkedNode> cache;
    private int capacity;
    private DLinkedNode head, tail; // 哑头和哑尾
    
    public LRUCache(int capacity) {
        this.capacity = capacity;
        cache = new HashMap<>();
        
        // 初始化哑节点
        head = new DLinkedNode(0, 0);
        tail = new DLinkedNode(0, 0);
        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);
            
            if (cache.size() > capacity) {
                // 超出容量,删除最久未使用的节点(链表尾部)
                DLinkedNode removed = removeTail();
                cache.remove(removed.key); // 同时从哈希表删除
            }
        } else {
            // 更新值,移到头部
            node.value = value;
            moveToHead(node);
        }
    }
    
    // 将节点插入到链表头部(head 之后)
    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);
    }
    
    // 删除链表尾部节点(tail 之前的节点,即最久未使用的)
    private DLinkedNode removeTail() {
        DLinkedNode tailNode = tail.prev;
        removeNode(tailNode);
        return tailNode;
    }
}

4.4 操作演示

示例capacity = 2

put(1, 1): cache={1:1}, 链表: H↔[1,1]↔T
put(2, 2): cache={1:1,2:2}, 链表: H↔[2,2]↔[1,1]↔T
get(1):    访问key=1,移到头部,返回1
           cache={1:1,2:2}, 链表: H↔[1,1]↔[2,2]↔T
put(3, 3): 容量满,删除尾部[2,2],插入[3,3]
           cache={1:1,3:3}, 链表: H↔[3,3]↔[1,1]↔T
get(2):    key=2不存在,返回-1
put(4, 4): 容量满,删除尾部[1,1],插入[4,4]
           cache={3:3,4:4}, 链表: H↔[4,4]↔[3,3]↔T
get(1):    -1
get(3):    返回3,移到头部,链表: H↔[3,3]↔[4,4]↔T
get(4):    返回4,移到头部,链表: H↔[4,4]↔[3,3]↔T

4.5 复杂度与工程意义

复杂度getput 均为 (哈希表 定位 + 双向链表 插删)。

为什么在 DLinkedNode 中存储 key?

淘汰节点时,需要从哈希表中删除对应的键(cache.remove(removed.key))。如果节点中只存 value,就无法找到对应的 key,因此节点必须存储 key。这是实现中容易遗漏的细节。

LRU 的工程应用

LRU 缓存在工程中极为常见:Redis 的内存淘汰策略之一就是 LRU;CPU 的 L1/L2/L3 缓存也使用类似策略;浏览器的页面缓存、数据库的 Buffer Pool 都有 LRU 的影子。理解 LRU 的实现,帮助你理解各类缓存系统的设计决策。


第 5 章 全专栏总结

5.1 数组题的解题框架

数组题的难点通常在于时间和空间的优化,核心技巧体系:

技巧解决的问题代表题
双指针(同向)原地修改,去重LeetCode 26、80、27
双指针(对向)排序数组查找,两数之和LeetCode 15、16、18
二分搜索有序数组查找,旋转数组LeetCode 33、81、4
哈希表辅助 查找,连续子序列LeetCode 1、128
贪心局部最优→全局最优LeetCode 42、134、135
位运算不用额外空间的计数/判重LeetCode 136、137
动态规划最优子结构问题LeetCode 70

5.2 链表题的解题框架

链表题的难点通常在于指针操作的正确性和边界处理,核心技巧体系:

技巧解决的问题代表题
哑节点统一处理头节点删除LeetCode 82、19、86
快慢指针环检测、找中点、倒数节点LeetCode 141、142、19
反转链表链表翻转变体LeetCode 92、25、24
多链表合并分拆后合并LeetCode 86、143
哈希表辅助节点映射/随机指针深拷贝LeetCode 138
HashMap + 双向链表 的有序缓存LeetCode 146

5.3 面试备考建议

经过 13 篇的系统学习,线性表(数组+链表)的面试题已经覆盖全面。备考的最后阶段,建议:

  1. 刷题顺序:先按本专栏的顺序,每篇配合 LeetCode 做题,理解之后再做变体
  2. 重点记忆:每种技巧的模板代码,尤其是双指针、二分、反转链表这三个最高频
  3. 边界意识:每做完一道题,主动检查空链表、单节点、头尾边界等 case
  4. 复杂度习惯:说出答案前先分析时间和空间复杂度,这是面试中的加分项
  5. 代码干净:变量命名清晰(prev/curr/nextleft/rightslow/fast),缩进规范,减少面试官的阅读负担

算法题的本质

算法题面试考察的不仅是能否得到正确答案,更是你的思维过程:能否清晰地分析问题、能否识别合适的数据结构和算法模式、能否主动发现并处理边界 case、能否在面试官提示下快速调整方向。把这些习惯训练出来,比记住 100 道题的解法更有价值。


至此,《线性表面试专栏》全 13 篇完结。从数组的内存模型到链表的 LRU 缓存,从双指针的原地修改到位运算的状态机,你已经系统掌握了线性表这一数据结构在面试中的所有核心考点。祝面试顺利。