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