很长时间卡在 512 的关卡过不去,一度怀疑是运气问题。后来忍不了了,把 2048 的源码翻出来看——看完第一反应是"就这?":核心移动逻辑不到一百行。第二反应是,原来我输掉的大部分对局,从算法视角看是必然的:我一直在犯几个策略层面可证明的错误。
这篇文章把 2048 的实现和策略都拆开:方块怎么合并、新方块怎么出生、为什么"大数压角"不是玄学、AI 是怎么玩到 32768 的。看完你大概率也能上 2048,当然,也可能从此再也回不去随手滑两下的快乐了。
一次滑动的完整过程
2048 的棋盘是 4×4,但所有移动都退化成一维问题:每次滑动,把每一行(或列)沿滑动方向独立处理。一行内的处理分三步:
以 [2, 2, 4, 4] 向左滑为例:
- 压缩:非空方块全部挤到一侧 →
[2, 2, 4, 4](这行本来就没空格,形状不变); - 合并:从滑动方向开始扫描,相邻且相等的一对方块合并,得到
[4, 8, 空, 空]; - 再压缩:把合并产生的空位挤掉 →
[4, 8, 空, 空]。
得分规则:每次合并,得分加上合并后新方块的数值。上面这步 2+2=4 得 4 分,4+4=8 得 8 分,合计 +12。
实现里最容易写错的边界条件有三个:
一个方块只合并一次。[4, 4, 4] 向左滑的结果是 [8, 4],不是 [16]。合并过的方块必须打上标记,不能再参与本轮后续合并。正确顺序是扫描方向上先到的先合并,[4, 4, 4] → [8, 空, 4] → 压缩 → [8, 4]。
合并顺序跟滑动方向一致。向左滑时从左往右扫,向右滑时从右往左扫。方向搞反了,[2, 2, 2] 向右滑会得到 [2, 4](最右的 2 保留)而不是 [4, 2]。虽然总分一样,但位置不一样,高手局的布局差一格就是死活之差。
没有有效移动就不生成新方块。滑动后棋盘没有任何变化(既没压缩也没合并),这一步不计入回合——不产生新方块、不生成随机数。这是个防刷机制:让你对着静止棋盘反复按键不会白嫖新的 2。
新方块的出生规则
每次有效移动后,在一个随机的空格里生成一个新方块,90% 是 2,10% 是 4。这是原版源码里写死的:
// 原版 game.js 的核心一行
var value = Math.random() < 0.9 ? 2 : 4;
两个直接推论:
"全是 4"的对局存在但罕见。连续 16 次都出 4 的概率是 0.1¹⁶ ≈ 10⁻¹⁶,理论上可能,实际上你这辈子在游戏里遇不到。但"这局特别难打"往往不是因为出了很多 4——一局正常打下来 4 的期望占比就是 10%,几十个方块里有五六个 4 完全正常,人的直觉会把这种正常波动解读成"游戏针对我"。
游戏不靠随机数作弊。原版的出生位置和数值都是纯随机,没有"你快赢了就给你塞个坏块"的针对逻辑。觉得被针对,大概率是伪随机数的分布特性和人的归因偏差在合伙骗你。
游戏什么时候结束
死亡判定:棋盘满(无空格)且没有任何相邻等值对。两个条件缺一不可——棋盘满但还有相邻相等的方块,滑一下又能合并,继续活。
这个判定给了策略第一条铁律:保持空格数量。空格是缓冲区,空格越多,一步坏棋的容错越高。死亡时棋盘上有几个 2 都无所谓,没有空格才是死因。新手最常见的死法是棋盘上塞满了 2 和 4 的小碎块——碎块是空格杀手。
最大可达方块也有理论答案:4×4 棋盘上,凑出 2ⁿ 需要两个 2ⁿ⁻¹,递推下去需要 2ⁿ 个 2。棋盘 16 格理论上限是 131072(2¹⁷),前提是所有 2 恰好按最优顺序出生——所以它只是数学上界。实际对局,顶级 AI(后面会讲)能稳定打出 32768。
为什么"大数压角"不是玄学
高手的棋盘几乎长一个样:最大的数在一个角,沿一条"蛇形"路径依次递减(2048→1024→512→256→128→64→32→16……)。这不是玄学,是合并链的物理约束:
大数要翻倍,需要把另一个等值数滑到它旁边。如果最大数在角落且整行单调递减,那么这一行是一个稳定的"仓库":1024 在 2048 旁边等着,512 在 1024 旁边等着,合并链永远不会断。反过来,如果大数在棋盘中间游走,它的"配偶"可能出现在任何方向,每次合并都要费几步重新列队,这几步里空格在流失。
由此推出两条实操规则:
最大数进了角就别让它出来。选定一个方向(比如左)作为主方向,垂直方向(上下)使用时确保最大数那行是满的——满行滑动时整行不会平移,大数才不会被挤离角落。
永远保留一条"退路"方向。只按两个方向(比如只左右 + 偶尔向下)的玩家,死亡速度快得多。禁止使用一个方向(比如从不按左),意味着那个方向来的一切都靠其他三步消化。这是对"大数压角"的过度执行——压角指的是"大数不走",不是"少用一个方向"。
AI 是怎么玩到 32768 的
原版发布后没多久,Stack Overflow 上出现了一个著名的回答(nneonneo 的实现),AI 平均能稳定达到 32768 方块。它的思路是搜索算法 expectimax,和 minimax 只差一个词:
- 玩家节点:四个滑动方向,取评估分最高的那个(max);
- 随机节点:新方块可能出现在任何空格、以 0.9/0.1 的概率取 2/4,按期望值评估(expect)。
为什么用 expectimax 而不是 minimax?因为 2048 的"对手"(随机数)不会针对你——它没有恶意,只是概率分布。按"最坏情况"做决策(minimax)会过度保守,按"期望"做决策(expectimax)才是正确的风险观。
搜索深度 3-6 层,关键在评估函数。AI 给每个局面打分,几项权重加起来:
| 评估项 | 直觉解释 |
|---|---|
| 单调性 | 行/列沿蛇形路径递减,奖励仓库结构 |
| 平滑度 | 相邻方块 log₂ 值接近,奖励"快能合并" |
| 空格数量 | 奖励缓冲区 |
| 最大数贴角 | 奖励大数的稳定性 |
这几项里,单调性的权重最大。你玩不出 2048 的原因,基本就是单调性被打碎了。
位棋盘:一行一次查表
nneonneo 那个实现还有个工程上的惊艳之处:整个棋盘压进一个 64 位整数。每个方块用 4 位(半字节)存它的 log₂ 值(2 存 1,4 存 2,……空格存 0),16 个方块正好 64 位。
更妙的是,一行 4 个方块 = 16 位 = 65536 种可能。预先算好 65536 项的移动表(每种行状态向左/向右滑的结果和得分),运行时一次移动 = 4 次查表(4 行)+ 按位运算拼转置。上下移动用"转置棋盘后复用左右表"的技巧,连第二张表都省了。整个 AI 每秒能评估数百万个局面,让深度 6 的搜索成为可能。
"用表换计算"这个思路值得记住:状态空间可枚举时,预处理是最好的优化。Levenshtein 距离的滚动数组、正则引擎的 DFA 化,本质都是同一招。
想自己上 2048
策略压缩成三句话:大数压角、保持单调、留足空格。行为层面就是:确定一个主方向(比如左),最大数行保持满格,非必要不碰垂直方向,碎块优先合并。
说了这么多,不如直接开一把验证:在线 2048 游戏 支持键盘和触屏,空格少的时候记得深呼吸再按方向键——毕竟从概率上讲,AI 都会犯错,何况我们。想换个口味,滑块拼图是另一种"保持结构"的训练。
最后留一个我没能解决的问题:打上 2048 之后游戏会问你"继续吗",但没有任何机制阻止你继续。理论上 4×4 的极限是 131072,AI 摸到过 32768。131072 这最后四倍的距离,人类和 AI 目前都还没真正跨过去——这个游戏比它看起来难得多。
评论