Пишемо модель кубика Рубіка у 10 рядків JavaScript коду

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Якось, ще на першому курсі вишу, вигадав собі задачку — написати солвер кубика Рубіка, хоча до того навіть не тримав ніколи його в руках... Солвер я написав, за пів року, з перервами, 99 рядків коду на C. Але то не так цікаво. Найцікавіша тут підзадача: як взагалі представити кубик Рубіка у комп’ютерній програмі? Перетворення матриць? Складна структура даних? Масиви, вектори, таблиці..? Насправді все доволі просто. Тож, пройшло 15 років — саме час згадати й описати один цікавий спосіб зрозуміло та покроково.

Що таке модель кубика Рубіка?

Модель, а точніше, мінімальна модель класичного кубика Рубіка — це структура даних, що відображає його стан, та одна функція (метод) оберту грані, тобто функція двох аргументів: номеру грані, та кількості обертів її на 90°.

Для класичного варіанту цього достатньо: все, що дозволено робити — це повертати грані (оберт середнього шару — те ж саме, що оберт двох паралельних йому граней в протилежну сторону), за годинниковою стрілкою чи проти — не має значення, поворот в одну сторону еквівалентно трьом в іншу.

Бажано, щоб моделлю було зручно і просто користуватись (якщо задача, наприклад, написати солвер), тобто: знаходити протилежну даній грань, кутові, середні, сусідні елементи прилеглих граней і таке інше.

Починаємо...

Перше, і найпростіше, що спадає на думку — пронумерувати елементи грані по колу (за годинниковою стрілкою):

По-перше, вже видно, як в цьому випадку виглядатиме структура даних: масив розмірністю 6✕9, тип — не важливий, ціле число, байт тощо (це наші кольори):

let cube = [...Array(6)].map((_, i) => Array(9).fill(i))
/*
[
  [0, 0, 0, 0, 0, 0, 0, 0, 0],
  [1, 1, 1, 1, 1, 1, 1, 1, 1],
  ...
  [5, 5, 5, 5, 5, 5, 5, 5, 5]
]
*/

По-друге, легко знаходити елементи: всі кутові — це всі парні індекси (за виключенням останнього, восьмого), а всі елементи, що посередині ребра — це всі непарні. Це розподілення, на кутові та середні, часто використовується в алгоритмах збірки.

По-третє, найголовніше, оберт грані реалізується простим зміщенням значень вектора по два кроки: (0, 1, 2, ..., 6, 7, 8)  (6, 7, 0, ..., 4, 5, 8)  (4, 5, 6, ..., 2, 3, 8) і так далі.

Тут треба зауважити, що останній елемент (на грані — центральний) ми не переміщаємо.

Насправді можна взагалі не зберігати центральні елементи, оскільки вони незмінні, в цьому випадку кубик можна представити масивом 6✕8. Але це не завжди зручніше.

Тепер можна написати нашу першу ітерацію функції оберту грані:

let rotate = (f, t) => {
  while (t--) cube[f].unshift(...cube[f].splice(-3, 2))
}

де — номер грані, — кількість обертів на 90° за годинниковою стрілкою (нуль, або будь-яке натуральне число).

Прилеглі грані та суміжні елементи

Далі — цікавіше. Крім 8 власних елементів даної грані потрібно «обертати» 12 суміжних елементів чотирьох прилеглих граней. Наскільки це складно зробити?

Інтуїція підказує, що оскільки кубик Рубіка — симетрична річ, то можливо є така послідовність a, b, c, d індексів елементів на суміжних з даною гранях, яка була б однакова для кожної з граней?

Перевіримо. Нехай на грані за a слідує b (хоча можна спробувати й іншу букву). Нагадую, що нумеруємо за годинниковою стрілкою:

Тоді, можемо розставити індекси на суміжних з лівою гранях:

Ми бачимо, що на нижній грані «зустрілися» d та c. Тобто ми отримали наступну послідовність індексів на грані: a, b, d, c. Просто розставимо цю послідовність на всіх шести гранях (це можна зробити, тому що у нас вже з кожної сторони є хоча б по одній букві):

Фантастика! Все зійшлося. Кожна грань має одну і ту саму послідовність індексів власних елементів — a, b, d, c, та одну і ту саму послідовність індексів кутових елементів прилеглих граней — a, b, c, d.

Тобто, наш кубик складається з шести однакових фігур. Замість a підставляємо, наприклад, нуль. Фігура виглядатиме так:

а кожний наступний індекс на одиницю більше (звісно по модулю 8):

Виявляється, що треба зовсім небагато ускладнити нашу функцію оберту, а саме — зробити обмін значень трійок послідовних елементів між чотирма прилеглими гранями. Напишемо готову функцію:

let rotate = (f, t) => {
  while (t--) {
    cube[f].unshift(...cube[f].splice(-3, 2))
    for (let i = 3; i; i--)
      for (let j = 0; j < 3; j++)
        [ cube[M[f][i]][(N[i] + j) % 8], cube[M[f][i - 1]][(N[i - 1] + j) % 8] ] =
        [ cube[M[f][i - 1]][(N[i - 1] + j) % 8], cube[M[f][i]][(N[i] + j) % 8] ]
  }
}

де N — масив [0, 2, 6, 4] (тобто послідовність a, b, c, d), та M — масив 6✕4 номерів чотирьох прилеглих до кожної із шести граней.

Лишилось тільки пронумерувати грані та записати масив суміжності M.

Таблиця суміжних граней

Звичайно, можна пронумерувати грані будь-яким чином і записати у масив 6✕4. Але задача була зробити модель якомога коротшою. Можливо і тут вдасться знайти симетрію та замість масиву написати коротку функцію? Спробуємо.

Помітимо, що букви a знаходяться одна навпроти одної для всіх протилежних граней. Це підказує ідею — пронумерувати такі грані парами: 0–1, 2–3 і т. д. В цьому випадку, функція знаходження протилежної сторони — це функція одного оператора: f ^ 1.

Далі, відносно грані з номером 1 першу прилеглу грань (зі сторони букви a) іменуємо номером 2. Те саме робимо для протилежної — грані номер 3:

Тож маємо наступну таблицю суміжності граней:

Складемо рядки попарно:

Знайдемо різницю (по модулю 6) між парними рядками:

Отже бачимо, що ця таблиця має значну надлишковість, а це означає, що вона може бути стиснута, або навіть зведена до коротшої функції. Ось ця функція:

let side = (f, i) => ([5, 3, 4, 2][i] + f + (4 * i + 2) * (f & 1)) % 6

Нарешті, замінимо масив M у нашій функції оберту грані на функцію side і приведемо повний код програмної моделі кубика Рубіка:

let cube = [...Array(6)].map((_, i) => Array(9).fill(i))

let side = (f, i) => ([5, 3, 4, 2][i] + f + (4 * i + 2) * (f & 1)) % 6

let N = [0, 2, 6, 4]

let rotate = (f, t) => {
  while (t--) {
    cube[f].unshift(...cube[f].splice(-3, 2))
    for (let i = 3; i; i--)
      for (let j = 0; j < 3; j++)
        [ cube[side(f, i)][(N[i] + j) % 8], cube[side(f, i - 1)][(N[i - 1] + j) % 8] ] =
        [ cube[side(f, i - 1)][(N[i - 1] + j) % 8], cube[side(f, i)][(N[i] + j) % 8] ]
  }
}
👍ПодобаєтьсяСподобалось10
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

А чому так складно?
Група Рубіка це підгрупа симетричної групи S48, з шістьма породжаючими елементами, кожен з яких є добуток п’яти 4-циклів, тому що заважає просто їх записати як є

const L = [[0, 2, 7, 5], [1, 4, 6, 3], [8, 32, 24, 16], [9, 33, 25, 17], [10, 34, 26, 18]];
const F = [[8, 10, 15, 13], [9, 12, 14, 11], [0, 16, 40, 39], [3, 19, 43, 36], [5, 21, 45, 33]];
const R = [[16, 18, 23, 21], [17, 20, 22, 19], [5, 24, 42, 15], [6, 27, 41, 12], [7, 29, 40, 10]];
const B = [[24, 26, 31, 29], [25, 28, 30, 27], [2, 37, 42, 18], [4, 35, 44, 20], [7, 32, 47, 23]];
const U = [[32, 34, 39, 37], [33, 36, 38, 35], [2, 8, 45, 31], [1, 11, 46, 28], [0, 13, 47, 26]];
const D = [[40, 42, 47, 45], [41, 44, 46, 43], [13, 21, 29, 37], [14, 22, 30, 38], [15, 23, 31, 39]];

Ну а далі, початкове становище буде var cube = [ ...Array(48).keys() ].map( i => i+1);

Ну а повертання просто послідовне виконання циклів:

function rotate(cube, rotation) {
  rotation.forEach(cycle => {
    const len = cycle.length;
    const first = cube[cycle[0]];
    for (var i=0; i < len; ++i) {
      cube[cycle[i]] = cube[cycle[i+1]];
    });
    cube[cycle[len-1]] = first;
  });
}

так це ж те саме. тільки ви представили таблично, а я — функціонально. якщо стиснути вашу таблицю — вийде та сама функція. не бачу різниці у складності

Ну... по-перше ми маємо в моєму коді немає немає unshift, splice, ... тому він виглядає цікавіше з точку зору швидкодії. Но суті це читання 20 чисел з пам’яті, за запис 20 чисел у пам’ять.

По друге, створюючі елементи групи рубіка можна легко знайти в інтернеті, тому не треба морочити голову з усіма візуалізаціями, малюванням кубіка-рубіка, слідкування куди яка грань іде, ... Мій код це 15 хвилин: скопіювати перестановки з інтернета та написати код який їх прикладає. Навіть ChatGPT зуміє.

А от це я не впевнений, що можна написати за 15 хвилин та відлагодити:

let rotate = (f, t) => {
  while (t--) {
    cube[f].unshift(...cube[f].splice(-3, 2))
    for (let i = 3; i; i--)
      for (let j = 0; j < 3; j++)
        [ cube[side(f, i)][(N[i] + j) % 8], cube[side(f, i - 1)][(N[i - 1] + j) % 8] ] =
        [ cube[side(f, i - 1)][(N[i - 1] + j) % 8], cube[side(f, i)][(N[i] + j) % 8] ]
  }
}

Тому й виникає питання, навіщо це робити?

.

на пайтоні мабуть буде красивіше

не роби на пайтоні тільки )

rotate можно const сделать, так как она не меняется

ахах, там все можно const сделать, но это по два символа на слово длиннее))

структура даних, що відображає його стан, та одна функція (метод) оберту грані, тобто функція двох аргументів: номеру грані, та кількості обертів її на 90°

як варіант — може бути функція 1 аргументу, номеру грані — а рахувати умовно тільки 1 оберт в якусь конкретну сторону (наприклад вправо) — їх буде максимум 3, тому що 4й — поверне в початковий стан

стан кубіка рубіка був би просто як послідовність поворотів номерів граней (кольорів центру): 1133115551166444... і тд

так, дійсно, можливо. але по мірі написання коду солвера стало зрозуміло, що найкоротше — буде зробити функцію повороту, як функцію нескінченного ряду аргументів, парами: f, i; f, i; f, i... тому що, алгоритми зборки мають «патерни» дій з гранями.

Підписатись на коментарі