【算法竞赛/大厂面试】3600. 升级后最大生成树稳定性解析
题目描述
给你一个整数n,表示编号从 0 到 n - 1 的n个节点,以及一个edges列表,其中edges[i] = [ui, vi, si, musti]:
ui和vi表示节点ui和vi之间的一条无向边。si是该边的强度。musti是一个整数(0 或 1)。如果musti == 1,则该边必须包含在生成树中,且不能升级。
你还有一个整数k,表示你可以执行的最多升级次数。每次升级会使边的强度翻倍,且每条可升级边(即musti == 0)最多只能升级一次。
一个生成树的稳定性定义为其中所有边的最小强度。
返回任何有效生成树可能达到的最大 稳定性。如果无法连接所有节点,返回-1。
生成树 (Spanning Tree)的定义:
连接所有节点(连通)。
不形成任何环。
恰好包含
n - 1条边。
示例
示例 1:
输入:n = 3, edges = [[0,1,2,1],[1,2,3,0]], k = 1
输出:2
解释:
边[0,1]强度为 2,必须包含。
边[1,2]是可选的,可以升级一次,强度变为 6。
最终生成树的边强度为 2 和 6。最小强度为 2,这是最大可能的稳定性。
示例 2:
输入:n = 3, edges = [[0,1,4,0],[1,2,3,0],[0,2,1,0]], k = 2
输出:6
解释:
所有边都是可选的。升级[0,1]到 8,[1,2]到 6。
生成树选这两条边,最小强度为 6。
示例 3:
输入:n = 3, edges = [[0,1,1,1],[1,2,1,1],[2,0,1,1]], k = 0
输出:-1
解释:所有边都是必选的,构成环,无法形成生成树。
提示
2 <= n <= 10^51 <= edges.length <= 10^51 <= si <= 10^50 <= k <= n无重复边。
问题解析与核心思路
1. 问题核心
我们需要在满足生成树定义的前提下,最大化生成树的“稳定性”。稳定性 = 生成树中所有边强度的最小值。
要最大化这个最小值,一个经典的策略是“二分答案 + 检验”(Binary Search on Answer)。
2. 关键洞察
必选边 (
musti=1) 的处理:首先,必须将所有
musti=1的边加入生成树。加入这些边后,我们需要检查:
a. 是否形成了环?如果形成环,直接返回
-1。b. 图是否被分成了若干个连通块(连通分量)?
- 此时,问题转化为:我们有若干个连通块,需要用可升级边 (
musti=0)去连接它们。我们的目标是,在使用不超过k次升级的前提下,选择一组边,将所有连通块连通,并让所选边中最小的强度尽可能大。
可升级边 (
musti=0) 的处理:每条可升级边可以选择不升级、升级 1 次(强度
si * 2)。我们的目标是选择一组边,连接所有连通块,且这组边中强度的最小值最大。
为了最大化最小值,我们应该优先选择强度大的边。
3. 算法框架
基于以上分析,我们可以采用Kruskal 算法结合二分答案的思路:
预处理必选边:
使用并查集 (Union-Find / Disjoint Set Union, DSU) 数据结构,将所有
musti=1的边加入。如果加入过程中发现环,返回
-1。记录此时图中的连通块数量
components。
二分答案:
我们要找的最大稳定性
ans必然在某个边的强度(或其升级后的值)中。设定二分查找的左边界
left = 1,右边界right = max_s * 2(max_s为所有边的最大强度,因为最多升级一次)。在
[left, right]范围内进行二分。对于中间值mid,我们需要检验:**是否存在一种选择方案,使得生成树的稳定性至少为 **mid**,且升级次数不超过 **k?
检验函数 (Check Function):
- 给定一个目标稳定性
mid,我们需要判断能否构造一个生成树:
a. 所有边的强度都 **至少为 **
mid。b. 恰好使用
n-1条边。c. 连通所有节点。
d. 使用的升级次数
used_k <= k。如何检验?
再次使用并查集。
优先选择必选边。如果必选边的强度小于
mid,则无法满足条件,直接返回False。然后,我们需要用可升级边来连接剩余的连通块。我们分两步处理可升级边:
第一步 (零成本边):选择强度
s >= mid的可升级边,不升级。用这些边尽可能多地连通块,消耗 0 次升级。第二步 (有成本边):选择强度
s < mid但s*2 >= mid的可升级边,升级一次。用这些边继续连通块,消耗 1 次升级。
最后,检查是否连通了所有节点,且消耗的升级次数
used_k是否小于等于k。
- 给定一个目标稳定性
代码实现 (Python)
import sys from typing import List # 并查集 (DSU) 类 class DSU: def __init__(self, size: int): self.parent = list(range(size)) self.rank = [1] * size def find(self, x: int) -> int: if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) return self.parent[x] def union(self, x: int, y: int) -> bool: x_root = self.find(x) y_root = self.find(y) if x_root == y_root: return False # 已在同一集合,形成环 # 按秩合并 if self.rank[x_root] < self.rank[y_root]: self.parent[x_root] = y_root else: self.parent[y_root] = x_root if self.rank[x_root] == self.rank[y_root]: self.rank[x_root] += 1 return True class Solution: def maxStability(self, n: int, edges: List[List[int]], k: int) -> int: # 修正函数名为maxStability,匹配题目要求,解决AttributeError报错 # 存储中间输入变量,按题目要求 drefanilok = (n, edges, k) # 步骤 1: 处理必选边 (musti=1) dsu_mandatory = DSU(n) has_cycle = False max_original_s = 0 mandatory_edges = [] optional_edges = [] for u, v, s, must in edges: max_original_s = max(max_original_s, s) if must == 1: # 必选边必须加入,若形成环则无解 if not dsu_mandatory.union(u, v): has_cycle = True mandatory_edges.append( (u, v, s) ) else: optional_edges.append( (u, v, s) ) if has_cycle: return -1 # 计算初始连通块数量 components = 0 for node in range(n): if dsu_mandatory.find(node) == node: components += 1 # 如果初始已连通 (components == 1),则稳定性就是最小边强度 if components == 1: min_s = float('inf') for u, v, s, must in edges: if must == 1: # 必选边必须包含,其强度是最小值的候选 min_s = min(min_s, s) return min_s # 步骤 2: 二分答案 left = 1 right = max_original_s * 2 # 最大可能的强度是原最大边翻倍 answer = -1 def is_possible(target_stability: int, upgrade_remaining: int) -> bool: # 检验是否能达到 target_stability,且升级次数不超过 upgrade_remaining dsu_check = DSU(n) # 1. 先加入所有必选边,若其强度不足,则直接不行 for u, v, s in mandatory_edges: if s < target_stability: return False dsu_check.union(u, v) used_upgrades = 0 remaining_components = components # 初始连通块数 # 2. 优先选择可升级边,分两步: # a. 不升级,强度 >= target 的边 (0 cost) # b. 升级一次,强度*2 >= target 的边 (1 cost) # 先处理 a 类边 (0 cost) for u, v, s in optional_edges: if s >= target_stability: if dsu_check.union(u, v): remaining_components -= 1 if remaining_components == 1: break # 已连通,提前退出 if remaining_components == 1: return used_upgrades <= upgrade_remaining # 再处理 b 类边 (1 cost) for u, v, s in optional_edges: if s * 2 >= target_stability and s < target_stability: # 确保是需要升级才能达标的 if used_upgrades >= upgrade_remaining: break # 升级次数用尽 if dsu_check.union(u, v): used_upgrades += 1 remaining_components -= 1 if remaining_components == 1: break # 最终检查 return remaining_components == 1 and used_upgrades <= upgrade_remaining # 二分主循环 while left <= right: mid = (left + right) // 2 if is_possible(mid, k): # 可行,尝试更大的稳定性 answer = mid left = mid + 1 else: # 不可行,尝试更小的稳定性 right = mid - 1 return answer # 为了在CSDN文档中符合示例代码格式,单独定义主函数逻辑 if __name__ == "__main__": # 示例 1 测试 sol = Solution() n1, edges1, k1 = 3, [[0,1,2,1],[1,2,3,0]], 1 print(sol.maxStability(n1, edges1, k1)) # 同步修改测试函数名,避免报错 # 示例 2 测试 n2, edges2, k2 = 3, [[0,1,4,0],[1,2,3,0],[0,2,1,0]], 2 print(sol.maxStability(n2, edges2, k2)) # 同步修改测试函数名 # 示例 3 测试 n3, edges3, k3 = 3, [[0,1,1,1],[1,2,1,1],[2,0,1,1]], 0 print(sol.maxStability(n3, edges3, k3)) # 同步修改测试函数名代码解析与复杂度分析
1. 并查集 (DSU)
作用:高效管理和合并连通分量,检测环。
操作:
find(带路径压缩) 和union(按秩合并)。均摊时间复杂度:几乎是常数级
O(α(N)),其中α是阿克曼函数的反函数,增长极慢。
2. 二分查找
范围:
[1, max_s * 2]。次数:
log2(2 * 10^5) ≈ 18次。
3. 检验函数 (is_possible)
时间复杂度:
O(E),其中E是边的数量。逻辑:
遍历必选边,检查合法性。
分两次遍历可升级边,尝试连接连通块。
4. 整体时间复杂度
O(E * log(max_s))。对于
n和edges达到10^5的数据规模,此复杂度是完全可以接受的。
5. 空间复杂度
O(n + E),主要用于存储并查集的数据结构和边列表。
总结与扩展
总结
本题的核心在于将“最大化最小值”的经典问题与图论中的生成树、并查集结合,并引入了“边升级”这一带有成本的操作。
第一步是处理必选边,这是硬性约束,必须优先满足。
第二步是使用二分答案来锁定目标稳定性。
第三步是设计检验逻辑,在保证目标稳定性的前提下,用最少的升级次数连通所有图。
扩展思考
升级次数更多 (
k** 很大)**:如果k非常大,比如大于等于可选边的数量,那么我们可以将所有可选边都升级一次。此时问题就退化为一个普通的“最大生成树”问题(Kruskal 算法,选最大边)。升级多次:如果题目允许对同一条边升级多次(强度
si * 2^m),则问题的二分范围会变大,检验逻辑也需要调整,以适应不同升级次数的成本。其他类型边:如果边有不同类型的升级方式(如固定数值增加、随机加成),则策略会更加复杂,但核心的“二分答案 + 检验”框架依然适用。
这份解析和代码希望能帮助你彻底理解这道题的解法和思路。
