lec4 Trees(2)

image-20241119170227426来到弱项,AVL树,旋转操作至今都没这么搞懂哈哈,不过没关系,今天就会解决。

前言

二叉搜索树(BST)操作的时间复杂度


平均时间复杂度

对于二叉搜索树(BST)的所有基本操作(查找、插入、删除),其平均时间复杂度是 O(d),其中 \(d\) 是树的深度。

  • 最佳情况
    最小的树深度是 $ _2 N $,其中 \(N\) 是树的节点数。对于一个完全平衡的二叉树,树的深度接近 $ _2 N \(,因此,在最佳情况下,BST 的操作时间复杂度为 O(\)N$)。

  • 最差情况
    最差情况是树的深度为 \(N\),即树退化为一条链表。在这种情况下,BST 的操作时间复杂度为 O(N)。


最佳树形

  • 最佳树形平衡的二叉搜索树,即每个节点的左子树和右子树的高度差不超过 1。
    例如,插入节点顺序为:4, 2, 6, 1, 3, 5, 7,构成一个平衡的二叉搜索树:

    1
    2
    3
    4
    5
        4
    / \
    2 6
    / \ / \
    1 3 5 7

    在这种树形下,深度 \(d\) 为 $ _2 N \(,因此操作时间复杂度为 O(\)N$)。


最差树形

  • 最差树形退化的二叉搜索树,即树的节点按升序或降序插入,导致树形退化为一条链表。
    例如,插入节点顺序为:2, 4, 6, 8, 10, 12,构成一个退化的树:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    2
    \
    4
    \
    6
    \
    8
    \
    10
    \
    12

    在这种情况下,树的深度 \(d = N\),即树退化为一条链表,操作时间复杂度为 O(N)。


插入升序元素的情况

  • 如果你按升序插入元素,例如 2, 4, 6, 8, 10, 12,则会导致 缺乏平衡,树形会退化为一条 不平衡的链表。这种树的深度最大,等于 \(N\),因此查找、插入、删除操作的时间复杂度为 O(N)。

树的平衡方法


1. 不平衡(Don’t Balance)

  • 描述:树在操作时不进行任何平衡调整。
  • 优点:实现简单,操作较为直接。
  • 缺点:随着插入和删除操作,树可能会变得非常不平衡,导致某些节点变得非常深,进而影响操作效率。

2. 严格平衡(Strict Balance)

  • 描述:树必须始终保持完全平衡,即每个节点的左右子树高度差不超过 1(例如 AVL 树),或者树的高度始终为 $ N $。
  • 优点:树保持最优的操作效率,查找、插入和删除操作的时间复杂度为 O(\(\log N\))。
  • 缺点:维护严格平衡需要频繁的旋转操作,增加了实现的复杂度。

3. 较好的平衡(Pretty Good Balance)

  • 描述:树允许轻微的不平衡,平衡条件比严格平衡宽松。例如,红黑树允许每个节点的左右子树的黑色节点数相同,但不要求每个节点的左右子树高度差不超过 1。
  • 优点:比严格平衡更容易实现且保持良好的操作效率。
  • 缺点:尽管不完全平衡,但依然能保证操作的时间复杂度为 O(\(\log N\)),适合大部分应用。

4. 访问时调整(Adjust on Access)

  • 描述:在访问(查找、插入、删除)时根据树的结构动态调整,常见的如伸展树(Splay Tree)。
  • 优点:在访问时自动优化树的形状,减少未来访问的成本。
  • 缺点:可能会导致某些操作后的树形较差,无法保证最优平衡。

完全二叉树(Complete Binary Tree)


1. 完全二叉树的定义

  • 满二叉树:在满二叉树中,每个节点要么是一个内部节点,拥有两个非空子节点,要么是一个叶子节点。

  • 完全二叉树:在完全二叉树中,除了可能在最后一层(深度为 $ d-1 $)没有完全填满外,所有的层都被完全填满。最后一层的节点按从左到右的顺序填充。即:

    • 每一层的节点都被填满,只有最底层可能不完全。
    • 最底层的节点从左侧开始填充,直到该层结束。
    image-20241119175244712

2. 完全二叉树的构建

  • 完全二叉树是通过从根节点开始,逐层填充节点,从左到右填充的方式构建的。每一层都填满后,才开始填充下一层,直到最后一层。

每一次操作之后都想成为一颗完全二叉树,这会是一个很昂贵的操作。

平衡二叉搜索树(Balanced Binary Search Trees)


平衡二叉搜索树简介

  • AVL树:是高度平衡的二叉搜索树(Height-Balanced Tree),其中每个节点的左子树和右子树的高度差不超过1。
  • 自调整树(如:Splay树):是一种通过不断地进行调整来保持树结构的平衡的树。
  • B树及多路查找树:这些是树的一种变体,允许每个节点有多个子节点,适用于需要大量数据存储的场景。

AVL树的平衡因子

  • 平衡因子:一个节点的平衡因子是其左子树高度减去右子树高度的差值。
    • 平衡因子计算公式
      $ = - $
  • AVL树的特点
    • 每个节点的左右子树高度差不超过1。
    • 如果一个节点的平衡因子的绝对值大于1,则该节点是不平衡的,必须进行旋转操作来恢复平衡。
    • 为了确保树保持平衡,AVL树中会存储每个节点的高度和/或平衡因子。

AVL树的高度

  • AVL树的高度函数
    $ S(h) $ 表示高度为 $ h $ 的最小AVL树的节点数。
    基础条件
    • $ S(0) = 1 $,表示高度为0时的AVL树只有一个节点(根节点)。
    • $ S(1) = 2 $,表示高度为1时的AVL树有两个节点(根节点和一个子节点)。
  • 递推关系: $ S(h) = S(h-1) + S(h-2) + 1 $
    • 其中 $ S(h-1) $ 表示左子树的最小节点数,$ S(h-2) $ 表示右子树的最小节点数。
  • 递推的结果
    $ S(h) > ^h $,其中 $ $ 约等于1.62,表示随着树的高度增加,AVL树的节点数呈现出斐波那契数列的增长趋势。

插入操作与AVL树的平衡调整


1. 插入操作与平衡因子

  • 插入操作的影响
    当向AVL树插入新节点时,可能会导致某些节点的平衡因子(balance factor)变为2或-2,表示该节点的左右子树高度差异过大,树失去了平衡。

  • 平衡因子的更新
    插入节点后,树的平衡性可能会受到影响,但只有从插入点到根节点的路径上的节点的高度会发生变化。我们需要沿着这条路径逐一更新每个节点的高度,并重新计算其平衡因子。


2. 更新节点高度

  • 更新高度
    在插入新节点后,我们需要沿着从插入点到根节点的路径,依次向上更新节点的高度。每个节点的高度更新规则是: $ = (, ) + 1 $

  • 更新平衡因子
    在更新节点高度的同时,还要计算并更新该节点的平衡因子。平衡因子的计算公式为: $ = - $ 如果平衡因子的绝对值大于1(即平衡因子为2或-2),则表明该节点不平衡,需要进行旋转操作。


3. 旋转操作

  • 旋转的必要性
    当某个节点的平衡因子为2或-2时,表示该节点失衡。此时需要通过旋转操作来恢复平衡。旋转操作通常有四种情况,分别为:
    • 左旋(Left Rotation):当右子树较高时,通过左旋调整。
    • 右旋(Right Rotation):当左子树较高时,通过右旋调整。
    • 左-右旋(Left-Right Rotation):当左子树的右子树较高时,先左旋再右旋。
    • 右-左旋(Right-Left Rotation):当右子树的左子树较高时,先右旋再左旋。

单旋转图示:

image-20241119184516301
image-20241119184527023
image-20241119184537402
image-20241119184553577

双旋转图示

image-20241119184836452
image-20241119184845015
image-20241119184854800
image-20241119184901659
image-20241119184908317
image-20241119184919087

AVL树插入与平衡调整实现


1. 树节点定义

在AVL树中,每个节点包含:

  • element:存储数据的元素。
  • left:指向左子节点的指针。
  • right:指向右子节点的指针。
  • height:节点的高度(可选)。可以通过平衡因子来计算高度,减少不必要的存储。
1
2
3
4
5
6
7
8
9
10
struct AvlNode{
Comparable element;
AvlNode *left;
AvlNode *right;
int height; // 存储高度,或仅存储平衡因子

AvlNode(const Comparable & ele, AvlNode *lt,
AvlNode *rt, int h = 0)
: element{ ele }, left{ lt }, right{ rt }, height{ h } { }
};

2. 获取节点的高度

为了计算树的平衡因子,可以定义一个辅助函数,获取给定节点的高度。如果节点为空,返回-1。

1
2
3
int height( AvlNode *t ) const {  
return t == nullptr ? -1 : t->height;
}

3. 插入操作

插入操作遵循二叉搜索树(BST)的基本规则:

  1. 如果插入节点为空,创建一个新节点。
  2. 否则,按照大小关系递归地插入元素。

插入操作的特殊之处在于:

  • 插入后需要沿着路径向上更新每个节点的高度。
  • 每次更新高度后,检查平衡因子,若超出容忍范围(2或-2),则需要进行旋转调整。
1
2
3
4
5
6
7
8
9
void insert(const Comparable &x, AvlNode * &t) {
if (t == nullptr)
t = new AvlNode{ x, nullptr, nullptr }; // 插入新节点
else if (x < t->element)
insert(x, t->left); // 插入到左子树
else if (t->element < x)
insert(x, t->right); // 插入到右子树
balance(t); // 插入后调整平衡
}

4. 平衡操作

平衡操作的核心是计算每个节点的平衡因子,并根据需要进行旋转。如果平衡因子大于1或小于-1,进行旋转操作来恢复平衡。

  • 平衡因子height(left subtree) - height(right subtree)
  • 平衡条件:如果平衡因子的绝对值大于1,表示不平衡,必须进行旋转。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void balance(AvlNode* &t) {
if (t == nullptr) return;

if (height(t->left) - height(t->right) > IMBALANCE) {
if (height(t->left->left) >= height(t->left->right))
rotateWithLeftChild(t); // 左左情况,单旋
else
doubleWithLeftChild(t); // 左右情况,双旋
} else if (height(t->right) - height(t->left) > IMBALANCE) {
if (height(t->right->right) >= height(t->right->left))
rotateWithRightChild(t); // 右右情况,单旋
else
doubleWithRightChild(t); // 右左情况,双旋
}

// 更新节点高度
t->height = max(height(t->left), height(t->right)) + 1;
}

5. 旋转操作

旋转是为了修复不平衡的AVL树。以下是几种旋转方式:

  • 单旋转:
    • 左旋:当左子树过高时,进行右旋。
    • 右旋:当右子树过高时,进行左旋。
1
2
3
4
5
6
7
8
9
10
11
12
void rotateWithLeftChild(AvlNode* &k2) {
AvlNode* k1 = k2->left;
k2->left = k1->right;
k1->right = k2;

// 更新高度
k2->height = max(height(k2->left), height(k2->right)) + 1;
k1->height = max(height(k1->left), k2->height) + 1;

// k1成为新的根节点
k2 = k1;
}
  • 双旋转:
    • 左-右旋:首先对左子树进行右旋,再对根节点进行左旋。
    • 右-左旋:首先对右子树进行左旋,再对根节点进行右旋。
1
2
3
4
void doubleWithLeftChild(AvlNode* &k3) {
rotateWithRightChild(k3->left); // 对左子树进行右旋
rotateWithLeftChild(k3); // 对根节点进行左旋
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#include <iostream>
#include <algorithm>
using namespace std;

struct AvlNode {
int element;
AvlNode* left;
AvlNode* right;
int height;

AvlNode(int ele, AvlNode* lt = nullptr, AvlNode* rt = nullptr, int h = 0)
: element(ele), left(lt), right(rt), height(h) {}
};

class AVLTree {
public:
AVLTree() : root(nullptr) {}

// 插入元素
void insert(const int& x) {
insert(x, root);
}

private:
AvlNode* root;

// 获取节点的高度
int height(AvlNode* t) const {
return t == nullptr ? -1 : t->height;
}

// 插入操作
void insert(const int& x, AvlNode*& t) {
if (t == nullptr) {
t = new AvlNode(x); // 插入新节点
}
else if (x < t->element) {
insert(x, t->left); // 插入到左子树
}
else if (x > t->element) {
insert(x, t->right); // 插入到右子树
}

balance(t); // 插入后平衡树
}

// 平衡操作
void balance(AvlNode*& t) {
if (t == nullptr) return;

if (height(t->left) - height(t->right) > 1) {
if (height(t->left->left) >= height(t->left->right)) {
rotateWithLeftChild(t); // 左左情况,单旋
} else {
doubleWithLeftChild(t); // 左右情况,双旋
}
} else if (height(t->right) - height(t->left) > 1) {
if (height(t->right->right) >= height(t->right->left)) {
rotateWithRightChild(t); // 右右情况,单旋
} else {
doubleWithRightChild(t); // 右左情况,双旋
}
}

t->height = max(height(t->left), height(t->right)) + 1; // 更新高度
}

// 左旋
void rotateWithLeftChild(AvlNode*& k2) {
AvlNode* k1 = k2->left;
k2->left = k1->right;
k1->right = k2;

k2->height = max(height(k2->left), height(k2->right)) + 1;
k1->height = max(height(k1->left), k2->height) + 1;

k2 = k1;
}

// 右旋
void rotateWithRightChild(AvlNode*& k1) {
AvlNode* k2 = k1->right;
k1->right = k2->left;
k2->left = k1;

k1->height = max(height(k1->left), height(k1->right)) + 1;
k2->height = max(height(k2->right), k1->height) + 1;

k1 = k2;
}

// 右左旋
void doubleWithRightChild(AvlNode*& k3) {
rotateWithLeftChild(k3->right); // 对右子树进行左旋
rotateWithRightChild(k3); // 对根节点进行右旋
}

// 左右旋
void doubleWithLeftChild(AvlNode*& k3) {
rotateWithRightChild(k3->left); // 对左子树进行右旋
rotateWithLeftChild(k3); // 对根节点进行左旋
}

// 中序遍历(显示树)
void inorder(AvlNode* t) const {
if (t == nullptr) return;
inorder(t->left);
cout << t->element << " ";
inorder(t->right);
}

public:
// 显示树的内容
void printTree() const {
inorder(root);
cout << endl;
}
};

int main() {
AVLTree tree;
tree.insert(30);
tree.insert(20);
tree.insert(10);
tree.insert(15);
tree.insert(25);
tree.insert(40);
tree.insert(50);

tree.printTree(); // 输出树的中序遍历结果

return 0;
}

AVL树举例:一看就会(Bushi)

每个圆圈上面的数字是高度。

image-20241119190543251
image-20241119190606045
image-20241119190807513
image-20241119190950875

AVL树的优缺点

AVL树的优点

  1. 搜索操作时间复杂度为 O(log N)
    由于AVL树始终保持平衡,因此无论是查找、插入还是删除操作,树的高度都保持在对数级别(log N)。因此,AVL树的查找操作时间复杂度为 O(log N),保证了高效性。

  2. 插入和删除操作时间复杂度为 O(log N)
    插入和删除操作虽然需要通过旋转来保持树的平衡,但由于AVL树是自平衡的,插入和删除操作的时间复杂度也为 O(log N),确保了对数级别的效率。

  3. 高度平衡增加的开销不大
    AVL树在每次插入和删除时,会调整树的高度平衡。虽然这增加了一些操作的时间,但由于平衡因子的存储并不会占用太多空间,且平衡调整操作的开销通常是常数级的,因此对性能的影响相对较小。

AVL树的缺点

  1. 编程和调试难度较大
    实现AVL树时,需要处理复杂的旋转和双旋操作,保持树的平衡。这使得AVL树的实现相对复杂,容易出错,调试起来也比较困难。

  2. 额外的空间开销
    为了存储每个节点的平衡因子(即每个节点的左右子树高度差),AVL树比普通的二叉搜索树(BST)需要额外的空间。虽然平衡因子通常只占用一个整数大小,但在节点数量非常大的情况下,这也会增加存储开销。

  3. 旋转操作可能影响性能
    每次插入或删除时,AVL树可能需要进行旋转操作来恢复平衡。虽然每次旋转的时间复杂度为 O(1),但当多次操作连续发生时,频繁的旋转操作会增加总的操作时间,影响整体性能。

  4. 对于磁盘上的大量数据,B树更为合适
    在大型数据库系统中,尤其是需要处理磁盘上的数据时,AVL树的平衡方式可能并不是最优的。B树(或B+树)通常是更合适的选择,因为B树能够减少磁盘访问次数,并在树的每个节点中存储多个元素,减少树的高度。

  5. 某些情况下,O(N)的操作可能是可接受的
    对于一些应用,特别是像Splay树那样的自调整树,如果执行许多操作时能保证整体时间较快,即使某些单个操作达到 O(N) 的时间复杂度,仍然是可以接受的。Splay树通过频繁调整访问路径,尽管某一操作可能很慢,但总体性能较为优秀。