AtCoder ABC 330全题解:从二分查找、前缀和到动态维护MEX的实战复盘 1. 项目概述一次完整的AtCoder Beginner Contest 330实战复盘最近刚打完AtCoder Beginner Contest 330感觉题目质量一如既往地在线既有适合新手的签到题也有能让人思考一阵子的中档题。对于刚接触AtCoder或者算法竞赛的朋友来说ABC系列AtCoder Beginner Contest是绝佳的练兵场它的题目通常逻辑清晰不涉及过于复杂的算法模板但又能很好地锻炼编程思维和代码实现能力。这次330场从A到F题覆盖了从基础循环、条件判断到贪心、前缀和、二分查找等经典思想非常适合用来检验自己的基础是否扎实。我打算结合自己的解题过程把每道题的思路、关键点以及一些容易踩的坑都梳理一遍希望能给正在备赛或者想提升算法能力的朋友一些参考。无论你是想解决“atcoder怎么解决英文”的界面困扰还是想为像“the 2023 icpc asia jinan regional contest”这类区域赛打基础从ABC开始稳扎稳打都是明智的选择。2. 赛题整体分析与解题策略2.1 竞赛环境与读题技巧对于AtCoder的比赛尤其是Beginner Contest第一个要克服的可能就是英文题目。很多新手看到满屏英文就发怵其实大可不必。ABC的题目描述通常用词简单直接语法结构也不复杂。我的经验是先快速浏览一遍题目抓住几个关键部分输入格式Input、输出格式Output和样例Sample。输入输出格式告诉你程序应该如何与评测机交互而样例则是验证你理解是否正确的黄金标准。遇到不认识的单词可以结合样例的输入输出数据去猜或者直接使用浏览器的划词翻译插件。重要的是理解题目要你“做什么”而不是纠结于每个单词的字面意思。例如题目中常出现的“integer”是整数“print”是打印“separated by spaces”就是用空格分隔。在策略上ABC的题目难度大致按A到F递增。A、B题往往是纯粹的模拟或简单计算目标是快速ACAccepted为后续题目争取时间。C、D题开始需要一些简单的算法思想如枚举、排序、贪心。E、F题则可能涉及更典型的数据结构或算法。比赛时建议按照顺序开题但如果卡在某一题超过20分钟还没有清晰思路不妨先跳过去看看后面的题目有时候后面的题目反而可能更对你胃口。始终保持冷静通过样例来调试和验证自己的逻辑是至关重要的。2.2 题目难度梯度与时间分配建议ABC 330的题目难度分布比较典型。A题“Counting Passes”可以看作是热身考察最基本的循环和条件判断。B题“Minimize Abs 1”需要一点点数学思维理解绝对值的含义。C题“Minimize Abs 2”在B题的基础上增加了维度需要更高效的枚举方法。D题“Counting Ls”是一道观察规律和计数题考验细心程度。E题“Mex and Update”引入了动态维护序列中最小未出现非负整数的概念需要合适的数据结构来支持。F题“Minimize Bounding Square”则是一个结合了二分答案和贪心思想的题目有一定综合性。对于一场2小时的比赛一个合理的时间分配可能是A题1-3分钟B题3-5分钟C题5-10分钟D题10-20分钟E题20-30分钟F题30-60分钟。这只是一个参考具体取决于个人对不同知识点的熟练度。核心原则是确保简单题不丢分中等题稳拿分难题尽力拼部分分。在ABC中前四题A-D的分数占比通常较高保证它们的正确率是获得高排名的关键。3. A~F题核心思路与代码实现详解3.1 A题Counting Passes——基础循环与条件判断题目简述给定一个整数序列和一个分数线L统计序列中有多少个数大于等于L。思路解析这可能是竞赛中最简单的题型之一。核心就是遍历整个数组对于每个元素判断它是否 L如果是则计数器加一。最后输出计数器的值即可。没有任何陷阱纯粹考察基本的输入输出和循环语法。代码实现与注意事项#include iostream using namespace std; int main() { int N, L; cin N L; int count 0; for (int i 0; i N; i) { int a; cin a; if (a L) { count; } } cout count endl; return 0; }注意题目中N的范围可能达到2e5但使用int类型存储计数完全足够。确保循环边界正确不要写成i N。3.2 B题Minimize Abs 1——绝对值与区间约束题目简述给定一个整数X以及区间[L, R]。需要找到一个整数Y使得Y在区间内并且 |X - Y| 最小。如果有多解输出最小的Y。思路解析绝对值的几何意义是距离。我们要找区间[L, R]中离X最近的点。分三种情况讨论如果X L那么区间内所有点都比X大最近的点就是左端点L。如果X R那么区间内所有点都比X小最近的点就是右端点R。如果L X R那么X本身就在区间内距离最近的点就是X自己。 题目要求如果距离相同输出较小的Y这在我们的分类讨论中已经自然满足当X处于区间中点时左右距离相等我们输出较小的即X本身但根据规则X在区间内时直接输出X即可这本身就是最小的那个。代码实现与注意事项#include iostream using namespace std; int main() { int N, L, R; cin N L R; // 题目描述是给一个数组但对每个元素独立处理所以可以边读边处理 for (int i 0; i N; i) { int X; cin X; int Y; if (X L) { Y L; } else if (X R) { Y R; } else { Y X; // X在区间内直接取X } cout Y (i N-1 ? \n : ); // 注意输出格式空格分隔 } return 0; }注意输出格式要求每个答案之间用空格分隔最后一个答案后面换行。这是一个常见的格式要求务必遵守否则会判为格式错误Presentation Error。3.3 C题Minimize Abs 2——二维枚举优化与数学性质题目简述给定一个整数D1 D 2e12找到非负整数x, y使得 |x^2 y^2 - D| 最小并输出这个最小值。思路解析暴力枚举x和y是不可行的因为D很大。我们需要利用数学性质进行优化。目标是使 x^2 y^2 尽可能接近D。我们可以固定x那么问题转化为寻找y使得 y^2 尽可能接近 (D - x^2)。由于y是非负整数我们可以通过计算target D - x*x然后寻找最接近target的完全平方数对应的y。 更高效的思路是枚举xx的范围是多少因为 x^2 D (可能的最优差值)实际上当x增大x^2超过D很多时差值会变大所以x枚举到 sqrt(D) 的附近即可可以适当放宽范围比如sqrt(D) 2。对于每个x计算rem D - x*x。然后寻找y使得y*y接近rem。y可以通过sqrt(rem)得到候选值y0和y01检查这两个y对应的值即可。同时因为x和y是对称的我们只需要枚举x就能覆盖所有情况。代码实现与注意事项#include iostream #include cmath #include algorithm #include climits using namespace std; using ll long long; int main() { ll D; cin D; ll ans LLONG_MAX; // 初始化为一个很大的数 // 枚举xx的上界设为 sqrt(D)2 足够 ll max_x sqrt(D) 2; for (ll x 0; x max_x; x) { ll rem D - x * x; if (rem 0) { // 如果x^2已经大于D计算差值并更新答案 ans min(ans, x * x - D); // 但此时继续增加x差值会更大可以考虑break不因为绝对值后面可能会变小但概率低为安全不break。 } else { // 寻找最接近rem的y^2 ll y sqrt(rem); // y是整数sqrt会向下取整 // 检查y和y1 ans min(ans, abs(x * x y * y - D)); ans min(ans, abs(x * x (y 1) * (y 1) - D)); } // 提前结束条件如果x^2远大于D且当前ans已经很小可以break // 但为了代码简单可以不优化枚举范围本身不大。 } cout ans endl; return 0; }注意1. 必须使用long long类型因为D和中间计算结果可能超过int范围。2.sqrt函数参数和返回值是浮点数转换为整数时会向下取整这正是我们需要的y的候选值。3. 要同时检查y和y1因为最接近的完全平方数可能是y^2或(y1)^2。3.4 D题Counting Ls——组合计数与前缀和优化题目简述有一个N x N的网格每个格子是o或x。我们需要统计满足以下条件的三元组(i1, j1), (i2, j2), (i3, j3)的数量三个格子位置互不相同。三个格子上的字符都是o。恰好有两个格子在同一行。恰好有两个格子在同一列。 换句话说这三个o构成一个“L”形可能旋转其中两个在同一行两个在同一列共享一个拐角点。思路解析直接枚举三个点复杂度是O(N^6)不可行。关键在于利用“L”形的结构。观察发现这个“L”形完全由它的拐角点即既在同行两个点中又在同列两个点中的那个点决定。假设拐角点坐标为(r, c)。那么我们需要在第r行上除了c列之外再找一个o记其列为c1在第c列上除了r行之外再找一个o记其行为r1。那么三个点就是(r, c), (r, c1), (r1, c)。并且要求(r1, c1)这个点不是o否则就会形成矩形有四个点在同一行/列不题目要求恰好两个在同一行两个在同一列。如果(r1,c1)是o那么(r, c1)和(r1, c1)在同一行不对重新梳理三个点中两个同行两个同列。设同行点为A(r,c1)和B(r,c)同列点为B(r,c)和C(r1,c)。所以B是拐角。A和C没有直接的行列关系。因此(r1, c1)是什么无关紧要只要A、B、C三点都是o即可。所以对于拐角点(r,c)如果它是o那么它对答案的贡献是(第r行上o的数量 - 1) * (第c列上o的数量 - 1)。减1是因为要排除拐角点自身。 因此我们可以预处理每一行和每一列的o的数量。然后遍历所有格子如果当前格子是o就计算贡献并累加。代码实现与注意事项#include iostream #include vector #include string using namespace std; using ll long long; int main() { int N; cin N; vectorstring S(N); for (int i 0; i N; i) cin S[i]; vectorint row_count(N, 0), col_count(N, 0); // 预处理行和列的o的数量 for (int i 0; i N; i) { for (int j 0; j N; j) { if (S[i][j] o) { row_count[i]; col_count[j]; } } } ll ans 0; for (int i 0; i N; i) { for (int j 0; j N; j) { if (S[i][j] o) { // 贡献为 (当前行o数-1) * (当前列o数-1) ans (ll)(row_count[i] - 1) * (col_count[j] - 1); } } } cout ans endl; return 0; }注意1. 答案可能很大需要用long long存储。2. 计算贡献时row_count[i] - 1可能为0这没关系乘法结果为0表示无法形成L形。3. 预处理行列计数是典型的空间换时间将复杂度从O(N^3)降到了O(N^2)。3.5 E题Mex and Update——动态维护与数据结构选择题目简述给定一个长度为N的序列A和Q个操作。每个操作将位置i的元素改为x。在每次操作后需要输出序列的mex值。mex定义为序列中未出现的最小非负整数。思路解析暴力方法每次修改后遍历序列统计出现过的数然后从0开始找第一个没出现的数。复杂度O(N*Q)不可行。 我们需要一种能快速维护当前数字出现情况并能快速查询mex的数据结构。一个关键观察是mex的值不会超过N。因为序列只有N个元素最坏情况下0到N-1都出现了那么mex就是N。所以我们只关心0到N这些数字的出现次数。 我们可以维护一个数组cntcnt[v]表示数字v出现的次数。但修改时我们需要将A[i]原来的值出现次数减1将新值x的出现次数加1。然后找mex即找到第一个cnt[v] 0的v。 如何快速找到第一个cnt[v]0的v我们可以维护一个集合如C的set里面存放所有当前cnt为0的数字在0到N范围内。那么mex就是这个集合中的最小值。每次更新时修改cnt[old_val]--如果减到0就把old_val插入集合。修改cnt[new_val]如果从0加到1就把new_val从集合中删除。输出集合的最小元素*set.begin()。 但要注意old_val和new_val可能大于N。对于大于N的值我们完全不关心因为它们不影响0到N的mex。所以我们只维护0到N的cnt和集合。当old_val N时才进行cnt减1和可能插入集合的操作当new_val N时才进行cnt加1和可能删除集合的操作。代码实现与注意事项#include iostream #include vector #include set using namespace std; int main() { int N, Q; cin N Q; vectorint A(N); vectorint cnt(N 2, 0); // 考虑到mex可能是N所以多开一点 for (int i 0; i N; i) { cin A[i]; if (A[i] N) { cnt[A[i]]; } } setint missing; // 存储0到N1中出现次数为0的数字 for (int i 0; i N 1; i) { if (cnt[i] 0) { missing.insert(i); } } while (Q--) { int i, x; cin i x; i--; // 转换为0-index int old_val A[i]; // 处理旧值 if (old_val N) { cnt[old_val]--; if (cnt[old_val] 0) { missing.insert(old_val); } } // 处理新值 A[i] x; if (x N) { if (cnt[x] 0) { missing.erase(x); } cnt[x]; } // 输出当前mex即missing集合中的最小值 cout *missing.begin() endl; } return 0; }注意1.cnt数组大小至少为N2因为mex可能等于N当0到N-1都出现时也可能等于N1当0到N都出现时虽然序列中不可能有N1这个值但我们的missing集合需要包含它作为备选。2. 使用set来维护缺失的数字可以以O(log N)的复杂度获取最小值、插入和删除。3. 一定要判断old_val和new_val是否在[0, N]范围内只维护这个范围内的计数避免集合过大和无效操作。3.6 F题Minimize Bounding Square——二分答案与贪心验证题目简述二维平面上有N个点。你可以进行最多K次操作每次操作可以将一个点的x坐标或y坐标加1或减1。你希望用一个边与坐标轴平行的正方形来覆盖所有点允许点在边上。问在操作次数限制下这个正方形的最小可能边长是多少。思路解析求最小可能边长且操作次数有限制这是一个典型的二分答案问题。我们可以二分正方形的边长len然后判断是否存在一种操作方案在不超过K次操作内使得所有点能被一个边长为len的正方形覆盖。 如何判断一个给定的len是否可行假设正方形的左边界为X下边界为Y那么其右边界为Xlen上边界为Ylen。一个点(x, y)要被覆盖需要满足X x Xlen且Y y Ylen。如果点不在这个区域内我们需要移动它。移动它到区域内的最小操作次数就是将其x坐标调整到区间[X, Xlen]的最小代价加上将其y坐标调整到区间[Y, Ylen]的最小代价。代价计算方式为如果x X代价为X-x如果x Xlen代价为x-(Xlen)否则代价为0。y坐标同理。 我们的目标是选择最优的X和Y使得所有点的移动总代价之和最小并且这个最小值 K。由于x和y坐标独立我们可以分别考虑x轴和y轴。问题转化为给定数轴上N个点x_i和一个区间长度len找一个区间[X, Xlen]使得所有点到该区间的最小移动距离之和最小。这是一个经典问题。最优的X一定可以取为某个点的坐标或者坐标加减len/2实际上对于绝对距离和最优区间左端点一定可以取为某个点坐标。更简单的做法是将所有x坐标排序。如果我们固定区间左端点X那么区间包含的点就是那些x坐标在[X, Xlen]内的点。区间外的点需要移动进来。总代价可以表示为所有小于X的点移动到X的代价之和 所有大于Xlen的点移动到Xlen的代价之和。我们可以用前缀和快速计算。 具体验证函数check(len)的设计将x坐标和y坐标分别排序并计算前缀和。使用双指针/滑动窗口。对于x坐标枚举窗口左端点i窗口右端点j满足x[j] x[i] len。那么窗口内的点不需要移动。窗口左边的点下标 i都需要移动到x[i]总代价为i * x[i] - sum_left。窗口右边的点下标 j都需要移动到x[i]len总代价为total_sum - sum_mid - (N - 1 - j) * (x[i]len)其中sum_mid是窗口内点的和。计算x轴总代价cost_x的最小值。对y坐标做同样处理得到cost_y的最小值。判断min_cost_x min_cost_y K。如果成立则len可行。代码实现与注意事项#include iostream #include vector #include algorithm #include climits using namespace std; using ll long long; ll calculate_min_cost(vectorll coord, ll len) { int n coord.size(); vectorll prefix(n 1, 0); for (int i 0; i n; i) prefix[i 1] prefix[i] coord[i]; ll min_cost LLONG_MAX; int j 0; for (int i 0; i n; i) { // 移动窗口右端点j使得 coord[j] coord[i] len while (j n coord[j] coord[i] len) { j; } // 现在窗口内是 [i, j-1] // 左边点移动到 coord[i] 的代价 ll left_cost (ll)i * coord[i] - prefix[i]; // 右边点移动到 coord[i]len 的代价 ll right_sum prefix[n] - prefix[j]; ll right_count n - j; ll right_cost right_sum - right_count * (coord[i] len); ll total_cost left_cost right_cost; min_cost min(min_cost, total_cost); } return min_cost; } int main() { int N; ll K; cin N K; vectorll X(N), Y(N); for (int i 0; i N; i) { cin X[i] Y[i]; } sort(X.begin(), X.end()); sort(Y.begin(), Y.end()); ll left 0, right 2e9; // 边长上界坐标绝对值1e9操作后可能更大取2e9足够 ll ans right; while (left right) { ll mid (left right) / 2; ll cost_x calculate_min_cost(X, mid); ll cost_y calculate_min_cost(Y, mid); if (cost_x cost_y K) { ans mid; right mid - 1; } else { left mid 1; } } cout ans endl; return 0; }注意1. 坐标和操作次数K的范围很大必须使用long long。2. 二分边界边长最小为0最大可以设为坐标范围的两倍因为极端情况可能把所有点移到一起。3.calculate_min_cost函数中滑动窗口的维护要小心边界。我们枚举左端点i右端点j指向第一个大于coord[i]len的点所以窗口内点下标是[i, j-1]。4. 计算代价的公式推导要准确利用前缀和可以O(1)计算区间和从而将单次验证的复杂度降到O(N log N)排序或O(N)滑动窗口。4. 常见错误与调试技巧实录4.1 数据类型溢出与范围判断这是竞赛中最常见的错误之一尤其是在使用C时。ABC的题目虽然面向初学者但数据范围经常设计得恰好卡住int类型。例如C题和F题涉及的平方运算或坐标差值很容易超过32位有符号整数的范围约21亿。错误示例int D; cin D; long long ans D * D;(在计算D*D时两个int相乘结果还是int已经溢出再赋值给long long为时已晚)。正确做法在输入和定义变量时就根据数据范围选择合适类型。如果题目中数值可能达到1e9或进行乘法运算果断使用long long。一个简单的习惯是涉及索引或小范围计数用int涉及题目输入的主数据、中间计算结果用long long。可以使用using ll long long;来简化代码。调试技巧在本地测试时可以故意构造边界数据如最大值进行测试。如果输出结果变成负数或异常大首先怀疑整数溢出。4.2 边界条件与特殊案例处理很多题目看似简单但隐藏着边界条件忽略它们会导致WAWrong Answer。循环边界在D题中计算贡献(row_count[i] - 1) * (col_count[j] - 1)如果某行只有一个o那么row_count[i]-1为0乘法结果为0这是符合逻辑的无法形成L形。但如果没减1就会错误计数。在E题中cnt数组和missing集合的大小需要开到N2以容纳mex可能为N或N1的情况。空集合/容器访问在E题中我们确信missing集合始终包含0到N1中至少一个数因为数字只有N个而我们有N2个位置所以*missing.begin()是安全的。但在其他场景下如果可能对空容器调用front()或begin()必须先判断。二分查找的终止条件F题的二分查找while (left right)和left mid 1; right mid - 1;是标准写法之一要确保循环能正确终止并更新答案。验证函数check(mid)的设计必须考虑所有点并且代价计算正确。4.3 时间复杂度估算与优化意识即使算法思路正确如果实现不够高效也会导致TLETime Limit Exceeded。ABC的时限通常比较宽松但养成估算复杂度的习惯至关重要。暴力枚举的陷阱C题如果双重循环枚举x和y复杂度是O(D)对于D最大2e12完全不可行。通过枚举x再计算y复杂度降为O(sqrt(D))。D题通过预处理行列计数将O(N^3)的枚举优化为O(N^2)。数据结构的选择E题如果每次修改后线性扫描找mex复杂度O(NQ)会超时。使用set维护缺失数集合将每次查询降为O(log N)。前缀和与双指针F题的验证函数如果对每个候选区间都重新计算代价复杂度会很高。使用排序、前缀和和滑动窗口可以将单次验证优化到O(N)。调试建议在提交前思考一下算法的最坏复杂度。例如N2e5O(N^2)的算法肯定超时需要O(N log N)或更好的算法。可以先用小数据测试逻辑再用接近上限的数据测试运行时间。4.4 格式错误与初始化问题输出格式B题要求答案之间用空格分隔最后一个后面换行。这是一个常见的格式要求。使用cout ans[i] (i n-1 ? \n : );可以优雅地处理。变量初始化局部变量不会自动初始化为0。例如A题中的计数器countC题中的ans应初始化为一个很大的数如LLONG_MAX必须手动初始化。多组数据输入虽然ABC通常单组数据但有些比赛有多组。要确保每轮循环前相关变量和容器被正确清空或重置。5. 从ABC到更高级别竞赛的进阶路径打完一场ABC特别是能稳定解决前5题甚至6题后意味着你已经具备了不错的算法基础。如何更进一步向像“ICPC Asia Regional Contest”这样的赛事迈进呢我的体会是需要系统性地构建知识体系和提升解题能力。第一步巩固与分类刷题。将ABC、ARCAtCoder Regular Contest以及CFCodeforces的Div.2题目按算法知识点分类刷题。重点掌握基础数据结构栈、队列、链表、并查集、堆优先队列。中级算法二分查找及其变种、前缀和与差分、双指针、贪心、排序。图论基础DFS/BFS、最短路Dijkstra, Floyd、最小生成树Kruskal。动态规划线性DP、背包DP、区间DP、树形DP。数学数论GCD、质数筛、组合数学。第二步参与虚拟竞赛与复盘。定期参加AtCoder或Codeforces的线上比赛模拟真实环境。赛后无论成绩如何一定要复盘重做错题不看题解重新思考直到独立AC。阅读优秀题解在AtCoder的Contest页面的“Editorial”栏有官方题解在“Submissions”页面可以查看排名靠前选手的代码学习更简洁、高效的写法。总结模板将常见的算法模型如二分答案、滑动窗口、拓扑排序整理成自己熟悉的代码模板。第三步组队训练与交流。ICPC是团队赛找两个靠谱的队友至关重要。定期进行团队训练磨合分工通常有人擅长思维/构造有人擅长数据结构/图论有人擅长数学/计算几何。训练中练习沟通技巧如何快速让队友理解自己的思路如何共同调试一道题。最后保持耐心和热情。算法竞赛的提升是非线性的可能会遇到长时间的瓶颈期。这时回归基础刷一些经典的专题或者暂时放松一下往往比硬扛更有效。每一次AC带来的成就感以及思维能力在潜移默化中的提升才是支撑你走得更远的根本动力。