freeCodeCamp 每日编程挑战 153 解析:用 JavaScript 判断井字棋(Tic-Tac-Toe)胜负
freeCodeCamp 的每日编程挑战(Daily Coding Challenge)系列为学习者提供了大量可独立练习的小型算法题,本篇聚焦其中第 153 题 "Tic-Tac-Toe"(题目文件位于 curriculum/challenges/english/blocks/daily-coding-challenges-javascript/69373793f5a867f769cde138.md):给定一个表示已结束的 3×3 井字棋对局的矩阵,判定 X 方、O 方谁获胜,或判定为平局。读完本文,你将掌握本题的完整规则、六个官方测试用例的逐步推导、官方参考解法的原理,以及数组遍历、行列对角线枚举、every() 判定等 JavaScript 数组技巧,并能结合 freeCodeCamp 仓库中的挑战结构、API 路由与前端组件理解该系列挑战的完整运行生态。
一、题目要求回顾
原题描述非常简洁,核心输入输出约定如下:
- 输入:一个 3×3 矩阵(二维数组),代表一局已经下完的井字棋棋局;
- 元素取值:矩阵中的每个元素只能是
"X"或"O"两个字符串之一; - 胜利条件:某一方在水平、垂直或对角线方向连成三个自己的棋子即获胜;
- 返回值:
- 玩家 X 三子连成一线时返回
"X wins"; - 玩家 O 三子连成一线时返回
"O wins"; - 双方都未三子连成一线时返回
"Draw"。
- 玩家 X 三子连成一线时返回
注意题目描述中特意强调输入表示的是“已完成(completed)”的对局,即不需要处理中间状态、不需要校验棋局是否合法、也不会出现棋盘未下满的情况,所有边界条件都被简化掉了,剩下的是一个纯粹的静态判定问题。
函数签名
题目给出的种子代码(seed)如下:
function ticTacToe(board) {
return board;
}
种子代码只接收一个参数 board,并且默认原样返回传入的矩阵。你的任务就是补全函数体,让它按照上述规则返回对应的结果字符串。该挑战对应的 challengeType 为 28,是每日编程挑战系列专用的题型标记。
二、深入理解胜负判定:3×3 棋盘上的 8 条“胜利线”
井字棋之所以简单,是因为在 3×3 的棋盘上,可能构成“三连”的位置是有限且固定的。把棋盘按行、列编号(行列下标均为 0、1、2),全部可能的胜利线恰好有 8 条:
| 编号 | 类型 | 覆盖的格子(行, 列) |
|---|---|---|
| 1 | 行 | (0,0) (0,1) (0,2) |
| 2 | 行 | (1,0) (1,1) (1,2) |
| 3 | 行 | (2,0) (2,1) (2,2) |
| 4 | 列 | (0,0) (1,0) (2,0) |
| 5 | 列 | (0,1) (1,1) (2,1) |
| 6 | 列 | (0,2) (1,2) (2,2) |
| 7 | 主对角线 | (0,0) (1,1) (2,2) |
| 8 | 副对角线 | (0,2) (1,1) (2,0) |
因此,判定胜负的算法本质可以抽象为三步:
- 从棋盘上枚举出这 8 条线(3 行 + 3 列 + 2 条对角线);
- 对每条线判断其三个元素是否全部相等(即全是
"X"或全是"O"); - 一旦某条线三个格子同为
"X",立即返回"X wins";同为"O"立即返回"O wins";8 条线全部检查完仍无胜者,则返回"Draw"。
这个“先收集线、再逐一校验”的思路,比逐个坐标穷举更清晰,也是官方参考解法采用的方式。
三、六个官方测试用例逐一拆解
题目的 --hints-- 区块给出了 6 个测试用例,它们是自动评分系统(assert.equal(...))将实际执行的断言。逐个推演可以帮助你验证自己对规则的理解:
用例 1:第一行 X 三连
ticTacToe([["X", "X", "X"], ["O", "O", "X"], ["O", "X", "O"]])
// => "X wins"
棋盘布局:
X X X
O O X
O X O
第 0 行三个格子全是 "X",X 获胜。
用例 2:第三列 X 三连
ticTacToe([["O", "O", "X"], ["X", "O", "X"], ["O", "X", "X"]])
// => "X wins"
棋盘布局:
O O X
X O X
O X X
第 2 列(下标 c=2)的三个格子从上到下依次是 "X"、"X"、"X",X 获胜。
用例 3:平局
ticTacToe([["X", "O", "X"], ["O", "X", "O"], ["O", "X", "O"]])
// => "Draw"
棋盘布局:
X O X
O X O
O X O
逐条检查 8 条线:三行分别为 X O X、O X O、O X O,均非全同;三列分别为 X O O、O X X、X O O,也均非全同;主对角线 X X O、副对角线 X X O 同样不满足条件。无人获胜,返回 "Draw"。
用例 4:副对角线 O 三连
ticTacToe([["X", "X", "O"], ["X", "O", "O"], ["O", "O", "X"]])
// => "O wins"
棋盘布局:
X X O
X O O
O O X
副对角线(从右上到左下)为 (0,2) = "O"、(1,1) = "O"、(2,0) = "O",O 获胜。注意这个用例说明副对角线也是合法的胜利线,容易遗漏。
用例 5:主对角线 X 三连
ticTacToe([["X", "O", "O"], ["O", "X", "O"], ["O", "X", "X"]])
// => "X wins"
棋盘布局:
X O O
O X O
O X X
主对角线(从左上到右下)为 (0,0) = "X"、(1,1) = "X"、(2,2) = "X",X 获胜。
用例 6:又一个平局
ticTacToe([["O", "X", "X"], ["X", "O", "O"], ["X", "O", "X"]])
// => "Draw"
棋盘布局:
O X X
X O O
X O X
主对角线 O O X、副对角线 X O X、各行 O X X / X O O / X O X、各列 O X X / X O O / X O X,没有任何一条线三子全同,返回 "Draw"。
综合来看,6 个用例覆盖了:水平胜(用例 1)、垂直胜(用例 2)、主对角线胜(用例 5)、副对角线胜(用例 4)以及两种不同形态的平局(用例 3、6),对算法的四个输出分支做到了全覆盖。
四、官方参考解法剖析
题目自带的 --solutions-- 区块给出了完整可运行的参考实现,这里逐段拆解:
function ticTacToe(board) {
const lines = [];
for (let row of board) {
lines.push(row);
}
for (let c = 0; c < 3; c++) {
lines.push([board[0][c], board[1][c], board[2][c]]);
}
lines.push([board[0][0], board[1][1], board[2][2]]);
lines.push([board[0][2], board[1][1], board[2][0]]);
for (let line of lines) {
if (line.every(cell => cell === "X")) return "X wins";
if (line.every(cell => cell === "O")) return "O wins";
}
return "Draw";
}
第一步:收集 8 条线
解法用一个 lines 数组收集全部候选胜利线,分三个动作完成:
- 3 条行:
for (let row of board)直接把矩阵的每一行原样push进lines。由于每行本身就是一个数组,lines里存储的是对原数组的引用,但因为本题只读不写,所以不存在副作用问题; - 3 条列:
for (let c = 0; c < 3; c++)中,通过[board[0][c], board[1][c], board[2][c]]按列下标聚合三个格子,构造出新的列数组。注意这里索引的写法是固定列下标 c、变化行下标 0/1/2,这正是“取第 c 列”的含义; - 2 条对角线:主对角线取
board[0][0]、board[1][1]、board[2][2](行列下标相同),副对角线取board[0][2]、board[1][1]、board[2][0](行递增、列递减)。
最终 lines 中恰好包含 8 个长度为 3 的数组,对应前文的 8 条胜利线。
第二步:逐一校验
第二层 for...of 遍历这 8 条线,对每条线调用数组方法 every(cell => cell === "X"):
every()会依次对数组每个元素执行回调,只有全部元素满足条件才返回true,一旦遇到不满足的元素立即短路返回false;- 因此
line.every(cell => cell === "X")语义就是“该行三个格子全为 X”; - 同理
line.every(cell => cell === "O")判断“全为 O”。
只要某条线全为 X,立刻 return "X wins" 结束整个函数;否则再检查是否为全 O;两条判断都失败则继续检查下一条线。由于先检查 X 再检查 O,即使出现理论上的双胜局面也会优先返回 "X wins"(当然,合法的井字棋对局不会出现双方同时三连)。
第三步:兜底返回
8 条线全部检查完毕都没有任何一方三连,函数走到最后一行返回 "Draw",与题目对平局的定义完全一致。
这段参考解法的时间复杂度为 O(1)(棋盘大小固定为 9 个格子,至多检查 8×3 个元素),空间复杂度同样是 O(1)(lines 数组大小恒定)。代码总共只有十几行,却完整覆盖了题目的全部要求,是“数组思维”的典型示范。
五、延伸思考:其他的等价实现方式
官方解法不是唯一解。理解以下替代方案,有助于你从不同角度巩固二维数组的遍历技巧:
方案 A:不收集 lines,边遍历边判断
不构造 lines 集合,直接在循环中分别检查每一行、每一列和两条对角线。例如可以维护 xWins / oWins 两个布尔值,遍历过程中用 && 累积判断。这种方式少一层数组嵌套,但代码分支会稍显重复,且对角线判断需要单独写,整体可读性不如“收集后统一校验”。
方案 B:字符串化后比较
把每条线 join("") 成字符串,再与 "XXX"、"OOO" 比较:
if (line.join("") === "XXX") return "X wins";
if (line.join("") === "OOO") return "O wins";
思路等价,join 之后利用字符串全等比较替代 every(),对于不熟悉函数式数组方法的初学者可能更直观。
方案 C:预定义胜利坐标
把 8 条线写成硬编码的坐标三元组,例如:
const WIN_LINES = [
[[0,0],[0,1],[0,2]], [[1,0],[1,1],[1,2]], [[2,0],[2,1],[2,2]], // 行
[[0,0],[1,0],[2,0]], [[0,1],[1,1],[2,1]], [[0,2],[1,2],[2,2]], // 列
[[0,0],[1,1],[2,2]], [[0,2],[1,1],[2,0]] // 对角线
];
再对每条线的三个坐标取值比较。这种写法把“规则数据”与“判断逻辑”分离,在挑战只读传入棋盘的设定下同样成立,后续若要把棋盘泛化到任意 n×n 再改造为通用算法即可。
六、这道题在 freeCodeCamp 仓库中的位置与生态
读懂单个挑战后,把它放回 freeCodeCamp 的整体代码结构里,能更好地理解它的学习定位与工程支撑。
挑战的组织方式
所有挑战以 Markdown 文件的形式存放在 curriculum/challenges/english/blocks/daily-coding-challenges-javascript/ 目录下,每个文件对应一道题,本题是其中的 "Challenge 153"。这个区块(block)本身的元信息定义在 curriculum/structure/blocks/daily-coding-challenges-javascript.json 中,它通过 challengeOrder 数组按 id 与 title 列出了从 "Challenge 1: Vowel Balance" 一直到 240 多题的完整顺序,本题对应条目为 "Challenge 153: Tic-Tac-Toe"。该文件还标记了区块的若干运行特性,例如 helpCategory: "JavaScript"(帮助分类为 JavaScript)、usesMultifileEditor: true(使用多文件编辑器)、blockLayout: "legacy-challenge-list"(旧式挑战列表布局)等。
每日编程挑战的前端入口
在客户端,每日编程挑战有专门的展示组件。入口组件 client/src/components/daily-coding-challenge/widget.tsx 渲染“今天的挑战”按钮与“挑战归档”按钮,跳转到 /learn/daily-coding-challenge/<MM-DD> 或 /learn/daily-coding-challenge/archive 路由;同目录下的 calendar.tsx / calendar-day.tsx 则负责按日历形式展示一个月内每天对应的挑战。可以说,挑战内容在课程中的呈现是“按日期驱动”的。
挑战数据如何从服务端下发
每日挑战的真实题目内容并不写死在课程 Markdown 里(课程 Markdown 主要承担题型与判题规则的定义),而是通过 API 下发。相关路由定义在 api/src/daily-coding-challenge/routes/daily-coding-challenge.ts 中,提供 6 个公开 GET 接口:
GET /daily-coding-challenge/date/:date:按YYYY-MM-DD精确获取某一天的挑战;GET /daily-coding-challenge/day/:day:按MM-DD(月-日)获取,内部会通过getSourceDate把日期映射回源挑战日期;GET /daily-coding-challenge/today:获取今天(按美国中部时间)的挑战;GET /daily-coding-challenge/month/:month:按YYYY-MM获取整月挑战的摘要列表;GET /daily-coding-challenge/all:获取全部挑战摘要;GET /daily-coding-challenge/newest:获取最新一道挑战的日期。
每个接口的请求与响应 schema 定义在 api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts,其中单道挑战的响应结构是 { id, date, challengeNumber, title, description, javascript, python },每个语言分支下又包含 tests(判题测试,即我们在 --hints-- 中看到的断言)与 challengeFiles(种子代码文件)。日期相关的时区与映射逻辑集中在 api/src/daily-coding-challenge/utils/helpers.ts,例如 getNowUsCentral() 计算美国中部当前时间、getUtcMidnight() 归一到 UTC 零点、getSourceDate() 把任意日期循环映射回 2025-08-11 至 2026-08-10 的源挑战区间(2 月 29 日会回落到 2 月 28 日的挑战)。前端拿到数据后还会用 client/src/utils/daily-coding-challenge-validator.ts 中的 Joi schema 对响应做二次校验。
判题与提交
正如 api/src/daily-coding-challenge/README.md 所注明的,这些 GET 路由只负责获取挑战信息,而挑战的提交与判题仍复用 API 主流程的挑战完成路由:学习者在前端编辑器里补全 ticTacToe 函数后,后端会运行题面 --hints-- 中的 assert.equal(...) 断言(即第 3 节拆解的 6 个用例)来判定答案是否正确。这也解释了为什么本题的判题逻辑如此“小而纯粹”——它需要能被原样编译、运行并逐条断言。
七、小结
“Challenge 153: Tic-Tac-Toe”是一道非常适合入门数组遍历与判定逻辑的小题:它把“井字棋胜负判定”这个经典问题压缩到 3×3 的固定棋盘上,答案只有三个字符串,却要求你完整覆盖行、列、主对角线和副对角线四种情况。官方参考解法用“收集 8 条线 + every() 全等判断”的范式,实现了清晰、简洁、不易遗漏分支的代码——这一范式同样可以迁移到数独行校验、Bingo 判定、五子棋连珠检查等更多棋盘类问题中。如果你正在系统练习 freeCodeCamp 的每日编程挑战序列,建议在独立完成本题后,把解法对照 仓库中的参考实现 与上述 API、组件源码一起阅读,从而对“题目定义 → 数据下发 → 在线判题”的完整链路形成整体认识。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.21 K637- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python270
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java321
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java220
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript220
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python300