拿到 [7,-1,3,7,2,-5],先过滤无效读数,再统计、去重、查询:这比依次背诵 vector、set、map 的成员函数更接近真实工作。这篇把同一批数据串成流水线,每一步都说明容器里实际还剩什么,以及迭代器能否继续使用。
专题目录 · 前置:存储布局与结构选择 · 后续:查找与排序
1. 容器、迭代器、算法如何分工
容器拥有元素,迭代器描述位置,算法处理迭代器定义的范围。对于 [first,last),first 指向第一项,last 指向不包含在范围内的终点;begin()==end() 就是空范围。end() 可以比较,但不能解引用。
[7] [-1] [3] [7] [2] [-5] | 尾后位置 |
迭代器不一定是裸指针,不同迭代器支持的操作也不同。因此,“任何算法都能用于任何容器”是错误的。
| 任务 | 对迭代器/元素的要求 | 合适的写法 |
|---|---|---|
| 从头寻找某个值 | 输入迭代器 | std::find(begin,end,value) |
| 原地排序 | 随机访问迭代器、可交换/移动的元素 | std::sort(vector.begin(),vector.end()) |
| 链表排序 | 链表不提供随机访问 | list.sort() |
| 有序键查找 | 使用容器维护的索引 | map.find(key)、set.lower_bound(key) |
| 批量过滤 vector | 可向前遍历并移动赋值 | remove_if 后接 erase |
std::sort 不能拿 list 的迭代器直接排序,set 的键也不能通过迭代器就地改写。标准库的容器种类不止旧资料中的“七种”;C++17 已有 array、forward_list、无序关联容器等。先学操作要求,比死记一个历史数量有用。
2. 过滤:remove 为什么没有真的缩短容器
约定读数小于 0 无效,0 是有效读数。原数据为 [7,-1,3,7,2,-5],目标得到 [7,3,7,2]。
remove_if 只有两个迭代器,不拥有容器,因此不能更改容器的 size。它把保留项按原有相对顺序移动到前面,返回有效前缀的新终点。后面的对象仍存在,但值处于有效而未指定的状态,不能把图中尾部的 ? 当成必然保留的原值。
std::vector<int> readings{7, -1, 3, 7, 2, -5}; |
这就是 C++17 的 erase-remove 写法;时间为 O(n)。逐项 vector.erase 每次都可能移动后缀,批量删除时最坏达到 O(n²)。标准草案:remove 算法
操作不变量: 扫描过的输入中,所有应该保留的项都在输出前缀里,顺序不变。自己手写过滤算法时,这就是读指针、写指针为什么能只向前移动的依据。
空数组或全负数在过滤后为空;[0,-1,0] 过滤后保留两个 0。完整源码对这些边界都有断言。
3. 去重:排序去重与保序去重不是同一需求
允许重排:sort → unique → erase
对 [7,3,7,2] 排序得到 [2,3,7,7],相同值相邻后,unique 才能把每一组相邻重复压缩为一项。
std::sort(readings.begin(), readings.end()); |
unique 不会搜索整个序列中所有重复项,且本身不改变 size。例如 [7,3,7] 的相邻元素均不相同,直接 unique 仍保留三个元素。排序后去重的总复杂度为 O(n log n),这也改变了顺序。标准草案:unique
必须保留第一次出现的顺序
保序去重的目标是 [7,3,2]。让 unordered_set 负责“以前见过吗”,让 vector 负责结果顺序:
std::unordered_set<int> seen; |
insert 的 second 表示这次是否真的插入了新键。平均总时间 O(n),额外空间 O(n),哈希冲突严重时最坏可能 O(n²)。输出顺序来自遍历原数组和追加结果,不能从哈希表的遍历顺序推断。
如果每个读数带有时间戳,“相同”的定义也要先说清:是数值相同、时间戳相同,还是整条记录相同?排序比较器与 unique 的等价判断必须服务于同一个去重目标。
4. 查找:有序前提不能省略
无序数据使用线性查找;排序后可以二分查找,但先排序的成本仍要计算。
auto found = std::find(raw.begin(), raw.end(), 7); |
lower_bound 找第一个不小于目标的位置,可能是 end();它不是“找到相等值才返回”的接口。对 vector 的二分查找比较次数和位置移动均为 O(log n)。对只有前向/双向迭代器的范围,比较次数可以仍是对数,但迭代器移动可能线性。因此查 map/set 应优先用成员 lower_bound,以使用其树索引。
小实验: 对 [2,3,7] 查 1,3,5,9,返回位置依次为下标 0,1,2,3;其中只有 3 确实存在。完整算法推导见查找与排序笔记。
5. 计数与字典:一次读取为什么增加了元素
如果想统计每个值出现几次,要在去重之前统计;否则重复次数已经丢失。
std::map<int, int> frequency; |
这里使用 operator[] 很合适:不存在的键会先插入一个值初始化的 int,即 0,然后自增。可是读取配置时同样的写法可能是错误的:
std::map<std::string, int> limits{{"imu", 100}}; |
要明确覆盖可用 insert_or_assign;希望仅在键不存在时插入可用 try_emplace。两者均在 C++17 中可用。标准草案:map 元素访问
set 的相等不一定是对象所有字段相等
如果 set<Person> 只比较年龄,那么两个姓名不同但年龄相同的人会被视为等价键,只保留一个。比较器必须满足严格弱序,不能写成 <=。也不强制必须重载 operator<,可以传入独立比较器。
struct Person { std::string name; int age; }; |
这里按年龄再按姓名排序,只有这两个字段都等价才去重;若业务身份是 ID,就应按 ID 定义唯一性,而不是碰巧使用某个展示字段。
6. 修改:删除后接住新的迭代器
按迭代器删除之后,不能继续对原来的失效迭代器执行 ++it。使用返回的下一个位置:
for (auto it = frequency.begin(); it != frequency.end(); ) { |
对 map、set、list,删除只使被删元素的迭代器和引用失效。对 vector,删除点及其后的迭代器、引用都会失效;即使容器没有重新分配,也不能继续拿旧位置当作原元素。
| 修改情形 | 原有位置还能用吗 |
|---|---|
vector 插入且重新分配 |
全部旧迭代器、指针、引用失效 |
vector 插入但未重新分配 |
插入点及之后失效,旧 end() 也失效 |
vector 删除 |
删除点及之后失效,旧 end() 也失效 |
list 插入 |
既有节点的迭代器、引用保持有效 |
map / set 插入 |
既有元素的迭代器、引用保持有效 |
unordered_map rehash |
迭代器失效,存活元素的指针和引用保持有效 |
表并非所有容器操作的完整规则,尤其不能从 list 推断 deque。标准的具体失效边界见 vector 修改规则与无序容器规则。
rehash 后重新查找,而不是继续使用旧迭代器
std::unordered_map<int, std::string> names{{1, "imu"}}; |
插入、reserve、rehash 都需要结合是否重建桶来判断。reserve 有助于预估容量,但不是对所有后续修改的一次性安全承诺。
7. 两类任务:范围输出与按优先级处理
有序 map 做范围查询
std::map<int, std::string> events{ |
找起点 O(log n),输出 k 个结果再花 O(k)。如果同一时间点允许多条记录,可以用 multimap,或把映射值改为一组记录;数据模型要先决定。
priority_queue 做当前最优任务
std::priority_queue<int> priorities; |
堆顶有保证,其余位置并未完全排序;同优先级任务也不保证先进先出。如果需要稳定次序,把序号作为第二排序条件。普通 FIFO 任务队列用 queue 即可,回溯或撤销历史则使用 stack。
8. 配套 C++17 示例与输出
下载 stl_operations.cpp。它不是孤立 API 拼盘:包括过滤与去重、保留首次出现顺序、词频删除、范围查询、比较器身份、rehash 后的引用检查。对应统一编译命令见专题学习路线。
源码中的输入与结果如下:
| 输入/动作 | 预期 |
|---|---|
过滤并排序去重 [7,-1,3,7,2,-5] |
[2,3,7] |
| 对过滤结果保序去重 | [7,3,2] |
| 输入为空或全负数 | 空结果,不能访问首元素 |
输入 [0,0,-1] |
[0],0 仍有效 |
对不存在的键 find |
返回 end(),元素数不变 |
| 只保留出现次数至少为 2 的键 | 示例中仅剩 7→2 |
容器选择也取决于数据语义:按数值去重与“每个传感器只保留最新读数”是不同需求。后者还需要传感器 ID 和时间戳,不能直接复用仅按读数排序去重的代码。