1. 题目背景与解题意义
华为OD机试双机位C卷黑白棋,这个题目确实在很多考生的题库名单里出现过,难度算不上顶尖,但思路很典型,属于那种看着简单、写起来考细节的题。尤其近几年OD机试慢慢从“背题就能过”变成“理解才能过”,说实话,单纯刷题但不动手把代码跑通、跑对边界的人,很容易在类似题目上翻车。
黑白棋(也叫Reversi、Othello)本身是个非常经典的棋类游戏。规则一句话就能概括:双方轮流落子,落子位置必须能夹住对方至少一颗棋子,夹住的棋子全部翻转成己方颜色,最后棋盘上谁的棋子多谁赢。听起来不难,但一旦要求你实现“给定局面判断合法位置”或者“对某个位置落子并更新棋盘”的时候,一大堆边界问题就冒出来了。这些边界问题恰恰是机试最喜欢埋坑的地方。
这道题能被放进双机位C卷,说明出题人想考察的不是你会不会背某个算法模板,而是你的代码能不能在时间限制内把规则理清楚、把复杂逻辑拆成可维护的模块。C/C++、Java、Python、Go、JS五种语言都能写,但推荐优先选择自己最熟、提交时间最可控的一门。
这篇就专门拆解:黑白棋题目的常见变体、怎么做题解分析、怎么把规则翻译成代码、五门语言各自怎么写最省事,最后附上我从实际刷题和模拟考试里总结的排查经验。无论你是第一次考OD还是二战三战,这篇都能直接当参考。
2. 常见题目变体与解题思路拆解
2.1 双机位C卷中的常见考法
黑白棋在OD机试里很少让你写一个完整的对战AI,更多地是把规则拆成小任务来考。我归纳了一下,目前见到的考法主要集中在三类:
第一类是“合法性判断”。给你一个棋盘状态,告诉你当前轮到黑棋或白棋下,要求找出所有可以落子的合法位置,或者判断某个指定位置是否合法。这个考的是对“夹住对方棋子”这一核心规则的深刻理解。
第二类是“落子后棋盘更新”。给你一个棋盘和一步落子,要求模拟翻转后的结果,输出新棋盘。这种考法看着功能简单,但对八个方向的遍历逻辑要求很高,稍不留神就会在边角方向上漏掉棋子。
第三类是“终局判胜负”。给定一个中间局面或终局局面,统计黑白棋子数量并判断谁赢,有时还会附加“有效棋步数”之类的条件。这种相对简单,但会结合前面两类逻辑一起出现,形成一道综合题。
坦白讲,我当年在准备这类题时最大的感受是:题目本身不难,难在你急了之后忽略的细节。比如用Java写的时候,二维数组的边界判断写错了,运行时抛一次ArrayIndexOutOfBoundsException,整道题就白费了。这种坑,谁踩谁知道。
2.2 解题思路的通用框架
不管题目具体怎么出,核心逻辑都绕不开三个环节:判断合法位置、执行翻转、更新计分(如果需要的话)。我在做题时习惯把这三步拆成独立的函数,然后用一个主流程去串起来。这样做的好处是每一步都能单独调试,出错了也容易定位。
判断合法位置是整个题目的地基。从规则出发,一个合法的落子必须满足两个条件:第一,该位置当前必须是空格;第二,从这个位置向上下左右和两条对角线共八个方向延伸,至少要有一个方向能形成“己方棋子-连续若干对方棋子-己方棋子”的格局。
有了这个判断逻辑,后续的“翻转”其实就是把判断过程中找到的满足条件的棋子全部变色。这里有个细节:判断和翻转最好是分开实现,不要混在一起,因为判断时你可能只想验证合法性,并不想真的改变棋盘状态,而翻转时需要明确知道哪些棋子需要变色,两者的逻辑侧重点不同。
另外,在很多题目里,边界条件会直接影响解法。比如有的变体规定棋盘是4x4,有的是8x8,有的甚至给一个非方形的棋盘。这时候如果你直接把方向常量写死,后面想改就麻烦了。所以我的习惯是把棋盘大小作为参数传入,所有方向计算都基于这个参数动态判断,这样无论题目怎么变,代码都能复用。
2.3 为什么这道题值得重点准备
从功利的角度说,黑白的棋性价比很高。它考察的是二维数组操作、方向遍历、边界判断,这些能力几乎是所有算法题的基础,练好它,对之后处理矩阵类题目(比如岛屿数量、八皇后变种)都有直接帮助。
从应试的角度说,黑白棋的规则固定、变体有限,只要你把代码模板吃透,考场上遇到类似题目基本就是“换汤不换药”。很多考生喜欢到处收集押题,其实与其押一百道不一样的题,不如把黑白棋这种高频题型完整啃下来,吃透一个比模糊地见过十个有效得多。
我个人的建议是:笔试前至少亲手写两遍这道题,第一遍允许查资料、慢慢调,第二遍就得限时40分钟内独立完成并跑通所有自测样例。达到这个水平后,考场上遇到同类问题的把握会大很多。
3. 核心规则解析与代码实现要点
3.1 棋盘表示与基本数据结构
做黑白棋第一步就是选好棋盘的数据结构。最常见的做法是用二维数组,值0表示空格,1表示黑子,2表示白子(或反过来,看你心情,但一定要统一)。如果你用C++,可以直接用vector<vector >,Java用int[][],Python用list of list,Go用[][]int,JS直接用二维数组,这些都没问题。
选二维数组的原因很简单:棋盘本来就是二维格子结构,数组天然适合随机访问,写方向遍历时也直观。虽然用一维数组加坐标换算也能做,但可读性和可维护性都会差很多,考试时间紧张时没必要给自己找麻烦。
棋盘的初始化也很讲究。标准黑白棋开局的中心四格是固定的:左上黑、右上白、左下白、右下黑(4x4或8x8都一样,规则就是这么定的)。不过OD机试里很少让你初始化棋盘,多数是给你一个现成的局面让你处理,所以这里只需要保证你读入数据的方式没问题就好。
3.2 八方向遍历的正确姿势
八个方向的遍历,看起来很简单,但不小心就出bug。我推荐用一个方向数组来统一处理,避免写八个if-else。这里以C++为例,你可以定义:
const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};然后统一遍历这8个方向,对每个方向做延伸判断。这个写法最大的好处是代码精简、不易遗漏方向,而且后续要调整方向顺序或者做对称处理都很方便。
判断某个方向是否“能翻转”的核心逻辑是:从落子位置出发沿当前方向走,第一步必须遇到对方棋子;然后继续沿同方向走,直到遇到己方棋子,则这个方向合法;如果先遇到空格或出界,则这个方向不合法,直接短路。
这里有个容易忽略的点:第一步必须是对方棋子,如果你第一步就踩到空格或己方棋子,那么这个方向直接作废。很多新手会在这一步踩坑,因为规则字面上是“夹住对方棋子”,但并没有强调这个“对方棋子”紧邻落子位置。实际上规则就是要求紧邻的,因为棋子翻转只能翻转连续的对方棋子,中间不能有空格。
3.3 合法性判断与翻转的统一实现
判断合法位置时,不需要真正翻转棋子,只需要判断是否存在至少一个合法方向。但翻转棋子时,需要真正把对应方向的棋子全部变色。怎么让这两者共享一套逻辑呢?我的做法是写一个函数,传入参数指定“只判断”还是“执行翻转”:
bool checkDirection(int x, int y, int dx, int dy, vector<vector<int>>& board, int curColor, bool doFlip) { int nx = x + dx, ny = y + dy; bool hasOpposite = false; while (nx >= 0 && nx < n && ny >= 0 && ny < n) { if (board[nx][ny] == 0) return false; if (board[nx][ny] == curColor) { if (doFlip) { int fx = x + dx, fy = y + dy; while (fx != nx || fy != ny) { board[fx][fy] = curColor; fx += dx; fy += dy; } } return hasOpposite; } hasOpposite = true; nx += dx; ny += dy; } return false; }这个函数简洁地把“判断”和“翻转”合并成了一个流程。当doFlip为false时,它只检查能否走到己方棋子并返回bool;当doFlip为true时,它在确认条件成立后,再次从起点沿该方向走到己方棋子处,把所有中间的棋子翻成当前颜色。注意我第二次遍历时是从起点的下一个格子开始的,终止条件是走到刚才找到的己方棋子位置,这样避免把起点和终点也翻转了。
3.4 五种语言实现时的注意事项
C++的特点是指针和引用灵活,但也最容易在边界上翻车。建议用vector而不是裸数组,因为vector自带size方法,避免越界访问。
Java写这题时最烦的是二维数组的边界判断。推荐在核心循环里每次都判断是否在界内,不要预先做可能漏判的简化。然后Java的int[][]默认值是0,如果你用0表示空格,那么在读入棋盘前不要额外初始化,否则可能覆盖掉真正需要的默认值。
Python的优势是写起来快,劣势是慢。好在黑白棋棋盘不大,即使双重循环也完全不会超时。这里要注意Python的深拷贝与浅拷贝:如果你需要保存棋盘状态做回溯,一定要用copy.deepcopy,或者自己手动逐行复制,直接list复制会共享内部引用,改一个就全改了。
Go的数组类型比较严格,[8][8]int和[][]int是不同类型,建议直接用切片切片,即[][]int,这样在函数间传递时更灵活。Go的越界不会像Java那样抛异常,而是直接panic,排查起来更难,所以边界判断一定不能省。
JS写这题很顺手,数组就是天生动态的。但JS里0和空数组在布尔判断中容易混,建议比较时都用全等===,避免隐形类型转换带来的诡异行为。
4. 完整实现流程与核心环节拆解
4.1 我直接给出一个能跑的C++参考实现
下面这段代码我实测过,能够处理“给定棋盘、当前下棋方、输出所有合法位置并统计翻转后的棋子数”这类核心需求。你只要根据题目输入格式稍作调整就能用:
#include <iostream> #include <vector> using namespace std; const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; int n; vector<vector<int>> board; bool isValidMove(int x, int y, int curColor) { if (board[x][y] != 0) return false; int oppColor = 3 - curColor; for (int k = 0; k < 8; k++) { int nx = x + dx[k], ny = y + dy[k]; bool hasOpp = false; while (nx >= 0 && nx < n && ny >= 0 && ny < n) { if (board[nx][ny] == 0) break; if (board[nx][ny] == curColor) { if (hasOpp) return true; break; } hasOpp = true; nx += dx[k]; ny += dy[k]; } } return false; } void applyMove(int x, int y, int curColor) { board[x][y] = curColor; int oppColor = 3 - curColor; for (int k = 0; k < 8; k++) { int nx = x + dx[k], ny = y + dy[k]; bool hasOpp = false; while (nx >= 0 && nx < n && ny >= 0 && ny < n) { if (board[nx][ny] == 0) break; if (board[nx][ny] == curColor) { if (hasOpp) { int fx = x + dx[k], fy = y + dy[k]; while (fx != nx || fy != ny) { board[fx][fy] = curColor; fx += dx[k]; fy += dy[k]; } } break; } hasOpp = true; nx += dx[k]; ny += dy[k]; } } } int main() { cin >> n; board.assign(n, vector<int>(n)); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { cin >> board[i][j]; } } int curColor; cin >> curColor; vector<pair<int,int>> moves; for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (isValidMove(i, j, curColor)) { moves.push_back({i, j}); } } } int blackCnt = 0, whiteCnt = 0; for (auto& p : moves) { applyMove(p.first, p.second, curColor); } for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (board[i][j] == 1) blackCnt++; if (board[i][j] == 2) whiteCnt++; } } cout << moves.size() << endl; for (auto& p : moves) { cout << p.first << " " << p.second << endl; } cout << blackCnt << " " << whiteCnt << endl; return 0; }这里用了3 - curColor来求对方的颜色,前提是你约定黑子=1、白子=2,这样1的对手是2,2的对手是1,刚好用3减就行。这是个很实用的小技巧,省得每次写ifelse判断。
4.2 函数拆解与主流程设计思路
上面这段代码我拆成了三个核心函数:isValidMove负责判断某个点是否可下,applyMove负责落子并翻转,主函数负责读入、遍历所有位置、统计输出。这个拆分思路本身比具体代码更值得借鉴。
拆分函数不是为了让代码看起来高级,而是为了让你在考试时能快速定位bug。假如最后输出结果不对,你只需要判断是isValidMove写错了还是applyMove写错了,定位范围缩小一大半。如果你把逻辑全堆在main里,排错时每个循环、每个边界条件都可能是凶手,排查效率太低了。
主流程里有一个容易漏掉的细节:如果你需要统计所有合法位置,并且要求把落子后的棋盘状态作为最终输出,那么你必须对每个合法位置都真实调用applyMove。有些同学只统计了合法位置数,忘了还要翻转棋子更新棋盘,结果后面统计黑白棋子数量时发现棋盘还是原样,那就白费了大半功夫。
4.3 边界条件与样例设计是考试的关键
OC考试给的样例通常不会覆盖所有边界情况,所以你自己得学会构造几个边界样例来验证代码。我常用的几个测试思路如下:
第一,测角点。把棋盘的四个角作为落子位置,重点看左上角(0,0)、右下角(n-1,n-1)这些位置。角点只有一个或两个方向可以延伸,最容易暴露方向遍历不完整的问题。
第二,测空格判断。落子位置必须为空,如果棋盘上已经没有空格,应该输出0个合法位置并且不改变棋盘状态。这个很容易在题目变体中出现,要特别小心。
第三,测双方无棋可下的局面。黑白棋有个规则,如果没有合法位置,就得跳过回合。虽然OD机试里很少直接考这个规则,但如果题目暗示了“无棋可下则输出0”,你的代码得能正确处理空棋盘或满棋盘的极端情况。
第四,测全同色棋盘。比如棋盘上全是黑子,当前轮到白子下,那么合法位置必然是0。这个看似简单,但有些代码在判断时会把“找不到对方棋子”误判为“合法”,从而输出错误的坐标,实测中很容易漏掉。
4.4 其他语言快速调整的思路
如果你用Python,上面C++的主逻辑完全可以直接翻译,但Python的while循环稍微注意下缩进即可。Python版本可以更简洁,因为语言本身更灵活,比如可以用for循环加break代替繁琐的while边界判断。我把核心逻辑简写如下:
def is_valid(x, y, board, cur): if board[x][y] != 0: return False opp = 3 - cur for dx, dy in dirs: nx, ny = x + dx, y + dy has_opp = False while 0 <= nx < n and 0 <= ny < n: if board[nx][ny] == 0: break if board[nx][ny] == cur: if has_opp: return True break has_opp = True nx += dx ny += dy return FalseJava的实现大体和C++一致,只是语法略啰嗦。需要注意Java的int数组默认值是0,如果你用0表示空格,那么读入时不要手动初始化成其他值。Go的实现基本一致,只是方向数组的声明稍微繁琐一点。JS则要注意可读性,函数不要写得太长,因为JS调试时很难看出具体哪里出了问题。
5. 常见问题、排查心得与避坑技巧
5.1 最容易踩的坑:方向数组与边界判断
我刷题时遇到最多的问题就是方向数组漏了方向。有些人喜欢手写八个方向的if-else,结果写着写着就漏了一个对角线。所以一定要用方向数组,把上下左右和四个对角线统一放进一个循环里。
第二个常见问题是把“边界判断”和“内容判断”顺序写反了。比如先访问board[nx][ny]再判断nx是否在界内,这会导致越界访问。正确方式是先用条件判断nx和ny是否在界内,再访问数组,两者顺序不能颠倒。
还有一个我一再强调的细节:第一步必须是对方棋子。有的实现里,循环逻辑先遇到己方棋子就返回false,这其实是对的,但如果你把hasOpp的判断写在前面,可能会出现“第一步是己方棋子但也算合法”的bug。这个时候一定要把第一步的身份判断和中间过程分开理解,不能混为一谈。
5.2 考试时的时间分配与调试策略
OD机试的时间是有限的,所以做题节奏很重要。我建议拿到题目后先花5分钟看清输入输出格式,尤其注意棋盘尺寸的输入方式。有些题目固定是8x8,不输入n,直接给8行数据;有些则输入n和n行数据。这两种情况处理方式不同,先搞清楚再动手写代码。
然后花10到15分钟把主体逻辑写完,剩下来的时间全部用来跑测试。不要着急提交,机试成绩只看结果,你提前交卷不会加分。测试时除了示例外,把前面提到的角点、空棋盘、全同色这些边界样例都跑一遍,能有效降低翻车概率。
如果中途发现结果不对,不要盲目重写,先用打印语句(cout/print/console.log)追踪中间状态。最常见的问题是缺少关键打印信息导致排查困难。比如你先打印一下isValid在几个具体点上的返回值,就能快速判断是判断逻辑错还是翻转逻辑错。
5.3 多语言混用时的额外注意
如果你平时用的是C++,但考试允许用Java,我建议不要临时换语言,除非你非常确定自己的Java水平不比C++差。考试不是炫技的地方,用你最稳的语言写比什么都强。我在真实考试中见过有人因为Java的二维数组语法不熟,在分配数组时浪费了好几分钟,这是完全可以避免的。
如果你确实要换语言,提前练习一下数组声明和读入方式。拿Java来说,int[][] board = new int[n][n]这句要写熟练,Scanner怎么读二维数组也要记熟。Python则要注意input().split()返回的是字符串列表,需要逐个转成int,这些问题都很基础,但考场上一次性写对的人不一定多。
Go语言的切片默认值是nil,不像数组有零值初始化。所以如果你用make([][]int, n)之后还要逐行make,一定要记住这个坑。JS则要注意数组的map方法返回的是新数组,如果你直接对原数组map修改,可能会搞混深拷贝和浅拷贝。
5.4 从真题场景中总结的经验
我在练习和复盘时发现,黑白棋这类“规则题”最忌讳的就是死记模板。比如你把8x8写死在代码里,题目突然给4x4就直接越界。更好的做法是永远把棋盘大小作为变量n传入,所有循环都用n做边界,这样任意尺寸都能跑通。
另外,建议养成“随手写辅助函数”的习惯。判断某个点是否在棋盘内这种小函数,哪怕只有一行,也值得单独抽出来。因为如果你在多个方向循环里反复写nx>=0 && nx<n,不仅容易出错,还很难一眼看出逻辑问题。抽成函数后用起来又清晰又不容易错。
最后一个小技巧:把棋盘输出做成一个可选的debug函数。考试时如果需要调试,可以直接打印当前棋盘状态,一眼看出翻转之后颜色是否对。这个函数平时练习时也很有用,尤其是你处理完多个合法落子后,检查棋盘是否被正确更新,debug函数能省下大量无意义的盯代码时间。
黑白棋这道题,真正拉开差距的往往不是算法难度,而是你是否能在限定时间内把规则准确翻译成代码。把本文的核心逻辑吃透、边界样例跑熟,考场上遇到它,你就有充足的底气去拿分。