1. 项目概述为什么“刷题统计”是C学习者的必修课如果你正在学习C无论是为了准备面试、参加算法竞赛还是单纯想夯实编程基础那么“刷题”这件事你一定不陌生。每天在LeetCode、牛客网、洛谷等平台上解决几道算法题几乎是每个C学习者的日常。但不知道你有没有这样的感觉刷了上百道题回头一看好像什么都学了又好像什么都没记住。题目类型混淆不清做过的题换个马甲就不认识了面试时被问到“你最擅长的算法是什么”也支支吾吾答不上来。这正是“刷题统计”这个项目要解决的问题。它不是一个简单的计数器而是一个个人算法能力的量化分析与管理工具。其核心价值在于将你零散、感性的刷题体验转化为结构化、可分析的数据。通过这个项目你可以清晰地看到自己的刷题轨迹在动态规划上花了多少时间二叉树类题目的通过率如何本周的刷题计划完成度是多少这些数据能帮你跳出“盲目刷题”的陷阱实现精准、高效的刻意练习。对于C开发者而言用C来实现这样一个工具更是意义非凡。这不仅仅是一个应用项目它本身就是一个综合性的C编程实践。你会涉及到文件I/O操作来持久化数据使用STL容器如vector,map,set来高效组织题目信息可能还需要设计简单的类来抽象“题目”、“分类”等实体甚至要用到时间日期库来处理刷题记录。在这个过程中你巩固的正是那些在面试八股文里被反复追问却又最容易被忽视的工程实践能力。2. 核心需求与功能设计拆解一个有效的刷题统计工具不应该只是一个记录“做了多少题”的流水账。它的设计应当围绕“如何提升刷题效率和质量”这一核心目标展开。我们需要从学习者的真实痛点出发拆解出关键功能模块。2.1 核心需求解析量化学习成果对抗遗忘曲线人的记忆是有规律的刷过的题目如果不复习很快就会生疏。统计工具需要能按时间、按分类展示刷题记录方便定期回顾和复习。识别能力短板实现精准提升是动态规划总是没有思路还是图论算法代码冗长易错统计工具需要能分析出你在不同算法分类下的通过率、平均耗时、重刷次数等直观地指出薄弱环节。规划学习路径管理刷题进程面对海量题库容易产生迷茫。工具应支持制定计划如“本周完成10道二叉树题目”并跟踪完成情况提供正向激励。统一记录入口降低管理成本题目可能来自多个平台LeetCode、AcWing等手动在多个平台间切换查看效率低下。一个本地的、统一的记录中心至关重要。2.2 系统功能模块设计基于以上需求我们可以将系统划分为以下几个核心模块题目管理模块这是系统的基石。负责题目的增删改查。每条记录至少应包含唯一ID、标题、所属平台、链接、算法分类如数组、链表、动态规划、深度优先搜索等、难度等级、首次通过日期、最近复习日期、通过状态、个人备注等。数据统计与分析模块这是系统的“大脑”。负责对存储的题目数据进行多维度的聚合分析。宏观统计总刷题数、总通过数、总体通过率、连续刷题天数等。分类统计按算法分类统计题目数量、通过率、最近活跃时间。用数据告诉你你在“动态规划”上投入了多少收获了多少。时间趋势分析生成每日/每周/每月的刷题数量折线图或日历热力图可视化你的努力轨迹。难度分布分析统计简单、中等、困难题目的数量和占比评估当前的挑战级别是否合适。复习提醒与计划模块这是系统的“教练”。基于艾宾浩斯遗忘曲线或自定义规则自动标记出需要复习的题目例如3天前通过但未复习的题。支持创建和跟踪自定义的刷题计划。数据持久化模块所有数据需要保存到本地文件如JSON、CSV或SQLite数据库确保程序关闭后数据不丢失。用户交互模块提供命令行界面CLI或图形界面GUI让用户能够方便地输入命令、查看统计结果。CLI轻量快捷适合开发者GUI则更直观友好。3. 技术选型与C工程实践要点用C实现这个项目我们有机会深入实践许多课本上学不到的工程细节。下面我们来聊聊具体的技术选择和背后的考量。3.1 核心数据结构设计如何用STL高效组织数据数据模型的设计直接决定了程序的效率和代码的清晰度。我们主要会用到以下几种STL容器std::vectorProblem这是存储所有题目对象的主容器。Problem是一个自定义的类或结构体。选择vector是因为刷题记录的增加是顺序的且我们需要频繁地进行遍历和随机访问例如按ID查找vector的连续内存布局能提供最佳的缓存友好性和访问速度。struct Problem { int id; std::string title; std::string platform; // “LeetCode” “AcWing” std::string category; // “DP” “BinaryTree” std::string difficulty; // “Easy” “Medium” “Hard” std::time_t first_solved_date; std::time_t last_review_date; bool status; // true for solved, false for attempted std::string notes; // ... 其他字段 }; std::vectorProblem problem_db;std::mapstd::string, CategoryStats用于实现分类统计。键Key是分类名称如“DP”值Value是一个统计结构体CategoryStats包含该分类下的总题数、通过数等。map基于红黑树实现能自动按键排序方便我们按分类名称快速查找和更新统计信息也便于输出有序的统计报告。struct CategoryStats { int total_count 0; int solved_count 0; // ... 其他统计字段 }; std::mapstd::string, CategoryStats stats_by_category;std::setint可用于标记需要今天复习的题目ID集合。set能保证元素唯一且自动排序当我们需要快速判断一个题目ID是否在待复习列表中时它的查找效率O(log n)很高。注意关于std::unordered_map的选择。如果你不关心分类名称的字典序输出并且追求极致的查找性能平均O(1)那么unordered_map是比map更好的选择。这里选择map是为了输出统计报告时更美观。这是一个典型的在“性能”和“功能便利性”之间的权衡在实际工程中很常见。3.2 数据持久化方案JSON vs. SQLite数据需要保存到硬盘。我们有几种选择纯文本文件CSV/TXT最简单但难以表示嵌套结构扩展性差不适合复杂查询。JSON文件推荐给初学者或中小型项目。JSON格式清晰可读性好现代C有优秀的库支持如nlohmann/json。它非常适合存储像vectorProblem这样结构化的数据。优点是设置简单文件可以直接用文本编辑器查看和修改。// 使用 nlohmann/json 库的示例 #include nlohmann/json.hpp using json nlohmann::json; void save_to_json(const std::vectorProblem db, const std::string filename) { json j; j[problems] db; // 需要为Problem结构体定义to_json和from_json方法 std::ofstream file(filename); file j.dump(4); // 缩进4个空格美化输出 }优点轻量、易读、易调试。缺点当数据量非常大例如数万条记录时每次读写整个文件效率较低缺乏数据库的事务、复杂查询等高级功能。SQLite数据库推荐给有更高要求或想学习数据库集成的开发者。SQLite是一个嵌入式的、零配置的数据库引擎整个数据库就是一个文件。你可以使用像SQLiteCpp这样的C封装库来操作。// 伪代码示例 SQLite::Database db(problem.db, SQLite::OPEN_READWRITE|SQLite::OPEN_CREATE); db.exec(CREATE TABLE IF NOT EXISTS problems (id INT PRIMARY KEY, title TEXT, category TEXT, ...));优点支持SQL查询如“找出所有未复习的困难动态规划题”性能好数据一致性有保障。缺点引入外部依赖复杂度稍高。实操建议对于个人刷题统计工具数据量通常在几千条以内JSON方案完全够用且更简单直观。本项目我们将以JSON作为持久化方案进行讲解。3.3 日期与时间处理避开ctime的坑C中处理时间是个容易踩坑的地方。传统的C风格ctime库函数如localtime,mktime不是线程安全的而且接口比较老旧。更现代、更安全的选择是C11的chrono库和std::tm的结合使用或者使用第三方库如date.hHoward Hinnant的日期库已部分进入C20。这里给出一个使用chrono和iomanip进行格式化输出的常用方法#include chrono #include iomanip #include sstream std::string time_t_to_string(std::time_t t) { std::tm tm *std::localtime(t); // 注意localtime不是线程安全的单线程程序可用 std::ostringstream oss; oss std::put_time(tm, %Y-%m-%d %H:%M:%S); return oss.str(); } std::time_t string_to_time_t(const std::string s) { std::tm tm {}; std::istringstream iss(s); iss std::get_time(tm, %Y-%m-%d %H:%M:%S); return std::mktime(tm); }重要心得在实际项目中如果对时间处理要求较高强烈建议封装一个独立的DateTime工具类将所有时间转换、比较、格式化操作集中管理。这样可以避免时间处理代码散落各处也便于未来替换底层时间库比如换用C20的chrono日历功能。4. 核心模块实现详解接下来我们深入到代码层面看看各个核心模块如何用C实现。我们将遵循“高内聚、低耦合”的原则进行设计。4.1 Problem类与数据管理首先我们需要一个健壮的Problem类来封装题目信息。// problem.hpp #ifndef PROBLEM_HPP #define PROBLEM_HPP #include string #include ctime class Problem { public: Problem() default; Problem(int id, std::string title, std::string platform, std::string category, std::string difficulty); // Getter and Setter 方法 int getId() const { return id_; } void setTitle(const std::string title) { title_ title; } // ... 其他getter/setter // 核心业务方法 void markAsSolved(); // 标记为通过并记录当前时间为首次通过时间 void markForReview(); // 标记为需要复习 bool needsReview(int days_interval) const; // 判断是否需要复习 // 用于JSON序列化/反序列化 (配合nlohmann/json库) NLOHMANN_DEFINE_TYPE_INTRUSIVE(Problem, id_, title_, platform_, category_, difficulty_, first_solved_date_, last_review_date_, status_, notes_) private: int id_; std::string title_; std::string platform_; std::string category_; std::string difficulty_; std::time_t first_solved_date_ 0; std::time_t last_review_date_ 0; bool status_ false; // false: 未通过/待完成 std::string notes_; }; #endif // PROBLEM_HPP在对应的.cpp文件中实现方法例如markAsSolvedvoid Problem::markAsSolved() { if (!status_) { status_ true; std::time_t now std::time(nullptr); if (first_solved_date_ 0) { first_solved_date_ now; } last_review_date_ now; // 通过即视为一次复习 } }设计要点使用NLOHMANN_DEFINE_TYPE_INTRUSIVE宏可以零成本地让Problem类支持nlohmann::json的序列化非常方便。将核心业务逻辑如标记完成、判断复习封装在类内部符合面向对象设计原则。4.2 统计引擎的实现统计引擎StatisticsEngine类负责所有数据分析工作。它应该持有problem_db的引用或指针并提供各种统计方法。// statistics_engine.hpp class StatisticsEngine { public: explicit StatisticsEngine(const std::vectorProblem db) : problem_db_(db) {} // 计算总体统计 struct OverallStats { int total_problems; int solved_problems; double pass_rate; // 通过率 int streak_days; // 连续刷题天数 // ... }; OverallStats calculateOverallStats() const; // 按分类统计 std::mapstd::string, CategoryStats calculateCategoryStats() const; // 生成时间热力图数据 (例如过去30天每天刷题数) std::mapstd::string, int generateHeatmapData(int last_days 30) const; // 查找待复习题目 std::vectorconst Problem* findProblemsForReview(int interval_days 3) const; private: const std::vectorProblem problem_db_; // 引用不拥有数据 };calculateCategoryStats的实现示例std::mapstd::string, CategoryStats StatisticsEngine::calculateCategoryStats() const { std::mapstd::string, CategoryStats result; for (const auto problem : problem_db_) { auto stats result[problem.getCategory()]; // map的operator[]会自动插入新元素 stats.total_count; if (problem.isSolved()) { stats.solved_count; } // 可以在这里计算其他统计如平均耗时如果有记录 } // 计算每个分类的通过率 for (auto [category, stats] : result) { if (stats.total_count 0) { stats.pass_rate static_castdouble(stats.solved_count) / stats.total_count * 100.0; } } return result; }性能考虑每次调用统计方法都遍历整个数据库在数据量大时可能成为瓶颈。一个优化策略是采用缓存机制当题目数据库发生变化时增、删、改标记缓存失效下次请求统计时重新计算并缓存结果。对于个人使用规模直接遍历通常已足够快。4.3 复习提醒算法的设计复习提醒是提升刷题效果的关键。一个简单的基于时间的算法如下首次通过标记通过日期first_solved_date。复习间隔采用一个递增的间隔序列例如[1, 3, 7, 14, 30]天。这意味着在第1、3、7...天后需要复习。判断逻辑对于一道已通过的题计算当前日期与last_review_date上次复习日期的天数差。如果天数差大于或等于下一个预设的间隔天数则提醒复习。更新逻辑用户复习后更新last_review_date为当前日期并将该题在间隔序列中的指针移到下一个位置或重置。在Problem类中实现needsReviewbool Problem::needsReview(int days_interval) const { if (!status_) return false; // 未通过的题不参与复习提醒 if (last_review_date_ 0) return false; // 从未复习过理论上不会发生 std::time_t now std::time(nullptr); double days_passed std::difftime(now, last_review_date_) / (60 * 60 * 24); return days_passed days_interval; }在实际系统中days_interval可以从一个属于用户的“复习计划”配置中读取实现个性化。5. 命令行界面CLI与用户交互一个友好的CLI是工具易用性的保证。我们可以使用一个简单的循环来解析用户命令。为了更好的结构可以使用像cxxopts这样的库来解析命令行参数但对于交互式命令一个switch-case循环也足够清晰。// main.cpp 交互循环部分 #include iostream #include string #include “ProblemManager.hpp” // 假设有一个统一的管理类 void printUsage() { std::cout 刷题统计工具命令列表\n add title platform category difficulty - 添加新题目\n solve id - 标记题目为已解决\n stats - 显示统计概览\n review - 显示待复习题目\n list [category] - 列出题目可筛选分类\n exit - 退出程序\n; } int main() { ProblemManager manager; manager.loadFromFile(problems.json); // 启动时加载数据 std::string command; while (true) { std::cout \n ; std::getline(std::cin, command); if (command exit || command quit) { manager.saveToFile(problems.json); break; } else if (command.substr(0, 4) add ) { // 解析参数调用 manager.addProblem(...) } else if (command.substr(0, 6) solve ) { int id std::stoi(command.substr(6)); manager.markProblemAsSolved(id); } else if (command stats) { manager.displayStatistics(); } else if (command review) { manager.displayReviewList(); } else if (command.substr(0, 5) list) { // 解析可选分类参数 manager.listProblems(...); } else { printUsage(); } } return 0; }交互设计心得对于add这类参数较多的命令直接解析字符串比较繁琐且容易出错。在实际项目中可以考虑两种优化1) 实现一个简单的命令行参数分词器2) 对于复杂操作改为分步交互式提示例如输入add后程序依次提示“请输入标题”、“请选择分类”等用户体验会更好。6. 项目构建、测试与扩展方向6.1 使用CMake管理项目对于稍具规模的C项目使用构建系统是必须的。CMake是当前的事实标准。一个简单的CMakeLists.txt如下cmake_minimum_required(VERSION 3.10) project(ProblemTracker) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 查找 nlohmann_json 包假设它通过包管理器如vcpkg, conan安装或作为子模块 find_package(nlohmann_json 3.9.1 REQUIRED) # 添加可执行文件 add_executable(problem_tracker src/main.cpp src/Problem.cpp src/ProblemManager.cpp src/StatisticsEngine.cpp ) # 链接库 target_link_libraries(problem_tracker PRIVATE nlohmann_json::nlohmann_json) # 设置头文件包含路径 target_include_directories(problem_tracker PRIVATE include)这样你可以在build目录下执行cmake .. make来编译项目管理依赖非常清晰。6.2 如何为项目添加单元测试测试是保证代码质量的关键。你可以使用Google Test、Catch2等测试框架。例如为StatisticsEngine添加测试// test_statistics.cpp #include gtest/gtest.h #include “StatisticsEngine.hpp” #include “Problem.hpp” TEST(StatisticsTest, EmptyDatabase) { std::vectorProblem empty_db; StatisticsEngine engine(empty_db); auto stats engine.calculateOverallStats(); EXPECT_EQ(stats.total_problems, 0); EXPECT_EQ(stats.solved_problems, 0); EXPECT_DOUBLE_EQ(stats.pass_rate, 0.0); } TEST(StatisticsTest, CategoryStats) { std::vectorProblem db; db.emplace_back(1, “Two Sum”, “LeetCode”, “Array”, “Easy”); db.back().markAsSolved(); db.emplace_back(2, “Binary Tree Inorder”, “LeetCode”, “BinaryTree”, “Medium”); // 第二题未解决 StatisticsEngine engine(db); auto cat_stats engine.calculateCategoryStats(); ASSERT_TRUE(cat_stats.find(“Array”) ! cat_stats.end()); EXPECT_EQ(cat_stats[“Array”].total_count, 1); EXPECT_EQ(cat_stats[“Array”].solved_count, 1); EXPECT_DOUBLE_EQ(cat_stats[“Array”].pass_rate, 100.0); }将测试集成到CMake中每次构建后运行测试能极大增强代码的可靠性。6.3 项目扩展方向这个基础版本已经可用但还有巨大的扩展空间图形化界面GUI使用Qt、Dear ImGui或wxWidgets为工具打造一个可视化界面。图表如通过率饼图、刷题热力图将比命令行输出直观得多。数据导入/导出支持从LeetCode的“我的提交”页面导出CSV并自动导入到本工具中省去手动添加的麻烦。标签系统除了固定的算法分类允许用户为题目添加自定义标签如“高频面试题”、“易错”、“巧解”实现更灵活的管理。解题笔记与代码管理将每道题的解题思路笔记、最优解C代码片段直接保存在记录中打造个人算法笔记库。云同步将数据文件存储在云盘如Dropbox、OneDrive或通过简单的自制后端实现多设备同步。与IDE集成开发一个VSCode或CLion插件让你在写代码的IDE里直接记录和查看刷题状态。7. 常见问题与调试心得在开发过程中你肯定会遇到各种问题。这里记录一些典型问题和解决方法。7.1 数据文件损坏或格式错误问题程序启动时读取JSON文件失败导致崩溃或数据丢失。解决健壮的读取代码使用try-catch块包裹json解析逻辑。bool ProblemManager::loadFromFile(const std::string filename) { std::ifstream file(filename); if (!file.is_open()) { std::cerr “警告无法打开文件 ” filename “将创建新数据库。” std::endl; problem_db_.clear(); return false; } try { json j; file j; problem_db_ j.getstd::vectorProblem(); std::cout “成功从 ” filename “ 加载 ” problem_db_.size() “ 条记录。” std::endl; return true; } catch (const json::exception e) { std::cerr “错误解析JSON文件失败 - ” e.what() std::endl; std::cerr “建议检查文件格式或使用备份文件恢复。” std::endl; problem_db_.clear(); // 清空避免使用损坏数据 return false; } }定期备份在保存数据前先将旧文件重命名为备份如problems.json.backup。版本控制将数据文件problems.json也纳入Git管理但注意不要提交包含个人进度的文件可以提交一个模板文件。7.2 时间处理导致的跨平台问题问题在Windows和Linux/macOS上std::localtime的行为或时区处理可能有细微差异。解决统一使用UTC时间存储在序列化到JSON时将std::time_t或转换成的字符串以UTC标准时间如ISO 8601格式”2023-10-27T10:30:00Z”存储。显示时再根据用户所在时区转换。这能从根本上避免时区混乱。使用第三方日期库如前所述date.h库提供了强大且一致的日期时间处理能力能很好地解决跨平台问题。7.3 程序性能随数据量增加而下降问题当刷题记录超过几千条后每次执行stats或list命令感觉变慢。分析与优化定位瓶颈使用简单的时间戳记录函数执行耗时找出慢在哪里。通常是calculateCategoryStats这种需要遍历所有数据的方法。引入缓存如前所述为StatisticsEngine添加一个缓存机制。当数据未变更时直接返回缓存的统计结果。优化数据结构如果频繁按ID查找题目可以考虑额外维护一个std::unordered_mapint, Problem*作为索引将查找复杂度从O(n)降到O(1)。但这会增加数据更新的复杂度需要同时更新vector和map属于以空间换时间的权衡。惰性计算不是每次命令都计算所有统计。例如stats命令只计算总体统计review命令只计算待复习题目避免不必要的计算。7.4 内存泄漏排查问题虽然本项目主要使用STL容器管理内存但如果在扩展时手动使用了new则需警惕。解决遵循RAII原则尽量使用智能指针std::unique_ptr,std::shared_ptr和STL容器让它们自动管理资源。使用Valgrind或AddressSanitizer在Linux/macOS下使用valgrind ./problem_tracker检查内存错误。在Clang/GCC中编译时添加-fsanitizeaddress选项可以检测内存泄漏和越界访问。Windows平台可以使用Visual Studio自带的内存诊断工具。开发这个工具的过程本身就是一个绝佳的C学习项目。它覆盖了从核心语法、面向对象设计、STL使用、文件操作、数据序列化到简单的软件架构等多个方面。当你亲手实现它并每天用它来管理自己的刷题进度时你对C的理解和对个人学习路径的掌控都会上升一个实实在在的台阶。