CodeM 2017初赛A轮复盘:从最大子段和到分层图最短路的算法实战
发布时间:2026/8/30 13:20:46 作者:尧图编辑部 阅读量:1,286

好的我理解你的需求。交给我了。当年第一次踏进 CodeM 2017 美团编程大赛初赛A轮的赛场时我还在读大三。说实话当时那点水平真不够看抱着“见见世面、看看大厂笔试到底考什么”的心态就报了名。结果三个小时的比赛前半小时紧张得手抖中段被一道中等题卡到怀疑人生最后十分钟疯狂检查边界条件整个体验比自己练习十套题都来得深刻。这篇文章就是围绕那场初赛A轮的完整复盘总结。我会从赛制准备、题目模型、实战节奏到赛后反思考察的能力模型手把手拆解这场比赛的方方面面。无论你是准备参加算法竞赛的学生、正在备战校招的求职者还是单纯想提升工程算法能力的开发者这篇文章都能给你一些在题库之外学不到的经验。光刷题是不够的你还需要知道比赛里那些“考场上没人告诉你”的事情。1. CodeM 2017初赛A轮参赛手册赛制、晋级线和报名阶段经验先说赛制。CodeM 2017是美团点评主办的第一届编程大赛说句公道话那一年大厂自办算法竞赛还是个新鲜事不像后来各家都开始搞。整个比赛分为初赛、复赛和决赛三个阶段。初赛又分成了A、B两轮选手可以只参加其中一轮也可以两轮都打系统会取你两轮中的最好成绩来进行晋级排名。这个设计其实挺人性化的给了选手一次“状态不好还有机会翻盘”的空间。A轮比赛是在牛客网的在线评测系统上完成的时间是3个小时左右题目数量印象中是5道按照难度递增排列。评测环境支持C/C、Java、Python等主流语言提交后实时可以看到通过率。这种在线赛制最大的好处是门槛低你在宿舍、在图书馆只要能上网就能参赛不需要像ACM区域赛那样跑去现场。但也正因为如此参赛人数非常多晋级竞争相当激烈。我记得当年初赛晋级复赛的比例大概只有前几百名具体数字记不太清了但那时候讨论群里都在说“A轮不拿个一千名以内基本就告别复赛了”。所以哪怕你只是为了体验一下目标也不能定得太低。如果以晋级为目标做题策略、时间的分配、抢分顺序都要提前想清楚这一点我放到后面专门讲。报名阶段有一个很容易被忽略的点赛前一定要把评测平台的编译环境摸一遍。我那年报名之后跟几个朋友一起在牛客网上找人少的时间段测试过本地代码和在线编译的差异发现牛客网的C编译器版本和本地不同有些老代码里用的写法在在线评测环境会编译不过。特别是用到了bits/stdc.h这种大包头文件有的平台支持有的不支持赛前不测试赛时第一题就能卡你半小时。准备阶段官方还放出过一些往年的真题或者模拟题资源。我的建议是不要盲目刷一堆题目而是先把时间花在“读题训练”上。CodeM的题面写得很长有的题还有类似实际业务场景的包装动不动就是“配送员”“商家”“订单”这种美团风格浓厚的背景。你要习惯从一大段场景描述里快速提炼出数学模型——这个能力在考试中几乎可以决定你的上限因为读题慢的人后面做难题的时间一定不够。数据结构和算法的储备上初赛A轮的考法基本是这几个方向基础DP、贪心、二分答案、最短路径、并查集、简单数论、状态压缩DP。线段树、后缀自动机这种高级数据结构在A轮也有可能出现但一般出现在压轴题位置分值比例不高。这里给大家一个参考如果你的目标只是晋级复赛把红黑名级别的难题放掉把前四道题稳稳拿住基本就够了如果目标是冲高名次那压轴题也要争取摸到部分分。2. 复盘A轮高频模型四道题串起一棵算法主干当年比赛结束后的第二天我做的第一件事就是把A轮的5道题全部重新看了一遍一半是靠记忆复盘一半是等讨论区大佬整理题解。回头再看感触最深的是A轮的题目模型其实非常集中——它不像一些偏难怪的比赛那样刻意刁难而是把算法世界里几个最经典最常用的考法轮番摆在你面前。下面我挑四道代表性的题目来逐一拆解。2.1 送分题最大子段和与边界条件第一道题通常是整个比赛最友善的存在。那年的签到题考的是一个很经典的模型给定一个长度为 n 的整数数组注意是整数有负数要你求最大的连续子段和。n 的上限是 10 的 5 次方级别暴力三重循环肯定过不了这题的核心考点就是最大子段和的线性DP写法也就是常说的 Kadane 算法。题解思路是这样的我们维护一个变量 cur表示“以当前位置结尾的最大子段和”再维护一个 best记录全局最大值。每次读入一个新数 x 的时候cur max(cur x, x)含义很简单——如果之前累加的部分让 cur 变大了就继续累加如果加上前面的反而比单独取 x 还小那就干脆从 x 重新开始。之所以可以这么贪心是因为连续子段要求不能断开如果以 i 结尾的最大子段和是负数它作为“前缀”对后面没有任何正贡献还不如直接扔掉。#include bits/stdc.h using namespace std; int main() { int n; scanf(%d, n); long long cur 0, best LLONG_MIN; for (int i 0; i n; i) { long long x; scanf(%lld, x); cur max(cur x, x); best max(best, cur); } printf(%lld\n, best); return 0; }这题最简单的写法十行之内就能搞定复杂度 O(n)空间 O(1)。但这道送分题有一个非常阴险的坑如果数组全部是负数best 的初始值必须设置成负无穷而不是0。很多人第一反应把 best 初始化为0结果遇到[-5, -2, -3]这种数据直接输出0判题直接 WA。我当时就差点栽在这个地方因为写代码的时候注意力全放在主逻辑上没有仔细想初始化的问题。赛场上时间紧容易忽略这种小细节所以建议不管多简单的题提交前都要想一想边界输入我测了吗数组全负数、全正数、只有一个元素这三种情况都过了吗这题虽然简单但它考察的恰恰是选手对“状态设计”的基本功。能够准确地说清楚 cur 状态表示什么以及为什么能这样转移才是真正吃透了这个模型。2.2 二分的进阶用法最小化最大值第二道题的难度开始往上跳了一档考的是贪心配合二分的经典套路。题目大致背景是美团配送小哥手上有 n 个包裹要运送每个包裹有对应的耗时现在要把这些包裹按顺序分成 m 批派送要求你规划一种划分方案使得“所有批次中总耗时最大的那一批”尽量小求这个最小的最大耗时。这题的输入规模大概也是 10 的 5 次方级别所以你要找的算法复杂度必须控制在 O(n log n) 或更好。这题的核心思路就是二分答案。你可以先思考一个问题如果我们已经给定了一个时间上限 x能不能判断是否可以用不超过 m 批的方式把包裹分完如果能判断那么我们就有了一个“验证函数”接下来就可以在数值范围上二分搜索最小的可行时间上限。判断的过程用贪心扫描就好从左往右累加耗时一旦当前这批的耗时超过了 x就新开一批。如果最终需要的批数不超过 m说明 x 是可行的我们可以尝试更小的时间上限否则 x 必须调大。bool check(long long x, vectorint a, int m) { long long cnt 1; // 至少需要1批 long long sum 0; for (int v : a) { if (sum v x) { cnt; sum v; } else { sum v; } } return cnt m; } int main() { int n, m; cin n m; vectorint a(n); long long l 0, r 0; for (int i 0; i n; i) { cin a[i]; l max(l, (long long)a[i]); // 下界单个包裹的最大耗时 r a[i]; // 上界所有包裹放在一批 } while (l r) { long long mid (l r) / 2; if (check(mid, a, m)) r mid; else l mid 1; } cout l endl; return 0; }这个“最小化最大值”的模型在A轮里出现非常频繁美团这种业务背景里也有很自然的映射——无论你是要调度骑手还是分配机器资源结论都差不多。二分答案相关的题目最考验选手的地方不是你知不知道“要二分”而是你能不能把边界条件和check函数的贪心逻辑想透彻。很多人在l和r的初始值上栽跟头。二分的下界不能从0开始而应该是所有包裹里的最大耗时否则就可能出现“某单个包裹耗时已经超过 x但你依然判定可行”的逻辑漏洞。二分循环里mid (l r) / 2和l mid 1的组合必须配合初始条件严格验证否则就会落入死循环。2.3 图论变式免费删边的最短路怎么建模第三道题开始进入图论的范畴也是我那次比赛里被卡得最难受的一道题。题目是这样给定一个 n 个点、m 条边的无向图每条边有正的权值要求从 1 号点走到 n 号点。现在你有 k 次机会k 不超过10可以跳过最多 k 条边不计算它们的权值也就是这 k 条边算作“免费”的。要求你计算从 1 到 n 的最短总代价。如果把这个条件去掉就是一个裸的 Dijkstra。但多的这个“k 次免费”直接让状态维度多了整整一维。看一眼数据范围n 是 10 的 4 次方m 是 10 的 5 次方k 是 10。这几乎就是明着告诉你这道题的复杂度可以是 O(k * m log n) 级别。所以正确的建模方式是分层图。分层图的核心想法是把原来的每个点拆成 k1 个状态副本(u, used)表示“当前在点 u且已经使用了 used 次免费机会”。在这个新图上你有两种移动方式从(u, used)沿着原图的边移动到(v, used)代价是边权 w表示正常付费走这条边从(u, used)移动到(v, used1)代价是0表示使用一次免费机会跳过这条边权。注意第二种移动也要从 u 到 v只是代价变成0模拟的就是“边依然可以走但不用付它的权值”。起点是(1, 0)终点是 n 号点的任何一个状态副本(n, 0)到(n, k)中的最小值。因为在终点时剩下的免费次数用不用完都无所谓只要到达 n 号点即可。#include bits/stdc.h using namespace std; struct Edge { int to; long long w; }; struct Node { int u, used; long long d; bool operator(const Node other) const { return d other.d; // 小顶堆 } }; vectorEdge g[10005]; long long dist[10005][11]; // dist[u][used] int n, m, k; long long dijkstra() { memset(dist, 0x3f, sizeof(dist)); priority_queueNode pq; dist[1][0] 0; pq.push({1, 0, 0}); while (!pq.empty()) { Node now pq.top(); pq.pop(); if (now.d ! dist[now.u][now.used]) continue; for (Edge e : g[now.u]) { // 不免费正常付费 if (now.d e.w dist[e.to][now.used]) { dist[e.to][now.used] now.d e.w; pq.push({e.to, now.used, dist[e.to][now.used]}); } // 免费使用一次机会 if (now.used k now.d dist[e.to][now.used 1]) { dist[e.to][now.used 1] now.d; pq.push({e.to, now.used 1, dist[e.to][now.used 1]}); } } } long long ans LLONG_MAX; for (int i 0; i k; i) ans min(ans, dist[n][i]); return ans; }这里要注意dist 数组要用二维第一维是节点编号第二维是已使用免费次数初始化为一个很大的数0x3f3f3f3f 或 LLONG_MAX 的合理近似值。在 Dijkstra 松弛时两种转移都要尝试更新。我当时就栽在少写了一个 free 转移的更新判断上结果样例过了、提交却 WA反复看了十几分钟才意识到是状态转移遗漏了。这道题其实非常值得好好吃透因为“分层图”是一个可以泛化的建模技巧。以后遇到任何“最多可以 k 次某种特殊操作”的图论题第一反应就应该是能不能通过拆点建立分层图k 次操作就是 k 层层与层之间走“代价为0的特殊边”。这个思路还能扩展到很多变式里比如“最多 k 条边的路径”“最多 k 次反向的机会”等。2.4 状态压缩DPn≤20背后的信号A轮的压轴题里有一道题非常典型考的是状态压缩DP。背景包装看起来又是跟路径规划有关大概是从一个点出发要访问若干个城市再回来。不过你要是能被包装迷惑就会忘了看一眼最关键的数据n 大约只有20。看到 n ≤ 20 这种数值第一反应就应该锁定两个方向一个是状态压缩DP另一个是DFS剪枝。如果你平时对算法复杂度有足够的敏感度就会知道 n 在20以下时2 的 n 次方大约 100 万级别正好是可以用位运算表示集合后做DP的经典范围。状态压缩DP状压DP的常规思路是用一个整数 mask 的二进制位集合来表示“已经访问过哪些点”。定义 dp[mask][i] 表示“已经访问过的城市集合是 mask当前停留在城市 i 时走过的总路程是多少”。初始状态是 dp[1][0] 0表示只访问了起点0、停在起点。转移的时候枚举下一个还没访问过的城市 j从 i 走到 j更新 dp[mask | (1j)][j] min(dp[mask | (1j)][j], dp[mask][i] dist[i][j])。最后的答案是 dp[(1n) - 1][i] dist[i][0]也就是访问了所有城市后停在某个城市 i再回到起点。#include bits/stdc.h using namespace std; int main() { int n; cin n; vectorint x(n), y(n); for (int i 0; i n; i) cin x[i] y[i]; vectorvectorint dist(n, vectorint(n)); for (int i 0; i n; i) for (int j 0; j n; j) dist[i][j] abs(x[i] - x[j]) abs(y[i] - y[j]); vectorvectorint dp(1 n, vectorint(n, 0x3f3f3f3f)); dp[1][0] 0; for (int mask 1; mask (1 n); mask) { for (int i 0; i n; i) { if (!(mask (1 i))) continue; if (dp[mask][i] 0x3f3f3f3f) continue; for (int j 0; j n; j) { if (mask (1 j)) continue; int nxt mask | (1 j); dp[nxt][j] min(dp[nxt][j], dp[mask][i] dist[i][j]); } } } int ans 0x3f3f3f3f; int full (1 n) - 1; for (int i 1; i n; i) { ans min(ans, dp[full][i] dist[i][0]); } cout ans endl; return 0; }状压DP这类题难的不是写代码而是你能不能从题面里识别出“这个 n 的范围其实是在暗示我”。我见过不少选手明明学过状压但考场上一看到大段场景描述就慌了完全顾不上 n 的大小硬是用暴力搜索去跑然后拿到一个超时的结果摊手。所以这里有一个比赛技巧当你看到一道题不知道用什么算法的时候先回头看一眼数据范围。数据范围其实已经帮你剧透了大半n≤10 大概率是暴力或状压n≤20 是状压DP的舒适区n≤10^5 意味着贪心/二分/单调栈n≤10^9 就可能是数学推导或矩阵快速幂。顺带提一句压轴题通常不只考一个算法它的变式可能还会把状压DP和别的限制条件结合起来比如要求满足某种时间窗约束或者每个城市最多访问两次。这时候状态设计就要改写比如再加一维记录“当前时间”或者“到达城市 i 时已经花费的次数”。这种扩展其实是对状态设计能力的真正考验建议有志于攻坚压轴题的选手一定要把状压DP的经典模型熟练到能够默写的程度。3. 赛场生存指南三个小时先AC哪道题比赛的经验有一个残酷的真相会做题未必能拿高分不会分配时间一定拿不到高分。三个小时说长不长说短不短你是慢慢磨完第一题然后被后面卡死还是先扫全卷再稳扎稳打结果可能天差地别。我自己总结了一套适合初赛A轮这种“难度递增、带签到题”的比赛节奏策略这里分享给大家。比赛开始后的前10分钟无论如何不要下笔。先把全部的题都读一遍。读题的时候在草稿纸上标记三道信息每个题的难度预判、合适的算法方向、预估需要的时间。按我当年的经验A轮5道题里通常有1道签到题、2道中档题、1道较难题、1道压轴题。你把5道题都读完后第一时间锁定签到题直接AC确保自己不至于空手而归。这一步非常关键因为比赛的排名事实上是很密集的签到题的分值占比往往是10%~15%不要了这分你后面基本就告别晋级了。AC签到题后千万不要顺着题号继续做第二题。你要根据自己刚才读题时的判断从2道中档题里挑出那个你更有把握的先做。这里有个经验法则在两道中档题之间犹豫时选那个输入数据范围更小、限制条件更少、模型更贴近你熟悉的算法的那道。因为中档题通常会在题面里设置一些小门槛看穿它的人只要十几分钟就能AC看不穿的人会被卡一两个小时。你要做的是先从那些“一眼能看穿”的开始拿分。最忌讳的行为是在一道中档题上死磕超过40分钟。比赛的状态和时间曲线类似抛物线开始阶段你的头脑最清醒后面越做越疲惫。如果你在一道题上卡了40分钟还没理清思路正确做法是标记一下跳到下一题。等你把后面能拿的分都拿完再回来重新审视这道题那时候你的心态会完全不一样——因为已经有保底分了脑子会更冷静反而容易想出卡住你的那个点。我在A轮就遇到过这种情况一道和概率期望相关的题目开场完全没头绪做完整张卷子其他题目再回头看反而一下想到了DP状态怎么设计最后成功利用剩余时间AC。还有一个很多人不重视的技术细节C选手务必在比赛开始前就做好 IO 优化。A轮部分题目数据量巨大n 到 10 的 6 次方级别的都有可能出现。这时候如果你用 cin 没关同步或者用 printf 写错了格式符都可能白白丢分。我的习惯是比赛一开始就在所有代码里统一加上ios::sync_with_stdio(false); cin.tie(0);或者干脆全部用 scanf/printf。还有能用long long的尽量用long long别赌数据范围不会爆 int。宁可多占一点内存也不要出现因为 int 溢出导致的 WA。提交策略方面我有一个坚定的原则每次提交前先构造一组极端数据自测。极端数据的类型包括最小值、最大值、空值如果允许、全相同值、随机大样例。这些数据用本地IDE跑一遍只要几秒但能拦住绝大部分常见的 WA。很多选手贪快样例一过就交结果 WA 一次、罚时20分钟心态还受影响。对比之下赛前多花30秒自测才是真正的聪明选择。顺带说一句CodeM 的评测系统我记得是支持查看返回结果的是 WA 还是 TLE 还是 RE 都能看到。如果你的代码返回 RE优先检查数组越界和除零TLE 则优先考虑是不是算法复杂度太高或者死循环。心态管理也是比赛的一部分。有一次我因为签到题提交了三次都因为一个小错被罚时整个人已经很急躁了。后来我发现一个规律当你连续两题 AC 失败时最应该做的不是继续死抠代码而是停下来喝口水、做两个深呼吸把刚才失败的代码从头到尾重新读一遍。往往那种时候错误会在30秒内瞬间映入眼帘。这没有什么高深的道理纯粹是让自己从情绪里抽离出来让你的逻辑思维重新上线而已。4. 竞赛题背后的工程价值算法在美团业务中的真实落地很多参加算法比赛的同学可能会有一个误区觉得自己刷的算法题跟实际工作没什么关系比赛就是为了拿奖好看。但CodeM这样一个以业务公司为主办方的大赛实际上所有题目都是精心设计过的——很多题的背景和算法模型都与美团真实的业务挑战有着千丝万缕的联系。最简单的例子就是上面提到的“最小化最大批次时间”的二分答案题。美团外卖的配送调度系统每天要面对海量的订单如何把订单合理地分派给骑手使得高峰期每个骑手的任务量尽量均衡同时所有订单都能在时限内送达——这就是一个典型的“任务分配、最大化最小化”问题。竞赛题里你管它叫二分答案业务里它叫负载均衡。再比如配送路径优化相关的题目背后映射的就是骑手一次取多个订单后的最优路径规划在现实中每优化 1% 的路线长度都意味着数百万级别的成本节省。另一个能明显感受到的工程思维差异是输入输出和数据规模。竞赛题里的输入范围直接决定了算法的复杂度要求而真实业务里数据量不一定给你一个明确的“n”但系统的延迟要求、机器资源限制就是那个隐含的约束。会写 O(n^2) 的算法不代表你能处理千万级的实时请求算法竞赛训练出来的“复杂度意识”本质上是一种资源敏感度——这是很多没经过竞赛训练的人相对缺乏的。不过话说回来竞赛和工程并非完全互相替代。算法竞赛更强调在极限约束下找到最优解而工程实际中更多时候是在“足够好的解”和“可维护的代码”之间做权衡。举个例子竞赛里你可能为了 O(n log n) 的复杂度写一个非常精巧的数据结构但如果工程里同样的场景每天只跑一次、数据量只有几千行那写个简单直观的解法可能反而是更好的选择。CodeM 这类比赛给我的最大启发不是“所有题都应该用最复杂的算法”而是“你要学会判断问题的最优解法在什么约束下成立”。面试角度看参加过 CodeM 或其他算法竞赛的经历在校招简历上绝对是一个有价值的加分项。它不只是一行光鲜的获奖记录更是向面试官证明你具备扎实的数据结构基础、分析和优化复杂度的能力、以及在压力下解决问题的能力。但要注意面试官一般不会因为你比赛获奖就直接发 offer他们大概率会拿着你赛题里出现过的模型现场出道变式题来考你。所以如果真的想在面试中利用比赛经历加分赛后的深度复盘比赛时的名次更重要。我在 CodeM 之后养成的习惯是每场赛题无论AC与否都在赛后一周内把每道题的题解和优化方向啃透这比盲目刷题有效得多。还有一点让我印象很深的是CodeM 期间美团在讨论区发布了很多关于配送调度、运筹优化的科普文章。我当时抱着好奇点开看才发现算法在真实业务中的复杂度远超竞赛题。竞赛题通常给定一个静态的全量输入你只需要离线算出答案而真实业务里的调度是动态的、带噪声的你不仅要考虑算法的最优性还要考虑异常情况、数据延迟、系统容错。这从另一个角度说明了竞赛成绩好不代表能直接写好业务代码但它确实培养了“通过算法思维拆解复杂问题”的底子这才是最长期的价值。所以我的建议是千万不要把 CodeM 或者任何一场编程大赛只当成一次“拿奖”的机会。它更是一次难得的算法能力体检也是一扇让你窥见大厂真实技术栈复杂度的窗口。如果你能在比赛后把那些经典模型的变式都吃透把竞赛思维迁移到工程问题中去那这场比赛带来的收益就远远不止一个名次那么简单了。最后再聊两句曾经踩过的坑。当年我准备初赛A轮的时候为了求快跳过了很多基础数据结构的训练直接去啃那些看起来高深的题目结果上了考场连签到题都差点因为状态定义模糊而出错。后来我才想明白算法竞赛的准备绝对没有捷径——那些看起来最基础的模型才是决定你上限的地基。所以无论你现在的目标是晋级复赛还是想在校招中多一分底气都从打好基本功开始再沿着“模型识别—复杂度分析—变式训练”这条路一步步走你的努力一定会反映在最终的分数上。