算法设计题在408里往往是拉开差距的"重头戏",但对时间有限的同学来说,追求满分性价比并不高 。这篇笔记针对两道链表经典真题(2015年、2019年统考真题),只讲暴力解法------保证代码逻辑正确、能跑出对的结果,把"效率优化"这部分分数主动让出去,用最低的学习成本拿到 70%~80% 的分数。
写在前面:暴力解法不是"划水",是有底线的
暴力解法能保住分数的前提,是这两点必须做到:
- 逻辑必须完全正确,不能因为图省事漏掉边界条件,否则不是"效率低"而是"做错了",扣分性质完全不同;
- 复杂度分析要如实写,暴力解是 O(n²) 就老实写 O(n²),不要照抄标准答案的 O(n) 蒙混过关------阅卷是对着代码看复杂度的,写的和代码对不上,反而显得你没理解自己写的算法。
带着这两条底线,我们来看题。
一、2015年统考真题:删除绝对值相等的重复结点
题目重述
用单链表保存 m 个整数,结点结构为 [data][link],且 |data| ≤ n(n 为正整数)。设计一个算法,对于链表中 data 的绝对值相等 的结点,仅保留第一次出现的结点,删除其余绝对值相等的结点。
举例 :链表为 21 → -15 → -15 → -7 → 15,因为第三个 -15 和最后一个 15 的绝对值都和前面出现过的重复,所以删除后变成 21 → -15 → -7。
暴力解思路
这题本质就是"删除无序链表中的重复元素"的变种,最直接的想法是:对每一个结点 p,把它后面所有的结点都过一遍,凡是绝对值和 p 相同的,就删掉。
也就是经典的双重循环:外层固定一个结点,内层负责在它后面"扫雷式"删除重复项。
代码实现
c
typedef struct LNode {
int data;
struct LNode *link;
} LNode, *LinkList;
void DelDup(LinkList head) {
LNode *p = head->link; // p指向当前要保留的结点
LNode *pre, *q;
while (p != NULL) {
pre = p; // pre记录p的直接前驱,方便内层删除结点
q = p->link;
while (q != NULL) {
if (abs(q->data) == abs(p->data)) {
pre->link = q->link; // 绝对值重复,删除q
free(q);
q = pre->link; // q后移,继续检查
} else {
pre = q;
q = q->link;
}
}
p = p->link; // p移到下一个待处理结点
}
}
复杂度分析
最坏情况下(比如所有结点绝对值都不重复),外层要走 m 次,每次内层也要扫完剩下的结点,时间复杂度为 O(m²) 。这版代码没有额外开辟数组或哈希表,空间复杂度是 O(1)。
一个容易被忽略的加分细节
这题的官方标准答案(追求 O(m) 时间)用的是一个"桶数组"的技巧:因为题目明确说了 |data| ≤ n,可以开一个大小为 n+1 的数组当"标记表",遍历一遍链表,遇到绝对值第一次出现就标记,之后再遇到相同绝对值直接删除------只需遍历一次,O(m) 时间搞定,但代价是要额外开 O(n) 的辅助数组。
这里有个很有意思的对比 :暴力解法虽然时间上是 O(m²),效率更差,但空间复杂度反而是 O(1),比标准答案更省空间。如果你考场上时间紧张写不出桶数组的思路,暴力解法依然是一个逻辑自洽、站得住脚的答案,复杂度分析时如实写清楚"时间 O(m²),空间 O(1)"即可,不丢人。
二、2019年统考真题:单链表结点重排
题目重述
设线性表 L=(a1,a2,a3,⋯ ,an−2,an−1,an)L=(a_1,a_2,a_3,\cdots,a_{n-2},a_{n-1},a_n) L=(a1,a2,a3,⋯,an−2,an−1,an) 采用带头结点的单链表保存,结点定义如下(题目已给出,直接用即可,不需要自己重新 typedef):
c
typedef struct node {
int data;
struct node *next;
} NODE;
请设计一个空间复杂度为 O(1) 且时间上尽可能高效的算法,重新排列 L 中的各结点,得到 L′=(a1,an,a2,an−1,a3,an−2,⋯ )L'=(a_1,a_n,a_2,a_{n-1},a_3,a_{n-2},\cdots) L′=(a1,an,a2,an−1,a3,an−2,⋯)。
⚠️ 先提醒一个关键区别
这道题和上一题不一样的地方在于:"空间复杂度O(1)"是题目明确写出来的硬性要求,不是"能省则省"的加分项 。也就是说,如果暴力解用了额外的 O(n) 空间,这不只是"没那么高效",而是直接违反了题目条件,扣分力度会比单纯的时间复杂度不达标更重。但即便如此,先把逻辑做对、拿到"正确性"这部分的分数,仍然是一笔划算的账。
暴力解思路
正规的满分解法需要用"快慢指针找中点 + 反转后半链表 + 交替合并"这一套组合技,一步想不到就容易写崩。暴力解法可以完全绕开这些技巧:
用一个数组,把链表里所有结点的指针 依次存进去,这样就把"链表操作"降级成了"数组下标操作"------想要哪个位置的结点,直接下标访问就行,不用再一步步 next 遍历。存好之后,用两个下标 i(从头开始)和 j(从尾开始),按照 a1,an,a2,an−1,⋯a_1,a_n,a_2,a_{n-1},\cdots a1,an,a2,an−1,⋯ 的顺序交替取值,重新穿针引线接成新链表。
代码实现
c
#define MAXN 10000 // 根据题目规模自行设定或用动态数组
void Rearrange(NODE *L) {
NODE *arr[MAXN];
NODE *p = L->next;
int n = 0;
// 第一步:把所有结点指针依次存入数组
while (p != NULL) {
arr[n++] = p;
p = p->next;
}
// 第二步:双下标交替取值,重新连接
int i = 0, j = n - 1;
NODE *tail = L; // tail从头结点开始,逐步向后接新结点
while (i <= j) {
tail->next = arr[i++]; // 先接前面的a_i
tail = tail->next;
if (i <= j) { // 注意:结点数为奇数时,最后一次只需接前面,不接后面
tail->next = arr[j--]; // 再接后面的a_j
tail = tail->next;
}
}
tail->next = NULL; // 别忘了封尾,否则可能连成环或指向脏数据
}
复杂度分析
遍历链表存入数组是 O(n),双下标合并也是 O(n),所以时间复杂度是 O(n) ,时间效率其实已经和标准答案一样快了;但因为用了大小为 n 的数组来存指针,空间复杂度是 O(n),没有达到题目要求的 O(1)。
这题暴力解丢分的地方要认清楚
| 维度 | 暴力解表现 |
|---|---|
| 正确性 | ✅ 完全正确,能得出正确的 L' |
| 时间复杂度 | ✅ O(n),已经是最优 |
| 空间复杂度 | ❌ O(n),违反了题目明确写出的"O(1)"硬性要求 |
这里想强调一个做题时容易忽略的阅读细节:"时间上尽可能高效"这种表述,是鼓励你往更优方向想,但没达到也不算违反题意;而像这题"空间复杂度为O(1)"这种写法,是一条明确的硬性约束,题目怎么要求就要怎么满足,这是审题时要认真区分的两种不同措辞。
如果你时间充裕,这题的满分解法(快慢指针找中点 + 反转后半段 + 合并)是一个非常值得掌握的组合技巧,因为"找中点""反转链表""合并链表"这三个操作单独拆开看,几乎是链表算法题里出现频率最高的三大基本功,值得单独用一篇笔记来精讲。
三、两题对比小结
| 2015年真题(删除重复) | 2019年真题(结点重排) | |
|---|---|---|
| 暴力解时间复杂度 | O(m²) | O(n) |
| 暴力解空间复杂度 | O(1) | O(n) |
| 题目对复杂度的要求 | 只说"尽可能高效"(软性期望) | 明确写出"空间复杂度O(1)"(硬性要求) |
| 暴力解丢分风险 | 较低,只丢"效率"这一小块 | 较高,直接违反了明文空间要求 |
审题时先看清楚题目对复杂度到底是"建议"还是"要求",再决定这道题值不值得为了保底而牺牲空间去写数组辅助的暴力解------这比单纯"背一个暴力模板"更重要。