基础结构之树结构详解

一)什么是树(tree)

 

 树是n(n≥0)个节点的有限集。当n=0时,称为空树。在任意一个非空树中,有如下特点:

  1. 有且仅有一个特定的称为根的节点。

  2. 当n>1时,其余节点可分为m(m>0)个互不相交的有限集,每一个集合本身又是一个树,并称为根的子树。

 术语:根节点、子树、叶子节点、父节点、孩子节点、兄弟节点、树的高度

二)二叉树

二叉树(binary tree)是树的一种特殊形式。这种树的每个节点最多有2个孩子节点。注意,这里是最多有2个,也可能只有1个,或者没有孩子节点

 

二叉树的存储

1.链式存储

  • 存储数据的data变量

  • 指向左孩子的left指针

  • 指向右孩子的right指针

2.数组存储

 

使用数组存储时,会按照层级顺序把二叉树的节点放到数组中对应的位置上。如果某一个节点的左孩子或右孩子空缺,则数组的相应位置也空出来,这样可以更方便地在数组中定位二叉树的孩子节点和父节点:

父节点下标 parent,那么左孩子下标为 leftChild = 2×parent + 1;右孩子下标为 rightChild = 2×parent + 2

思考:什么二叉树适合用数组存储?

二叉树的遍历

线性结构(数组、链表)的遍历很简单,那么非线性结构树是如何遍历的呢?

主要分为:深度优先遍历(先序遍历、中序遍历、后序遍历)广度优先遍历(层序遍历)

深度优先遍历

先序(根)遍历

先序遍历输出顺序是根节点、左子树、右子树

public void preTraversal(TreeNode(parent,left,right,data) node) {if (null == node) {return;}// 输出节点值print(node.data);// 递归preTraversal(node.left);preTraversal(node.right);
}

中序(根)遍历

中序遍历输出顺序是左子树、根节点、右子树


public void middleTraversal(TreeNode(parent,left,right,data) node) {if (null == node) {return;}// 递归middleTraversal(node.left);// 输出节点值print(node.data);// 递归middleTraversal(node.right);
}

后序(根)遍历

后序遍历输出顺序是左子树、右子树、根节点

public void afterTraversal(TreeNode(parent,left,right,data) node) {    if (null == node) {return;}    // 递归    afterTraversal(node.left);    afterTraversal(node.right);    // 输出节点值    print(node.data); 
}

广度优先遍历

层序遍历

由于二叉树同一层次的节点之间是没有直接关联的,所以层序遍历需要利用队列的方式来实现

  
public void levelTraversal(TreeNode(parent,left,right,data) root) {Queue<TreeNode> queue = new LinkedList<>();// 放入根节点queue.offer(root);while(!queue.isEmpty()) {// 出队TreedNode node = queue.pull();// 输出元素值print(node.data);if (node.left != null) {queue.offer(node.left);}if (node.right != null) {queue.offer(node.right);}}}

二叉树的应用

二叉树包含许多特殊的形式,每一种形式都有自己的作用,但是其最主要的应用还在于进行查找操作维持相对顺序

三)二叉查找树

二叉查找树在二叉树的基础上增加了以下几个条件:

  1. 如果左子树不为空,则左子树上所有节点的值均小于根节点的值

  2. 如果右子树不为空,则右子树上所有节点的值均大于根节点的值

  3. 左、右子树也都是二叉查找树

总结:左子树 < 根节点 < 右子树

如果要查找为4的节点:

  1. 访问根节点,4 < 6 ;

  2. 访问6的左孩子节点3,4 > 3;

  3. 访问3的右孩子节点4,4 = 4;

对于一个节点分布相对均衡的二叉查找树来说,如果节点总数是n,那么搜索节点的时间复杂度就是O(logn),和树的深度是一样。

二叉查找树存在的问题:

这里涉及到二叉树的自平衡,自平衡的方式有很多,比如平衡二叉树红黑树、树堆等。

四)平衡二叉树(AVL树)

平衡二叉树也称为AVL树,在二叉搜索树的基础上,平衡二叉树还需要满足如下条件:

  • 左右两个子树的高度差(平衡因子)的绝对值不超过1

  • 左右两个子树都是一棵平衡二叉树

 

平衡二叉树的目的是为了减少二叉查找树层次,提高查找速度。平衡二叉树是如何在插入的时候保持平衡呢?

自旋

树的自旋单旋转双旋转,分别作用于解决不同的情况。

LL情况(右旋或LL旋转)

在左子树的左节点上插入了新的数据,导致左右子树高度差绝对值 > 1;这种情况称为 左左(LL) 的情况,解决办法:

动图:

这里写图片描述

RR情况(左旋或RR旋转)

在右子树的右节点上插入了新的数据,导致左右子树高度差绝对值 > 1;这种情况称为 右右(RR) 的情况,解决办法:

动图:

这里写图片描述

LR情况(LR旋转-对左孩子RR旋,对根节点LL旋)

在左子树的右节点上插入了新的数据,导致左右子树高度差绝对值 > 1;这种情况称为 左右(LR) 的情况,解决办法:

 

RL情况(RL旋转-对右孩子LL旋,对根节点RR旋)

在右子树的左节点上插入了新的数据,导致左右子树高度差绝对值 > 1;这种情况称为 右左(RL) 的情况,解决办法:

 

实现代码:

    // 节点对象AvlNode root;
​public void insert(int data){root = insert(root,data);}// 插入节点public AvlNode insert(AvlNode n, int data) {if (n == null) {n = new AvlNode(data);}// 大于插入右子树if (data > n.data) {n.right = insert(n.right, data);
​// 检查并调整平衡,由于是右边插入,右子树的高度肯定大于等于左子树的高度if (height(n.right) - height(n.left) == 2) {// 判断data是插入点的左孩子还是右孩子if (data < n.right.data) {// RL情况,RL旋转n = doubleRightLeftRotate(n);} else {// RR情况,RR旋转n = singleRightRotate(n);}}// 小于插入左子树} else if (data < n.data) {n.left = insert(n.left, data);
​// 检查并调整平衡,由于是左边插入,左子树的高度肯定大于等于右子树的高度if (height(n.left) - height(n.right) == 2) {// 判断data是插入点的左孩子还是右孩子if (data < n.left.data) {// LL情况,LL旋转n = singleLeftRotate(n);} else {// LR情况,LR旋转n = doubleLeftRightRotate(n);}}}// 重新计算高度n.height = height(n);return n;}
​// 计算高度public int height(AvlNode n) {int left = n.left == null ? 0 : height(n.left);int right = n.right == null ? 0 : height(n.right);return Math.max(left,right) + 1;}
​// LL旋转private AvlNode singleLeftRotate(AvlNode root){// 把 root 的左节点 root_left 旋转为根结点AvlNode root_left = root.left;// 同时 root_left 的右子树变为 root 的左子树root.left = root_left.right;// root 变为 root_left 的右子树root_left.right = root;// 重新计算 root/root_left 的高度root.height = height(root);root_left.height = height(root_left);return root_left;//返回新的根结点}
​// RR旋转private AvlNode singleRightRotate(AvlNode root){// 把 root 的左节点 root_right 旋转为根结点AvlNode root_right = root.right;// 同时 root_right 的左子树变为 root 的右子树root.right = root_right.left;// root 变为 root_right 的右子树root_right.left = root;// 重新计算 root/root_right 的高度root.height = height(root);root_right.height = height(root_right);return root_right;//返回新的根结点}
​// RL情况(LR旋转-对右孩子LL旋,对根节点RR旋)private AvlNode doubleRightLeftRotate(AvlNode root){// 对 root 的右节点 root_right 进行LL旋转root.right = singleLeftRotate(root.right);// 对 root 进行 RR旋转return singleRightRotate(root);//返回新的根结点}
​// LR情况(LR旋转-对左孩子RR旋,对根节点LL旋)private AvlNode doubleLeftRightRotate(AvlNode root){// 对 root 的右节点 root_left 进行RR旋转root.left = singleRightRotate(root.left);// 对 root 进行 LL旋转return singleLeftRotate(root);//返回新的根结点}
​static class AvlNode {int data; AvlNode left; AvlNode right;// 当前节点高度int height;public AvlNode(int data) {this.data = data;}}

五)红黑树(Red-Black Tree,简称RBTree)

红黑树是一种含有红黑结点并能自平衡的二叉查找树,实际应用非常广泛,比如Linux内核中的完全公平调度器、高精度计时器、ext3文件系统等等,各种语言的函数库如Java的TreeMap和TreeSet,C++ STL的map、multimap、multiset等。

在二叉查找树的条件上,红黑树还必须满足下面性质:

  • 任何节点是红色或黑色。

  • 根节点是黑色。

  • 父子节点之间不能出现两个连续的红节点。

  • 任何一个节点向下遍历到其子孙的叶子节点,所经过的黑节点个数必须相等。

  • NIL节点被认为是黑色的(叶子节点)。

class  Node<T>{public  T value;public   Node<T> parent;public   boolean isRed;public   Node<T> left;public   Node<T> right;
}

RBTree的插入操作

插入新节点过后,可能会导致树的不平衡,这时就需要对树进行旋转操作和颜色修复,使得它符合红黑树的定义

新插入的节点是红色的,插入修复操作如果遇到父节点的颜色为黑则修复操作结束。也就是说,只有在父节点为红色节点的时候是需要插入修复操作的

插入修复操作分为以下的三种情况,而且新插入的节点的父节点都是红色的:

  1. 叔叔节点也为红色。

  2. 叔叔节点为空或黑色,且祖父节点、父节点和新节点处于一条斜线上。

  3. 叔叔节点为空或黑色,且祖父节点、父节点和新节点不处于一条斜线上。

插入操作-Case 1 – 叔叔节点也为红色。

case 1的操作是将父节点和叔叔节点与祖父节点的颜色互换,这样就符合了RBTRee的定义。即维持了高度的平衡,修复后颜色也符合RBTree定义的第三条和第四条。下图中,操作完成后 “90” 节点变成了新的节点。如果 “90” 节点的父节点不是黑色的话,则继续做修复操作。

插入操作-Case 2 – 叔叔节点为空或黑色,且祖父节点、父节点和新节点处于一条斜线上。(LL或RR)

case 2(LL或RR)的操作是将父节点进行右旋(左旋)操作,并且和祖父节点互换颜色。通过该修复操作RBTRee的高度和颜色都符合红黑树的定义。

插入操作-Case 3 – 叔叔节点为空或黑色,且祖父节点、父节点和新节点不处于一条斜线上。(LR或RL)

case 3的操作是将新节点进行左旋,这样就从case 3转换成case 2了。

总结

树结构是一个非常重要而且应用非常广的数据结构,作为研发人员也必须要了解的数据结构,了解了树结构你才能读懂很多组件的原理。树结构的延伸也非常的广,不止包含平衡二叉树、红黑树、还包含B树、B+树、2-3树、哈夫曼树、二叉堆等等,每一种都有相应的应用场景。

Published by

风君子

独自遨游何稽首 揭天掀地慰生平