- 本人的LeetCode账号:Router,欢迎关注获取每日一题题解,快来一起刷题呀~
- 本人Gitee账号:路由器,欢迎关注获取博客内容源码。
一、关联式容器
STL
容器中,底层为线性序列的数据结构的容器vector list deque forward_list
,这些容器被称为序列式容器;
关联式容器也是来存储数据的,插入元素时,数据是必须往固定的位置插入的,数据之间有着强烈的关联关系,因此数据的检索效率会更高。
二、key模型有序序列set
原型:
第二个参数是仿函数,用来给内部进行比较。
1 简单使用
所有的容器都可以用范围for或迭代器来遍历,其中范围for的底层的原理就是迭代器。
int main()
{
set<int> myset;
myset.insert(1);
myset.insert(3);
myset.insert(-5);
myset.insert(9);
myset.insert(-10);
auto it = myset.begin();
while (it != myset.end())
{
cout << *it << ' ';
++it;
}
return 0;
}
2 迭代器
显然支持正向和反向迭代器的set
的迭代器是一个双向迭代器。
3 修改容器
它的删除支持给一个迭代器位置删除、给一个值删除、或者给一个迭代器区间删除:
删除一个迭代器位置和删除一个值的区别:如果按迭代器位置删除,给的是一个无效迭代器,很可能会报错。
int main()
{
set<int> myset;
myset.insert(1);
myset.insert(3);
myset.insert(-5);
myset.insert(9);
myset.insert(-10);
auto it = myset.begin();
while (it != myset.end())
{
cout << *it << ' ';
++it;
}
cout << endl;
it = myset.find(10);
myset.erase(it);
for (auto e : myset)
{
cout << e << ' ';
}
return 0;
}
而用值删除,如果目标元素不存在,我们直接删,它不会给我报错,会通过返回值告诉我被删除的数据的个数:
对于set
来说,返回1表示这个元素存在且被删除,返回0表示这个元素不存在。
交换:
4 lower_bound和upper_bound
set维护的是一个有序集合,其lower_bound(val)
返回的是第一个不在val
之前的元素的迭代器。(即如果set
中有val
,它可以是指向val
的迭代器)。
upper_bound(val)
返回的是第一个在val
之后的元素的迭代器,如果set
中有val
,它会指向它之后的一个元素。
二、multiset
为了解决排序而不去重的需求,multiset
容器应运而生。
它的接口和set
的接口都类似,区别在哪呢?
它可以直接排序,并且元素重复时依然允许我们插入。
1 multiset的find
既然multiset
中可能有重复元素,那么我们find(重复元素)
返回的迭代器到底是指向谁的?
可见它找的是中序的第一个结点,返回的是中序的第一个值所在的结点的迭代器。
2 multiset的erase
按迭代器删除只删除迭代器指向的节点,按值删除可以在multiset
中删除所有值为val
的结点。
这里如果一个一个删除,那么也可能出现迭代器失效的问题,而C++98
中没有用返回值来防止迭代器失效,C++11
给了我们用返回值来防止这种迭代器失效的场景:
如果不能用C++11,你可以提前储存下一个位置的迭代器来规避迭代器失效。
不过一次性删除multiset
中所有值为val
的元素,我们可以按值删除。
set
没有像其他容器一样给我们提供专门的修改接口,就算通过迭代器也不能修改,*it
在set
中是常引用,无法修改。
因为擅自修改一个值,很可能直接破坏了搜索树的结构,有的结构破坏了以后,没法通过旋转等操作弄回来。
三、key-value模型有序序列map
每个结点除了存一个Key
,还存了一个T
,它是使用pair<const Key, T>
键值对实现的。
1 map的简单使用
map
的大部分操作和set
都相同,除了这个operator[]
:
它的insert
:
它的value_type
是pair
去typedef
出来的。
它的遍历不能直接把pair
输出出来,因为pair
没有去重载operator<<
,
void test_map1()
{
map<string, string> dict;
// 第一种插入方式
pair<string, string> p1("sort", "排序");
dict.insert(p1);
// 第二种插入方式 匿名对象
dict.insert(pair<string, string>("unique", "不同的"));
// 还可使用make_pair 优势在于自动推导类型
dict.insert(make_pair("value", "值"));
// 还可以用{}
dict.insert({ "type", "类型" });
map<string, string>::iterator it = dict.begin();
while (it != dict.end())
{
cout << '(' << (*it).first << ' ' << (*it).second << ')';
// 还是operator->看起来正常
cout << '(' << it->first << ' ' << it->second << ')';
++it;
}
cout << endl;
// 范围for
for (auto& p : dict)
{
cout << '(' << p.first << ',' << p.second << ')' << endl;
}
}
这里排序是按key
进行排序的。
erase和find和set的类似,但是是只针对key
做查找和删除。
2 map的operator[]
map的key不支持修改,但是其value支持修改。
如果我们朴素的去统计一个字典中单词出现的次数:
void test_map2()
{
string arr[] = { "苹果", "香蕉", "菠萝", "香蕉", "苹果", "梨", "苹果" };
map<string, int> cnt;
for (auto& s : arr)
{
auto it = cnt.find(s);
if (it == cnt.end()) cnt.insert(make_pair(s, 1));
else
{
++(*it).second;
}
}
}
这里还可以优化一下,cnt.find
查找一次,cnt.insert
再查找一次。
我们先看这个insert的返回值:
如果key已经存在了,返回值的第二个成员为true,第一个成员就是新位置的迭代器,如果已经存在了,那么第二个成员为false,第一个成员为就是已经存在的位置的迭代器。
所以不用find
了,直接用insert
的返回值来搞就行了
void test_map2()
{
string arr[] = { "苹果", "香蕉", "菠萝", "香蕉", "苹果", "梨", "苹果" };
map<string, int> cnt;
for (auto& s : arr)
{
// 如果是key第一次出现 直接就插入进去了 pb.second == true
auto pb = cnt.insert(make_pair(s, 1));
// 如果插入失败 说明key不是第一次出现 这时
// 返回了一个迭代器指向已经出现的节点
// 然后利用这个迭代器++其second(次数)
if (pb.second == false)
{
pb.first->second++;
}
}
for (auto& kv : cnt)
{
cout << kv.first << ':' << kv.second << endl;
}
}
但是其实更常用的做法是使用operator[]
void test_map2()
{
string arr[] = { "苹果", "香蕉", "菠萝", "香蕉", "苹果", "梨", "苹果" };
map<string, int> cnt;
for (auto& s : arr)
{
++cnt[s];
}
for (auto& kv : cnt)
{
cout << kv.first << ':' << kv.second << endl;
}
}
以前的operator[]
是数组行为,但这里operator[]
是以关键字来返回value的引用,如果不存在则插入:
// 这是operator[]的简略等价版本
(*((this->insert(make_pair(k,mapped_type()))).first)).second
含义就是this
调用insert
函数,插入make_pair(k,mapped_type())
,然后得到其返回值pair<iteraot, bool>
的first
,即那个key位置的迭代器,然后解引用这个迭代器去访问其second
,访问到value
.
所以这个operator[]
同时做到了插入、查找、修改value的功能,如果[key]
第一次出现,那么它会做到插入并且返回刚初始化的value的引用,如果[key]
不是第一次出现,通过查找返回value
的引用,然后可以通过其他运算修改这个值。
operator
的三种作用:
dict["string"];// 单纯插入
dict["sort"] = "排序"; // 插入加排序
dict["sort"] = "不想玩了"; // 修改
四、multimap
它的用法和map的用法几乎一致,和multiset
一样,如果key
存在它仍然会继续插入。
因此它就没有operator[]
,因为key
相同的可能有很多节点,我到底要返回哪一个呢?
void test_map3()
{
multimap<string, string> mdict;
mdict.insert(make_pair("排序", "sort"));
mdict.insert(make_pair("排序", "mysort"));
for (auto& e : mdict)
{
cout << e.first << ' ' << e.second << endl;
}
}
它支持count
,会返回关键字等于key
的节点的个数。
erase
和multiset
也是一样,传迭代器就删除一个结点,传键值就删除所有键值为key的结点。
对于topk问题,数据量不大时,我们可以用map<string, int>
计数,然后vector<map<string, int>::iterator>
加函数对象排序;
也可以用multimap<int, string>
把<次数, 关键字>
存进来并排序,降序的话搞个greater<int>
进去就行。
五、平衡二叉树—AVL树
前面对map/multimap/set/multiset
进行了简单的介绍,在其文档介绍中发现,这几个容器有个共同点是: 其底层都是按照二叉搜索树来实现的,但是二叉搜索树有其自身的缺陷,假如往树中插入的元素有序或者接 近有序,二叉搜索树就会退化成单支树,时间复杂度会退化成O(N),因此map、set等关联式容器的底层结构 是对二叉树进行了平衡处理,即采用平衡树来实现。
AVL树又称高度平衡二叉搜索树,以高度来控制二叉搜索树的平衡。
二叉搜索树虽可以缩短查找的效率,但如果数据有序或接近有序二叉搜索树将退化为单支树,查找元素相当 于在顺序表中搜索元素,效率低下。因此,两位俄罗斯的数学家G.M.Adelson-Velskii和E.M.Landis在1962年 发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之 差的绝对值不超过1(需要对树中的结点进行调整),即可降低树的高度,从而减少平均搜索长度。
AVL树有很多实现方法,其中一种比较经典的实现方式就是去使用平衡因子(右子树高度减左子树高度),这种情况下,AVL树要求:
- 它的左右子树都是AVL树
- 左右子树高度之差(简称平衡因子)的绝对值不超过1(-1/0/1)
如果一棵二叉搜索树是高度平衡的,它就是AVL树。如果它有n个结点,其高度可保持在 ,搜索时间复杂度O(logN)
,效率就非常高,如果往AVL树中插入10亿个结点,它最多查找30次。
1 基础结构
template<class K, class V>
struct AVLTreeNode
{
AVLTreeNode<K, V>* _left;
AVLTreeNode<K, V>* _right;
AVLTreeNode<K, V>* _parent;// 三叉链
pair<K, V> _kv;
int _bf;// 平衡因子
// 不加平衡因子需要用递归实现 不好理解
// 新增结点平衡因子为0
AVLTreeNode(const pair<K, V>& kv = pair<K, V>())
: _kv(kv), _left(nullptr), _right(nullptr), _parent(nullptr), _bf(0)
{}
};
template<class K, class V>
class AVLTree
{
typedef AVLTreeNode<K, V> Node;
public:
AVLTree() : _root(nullptr) {}
private:
Node* _root;
};
2 插入
插入时,要更新祖先的平衡因子(更新到根也会结束),更新逻辑:
cur == parent->left
,parent->bf--
。cur == parent->right
,parent->bf++
。- 如果更新后
parent->bf == 0
,说明更新前parent
的bf是1或-1,现在变成0,说明填上了矮的那边,parent
所在的子树高度不变,自然更新结束。 - 更新以后,
parent->bf == 1 / -1
,当前parent
所在子树是平衡的,但是要继续往上更新新插入结点的影响,说明更新前parent
的bf
是0,新插入的节点使得高度变高了,需要继续往上更新。 - 更新以后,
parent->bf == 2 / -2
,则说明parent
所在的子树已经不平衡了,需要旋转处理
我们提供一下插入的总框架:
// 插入
bool Insert(const pair<K, V> &kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
return true;
}
Node *parent = nullptr;
Node *cur = _root;
while (cur)
{
if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else
return false;
}
// 插入一个结点 只会影响它的祖先的平衡因子
cur = new Node(kv);
cur->_parent = parent;
if (parent->_kv.first > cur->_kv.first)
{
parent->_left = cur;
}
else
{
parent->_right = cur;
}
// 控制平衡
// 1、更新平衡因子
// 2、出现异常的平衡因子(2, -2),那么需要“旋转平衡树"
while (parent)
{
if (parent->_left == cur)
{
--parent->_bf;
}
else
{
++parent->_bf;
}
if (parent->_bf == 0)
{
break; // 平衡因子变成0的情况 直接停止更新
}
else if (abs(parent->_bf) == 1)
{
// 平衡因子没有破坏平衡
// 继续往上更新
cur = parent;
parent = parent->_parent;
}
else if (abs(parent->_bf) == 2)
{
// 旋转处理
if (parent->_bf == -2 && cur->_bf == -1)
{
// 这种情况就是右单旋转
RotateR(parent);
}
// 左单选
else if (parent->_bf == 2 && cur->_bf == 1)
{
RotateL(parent);
}
// 左右双旋
else if (parent->_bf == -2 && cur->_bf == 1)
{
RotateLR(parent);
}
// 右左双旋
else if (parent->_bf == 2 && cur->_bf == -1)
{
RotateRL(parent);
}
break;
}
else
{
// 说明插入平衡因子之前这个位置就出问题了
// assert断掉
assert(false);
}
}
return true;
}
3 旋转的情况
I 右单旋(左边高)
注意这个过程要控制三叉链,结合下面的图,我们写出代码:
// 右单旋
void RotateR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
Node* parentParent = parent->_parent;
subL->_right = parent;
parent->_parent = subL;
parent->_left = subLR;
if (subLR)
subLR->_parent = parent;
if (parent == —_root)
{
_root = subL;
subL->_parent = nullptr;
}
else
{
if (parentParent->_left == parent) parentParent->_left = subL;
else parentParent->_right = subL;
subL->_parent = parentParent;
}
subL->_bf = parent->_bf = 0;
}
II 左单旋(右边高)
同理,这里就是反过来了,parent的平衡因子是2,cur的平衡因子是1,只要画出图来,注意维护三叉链,很快就能写出来:
// 左单旋 平衡因子为parent == 2 cur == 1
// 转后平衡因子为0,0
void RotateL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
Node* parentParent = parent->_parent;
parent->_parent = subR;
subR->_left = parent;
parent->_right = subRL;
if (subRL) subRL->_parent = parent;
if (parent == _root)
{
_root = subR;
subR->_parent = nullptr;
}
else
{
if (parentParent->_left == parent)
{
parentParent->_left = subR;
}
else parentParent->_right = subR;
subR->_parent = parentParent;
}
subR->_bf = parent->_bf = 0;
}
III 左右双旋和右左双旋
// 左右双旋
void RotateLR(Node *parent)
{
// 先选parent的左孩子 进行左旋
RotateL(parent->_left);
// 再把parent进行右旋
RotateR(parent);
// 平衡因子更新:
}
// 右左双旋
void RotateRL(Node *parent)
{
// 先旋parent的右孩子 进行右旋
RotateR(parent->_right);
// 再把parent进行左旋
RotateL(parent);
// 平衡因子更新
}
如何在左右双旋的情况下更新平衡因子我们稍后讨论。
4 验证平衡
手动验证未免过于麻烦,我们遍历每个点,如果每个点的左右子树的高度差小于一即说明平衡,否则说明不平衡。
bool _isBalance(Node *root)
{
if (root == nullptr)
return true;
int leftheight = _height(root->_left);
int rightheight = _height(root->_right);
// 检查平衡因子是否异常
if (rightheight - leftheight != root->_bf)
{
cout << root->_kv.first << "现在是" << root->_bf << endl;
cout << root->_kv.first << "应该是" << rightheight - leftheight << endl;
return false;
}
return abs(leftheight - rightheight) < 2 && _isBalance(root->_left) && _isBalance(root->_right);
}
int _height(Node *root)
{
if (root == nullptr)
return 0;
int leftheight = _height(root->_left);
int rightheight = _height(root->_right);
return 1 + max(leftheight, rightheight);
}
发现这组测试用例:{4, 2, 6, 1, 3, 5, 15, 7, 16, 14}
通过不了验证:
void TestAVLTree1()
{
AVLTree<int, int> t;
//int a[] = { 5, 4, 3, 2, 1 };
// int a[] = { 16, 3, 7, 11, 9, 26, 18, 14, 15 };
// int a[] = { 1, 2, 3, 4, 5 };
int a[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14 };
for (auto& e : a)
{
t.Insert(make_pair(e, e));
if (!t.isBalance())
{
cout << "插入" << e << "出问题了" << endl;
}
}
t.Inorder();
if (!t.isBalance()) cout << "该树异常" << endl;
}
它的问题在于我们右左双旋时,平衡因子都是按照单旋时的平衡因子更新,没有做对应的平衡因子更新
5 右左双旋平衡因子更新
触发右左双旋的三个场景如下,并且它们引发的不同平衡因子更新变化结果如下:
方法是去识别60的平衡因子,插入结点后,旋转前,第一种情况“60”的平衡因子是1,第二种情况“60”的平衡因子是-1,第三种情况60的平衡因子是0.
// 左右双旋
void RotateLR(Node *parent)
{
Node *subL = parent->_left;
Node *subLR = subL->_right;
int checkbf = subLR->_bf;
// 先选parent的左孩子 进行左旋
RotateL(parent->_left);
// 再把parent进行右旋
RotateR(parent);
// 平衡因子更新:
if (checkbf == -1)
{
// 图1
parent->_bf = 1;
subLR->_bf = subL->_bf = 0;
}
else if (checkbf == 1)
{
// 图2
subL->_bf = -1;
subLR->_bf = parent->_bf = 0;
}
else
{
// 图3
subL->_bf = subLR->_bf = parent->_bf = 0;
}
}
6 左右双旋平衡因子更新
仿照右左双旋平衡因子更新的方法,画出图来:
// 左右双旋
void RotateLR(Node *parent)
{
Node *subL = parent->_left;
Node *subLR = subL->_right;
int checkbf = subLR->_bf;
// 先选parent的左孩子 进行左旋
RotateL(parent->_left);
// 再把parent进行右旋
RotateR(parent);
// 平衡因子更新:
if (checkbf == -1)
{
// 图1
parent->_bf = 1;
subLR->_bf = subL->_bf = 0;
}
else if (checkbf == 1)
{
// 图2
subL->_bf = -1;
subLR->_bf = parent->_bf = 0;
}
else
{
// 图3
subL->_bf = subLR->_bf = parent->_bf = 0;
}
}
7 通过验证
8 AVL树的删除
因为AVL树也是二叉搜索树,可按照二叉搜索树的方式将节点删除,然后再更新平衡因子,只不错与删除不 同的时,删除节点后的平衡因子更新,最差情况下一直要调整到根节点的位置。
因为比较复杂,这里不做讲解,详细可以参考《算法导论》或者《数据结构-用面向对象方法与C++描述》殷人昆版。
9 AVL树的性能
AVL树是一棵绝对平衡的二叉搜索树,其要求每个节点的左右子树高度差的绝对值都不超过1,这样可以保证查询时高效的时间复杂度,即logN
。但是如果要对AVL树做一些结构修改的操作,性能非常低下,比如: 插入时要维护其绝对平衡,旋转的次数比较多,更差的是在删除时,有可能一直要让旋转持续到根的位置。 因此:如果需要一种查询高效且有序的数据结构,而且数据的个数为静态的(即不会改变),可以考虑AVL树, 但一个结构经常修改,就不太适合。
我们来测试一下AVL树插入1e9个结点的速度和它的高度。
可以看到AVL树的插入的效率还是很不错的。