第五章 树
参考文章:www.hello-algo.com/
1、二叉树
1.1、二叉树的基本概念
- 定义:二叉树是一种非线性数据结构,每个节点最多有两个子节点,分别称为左子节点和右子节点。
- 节点结构:每个节点包含:
- 一个值(
val) - 一个指向左子节点的引用(
left) - 一个指向右子节点的引用(
right)
/* 二叉树节点类 */ class TreeNode { int val; // 节点值 TreeNode left; // 左子节点引用 TreeNode right; // 右子节点引用 TreeNode(int x) { val = x; } } - 一个值(
在树结构中,每个节点都有两个指针,分别指向左子节点和右子节点,该节点被称为这两个字节点的父结点。其左子节点及其以下所有节点所形成的树称为该节点的左子树。同理,可得右子树。
**在二叉树中,除了叶子节点,其他所有节点都包括子节点和非空子树。**如下图,如果将节点 2 视为父结点,那么节点 4 是其左子节点,节点 5 是其右子节点,节点 4 及其底下所有节点形成的树是节点 2 的左子树,节点 5 及其底下所有节点形成的树称为节点 2 的右子树。
1.2、二叉树的常见术语:
- 根节点:位于顶层,没有父节点。
- 叶节点:没有子节点的节点,两个指针均指向 null。
- 边:连接两个节点的线段。
- 节点所在的层:从根节点开始,逐层向下递增,根节点所在的层是 1。
- 节点的度:节点的子节点的数量。在二叉树中,节点的度的取值范围是0、1 或 2。
- 节点的深度:从根节点到该节点所经过边的数量。
- 节点的高度: 从该节点到最远叶节点所经过边的数量。
- 二叉树的高度:从根节点到最远叶节点的边的数量。
请注意,我们通常将“高度”和“深度”定义为“经过的边的数量”,但有些题目或教材可能会将其定义为“经过的节点的数量”。在这种情况下,高度和深度都需要加 1 。
1.3、 二叉树的基本操作
-
初始化:创建节点并构建引用关系。
示例代码(Java):
// 初始化节点 TreeNode n1 = new TreeNode(1); TreeNode n2 = new TreeNode(2); TreeNode n3 = new TreeNode(3); TreeNode n4 = new TreeNode(4); TreeNode n5 = new TreeNode(5); // 构建节点之间的引用(指针) n1.left = n2; n1.right = n3; n2.left = n4; n2.right = n5; -
插入与删除节点:
- 插入:通过修改指针将新节点插入到指定位置。
- 删除:修改指针以移除节点,可能需要调整子树。
- 示例代码(Java):
```python
TreeNode P = new TreeNode(0);
// 在 n1 -> n2 中间插入节点 P
n1.left = P;
P.left = n2;
// 删除节点 P
n1.left = n2;
```
1.4、常见二叉树类型
-
完美二叉树:
- 每层节点都被完全填满。
- 叶节点的度为 0,其余节点的度为 2。
- 节点总数为 (2^{h+1} - 1),其中 (h) 是树的高度。
请注意,完美二叉树也常被称为满二叉树。
- 完全二叉树:
-
只有最底层的节点未被填满,但尽量靠左填充。
-
完美二叉树是完全二叉树的特例。
-
- 完满二叉树:
- 除了叶节点外,所有节点都有两个子节点。
- 平衡二叉树:
- 任意节点的左子树和右子树的高度差不超过 1。
1.5、二叉树的退化
-
理想情况:完美二叉树,高度为 (O(\log n)),操作效率高。
-
最差情况:退化为链表,高度为 (O(n)),操作效率低。
-
对比:
特性 完美二叉树 链表 第 (i) 层节点数 (2^{i-1}) 1 高度为 (h) 的叶节点数 (2^h) 1 高度为 (h) 的节点总数 (2^{h+1} - 1) (h + 1) 节点总数为 (n) 的树的高度 (\log_2(n + 1) - 1) (n - 1)
2、二叉树遍历
1. 二叉树遍历概述
- 定义:二叉树遍历是指按照某种顺序访问二叉树中的所有节点,确保每个节点恰好被访问一次。
- 重要性:遍历是二叉树操作的基础,广泛应用于搜索、排序、统计等场景。
2. 遍历方式分类
二叉树的遍历方式主要分为两大类:
- 层序遍历(Level-order Traversal):按层次顺序从上到下、从左到右访问节点,也是广度优先搜索的一种。
- 深度优先遍历(Depth-First Traversal):包括前序遍历、中序遍历和后序遍历,基于递归或栈实现。
3. 层序遍历
3.1 算法原理
- 核心思想:借助队列实现广度优先搜索(BFS),逐层访问节点。
- 步骤:
- 初始化队列,将根节点入队,初始化一个动态数组,保存遍历二叉树的序列。
- 当队列非空时,执行以下操作:
- 弹出队首节点,访问该节点。
- 将该节点的左子节点(若存在)入队。
- 将该节点的右子节点(若存在)入队。
- 重复上述过程,直到队列为空。
3.2 代码实现(Java)
package com.liucc.chapter_tree;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import com.liucc.utils.PrintUtil;
import com.liucc.utils.TreeNode;
/**
* 层序遍历(广度优先搜索) 遍历树
*/
public class binary_tree_bfs {
public static List<Integer> levelOrder(TreeNode root){
// 初始化队列
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
// 初始化一个列表,保存遍历序列
List<Integer> res = new ArrayList<>();
// 遍历队列
while (!queue.isEmpty()) {
TreeNode poll = queue.poll(); // 出队
res.add(poll.val); // 访问
if (poll.left!= null) {
queue.offer(poll.left); // 左子节点入队
}else if (poll.right != null) {
queue.offer(poll.right); // 右子节点入队
}
}
return res;
}
public static void main(String[] args) {
/* 初始化二叉树 */
// 这里借助了一个从数组直接生成二叉树的函数
TreeNode root = TreeNode.listToTree(Arrays.asList(1, 2, 3, 4, 5, 6, 7));
System.out.println("\n初始化二叉树\n");
PrintUtil.printTree(root);
/* 层序遍历 */
List<Integer> list = levelOrder(root);
System.out.println("\n层序遍历的节点打印序列 = " + list);
}
}
3.3 复杂度分析
- 时间复杂度:O(n),每个节点被访问一次。
- 空间复杂度:O(n),在最坏情况下(满二叉树),队列中可能存储约 n/2 个节点。
4. 深度优先遍历
深度优先遍历基于递归或栈实现,按照递归的顺序访问节点。根据访问根节点的时机,分为三种遍历方式:
4.1 前序遍历(Pre-order Traversal)
- 访问顺序:根节点 -> 左子树 -> 右子树。
- 代码实现(Java):
void preOrder(TreeNode root) {
if (root == null)
return;
// 访问优先级:根节点 -> 左子树 -> 右子树
list.add(root.val);
preOrder(root.left);
preOrder(root.right);
}
4.2 中序遍历(In-order Traversal)
- 访问顺序:左子树 -> 根节点 -> 右子树。
- 代码实现(Java):
void inOrder(TreeNode root) {
if (root == null)
return;
// 访问优先级:左子树 -> 根节点 -> 右子树
inOrder(root.left);
list.add(root.val);
inOrder(root.right);
}
4.3 后序遍历(Post-order Traversal)
- 访问顺序:左子树 -> 右子树 -> 根节点。
- 代码实现(Java):
void postOrder(TreeNode root) {
if (root == null)
return;
// 访问优先级:左子树 -> 右子树 -> 根节点
postOrder(root.left);
postOrder(root.right);
list.add(root.val);
}
4.4 复杂度分析
- 时间复杂度:O(n),每个节点被访问一次。
- 空间复杂度:O(n),递归调用栈的深度在最坏情况下(树退化为链表)为 n。
4.5 完整代码
package com.liucc.chapter_tree;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import com.liucc.utils.PrintUtil;
import com.liucc.utils.TreeNode;
/**
* 深度优先搜索 遍历树
*/
public class binary_tree_dfs {
static List<Integer> list = new ArrayList<>();
//前序遍历 根->左->右
static void preOrder(TreeNode root){
if (root == null) {
return;
}
list.add(root.val);
preOrder(root.left);
preOrder(root.right);
}
// 中序遍历 左->根->右
static void inOrder(TreeNode root){
if (root == null) {
return;
}
inOrder(root.left);
list.add(root.val);
inOrder(root.right);
}
// 后序遍历 左->右->根
static void postOrder(TreeNode root){
if (root == null) {
return;
}
postOrder(root.left);
postOrder(root.right);
list.add(root.val);
}
public static void main(String[] args) {
/* 初始化二叉树 */
// 这里借助了一个从数组直接生成二叉树的函数
TreeNode root = TreeNode.listToTree(Arrays.asList(1, 2, 3, 4, 5, 6, 7));
System.out.println("\n初始化二叉树\n");
PrintUtil.printTree(root);
// 前序遍历
preOrder(root);
System.out.println("\n前序遍历序列打印:" + list.toString());
// 中序遍历
list.clear();
inOrder(root);
System.out.println("\n中序遍历序列打印:" + list.toString());
// 后序遍历
list.clear();
postOrder(root);
System.out.println("\n后序遍历序列打印:" + list.toString());
}
}
5. 遍历的应用
- 层序遍历:适用于按层次处理节点,例如计算树的宽度、逐层打印节点。
- 前序遍历:常用于复制树结构、序列化二叉树。
- 中序遍历:在二叉搜索树中,中序遍历的结果是有序的,可用于排序和查找。
- 后序遍历:常用于删除树节点、计算树的后序表达式。
3、二叉树数组表示
在之前的章节中,我们二叉树是通过链表实现的,二叉树的基本单位是TreeNode ,节点之间通过指针相互连接。那么通过数组是否可以实现二叉树这种数据结构呢?答案肯定也是可以的。
3.1、完美二叉树的数组表示
- 定义:完美二叉树(完全二叉树)是一种特殊的二叉树,其中每一层(除最后一层外)的节点都完全填满,并且所有叶子节点都位于最后一层。
- 数组表示方法:将完美二叉树的节点按照层序遍历的顺序存储到数组中。每个节点的索引与其子节点和父节点的索引存在固定关系:
- 父节点索引:
(i - 1) / 2 - 左子节点索引:
2 * i + 1 - 右子节点索引:
2 * i + 2
- 父节点索引:
- 优点:这种表示方法简单且高效,适用于完美二叉树。
3.2、任意二叉树的数组表示
- 问题:对于非完美二叉树,直接按层序遍历存储会导致数组中存在大量空位(
None),无法唯一表示二叉树结构。
-
解决方案:在数组中显式地存储所有空节点(
None),使数组能够唯一表示任意二叉树。 -
示例代码
tree = [1, 2, 3, 4, None, 6, 7, 8, 9, None, None, 12, None, None, 15]
3.3、代码实现
以下代码实现了一棵基于数组表示的二叉树,包括以下几种操作。
- 给定某节点,获取它的值、左(右)子节点、父节点。
- 获取前序遍历、中序遍历、后序遍历、层序遍历序列。
package com.liucc.chapter_tree;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import com.liucc.utils.PrintUtil;
import com.liucc.utils.TreeNode;
/**
* 基于数组实现的二叉树
*/
class ArrayBinaryTree{
private List<Integer> tree;
public ArrayBinaryTree(List<Integer> arr){
this.tree = arr;
}
// 列表容量
public int size(){
return tree.size();
}
// 获取索引为 i节点的值
public Integer val(int i){
// 索引越界,返回 null
if (i < 0 || i >= size()) {
return null;
}
return tree.get(i);
}
// 获取索引为 i 左子节点的索引
public Integer left(int i){
return 2 * i + 1;
}
// 获取索引为 i 右子节点的索引
public Integer right(int i){
return 2 * i + 2;
}
// 获取索引为 i 父节点的索引
public Integer parent(int i){
return (i - 1) / 2;
}
// 层序遍历
public List<Integer> levelOrder(){
List<Integer> res = new ArrayList<>();
for (int i = 0; i < size(); i++) {
if (val(i) != null) {
res.add(val(i));
}
}
return res;
}
// 深度优先遍历
/**
*
* @param i 当前节点索引
* @param order 遍历方式 pre:前序、in:中序、post:后序
* @param res 遍历结果
*/
public void dfs(Integer i, String order, List<Integer> res){
// 如果为空位,直接返回
if (val(i) == null) {
return;
}
if ("pre".equals(order)) {
res.add(val(i));
}
dfs(left(i), order, res);
if ("in".equals(order)) {
res.add(val(i));
}
dfs(right(i), order, res);
if ("post".equals(order)) {
res.add(val(i));
}
}
// 前序遍历
public List<Integer> preOrder(){
List<Integer> res = new ArrayList<>();
dfs(0, "pre", res);
return res;
}
// 中序遍历
public List<Integer> inOrder(){
List<Integer> res = new ArrayList<>();
dfs(0, "in", res);
return res;
}
// 后序遍历
public List<Integer> postOrder(){
List<Integer> res = new ArrayList<>();
dfs(0, "post", res);
return res;
}
}
public class array_binary_tree {
public static void main(String[] args) {
// 初始化二叉树
// 这里借助了一个从数组直接生成二叉树的函数
List<Integer> arr = Arrays.asList(1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15);
TreeNode root = TreeNode.listToTree(arr);
System.out.println("\n初始化二叉树\n");
System.out.println("二叉树的数组表示:");
System.out.println(arr);
System.out.println("二叉树的链表表示:");
PrintUtil.printTree(root);
// 数组表示下的二叉树类
ArrayBinaryTree abt = new ArrayBinaryTree(arr);
// 访问节点
int i = 1;
Integer l = abt.left(i);
Integer r = abt.right(i);
Integer p = abt.parent(i);
System.out.println("\n当前节点的索引为 " + i + " ,值为 " + abt.val(i));
System.out.println("其左子节点的索引为 " + l + " ,值为 " + (l == null ? "null" : abt.val(l)));
System.out.println("其右子节点的索引为 " + r + " ,值为 " + (r == null ? "null" : abt.val(r)));
System.out.println("其父节点的索引为 " + p + " ,值为 " + (p == null ? "null" : abt.val(p)));
// 遍历树
List<Integer> res = abt.levelOrder();
System.out.println("\n层序遍历为:" + res);
res = abt.preOrder();
System.out.println("前序遍历为:" + res);
res = abt.inOrder();
System.out.println("中序遍历为:" + res);
res = abt.postOrder();
System.out.println("后序遍历为:" + res);
}
}
3.4、优点与局限性
- 优点:
- 空间效率:不需要存储指针,节省空间。
- 访问效率:数组存储在连续内存中,对缓存友好,访问速度快。
- 随机访问:可以直接通过索引访问任意节点。
- 局限性:
- 内存限制:数组需要连续内存空间,不适合存储数据量过大的树。
- 动态操作:增删节点需要通过数组插入或删除操作实现,效率较低。
- 空间浪费:当二叉树中存在大量空节点时,数组中实际存储的节点数据比重较低,空间利用率低。
4、二叉搜索树
定义
二叉搜索树(BST)是一种特殊的二叉树,满足以下条件:
- 对于根节点,左子树中所有节点的值 < 根节点的值 < 右子树中所有节点的值。
- 任意节点的左、右子树也是二叉搜索树。
二叉搜索树的操作
1. 查找节点
-
目标:在BST(二叉搜索树)中查找一个目标值
num。 -
过程:
- 从根节点开始,比较目标值
num与当前节点值cur.val。 - 如果
cur.val < num,则向右子树移动;如果cur.val > num,则向左子树移动。 - 如果
cur.val == num,则找到目标节点。
- 从根节点开始,比较目标值
-
时间复杂度:平均情况下为
O(log n),最坏情况下为O(n)(树退化为链表)。 -
代码实现:
/* 查找节点 */ TreeNode search(int num) { TreeNode cur = root; // 循环查找,越过叶节点后跳出 while (cur != null) { // 目标节点在 cur 的右子树中 if (cur.val < num) cur = cur.right; // 目标节点在 cur 的左子树中 else if (cur.val > num) cur = cur.left; // 找到目标节点,跳出循环 else break; } // 返回目标节点 return cur; }
2. 插入节点
-
目标:在BST中插入一个新值
num。 -
过程:
- 如果树为空,初始化根节点为
TreeNode(num)。 - 否则,从根节点开始,根据
num与当前节点值的大小关系,找到插入位置。 - 如果
cur.val < num,则向右子树移动;如果cur.val > num,则向左子树移动。 - 如果找到重复值,则直接返回,不插入。
- 插入新节点后,需要继续保持BST的性质。
- 如果树为空,初始化根节点为
-
时间复杂度:平均情况下为
O(log n),最坏情况下为O(n)(树退化为链表)。 -
代码实现:
/* 插入节点 */ void insert(int num) { // 若树为空,则初始化根节点 if (root == null) { root = new TreeNode(num); return; } TreeNode cur = root, pre = null; // 循环查找,越过叶节点后跳出 while (cur != null) { // 找到重复节点,直接返回 if (cur.val == num) return; pre = cur; // 插入位置在 cur 的右子树中 if (cur.val < num) cur = cur.right; // 插入位置在 cur 的左子树中 else cur = cur.left; } // 插入节点 TreeNode node = new TreeNode(num); if (pre.val < num) pre.right = node; else pre.left = node; }
3. 删除节点
- 目标:从BST中删除一个值为
num的节点。 - 过程:
- 首先找到目标节点
cur。 - 根据目标节点的子节点数量,分为三种情况:
- 子节点数量为0:直接删除该节点。
- 首先找到目标节点
2. **子节点数量为1**:将目标节点替换为其子节点。
3. **子节点数量为2**:找到右子树的最小节点(或左子树的最大节点),用其值覆盖目标节点,然后删除该最小节点。
-
时间复杂度:平均情况下为
O(log n),最坏情况下为O(n)(树退化为链表)。 -
代码实现:
/* 删除节点 */ void remove(int num) { // 若树为空,直接提前返回 if (root == null) return; TreeNode cur = root, pre = null; // 循环查找,越过叶节点后跳出 while (cur != null) { // 找到待删除节点,跳出循环 if (cur.val == num) break; pre = cur; // 待删除节点在 cur 的右子树中 if (cur.val < num) cur = cur.right; // 待删除节点在 cur 的左子树中 else cur = cur.left; } // 未找到待删除节点,则直接返回 if (cur == null) return; // 子节点数量 = 0 or 1 if (cur.left == null || cur.right == null) { // 当子节点数量 = 0 / 1 时, child = null / 该子节点 TreeNode child = cur.left != null ? cur.left : cur.right; // 删除节点 cur if (cur != root) { if (pre.left == cur) pre.left = child; else pre.right = child; } else { // 若删除节点为根节点,则重新指定根节点 root = child; } } // 子节点数量 = 2 else { // 获取中序遍历中 cur 的下一个节点 TreeNode tmp = cur.right; while (tmp.left != null) { tmp = tmp.left; } // 递归删除节点 tmp remove(tmp.val); // 用 tmp 覆盖 cur cur.val = tmp.val; } }
完整代码
package com.liucc.chapter_tree;
import com.liucc.utils.PrintUtil;
import com.liucc.utils.TreeNode;
/**
* 二叉搜索树
* 特点:左子树值 < 根节点值 < 右子树值
*/
class BinarySearchTree{
private TreeNode root;
public BinarySearchTree(){
this.root = null; // 初始化空树
}
// 获取根节点
public TreeNode getRoot(){
return root;
}
// 查找节点
public TreeNode search(int num){
TreeNode cur = root;
while (cur != null) {
if (cur.val == num) { // 找到目标值
return cur;
}else if (cur.val > num) { // 目标值在左子树
cur = cur.left;
}else{ // 目标值在右子树
cur = cur.right;
}
}
return null;
}
/**
* 插入节点
* 注意事项:1、插入节点的值不能重复;2、定义节点 prev 保存父节点,方便插入操作
*/
public void insert(int num){
// 空树情况
if (root == null) {
root = new TreeNode(num);
return;
}
// 循环查找,定位待插入元素位置
TreeNode cur = root, prev = null;
while (cur != null) {
// 元素重复,直接结束
if (cur.val == num) {
System.out.println("树中已存在值为" + num + "的节点,禁止重复插入");
return;
}
prev = cur;
if (cur.val > num) { // 左子树
cur = cur.left;
}else{ // 右子树
cur = cur.right;
}
}
// 插入新节点
TreeNode newNode = new TreeNode(num);
if (prev.val > num) {
prev.left = newNode;
}else{
prev.right = newNode;
}
}
/**
* 删除节点
* 注意事项:节点删除后,依然要保持二叉搜索书的特点
* @param num
*/
public void remove(int num){
// 如果树为空,提前返回
if (root == null) {
return;
}
TreeNode cur = root, prev = null;
// 1、循环查找,定位待删除节点位置
while (cur != null) {
// 找到目标节点,退出循环
if (cur.val == num) {
break;
}
prev = cur;
if (cur.val > num) { // 左子树找
cur = cur.left;
}else{ // 右子树找
cur = cur.right;
}
}
// 2、删除节点
// 未找到待删除节点
if (cur == null) {
System.out.println("未找到待删除节点");
return;
}
// 2.1、待删除节点的度为 0 或 1
if (cur.left == null || cur.right == null) {
TreeNode child = cur.left !=null ? cur.left : cur.right;
if (prev.val > cur.val) { // 挂在父结点的左子树
prev.left = child;
}else{ // 挂在右子树
prev.right = child;
}
}
// 2.2、度为 2,不可直接删除,找到右子树中的最小节点,来替换删除节点
else{
// 中序遍历查找
TreeNode temp = cur.right;
while (temp.left != null) {
temp = temp.left;
}
// 递归删除
remove(temp.val);
// 替换待删除节点
cur.val = temp.val;
}
}
}
public class binary_search_tree {
public static void main(String[] args) {
/* 初始化二叉搜索树 */
BinarySearchTree bst = new BinarySearchTree();
// 请注意,不同的插入顺序会生成不同的二叉树,该序列可以生成一个完美二叉树
int[] nums = { 8, 4, 12, 2, 6, 10, 14, 1, 3, 5, 7, 9, 11, 13, 15 };
for (int num : nums) {
bst.insert(num);
}
System.out.println("\n初始化的二叉树为\n");
PrintUtil.printTree(bst.getRoot());
/* 查找节点 */
TreeNode node = bst.search(7);
System.out.println("\n查找到的节点对象为 " + node + ",节点值 = " + node.val);
/* 插入节点 */
bst.insert(16);
System.out.println("\n插入节点 16 后,二叉树为\n");
PrintUtil.printTree(bst.getRoot());
/* 删除节点 */
bst.remove(1);
System.out.println("\n删除节点 1 后,二叉树为\n");
PrintUtil.printTree(bst.getRoot());
bst.remove(2);
System.out.println("\n删除节点 2 后,二叉树为\n");
PrintUtil.printTree(bst.getRoot());
bst.remove(4);
System.out.println("\n删除节点 4 后,二叉树为\n");
PrintUtil.printTree(bst.getRoot());
}
}
4. 中序遍历有序
- 目标:通过中序遍历BST,得到一个有序的序列。
- 过程:中序遍历(左-根-右)会按照从小到大的顺序访问BST中的所有节点。
- 时间复杂度:
O(n)。
二叉搜索树的效率
- 查找、插入、删除操作:平均情况下为
O(log n),最坏情况(树退化为链表)下为O(n)。 - 中序遍历:
O(n)。 - 平衡二叉搜索树(如AVL树):通过自平衡操作,保证所有操作的时间复杂度为
O(log n)。
二叉搜索树的常见应用
- 数据存储与检索:快速查找、插入和删除操作。
- 排序:通过中序遍历得到有序序列。
- 动态数据结构:支持动态插入和删除操作的数据结构。
- 区间查询:通过BST的有序性,快速定位区间内的数据。
5、AVL树*
1. AVL树的背景与意义
在二叉搜索树(BST)中,频繁的插入和删除操作可能导致树的退化(如退化为链表),从而使查找、插入和删除操作的时间复杂度退化为O(n)。
1962年,G. M. Adelson-Velsky和E. M. Landis提出了AVL树,通过旋转操作确保树在插入和删除操作后保持平衡,从而将操作时间复杂度维持在O(log n)。
2. AVL树的基本概念
-
定义:AVL树是一种平衡二叉搜索树,满足以下两个条件:
- 是一棵二叉搜索树(BST)。
- 每个节点的左右子树高度差(平衡因子)的绝对值不超过1。
/* AVL 树节点类 */ class TreeNode { public int val; // 节点值 public int height; // 节点高度 public TreeNode left; // 左子节点 public TreeNode right; // 右子节点 public TreeNode(int x) { val = x; } } -
节点高度:节点的高度是从该节点到最远叶节点的边的数量。叶节点的高度为0,空节点的高度为-1。
/* 获取节点高度 */ int height(TreeNode node) { // 空节点高度为 -1 ,叶节点高度为 0 return node == null ? -1 : node.height; } /* 更新节点高度 */ void updateHeight(TreeNode node) { // 节点高度等于最高子树高度 + 1 node.height = Math.max(height(node.left), height(node.right)) + 1; } -
平衡因子:节点的平衡因子定义为左子树高度减去右子树高度。对于任意节点,平衡因子的范围是[-1, 1]。
/* 获取平衡因子 */ int balanceFactor(TreeNode node) { // 空节点平衡因子为 0 if (node == null) return 0; // 节点平衡因子 = 左子树高度 - 右子树高度 return height(node.left) - height(node.right); }
3. AVL树的旋转操作
-
旋转的目的:通过旋转操作调整树的结构,使失衡节点重新恢复平衡,同时保持二叉搜索树的性质。
-
旋转类型:
(1)右旋:用于处理左偏树(平衡因子 > 1)的情况。
操作步骤:
-
将失衡节点的左子节点(child)提升为新的根节点。
-
将失衡节点(node)变为child的右子节点。
-
如果child有右子节点(grand_child),将其作为node的左子节点。
-
更新相关节点的高度。
-
如果当节点 `child` 有右子节点(记为 `grand_child` )时,需要在右旋中添加一步:将 `grand_child` 作为 `node` 的左子节点。
代码实现
/* 右旋操作 */
TreeNode rightRotate(TreeNode node) {
TreeNode child = node.left;
TreeNode grandChild = child.right;
// 以 child 为原点,将 node 向右旋转
child.right = node;
node.left = grandChild;
// 更新节点高度
updateHeight(node);
updateHeight(child);
// 返回旋转后子树的根节点
return child;
}
(2)左旋:用于处理右偏树(平衡因子 < -1)的情况。
操作步骤:
- 将失衡节点的右子节点(child)提升为新的根节点。
- 将失衡节点(node)变为child的左子节点。
- 如果child有左子节点(grand_child),将其作为node的右子节点。
- 更新相关节点的高度。
同理,当节点 child 有左子节点(记为 grand_child )时,需要在左旋中添加一步:将 grand_child 作为 node 的右子节点。
代码实现
/* 左旋操作 */
TreeNode leftRotate(TreeNode node) {
TreeNode child = node.right;
TreeNode grandChild = child.left;
// 以 child 为原点,将 node 向左旋转
child.left = node;
node.right = grandChild;
// 更新节点高度
updateHeight(node);
updateHeight(child);
// 返回旋转后子树的根节点
return child;
}
(3)先左旋后右旋:用于处理左偏树中,左子节点的平衡因子 < 0 的情况。
操作步骤:
- 对失衡节点的左子节点执行左旋。
- 对失衡节点执行右旋。
(4)先右旋后左旋:用于处理右偏树中,右子节点的平衡因子 > 0 的情况。
操作步骤:
- 对失衡节点的右子节点执行右旋。
- 对失衡节点执行左旋。
通过判断失衡节点的平衡因子及其子节点的平衡因子的正负号,我们可以判断失衡节点属于这四类情况中的哪一种。
| 失衡节点的平衡因子 | 子节点的平衡因子 | 应采用的旋转方法 |
|---|---|---|
| >1 (左偏树) | ≥0 | 右旋 |
| >1 (左偏树) | <0 | 先左旋后右旋 |
| <−1 (右偏树) | ≤0 | 左旋 |
| <−1 (右偏树) | >0 | 先右旋后左旋 |
代码实现
/* 执行旋转操作,使该子树重新恢复平衡 */
TreeNode rotate(TreeNode node) {
// 获取节点 node 的平衡因子
int balanceFactor = balanceFactor(node);
// 左偏树
if (balanceFactor > 1) {
if (balanceFactor(node.left) >= 0) {
// 右旋
return rightRotate(node);
} else {
// 先左旋后右旋
node.left = leftRotate(node.left);
return rightRotate(node);
}
}
// 右偏树
if (balanceFactor < -1) {
if (balanceFactor(node.right) <= 0) {
// 左旋
return leftRotate(node);
} else {
// 先右旋后左旋
node.right = rightRotate(node.right);
return leftRotate(node);
}
}
// 平衡树,无须旋转,直接返回
return node;
}
4. AVL树的常见操作
-
插入节点:
- 按照BST(二叉搜索树)的插入规则插入新节点。
- 从插入点向上回溯,检查每个祖先节点的平衡因子。
- 如果发现失衡节点,根据平衡因子和子节点的平衡因子选择适当的旋转操作。
代码实现
/* 插入节点 */ void insert(int val) { root = insertHelper(root, val); } /* 递归插入节点(辅助方法) */ TreeNode insertHelper(TreeNode node, int val) { if (node == null) return new TreeNode(val); /* 1. 查找插入位置并插入节点 */ if (val < node.val) node.left = insertHelper(node.left, val); else if (val > node.val) node.right = insertHelper(node.right, val); else return node; // 重复节点不插入,直接返回 updateHeight(node); // 更新节点高度 /* 2. 执行旋转操作,使该子树重新恢复平衡 */ node = rotate(node); // 返回子树的根节点 return node; } -
删除节点:
- 按照BST的删除规则删除节点。
- 从删除点向上回溯,检查每个祖先节点的平衡因子。
- 如果发现失衡节点,根据平衡因子和子节点的平衡因子选择适当的旋转操作。
代码实现
/* 删除节点 */ void remove(int val) { root = removeHelper(root, val); } /* 递归删除节点(辅助方法) */ TreeNode removeHelper(TreeNode node, int val) { if (node == null) return null; /* 1. 查找节点并删除 */ if (val < node.val) node.left = removeHelper(node.left, val); else if (val > node.val) node.right = removeHelper(node.right, val); else { if (node.left == null || node.right == null) { TreeNode child = node.left != null ? node.left : node.right; // 子节点数量 = 0 ,直接删除 node 并返回 if (child == null) return null; // 子节点数量 = 1 ,直接删除 node else node = child; } else { // 子节点数量 = 2 ,则将中序遍历的下个节点删除,并用该节点替换当前节点 TreeNode temp = node.right; while (temp.left != null) { temp = temp.left; } node.right = removeHelper(node.right, temp.val); node.val = temp.val; } } updateHeight(node); // 更新节点高度 /* 2. 执行旋转操作,使该子树重新恢复平衡 */ node = rotate(node); // 返回子树的根节点 return node; } -
查找节点:
- AVL树的查找操作与普通BST相同,时间复杂度为O(log n)。
代码实现
public TreeNode search(int val){ TreeNode cur = root; while (cur != null) { if (cur.val < val) { cur = cur.right; }else if (cur.val > val) { cur = cur.left; }else break; } return cur; }
5. AVL树的应用场景
- AVL树适用于需要频繁进行插入、删除和查找操作的场景,如数据库索引、符号表等。
- 由于AVL树始终保持平衡,因此在这些场景中,AVL树能够提供高效的性能。
6. AVL树的优缺点
- 优点:
- 操作时间复杂度稳定为O(log n)。
- 适合频繁的增删查改操作。
- 缺点:
- 插入和删除操作需要进行旋转,增加了操作的复杂性。
- 旋转操作可能导致较高的维护成本。