← 返回博客列表

一、链表概述:单/双/循环链表

链表(Linked List)是一种线性数据结构,由一系列节点(Node)组成,每个节点包含数据域和指针域。与数组的连续内存不同,链表通过指针将零散的内存块串联起来,实现了灵活的动态内存管理。

链表 vs 数组核心区别
• 内存布局:数组连续存储,链表离散存储
• 随机访问:数组 O(1),链表 O(n)(必须从头遍历)
• 插入/删除:数组 O(n)(需移动元素),链表 O(1)(只需改指针,前提是已知位置)

1.1 三种链表结构图解

根据指针方向和首尾连接方式,链表分为单链表、双链表和循环链表三种基础形态。

三种链表节点结构对比 ① 单链表(Singly Linked List) 每个节点只有一个 next 指针,指向下一节点;尾节点 next = NULL head 指针 → 1 next → 2 next → 3 next → 4 next=∅ ② 双链表(Doubly Linked List) 每个节点有 prev(前驱)和 next(后继)两个指针,可双向遍历 head 指针 → ← 1 prev=∅ next → ← 2 prev next → ← 3 prev next=∅ ③ 循环链表(Circular Linked List) 尾节点 next 指向头节点,形成闭环;可从任一节点遍历全表 1(头) next→ 2 next→ 3 next→ 4(尾) next→头 尾节点 next 回头节点,形成环 单链表单向遍历 O(n);双链表双向遍历;循环链表可从任意点出发绕一圈回到起点
图 1:单链表、双链表、循环链表节点结构对比

1.2 链表节点的 C++ 定义

三种链表的核心差异体现在节点结构上:

// 单链表节点:LeetCode 标准定义
struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

// 双链表节点:LRU 等场景使用
struct DListNode {
    int key;
    int val;
    DListNode* prev;
    DListNode* next;
    DListNode(int k, int v) : key(k), val(v), prev(nullptr), next(nullptr) {}
};

二、链表反转原理

链表反转是最经典的链表操作,面试出现率极高。核心思想是把每个节点的 next 指针从"指向下一个"改为"指向前一个"。实现方式主要有两种:三指针迭代法和递归回溯法。

2.1 三指针迭代法原理

迭代法使用三个指针 prev、curr、next,边走边改指针方向,是面试中最推荐的写法。

三指针迭代反转链表:以 1→2→3→4 为例 步骤 0:初始状态 prev=NULL, curr=1, next=null NULL prev × 断开 1 curr → 2 → 3 → 4 next=∅ 步骤 1:处理节点 1 —— 先存 next=2,再改 curr.next=prev,指针整体前移 1 prev ← 反转 NULL × 2 curr → 3 next → 4 步骤 2:处理节点 2 —— next=3,curr.next=prev(1),prev→2,curr→3 2 prev ← 1 ← NULL × 3 curr → 4 next 步骤 3~4:同理反转 3、4;最终 curr=NULL,prev 指向新头节点 4 (中间步骤同上模式,直接展示最终结果) 4 新头 ← 3 ← 2 ← 1 ← NULL(尾) 三指针迭代操作顺序(每轮循环) ① next = curr->next; 先保存下一个节点,防止断链后丢失 ② curr->next = prev; 反转指针:当前节点指向前一个(核心操作) ③ prev = curr; prev 前移,跟上 curr 的脚步 ④ curr = next; curr 前移,用之前保存的 next ⏱ 循环终止与返回 while (curr != NULL) { ①②③④ } 退出时 curr=NULL,prev 恰好站在 原链表最后一个节点上——即新链表的头! 记忆口诀:"保存下一个→回头→前驱跟进→当前跟进";四步顺序不能乱
图 2:三指针迭代法反转链表的完整过程(含每轮四步操作顺序)

2.2 递归回溯法原理

递归法思路更巧妙:一路递归到尾节点,回溯时逐层反转 next 指针。尾节点本身就是新头,逐层向上传。

递归回溯反转链表:1→2→3→4→∅ 阶段一:递归深入(递),一路到尾节点 reverseList(1) ↓ 调用 reverseList(2) ↓ reverseList(3) ↓ reverseList(4) ↓ 触底! 4.next==NULL → return 4 原链接结构(深入过程中尚未改动): 1 → 2 → 3 → 4(新头) 阶段二:回溯逐层反转指针(归),共 3 层回溯 回溯 ①:从 reverseList(4) 退回 reverseList(3),newHead=4 执行:3->next->next = 3; 即 4->next = 3; ← 反向连上 执行:3->next = NULL; 断开 3→4,防止环 1 → 2 → 3 ↔ 4 →NULL 回溯 ②:退回 reverseList(2),newHead 仍=4(层层向上传) 执行:2->next->next = 2; 即 3->next = 2; 执行:2->next = NULL; 1 → 2 ← 3 ← 4 回溯 ③:退回 reverseList(1),反转最后一条边,完成! 执行:1->next->next = 1; 即 2->next = 1; 执行:1->next = NULL; return newHead=4; 4(头) ← 3 ← 2 ← 1(尾) →NULL 递归公式:head->next->next = head; head->next = NULL; 返回值始终是尾节点(新头)
图 3:递归回溯法反转链表——先深入到尾,再逐层"回头"

三、反转示例:LeetCode 206 迭代+递归

题目(LeetCode 206 反转链表):给定单链表的头节点 head,请你反转链表,并返回反转后的链表。

3.1 示例输入输出

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

3.2 解法一:三指针迭代(推荐)

#include <iostream>
using namespace std;

// LeetCode 标准单链表节点定义
struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

// 解法一:三指针迭代法 O(n) / O(1)
ListNode* reverseList(ListNode* head) {
    ListNode* prev = nullptr;  // 前驱指针,初始为 NULL(新链表的尾部)
    ListNode* curr = head;     // 当前指针,从原头节点开始遍历
    ListNode* next = nullptr;  // 暂存下一个节点,防止断链

    while (curr != nullptr) {
        next = curr->next;      // ① 先保存下一个节点
        curr->next = prev;      // ② 反转当前节点的 next 指针
        prev = curr;            // ③ prev 前移一步
        curr = next;            // ④ curr 前移一步
    }
    return prev;  // 循环结束时 curr=NULL,prev 就是新的头节点
}

// 辅助函数:从数组构造链表
ListNode* buildList(int arr[], int n) {
    if (n == 0) return nullptr;
    ListNode* head = new ListNode(arr[0]);
    ListNode* p = head;
    for (int i = 1; i < n; ++i) {
        p->next = new ListNode(arr[i]);
        p = p->next;
    }
    return head;
}

// 辅助函数:打印链表
void printList(ListNode* head) {
    while (head) {
        cout << head->val;
        if (head->next) cout << " -> ";
        head = head->next;
    }
    cout << " -> NULL" << endl;
}

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    ListNode* head = buildList(arr, 5);
    cout << "反转前: "; printList(head);
    head = reverseList(head);
    cout << "反转后: "; printList(head);
    return 0;
}
💡 面试加分点
1. 空间复杂度 O(1),时间 O(n),是反转链表的最优解
2. 四步顺序不可调换——尤其是"先存 next"这一步,否则必丢链
3. 边界情况:head==NULL 或只有一个节点时直接返回,循环条件都能优雅处理

3.3 解法二:递归回溯

// 解法二:递归法 O(n) / O(n)(栈深度)
ListNode* reverseListRecursive(ListNode* head) {
    // 递归终止:空节点或最后一个节点,返回自己(它就是新头)
    if (head == nullptr || head->next == nullptr) {
        return head;
    }
    // ① 先递归处理后面的子链表,得到新头 newHead
    ListNode* newHead = reverseListRecursive(head->next);

    // ② 回溯:把子链表中 head 的后继反过来指向 head
    head->next->next = head;

    // ③ head 自己的 next 断开,防止出现环
    head->next = nullptr;

    return newHead;  // 新头层层向上传递,从不改变
}
⚠ 递归法风险
当链表长度达到 1 万以上时,递归深度过大会导致 栈溢出(Stack Overflow)。工程代码中默认用迭代法,递归只在链表较短或面试秀技巧时使用。

四、合并两个有序链表原理

合并两个有序链表是双指针技巧的经典应用。核心思路:用两个指针分别遍历两条链,每次选值更小的节点接在结果链上,直到某条链遍历完,把另一条链剩余部分整条接上。

4.1 双指针逐步骤合并图解

合并两个有序链表:L1=[1,2,4],L2=[1,3,4] L1 节点 L2 节点 结果链节点 L1: 1 → 2 → 4 L2: 1 → 3 → 4 步骤 1:两指针值相等 1==1,任选其一(选 L1 的 1)接在 dummy 后 p1 指向 L1[0]=1,p2 指向 L2[0]=1;比较 val 相等,选 p1。prev.next=p1,prev 和 p1 都后移。 dummy → 1 prev↑ 步骤 2:p1=2,p2=1;选更小的 L2 的 1 接上 prev.next = p2(L2[0]=1);prev 和 p2 后移。结果链:dummy→1(L1)→1(L2) dummy → 1 → 1 prev↑ 步骤 3:p1=2,p2=3;选更小的 L1 的 2 接上 prev.next=p1=2;prev、p1 后移。结果链继续增长 dmy → 1 → 1 → 2 prev↑ 步骤 4~7:同上模式依次接上 3→4→4;L1 先耗尽,把 L2 剩余整条接上 合并完成后结果:dummy → 1 → 1 → 2 → 3 → 4 → 4;最终返回 dummy.next 即为真正的头。 dmy → 1 → 1 → 2 → 3 → 4 → 4 🎯 为什么需要 dummy 哨兵头? 因为真正的头节点不确定来自 L1 还是 L2,dummy.next 可以统一返回;同时避免处理"结果链第一个节点"的特殊分支代码。 复杂度:时间 O(m+n) 每个节点走一次;空间 O(1) 只用常数指针
图 4:双指针逐步骤合并有序链表——谁小选谁,接完后移

五、合并示例:LeetCode 21

题目(LeetCode 21 合并两个有序链表):将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

5.1 示例输入输出

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

5.2 完整 C++ 代码

#include <iostream>
using namespace std;

struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

// 合并两个有序链表:哨兵头 + 双指针 O(m+n) / O(1)
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
    // 哨兵节点(dummy head):值无意义,只用来简化头节点处理
    ListNode* dummy = new ListNode(0);
    ListNode* prev = dummy;  // 结果链的"当前尾部"

    ListNode* p1 = l1;       // 遍历 L1 的指针
    ListNode* p2 = l2;       // 遍历 L2 的指针

    // 当两条链都还有节点时,谁小接谁
    while (p1 != nullptr && p2 != nullptr) {
        if (p1->val <= p2->val) {
            prev->next = p1;   // 把 p1 接入结果链尾部
            p1 = p1->next;     // p1 前移
        } else {
            prev->next = p2;   // 把 p2 接入结果链尾部
            p2 = p2->next;     // p2 前移
        }
        prev = prev->next;     // 结果链尾部也要前移
    }

    // 此时至少有一条链已空,把另一条链剩余部分整条接上
    prev->next = (p1 != nullptr) ? p1 : p2;

    return dummy->next;  // 真正的头是 dummy 的下一个
}

// 辅助函数:构造链表
ListNode* build(int a[], int n) {
    if (!n) return nullptr;
    ListNode* h = new ListNode(a[0]);
    ListNode* p = h;
    for (int i = 1; i < n; ++i) {
        p->next = new ListNode(a[i]);
        p = p->next;
    }
    return h;
}

void print(ListNode* h) {
    while (h) { cout << h->val << " "; h = h->next; }
    cout << endl;
}

int main() {
    int a[] = {1, 2, 4};
    int b[] = {1, 3, 4};
    ListNode* l1 = build(a, 3);
    ListNode* l2 = build(b, 3);
    ListNode* res = mergeTwoLists(l1, l2);
    cout << "合并结果: ";
    print(res);
    return 0;
}
🌟 代码风格要点
1. dummy 节点是链表操作的通用套路:需要"构造新链表"时几乎必用
2. 退出 while 后不用写两个 if,三目运算符一条搞定剩余拼接
3. 合并 k 个有序链表时,把这里的双指针换成最小堆(priority_queue)即可,思路完全一致

六、LRU 缓存原理

LRU(Least Recently Used,最近最少使用)是最经典的缓存淘汰策略。当缓存满了之后,再放入新数据时,优先删除"最久没有被访问过"的那条记录。

LRU 核心要求(get 和 put 都必须 O(1))
• get(key):取到值的同时,把该条标记为"最近使用过"
• put(key, value):若 key 已存在则更新值;不存在则插入;容量满时先删除 LRU 节点

6.1 为什么用"哈希表 + 双链表"

单一数据结构满足不了需求:哈希表查值快 O(1),但无法表达"最近使用"顺序;链表能表达顺序,但查找慢。所以把两者结合:

LRU = 哈希表 + 双链表 组合架构 哈希表 unordered_map<int, DListNode*> key 直接映射到节点指针,O(1) 定位 key: 1 → 节点(k=1,v=10)指针 key: 2 → 节点(k=2,v=20)指针 key: 3 → 节点(k=3,v=30)指针 get(3) 时:map[3] 一步找到右侧节点 双链表:头=最近使用,尾=最久未使用 dummy head (哨兵) ⇄ k=3, v=30 ★ 最近使用 (刚被访问) ⇄ k=1, v=10 ⇄ k=2, v=20 ⚡ LRU 下一个被删 ⇄ dummy tail (哨兵) 🔍 get(key) 流程(O(1)) ① 通过哈希表 map[key] 直接找到节点指针 ② 在双链表中:先 remove(该节点) → 再 addToHead(该节点) ③ 相当于把节点"挪到最前面"标记为最近使用 ④ 返回节点的 value (注:因为是双链表,remove 操作无需遍历,O(1)) 💾 put(key, val) 流程(O(1)) ① key 已存在:改值 + get 的逻辑(移头) ② key 不存在:创建新节点 + addToHead + 写哈希表 ③ 若 size > capacity: · remove(tail.prev) —— 删尾节点(LRU) · map.erase(删除节点的 key) —— 同步删哈希表
图 5:LRU 架构——哈希表 O(1) 查,双链表 O(1) 表达使用顺序并支持头尾增删

6.2 get/put 操作过程图解

LRU 操作过程:容量=3,已有 [3→1→2](左头右尾,头=最近) ① 操作前(容量 3,已满) k=3 头(新) ⇄ k=1 ⇄ k=2 尾(LRU) ② get(1):命中 → 把 k=1 移到头部 步骤:remove(k=1) → addToHead(k=1) k=1 ★ 新头 ⇄ k=3 ⇄ k=2 尾(LRU) ③ put(4, 40):新 key,容量满 → 先删 LRU(k=2),再加到头部 淘汰步骤:remove(tail.prev=k=2) + map.erase(2);size 从 3 变 2 插入步骤:new DListNode(4,40) → addToHead(4) → map[4]=ptr;size=3 k=4 🆕 新头 ⇄ k=1 ⇄ k=3 尾(LRU) ✕ 已删除 k=2 (原 LRU 已淘汰) ④ put(3, 99):key=3 已存在 → 改值为 99 + 移到头部(等同于 get 后再修改) k=3 头 v=99 ⇄ k=4 ⇄ k=1 尾(LRU)
图 6:LRU 的 get 和 put 过程完整示例(容量=3)

七、LRU 示例:LeetCode 146

题目(LeetCode 146 LRU 缓存):请你设计并实现一个满足 LRU 缓存约束的数据结构。实现 LRUCache 类:

7.1 完整 C++ 代码

#include <iostream>
#include <unordered_map>
using namespace std;

// 双链表节点:同时存 key 和 value
// key 必须保存,因为删除尾节点时需要从哈希表里同步删除,只能通过节点里的 key 反查
struct DListNode {
    int key;         // 存 key:删节点时用来 erase 哈希表
    int val;         // 存值
    DListNode* prev;
    DListNode* next;
    DListNode(int k = 0, int v = 0)
        : key(k), val(v), prev(nullptr), next(nullptr) {}
};

class LRUCache {
private:
    unordered_map<int, DListNode*> cache;  // key → 节点指针,O(1) 查
    DListNode* head;                        // 哨兵头,它的 next 是真正的"最近使用"
    DListNode* tail;                        // 哨兵尾,它的 prev 是真正的"最久未使用"
    int capacity;                           // 容量上限
    int size;                               // 当前节点数量

    // ---------- 4 个链表原子操作,全部 O(1) ----------

    // ① 把某个节点"加到头部"(head 之后第一个位置)
    //    用于:get 命中后、put 新插入时
    void addToHead(DListNode* node) {
        node->prev = head;
        node->next = head->next;
        head->next->prev = node;  // 先改原第一个节点的 prev
        head->next = node;        // 再改 head 的 next
    }

    // ② 从链表中移除某个节点(不释放内存,只摘链)
    //    因为是双链表,已知节点指针即可删除,无需遍历
    void removeNode(DListNode* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

    // ③ 把某个节点"移到头部" = 先摘出来 + 再加到头
    //    用于:get 命中、put 更新已有 key
    void moveToHead(DListNode* node) {
        removeNode(node);
        addToHead(node);
    }

    // ④ 删除尾部的真实节点(tail.prev,最久未使用)并返回它
    //    返回是为了拿到它的 key,好同步 erase 哈希表
    DListNode* removeTail() {
        DListNode* res = tail->prev;
        removeNode(res);
        return res;
    }

public:
    // 构造函数:初始化容量、哨兵头和尾
    LRUCache(int cap) : capacity(cap), size(0) {
        head = new DListNode();
        tail = new DListNode();
        head->next = tail;  // 两个哨兵互相连上 → 形成空链表
        tail->prev = head;
    }

    // get:命中则取值并移头,否则 -1
    int get(int key) {
        if (!cache.count(key)) {
            return -1;  // 未命中
        }
        DListNode* node = cache[key];
        moveToHead(node);  // 命中后:标记为最近使用
        return node->val;
    }

    // put:区分"key 已存在"和"key 不存在"
    void put(int key, int value) {
        if (cache.count(key)) {
            // 情况一:key 已存在 → 改值 + 移头
            DListNode* node = cache[key];
            node->val = value;
            moveToHead(node);
        } else {
            // 情况二:key 不存在 → 新建节点
            DListNode* newNode = new DListNode(key, value);
            cache[key] = newNode;   // 写入哈希表
            addToHead(newNode);    // 接入头部
            size++;

            // 若超出容量 → 删除 LRU 节点(尾部真实节点)
            if (size > capacity) {
                DListNode* tailNode = removeTail();  // 从双链表删除
                cache.erase(tailNode->key);           // 同步从哈希表删除!
                delete tailNode;                       // 释放内存
                size--;
            }
        }
    }
};

int main() {
    LRUCache lru(2);          // 容量 2
    lru.put(1, 1);            // [(1,1)]
    lru.put(2, 2);            // [(2,2), (1,1)]   头=2 尾=1
    cout << lru.get(1) << endl;  // 命中 1,移头:[(1,1), (2,2)] → 输出 1
    lru.put(3, 3);            // 容量满,踢 2(LRU),插入 3 头:[(3,3), (1,1)]
    cout << lru.get(2) << endl;  // 没命中 → -1
    lru.put(4, 4);            // 踢 1,插入 4:[(4,4), (3,3)]
    cout << lru.get(1) << endl;  // -1
    cout << lru.get(3) << endl;  // 3
    cout << lru.get(4) << endl;  // 4
    return 0;
}
💎 面试踩坑点
1. DListNode 里必须存 key,否则删除尾节点时无法同步删除哈希表——map 只能按 key 删
2. 两个哨兵(head + tail)避免了操作真头真尾的空指针判断,4 条指针修改极其对称
3. 封装 addToHead/removeNode/moveToHead/removeTail 四个原子函数,主逻辑就不会乱;所有操作最终都是这四个的组合
4. 若题目允许用 STL,可以直接用 std::list + unordered_map,但手写双链表在面试中更能体现基础

八、总结

链表三大核心技法——反转、合并、LRU——是算法面试的常青树。它们本质上是指针操作的三种典型模式:原地改向(反转)、多链协同(合并)、数据结构组合(LRU)。

三技法对比总表

维度 链表反转 合并有序链表 LRU 缓存
核心思想 三指针 prev/curr/next 边走边改方向 双指针 p1/p2 谁小接谁 哈希表 O(1) 查 + 双链表表达使用顺序
关键结构 单链表 单链表 + dummy 哨兵头 unordered_map + 双链表(头尾双哨兵)
时间复杂度 O(n) O(m + n) get O(1),put O(1)
空间复杂度 迭代 O(1) / 递归 O(n) O(1)(纯指针拼接) O(capacity)
指针记忆点 "保存 next → 回头 → prev跟进 → curr跟进" "选小接入 → 双指针前移 → 收尾直连" 4 个原子操作:addToHead / removeNode / moveToHead / removeTail
常见坑 忘记先存 next 导致断链;递归栈溢出 不用 dummy 导致头节点判断分支复杂 双链表节点不存 key,删 LRU 时无法同步 erase map
对应 LeetCode 206、92(区间反转)、25(K个一组) 21、23(合并K个)、148(归并排序链表) 146、460(LFU 进阶)、面试高频设计题

延伸学习建议

🎯 一句话心法
链表题没有算法黑魔法,全是画图→改指针→验证边界的基本功。把本文三张过程图自己画一遍,链表面试就能稳过。