C/C++刷题工程化实战:CMake构建、单元测试与CI集成 简介本资源是面向C/C初学者与算法练习者的题库实战源码合集以LeetCode和牛客网高频编程题为基础帮助读者通过动手编码巩固语法、锤炼算法思维。包内共40个文件含24个C语言源文件、14个C源文件另附1张PNG图片与1份readme说明压缩包约148KB体量轻便、便于逐题研读。C语言部分侧重指针、结构体与动态内存管理等核心机制C部分则体现封装、继承与多态等面向对象思想两类源码互为参照可对比同一问题的不同实现路径。每个文件对应一道具体题目命名带日期或编号方便按序练习与复盘。目前已有353人学习下载适合希望系统刷题、积累解题模板与排错经验的读者通过模仿与优化逐步建立对算法和数据结构的深刻理解。1. 从题库到源码一套 C/C 刷题工程到底长什么样很多人第一次听到「基于题库的 C/C 设计源码」脑子里浮现的是一堆main.cpp里塞满for循环的散装文件。我一开始也这么想直到某次帮一个学弟整理他的刷题记录——三百多个文件命名从test1.cpp到test_final_v2.cpp编译全靠手动复制粘贴想找一道「链表反转」得翻十分钟。那一刻我意识到刷题本身不产生工程能力把刷题过程工程化才产生。这套源码要解决的核心问题就一个让每一道题的解法可编译、可测试、可检索、可复用而不是躺在文件夹里当电子垃圾。它适合两类人——正在系统刷题但代码越堆越乱的学生以及想用题库练手 C 工程化CMake、单元测试、CI的转岗开发者。下面我按自己实际搭过的一套结构把选型、目录、构建、测试和踩坑讲透。2. 目录结构与构建系统为什么我最终选了 CMake 而不是手写 Makefile2.1 单题单目录的划分逻辑散装文件最大的问题是「一道题的所有信息挤在一个文件里」——解法、测试用例、输入解析、调试输出全混在一起。我一般会按「题目 ID 短名」建目录每个目录里固定放三类文件解法头文件、测试文件、可选的输入数据。这样做的好处是当你想对比同一道题的两种解法时不需要复制整个文件只需要新增一个头文件。problems/ p0206_reverse_list/ solution.h test.cpp input.txt p0001_two_sum/ solution.h test.cppsolution.h里只放类或函数定义不写main。test.cpp负责调用并断言。input.txt用于那些需要从标准输入读数据的题目方便本地复现。这个划分看起来简单但它强制你把「算法逻辑」和「测试逻辑」分开后面接单元测试框架时几乎零改动。2.2 CMake 的最小可用配置手写 Makefile 在题目数量超过五十道之后会变成噩梦——每加一道题就要改一次编译规则。CMake 的GLOB虽然被很多人诟病「不推荐」但在题库这种「文件频繁增减」的场景下它反而是最省事的。下面是我常用的顶层CMakeLists.txtcmake_minimum_required(VERSION 3.16) project(leetcode_cpp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 收集所有题目目录下的 test.cpp file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/problems/*/test.cpp) # 为每个 test.cpp 生成一个独立可执行文件 foreach(test_src ${TEST_SOURCES}) get_filename_component(problem_dir ${test_src} DIRECTORY) get_filename_component(problem_name ${problem_dir} NAME) add_executable(${problem_name} ${test_src}) target_include_directories(${problem_name} PRIVATE ${problem_dir}) endforeach()这段配置的关键在CONFIGURE_DEPENDS——它让 CMake 在每次构建时重新扫描目录新增题目后不需要手动重新运行cmake。target_include_directories把当前题目目录加入头文件搜索路径这样test.cpp里直接#include solution.h就能找到同目录的解法。2.3 编译选项与调试符号刷题代码经常需要单步调试所以-g不能省。同时我建议把警告开到-Wall -Wextra因为题库里很多边界错误比如有符号无符号比较在刷题时不容易发现但编译器能帮你抓出来。add_compile_options(-Wall -Wextra -g -O0)-O0是为了调试时变量不被优化掉。等你确认解法正确、需要跑性能对比时再单独开一个 Release 配置加-O2。不要一上来就-O2否则gdb里看到的值全是optimized out那才是真正的黑匣子。3. 测试与验证怎么让每道题都有「后悔药」3.1 用断言做最小测试不引入重型框架很多人一提到测试就想到 Google Test但题库场景下每道题的测试逻辑往往只有几行——构造输入、调用解法、比对输出。引入一个需要额外下载和链接的框架反而增加了复现成本。我一般直接用cassert加一个简单的EXPECT_EQ宏// test_common.h #pragma once #include iostream #include vector #include string #define EXPECT_EQ(actual, expected) \ do { \ auto _a (actual); \ auto _e (expected); \ if (_a ! _e) { \ std::cerr FAILED: #actual #expected \ \n actual: _a \ \n expected: _e std::endl; \ std::exit(1); \ } \ } while (0)这个宏的好处是失败时直接打印表达式原文和两边的值不需要额外工具就能定位。std::exit(1)保证 CI 里能正确捕获失败状态。3.2 一道完整题目的测试写法以「两数之和」为例solution.h里只放核心逻辑// problems/p0001_two_sum/solution.h #pragma once #include vector #include unordered_map class Solution { public: std::vectorint twoSum(std::vectorint nums, int target) { std::unordered_mapint, int seen; for (int i 0; i (int)nums.size(); i) { int need target - nums[i]; auto it seen.find(need); if (it ! seen.end()) { return {it-second, i}; } seen[nums[i]] i; } return {}; } };test.cpp负责构造用例并断言// problems/p0001_two_sum/test.cpp #include solution.h #include ../test_common.h int main() { Solution s; // 用例 1普通情况 std::vectorint nums1 {2, 7, 11, 15}; auto r1 s.twoSum(nums1, 9); EXPECT_EQ(r1.size(), 2u); EXPECT_EQ(nums1[r1[0]] nums1[r1[1]], 9); // 用例 2重复元素 std::vectorint nums2 {3, 3}; auto r2 s.twoSum(nums2, 6); EXPECT_EQ(r2.size(), 2u); // 用例 3无解 std::vectorint nums3 {1, 2}; auto r3 s.twoSum(nums3, 10); EXPECT_EQ(r3.size(), 0u); std::cout p0001 passed std::endl; return 0; }注意这里没有直接断言返回的下标是{0,1}因为哈希表的遍历顺序不保证。断言「两个下标对应的值之和等于 target」才是稳定的验证方式。这个细节在刷题时很容易被忽略但一旦你开始批量跑测试顺序相关的断言会带来大量假失败。3.3 批量运行与结果汇总有了统一的可执行文件命名规则批量运行只需要一个 shell 脚本#!/usr/bin/env bash set -e cd build fail0 for exe in p*; do if [ -x $exe ]; then if ! ./$exe /dev/null 21; then echo FAILED: $exe fail1 fi fi done exit $failset -e让脚本在遇到非零返回时立即停止fail变量确保即使中间有失败也会继续跑完所有题目再统一报告。这个脚本可以直接放进 CI 的script阶段每次提交自动验证所有题目。4. 避坑与常见问题那些让我重写过三次目录的教训4.1 头文件重复定义导致的链接错误现象编译时报multiple definition of Solution::twoSum明明每个题目目录是独立的。原因把函数实现直接写在头文件里且没有加inline或放到类内。当同一个头文件被多个翻译单元包含时链接器看到多份定义。解决类内定义的成员函数默认是inline的所以把实现写在class内部没问题。如果一定要写在类外必须加inline关键字或者把实现移到.cpp文件。我一般推荐直接写在类内题库场景下文件不会大到需要分离编译。4.2 GLOB 不触发重新构建现象新增了一道题运行cmake --build后新题目没有被编译。原因早期 CMake 的file(GLOB)在配置阶段只执行一次新增文件不会自动触发重新配置。解决使用CONFIGURE_DEPENDS选项CMake 3.12它会让构建系统在每次构建时检查目录变化。如果 CMake 版本太低只能手动重新运行cmake ..。这也是为什么我在 2.2 节的配置里特意加了这个参数。4.3 测试用例里的未定义行为现象本地跑测试通过提交到在线判题系统却报「运行时错误」。原因测试用例里用了std::vectorint的operator[]访问越界下标而本地因为内存布局恰好没崩溃。这类未定义行为在题库代码里非常常见尤其是手写二分查找和双指针时。解决测试代码里用.at()替代[]至少在你验证阶段让它抛出异常而不是静默越界。另外编译时加-fsanitizeaddress,undefined可以在运行时捕获越界和未定义行为add_compile_options(-fsanitizeaddress,undefined) add_link_options(-fsanitizeaddress,undefined)这个选项会让程序变慢但刷题阶段正确性优先于速度。等所有题目都通过后再关掉做性能测试。4.4 输入解析与题目描述不一致现象本地用input.txt跑出来结果正确但换成在线判题的标准输入就出错。原因题目描述里的输入格式往往有隐含规则——比如第一行是测试用例数量或者字符串可能包含空格。自己造input.txt时容易忽略这些。解决每道需要标准输入的题目在input.txt里至少放三组数据最小规模、最大规模、含边界值空输入、单元素、重复元素。读取时用while (std::cin x)而不是固定次数循环让程序自然处理到文件结尾。4.5 目录名与题目 ID 不同步现象想找「第 206 题」时发现目录名是reverse_list没有编号搜索困难。原因一开始只用了题目短名后来题目多了之后无法按 ID 排序。解决统一用p 四位数字 下划线 短名 的格式比如p0206_reverse_list。四位数字保证排序时按数值顺序排列p前缀避免目录名以数字开头导致某些工具解析异常。这个规则一旦定下就不要改否则重命名几十个目录的代价很大。5. 进阶技巧把题库变成可检索的个人知识库5.1 用标签系统做跨题目索引目录结构解决了「一道题在哪里」的问题但解决不了「哪些题用了滑动窗口」的问题。我的做法是在每个solution.h顶部加一行注释标签// tags: two-pointers, hash-table, array然后用一个 Python 脚本扫描所有solution.h生成一个index.mdimport os import re root problems index {} for dirpath, _, filenames in os.walk(root): for fn in filenames: if fn solution.h: path os.path.join(dirpath, fn) with open(path, encodingutf-8) as f: first_line f.readline() m re.match(r//\s*tags:\s*(.), first_line) if m: tags [t.strip() for t in m.group(1).split(,)] problem os.path.basename(dirpath) for tag in tags: index.setdefault(tag, []).append(problem) with open(index.md, w, encodingutf-8) as f: for tag in sorted(index): f.write(f## {tag}\n\n) for p in sorted(index[tag]): f.write(f- {p}\n) f.write(\n)这个脚本跑一次不到一秒生成的index.md可以直接在编辑器里预览。当你复习时想集中看「所有二分查找的题」打开索引就能跳转比在文件管理器里翻找快得多。5.2 用编译数据库提升编辑器体验compile_commands.json是 Clang 工具链的编译数据库很多编辑器如 VS Code 的 C/C 插件、CLion靠它做代码跳转和补全。CMake 默认不生成需要加一个选项cmake -DCMAKE_EXPORT_COMPILE_COMMANDSON ..生成的compile_commands.json在build/目录下把它软链接到项目根目录编辑器就能自动识别每个文件的编译参数。这个文件对刷题代码尤其有用因为每道题的test.cpp包含路径不同没有它的话编辑器会报大量「找不到头文件」的假错误。5.3 性能对比的简易方法同一道题写了两种解法时我一般会在test.cpp里加一个可选的性能测试分支用环境变量控制if (std::getenv(BENCH)) { std::vectorint big(100000); // 填充数据... auto start std::chrono::steady_clock::now(); s.twoSum(big, 199999); auto end std::chrono::steady_clock::now(); std::cerr elapsed: std::chrono::duration_caststd::chrono::microseconds(end - start).count() us std::endl; }这样正常跑测试时不会触发性能测试需要对比时设置BENCH1 ./p0001_two_sum即可。注意用std::cerr输出耗时避免污染标准输出的测试结果。5.4 我踩过的最大的坑最后说一个血泪经验不要为了「看起来专业」而引入你不需要的依赖。我曾经花了两天时间给题库接了一个 JSON 解析库想把测试用例写成 JSON 格式结果发现每道题的输入结构差异太大JSON 反而比直接写 C 初始化列表更啰嗦。后来全部回退到纯 C 测试代码维护成本立刻降下来。题库工程化的目标是「减少重复劳动」不是「堆技术栈」。如果你发现某个工具让你写测试的时间变长了果断砍掉它。希望帮到你。本文还有配套的精品资源点击获取