17、指针与链表:单向链表的定义、链表的创建与遍历、链表的插入与删除

指针学到这个阶段,很多同学会问:“指针到底有什么用?”

嗯,前面我们讲了指针和数组、指针和函数,但说实话,那些都只是开胃菜。指针真正的用武之地,是动态数据结构——比如链表。

我个人做嵌入式开发这么多年,链表几乎无处不在。任务队列、内存池管理、缓冲区调度……底层系统里到处都有它的影子。你想想看,如果不用链表,用数组去管理一个动态变化的任务列表,那得多痛苦?

核心认知:链表是“用指针串起来的一组节点”。每个节点存数据,还存一个指向下一个节点的指针。就这么简单。


17.1 单向链表的定义

先看结构体定义。单向链表的每个节点,至少包含两部分:

  • 数据域:存储实际数据
  • 指针域:指向下一个节点
// 单向链表节点定义
struct Node {
    int data;           // 数据域
    struct Node* next;  // 指针域,指向下一个节点
};

这里有个细节:struct Node* next 是一个指向自身结构体的指针。这种写法叫自引用结构体。我在项目中第一次写这个的时候,还愣了一下——结构体里怎么能包含自己?其实它包含的是指针,不是结构体本身,所以没问题。

我的习惯:定义链表节点时,我会用 typedef 给它起个别名,后面写代码会清爽很多:

typedef struct Node {
    int data;
    struct Node* next;
} Node, *PNode;

这样后面声明节点指针时,直接写 PNode p 就行了。


17.2 链表的创建

创建链表,说白了就是动态分配节点,然后把它们串起来

我常用的方式是头插法尾插法。先看头插法:

// 头插法创建链表
Node* createList_head(int arr[], int n) {
    Node* head = NULL;  // 头指针初始为空
    for (int i = 0; i < n; i++) {
        Node* newNode = (Node*)malloc(sizeof(Node));
        newNode->data = arr[i];
        newNode->next = head;  // 新节点指向原来的头
        head = newNode;        // 更新头指针
    }
    return head;
}

头插法的特点是:新节点永远插在最前面。所以最后遍历的时候,数据顺序是反的。如果你希望顺序和数组一致,那就用尾插法:

// 尾插法创建链表
Node* createList_tail(int arr[], int n) {
    Node* head = NULL;
    Node* tail = NULL;
    for (int i = 0; i < n; i++) {
        Node* newNode = (Node*)malloc(sizeof(Node));
        newNode->data = arr[i];
        newNode->next = NULL;
        if (head == NULL) {
            head = newNode;   // 第一个节点
            tail = newNode;
        } else {
            tail->next = newNode;  // 尾节点指向新节点
            tail = newNode;        // 更新尾指针
        }
    }
    return head;
}

我曾经踩过的坑:尾插法里,每次插入后一定要记得更新 tail 指针。有一次我忘了更新 tail,结果所有新节点都插在了同一个位置——链表直接变成死循环。调试了一下午才发现。


17.3 链表的遍历

遍历链表很简单,就是从头节点开始,顺着 next 指针一路走到底

void traverseList(Node* head) {
    Node* p = head;
    while (p != NULL) {
        printf("%d ", p->data);
        p = p->next;  // 移动到下一个节点
    }
    printf("\n");
}

这里有个关键点:遍历时不要修改 head 指针。我见过有人直接拿 head 去遍历,结果遍历完 head 变成 NULL 了,链表丢了。正确的做法是用一个临时指针 p 去走。

我的习惯:遍历时用 for (Node* p = head; p != NULL; p = p->next),这样代码更紧凑,也不容易忘记更新指针。


17.4 链表的插入

插入操作分三种情况:头插、尾插、中间插。核心逻辑都一样:先找到插入位置,然后调整指针。

// 在指定位置之后插入节点
int insertAfter(Node* prev, int data) {
    if (prev == NULL) {
        return -1;  // 前驱节点不能为空
    }
    Node* newNode = (Node*)malloc(sizeof(Node));
    if (newNode == NULL) {
        return -1;  // 内存分配失败
    }
    newNode->data = data;
    newNode->next = prev->next;  // 新节点指向原后继
    prev->next = newNode;        // 前驱指向新节点
    return 0;
}

你看,插入的核心就两行代码:newNode->next = prev->nextprev->next = newNode顺序不能错。如果先把 prev->next 指向 newNode,那后面的节点就找不到了。

避坑指南:我曾经在项目里写插入代码,把两行顺序写反了。结果链表从插入点之后全部丢失。那是一个实时任务调度器,链表断了,任务直接跑飞。从那以后,我每次写插入都会默念一遍:“先连后断”。


17.5 链表的删除

删除操作比插入稍微麻烦一点,因为要处理被删除节点的内存释放

// 删除指定节点的后继节点
int deleteAfter(Node* prev) {
    if (prev == NULL || prev->next == NULL) {
        return -1;  // 没有可删除的节点
    }
    Node* temp = prev->next;       // 暂存要删除的节点
    prev->next = temp->next;       // 跳过该节点
    free(temp);                    // 释放内存
    return 0;
}

删除的关键步骤:

  1. 用临时指针保存要删除的节点
  2. 调整前驱节点的 next 指针,跳过该节点
  3. 调用 free() 释放内存

特别注意:free 之后,那个指针就成了野指针。我建议立即把临时指针置为 NULL,避免误用。虽然 free 之后指针变量还在,但指向的内存已经不属于你了。


17.6 知识体系图

下面这张图总结了单向链表的完整知识结构,从定义到操作,一目了然:

单向链表知识体系 单向链表 1. 节点定义 2. 链表创建 3. 链表遍历 4. 插入操作 5. 删除操作 6. 注意事项 数据域 + 指针域 自引用结构体 头插法 / 尾插法 动态内存分配 先连后断

17.7 总结与经验

单向链表,说白了就是用指针把零散的内存块串成一条链。它的优点是插入和删除快(O(1)),缺点是查找慢(O(n))。

我在实际项目中,链表用得最多的地方是:

  • 任务调度队列:RTOS 里的就绪队列、延时队列
  • 内存池管理:空闲块链表
  • 缓冲区管理:环形缓冲区的链表实现

我的建议:初学者一定要自己手写一遍链表的创建、插入、删除、遍历。不要复制粘贴。写一遍,你才能真正理解指针是怎么“串”起来的。我当年学链表时,光画图就画了十几张纸。

嗯,链表的内容就到这里。记住一句话:指针是链表的灵魂,链表是指针的舞台


公众号:蓝海资料掘金营,微信 deep3321