二叉树专题一

202 阅读10分钟

这是我参与2022首次更文挑战的第14天,活动详情查看:2022首次更文挑战 | 创作学习持续成长,夺宝闯关赢大奖 - 掘金 (juejin.cn)

题目链接

  1. 二叉树的前序遍历 leetcode-cn.com/problems/bi…
  2. 二叉树的后序遍历 leetcode-cn.com/problems/bi…
  3. 验证二叉树的前序序列化 leetcode-cn.com/problems/ve…
  4. 路径总和 leetcode-cn.com/problems/pa…
  5. 从前序与中序遍历序列构造二叉树 leetcode-cn.com/problems/co…

题解及分析

二叉树的前序遍历

给你二叉树的根节点root,返回它节点值的前序遍历。

(前序遍历参考题目总结) 思路一:递归
二叉树天然适合递归。在遍历节点的过程中,我们可以制定子节点的遍历规则,递归的寻找值

var preorderTraversal = function(root) {
    const result = []
    function preOrder(root) {
        if(!root) return 
        result.push(root.val)
        preOrder(root.left)
        preOrder(root.right)
    }
    
    preOrder(root)
    return result
}

思路二:迭代
利用栈的特性来完成子节点查找

  • 每次遍历时,将当前节点存入栈中,将指针指向左子节点并不断查找左子节点
  • 如果到达叶子节点,则从栈顶弹出第一个节点,将指针指向右子节点
  • 继续以左子节点为优先查找
var preorderTraversal = function(root) {
    const result = []
    if (root == null) {
        return result
    }
    const stack = []
    while(stack.length || root) {
        while(root) {
            result.push(root.val)
            stack.push(root)
            root = root.left
        }

        root = stack.pop()
        root = root.right
    }

    return result
}

思路三:莫里斯遍历(其实我也不太懂为什么)
引用下leetcode的说法

有一种巧妙的方法可以在线性时间内,只占用常数空间来实现前序遍历。这种方法由J.H.Morris在1979年的论文「Traversing Binary Trees Simply and Cheaply」中首次提出,因此被称为Morris遍历。
Morris遍历的核心思想是利用树的大量空闲指针,实现空间开销的极限缩减。其前序遍历规则总结如下:
1.新建临时节点,令该节点为root;
2.如果当前节点的左子节点为空,将当前节点加入答案,并遍历当前节点的右子节点;
3.如果当前节点的左子节点不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点:

  • 如果前驱节点的右子节点为空,将前驱节点的右子节点设置为当前节点。然后将当前节点加入答案,并将前驱节点的右子节点更新为当前节点。当前节点更新为当前节点的左子节点。
  • 如果前驱节点的右子节点为当前节点,将它的右子节点重新设为空。当前节点更新为当前节点的右子节点。 4.重复步骤2和步骤3,直到遍历结束。
var preorderTraversal = function(root) {
    const result = []
    if (root == null) {
        return result
    }
    let p1 = root
    let p2 = null

    while(p1 != null){
        p2 = p1.left
        if(p2 != null) {
            while(p2.right != null && p2.right != p1) {
            p2 = p2.right
            }
            if(p2.right == null) {
                result.push(p1.val)
                p2.right = p1
                p1 = p1.left
                continue
            } else {
                p2.right = null
            }
        } else {
            result.push(p1.val)
        }
        p1 = p1.right
    }
    return result
}

二叉树的后序遍历

给你一棵二叉树的根节点root,返回其节点值的后序遍历。

(后序遍历参考题目总结)

思路一:递归
还是那句话,二叉树天然适合递归。
唯一不同的是,root值的保存要放在遍历完左右节点之后

var postorderTraversal = function(root) {
    const result = []
    function postOrder(root) {
        if(root === null) {
            return result
        }
        postOrder(root.left)
        postOrder(root.right)
        result.push(root.val)
    }
    postOrder(root)
    return result
}

思路二:迭代 其实跟前序遍历差不多,但是每次保存值的时机略有不同

  • 需要用一个节点来记录上一个节点哦
var postorderTraversal = function(root) {
    const result = []
    if(root == null) {
        return result
    }
    const stack = []
    let prev = new TreeNode
    while(root !== null || stack.length) {
        while(root !== null) {
            stack.push(root)
            root = root.left
        }
        root = stack.pop()
        if(root.right === null || root.right == prev) {
            result.push(root.val)
            prev = root
            root = null
        } else {
            stack.push(root)
            root = root.right
        }
    }
    return result
}

思路三:莫里斯遍历
思路也差不多,详细参考leetcode 二叉树的后序遍历 - 二叉树的后序遍历 - 力扣(LeetCode) (leetcode-cn.com)

var postorderTraversal = function(root) {
    const result = []
    if(root == null) {
        return result
    }
    
    let p1 = root
    let p2 = null
    while(p1 !== null) {
        p2 = p1.left
        if(p2 !== null) {
            while(p2.right !== null && p2.right != p1) {
                p2 = p2.right
            }
            if(p2.right == null) {
                p2.right = p1
                p1 = p1.left
                continue
            } else {
                p2.right = null
                
            }
        }
    }

    return result
}

验证二叉树的前序序列化

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

       9
     /   \
    3     2
   / \   / \
  4   1  #  6
 / \ / \   / \
 # # # #   # #

例如,上面的二叉树可以被序列化为字符串 "9,3,4,#,#,1,#,#,2,#,6,#,#",其中#代表一个空节点。 给定一串以逗号分隔的序列,验证它是否是正确的二叉树的前序序列化。编写一个在不重构树的条件下的可行算法。 每个以逗号分隔的字符或为一个整数或为一个表示 null 指针的'#' 。 你可以认为输入格式总是有效的,例如它永远不会包含两个连续的逗号,比如 "1,,3" 。 示例 1: 输入: "9,3,4,#,#,1,#,#,2,#,6,#,#" 输出: true

以下答案参考拍案叫绝的两种解法:「栈」和「入度出度」 - 验证二叉树的前序序列化 - 力扣(LeetCode) (leetcode-cn.com)

思路一:转换
先上流程 1615551708-uxodPT-331.gif
这个思路重点在于:

  • 叶子节点的特点是其子节点为#
  • 我们每遍历完一个节点之后,如果他的两个子节点均为#,那么我们就用#来替代这个节点
    • 这么做的意义有两个
      1.统一化管理节点
      2.将问题转换为最终对#的判断

通过这些转换,最终节点会变成一个根节点

var isValidSerialization = function(preorder) {
    preorder = preorder.split(',')
    const n = preorder.length
    const stack = []
    for(let i = 0; i < n; i++) {
        stack.push(preorder[i])
        /**
        * 判断的条件有三
        * stack中存在3个及以上元素
        * stack中最后两个元素为#
        * stack中倒数第三个元素不为#
        */ 
        while(stack.length >= 3 && stack[stack.length - 1] === '#' && stack[stack.length - 2] === '#' && stack[stack.length - 3] !== '#') {
            stack.splice(-3)
            stack.push('#')
        }
    }
    return stack.length === 1 && stack.pop() === '#'
}

思路二:入度出度
所谓入度出度指的是:

  • 入度:有多少个节点指向它
  • 出度:它指向多少个节点
    换言之,在一棵二叉树中:
  • 每个空节点("#")会提供0个出度和1个入度。
  • 每个非空节点会提供2个出度和1个入度(根节点的入度是0)。
    在二叉树中,所有节点的入度之和等于出度之和。那么说到这,问题就转变成为"所有节点的出度减去入度是否为0"

写法一:用栈来保存上一个节点

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

写法二:直接计算出入度

var isValidSerialization = function(preorder) {
    preorder = preorder.split(',')
    const n = preorder.length
    let i = 0
    let count = 1
    while (i < n) {
        if (!count) {
            return false
        }
        if (preorder[i] === '#') {
            --count
            ++i
        } else {
            ++i
            ++count
        }
    }
    return count === 0
}

路径总和

给你二叉树的根节点 root 和一个表示目标和的整数targetSum。判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和targetSum。如果存在,返回true;否则,返回false。
叶子节点 是指没有子节点的节点。 示例 1:

输入:root = [5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22
输出:true
解释:等于目标和的根节点到叶节点路径如上图所示。

题目中需要注意的是根节点到叶子节点的路径,换句话说,如果root有子节点但自身的值等于targetSum,也需要返回false

思路一:广度遍历
我们自上而下遍历每一个节点,如果节点存在子节点,则把节点和累加值存起来,一直到找到叶子节点时判断值和targetSum是否相等

  • 维护一个栈,把根节点保存起来
  • 维护一个数组,把根节点的值保存起来
  • 遍历栈,如果有子节点,则按左/右的顺序存入节点,同时将栈顶的节点值和当前节点相加的值存入数组
var hasPathSum = function(root, targetSum) {
    if(!root) {
        return false
    }

    const queue = [root]
    const res = [root.val]

    while(queue.length) {
        const cur = queue.pop()
        const temp = res.pop()

        if(cur.left === null && cur.right === null) {
            if(temp === targetSum) return true
        }

        if(cur.left) {
            queue.push(cur.left)
            res.push(temp + cur.left.val)
        }

        if(cur.right) {
            queue.push(cur.right)
            res.push(temp + cur.right.val)
        }
    }

    return false
}

思路二:深度遍历
从根节点向下寻找

  • 遇到子节点则先查找左子节点,再查找右节点
  • 如果到底部后仍找不到答案,则优先找同个父节点的右节点
var hasPathSum = function(root, targetSum) {
    if(!root) {
        return false
    }

    if(root.left === null && root.right === null) {
        return root.val === targetSum
    }

    return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val)
}

思路三:回溯
先上leetcode大佬代码随想录的示意图

image.png

  • 和深度遍历的区别是,某些时候深度遍历找的是所有路径中某个符合条件的路径,而回溯是按照节点的左右子顺序遍历,找到符合条件的节点则返回true,这道题并不明显,不过还是记录下
var hasPathSum = function(root, targetSum) {
    if(!root) {
        return false
    }
    return backTracking(root, targetSum - root.val)
}

var backTracking = function(root, curSum) {
    if(root.left === null && root.right === null && curSum === 0) {
        return true
    }

    if(root.left === null && root.right === null) {
        return false
    }

    if(root.left) {
        curSum -= root.left.val
        if(backTracking(root.left, curSum)) return true
        curSum += root.left.val
    }

    if(root.right) {
        curSum -= root.right.val
        if(backTracking(root.right, curSum)) return true
    }
    return false
}

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

给定两个整数数组 preorder 和 inorder ,其中 preorder 是二叉树的先序遍历, inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。

示例 1: image.png 输入: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出: [3,9,20,null,null,15,7]

示例 2:
输入: preorder = [-1], inorder = [-1]
输出: [-1]

这道题实际上考的是我们对前序遍历和中序遍历的理解

思路一:分而治之+递归
对于任意一颗树而言,前序遍历的形式总是
根节点, [左子树的前序遍历结果], [右子树的前序遍历结果]
即根节点总是前序遍历中的第一个节点。
而中序遍历的形式总是
[左子树的中序遍历结果], 根节点, [右子树的中序遍历结果]


leetcode官方提供了一个很详细的视频解释了这个做法从前序与中序遍历序列构造二叉树 - 从前序与中序遍历序列构造二叉树 - 力扣(LeetCode) (leetcode-cn.com),这里搬运下最重要的一个图

微信截图_20220227162345.png

那么我们可以使用递归的思路,倒推每一个二叉树的子节点

  • 我们假定每一个部分的根节点下标都为pIndex,这个节点就是左右子树分割点
  • 以此类推,参考关系图指定左右子树的起始和结束
var buildTree = function(preorder, inorder) {
    let preLen = preorder.length
    let inLen = inorder.length
    // 用map保存中序遍历的节点下标
    const map = new Map()
    for(i = 0; i < inLen; i++) {
        map.set(inorder[i], i)
    }

    return build(preorder, 0, preLen - 1, map, 0, inLen - 1)
}

var build = function(preorder, preleft, preright, map, inleft, inright) {
    if((preleft > preright) || (inleft > inright)) {
        return null
    }
    let val = preorder[preleft]
    const root = new TreeNode(val)
    let pIndex = map.get(val)

    root.left = build(preorder, preleft + 1, pIndex - inleft + preleft, map, inleft, pIndex - 1)
    root.right = build(preorder, pIndex - inleft + preleft + 1, preright, map, pIndex + 1, inright)

    return root
}

思路二:迭代
一样参考leetcode的官方迭代,大致思路如下:

  • 我们用一个栈和一个指针辅助进行二叉树的构造。初始时栈中存放了根节点(前序遍历的第一个节点),指针指向中序遍历的第一个节点;
  • 我们依次枚举前序遍历中除了第一个节点以外的每个节点。如果index恰好指向栈顶节点,那么我们不断地弹出栈顶节点并向右移动index,并将当前节点作为最后一个弹出的节点的右儿子;如果index和栈顶节点不同,我们将当前节点作为栈顶节点的左儿子;
  • 无论是哪一种情况,我们最后都将当前的节点入栈。
var buildTree = function(preorder, inorder) {
    if(preorder.length === 0) return null
    let stack = []
    let inorderIndex = 0
    const root = new TreeNode(preorder[0])
    stack.push(root)
    for(let i = 1; i < preorder.length; i++) {
        let curNodeVal = new TreeNode(preorder[i])
        let peek = stack[stack.length - 1]
        if(peek.val !== inorder[inorderIndex]) {
           peek.left = curNodeVal
        } else {
            let fatherNode = null
            while(stack.length && stack[stack.length-1].val === inorder[inorderIndex]) {
                inorderIndex++
                fatherNode = stack.pop()
            }
            fatherNode.right = curNodeVal
        }
        stack.push(curNodeVal)
    }
    return root
}

(这个解法我也没理明白...先记录吧)

题目总结


二叉树最终要的是各种遍历

前序遍历指的是

  • 记录根节点
  • 从根节点开始,一直寻找左子节点向下遍历
  • 到达叶节点后,然后返回上一个节点
  • 取右子节点
  • 继续深度优先遍历,依旧先左后右
    前序遍历记录的顺序是 中-左-右

中序遍历指的是

  • 从根节点开始,一直寻找左子节点向下遍历
  • 到达叶节点后,然后返回上一个节点
  • 记录根节点
  • 取右子节点
  • 继续深度优先遍历,依旧先左后右
    中序遍历记录的顺序是 左-中-右

后序遍历指的是

  • 从根节点开始,一直寻找左子节点向下遍历
  • 到达叶节点后,然后返回上一个节点
  • 取右子节点
  • 记录根节点
  • 继续深度优先遍历,依旧先左后右
    中序遍历记录的顺序是 左-右-中

示例一: image.png

  • 先序遍历:A B D H E I C F J K G
  • 中序遍历: D H B E I A J F K C G
  • 后序遍历: H D I E B J K F G C A

示例二: image.png

  • 前序遍历: A B D F G H I E C
  • 中序遍历: F D H G I B E A C
  • 后序遍历: F H I G D E B C A