链表 (Linked List)

title: ""

章节概述

链表(Linked List)是一种非连续存储的线性数据结构,每个节点通过指针链接到下一个节点。
与数组不同,链表不要求元素连续存放,插入和删除操作只需要修改指针,无需移动数据。

本节侧重底层实现与内存理解,与 CPP教程对应章节 形成互补——CPP教程侧重 std::list/std::forward_list 的STL使用与迭代器操作,本教程侧重手动管理节点内存、指针链接、以及理解链表的真实内存布局。你还将学习Linux内核中著名的侵入式链表(intrusive linked list)模式。

在CPP教程中对应章节侧重 STL 的 list/forward_list 用法与迭代器,本节侧重手动 malloc/free 节点、指针操作与内存布局。



第一节: 单向链表 —— 最基础的链式结构


1.1 节点结构与创建

单向链表每个节点包含数据和指向下一个节点的指针:

#include <stdlib.h>
#include <stdio.h>
#include <stdbool.h>
 
typedef struct SNode {
    int data;
    struct SNode *next;
} SNode;

内存布局: 每个节点独立分配在堆上,通过 next 指针串联。与数组的连续内存不同,
链表节点散布在堆的各个位置。

graph LR
    head["head (栈)"] --> n1["Node0\n[data=10]"]
    n1 -->|"next"| n2["Node1\n[data=20]"]
    n2 -->|"next"| n3["Node2\n[data=30]"]
    n3 -->|"next"| null["NULL"]

    subgraph 堆
        n1
        n2
        n3
    end

对比数组:数组 [10,20,30] 在一条连续内存上,链表 10→20→30 散布各处。详见 动态内存管理


1.2 头插法 —— O(1)

SNode* slist_prepend(SNode *head, int value) {
    SNode *new_node = (SNode*)malloc(sizeof(SNode));
    if (!new_node) {
        fprintf(stderr, "malloc failed\n");
        return head;
    }
    new_node->data = value;
    new_node->next = head;   // 新节点指向原来的头
    return new_node;          // 返回新的头
}

指针关系变化:

graph LR
    subgraph "头插前"
        h1["head"] --> old["旧头: 20 → 30 → NULL"]
    end
    subgraph "头插后"
        h2["head"] --> new["新头: 10"] --> old2["旧头: 20 → 30 → NULL"]
    end

练习 1.2.1: 为什么头插法返回新头指针?如果参数是 SNode** head(二级指针),如何实现?


1.3 尾插法 —— O(n)

SNode* slist_append(SNode *head, int value) {
    SNode *new_node = (SNode*)malloc(sizeof(SNode));
    if (!new_node) return head;
    new_node->data = value;
    new_node->next = NULL;
 
    if (head == NULL) {
        return new_node;  // 空链表,新节点就是头
    }
 
    // 遍历到最后一个节点
    SNode *cur = head;
    while (cur->next != NULL) {
        cur = cur->next;
    }
    cur->next = new_node;
    return head;
}

优化 —— 带尾指针的单链表:

尾插法的 O(n) 开销来自于找尾节点。如果常用尾插,可以维护一个尾指针:

typedef struct {
    SNode *head;
    SNode *tail;
} SListWithTail;
 
void slist_tail_append(SListWithTail *list, int value) {
    SNode *new_node = (SNode*)malloc(sizeof(SNode));
    new_node->data = value;
    new_node->next = NULL;
 
    if (list->tail) {
        list->tail->next = new_node;
    } else {
        list->head = new_node;  // 空链表
    }
    list->tail = new_node;
}

练习 1.3.1: 实现带尾指针的 slist_tail_prepend(头插),分析是否需要修改尾指针。


1.4 查找、删除与销毁

// 查找 —— 返回节点指针
SNode* slist_find(SNode *head, int value) {
    SNode *cur = head;
    while (cur != NULL) {
        if (cur->data == value)
            return cur;
        cur = cur->next;
    }
    return NULL;
}
 
// 删除指定值的第一个节点
SNode* slist_remove(SNode *head, int value) {
    if (head == NULL) return NULL;
 
    // 头节点特殊处理
    if (head->data == value) {
        SNode *new_head = head->next;
        free(head);
        return new_head;
    }
 
    // 找前驱
    SNode *prev = head;
    SNode *cur = head->next;
    while (cur != NULL) {
        if (cur->data == value) {
            prev->next = cur->next;
            free(cur);
            return head;
        }
        prev = cur;
        cur = cur->next;
    }
    return head;  // 未找到
}
 
// 销毁整条链表 —— 必须逐个释放
void slist_destroy(SNode *head) {
    SNode *cur = head;
    while (cur != NULL) {
        SNode *next = cur->next;  // 先保存下一个节点地址
        free(cur);
        cur = next;
    }
}

销毁链表的内存陷阱: 不能先 free(current) 再访问 current->next——free 后的内存已不可访问。

sequenceDiagram
    participant code as slist_destroy
    participant mem as 内存

    code->>mem: cur = head (0x1000)
    code->>mem: next = cur->next (0x2000)
    code->>mem: free(0x1000)  不能再访问
    Note over code,mem: 必须先保存 next
    code->>mem: cur = next (0x2000)
    code->>mem: next = cur->next (0x3000)
    code->>mem: free(0x2000)

练习 1.4.1: 实现 slist_remove_all(SNode *head, int value) 删除链表中所有等于 value 的节点。


1.5 反转单向链表 —— 经典面试题

SNode* slist_reverse(SNode *head) {
    SNode *prev = NULL;
    SNode *cur = head;
    SNode *next;
 
    while (cur != NULL) {
        next = cur->next;      // 1. 暂存下一个
        cur->next = prev;      // 2. 反转指针
        prev = cur;            // 3. 前驱前进
        cur = next;            // 4. 当前前进
    }
    return prev;  // 新的头节点
}

指针变化图解:

graph LR
    subgraph "反转前"
        a1["10"] --> a2["20"] --> a3["30"] --> n1["NULL"]
    end
    subgraph "反转后"
        b3["30"] --> b2["20"] --> b1["10"] --> n2["NULL"]
    end

反转链表涉及指针三步操作,可以从汇编角度理解:每次迭代修改一个 next 寄存器指向的值,参见 。

练习 1.5.1: 实现递归版本的链表反转。分析递归版本的栈深度是否安全。



第二节: 双向链表


2.1 节点结构

双向链表每个节点有两个指针:前驱 prev 和后继 next

typedef struct DNode {
    int data;
    struct DNode *prev;
    struct DNode *next;
} DNode;
 
typedef struct {
    DNode *head;
    DNode *tail;
    size_t size;
} DList;
graph LR
    subgraph "双向链表"
        h["head"] --> n1["Node0\n[data=10]"]
        n1 -->|next| n2["Node1\n[data=20]"]
        n2 -->|next| n3["Node2\n[data=30]"]
        n3 -->|next| null["NULL"]
        n1 -->|prev| null2["NULL"]
        n2 -->|prev| n1
        n3 -->|prev| n2
        t["tail"] --> n3
    end

2.2 双向链表的优势

双向链表的代价是每个节点多 8 字节(64位),但换来的是:

  1. O(1) 尾删:可以直接通过 tail->prev 找到新的尾
  2. O(1) 前向遍历与删除:给定节点的指针,可以 O(1) 找到前驱并删除
  3. 插入/删除不再需要”找前驱”:这在单向链表中是 O(n) 的
DList* dlist_create() {
    DList *list = (DList*)malloc(sizeof(DList));
    if (!list) return NULL;
    list->head = NULL;
    list->tail = NULL;
    list->size = 0;
    return list;
}
 
// O(1) 删除指定节点(因为可以访问 prev)
void dlist_remove_node(DList *list, DNode *node) {
    if (!node) return;
 
    if (node->prev)
        node->prev->next = node->next;
    else
        list->head = node->next;  // 删除的是头
 
    if (node->next)
        node->next->prev = node->prev;
    else
        list->tail = node->prev;  // 删除的是尾
 
    free(node);
    list->size--;
}
 
// O(1) 头插
void dlist_push_front(DList *list, int value) {
    DNode *node = (DNode*)malloc(sizeof(DNode));
    if (!node) return;
    node->data = value;
    node->prev = NULL;
    node->next = list->head;
 
    if (list->head)
        list->head->prev = node;
    else
        list->tail = node;  // 空链表,新节点也是尾
 
    list->head = node;
    list->size++;
}
 
// O(1) 尾插
void dlist_push_back(DList *list, int value) {
    DNode *node = (DNode*)malloc(sizeof(DNode));
    if (!node) return;
    node->data = value;
    node->next = NULL;
    node->prev = list->tail;
 
    if (list->tail)
        list->tail->next = node;
    else
        list->head = node;  // 空链表,新节点也是头
 
    list->tail = node;
    list->size++;
}

练习 2.2.1: 实现 dlist_insert_before(DList *list, DNode *ref, int value) 在 ref 节点前插入新节点。


2.3 双向链表的销毁

与单向链表类似,逐个释放:

void dlist_destroy(DList *list) {
    if (!list) return;
    DNode *cur = list->head;
    while (cur) {
        DNode *next = cur->next;
        free(cur);
        cur = next;
    }
    free(list);
}

练习 2.3.1: 为什么在双向链表中也可以像单向链表一样用 next 遍历销毁?遍历销毁时访问 prev 是否有意义?



第三节: 循环链表与哨兵节点


3.1 循环单链表

尾节点的 next 指向头节点,形成一个环:

typedef struct {
    SNode *head;  // 可以指向任意节点
} CircularList;
graph LR
    n1["Node0"] --> n2["Node1"]
    n2 --> n3["Node2"]
    n3 --> n1

循环链表的好处:从任意节点出发都能遍历整条链表,不需要区分”头”和”尾”。

// 在 head 节点后插入
void clist_insert_after(SNode *node, int value) {
    SNode *new_node = (SNode*)malloc(sizeof(SNode));
    new_node->data = value;
    new_node->next = node->next;
    node->next = new_node;
}
 
// 遍历(需要一个终止条件)
void clist_print(SNode *head) {
    if (!head) return;
    SNode *cur = head;
    do {
        printf("%d ", cur->data);
        cur = cur->next;
    } while (cur != head);
    printf("\n");
}

练习 3.1.1: 实现约瑟夫环(Josephus problem)——n个人围成一圈,每次数到m的人出列,求最后剩下的人的编号。用循环链表实现。


3.2 带哨兵节点的双向循环链表

这是一个非常优雅的设计——用一个不存数据的”哨兵节点”(sentinel/dummy node)简化边界条件:

typedef struct {
    DNode sentinel;  // 哨兵节点,不存数据
    size_t size;
} SentinelList;

初始化时,哨兵节点的 prevnext 都指向自己:

void sentinel_list_init(SentinelList *list) {
    list->sentinel.prev = &list->sentinel;
    list->sentinel.next = &list->sentinel;
    list->size = 0;
}

为什么哨兵节点能简化代码?

普通双向链表在空表、删除头、删除尾时需要特殊的 if 判断。
哨兵节点让每个有效节点永远有前驱和后继(即使是空表,哨兵也存在)。

// 哨兵链表: 尾部插入,代码极其简洁
void sentinel_push_back(SentinelList *list, int value) {
    DNode *node = (DNode*)malloc(sizeof(DNode));
    node->data = value;
 
    // 始终在 sentinel 之前(即尾部)插入
    node->next = &list->sentinel;
    node->prev = list->sentinel.prev;
    list->sentinel.prev->next = node;
    list->sentinel.prev = node;
    list->size++;
}
 
// 删除节点 —— 也极其简洁
void sentinel_remove(DNode *node) {
    node->prev->next = node->next;
    node->next->prev = node->prev;
    free(node);
}

注意:上面的 sentinel_remove 不需要知道链表结构体,因为每个节点都有完整的 prevnext

graph LR
    subgraph "带哨兵的循环链表"
        s["sentinel (哨兵)"] --> n1["Node0 [10]"]
        n1 --> n2["Node1 [20]"]
        n2 --> n3["Node2 [30]"]
        n3 --> s
        s -->|"prev"| n3
        n1 -->|"prev"| s
        n2 -->|"prev"| n1
        n3 -->|"prev"| n2
    end

练习 3.2.1: 为哨兵链表实现 sentinel_findsentinel_push_frontsentinel_pop_back



第四节: Linux 内核链表 —— 侵入式链表模式


4.1 侵入式 vs 非侵入式

我们前面实现的链表是”非侵入式”(non-intrusive)的——节点 struct 本身就是链表节点:

struct DNode { int data; DNode *prev; DNode *next; };

但 Linux 内核使用的是”侵入式”(intrusive)链表——链表指针嵌入到”宿主”结构体中:

// Linux 内核风格(简化版)
struct list_head {
    struct list_head *prev;
    struct list_head *next;
};
 
struct my_data {
    int id;
    char name[32];
    struct list_head list;  // 嵌入的链表节点
};
graph TB
    subgraph "非侵入式: DNode 就是节点"
        dn1["DNode\n{data=10, prev, next}"]
        dn2["DNode\n{data=20, prev, next}"]
    end
    subgraph "侵入式: list_head 嵌入在 my_data 中"
        md1["my_data\n{id=1, name=\"Alice\"\n◉ list_head}"]
        md2["my_data\n{id=2, name=\"Bob\"\n◉ list_head}"]
    end

侵入式链表的优势:

  1. 节点不拥有数据——同一个数据对象可以同时挂在多个链表中
  2. 不需要为每个数据类型重写链表代码——链表操作只操作 list_head
  3. 零额外分配——list_head 是宿主结构体的一部分,不需要单独 malloc

侵入式链表的代价:

  1. 需要从 list_head 指针反推出宿主结构体指针(container_of 宏)
  2. 代码理解门槛更高

4.2 container_of 宏 —— 侵入式链表的核心技巧

#include <stddef.h>  // offsetof
 
// 从成员指针反推出包含该成员的结构体指针
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

原理: 已知成员在结构体中的偏移量(offsetof),用成员地址减去偏移量,即得结构体地址。

graph LR
    subgraph "my_data 在内存中"
        offset0["id: 4B"] --> offset4["name: 32B"] --> offset36["list_head: 16B"]
    end
    subgraph "container_of 计算"
        calc["ptr_to_listhead - offsetof(my_data, list) = ptr_to_my_data"]
    end

offsetof 的原理和汇编表示,参见 指针深度剖析


4.3 简化的内核链表实现

// ===== list.h =====
typedef struct list_head {
    struct list_head *prev;
    struct list_head *next;
} list_head_t;
 
#define LIST_HEAD_INIT(name) { &(name), &(name) }
#define LIST_HEAD(name) list_head_t name = LIST_HEAD_INIT(name)
 
static inline void INIT_LIST_HEAD(list_head_t *list) {
    list->prev = list;
    list->next = list;
}
 
// 在 prev 和 next 之间插入
static inline void __list_add(list_head_t *new_node,
                               list_head_t *prev,
                               list_head_t *next) {
    next->prev = new_node;
    new_node->next = next;
    new_node->prev = prev;
    prev->next = new_node;
}
 
static inline void list_add(list_head_t *new_node, list_head_t *head) {
    __list_add(new_node, head, head->next);  // 头插
}
 
static inline void list_add_tail(list_head_t *new_node, list_head_t *head) {
    __list_add(new_node, head->prev, head);  // 尾插
}
 
static inline void __list_del(list_head_t *prev, list_head_t *next) {
    next->prev = prev;
    prev->next = next;
}
 
static inline void list_del(list_head_t *entry) {
    __list_del(entry->prev, entry->next);
}
 
static inline int list_empty(const list_head_t *head) {
    return head->next == head;
}
 
// 遍历宏
#define list_for_each(pos, head) \
    for (pos = (head)->next; pos != (head); pos = pos->next)
 
#define list_entry(ptr, type, member) \
    container_of(ptr, type, member)
 
#define list_for_each_entry(pos, head, member) \
    for (pos = list_entry((head)->next, typeof(*pos), member); \
         &pos->member != (head); \
         pos = list_entry(pos->member.next, typeof(*pos), member))

4.4 使用侵入式链表

struct student {
    int id;
    char name[32];
    int score;
    list_head_t list;  // 嵌入每个 student 中
};
 
int main() {
    LIST_HEAD(student_list);  // 初始化链表头
 
    // 添加三个学生
    struct student s1 = {1, "Alice", 95};
    struct student s2 = {2, "Bob",   87};
    struct student s3 = {3, "Charlie", 92};
 
    list_add_tail(&s1.list, &student_list);
    list_add_tail(&s2.list, &student_list);
    list_add_tail(&s3.list, &student_list);
 
    // 遍历
    struct student *entry;
    list_for_each_entry(entry, &student_list, list) {
        printf("ID=%d, Name=%s, Score=%d\n",
               entry->id, entry->name, entry->score);
    }
 
    // 删除 Bob
    list_del(&s2.list);
 
    return 0;
}

关键观察: student 结构体可以在栈上也可以全局分配(本例在栈上),不需要 malloc
但这意味着 student 的生命周期由外部管理——链表只持有这些对象的”弱引用”。

练习 4.4.1: 实现 list_for_each_entry_safe —— 安全遍历宏(允许在遍历过程中删除当前节点)。

关于 C++ 中 std::list 如何实现,参见 forward_list



第五节: 链表性能分析与适用场景


5.1 复杂度对比

操作单向链表双向链表动态数组
头插O(1)O(1)O(n)
尾插(已知tail)O(1)O(1)均摊 O(1)
中间插入O(n)找位置+O(1)插入O(n)找位置+O(1)插入O(n)移动
删除已知节点O(n)找前驱O(1)O(n)移动
随机访问O(n)O(n)O(1)
内存开销8B/节点(64bit)16B/节点(64bit)0B额外开销

5.2 内存碎片与缓存

数组在内存连续,CPU 缓存友好(空间局部性);链表节点散布堆中,每次访问下一个节点都可能触发 cache miss。

// 演示链表的内存散落
void show_scattered_memory(SNode *head) {
    printf("Nodes scattered in memory:\n");
    SNode *cur = head;
    while (cur) {
        printf("  addr=%p, data=%d, next=%p (delta=%td)\n",
               (void*)cur, cur->data, (void*)cur->next,
               (char*)cur->next - (char*)cur);
        cur = cur->next;
    }
}
// 典型输出: delta 可能是几百或几千字节的随机值

关于 CPU 缓存和内存局部性,参见 。


5.3 链表的正确使用场景

适合链表的场景:

  • 频繁进行插入/删除且不关心随机访问
  • 无法预知元素数量(不需要像数组那样预分配)
  • 需要稳定的元素地址(数组扩容时地址会变)

不适合链表的场景:

  • 需要频繁随机访问
  • 内存紧张(节点额外内存开销大)
  • 对缓存性能有要求的大规模数据

练习 5.3.1: 编写一个程序,分别用链表和动态数组实现相同的大规模插入操作(10万元素,在头部插入),对比时间。尝试解释结果差异的原因。



章节测试


判断题(10题)

判断题 1

单向链表的头插法时间复杂度是 O(n)。

判断题 2

双向链表比单向链表多占用一倍的指针内存。

判断题 3

链表的元素在内存中是连续存放的。

判断题 4

销毁链表时可以一次性 free(head) 释放所有节点。

判断题 5

带哨兵节点的链表可以消除边界条件的特殊判断。

判断题 6

Linux 内核的 list_head 是侵入式链表的实现。

判断题 7

侵入式链表中的节点不需要单独 malloc

判断题 8

循环单链表从任意节点出发都能遍历所有元素。

判断题 9

container_of 宏利用的是 C 语言中结构体成员地址与结构体首地址之间的固定偏移关系。

判断题 10

链表的查找操作时间复杂度为 O(log n)。


选择题(10题)

选择题 1

在单向链表中删除已知 cur 指针指向的节点,需要什么额外信息?

  • A. 不需要,直接 free(cur)

  • B. 需要 cur 的前驱节点的指针

  • C. 需要链表的头指针

  • D. 需要链表的长度

选择题 2

以下代码有什么问题?

void destroy(SNode *head) {
    SNode *cur = head;
    while (cur) {
        free(cur);
        cur = cur->next;  // 问题在哪?
    }
}
  • A. 没有循环终止条件

  • B. free(cur) 后访问 cur->next 是未定义行为

  • C. 需要检查 head 是否为 NULL

  • D. free 的参数类型错误

选择题 3

在 64 位系统中,一个双向链表节点(不含数据)占用多少额外内存?

  • A. 4 字节

  • B. 8 字节

  • C. 16 字节

  • D. 24 字节

选择题 4

以下关于头插法和尾插法的描述,正确的是?

  • A. 头插法会改变元素插入顺序,尾插法保持插入顺序

  • B. 头插法和尾插法都是 O(n)

  • C. 尾插法始终是 O(1)

  • D. 头插法比尾插法慢

选择题 5

Linux 内核使用侵入式链表的主要原因是?

  • A. 节省内存

  • B. 一个数据对象可以同时出现在多个链表中

  • C. 它比非侵入式链表更快

  • D. 不需要写遍历代码

选择题 6

offsetof(type, member) 宏返回的是什么?

  • A. 结构体的大小

  • B. 成员的大小

  • C. 成员在结构体中的字节偏移量

  • D. 结构体的内存地址

选择题 7

在带哨兵的双向循环链表中,空链表时哨兵节点的 prev 和 next 分别指向?

  • A. prev=NULL, next=NULL

  • B. prev=head, next=head

  • C. prev=sentinel, next=sentinel(都指向自己)

  • D. prev=NULL, next=sentinel

选择题 8

反转单向链表的迭代算法中,核心操作是什么?

  • A. 交换相邻节点的数据

  • B. 逐个修改节点的 next 指针,使其指向前一个节点

  • C. 从尾到头重新构建链表

  • D. 使用栈暂存所有节点

选择题 9

约瑟夫环问题最适合使用哪种数据结构?

  • A. 动态数组

  • B. 单向链表

  • C. 循环单向链表

  • D. 哈希表

选择题 10

以下关于链表和数组的选择,最合理的建议是?

  • A. 任何时候都应该用数组,因为链表有指针开销

  • B. 需要大量随机访问时用数组,需要频繁头插/删除时用链表

  • C. 链表始终比数组快

  • D. 数组的内存碎片比链表严重


编程大题

编程题 1:实现完整双向链表库

要求

  1. 实现带哨兵节点的泛型双向循环链表(类似 Linux 内核 list.h
  2. 支持 list_add, list_add_tail, list_del, list_empty
  3. 实现 list_for_each, list_for_each_entry, list_for_each_entry_safe
  4. 用此链表实现一个简单的任务调度器(任务有 id、priority、status)
  5. 任务可以插入不同优先级队列,演示同一个 task 挂在多个链表中的能力

提示: 参考 Linux 内核 include/linux/list.h

编程题 2:两个大数相加(用链表表示)

要求

  1. 用单链表表示大整数(每位存储一个十进制数字,从低位到高位)
  2. 实现两个大数的加法(模拟手工竖式加法)
  3. 处理进位
  4. 输出结果链表

提示: 实际上用数组表示大数更方便(随机访问),此题旨在加深对链表遍历的理解。

编程题 3:LRU 缓存(双向链表 + 哈希表 = O(1))

要求

  1. 用双向链表维护访问顺序(最近使用的在头部,最久未使用的在尾部)
  2. 用哈希表(或简单数组)做 key→节点指针的映射
  3. get(key): 如果存在,将节点移到链表头部,返回值;否则返回 -1
  4. put(key, value): 如果存在更新值并移到头部;如果不存在,在头部插入;容量满时删除尾部节点
  5. 要求所有操作 O(1)(需要结合下一章哈希表的内容)

提示: 本练习需要结合 哈希表 的知识,可以先实现一个简化版(用线性搜索 key),后续优化。

推荐练习题(力扣)

知识点题目建议
循环链表力扣约瑟夫环
双向链表力扣链表
队列(链表实现)力扣队列
链表+队列力扣链表


知识网络