Этюд для программиста или головоломка крисс–кросс

в 10:36, , рубрики: java, Программирование, метки:

Этюд для программиста или головоломка крисс–кросс
Думаю многимам знакома книга «Этюды для программистов» Чарльза Уэзерелла. Если нет, то здесь можно прочитать интервью с автором и небольшой обзор книги. Мне самому совсем недавно попала данная вещь в руки, и было решено обязательно реализовать одну из задачек.

Итак предлагаю разобрать с вами один из этюдов. Писать будем на Java, поработаем с графикой и GUI + разберем алгоритм перебора с возвратом для нахождения нашего решения. Мало вероятно, что статья заинтересует профи, но вот новичкам, а особенно тем, кто только изучает Java, статья может оказаться полезной.
Всем заинтересовавшимся – добро пожаловать!

Постановка задачи

Тут приведу отрывок из «Этюдов», который введет читателя в курс дела.

Многие считают кроссворды слишком трудной головоломкой, потому что отгадать слово им не под силу. Но вписывать буквы в клетки нравится. Для подобных людей существует более простая головоломка — крисс-кросс. Каждый крисс-кросс состоит из списка слов, разбитых для удобства на группы в соответствии с длиной и упорядоченных по алфавиту внутри каждой группы, а также из схемы, в которую нужно вписать слова. Схема подчиняется тому же правилу, что и в кроссворде, — в местах пересечения слова имеют общую букву, однако номера отсутствуют, поскольку слова известны заранее, требуется лишь вписать их в нужные места.

Пример такой головоломки вы можете видеть на рисунке
Этюд для программиста или головоломка крисс–кросс
Мне, честно, не особо нравится вписывать буквы в клетки, поэтому наша программа будет просто выдавать уже готовую, заполненную схему + маленький бонус. Список слов будем читать из текстового файла. Если будет много желающих именно порешать такие головоломки, с радостью добавлю эту возможность.
Нус, от слов к делу – задача есть, желание есть, поехали!

Архитектура

Ну или Как будем все хранить и рисовать.
Вариантов у меня было много, на этой стадии я несколько раз переделывал все чуть ли не с нуля. Думаю найдутся те кто увидит решение по проще и изящнее, в любом случае здравая критика приветствуется.
Изначальная идея — создать класс «клетки с буквой» и класс контейнер для этих клеток «слово». Каждая «клетка с буквой» хранит в себе свой символ, плюс две логические координаты, умножая эти координаты на размер клетки (константа) без труда определяем место рисования клетки и символа. Основная сложность здесь для меня была в том, что все слова делятся на два типа: горизонтальные и вертикальные. Соответственно вся их обработка при таком подходе тоже будет иметь две версии. От этого хотелось избавиться.
Следующая мысль: у каждого слова одна координата общая для всех клеток у горизонтальных слов это Y, а у вертикальных X. Сделаем так: у нас будет «координата слова», постоянная величина для этого слова, и каждая клетка в свою очередь содержит лишь одну координату, разную у всех клеток в слове. «Фууууух, запутанно как-то», — скажете вы, но лучшего решения я не нашел, возможно в коде будет понятнее. Кстати, в конце статьи ищите сcылку на GitHub, можно будет посмотреть код проекта целиком.

Наша «клетка с буквой»

public class CharCell { 

    private char value;        
    private int variableCoord;    
    public final static int CELL_SIZE = 30;

    public CharCell( char value, int variableCoord ) {
        this.value = value;
        this.variableCoord = variableCoord;        
    }
    ...
}

Наше «слово»

В этом классе хранится массив CharCell — все буквы нашего слова, координата этого слова и поле int orientation, которое как можно догадаться может иметь два значения

public class Orientation {
    public final static int HORIZ = 0;
    public final static int VERTIC = 1;
 }

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

public class Word {
 
    private CharCell [] cells;
    private int orientation;
    private int wordCoord;
    private int length;
    
    public Word( String word, int orientation, int wordCoord, int initialVariableCoord ) {
        
        this.orientation = orientation;    
        this.wordCoord = wordCoord;
        this.length = word.length();
        cells = new CharCell[length];
        
        for ( int i = 0 ; i < length ; i++ ) {
            cells[i] = new CharCell( word.charAt(i), initialVariableCoord + i ) ;            
        }                
    }
    ...
}

Как видно код создания нового слова, совершенно не зависит от его «ориентации» в пространстве, также никаких различий в обработке не будет и в других, более сложных функциях – добавления, проверки и т.д. Но обо всем по порядку, рассмотрим пока, как собственно рисуются наши буквы.

Рисование

Код рисования лежит в классе CharCell и заключен в методе showCharCell(см ниже) именно здесь у нас и идет разделение на горизонтальные и вертикальные слова.
Чтобы начать рисовать, нужно определить класс, наследующий JComponent и переопределить метод paintComponent (). Таким классом в нашей программе является класс WordArea, о котором речь пойдет чуть позже. Именно из этого класса функция showCharCell принимает Graphics2D и другие параметры. Метод paintComponent () получает один параметр типа Graphics, в котором и содержатся все методы рисования и набор установок для изображения рисунков, фигур и текста. Каждый раз когда необходимо нарисовать окно выполняется метод paintComponent () всех его компонентов.

Собственно процедура рисования нашей «клетки с буквой»

public void showCharCell ( Graphics2D g2D, Font font, FontRenderContext context, int orient, int constCoord ) {
        int coordX;
        int coordY;
        if ( orient == Orientation.HORIZ ) {
            coordX = variableCoord * CELL_SIZE;
            coordY = constCoord * CELL_SIZE;
        }
        else {
            coordX = constCoord * CELL_SIZE;
            coordY = variableCoord * CELL_SIZE;
        }
        String s = String.valueOf(value);
        Rectangle2D bounds = font.getStringBounds( s, context );
        LineMetrics metrics = font.getLineMetrics( s, context );        
        float descent = metrics.getDescent();
        float leading = metrics.getLeading();
        Rectangle2D.Double rect = new Rectangle2D.Double( coordX, coordY, CELL_SIZE, CELL_SIZE );
        double x = rect.getX() + (rect.getWidth() - bounds.getWidth())/2;
        double y = rect.getY() + rect.getHeight() - descent - leading;
        g2D.draw( rect );        
        g2D.drawString( s, (int)x, (int)y );
    }

Метод getStringBounds()возвращает прямоугольник, ограничивающий строку, которую мы хотим нарисовать. Это нужно нам, чтобы наша буква находилась точно в середине нарисованного квадрата. Для выравнивания по ширине, сначала получаем ширину квадрата в который хотим вписать нашу букву, часть этой ширины занимает буква, следовательно размер незаполненного пространства с каждой стороны равен половине разности между шириной квадрата и шириной символа. Думаю из кода должно быть понятно. Для выравнивания по высоте вычитаем от нижней стороны нашего квадрата глубину посадки символов и интерлиньяж (descent и leading). Теперь все наши буквы аккуратно рисуются в своих клетках.
Метод класса Word, который нарисует любое слово, элементарный:

public void showWord( Graphics2D g2D, Font font, FontRenderContext context ) {         
        for ( int i = 0 ; i < length ; i++ ) {
            cells[i].showCharCell ( g2D, font, context, orientation, wordCoord );        
        }
    }

Пришло время коснуться самого сложного класса нашего этюда – WordArea
Как говорилось выше он является JComponent и содержит в себе немного модифицированный ArrayList<Word> mainWordArea. Это и есть наша схема слов, добавить в нее новое слово очень просто mainWordArea.add( new Word ( «Слово», Orientation.HORIZ, x, y ) ), именно так наша программа и добавляет слова в нашу схему пробуя все возможные варианты.
Ну а вот и paintComponent ()

@Override
     public void paintComponent ( Graphics g ) {
        Graphics2D g2D = ( Graphics2D ) g;        
        g2D.setRenderingHint( RenderingHints.KEY_ANTIALIASING,
                              RenderingHints.VALUE_ANTIALIAS_ON );    
        context = g2D.getFontRenderContext();        
        g2D.setFont( font );        
        Iterator<Word> wordAreaIter = mainWordArea.iterator();        
        while ( wordAreaIter.hasNext() ) 
            wordAreaIter.next().showWord( g2D, font, context );        
    } 

Просто перебираем все слова в mainWordArea, отрисовывая каждое из них.

Надеюсь у меня получилось донести до читателя логику работы программы, и мы можем идти дальше, тем более что все приготовления закончились и пришло время решать нашу головоломку!

Перебор с возвратом

Перебор с возвратом (Backtracking) — общий метод нахождения решений задачи, в которой требуется полный перебор всех возможных вариантов. Подробнее можно почитать тут. Я расскажу лишь саму идею касательно нашей программы.
Будет пытаться добавить новое слово из списка пока это возможно, как только в схему станет невозможно добавить новое слово, вернемся на шаг назад, удалив последнее добавленное слово и попробуем добавить другое. Такой алгоритм найдет нам все возможные варианты, казалось бы неплохо, но такая программа работает очень медленно, а для списка в 10 слов я так и не смог дождаться пока она выполнится.
Обратимся за помощью к нашей книге, Уэзерелл пишет:

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

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

wordsBackTracking()

/**
     *  Перебор всех слов с возвратом (Backtracking) 
     *  Составляет схему крисс-кросс
     */    
    private void wordsBackTracking ( ArrayWord<Word> wordArea, List<String> words ) {
                
        if ( accept( wordArea ) ) { 
            ArrayWord<Word> tempWordArea = new ArrayWord<Word>( wordArea );
            copyArrayWord( tempWordArea, wordArea );
            allWordArea.add( tempWordArea ); 
            mainWordArea = tempWordArea;
            return;
        }
        
        if ( reject( wordArea, words ) ) return;    
        
        for ( int i = 0 ; i < words.size() ; i++ ) {
            
            List<String> tempWords = new LinkedList<String>( words );
            String newWord = tempWords.get(i);
            tempWords.remove(i);            
            
            addNewWord( wordArea, tempWords, newWord );                        
        }                        
    }    
    /**
     * Добавляет новое слово, если это возможно
     */        
    private void addNewWord ( ArrayWord<Word> wordArea, List<String> words, String newWord ) {        
        Word existentWord;            
        for ( int k = 0 ; k < wordArea.size() ; k++ ) {
            existentWord = wordArea.get(k);
            ///сравниваем символы в новом и уже занесенном словах
            for ( int i = 0 ; i < existentWord.length() ; i++ ) {
                for ( int j = 0 ; j < newWord.length() ; j++ ) {
                    if ( existentWord.get(i).value() == newWord.charAt(j) ) {                                    
                        int newOrient = invert( existentWord.orientation() );
                        int newWordCoord = existentWord.get(i).coord();
                        int initialVariableCoord = existentWord.coord() - j;
                        Word word = new Word (newWord,newOrient,newWordCoord,initialVariableCoord);
                        ///если слово "проходит" проверку - добавляем его
                        int interCount = wordArea.intersectCount();
                        if ( check ( wordArea, word, existentWord.coord() ) ) {                    
                            wordArea.add ( word );
                            ///сохраняем смещение относительно (0,0) сохранив предыдущие
                            int minX = wordArea.minX();
                            int minY = wordArea.minY();                            
                            if ( existentWord.orientation() == Orientation.HORIZ )
                            wordArea.setMinY( initialVariableCoord );                        
                            else wordArea.setMinX( initialVariableCoord );    
                            ///запускаем косвенную рекурсию			                        
                            wordsBackTracking ( wordArea, words );    
                            ///убираем последнее добавленное слово
                            wordArea.remove( wordArea.size()-1 );
                            wordArea.reset();
                            wordArea.setMinX( minX );    
                            wordArea.setMinY( minY );
                            wordArea.setInterCount( interCount );                                                
                        }
                    }            
                }            
            } 
        }        
    }

wordsBackTracking принимает два аргумента: схему слов крисс-кросс(с одним словом, с несколькими или со всеми) и список еще не использованных слов. Если решение подходит, запоминаем его. Если решение не оптимально выходим из функции, сокращая дерево поиска.
Метод addNewWord() пытается добавить новое слово в схему, следя за тем, чтобы оно не нарушало правила составления кроссвордов, когда это получается, переходит на следующий уровень рекурсии снова вызывая wordsBackTracking(). Так эти две косвенно-рекурсивные функции находят вполне годные схемы. Слишком длинные списки слов, задавать не советую, но пример из книги находится на раз-два.
Этюд для программиста или головоломка крисс–кросс
Чуть не забыл про обещанный бонус, если его можно так назвать: как мог увидеть внимательный читатель все наши найденные решения сохраняются, и пощелкав мышкой по фрейму можно их просмотреть. Думаю будет интересно увидеть как программа находит все более изящные решения.
А вот еще пример из 16 слов:
Этюд для программиста или головоломка крисс–кросс

Спасибо за внимание!

Как и обещал ссылка на GitHub
Файл со словами words.txt
Собирать: javac CrissCrossFrame.java
Запускать: java CrissCrossFrame
На этом, уважаемый читатель, разрешите откланяться и еще раз спасибо за прочтение.

Автор: MichaelKA

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js