尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++国际象棋引擎开发:从数据结构到AI搜索算法实战

C++国际象棋引擎开发:从数据结构到AI搜索算法实战 1. 项目概述为什么用C写国际象棋程序如果你对C编程有一定兴趣同时又是个棋类爱好者那么用C亲手打造一个国际象棋程序绝对是一个能让你编程水平和算法思维都得到巨大提升的“硬核”项目。这听起来可能有点复古毕竟现在AI下棋都直接上深度学习了但恰恰是这种“复古”能让你触及计算机科学中一些最经典、最核心的思想。它不是一个简单的“小游戏”而是一个融合了数据结构、算法设计、性能优化和软件工程实践的综合性工程。这个项目的核心价值在于它迫使你思考如何将现实世界的复杂规则国际象棋的走法、吃子、特殊规则如王车易位、吃过路兵、升变等精确地转化为计算机能理解和处理的数据与逻辑。你需要设计一个高效的棋盘表示法实现一个能生成所有合法走法的引擎并最终构建一个能评估局面优劣、并向前“思考”几步的AI。整个过程就像是在用代码搭建一个微型的、确定性的世界。对于学习者而言完成这个项目你对指针、内存管理、STL容器、递归、搜索算法如极小化极大算法、Alpha-Beta剪枝的理解会深刻得多。对于有经验的开发者这则是一个绝佳的优化试验场你可以尝试引入位棋盘、置换表、开局库、终局库等高级技术甚至挑战多线程并行搜索体验将计算性能压榨到极致的乐趣。我最初写这个程序就是为了解决一个很实际的问题想找一个既能锻炼底层编码能力又有明确规则和胜负标准的项目。网上很多教程要么太浅只画个棋盘要么直接调用现成的引擎库。自己从头实现一遍踩过所有的坑之后你会发现那些经典的算法书突然变得无比亲切。接下来我就把自己从零构建一个具备基础AI对战功能的C国际象棋程序的完整过程、核心思路和踩坑经验分享给你。2. 核心架构与数据结构设计写程序尤其是这种逻辑复杂的程序最忌讳一上来就埋头敲代码。设计好数据结构就成功了一半。对于国际象棋程序核心的数据结构就是如何表示棋盘和棋子。2.1 棋盘表示法的选择与实现最常见的棋盘表示法有两种数组表示法和位棋盘表示法。对于初学者和大多数情况我强烈推荐从数组表示法开始它直观易懂便于调试。数组表示法很简单用一个8x8的二维数组或者一个大小为64的一维数组来表示棋盘。每个格子存储一个值代表该位置上的棋子如‘K’代表白王‘q’代表黑后或者是空位。我最初用的是char board[8][8]。// 简单的8x8字符数组表示 char board[8][8] { {r, n, b, q, k, b, n, r}, {p, p, p, p, p, p, p, p}, { , , , , , , , }, // ... 初始化空行 {P, P, P, P, P, P, P, P}, {R, N, B, Q, K, B, N, R} }; // 小写代表黑方大写代表白方。这种方法的好处是极其直观打印棋盘、根据坐标查找棋子都非常方便。但是在生成走法、判断将军等需要大量遍历棋盘的操作时效率是硬伤。你需要不断循环检查64个格子。当你对基本逻辑驾轻就熟开始追求性能时位棋盘Bitboard就是必经之路。这是国际象棋引擎领域的标准做法。其核心思想是用一个64位的整数如uint64_t来表示一类棋子例如所有白兵在棋盘上的位置。每一位对应棋盘上的一个格子通常a1对应最低位h8对应最高位1表示有该棋子0表示没有。// 用位棋盘表示白兵 uint64_t whitePawns 0x000000000000FF00ULL; // 初始位置在第2行rank 2 // 用位棋盘表示黑王 uint64_t blackKing 0x0800000000000000ULL; // 初始位置在e8格位棋盘的优势在于许多棋盘操作可以通过极其高效的位运算来完成。例如计算白兵所有可能的向前走一步的走法可以利用位移操作。判断一个格子是否被攻击可以通过预生成的“攻击掩码”表进行按位与运算。这比数组遍历快了几个数量级。在我的项目后期将数组表示重构为位棋盘表示后走法生成速度提升了近百倍。当然它的缺点是代码可读性下降调试更为困难。我的建议是第一版用数组实现确保所有规则正确性能优化时再考虑重构成位棋盘。2.2 走法Move的数据结构设计如何表示“一步棋”这是一个关键的设计点。一步棋至少需要包含起始位置、目标位置。此外还需要考虑特殊走法吃子需要知道被吃的棋子是什么、兵升变升变成什么棋子、王车易位、吃过路兵。一个紧凑而通用的设计是使用一个整数如32位int来编码所有这些信息。通过位域操作来存取不同的部分。struct Move { int from; // 起点索引 (0-63) int to; // 终点索引 (0-63) int piece; // 移动的棋子类型 int captured; // 被吃的棋子类型 (若无则为空) int promotion; // 升变类型 (如升变为皇后) bool isCastle; // 是否是王车易位 bool isEnPassant; // 是否是吃过路兵 // ... 其他标志位 }; // 或者更紧凑的用一个32位整数编码 // 位 0-5: 起点 (0-63) // 位 6-11: 终点 (0-63) // 位 12-15: 移动的棋子类型 // 位 16-19: 被吃棋子/升变棋子类型 // 位 20-23: 特殊标志位易位、过路兵等 typedef uint32_t CompactMove;我最初使用了结构体因为可读性好方便调试。在实现了AI搜索后发现需要存储和比较大量的走法这时将Move对象替换为CompactMove整数并配合预生成的走法表节省了大量内存也提升了缓存命中率对搜索速度有显著帮助。2.3 游戏状态GameState的管理除了当前棋盘程序还需要维护一些全局状态信息这些信息对于走法生成和局面评估至关重要当前行棋方轮到白方还是黑方走棋。王车易位权记录双方是否还保留长易位或短易位的权利。一旦王或相应的车移动过该权利即丧失。吃过路兵目标格如果对方上一步走了兵进两格那么本方案可以在特定条件下“吃过路兵”。需要记录这个目标格坐标。半回合计数用于判断“五十步和棋”规则。从上次吃子或兵移动开始计步。总回合数。历史走法列表用于判断“三次重复局面和棋”也可以用于实现悔棋功能。将这些状态封装在一个GameState或Position类中是明智的做法。这样在进行AI搜索时可以方便地保存当前状态、执行一步假想的走法、评估局面然后撤销这步走法Undo回溯到之前的状态。“撤销”功能的设计至关重要它避免了在搜索树的不同分支间反复拷贝整个棋盘状态的开销。我的做法是在执行走法时将改变前的相关信息如被吃棋子、易位权变化等压入一个栈撤销时再弹出来恢复。3. 走法生成引擎规则的核心实现走法生成是引擎最复杂、最容易出bug的部分。你的程序必须能为一方的所有棋子生成在当前局面下所有符合规则的合法走法。注意是“合法”走法即走完之后不能让自己处于被将军的状态。3.1 分棋子类型生成候选走法我的实现策略是为每种棋子类型王、后、车、象、马、兵编写一个独立的生成函数。这些函数基于当前棋盘生成该棋子所有“符合棋子基本走法规则”的移动目标格我称之为“候选走法”。这一步暂时不检查是否会导致己方被将军。王和后后可以看作车和象的结合。生成逻辑是沿着横、竖、斜八个方向一直走到碰到棋盘边界或被己方棋子挡住为止。如果碰到对方棋子则生成一个吃子走法并停止。车和象原理同上车只走横竖象只走斜线。马马走“日”字检查8个可能的目标格如果目标格为空或是对方棋子则生成走法。兵兵最特殊。分为向前走一格如果目标格为空、向前走两格如果兵在初始位置且前方两格为空、斜向吃子如果目标格有对方棋子。此外还需要处理吃过路兵如果对方上一步的兵移动到了我方面前相邻列并且是进了两格那么我方的兵可以斜向走到对方兵身后的格子将其吃掉。兵升变当兵走到对方底线对于白兵是第8行黑兵是第1行时生成的走法需要包含升变选项后、车、象、马。注意生成兵的走法时必须考虑“兵不能从起始位置跳过被阻挡的格子前进两格”。例如白兵在e2如果e3有棋子那么它既不能走到e3也不能走到e4。很多初学者在这里会漏掉对e3的检查。3.2 合法性校验与将军检测生成了所有候选走法后最关键的一步是过滤掉那些会导致己方王被将军的非法走法。这就是“合法性校验”。最直接但低效的方法是模拟执行一步候选走法然后检查执行后己方王是否被对方任何棋子攻击。如果被攻击则这步走法非法。检查王是否被攻击可以调用一个isSquareAttacked(square, attackerColor)函数这个函数遍历对方所有棋子看它们是否能攻击到王所在的格子。这里有一个非常重要的优化技巧你不需要为每一步候选走法都全盘扫描检查是否被将军。对于大多数走法王的位置根本没变。你可以先计算当前局面下王是否已经被将军。如果已经被将军那么你的走法必须解除将军例如吃掉将军的棋子、垫将或将王移开能解除将军的走法才是合法的。如果当前没有被将军那么你的走法不能“露将”即移动后把自己的王暴露在对方攻击之下。在实现时可以优先处理将军的情况减少不必要的模拟执行和攻击检测次数。我的踩坑经验最初我忘了在生成走法时考虑“王车易位”的特殊合法性条件。易位不仅要看王和车从未移动过还要检查王和车之间的格子必须为空。王在移动过程中经过的格子王本身的位置、目标位以及中间格不能处于被对方攻击的状态。王当前不能处于被将军的状态。 我因为漏掉了条件2导致程序允许在王被“将军”的路径上易位这严重违反了国际象棋规则调试了很久才发现。3.3 特殊规则的处理要点王车易位如上所述合法性检查是重点。在数据结构上需要用标志位记录双方是否还拥有易位权利并在每次移动王或车时更新这些标志。吃过路兵关键在于时机。这个权利只存在于对方移动兵之后的紧接着的一步。在你的GameState中记录下“吃过路兵目标格”生成走法时兵只有在特定条件下才能走到这个格子上并吃掉对方的兵对方兵不在目标格而在其相邻的前一格。兵升变在生成走法时如果兵走到了底线不要只生成一种走法。应该生成四个不同的走法对象分别代表升变为后、车、象、马。在AI搜索中升变通常默认选择升变为后因为后价值最高但在某些特殊和棋局面下升变为马或车可能是强制性的避免无子可动和棋你的引擎也需要能处理这种情况。4. 局面评估与AI搜索算法有了走法生成程序已经可以让人机轮流走棋了。但要让它变得“聪明”就需要一个能评估局面好坏并向前思考几步的AI。这通常分为两部分局面评估函数和搜索算法。4.1 构建简单的局面评估函数评估函数的作用是给一个棋盘局面打一个分数正分表示白方优势负分表示黑方优势。一个最基础的评估函数只考虑“子力价值”王无限大或一个很大的值如20000后900车500象330马320兵100评估分数 Σ(己方棋子价值) - Σ(对方棋子价值)。这就是“物质”评估。但仅此远远不够。好的引擎还会考虑“位置”评估兵形叠兵、孤兵、落后兵是弱形。棋子活动性象、马、车控制的中心格数量。王的安全王周围是否有兵阵保护是否处于开阔地。中心控制对d4, d5, e4, e5四个中心格的控制力。我最初只实现了子力价值AI表现得像个“守财奴”只会换子毫无 positional sense。后来我加入了一个简单的“棋子位置价值表”Piece-Square Table例如鼓励马走向中心鼓励兵向前推进。效果立竿见影AI开始有了一些基本的局面概念。// 一个简单的白马中局位置价值表从白方视角a1是0h8是63 int knightTable[64] { -50,-40,-30,-30,-30,-30,-40,-50, -40,-20, 0, 0, 0, 0,-20,-40, -30, 0, 10, 15, 15, 10, 0,-30, -30, 5, 15, 20, 20, 15, 5,-30, -30, 0, 15, 20, 20, 15, 0,-30, -30, 5, 10, 15, 15, 10, 5,-30, -40,-20, 0, 5, 5, 0,-20,-40, -50,-40,-30,-30,-30,-30,-40,-50, }; // 在评估时除了棋子本身价值再加上它在位置表中的附加值。4.2 极小化极大算法与Alpha-Beta剪枝AI如何“思考”最核心的算法是极小化极大算法。其思想是假设双方都绝对理性白方我方会选择让自己评估分数最高的走法而黑方对方会选择让白方评估分数最低的走法。程序通过递归模拟未来几步可能发生的情况来倒推当前应该走哪一步。例如思考深度为33层递归层1白方走生成所有白方走法。层2黑方应对白方的每一种走法生成黑方所有应着。层3白方再应对黑方的每一种应着再次生成白方的走法。到达深度后调用评估函数给这个最终局面打分。回溯层3的白方会选择对自己最有利分数最高的走法这个分数就是层2黑方应对该走法后局面的分数。层2的黑方则会选择对所有白方走法中最不利分数最低的那个分数作为对层1白方那步走法的回应分数。最后层1的白方选择能获得最高回应分数的那步走法。直接实现极小化极大算法搜索树会爆炸性增长分支因子约35深度为N时约有35^N个节点。Alpha-Beta剪枝是必须的优化。它能在不影响最终结果的前提下剪掉大量不必要的分支搜索。其核心是维护两个值alpha当前路径白方至少能得到的分数下界和beta当前路径黑方至多允许白方得到的分数上界。在搜索过程中如果发现某个分支的分数已经不可能比已知的最佳选择更好就立即停止搜索该分支。int alphaBeta(int depth, int alpha, int beta) { if (depth 0) return evaluatePosition(); // 到达搜索深度评估局面 vectorMove moves generateAllLegalMoves(); // 对走法进行排序能极大提升Alpha-Beta剪枝效率好的走法先搜索 orderMoves(moves); for (Move move : moves) { makeMove(move); // 执行走法 int score -alphaBeta(depth - 1, -beta, -alpha); // 注意分数取反和alpha/beta互换 unmakeMove(move); // 撤销走法 if (score beta) { return beta; // Beta剪枝 } if (score alpha) { alpha score; // 更新最佳值 if (depth maxDepth) { bestMove move; // 记录根节点的最佳走法 } } } return alpha; }一个关键技巧走法排序。Alpha-Beta剪枝的效率极度依赖于走法被搜索的顺序。如果最好的走法能最先被搜索到就能剪掉更多分支。我的排序启发式规则是先搜索吃子走法特别是“吃大子”。搜索产生威胁的走法将军、攻击重要格子。搜索 killer moves在搜索树其他分支中导致剪枝的走法在当前位置也可能有效。最后搜索安静的走法。4.3 迭代加深与时间控制你应该实现迭代加深。即先搜索深度1得到最佳走法和分数再搜索深度2深度3……直到分配的时间用完。这样做有几个好处时间控制你可以在任何时候中断搜索并返回当前最深度的最佳走法保证程序能在限定时间内出招。走法排序优化前一次较浅搜索得到的最佳走法可以在下一次更深搜索时作为第一个走法来搜索提升剪枝效率。局面性搜索对于非常复杂的战术局面可能深度6都算不完但深度5的结果已经可用了。在我的实现中主循环是这样的Move searchBestMove(int maxTimeMs) { auto startTime std::chrono::steady_clock::now(); Move bestMoveSoFar; for (int depth 1; depth MAX_DEPTH; depth) { int score alphaBetaRoot(depth); // 根节点的Alpha-Beta搜索 auto currentTime std::chrono::steady_clock::now(); auto elapsed std::chrono::duration_caststd::chrono::milliseconds(currentTime - startTime); if (elapsed.count() maxTimeMs) { break; // 时间到返回上一深度的结果 } // 如果搜索正常完成更新最佳走法 if (!searchWasAborted) { // 还需要一个中断标志 bestMoveSoFar currentBestMove; } } return bestMoveSoFar; }5. 性能优化与高级特性探索当基础功能完成后你可以进入“发烧友”阶段尝试以下优化和高级特性这能让你的引擎实力产生质的飞跃。5.1 置换表避免重复计算在搜索树中不同的走法顺序可能导致相同的棋盘局面被多次搜索到。置换表就是一个缓存用来存储已经搜索过的局面的结果分数、最佳走法、搜索深度等。当再次遇到相同局面时可以直接查表避免重复搜索。这通常使用Zobrist哈希来为棋盘局面生成一个几乎唯一的64位哈希键。实现置换表后我的引擎在相同时间内搜索深度平均增加了1到2层对于中残局尤其有效。需要注意的是置换表需要处理哈希冲突并且存储的信息有深度限制浅深度搜索的结果不能覆盖深度的结果。5.2 开局库与终局库开局库存储成千上万盘职业棋手对局的开头十几步。在游戏开始时如果当前局面存在于开局库中就直接从库中随机选择一个走法。这能保证你的引擎开局不亏并且节省大量计算时间。你可以使用标准的ECOEncyclopaedia of Chess Openings格式的开局库文件。终局库对于子力很少的残局例如王单兵对王存在理论上的必胜或必和走法。终局库如Syzygy Tablebases存储了所有这些局面的完美结果。当局面子力少到可以查库时引擎可以直接给出最优走法实现“上帝模式”。这对于测试引擎的残局功力非常有用。5.3 多线程并行搜索现代CPU都是多核的让搜索算法并行化能极大提升强度。最常用的方法是主从式并行搜索一个主线程管理整个搜索树将不同的根走法分配给不同的工作线程去搜索。或者更复杂一点使用Young Brothers Wait Concept等算法在搜索树内部进行并行。我尝试过使用C11的std::thread和std::async来实现简单的并行。将根节点的前几个走法分配给不同线程同时搜索最后汇总结果。这带来了接近线程数量的性能提升在4核机器上搜索速度提升了约3倍。但多线程引入了数据竞争和复杂的状态管理调试起来非常头疼。务必确保你的走法生成和局面执行/撤销函数是线程安全的或者为每个线程准备独立的上下文。5.4 与图形界面或UCI协议对接一个纯命令行的引擎可玩性不高。你可以自己写一个简单的图形界面使用像SFML、SDL2或甚至控制台图形库来绘制棋盘处理鼠标点击事件。这能让你更直观地测试和展示你的引擎。实现UCI协议UCIUniversal Chess Interface是象棋引擎与图形界面如Arena, ChessBase, lichess.org通信的标准协议。实现UCI后你的引擎就可以在任何支持UCI的图形界面中运行与世界上其他引擎对战。UCI协议基于标准输入输出定义了一套简单的文本命令如position startpos moves e2e4 e7e5,go depth 6你需要解析这些命令并作出响应。实现UCI是我觉得最有成就感的一步看到自己的引擎在专业界面上运行并与其他引擎对战感觉完全不同。6. 调试、测试与性能分析开发这样一个复杂程序没有系统的调试和测试策略是行不通的。6.1 单元测试与局面验证走法生成测试找一些经典局面如开局、中局战术组合、残局手动或借助其他可靠引擎列出所有合法走法。用你的程序生成走法列表进行比对。确保数量一致且每个走法都正确。特殊规则测试专门构造测试局面验证王车易位、吃过路兵、兵升变、将军、将死、无子可动逼和等情况是否正确处理。Perft测试这是象棋引擎开发中标准的调试工具。Perft函数计算在给定深度内所有可能走法序列的数量不进行剪枝。网上有大量标准局面的Perft结果例如初始局面深度6的Perft结果是119060324。让你的程序计算结果并与之对比是发现走法生成bug最有效的方法。6.2 性能分析与瓶颈定位当你的引擎变慢时需要知道时间花在哪里。使用性能分析工具如Visual Studio的Profiler,gprof,perf。我最初的性能分析显示超过60%的时间花在了isSquareAttacked函数上因为它为每个候选走法都做了全盘扫描。这促使我优化了攻击检测逻辑并最终推动了向位棋盘表示的改革。另一个常见的瓶颈是评估函数。如果评估函数计算过于复杂比如计算所有棋子的攻击关系它会在搜索树的叶子节点被调用成千上万次。确保评估函数尽可能高效多用查表少用复杂循环。6.3 对弈测试与Elo等级分估算如何知道你的引擎有多强让它和其他引擎对战。寻找对手可以从一些简单的开源引擎开始如Sunfish in Python然后挑战更强大的如Stockfish, Komodo的简化版。组织循环赛使用像Cute Chess这样的命令行工具可以自动安排多盘比赛并计算Elo等级分。Elo分是国际象棋通用的实力衡量标准。一个只实现了基础子力评估和Alpha-Beta搜索的引擎大概能有1000-1200的Elo分业余爱好者水平。加入了位置评估、走法排序、置换表后可以上升到1500-1800。如果再实现开局库和更复杂的搜索优化突破2000分也是可能的当然离顶尖引擎的3500分还有巨大差距。分析棋局仔细复盘引擎输掉的棋局。是因为漏算了战术还是评估函数有缺陷导致它错误地放弃了优势通过分析败局来针对性改进是提升引擎实力最直接的方法。7. 常见问题与实战排错记录在开发过程中我遇到了无数稀奇古怪的bug。这里记录几个最具代表性的希望能帮你避坑。问题一AI有时会送子走明显吃亏的棋。排查首先检查评估函数。是不是某个棋子的价值设错了比如把后的价值设成了和车一样。然后检查走法生成是否漏掉了某些吃子走法最后也是我最常犯的错误在Alpha-Beta搜索中分数取反和alpha/beta参数传递搞错了。递归调用时必须是-alphaBeta(..., -beta, -alpha)这个负号很容易漏掉或写错位置导致AI的决策逻辑完全颠倒。问题二搜索深度增加后程序运行速度没有预期中变慢那么多甚至有时更快了排查这通常是Alpha-Beta剪枝和走法排序生效的好现象。但如果快得离谱可能是搜索提前终止的bug。检查你的递归终止条件。除了深度为0是否还应该在“将死”或“逼和”局面时立即终止并返回一个极大/极小值我遇到过因为将军检测函数有误导致程序认为局面永远无法将死从而陷入近乎无限搜索的情况。问题三实现了置换表后引擎实力反而下降了走一些明显更差的棋。排查置换表引入的典型问题。哈希冲突两个不同的局面哈希到了同一个表项导致错误的搜索结果被使用。尝试增加哈希表大小或使用更复杂的冲突解决策略如双哈希。信息覆盖错误深度更深的搜索结果覆盖了深度浅但分数更好的结果或者反之需要仔细设计置换表表项的数据结构包含分数类型精确值、下界、上界、搜索深度并制定正确的存储和读取策略。局面哈希不包含全部状态你的Zobrist哈希值必须包含所有影响局面唯一性的信息包括易位权、吃过路兵目标格、当前行棋方。我最初只哈希了棋子位置导致黑方走完和白方走完的相同棋子布局被误认为是同一局面引发灾难性错误。问题四在多线程搜索中程序偶尔会崩溃或给出随机的最佳走法。排查这是数据竞争的典型症状。全局变量你的棋盘状态board、走法列表moveList等是否是全局的多个线程同时读写这些变量必然导致混乱。必须确保每个线程有自己独立的上下文ThreadData或者使用锁但锁会严重影响性能。伪随机数生成器如果你的走法排序或哈希键生成用了随机数且使用全局的rand()它在多线程下行为未定义。使用C11的random库并为每个线程创建独立的随机数引擎。置换表共享多个线程共享一个置换表进行读写是性能优化的关键但也必须加锁使用细粒度锁或原子操作或使用“锁无关”的设计否则表内数据会被破坏。问题五引擎在残局中表现极其愚蠢比如有必胜的兵升变机会却来回走王。排查这通常是地平线效应的体现。由于搜索深度有限AI看不到足够远的未来。比如它看到推进兵会导致在搜索深度内被对方吃掉就认为不好于是选择“拖延”。但实际上推进兵是致胜的关键。缓解方法实现静止搜索在达到常规搜索深度后不立即评估而是继续搜索所有“吃子”走法序列直到局面“安静”下来没有直接吃子威胁。这能有效避免因为搜索深度不足而漏算战术组合。调整评估函数在残局中王的活跃度变得极其重要。修改评估函数在子力很少时鼓励王走向中心、走向对方的兵。同时给“通路兵”前方无对方兵阻挡的兵和“远方通路兵”更高的奖励引导引擎去创造和推进通路兵。开发一个完整的C国际象棋引擎是一个漫长的旅程但每一步的突破都带来巨大的满足感。从最初一个只能摆棋子的棋盘到一个能和你进行基础对弈的程序再到一个拥有开局库、能进行多线程思考、可以通过UCI协议与外界对话的“准专业”引擎整个过程就像在精心打磨一件复杂的机械艺术品。最宝贵的收获不是最终的程序而是在解决无数个具体问题中对算法、数据结构、系统设计和调试方法产生的深刻理解。如果你正在寻找一个能全面锤炼C功力的项目我找不出比这更经典、更富挑战性、也更有趣的选择了。
返回列表