1. 树的基础概念
树是一种特别常用的数据结构,在计算机文件系统或者计算机数据库系统中,都往往会采用树数据结构。
比如在文件系统中会采用树数据结构作为文件目录层级,在数据库系统中则会用树数据结构作为数据库的索引,特别是B树。
这里我借用一下百度百科上面的定义:
树 是一种 数据结构 ,它是由 n(n≥1 )个有限节点组成一个具有层次关系的 集合 。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
我简单手绘了一张图,为大家表示树的数据结构的形态:
这张图上的每一个圆圈都代表一个节点,在最中间的节点1代表着这棵树的根节点,可以看到在节点1下面有三个箭头分别指向了三个节点,这三个元素就是根节点的三个子节点,对于子节点来说节点一就是它们的父节点,每个节点都拥有自己的子节点和父节点,不同节点的子节点之间不会重合。
每个节点也可以独立成为一棵子树,比如节点3就是一棵子树,它下面分别有5和6两个子节点,节点2和4也可以说是一个子树,但是它们没有子节点。
接下来还有几个关于树的名词需要了解:
-
高度 / 深度 :树的高度是其根结点到最深节点的长度,比如我上面的例图中的树的高度就是3,你可以将其理解为3层。
-
叶子节点 :没有下级节点的节点被称为叶子节点,比如节点2、4、5和6。
-
非叶子节点 :有下级节点的节点被称为非叶子节点,比如节点3。
-
节点的度 :其直接子节点的个数,比如节点3只拥有两个子节点,其度就为2。
-
树的度 :该树中所有节点拥有的最大度,在例图中树的度就为3,因为1号节点有三个子节点,没有其他节点拥有更多的子节点,所以树的度为3。
了解完关于树的基础概念,就要讲讲树一般怎么进行构造了,由于树的直接子节点的数量的是不固定的,所以我们可以采用链表的方式来构造一个子节点,就像这样:
public class DiyTree<T extends Comparable> {
private Node<T> root;
private static class Node<T extends Comparable> {
private LinkedList<Node<T>> item;
}
}
复制代码
这相当于每一个节点下面都包含着一个链表,如果用画图的方式表述的话就是这样的:
在上图中我用矩形代替链表,圆形代替里面的一个个的元素,每一个元素都可以延展出一个链表来。
以上,我介绍的树都是N树
,除了N树之外还有一种叫做二叉树的树,我们先来看看它的介绍:
-
N树 :即一个子节点允许拥有N个子节点,例图中的树就是N树,我们所熟知的B树就是N树的一种。
-
二叉树 :即一个子节点只允许拥有2个子节点,这两个子节点往往被称为左节点和右节点。
上图中就是一个二叉树,二叉树的代码构造也比N树要好理解,因为它只有两个子节点:
public class DiyTree<T extends Comparable> {
private Node<T> root;
private static class Node<T extends Comparable> {
private T item;
private Node<T> left;
private Node<T> right;
public Node(T t) {
item = t;
}
}
}
复制代码
N树与二叉树最大的区别就是子节点所允许的数量不同,数量不同带来的另一层差异就是树的高度不同。
比如我有7个节点,放在二叉树上需要3层才能摆下,在N树中两层就够了,节点越多,二叉树所需要的树高就越多,而N树却可以以极低的树高维持上千万的数据(MySQL中的B+索引的树高是3层),这在需要访问磁盘的程序中非常有用,当然了,今天本文的重点还是会围绕二叉树展开,N树暂时不讲。
2. 二叉搜索树
上一节中简单说了二叉树的概念,那么它有什么用呢?
一个东西的引入必然有其作用,二叉树往往被用以二叉搜索树中,二叉搜索树是二叉树的变形,它以O(logN)的时间复杂度进行数据查找,在一个有2147483647个数字(21亿)的数组中,它最多只需要进行31次查找就能查找任何元素。
虽然它这么快,但是通过二叉树你可以很容易的变形到二叉搜索树去,因为它需要的条件非常简单:
-
二叉树。
-
左节点小于它的父节点,右节点大于它的父节点。
在上图中,白色背景的树就不是一颗二叉搜索树,而蓝色背景的树则是二叉搜索树,因为白色背景的树不满足我上面所说的第二个条件。
通过二叉搜索树可以保证你的树是有序的,所以在查找某个元素的过程中,每一次比较就可以缩小一半的查找范围,所以它的时间复杂度是O(logN)。
比如我们要查找1这个元素,在上图中只需要对根节点进行比较就能轻松的知道1是在这棵树左边的链路之中,这样我们就不必再去右边的链路中查找了,利用递归的方式不断重复这一动作,从而就能很快找到我们需要的元素。
当然,二叉搜索树它也有缺点,比如我们依次插入1,0,2,3,4
这五个元素,你就会发现它形成了一个链表,你的查找速度就会变成O(N)
:
有没有办法解决这个问题呢?
有,那就是在其链路有点长的时候对它重新调整一次,这种自平衡的二叉搜索树叫做二叉平衡树。
3. 二叉平衡树(AVL)
本节是本篇文章中最重要也是最难的部分,请读者们注意。
上节说到了二叉平衡树可以解决二叉搜索树一遍链路过长的问题,这一节我们就来实现一个二叉平衡树,实现方式将用最经典的AVL,这是一种比较苛刻的二叉平衡树方案,它要求树两个子节点的层高相差不能大于1,也是比较麻烦的一种方案,业界中更常用的红黑树则没有这么严格的平衡要求。
之所以选用这种方案来讲解,一是它经典,二是经典算法书—算法第四版居然没有讲这个AVL实现,所以我想补充一下。
先来看上面这张图,这张图其实是一个比较正常的二叉树,根节点的左边层高为1,右边的层高为2,它俩相差没有大于1,只是等于1。
如果我此时在插入一个4呢?
这下很明显的就能看出这棵树两边不平衡了,根节点的左边层高为1,右边层高为3,相差大于1了。
但是我们不能这样的来调整根节点,应该从插入处不断往上寻找不平衡点,在第一个不平衡的地方进行平衡操作。
图中的新插入点为4,它的父节点是3,父节点的左节点没有元素,可以看作层高为0,右节点有一个4,可以看作层高为1,所以它俩相差为1,不需要调整。
紧接着网上走到2处,2的左节点没有元素看作层高为0,右节点层高则为2,相差为2,所以真正引起不平衡的节点是2节点,需要调整此处让这个树重新平衡。
那么,怎么调整呢?
经典之所以是经典,是因为有大师已经发明过之后经历了时间的洗礼而经久不衰。
在AVL中,前辈们已经总结了四种树的不平衡情况,并通过一种名为旋转
的方式对树进行再平衡。
为了更直观的理解,我不会直接列出这四种情况,而是通过配图的方式逐个讲解。
3.1 左旋转
像上图这种明显节点2处的右链路要比左链路长,那么我们需要进行一次左旋转:
将其变成上图中右边的样子,这样整棵树又恢复了平衡,左旋转看上去非常简单:
就是将不平衡的节点,放到其子节点的左节点。
因为节点2是小于节点3的,所以它放在节点3的左节点正正好。
当然,有人可能有疑问了,如果节点3的左节点有值怎么办?
原来的例子中不会出现这种情况,但是如果你在上图中右树再增加一个节点5,就会出现这种情况:
可以明显的看出,根节点1产生了不平衡,我们照例使用左旋转进行修正:
我们照例将根节点放在了节点3 的左节点上面,并将原来节点3的左节点放在了根节点的右节点上 ,因为节点3下的所有节点都是大于根节点的。
所以左旋大概是以下两步:
-
将不平衡节点,放到其子节点的左节点。
-
将其子节点原来的左节点,放到不平衡节点的右节点。
3.2 右旋转
右旋转是左旋转的镜像问题:
左树明显是节点9不平衡,经过右旋转调整之后变成右边那样。
其步骤和左旋转也是相对应的:
-
将不平衡节点,放到其子节点的右节点。
-
将其子节点原来的右节点,放到不平衡节点的左节点。
理解左旋转,自然理解右旋转,如果这里有点蒙的话,可以再看一遍左旋转,然后自行推演一下。
3.3 双旋转
双旋转是说在某种情况下,一次旋转无法解决问题,所以需要两次旋转才能解决问题,双旋转可以分为两种:
-
左右旋转 :先左旋转再右旋转。
-
右左旋转 :先右旋转再左旋转。
可以先来看下这个例子:
在这个例子中,很明显是节点9失衡,它的左子树层高和右子树相差为2,但是如果按照我们上面的经验对它直接进行右旋转是行不通的:
直接右旋转会变成这样,整棵树依然是失衡的。
因为它的失衡形式其实和我上面举出的右旋转的例子有些不一样:
左树是我们可以通过右旋转解决的失衡情形,而右树则不一样,它的节点5处是右层高大于左边,而非左层高大于右边。
由此可见,当失衡节点的所有子节点都偏向一侧时,才可以使用但旋转解决。
比如上图中的左树的节点9和节点5其实都是偏向左侧的,所以可以右旋转解决。
上图中的节点2和节点3都偏向右侧,所以可以使用左旋转解决。
但是上图这种并非都偏向一侧的则需要两次旋转,在上图中失衡的节点是节点5,它先偏向左边,再偏向右边,所以需要先左旋转再右旋转:
不过在第一次左旋转时需要对失衡节点的子节点进行左旋转,然后再对失衡节点进行右旋转。
在这张图上,我将第一次旋转的节点标为红色,将第二次旋转的节点标为黄色,可以很轻易的看出它的运行步骤。
与之相对应的还有它的镜像问题,需要进行一次右左旋转解决。
3.4 代码实现
AVL理论上的东西不难,但是上手实现时遇见了很多问题,重新看了好几遍书上的代码后才将其实现出来,读者们可以直接copy运行。
AVL的基本结构:
public class DiyTree<T extends Comparable> {
private Node<T> root;
private static class Node<T extends Comparable> {
private T item;
private Node<T> left;
private Node<T> right;
private Integer height = 0;
public Node(T t) {
item = t;
}
}
}
复制代码
AVL的基本结构比上文中的二叉搜索树的结构多了一个高度。
然后进行数据插入:
public void add(T t) {
Node<T> node = new Node<>(t);
root = insertTree(root, node);
}
private Node<T> insertTree(Node<T> root, Node<T> node) {
if (root == null) {
return node;
}
int i = node.item.compareTo(root.item);
if (i > 0) {
root.right = insertTree(root.right, node);
} else if (i < 0){
root.left = insertTree(root.left, node);
} else {
// 节点相同暂不处理
}
// 插入之后进行平衡 会平衡从根节点往下的所有节点
return balance(root);
}
复制代码
数据插入就是正常的寻找,然后插入,插入之后才进行平衡:
private Node<T> balance(Node<T> root) {
// 平衡条件是计算左右子节点是否相差大于1
if (height(root.left) - height(root.right) > 1) {
if (height(root.left.left) >= height(root.left.right)) {
// 左左 - 使用右旋转
root = rightRotate(root);
} else {
// 左右 - 使用左右双旋转 - 先对root.left进行左旋转,再对root进行右旋转
root = leftRightRotate(root);
}
}
if (height(root.right) - height(root.left) > 1) {
if (height(root.right.right) >= height(root.right.left)) {
// 右右 - 使用左旋转
root = leftRotate(root);
} else {
// 右左 - 使用右左双旋转 - 先对root.right进行右旋转,再对root进行左旋转
root = rightLeftRotate(root);
}
}
root.height = Math.max(height(root.left), height(root.right)) + 1;
return root;
}
复制代码
平衡时是从最深层的节点往上进行平衡,因为插入方法是一个递归方法,插入时会出现四种情况,分别用四种旋转解决。
还有一个求高度的辅助方法:
private int height(Node<T> node) {
return node == null ? -1 : node.height;
}
复制代码
右旋转方法:
private Node<T> rightRotate(Node<T> node) {
// 先拿到节点的左边
Node<T> newNode = node.left;
// 去掉这个新节点的右边
node.left = newNode.right;
// 新节点的右边给予原来的root
newNode.right = node;
// 处理高度
node.height = Math.max(height(node.left), height(node.right)) + 1;
newNode.height = Math.max(height(newNode.left), height(newNode.right)) + 1;
return newNode;
}
复制代码
右左旋转方法:
private Node<T> rightLeftRotate(Node<T> node) {
node.right = rightRotate(node.right);
return leftRotate(node);
}
复制代码
最后,你可以根据我的右旋转方法和右左旋转方法推导出左旋转和左右旋转方法。 代码编写完成后,我在visualgo这个网站输入和我本地测试用例一样的数据,用这种方法来测试代码的正确性。
我测试了两组数据:1, 2, 3, 4, 5, 6, 7, 8, 9
和 50, 45, 40, 55, 60, 47, 57, 35, 38
,这两组数据涉及所有的旋转情况,本地测试结果和线上网站的执行结果是一致的。
4. AVL的遍历
说完了AVL的插入和平衡,再来说说它的遍历,二叉树的遍历可以分为以下几类:
-
先序遍历 :先打印root节点。
-
中序遍历 :打印完左节点再打印root节点。
-
后序遍历 :打印完左节点和右节点,最后打印root节点。
-
层序遍历 :从root开始,一层一层的从左往右进行打印。
前三种遍历中的先、中、后
都是相对于root节点来说的,理解了这点后你可以将任意一种遍历代码方便的变形为其他遍历的代码。
4.1 先序遍历
拿这张图来举例,先序遍历的话,它的结果就应该是:3, 1, 0, 2, 4, 5
。
代码也很简单:
public void preorder() {
if (root == null) {
return;
}
order(root);
}
public void order(Node<T> root) {
if (root == null) {
return;
}
System.out.println(root.item);
order(root.left);
order(root.right);
}
复制代码
4.2 中序遍历
拿这张图来举例,中序遍历的话,它的结果就应该是:0, 1, 2, 3, 4, 5
。
代码也很简单:
public void preorder() {
if (root == null) {
return;
}
order(root);
}
public void order(Node<T> root) {
if (root == null) {
return;
}
order(root.left);
System.out.println(root.item);
order(root.right);
}
复制代码
4.3 后序遍历
拿这张图来举例,后序遍历的话,它的结果就应该是:0, 2, 1, 5, 4, 3
。
代码也很简单:
public void preorder() {
if (root == null) {
return;
}
order(root);
}
public void order(Node<T> root) {
if (root == null) {
return;
}
order(root.left);
order(root.right);
System.out.println(root.item);
}
复制代码
可以看到这三种遍历方式代码几乎没差,至于最后的一种层序遍历就留给大家实现了~
5. 结语
到这里,树相关的知识点就告一段落了。
今天讲了树的基础概念、二叉搜索树和AVL的实现,学习完这章你应该会对树这一数据结构有一个不错的掌握。
二叉平衡树也是一个在现代编程语言中都会内置的一个数据类型,比如Java中的TreeMap
就是二叉树的典型代表,并且Java中的HashMap
也会在冲突过多的时候转换成TreeMap
,它们都是Java中极其重要的工具类。
截止到本文,我一共讲了五种数据结构,其中二叉平衡树是最快数据结构,它的查找和插入的速度都已经到达指数级别,但是在数据结构中还有一种能将查找和插入速度达到常数级别,它就是Hash。
Hash,又称哈希表,是一种查找和插入都达到常数级别的数据结构,我将会在下一篇中仔细讨论它,请大家拭目以待。
作者:和耳朵
链接:https://juejin.cn/post/6999793887820644359
来源:掘金