第十一节:平衡树、AVL树(简介、旋转详解、代码实操)

一. 平衡树详解

1. 什么是平衡树?

 平衡树(Balanced Tree)是一种特殊的二叉搜索树

 其目的是通过一些特殊的技巧来维护树的高度平衡; 从而保证树搜索、插入、删除等操作的时间复杂度都较低

2. 为什么需要平衡树?

 如果一棵树退化成链状结构,那么搜索、插入、删除等操作的时间复杂度就会达到最坏情况,即O(n),因此不能满足要求。

 平衡树通过不断调整树的结构,使得树的高度尽量平衡,从而保证搜索、插入、删除等操作的时间复杂度都较低,通常为O(logn)

 因此,如果我们需要高效地处理大量的数据,那么平衡树就显得非常重要了。

3. 如何让树更加平衡?

◼ 方式一:限制插入、删除的节点(比如在树特性的状态下,不允许插入或者删除某些节点,不现实

◼ 方式二:在随机插入或者删除元素后,通过某种方式观察树是否平衡,如果不平衡通过特定的方式(比如旋转),让树保持平衡。

4. 常见的平衡二叉搜索树?

 AVL树:这是一种最早的平衡二叉搜索树,在1962年由G.M. Adelson-Velsky和E.M. Landis发明。

 红黑树:这是一种比较流行的平衡二叉搜索树,由R. Bayer在1972年发明。

 Splay树:这是一种动态平衡二叉搜索树,通过旋转操作对树进行平衡。

 Treap:这是一种随机化的平衡二叉搜索树,是二叉搜索树和堆的结合。

 B-树:这是一种适用于磁盘或其他外存存储设备的多路平衡查找树。

PS:这些平衡二叉搜索树都用于保证搜索树的平衡,从而在插入、删除、查找操作时保证了较低的时间复杂度。

红黑树和AVL树是应用最广泛的平衡二叉搜索树:

(1) 红黑树:红黑树被广泛应用于实现诸如操作系统内核、数据库、编译器等软件中的数据结构,其原因在于它在插入、删除、查找操作时都具有较低的时间复杂度。

(2) AVL树:AVL树被用于实现各种需要高效查询的数据结构,如计算机图形学、数学计算和计算机科学研究中的一些特定算法。

 

二. AVLTree介绍

1. 定义

   是一种自(Self)平衡二叉搜索树任意节点的权值只有1或-1或0(绝对值<=1)。它是二叉搜索树的一个变体,在保证二叉搜索树性质的同时,通过旋转操作保证树的平衡。

   由于AVL树具有自平衡性,因此其最坏情况下的时间复杂度仅O(log n)。

2. 平衡因子(权值)

    在AVL树中,每个节点都有一个权值(平衡因子),该权值代表了以该节点为根节点的子树的高度差如下图,里面的计算方式根节点是当作0来计数的, 也有当1来计算的,。

(1) 在AVL树中,任意节点的平衡因子只有1或-1或0(绝对值<=1),因此AVL树也被称为高度平衡树。反之就是不平衡的

(2) 对于每个节点,它的左子树和右子树的高度差不超过1。

(3) 这使得AVL树具有比普通的二叉搜索树更高的查询效率。

(4) 当插入或删除节点时,AVL树可以通过旋转操作来重新平衡树,从而保证其平衡性.

3. 如何维护平衡?

    AVL树的插入和删除操作与普通的二叉搜索树类似,但是在插入或者删除之后,需要继续保持树的平衡。

(1). AVL树需要通过旋转操作来维护平衡。

(2). 四种情况旋转操作:左左情况、右右情况、左右情况和右左情况双旋。 后面详细介绍

(3). 具体使用哪一种旋转,要根据不同的情况来进行区分和判断。

4.  AVL树封装过程

     步骤一:学习AVL树节点的封装;

     步骤二:学习AVL树的旋转情况下如何编写代码;

     步骤三:写出不同情况下进行的不同旋转操作;

     步骤四:写出插入操作后,树的再平衡操作;

     步骤五:写出删除操作后,树的再平衡操作;

我们可以通过分治的思想,一步步实现上面的功能,再将功能组合在一起就完成了AVL树的编写过程。

 

三. AVLTreeNode封装

1. 属性

    value属性继承TreeNode, left、right、parent节点重写为AVLTreeNode类型

import { TreeNode } from './00-二叉搜索树BSTree';

class AVLTreeNode<T> extends TreeNode<T> {
	// value继承即可,left、right、partent都需要为ALVTreeNode类型
	left: AVLTreeNode<T> | null = null;
	right: AVLTreeNode<T> | null = null;
	parent: AVLTreeNode<T> | null = null;
}

 2. 获取高度

  节点的高度指的是左右子树中最大的那个, 这里如果没有左右子树,那么节点的高度计为1 (也有计为0的)。

  主要是用来服务于平衡因子的,所以高度差没有影响。

  这里又是递归,以其中leftHeight为例分析:循环调用,直到最后那个叶子节点,返回0,然后逆向往后退回。

   /**
	 * 01-获取节点高度
	 * @returns 返回节点的高度
	 */
	getHeight(): number {
		let leftHeight = this.left ? this.left.getHeight() : 0;
		let rightHeight = this.right ? this.right.getHeight() : 0;
		return Math.max(leftHeight, rightHeight) + 1;
	}

3. 获取平衡因子

   左子树高度 和 右子树高度之间的差值。

	/**
	 * 02-获取平衡因子
	 * @returns 返回平衡因子
	 */
	getBalanceFactor(): number {
		let leftHeight = this.left ? this.left.getHeight() : 0;
		let rightHeight = this.right ? this.right.getHeight() : 0;
		return leftHeight - rightHeight;
	}

4. 判断节点是否平衡

   只要平衡因子的绝对值 <= 1 ,是平衡的。  即平衡因子可以是:-1、0、1 

	/**
	 * 03-判断节点是否平衡
	 * @returns true表示节点平衡  false表示不平衡
	 */
	isBalanced(): boolean {
		let factor = this.getBalanceFactor();
		return Math.abs(factor) <= 1;
	}

5. 获取最高子节点

   判断左右子节点的高度,哪个高,则返回哪个。

   极个别情况,左右子节点高度相同,这里通常返回同方向的子节点, 即当前节点本身是左结点,那么就返回左子节点。(很少这种情况)

    /**
	 * 04-获取最高的子节点
	 * @returns 最高的子节点,没有则返回null
	 */
	get higerChild(): AVLTreeNode<T> | null {
		let leftHeigh = this.left ? this.left.getHeight() : 0;
		let rightHeight = this.right ? this.right.getHeight() : 0;

		//1. 左子节点高
		if (leftHeigh > rightHeight) return this.left;

		//2. 右子节点高
		if (leftHeigh < rightHeight) return this.right;

		//3. 高度相同,返回同方向 (这个常见规律,很少高度相同的)
		return this.isLeft ? this.left : this.right;
	}

 

四. 节点旋转

1. 说明

   这里从节点的角度单纯的分析最简单的 右旋转 和 左旋转,后面AVLTree的四种旋转情况都是以这个为基础的。

   图解:对应如下截图,左左情况---右旋转,  右右情况---左旋转

2. 右旋转

注意:

   A. 下面的步骤,处理哪个位置,通常是两步骤:(1). 谁指向该节点  (2) 该节点的父节点指向谁 。 所以只需要记住上述的四大步骤,里面的小步骤根据图解分析即可。[重要!!]

   B. 这里的this,就是图中的root,因为后续调用的时候是 xxx.rightRotation

(1). 处理pivot的位置  

   A. 选择当前节点的左子节点作为旋转轴心 (pivot)

   B. pivot的父节点指向 this(root)当前节点的父节点

(2). 处理pivot右节点的位置

   A. this(root) 当前节点的左节点, 指向 pivot 的右节点(可能是null)

   B. pivot的右节点存在的情况下, pivot右节点的父节点指向this节点

(3). 处理this节点的位置

   A. pivot 的右节点指向 this

   B. this 节点的父节点指向 pivot

(4). 判断pivot是否有父节点,处理父节点和pivot指向 (步骤1的遗留)

   分三种情况:没有父节点、父节点的左结点指向、父节点的右节点指向

PS:不好理解的地方:为什么最后一步是判断pivot的partent, 而不是 this.partent?

解释:

    步骤1中:pivot.parent = this.parent;   所以二者是等价的

    步骤3中:this.parent = pivot;  所以this.parent已经变了,没法使用了

/**
	 * 05-右旋转
	 */
	rightRotation() {
		const isLeft = this.isLeft;
		const isRight = this.isRight;

		//1. 处理pivot的位置
		let pivot = this.left!;
		pivot.parent = this.parent;

		//2. 处理pivot右节点的位置
		this.left = pivot.right; //pivot.right可能为null
		if (pivot.right) {
			pivot.right.parent = this;
		}

		//3. 处理this节点的位置
		pivot.right = this;
		this.parent = pivot;

		//4. 判断pivot是否有父节点,处理父节点和pivot指向
		//4.1 pivot没有父节点,它就是最上层了
		if (!pivot.parent) {
			return pivot;
		}
		//4.2 作为左节点
		else if (isLeft) {
			pivot.parent.left = pivot;
		}
		//4.3 作为右节点
		else if (isRight) {
			pivot.parent.right = pivot;
		}
		return pivot;
	}

测试:  

{
	console.log('-------------------01-右旋转------------------------');
	let avlNode1 = new AVLTreeNode(10);
	avlNode1.left = new AVLTreeNode(15);
	avlNode1.left.left = new AVLTreeNode(20);
	//需要设置父节点才能测试
	avlNode1.left.parent = avlNode1;
	avlNode1.left.left.parent = avlNode1.left;
	const parent = new AVLTreeNode(5);
	parent.left = avlNode1;
	avlNode1.parent = parent;
	btPrint(parent);
	avlNode1.rightRotation();
	btPrint(parent);
}

 

 3. 左旋转

注意:

    A. 上述步骤,处理哪个位置,通常是两步骤:(1). 谁指向该节点  (2) 该节点的父节点指向谁。所以只需要记住上述的四大步骤,里面的小步骤根据图解分析即可。[重要!!]

    B. 这里的this,就是图中的root,因为后续调用的时候是 xxx.leftRotation

(1). 处理pivot的位置  

    A. 选择当前节点的右子节点作为旋转轴心 (pivot)

    B. pivot的父节点指向 this(root)当前节点的父节点

(2). 处理pivot左侧节点的位置

    A. this(root) 当前节点的右节点, 指向 pivot 的左节点(可能是null)

    B. pivot的左节点存在的情况下, pivot左节点的父节点指向this节点

(3). 处理this节点的位置

    A. pivot 的左节点指向 this

    B. this 节点的父节点指向 pivot

(4). 判断pivot是否有父节点,处理父节点和pivot指向 (步骤1的遗留)

    /**
	 * 06-左旋转
	 */
	leftRotation() {
		const isLeft = this.isLeft;
		const isRight = this.isRight;
		//1.处理pivot的位置
		let pivot = this.right!;
		pivot.parent = this.parent;
		//2.处理pivot左结点的位置
		this.right = pivot.left;
		if (pivot.left) {
			pivot.left.parent = this;
		}
		//3.处理this(root)的位置
		pivot.left = this;
		this.parent = pivot;
		//4. 判断piovt是否有父节点,处理pivot和父节点的指向
		if (!pivot.parent) {
			return pivot;
		} else if (isLeft) {
			pivot.parent.left = pivot;
		} else if (isRight) {
			pivot.parent.right = pivot;
		}
		return pivot;
	}

 

五. AVLTree实操

1.  基本封装

   继承BSTree二叉搜索树, 使用insert插入,现在是符合二叉搜索树特性的
import { BSTree } from './00-二叉搜索树BSTree';

class ALVTree<T> extends BSTree<T> {}

let alv = new ALVTree<number>();
alv.insert(10);
alv.insert(20);
alv.insert(30);

alv.print();

2. 四种旋转情况【重点】

(1). 不平衡的四种情况

     分别为:左左(LL)、右右(RR)、左右(LR)、右左(RL) ,详见下图

     这里的第一个:左 or 右, 取决于pivot是 isLeft 还是 isRight

               第二个:左 or 右, 取决于current是 isLeft 还是 isRight (current详见后面的说明)

2. 如何旋转

    PS: 这里先不管如何去找不平衡的节点,假设已经找到了

(1). 我们需要先找到失衡的节点:

   失衡的节点称之为root,

   失衡节点的儿子(更高的儿子)称之为pivot

   失衡节点的孙子(更高的孙子)称之为current

(2). 如果从root到current的是:

   LL:左左情况,那么root右旋转;

   RR:右右情况,那么root左旋转;

   LR:左右情况,那么先对pivot 进行左旋转(变成左左了),再对root进行右旋转

   RL:右左情况,那么先对pivot 进行右旋转(变成右右了),再对root进行左旋转

代码实操

    /**
	 * 01-再平衡方法
	 * (根据不平衡的节点的情况(LL/RR/LR/RL)让子树平衡)
	 * @param root 不平衡的节点
	 */
	rebalance(root: AVLTreeNode<T>) {
		const pivot = root.higerChild;
		const current = pivot?.higerChild;
		let resultNode: AVLTreeNode<T> | null = null; //旋转后的根节点

		//第一层:L (left)
		if (pivot?.isLeft) {
			// 第二层:L, 即LL(左左)
			if (current?.isLeft) {
				resultNode = root.rightRotation();
			}
			//第二层:R, 即LR(左右)
			else {
				pivot.leftRotation();
				resultNode = root.rightRotation();
			}
		}
		// 第一层: R (right)
		else {
			// 第二层:L, 即RL(右左)
			if (current?.isLeft) {
				pivot?.rightRotation();
				resultNode = root.leftRotation();
			}
			//第二层:R, 即RR(右右)
			else {
				resultNode = root.leftRotation();
			}
		}

		//判断返回的节点是否有父节点,没有则用this.root指向
		if (!resultNode.parent) {
			this.root = resultNode;
		}
	}

3. 插入后调整

(0).  模板方法补充

  子类对象中重写父类对象的方法,通过子类对象调用的时候,生效的是子类中的方法;通过父类对象调用的时候,生效的是父类方法。
查看代码
 class parent {
	protected createNode1() {
		console.log('AA');
	}
	protected createNode2() {}

	getMsg() {
		this.createNode1();
		this.createNode2();
	}
}

class child extends parent {
	protected createNode1() {
		console.log('aa');
	}
	protected createNode2() {
		console.log('bb');
	}
}

// 测试
{
	console.log('--------------parent类测试---------------');
	let p1 = new parent();
	p1.getMsg(); //AA
}

{
	console.log('--------------child类测试---------------');
	let c1 = new child();
	c1.getMsg(); //aa  bb
}

(1). 父类中insert的再平衡处理

   BSTree中insert方法需要处理节点的创建、增加校验平衡处理,为了服务于子类AVLTree

(2). 子类ALVTree中方法的重写

  A 增加createNode方法,用于重写父类中的方法

  B 重写checkBalance,判断节点是否平衡,不平衡的话进行旋转处理

/**
	 * 02-校验节点是否平衡,并进行再平衡处理
	 * @param node 校验平衡开始节点
	 */
	checkBalance(node: AVLTreeNode<T>) {
		let current = node.parent;
		while (current) {
			if (!current.isBalanced()) {
				//表示current节点不平衡
				this.rebalance(current);
			}
			current = current.parent;
		}
	}
	/**
	 * 03-创建节点对象(模板方法,重写父类中的方法)
	 * @param value 节点内容
	 * @returns 返回节点实例
	 */
	protected createNode(value: T): AVLTreeNode<T> {
		return new AVLTreeNode(value);
	}

为什么是从parent节点开始呢?

因为插入的节点默认子节点,子节点不存在平衡一说,所以从父节点开始遍历搜索

(3). Node节点需要保存父节点

  父类BSTree中的insertNode方法中需要处理一下parent的指向

测试:

{
	const avlTree = new AVLTree<number>();
	// for (let i = 0; i < 15; i++) {
	// 	avlTree.insert(Math.floor(Math.random() * 200));
	// }

	avlTree.insert(106);
	avlTree.insert(17);
	avlTree.insert(198);
	avlTree.insert(15);
	avlTree.insert(72);
	avlTree.insert(22);
	avlTree.insert(21);
	avlTree.insert(56);
	avlTree.insert(23);
	avlTree.insert(57);

	avlTree.print();
}

六. AVLTree--删除后调整【难】

1. checkBalance再平衡

思考: checkBalance传入谁?

      很明显应该是删除的节点;

      但是如果有两个子节点的情况,我们需要找的是前期和后继,最终是将前驱和后继位置的节点删除掉的;

      寻找的应该是从AVL树中被移除位置的节点;

(1) 情况一:删除节点本身是叶子节点

     传入current节点即可,并且需要根据current节点的parent去寻找失衡节点;

(2) 情况二:删除节点只有一个子节点

     传入current节点即可,并且需要根据current节点的parent去寻找失衡节点;

(3)情况三:删除节点有两个子节点:

      找到后继节点successor原来的位置,并且需要根据successor节点去寻找失衡节点;

总结:这里的关键点是两个

     关键点一:必须要找到检测位置的节点;

     关键点二:检测位置的节点必须有父节点;

2. 上述情况一 和 情况二 的代码处理

      if (replaceNode && current.parent) {

            replaceNode.parent = current.parent;

      }

 

3. 上述情况三

   如果需要找后继节点,那么父节点的操作会比较复杂;我们可以利用我之前提到的第二种方案,来减少一些父节点的设置操作;

4. rebalance的优化

(1) 目前我们rebalance的操作是哪些节点会执行呢?

      插入节点的所有父节点(一直向上查找父节点);

      删除节点的所有父节点(一直向上查找父节点);

(2) 但是 是否需要每次插入、删除都需要将所有的父节点都rebalance操作呢?

      这个取决于在插入一个节点后后,是否改变了祖父节点的高度;

      这个取决于在删除一个节点后后,是否改变了祖父节点的高度;

(3)我们得出结论:

     插入节点,再平衡rebalance后不需要继续后续节点的再平衡rebalance ;

     删除节点,再平衡rebalance后需要继续后续节点的再平衡rebalance;

/**
	 * 02-校验节点是否平衡,并进行再平衡处理
	 * @param node 校验平衡开始节点
	 * @param isAdd true表示增加方法平衡  false表示删除方法的平衡
	 */
	checkBalance(node: AVLTreeNode<T>, isAdd = true) {
		let current = node.parent;
		while (current) {
			if (!current.isBalanced()) {
				//表示current节点不平衡
				this.rebalance(current);

				// 这个位置时旋转完成后的操作
				// break决定不会进一步去查找父节点有没有平衡的情况了
				// 添加的情况是不需要进一步向上查找的, 直接break
				// 删除的情况是需要进一步向上查找的, 不能break
				if (isAdd) break;
			}
			current = current.parent;
		}
	}

 

 

 

 

 

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鹏飞)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 声     明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
  • 声     明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2024-01-09 16:40  Yaopengfei  阅读(10)  评论(1编辑  收藏  举报