华为OD真题“竖直四子棋”详解:从算法实现到工程避坑

发布时间:2026/7/27 7:46:10

华为OD真题“竖直四子棋”详解:从算法实现到工程避坑 1. 项目概述从一道华为OD真题看编程实战最近在技术社区和求职圈里华为OD的机试真题热度一直居高不下尤其是C卷的题目常常成为大家讨论和模拟练习的重点。我注意到一道名为“竖直四子棋”的题目标称是200分且100%通过率的真题。这立刻引起了我的兴趣因为“四子棋”本身是一个经典的博弈类问题而“竖直”这个限定词又暗示了规则上可能有的变化。对于正在准备类似机考或者单纯想提升自己C/C算法与工程实现能力的朋友来说深入剖析这道题远不止是得到一份“能AC的代码”那么简单。它更像是一个完整的项目缩影涵盖了问题理解、逻辑抽象、数据结构设计、核心算法实现、边界处理以及代码健壮性测试的全流程。今天我就结合自己多年的开发经验把这道题掰开揉碎了讲不仅给出思路和代码更重点分享在实现过程中那些容易踩坑的细节和调试心得希望能帮你真正吃透这一类问题。2. 问题深度解析与核心逻辑建模拿到任何算法题第一步也是最关键的一步就是彻底理解题意。题目描述通常是简洁的但魔鬼藏在细节里。2.1 “竖直四子棋”规则还原与抽象根据常见的“四子棋”Connect Four和“竖直”这个修饰语我们可以推断出题目的核心规则。标准四子棋是在一个水平的网格中棋子受重力下落玩家轮流在某一列顶部放入棋子棋子会落到该列最低的空位。获胜条件是横、竖、斜两种对角线方向有四个己方棋子连成一线。那么“竖直”四子棋有何不同我推测这里的“竖直”可能强调了棋盘的方向性或落子规则的特异性。一种合理的解读是棋盘是竖直放置的即我们通常看到的棋盘旋转了90度。但这对于计算机内部的二维数组表示来说只是坐标映射的问题本质不变。另一种更可能、也更能增加题目复杂度的解读是棋子不再受“重力”影响下落至最低处而是严格放置在所选择列的“顶部”并且后续棋子会堆叠在已有棋子之上。这听起来和标准规则一样请注意在标准的水平棋盘视角下“顶部”就是第一行。但在竖直视角下“顶部”可能对应的是数组的起始索引如[0][col]。关键在于无论视角如何落子的逻辑是固定的在选定列col从该列的第一个空位可能是从上往下找也可能是从下往上找放入棋子。因此我们需要从题目描述虽然这里未给出原文中确认几个核心点这在机试中至关重要棋盘规模行数R和列数C是多少常见的是6行7列但考题可能变化。落子规则给定一个列号col假设从0或1开始索引棋子应放在该列的哪个位置是找到该列中第一个为“空”的行索引。获胜判定需要检查四个方向水平同一行、垂直同一列、主对角线从左上到右下、副对角线从右上到左下。只要有任意方向满足连续四个相同棋子游戏立即结束当前落子玩家获胜。输入输出格式输入如何给出是一连串的落子列序列吗输出是什么是每一步后的棋盘状态还是最终获胜者和步数或者是“Draw”平局注意在真实的华为OD考试中务必仔细阅读题目说明中的每一句话、每一个示例。我见过太多人因为忽略了“棋盘满员即平局”或者“列号从1开始”这样的细节而丢分。这里我们基于最常见的情况进行建模一个R行C列的网格玩家1和玩家2轮流输入一个有效的列号棋子落入该列最低的空位假设第0行是顶部第R-1行是底部。如果落子后导致四子连珠则当前玩家胜如果棋盘下满仍未分胜负则为平局。2.2 数据结构设计与选择明确了规则接下来就要为这个游戏世界选择合适的数据容器。这直接影响到后续代码的简洁性和效率。方案一二维向量/数组最直观的使用vectorvectorint board(R, vectorint(C, 0))或者原生二维数组int board[R][C]。用0表示空位1和2分别表示两位玩家的棋子。优点访问任意位置board[row][col]非常快符合人类思维直觉便于调试时打印棋盘。缺点在判断某一列是否已满或者找到该列第一个空位时需要遍历该列的所有行。在R和C不大比如10的情况下这完全不是问题。方案二为每一列维护一个“高度”数组由于落子只依赖于列的状态我们可以额外使用一个数组vectorint colHeight(C, 0)记录每一列当前已经有多少颗棋子即下一个棋子的行索引。这样当玩家选择列c时下一个空位的行就是colHeight[c]。落子后执行board[colHeight[c]][c] player; colHeight[c]。优点将落子操作的时间复杂度从O(R)降低到O(1)代码更精炼。缺点需要额外维护一个数组但空间开销极小。对于这道题两种方案都可以。但方案二更能体现对问题特性的优化思考这在机试中可能是加分项。我们选择方案二作为基础。方案三位棋盘这是一种极致的优化用整数的每一个bit来代表棋盘上的一个位置状态。对于四子棋由于每个点有三种状态空、玩家1、玩家2可能需要两个bit位图。这种方法在追求极限性能的AI对弈中常见但对于机试和日常理解而言过于复杂不推荐。因此我们的核心数据结构如下int rows, cols; // 棋盘行数和列数 vectorvectorint board; // 棋盘状态0为空1为玩家12为玩家2 vectorint colHeight; // 每列当前高度即该列下一个棋子应放的行索引 int currentPlayer; // 当前玩家1或23. 核心算法实现与关键代码拆解有了清晰的数据模型我们就可以动手实现核心逻辑了。整个程序可以划分为几个清晰的模块。3.1 初始化与棋盘状态管理首先我们需要根据输入初始化棋盘。假设题目输入第一行是行数R和列数C后续是一系列落子列假设从0开始索引。// 初始化棋盘 rows R; cols C; board.assign(rows, vectorint(cols, 0)); // 所有位置初始化为0空 colHeight.assign(cols, 0); // 所有列高度初始化为0 currentPlayer 1; // 玩家1先手一个良好的习惯是编写一个打印棋盘的函数用于调试这在复杂逻辑中至关重要。void printBoard() { // 逆序打印让最后落子的底部在控制台下方更符合观看习惯 for (int i rows - 1; i 0; --i) { for (int j 0; j cols; j) { char c; switch(board[i][j]) { case 0: c .; break; case 1: c X; break; // 玩家1 case 2: c O; break; // 玩家2 default: c ?; } cout c ; } cout endl; } // 打印列号方便查看 for (int j 0; j cols; j) cout --; cout endl; for (int j 0; j cols; j) cout j ; cout endl endl; }3.2 落子操作与有效性校验这是游戏的核心驱动函数。每次落子需要检查列号是否合法。检查该列是否已满。放置棋子。更新列高度。检查是否获胜或平局。// 返回值-1表示无效操作0表示正常落子未结束1表示玩家1胜2表示玩家2胜3表示平局 int dropPiece(int col) { // 1. 校验列号 if (col 0 || col cols) { return -1; // 无效列 } // 2. 校验该列是否已满 if (colHeight[col] rows) { return -1; // 该列已满无法落子 } // 3. 放置棋子 int row colHeight[col]; board[row][col] currentPlayer; // 4. 更新列高度 colHeight[col]; // 5. 检查游戏状态 if (checkWin(row, col)) { return currentPlayer; // 当前玩家获胜 } // 检查是否平局所有列都满了 bool isDraw true; for (int h : colHeight) { if (h rows) { isDraw false; break; } } if (isDraw) { return 3; // 平局 } // 切换玩家 currentPlayer (currentPlayer 1) ? 2 : 1; return 0; // 游戏继续 }3.3 获胜判定算法的优化实现checkWin(int row, int col)函数是算法的精髓。最笨的方法是每次落子后扫描整个棋盘但那样效率太低。正确做法是以刚落子的位置(row, col)为中心向四个方向探测。方向向量水平(0, 1)和(0, -1)垂直(1, 0)和(-1, 0)主对角线\(1, 1)和(-1, -1)副对角线/(1, -1)和(-1, 1)算法思路对于每一个方向对如水平方向的两个向量我们从落子点开始向正反两个方向延伸统计连续相同棋子的数量。如果总数正向反向1 4则获胜。bool checkWin(int row, int col) { int player board[row][col]; // 四个方向对水平、垂直、主对角线、副对角线 vectorpairint, int directions {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; for (auto dir : directions) { int dx dir.first, dy dir.second; int count 1; // 包括刚落子的这颗棋子 // 正向延伸 for (int step 1; step 4; step) { int newRow row step * dx; int newCol col step * dy; if (newRow 0 || newRow rows || newCol 0 || newCol cols || board[newRow][newCol] ! player) { break; } count; } // 反向延伸 for (int step 1; step 4; step) { int newRow row - step * dx; int newCol col - step * dy; if (newRow 0 || newRow rows || newCol 0 || newCol cols || board[newRow][newCol] ! player) { break; } count; } // 判断是否连成四子 if (count 4) { return true; } } return false; }实操心得这里有一个非常容易出错的点——边界检查。在向某个方向延伸时必须确保新的坐标(newRow, newCol)没有超出棋盘范围[0, rows-1] x [0, cols-1]。if判断中几个条件的顺序也有讲究一定要先做下标越界检查然后再去访问board数组否则会引发未定义行为或程序崩溃。这种“短路与”的判断顺序是防御性编程的基本功。3.4 主流程与输入输出处理最后我们将所有模块串联起来并处理输入输出。假设输入格式是第一行两个整数 R C第二行开始是一系列整数代表落子列直到游戏结束。int main() { int R, C; cin R C; // 初始化游戏状态代码见3.1节 // ... int moveCol; int step 0; vectorint moves; // 可选记录每一步的列号用于复盘或错误输出 while (cin moveCol) { moves.push_back(moveCol); step; int result dropPiece(moveCol); if (result -1) { // 无效操作根据题目要求处理可能是输出错误并结束 cout Invalid move at step step : column moveCol is invalid or full. endl; break; } else if (result 1 || result 2) { cout Player result wins after step moves! endl; printBoard(); // 打印最终棋盘 break; } else if (result 3) { cout Game ended in a draw after step moves. endl; printBoard(); break; } // result 0, 游戏继续读取下一步 // 可以在这里打印每一步后的棋盘用于调试 // cout After move step (col moveCol ): endl; // printBoard(); } // 如果输入序列结束了但游戏未结束理论上不应该但需考虑 // ... return 0; }4. 边界条件与异常处理全攻略代码能处理正常流程只是第一步健壮的程序必须能优雅地处理各种边界和异常情况。以下是几个必须考虑的陷阱4.1 输入数据的鲁棒性处理机试系统的输入可能不会像我们想象的那么“干净”。列号索引题目明确说了列号从0开始还是从1开始如果从1开始你需要在读入后col--。这是一个经典的“坑点”。非法输入输入中可能包含非数字字符或者列号超出了[0, C-1]的范围。dropPiece函数已经做了列号合法性和列满的检查并返回了-1。主函数需要根据题目要求决定如何处理这种无效输入是直接判负还是忽略并继续读取下一个通常题目会说明一旦出现非法操作即判当前玩家输。所以我们的代码应该能在检测到result -1时立即判定另一方获胜。if (result -1) { // 当前玩家进行了非法操作 int winner (currentPlayer 1) ? 2 : 1; // 对方获胜 cout Player winner wins due to invalid move by player currentPlayer endl; break; }输入序列提前结束如果输入序列用完了游戏还没结束怎么办这取决于题目定义。可能是平局也可能是未完成。我们的主循环while (cin moveCol)会自然结束我们可以在循环外补充状态判断。4.2 棋盘状态的完整性校验平局判断的时机我们的平局判断是在每次落子后检查是否所有列都满了。这是正确的。但要注意获胜检查必须在平局检查之前。因为有可能最后一步落子同时导致棋盘被填满和四子连珠此时应该判定为获胜而不是平局。多线程/并发幻觉虽然本题是单线程顺序输入但要养成“状态原子性”的思维。即dropPiece函数执行期间board和colHeight的状态应该被视为一个整体不能被分割。在这个简单场景下就是确保一次落子操作检查、放置、更新高度、检查胜负是连贯的。4.3 性能与可扩展性思考虽然本题棋盘小但养成好习惯很重要。时间复杂度每次落子dropPiece中checkWin是O(1)的固定检查4个方向每个方向最多延伸3步平局检查是O(C)的。总体是线性的完全足够。空间复杂度O(R*C)就是棋盘本身。如果棋盘非常大比如1000x1000频繁的平局检查O(C)可能成为瓶颈。可以维护一个计数器piecesCount每成功落子一次就加1当piecesCount R * C时即为平局将平局判断优化为O(1)。5. 从解题到工程代码风格与测试心得写出能AC的代码是目标但写出清晰、健壮、可维护的代码是本事。5.1 模块化与代码组织将不同的功能封装成函数如initializeBoard,printBoard,dropPiece,checkWin。这使主逻辑清晰也便于单独测试每个函数。例如你可以写一个单元测试来专门验证checkWin在各种连珠情况下的正确性。5.2 防御性编程与断言在关键位置加入断言或严谨的校验。int row colHeight[col]; // 防御性断言理论上colHeight[col] rows 已由前文保证 assert(row 0 row rows col 0 col cols); board[row][col] currentPlayer;在调试版本中这能帮你快速定位逻辑错误。5.3 全面的测试用例设计不要只依赖题目给的样例。自己设计测试用例覆盖各种场景正常获胜分别测试水平、垂直、两条对角线获胜。边界获胜棋子在棋盘边缘连成四子。最后一步获胜填满棋盘前一步获胜。最后一步平局恰好填满棋盘且无人获胜。非法输入列号过小、过大、列已满时落子。长序列测试随机生成很长的合法序列确保程序不崩溃、内存不泄漏。一个简单的测试方法是将棋盘初始化然后硬编码一系列落子步骤观察输出是否符合预期。void testCase1() { // 测试水平获胜 rows6; cols7; // ... 初始化 vectorint moves {3, 3, 2, 2, 1, 1, 0}; // 玩家1在0,1,2,3列连成一线 for (int col : moves) { int res dropPiece(col); if (res 1) { cout Test Horizontal Win PASSED endl; return; } } cout Test Horizontal Win FAILED endl; }5.4 调试技巧可视化与日志当程序行为不符合预期时printBoard()是你的最佳盟友。在每一步落子后打印棋盘能让你清晰地看到棋局是如何演变的快速定位是落子逻辑错了还是获胜判断逻辑错了。另外可以在checkWin函数内部加入详细日志打印出每次检查的方向和统计到的连续棋子数这对于诊断复杂的边界情况特别有用。6. 常见“坑点”与实战避坑指南结合我自己和周围朋友踩过的坑这里总结几个最容易出错的地方索引混淆这是最大的坑。行和列的索引是从0开始还是1开始board[row][col]和colHeight[col]的关系是否正确在纸上画一个3x3的小棋盘手动模拟几次落子确保你的索引计算和脑海中的图像一致。获胜判断的方向遗漏只检查了水平和垂直忘了两条对角线。或者检查对角线时方向向量写错了。务必用(1,1)和(1,-1)这两组向量。获胜判断的计数错误在checkWin中连续棋子的计数应该从1开始包括刚落下的子然后向两个方向延伸。最容易错的是只向一个方向数了4个或者数了5个才判断赢。我们的算法是“中心扩散法”正确且简洁。平局判断的条件平局是棋盘完全填满且没有获胜者。一定要先判断获胜再判断平局。判断棋盘是否填满高效的方法是检查colHeight数组是否每一项都等于rows或者维护一个总棋子计数器。玩家切换的时机一定要在确定本次落子没有导致游戏结束即返回0后再切换当前玩家。如果在落子后立即切换那么checkWin返回的获胜玩家标识就会错位。输入循环的终止条件你的while循环是读整数如果输入文件结束或出现非数字cin moveCol会失败并退出循环。要确保在这种情况下程序能输出一个合理的结果例如“Incomplete game”而不是无声无息地结束。这道“竖直四子棋”的题目本质上是一个状态模拟和条件判断问题。它考察的不是高深的算法而是编程者严谨的思维、对细节的把握以及将自然语言规则无歧义地转化为代码的能力。把这些点都考虑到实现干净测试充分拿到100%的通过率就是水到渠成的事情。编程实战中这种把复杂问题分解为清晰模块并逐一稳健实现的能力远比死记硬背算法模板重要得多。

相关新闻