1. 从题目到可执行程序:一次弄清“基本操作”的范围与验收标准
我当年拿到数据结构第一次作业的时候,第一反应不是打开编辑器,而是翻教材。题目就一行字——“线性表的基本操作”,既没有说要用顺序表还是链表,也没有说“基本操作”到底算哪几个函数。很多人卡在第一步:这题到底要我交什么?
这门课如果用的是严蔚敏《数据结构(C语言版)》或者配套的王卓视频课,作业的默认语境通常是这样:用 C 语言实现顺序表和单链表两种存储结构,分别完成初始化、按位置取值、按值查找、插入、删除这几组最基础的操作,再写一个主函数做验证。这也是绝大多数课程作业和实验报告里“线性表”部分的核心验收范围。如果老师没有额外提“把两个有序表合并”“实现原地逆置”这类附加要求,先把这套东西跑通,就已经完成了作业的主体。
别小看这件事。我帮同学调试这份作业时发现,很多人不是不会背代码,而是不知道为什么要这么写:插入时为什么要从最后一个元素开始往后移动?删除时为什么又要从待删位置的下一个开始往前覆盖?单链表删除为什么必须留一个前驱指针?这些问题在第一次作业里不解决,后面学栈、队列、串、图,全都会反复踩类似的坑。这篇就顺着提交一份作业的实际流程讲:先分析需求边界,再做两种存储结构的实现,再说调试和报告怎么写,最后把作业里容易延伸出来的考点提一下。
如果你正在准备考研、或者在做 408 数据结构复习,这篇文章的前半段也是很好的基础补漏材料。我第一次复习时发现,很多看上去“记住了”的点,其实并没有真正理解,比如为什么顺序表插入的时间复杂度不是 O(1)、带头结点的链表处理空表时候的优势在哪里。这些我会在实现里一起解释清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序表实现篇:下标边界和移动方向比一切花活都重要
顺序表的存储思想很简单:用一段连续的内存空间,把元素一个挨一个地放好。你可以把它理解成电影院的一排座位,座位号是连续的,你知道某个座位在哪,就能直接走过去坐下,不需要问旁边的人。所以顺序表的优势是“按位置取元素”非常快,C 语言里就是一次下标访问。
2.1 结构体定义:静态数组还是动态分配
教材里常见的写法有两种:一种是在结构体内部直接开定长数组,另一种是只保存指针,运行时通过 malloc 动态申请。课程第一次作业,我建议先用定长数组的方式,因为它能把注意力集中在“插入、删除”的逻辑上,不会被内存管理干扰。等到实验要求你处理不固定长度数据时,再换成动态分配。
下面是我常给初学者的模板:
c复制#define MAXSIZE 100
#define OK 1
#define ERROR 0
typedef int ElemType; // 课程作业里多数用int,后续可以换成结构体
typedef struct {
ElemType data[MAXSIZE];
int length; // 当前表长,不是数组下标上限
} SqList;
注意 length 是“当前已经存了多少个元素”,它和数组下标之间差 1。比如表里有 5 个元素,length 是 5,最后一个元素存在 data[4]。这个关系一旦搞混,后面的插入删除一定会写错。
初始化就很简单了:
c复制void InitList(SqList *L) {
L->length = 0;
}
有的老师会要求 InitList 返回状态,比如 int InitList(SqList *L) 返回 OK,严谨一点倒也没问题。但至少你要明白:静态数组在栈上已经分配好了,初始化只需要把头几个“可编程”的状态变量设好,真正要做的是把长度清零。
2.2 ListInsert 最容易写错的循环移位方向
顺序表插入的核心是“把插入位置及其后面的元素整体后移一位”。这段代码的难点不在逻辑本身,而在写循环时极易把方向搞反。
先看正确版本:
c复制int ListInsert(SqList *L, int pos, ElemType e) {
// pos 表示逻辑位置,范围是 1 ~ L->length+1
if (pos < 1 || pos > L->length + 1) {
return ERROR;
}
if (L->length >= MAXSIZE) {
return ERROR;
}
// 从最后一个元素开始,逐个往后挪
for (int j = L->length - 1; j >= pos - 1; j--) {
L->data[j + 1] = L->data[j];
}
L->data[pos - 1] = e;
L->length++;
return OK;
}
我见过很多新手把这个循环写成从前往后搬:
c复制// 错误写法,演示用
for (int j = pos - 1; j < L->length; j++) {
L->data[j + 1] = L->data[j];
}
这样写的一个典型现象是:插入位置之后的元素会“全部变成同一个数”。比如原表是 {1, 2, 3, 4, 5},想在位置 3 插入 99,从前往后移动时,data[2] 先覆盖 data[3],此时 3 的位置已经没人管了,接着 data[3] 又把刚复制的 3 覆盖到 data[4],结果一片混乱。
为什么必须从后往前?因为后移是一个“复制覆盖”的过程。如果先动前面的元素,它后面的原始数据还没备份,就会被覆盖掉;先从尾部开始,每次搬的都是还没有被改写过的元素,靠后腾出来的空位不断向前推进,最后才会空出 data[pos-1] 这个坑。
另外注意边界条件:pos 可以等于 L->length + 1,也就是插入到末尾。此时循环体的起点 j = L->length - 1,终点 j = pos - 1 = L->length,条件是 j >= L->length 为假,实际不会进入循环,直接走到末尾赋值。好多人第一次写时会把循环条件写成 j > pos - 2 或 j >= pos - 1 写反,建议自己拿一张草稿纸,把数组下标画出来推一遍。
2.3 删除:为什么“从前往后覆盖”就对了
删除是一个逆过程:把待删元素后面的所有元素整体前移一位。这里的方向正好和插入相反,要从前往后搬。
c复制int ListDelete(SqList *L, int pos, ElemType *e) {
if (pos < 1 || pos > L->length) {
return ERROR;
}
*e = L->data[pos - 1];
// 从被删位置的后一个元素开始往前覆盖
for (int j = pos; j < L->length; j++) { // j 从 pos 到 length-1
L->data[j - 1] = L->data[j];
}
L->length--;
return OK;
}
解释一下这个 j 的取值:位置 pos 对应数组下标 pos - 1,我们要把下标 pos 的元素挪到 pos - 1,把下标 pos + 1 的元素挪到 pos……直到最后一个元素下标 length - 1。所以循环变量 j 从 pos 开始,直到 length - 1 结束,C 的写法就是 j < L->length。
如果你反过来从后往前覆盖,结果会把前面的元素不断带到后面,原来的元素被错误复制到靠后的位置。这和插入是同一类问题——方向取决于数据在被移动之前是否可能被破坏。
删除还有个容易忽略的细节:被删元素要不要通过指针带出去。在严蔚敏教材的 ADT 描述里,删除操作通常允许调用方拿到被删的值,所以参数里往往会带一个 ElemType *e。作业如果只是让删除,你可以不带,但养成“被删的值要能取出来”的习惯不算坏事,后面做链表和树的时候会很有用。
2.4 按位置取、按值查、遍历打印
顺序表的按位置取元素是它最大的爽点:
c复制int GetElem(SqList L, int pos, ElemType *e) {
if (pos < 1 || pos > L.length) {
return ERROR;
}
*e = L.data[pos - 1];
return OK;
}
时间复杂度 O(1),因为连续存储的单位大小固定,编译器只要用起始地址加偏移量就能算出目标内存地址,这就是“随机存取”。
按值查找就朴素一些,得从头到尾逐个比较:
c复制int LocateElem(SqList L, ElemType e) {
for (int i = 0; i < L.length; i++) {
if (L.data[i] == e) {
return i + 1; // 返回逻辑位置,不是数组下标
}
}
return 0; // 找不到,返回0
}
很多同学在这里直接 return i,然后被后面调试折磨。这样统一返回逻辑位置(下标 + 1)是为了和 Insert/Delete 的 pos 语义一致。找元素的位置是为了下一步操作它,如果 Insert 和 Delete 都按 1 起始,而查找返回 0 起始,程序里到处都要加一减一,很容易出错。
遍历打印也很简单:
c复制void PrintList(SqList L) {
printf("length=%d: ", L.length);
for (int i = 0; i < L.length; i++) {
printf("%d ", L.data[i]);
}
printf("\n");
}
顺序表到这里基本就齐了。如果你只是交了这些函数,配合主函数验证,已经是一份合格作业。但很多老师会在实验报告里问你“为什么插入和删除的时间复杂度是 O(n)”,这一块我在后面“作业报告”章节会给出完整的推导。
3. 单链表实现篇:头结点、尾插、按值删除是我见过翻车最多的地方
单链表和顺序表最大的不同在于,它不要求元素在内存里紧挨着存放,而是靠“指针”把一个个节点串起来。这带来一个很直观的差别:插入、删除不需要移动大量元素,只需要修改几个 next 指针。
但反过来,这种自由也带来了新问题:你不能再用下标直接访问第 k 个元素,必须从头开始一个一个 next 过去。
3.1 结构定义:为什么很多实现都带头结点
链表节点最基本的定义是这样:
c复制typedef struct LNode {
ElemType data;
struct LNode *next;
} LNode;
typedef LNode *LinkList;
这里有个经常让人困惑的设计:链表到底要不要“头结点”?如果只想表示一个节点,LinkList L 指向第一个节点也是可以的。但严蔚敏教材和绝大多数课程实现里,会创建一个“不存数据”的头结点,让 L 永远指向这个头结点:
c复制int InitList(LinkList *L) {
*L = (LNode *)malloc(sizeof(LNode));
if (*L == NULL) {
return ERROR;
}
(*L)->next = NULL; // 头结点暂时没有后继
return OK;
}
带头结点的最大好处是:空表和非空表,头部操作的处理逻辑完全一致。如果不带头结点,在头部插入新节点时,为了修改“链表头指针本身”,你得用二级指针或者在返回值上做文章;带头结点后,所有“在 p 节点后插入新节点”的操作都只需要找到目标位置的前驱,头指针永远不用变。
展开说,就是:空表时,L 指向头结点,而头结点的 next 为 NULL。插入第一个数据节点时,相当于在头结点后面插入一个节点;删除整个链表时,也从 head->next 开始释放,head 本身负责兜底。
3.2 尾插法:作业里输入数据最常用的建表方式
第一次作业通常要输入一组数据,再对它做各种操作。最常用的建表方法是尾插法,保证输入顺序和表内顺序一致。
c复制void CreateListTail(LinkList L, ElemType a[], int n) {
LNode *tail = L;
for (int i = 0; i < n; i++) {
LNode *p = (LNode *)malloc(sizeof(LNode));
if (p == NULL) {
exit(-1);
}
p->data = a[i];
p->next = NULL;
tail->next = p;
tail = p;
}
}
这里必须维护一个 tail 指针,让它始终指向当前最后一个节点。因为单链表是单向的,每插入一个新节点,都要从尾部接过去;如果你不用 tail,每次都要从头遍历到末尾,插入 n 个节点的时间会变成 O(n^2),作业数据量小感觉不出来,但考试会问。
还有一种是头插法,每次都把新节点插到头结点后面。它的结果是让输入顺序反过来,如果你想要逆序表,头插法正好省一次逆置。有同学喜欢用头插法创建“看起来是正序”的表,调试时会很困惑,千万不要弄混。
c复制// 头插法,新节点总是插在头结点后
void CreateListHead(LinkList L, ElemType a[], int n) {
for (int i = 0; i < n; i++) {
LNode *p = (LNode *)malloc(sizeof(LNode));
p->data = a[i];
p->next = L->next; // 新节点指向原第一个节点
L->next = p; // 头结点指向新节点
}
}
头插法的“反转”效果很好用来做链表逆置:把原表遍历一遍,每次把当前节点摘下来头插到新表,原地逆置就完成了。这是作业和面试里的高频玩法。
3.3 按位插入:找前驱节点是单链表的关键动作
单链表插入的教科书语句是“在 p 节点之后插入新节点,只需两步”:
c复制s->next = p->next;
p->next = s;
这两行的顺序不能反过来。如果先执行 p->next = s,那么原来 p 后面的链表就找不到了,因为没有任何指针指向它了。这也是第一次写链表最容易忽略的顺序问题。
但在作业场景中,我们往往还要“按位置插入”。这时要先从头结点出发,找到第 pos 个位置的前驱节点:
c复制int ListInsert(LinkList L, int pos, ElemType e) {
LNode *p = L; // 从头结点出发
int j = 0;
while (p && j < pos - 1) {
p = p->next;
j++;
}
if (!p || j != pos - 1) {
return ERROR; // pos 不合法,或者已经遍历到空
}
LNode *s = (LNode *)malloc(sizeof(LNode));
if (s == NULL) {
return ERROR;
}
s->data = e;
s->next = p->next;
p->next = s;
return OK;
}
仔细理解这个 while:从头结点开始数,头结点是“第 0 个”,我们要找的是“第 pos - 1 个位置的前驱”。当 pos = 1 时,j < 0 不成立,p 指向头结点,正好把新节点插到头结点后面,也就是头部插入。当 pos = length + 1 时,p 会一路走到当前最后一个节点,让新节点成为新的尾节点。pos 再大就找不到合法前驱了。
3.4 按值删除:为什么必须预先记住前驱
按值删除是单链表作业里翻车率最高的操作之一。很多人会写一个循环找到目标节点 p,然后执行 free(p)。看起来没问题,但链表已经断了:p 前一个节点的 next 还指向 p,p 被释放后,前驱节点指向了一块已被释放的内存,下一次遍历走到这里就会出问题。
正确的做法是始终保持两个指针:一个指向当前节点 cur,一个指向它的前驱 pre。遍历过程中不断同步移动:
c复制int ListDeleteByValue(LinkList L, ElemType e, ElemType *deleted) {
LNode *pre = L;
LNode *cur = L->next;
while (cur != NULL && cur->data != e) {
pre = cur;
cur = cur->next;
}
if (cur == NULL) {
return ERROR; // 没找到
}
*deleted = cur->data;
pre->next = cur->next; // 先跳过当前节点,让前驱直连后继
free(cur); // 再释放当前节点
return OK;
}
这种删除方式有一个特性:它只会删除第一个匹配到的值。如果表里有两个重复元素,只删一个。如果你要让所有等于 e 的节点都消失,需要用一个循环反复删除,或者改成遍历时边找边删的写法:
c复制// 删除所有值等于 e 的节点
void DeleteAllValue(LinkList L, ElemType e) {
LNode *pre = L;
LNode *cur = L->next;
while (cur) {
if (cur->data == e) {
pre->next = cur->next;
free(cur);
cur = pre->next;
} else {
pre = cur;
cur = cur->next;
}
}
}
注意这个循环里,如果发生了删除,pre 不要移动,因为此时 pre->next 已经越过被删节点,指向了下一个待检查节点;只有没发生删除时,pre 和 cur 才一起前进。
3.5 按值得查与按位取值
单链表按位置取元素时,最忌讳“把节点指针当数组下标推进”:
c复制int GetElem(LinkList L, int pos, ElemType *e) {
LNode *p = L->next; // 跳过空头结点
int j = 1;
while (p && j < pos) {
p = p->next;
j++;
}
if (!p) {
return ERROR;
}
*e = p->data;
return OK;
}
按值查找就遍历比较,找到返回逻辑位置:
c复制int LocateElem(LinkList L, ElemType e) {
LNode *p = L->next;
int pos = 1;
while (p && p->data != e) {
p = p->next;
pos++;
}
if (p == NULL) {
return 0;
}
return pos;
}
链表查找的时间复杂度是 O(n),这一点和顺序表一样,但常数比顺序表大,因为每一次 next 本质上是一次指针跳转,对缓存不太友好。数据量小的时候无感,数据量大时顺序表往往更快,这也是课程后半部分“为什么有时要研究结构优劣”的一个伏笔。
4. 调试阶段实录:段错误的排查链路与两个高频翻车点
很多同学写完代码,第一遍编译通过,输入数据后要么输出一堆相同数字,要么直接“Segmentation fault”。下面把这段最痛苦的调试过程拆开讲,因为这些场景几乎每一届同学都会遇到。
4.1 现象一:插入后所有元素都变成同一个值
有一次我帮人调顺序表,他插入位置 3 后,输出变成了 1 2 3 3 3。我看了一眼循环,就是前面提到的“从前往后移动”导致的。改一个方向就好。
这里最值得学的是如何发现这类错误:不要只在正常用例下验证,专门构造能区分“是否有覆盖”的场景。比如插入少量单调元素 {1,2,3,4,5},插入位置放在中间,插入值用一个明显不重复的数如 99,然后看输出。如果出现重复或者丢元素,大概率是移动方向或边界条件写错了。
调试时可以借助 IDE 的断点,或最简单的方式:在循环里临时加 printf,打印出每一步 j、data[j]、data[j+1] 的值。如果某一步出现了“左边的值被覆盖后,右边还没备份”的过程,问题马上就能看到。
4.2 现象二:删除第一个元素后,访问链表直接崩溃
这个我在单链表调试里见的最多。错误代码大概是这样的思路:
c复制// 伪代码,展示错误
void DeleteNode(LinkList L, ElemType e) {
LNode *p = L->next;
LNode *q = NULL; // 忘记维护前驱
while (p && p->data != e) {
p = p->next;
}
if (p) {
q = p->next; // 试图记录后继
free(p); // 但前一个节点还指向 p,链表断了
// 后面的代码根本没法把 pre->next 改为 q,因为 pre 已丢失
}
}
这类段错误排查起来通常不是马上崩,而是下一次遍历到某个指针位置时崩。因为 free 之后,那块内存虽然被释放,但内容可能还在,程序暂时还能跑;一旦重新分配或被系统回收,原来存储 next 的位置就会被改掉,程序就会在随机位置访问非法内存。
排查思路:看到一个“看起来指向合法节点,但访问它的 next 时崩掉”的问题,优先怀疑你的链表里是否残留了指向已 release 节点的指针。修复方法就是改成前面写的 pre/cur 同步走。这个机制务必要吃透:单链表删除某个节点,本质上不是删它自己,而是让它的前驱绕开它。
4.3 用边界用例把 bug 逼出来
调试线性表作业,我建议准备下面的测试列表,不要只在中间位置玩:
| 测试目标 | 输入示例 | 期望结果 | 如果出错,说明什么 |
|---|---|---|---|
| 空表删除 | 初始化后直接 delete(1) | 返回 ERROR,不崩 | 边界判断缺失 |
| 头部插入 | 在空表位置 1 插入 5 | 表为 | 链表指针改动是否到位 |
| 尾部插入 | 在 {1,2,3} 的位置 4 插入 9 | 表为 | 是否允许 pos = length+1 |
| 中间插入 | 在 {1,2,3} 的位置 2 插入 9 | 表为 | 循环移动方向是否正确 |
| 非法位置 | 在 {1,2} 的位置 5 插入 3 | 返回 ERROR,不崩 | 越界判断 |
| 删除尾元素 | 在 {1,2,3} 删除位置 3 | 表为 | 空位覆盖是否存在 |
| 链表中删除重复值 | 删除所有等于 2 的节点 | 表为 | pre 是否更新正确 |
| 删除头结点后的数据 | 在链表第一个数据节点位置删除 | 表头是否仍然正确 | head 是否被误移动 |
把这张表跑完,作业基本就稳了。这一步看上去糙,但它筛出来的问题,比任何书上写的“边界条件很重要”都有说服力。考试里经常出现类似的边界选择题,很多都是从这些用例里提炼出来的。
5. 作业报告和验收环节:复杂度分析、测试数据、运行结果如何写得有说服力
我批过几次本科生的实验报告,发现一个普遍现象:贴了一大段代码,结论写“程序运行正确,结果如下”,然后就没有然后了。代码写得不错,但报告分拿不满,挺可惜的。
5.1 复杂度公式自己推一遍,别只背结论
顺序表插入的平均移动次数,是很多复习资料都会反复考的。推导过程并不复杂:在长度为 n 的表中插入一个新元素,可能的插入位置是 1 到 n+1,一共 n+1 种。如果插在第 i 个位置,需要移动 n - i + 1 个元素。把所有位置需要的移动数加起来再求平均,就是:
- 总移动次数:(\sum_{i=1}^{n+1} (n - i + 1) = \frac{n(n+1)}{2})
- 平均移动次数:(\frac{n(n+1)}{2} \div (n+1) = \frac{n}{2})
所以插入操作的时间复杂度是 O(n)。删除操作,合法位置是 1 到 n,插在第 i 个位置需要移动 n - i 个元素,平均移动次数是 ((n-1)/2),依然是 O(n)。而单链表插入时,如果已经找到了待插入位置的前驱,修改指针只需要 O(1);但为了找这个前驱,往往要遍历 O(n) 个节点,所以整体上还是 O(n)。二者的差异不在“量级”,而在“移动元素”和“移动指针”的常数开销:顺序表要把整个区域往后搬,链表只需要改两个 next 字段,元素越重,链表优势越大。报告的复杂度分析里如果能把这个区别写出来,比单纯抄一句“顺序表插入 O(n)、链表插入 O(1)”深刻得多——后者是很多人的常见误区。
5.2 表和序列:没有运行结果的代码是没有说服力的
实验报告里的“运行结果”不是贴一张图就够了,更好的做法是展示多组测试用例及对应输出,比如:
- 测试正常插入:原表
{3, 1, 4, 1, 5, 9, 2, 6},在位置 3 插入 100,输出{3, 1, 100, 4, 1, 5, 9, 2, 6}; - 测试删除:在位置 5 删除元素 1,输出
{3, 1, 100, 4, 5, 9, 2, 6}; - 测试失败分支:在空表删除、非法位置插入,程序能正常返回 ERROR,不崩。
每一条用例旁边简单标注“验证了哪个函数、哪个边界条件”,老师在验收时扫一眼就知道你确实是在测试功能,不是摆拍一个成功路径。
5.3 顺序表和链表的选型对比,是“基本操作”作业的隐藏得分点
下面这张表几乎是面试和考研常客,建议自己整理一遍并理解每个结论背后的原因:
| 对比维度 | 顺序表 | 单链表 |
|---|---|---|
| 存储结构 | 连续内存 | 零散内存,靠指针连接 |
| 按位置取元素 | O(1),随机存取 | O(n),需从头遍历 |
| 按值查找 | 平均 O(n),不支持跳过 | 平均 O(n),无法随机 |
| 插入/删除 | 需要移动元素,平均 O(n) | 若已知前驱,指针修改 O(1) |
| 空间分配 | 需要提前确定或扩容 | 每节点按需申请,灵活但有指针开销 |
| 主要开销 | 移动元素 | 指针跳转、维护 next |
大多数教材在“基本操作”之后,会留一个思考题:如果你存的是一批很大的整数,比如复杂的结构体,频繁做插入删除,选哪个?答案多半是单链表,因为移动一个几百字节的结构体比修改两个指针贵太多。而如果你要做大量按位置随机访问,顺序表是更合适的选择。作业报告或者答辩环节若能给出这样场景化的选型说明,比空背优缺点更有说服力。
6. 从作业走向更高阶:合并有序表、逆置、约瑟夫环与考试视角
交完线性表作业,并不代表这部分内容学完了。我在后续复习和就业面试过程中发现,线性表的很多技巧其实都来自“基本操作”的组合。这里挑几个能帮你巩固本章核心的点,正好能拿来和同学一起练手。
6.1 几个值得写在“拓展实验”里的练习
第一个是两个有序单链表合并。严蔚敏教材里有一个经典函数 MergeList,把两个递增有序表合并成一个递增有序表。它几乎是对链表插入操作的综合运用:每次比较两个表的当前节点,把小的一方接进结果表。这里的难点是比较结束后,还要把剩余部分“整段链接”过去,而不是一个一个复制。可以自己练一练,很多同学第一次写时都会把剩余节点循环硬接一遍,其实只需要 while 外再做一个拼接。
第二个是顺序表原地逆置。要求不能开新的数组,把 {1,2,3,4,5} 变成 {5,4,3,2,1}。最直接的双指针交换法就是对“按位置取元素”的活用;如果换成链表,可以用头插法那个特性完成一次遍历逆置。
第三个是约瑟夫环问题。它很能检验你有没有真正理解“循环单链表”以及“删除节点时前驱怎么处理”。约瑟夫环用线性表实现时,每次报数到 m 的人出列,本质上是一次“从当前位置走 m-1 步,然后删除下一个节点”的操作。如果你能在纸面上把指针转动画出来,说明单链表那些插入删除你已经内化了。
这些拓展练习不一定在作业硬性要求里,但一旦你写出来了,实验报告里加一节“拓展实现”,老师很难不给高分。
6.2 考研与面试里线性表的那些高频考点
在 408 和各类自主命题中,线性表经常以选择题或者代码题的形式出现。选择题喜欢考的是:
- 插入和删除后,数组元素移动次数;
- 带头结点与不带头结点的区别;
- 头插法建表后元素次序变化的判断;
- 单链表删除时需要几个指针,找到待删节点的前一节点复杂度如何。
代码题更喜欢考察链表的操作,因为链表能考出“对指针是否理解”的功夫。常见题型包括:链表逆置、两两交换相邻节点、删除倒数第 k 个节点、判断链表有无环。你会发现,这些题底层的核心动作,仍然是找前驱、改 next、边界判断三件事。当年作业里没吃透的 pre/cur 同步移动,到了面试题里化身各种复杂一点的场景,本质完全没变。
我后来帮同学准备复试时,经常问他一句:“不要看代码,你说一下为什么单链表删除某个节点,一定要知道它的前驱?”能答清楚这个的人,很多题目就都不会卡住。数据结构这东西很奇怪,它不像高数那样题型繁多,反而是把几个非常基础的动作练到肌肉记忆,后面的数据结构就不会有你想象中那么难。
7. 我最后再给代码前容易忽视的“清理工作”提个醒
作业代码写完通常时间已经很久,但有几个细节我建议你提交前逐项检查一下。
第一,动态申请的内存要配对释放。顺序表如果用动态分配,程序末尾要 free 掉整个数组;链表要写一个 DestroyList 或者 ClearList。有些同学的 main 里不断 create 新表,内存却一次没释放,程序跑起来没报错,但这是隐患。严谨的老师会在答辩时问一句“你写了销毁函数吗”。
第二,代码里到处都是 printf 调试输出,提交前记得清理或整理。不会有人想看你在循环里每次移动都打印一堆过程数据。把调试用的输出注释掉,保留最终的遍历打印函数。
第三,把 main 函数改成“演示菜单”或“分块验证”风格。比如用注释分隔“顺序表测试”“链表测试”,每个操作前打印一句“正在测试:在位置3插入100”,输出一目了然。很多同学给的 main 函数只有一堆函数调用,没有输出提示,验收老师要盯屏幕好几秒才知道这行输出是从哪来的。
我当时最后提交前,把主函数调整成了这个样子:
c复制int main() {
// ---------- 顺序表测试 ----------
SqList L;
InitList(&L);
int initData[] = {3, 1, 4, 1, 5, 9, 2, 6};
for (int i = 0; i < 8; i++) {
ListInsert(&L, i + 1, initData[i]);
}
PrintList(L);
ElemType e = 100;
printf("在位置3插入100: ");
ListInsert(&L, 3, e);
PrintList(L);
printf("删除位置5的元素: ");
ListDelete(&L, 5, &e);
printf("删除了 %d,结果: ", e);
PrintList(L);
printf("查找元素9的位置: %d\n", LocateElem(L, 9));
// ---------- 单链表测试 ----------
LinkList LL;
InitList(&LL);
CreateListTail(LL, initData, 8);
...
return 0;
}
这段代码跑起来的输出,直接就能粘贴到实验报告的“运行结果”里,清晰又完整。
线性表的基本操作看起来只是作业一,但它几乎决定了我后面整个数据结构课程的学习节奏。顺序表教会我“为什么循环方向这么重要”,链表教会我“内存和指针的关系到底是什么意思”。把这些基础动作搞透,后面学栈、队列、串和二叉树时你会明显轻松很多。
