javascript:二叉树(Binary-Tree)与经典问题

128 阅读10分钟

1.二叉树的基础知识

1.基本性质

  • 每个结点的度最多为 2。
  • 度为 0 的结点比度为 2 的结点多一个。 证明:设度为 0 的结点为 n0,度为 1 的结点为 n1,度为 2 的结点为 n2。那么总结点数为 n0 + n1 + n2,而总边数为 0 * n0 + 1 * n1 + 2 * n2。而我们知道总边数等于总结点数减去 1,那么有 n0 + n1 + n2 − 1 = 0 * n0 + 1 * n1 + 2 * n2,即 n0 − 1 = n2

2.遍历

根据根结点被访问的时机,分为前序遍历(根、左子树、右子树)、中序遍历(左子树、根、右子树)和后序遍历(左子树、右子树、根)。
通过中序遍历和前后序遍历任意一种可以还原一棵二叉树
举例:拿中序遍历和前序遍历还原,首先在前序遍历中第一个节点是根节点,再在中序遍历中查找根节点的位置,找到位置就可以把中序遍历拆成左子树的中序遍历和右子树的中序遍历两部分,又因为左子树的中序遍历的节点数量等于左子树的前序遍历的节点数量,就可以根据左子树中序遍历节点数量找到左子树前序遍历的结果,剩下的就是右子树前序遍历结果,再对子树进行上述操作,最终可以还原一棵二叉树。

3.特殊的二叉树

  • 完全二叉树 (complete binary tree)
    • 编号为i(从1开始)的子节点:左孩子编号:2*i,右孩子编号:2*i+1,那么就不需要记录子树的地址,节省大量之前存储边的空间,优化思想:计算式和记录式直接的转行
    • 可以用连续空间存储(数组)
  • 满二叉树 (full binary tree) – 指所有结点的度都是 0 或 2 的二叉树
  • 完美二叉树 (perfect binary tree)

4.关于树结构的理解

结点表示集合 (set),边表示关系 (relationship)。

2.学习二叉树的作用

1.二叉树是理解高级数据结构的基础

  1. 完全二叉树 – 堆、优先队列
  2. 多叉树/森林 – 字典树、AC 自动机、并查集
  3. 二叉排序树 (BST, Binary Search Tree) – AVL 树、2-3 树、红黑树

2.二叉树是练习递归技巧的最佳选择

设计/理解递归程序:

  1. 数学归纳法 -> 结构归纳法
  2. 赋予递归函数一个明确的意义
  3. 思考边界条件
  4. 实现递归过程

3.左孩子右兄弟表示法节省空间

多叉树转二叉树 拥有n个节点的k叉树,指针域有k*n,有n-1条边,那么浪费的指针域就是k*n - (n-1) => (k-1)*n + 1,得出k越大浪费的越多

3.经典面试题-二叉树的基本操作

144. 二叉树的前序遍历

// root:根节点地址
// ans:结果数组
// 赋予函数意义:前序遍历root这棵树,把root的前序遍历结果,添加到ans数组的末尾
var preorder = function(root, ans){
    // 边界条件
    if(root === null) return;
    // 当前节点的值放到数组的末尾
    ans.push(root.val);
    // 递归的把左子树的前序遍历结果添加到ans数组末尾
    preorder(root.left, ans);
    // 右子树同理
    preorder(root.right, ans);
    return ;
}
var preorderTraversal = function(root) {
    let ans =  [];
    preorder(root, ans);
    return ans;
};

589. N 叉树的前序遍历

函数意义:前序遍历以root为根节点的N叉树 边界条件:root为空时不需要遍历 递归过程:前序遍历root下的每一棵子树

var __preorder = function(root, ans){
    if(root === null) return;
    ans.push(root.val);
    // 前序遍历root的每一棵子树
    for(let i of root.children){
        __preorder(i, ans);
    }
}
var preorder = function(root) {
    let ans = [];
    __preorder(root, ans);
    return ans;
};

226. 翻转二叉树

交换左右子树,再递归翻转左右子树。

var invertTree = function(root) {
    // root为空不需要反转
    if(root === null) return root;
    // 交换左右子树
    [root.left, root.right] = [root.right, root.left];
    // 递归反转左右子树
    invertTree(root.left);
    invertTree(root.right);
    return root;
};

剑指 Offer 32 - II. 从上到下打印二叉树 II

使用将行号作为参数的递归即可。也可以使用队列 BFS 来进行层序遍历。

// k:当前节点要放到第几个数组中
var getResult = function(root, k, ans){
    if(root === null) return ;
    // 证明ans中没有第k个数组,这时在ans末尾添加一个空数组
    if(k === ans.length) ans.push([]);
    // 把root的值放入第k个数组的末尾
    ans[k].push(root.val);
    // 把左子树放到第k+1个数组
    getResult(root.left, k + 1, ans);
    // 当把所有左子树中的值,放到ans数组中以后,再把右子树的值放到相关的ans数组中,
    // 相当于是从左到右把每一行的值存储到数组中
    getResult(root.right, k + 1, ans);
    return;
}
var levelOrder = function(root) {
    let ans = [];
    getResult(root, 0, ans);
    return ans;
};

107. 二叉树的层序遍历 II

先和上题一样层序遍历,然后反转数组

var getResult = function(root, k, ans){
    if(root === null) return ;
    if(k === ans.length) ans.push([]);
    ans[k].push(root.val);
    getResult(root.left, k + 1, ans);
    getResult(root.right, k + 1, ans);
    return;
}
var levelOrderBottom = function(root) {
    let ans = [];
    getResult(root, 0, ans);
    // 编程技巧,用两个指针颠倒数组
    // for(let i = 0, j = ans.length - 1; i < j; i++, j--){
    //     [ans[i], ans[j]] = [ans[j], ans[i]]
    // }
    ans.reverse();
    return ans;
};

103. 二叉树的锯齿形层序遍历

先和上上题一样层序遍历,然后把偶数行倒序

var getResult = function(root, k, ans){
    if(root === null) return ;
    if(k === ans.length) ans.push([]);
    ans[k].push(root.val);
    getResult(root.left, k + 1, ans);
    getResult(root.right, k + 1, ans);
    return;
}
var reverse = function(ans){
    for(let i = 0, j = ans.length - 1; i < j; i++, j--){
        [ans[i], ans[j]] = [ans[j], ans[i]]
    }
    return ;
}
var zigzagLevelOrder = function(root) {
    let ans = [];
    getResult(root, 0, ans);
    for(let i = 1; i < ans.length; i += 2){
        reverse(ans[i]);
    }
    return ans;
};

4.经典面试题-二叉树的进阶操作

110. 平衡二叉树

对获取树高的函数进行修改,在获取树高的函数中对平衡进行判断即可。

// // 返回这棵树的树高
// var getHeight = function(root){
//     // 空树树高是0
//     if(root === null) return 0;
//     let l = getHeight(root.left);
//     let r = getHeight(root.right);
//     // 当前树的树高是左右两棵子树中较高的那棵高度加1
//     return Math.max(l, r) + 1;
// }
// 基于原来的方法,改写成判断这棵树是否平衡
// 加设定,当这棵树不平衡时返回一个负数
var getHeight = function(root){
    if(root === null) return 0;
    let l = getHeight(root.left);
    let r = getHeight(root.right);
    // 当左子树或者右子树不平衡了,当前树也不平衡,返回负数
    if(l < 0 || r < 0) return -2;
    // 左子树平衡右子树也平衡,这时要是左右子树高度差大于1,当前树也不平衡,返回负数
    if(Math.abs(l - r) > 1) return -2;
    return Math.max(l, r) + 1;
}
// 技巧:如何定义一个函数的功能
var isBalanced = function(root) {
    // 根据函数定义,平衡时应该返回一个大于等于0的值
    return getHeight(root) >= 0;
};

112. 路径总和

递归向下求值,每次减去当前结点的值,递归结束的条件是遇到叶子结点且刚 好求得的值为 0。

var hasPathSum = function(root, targetSum) {
    // 空树什么都找不到
    if(root === null) return false;
    // root是叶子节点的话,targetSum和当前节点值刚好相等时返回true
    if(root.left === null && root.right === null) return targetSum === root.val;
    // 如果左子树不为空,且在左子树中可以找到一条路径的值等于targetSum减去当前节点值的话,能找到
    if(root.left && hasPathSum(root.left, targetSum - root.val)) return true;
    // 右子树一样
    if(root.right && hasPathSum(root.right, targetSum - root.val)) return true;
    // 即在左边找不到,又在右边找不到,返回false
    return false;
};

105. 从前序与中序遍历序列构造二叉树

递归拆分。前序遍历的第一个结点是根结点,在中序遍历中找到该根结点的位置,区分出左右子树,再递归向下拆分左右子树即可。

var buildTree = function(preorder, inorder) {
    // 如果前序遍历为空,返回一棵空树
    if(preorder.length === 0) return null;
    let pos = 0;
    // 找根节点位置
    while(inorder[pos] !== preorder[0]) ++pos;
    // 拆分左右子树的前中序列
    let l_pre = [], l_in = [], r_pre = [],r_in = [];
    for(let i = 0; i < pos; i++){
        l_pre.push(preorder[i + 1]);
        l_in.push(inorder[i]);
    }
    for(let i = pos + 1; i < preorder.length; i++){
        r_pre.push(preorder[i]);
        r_in.push(inorder[i]);
    }
    // 建立树,传入根节点的值
    let root = new TreeNode(preorder[0]);
    // 递归建立左右子树
    root.left = buildTree(l_pre, l_in);
    root.right = buildTree(r_pre, r_in);
    return root;
};

222. 完全二叉树的节点个数

递归数左子树和右子树的结点,加上根结点即可。

var countNodes = function(root) {
    if(root === null) return 0;
    return countNodes(root.left) + countNodes(root.right) + 1;
};

剑指 Offer 54. 二叉搜索树的第k大节点

二叉搜索树性质:中序遍历结果是有序序列 统计右子树的结点个数 cntr,递归处理即可。如果 k = cntr + 1,那么是根结点,如果 k ≤ cntr,那么在右子树中且还是第 k 大,否则在左子树中且是第 k − (cntr + 1) 大。

// 获取二叉树节点个数
var getCount = function(root){
    if(root === null) return 0;
    return getCount(root.left) + getCount(root.right) + 1;
}
var kthLargest = function(root, k) {
    // 右子树的节点数量
    let cnt_r = getCount(root.right);
    if(k <= cnt_r) return kthLargest(root.right, k);
    if(k === cnt_r + 1) return root.val;
    // 因为已经排除掉cnt_r + 1个元素,所以在左子树中找k - (cnt_r + 1)个元素
    return kthLargest(root.left, k - (cnt_r + 1));
};

剑指 Offer 26. 树的子结构

先和根结点比较,再递归地和左右子树比较即可。

// 两棵树贴合匹配
var isMatch = function(A, B){
    // 匹配成功
    if(B === null) return true;
    if(A === null) return false;
    if(A.val !== B.val) return false;
    return isMatch(A.left, B.left) && isMatch(A.right, B.right);
}
// 在大树中找出一个子部份,能够匹配上B
var isSubStructure = function(A, B) {
    if(B === null) return false;
    if(A === null) return false;
    if(A.val === B.val && isMatch(A, B)) return true;
    return isSubStructure(A.left, B) || isSubStructure(A.right, B);
};

662. 二叉树最大宽度

参考完全二叉树,给节点进行编号,根节点从0开始编号,利用队列层序遍历后,每一层内编号最大结点和最小编号结点的编号之差加一即为所求。

var widthOfBinaryTree = function(root) {
    let ans = 0;
    // 队列中存储节点和节点编号打包成的数组
    let q = [];
    // 先把根节点和根节点编号0放入队列中
    q.push([root, 0]);
    // 当队列不为空时,每次处理一行
    while(q.length){
        // 记录当前队列中的元素数量,其实就是上一行的元素数量
        let cnt = q.length;
        // l:编号最小值 r:编号最大值
        let l = q[0][1], r = q[0][1];
        for(let i = 0; i < cnt; i++){
            // 队首元素
            let p = q[0][0];
            // 队首编号
            let ind = q[0][1];
            // 更新最大编号
            r = ind;
            // 把上一行的元素的子节点也就是下一行入队列
            // 下面注释的写法会存在整型超界问题,需要缩小编号范围
            // 做一个位移,编号方式改为父节点编号减去上一行最小编号再乘2,效果是等价的
            // if(p.left) q.push([p.left, ind * 2]);
            if(p.left) q.push([p.left, (ind - l) * 2]);
            if(p.right) q.push([p.right, (ind - l) * 2 + 1]);
            // 上一行出队列
            q.shift();
        }
        // 在原宽度和当前这一行的最大宽度取较大值
        ans = Math.max(ans, r - l + 1);
    }
    return ans;
};

968. 监控二叉树

dp 的第一维表示“父结点”是否放置摄像头,第二维表示“当前结点”是否放 置摄像头。例如 dp[0][0] 表示父结点不放置摄像头,当前结点也不放置摄像头 的情况下,覆盖整棵树所需要的最少摄像头数。 对放置情况进行分情况讨论。

// dp数组存的值,是不包括父节点放的摄像头
var getDP = function(root, dp){
    // 当前节点是空节点
    if(root === null){
        // 当前不放,覆盖节点,需0个
        dp[0][0] = 0;
        // 空节点不能放摄像头,给个极大值
        dp[0][1] = 10000;
        // 当前不放,覆盖节点,需0个
        dp[1][0] = 0;
        // 空节点不能放摄像头,给个极大值
        dp[1][1] = 10000;
        return ;
    }
    // 当前节点是叶子节点
    if(root.left === null && root.right === null){
        // 父不放,当前也不放,想覆盖叶子节点不可能,给极大值
        dp[0][0] = 10000;
        // 父不放,当前放,需1个
        dp[0][1] = 1;
        // 父放,当前不放,需0个
        dp[1][0] = 0;
        // 父放,当前放,需1个
        dp[1][1] = 1;
        return ;
    }
    // 获得左右子树的dp值
    let l = [[],[]], r = [[],[]];
    getDP(root.left, l);
    getDP(root.right, r);
    // 当前节点是左右子树的父节点,所以dp的第二个索引和l,r的第一个索引一致
    // 父不放,当前也不放,还想覆盖子树,要么左放右不放,要么左不放右放,要么都放,所以在3种情况取最小值
    dp[0][0] = Math.min(Math.min(l[0][1] + r[0][0], l[0][0] + r[0][1]), l[0][1] + r[0][1]);
    // 父放,当前也不放,允许子不放,所以在dp[0][0]的3种情况再加左右都不放,4种情况取最小值
    dp[1][0] = Math.min(dp[0][0], l[0][0] + r[0][0]);
    // 父不放,当前放,允许子不放,所以也是在4种情况取最小值再加上当前放的一个
    dp[0][1] = Math.min(Math.min(l[1][0] + r[1][0], l[1][1] + r[1][1]), Math.min(l[1][0] + r[1][1], l[1][1] + r[1][0])) + 1;
    // 与dp[0][1]情况一致
    dp[1][1] = dp[0][1];
    return ;
}
var minCameraCover = function(root) {
    let dp = [[],[]];
    // 要覆盖掉当前root所在的所有子树中所有节点,最少用的摄像头数量,分成dp数组的4种状态,记录进去
    getDP(root, dp)
    // 当前节点是根节点,没有父节点,所以在父节点不放的两种情况下取最小值
    return Math.min(dp[0][0], dp[0][1]);
};