LeetCode 652. 寻找重复的子树
题目描述


题意分析
以树中的每个节点为根,连同它的全部后代构成一棵子树。两棵子树只有在对应位置的节点值相同、左右结构也完全一致时才算重复,不能仅比较根节点值,也不能交换左右孩子。
对每一种出现至少两次的子树,只返回其中任意一个实际根节点。相同子树出现三次或更多次,答案中仍只保留一个代表,不需要复制整棵子树。
解法:后序遍历 + 子树 ID
核心思路
[!blue]
判断两棵树相同,本来就可以递归检查“根值相同、左子树相同、右子树相同”。把已经识别过的子树压缩成一个整数编号,就能用固定长度的三元组
(节点值, 左子树编号, 右子树编号)表达这个条件,不必在每个祖先处重复保存完整的子树描述。空节点固定返回编号
0,非空子树从1开始编号。必须先递归得到左右孩子的编号,再生成当前签名,因此使用后序遍历。signatureToId保存签名与编号的对应关系:签名已存在就复用编号,第一次出现才分配新编号。从空子树开始归纳,若左右孩子的编号能准确区分子树,那么相同三元组就意味着根值与左右子树都相同;三元组不同则至少有一处不同。因此当前节点的编号同样能准确区分整棵子树。这里编号是查表后分配的标识,不是直接拿哈希值作为结构编号。
frequency[id]统计这种子树已经出现的次数。第一次只是登记,第二次才确认存在重复并加入当前根节点,第三次及以后不再加入。空节点直接返回0,不参与计数或输出。签名键也必须按内容比较。Java 用不可变的
List.of保存三项,Go 用可以直接比较的[3]int数组;签名一旦入表便不再修改,左右编号的位置始终固定。
解题步骤
- 为本次调用创建编号表、频次数组和答案列表;频次的第
0项留给空子树编号。- 递归遇到空节点就返回
0,否则先取得左右子树编号。- 用当前值和两个编号构造签名并查表。若是新签名,分配
表中已有签名数 + 1作为编号,并补一个初始为零的频次位置。- 将当前编号的频次加一,只有变为
2时才收录当前节点。- 把编号返回父节点,直到根节点处理结束,返回所有已收录的代表。
代码实现
class Solution {
private Map<List<Integer>, Integer> signatureToId;
private List<Integer> frequency;
private List<TreeNode> answer;
public List<TreeNode> findDuplicateSubtrees(TreeNode root) {
signatureToId = new HashMap<>();
frequency = new ArrayList<>();
// 编号零留给空节点
frequency.add(0);
answer = new ArrayList<>();
getId(root);
return answer;
}
private int getId(TreeNode node) {
if (node == null) {
return 0;
}
int leftId = getId(node.left);
int rightId = getId(node.right);
// 按值和有序左右编号识别结构,相同签名复用编号
List<Integer> signature = List.of(node.val, leftId, rightId);
Integer id = signatureToId.get(signature);
if (id == null) {
id = signatureToId.size() + 1;
signatureToId.put(signature, id);
frequency.add(0);
}
int seen = frequency.get(id) + 1;
frequency.set(id, seen);
// 只在第二次出现时报告,避免重复加入同一结构
if (seen == 2) {
answer.add(node);
}
return id;
}
}
func findDuplicateSubtrees(root *TreeNode) []*TreeNode {
signatureToID := make(map[[3]int]int)
// 编号零留给空节点
frequency := []int{
0,
}
answer := make([]*TreeNode, 0)
var getID func(*TreeNode) int
getID = func(node *TreeNode) int {
if node == nil {
return 0
}
leftID := getID(node.Left)
rightID := getID(node.Right)
// 按值和有序左右编号识别结构,相同签名复用编号
signature := [3]int{
node.Val,
leftID,
rightID,
}
id, exists := signatureToID[signature]
if !exists {
id = len(signatureToID) + 1
signatureToID[signature] = id
frequency = append(frequency, 0)
}
frequency[id]++
// 只在第二次出现时报告,避免重复加入同一结构
if frequency[id] == 2 {
answer = append(answer, node)
}
return id
}
getID(root)
return answer
}
复杂度分析
- 时间复杂度:期望 $O(n)$,其中 $n$ 为节点数。每个节点访问一次,三元组长度固定,哈希表查找与计数更新的期望开销均为 $O(1)$。
- 空间复杂度:$O(n)$。不同子树类型至多有 $n$ 种,编号表和频次数组各占 $O(n)$;递归深度为树高 $h$,最坏也是 $O(n)$。答案只保存根节点引用,不复制子树。
关键点总结
[!green]
- 编号代表完整子树,三元组的两项孩子编号把深层结构递归带入了当前签名。
- 后序遍历保证孩子编号已经确定,空节点编号则保留了缺失孩子的位置。
- “频次恰好变为二”同时完成重复确认与结果去重,不需要额外的已输出集合。
易错点总结
[!yellow]
- 计数大于等于二就加入,会重复报告同一结构。
- Java 使用普通数组作哈希键,会按对象身份而非三项内容比较。
- 左右编号不区分,会把镜像结构当作相同子树。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 100. 相同的树 | 简单 | 完整相同树的条件来自节点值与两个孩子结构,本题把这个递归条件编码成签名后聚合。 |
| 609. 在系统中查找重复文件 | 中等 | 同样按内容签名查重,文件只比较字节内容,本题还必须编码子树结构。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!