从刚入门C++刷题那会儿,我最大的感受就是“那些AC的题解,怎么都用上了我没见过的API”。同样的逻辑,有人用三行STL搞定,有人却要手写十行还容易出错。C++算法题里最值钱的不是语法本身,而是标准库那些被反复验证过的“轮子”。这篇我就把刷题两年多来真正高频、真正救过命的C++算法常用API理一遍,包括输入输出优化、容器选型、排序二分、字符串处理、位运算、图论写法,再附上我踩过的坑。全文适合正在刷leetcode、蓝桥杯、ACM校赛或者准备算法面试的朋友,C++基础要有一点,但不用深。
1. 输入输出:别让性能瓶颈卡在第一步
1.1 同步开关与快速读写模板
算法题里最容易让人忽略的,是cin和cout的默认行为。C++的iostream为了和C标准库兼容,默认会和stdio保持同步,这意味着每次cin都会做额外的同步检查,数据量一大,效率差距非常明显。我见过不少新手在10^5级数据下用cin超时,换了scanf立刻过。但这不是说必须放弃cin,而是要先做两件事:ios::sync_with_stdio(false);和cin.tie(nullptr);。
第一句切断与stdio的同步,让cin只走自己的缓冲区;第二句解绑cin和cout的绑定关系。默认情况下每次输出都会先刷新输入缓冲区,解绑之后速度能再上一个台阶。我个人的习惯模板是这样:
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; for (int i = 0; i < n; ++i) { int x; cin >> x; cout << x << '\n'; } return 0; }注意输出尽量用'\n'而不是endl。endl会在换行的同时强制刷新输出缓冲区,刷题场景完全没必要,白白损失性能。这个细节在输出量大的题里特别致命,我有一次就是全用endl超时,改成换行符直接快了一倍。
1.2 格式化输出与浮点精度控制
算法题常遇到要求输出固定小数位数的场景,比如“保留两位小数”。纯用cout的话要记得加fixed << setprecision(n):
cout << fixed << setprecision(2) << ans << '\n';fixed表示以定点形式输出,不写的话setprecision(2)是对总有效位数生效,结果很可能和预期完全不一样。我试过不少次忘记加fixed,输出9.9的地方变成了9.8999,排查半天才发现是格式问题。
读取字符串时还要注意一个细节:cin >> s遇到空格会停止,而很多题面的整行输入会包含空格,这时候需要用getline(cin, str)。但getline有一个经典大坑:如果前面用过cin >>,输入流里会残留一个换行符,getline会直接读到空串。解决方法很简单,在getline之前加一句cin.ignore();,把残留的换行吞掉。这个坑几乎每个刷题的人都踩过。
2. 容器选型:用对容器,等于省一半时间
2.1 vector 与 string:默认选择背后的扩容逻辑
vector和string是刷题时最高频的两个连续容器,绝大多数情况它们是默认选择。vector的底层是动态数组,支持随机访问,尾部插入删除是O(1)均摊,中间插入删除是O(n)。很多人不知道的是,vector扩容不是每次加一个元素就重新分配一次内存,而是按比例扩容,常见编译器是1.5倍或2倍,均摊复杂度才是O(1)。
但扩容有一个隐藏代价:元素移动。如果元素是自定义结构体且比较重,扩容时会触发大量拷贝,影响性能。如果事先知道数据规模,直接vector<int> v(n);预留空间,避免中途扩容。另一个细节是v.reserve(n)只预留容量不改变大小,v.resize(n)会改变大小并构造元素,两者用途完全不同,别搞混。
string本质上也是动态数组,但多了字符串专用操作。刷题时经常有人纠结用char[]还是string,我的建议是无脑string。它管理内存、支持直接+拼接、有丰富的成员函数,关键是写起来快,不容易出现数组越界或者忘记\0这样的低级问题。
2.2 map 与 unordered_map:有序与哈希怎么选
说到键值对,很多人第一反应是map,然后发现运行时间不好看,换成unordered_map又经常担心哈希冲突。我用这两个容器的经验可以总结成几句话。
map底层是红黑树,保证键有序,所有操作O(log n),这个复杂度非常稳定,不管数据怎么构造都不会退化。需要按键排序输出、找大于某个键的第一个元素、求前驱后继时,map是无脑选择。
unordered_map底层是哈希表,均摊O(1),听起来快得多。但哈希表存在几个隐患:第一是哈希冲突,极端构造的数据能让冲突急剧增加,退化到O(n),这在算法竞赛里是可以被卡死的点;第二是unordered_map的内存占用普遍比map大;第三就是自定义类型需要自己提供哈希函数,比较繁琐。
我的选型原则是:需要有序性选map,纯查询、数据随机、键类型是int/string等内置类型时优先unordered_map。但如果你不确定数据会不会被针对性构造,老老实实用map最安全。这个“安全第一”的思路,在ACM区域赛级别的题里尤其重要。
2.3 queue、stack、deque与priority_queue的适用场景
队列、栈、双端队列、优先队列四兄弟,各自有非常明确的适用场景。queue就是BFS的标配,FIFO顺序,没有任何取巧空间,直接用。stack常用于DFS的迭代实现、括号匹配、表达式求值这类题目。deque是双端队列,两端插入删除都是O(1),滑动窗口问题里特别好用,因为它支持从头和尾部同时操作。
priority_queue是算法题里最被低估的容器,它是堆结构,底层默认是大根堆,也就是队头元素最大。需要小根堆时,要么声明时多写两个参数,要么直接存负数。最省事的写法是:
priority_queue<int, vector<int>, greater<int>> pq; // 小根堆图论里的Dijkstra、贪心里的“每次取最小值”、合并果子这类经典题,全都要靠它。用过之后你会发现,手写堆的场景在刷题阶段基本消失了,priority_queue就是帮你把堆封装好的那个轮子。
3. 常用算法API:刷题时的“军火库”
3.1 sort、stable_sort与自定义比较
排序是算法题里最基础的技能,C++的sort是内省排序,平均情况O(n log n),最坏情况下也能保证O(n log n),实际上用的是一种结合快排、堆排、插入排序的混合策略。直接对vector排序:
sort(v.begin(), v.end()); // 升序 sort(v.rbegin(), v.rend()); // 降序反向迭代器rbegin和rend估计是很多人会忽略的高级用法,它比sort(v.begin(), v.end(), greater<int>())写起来更短。
自定义排序也是刷题高频需求,比如按结构体某个字段排序。核心写法是用lambda表达式作为比较器:
sort(v.begin(), v.end(), [](const Node& a, const Node& b) { return a.val > b.val; // 按val降序 });stable_sort的区别在于它是稳定的,相同关键字的元素不改变相对顺序。大多数排序题用sort就够了,如果需要同时按多个字段排序,并且要求主关键字相同时保持输入顺序,那就必须用stable_sort,或者在比较器里对次关键字也做判断。
3.2 lower_bound与upper_bound:二分边界不再手写
手写二分是大忌,不是不能写,是太容易在处理边界时翻车。C++标准库的lower_bound和upper_bound是刷题党的救星,两者都要求序列已经有序。
lower_bound(begin, end, x)返回第一个大于等于x的迭代器upper_bound(begin, end, x)返回第一个大于x的迭代器
它们在二分查找、判断元素是否存在、统计某个值的出现次数、插入位置确定等场景里无所不能。配合vector使用:
int pos = lower_bound(v.begin(), v.end(), x) - v.begin(); bool exists = binary_search(v.begin(), v.end(), x);binary_search只返回是否存在,不返回位置,所以实际刷题中lower_bound的后两个兄弟用处更大。还有一个绝妙的组合:用lower_bound在二分答案里配合前缀和做区间计数,这个技巧在二维前缀和、树状数组问题里反复出现。
equal_range是我后来才发现的一个宝贝,它一次性返回等于某值的区间范围,相当于同时调用lower_bound和upper_bound。返回pair,可以直接用来统计某个值在有序数组里出现的区间起点和终点。
3.3 accumulate、minmax_element与iota的妙用
这三个函数虽然不那么显眼,但关键时刻非常省事。accumulate用来求和:
int sum = accumulate(v.begin(), v.end(), 0); long long sum = accumulate(v.begin(), v.end(), 0LL);注意第三个参数是初始值,类型决定了整个累加的类型。如果容器里存的是int,直接传0,求和结果会被截断成int。涉及大数时后面一定加0LL,这个细节坑了不少人,包括我。
minmax_element一趟遍历同时拿到最小值和最大值,比单独调两次min_element和max_element效率更高:
auto [minIt, maxIt] = minmax_element(v.begin(), v.end());iota的作用是把区间依次填充为递增的值,比如把一个数组初始化为1,2,3,...,再配上一个按照某种规则排序的lambda,可以实现“把下标按对应值的大小排序”这类需求。这种“间接排序”的高频技巧,配合iota能写得非常优雅。
3.4 next_permutation:全排列问题的最短解
全排列问题几乎是入门必刷的题,如果每次都手写回溯,费时又容易错。标准库的next_permutation(begin, end)直接给出字典序的下一排列,配合do-while循环可以枚举所有排列:
sort(a.begin(), a.end()); // 必须先排序,才能从最小排列开始 do { // 处理当前排列 } while (next_permutation(a.begin(), a.end()));类似的还有prev_permutation,生成上一排列,实际用得少,但偶尔需要从某个已知排列开始逆序枚举时很有用。这一个API足以应付入门阶段几乎所有的全排列枚举题,不需要手写递归。
4. 字符串与数值转换:一头一尾的高频操作
4.1 字符串与数字互转的几种方式
算法题里字符串与数字的互相转换出现频率极高。C++提供的最简方法是:
int x = stoi(s); // string转int,遇到非法字符会抛异常 long long y = stoll(s); // string转long long string t = to_string(x); // 数字转stringstoi系列还能指定起始位置和进制:stoi(s, nullptr, 16)表示按16进制解析。但要注意它的抛异常行为,如果在刷题的环境里输入一定合法,可以放心用;如果输入可能包含非数字字符,需要考虑捕获std::invalid_argument异常,或者用stringstream:
stringstream ss(s); int x; ss >> x;stringstream的缺点是慢,在循环里大量做转换时性能拉胯。我通常只在需要复杂解析(比如同时包含字母和数字,需要多次抽取)时才用它。
遇到超大的数字,比如10^18以上,long long也装不下,可以考虑直接用字符串处理,或者用__int128,这在GCC下可用,但要注意它不能直接通过iostream输入输出,需要自己写转换。
4.2 substr、find和split的常见姿势
刷题时对字符串的处理,主要集中在截取、查找、分割三类需求。substr截取子串:
string sub = s.substr(pos, len); // 从pos开始取len个字符第二个参数不写则一直取到末尾。这个函数很好用,但耗时是O(len),如果你反复截取一个长字符串的多个子串再做处理,性能会翻车。更优的做法是用下标直接访问原字符串,避免拷贝。
find查找某个子串或字符的位置:
size_t pos = s.find("abc"); // 找不到时返回string::npos判断等用if (pos != string::npos),这个习惯要刻进肌肉记忆,因为string::npos的实际值是最大size_t,直接当真值判断容易踩坑。
split在C++里没有现成API,这是许多转Python选手最不适应的点。最常用的替代姿势是配合getline用分隔符切割:
stringstream ss(s); string token; while (getline(ss, token, ',')) { // 按逗号切割 }要注意的是这种方式会吞掉空字符串,某些场景需要保留空token时得自己写手动扫描。
4.3 前缀和与哈希的API配合
前缀和本身不算API,但在算法题里应用范围极广。它解决的问题是“区间和”的高频查询,做法是先预处理数组:
vector<long long> pre(n + 1, 0); for (int i = 0; i < n; ++i) { pre[i + 1] = pre[i] + a[i]; } // 区间[l, r]的和 = pre[r + 1] - pre[l]有了前缀和后,如果还需要快速判断某个区间内某个字符/数字出现的次数,可以扩展成“前缀计数数组”,每类字符维护一个前缀和。
另外,遇到字符串匹配的题,KMP是经典解法。标准库没有提供KMP,但大多数刷题平台支持C++17的std::search或std::string::find,它们的实现通常足够快,适合数据规模不大的题。数据量大或者需要严格复杂度时,还是要手写next数组实现KMP。这个差距在特定题里非常明显,比如1010个长度级别的字符串匹配,find在极端情况下退化会超时。
5. 位运算与图论场景的API速查
5.1 __builtin系列:一个循环都不写
位运算是很多竞赛题的隐藏考点。GCC系编译器的__builtin系列函数是刷题党爱不释手的武器:
int cnt = __builtin_popcount(x); // 统计二进制中1的个数 int cnt = __builtin_popcountll(x); // long long版本 int low = __builtin_ctz(x); // 末尾0的个数,即lowbit对应的是2^ctz int high = __builtin_clz(x); // 前导0的个数其中__builtin_ctz和(x & -x)结合使用,可以直接拿到最低位的1对应的值,这在树状数组、枚举子集时非常关键。比如循环枚举一个集合的子集可以用这个技巧:
for (int sub = mask; sub; sub = (sub - 1) & mask) { // 子集sub }配合popcount做剪枝也极其常见,比如判断一个数是不是2的幂:__builtin_popcount(x) == 1。
5.2 邻接表与pair的组合使用
图论题的存图方式,邻接表是默认选择。相比直接用vector<vector<int>> graph,带权图更常用的是vector<pair<int, int>>,第一个int存邻居节点,第二个int存边权:
vector<vector<pair<int, int>>> graph(n); graph[u].push_back({v, w});C++11以后,花括号初始化pair非常方便,直接{v, w}就能构造。访问的时候用:
for (auto [v, w] : graph[u]) { // 处理边u->v,权值为w }结构绑定语法让遍历看起来非常清爽,这也是我现在写图的固定姿势。相比定义结构体Edge再重载操作符,pair方案在写法上短了一截,逻辑也更直接。
5.3 Dijkstra中的优先队列写法
最短路里的Dijkstra是优先队列的经典应用。算法思路是每次从未确定最短路的节点中取出距离最小的节点进行松弛,这个“取出最小”由priority_queue完成。常见的写法:
const long long INF = 0x3f3f3f3f3f3f3f3fLL; vector<long long> dist(n, INF); priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<pair<long long, int>>> pq; dist[s] = 0; pq.push({0, s}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d > dist[u]) continue; // 重要剪枝:跳过旧的过期元素 for (auto [v, w] : graph[u]) { if (dist[v] > dist[u] + w) { dist[v] = dist[u] + w; pq.push({dist[v], v}); } } }这里优先队列存的是pair<距离, 节点号>,默认排序先比较第一维再比较第二维,刚好满足“按距离取最小”的需求。小根堆用greater参数声明,注意头文件是<queue>和<functional>,不过用bits/stdc++.h的话都一起带进来了。“跳过过期元素”那行continue必须有,否则同一个节点可能被重复松弛多次,复杂度会退化。
6. 避坑指南:这些坑我踩过,你直接避开
6.1 迭代器失效与删除时的循环写法
用vector和string时,插入和删除操作会导致迭代器失效,这是入门者最容易翻车的点之一。比如在for循环里直接erase某个元素,然后继续对同一个迭代器操作,轻则逻辑错误,重则崩溃。
删除满足条件的元素,最健壮的方式是配合remove-erase惯用法:
v.erase(remove_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }), v.end());remove_if把不删除的元素挪到前面,返回新的逻辑尾部,erase再把后面的残留删掉。这一套组合不仅代码短,而且是O(n),比循环erase高效得多。如果一定要在遍历中删除,必须用迭代器返回值的惯用法:
for (auto it = v.begin(); it != v.end(); ) { if (*it % 2 == 0) it = v.erase(it); else ++it; }这里erase返回下一个有效迭代器,相当于每次删除后更新it。这个细节记不住的话,用remove-erase是最省心的。
6.2 比较函数与严格弱序
自定义比较器翻车,多半是因为没有遵循“严格弱序”的规则。简单说就是比较器的结果必须像小于号那样,满足不对称、可传递、每个元素与自身不成立这三个要求。最容易出的错误是写反了等号,或者把>=、<=写进去。
sort(v.begin(), v.end(), [](int a, int b) { return a >= b; // 错误! });用>=违反严格弱序,会让sort的底层逻辑无法正确处理相等元素,可能导致排序结果完全乱掉,甚至越界崩溃。正确写法是return a > b;。同理,多字段比较时,要小心对每个字段都使用严格的>或者<,不要在某个地方写成>=。这个坑排查起来特别费劲,因为程序没有明显报错,但结果就是不对劲。
6.3 unordered_map的哈希冲突与自定义哈希
unordered_map虽然平均O(1),但哈希函数被专门构造的攻击数据卡死时,复杂度会退化。某些刷题平台上有专门卡unordered_map<string, int>的测试点,特别是当键是自己构造的结构体或者长字符串时,冲突概率更高。
一个实用的改善方法是给unordered_map指定自定义哈希:
struct CustomHash { static uint64_t splitmix64(uint64_t x) { x += 0x9e3779b97f4a7c15ULL; x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL; x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL; return x ^ (x >> 31); } size_t operator()(uint64_t x) const { static const uint64_t FIXED_RANDOM = chrono::steady_clock::now().time_since_epoch().count(); return splitmix64(x + FIXED_RANDOM); } }; unordered_map<long long, int, CustomHash> mp;这样即使键是连续增长的数值,也不会因为默认哈希的规律性导致大量碰撞。不过话说回来,题目没有明确针对哈希容器构造数据时,默认的unordered_map完全够用,不要过早优化,先跑一遍再考虑替换。
6.4 暴力枚举与剪枝的心态问题
最后聊点非API层面的心得。很多新手面对暴力枚举题,第一反应是“这题没技术含量”,但实际上,暴力+剪枝往往是一道题从TLE到AC的关键。所谓剪枝,就是在枚举的过程中提前排除不可能产生答案的分支。比如排列组合类问题,可以先判断当前部分前缀已经超过目标值,就直接return,不再继续深入。
配合C++的API,剪枝实现的常见手法是先排序再枚举(方便提前判断),或者在回溯函数里用参数传递当前累积值,避免重复计算。如果一道题的数据范围在20左右,二进制枚举子集加popcount判断,往往比复杂的动态规划还快,代码量也小。暴力枚举不是丢人的解法,能在限定范围内跑出正确答案就是好解法,等遇到数据规模推不动了,再去想优化策略。我见过不少选手一上来就写高级数据结构,结果代码半天调不通,反而是先暴力后剪枝的版本秒过,这种“从暴力出发,向优化演进”的节奏,才是刷题最稳的路径。
要说还有什么亲身体会,就是刷题时别急着背API列表,更重要的记住了功能和适用场景,用错了容器再漂亮的API也会变成性能杀手。我自己曾经在优先队列里用过默认的大根堆做Dijkstra,样例全过,大数据TLE,查了一个下午才发现问题。现在我的习惯是每次提交前都快速检查一遍:容器选型对不对、比较器是不是严格弱序、输出有没有多余刷新、数据类型有没有溢出。这套检查和上面的API清单一起,陪我打了不少比赛,也推荐你从现在开始就养成同样的习惯。