关联式容器:set 与 multiset 的有序存储

关联式容器:set 与 multiset 的有序存储

在C++ STL容器家族中,关联式容器与序列式容器(vector、list、deque)的核心区别的是:序列式容器按插入顺序存储,关联式容器按关键字有序存储。而set与multiset,作为关联式容器中最基础、最常用的两个,更是将"有序"这一特性发挥到极致,成为需要有序去重、有序存储场景的首选工具。

很多开发者在使用时,容易混淆set与multiset的区别,也不清楚它们"有序"的底层逻辑,导致出现"想去重却用了multiset""想保留重复元素却用了set""频繁插入删除后有序性异常"等问题。更有甚者,在需要有序存储时,盲目用vector排序后维护,忽略了set/multiset自带的自动排序、高效查找特性,导致代码冗余、性能低下。

今天这篇博客,我们聚焦set与multiset的核心------有序存储,从底层原理简化解析、核心特性对比、基础用法、进阶实操,到适用场景与避坑指南,用通俗的讲解+可直接运行的示例代码,帮你彻底吃透这两个容器,搞懂"它们为什么能自动有序""什么时候用set、什么时候用multiset""怎么高效运用它们的有序特性"。

一、先搞懂核心:set 与 multiset 是什么?

set与multiset同属于STL中的有序关联式容器(基于红黑树实现,后续简化讲解),它们的核心功能都是"按关键字有序存储元素",区别仅在于"是否允许存储重复元素"------这也是我们区分两者的关键,没有其他本质差异。

1. 核心定义(精准不冗余)

  • set(集合):存储唯一关键字(元素),自动按关键字升序(默认)排序,不允许重复元素。插入重复元素时,会被自动忽略,不会报错。

  • multiset(多重集合):存储可重复关键字(元素),自动按关键字升序(默认)排序,允许重复元素。插入重复元素时,会全部保留,且按排序规则插入到对应位置,维持整体有序。

一句话总结:两者都有序,唯一区别是set去重,multiset允许重复

2. 底层原理简化解析(不用懂红黑树源码,知其然即可)

set与multiset之所以能实现"自动有序、高效查找",核心是底层基于红黑树(一种自平衡的二叉搜索树)实现。红黑树的特性决定了set/multiset的优势:

  • 自动排序:插入元素时,红黑树会根据元素的关键字(即元素本身),自动将其插入到合适的位置,维持树的有序性,因此set/multiset的元素始终是有序的,无需手动排序。

  • 高效操作:查找、插入、删除元素的时间复杂度均为O(log n)(红黑树的高度是log n级别),远优于vector(查找O(n))、list(查找O(n))。

  • 去重机制(仅set):set在插入元素时,会先通过红黑树查找该元素是否已存在(O(log n)效率),若存在则忽略插入,实现自动去重。

补充:set/multiset的"有序"是严格弱序(默认升序),也可以自定义排序规则(如降序、按自定义结构体字段排序),后续会详细讲解。

3. set vs multiset 核心特性对比表(一目了然)

为了快速区分两者,避免用错,这里整理了核心特性对比(聚焦有序存储、去重、操作效率等关键维度):

特性 set(集合) multiset(多重集合)
有序性 自动按关键字升序(默认),维持有序 自动按关键字升序(默认),维持有序
重复元素 不允许,插入重复元素会被忽略 允许,重复元素全部保留,按序排列
关键字(元素) 关键字唯一,元素即关键字(不可修改) 关键字可重复,元素即关键字(不可修改)
插入/删除/查找效率 O(log n)(基于红黑树) O(log n)(基于红黑树,与set一致)
迭代器特性 双向迭代器,支持++、--,不支持随机访问 双向迭代器,与set完全一致
核心用途 有序去重存储、高效查找 有序可重复存储、统计重复元素个数
关键提醒:set和multiset中的元素(关键字)不可修改!因为元素是红黑树的节点,修改元素值会破坏红黑树的有序性,导致容器失效。如果需要修改元素,只能先删除旧元素,再插入新元素。

二、基础用法:从初始化到核心操作(附示例,可直接复制)

使用set与multiset前,需包含头文件 #include <set>,并使用std命名空间(或显式调用std::set、std::multiset)。由于两者用法高度一致,我们统一讲解,重点标注差异点。

1. 初始化(5种常用方式,set和multiset通用)

初始化时可指定排序规则(默认升序),以下示例默认升序,自定义排序后续单独讲解。

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

int main() {
    // 方式1:默认初始化,空容器,默认升序
    set<int> s1;
    multiset<int> ms1;
    
    // 方式2:初始化时插入多个元素(C++11及以上,初始化列表,最简洁)
    set<int> s2 = {3, 1, 4, 1, 5}; // 自动去重+排序,最终s2: {1, 3, 4, 5}
    multiset<int> ms2 = {3, 1, 4, 1, 5}; // 允许重复+排序,最终ms2: {1, 1, 3, 4, 5}
    
    // 方式3:通过已有容器/数组初始化(拷贝构造,左闭右开区间)
    int arr[] = {2, 7, 1, 8, 2};
    set<int> s3(arr, arr + 5); // 去重排序:{1, 2, 7, 8}
    multiset<int> ms3(arr, arr + 5); // 重复排序:{1, 2, 2, 7, 8}
    
    // 方式4:拷贝构造(从另一个set/multiset拷贝)
    set<int> s4(s2); // s4与s2完全一致:{1, 3, 4, 5}
    multiset<int> ms4(ms2); // ms4与ms2完全一致:{1, 1, 3, 4, 5}
    
    // 方式5:指定排序规则初始化(示例:降序,后续详解)
    set<int, greater<int>> s5 = {3, 1, 4, 1, 5}; // 降序+去重:{5, 4, 3, 1}
    multiset<int, greater<int>> ms5 = {3, 1, 4, 1, 5}; // 降序+重复:{5, 4, 3, 1, 1}
    
    // 打印测试(后续讲遍历方式,此处暂用简单遍历)
    cout << "s2: ";
    for (auto it = s2.begin(); it != s2.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl; // 输出:1 3 4 5
    
    cout << "ms2: ";
    for (auto it = ms2.begin(); it != ms2.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl; // 输出:1 1 3 4 5
    
    return 0;
}

2. 核心操作(高频必学,区分set与multiset差异)

set与multiset的大部分操作完全一致,差异主要集中在"插入重复元素"和"删除重复元素"上,重点标注差异点。

(1)插入元素:insert()(核心,体现有序+去重特性)

插入元素时,容器会自动排序并(set)去重,时间复杂度O(log n),支持插入单个元素、多个元素、区间元素。

  • set:插入重复元素,返回"插入失败"的标识(pair类型,first是迭代器,second是bool值,false表示插入失败);

  • multiset:插入重复元素,直接成功,返回指向插入元素的迭代器,无bool标识。

cpp 复制代码
set<int> s = {1, 3, 4};
multiset<int> ms = {1, 3, 4};

// 1. 插入单个元素
// set插入重复元素(1已存在),插入失败
auto res1 = s.insert(1);
cout << "set插入1:" << (res1.second ? "成功" : "失败") << endl; // 输出:失败
// set插入新元素(2),插入成功
auto res2 = s.insert(2);
cout << "set插入2:" << (res2.second ? "成功" : "失败") << endl; // 输出:成功,s变为{1,2,3,4}

// multiset插入重复元素(1已存在),插入成功
auto it_ms = ms.insert(1);
cout << "multiset插入1,插入位置元素:" << *it_ms << endl; // 输出:1,ms变为{1,1,3,4}
// multiset插入新元素(2),插入成功
ms.insert(2); // ms变为{1,1,2,3,4}

// 2. 插入多个元素(初始化列表)
s.insert({5, 6, 2}); // 去重+排序,s变为{1,2,3,4,5,6}
ms.insert({5, 6, 2}); // 重复+排序,ms变为{1,1,2,2,3,4,5,6}

// 3. 插入区间元素(从另一个容器拷贝)
set<int> s_temp = {7, 8};
s.insert(s_temp.begin(), s_temp.end()); // s变为{1,2,3,4,5,6,7,8}
multiset<int> ms_temp = {7, 8, 8};
ms.insert(ms_temp.begin(), ms_temp.end()); // ms变为{1,1,2,2,3,4,5,6,7,8,8}
(2)删除元素:erase()(3种常用方式,注意multiset删除重复元素的细节)

删除元素时,时间复杂度O(log n),支持按迭代器删除、按值删除、按区间删除,set与multiset的差异在于"按值删除"时的行为。

  • set:按值删除,最多删除1个元素(因为无重复),返回删除的元素个数(0或1);

  • multiset:按值删除,会删除所有等于该值的元素,返回删除的元素个数。

cpp 复制代码
set<int> s = {1, 2, 3, 4, 5};
multiset<int> ms = {1, 1, 2, 3, 4, 5, 5, 5};

// 方式1:按迭代器删除(删除指定位置元素)
auto it_s = s.begin();
++it_s; // 指向2
s.erase(it_s); // 删除2,s变为{1,3,4,5}

auto it_ms = ms.begin();
++it_ms; // 指向第一个1
ms.erase(it_ms); // 删除第一个1,ms变为{1,2,3,4,5,5,5}

// 方式2:按值删除(核心差异点)
int del_val = 5;
// set按值删除5,删除1个(若存在)
int s_del_cnt = s.erase(del_val);
cout << "set删除" << del_val << ",删除个数:" << s_del_cnt << endl; // 输出:1,s变为{1,3,4}

// multiset按值删除5,删除所有等于5的元素
int ms_del_cnt = ms.erase(del_val);
cout << "multiset删除" << del_val << ",删除个数:" << ms_del_cnt << endl; // 输出:3,ms变为{1,2,3,4}

// 方式3:按区间删除(左闭右开,删除[s.begin(), s.end()-1])
auto it1 = s.begin();
auto it2 = s.end();
--it2; // 指向4
s.erase(it1, it2); // 删除1、3,s变为{4}

auto it3 = ms.begin();
auto it4 = ms.begin();
++it4;
++it4; // 指向3
ms.erase(it3, it4); // 删除1、2,ms变为{3,4}
(3)查找元素:find()(高效查找,核心优势)

基于红黑树的查找,时间复杂度O(log n),远优于vector和list的O(n),用法一致。

  • 查找成功:返回指向该元素的迭代器;

  • 查找失败:返回容器的end()迭代器(不可解引用)。

cpp 复制代码
set<int> s = {1, 3, 4, 6, 8};
multiset<int> ms = {1, 3, 3, 4, 6, 8, 8};

// 查找存在的元素
auto it_s1 = s.find(4);
if (it_s1 != s.end()) {
    cout << "set找到元素:" << *it_s1 << endl; // 输出:4
}

auto it_ms1 = ms.find(3);
if (it_ms1 != ms.end()) {
    cout << "multiset找到元素:" << *it_ms1 << endl; // 输出:3(返回第一个匹配的元素)
}

// 查找不存在的元素
auto it_s2 = s.find(5);
if (it_s2 == s.end()) {
    cout << "set未找到元素5" << endl; // 输出:未找到
}

auto it_ms2 = ms.find(7);
if (it_ms2 == ms.end()) {
    cout << "multiset未找到元素7" << endl; // 输出:未找到
}
(4)其他高频操作(set与multiset通用)
  • size():返回容器中元素的个数,时间复杂度O(1);

  • empty():判断容器是否为空(size() == 0),返回bool值,高效推荐;

  • clear():清空容器中所有元素,释放红黑树节点内存,size变为0;

  • count(val):统计容器中值为val的元素个数,set返回0或1,multiset返回实际重复个数(O(log n + k),k为重复个数);

  • swap():交换两个同类型容器的元素,时间复杂度O(1)(仅交换红黑树的根节点,无需拷贝元素)。

cpp 复制代码
set<int> s = {1, 3, 4};
multiset<int> ms = {1, 1, 3, 4};

cout << "s的大小:" << s.size() << endl; // 输出:3
cout << "ms是否为空:" << (ms.empty() ? "是" : "否") << endl; // 输出:否

cout << "s中3的个数:" << s.count(3) << endl; // 输出:1
cout << "ms中1的个数:" << ms.count(1) << endl; // 输出:2

// 交换容器
set<int> s_temp = {5, 6};
s.swap(s_temp);
cout << "交换后s的元素:";
for (auto val : s) { cout << val << " "; } // 输出:5 6

// 清空容器
ms.clear();
cout << "清空后ms的大小:" << ms.size() << endl; // 输出:0

3. 遍历方式(3种常用,适配双向迭代器)

set与multiset的迭代器是双向迭代器,支持++、--操作,不支持随机访问(无法用下标[ ]访问,无法用+ n、-n跳转),常用3种遍历方式,两者用法完全一致。

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

int main() {
    set<int> s = {1, 3, 4, 6, 8};
    multiset<int> ms = {1, 3, 3, 4, 6, 8, 8};
    
    // 方式1:迭代器遍历(最通用,推荐)
    // 普通迭代器(可读,不可修改元素)
    cout << "set迭代器遍历:";
    set<int>::iterator it_s;
    for (it_s = s.begin(); it_s != s.end(); ++it_s) {
        cout << *it_s << " "; // 输出:1 3 4 6 8
    }
    cout << endl;
    
    // 常量迭代器(只读,更安全)
    cout << "multiset常量迭代器遍历:";
    multiset<int>::const_iterator cit_ms;
    for (cit_ms = ms.cbegin(); cit_ms != ms.cend(); ++cit_ms) {
        cout << *cit_ms << " "; // 输出:1 3 3 4 6 8 8
    }
    cout << endl;
    
    // 方式2:范围for循环(C++11及以上,最简洁,推荐)
    cout << "set范围for遍历:";
    for (int val : s) {
        cout << val << " "; // 输出:1 3 4 6 8
    }
    cout << endl;
    
    // 方式3:反向迭代器(从尾部到头部遍历,逆序)
    cout << "multiset反向遍历:";
    multiset<int>::reverse_iterator rit_ms;
    for (rit_ms = ms.rbegin(); rit_ms != ms.rend(); ++rit_ms) {
        cout << *rit_ms << " "; // 输出:8 8 6 4 3 3 1
    }
    cout << endl;
    
    // 错误示例:不支持随机访问,无法用下标访问
    // cout << s[0] << endl; // 编译报错
    // auto it = s.begin() + 2; // 编译报错,只能用++、--跳转
    
    return 0;
}

三、进阶用法:自定义排序与自定义类型存储

set与multiset默认按"元素升序"排序,但实际开发中,我们经常需要自定义排序规则(如降序、按结构体字段排序),或存储自定义类型(结构体、类),这也是有序存储的核心进阶场景。

1. 自定义排序规则(3种常用方式)

自定义排序的核心是:向set/multiset传递"排序函数"或"函数对象",告诉容器如何比较元素大小,维持有序性。

方式1:使用STL内置函数对象(最简单,适用于内置类型)

STL提供了greater<T>(降序)、less<T>(升序,默认)两个函数对象,直接作为模板参数即可。

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

int main() {
    // 方式1:降序排序(内置greater函数对象)
    set<int, greater<int>> s_desc = {3, 1, 4, 1, 5}; // 降序+去重:{5,4,3,1}
    multiset<int, greater<int>> ms_desc = {3, 1, 4, 1, 5}; // 降序+重复:{5,4,3,1,1}
    
    cout << "set降序遍历:";
    for (auto val : s_desc) { cout << val << " "; } // 输出:5 4 3 1
    cout << endl;
    
    cout << "multiset降序遍历:";
    for (auto val : ms_desc) { cout << val << " "; } // 输出:5 4 3 1 1
    cout << endl;
    
    return 0;
}
方式2:自定义全局排序函数(适用于简单自定义规则)

定义一个返回bool值的全局函数,作为模板参数传递给容器,函数参数为两个元素,返回"a是否应该排在b前面"。

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

// 自定义排序规则:按元素的绝对值降序排序
bool cmpAbsDesc(int a, int b) {
    return abs(a) > abs(b); // 绝对值大的排在前面
}

int main() {
    set<int, decltype(cmpAbsDesc)*> s(cmpAbsDesc); // 传递排序函数
    multiset<int, decltype(cmpAbsDesc)*> ms(cmpAbsDesc);
    
    s.insert({-5, 3, -2, 5, 1}); // 去重+按绝对值降序:{5,-5,3,-2,1}
    ms.insert({-5, 3, -2, 5, 1}); // 重复+按绝对值降序:{-5,5,3,-2,1}
    
    cout << "set按绝对值降序:";
    for (auto val : s) { cout << val << " "; }
    cout << endl;
    
    cout << "multiset按绝对值降序:";
    for (auto val : ms) { cout << val << " "; }
    cout << endl;
    
    return 0;
}
方式3:自定义函数对象(适用于复杂规则、自定义类型)

定义一个结构体/类,重载()运算符(成为函数对象),在运算符中实现排序规则,适用于自定义类型或复杂的排序逻辑。

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

// 自定义结构体:学生信息
struct Student {
    string name;
    int age;
    int score;
    
    // 构造函数(方便初始化)
    Student(string n, int a, int s) : name(n), age(a), score(s) {}
};

// 自定义函数对象:按学生成绩降序排序,成绩相同按年龄升序
struct CmpStudent {
    bool operator()(const Student& a, const Student& b) const {
        if (a.score != b.score) {
            return a.score > b.score; // 成绩降序
        } else {
            return a.age < b.age; // 成绩相同,年龄升序
        }
    }
};

int main() {
    // 存储自定义结构体,使用自定义函数对象排序
    set<Student, CmpStudent> s_stu;
    multiset<Student, CmpStudent> ms_stu;
    
    // 插入学生信息(自动按规则排序+去重)
    s_stu.insert(Student("张三", 18, 90));
    s_stu.insert(Student("李四", 19, 85));
    s_stu.insert(Student("王五", 18, 90)); // 成绩相同、年龄相同(假设),会被去重
    s_stu.insert(Student("赵六", 17, 95));
    
    ms_stu.insert(Student("张三", 18, 90));
    ms_stu.insert(Student("王五", 18, 90)); // 成绩相同、年龄相同,允许重复,会相邻排列
    
    // 遍历输出
    cout << "set学生信息(按成绩降序):" << endl;
    for (const auto& stu : s_stu) {
        cout << "姓名:" << stu.name << ",年龄:" << stu.age << ",成绩:" << stu.score << endl;
    }
    
    cout << "\nmultiset学生信息:" << endl;
    for (const auto& stu : ms_stu) {
        cout << "姓名:" << stu.name << ",年龄:" << stu.age << ",成绩:" << stu.score << endl;
    }
    
    return 0;
}

注意:存储自定义类型时,必须指定排序规则(函数对象或全局函数),否则编译器无法判断如何比较元素大小,会报错。

四、适用场景:set 与 multiset 什么时候用?(精准匹配)

结合两者的有序特性和去重差异,以下场景优先选择set或multiset,替代vector、list等序列式容器,提升代码效率和简洁度。

1. set 的适用场景(有序+去重)

  • 有序去重存储:需要存储不重复的元素,且希望元素自动有序,无需手动排序(如存储用户ID、唯一编号,要求有序且不重复);

  • 高效查找:需要频繁查找元素,且元素个数较多(n较大),O(log n)效率远优于vector/list(如查询某个用户ID是否存在);

  • 有序集合运算:需要实现集合的交集、并集、差集(可配合STL算法set_intersection、set_union等),如两个有序去重集合的合并去重。

典型示例:用户ID管理(有序存储,不重复,快速查询)、考试成绩排名(去重,按成绩有序)、关键词去重并排序。

2. multiset 的适用场景(有序+可重复)

  • 有序可重复存储:需要存储重复元素,且希望元素自动有序,保留重复个数(如统计多个班级的成绩,允许相同成绩,按成绩有序排列);

  • 重复元素统计:需要统计某个元素的重复次数(配合count()函数),且元素需有序(如统计单词出现次数,按单词字典序排列);

  • 有序队列(允许重复):需要一个有序的队列,允许插入重复元素,且能高效查找、删除(如任务优先级队列,允许相同优先级的任务)。

典型示例:成绩统计(保留所有成绩,按成绩有序,统计某分数的人数)、单词频率统计(按单词字典序,统计出现次数)、相同优先级任务的有序管理。

3. 不适用场景(避坑指南)

以下场景坚决不用set/multiset,否则会导致性能低下或代码冗余:

  • 需要修改元素值:set/multiset的元素不可修改,修改需删除再插入,操作繁琐,若频繁修改,优先用vector(排序后维护);

  • 需要随机访问元素:set/multiset不支持随机访问,若频繁按下标访问,优先用vector;

  • 元素无需有序,仅需去重:若无需有序,仅需去重,优先用unordered_set(哈希表实现,插入、查找效率O(1),比set更快);

  • 频繁在中间插入/删除,且无需有序:优先用list(O(1)效率),set/multiset的插入删除效率O(log n),且需维持有序,不如list高效。

五、高频避坑总结(新手必看)

结合日常开发和笔试面试中的高频错误,总结5个核心避坑点,帮你规避隐患,写出更安全、高效的代码。

避坑1:试图修改set/multiset中的元素

错误:直接通过迭代器修改元素值(如 *it = 10;),编译报错或导致容器有序性失效。

解决方案:先删除旧元素,再插入新元素,示例如下:

cpp 复制代码
set<int> s = {1, 3, 4};
auto it = s.find(3);
// 错误:*it = 5; // 编译报错,元素不可修改
// 正确:删除旧元素,插入新元素
s.erase(it);
s.insert(5); // s变为{1,4,5}

避坑2:混淆set与multiset的去重特性

错误:需要去重却用了multiset,导致存储重复元素;需要保留重复元素却用了set,导致元素被丢失。

解决方案:牢记"去重用set,重复用multiset",插入前明确是否允许重复元素。

避坑3:用下标[ ]访问set/multiset元素

错误:习惯性用vector的方式,通过下标访问元素(如 s[0]),编译报错。

原因:set/multiset的迭代器是双向迭代器,不支持随机访问,无下标访问功能。

解决方案:访问元素用迭代器遍历,或用find()查找后访问。

避坑4:自定义类型未指定排序规则

错误:存储自定义结构体/类时,未传递排序规则,编译器无法比较元素大小,编译报错。

解决方案:必须指定排序规则(函数对象或全局函数),作为set/multiset的模板参数。

避坑5:频繁插入删除,优先用set/multiset

错误:元素无需有序,仅需频繁插入删除,却用set/multiset,导致性能浪费(O(log n) vs list的O(1))。

解决方案:无需有序时,频繁插入删除用list;无需有序但需去重,用unordered_set。

六、总结:set 与 multiset 的核心价值

set与multiset的核心价值,在于**"自动有序+高效操作"**------它们帮我们封装了红黑树的底层实现,无需手动排序、无需关心去重(set),就能实现有序存储,同时提供O(log n)的插入、删除、查找效率,大幅简化代码,提升性能。

最后,用一句口诀帮你快速记住两者的用法和场景:

有序去重选set,重复有序multiset;高效查找是优势,不可修改要注意;无需有序别滥用,选错容器伤性能

希望本文的讲解,能帮你彻底吃透set与multiset,在需要有序存储的场景中,灵活运用这两个容器,避免踩坑,写出更高效、更简洁的C++代码。

相关推荐
追随者永远是胜利者1 小时前
(LeetCode-Hot100)72. 编辑距离
java·算法·leetcode·职场和发展·go
musenh1 小时前
springmvc学习
java·学习
未来龙皇小蓝1 小时前
RBAC前端架构-06:使用localstorage及Vuex用户信息存储逻辑
前端·vue.js
硬汉嵌入式1 小时前
斯坦福大学计算机科学早期发布的简明C语言教程《Essential C》
c语言·开发语言
啊阿狸不会拉杆1 小时前
《计算机视觉:模型、学习和推理》第 2 章-概率概述
人工智能·python·学习·算法·机器学习·计算机视觉·ai
石牌桥网管1 小时前
golang Context介绍
开发语言·算法·golang
Hello.Reader1 小时前
Flink State Backend 选型、配置、RocksDB 调优、ForSt 与 Changelog 一次讲透
java·网络·数据库
_OP_CHEN1 小时前
【算法提高篇】(四)线段树之多个区间操作:懒标记优先级博弈与实战突破
算法·蓝桥杯·线段树·c/c++·区间查询·acm、icpc·区间操作
俩娃妈教编程1 小时前
2025 年 09 月 三级真题(1)--数组清零
c++·算法·gesp真题