首页/数据结构/线性表/单链表的基本操作 🔗 在 Obsidian 中打开
数据结构 · 线性表

单链表的基本操作

重要度 ⭐⭐ 单链表插入删除查找408
速查
单链表是非随机存取结构:按位查找 O(n);插入/删除的主体时间花在查找前驱上,已知结点位置后指针修改本身 O(1)——这是链表的核心优势。

核心概念

单链表是最基本的链式存储结构,用一组任意的存储单元存放数据元素,每个结点包含两个域:

  • 数据域(data):存储元素的信息
  • 指针域(next):存储直接后继结点的存储地址

单链表非随机存取:不能直接访问第 i 个结点,必须从头指针出发沿 next 逐个查找。

两种实现思路

带头结点:第一个结点不存数据,仅作标识。空表与非空表处理统一,无需对首结点特殊处理——408 默认带头结点

不带头结点:第一个结点直接存数据,插入/删除需判断是否修改头指针,代码更复杂。

typedef struct LNode {
    int data;              // 数据域(考试中常用 int)
    struct LNode *next;    // 指针域
} LNode, *LinkList;
408 注意LNode 是结构体类型名,LinkList 是指向该结构体的指针类型。习惯上用 LinkList 声明头指针、用 LNode * 声明结点指针,两者完全等价

算法步骤

插入(第 i 位插入 e)

1. 令 p = L, j = 0
2. 当 j < i-1 且 p != NULL:p = p->next, j++
3. 若 p == NULL,位置不合法,返回 false
4. 分配新结点 s;s->data = e
5. s->next = p->next    // 先接后断!
6. p->next = s          // 再接前
7. 返回 true

删除(删第 i 位,e 带回)

1. 令 p = L, j = 0
2. 当 j < i-1 且 p != NULL:p = p->next, j++
3. 若 p == NULL 或 p->next == NULL,位置不合法
4. 令 q = p->next        // q 指向待删结点
5. e = q->data
6. p->next = q->next     // 跨过待删结点
7. free(q);返回 true

查找

按位 GetElem:p = L->next, j = 1;当 j < i 且 p != NULL 时 p=p->next, j++;返回 p
按值 LocateElem:p = L->next;当 p != NULL 且 p->data != e 时 p = p->next;返回 p
求表长 Length:p = L->next, count = 0;p != NULL 时 count++, p = p->next;返回 count

代码实现(C 语言)

插入操作

// 在第 i 个位置插入元素 e(带头结点)
bool ListInsert(LinkList L, int i, int e) {
    if (i < 1) return false;         // 位置不合法
    LNode *p = L;  int j = 0;
    while (p != NULL && j < i - 1) { // 找第 i-1 个结点
        p = p->next;  j++;
    }
    if (p == NULL) return false;
    LNode *s = (LNode *)malloc(sizeof(LNode));
    s->data = e;
    s->next = p->next;              // 先接后断(核心!)
    p->next = s;
    return true;
}

删除操作

bool ListDelete(LinkList L, int i, int *e) {
    if (i < 1) return false;
    LNode *p = L;  int j = 0;
    while (p != NULL && j < i - 1) { p = p->next; j++; }
    if (p == NULL || p->next == NULL) return false;
    LNode *q = p->next;
    *e = q->data;
    p->next = q->next;               // 断开 q
    free(q);
    return true;
}

头插法建表

LinkList HeadInsert(int a[], int n) {
    LinkList L = (LinkList)malloc(sizeof(LNode));
    L->next = NULL;
    for (int i = 0; i < n; i++) {
        LNode *s = (LNode *)malloc(sizeof(LNode));
        s->data = a[i];
        s->next = L->next;   // 插到头结点之后
        L->next = s;
    }
    return L;
}

尾插法建表

LinkList TailInsert(int a[], int n) {
    LinkList L = (LinkList)malloc(sizeof(LNode));
    LNode *r = L;                    // r 始终指向尾结点
    for (int i = 0; i < n; i++) {
        LNode *s = (LNode *)malloc(sizeof(LNode));
        s->data = a[i];
        r->next = s;
        r = s;
    }
    r->next = NULL;                  // 尾结点的 next 置空
    return L;
}

手算示例

插入:ListInsert(L, 3, 99)

链表:L → [头] → 5 → 12 → 7 → 3 → NULL

  1. $p = L$(头结点),$j = 0$
  2. j < 2,$p = p\text{->next}$(指向 5),$j = 1$
  3. j < 2,$p = p\text{->next}$(指向 12),$j = 2$
  4. $j == 2$ 退出循环,p 指向第 2 个结点(12)
  5. 创建新结点 s,$s\text{->data} = 99$
  6. $s\text{->next} = p\text{->next}$(即 $s\text{->next} = 7$ 的地址)
  7. $p\text{->next} = s$(12 的 next 指向 99)

结果:L → [头] → 5 → 12 → 99 → 7 → 3 → NULL

删除:ListDelete(L, 4, &e)

从上一结果开始:L → [头] → 5 → 12 → 99 → 7 → 3 → NULL

  1. $p = L$,$j = 0$ → 循环找到第 3 个结点(99)
  2. $q = p\text{->next}$(即结点 7
  3. $e = 7$
  4. $p\text{->next} = q\text{->next}$(即 99 → 3)
  5. free(q)

结果:L → [头] → 5 → 12 → 99 → 3 → NULL

时间 / 空间复杂度

操作时间复杂度说明
按位查找 GetElemO(n)从头遍历到第 i 个结点
按值查找 LocateElemO(n)最坏遍历整个链表
插入 ListInsertO(n)查找 O(n) + 插入 O(1)
删除 ListDeleteO(n)查找 O(n) + 删除 O(1)
头插法建表O(n)每个元素 O(1),共 n 个
尾插法建表O(n)每个元素 O(1),共 n 个
求表长O(n)需要遍历整个链表

空间复杂度:所有操作均为 O(1) 额外空间(除建表需 O(n) 存储结点)。

关键对比插入/删除的时间主要花在查找上,真正修改指针的操作是 O(1)。==已知结点位置后,插入/删除只需 O(1)==,这是链表相对顺序表的优势。

常见考法

  1. 代码填空:给出插入/删除代码框架,填写空缺的指针操作语句(出现频率最高)
  2. 算法设计题:链表逆置(头插法思想);删除链表中值为 x 的所有结点;查找倒数第 k 个结点(双指针法);两个有序链表归并;判断链表是否有环(快慢指针)
  3. 手算指针操作:画出插入/删除过程中指针变化
  4. 头插法 vs 尾插法:询问建表结果或特性

易错点

易错清单
  1. 插入顺序必须是"先接后断":$s\text{->next} = p\text{->next}$ 必须在 $p\text{->next} = s$ 之前,否则原链表中 p 后面的结点全部丢失。
  2. 查找时 j 的初始值:带头结点 $j=0$,不带头结点 $j=1$,循环条件不同。
  3. 删除操作要判断 p->next != NULL(第 i 个结点存在)。
  4. 内存泄漏:删除结点后必须 free(q)
  5. 不带头结点时删除/插入第一个结点需修改头指针 L,必须单独处理。
  6. 头插法建表顺序相反:输入 1,2,3 → 得到 3,2,1。

核心结论

  1. 单链表是非随机存取结构,按位查找/插入/删除都需要 O(n) 时间。
  2. 已知结点指针后,插入和删除本身是 O(1)(链表的核心优势)。
  3. 头插法结果与输入顺序相反,尾插法结果与输入顺序相同
  4. 带头结点的链表统一空表和非空表的操作,减少边界判断。
  5. 链表适合频繁插入/删除的场景,不适合频繁按位查找。
  6. 单链表只能单向遍历,无法直接找到前驱结点(需从头重遍历)。

记忆卡片

插入两条语句为何不能颠倒?
先 $s\text{->next}=p\text{->next}$ 再 $p\text{->next}=s$;颠倒会丢失后继。口诀:"先接后断,不断不乱"。
头插法与尾插法建表区别?
头插结果与输入相反;尾插一致但需维护尾指针 r。
为何 408 推荐带头结点?
空表/非空表处理统一,首结点操作无需修改头指针。
插入删除是 O(1) 还是 O(n)?
已知结点位置 → O(1);需先查找 → 整体 O(n)。
不带头结点删首结点特殊在哪?
需修改头指针 $L = L\text{->next}$;带头结点则无需区分。
如何判断链表有环?
快慢指针:fast 每次 2 步、slow 每次 1 步,相遇则有环。

交互动画 · 快慢指针判环 & 头插法逆置

带环链表 1→2→3→4→5→(回到3),快慢指针同时从 1 出发 1 2 3 4 5 S F
快慢指针判环:slow 每次 1 步,fast 每次 2 步
点击「判环 · 走一步」观察两指针位置;或「逆置 · 下一步」观察逆置过程
快慢指针相遇说明有环(fast 每次快 1 步,必定追上 slow);链表逆置用头插法思想,每步摘取原链首元素插到新链表头部。

相关知识点

sequential-and-linked-storage array-vs-linked-list static-linked-list singly-linked-list-implementation

↑ 以上为站内 HTML 相对链接(纯网页可浏览);本页右上「在 Obsidian 中打开」跳回源笔记。