C++ - map 和 set 使用介绍

简介

 其实在map 和 set 的底层实现当中有差不多一半的 结构是使用 二叉搜索树来实现,关于二叉搜索树可以看下面这个篇博客:
C++ - 搜索二叉树_chihiro1122的博客-CSDN博客

 而 set 是 key 模型,他是直接按照 key 值大小来有规律的在 二叉搜索树当中存储。

而 map 是 key - value  模型,在 上述博客中的 搜索二叉树的运用当中对 key - value 这个模型进行了举例说明。

map 是一个非常好用的 数据结构,我们知道 二叉搜索树 在查找数据当中所带来的极高效率,然而我们在平常使用的时候,不会去手搓一个 二叉搜索树。用现成的 map 和 set 就行了。

set

 set 是 key 模型,它在存储一些比如 只有 int 类型的数的时候,为未来带来非常快速的查找。

在了解 二叉搜索树 是如何实现之后,我们就应该明白 set 的一些特性:自动排序,去重

set 当中在打印的时候,比如使用它的迭代器来打印,他是按照树的中序来打印的,所以,打印出来的结果是有序的。而且二叉搜索树当中的值只有一个,不会重复,就算你在构建树或者在后续插入结点的时候,造成了重复的值,这些值也会被set 进行去重:


	set<int> s;
	s.insert(5);
	s.insert(5);
	s.insert(5);
	s.insert(3);
	s.insert(8);
	s.insert(1);
	s.insert(10);

	std::set<int>::iterator it = s.begin();
	for (auto e : s)
	{
		cout << e << endl;
	}

	return 0;

输出:

1
3
5
8
10

发现,尾差的结点当中是无序的,而且有重复值,但是输出都 是有序而且去重了的。

注意:在 set 当中重写了 Find()函数,但是库当中也有find()函数,两者虽然都能够达到查找的目的,但是两个 find()函数的查找逻辑是完全不一样的:

	set<int> s;
	s.insert(5);
	s.insert(5);
	s.insert(5);
	s.insert(3);
	s.insert(8);
	s.insert(1);
	s.insert(10);

	std::set<int>::iterator it = s.find(5); // set 当中的 find()函数
	auto it1 = find(s.begin(), s.end(), 5);
	
	if(it != s.end())
		cout << "set::find() :" << * it << endl;

	cout << "std::find() :" << * it1 << endl;

	return 0;

输出:
 

set::find() :5
std::find() :5

在 set 当中的find()函数,是按照 二叉搜索树当中 查找的逻辑来查找的,按照给定的 key 值,比如 当前结点的值比key大,走左子树,反之;

而在 库当中的 find()函数是直接暴力查找,要遍历整颗树。

 因为 set 的find()函数如果找到结点返回的是该结点的迭代器,如果没有找到 返回的是 end()位置迭代器。

set 当中的一些函数介绍

 set 当中有一些 自己实现的,好用的一些函数,以下我们简单介绍一些:
 

key_comp 和 value_comp

 是 获取到 key 和 value 的仿函数,获取到仿函数对象。

count()函数

 count()函数判断给值在树当中出现多少次,因为是搜索二叉树,只会出现一次,所以当 count()函数找到了,就会返回1,找不到就返回0:

 而且count()函数是被 const 修饰的,也就是说该函数的 this指针是不能进行修改的,count()函数不能用于修改 二叉搜索树。

其中的value_type 和 value_key 是一样的,都是 key,也就是模版参数类型。只不过在库当中经常模版参数 T ,typedef 一下,命名为 类似 value_type 这样的参数。

	if (s.find(5) != s.end())
		cout << "找到了" << endl;

	if (s.count(5))
		cout << "找到了" << endl;

由上述例子可以发现,count()再日常使用方式,比find()函数使用还要方便一些,而且  count()函数的效率和 find()函数的效率都是一样的,因为都是使用二叉搜索树的查找方式来寻找的。

 那么count()函数和 find()函数差不多,那么为什么要多设计一个 count()函数出来呢

请看下述的 multiset 容器。 

lower_bound 和 upper_bound

这两个函数是用于取出迭代器区间的。

有时候我们可能会有一些需求,比如要去除某迭代器区间的值,然后删除;或者在 insert()插入函数当中,有重载有 用 迭代器区间来插入值的 函数:

 在官方文档当中给出了下面这个例子:
 

std::set<int> myset;
  std::set<int>::iterator itlow,itup;

  for (int i=1; i<10; i++) myset.insert(i*10); // 10 20 30 40 50 60 70 80 90

  itlow=myset.lower_bound (30);                //       ^
  itup=myset.upper_bound (60);                 //                   ^

  myset.erase(itlow,itup);                     // 10 20 70 80 90

  std::cout << "myset contains:";
  for (std::set<int>::iterator it=myset.begin(); it!=myset.end(); ++it)
    std::cout << ' ' << *it;
  std::cout << '\n';

 这个例子就是删除某一段迭代器区间当中的所以值。

我们发现 erase()函数函数也是重载了 迭代器区间 参数的函数。

用 lower_bound ()函数取出 左迭代器区间;用 upper_bound()函数取出 右迭代器区间;

 再把取出的迭代器,传入到 erase()函数当中就返程了删除操作。

 注意,在STL当中的区间一般都是 左闭右开 的,但是上述程序的输出结果是:

10 20 70 80 90

其实这里的erase()函数其实也是 左闭右开,这里把 60 也删掉了是因为,upper_bound (60)在实际当中 返回的是 删除的右区间的数要稍微比 60 大一点,才会把 60 给删掉;但 erase()的本质上还是左闭右开区间的删除。

 equal_range()函数 

equal_range () 函数也是可以取出迭代器区间的,但是这个函数的返回值返回的是一个 pair 对象,(关于 pair 类会在 map 当中说明)。只不过 equal_range()函数返回的迭代器区间只有一个值,并不想之前的一样可以有多个值

如果 equal_range() 没有找到 值,返回的是比 找到的值稍大一点的区间,但是这个区间也是一个不存在的区间;假如你传入 6,那么他可能返回的区间是 [7,7) ,这个区间相当于是一个不存在的区间;如果传入的值已经大于 二叉搜索树当中最大的值了,返回的 是 end()。

在官方文档中的 例子 是这样写的:

std::set<int> myset;

  for (int i=1; i<=5; i++) myset.insert(i*10);   // myset: 10 20 30 40 50

  std::pair<std::set<int>::const_iterator,std::set<int>::const_iterator> ret;
  ret = myset.equal_range(30);
  auto it1 = ret.first;
  auto it2 = ret.second;

  std::cout << "the lower bound points to: " << *it1 << '\n';
  std::cout << "the upper bound points to: " << *it2 << '\n';

像上述例子,我们也取出了 it1 和 it2 两个迭代器左右区间。

 上述的 pair 当中 second 也返回的是 比 右区间要稍微大一点的值。

 multiset,另一种 set

 除了set 本身,还有一个 set 叫做 multiset;

set 是 排序加去重;multiset 只是 排序。

 multiset 在功能上 和 set 是基本完全一样的,使用也是和 set 没多大区别。

例子:
 

	multiset<int> s;
	s.insert(5);
	s.insert(5);
	s.insert(5);
	s.insert(3);
	s.insert(8);
	s.insert(1);
	s.insert(10);

	for (auto e : s)
	{
		cout << e << " ";
	}

输出:

1 3 5 5 5 8 10

multiset支持 相同的值继续插入,相同的值插入在左右两边都可以。

那 对应 的在 set 当中意义不大的函数 ,比如 count()函数在 multiset当中就有意义了

那么如果我能使用 find()函数查找 上述例子当中的 5 ,find()函数会返回哪一个 5呢?

 不一定是 第一个 5 ,或者说是 第一个插入的 5,因为 multiset 当中为了 防止 出现 极端情况,就会旋转子树。其实 find()函数返回的是 中序当中的第一个 5。

 map

 map在定义的时候和 set 的区别就是,map 的 定义需要两个参数,一个是 用于排列的 key,另一个是 key 对应的 value值。

由上述声明当中也可以发现,Compare 比较器 是 Key。是按照 key 值 来排序的。

而,在 map 当中的 value_type 不一样了,不在是 模版参数当中的 T:

 他是 一个 pair 类模板。而且 pair 其中的 key 还是一个 const 的 key,也就是 不允许修改了。而 value 是可以修改的。

其实在 set 当中也是不允许修改 其中结点的值的,实现也简单,set 当中的 迭代器 和 const 迭代器,都是 const 迭代器。也就是说set 当中只有 const 迭代器:

 

 所以在 map 当中使用 insert()函数来插入结点的时候,就不能直接传入 两个参数来插入了。

必须传入一个 pair 对象,因为 insert()函数的当中的参数类型是 value_type ,是 pair 的typedef:

	map<string, string> dict;
	pair<string, string> p("你好","hello");
	dict.insert(p); // 方式1 
	dict.insert(pair<string, string>("再见", "byebye")); // 方式2
	dict.insert({ "string", "字符串" });  // 方式3

在 map 当中,支持 key 不相同但 value 相同;不支持 key 相同,value 不相同;

其实就是 key值不能相同,相同会进行去重,但value 是可以相同的。

key 相同,不插入,不覆盖;插入过程当中只比较 key。 

 虽然在 map 当中模版可以支持任意类型,但是因为 map 的底层是用二叉搜索树来实现的,避免不了要比较大小,所以,key 的类型必须是有 比较大小功能的 类型。如果有一种需求,想使用一种没有比较大小的作为 key,那么可以用仿函数来实现。在 map 的模版参数当中给出了 Compare = less<Key>。默认是 Key ,我们可以自己写一个仿函数来 构建 这个类型的比较函数。

 关于如何仿函数运用的文章:C++ - 优先级队列(priority_queue)的介绍和模拟实现 - 反向迭代器的适配器实现 - 仿函数_c++ priority_queue迭代器_chihiro1122的博客-CSDN博客

 多参数的隐式类型转换

 dict.insert({ "string", "字符串" });   这种写法是最简便的,看上去有点怪,其实这发生了隐式类型的转换。我们知道,单参数的构造函数支持隐式类型的转换,可以看下面这边博客当中,对隐式类型转换的介绍:C++ 构造函数-2_chihiro1122的博客-CSDN博客

 其实多参数的隐式类型转换也是可以的,只需要把 隐式转换的 变量或者常量用 "{}"括起来,用 ","分隔就行
 

class A
{
public:
	A(int a, int b)
	{
		_a = a;
		_b = b;
	}
private:
	int _a;
	int _b;
};

int main()
{
	A Aaa = { 1 , 2 };

	return 0;
}

其实下述三者的本质都是隐式类型的转换,只不过参数个数不同。

	string str = "hello";
	A Aaa = { 1 , 2 };
	pair<string, string> p = { "string" , "字符串" };

 隐式类型转换,本质上需要调用两个函数,一个是构造函数,一个是拷贝构造函数;构造函数利用 参数 构造临时对象,而拷贝构造函数 把 临时对象拷贝到 我们要创建的对象的当中。

但实际上,编译器不会这样做,他觉得只构建一个对象,却要调用两个构造函数,很麻烦,所以通常情况下,编译器会对这里进行优化,比如上述例子,会直接使用 "hello" 等等的参数作为构造函数的参数,直接构造出 对象。

如果构建的对象加了 引用类型,比如:

const pair<string, string> p& = { "string" , "字符串" };

 当构建的是对象引用的时候,优化就没有了;

但是单独加 & 是不行的,会报错,需要加 const 修饰才行。因为 此时 对象引用 引用的是 隐式类型转换,构造的临时对象,临时对象具有常性,需要加 const 修饰。

 make_pair()

 上述在使用 insert()函数的时候,我们还有单独构建出一个 pair 对象出来,非常的麻烦。

所以在 C++ 当中提供给了一个函数:make_pair()。

它的实现大概是这样的:

	dict.insert(make_pair("string", "字符串"));

  这是 C++ 98 经常使用的方式,在 C++ 11 是不支持隐式类型转换。

使用 make_pair() 函数看似多调用一个函数去构造,其实在效率上没有多大的损失,和隐式类型转换方式构建差不多,因为 make_pair()函数代码少,会被定义成内联函数,内联函数就基本没有消耗了。

 operator[] 函数 

 之前说过,插入一个 key 值在树当中已经存在的结点,不插入,不覆盖;

如果想要覆盖 value 值的话,就要使用  operator[] 函数 。

如下面这个例子:

	string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };

	map<string, int> countMap;
	for (const auto& e : arr)
	{
		auto it = countMap.find(e);
		if (countMap.end() == it)
		{
			countMap.insert(make_pair(e, 1));     // 没有找到,插入新的水果
		}
		else
		{
			it->second++;
		}
	}

	map<string, int>::iterator it = countMap.begin();
	while (it != countMap.end())
	{
		cout << (*it).first << ":";
		cout << (*it).second << endl;
		it++;
	}

输出:
 

苹果:6
西瓜:3
香蕉:2

还可以这样写:

	string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };

	map<string, int> countMap;

	for (const auto& e : arr)
	{
		countMap[e]++;
	}

	map<string, int>::iterator it = countMap.begin();
	while (it != countMap.end())
	{
		cout << (*it).first << ":";
		cout << (*it).second << endl;
		it++;
	}

 输出:
 

苹果:6
西瓜:3
香蕉:2

这里就巧妙的用到 operator[] ()函数。

operator[]()函数的使用:

 返回值 mapped_type& 是 模版参数的第二个位置,也就是 value。参数传入是 key_type 是key。

也就是传入 key ,返回 value。

到这里,就可以理解 为什么 countMap[e]++; 这句代码可以 ++ value。

关键是 开始的时候 countMap 当中是什么都没有的,第一次是如何处理的?

文档当中是这样说的:

A call to this function is equivalent to:

(*((this->insert(make_pair(k,mapped_type()))).first)).second

 也就是相当于是在返回值 处调用这个:

mapped_type& operator[] (const key_type& k);
{
     // `````````````````````````
    return (*((this->insert(make_pair(k,mapped_type()))).first)).second;
}

 简单来看就是使用 insert()函数来实现的,所以我们还要探究一下 insert()函数:
上述insert()函数的返回值是:

pair<iterator,bool>

 发现,pair当中的模版参数有一个 是迭代器,官方文档当中,对于insert()的这个返回值是这样描述的:

 总结,两种情况:

  • 如果插入的结点(插入的key值)在树当中已经存在,返回pair<树里面key值结点的iterator,false>
  • 如果插入的结点(插入的key值)在树当中不存在,返回pair<新插入的key值结点的iterator,true>

 由上述发现,insert()函数不止有插入的价值,还有查找的价值,有没有找到在 返回值的模版参数当中的 bool 值类型当中就可以知道

 把他简化一下,上述的代码如下所示:

mapped_type& operator[] (const key_type& k);
{
     pair<iterator, bool> ret = insert(make_pair(key, V())); // 这不管 树当中有没有 这个key,                    
                                                             //都不会去修改 value的值
     return ret.first->second;  // 统一在这里进行处理
}

 operator[] ()函数实现原理细节解析

  • 上述V() 是 value的匿名对象,我们期望在这里调用 value的构造函数,在 value 的第地方我们不建议直接写 0 ,写 0 就写死了,写 value的构造函数比较好。
  • 匿名对象调用的是 默认构造。所以,如果不给 value 的值,默认给的是 value 默认构造的缺省值。
  •  而且,insert()函数有一个特性,他是不管 value的,不管 树中的key 和 当前插入的 key 想不想等,都不会去覆盖。
  • 所以,因为这个函数的返回值是 value 的引用,我们直接返回  insert()函数返回的  pair对象,其中就有 first(iterator)和 second(bool)。
  • 我们从 迭代器 first(iterator) 当中取出 其中的 second(value),使用引用返回 (value&)的话就可以直接对外部的访问的结点的 value 值进行修改。

 现在我们来看,之前那一句代码究竟是如何实现的,实现的过程是什么

 countMap[e]++; 这一行代码,无非就两种情况:

  • 第一种情况是,当前 e 从 arr 数组当中取到水果 在树 当中没有出现,那么此时的insert()就会构建出一个 int 的匿名对象,而 int 的匿名对象 默认构造函数的缺省参数就是 0 。然后 insert()函数插入这个水果,然后 insert()函数返回 当前 插入的这个结点的 迭代器 给到 pair 对象当中的 first成员,把 false 给到 second 成员。最后返回 一个 pair 类的对象 ret。 
  •  然后,返回的 是 ret 对象 first成员 当中存储的迭代器的 second成员,注意,此处的 second成员是迭代器指针指向的 pair 对象(当前插入的结点) 当中的 second 成员,它存储的是 当前结点的 value。
  • 把 这个 second 也就是这个结点的 value值,作为 value& 返回,那么在外部对这个引用进行 ++ ,相当于就是对这个 value 进行 ++。
  • 第二种情况是,当前 e 从 arr 数组当中取出的水果 是 树当中出现过的,insert()函数只看 key 相同,就会插入失败,失败同样会返回 从 arr 取出的水果在树当中的位置用迭代器表示如上述情况一样返回以 一个 pair 对象返回。
  • 那么,此时就没有对这个水果进行插入,但是到了返回值的时候,还是会像上述情况一样取出 ret 的 first 当中的 second,此处(second)存储的是 这个结点(水果)出现的次数,这个 second 同样以 引用的形式返回,在函数外 进行 ++ , 影响到 函数内部的 second。

 operator[] ()函数是 map 这个容器设计最 巧妙之处,他的功能不止在于上面,这个函数的返回值是 value& ,因为不是那 const 修饰的,我们可以用这个 value& 做很多事情。可读,可修改,可查找,可以插入新的结点:

	map<string, string> m;

	m.insert(make_pair("你好", "hello"));
	m["你好"] = "byebye";

	cout << m["你好"] << endl; // byebye

 还有一种是 插入 加 修改

// set 这个 key 在 树当中是没有的
// 先 调用 inset()进行插入
// 然后在 利用返回值 value& 进行修改
	m["set"] = "集合";

 pair类模板介绍

 库当中的 pair 类模板大概是这样定义的:

template <class T1, class T2>
struct pair
{
	typedef T1 first_type;
	typedef T2 second_type;
	T1 first;
	T2 second;
	pair() : first(T1()), second(T2())
	{}
	pair(const T1& a, const T2& b) : first(a), second(b)
	{}
};

在 pair 当中有两个核心成员:first 和 second。由 T1 和 T2 来控制模版当中的参数。

 官方库当中之所以要使用 pair 这个结构来存储 key - value 值,而不是直接用 key 和 value 的变量来存储,是因为在迭代器的实现当中,需要实现 operator*()这个 重载运算符函数,这个函数当中是需要返回 key 和 value的,而在 map 当中有 key 和 value 两个成员,如果用单独 变量来存储的话,两个单独的变量不好返回,只要把他们作为一个结构来返回。

 在 pair 库当中是没有实现 << 和 >>(流提取和流插入),在使用迭代器来进行打印的时候,迭代器取出来的是 一个 pair 对象,我们要想打印出 key 和 value 值,就要访问对象当中的 first 和 second 成员:
 

	map<string, string> dict;
	pair<string, string> p("你好","hello");
	dict.insert(p); // 方式1 
	dict.insert(pair<string, string>("再见", "byebye"));
	dict.insert(make_pair("string", "字符串"));
	dict.insert({ "string", "字符串" });

	map<string, string>::iterator it = dict.begin();
	while (it != dict.end())
	{
		cout << (*it).first << " ";
		cout << (*it).second << " " << endl;
		it++;
	}

输出:

string 字符串
你好 hello
再见 byebye

或者用 "->"   :

		cout << it->first << " ";
		cout << it->second << " " << endl;

这个指针的解引用操作符,上述应该是 it->->first  这样使用,但是编译器优化了,看上去就像直接使用 "->" 一样。

这里使用的 "->" 其实是 operator-> ()这个函数实现的:

 operator-> ()这个函数返回的是一个 指针,前面一个 "->" 是重载函数名,后一个 "->" 是函数返回的指针的解引用。

 pair 当中的 first成员,也就是 map 当中的 key 值是不能修改的;而 second 成员,也就是 map 当中的 value 值是可以修改的。

 multimap,另一种 map

 multimap和 multiset 是类似的,也是支持排序,不支持去重。

multimap 和 map 之间不同点不止去不去重,在 multimap 当中没有实现 operator[]()函数

至于原因我们也很容易想到,可能一个key 对应 多个 value,所以没有办法提供 operator[]()函数。

而 ,multimap 当中的 insert()函数的返回值 只是 iterator了,不再是 pair<iterator,bool> 这样的类型。因为 在 multimap 当中永远插入成功。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

chihiro1122

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值