blog/source/_posts/avl-tree.md
2021-05-13 10:19:30 +08:00

219 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 自平衡二叉树
date: 2021-04-13 12:09:40
tags: [架构知识, 算法设计, 树形结构, 数据结构, 二叉树, 平衡二叉树, Java]
categories:
- [架构知识, 数据结构]
- [架构知识, 算法设计]
keywords: 算法设计,树形结构,数据结构,二叉树,平衡二叉树,Java,AVL
mathjax: true
---
普通的二叉查找树BST虽然已经实现了对于节点的快速查找但是如果树的拓扑结构没有设计正确例如将一个有序序列存入BST中就会使BST的二分查找能力损失也就是常说的失去了平衡。为了保证BST的查找能力在BST形成过程中进行平衡调整就形成了平衡二叉查找树简称平衡二叉树AVL-tree
<!-- more -->
> 自平衡二叉树的英文缩写是采用其两位发明者的名字所以与其直译英文Self-balanced Binary Search Tree不同。
AVL具备以下性质
1. 从任何一个节点出发左子树和右子树的深度差的绝对值不超过1。这个深度差被称作**平衡因子**。
1. 任何一个节点的左子树和右子树都是平衡二叉树。
通过AVL所实现的拓扑结构就保证了树的二分查找能力AVL在查找、插入和删除时的复杂度均为$O(logN)$。
## AVL树构建
要构建一棵AVL树我们依旧可以使用前面定义的节点类但是为了能够适应AVL树的一些额外属性和操作我们还是需要将这个节点类扩展一下。
```java
public class AVLNode extends BSTNode {
private Integer depth;
public AVLNode(Integer value) {
super(value);
this.depth = 0;
}
public Integer getDepth() {
return this.depth;
}
public Integer setDepth(Integer depth) {
this.depth = depth;
}
public void increaseDepth() {
this.depth++;
}
public void decreaseDepth() {
this.depth--;
}
}
```
实例中的AVL树节点类增加了一个用于保存节点深度的属性这个深度属性可以用来比较得出是否平衡的结论。
## 树的旋转
向一个AVL中插入或者删除一个节点会导致AVL的不平衡。例如以下示例。
{% oss_image avl-tree/avl-insert.svg 'AVL树插入节点导致不平衡' 400 %}
左侧的AVL在插入一个新的节点5之后就变得不平衡了。节点50的左子树的高度为4右子树的高度为2AVL已经失去了平衡。在这种失衡的情况下AVL是通过旋转最小失衡子树来重新获取平衡的。那么这里就引入了一个新的名词**最小失衡子树**。
**最小失衡子树**是指从新插入的节点向根查找第一个平衡因子的绝对值超过1的节点即为最小失衡子树的根节点。一棵失衡的AVL树中是可能会同时存在多棵失衡子树的但是要使AVL树恢复平衡只需要调整最小的失衡子树即可。
对失衡子树的调整是通过旋转来完成的,旋转子树的目的是降低树的高度。子树的旋转有两个方向:左旋和右旋,使用哪个方向是由子树的高度决定的。如果节点的右子树高度比较高,那么就采用左旋,降低右子树高度;反之,使用右旋可以降低左子树高度。
### 左旋
最小失衡子树的左旋可以遵循以下步骤处理:
1. 子树根节点ori的右孩子rc替代根节点成为新的根节点。
1. 右孩子rc的左子树变为原根节点ori的右子树。
1. 原根节点ori变为右孩子rc的左子树。
具体操作实现可以参考以下例程。
```java
public void rotateLeft(AVLNode root) {
root.getRightChild().ifPresent(rightChild -> {
root.getParent().ifPresent(parent -> {
if (root.isLeftChild()) {
parent.attachLeftChild(rightChild);
} else {
parent.attachRightChild(rightChild);
}
});
rightChild.getLeftChild().ifPresent(root::attachRightChild);
rightChild.attachLeftChild(root);
});
}
```
## 右旋
最小失衡子树的右旋非左旋正好相反,可以遵循以下步骤:
1. 子树根节点ori的左孩子lc替代根节点成为新的根节点。
1. 左孩子lc的右子树变为原根节点ori的左子树。
1. 原根节点ori变为左孩子lc的右子树。
具体操作实现可以参考以下例程。
```java
public void rotateRight(AVLNode root) {
root.getLeftChild().ifPresent(leftChild -> {
root.getParent().ifPresent(parent -> {
if (root.isLeftChild()) {
parent.attachLeftChild(leftChild);
} else {
parent.attachRightChild(leftChild);
}
});
leftChild.getRightChild().ifPresent(root::attachLeftChild);
leftChild.attachRightChild(root);
});
}
```
## 节点插入
向AVL中的某一个节点k的左右子树上插入一个新节点n会有以下四种情况可以破坏原有AVL的平衡性
1. 在节点k的左子树根节点的左子树上插入节点n简称LL插入。要重新达到平衡需要执行右旋操作。
1. 在节点k的右子树根节点的右子树上插入节点n简称RR插入。要重新达到平衡需要执行左旋操作。
1. 在节点k的左子树根节点的右子树上插入节点n简称LR插入。要重新达到平衡需要先执行左旋操作再执行右旋操作。
1. 在节点k的右子树根节点的左子树上插入节点n简称RL插入。要重新达到平衡需要先执行右旋操作再执行左旋操作。
由于新节点都是作为叶子节点插入,所以在完成节点插入以后,需要完成一项工作,就是更新所有父代节点的深度值。这个更新不需要遍历整棵树,只需要遍历新节点的所有父代路径即可。以下是深度值更新的示例。
```java
public void updateDepth(AVLNode node) {
if (node.isLeaf()) {
node.setDepth(0);
} else {
Integer leftChildDepth = node.getLeftChild().get(AVLNode::getDepth).orElse(0);
Integer rightChildDepth = node.getRightChild().get(AVLNode::getDepth).orElse(0);
node.setDepth(IntStream
.of(leftChildDepth, rightChildDepth)
.map(d -> d + 1)
.max()
.orElse(0));
}
node.getParent().ifPresent(this::upateDepth);
}
```
虽然在节点插入后的重新平衡有四种情况,但是总结起来,只是一个不断遍历新插入节点的父级路径,使其父级路径重新平衡的过程。而在这个过程中,针对每一个节点,可以判断其左右子树的深度值,如果左侧的深度大于右侧的深度,那么就采用右旋进行平衡;反之采用左旋进行平衡。
以下是一个处理新插入节点并实现再平衡的示例。
```java
public void insert(Integer value) {
// 这个pointer十分有用可以用来对树进行遍历和操作
AVLNode pointer = this.root;
ANLNode newNode = new AVLNode(value);
// 首先完成节点的插入
while (!pointer.isLeaf()) {
if (pointer.compareTo(newNode) > 0) {
pointer = pointer.getRightChild().get();
continue;
}
if (pointer.compareTo(newNode) < 0) {
pointer = pointer.getLeftChild().get();
continue;
}
if (pointer.compareTo(newNode) == 0) {
throw new RepeatValueException();
}
}
if (newNode.compareTo(pointer) < 0) {
pointer.attachLeftChild(newNode);
} else {
pointer.attachRightChild(newNode);
}
this.updateDepth(newNode);
// 然后开始对新节点的父级路径进行再平衡
pointer = newNode;
while (pointer.getParent().isPresent()) {
Integer leftChildDepth = pointer.getLeftChild().map(AVLNode::getDepth).orElse(0);
Integer rightChildDepth = pointer.getRightChild().map(AVLNode::getDepth).orElse(0);
if (Math.abs(leftChildDepth - rightChildDepth) > 1) {
if (leftChildDepth > rightChildDepth) {
this.rightRotate(pointer);
} else {
this.leftRotate(pointer);
}
this.updateDepth(pointer);
pointer = newNode;
} else {
pointer = pointer.getParent().get();
}
}
}
```
实例代码中虽然不仅对仅需要再平衡的最小失衡子树进行了平衡,而且也同时扫描了新插入节点的所有父级路径。这样做的目的主要是把需要两次旋转的操作化简到了一个循环中。由于每次仅会扫描一条路径,所以增加的复杂度有限。
## 节点删除
AVL中的节点删除与BST中的节点删除操作是相同的只是AVL在完成删除操作以后需要修正所有的不平衡节点。一般都会分为以下四种情况来处理。
1. 被删除节点是叶子节点。
1. 被删除节点只有左子树。
1. 被删除节点只有右子树。
1. 被删除节点既有左子树又有右子树。
在完成节点的删除以后,可以选择被删除节点的左右子树中深度较大的那一支,来更新整条路径上的节点深度,并进行再平衡操作。
## AVL树的缺点
AVL在大量查找操作的情况下效率会更高但是对于增删操作AVL会进行大量的再平衡操作这样就大大降低了AVL的性能。所以在查找操作远大于增删操作次数的时候使用AVL会得到更高的效率。如果在树中的增删操作比较多那么可以选择增删性能更高的红黑树。
## 系列文章
1. {% post_link binary-tree-basic %}
1. {% post_link binary-search-tree %}
1. {% post_link avl-tree %}