
1. 训练记录里到底该记什么我的Day115复盘逻辑写训练记录这事我坚持了115天最大的体会是记录的核心不是今天做了几道题而是今天踩了哪些坑、想通了哪个卡了很久的点、下次再遇到这类题能不能一眼看穿考点。很多人记了两三天就放弃多半是把它写成了流水账——题号、答案、对错毫无信息量。你回头看的时候脑子里留不下任何东西。我现在的记录格式分三块题目定位、核心考点、思维复盘。题目定位写清楚是哪本书、哪一章、哪个题号方便以后翻查核心考点写这道题真正想考的东西比如运算符优先级函数重载的匹配规则内存泄漏的触发点而不是做了一个排序这种泛泛而谈思维复盘是重头戏写清楚我当时为什么卡住了、后来通过什么线索想通的、如果再让我做一遍我会先看哪里。Day115这天我主要复习了三个板块随机数与伪随机数的底层区别、字符串数组的初始化陷阱、以及const/static/final三兄弟的混用场景。这三个板没用直接关联但都属于C基础里最容易翻车、面试最爱深挖、实际工程里天天碰的知识点。我用一篇文章把这三块完整梳理一遍按训练记录的方式写入。2. 从Day115的题目出发随机数到底随机在哪2.1 rand()看似简单坑却藏在种子和范围里Day115练的第一道题是生成一组随机数要求落在指定区间。大多数新手第一反应是rand() % n不少教程也这么教但这个写法有两个经典问题。第一个问题是rand()的周期和分布质量。它内部是线性同余生成器周期有限低位的随机性尤其差。早期实现里rand() % 2的结果会呈现明显的交替规律拿它做抛硬币模拟统计结果根本不可信。这也是为什么C11之后标准库推出了random头文件提供了mt19937这类高质量的伪随机数引擎。第二个问题是种子。用rand()前必须调用srand()设置种子否则每次程序启动种子固定为1生成的序列完全一样。有些人喜欢srand(time(NULL))每次都变但如果在同一个秒级时间段内连续启动进程种子相同结果还是相同。更细致的做法是用std::random_device来生成种子。实操中我推荐的写法是这样的#include random std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distributionint dist(1, 100); for (int i 0; i 10; i) { std::cout dist(gen) std::endl; }这段代码里的uniform_int_distribution会自动处理边界闭区间[1, 100]不需要手写%和1。同时mt19937的周期是2的19937次方减1对练习和大部分工程场景都足够用。2.2 分布模板和引擎分离为什么这样设计random把生成随机比特和映射成指定分布拆成了两个独立部分。引擎只负责产生均匀分布的原始随机数分布对象负责把原始随机数映射到目标区间。这个设计很像工厂流水线引擎是原料车间分布是加工车间两者解耦后可以随意组合。这种拆分的直接好处是你可以换引擎而不影响分布逻辑也可以换分布而不用改引擎。做蒙特卡洛模拟需要正态分布就换成std::normal_distributiondouble模拟掷骰子就用uniform_int_distribution(1, 6)。每个分布模板内部对边界、舍入、浮点精度都有完善处理比自己手写边界判断稳得多。我当初做题时顺手测试了一下rand()和mt19937的分布对比用100000个样本做直方图前者肉眼可见地出现了局部偏密后者平滑得多。这个实验建议大家自己跑一遍对为什么工程代码里不直接用rand()会有直观感受。2.3 踩坑记录time播种撞车事件Day115的错题集里有一条非常典型的翻车记录。我写了一个批量生成随机数的小程序用的是srand(time(NULL))加rand()在命令行里快速执行了三次三次输出竟然完全一样。排查后发现是三次执行都落在同一秒内time(NULL)返回的值相同种子自然一样。这个问题在自动化测试、批量任务调度场景里尤其致命。如果多个进程同时启动种子相同意味着所有进程拿到完全相同的随机序列某些依赖随机性的测试就会产生一模一样的随机结果查错时极具迷惑性。后来我改用std::random_device作为种子源它在多数平台上会调用操作系统提供的真实熵源每次启动都能拿到不同种子。需要注意random_device在某些嵌入式平台或特殊环境下可能退化为伪随机这时可以用chrono::high_resolution_clock::now().time_since_epoch().count()混合系统时间微秒级精度充当种子效果也不错。3. 字符串数组初始化Day115的重点考题3.1 几种初始化方式到底差在哪字符串数组是C里典型的看着简单、一写就错的考点。Day115练的这道初始化题表面是填空实际把三种初始化方式全考了一遍。char s1[] hello; // 栈上自动分配6字节包含结尾的\0 char s2[5] hello; // 编译错误空间不足字符串字面量还含\0 char* s3 hello; // 老标准里是const char*修改s3指向的内容是未定义行为第一种写法最常用编译器根据字面量长度自动确定数组大小sizeof(s1)等于6而不是5因为末尾隐式补了\0。第二种写法是很多初学者会犯的错看到hello是5个字符就开5个元素的数组忽略了\0的存在。第三种写法才是真正的坑。在C11以前把字符串字面量赋给char*是允许的但通过这个指针修改字符内容属于未定义行为。很多老教材这么教导致不少人在写代码时直接char* p abc; p[0] x;运行结果时好时坏有的编译器直接崩溃。C11之后字符串字面量类型是const char[N]正规写法应该是const char* s3 hello;。3.2 为什么\0这么容易被忽略\0是C风格字符串的结束标志没有它string.h里的strlen、strcpy根本不知道字符串在哪结束。写char s[5] {h, e, l, l, o}时这5个字符后面没有\0如果强行用strlen(s)去读长度函数会继续往后读内存直到撞上一个\0为止——读到的长度完全随缘可能5可能几十甚至直接触发段错误。这类问题的本质是C/C的数组不携带长度信息一切字符操作都依赖约定和人为保证。C里更好的替代方案是std::string它内部自己管理长度和容量不需要关心\0也没有缓冲区溢出风险。我现在的练习代码里凡是涉及字符串处理默认用std::string只有做序列化、协议解析、嵌入式相关代码时才会显露char[]和\0的相关操作。3.3 C17的std::string_view和初始化新动向Day115复习时顺带看了一下std::string_view。它是对一段连续字符缓冲区的视图不拥有数据只记录指针和长度。用它做函数参数时可以接收std::string、const char*、char[]字面量避免了隐式构造临时std::string带来的堆分配开销。void print(std::string_view sv) { std::cout sv std::endl; } print(hello); // 直接引用字面量 std::string str world; print(str); // 引用str内部的缓冲区要注意的是string_view不负责生命周期如果原始字符串被销毁或悬空再访问这个视图就是悬垂引用。比如返回局部std::string内部的string_view函数一出作用域就完蛋了。这块内容Day115没细考但作为字符串初始化的延伸知识点我觉得值得记录面试时这也是高频加分项。4. const、static、final三兄弟的混用场景4.1 一个例子看清三者的分工Day115的第三个考题是一个类设计题要求用const、static、final分别解决三个不同需求。一开始容易把这三者混在一起因为某些修饰可以叠加。我整理了一个典型例子class Config { public: static constexpr int kMaxSize 1024; static void setEnv(std::string name) { env_name_ std::move(name); } static const std::string getEnv() { return env_name_; } virtual void load() const { /* const成员函数 */ } virtual ~Config() default; }; class AppConfig final : public Config { public: void load() const override { /* 实现 */ } };kMaxSize同时是static和constexpr和const表示编译期常量、类级共享、只读不写。static成员函数setEnv和getEnv不依赖某个具体对象直接通过类名调用。const成员函数load承诺不修改对象内部状态但注意这里的const是修饰this指针所指对象的。final修饰的AppConfig禁止被继承防止语义被打破。4.2 static的隐藏语义存储位置与生命周期static在C里有好几种含义很多人把它和全局变量画等号但更准确的说法是静态存储期 限定作用域。局部静态变量只在第一次执行到声明处时初始化后续调用直接复用同一份内存类静态成员则属于整个类不属于任何单个对象。理解static的最好角度是存储布局。静态变量存放在静态存储区不在栈上也不在堆上生命周期从程序启动持续到程序结束。这就带来了线程安全问题多个线程同时读写同一个静态变量时数据竞争自然产生。C11起局部静态变量的初始化是线程安全的但初始化之后的访问操作仍需自行加锁。在Day115的练习题里我用static实现了一个简易的全局计数class Counter { public: static int now() { return counter_; } static void reset() { counter_ 0; } private: static int counter_; }; int Counter::counter_ 0;类内声明、类外定义这是无数新手会卡住的地方。C规定静态数据成员在类内只是声明必须在命名空间作用域内定义并初始化一次否则链接的时候会报未定义引用。唯一的例外是static constexpr整型C17之后可以在类内直接初始化且无需类外定义。4.3 constexpr、const和final的边界问题const和constexpr经常被并列提起但含义完全不同。const描述的是运行时只读constexpr描述的是编译期可求值。一个constexpr变量必然是const的但反过来不一定成立——比如const int x rand();x在运行时才确定值就不能当编译期常量用。我用一个小例子说明这个区别const int a 10; // 可以当编译期常量用也可以运行时确定 constexpr int b 10; // 强制编译期确定 const int c std::rand(); // 合法运行时只读 constexpr int d std::rand(); // 错误rand()不是constexpr函数 constexpr int e kMaxSize * 2; // 合法kMaxSize是编译期常量final则用法相对简单修饰类时阻止继承修饰虚函数时阻止派生类覆盖。它并不是万金油只在确定不需要再扩展的边界场景使用比如内部基础设施、工具类、叶子配置类。如果过度使用final代码后续扩展时会很痛苦接口设计反而被锁死。Day115的训练记录里我将这三个修饰符的异同做成了一句话总结static管归属和生命周期const管可修改性final管继承和覆盖三者可以叠加但各管一件事。4.4const成员函数的经典误用案例const成员函数最容易被忽略的坑是它不能修改成员变量但可以修改mutable标记的成员也可以通过指针间接修改动态内存指向的内容。struct Foo { int* ptr nullptr; mutable int cache 0; int get() const { *ptr 42; // 合法修改了ptr指向的内容不是ptr本身 cache 1; // 合法mutable成员允许在const函数中修改 return *ptr; } };这种语义常让初学者困惑。const成员函数保证的是成员变量不被修改这句话对ptr这个指针变量成立但对它指向的内存不一定成立。设计API时如果不想暴露内部可变状态必须通过引用返回时加const限定比如const std::string name() const。Day115里有一道判断改错题就是考const成员函数内能否给非mutable成员赋值答案是编译错误。很多人在笔试时虽然能判断对错但说不出背后的语义原因我在这篇记录里把原因补全了。5. VSCode调CDay115的实操环境心得5.1 为什么我还是在用VSCode写C练习题用VSCode做C日常练习很大程度是因为它轻量、跨平台、启动快。不像Visual Studio那样安装就得几个G也不像CLion那样需要授权。VSCode加上C/C扩展、Code Runner插件写课后习题完全够用。换个角度说VSCode的调试体验也确实不差。断点、逐帧查看变量值、查看调用栈这些核心功能都在只是需要先花点时间配置文件。如果嫌配置麻烦直接用支持一键运行插件的环境也是可以的但想理解编译、链接、调试全链路VSCode这套配置过程本身就是一个不错的学习材料。5.2 配置流程一份能直接跑的最小配置我建议采用tasks.json配合launch.json的方式核心是让两个JSON文件彼此配合。tasks.json负责编译生成可执行文件launch.json负责启动调试器加载可执行文件。首先在项目根目录创建.vscode/tasks.json一个可以直接用的最小配置{ version: 2.0.0, tasks: [ { label: g build, type: shell, command: g, args: [ -g, main.cpp, -o, main.exe ], group: { kind: build, isDefault: true } } ] }这里的-g选项会生成调试信息如果没有这一步调试器没法把汇编指令对应回源代码行。然后是.vscode/launch.json{ version: 0.2.0, configurations: [ { name: C Debug, type: cppdbg, request: launch, program: ${workspaceFolder}/main.exe, args: [], stopAtEntry: false, cwd: ${workspaceFolder}, environment: [], externalConsole: false, MIMode: gdb, miDebuggerPath: gdb, setupCommands: [ { description: Enable pretty printing, text: -enable-pretty-printing, ignoreFailures: true } ], preLaunchTask: g build } ] }关键字段是preLaunchTask它会在启动调试前先执行前面的编译任务。也就是说你按F5它会自动完成编译新的二进制 加载到调试器 停在断点整个过程体验和IDE差距不大。5.3 头文件路径、多文件编译和Win下的坑当练习从单文件过渡到多文件工程时tasks.json里的command和args也要跟着变。最简单的方式是把所有cpp文件一起传给g比如g -g main.cpp utils.cpp -o main.exe如果你的项目有几十个文件建议了解Makefile或CMake层面的构建方式而不是手动打命令。Windows下还有一个高频问题gdb相关的报错大多是调试器路径没配好。MinGW的bin目录里需要有gdb.exe把miDebuggerPath指向实际路径例如miDebuggerPath: D:/mingw64/bin/gdb.exe。路径里的斜杠最好用正斜杠反斜杠会被当成转义符处理。另外提醒一下如果你同时装了Visual Studio的C工具链和MinGWVSCode有时候会挑错编译器导致头文件路径、标准库实现完全不匹配。处理方式是打开命令面板搜C/C: Select Configuration指定用MinGW并且确认compilerPath指向g.exe的真实路径。5.4 一个调试技巧观察随机数序列的分布Day115的练习题正好可以利用调试器做个小实验。在生成随机数的循环后设置条件断点比如i 100000然后查看n个样本的均值、方差验证uniform_int_distribution是否真的均匀。VSCode的调试监视窗口里可以添加表达式比如stats.mean、stats.variance。断点命中的瞬间左侧变量面板会展示当前作用域所有变量。这个方法比在程序里打印一堆日志高效得多因为你能看到的是程序运行的中间态而不是只有最终输出。6. C新手常见问题速查Day115实测记录6.1 冒泡排序算法与随机数、字符串的交叉练习Day115的算法题之一是手写冒泡排序。单纯排序没意思我把题目的输入改成随机数生成输出必须按指定格式打印。这道题就综合了随机数、数组遍历、格式化输出三个知识点。冒泡排序写起来很规整但有个细节值得注意内层循环的结束条件要不要减一。标准写法是相邻两两比较交换n-1轮。代码里如果过渡比较比如每轮都遍历到最后一个元素虽然结果不一定错但会做大量无效比较。void bubbleSort(std::vectorint arr) { int n arr.size(); for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { std::swap(arr[j], arr[j 1]); } } } }6.2 结构体链表的C基本语法复盘另一道练习是结构体链表需要先定义节点结构体再实现插入、删除、遍历。C写法相比C语言的最大区别是可以用构造函数、引用、STL容器替代裸指针但作为基本功我仍然建议学一遍手写链表。struct Node { int data; Node* next; Node(int val) : data(val), next(nullptr) {} }; void insertAfter(Node* prev, int value) { if (!prev) return; Node* newNode new Node(value); newNode-next prev-next; prev-next newNode; }这里每次new出来的节点必须记得delete否则就是内存泄漏。链表练习的最大价值之一就是逼你养成手动管理内存时必须成对出现new和delete的习惯。6.3 运算符优先级短路求值的隐藏规律Day115还复习了运算符优先级特别是和||的短路求值。逻辑与运算符左边为假时右边不会执行逻辑或运算符左边为真时右边不会执行。这个特性在条件判断里经常被利用但也容易因改变书写顺序引入bug。实操中经常用短路特性来保护空指针if (ptr ! nullptr ptr-value() 10) { // 安全ptr为空时第二个表达式不会执行 }如果把顺序反过来写成ptr-value() 10 ptr ! nullptr空指针解引用就会先发生直接崩溃。这类顺序问题笔试面试都喜欢考Day115我记下了一条规则先做判空再做访问。6.4 回调函数与快速幂面试中绕不开的两个点回调函数在C里的实现方式多种多样最简形式是函数指针进阶是用std::function。Day115用std::function实现了一个简易的按钮回调void onButtonClick(const std::functionvoid() callback) { if (callback) { callback(); } } onButtonClick([]() { std::cout button clicked std::endl; });快速幂算法则是个典型的分治优化把指数运算复杂度从O(n)降到O(logn)。核心思路是把幂分解成二进制位每次对底数做平方int quickPow(int base, int exp) { int result 1; while (exp 0) { if (exp 1) { result * base; } base * base; exp 1; } return result; }这个算法在密码学、取模运算里用得很多。注意代码里的exp 1是右移一位相当于整除2别写成exp 1这种笔误。7. 我在Day115训练里踩过的最值钱的坑说一个最值钱的坑这个坑我踩的时候浪费了整整一个下午。题目是用final修饰类之后为什么内部成员函数仍然可以继续加override。我当时的理解是final已经限制继承override就无处安放了。实际上final只影响是否允许被派生类重写不影响基类自身定义虚函数。换句话说基类完全可以用virtual void work() final定义函数暗示派生类无法重写而派生类override一个不是虚函数的成员本身就是个错误。实际操作里我经常用final配合override来标记设计意图接口作者希望后续实现固定在当前层避免子类无意覆盖。写完这个过程后我对继承层级控制的理解比背十道八股题都深。这也是训练记录的价值所在——它不是代码的搬运工而是思考路径的完整存档。最后再分享一个小技巧每天做训练记录时尽量在新的代码里复现之前踩过的坑。比如随机数种子撞车、const成员函数改mutable、字符串数组忘记\0这三个问题我各写了一个最小复现程序放在专门目录里。过两天再扫一眼马上就回忆起来当时的错误直觉是什么。复现——纠正——归档这个循环走下来基础薄弱的点才会真正被补上。