简介:如果正为互联网公司技术面试做准备,《剑指Offer》C++源代码包是一份可直接落地的复习素材,它把书中数组、链表、二叉树、动态规划、字符串处理、排序与搜索等高频题目做成了工程化的C++实现。压缩包共2098个文件,大小44.2MB,以242个cpp实现文件和226个h头文件为核心,配合71套vcxproj/filters工程配置、110个pdb调试符号、54个exe可执行程序和大量tlog构建日志,能够完整体现Visual Studio下的项目组织、编译链接与调试过程。在CSDN上已有373人学习下载,内容实用度可见一斑。学习时,可以按.cpp与.h的接口分离结构逐个题目拆解,关注每个解题思路如何落地为代码,例如动态规划的转移方程怎么写、递归怎样避免栈溢出、STL容器在何种场景下更高效;亲手改写这些源码并观察运行结果,不仅有助于记忆算法模板,还能增强内存管理、面向对象封装和异常处理的实战能力,最终在面试手写代码时更加从容。
1. 剑指Offer的C++源代码:一份值得反复拆的算法底稿
去年准备C++岗位面试时,我把LeetCode刷完两遍,真到白板手写还是卡壳。后来朋友把他的《剑指Offer》C++源代码包发我,我按题号一个文件一个文件重新抄、编译、改错,才真正把算法题从“看过”变成“会写”。这份源代码好在两点:一是题目按面试场景组织,覆盖链表、二叉树、字符串、动态规划等高频考点;二是代码风格收敛,不炫技、不堆STL,刚好能看清每个解法在C++里的完整边界。它适合准备C++岗面试、想把高频题练成肌肉记忆的求职者,也适合刚学完语法、不知道指针、引用和容器怎么落进真实题目的入门者。这篇文章我先把跑通环境的路径讲透,再给一套我验证过的读码和二刷方法,最后是五条血泪踩坑记录。
2. 跑通源码的完整路径:目录结构、编译环境与第一个用例
拿到源代码包先别急着从头到尾读代码。我一般做三件事:列目录、选环境、跑通第一个用例。环境不痛快,看什么代码都像bug,这一步省不了。
2.1 先看目录结构:按题号组织,适合逐题过
常见的剑指Offer源码包是按题号组织目录的,每个题目一个文件夹,命名类似03_DuplicateNumber,里面是一个或多个.cpp文件,有的带main()做自测。先列一遍目录,能快速知道覆盖了多少题,也能确认题号和书里章节的对应关系,避免后面想查某道题时到处翻。
# 在源码根目录下执行,列出所有题目文件夹 ls -d */ | head -30这一步我通常花五分钟,目的不是记住每个目录名,而是确认三件事:有没有配套的头文件目录、有没有统一的main.cpp组织方式、有没有提供测试数据。这些信息决定后面编译时用单文件编译还是多文件联编。如果看到common/或include/这类公共目录,说明多个题目共用工具函数,编译时要一起带上。
2.2 选定编译环境:Visual Studio、VSCode与命令行怎么选
不同题目的代码风格不一样,有的只依赖标准库,有的用了Windows API,所以环境选择要看你的主要场景。我按使用频率排了个表,新手和熟手可以直接照着选。
| 环境 | 编译器 | 适合场景 | 常见坑 |
|---|---|---|---|
| Visual Studio | MSVC | Windows下断点调试最顺手 | 老项目缺少运行库,fopen等函数报安全警告 |
| VSCode + MinGW | g++ | 轻量、配置一次到处跑 | tasks.json和launch.json容易配乱,跳转失效 |
| 纯命令行 | g++ | 快速验证、模拟OJ环境 | 无调试器,崩了只能加日志定位 |
我自己的习惯是VSCode配MinGW为主、Visual Studio为辅。VSCode配置C/C++环境时,先装C/C++扩展,再把MinGW的bin目录加进系统PATH,然后建.vscode/tasks.json:
{ "version": "2.0.0", "tasks": [ { "label": "cpp-build", "type": "shell", "command": "g++", "args": ["-g", "-std=c++11", "${file}", "-o", "${fileDirname}/${fileBasenameNoExtension}.exe"], "group": "build" } ] }参数说明:-g生成调试信息,没有它断点全部失效;-std=c++11指定标准,源码里如果用了auto、shared_ptr这类特性,老编译器默认标准可能不支持;${file}是当前打开的源文件,${fileBasenameNoExtension}是去掉扩展名的文件名,避免每次手动改输出名。配好后按Ctrl+Shift+B就能编译,而不是每次敲一长串命令。
2.3 第一个用例:数组中重复的数字
我习惯把面试题3“数组中重复的数字”作为第一个跑通的用例。它短、依赖少,而且考察点典型——交换法、输入校验、指针输出参数。下面这份是常见的C++实现,也是我照着源码包手动敲过一遍的版本:
// 面试题3:数组中重复的数字 // 输入: numbers 数组, length 数组长度, duplication 输出参数 // 返回值: 找到重复返回 true, 否则返回 false bool duplicate(int numbers[], int length, int* duplication) { if (numbers == nullptr || length <= 0) { return false; // 输入合法性检查 } for (int i = 0; i < length; ++i) { if (numbers[i] < 0 || numbers[i] > length - 1) { return false; // 题目要求数字范围是 0~n-1 } } for (int i = 0; i < length; ++i) { while (numbers[i] != i) { // 当前位置的值没放到正确位置 if (numbers[i] == numbers[numbers[i]]) { *duplication = numbers[i]; // 目标位置已有同值, 说明重复 return true; } // 交换 numbers[i] 和 numbers[numbers[i]] int temp = numbers[i]; numbers[i] = numbers[temp]; numbers[temp] = temp; } } return false; }逻辑说明:这道题利用了“数组长度为n且所有数字在0到n-1范围内”这个条件,把每个数字放到下标等于它的位置上。遍历时如果当前位置的值numbers[i]不等于下标i,就把它交换到目标位置。交换前先检查目标位置是否已经有同值,有就直接返回。整个过程每个元素最多交换两次,所以时间复杂度O(n)、空间O(1),比哈希表省内存。
参数说明:numbers是待查数组,length是数组长度,duplication是输出型指针参数——C++里函数返回值已经用来表示“是否找到重复”,结果只能通过指针或引用带出来,这也是面试官爱问的点:为什么不用返回值直接返回数字?因为返回值通道已经被占用,需要设计多个出口。这套代码跑通后,再对照源码包里的其他写法看差异,很容易注意到别人多做了哪些边界判断。
2.4 两个高频环境坑
先说VSCode下函数跳转失效。现象是Ctrl+点击函数名跳不到定义处,变量跳转也没反应。原因多数是C/C++扩展没有找到正确的编译器路径,或者打开了单文件而不是整个工作区。解决方式:在.vscode/c_cpp_properties.json里设置compilerPath为MinGW下的g++实际路径,然后重载窗口。如果还不行,清掉~/.cache下VSCode的扩展缓存再试。
Windows下Visual Studio里用fopen会报C4996安全警告,这是MSVC的主动拦截。原因不是代码写得有问题,而是VS推荐用带_s后缀的安全版本。解决方式两个,二选一:项目属性里加预处理器定义_CRT_SECURE_NO_WARNINGS,或者把代码改成fopen_s。g++环境下不存在这个问题,这也解释了为什么同一份源码在MinGW下编译顺滑、拿到VS里就报错——现象、原因、解决一条线说清楚,后面才不慌。
3. 把源码读透:从背答案到会推导,以链表题为例
很多人的读法是从第一题看到最后一题,看完合上书什么都不剩。我给一套自己的读法,核心是“先写、再对照、最后问为什么”。链表题是剑指Offer里最值得用这套方法读的类型,因为指针操作看得见摸得着,出错也最直观。
3.1 读法一:先自己写,再对照源码找差异
看到题目描述后,关掉源码文件,自己先在编辑器里写一版,哪怕编译不过也写。写完再打开源码对照,你会发现自己写的和官方写法差异集中在三处:边界条件(空指针、空数组)、循环条件(while还是if)、变量命名。这三处就是面试手写时的评分点。比如“合并两个排序链表”,很多人第一版漏掉l1 == nullptr和l2 == nullptr的收尾处理,源码里却用一行return l1 == nullptr ? l2 : l1;把尾部剩下一截接回去。这个diff过程比直接读十遍有效。
3.2 读法二:抓关键跳变点,以反转链表为例
反转链表是面试出现频率最高的题之一。源码包里的迭代版实现通常长这样:
// 面试题24:反转链表, 返回新链表头 struct ListNode { int m_nValue; ListNode* m_pNext; }; ListNode* ReverseList(ListNode* pHead) { ListNode* pReversedHead = nullptr; ListNode* pNode = pHead; ListNode* pPrev = nullptr; // 前驱节点, 反转时要用 while (pNode != nullptr) { ListNode* pNext = pNode->m_pNext; // 先存后继, 防止断链 if (pNext == nullptr) { pReversedHead = pNode; // 原链表的尾节点就是新头 } pNode->m_pNext = pPrev; // 当前节点的指针指向前驱 pPrev = pNode; pNode = pNext; } return pReversedHead; }逻辑说明:这个算法用三个指针在链表上走一遍,pNode是当前节点,pPrev是它前面的节点,pNext是它后面的节点。每次循环先保存pNext,因为下一步就要把pNode->m_pNext改指向pPrev,不先存就会断链;然后把pPrev和pNode整体往后移。pNext为空表示走到了原链表末尾,这个节点就是反转后的头节点,记进pReversedHead。
参数说明:pHead是原链表头指针,函数返回新链表头。注意这里全程操作的是指针,不是值传递——如果按值传入ListNode*,在函数里改pNode不会影响外部变量,所以返回值才带了新头出来。面试时这道题的常见翻车点是递归写法里base case写错,以及忘记处理空链表。用这套迭代法三指针走一遍最稳妥,因为它不依赖递归栈,边界条件也直白。
3.3 读法三:用C++特性反推考点
看源码时别只盯算法,还要看它为什么用这种C++写法。剑指Offer里很多题考点不在算法本身,而在语言机制上:树的题目到处是引用参数,因为要在函数里修改指针本身;字符串题反复在char*和string之间切换,考的是数组和指针的等价关系;容器题用stack、queue、unordered_map选型,考STL熟悉程度。经常有人抱怨“C++为什么没有普遍GC,手动内存管理太烦”,但正因为没有垃圾回收,new出来的节点由谁释放、何时释放才是每道链表题必须回答的问题。读代码看到delete不要跳过,那往往就是考点位置。
具体来说,读“重建二叉树”时,注意函数签名里的vector<int>& pre为什么用引用——因为每次递归要切分区间,如果用值传递,每层递归都复制整个数组,时间和空间直接翻倍。读“字符串排列”时,注意swap之后为什么要再swap一次,这是回溯法的状态还原。把这类语言细节单独标出来,你会发现源码里每处“多余动作”都在提醒C++和Java不一样的地方。
4. 二刷的取舍:哪些题值得手写,哪些题值得改写
一刷是把所有题过一遍,二刷必须做减法。源码包里六十多道题不是每题都值得花同样时间,我的分组标准是看面试时这道题考的是“手写能力”还是“容器选型”。
| 档位 | 典型题目 | 练习方式 | 理由 |
|---|---|---|---|
| 手写档 | 反转链表、二叉树遍历、快速幂、二分查找、单调栈 | 白板手写,不查资料 | 面试官一看就知道你基础扎不扎实 |
| STL档 | 两个栈实现队列、哈希计数类、TopK | 用容器实现,重点说选型理由 | 考的是工程能力,不是背容器源码 |
| 模板档 | 冒泡排序、直接插入排序 | 能默写即可 | 考察频率低,优先级靠后 |
4.1 手写档里最值得练的三类
第一类是链表和二叉树操作,这类题一旦写错就全盘崩,没有中间状态。第二类是快速幂这类带二进制技巧的题,代码短但边界多。第三类是单调栈类的“下一个更大元素”系列,逻辑隐蔽,很多人现场推不出来。以快速幂为例,剑指Offer面试题16“数值的整数次方”的常见实现是:
// 面试题16:数值的整数次方, 返回 base 的 exponent 次幂 double Power(double base, int exponent) { if (exponent == 0) { return 1.0; } if (exponent < 0) { base = 1.0 / base; // 负数次幂先转成正数次幂 exponent = -exponent; } double result = 1.0; while (exponent) { if (exponent & 1) { // 当前二进制位为1, 累乘当前底数 result *= base; } base *= base; // 底数自乘, 对应二进制下一位 exponent >>= 1; // 右移, 继续处理下一位 } return result; }逻辑说明:快速幂的核心是把指数看成二进制数。从低位到高位逐位处理,每一位代表“要不要乘此时底数的一次幂”。exponent & 1判断最低位是否为1,是就乘入结果;base *= base让底数跟随位权平方增长;exponent >>= 1右移处理下一位。时间复杂度从O(n)降到O(log n)。
参数说明:base是底数,exponent是指数。这段代码对负数指数做了处理,但有个前提:base不能为0,否则1.0 / base直接除零。面试时要在返回值上再补一个valid输出参数表示是否计算有效,或者调用前先检查。这也是剑指Offer代码里常见的“数字有效性”考点——算法本身只是一半,另一半是边界条件。我二刷时会把这道题手写三遍,每遍要求自己在10分钟内完成且不查任何资料。
4.2 STL档改写的实际收益
有些题源码里用C风格数组模拟栈和队列,这是为了让思路不受STL干扰。但二刷时我会要求自己用std::stack和std::queue改写一遍,因为工程上几乎不会手写栈。拿“用两个栈实现队列”来说,C风格版本要自己管理栈顶指针,改写后头文件减少、逻辑更清晰:
// 两个栈实现队列的 C++ STL 改写版 class QueueWithTwoStacks { public: void push(int value) { stackIn.push(value); // 入队只压入 stackIn } int pop() { if (stackOut.empty()) { // 输出栈为空时, 把输入栈全部倒过来 while (!stackIn.empty()) { stackOut.push(stackIn.top()); stackIn.pop(); } } int top = stackOut.top(); stackOut.pop(); return top; } private: std::stack<int> stackIn; std::stack<int> stackOut; };逻辑说明:入队操作永远发生在stackIn,出队时如果stackOut空了,就把stackIn的元素全倒进stackOut。因为栈是后进先出,倒过去之后原先进来的元素就到了栈顶,正好变成队列的先入先出顺序。这个“倒一次,后面连续出队都是O(1)”的设计,是这道题真正的考点。
参数说明:push的value是入队元素,pop的返回值是队头元素。注意pop里先判stackOut是否为空,再决定要不要倒数据,不能省。这个顺序写反了,会出现输出栈还有残留时被新数据覆盖的情况。改写这种题,对比STL版和手写版,你会更清楚top()和pop()的边界语义——很多新手栽在top不检查就直接取。
4.3 二刷的节奏安排
我二刷是按“40行内能否复现”来分组的。有些题比如快速幂不到30行,必须闭眼写;有些题比如“树的子结构”要60行以上,重点记主流程就好,不必背细节。每天的安排是两道手写档加一道STL档,手写档限时20分钟,STL档限时10分钟,写完对照源码标出差异点。这个节奏持续两周,基本能把高频题的手感稳定下来。
5. C++实现避坑实录:内存、空指针与STL边界
源码包里的代码能编译能跑,但你自己照着写一遍时,坑一个都不会少。下面五条是我实际踩过的,每条都按现象、原因、解决三步写清楚。
5.1 返回局部变量地址:为什么本地测试过了,合入就崩
现象:写了类似char* toString()的函数,内部用char str[100]存结果后直接返回str,单测时输出正常,放到完整工程里偶发乱码和崩溃。
原因:char str[100]是栈上局部数组,函数返回时栈空间被回收,指针指向的内存内容随时可能被后续调用覆盖。单测时恰好没有其他函数立刻复用这块栈,所以看起来“正常”。
解决:两种改法,一是返回std::string让对象管理内存,二是用new char[100]分配堆内存并把释放责任交给调用方。剑指Offer源码里常见的是后者,因为题目要求C风格接口;但自己写工程时建议优先std::string,少一份内存泄漏风险。从那以后我但凡看到函数返回数组名或局部变量地址,一律停下来问一句:这块内存是谁的。
5.2 空指针检查的顺序:先判外还是先判内
现象:代码明明写了空指针判断,但运行到pHead->m_pNext还是崩溃,报错指向那行判断语句。
原因:判断写成了if (pHead->m_pNext != nullptr && pHead != nullptr),先解引用了指针,再检查它本身。C++对&&的短路求值只能保护左边成立后才求右边,保护不了左边已经越界。
解决:把空指针检查放在最前面,写成if (pHead == nullptr || pHead->m_pNext == nullptr)。因为||同样有短路规则,左边为真时右边不会再执行。这个顺序问题在链表题里几乎每题都会遇到,源码里看那些先判空再取成员的写法,不是风格偏好,是必要的防崩逻辑。
5.3 vector边遍历边erase:迭代器失效问题
现象:用for (auto it = v.begin(); it != v.end(); ++it)遍历时调用erase(it),程序要么越界崩溃,要么输出的删除结果不对。
原因:std::vector的erase会让被删元素之后的所有迭代器失效,循环里继续用原来的it是未定义行为。很多人以为erase后it会自动指向下一个元素——它不会。
解决:利用erase的返回值,写法是it = v.erase(it);,返回的是被删元素的下一个位置,然后再继续循环。如果是删除满足条件的所有元素,更推荐std::remove_if配合erase的两步方案,先统一搬到末尾再批量删,避免循环里反复移动元素。
5.4 数组越界:本地不出错、OJ却超时
现象:题解在本地编译运行都正常,换到OJ上要么超时要么结果错,调试半天找不到逻辑问题。
原因:数组访问越界是C++里最隐蔽的坑,本地没崩只是因为越界踩到的内存恰好没被使用,OJ的内存布局更紧或者启用了地址检测,才暴露出来。常见越界点是从1开始的下标习惯导致length位置写成length而不是length-1,以及字符串数组忘了在末尾留\0的位置。
解决:把源码里所有数组访问和下标运算过一遍,重点查循环上界。我一般在代码入口加一段断言检查下标范围,调试期开着,提交时再关。另外字符串转数组操作,strlen返回的长度不包含\0,拷贝时要加1,这也是剑指Offer字符串题的高频考点。
5.5 string与char*之间切换:赋值容易、共享内存难
现象:把一个char*直接赋值给另一个char*变量,然后改了其中一个,发现另一个也变了。
原因:两个指针指向同一块内存,复制指针不等于复制内容。这在C风格代码里是常态,但在C++里很容易被误以为是值拷贝。
解决:如果确认要拷贝内容,用strcpy或memcpy,明确目标缓冲区大小;如果只需要只读引用,保持指针赋值同时加const修饰,防止无意修改。源码包里的字符串题几乎都是在这两种模式之间切换,读的时候注意区分“指向共享”和“持有副本”,这两种语义差别就是面试官追问的扩展点。
6. 把源码变成肌肉记忆:限时手写与变体自测
源码读十遍不如限时写一遍。我最后阶段的练习方式很简单:把题号和对应解法做成一张表,每天随机抽三道,按面试标准限时手写。
| 题目难度 | 限时 | 允许查资料 |
|---|---|---|
| 反转链表、快速幂这类基础题 | 10分钟 | 不允许 |
| 二叉树路径、动态规划这类中档题 | 25分钟 | 允许查STL接口 |
| 复杂状态转换题 | 40分钟 | 允许查思路 |
限时不是目的,目的是逼出第一反应。写完之后必须做一件事:把代码里的while改成for、把递归改成迭代、把数组改成std::vector,看解法还成立吗。拿旋转数组的最小数字来说,原题条件是“递增数组的旋转”,如果输入改成[1, 0, 1, 1, 1]这种含重复值的数组,原来的二分逻辑可能会把区间缩错,这时要在中间值和两端值相等时退化成顺序遍历。把这类变体记进源码注释里,比另开一个笔记有效,因为再看代码时问题就在眼前。
手写多了以后我发现,真正让我不卡壳的其实不是背下代码,而是每道题都能说清三个问题:最坏情况下的复杂度是多少、如果输入规模小一档能不能换更简单的方法、如果改用STL容器那段手写代码还要不要。从那以后我每次刷题都强制走一遍“限时手写、变体自测、注释考点”的流程,源码包成了我随查随用的底稿而非背诵材料。这套方法不一定适合所有人,但如果你也经历过“看过全会、写完就废”的阶段,不妨照这个顺序再拆一遍这份剑指Offer的C++源代码,希望帮到你。
本文还有配套的精品资源,点击获取