数据结构-红黑树和set

对于二叉搜索树、平衡二叉树以及红黑树,只需了解背后的原理,不做代码实现的要求。 重要清楚各种操作的时间复杂度,为使用set与map铺垫。

二叉搜索树

基本概念

二叉搜索树(也称二叉排序树,简称BST)是一颗空树,或者是具有以下特性的二叉树;

• 若左子树非空,则左子树上所有结点的值均小于根结点的值。

• 若右子树非空,则右子树上所有结点的值均大于根结点的值。

• 左、右子树也分别是一颗二叉搜索树。

也就是左子树结点值<根结点值<右子树结点值。

所以中序便历一定是从小到大的

相较于堆,二叉搜索树是大小关系更为严格的数据结构。但是并不需要必须是一棵完全二叉树,也就是树的形态是任意的。

根据二叉树的定义,左子树结点值<根结点值<右子树结点值,所以对二叉搜索树进行中序遍历,可 以得到一个递增的有序序列。 构造一颗二叉搜索树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。

查找操作

二叉搜索树的查找是从根结点开始,沿某个分支逐层向下比较的过程,若二叉搜索树非空,先将给定值与根结点的关键字比较,若相等,则查找成功;若不等,如果小于根结点的关键字,则在根结点的 左子树上查找,否则在根结点的右子树上查找。而这也是一个递归的过程。

时间复杂度:

最坏情况下会从根节点开始,查找到叶子结点。因此时间复杂度是和树的高度有关的,而树高最差会变成一条单链表,因此时间复杂度为 O( N )

插入操作

二叉搜索树作为一种动态树表,其特点是树的结构通常不是一次生成的,而是在查找的过程的过程 中,当树中不存在关键字值等于给定值的结点时再进行插入的。插入的结点一定是一个新添加的叶结点,且是查找失败时的查找路径上访问的最后一个结点的左孩子或右孩子。 若原二叉树为空树,则直接插入结点;否则根据二叉搜索树的特性,将插入的关键字对比,若关键字 key 小于根结点值,则插入到左子树,若关键字 key 大于根结点值,则插入到右子树

时间复杂度:

插入与查找的过程一致,因此时间复杂度为: O( N )

构造BST树

二叉搜索树的构造就是不断向原来的树中插入新的结点即可

虽然结点的值都相同,不同的构造顺序会有产生不同的二叉搜索树,会影响查找和插入的效率。并且,构造序列越有序,二叉搜索树的查找效率越低。

删除操作

对于二叉搜索树的删除,就不是那么容易了,我们不能因为删除了结点,而让这棵树变得不满⾜二叉搜索树的特性,所以删除需要考虑多种情况。

1. 若被删除结点是叶子结点,则直接删除

2. 若被删除结点 只有一颗左子树或右子树,则让 x 的子树成为 x 父结点的子树,替代 x 的位 置。

3. 若结点 x 既有左子树,又有右子树。两种策略:

a. 令 x 的直接后继替代 ,然后从二叉搜索树中删去这个直接后继;

b. 令 x 的直接前驱替代 ,然后从二叉搜索树中删去这个直接前驱。

时间复杂度

查 找前驱和后继的操作,最差也会遍历整个二叉树,因此时间复杂度为:O( N )

平衡二叉树

在上一节中提到,在某些特定的情况下,二叉搜索树是会退化成单链表的,并且各种操作的效率也 会明显的下降,因此我们需要一些特别手段保证这个二叉搜索树的"平衡",进而保证各种操作的 效率。这就是我们接下来要学习的平衡二叉树

基本概念

为了保证二叉搜索树的性能,规定在插入和删除结点时,要保证任意结点的左、子树的高度差的绝对值不超过 1 ,这样的二叉树称为平衡二叉树(简称AVL树)。且满足BST 特性:子树的数值比结点大

其中结点左子树与右子树的高度差定义为该结点的平衡因子(一般是左子树的高度减去右子树的高度。当然,反过来也是可以的)。由此可见,平衡二叉树中,每一个结点的平衡因子只可能是 -1、0 或 1。

左单旋

如果只是单树不稳定,直接左旋就可以了

如果是复杂的树:

1.该结点成为右孩子的左子树

2.右孩子原本的左子树成为该结点的右子树

右单旋

与左旋相差不大

1.该结点成为左孩子的右子树

2.左孩子的右子树成为该结点的左子树

插入操作

在二叉搜索树中插入新结点之后,插入路径的点中,可能存在很多平衡因子的绝对值大于 的,此时找到距离插入结点最近的不平衡的点,以这个点为根的入树就是最小不平衡子树。

可以发现,仅需让最小不平衡子树平衡,所有结点就都平衡了。

至于为什么调整这一棵子树就可以让所有结点平衡?我们可以感性理解一下:

• 本来整棵树就是平衡二叉树,如果来了一个结点导致失衡,那么失衡结点的平衡因子只能是 2 或者 -2 ;

• 当我们把最小平衡子树调整平衡之后,那么这棵子树的高度就会减 ,向上传递的过程中,会让 整个路径里面的平衡因子都向 0 靠近一位,原来的 2 会变成 1,原来的 -2 会变成 -1 ,整棵树就变得平衡了。

最小不平衡子树的出现可以细分成 4 种情况,因此调整策略也会分 4 种情况讨论。为了方便叙述,以下给的树都是最小不平衡子树 设最小不平衡子树的根节点为T。

LL 型-右单旋

LL 表示:新结点由于插入在T结点的左孩子(L)的左子树(LL)中,从而导致失衡。

此时需要将L右旋:

• 将结点L向右上旋转代替结点T作为根结点;

• 将节点T向右下旋转作为结点L的右子树的根结点;

• 结点L的原右子树(LR)则作为结点T的左子树。

旋转之后,依旧满足平衡二叉树的特性:LL < L < LR < T < R

RR型-左单旋

RR表示:新结点由于插入在T结点的右孩子(R)的右子树(RR)中,从而导致失衡。

此时需要一次向左的旋转操作,将R左旋:

• 将结点R向左上旋转代替结点T作为根结点;

• 将节点T向左下旋转作为结点R的左子树的根结点;

• 结点R的原左子树(RL)则作为结点T的右子树

LR型-左右双旋

LR表示:新结点由于插入在T结点的左孩子(L)的右子树(LR)中,从而导致失衡

此时需要两次旋转操作,先将LR左旋,再将LR右旋。 将LR左旋:

• 将结点LR向左上旋转代替结点L作为根结点;

• 将节点L向左下旋转作为结点LR的左子树的根结点; 将LR右旋:

• 结点LR的原左子树(LRL)则作为结点L的右子树。

• 将结点LR向右上旋转代替结点T作为根结点;

• 将节点T向右下旋转作为结点LR的右子树的根结点;

• 结点LR的原右子树(LRR)则作为结点T的左子树。

RL型-右左双旋、

RL表示:新结点由于插入在T结点的右孩子(R)的左子树(RL)中,从而导致失衡。

此时需要两次旋转操作,先将RL右旋,再将RL左旋。

将RL右旋:

• 将结点RL向右上旋转代替结点R作为根结点;

• 将节点R向右下旋转作为结点RL的右子树的根结点;

• 结点RL的原右子树(RLR)则作为结点R的右子树。

将RL左旋:

• 将结点RL向左上旋转代替结点T作为根结点;

• 将节点T向左下旋转作为结点RL的左子树的根结点;

• 结点RL的原左子树(RLL)则作为结点T的右子树

插入操作的时间复杂度:

旋转操作仅需修改指针,因此最大的时间开销就是先把结点插入到空结点的位置,时间复杂度和查找 一致,因此为 O(logN)

删除操作

与插入操作的思想类似,都是先按照二叉搜索树的形式操作,然后想办法使其平衡。具体步骤

  1. 用二叉搜索树的方法对结点w执行删除操作。 与插入操作的思想类似,都是先按照二叉搜索树的形式操作,然后想办法使其平衡。

  2. 从结点w开始,向上找到第一个不平衡的结点T(即最小不平衡子树);X为结点T的高度最高的孩 子结点,Y是结点X的高度最高的孩子结点。

  3. 然后对以Y为根的子树进行平衡调整,调整方法就根据Y的位置情况。其中T、X和Y可能的位置

有4种情况:

◦ X是T的左孩子,Y是X的左孩子(LL,X右单旋转)

◦ X是T的左孩子,Y是X的右孩子(LR,Y先左后右双旋转)

◦ X是T的右孩子,Y是X的右孩子(RR,X左单旋转)

◦ X是T的右孩子,Y是X的左孩子(RL,Y先右后左双旋转)

调整之后,继续向上找下一个不平衡的子树,然后重复3操作,直到找不到为止

也就是说,删除操作可能导致从根节点到删除结点的很多结点都失衡,一次调整是不能保证全部结点都平衡的。需要不断向上寻找,遇到一个不平衡的就去调整,一直到根节点为止

红黑树

基本概念

红黑树(简称RBT),也是一棵二叉搜索树。它是在搜索树的基础上,使每个结点上增加一个存储位表示结点的颜色,可以是Red或者Black,通过对任意一条从根到叶子的路径上各个结点着色方式的限制,确保没有一条路径会比其他路径长出2倍,因此这是一棵接近平衡的二叉搜索树。 红黑树相对于AVL树来说,牺牲了部分平衡性以换取插入/删除操作时少量的旋转操作,整体来说性能要优于AVL树。

红黑树的规则

在一棵红黑树中,需要满足下面几条规则,在每次插入和删除之后,都应该让红黑树满足下面的规 则:

  1. 每个结点要么是红色要么是黑色;

  2. 根节点和叶子结点(这里的叶子结点不是常规意义上的叶子结点,而是空结点;

  3. 如果一个结点是红色的,则它的两个孩⼦结点必须是黑色的,也就是说任意一条路径不会有连续的红色结点;

  4. 对于任意一个结点,从该结点到其所有叶子的路径上,均包含相同数量的黑色结点

前提:必须是二叉搜索树

红黑树的性质

根据红黑树的规则,我们可以得出红黑树的两个重要性质:

  1. 从根结点到叶结点的最长路径不大于最短路径的2倍

比较容易证明,这里就不用严谨的数学方式了,直接看一个具体的例子感受感受

  1. 有 n 个结点的红黑树,高度 h ≤2log (n+ 1) ,也就是说查找时间复杂度为 O(logN)

红黑树的查找

与二叉搜索树的查找一样:从根结点开始,沿某个分支逐层向下比较的过程,若非空,先将给定值与 根结点的关键字比较,若相等,则查找成功;若不等,如果小于根结点的关键字,则在根结点的左子树上查找,否则在根结点的右子树上查找。

时间复杂度:

由于平衡二叉树会限制树的高度不会过高,趋近于 log n 级别,因此时间复杂度为 O(logN) 。

红黑树的插入

第一步,也是先按照二叉搜索树的插入方式插入新的结点。接下来思考一个小问题:新插⼊的结点染 成红色还是黑的好呢?

明显是红色较好。

如果染成黑色,一定会让这条路径上的黑色结点数量增多,那么就需要调整所有从根节点到叶子结点的路径,使其重新符合红黑树的特性。不仅每次插入都要调整,而且调整的规模还非常庞大。

但是,如何染成红色,有可能就不需要调整。如果需要调整,也只会破坏根节点不能为红,以及不能 出现连续的红色结点这两个规则。

因此红黑树的插入过程大致为

  1. 按照二叉搜索树的插入方式插入新的结点;

  2. 默认该点是红色,如果破坏了红黑树的规则,然后就分情况讨论

接下来,就详细讨论插入新结点之后会遇到的所有情况,以及每种情况需要如何调整。 为了后续叙述方便,标记新插入的结点为c(cur),父结点为p(parent),亲的⽗结点为 g(grandfather),⽗结点的兄弟为u(uncle)

情况一:插入的是根节点

这是第一次插入结点,直接将结点的颜色变成黑色即可。

情况二:叔叔是红色

这种情况下,不需要旋转,只需要不断变色即可。具体的策略是:

• 父亲、叔叔和爷爷同时变色,然后将爷爷看做新插入的结点,继续向上判断。

情况三:叔叔是黑色

这种情况需要继续分类讨论,根据祖父、父亲、新结点三者的位置,分情况旋转+变色。这一块的旋转操作和平衡二叉树的旋转一样,无非就是多了一个变色,因此不需要有太大的心理负担

LL 型-右单旋+父爷变色

如果父亲和新结点的位置关系相对于爷爷呈现:新结点是爷爷的左孩子的左孩子,仅需两步

右旋父亲结点;

然后将父亲和爷爷变色

RR 型-左单旋+父爷变色

如果父亲和新结点的位置关系相对于爷爷呈现:新结点是爷爷的右孩子的右孩子,仅需两步

• 左旋父亲结点;

• 然后将父亲和爷爷变色

LR 型-左右双旋+儿爷变色

如果父亲和新结点的位置关系相对于爷爷呈现:新结点是爷爷的左孩⼦的右孩子,仅需两步:

• 新结点先左旋,再右旋;

• 然后将新结点和爷爷结点变色

RL 型-右左双旋+儿爷变色

如果父亲和新结点的位置关系相对于爷爷呈现:新结点是爷爷的右孩子的左孩子,仅需两步:

• 新结点先右旋,再左旋;

• 然后将新结点和爷爷结点变色

红黑树的构造

红黑树的构造,就是不断向红黑树中插入新的结点

set / multiset--存储单关键字

set 与 multiset 的区别:set 不能存相同元素, multiset 可以存相同的元素,其余的使⽤⽅式完全⼀致。因此,我们有时候可以用 set 帮助我们给数据去重。

在这里只练习使用 set ,因为set会用了, multiset 也会用

创建set

端口

size / empty:

size: 、返回 set 中实际元素的个数。时间复杂度 O( 1 )

empty:判断 set 是否为空。时间复杂度:O( 1 )。

begin/end

迭代器,可以使用范围for遍历整个红黑树。

遍历是按照中序遍历的顺序,因此是一个有序的序列。

insert

向红黑树中插入一个元素

时间复杂度:O(logN)

cpp 复制代码
#include <iostream>

#include <set>

using namespace std;

int a[] ={10, 60, 20, 70, 80, 30, 90, 40, 100, 50};

int main()
{
	set<int> mp;
	
	//插入
	for(auto x : a)
	{
		mp.insert(x);
	 } 
	 
	 //便历 set, 最终的结果应该是有序的
	for(auto x : mp)
	{
		cout << x << " ";
	}
	cout << endl;
	
	
	
	return 0;
}


10 20 30 40 50 60 70 80 90 100
erase

删除一个元素

时间复杂度:O(logN)

find / count

find :查找一个元素,返回的是迭代器。时间复杂度:O(logN)。

c ount查询元素出现的次数,一般用来判断元素是否在红黑树中。

时间复杂度:O(logN)。

如果想查找元素是否在set中,我们一般不使用find,而是用count。因为find的返回值是一个迭 代器,判断起来不方便。

cpp 复制代码
#include <iostream>

#include <set>

using namespace std;

int a[] ={10, 60, 20, 70, 80, 30, 90, 40, 100, 50};

int main()
{
	set<int> mp;
	
	//插入
	for(auto x : a)
	{
		mp.insert(x);
	 } 
	 
	 //便历 set, 最终的结果应该是有序的
	for(auto x : mp)
	{
		cout << x << " ";
	}
	cout << endl;
	
	if(mp.count(1))  cout << "1" << endl;
	if(mp.count(99)) cout << "99" << endl;
	if(mp.count(30)) cout << "30" << endl;
	if(mp.count(10)) cout << "10" << endl;
	
	mp.erase(30);
	mp.erase(10);
	
	if(mp.count(30)) cout << "30" << endl;
	else cout << "no:30" << endl;
	if(mp.count(10)) cout << "10" << endl;
	else cout << "no:10" << endl;
	

	
	
	return 0;
}
lower_bound/upper_bound

lower_bound:大于等于x的最小元素,返回的是迭代器;

时间复杂度:O(logN)。--查找的数比较小,可以是 =x 的数

upper_bound: 大于x的最小元素,返回的是迭代器

时间复杂度:O(logN)。---查找的数比较大

cpp 复制代码
#include <iostream>

#include <set>

using namespace std;

int a[] ={10, 60, 20, 70, 80, 30, 90, 40, 100, 50};

int main()
{
	set<int> mp;
	
	//插入
	for(auto x : a)
	{
		mp.insert(x);
	 } 
	 
	 //便历 set, 最终的结果应该是有序的
	for(auto x : mp)
	{
		cout << x << " ";
	}
	cout << endl;
	
	if(mp.count(1))  cout << "1" << endl;
	if(mp.count(99)) cout << "99" << endl;
	if(mp.count(30)) cout << "30" << endl;
	if(mp.count(10)) cout << "10" << endl;
	
	mp.erase(30);
	mp.erase(10);
	
	if(mp.count(30)) cout << "30" << endl;
	else cout << "no:30" << endl;
	if(mp.count(10)) cout << "10" << endl;
	else cout << "no:10" << endl;
	
	auto x = mp.lower_bound(20);
	auto y = mp.upper_bound(20);
	
	cout<< *x << " " << *y << endl;
	
	
	return 0;
}

10 20 30 40 50 60 70 80 90 100
30
10
no:30
no:10
20 40

map/multimap

map 和 multimap 的区别 :: map 不能存相同元素,multimap 可以存相同的元素,其余的使用方法完全一致吗,因此,这⾥只练习使用 map 。

map 与 set 的区别:set 里面存的是一个单独的关键字,也就是存一个int ,char,double 或者 string 。而 map 里面存的是一个 pair<key, value>, (k-v模型)不仅有一个关键字,还会有一个与关键字绑定的值,比较方式是按照 key 的值来比较。

比如,我们可以在 map 中:

• 存<int, int> ,来统计数字出现的次数

• 存 <string, string>,来统计字符串出现的次数;

甚至存 <int , vector< int >> 来表示一个数后面跟了若干个数······,用来存储树

因为模板这个强大的功能,使得 map 有很多用途,后面做题的时候慢慢体会

创建

cpp 复制代码
#include <iostream>
#include <map>

using namespace std;

void print(map<string,int>& mp)
{
	for(auto& p : mp)
	{
		cout << p.first << " " << p.second << endl;
	}
	
}

int main()
{
	、
	map<int, int> mp1;
    map<int, string> mp2;
    map<string, int> mp3;
    map<int, vector<int>> mp4;

	 
	
	
	return 0;
	
}

接口

size / empty:

size: 、返回 set 中实际元素的个数。时间复杂度 O( 1 )

empty:判断 set 是否为空。时间复杂度:O( 1 )。

begin/end

迭代器,可以使用范围for遍历整个红黑树。

遍历是按照中序遍历的顺序,因此是一个有序的序列。

insert

向红黑树中插入一个元素

时间复杂度:O(logN)

erase

删除一个元素

时间复杂度:O(logN)

find / count

find :查找一个元素,返回的是迭代器。时间复杂度:O(logN)。

c ount查询元素出现的次数,一般用来判断元素是否在红黑树中。

时间复杂度:O(logN)。

如果想查找元素是否在set中,我们一般不使用find,而是用count。因为find的返回值是一个迭 代器,判断起来不方便。

cpp 复制代码
#include <iostream>
#include <map>

using namespace std;

void print(map<string,int>& mp)
{
	for(auto& p : mp)
	{
		cout << p.first << " " << p.second << endl;
	}
	
}

int main()
{
	map<string, int> mp;
	
	//插入
	mp.insert({"张三",1});
	mp.insert({"李四",2});
	mp.insert({"王五",3});
	
	//print(mp);
	
	//operator[] 可以让 map 向数组一样使用
	cout << mp["张三"] << endl;
	mp["张三"] * 110;
	cout << mp["张三"] << endl;
	
	//注意事项: operator[] 有可能会向 map 中插入的元素
	//[] 里面的内存如果不存在 map 中,会先插入,然后再拿值
	//插入的时候,第一个关键字就是[] 里面的内容,第二个关键字是一个默认值
	if(mp.count("赵六") && mp["赵六"] == 4) cout << "yes" << endl;
	else cout << "no" << endl;
	
	print(mp);
	 
	
	
	
	return 0;
	
}
1
1
no
李四 2
王五 3
张三 1

lower_bound/upper_bound

lower_bound: 大于等于x的最小元素,返回的是迭代器。时间复杂度:O(logN)

upper_bound: 大于x的最小元素,返回的是迭代器。时间复杂度: O(logN)

cpp 复制代码
#include <iostream>
#include <map>

using namespace std;

void print(map<string,int>& mp)
{
	for(auto& p : mp)
	{
		cout << p.first << " " << p.second << endl;
	}
	
}

//统计一堆字符中,每一个字符串出现的次数
void fun()
{
	string s;
	map<string,int> mp;//字符串,字符串出现的次数 
	
	for(int i = 1; i <= 10; i++)
	{
		cin >> s;
		mp[s]++; //体现operator 的强大 
	}
	
	print(mp);
	
 } 

int main()
{
	fun();

	
	return 0;
	
}
aa
aa
aa
bb
bb
ccc
ccc
bb
aa
ccc

aa 4
bb 3
ccc 3

这一篇就到这里啦~有点小难,后期会单出一篇算法题~

下一篇写 哈希表和undered_set

相关推荐
tobias.b2 小时前
408真题解析-2010-3-数据结构-线索二叉树
数据结构·链表·计算机考研·408真题解析
tobias.b2 小时前
408真题解析-2010-2-数据结构-双端队列
数据结构·计算机考研·408真题解析
宵时待雨2 小时前
数据结构(初阶)笔记归纳7:链表OJ
c语言·开发语言·数据结构·笔记·算法·链表
充值修改昵称2 小时前
数据结构基础:堆高效数据结构全面解析
数据结构·python·算法
2501_901147832 小时前
组合总和IV——动态规划与高性能优化学习笔记
学习·算法·面试·职场和发展·性能优化·动态规划·求职招聘
好奇龙猫2 小时前
【大学院-筆記試験練習:线性代数和数据结构(15)】
数据结构·线性代数
人工智能培训2 小时前
数字孪生技术:工程应用图景与效益评估
人工智能·python·算法·大模型应用工程师·大模型工程师证书
源代码•宸2 小时前
Golang原理剖析(Go语言垃圾回收GC)
经验分享·后端·算法·面试·golang·stw·三色标记
无小道2 小时前
基于epoll的单进程Reactor服务器
运维·服务器·c++·网络编程·reactor·epoll