Lesson 0403 · 链表进阶:尾插、查找、删除与释放

④ 数据结构与算法 — 第 3 课

上节课:链表节点 val + next,会用头插法和遍历。
这节课:补齐最常用的四个操作——尾插法、查找、删除、释放整条链表。每个操作都只动指针,但「先动哪一根」有讲究。
三句话速览
  1. 尾插:走到最后一个节点,把新节点接在它后面。
  2. 删除分两段:先删头,再删中间;删完 p 不动。
  3. 释放就是反复删头节点,最后 head 变成 nullptr。

1. 尾插法:保持输入顺序

头插法会把顺序反过来。尾插法把新节点放到最后,输入和输出顺序就一样了。

void insertTail(Node*& head, int x) {
    Node* newNode = new Node{x, nullptr};

    if (head == nullptr) {        // 链表是空的:新节点就是头
        head = newNode;
        return;
    }

    Node* p = head;
    while (p->next != nullptr) {   // 走到最后一个节点
        p = p->next;
    }
    p->next = newNode;            // 最后节点接上新节点
}

调用:

Node* head = nullptr;
insertTail(head, 10);
insertTail(head, 20);
insertTail(head, 30);
// 链表:10 -> 20 -> 30

指针是怎么动的?拿「往 10 → 20 后面接 30」这一趟来看:

步骤代码谁指向谁链表
初始p = headp → 1010 → 20
1while (p->next != nullptr) p = p->next;10->next 是 20,p 走到 20;20->next 是 nullptr,停10 → 20
2newNode = new Node{30, nullptr}newNode → 30,30 的 next 暂时为空10 → 20 30
3p->next = newNode20 的 next 从 nullptr 改成 3010 → 20 → 30

尾插唯一容易错的地方是空链表:这时 p = head 就是 nullptr,再去读 p->next 会直接崩。所以循环之前必须先判 head == nullptr。

回看头插:它其实是在反转

头插是「每次把新节点塞到队首」,所以先来的反而被挤到后面。用上节课的例子(依次插入 30、20、10):

步骤操作head 指向链表
初始head = nullptr空空
1insertHead(head, 30)head → 3030
2insertHead(head, 20)head → 20 → 3020 → 30
3insertHead(head, 10)head → 10 → 20 → 3010 → 20 → 30

输入 30 20 10,输出 10 20 30:头插就是原地把顺序倒过来。尾插之所以能保持顺序,是因为它每次都老老实实走到队尾,让先来的留在前面。

这个尾插版本每次都要从头走到尾。连续插 n 个数,总复杂度是 O(n²)。改进方法是维护一个 tail 指针,直接记住尾巴。我们先理解这个简单版,课后练习再升级。

2. 查找:返回节点地址

Node* find(Node* head, int x) {
    for (Node* p = head; p != nullptr; p = p->next) {
        if (p->val == x) return p;   // 找到就返回这个节点
    }
    return nullptr;                   // 没找到
}

如果返回 nullptr,说明链表中没有这个值。调用方拿到的如果是 nullptr,就不能再写 result->val——先判空再用。

3. 按值删除:删掉所有等于 x 的节点

删除分两步处理:先处理头部连续等于 x 的情况,再处理中间节点。

void deleteValue(Node*& head, int x) {
    // 1. 如果头节点就是要删的值,把头指针后移
    while (head != nullptr && head->val == x) {
        Node* tmp = head;
        head = head->next;
        delete tmp;
    }

    // 2. 处理中间节点
    Node* p = head;
    while (p != nullptr && p->next != nullptr) {
        if (p->next->val == x) {
            Node* tmp = p->next;
            p->next = tmp->next;      // 跳过 tmp
            delete tmp;
        } else {
            p = p->next;              // 只有没删时才往后走
        }
    }
}

重点:删除成功后 p 不前进,因为新的 p->next 可能还是 x。

拿本课完整示例的链表 10 → 20 → 30 → 20、执行 deleteValue(head, 20),把「谁指向谁」一步步画出来:

步骤发生了什么谁指向谁链表
0检查头节点head->val 是 10,不等于 20,头不动;p 从 10 出发10 → 20 → 30 → 20
1p->next 是 20,命中tmp 记住 20;p->next 改成 tmp->next(30)10 → 30 → 20
20 已脱链
2删完 p 不前进新的 p->next 是 30,不等于 20,p 才移到 3010 → 30 → 20
3p->next 是 20,又命中tmp 记住 20;p->next 改成 tmp->next(nullptr)10 → 30
4p->next 是 nullptr循环结束,head 仍指向 1010 → 30

第 2 步是整段代码的灵魂:如果删完立刻 p = p->next,就会跳过后一个 20,最后链表中还剩一个。所以删完必须让 p 留在原地,重新检查新的 p->next。

4. 释放整条链表

void freeList(Node*& head) {
    while (head != nullptr) {
        Node* tmp = head;
        head = head->next;
        delete tmp;
    }
}

每次先记住当前节点,头指针后移,再删掉刚记住的节点。顺序是记 → 移 → 删:先 delete head 再读 head->next,读的就是已经释放的内存。最后 head 自动变成 nullptr。

5. 完整示例:尾插 + 查找 + 删除 + 释放

#include <iostream>
using namespace std;

struct Node {
    int val;
    Node* next;
};

// ==== 建表:尾插 ====
void insertTail(Node*& head, int x) {
    Node* newNode = new Node{x, nullptr};
    if (head == nullptr) {
        head = newNode;
        return;
    }
    Node* p = head;
    while (p->next != nullptr) p = p->next;
    p->next = newNode;
}

// ==== 查找 ====
Node* find(Node* head, int x) {
    for (Node* p = head; p != nullptr; p = p->next) {
        if (p->val == x) return p;
    }
    return nullptr;
}

// ==== 按值删除 ====
void deleteValue(Node*& head, int x) {
    while (head != nullptr && head->val == x) {
        Node* tmp = head;
        head = head->next;
        delete tmp;
    }

    Node* p = head;
    while (p != nullptr && p->next != nullptr) {
        if (p->next->val == x) {
            Node* tmp = p->next;
            p->next = tmp->next;
            delete tmp;
        } else {
            p = p->next;
        }
    }
}

// ==== 打印 ====
void printList(Node* head) {
    for (Node* p = head; p != nullptr; p = p->next) {
        cout << p->val << " ";
    }
    cout << endl;
}

// ==== 释放 ====
void freeList(Node*& head) {
    while (head != nullptr) {
        Node* tmp = head;
        head = head->next;
        delete tmp;
    }
}

// ==== main ====
int main() {
    Node* head = nullptr;

    insertTail(head, 10);
    insertTail(head, 20);
    insertTail(head, 30);
    insertTail(head, 20);

    printList(head);            // 10 20 30 20

    cout << (find(head, 30) != nullptr) << endl;   // 1

    deleteValue(head, 20);
    printList(head);            // 10 30

    freeList(head);
    return 0;
}

6. 坑点清单

7. 小测

Q1. 尾插法把新节点放在哪里?
Q2. 查找失败时 find 返回什么?
Q3. 删除中间节点后,p 为什么不马上往后走?

8. 动手练习

题目1:尾插 + 删除

读入 n 和 n 个数,用尾插法建链表;再读入 x,删除链表中所有等于 x 的节点;输出剩余链表;最后释放链表。
题目2(选做):优化尾插法

用 tail 指针维护链表尾部,让每次 insertTail 都是 O(1),不用从头走到尾。
卡住时的自查顺序
  1. 先把链表画出来,每改一根指针就重画一遍,看有没有节点脱链。
  2. 尾插:有没有先判 head == nullptr;循环是不是停在最后一个节点。
  3. 删除:删的是不是「p->next」而不是 p;删完 p 有没有乱动。
  4. 释放:顺序是不是「记 → 移 → 删」,中间有没有已经释放还去读的指针。
  5. 边界:空链表、只有一个节点、要删的正好是头、连续两个相同的值。

9. 本课小结

下一课预告
0404 树与二叉树基础 —— 从「一个 next」变成「left + right」,一条链表长成一棵树。会讲清根、父、子、叶子、深度,以及前序 / 中序 / 后序 / 层序四种遍历在同一棵小树上分别访问出什么顺序。

← 上一课:链表 · 课程对照表

书籍 《算法 (第4版)》第 1.3 节(链表)