更新:我非常喜欢这个主题,所以我写了编程谜题、国际象棋局面和哈夫曼编码。如果你读完这篇文章,我已经确定了存储完整游戏状态的唯一方法是通过存储完整移动列表。继续阅读了解原因。因此,我对棋子布局的问题进行了稍微简化的版本。
问题
这张图片展示了起始的国际象棋局面。国际象棋在一个8x8的棋盘上进行,每个玩家都以相同的16个棋子开始比赛,包括8个兵,2个车,2个马,2个象,1个皇后和1个国王,如下图所示:
位置通常用列字母加行数字表示,因此白方的皇后位于d1。移动通常以象棋代数记谱的形式存储,这种方法是明确无误的,并且通常只指定必要的最小信息。考虑以下开局:
- e4 e5
- Nf3 Nc6
- …
它的翻译如下:
- 白方将王兵从e2移动到e4(它是唯一可以到达e4的棋子,因此为“e4”);
- 黑方将王兵从e7移动到e5;
- 白方将马(N)移动到f3;
- 黑方将马移动到c6。
- …
棋盘看起来像这样:
任何程序员都需要具备的重要能力是能够正确明确地指定问题。
那么缺少什么或含糊不清呢?事实证明有很多。
棋盘状态 vs 游戏状态
第一件需要确定的事情是,你是在存储游戏状态还是棋子在棋盘上的位置。仅编码棋子的位置是一回事,但问题中提到了“所有随后的合法移动”。问题也没有提及知道此刻为止的移动。我将解释这实际上是个问题。
王车易位
游戏进展如下:
- e4 e5
- Nf3 Nc6
- Bb5 a6
- Ba4 Bc5
棋盘如下所示:
白方可以选择王车易位。这需要满足国王和相关的车从未移动过,因此需要存储每一侧国王和车是否移动。如果它们不在起始位置上,那么它们已经移动,否则需要指定。
有几种处理这个问题的策略。
首先,我们可以存储额外的6位信息(对于每个车和国王各1位),以指示该棋子是否移动。如果正确的棋子恰好在其中一个6个方格中,则只需为这六个方格中的一个存储一位即可简化此过程。或者,我们可以将每个未移动的棋子视为另一种棋子类型,这样每边就不是6种棋子类型(兵、车、马、象、后和王),而是8种(添加未移动的车和未移动的国王)。
吃过路兵
国际��棋中另一条奇特且常被忽略的规则是吃过路兵。
游戏已经进展。
- e4 e5
- Nf3 Nc6
- Bb5 a6
- Ba4 Bc5
- O-O b5
- Bb3 b4
- c4
黑方的b4兵现在可以选择将其移动到c3并取下白方的c4兵。这只会在第一次机会发生,这意味着如果黑方现在放弃该选项,则下一步就无法进行。因此我们需要存储这个信息。
如果我们知道上一步棋,我们肯定可以回答是否可能进行吃过路兵。或者,我们可以存储每个兵在第四排是否刚刚向前双步移动。或者,我们可以查看棋盘上每个可能的过路兵位置,并有一个标志来指示是否可能进行过路兵。
晋升
这是白方的回合。如果白方将h7兵移动到h8,则可以晋升为任何其他棋子(但不包括国王)。99%的情况下,它会晋升为皇后,但有时不会,通常是因为这可能会导致和棋而不是取胜。这被写成:
56. h8=Q
在我们的问题中,这很重要,因为它意味着我们不能指望双方拥有固定数量的棋子。如果所有8个兵都晋升,完全有可能(但极其不可能)让一方最终拥有9个皇后、10个车、10个象或10个马。
和棋
当处于无法获胜的位置时,你最好的策略是尝试
和棋。最有可能的变体是你无法进行合法移动(通常是因为任何移动都会将你的国王置于将军状态)。在这种情况下,你可以宣布和棋。这个很容易解决。
第二个变体是通过
三次重复。如果在游戏中出现了三次相同的棋盘位置(或者在下一步将出现第三次),则可以宣布和棋。这些位置不需要按任何特定顺序出现(这意味着不必重复相同的移动顺序三次)。这个问题大大复杂化了,因为你必须记住每个以前的棋盘位置。如果这是问题的要求,则唯一可能的解决方案是存储每个先前的移动。
最后,还有
五十步规则。如果在前50个连续回合中没有兵移动或棋子被吃掉,玩家可以宣布平局,因此我们需要存储自上次兵移动或棋子被吃掉以来的回合数(两者中较晚的那个)。这需要6位(0-63)。
谁的回合?当然,我们还需要知道轮到谁了,这是一位信息。
两个问题:由于僵局情况,存储游戏状态的唯一可行或合理方式是存储导致该位置的所有移动。我将解决这个问题。棋盘状态问题将简化为:存储棋盘上所有棋子的当前位置,忽略了王车易位、吃过路兵、僵局条件和轮到谁的问题。
棋子布局可以广泛地处理为以下两种方式之一:通过存储每个方格的内容或存储每个棋子的位置。
简单内容:有六种棋子类型(兵、车、马、象、后和王)。每个棋子可以是白色或黑色,因此一个方格可能包含12种可能的棋子,或者可能为空,因此有13种可能性。13可以存储在4位(0-15)中,因此最简单的解决方案是为每个方格存储4位,共64个方格或256位信息。
这种方法的优点在于操作非常容易和快速。即使增加3个可能性也不会增加存储要求:上一回合移动了2个空位的兵、未移动的国王和未移动的车,这将解决许多之前提到的问题。
但我们可以做得更好。
13进制编码
通常将棋盘位置视为一个非常大的数字是有帮助的。这在计算机科学中经常发生。例如,
停机问题正确地将计算机程序视为一个大数字。
第一种解决方案将位置视为64位16进制数,但正如所示,这些信息中存在冗余(每个“数字”有3个未使用的可能性),因此我们可以将数字空间减少到64位13进制数字。当然,这不能像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 的内容。
2256约等于1.16e77。1364约等于1.96e71,需要237位存储空间。仅节省了7.5%的成本,代价是显著增加的操作成本。
可变基编码
在合法的棋盘上,某些棋子不能出现在某些方格中。例如,兵卒不能出现在第一或第八排,将这些方格的可能性减少到11个。这将使可能的棋盘减少到1116 x 1348 = 1.35e70(约),需要233位存储空间。
实际编码和解码这些值为十进制数(或二进制数)有点复杂,但可以可靠地完成,留给读者作为练习。
可变宽度字母表
前两种方法都可以描述为固定宽度字母表编码。字母表的11、13或16个成员中的每一个都被替换为另一个值。每个“字符”具有相同的宽度,但是考虑到每个字符的出现概率不相等,因此可以提高效率。
考虑
摩尔斯电码(如上图)。消息中的字符被编码为一系列短划和点的序列。这些短划和点通过无线电进行传输(通常是),它们之间有一个暂停来分隔它们。
注意字母E(
英语中最常用的字母)是单个点,是最短的可能序列,而Z(最不频繁出现的字母)是两个破折号和两个蜂鸣声。
这种方案可以显着减小预期消息的大小,但代价是增加随机字符序列的大小。
应注意到,摩尔斯电码具有另一个内置特性:破折号的长度与三个点相同,因此上述代码是根据此设计的,以最小化使用破折号。由于1和0(我们的构建块)没有这个问题,因此这不是我们需要复制的功能。
最后,摩尔斯电码中有两种休息方式。短暂停顿(与点的长度相同)用于区分点和破折号。较长的间隔(与破折号的长度相同)用于分隔字符。
那么这怎样适用于我们的问题呢?
哈夫曼编码
有一种处理可变长度编码的算法称为
哈夫曼编码。哈夫曼编码创建一个可变长度的代码替换,通常使用符号的预期频率赋予较常见符号更短的值。
在上面的树中,字母E被编码为000(或left-left-left),而S则为1011。很明显,这种编码方案是
无歧义的。
这与莫尔斯电码有重要区别。莫尔斯电码有字符分隔符,因此可以进行否则模棱两可的替换(例如4个点可以是H或2个I),但我们只有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位的起始位置进行异或,然后对其进行编码(使用霍夫曼编码或者例如某种
run length encoding方法)。显然,这将非常有效率(64个0可能对应64位),但存储所需的空间会随着游戏的进展而增加。
棋子位置
如前所述,攻击这个问题的另一种方法是存储每个玩家拥有的每个棋子的位置。这在终局位置下特别有效,因为大多数方格将为空(但在霍夫曼编码方法中,空方格只使用1位)。
每一方都有一个国王和0-15个其他棋子。由于晋升的缘故,这些棋子的确切组成可能会有足够的变化,以至于你不能假设基于起始位置的数字是最大值。
逻辑上将其划分为存储由两个方面(白色和黑色)组成的位置。每一方都有:
- 国王:6位用于位置;
- 是否有兵:1(是),0(否);
- 如果是,兵的数量:3位(0-7+1 = 1-8);
- 如果是,每个兵的位置编码:45位(见下文);
- 非兵的数量:4位(0-15);
- 对于每个棋子:类型(2位,皇后、车、马、象)和位置(6位)
至于兵的位置,兵只能在48个可能的格子上(不像其他棋子有64个)。因此,最好不要浪费使用每个兵6位的额外16个值。因此,如果你有8个兵,就有488种可能性,相当于28,179,280,429,056。你需要45位来编码这么多的值。
这是每方105位或总共210位。然而,起始位置是这种方法的最坏情况,随着你移除棋子,它会变得更好。
应该指出,可能性少于488,因为兵不能都在同一个格子上。第一个有48种可能性,第二个有47种,依此类推。48 x 47 x … x 41 = 1.52e13 = 44位存储。
您可以通过消除被其他棋子占据的方格(包括对方)来进一步改善这一点,这样您就可以先放置白色非兵棋子,然后是黑色非兵棋子,接着是白色兵和最后是黑色兵。在起始位置上,这将减少白色的存储需求为44位,黑色的存储需求为42位。
综合方法
另一个可能的优化是每种方法都有其优点和缺点。您可以选择最好的4种方法,然后在前两位中编码一个方案选择器,然后在此后编码方案特定的存储。
由于开销很小,这远远是最好的方法。
游戏状态
我回到了存储“游戏”而不是“位置”的问题。由于三次重复,我们必须存储到此时为止发生的移动列表。
注释
您必须确定的一件事是,您是仅存储移动列表还是对游戏进行注释?例如,国际象棋比赛经常进行注释:
17.Bb5!! Nc4?
白色的走法被标记为两个惊叹号,表示出色,而黑色的走法则被视为错误。请参见
Chess punctuation。
此外,您还可能需要存储自由文本,因为移动是描述的。
我假设移动足够了,因此不需要注释。
代数表示法
我们可以简单地在此处存储移动的文本(“e4”,“Bxb5”等)。包括终止字节,每个移动大约需要6个字节(最坏情况)。这不是特别高效的方法。
尝试的第二件事是存储起始位置(6位)和结束位置(6位),因此每个移动需要12位。这要好得多。
或者,我们可以以可预测和确定的方式从当前位置确定所有合法移动,并说明我们选择了哪一个。然后回到上面提到的变量基数编码。白色和黑色各有20个可能的移动,第一次移动时更多,依此类推。
结论
这个问题没有绝对正确的答案。有许多可能的方法,上述只是其中的几种。
我喜欢这个和类似问题的原因是它要求任何程序员都需要具备的能力,例如考虑使用模式、准确确定需求并思考边界情况。
国际象棋位置截图来自
Chess Position Trainer 。