演算法與資料結構——AVL樹(平衡二叉搜尋樹)

风陵南發表於2024-09-04

AVL樹

在“二叉搜尋樹”章節提到,在多次插入和刪除操作後,二叉搜尋樹可能退化為連結串列。在這種情況下,所有操作的時間複雜度將從O(logn)劣化為O(n)

如下圖,經過兩次刪除節點操作,這棵二叉搜尋樹便會退化為連結串列

再例如,下圖所示的完美二叉樹中插入兩個節點後,樹將嚴重向左傾斜,查詢操作的時間複雜度也隨之劣化。

1962 年 G. M. Adelson‑Velsky 和 E. M. Landis 在 論 文 “An algorithm for the organization of information”中提出了 AVL 樹。AVL樹能夠確保在持續新增和刪除節點後不會退化,從而使得各種操作的時間複雜度保持在O(logn)級別。

AVL樹常見術語

AVL樹既是二叉搜尋樹,也是平衡二叉樹,同時滿足這兩類二叉樹的所有性質,因此是一種平衡二叉搜尋樹(balanced binary search tree)

節點高度

由於AVL樹的相關操作需要獲取節點高度,因此我們需要為節點類新增height變數:

/*AVL 樹節點類*/
struct TreeNode{
	int val{};
	int height = 0;
	TreeNode *left{};
	TreeNode *right{};
	TreeNode() = default;
	explicit TreeNode(int x) :val(x){}
};

“節點高度”是指從該節點到它的最遠葉節點的距離,即所經過的“邊”的數量。需要特別注意的是,葉節點的高度為0,而空節點的高度為-1。我們將建立兩個工具函式,分別用於獲取和更新節點的高度:

/*獲取節點高度*/
int AVLTree::height(TreeNode *node){
	return node == nullptr ? -1 : node->height;
}

/*更新節點高度*/
void AVLTree::updateHeight(TreeNode *node){
	// 節點高度等於最高子樹高度 + 1
	node->height = max(height(node->left), height(node->right)) + 1;
}

節點平衡因子

節點的平衡因子(balance factor)定義為節點左子樹的高度減去右子樹的高度,同時規定空節點的平衡因子為0。我們同樣將獲取節點平衡因子的功能封裝成函式,方便後續使用:

/*獲取平衡因子*/
int AVLTree::balanceFactor(TreeNode *node){
	// 空節點 平衡因子為0
	if (node == nullptr)
		return 0;
	// 節點平衡因子 = 左子樹高度 - 右子樹高度
	return height(node->left) - height(node->right);
}

設平衡因子為f,則一棵AVL樹的任意節點的平衡因子都滿足 -1 < = f <= 1。

AVL樹旋轉

AVL樹的特點在於“旋轉”操作,它能夠在不影響二叉樹的中序遍歷序列的前提下,使失衡節點重新回覆平衡。換句話說,**旋轉操作既能保持“二叉搜尋樹”的性質,也能使樹重新變成“平衡二叉樹”。

我們將平衡因子絕對值 > 1 的節點稱為“失衡節點”。根據節點失衡的情況不同,旋轉操作分為四種:右旋、左旋、先右旋後左旋、先左旋後右旋。

右旋

如圖所示,節點下方為平衡因子。從底往頂看,二叉樹中首個失衡節點時“節點3”。我們關注以該失衡節點為根節點的子樹,將該節點記為node,其左子節點記為child,執行“右旋操作”。完成右旋後,子樹恢復平衡,並且仍然保持二叉搜尋樹的性質。


另外當child節點有右子節點時(記為grand_child),需要在右旋中新增一步:將grand_child作為node的左子結點。

“右旋”是一種形象化的說法,實際上需要透過修改節點指標來實現。

/*右旋操作*/
TreeNode* AVLTree::rightRotate(TreeNode *node){
	TreeNode *child = node->left;
	TreeNode * grand_child = child->right;
	// 以child為原點,將 node 向右旋轉
	child->right = node;
	node->left = grand_child;
	// 更新節點高度
	updateHeight(node);
	updateHeight(child);
	// 返回旋轉後子樹的根節點
	return child;
}

左旋

相應地,如果考慮上述失衡二叉樹的“映象”,則需要執行下圖所示的“左旋”操作。

同理,當節點child有左子結點(記為grand_child)時,需要在左旋中新增一步grand_child作為node的右子節點。

觀察發現,左旋和右旋操作在邏輯上是映象對稱的,它們分別解決的兩種失衡情況也是對稱的。基於對稱性,我們只需要將右旋程式碼啊的所有left替換為right,將所有的right替換為left,即可得到左旋的實現程式碼:

/*左旋操作*/
TreeNode *AVLTree::leftRotate(TreeNode *node){
	TreeNode *child = node->right;
	TreeNode *grand_child = child->left;
	// 以child為原點 將 node 向左旋轉
	child->left = node;
	node->right = grand_child;
	// 更新節點高度
	updateHeight(node);
	updateHeight(child);
	// 返回旋轉後子樹的根節點
	return child;
}

先左旋後右旋

下圖中的失衡節點3,僅使用左旋或右旋都無法使子樹恢復平衡。此時需要先對child執行“左旋”,再對node執行“右旋”。

先右旋後左旋

對於上述失衡二叉樹的映象情況,需要先對child執行“右旋”,再對node執行“左旋”操作。

旋轉的選擇

下面展示了四種失衡情況與上述案例逐個對應,分別需要採用右旋、先左旋後右旋、先右旋後左旋、左旋的操作。

如下表所示,我們透過判斷失衡節點的平衡因子以及較高一側子節點的平衡因子的正負號,來確定失衡節點屬於那種情況

失衡節點的平衡因子 子節點的平衡因子 應採用的旋轉方法
> 1 (左偏樹) ≥ 0 右旋
> 1 (左偏樹) < 0 先左旋後右旋
< -1 (右偏樹) ≤ 0 左旋
< -1 (右偏樹) > 0 先右旋後左旋

為便於使用,我們將旋轉操作封裝成一個函式。有了這個函式,我們就能對各種失衡情況進行旋轉,使失衡節點重新恢復平衡。

/*執行旋轉操作,使該子樹重新恢復平衡*/
TreeNode *AVLTree::rotate(TreeNode *node){
	// 獲取節點 node 的平衡因子
	int _balanceFactor = balanceFactor(node);
	// 左偏樹
	if (_balanceFactor > 1){
		if (balanceFactor(node->left) < 0){
			// 先左旋child再右旋node
			node->left = leftRotate(node->left);
			return rightRotate(node);
		}
		else{
			// 直接右旋
			return rightRotate(node);
		}
	}
	// 右偏樹
	if (_balanceFactor < -1){
		if (balanceFactor(node->right) > 0){
			// 先右旋child 再左旋node
			node->right = rightRotate(node->right);
			return leftRotate(node);
		}
		else
		{
			// 直接左旋
			return leftRotate(node);
		}
	}
	// 平衡樹,無需旋轉,直接返回
	return node;
}

AVL樹常用操作

插入節點

AVL樹的節點插入操作與二叉搜尋樹在主體上類似。唯一的區別在於,在AVL樹中插入節點後,從該節點到根節點的路徑上可能會出現一系列失衡節點。因此,我們需要從這個節點開始,自底向上執行旋轉操作,使其所有失衡節點恢復平衡

/*遞迴插入節點(輔助方法)*/
TreeNode *AVLTree::insertHelper(TreeNode *node, int val){
	if (node == nullptr){
		return new TreeNode(val);
	}
	if (node->val < val){
		node->right = insertHelper(node->right, val);
	}
	else if (node->val > val){
		node->left = insertHelper(node->left, val);
	}
	else
		// 重複節點 直接返回
		return node;
	updateHeight(node); // 更新節點高度
	// 執行旋轉操作,使該子樹重新恢復平衡
	node = rotate(node);
	// 返回子樹根節點
	return node;
}

刪除節點

類似地,在二叉搜尋樹的刪除節點方法的基礎上,需要從底至頂執行旋轉操作,使所有失衡節點恢復平衡。

/*刪除節點*/
void AVLTree::remove(int val){
	root = removeHelper(root, val);
}
/*遞迴刪除節點(輔助方法)*/
TreeNode *AVLTree::removeHelper(TreeNode *node, int val){
	if (node == nullptr){
		return nullptr;
	}
	/*查詢節點並刪除*/
	if (node->val < val){
		node->right = removeHelper(node->right, val);
	}
	else if (node->val > val){
		node->left = removeHelper(node->left, val);
	}
	else{
		// 子節點數量為0 或 1
		if (node->left == nullptr || node->right == nullptr){
			TreeNode *child = node->left == nullptr ? node->right : node->left;
			delete node;
			node = child;
		}
		// 子節點數量為 2
		else{
			// 找到中序遍歷的後一個節點(右子樹的最左邊元素)
			TreeNode *tmp = node->right;
			while (tmp->left != nullptr){
				tmp = tmp->left;
			}
			int tmpVal = tmp->val;
			// 遞迴刪除 (返回值是根節點)(刪除右子樹的最左邊元素返回值就是右子樹根節點)
			node->right = removeHelper(node->right, tmpVal);
			// 再覆蓋值(相當於交換後刪除)
			node->val = tmpVal;
		}
	}
	updateHeight(node); // 更新每次遇到的節點高度
	// 執行旋轉操作,使該子樹重新恢復平衡
	node = rotate(node);
	// 返回子樹的根節點
	return node;
}

查詢節點

AVL樹的節點查詢操作與二叉搜尋樹一致。

AVL樹典型應用

  • 組織和儲存大型資料,適用於高頻查詢、低頻增刪的場景。
  • 用於構建資料庫中的索引系統。
  • 紅黑樹也是一種常見的平衡二叉搜尋樹。相較於AVL樹,紅黑樹的平衡條件更加寬鬆,插入與刪除節點所需的旋轉操作更少,節點增刪操作的平均效率更高。

相關文章