
Привет, Хабр.
Я работаю учителем математики и информатики в солнечном Таиланде. Во время школьных каникул, вместо регулярных путешествий по Азии я решил развлечь себя изучением синтаксиса JavaScript.
Когда-то, мы с моей замечательной (но ныне бывшей) женой фанатели от нестандартных судоку со знаками «больше-меньше», мы сами печатали себе уникальные сетки, а иногда, я даже рисовал их руками на основе готовых шаблонов из интернета.
В этой статье я хочу рассказать об эволюции моего математического движка генерации сеток судоку: от наивного перетаскивания массивов к строгой комбинаторике и факториальной системе счисления.
Версия 1. Наивное перемешивание
Идея первой версии была до банального проста. Зачем генерировать сетку с нуля, решая NP-полную задачу с бэктрекингом, если можно взять одну готовую, 100% валидную сетку и хорошенько её «перемешать»?
Правила классического судоку позволяют нам делать несколько геометрических преобразований, которые не ломают логику решения:
1. Менять местами любые две строки внутри одного горизонтального блока 3х3.
2. Менять местами любые два столбца внутри вертикального блока 3х3.
3. Менять местами сами крупные блоки строк (3х9) и столбцов (9х3) целиком.
С самого начала элегантным решением казалось работать с сидом вида: 3 блока по 6 шестнадцатеричных символов. Такой можно было бы легко распаковать в бинарный ряд флагов для операций перемешивания.
Я написал простенький хэш, который брал 18-значный сид, превращал его в массив битов, и на основе этих нулей и единиц применял (или пропускал) функции перемешивания к базовой матрице.
// Базовая сетка для перемешивания const BASE_GRID = [ [5,3,4, 6,7,8, 9,1,2], [6,7,2, 1,9,5, 3,4,8], [1,9,8, 3,4,2, 5,6,7], [8,5,9, 7,6,1, 4,2,3], [4,2,6, 8,5,3, 7,9,1], [7,1,3, 9,2,4, 8,5,6], [9,6,1, 5,3,7, 2,8,4], [2,8,7, 4,1,9, 6,3,5], [3,4,5, 2,8,6, 1,7,9] ]; // МАТЕМАТИЧЕСКИЙ ДВИЖОК И ЛОГИКА ГЕНЕРАЦИИ СЕТКИ v1 const Utils = { // Генератор псевдослучайных чисел (алгоритм Mulberry32) seededRandom: (seed) => { return () => { let t = seed += 0x6D2B79F5; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }, // Конвертирует шестнадцатеричную строку в массив битов hexToBits: (hex) => { return [...hex].flatMap(char => { const v = parseInt(char, 16); return [(v >> 3) & 1, (v >> 2) & 1, (v >> 1) & 1, v & 1]; }); }, // Набор функций для валидного перемешивания сетки судоку transforms: { swapRows: (g, r1, r2) => { [g[r1], g[r2]] = [g[r2], g[r1]]; }, swapCols: (g, c1, c2) => { for (let r = 0; r < 9; r++) [g[r][c1], g[r][c2]] = [g[r][c2], g[r][c1]]; }, swapRowBlocks: (g, b1, b2) => { for (let i = 0; i < 3; i++) Utils.transforms.swapRows(g, b1 * 3 + i, b2 * 3 + i); }, swapColBlocks: (g, b1, b2) => { for (let i = 0; i < 3; i++) Utils.transforms.swapCols(g, b1 * 3 + i, b2 * 3 + i); } } }; // Массив доступных трансформаций, вызываются на основе битов сгенерированных из сида const PROCEDURES = [ g => Utils.transforms.swapColBlocks(g, 0, 1), g => Utils.transforms.swapCols(g, 0, 1), g => Utils.transforms.swapRows(g, 0, 1), g => Utils.transforms.swapCols(g, 0, 2), g => Utils.transforms.swapRowBlocks(g, 0, 1), g => Utils.transforms.swapRows(g, 0, 2), g => Utils.transforms.swapCols(g, 1, 2), g => Utils.transforms.swapRows(g, 1, 2), g => Utils.transforms.swapColBlocks(g, 0, 2), g => Utils.transforms.swapCols(g, 3, 4), g => Utils.transforms.swapRows(g, 3, 4), g => Utils.transforms.swapCols(g, 3, 5), g => Utils.transforms.swapRowBlocks(g, 0, 2), g => Utils.transforms.swapRows(g, 3, 5), g => Utils.transforms.swapCols(g, 4, 5), g => Utils.transforms.swapRows(g, 4, 5), g => Utils.transforms.swapColBlocks(g, 1, 2), g => Utils.transforms.swapCols(g, 6, 7), g => Utils.transforms.swapRows(g, 6, 7), g => Utils.transforms.swapCols(g, 6, 8), g => Utils.transforms.swapRowBlocks(g, 1, 2), g => Utils.transforms.swapRows(g, 6, 8), g => Utils.transforms.swapCols(g, 7, 8), g => Utils.transforms.swapRows(g, 7, 8) ]; async function generate(seedStr) { // ... валидация сида и инициализация ... const grid = BASE_GRID.map(row => [...row]); // Первый прогон применяем базовые математические трансформации для создания решения for (let i = 0; i < 3; i++) { const bits = Utils.hexToBits(clean.slice(i * 6, i * 6 + 6)); bits.forEach((bit, idx) => { if (bit && PROCEDURES[idx]) PROCEDURES[idx](grid); }); } // Второй прогон для усиления влияния конца сида со смещением for (let i = 0; i < 3; i++) { const bits = Utils.hexToBits(clean.slice(i * 6, i * 6 + 6)); bits.forEach((bit, idx) => { const newIdx = (idx + 5) % PROCEDURES.length; if (bit && PROCEDURES[newIdx]) PROCEDURES[newIdx](grid); }); } GameState.solutionGrid = grid; // ... рендер }
Версия 2. Борьба с паттернами
Очень быстро я обнаружил существенный изъян генерации в первой версии. Дело в том, что из-за ограниченного набора свопов (только строки и столбцы) состав групп цифр оставался неизменным. Появлялся паттерн, который легко замечал человеческий глаз: в каждом блоке 3х3 всегда находилась строка, в которой в разном порядке повторялись первые три цифры самого первого верхнего левого блока 3х3. Геометрия сетки менялась, но математическое нижнее бельё торчало наружу.
Чтобы решить эту проблему, я добавил глобальную подмену самих цифр. Если мы во всей сетке заменим все единицы на девятки, а девятки на единицы, судоку от этого не перестанет быть правильным. Я добавил в массив PROCEDURES ещё восемь свопов, которые глобально меняли цифры местами (например, тройки с пятерками).
JavaScript // МАТЕМАТИЧЕСКИЙ ДВИЖОК И ЛОГИКА ГЕНЕРАЦИИ СЕТКИ v2 const Utils = { // ... старые утилиты ... transforms: { // ... старые свопы строк и колонок ... // Глобальная замена двух цифр местами swapDigits: (g, d1, d2) => { for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { if (g[r][c] === d1) g[r][c] = d2; else if (g[r][c] === d2) g[r][c] = d1; } } } } }; // Массив доступных трансформаций (с математической некоммутативностью) const PROCEDURES = [ g => Utils.transforms.swapColBlocks(g, 0, 1), g => Utils.transforms.swapCols(g, 0, 1), g => Utils.transforms.swapRows(g, 0, 1), g => Utils.transforms.swapDigits(g, 1, 9), // НОВАЯ ТРАНСФОРМАЦИЯ // ... g => Utils.transforms.swapDigits(g, 2, 8), // ... g => Utils.transforms.swapDigits(g, 3, 7), // ... g => Utils.transforms.swapDigits(g, 4, 6), // ... ]; async function generate(seedStr) { // ... const grid = BASE_GRID.map(row => [...row]); const allBits = Utils.hexToBits(clean); // Три прогона с разными смещениями для максимальной хаотичности allBits.forEach((bit, idx) => { if (bit) PROCEDURES[idx % PROCEDURES.length](grid); }); allBits.forEach((bit, idx) => { if (bit) PROCEDURES[(idx + 13) % PROCEDURES.length](grid); }); allBits.forEach((bit, idx) => { if (bit) PROCEDURES[(idx + 29) % PROCEDURES.length](grid); }); GameState.solutionGrid = grid; // ... }
Версия 3. Биекция и факториалы
Движок работал, паттерны исчезли. Но затем, я серьезно задумался об уникальности сида. При последовательном применении трансформаций (тем более с битовыми масками и случайными смещениями) мы неизбежно сталкиваемся с коллизиями. Два разных сида могли сгенерировать одинаковую сетку, а некоторые трансформации всё ещё могли отменять друг друга. Мне нужна была жесткая связь: 1 сид = 1 уникальная сетка.
Я решил отказаться от физического перетаскивания элементов массива в памяти и посчитать всё математически. Сколько вообще уникальных сеток можно получить из одного базового шаблона с помощью этих операций?
Перестановка 9 цифр: 9! вариантов.
Перестановка 3 крупных блоков строк: 3!
Перестановка 3 крупных блоков столбцов: 3!
Перестановка линий внутри каждого из 3 блоков строк: (3!)^3
Перестановка линий внутри каждого из 3 блоков столбцов: (3!)^3
Перемножив это, мы получаем 609,492,049,920 уникальных комбинаций из одной базовой сетки.
Вместо того чтобы «двигать» массивы, мы можем превратить 18-значный hex-сид в огромное число BigInt. Берем остаток от деления этого числа на 609,492,049,920, чтобы безопасно закольцевать диапазон. А затем просто распаковываем полученное число N через остатки от деления на индексы для алгоритма факториальной системы счисления.
Теперь мы не перемешиваем массив. Мы за один проход O(N) вычисляем, какая цифра должна стоять в конкретной ячейке для данной перестановки.
JavaScript // МАТЕМАТИЧЕСКИЙ ДВИЖОК И ЛОГИКА ГЕНЕРАЦИИ СЕТКИ v3 /* алгоритм генерирует 609 492 049 920 уникальных комбинаций из одного базового шаблона, эффективно используя математические свойства симметрии судоку. Общий объем вариантов складывается из перемножения всех независимых трансформаций: перестановки 9 цифр (9!), перемешивания 3 крупных блоков (3! * 3!) и ротации линий внутри каждого из них ((3!)^3 * (3!)^3). Благодаря такой каскадной системе, 18-значный сид обеспечивает огромное разнообразие сеток, гарантируя, что игрок практически никогда не встретит две одинаковые сетки, несмотря на использование всего одной исходной матрицы */ const Utils = { // Возвращает k-тую перестановку элементов массива (Алгоритм факториальной системы счисления) getPermutation: (arr, k) => { let available = [...arr]; let result = []; let fact = 1; for (let i = 2; i < available.length; i++) fact *= i; for (let i = available.length - 1; i > 0; i--) { const idx = Math.floor(k / fact); result.push(available[idx]); available.splice(idx, 1); k %= fact; fact /= i; } result.push(available[0]); return result; } }; async function generate(seedStr) { // ... очистка и подготовка сида ... // МАТЕМАТИЧЕСКАЯ БИЕКЦИЯ (1 сид = 1 уникальная сетка) const MAX_PERMUTATIONS = 609492049920n; const seedBigInt = BigInt("0x" + clean); let N = Number(seedBigInt % MAX_PERMUTATIONS); // Распаковываем число N на составляющие остатки от деления для каждой группы перестановок const pDigits = Utils.getPermutation([1,2,3,4,5,6,7,8,9], N % 362880); N = Math.floor(N / 362880); const pRowBlocks = Utils.getPermutation([0,1,2], N % 6); N = Math.floor(N / 6); const pColBlocks = Utils.getPermutation([0,1,2], N % 6); N = Math.floor(N / 6); const pR0 = Utils.getPermutation([0,1,2], N % 6); N = Math.floor(N / 6); const pR1 = Utils.getPermutation([0,1,2], N % 6); N = Math.floor(N / 6); const pR2 = Utils.getPermutation([0,1,2], N % 6); N = Math.floor(N / 6); const pC0 = Utils.getPermutation([0,1,2], N % 6); N = Math.floor(N / 6); const pC1 = Utils.getPermutation([0,1,2], N % 6); N = Math.floor(N / 6); const pC2 = Utils.getPermutation([0,1,2], N % 6); const pRows = [pR0, pR1, pR2]; const pCols = [pC0, pC1, pC2]; // Собираем итоговую сетку напрямую из BASE_GRID без "физических" свопов const grid = []; for (let r = 0; r < 9; r++) { const rowArr = []; for (let c = 0; c < 9; c++) { // Находим координаты оригинальной ячейки с учетом перестановки блоков и линий внутри них const oldR = pRowBlocks[Math.floor(r / 3)] * 3 + pRows[Math.floor(r / 3)][r % 3]; const oldC = pColBlocks[Math.floor(c / 3)] * 3 + pCols[Math.floor(c / 3)][c % 3]; // Получаем старое значение ячейки и применяем к нему глобальную перестановку цифр const oldVal = BASE_GRID[oldR][oldC]; rowArr.push(pDigits[oldVal - 1]); } grid.push(rowArr); } GameState.solutionGrid = grid; // ... рендер }
Итоги
Этот путь наглядно показал мне, как важно иногда оторваться от редактора кода и посмотреть на задачу через призму математики. Итоговый алгоритм не просто избавился от коллизий и паттернов, но и стал работать элегантнее.
Вместо послесловия
Магия биекции настолько меня вдохновила, что я довел код до состояния готового продукта «для одного пользователя». Приложение работает прямо из одного HTML-файла без лишних библиотек, то чего мне не хватало во всех магазинах приложений. Вот так это выглядит на экране:


Комментарии (15)

aleksandy
24.03.2026 22:11Так а потыкать-то где?

arehspb Автор
24.03.2026 22:11К сожалению пока нигде. Хочется сделать apk для андроид, но каникулы подходят к концу. В понедельник на работу, школьные чаты уже о себе напоминают и моё "приключение" вот-вот закончится. Теперь любая работа над этим проектом будет продвигаться гораздо медленнее. Вероятно через какое-то неопределённое время я смогу поделиться результатами.

domix32
24.03.2026 22:11Так раз у вас статичная страничка, то бахнули б на github её. ну и судя по ссылкам оно у вас уже где-то есть в телеграмных мини-приложениях.

arehspb Автор
24.03.2026 22:11Честно говоря, где-то на тысячной строчке кода, примерно на моменте когда я писал логику обхода мёртвых паттернов, я стал смотреть на этот проект несколько по-другому. Из простого он превратился в надежду хоть немного поправить финансовое положение. Признаюсь, чувствую себя виноватым за такой меркантильный подход, но я не без причин в этом году отказался от путешествий по Азии.

GCU
24.03.2026 22:11Желаю успехов, но судоку кажется довольно избитой темой. В любом случае это будет опыт изучения коммерческого потенциала, которым можно поделиться, а на github можно закинуть и через год.
P.S. Я делал судоку в игре Newspaper puzzle (2024) на newgrounds

arehspb Автор
24.03.2026 22:11Спасибо. Думаю в этом проекте ещё есть парочка интересных решений которыми здорово будет поделиться с такими же новичками как я. Обязательно напишу об этом статью. К тому же я перевёл своё приложение на 36 языков включая Ассемблер, Яваскрипт и Эльфийский, для этого тоже пришлось провести занимательное исследование)

domix32
24.03.2026 22:11Ну, коммерциализировать конечно можно, только не очень понятно как вы при этом конкурентов будете обходить. Ну, да не суть.

arehspb Автор
24.03.2026 22:11Понимаю вашу иронию. Конкурировать планирую не лобовой атакой, а гибкостью. У меня есть ползунок сложности ), карандашные заметки и сид которым можно делиться в чате. Много всяких мелочей, я начинал проект именно как что-то для себя, а я годами играл в эту игру и точно знаю чего нет других приложениях.

domix32
24.03.2026 22:11Да не, я без иронии. Вам определённо виднее как получить из этого профит, так что только успехов могу пожелать.
GCU
Проблема генерации судоку в другом - нужно уметь как-то оценить сложность для игрока, а это не столько само решение, сколько начальное условие (какие цифры убрать)
Блоки с явным указанием 3! и 9! выглядят страшновато, тем более что это всегда факториал от длины массива. Изящнее был бы какой-нибудь класс-помощник которому можно было передать N и запрашивать перестановки, при этом он сам бы внутри менял N.
Типа:
reader = new PermutationsReader(N)
pDigits = reader.readPermutation([1,2,3,4,5,6,7,8,9]);
...
const pRows = reader.readPermutations([0,1,2], 3)
arehspb Автор
Программа поддерживает изменение сложности "на лету" (ползунок на скриншоте). От 5 до 0 отображаемых цифр подсказок, и от 0 до 4 скрываемых/отображаемых знака больше меньше. То какие цифры и знаки будут скрываться и в какой последовательности, определяет сид, то есть для каждого сида будет всегда своя одинаковая уникальная последовательность отображения подсказок.
arehspb Автор
"Don't Repeat Yourself" ). Вы правы, так будет почище.
arehspb Автор
Прочитал удалённый кусочек вашего комментария на почте, думаю разумно будет дать больше пояснений.
609 492 049 920 - это математический предел уникальных и правильных сеток которые можно сгенерировать из одной базовой не нарушая при этом правила Судоку. Операция остатка от деления (A % B) работает как кольцевой счетчик, как циферблат. В результате выражение seedBigInt % 609492049920 всегда выдаст нам число от 0 до 609492049919. Получив это итоговое число PermutationsReader начинает его распаковывать в обратном порядке:
делит N на 362880 (9!, используются системы счисления перестановок, 362880 для цифр и 6 для блоков),
берет остаток и понимает: цифры меняем вот по такой схеме,
само число N уменьшается, мы отрезали от него часть данных,
дальше делит остаток на 6 и понимает: строки меняем так,
и так до тех пор пока от огромного не останется ноль а сетка не будет полностью перемешана уникальным образом.
прочитал данные -> сдвинулся -> прочитал следующие -> сдвинулся
Таким образом достигается жесткая биекция.
GCU
Кажется что ничего страшного не произойдёт даже если ноль в конце не останется после извлечения всех интересующих перестановок (их число же фиксированное). Комментарий я удалил, так как понял что входной hex переваливает за значения max safe int, и остаток от деления как раз сокращает число для нормальной работы с number