Обновление: мне так понравилась эта тема, что я написал « Пазлы для программирования», «Шахматные позиции» и «Кодирование Хаффмана» . Если вы прочитаете это, я определил, что единственный способ сохранить полное состояние игры - это сохранить полный список ходов. Читайте почему. Поэтому я использую несколько упрощенный вариант задачи для разметки деталей.
Эта проблема
Это изображение показывает начальную позицию в шахматах. Шахматы происходят на доске 8x8, где каждый игрок начинает с идентичного набора из 16 фигур, состоящего из 8 пешек, 2 ладей, 2 коней, 2 слонов, 1 ферзя и 1 короля, как показано здесь:
Позиции обычно записываются в виде буквы в столбце, за которой следует номер ряда, так что ферзь белых находится на d1. Ходы чаще всего хранятся в алгебраической нотации , которая недвусмысленна и обычно указывает только минимальную необходимую информацию. Рассмотрим это открытие:
- e4 e5
- Кf3 Кc6
- …
что переводится как:
- Белые перемещают королевскую пешку с е2 на е4 (это единственная фигура, которая может добраться до е4, отсюда «е4»);
- Черные перемещают королевскую пешку с е7 на е5;
- Белые перемещают коня (N) на f3;
- Черные переводят коня на c6.
- …
Плата выглядит так:
Важное умение любого программиста - уметь правильно и однозначно указать проблему .
Так что отсутствует или неоднозначно? Оказывается, много.
Состояние доски против состояния игры
Первое, что вам нужно определить, это сохранить ли вы состояние игры или положение фигур на доске. Простое кодирование позиций фигур - это одно, но проблема говорит «все последующие допустимые ходы». Проблема также ничего не говорит о знании ходов до этого момента. Я объясню, что это действительно проблема.
Рокировка
Игра проходила следующим образом:
- e4 e5
- Кf3 Кc6
- Сb5 а6
- Ba4 Bc5
Плата выглядит следующим образом:
У белых есть возможность рокировки . Частью требований для этого является то, что король и соответствующая ладья никогда не могли двинуться, поэтому нужно будет запомнить, ходили ли король или любая ладья каждой стороны. Очевидно, что если они не на своих исходных позициях, они переместились, в противном случае это необходимо указать.
Есть несколько стратегий, которые можно использовать для решения этой проблемы.
Во-первых, мы могли бы сохранить 6 дополнительных битов информации (по 1 для каждой ладьи и короля), чтобы указать, переместилась ли эта фигура. Мы могли бы упростить это, сохранив только бит для одного из этих шести квадратов, если в нем окажется нужная часть. В качестве альтернативы мы могли бы рассматривать каждую неподвижную фигуру как другой тип фигуры, поэтому вместо 6 типов фигур на каждой стороне (пешка, ладья, конь, слон, ферзь и король) есть 8 (добавляя неподвижную ладью и неподвижного короля).
Мимоходом
Еще одно своеобразное правило в шахматах, которым часто пренебрегают, - это En Passant .
Игра прогрессирует.
- e4 e5
- Кf3 Кc6
- Сb5 а6
- Ba4 Bc5
- OO b5
- Сb3 b4
- c4
Пешка черных на b4 теперь может переместить пешку с b4 на c3, взяв белую пешку на c4. Это происходит только при первой возможности, а это означает, что если черные откажутся от опциона сейчас, они не смогут воспользоваться им следующим ходом. Итак, нам нужно это сохранить.
Если мы знаем предыдущий ход, мы однозначно можем ответить, возможен ли En Passant. В качестве альтернативы мы можем запомнить, переместилась ли туда каждая пешка на 4-й горизонтали двойным ходом вперед. Или мы можем посмотреть на каждую возможную позицию En Passant на доске и иметь флажок, чтобы указать, возможно ли это или нет.
Продвижение
Это ход белых. Если белые перемещают свою пешку с h7 на h8, она может быть переведена на любую другую фигуру (но не на короля). В 99% случаев он повышается до королевы, но иногда это не так, обычно потому, что это может привести к тупику, когда в противном случае вы бы выиграли. Это записывается как:
- h8 = Q
Это важно в нашей задаче, потому что мы не можем рассчитывать на фиксированное количество частей на каждой стороне. Вполне возможно (но невероятно маловероятно), что одна из сторон получит 9 ферзей, 10 ладей, 10 слонов или 10 коней, если все 8 пешек будут продвинуты.
Тупик
Когда вы находитесь в позиции, из которой вы не можете выиграть, ваша лучшая тактика - попытаться выйти из тупика . Наиболее вероятный вариант - это когда вы не можете сделать легальный ход (обычно из-за любого хода, когда ваш король ставит шах). В этом случае вы можете претендовать на ничью. Этого легко обслужить.
Второй вариант - трехкратное повторение . Если одна и та же позиция на доске встречается три раза в игре (или встречается в третий раз на следующем ходу), может быть заявлена ничья. Позиции необязательно располагаться в каком-либо определенном порядке (это означает, что одна и та же последовательность ходов не должна повторяться три раза). Это значительно усложняет задачу, потому что вы должны помнить каждую предыдущую позицию на доске. Если это требование задачи, единственно возможное решение проблемы - сохранить каждый предыдущий ход.
Наконец, есть правило пятидесяти ходов . Игрок может потребовать ничью, если ни одна пешка не двинулась, и ни одна фигура не была взята за предыдущие пятьдесят последовательных ходов, поэтому нам нужно будет сохранить, сколько ходов было сделано после того, как пешка была сделана или взята фигура (последний из двух. 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 (наименее частая) - это два тире и два звуковых сигнала.
Такая схема может значительно уменьшить размер ожидаемого сообщения, но за счет увеличения размера случайной последовательности символов.
Следует отметить, что код Морзе имеет еще одну встроенную функцию: тире имеют длину до трех точек, поэтому приведенный выше код создан с учетом этого, чтобы минимизировать использование тире. Поскольку единицы и нули (наши строительные блоки) не имеют этой проблемы, это не та функция, которую нам нужно воспроизводить.
Наконец, в азбуке Морзе есть два вида пауз. Короткая пауза (длина точки) используется для различения точек и тире. Более длинный пробел (длина тире) используется для разделения символов.
Итак, как это применимо к нашей проблеме?
Кодирование Хаффмана
Существует алгоритм работы с кодами переменной длины, называемый кодированием Хаффмана . Кодирование Хаффмана создает замену кода переменной длины, обычно использует ожидаемую частоту символов для присвоения более коротких значений более общим символам.
В приведенном выше дереве буква E закодирована как 000 (или слева-слева-слева), а 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 бита.
Государственная разница
Другой возможный подход - объединить самый первый подход с кодированием Хаффмана. Это основано на предположении, что наиболее ожидаемые шахматные доски (а не случайно сгенерированные) с большей вероятностью, по крайней мере частично, будут напоминать стартовую позицию.
Итак, что вы делаете, это XOR 256-битной текущей позиции платы с 256-битной начальной позицией, а затем кодируете это (используя кодирование Хаффмана или, скажем, какой-либо метод кодирования длины прогона ). Очевидно, это будет очень эффективно для начала (64 0, вероятно, соответствуют 64 битам), но по мере прохождения игры требуется увеличение объема памяти.
Штучная позиция
Как уже упоминалось, другой способ решения этой проблемы состоит в том, чтобы вместо этого запоминать позицию каждой фигуры игрока. Это особенно хорошо работает с позициями эндшпиля, где большинство квадратов будет пустым (но в подходе кодирования Хаффмана для пустых квадратов в любом случае используется только 1 бит).
У каждой стороны будет король и 0-15 других фигур. Из-за продвижения по службе точный состав этих фигур может настолько различаться, что вы не можете предположить, что числа, основанные на начальных позициях, максимальны.
Логический способ разделить это - сохранить позицию, состоящую из двух сторон (белой и черной). У каждой стороны есть:
- Король: 6 бит на локацию;
- Имеет пешки: 1 (да), 0 (нет);
- Если да, количество пешек: 3 бита (0-7 + 1 = 1-8);
- Если да, положение каждой пешки кодируется: 45 бит (см. Ниже);
- Количество пешек: 4 бита (0-15);
- Для каждой фигуры: тип (2 бита для ферзя, ладьи, коня, слона) и расположение (6 бит)
Что касается расположения пешек, пешки могут быть только на 48 возможных полях (а не на 64, как остальные). Таким образом, лучше не тратить лишние 16 значений, которые использовались бы при использовании 6 бит на пешку. Итак, если у вас 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 лучших, а затем закодировать селектор схемы в первых двух битах, а затем - хранилище для конкретной схемы.
С такими небольшими накладными расходами это, безусловно, лучший подход.
Состояние игры
Я возвращаюсь к проблеме сохранения партии, а не позиции . Из-за трехкратного повторения мы должны сохранить список ходов, которые произошли к этому моменту.
Аннотации
Вам нужно определить одну вещь: просто храните ли вы список ходов или комментируете игру? Шахматные партии часто снабжены аннотациями, например:
- Сb5 !! Nc4?
Ход белых отмечен двумя восклицательными знаками как блестящий, а ход черных считается ошибкой. См. Раздел « Шахматная пунктуация» .
Кроме того, вам может потребоваться сохранить произвольный текст, поскольку описаны ходы.
Я предполагаю, что ходов достаточно, поэтому аннотаций не будет.
Алгебраические обозначения
Здесь можно просто сохранить текст хода («e4», «Bxb5» и т. Д.). Включая завершающий байт, вы смотрите около 6 байтов (48 бит) на ход (худший случай). Это не особенно эффективно.
Второе, что нужно попробовать, - это сохранить начальное местоположение (6 бит) и конечное местоположение (6 бит), так что 12 бит на ход. Это значительно лучше.
В качестве альтернативы мы можем определить все допустимые ходы из текущей позиции предсказуемым и детерминированным способом и в выбранном нами состоянии. Затем это возвращается к упомянутой выше базовой кодировке переменных. У белых и черных есть по 20 возможных ходов на первый ход, больше на второй и так далее.
Вывод
На этот вопрос нет абсолютно правильного ответа. Существует множество возможных подходов, из которых приведено лишь несколько.
Что мне нравится в этой и подобных проблемах, так это то, что они требуют способностей, важных для любого программиста, таких как рассмотрение модели использования, точное определение требований и размышление о крайних случаях.
Шахматные позиции взяты как скриншоты из Chess Position Trainer .