ARTICLE DETAIL

资讯详情

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

IOI 2014交互题P5884:用并查集与拖延合并策略破解连通性

IOI 2014交互题P5884:用并查集与拖延合并策略破解连通性 去年备战省选的时候我刷到一道很有意思的交互题——IOI 2014的game洛谷编号P5884。这道题让我卡了整整一个晚上但想明白之后特别爽因为这题考察的不是板子而是对“连通性”本质的理解。今天把它整理出来希望能帮到同样被交互题折磨的同学。这道题的核心在于你不是去“求”一个答案而是“构造”一组答案。交互器会不断问你“这条边存在吗”你要决定回答存在还是不存在但最终所有回答为“存在”的边必须让图连通。难就难在你要让交互器在整个过程中始终无法确定图到底连不连通直到最后一个问题结束。先说结论这道题的解法是用并查集维护连通块再用一个二维计数矩阵记录“两个连通块之间还剩多少条边没问”。每次询问时如果两个点已经连通直接返回true如果还没连通只要两个块之间还有没问过的边就返回false只有当两个块之间所有边都问完了才不得不返回true并合并。这个思路叫作“拖延合并”我觉得是交互题里非常经典的信息控制手段。下面我会从题目本身的拆解开始一步步把思路推导出来再给出完整的C实现和调试心得。中途会穿插一些我实际踩过的坑希望能让你少走弯路。1. 题目到底在问什么1.1 交互题的玩法P5884这道题不是传统意义上的“给定输入输出答案”而是你写一个程序跟评测系统中的交互器进行对话。交互器会调用你实现的函数你返回一个布尔值交互器根据你的返回值继续提问。通常情况下题面会要求你实现下面两个函数void init(int n)游戏初始化告诉你图里有n个点点编号是0到n-1。bool addEdge(int u, int v)交互器询问点u和点v之间是否存在一条边你需要返回true或false。评测时交互器会以某种顺序询问所有可能的点对。也就是说n个点之间最多有n*(n-1)/2条边每一条边都会被问一次顺序不确定。你的每次回答会被记录下来最终形成一张由所有true回答组成的图。交互器会在每次得到回答后检查根据目前已经揭示的边它是否已经能确定整张图一定是连通的。你的任务是让它在最后一次回答之前始终无法确定这一点。等到所有边都问完之后由你所有true回答组成的图必须是一张连通图否则任务失败。1.2 我们要达到的目标很多人第一次看到这题会懵因为它跟平时做的图论题完全不同。平时我们是“给定图判断连通性”这里是“自己构造图但要让别人猜不到连通性”。我把这题的目标拆成两个层面保证最后图连通。这是硬性条件你回答的所有true边必须能把n个点串成一个整体。如果最后还有孤立的连通块直接失败。全程隐藏连通性。在最后一次addEdge调用之前交互器根据已有信息不能推出“图一定连通”。也就是说它不能得到充分证据证明整个图已经是一个连通块。这两条看着矛盾其实不矛盾你需要在最后一刻才让所有信息完整。在这之前你始终要留一手让整个图看起来像是“可能连通也可能不连通”。1.3 一个小例子直观理解假设n3有三个点A、B、C交互器会问三条边AB、AC、BC顺序随机。如果交互器先问AB你回答true那么此时交互器知道A和B是连通的但它不知道C是否也连进来所以它不能确定整张图是否连通。接下来问AC如果你又回答true那么A、B、C三个点都已经连通了。交互器立刻就能确定整张图一定连通这样你在最后一条边BC还没问完之前就已经暴露了游戏失败。正确的策略是只要还能拖就尽量回答false。比如AB回答trueAC回答falseBC问的时候再回答true。这样一来BC成为连接C与{A,B}的最后一条线索。在BC被问之前交互器最多知道AB存在AC不存在但它不知道BC是否存在因此无法判断C是否最终会连进这个大块里。直到BC被问完它才第一次确定整张图连通。这就是“拖延合并”的直观含义能不加边就不加边直到两个块之间再也没有其他可能连通的边了再被迫加一条。2. 破解题眼怎么让交互器一直猜不透2.1 从结果倒推最后图必须连通我们先假设游戏已经进行到后期交互器把所有边都问完了。此时true边的集合必须让图连通也就是说任意两个点之间都能通过true边到达彼此。这意味着在问边过程中虽然你不能让交互器提前确定连通但最终所有点必须被“合并”进同一个连通块。换句话说你的决策过程本质上是在维护若干连通块并把它们一步步合并只是每次合并都尽量延迟到不得不合并的时候。那什么时候是“不得不合并”对于两个互不相连的连通块X和Y如果X和Y之间的所有可能边都已经问过并且除了当前这条边之外其他边你都回答了false那么当前这条边就是X和Y之间唯一的潜在连接。如果连它都回答falseX和Y就彻底没有边相连了最终图一定不连通。所以这时你只能回答true让X和Y合并。2.2 拖延合并的核心思想整个算法的核心可以概括为一句话对于任意两个当前不连通的连通块只要它们之间还有没询问过的边就永远回答false只有当它们之间所有边都问完了才回答true并合并。为什么这样做能保证交互器无法提前确定图连通因为交互器想要确定整张图连通就必须知道“不存在拦在中间的孤立块”。但只要两个连通块之间还有没问过的边交互器就无法确定它们最终会不会通过这条边连起来。它看到的只是“目前没连但以后可能连”所以无法下结论。举个例子如果交互器已经确认了三个连通块A、B、C而A和B之间还有一条边没问那么即使B和C已经连通交互器也无法确定A最终会不会并进来。除非所有块之间已经没有未问边了否则它永远留有悬念。而我们通过“返回false”来减少两个块之间的未问边数同时不真正让它们连通。这相当于在消耗“缓冲边”只要还有缓冲边两个块就能继续保持分离状态而不暴露最终结论。一旦缓冲边耗尽就必须合并一次把两个块变成一个块然后继续重复这个过程。2.3 为什么返回false不等于永远不连可能有人会问如果我一直返回false最后两个块之间所有的边都问完了难道不会让交互器知道这两个块之间“没有边”从而推断出它们永远不连通吗这里要分清楚“确定不连通”和“确定连通”的区别。交互器的目标不是判断每对点之间是否有边而是判断整张图是否连通。即使交互器知道块X和块Y之间没有直接边它也无法确定X和Y是否通过中间的其他块间接连通。因为你之前可能已经让X与某些块连通Y也与某些块连通只要存在一条经过其他块的路径X和Y依然可能在同一个连通块里。所以当你对一条边回答false时交互器只是排除了“X和Y通过这条边直接相连”的可能但无法排除“X和Y通过其他路径相连”的可能。这就是你隐藏信息的关键空间。随着游戏推进你的true边会慢慢把所有点连成一个整体。当你回答true并合并两块时交互器会知道这两个块连通了但只要还存在其他分离的块或者还存在未问的块间边它就无法确定整张图的连通性。拖延合并策略恰恰保证了你每次合并之前两个块之间的所有缓冲边都已经耗尽合并之后立刻进入下一轮“拖延”。3. 算法设计与复杂度分析3.1 用并查集维护已确定连通块既然要维护“哪些点已经通过true边连通”最自然的数据结构就是并查集。每次addEdge返回true时如果两个点不在同一个集合里就把它们合并。返回false时不用改并查集因为false表示这条边不存在不影响连通关系。并查集的实现只需要路径压缩不需要按秩合并因为n最多1500左右数据规模不大。路径压缩加上循环合并不会成为瓶颈。这里有一个容易搞混的点返回true的时刻有些是两个点已经在同一个集合里有些是两个集合之间的最后一条缓冲边。前者不需要合并操作后者需要合并。两者都返回true但在内部处理上要区分开。3.2 用计数矩阵记录两个块之间还剩几条边没问光靠并查集还不够因为你需要知道两个连通块之间还有多少条边没问。如果只用一个并查集你无法判断当前这条边是不是两个块之间最后一条边。所以我开了一个二维数组cnt[i][j]表示“以i为根的连通块”与“以j为根的连通块”之间还有多少条边没有被询问。注意这里i和j都必须是并查集的根节点否则计数会乱套。初始化时任意两个点i和j之间都有一条可能的边所以cnt[i][j] 1。自己到自己不需要所以对角线设为0。为什么用二维矩阵而不是哈希表因为n不大。n1500时矩阵大小为1500×1500约225万个元素每个元素int类型占4字节内存不到9MB完全没问题。如果n到10万级别这个方案就不适用了需要换更复杂的数据结构但在本题的限制下二维矩阵是最简单直接的选择。每次addEdge询问(u,v)时先找到u所在块的根fuv所在块的根fv。如果fu fv说明两个点已经确认连通直接返回true即可。如果fu ! fv就看cnt[fu][fv]如果cnt[fu][fv] 0说明这两个块之间还有没问过的边。此时返回false并把cnt[fu][fv]减1。同时要减对称的那一侧cnt[fv][fu]因为矩阵是对称的。如果cnt[fu][fv] 0说明这两个块之间所有边都问完了且之前都返回了false。为了最终图连通当前这条边必须返回true并合并fu和fv这两个块。3.3 合并操作的正确姿势合并两个连通块时不能只改并查集的father数组还要更新计数矩阵。假设把fv合并到fu那么新的连通块根为fu原来的fv不再作为根存在。对于任意其他根kk不等于fu也不等于fv原本fu与k之间的未问边数加上fv与k之间的未问边数就是新块与k之间的未问边数。写成代码就是for (int k 0; k n; k) { if (k ! fu k ! fv) { cnt[fu][k] cnt[fv][k]; cnt[k][fu] cnt[fu][k]; } }注意cnt是对称矩阵所以更新一侧之后另一侧也要同步更新。合并完成后最好把fv那一行一列清零防止后续误用已经失效的根。还有一个细节为什么合并时不需要考虑cnt[fu][fv]因为在返回true之前这个值已经为0了正是因为它为0我们才决定合并。合并后它们属于同一个块不再有“块间未问边”的概念所以不用管。3.4 复杂度为什么能扛住我们来算一下复杂度。初始化二维数组O(n^2)。每次addEdge调用find路径压缩后近似O(α(n))。每次返回false只有一次计数器减1O(1)。每次合并遍历一次所有点更新cntO(n)。最多合并n-1次因为连通块数量从n降到1每次合并减少一个块。所以合并总复杂度O(n^2)。整体最坏情况下交互器询问O(n^2)条边每条边O(α(n))加上合并O(n^2)总复杂度O(n^2)。对于n1500这个规模非常轻松。在洛谷上跑起来基本是秒过。内存方面二维数组约9MB加上并查集等小数组完全在OJ内存限制内。如果你用vectorvector 也不要紧但要注意vector的分配开销最好一次性resize到位。4. C代码实现与逐段讲解4.1 完整可提交代码下面是我实践后整理的完整代码可以直接在洛谷P5884提交。#include bits/stdc.h using namespace std; static int n; static vectorint fa; static vectorvectorint cnt; int find(int x) { if (fa[x] x) return x; return fa[x] find(fa[x]); } void init(int N) { n N; fa.resize(n); cnt.assign(n, vectorint(n, 0)); for (int i 0; i n; i) { fa[i] i; for (int j 0; j n; j) { if (i ! j) cnt[i][j] 1; } } } bool addEdge(int u, int v) { int fu find(u); int fv find(v); if (fu fv) return true; if (cnt[fu][fv] 0) { cnt[fu][fv]--; cnt[fv][fu]--; return false; } // 两个块之间所有边都问完了必须合并 fa[fv] fu; for (int k 0; k n; k) { if (k ! fu k ! fv) { cnt[fu][k] cnt[fv][k]; cnt[k][fu] cnt[fu][k]; } } // 清空失效根 fv 的行列防止误用 for (int k 0; k n; k) { cnt[fv][k] 0; cnt[k][fv] 0; } return true; }4.2 关键函数细节拆解第一处需要注意的地方是init里的初始化。很多人会忘记把对角线cnt[i][i]设为0或者把所有位置都设成1。对角线如果不设0后续如果出现fu fv的判断不会去访问cnt[fu][fv]所以一般不会出错但为了语义清晰还是要设为0。第二处是addEdge的分支逻辑。先判断fu fv这是最简单的情况直接返回true。需要注意返回true并不意味着新增了一条“有价值”的边因为这两个点本来就已经在同一个连通块里这条边对整体连通性没有任何贡献。但是返回true是安全的因为交互器已经知道这两个点连通这条边是否存在不会让它获得新的连通性信息。第三处是cnt[fu][fv] 0的分支。这里一定要同时更新两个对称位置否则会导致后续计数不对称出现合并时机错误。我最初写的时候只减了cnt[fu][fv]结果后面出现负数查了半天才发现是漏了对称位置。第四处是合并时的更新逻辑。合并的核心是把fv的信息合并到fu上。这里用cnt[fu][k] cnt[fv][k]再同步cnt[k][fu]。注意要跳过k fu和k fv否则会把不该加的值加进去尤其是k fv时cnt[fv][fv]根本没有意义。4.3 关于交互题本地怎么调试的提醒交互题在本地调试跟普通题目不太一样。普通题目你写好main函数读文件算答案交互题则要求你实现的函数被评测系统的交互器调用。在本地你需要自己写一个模拟交互器或者写一个简单的main函数模拟不断调用addEdge的过程。我建议你在本地这样调试写一个main函数把init(3)调用一次然后按某种顺序调用addEdge最后检查所有返回true的边能不能构成连通图同时观察每次调用后交互器是否已经能确定连通。你甚至可以写一个辅助函数用并查集把所有true边加进去如果某次调用后并查集只剩一个连通块就说明交互器此时已经能确定连通了你的策略在这一步就失败了。不过一定要记住你本地写的main函数只是测试用提交时不能带main函数否则会因为重复定义而编译失败。这是交互题最常见的CE原因之一。另外洛谷的交互题一般会要求你只提交init和addEdge这两个函数不会让你提交完整程序。具体以题面为准。如果你不确定可以先看题面的“交互方式”说明或者翻一翻该题的讨论区。5. 我踩过的坑与排查建议5.1 忘记维护对称计数导致结果错乱这是我最开始犯的错误。我写完第一版代码后在本地模拟简单用例发现有时候交互器过早判断出连通。查了很久才发现我只在cnt[fu][fv] 0时减了cnt[fu][fv]忘记减cnt[fv][fu]。后续在合并更新时两个方向的计数不一致导致某些块之间明明还有未问边却被错误地判断为0提前合并。排查方法很简单在每次addEdge调用后用一个双重循环检查cnt[i][j]和cnt[j][i]是否相等。如果发现不对称就能定位到哪个更新分支出了问题。这个对称性在算法中非常重要因为后面所有判断都依赖它。5.2 合并时把根搞错顺序合并操作中我一开始写成fa[fv] fu这个方向没问题。但后面更新cnt时我用的依然是原来的fu和fv这时fv已经不是根了但作为数组下标还是那个数字。问题不大只要你在合并之前把fu和fv都存下来后面继续用这两个值就行。但如果你在合并前去调用find返回值可能会变因为并查集的结构已经改了。所以合并之前一定要先把两个根保存到局部变量里后面所有对cnt的操作都用这两个局部变量不要再动态调用find。5.3 初始化时把对角线也设成1另一个容易犯的低级错误是初始化cnt时用双重循环把所有位置都赋1忘记跳过对角线。这样会导致cnt[i][i] 1。虽然逻辑上fu fv时不会访问它但如果你后续调试时打印矩阵看到对角线为1会很疑惑。更有潜在风险的是如果某个特殊分支不小心访问了cnt[fu][fu]就会把本来不存在的“自环未问边”当成一条待问边可能引发错误。我的建议是初始化时明确用if (i j) cnt[i][j] 0; else cnt[i][j] 1;把边界情况写清楚不给任何模糊空间。5.4 交互题常见CE和RE原因交互题的编译错误和运行错误往往不是算法问题而是提交格式问题。我整理了一个速查表错误类型常见原因解决方法CE提交了带main的完整程序只提交init和addEdge函数CE没包含必要的头文件使用#include bits/stdc.hRE数组越界检查是否访问了cnt[fv][fv]等无效位置RE并查集路径压缩递归过深n很小一般不会但可以改循环findWA合并后未清空fv行的信息合并后把fv行列清零WA计数器更新不对称每次更新cnt要同步对称位置6. 从这题能带走的通用套路6.1 交互题的“信息延迟”思想P5884给我的最大启发是交互题里的信息控制思维。普通算法题你只管算答案但交互题要求你考虑“你的每一步回答会给对方多少信息”。很多交互题的难点不在数据结构而在于如何设计回答策略让交互器始终无法通过已获得的答案推出你想隐藏的事实。这题里的“拖延合并”其实是一种通用的延迟策略只要存在替罪羊缓冲边就不要暴露真实状态直到所有退路都堵死再亮出底牌。你可以把这个思路迁移到其他交互题里比如“猜数字”“猜图结构”“猜排列”这类问题。6.2 类似题目推荐和变形思考如果你对这类交互题感兴趣可以找一些同样考察连通性判断和信息隐藏的题目练手。洛谷上还有其他交互题难度不同但核心都是有策略地构造答案。变形思考方面如果把n放大到10^5二维数组cnt就存不下了。这时可以考虑用哈希表只记录仍然活跃的连通块之间的计数或者在合并时用小集合合并到大集合的启发式合并来优化。不过复杂度分析会复杂很多并不适合入门。作为进阶练习你可以思考如果交互器不是询问所有边而是只问一部分边策略要怎么调整这时你需要额外记录哪些点对永远不会被问到避免在初始化时把它们当成缓冲边。最后再分享一点个人经验我刷这题时最大的感悟是交互题一定要先用小数据手动模拟把交互器的视角代进去。你不要把自己当成回答者而要当成那个正在猜图的交互器想想它看到你的false和true之后能不能下结论。一旦你用这种视角去看代码很多看似复杂的策略就变得理所当然。如果你在实现过程中遇到了奇怪的问题优先检查计数器的对称性和合并后的清空操作这两个地方是这题最容易出错的位置。希望这篇东西能让你少踩几个坑顺利把P5884拿下。
返回列表