C++训练Day73:指针、快速幂与字符串处理的实战复盘
2026/9/24 23:12:14 网站建设 项目流程

写训练记录写到第73天,这个阶段其实挺有意思。刚起步那会,每天被指针、引用、内存分配折腾得晕头转向,一道链表反转能写一晚上;到了现在,再回头看这些基础题,会有一种“原来当时卡住是因为没理解底层”的顿悟感。Day73是个很微妙的节点,语法层面的新鲜感基本消退,算法和数据结构的题目开始成为主力,同时还会不断回头补基础漏洞。

这篇记录不打算写成流水账,主要分享三件事:一是这个阶段我到底在练什么、为什么这么练,二是几个绕不开的知识点(指针、const/static、运算符优先级、随机数)在训练里是怎么被反复锤打的,三是三道经典题目的完整复盘和踩坑记录。如果你也正处在C++学习的中期爬坡阶段,或者刷题刷到怀疑人生,这篇应该能给你一点参考。

1. 今日训练内容与选题思路

1.1 为什么“Day73”开始混合刷题

Day1到Day30那会儿,我基本是照着语法书一个知识点一个知识点地过,int、float、循环、数组、函数、结构体,每个都单独练。到了Day40左右开始接触链表、栈、队列这些线性结构,才发现语法会写和能用是两码事。Day60以后,我开始把算法题和语法复习混在一起练,每天不只看“这题怎么做”,还要问“这个写法在底层到底发生了什么”。

Day73的训练内容就是这种思路的延续。我没有专门去开新章节,而是选了三个看起来不相关的点串在一起:链表反转(复习指针操作和递归)、快速幂(复习位运算和分治思想)、字符串数组初始化(复习C风格字符串和C++标准库的取舍)。这三个点正好覆盖了C++训练中最容易出问题的几个区域:指针的指向关系、运算的边界条件、内存的分配方式。

如果你也处在类似阶段,我的建议是不要只刷单一类型的题,每天在“语法补漏 + 数据结构 + 一个数学/位运算题”之间做组合,这样训练效果会比单刷一类题好很多。

1.2 从热搜词看大家C++训练的真实痛点

最近在社区里看到不少C++相关的高频搜索词,比如“指针用法”“const/static详解”“结构体链表基本语法”“运算符优先级顺序表”“快速幂算法”“按位与”这些。说实话,这些词暴露了绝大部分C++初学者的真实状态:不是不知道语法怎么念,而是不知道语法怎么用。

比如“指针用法”这个词,搜的人很多,但真正的问题往往不是“指针是什么”,而是“指针到底该在什么场景下用、怎么避免把指针用崩”。再比如“结构体链表基本语法”,实际卡住的地方通常是“创建节点、连接节点、遍历节点”这三步循环,而不是struct关键字本身。今天训练里我特意把这些痛点对应的题目都安排了一遍。

1.3 训练环境的预处理

开始做题前,我先花十分钟把环境梳理了一遍。我用的是VS Code + MinGW-w64(g++ 11.2.0),编译参数是-std=c++17 -Wall -Wextra -g,调试用GDB。之所以不用IDE的一键运行,是因为命令行编译能让你看清每一个中间文件的生成过程,对理解编译原理有好处。

如果你的环境还没配好,建议优先搞定这三点:编译器能正常处理C++17标准、调试器能命中断点、编译时报错信息能定位到具体行号。这三件事不解决,后面所有的训练都会很痛苦,你会在“环境问题”和“代码问题”之间来回折腾,根本分不清到底是谁的错。

2. 核心知识点拆解:训练里反复踩的四个点

2.1 指针:从“会写”到“不会崩”

指针是C++训练里绕不过去的大山,Day73再看指针,我的理解已经和Day10完全不同了。

很多初学者以为指针就是“存地址的变量”,这句话没错,但真正写代码时会发现,指针的问题从来不在“存地址”这一步,而在“解引用”这一步。你定义一个int* p;却不初始化,然后直接*p = 10;,程序在绝大多数情况下不会立刻崩,而是悄悄踩踏了未知内存,这就导致问题特别难排查。今天做链表反转时,我专门写了个错误版本,故意让头节点的指针悬空,然后在GDB里观察野指针的地址值,看到那个乱七八糟的十六进制数,才真正理解了为什么“初始化指针”是铁律。

另一个容易踩的坑是“指针的指针”和“指针引用”。写树形结构时经常要把Node*&作为参数传递,用来修改调用者手里的指针本身。如果这里用成Node*,看起来只差一个&,实际效果是函数内部改了指针的指向,但调用者手里的指针纹丝不动。今天我专门用一个insertNode函数验证了这俩的区别,这种基础细节,只要是训练C++就不可能绕开。

2.2 const 与 static:它们到底在约束谁

很多人对const的理解就是“变量不可改”,但训练到中后期,你会发现const的真实作用是“约定”。const int* pint* const p有什么区别?前者是“p指向的东西不能通过p改”,后者是“p本身不能改”。写链表类时,const还经常出现在成员函数后面,比如int getVal() const;,意思是这个成员函数不会修改对象的状态。如果不写这个const,那么一个const Node对象就没法调用这个函数。

static的坑稍微简单一点,但也很隐蔽。类里的 static 成员变量是所有对象共享的,它必须在类外单独定义,否则链接时会报“undefined reference”。今天做快速幂时我用了一个 static 成员来统计函数调用次数,结果忘了在类外定义,直接报链接错误,解决完后倒是把 static 的语义彻底记住了。

2.3 运算符优先级:一道题引发的血案

今天被一个看似简单的表达式坑了几分钟:if (x & 1 == 0)。我在判断一个数是奇数还是偶数。直觉告诉我这是“x按位与1,然后判断是否等于0”。实际上,C++里==的优先级比&高,所以x & 1 == 0会被解析成x & (1 == 0),也就是x & false,不管x是多少,结果都是0,条件判断永远不成立。这种问题真的只有踩过一次才知道疼,建议使用位运算时,能用括号就用括号,别跟优先级赌命。

同样经典的还有*p++(*p)++的区别:*p++是先取出p指向的值,然后p自增;(*p)++是把p指向的值自增。这两行代码一字之差,语义完全不同,在链表遍历里如果写错,轻则死循环,重则内存越界。

2.4 随机数:测试数据的好帮手

刷题时经常需要造测试数据,比如生成一个长度不定的数组来测排序算法。很多老教程还在用rand()srand(time(0)),但这个组合在C++11之后已经算是过时方案了,rand()的随机质量一般,而且取值范围是[0, RAND_MAX],想生成[1, n]还得自己取模,取模还会引入分布偏差。

我现在的习惯是用<random>库,核心就几行代码:

std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution<int> dis(1, 100); int value = dis(gen); // 生成 [1, 100] 的随机数

用这个方案生成测试数据,比rand()稳定得多,尤其在测边界条件的时候特别省心。今天做快速幂的验证时,我生成了100组随机底数和指数,把递归版和迭代版的结果逐一对比,两边完全一致才敢确认代码没写错。

3. 实操过程与核心环节实现

3.1 题目一:反转链表,迭代版与递归版双实现

先设计一个单链表结构:

struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} };

迭代版反转链表的核心是三指针:prev、cur、next,每次先把 cur 的下一个节点存下来,再改指向,然后三个指针整体向后滑动。这个写法的关键点是循环结束条件,cur != nullptr,循环结束后prev正好停在新的头节点:

ListNode* reverseList_iter(ListNode* head) { ListNode* prev = nullptr; ListNode* cur = head; while (cur) { ListNode* next = cur->next; // 先保存,防止断链 cur->next = prev; // 反向指 prev = cur; // prev 前进 cur = next; // cur 前进 } return prev; }

递归版的代码更短,但理解难度反而更高:

ListNode* reverseList_rec(ListNode* head) { if (!head || !head->next) return head; ListNode* newHead = reverseList_rec(head->next); head->next->next = head; head->next = nullptr; return newHead; }

递归版的核心是“先处理后面的节点,再回头处理当前节点”。建议在纸上画一遍链表的三节点走位,把每一层递归时的 head 指向、newHead 指向都标清楚,再回到代码里就顺了。今天我用一个5个节点的链表测试了两个版本,结果一致,但递归版在节点数上万时可能会栈溢出,工程上更推荐迭代版。

3.2 题目二:快速幂,位运算与分治思想

快速幂解决的问题是“计算 x 的 n 次方”,朴素写法是循环n次相乘,时间复杂度 O(n),当 n 是 1e9 级别时显然不可行。快速幂的核心思路是二分:x^n 可以拆成 (x^(n/2))^2,这样只需要 O(log n) 次乘法。

递归版快速幂:

long long fastPow_rec(long long x, long long n) { if (n == 0) return 1; long long half = fastPow_rec(x, n / 2); return n % 2 == 0 ? half * half : half * half * x; }

迭代版利用的是“把指数拆成二进制”的思路。举个例子,计算 x^13,13 的二进制是 1101,也就是 x^13 = x^8 * x^4 * x^1。迭代时不断让底数自乘(x = x^2, x^4, x^8...),同时根据当前二进制位是否为1决定是否乘进结果里:

long long fastPow_iter(long long x, long long n) { long long result = 1; while (n > 0) { if (n & 1) result *= x; x *= x; n >>= 1; } return result; }

这里有个关键点:两个版本都用了long long而不是int,因为中间结果很容易超过 int 的范围。我测试 x=3, n=20 时结果已经是 3486784401,早就超出 int 上限了。如果题目要求取模,常规做法是在每步乘法后对某个质数取模,避免溢出。

今天还专门测了 n=0 和 n=1 这两个边界:n=0 返回 1,n=1 在循环里会直接把 x 乘进结果,两个版本输出一致。

3.3 题目三:字符数组初始化与字符串转数组

这个题目看起来基础,但实际写时特别容易栽跟头。C风格字符串是char[],以 '\0' 结尾;C++的std::string是对象,有自己的长度信息。把std::string转成 C 风格字符数组,标准做法是:

std::string s = "hello"; const char* cstr = s.c_str(); // 只读版本,最常见 std::vector<char> buf(s.begin(), s.end()); buf.push_back('\0'); // 手动补终止符

下面这个写法是反面教材,也是很多人常犯的错误:

const char* p = s.data(); // C++17 之前 data() 返回的是 const char* char* q = const_cast<char*>(p); // 强行去掉 const q[0] = 'H'; // 未定义行为,可能直接崩

为什么不能这么改?因为std::string的内部缓冲区不保证以可写方式暴露给外部,有的实现里data()返回的内存是只读的或共享的,强行写入会导致未定义行为。正确做法是用&s[0]或者直接复制到新的std::vector<char>里再修改。

今天我用这个知识点配合按位运算写了一个小工具:把字符串里的每个字符转成二进制形式(用unsigned char类型避免符号位问题),然后用按位与去提取每个bit。这个练习把“字符串处理”和“位运算”串在了一起,比单纯背“按位与的规则”有用得多。

3.4 编译、运行与GDB验证

三道题的代码都在VS Code里编译通过后,我用GDB对反转链表版做了断点调试。在cur->next = prev;这行打断点,每执行一次就打印prevcurnext三个指针的地址和值,能非常直观地看到“断链-反指-前进”的过程。这也是我今天最推荐的调试练习:不要只看结果对不对,要亲眼看着链表的指针是怎么一步一步变成反序的。

如果你也用GDB,推荐两个常用命令:

p *cur // 查看 cur 指向的结构体内容 p cur->next // 查看当前节点的 next 字段

4. 常见问题与排查技巧实录

4.1 段错误(Segmentation Fault)

今天练反转链表时,我故意把cur初始化为head->next,跳过了第一个节点,结果循环里解引用了空指针,程序直接段错误。排查方法很简单,先用GDB运行,崩溃后会显示崩在哪一行,再用bt命令查看调用栈,通常一眼就能定位。

段错误最常见的三种原因:解引用空指针、指针越界、访问已释放的内存。训练链表、树这类题目时,90%的崩溃都能归结到这三点。我的经验是:每次访问一个指针之前,先问一句“这个指针有没有可能是空的或悬空的”,养成这个习惯后段错误会少很多。

4.2 编译错误与链接错误

今天遇到的最典型的编译错误是“invalid conversion from ‘const char*’ to ‘char*’”,原因是把s.c_str()的返回值直接赋给char*了。遇到这种编译错误,不要急着强转,先想想设计意图:你到底需不需要修改内部缓冲区?如果需要,就老老实实复制一份。

链接错误也有一个经典案例,就是前面提到的 static 成员变量没有在类外定义。错误信息会显示一堆 “undefined reference”,很多人看到这个就懵了,其实只要在类外补上int MyClass::count = 0;就解决了。

4.3 逻辑错误比编译错误更隐蔽

今天快速幂迭代版第一次跑出来结果不对,检查了二十分钟才发现,原来是result *= x;x *= x;的顺序反了。如果先让x自乘,再判断当前二进制位,就会导致底数领先指数一步,结果自然全错。这类逻辑错误编译器不会报错,代码也能正常编译运行,只能靠测试数据去发现。建议在验证时多测边界值:n=0、n=1、n=2、n=大数,再结合随机数造多组数据对比,别只测一个例子就结束。

4.4 常见问题速查表

问题现象可能原因定位思路解决方法
程序运行时崩溃,提示 segment fault解引用空指针或野指针用GDB看崩溃行,检查该行访问的指针初始化所有指针,使用前判空
编译报 invalid conversionconst 无故被移除看报错行涉及的类型需要修改时复制一份,否则用 const char*
链接报 undefined referencestatic 成员未定义看哪个符号缺失在类外补定义
条件判断结果老是不对运算符优先级搞混把表达式单独打印出来看统一加括号,别省
递归版链表反转栈溢出节点数过多看崩溃调用栈深度改用迭代版

5. 训练节奏与记录方法复盘

5.1 Day1到Day73的训练节奏参考

有不少人问过我怎么坚持到73天的。我的体会是,把训练切分成三个阶段目标非常关键:

  • Day1~Day20:语法基础为主线,每天只学一两个语法点,配上最简单的练习,目标是能看懂别人写的C++代码。
  • Day21~Day45:数据结构开始进场,数组、链表、栈、队列、树逐一出场,重点练“把思路翻译成代码”的能力。
  • Day46~Day73:算法与语法交叉训练,排序、二分、双指针、位运算、快速幂这些经典题型每天保持手感,同时回头复习const、static、指针等语法细节。

这个节奏不一定适合所有人,但核心思路可以参考:前期重语法广度,中期重结构理解,后期重算法与语法的结合运用。

5.2 笔记怎么记才不白写

到Day73这个阶段,我已经不满足于“今天做了哪些题”这种流水账了。现在每道题我会固定记四个维度:题目描述与考点、我的第一遍思路、标准解法的关键步骤、我栽过的坑或没想通的地方。

比如今天反转链表这题,笔记里记了一个很重要的自我提醒:“断链前必须先保存下一个节点”。这句话就是我这道题的核心经验,比抄一遍代码有用得多。如果你也写训练笔记,建议用“哪句话是你下次看到就能立刻想起整道题解法”的标准来精简笔记,而不是把代码原封不动贴上去。

5.3 Day73之后的训练计划

到了这个节点,我的下一步计划是把更多精力转向工程化方向:读STL容器的源码实现、写一个迷你版 vector/string、找一个开源C++项目做代码阅读。毕竟刷题练的是“局部手艺”,而工程练的是“系统配合”。目前的想法是先从模拟实现std::string开始,用上今天复习的指针、const、动态内存这些知识,让它们真正派上用场。

最后再分享一个我个人的小习惯:每做完三道题,我会用一句话把它们串成一个场景。比如今天的三道题可以串成“用链表组织数据,用位运算加速计算,用字符串完成输入输出”,这样一天下来,脑子里留下的不是三块孤立的碎片,而是一个能转起来的小系统。这个方法看起来很简单,但对我坚持73天帮助非常大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询