ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PAT顶级真题题解:字符串哈希、拆点最短路、树形DP与线段树倒置

PAT顶级真题题解:字符串哈希、拆点最短路、树形DP与线段树倒置 三月底的机房里我看到第四题题面第一行写着维护一个01序列的时候就知道这场的顶级大概率是场硬仗。2024年春季的攀拓PAT顶级考试四道题分别落在字符串哈希、分层图最短路、树形DP方案数、线段树区间倒置这几个经典方向看起来都是老面孔但每道题都往深里挖了一层没有一道能靠背模板改输入输出直接糊弄过去。这篇文章把我在考场上的完整思考过程、考后验证过的解法以及踩过的坑都整理出来想冲顶级的同学可以对照着查漏补缺。先说结论顶级确实比甲级高了一个维度考的不是会不会某个算法而是在有限时间里能不能把算法和题目约束对齐。如果你甲级能稳定拿满再刷透下面这些题型2024年秋季的考试完全可以搏一搏。1. 考场概况四道题的考点分布与难度阶梯1.1 考试基本信息与我的时间分配攀拓PAT顶级考试仍然是三个小时四道题在线评测支持C、Java和Python。从去年开始我基本固定用C17这次也一样主要原因是考场环境下C的调试效率和无脑STL确实更稳。我自己的时间分配是这样的第一题大约35分钟第二题约50分钟第三题用时最长花了近70分钟最后第四题只剩下不到25分钟结果只交了一个暴力版本。这个节奏其实不太健康——第三题我因为一个组合数取模的小问题卡了很久后面会详细说。如果重新来一次我会把第三题的边界检查控制在15分钟内给第四题留出至少40分钟。1.2 四道题考点一览题号核心考点难度评估主要失分点1字符串哈希、回文判断、分类计数中等哈希冲突、长度分类漏情况2最短路扩展、状态拆点中等偏难状态定义不完整3树形DP、方案数统计、组合取模难取模初始化、合并顺序4线段树、区间倒置、懒标记叠加难两个懒标记的下传顺序这个分布非常典型不考冷门算法但把热门算法的易错点集中放大。字符串题考哈希而不是KMP或AC自动机说明命题组更看重你对基础数据结构的掌控力线段树那题用的是区间整体倒置而不是区间取反这个细节区分度极高。2. 第一题回文串拼接计数——字符串哈希的正面战场2.1 题目模型还原题面大意是给定n个字符串统计有多少对下标(i, j)满足拼接起来的s[i] s[j]是回文串。数据范围给得很直接n不超过10^5所有字符串的总长度不超过10^6。这道题难的不是算法而是把拼接回文这个条件拆清楚。我一开始想用Manacher后来发现完全没必要字符串哈希就能做得干净利落。2.2 预处理正反哈希与O(1)回文判断我先对每个字符串分别求出正向哈希和反向哈希同时预处理幂数组。这样任意区间是否是回文串就能用正向区间哈希 反向逆序区间哈希在O(1)时间内判断。双哈希我建议保留单哈希在1e5量级下冲突概率虽然不高但PAT数据里故意卡概率这种事不是没发生过。我用的是P113331、MOD11e97P2131、MOD21e99两组参数using ll long long; const ll MOD1 1000000007LL; const ll MOD2 1000000009LL; const ll P1 13331LL; const ll P2 131LL; ll h1[N], h2[N], rh1[N], rh2[N], pw1[N], pw2[N]; // h1为正向哈希rh1为反向哈希pw为幂数组2.3 核心分类三种长度关系假设我们要判断s[i] s[j]是否回文记len_i和len_j分别是两个串的长度。这里必须分类讨论漏一种就错长度相等此时要求s[j]恰好等于s[i]的逆序。这个情况最简单直接用整串哈希判等。len_i len_j前半段是s[i]的前len_j个字符后半段是s[j]的逆序二者必须完全匹配同时s[i]剩下的中间部分必须自回文。注意这里s[i]剩下的部分是s[i]的第len_j到第len_i-1个字符顺序仍然是原序。len_i len_j对称处理。s[i]必须等于s[j]前len_i个字符的逆序且s[j]剩下的后部必须自回文。有了这个分类实现思路就清晰了枚举每个字符串作为较长的那一侧用哈希判断它与另一侧能否配对。具体做法是先把所有字符串的正向哈希和反向哈希扔进两个map用双哈希拼成的pair做key统计频次然后对于每个串枚举可能的切割点检查剩余部分是否回文并从计数表中取匹配串的数量。2.4 实测中的几个坑第一个坑是unordered_map被卡。我一开始用unordered_map存键值对本地跑样例没问题交上去TLE。后来改成map——性能反而稳定了。PAT的评测机对哈希表的碰撞攻击比较敏感字符串题里还是优先用map或者自己写一个基于vector排序的计数不要迷信unordered_map。第二个坑是长度相等的串被重复计数。如果s[i]s[j]回文那么s[j]s[i]不一定回文所以不能简单地把答案除以2。一定要严格按照较长侧的枚举方向来计数等长的两个串在一侧只统计一次。第三个坑是空串。总长度中可能包含空串空串与任何回文串拼接仍是该串自己。边界条件不要忘了处理。3. 第二题带类型的边权最短路——拆点之后是普通Dijkstra3.1 题目模型还原这道题给了一张有向图n个点、m条边每条边除了长度w之外还带着一个类型标记type0或1。路径的代价不再是简单的边权之和而是引入了一个额外的惩罚如果路径上相邻两条边的类型相同就会产生一个额外的代价c相邻类型不同的边没有惩罚。求从起点s到终点t的最小总代价。如果忽略类型这就是裸的最短路加上相邻边类型相同这一条普通Dijkstra的dist数组就不够用了——因为到达同一个点u最后一条边的类型不同未来扩展时的惩罚代价就完全不同。3.2 为什么必须把最后一条边的类型纳入状态我们设想两个方案都到达了节点u方案A最后一条边类型是0方案B最后一条边类型是1。如果只记录一个最小代价dist[u]那么当方案A的代价更小时B就被丢弃了。可是接下来若要从u走一条类型为0的边B因为上一条边是1没有惩罚反而可能比A更优。这说明当前最小代价不一定有未来最优性违背了Dijkstra的贪心前提。解决办法就是把状态拆开dist[u][t]表示到达u、且最后经过的一条边类型为t的最小总代价。这样状态数翻倍但每个状态都满足最优子结构可以直接跑Dijkstra。3.3 拆点与转移公式实现上不需要真的把每个点拆成两个节点只需要在转移时枚举新边的类型并计算额外代价即可struct Edge { int to, w, type; }; struct State { ll dist; int u, type; bool operator(const State other) const { return dist other.dist; // 小根堆 } }; ll dis[N][2]; bool vis[N][2]; priority_queueState pq; // 初始化起点没有上一条边两种状态都设为0 dis[s][0] dis[s][1] 0; pq.push({0, s, 0}); pq.push({0, s, 1}); while (!pq.empty()) { auto [d, u, t] pq.top(); pq.pop(); if (vis[u][t]) continue; vis[u][t] true; for (auto e : g[u]) { int extra (e.type t) ? penalty : 0; if (dis[e.to][e.type] d e.w extra) { dis[e.to][e.type] d e.w extra; pq.push({dis[e.to][e.type], e.to, e.type}); } } }答案是min(dis[t][0], dis[t][1])。3.4 复杂度与考场上的一个决策点复杂度是O((n m) log n)完全能过。考场上有两个选择一是真正拆点建图把每个原节点拆成入边类型为0和入边类型为1两个节点然后跑标准Dijkstra二是不建图直接在堆里记录状态。我推荐第二种因为少写很多建图代码也不容易写错。这道题最隐蔽的坑是起点初始化。起点没有上一条边如果只把dist[s][0]设为0那么从起点出发的第一条边如果是type1就会在转移时错误地产生一个同类型惩罚。正确处理就是上面代码里那样两个状态都初始化为0或者单独用一个状态表示起点的上一条边不存在。4. 第三题删除最少的边划分同色连通块——树形DP与计数4.1 题目模型还原给一棵n个节点的树每个节点颜色是黑色或白色。现在可以删除若干条边删完之后每个连通块内部必须同色全是黑色或全是白色。要求最小删除边数并且输出达到最小删除边数的方案总数对998244353取模的结果。这道题是典型的树形DP计数题难点在于状态定义要同时包含当前连通块目标颜色和子树内的最优性。别被方案数吓到它本质就是每个转移分支的乘法原理。4.2 DP状态设计令dp[u][c]表示处理完u的子树且u所在的连通块最终颜色固定为c时子树内部满足条件的最小删边数以及对应的方案数。这里c取0代表黑色1代表白色。转移要分两类情况讨论合并儿子如果儿子v所在连通块的颜色和u的当前连通块颜色相同那u和v之间这条边可以不删代价不变方案数乘上dp[v][c]对应的方案数。切断儿子不管儿子v那边最终是什么颜色只要它自己内部满足条件即可。此时u和v之间的这条边必须删除代价加1方案数乘上儿子子树两种颜色状态里代价较小的方案数之和。每个节点u的dp[u][0]和dp[u][1]互不影响分别做一次树上背包式的合并就行。4.3 转移与取模细节我用pairint, ll代表(最小删边数, 方案数)const int MOD 998244353; struct Node { int cost; ll ways; }; Node better(Node a, Node b) { if (a.cost ! b.cost) return (a.cost b.cost) ? a : b; return {a.cost, (a.ways b.ways) % MOD}; } // 合并 u 与儿子 v void merge(int u, int v, int c) { Node opt0 dp[v][0]; Node opt1 dp[v][1]; Node bestSon better(opt0, opt1); // 切断时的儿子最优状态 // 情况1不切边要求儿子块颜色也是 c Node keep dp[u][c]; keep.cost dp[v][c].cost; keep.ways keep.ways * dp[v][c].ways % MOD; // 情况2切边删边数1 Node cut dp[u][c]; cut.cost bestSon.cost 1; cut.ways cut.ways * bestSon.ways % MOD; dp[u][c] better(keep, cut); }答案就是min(dp[root][0], dp[root][1])方案数对应输出。4.4 我在考场上踩的取模坑这题我卡了将近40分钟问题出在一个非常基础的地方初始化。每个节点u在处理儿子之前如果颜色数组里u本身是黑色那么dp[u][0]应该初始化为{0, 1}dp[u][1]应该初始化为{INF, 0}白色反之。如果反过来初始化为{0, 1}那么方案数会被一路传染到完全不合法的状态里而且表面看有方案数。这种错误样例测不出来得随机对拍才能暴露。考场上没有对拍只能重新从定义出发推非常浪费时间。另一个要注意的是切断时儿子状态取better(opt0, opt1)。如果两种颜色的代价恰好相同方案数要相加不能用其中一个。这个细节在年度题里经常出现本质是组合计数里的加法原理。5. 第四题线段树维护区间倒置与赋值——两个懒标记的协作5.1 题目模型还原这道题是压轴题维护一个长度为n的01序列支持三种操作区间赋值把区间[l, r]内所有数字设为x。区间倒置把区间[l, r]内的数字整体顺序反转。注意不是取反是类似reverse的倒置。区间查询查询区间[l, r]内最长连续1的长度。这道题一看就知道要线段树难在区间倒置这个操作和区间赋值这个操作叠加时懒标记的协作必须非常小心。5.2 节点信息设计每个线段树节点维护以下信息len区间长度。pre0/suf0/max0前缀连续0长度、后缀连续0长度、区间内最长连续0长度。pre1/suf1/max1对应连续1的信息。rev倒置标记true表示该区间需要整体反转顺序。cover覆盖标记-1表示无覆盖0或1表示整个区间被赋值为该值。区间倒置操作的效果是区间顺序反转后原来在前缀的信息变成后缀。具体来说pre0和suf0要交换pre1和suf1要交换而max0和max1不变区间倒置不会改变0和1的分布密度只是位置镜像翻转所以最长连续段的长度不变。5.3 两个懒标记的协作顺序区间倒置和区间赋值是两种不同类型的操作叠加时要约定一个清晰的优先规则。我的做法是在下传标记时先下传cover再下传rev。为什么因为赋值操作语义更强一个区间被赋值为全0或全1之后顺序怎么反转都是一样的rev标记就失去了意义。所以applyCover时要顺手把rev清零而applyRev时如果节点已有cover也应该先保留cover再交换pre/suf信息。具体实现void applyCover(int p, int x) { tr[p].pre0 tr[p].suf0 tr[p].max0 (x 0 ? tr[p].len : 0); tr[p].pre1 tr[p].suf1 tr[p].max1 (x 1 ? tr[p].len : 0); tr[p].cover x; tr[p].rev false; // 赋值后倒置标记失效 } void applyRev(int p) { swap(tr[p].pre0, tr[p].suf0); swap(tr[p].pre1, tr[p].suf1); tr[p].rev ^ 1; } void pushdown(int p) { if (tr[p].cover ! -1) { applyCover(p 1, tr[p].cover); applyCover(p 1 | 1, tr[p].cover); tr[p].cover -1; } if (tr[p].rev) { applyRev(p 1); applyRev(p 1 | 1); tr[p].rev false; } }合并两个子区间时关键是用左儿子的suf和右儿子的pre拼出跨中点的连续段长度Node merge(const Node L, const Node R) { Node res; res.len L.len R.len; res.pre0 (L.pre0 L.len) ? L.len R.pre0 : L.pre0; res.suf0 (R.suf0 R.len) ? R.len L.suf0 : R.suf0; res.max0 max({L.max0, R.max0, L.suf0 R.pre0}); res.pre1 (L.pre1 L.len) ? L.len R.pre1 : L.pre1; res.suf1 (R.suf1 R.len) ? R.len L.suf1 : R.suf1; res.max1 max({L.max1, R.max1, L.suf1 R.pre1}); res.cover -1; res.rev false; return res; }5.4 对拍调试技巧这种题最容易出错的地方是区间倒置和区间查询在边界上的交互。我当时提交之前用了一个很笨但很有效的办法写一个O(n)的暴力类随机生成n在20以内的序列随机执行几百次操作逐行对比线段树的输出。对拍发现问题后多半是merge里pre和suf搞反了或者pushdown顺序反了。还有一个细节容易被忽略区间倒置操作定位到完全覆盖的节点时只需要交换pre/suf并翻转rev标记不需要把标记一路传到叶子。但如果这个节点同时带有cover标记要先保证cover标记状态正确。很多考生在这里把rev看成普通swap操作导致后续区间查询时信息错乱得分率很低。6. 考后复盘从这四道题看顶级备考应该练什么6.1 我的考场失误与时间管理建议回头看这次考试最大的失误是第三题取模初始化卡了太久。复盘的时候我发现这类问题其实完全可以通过写代码前先在草稿纸上列出所有状态初始化条件来避免。顶级考场里时间是最大敌人任何一个低级错误都可能吃掉一整道题的时间。我建议的分配方案是第一题不超过40分钟第二题不超过50分钟第三题和第四题各留约45分钟。如果某个题在20分钟内还没有成型思路果断先写暴力拿部分分然后去推下一题。顶级四道题每道都有不小分值一题爆肝到底的收益远低于稳拿三题基础分。6.2 每个考点背后的能力训练这四道题看着分散其实指向同一个能力把高级算法落地到具体题目约束时能快速嗅出哪里会出错。字符串那题训练的是分类讨论和哈希工程能力。图论那题训练的是状态设计的直觉遇到带额外条件的图论题先想能不能把条件编码进状态里。树形DP训练的是归纳与组合计数合并子树的顺序、取模的边界、最优解相同时方案数相加这些都是高端DP题反复出现的套路。线段树那道则是综合性最强的它同时考验信息设计、懒标记顺序、以及用暴力对拍验证正确性的工程习惯。建议平时刷题的时候不要只满足于AC每道题写完后想一想如果我换一组更强的测试数据我的代码哪里会崩用这个方法逼自己把边界条件想清楚考场上就不会被看起来对但实际上是巧合的代码坑到。6.3 一点个人体会考完走出考场的时候我就一个感受顶级和甲级之间隔的不是知识面而是对错误条件的嗅觉。甲级题通常一个算法盖过去就结束了顶级题却总在细节里设埋伏——长度分类漏一项、状态定义少一维、懒标记顺序反了、初始化少一个状态每个埋伏单独看都不致命但叠加在一起就是三个小时的灾难。准备顶级考试建议在刷题之外专门做一件事整理一张易错清单把每次WA的原因分类归档考前翻一遍比多刷十道题更有用。希望这篇题解能帮你少踩几个坑秋季考场见。
返回列表