news 2026/11/4 10:43:33

信息学竞赛四题精讲:从模拟到贪心,复杂度压缩的通用直觉

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
信息学竞赛四题精讲:从模拟到贪心,复杂度压缩的通用直觉

简介:这份资料是“科大国创杯”2023年安徽省青少年信息学科普日活动ACSP-J组的赛题解析文档,面向备战信息学竞赛的初中生及算法入门学习者,聚焦模拟、排序、双指针、最短路径与区间合并等常见竞赛考点。压缩包内共1个PDF文件,约106KB,以题解正文形式呈现四道题目的思路与复杂度分析:评分题讲解去极值求平均的模拟做法,数数题借助排序与双指针统计合法三角形,行走题在网格图上处理删边后的最短路,石子题则用贪心与栈维护区间合并代价。内容按题目分页组织,每道题均给出算法要点和时间复杂度推导,便于对照练习与复盘。目前已有1315人学习,适合需要梳理竞赛解题框架、查漏补缺的选手参考。

1. 四道题背后的算法直觉:为什么模拟题反而最容易翻车

信息学竞赛里有个反直觉的现象:四道题里,看起来最“送分”的模拟题,往往是全场失分最集中的地方。评分这道题,题意简单到一句话就能说完——去掉最大最小分再求平均,可真正写起来,边界处理、精度控制、排序开销,处处是坑。而数数、行走、石子这三道题,表面考的是双指针、最短路、贪心合并,底层考的其实是同一件事:你能不能把问题的结构看清楚,把暴力枚举的复杂度压下来。这套 ACSP-J 组的题目,恰好把“模拟—排序双指针—图论分层—贪心栈合并”这条从入门到进阶的思维链条完整串了一遍。搞懂这四道题为什么这么设计、每一步复杂度是怎么抠出来的,比刷十道同类题更有价值。

2. 评分与数数:排序之后,暴力枚举怎么被压成线性

2.1 评分题:为什么“只算和”就能省掉一次除法

评分题的题意是:n 个选手,每个选手有 m 个评委打分,去掉一个最高分和一个最低分,求剩余得分的平均值。最直白的写法是每个选手存一个数组,排序,去掉首尾,求和除以 m-2。这个写法没错,但它的复杂度是 O(n·m log m),排序开销全花在找最大最小值上。

关键观察在于:找最大值和最小值根本不需要完整排序。一次线性扫描就能同时拿到 max 和 min,求和也可以在同一个循环里完成。于是每个选手的处理变成 O(m),整体 O(n·m)。题目正文里提到“由于评委个数一样,所以可以只算出分数和”,这句话的潜台词是:平均值之间的比较可以转化为总和之间的比较,如果题目只要求排名而不要求输出具体平均分,连除法都能省掉。

常见的实现思路是这样:

def score(players, m): result = [] for scores in players: total = sum(scores) # 一次扫描拿最大最小,避免 O(m log m) 排序 mx = max(scores) mn = min(scores) # 去掉一个最高一个最低,剩余 m-2 个 avg = (total - mx - mn) / (m - 2) result.append(avg) return result

这里有个容易被忽略的细节:max和min各扫一遍,加上sum一共三遍,虽然还是 O(m),但常数是 3。如果追求极致,可以合并成一个循环同时维护 total、mx、mn,常数降到 1。竞赛里 m 通常不大,这点常数差异不影响通过,但“能不能在一个循环里做完”是区分熟练度和直觉的地方。

提示:如果 m 恰好等于 2,去掉最大最小后剩余 0 个评委,除法会除零。题目一般保证 m ≥ 3,但自己写测试时最好覆盖这个边界。

2.2 数数题:双指针的右端点为什么单调

数数题给 n 根木棍,问有多少组三元组 (i, j, k) 满足 i < j < k 且能构成三角形。三角形条件是任意两边之和大于第三边,排序后只需检查最短两边之和大于最长边,即 a[i] + a[j] > a[k]。

暴力三重循环是 O(n³),n 稍大就超时。排序后,固定 i 和 j,合法的 k 满足 a[k] < a[i] + a[j] 且 k > j。由于数组有序,所有满足 a[k] < a[i] + a[j] 的 k 构成一个从 j+1 开始的连续区间,右端点就是最后一个满足不等式的下标。

核心在于:当 i 固定、j 增大时,a[i] + a[j] 单调不减,所以右端点只会往右移,不会回退。这就是双指针成立的条件——右端点关于 j 单调。于是内层不用每次从 j+1 重新找右端点,而是维护一个指针 k,j 每增大一步,k 就尝试往右扩展。

def count_triangles(a): a.sort() n = len(a) ans = 0 for i in range(n - 2): k = i + 2 # k 至少比 j 大 1,j 从 i+1 开始 for j in range(i + 1, n - 1): if k <= j: k = j + 1 # 右端点单调右移,k 不回退 while k < n and a[i] + a[j] > a[k]: k += 1 # [j+1, k-1] 都是合法的最长边 ans += k - j - 1 return ans

这段代码里k在外层 i 固定时只增不减,内层 j 循环总共让 k 移动 O(n) 次,所以固定 i 的复杂度是 O(n),整体 O(n²)。如果每次 j 增大都重新从 j+1 扫到右端点,最坏会退化回 O(n³)。双指针的“血泪经验”就在这里:指针的单调性必须由问题结构保证,不能想当然。

参数说明:a是排序后的木棍长度数组;k是当前最长边的候选右端点(开区间,即 k 本身不合法);ans累加的是每个 (i, j) 对应的合法 k 个数。注意k <= j的重置逻辑,当 j 追上 k 时必须把 k 推到 j+1,否则区间会算错。

3. 行走:删掉一条边之后,最短路为什么还能 O(1) 查

3.1 网格图的分层结构:x+y 相同的点在同一斜线

行走题给一个 n×n 网格,从 (1,1) 走到 (n,n),每步只能向下或向右。每次询问删掉一条边,问最短路长度。不删边时最短路长度固定是 2n-2,因为无论怎么走,向下 n-1 步、向右 n-1 步,总步数不变。

关键观察是:把网格按 x+y 的值分层,每条斜线上的点 x+y 相同。任何一条从 (1,1) 到 (n,n) 的路径,必然依次经过 x+y = 2, 3, ..., 2n 的每一层,每层恰好一个点。一条边连接相邻两层,所以路径可以看成在每两层之间选一条边。

如果删除的边不在某条最短路上,答案不变。如果删除的边在最短路上,问题变成:对这条路径上的每条边,找一条替代边,使得绕行代价最小。由于每层只有一个点,替代边只能在相邻两层之间找,而相邻两层之间的边数是 O(n) 量级。

3.2 替代边的枚举:为什么总复杂度是 O(n² + q)

题目正文给出的思路是:先求一条最短路,对路径上的每条边,枚举所有跟它在同一层之间的边来代替它。路径长度是 2n-2,每条边所在层间的候选边数是 O(n),所以预处理所有“删路径边”的答案是 O(n²)。询问时,如果删的边不在路径上,直接输出 2n-2;如果在路径上,查预处理结果。总复杂度 O(n² + q)。

这里的设计取舍值得细看:为什么不预处理所有边的删除答案?因为边总数是 O(n²),每条边都跑一次最短路就是 O(n² · n²),不可接受。而只预处理路径上的 O(n) 条边,每条花 O(n) 枚举替代,正好 O(n²)。这是典型的“只对可能影响答案的边做预处理”的思路。

def walk(n, removed_edges): # 先求一条最短路,记录路径上的边 path_edges = get_one_shortest_path(n) # 长度 2n-2 path_set = set(path_edges) # 预处理:路径上每条边被删后的最短路 backup = {} for e in path_edges: best = 2 * n - 2 # 枚举与 e 同层的所有边作为替代 for alt in same_layer_edges(e): if alt not in path_set or alt == e: continue # 用替代边绕行的代价 cost = detour_cost(e, alt) best = min(best, cost) backup[e] = best # 回答询问 ans = [] for e in removed_edges: if e not in path_set: ans.append(2 * n - 2) else: ans.append(backup[e]) return ans

same_layer_edges(e)返回与边 e 连接相同两层斜线的所有边;detour_cost计算用替代边替换后路径长度的变化。注意替代边本身如果也在路径上,绕行可能形成环,需要排除或特殊处理。这个细节是这道题最容易翻车的地方:替代边不能简单取同层任意边,还要保证替换后路径仍然连通且不重复经过同一层。

注意:如果网格中存在多条最短路,删掉某条路径边后,可能存在另一条完全不经过该层间边的路径。上面的预处理假设替代只发生在同层,这在“路径边被删”时是充分的,因为路径在每层只有一个点,绕行必然发生在该层间。

4. 石子:贪心合并的栈结构,为什么平均值决定顺序

4.1 单点合并:最小值在区间外时为什么要“打包”

石子题先考虑 l = r 的情况:给定一个起点,每次把相邻的两堆合并,花费是两堆石子数之和,问把整个区间合并成一堆的最小花费。这是经典的区间合并问题,但题目正文给出的贪心视角很特别:如果当前未合并部分的最小值就在已合并区间的左边或右边,那一定先合并它。

为什么?因为合并花费与参与合并的堆的大小有关,越小的堆越应该早合并,避免它被后面的大堆“拖累”多次。如果最小值不在已合并区间的紧邻位置,比如最小值在左边但中间隔了几堆,那么中间那些堆合并完之后,下一步必然轮到最小值。于是可以把“最小值及其右侧连续的一段”打包成一个整体,因为它们会紧接着一起被合并。

打包之后,每个包有一个“平均代价”,比较两个包谁先合并,等价于比较它们内部石子的平均值。平均值小的包先合并更优。这个结论的直觉是:先合并的包会被后续每次合并“带上”一次,所以它的单位代价应该更小。

4.2 栈的单调性:从左往右扫,维护平均值递增

对于起点左边的点,从左往右扫,维护一个栈。每次加入一个新点,如果栈顶的平均值大于栈顶下一个的平均值,就把栈顶和栈顶下一个合并。这样栈内从栈顶到栈底平均值递增。右边从右往左同理。询问时,每次选左右栈顶平均值较小的那个合并。

def merge_cost(left_stack, right_stack): cost = 0 while left_stack or right_stack: # 选平均值较小的栈顶先合并 if not right_stack or (left_stack and left_stack[-1].avg <= right_stack[-1].avg): pack = left_stack.pop() else: pack = right_stack.pop() # 合并代价 = 包内石子数 × 已合并区间和 + 包内贡献 cost += pack.size * current_sum + pack.internal current_sum += pack.total return cost

pack.avg是包内石子的平均值,pack.size是包内堆数,pack.internal是包内合并的内部花费,pack.total是包内石子总数。栈的维护保证了每次取出的都是当前全局最优的合并对象。

题目正文提到“数据随机时栈大小是 O(log n)”,所以对每个位置记录左右栈,总空间 O(n log n)。如果数据不随机,栈可能退化到 O(n),需要额外维护结构,但这不是考察重点。这里的取舍很清楚:随机数据下,栈的期望深度很小,直接存下来最省事;非随机数据需要更复杂的平衡结构,但竞赛题通常不会在这上面卡人。

提示:栈合并的条件是“栈顶平均值大于栈顶下一个的平均值”,注意是严格大于。如果相等,合不合并都不影响最优性,但会影响栈的大小,实现时统一用大于即可。

5. 避坑与排查:这四道题最容易翻车的五个地方

现象一:评分题用排序找最大最小,大数据超时。原因是对每个选手都做 O(m log m) 排序,n 和 m 都大时常数爆炸。解决方式是改用一次线性扫描同时求 total、max、min,把每个选手的处理压到 O(m)。

现象二:数数题双指针右端点回退,复杂度退化。现象是本地小数据能过,大数据 TLE。原因是内层 j 增大时重新从 j+1 找右端点,没有利用单调性。解决方式是把 k 提到外层,固定 i 时 k 只增不减,j 增大时只做while扩展,不回退。

现象三:行走题替代边选错,答案偏大。现象是删路径边后输出的最短路比真实值大。原因是替代边选在了错误的层间,或者替代边本身也在路径上导致绕行不合法。解决方式是严格按 x+y 分层,替代边必须连接与被删边相同的两层,且替换后路径在每层仍只有一个点。

现象四:石子题栈合并条件写反,贪心失效。现象是合并花费比预期大。原因是把“栈顶平均值大于栈顶下一个”写成了小于,导致栈的单调性方向反了。解决方式是画一个小例子手动模拟,确认平均值小的包应该靠近栈顶(先被合并)。

现象五:边界情况漏判,小数据 WA。评分题 m=2 除零、数数题 n<3 无解、行走题 n=1 路径为空、石子题 l=r 单点。这些边界在样例里往往不出现,但评测数据一定会覆盖。养成习惯:写完先手造最小规模输入跑一遍。

6. 从 O(n³) 到 O(n²):复杂度压缩的通用直觉

这四道题放在一起看,其实在反复训练同一种能力:识别问题结构,找到单调性或不变量,把暴力枚举的维度降下来。评分题的不变量是“总和减去最大最小”,数数题的单调性是“右端点随 j 增大而右移”,行走题的结构是“按 x+y 分层后每层一个点”,石子题的贪心依据是“平均值小的包先合并”。

我一般会这么理解复杂度压缩:先写出最暴力的解法,然后问自己——枚举的哪些维度是冗余的?哪些量在枚举过程中单调变化?哪些子问题被重复计算了?数数题里,固定 i 后 j 和 k 的关系是单调的,所以 k 不用每次重找;行走题里,路径边只有 O(n) 条,所以只对它们预处理;石子题里,栈的单调性让每次合并的选择变成 O(1)。

一个可验证的检查方法是:写完代码后,构造一组让指针“最坏移动”的数据,数一数内层循环实际执行了多少次。如果次数和理论复杂度对不上,说明单调性没利用好。比如数数题,如果 k 在 j 循环里被重置,实际执行次数就是 O(n³),一测便知。

题目暴力复杂度压缩后关键结构
评分O(n·m log m)O(n·m)线性扫描替代排序
数数O(n³)O(n²)右端点单调
行走O(q·n²)O(n²+q)按斜线分层,只预处理路径边
石子O(n²) 单点O(n log n)平均值单调栈

从那以后我每次遇到“看起来能暴力”的题,都会先强制自己写出暴力版,再逐条问“这个循环能不能不跑”。希望这套题的结构分析,能帮你在下一次比赛里少踩一个坑。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 10:42:11

动漫网站毕业设计:前后端分离、六张核心表与接口闭环

简介&#xff1a;这份动漫网站设计与实现方向的毕业论文文档&#xff0c;面向计算机相关专业学生及需要完成网站制作类毕业设计的读者&#xff0c;提供从选题落地到系统测试的完整写作与开发参考。压缩包内共1个doc文件&#xff0c;大小约569KB&#xff0c;以论文正文形式组织&…

作者头像 李华
网站建设 2026/11/4 10:39:49

无菌隔离器验证难点拆解:密封性、气流流型与生物去污落地

干这行十几年&#xff0c;被问得最多的问题之一就是无菌隔离器验证到底难在哪。外行看&#xff0c;不就是个带手套的玻璃箱子吗&#xff0c;测测漏、熏一熏、数数尘埃粒子&#xff0c;能有多复杂。真上手做过一轮完整验证的人都知道&#xff0c;难点从来不是某个单项测试会不会…

作者头像 李华
网站建设 2026/11/4 10:39:02

AI Engineering从零到落地:技术栈、路径与实操经验

第一次有人让我完整讲一遍"AI Engineering 到底在做什么"的时候&#xff0c;我发现自己给不出一个利落的答案。这个方向太新了&#xff0c;你可以顺手搜到海量"大模型原理"教程&#xff0c;也能找到各种框架十分钟上手的文章&#xff0c;但很少有人能讲清楚…

作者头像 李华
网站建设 2026/11/4 10:38:38

2026年主流网盘同步功能横评与选型指南

1. 网盘同步功能的核心价值解析在数字化办公成为主流的当下&#xff0c;文件同步需求呈现爆发式增长。根据我过去五年测试过的47款网盘产品经验&#xff0c;真正的同步网盘需要同时满足三个刚性指标&#xff1a;实时双向同步能力&#xff08;任何终端修改即时生效&#xff09;、…

作者头像 李华
网站建设 2026/11/4 10:38:27

RK平台BLE广播扫描低功耗唤醒方案设计与实践

RK 平台上的 BLE 低功耗唤醒&#xff0c;是这两年做电池类产品绕不开的一个方案点。我接触过的项目里&#xff0c;凡是带锂电池、要求待机几个月起步的设备&#xff0c;最后几乎都会把“谁能把主控叫醒”这个问题单独拆出来做设计。原因很直接&#xff1a;主控 SoC 跑起来的时…

作者头像 李华
网站建设 2026/11/4 10:37:54

金融服务平台搭建实战:账户建模、支付链路与对账风控全解析

如果让我用一句话总结这些年做金融服务相关项目的感受&#xff0c;那就是&#xff1a; 钱是世界上最敏感的数据 &#xff0c;而金融服务平台的本质&#xff0c;就是一套把这种敏感数据变成可靠服务的工程化体系。今天想分享的这个项目&#xff0c;内部代号就叫 financial-se…

作者头像 李华