从第1001题开始按顺序刷PAT乙级的时候,我给自己定了个目标:除了个别要背模板的题,其余的尽量一遍AC。结果刷到PAT乙级1075这题,我老老实实交了三次才过:第一次编译通过但答案错误,第二次加了个特判把部分测试点救回来,第三次才真正想明白它到底在挖什么坑。后来回头看,这道题其实一点也不偏,它考的是链表题里最核心的一套静态链表处理思路——你只要把这条路走通,后面再遇到“链表去重”“链表反转”“链表排序”之类的题,基本都能套同一个框架。
这篇就拿1075链表元素分类当例子,把我从读题到AC的完整过程、代码、以及评测里容易踩的边角坑都拆开讲一遍。适合正在准备PAT乙级、考研复试上机,或者想补链表基础的人参考。
1. 把题目翻译成人话:元素分类到底在做什么
1.1 先看清输入输出在说什么
题目大致是这样的:给定一个单链表,第一行给出链表首结点的地址、结点总个数N和一个阈值K,接下来N行每行给出一个结点的地址、数据和下一个结点的地址。要求把所有数据为负数的结点排到最前面,数据在[0, K]范围内的结点排中间,数据大于K的结点排最后,每一类内部要保持原来的相对顺序,最后输出重排后的完整链表。
注意这里的“地址”长得很吓人,其实就是一个五位非负整数,比如00100这种。数据是int范围内的整数,next可能是另一个五位地址,也可能直接是-1,表示链表结束。
我当时想了想,这不就是个分类题吗?把三种结点分开,再按顺序串起来。真正动手才发现,有两个地方特别容易翻车:第一,结点的地址是乱的,输入给的三元组并不是从左到右的链表顺序;第二,N行输入里不是所有结点都一定在链表上,必须从head出发沿着next往下走,能走到才算有效结点。
1.2 第一反应“直接排序”为什么是错的
拿到题,很多人第一反应是:把所有结点放进一个数组,然后自定义比较规则,让负数排最前、[0,K]居中、大于K排最后,然后按排好的顺序输出。从结果上看,好像没问题。但这里藏着一个致命伤:你需要的是“同类内部保持原有顺序”,也就是稳定性。
C++的sort不是稳定排序,直接sort会把同类内部的顺序打乱,比如原来负数是-4在-6前面,排序后可能变成-6在-4前面。有些人会说,那我用stable_sort不就行了?从稳定性上确实可以,但思路还是绕了远路。这题根本不需要排序,它需要的是把链表遍历一遍,针对每个结点做一次三分类判断,然后按类拼接。
更深一层的问题是,即便你把结点顺序排好了,输出也不是简单的“按序输出”。输出时每个结点都要打印自己的地址、数据,以及下一个结点的地址。这个“下一个地址”必须是你重排后的下一个结点的地址,不是原来链表里的next。也就是说,你真正要做的事情是:重新组织出一条逻辑顺序,再按这个顺序去生成新的连接关系。既然这样,不如一开始就别纠结指针怎么改,直接把地址收集出来排序分类,最后统一输出。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心套路:用静态链表把地址变成数组下标
2.1 为什么PAT的链表题都爱用数组模拟
我见过不少刚开始刷题的人,看到链表就先new一个Node,再手动维护指针。这种思路本身没错,但放到PAT这类题里很不划算。
原因有两个。第一个是地址范围有限:结点地址是五位非负整数,最大值撑死99999,这意味着可以直接开一个十万左右的数组,用地址当下标。访问一个结点的data和next都是O(1),不需要遍历查找,更不需要动态分配。第二个原因是输入给定的就是地址,不是Node*,如果你非要用动态结构,还得自己维护一个“地址到指针”的映射,徒增复杂度。
所以通用做法是:定义结构体Node,内部只需要两个字段,data和next,然后开一个足够大的数组,把每个结点的信息存到以地址为下标的位置上。这就是静态链表。
2.2 结点结构怎么设计
我当时写的结构体很简单:
cpp复制struct Node {
int data;
int next;
} node[100010];
下标就是结点地址。例如node[100]的data是地址100这个结点存的数据,next是它的后继地址。
读入的时候,每次都读三个整数addr、data、nextAddr,然后执行:
cpp复制node[addr].data = data;
node[addr].next = nextAddr;
这一步不需要管输入行的顺序,反正所有结点都用地址直接索引了。只要你给的下标合法,随便什么顺序读进来都能正确存储。
2.3 从head出发收集有效结点是关键一步
当你有了完整数组,下一步绝对不要直接对N行输入做操作。正确的做法是让一个指针p从head开始,不断执行p = node[p].next,每经过一个结点就把它的地址收进某个容器,直到p变成-1。
这样做的意义在哪儿?它能保证你只处理“真的在链表上”的结点。有些题目并不会明确告诉你“所有输入结点都在链表上”,如果输入里混了几个孤立结点,你直接按N来处理,最后输出就会多出一些不该存在的东西,答案自然错。即便题目保证了所有结点都在链表上,你从head走一遍也不会有任何损失,反而代码更鲁棒。
这个“收集有效结点”的习惯,我建议从这题开始就养成。因为PAT乙级后面还有好几道链表题,出题人非常喜欢用“部分输入结点不在链表上”这种隐藏条件来卡人。
3. 分类拼接:三个容器比原地改指针好用得多
3.1 遍历时直接按值分到三组
1075要分成三类:data < 0、0 <= data <= K、data > K。
我用了vector
cpp复制int p = head;
while (p != -1) {
if (node[p].data < 0) {
v[0].push_back(p);
} else if (node[p].data <= K) {
v[1].push_back(p);
} else {
v[2].push_back(p);
}
p = node[p].next;
}
注意data正好等于K的时候,走的是else if分支,这是边界条件,别漏。
3.2 为什么不在原链表上直接改next关系
如果要在原链表上原地重排,你会遇到很多“特殊情况”。比如负数类一个都没有,那上一类最后一个结点的next要指向谁?K区间这一类为空的时候又该怎么办?你每改一个next,后面再遍历时,可能已经回不到原来的链表结构了。为了避免这些分支地狱,最干净的办法是先把地址全部收集起来,最后合成一个总序列再输出。
三段分别收集完毕之后,直接拼成一个总的ans列表:
cpp复制vector<int> ans;
for (int i = 0; i < 3; i++) {
for (int addr : v[i]) {
ans.push_back(addr);
}
}
到这里,问题已经不再是“链表”了。你手里拿到的其实是一个排好序的地址序列,按照它的顺序输出即可。
3.3 输出阶段的几个细节
输出阶段是这道题最让人纠结的地方。每个非末尾结点要输出“当前地址 数据 下一个结点地址”,而“下一个结点地址”应该是ans里下一个元素的地址,不一定是node里存的next。
末尾结点的输出则是“当前地址 数据 -1”,把原来的next彻底丢掉。
如果你直接遍历ans,在第i个结点时想看下一个是谁,只需要看ans[i+1]是否存在。不存在就说明当前是最后一个,打印-1即可。这比维护prev、cur指针要直观得多。
4. 可直接AC的C++实现与逐段拆解
4.1 核心代码
把上面的思路整理成完整代码,大概长这样:
cpp复制#include <cstdio>
#include <vector>
using namespace std;
struct Node {
int data;
int next;
} node[100010];
int main() {
int head, n, k;
scanf("%d %d %d", &head, &n, &k);
for (int i = 0; i < n; i++) {
int addr, data, nextAddr;
scanf("%d %d %d", &addr, &data, &nextAddr);
node[addr].data = data;
node[addr].next = nextAddr;
}
vector<int> v[3];
int p = head;
while (p != -1) {
if (node[p].data < 0) {
v[0].push_back(p);
} else if (node[p].data <= k) {
v[1].push_back(p);
} else {
v[2].push_back(p);
}
p = node[p].next;
}
vector<int> ans;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < (int)v[i].size(); j++) {
ans.push_back(v[i][j]);
}
}
for (int i = 0; i < (int)ans.size(); i++) {
if (i != (int)ans.size() - 1) {
printf("%05d %d %05d\n", ans[i], node[ans[i]].data, ans[i + 1]);
} else {
printf("%05d %d -1\n", ans[i], node[ans[i]].data);
}
}
return 0;
}
4.2 一段一段解释为什么这样写
读入部分的循环不用多说,注意数组下标用的是addr本身,所以任何顺序的输入都能正确落位。
遍历部分,p从head开始,每访问一个结点就根据data大小分入三组。这里我没有中途修改node[p].next,因为后面不再需要原始链表结构了,遍历只依赖它一次。
合并成ans之后,输出部分是很多初学者容易写砸的地方。需要注意的第一点是printf("%05d", ans[i])。地址必须占满五位,不足五位要在前面补0。如果直接printf("%d", ans[i]),遇到00100这种地址就会输出100,判题直接给你答案错误。
第二点是node[ans[i]].data。ans[i]存的是地址,想获取它的data,必须以它为下标去访问结构体数组。这里别把ans[i]和data本身搞混。
第三点是中间结点的下一个地址直接写ans[i + 1],不是node[ans[i]].next。在重排后,当前结点逻辑上的后继就是ans序列里的下一个地址。使用原next会出错,因为原链表顺序已经被打散了。
最后一点是末尾的-1。输出时不能用%05d格式化-1,否则会变成“-0001”或者被补零搞乱,所以末尾要单独写死成-1。
4.3 如果你用Python刷,思路一模一样
PAT支持Python提交,1075用Python写也没问题,而且代码会比C++更短。核心是用两个字典分别存data和next:
python复制head, n, k = map(int, input().split())
data = {}
nxt = {}
for _ in range(n):
addr, val, next_addr = map(int, input().split())
data[addr] = val
nxt[addr] = next_addr
group = [[], [], []]
p = head
while p != -1:
if data[p] < 0:
group[0].append(p)
elif data[p] <= k:
group[1].append(p)
else:
group[2].append(p)
p = nxt[p]
ans = group[0] + group[1] + group[2]
for i in range(len(ans)):
if i == len(ans) - 1:
print("%05d %d -1" % (ans[i], data[ans[i]]))
else:
print("%05d %d %05d" % (ans[i], data[ans[i]], ans[i + 1]))
Python版本的性能完全够用,但要注意输入量大的时候,建议用sys.stdin.buffer.read().split()一次性读入,否则可能因为频繁调用input导致速度偏慢。C++用scanf/printf则基本没有这个烦恼。
5. 我在OJ上踩过的三个隐蔽坑
5.1 输入里混着不在链表上的孤立结点
这是1075题最阴的地方之一。我在第一次提交时,错误地认为输入的N行就是整条链表的所有结点,于是直接对这N个结点做分类。结果部分测试点通不过。
后来我把head开始的链表完整打印出来,数了数,发现实际能走到的结点数量比N小。这说明输入里确实存在一些结点,它们虽然占了一行输入,但根本不在以head为起点的链表上。
正确的应对方式就是我前面反复强调的:不要信N,要信head,用while循环沿着next去走。能遍历到多少就是多少,遍历不到的结点一律不处理。这个习惯对你做“链表去重”那一类题同样有帮助,因为孤立结点会导致统计数量错误。
5.2 最后一行的next到底该输出什么
第二个坑是输出末尾结点时,直接打印了node[last].next。这看起来没什么问题,毕竟它是链表原本的一部分。但关键在于:你的重排已经改变了结点的前后关系。原本位于末尾的结点,在重排后可能变成了中间某一段的结尾,它的原next可能指向一个根本不在ans列表里的孤立结点,甚至是一个看起来合法但完全不应该出现的地址。
正确的做法就是无视所有结点的原始next,统一按照ans序列来生成输出。当前元素是ans最后一个时,next一律是-1。这样既不会带出孤立结点,也符合题目的预期。
5.3 地址输出少了前导0
地址是五位的。如果输入是00000,你把它读成整数0,用printf("%d", 0)输出会变成0,但题目期望的输出是00000。这属于格式错误,虽然逻辑完全正确,照样不给分。
处理办法是输出地址统一使用%05d。注意-1不能用这种方式直接格式化,需要单独处理。我自己习惯把循环里所有正常地址用%05d,末尾用printf("-1"),不混着来。
还有一个容易被忽略的点:读入时,如果输入是00001,用scanf("%d", &addr)读到的值是1,存在数组下标1里,完全没问题。不要觉得有前导0就读不进去,整数读取会自动忽略前导0。存储和输出是两个层面的事,存储用int,输出用格式化补零即可。
5.4 边界值K的处理
分类条件里有一个容易忽略的边界:data等于K时,它应该属于中间类[0, K],而不是大于K的那一类。所以判断要用data <= k,不是data < k。如果K=0,那就只有负数、等于0、大于0三类,逻辑依然成立。判题样例里很可能专门准备边界数据来测试这一点,只要你用小于等于,就不会翻车。
另外,如果链表本身为空,head会是-1。此时while循环一次都不执行,ans为空。有些题目会保证链表非空,但如果你希望代码足够稳,可以在输出前加一句判断:
cpp复制if (ans.empty()) {
printf("-1\n");
return 0;
}
这样即使遇到空链表,也不会因为循环体里访问ans[0]而崩溃。
6. 从1075延伸出去:乙级链表题的通用解法与备考心得
6.1 所有链表题都在考同一套东西
刷完1075之后,我特意把乙级里其他链表题翻出来对比,发现命题套路高度统一:给你一堆形如“地址 数据 下一地址”的记录,再给一个头结点。解法都是静态链表存储、从head出发收集有效结点到vector、对vector做各种操作、最后按新顺序统一输出。区别只在于中间那一步做什么。
比如链表去重,收集有效结点后,第一次出现的绝对值保留,重复出现的放进另一个“删除链表”,最后要输出两条链表。再比如链表反转,收集后直接reverse整个vector,或者只反转部分区间。1075则是按数据值做三分类。
所以这道题真正的价值不是让你记住“三类元素怎么分”,而是让你理解“地址序列”这种抽象方式。在PAT里,链表本质上不是一串动态对象,而是一串可重排的地址。节点之间的连接关系是你自己定义的,不是数据里写死的。
6.2 测试点是怎么设计出来卡人的
站在出题人角度,1075这种题能卡人的点并不多。我自己总结了会被设计的测试点类型:
- 样例常规数据,用来通过最基础路径。
- 三类中有某一类为空,比如没有负数,或者没有大于K的结点。
- K=0的边界情况,验证你是否把等于K的值分错类。
- 数据中含头部带有前导0的地址,测试你输出是否补零。
- 存在从head出发无法到达的孤立结点,测试你是否严格按链表遍历。
- 所有结点都属于同一类,此时整个ans只有一个类的结点,逻辑也要能跑通。
写代码时对照这些测试点去检查自己的实现,要比反复提交省时间得多。我在上机考试时有个习惯:写完代码先不急着交,先手动构造几个小样例,重点覆盖“某类为空”和“所有结点同类”这两种情况,确认输出格式没问题再提交。
6.3 关于刷题环境与常见报错
有些刚开始刷PAT的人会在本地折腾编译环境,然后遇到各种报错。网上常有人提到类似“error: java_home is not set and no 'java' command could be found in your path”的问题,这通常是本地搭建的判题辅助工具或者运行脚本时,系统找不到JDK路径造成的。如果你准备用Java做题,建议先把JAVA_HOME环境变量配好,并确保java -version在终端里能正常输出版本信息。如果你像我一样用C/C++,就完全不需要关心Java环境。
另外要区分一个常见误解:PAT是上机编程题,提交代码后由在线评测系统用测试用例自动跑,黑盒比对输出结果,不是选择题,也不是只交答案不交代码。所以你必须保证代码能在标准输入输出下工作。这种模式对很多人来说需要一个适应过程,解决办法就是多刷题。
还有一个小提醒:网上搜“1075”时,可能会看到Windows服务启动失败、错误码1075之类的文章。那个1075是系统错误码,表示服务不存在或依赖服务不可用,跟PAT乙级1075这道链表分类题毫无关系,只是数字恰好相同。不要把两件事混在一起纠结,看到“1075”先确认上下文到底是OJ题号还是系统报错。
6.4 个人刷题路线的一些建议
如果你刚开始准备PAT乙级,我建议按题型来归纳,不要只顺着题号刷。前二三十题可以帮你熟悉输入输出和基本语法,但到了链表这块,一定要集中火力练。我是把1075和1090、1025这类题放在同一天做的,每做完一道就对比一下它们收集有效结点的代码有没有复用空间。
用C/C++刷题的同学可以重点掌握scanf/printf、vector、结构体数组几个工具。用Java就好好封装一个Node类加HashMap,用Python就用字典模拟。语言不重要,重要的是你脑子里要能稳定复现“地址数组存节点、head出发收集、vector重排、统一输出”这条流水线。
最后分享一个小技巧:做这类输出格式复杂的题,尽量把“构造答案序列”和“打印结果”分成两个阶段。不要在遍历链表的同时边排序边打印,这样代码会非常难调。先构造好ans序列,再从头到尾打印,出问题时你只需要打印一遍ans,看看到底是收集错了还是输出格式错了,定位会快很多。
