C++实现24点游戏:从算法到工程实践 1. 项目概述从“算24点”到C实战编程最近在整理旧项目时翻出了一个大学时期用Visual Studio和C写的“二十四点游戏”。这可不是一个简单的控制台小程序它完整地实现了从随机发牌、表达式计算、到求解与验证的全流程。对于很多C初学者或者想找一个既有算法深度又有界面交互的练手项目来说“二十四点”是个绝佳的选择。它麻雀虽小五脏俱全涉及随机数生成、数据结构栈的应用、算法深度优先搜索、字符串处理以及最基础的命令行交互逻辑。如果你正在学习C厌倦了书本上的例题想找一个能串联起多个知识点的综合实践或者你是一位开发者需要参考一个结构清晰、可扩展的游戏逻辑实现那么这份源代码和背后的实现思路或许能给你带来不少启发。接下来我将带你深入这个项目的每一个核心模块分享我当时的设计考量、踩过的坑以及优化思路。2. 项目整体设计与核心思路拆解2.1 游戏规则与核心挑战解析“二十四点”的规则大家都不陌生随机抽取4张扑克牌通常用1-13代表A-K其中1代表Ace11、12、13代表J、Q、K玩家需要使用加、减-、乘*、除/四种运算符和括号将这4个数字组合成一个表达式使得表达式的计算结果等于24。每个数字必须且只能使用一次。这个看似简单的游戏对程序来说有几个核心挑战数字组合的全排列4个数字不同的排列顺序会影响运算结果因此需要遍历所有可能的排列4! 24种。运算符的穷举在数字之间插入运算符每个位置有4种选择、-、*、/。对于3个运算符位置理论上有4³64种组合但结合括号后实际有效的运算符插入模式即运算优先级结构是有限的。运算结构的枚举括号决定了运算的优先级。对于4个数本质上是二叉树的所有可能形态。常见的结构有((a b) (c d))、(a (b (c d)))、(((a b) c) d)等。我们需要枚举所有可能的二叉树结构。浮点数精度处理由于涉及除法结果可能是浮点数。判断结果是否等于24时不能直接用而需要判断其与24的差的绝对值是否小于一个极小的误差如1e-6。表达式生成与去重找到解法后需要生成可读的表达式字符串。同时不同的数字排列和运算符组合可能产生本质上相同的表达式如加法、乘法的交换律需要进行适当的去重或规范化以提供更友好的答案。2.2 技术栈选型与开发环境搭建为什么选择Visual Studio和原生C这是基于教学、性能和可控性的综合考量。开发环境Visual Studio 2022理由VS提供了业界最强大的C开发体验特别是其调试器对于理解递归、栈变化等复杂程序流至关重要。社区版完全免费对学习者极其友好。其集成的项目管理系统.sln,.vcxproj也让代码组织变得简单。项目创建创建一个新的“控制台应用”项目即可。确保使用C17或更新的标准以利用std::optional、结构化绑定等现代特性简化代码。核心C标准库组件vector,array用于存储数字集合、表达式元素和中间结果。algorithm特别是next_permutation函数用于优雅地生成数字的全排列避免了手动编写递归排列算法的麻烦。stack用于实现表达式求值这是本项目算法部分的核心数据结构。cmath使用fabs函数进行浮点数精度比较。randomC11取代传统的rand()和srand()提供更可靠、分布更均匀的随机数生成用于发牌。string,sstream用于构建和输出表达式字符串。注意许多教学代码仍在使用rand() % 13 1来生成1-13的随机数。这不仅是过时的而且存在分布不均匀、周期性等问题。在现代C中务必使用random库。2.3 系统架构设计整个程序可以划分为几个相对独立的模块遵循高内聚、低耦合的原则游戏控制模块 (GameController)负责游戏流程控制包括欢迎界面、发牌、接受用户输入、判断对错、显示答案、开始新游戏等。计算核心模块 (Solver)这是项目的“大脑”。接收4个数字穷举所有可能的表达式寻找结果为24的解并返回所有找到的表达式字符串。表达式求值模块 (Evaluator)被Solver调用。给定一个中缀表达式字符串如(12)*(34)计算其浮点数结果。这里需要处理运算符优先级和括号通常使用“双栈法”操作数栈和运算符栈将中缀表达式转为后缀表达式逆波兰式再求值或者直接递归求值。工具模块 (Utils)包含随机数生成器、精度比较函数、表达式规范化辅助函数等。这种架构的好处是Solver和Evaluator模块可以独立测试和复用。例如你可以轻易地将命令行界面替换为图形界面如Qt而核心算法无需改动。3. 核心算法实现细节与难点剖析3.1 穷举算法的实现排列、组合与结构这是本项目最核心的部分。一种直观且易于实现的算法思路是枚举数字的所有排列 - 枚举所有可能的运算符组合 - 枚举所有可能的括号结构运算顺序。步骤一数字全排列使用std::arraydouble, 4存储4个数字。利用std::next_permutation函数可以轻松遍历所有24种排列。记得在循环前先用std::sort对数组排序因为next_permutation要求输入序列是升序的。std::arraydouble, 4 nums {a, b, c, d}; std::sort(nums.begin(), nums.end()); do { // 对当前排列nums进行后续操作 } while (std::next_permutation(nums.begin(), nums.end()));步骤二运算符穷举运算符有4种填充在3个位置。我们可以用一个三层循环来生成所有64种组合。const std::arraychar, 4 ops {, -, *, /}; for (char op1 : ops) { for (char op2 : ops) { for (char op3 : ops) { // 当前运算符组合为 op1, op2, op3 } } }步骤三括号结构枚举这是难点。对于4个数字其运算本质是一棵有4个叶子节点数字的二叉树。我们可以递归地枚举所有可能的二叉树。一种实用的方法是“递归分割法”对于数字序列nums[0..3]我们尝试在所有可能的位置i进行分割将序列分成左子序列nums[0..i]和右子序列nums[i1..3]然后分别递归计算左右子序列所有可能的结果集合最后将左右集合的结果用当前运算符组合起来。// 伪代码递归函数返回给定数字序列所有可能的运算结果及对应表达式 vectorpairdouble, string getAllResults(vectordouble nums) { vectorpairdouble, string res; if (nums.size() 1) { // 基线条件只有一个数字直接返回该数字和它的字符串形式 res.emplace_back(nums[0], to_string(nums[0])); return res; } // 遍历所有分割点 for (int i 0; i nums.size() - 1; i) { auto leftResults getAllResults(截取nums[0..i]); auto rightResults getAllResults(截取nums[i1..end]); // 遍历左右所有结果用四种运算符组合 for (auto [lVal, lExp] : leftResults) { for (auto [rVal, rExp] : rightResults) { // 加法、乘法满足交换律可考虑去重 res.emplace_back(lVal rVal, ( lExp rExp )); res.emplace_back(lVal * rVal, ( lExp * rExp )); // 减法、除法注意顺序且除数不能为0 res.emplace_back(lVal - rVal, ( lExp - rExp )); if (fabs(rVal) 1e-6) { // 避免除零 res.emplace_back(lVal / rVal, ( lExp / rExp )); } // 也可以考虑右减左、右除左以覆盖更多情况 } } } return res; }调用getAllResults得到4个数字的所有可能结果和表达式后遍历结果集找出值接近24的项即可。实操心得这种递归分割法比显式地枚举括号位置更通用和清晰并且很容易扩展到更多数字虽然复杂度会爆炸式增长。它直接对应了表达式树的构建过程。3.2 表达式求值器的两种实现Solver需要频繁计算表达式的值。我们可以选择在生成表达式字符串后求值也可以在递归组合结果时直接计算数值。这里介绍两种求值器。方法一双栈法中缀转后缀求值这是数据结构课程的经典例题。算法步骤如下初始化一个操作数栈numStack和一个运算符栈opStack。遍历表达式字符串。遇到数字压入numStack。遇到运算符op如果opStack为空或栈顶是(直接压入。否则比较op与栈顶运算符的优先级。如果op优先级不高于栈顶则循环弹出栈顶运算符和两个操作数进行计算结果压回numStack直到条件不满足再将op压栈。遇到(直接压入opStack。遇到)循环弹出opStack中的运算符并计算直到遇到(弹出(。遍历结束后将opStack中剩余运算符依次弹出计算。最后numStack栈顶即为结果。方法二递归求值如果表达式是以二叉树或类似(leftExp op rightExp)的字符串形式生成的可以直接递归求值逻辑更直观且天然处理了优先级。double evaluate(const string exp) { // 假设exp是“(A op B)”的形式或者是一个纯数字 // 需要先解析出最外层的操作符和左右子表达式这是一个简化的示意 // 实际实现需要处理字符串解析的细节 }在“递归分割法”中我们实际上采用了类似方法二的思路在组合表达式时直接计算了数值避免了字符串的反复解析效率更高。3.3 精度处理与表达式规范化浮点数精度这是必须小心处理的坑。因为除法可能产生无限循环小数用double存储会有精度损失。判断两个浮点数a和b是否“相等”应使用bool isEqual(double a, double b) { return std::fabs(a - b) 1e-6; // 1e-6是一个常用的误差阈值 }在判断结果是否为24时就使用isEqual(value, 24.0)。表达式规范化与去重由于数字排列和运算符组合的穷举会产生大量重复或本质相同的解。例如(12)34和1(23)4计算结果相同但字符串不同。更复杂的加法和乘法满足交换律ab和ba是等价的。实现完全的去重比较复杂一个实用的简化策略是在递归分割时对于加法和乘法强制规定左子表达式的“某种值”如字符串哈希或排序后的数字序列不大于右子表达式否则不生成该分支。这需要定义一种表达式的规范形式。或者在收集所有解后对表达式字符串进行简单的标准化如去除不必要的括号然后使用std::unordered_setstring进行去重。对于教学项目也可以选择不去重或者只去除完全字符串相同的解这已经能大大改善输出。4. 完整实现流程与关键代码解读4.1 项目文件结构一个清晰的项目结构有助于管理代码。建议如下24PointGame/ ├── 24PointGame.sln # Visual Studio解决方案文件 ├── 24PointGame/ │ ├── 24PointGame.vcxproj # 项目文件 │ ├── Source Files/ │ │ ├── main.cpp # 程序入口游戏主循环 │ │ ├── GameController.cpp/.h # 游戏流程控制 │ │ ├── Solver.cpp/.h # 24点求解器核心 │ │ ├── ExpressionEvaluator.cpp/.h # 表达式求值器如果单独实现 │ │ └── Utils.cpp/.h # 工具函数 │ └── Resource Files/ # 如有图标等资源4.2 核心类与函数设计Solver类// Solver.h #pragma once #include vector #include string #include array class Solver { public: // 主求解函数输入4个数字返回所有找到的解法表达式 static std::vectorstd::string solve(const std::arraydouble, 4 numbers); private: // 递归分割法的核心实现 static std::vectorstd::pairdouble, std::string dfs(const std::vectordouble nums); // 辅助函数判断是否为24 static bool is24(double value); // 辅助函数生成规范化表达式可选 static std::string normalizeExp(const std::string exp); };GameController类// GameController.h #pragma once #include array class GameController { public: void run(); // 启动游戏 private: void showWelcome(); std::arraydouble, 4 drawCards(); // 随机发牌 void showCards(const std::arraydouble, 4 cards); bool getPlayerInput(std::string expression); // 获取玩家输入的表达式 bool validateExpression(const std::string exp, const std::arraydouble, 4 cards); // 验证表达式合法性 double calculateExpression(const std::string exp); // 计算表达式值 void showSolutions(const std::arraydouble, 4 cards); // 显示所有解 };4.3 主程序流程 (main.cpp)#include GameController.h int main() { // 设置随机数种子 std::random_device rd; std::mt19937 gen(rd()); GameController game; game.run(); return 0; }4.4 关键代码片段Solver::solve 的实现这里展示递归分割法求解的核心逻辑。注意为了清晰省略了详细的表达式字符串拼接和部分错误处理。std::vectorstd::string Solver::solve(const std::arraydouble, 4 numbers) { std::vectorstd::string solutions; std::vectordouble numList(numbers.begin(), numbers.end()); // 首先对数字排序用于生成排列 std::sort(numList.begin(), numList.end()); // 关键使用递归函数获取所有可能的结果 auto allResults dfs(numList); // dfs返回 vectorpair值, 表达式 for (const auto [value, expression] : allResults) { if (is24(value)) { // 可能需要对expression进行后处理比如去除最外层多余括号 std::string finalExp expression; // 简单去重检查solutions中是否已存在相同字符串的表达式 if (std::find(solutions.begin(), solutions.end(), finalExp) solutions.end()) { solutions.push_back(finalExp); } } } return solutions; } // 递归分割函数 std::vectorstd::pairdouble, std::string Solver::dfs(const std::vectordouble nums) { std::vectorstd::pairdouble, std::string res; if (nums.size() 1) { // 基线条件单个数字直接返回其值和字符串形式 // 注意整数直接转浮点数可能需处理.0的情况 std::string exp std::to_string((int)nums[0]); // 简单处理假设都是整数牌 res.emplace_back(nums[0], exp); return res; } // 遍历所有可能的分割点 for (size_t i 0; i nums.size() - 1; i) { // 分割左半部分 nums[0..i] std::vectordouble left(nums.begin(), nums.begin() i 1); // 分割右半部分 nums[i1..end] std::vectordouble right(nums.begin() i 1, nums.end()); auto leftRes dfs(left); auto rightRes dfs(right); // 组合左右两部分的所有结果 for (const auto [lVal, lExp] : leftRes) { for (const auto [rVal, rExp] : rightRes) { // 加法 res.emplace_back(lVal rVal, ( lExp rExp )); // 乘法 res.emplace_back(lVal * rVal, ( lExp * rExp )); // 减法 (左减右) res.emplace_back(lVal - rVal, ( lExp - rExp )); // 减法 (右减左) - 覆盖更多情况 res.emplace_back(rVal - lVal, ( rExp - lExp )); // 除法 (左除以右)确保除数不为零 if (std::fabs(rVal) 1e-6) { res.emplace_back(lVal / rVal, ( lExp / rExp )); } // 除法 (右除以左) if (std::fabs(lVal) 1e-6) { res.emplace_back(rVal / lVal, ( rExp / lExp )); } } } } return res; }5. 常见问题、调试技巧与优化方向5.1 编译与运行常见问题“无法打开源文件...”或“未定义的标识符”检查确保所有.cpp文件都已添加到项目的“源文件”筛选器中所有.h文件都在“头文件”筛选器下或在#include时使用了正确的相对或绝对路径。检查在Visual Studio中右键点击项目 - “属性” - “C/C” - “常规” - “附加包含目录”确保包含了自定义头文件所在的目录。程序运行后立即退出在main函数末尾return 0;之前添加system(pause);或std::cin.get();以便在控制台窗口停留查看输出。更推荐的做法是在VS中按CtrlF5开始执行不调试运行程序结束后会提示“按任意键继续”。随机数每次运行都一样你很可能错误地使用了std::mt19937 gen(123);固定种子。确保使用std::random_device来获取真随机种子std::mt19937 gen(std::random_device{}());。5.2 算法逻辑调试技巧输出中间结果在递归函数dfs的关键位置如进入函数、组合结果时打印当前的数字集合、左右部分的值和表达式可以帮助你理解程序的枚举过程并发现逻辑错误。使用小型测试用例不要一开始就用4个随机数测试。使用{1, 2, 3, 4}这样有明确解的例子手动推导几个解然后对比程序输出。利用Visual Studio调试器设置断点在Solver::solve和dfs函数开始处设置断点。监视窗口添加监视nums当前数字向量、left、right、res等变量。调用堆栈当程序停在递归深处时查看“调用堆栈”窗口可以清晰地看到递归的层级和每一步的参数对于理解递归流程至关重要。逐语句(F11)与逐过程(F10)灵活使用深入函数内部或跳过函数调用。5.3 性能优化与扩展思路剪枝优化在递归组合时如果中间结果已经出现非有限值如无穷大INF、NaN或者已经远远超过24例如在纯乘法中数字都大于1结果很快会远大于24可以提前终止该分支的进一步计算节省大量时间。记忆化搜索对于相同的数字集合不考虑顺序其所有可能的运算结果集合是相同的。可以使用一个哈希表mapvectordouble, vectorpairdouble, string来存储已经计算过的数字集合的结果避免重复计算。这对于多次求解或数字更多时效果显著。并行计算数字的全排列是相互独立的。可以使用C11的thread或future库将不同的排列分配给多个线程同时求解充分利用多核CPU。注意线程间共享数据如结果容器需要加锁或使用线程安全容器。支持更多运算符可以很容易地扩展支持乘方^、阶乘!等运算符只需在递归组合部分增加相应的运算分支并注意新运算符的优先级和定义域如阶乘要求整数且不能太大。图形化界面使用Qt、wxWidgets或简单的Windows API为游戏添加图形界面。将发牌显示为扑克牌图片提供数字和运算符按钮让用户点击组合并实时验证表达式体验会好很多。5.4 表达式验证的陷阱在GameController::validateExpression中验证玩家输入的表达式是否合法是一个挑战。你需要检查是否只使用了给定的4个数字且每个数字只用了一次。是否只使用了允许的运算符。括号是否匹配。除数是否为零在求值前做语法检查时可能难以判断可以在求值过程中捕获异常或检查结果是否为INF/NaN。一个相对稳健的做法是将玩家输入的表达式字符串用你自己的表达式求值器或一个简化版的解析器解析成一棵语法树或一个RPN序列在这个过程中同时检查语法和数字使用情况。如果解析成功且使用了正确的数字再计算其值并与24比较。这个用Visual Studio和C实现二十四点游戏的项目虽然代码量不大但几乎触及了初级到中级C编程的多个关键知识点从基本的控制流、函数、类到STL容器的使用、算法库的调用再到递归、回溯、栈应用等核心算法思想最后还涉及浮点数精度、字符串处理等实用技巧。它完美地诠释了“小游戏大道理”。在实现过程中最深的体会是设计清晰的程序结构比一开始就追求完美的算法更重要。先让程序能正确跑起来得到结果然后再考虑优化如剪枝、去重和扩展如GUI。调试递归函数时耐心和调试器的熟练使用是成功的关键。最后将这个项目作为你C学习路上的一个里程碑它的完整实现会给你带来巨大的成就感并为你理解更复杂的软件系统打下坚实的基础。