Введение

Как известно, нахождение оптимального алгоритма решения любой NP-полной задачи - это цель амбициозная, пахнущая славой и неплохими деньгами. Как раз к таким задачам относится Судоку, и как раз своим решением этой головоломки я горел последний месяц. На данный момент сделана (по ощущениям) лишь половина дела, и хоть результаты и вышли интересными (по крайней мере для меня-любимого) - дело еще далеко до завершения, т.к. в определенном моменте настал "творческий тупик". Впрочем, надеюсь, что он пройдет и на свет появится по крайней мере какое-то новое любопытное решение. Пока что лишь поделюсь своими первыми наработками в этом направлении. Пока что они не вполне вылизаны + написаны на Java, перевод на какой-нибудь более простой для восприятия язык планируется лишь с окончательной победой на Java. Потому если не перевариваете Java и всякие там стримы - лучше не стоит.

Пишу статью в том числе и для себя, чтобы подвести предварительные итоги этой кампании и подумать о дальнейших шагах. Ну, может, кому-то тоже будет интересно.

Шаг 0. С чего начинаем

Начать следует с общей сути алгоритма. Это не совсем обычный перебор вслепую или в полу-слепую. По результатам работы готовой части алгоритма мы получаем такие "кусочки" этой головоломки, которые по отдельности гарантированно не нарушают правила поля, и нашей задачей лишь остается выбрать правильные кусочки и собрать из них правильный паззл.

Начинается все с подбора «опорных» точек для анализа.

Нам не нужно подбирать варианты исходя из каждой точки поля - достаточно будет выявить ограниченный набор точек и найти все не нарушающие правила поля варианты исходя из них. Варианты в данном случае - это заполнение других точек относительно опорной, которые входят в ту же строку, тот же столбец и тот же квадрат (образуют сегмент в моей терминологии).

При идеальном распределении каждая «опорная точка» — это новый квадрат и новая строка/столбец. Идеальный случай выглядит как-то так.

При поле:

Я понимаю, что это не совсем "чистое" судоку, т.к. количество решений для него можно подобрать > 1, это лишь тестовый пример
Я понимаю, что это не совсем "чистое" судоку, т.к. количество решений для него можно подобрать > 1, это лишь тестовый пример

опорными точками будут позиции 0.0, 1.3, 2.1, 3.2 (счет с нуля).

Понятно, что так получится не всегда, но в любом случае таким образом мы стремимся к минимизации опорных точек, а значит и минимизации дальнейших сравнений — и это при полном покрытии нашего поля.

О том, как выглядит подбор этих опорных точек в коде — немного ниже; технически подбор опорных точек — это не первый шаг, хотя логически он напрашивается первым.

Шаг 1. Обозначаем основные классы и формируем мапу потенциалов.

Определимся, какие сущности в форме классов нам пригодятся в дальнейшем.

Position - позиция в поле. Ничего интересного, row да column - индексы с нуля.

PositionPotential - название тоже говорит само за себя, потенциал позиции. Важная мета-информация, получаемая на первом этапе анализа исходного поля. Включает в себя possibleNumbers - возможные значения в этой позиции исходя из того, как заполнены связанные строка/столбец/квадрат (из примера выше для 0.0 это будет 2,3,4 - 1 исключаем, т.к. она уже находится внизу по строке 0); squareIndex - индекс квадрата поля (для 2.1, скажем, этот индекс будет 2); segmentInfo - информация о сегменте данной позиции, самые важные данные здесь - это segmentPositions (для 2.1, например, это будут 0.1, 1.1, 2.0, 2.2, 2.3, 3.1, т.е. свободные точки, связанные с этой по сегменту).

Cell - позиция в ассоциации с числом. position, number - атрибуты. Если number пустое - поле не заполнено.

Sudoku и имплементация SudokuImpl - класс, отвечающий за поиск решения. В нем и будет происходить вся основная логика. Получает на вход n - размерность квадратов и матрицу field - наше игровое поле.

В коде все это выглядеть будет так.

Структуры

Position

public record Position(
        int row,
        int column
) {

    // опустим детали...
}

SegmentInfo

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public record SegmentInfo(Position startingPoint,
                          Set<Integer> possibleNumbers,
                          Set<Position> segmentPositions) {

   // опустим детали...
}

PositionPotential

import java.util.HashSet;
import java.util.Set;

public class PositionPotential {

    private final Set<Integer> possibleNumbers;

    private SegmentInfo segmentInfo;

    private int squareIndex;

    public PositionPotential(int possibleNumber) {
        Set<Integer> setWithSingleNumber = new HashSet<>();
        setWithSingleNumber.add(possibleNumber);

        this.possibleNumbers = setWithSingleNumber;
    }

    public PositionPotential(Set<Integer> possibleNumbers, int squareIndex) {
        this.possibleNumbers = possibleNumbers;
        this.squareIndex = squareIndex;
    }

    public PositionPotential(Set<Integer> possibleNumbers, SegmentInfo segmentInfo, int squareIndex) {
        this.possibleNumbers = possibleNumbers;
        this.segmentInfo = segmentInfo;
        this.squareIndex = squareIndex;
    }

    // опустим прочие детали...
}

Cell

public record Cell(
        Position position,
        Integer number
) {
    public boolean isEmpty() {
        return number == null;
    }
    // опустим прочие детали...
}

Интерфейс центрального класса
public interface Sudoku {

    int[][] getVariant();

    boolean checkVariant(int[][] variant);
}

С основными сущностями определились. Теперь давайте получим из нашей матрицы field все ячейки - пустые и заполненные - и по ним сформируем мапу из свободных позиций и потенциалов. Подробно останавливаться на этом процессе вряд ли есть смысл, он не сложный и второстепенный - можно будет посмотреть в полном коде. Главное, что на выходе мы получаем вот такую вот информацию.

Set<Cell> cells = getCells(this.field);
Map<Position, PositionPotential> positionPotentialMap = createNewPotentialMapForCells(cells);

Теперь, исходя из нее, можно получить и "опорные" точки для анализа.

Алгоритм выглядит вот так.

Возможно, кривовато, но вроде работает
private Set<Position> getPositionsForAnalyze(Map<Position, PositionPotential> positionPotentialMap) {
        Set<Position> result = new HashSet<>();

        // получаем все свободные строки
        Set<Integer> rowsToFeel = positionPotentialMap.keySet().stream().map(Position::row).collect(Collectors.toSet());

        // т.к. мы будем проводить манипуляции с мапой, которые не должны отразиться на изначальном объекте - получаем ее копию
        Map<Position, PositionPotential> positionPositionPotentialCopy = copyOfPotentialMap(positionPotentialMap);

        for (int row : rowsToFeel) {
            // получаем первую позицию с данной строкой; если таковых нет - доудалялись. придется восстанавливать мапу, откинув только уже выбранные опорные точки.
            Position firstPositionWithRow = positionPositionPotentialCopy.keySet().stream().filter(p -> p.row() == row).findFirst().orElse(null);
            if (firstPositionWithRow == null) {
                positionPositionPotentialCopy = copyOfPotentialMap(positionPotentialMap);
                for (Position position : result) {
                    positionPositionPotentialCopy.remove(position);
                }
                firstPositionWithRow = positionPositionPotentialCopy.keySet().stream().filter(p -> p.row() == row).findFirst().orElseThrow();
            }

            // добавляем в результат первую позицию по строке
            result.add(firstPositionWithRow);

            Set<Position> positionsToDelete = new HashSet<>();
            positionsToDelete.add(firstPositionWithRow);

            int squareOfPosition = positionPositionPotentialCopy.get(firstPositionWithRow).getSquareIndex();

            // все связанные по квадрату, столбцу или строке позиции убираем из рассмотрения - пока не расставим все точки либо пока не останется места для распределения на следующие строки
            Position finalFirstPositionWithRow = firstPositionWithRow;
            Set<Position> connectedPositions = positionPositionPotentialCopy.entrySet().stream().filter(es -> {
                Position position = es.getKey();
                PositionPotential positionPotential = es.getValue();

                return position.row() == finalFirstPositionWithRow.row() || position.column() == finalFirstPositionWithRow.column() || positionPotential.getSquareIndex() == squareOfPosition;
            }).map(Map.Entry::getKey).collect(Collectors.toSet());

            // удаляем
            positionsToDelete.addAll(connectedPositions);

            for (Position positionToDelete : positionsToDelete) {
                positionPositionPotentialCopy.remove(positionToDelete);
            }
        }

        return result;
    }

Set<Position> positionsToAnalyze = getPositionsForAnalyze(positionPotentialMap);

Итак, "опорные точки" у нас есть. Дальше можно приступать к валидации и анализу возможных вариантов по каждой точке.

Шаг 2. Валидация, подбор вариантов

Определимся с необходимыми структурами здесь.

PossibleNumbersValidationResult - результат валидации всех возможных чисел в опорной точке. Содержит информацию, по какой позиции результат; валидно/не валидно (если невалидно - то решения в целом не существует, ибо как минимум одну ячейку мы заполнить уже не можем); результаты валидации с подобранными вариантами - для каждой возможной цифери.

PossibleNumberValidationResult - результат валидации конкретного числа в опорной точке. Опять же, для какой позиции результат; по какой цифери; валидно/не валидно (если не валидно - ветки с этим числом в опорной точке в принципе не имеет смысла подбирать). И коллекция из SegmentValidBatch, о нем ниже.

CellsVariant - вариант заполнения какого-то фрагмента поля (строки, столбца, квадрата). По сути просто коллекция заполненных Cell, связанная с типом ROW, COLUMN или SQUARE.

SegmentValidBatch - вариант, как заполнить сегмент (включая опорную точку). Имеет три CellsVariant - строка, столбец, квадрат.

Как выглядит в коде.

Собсна, вот

PossibleNumbersValidationResult

public record PossibleNumbersValidationResult(
        Position forPosition,
        boolean isValid,
        Map<Integer, PossibleNumberValidationResult> validationResultByNumber
) {
//... опустим детали
}

PossibleNumberValidationResult

public record PossibleNumberValidationResult(
        Position forPosition,
        int number,
        boolean isValid,
        List<SegmentValidBatch> segmentValidBatches
) {
//... опустим детали
}

CellsVariant

public class CellsVariant {

    private final Set<Cell> cells;

    private UUID batchUUID;

    private final UUID uuid;

    private final SubSegmentType type;

  // опустим детали...
}

Итак, мы берем некую опорную точку и идем ее валидировать. Результаты мы собираем в лист. То, что мы получим - это на данный момент и есть тот максимум, что пока можно извлечь из алгоритма (как все это соотносить - отдельный вопрос, пока актуальный).

List<PossibleNumbersValidationResult> possibleNumbersValidationResults = new ArrayList<>();
for (Position position : positionsToAnalyze) {
      PossibleNumbersValidationResult possibleNumbersValidationResult = possibleNumbersValidation(position, positionPotentialMap);
      possibleNumbersValidationResults.add(possibleNumbersValidationResult);
}

Метод possibleNumbersValidation

possibleNumbersValidation
private PossibleNumbersValidationResult possibleNumbersValidation(Position position,
                                                                      Map<Position, PositionPotential> positionPotentialMap) {
        PositionPotential potential = positionPotentialMap.get(position);
        Set<Integer> possibleNumbers = potential.getPossibleNumbers();

        // получаем сегмент нашей позиции
        Map<Position, PositionPotential> positionSegment = getSegment(position, positionPotentialMap);

        // получаем позиции, сгруппированные по строке опорной позиции - т.е. по сути столбец сегмента
        Map<Position, PositionPotential> positionsByRow = positionSegment.entrySet().stream().filter(p -> Objects.equals(p.getKey().row(), position.row())).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        // получаем позиции, сгруппированные по столбцу опорной позиции - т.е. по сути строку сегмента
        Map<Position, PositionPotential> positionsByColumn = positionSegment.entrySet().stream().filter(p -> Objects.equals(p.getKey().column(), position.column())).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        // получаем позиции, сгруппированные по квадрату опорной позиции - из них потом мы уберем те позиции, которые пересекаются со строкой и столбцом
        Map<Position, PositionPotential> positionsBySquare = positionSegment.entrySet().stream().filter(p -> p.getValue().getSquareIndex() == potential.getSquareIndex()).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

        boolean isValid = false;
        Map<Integer, PossibleNumberValidationResult> possibleNumberValidationResultMap = new HashMap<>();

        // пробегаемся
        for (int possibleNumber : possibleNumbers) {
            PossibleNumberValidationResult possibleNumberValidationResult = possibleNumberValidation(position, possibleNumber, positionsByRow, positionsByColumn, positionsBySquare);
            possibleNumberValidationResultMap.put(possibleNumber, possibleNumberValidationResult);
            // если хотя бы одна возможная цифра валидна - в целом результат валиден
            if (possibleNumberValidationResult.isValid()) {
                isValid = true;
            }
        }

        return new PossibleNumbersValidationResult(position, isValid, possibleNumberValidationResultMap);
    }

Метод possibleNumberValidation выглядит немного страшновато и о нем чуть ниже. Пока остановлюсь на том, что внутри него используется метод validateSubSegmentAfterDeletion - он призван валидировать мапу-подсегмент (строка, столбец, квадрат), из которого мы удалили рассматриваемое число.

Этот метод - так сказать, самая мякотка, выполняет работу по валидации и возвращает нам все возможные валидные варианты заполнения, когда в опорной точке стоит рассматриваемое число.

Начнем с него и продолжим идти вглубь.

Работает валидация в два этапа:

  • смотрит на встречаемость той или иной комбинации в подсегменте. Если ее частота превышает ее размерность - мы не сможем заполнить подсегмент. Невалидно.

  • смотрим на комбинации из одной цифры. Если таковые есть - запоминаем их, как обязательные варианты для заполнения той или иной позиции. Вычеркиваем число из других позиций. Делаем это циклично, пока ситуация "есть позиции с единственным вариантом в подсегменте" сохраняется, т.к. может быть "цепная реакция". Если что-то по итогу оказалось пустым - подсегмент на рассматриваемом числе в опорной позиции невалиден.

А затем мы уже строим возможные варианты для остальных позиций, с учетом единственно-возможных вариантов.

Как выглядит код.

С комментариями
private SubSegmentFillingResult validateSubSegmentAfterDeletion(Map<Position, PositionPotential> subSegment,
                                                                    Position forPosition,
                                                                    int number,
                                                                    SubSegmentType subSegmentType) {
        // результат с возможными заполнениями подсегмента. инициализируем его переданным типом.
        SubSegmentFillingResult result = new SubSegmentFillingResult(subSegmentType);
        // если подсегмент изначально пуст и в анализе, получается, не нуждается - просто вернем true
        if (subSegment.isEmpty()) {
            result.setValid(true);
            return result;
        }

        // заводим коллекцию, в которой будут складываться ячейки с единственно возможным заполнением
        Set<Cell> mandatoryCells = new HashSet<>();

        // мапа, в которой будет вестись статистика: сколько раз та или иная комбинация встречается на протяжении подсегмента. пригодится.
        Map<Set<Integer>, Integer> combinationCount = new HashMap<>();

        // мапа, в которой мы держим позиции, соотнесенные с единственно возможным вариантом заполнения. пригодится для формирования mandatoryCells.
        Map<Position, Integer> singleElementsMap = new HashMap<>();

        Map<Position, PositionPotential> copyOfSegment = copyOfPotentialMap(subSegment);

        for (Map.Entry<Position, PositionPotential> segmentEntry : copyOfSegment.entrySet()) {
            Position position = segmentEntry.getKey();
            PositionPotential positionPotential = segmentEntry.getValue();

            // ведем статистику встречаемости
            Set<Integer> possibleNumbers = positionPotential.getPossibleNumbers();
            combinationCount.putIfAbsent(possibleNumbers, 0);
            combinationCount.put(possibleNumbers, combinationCount.get(possibleNumbers) + 1);

            // если возможно только одно число - заполняем мапу singleElementsMap
            if (possibleNumbers.size() == 1) {
                singleElementsMap.put(position, possibleNumbers.stream().findFirst().orElseThrow());
            }
        }

        // если встречается хоть одна комбинация, частота которой превышает ее размерность - это значит, что заполнить подсегмент мы уже не можем. вердикт - невалидно.
        for (Map.Entry<Set<Integer>, Integer> combinationCountEntry : combinationCount.entrySet()) {
            Set<Integer> combination = combinationCountEntry.getKey();
            Integer count = combinationCountEntry.getValue();

            if (count > combination.size()) {
                result.setValid(false);
                return result;
            }
        }

        do {
            // здесь мы делаем манипуляции с мапой singleElementsMap, т.е. с позициями, которые имеют только один вариант для заполнения.
            // формируем по ним mandatoryCells, а так же убираем "заполненные" этим числом позиции из анализа + убираем из остальных позиций это число, как возможное
            // т.к. операция может вызвать "цепную реакцию" - делаем это все в цикле.
            Set<Integer> singleElementsSet = new HashSet<>(singleElementsMap.values());

            for (Map.Entry<Position, Integer> singeElementEntry : singleElementsMap.entrySet()) {
                Position position = singeElementEntry.getKey();
                Integer mandatoryNumber = singeElementEntry.getValue();

                mandatoryCells.add(new Cell(position, mandatoryNumber));

                copyOfSegment.remove(position);
            }

            if (copyOfSegment.isEmpty()) {
                break;
            }

            for (PositionPotential potential : copyOfSegment.values()) {
                potential.removePossibleNumbers(singleElementsSet);
                if (potential.getPossibleNumbers().isEmpty()) {
                    // если по итогу манипуляции мы не можем заполнить какую-то позицию - то это тоже нездоровая ситуация. вердикт - невалидно.
                    result.setValid(false);
                    return result;
                }
            }

            singleElementsMap = copyOfSegment.entrySet()
                    .stream()
                    .filter(e -> e.getValue().getPossibleNumbers().size() == 1)
                    .collect(Collectors.toMap(Map.Entry::getKey, v -> v.getValue().getPossibleNumbers().stream().findFirst().orElseThrow()));
        } while (!singleElementsMap.isEmpty());


        // проваливаемся в метод, строящий возможные варианты заполнения с опорной точкой и валидируемым числом, учитывая mandatoryCells
        NumberSequenceVariant<Position> numberSequenceVariants = getMainVariantForSubSegment(copyOfSegment, mandatoryCells, forPosition, number);

        result.setNumberSequenceVariant(numberSequenceVariants);

        if (numberSequenceVariants.isEnd()) {
            result.setValid(false);
            return result;
        }

        result.setValid(true);

        return result;
    }

Теперь - к методам getMainVariantForSubSegment, getVariantOnRootNumberForSubSegment и франкенштейну NumberSequenceVariant.

Метод getMainVariantForSubSegment ничего особо интересного не представляет - это просто предбанник для второго метода, который снова обогатит нам подсегмент mandatoryCells (примечание: я не до конца понимаю, зачем сделал такую свистопляску с перегонкой в Cell в предыдущем методе и обратно в Map здесь, но по крайней мере сигнатура метода выглядит как-то понаглядней, что ли...); возьмет первую позицию из подсегмента и создаст по ее возможным циферям "корневой" NumberSequenceVariant, и будет раскручивать дальнеший поиск вариантов посредством вызова второго метода.

С комментариями
private NumberSequenceVariant<Position> getMainVariantForSubSegment(Map<Position, PositionPotential> subSegment, Set<Cell> mandatoryCells, Position forPosition, int number) {
        // инициализация "корня"
        NumberSequenceVariant<Position> numberSequenceVariant = new NumberSequenceVariant<>(number, forPosition);
        List<NumberSequenceVariant<Position>> resultsByPossibleNumbersInFirstPositionInSubSegment = new ArrayList<>();

        Map<Position, PositionPotential> subSegmentCopy = copyOfPotentialMap(subSegment);

        // возвращаем mandatoryCell на свои места
        for (Cell mandatoryCell : mandatoryCells) {
            subSegmentCopy.put(mandatoryCell.position(), new PositionPotential(mandatoryCell.number()));
        }

        Position firstPosition = subSegmentCopy.keySet().stream().findFirst().orElseThrow();
        Set<Integer> possibleNumbersForFirstPosition = subSegmentCopy.values().stream().findFirst().map(PositionPotential::getPossibleNumbers).orElseThrow();

        // если подсегмент только из одной позиции - просто добавляем в последовательность возможные цифры из первой позиции, и уходим; дальнейшие действия ни к чему.
        if (subSegmentCopy.size() == 1) {
            for (int possibleNumber : possibleNumbersForFirstPosition) {
                resultsByPossibleNumbersInFirstPositionInSubSegment.add(new NumberSequenceVariant<>(possibleNumber, firstPosition));
            }
            numberSequenceVariant.justAddNewVariantsToBranches(resultsByPossibleNumbersInFirstPositionInSubSegment);
            return numberSequenceVariant;
        }

        // оно нам ни к чему в дальнейшем разборе...
        subSegmentCopy.remove(firstPosition);

        // пробегаемся по возможным цифрам для первой позиции, по каждой получаем варианты
        for (int possibleNumber : possibleNumbersForFirstPosition) {
            NumberSequenceVariant<Position> variant = getVariantOnRootNumberForSubSegment(possibleNumber, firstPosition, subSegmentCopy);
            // если метод вернул null - значит, путной цепочки собрать не вышло. увы. null-ы нам не нужны.
            if (variant != null) {
                resultsByPossibleNumbersInFirstPositionInSubSegment.add(variant);
            }
        }

        // добавляем результаты по цифрам для первой позиции в корень и возвращаем его
        numberSequenceVariant.justAddNewVariantsToBranches(resultsByPossibleNumbersInFirstPositionInSubSegment);
        return numberSequenceVariant;
    }

Перед тем, как двигаться дальше к getVariantOnRootNumberForSubSegment - немного остановимся на франкенштейнах.

Первый из них — NumberSequenceVariant. Что‑то вроде дерева, только стремное, как из фильма «Сонная лощина». Суть его в том, что каждый «узел» ассоциирован с определенной позицией — это раз; последовательность «вниз» из чисел и позиций образует ветку — это два; в каждой ветке число не может повторяться — это три; есть функциональность для удаления веток ниже определенной размерности — это нам пригодится и это четыре; есть возможность из веток получить внятные коллекции элементов — это пять. Решение словно бы немного попахивает, но работает и, возможно, работает правильно. Порефакторить бы, но трогать уже даже немного боязно :-)

А вот и оно, родимое, полный код этого костыльного ужаса
public class NumberSequenceVariant<KEY> {
    private final int number;
    private final KEY key;
    private final List<NumberSequenceVariant<KEY>> branches = new ArrayList<>();

    public NumberSequenceVariant(int number, KEY key) {
        this.number = number;
        this.key = key;
    }

    public boolean isEnd() {
        return branches.isEmpty();
    }

    public void justAddNewVariantsToBranches(List<NumberSequenceVariant<KEY>> newVariants) {
        branches.addAll(newVariants);
    }

    public void justAddWithIgnoringSame(int number, KEY key) {
        if (number == this.number) {
            return;
        }
        branches.add(new NumberSequenceVariant<>(number, key));
    }

    public boolean addVariantToEndOfPathWithIgnoringSame(int number, KEY keyOfVariant, Set<KEY> passedPath) {
        if (number == this.number) {
            return false;
        }

        Set<KEY> pathWithoutCurrentPosition = new HashSet<>(passedPath);

        boolean wasDeletedInPath = pathWithoutCurrentPosition.remove(key);

        if (!wasDeletedInPath) {
            return false;
        }

        if (pathWithoutCurrentPosition.isEmpty()) {
            branches.add(new NumberSequenceVariant<>(number, keyOfVariant));
            return true;
        }

        boolean wasAdding = false;

        for (NumberSequenceVariant<KEY> child : branches) {
            if (child.addVariantToEndOfPathWithIgnoringSame(number, keyOfVariant, pathWithoutCurrentPosition)) {
                wasAdding = true;
            }
        }

        return wasAdding;
    }

    /**
     * Удаление всех веток, размер которых не соответствует.
     */
    public void removeAllBranchesWithSizeLessThan(int size) {
        if (size == 1 || isEnd()) {
            return;
        }

        Set<NumberSequenceVariant<KEY>> toDelete = new HashSet<>();
        for (NumberSequenceVariant<KEY> branch : branches) {
            int counter = 1;
            boolean result = branch.removeRecursion(size, counter);
            if (!result) {
                toDelete.add(branch);
            }
        }

        branches.removeAll(toDelete);
    }

    public Set<Set<Pair<KEY, Integer>>> getKeyValuePairs() {
        Set<Set<Pair<KEY, Integer>>> result = new HashSet<>();
        Pair<KEY, Integer> currentCell = Pair.of(key, number);

        for (NumberSequenceVariant<KEY> branch : branches) {
            if (branch.isEnd()) {
                Set<Pair<KEY, Integer>> singleSet = new HashSet<>();
                singleSet.add(Pair.of(branch.key, branch.number));
                singleSet.add(currentCell);
                result.add(singleSet);
                continue;
            }
            Set<Set<Pair<KEY, Integer>>> resultsFromBranch = branch.getKeyValuePairs();
            for (Set<Pair<KEY, Integer>> resultFromBranch : resultsFromBranch) {
                Set<Pair<KEY, Integer>> appendedWithCurrentCell = new HashSet<>(resultFromBranch);
                appendedWithCurrentCell.add(currentCell);
                result.add(appendedWithCurrentCell);
            }
        }

        if (result.isEmpty()) {
            Set<Pair<KEY, Integer>> singleSet = new HashSet<>();
            singleSet.add(Pair.of(key, number));
            result.add(singleSet);
        }

        return result;
    }

    private boolean removeRecursion(int size, int counter) {
        counter++;

        if (isEnd()) {
            return counter >= size;
        }

        boolean result = false;
        Set<NumberSequenceVariant<KEY>> toDelete = new HashSet<>();
        for (NumberSequenceVariant<KEY> branch : branches) {
            result = branch.removeRecursion(size, counter);
            if (!result) {
                toDelete.add(branch);
            }
        }

        branches.removeAll(toDelete);

        return !isEnd();
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        NumberSequenceVariant<KEY> that = (NumberSequenceVariant) o;
        return number == that.number && Objects.equals(key, that.key);
    }

    @Override
    public int hashCode() {
        return Objects.hash(number, key);
    }
}

Второе - это VariantSelectionElementChain.

Для чего он нужен?

Алгоритм подбора вариантов заполнения сводится к такому:

убираем из всех позиций "корневое" число (т.е. число из первой позиции, с которым мы определились в предыдущем методе). Допустим, у нас вышло как-то так:

позиция x1.y1: {2, 3}

позиция x2.y2: {1}

позиция x3.y3: {4}

позиция x4.y4: {3, 4}

Сортируем по размерности, x2.y2 и x3.y3 у нас выносятся на самый верх.

Элементы внутри тоже на всякий случай сортируем (правда, я уже забыл, в чем была моя задумка, виноват :)).

Берем первый элемент из этой цепочки. Добавляем его возможные цифры в результирующий NumberSequenceVariant, можно без каких-либо проверок и условий, просто записываем в конец.

Далее начинаем проходить циклом по другим вариантам.

Идем попарно, берем левое и правое; если правое состоит из одного элемента - пытаемся добавить в дерево, и если попытка была неудачной - сразу бросаем это дело, т.к. тут уже явный тупик (каким-то образом прокрались два одинаковых единственно-возможных варианта); если же нет - пробегаемся по каждой цифре из правого. Если левое включает в себя цифру из правого и левое состоит из 1 элемента - мы сразу отбрасываем такой вариант; если нет - пытаемся добавить число в дерево; если была хотя бы одна успешная попытка добавления - то продолжаем процесс; если не было ни одной - то это тупик.

Ну и да, по тому, по какой ветви распределять цифры - будет отвечать переменная passedPath, коллекция позиций (я на всякий случай поясню: passedPath - "пройденный путь" xD). Та самая, что принимается в качестве аргумента в addVariantToEndOfPathWithIgnoringSame. Мы дополняем ее пройденной позицией в конце добавления либо единственно возможного варианта, либо любого из не-единственных. Да, тут получается некоторый рассинхрон между ветками, которые постоянно дополняются и ветками, в которых ничего не залилось... но последние ветки-неудачники нам и не нужны, мы их все равно потом "обрезаем".

Собственно, VariantSelectionElementChain нужен лишь для организации этой цепочки.

Код VariantSelectionElementChain.

Тута
public class VariantSelectionElementChain implements Comparable<VariantSelectionElementChain> {

    private final Position position;

    private final List<Integer> sortedPossibleNumbers;

    public VariantSelectionElementChain(Position position, PositionPotential positionPotential, int excludeNumber) {
        this.position = position;
        PositionPotential potentialCopy = positionPotential.safeCopy();

        this.sortedPossibleNumbers = potentialCopy.getPossibleNumbers().stream().filter(n -> n != excludeNumber).sorted().toList();
    }

    @Override
    public int compareTo(VariantSelectionElementChain o) {
        int first = sortedPossibleNumbers.size();
        int second = o.sortedPossibleNumbers.size();

        return Integer.compare(first, second);
    }

    public Position getPosition() {
        return position;
    }

    public List<Integer> getSortedPossibleNumbers() {
        return sortedPossibleNumbers;
    }
}

И код самого метода.

Еще один страх божий, на этот раз без комментариев - описание алгоритма вроде и так понятное
 private NumberSequenceVariant<Position> getVariantOnRootNumberForSubSegment(int number, Position position, Map<Position, PositionPotential> subSegment) {
        Set<Position> passedPath = new HashSet<>();
        NumberSequenceVariant<Position> result = new NumberSequenceVariant<>(number, position);

        passedPath.add(position);

        List<VariantSelectionElementChain> variantSelectionChain = subSegment.entrySet()
                .stream()
                .map(es -> new VariantSelectionElementChain(es.getKey(), es.getValue(), number))
                .sorted()
                .toList();

        VariantSelectionElementChain firstElement = variantSelectionChain.stream().findFirst().orElseThrow();
        for (int possibleNumberInFirstElement : firstElement.getSortedPossibleNumbers()) {
            result.justAddWithIgnoringSame(possibleNumberInFirstElement, firstElement.getPosition());
        }
        if (result.isEnd()) {
            return null;
        }

        if (variantSelectionChain.size() == 1) {
            return result;
        }

        passedPath.add(firstElement.getPosition());

        for (int i = 0; i < variantSelectionChain.size() - 1; i++) {
            VariantSelectionElementChain leftElement = variantSelectionChain.get(i);
            VariantSelectionElementChain rightElement = variantSelectionChain.get(i + 1);

            Position rightElementPosition = rightElement.getPosition();

            List<Integer> numbersForLeft = leftElement.getSortedPossibleNumbers();
            List<Integer> numbersForRight = rightElement.getSortedPossibleNumbers();

            boolean wasAdding = false;

            if (numbersForRight.size() == 1) {
                wasAdding = result.addVariantToEndOfPathWithIgnoringSame(numbersForRight.stream().findFirst().orElseThrow(), rightElementPosition, passedPath);
                if (!wasAdding) {
                    break;
                }
                passedPath.add(rightElementPosition);
                continue;
            }

            for (int numberInRight : numbersForRight) {
                if ((numbersForLeft.contains(numberInRight) && numbersForLeft.size() == 1)) {
                    continue;
                }
                if (result.addVariantToEndOfPathWithIgnoringSame(numberInRight, rightElementPosition, passedPath)) {
                    wasAdding = true;
                }
            }

            if (!wasAdding) {
                break;
            }

            passedPath.add(rightElementPosition);
        }

        result.removeAllBranchesWithSizeLessThan(subSegment.size() + 1);
        if (result.isEnd()) {
            return null;
        }

        return result;
    }

После всей этой истории на выходе мы получаем SubSegmentFillingResult, который держит информацию о том, валиден/невалиден ли подсегмент (будет невалидным и в случае отсутствия вариантов); заполненный экземпляр NumberSequenceVariant и тип подсегмента.

Шаг 3. Дальнейший подбор вариантов

Таким образом мы получаем провалидированные подсегменты. Теперь возвращаемся к методу possibleNumberValidation. Как я и говорил, код у него страшноватый и связанный с тем, как мы в итоге соотносим подсегменты друг с другом, группируя их в валидные "батчи".

А вот и он
private PossibleNumberValidationResult possibleNumberValidation(Position forPosition,
                                                                    int number,
                                                                    Map<Position, PositionPotential> positionsByRow,
                                                                    Map<Position, PositionPotential> positionsByColumn,
                                                                    Map<Position, PositionPotential> positionsBySquare) {
        Map<Position, PositionPotential> positionsByRowCopyWithoutNumber = copyOfPotentialMap(positionsByRow).entrySet().stream().peek(e -> e.getValue().removePossibleNumber(number)).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        Map<Position, PositionPotential> positionsByColumnCopyWithoutNumber = copyOfPotentialMap(positionsByColumn).entrySet().stream().peek(e -> e.getValue().removePossibleNumber(number)).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
        Map<Position, PositionPotential> positionsBySquareCopyWithoutNumber = copyOfPotentialMap(positionsBySquare).entrySet().stream().peek(e -> e.getValue().removePossibleNumber(number)).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

        Set<Position> positionsIntersectionsWithSquare = new HashSet<>();

        for (Position positionInRow : positionsByRowCopyWithoutNumber.keySet()) {
            if (positionsBySquareCopyWithoutNumber.remove(positionInRow) != null) {
                positionsIntersectionsWithSquare.add(positionInRow);
            }
        }

        for (Position positionInColumn : positionsByColumnCopyWithoutNumber.keySet()) {
            if (positionsBySquareCopyWithoutNumber.remove(positionInColumn) != null) {
                positionsIntersectionsWithSquare.add(positionInColumn);
            }
        }

        SubSegmentFillingResult columnSubSegmentFillingResult = new SubSegmentFillingResult(SubSegmentType.COLUMN);
        columnSubSegmentFillingResult.setValid(true);
        if (!positionsByRowCopyWithoutNumber.isEmpty()) {
            columnSubSegmentFillingResult = validateSubSegmentAfterDeletion(positionsByRowCopyWithoutNumber, forPosition, number, SubSegmentType.COLUMN);
        }

        if (!columnSubSegmentFillingResult.isValid()) {
            return new PossibleNumberValidationResult(forPosition, number, false, null);
        }

        SubSegmentFillingResult rowSubSegmentFillingResult = new SubSegmentFillingResult(SubSegmentType.ROW);
        rowSubSegmentFillingResult.setValid(true);
        if (!positionsByColumnCopyWithoutNumber.isEmpty()) {
            rowSubSegmentFillingResult = validateSubSegmentAfterDeletion(positionsByColumnCopyWithoutNumber, forPosition, number, SubSegmentType.ROW);
        }

        if (!rowSubSegmentFillingResult.isValid()) {
            return new PossibleNumberValidationResult(forPosition, number, false, null);
        }

        SubSegmentFillingResult squareSubSegmentFillingResult = new SubSegmentFillingResult(SubSegmentType.SQUARE);
        squareSubSegmentFillingResult.setValid(true);
        if (!positionsBySquareCopyWithoutNumber.isEmpty()) {
            squareSubSegmentFillingResult = validateSubSegmentAfterDeletion(positionsBySquareCopyWithoutNumber, forPosition, number, SubSegmentType.SQUARE);
        }

        if (!squareSubSegmentFillingResult.isValid()) {
            return new PossibleNumberValidationResult(forPosition, number, false, null);
        }

        if (squareSubSegmentFillingResult.variantsAreEmpty() || columnSubSegmentFillingResult.variantsAreEmpty() || rowSubSegmentFillingResult.variantsAreEmpty()) {
            if (squareSubSegmentFillingResult.variantsAreEmpty() && columnSubSegmentFillingResult.variantsAreEmpty() && rowSubSegmentFillingResult.variantsAreEmpty()) {
                return new PossibleNumberValidationResult(forPosition, number, true, null);
            }

            if (squareSubSegmentFillingResult.variantsAreEmpty()) {
                if (!rowSubSegmentFillingResult.variantsAreEmpty() && !columnSubSegmentFillingResult.variantsAreEmpty()) {
                    List<SegmentValidBatch> validBatches = getValidBatchesForColumnAndRow(columnSubSegmentFillingResult, rowSubSegmentFillingResult, positionsIntersectionsWithSquare);
                    if (validBatches.isEmpty()) {
                        return new PossibleNumberValidationResult(forPosition, number, false, null);
                    }
                    return new PossibleNumberValidationResult(forPosition, number, true, validBatches);
                }

                if (!rowSubSegmentFillingResult.variantsAreEmpty()) {
                    List<SegmentValidBatch> validBatches = getValidBatchesForColumnOrRow(columnSubSegmentFillingResult);
                    if (validBatches.isEmpty()) {
                        return new PossibleNumberValidationResult(forPosition, number, false, null);
                    }
                    return new PossibleNumberValidationResult(forPosition, number, true, validBatches);
                }

                List<SegmentValidBatch> validBatches = getValidBatchesForColumnOrRow(columnSubSegmentFillingResult);
                if (validBatches.isEmpty()) {
                    return new PossibleNumberValidationResult(forPosition, number, false, null);
                }
                return new PossibleNumberValidationResult(forPosition, number, true, validBatches);
            }

            if (!rowSubSegmentFillingResult.variantsAreEmpty()) {
                List<SegmentValidBatch> validBatches = getValidBatchesForColumnOrRowAndSquare(columnSubSegmentFillingResult, squareSubSegmentFillingResult, positionsIntersectionsWithSquare);
                if (validBatches.isEmpty()) {
                    return new PossibleNumberValidationResult(forPosition, number, false, null);
                }
                return new PossibleNumberValidationResult(forPosition, number, true, validBatches);
            }

            List<SegmentValidBatch> validBatches = getValidBatchesForColumnOrRowAndSquare(columnSubSegmentFillingResult, squareSubSegmentFillingResult, positionsIntersectionsWithSquare);
            if (validBatches.isEmpty()) {
                return new PossibleNumberValidationResult(forPosition, number, false, null);
            }
            return new PossibleNumberValidationResult(forPosition, number, true, validBatches);
        }

        List<SegmentValidBatch> validBatches = getValidBatchesForColumnRowAndSquare(columnSubSegmentFillingResult, rowSubSegmentFillingResult,
                squareSubSegmentFillingResult,
                positionsIntersectionsWithSquare);

        if (validBatches.isEmpty()) {
            return new PossibleNumberValidationResult(forPosition, number, false, null);
        }

        return new PossibleNumberValidationResult(forPosition, number, true, validBatches);
    }

Для начала - о positionsIntersectionsWithSquare. Ни для кого не секрет, что квадрат отчасти имеет пересечения со строкой и столбцом у сегмента. Нам ни к чему проверять эти пересечения в рамках проверки квадрата и строить варианты с ними - зачем, когда это будет происходить в рамках проверки столбца и строки? Потому мы исключаем их из подсегмента квадрата и анализируем только те ячейки, которые пересекаться не будут. Однако при соотнесении строки, столбца и квадрата нам нужно будет эти пересечения учитывать. Потому запоминаем их на будущее.

Далее - получаем результат для столбца, строки, квадрата. Затем идет большущий блок, где мы будем соотносить варианты столбца, строки, квадрата друг с другом. Если, например, не заполнен квадрат - соотносим строку и столбец. Если не заполнена строка либо столбец - соотносим оставшееся с квадратом. Заполнено все - соотносим строку, столбец, квадрат. И так далее. Каждый раз логика будет немного отличаться. Все эти методы по соотнесению привожу ниже.

Разные варианты соотнесения
private List<SegmentValidBatch> getValidBatchesForColumnOrRow(SubSegmentFillingResult subSegmentFillingResult) {
        List<SegmentValidBatch> result = new ArrayList<>();

        Set<CellsVariant> subSegmentVariants = variantsToCells(subSegmentFillingResult);

        if (Objects.equals(subSegmentFillingResult.getType(), SubSegmentType.ROW)) {
            for (CellsVariant subSegmentVariant : subSegmentVariants) {
                result.add(new SegmentValidBatch(null, subSegmentVariant, null));
            }
        } else if (Objects.equals(subSegmentFillingResult.getType(), SubSegmentType.COLUMN)) {
            for (CellsVariant subSegmentVariant : subSegmentVariants) {
                result.add(new SegmentValidBatch(subSegmentVariant, null, null));
            }
        } else {
            throw new RuntimeException("Wrong type");
        }

        return result;
    }

    private List<SegmentValidBatch> getValidBatchesForColumnAndRow(SubSegmentFillingResult columnSubSegmentFillingResult,
                                                                   SubSegmentFillingResult rowSubSegmentFillingResult,
                                                                   Set<Position> positionsIntersectionsWithSquare) {
        List<SegmentValidBatch> result = new ArrayList<>();

        if (!Objects.equals(columnSubSegmentFillingResult.getType(), SubSegmentType.COLUMN) || !Objects.equals(rowSubSegmentFillingResult.getType(), SubSegmentType.ROW)) {
            throw new RuntimeException("Wrong type");
        }

        Set<CellsVariant> columnSubSegmentVariants = variantsToCells(columnSubSegmentFillingResult);
        Set<CellsVariant> rowSubSegmentVariants = variantsToCells(rowSubSegmentFillingResult);

        for (CellsVariant columnSubSegmentVariant : columnSubSegmentVariants) {
            Set<Integer> intersectionsWithSquareForColumn = columnSubSegmentVariant.getCells().stream().filter(cell -> positionsIntersectionsWithSquare.contains(cell.position())).map(Cell::number).collect(Collectors.toSet());

            for (CellsVariant rowSubSegmentVariant : rowSubSegmentVariants) {
                Set<Integer> intersectionsWithSquareForRow = rowSubSegmentVariant.getCells().stream().filter(cell -> positionsIntersectionsWithSquare.contains(cell.position())).map(Cell::number).collect(Collectors.toSet());
                intersectionsWithSquareForRow.retainAll(intersectionsWithSquareForColumn);

                if (!intersectionsWithSquareForRow.isEmpty()) {
                    continue;
                }

                result.add(new SegmentValidBatch(columnSubSegmentVariant, rowSubSegmentVariant, null));
            }
        }

        return result;
    }

    private List<SegmentValidBatch> getValidBatchesForColumnOrRowAndSquare(SubSegmentFillingResult subSegmentFillingResult,
                                                                           SubSegmentFillingResult squareSubSegmentFillingResult,
                                                                           Set<Position> positionsIntersectionsWithSquare) {
        // todo тут подумать еще раз, что если будет слепое пятно в виде строки/столбца, который заполнен и пересечения мы не учитываем с ним. не должны ли possibleNumbers для столбцов и строк быть еще и пересечением с квадраотом???
        List<SegmentValidBatch> result = new ArrayList<>();

        Set<CellsVariant> subSegmentVariants = variantsToCells(subSegmentFillingResult);
        Set<CellsVariant> squareSubSegmentVariants = variantsToCells(squareSubSegmentFillingResult);

        if (!Objects.equals(subSegmentFillingResult.getType(), SubSegmentType.COLUMN) && !Objects.equals(subSegmentFillingResult.getType(), SubSegmentType.ROW)) {
            throw new RuntimeException("Wrong type");
        }

        if (!Objects.equals(squareSubSegmentFillingResult.getType(), SubSegmentType.SQUARE)) {
            throw new RuntimeException("Wrong type");
        }

        boolean isRow = subSegmentFillingResult.getType() == SubSegmentType.ROW;

        for (CellsVariant subSegmentVariant : subSegmentVariants) {
            Set<Integer> intersectionsWithSquare = subSegmentVariant.getCells().stream().filter(cell -> positionsIntersectionsWithSquare.contains(cell.position())).map(Cell::number).collect(Collectors.toSet());

            for (CellsVariant squareSubSegmentVariant : squareSubSegmentVariants) {
                boolean hasDupes = squareSubSegmentVariant.getCells().stream().map(Cell::number).anyMatch(intersectionsWithSquare::contains);
                if (hasDupes) {
                    continue;
                }
                if (isRow) {
                    result.add(new SegmentValidBatch(null, squareSubSegmentVariant, squareSubSegmentVariant));
                    continue;
                }
                result.add(new SegmentValidBatch(squareSubSegmentVariant, null, squareSubSegmentVariant));
            }
        }

        return result;
    }

    private List<SegmentValidBatch> getValidBatchesForColumnRowAndSquare(SubSegmentFillingResult columnSubSegmentFillingResult,
                                                                         SubSegmentFillingResult rowSubSegmentFillingResult,
                                                                         SubSegmentFillingResult squareSubSegmentFillingResult,
                                                                         Set<Position> positionsIntersectionsWithSquare) {
        List<SegmentValidBatch> result = new ArrayList<>();

        Set<CellsVariant> columnSubSegmentVariants = variantsToCells(columnSubSegmentFillingResult);
        Set<CellsVariant> rowSubSegmentVariants = variantsToCells(rowSubSegmentFillingResult);
        Set<CellsVariant> squareSubSegmentVariants = variantsToCells(squareSubSegmentFillingResult);

        if (!Objects.equals(columnSubSegmentFillingResult.getType(), SubSegmentType.COLUMN) || !Objects.equals(rowSubSegmentFillingResult.getType(), SubSegmentType.ROW)) {
            throw new RuntimeException("Wrong type");
        }

        if (!Objects.equals(squareSubSegmentFillingResult.getType(), SubSegmentType.SQUARE)) {
            throw new RuntimeException("Wrong type");
        }

        for (CellsVariant columnSubSegmentVariant : columnSubSegmentVariants) {
            Set<Integer> intersectionsWithSquareByColumn = columnSubSegmentVariant.getCells().stream().filter(cell -> positionsIntersectionsWithSquare.contains(cell.position())).map(Cell::number).collect(Collectors.toSet());

            for (CellsVariant rowSubSegmentVariant : rowSubSegmentVariants) {
                Set<Integer> intersectionsWithSquareByRow = rowSubSegmentVariant.getCells().stream().filter(cell -> positionsIntersectionsWithSquare.contains(cell.position())).map(Cell::number).collect(Collectors.toSet());

                Set<Integer> intersectionsByRowAndColumn = new HashSet<>(intersectionsWithSquareByRow);
                intersectionsByRowAndColumn.retainAll(intersectionsWithSquareByColumn);

                if (!intersectionsByRowAndColumn.isEmpty()) {
                    continue;
                }

                for (CellsVariant squareSubSegmentVariant : squareSubSegmentVariants) {
                    Set<Integer> intersectedNumbersInRowColumnForSquare = new HashSet<>(intersectionsWithSquareByColumn);
                    intersectedNumbersInRowColumnForSquare.addAll(intersectionsWithSquareByRow);

                    boolean hasDupesForSquare = squareSubSegmentVariant.getCells().stream().map(Cell::number).anyMatch(intersectedNumbersInRowColumnForSquare::contains);
                    if (hasDupesForSquare) {
                        continue;
                    }

                    result.add(new SegmentValidBatch(columnSubSegmentVariant, rowSubSegmentVariant, squareSubSegmentVariant));
                }
            }
        }

        return result;
    }

    private Set<CellsVariant> variantsToCells(SubSegmentFillingResult fillingResult) {
        return fillingResult.getNumberSequenceVariant().getKeyValuePairs().stream().map(
                setOfPairs -> setOfPairs.stream().map(pair -> new Cell(pair.getKey(), pair.getValue())).collect(Collectors.toSet())
        ).map(r -> new CellsVariant(r, fillingResult.getType())).collect(Collectors.toSet());
    }

С possibleNumberValidation, похоже, разобрались!

Что имеем?

После всех манипуляций на выходе в PossibleNumbersValidationResult мы имеем информацию о том, применима ли конкретная цифра в конкретной "опорной" позиции - и все SegmentValidBatch под нее, т.е объединения валидной строки, столбца, квадрата, которые вместе образуют валидный сегмент (включая и цифру в "опорной" позиции теперь).

Как я и говорил: мы получили кучу больших и полноценных кусков паззла. Осталось только как-то быстро и безболезненно собрать их воедино.

Я проверял подбор на том же "Эвересте" от безумного финского математика Арто Инкала. Для каждой опорной позиции этих самых кусочков оказывалось впечатляюще много (собственно поэтому я и не привожу результаты работы программы). Всего, насколько я помню, их оказалось около 80 тысяч. Но что обнадеживает - для каждой позиции есть и единственно верный вариант заполнения среди кучи мусора. Значит, что-то отдаленно похожее на решение все же вырисовывается.

Конечно, я пытался соотносить все это дело. В тупую прямым перебором - такой вариант меня не очень привлекает, потому были какие-то небезынтересные, но бесполезные свистопляски с Index-ами и прочим, о чем я пока рассказывать не стану: в любом случае при сборке возможных вариантов прога зависает так, что у меня в какой-то момент лопается терпение и я заканчиваю мучения идеи.

Возможно, можно будет как-то совместить мои задумки по "индекам" и алгоритм X, скажем... но это мысли вслух.

В любом случае, промежуточный результат моих изысканий такой. Надеюсь, мне хватит энергии и фантазии их продолжить.

Буду рад, если у кого-нибудь появятся дельные идеи, как что-либо улучшить/обыграть. Адекватная критика, разумеется, тоже приветствуется.

Полный код проекта с недоделанным куском Index-а тоже прилагаю в публичном репе с неработающим пока тестом. Надеюсь, все нецензурные комментарии я оттуда поубирал...

Всем дзякую! Возможно, кому-то покажется интересным.

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


  1. Zenitchik
    30.01.2025 21:14

    Лень читать. Просто поделюсь своим.
    https://github.com/grunmouse/sudocu
    Доки нет. Почитайте код.
    То, что не может решить этот алгоритм, - я сам тоже без перебора решить не могу. Проверял на игре для телефона.

    Мне эта тема уже не интересна, отпускаю в свободное плаванье, если кому-то нужно.


    1. igorsmolkako Автор
      30.01.2025 21:14

      Ну, без перебора в любом случае не обойтись... вопрос только "что перебираем". У меня вырисовывается задумка перебирать крупные валидные варианты сразу, коих даже может оказаться не так уж и много.

      За ссылку спасибо, гляну, как будет время)


      1. Zenitchik
        30.01.2025 21:14

        После прохода всех эвристик, остаются варианты, считаемые по пальцам одной руки.

        По крайней мере, так было, когда я пробовал на игре.


        1. igorsmolkako Автор
          30.01.2025 21:14

          Ну это да. И финальный прогон по ним с проверкой даст искомое.