C语言链表完全指南:从单节点到链表管理

引言

在数据结构的学习中,我们首先学习了顺序表(数组)。顺序表虽然访问速度快,但插入和删除操作需要移动大量元素,效率较低。此外,顺序表的大小固定,扩容需要重新分配内存并拷贝数据。

链表 解决了这些问题。链表中的元素在内存中不连续存储,通过指针连接形成逻辑上的线性结构。

今天,我们将从零开始实现一个完整的带头结点的单向链表,涵盖初始化、插入、删除、遍历等核心操作。


第一部分:链表的基本概念

一、什么是链表?

链表是一种物理存储单元上非连续、非顺序的线性结构。数据元素之间的逻辑关系通过指针链接实现。

链表的分类

分类标准 类型 说明
方向 单向链表 每个节点只有一个指向后继的指针
双向链表 每个节点有前驱和后继两个指针
是否循环 非循环链表 尾节点指针指向NULL
循环链表 尾节点指针指向头节点
是否有头结点 带头结点 头结点不存储数据,简化操作
不带头结点 第一个节点就存储数据

本文实现的是带头结点的单向非循环链表

二、带头结点的优势

特点 不带头结点 带头结点
空表判断 head == NULL head->next == NULL
头插操作 需要修改头指针 操作统一,不修改头指针
删除头节点 需要修改头指针 操作统一
代码复杂度 较高 较低

第二部分:数据结构设计

一、节点结构体

cpp 复制代码
typedef int ElemType;  // 链表存储的数据类型

// 节点结构体
typedef struct ListNode {
    ElemType data;           // 数据域:存储实际数据
    struct ListNode* next;   // 指针域:指向下一个节点
} ListNode;

节点结构体大小

cpp 复制代码
┌─────────────────────────────────────────────┐
│                ListNode 节点                │
├─────────────────────────────────────────────┤
│  data (4字节)  │  next (8字节,64位系统)     │
└─────────────────────────────────────────────┘
总大小:12字节(可能内存对齐后为16字节)

二、链表管理结构体

cpp 复制代码
// 链表管理结构体
struct LinkList {
    ListNode* head;      // 指向头结点(不存储数据)
    size_t cursize;      // 当前链表中的元素个数
};

设计意图

  • head:始终指向头结点,头结点的next指向第一个实际存储数据的节点

  • cursize:记录链表长度,避免每次遍历计算


第三部分:链表的基本操作

一、初始化

原理 :创建头结点,让head指向它,头结点的next指向NULL

cpp 复制代码
void InitLinkList(struct LinkList* ps) {
    assert(ps != NULL);
    
    // 分配头结点内存
    ListNode* p = (ListNode*)malloc(sizeof(ListNode));
    if (p == NULL) return;
    
    p->data = 0;      // 头结点数据域无用,可任意赋值
    p->next = NULL;   // 初始时没有实际节点
    
    ps->head = p;
    ps->cursize = 0;
}

初始化后的内存布局

二、创建新节点(辅助函数)

cpp 复制代码
ListNode* BuyNode(ElemType val) {
    ListNode* p = (ListNode*)malloc(sizeof(ListNode));
    if (p == NULL) return p;
    
    p->data = val;
    p->next = NULL;
    return p;
}

三、遍历与打印

cpp 复制代码
void PrintLinkList(struct LinkList* ps) {
    assert(ps != NULL);
    
    // 从第一个实际节点开始遍历(跳过带头结点)
    for (ListNode* p = ps->head->next; p != NULL; p = p->next) {
        printf("%d ", p->data);
    }
    printf("\n");
}

第四部分:插入操作

一、尾插(在尾部插入)

原理:遍历到尾节点,将新节点链接到尾部。

cpp 复制代码
// 时间复杂度:O(n)
bool InsertBack(struct LinkList* ps, ElemType val) {
    assert(ps != NULL);
    
    ListNode* p = BuyNode(val);
    if (p == NULL) return false;
    
    // 找到尾节点
    ListNode* q = ps->head;
    while (q->next != NULL) {
        q = q->next;
    }
    
    // 链接新节点
    p->next = q->next;  // 等价于 p->next = NULL
    q->next = p;
    ps->cursize++;
    
    return true;
}

示意图

cpp 复制代码
插入前 : head → ┌───┐   ┌───┐   ┌───┐
                │10 │→  │20 │→  │30 │→ NULL
                └───┘   └───┘   └───┘
插入val=40后:
                ┌───┐   ┌───┐   ┌───┐   ┌───┐
                │10 │→  │20 │→  │30 │→  │40 │→ NULL
                └───┘   └───┘   └───┘   └───┘

二、头插(在头部插入)

原理 :新节点的next指向原来的第一个节点,头结点指向新节点。

cpp 复制代码
// 时间复杂度:O(1)
bool InsertFront(struct LinkList* ps, ElemType val) {
    assert(ps != NULL);
    
    ListNode* p = BuyNode(val);
    if (p == NULL) return false;
    
    p->next = ps->head->next;  // 新节点指向原第一个节点
    ps->head->next = p;         // 头结点指向新节点
    ps->cursize++;
    
    return true;
}

示意图

三、按位置插入

原理 :找到第pos-1个节点,在其后插入新节点。

cpp 复制代码
// 时间复杂度:O(n)
bool InsertPos(struct LinkList* ps, ElemType val, int pos) {
    assert(ps != NULL);
    
    // 位置有效性检查:1 ≤ pos ≤ cursize+1
    if (pos < 1 || pos > ps->cursize + 1) return false;
    
    ListNode* p = BuyNode(val);
    if (p == NULL) return false;
    
    // 找到第 pos-1 个节点
    ListNode* q = ps->head;
    for (int i = 0; i < pos - 1; i++) {
        q = q->next;
    }
    
    // 插入新节点
    p->next = q->next;
    q->next = p;
    ps->cursize++;
    
    return true;
}

第五部分:删除操作

一、尾删(删除尾部节点)

原理 :找到倒数第二个节点,将其next设为NULL,释放尾节点。

cpp 复制代码
// 时间复杂度:O(n)
bool PopBack(struct LinkList* ps) {
    assert(ps != NULL);
    if (ps->cursize == 0) return false;  // 空链表
    
    // 找到倒数第二个节点
    ListNode* q = ps->head;
    while (q->next->next != NULL) {
        q = q->next;
    }
    
    // 释放尾节点
    ListNode* p = q->next;
    q->next = p->next;  // 即 q->next = NULL
    free(p);
    p = NULL;
    ps->cursize--;
    
    return true;
}

二、头删(删除头部节点)

原理:头结点绕过第一个节点,指向第二个节点,释放第一个节点。

cpp 复制代码
// 时间复杂度:O(1)
bool PopFront(struct LinkList* ps) {
    assert(ps != NULL);
    if (ps->cursize == 0) return false;
    
    ListNode* p = ps->head->next;  // 要删除的节点
    ps->head->next = p->next;       // 头结点指向第二个节点
    free(p);
    p = NULL;
    ps->cursize--;
    
    return true;
}

三、按位置删除

cpp 复制代码
// 时间复杂度:O(n)
bool PopPos(struct LinkList* ps, int pos) {
    assert(ps != NULL);
    if (ps->cursize == 0) return false;
    if (pos < 1 || pos > ps->cursize) return false;
    
    // 找到第 pos-1 个节点
    ListNode* q = ps->head;
    for (int i = 0; i < pos - 1; i++) {
        q = q->next;
    }
    
    // 删除第 pos 个节点
    ListNode* p = q->next;
    q->next = p->next;
    free(p);
    p = NULL;
    ps->cursize--;
    
    return true;
}

第六部分:完整测试代码

cpp 复制代码
#include "List.h"
#include <stdio.h>

int main() {
    struct LinkList list;
    
    // 1. 初始化
    InitLinkList(&list);
    printf("初始化后,元素个数:%zu\n", list.cursize);
    
    // 2. 头插
    InsertFront(&list, 10);
    InsertFront(&list, 20);
    InsertFront(&list, 30);
    printf("头插后:");
    PrintLinkList(&list);  // 30 20 10
    
    // 3. 尾插
    InsertBack(&list, 40);
    InsertBack(&list, 50);
    printf("尾插后:");
    PrintLinkList(&list);  // 30 20 10 40 50
    
    // 4. 按位置插入
    InsertPos(&list, 100, 3);  // 在第3个位置插入100
    printf("位置插入后:");
    PrintLinkList(&list);  // 30 20 100 10 40 50
    
    // 5. 头删
    PopFront(&list);
    printf("头删后:");
    PrintLinkList(&list);  // 20 100 10 40 50
    
    // 6. 尾删
    PopBack(&list);
    printf("尾删后:");
    PrintLinkList(&list);  // 20 100 10 40
    
    // 7. 按位置删除
    PopPos(&list, 2);
    printf("删除位置2后:");
    PrintLinkList(&list);  // 20 10 40
    
    return 0;
}

第七部分:顺序表 vs 链表

操作 顺序表 链表 说明
按下标访问 O(1) O(n) 链表需要遍历
头部插入 O(n) O(1) 链表只需改指针
头部删除 O(n) O(1) 链表只需改指针
尾部插入 O(1)(扩容时O(n)) O(n) 链表需遍历到尾
中间插入 O(n) O(n) 都需要定位
内存占用 较少 较多 链表有指针开销
缓存友好 顺序表内存连续

选择建议

  • 频繁按下标访问 → 顺序表

  • 频繁头插/头删 → 链表

  • 频繁尾插 → 顺序表(或维护尾指针的链表)

  • 大小频繁变化 → 链表


第八部分:常见错误与注意事项

一、指针操作错误

cpp 复制代码
// ❌ 错误:p未初始化就修改next
ListNode* p;
p->next = NULL;

// ✅ 正确:先分配内存
ListNode* p = (ListNode*)malloc(sizeof(ListNode));
p->next = NULL;

二、内存泄漏

cpp 复制代码
// ❌ 错误:删除节点后未释放内存
q->next = p->next;
// p 的内存泄漏了

// ✅ 正确:释放内存
q->next = p->next;
free(p);
p = NULL;

三、空指针解引用

cpp 复制代码
// ❌ 错误:未检查链表是否为空
bool PopFront(struct LinkList* ps) {
    ListNode* p = ps->head->next;
    ps->head->next = p->next;  // 如果p是NULL,这里崩溃
    free(p);
    return true;
}

// ✅ 正确:检查空链表
bool PopFront(struct LinkList* ps) {
    if (ps->cursize == 0) return false;
    // ... 正常删除逻辑
}

总结

一、链表操作复杂度总结

操作 时间复杂度 代码关键点
初始化 O(1) 创建头结点
尾插 O(n) 遍历到尾节点
头插 O(1) 新节点指向原第一个节点
中间插入 O(n) 定位到前驱节点
尾删 O(n) 找到倒数第二个节点
头删 O(1) 头结点指向第二个节点
中间删除 O(n) 定位到前驱节点
遍历 O(n) 从头结点下一个开始

二、带头结点的优势

场景 不带头结点 带头结点
空表判断 head == NULL head->next == NULL
头插操作 需要修改头指针 操作统一
删除头节点 需要修改头指针 操作统一

三、核心函数速查

函数 功能 关键操作
InitLinkList 初始化 创建头结点
BuyNode 创建节点 malloc + 赋值
InsertBack 尾插 遍历到尾 + 链接
InsertFront 头插 头结点后插入
InsertPos 位置插入 找前驱 + 链接
PopBack 尾删 找倒数第二个 + 释放
PopFront 头删 头结点绕过第一个
PopPos 位置删除 找前驱 + 释放

链表是数据结构中非常重要的基础结构。本文实现了带头结点的单向链表,涵盖以下知识点:

  1. 节点结构:包含数据域和指针域

  2. 头结点:简化插入删除操作

  3. 插入操作:头插、尾插、按位置插入

  4. 删除操作:头删、尾删、按位置删除

  5. 内存管理:malloc分配、free释放

  6. 复杂度分析:理解各操作的时间复杂度

学习建议

  1. 画图理解指针操作,不要只靠想象

  2. 注意边界条件(空链表、单节点链表)

  3. 插入和删除时确保指针指向正确

  4. 释放内存后将指针置NULL防止野指针

相关推荐
『昊纸』℃1 小时前
C语言程序设计从入门到进阶【比特鹏哥c语言2023完整版视频教程】(c语言基础入
c语言·函数·常量·编程教程·变量作用域
小小de风呀1 小时前
de风——【从零开始学C++】(四):类和对象(下)
开发语言·c++·算法
aqiu1111112 小时前
[特殊字符]【算法日记 14】数论入门神题:最大公约数与最小公倍数的“乘积守恒定律”
算法
保卫大狮兄2 小时前
一文讲清:仓库管理最核心的10个公式
人工智能·算法·仓库管理
凉、介3 小时前
Armv8-A virtualization 笔记 (一)
c语言·笔记·学习·嵌入式·虚拟化·hypervisor
翻身的咸鱼ing3 小时前
常用代码知识
算法·深度优先·哈希算法
feifeigo1233 小时前
自适应大邻域搜索(ALNS)算法的MATLAB 实现
开发语言·算法·matlab
RH2312113 小时前
2026.4.29数据结构 直接插入排序&&希尔排序
数据结构·算法·排序算法
搬砖的小码农_Sky4 小时前
AI Agent:OpenClaw的算法架构
人工智能·算法·ai·架构·人机交互·agi