1. 第108天训练计划:我在这一天把什么搞定了
写这篇记录的时候,我刚从VS Code的调试窗口里退出来,顺手完成了今天最后一道课后题。第108天的C++课后习题训练,没有想象中那种“坚持满一百天”的仪式感,反而更像一个平平常常的工作日:白天忙完手头的事,晚上八点准时坐到电脑前,打开昨天没做完的题,接着练。
为什么会卡在“第108天”这个数字上?因为按照大多数人的学习曲线,一周热度、一个月麻木、三个月放弃是常态,能撑过108天,基本已经形成了稳定的肌肉记忆。第100天左右我给自己做了个小复盘,确认两件事:一是基础语法已经不需要刻意去翻书了;二是真正能拉开差距的,反而是那些课堂上被一笔带过的底层细节,比如运算符重载的返回类型、模板推导的规则、STL容器在极端场景下的迭代器失效问题。这一阶段的训练重心,已经从“看懂代码”切换到了“能解释为什么这样写是对的”。
今天的训练内容延续了上周的节奏,主要分三块:算法题、STL应用、面向对象易混概念澄清。时间分配大约是四十分钟算法、一小时语法复盘、二十分钟整理错题。会挑这三块,是因为它们正好对应了刷题时最容易卡壳的三个痛点——思路想不到、容器用不对、类设计一写就乱。如果你也正在学C++,或者学到一半发现越来越迷糊,这份记录里的具体题目、排查过程和学习方法,应该能给你一点参考。
1.1 从Day1到Day108,我的训练路线是怎么调整的
第一周的时候,我也和大多数人一样,从网上找了一堆“C++小游戏”项目来练手,像猜数字、贪吃蛇、简易通讯录,写起来确实爽,运行起来也有成就感。但练到第二周就发现问题了:小游戏项目对算法的锻炼非常有限,写十遍循环嵌套和写一遍漂亮的递归完全是两码事。所以从Day15开始,我把训练方式改成了“七三开”——七成时间刷OJ上的基础算法题,三成时间啃语言特性。这个调整直接决定了后续九十多天的训练质量。
到Day40左右,我开始强迫自己用现代C++风格写题,也就是尽量用STL容器而不是裸数组,用std::sort而不是手写快排,用auto推导而不是写全类型名。刚开始很别扭,总觉得std::vector<int>比int a[100]难读,后来才意识到,这其实是提前给工作项目里的代码风格打基础。等走到Day108,再回头看早期代码,最明显的感受是:那时候写的很多“解题代码”,本质上只是在用C语言的语法套一个C++的文件名。
另一个调整是把重心从“写出能跑的代码”挪到“说出为什么这样写”。Day80以后,我会刻意在一道题解完后,用文字把思路整理出来,甚至在注释里写上复杂度推导过程。这个习惯在今天迎来了回报:做二分查找进阶题时,边界条件翻来覆去出错,我翻出几十天前写的注释,瞬间就明白了当初踩坑的点,比重新搜索解法高效得多。
1.2 今天的三块训练内容与选题逻辑
今天选这三块,不是随机凑数,而是对应了我当前最弱的三个能力。算法题选的是一道“栈+排序”方向的练习,用来巩固单调栈思路;STL部分练的是string与字符数组的相互转换,以及std::sort自定义比较函数;面向对象部分则专门整理了“重载、覆盖、隐藏”这三个让新手头疼的概念对比。
选这些题目还有个原因:我在后台看到自己的搜索记录里躺着“c++ 冒泡排序”“c++ 二分查找”“快速幂算法c++”这些词。这些词基本都是国内教材和面试题的高频命题区间。也就是说,这一天的训练表面上是在做课后题,本质上是在为将来的求职笔试“预埋零件”。如果能把这三块练到“不用想就能写出来”的程度,后面刷任何大厂笔试题,至少不会被基础模板题卡住。
2. 环境与工具:VSCode与C++运行时的那些坑
说句实在话,学习C++的挫败感有一半不是来自语言本身,而是来自开发环境。特别是Windows系统,装个编译器、配个编辑器、跑一段代码,每一步都可能蹦出奇怪的问题。今天训练开始前,我帮一个同实验室的学弟远程看了下他连编译都过不去的状态,最后发现是Visual C++ Redistributable版本问题。所以这篇记录里,我决定专门写一小节环境配置的内容。
2.1 VSCode配置C/C++环境:一份能直接用起来的方案
我不否认Visual Studio Community功能强大,但那玩意启动速度实在太慢了,日常刷课后题完全没必要。我的主力工具是VSCode加MinGW-w64。配置流程其实不复杂,核心就四步:安装编译器、安装VSCode扩展、配置tasks.json和launch.json、验证编译。网上无数教程把这四步讲得云里雾里,但真正常用的配置其实很小。
先说编译器。在Windows上,我推荐下载MinGW-w64,选x86_64架构,安装完之后把bin目录加到系统环境变量的Path里。判断是否配置成功的标准,是在terminal里输入g++ --version能正常输出版本信息。这一步很多人卡住,多半是下载了不带安装程序的压缩包,解压后没有把路径加进Path,或者只加了系统变量没加用户变量。
再说VSCode配置。装上C/C++扩展后,按Ctrl+Shift+P打开命令面板,搜索“C/C++: Edit Configurations (UI)”,在配置里填上编译器路径,一般会自动找到。然后创建工作目录下的.vscode文件夹,写好tasks.json。我最常用的一段配置是:
{ "version": "2.0.0", "tasks": [ { "type": "cppbuild", "label": "C/C++: g++.exe build active file", "command": "D:/mingw64/bin/g++.exe", "args": [ "-fdiagnostics-color=always", "-g", "${file}", "-o", "${fileDirname}/${fileBasenameNoExtension}.exe" ], "options": { "cwd": "${fileDirname}" }, "problemMatcher": [ "$gcc" ], "group": { "kind": "build", "isDefault": true } } ] }这里需要根据你自己的编译器实际路径修改command字段。配置完tasks.json,再用快捷键Ctrl+Shift+B就能直接编译,调试时可以配一个launch.json让VS Code调用gdb。这一段看着繁琐,但一劳永逸,之后每次做题只需要保存写代码、按快捷键编译运行,非常顺手。
2.2 关于Visual C++ Redistributable的报错排查
今天学弟遇到的问题是:在Windows上用pip安装一个Python包时,报错“error: microsoft visual c++ 14.0 or greater is required”,然后他误以为是自己电脑里Visual Studio坏了,重装了半天也没解决。这个报错现在很常见,原因其实很简单——某些第三方库在编译C++扩展时需要用到MSVC工具链,系统里没有对应版本的运行库就会报这个错。
排查思路分两步。第一步,先确认系统里到底装了哪些Visual C++ Redistributable版本。可以去“设置-应用”里搜“Visual C++”,把“2015-2022”这个合集版本装上,基本能覆盖绝大多数需求。第二步,如果已经装了运行库还是报错,那问题就不是运行库,而是缺少Build Tools组件,也就是编译器本体。这种情况下需要去安装Visual Studio Build Tools,勾选“使用C++的桌面开发”工作负载,这一步会比较重,但能彻底解决问题。
顺带一提,老旧的Visual C++ 6.0教书工具在Windows 10/11上非常容易崩溃,不建议新同学花时间折腾。我的建议很简单:除非学校硬性要求,否则别用VC6.0,直接上MinGW-w64加VSCode,或者Visual Studio Community,都比VC6.0省心得多。
3. 算法题实战:排序、二分与快速幂的深入对比
今天算法训练的主线,是从基础排序一路往二分和快速幂推进。这三块本来就是教材课后题的重头戏,也是面试笔试里的高频题。与其把三块分开写,不如放在一起对比:它们背后其实共享同一个核心思想,就是“如何利用已知信息减少工作量”。
3.1 冒泡排序:课堂必讲、面试必问的原因
首先说明,我自己在刷题时基本不用冒泡排序,因为数据量稍微上去一点就扛不住。冒泡排序的平均时间复杂度是O(n^2),处理一万条有序性较差的随机数据大概就需要上亿次比较,这个规模在OJ题里很容易超时。但为什么教材还是要讲它?因为它是最直观的“比较交换”思想入门:相邻两个元素比较,顺序不对就交换,每一轮把当前未排序部分的最大值“冒”到末尾。
课后题里如果要求手写冒泡,通常会玩两个变形。第一是“是否发生交换”的标记优化,也就是如果某一轮扫描全程没有发生任何交换,说明序列已经有序,直接结束;第二是把冒泡改成“双向冒泡”,来回两个方向交替扫描,减少重复比较。这两个优化在实际代码里都很好写,但真正值得学习的是那层思维:同样的基础算法,在什么场景下可以通过提前终止来降低平均复杂度。这个思想在后续学快速排序、堆排序时非常有用。
今天我做的一道冒泡题,要求统计排序过程中元素交换的次数。这类题的真正考点不是模拟,而是让你意识到“交换次数等于逆序对数量”,于是就可以用归并排序在O(n log n)时间内快速统计。如果只死背着冒泡模板,大概率做不出来。所以我的建议是:冒泡要会写,但更要弄懂它为什么慢,以及怎么用更好的算法替代它。
3.2 二分查找:边界条件和写法的黄金法则
二分查找这个话题,我在Day30左右就练过一轮,今天重新做是因为它太容易“看着简单、写出来就错”。二分的核心限制是序列必须有序,核心思路是每次把搜索范围缩小一半,因此复杂度是O(log n)。但细节上最磨人的是边界条件:用left < right还是left <= right,中间值用mid = (left + right) / 2还是mid = left + (right - left) / 2,这两个小问题能坑掉无数人。
我自己的统一写法是采用“左闭右闭”区间,配合while (left <= right),搜索空间包含left和right两个端点。当目标值小于中间值时,把right更新为mid - 1;大于中间值时,把left更新为mid + 1。这套写法的好处是逻辑直观,找边界也不会漏。另外一个极其关键的细节是防止整型溢出,(left + right) / 2在极端情况下可能两数相加超过int上限,所以更稳的写法永远是left + (right - left) / 2。哪怕刷题数据一般不会大到越界,养成这个习惯能避免不少线上事故。
今天练习的进阶题“搜索旋转排序数组”,就是在部分有序的数组里做二分。题本身不复杂,关键是判断哪一半是有序的,再根据目标值落在哪个区间决定搜索方向。这道题能很好地检验你对二分“排除不可能区间”这一本质的理解程度,而不是只会背模板。
3.3 快速幂:用二进制思维把复杂度从O(n)降到O(log n)
今天新练的另一个点是快速幂,因为我注意到自己在处理大指数取模时经常超时。教科书上的朴素方案是for循环乘n次,复杂度O(n),而快速幂利用指数的二进制表示,把指数拆成若干个2的幂之和,底数随之反复平方,每次迭代把指数右移一位,整体复杂度降到O(log n)。
光说理论有点抽象,我拿计算3^10举例。10的二进制是1010,也就是10 = 8 + 2,所以3^10 = 3^8 * 3^2。代码里维护一个结果变量res,初始值为1,一个底数base,初始值为3。指数10的末位是0,res不乘,base平方变成9;指数右移一位变成5,末位是1,res乘以9,base再平方变成81;指数右移变成2,base再平方变成6561……按这个逻辑迭代下去,比直接乘十次快得多。配合取模运算,就能高效处理大规模组合数问题。
今天还顺手把“判断质数”的优化也复习了一遍:只需要试除到sqrt(n),并且跳过偶数,甚至可以进一步用6的倍数两侧来减少判断次数。这种优化和快速幂一样,本质上都是“利用数学性质减少不必要的计算”,刷题时经常连在一起考。
3.4 单调栈:一道体型“下一个更大元素”把思路串起来
单调栈是我整理训练记录时临时加进来的,因为热搜词里频繁出现,而它确实又是一个“会者不难、难者不会”的知识点。单调栈解决的问题特别典型:给定一个数组,找到每个元素右边第一个比它大的元素。正常人第一反应是双重循环O(n^2),但单调栈可以做到O(n)。
它的原理是用栈保存数组下标,始终保持栈内元素从栈底到栈顶按对应值单调递减。遍历数组时,如果当前元素比栈顶对应元素大,说明栈顶元素的“下一个更大元素”就是当前元素,这时弹出栈顶并记录答案。每个元素最多入栈一次、出栈一次,因此总复杂度是线性的。听懂这个描述不难,但真正把代码写顺,需要从“下标入栈而不是值入栈”这个关键操作开始练起。今天这道题让我明确了:单调栈的代码不到二十行,但推导过程值得写在错题本上反复品味。
4. 语言特性复盘:从课后题里挖出的易混概念
算法题之外的另一个大头,是C++语言本身的特性。这一块最容易出现的问题是“看得懂、说不清”,特别是string与字符数组的转换、面向对象里的覆盖与隐藏、模板和回调函数等概念,考试前背一背好像会了,一到手写代码就露馅。
4.1 string与字符数组:初始化、转换与隐藏的坑
今天针对“c++字符串数组初始化”和“c++字符串转数组”这两个高频搜索词,我把常见的写法全部过了一遍,顺便踩了一个小坑。字符串的常见初始化方式大致有四种:直接用双引号初始化string s = "hello"、用字符数组初始化、用fill或memset批量填充、以及用重复字符构造string s(10, 'a')。这些写法看着平淡,但面试时描述“string和char数组的区别”这种问题,答案里必须提到“string是类,会自动管理内存;char数组是固定大小内存块,越界不改报错但会污染相邻数据”。
字符串转数组这个点,最常见需求是把string转换成const char*,用.c_str()方法,再配合strcpy拷贝到目标字符数组里。还有一个点容易被忽略:用cin读字符串时默认以空白字符分隔,如果字符串本身包含空格,就必须用getline(cin, s)。
我今天踩的坑也和读入有关。写一道包含多行句子处理的题,我用cin >>去读每一行,结果第一行以后的内容全部错位,排查了半天才发现是流缓冲区里还残留着上一行的换行符,需要用cin.ignore()清掉。这个坑非常经典,属于那种书上会提但不强调、实际一写就翻车的类型。
4.2 重载、覆盖、隐藏:同名函数的三种结局
C++初学者最爱搞混的三个概念就是重载、覆盖和隐藏。搜热词“c++ 覆盖 隐藏”的人多,说明这个点确实是普遍难点。我用大白话帮你捋一遍。
重载发生在同一个作用域,比如同一个类里有两个同名函数,但参数列表不同,编译器根据实参类型决定调用哪个。覆盖和隐藏都发生在继承体系中。覆盖要求基类和派生类函数签名完全一致,并且基类函数声明为virtual,这样通过基类指针调用时才能实现多态,也就是动态绑定。隐藏则更隐蔽:只要派生类里有个函数和基类同名,不管参数是否一致、基类是否虚函数,基类的同名函数都会被“遮住”,这时用派生类对象直接调用那个名字,只会命中派生类版本。
我建议你用一个例子来加深记忆:基类Animal有一个void speak()和虚函数virtual void run(),派生类Dog定义了自己的void speak(int x)和void run()。此时speak的两种版本是隐藏关系,因为签名不同且未加virtual;run是覆盖关系,签名相同且基类为虚。理解了这组对比,笔试中“以下哪组构成覆盖”的题基本就能秒答。
4.3 回调函数与前置声明:读懂C++模块化设计
今天在代码里小范围练了一下回调函数和前置声明,这两个概念在中小型项目里很常用。所谓回调函数,本质上是把一个函数指针作为参数传给另一个函数,让后者在合适时机调用。最经典的例子是qsort和std::sort里的比较函数。现代C++更推荐用std::function封装可调用对象,但原理完全没变:函数不只是被调用的实体,也可以被当作数据传递。
前置声明则是解决“两个类互相引用”的利器。如果class A的成员里有class B的指针,而class B的成员里又需要class A的指针,直接在头文件里互相#include很容易循环依赖。正确做法是,在两个头文件里都只写class A;和class B;这种前置声明,然后在cpp文件里引入完整定义。光这一条,就能让很多从Java转过来的同学少掉不少头发。
不过要注意,前置声明只能用于“声明指针或引用”,不能用来实例化对象,也不能在声明处调用对方的成员函数,因为此时编译器还不知道目标类的完整内存布局。今天课后题里我遇到的就是这个误区,本来想着前置声明能少include一个头文件,结果在构造函数里直接new B()报错。这就是典型的“把标准库类的用法套在了自定义类上”。
5. 第108天踩坑实录:编译、运行与逻辑三类问题
坚持训练一百多天,最大的收获不是会了多少算法,而是积累了一堆“常见问题排查清单”。很多问题其实搜一下就能找到答案,但搜之前往往自己已经折腾了好几个小时,纯粹浪费时间。所以我在这篇记录里专门新增一个章节,把今天遇到的几类问题写清楚,方便以后照方抓药。
5.1 编译错误:为什么会一直卡在“找不到头文件”
今天第一次编译就报错“fatal error: vector: No such file or directory”。看到这个报错的第一反应不是去下载什么组件,而是立刻检查编译器路径。因为在Windows上使用VSCode时,这个错误最常见的成因是tasks.json里的编译器路径配错了,或者配置的编译器和VSCode智能提示用的编译器不是同一个。
解决办法也简单:先确认MinGW-w64的g++确实在系统Path里,然后重新配置includePath。在VSCode里按Ctrl+Shift+P搜索“C/C++: Edit Configurations (UI)”,在“Include Path”中添加D:/mingw64/include这一项,就能消除大部分“找不到头文件”的红色波浪线。注意把路径替换成你自己的MinGW安装目录。
另一个与编译相关的经典坑是源码文件编码问题。如果文件用了UTF-8编码但控制台默认不是UTF-8,运行带中文输出的程序时就会乱码。网上各种说加system("chcp 65001")的偏方我都不推荐,最简单的方式是把代码文件格式保持为UTF-8,同时修改Windows终端默认代码页,或者干脆在代码里避免输出中文字符,改用英文提示。
5.2 运行时错误:数组越界不是简单崩溃,还可能无声无息
C++不像Java会在越界时抛出异常,许多情况下数组越界并不会立刻崩溃,而是“静默地”改掉了相邻内存的内容,直到某个时刻程序莫名崩溃或结果异常,排查起来特别费力。今天做排序题时我就因为下标写错,把一个越界值写进了数组旁边的一个变量,导致后面所有计算全部错误,但编译器一点提示都没有。
后来我把调试方法固定成了三步:第一步,把错误示例的数组长度肉眼检查一遍,确认循环的上限是否严格小于数组长度;第二步,打开AddressSanitizer之类的检测工具,编译时加上-fsanitize=address参数,运行时就很容易精准定位越界位置;第三步,如果题目让用vector,干脆别用裸数组,直接用.at(index)访问元素,它在越界时会抛出std::out_of_range异常,至少能把崩溃点暴露出来。
写课后题阶段可能觉得裸数组更快,但我真心建议在练习时多使用std::array和std::vector,让编译器帮你检查边界,提前培养安全编程的习惯。
5.3 逻辑错误:做题时最容易错的那个判断条件
编译和运行都正常,但输出和样例不一致,这种情况几乎天天遇到。今天我犯的逻辑错误特别典型:在二分查找里,本来应该用if (nums[mid] < target)来缩小区间,我一不小心写成了if (nums[mid] <= target),结果在目标值正好等于中间值时,把搜索范围错误地切到了右半段,最终死循环。原因其实很简单,写代码时脑子里想的是“小于等于就右移”,忘了已经命中目标应该直接返回。
这类问题靠瞪眼睛是看不出疗效的,我的方法是:把代码里所有涉及比较的边界条件单独打印出来,或者用一小段测试数据手动走一遍循环。今天这道二分题,我就先把数组{1, 3, 5, 7, 9}在纸上演算了两遍,才定位到是<=和<的差别。这种“手动模拟”看似笨拙,但确实是排查逻辑错误最可靠的方式,尤其是刷题平台只给“答案错误”不给任何调试信息的时候。
6. 坚持108天的学习方法论:给同样在刷题的读者
其实到这一节,已经不算严格意义上的技术内容了,但我还是想写一点。因为我知道很多读者不是不想学,而是学着学着就断了。Day108这个节点给了我一个很好的视角,回头看整个坚持过程,真正有效的不是“咬牙硬撑”,而是那些让学习能持续进行的具体方法。
6.1 每天练什么:用“三明治”结构分配时间
我习惯把每天的训练分成三层:最外层是二十分钟左右的简单题热身,目的是快速进入状态;中间层是四十分钟左右的核心算法题,用来啃硬骨头;最里层是二十分钟的语法复盘和错题整理。如果把时间全砸在难题上,人很容易在第40分钟就崩溃;如果一直刷简单题,又几乎没有任何提高。这个“三明治”结构完美解决了我前中期“要么太忙没学、要么太闲没劲”的问题。
当然每周我也会留一天的机动时间,比如今天因为帮学弟解决环境问题,占用了大约十五分钟,那就把语法复盘压缩到十分钟,整体进度也不会被拖垮。坚持得久的人不是每天效率都奇高,而是不管高效还是低效,都保证当天至少碰了代码,不让自己断档。
6.2 错题怎么记:从Day30开始用的复盘格式
Day30以前,我的错题本就是一个made in Chrome的书签文件夹,全是“已收藏,永不回顾”。从Day30开始,我换成了每条错误记录固定三个部分:错误代码、错误原因、正确思路与代码。其中“错误原因”必须写清是语法层面、逻辑层面还是思路层面的问题,比如“边界条件设错”就不够好,要具体到“二分搜索右边界是闭区间时,更新right应为mid-1而不是mid”。
这样做的好处是,复习错题时不用重新跑一遍代码就能迅速回忆起当时卡住的地方。今天做二分前,我顺手翻了一下Day36的错题记录,五分钟时间就把边界问题的几个雷全部排了一遍,这种复利效应是碎片化刷题完全给不了的。
6.3 从课后习题到面试:第108天的及时转向
到了第108天,我发现单纯刷课后题已经没法再带来明显的成长,于是开始主动去翻面经里的C++面试题。像“c++八股文”这样的词很火,里面各种STL底层原理、内存管理、虚函数表、智能指针的问题,确实能照亮很多知识盲区。今年的一个明显感受是,面试官早已不满足于“你会不会写”,而是反复追问“为什么”,这恰好和我在Day80后坚持的“注释里写推导过程”习惯对上了。
如果你顺利刷过两三个月,我建议你也做一次“转向评估”:能不能独立讲清楚std::vector的扩容机制,能不能手写一个shared_ptr简化版,知不知道static_cast和dynamic_cast的区别。如果这些话题能张口就来,那就可以从题库模式切到项目实战模式;如果说不全,说明基础地基还有不少松动的地方,别急着往深水区冲,先把坑填平再说。
今天最后一个复盘动作,是整理了一份近期要回看的易错清单:std::sort自定义排序规则忘记写成严格弱序导致未定义行为、getline之前的换行符清理、二分查找的边界更新口诀、还有模板推导时的const引用问题。这些内容看起来零散,但正是它们构成了“学了不白学”的底气。写到这里,今天的任务算是彻底收工了。希望这份第108天的训练记录,能给你一点坚持下去的参照。