[路飞]_前端算法第二十六弹-331. 验证二叉树的前序序列化

308 阅读4分钟

「这是我参与11月更文挑战的第18天,活动详情查看:2021最后一次更文挑战

题目要求

序列化二叉树的一种方法是使用前序遍历。当我们遇到一个非空节点时,我们可以记录下这个节点的值。如果它是一个空节点,我们可以使用一个标记值记录,例如 #

例如,上面的二叉树可以被序列化为字符串 "9,3,4,#,#,1,#,#,2,#,6,#,#",其中 # 代表一个空节点。

给定一串以逗号分隔的序列,验证它是否是正确的二叉树的前序序列化。编写一个在不重构树的条件下的可行算法。

每个以逗号分隔的字符或为一个整数或为一个表示 null 指针的 '#'

你可以认为输入格式总是有效的,例如它永远不会包含两个连续的逗号,比如 "1,,3"

示例 1:

输入: "9,3,4,#,#,1,#,#,2,#,6,#,#"
输出: true

示例 2:

输入: "1,#"
输出: false

示例 3:

输入: "9,#,#,1"
输出: false

栈方法(假定槽位)

我们可以把每一个节点当做一个槽位,等待被填充中。如果该节点为空节点,则消耗掉了一个槽位。

如果该节点为一个非空节点,则消耗掉一个槽位的同时,又新增了两个槽位。根节点需特殊处理。

我们使用栈维护槽位的变化。栈中的每一个元素,都代表了剩余槽位的数量,栈顶元素代表下一步可用的槽位的数量。遇到空节点时,栈顶元素-1,遇到非空节点时,栈顶元素-1,+2。只要栈顶元素为0,即可出栈。

遍历结束后,若栈为空,代表没有需要填充的槽位。则是合法序列。否则不合法,在遍历过程中,如若槽位不足,同样不合法。

var isValidSerialization = function (preorder) {
  let arr = preorder.split(',');
  let stack = [1];
  for (let i = 0; i < arr.length; i++) {
    const item = arr[i];
    if (!stack.length) return false;
    const len = stack.length;
    if (item == "#") {
      stack[len - 1]--;
      if (stack[len - 1] == 0) stack.pop()
    } else {
      stack[len - 1]--;
      if (stack[len - 1] == 0) stack.pop()
      stack.push(2)
    }
  }
  return stack.length == 0
};

槽位法升级

上面方法中,把每一个节点的槽位,看成是独立的,如果我们把所有槽位看成是一个整体。用一个元素维护所有的槽位数量的变化。

var isValidSerialization = function (preorder) {
  const arr = preorder.split(",");
  const len = arr.length;
// 默认初始化有一个槽位,如果是一个空链表["#"],也需要消耗一个槽位。
  let slots = 1;
  for (let i = 0; i < len; i++) {
    if (slots == 0) return false;
    if (arr[i] == "#") {
      slots--
    } else {
      slots++
    }
  }
  return slots === 0
};

二叉树特性法

根据前序遍历的特性,根→左→右的顺序遍历,只有当根节点的所有左子树全部遍历完成之后,才会遍历右子树。所以我们可以先判断左子树是否有效,再判断右子树是否有效。最后再判断根节点。用递归的思路处理。下面就是如何判断一棵子树是否有效呢,我们先需要找到最深的结点,也就是叶子结点,即左右子树均为”#”的结点。所以如果遍历到了叶子结点,则证明遍历到了最后一层,如果该节点成立,我们可以把这个节点置空,来判断其根节点是否也成立。即利用“#”将叶子结点替代。如题中把 4## 替换成 #

  • "9,3,4,#,#,1,#,#,2,#,6,#,#"
  • "9,3,#,1,#,#,2,#,6,#,#"
  • "9,3,#,#,2,#,6,#,#"
  • "9,#,2,#,6,#,#"
  • "9,#,2,#,#"
  • "9,#,#"
  • "#"

如上

var isValidSerialization = function (preorder) {
    const arr = preorder.split(",");
    const len = arr.length;
    let stack = [];
    for (let i = 0; i < len; i++) {
        stack.push(arr[i])
        while (stack.length >= 3 && stack[stack.length - 1] == "#" && 
				stack[stack.length - 2] == "#" && stack[stack.length - 3] != "#") {
            stack.pop(), stack.pop(), stack.pop()
            stack.push('#')
        }
    }
    return stack.length == 1 && stack[0] == "#"
};

递归方法

我们依旧根据二叉树的前序遍历的特性,我们进行一次前序遍历,来判断二叉树的节点数。

我们先将给定的字符串分割成数组。利用前序遍历,遍历数组,计算得到左右子节点的数量再加上根节点个数1,判断是否等于数组的长度。

var isValidSerialization = function (preorder) {
  const arr = preorder.split(",");
  let isValid = true;
  let len = dfs(0, arr);
  return isValid && len == arr.length

  function dfs(index, arr) {
    if (index >= arr.length) {
      isValid = false;
      return 0
    }
    if (arr[index] == "#") return 1;
    let leftLen = dfs(index + 1, arr);
    let rightLen = dfs(index + 1 + leftLen, arr)
    return 1 + leftLen + rightLen
  }
};

二叉树的规律,入度和出度

由于一个二叉树的每一个非空节点都有两个出度,而空节点没有出度,所有结点(根结点除外)都有一个入度。

我们可以使用 inout 来分别记录「入度」和「出度」的数量;mn分别代表「非空节点数量」和「空节点数量」。

同时,一颗合格的二叉树最终结果必然满足 in == out

但我们又不能只利用最终 in == out 来判断是否合法,这很容易可以举出反例:考虑将一个合法序列的空节点全部提前,这样最终结果仍然满足 in == out,但这样的二叉树是不存在的。

我们还需要一些额外的特性,支持我们在遍历过程中提前知道一颗二叉树不合法。

例如,我们可以从合格二叉树的前提出发,挖掘遍历过程中 inoutnm 的关系。

一颗合格二叉树 mn 的最小的比例关系是 1 : 2,也就是对应了这么一个形状:

 4 
/ \\
# #

而遍历过程中 m 和 n ``的最小的比例关系则是 1 : 0,这其实对应了二叉树空节点总是跟在非空节点的后面这一性质。

换句话说,在没到最后一个节点之前,我们是不会遇到 空节点数量 > 非空节点数量 的情况的。

非空节点数量 >= 空节点数量 在遍历没结束前恒成立:m>=n

之后我们再采用一个技巧,就是遍历过程中每遇到一个「非空节点」就增加两个「出度」和一个「入度」,每遇到一个「空节点」只增加一个「入度」。而不管每个「非空节点」是否真实对应两个子节点。

var isValidSerialization = function (preorder) {
  const arr = preorder.split(",");
  const len = arr.length;
  let enter = 0,
    out = 0;
  for (let i = 0; i < len; i++) {
    if (arr[i] != "#") out += 2;
    if (i != 0) enter++;
    if (i != (len - 1) && out <= enter) return false
  }
  return enter == out
};

由于in在JavaScript中为特殊字符,所以我们用enter代替。