CC++ & Algorithm

set与multiset集合

困难42
语言版本:通用
概述:用“班级名单”和“超市购物清单”做比,讲解C++中set和multiset的自动排序、元素唯一或可重复等特性,以及常用成员函数,同时给出Python中对应的实现方式。

集合大作战:set 和 multiset 的自动排序与重复管理

1. 从生活中的例子引入

想象一下,你们班上要举办一场运动会,老师需要统计所有报名参加“50米跑”的同学。为了避免重复统计,老师会用一个 “名单” 来记录——每个同学的名字只能出现一次。如果小明已经报了名,他再报名时老师就会说:“你已经在名单上了,不用重复登记。”这个名单就像是 C++ 里的 set(集合):里面的元素是唯一的,而且老师会按拼音顺序把名字排好,方便查找。

再比如,你去超市买零食,列了一个 “购物清单”:薯片 2 包、巧克力 3 块、可乐 2 瓶。清单上同一个商品可以出现多次(因为你要买多份)。而且你可能把清单按商品名字的顺序写好,方便在货架上快速找到。这个“可重复、有序”的清单,就像是 C++ 里的 multiset(多重集合)。

生活中很多数据都像这样:既希望自动排序,又允许或者不允许重复。计算机里也经常需要处理这类数据,比如保存一组学号(不能重复)、统计文章里每个单词出现的次数(可重复)等。Set 和 multiset 就是专门帮我们做这件事的工具。

另外,你还能想到哪些场景?比如:

  • 考试分数统计:老师想把全班成绩排序,但同分数可以出现多次(用 multiset)。
  • 在线游戏匹配:系统记录所有玩家的 ID,每个 ID 只能出现一次(用 set)。
  • 图书馆借书记录:每本书的索书号是唯一的,但不同读者可以借同一本书的不同副本(类似 multiset 的“相同值”概念)。

2. 讲解STL的原理和使用方法

2.1 什么是 set 和 multiset?

  • set:一个有序的容器,内部元素不重复。当你插入一个元素时,如果它已经存在,插入不会成功。set 内部通常用红黑树(红黑树是一种平衡二叉搜索树)实现,所以插入、删除、查找的平均时间复杂度都是 O(log n),非常快。这比在无序列表里逐个寻找快得多(列表查找是 O(n)),尤其当数据量很大时(比如 10 万个元素),log₂(100,000) ≈ 17,而线性查找平均要 5 万次。
  • multiset:与 set 类似,也是有序的,但允许元素重复。你可以插入多个相同的值,multiset 会为每个值保留多个副本。它的内部同样是红黑树,只是不强制唯一性。

2.2 头文件和命名空间

在 C++ 中使用 set 和 multiset 需要包含头文件 <set>,并使用 std:: 命名空间(或者用 using namespace std;)。

#include <set>
using namespace std;

2.3 常用成员函数详解

(1) 插入元素

  • insert(value):插入一个元素。对 set 来说,如果 value 已存在,则插入失败;对 multiset 来说,总是成功。返回一个 pair<iterator, bool>,其中 iterator 指向新插入的位置(如果插入失败则指向已有元素的位置),bool 表示是否插入成功(仅在 set 中有意义)。
  • 也可以一次插入多个元素,用初始化列表:s.insert({1,2,3});
  • 还可以用 emplace(value) 直接在容器中构造元素,避免拷贝,效率更高。

生活例子:想象你有一张“午饭菜单”的 set(不能重复点同样的菜)。你点“红烧肉”,成功;再点“红烧肉”,服务器告诉你“这道菜已经点过了”。而 multiset 就像超市购物车,你可以往里面放三包薯片,每放一次都记录一次。

(2) 查找元素

  • find(value):返回指向该元素的迭代器;如果没找到,返回 end()。注意:在 multiset 中,如果存在多个相同值,find 返回的迭代器指向其中第一个(通常是最先插入的或排序中的第一个)。
  • count(value):返回元素值为 value 的个数。set 中只能是 0 或 1;multiset 中可以 >= 1。注意:count 在 multiset 中也是 O(log n) 时间复杂度(加上重复个数),但实际实现中它会遍历所有等于 value 的节点,复杂度依然是 O(log n + 重复个数)。
  • lower_bound(value)upper_bound(value):用于范围查找,返回迭代器。
    • lower_bound:第一个不小于 value 的元素位置(即 >= value 的第一个)。
    • upper_bound:第一个大于 value 的元素位置。
  • equal_range(value):返回一个 pair,其 first 和 second 分别为 lower_boundupper_bound,可用于遍历所有等于 value 的元素(multiset 中非常有用)。

生活例子:考试结束后,老师用 set 记录所有及格的学号(唯一)。学号 1024 及格了,老师用 find 找到他;学号 2048 没及格,找不到。对于 multiset,统计学号 1024 出现的次数(比如他的分数重复出现?不,通常学号唯一,但多门课成绩可以用 multiset 记录分数)。

(3) 删除元素

  • erase(value):删除所有等于 value 的元素,返回被删除的个数(set 中只能是 0 或 1,multiset 中可以是任意数量)。
  • erase(iterator):删除迭代器指向的单个元素。注意:使用迭代器删除后,该迭代器会失效,不能再使用。
  • erase(first, last):删除迭代器区间 [first, last) 内的所有元素。
  • clear():清空所有元素。

新手容易犯的错误:在 multiset 中,想只删除一个等于 value 的元素,却写了 erase(value),结果删除了全部。正确做法是:先用 find 得到迭代器,然后 erase(it)。另一个错误是:在循环中删除元素时,直接 erase(it++)erase(it) 后没有更新迭代器。标准做法是:it = s.erase(it)(C++11 起 erase 返回下一个元素的迭代器)。

(4) 其他访问

  • size():返回元素个数。对于 multiset,包含重复的计数。
  • empty():判断是否为空。
  • begin()end():返回首迭代器和尾后迭代器,用于遍历。

2.4 遍历

set 和 multiset 都是有序的,默认按元素值的升序排列。可以通过迭代器(或 C++11 范围 for 循环)直接遍历。

for (auto it = s.begin(); it != s.end(); ++it) {
    cout << *it << " ";
}
// 或者
for (int x : s) cout << x << " ";

注意:遍历时不要修改元素(因为会破坏排序结构)。但可以删除当前迭代器指向的元素(但要小心迭代器失效)。

2.5 自定义排序

默认使用 std::less<T> 进行升序。如果希望降序,或按其他规则排序,可以在定义时传入比较器。例如:

set<int, greater<int>> s; // 降序

对于自定义类型(比如 Student),需要重载 < 运算符,或者提供函数对象。例如:

struct Student {
    string name;
    int score;
    bool operator<(const Student& other) const {
        return score > other.score; // 按分数降序排列
    }
};
set<Student> s;

这部分会在后续文章中详细讲解。

3. 生活中的综合示例:比赛选手管理

假设你是一个小比赛的主办方,需要管理报名选手(每个选手只能报名一次)以及选手的得分(多次得分可以记录)。我们使用 set 管理选手 ID,用 multiset 管理得分。

#include <iostream>
#include <set>
#include <string>
using namespace std;

int main() {
    // ===== 选手管理(set)=====
    cout << "=== 选手报名管理(set)===" << endl;
    set<string> players; // 选手名字唯一

    players.insert("Alice");
    players.insert("Bob");
    players.insert("Charlie");
    // Alice 尝试再次报名
    auto result = players.insert("Alice");
    if (result.second) {
        cout << "Alice 报名成功" << endl;
    } else {
        cout << "Alice 已经报名过了,不能重复报名" << endl;
    }

    // 打印所有选手(按名字字母序)
    cout << "已报名选手:";
    for (const string& name : players) cout << name << " ";
    cout << endl;
    cout << "选手人数:" << players.size() << endl;

    // Bob 退赛
    players.erase("Bob");
    cout << "Bob 退赛后剩余选手:";
    for (const string& name : players) cout << name << " ";
    cout << endl << endl;

    // ===== 得分管理(multiset)=====
    cout << "=== 得分记录(multiset)===" << endl;
    multiset<int> scores; // 得分可以有重复

    // Alice 三次得分
    scores.insert(85);
    scores.insert(92);
    scores.insert(88);
    // Charlie 两次得分
    scores.insert(90);
    scores.insert(88);
    cout << "所有得分(升序):";
    for (int s : scores) cout << s << " ";
    cout << endl;

    // 统计某个分数出现次数
    cout << "88 分出现了 " << scores.count(88) << " 次" << endl;

    // 查找最低分和最高分
    cout << "最低分:" << *scores.begin() << endl;
    cout << "最高分:" << *scores.rbegin() << endl; // rbegin() 是反向迭代器

    // 删除所有 88 分(比如裁判发现这个判分有误)
    int erased = scores.erase(88);
    cout << "删除了 " << erased << " 个 88 分,剩余得分:";
    for (int s : scores) cout << s << " ";
    cout << endl;

    return 0;
}

输出结果:

=== 选手报名管理(set)===
Alice 已经报名过了,不能重复报名
已报名选手:Alice Bob Charlie 
选手人数:3
Bob 退赛后剩余选手:Alice Charlie 

=== 得分记录(multiset)===
所有得分(升序):85 88 88 90 92 
88 分出现了 2 次
最低分:85
最高分:92
删除了 2 个 88 分,剩余得分:85 90 92 

4. Python 等价功能的代码实现(带详细注释)

Python 内置的 setfrozenset 是无序的、不重复的集合,不保证元素顺序。但是我们可以用 listcollections 模块模拟有序且允许重复的集合。注意,Python 官方的 set 是基于哈希表实现的,不是红黑树,所以它是无序的。但 Python 3.7+ 中 dict 有序,我们可以利用 dict 的键来模拟有序集合。不过对于教育和竞赛,我们通常用 list 手动维护有序性,或用 bisect 模块维护升序列表。

更常用的做法是使用 collections.Counter 来统计次数(类似 multiset 的计数能力),但输出不是自动排序的。如果希望自动排序,可以结合 sorted()SortedContainers 第三方库(非标准库)。

下面给出一个模拟 set 和 multiset 的简单实现(基于列表,手动保证唯一性和顺序),方便理解。

# Python 没有直接的有序唯一容器,但我们可以用 list + 二分查找实现
import bisect

class SetLike:
    """类似 set,元素唯一且有序(升序)"""
    def __init__(self):
        self._data = []
    
    def insert(self, value):
        # 检查是否已存在
        pos = bisect.bisect_left(self._data, value)
        if pos == len(self._data) or self._data[pos] != value:
            self._data.insert(pos, value)
            return True  # 插入成功
        return False  # 已存在
    
    def erase(self, value):
        pos = bisect.bisect_left(self._data, value)
        if pos < len(self._data) and self._data[pos] == value:
            self._data.pop(pos)
            return True
        return False
    
    def count(self, value):
        # 使用二分查找
        left = bisect.bisect_left(self._data, value)
        right = bisect.bisect_right(self._data, value)
        return right - left
    
    def find(self, value):
        pos = bisect.bisect_left(self._data, value)
        if pos < len(self._data) and self._data[pos] == value:
            return pos
        return -1
    
    def __contains__(self, value):
        return self.count(value) > 0
    
    def __repr__(self):
        return str(self._data)


class MultiSetLike:
    """类似 multiset,允许重复且有序(升序)"""
    def __init__(self):
        self._data = []
    
    def insert(self, value):
        # 插入保持有序
        bisect.insort(self._data, value)
    
    def erase(self, value, all_=False):
        """删除一个或所有等于 value 的元素"""
        if all_:
            # 删除所有
            left = bisect.bisect_left(self._data, value)
            right = bisect.bisect_right(self._data, value)
            del self._data[left:right]
            return right - left
        else:
            # 只删除第一个
            pos = bisect.bisect_left(self._data, value)
            if pos < len(self._data) and self._data[pos] == value:
                self._data.pop(pos)
                return 1
            return 0
    
    def count(self, value):
        left = bisect.bisect_left(self._data, value)
        right = bisect.bisect_right(self._data, value)
        return right - left
    
    def __repr__(self):
        return str(self._data)

# 使用示例
if __name__ == "__main__":
    print("=== Python 模拟 set ===")
    s = SetLike()
    s.insert(10)
    s.insert(5)
    s.insert(15)
    s.insert(5)
    print("当前集合:", s)   # [5, 10, 15]
    print("是否包含 10:", 10 in s)  # True
    print("5 出现次数:", s.count(5)) # 1
    s.erase(5)
    print("删除 5 后:", s)  # [10, 15]

    print("\n=== Python 模拟 multiset ===")
    ms = MultiSetLike()
    ms.insert(10)
    ms.insert(5)
    ms.insert(5)
    ms.insert(15)
    ms.insert(5)
    print("当前多重集合:", ms)  # [5, 5, 5, 10, 15]
    print("5 出现次数:", ms.count(5)) # 3
    ms.erase(5, all_=False)   # 删除一个5
    print("删除一个5后:", ms)  # [5, 5, 10, 15]
    ms.erase(5, all_=True)    # 删除所有5
    print("删除所有5后:", ms)  # [10, 15]

注意:上述 Python 代码仅仅是教学模拟,实际竞赛中可以直接使用 list + sort,但效率不高(插入是 O(n))。真正高效的实现需要用到 bisectSortedContainers。对于信息学竞赛,Python 选手可以使用 collections.Counter 统计次数,配合 sorted() 排序输出。

更实际的做法:如果只需要自动排序和统计次数,可以直接用 list 并每次插入后排序(但要考虑效率),或者用 heapq 维护堆(但不是有序列表)。以下是一种常见做法:把所有元素放进 list,排序后输出。

# 更实用的 Python 做法:用 list + sorted()
data = [10, 5, 15, 5, 5]
data.sort()  # 升序排序
print("排序后的列表(模拟 multiset):", data)
# 统计
from collections import Counter
cnt = Counter(data)
print("5 出现的次数:", cnt[5])

5. 总结要点和注意事项

  • set 与 multiset 的核心区别:是否允许重复元素。
  • 内部结构:基于红黑树,插入、查找、删除都是 O(log n) 级别,非常高效。
  • 自动排序:默认升序(< 比较),可以通过比较器自定义排序规则。
  • 成员函数使用insertfindcounteraselower_boundupper_boundequal_range 等。
  • 遍历:使用迭代器或范围 for 循环,得到有序序列。
  • 注意事项
    • set 的 insert 返回 pair,可用于判断是否插入成功。
    • multiset 的 erase(value) 会删除所有等于 value 的元素,如果只想删除一个,应该用 erase(iterator)
    • findcount 的时间复杂度都是 O(log n),但 count 在 multiset 中可能遍历更多元素(实际上依然 O(log n))。
    • 自定义类型需要重载 < 运算符,或者提供比较器。
  • 常见错误
    1. 误以为 erase(value) 只删除一个,实际删除了全部。
    2. 在循环中删除元素后继续使用失效的迭代器。解决方法:使用 it = s.erase(it)(C++11 后 erase 返回下一个迭代器)。
    3. 试图修改 set 中的元素(比如通过解引用迭代器赋值),这会导致容器内部排序混乱。
    4. 忘记包含头文件 <set>
  • Python 对比:Python 标准库没有直接对应有序集合的容器,但可以用 bisect 模块手动模拟;更实际的方法是使用 set(无序唯一)和 Counter(可重复无序)。如果要保持顺序,建议用 list + sort 或第三方库 sortedcontainers

6. 相关指引

掌握了 set 和 multiset,你就能轻松处理“自动排序、快速查找”的数据了。接下来你可以学习:

  • map 和 multimap:它们是“键-值”对的有序容器,像一本自动排序的字典,可以通过键快速查找值。
  • unordered_set 和 unordered_multiset:它们基于哈希表,查找更快(平均 O(1)),但顺序是乱的,适合只关心“是否存在”而不关心顺序的场景。
  • 迭代器失效问题:了解在遍历过程中删除/插入元素时如何安全地操作。
  • 自定义比较器:深入支持复杂类型的排序。

下一篇文章,我们将学习 map 与 multimap 映射,它们像是“有自动排序的字典”。

例题精讲

1单选题

关于C++中set容器的描述,下列哪项是正确的?

Aset中的元素按插入顺序存储,且允许重复元素
Bset中的元素按值的大小自动排序,且不允许重复元素
Cset中的元素按值的大小自动排序,且允许重复元素
Dset中的元素按插入顺序存储,且不允许重复元素
2判断题

C++中,multiset容器内的元素默认按升序排列,且允许重复元素存在。

3填空题
以下C++代码使用set存储几个整数,并输出排序后的结果。请补全代码。

#include <iostream>
#include <set>
using namespace std;

int main() {
    set<int> s;
    s.___(3);
    s.insert(1);
    s.insert(2);
    for (auto x : s) {
        cout << x << " ";
    }
    return 0;
}
4单选题

对于multiset<int> ms = {5, 1, 3, 1, 2}; 执行操作 ms.erase(1); 后,ms中元素1的个数变为多少?

A0
B1
C2
D不确定
5填空题
Python中可以用list和sort来模拟multiset的自动排序和可重复特性。以下代码实现插入若干数字并打印升序结果,请补全。

nums = []
nums.append(3)
nums.append(1)
nums.append(2)
nums.append(1)
nums.___()
print(nums)