【C++修炼】map 和 set 的使用

1. 序列式容器和关联式容器

序列式容器:

  • 序列式容器: string (字符串动态数组/顺序表)、vector (动态数组/顺序表)、list (双向链表)、deque (双端队列)、array (静态数组/顺序表)、forward_list (单向链表) 等。

序列容器逻辑结构上都是线性的,通过元素在容器中的位置/索引来组织数据 (进行线性存储或访问)。元素的位置取决于插入顺序或移除顺序,而不是它们的值。任意位置的两个元素之间没有关联关系。随意交换容器内的元素位置,不会破坏容器的结构与性质。

关联式容器:

  • 有序 (红黑树系列): map (key/value搜索) /set (key搜索) /multimap (支持冗余) /multiset (支持冗余)。这类容器通常采用红黑树,一种平衡二叉搜索树。存储元素时,容器根据键进行大小排序,默认排升序,允许自定义比较规则。
  • 无序 (哈希系列): unordered_map (key/value搜索) /unordered_set (key搜索) /unordered_multimap (支持键冗余) /unordered_multiset (支持键冗余)。这类容器在C++11引入,通常称为哈希容器,均采用哈希表。元素位置由哈希函数确定,通常未排序。

关联容器逻辑结构上都是⾮线性的,用于存储键值对 (key/value) 或单键 (key),++通过 key 进行元素的存储与访问++ ,根据键 快速搜索值 (单键的"键"同时也是"值")。位置之间有紧密关联关系,交换⼀下元素,容器的结构或性质可能就被破坏了。例如二叉搜索树的规则是:左子树所有结点的值 <= 根结点的值 <= 右子树所有结点的值,随意交换就可能破坏搜索树的这种大小关系。

本章节将学习 map/set/multimap/multiset。我们知道,普通二叉搜索树增/删/查的时间复杂度为O(N),实际效率取决于深度,最好情况是完全二叉树高度为logN,最坏情况是单边树高度为N。因此,普通二叉搜索树非常依赖插入/删除顺序,无法保证平衡性 (越接近完全二叉树平衡性越好)。红黑树是一种平衡搜索树,使二叉搜索树经过平衡接近完全二叉树,效率保证在O(logN)。

2. set 系列的使用

2.1 set/multiset 参考文档

set/multiset 的介绍文档

set 和 multiset 都是key搜索场景,存储单键,所以结点的值 value 同时也是键 key。

set 的相关接口

2.2 set 类的介绍

  • set 的声明如上,T 就是 set 底层关键字 (键key) 的类型 key_type,同时 T 也是值 value 的类型 value_type
  • set 默认要求 T 类型⽀持⼩于⽐较 less<T>,如果不⽀持或者需要自定义比较规则,可⾃⾏实现仿函数传给第⼆个模版参数 Compare
  • set 底层存储数据的内存是从空间配置器申请的,如果需要可以⾃⼰实现内存池,传给第三个参数 Alloc
  • 后两个模版参数通常不需要传
  • set 底层⽤红⿊树实现,增删查效率是O(logN)
  • set 容器的迭代器 (iterator) 遍历规则是搜索树的中序遍历,默认排升序。
  • STL容器接⼝设计⾼度相似,set 容器的接口可参考前⾯的 string/vector/list

注意:set 容器存储数据并非有序,内存地址本质也是线性的。但搜索树的性质,加上中序遍历,使得 set 的遍历一定有序,很多接口的实现就依靠这一点。

2.3 set 的构造和迭代器

构造

  • set 的构造关注以下⼏个接⼝即可。
  • empty(1):empty这里有两个构造。第一个是默认构造 ,两个形参 comp 和 alloc 都有缺省值 (两个形参都是自定义类型,可理解为调用自定义类型的默认构造初始化匿名对象,再调用它们的拷贝构造初始化comp/alloc)。一个仿函数 comp,一个空间配置器 alloc,一般无需传参;第二个是带参构造 ,只有一个 alloc,允许单独传参 alloc (缺省值必须从右向左给,传参则必须从左向右,所以默认构造必须先传参comp,才能传参alloc)。
  • range (2):用一个左闭右开的迭代器区间 [first, last) 进行初始化。该构造函数为模板,可用 set 迭代器,也可以用其他容器迭代器。
  • copy (3):拷贝构造,有两个。可以单独传参 set 对象,也能再传一个 alloc。
  • move (4):移动构造。类和对象章节说过:类实际有8个默认成员函数,已学过的有6个:构造/析构/拷贝构造/赋值重载/取地址重载/const取地址重载。还有两个C++11新增的移动构造和移动赋值,暂不说明。
  • initializer list (5):initializer列表初始化。il 相当于一个轻量级的只读的临时数组,本身不存储数据,通过指针指向编译器生成的临时数组。可用于临时存储实参列表,例如 vector<int> v = { 1, 2, 3 },il 可临时存储实参列表 { 1, 2, 3 },set 的构造函数就可以通过 il 初始化 v,实现上述像数组一样初始化容器对象 v 的效果。

迭代器

  • set ⽀持正向/反向迭代,迭代遍历默认排升序;⽀持迭代器就⽀持范围 for ,set 的 iterator 和 const_iterator 都不⽀持修改数据,因为二叉搜索树都不支持修改 Key (键)。

2.4 set 的增删查

增

  • single element (1):两个重载。第一个,const value_type& val,插入一个元素,val 既是值 value 又是键 key (返回值pair后续map处再说明);第二个,value_type&& val,&& 是右值引用,涉及C++11的移动语义,暂不介绍。
  • with hint (2):支持在特定位置 position 插入元素 val。
  • range (3):依次插入迭代器区间中的所有元素,左闭右开 [first, last),允许其他容器迭代器。
  • initializer list (4):插入initializer列表中的所有元素。

set 不支持冗余,有自动去重的性质,相同值都会插入失败。

代码示例 2.4.1

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
	// 一、默认构造与插入
	// 自动去重 (遇到相等值则插入失败) + 默认排升序
	set<int> s;
	// 降序排序:利用仿函数自定义比较规则,例如构造初始化时传一个 greater 仿函数
	//set<int, greater<int>> s;
	s.insert(5);
	s.insert(2);
	s.insert(7);
	s.insert(5); // 相等值插入失败

	// 二、遍历:set 迭代器,中序遍历,默认升序
	auto it = s.begin(); // it 类型为 set<int>::iterator
	while (it != s.end())
	{
		// set 迭代器不支持修改,无论普通迭代器还是const迭代器
		// *it = 1; // error C3892: "it": 不能给常量赋值
		cout << *it << " ";
		++it;
	}
	cout << endl;

	// 三、initializer列表 (构造和insert都支持)
	// s 已有:2、5、7。自动去重,只成功插入8、3、9
	s.insert({ 2,8,3,9 }); // 构造也支持initializer列表
	for (auto e : s)       // 有迭代器就支持范围for遍历
	{
		cout << e << " ";
	}
	cout << endl;

	// 四、字符串同样可以比较,字符相当于整型,底层是ASCLL码 (0~255)
	set<string> strset = {"sort", "insert", "add"};
	/* 
    按语义,这里先构造一个 set 匿名对象,再拷贝构造 strset。
	大多编译器会优化为一个构造,直接构造 strset。
    相当于下面这种写法 strset({ "sort", "insert", "add" })。
	两种写法结果相同,但语义不同。前者形式上像赋值,实际效果和后者一致。
	*/
	/* set(initializer_list<value_type> il,
				const key_compare& comp = key_compare(),
					const allocator_type& alloc = allocator_type());
	直接调用 initializer list (5)
    { "sort", "insert", "add" } 隐式类型转换为 initializer_list<value_type> il
	*/
    for (auto& e : strset)
	{
		cout << e << " ";
	}
	cout << endl;
    // 字符串或 string 的比较:按字符的ASCLL码比较,从第一个字符开始,逐位对应
	/*
    sort 的 s 大于 insert 的 i,相当于 sort 字符串大于 insert
	insert 的 i 大于 add 的 a,相当于 insert 字符串大于 add
	若相等则继续逐个比较各字符,例如继续比较第2个字符 sort 的 o 和 insert 的 n
	默认排升序:add < insert < sort
	*/
    
	return 0;
}

查

  • 查找指定值 val,成功则返回一个指向该元素位置的迭代器对象,失败则返回 std::end()。
  • val 既是键 key 也是值 value。value_type == key_type == 模板参数 T。
  • val 大于根的 key,往右走;val 小于根的 key,往左走。查找次数和搜索树的高度相关,时间复杂度为O(logN)。

set 没有相等值,但为了和 multiset 兼容统一,出现了 count。

  • count 可返回 val 的个数。set 中只可能为0或1。multiset 中可能有多个相等值。
  • size_type 即 unsigned integral type 无符号整型 ,和 size_t 类似。

代码示例 2.4.2

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
	set<int> s = { 4,2,7,2,8,5,9 }; // 2 4 5 7 8 9
	for (auto e : s)
	{
		cout << e << " ";
	}
	cout << endl;

	// 利用 find 查找判断 7 存在不存在
	auto pos = s.find(7);
	if (pos != s.end())
	{
		cout << "find success!!" << endl;
	}
	else
	{
		cout << "find fail!!" << endl;
	}

	// 利⽤ count 快速判断 7 存在不存在,更简洁
	if (s.count(7))
	{
		cout << "find success!!" << endl;
	}
	else
	{
		cout << "find fail!!" << endl;
	}

	return 0;
}

运行结果:

删

  • (1):删除 position 位置的元素。返回值是迭代器:指向被删除元素的下一元素 (默认排升序,返回后一元素),如果删除的是容器中的最后一个元素,则返回 set::end。
  • (2):删除指定值。如果 val 存在则删除,不存在直接返回。返回值是 size_type 类型,表示删除的元素个数 ,成功返回1,失败返回0。
  • (3):依次删除迭代器区间 [first, last) 中的所有元素 (默认升序),左闭右开,返回值同 (1),但是最后一个被删除元素的下一位置,即 last。

注:已知 set 默认排升序。因此,std::begin 指向中序遍历的第一个元素,即最小元素;std::end 指向的是中序遍历的最后一个元素的下一位置,即最大元素的下一位置。erase (1) 中"被删除元素的下一元素"是指升序序列中的后一元素。erase (3) 同理。

迭代器失效问题

代码示例 2.4.3

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
    // 自动去重 + 升序排序 :2 4 5 7 8 9
    set<int> s = { 4, 2, 7, 2, 8, 5, 9 };
    for (auto e : s)
    {
        cout << e << " ";
    }
    cout << endl;

    // 一、删除最⼩值:调用 erase (1) 传 set 迭代器对象
    // begin 返回中序遍历的第一个元素,即最小元素
    s.erase(s.begin());
    for (auto e : s)
    {
        cout << e << " ";
    }
    cout << endl;
    
    // 二、删除指定值:调用 erase (2)
    int x;
    cin >> x;
    int num = s.erase(x); // 删除 x,返回值是被删除的元素个数,0或1
    if (num == 0)
    {
        cout << x << " erase fail!!" << endl;
    }
    else if (num == 1)
    {
        cout << x << " erase success!!" << endl;
    }
    // erase (2) 可理解为 find + erase (1),找到了就删,没找到就不删,并不是报错
    // 删除8
    auto pos = s.find(8); // 注意 pos 迭代器失效问题
    s.erase(pos);
    cout << "8 erase success!!" << endl;
    for (auto e : s)
    {
        cout << e << " ";
    }
    cout << endl;

	return 0;
}

运行结果:

这里需要再次说明迭代器失效问题,之前 vector 章节也遇到过类似情况。当时是两种情况:

  • 插入元素,例如 insert 或 push_back 可能导致底层数组进行扩容,通常是异地扩容,所以指向原位置的 pos 已失效。
  • 删除元素,例如 erase 可能导致原本位置被其他元素覆盖,例如删除中间位置的元素,后面的元素会前移一步覆盖 pos 位置。
  • 同理,插入元素可能导致其他元素后移,同样认为迭代器失效。

若使用了失效的迭代器对象,Visual Studio系列编译器统一处理为编译报错,即使第二种或第三种情况 *pos 仍然可以得到有效数据。

因此,只要进行了插入/删除操作,就必须对迭代器对象进行更新,否则再次使用迭代器对象就会报错。

  • set 同理。虽然 set::insert 没有扩容概念,但搜索树的插入需要比较键的大小进行排序,可能改变搜索树的结构,进而导致中序遍历中元素位置变化。
  • set::erase 同理。不管是直接删除叶子结点,还是替代法删除其他结点,前者导致迭代器指向空位置,后者导致迭代器指向另一个有效结点。

只需记住:对于容器,包括 set,只要用了迭代器,涉及插入/删除操作,就必须注意迭代器失效问题,操作完注意更新迭代器对象。

erase (3) 和 lower/upper_bound

传参任意值 val。

  • lower_bound:返回第一个大于等于 val 的元素的位置。
  • upper_bound:返回第一个大于 val 的元素的位置。

返回值是 set 迭代器对象。val 可能存在,也可能不存在。

这两个函数可用于查找迭代器区间。

代码示例 2.4.4

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
	std::set<int> myset;
	for (int i = 1; i < 10; i++)
		myset.insert(i * 10); // 10 20 30 40 50 60 70 80 90
	for (auto e : myset)
	{
		cout << e << " ";
	}
	cout << endl;

	// 要求删除区间 [30, 50],边界元素存在,即删除30、40、50
	//auto itlow = myset.lower_bound(30); // >= 30,得到30
	//auto itup = myset.upper_bound(50);  // > 50 的下一元素,得到60
	//myset.erase(itlow, itup);
	
	// 要求删除区间 [25, 55],边界元素不存在,即删除30、40、50
	auto itlow = myset.lower_bound(25); // >= 25,得到30
	auto itup = myset.upper_bound(55);  // > 55 的下一元素,得到60 
	// 调用 erase (3) 删除这段区间 [25, 55] 的值,即删除左闭右开区间 [30, 60)
	myset.erase(itlow, itup);
	for (auto e : myset)
	{
		cout << e << " ";
	}
	cout << endl;

	return 0;
}

运行结果:

find 的效率对比

代码示例 2.4.5

cpp 复制代码
#include <iostream>
#include <set>
#include <algorithm>
using namespace std;

int main()
{
	set<int> s = { 4,2,7,2,8,5,9 }; // 2 4 5 7 8 9
	for (auto e : s)
	{
		cout << e << " ";
	}
	cout << endl;

	int x;
	cin >> x;
	// 算法库 <algorithm> 的 find 查找 O(N) 
	auto pos1 = find(s.begin(), s.end(), x);
	cout << "pos1: " << *pos1 << endl;

	// set 自己实现的 find 查找 O(logN) 
	auto pos2 = s.find(x);
	cout << "pos2: " << *pos2 << endl;

	return 0;
}

运行结果:

  • 标准库实现的 std::find 面向各种容器,底层数据结构各不相同,不管是序列式容器还是关联式容器,都是按部就班的逐一遍历各个元素,其时间复杂度为O(N)。
  • 但对于 set/multiset/map/multimap 等关联式容器,底层是红黑树,得到一定平衡的二叉搜索树,所以 find 查找的时间复杂度可达到O(logN)。

严格来说,std::find 只是给 vector/list 等序列容器偶尔查找使用的。如果是大量查找,为了效率问题,一般建议使用红黑树系列容器,例如 set,使用它自带的 find 查找。

牢记:对于关联式容器,红黑树系列,使用自带的 find 查找即可。

其他 set 接口

  • swap 交换:交换 set 对象 *this 和 x 的内容。直接交换搜索树的根结点 _root 成员变量即可。
  • clear 清空所有元素。
  • 返回 set 容器中使用的仿函数对象的一个拷贝
  • set 存储单键,键即值,key_compare 相当于 value_compare,即 set 的第二个模板参数 Compare,默认为 less<T>。

2.5 multiset 和 set 的差异

multiset 和 set 接口完全一致,其使⽤也基本类似,区别在于 multiset ⽀持冗余,insert/erase/find/count (增删查) 的一些细节因此有所差异,参照下⾯代码示例理解。

注:前面说过,如果支持冗余,相等值可以在左,也可以在右,保持统一即可。因为经过旋转平衡,其左右位置可能发生变化。

  • 不同点一:multiset 排序,但不去重。

代码示例 2.5.1

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
	// 不同点一:multiset 不去重
	multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 }; // 2 2 4 4 4 4 5 7 8 9
	auto it = s.begin();
	while (it != s.end())
	{
		cout << *it << " ";
		++it;
	}
	cout << endl;

	return 0;
}

运行结果:

  • 不同点二:multiset 可以存在多个相等值,find 查找默认找中序遍历的第一个元素。

如何判断遇到的元素就是中序遍历的第一个?

中序遍历一定是:左子树---根结点---右子树。如果当前结点的值就是需要的值,右子树就不需要再关心,因为根比右子树先遍历。但需要判断当前结点的左子树是否还有目标值。如果有,左子树比根先遍历,说明当前结点不是目标结点。必须确保当前相等值结点的左子树不再存在相等值,才可返回。

注:由于旋转平衡,相等值的左右并非固定,如果固定在右,遇到第一个相等值就可以直接返回了,因为根比右子树先遍历,但并非如此。

代码示例 2.5.2

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
	multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 };
	// 不同点二:find 查找中序的第⼀个相等值
	int x;
	cin >> x;
	auto pos = s.find(x);
	while (pos != s.end() && *pos == x) // 可打印所有相等值
	{
		cout << *pos << " ";
		++pos;
	}
	cout << endl;

	return 0;
}

运行结果:

要求查找中序遍历的第一个相等值,好处就是可以继续遍历后续的其他相等值。

  • 不同点三:multiset 可能存在多个相等值,erase (2) 删除指定值 val,默认删除所有相等值。

代码示例 2.5.3

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
	multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 };
	for (auto e : s)
	{
		cout << e << " ";
	}
	cout << endl;

	// 不同点三:erase 会删除所有相等值 x
	int x;
	cin >> x;
	s.erase(x);
	for (auto e : s)
	{
		cout << e << " ";
	}
	cout << endl;

	return 0;
}

运行结果:

  • 不同点四:multiset 可能存在多个相等值,count 返回值可能大于1。

代码示例 2.5.4

cpp 复制代码
#include <iostream>
#include <set>
using namespace std;

int main()
{
	multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 };
	for (auto e : s)
	{
		cout << e << " ";
	}
	cout << endl;

	// 不同点四:count 返回值可能大于1
	int ret = s.count(4);
	cout << "ret: " << ret << endl;
	ret = s.count(2);
	cout << "ret: " << ret << endl;
	ret = s.count(9);
	cout << "ret: " << ret << endl;

	return 0;
}

运行结果:

2.6 set 的OJ练习题

2.6.1 两个数组的交集

349. 两个数组的交集 - 力扣 (LeetCode)

解析:找交集,只返回一个相等值,结果必定需要去重 。利用 set,不仅可以完成去重,还可以自动排序,得到两个不重复的升序序列,此时就能利用双指针轻松获取交集。cur1 和 cur2 从第一个元素开始,同时遍历两序列,判断当前值是否相等:1. 相等则同时向后走一步;2. 不相等则较小值向后走一步。 因为是升序,cur1 和 cur2 中的较大值往后走一定是更大的值,不可能遇到交集,只能较小值单独往后走,才可能遇到相等值。

代码示例 2.6.1

cpp 复制代码
class Solution 
{
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) 
    {
        set<int> set1(nums1.begin(), nums1.end());
        set<int> set2(nums2.begin(), nums2.end());
        set<int>::iterator cur1 = set1.begin(); // 4 5 9
        set<int>::iterator cur2 = set2.begin(); // 4 8 9
        vector<int> ret;
        while (cur1 != set1.end() && cur2 != set2.end())
        {
            if (*cur1 == *cur2)
            {
                ret.push_back(*cur1); // 找到交集,保存下来
                cur1++;
                cur2++;
            }
            else
            {
                *cur1 < *cur2 ? cur1++ : cur2++;
            }
        }
        return ret;
    }
};

延申:找差集。反过来即可,不属于交集,就属于差集。相等,说明属于交集,cur1 和 cur2 同时向后走;不相等,较小值一定不属于交集,那就一定属于差集,保存下来即可。需要注意的是,两个序列不一定同时走到空,循环结束,剩下序列没有遍历完的元素也属于差集。

2.6.2 环形链表Ⅱ

142. 环形链表Ⅱ - 力扣 (LeetCode)

这道题是之前数据结构链表部分的OJ题,找链表中环形的"入口结点",给出了两种思路:

  • 思路一:通过距离关系式 L = (n - 1) * R + (R - X) 。p1 从H走L步到达E,相当于 p2 从M绕环走n-1圈,然后走R-X步到达E。

代码示例 2.6.2

cpp 复制代码
class Solution 
{
public:
    ListNode *detectCycle(ListNode *head) 
    {
        ListNode *slow = head, *fast = head;
        while (fast != nullptr && fast->next != nullptr)
        {
            slow = slow->next;
            fast = fast->next->next;
            if (slow == fast)
            {
                ListNode *meet = slow; // 快慢指针相遇结点
                while (head != meet)
                {
                    // 分别从头结点head处和相遇结点meet处,一起走L步会相遇
                    head = head->next;
                    meet = meet->next;
                }
                return meet;
            }
        }
        return nullptr;
    }
};
  • 思路二:直接从相遇结点截断,转为判断两个链表是否相交的问题。例如下图,meet 是相遇结点,newhead 是它的下一结点 meet->next,直接从中间截断。分成链表ab 和链表cb ,它们的相交部分就是 b 部分,两个链表的交点就是入环结点。

代码示例 2.6.3

cpp 复制代码
class Solution 
{
    // 求相交链表的交点并返回
    ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) 
    {
        ListNode *curA = headA, *curB = headB;
        int lenA = 1, lenB = 1;
        while (curA->next != nullptr) // 计算链表A的长度
        {
            lenA++;
            curA = curA->next;
        }
        while (curB->next != nullptr) // 计算链表B的长度
        { 
            lenB++; 
            curB = curB->next; 
        }
        // 尾节点不同,说明不相交
        if (curA != curB) return nullptr; 
        // 计算长度差,让长链表先走差值步,先同步长度再找交点
        int sub = std::abs(lenA - lenB); 
        ListNode *longList = headA, *shortList = headB;
        if (lenA < lenB)
        {
            longList = headB; 
            shortList = headA; 
        }
        while (sub--) longList = longList->next;
        // 同步遍历,找交点
        while (longList && shortList) 
        {
            if (longList == shortList) return longList;
            longList = longList->next;
            shortList = shortList->next;
        }
        return nullptr;
    }
public:
    ListNode *detectCycle(ListNode *head) 
    {
        ListNode* slow = head;
        ListNode* fast = head;
        while (fast != nullptr && fast->next != nullptr) 
        {
            slow = slow->next;
            fast = fast->next->next;
            if (slow == fast) 
            {
                // 找到相遇点,断环,改为求相交链表问题
                ListNode* meet = slow;
                ListNode* newHead = meet->next;
                // 1. 断开环
                meet->next = nullptr;
                // 2. 求 "head到meet" 与 "newHead到meet" 的交点
                ListNode* entry = getIntersectionNode(head, newHead);
                // 3. 恢复链表结构(题目不允许修改链表)
                meet->next = newHead;
                return entry;
            }
        }
        return nullptr;
    }
};
  • 用 set 去重的特性解决更简单:遍历链表将所有结点都存入 set,如果存在环,遍历完一遍就会回到环的入口,此时只需要判断 set 中是否存在当前结点即可,判断方式也很简单,利用 set::count 即可。

代码示例 2.6.4

cpp 复制代码
class Solution 
{
public:
    ListNode *detectCycle(ListNode *head) 
    {
        set<ListNode*> s;
        ListNode *cur = head;
        // 遍历链表,插入所有结点
        while (cur)
        {
            // 如果返回0,说明结点不存在,直接插入
            // 如果返回1,说明当前结点即入环结点,返回当前结点
            if (s.count(cur) == 1) return cur;
            else if (s.count(cur) == 0) s.insert(cur);
            cur = cur->next;
        }
        return nullptr;
    }
};

3. map 系列及其差异

3.1 map/multimap 参考文档

map/multimap 的介绍文档

map 系列和 set 系列,区别在于:

  • map 系列是key/value搜索场景,结点中存储的是键值对 (key_value) ,键和值分离。set 系列是键值不分离,键同时也是值。

map 系列和 set 系列,共同点是:

  1. map 系列同样基于红黑树,遵守二叉搜索树的结构,根据键 (Key) 进行排序:左子树所有结点的 key <= 根结点的 key <= 右子树所有结点的 key。
  2. map 系列将键 (Key)/值 (Value) 分离,好处是值不再影响排序,对于搜索树结构而言,值如何毫不关心。(例如下图,不管是map还是multimap,值x/y/z随意重复、毫无规律,但键1/2/3/4/5受搜索树的规则约束)。

简而言之,set 系列中,键就是我们需要的数据 (值),根据这些数据就能在搜索树中完成排序。但在 map 系列中,键和值分离,值才是我们需要的数据,值不需要参与比较与排序。键和值一一对应 (multimap支持键冗余,结点的键可以重复,一个键对多个值),通过键完成比较与排序,值的获取依赖于键。

map 的相关接口

set/multiset/map/multimap 的接口基本一致,map 会多一个 operator\[\]。

和数组不同,数组通过下标区分元素的位置,也通过下标+\[\]获取元素。

map 通过键 (Key) 进行比较排序,唯一区分元素的位置,通过键+\[\]获取值 (Value)。

3.2 map 类的介绍

  • map 的声明如上,Key 就是 map 底层关键字 (键) 的类型,T 是 map 底层 value 值的类型。
  • map 同样要求键 (Key) ⽀持⼩于⽐较 less<Key>,如果不⽀持或者需要自定义比较规则,传一个仿函数给模板参数 Compare。
  • map 底层同样有从空间配置器申请内存的需求。
  • 同样地,Compare/Alloc 一般不需要传。
  • map 底层同样⽤红⿊树实现,增删查改效率同样是 O(logN) (可以修改值value,仍然不可以修改键key,会破坏搜索树结构)。
  • 迭代器同样走中序遍历,默认按照键 (Key) 升序排序。
  • map 支持 operator\[\]。可以通过键+\[\]快速获取值 value,而且支持修改 value。类似数组,但不是下标,而是键 (Key)。迭代器同理,现在非const迭代器允许进行修改了,只能通过非const迭代器修改值 (Value)。

需要特别注意的是:

  • T 单独表示值的类型 value_type,这里口头说的 value_type 在标准库中的正式名称其实叫做 mapped_type。

真正的 value_type,是 pair<const key_type, mapped_type>。

3.3 pair 类型的介绍

介绍

map 底层的红⿊树结点中,存储的数据是键值对,为什么是"对"呢?

实际上,键与值确实分离了,但并不意味着一个结点就必须分别存储两个变量 _key 和 _value。反而通过 pair 将它们组合成了**"一对"** ,所以叫键值对。相当于仍然只有一个变量,但这个变量的类型是 pair<键的类型, 值的类型>,它相当于两个变量的组合。

  1. pair 是一个类模板,有两个模板参数 T1 和 T2。T1/T2 类型可以相同,也可以不同,代表需要组合的两个值的类型。
  2. pair 中分别有两个成员变量 first 和 second。first_type 对应 T1,second_type 对应 T2 (map的key_type传给T1,mapped_type传给T2,相当于first是键,second是值)。
  3. 注意 map 对象中存储的是 pair 键值对,必须通过 first 和 second 单独访问变量。不可以通过 cin/cout 直接输入/输出一个 pair 对象,因为 pair 这个类不是内置类型,而且没有重载流插入/流提取。

将两个变量存入一个 pair 对象,使它两变成一对,可以组合使用,也可以访问 first/second 单独使用。这完美的保证了键与值的对应关系。

下面通过代码示例介绍 pair 的使用。

pair 对象的4种获取方法

代码示例 3.3.1

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	// 用 map 实现一个水果数量统计表:
	// 第一个是键的类型 string,对应水果的名字;第二个是值的类型 int,对应水果的数量
	map<string, int> fruitCount;
	// 用 pair 组织数据,组合成一个键值对 kv1
	pair<string, int> kv1("apple", 20); // 20颗苹果
	// 插入 kv1 到 fruitCount 表中
	fruitCount.insert(kv1);

	// 插入更多数据
	fruitCount.insert(pair<string, int>("banana", 40)); // 40根香蕉
	fruitCount.insert(make_pair("pear", 100));          // 100颗梨子
	fruitCount.insert( {"cherry", 10} );                // 10颗樱桃 

	// for循环:逐一取出表中的键值对,即e,打印键与值
	for (auto& e : fruitCount)
	{
		cout << "first: " << e.first << " second: " << e.second << endl;
	}

	return 0;
}

运行结果:

可以构造有名对象 kv1 进行插入,也可以构造匿名对象进行插入。

值得说明的是 make_pair:

  • make_pair 是标准库实现的一个函数模板。它的作用是返回一个 pair 对象。相当于,任意两个对象,我们传参给 make_pair,可以自动完成类型推导,并将这两个对象组合成一个 pair 对象并返回。

无论是构建有名对象 pair<string, int> kv1("apple", 20),还是构建匿名对象 pair<string, int>("pear", 100), 都比较麻烦。因为需要我们手动写明两个对象的类型,make_pair 则不需要,传参的同时自动完成了类型推导,函数返回值就是 pair 对象。

cpp 复制代码
template <class T1, class T2>
struct pair
{
    // pair 的构造函数,两个参数分别为 T1 和 T2 类型
	pair(const T1& a, const T2& b) : first(a), second(b)
	{
		// ...
	}
};

第四种写法是多参数隐式类型转换 (C++11才开始支持多参数转化):

  • 在类和对象章节中说过,通过构造函数可以实现内置类型到类类型的隐式类型转换 (构造函数前加explicit可禁止该隐式类型转换)。前面是单参数,C++11后支持 {"cherry", 10} 这种多参数的情况,实参的类型和个数刚好与 pair 的构造函数 (没有加explicit) 对应,const T1& a = "cherry",const T2& b = 10。因此,{"cherry", 10} 可以隐式类型转换成一个 pair 对象。

创建或获取 pair 对象大致以上4种写法,一般优先后面两种。

pair 的大小比较

优先比较 first,再比较 second。

cpp 复制代码
// 等于==
// first 和 second 同时相等
template <class T1, class T2>
  bool operator== (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return lhs.first==rhs.first && lhs.second==rhs.second; }

// 不等于!=
// first 或 second 有一个不相等
template <class T1, class T2>
  bool operator!= (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return !(lhs==rhs); }

// 小于<
// first 小就小,大于或等于就看 second,second 小就小
template <class T1, class T2>
  bool operator<  (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return lhs.first<rhs.first || (!(rhs.first<lhs.first) && lhs.second<rhs.second); }

// 小于等于<=
// 完全相等,或者 first 小于等于,或者 first 等于但 second 小于等于
template <class T1, class T2>
  bool operator<= (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return !(rhs<lhs); }

// 大于>
// first 大就大,小于或等于就看 second,second 大就大
template <class T1, class T2>
  bool operator>  (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return rhs<lhs; }

// 大于等于>=
// 完全相等,或者 first 大于等于,或者 first 等于但 second 大于等于
template <class T1, class T2>
  bool operator>= (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return !(lhs<rhs); }

3.4 map 的构造和迭代器

迭代器修改值

  • map、multimap 的构造和 set/multiset 都是完全一致的。有默认构造,有迭代器区间构造,有拷贝构造,也有initializer列表构造。
  • map/multimap/set/multiset 的迭代器也完全一致,都是双向迭代器 (bidirectional iterator),支持正向迭代也支持反向迭代。需要注意的是,set/multiset 不支持修改数据,无论普通迭代器还是const迭代器。map/multimap 支持修改值,但不支持修改键。

代码示例 3.4.1

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	map<string, int> fruitCount;
	fruitCount.insert({ "apple", 20 });
	fruitCount.insert({ "banana", 40 });
	fruitCount.insert({ "pear", 100 });
	fruitCount.insert({ "cherry", 10 });

	// map<string, int>::iterator it = fruitCount.begin();
	auto it = fruitCount.begin();
	while (it != fruitCount.end())
	{
		// map/multimap 只能修改值
		// 注意!*it 解引用后不是键或值,不是数据
		// map 存储键值对,it 指向 pair 对象,解引用后还要单独访问 first/second
		(*it).first = "watermelon"; // first 是键,不可修改,报错
		(*it).second = 5;           // second 是值,可以修改
		it++;
	}

	return 0;
}

报错:

operator-> 重载

  • pair 重载了 operator-> 运算符,map/multimap 迭代器可以直接使用 operator-> 访问 pair 中的 first/second。

代码示例 3.4.2

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	map<string, int> fruitCount;
	fruitCount.insert({ "apple", 20 });
	fruitCount.insert({ "banana", 40 });
	fruitCount.insert({ "pear", 100 });
	fruitCount.insert({ "cherry", 10 });

	auto it = fruitCount.begin();
	while (it != fruitCount.end())
	{
		// pair 重载了 operator-> 运算符
		// it 指向的是 pair 对象,因此 it 可以调用 pair 的 operator->
		cout << it->first << ": " << it->second << endl;
		// 实际,it->first == it.operator->()->first
        // 相当于省略了一个 ->
        // 第一个->,it.operator->(),返回 pair* 对象
        // 第二个->,相当于 pair*->first 
        cout << it.operator->()->first << ": " << it.operator->()->second << endl;
        // 该问题在类和对象,讲默认成员函数,运算符重载时有过说明

		it++;
	}

	return 0;
}

运行结果:

initializer列表+多参数隐式类型转换

关于 map 的initializer列表构造需要特别说明,例如下面这种玩法。

代码示例 3.4.3

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	auto kv1 = make_pair("apple", 20);
	auto kv2 = make_pair("banana", 40);
	auto kv3 = make_pair("pear", 100);
	auto kv4 = make_pair("cherry", 10);

	// 同时传入多个元素,调用initializer列表构造进行初始化
	map<string, int> fruitCount = { kv1, kv2, kv3, kv4 };

	auto it = fruitCount.begin();
	while (it != fruitCount.end())
	{
		cout << it->first << ": " << it->second << endl;
		it++;
	}

	return 0;
}

运行结果:

已知,initializer_list<value_type> il 是轻量级的、只读的、临时的数组,可以临时存储传过来的多个实参,该数组由编译器生成,形参 il 只是用指针指向该数组,il 本身不存储实参数据。

通过initializer列表,构造函数可以同时接收多个元素,完成初始化。

如果结合C++11,initializer列表+多参数隐式类型转换:

代码示例 3.4.4

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	map<string, int> fruitCount = { {"apple", 20}, {"banana", 40}, 
                                        {"pear", 100}, {"cherry", 10} };

	auto it = fruitCount.begin();
	while (it != fruitCount.end())
	{
		cout << it->first << ": " << it->second << endl;
		it++;
	}

	return 0;
}

首先,四个实参 {"apple", 20}、{"banana", 40}、{"pear", 100}、{"cherry", 10},都是多参数 (两个参数,一个string类型,一个int类型)。map 的元素类型是 pair<string, int>。每个实参,例如 {"apple", 20},两个参数类型与 pair<string, int> 一一对应,因此可以调用 pair 的构造函数,完成隐式类型转化。

然后,相当于四个实参都变成了 pair 对象,再调用 map 的initializer列表构造完成初始化。

initializer_list<T> 是模板类,value_type 是 map 的元素类型------键值对 pair<const key_type, mapped_type>,即 pair<string, int>,所以 pair<string, int> == value_type == T 。il 的类型是 initializer_list< pair<string, int> >,因此 il 指向的数组可以存储这4个 pair<string, int> 对象,完成 map<string, int> 对象 fruitCount 的初始化。

3.5 map 的增删查及其差异

增

insert 插入主要分为以下几种:插入单个元素、在指定位置插入、插入一段迭代器区间中的元素。

插入单个元素:

  • set 系列存储单键 (Key)。元素类型就是键的类型 T,键同时也是值。map 系列存储键值对 (Key/Value)。元素类型是 value_type,键 (Key) 的类型是 key_type,值 (Value) 的类型是 mapped_type,真正存储的元素是 pair<const key_type, mapped_type> 对象,键和值的组合。
  • 插入成功后,insert 会返回一个迭代器,指向新元素的位置。如果插入失败,则返回已存在元素的位置。
  • multiset 和 multimap 支持冗余,允许重复的键 (Key),不需要判断插入成功或失败,只返回了迭代器。set 和 map 不支持冗余,不允许键重复,用 pair<iterator, bool> 作为返回值类型。first 是迭代器,second 是插入结果 (true/false)。

插入一段迭代器区间中的元素:

  • 实现为函数模板,允许其他容器的迭代器区间。
  • 相当于依次取出区间内的元素,逐个插入。也就是循环加上面的插入单个元素。

代码示例 3.5.1

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	map<string, int> fruitCount = { {"apple", 20}, {"banana", 40},
                                        {"pear", 100}, {"cherry", 10} };

	// set/map 不支持冗余,同样地,如果键 (Key) 重复,则会插入失败
	auto retPair = fruitCount.insert({ "apple", 30 }); // 即使值不同也插入失败
	// set/map 可能插入失败,因此返回值类型是 pair<iterator, bool>
    // pair<map<string, int>::iterator, bool>
	if (retPair.second == false)
	{
		// 返回 false,插入失败,返回的迭代器指向已存在元素的位置
		cout << retPair.first->first << ": " << retPair.first->second << endl;
		cout << "insert fail" << endl;
	}

	auto it = fruitCount.begin();
	while (it != fruitCount.end())
	{
		cout << it->first << ": " << it->second << endl;
		it++;
	}

	return 0;
}

运行结果:

需要强调:

  • map 和 set 都不支持冗余。元素不存在,才能插入,返回新插入元素的位置;元素存在,则插入失败,返回已存在元素的位置。
  • multimap 和 multiset 支持冗余。键重复也能插入,只要插入就会成功,统一返回新插入元素的位置;

删

erase 删除共3种:指定位置删除、指定键值删除 、指定区间删除。

注:"键值"是键的值,单纯指键存储的数据,而不是键和值。

指定位置删除:

  • map/multimap/set/multiset 完全一致。获取到某个元素的位置后,传参迭代器即可删除该位置上的元素。

指定键值删除:

  • 需要说明,键 (Key) 是搜索树比较排序的依据,唯一区分数据所在位置。删除与值 (Value) 无关,只与键 (Key) 有关。map/multimap/set/multiset 中的形参 val 和 k 指的都是键 (Key)。
  • map/multimap/set/multiset 需要区分是否支持冗余。如果 k 存在,就删除,支持冗余则删除所有重复元素;如果 k 不存在,就不删除。最终返回实际删除的元素个数,不支持冗余只返回0或1,支持冗余可能返回0/1/多个。

指定区间删除:

  • map/multimap/multiset 和 set 一致,这里的区间指中序遍历得到的序列的区间,默认是升序序列。可以通过 lower_bound(30)、upper_bound(50) 分别获取左闭右开区间 [first, last) 的两端。
  • 区间中,元素存在就删除。multimap/multiset 支持冗余,若有多个重复元素,则全部删除。如果区间中有元素不存在,就不删除。该重载不返回删除元素个数。

代码示例 3.5.2

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	// 水果编号表:first 代表水果编号,second 代表水果名称
	map<int, string> table;
	table.insert({ 1, "apple" });
	table.insert({ 2, "banana" });
	table.insert({ 3, "pear" });
	table.insert({ 4, "apple" });
	table.insert({ 5, "apple" });
	table.insert({ 6, "apple" });
	table.insert({ 7, "cherry" });

	auto it = table.begin();
	while (it != table.end())
	{
		cout << it->first << ": " << it->second << endl;
		it++;
	}

	cout << "\nAfter erase: " << endl;
	// 键 (Key) 是唯一区分元素位置的依据,根据键删除元素
	// 根据键划分区间:删除区间[4, 6]
	table.erase(table.lower_bound(4), table.upper_bound(6));

	it = table.begin();
	while (it != table.end())
	{
		cout << it->first << ": " << it->second << endl;
		it++;
	}

	return 0;
}

运行结果:

代码示例 3.5.3

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	// 水果编号表:first 代表水果编号,second 代表水果名称
	multimap<int, string> table;
	table.insert({ 1, "apple" });
	table.insert({ 2, "banana" });
	table.insert({ 3, "pear" });
	table.insert({ 4, "apple" }); // 多个重复键
	table.insert({ 4, "banana" });
	table.insert({ 4, "pear" });
	table.insert({ 5, "apple" });
	table.insert({ 6, "apple" });
	table.insert({ 7, "cherry" });

	auto it = table.begin();
	while (it != table.end())
	{
		cout << it->first << ": " << it->second << endl;
		it++;
	}

	cout << "\nAfter erase: " << endl;
	// 键重复的一起删除
	// 根据键划分区间:删除区间[4, 6]
	table.erase(table.lower_bound(4), table.upper_bound(6));

	it = table.begin();
	while (it != table.end())
	{
		cout << it->first << ": " << it->second << endl;
		it++;
	}

	return 0;
}

运行结果:

查

find 查找只有1种:根据键值查找。

根据键值查找:

  • val 和 k 都是键 (Key),根据键查找到元素 (set/multiset 的元素就是键,也是值;map/multimap 的元素是键值对,通过键,找到键值对,找到值)。

总结以及其他接口

  • map/multimap/set/multiset 的增删查都与键 (Key) 有关,因为键 (Key) 决定了元素在搜索树中的位置。

map/multimap/set/multiset 的其他接口基本一致,没什么好说的。例如:empty、size、swap、clear、key_comp、value_comp、count、lower_bound、upper_bound。

  • set/multiset 存储单键,键就是值,因此 key_comp 和 value_comp 仿函数其实就是同一个。map/multimap 存储键值对,键值分离,key_comp 和 value_comp 就是键和值各自所属类型的比较规则了。
  • count 计算指定元素在搜索树中的个数,并返回。同样与键 (Key) 有关。键重复就算作同一元素,即使 map/multimap 存储键值对的值 (Value) 不同。
  • lower_bound 返回大于等于的元素的迭代器,找左闭;upper_bound 返回大于的元素的迭代器,找右开。唯一区别就是迭代器指向的元素,set/multiset 的元素是键/值,map/multimap 的元素是键值对。

还有个 equal_range 接口没讲,简单说明一下。

这个接口和 lower_bound/upper_bound 类似,也是找左闭右开区间。但找的是相等元素的左闭右开。

代码示例 3.5.4

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	map<char, int> mymap;

	mymap['a'] = 10;
	mymap['b'] = 20;
	mymap['c'] = 30;

	// 根据键 (Key) 划分区间:找'b'的左闭右开
    // 相当于 lower_bound('b') + upper_bound('b')
    // 前者找大于等于,后者找大于
	auto ret = mymap.equal_range('b'); // ['b', 'c')
    // 右端是开区间,区间内的元素不包括'c'

	cout << "lower bound points to: ";
	cout << ret.first->first << "=>" << ret.first->second << endl;

	cout << "upper bound points to: ";
	cout << ret.second->first << "=>" << ret.second->second << endl;

	return 0;
}

运行结果:

equal_range 更多是为 multimap 和 multiset 设计的。multimap/multiset 支持冗余,可能有多个相等元素,左闭右开区间 [first, last) 可以包含所有重复元素。但 map/set 不支持冗余,元素不可重复,区间内只有一个元素。

3.6 map 的数据修改

set/multiset 不支持修改,因为存储单键,键也是值。map/multimap 支持修改,因为存储键值对,不可修改键,可以修改值。

  • 第一种修改⽅式是通过迭代器 (map/multimap通用)。
  • 第二种修改方式是通过修改接⼝ operator\[\] (multimap不支持,只有map支持)。

同时,operator\[\] 还⽀持插⼊与查找。所以它是⼀个多功能复合接⼝。传给 operator\[\] 函数一个 key,返回一个值 value。注意,返回类型是引用,因此可以直接修改 value。

代码示例 3.6.1

cpp 复制代码
#include <iostream>
#include <map>
using namespace std;

int main()
{
	// 水果名称: 出现次数
	map<string, int> countMap;

	// key 不存在,插入
	countMap["apple"];       // value = int(),内置类型初始化不确定,此处为0
    // key 不存在,插入+修改
	countMap["pear"] = 10;   // value = 10
	countMap["banana"] = 20; // value = 20
	cout << "插入完成:" << endl;
	for (auto& e : countMap)
	{
		cout << e.first << ": " << e.second << endl;
	}

	// key 存在,查找:返回 key 对应的 value
	cout << "查找完成:" << endl;
	cout << countMap["pear"] << endl;

	// key 存在,修改:operator[] 返回 value 的引用
	countMap["banana"] *= 10; // value *= 10
	cout << "修改完成:" << endl;
	for (auto& e : countMap)
	{
		cout << e.first << ": " << e.second << endl;
	}

	return 0;
}

运行结果:

我们知道,对于 insert 插入,插入成功会返回新插入元素的位置,插入失败则返回已存在元素的位置。

insert 接口本身就复合了插入与查找。

因此,insert 可以用来实现 operator\[\]:

  • map::insert 有两层 pair,第一层是 insert 返回的 pair<iterator, bool>,第二层是 iterator 指向的 map 元素 pair<key_type, mapped_type> first。operator\[\] 需要先获取 pair<iterator, bool> 的 first,然后再返回 first->second。

代码示例 3.6.2

cpp 复制代码
// map 的 operator[] 实现
// 元素不存在,insert 充当插入功能,返回新插入元素的位置,并返回对应的 value
// 元素存在,insert 充当查找功能,返回已存在元素的位置,并返回对应的 value
mapped_type& operator[] (const key_type& k)
{
    // value = mapped_type() 相当于用缺省值初始化 value
	pair<iterator, bool> ret = insert({ k, mapped_type() });
    // it 指向新插入的元素
	iterator it = ret.first;
    // map 的元素类型是 pair<const key_type, mapped_type>,返回 second
	return it->second;
}

注意,insert 不能替代 find,除非确定 key 存在。当然,map::insert 会同时返回插入结果,我们可以根据插入结果判断元素原本是否存在。

  • 对于 multimap,键可以重复,一个键可以对应多个值,并且可能各不相同,operator\[\] 无法确定返回哪个值,因此 multimap 无法实现 operator\[\]。

3.7 map 的OJ练习题

3.7.1 随机链表的复制

138. 随机链表的复制 - 力扣 (LeetCode)

难点:由于随机结点,我们不能在遍历链表时,一边完成拷贝一边完成结点的链接关系。因为 random 可能指向未拷贝结点。

之前C语言实现的一般解法:

  • 第一次遍历,先对各个结点进行拷贝,拷贝结点统一放在原结点后⾯。再进行第二次遍历,对各个拷贝结点进行 random 关系的链接。最后一次遍历,将拷贝结点从原链表解离,并完成拷贝结点 next 关系的链接,得到原本的链表和拷贝链表。

代码示例 3.7.1

cpp 复制代码
/**
 * Definition for a Node.
 * struct Node {
 *     int val;
 *     struct Node *next;
 *     struct Node *random;
 * };
 */

struct Node* copyRandomList(struct Node* head) 
{
    // 第一次遍历
	struct Node* cur = head;
    while (cur)
    {
        struct Node* ptail = (struct Node*)malloc(sizeof(struct Node));
        ptail->val = cur->val;
        ptail->next = cur->next;
        cur->next = ptail;
        cur = ptail->next;
    }
    // 第二次遍历
    cur = head;
    while (cur)
    {
        struct Node* copy = cur->next;
        if (cur->random != NULL)
        {
            copy->random = cur->random->next;
        }
        else
        {
            copy->random = NULL;
        }
        cur = copy->next;
    }
    // 第三次遍历
    cur = head;
    struct Node* copyHead = NULL;
    while (cur)
    {
        struct Node* tail = cur->next;
        if (!copyHead)
        {
            copyHead = tail;
        }
        cur->next = tail->next;
        cur = tail->next;
        if (cur)
        {
            tail->next = cur->next;
        }
    }
    return copyHead;
}

使用 map 的C++解法:

  • 直接让 { 原结点, 拷⻉结点 } 建⽴映射关系存入 map。控制随机指针就⾮常⽅便简单。这体现了 map 在解决⼀些问题时的价值,完全是降维打击。

代码示例 3.7.2

cpp 复制代码
class Solution 
{
public:
    Node* copyRandomList(Node* head) 
    {
        if (head == nullptr) return head;
        // 原结点: 拷贝结点
        map<Node*, Node*> m;
        Node* cur = head;
        while (cur)
        {
            Node* copy = new Node(cur->val);
            m[cur] = copy; // 建立原结点与拷贝结点的映射关系
            cur = cur->next;
        }
        cur = head;
        while (cur)
        {
            // 通过 m[cur] 找到当前拷贝结点,m[cur->next] 即对应的后继拷贝结点
            m[cur]->next = m[cur->next]; // 建立当前拷贝结点与后继拷贝结点的链接关系
            // 同理,m[cur->random] 即当前拷贝结点 m[cur] 对应的随机拷贝结点
            m[cur]->random = m[cur->random];
            cur = cur->next;
        }
        // m[head] 得到拷贝链表的头结点
        // 注意 head 为空 nullptr,键 nullptr 不存在,m[head] 不是查找,而是插入
        return m[head];
    }
};

3.7.2 前K个高频单词

692. 前K个高频单词 - 力扣 (LeetCode)

题目分析:该题我们同样可以利⽤ map,建立单词与出现频率的映射关系。第一个要求是,按出现频率由⾼到低排序并返回前K个,这个不难,不管是排序还是优先级队列 (堆) 都可以解决。问题在于第二个条件:如果出现频率相同,不同的单词要按字典顺序排序。

思路一:由于频率可能重复,所以建立 { 单词, 出现频率 } 的映射关系存入 map。map 在存储时,会先按 key 进行排序,即按字典顺序排序。理论上,我们只需要再按出现频率排序即可。这里的问题是排序的稳定性。像 std::sort 底层是快排,快排是不稳定排序,按出现频率排序的同时会破坏字典顺序 (类似的还有堆排和希尔排序)。对于该问题,有两种解决办法:

  • 一、继续使用不稳定排序,但需要控制排序的比较逻辑,同时比较字典顺序和出现频率。
  • 二、使用稳定排序,只需要比较出现频率。稳定排序可用 stable_sort,和 sort 差不多,但底层基于归并排序,是稳定排序。

代码示例 3.7.3

cpp 复制代码
class Solution 
{
    // 仿函数,自定义比较规则
    struct Compare1
    {
        bool operator()(const pair<string, int>& x, const pair<string, int>& y) const
        {
            // 只比较 出现频率
            return x.second > y.second;
        }
    };
    struct Compare2
    {
        bool operator()(const pair<string, int>& x, const pair<string, int>& y) const
        {
            // 同时比较 字典顺序 和 出现频率
            return x.second > y.second || 
                    (x.second == y.second && x.first < y.first);
        }
    };

    // 思路一:排序 (不稳定排序和稳定排序)
public:
    vector<string> topKFrequent(vector<string>& words, int k) 
    {
        // 单词: 出现频率
        // 出现频率可能重复,因此用单词作为 key
        map<string, int> m;
        for (auto &word : words)
        {
            // 插入+修改
            m[word]++;
        }
        // sort 和 stable_sort 都需要使用随机迭代器
        // set/map/multiset/multimap 都是双向迭代器
        // 因此需要将 map 中的键值对存入 vector 数组中
        vector<pair<string, int>> v(m.begin(), m.end());

        // 1. 使用稳定排序,只按出现频率排序
        // stable_sort(v.begin(), v.end(), Compare1()); // 传仿函数1的匿名对象
        // 2. 使用不稳定排序,添加比较逻辑,同时按字典顺序和出现频率进行比较
        sort(v.begin(), v.end(), Compare2()); // 传仿函数2的匿名对象
        
        // 取前K个
        vector<string> ret;
        for (int i = 0; i < k; i++) ret.push_back(v[i].first);

        return ret;
    }
};

思路二:和思路一的不稳定排序基本一致,但采用优先级队列 priority_queue 进行堆排 (不稳定排序),同时控制比较逻辑。

代码示例 3.7.4

cpp 复制代码
class Solution 
{
    // 仿函数,自定义比较规则
    struct Compare
    {
        bool operator()(const pair<string, int>& x, const pair<string, int>& y) const
        {
            // 同时比较 字典顺序 和 出现频率
            // 大堆用小于,小堆用大于
            // return x.second > y.second || (x.second == y.second && x.first < y.first);
            // 这里使用大堆,比较逻辑需要反过来
            return x.second < y.second || 
                    (x.second == y.second && x.first > y.first);
        }
    };

    // 思路二:堆排 + 控制比较逻辑
public:
    vector<string> topKFrequent(vector<string>& words, int k) 
    {
        map<string, int> m;
        for (auto &word : words)
        {
            m[word]++;
        }
        // 将键值对存入大堆,存储过程中根据仿函数自定义的比较规则自动完成堆排
        // priority_queue 是适配器,有三个模板参数依次为:元素类型,底层容器,仿函数
        // 分别对应:pair<string, int>元素类型、vector<pair<string, int>>容器类型、Compare()仿函数
        priority_queue<pair<string, int>, vector<pair<string, int>>, Compare> pq(m.begin(), m.end());

        // 取前K个
        vector<string> ret;
        for (int i = 0; i < k; i++)
        {
            // 大堆,根结点是最大的
            ret.push_back(pq.top().first);
            // 删除根结点,第二大的结点通过调整算法成为新的根结点
            pq.pop();
        }

        return ret;
    }
};

关于思路二的代码优化问题:可以选择改用小堆。

因为大堆是对所有映射关系进行二次排序,实际返回结果只需要前K个,排序的数据量越多越浪费。

用小堆中只需要存储K个映射关系,既减少了数据量,也降低了每次堆排的销毁,只需要排K个数据即可。

根结点是最小的,只需要遍历所有映射关系,不断与根结点比较,堆的根结点更小,就用更大的映射关系置换根结点。堆中最终留下的就是前K个最大结点。

相关推荐
HPtiger18 分钟前
C++ 入门笔记:从 C 到 C++ 的核心过渡(命名空间、输入输出、缺省参数、重载、引用、内联)
c语言·c++·学习
星恒随风26 分钟前
MySQL 从零入门(三):表的约束详解
linux·服务器·数据库·笔记·mysql
fengci.1 小时前
CTF2MISC中等部分
开发语言·前端·学习
vvilkim2 小时前
数据结构类型、区别与应用场景:一篇讲透选型逻辑
数据结构
爱分享的康康2 小时前
车载服务器如何选型|康谋自动驾驶实车测试硬件评估指南
经验分享·车载系统
xys_6783 小时前
JavaEE通关手册:初识Java
java·开发语言
努力努力再努力wz3 小时前
【Lua 进阶系列】从 C++ 对象到 Lua 变量:一文理解 userdata、Lua 栈与元表绑定机制
c++·junit·lua
YueTann3 小时前
mgtv5th-baseline学习
学习
慧都产品智能化4 小时前
Qt 表格升级攻略:复用已有编辑器,减少重复开发
c++·qt·编辑器·qt6.3