从AtCoder ABC 330实战解析算法竞赛入门:二分查找与MEX维护
1. 项目概述从AtCoder Beginner Contest 330看算法竞赛入门实战最近刚打完AtCoder Beginner Contest 330感觉这场的题目设置对新手特别友好既有考察基础编程能力的签到题也有需要一点算法思维的中档题非常适合用来检验自己的学习成果或者作为日常的训练赛。很多刚接触AtCoder的朋友可能会被全英文的题目描述和独特的评测系统比如严格的输入输出格式、特殊的浮点数精度要求劝退但一旦上手你会发现它是提升编程和算法能力的绝佳平台。这场ABC 330的A到F题就完美覆盖了从“看懂题目就能写”到“需要仔细设计算法”的各个阶段。无论你是想巩固循环和条件判断还是想挑战一下贪心、前缀和、二分查找乃至简单的图论思想都能在这套题里找到对应的练习。接下来我就结合自己的解题过程把这六道题的核心思路、代码实现细节以及一些容易踩的坑从头到尾拆解一遍希望能给正在入门算法竞赛的你一些实实在在的参考。2. 题目A~F逐题精讲与避坑指南2.1 A题Counting Passes —— 巩固基础循环与判断A题通常是“签到题”目的是让选手快速进入状态并确保基本的输入输出操作无误。330的A题《Counting Passes》正是如此。题目大意是给定一个整数列表代表N个学生的分数和一个及格分数线L你需要统计有多少个分数大于等于L。核心思路与实现这道题几乎没有算法可言纯粹考察对循环和条件判断的掌握。解题步骤清晰读取两个整数N和L。循环N次每次读取一个分数。在循环内部判断当前分数是否 L如果是则计数器加一。循环结束后输出计数器的值。代码示例PythonN, L map(int, input().split()) scores list(map(int, input().split())) count 0 for score in scores: if score L: count 1 print(count)注意事项与避坑点输入格式AtCoder的题目输入通常是空格分隔的整数。使用input().split()获取字符串列表再用map(int, ...)转换为整数是标准操作。务必注意题目中N和L是在同一行而分数列表在下一行。变量命名虽然简单但良好的命名如count,score能让代码更易读尤其是在紧张的比赛环境中。边界情况考虑N0的情况虽然题目通常保证N1。我们的循环逻辑也能正确处理因为list(map(int, input().split()))在输入为空时会得到空列表for循环不会执行count保持0。提示对于这类题目目标是又快又准地拿下。在比赛开始前可以提前准备好常用语言的快速输入输出模板节省时间。2.2 B题Minimize Abs 1 —— 理解题意与绝对值处理B题开始引入简单的数学思维。题目《Minimize Abs 1》要求给定一个整数L, R以及一个包含N个整数的数组A。对于A中的每一个元素A_i你需要找到一个整数X满足 L ≤ X ≤ R并且使得 |A_i - X| 的值最小。输出每个A_i对应的|A_i - X|的最小值。核心思路解析关键在于理解绝对值函数 |A_i - X| 的几何意义它表示数轴上点A_i和点X之间的距离。我们的X被限制在区间[L, R]内。因此问题转化为对于给定的点A_i在闭区间[L, R]上找一点X使得A_i到X的距离最短。如果A_i本身就在区间[L, R]内那么最近的点就是它自己最小距离为0。此时取X A_i即可。如果A_i小于L那么区间[L, R]上离它最近的点就是左端点L最小距离为 L - A_i。如果A_i大于R那么区间[L, R]上离它最近的点就是右端点R最小距离为 A_i - R。代码实现N, L, R map(int, input().split()) A list(map(int, input().split())) ans [] for a in A: if a L: ans.append(L - a) # a在区间左侧最近点是L elif a R: ans.append(a - R) # a在区间右侧最近点是R else: ans.append(0) # a在区间内距离为0 print(*ans) # 使用*解包列表以空格分隔输出避坑技巧逻辑清晰使用if-elif-else结构清晰地处理三种情况比用min和max组合的写法更直观不易出错。输出格式题目要求输出N个整数用空格分隔。Python中print(*list)是非常简洁的输出方式。思维误区不要试图去“计算”X的具体值。题目只要求输出最小绝对值差我们根据上述分类直接计算这个差值即可无需存储X。2.3 C题Minimize Abs 2 —— 从枚举到二分查找的优化C题《Minimize Abs 2》是B题的进阶版难度明显提升。题目描述给定一个正整数D找到一对非负整数(x, y)使得 |x^2 y^2 - D| 的值最小。输出这个最小值。暴力枚举的局限性最直接的想法是枚举x和y。因为D最大可达2×10^12如果x和y都从0枚举到sqrt(D)约1.4e6那么双重循环的复杂度是O(D)无法在规定时间内通常2秒完成。优化思路固定其中一个变量比如x。那么表达式变为 |x^2 y^2 - D|其中y^2的目标值是D - x^2。问题转化为对于每个固定的x我们需要找到一个非负整数y使得 y^2 尽可能接近target D - x^2。如果target 0意味着即使y0x^2也已经超过D此时最佳y就是0。如果target 0我们需要找到使 |y^2 - target| 最小的y。由于y^2是单调递增的我们可以通过二分查找快速找到最接近target的平方数。具体步骤初始化答案ans为一个很大的数如10**18。枚举x从0开始直到x*x D ans时停止。这是一个重要的剪枝如果当前x的平方已经比D加上当前最优答案还要大那么即使y0差值也会超过当前最优解后续的x没有必要再枚举。对于每个x计算target D - x*x。如果target 0则候选y为0更新ans min(ans, abs(x*x - D))。如果target 0使用二分查找在[0, sqrt(D)]的整数范围内实际上查找y的平方找到使y*y最接近target的y。通常需要检查找到的y以及y1因为y是整数平方根可能不是整数。用找到的最佳y更新全局答案ans。代码实现Pythonimport math D int(input()) ans 10**18 x 0 while True: x2 x * x if x2 - D ans: # 剪枝如果x^2 - D已经大于当前最优解后续x只会更大 break target D - x2 if target 0: y 0 else: y int(math.isqrt(target)) # Python 3.8 可以使用math.isqrt求整数平方根 # 检查y和y1哪个更优 ans min(ans, abs(x2 y*y - D)) if (y1)*(y1) target or abs(x2 (y1)*(y1) - D) abs(x2 y*y - D): # 实际上对于整数平方根y是最接近的但检查y1是稳妥的做法 ans min(ans, abs(x2 (y1)*(y1) - D)) ans min(ans, abs(x2 y*y - D)) x 1 print(ans)实操心得剪枝是关键不加剪枝的枚举一定会超时。x*x D ans这个条件需要理解其含义当前最优答案是ans如果新的x的平方与D的差值即使取y0已经大于ans那么无论y取何值总差值只会更大或相等。整数平方根使用math.isqrt()比int(math.sqrt())更安全、更快因为它直接返回整数且避免了浮点数误差。二分查找实现也可以自己写二分查找y但math.isqrt在大多数情况下更简洁高效。核心是理解“固定x求最优y”这个子问题模型。2.4 D题Counting Ls —— 二维前缀和与组合计数D题《Counting Ls》引入了二维矩阵和组合数学。题目给出一个N×N的网格包含字符o和x。我们需要统计满足以下条件的三元组((r1, c1), (r2, c2), (r3, c3))的数量这三个位置上的字符都是o。其中两个位置在同一行另外两个位置在同一列。换句话说这三个o构成一个“L”形可能旋转或镜像其中两个点共线行或列第三个点与这条线上的一个点共线列或行。暴力枚举不可行N最大2000如果枚举所有三个点的组合复杂度是O(N^6)完全不可接受。高效算法思路我们转换一下统计视角。考虑“L”形的拐点。对于一个o位于(i, j)如果它能作为“L”形的拐点那么需要满足在第i行上除了(i, j)这个点之外至少还有一个o。设第i行上o的总数为row[i]那么该行上可供选择的另一个o有(row[i] - 1)个。在第j列上除了(i, j)这个点之外至少还有一个o。设第j列上o的总数为col[j]那么该列上可供选择的另一个o有(col[j] - 1)个。对于拐点(i, j)它能形成的“L”形数量就是(row[i] - 1) * (col[j] - 1)。因为我们可以独立地从行里选一个其他o从列里选一个其他o这两个点加上拐点就构成了一个“L”形。因此算法步骤为预处理两个数组row和col分别统计每一行和每一列中o的数量。遍历整个网格对于每个o的位置(i, j)将(row[i] - 1) * (col[j] - 1)累加到答案中。代码实现N int(input()) grid [input().strip() for _ in range(N)] row [0] * N col [0] * N # 预处理行和列的o的个数 for i in range(N): for j in range(N): if grid[i][j] o: row[i] 1 col[j] 1 ans 0 for i in range(N): for j in range(N): if grid[i][j] o: ans (row[i] - 1) * (col[j] - 1) print(ans)常见问题与排查为什么是乘法因为行和列的选择是独立的。从行里选一个点有(row[i]-1)种方式从列里选一个点有(col[j]-1)种方式根据乘法原理总的组合方式就是它们的乘积。会重复计数吗不会。我们以每个o作为唯一的拐点进行计数每个“L”形只有一个拐点即行列交汇的那个点因此每个“L”形只被计算一次。时间复杂度O(N^2)对于N2000完全可行。输入读取注意使用input().strip()去除可能的换行符确保字符判断准确。2.5 E题Mex and Update —— 维护集合的MEX值E题《Mex and Update》是一个需要在线处理更新的问题。题目初始给定一个长度为N的序列A并需要处理Q次更新操作。每次操作将位置i的元素修改为x。在每次操作后都需要输出当前序列A的MEX值。 MEX定义最小的、没有出现在序列中的非负整数。暴力法的缺陷每次修改后如果重新扫描整个数组计算MEX复杂度为O(N*Q)在N和Q最大均为2×10^5时无法承受。高效维护思路我们需要一种能快速更新元素、并快速查询MEX的数据结构。核心观察是MEX的值不会超过N。因为序列只有N个数最坏情况是0到N-1都出现了此时MEX为N。所以我们可以只关心0到N这些数的出现情况。我们可以维护一个数组count记录每个数0到N在序列中出现的次数。同时我们需要一个数据结构来快速找到count中第一个值为0的索引即MEX。一个简单高效的方法是维护一个有序集合如Python的SortedList里面存放所有count值为0的数字。那么MEX就是这个集合中的最小值。当更新发生时旧值old_val的count减1。如果减到0则将其加入“零次数集合”。新值new_val的count加1。如果从0加到1则将其从“零次数集合”中移除。查询MEX就是取“零次数集合”中的最小值。数据结构选择Python中可以使用SortedList从sortedcontainers库但AtCoder环境通常不提供或者自己用set维护最小值。但维护set的最小值在删除元素时可能不方便。一个更巧妙的办法是由于我们只关心最小的未出现数我们可以维护一个变量mex并从0开始尝试递增。配合count数组我们可以实现均摊O(1)的查询。初始时计算count数组然后mex从0开始递增直到找到第一个count[mex] 0。当更新发生时如果旧值old_val小于当前的mex并且count[old_val]减到0那么mex可能需要减小但mex只会增大不会减小这是关键。实际上更简单的方法是如果新值new_val等于当前的mex那么mex就需要向后寻找下一个未出现的数。因此维护策略可以是每次更新后如果被修改为的值恰好是当前的mex则循环递增mex直到count[mex] 0。代码实现维护mex变量法import sys input sys.stdin.readline N, Q map(int, input().split()) A list(map(int, input().split())) # 只关心0到N的出现次数 max_val N 5 # 稍微开大一点防止越界 cnt [0] * (max_val) for a in A: if a max_val: cnt[a] 1 # 初始mex mex 0 while mex max_val and cnt[mex] 0: mex 1 out_lines [] for _ in range(Q): i, x map(int, input().split()) i - 1 # 转换为0-index old_val A[i] A[i] x # 处理旧值 if old_val max_val: cnt[old_val] - 1 if cnt[old_val] 0 and old_val mex: # 如果旧值消失且它比当前mex小那么mex可以更新为这个更小的值 mex old_val # 处理新值 if x max_val: cnt[x] 1 # 如果新值等于当前的mex则需要向后寻找新的mex while mex max_val and cnt[mex] 0: mex 1 out_lines.append(str(mex)) print(\n.join(out_lines))避坑技巧数组大小cnt数组大小设为N5足够因为MEX最大为N。索引转换题目输入是1-index代码中通常使用0-index记得减1。维护mex的逻辑这是本题最精妙的地方。当旧值被移除且它小于mex时mex有可能变小因为那个数现在不出现了。当新值加入且等于mex时mex需要向后移动。这个维护过程是均摊O(1)的因为每个数最多被mex指针经过一次从出现到消失。输入输出优化对于大量查询使用sys.stdin.readline和批量输出”\n”.join()可以显著加快速度。2.6 F题Minimize Bounding Square —— 二分答案与贪心调整F题《Minimize Bounding Square》是330的压轴题综合了二分答案、贪心和前缀和的思想。题目描述在二维平面上有N个点。你可以进行最多K次操作每次操作可以将一个点的x坐标或y坐标加1或减1。你的目标是通过这些操作使得所有点能被一个边与坐标轴平行的正方形完全覆盖并且这个正方形的边长最小。求这个最小的正方形边长。问题转化正方形边长最小化且可以操作点。我们假设最终正方形的边长为L中心位置不确定。但因为是轴对称且操作是对每个点独立进行的一个经典的技巧是将x坐标和y坐标独立考虑。 对于一个给定的边长L我们需要判断能否在K次操作内将所有点的x坐标调整到某个区间[X, XL]内同时将所有点的y坐标调整到某个区间[Y, YL]内。并且移动x和移动y的操作次数之和不超过K。判断函数设计对于一维情况只考虑x坐标给定数组x[]和长度L最少需要多少次操作才能将所有数移动到某个长度为L的区间内 这是一个经典问题。最优的区间左端点一定是某个x[i]或者更准确地说最优区间是包含所有点移动后位置的一个滑动窗口。我们可以先对x数组排序。假设我们最终希望所有点落在区间[left, leftL]内。 对于一个排序后的数组将点移动到区间内的操作次数等于区间左侧的点移动到left的距离和加上区间右侧的点移动到leftL的距离和。这可以通过前缀和快速计算。 具体地对于排序后的数组a假设我们选择窗口覆盖下标从i到j的点j-i1个点窗口长度为L即a[j] - a[i] L。那么将所有点调整到这个窗口内的最小操作次数可以贪心地选择窗口为[a[i], a[i]L]。操作次数 (将a[i]到a[j]的点移动到a[i]L/2附近) 等等更精确的计算是 我们希望所有点落在[X, XL]。对于窗口内的点最少的操作次数是让它们尽可能不动或少动。实际上最优的X一定在某个点附近。我们可以枚举窗口的左端点a[i]那么窗口就是[a[i], a[i]L]。对于点a[k]如果a[k] a[i]需要增加a[i] - a[k]次操作。如果a[k] a[i]L需要减少a[k] - (a[i]L)次操作。如果a[i] a[k] a[i]L则无需操作。 通过维护前缀和我们可以O(1)计算将一个连续子数组压缩到某个区间的操作次数。但更常用的方法是双指针对数组排序。用两个指针l和r维护一个窗口使得a[r] - a[l] L。对于这个窗口最优的汇聚点可以是a[l]到a[r]之间的任意点但可以证明汇聚到中位数位置或窗口内任意点的总移动距离最小。然而我们要求的是移动到某个长度为L的区间而不是一个点。一个巧妙的方法是将窗口内的点移动到以窗口左端点或右端点为边界的区间。但这样可能不是最优。 实际上更通用的方法是问题等价于求最小的操作次数使得数组的极差最大值减最小值不超过L。因为我们可以通过操作将最大值减小、最小值增大。那么对于排序后的数组如果我们希望极差不超过L就需要找到一个最长的子数组其首尾差值不超过L。剩下的点就需要被操作“吸收”进这个区间。操作次数就是将这些“外部点”移动到区间边界的距离和。 但这样还是复杂。一个更直接且正确的二分判断方法是对于给定的边长L分别计算x坐标和y坐标调整到某个长度为L的区间所需的最小操作次数记为need_x和need_y。如果need_x need_y K则边长L可行。那么如何计算一维数组arr调整到某个长度为L的区间的最小操作次数need呢 我们可以枚举区间的左端点left。对于固定的left区间为[left, leftL]。操作次数 Σ max(0, left - arr[i]) Σ max(0, arr[i] - (leftL))。即所有小于left的点要增加到left所有大于leftL的点要减少到leftL。 由于arr是排序后的我们可以通过二分查找找到第一个left的位置mid1和第一个leftL的位置mid2。那么左边部分arr[0:mid1]的操作次数 left * mid1 - prefix[mid1]右边部分arr[mid2:]的操作次数 (prefix[n] - prefix[mid2]) - (leftL) * (n-mid2)其中prefix是前缀和数组。 我们需要枚举left但left的取值可以是所有arr[i]以及arr[i]-L为了将点包含进来。这样枚举是O(N)的加上二分查找总体O(N log N)可以接受。二分搜索框架显然最小的正方形边长L满足单调性如果边长L可行那么更大的边长一定也可行。因此我们可以二分搜索最小的L。 下界lo为0所有点重合上界hi可以设为坐标的最大范围例如1e9。 在二分判断函数check(L)中我们分别计算x坐标和y坐标所需的操作次数求和后判断是否K。代码实现框架import sys import bisect input sys.stdin.readline def min_operations_to_interval(arr, L): 返回将数组arr压缩到某个长度为L的区间所需的最小操作次数 arr.sort() n len(arr) if n 0: return 0 # 计算前缀和 prefix [0] * (n 1) for i in range(n): prefix[i1] prefix[i] arr[i] INF 10**18 min_ops INF # 枚举区间的左端点。左端点只需要在arr[i]和arr[i]-L中考虑即可。 candidates [] for a in arr: candidates.append(a) candidates.append(a - L) # 去重排序不是必须但可以简化。我们直接枚举每个arr[i]作为左端点试试。 # 实际上最优左端点一定在某个arr[i]处或arr[i]-L。我们枚举arr[i]作为左端点。 for i in range(n): left arr[i] # 二分找到第一个 left 的位置 # 由于arr有序i就是第一个left的位置 # 二分找到第一个 leftL 的位置 j bisect.bisect_right(arr, left L) # 计算操作次数 left_ops left * i - prefix[i] # 左边部分 right_ops (prefix[n] - prefix[j]) - (left L) * (n - j) # 右边部分 total_ops left_ops right_ops min_ops min(min_ops, total_ops) # 再考虑以arr[i]-L作为左端点 left2 arr[i] - L # 二分找到第一个 left2 的位置 i2 bisect.bisect_left(arr, left2) # 二分找到第一个 left2L 的位置注意left2L arr[i] j2 bisect.bisect_right(arr, left2 L) # 即 arr[i] left_ops2 left2 * i2 - prefix[i2] right_ops2 (prefix[n] - prefix[j2]) - (left2 L) * (n - j2) total_ops2 left_ops2 right_ops2 min_ops min(min_ops, total_ops2) return min_ops def main(): N, K map(int, input().split()) points [tuple(map(int, input().split())) for _ in range(N)] xs [p[0] for p in points] ys [p[1] for p in points] lo, hi 0, 10**9 while lo hi: mid (lo hi) // 2 need_x min_operations_to_interval(xs, mid) need_y min_operations_to_interval(ys, mid) if need_x need_y K: hi mid else: lo mid 1 print(lo) if __name__ __main__: main()注意事项与高级技巧单调性证明理解为什么能二分是关键。如果边长L可行那么任意更大的边长L’L我们只需使用相同的操作方案或者甚至更少的操作就能让点落在更大的正方形里所以一定也可行。一维问题转化将二维问题分解为两个独立的一维问题是本题最重要的突破点。因为操作次数是x和y上的操作之和正方形边长同时约束了x和y的跨度所以可以分开考虑再合并。计算一维最小操作次数上面提供的min_operations_to_interval函数是一种实现方式。它枚举每个点作为区间左端点或左端点-L的位置。这里使用了前缀和和二分查找来快速计算操作次数复杂度O(N log N)。对于N2e5二分判断的总复杂度是O(N log N log R)可以接受。边界处理注意前缀和数组的下标处理以及二分查找函数bisect_left和bisect_right的区别。精度与范围坐标和操作次数可能很大使用64位整数Python int自动支持。3. 比赛策略与提升建议打完一场比赛复盘和总结比单纯做出题目更重要。针对像ABC 330这样的入门级比赛我有几个策略上的建议时间分配与开题顺序A、B题通常应该在10-15分钟内解决。如果卡住检查是否理解错题意或者有简单的边界情况没考虑。C题开始需要一些算法思维如果10分钟没有清晰思路可以先看D题。D、E、F的难度可能并非严格递增有时E比D简单。快速阅读每个题目的题意和数据范围判断自己可能解决哪一道优先做有思路的。调试与验证小数据测试写完代码后用题目中的样例测试是必须的。但更要自己构造一些小的、极端的数据比如N1, N0数组全零最大值最小值等来测试程序的边界行为。输出中间变量在本地调试时可以打印一些关键的中间变量比如循环次数、计算结果确保逻辑符合预期。对拍对于C、F这样可能有多种解法的题目可以写一个暴力枚举的算法仅适用于小数据与你的优化算法进行对比随机生成小数据测试确保答案一致。英文题目阅读技巧抓主干先快速浏览题目描述抓住输入输出格式、数据范围、核心任务这三个关键。善用样例样例是理解题意的关键。通过输入输出样例可以反推题目的具体要求尤其是格式和边界。熟悉常见词汇integer,array,sequence,minimum,maximum,count,find,print,separated by spaces,newline等是高频词。遇到生词可以结合上下文猜或者用翻译插件辅助。练习是关键多打比赛自然就熟悉了题目的表述方式和常见的套路。如何利用比赛提升赛后补题对于没做出来的题赛后务必弄懂并独立实现AC代码。可以参考官方题解或其他选手的代码但一定要理解其思路。整理模板将常用的算法如二分查找、前缀和、并查集、BFS/DFS封装成自己的函数模板方便比赛时快速调用。分析错因如果是WA错误答案分析是算法逻辑错误、边界条件没处理好还是简单的笔误。如果是TLE超时思考复杂度是否过高有无优化方法。定期回顾可以建立一个自己的错题本或解题记录定期回顾相似的题型总结解题模式。ABC 330的题目梯度设置得很好从A到F几乎涵盖了入门阶段需要掌握的所有基础思维和算法输入输出、循环判断、绝对值处理、二分查找、前缀和与组合计数、维护集合MEX、二分答案与贪心。把这些题目吃透对于巩固基础、准备更高级别的比赛大有裨益。最重要的是保持练习和思考的习惯每次比赛都是一次宝贵的实战演练。