多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

CodeForces 1290C Prefix Enlightenment:带权并查集与异或约束建模

CodeForces 1290C Prefix Enlightenment:带权并查集与异或约束建模 CodeForces 1290C Prefix Enlightenment 是我刷过的题里面把每个灯最多出现在两个集合里这个条件用得最漂亮的一道。第一眼看到翻转灯和前缀两个词我本能地往差分、贪心、区间数据结构上去想结果研究了半天讨论区发现正解居然是一棵带权并查集而且代码量比我想象中小得多。如果你也卡在这题上大概率不是并查集不会写而是没有把选集合这件事抽象成变量。这篇文章就沿着我的思路展开先把题意压缩成选哪些集合的问题再讲清楚异或方程组是怎么被并查集维护的最后给一份可以直接提交的 Python 代码。1. 先把题意彻底翻译成人话1.1 题目在问什么以及那句最多出现在两个集合的分量题目本身不复杂。你有 n 个灯初始状态是一个 01 串1 表示亮0 表示灭。然后给你 k 个集合每个集合里装的是灯的编号。一次操作就是挑一个集合把这个集合里所有灯的状态全部反转亮变灭灭变亮。现在对于每个前缀位置 i你需要回答只用若干次操作能不能让第 1 到第 i 盏灯全部变亮并且总操作次数最少是多少。每个前缀是独立的问题也就是说你在回答前缀 i 的时候不用考虑前缀 i-1 的操作选择。这个描述本身没什么特殊的真正特殊的是后面这句每个灯最多出现在两个集合里。这句话几乎是整道题的命门。如果没有这个限制一盏灯可能属于很多个集合那它最终变亮与否就和很多个选不选集合的决定纠缠在一起问题就变成解一个一般性的 XOR 方程组得上高斯消元而且还要支持动态询问复杂度会非常难看。正因为每个灯最多关联两个集合每个灯带来的约束就只牵涉两个变量这才有可能用并查集这种结构去维护。我一开始没重视这句话先去想怎么模拟翻转走了不少弯路。后来才意识到操作的顺序根本不重要重要的是哪些集合被选择了奇数次。1.2 前缀询问为什么可以一边扫一边答还有一个容易让人困惑的点题目问的是每个前缀独立的最小值理论上你可以对每个前缀都重新求一次答案那复杂度肯定爆炸。但这里有个关键性质前缀 i 的约束只包含前 i 盏灯。当你扫描到第 i 个位置的时候你往约束系统里加入的恰好是前 i 盏灯各自产生的约束后边的灯一个都没加进来。所以扫描到第 i 位时当前这个约束系统描述的就是前缀 i 的全部限制。前缀 i1 只不过是在这个系统上再多加一条约束而已。这天然支持增量维护每处理一盏灯往并查集里加一条边同时把全局答案改一改输出当前的答案就是这个前缀的答案。很多题解没把这个扫描位置 约束集合大小的点讲透我在这里先把它点出来后面所有的代码都是围绕这个增量过程展开的。2. 核心建模每个集合变成一个 0/1 变量2.1 一盏灯的最终状态怎么写把每个集合看成一个有选择状态的变量x[j] 0表示不选集合 jx[j] 1表示选集合 j或者说这个集合被操作了奇数次题目里一次操作是翻转一个集合里的所有灯那么连续操作一个集合两次等于没操作所以我们在意的是操作次数的奇偶性。因此一个有 m 个灯、被集合 a 和 b 包含的灯 v它的最终状态是初始状态 s[v] ^ x[a] ^ x[b]如果灯 v 只属于集合 a那就是s[v] ^ x[a]如果哪个集合都不属于那它永远等于s[v]。这个式子其实不需要复杂的推导用异或看就行每选一次包含灯 v 的集合灯 v 的状态就翻转一次翻转偶数次等于没翻转。要让灯 v 最终是亮的也就是最终状态等于 1那么有s[v] ^ x[a] ^ x[b] 1移项一下得到灯 v 对变量提出的要求x[a] ^ x[b] 1 ^ s[v]我们把右边的值统一记为need它要么是 0要么是 1。每个灯 v 就对应一个约束方程方程左边是它所属的集合变量异或在一起。2.2 三种情况对应三类约束按灯属于几个集合分类正好有三种不属于任何集合没有变量参与到翻转里只有s[v] 1的时候这个灯才能亮。这种情况题目数据的保证会兜底代码里直接跳过。只属于一个集合 a约束退化成x[a] need也就是一个变量被固定为某个值。属于两个集合 a、b约束是x[a] ^ x[b] need。如果need 0要求两个变量相等如果need 1要求两个变量不等。所以整个问题就变成了有 k 个 0/1 变量有 n 条约束每条约束要么把一个变量固定成某个值要么连接两个变量要求它们相等或不等。然后要给每个前缀求一个满足所有约束的赋值方案使得被选中的集合数量也就是值为 1 的变量数量最小。建模到这里题目的灯已经完全不重要了剩下的就是一个纯代数问题。2.3 一组约束的几何直观带着权值的图如果我们把每个集合看成一个点那么每条两个变量的约束就是两个点之间的一条边边上的权是need代表两个端点取值是否相同。一条固定变量 a 为 val的约束可以理解成点 a 和一个特殊常量点之间有一条边这个常量点的值永远是 0。带权并查集能维护的核心事实是一个连通块内部只要确定了根节点的值其他所有节点的值就全部确定了。这是因为每条边都给出了两个点之间的异或关系沿着树边走就能推出任意点的值。如果加入一条边之后形成了环并且环上的异或和不为 0那就产生矛盾说明这组约束无解。这道题还要求统计代价所以光维护关系不够还得在每个连通块里记下根取 0 或者取 1 时块内有多少个变量必须取 1。3. 带权并查集维护相等/不等并同时算代价3.1 dis 数组的语义和路径压缩普通的并查集只能维护两个点是否在同一集合但在本题里我们还需要知道两个点之间是相等还是不等所以要用带权并查集。这里的权是一个 0/1 值。我习惯这么定义fa[x] : x 的父节点 dis[x] : x 与 fa[x] 的取值异或即 x fa[x] ^ dis[x]也就是说如果dis[x] 0x 和它父亲取值相同如果dis[x] 1x 和它父亲取值相反。路径压缩之后dis[x]要更新成 x 与根节点的取值异或。递归版路径压缩的关键代码是def find(x): if fa[x] x: return x r find(fa[x]) dis[x] ^ dis[fa[x]] fa[x] r return r这里容易写错的点是顺序必须先递归调用find(fa[x])此时dis[fa[x]]已经被更新成旧父节点到根的异或值然后再把原来的dis[x]异或上去得到的才是 x 到根的值。如果先更新fa[x]再取dis[fa[x]]拿到的就是新父节点的值会出错。3.2 合并两个连通块时的推导现在处理一条约束x[a] ^ x[b] c。假设经过find之后a 的根是rab 的根是rb同时有x[a] dis[a] ^ x[ra] x[b] dis[b] ^ x[rb]把这两行代入约束dis[a] ^ x[ra] ^ dis[b] ^ x[rb] c整理成根之间的关系x[ra] ^ x[rb] dis[a] ^ dis[b] ^ c记d dis[a] ^ dis[b] ^ c含义就是x[rb] x[ra] ^ d。合并时把rb的父节点设成ra同时把dis[rb]设成d即可fa[rb] ra dis[rb] d这一步推导是整个题解的核心很多代码直接写了unite(a, b, c)但如果不理解 d 是怎么来的写错一个符号都很难查。我强烈建议自己推一遍这个式子。3.3 连通块的代价怎么算cnt 与 fixed光有关系还不够还得在每个时刻知道当前约束系统的最小操作次数。对每个连通块我们记录以根为基准的两种代价cnt0[root]根取 0 时这个连通块内有多少个变量必须取 1cnt1[root]根取 1 时这个连通块内有多少个变量必须取 1因为每个变量取 1 就意味着对应的集合被选择了要付 1 次操作代价。初始化时每个集合单独成一个连通块根就是它自己cnt0[i] 0根取 0 时自己取 0代价 0cnt1[i] 1根取 1 时自己取 1代价 1合并两个连通块时假设新根是ra关系是d dis[rb]。如果d 0那么rb与ra同值如果d 1则反值if d 0: cnt0[ra] cnt0[rb] cnt1[ra] cnt1[rb] else: cnt0[ra] cnt1[rb] cnt1[ra] cnt0[rb]这个式子的意思很直观当新根取 0 时rb的值已经由d唯一确定rb所在块内所有取 1 的变量数就是对应那一项的cnt直接累加。此外还要处理固定值约束。初始化时给每个根记一个fixed[root]取值为-1/0/1-1表示根的值还没被确定0或1表示根被固定成这个值当固定约束出现时我们先找到a的根root要固定变量a为val。由x[a] dis[a] ^ x[root]可得根的值应该是val ^ dis[a]于是调用set_root_fix(root, val ^ dis[a])。一个连通块的贡献是这样算的cost(root) cnt0[root] 如果 fixed[root] 0 cnt1[root] 如果 fixed[root] 1 min(cnt0, cnt1) 如果没有固定固定值的意义在于一旦根被固定这个连通块就失去了自由选择的余地代价不再取 min而是取被固定那一种的值。全局答案就是所有根cost的和。4. 逐个前缀增量更新答案是怎么顺出来的4.1 灯属于 0 个、1 个、2 个集合时的处理现在从左往右扫灯每扫到一盏灯就往并查集里加这条灯带来的约束然后立刻输出当前全局答案。设当前灯 i 的初始状态是s[i]先算need 1 ^ s[i]也就是灯要变亮需要关联的集合变量异或出什么值。三种情况belong[i]为空灯不受任何集合控制。如果s[i] 1它本来就是亮的什么也不用做如果s[i] 0那它永远没法亮。题目数据保证每个前缀都有解所以这种情况要么不出现要么灯本来就是亮的代码里跳过即可。belong[i]只有一个集合 a调用set_root_fix固定变量 a 的值为need。belong[i]有两个集合 a、b调用unite(a, b, need)让x[a] ^ x[b] need。注意这里不用真的去管第 i 盏灯后面的灯因为后面的约束还没加进来这个系统恰好就是前缀 i 的约束系统。4.2 合并时的全局答案更新公式全局答案不是每次重算的而是在合并操作里增量维护。每次unite(a, b, c)之前先减去两个根各自的costans - cost(ra) cost(rb)合并完之后再把新根的cost加回去ans cost(ra)set_root_fix也一样先把该根的旧cost减掉更新fixed之后再加新cost。这样每次操作的更新量是 O(1) 的加上并查集本身的find代价整道题的复杂度就是 O((nk) α(nk))。这个先减后加的思路很基础但特别容易忘。我一开始只想着维护cnt忘了更新全局ans结果样例都过不了。4.3 手推一个 3 灯 2 集合的完整样例拿一个小样例完整走一遍比空谈公式直观得多。n 3, k 2 s 010 S0 {1, 2} S1 {2, 3}三个灯带来的约束灯 1只在集合 S0s 0所以x0 1灯 2在 S0、S1s 1所以x0 ^ x1 0灯 3只在集合 S1s 0所以x1 1初始化后每个集合都是独立根cnt0 0, cnt1 1每个根的fixed -1所以每个根的cost min(0, 1) 0全局ans 0。处理灯 1固定x0 1根是 0先减cost(0) 0设置fixed[0] 1再加cost(0) cnt1[0] 1ans变成 1输出 1处理灯 2约束x0 ^ x1 0ra 0, rb 1先减cost(0) cost(1) 1 0 1ans变成 0计算d dis[0] ^ dis[1] ^ 0 0合并后cnt0[0] 0 0 0cnt1[0] 1 1 2此时fixed[0]仍然是 1所以新根cost(0) cnt1[0] 2ans变成 2输出 2对应x0 1, x1 1两个集合都被选代价 2处理灯 3固定x1 1find(1)得到根 0且dis[1] 0也就是x1 x0需要固定根 0 的值为need ^ dis[1] 1 ^ 0 1fixed[0]已经就是 1先减cost(0) 2再加回2ans不变输出 2最终答案是1 2 2和预期完全一致。5. 完整实现与提交前检查5.1 代码逐段拆解下面是我整理的完整 Python 提交版。为了在 Codeforces 上稳妥运行我把递归深度开大了一点并查集的 find 用的是递归写法路径压缩后深度基本是常数不会真的递归到底。import sys sys.setrecursionlimit(300000) def solve(): input sys.stdin.readline n, k map(int, input().split()) s input().strip() belong [[] for _ in range(n)] for i in range(k): arr list(map(int, input().split())) for v in arr[1:]: belong[v - 1].append(i) fa list(range(k)) dis [0] * k cnt0 [0] * k cnt1 [1] * k fixed [-1] * k def find(x): if fa[x] x: return x r find(fa[x]) dis[x] ^ dis[fa[x]] fa[x] r return r def cost(root): if fixed[root] 0: return cnt0[root] if fixed[root] 1: return cnt1[root] return min(cnt0[root], cnt1[root]) ans 0 def set_root_fix(root, val): nonlocal ans ans - cost(root) if fixed[root] -1: fixed[root] val # 如果 fixed[root] 已有值且与 val 不同说明矛盾 # 本题数据保证不会发生所以这里不额外处理 ans cost(root) def unite(a, b, c): nonlocal ans ra, rb find(a), find(b) if ra rb: return ans - cost(ra) cost(rb) d dis[a] ^ dis[b] ^ c fa[rb] ra dis[rb] d if d 0: cnt0[ra] cnt0[rb] cnt1[ra] cnt1[rb] else: cnt0[ra] cnt1[rb] cnt1[ra] cnt0[rb] if fixed[rb] ! -1: req fixed[rb] ^ d if fixed[ra] -1: fixed[ra] req # 同理冲突数据不会出现 ans cost(ra) res [] for i in range(n): need 1 - (ord(s[i]) - 48) if len(belong[i]) 0: pass elif len(belong[i]) 1: a belong[i][0] ra find(a) set_root_fix(ra, need ^ dis[a]) else: a, b belong[i][0], belong[i][1] unite(a, b, need) res.append(str(ans)) print( .join(res)) if __name__ __main__: solve()这份代码的输入格式是第一行 n、k第二行 s接下来 k 行每行是集合大小加集合内灯的编号。集合编号从 0 开始灯的编号从 1 开始读入时要减一。5.2 三个容易踩的坑第一个坑是dis数组的含义混淆。不同人的写法定义可能相反有的是x 到父节点的异或有的是父节点到 x 的异或。这本身无所谓但一旦确定了就得在每个合并公式里保持一致。我推导的时候用的是x fa[x] ^ dis[x]合并公式d dis[a] ^ dis[b] ^ c是配合这个定义算出来的如果你按别的博客的代码抄但find里的更新顺序不同很可能就错了。第二个坑是固定值合并时忘记处理。两个连通块合并的时候如果rb那边已经有fixed值这个固定信息不能丢要换算成新根的固定值也就是req fixed[rb] ^ d。我第一版代码只合并了cnt和fa没管fixed结果某个测试点里前缀答案突然少了一块代价。第三个坑是遇到ra rb直接返回时没有检查这条约束是否真的满足。如果dis[a] ^ dis[b] c那这条边在环上没有矛盾返回没问题如果不等于c说明这个前缀无解。题目数据保证每个前缀有解所以这里可以直接返回。但为了调试方便我建议在本地写一个断言万一自己造数据造出矛盾能第一时间发现。5.3 复杂度分析与能过的常数复杂度在上文已经说过每个灯最多触发一次find加一次合并并查集操作近似 O(1)所以总复杂度是 O((n k) α(nk))空间 O(n k)。n 和 k 都是 3e5 量级Python 在这道题上是能过的。从常数角度提两个小优化。第一别在循环里重复len(belong[i])这种调用一次取出来存成变量第二ord(s[i]) - 48比int(s[i])快因为省去了字符串解析的开销。这些在 3e5 的数据量下看起来微不足道但 Codeforces 的 Python 时间限制不会给太多余地能省一点是一点。6. 这类题的识别信号与延伸思考6.1 什么时候应该想到带权并查集经过这题你会发现带权并查集的适用场景有很强的信号特征。最显眼的就是每个元素最多出现在两个集合里这句话。它本质上是告诉你每个约束方程最多涉及两个变量。当约束方程都是x_a ^ x_b c这种二元形式时你完全可以把方程看成图上的边把维护方程组是否有解、求某个最优解转化成动态维护图的连通性和边权一致性。另一个信号是题目要求多组前缀询问但每个前缀只比前一个多一条约束。这几乎是在明示你可以增量维护每次加一条边更新一下全局统计量。如果题目改成任意区间询问那就需要可撤销并查集或者离线分治难度会高一个档次。6.2 与 2-SAT、XOR 方程组的关系把这道题和 2-SAT 放在一起看很有意思。2-SAT 的子句是两个文字用或连接而这里的两条约束是两个变量异或等于 0 或 1。它们都是二元的但语义不同。其实这道题更本质的模型是 GF(2) 上的线性方程组x_a x_b c (mod 2)因为每个方程只涉及两个变量所以可以用图来建模并查集维护的就是沿着路径异或起来是否一致。这也是为什么它不需要高斯消元高斯消元适合变量之间约束稠密的情况而这里方程数虽然多但每个方程的度数只有 2图是稀疏的并查集是天然适配的数据结构。更进一步说如果题目改成每个灯最多出现在三个集合里那约束就变成三元异或并查集就不够用了。三元异或约束对应的是三维向量空间的线性相关性问题维护起来复杂得多。所以最多两个集合这个看似不起眼的限制直接决定了算法的选型。6.3 一点个人经验我自己刚做这题的时候第一反应是建图跑 2-SAT因为每个灯最多两个集合很像 2-SAT 里每个变量出现两次的样子。后来发现 2-SAT 求的是存在性而这题要的是带权的最小代价还得支持前缀增量于是又卡了一阵。最后看懂了带权并查集的做法有种原来如此的感觉。说实话这题最值的不是并查集本身而是那种把题目条件翻译成变量方程的思维方式。后面我遇到不少题都会先问自己一句题目里的每个对象到底在约束什么这个约束是几元的如果是二元的很多问题都能用图或者并查集解决。顺手一提这场 Div.1 的 F 题 Artistic Partition 也是我觉得很值得琢磨的题和本题是完全不同的两个方向有空我们可以再单独聊。这题如果只是看懂题解可能过几天就忘但如果自己动手把推导走一遍、把样例手推一遍再改几处代码试试错误会发生什么它的价值才能真正长在你身上。
返回列表