STL(5)—set和map的使用与AVL树的插入(四种旋转情况)

- 本人的LeetCode账号:Router,欢迎关注获取每日一题题解,快来一起刷题呀~

一、关联式容器

  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没有像其他容器一样给我们提供专门的修改接口,就算通过迭代器也不能修改,*itset中是常引用,无法修改。

  因为擅自修改一个值,很可能直接破坏了搜索树的结构,有的结构破坏了以后,没法通过旋转等操作弄回来。

三、key-value模型有序序列map

  每个结点除了存一个Key,还存了一个T,它是使用pair<const Key, T>键值对实现的。

1 map的简单使用

  map的大部分操作和set都相同,除了这个operator[]

  它的insert

  它的value_typepairtypedef出来的。

  它的遍历不能直接把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的节点的个数。

  erasemultiset也是一样,传迭代器就删除一个结点,传键值就删除所有键值为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 插入

  插入时,要更新祖先的平衡因子(更新到根也会结束),更新逻辑:

  1. cur == parent->leftparent->bf--
  2. cur == parent->rightparent->bf++
  3. 如果更新后parent->bf == 0,说明更新前parent的bf是1或-1,现在变成0,说明填上了矮的那边,parent所在的子树高度不变,自然更新结束。
  4. 更新以后,parent->bf == 1 / -1,当前parent所在子树是平衡的,但是要继续往上更新新插入结点的影响,说明更新前parentbf是0,新插入的节点使得高度变高了,需要继续往上更新。
  5. 更新以后,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树的插入的效率还是很不错的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值