Думаю многим хабровчанам знакома книга «Этюды для программистов» Чарльза Уэзерелла. Если нет, то здесь можно прочитать интервью с автором и небольшой обзор книги. Мне самому совсем недавно попала данная вещь в руки, и было решено обязательно реализовать одну из задачек.
Итак предлагаю разобрать с вами один из этюдов. Писать будем на 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(). Все вспомогательные методы я здесь размещать не буду, желающие могут посмотреть в исходниках. Ну а главную связку представлю.
/** * Перебор всех слов с возвратом (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
На этом, уважаемый читатель, разрешите откланяться и еще раз спасибо за прочтение.
ссылка на оригинал статьи http://habrahabr.ru/post/166471/
Добавить комментарий