简介:《剑指Offer》C++源代码包面向准备技术面试、希望系统刷题并提升算法实现能力的C++学习者,围绕书中高频面试题给出可直接编译运行的参考实现。压缩包共2098个文件、约44.2MB,以cpp与h源码为主体,配套vcproj/vcxproj工程、sln解决方案、txt说明及exe/obj/pdb等编译产物,便于对照工程配置与调试过程。内容覆盖数组、链表、树、栈队列等基础结构,也涉及动态规划与递归、字符串匹配、排序查找、设计模式、STL容器与智能指针、内存管理、面向对象、异常处理和模板泛型等知识点,并含多项目源码与类图组织。已有377人学习,适合按题查阅思路、动手调试代码、复盘边界条件与性能取舍,也可作为面试前的算法与C++特性复习材料。
1. 面试前一天翻出剑指 Offer 源码,我用它补了三个漏洞
去年秋招季,一个学弟在面试前一晚把一摞 C++ 题解翻出来重刷,刷到反转链表那题,在纸上写得很顺,真上手敲的时候还是把cur->next先覆盖、再去找后继,结果指针直接断链。这个场景和「剑指 Offer C++ 题解源码」这个仓库的定位完全吻合——它不是一个只放答案的题库,而是一套能编译、能跑、每道题都带完整struct定义和输入构造的 C++ 代码集合,覆盖数组、链表、二叉树、栈队列、动态规划、位运算几个大类。适合两类人:一类是正刷题准备面试、想拿一份能直接跑通的参考实现对照的开发者;另一类是想把这套代码改造成自己刷题台账,加断言、加对拍、加性能计时的人。它解决的核心问题很朴素:让每道题的解法在一个真实可编译的工程里活起来,而不是散在几十个笔记文件里。
2. 仓库结构与技术选型:题解为什么该按数据结构分目录
拿到一份题解源码,第一眼要看的不是代码写得多花,而是目录怎么切。按题号 1、2、3 平铺是最省事的做法,也是最容易烂尾的做法,第三周就找不到东西了。这份 C++ 版的剑指 Offer 源码采用的是「按数据结构 + 算法范式分目录」的方式,把题目的共性抽到include/下做基础设施,源码按章节落盘,测试单独一层。这个结构不是纯审美问题,它直接决定了你后面能不能批量跑回归。
2.1 目录分层:include / src / tests 三段式
常见的目录切法大致长这样,具体文件名会随仓库略有差异,但三段式的骨架基本一致:
offer-cpp/ ├── CMakeLists.txt # 统一构建入口 ├── include/ │ ├── list_node.h # ListNode 定义 + 建表/打印/销毁工具 │ ├── tree_node.h # TreeNode 定义 + 层序建树/释放 │ └── common.h # 计时、随机数、断言宏 ├── src/ │ ├── ch02_array/ # 数组与字符串 │ ├── ch03_list/ # 链表 │ ├── ch04_tree/ # 二叉树 │ ├── ch05_stack_queue/ # 栈、队列、单调栈 │ ├── ch06_dp/ # 动态规划与贪心 │ └── ch07_bit/ # 位运算与数学 ├── tests/ │ ├── test_list.cpp │ ├── test_tree.cpp │ └── CMakeLists.txt └── README.mdinclude/放节点定义是有讲究的。链表和二叉树这两类题,几乎每道题都要用到ListNode、TreeNode,如果每个.cpp里各写一遍结构体,后面你想给节点加一个id字段做调试追踪,就得改几十个文件。抽到公共头文件里,改一处全局生效。src/按章节分目录,好处是复习某一类题的时候可以只编译那个目录,file(GLOB src/ch03_list/*.cpp)就能单独出一个可执行文件,迭代速度快。
tests/单独一层,是因为题解源码最容易出问题的地方不在解法,而在输入构造。链表题的输入怎么从vector<int>变成一条链表、二叉树题怎么从一个层序数组建出一棵树、建完怎么释放,这些样板代码一旦散落在各处,就会到处是内存泄漏。放在测试层统一封装,主逻辑只留算法本身。
注意:如果
include/里放了using namespace std;,别放。头文件被到处 include,命名空间污染会传遍整个工程,后面你自己加一个count变量都可能跟std::count撞。
每个源文件的组织方式,我一般推荐「一题一函数,签名贴近原题」:
// src/ch03_list/reverse_list.cpp #include "list_node.h" // 输入:链表头指针;输出:反转后的新头指针 // 空链表或单节点直接返回原头,不需要特殊分支处理 ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* cur = head; while (cur != nullptr) { ListNode* nxt = cur->next; // 先把后继存下来,否则改完 next 就找不到了 cur->next = prev; // 当前节点反向指向前驱 prev = cur; // 前驱前移 cur = nxt; // 当前指针前移 } return prev; // 循环结束时 cur 为空,prev 指向原尾节点,即新头 }这段代码三个参数的含义值得单独说清楚:prev始终指向「已经反转完成那一段的头」,初始为nullptr;cur是「还没处理那一段的头」;nxt是一个纯临时变量,只在循环体内部活一轮。整个算法的关键动作是那行cur->next = prev,它一旦执行,原来的链就断了,所以nxt必须在它之前取。翻车点几乎全在这里:把取后继和改指针的顺序写反,跑出来的结果就是只剩一个节点,后面全丢,而且不报错。这种题在笔试题里失分最冤,因为你逻辑是对的,只是语句顺序错了。
2.2 构建方式:单文件 g++ 与 CMake 两条路
这份源码的编译门槛很低,因为它不依赖任何第三方库,纯标准库就能跑。想快速验证一道题,直接单文件编译最快:
# 单文件编译,-I 指向公共头文件目录,-std=c++17 保证能用结构化绑定和 optional g++ -std=c++17 -Wall -Wextra -Iinclude src/ch03_list/reverse_list.cpp -o reverse_list # 带上 AddressSanitizer 跑一遍,能顺手抓出越界和内存泄漏 g++ -std=c++17 -g -fsanitize=address -Iinclude \ src/ch03_list/reverse_list.cpp tests/test_list.cpp -o test_list_asan ./test_list_asan-Wall -Wextra这两个开关别省。链表和二叉树题里最常见的 bug 是「有分支没有返回值」和「有符号无符号比较」,编译器开着警告能直接指到行号,比你自己盯着看快十倍。-fsanitize=address在后半程做回归的时候尤其值钱,题解代码里手写new建树、建链表的场景太多了,泄漏和重复释放都是静默发生的。
如果要把整个仓库当成一个工程来跑,走 CMake:
cmake_minimum_required(VERSION 3.16) project(offer_cpp CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra") # CONFIGURE_DEPENDS 保证新增 .cpp 后不用手动重新 cmake file(GLOB_RECURSE SRC_FILES CONFIGURE_DEPENDS src/*/*.cpp) add_executable(offer_run ${SRC_FILES}) target_include_directories(offer_run PRIVATE include) enable_testing() add_subdirectory(tests)对应的构建步骤:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j cd build && ctest --output-on-failureCMAKE_BUILD_TYPE=Debug是刷题阶段该用的配置,因为它关掉优化,gdb里单步走的时候变量值跟源码对得上;等你后面想测一下某个解法的实际耗时,再切到Release,否则-O2会把你的循环优化得面目全非,测出来的时间没参考价值。ctest --output-on-failure的作用是只打印失败的用例,几十个测试一起跑的时候输出才看得下去。
这套结构带来的直接收益是:你可以只改一个.cpp,重新cmake --build增量编译,几百毫秒出结果,而不是每次开一个在线判题页面粘贴代码。
3. 链表与二叉树模块拆解:指针题在源码里怎么写成模板
链表和二叉树是这份 C++ 题解里占比最大的两块,也是最容易在面试现场写崩的两块。源码里这两类题的写法有明显的套路痕迹:节点定义统一、边界处理统一、递归入口统一。拆开看,套路就三层。
3.1 链路节点与树节点的统一定义
节点定义是所有链表/树题的地基,写错一个构造函数,后面全是坑:
// include/list_node.h #pragma once #include <vector> #include <iostream> struct ListNode { int val; ListNode* next; explicit ListNode(int x) : val(x), next(nullptr) {} }; // 用数组建链表,返回头指针;空数组返回 nullptr inline ListNode* buildList(const std::vector<int>& data) { ListNode dummy(0); // 栈上哨兵,避免对 head 判空 ListNode* tail = &dummy; for (int v : data) { tail->next = new ListNode(v); tail = tail->next; } return dummy.next; // dummy 是栈对象,返回它后面的堆节点是安全的 } // 释放整条链表,防止 ASan 报泄漏 inline void freeList(ListNode* head) { while (head) { ListNode* nxt = head->next; delete head; head = nxt; } }explicit这个关键字在小项目里经常被忽略,但它能挡住ListNode n = 5;这种隐式转换,避免某些诡异的编译错误。buildList用栈上的dummy做哨兵,好处是省掉「第一个节点要单独处理」的分支——这个套路在合并链表、分割链表、删除倒数第 N 个节点里会反复出现,值得背下来。freeList里必须先存nxt再delete,这个顺序跟反转链表是同一个道理,delete之后那块内存就还回去了,再读head->next是未定义行为,ASan 会直接报 heap-use-after-free。
树节点同理:
// include/tree_node.h struct TreeNode { int val; TreeNode* left; TreeNode* right; explicit TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; // 按层序数组建树,'#' 或 INT_MIN 表示空位 inline TreeNode* buildTree(const std::vector<int>& layer) { if (layer.empty() || layer[0] == INT_MIN) return nullptr; TreeNode* root = new TreeNode(layer[0]); std::queue<TreeNode*> q; q.push(root); size_t i = 1; while (!q.empty() && i < layer.size()) { TreeNode* cur = q.front(); q.pop(); if (i < layer.size() && layer[i] != INT_MIN) { cur->left = new TreeNode(layer[i]); q.push(cur->left); } ++i; if (i < layer.size() && layer[i] != INT_MIN) { cur->right = new TreeNode(layer[i]); q.push(cur->right); } ++i; } return root; }层序建树这块最容易写错的是下标i的推进节奏:每弹出一个节点,要消费两个位置(左、右),即使某一侧是空位也要占掉一个下标。不少人写成「只有非空才++i」,建出来的树结构就整体错位了,而算法本身是对的,排查起来会怀疑人生。用INT_MIN而不是-1当空位标记,是为了避免跟真实节点值冲突,题解里节点值范围通常覆盖负数。
3.2 反转链表与合并链表:哨兵节点与三指针
反转链表在 3.1 之外的变体是「反转从 m 到 n 的区间」,源码里通常用一个dummy挂在头上,把「m 等于 1 时头节点会变」这个特殊情况吃掉:
// 反转 [left, right] 区间(1-based),返回新头 ListNode* reverseBetween(ListNode* head, int left, int right) { ListNode dummy(0); dummy.next = head; ListNode* pre = &dummy; for (int i = 1; i < left; ++i) pre = pre->next; // pre 停在区间前一个节点 ListNode* cur = pre->next; for (int i = 0; i < right - left; ++i) { ListNode* nxt = cur->next; // 待搬运节点 cur->next = nxt->next; // 从原位置摘掉 nxt->next = pre->next; // 头插到区间最前 pre->next = nxt; } return dummy.next; }这段的核心是「头插法」:pre不动,cur也不动,每次把cur后面那个节点搬到pre后面。参数left、right是闭区间且从 1 开始,循环次数是right - left,第一轮搬的是第left+1个节点。这个写法比「先切断、再整体反转、再接回去」少很多指针操作,也不容易漏掉尾部的连接。
合并两个有序链表是另一个高频题,源码里几乎都用哨兵:
ListNode* mergeTwoLists(ListNode* a, ListNode* b) { ListNode dummy(0); ListNode* tail = &dummy; while (a && b) { if (a->val <= b->val) { tail->next = a; a = a->next; } else { tail->next = b; b = b->next; } tail = tail->next; } tail->next = a ? a : b; // 剩下的整段直接挂上,不用再循环 return dummy.next; }tail->next = a ? a : b这一行是很多人想不到的省事写法:因为两条链表各自有序,剩下那段不需要再逐个比较,整段接上就行。写成while (a) { tail->next = a; ... }虽然也对,但多了一层循环,面试官会追问「还能不能更简单」,答不上来反而扣分。注意这里<=用的是小于等于而不是小于,如果题目要求「相等时保持原相对顺序」,这个等号就是稳定性保证。
3.3 二叉树遍历与重建:递归边界的写法
二叉树题在源码里有两套模板,一套是「递归返回节点」,一套是「递归改引用参数」。重建二叉树是前者的典型:
TreeNode* buildCore(const std::vector<int>& pre, int pl, int pr, const std::vector<int>& in, int il, int ir, const std::unordered_map<int, int>& pos) { if (pl > pr) return nullptr; // 空区间,递归出口 int rootVal = pre[pl]; int k = pos.at(rootVal); // 根在中序里的位置,O(1) 查到 int leftSize = k - il; // 左子树节点个数 TreeNode* root = new TreeNode(rootVal); root->left = buildCore(pre, pl + 1, pl + leftSize, in, il, k - 1, pos); root->right = buildCore(pre, pl + leftSize + 1, pr, in, k + 1, ir, pos); return root; } TreeNode* rebuildTree(const std::vector<int>& pre, const std::vector<int>& in) { std::unordered_map<int, int> pos; for (int i = 0; i < (int)in.size(); ++i) pos[in[i]] = i; // 预处理,避免每次线性找 return buildCore(pre, 0, pre.size() - 1, in, 0, in.size() - 1, pos); }六个下标参数看着头大,但含义很清晰:pl/pr是当前子树在先序里的闭区间,il/ir是同一棵子树在中序里的闭区间,pos是根节点值的下标映射。递归出口pl > pr用大于而不是等于,因为叶子节点的区间是pl == pr,此时还要建一个节点;只有区间空了才返回nullptr。这一条边界写错,典型表现是段错误或者多建出一堆空节点。
pos这张哈希表是性能关键。不预处理的话,每层递归都要在中序数组里线性扫一遍找根,O(n log n)的树退化成O(n²),栈深的题直接超时。用unordered_map而不是map,因为这里只需要查得快、不需要有序,map的红黑树在十万级节点上会明显拖慢。
提示:
pos.at(rootVal)和pos[rootVal]的区别在于前者查不到会抛异常,后者会静默插入一个0。输入非法(前序和中序不匹配)的时候,用at能第一时间暴露问题,别图省事用下标。
另一套「递归改引用」的模板用在「求深度」「判断平衡」「路径和」这类题上:
// 自底向上求树高,同时判断是否平衡;返回 -1 表示已失衡 int checkHeight(TreeNode* root) { if (!root) return 0; int lh = checkHeight(root->left); if (lh == -1) return -1; // 左子树已失衡,提前剪枝 int rh = checkHeight(root->right); if (rh == -1) return -1; if (std::abs(lh - rh) > 1) return -1; return std::max(lh, rh) + 1; }这套写法的价值在于「一次遍历」:每个节点只算一次高度,O(n);如果写成「外层每层调一次求高函数」,就是O(n²),树链化的时候直接卡死。提前返回-1做剪枝这一步,新手经常忘,忘掉之后结果还是对的,但慢很多,属于不影响正确性、只影响性能的隐性坑。
4. 高频算法模块:动态规划、双指针与位运算的源码落地
数据结构类题目偏「怎么写」,算法类题目偏「怎么想」。这份 C++ 题解里动态规划、双指针、位运算这三块占了相当篇幅,它们的共性是状态定义和边界条件比具体实现更值钱。下面就按「状态怎么定、边界怎么写、优化怎么做」拆。
4.1 动态规划:剪绳子与连续子数组最大和的状态定义
剪绳子(整数拆分)是典型的「状态转移一眼看出、边界却容易写错」的题:
// 长度为 n 的绳子切成若干段(至少两段),求各段乘积最大值 int cuttingRope(int n) { if (n == 2) return 1; if (n == 3) return 2; // dp[i] 表示长度 i 的绳子「拆或不拆」能得到的最大乘积 std::vector<long long> dp(n + 1, 0); dp[1] = 1; dp[2] = 2; // 长度 2 的段不拆时乘积是 2,比拆成 1*1 更优 dp[3] = 3; // 长度 3 的段同理,这是「作为子段」的价值,不是题目答案 for (int i = 4; i <= n; ++i) { for (int j = 1; j <= i / 2; ++j) { dp[i] = std::max(dp[i], dp[j] * dp[i - j]); } } return static_cast<int>(dp[n]); }这段代码最反直觉的地方是dp[2] = 2和dp[3] = 3:题面问长度 2 时答案是 1,长度 3 时答案是 2,但dp数组里存的语义是「这一整段作为更大绳子的一个子段时的最大贡献」,所以 2 和 3 不拆更划算。如果按题面答案去初始化dp[2]=1, dp[3]=2,n=4时会得到 3 而不是 4(4 应该拆成 2+2)。这个语义分层是动态规划里最常见的认知错位,源码里一般会在注释里标清楚。
内层循环j <= i / 2是优化:拆分有对称性,1×(i-1)和(i-1)×1结果一样,只枚举一半就够,时间减半。long long是为了防溢出,题解里如果注明结果要取模,那就改成% 1000000007,并且dp用long long存中间值更稳妥。
另一类是「连续子数组最大和」,状态定义走的是另一条路:
int maxSubArray(const std::vector<int>& nums) { long long cur = nums[0]; // 以 i 结尾的最大和 long long best = nums[0]; // 全局最大 for (size_t i = 1; i < nums.size(); ++i) { cur = std::max((long long)nums[i], cur + nums[i]); // 要么接着算,要么从当前重开 best = std::max(best, cur); } return (int)best; }状态只有一维,转移方程cur = max(nums[i], cur + nums[i])表达的意思是「前面那段如果是负贡献,不如丢掉从当前元素重新开始」。best必须在每轮更新,不能只在最后取cur,因为最大值可能出现在中间某一段。用long long存中间值的原因是:当数组长度上万、元素都接近INT_MAX时,累加会溢出成负数,max直接选错分支,结果错得毫无规律。
4.2 双指针与归并:数组中的逆序对
「数组中的逆序对」是这份源码里少数需要写长函数的题,它把归并排序和计数揉在一起:
long long mergeCount(std::vector<int>& a, std::vector<int>& tmp, int l, int r) { if (l >= r) return 0; int mid = l + (r - l) / 2; // 防溢出的 midpoint 写法 long long cnt = mergeCount(a, tmp, l, mid) + mergeCount(a, tmp, mid + 1, r); int i = l, j = mid + 1, k = l; while (i <= mid && j <= r) { if (a[i] <= a[j]) { tmp[k++] = a[i++]; } else { tmp[k++] = a[j++]; cnt += mid - i + 1; // 左半段从 i 到 mid 全都比 a[j] 大 } } while (i <= mid) tmp[k++] = a[i++]; while (j <= r) tmp[k++] = a[j++]; for (int t = l; t <= r; ++t) a[t] = tmp[t]; // 回写,供上层继续用 return cnt; }关键逻辑是cnt += mid - i + 1这一行:当a[j]被取出时,说明左半段从i到mid的所有元素都比它大,而它们在原数组里又都排在a[j]前面,所以一次性能贡献mid - i + 1个逆序对。这个「批量计数」的思路是归并解逆序对的核心,理解了这一点就不用死记。
三个参数里tmp是复用的临时数组,在主函数里一次性分配好后逐层传引用,避免每层递归都new一个vector;l、r是闭区间。mid = l + (r - l) / 2而不是(l + r) / 2,是因为l + r在l、r都接近INT_MAX时会上溢成负数,取到的mid非法,递归直接崩。这个问题在真实工程里血泪经验不少,写二分的时候养成这个习惯比较稳。
注意:
a[i] <= a[j]这里必须带等号。如果写成a[i] < a[j],相等元素会被算成逆序对,答案偏大。这个等号属于「不影响排序正确性、只影响计数正确性」的细节,对拍的时候才能抓出来。
4.3 位运算:n & (n-1) 在二进制计数里的用法
位运算这块题量不大,但技巧性最强,n & (n-1)是绕不开的一个:
// 统计 32 位无符号整数二进制中 1 的个数 int hammingWeight(uint32_t n) { int cnt = 0; while (n) { n &= (n - 1); // 每次把最低位的 1 抹成 0 ++cnt; } return cnt; }n & (n-1)的效果是「消掉最低位的那个 1」,因为减一会把最低位的 1 变成 0、把它右边的 0 全变成 1,再和自己做与运算,那一位就没了。循环次数等于 1 的个数,最坏 32 次,比逐位右移的固定 32 次在稀疏场景下更快。参数用uint32_t而不是int,是因为负数在 C++ 里右移是算术右移,最高位补 1,如果换成逐位移位的写法处理负数会死循环;用无符号类型从根上绕开这个问题。
另一个常见变体是「判断一个数是不是 2 的整数次幂」:
bool isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; // 2 的幂只有一个 1,抹掉后必为 0 }n > 0这个前置条件必须有,因为0 & -1 == 0也成立,而 0 不是 2 的幂。这类题在源码里通常是单行函数,看着简单,但边界条件漏一个就错。
位运算在工程里还有一个实用场景:用位掩码表示状态集合。题解里有几道「状态压缩」的题就是这么写的,用一个int的第i位表示「第 i 个元素被选中」,比vector<bool>省空间,也方便做集合运算(并集|、交集&、差集& ~)。理解了这一点,回头看那些「用位运算替代数组」的解法就不觉得是在炫技了。
5. 踩坑排查:从编译报错到答案对不上的 5 个高频问题
代码跑不起来或者跑出来结果不对,是最耗时间的阶段。下面这五条是拆这份 C++ 题解源码时反复出现的,每条按「现象 → 原因 → 解决」记,遇到问题可以对着查。
5.1 链表输出只剩一个节点,或者直接段错误
现象:反转链表、合并链表这类题,跑出来的结果只有头节点,或者程序在cur = cur->next那行崩掉。
原因:改指针和取后继的顺序错了。cur->next = prev执行完之后,原来的链就断了,如果这时才去取cur->next,拿到的是prev,循环立刻进入死循环或提前结束;如果是空指针被解引用,就是段错误。另一个常见原因是buildList返回后忘了判空,直接访问head->next。
解决:写死这个顺序——先ListNode* nxt = cur->next;,再改cur->next,最后cur = nxt;。另外所有对外部输入的指针,第一次使用前先判空;可以在buildList里assert一下返回值和data.size()是否匹配,早失败比晚失败好排查。
5.2 二叉树题递归爆栈,或者建出来的树结构整体错位
现象:树深一点的用例直接Segmentation fault,gdb 栈回溯几百层;或者层序建树之后,遍历出来的结构跟预期对不上,但算法逻辑看着没错。
原因:爆栈通常是递归没有终止条件,或者终止条件写成了if (!root->left && !root->right)只处理叶子,非叶子的空指针没兜住。结构错位则是层序建树时下标i只在节点非空时才自增,导致后续节点全部串位。
解决:递归函数第一行永远是if (!root) return ...;。层序建树用固定节奏消费数组:每弹一个节点,无论左右是否为空都各占一个数组下标,非空才new并入队。写完拿一棵三层满二叉树手推一遍下标序列,能立刻发现错位。
5.3 大用例答案变成负数或者明显偏小
现象:小数据全对,长度上万的用例结果变成负数,或者比暴力解小一大截。
原因:int溢出。动态规划里两数相乘、前缀和累加、(l + r) / 2求中点,都可能在中间步骤超出int范围。max比较时溢出成负数会被判成更小的值,选错分支,而且编译器不会报错。
解决:凡是有累加、累乘、求和的中间变量,一律用long long,最后再按题目要求取模或转回int。二分的中点用l + (r - l) / 2。这两个习惯我现在的默认写法就是这样,不需要每次现场判断会不会溢出。
5.4 多个测试用例连着跑,第二个开始结果就不对
现象:单独跑某个用例正常,批量跑的时候从第二个用例起结果开始漂移。
原因:全局或静态变量没重置。题解代码里常见static int count = 0;这类跨调用保留状态的写法,单测里无所谓,一旦被测试框架循环调用,计数就累加了。类似的还有vector作为输出参数传入时忘记clear(),上一次的结果留在里面。
解决:状态尽量做成函数内局部变量,确需跨调用保留的(比如记忆化搜索的缓存),在入口函数里显式重置。输出参数型的vector&,函数开头第一句就res.clear(),别指望调用方帮你清。
5.5 复制粘贴的代码编译报错,错误信息指向奇怪的位置
现象:从网页上抄下来的题解,编译报stray '\357'、invalid character或者某个标识符未定义,但你肉眼看着代码完全正常。
原因:全角字符混进了代码里。中文标点里的全角逗号、全角括号、全角分号,在编辑器里和半角长得几乎一样;箭头→、乘号×、省略号…这些非 ASCII 字符也会混进来。另一种是 Windows 换行符\r\n在部分环境下导致的意外。
解决:编译前先扫一遍源文件,grep -P '[^\x00-\x7F]' src/能揪出所有非 ASCII 字符,除了注释和字符串字面量里的中文,其他位置全部该清掉。编辑器统一设成 UTF-8 无 BOM,换行统一 LF。这条看着低级,但抄题解的场景下命中率真的不低。
6. 二次开发:把题解仓库改造成自己的算法训练台
仓库能跑通之后,剩下的事情就是让它在你自己手上变得更有用。原样刷一遍题的价值有限,真正拉开差距的是两件事:一是让每次改动都有自动化验证兜底,二是让同一道题的不同解法能互相对拍。
第一步,加一个极简测试宏,把「跑一下看看」变成「跑一百个用例」。不需要引入第三方测试框架,二十行就够:
// include/common.h #pragma once #include <iostream> #include <chrono> static int g_failed = 0; #define CHECK(expr) do { \ if (!(expr)) { \ std::cerr << __FILE__ << ":" << __LINE__ \ << " failed: " #expr "\n"; \ ++g_failed; \ } \ } while (0) // 计时器:作用域结束时打印耗时,适合粗粒度比较解法 struct ScopedTimer { std::string name; std::chrono::steady_clock::time_point t0; explicit ScopedTimer(std::string n) : name(std::move(n)), t0(std::chrono::steady_clock::now()) {} ~ScopedTimer() { auto ms = std::chrono::duration_cast<std::chrono::microseconds>( std::chrono::steady_clock::now() - t0).count(); std::cerr << name << " cost " << ms << " us\n"; } };CHECK用do { } while (0)包住是宏的标准写法,保证它在if/else里当单条语句用时不会断掉分支结构。#expr会把失败的表达式原样打出来,比只打印「failed」有用得多。ScopedTimer利用析构函数在作用域结束时输出耗时,用起来就是{ ScopedTimer t("mergeCount"); ... },不用手动掐表。
第二步,给每道题写暴力解做对拍。递归、动态规划这类解法最怕的是「大部分用例对、边界错」,人眼看不出来,随机对拍能抓出来:
// 以反转链表为例:暴力解直接收集值再倒着重建,跟原解法比对 void fuzzReverseList(int round) { std::mt19937 rng(20240101); for (int r = 0; r < round; ++r) { int n = rng() % 50; std::vector<int> data(n); for (int& v : data) v = (int)(rng() % 200) - 100; ListNode* h1 = buildList(data); ListNode* h2 = buildList(data); ListNode* got = reverseList(h1); ListNode* want = reverseList(h2); std::vector<int> g, w; for (ListNode* p = got; p; p = p->next) g.push_back(p->val); for (ListNode* p = want; p; p = p->next) w.push_back(p->val); CHECK(g == w); freeList(got); freeList(want); } }这里的「暴力解」其实是拿同一份实现跑两遍,真正的对拍应该换成另一种思路的版本(比如用栈或递归实现),这样两份独立实现互相印证才有意义。std::mt19937固定种子保证每次复现的是同一批用例,出问题能定位;排查完之后再换随机种子扩大覆盖。round参数控制用例数量,日常回归跑 200 轮,提交前跑 2000 轮。
第三步,把 Sanitizer 挂进 CMake 的可选开关,别每次手动敲长命令:
option(ENABLE_SANITIZER "build with ASan + UBSan" OFF) if (ENABLE_SANITIZER) add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g) add_link_options(-fsanitize=address,undefined) endif()打开方式就是cmake -S . -B build-asan -DENABLE_SANITIZER=ON,跟普通构建完全隔离,不会污染日常的 Debug 目录。-fno-omit-frame-pointer是为了让 ASan 的栈回溯打得完整,省略了它报错信息里只有一串地址,看不出调用链。
第四步,把同一道题的多种解法放成一个文件里,用ScopedTimer对比。比如「求第 n 个斐波那契数」,朴素递归、带记忆化、矩阵快速幂三种写法的差距在n = 40附近就能拉开量级,跑一次比看十篇分析印象深刻。这也顺便解决了「这题到底几种解法」的困惑——文件里摆着几份实现,一目了然。
具体的改造节奏,我自己的习惯是:新加一道题,先写暴力解和CHECK用例让它变绿,再写优化解,两者对拍跑通,最后挂 Sanitizer 跑一轮确认没有内存问题,四步都过了才认为这题算做完。从那以后我每次往仓库里加题,都强制走一遍「暴力解 → 对拍 → ASan → 计时」这条流水线,宁可开头慢一点,也不愿意在面试前夜才发现某个边界一直算错。希望这套改造思路能帮到你。
本文还有配套的精品资源,点击获取