看过不少刷题仓库,仓库名叫t3code的其实挺多,但多数都是把自己的训练赛代码往上一丢就完事。我自己的t3code不太一样——它是我攒了三年多的一套T3难度题目的题解集合,从Topcoder风格的T3划分到国内赛事常见的第三题,凡是那种“会T1是本能、T2靠手速、T3决定上不上分”的题目,我都往里塞。这篇文章就聊聊我在这套题解仓库里沉淀下来的东西:T3到底难在哪、我平时用什么样的套路去拆、代码模板长什么样、以及我踩过哪些坑。
不管你是刚把T1刷明白的新手,还是卡在某个分段上不去的进阶选手,这篇文章应该都能给你一点能直接用的东西。我尽量不说空话,每个结论都是从实际调试和比赛复盘里抠出来的。
1. 内容整体设计与思路拆解
1.1 T3为什么值得单独建一个仓库
先说结论:T3是区分选手档次的分水岭。
在一场三道题的赛制里,T1通常考单一知识点,比如一个排序、一个简单模拟、一个最短路模板,基本是“见过就会”。T2开始叠加一些变化,但核心模型仍然明显。T3就不一样了,它往往是整场比赛里最考验状态设计、边界处理和多知识点串联的题,过了T3,排名直接往前拉一大截。
我复盘过自己参加过的几十场训练赛,发现一个规律:T1做对是及格线,T2做对是保底分,T3做对才是真正的净胜分。很多人T1、T2都能AC,但一到T3就看不懂题或者写不完,最后排名被拉得特别开。所以我把T3单独拿出来做t3code这个仓库,不是为了装样子,是因为T3的题目特征太鲜明,复习价值也最大。
T3题目的典型特征有三个:
- 知识点不是单一出现,而是两到三个模型嵌套,比如“二分答案 + 贪心check + 数据结构维护”。
- 题目描述里通常藏着一句决定复杂度瓶颈的话,读不懂就看不出真正的考点。
- 实现细节极多,边界情况、初始化顺序、数据范围溢出,哪一个没处理好都可能导致WA到怀疑人生。
1.2 我选T3题的标准
不是所有第三题都值得进仓库。我的筛选标准很简单:这道题我如果当场没做出来,事后看了题解拍大腿,那它就值得收。那种看题解都莫名其妙、解题思路过于偏门的题,我一般不收,因为复习成本高、收益低。
我会优先收纳这几类T3:
- 二分答案类,尤其是check函数里藏贪心或单调性证明的题。
- DP类,包括区间DP、树上DP、状压DP,只要状态设计有巧思就收。
- 图论转最短路径或最小生成树的题,重点看建模过程。
- 数学推导加数据结构的组合题,比如前缀和优化、单调栈优化。
另外,我每道题都会写一个“一句话考点”的注释。比如“这题考的是把绝对值拆开用四个数据结构维护”,这一行字在复习时比整段题解都管用。后面真正刷多了会发现,T3的考点翻来覆去就那么十几个模型,只是穿衣服的方式不同。
2. 核心细节解析与实操要点
2.1 五分钟判断T3能不能做
训练赛时间宝贵,读到T3时不能闷头硬想。我总结了一套快速判断策略,基本能稳定在五分钟内决定投入还是跳过:
第一步,看数据范围。n是10^5还是10^3直接决定复杂度方向。n到10^5,基本告别二维DP和O(n^2)算法;n只有20,基本直奔状态压缩。数据范围是出题人给的提示,不是摆设。
第二步,看样例解释。T3的样例通常自带推导过程。我见过很多题目,样例解释里明确写了“第一步选择xx,第二步选择yy”,这其实就在暗示贪心策略或DP顺序。样例不只是用来验答案的,它是题面的延伸。
第三步,看有没有“显然”的暴力做法。如果连暴力都不知道怎么枚举,说明这题的状态空间我没理解对,需要重新读题。反过来,如果能很快写出暴力,那T3大概率是“暴力 + 优化某个维度”的结构,考点就藏在优化上。
这套判断流程我写成了标准操作,不管比赛多紧张都先过这三步。实测下来命中率很高,四场里至少有三场能准确判断,不会出现死磕两小时最后零分的情况。
2.2 拆题目的三层递进
拿到一道T3,我习惯按三层来拆:暴力层、优化层、特殊性质层。暴力层先不管复杂度,把最朴素的枚举逻辑写通,确认自己对题意的理解没有偏差。优化层找瓶颈,看是枚举的东西太多,还是重复计算太多。特殊性质层则是找题面里那些“恰好可以优化”的条件,比如答案单调、转移具有最优子结构、满足结合律,这些性质往往就是T3的钥匙。
举一个我实际练过的例子:题目大意是给一个长度为n的数组,要求选出一个子序列,使相邻两个数的差的绝对值之和对某个模数取最大。暴力做法是枚举所有子序列,复杂度O(2^n)。优化层看一眼,发现后一个数的选取只跟前一个数有关,这是典型的DP结构,O(n^2)能过n≤2000。但数据范围是10^5,还得再优化。特殊性质层一找,发现转移方程只需要维护当前模数下的最大值,用两个辅助数组就能把转移降到O(n),于是整道题从指数级一路优化到线性。
这三层拆解方法我几乎套用在每一道T3上。强烈建议你也试试,比自己盯着题面发呆有效得多。
3. 实操过程与核心环节实现
3.1 t3code仓库的目录与代码组织
我仓库的结构很简单,但没有偷懒:
t3code/ ├─ binary_search/ │ ├─ b01_cow_across.cpp │ └─ b02_max_min_distance.cpp ├─ dp/ │ ├─ d01_stone_merge_interval.cpp │ ├─ d02_tree_independent_set.cpp │ └─ d03_bitmask_tsp.cpp ├─ graph/ └─ misc/每个子目录按考点命名,文件名带编号和简短描述。每份代码开头有一个统一的注释块,记录题目来源、一句话考点、核心复杂度、做这道题时的状态(当场AC还是事后补题)。看起来简单,但复习的时候极其有用,你能直接筛选出自己当时没想明白的类型,重点突破。
代码风格我统一成一套T3答题模板,核心思路是“先想清楚、再写出来、最后清理”。任何一道T3的代码都按这个顺序组织:全局变量与常量定义、读入与建图或初始化、核心算法函数、主函数调用。用一个统一的框架能让调试的时候快速定位问题,比赛里也少一个变量名重复定义的愚蠢错误。
3.2 模板示例:二分答案 + 贪心check
二分答案是我在T3里遇到最多的一类,这里给一个可直接复用的模板。我用一个具体场景来讲:有n个摊位,每个摊位的商品数已知,要求把所有商品分给恰好k个人,每个人分到的商品集合必须是连续的摊位区间中的一些商品,求“分到最多商品的那个人”的最小值。这是非常经典的“最大值最小化”问题。
#include <bits/stdc++.h> using namespace std; int n, k; vector<int> a; bool check(long long limit) { long long cur = 0; int cnt = 1; for (int i = 0; i < n; i++) { if (a[i] > limit) return false; // 单个元素超过上限,直接不可行 if (cur + a[i] <= limit) { cur += a[i]; } else { cnt++; cur = a[i]; if (cnt > k) return false; } } return cnt <= k; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> k; a.resize(n); long long sum = 0; for (int i = 0; i < n; i++) { cin >> a[i]; sum += a[i]; } long long lo = 0, hi = sum; while (lo + 1 < hi) { long long mid = (lo + hi) / 2; if (check(mid)) { hi = mid; } else { lo = mid; } } cout << hi << "\n"; return 0; }这个模板有两个关键点。第一,check函数里的贪心策略是“能塞就塞”,也就是从左到右扫描,当前区间还能放就继续放,放不下才新开一组。这个贪心在绝大多数分段连续分配的题目里都是最优的,但你要在草稿纸上证明或至少感受到“为什么不会亏”,不然改个条件就翻车。第二,左边界lo初始设为0不够严谨,虽然这里check里对单个元素做了判断,但更稳妥的写法是把lo初始化成数组最大值,避免每次check都多做一次判断。
我用这个模板十分钟之内就能改出对应题目的解。T3里的二分题,难点通常不在二分本身,而在check函数里要用的那个贪心或数据结构构造。把模板里的贪心部分换掉,其他结构都差不多。
3.3 模板示例:区间DP的状态设计
另一类高频T3是区间DP。以经典的“石子合并”改版为例:有n堆石子排成一圈,每次可以合并相邻两堆,代价是两堆石子数之和,问最终合成一堆的最小总代价。
常规直线版本的状态转移是dp[i][j] = min(dp[i][k] + dp[k+1][j]) + sum[i][j]。环形版本的处理技巧是把数组复制一倍,令dp[i][i+n-1]覆盖原数组所有环上的区间,最后在所有长度为n的区间里取最小值。这个“复制一倍”的思路在T3环形DP里特别常见。
#include <bits/stdc++.h> using namespace std; const int INF = 1e9; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<int> a(2 * n + 1), pre(2 * n + 1, 0); for (int i = 1; i <= n; i++) { cin >> a[i]; a[i + n] = a[i]; } for (int i = 1; i <= 2 * n; i++) { pre[i] = pre[i - 1] + a[i]; } vector<vector<int>> dp(2 * n + 1, vector<int>(2 * n + 1, INF)); for (int i = 1; i <= 2 * n; i++) dp[i][i] = 0; for (int len = 2; len <= n; len++) { for (int l = 1; l + len - 1 <= 2 * n; l++) { int r = l + len - 1; for (int k = l; k < r; k++) { dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r] + pre[r] - pre[l - 1]); } } } int ans = INF; for (int i = 1; i <= n; i++) { ans = min(ans, dp[i][i + n - 1]); } cout << ans << "\n"; return 0; }区间DP在T3里的常见变式是“转移时需要额外信息”,比如合并的代价不是简单求和,取决于当前区间的某种极值。遇到这种情况不要慌,在状态数组里多加一维记录极值信息就行。真正的坑是区间长度枚举顺序,必须从小区间往大区间推,否则转移依赖的状态还没算出来。
3.4 一个真实训练赛题目的完整复盘
下面是我最近收进t3code的一道题,题目描述很短,但足够代表一类T3。大意如下:
给定一棵n个节点的树,每个节点有一个权值w[i]。你要选出一个包含根节点1的连通块,使连通块内所有节点权值之和尽量大,但连通块内任意两个相邻节点权值之差不能超过K。n≤10^5。
读完题先按我的三层拆法走一遍。暴力是所有子集枚举或者DFS枚举连通块,复杂度不可行。优化层看这是个“在树上选满足约束的最大和连通块”,可以尝试树形DP。特殊性质是“权值差不超过K”这个约束,它其实只影响选择子节点时是否允许加入。
于是状态设计为dp[u]表示以u为根、选入连通块时,从子树中能获得的最大附加权值和(u本身权值单独计算)。转移时,对每个子节点v,如果abs(w[u] - w[v]) <= K,那么dp[u] += max(0, dp[v]),否则不选v。这里max(0, dp[v])是关键,因为子树贡献可能是负的,强制选入反而亏。
多个子节点之间相互独立,所以直接累加即可。一次DFS遍历就能完成,复杂度O(n)。但有个细节容易忽略:如果dp[v]为负数,你跳过它是正确的,但你不能在累加过程中修改w[v]或对树做某种排序,这题不要求贪心顺序,直接遍历所有子节点就行。我场上第一版代码多写了一个sort,浪费了不少时间,实际上完全没必要。
这道题的代码核心就是DFS加一个差值判断,不贴全了。收纳它进仓库的原因正是“约束条件直接翻译成转移条件”的套路,在T3里出现频率极高。看到“任意两个……不超过K”“存在一个……满足……”这类描述,第一反应就是把约束写进转移判断条件里。
4. 常见问题与排查技巧实录
4.1 我在T3上最常踩的五个坑
刷了这么多T3,很多错误是反复出现的。我把它们整理成一张速查表,每次提交前挨个检查一遍:
| 问题类型 | 典型表现 | 排查思路 |
|---|---|---|
| 边界错误 | 数组下标从0开始但循环写成1..n;二分左右边界弄反 | 先拿n=1和n=2的样例手算一遍,边界循环打印下标 |
| 初始化遗漏 | DP数组忘记置INF;多组数据忘记重置全局变量 | 所有二维DP务必初始化,多组数据在main开头就统一reset |
| 数据类型溢出 | 计算总和、中间结果用int存,n扩大后直接爆 | 涉及累加、乘法的量全部开long long,不心存侥幸 |
| 状态转移顺序错 | 区间DP长度循环写成从n开始往下减 | 牢记“先长度,再左端点,再断点”的顺序 |
| 题意理解偏差 | 把“连续子数组”理解成“子序列”;忽略“恰好”和“至少” | 读题时圈出数量词,样例第二组往往就是来治这个的 |
其中题意理解偏差是最浪费时间的,因为它不是改几行代码能解决的,需要重做。我的对策是动键盘前,用自己的话把题目要求写一行注释放到代码开头,写完注释再动手。这行注释如果写错了,代码写得再漂亮也是白搭。
4.2 超时问题的三个排查方向
T3时间超限也很常见,但原因基本集中在三处。
第一个方向是循环层数写多了。比如复杂度本该是O(n log n)的题,因为多套了一层循环变成O(n^2)。这种问题用数据范围就能直接判断,n=10^5的题你的代码跑O(n^2)必然超时,别抱侥幸心理。
第二个方向是STL使用过重。在循环里反复调用lower_bound、set的insert/erase,或者频繁构造vector,常数会大得让你怀疑人生。优化办法是能用数组用数组,能离线排序就离线排序。我有一道T3,就是换掉一个map改成排序加双指针,从TLE变成压线过。
第三个方向是二分次数太多。如果你在二分check里又用了另一个二分,复杂度直接多了log。看题目数据范围是否允许,如果可以就尝试用尺取法或预处理前缀极值替代内层二分。三道T3里至少有一道会卡这个,务必留意。
4.3 从WA到AC的调试习惯
我调试T3代码有一套固定流程,这里分享给你们:
第一步,先跑题目给的样例。如果样例都过不了,直接用小数据手工模拟,看是逻辑问题还是实现偏差。第二步,自己构造边界数据,包括n取最小值、数组全相同、答案取极端值等。很多WA就是边界数据才暴露的。第三步,构造随机数据和暴力代码对拍。我会写一个简单的暴力版本,然后用脚本生成小规模随机数据,对比两个程序的输出。这个对拍流程是发现隐藏bug最有效的手段,没有之一。
对拍脚本也放在t3code仓库里,每次比赛前都提前准备好模板。代码如下:
import random import subprocess for _ in range(1000): n = random.randint(1, 8) k = random.randint(1, 5) arr = [random.randint(1, 10) for _ in range(n)] # 按题目格式写测试输入 with open("test.in", "w") as f: f.write(f"{n} {k}\n") f.write(" ".join(map(str, arr)) + "\n") # 分别运行暴力程序和优化程序 subprocess.run(["./brute"], stdin=open("test.in"), stdout=open("brute.out", "w")) subprocess.run(["./fast"], stdin=open("test.in"), stdout=open("fast.out", "w")) b = open("brute.out").read().strip() f = open("fast.out").read().strip() if b != f: print("WA found!") print(open("test.in").read()) print("brute:", b) print("fast:", f) break这个脚本帮我抓到的bug,比我自己检查代码抓到的多得多。特别是那种“小数据正确、大数据出问题”的T3,对拍几乎是唯一可靠的排查手段。
5. 最后再分享两个提高刷T3效率的习惯
第一个习惯是“题解延迟看”。我规定自己一道T3必须独立思考至少40分钟,想不出来才允许看题解。这样做不是为了折磨自己,而是逼着大脑把错误的思路全部尝试一遍,之后看题解时,每一行都会有“原来如此”的顿悟感,记忆远超直接看题解。我仓库里记得最牢的题,全是当时卡了最久的题。
第二个习惯是“错题三刷”。从t3code里随机抽一道当时没AC的题,不看代码重新做一遍,做对后就划掉;做不对就保留。三刷能过,说明这个考点真的内化了。我用这个方法把动态规划类的T3从最拉跨的类型变成了拿分项,属实有效。
这套t3code的整理与复刷流程,是我刷题效率提升幅度最大的一次改版。如果你也有一个落灰的刷题仓库,不妨从今天开始,只挑T3难度的题进去,把注释写清楚,每道题按三层拆解法记录思路。坚持一两个月,回头再看分数变化,你会回来感谢自己的。