【数据结构与算法】六、二叉树

前言

大家好,我是春风

今天继续来看左神的算法视频-树的部分。

一、二叉树

二叉树的特点是遍历和查找快,像HashMap使用的红黑树,其实就是一颗平衡二叉树。二叉树查询时,也是类似于二分查找的模式,每次都过滤掉一半的节点,即左/右子树及其子孙节点。

1. 二叉树遍历

二叉树遍历根据遍历顺序不同,可以分为前序遍历-根左右;中序遍历-左根右;后序遍历-左右根

在遍历时,比如前序遍历,并不是每次都按一个子树遍历完子树的根左右,再遍历下一个子树,而是注意递归的逻辑,比如深度优先的递归,是根->左->左的左-....->再递归回来遍历右。

以上三种遍历顺序,可以用递归函数来写,当然也可以利用栈来代替递归函数

比如先序遍历,递归函数中需要每次把节点递归遍历,伪代码如下:

public static void f(TreeNode head) {
    if (head == null) {
        return;
    }
    
    f(head.left);
    
    f(head.light);
}
复制代码

非递归遍历:

改用栈的方式,即可以入栈出栈的方式代替递归。

先序遍历

Stack<Node> stack = new Stack<Node>();
// 将顶节点压入栈
while (!stack.empty()) { //每次弹出节点的时候都将该节点的左右子节点压入栈
    // 弹出当前节点,并打印
    stack.pop();
    
    // 将当前节点的左孩子压入栈
    stack.push(cur.left);
    
    // 将当前节点的右孩子压入栈
    stack.push(cur.right);
}
复制代码

后序遍历后序遍历就是在先序的基础上将出栈的结果,每次都压入到一个收集栈,最后从收集栈遍历出数据。但是先序的第一次入栈时,需要改成先右再左。

Stack<Node> stack = new Stack<Node>();
Stack<Node> stack2 = new Stack<Node>();
// 将顶节点压入栈
while (!stack.empty()) { //每次弹出节点的时候都将该节点的左右子节点压入栈
    // 弹出当前节点,并将当前节点压入到收集栈
    stack.push(stack.pop());

    // 将当前节点的右孩子压入栈
    stack.push(cur.right);

    // 将当前节点的左孩子压入栈
    stack.push(cur.left);
}

// 从收集栈中取出遍历结果
while (!stack2.empty()) {
    stack2.pop();
}
复制代码

中序遍历: 将整个左边界入栈,再依次弹出,弹出时,如果当前节点有右节点,则右节点继续把它的所有左边界压入栈,周而复始。

压入整个左边界就能遍历完整颗树,是因为任何一颗二叉树都是可以被左边界或者右边界分解的而且是先左再头,比如:

/**
*    1
*   / \
* 2    3
*/
复制代码

这颗树被左边界分解就是 1、2这条线和3这条线

中序遍历代码:

while (!stack.empty() || head != null) {
    if (head != null) {// 把当前节点的左边界全部入栈
        stack.push(head.left);
        head = head.left;
    } else { //否则当前节点已经没有左孩子了,则弹出该节点,并将指向右孩子,将右孩子的左边界全部入栈,周而复始
        stack.pop();
        head = head.right;
    }
}
复制代码

2. 宽度优先

深度优先:上面的前、中、后序遍历都是属于深度优先的遍历方式,利用栈的结构,使得能优先遍历完一整颗树高。

而宽度优先则需要借助队列,比如我们要求一棵树的最大宽度?

方法一:使用队列+哈希表,每次记录当前层级的宽度

队列进行宽度优先遍历,然后用哈希表记录当前层级以及该层级的节点数

方法二:使用队列,进行宽度优先遍历:

然后创建对象curEnd、nextEnd、curNum,每次遍历时借助这些对象赋值,来存储最大宽度

队列宽度优先遍历代码

Queue<Node> queue = new LinkedList<Node>();

while (!queue.isEmpty()) {
    // 弹出当前节点,并将当前节点的左右孩子依次放入队列
    queue.poll(); // poll与peek的区别,都弹出头元素,poll弹出并删除,peek不删除

    // 将当前节点的左右孩子入队列
    queue.offer(head.left); //一般用offer不用add,队列满了再放,add直接抛出异常,offer则会返回false

    queue.offer(head.right);
}
复制代码

3. 二叉树题目

1. 怎么判断一棵树是搜索二叉树?

搜索二叉树:即有序的二叉树

中序遍历,判断遍历结果是否是有序的。

代码:递归的中序遍历

private static final int preValue = Integer.MIN_VALUE;

public static boolean isBST(Node head) {
   boolean isLeftBST = isBST(head.left);
   if(!isLeftBST) {
      return false;
   }
   if (head.value <= preValue) {
      return false;
   } else {
      preValue = head.value;
   }
   
   return isBST(head.right);
}
复制代码

非递归的中序遍历:

public static boolean isBST(Node head) {
   Stack<Node> stack = new Stack<Node>();

   while (!stack.isEmpty() || head != null) {
      if (head != null) {
         stack.push(head);
         head = head.left;
      } else {
         head = stack.pop();

         if (head.value <= preValue) {
            return false;
         } else {
            preValue = head.value;
         }
         
         head = head.right;
      }
   }
}
复制代码

2. 怎么判断一棵树是完全二叉树?

通过宽度优先遍历,当找到一个节点没有两个孩子节点,则有以下这些情况:

  1. 一个孩子都没有,则找下一个节点,下一个节点一定是叶子节点才满足
  2. 有一个右孩子,则肯定不满足
  3. 有一个左孩子,则找下一个节点,下一个节点一定是叶子节点才满足

3. 怎么判断一棵树是满二叉树?

求树高h和节点数N 满二叉树满足N=2^h - 1

或者进行宽度优先遍历,同一层级的孩子节点数一定相等

递归套路解决树形问题

递归可以解决一切树形问题,这也就是树形DP动态规划),因为只要一颗树中任何一颗子树满足条件,则这棵树也就满足条件。

  1. 判断一颗树是否平衡二叉树

平衡二叉树就是针对任何一个任何一个节点,它的左子树的高度和右子树的高度差不超过1。所以我们在递归每一个节点时,需要知道以该节点为顶点的子树是否是平衡二叉树,以及以该节点为顶点的子树的树高,这样到它父节点的时候,可以通过子节点返回的树高再做条件判断。

所以我们需要这样一个返回类:

public static class ReturnObj {
    public boolean isBst;//是否平衡树
    public int height;//树高
    
    public ReturnObj(boolean isBst, int height) {
        this.isBst = isBst;
        this.height = height;
    }
}
复制代码

然后进行树形DP-递归方法:

public static ReturnObj process(Node head) {
    if (head == null) {
        return new ReturnObj(true, 0);
    }
    
    // 左子树的判断结果
    ReturnObj leftReturnObj = process(head.left);
    // 右子树的判断结果
    ReturnObj rightReturnObj = process(head.right);
    
    // 左子树和右子树的最大的树高作为该节点子树的树高
    int height = Math.max(leftReturnObj.height, rightReturnObj.height);
    
    // 左子树是平衡二叉树,右子树也是平衡二叉树,且左子树和右子树的树高相差不超过1,则为平衡树
    boolean isBst = leftReturnObj.isBst && rightReturnObj.isBst 
            && Math.abs(leftReturnObj.height-rightReturnObj.height)<2;
    
    return new ReturnObj(isBst, height);
}
复制代码
  1. 判断一棵树是否是搜索二叉树

搜索二叉树,即每一颗子树都满足左子树最大的值<当前节点<右子树最小的值。

所以我们需要这样一个返回类:

public static class ReturnObj {
    public boolean isBst;
    public int max;
    public int min;

    public ReturnObj(boolean isBst, int max, int min) {
        this.isBst = isBst;
        this.max = max;
        this.min = min;
    }
}
复制代码

进入递归,动态判断:

public static ReturnObj process(Node head) {
    if (head == null) {
        return new ReturnObj(true, 0, 0);
    }

    // 左子树的判断结果
    ReturnObj leftReturnObj = process(head.left);
    // 右子树的判断结果
    ReturnObj rightReturnObj = process(head.right);

    // 当前节点为头的子树最大值和最小值
    int max = head.value;
    int min = head.value;

    // 如果有孩子,则看左右孩子为头的子树中求最大值,最小值
    if (leftReturnObj != null) {
        max = Math.max(leftReturnObj.max, max);
        min = Math.min(leftReturnObj.min, min);
    }

    if (rightReturnObj != null) {
        max = Math.max(rightReturnObj.max, max);
        min = Math.min(rightReturnObj.min, min);
    }

    boolean isBst = true;
    
    // 两种情况:
    // 1.如果左孩子不为空,左孩子不是搜索二叉树,或者左孩子的最大值大于当前节点值,则该子树不是搜索二叉树
    if (leftReturnObj != null && (!leftReturnObj.isBst || leftReturnObj.max > head.value)) {
        isBst = false;
    }

    // 2.如果右子树不为空,右子树不是搜索二叉树,或者右子树的最小值小于当前节点的值,则该二叉树不是搜索二叉树
    if (rightReturnObj != null && (!rightReturnObj.isBst || rightReturnObj.min < head.value)) {
        isBst = false;
    }
    
    return new ReturnObj(isBst, max, min);
}
复制代码
  1. 判断一颗二叉树是否是满树?
class ReturnInfo{
    private Integer height;// 树高
    private Integer num; // 节点个数

    public ReturnInfo(int height, int num){
        this.height = height;
        this.num = num;
    }
}

public ReturnInfo returnInfo(Node node) {

    if (node == null) {
        return new ReturnInfo(0,0);
    }

    int height = Math.max(returnInfo(node.left).height, returnInfo(node.right).height) + 1;
    int num = returnInfo(node.left).num + returnInfo(node.right).num + 1;

    return new ReturnInfo(height, num);
}

public static void main(String[] args) {
    Node head = new Node();

    int height = returnInfo(head).height;
    int num = returnInfo(head).num;

    if (num == (1<<height - 1)) {
        //如果节点个数=树高^2 -1 ,则是满树
        return true;
    }
}
复制代码

上面都是用树形DP的套路来解,一般非树形DP套路的问题,可能就需要暴力求解了

  1. 寻找两个节点的最低公共祖先?

两个节点有最低公共祖先的情况只有两种:

  • 两个节点不在同一条树高线路上,即一个节点不是另外一个节点的父节点或者祖先节点,那最低公共祖先就是他们第一次汇聚的那个祖先节点;
  • 反之另外一种情况就是一个节点是另外一个节点的祖先节点,那最低公共祖先就是该祖先节点。

解法一:先找到每个节点的父节点,然后向上遍历,当一个节点向上遍历过程中,和另外一个节点向上的节点有重合,则第一个重合的节点就是最低公共祖先

class Node{
    private Node left;
    private Node right;
}

public static Node lca(Node head, Node o1, Node o2) {
    Map<Node, Node> fatherMap = new HashMap<>();

    fatherMap.put(head, head);
    process(head, fatherMap);// 每个节点的父节点

    // 遍历o1的父亲和祖宗节点到set里
    Set<Node> set = new HashSet<>();
    Node cur = o1;
    while (cur != fatherMap.get(cur)) {
        set.add(cur);
        cur = fatherMap.get(cur);
    }
    
    cur = o2;
    while (cur != fatherMap.get(cur)) {
        if (set.contains(cur)) {
            return cur;
        }
        
        cur = fatherMap.get(cur);
    }
}

public static void process(Node head, Map<Node, Node> fatherMap) {

    if (head == null) {
        return;
    }

    fatherMap.put(head.left, head);
    fatherMap.put(head.right, head);

    process(head.left, fatherMap);
    process(head.right, fatherMap);
}
复制代码

解法二:左右子树里面都找节点o1和o2,如果左右两边都找到了,则该节点就是最低公共祖先

class Node{
    private Node left;
    private Node right;
}

public static Node lca(Node head, Node o1, Node o2) {
    if (head == null || head == o1 || head == o2) { //这个节点是要找的节点,则返回该节点,往上返回
        return head;
    }
    
    // 从左右树里找o1 o2,找到的节点往上一直返回
    Node leftNode = lca(head.left, o1, o2);
    Node rightNode = lca(head.right, o1, o2);
    
    if (leftNode != null && rightNode != null) {
        //两边都找到了,则当前节点就是最低公共祖先
        return head;
    }
    
    // 否则哪边找到了就返回哪边的节点
    return leftNode != null ? leftNode : rightNode;
}
复制代码
  1. 找一个节点的前驱节点/后继节点,树中每个节点都有指向父节点的指针

前驱节点就是找中序遍历结果中的前面一个节点,找前驱节点,对于该节点来说,前驱节点只会存在两种情况:

  • 该节点有左子树,则前驱节点就是左孩子一直取右边界的那个节点
  • 该节点没有左子树,则往上找父节点
class Node{
    private Node left;
    private Node right;
    private Node parent;
}

public static Node lca(Node head) {
    if (head.right != null) {
        return process(head.right;
    } else {
        Node parent = head.parent;
        while (parent != null && parent.left != head) {
            head = parent;
            parent = head.parent;
        }
        return parent;
    }
}

public static Node process(Node head) {

    while (head.left != null) {
        head = head.left;
    }
    
    return head;
}
复制代码

拓展:

  1. 二叉树的序列化和反序列化

将树形结构序列化为一个字符串,并且能通过该字符串反序列化出原来的树形结构。

通过先序遍历+空节点的处理:

  1
 / \
1   1
   /
  1
复制代码

先序遍历结果:1_1_##1_1##_#

  1. 微软折痕题:一个纸条,每次对折,求根据对折次数从上到下打印凹凸折痕?

规律:第一次对折,会产生一个凹折痕,第二次对折,就会在凹折痕的上下分别产生一个凹折痕和一个凸折痕,如是反复。

所以,用树形结构来表示,就是顶节点和每一个左子树节点都会是凹折痕,右孩子节点会是凸折痕,树的层级树就是对折次数,而这颗树通过中序遍历后的结果,就是纸条从上到下,折痕的结果。

     凹1
    /  \
   凹2  凸2
  /  \ /  \
凹3 凸3 凹4 凸4
复制代码

中序遍历结果:凹3-凹2-凸3-凹1-凹4-凸2-凸4

public static void process(int i, int N, boolean down) {
    if (i >= N) {
        return;
    }

    process(i+1, N ,true);//中序遍历:
    System.out.println(down?"凹":"凸");
    process(i+1, N, false);
}

public static void main(String[] args) {
    process(0,3,true);
}
复制代码

总结:

  1. 掌握树的基础是遍历,分为两类:深度优先和宽度优先;
  2. 利用递归做树形DP(动态规划),可以解决大多数树的判断问题
  3. 二叉树的遍历可以扩展为多叉树,之前学习过的网格问题就是一个样例

下一次,继续学习《图》~

猜你喜欢

转载自juejin.im/post/7131593550252212237