1. 问题背景与核心概念解析
今天我们来拆解一道LeetCode上的树结构难题——"2003. 每棵子树内缺失的最小基因值"。这道题在周赛中出现时让不少选手感到棘手,但其实只要掌握几个关键结论,就能化繁为简。我们先明确题目要求:
给定一棵以节点0为根的树结构,每个节点都有一个唯一的基因值(即所有节点的基因值互不相同)。需要为每个子树找出其内部缺失的最小正整数基因值。举个例子,如果某子树包含的基因值为[2,3,5],那么缺失的最小正整数就是1;如果包含[1,2,3],则缺失的最小值是4。
关键提示:题目中的"缺失最小基因值"实际上就是数学中的"最小缺失正整数"概念(Mex, Minimum Excluded Value),这与经典的数组找缺失最小正整数问题一脉相承。
2. 三大核心结论及其证明
2.1 结论一:无1子树的处理策略
现象观察:当某子树中完全不包含基因值1时,该子树的答案必定是1。这是所有正整数中最小的一个,且题目保证基因值互不相同。
逻辑证明:
- 根据Mex定义,我们需要找的是不在集合中的最小正整数
- 若集合中不存在1,则1就是最小的缺失值
- 这一结论与子树的具体结构无关,是全局性结论
实现技巧:
- 可以通过一次DFS预处理,标记出哪些子树包含1
- 对于不包含1的子树,可以直接返回1,无需进一步计算
2.2 结论二:含1子树的链式特性
关键发现:所有包含基因值1的子树,它们的根节点实际上形成了一条从原始根节点(节点0)到基因值为1的节点的路径。这是因为:
- 子树包含1的条件是该节点在1的祖先路径上
- 树结构中,一个节点的子树包含另一个节点的条件是前者是后者的祖先
- 因此,只有1节点及其祖先节点的子树才会包含1
实例说明: 考虑如下树结构:
0 (val=2) └── 1 (val=1) └── 2 (val=3)这里只有节点0和节点1的子树包含1,它们正好形成0→1的路径。
2.3 结论三:链上节点的计算顺序
最优策略:对于包含1的那些子树(即链上的节点),我们应该从基因值为1的节点开始,沿着祖先路径向上计算。这是因为:
- 子树的Mex值具有单调不减的性质
- 越靠近叶子节点的子树,包含的基因值越少,Mex计算越简单
- 可以利用已计算的子节点结果来优化父节点的计算
算法选择:
- 使用哈希集合来维护当前路径上的基因值
- 从1节点开始,初始Mex设为2
- 沿着父节点回溯,动态更新Mex值
3. 算法实现与优化细节
3.1 预处理阶段:建立树结构和定位1节点
def build_tree(parents): tree = defaultdict(list) one_pos = -1 for i, p in enumerate(parents): tree[p].append(i) if vals[i] == 1: one_pos = i return tree, one_pos这个预处理步骤完成了两件事:
- 将父节点表示法转换为邻接表形式
- 记录基因值1所在的节点位置
3.2 标记包含1的子树
def mark_contains_one(tree, one_pos): contains_one = [False] * len(vals) stack = [one_pos] while stack: node = stack.pop() contains_one[node] = True for child in tree[node]: stack.append(child) return contains_one使用DFS遍历从1节点开始的所有子树,标记这些节点为"包含1"。
3.3 主算法实现
def smallestMissingValueSubtree(parents, vals): n = len(parents) res = [1] * n tree, one_pos = build_tree(parents) if one_pos == -1: # 没有基因值1的特殊情况 return res contains_one = mark_contains_one(tree, one_pos) # 处理链上的节点 current_mex = 2 visited = set() node = one_pos while node != -1: # 收集当前节点的所有子节点基因值(不包括已访问的) stack = [node] while stack: curr = stack.pop() if vals[curr] in visited: continue visited.add(vals[curr]) for child in tree[curr]: stack.append(child) # 更新Mex值 while current_mex in visited: current_mex += 1 res[node] = current_mex node = parents[node] # 移动到父节点 return res4. 复杂度分析与边界情况
4.1 时间复杂度
- 建树和定位1节点:O(N)
- 标记包含1的子树:O(N)
- 主处理流程:
- 每个节点最多被访问一次
- Mex更新过程整体是O(N)(因为Mex最多增加到N+1)
- 总复杂度:O(N)
4.2 空间复杂度
- 树结构的存储:O(N)
- 包含1的标记数组:O(N)
- 已访问基因值的集合:最坏O(N)
- 总空间:O(N)
4.3 边界情况处理
没有基因值1的情况:
- 所有子树的Mex都是1
- 直接返回全1数组
基因值1在根节点:
- 只有根节点需要计算Mex
- 其他节点可以直接返回1
单节点树:
- 如果节点值为1,Mex为2
- 否则Mex为1
5. 实战技巧与常见错误
5.1 调试技巧
可视化小规模测试用例: 手工绘制树结构,标注每个节点的基因值,手动计算预期结果。
关键检查点:
- 确认1节点定位正确
- 验证包含1的子树标记正确
- 检查Mex更新逻辑是否准确
5.2 常见错误
错误假设基因值范围:
- 误认为基因值在1-N范围内
- 实际上基因值可以是任意正整数
Mex更新顺序错误:
- 应该先收集所有基因值,再统一计算Mex
- 而不是边收集边计算
忽略父节点指针终止条件:
- 根节点的父节点通常是-1
- 需要正确处理这个边界
5.3 性能优化
基因值离散化:
- 如果基因值范围很大但数量少,可以先映射到连续区间
- 但题目中N≤10^5,通常不需要
双指针Mex优化:
- 维护当前Mex候选值
- 使用位图代替哈希集合(当N很大时)
并行处理非链节点:
- 不包含1的子树可以并行处理
- 但在编程竞赛中通常不需要
6. 同类问题扩展
掌握了这道题的解法后,可以解决一系列类似问题:
子树Mex查询:
- 动态版本:支持修改节点值
- 需要更复杂的数据结构
路径Mex问题:
- 计算树上路径的Mex
- 可能需要树链剖分
带权Mex问题:
- 每个值有权重
- 求最小权重和Mex
区间Mex问题:
- 数组区间查询
- 离线处理+线段树
这道题很好地展示了如何将树结构的特性与算法设计相结合。通过分析问题本质得出的三个核心结论,不仅适用于本题,也为处理其他树结构问题提供了思路框架。在实际编码时,注意处理好预处理和Mex更新的细节,就能高效解决这个问题。