备战ACM那阵子,我被一道链表题狠狠教训过:题目本身不复杂,创建一条单链表、按条件删除结点,结果栽在辅助函数上,改了半个多小时还没过。后来复盘才发现,主算法一点问题没有,纯粹是 deleteNodeByValue 这个辅助函数处理头结点时写漏了一行。当时我就意识到,ACM格式下链表题真正拉开差距的,往往不是那些花哨算法,而是创建和删除这两个最基本的辅助函数。今天就把这块掰开揉碎讲清楚:怎么创建、怎么删除、有哪些边界坑,以及我踩过之后的处理办法。适合正在刷题、准备竞赛,或者刚上完单链表基本操作实验的同学。
1. ACM题里的链表辅助函数,为什么值得先磨好
1.1 一场比赛翻车的教训
那次翻车的过程我记得很清楚。题目要求输入一串整数,建立链表,然后删除所有值为偶数的结点,最后输出剩余结点。思路简单到不行,甚至都不用排序、不用反转,就是一个"创建 + 按值删除"的组合。
我当时的代码里,删除函数长这样:
c复制void deleteEven(Node* head) {
Node *prev = NULL, *cur = head;
while (cur != NULL) {
if (cur->val % 2 == 0) {
prev->next = cur->next;
free(cur);
} else {
prev = cur;
}
cur = cur->next;
}
}
看着挺像回事,对吧?问题出在当第一个结点就是偶数时,prev 还是 NULL,直接执行 prev->next = cur->next 就等于往空指针里写地址,程序当场崩溃。我当时还纳闷,明明本地跑的时候是好的——因为我本地测试用例第一个数恰好是奇数,根本没触发头结点删除分支。交到评测机上,第一组数据就挂了。
这个故事听起来很蠢,但它在竞赛圈里太常见了。你会发现,真正让你浪费大量罚时的,往往不是复杂的平衡树、线段树,而是这种看起来人人都该会的链表基础操作。因为主算法你会在纸上反复推演,辅助函数却常常"凭手感"直接写,边界条件根本没细看。
1.2 ACM格式对链表代码的隐性要求
所谓的"ACM格式",圈内人都懂,其实就是一套面向在线评测的代码组织方式:标准输入输出、单文件提交、不能依赖图形化调试器、一次运行定成败。这套格式对链表代码有三条隐性要求。
第一条,运行环境不给你试错机会。本地折腾半天没事,评测机上一个空指针解引用就是 Runtime Error,一个死循环就是 Time Limit Exceeded,没有任何中间状态可以观察。第二条,内存和栈有明确限制。链表结点一个个 malloc 出来,如果忘记释放,多组测试数据叠加起来就可能 Memory Limit Exceeded。第三条,代码要足够紧凑。比赛时间有限,辅助函数越短、越不容易写错越好。
所以链表题在 ACM 里考察的其实不单是"会不会写链表",而是"能不能在高压下稳定写出无懈可击的链表代码"。创建和删除作为最基础的辅助函数,几乎每一道链表题都要用到合并、反转、集合差集这些操作,本质上都是在这两个函数的基础上做组合。地基不稳,上面搭什么都是虚的。
1.3 三条设计原则:单职责、边界完整、可组合
经过那次翻车之后,我给自己定了几条辅助函数的设计原则,后来也一直在用。
第一条是单职责。一个函数只干一件事:createNode 只负责分配并初始化一个结点,freeList 只负责释放整条链表,deleteNodeByValue 只负责按值删点。不要想着写一个"多功能万能链表工具",那样参数复杂、容易混乱,比赛现场根本记不住用法。
第二条是边界完整。写之前先在脑子里过一遍空链表、单节点链表、删除头结点、删除尾结点、删除后链表变空这几种情况。ACM 评测数据最喜欢卡这些边界,稍微漏一种就是 Wrong Answer 或者 Runtime Error。
第三条是可组合。辅助函数的接口设计要方便复用,比如删除函数如果基于哑结点实现,那么合并两个有序链表、集合差集删除这些题都可以直接套同一套代码骨架。我后面会详细讲哑结点思路,它是链接辅助函数之间的"通用接口"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 创建链表辅助函数:头插、尾插与哑结点怎么选
2.1 从结构体定义开始
写链表第一步是定义结点结构体。C 语言和 C++ 在 ACM 里都能用,结点定义写法略有不同。
C 语言通常用 typedef:
c复制typedef struct Node {
int val;
struct Node* next;
} Node;
C++ 可以直接写:
cpp复制struct Node {
int val;
Node* next;
Node(int x) : val(x), next(NULL) {}
};
注意 C 语言里 struct Node* next 不能省略 struct,这是新手经常写错的地方。另外,不管哪种写法,next 指针在创建结点时都必须初始化为 NULL,否则它会指向一个随机地址,遍历的时候就是野指针崩溃。
很多同学有一个坏习惯:定义结点时不初始化 next,等到用的时候再赋值。这在小规模本地测试里可能侥幸跑通,但一到评测环境就容易出事。更麻烦的是,这种 bug 是随机出现的,非常难定位。所以我的习惯是:next 的初始化永远在结点创建时一步完成,绝不拖到外面。
2.2 createNode:分配一个结点的标准写法
创建链表辅助函数的地基是 createNode。它单独拆出来,是因为后面头插、尾插、删除、合并全都要创建结点,抽成公共函数能保证每次分配的行为一致。
c复制Node* createNode(int val) {
Node* node = (Node*)malloc(sizeof(Node));
if (node == NULL) {
exit(1);
}
node->val = val;
node->next = NULL;
return node;
}
C++ 里用 new 对应版本:
cpp复制Node* createNode(int val) {
return new Node(val);
}
这里有一个实际比赛里的取舍:很多人图省事,malloc 之后不检查返回值。在线评测系统一般内存充足,检查返回值确实很少触发,但遇到极端数据量大、接近内存上限的题目,malloc 失败就会直接得到 NULL,然后下一步写入 node->val 就是向空地址写数据。我个人建议还是保留检查,反正开销极小,代码也不复杂。当然,如果你确定自己的链表题不会卡内存,省略检查也问题不大,这个属于个人风格。
2.3 头插法与尾插法
创建整条链表,最常用的两种方式是头插法和尾插法。
头插法的核心是每次把新结点插到链表头部,它的代码非常短:
c复制Node* buildByHead(int* arr, int n) {
Node* head = NULL;
for (int i = 0; i < n; i++) {
Node* node = createNode(arr[i]);
node->next = head;
head = node;
}
return head;
}
头插的时间复杂度是 O(n),空间 O(1)。它的特点是:插入顺序和最终链表顺序相反。如果输入的 arr 是 1、2、3,最终链表是 3、2、1。这个特性并不总是坏事——很多题目需要逆序输出,或者处理"反转链表",用头插法天然就做完了。
尾插法则能保持输入顺序,但需要额外维护一个尾指针:
c复制Node* buildByTail(int* arr, int n) {
Node* head = NULL;
Node* tail = NULL;
for (int i = 0; i < n; i++) {
Node* node = createNode(arr[i]);
if (tail == NULL) {
head = tail = node;
} else {
tail->next = node;
tail = node;
}
}
return head;
}
新手最容易犯的错误是尾插时不维护 tail,每次插入都从头开始遍历找尾:
c复制// 错误示范
for (int i = 0; i < n; i++) {
Node* node = createNode(arr[i]);
if (head == NULL) {
head = node;
} else {
Node* p = head;
while (p->next != NULL) p = p->next;
p->next = node;
}
}
这个写法的复杂度是多少?每次插入都要遍历整条链表,整个建链过程是 O(n^2)。如果 n 是 10^5,直接超时。所以尾插法的关键结论是:必须用尾指针把追加操作从 O(n) 降到 O(1)。
关于头插和尾插的选择,用一个简单的标准判断:如果题目要求最终链表顺序和输入顺序一致,用尾插;如果只是关心"拿到一条链",或者利用逆序特性,头插往往更省事。
| 对比项 | 头插法 | 尾插法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n)(维护尾指针时) |
| 空间复杂度 | O(1) | O(1) |
| 链表顺序 | 与输入相反 | 与输入一致 |
| 典型场景 | 逆序建链、反转 | 按给定序列建链 |
| 代码量 | 少 | 稍多 |
2.4 哑结点:创建辅助函数里的"降维打击"
前面两种建链方案,在处理"第一个结点"的时候都有特殊判断:头插要改 head,尾插要判断 head 是否为 NULL。这种"第一个结点特殊处理"逻辑,是 ACM 里大量 bug 的来源。解法就是引入哑结点。
哑结点也叫 dummy node,它本身不存储真实数据,只是作为一个占位头结点挂在链表最前面。这样所有结点(包括原来的第一个数据结点)都有了前驱,插入和删除的代码就不再需要判断"是不是头结点"了。
用哑结点重写尾插建链:
c复制Node* buildByTailDummy(int* arr, int n) {
Node* dummy = createNode(-1);
Node* tail = dummy;
for (int i = 0; i < n; i++) {
tail->next = createNode(arr[i]);
tail = tail->next;
}
return dummy->next;
}
看到区别了吗?循环体里不再有 if (tail == NULL) 的判断,因为 dummy 一定存在,tail 永远不是 NULL,直接 append 就行。我第一次看到这种写法时的反应是:为什么没人早点告诉我。从此以后,我的所有链表创建、删除、合并代码默认使用哑结点方案,头结点的边界问题大幅减少。
不过要留个心眼:哑结点是 malloc 出来的,用完之后如果程序不结束,记得 free 掉。在 ACM 单次运行的模式下,程序退出时操作系统会回收内存,所以很多人不 free;但如果你跑多组测试数据,或者拿去做工程化示例,哑结点本身也会成为内存泄漏的一部分。我会在删除那一章专门讲释放问题。
3. 删除链表辅助函数:按值删、删头、整表释放
3.1 按值删除所有目标结点:迭代法
删除是链表中坑最多的操作,没有之一。最典型的场景是给定一个值 val,删除链表中所有等于 val 的结点。
常规迭代法需要维护 prev 和 cur 两个指针:
c复制void deleteNodeByValue(Node** headRef, int val) {
Node* dummy = createNode(-1);
dummy->next = *headRef;
Node* prev = dummy;
Node* cur = *headRef;
while (cur != NULL) {
if (cur->val == val) {
prev->next = cur->next;
free(cur);
} else {
prev = cur;
}
cur = prev->next;
}
*headRef = dummy->next;
free(dummy);
}
这段代码有几点值得细看。
第一,为什么用 Node** headRef?因为删除可能发生在头结点,这会导致链表头指针变化,如果只传 Node* head,函数内部改了头,外部拿到的还是旧的、已经释放的地址,接下来外层遍历就直接野指针了。关于这一点,我会在下一章单独展开。
第二,为什么引入 dummy?因为删除头结点时,prev 如果是 NULL 就会崩溃。dummy 保证了 prev 永远有值,统一了所有删除情况。
第三,为什么删除某结点后 cur = prev->next 而不是 cur = cur->next?因为 cur 已经被 free 掉了,再去访问 cur->next 属于"使用已释放内存",属于未定义行为,有时候能跑,有时候随机崩溃。正确做法是先保存下一个结点,或者像这里一样通过 prev 拿到新 cur。
3.2 删除头结点的经典坑:悬垂指针
我们来看一个最经典的错误版本:
c复制// 错误示范
void deleteNodeByValue(Node* head, int val) {
Node* prev = NULL;
Node* cur = head;
while (cur != NULL) {
if (cur->val == val) {
if (prev == NULL) {
head = cur->next;
} else {
prev->next = cur->next;
}
free(cur);
} else {
prev = cur;
}
cur = cur->next;
}
}
为什么错了?因为 head 是函数的一个局部参数,它只是把外部 head 的值拷贝了一份。当你在函数里写 head = cur->next 时,修改的只是函数内部那个形参 head,外部的头指针依然是原来的地址。而原来的头结点已经被 free 了,外部再访问它就等于操作悬垂指针。
这就是很多同学本地跑样例"看起来正常"的原因:如果你的测试用例第一个数不等于 val,外部 head 指向的是未删除的结点,输出没问题;一旦第一个数就是要删除的值,外部 head 指向被释放的内存,数据可能恰好没被改写,碰巧还能输出,但换个编译器或者操作系统就崩溃。
所以即使是加哑结点,也必须通过二级指针把链表的头更新回外部。这也是为什么我把删除辅助函数统一写成接收 Node** 的形式。
3.3 整表释放:malloc 和 free 的记账本
删除的另一个大类是整表释放。ACM 选手经常忽略这个函数,因为程序运行结束后 OS 会回收所有内存。但在两类场景下,它反而很关键:一是题目有多组测试数据,每组都建链表却不释放,累积的内存开销可能触发 MLE;二是本地反复运行调试,内存泄漏多了程序会变得迟钝,状态也不准。
写整表释放比想象中更容易错:
c复制// 错误示范
void freeList(Node* head) {
while (head != NULL) {
free(head);
head = head->next;
}
}
问题在于:free(head) 之后,head->next 已经属于"使用已释放内存"了。正确顺序是先保存 next,再释放当前:
c复制void freeList(Node* head) {
while (head != NULL) {
Node* next = head->next;
free(head);
head = next;
}
}
这是一条铁律:在链表中释放任何结点之前,先把它的 next 存下来。不管是整表释放还是删单个结点,都适用。养成这个肌肉记忆之后,use-after-free 类的运行时错误会少掉一大半。
3.4 递归删除与循环删除的取舍
删除整条链表也可以递归写:
c复制void freeListRecursive(Node* head) {
if (head == NULL) return;
freeListRecursive(head->next);
free(head);
}
递归写法的优点是顺序天然"从后往前",看上去很干净。缺点也很明显:链表长了,递归深度会超过系统栈限制,导致栈溢出。竞赛里链表长度达到 10^5、10^6 很常见,此时此刻递归版自由释放就是一颗定时炸弹。所以我的建议是默认使用迭代版,递归版只在链表较短或者面试时展示思路用。
另外提一句,用 Python 刷链表题的同学没有手动释放内存的烦恼,GC 会自动回收。但"删除结点后千万不要立即访问被删除结点的 next"这个逻辑在 Python 里同样成立,只是形式变成了"把 next 指向一个已经不存在的节点引用",只是不会崩溃,但后续逻辑照样出 Bug。
4. 头指针更新的三种方案:二级指针、哑结点、返回值
4.1 问题本质:C 语言的参数传递
理解了前面那么多错误,再往深处看一层:为什么头指针这么容易搞丢?根子在 C 语言的参数传递机制。
C 语言传参是值传递,函数形参只是实参的副本。当外部有一个 Node* head 指向链表头,你把 head 传进函数,函数里修改 head 形参,外部那个 real head 纹丝不动。而"删除头结点"这件事,本质上是"别人欠我的",因为你不仅要删除节点,还要把"链表现在的新头是谁"这个信息传回给外部。
解决思路就是那三板斧:要么把"头指针的地址"传进去(二级指针),要么用哑结点让头指针根本不需要变(外部头永远指向 dummy,真正操作的是 dummy 后面的结点),要么让函数返回新的头指针。三种方案都能解决,但各有适用场景。
4.2 方案一:二级指针
二级指针的写法是这样的:
c复制void deleteNodeByValue(Node** headRef, int val) {
Node** cur = headRef;
while (*cur != NULL) {
if ((*cur)->val == val) {
Node* toDelete = *cur;
*cur = (*cur)->next;
free(toDelete);
} else {
cur = &((*cur)->next);
}
}
}
这个写法的精妙之处在于,cur 本身存的是"某个指针变量的地址"。一开始 cur = headRef,它指向外部头指针变量。如果删除头结点,直接用 *cur = (*cur)->next 修改外部头指针本身,外部拿到的 head 自动变成了新头,根本不需要 return。
而且这个写法不需要 prev 变量,因为 cur 从"指向 head 的指针"变成"指向某个 prefix 的 next 字段的指针",天然记录着前驱地址。删除一个结点,就是修改前驱 next 指向下一个结点。代码非常紧凑,是我的个人最爱。
缺点也有:二级指针对新手来说有点抽象,如果面试时紧张容易绕晕。另外,如果同一个函数要同时处理"按位置删除 ""按值删除",代码会比较隐晦,不利于快速修改。
4.3 方案二:哑结点(最稳)
哑结点方案在前一章已经出现过,我再强调一下它的核心逻辑:
c复制void deleteNodeByValue(Node** headRef, int val) {
Node* dummy = createNode(-1);
dummy->next = *headRef;
Node* prev = dummy;
Node* cur = *headRef;
while (cur != NULL) {
if (cur->val == val) {
prev->next = cur->next;
free(cur);
} else {
prev = cur;
}
cur = prev->next;
}
*headRef = dummy->next;
free(dummy);
}
相比二级指针,dummy 方案的思维负担更小:你只需要记住"dummy 永远在链头,所有结点都有前驱",剩下的就是普通的"先接线、再释放"。唯一的额外动作是函数结尾要更新 *headRef 并释放 dummy,这两个步骤我建议写成模板,每次都照着写,不要临场想。
为什么我仍然传 Node** headRef?因为虽然 dummy 保证了删除过程中不用动 head,但删除完整个链表后,外层还是需要知道新头是谁:dummy->next。所以归根到底,只要链表可能变空或者变头,就必须通过某种方式把新头传出去,dummy 只是让这个动作从"删除时随时要改"变成了"最后统一改一次"。
4.4 方案三:返回新头指针
有些人不喜欢二级指针,也不想 malloc 一个 dummy,那就用返回值:
c复制Node* deleteNodeByValue(Node* head, int val) {
Node* dummy = createNode(-1);
dummy->next = head;
Node* prev = dummy;
Node* cur = head;
while (cur != NULL) {
if (cur->val == val) {
prev->next = cur->next;
free(cur);
} else {
prev = cur;
}
cur = prev->next;
}
Node* newHead = dummy->next;
free(dummy);
return newHead;
}
调用方写成 head = deleteNodeByValue(head, val);。这种写法在工程上也很常见,很多官方题解就这么写。它比二级指针更直观,比纯哑结点方案多返回一个值。缺点是如果函数的调用点很多,而你又忘记接收返回值,立刻回到"外部头未更新"的坑里。
4.5 三种方案对比
| 方案 | 外部头更新方式 | 代码复杂度 | 理解难度 | 典型场景 |
|---|---|---|---|---|
| 二级指针 | 自动通过解引用更新 | 最低 | 偏高 | 追求代码简洁、熟悉指针 |
| 哑结点 | 结尾统一更新 | 中 | 低 | 推荐默认使用 |
| 返回新头 | 调用点手动接收 | 中 | 低 | 工程向、官方题解常见 |
我个人的建议是:熟练掌握哑结点方案作为通用默认,再额外掌握二级指针写法作为进阶备选。两者能互相校验——当一种写法判断不了边界条件时,用另一种思路推演一遍,经常能发现问题。返回新头的写法在面试里可以展示思路,但比赛时容易在调用点漏接,我不太推荐作为唯一方案。
5. 辅助函数在合并、逆序、循环链表里的实际配合
5.1 合并两个有序链表:创建与删除的黄金组合
合并两个有序单链表是高频题。用我们前面准备的哑结点辅助函数,代码写出来非常清爽:
c复制Node* mergeTwoLists(Node* l1, Node* l2) {
Node* dummy = createNode(-1);
Node* cur = dummy;
while (l1 != NULL && l2 != NULL) {
if (l1->val <= l2->val) {
cur->next = l1;
l1 = l1->next;
} else {
cur->next = l2;
l2 = l2->next;
}
cur = cur->next;
}
cur->next = (l1 != NULL) ? l1 : l2;
Node* head = dummy->next;
free(dummy);
return head;
}
这段代码没有调用 createNode 去新建结点,只是把两个旧链表重新接起来。所以"创建"在这里不是分配新结点,而是在逻辑上构建一条新链;"删除"在这里也不是释放结点,而是"断开旧连接,接入新连接"。真正被分配和释放的只有一个哑结点。
这个例子告诉我们,辅助函数的意义不在于每个函数独立使用,而在于它们组合起来能写出整洁的题解。如果不引入哑结点,合并函数一开始就得判断"l1 和 l2 哪个作为新头",写出来的分支会明显更碎,出错概率也更高。
5.2 单链表逆序:头插法的复现
单链表逆序是另一个高频考点,本质上就是头插法创建链表的"现场版":
c复制Node* reverseList(Node* head) {
Node* newHead = NULL;
while (head != NULL) {
Node* next = head->next;
head->next = newHead;
newHead = head;
head = next;
}
return newHead;
}
仔细看这个循环:每拿到一个原链表结点,就把它拎出来,插到 newHead 的头部。这不就是在用头插法重新创建一条链表吗?区别只是结点不是新建的,而是从老链上拆下来的。理解了头插法创建链表的逻辑,逆序不需要额外记忆,自己推一遍就能写出来。
5.3 循环单链表:创建与删除的边界改写
循环单链表在热词里出现得很频繁,它的创建只是把尾结点的 next 从 NULL 改成 head:
c复制Node* createCircleByTail(int* arr, int n) {
if (n <= 0) return NULL;
Node* head = createNode(arr[0]);
Node* tail = head;
for (int i = 1; i < n; i++) {
tail->next = createNode(arr[i]);
tail = tail->next;
}
tail->next = head;
return head;
}
遍历循环链表的终止条件也要改,不能用 while (p != NULL),而是 while (p != head)。
循环链表的删除比单链表更麻烦,因为"删除头结点"时你还要从尾结点那里找到能够接到新头的链路。如果是删除唯一一个结点,整个头指针都得置空。一个可读性较高的思路是:先在循环链表里定位到目标结点的前驱,然后统一修改 next。这个 "找前驱" 的动作天然要求你判断"需要删除的 target 是不是 head,如果一层循环之内没有找到,并且最后一个结点的 next 就是 head,目标就不存在"。
这类题目在 ACM 里更多出现在约瑟夫环问题中。约瑟夫环正好利用循环链表删除结点的过程,每轮从当前结点出发数 k 次,然后删除下一个结点。此时移动的是 head 指针,删除的是 head 的下一个结点,天然避免了"删除头结点还要更新头指针"的复杂度,因为环上每个结点都地位均等。这也是循环链表最值得注意的技巧:让删除永远发生在"非头结点"的位置。
5.4 链表插入辅助函数:也归"创建"管
插入操作从某种意义上来说也是"创建"的一部分,它先创建新结点,再把它接入链表。最常见的辅助函数是在指定结点之后插入:
c复制void insertAfter(Node* prev, int val) {
if (prev == NULL) return;
Node* node = createNode(val);
node->next = prev->next;
prev->next = node;
}
注意执行顺序:先设置 node->next = prev->next,再修改 prev->next = node。如果写反了,先把 prev->next 改成 node,你就找不到原来的后继了。在 ACM 的拼接题里,"先接新链的尾,再拆旧链"这个顺序意识,能避免大量断链问题。
6. 排错经验:断链、悬垂指针与一套边界测试清单
6.1 断链的三个征兆与定位
链表操作的 bug 往往不马上暴露,而是通过症状倒推。经验里最常见的三个征兆是:
第一,打印链表时死循环。这通常是链表中间某个 next 指回了前面的某个结点,形成了一个环。创建循环链表却忘记在结尾把尾 next 置 NULL,是最常见的原因。第二,打印出来的内容顺序不对或者少了几个结点。这往往是删除时 prev 没有跟着移动,跨过了目标结点,或者拼接时接线顺序错误。第三,程序直接崩溃。大概率是访问了 NULL 指针,或者访问了已经 free 的悬垂指针。
这些 bug 的定位方式没有捷径,只能靠"分而治之":每做一步操作就打印一次链表,缩小问题出现的操作区间。不要一口气写出 50 行代码再开始调,那会把自己逼疯。
6.2 免费的调试利器:printList
我在比赛和训练中调试链表,几乎全靠一个打印函数:
c复制void printList(Node* head) {
while (head != NULL) {
printf("%d -> ", head->val);
head = head->next;
}
printf("NULL\n");
}
循环链表则需要一个 do-while 版本:
c复制void printCircle(Node* head) {
if (head == NULL) return;
Node* cur = head;
do {
printf("%d -> ", cur->val);
cur = cur->next;
} while (cur != head);
printf("back to head\n");
}
每一次链表操作之后都打印一次,对比"预期输出"和"实际输出",基本能在半分钟内锁定问题出现在哪一步。注意打印函数本身也要检查:如果你打印时出现死循环,说明链表本身就带着环,先别管算法,先把链建对再说。
6.3 边界测试清单
我每次写完一个链表辅助函数,都会用下面这份清单做本地自测。看起来简单,但真的能拦住绝大多数评测机的卡点:
- 空链表:head 为 NULL,创建、删除、打印都不能崩。
- 单节点链表:删除后应变为空。
- 两个结点:删除头结点、删除尾结点、全部删除三种情况。
- 删除目标值在头部、中部、尾部各测一次。
- 链表中所有值都等于目标值,一次全删。
- 链表中没有目标值,链表保持不变。
- 长链表(10^5 以上):验证不会死循环、不会栈溢出。
- 连续插入后逆序输出:验证 next 指针是否真的串成一条链。
这份清单强烈建议存进自己的代码模板注释里。赛前看一遍,写代码的时候顺手对照,能少交好几次 Wrong Answer。
6.4 代码风格与命名建议
最后说说路线规划层面的建议。ACM 格式下的代码,命名不需要多花哨,但至少要一眼看出用途。我的模板是这样:
createNode(int val):创建单个结点buildByHead / buildByTail:整链创建insertAfter(Node* prev, int val):指定位置后插入deleteNodeByValue(Node** head, int val):按值删除freeList(Node* head):释放整条链printList(Node* head):调试打印
这些函数提前写好,放到自己的算法模板库里。真正比赛时,你根本不需要现场想"删除头结点要不要特殊处理"这种问题,直接调用已经打磨好的版本。磨刀不误砍柴工,这话放在链表辅助函数上特别真实。我见过太多选手,思路一流,却被自己的基础代码拖后腿,实在可惜。
根据我个人经验,链表辅助函数最值得花时间打磨的阶段不是比赛前夜,而是平时刷题的时候。每遇到一次链表题,就把自己的模板改进一点点,加一个边界测试、优化一行代码。时间久了,这些函数就像条件反射一样可靠,那时你再回头看,会发现"创建&删除"早已不再是链表题的难点,反而成了你解题的底气。
