备考PAT甲级这件事,我太有体会了。当年我也是从乙级一路打到甲级,中间踩过无数坑:题解看了一大堆,代码复制下来也能跑,可换一道同类题就抓瞎;刷题顺序乱七八糟,今天做树明天做图,知识点全是散的;更不用说考场上被一个超时卡到心态崩盘。后来我才想明白,真正有价值的不只是“题解”和“代码”,而是题解背后那套可以迁移的“解析思路”。这份合集就是我从实验室同学的需求出发整理出来的,把PAT甲级高频题型的解法、代码模板和避坑经验做了一次系统归类,适合准备考研复试机试、想冲大厂算法实习,或者单纯想系统提升数据结构和算法能力的人。如果你是零基础,建议从第二章的方法论开始看;如果你已经刷了几十题,可以直接跳到第三、四章的套路拆解和真题实战。
1. 先把PAT甲级的“考点地图”吃透
1.1 甲级和乙级、顶级到底差在哪
很多人一上来就问:“我乙级还没刷完,能不能直接干甲级?”先说结论:能,但要有心理准备。PAT乙级主要考察基础语法和简单数据结构,数组、链表、栈、队列、排序、字符串处理,基本就是“把题目翻译成代码”的难度。而甲级完全不同,它默认你已经会写代码,考的是“在有限时间内为给定问题设计出高效算法”的能力。
甲级和顶级也有明显区别。顶级更接近竞赛题,经常出现复杂的动态规划、网络流、计算几何这类内容,面向的是专业竞赛选手。甲级则更偏向工程场景下的算法设计,题目里大量出现树、图、最短路径、拓扑排序这类经典数据结构问题,考察的是你是否具备扎实的算法功底,能不能在考试压力下写出稳定、高效的代码。
三者定位不同,备考思路自然也不同。想考甲级,就别抱着乙级题刷到天荒地老,分段进阶才是正路:先用乙级题目熟悉OJ输入输出和基础语法,然后尽快切换到甲级题库,按考点模块逐类攻克。
1.2 官方考点拆成一张优先级表
备考甲级最怕的就是“盲目刷题”。两百多道题,如果不知道考点分布,很容易陷进去出不来。我根据历年真题和考试大纲,把考点拆成了几个大类,按出现频率和性价比做了排序。
| 考点大类 | 具体内容 | 出现频率 | 建议优先级 |
|---|---|---|---|
| 数据结构 | 链表、栈、队列、堆、哈希表 | 非常高 | 必拿 |
| 树 | 二叉树的遍历、重建、BST、堆、并查集 | 非常高 | 必拿 |
| 图 | 连通块、最短路径、拓扑排序、最小生成树 | 高 | 必拿 |
| 排序与查找 | sort、二分、结构体排序 | 非常高 | 必拿 |
| STL应用 | vector、map、set、priority_queue、string | 非常高 | 必拿 |
| 数学问题 | 素数、质因数、分数运算、大整数加法 | 中高 | 尽量拿 |
| 动态规划 | 背包、LIS、数塔、状态机 | 中 | 按目标取舍 |
| 贪心 | 区间调度、排序后贪心 | 中 | 按目标取舍 |
| 字符串处理 | getline、find、substr、正则思想 | 高 | 必拿 |
这张表不是让你死记,而是帮你做减法。如果你的目标是拿100分,那图论和树是绝对不能放的部分;如果目标只是过线,数学题、字符串题这种“低门槛送分题”更要抓住。
1.3 分数结构决定你的时间分配
PAT甲级一场考试一般是4道题,满分100分,考试时间180分钟。前两题通常偏简单,考察基本数据结构和简单算法;后两题明显上强度,经常会出现图论、复杂模拟或需要精细优化的题。
我第一次考时犯过一个经典错误:第一题写得太细,抠输入格式抠了半小时,结果最后一题连题目都没看完。后来我总结了一套时间分配法:发卷后先花5分钟快速浏览全部4题,判断每题的难度和大致类型;按分值分配时间,前两题控制在一个半小时以内,剩下时间重点攻第三题;第四题如果20分钟内没有完整思路,果断先拿部分分,然后回头检查前面的题。
有了这张“地图”,你接下来刷题才有方向。下面就是第二个关键问题:题解合集到底应该怎么用。
2. 题解合集这样用,刷题效率才最高
2.1 刷题顺序:先模块后套题
我观察过我带过的学弟学妹,最容易犯的错就是“按题号顺序刷”。PAT题库的编号基本是按年份排的,同一年的题难度波动很大,今天做了一道简单模拟题,明天就碰上压轴图论题,知识点被切得七零八落,学了后面忘了前面。
我的建议很明确:按知识点模块刷,每个模块集中刷15到20道题,打透一个再换下一个。比如先专门刷“树的遍历与重建”,把前序、中序、后序、层序各种组合都见一遍,算法模板自然就刻在脑子里了。模块刷完后,再按年份成套做题,掐时间模拟真实考试,训练自己的时间分配和抗压能力。
如果你不知道模块怎么划分,直接参考PAT题库页面的“按知识点”分类,或者用我上一章的考点表自己建一套题目清单。
2.2 一份“最优题解”该怎么读
题解不是用来“背”的,而是用来“拆”的。同样一道题,A题解只贴代码,B题解详细讲了思路还分析了复杂度,这两者的价值天差地别。在我看来,一份真正有用的题解至少要包含四个部分:题目想让你做什么、用什么数据结构描述数据、核心算法思路、代码中哪些点是容易踩坑的地方。
正确读题解的姿势是:先读题,自己独立想15分钟,哪怕是写暴力也能想出一个方向;然后再看题解的思路部分,重点看“为什么想到这样做”;看到核心代码前先暂停,尝试自己补全实现;最后对照题解代码,找出自己的差距,并记录到错题本里。
那种“看完思路直接抄代码”的方法,练出来的只是手速,不是算法思维。考试时题目稍微一变就会暴露。
2.3 建立你自己的代码模板库
刷到中后期,我强烈建议你建一个自己的代码模板库,用Markdown、VuePress或者哪怕一个TXT文件都行。每个模板只写一道核心题型的完整代码,再附上“适用场景”、“易错点”、“复杂度”三个注释段。
比如“Dijkstra模板”旁边我会写:适用于单源正权最短路;dist初始化为INF;堆优化时要注意pair默认先按first排序,所以要把距离放前面;遇到需要输出路径的题,再加一个pre数组记录前驱。考试前把这些模板从头到尾过一遍,比临时翻题解有用一百倍。
接下来进入最核心的部分:高频题型到底怎么解,代码怎么写才稳。
3. 高频题型的核心套路与代码模板
3.1 排序与二分:把STL用成“条件反射”
PAT甲级的排序题几乎不会让你手写快排,它考的是“你会不会用排序解决实际问题”。最常见的场景是结构体排序:读入一批数据,要求按某个字段排,字段相同再按另一个字段排。这时候,sort加自定义比较函数的组合就是标准答案。
#include <bits/stdc++.h> using namespace std; struct Student { string id; int score; }; bool cmp(const Student &a, const Student &b) { if (a.score != b.score) return a.score > b.score; // 分数高的在前 return a.id < b.id; // 同分按学号升序 } int main() { int n; cin >> n; vector<Student> stu(n); for (int i = 0; i < n; i++) { cin >> stu[i].id >> stu[i].score; } sort(stu.begin(), stu.end(), cmp); for (auto &s : stu) { cout << s.id << " " << s.score << "\n"; } return 0; }二分法在PAT里更多是“二分答案”或“二分查找”的变体。我自己的习惯是始终使用同一种写法,防止考试时边界搞混。下面这套我用的是“左闭右开”区间,循环结束条件是l < r,mid取左中位数,避免死循环。
// 在升序数组a中找第一个 >= target 的下标 int lowerBound(vector<int>& a, int target) { int l = 0, r = a.size(); // 左闭右开 while (l < r) { int mid = l + (r - l) / 2; if (a[mid] < target) l = mid + 1; else r = mid; } return l; }这套模板的好处是逻辑简单:所有满足条件的情况都归到r = mid,所有不满足的都归到l = mid + 1,不容易写错。练习时建议把lower_bound和upper_bound都用自定义方式实现一遍,理解后才敢在考场直接调STL。
3.2 树的遍历:重建、层序与BST的判断
树是PAT甲级的“半壁江山”,尤其是二叉树的遍历,几乎每场都考。甲级常用的考察方式有两种:第一种是给出中序+前序或中序+后序,让你重建二叉树并输出层序遍历;第二种是给一个插入序列,建BST然后求某个遍历序或判断两个序列是否得到同一棵BST。
重建二叉树的核心是“在中序序列中定位根的位置”。前序(或后序)提供了根,中序利用根把左右子树切开,递归处理即可。这里一定要注意边界:中序的根的位置要用unordered_map提前存好,否则每次都find一遍,最坏情况会退化成O(N^2),在大数据点上很危险。
#include <bits/stdc++.h> using namespace std; struct TreeNode { int val; TreeNode *left, *right; TreeNode(int v) : val(v), left(nullptr), right(nullptr) {} }; vector<int> inOrder, postOrder; unordered_map<int, int> pos; // 中序值 -> 下标 TreeNode* build(int inL, int inR, int postL, int postR) { if (inL > inR) return nullptr; int rootVal = postOrder[postR]; TreeNode* root = new TreeNode(rootVal); int rootIdx = pos[rootVal]; int leftSize = rootIdx - inL; root->left = build(inL, rootIdx - 1, postL, postL + leftSize - 1); root->right = build(rootIdx + 1, inR, postL + leftSize, postR - 1); return root; } void levelOrder(TreeNode* root) { if (!root) return; queue<TreeNode*> q; q.push(root); vector<int> ans; while (!q.empty()) { TreeNode* cur = q.front(); q.pop(); ans.push_back(cur->val); if (cur->left) q.push(cur->left); if (cur->right) q.push(cur->right); } for (size_t i = 0; i < ans.size(); i++) { cout << (i ? " " : "") << ans[i]; } } int main() { int n; cin >> n; inOrder.resize(n); postOrder.resize(n); for (int i = 0; i < n; i++) cin >> postOrder[i]; for (int i = 0; i < n; i++) { cin >> inOrder[i]; pos[inOrder[i]] = i; } TreeNode* root = build(0, n - 1, 0, n - 1); levelOrder(root); return 0; }这段代码里最容易被忽略的是递归边界的减法:postL + leftSize - 1是用左子树的大小来划分后序区间,很多人在这一步会推错。强烈建议自己在草稿纸上画一棵树,把每个区间标出来,跑通一次以后就不会再错。BST的判断也是一类常考题:对一棵给定树,看其中序遍历是否升序。如果是,那就是BST;如果不是,就不是。前提是要记住BST“左小右大”的定义,并且处理重复值时需要用<=做区分。
3.3 图的遍历:连通块、最短路与拓扑
图论题在PAT里属于“区分度比较大”的部分。题目描述经常很绕,但只要识别出“求连通分量数”、“求两座城市之间最短路径”、“判断依赖关系是否合法”这几类模型,解法基本是固定的。
DFS解决连通块计数是最基础的,尤其适合二维矩阵类题目,比如地图里有多少个独立区域。这类题的坑在于:方向数组写错、越界判断漏掉、访问标记没做导致死循环。我的建议是统一用visit数组做标记,而不是修改原图,这样既能防重复访问,也方便后面复用原数据。
最短路径题在PAT里最常考的是Dijkstra算法,而且经常加两个附加条件:输出路径、多条最短路时选某种次级条件(比如总花费最小、边数最少)。这种“第二标尺”问题是我的必杀考点,因为几乎每次考试都会出现。解法是在更新最短路时,同时对第二维做判断。
#include <bits/stdc++.h> using namespace std; const int INF = 0x3f3f3f3f; int n, m; vector<vector<pair<int, int>>> graph; // to, weight vector<int> dist, cost, pre; void dijkstra(int src) { dist.assign(n, INF); cost.assign(n, INF); pre.assign(n, -1); vector<bool> vis(n, false); dist[src] = 0; cost[src] = 0; for (int round = 0; round < n; round++) { int u = -1; int minDist = INF; for (int i = 0; i < n; i++) { if (!vis[i] && dist[i] < minDist) { minDist = dist[i]; u = i; } } if (u == -1) break; vis[u] = true; for (auto [v, w] : graph[u]) { if (!vis[v] && dist[u] + w < dist[v]) { dist[v] = dist[u] + w; cost[v] = cost[u] + w; // 如果是求最短路相同情况下的最小花费,这里可以换 pre[v] = u; } else if (!vis[v] && dist[u] + w == dist[v]) { // 第二标尺比较 if (cost[u] + w < cost[v]) { cost[v] = cost[u] + w; pre[v] = u; } } } } }升级提速可以直接把找最小dist的循环换成priority_queue,复杂度的区别在稀疏图里非常明显。PAT里n通常不超过1000,朴素版本往往也能过,但考场上时间有限,我建议直接写堆优化版本,避免在规模大的测试点被卡超时。堆优化时比较器一定写成greater<pair<int,int>>,注意pair的比较顺序是first优先。第一维放距离,第二维放节点编号,千万别反了。
拓扑排序也是一个出现率不低的考点,典型场景是“课程安排”、“项目依赖顺序”。核心算法是统计每个点的入度,每次取出入度为0的点,删除它的出边,重复直到队列为空。如果最终从队列中出来的节点数不等于总节点数,说明图里有环,无法拓扑排序。
3.4 动态规划与贪心:识别特征比背模板更重要
很多同学看到DP就慌,觉得状态转移方程太难想。PAT甲级的动态规划其实没有竞赛那么难,常见模型就那么几种:01背包、最长不下降子序列(LIS)、最大连续子序列和、数塔问题、编辑距离。更重要的是学会“识别”这道题应该用DP:题目要求最大值或最小值,并且当前状态可以由更小的子问题转移过来,候选方案有重叠结构,那就大概率是DP。
以最大连续子序列和为例,状态dp[i]表示“以第i个元素结尾的最大和”,转移方程是dp[i] = max(nums[i], dp[i-1] + nums[i])。这个题在PAT里会以各种包装出现,但本质不变。我遇到过一道看似是数组处理的题,实际就是最大连续子序列和加了一个要求输出序列首尾元素,能认出来就有救。
贪心的特征是每一步都做当前看起来最优的选择,并且这个策略能证明全局最优。PAT里常见的贪心模型有:区间调度(按结束时间排序)、背包变体(按单位价值排序)、哈夫曼编码(用优先队列)。这里面最容易翻车的是“想当然贪心”:题目里面两个并列条件,不一定都能用贪心解。如果你发现自己的贪心策略无法证明对,那大概率不是贪心,而是需要DP。
3.5 数学与字符串:这些“送分题”别丢分
数学题在甲级里虽然不占大头,但属于性价比极高的部分。素数判断、埃氏筛、最大公约数、最小公倍数、分数化简和四则运算,是出镜率最高的几个点。分数运算建议统一用“假分数+约分”的思路,每一步做完都调用一次gcd化简,避免中间溢出。大整数加法也要掌握字符串写法,PAT里偶尔会出超过long long范围的高精度题。
字符串的处理更是每场必考,因为PAT本身是“PAT重点考察数据结构与算法,但也考察代码基本功”。这里的难点不是算法,而是C++的字符串API用得不熟。getline(cin, s)和cin >> s的区别必须清楚:读一行带空格的字符串要用getline,但getline之前如果有cin >> n,一定要先getline(cin, tmp)把换行符吃掉,否则读到的第一行是空串。这道“换行符坑”我亲眼见过好几个人在考场上浪费十来分钟。
还有substr、find、stoi、to_string这些函数要熟练。字符串题往往不需要什么高级算法,但要求代码写得快、写得稳。
4. 真题实战:一道树的遍历经典题完整拆解
4.1 题目长什么样
光讲模板还不够,我带你看一道非常经典的PAT甲级真题——这类题几乎每年都会换个包装出现。题目大意是:给定一棵二叉树的后序遍历序列和中序遍历序列,节点编号为1到N,要求输出这棵树的层序遍历序列。
这道题的考点非常明确:树的遍历、重建二叉树、层序遍历。看起来简单,实际动手写的时候,很多人在递归边界上翻车,也有的人建完树却不知道怎么输出层序。下面我完整拆解一遍。
4.2 思路推导:后序+中序怎么重建树
后序遍历的顺序是“左子树、右子树、根”,所以后序序列的最后一个元素一定是整棵树的根。拿到根的值之后,去中序序列里找到根的位置。中序序列的结构是“左子树、根、右子树”,于是根的左边是左子树的中序区间,右边是右子树的中序区间。
关键在于利用“左子树的大小”把后序序列也切成两段:后序序列中,从开头数leftSize个元素属于左子树,接着往后数rightSize个属于右子树,最后一个才是根。只要每次递归都把inL, inR, postL, postR这四个边界算清楚,整棵树就能正确重建出来。我自己会用一个unordered_map存中序值到下标的映射,这一步能将查找根位置的时间从O(N)降到O(1),整体复杂度是O(N log N)或者接近O(N),不会在大数据点上被卡。
重建完成后,层序遍历就是标准的BFS:根节点入队,每次从队首取出节点,把它的左右孩子按顺序入队,输出顺序自然就是层序。
4.3 参考代码与复杂度分析
完整的代码模板我在前面3.2节已经给过,这里再补充一个容易忽视的细节:输出层的节点之间用空格分隔,最后一个节点后面不能有多余空格,否则会报Presentation Error。虽然PAT现在对格式错误的判定比较宽容,但考场上减少这类无谓扣分总归是好的。
这段代码的时间复杂度为O(N log N),其中N是节点数,递归构建每个节点只处理一次,unordered_map的查找近似O(1)。空间复杂度为O(N),主要是递归栈和存储节点的开销。对于PAT甲级的数据范围(N通常在30以内甚至更小,个别题N会到几千),这个复杂度绰绰有余。
4.4 变体与踩坑记录
这类题不止一种考法。有的题给的是前序+中序,只需要把“后序最后一个元素是根”换成“前序第一个元素是根”,递归边界做相应调整;有的题让你输出后序遍历,那就把建树过程整理成postOrder(root)递归输出;还有的题会问“这棵树是不是完全二叉树”或者“最底层最左边的节点是什么”,这些都是在一棵已经建好的树上做扫描,代码量不大但思路必须清晰。
我踩过最狠的一个坑是:递归函数里忘记处理rootIdx等于inL或inR的边界情况,导致leftSize直接为0,后序区间划分异常,递归死循环导致栈溢出。后来我写这类递归前,都会先在草稿纸上画出“左子树为空”、“右子树为空”、“左右都空”这三种边界形态,确保递归都能正确终止。
5. 避坑指南:PAT考场与刷题中常见的坑
5.1 输入输出:快慢不是玄学
PAT甲级的输入量一般情况下不算大,用cin/cout完全足够。但如果你在第2题以后遇到“输入很多行、每行很多数”的题,就需要小心了。我建议所有刷题代码开头都加上这两行:
ios::sync_with_stdio(false); cin.tie(nullptr);这两行的作用是关闭C与C++输入输出的同步,以及取消cin和cout的绑定,能显著提升大输入量下的速度。如果你不想加或者忘了加,遇到数据量极大的题,同样一个算法可能用cin会超时,用scanf却能过,区别就在这。另外输出用'\n'而不是endl,因为endl每次输出还会强制刷新缓冲区,多耗不少时间。
5.2 常见运行时错误诊断
PAT判题结果里最常见的就是“段错误”(Segmentation Fault)和“运行超时”(Time Limit Exceeded)。段错误十次里有九次是数组越界或者访问了空指针。比如在build函数里递归访问vec[i]前没有检查i是否在合法范围,或者pre[v]没有初始化就被拿去输出路径,这类问题在本地测试小数据时不明显,一旦遇到边界数据就会崩。
超时则要先看复杂度是不是写高了。n是1000,你写O(N^2)也许还能过,但如果n到100000,O(N^2)基本必超。优化方向按优先级排列:把cin/cout解绑、把map换成unordered_map、把朴素版Dijkstra换成堆优化版、把O(N^2)的双重循环改成双指针或二分。如果这些优化都做完了还超时,那就重新审视算法模型,看看是不是可以用更高效的数据结构。
5.3 考试策略:部分正确也是分
PAT的计分方式不是零和游戏,每个测试点都算分,所以即使你写不出满分解法,暴力算法也能帮你拿到一部分分数。比如一道图论的题,正解是堆优化的Dijkstra,但你不太确定怎么记录路径,那就先把不记录路径的Dijkstra写上,至少能过掉基础数据点;如果连Dijkstra都忘了,直接写DFS暴力搜索,也能拿一波分。考场上“先暴力拿分,再逐步优化”是最务实的节奏,千万不要死磕一道题直到交卷。
5.4 一次考出高分的“临门一脚”
考试当天的心态和状态也很重要。我的建议是正式考试前至少完整模拟2到3次,使用PAT官网的模拟考试功能,掐表180分钟,让自己习惯“倒计时压力”下的做题节奏。到了考场,先把手上的资源和自己的薄弱知识点在草稿纸上列出来,每做完一题就快速验证一下边界数据,检查数组大小是否够、初始化是否重置。
最后分享一个小技巧
我现在带人刷PAT,都会让他们在每道题提交前问自己三个问题:数据范围允许我用这个复杂度吗?边界情况我测了吗?输出格式和题目要求完全一致吗?这三个问题能拦截掉考场上大约七成的失误。PAT甲级说到底不是智力竞赛,而是“算法基本功+抗压能力+细节把控”的综合测试。把模板库建起来、把每类题型的识别特征记牢、再通过套题训练形成肌肉记忆,通过考试就是水到渠成的事。希望这套题解合集的方法能帮你少走我当年走过的弯路,祝你在下一次考试里一把过。