Оновлення: ця тема мені так сподобалася, я писав загадки для програмування, шахові позиції та кодування Хаффмана . Якщо ви прочитаєте це, я визначив, що єдиним способом збереження повного стану гри є збереження повного списку ходів. Читайте далі, чому. Тому я використовую дещо спрощену версію задачі для компонування.
Проблема
Це зображення ілюструє початкову шахову позицію. Шахи відбуваються на дошці 8х8, кожен гравець починає з однакового набору з 16 фігур, що складається з 8 пішаків, 2 граків, 2 лицарів, 2 єпископів, 1 королеви та 1 короля, як показано тут:
Позиції, як правило, реєструються як буква для стовпця, за якою слідує номер для рядка, тому дама білих знаходиться на d1. Переміщення найчастіше зберігаються в алгебраїчних позначеннях , що є однозначним і, як правило, визначає лише мінімальну необхідну інформацію. Розгляньте це відкриття:
- e4 e5
- Nf3 Nc6
- …
що перекладається як:
- Білий переміщує пішака короля з e2 на e4 (це єдина фігура, яка може дістатися до e4, отже, “e4”);
- Чорний переміщує пішака короля з e7 на e5;
- Білий переміщує лицаря (N) до f3;
- Чорний переводить лицаря на c6.
- …
Дошка виглядає так:
Важливою здатністю будь-якого програміста є вміння правильно і однозначно вказати проблему .
То що бракує чи неоднозначно? Як виявляється, багато.
Держава дошки проти держави гри
Перше, що вам потрібно визначити, це те, чи зберігаєте ви стан гри чи положення фігур на дошці. Кодування просто позицій частин - це одне, але проблема говорить "всі наступні юридичні дії". Проблема також нічого не говорить про знання кроків до цього моменту. Це власне проблема, як я поясню.
Рокінг
Гра тривала наступним чином:
- e4 e5
- Nf3 Nc6
- Bb5 a6
- Ba4 Bc5
Дошка виглядає так:
Білий має можливість закидати . Частина вимог для цього полягає в тому, що король і відповідна ладья ніколи не могли рухатися, тому, чи рухався король або кожна грач з кожної сторони, потрібно буде зберігати. Очевидно, що якщо вони не перебувають на вихідних позиціях, вони рухаються, інакше це потрібно вказати.
Існує кілька стратегій, які можна використовувати для вирішення цієї проблеми.
По-перше, ми могли б зберегти додаткові 6 біт інформації (по 1 на кожну ладью та короля), щоб вказати, чи рухалася ця фігура. Ми могли б впорядкувати це, лише зберігаючи трохи для одного з цих шести квадратів, якщо в ньому випадково опиниться потрібний фрагмент. В якості альтернативи ми могли б розглядати кожну непорушену фігуру як інший тип фігури, тож замість 6 типів фігур на кожній стороні (пішак, ладья, лицар, єпископ, королева та король) їх є 8 (додаючи непорушну ладью та незворушного короля).
En Passant
Ще одне своєрідне і часто нехтуване правило в шахах - 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
Це важливо в нашій проблемі, оскільки це означає, що ми не можемо розраховувати на наявність фіксованої кількості штук на кожній стороні. Цілком можливо (але неймовірно малоймовірно), щоб одна сторона отримала 9 ферзів, 10 граків, 10 єпископів або 10 лицарів, якщо всі 8 пішаків отримають підвищення.
Патова ситуація
Коли ти в позиції, з якої ти не можеш виграти, твоя найкраща тактика - це спробувати глухий кут . Найімовірнішим варіантом є те, що ви не можете зробити законний крок (зазвичай тому, що будь-який крок, коли ви ставите свого короля під контроль). У цьому випадку ви можете вимагати нічию. Це легко задовольнити.
Другий варіант - триразове повторення . Якщо одна і та ж позиція дошки трапляється тричі в грі (або траплятиметься втретє при наступному ході), може бути заявлена нічия. Позиції не повинні відбуватися в якомусь певному порядку (це означає, що він не повинен повторювати однакову послідовність ходів тричі). Це значно ускладнює проблему, тому що ви повинні пам'ятати кожну попередню позицію дошки. Якщо це є вимогою проблеми, єдино можливим рішенням проблеми є збереження кожного попереднього ходу.
Нарешті, існує правило п’ятдесяти ходів . Гравець може претендувати на нічию, якщо жодна пішак не переїхала і жодна фігура не була взята за попередні п'ятдесят послідовних ходів, тому нам потрібно буде зберегти, скільки ходів було переміщено пішаком або взята фігура (остання з двох. Для цього потрібно 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 .
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 (або ліворуч-ліворуч-ліворуч), а 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 0s, ймовірно, відповідає 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 можливостей, що дорівнює 281779280429.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 можливих ходів на першому ході, більше на другому тощо.
Висновок
На це питання немає абсолютно правильної відповіді. Існує багато можливих підходів, серед яких вищезазначене лише декілька.
Мені подобається в цій та подібних проблемах те, що вона вимагає здібностей, важливих для будь-якого програміста, таких як розгляд шаблону використання, точне визначення вимог та роздуми про кутові випадки.
Позиції в шахах, зроблені як скріншоти з Chess Position Trainer .