Lesson 0402 · 链表

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

上节课:顺序表(vector)按下标访问 O(1),但中间插入/删除要 O(n),因为后面所有元素都要搬家。
这节课:链表用「指针」把节点串起来,中间插入/删除只改两个指针,不用搬家。
三句话速览
  1. 节点 = 数据 val + 下一个节点的地址 next。
  2. 头插改两行:新节点先指旧 head,再改 head。
  3. 链表插删 O(1),但按下标访问要 O(n)。

1. 链表长什么样

链表里每个元素叫一个节点(Node),节点只存两样东西:

struct Node {
    int val;       // 数据
    Node* next;    // 指向下一个节点
};

节点在内存里不要求连续,全靠 next 一个个串起来:

head -> [10|next] -> [20|next] -> [30|nullptr]

最后一个节点的 next 是 nullptr,表示「后面没有了」。整条链表只靠一个 head 指针就能找到全部节点——所以 head 是命根子,一丢,整条链表就找不回来了。

2. 手动搭一条链表

#include <iostream>
using namespace std;

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

int main() {
    Node* a = new Node{10, nullptr};
    Node* b = new Node{20, nullptr};
    Node* c = new Node{30, nullptr};

    a->next = b;      // a 后面接 b
    b->next = c;      // b 后面接 c

    // 现在链表是:10 -> 20 -> 30
    return 0;
}

Node* a 是堆上的一个节点。a->next 等价于 (*a).next,意思是「访问 a 指向的那个节点的 next」。

注意最后一条 b->next = c:三个节点是分别 new 出来的,地址互不相邻也没关系,是 next 把它们连成了一条链。

3. 遍历链表

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

逐行看:

循环条件必须是 p != nullptr。如果写成 p->next != nullptr,最后一个节点就不会被访问——这是链表里最常见的差一错误。

4. 头插法:新节点放到最前面

void insertHead(Node*& head, int x) {
    Node* newNode = new Node{x, head};  // 新节点的 next 指向原来的 head
    head = newNode;                     // head 指向新节点
}

注意参数是 Node*& head:我们要修改 main 里的 head 本身,所以要传引用。传值的话改的是副本,函数一返回,main 的 head 还是原来的。

两行的顺序也不能反:先构造新节点、让它接上旧链表,再让 head 指过来。反过来就先丢了旧 head,后面的节点全找不到了。

调用:

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

先插 30、再插 20、最后插 10,结果却是 10 20 30——因为每个新节点都抢到了最前面。头插天然会反转插入顺序,这一点下一课还会用到。

5. 在某个节点后面插入

void insertAfter(Node* p, int x) {
    Node* newNode = new Node{x, p->next};
    p->next = newNode;
}

关键步骤:先让新节点接上 p->next,再把 p->next 改成新节点。顺序不能反:如果先把 p->next 改成新节点,原来 p->next 指向的那半条链表就丢了。

6. 删除某个节点后面的节点

void deleteAfter(Node* p) {
    if (p == nullptr || p->next == nullptr) return;  // 后面没有节点

    Node* toDelete = p->next;
    p->next = toDelete->next;      // 跳过 toDelete
    delete toDelete;               // 释放内存
}

删除也只需要改一个指针:让 p->next 跳过要删的节点。不过跳过之前得先用 toDelete 记住它,否则 delete 的时候就没法引用它了。

7. 链表 vs 顺序表

操作顺序表 vector链表
按下标访问O(1) ✅O(n) ❌
已知位置插入O(n)O(1) ✅
已知位置删除O(n)O(1) ✅
查找某个值O(n)O(n)
内存连续,可能浪费每节点多存一个指针
「已知位置」很重要:链表要在中间插入,得先从头走到那个位置,找位置本身还是 O(n)。如果已经有指向那个节点的指针,插删才是 O(1)。

8. 完整示例:头插 + 遍历

#include <iostream>
using namespace std;

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

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

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

int main() {
    Node* head = nullptr;

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

    printList(head);   // 10 20 30

    return 0;
}

把这段抄进编辑器跑一遍,输出应该是 10 20 30。然后试试把三次 insertHead 的顺序换成 10、20、30,看看输出变成什么——这就是「头插会反转」。

9. 坑点清单

10. 小测

Q1. 链表节点至少包含哪两个部分?
Q2. 链表末尾节点通常怎样表示?
Q3. 头插法 insertHead 中,head 参数为什么传引用?

11. 动手练习

题目1:从输入建链表并打印

先读入 n,再读 n 个数,每次用头插法插入链表,最后遍历输出。
注意:头插法会反转输入顺序,例如输入 3 10 20 30,输出 30 20 10。这是正常的。
题目2(选做):尾插法

实现 insertTail,把新节点放到链表末尾,让输出顺序和输入顺序一样。
卡住时的自查顺序
  1. 先把链表画出来:head -> ... -> nullptr,每一步改完就重画一遍。
  2. 遍历条件是不是 p != nullptr;换成 p->next 会漏最后一个。
  3. 头插的参数是不是 Node*& head;漏了 & 就只改了副本。
  4. 插入时是不是「先接后断」:新节点先指 p->next,再改 p->next。
  5. 边界试过没有:空链表 head == nullptr、只有一个节点、插在末尾。

12. 本课小结

下一课预告
0403 链表进阶 —— 补齐四个最常用的操作:尾插(保持输入顺序)、查找、按值删除(含连续重复值)、释放整条链表,并用指针变化表把「谁指向谁」一步步画清楚。

← 上一课:线性表与顺序表 · 课程对照表

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