ARTICLE DETAIL

资讯详情

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

洛谷P3621风铃题解:位掩码树形DP破解APIO2007

洛谷P3621风铃题解:位掩码树形DP破解APIO2007 洛谷P3621这道题我在信奥训练里卡了一整个晚上。题目名字叫“风铃”APIO2007的题C实现看着是个树形结构实际上如果你只盯着铃铛的深浅和大小去模拟摆放基本会把自己绕晕。这篇博文我直接把这道题的建模过程、位掩码DP思路和完整C代码全部拆开讲特别是那个“三段连续块”的核心洞察理解了它代码30分钟就能写出来。建议刷树形DP和DFS合并类题目的同学重点看尤其是容易被“无解判定”卡住的选手。1. 题目定位与核心思想1.1 这道题到底在考什么APIO2007的P3621风铃表面是一道模拟题实际上是一道非常典型的“树形结构 区间划分 兄弟子树交换”问题。题目会给你一棵树树的每个叶子节点对应一个铃铛铃铛拥有“大小”和“深浅”两个属性。你可以做的操作只有一个对于一个节点的左右两个分支你可以把整个分支交换位置。你要判断经过有限的交换之后能不能让整个风铃达到题目要求的“不碰撞”形态如果可以还要输出最少的交换次数。这里最迷惑人的地方在于你以为你是在处理铃铛的物理位置实际上你处理的是叶子的“排序区间”。因为交换兄弟子树本质上是把一棵子树的全部叶子作为一个整体搬到另一棵子树的左边或右边。叶子之间的相对顺序可以被改变但限制是一棵子树内部的叶子在最终序列里必须占据连续的一段。想通了这点题目就从“模拟风铃”变成了“判断二叉树能否通过交换兄弟子树使得所有叶子的段号按全局连续分布”。1.2 建模风铃其实就是一棵二叉树先看操作。每一次操作你只能选一个节点交换它下面的两条链。注意这个操作不会改变任意一条链内部的铃铛顺序它改变的只是两个“子树块”之间的左右位置。所以从算法角度整棵风铃就是一棵二叉树每个内部节点都有两个儿子儿子可能是一个单铃铛也可能是一棵子树。交换操作就是swap两个儿子的位置。这个操作有几个重要性质。第一两个叶子的LCA越深它们之间的相对顺序越容易调整第二如果两个叶子在树上是祖先关系也就是其中一个挂在另一个的下面那么它们的相对先后顺序是永远改不了的。上面这一点是后面判断无解的关键。不过只靠这个性质还不够因为我们要求的不只是任意两个叶子能排成某个顺序而是整棵子树的叶子集合在最终序列里必须抱成一团。因为任何交换都是以整棵子树为单位你不可能把一个子树内部的叶子拆开再和外部叶子交错排列。所以建模出来的问题就是给定一棵二叉树每个叶子上有一个“段号”你能不能通过若干次交换兄弟子树使得每个节点的子树叶子集合都对应全局序列中一个连续区间并且最终全局序列中所有段号按0、1、2或者题目规定的顺序排列。如果能求最小交换次数。到这里风铃的物理形态已经完全不重要了。1.3 目标序列的三段划分原题中铃铛有两个属性为什么排序目标可以被压缩成三段因为最终合法的风铃形态里铃铛类型在深度方向上的分布是有限制的。简单说所有满足某种组合的铃铛必须集中在一个连续区间所有满足另一种组合的必须集中在另一个区间依此类推。这个限制让叶子最终可以映射成有限的几个“段号”。我处理的时候直接根据题目要求的最终合法形态把每个叶子映射成一个整数segseg只可能有0、1、2三种取值。映射规则完全由题目给定的两个属性决定你可以在读入的时候用一个函数搞定。比如我当时的映射是某些组合归为前段0某些归为中段1剩下的归为后段2。注意这里不用关心这三个段具体代表什么物理含义它们存在的意义只有一个最终全局序列必须按段号0、段号1、段号2的顺序排列而且每个段内部的叶子必须连续在一起。有了这一步后面所有关于铃铛属性的讨论都可以扔掉了。2. 掩码DP设计详解2.1 为什么用位掩码表示子树DFS返回什么信息是这道题的关键设计。我们要判断一棵子树能不能在最终序列里连续以及它占的是哪些段。最容易想到的是返回一个区间[l, r]表示这棵子树的叶子覆盖了段l到段r。但是区间的判断在合并时很容易漏掉一种情况同一段叶子分别出现在两个子树里。比如左子树有段1的叶子右子树也有段1的叶子从区间上看可能有交集但实际上这两个区域的叶子最终都必须合并成一个连续块而两块又不能互相穿插就会出现结构冲突。位掩码更合适。我用3位二进制表示一棵子树覆盖了哪些段第0位表示包含段0的叶子第1位表示包含段1第2位表示包含段2。一棵子树合法要求它的掩码必须在二进制表示下是连续的也就是掩码只能是001、010、100、011、110、111这六种之一。这个掩码除了描述“覆盖哪些段”还隐式描述了子树的叶子在全局序列中的位置范围。后续合并两个儿子时只要把两个掩码做并集再检查并集是否连续、两个掩码是否有重叠就能判断这一层能否调整到位。2.2 三类合法掩码与非法情况3位二进制一共有8种掩码但合法掩码只有6种001、010、100、011、110、111。不符合的有0空子树和101覆盖了段0和段2中间缺段1。为什么101一定非法因为如果一棵子树同时包含段0和段2那它的叶子在最终序列里必须占据一块连续区域这块区域从段0延伸到段2中间必然要跨过段1。但段1的叶子不在这个子树里而在别的子树里那别的子树就只能被迫插进这块区域的中间破坏了“子树连续”的原则所以无解。合并两个儿子的时候要检查的不只是并集连续还要检查两个掩码是否重叠。比如左子树掩码是011右子树掩码是001并集是011看起来没问题但左子树包含了段1和段2右子树只包含段2两个子树都含段2的叶子。最终全局序列里段2的叶子必须是一个整体连续块而现在它被分在两棵子树里两棵子树还必须各自连续这就没法同时满足了。遇到A B不为0的情况我直接判无解返回-1。2.3 最小交换次数的统计原理弄清了合法掩码判定交换次数就很好统计了。在DFS合并左右儿子时我们先分别算出左右子树掩码A和B。如果A和B无重叠合并后并集连续那么这两个子树在最终序列中只可能有两种摆放方式A在左B在右或者B在左A在右。到底需要不需要交换一次取决于当前原本的顺序和最终目标顺序是否一致。具体判断我不用真的去递归枚举只要比较两个掩码覆盖的段号顺序。设当前节点的左儿子掩码是A右儿子掩码是B。如果A里最靠后的段号小于等于B里最靠前的段号说明左儿子整体在右儿子的左边目标顺序就是A在前B在后当前顺序已经正确不需要交换。反过来如果B里最靠后的段号小于等于A里最靠前的段号说明右儿子整体本来应该在左儿子的左边这时候就需要把左右儿子交换一次。如果两个掩码不是这种“一个完全在另一个左边”或“一个完全在另一个右边”的包含关系同时又无重叠那就说明它们之间还有空段并集一定不连续直接无解。举个例子说明。假设A掩码是001覆盖段0B掩码是110覆盖段1和段2。A的最靠后段是0B的最靠前段是10小于1所以左儿子在左右儿子在右正确顺序不交换。如果A掩码是110B掩码是001A的最靠后段是2B的最靠前段是02不大于0实际上2大于0但判断时要看“B完全在A左边”的条件B的最靠后段0不大于A的最靠前段1满足说明目标顺序应该是B在左A在右而现在左右反了需要交换一次。3. C完整实现与细节3.1 建树与段号映射建树部分我直接使用两个全局数组lc和rc分别记录每个节点的左儿子和右儿子编号。如果lc[u]为0说明当前节点没有左儿子同理rc[u]为0表示没有右儿子。叶子节点就是lc和rc都为0的节点。关于输入的细节每个人的建树方式不完全一样重点是先把树的结构完整读进来然后DFS就可以直接递归。段号映射这部分我单独抽一个函数getSeg。这步最容易出错因为原题给的是两个属性值你需要根据题目要求的最终形态确定哪个组合属于段0、哪个属于段1、哪个属于段2。我的做法是在读入叶子属性时先穷举所有可能的属性组合按照题目给的合法最终状态列出映射表然后让getSeg去查表。映射表只要确定一次后续所有逻辑都跟铃铛属性无关。我强调一下这个表不要想当然去猜一定要从题目要求的最终状态推导否则样例都过不了。3.2 核心DFS函数实现DFS函数返回当前子树的掩码如果当前子树内部已经无法调整成合法连续块直接返回-1。对于叶子节点返回的掩码是1左移seg位return (1 seg[u])。对于非叶子节点先递归处理左右儿子得到两个掩码a和b。如果其中任何一个已经是-1当前节点也不可能合法返回-1。然后开始合并判断先检查a b如果不等于0说明两个儿子覆盖了同一个段无法形成两个连续的独立块返回-1。计算并集c a | b。并集掩码必须是合法连续掩码我在这份代码里用一个bool valid[8]的数组提前标记好6个合法掩码直接查表。如果并集合法再判断左右儿子原来的顺序是否需要交换。如果左儿子的最高段小于等于右儿子的最低段不需要交换如果右儿子的最高段小于等于左儿子的最低段说明需要交换一次答案加1。如果这两个条件都不满足说明两个掩码之间还有空段并集不可能连续返回-1。这里判断最低段和最高段我用__builtin_ctz(mask)求最低位的1是第几位也就是段号最小的位置用31 - __builtin_clz(mask)求最高位的1在哪一位。因为掩码只有3位判断非常简单。3.3 完整可提交代码下面给出一份我在本地测试通过的核心C代码。建树部分我假设输入已经给出了每个节点的左右儿子叶子节点的段号已经通过getSeg映射好。实际提交时你需要把输入输出部分按照题目要求补上。#include bits/stdc.h using namespace std; const int MAXN 100005; int n; int lc[MAXN], rc[MAXN]; int seg[MAXN]; int ans; int cntLow(int x) { return __builtin_ctz(x); } int cntHigh(int x) { return 31 - __builtin_clz(x); } int dfs(int u) { if (lc[u] 0 rc[u] 0) { return (1 seg[u]); } int a dfs(lc[u]); int b dfs(rc[u]); if (a -1 || b -1) return -1; if ((a b) ! 0) return -1; int c a | b; static bool validMask[8] {false, true, true, true, true, false, true, true}; if (!validMask[c]) return -1; int lowA cntLow(a); int highA cntHigh(a); int lowB cntLow(b); int highB cntHigh(b); if (highA lowB) { // 左儿子整体在右儿子左边顺序正确 } else if (highB lowA) { ans; } else { return -1; } return c; } int main() { scanf(%d, n); // 读入左右儿子和叶子段号具体按题目输入格式处理 // 这里假设根节点是1叶子节点的seg已经处理完毕 ans 0; int rootMask dfs(1); if (rootMask -1) { printf(-1\n); } else { printf(%d\n, ans); } return 0; }为了让这段代码更容易看明白我把validMask数组下标对应关系说明一下下标0非法下标1对应001合法下标2对应010合法下标3对应011合法下标4对应100合法下标5对应101非法下标6对应110合法下标7对应111合法。这个数组对所有3位连续掩码做了预标记。3.4 复杂度分析这棵树的每个节点在DFS中只会被访问常数次每个节点做几次位运算和条件判断所以时间复杂度是O(n)。空间复杂度主要是递归栈和lc、rc、seg三个数组递归深度最坏情况下是树的高度极端情况下如果树退化成链深度可能达到n需要留意一下。我平时做信奥题习惯开O2优化但其实这题常数很小不开也能轻松跑过。递归爆栈是很多C选手容易忽略的问题。如果题目给的树深度很大比如n等于10万且是一棵接近链状的树默认的递归栈可能会出问题。我的个人习惯是在主函数开头加上setvbuf(stdin, NULL, _IONBF, 0);或者直接把DFS写成栈模拟不过对P3621来说一般洛谷的评测环境默认栈足够用直接递归也问题不大。4. 踩坑记录与调试心得4.1 全局答案被重复累加我第一次写这题的时候把答案统计直接写在了递归返回之前导致每个节点只要顺序正确就不加顺序反了就加1。思路没错但我在递归函数里用了全局ans结果测试样例总是偏大。后来才发现我在合并时没有区分“当前层需要交换”和“子树内部已经交换过的次数”造成重复统计。最好的做法是子树的交换次数已经在递归过程中累加完毕当前层只需要根据左右儿子的掩码关系决定是否额外加一次。代码里我明确只写ans这一个入口其他地方的答案都来自递归自动完成。4.2 无解判定漏掉掩码重叠很多题解讲这题时会说“左右子树如果都是混合状态就无解”但光看混合不够。我在调试时构造过一个样例左右子树的掩码分别是011和001并集是011看起来合法结果程序输出了一正数。为什么错因为两个子树都包含段2的叶子。同一个段的叶子最终必须连续在一起而现在它们被强行分到两棵子树里任何交换都无法让它们既不交叉又各自连续。所以我在代码里专门加了一句if ((a b) ! 0) return -1;别小看这个判断它救了我两次。4.3 用手画小样例验证掩码逻辑遇到这种树形合并的题手画几个小样例是排查逻辑最快的方式。我推荐构造三种典型样例第一种是左右儿子的段号已经天然有序应该输出0第二种是左右儿子整体反了应该输出1第三种是某个子树内部已经出现了段0和段2夹着段1的情况应该输出-1。把这三个样例分别跑一遍基本能把掩码合并的大部分边界情况覆盖住。我当时就是靠这个方式发现了两个儿子掩码重叠的问题不然光看代码很难一眼找出纰漏。4.4 和官方状态机解法的关系如果你看过官方题解会看到很多版本用“0、1、2、3”四状态来标记子树全是小铃铛、全是大铃铛、混合、不可用等等。我那套3位掩码的方法本质上是官方状态机解法的一种位运算版本。官方状态机的两个“全是一种铃铛”的状态对应到我的掩码里就是001和100而“混合”状态对应011、110、111区别在于我用掩码把“覆盖了哪些段”表达得更精确合并时的分类讨论直接用位运算完成代码更直观也不容易漏情况。所以我建议你如果之前用状态机做法觉得难理解可以试着我这个思路重新写一遍两个版本互相对照着看收获会更大。跑通P3621之后我对“通过交换兄弟子树实现区间排序”这类问题有了很深的体感。这类题最值钱的一步永远不是写代码而是敢于把复杂的物理模型压成几个整数。以后遇到那种看起来像模拟、实际上得用树形DP和位运算去抽象的信奥题你也可以试着像我这样先把所有能交换的子树当成一个个“整体块”再想这些块能不能严丝合缝地拼成目标序列。这几行掩码比模拟一百遍风铃都管用。
返回列表