更新:我非常喜欢这个主题,我写了Programming Puzzles, Chess Positions 和 Huffman Coding。如果您通读本文,我已经确定存储完整游戏状态的唯一方法是存储完整的移动列表。请继续阅读以了解原因。因此,我使用问题的略微简化版本来进行拼图布局。
问题
此图说明了国际象棋的起始位置。国际象棋发生在一个 8x8 棋盘上,每个玩家从一组相同的 16 个棋子开始,包括 8 个棋子、2 个车、2 个马、2 个象、1 个皇后和 1 个国王,如下所示:

位置通常记录为列的字母,后跟行的数字,因此白方的皇后在 d1。移动通常以代数符号存储,这是明确的,通常只指定必要的最少信息。考虑这个开口:
- e4 e5
- Nf3 Nc6
- …</li>
翻译为:
- 白方将国王的兵从 e2 移动到 e4(这是唯一可以到达 e4 的棋子,因此是“e4”);
- 黑方将国王的棋子从e7移到e5;
- 白将马(N)移至f3;
- 黑将马移动到c6。
- …</li>
董事会看起来像这样:

任何程序员的一项重要能力是能够正确且明确地指定问题。
那么有什么遗漏或模棱两可的呢?事实证明很多。
棋盘状态与游戏状态
您需要确定的第一件事是存储游戏的状态还是棋盘上棋子的位置。简单地编码棋子的位置是一回事,但问题是“所有后续的合法动作”。这个问题也没有说明知道到目前为止的动作。正如我将解释的那样,这实际上是一个问题。
卡斯特林
比赛进行如下:
- e4 e5
- Nf3 Nc6
- Bb5 a6
- Ba4 Bc5
该板如下所示:

白有易位的选择。对此的部分要求是国王和相关的车永远不会移动,因此需要存储国王或双方的任何一个车是否移动。显然,如果他们不在他们的起始位置,他们已经移动,否则需要指定。
有几种策略可用于处理此问题。
首先,我们可以存储额外的 6 位信息(每个 rook 和 king 1 个)来指示该棋子是否已经移动。如果正确的部分恰好在其中,我们可以通过只为这六个正方形之一存储一点来简化这一点。或者,我们可以将每个未移动的棋子视为另一种棋子类型,而不是每边的 6 个棋子类型(棋子、车、马、主教、皇后和国王)有 8 个(添加未移动的车和未移动的国王)。
过路人
国际象棋中另一个特殊且经常被忽视的规则是En Passant。

比赛进行了。
- e4 e5
- Nf3 Nc6
- Bb5 a6
- Ba4 Bc5
- OO b5
- BB3 B4
- c4
黑方在 b4 上的兵现在可以选择将他在 b4 上的兵移动到 c3 上,然后将白方在 c4 上的兵。这仅在第一次机会时发生,这意味着如果黑方现在放弃该选项,他将无法采取下一步行动。所以我们需要存储它。
如果我们知道上一步,我们肯定可以回答 En Passant 是否可行。或者,我们可以存储其第 4 级的每个棋子是否刚刚移动到那里并向前移动。或者我们可以查看棋盘上每个可能的 En Passant 位置,并有一个标志来指示它是否可能。
晋升

这是白棋的一步。如果白方将他的棋子从 h7 移到 h8,它可以升级为任何其他棋子(但不能升级为国王)。99% 的情况下它会被提升为女王,但有时不会,通常是因为这可能会迫使您陷入僵局,否则您会获胜。这写成:
- h8=Q
这在我们的问题中很重要,因为这意味着我们不能指望每边都有固定数量的棋子。如果所有 8 个棋子都得到提升,那么一方完全有可能(但极不可能)最终得到 9 个皇后、10 个车、10 个象或 10 个骑士。
相持
当处于你无法取胜的位置时,你最好的策略是尝试相持。最可能的变体是您无法进行合法的移动(通常是因为在控制您的国王时任何移动)。在这种情况下,您可以要求平局。这个很容易满足。
第二种变体是三重重复。如果相同的棋盘位置在一场比赛中出现了 3 次(或将在下一步出现第 3 次),则可以主张平局。这些位置不需要以任何特定的顺序出现(这意味着它不必重复三次相同的移动顺序)。这使问题变得非常复杂,因为您必须记住以前的每一个棋盘位置。如果这是问题的要求,则问题的唯一可能解决方案是存储以前的每一步。
最后,还有五十步规则。如果在前 50 次连续移动中没有棋子移动并且没有棋子被拿走,玩家可以声称平局,因此我们需要存储自从棋子被移动或拿走棋子以来的移动数(两者中的最新一次。这需要6 位 (0-63)。
该轮到谁啦?
当然,我们还需要知道轮到谁了,这只是一点点信息。
两个问题
由于僵局的情况,存储游戏状态的唯一可行或明智的方法是存储导致该位置的所有移动。我会解决这个问题。棋盘状态问题将简化为:存储棋盘上所有棋子的当前位置,忽略易位、过路、相持条件以及轮到谁。
棋子布局可以通过以下两种方式之一进行广泛处理:通过存储每个方格的内容或通过存储每个棋子的位置。
简单的内容
有六种棋子类型(棋子、车、马、主教、皇后和国王)。每个棋子可以是白色或黑色,因此一个正方形可能包含 12 个可能的棋子之一,或者它可能是空的,因此有 13 种可能性。13可以存储4位(0-15)所以最简单的解决方案是每个平方存储4位乘以64个平方或256位信息。
这种方法的优点是操作非常容易和快速。这甚至可以通过在不增加存储要求的情况下增加 3 种可能性来扩展:在最后一回合移动了 2 个空格的棋子、没有移动的国王和没有移动的车,这将满足很多需求前面提到的问题。
但我们可以做得更好。
Base 13 编码
将董事会职位视为一个非常大的数字通常会有所帮助。这通常在计算机科学中完成。例如,停机问题(正确地)将计算机程序视为一个大数。
第一个解决方案将位置视为 64 位以 16 为基数的数字,但正如所证明的那样,此信息中存在冗余(每个“数字”有 3 个未使用的可能性),因此我们可以将数字空间减少到 64 个以 13 为基数的数字。当然,这不能像 base 16 那样高效,但它会节省存储需求(并且最小化存储空间是我们的目标)。
以 10 为底,数字 234 等价于 2 x 10 2 + 3 x 10 1 + 4 x 10 0。
在基数 16 中,数字 0xA50 等价于 10 x 16 2 + 5 x 16 1 + 0 x 16 0 = 2640(十进制)。
所以我们可以将我们的位置编码为 p 0 x 13 63 + p 1 x 13 62 + ... + p 63 x 13 0其中 p i代表正方形i的内容。
2 256大约等于 1.16e77。13 64大约等于 1.96e71,需要 237 位存储空间。仅节省 7.5% 的代价就是显着增加了操作成本。
可变碱基编码
在法律板上,某些棋子不能出现在某些方格中。例如,棋子不能出现在第一排或第八排,从而将这些方格的可能性减少到 11。这将可能的棋盘减少到 11 16 x 13 48 = 1.35e70(大约),需要 233 位存储空间。
实际上,在十进制(或二进制)之间编码和解码这些值有点复杂,但它可以可靠地完成,并留给读者作为练习。
可变宽度字母
前两种方法都可以描述为定宽字母编码。字母表的 11、13 或 16 个成员中的每一个都替换为另一个值。每个“字符”的宽度相同,但是当您考虑每个字符的可能性不相等时,效率可以提高。

考虑摩尔斯电码(如上图所示)。消息中的字符被编码为一系列破折号和点。这些破折号和圆点通过无线电传输(通常),它们之间有一个暂停来分隔它们。
请注意字母 E(英语中最常见的字母)是一个单点,是可能的最短序列,而 Z(最不常见)是两个破折号和两个哔哔声。
这种方案可以显着减小预期消息的大小,但代价是增加了随机字符序列的大小。
应该注意的是,摩尔斯电码还有另一个内置功能:破折号长达三个点,因此在创建上述代码时考虑到这一点,以尽量减少破折号的使用。由于 1 和 0(我们的构建块)没有这个问题,所以我们不需要复制它。
最后,摩尔斯电码中有两种休止符。短休止符(点的长度)用于区分点和破折号。较长的间隙(破折号的长度)用于分隔字符。
那么这如何适用于我们的问题呢?
霍夫曼编码
有一种处理可变长度代码的算法称为霍夫曼编码。霍夫曼编码创建可变长度代码替换,通常使用符号的预期频率来为更常见的符号分配较短的值。

在上面的树中,字母 E 被编码为 000(或 left-left-left),S 为 1011。应该清楚的是,这种编码方案是明确的。
这是与摩尔斯电码的一个重要区别。摩尔斯电码具有字符分隔符,因此它可以进行其他不明确的替换(例如,4 个点可以是 H 或 2 个 Is),但我们只有 1 和 0,因此我们选择了明确的替换。
下面是一个简单的实现:
private static class Node {
private final Node left;
private final Node right;
private final String label;
private final int weight;
private Node(String label, int weight) {
this.left = null;
this.right = null;
this.label = label;
this.weight = weight;
}
public Node(Node left, Node right) {
this.left = left;
this.right = right;
label = "";
weight = left.weight + right.weight;
}
public boolean isLeaf() { return left == null && right == null; }
public Node getLeft() { return left; }
public Node getRight() { return right; }
public String getLabel() { return label; }
public int getWeight() { return weight; }
}
使用静态数据:
private final static List<string> COLOURS;
private final static Map<string, integer> WEIGHTS;
static {
List<string> list = new ArrayList<string>();
list.add("White");
list.add("Black");
COLOURS = Collections.unmodifiableList(list);
Map<string, integer> map = new HashMap<string, integer>();
for (String colour : COLOURS) {
map.put(colour + " " + "King", 1);
map.put(colour + " " + "Queen";, 1);
map.put(colour + " " + "Rook", 2);
map.put(colour + " " + "Knight", 2);
map.put(colour + " " + "Bishop";, 2);
map.put(colour + " " + "Pawn", 8);
}
map.put("Empty", 32);
WEIGHTS = Collections.unmodifiableMap(map);
}
和:
private static class WeightComparator implements Comparator<node> {
@Override
public int compare(Node o1, Node o2) {
if (o1.getWeight() == o2.getWeight()) {
return 0;
} else {
return o1.getWeight() < o2.getWeight() ? -1 : 1;
}
}
}
private static class PathComparator implements Comparator<string> {
@Override
public int compare(String o1, String o2) {
if (o1 == null) {
return o2 == null ? 0 : -1;
} else if (o2 == null) {
return 1;
} else {
int length1 = o1.length();
int length2 = o2.length();
if (length1 == length2) {
return o1.compareTo(o2);
} else {
return length1 < length2 ? -1 : 1;
}
}
}
}
public static void main(String args[]) {
PriorityQueue<node> queue = new PriorityQueue<node>(WEIGHTS.size(),
new WeightComparator());
for (Map.Entry<string, integer> entry : WEIGHTS.entrySet()) {
queue.add(new Node(entry.getKey(), entry.getValue()));
}
while (queue.size() > 1) {
Node first = queue.poll();
Node second = queue.poll();
queue.add(new Node(first, second));
}
Map<string, node> nodes = new TreeMap<string, node>(new PathComparator());
addLeaves(nodes, queue.peek(), "");
for (Map.Entry<string, node> entry : nodes.entrySet()) {
System.out.printf("%s %s%n", entry.getKey(), entry.getValue().getLabel());
}
}
public static void addLeaves(Map<string, node> nodes, Node node, String prefix) {
if (node != null) {
addLeaves(nodes, node.getLeft(), prefix + "0");
addLeaves(nodes, node.getRight(), prefix + "1");
if (node.isLeaf()) {
nodes.put(prefix, node);
}
}
}
一种可能的输出是:
White Black
Empty 0
Pawn 110 100
Rook 11111 11110
Knight 10110 10101
Bishop 10100 11100
Queen 111010 111011
King 101110 101111
对于起始位置,这等于 32 x 1 + 16 x 3 + 12 x 5 + 4 x 6 = 164 位。
状态差异
另一种可能的方法是将第一种方法与霍夫曼编码相结合。这是基于这样的假设,即大多数预期的棋盘(而不是随机生成的棋盘)更有可能(至少部分地)类似于起始位置。
因此,您要做的是将 256 位当前板位置与 256 位起始位置进行异或,然后对其进行编码(使用 Huffman 编码,或者说,某种运行长度编码方法)。显然,这将非常有效(64 个 0 可能对应于 64 位),但随着游戏的进行需要增加存储空间。
棋子位置
如前所述,解决此问题的另一种方法是存储玩家拥有的每个棋子的位置。这特别适用于大多数方格为空的残局位置(但在霍夫曼编码方法中,空方格无论如何只使用 1 位)。
每一方都有一个国王和0-15个其他棋子。由于促销,这些部分的确切组成可能会发生很大变化,以至于您不能假设基于起始位置的数字是最大值。
划分它的逻辑方法是存储一个由两条边(白色和黑色)组成的位置。每一方都有:
- A king:6位为位置;
- 有棋子:1(是),0(否);
- 如果是,棋子数:3 位(0-7+1 = 1-8);
- 如果是,则对每个棋子的位置进行编码:45 位(见下文);
- 非棋子数:4位(0-15);
- 对于每件:类型(女王、车、骑士、主教 2 位)和位置(6 位)
至于棋子的位置,棋子只能在 48 个可能的方格上(而不是像其他方格一样的 64 个)。因此,最好不要浪费每个 pawn 使用 6 位会使用的额外 16 个值。因此,如果您有 8 个棋子,则有 48 8个可能性,等于 28,179,280,429,056。您需要 45 位来编码那么多值。
这是每边 105 位或总共 210 位。然而,对于这种方法来说,起始位置是最坏的情况,当你移除碎片时它会变得更好。
需要指出的是,少于 48 的可能性有8个,因为棋子不可能都在同一个方格中。第一个有 48 个可能性,第二个有 47 个,依此类推。48 x 47 x … x 41 = 1.52e13 = 44 位存储。
您可以通过消除其他棋子(包括另一边)占据的方格来进一步改善这一点,这样您就可以先放置白色非棋子,然后放置黑色非棋子,然后放置白色棋子,最后放置黑色棋子。在起始位置,这将白色的存储要求降低到 44 位,黑色的存储要求降低到 42 位。
组合方法
另一种可能的优化是这些方法中的每一种都有其优点和缺点。比如说,你可以选择最好的 4 个,然后在前两位编码一个方案选择器,然后再编码方案特定的存储。
由于开销很小,这将是迄今为止最好的方法。
游戏状态
我回到存储游戏而不是位置的问题。由于三重重复,我们必须存储到目前为止发生的移动列表。
注释
您必须确定的一件事是您只是存储移动列表还是注释游戏?国际象棋游戏经常被注释,例如:
- BB5!!NC4?
白棋以两个感叹号标记为绝妙,而黑棋则被视为错误。请参阅国际象棋标点符号。
此外,您可能还需要在描述移动时存储自由文本。
我假设这些动作已经足够了,所以不会有注释。
代数记法
我们可以简单地将移动的文本存储在这里(“e4”、“Bxb5”等)。包括一个终止字节,每次移动大约需要 6 个字节(48 位)(最坏情况)。这不是特别有效。
要尝试的第二件事是存储起始位置(6 位)和结束位置(6 位),因此每次移动 12 位。那要好得多。
或者,我们可以以可预测和确定的方式确定当前位置的所有合法移动,并说明我们选择的状态。然后回到上面提到的可变基编码。白方和黑方在他们的第一步中各有 20 种可能的步法,第二步则更多,以此类推。
结论
这个问题没有绝对正确的答案。有许多可能的方法,以上只是其中的一小部分。
我喜欢这个问题和类似问题的地方在于,它需要对任何程序员都很重要的能力,比如考虑使用模式、准确确定需求和考虑极端情况。
国际象棋位置作为国际象棋位置训练器的屏幕截图。