在 C++ 的世界里,STL(Standard Template Library,标准模板库)就像是一个装备齐全的超级工具箱,里面装满了各种高效、实用的工具,帮助开发者快速解决数据存储、处理和算法实现等问题。无论是新手程序员还是经验丰富的开发者,STL 都是 C++ 编程中不可或缺的得力助手。接下来,就让我们一起深入探索这个神奇的工具箱,领略它的强大魅力。
一、STL 核心概念:揭开 STL 的神秘面纱
STL 主要由容器(Containers)、算法(Algorithms)和迭代器(Iterators)三大组件构成,它们相互协作,形成了一个高效、灵活的编程框架。
(一)容器:数据的 “收纳盒”
容器是 STL 中用于存储数据的工具,就好比生活中用来收纳物品的盒子。不同类型的容器,就像不同规格和功能的收纳盒,适用于不同的场景。
-
序列式容器:这类容器按照元素的插入顺序进行存储,包括vector(动态数组)、deque(双端队列)和list(双向链表)。
-
vector类似于一个可以自动扩容的数组,它在内存中连续存储元素,支持快速的随机访问,就像一本整齐排列的通讯录,你可以快速通过页码找到对应的联系人信息。例如,在存储学生成绩列表时,vector能够方便地根据索引快速获取每个学生的成绩。
-
deque则像一个两端都可以打开的收纳盒,它允许在两端快速插入和删除元素,在需要频繁在头部和尾部进行操作的场景中表现出色,比如实现一个任务队列,新任务可以从一端加入,完成的任务从另一端取出。
-
list是一个双向链表,每个元素都连接着前后两个节点,它在插入和删除元素时不需要移动其他元素,效率很高,就像一串手链,添加或移除珠子不会影响其他珠子的位置。当需要频繁进行插入和删除操作时,list是一个不错的选择。
-
关联式容器:这类容器通过键值对来存储和访问元素,包括map(映射)、multimap(多重映射)、set(集合)和multiset(多重集合)。
-
map就像一本字典,每个单词(键)都对应着一个解释(值),通过键可以快速查找对应的值。例如,在统计单词出现次数的程序中,map可以将单词作为键,出现次数作为值,方便地进行查询和更新。
-
set则像一个收纳不重复物品的盒子,它会自动对元素进行排序并去除重复元素,适合用于需要存储唯一元素的场景,比如存储班级里不重复的学生学号。
(二)算法:数据处理的 “魔法咒语”
算法是 STL 中用于处理数据的函数,它们就像一个个魔法咒语,能够对容器中的数据进行各种神奇的操作。从简单的查找、排序,到复杂的数值计算、集合操作,STL 提供了丰富多样的算法。
例如,sort算法可以对容器中的元素进行排序,就像将杂乱无章的书籍按照一定的规则整理得整整齐齐;find算法用于在容器中查找特定元素,如同在书架上寻找一本特定的书;accumulate算法可以对容器中的元素进行累加,就像计算一堆物品的总数量。
(三)迭代器:容器与算法之间的 “桥梁”
迭代器是一种可以遍历容器元素的对象,它充当了容器和算法之间的桥梁,使得算法能够以统一的方式处理不同类型的容器。
迭代器的工作方式类似于生活中的 “游标卡尺”,它可以逐个 “测量” 容器中的元素。算法通过迭代器来访问和操作容器中的数据,而不需要关心容器的具体实现细节。这样,同一个算法可以应用于不同的容器,大大提高了代码的复用性和灵活性。
二、STL 容器详解:选择合适的 “收纳盒”
容器是 STL 中用于存储和管理数据的类模板,根据存储和访问数据的方式,可分为序列式容器、关联式容器和无序容器。它们各自有着独特的设计理念与适用场景,就像不同类型的收纳盒,只有根据物品特点选择合适的容器,才能让数据管理更高效。
(一)序列式容器
1.vector:动态数组
vector 就像是一个可以自动扩容的 “动态数组”,在内存中以连续的空间存储元素。当你向它添加元素时,如果内部空间不足,它会自动申请更大的内存空间,并将原有的数据复制过去。这一特性使得 vector 在访问元素时,能像普通数组一样通过下标快速定位,时间复杂度为 O (1),因为内存连续,CPU 缓存命中率高,访问效率极佳。(具体实现可以参考我之前的文章:c++ 手写STL篇(一)用数组实现vector核心功能,里面详细介绍了动态数组底层实现细节)
常用方法:
- push_back(const T& value):在 vector 尾部添加一个元素,时间复杂度 O (1)(均摊),但在动态数组扩容时,由于要申请新空间并将原空间数据移动到新空间,时间复杂度高。例如numbers.push_back(4); 。
- pop_back():删除 vector 尾部的元素,时间复杂度 O (1)。如numbers.pop_back(); 。
- size():返回 vector 中元素的个数,时间复杂度 O (1)。像int len = numbers.size(); 。
- clear():清空 vector 中的所有元素,将元素个数置为 0,但不会释放内存空间,时间复杂度 O (n)。使用numbers.clear(); 即可清空。
- insert(iterator position, const T& value):在指定位置插入一个元素,插入位置之后的元素依次后移,时间复杂度 O (n)。例如numbers.insert(numbers.begin() + 1, 5); ,在第二个位置插入 5 。
例如,在游戏开发中,需要存储玩家的装备列表,使用 vector 就非常合适,因为玩家的装备数量可能随时增减。下面是一个简单示例,展示 vector 的基本使用:
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers;
numbers.push_back(1);
numbers.push_back(2);
numbers.push_back(3);
// 通过下标访问元素(亦可以使用at方法访问,越界会抛出out_of_range异常)
std::cout << "The second element is: " << numbers[1] << std::endl;
// 使用迭代器遍历
for (std::vector<int>::iterator it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << *it << " ";
}
// 使用常用方法
numbers.push_back(4);
numbers.pop_back();
std::cout << "\nSize of vector: " << numbers.size() << std::endl;
numbers.clear();
std::cout << "Is vector empty? " << (numbers.empty()? "Yes" : "No") << std::endl;
return 0;
}
从底层实现来看,vector 维护了三个指针,分别指向起始位置、当前最后一个元素的下一个位置以及分配的最大存储位置。当空间不足时,vector 会申请一块更大的内存,将原有数据拷贝过去,然后释放旧内存,这也是为什么在频繁扩容时,vector 可能会带来一定的性能开销。
2.deque:双端队列
deque(双端队列)如同一个两端都可以进出货物的 “站台”,它在头部和尾部添加、删除元素的效率都很高,时间复杂度为 O (1)。与 vector 不同,deque 并不是连续的内存空间,它采用分段连续存储,通过一个中央控制块来管理这些分段的内存。
常用方法:
- push_front(const T& value):在 deque 头部添加一个元素,时间复杂度 O (1)。如dq.push_front(0);
- push_back(const T& value):在 deque 尾部添加一个元素,时间复杂度 O (1) ,和 vector 的push_back类似;
- pop_front():删除 deque 头部的元素,时间复杂度 O (1)。dq.pop_front(); 可实现该操作;
- pop_back():删除 deque 尾部的元素,时间复杂度 O (1);
- at(size_type n):返回指定位置 n 的元素,进行边界检查,越界会抛出out_of_range异常,时间复杂度 O (n)。比如int element = dq.at(1);
- front():返回 deque 头部的元素,时间复杂度 O (1)。int first = dq.front();
- back():返回 deque 尾部的元素,时间复杂度 O (1)。
在处理需要频繁在两端操作的数据时,比如任务队列,deque 就是一个很好的选择。例如,在多线程编程中,多个线程可能需要从任务队列的两端添加或取出任务,deque 就能高效应对。以下是 deque 的使用示例:
#include <deque>
#include <iostream>
int main() {
std::deque<int> dq;
dq.push_front(1);
dq.push_back(2);
std::cout << "Front element: " << dq.front() << std::endl;
std::cout << "Back element: " << dq.back() << std::endl;
dq.push_front(0);
dq.pop_back();
std::cout << "New front element: " << dq.front() << std::endl;
try {
std::cout << "Element at index 1: " << dq.at(1) << std::endl;
} catch (const std::out_of_range& e) {
std::cout << "Out of range error: " << e.what() << std::endl;
}
return 0;
}
由于 deque 的内存不连续(准确说是的多块内部连续的内存块),它不能像 vector 一样通过下标快速随机访问元素,随机访问的时间复杂度为 O (n),不过它提供了at()函数来进行边界检查的访问。
3.list:双向链表
list 是一个双向链表,每个元素都通过指针连接到前后元素,就像一串珍珠项链。这种结构使得 list 在插入和删除元素时不需要移动其他元素,只需要修改指针的指向,时间复杂度为 O (1),因此在频繁插入和删除操作的场景下表现出色,比如实现 undo/redo 功能时,list 能发挥出其独特优势。
(具体实现可以参考我之前的文章:c++ 手写STL篇(二) 用双向链表实现list核心功能,里面详细介绍了双向链表底层实现细节)
常用方法:
- push_back(const T& value):在 list 尾部添加一个元素,时间复杂度 O (1)。lst.push_back(4);
- push_front(const T& value):在 list 头部添加一个元素,时间复杂度 O (1)。lst.push_front(0);
- pop_back():删除 list 尾部的元素,时间复杂度 O (1)。lst.pop_back();
- pop_front():删除 list 头部的元素,时间复杂度 O (1)
- insert(iterator position, const T& value):在指定位置插入一个元素,时间复杂度 O (1)。如lst.insert(lst.begin(), 5);
- erase(iterator position):删除指定位置的元素,时间复杂度 O (1)。lst.erase(lst.begin());
- sort():对 list 中的元素进行排序,时间复杂度 O (n log n),内部使用归并排序算法。lst.sort();
- reverse():反转 list 中的元素顺序,时间复杂度 O (n)。lst.reverse()。
但由于链表的内存不连续,list 访问元素的速度相对较慢,无法像 vector 那样进行随机访问,只能从头节点开始逐个遍历,时间复杂度为 O (n)。下面是 list 的基本使用代码:
#include <list>
#include <iostream>
int main() {
std::list<int> lst;
lst.push_back(1);
lst.push_back(2);
// 使用迭代器遍历
for (std::list<int>::iterator it = lst.begin(); it != lst.end(); ++it) {
std::cout << *it << " ";
}
// 在指定位置插入元素
std::list<int>::iterator pos = lst.begin();
std::advance(pos, 1);
lst.insert(pos, 3);
// 使用常用方法
lst.push_front(0);
lst.pop_back();
lst.sort();
lst.reverse();
for (int num : lst) {
std::cout << num << " ";
}
return 0;
}
从底层实现角度,list 的每个节点都包含数据和指向前驱、后继节点的指针,这种结构虽然增加了内存开销,但换来了灵活的插入和删除操作。
(二)关联式容器
1.set 和 multiset
set 是一个有序的集合,其中的元素不允许重复,内部通过红黑树实现,保证了插入、删除和查找操作的平均时间复杂度为 O (log n)。就像一个班级里学生的学号集合,每个学号都是唯一的,在 set 中插入重复元素不会生效。
(红黑树的具体实现可以参考我之前的文章:c++ 手写STL篇(四) 实现红黑树(RB-Tree))
常用方法:
- insert(const T& value):插入一个元素,如果元素已存在(set 中)则插入失败,时间复杂度 O (log n)。s.insert(3);
- erase(iterator position):删除指定位置的元素,时间复杂度 O (log n)。s.erase(s.begin());
- erase(const T& value):删除值为 value 的元素,时间复杂度 O (log n)。s.erase(2);
- find(const T& value):查找值为 value 的元素,找到返回对应的迭代器,否则返回end(),时间复杂度 O (log n)。auto it = s.find(1);
- count(const T& value):返回值为 value 的元素个数(set 中为 0 或 1,multiset 中可为多个),时间复杂度 O (log n)。int numCount = ms.count("apple");
- lower_bound(const T& value):返回第一个大于等于 value 的元素的迭代器,时间复杂度 O (log n);
- upper_bound(const T& value):返回第一个大于 value 的元素的迭代器,时间复杂度 O (log n)。
multiset 则允许元素重复,同样基于红黑树实现,适用于统计单词出现次数等场景。例如,统计文本中每个单词出现的频率,使用 multiset 就很方便。下面是 set 和 multiset 的示例代码:
#include <set>
#include <iostream>
#include <string>
int main() {
std::set<int> s;
s.insert(1);
s.insert(2);
s.insert(1); // 重复插入不会生效
std::cout << "Set size: " << s.size() << std::endl;
s.erase(1);
auto findIt = s.find(2);
if (findIt != s.end()) {
std::cout << "Element 2 found in set" << std::endl;
}
std::multiset<std::string> ms;
ms.insert("apple");
ms.insert("banana");
ms.insert("apple");
std::cout << "Number of 'apple' in multiset: " << ms.count("apple") << std::endl;
auto range = ms.equal_range("apple");
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->second << " ";
}
return 0;
}
由于 set 和 multiset 的有序性,它们提供了一些独特的操作,比如lower_bound()和upper_bound(),可以用于查找特定元素的插入位置或范围。
2.map 和 multimap
map 是一种键值对存储的容器,类似于字典,通过键来快速查找对应的值。它内部同样基于红黑树实现,保证了键值对的有序存储,插入、删除和查找操作的平均时间复杂度为 O (log n) 。在使用 map 时,键必须是唯一的,这就像字典里的每个单词都对应唯一的解释。
(红黑树的具体实现可以参考我之前的文章:c++ 手写STL篇(四) 实现红黑树(RB-Tree))
常用方法详解:
- insert(pair<const Key, T> value):插入一个键值对,如果键已存在则插入失败,时间复杂度 O (log n)。studentGrades.insert(std::make_pair("Charlie", 80));
- erase(iterator position):删除指定位置的键值对,时间复杂度 O (log n)。studentGrades.erase(studentGrades.begin());
- erase(const Key& key):删除键为 key 的键值对,时间复杂度 O (log n)。studentGrades.erase("Alice");
- find(const Key& key):查找键为 key 的键值对,找到返回对应的迭代器,否则返回end(),时间复杂度 O (log n)。auto findIt = studentGrades.find("Bob");
- operator[](const Key& key):通过键访问对应的值,如果键不存在则插入一个默认值,时间复杂度 O (log n)。int grade = studentGrades["Alice"];
- count(const Key& key):返回键为 key 的键值对个数(map 中为 0 或 1,multimap 中可为多个),时间复杂度 O (log n)。
multimap 允许相同的键存在,适合处理一对多的映射关系。例如,存储学生姓名和成绩的对应关系,使用 map 就非常方便。以下是 map 和 multimap 的示例:
#include <map>
#include <iostream>
#include <string>
int main() {
std::map<std::string, int> studentGrades;
studentGrades["Alice"] = 90;
studentGrades["Bob"] = 85;
std::cout << "Alice's grade: " << studentGrades["Alice"] << std::endl;
studentGrades.insert(std::make_pair("Charlie", 80));
studentGrades.erase("Bob");
auto findIt = studentGrades.find("Alice");
if (findIt != studentGrades.end()) {
std::cout << "Alice's grade found: " << findIt->second << std::endl;
}
std::multimap<std::string, std::string> courseStudents;
courseStudents.insert({"Math", "Alice"});
courseStudents.insert({"Math", "Bob"});
courseStudents.insert({"English", "Alice"});
std::cout << "Students in Math course: " << std::endl;
auto range = courseStudents.equal_range("Math");
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->second << std::endl;
}
return 0;
}
map 和 multimap 的迭代器遍历顺序是按照键的升序排列(红黑树也是一种二叉搜索树,升序遍历就是树的中序遍历),这在处理需要有序存储和查找的数据时非常有用。
在实际编程中,选择合适的容器至关重要。如果需要频繁随机访问元素,vector 是首选;若要在两端频繁插入和删除元素,deque 更为合适;对于频繁的插入和删除操作,list 能提高效率;而当需要根据键快速查找值,或者存储有序且不重复 / 可重复的数据时,map、multimap、set 和 multiset 则能发挥强大作用。理解每种容器的特性和适用场景,将帮助我们编写出更高效、更优雅的代码。
(三)无序容器
无序容器是 C++11 引入的新成员,包括unordered_set、unordered_multiset、unordered_map和unordered_multimap。它们与对应的有序容器功能类似,但内部通过哈希表(Hash Table)实现,元素没有固定的顺序,这使得在理想情况下插入、删除和查找操作的平均时间复杂度为 O (1) ,性能表现更为出色,尤其适合在不需要元素有序,且注重查找效率的场景下使用。
(哈希表的具体实现可以参考我之前的文章:c++ 手写STL篇(三)实现hashtable)
1.unordered_set 和 unordered_multiset
unordered_set是一个无序的集合,元素不允许重复。unordered_multiset则允许元素重复。两者都利用哈希函数将元素映射到哈希表的桶中,通过哈希值快速定位元素。
常用方法详解:
- insert(const T& value):插入一个元素,如果元素已存在(unordered_set中)则插入失败,时间复杂度平均为 O (1),最坏为 O (n)。例如us.insert(5); 。
- erase(iterator position):删除指定位置的元素,时间复杂度平均为 O (1),最坏为 O (n)。us.erase(us.begin()); 。
- erase(const T& value):删除值为 value 的元素,时间复杂度平均为 O (1),最坏为 O (n)。us.erase(3); 。
- find(const T& value):查找值为 value 的元素,找到返回对应的迭代器,否则返回end(),时间复杂度平均为 O (1),最坏为 O (n)。auto it = us.find(2); 。
- count(const T& value):返回值为 value 的元素个数(unordered_set中为 0 或 1,unordered_multiset中可为多个),时间复杂度平均为 O (1),最坏为 O (n)。int numCount = ums.count("book"); 。
下面是unordered_set和unordered_multiset的使用示例:
#include <unordered_set>
#include <iostream>
#include <string>
int main() {
std::unordered_set<int> us;
us.insert(1);
us.insert(2);
us.insert(1); // 重复插入不会生效
std::cout << "Unordered set size: " << us.size() << std::endl;
us.erase(1);
auto findIt = us.find(2);
if (findIt != us.end()) {
std::cout << "Element 2 found in unordered set" << std::endl;
}
std::unordered_multiset<std::string> ums;
ums.insert("apple");
ums.insert("banana");
ums.insert("apple");
std::cout << "Number of 'apple' in unordered multiset: " << ums.count("apple") << std::endl;
for (const auto& str : ums) {
std::cout << str << " ";
}
return 0;
}
在实际应用中,若需要快速判断某个元素是否存在,例如在一个大型词汇表中查找某个单词,unordered_set会是一个很好的选择,其高效的查找性能能显著提升程序的运行效率。而unordered_multiset则适用于统计元素出现次数,且允许元素重复的场景,比如统计一篇文章中每个单词出现的频次。
2.unordered_map 和 unordered_multimap
unordered_map是一种无序的键值对存储容器,通过键快速查找对应的值,键必须唯一。unordered_multimap允许相同的键存在,适用于一对多的映射关系。它们同样基于哈希表实现,平均情况下插入、删除和查找操作的时间复杂度为 O (1) 。
(哈希表的具体实现可以参考我之前的文章:c++ 手写STL篇(三)实现hashtable)
常用方法详解:
- insert(pair<const Key, T> value):插入一个键值对,如果键已存在(unordered_map中)则插入失败,时间复杂度平均为 O (1),最坏为 O (n)。um.insert(std::make_pair("David", 95)); 。
- erase(iterator position):删除指定位置的键值对,时间复杂度平均为 O (1),最坏为 O (n)。um.erase(um.begin()); 。
- erase(const Key& key):删除键为 key 的键值对,时间复杂度平均为 O (1),最坏为 O (n)。um.erase("Alice"); 。
- find(const Key& key):查找键为 key 的键值对,找到返回对应的迭代器,否则返回end(),时间复杂度平均为 O (1),最坏为 O (n)。auto findIt = um.find("Bob"); 。
- operator[](const Key& key):通过键访问对应的值,如果键不存在则插入一个默认值(仅unordered_map有此操作),时间复杂度平均为 O (1),最坏为 O (n)。int score = um["Alice"]; 。
- count(const Key& key):返回键为 key 的键值对个数(unordered_map中为 0 或 1,unordered_multimap中可为多个),时间复杂度平均为 O (1),最坏为 O (n)。
例如,在一个学生成绩管理系统中,如果需要快速根据学生姓名查找成绩,使用unordered_map可以高效实现;而当统计一门课程中每个学生的多次考试成绩时,unordered_multimap就能派上用场。以下是unordered_map和unordered_multimap的示例代码:
#include <unordered_map>
#include <iostream>
#include <string>
int main() {
std::unordered_map<std::string, int> um;
um["Alice"] = 90;
um["Bob"] = 85;
std::cout << "Alice's score: " << um["Alice"] << std::endl;
um.insert(std::make_pair("Charlie", 80));
um.erase("Bob");
auto findIt = um.find("Alice");
if (findIt != um.end()) {
std::cout << "Alice's score found: " << findIt->second << std::endl;
}
std::unordered_multimap<std::string, int> umm;
umm.insert({"Math", 90});
umm.insert({"Math", 85});
umm.insert({"English", 95});
std::cout << "Scores in Math course: " << std::endl;
auto range = umm.equal_range("Math");
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->second << " ";
}
return 0;
}
需要注意的是,无序容器的遍历顺序是不确定的,因为元素的存储顺序由哈希值决定。当哈希函数设计不合理,导致大量元素哈希冲突时,无序容器的性能会退化,查找等操作的时间复杂度可能接近 O (n)。在使用无序容器时,选择合适的哈希函数对于性能优化至关重要,C++ 标准库为常见类型提供了默认的哈希函数,但对于自定义类型,开发者需要自行实现哈希函数,以确保无序容器能高效工作。
三、算法:数据处理的 “魔法工厂”
STL 算法根据功能特性,主要可分为非修改型算法、修改型算法、排序算法、数值算法、集合算法等。这些算法通过迭代器与容器进行交互,从而实现对不同类型容器数据的统一处理。这种设计理念使得算法与容器解耦,无论容器是vector、list,还是map,只要提供合适的迭代器,算法就能对其数据进行操作,极大地增强了代码的复用性和扩展性。
(一)非修改型算法:数据的 “观察者”
非修改型算法不会改变容器中元素的值,主要用于遍历、查找、计数等操作,就像数据的 “观察者”,在不干扰数据原有状态的前提下获取信息。
1. 查找算法
std::find:用于在容器中查找指定元素,从起始迭代器开始,到结束迭代器结束,返回第一个匹配元素的迭代器,如果未找到则返回结束迭代器。例如,在vector中查找特定整数:
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
auto it = std::find(numbers.begin(), numbers.end(), 3);
if (it != numbers.end()) {
std::cout << "Element 3 found at position: " << std::distance(numbers.begin(), it) << std::endl;
} else {
std::cout << "Element 3 not found" << std::endl;
}
return 0;
}
std::find_if:与std::find类似,但通过用户自定义的谓词函数来判断元素是否符合条件。比如,在list中查找第一个大于 5 的元素:
#include <list>
#include <algorithm>
#include <iostream>
bool greaterThanFive(int num) {
return num > 5;
}
int main() {
std::list<int> lst = {3, 6, 4, 7};
auto it = std::find_if(lst.begin(), lst.end(), greaterThanFive);
if (it != lst.end()) {
std::cout << "First element greater than 5 found: " << *it << std::endl;
} else {
std::cout << "No element greater than 5 found" << std::endl;
}
return 0;
}
2. 计数算法
std::count:统计容器中指定元素出现的次数。例如,统计vector中数字 2 出现的次数:
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 2, 3, 2};
int count = std::count(numbers.begin(), numbers.end(), 2);
std::cout << "Number of 2 in the vector: " << count << std::endl;
return 0;
}
std::count_if:根据用户自定义的谓词函数,统计满足条件的元素个数。比如,统计list中偶数的个数:
#include <list>
#include <algorithm>
#include <iostream>
bool isEven(int num) {
return num % 2 == 0;
}
int main() {
std::list<int> lst = {1, 2, 3, 4, 5};
int count = std::count_if(lst.begin(), lst.end(), isEven);
std::cout << "Number of even numbers in the list: " << count << std::endl;
return 0;
}
(二)修改型算法:数据的 “改造师”
修改型算法会改变容器中元素的值或位置,如同数据的 “改造师”,对数据进行加工处理。
1. 拷贝与替换算法
- std::copy:将一个容器中的元素拷贝到另一个容器中。例如,将vector中的元素拷贝到另一个vector:
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> source = {1, 2, 3};
std::vector<int> target(3);
std::copy(source.begin(), source.end(), target.begin());
for (int num : target) {
std::cout << num << " ";
}
return 0;
}
std::replace:将容器中指定元素替换为另一个元素。如将list中的数字 2 替换为 20:
#include <list>
#include <algorithm>
#include <iostream>
int main() {
std::list<int> lst = {1, 2, 3, 2};
std::replace(lst.begin(), lst.end(), 2, 20);
for (int num : lst) {
std::cout << num << " ";
}
return 0;
}
2. 变换算法
std::transform:对容器中的每个元素应用一个函数,将结果存储到另一个容器(也可存储到原容器)。例如,将vector中的每个元素乘以 2:
#include <vector>
#include <algorithm>
#include <iostream>
int multiplyByTwo(int num) {
return num * 2;
}
int main() {
std::vector<int> numbers = {1, 2, 3};
std::vector<int> result(3);
std::transform(numbers.begin(), numbers.end(), result.begin(), multiplyByTwo);
for (int num : result) {
std::cout << num << " ";
}
return 0;
}
(三)排序算法:数据的 “秩序维护者”
排序算法用于对容器中的元素进行排序,是 STL 算法中最常用的一类,堪称数据的 “秩序维护者”。
1. 通用排序算法
std::sort:对指定范围内的元素进行快速排序,默认按升序排列。它适用于随机访问迭代器的容器,如vector、deque。例如,对vector中的整数进行排序:
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> numbers = {3, 1, 4, 1, 5, 9, 2, 6};
std::sort(numbers.begin(), numbers.end());
for (int num : numbers) {
std::cout << num << " ";
}
return 0;
}
std::stable_sort:与std::sort功能类似,但保证相等元素的相对顺序不变,适用于对稳定性有要求的排序场景。
2. 部分排序算法
std::partial_sort:对指定范围内的元素进行部分排序,将最小的几个元素放在前面。例如,找出vector中最小的 3 个元素:
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> numbers = {3, 1, 4, 1, 5, 9, 2, 6};
std::vector<int> result(3);
std::partial_sort(numbers.begin(), numbers.begin() + 3, numbers.end());
for (int num : numbers) {
std::cout << num << " ";
}
return 0;
}
std::nth_element:将指定范围内的第 n 个元素放在其正确排序位置上,比它小的元素放在左边,比它大的元素放在右边。
(四)数值算法:数据的 “数学魔法师”
数值算法主要用于数值计算,如同数据的 “数学魔法师”,实现各种数学运算。
1. 累加算法
std::accumulate:计算容器中元素的总和。例如,计算vector中所有整数的和:
#include <vector>
#include <numeric>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
int sum = std::accumulate(numbers.begin(), numbers.end(), 0);
std::cout << "Sum of the numbers: " << sum << std::endl;
return 0;
}
2. 内积算法
std::inner_product:计算两个容器对应元素乘积的和,即内积。例如,计算两个vector的内积:
#include <vector>
#include <numeric>
#include <iostream>
int main() {
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = {4, 5, 6};
int result = std::inner_product(v1.begin(), v1.end(), v2.begin(), 0);
std::cout << "Inner product: " << result << std::endl;
return 0;
}
(五) 集合算法:数据的 “集合运算专家”
集合算法用于处理容器间的集合关系,是数据的 “集合运算专家”,包括并集、交集、差集等操作。
1. 并集运算
std::set_union:计算两个有序容器的并集,将结果存储到另一个容器。例如,计算两个set的并集:
#include <set>
#include <algorithm>
#include <iostream>
int main() {
std::set<int> s1 = {1, 2, 3};
std::set<int> s2 = {3, 4, 5};
std::set<int> result;
//std::inserter时迭代器适配器,用于将元素插入到容器(从前往后),具体见后文
std::set_union(s1.begin(), s1.end(), s2.begin(), s2.end(), std::inserter(result, result.begin()));
for (int num : result) {
std::cout << num << " ";
}
return 0;
}
2. 交集运算
std::set_intersection:计算两个有序容器的交集,将结果存储到另一个容器。如计算两个set的交集:
#include <set>
#include <algorithm>
#include <iostream>
int main() {
std::set<int> s1 = {1, 2, 3};
std::set<int> s2 = {3, 4, 5};
std::set<int> result;
std::set_intersection(s1.begin(), s1.end(), s2.begin(), s2.end(), std::inserter(result, result.begin()));
for (int num : result) {
std::cout << num << " ";
}
return 0;
}
四、迭代器:连接容器与算法的 “桥梁”
迭代器是一种广义指针,它提供了一种访问容器中元素的统一接口。与普通指针不同,迭代器不仅具备指针的基本解引用和递增等操作,还根据不同容器和算法的需求,扩展了丰富的功能。例如,在 STL 算法中,绝大多数算法都是通过迭代器来指定操作范围,这种设计使得算法与容器分离,增强了代码的通用性和可复用性。
以std::vector和std::list为例,虽然它们的底层数据结构差异巨大,vector采用连续内存存储,list采用双向链表结构,但通过迭代器,std::sort等算法无需关心容器的具体实现,只需使用迭代器指定范围,就能对其中的元素进行排序操作。这就如同不同品牌、不同型号的汽车,都可以使用通用的加油枪加油,迭代器就是 STL 中统一的 “加油枪”。
STL 中的迭代器主要分为五种类型:输入迭代器、输出迭代器、前向迭代器、双向迭代器和随机访问迭代器,每种类型都有其独特的功能和适用场景。
(一)输入迭代器
输入迭代器用于从容器中读取数据,它可以被解引用获取元素值,也可以递增指向下一个元素。但输入迭代器只能进行一次遍历,不能重复访问已经遍历过的元素,且不支持元素的修改操作。它就像一个只能 “读碟” 一次的光驱,读完后无法回退重新读取。
例如,std::istream_iterator是典型的输入迭代器,常用于从标准输入流读取数据到容器中:
#include <iostream>
#include <vector>
#include <iterator>
int main() {
std::vector<int> numbers;
std::copy(std::istream_iterator<int>(std::cin), std::istream_iterator<int>(), std::back_inserter(numbers));
for (int num : numbers) {
std::cout << num << " ";
}
return 0;
}
在上述代码中,std::istream_iterator<int>(std::cin)和std::istream_iterator<int>()构成了输入迭代器范围,std::copy算法通过它们从标准输入读取整数,并将数据存储到vector中。
(二) 输出迭代器
输出迭代器用于向容器中写入数据,它只能进行递增操作和赋值操作,不能解引用读取元素值,也不支持随机访问。输出迭代器类似打印机的纸槽,只能按顺序接收数据并输出,无法查看已输出的数据。
std::ostream_iterator是常见的输出迭代器,用于将容器中的元素输出到标准输出流:
#include <iostream>
#include <vector>
#include <iterator>
int main() {
std::vector<int> numbers = {1, 2, 3};
std::copy(numbers.begin(), numbers.end(), std::ostream_iterator<int>(std::cout, " "));
return 0;
}
这里,std::ostream_iterator<int>(std::cout, " ")作为输出迭代器,将vector中的元素依次输出到控制台,并以空格分隔。
(三)前向迭代器
前向迭代器在输入迭代器的基础上,允许对容器进行多次遍历,它支持解引用读取元素值、递增操作,还可以进行赋值操作来修改元素。前向迭代器如同只能前进的单向列车,可在轨道上多次行驶,每次都能访问沿途站点(元素)。
std::list的迭代器就是前向迭代器,适用于只需要顺序遍历容器的场景:
#include <list>
#include <iostream>
int main() {
std::list<int> lst = {1, 2, 3};
for (std::list<int>::iterator it = lst.begin(); it != lst.end(); ++it) {
*it = *it * 2;
std::cout << *it << " ";
}
return 0;
}
代码中通过前向迭代器遍历list,对每个元素进行翻倍操作并输出。
(四) 双向迭代器
双向迭代器在前向迭代器的基础上,增加了递减操作,使得迭代器可以在容器中前后移动,方便进行反向遍历。双向迭代器就像可以双向行驶的地铁,能够灵活地在轨道上往返。
std::set和std::map的迭代器均为双向迭代器。例如,使用双向迭代器反向遍历set:
#include <set>
#include <iostream>
int main() {
std::set<int> s = {1, 2, 3};
for (std::set<int>::reverse_iterator rit = s.rbegin(); rit != s.rend(); ++rit) {
std::cout << *rit << " ";
}
return 0;
}
上述代码通过反向迭代器(基于双向迭代器实现),从后往前遍历set并输出元素。
(五)随机访问迭代器
随机访问迭代器是功能最强大的迭代器类型,它支持所有的指针算术运算,包括加法、减法、比较运算等,可以像普通数组指针一样随机访问容器中的任意元素。随机访问迭代器好比拥有瞬移能力的 “超级传送门”,能快速到达容器中的任何位置。
std::vector和std::deque的迭代器属于随机访问迭代器。利用随机访问迭代器,可以高效地实现对容器元素的快速定位和操作:
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
std::vector<int>::iterator it = numbers.begin() + 2;
std::cout << "The third element is: " << *it << std::endl;
return 0;
}
在这段代码中,通过将迭代器it移动到第三个元素的位置,直接访问并输出该元素。
(六) 迭代器失效深度讨论
迭代器失效是使用 STL 迭代器时需要重点关注的问题,其本质原因在于容器内部结构的改变,导致原有的迭代器无法正确指向元素。不同类型的容器在进行插入、删除等操作时,迭代器失效的情况各有不同。
1.vector 迭代器失效情况
vector采用连续内存存储数据,当进行插入操作时,如果插入元素后容器的容量不足,vector会重新分配一块更大的内存空间,并将原有的数据拷贝过去。在这个过程中,所有指向原内存空间的迭代器都会失效。例如:
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3};
std::vector<int>::iterator it = numbers.begin();
numbers.push_back(4); // 假设此时未触发内存重新分配
*it = 10; // 此时it有效
numbers.push_back(5); // 假设此时触发内存重新分配
// *it = 20; // 这行代码会导致未定义行为,因为it已经失效
return 0;
}
为避免vector迭代器失效,在插入元素后,可以重新获取迭代器,例如在插入后使用it = numbers.begin();更新迭代器。
在删除操作中,删除某个元素后,该元素之后的迭代器都会失效。例如:
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3};
std::vector<int>::iterator it = numbers.begin() + 1;
numbers.erase(numbers.begin());
// *it = 20; // 这行代码会导致未定义行为,因为it已经失效
return 0;
}
此时可以通过it = numbers.erase(numbers.begin());来删除元素并更新迭代器,erase函数会返回删除元素之后的迭代器,保证迭代器的有效性。
2.list 迭代器失效情况
list是双向链表结构,插入操作通常不会导致其他迭代器失效,因为插入元素只需要改变相邻节点的指针指向,不会影响其他节点的内存地址。但在删除操作时,指向被删除元素的迭代器以及其相邻的迭代器(在某些实现中)会失效。例如:
#include <list>
#include <iostream>
int main() {
std::list<int> lst = {1, 2, 3};
std::list<int>::iterator it = lst.begin();
std::advance(it, 1); // it指向2
lst.erase(it);
// *it = 20; // 这行代码会导致未定义行为,因为it已经失效
return 0;
}
为避免list迭代器失效,可以在删除元素前保存下一个迭代器,如
auto nextIt = std::next(it);
lst.erase(it);
it = nextIt;
这样就能保证迭代器的正确使用。
3.map 和 set 迭代器失效情况
map和set基于红黑树实现,插入操作一般不会使迭代器失效,但当插入元素导致红黑树的结构发生重大调整(如重新平衡)时,部分迭代器可能会失效。删除操作时,指向被删除元素的迭代器会失效。例如:
#include <set>
#include <iostream>
int main() {
std::set<int> s = {1, 2, 3};
std::set<int>::iterator it = s.begin();
s.erase(2);
// *it = 20; // 这行代码会导致未定义行为,如果it指向的是被删除的2,此时it已经失效
return 0;
}
在使用map和set时,为防止迭代器失效,在删除元素后,若还需使用迭代器,应避免继续使用指向被删除元素的迭代器,可重新获取迭代器进行后续操作。
五、适配器与分配器:STL 的 “扩展配件”
在 C++ STL 的庞大体系中,适配器与分配器如同精心设计的 “扩展配件”,为程序开发带来更多的灵活性与定制性。适配器通过转换接口的方式,让不同的组件能够协同工作;分配器则负责管理容器中元素的内存分配与释放,开发者可根据需求定制内存管理策略。接下来,我们深入探索这两个重要组件的工作原理与实际应用。
(一)适配器
适配器的核心功能是对容器、迭代器或函数的接口进行转换,使其满足特定的需求,就像桥梁工程师搭建不同道路间的连接桥梁,让原本无法直接协同的组件顺畅合作。STL 中的适配器主要分为容器适配器、迭代器适配器和函数适配器。
1.容器适配器
容器适配器基于其他容器实现,通过封装特定的操作,提供更符合特定需求的数据结构接口。常见的容器适配器有std::stack、std::queue和std::priority_queue。
(1)std::stack
后进先出(LIFO)的数据结构,常用于函数调用栈、表达式求值等场景。它默认基于std::deque实现,也可使用std::vector等支持push_back、pop_back和back操作的容器作为底层容器。
适配器可以改变容器或算法的接口,使其满足特定的需求。例如,std::stack和std::queue就是基于其他容器实现的适配器。std::stack是一种后进先出(LIFO)的数据结构,std::queue是一种先进先出(FIFO)的数据结构。
#include <stack>
#include <iostream>
int main() {
std::stack<int> stk;
stk.push(1);
stk.push(2);
std::cout << "Top element: " << stk.top() << std::endl;
stk.pop();
std::cout << "New top element: " << stk.top() << std::endl;
return 0;
}
std::stack提供push向栈顶压入元素,pop弹出栈顶元素,top访问栈顶元素等操作。
(2)std::queue
先进先出(FIFO)的数据结构,适用于任务队列、消息队列等场景。它默认基于std::deque,也可使用其他合适的容器。
#include <queue>
#include <iostream>
int main() {
std::queue<int> q;
q.push(1);
q.push(2);
std::cout << "Front element: " << q.front() << std::endl;
q.pop();
std::cout << "New front element: " << q.front() << std::endl;
return 0;
}
std::queue的push用于将元素入队,pop使队首元素出队,front访问队首元素,back访问队尾元素
(3)std::priority_queue
优先队列,元素按照特定的优先级顺序出队,默认使用std::less比较函数,基于std::vector实现。常用于任务调度、最短路径算法等场景。
#include <queue>
#include <iostream>
int main() {
std::priority_queue<int> pq;
pq.push(3);
pq.push(1);
pq.push(2);
std::cout << "Top element (highest priority): " << pq.top() << std::endl;
pq.pop();
std::cout << "New top element: " << pq.top() << std::endl;
return 0;
}
在优先队列中,优先级高的元素先出队,用户也可自定义比较函数来调整元素的优先级规则。
2.迭代器适配器
迭代器适配器用于修改迭代器的行为或功能,为操作容器元素提供更多便利。常见的迭代器适配器包括std::reverse_iterator、std::back_inserter、std::front_inserter和std::inserter。
(1)std::reverse_iterator
反向迭代器,可使迭代器反向遍历容器。例如,反向遍历vector:
#include <vector>
#include <iostream>
#include <iterator>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (std::vector<int>::reverse_iterator rit = numbers.rbegin(); rit != numbers.rend(); ++rit) {
std::cout << *rit << " ";
}
return 0;
}
通过rbegin和rend获取反向迭代器,即可从后往前遍历容器。
(2)std::back_inserter
后向插入迭代器,将元素插入到容器的末尾,常用于std::copy等算法中,实现数据的追加。
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> source = {1, 2, 3};
std::vector<int> target;
std::copy(source.begin(), source.end(), std::back_inserter(target));
for (int num : target) {
std::cout << num << " ";
}
return 0;
}
std::back_inserter会调用容器的push_back操作,确保元素插入到容器尾部。
3.函数适配器
函数适配器用于修改函数对象的行为,常见的有std::bind、std::mem_fn等。
(1)std::bind
将函数的某些参数绑定为固定值,生成一个新的可调用对象。例如,绑定std::plus函数的一个参数:
#include <functional>
#include <iostream>
int main() {
auto addFive = std::bind(std::plus<int>(), std::placeholders::_1, 5);//placeholders占1位参数位,实现传入参数+5后输出结果的功能
std::cout << addFive(3) << std::endl;
return 0;
}
std::bind通过std::placeholders::_1等占位符指定参数位置,实现对函数参数的灵活绑定。
(2)std::mem_fn
用于生成指向成员函数的函数对象,方便对类对象的成员函数进行调用。
#include <iostream>
#include <functional>
class MyClass {
public:
void print() {
std::cout << "Hello from MyClass" << std::endl;
}
};
int main() {
MyClass obj;
auto printFn = std::mem_fn(&MyClass::print);
printFn(obj);
return 0;
}
std::mem_fn简化了对类成员函数的调用方式,提高了代码的可读性。
(二)分配器
分配器负责容器中元素的内存分配和释放,是 STL 内存管理的核心组件,如同幕后管家,默默处理着内存资源的调配工作。默认情况下,STL 容器使用标准分配器,但开发者也可根据需求自定义分配器。
1.分配器的基本原理
分配器定义了一系列内存分配和释放的接口,如allocate用于分配内存,deallocate用于释放内存。当容器需要存储元素时,会调用分配器的allocate方法申请内存;当元素不再需要时,通过deallocate方法释放内存。
(标准分配器allocate和deallocate的底层原理类似内存池,可以参考我以前文章:c++ 手写内存池:实现三级缓冲结构内存池)
2.自定义分配器
自定义分配器需要实现标准分配器的接口,并遵循一定的规范。例如,简单实现一个自定义分配器:
#include <memory>
template <typename T>
class MyAllocator {
public:
using value_type = T;
using pointer = T*;
using const_pointer = const T*;
using reference = T&;
using const_reference = const T&;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
template <typename U>
struct rebind {
using other = MyAllocator<U>;
};
pointer allocate(size_type n) {
return static_cast<pointer>(::operator new(n * sizeof(T)));
}
void deallocate(pointer p, size_type n) {
::operator delete(p);
}
};
自定义分配器可用于特定的内存管理场景,如实现内存池、提高内存分配效率等。
在大规模数据处理中,自定义分配器可优化内存分配策略,减少内存碎片;在嵌入式系统开发中,由于内存资源有限,通过定制分配器能更精确地控制内存使用;在高性能计算领域,合理的分配器设计可提升程序的运行效率。
3.分配器的应用场景
在大规模数据处理中,自定义分配器可优化内存分配策略,减少内存碎片;在嵌入式系统开发中,由于内存资源有限,通过定制分配器能更精确地控制内存使用;在高性能计算领域,合理的分配器设计可提升程序的运行效率。
STL 作为 C++ 编程的重要组成部分,其丰富的功能和强大的性能为开发者提供了极大的便利。从容器的数据存储,到算法的高效处理,再到迭代器的灵活访问,以及适配器和分配器的扩展功能,每一个部分都蕴含着编程的智慧。随着 C++ 标准的不断演进,STL 也在持续发展和完善,未来将会有更多实用的特性加入。希望通过本文的介绍,你能对 STL 有更深入的理解,并在实际编程中熟练运用它,开启高效编程的新篇章。