深入浅出 STL 之 map 与 set:从入门到实战

前言

在 C++ 标准模板库(STL)中,容器可以分为两大类:序列式容器关联式容器 。序列式容器如 vectorlistdeque 等,元素按线性顺序存储,每个位置没有内在的"意义",交换两个元素不会破坏容器的结构。关联式容器则不同,它们通常基于树或哈希表实现,元素之间通过**关键字(key)**建立紧密的关联关系,交换两个元素可能完全破坏容器的逻辑结构。

mapset 是 STL 中最经典的关联式容器,底层采用红黑树 (一种自平衡二叉搜索树)实现。它们提供了对数时间复杂度的插入、查找、删除操作,并且能够按照关键字的有序 方式进行遍历。本文将从零开始,详细讲解 set / multisetmap / multimap 的使用方法、内部原理、常见陷阱,并结合力扣题目展示它们的实战技巧。全文约 5000 字,适合所有 C++ 开发者阅读。

阅读本文前,建议先了解二叉搜索树的基本概念(可参考我的前一篇文章《深入剖析二叉搜索树》)。


一、序列式容器 vs 关联式容器

在学习具体容器之前,我们先明确两类容器的本质区别。

1.1 序列式容器

逻辑结构 :线性序列。

访问方式 :按元素在容器中的存储位置 (下标或迭代器)访问。

典型代表vectorlistdequearrayforward_list

特点:元素之间没有强制性的比较或顺序关系,你可以随意交换两个元素,容器依然合法。

复制代码
vector<int> v = {3, 1, 4};
swap(v[0], v[2]);   // 变成 {4, 1, 3},仍然是合法的vector

1.2 关联式容器

逻辑结构 :非线性结构(通常是树或哈希表)。

访问方式 :按元素的关键字(key) 访问。

典型代表setmapmultisetmultimap(红黑树实现);unordered_setunordered_map(哈希表实现)。

特点:元素的位置由关键字决定,交换两个不同的关键字元素会破坏容器的排序性质,导致未定义行为。

复制代码
set<int> s = {3, 1, 4};
// swap(*s.begin(), *s.rbegin()); // 绝对不要这样做!会破坏有序性

关联式容器的两大分类

  • 有序关联容器(本章重点):底层为红黑树,元素始终按关键字排序,查找时间复杂度 O(log n)。

  • 无序关联容器(下一章讲解):底层为哈希表,元素无序,查找平均 O(1)。


二、set 系列容器详解

set 代表"集合",它的特点是:存储唯一的关键字,并且按关键字升序排列 。如果你需要存储重复的关键字,应该使用 multiset

2.1 set 的模板声明

复制代码
template < class T,                        // 关键字类型
           class Compare = less<T>,        // 比较仿函数(默认升序)
           class Alloc = allocator<T>      // 空间配置器(一般不改)
         > class set;
  • T:存储在 set 中的元素类型,也就是 key 的类型。

  • Compare:用于比较两个关键字的函数对象,默认 std::less<T> 要求 T 支持 operator<。如果你想实现降序,可以传 std::greater<T>

  • Alloc:内存分配器,几乎永远使用默认值。

2.2 set 的核心特性

  1. 唯一性:不允许重复的关键字。插入重复值时,插入操作失败。

  2. 有序性 :按照 Compare 规则,元素总是有序的。默认升序,中序遍历红黑树得到递增序列。

  3. 不可修改 :因为修改关键字会破坏有序结构,所以 set 的迭代器是常量迭代器const_iterator),不能通过迭代器修改元素值。

  4. 效率:插入、删除、查找都是 O(log n)。

2.3 set 的构造与遍历

常用构造函数

构造函数 说明
set() 空构造
set(InputIterator first, InputIterator last) 迭代器区间构造
set(const set& x) 拷贝构造
set(initializer_list<value_type> il) 列表初始化(C++11)

迭代器

  • begin() / end():正向迭代器(双向迭代器,但只能读)。

  • rbegin() / rend():反向迭代器。

示例代码

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

int main() {
    // 默认升序
    set<int> s1 = {5, 2, 8, 2, 5, 1};  // 重复值自动去重
    for (int x : s1) cout << x << " ";  // 输出:1 2 5 8
    cout << endl;

    // 降序
    set<int, greater<int>> s2 = {5, 2, 8, 2, 5, 1};
    for (int x : s2) cout << x << " ";  // 输出:8 5 2 1
    cout << endl;

    // 迭代器遍历
    set<string> strSet = {"apple", "banana", "cherry"};
    auto it = strSet.begin();
    while (it != strSet.end()) {
        // *it = "xxx";  // 错误:不能修改
        cout << *it << " ";
        ++it;
    }
    return 0;
}

2.4 set 的插入

set 提供了多个 insert 重载:

复制代码
pair<iterator,bool> insert (const value_type& val);          // 插入单个元素
void insert (initializer_list<value_type> il);               // 插入列表
template <class InputIterator>
void insert (InputIterator first, InputIterator last);       // 插入区间

返回值 :单个元素插入返回 pair<iterator, bool>,其中 bool 表示是否插入成功(true=成功插入新元素,false=已存在且未插入),iterator 指向已存在元素或新插入元素的位置。

示例

复制代码
set<int> s;
auto ret = s.insert(10);
if (ret.second) cout << "插入成功,元素:" << *ret.first << endl;
else cout << "已存在" << endl;

s.insert({20, 30, 10});  // 10 已存在,不会重复插入

2.5 set 的查找与计数

成员函数 说明
find(const key_type& k) 返回指向 k 的迭代器,若不存在则返回 end()
count(const key_type& k) 返回 k 在 set 中的个数(只能是 0 或 1,对 multiset 有用)。
lower_bound(const key_type& k) 返回第一个 >= k 的迭代器。
upper_bound(const key_type& k) 返回第一个 > k 的迭代器。

为什么推荐使用 set 自己的 find 而不是全局 find?

全局 std::find 是线性遍历 O(n),而 set::find 利用红黑树特性 O(log n)。数据量大时差异巨大。

复制代码
set<int> s = {10, 20, 30, 40, 50};
auto pos = s.find(30);
if (pos != s.end()) cout << "找到了" << endl;

// 用 count 快速判断存在
if (s.count(25)) cout << "存在" << endl;
else cout << "不存在" << endl;

2.6 set 的删除

复制代码
iterator erase(const_iterator position);          // 删除迭代器指向的元素
size_type erase(const key_type& k);               // 删除所有等于 k 的元素(返回删除个数)
iterator erase(const_iterator first, const_iterator last); // 删除区间

注意erase(key) 返回 size_type,对于 set 最多返回 1;对于 multiset 可能返回 >1。

复制代码
set<int> s = {1,2,3,4,5};
s.erase(3);                     // 删除 3
auto it = s.find(2);
if (it != s.end()) s.erase(it); // 迭代器删除

// 删除区间 [lower, upper)
auto low = s.lower_bound(2);
auto up = s.upper_bound(4);
s.erase(low, up);               // 删除 2,3,4(注意 upper_bound(4) 指向 5)

2.7 multiset 的特点

multisetset 几乎一样,唯一区别是 允许关键字重复。这导致以下行为差异:

  • insert 永远成功(不会因为重复而失败)。

  • find 返回中序遍历下第一个等于该值的元素的迭代器。

  • count 返回实际重复个数。

  • erase(value) 会删除所有等于 value 的元素,返回删除个数。

  • 没有 operator[](set 本来就没有,multiset 也没有)。

    multiset ms = {1,2,2,3,2};
    for (int x : ms) cout << x << " "; // 1 2 2 2 3
    cout << ms.count(2); // 输出 3
    ms.erase(2); // 删除所有 2

2.8 实战:力扣 349. 两个数组的交集

题目 :给定两个数组,返回它们的交集(每个元素唯一,顺序任意)。

思路 :将两个数组分别存入 set 去重并排序,然后利用双指针(因为 set 有序)找出公共元素。

复制代码
class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        set<int> s1(nums1.begin(), nums1.end());
        set<int> s2(nums2.begin(), nums2.end());
        vector<int> ret;
        auto it1 = s1.begin(), it2 = s2.begin();
        while (it1 != s1.end() && it2 != s2.end()) {
            if (*it1 < *it2) ++it1;
            else if (*it1 > *it2) ++it2;
            else {
                ret.push_back(*it1);
                ++it1; ++it2;
            }
        }
        return ret;
    }
};

时间复杂度 O(n log n)(主要是构造 set 的开销),比暴力 O(n²) 优雅得多。

2.9 实战:力扣 142. 环形链表 II

题目 :给定一个链表,返回链表开始入环的第一个节点。如果不带环返回 nullptr。

传统解法 :快慢指针 + 数学推导。

使用 set :遍历链表,将每个节点的地址存入 set,第一个出现重复地址的节点就是环的入口。代码极其简洁,体现了关联容器的"降维打击"。

复制代码
class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
        set<ListNode*> s;
        ListNode* cur = head;
        while (cur) {
            if (s.count(cur)) return cur;  // 或者用 find
            s.insert(cur);
            cur = cur->next;
        }
        return nullptr;
    }
};

注意:空间复杂度 O(n),而快慢指针法 O(1)。但在面试或竞赛中,如果空间充足,使用 set 可以大幅降低编码难度。


三、map 系列容器详解

map 是"映射"表,存储的是键值对(key-value pair) 。每个 key 唯一,通过 key 快速访问对应的 value。multimap 允许 key 重复。

3.1 map 的模板声明

复制代码
template < class Key,                     // 关键字类型
           class T,                       // 映射值类型
           class Compare = less<Key>,     // 比较仿函数
           class Alloc = allocator<pair<const Key,T>> >
         class map;
  • Key:键的类型,不能修改。

  • T:值的类型,可以修改。

  • 内部存储的节点类型是 pair<const Key, T>,键部分为 const,确保不会破坏排序。

3.2 pair 类型

pair 是一个简单的结构体,定义在 <utility>(但 <map> 已包含)。它有两个公有成员:firstsecond

复制代码
template <class T1, class T2>
struct pair {
    T1 first;
    T2 second;
    pair();
    pair(const T1& a, const T2& b);
    // ... 其他构造函数
};

常用辅助函数:make_pair 可以自动推导类型。

3.3 map 的构造与基本使用

构造函数与 set 类似,支持空构造、区间构造、拷贝构造、列表初始化。

复制代码
map<string, string> dict = {
    {"left", "左边"},
    {"right", "右边"},
    {"insert", "插入"}
};
// 或者使用 initializer_list
dict.insert({"auto", "自动的"});

// 遍历
for (const auto& kv : dict) {
    cout << kv.first << " -> " << kv.second << endl;
}

迭代器的使用注意 :迭代器指向的是 pair<const Key, T>,所以可以通过 it->second 修改值,但不能修改 it->first

3.4 map 的插入

insert 的签名与 set 类似,但参数是 pair<const Key, T>

复制代码
pair<iterator,bool> insert (const value_type& val);

多种插入写法

复制代码
map<string, int> m;
// 1. 使用 pair 构造函数
m.insert(pair<string, int>("apple", 1));
// 2. 使用 make_pair
m.insert(make_pair("banana", 2));
// 3. 使用 {}(C++11 统一初始化)
m.insert({"cherry", 3});
// 4. 直接用 [] 赋值(后面会讲)
m["durian"] = 4;

返回值与 set 含义相同:first 指向已存在或新插入的迭代器,second 表示是否插入成功。

3.5 map 的查找与删除

  • find(key):返回指向 key 所在节点的迭代器,若不存在返回 end()

  • count(key):返回 key 出现的次数(0 或 1)。

  • erase(key):删除 key 及其映射值,返回删除个数(0 或 1)。

  • 其他与 set 基本一致。

    auto it = dict.find("left");
    if (it != dict.end()) {
    cout << it->second << endl; // 输出:左边
    it->second = "左侧"; // 修改 value 合法
    }

    int n = dict.erase("right"); // n == 1

3.6 map 的 operator\[\] ------ 多功能神器

map 最独特也最容易让人困惑的接口就是 operator[]。它的声明如下:

复制代码
mapped_type& operator[] (const key_type& k);

行为

  • 如果 k 在 map 中,返回对应 value 的引用。

  • 如果 k 不在 map 中,则自动插入一个键值对 (k, mapped_type()),其中 mapped_type() 是值类型的默认构造值(例如 int 为 0,string 为空字符串),然后返回该 value 的引用。

内部实现原理(简化):

复制代码
mapped_type& operator[](const key_type& k) {
    // insert 返回 pair<iterator, bool>,无论插入成功还是失败,iterator 都指向 k 所在节点
    pair<iterator, bool> ret = insert({k, mapped_type()});
    return ret.first->second;   // 返回 value 的引用
}

因此 operator[] 实际上完成了:查找 + 插入(若不存在)+ 返回 value 引用。这使得代码可以非常简洁。

典型应用:统计单词频率

复制代码
map<string, int> freq;
string words[] = {"apple", "banana", "apple", "apple", "banana"};
for (const auto& w : words) {
    freq[w]++;   // 第一次遇到 w 时插入并初始化为 0,然后 ++ 变成 1;后续遇到直接 ++
}
for (const auto& p : freq) {
    cout << p.first << " : " << p.second << endl;
}

注意陷阱

  • operator[] 在访问不存在的 key 时会插入 一个新元素。如果只是想查询,用 find 更安全。

  • 不要试图用 operator[] 来遍历 map,它会插入新的默认元素,改变容器大小。

3.7 multimap 的特点

multimap 允许 key 重复,因此:

  • insert 总是成功。

  • find 返回中序第一个匹配的迭代器。

  • count 返回重复次数。

  • erase(key) 删除所有该 key 的元素。

  • 不支持 operator[],因为 key 不唯一,无法通过一个 key 确定唯一的 value。

multimap 常用于一对多的映射,例如一个学生可以选修多门课程。

复制代码
multimap<string, string> courses;
courses.insert({"张三", "数学"});
courses.insert({"张三", "英语"});
auto range = courses.equal_range("张三");  // 返回 pair<iterator,iterator> 表示区间
for (auto it = range.first; it != range.second; ++it)
    cout << it->second << " ";

3.8 实战:力扣 138. 随机链表的复制

题目 :一个链表,每个节点除了 next 指针,还有一个 random 指针指向链表中的任意节点或空。要求深拷贝原链表。

常规解法 :在原链表中穿插拷贝节点,再拆分,极其繁琐。

使用 map :第一遍遍历,建立原节点 → 拷贝节点的映射;第二遍遍历,利用映射设置 nextrandom。代码清晰易懂。

复制代码
class Solution {
public:
    Node* copyRandomList(Node* head) {
        if (!head) return nullptr;
        map<Node*, Node*> nodeMap;
        Node* cur = head;
        // 第一遍:创建新节点,建立映射
        while (cur) {
            nodeMap[cur] = new Node(cur->val);
            cur = cur->next;
        }
        // 第二遍:连接 next 和 random
        cur = head;
        while (cur) {
            nodeMap[cur]->next = nodeMap[cur->next];
            nodeMap[cur]->random = nodeMap[cur->random];
            cur = cur->next;
        }
        return nodeMap[head];
    }
};

时间复杂度 O(n),空间复杂度 O(n)。比传统方法简单了不止一个数量级。

3.9 实战:力扣 692. 前K个高频单词

题目:给一个单词列表,返回出现频率最高的 k 个单词。若频率相同,按字典序从小到大排列。

分析 :先用 map 统计频率(map 自动按单词字典序排序),然后将键值对放入 vector,再按照频率降序、字典序升序的自定义规则排序,取前 k 个。

解法一:使用 stable_sort

由于 map 已经按照字典序排好,我们只需要按频率稳定排序即可(相同频率时保持原来的字典序顺序)。stable_sort 是稳定的排序算法。

复制代码
class Solution {
public:
    vector<string> topKFrequent(vector<string>& words, int k) {
        map<string, int> cnt;
        for (auto& w : words) cnt[w]++;
        vector<pair<string, int>> v(cnt.begin(), cnt.end());
        // 按频率降序,稳定排序保证相同频率时字典序小的在前面
        stable_sort(v.begin(), v.end(),
            [](const pair<string,int>& a, const pair<string,int>& b) {
                return a.second > b.second;
            });
        vector<string> res;
        for (int i = 0; i < k; ++i) res.push_back(v[i].first);
        return res;
    }
};

解法二:自定义比较的 sort

直接使用 sort,在比较函数中同时判断频率和字典序。

复制代码
vector<string> topKFrequent(vector<string>& words, int k) {
    map<string, int> cnt;
    for (auto& w : words) cnt[w]++;
    vector<pair<string, int>> v(cnt.begin(), cnt.end());
    sort(v.begin(), v.end(),
        [](const pair<string,int>& a, const pair<string,int>& b) {
            return a.second > b.second || (a.second == b.second && a.first < b.first);
        });
    vector<string> res;
    for (int i = 0; i < k; ++i) res.push_back(v[i].first);
    return res;
}

解法三:使用优先队列(最小堆)

也可以用小根堆只保留前 k 个,但注意自定义比较器的写法(优先队列默认大堆,比较函数与 sort 相反)。这里不再展开。


四、总结与进阶建议

4.1 核心知识点回顾

容器 是否有序 是否允许重复 key 是否允许修改 key 是否允许修改 value operator\[\]
set 是(升序/降序) 否(const迭代器) ---
multiset ---
map 是(按key升序) 是(通过迭代器或\[\]) 有(多功能)
multimap

4.2 使用建议

  1. 需要集合(元素唯一且有序)set

  2. 需要字典映射(key 唯一,value 可变)map

  3. 需要一对多映射或允许重复 keymultimapmultiset

  4. 只关心存在性,不关心顺序unordered_set / unordered_map(哈希实现,O(1) 平均)。

  5. 不要再自己手写二叉搜索树,除非学习目的。工程上直接用 STL 容器。

4.3 性能与注意事项

  • 红黑树的插入、删除、查找都是 O(log n),但常数较大。对于小数据量(< 1000),vector + 线性查找可能更快。

  • 避免在循环中频繁使用 operator[] 进行查找,因为它会在缺失时插入,改变容器大小。

  • erase 在迭代器失效方面:maperase(iterator) 只使被删迭代器失效,其他迭代器(包括 end())仍然有效。这是与 vector 的重要区别。

  • 尽量使用 const_iterator 如果你不打算修改元素,以表明意图。

4.4 延伸学习

  • 无序关联容器unordered_setunordered_map。它们基于哈希表,查找平均 O(1),但元素无序,且需要提供哈希函数。

  • 红黑树原理 :理解旋转、颜色规则,对理解 map 的平衡机制有帮助。

  • 自定义比较函数 :当你的 key 类型不支持 operator< 或者你需要特殊比较逻辑时,可以传入仿函数或 lambda。


结语

mapset 是 C++ STL 中最常用、最强大的容器之一。掌握它们不仅能让你的代码更加简洁高效,还能在算法竞赛和日常开发中如虎添翼。本文从基本概念到进阶实战,涵盖了所有常用接口和典型场景。希望你能够亲自敲一遍示例代码,并尝试解决文中的力扣题目。如果遇到问题,欢迎在评论区交流讨论。

相关推荐
牛油果子哥q1 小时前
【C++封装】C++封装思想与访问权限终极精讲:public/private/protected权限解析、类封装设计、继承权限变化、工程私有化规范与面试坑点
c++·面试
织梦旅途1 小时前
C++ 第一课 从 Hello Word!立刻开始
c++
.千余1 小时前
【C++】 String 常用操作:增删查改 | 查找 | 截取 | IO
java·服务器·开发语言·c++·笔记·学习
码云骑士1 小时前
【Java基础】JDK安装常见问题教辅-从踩坑到排雷
java·开发语言
jelly酱1 小时前
Qt 坐标体系入门:从 GUI 概念到坐标实践
c++
代码改善世界1 小时前
【C++进阶】哈希表封装unordered_map和unordered_set
c++·哈希算法·散列表
c238561 小时前
C++ lambda 表达式详细介绍
开发语言·c++
艾莉丝努力练剑1 小时前
【QT】界面优化:QSS
linux·运维·开发语言·网络·qt·计算机网络·udp
jieyucx1 小时前
站在云原生高并发天花板:拆解 Go 语言 GMP 模型与 I/O 多路复用的神级配合
开发语言·云原生·golang