ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

USACO银组真题解析:用图论连通分量建模,破解奶牛语言翻译问题

USACO银组真题解析:用图论连通分量建模,破解奶牛语言翻译问题 做USACO往年的银组题最常有的感受是题目背景憨憨的考点却不含糊。P3026 [USACO11OPEN] Learning Languages S 是典型一例——农场里有 N 头牛、M 种语言每头牛会若干门语言问最少让多少头牛额外学一门语言才能让任意两头牛都能直接或通过翻译交流。单看题面很容易误以为是个字符串匹配或贪心题实际上它是为“连通分量”量身定做的一道图论启蒙题。在洛谷上难度标记为普及/提高-但里面的建模套路足够你迁移到后面很多带“传递性”的题目上。这篇文章把从读题、建图、计数到公式推导的完整过程写一遍顺便把我实际写代码时踩过的坑也记下来。1. 题目到底在说什么不靠嘴靠图1.1 原文背景与关键约束N 头奶牛编号 1 到 NM 种语言编号 1 到 M。每头牛会若干门语言输入格式是每头牛先给一个 K再给出 K 个语言编号。如果 K 0说明这头牛什么语言都不会这个细节非常关键后面公式推导和边界处理都跟它有关。两个牛之间能直接交流前提是它们会说同一种语言。如果 A 会说语言 xB 会说语言 x 和 yC 会说语言 y那么 A 和 C 虽然没共同语言但可以借助 B 翻译交流。翻译链还可以更长A - B - C - D 这样一路传下去。题目要问的是最少让多少头牛额外学习一门语言才能让任意两头牛都能互通。注意“额外学习一门语言”的含义你选中一头牛就只能让它学一门新语言不能让它学三门外语来当万能翻译。所以最后统计的是“学习的牛头数”不是“学习次数”也不是“新增语言连接数”。数据范围 N、M 都不超过 100非常小。这意味着你不需要考虑任何高效优化哪怕是 O(N^3) 的暴力也随便跑。USACO 早期银组题就是这样数据给得很宽松真正考察的是你怎么把这个故事抽象成图。1.2 传递闭包翻译链的长度无所谓这道题最核心的一个转化是把“翻译”看成语言之间的桥。如果有一头牛同时会说语言 i 和语言 j那么语言 i 的说话者和语言 j 的说话者之间就存在一座桥。翻译链再怎么绕本质上就是沿着这些桥一步步走。用图论的语言来说就是把每种语言看作一个节点若存在一头牛同时会说语言 i 和 j就在节点 i 和 j 之间连一条无向边。于是“会说语言 i 的牛能不能和会说语言 j 的牛交流”等价于“节点 i 和节点 j 在图中是否连通”。连通性天然有传递性这和题目里翻译链可以无限延伸是完全对应的。想通这一层之后题目就从一个“牛学语言”的模拟题变成了“数连通块”的图论题。你可以完全不管哪头牛具体会哪几门语言只看语言之间的关系图。这也是为什么我说这道题适合刚学完 DFS、BFS、并查集的人练手——它没有任何复杂算法全部功夫都在建模上。1.3 把样例在图上画一遍拿一个典型的样例来说3 5 2 1 2 1 3 1 5三头牛牛 1 会语言 1 和 2牛 2 会语言 3牛 3 会语言 5。建出来的语言图是这样语言 1 和语言 2 之间有一条边因为牛 1 同时懂这两门语言 3 是个孤立点语言 5 是个孤立点。语言 4 没有人会说根本不出现在图里。所以这张图一共有 3 个连通块{1, 2}、{3}、{5}。要让所有牛都能交流至少需要把这三个连通块打通成一块。任取一种方案让牛 2 学语言 1它就和牛 1 所在的连通块融合再让牛 3 学语言 2它也进入同一个连通块。答案就是 2。这个数字恰好等于连通块数减一3 - 1 2。在这里能直观看到问题被化简成了“把 cnt 个连通块合并成 1 个需要 cnt - 1 次操作”。但不要高兴太早这只是没有空手牛的简单情况真正麻烦的边界在第三节细说。2. 建图思路牛与语言谁是节点2.1 以牛为节点的直觉方案很多人第一反应是建“牛图”两头牛只要会说同一种语言就连一条边。然后统计牛图里有几个连通块答案好像是连通块数减一。这个思路在“所有牛都会至少一门语言”的前提下是成立的而且直观。比如四头牛分别只会语言 1、2、3、4牛图中四个点互不相连有 4 个连通块答案是 3。随便选三头牛让它们学第四头牛的语言即可全部都通。但一旦出现空手牛这个模型就开始出问题。假设两头牛什么都不会牛图里是两个孤立点连通块数为 2按“连通块数减一”的公式会算出答案是 1。可在真实规则下两边都空手的牛要交流必须让两头牛各学一门共同语言学习次数是 2。为什么公式失效了因为让一头空手牛去学一门没人说过的新语言它并不会立刻和任何人连通连通块数没有减少这次学习“白费”了。牛图模型里没有地方记录这种白费。所以以牛为节点不是不能做而是需要额外特判空手牛代码容易写漏。我在本地第一次交就是这么错的后面细说。2.2 以语言为节点的传递模型换成以语言为节点情况就清爽很多。读入每头牛的语言列表时如果这头牛一句都不会K 0就把 emptyCow 加一它完全不参与建图。如果它会至少一门语言就把列表里的语言全部标记为“出现过”然后在语言之间连边。连边的方式可以自由选择把第一门语言和后面所有语言都连边或者只把相邻两门语言连边langs[0]-langs[1]、langs[1]-langs[2]……因为图连通性具有传递性相邻连边已经足够让这些语言处于同一连通块。语言图中的每个连通块代表一个“语言族群”族群内任意两门语言都连通也就是说会这些语言的牛全部可以互相交流。于是整个问题变成当前一共有 cnt 个语言族群外加 emptyCow 头完全不会语言的牛最少要学多少门才能合成一个大家庭。2.3 两种建图各自要注意什么把两种方式放在一起对比能更清楚地看到为什么语言图是正解。建图方式节点含义连通块含义处理空手牛牛为节点一头牛通过共同语言直连或翻译可达的牛群空手牛会破坏“答案 连通块数 - 1”的公式语言为节点一门语言两两语言互相连通的语言族群空手牛不进图单独计数既安全又直观如果你非要写牛图也可以做对把空手牛单独拎出来最后答案里额外加上“至少得让它们都学一次”的数量。但既然语言图已经把这个问题自然消化掉了何必自找麻烦。竞赛里选建模方式的标准就一条哪个模型能让边界情况最少就用哪个。语言图的另一个好处是天然过滤了“没人说的语言”。比如 M 100但实际只有 3 种语言被牛说那语言图里就只有这 3 个有效节点。如果拿牛图做你也得额外考虑这些没出现的语言虽然不会影响答案但写出来总归多一层心智负担。3. 连通块计数与答案公式3.1 DFS 给语言图上色统计连通块的标准做法是 DFS 或 BFS。这里用 DFS 最顺vectorint g[105]; // 语言图 bool vis[105]; // 访问标记 bool hasCow[105]; // 这门语言是否有牛会 void dfs(int u) { vis[u] true; for (int v : g[u]) { if (!vis[v]) dfs(v); } }主函数里从 1 到 M 遍历每种语言满足两个条件才作为新连通块的起点这门语言有牛会即hasCow[lang] true它还没被访问过。只有起点满足条件才cnt然后从它开始 dfs。少了hasCow这个判断会出大问题我第三小节单说。3.2 ans (cnt - 1) emptyCow 从哪里来假设语言图里有 cnt 个连通块emptyCow 头牛一句都不会。先看非空手牛的连通块。当前这些语言族群之间互不相通要让它们全部连通最少需要 cnt - 1 次操作。每次操作的具体做法是从连到块 A挑一头会某种语言的牛让它学块 B 的某门语言A 和 B 就合并成一个连通块。每学一次连通块数量减少一直到剩一个。再看 emptyCow 头空手牛。它们没有任何语言想和别人交流必须得学一门语言。学会之后这头牛就加入某个已有的语言族群如果学的是全新语言并且没有其他牛学它仍然无法交流所以最优策略一定是学已有族群的语言。每一头空手牛都要学一次所以这部分花费就是 emptyCow。两者相加ans (cnt - 1) emptyCow。这里有个特别容易忽略的例外如果 cnt 0也就是没有任何牛会说任何语言。此时公式会给出 -1 emptyCow N - 1但真实答案是 N。原因在于第一头空手牛学语言时它学的一定是一门“全新语言”没有第二个说话者这次学习不会让任何两个个体开始交流。必须有一头牛“开荒”其他 N - 1 头牛再学同一门语言大家才全通。所以代码里特判一下if (cnt 0) ans emptyCow; // 此时 emptyCow N else ans cnt - 1 emptyCow;这个特判不写小数据可能测不出来因为极少有人专门拿“全员哑巴”去拍。3.3 四类边界数据逐个过为了验证公式正确我一般会构造四类数据第一类全员空手4 3 0 0 0 0cnt 0emptyCow 4答案是 4。每头牛都必须学一次而且都得学同一门语言比如语言 1。第二类只有一门语言所有牛都会3 1 1 1 1 1 1 1cnt 1emptyCow 0答案是 0。大家本来就能交流不需要任何牛学习。第三类每头牛只会一门且各不相同4 4 1 1 1 2 1 3 1 4语言图有 4 个孤立点cnt 4emptyCow 0答案是 3。选三头牛分别学第四种语言即可。第四类空手牛与多连通块混合4 3 0 0 2 1 2 1 3牛 3 会语言 1、2所以语言 1 和 2 在一个块牛 4 会语言 3单独一个块。cnt 2emptyCow 2答案是 2 - 1 2 3。验证一下让牛 4 学语言 2把两个语言族群连起来两头空手牛各学一次语言 1。总共三次全部互通。边界构造完公式就站得住了。4. 完整代码与本地实测踩坑4.1 DFS 版本 C 代码下面是我在实际提交时使用的完整代码注释里标注了每个关键点。#include bits/stdc.h using namespace std; const int MAXM 105; vectorint g[MAXM]; bool vis[MAXM]; bool hasCow[MAXM]; void dfs(int u) { vis[u] true; for (int v : g[u]) { if (!vis[v]) dfs(v); } } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N, M; cin N M; int emptyCow 0; for (int i 0; i N; i) { int k; cin k; if (k 0) { emptyCow; continue; } vectorint langs(k); for (int j 0; j k; j) { cin langs[j]; hasCow[langs[j]] true; } // 把第一门语言与后面所有语言连边 // 这样该牛会的所有语言必然落在同一个连通块 for (int j 1; j k; j) { g[langs[0]].push_back(langs[j]); g[langs[j]].push_back(langs[0]); } } int cnt 0; for (int lang 1; lang M; lang) { if (hasCow[lang] !vis[lang]) { cnt; dfs(lang); } } int ans; if (cnt 0) { ans emptyCow; // 全员空手每头牛都得学一次 } else { ans cnt - 1 emptyCow; } cout ans \n; return 0; }代码本身不长核心就三块读入并建图、DFS 数连通块、套公式。这个实现有一个小优化同一头牛的语言列表里我选择“第一门语言与其余语言全部连边”。如果牛的 K 很大这样做会有 K - 1 条边改成相邻连边也能达到同样效果区别只是邻接表里边数更少。换成相邻连边版本时记得别把langs[0]漏掉保证列表里每个语言都能通过路径连到其他语言。4.2 并查集版本更短的另一种解法DFS 有递归虽然这题 M 只有 100 不会爆栈但如果你还没学到 DFS或者单纯想用更短的代码并查集版本更合适。思路完全一样读入时把同一头牛会的语言 union 到一起最后统计有效根节点个数。#include bits/stdc.h using namespace std; const int MAXM 105; int fa[MAXM]; bool hasCow[MAXM]; int find(int x) { return fa[x] x ? x : fa[x] find(fa[x]); } void unite(int a, int b) { a find(a), b find(b); if (a ! b) fa[a] b; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N, M; cin N M; for (int i 1; i M; i) fa[i] i; int emptyCow 0; for (int i 0; i N; i) { int k; cin k; if (k 0) { emptyCow; continue; } int firstLang; cin firstLang; hasCow[firstLang] true; for (int j 1; j k; j) { int lang; cin lang; hasCow[lang] true; unite(firstLang, lang); } } setint roots; for (int lang 1; lang M; lang) { if (hasCow[lang]) { roots.insert(find(lang)); } } int cnt (int)roots.size(); int ans; if (cnt 0) ans emptyCow; else ans cnt - 1 emptyCow; cout ans \n; return 0; }并查集版本有个好处不用显式建邻接表union 函数内部自动处理传递性。统计根节点时用 set 去重代码非常干净。两个版本时间复杂度都在 O(N·M M log M) 级别对本题来说随便过。4.3 我在提交/对拍时踩过的坑坑一忘了 hasCow 过滤。这个坑最隐蔽。如果直接从 1 到 M 把每个未访问语言都作为 DFS 起点没牛会的语言也会被当成孤立连通块计数。比如 M 100实际只有 3 门语言被使用cnt 会变成 100 而不是 3答案直接变成 99 或更大。我一开始就是这样测样例 2 没过才回头补的 hasCow 数组。教训是在做节点编号密集的图题时永远要问自己一句——“这个编号真的存在实体吗”。坑二对 K 0 的处理位置不对。有些写法是先cin k然后 for 循环读后续语言编号。如果遇到 K 0 就想当然地continue却没有先处理“跳过剩余读入”——其实 K 0 时后面本来就没有需要读的数据所以单纯continue是对的。真正会错的是另一种写法先读第一门语言再循环读剩下的遇到 K 0 时却直接去读第一门语言把下一头牛的 K 值当成语言编号读进 buffer导致后面所有输入全部错乱。USACO 的输入格式不复杂但这种低级读入错误最容易在临场时犯。稳妥起见永远先cin k再判断k 0再决定后面读不读。坑三递归 DFS 忘写访问标记。如果是第一次写 DFS 的人很容易写出只标记起点、不标记邻接点的代码导致同一个连通块被重复计数。实际上只要dfs开头把当前节点vis[u] true中间循环里再检查!vis[v]就不会有问题。这个小细节同样值得自查。坑四纠结“让哪头牛学哪门语言”。有时样例输出是对的但自己想不出一个具体的分配方案就开始怀疑公式。其实这道题不需要构造方案只要证明存在性就行。每次操作选连通块 A 的任意一头牛学连通块 B 的任意一门语言A、B 必然合并。所以公式正确性不依赖具体选哪头牛。最后我在实际做这道题时最大的体会有两方面。第一看到题面里有“通过别人传递”这类字眼第一反应应该是往图的连通性上靠而不是纠结于牛之间的两两关系。第二边界数据一定不能偷懒K 0 的空手牛和没人说的空语言是这题最容易翻车的地方。如果你刚入门图论建议亲手把第三节那四类边界数据都跑一遍再把这题过掉。之后遇到“最少需要多少次操作让所有节点连通”的题基本就是同一套建模思路换个故事背景而已。
返回列表