华为OD机试的C卷里,最容易被低估的题目之一就是【黑白棋】。这题看着像博弈题,实际上考的是最朴素的八方向扫描,Python和JS都能写,但很多人第一次做的时候连题目都没读懂——落子之后到底哪些棋子要翻转,边界怎么处理,坐标是从0开始还是从1开始,每个环节都能让人卡住。这篇文章就把这道题从规则建模、完整实现到踩坑点全部拆开讲清楚,适合正在刷OD机试真题、尤其是C卷Python或JS方向的读者。黑白棋本身不难,难的是把生活化的棋类规则翻译成代码时,脑子里必须有一个清晰的扫描模型,否则写出来的循环要么越界,要么漏翻棋子。
1. 黑白棋机试题的底层逻辑:8方向扫描与状态翻转
1.1 题目到底长什么样
华为OD机试里的【黑白棋】不是要求你写一个能下棋的AI,而是给定一个8乘8的棋盘状态,棋盘上用*表示空位,X表示黑棋,O表示白棋。输入前8行是棋盘,接下来是一个落子坐标,再接下来是一个要落的棋子颜色。题目要求你模拟这次落子,把该翻转的棋子全部翻转,最后输出新的棋盘状态;如果这个位置不能落子,就按题目约定输出指定错误信息。
这才是真题的真实考法,棋盘上可能已经有了很多棋子,初始布局不是固定的。你只需要处理一步落子,不需要考虑轮流下棋、终局判断、胜负统计这些额外的东西。很多人第一眼看到"黑白棋"三个字,脑子里自动浮现出棋类AI、博弈树、评估函数,直接吓住,其实完全跑偏了。
1.2 把"夹住"翻译成代码
黑白棋的核心规则一句话就能讲完:落子之后,从落子位置向八个方向看,如果某个方向上连续是一个或多个对方棋子,再往后遇到了己方棋子,那么中间夹住的这一串对方棋子全部变成己方棋子。
用生活化的类比,这就像做一个三明治,你手里拿着的棋子是上面那层面包片,棋盘上已经有的己方棋子是最底下那层,中间夹着的火腿肠就是对方棋子。火腿肠被夹住了,就变成你的。
更严格一点说,判定某个方向是否合法需要三步。第一步,落子位置本身必须是空位,这一点最容易被忽略。第二步,从落子位置沿某个方向走一格,这一格必须是对方棋子,如果这一格是空的或者己方棋子,这个方向直接作废。第三步,沿着这个方向继续走,中间每一步都得是对方棋子,直到遇到棋盘边界或者遇到己方棋子。如果先遇到己方棋子,中间这些对方棋子全部翻转;如果先遇到边界或者空格,这个方向什么也不发生。
1.3 这题不是博弈,是模拟
理解这道题的正确姿势,是把心态从"下棋"切换到"状态更新"。黑白棋每一步落子对棋盘的影响是局部的,只影响落子位置所在的八个方向延伸出去的那几条线。这种题目本质上就是一个矩阵遍历加条件判断的问题,跟你平时刷的岛屿数量、扫雷游戏、迷宫寻路属于同一类,区别只是每个方向的判断逻辑稍微绕一点。
OD机试之所以喜欢出这类题,是因为它同时考察了读题能力、边界处理和代码组织能力。一个简单的规则,如果脑子里的方向模型不清晰,写出来的代码很容易出现重复逻辑或者漏判。
2. 方向数组与合法落子判定:这道题的心脏
2.1 为什么八个方向一定要用增量数组
新手最容易写出的代码是这样的:判断上面怎么写一遍,判断下面再复制一遍,判断左上再复制一遍,代码十分钟写完,却要花四十分钟排查某个方向的坐标算错了。方向增量数组就是为了消灭这种重复。
八个方向对应八组行和列的偏移量,按顺序写在数组里:
DIRS = [ (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1) ]这样写的好处有两个。第一,逻辑只写一遍,八个方向通过循环遍历,不会漏方向。第二,合法判定和翻转操作可以共用同一个方向循环框架,后续维护时只需要改一处逻辑,不会出现"判定能过但翻转错误"这种见了鬼的问题。
2.2 合法落子判定三步法
我在做题时习惯把判定拆成三个明确步骤,每一步都有对应的代码形态。第一步检查当前位置是不是空位,不是的话直接返回不可落;第二步初始化八个方向的循环,每个方向先把坐标移动到紧邻格,检查它是不是对方棋子,不是就跳过这个方向;第三步进入while循环,一直沿着方向前进,只要还在棋盘内且是对方棋子就继续走,最后判断停下来的位置是不是己方棋子,是的话说明这个方向能够夹住对方棋子,整个落子合法。
第三步里有三个细节必须同时满足:停下来的坐标必须在棋盘内、该位置的棋子颜色必须是己方、中间至少有一颗对方棋子。有人会把"停下来的坐标在棋盘内"这一条漏掉,导致数组越界,这是最常见的运行时错误。
2.3 翻转操作和判定共用一个循环框架的原因
翻转的本质是"先标记,后确认"。落子后,从落子点向某个方向走,一边走一边把沿途的对方棋子坐标记录到一个临时列表里。走完之后如果发现这个方向封口了,也就是遇到了己方棋子,就把列表里记录的所有坐标全部改成己方颜色;如果没封口,这个列表直接丢掉,继续检查下一个方向。
判定和翻转的循环结构几乎一模一样,区别只在于判定只需要返回一个布尔值,翻转需要真正执行修改。所以我把这两部分拆成两个函数,但保持相同的命名和遍历风格。有些人喜欢把判定和翻转合并,落子前先判定,判定通过了再落子,这样也可以,但代码会绕一些。我推荐先判定后翻转,逻辑更直白,也方便你在打印日志时确认每一步的状态。
3. Python实现:完整代码与逐段拆解
3.1 棋盘读入技巧
Python里读机试的输入,最稳的方式是sys.stdin把所有行一次性读进来,然后用列表推导过滤空行。棋盘每一行是一个8位字符串,直接用list(line)把它变成可修改的字符列表,这样落子时才能改棋盘,字符串本身是不可变的。
坐标行的处理有个隐藏细节。真题给的坐标可能是"第几行第几列",行列之间有空格,也可能用逗号分隔。我建议统一用split()按空白切割,拿到两个整数后再判断是否需要减一。很多题目描述里说左上角是(1,1),那么数组下标必须做减一转换,这一步做错整个答案就是零分。
3.2 两个核心函数:could_place 与 place_and_flip
could_place负责回答"这个位置能不能落子",它只做判断不修改棋盘。place_and_flip负责真正落子并完成翻转。我单独把它们拆开,是因为机试调试时经常需要单独验证"某个位置是否合法",合在一起每次都要打印棋盘状态,很麻烦。
import sys DIRS = [ (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1) ] def in_board(r, c): return 0 <= r < 8 and 0 <= c < 8 def opp_color(color): return 'O' if color == 'X' else 'X' def can_place(board, r, c, color): if board[r][c] != '*': return False opp = opp_color(color) for dr, dc in DIRS: nr, nc = r + dr, c + dc if not in_board(nr, nc) or board[nr][nc] != opp: continue while in_board(nr, nc) and board[nr][nc] == opp: nr += dr nc += dc if in_board(nr, nc) and board[nr][nc] == color: return True return False def place_and_flip(board, r, c, color): board[r][c] = color opp = opp_color(color) for dr, dc in DIRS: nr, nc = r + dr, c + dc to_flip = [] while in_board(nr, nc) and board[nr][nc] == opp: to_flip.append((nr, nc)) nr += dr nc += dc if in_board(nr, nc) and board[nr][nc] == color: for fr, fc in to_flip: board[fr][fc] = color return board def main(): lines = [line.strip() for line in sys.stdin if line.strip()] board = [list(line) for line in lines[:8]] parts = lines[8].split() r, c = int(parts[0]), int(parts[1]) color = lines[9].strip() r -= 1 c -= 1 if not can_place(board, r, c, color): print("invalid") else: board = place_and_flip(board, r, c, color) for row in board: print(''.join(row)) if __name__ == "__main__": main()这段代码的main函数里,r -= 1和c -= 1默认针对坐标从1开始的题目。如果题目示例明确说坐标从0开始,就把这两行删掉。我建议考试时先用题目给的示例输入试一次,输出对不上就立刻怀疑这里。
3.3 用一组实际用例验证
给你一组可以手算验证的输入:
******** ***O*X** ***O*O** ***O*O** ***X*O** ******** ******** ******** 1 4 X坐标1 4转成数组下标是(0, 3),这里本来是空位。从它向下看,(1,3)是O,(2,3)是O,(3,3)是O,(4,3)是X,中间三颗白子被夹住,全部翻成黑子。输出应该变成:
***X**** ***X*X** ***X*O** ***X*O** ***X*O** ******** ******** ********你可以自己拿笔在纸上推一遍。特别注意(1,4)这个位置是X,它属于另一条线,不参与这次翻转,棋盘上的其它棋子一个都不能动。这种"只翻转夹住的一串"的要求,靠的就是to_flip列表的精确记录。
4. JavaScript实现:同样的逻辑,不同的语法坑
4.1 二维数组创建最容易踩的坑
JS里很多人一上来就写Array(8).fill(Array(8).fill('*')),然后惊悚地发现改一个格子,整列都跟着变。原因在于fill传入的是一个数组引用,每一行其实都是同一个数组。正确写法是用Array.from配合箭头函数:
let board = Array.from({ length: 8 }, () => Array(8).fill('*'));如果是从输入字符串转换,就用line.split('')生成每一行的字符数组,这种方式不会有引用共享问题。我见过好几个把机试当练习的开发者,在这上面浪费了二十分钟,拿到题目后先把这个语法点绕过去,后面就顺畅了。
4.2 输入输出处理的固定模板
Node.js环境下的标准输入用readline逐行读,我会把所有非空行收集到一个数组里,close事件触发后再统一解析。字符串数组要先通过split('')转成可修改的二维数组,输出时用row.join('')转回字符串。
const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const DIRS = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; function inBoard(r, c) { return r >= 0 && r < 8 && c >= 0 && c < 8; } function opp(color) { return color === 'X' ? 'O' : 'X'; } function canPlace(board, r, c, color) { if (board[r][c] !== '*') return false; const op = opp(color); for (const [dr, dc] of DIRS) { let nr = r + dr, nc = c + dc; if (!inBoard(nr, nc) || board[nr][nc] !== op) continue; while (inBoard(nr, nc) && board[nr][nc] === op) { nr += dr; nc += dc; } if (inBoard(nr, nc) && board[nr][nc] === color) return true; } return false; } function placeAndFlip(board, r, c, color) { board[r][c] = color; const op = opp(color); for (const [dr, dc] of DIRS) { let nr = r + dr, nc = c + dc; const toFlip = []; while (inBoard(nr, nc) && board[nr][nc] === op) { toFlip.push([nr, nc]); nr += dr; nc += dc; } if (inBoard(nr, nc) && board[nr][nc] === color) { for (const [fr, fc] of toFlip) { board[fr][fc] = color; } } } } let inputLines = []; rl.on('line', (line) => { if (line.trim().length > 0) inputLines.push(line.trim()); }); rl.on('close', () => { let board = inputLines.slice(0, 8).map((line) => line.split('')); const parts = inputLines[8].trim().split(/\s+/); let r = parseInt(parts[0], 10) - 1; let c = parseInt(parts[1], 10) - 1; const color = inputLines[9].trim(); if (!canPlace(board, r, c, color)) { console.log('invalid'); return; } placeAndFlip(board, r, c, color); for (const row of board) { console.log(row.join('')); } });4.3 两种语言选谁更稳
如果你Python和JS都还凑合,我建议Python优先。Python的代码量更短,列表推导、字符串转字符数组、逐行拼接输出都更直觉。JS的优势在于如果你平时做前端多,readline模板已经背下来了,那用JS也没问题。机试判分看的是输出结果,不是代码风格,选自己最熟的语言才是对的。
无论用哪种语言,核心逻辑完全一致:方向数组、边界检查、先判定后翻转。语言差异只体现在语法上。JS里const [dr, dc] of DIRS这种解构写法很漂亮,但如果你用的Node版本比较老,记得改成for (let i = 0; i < DIRS.length; i++)这种传统写法,不要因为解构不兼容导致运行时错误。
5. 双机位C卷实战:输入解析、边界条件和时间分配
5.1 坐标偏移是零分重灾区
真题里的坐标描述通常有两种。一种是"第1行第1列用0表示",另一种是"左上角坐标为(1,1)"。后者需要转成数组下标时减一,非常容易错。我的建议是不要死记,考试时先看题目给的输入示例,用示例数据跑一遍自己的程序,发现输出和示例不一致,检查范围优先排序是:坐标偏移、颜色字符大小写、棋盘读入是否多读少读。
这个题目还有一个容易忽略的点:输入里可能混有空格和空行。有些样例在棋盘和坐标之间留了一个空行,如果你用lines[8]直接取坐标,空行被过滤掉之后坐标的下标会变。我统一用"过滤非空行"的方式来处理,这样不管样例里有没有空行都一样。
5.2 最容易翻车的四个测试用例
我用一个表格把这几个必须自测的场景列出来,省得临场抓瞎。
| 场景 | 输入特点 | 预期行为 | 对应代码位置 |
|---|---|---|---|
| 落子位置有棋子 | 坐标处是X或O | 输出invalid | can_place第一步 |
| 落子位置被己方棋子包围 | 周围没有对方棋子 | 输出invalid | 各方向扫描都作废 |
| 落子位置在角落 | 左上角有对方棋子斜线 | 只翻转对角线 | 方向越界跳过 |
| 中间有多个对方棋子 | 一条线上三颗对方棋子 | 整串全部翻转 | while收集列表 |
每个方向独立判断,一个方向合法就落子,不要把八个方向的结果做逻辑与。有学员写过"把所有方向翻转完才算合法"的代码,方向之间互相污染,结果一个方向合法但另一个方向不合法时把不该翻的也翻了。每个方向是独立的事件。
5.3 机试节奏和时间分配
C卷的题量通常不小,留给每道题的时间有限。我建议黑白棋这类保姆级模拟题控制在20分钟以内完成。时间分配是这样:读题加画图确认规则5分钟、写核心逻辑10分钟、用两个样例验证5分钟。如果超过30分钟还没写对,建议先跳过做后面的题,最后再回来调试。
双机位考试还有一层空间上的约束:你所在的物理环境被两个摄像头盯着,桌面尽量清爽,不要放纸质笔记。平时刷题时就要练习在纯编辑器环境下不看自己的笔记写代码,很多人的真实问题是考场环境下鼠标键盘的位置都变了,手速和节奏全乱。这不是技术问题,但确实影响发挥。
6. 从黑白棋到方向数组系列题:机试的举一反三
6.1 方向数组是机试算法题的万能钥匙
黑白棋里用到的方向数组,几乎原封不动可以复用到其它高频题。比如岛屿数量用四方向的(1,0),(-1,0),(0,1),(0,-1)做DFS;扫雷游戏数雷就是八方向计数,和黑白棋的方向判定几乎一模一样;迷宫问题做BFS时,方向数组配合visited数组就是完整模板。
所以刷这道题的重点不是背答案,而是把"方向数组加边界检查"这个范式焊在脑子里。我每次做矩阵类题目,第一件事就是把方向数组写出来,然后写一行边界判断函数,剩下的都是业务逻辑。
6.2 机试阅卷是黑盒,输出格式比算法更值钱
OD机试的判分方式是黑盒测试,跑用例比对输出。这意味着算法再漂亮,输出格式和预期差一个空格都是零分。最后一行的换行符通常无所谓,但中间每一行结尾不能有多余空格,不能输出调试信息。这是我反复强调的点,也是很多人栽跟头的地方。
我把通用的方向数组搜索模板贴在这里,你可以直接抄走作为自己的代码片段:
def in_board(r, c, rows, cols): return 0 <= r < rows and 0 <= c < cols def scan(board, r, c): rows, cols = len(board), len(board[0]) # 八方向,四方向时只保留上下左右四个 for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]: nr, nc = r + dr, c + dc if not in_board(nr, nc, rows, cols): continue # 按题目要求处理6.3 把这道题练成条件反射
我个人建议的做法是把黑白棋手写出完整代码,然后第二天不看书再写一遍。这个过程比你盯着题解看三遍有用得多,因为你会发现在第一遍里觉得理所当然的边界处理,第二遍时仍然会错,错的地方就是你的思维盲区。
如果你能逼自己在20分钟内闭卷写完Python版和JS版各一次,再回到真题环境里,这道题对你来说就是送分题。方向数组的套路一旦熟练,后面遇到矩阵旋转、八皇后攻击范围检查、象棋棋子走法判断,你会觉得都是同一道题换了层皮。
黑白棋这道题真正想考察的不是你会不会下棋,而是你有没有把复杂规则抽象成可遍历的代码结构的能力。认真完成一次从规则到代码的转换,再遇到任何类似的模拟类题目,你心里就有底了。