最近把自己过去三个月刷的算法题重新翻了一遍,发现绝大多数当时“改到吐血”的题,现在都记不清楚到底卡在哪儿了。痛定思痛,我决定以“自用笔记”的形式,把C++算法入门阶段最容易踩坑、最值得反复回头看的点,系统整理成一个长期维护的文档。这篇文章就是这个笔记的一部分,也是我整理思路的起点。
先说清楚这篇内容的定位:不是一份面面俱到的C++教程,也不是复杂的竞赛高级算法教材,而是给那些“刚刚学完C++语法、准备拿算法题练手,但经常卡在莫名其妙的调试和STL细节上”的人。你不需要已经会指针、模板这些东西,只要能写循环、数组、函数,就能从这篇文章里拿走很实用的东西。内容里涉及的标准库、调试手段、刷题顺序,都是我实际试过、踩过坑之后留下来的结论。
我准备把这份笔记分成几块来记:语言选型逻辑、基础模板、调试能力、STL容器的实战姿势、刷题节奏、以及笔记本身怎么维护。下面是当前版本已经整理出来的部分。
1. 为什么入门算法我选C++而不是Java或Python
很多刚开始学算法的人会纠结语言选型,我的体会是:选C++并不因为它“最好”,而是因为在算法学习这条路上,它能够用最小的阻碍让你同时理解“逻辑”和“机器”两边的事情。
第一,C++的编译运行效率在入门阶段非常宽容。初学算法的人写出的第一版解法,十有八九是暴力枚举或者复杂度过高的写法,用动态语言跑可能直接超时,用C++往往还能在数据量不大的情况下跑完。这种“慢代码最后也能得到正确结果”的体验,对建立信心很有帮助。等学到复杂度优化阶段,你再回头用C++测同一个问题,也能更直观地感知到“到底是算法变快了,还是语言本身快”。
第二,C++标准库里的容器和算法,覆盖了刷题时90%以上的数据组织需求。vector、queue、stack、map、set、priority_queue,每一样都能对应到某一类经典问题。而且这些容器底层是如何用内存、如何在增删时移动元素,这些理解起来并不难,却会让你的抽象能力往前走一大步。相比之下,Python里的list啪一下append很方便,但你对底层发生的操作没有概念。
第三,C++的“引用”和“传值”差异非常显眼。初学者用Java或Python时不必关心参数传进去会不会产生拷贝,而C++里你会被反复提醒:这里要加引用,不然整个vector会被复制一遍。这种“疼过”之后记住的效率意识,在以后设计接口和写业务代码时都非常值钱。
当然,我并不是说人人学算法都该从C++开始。如果你目前的工作方向已经完全钉死在Java或者Python生态,而且并不打算深入底层,那用熟悉语言开始也未尝不可。但如果你明确要走技术开发方向、想扎实理解数据结构与算法的核心概念,C++是我个人最推荐的第一门算法语言。语言选型这件事,也可以参考我的判断维度:运行速度、容器丰富度、对内存/引用的可见性、网上学习资料的数量。C++在这四个维度上都很均衡,尤其最后一项,几乎所有经典算法教材和题解都有C++版本,遇到不会的题,看题解时的语言门槛最低。
2. 算法笔记必备的基础模板:从环境到快读快写
在开始大量刷题之前,我先花了一点时间整理自己的“起手式”。这个起手式包含三件事:环境选择、输入输出习惯、一段可以直接复制到任何题目里的代码骨架。
先说环境。我个人是在本地用一套轻量配置,包含编译器和代码编辑器,日常练习先在本地写好、跑出结果,再提交到在线评测平台验证。这样做的理由很简单:本地可以随意printf、打断点,调试效率远高于在网页上盲改。环境不必折腾得太豪华,够用就行。图形化调试器用自带的那套就行,关键是会用断点,这一块后面单独说。
然后是输入输出。cin和cout在同步关闭之后,性能上和scanf/printf的差距已经很小,日常刷题完全够用。我习惯在模板开头的main函数里加一行:
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); // 从这里开始写题 return 0; }这里需要注意,ios::sync_with_stdio(false)关闭了C与C++输入输出流的同步,让cin不再因为兼容printf而变慢;cin.tie(nullptr)解除了cin和cout的绑定,避免每次输入都强制刷新输出缓冲区。这两行加起来就是竞赛圈说的“关流”,它能让你的cin速度明显提升。有人担心这样再混用printf会混乱,这是对的,所以我给自己定的规矩是:用了这块模板之后,全程只用cin/cout。
对于需要读取大量整数、且每一行数量不固定的场景,我还会用getline配合stringstream来按行拆分,这个技巧在处理“第一行给N,后面每行几个数不一定”的题目时特别管用:
string line; getline(cin, line); stringstream ss(line); int x; while (ss >> x) { // 处理x }不过有一个坑要提前说:如果在cin读完后立刻混合使用getline,可能会读到一行空内容,因为之前的输入操作把换行符留在了缓冲区。解决的办法是每道题确定自己的输入策略,要么全部用cin直接读,要么整行用getline。不要一会儿一种,混用最容易出怪问题。
我的笔记里还固定了一段“调试输出开关”,平时开着一堆调试打印,提交前把宏注释掉就行,避免手删代码时误删逻辑:
// #define LOCAL_DEBUG #ifdef LOCAL_DEBUG #define dbg(x) cout << #x << " = " << x << endl; #else #define dbg(x) #endif当题目逻辑比较复杂时,这个开关能让我在本地保持调试输出,提交前又不用满文件找cout。对于那些一行测试数据就能定位问题的小题,我通常不开调试,直接用思维推。
3. 调试能力远比背模板重要:断点调试入门
我想强调一个反直觉但很重要的经验:算法入门阶段,提升最快的路径不是多看几道题解,而是把本地调试工具用熟。很多初学者遇到答案错误,第一反应是加printf打印中间变量,打印好几轮没看出来,就把代码贴到群里问。这其实是效率最低的排查方式。
断点调试的核心在于“停在你想停的地方,看你想看的状态”。以常见的图形化调试器为例,基本流程是:在代码行号左侧点击一下设置断点,然后以调试模式运行程序。程序执行到断点处会暂停,这时可以逐行执行(单步)、跳到下一个断点(继续),并在变量窗口里直接查看所有局部变量的当前值。
这个能力对你的直接帮助有三个场景:
第一个场景,程序崩溃或者死循环。不确定是哪一行出的问题,就先把断点打在疑似位置附近的循环入口,再一步步走。不用靠猜。
第二个场景,数组越界或下标错误。这类问题用printf根本防不住,因为你根本不知道哪个下标是错的,而断点停在出问题那一行时,监视窗口里能看到越界下标的具体值,一眼就知道是哪一步计算导致的。
第三个场景,逻辑分支复杂的时候。比如DFS回溯、双指针移动,每一步的变量都必须正确,错一步就会连环错。单步执行配合变量窗口,相当于把代码的运行过程可视化。
还有两个实际调试中会反复用到的检查方式:条件断点和监视表达式。条件断点是指断点在某个变量满足指定条件时才触发,比如想在i == 5时才停下来,就在断点条件里写i == 5。监视表达式则是把某个表达式加到监视窗口里,比如a[i + 1] - a[i],运行过程中实时看它的变化。这两个组合起来能解决大量“只在某类数据下才出错的”难题。
有人会说,很多在线评测平台看不到测试数据,本地过了一提交就错,断点能有什么用?我的回答是:断点不是为了看测试数据,是为了看自己的代码在“你给的样例”上到底执行的逻辑是什么。绝大多数情况下,题目的样例就是笔者设计出来逼你理解逻辑的,样例过了但提交错,往往是因为某些特殊情况没考虑到。这时候应该做的,是构造更多边界测试数据,而不是只盯着代码看。断点配合边界数据,能让你在几分钟内定位到一个“单独看完全没错,但合起来逻辑矛盾”的地方。
我给自己的硬性要求是:一道题如果超过十五分钟还找不出错,必须开调试器单步走,不许再靠肉眼检查。这个习惯帮我节省了大量时间,也让我对自己写的每一行代码都更确认。
4. 高频STL容器实战姿势:vector、queue、stack、map、set逐个上手
到了算法题这一步,C++标准库容器就是你的“弹药库”。但会用和用得对之间隔着一整个坑区。下面是我笔记里按使用频率排序的容器要点,以及我踩过的典型问题。
vector是最常用的动态数组。需要注意的第一点是初始化方式:
vector<int> a(10); // 10个0 vector<int> b(10, 5); // 10个5 vector<int> c{1, 2, 3}; // 1,2,3第二点是reserve和resize的区别。reserve(n)只预留容量、不改变size,适合提前知道元素总数、要避免多次扩容的场景;resize(n)会直接改变size,新位置元素默认初始化。如果你要用下标访问,那就必须resize,只reserve就访问会越界。这个坑我印象特别深,曾经有一道排序题,我reserve了大小却用下标赋值,本地跑不出错,一提交就Runtime Error,花了好一阵才反应过来是size压根没有变化。
queue和stack在BFS、DFS相关题目里是常客。queue用在BFS时,一个常见错误是忘记在push时同步标记“已访问”。如果等到pop出来再标记,同一个节点可能被多个分支重复入队,数据量一大就会超时或者死循环。stack则常被用来模拟递归过程,比如树的前序非递归遍历,这时候要注意入栈顺序:先入栈右子树、再入栈左子树,弹出才是先左后右。这些细节,光看文档永远不会意识到,只有写过错题才记得住。
map和set在入门算法里最常用来做三种事:去重、计数、以及按顺序维护数据。其中要特别注意有序和哈希的选型。map和set基于红黑树,插入查找都是O(log N),适合需要有序遍历的场景;unordered_map和unordered_set基于哈希表,平均O(1)但会打乱顺序。初学者经常会犯一个错误:明明只需要判断“某个值是否存在”,却用了map,把简单问题做复杂。我现在的习惯是:只判断在不在,优先用unordered_set或set;需要记录出现次数,才用map。
迭代器是另一个容易踩坑的地带。在遍历vector时,如果你需要边遍历边删除,不要用for (int i...),因为在删除中间元素后,后面的下标全部前移,继续用原下标会跳过元素。我习惯改用迭代器实现:
for (auto it = v.begin(); it != v.end(); ) { if (条件) it = v.erase(it); else ++it; }这里的关键点是erase会返回下一个有效迭代器,赋值给当前迭代器继续遍历。还有一条教训:在循环里调用了可能导致迭代器失效的操作(比如push_back)之后,不要再继续使用之前保存的迭代器。容器扩容导致的内存重新分配,会让老迭代器指向失效地址,再访问就是未定义行为,表现可能是对、可能是乱码、也可能是崩溃。
priority_queue适合处理TopK、贪心取极值的问题。默认是大根堆,如果要小根堆,需要传入greater<int>:
priority_queue<int, vector<int>, greater<int>> pq;这一段的基本结论,我整理了一张简单的对照表放在笔记末尾,方便自己回顾:
| 容器 | 底层结构 | 查找复杂度 | 适用场景 | 常见坑 |
|---|---|---|---|---|
| vector | 动态数组 | O(1)随机访问 | 需要按下标访问、频繁末尾追加 | reserve不等于resize |
| queue | 队列 | 仅队首可访问 | BFS、逐层处理 | 入队时就要标记已访问 |
| stack | 栈 | 仅栈顶可访问 | DFS模拟、括号匹配 | 入栈顺序决定遍历方向 |
| map/set | 红黑树 | O(log N),有序 | 需要有序操作 | 不需要有序时性能浪费 |
| unordered_* | 哈希表 | 平均O(1) | 判断存在、去重 | 无法有序遍历,哈希冲突可能退化 |
这张表我每次更新笔记都会回头看一眼,提醒自己选容器时先问两个问题:需不需要有序?需不需要按下标访问?两个都否,就优先unordered系列;需要有序,就map/set;只有下标访问和尾部操作,用vector就够了。
5. 面向自用的刷题节奏:先暴力,再优化
算法入门阶段容易有两种极端:一是沉迷看题解,看完觉得都会,自己一写就废;二是从第一天就挑战难题,几天后心态崩溃。我给自己设计的节奏是“三类题交替做”,每一类对应不同的训练目标。
第一类是纯输入输出和模拟题。这类题不涉及复杂算法,更像翻译:把题目描述翻译成代码。它的价值在于让你对模板代码、变量管理、循环边界形成肌肉记忆。第二类是枚举和搜索题,比如全排列、子集、连通块染色,这类题是训练递归和回溯能力的好素材。第三类是经典数据结构应用题,比如用栈配括号、用队列模拟约瑟夫问题、用map做词频统计,它们让你真正理解“算法的本质是组织数据”。
每个阶段我都要求自己遵守一条铁律:先暴力,再优化。拿到一道题,只要暴力解法能正确写出,就先写出来跑通,哪怕复杂度很烂。然后才尝试用有序表、前缀和、双指针等手段去优化。这么做有一个很实际的好处:暴力版是一个完全正确的“基准答案”,优化版跑出来的错误,可以通过和暴力版结果逐项对比,迅速定位是哪一步优化思路写错了。没有基准的优化,出错了都不知道是谁的错。
做题过程中如果一道题卡了超过两小时,我的做法不是死磕,而是去看别人的思路,但只把核心思路看懂,然后合上题解自己重新写。这比照着题解抄一遍有效得多。抄题解的时候大脑会进入“打字模式”,根本不理解递归到底怎么回溯的,而重新写的这个过程才是真正把思路转换成自己的。
搞清楚一道题之后,我还会马上做一件事:把这道题的“题型标签”记到笔记里。标签不是题目名字,而是抽象出来的考点,比如“双指针+滑动窗口”“DFS+回溯去重”。积累一段时间后,你会发现不同的题在标签层面高度相似,这为后续“看到新题就能归类”打下了基础。
数据范围也是我笔记里特意记录的内容。同一个题目,如果N的范围是10、1000、还是10^5,采用的思路几乎完全不同。我给自己写了一个数据范围对照口诀:N小于20,可以考虑枚举/状态压缩;N小于1000,O(N^2)不一定超时;N在10^5量级,要往O(N log N)甚至O(N)方向想;N到10^7以上,基本只能线性扫。这个对照不是真理,但它是一个特别适合新手的“导航仪”,至少能帮你排除那些明显不对的暴力方案。
6. 自用笔记怎么维护:模板沉淀、错题集与更新机制
现在说说这篇笔记本身的维护方式,也算是我最重要的“元方法”:笔记不是抄代码,不是为了给别人看的,而是为了让你三周、三个月后还能快速捡起来。
我给每道做过的题都留了几行“笔记痕迹”,包括四个部分:一是题目的核心考点标签;二是我的第一版提交时间、是否超时、错误类型;三是最终解法的时间复杂度与空间复杂度;四是一句话的解题思路。这些话必须用自己的语言写,不能用题解原话。写完这四行,我才会把通过后的代码贴进存档。三周后,我再翻到这道题时,先不看代码,只凭这句话重新推一遍整个思路,推不出来才看代码。这个“复读实验”是我验证自己是否真正理解一道题的有效方式。
错题集这部分我按“错误原因”而不是“知识点”分类。常见分类有:边界条件写错(多一少一)、容器误用(该用set用了vector)、递归终止条件缺失(导致栈溢出)、数据溢出(int爆了,要用long long)、初始化遗漏(多组数据时全局变量没有重置)。按错误原因分类的好处是,我经常发现自己在不同类型的题上犯同一种思维错误,比如总是把循环边界写成<=、总是忘记处理空输入。一旦发现这类规律,我会把它单独列成一页“我的常见错误清单”,每次刷题前扫一眼,错误率肉眼可见地下降。
更新机制上,我给自己定的是“每周一次小更新,每月一次大整理”。每周刷完题后,把新题的标签、复杂度、错误类型补充进对应页面;每月底做一次回顾,把已经掌握得很好的标签标记为“稳定”,把反复出现的错误类型前移。笔记的标题既然是“自用、更新中”,就意味着它不需要完美,它的核心价值是作为对外部知识的二次加工结果,永远忠于自己当时的真实水平。
笔记的组织结构我目前是这样:根目录按“基础语法与模板”“数据结构常用法”“搜索与回溯”“动态规划入门”“排序与二分”“贪心初步”分文件夹,每个文件夹里是一系列小题签的md文件。这样做的好处是,当我在新题里遇到“好像之前碰过类似的排序技巧”,我能快速定位到对应文件,而不是面对一篇几千行的流水账。
有一点也要提醒自己:笔记里如果有代码块,一定要写上对应的数据范围与测试样例,哪怕只有一组。因为代码离开了题目上下文,过两周再看就只是一串看不懂符号。
最后回到最初的话题。这份“cpp算法入门笔记”最大的意义,不是让我记了多少道题,而是让我在面对一个完全没见过的算法问题时,能有一个可以回溯的思考路径:先判断数据范围,再选择容器,然后先写暴力,再逐步优化。这个路径本身,就是我从三个月前那个看到题就发懵的状态,到现在能有一定思路的最大变化来源。如果你也在入门C++算法的路上,建议你也给自己建一份“自用、更新中”的笔记,不用长,不用精致,但要诚实记录你每一步卡住的原因、解决的方式、以及那些“原来是这样”的时刻。它们才是真正属于你的算法财富。