1. 从一道题看最小生成树的本质
Clarke and MST 这个标题,第一次看到的时候我以为是某位选手的ID加上一道模板题,点进去才发现里面藏着一个挺有意思的变形。题目大意是:给定一张图,每条边有一个权值,要求找出一棵生成树,使得所有边权按位与(AND)之后的值最大。这个"按位与最大"的目标和传统的最小生成树"边权和最小"完全不是一个路子,所以直接套Kruscal模板是过不了的,得先想清楚这个目标函数的性质。
我先把结论摆出来:按位与运算有一个关键性质——高位优先。也就是说,二进制下如果第k位能变成1,那么哪怕后面所有低位全是0,结果也比第k位是0、后面全是1要大。这个性质直接决定了贪心策略的可行性:我们从高位到低位逐位尝试,看能不能构造出一棵生成树,使得当前这一位(以及之前已经确定为1的高位)在最终结果里保持为1。
具体做法是:从第30位(或者根据权值范围取更高位)开始往下枚举,维护一个"当前允许使用的边集"。对于第i位,我们只保留那些权值在第i位为1的边,然后在这个边集上跑Kruscal,看能不能形成一棵生成树。如果能,说明这一位可以贡献到最终答案里,答案加上这一位,同时把边集更新为这个子集;如果不能,说明这一位无论如何都保不住,答案这一位就是0,边集不变,继续看下一位。
这个思路为什么对?因为按位与的结果只关心哪些位最终是1。我们贪心地从高位往低位确定,每次只在"能形成生成树"的前提下保留这一位为1的边,这样高位一旦确定就不会被低位影响,符合按位与高位优先的特性。而Kruscal在这里的作用就是判断"当前边集能否构成生成树"——只要能选出n-1条边且不形成环,就说明可以。
2. 并查集在其中的角色与实现细节
并查集在这道题里承担的是"连通性判定"的职责。Kruscal的核心就是按边权排序后依次加边,用并查集判断加入这条边会不会成环。但在这道题里,我们其实不需要对边排序——因为我们的目标不是最小化边权和,而是判断当前边集能否构成生成树。所以Kruscal的"排序"这一步可以省掉,直接遍历边集,能加就加,最后看加入了多少条边。
不过这里有个细节需要注意:每次尝试某一位时,并查集必须重新初始化。因为上一位的连通状态和这一位要用的边集不一定兼容,不能复用。我见过有人为了省事,在循环外面初始化一次并查集,结果WA到怀疑人生。这个坑很典型,因为并查集的parent数组是会被路径压缩修改的,复用会导致连通性判断完全错乱。
并查集的实现我用的是带路径压缩的版本,没有加按秩合并,因为这道题的数据规模通常不会太大(n和m一般在1e5以内),路径压缩已经足够把单次find的均摊复杂度压到接近常数。如果你追求极致性能,可以加上按秩合并,但实际测下来差别不大。
int parent[100005]; int find(int x) { if (parent[x] != x) parent[x] = find(parent[x]); return parent[x]; } bool unite(int a, int b) { int ra = find(a), rb = find(b); if (ra == rb) return false; parent[ra] = rb; return true; }上面这段代码是并查集的核心。注意find里的路径压缩写法,递归版本在数据量大时可能爆栈,可以改成迭代版本。我在实际写的时候习惯用迭代,虽然代码稍微长一点,但心里踏实。
3. 逐位贪心的完整实现与参数选择
现在把整个算法的流程串起来。假设边权最大不超过2^30,那么我们从第30位枚举到第0位。每一位的处理分三步:第一步,把当前边集中所有第i位为1的边筛选出来,形成候选边集;第二步,用并查集在候选边集上跑Kruscal,统计能加入的边数;第三步,如果加入的边数等于n-1,说明这一位可以保留,答案加上(1<<i),同时把当前边集更新为候选边集;否则这一位放弃,边集不变。
这里有一个容易忽略的点:边集的更新时机。只有在当前位能形成生成树时,我们才把边集缩小到候选边集。如果这一位不能形成生成树,我们继续用原来的边集去看下一位。这个逻辑保证了我们不会因为某一位保不住而丢失更高位已经确定的信息。
关于枚举的起始位,我一般取30,因为大多数题目的边权在int范围内。如果边权可能达到long long范围,那就取60。但实际比赛中,30位已经足够覆盖绝大多数情况。如果你不确定,可以先把所有边权的最大值求出来,然后取它的最高位作为起始位,这样能省掉一些无用的枚举。
int n, m; struct Edge { int u, v, w; } edges[200005]; int solve() { int ans = 0; vector<Edge> cur(edges, edges + m); for (int bit = 30; bit >= 0; bit--) { vector<Edge> nxt; for (auto &e : cur) { if (e.w >> bit & 1) nxt.push_back(e); } // 初始化并查集 for (int i = 1; i <= n; i++) parent[i] = i; int cnt = 0; for (auto &e : nxt) { if (unite(e.u, e.v)) cnt++; } if (cnt == n - 1) { ans |= (1 << bit); cur = nxt; } } return ans; }这段代码是整个解法的骨架。注意里面用vector来维护当前边集,每次筛选后如果可行就替换。这样做的好处是逻辑清晰,缺点是每次都要复制vector,在m很大的时候会有性能开销。如果追求极致,可以用一个标记数组来标记哪些边还在当前边集中,避免复制。但一般来说,m在1e5以内时,vector的复制开销是可以接受的。
4. 复杂度分析与常见错误排查
这个算法的时间复杂度是O(30 * (m + n)),因为每一位都要遍历当前边集跑一次Kruscal,而Kruscal的复杂度主要取决于边数和并查集操作。30是常数,所以整体可以认为是O(m + n)的级别,对于1e5的数据规模来说完全够用。
空间复杂度是O(m + n),主要是存储边和并查集数组。
常见错误我整理了一个速查表,这些都是我在做题和帮别人debug时反复见到的:
| 错误类型 | 具体表现 | 原因 | 修正方法 |
|---|---|---|---|
| 并查集未重置 | 答案偏小或偏大 | 上一位的连通状态污染了当前位 | 每次尝试新位前重新初始化parent数组 |
| 边集更新时机错误 | 答案偏小 | 在不能形成生成树时也更新了边集 | 只在cnt==n-1时更新cur |
| 枚举位数不够 | 答案偏小 | 边权超过2^30时高位丢失 | 根据最大边权确定起始位 |
| 生成树判定条件错误 | 答案偏大 | 用cnt>=n-1判断,但cnt不可能超过n-1 | 严格判断cnt==n-1 |
| 递归find爆栈 | 运行时错误 | 数据量大时递归深度过深 | 改用迭代版本find |
其中并查集未重置是最容易犯的错,因为写惯了模板题的人会下意识觉得并查集初始化一次就行。但这道题的逻辑决定了每次尝试新位都是一次独立的连通性判定,必须重置。
还有一个细节:如果图本身不连通,那么无论怎么选边都无法形成生成树,最终答案应该是0。这个情况在代码里自然处理了——因为cnt永远达不到n-1,ans保持为0。但有些题目会保证图连通,所以这个边界情况不一定能测到,自己写的时候心里要有数。
5. 从这道题延伸出的思考
这道题表面上是Kruscal加并查集的组合,但真正有意思的地方在于它把"最小生成树"的框架用在了"按位与最大"这个完全不同的目标上。Kruscal在这里退化成了一个"连通性判定工具",排序步骤被完全跳过,这是很多人一开始想不到的。
我后来在想,如果目标改成"按位或最大"或者"按位异或最大",思路会不会不一样?按位或的话,高位优先的性质依然成立,但贪心策略可能需要调整,因为或运算下某一位为1的边可能不止一条,选择哪一条会影响后续。按位异或就更复杂了,因为异或没有单调性,高位优先不成立,得换别的思路。这些问题没有标准答案,但拿来锻炼思维挺好的。
另外,这道题也提醒我,模板题做多了容易形成思维定式。看到"生成树"就想到Kruscal,看到Kruscal就想到排序,但实际题目往往会在某个环节做变形。真正重要的是理解每个算法组件解决的是什么问题——Kruscal解决的是"在边集上选出一棵生成树",并查集解决的是"动态连通性判定",至于边怎么排序、按什么顺序加,那是可以根据题目目标灵活调整的。
我在实际写这道题的时候,第一次提交WA了,就是因为并查集没有重置。后来改过来之后又发现枚举位数只到20,导致大权值的边被忽略。这两个坑踩完之后,这道题才算真正吃透了。现在回头看,这道题的价值不在于它有多难,而在于它逼着你去思考每个算法步骤的必要性——排序真的必要吗?并查集的状态能不能复用?这些问题想清楚了,对Kruscal和并查集的理解会上一个台阶。