C++第108天训练实录:算法、STL与避坑指南 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::vectorint比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扩展后按CtrlShiftP打开命令面板搜索“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-coloralways, -g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], options: { cwd: ${fileDirname} }, problemMatcher: [ $gcc ], group: { kind: build, isDefault: true } } ] }这里需要根据你自己的编译器实际路径修改command字段。配置完tasks.json再用快捷键CtrlShiftB就能直接编译调试时可以配一个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的末位是0res不乘base平方变成9指数右移一位变成5末位是1res乘以9base再平方变成81指数右移变成2base再平方变成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的两种版本是隐藏关系因为签名不同且未加virtualrun是覆盖关系签名相同且基类为虚。理解了这组对比笔试中“以下哪组构成覆盖”的题基本就能秒答。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里按CtrlShiftP搜索“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之类的检测工具编译时加上-fsanitizeaddress参数运行时就很容易精准定位越界位置第三步如果题目让用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天的训练记录能给你一点坚持下去的参照。