图解红黑树及Java进行红黑二叉树遍历的方法

红黑树
红黑树是一种数据结构与算法课堂上常常提到但又不会细讲的树,也是技术面试中经常被问到的树,然而无论是书上还是网上的资料,通常都比较刻板难以理解,能不能一种比较直观的方式来理解红黑树呢?本文将以图形的方式来解释红黑树的插入与删除操作。
对树结构的学习是一个递进的过程,我们通常所接触的树都是二叉树,二叉树简单来说就是每个非叶子节点都有且只有两个孩子,分别叫做左孩子和右孩子。二叉树中有一类特殊的树叫二叉查找树,二叉查找树是一种有序的树,对于每个非叶子节点,其左子树的值都小于它,其右子树的值都大于它。比二叉查找树更进一步的是二叉平衡树,二叉平衡树除了保证有序外,还能够保持每个节点左右子树的高度相差不超过1。常见的平衡树有AVL树,Treap,红黑树,伸展树,等等。
红黑树是一种二叉查找树,但在每个节点上增加一个存储位表示节点的颜色,可以是RED或BLACK。通过对任何一条从根到叶子的路径上各个节点着色方式的限制,红黑树确保没有一条路径会比其他路径长出两倍,因而是接近平衡的。
红黑树满足一下5个性质:

  • 每个节点是红色或者黑色;
  • 根节点是黑色;
  • 每个叶子节点NIL是黑色;
  • 如果一个节点是红色,则它的两个孩子都是黑色;(每条路径上不能有两个连续的红色节点)
  • 任一节点到其所有子孙叶子节点NIL的路径上包含相同数目的黑色节点。

注意,在红黑树中,把传统二叉树的叶子节点的孩子指向NIL,称NIL为红黑树中的叶子节点。NIL节点中含有指向父节点的指针,这可能是需要把null改为NIL的原因。

一、插入操作
首先以二叉查找树的插入方式(插入的新节点都在叶子节点处)插入新的节点,并将其绘为红色。然后再重绘其颜色或旋转以保持红黑树的性质,调整分为以下三种情况:
1 新节点N没有父节点(即位于根上)
将新节点N绘为黑色。

2 新节点N的父节点P为黑色
不用调整。

3 新节点N的父节点P为红色
因为红黑树不允许有两个连续的红色节点(性质4),所以需要调整,根据N的叔父节点颜色分为两种情况:(我们以 N的父节点P为左孩子为例,P为右孩子的情况类似,不再详述)
3.1 新节点N的叔父节点U为红色
将新节点N的父节点P和叔父节点U都绘为黑色,将其祖父节点G绘为红色,这样保证从G到每个null节点的路径上所包含的黑色节点个数与原来保持一致。但由于我们把G变成了红色,如果G的父亲也是红色,就可能导致连续两个红色节点(违反性质4),所以,需要重新检查G是否违反了红黑树性质。

3.2 新节点N的叔父节点U为黑色
若新节点N是其父节点P的左孩子:将其父节点P绘为黑色,祖父节点G绘为红色,然后对G进行一次右旋转。

若新节点N是其父节点P的右孩子:对其父节点进行一次左旋转,问题转化为左孩子的情况。

二、删除操作
《算法导论》和维基百科上的做法都是,当删除一个黑色节点D时,把D的黑色“下推”至其子节点C,也就是说C除了本身的颜色外多了一重额外的黑色,然后不断把这重额外的黑色沿树上移,直到碰到一个红色节点,把其变为黑色以保证路径上黑色节点数目不变,或者移到树的根部,这样所有路径上的黑色节点数目都减一,保持相等。上移过程中可能需要旋转和修改一些节点的颜色,以保证路径上黑色节点数目不变。
这种做法可能有利于代码的实现(可用迭代的方式),但却不便于理解(个人认为)。本着理解优先的目的,我根据被删除节点D的孩子是否为NIL做如下分类:
1 被删除节点D的两个孩子都是NIL
1.1 被删除节点D是红色
用NIL替换D即可。

1.2 被删除节点D是黑色(我们以D是左孩子为例)
1.2.1 被删除节点D的兄弟节点B的两个孩子都为NIL
将D的兄弟节点B绘为红色,父节点P绘为黑色。

图中半红半黑表示该节点可能为红色,也可能为黑色。如果P原来是红色,这样修改后路径上的黑色节点数目和删除D之前一样;如果P原来是黑色,那么删除D后会导致路径上黑色节点的数目比删除前少了一个,所以还需继续检查经过P的路径上黑色节点数目的改变是否影响了红黑树的性质。
1.2.2 被删除节点D的兄弟节点B有一个孩子不为NIL
这个孩子一定是红色的,否则从D的父节点到各个叶子节点的路径上黑色节点的数目就会不等(违反性质5)。
若这个孩子为右孩子,将B的这个右孩子绘为黑色,B绘为其父节点P原来的颜色,P绘为黑色,然后对P进行一次左旋转。

若这个孩子为左孩子,将B的这个左孩子绘为黑色,B绘为红色,然后对B进行一次右旋转,问题转化为右孩子的情况。

1.2.3 被删除节点D的兄弟节点B的两个孩子都不为NIL
若B为红色,则B的两个孩子一定为黑色。将B绘为黑色,B的左孩子绘为红色,然后对P进行一次左旋转。

若B为黑色,则B的两个孩子一定为红色。将B的父节点P绘为黑色,B的右孩子绘为黑色,B绘为其父节点P原来的颜色,然后对P进行一次左旋转。

2 被删除节点D的两个孩子都不是NIL
按照二叉查找树删除节点的方法找到D的后继节点S,交换D和S的内容(颜色保持不变),被删除节点变为S,如果S有不为NIL的节点,那么继续用S的后继节点替换S,直到被删除节点的两个孩子都为NIL,问题转化为被删除节点D的两个孩子都为NIL的情况。
3 被删除节点D有一个孩子不是NIL
这个孩子C一定是红色节点,否则从D到各个NIL节点的路径上的黑色节点数目就会不同(违反性质5)。
交换D和C的内容(颜色保持不变),被删除节点变为C,问题转化为被删除节点D的两个孩子都为NIL的情况。

二叉树的遍历
二叉树的遍历有三种:前序遍历、中序遍历和后序遍历。每种遍历的实现又有递归和迭代两种,这篇文章我们来讨论如何用比较优雅的代码来实现二叉树的遍历。
首先我来定义一个二叉树的节点:

public class TreeNode {
  int val;
  TreeNode left;
  TreeNode right;

  public TreeNode(int x) {
    val = x;
  }
}

一、前序遍历(Preorder Traversal)
简单来讲,前序遍历就是先访问父节点,再访问左孩子,最后访问右孩子,即以父、左、右的顺序来遍历。
递归实现非常简单,代码如下:

public class Solution {
  List<Integer> result = new ArrayList<Integer>();

  public List<Integer> preorderTraversal(TreeNode root) {
    dfs(root);
    return result;
  }

  private void dfs(TreeNode root) {
    if (root == null) {
      return;
    }
    result.add(root.val);
    dfs(root.left);
    dfs(root.right);
  }
}

迭代实现需要借助一个栈,存储没被访问的右节点,代码如下:

public class Solution {

  public List<Integer> preorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<Integer>();

    if (root == null) {
      return result;
    }

    Stack<TreeNode> stack = new Stack<TreeNode>();
    stack.push(root);

    while (!stack.isEmpty()) {
      TreeNode curr = stack.pop();
      result.add(curr.val);

      if (curr.right != null) {
        stack.push(curr.right);
      }
      if (curr.left != null) {
        stack.push(curr.left);
      }
    }

    return result;
  }
}

二、中序遍历(Inorder Traversal)
简单来讲,中序遍历就是先访问左孩子,再访问父节点,最后访问右孩子,即以左、父、右的顺序遍历。
递归代码也比较容易,如下所示:

public class Solution {

  public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<Integer>();
    recurse(root, result);
    return result;
  }

  private void recurse(TreeNode root, List<Integer> result) {
    if (root == null) return;
    recurse(root.left, result);
    result.add(root.val);
    recurse(root.right, result);
  }
}

上面这种写法有别于前序遍历的递归代码,前序遍历的递归我们使用了成员变量来存储遍历的结果,这里我们使用方法参数来保存遍历的结果。两种写法都可以,喜欢哪种就使用哪种。
中序遍历的迭代实现没有前序遍历那么简单,虽然也需要借助一个栈,但迭代终止的条件却有所不同。想象一下,对于一棵二叉树,我们最先访问的节点是其最左边的节点,我们当然可以通过一个 while 循环到达其最左边,可是当我们回退时,我们如何知道某个节点的左孩子是否已经访问过了?我们使用一个 curr 变量记录当前访问的节点,当我们把一棵子树的右节点都访问完毕时,我们就该回退该子树的父节点了,而此时 curr 为 null,所以我们可以以此来区分一个节点的左子树是否已被访问过。代码如下:

public class Solution {

  public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<Integer>();

    Stack<TreeNode> stack = new Stack<TreeNode>();
    TreeNode curr = root;

    while (curr != null || !stack.isEmpty()) {
      while (curr != null) {
        stack.push(curr);
        curr = curr.left;
      }
      curr = stack.pop();
      result.add(curr.val);

      curr = curr.right;
    }

    return result;
  }
}

三、后序遍历(Postorder Traversal)
简单来讲,后序遍历就是先访问左孩子,在访问右孩子,最后访问父节点, 即以左、右、父的顺序遍历。
仿照中序遍历,可以很容易地写出后序遍历的递归实现:

public class Solution {

  public List<Integer> postorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<Integer>();
    recurse(root, result);
    return result;
  }

  private void recurse(TreeNode root, List<Integer> result) {
    if (root == null) return;
    recurse(root.left, result);
    recurse(root.right, result);
    result.add(root.val);
  }
}

后序遍历的迭代,也需要一个标识要区分一个节点的左右孩子是否已经访问过了,如果没有,则依次访问其左右孩子,如果访问过了,则访问该节点。为此,我们用一个 pre 变量来表示上一个访问的节点,如果上一个访问的节点是当前节点的左孩子或右孩子,那么说明当前节点的左右孩子已经访问过了,那么就可以访问该节点了,否则,则需要进入左右孩子依次访问。代码如下:

public class Solution {

  public List<Integer> postorderTraversal(TreeNode root) {
    List<Integer> result = new LinkedList<Integer>();

    Stack<TreeNode> stack = new Stack<TreeNode>();
    if (root != null) stack.push(root);

    TreeNode pre = root;

    while (!stack.isEmpty()) {
      TreeNode curr = stack.peek();
      if (curr.left == pre || curr.right == pre || (curr.left == null && curr.right == null)) {
        result.add(curr.val);
        stack.pop();
        pre = curr;
      } else {
        if (curr.right != null) stack.push(curr.right);
        if (curr.left != null) stack.push(curr.left);
      }
    }

    return result;
  }
}

后序遍历的迭代还有另外一种比较简单的实现,我们知道先序遍历的顺序是父、左、右,而后序遍历的顺序是左、右、父,那么如果我们把先序遍历稍作修改,改成父、右、左的顺序,那么就刚好与后序遍历的顺序相反了,以如此顺序访问完,最后我们对访问结果做个反转就可以了。而先序遍历的迭代实现相对来说比较容易,仿照上面写法我们可以如下实现:

public class Solution {

  public List<Integer> postorderTraversal(TreeNode root) {
    List<Integer> result = new LinkedList<Integer>();

    Stack<TreeNode> stack = new Stack<TreeNode>();
    if (root != null) stack.push(root);

    while (!stack.isEmpty()) {
      TreeNode curr = stack.pop();
      result.add(curr.val);
      if (curr.left != null) stack.push(curr.left);
      if (curr.right != null) stack.push(curr.right);
    }

    Collections.reverse(result);

    return result;
  }
}

四、总结
三种遍历的递归实现都很容易。前序遍历的迭代实现最好写,只需要一个栈就好;中序遍历最难,循环条件除了判断栈是否为空,还要判断当前节点是否为空,以表示是否左子树已经遍历完毕;后续遍历的迭代如果转化为前序遍历的迭代,就容易很多,否则,也需要记录上一个访问的节点,以表示当前节点的左右子树是否已经访问完毕。

(0)

相关推荐

  • java HashMap,TreeMap与LinkedHashMap的详解

     java HashMap,TreeMap与LinkedHashMap的详解 今天上午面试的时候 问到了Java,Map相关的事情,我记错了HashMap和TreeMap相关的内容,回来赶紧尝试了几个demo理解下 package Map; import java.util.*; public class HashMaps { public static void main(String[] args) { Map map = new HashMap(); map.put("a", &

  • java中treemap和treeset实现红黑树

    TreeMap 的实现就是红黑树数据结构,也就说是一棵自平衡的排序二叉树,这样就可以保证当需要快速检索指定节点. TreeSet 和 TreeMap 的关系 为了让大家了解 TreeMap 和 TreeSet 之间的关系,下面先看 TreeSet 类的部分源代码: public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, java.io.Serializab

  • TreeSet详解和使用示例_动力节点Java学院整理

    第1部分 TreeSet介绍 TreeSet简介 TreeSet 是一个有序的集合,它的作用是提供有序的Set集合.它继承于AbstractSet抽象类,实现了NavigableSet<E>, Cloneable, java.io.Serializable接口. TreeSet 继承于AbstractSet,所以它是一个Set集合,具有Set的属性和方法. TreeSet 实现了NavigableSet接口,意味着它支持一系列的导航方法.比如查找与指定目标最匹配项. TreeSet 实现了Cl

  • 详解Java中HashSet和TreeSet的区别

    详解Java中HashSet和TreeSet的区别 1. HashSet HashSet有以下特点: 不能保证元素的排列顺序,顺序有可能发生变化 不是同步的 集合元素可以是null,但只能放入一个null 当向HashSet集合中存入一个元素时,HashSet会调用该对象的hashCode()方法来得到该对象的hashCode值,然后根据 hashCode值来决定该对象在HashSet中存储位置. 简单的说,HashSet集合判断两个元素相等的标准是两个对象通过equals方法比较相等,并且两个

  • java 中HashMap、HashSet、TreeMap、TreeSet判断元素相同的几种方法比较

    java 中HashMap.HashSet.TreeMap.TreeSet判断元素相同的几种方法比较 1.1     HashMap 先来看一下HashMap里面是怎么存放元素的.Map里面存放的每一个元素都是key-value这样的键值对,而且都是通过put方法进行添加的,而且相同的key在Map中只会有一个与之关联的value存在.put方法在Map中的定义如下. V put(K key, V value); 它用来存放key-value这样的一个键值对,返回值是key在Map中存放的旧va

  • 浅谈java中的TreeMap 排序与TreeSet 排序

    TreeMap: package com; import java.util.Comparator; import java.util.TreeMap; public class Test5 { /** * @param args */ public static void main(String[] args) { // TODO Auto-generated method stub TreeMap<String, String> tree = new TreeMap<String,

  • Java数据结构之红黑树的真正理解

    真正的帮助大家理解红黑树: 一.红黑树所处数据结构的位置: 在JDK源码中, 有treeMap和JDK8的HashMap都用到了红黑树去存储 红黑树可以看成B树的一种: 从二叉树看,红黑树是一颗相对平衡的二叉树 二叉树-->搜索二叉树-->平衡搜索二叉树--> 红黑树 从N阶树看,红黑树就是一颗 2-3-4树 N阶树-->B(B-)树 故我提取出了红黑树部分的源码,去说明红黑树的理解 看之前,理解红黑树的几个特性,后面的操作都是为了让树符合红黑树的这几个特性,从而满足对查找效率的O

  • Java TreeMap排序算法实例

    本文实例讲述了Java TreeMap排序算法.分享给大家供大家参考,具体如下: TreeMap 和 HashMap 用法大致相同,但实际需求中,我们需要把一些数据进行排序: 以前在项目中,从数据库查询出来的数据放在List中,顺序都还是对的,但放在HashMap中,顺序就完全乱了. 为了处理排序的问题: 1. 对于一些简单的排序,如:数字,英文字母等 TreeMap hm = new TreeMap<String, String>(new Comparator() { public int

  • Java中HashMap和TreeMap的区别深入理解

    首先介绍一下什么是Map.在数组中我们是通过数组下标来对其内容索引的,而在Map中我们通过对象来对对象进行索引,用来索引的对象叫做key,其对应的对象叫做value.这就是我们平时说的键值对. HashMap通过hashcode对其内容进行快速查找,而 TreeMap中所有的元素都保持着某种固定的顺序,如果你需要得到一个有序的结果你就应该使用TreeMap(HashMap中元素的排列顺序是不固定的). HashMap 非线程安全 TreeMap 非线程安全 线程安全 在Java里,线程安全一般体

  • java TreeMap源码解析详解

    java TreeMap源码解析详解 在介绍TreeMap之前,我们来了解一种数据结构:排序二叉树.相信学过数据结构的同学知道,这种结构的数据存储形式在查找的时候效率非常高. 如图所示,这种数据结构是以二叉树为基础的,所有的左孩子的value值都是小于根结点的value值的,所有右孩子的value值都是大于根结点的.这样做的好处在于:如果需要按照键值查找数据元素,只要比较当前结点的value值即可(小于当前结点value值的,往左走,否则往右走),这种方式,每次可以减少一半的操作,所以效率比较高

随机推荐