程序员难题:在整个游戏过程中编码棋盘状态

100

并不是一个严格的问题,更像是一个谜题...

这些年来,我参与过一些新员工技术面试。除了问"你知道X技术吗"这样的标准问题外,我还尝试了解他们如何解决问题。通常,我会在面试前一天通过电子邮件发送问题,并期望他们在隔天想出解决方案。

通常结果会相当有趣——错误的,但很有趣——如果他们能够解释为什么采取了特定的方法,那么他们仍然会得到我的推荐。

所以我想把其中一个问题提供给Stack Overflow的听众。

问题:您能想到最节省空间的方式来编码国际象棋游戏(或其子集)的状态吗?也就是说,给定一个棋盘并合法地排列棋子,对游戏中所有初始状态和所有后续的合法移动进行编码。

答案不需要包含代码,只需描述您将使用的算法即可。

编辑:正如其中一位发布者所指出的那样,我没有考虑移动之间的时间间隔。如有需要,请自由添加这个作为可选项 :)

编辑2:仅作进一步澄清...请记住,编码器/解码器是具有规则意识的。唯一真正需要存储的是玩家的选择——任何其他东西都可以被编码器/解码器认为是已知的。

编辑3:在这里挑选一个赢家会很困难:) 有很多好答案!


4
国际象棋游戏的初始状态不是明确定义的吗?为什么需要对其进行编码?我认为只对每个回合(即行动)之间的差异进行编码应该就足够了。 - tanascius
6
严格来说,您还需要编码所有过去的位置,因为如果相同的位置出现三次,则为平局。http://en.wikipedia.org/wiki/Threefold_repetition - flybywire
4
建议:将此变成一个真正的比赛,人们可以提交他们编写的程序。这个程序可以将一局国际象棋比赛作为输入(你可以定义一个基本的、易读的、非优化的格式),并输出压缩后的比赛。使用一个参数,它可以接收压缩后的比赛,并重新生成原始输入,这个输入必须与原来的相匹配。 - Vilx-
2
我会回复:“存储很便宜。将其以人类可读的形式存储,如果有十亿个,则进行压缩”。 - No Refunds No Returns
2
更重要的是,这将表明您无法遵循说明...即使最优秀的编码人员在某些时候也需要遵循说明。我曾经遇到过这样的情况,被告知以某种方式实现某个功能,尽管我认为(并说)这是一个愚蠢的实现,但最终却因为没有了解或理解实现方式背后的非常好的原因而感到尴尬。 - Andrew Rollings
显示剩余18条评论
31个回答

133

更新:我非常喜欢这个主题,所以我写了编程谜题、国际象棋局面和哈夫曼编码。如果你读完这篇文章,我已经确定了存储完整游戏状态的唯一方法是通过存储完整移动列表。继续阅读了解原因。因此,我对棋子布局的问题进行了稍微简化的版本。

问题

这张图片展示了起始的国际象棋局面。国际象棋在一个8x8的棋盘上进行,每个玩家都以相同的16个棋子开始比赛,包括8个兵,2个车,2个马,2个象,1个皇后和1个国王,如下图所示:

starting chess position

位置通常用列字母加行数字表示,因此白方的皇后位于d1。移动通常以象棋代数记谱的形式存储,这种方法是明确无误的,并且通常只指定必要的最小信息。考虑以下开局:

  1. e4 e5
  2. Nf3 Nc6

它的翻译如下:

  1. 白方将王兵从e2移动到e4(它是唯一可以到达e4的棋子,因此为“e4”);
  2. 黑方将王兵从e7移动到e5;
  3. 白方将马(N)移动到f3;
  4. 黑方将马移动到c6。

棋盘看起来像这样:

early opening

任何程序员都需要具备的重要能力是能够正确明确地指定问题。

那么缺少什么或含糊不清呢?事实证明有很多。

棋盘状态 vs 游戏状态

第一件需要确定的事情是,你是在存储游戏状态还是棋子在棋盘上的位置。仅编码棋子的位置是一回事,但问题中提到了“所有随后的合法移动”。问题也没有提及知道此刻为止的移动。我将解释这实际上是个问题。

王车易位

游戏进展如下:

  1. e4 e5
  2. Nf3 Nc6
  3. Bb5 a6
  4. Ba4 Bc5

棋盘如下所示:

later opening

白方可以选择王车易位。这需要满足国王和相关的车从未移动过,因此需要存储每一侧国王和车是否移动。如果它们不在起始位置上,那么它们已经移动,否则需要指定。

有几种处理这个问题的策略。

首先,我们可以存储额外的6位信息(对于每个车和国王各1位),以指示该棋子是否移动。如果正确的棋子恰好在其中一个6个方格中,则只需为这六个方格中的一个存储一位即可简化此过程。或者,我们可以将每个未移动的棋子视为另一种棋子类型,这样每边就不是6种棋子类型(兵、车、马、象、后和王),而是8种(添加未移动的车和未移动的国王)。

吃过路兵

国际��棋中另一条奇特且常被忽略的规则是吃过路兵

en passant

游戏已经进展。

  1. e4 e5
  2. Nf3 Nc6
  3. Bb5 a6
  4. Ba4 Bc5
  5. O-O b5
  6. Bb3 b4
  7. c4

黑方的b4兵现在可以选择将其移动到c3并取下白方的c4兵。这只会在第一次机会发生,这意味着如果黑方现在放弃该选项,则下一步就无法进行。因此我们需要存储这个信息。

如果我们知道上一步棋,我们肯定可以回答是否可能进行吃过路兵。或者,我们可以存储每个兵在第四排是否刚刚向前双步移动。或者,我们可以查看棋盘上每个可能的过路兵位置,并有一个标志来指示是否可能进行过路兵。

晋升

pawn promotion

这是白方的回合。如果白方将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(十进制)。
所以我们可以将我们的位置编码为p0 x 1363 + p1 x 1362 + ... + p63 x 130,其中pi表示方格 i 的内容。

2256约等于1.16e77。1364约等于1.96e71,需要237位存储空间。仅节省了7.5%的成本,代价是显著增加的操作成本。

可变基编码

在合法的棋盘上,某些棋子不能出现在某些方格中。例如,兵卒不能出现在第一或第八排,将这些方格的可能性减少到11个。这将使可能的棋盘减少到1116 x 1348 = 1.35e70(约),需要233位存储空间。

实际编码和解码这些值为十进制数(或二进制数)有点复杂,但可以可靠地完成,留给读者作为练习。

可变宽度字母表

前两种方法都可以描述为固定宽度字母表编码。字母表的11、13或16个成员中的每一个都被替换为另一个值。每个“字符”具有相同的宽度,但是考虑到每个字符的出现概率不相等,因此可以提高效率。

morse code

考虑摩尔斯电码(如上图)。消息中的字符被编码为一系列短划和点的序列。这些短划和点通过无线电进行传输(通常是),它们之间有一个暂停来分隔它们。
注意字母E(英语中最常用的字母)是单个点,是最短的可能序列,而Z(最不频繁出现的字母)是两个破折号和两个蜂鸣声。
这种方案可以显着减小预期消息的大小,但代价是增加随机字符序列的大小。
应注意到,摩尔斯电码具有另一个内置特性:破折号的长度与三个点相同,因此上述代码是根据此设计的,以最小化使用破折号。由于1和0(我们的构建块)没有这个问题,因此这不是我们需要复制的功能。
最后,摩尔斯电码中有两种休息方式。短暂停顿(与点的长度相同)用于区分点和破折号。较长的间隔(与破折号的长度相同)用于分隔字符。
那么这怎样适用于我们的问题呢?

哈夫曼编码

有一种处理可变长度编码的算法称为哈夫曼编码。哈夫曼编码创建一个可变长度的代码替换,通常使用符号的预期频率赋予较常见符号更短的值。

Huffman code tree

在上面的树中,字母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(), &quot;&quot;);
  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

3
然后压缩结果(如果头信息不会增加结果的大小);^) - Toad
5
好的帖子。小修正:王车易位需要4位,每个方向(白色和黑色,王翼和后翼)各一位,因为战车可能已经移动并且回到原位。更加重要的是,您应该明确指出当前轮到哪方走棋。=) - A. Rex
10
关于晋升为骑士,我曾经做过一次。情况非常惊险——他只差一步就将我困死,我束手无策。他忽略了我的兵,因为虽然兵可以晋升,但已经晚了一步。如果我当时能晋升为骑士并将他困死,那该多好啊!真希望那时有摄像机记录下这一刻! - Loren Pechtel
2
我很惊讶你的文章没有提到[FEN][1],它处理了王车易位、吃过路兵等问题。 [1] http://en.wikipedia.org/wiki/FEN - Ross
1
即使要完全支持50步规则和三次重复规则,也只需要严格地表示棋盘内容,就在最后一次兵移动或棋子被吃掉之后(包括王车易位和吃过路兵的权利),再加上自那个位置以来的所有走法。为此,记录整个历史并不是必要的。 - wberry
显示剩余24条评论

48

最好将国际象棋游戏存储在一种易于阅读且标准的格式中。

Portable Game Notation 假定一个标准的起始位置(尽管它不必如此),然后按回合列出移动。这是一种紧凑、易于阅读的标准格式。

例如:

[Event "F/S Return Match"]
[Site "Belgrade, Serbia Yugoslavia|JUG"]
[Date "1992.11.04"]
[Round "29"]
[White "Fischer, Robert J."]
[Black "Spassky, Boris V."]
[Result "1/2-1/2"]

1. e4 e5 2. Nf3 Nc6 3. Bb5 {This opening is called the Ruy Lopez.} 3... a6
4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8  10. d4 Nbd7
11. c4 c6 12. cxb5 axb5 13. Nc3 Bb7 14. Bg5 b4 15. Nb1 h6 16. Bh4 c5 17. dxe5
Nxe4 18. Bxe7 Qxe7 19. exd6 Qf6 20. Nbd2 Nxd6 21. Nc4 Nxc4 22. Bxc4 Nb6
23. Ne5 Rae8 24. Bxf7+ Rxf7 25. Nxf7 Rxe1+ 26. Qxe1 Kxf7 27. Qe3 Qg5 28. Qxg5
hxg5 29. b3 Ke6 30. a3 Kd6 31. axb4 cxb4 32. Ra5 Nd5 33. f3 Bc8 34. Kf2 Bf5
35. Ra7 g6 36. Ra6+ Kc5 37. Ke1 Nf4 38. g3 Nxh3 39. Kd2 Kb5 40. Rd6 Kc5 41. Ra6
Nf2 42. g4 Bd3 43. Re6 1/2-1/2

如果您想将其缩小,那么只需压缩即可。工作完成!


23
针对这个被两个踩的回答,我想辩护一下:1)它可以实现你想要的功能。2)它通过了http://thedailywtf.com/articles/riddle-me-an-interview.aspx测试:“……能够解决这些谜题的人中,有一些恰恰不是你想要的程序员类型。你会愿意与那个人共事吗?他建造一个排水量刻度/驳船,用它将一架747飞机开到码头,然后再用它来称重巨型喷气式飞机,而不是在第一时间就联系波音公司吗?”你不会雇佣那些在面试中发明随机编码的人,因为他们在写代码时也会这样做。 - Rob Grant
1
好的,如果我特别要求他们解决一个问题以了解他们的解决问题技巧,那么你可以假设我会用其他问题来涵盖其他方面。 - Andrew Rollings
7
@reinier: 我并不是说我完全不了解信息密度问题(你把我的回复误解为是无能的承认)。当然,你想要雇用符合现有数据存储标准的编程人员,并且认识到使用适当的现有工具而不是自己编写工具可能是个好主意——“我们发明了2.0版本的轮子!它现在更加圆润!” 你绝对不想雇用那些认为使用库函数是软弱的表现的人。 - Rob Grant
18
在面试中,这绝对会是我对这个问题的第一反应。你想展示你的第一直觉是寻找一个准备好的解决方案。如果面试官告诉你他们想听听你自己能想出什么解决方案,那么你可以提出一种位压缩的解决方案。 - Bill the Lizard
2
在这件事上,我支持Robert的看法 - 现有方案实用、易于人理解并且足够紧凑。与定制的超级压缩解决方案和复杂算法相比,所有这些都是重大成就。如果涉及到面试,我一定也会考虑实用方面!你会惊讶地发现有多少聪明人提出了超级复杂的不切实际的解决方案。这通常归因于他们可以在头脑中处理复杂性,但是对于我们其他人呢... - MaR
显示剩余10条评论

15

好的难题!

我看到大多数人都在存储每个棋子的位置。为什么不采用更简单的方法,存储每个方格的内容?这会自动处理晋升和被吃掉的棋子。

而且它允许使用Huffman编码。实际上,棋盘上的初始棋子频率几乎完美符合这一点:一半的方格为空,其余方格中有一半是兵等等。

考虑到每个棋子的频率,我在纸上构建了一个Huffman树,这里不再重复。结果如下,其中c代表颜色(白=0,黑=1):

  • 空方格为0
  • 兵为1c0
  • 车为1c100
  • 马为1c101
  • 象为1c110
  • 后为1c1110
  • 王为1c1111

对于整个初始局面,我们有:

  • 空方格:32 * 1位 = 32位
  • 兵:16 * 3位 = 48位
  • 车/马/象:12 * 5位 = 60位
  • 后/王:4 * 6位 = 24位

总计:164位用于初始棋盘状态。比当前最高票答案的235位要少得多。而且随着游戏的进行(除了晋升之后),它只会变得更小。

我只关注了棋子在棋盘上的位置;额外状态(轮到谁走棋,谁已经王车易位,吃过路兵等等)将需要分别编码。也许最多还有16位,因此整个游戏状态需要180位

可能的优化:

  • 把不太频繁的棋子留出来,将它们的位置单独存储。但这并没有帮助到什么……用空白方块代替国王和皇后可以节省5位比特,恰好可以用于其他方式编码它们的位置。
  • “后排没有兵”可以通过为后排使用不同的Huffman表进行编码来轻松解决,但我怀疑这并没有多大帮助。你可能最终仍然会得到相同的Huffman树。
  • “一个白象,一个黑象”可以通过引入不带位的额外符号来进行编码,然后可以从象所在的方格推断出位。(晋升为象的兵会破坏这个方案……)
  • 空方块的重复可以通过引入额外符号来进行运行长度编码,例如“连续两个空方块”和“连续四个空方块”。但很难估计这些的频率,如果估计错误,反而会有害无益。

银行排没有兵会节省一些空间 - 你可以从所有其他棋型中削减第三位。因此,你将为每个实际在银行排上的棋子节省一个比特。 - Loren Pechtel
2
你可以为每个64个方格单独构建一棵哈夫曼树,因为某些方格中的棋子出现的频率可能会比其他方格更高。 - Claudiu

9

采用大型查找表的方法

棋面状态 - 18 字节
合法棋面数量估计为 1043
枚举所有棋面虽然不切实际,但这至少说明需要 144 位来存储一个棋面。另外还需要一位来指示下一步是哪一方行动。

每个棋面通常有30-40个合法着法,但可能高达218个。让我们枚举每个棋面的所有合法着法,现在每个着法可以编码成一个字节。

我们仍然有足够的空间来存储特殊着法,例如使用 0xFF 表示认输。


3
直接切入需求的核心——“编码国际象棋游戏状态最节省空间的方式”——没有比字典更好的压缩工具了,这甚至包括苍蝇。 - Andrew
1
我发现了一个有趣的链接,可以了解生成这样一个字典需要多长时间 :) http://ioannis.virtualcomposer2000.com/math/EveryChess.html - Andrew Rollings
1
Shannons的估计有点过时了 :-) 他既没有包括促销也没有包括捕获,这些会使数字增加相当多。Victor Allis在1994年给出了一个上限为5x10^52。 - Gunther Piez
使用可变长度编码,平均值至少为10^43吧?偏向更多位置的编码必然会降低这个值,特别是因为许多位置是不可能的。 - Phil H
EveryChess链接现在“待售”,archive.org链接:https://web.archive.org/web/20120303094654/http://ioannis.virtualcomposer2000.com/math/EveryChess.html - oPless

4
攻击编码初始位置后步骤的子问题。方法是创建一个“链接列表”来存储每个步骤。游戏中的每个步骤都被编码为“旧位置->新位置”对。在棋局开始时,您知道初始位置;通过遍历步骤的链接列表,您可以到达X步后的状态。
为了编码每个步骤,您需要64个值来编码起始位置(棋盘上64个方格的6位 - 8x8个方格),以及6位用于结束位置。每方1次移动需要16位。
编码给定游戏所需的空间与移动次数成比例:
10 x(白方步数+黑方步数)位。
更新:晋升的兵可能会带来麻烦。需要能够说明兵晋升为什么 - 可能需要特殊的位(使用格雷码以节省空间,因为兵的晋升极为罕见)。
更新2:您不必编码结束位置的完整坐标。在大多数情况下,正在移动的棋子最多只能移动到X个地方。例如,兵在任何给定点最多有3个移动选项。通过意识到每种棋子类型的最大移动次数,我们可以在“目标”的编码上节省位数。
Pawn: 
   - 2 options for movement (e2e3 or e2e4) + 2 options for taking = 4 options to encode
   - 12 options for promotions - 4 promotions (knight, biship, rook, queen) times 3 squares (because you can take a piece on the last row and promote the pawn at the same time)
   - Total of 16 options, 4 bits
Knight: 8 options, 3 bits
Bishop: 4 bits
Rook: 4 bits
King: 3 bits
Queen: 5 bits

黑方或白方每次移动的空间复杂度为:起始位置6位 + (根据移动物体类型可变的位数)。

刚刚更新,我的意思是128种组合——显然少于128位 :) :) - Alex Weinstein
1
游戏状态与移动不同。任何给定的位置可以被视为一个顶点或节点,而合法的移动可以被视为有向边缘或箭头,形成一个(有向无环)图。 - Shaggy Frog
我不确定为什么会有负面评价 - 我很想听听人们对更新后的想法的意见。 - Alex Weinstein
1
这不会影响你的推理,但有一个微小的更正:一个兵可以有四个着法,不包括晋升,或者包括晋升有12个着法。例如,E2的兵:E3、E4、Exd3、Exf3。例如E7的兵:E8Q、E8N、E8R、E8B、Exd8Q、Exd8N、Exd8R、Exd8B、Exf8Q、Exf8N、Exf8R、Exf8B。 - A. Rex
1
一个小问题--5位只能编码32个值。为了指定棋盘上的任何一个方格,你需要6位。 - Chris Dodd
谢谢,A.Rex和Chris!非常好的评论!我更新了答案以反映您的反馈。 - Alex Weinstein

4

在每个位置获取所有可能移动的数量。

下一步移动的生成方式为:

index_current_move =n % num_of_moves //this is best space efficiency
n=n/num_of_moves

可证明的最佳空间效率,用于存储随机生成的游戏,平均每步需要约5位,因为有30-40个可能的移动。组装存储只是以相反的顺序生成n。

存储位置更难破解,因为存在很大的冗余性。(一个站点上可能有多达9个皇后,但在这种情况下没有兵,如果主教在棋盘上,则在不同颜色的方格上)但通常就像存储相同的棋子组合在剩下的方格上一样。

编辑:

保存移动的关键在于仅存储移动的索引。我们应该添加从确定性移动生成器(位置)生成的移动索引,而不是存储Kc1-c2并尝试减少此信息。

在每个移动中,我们添加大小信息。

num_of_moves = get_number_of_possible_moves(postion) ;

在池中,这个数字无法减少

生成信息池是

n=n*num_of_moves+ index_current_move

额外说明

如果在最后一个位置只有一种可用的移动方式,则将其保存为之前已经强制执行的移动次数。 例如:如果起始位置对于每个方向都有1次强制移动(2次移动),并且我们想将其保存为一次移动游戏,则在池n中存储1。

存储信息池的示例

假设我们已知起始位置并进行了3次移动。

在第一步中,有5种可用的移动方式,我们选择移动索引4。在第二步中,有6种可用的移动方式,我们选择位置索引3,而在第3步中,该方向有7种可用的移动方式,他选择了选择移动索引2。

矢量形式; index=[4,3,2] n_moves=[5,6,7]

我们将此信息进行反向编码,因此n= 4+5*(3+6*(2))=79(不需要乘以7)

如何解决这个循环? 首先,我们有位置,并发现有5种移动可用。所以

index=79%5=4
n=79/5=15; //no remainder

我们将移动索引4并再次检查位置,从这个位置开始,我们发现有6种可能的移动。
index=15%6=3
n=15/6=2

我们选择移动索引3,这将使我们到达一个位置,有7个可能的移动。

index=2%7=2
n=2/7=0

我们执行最后一步索引2并达到最终位置。
如您所见,时间复杂度为O(n),空间复杂度为O(n)。 编辑:实际上,时间复杂度是O(n ^ 2),因为乘以的数字增加了,但存储10,000个动作的游戏应该没有问题。
保存位置 可以接近最优。
当我们了解信息和存储信息时,让我多谈几句。普遍想法是减少冗余(稍后再谈)。假设没有晋升和拿取,因此每边有8个兵,2个车,2个马,2个象,1个国王和1个皇后。
我们必须保存什么: 1.每个和平的位置 2.口香糖的可能性 3.吞没的可能性 4.有可用移动的侧面
假设每个棋子都可以站在任何地方,但不要放2个相同的地方。 同一颜色的8个兵可以在棋盘上排列的方式数量为C(64/8)(二项式),为32位,然后是2R - > C(56/2),2B-> C(54/2),2N- > C(52/2),1Q-> C(50/1),1K - > C(49/1)和同样适用于其他网站,但从8P开始 - > C(48/8)等。
将这个数字乘以两侧得到4634726695587809641192045982323285670400000,约为142位,我们必须添加一个可能的吞没位(吞没兵可以在其中之一),16(4位)用于城堡限制和一个位用于具有移动的网站。我们最终得到142 + 3 + 4 + 1 = 150位 但现在让我们在没有拿取的32件物品上寻找冗余。
  1. 如果黑白兵在同一列且面对面,那么每个兵都面对着另一个兵,这意味着白兵最多只能在第6排。这使我们得到8*C(6/2)而不是C(64/8)*C(48/8),减少了56位信息。

  2. 王车易位的可能性也是多余的。如果车没有在起始位置,那么就不存在与该车的王车易位可能性。因此,我们可以想象在棋盘上添加4个方格,以获取额外的信息,以判断是否可以与该车进行王车易位,并删除4个王车易位的比特位。所以,我们有了C(58/2)*C(42/2)而不是C(56/2)*C(40/2)*16,失去了3.76位(几乎全部4位)。

  3. 吃过路兵:当我们存储8个“吃过路兵”的可能性之一时,我们知道黑兵的位置并减少了信息冗余(如果是白方走棋并且有第三个兵可以吃过路兵,那么黑兵就在c5上,白兵要么在c2、c3或c4上)。因此,我们得到了3而不是C(6/2),失去了2.3位。如果我们还存储了可以进行“吃过路兵”的一侧(3种可能性-左、右、两者),并且我们知道可以进行“吃过路兵”的兵的位置(例如,从之前的“吃过路兵”例子中,黑色兵在c5上,可以在左边、右边或两边。如果它在一个方向上,我们有2*3(3用于存储可能性,2个可能的走法为黑色兵在第7或第6排),而不是C(6/2),我们减少了1.3位;如果在两侧,我们减少了4.2位。这样,我们每个“吃过路兵”可以减少2.3+1.3=3.6位。

  4. 象:象只能在相反的方格上,这使每个方格的信息冗余减少了1位。

如果没有棋子被吃掉,我们需要150-56-4-3.6-2=85位来存储国际象棋的位置

如果考虑到吃子和升变,那么可能需要的位数也不会多很多(但如果有人觉得这篇长帖有用,我会在以后写关于这个的内容)。


有趣的方法。再添加一些细节 :) - Andrew Rollings
我还添加了保存位置的方法。在没有采取措施的情况下,我将位置降至85位,这是一个很好的说明,可以走多远。我认为最好的想法是保存易位的可能性,其中几乎所有4位都是冗余的。 - Luka Rahne

4

优化典型人类玩家玩的典型游戏的平均情况大小会增加趣味性,而不是最坏情况。 (问题陈述没有说明哪种情况; 大多数回答都假设最坏情况。)

对于移动序列,请让一个好的国际象棋引擎从每个位置生成移动; 它将产生一个按其质量排名排序的k个可能移动的列表。 人们通常比随机移动更经常选择好的移动,因此我们需要从列表中的每个位置学习到人们选择“好”移动的概率的映射。 使用这些概率(基于某个互联网国际象棋数据库中的游戏语料库),使用算术编码对移动进行编码。 (解码器必须使用相同的国际象棋引擎和映射。)

对于起始位置,ralu的方法是可行的。如果我们有一种方式通过概率加权来选择,例如,棋子通常以保护彼此的配置出现,而不是随机的,我们可以在那里使用算术编码进行细化。更难的是看到如何轻松地将该知识纳入其中。一个想法:退回到上面的移动编码,从标准开局位置开始,找到以所需棋盘结束的序列。(您可以尝试具有启发式距离的A *搜索,该距离等于棋子从其最终位置的距离之和,或类似的东西。)这样可以通过过度指定移动序列与利用下棋知识获得效率之间的平衡来换取一些低效率。(您可以通过消除会导致在A *搜索中出现先前探索过的位置的移动选择来挽回一些低效率:这些移动选择在算术编码中可能会获得重量0。)
此外,很难估计平均复杂度的节省量,需要从实际语料库中收集一些统计数据。但我认为所有移动同等可能的起始点已经击败了这里的大多数提案:算术编码不需要每个移动的整数位数。

将这些信息存储到池中的复杂性为O(n),请检查我的编辑答案。 - Luka Rahne
ralu,我不确定你的意思,但如果你的表示一系列移动的方法在最坏情况下使用了最优空间,那么我不反对。这里的想法是利用某些移动比其他移动更有可能的优势。 - Darius Bacon
你只需要使用确定性(和强大的)国际象棋引擎,以确定的方式对给定位置的可用移动进行排序,就可以找到更有可能的位置。 - Luka Rahne

4

昨晚我看到了这个问题,它引起了我的兴趣,所以我躺在床上想出了解决方案。我的最终答案与int3的答案非常相似。

基本解决方案

假设是标准的国际象棋游戏,并且您不编码规则(例如白色总是先走),那么您可以通过仅编码每个棋子的移动来节省很多空间。

总共有32个棋子,但在每次移动中,您知道哪种颜色正在移动,因此只需要关注16个格子,即这一步移动的棋子,只需要4位

每个棋子只有有限的移动集,您可以以某种方式枚举它们。

  • 兵:4个选项,2位(向前1步,向前2步,对角线各1步)
  • 车:14个选项,4位(每个方向最多7步)
  • 象:13个选项,4位(如果你在一个对角线上有7个,那么另一个对角线上只有6个)
  • 马:8个选项,3位
  • 后:27个选项,5位(车+象)
  • 王:9个选项,4位(8个一步移动加上易位选项)

对于晋升,有4种棋子可供选择(车,象,马,后),因此在该移动中,我们需要添加2位以指定它。我认为所有其他规则都会自动处理(例如吃过路兵)。

进一步的优化

首先,在捕获一个颜色的8个棋子后,您可以将棋子编码减少到3位,然后是4个棋子的2位,以此类推。

但主要的优化是仅枚举游戏中每个点可能的移动。假设我们将兵的移动存储为{00, 01, 10, 11},分别表示向前1步,向前2步,左对角线和右对角线。如果某些移动不可能,我们可以从本轮编码中删除它们。

我们知道每个阶段的游戏状态(通过跟踪所有移动),因此在读取哪个棋子将移动后,我们总是可以确定需要读取多少位。如果我们意识到兵在这一点上只能向右对角线吃子或向前移动一步,我们就知道只需要读取1位。

简而言之,上面每个棋子的位存储是最大值。几乎每个移动都会有较少的选项,通常也会有更少的位数。


3

在棋盘上的位置可以用7个比特位(0-63,以及1个值指定它不再在棋盘上)来定义。因此,对于棋盘上的每个棋子,都要指定其所在位置。

32个棋子 * 7个比特位 = 224个比特位

编辑: 正如Cadrian所指出的那样...我们还有“兵升为后”的情况。我建议我们在末尾添加额外的比特位,以指示哪些兵被晋升了。

因此,对于每个被晋升的兵,我们在224个比特位后跟随5个比特位,指示被晋升的兵的索引,如果是列表的结尾,则为11111。

因此,最小情况(没有晋升)是224个比特位+5个比特位(没有晋升)。对于每个被晋升的兵,增加5个比特位。

编辑: 正如shaggy frog所指出的,我们需要另外一个比特位来指示轮到谁了 ;^)


andrew:实际上我不行。我忘记考虑兵升变成皇后的情况了(就像cadrian的回答所建议的那样)。所以看起来我实际上需要另外一个额外的位。 - Toad
我可以看出黑白象如何一起定义。不过我对马还有疑问... - int3
@reinier:请看我的在cadrian回答下的评论。 - Andrew Rollings
1
你缺少非皇后晋升。 - Loren Pechtel
@Toad...或者7z,如果头文件不增加结果并且比gzip更好的话。 - Ken
显示剩余7条评论

3
大多数人已经对棋盘状态进行了编码,但是关于移动本身...以下是位编码描述。
每个棋子的位数:
- 棋子ID:最多4位用来识别每方的16个棋子。白色/黑色可以推断出来。对棋子进行排序。随着棋子数量降至各自的二次幂以下,使用较少的位数描述剩余的棋子。 - 兵:第一步有3种可能性,因此+2位(向前移动一或两个方格,吃过路兵)。后续的移动不允许向前移动两步,所以+1位就足够了。晋升可以在解码过程中通过注意到兵何时到达最后一排来推断出来。如果知道兵已经晋升,解码器将期望另外2位表示它晋升为哪个4个主要棋子之一。 - 象:每个被使用的对角线+1位,沿着对角线的距离最多+4位(16种可能性)。解码器可以推断出该棋子沿着该对角线移动的最大可能距离,因此如果是较短的对角线,则使用较少的位。 - 马:8种可能的走法,+3位 - 车:水平/垂直+1位,沿线距离+4位。 - 王:8种可能的走法,+3位。用一次“不可能”的移动表示王车易位——因为只有当国王在第一排时才能进行王车易位,所以将该移动编码为指示将国王向“后”移动——即超出棋盘。 - 后:8个可能的方向,+3位。最多+4位用于沿线/对角线的距离(如果对角线较短,则使用较少的位数,如象的情况)
假设所有棋子都在棋盘上,这些是每个移动的位数:兵-第一步6位,随后5位。晋升时7位。象:9位(最大),马:7位,车:9位,王:7位,后:11位(最大)。

32位来标识一个块?我想你是指5个(32个块)。如果需要编码“结束”状态,则为6个。 - Toad
兵可以晋升为车、马或象。这是为了避免僵局或避免对抗而常见的做法。 - Kobi
这不会影响你的推理,但有一个小修正:兵可以有四个移动方式,不包括晋升,或者包括晋升则有12个移动方式。例如,位于e2的兵可以移动到e3、e4、exd3和exf3。例如,位于e7的兵可以移动到e8Q、e8N、e8R、e8B、exd8Q、exd8N、exd8R、exd8B、exf8Q、exf8N、exf8R和exf8B。 - A. Rex
也许我理解错了,但是兵在第一步时不能进行“吃过路兵”的动作。实际上,你不需要特殊的“吃过路兵”标记,因为这已经在游戏规则中了 - 它只会是一个对角线移动。第一步有4个选项,随后的移动有3个选项。 - DisgruntledGoat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接