剑指Offer源代码C++:工程化重写,把算法题变成面试手撕代码训练
简介《剑指Offer》C源代码包是一份面向技术面试准备者的算法题解实现合集主题与书中一一对应覆盖链表、树、栈与队列、数组、动态规划、递归、字符串匹配、经典排序搜索、设计模式以及C内存管理、智能指针和泛型编程等高频考点。7z压缩包共2098个文件整体大小44.2MB以242个cpp源文件和226个头文件为主体同时包含sln、vcxproj等工程配置、obj与pdb等编译产物以及少量说明文档方便读者直接打开工程编译调试。该资源已有373人浏览学习适合不同层级的C开发者系统刷题与查漏补缺也可作为面试前的快速复习索引。阅读时可将每个问题背后的解题思路与代码实现相互对照尤其关注边界条件、复杂度分析和优化细节通过实际运行与改写代码真正掌握这些算法而不只是记住题解。1. 剑指offer源代码C这不是抄一遍题而是把面试手撕代码变成工程习惯“剑指offer源代码C”这句话是做算法题的人最先搜的东西。搜到的结果往往是一份现成源码仓拖下来跑通然后就觉得自己会了。等面试官把输入换一种边界条件、把问法改一个角度照抄的代码立刻露馅。我的做法和大多数人不一样把这套题集当成一个迷你 C 工程来重写每道题拆成声明、实现、测试三件套链表、二叉树、位运算、双指针这些模式练到能徒手写出来。这个方向适合两类人——一类是校招或社招前需要突击手撕代码的另一类是能看懂 C 语法、一碰指针和内存就发怵的开发者。下面按我实际的练习路径拆开讲。2. 先把工程摊开目录结构、CMake 与一组编译选项直接开写题解之前先花半小时把工程搭好。很多人把一百多道题堆进一个 cpp 里结果每次调试都要靠注释去切换 main 入口改一个链表题要翻几屏才能找到对应结构体。这样练题时间全耗在找代码上。工程骨架的价值在于结构体定义在一处工具函数谁都能调测试入口统一在一个 main 里。后续每加一道题只需要写实现函数和测试用例不用再动构建脚本。2.1 目录怎么分每道题拆成「声明 实现 测试」三件套我一般按主题分目录而不是一题一个目录。原因是剑指 Offer 里的题会反复用同一个结构体——链表节点、二叉树节点、栈和队列集中声明比到处复制粘贴好维护。下面是一个我常用的目录结构offer-cpp/ ├── include/ │ ├── ListNode.h │ ├── TreeNode.h │ └── problems.h ├── src/ │ ├── list_problems.cpp │ ├── tree_problems.cpp │ └── array_problems.cpp ├── tests/ │ └── offer_test.cpp ├── CMakeLists.txt └── build/include 下放结构体声明和函数声明src 下按题目类型放实现tests 下放带 main 的测试入口。build目录是 CMake 生成的不提交到版本管理。这个结构对应一个习惯头文件里只放声明不放实现。结构体和函数声明在problems.h汇总实现文件按链表、树、数组拆分。这样做的好处是做题时如果发现一个工具函数比如打印链表、生成测试二叉树多道题都在用就把它提出来放进公共源文件而不是每题复制一份。等到题目刷到后半段这套工程本身就是你的 C 代码库。2.2 CMakeLists 从零搭一次编译全部题目的最小配置我不用 VS 的解决方案文件也不建 Makefile直接用 CMake。原因是 CMake 在 Windows 和 macOS、Linux 下行为一致换机器不用改配置面试前换环境也不慌。一个最小可用的 CMakeLists 长这样cmake_minimum_required(VERSION 3.10) project(offer_cpp CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) endif() set(CMAKE_CXX_FLAGS_DEBUG -g -O0 -Wall -Wextra) set(CMAKE_CXX_FLAGS_RELEASE -O2 -DNDEBUG) add_executable(offer_cpp src/list_problems.cpp src/tree_problems.cpp src/array_problems.cpp tests/offer_test.cpp ) target_include_directories(offer_cpp PRIVATE include) enable_testing() add_test(NAME offer_basic COMMAND offer_cpp)第一行是 CMake 最低版本3.10 足够覆盖多数新项目模板。project指定语言为 CXX避免 CMake 去做 C 编译器的探测。CMAKE_CXX_STANDARD 14是我刻意选的不是越新越好C11 到 C14 的语法差异在面试手撕场景里感知不明显而 C17 的结构化绑定在部分老编译器上还有兼容问题用 14 是最稳的中间值。CMAKE_CXX_FLAGS_DEBUG里的-O0是关键。调试链表和指针时如果开了-O2变量可能被优化到寄存器里调试器里看不到链表下一跳的值排查断链问题会非常难受。-Wall -Wextra会把类型转换、未初始化变量这类警告露出来这几条警告在面试时能帮你提前发现低级错误。2.3 编译选项与断言开关debug 不开优化release 再开 O2上面的配置里release 模式带了-DNDEBUG。这个宏和assert直接相关只要定义了 NDEBUG代码里的assert(condition)就全被预处理器摘掉不再生成检查语句。所以如果写完题没有做专门的边界验证在 release 模式下跑得很顺不代表逻辑对——断言早被删了。我一般这样用debug 模式写题assert放在每个函数的入口处检查空指针、数组长度、除零这类前提。确认无误后再切到 release 模式看优化后的运行时间和栈空间最后用 ctest 统一跑回归。构建和测试命令如下cmake -S . -B build cmake --build build ctest --test-dir build --output-on-failure-S . -B build指定源码目录为当前目录、构建目录为 build避免了老式cmake ..在目录间跳来跳去的习惯。ctest --output-on-failure会在测试失败时打印可读的错误输出比直接跑二进制人肉看返回码好使。这套配置建好后后面所有章节的代码都可以在这个工程里直接编译。3. 两类高频结构先写稳链表哨兵技巧与二叉树递归骨架剑指 Offer 的题里链表和二叉树出现的频率最高。这两个结构有个共同特点——边界条件特别多空指针判断漏一处整个函数就崩。而它们也恰恰是你最能和竞争者拉开差距的地方大多数人都能写对核心逻辑但能一次写对空链表、单节点、头尾操作的候选人少很多。所以这一章我不列题解而是把链表和二叉树里最常用的两个技巧拆开链表的哨兵dummy节点以及二叉树递归函数“到底该返回什么”。3.1 链表为什么带头节点的代码能少写一半边界判断链表题里最烦人的不是循环而是头节点的特殊处理。比如反转链表时原头节点变成尾节点需要把它的 next 置空删除节点时要改的可能恰好是头节点。这些分支写起来啰嗦而且最容易漏。用带头节点的写法能规避一大部分问题。这个”头节点“是虚拟的不存有效数据只用来占位。反转链表用迭代实现代码是这样的#include cstddef struct ListNode { int val; ListNode* next; explicit ListNode(int x 0) : val(x), next(nullptr) {} }; ListNode* reverseList(ListNode* head) { ListNode* prev nullptr; ListNode* curr head; while (curr ! nullptr) { ListNode* next curr-next; // 先保存后继防止断链后找不回去 curr-next prev; // 把当前节点指向前驱 prev curr; // 前驱移动到当前节点 curr next; // 当前节点移动到原后继 } return prev; }这里我没用虚拟头节点因为反转链表本身返回的是新头用一个prev空指针就能完成。循环里最核心的是那句curr-next prev之前先存住next。第一次写的人十个里有九个丢这一行结果就是遍历到第二个节点时空指针访问直接崩溃。3.2 合并有序链表哨兵节点让尾插不用判断谁是头合并两个排序链表时哨兵节点的优势体现得很明显。不用哨兵你要先比较两个链表的头节点确认结果链的头是谁然后再进入循环判断逻辑至少多两个分支。用哨兵则完全不用管头ListNode* mergeSortedLists(ListNode* l1, ListNode* l2) { ListNode dummy(0); // 虚拟头节点不参与数据 ListNode* tail dummy; // tail 永远指向结果链的尾节点 while (l1 ! nullptr l2 ! nullptr) { if (l1-val l2-val) { tail-next l1; l1 l1-next; } else { tail-next l2; l2 l2-next; } tail tail-next; } // 退出时必有一边为空直接把非空那边接上 tail-next (l1 ! nullptr) ? l1 : l2; return dummy.next; }dummy对象在栈上分配不是指针所以访问dummy.next不需要判空。函数返回时它是局部对象析构只释放自己的头节点内存不影响结果链表。这个写法不管你 l1 或 l2 是不是空链表代码都不用加特判循环条件已经处理了。面试时你写出这种风格比写一堆 if 分支要利落得多。3.3 二叉树递归函数先想清楚「返回什么」再写体二叉树题的递归写法最大的坑是函数签名乱变。有些人写着写着一会儿返回TreeNode*一会儿返回void然后把调用方该接收的返回值丢掉导致树的结构改了但外层拿不到新指针。一个适用的经验是如果这道题要「改变树的结构」函数就返回处理后的节点指针调用方必须接收返回值如果只是「遍历并修改节点的内容」返回 void 就够了。求二叉树镜像属于前者因为每一层都要把左右孩子交换并接回父节点。两种写法的差别在关键位置#include algorithm struct TreeNode { int val; TreeNode* left; TreeNode* right; explicit TreeNode(int x 0) : val(x), left(nullptr), right(nullptr) {} }; TreeNode* mirrorTree(TreeNode* node) { if (node nullptr) return nullptr; // 先处理左右子树再交换 TreeNode* left mirrorTree(node-left); TreeNode* right mirrorTree(node-right); node-left right; node-right left; return node; }这个版本中递归调用返回的是交换完的子树根节点赋值回node-left和node-right结构修改才能生效。把mirrorTree返回 void、在函数里直接 swap也能过测试因为交换不需要父节点感知。但一旦题目升级成“把二叉树变成其镜像并用数组序列化”返回 void 的版本就不好扩展了。写递归函数时我有个习惯动if之前先注释一行“本函数返回什么”比如// 返回交换左右子树后的根节点。这行注释能让你在递归调用处不犯迷糊也方便面试时向面试官解释思路。4. 从源码到算法模式位运算、双指针与动态规划的 C 写法链表和二叉树练的是结构操作这一章的题考的是算法思维。剑指 Offer 里常被翻牌的三类位运算、双指针、动态规划。这三类的共同点是——题解看一遍觉得自己懂了合上书一写就卡住。因为它们的解题关键不在语法而在那个“为什么可以这么做”的转折点。我复习的时候把每类模式的切入点固定下来每次练题先复述这个切入点再动手写代码。4.1 位运算求整数次方处理底数为 0、指数为负和 INT_MIN 溢出求数值的整数次方是典型的“看着简单、一写就错”的题。直接循环相乘固然对但面试官想看的往往是你能不能用二分思想把复杂度降到对数级。位运算实现快速幂是常见做法double power(double base, int exp) { if (base 0.0) return 0.0; bool negative exp 0; // 用 long long 承接避免 exp INT_MIN 时取反溢出 long long e negative ? -(static_castlong long(exp)) : exp; double result 1.0; while (e 0) { if (e 1) result * base; // 当前二进制位是 1 才乘 base * base; // 底数自乘对应二进制位权 e 1; } return negative ? 1.0 / result : result; }这里的两个坑都是实战里踩出来的。第一个是exp INT_MIN时的取反溢出-INT_MIN在 int 范围内是未定义行为先转成long long再取反就安全。第二个是底数为 0、指数为负的情况数学上无定义工程惯例是返回 0.0 或抛出参数异常我选择返回 0.0并在测试用例里明确标记这个行为。每次循环迭代base变成自己的平方e右移一位。e 1判断当前最低位是否为 1决定是否把当前的base乘进结果。这个技巧把 O(n) 的相乘次数降到 O(log n)面试时写出来会是一个加分项。4.2 双指针找两数和有序数组为什么可以放心移动指针双指针的经典场景是在递增数组中找两个数使其和等于给定 target。暴力枚举是 O(n²)双指针能压到 O(n)。但难点在于理解为什么向内收缩不会漏解#include vector bool twoSum(const std::vectorint sorted, int target, int* outA, int* outB) { if (sorted.empty()) return false; int left 0; int right static_castint(sorted.size()) - 1; while (left right) { int sum sorted[left] sorted[right]; if (sum target) { *outA sorted[left]; *outB sorted[right]; return true; } if (sum target) { left; // 和太小只能向右移动左指针增大和 } else { --right; // 和太大只能向左移动右指针减小和 } } return false; }安全性在于数组有序当left right小于 target 时右指针已经是当前能取到的最大值向左移动右指针只会让和更小不可能凑出 target所以只能向右移动左指针。大于 target 时同理。这个推理一句话就能讲清楚但很多人写题时会卡在“会不会跳过正确答案”。把这句话先说出来再写代码正确率会高很多。outA和outB是输出型参数。这里用指针传递而不是引用是为了在返回值上区分「找到了」和「没找到」。面试时如果面试官问为什么不用引用你可以解释引用无法表达“无结果”的回传语义。这也是这道题除了双指针之外的第二个考察点。4.3 动态规划求连续子数组最大和先写递推式再决定要不要打表动态规划题最忌讳一上来就开数组打表。先写递推式再根据递推式里依赖哪些历史状态决定要不要真的开一维数组。连续子数组最大和常被称为“最大子序和”的状态转移非常简洁以当前位置结尾的最大和要么是当前元素自己要么是前一个位置的最大和加上当前元素。用滚动变量就能解决不需要额外数组。#include vector #include algorithm int maxSubArray(const std::vectorint nums) { int dpEndHere nums[0]; // 以当前位置结尾的最大子数组和 int best nums[0]; // 已经扫过的所有位置中的最大值 for (size_t i 1; i nums.size(); i) { dpEndHere std::max(nums[i], dpEndHere nums[i]); best std::max(best, dpEndHere); } return best; }这里只开了两个 int空间复杂度 O(1)。如果你一上来就建vectorint dp(nums.size())空间复杂度变成 O(n)而且没有明显收益。面试官追问空间复杂度时你能答出“递推只依赖 dp[i-1]可以用滚动变量”印象分比写对题更高。这个例子也引出一个习惯凡是动态规划题先口头说清状态定义再说转移方程最后才写代码。状态定义错了代码写得再漂亮也白搭而状态定义一旦说清代码基本就是按方程逐行翻译。5. 编译与运行避坑五个翻车现场每个都能省你半天这一章是实战里最值得记的部分。以下五个问题我在帮 A 同学和某开发者看代码时反复见到自己也踩过不止一次。每一条按“现象 → 原因 → 解决”整理。5.1 VS 下 scanf 报 C4996不是代码错而是安全函数重定义写剑指 Offer 的题目时很多人习惯用scanf读输入。在 Visual Studio 里一编译报error C4996: scanf: This function or variable may be unsafe。第一反应是代码写错了其实这是微软编译器默认要求用scanf_s这类安全增强版函数。原因在于 VS 的默认预处理器宏里带了_CRT_SECURE_NO_WARNINGS的相反检查对 C 标准库的不安全函数发警告。解决办法有两个要么在源文件最顶端加#define _CRT_SECURE_NO_WARNINGS要么在 CMakeLists 里给 MSVC 加编译选项。我推荐后者因为不用改代码if(MSVC) add_compile_options(/utf-8 /D_CRT_SECURE_NO_WARNINGS) endif()/utf-8同时解决了下一节的编码问题一条选项两件事都办了。如果你不是用 CMake 而是直接在 VS 里建项目也要在“配置属性 → C/C → 命令行”里加上这两个参数。注意#define必须出现在任何#include之前不然无效。5.2 反转链表执行完外层指针没变缺了二级指针或引用现象很经典写了一个void reverseList(ListNode* head)在函数里做链表反转打印函数内head是对的但回到 main 一看外层head还是指向原来的头节点。原因是 C 函数参数默认按值传递。head传进去的是一个指针副本在函数里改的是副本指向的节点而不是存储“头节点地址”的那个变量。如果你希望通过函数修改调用方的头指针本身就要用二级指针或指针的引用。解决方式是在参数上加一个引用void reverseListInPlace(ListNode* head) { ListNode* prev nullptr; ListNode* curr head; while (curr ! nullptr) { ListNode* next curr-next; curr-next prev; prev curr; curr next; } head prev; // 修改的是调用方的 head }ListNode* head的语义是“链表头指针的引用”函数内对head的赋值会直接反映到调用方。如果你在面试时被要求写ListNode* reverseList(ListNode* head)这种签名那就选择返回新头调用处写成head reverseList(head)。两种方式都行但别混用了返回值又忽略返回值等于白做。5.3 递归一上 10 万就崩不是题解错是调用栈被压爆二叉树深度类题目很容易遇到递归深度问题。本地测试用三层、五层树跑得好好的一换 10 万层的单链二叉树程序崩溃。此时多数人以为是代码写炸了实际是调用栈溢出。程序默认栈空间在 Windows 下通常是 1MB 左右Linux 是 8MB每次递归调用会占几十到上百字节栈帧。10 万层递归轻松超出限制触发 stack overflow。解决思路不是无限加大栈而是换掉递归写法。以二叉树最大深度为例递归版简洁但可以在掌握递归后主动练一遍迭代版#include stack int maxDepthIterative(TreeNode* root) { if (root nullptr) return 0; std::stackstd::pairTreeNode*, int stk; stk.push({root, 1}); int depth 0; while (!stk.empty()) { TreeNode* node stk.top().first; int d stk.top().second; stk.pop(); depth std::max(depth, d); if (node-left) stk.push({node-left, d 1}); if (node-right) stk.push({node-right, d 1}); } return depth; }这个迭代版用显式栈模拟递归每个节点入栈时携带自己的深度。它的栈消耗由堆分配承担不再受调用栈大小限制。面试时递推版写完可以主动补一句“如果深度较大我会改成显式栈”这是展示工程意识的时机。5.4 中文注释在 Linux 下变乱码编码在编译期就出问题了在 Windows 上用 VS 写了带中文注释的代码传到 Linux 用 g 编译注释变成乱码有些编译器还会直接报错。原因是 Windows 简体中文环境默认用 GBK 保存文件而 Linux 下 GCC 默认按 UTF-8 解析源码。解决最彻底的办法是统一 UTF-8 编码。但很多人不知道的是GCC 在报错时会把乱码字节带进错误信息让你误以为是代码语法错。我建议在 CMakeLists 里给 GCC 和 Clang 也加上编码相关参数if(UNIX AND NOT APPLE) add_compile_options(-finput-charsetUTF-8 -fexec-charsetUTF-8) endif()-finput-charsetUTF-8告诉编译器源文件按 UTF-8 解读-fexec-charsetUTF-8让宽字符常量也按 UTF-8 生成。加上这两个之后从 Windows 拷过来的文件只要另存成 UTF-8 格式跨平台编译就不会再乱。如果你在 Windows 上用的编辑器是默认 GBK先“另存为 UTF-8”再提交不然参数加了也没用。5.5 用 vector 和 list 把题过了面试官却让你现场手写用 STL 容器做题非常爽vector当数组用、list当链表用、set去重、map计数代码又短又不容易错。但剑指 Offer 风格的面试现场面试官常会追问“如果不能用 STL你手写链表反转怎么做”或者直接让你实现一个删除指定节点的链表函数。现象是习惯了 STL 之后手写struct ListNode时反而会卡比如忘了初始化next或者没写explicit导致隐式转换。原因是用容器时不需要关心节点生命周期和指针指向手写时必须自己管理。我的对策很朴素链表和二叉树的相关题目做题时一律手写结构体禁止使用std::list或std::shared_ptr。会 STL 是加分项但面试考察的是你对底层结构的控制力。复习时把常用 STL 容器的时间复杂度背下来写题时用手写结构体两边都兼顾。如果某题明确考 STL比如“用两个栈实现队列”那就直接用std::stack这类题考的是数据结构的组合不是内存管理。6. 验证不止跑通用一组宏把边界值变成自动化检查题写完能跑通只是第一步。真正决定代码质量的是边界值测试而手写代码时最容易漏的恰恰是边界。一道链表中环的入口、一道删除链表节点空链表、单节点、尾节点这几个输入足够把大多数错误实现打回原形。我的习惯是把测试断言写成一组宏全部题共用同一个测试框架。宏的好处是能拿到表达式字符串和文件名行号出错时知道具体是哪个测试、哪一行。调试时比打印一堆cout 再人肉比对快很多#include iostream #define CHECK(cond) \ do { \ if (!(cond)) { \ std::cerr FAIL __FILE__ : __LINE__ \ - #cond std::endl; \ return 1; \ } \ } while (0)配合这个宏链表反转题的测试可以这样组织。测试矩阵按输入规模分层测试输入预期结果主要覆盖点nullptr返回nullptr空链表单节点返回该节点循环不进入两个节点顺序互换头尾指针切换两个相同值的节点不崩溃顺序符合预期比较运算符边界测试代码如下int testReverseList() { ListNode* head nullptr; CHECK(reverseList(head) nullptr); head new ListNode(1); ListNode* r reverseList(head); CHECK(r-val 1); ListNode* n1 new ListNode(1); ListNode* n2 new ListNode(2); n1-next n2; r reverseList(n1); CHECK(r-val 2 r-next-val 1 r-next-next nullptr); return 0; }测试里不能忘了释放内存。因为这里是验证用的小程序我直接new完不释放进程退出时系统回收但如果你把这段代码放进长期维护的工程里建议用 RAII 或std::unique_ptr管理节点。测试列表里每个用例只做一件事一个用例挂了直接看到输出里印出的FAIL行号。避免在一个用例里测七种条件否则挂了不知道是哪一处。这也是“用测试倒推设计”的雏形——你会发现为了写出边界齐全的测试你不得不把函数入口的空指针行为、返回值的语义、参数的有效范围先想清楚这些恰恰是面试官最常追问的。我后来养成的习惯是每写完一道题先写测试再写实现。测试写不出来的时候说明这道题我还没真正理解输入输出边界。这个习惯改起来很费劲但确实让我在面试手撕环节少了很多翻车时刻。把工程里的每一道题都挂上这组测试矩阵练习才算闭环。希望帮到你。本文还有配套的精品资源点击获取