Главная ошибка наивного генератора судоку — проверять, что решение существует, но не проверять, что оно единственное.Вот почти заполненное поле:534..8912 672195348 198342567 859..1423 426853791 713924856 961537284 287419635 345286179 solve() быстро заполнит четыре пропуска. Только завершений здесь два: цифры 6 и 7 можно переставить, не нарушив ни строку, ни столбец, ни блок.countSolutions(puzzle, 2) останавливается после второго решения и возвращает 2. Это не демонстрационная картинка. Та же строка из 81 символа лежит в solver.spec.ts, тест так и называется: «контрпример из лида действительно имеет два решения».Я реализовал три стратегии поиска, а MRV и propagation дополнительно сравнил на наборах задач. Ещё измерил две операции: получение первого решения и доказательство того, что второго решения нет. Вторая вызывается после каждой попытки убрать подсказку, поэтому она в основном определяет цену генерации. React, Web Worker и тесты появятся дальше как обвязка этого поиска, а не как отдельные темы.Это первый выпуск рубрики «ИграКОД» — про алгоритмы через запускаемые мини-игры. Ранее в цикле выходили материалы про useEffect, any, перенос TypeScript на Go, варианты архитектуры React-магазина и мы собирали и разбирали комбайн. Читать далее

Проверяемый контрпример: одна сетка и два корректных завершения
Обложка собрана кодом из поля, которое используется в тесте. Четыре пропуска допускают два завершения.
Главная ошибка наивного генератора судоку — проверять, что решение существует, но не проверять, что оно единственное.
Вот почти заполненное поле:
534..8912
672195348
198342567
859..1423
426853791
713924856
961537284
287419635
345286179
solve() быстро заполнит четыре пропуска. Только завершений здесь два: цифры 6 и 7 можно переставить, не нарушив ни строку, ни столбец, ни блок.

Одна сетка может иметь два корректных решения
countSolutions(puzzle, 2) останавливается после второго решения и возвращает 2.
Это не демонстрационная картинка. Та же строка из 81 символа лежит в solver.spec.ts, тест так и называется: «контрпример из лида действительно имеет два решения».
Я реализовал три стратегии поиска, а MRV и propagation дополнительно сравнил на наборах задач. Ещё измерил две операции: получение первого решения и доказательство того, что второго решения нет. Вторая вызывается после каждой попытки убрать подсказку, поэтому она в основном определяет цену генерации. React, Web Worker и тесты появятся дальше как обвязка этого поиска, а не как отдельные темы.
Что считаем работойЭто первый выпуск рубрики «ИграКОД» — про алгоритмы через запускаемые мини-игры. Ранее в цикле выходили материалы про
useEffect,any, перенос TypeScript на Go, варианты архитектуры React-магазина и мы собирали и разбирали комбайн.
Перед кодом договоримся о трёх метриках. nodes — присваивания-догадки, из которых поиск может откатиться. deductions — форсированные ходы: голые и скрытые одиночки. Здесь и далее под deductions я считаю только эти две реализованные техники. Время замеряется пакетами по 20 вызовов, чтобы субмиллисекундные операции меньше зависели от шума таймера; для каждого поля берётся медиана пяти серий. Этот протокол используется для сравнения решателей. Генерация измеряется отдельно, поскольку один её запуск значительно дороже.
У генератора есть ещё два счётчика: сколько полных сеток он успел попробовать и сколько раз вызвал countSolutions(..., 2). Они важны: финальное поле может решаться мгновенно, хотя по пути генератор отбросил десятки кандидатов.
Сам алгоритм генерации давно известен. Схему «полная сетка → удаление подсказки → подсчёт решений» разбирали на Хабре ещё в 2013 году. Здесь интересен измеряемый путь от решателя до генератора и неприятные контракты, которые обнаружились по дороге.
Поле в коде — Uint8Array на 81 значение. Геометрия строк, столбцов и блоков вычисляется один раз. У клетки двадцать уникальных соседей. Множество кандидатов хранится в девятибитной маске, поэтому локальная проверка хода дёшева. Игровой интерфейс принимает конфликтующую цифру и подсвечивает её, а решатель перед поиском проверяет целостность всей сетки.
Первый решатель берёт очередную пустую клетку и перебирает допустимые цифры:
function search(work: Grid, from: number): boolean {
let cell = from;
while (cell < CELLS && work[cell] !== 0) cell++;
if (cell === CELLS) return true;
for (const digit of candidates(work, cell)) {
counter.nodes++;
work[cell] = digit;
if (search(work, cell + 1)) return true;
work[cell] = 0;
}
return false;
}
На трудном примере Питера Норвига такой порядок посещает 9 727 396 узлов. Большая часть работы уходит на широкие ветки, выбранные слишком рано.
Minimum remaining values выбирает клетку с минимальным числом кандидатов:
function selectMrvCell(work: Grid): number {
let best = -1;
let bestCount = SIZE + 1;
for (let cell = 0; cell < CELLS; cell++) {
if (work[cell] !== 0) continue;
const count = countDigits(candidatesMask(work, cell));
if (count < bestCount) {
best = cell;
bestCount = count;
if (count <= 1) break;
}
}
return best;
}
Рекурсия осталась той же, но на примере Питера Норвига счётчик падает до 718 узлов.
Третий решатель перед догадкой исчерпывает голые и скрытые одиночки. Такие ходы сразу попадают в deductions:
function search(work: Grid): boolean {
const filled: number[] = [];
if (propagateSingles(work, filled, counter) === "contradiction") {
undoFills(work, filled);
return false;
}
const cell = selectMrvCell(work);
if (cell === -1) return true;
for (const digit of candidates(work, cell)) {
counter.nodes++;
// догадка, рекурсивный спуск, откат
}
}
На том же поле остаётся 25 узлов. Задачи уровня easy из сгенерированного набора проходят вообще без догадок.
Первая таблица показывает поведение реализаций внутри принятой модели сложности. Она не проверяет рейтинг независимо: уровень поля сам определяется числом ветвлений решателя с propagation.
уровень | MRV, узлов | MRV + одиночки, узлов | дедукций |
|---|---|---|---|
easy | 185 | 56 | |
medium | 671 | 2 | 56 |
hard | 631 | 6 | 83 |
evil | 692 | 18 | 176 |

MRV и propagation: узлы перебора по уровням
Среднее по 12 полям каждого уровня; вертикальная шкала логарифмическая.
Когда я впервые увидел соседние строки easy и evil, решил, что перепутал подписи: последовательный перебор потратил на поле easy при seed = 1 целых 8,4 млн узлов, а на evil — всего 5 718. Перезапустил набор, получил те же числа. Классификатор здесь ни при чём: порядок клеток у примитивного алгоритма просто очень неудачен для первого поля.
Чтобы не замыкать сравнение на собственном генераторе, я прогнал все 95 задач из публичного набора top95 Питера Норвига. Они не проходят через наши уровни. В таблице приведены средние по всему набору; максимум относится к решателю с propagation:
независимый набор | MRV: среднее узлов | propagation: среднее узлов | propagation: среднее дедукций | propagation: максимум узлов |
|---|---|---|---|---|
top95 Питера Норвига | 22 904 | 64 | 423 | 446 |
Все 95 полей имеют единственное решение. На этом наборе распространение ограничений тоже срезает поиск, хотя цифры уже ничего не говорят о шкале easy/evil.
Если коротко: MRV сокращает число неудачных веток, а propagation позволяет вообще не открывать часть этих веток.
Зачем генератору второй поискГенерация начинается с полной случайной сетки. Затем клетки перемешиваются и удаляются по одной или центрально-симметричными парами. Каждый кандидат проходит проверку с лимитом два:
export function countSolutions(grid: Grid, limit = 2): CountResult {
const search = (): boolean => {
const filled: number[] = [];
if (propagateSingles(work, filled, counter) === "contradiction") {
undoFills(work, filled);
return false;
}
const cell = selectMrvCell(work);
if (cell === -1) {
count++;
undoFills(work, filled);
return count >= limit;
}
for (const digit of candidates(work, cell)) {
counter.nodes++;
work[cell] = digit;
const stop = search();
work[cell] = 0;
if (stop) {
undoFills(work, filled);
return true;
}
}
undoFills(work, filled);
return false;
};
search();
return { count, nodes: counter.nodes, deductions: counter.deductions };
}
При count === 1 удаление принимается. Значение 2 означает, что найдено как минимум два решения, поэтому цифра возвращается. Ноль при корректной генерации невозможен: исходная полная сетка всё ещё остаётся допустимым решением. В коде такой результат считается нарушением инварианта и сразу приводит к ошибке. Третье решение искать уже незачем.
У «цены единственности» теперь три счётчика. Средние значения получены на 12 полях каждого уровня:
уровень | решить: nodes | решить: deductions | доказать: nodes | доказать: deductions | время: доказать / решить |
|---|---|---|---|---|---|
easy | 56 | 56 | 1,02× | ||
medium | 2 | 56 | 3 | 71 | 1,12× |
hard | 6 | 83 | 11 | 141 | 1,49× |
evil | 18 | 176 | 25 | 239 | 1,27× |

Поиск одного решения и доказательство единственности
График показывает только ветвления; дедукции и отношение времени приведены в таблице.
Последний столбец — отношение средних времён. Для каждого из 12 полей сначала выполняется прогрев, затем берётся медиана пяти серий по 20 вызовов; отдельно усредняются времена «доказать» и «решить», после чего первое делится на второе.
Нулевая строка easy больше не выглядит как «работы нет»: решатели делают в среднем 56 форсированных ходов. По времени поиск второго решения почти ничего не добавляет — 1,02×. На hard приходится проверить 141 дедукцию вместо 83, а время вырастает в 1,49 раза.
Это цена одной проверки готового поля. Генератор платит её после каждого кандидата на удаление и иногда начинает заново с другой полной сетки:
уровень | генерация, мс | попыток | проверок уникальности | узлов в проверках | дедукций в проверках |
|---|---|---|---|---|---|
easy | 36,5 | 1 | 81 | 132 | 3 180,5 |
medium | 52,1 | 1,5 | 121,5 | 262 | 5 123,5 |
hard | 130,4 | 4 | 324 | 646,5 | 13 830 |
evil | 377,6 | 12 | 972 | 2 074 | 41 210,5 |
Здесь время включает всю генерацию. Один прогрев выполняется без таймера, затем берётся по одному замеру для 12 разных значений seed. В таблице приведены медианы этих 12 значений. Абсолютные миллисекунды на другой машине изменятся; счётчики работы останутся теми же.
Как seed = 7 обнаружил нечестный контрактДля seed = 7 я сначала решил, что сломан оценщик сложности. В интерфейсе была нажата кнопка evil, а под готовым полем появлялось hard. Оказалось, оба слоя показывали правду о разных вещах.
Генератор перебирал 40 вариантов, не находил уровень evil и возвращал ближайший hard. Компонент продолжал показывать запрос пользователя. Ошибка была в API: режим наилучшего приближения никак не отражался в типе результата.
Теперь контракт жёсткий. Генератор делает до 120 детерминированных попыток, возвращает точное совпадение или выбрасывает ошибку:
for (let attempt = 0; attempt < attempts; attempt++) {
const result = digOnce(/* детерминированный sub-seed */);
addAttempt(generation, result.stats);
const rating = rate(result.puzzle);
if (rating.level === level) {
return { puzzle: result.puzzle, solution, rating, generation };
}
}
throw new Error(`Не удалось сгенерировать уровень ${level} за ${attempts} попыток`);
Значения seed = 7 и seed = 8 закреплены регрессионными тестами. Для seed = 7 точный evil нашёлся на 95-й попытке:

Генератор для seed = 7: точный уровень evil и полная статистика попыток
95 попыток, 7 695 проверок уникальности, 16 445 узлов и 332 647 дедукций внутри этих проверок.
Что всё-таки означает уровеньУровни easy, medium, hard и evil — пороги по searchNodes эталонного решателя. Это удобно для воспроизводимого генератора, но не моделирует человека. Отдельный оценщик пробует одиночки, замкнутые кандидаты и голую пару — в смысле классических техник решения, описанных в HoDoKu. Его hardestTechnique иногда расходится с уровнем поиска.
Число подсказок тоже не спасает. McGuire, Tugemann и Civario доказали нижнюю границу в 17 подсказок для классического судоку с единственным решением. Граница говорит о существовании поля, а не о том, насколько трудно его решать.

Число подсказок против стоимости перебора
48 сгенерированных полей: Spearman = −0,191; Pearson для log10(nodes + 1) = −0,180.
В этой небольшой выборке монотонной связи не видно. Делать из 48 синтетических задач общий вывод о судоку я бы не стал. Для человеческой сложности нужен более богатый набор техник и данные реальных решателей — именно эту проблему подробно разбирает Radek Pelánek.
Как поиск попал во frontend
Граница между React UI, Web Worker и алгоритмическими модулями
React хранит сессию; решатель и генератор остаются обычными TypeScript-модулями.
React держит введённые цифры, карандашные пометки, выделение, таймер и историю undo/redo. Видимое поле вычисляется из исходных подсказок и пользовательского ввода. Правила компонентов не касаются: проверку конфликтов и завершения они вызывают из engine/.
Генерация уровня evil при seed = 7 занимает заметные секунды в браузере, поэтому поиск выполняется в Web Worker. Запрос получает id, а компонент применяет только последний ответ. Ошибка и таймаут завершают Promise; старый результат не может заменить более новый.
С трассой обнаружилась другая, очень визуальная ошибка. Финальное решение было правильным, но после отката на слайдере оставались одиночки из уже отвергнутой ветки. Теперь трасса ведётся как стек: при backtrack дедукции снимаются в обратном порядке. Тест проигрывает каждый шаг и сверяет промежуточное поле.
Что можно проверитьГлавный инвариант генератора запускается на тридцати значениях seed:
it("каждое сгенерированное поле имеет ровно одно решение", () => {
for (let seed = 1; seed <= 30; seed++) {
const { puzzle } = generate({ seed });
expect(hasUniqueSolution(puzzle)).toBe(true);
}
});
В 56 тестах также проверяются три решателя, поле из лида, стек трассы, точные уровни для seed = 7 и seed = 8, таймер после undo, доступность доски, ошибки Web Worker и нулевой результат проверки уникальности. pnpm report заново создаёт JSON с измерениями и все графики.
Проект гарантирует корректность и единственность сгенерированного поля, воспроизводимость в рамках одной версии и точный запрошенный уровень либо явную ошибку. Он не обещает человеческую сложность, минимальное число подсказок и одинаковое время на разных машинах. Центральная симметрия влияет только на расположение подсказок.
Открыть стенд · посмотреть исходники
Источники и материалыPeter Norvig — Solving Every Sudoku Puzzle и его набор top95
статьи Хабра про генерацию судоку и Algorithm X
Меня зовут Виктор Горбачёв. Больше семи лет пишу коммерческий фронтенд, последние два с лишним года — тимлид кросс-функциональной команды; преподавал React и TypeScript. В «ИграКОД» я разбираю алгоритмы через небольшие работающие проекты, которые можно запустить и проверить.
Подписывайтесь, чтобы не пропустить интересное. Лайки помогают понять, что статьи нужны и мне продолжать писать.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как я делал текстовую игру для веба | 0 | 8.32 | 31-07-2026 |
| 2 | Подножка для AI в виде UTF-8 | 0 | 10.07 | 14-10-2025 |
| 3 | IllegalMonitorStateException. Красим код в поисках монитора | 0 | 12.6 | 29-07-2026 |
| 4 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 5 | [Перевод] Стохастический клеточный автомат на системе типов | 0 | 8.92 | 01-08-2026 |
| 6 | Давайте заглянем в этот самый вайб-код | 0 | 8.44 | 20-03-2026 |
| 7 | Если ссылки схлопываются, значит это кому‑то нужно | 0 | 8.26 | 30-07-2026 |
| 8 | Нейросеть-автопилот вместо 400 Playwright-тестов | 0 | 7 | 07-07-2026 |
| 9 | Мне надоело писать один и тот же код. Поэтому я сделал Featuregen | 3 | 6 | 09-07-2026 |
| 10 | Продакшн‑разработка в одиночку с AI‑агентами: принуждай к правилам, а не объясняй их | 0 | 8.25 | 24-07-2026 |