ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++进阶路线:从环境配置、语法深挖到算法模板与崩溃调试

C++进阶路线:从环境配置、语法深挖到算法模板与崩溃调试 1. 从热搜词看C第二阶段的真实主线C学习笔记写到第二篇我特意反反复复看了后台那一串关联搜索词。说实话第一眼扫过去会觉得乱又是环境配置又是算法模板中间还夹着一个“access violation c0000005”这种报错词。但如果你把这些词按时间线排一排会发现它们根本不是乱搜而是一个C学习者在跨过“Hello World”之后被现实按着头往前走的一条必经之路。1.1 搜索词暴露了四类真实需求我把这批热词归了下类大概能分成四拨分组代表搜索词背后对应的问题环境基建vscode配置c/c环境、vscode c、microsoft visual c redistributable、c/c构建代码还没写几行环境先把人折腾哭了语法细节c字符串数组初始化、c结构体链表基本语法、c回调函数例子、c if语句、c字符串转数组基础语法会了真上手写项目处处是坑算法入门冒泡排序算法c、c前缀和、c随机数、判断质数c优化刷题入场券从排序到套路化思维进阶套路快速幂算法c、单调栈算法c、c分治算法、c广搜模板、卢卡斯定理c怎么写算法从“会”到“懂套路”的阶段崩溃排错c#调用c出现access violation c0000005开始做真实项目被内存问题教育了这五类词正好说明一件事C学习的第二阶段重点已经不再是“多记几条语法规则”而是把你从“能看懂代码”变成“能造出东西并且能在它崩了之后自己救回来”。我见过太多人卡在这个阶段不是不够聪明而是不知道该往哪个方向使劲。1.2 第二阶段我准备啃下的四块骨头基于上面这批搜索词我给自己的第二阶段定了四条线这篇笔记就按这四条线展开。第一环境线。不只是把VSCode配好能跑而是搞清楚“编译、链接、运行”这三个词分别对应哪些步骤为什么换个电脑代码就编译不过为什么有些人机器上缺个Redistributable就弹出DLL错误。第二语法线。重点不是新语法而是把之前“好像懂了”的东西往深挖一层数组初始化到底发生了什么、结构体里的指针到底指向哪里、if后面的比较表达式到底是怎么求值的。第三算法线。从冒泡排序这种入门排序开始一路走到前缀和、快速幂、单调栈这些有“套路”可循的算法。这一阶段要建立的不只是代码能力更是一种“看到题目先判断该用哪一类算法”的嗅觉。第四调试线。尤其要以“access violation”这种崩溃问题为入口学会用排除法缩小问题范围。这个能力在真实项目里比背多少语法都值钱。2. 环境这道坎编辑器、运行库与构建链路2.1 VSCode配置的坑编译器路径和编码问题先聊环境因为“vscode配置c/c环境”这组词在热搜里出现了不止一次。我猜多数人第一次打开VSCode写C时界面长这样装了个C/C插件写个main.cpp点了右上角那个运行按钮然后开始刷各种报错。我最开始也是在tasks.json和launch.json之间反复横跳。这里先说结论你只需要两个文件就能让VSCode跑起来。一个是tasks.json负责把源码编译成可执行文件另一个是launch.json负责让调试器找到这个可执行文件并打断点。以Linux或macOS为例tasks.json的核心配置是这样{ tasks: [ { type: cppbuild, label: C/C: g build active file, command: /usr/bin/g, args: [ -g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension} ], options: { cwd: ${fileDirname} }, group: build } ], version: 2.0.0 }这里面最坑的是command那一行的编译器路径。Windows用户如果用的是MinGW-w64路径大概是C:\mingw64\bin\g.exe千万别写成老MinGW的路径也不要只装一个“TC”之类的上古编译器。很多人的第一个报错就是来自这里。另一个典型的坑是乱码。你在Windows控制台里运行程序中文输出变成一堆“锟斤拷”这不是代码写错了是源文件默认UTF-8编码而Windows控制台默认用GBK代码页936去解读。我早期的解决办法是每次弹窗都手动chcp 65001后来干脆在程序开头加一句setlocale(LC_ALL, );这句能让程序按当前系统语言环境来处理中文字符比system(chcp 65001)干净不会为了改编码而额外启动一个子进程。如果还乱再去看VSCode编辑器右下角的编码是不是UTF-8源文件本身一定要统一。这里强调一下环境配置阶段不要追求“一次配好永不折腾”你真正要积累的能力是“把一个编译错误和某个配置项关联起来”的敏感度。编译器报“找不到头文件”十有八九是include路径没配报“无法打开输出文件”大概率是上个程序还在运行文件被占用报“undefined reference”那就要去看链接阶段的库路径了。2.2 Visual C Redistributable运行时库不是开发包热搜里出现了两组词microsoft visual c redistributable和microsoft visual c 2019 redistributable package。这词看着长其实背后是一个很日常的场景——装了个软件提示缺少dll文件你一搜发现要装这个红字开头的运行时库。Redistributable是运行时库不是开发包。它里面装的是msvcp140.dll、vcruntime140.dll这一堆系统级动态链接库。你写C时如果用VS编译默认会链接到这些库而程序发布到一台没有装过Visual Studio的电脑上它自然找不到这些dll。所以你要做的不是把整个Visual Studio搬到目标机器上而是装对应版本的运行库。Windows下的通用建议是把x64和x86的“Visual C Redistributable 2015-2022”都装上因为有些老软件依赖2013版有些新软件又指向2022版多装几个并不冲突。我见过有人为这事折腾半天最后发现是下载了“运行库合集”里捆绑的乱七八糟软件。这个场景我要提醒一句再去官方渠道下载别碰“高速下载”旁边那些花里胡哨的按钮。2.3 从单文件到多文件为什么这时候开始学CMake第二阶段还有一个容易被忽略的分水岭你写的项目开始从main.cpp一个文件变成main.cpp utils.cpp game.cpp好几个文件。这时候再让VSCode的tasks.json去编译“当前活动文件”就会出问题——因为utils.cpp没被一起编译链接阶段一堆“undefined reference”。解决办法有两种。第一种继续用VSCode把tasks.json里的args改成编译多个源文件args: [ -g, ${workspaceFolder}/*.cpp, -o, ${workspaceFolder}/program ]这种方法适合刚进入多文件阶段、还没有目录结构的项目。但一旦你开始分目录、加第三方库这种写法就会变成噩梦。第二种也是我更推荐的这个阶段直接开始学CMake。CMake本身不是一个编译器它负责生成各个平台对应的构建文件Makefile或者Visual Studio工程。一个最简的CMakeLists.txt长这样cmake_minimum_required(VERSION 3.16) project(my_project) set(CMAKE_CXX_STANDARD 17) add_executable(program src/main.cpp src/utils.cpp )有了CMakeVSCode只需要装一个“CMake Tools”插件然后在项目根目录CtrlShiftP搜“CMake: Configure”选中编译器后面的构建和调试都会被插件接管。这一步的收益是长久的因为后面不管你是到Linux服务器上交叉编译还是想接第三方库CMake都是绕不开的底座。3. 语法进阶的四个硬骨头3.1 字符串数组初始化char、指针与二进制缓冲区热搜里有“c字符串数组初始化”和“c字符串转数组”还有一条特别有画面感的“unsigned char recdata[512] {0} 转换成char c”。这几个搜索词指向的是同一个困惑C里“字符”这个类型既能当文本又能当字节到底怎么倒腾。先说字符串数组初始化。凡是学C语言起家的人肯定都写过这两种写法char str1[] hello; const char* str2 hello;第一行的意思是在栈上开一块6字节内存5个字符加一个\0把hello拷贝进去str1本身就是一个数组内容可改。第二行的意思是在只读区放一个字符串常量str2这个指针指向它内容不允许修改。如果你写了str2[0] H在不少系统上会直接崩溃。第二句话里的“转换成char”本质是字节缓冲区在不同视图之间切换。比如从网络或者设备读了一包数据存在unsigned char recdata[512]里现在想按char来看里面的ASCII内容最保险的做法是一个字节一个字节地转换char chardata[512]; for (int i 0; i 512; i) { chardata[i] static_castchar(recdata[i]); }想省事也可以用reinterpret_cast直接重解释整块内存char* p reinterpret_castchar*(recdata);但这里有个前提你得保证这个缓冲区确实以\0结尾否则后续不管打印还是当字符串处理都可能越界读出一大串垃圾。所以我个人在拿网络数据做转换时习惯先把长度固定下来然后明确按字节处理而不是急着转成“字符串”。这个习惯能帮你避开一大半踩内存的坑。3.2 结构体与链表第一份“自己设计的数据结构”数据结构的第一个门槛通常不是数组而是链表。原因很简单数组是语言自带的链表逼你必须用结构体加指针去组织数据。热搜词“c结构体链表基本语法”下的标准代码长这样struct Node { int data; Node* next; };创建节点的标准动作是Node* head nullptr; Node* new_node new Node{42, nullptr}; new_node-next head; head new_node;这个代码里最容易被忽视的是第三步head new_node。很多人第一次写会这样写void insert_head(Node* head, int value) { Node* new_node new Node{value, nullptr}; new_node-next head; head new_node; }因为在函数里只把head这个指针的副本改了外部的head根本不会变链表头依然是最开始的那个节点。正确的做法是传指针的指针或者用C的引用void insert_head(Node* head, int value) { Node* new_node new Node{value, nullptr}; new_node-next head; head new_node; }这个细节值得你停下来多想一想为什么传Node*不够非要传Node*。想明白了你才算真理解“指针也是值”这句话——指针本身是一个存着地址的变量你要修改这个变量的值就要把它的地址或者引用传进去。后面等你用到STL的std::list时会发现自己的链表代码基本不会被用到。但自己写一遍链表的价值在于你能直观感受到“节点”“指针”“内存分配”之间的关系这对以后理解容器、树、图都有帮助。3.3 if 与比较运算符分支里的隐蔽语义“c if语句”和“c 比较运算符”也进了热搜。这两个词单独拿出来说会显得有点基础但那是因为它们的使用场景太日常反而让人忽略了几个真的会出事的细节。第一个是和的混淆。初学者偶尔会把if (x 5)写成赋值这在C里是合法的——结果是x被赋值为5然后if把5当作“真”分支就会无条件走进去。现代编译器通常会给警告但你要把它当成一个习惯戒掉判断相等永远写如果你想防止自己犯错可以把常量写在左边if (5 x)这样万一写成就会编译报错因为不能给常量赋值。第二个是浮点数的比较。if (0.1 0.2 0.3)这个表达式大概率是false。因为浮点数在二进制里是近似存储的0.1 0.2算出来可能是0.30000000000000004。正确的做法是比较“差的绝对值是否小于一个很小的数”if (fabs((0.1 0.2) - 0.3) 1e-9)第三个是短路求值。if (ptr ptr-value 1)这种写法是安全的因为当ptr为nullptr时整个表达式的结果已经确定为假后面的ptr-value根本不会执行。反过来如果你把顺序写成if (ptr-value 1 ptr)那程序就炸了。理解短路求值之后你就会下意识地把“安全检查”放在前面。3.4 回调函数让代码拥有扩展点“c回调函数例子”也是一个高频搜索词。回调这个概念第一次接触会觉得抽象其实你早就用过了——std::sort可以接受一个比较函数这个比较函数就是一个回调。最简单的函数指针写法是这样bool greater(int a, int b) { return a b; } int main() { std::vectorint v{3, 1, 4, 1, 5}; std::sort(v.begin(), v.end(), greater); }这里的greater没有带括号它作为函数指针被传给sort排序算法在比较元素时会回调这个函数。这样设计的价值在于排序算法本身不需要改动你想按升序排、降序排、还是按绝对值排只需要换一个比较回调进去。如果函数需要捕获外部的变量函数指针就不好用了这时候C11之后的std::function和lambda可以顶上int threshold 2; std::sort(v.begin(), v.end(), [threshold](int a, int b) { return a threshold b threshold; });我自己在项目里用回调最多的场景其实是事件通知某个网络包到了、某个按钮被点下了、某个数据处理完了都通过回调把控制权交给上层。这种“把行为当作参数传递”的思路是从“按部就班写代码”到“设计可扩展代码”的重要转折点。4. 算法起步的思维跃迁从冒泡到前缀和4.1 冒泡排序第一个值得手写的算法“冒泡排序算法c”能进热搜我一点也不意外因为几乎所有C入门算法教材都会拿它开刀。冒泡排序的思想很简单相邻两数比较顺序不对就交换每一轮把当前范围内最大的数“冒”到最后。void bubble_sort(int arr[], int n) { for (int i 0; i n - 1; i) { bool swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { std::swap(arr[j], arr[j 1]); swapped true; } } if (!swapped) break; } }有人会问现在谁还用冒泡排序确实实际工程里排序直接用std::sort就行平均复杂度O(N log N)比冒泡的O(N^2)强太多。但冒泡排序仍然是值得手写一遍的算法因为它是你理解“嵌套循环控制数据范围”的启蒙课。内层循环的n - 1 - i这个上界为什么要随着i缩小swapped这个标志为什么能提前结束这些问题想明白了后面的排序算法学起来会顺很多。4.2 C随机数从rand()到mt19937“c随机数”进入热搜多半是有人想写猜数字游戏或者给程序加一点“随机敌人”。以前C风格的做法是srand(time(nullptr)); int x rand() % 100; // 0~99这段代码有两个明显的毛病。第一rand()的周期和分布质量都很差在Windows或者老编译器上rand() % 100产生的低几位分布不均匀你用暴力统计会发现偏斜。第二用time(nullptr)做种子一秒以内连续启动程序得到的随机序列完全一样。C11开始有了正经的随机数库正确写法是这样的#include random std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distributionint dist(1, 100); int random_number dist(gen);std::mt19937是梅森旋转算法的一个实现周期长、分布均匀配合uniform_int_distribution可以精确控制生成范围。我自己的习惯是写一个小工具类把gen作为成员变量避免每次生成随机数都重新创建。如果你要写“c愤怒的小鸟”这类小游戏抛物线力度、障碍物位置这些都需要随机性用一个质量高的随机源会让你的程序行为更“正常”。4.3 前缀和第一个“空间换时间”的套路从这一小节开始相当于从“会写基础语法”正式进入“会套路”。前缀和是刷题路上第一个让我有“原来如此”感觉的算法。它的思想特别朴素开一个数组pre让pre[i]存“原数组前i个元素的和”然后任意区间[l, r]的和变成了pre[r] - pre[l-1]一个减法搞定查得特别快。int n; std::cin n; std::vectorint a(n 1); std::vectorint pre(n 1, 0); for (int i 1; i n; i) { std::cin a[i]; pre[i] pre[i - 1] a[i]; } // 求区间 [l, r] 的和 int l, r; std::cin l r; std::cout pre[r] - pre[l - 1] std::endl;注意这里数组下标从1开始pre[0]留作0这样处理边界时不用额外判断。这个算法的本质是“把多次重复的加法计算结果提前存好”属于空间换时间的思路。后面你会不断遇到类似的模式前缀最大值、前缀异或和、二维前缀和全都建立在这个思想之上。5. 进阶套路储备快速幂、单调栈、分治与广搜5.1 快速幂指数很大的时候的朴素答案“快速幂算法c”也是热搜词。它的场景很直接计算a^b mod p如果b比较大比如10^9用循环一次一次相乘会超时。快速幂的核心是把指数按二进制拆开。比如计算3^1313的二进制是1101也就是13 8 4 1那么3^13 3^8 * 3^4 * 3^1。我们只需要不断把a平方然后看b的当前二进制位是否为1是1就把结果乘上去。long long quick_pow(long long a, long long b, long long mod) { long long res 1; a % mod; while (b 0) { if (b 1) { res res * a % mod; } a a * a % mod; b 1; } return res; }这段代码的复杂度是O(log b)从O(N)降到了对数级。这个模板在竞赛题里几乎是默认技能后面学卢卡斯定理、费马小定理的时候也要用到它。5.2 单调栈下一个更大元素“单调栈算法c”是我觉得这批热搜里最有含金量的一个词因为能搜到这个的人说明已经开始接触真正的算法套路了。单调栈解决的经典问题是“下一个更大元素”给定一个数组找出每个位置右边第一个比它大的数。朴实的方法是双重循环每个元素都往后遍历复杂度O(N^2)。单调栈的解法能做到O(N)而且代码短std::vectorint next_greater(const std::vectorint nums) { std::vectorint result(nums.size(), -1); std::stackint st; for (int i 0; i (int)nums.size(); i) { while (!st.empty() nums[i] nums[st.top()]) { result[st.top()] nums[i]; st.pop(); } st.push(i); } return result; }栈里面存的是下标栈底到栈顶对应的数组值是递减的。当新元素大于栈顶元素时说明栈顶元素遇到了它右边第一个更大的值于是弹出栈顶并记录答案。为什么栈内是递减的因为如果新元素比栈顶小它没资格弹栈顶只能入栈等待未来某个更大元素来处理它。我第一次学这个算法时觉得靠“栈的单调性”就能维护候选集特别巧妙。后来发现凡是遇到“找出某个方向满足条件的上一个/下一个元素”这种问题都可以往单调栈的方向想。它能处理的不只是数字还有柱状图中最大矩形这类二维题型。5.3 分治与广搜两把常用思维钥匙“c分治算法”和“c广搜模板”是两类基础但极常用的算法模板。分治的核心是三步分解、解决、合并。归并排序就是最典型的例子把数组对半分分到只剩一个元素然后两两合并有序数组。void merge_sort(int arr[], int left, int right) { if (left right) return; int mid left (right - left) / 2; merge_sort(arr, left, mid); merge_sort(arr, mid 1, right); merge(arr, left, mid, right); // 合并两个有序区间 }分治的价值在于它能递归地简化问题但写的时候要注意“递归出口”和“合并逻辑”这两处边界。广搜BFS则适合处理“最短步数”类的问题比如迷宫从起点到终点的最少步数。基本的模板是队列加visited数组std::queueNode q; vectorvectorbool visited(n, vectorbool(m, false)); q.push(start); visited[start.x][start.y] true; while (!q.empty()) { Node cur q.front(); q.pop(); // 遍历上下左右四个方向 for (int i 0; i 4; i) { Node next {cur.x dx[i], cur.y dy[i]}; if (在界内 能走 !visited[next.x][next.y]) { visited[next.x][next.y] true; q.push(next); } } }广搜要记住的关键是一句“入队即标记visited”否则同一个点会被反复入队队列越来越大复杂度瞬间失控。这个细节是很多人在写广搜时踩的坑。5.4 数学向算法质数判定与卢卡斯定理热搜里还有“判断质数c优化”和“卢卡斯定理c怎么写”这些都是算法从“模板”走向“数学”的方向。最朴素的质数判断是枚举到sqrt(n)优化版本可以跳过偶数、每次步长加2。判断单个大质数时还能用“6的倍数法”快速排除大量候选。卢卡斯定理则是组合数取模的一个结论当模数p是质数且n、m很大时直接算组合数会溢出或者超时这时可以用C(n, m) ≡ C(n % p, m % p) * C(n / p, m / p) (mod p)它的本质是递归地按p进制拆解n和m。这个定理在“n和m远大于p”的场景下特别好用。C实现时需要先预处理阶乘和阶乘的逆元再用快速幂算逆元。如果你想在C里写出一道数学味道比较浓的编程题这类数学算法是绕不开的储备。它们不像单调栈那样“见得多就熟”需要你真的去推公式、补数论基础但好处是一旦掌握组合计数类题目对你来说就不再是禁区。6. 崩溃与面试Access Violation和面经复盘6.1 Access Violation C0000005一次跨语言调用的排查热搜里那条“c#调用c出现access violation c0000005”值得单独拿出来讲因为它把两个关键词凑到了一起跨语言调用和访问违规。Access Violation c0000005的含义是你访问了一个不该访问的内存地址比如空指针、越界、悬空指针都可能导致这个错误。C#调用C时出现它最常见的几个原因按出现频率排是这样第一是平台位数不匹配。C#工程是x86C DLL是x64或者反过来调用时内存布局完全错乱直接崩。排查方法是在C#工程的生成属性里明确设成x64然后确认C那边也是x64。第二是DllImport的函数签名与C导出的函数不一致。C导出的函数如果用了__cdecl而C#默认按Winapi实际是stdcall去约定函数调用方式参数栈管理不一致访问就会越界。解决办法是显式指定[DllImport(mydll.dll, CallingConvention CallingConvention.Cdecl)] public static extern int MyFunc(int x, int y);第三是结构体布局不对齐。C里的struct包含int、double、char时会按照对齐规则在内部填充字节C#如果直接用一个不设布局的类去对应拿到的是一个完全错位的内存视图。这时候需要给C#结构体加上[StructLayout(LayoutKind.Sequential)]必要时用Pack来控制对齐。我的排查流程是先开调试器看异常地址是0x00000000这种空指针还是接近0的一小块地址大概率是偏移一块小结构的悬空指针再回头核对平台位数和调用约定。别一上来就怀疑C库写错了跨语言调用问题七成出在接口边界上。这个报错也提醒了我们当程序有能力访问到越界内存时它不一定立刻崩有时候会在完全无关的地方表现异常。这也是为什么C这种“两手空空”的语言对内存模型的理解要求更高。6.2 面试前要回看的C清单热搜词里还有“c面试题”和“c八股”。如果你打算找C相关的岗第二阶段就应该开始有意识地收集和整理面试题了。不用背但要能对着问题用几句话说清楚并且随手写出小程序验证。我按经验列一下这个阶段最常被问到的点主题常见问题我的建议多态虚函数是怎么实现的手动画一遍虚表结构讲清虚指针放在哪内存new/delete和malloc/free的区别一个调用构造析构一个只管内存拷贝深拷贝和浅拷贝有什么区别用带指针的结构体举例写一遍智能指针shared_ptr会不会循环引用手写一个弱指针的例子容器vector的扩容机制知道“倍增”和“挪动元素”的区别排序快排为什么快能写一遍递归快排讲清楚分治思想constconst在C里有哪些用法修饰变量、修饰指针、修饰成员函数各说一个面试官其实很少只盯着一个问题追到底他更想看你“能不能把一个点讲深”。比如你提到智能指针他就顺手问“那你知道为什么unique_ptr不能拷贝只能移动吗”。这种连环追问只有在亲自动手写过之后才不会紧张。6.3 把学到的东西变成作品说实话热搜词里那一堆“c小游戏代码”“c愤怒的小鸟”“c 编程魔方还原”我看着觉得比“c八股”更有希望。因为那些词背后是一个人在认真地想让代码变得有意思。学C第二阶段我最大的体会是别只跟着教程练习册走早点开始做一个小而完整的项目。哪怕只是一个命令行版的猜数字、一个带简单物理的愤怒小鸟仿制品甚至一个魔方还原程序——它逼你去解决那些搜索词里解决不了的问题怎么组织多个文件、怎么处理用户输入异常、怎么让逻辑跑在循环里不卡死。这些能力看教程学不来。我个人还养成了一个习惯建一个“报错日志”文档每次遇到玄学报错就记下报错信息、上下文、解决过程。后来面试让我分享一个“印象深刻的调试经历”我直接就从这本日志里挑了一段讲比我临时编的有说服力得多。最后说点实在的。C学习的第二阶段本质上是“从会写语法”到“会查问题”的转变。环境配置、语法细节、算法模板、崩溃调试这四条线你可以按自己的节奏推进但始终要记得代码不是背下来的是查出来的、调出来的。你每遇到一个报错并且自己解决掉能力就比昨天涨一截。下一次再看到百度热搜里又冒出一条C报错词你不妨试着先去搜一搜原因而不是急着复制别人的答案——因为总有一天你要在没有热搜可搜的问题面前独立判断该怎么往下走。
返回列表