LeetCode 记录-寻找重复的子树

128 阅读5分钟

LeetCode 记录-寻找重复的子树

我的解法

本题我没有想出一个自己觉得高效可行的解法,但还是记录一下自己的思路,用来查漏补缺。

思路

image.png

首先,我看到这是树的题目,而且是中等难度的题目,估计是使用 DFS 或者 BFS 算法就可以解决。

然后我去看示例。想找到合适的逻辑在一遍 DFS 或者 BFS 里找到所有重复的子树。但我始终没想到如何快速的对比两颗子树是否相同。

本来是想两边递归去对比结构和值,但觉得这样时间复杂度实在太高,不太合理。所以就放弃自己做了。


官方解法 1:使用序列化进行唯一标识

思路

一种容易想到的方法是将每一棵子树都「序列化」成一个字符串,并且保证:

相同的子树会被序列化成相同的子串; 不同的子树会被序列化成不同的子串。 那么我们只要使用一个哈希表存储所有子树的序列化结果,如果某一个结果出现了超过一次,我们就发现了一类重复子树。

序列化一棵二叉树的方法可以参考「剑指 Offer 37. 序列化二叉树」的官方题解,这里也给出两种常用的序列化方法:

第一种方法是使用层序遍历的方法进行序列化,例如示例 1 中的二叉树可以序列化为:

1,2,3,4,null,2,4,null,null,4

这也是力扣平台上测试代码时输入一棵二叉树的默认方法。

第二种方法是使用递归的方法进行序列化。我们可以将一棵以 x 为根节点值的子树序列化为:

x(左子树的序列化结果)(右子树的序列化结果)

左右子树分别递归地进行序列化。如果子树为空,那么序列化结果为空串。示例 1 中的二叉树可以序列化为:

1(2(4()())())(3(2(4()())())(4()()))

下面的代码使用的是第二种方法。我们需要使用一个哈希映射 seen 存储序列到子树的映射。如果在计算序列时,通过 seen 查找到了已经出现过的序列,那么就可以把对应的子树放到哈希集合 repeat 中,这样就可以保证同一类的重复子树只会被存储在答案中一次。

使用序列化来表示一棵树的唯一性是我没有想到的。这样就可以在一边 dfs 里面去生成所有子树的唯一标识,并判断出重复的子树。

代码

const findDuplicateSubtrees = (root) => {
  const seen = new Map();
  const repeat = new Set();

  const dfs = (node) => {
    if (!node) {
      return "";
    }

    let sb = "";
    sb += node.val;
    sb += "(";
    if (node.left) {
      sb += dfs(node.left);
    }
    sb += ")(";
    if (node.right) {
      sb += dfs(node.right);
    }
    sb += ")";

    if (seen.has(sb)) {
      repeat.add(seen.get(sb));
    } else {
      seen.set(sb, node);
    }

    return sb;
  };

  dfs(root);
  return [...repeat];
};

复杂度分析

时间复杂度

O(n2n^2),其中 n 指树中节点的数目。在最坏的情况下,树呈现链状的结构,而每一个节点都会在其左右子树序列的基础上最多再增加 9 个字符(两组括号以及节点本身的值)。那么所有子树的序列长度之和为1n=O(n2)\sum_1^n=O(n^2)。构造出这些序列就需要O(n2)O(n^2)的时间。

说实话,我不太理解这里的时间复杂度为什么是 O(n2n^2),我理解它官方解释这个序列长之和1n=O(n2)\sum_1^n=O(n^2),是将每颗子树都重新调用了一边 dfs,等于是每颗子树都重新计算了一边。但这里其实并没有。我考虑最坏的情况,就是树呈现向右的链状结构,当在 root 调用了 dfs 后,在中间运行sb+=dfs(node.right)的时候,会一层层往下,直到最后一层(leaf)。然后从 leaf 开始返回,前面一层都是在子树序列的基础上加至多 9 个字符,所以我理解时间复杂度应该是 O(nn)。

空间复杂度

O(n2n^2),即为哈希表需要使用的空间

我觉得是 O(n)。 但也不太确定,等后续有时间再深入研究一下。

官方解法 2:使用三元组进行唯一表示

思路

通过方法一中的递归序列化技巧,我们可以进一步进行优化。

我们可以用一个三元组直接表示一棵子树,即 (x,l,r)(x,l,r),它们分别表示:

根节点的值为 x;

左子树的序号为 l;

右子树的序号为 r。

这里的「序号」指的是:每当我们发现一棵新的子树,就给这棵子树一个序号,用来表示其结构。那么两棵树是重复的,当且仅当它们对应的三元组完全相同。

使用「序号」的好处在于同时减少了时间复杂度和空间复杂度。方法一的瓶颈在于生成的序列会变得非常长,而使用序号替换整个左子树和右子树的序列,可以使得每一个节点只使用常数大小的空间。

代码

var findDuplicateSubtrees = function (root) {
  const seen = new Map();
  const repeat = new Set();
  let idx = 0;

  const dfs = (node) => {
    if (!node) {
      return 0;
    }

    const stx = [node.val, dfs(node.left), dfs(node.right)].toString();
    if (seen.has(stx)) {
      const pair = seen.get(stx);
      repeat.add(pair[0]);
      return pair[1];
    } else {
      seen.set(stx, [node, ++idx]);
      return idx;
    }
  };

  dfs(root);
  return [...repeat];
};

时间复杂度

O(nn),其中 n 是书中节点的数目。

我理解,方法二和方法一的时间复杂度应该是一样的,都是采用递归的方式生成唯一序列号。

空间复杂度

O(nn),即为哈希表需要使用的空间