31、动态数据结构(二):单向链表的插入与删除

链表这东西,说白了就是一根绳子串起一堆珠子。

每个珠子(节点)里存着数据,还记着下一个珠子在哪。跟数组不一样,数组是一排连续的座位,链表则是散落在内存各处的碎片,靠指针串起来。

我刚开始学链表时,总觉得它不如数组直观。直到有一次做项目,需要频繁插入删除数据,数组搬来搬去效率太低了,我才真正体会到链表的妙处。

31.1 链表节点的定义

先看看节点长什么样。每个节点包含两部分:数据域和指针域。

typedef struct Node {
    int data;           // 数据域
    struct Node *next;  // 指针域,指向下一个节点
} Node;

嗯,这里要注意:next 是指向自身结构体的指针。这种自引用的定义方式,是链表的基石。

31.2 创建新节点

插入操作之前,得先会创建节点。我习惯写一个辅助函数:

Node* createNode(int data) {
    Node *newNode = (Node*)malloc(sizeof(Node));
    if (newNode == NULL) {
        printf("内存分配失败\n");
        exit(1);
    }
    newNode->data = data;
    newNode->next = NULL;
    return newNode;
}
注意:每次 malloc 后一定要检查返回值!我曾经在一个嵌入式项目里忘了检查,结果内存耗尽时程序直接崩溃,排查了半天才发现是这里的问题。

31.3 头插法

头插法,就是把新节点插到链表最前面。新节点成为新的头节点。

void insertAtHead(Node **head, int data) {
    Node *newNode = createNode(data);
    newNode->next = *head;
    *head = newNode;
}

为什么传二级指针?因为要修改头指针本身。如果你传一级指针,函数里改的是副本,外面根本不知道。

头插法的特点是:插入顺序和链表顺序相反。你插 1、2、3,链表里存的是 3、2、1。我刚开始觉得这很奇怪,后来发现有些场景正好需要这种逆序效果。

31.4 尾插法

尾插法,就是把新节点挂在链表末尾。这样插入顺序和链表顺序一致。

void insertAtTail(Node **head, int data) {
    Node *newNode = createNode(data);
    if (*head == NULL) {
        *head = newNode;
        return;
    }
    Node *temp = *head;
    while (temp->next != NULL) {
        temp = temp->next;
    }
    temp->next = newNode;
}

你想想看,每次尾插都要遍历到链表末尾。如果链表很长,这个操作就很慢。我做过一个日志系统,每秒要插入上千条记录,用尾插法性能完全扛不住。后来改成了头插法,再在读取时反转,才解决了问题。

31.5 中间插法

中间插法,就是在指定位置插入。比如在第 k 个节点后面插入。

void insertAtPosition(Node **head, int data, int position) {
    if (position < 0) return;
    if (position == 0) {
        insertAtHead(head, data);
        return;
    }
    Node *newNode = createNode(data);
    Node *temp = *head;
    for (int i = 0; i < position - 1 && temp != NULL; i++) {
        temp = temp->next;
    }
    if (temp == NULL) {
        printf("位置超出范围\n");
        free(newNode);
        return;
    }
    newNode->next = temp->next;
    temp->next = newNode;
}
小技巧:插入操作的核心就两步:先让新节点指向后一个节点,再让前一个节点指向新节点。顺序不能反!如果先改了前一个节点的 next,你就找不到后面的节点了。

31.6 删除节点

删除节点分三种情况:删头节点、删中间节点、删尾节点。

void deleteNode(Node **head, int key) {
    if (*head == NULL) return;

    Node *temp = *head, *prev = NULL;

    // 如果要删除的是头节点
    if (temp != NULL && temp->data == key) {
        *head = temp->next;
        free(temp);
        return;
    }

    // 查找要删除的节点
    while (temp != NULL && temp->data != key) {
        prev = temp;
        temp = temp->next;
    }

    // 没找到
    if (temp == NULL) return;

    // 断开链接
    prev->next = temp->next;
    free(temp);
}

删除操作最怕什么?最怕内存泄漏。free 掉节点后,那个内存就还给系统了。如果你忘了 free,程序跑久了内存会越占越多。

我曾经踩过的坑:有一次删除节点后,还继续使用那个节点的数据。虽然当时没报错,但数据已经被污染了。记住:free 之后,那个指针就成了野指针,千万别再用!

31.7 释放整个链表

链表用完了,要记得释放。不然内存泄漏等着你。

void freeList(Node **head) {
    Node *temp;
    while (*head != NULL) {
        temp = *head;
        *head = (*head)->next;
        free(temp);
    }
}

释放链表时,一定要先保存下一个节点的地址,再释放当前节点。不然释放完当前节点,你就找不到下一个节点了。

31.8 核心逻辑流程图

下面这张图,把链表的插入、删除、释放串起来了。你看一遍就能理解整体流程。

单向链表操作核心流程 链表操作入口 插入操作 删除操作 释放链表 插入三种方式 • 头插法:新节点成为头 • 尾插法:遍历到末尾插入 • 中间插:找到位置再插入 删除三种情况 • 删头节点:直接改头指针 • 删中间节点:跳过该节点 • 删尾节点:前一个指向NULL 释放链表步骤 • 保存下一个节点地址 • free 当前节点 • 移动到下一个节点 操作完成

31.9 总结一下

链表操作其实就几个核心点:

  • 插入:先连后断,顺序不能乱
  • 删除:找到节点,跳过它,释放它
  • 释放:先存后删,循环到底

我个人觉得,链表是理解指针最好的练习。你把这些操作写熟了,指针这块基本就通了。

核心口诀:

插入先连后断开,删除找到再跳过。

释放链表循环走,先存后删不出错。

专注资料整理