单链表查找、插入、删除详解:从原理到C代码实现
2026/9/9 13:24:44 网站建设 项目流程

写单链表之前,先想清楚一个问题:为什么那么多初学数据结构的人,在“查找、插入、删除”这一节卡了很久?

因为数组太“温柔”了。数组的逻辑是连续的,你要删除一个元素,后面的全部往前挪;你要插入一个元素,后面的全部往后挪。这种“大规模搬移”在数据量小的时候无所谓,一旦数据量到达百万、千万级别,每一次插入删除都伴随着大量数据拷贝,性能立刻崩盘。

单链表之所以被设计出来,核心目的只有一个:牺牲随机访问的能力,换来插入和删除的 O(1) 复杂度

但正因为它的物理存储不连续,导致“查”需要从头开始走,而“插”和“删”又容易把链子弄断。很多同学写代码时不是不会背逻辑,而是一到具体操作就不知道 next 指针该什么时候改、改完会不会丢结点、顺序反了会不会死循环。

这篇文章会从零开始,把单链表的查找、插入、删除三个核心操作完整拆开讲清楚。你会看到三个层面的东西:

  1. 逻辑层面:每一步操作的指针变化过程。
  2. 代码层面:基于严蔚敏《数据结构(C语言版)》带头结点单链表的完整实现。
  3. 实战层面:为什么 pre 指针这么重要、为什么删除后要 free、为什么很多教材课后题和考研 408 都在考这几个操作。

我们不用死记代码,而是把一个结点当成一个“纸条”,把 next 指针当成“纸条上的线索”。搞懂线索怎么传递,代码自然就写出来了。

1. 单链表的存储结构:先认识基本单元

在写查找、插入、删除之前,必须先搞清楚单链表在内存里长什么样。

单链表的基本存储单元是结点(Node),每个结点由两部分组成:

  • 数据域(data):存放该结点的数据。
  • 指针域(next):存放下一个结点的地址。

在 C 语言中,结点的定义通常长这样:

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

注意一个细节:struct LNode *next不能写成LNode *next,因为在这个结构体声明的内部,类型别名LNode还没有定义完,必须用完整的struct LNode来声明指针。很多新手在这里报编译错误,就是没理解 C 语言的声明顺序。

再看看整个单链表的物理形态,假设有 3 个数据元素A、B、C,它们的实际存储可能是:

地址0x100: A | 0x108 地址0x108: B | 0x200 地址0x200: C | NULL

每个结点的地址是分散的,靠next字段串成一条逻辑上的链。头指针L指向第一个结点。

这里有一个非常关键的概念:带头结点 vs 不带头结点

带头结点的单链表会在第一个数据结点之前增加一个额外的结点,叫头结点。头结点的数据域可以留空,也可以存链表长度等信息,它的 next 才指向第一个真正存储数据的结点。头指针L指向头结点。

为什么很多教材(尤其是严蔚敏版和 408 统考教材)都默认带头结点?因为带头结点之后,空表和非空表的处理逻辑可以统一

  • 带头结点时,空表就是L->next == NULL,而L始终有效。
  • 不带头结点时,空表就是L == NULL,一旦在头部插入或删除,必须修改头指针本身,需要用到二级指针或返回新头指针。

所以后面的所有代码,统一基于带头结点的单链表来写。这是最符合考试和工程使用习惯的写法。接下来先解决一个问题:如何把一个链表建立起来?因为查、插、删都得先有一张表。

2. 创建单链表:头插法和尾插法

很多教程把“创建”单独拆开讲,但实际上查找、插入、删除操作都会在创建过程中反复出现。头插法本质是“在头部反复插入”,尾插法本质是“在尾部反复插入”。搞懂创建过程,后面的核心操作就完成了一半。

下表是两种建表方式的对比:

建表方式思路插入位置元素结果顺序时间复杂度
头插法每次新结点插入到链表头部头结点之后与输入顺序相反O(n)
尾插法每次新结点追加到链表尾部链表尾部与输入顺序一致O(n)

2.1 头插法示例

#include <stdio.h> #include <stdlib.h> typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; // 头插法建立单链表 LinkList List_HeadInsert(LinkList &L, int arr[], int n) { L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; // 初始为空表 for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = arr[i]; s->next = L->next; // 新结点的 next 指向原本的第一个数据结点 L->next = s; // 头结点的 next 指向新结点 } return L; }

这段代码中,最关键的是这两个赋值:

s->next = L->next; L->next = s;

顺序不能颠倒。如果先执行L->next = s,那么原来的第一个数据结点地址就丢了,s->next = L->next这一步就会把 s 自己指向自己,造成链表断裂或死循环。

2.2 尾插法示例

尾插法需要一个额外的r指针始终指向当前链表的尾部:

LinkList List_TailInsert(LinkList &L, int arr[], int n) { L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; LNode *r = L; // r 指向尾结点 for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = arr[i]; s->next = NULL; r->next = s; // 当前尾结点的 next 指向新结点 r = s; // 更新尾指针 } return L; }

尾插法的优点是生成的链表顺序和输入顺序完全一致,更符合人的直觉。在后面的完整示例中,默认使用尾插法。

这里要提醒一个重要问题:arr是在栈上分配的数组,malloc出来的结点在堆上。建表之后,栈上的数组生命周期结束并不影响链表,因为链表已经通过malloc在堆上给每个结点分配了独立空间。

3. 单链表的查找操作

查找是单链表最基础的操作,也是插入和删除的前置步骤。单链表是顺序存取结构,它没有数组那样的随机访问能力,所以查找只能从头开始,沿着 next 逐个遍历。

单链表的查找分成两种典型场景:

  1. 按位查找:给定序号 i,找第 i 个结点。
  2. 按值查找:给定值 e,找第一个 data 等于 e 的结点。

两种查找的时间复杂度都是 O(n),这正好呼应了数组 O(1) 随机访问和链表 O(n) 顺序访问的核心差异。

3.1 按位查找

按位查找的思路非常直接:从头结点的 next 出发,走 i-1 步,到达第 i 个结点。需要注意的是,这里有一个绝大多数新手都会犯的错误——第 i 个结点到底是哪一个结点

头结点不算数据结点。所以p = L->next时,p 指向的是第 1 个数据结点。要找第 i 个结点,循环i - 1次。

// 按位查找:返回第 i 个结点 LNode *GetElem(LinkList L, int i) { if (i < 1) { return NULL; } LNode *p = L->next; // 第 1 个数据结点 int j = 1; while (p != NULL && j < i) { p = p->next; j++; } return p; // 如果 i 超出链表长度,返回 NULL }

这里有一个「返回 NULL」的细节:如果 i 大于链表长度,循环会在p == NULL时提前退出,返回 NULL。所以调用者必须对 NULL 做判断,不能直接访问p->data,否则就会发生空指针访问。

3.2 按值查找

按值查找也很简单,从第一个数据结点开始,依次比较 data 字段,找到直接返回该结点,找不到返回 NULL:

// 按值查找:返回第一个 data 等于 e 的结点 LNode *LocateElem(LinkList L, int e) { LNode *p = L->next; while (p != NULL && p->data != e) { p = p->next; } return p; }

这个函数返回的是第一个匹配的结点地址。如果链表里有多个相同值,它只返回第一个。如果希望返回所有匹配结点,就需要把逻辑改成在循环里逐个输出。

3.3 查找操作的时间复杂度分析

操作最好情况最坏情况平均情况
按位查找第 i 个结点O(1),i=1 时O(n),i=n 时O(n)
按值查找O(1),第一个就是O(n),最后一个才是O(n)

这个 O(n) 不是缺陷,而是单链表为了支持 O(1) 插入删除所付出的代价。理解这一点,才能理解为什么工程上频繁查找的场景要选数组或哈希表,频繁插入删除的场景才选链表。

4. 单链表的插入操作

插入操作是单链表中最重要的部分,因为它决定了你是否真正理解了指针的概念。很多人觉得指针难,其实不是指针难,而是搞不清楚“一个指针变量里存的是谁的地址、修改这个指针会影响谁”。

单链表的插入按位置可以分成三种典型情况:

  1. 头插:在头结点之后插入新结点,新结点成为第 1 个数据结点。
  2. 尾插:在最后一个结点之后插入新结点,新结点成为新的尾结点。
  3. 中间插入:在第 i 个位置插入新结点,需要先找到第 i-1 个结点。

其中头插的本质就是在第 1 个位置插入,尾插本质是在第 n+1 个位置插入。所以只要把“在第 i 个位置前插入”这个核心操作写清楚,其他两种情况自然解决。

4.1 在第 i 个位置插入结点

分两步:

第一步,调用GetElem(L, i-1)找到前驱结点p。 第二步,让新结点s的 next 指向 p 原来的后继,再让 p 的 next 指向 s。

// 在第 i 个位置插入元素 e bool ListInsert(LinkList L, int i, int e) { if (i < 1) { return false; } LNode *p = GetElem(L, i - 1); // 找到第 i-1 个结点 if (p == NULL) { return false; // i 超出范围 } LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = e; s->next = p->next; // 新结点先接上原来的下一个结点 p->next = s; // 前驱结点再接上新结点 return true; }

这里的关键代码是:

s->next = p->next; p->next = s;

如果顺序颠倒,先执行p->next = s,那 p 的原始后继就找不到了,s 的 next 不知道该指向哪里,链表在插入点之后的部分就彻底丢失。凡是涉及链表指针修改,永远先接后断,这句话值得反复默念。

4.2 前插操作:把“查前驱”省略掉的技巧

假设知道一个结点的地址p,想在 p 前面插入一个新结点 s,按常规思路必须从头遍历找到 p 的前驱。这是 O(n) 的操作。

但单链表有一个经典技巧:先把 s 插到 p 的后面,再交换 s 和 p 的 data。这样从最终结果看,s 就在 p 的前面了,而时间复杂度是 O(1)。

// 在结点 p 之前插入结点 s,时间复杂度 O(1) bool InsertPriorNode(LNode *p, LNode *s) { if (p == NULL || s == NULL) { return false; } s->next = p->next; // 先把 s 插到 p 后面 p->next = s; // 交换 data,相当于 s 到了 p 的前面 int temp = p->data; p->data = s->data; s->data = temp; return true; }

这个技巧在考研 408 算法题里经常出现,因为题目往往只给某个结点的指针,不给你头指针。如果不会这个 O(1) 前插技巧,题目就会变成 O(n) 的遍历查找,虽然也能解,但复杂度不达标。

不过也要提醒一句:这个技巧只适用于可以交换数据域的场合。如果数据域是一个很大的结构体,交换 data 的代价可能比遍历还高。这时候就要评估到底用哪种方案。

4.3 插入操作的时间复杂度

插入位置平均移动次数时间复杂度
已知结点 p,在 p 之后插入0O(1)
已知结点 p,在 p 之前插入0(交换数据)O(1)
按位置在第 i 个位置插入O(n),主要花在查找前驱O(n)

单链表真正 O(1) 的效率体现在已经找到位置的前提下,而不是凭空说“单链表插入快”。如果你要插入的位置需要从头遍历才能找到,那整体依然是 O(n)。这个点面试时高频出现,不要答错。

5. 单链表的删除操作

删除操作与插入操作是对称的:插入的核心是改两个指针,删除的核心也是改两个指针,但多了一个释放内存的问题。

如果删除的是中间结点,核心步骤是:

  1. 找到前驱结点p
  2. p->next指向被删除结点的后继。
  3. 保存被删除结点的数据(如果需要)。
  4. free释放被删除结点的内存。

5.1 按位删除第 i 个结点

// 删除第 i 个结点,并用 e 返回被删除元素的值 bool ListDelete(LinkList L, int i, int *e) { if (i < 1) { return false; } LNode *p = GetElem(L, i - 1); // 找到第 i-1 个结点 if (p == NULL || p->next == NULL) { return false; // 第 i 个结点不存在 } LNode *q = p->next; // q 指向被删除结点 *e = q->data; // 取出数据 p->next = q->next; // 把 q 从链表中摘除 free(q); // 释放内存 return true; }

这里的边界条件非常有讲究:

  • 如果i == 1GetElem(L, 0)返回头结点,删除第一个数据结点的逻辑和删除中间结点完全一致。
  • 如果i > 链表长度p == NULL或者p->next == NULL,直接返回 false。
  • 删除后必须free(q),否则就造成内存泄漏。

尤其要注意p->next == NULL这个判断。如果 p 是最后一个结点,那么 p->next == NULL,说明没有第 i 个结点可删。如果单独判断 i 是否超过长度但忘记了 p->next 这一判断,在某些边界情况下会操作空指针。

5.2 删除指定结点 p:又一个经典 O(1) 技巧

类似前插,如果只知道某个结点的地址 p,想删除它,常规思路是找到它的前驱。但有一个 O(1) 的替代方案:把后继 q 的数据拷贝到 p,然后删除 q。

// 删除指定结点 p,时间复杂度 O(1) bool DeleteNode(LNode *p) { if (p == NULL || p->next == NULL) { return false; // 如果 p 是最后一个结点,无法用此方法 } LNode *q = p->next; // q 是 p 的后继 p->data = q->data; // 把 q 的数据复制到 p p->next = q->next; // 把 q 从链表摘除 free(q); // 释放 q return true; }

注意一个关键限制:这个方法不能用来删除链表的最后一个结点。因为 p 是最后一个结点时,p->next == NULL,没有后继可以复制数据。这种情况下,仍然需要从头遍历找到前驱才能删除。

所以这种“偷天换日”的删除技巧不是万能的。考试时如果题目说“删除给定指针 p 指向的结点,且 p 是唯一已知指针”,通常默认 p 不是最后一个结点。如果题目没有说明,你要在答案里补充边界情况的处理。

5.3 删除操作的时间复杂度

删除方式时间复杂度使用条件
已知前驱,删除后继O(1)需要能拿到前驱指针
按位删除第 i 个结点O(n),主要花在查找前驱通用
已知结点 p,复制后继法删除O(1)p 不能是最后一个结点

单链表的删除操作,时间复杂度的大头永远是“找前驱”。所以如果业务代码里大量出现“按值删除”且链表很长,需要评估是否改用其他数据结构,比如哈希表辅助索引。

6. 完整示例:带头结点单链表查插删综合演示

为了让你能看到完整可运行的代码,这一节给出一个基于 C 语言、带头结点头插法建表的综合示例。这个示例涵盖了:

  • 尾插法建表
  • 打印链表
  • 按位查找
  • 按位插入
  • 按位删除

我建议你先自己读代码,在关键位置标注出指针变化过程,再动手编译运行。

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; // 尾插法建立带头结点的单链表 LinkList CreateList(int arr[], int n) { LinkList L = (LNode *)malloc(sizeof(LNode)); L->next = NULL; LNode *r = L; for (int i = 0; i < n; i++) { LNode *s = (LNode *)malloc(sizeof(LNode)); s->data = arr[i]; s->next = NULL; r->next = s; r = s; } return L; } // 按位查找 LNode *GetElem(LinkList L, int i) { if (i < 1) { return NULL; } LNode *p = L->next; int j = 1; while (p != NULL && j < i) { p = p->next; j++; } return p; } // 按位插入 bool ListInsert(LinkList L, int i, int e) { if (i < 1) { return false; } LNode *p = GetElem(L, i - 1); 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 = GetElem(L, i - 1); if (p == NULL || p->next == NULL) { return false; } LNode *q = p->next; *e = q->data; p->next = q->next; free(q); return true; } // 打印链表 void PrintList(LinkList L) { LNode *p = L->next; printf("链表内容:"); while (p != NULL) { printf(" %d", p->data); p = p->next; } printf("\n"); } int main() { int arr[] = {10, 20, 30, 40}; int n = sizeof(arr) / sizeof(arr[0]); LinkList L = CreateList(arr, n); PrintList(L); // 1. 查找第 3 个结点 LNode *p = GetElem(L, 3); if (p != NULL) { printf("第3个结点的值为: %d\n", p->data); } else { printf("第3个结点不存在\n"); } // 2. 在第 2 个位置插入 99 if (ListInsert(L, 2, 99)) { printf("在第2个位置插入99成功\n"); } else { printf("插入失败\n"); } PrintList(L); // 3. 删除第 4 个结点 int deletedValue = 0; if (ListDelete(L, 4, &deletedValue)) { printf("删除的第4个结点值为: %d\n", deletedValue); } else { printf("删除失败\n"); } PrintList(L); // 4. 释放全部结点,防止内存泄漏 LNode *cur = L; while (cur != NULL) { LNode *next = cur->next; free(cur); cur = next; } return 0; }

6.1 编译和运行

如果你使用的是 GCC,可以直接在终端编译:

gcc linkedlist_demo.c -o linkedlist_demo ./linkedlist_demo

如果你使用的是 Visual Studio,需要把代码保存为.c后缀,在“解决方案资源管理器”中查看源文件属性,确认编译方式为“编译为 C 代码”。

6.2 预期输出

链表内容: 10 20 30 40 第3个结点的值为: 30 在第2个位置插入99成功 链表内容: 10 99 20 30 40 删除的第4个结点值为: 30 链表内容: 10 99 20 40

注意删除第 4 个结点后,链表内容变成了10 99 20 40。因为插入 99 之后,链表变成了10 99 20 30 40,第 4 个结点是原来的 30,删除之后就剩 4 个元素了。

6.3 如何验证代码是正确的

验证步骤很简单,但不只是看输出:

  1. 先验证查找:输出第 3 个结点是 30,说明查找逻辑正确。
  2. 再验证插入:插入后打印的顺序是10 99 20 30 40,说明插入没有破坏链。
  3. 最后验证删除:删除后打印的顺序是10 99 20 40,说明删除正确摘除了 30。
  4. 如果使用 Valgrind 检查内存,确保没有任何内存泄漏:
valgrind --leak-check=full ./linkedlist_demo

Valgrind 输出中definitely lost: 0 bytes表示没有结点泄漏。这通常不是初学阶段必须做的,但到考研、面试或实际工程阶段,会检查内存是否释放完整。

7. 常见错误与调试方法

我接触过大量学习链表的初学者,下面这些错误几乎每个人都踩过。把它们整理成一张排查表,遇到问题按表检查,效率会高很多。

问题现象可能原因排查方式解决方案
程序运行直接崩溃访问了 NULL 指针检查 GetElem 返回值是否为 NULL;gdb 查看崩溃调用栈每个返回结点指针的调用处都做 NULL 判断
打印链表时出现死循环某个结点的 next 指向了自己打印每个结点的 next 地址,检查循环画图检查指针赋值顺序,必须“先接后断”
打印链表时少了一个结点插入或删除时指针顺序写反了在每次操作前后打印整个链表s->next = p->next; p->next = s; 不能换序
删除后链表内容没错但内存越用越多删除结点没有 free用 Valgrind 检查删除后调用 free(q)
链表内容乱序头插法的结果是逆序确认建表方式想保持原序使用尾插法,想逆序使用头插法
在头部插入失败头结点不为空,p 指向头结点而非 NULL检查 GetElem(L, 0) 是否返回头结点带头结点时 GetElem(L, 0) 返回 L,是正确的

7.1 最简单的调试方法:画图 + 打印地址

很多同学写链表卡住,是因为在脑子里模拟指针变化太抽象。强烈建议:拿一张纸,画格子代表结点,画箭头代表 next,每一步操作都按代码顺序把旧箭头划掉、画上新箭头。

等你养成了画图习惯,再看代码就会有一种“画面感”,比如看见s->next = p->next就知道这是在把 s 接到 p 后面的第一个位置上。

同时,你可以在代码里临时打印地址,观察指针的指向关系:

printf("p 的地址=%p, p->next=%p\n", p, p->next); printf("s 的地址=%p, s->next=%p\n", s, s->next);

通过这些输入输出,能非常直观地看到指针重连的过程。

7.2 gdb 定位崩溃位置的思路

如果你用的是 Linux 环境,编译时加上调试信息:

gcc -g linkedlist_demo.c -o linkedlist_demo gdb ./linkedlist_demo

在 gdb 中输入run运行,崩溃后输入bt查看调用栈,可以快速定位到是哪个函数哪一行访问了非法内存。

8. 单链表查插删的面试与考点总结

8.1 高频考点

单链表的查插删是考研 408 和各大厂面试手撕算法的“基础题中的基础题”。高频考点包括:

  • 头插法的逆序特性:常用于链表反转、两数相加等题目。
  • O(1) 前插和 O(1) 删除结点:只给结点指针时高效操作。
  • 倒数第 k 个结点:双指针技巧。
  • 判断链表是否有环:快慢指针。
  • 合并两个有序链表:需要用尾插法逐个比较。
  • 单链表反转:三指针迭代法或递归法,核心就是反复执行“先接后断”。

8.2 典型变式题一:单链表反转

单链表反转本质就是不断使用“头插法”,把原链表中的结点依次摘下,头插到新链表中:

// 单链表反转 LinkList ReverseList(LinkList L) { LNode *prev = NULL; LNode *cur = L->next; while (cur != NULL) { LNode *next = cur->next; // 先保存后继 cur->next = prev; // 反转指针 prev = cur; // 移动 prev cur = next; // 移动 cur } L->next = prev; // 头结点指向新的第一个结点 return L; }

这里的关键又回到了“先保存后继”,如果不先把cur->next存到next变量,一旦执行cur->next = prev,原链表在 cur 之后的部分就丢了,循环也无法继续。

8.3 典型变式题二:合并两个有序链表

已知两个长度为 m 和 n 的升序单链表,合并后仍然有序。这类题在热搜词里出现过,是链表算法题的经典入门题。

// 合并两个升序单链表 LinkList MergeList(LinkList La, LinkList Lb) { LinkList Lc = (LNode *)malloc(sizeof(LNode)); LNode *pa = La->next; LNode *pb = Lb->next; LNode *pc = Lc; while (pa != NULL && pb != NULL) { if (pa->data <= pb->data) { pc->next = pa; pc = pa; pa = pa->next; } else { pc->next = pb; pc = pb; pb = pb->next; } } // 把剩余部分直接接上 if (pa != NULL) { pc->next = pa; } else { pc->next = pb; } return Lc; }

这个函数的时间复杂度是 O(m+n),因为两个链表都只遍历一遍。注意这里用的是“尾插法”的思路,每个结点只会被移动一次,所以不会额外申请新空间。

8.4 遇到链表题的通用思路

如果面试或考试遇到一条没见过的单链表题,建议按下面顺序思考:

  1. 题目给没给头指针?如果只有某个结点指针,能不能用 O(1) 前插、O(1) 删除?
  2. 能不能用双指针(快慢指针、前后指针)?
  3. 能不能用头插法改变顺序?
  4. 能不能用递归?递归的终止条件和递推关系是什么?
  5. 是否需要 dummy 头结点来统一逻辑?很多链表的删除题,尤其涉及删除头结点的情况,用 dummy 结点可以避免大量边界判断。

9. 最佳实践与工程建议

写到这里,单链表的查插删已经从原理到代码完整过了一遍。最后给几条工程和备考建议。

9.1 不要直接修改传入的链表而不说明

实际工程中,如果函数会修改链表结构,要么在函数命名上明确表达,比如ListInsertListDelete,要么传入二级指针LinkList *L。不要写一个名为PrintAndModify的函数,但内部却悄悄改了链表结构。命名要诚实,签名要能体现副作用。

9.2 malloc/free 必须成对出现

在编写插入、删除逻辑时,要形成条件反射:

  • 插入一个新结点,必然有一次malloc
  • 删除一个结点,必然有一次free
  • malloc之后必须判断是否失败,虽然考试代码里经常省略,但工程上必须处理。

如果团队规范要求严格,应该封装一个安全 malloc 宏或统一内存管理模块。

9.3 边界条件优先考虑

单链表最容易出错的位置就是“头和尾”。每次写插入、删除,先问自己三个问题:

  1. 如果链表为空,代码还能正常运行吗?
  2. 如果操作的是第一个数据结点,头指针会被影响吗?
  3. 如果操作的是最后一个结点,next 指针会指向 NULL 吗?

这三个问题想清楚了,边界条件基本就覆盖完了。

9.4 考研复习和面试准备建议

如果你正在准备考研 408、软考数据结构或大厂面试:

第一优先级是手写代码。不是看书看会,而是白纸上手写。合上书,自己从无到有写出建表、查找、插入、删除、反转、合并六个函数,每个都调试通过。这个过程看起来笨,但效率远高于一直“看懂”别人的代码。

第二优先级是复杂度分析。每次写完一个操作,立即写出它的最好、最坏、平均时间复杂度,并且解释清楚复杂度主要花在“查找前驱”而不是“修改指针”。这几乎是 408 每年必考的得分点。

第三优先级是背熟边界条件。面试官问到链表插入时,大概率会紧接着问“如果 i 不合法怎么办”。答出 NULL 判断和返回 false,基本就过关了;答不出,说明还没真正理解。

9.5 工程上什么时候真的用链表

以 Python/Java 为例,日常开发中很少手写单链表,因为listArrayListLinkedList都封装好了。但不要因此觉得链表没用:

  • 实现 LRU 缓存时,哈希表 + 双向链表是经典方案。
  • 操作系统内存管理中的空闲块链表、页面缓冲队列,底层就是链表思想。
  • 文件系统的链式分配,用每个块的指针字段指向下一个块。
  • 底层中间件中大量使用无锁链表、无锁队列,其核心思想仍然是“先接后断,防止 ABA”。

所以学单链表不是学一个考试数据结构,而是在练“如何通过指针组织离散内存”这套思维。这套思维会一直影响你对缓存、索引、队列、树的理解。

10. 总结

单链表的查找、插入、删除,是数据结构入门的第一个分水岭。

  • 查找:只能从头遍历,O(n)。
  • 插入:核心是“先接后断”,关键两步是s->next = p->next然后p->next = s
  • 删除:核心是“找到前驱,越过当前”,别忘了free
  • 前插/删除指定结点:可以用交换数据的方式做到 O(1),但要注意限制条件。

建议你现在就做一件事:打开编译器,把这篇文章里的完整代码敲一遍,运行通过之后,再手写一遍创建、查找、插入、删除四个函数,最后试着写一下链表反转和有序链表合并。把这几道题全部跑通之后,再看后面的栈、队列和树,你会发现它们都建立在同一套指针操作思维之上。收藏这篇文章,写代码卡住的时候回来看看,相信会省下不少时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询