Текст
                    
№


I lilliutiietlt i
i inioiiEftieie
i i i is
11ШШ64Н
Мозговой M.B
llllillllltlftll
IBIIIIIIIIIIIII

к
10001010001
101000101
niHtiiiitieiiii
Ж»





1010000'
0001010'
I
ГС

10001001
ЮОООЮОО
01000001*
XX) ЮЮОО*
«1000101*
ЮОООЮОО
01000001*
000101000001000101000001000101ООО»
0010001010000010001010000010001010
0000010001010000010001010000010001
10100000100010100000100010100000'
............Э00101ОООГ"4""4'4 я....
Ю1000101*
ЮОООЮОО
01000001*
юоююоо*
Ю1000Ю1*
ЮЛПП1ПП0

4
$
моооюю
«0010001
>10000010
101010000
H0001010
Ю00Ю001
0100000100010100000100010100000100
*0010100000100010100000100010100000
Ю100010100000100010100000100010100
Ю0001ОО01010000010001010000010О01о
............910000010.............
юююооо
>10001010
Ю00Ю001
>10000010
юююооо
11П0П1П1П

10001010000011 Jbltl100000ir,r>0-^1
ACC
лп нетривиальных проектов,
О 0 решений и задач


Мозговой М.В. МАСТЕРПКЛАСС 85 нетривиальных проектов, решений и задач Наука и Техника Санкт-Петербург 2007
Мозговой М.В. C++ МАСТЕР-КЛАСС. 85 НЕТРИВИАЛЬНЫХ ПРОЕКТОВ, РЕШЕНИЙ И ЗАДАЧ. — СПб.: Наука и Техника, 2007. — 272 с. ISBN 5-94387-286-8 Серия «Секреты мастерства» Данная книга посвящена анализу интересных задач, встречающихся в повседневной практике программирования и требующих нетривиальных подходов в их решении. На основе относительно небольшого количества характерных реалистичных примеров иллюстрируется применение важных алгоритмов и методик программирования. Обозначены задачи, в которых могут использоваться те или иные подходы и решения. Книга написана в доступной форме блестящим программистом и великолепным популяризатором, автором таких книг, как «Классика программирования: алгоритмы, языки, автоматы, компиляторы. Практический подход» и «Занимательное программирование». Будет несомненно полезна всем, кто, обладая базовыми знаниями C++, хочет повысить свой уровень и культуру программирования. ISBN 5-94387-286-8 Контактные телефоны издательства: (812) 567 70 25, (812) 567 70 26, (044) 516 38 66 Официальный сайт: www.nit.com.ru © Мозговой М.В. © Наука и техника (оригинал-макет), 2007
СОДЕРЖАНИЕ ВВЕДЕНИЕ..........................................................15 ГЛАВА 1. СТРУКТУРЫ ДАННЫХ.........................................15 1.1. МАТЕМАТИКА НА СТЕКЕ..........................................16 Вычисление формул без грамматического разбора........16 1.2. РАЗРЕЖЕННЫЕ МАТРИЦЫ..........................................17 Программирование разреженных структур данных.........17 1.3. БИНАРНЫЕ ДЕРЕВЬЯ - ЭТО ТАКИЕ ДЕРЕВЬЯ.........................21 1.3.1. Игра «Животные», или развлечение с бинарными деревьями.21 1.3.2. Генератор формул...................................23 Автоматическое создание дерева математического выражения............................................23 1.3.3. Расчет сопротивления, или электрическая цепь как бинарное дерево...................................................28 ГЛАВА 2. РЕШЕНИЕ МАТЕМАТИЧЕСКИХ ЗАДАЧ.............................29 2.1. АЛГЕБРА И ГЕОМЕТРИЯ..........................................30 2.1.1. Интерполяция по Лагранжу, или восстановление недостающей информации...................................30 2.1.2. Помощник химика....................................31 Сведение химических уравнений к алгебраическим...... 31 2.1.3. «Проволочная» графика..............................34 Геометрические преобразования плоских фигур..........34 2.2. РАСЧЁТ ТРАЕКТОРИЙ............................................35
Содержание 2.2.1. Противотанковая оборона............................35 Расчёт упреждающего выстрела........................35 2.2.2. Столкновение.......................................38 Расчёт скоростей физических тел.....................38 2.2.3. Бильярд............................................40 Расчёт траекторий шаров после столкновения..........40 2.2.4. Баллистическая игра................................43 Расчёт траекторий снарядов..........................43 2.2.5. Лабиринт для лазера................................44 Угол падения равен углу отражения...................44 ГЛАВА 3. АЛГОРИТМЫ НА ГРАФАХ.....................................55 3.1. АНАЛИЗ ГРАФОВ...............................................56 3.1.1. Решение неразрешимой задачи........................56 Анализ проблемы останова............................56 3.1.2. Планировщик СУБД...................................66 Разрешение взаимных блокировок......................66 3.1.3. Сортировка сайтов..................................77 Поиск компонент связности графа.....................77 3.2. ВОЛНОВАЯ ТРАССИРОВКА........................................82 3.2.1. Волновая трассировка...............................82 Поиск маршрута в лабиринте..........................82 3.2.2. Цветные линии......................................85 Волновая трассировка в популярной игре..............85 3.2.3. Игра Square Head...................................86 Волновая трассировка плюс немного фантазии..........86 3.2.4. Закраска контура, или необычное применение волновой трассировки..............................................87 ГЛАВА 4. РЕКУРСИЯ И ПЕРЕБОР С ВОЗВРАТАМИ. ЭВРИСТИЧЕСКИЙ ПОИСК..............................................89 4.1. РЕКУРСИВНЫЕ ОБЪЕКТЫ. ФРАКТАЛЬНЫЕ УЗОРЫ......................90 Рисование рекурсивных объектов..................... 90 4
Содержание 4.2. ПРОСТОЙ ПОИСК В ИГРАХ И ГОЛОВОЛОМКАХ.........................98 4.2.1. Генератор кроссвордов............................ 98 Знакомство с перебором с возвратами..................98 4.2.2. Японские кроссворды...............................105 Оптимизированный перебор с возвратами...............105 4.2.3. Игра «Королевская балда»..........................106 Поиск наилучшего хода в игре........................106 4.2.4. Пентамино, или ещё одна задача на перебор с возвратами............................................. 108 4.2.5. Кубики сома - трёхмерный аналог полимино..........109 4.2.6. Головоломка с домино - несколько более сложный случай перебора...........................................111 4.2.7. Людоеды и миссионеры..............................111 Поиск решения известной головоломки..................111 4.2.8. Раскраска карт (последний пример перебора с возвратами)... 115 4.3. ЭВРИСТИЧЕСКИЙ ПОИСК.........................................115 4.3.1. Игра в 15, или эвристический поиска*...............115 4.3.2. Игры со сдвигающимися блоками......................, 125 ГЛАВА 5. ВИЗУАЛИЗАЦИЯ И АНИМАЦИЯ..................................127 5.1. ПЛАНЕТАРНАЯ СИСТЕМА. УНИВЕРСАЛЬНАЯ ДЕМОНСТРАЦИОННАЯ АСТРОНОМИЧЕСКАЯ МОДЕЛЬ............................................128 5.2. «ЧЕРЕПАШЬЯ» ГРАФИКА - НЕСТАНДАРТНАЯ МОДЕЛЬ РИСОВАНИЯ.................................................129 5.3. КОСМИЧЕСКАЯ ДУЭЛЬ, ИЛИ «ПРОВОЛОЧНАЯ» ГРАФИКА В ДЕЙСТВИИ.......................................................131 5.4. ЭВРИСТИЧЕСКИЙ ПОИСК И СОКОБАН...............................132 Непростая работа для А*.............................132 5.5. ВИЗУАЛИЗАЦИЯ ПРОСТОГО ТРЕХМЕРНОГО МИРА......................133 Трехмерный лабиринт.................................133 5.6. БУКВЫ И ЗВУКИ. ПРОСТОЙ МУЗЫКАЛЬНЫЙ РЕДАКТОР.................141 5.7. ГЕНЕАЛОГИЧЕСКОЕ ДРЕВО (ПРЕДСТАВЛЕНИЕ И ВИЗУАЛИЗАЦИЯ ДРЕВОВИДНЫХ ДАННЫХ)................141 5.8. СКРИНСЕЙВЕР - ДЕЛАЕМ ПРОСТУЮ, НО ЭФФЕКТНУЮ АНИМАЦИЮ..........142 5
Содержание ГЛАВА 6. ОБУЧАЮЩИЕСЯ ПРОГРАММЫ...................................143 6.1. КЛАССИФИКАЦИЯ И КЛАСТЕРИЗАЦИЯ..............................144 6.1.1. Классификация и кластеризация.....................144 Алгоритмы KNN и C-Means............................144 6.1.2. Поисковая система и рубрикатор....................152 Классификация и кластеризация текстовых документов.152 6.1.3. Определение авторства.............................155 Классификация произведений различных авторов.......155 6.1.4. Распознавание языка документа.....................161 Классификация документов на разных языках..........161 6.1.5. Дерево принятия решений...........................162 Разработка простой экспертной системы..............162 6.2. САМООБУЧАЮЩИЕСЯ ПРОГРАММЫ..................................167 6.2.1. Самообучающиеся крестики-нолики...................167 Компьютер учится на собственном опыте..............167 6.2.2. Самообучающаяся программа для игры в ним. Автоматический поиск выигрышной стратегии..............177 ГЛАВА 7. МОДЕЛИРОВАНИЕ ВЕРОЯТНОСТНЫХ ПРОЦЕССОВ...................179 7.1. РАНДОМИЗИРОВАННЫЕ АЛГОРИТМЫ................................180 7.1.1. Генерация заголовков............................. 180 Формирование предложений по шаблону................180 7.1.2. Генератор текста..................................181 Применение цепей Маркова...........................181 7.2. КОМПЬЮТЕРНЫЕ ЭКСПЕРИМЕНТЫ..................................185 7.2.1. Экспериментальное определение числа 7С. Использование метода Монте-Карло..........................185 7.2.2. Доска Гальтона. Моделирование распределения Гаусса.185 7.2.3. Автобусная остановка..............................187 Экспериментальная проверка гипотезы................187 7.2.4. Прокси-сервер.....................................189 Использование неравномерно распределённых случайных величин................................. 189 6
Содержание 7.2.5. Автострада........................................191 Моделирование автомобильных пробок..................192 7.2.6. Змейки-лесенки: экспериментальный анализ игры.....202 7.3. БИОЛОГИЧЕСКИЕ МОДЕЛИ.........................................203 7.3.1. Волчий остров. Классическая биологическая модель..203 7.3.2. Инфекция стригущего лишая.........................204 Ещё одна модель из биологии.........................204 ГЛАВА 8. ОПЕРАЦИИ С ТЕКСТОВЫМИ ДАННЫМИ............................209 8.1. В КАЧЕСТВЕ РАЗМИНКИ - ПОИСК АНАГРАММ.........................210 Ничего, кроме смекалки...............................210 8.2. ПРОВЕРКА ПРАВОПИСАНИЯ. ИСПОЛЬЗОВАНИЕ РАССТОЯНИЯ ЛЕВЕНШТЕЙНА..211 8.3. БАННЕРОРЕЗАЛКА, ИЛИ ПОИСК СТРОК ПО ШАБЛОНУ..................212 8.4. ТРАНСЛИТЕРАЦИЯ DLYATEH, КТО NE MOZHET PISAT* PO-RUSSKJ.......214 8.5. АББРЕВИАТОР, ИЛИ КАК ПРАВИЛЬНО ПРОИЗНЕСТИ «КД-ПЗУ»?..........215 8.6. ВЫРАВНИВАНИЕ ПО ШИРИНЕ. КРАСИВОЕ ФОРМАТИРОВАНИЕ ТЕКСТА.......216 8.7. РАССТАНОВКА ПЕРЕНОСОВ.......................................217 ГЛАВА 9. РАЗЛИЧНЫЕ АЛГОРИТМЫ.....................................219 9.1. СТРАТЕГИИ ДЛЯ ИГР...........................................221 9.1.1. Мастермайнд.......................................221 Разработка стратегии для классической игры..........221 9.1.2. Эндшпиль..........................................223 Формализация алгоритма матования....................223 9.1.3. Чемпион по Minesweeper............................228 Сложная стратегия для простой игры..................228 9.2. АНАЛИЗ И ОБРАБОТКА ИЗОБРАЖЕНИЙ..............................230 9.2.1. Обработка сканированного изображения..............230 Коррекция сканированной картинки: обрезка и поворот.230 9.2.2. Архиватор монохромных изображений.................231 Алгоритм RLE........................................231 7
Содержание 9.2.3. Жесты мыши.........................................232 Модный способ ввода команд...........................232 9.3. СТЕГАНОГРАФИЯ. ИЛИ МАСКИРОВКА НАЛИЧИЯ ПРИСУТСТВИЯ............239 9.3.1. Стеганография в тексте.............................239 Пересылка секретных сообщений........................239 9.3.2. Стеганография в изображениях - более надёжный способ передачи секретных сообщений...............................244 9.4. СПЕЦИАЛИЗИРОВАННЫЕ АЛГОРИТМЫ.................................245 9.4.1. Оптимальное вычисление.............................245 Принцип динамического программирования...............245 9.4.2. Календарь чемпионата...............................250 Составление расписания игр...........................250 9.4.3. Исследование структуры спроса......................253 ГЛАВА 10. АРХИТЕКТУРА ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ, ИЛИ О ЧЕМ ПОДУМАТЬ НА СОН ГРЯДУЩИЙ..................................257 10.1. ИГРЫ И ГОЛОВОЛОМКИ..........................................258 10.1.1. Классический puzzle - простая игра на составление изображения................................................258 10.1.2. Puzzle со сдвигами - составление изображения по необычным правилам.......................................259 10.1.3. «Шахматная головоломка» - подсчёт атакуемых полей.259 10.1.4. Ball Game - взаимодействие с объектами игрового мира............................................260 10.2. ХРАНЕНИЕ И ОБРАБОТКА ДАННЫХ.................................261 10.2.1. Логические схемы - инструмент для изучающих электронику................................................261 10.2.2. Футбольный органайзер - электронный справочник болельщика.................................................262 10.2.3. В помощь шахматисту - система для анализа шахматных партий...........................................262 10.3. АНАЛИЗ ТЕКСТОВОЙ ИНФОРМАЦИИ.................................263 10.3.1. Контекстная подсказка - разработка простой справочной системы.........................................263 8
Содержание 10.3.2. Алфавитный указатель. Автоматическое создание указателя........................................264 10.3.3. Спонсорские ссылки. Рекламный модуль для вашего сайта..........................................264 9

Введение ^ataHaus,^. ВВЕДЕНИЕ Как однажды заметил мой коллега, учиться можно как по теоретическим материалам, так и по примерам и листингам. Хороших учебников по про- граммированию написано уже достаточно много. А вот найти книгу, пос- вящённую интересным практическим примерам, уже сложнее. Можно упомянуть, в частности, «Жемчужины программирования» Джона Бентли, «Этюды для программистов» Чарльза Уэзерелла, «Практика программиро- вания» Брайана Кернигана и Роба Пайка. Ситуация, по правде говоря, не особо радует. Если, скажем, юристы и врачи посвящают достаточно много времени изучению случаев из практики, почему бы программистам не де- лать то же самое? Конечно, здесь надо сделать пару оговорок. Во-первых, практические при- меры встречаются во многих книгах, но, как правило, лишь в качестве по- яснений к теоретическому материалу. Во-вторых, сборники задач (иногда с решениями) тоже публикуются, но здесь уже вступают в игру принципы отбора примеров. Как правило, задачники предназначены либо для школ, и в этом случае состоят из типовых упражнений, либо для кружков, занимаю- щихся подготовкой к олимпиадам по программированию (соответственно, составлены такие сборники из задач олимпиадного характера). К сожале- нию, авторы далеко не всегда берут на себя труд завернуть задачу в какую- либо осмысленную обёртку, помогающую понять практическую пользу того или иного иллюстрируемого алгоритма. Иногда условия задания становят- ся совсем уж нереалистичными («двигаясь из комнаты в комнату, принц закрывал на ключ дверь, через которую он прошел, после он уже не мог вос- пользоваться этой дверью»), а идеи решений — слишком узкоспециализи- рованными. Я не хочу критиковать типовые и олимпиадные задачи: у них есть опре- делённая область применения, а с учётом реалий образовательной системы и условий проведения олимпиад предлагать задачи иного характера, вероят- но, просто невозможно. Однако заставлять программиста решать подобные задачи — это то же самое, что заставлять футболиста прыгать со скакалкой. С одной стороны, польза очевидна, а с другой — наверняка, можно отыскать и более подходящее занятие. На практике программисты занимаются реализацией проектов, то есть раз- работкой некоторого законченного программного обеспечения, обладаю- щего требуемой функциональностью. За этой угловатой формулировкой скрывается простая идея: программисты пишут не процедуры сортировки или алгоритмы, помогающие принцу сбежать из замка, а законченные про- граммы. Если угодно, software. Безусловно, любое программное обеспече- ние на уровне «кирпичиков» состоит из отдельных процедур, но если кто-то 11
г C++ мастер-класс. 85 нетривиальных проектов, решений и задач полагает, что соединить процедуры в одно целое нетрудно, он либо наивен, либо просто заблуждается. На мой взгляд, именно задача построения здания, а не изготовления кир- пичей является центральной для разработчика программного обеспечения. Сложность систем, а не отдельных алгоритмов заставляет создавать новые парадигмы программирования, такие как структурное программирование или ООП, заниматься архитектурой проекта, использовать методики «про- граммотехники» (software engineering). К сожалению, настоящие проекты почти всегда объёмны и для детального описания в книгах мало подходят. К счастью, иногда может хватить упро- щённой формулировки, ограниченного решения, а то и простой подсказки, намёка на верную дороту Некоторые задачи полезны уже своими формули- ровками, расширяющими кругозор читателя. / Книга, которую вы держите в руках, посвящена, в первую очередь, задачам «проектного» типа. Мне также хочется, чтобы решение иллюстрировало некоторый важный или интересный принцип (поиск методом А*, «чере- пашью» графику, метод Монте-Карло) и было полезным в повседневной практике программирования. Иногда можно и расслабиться, рассмотреть какую-нибудь малополезную, но интересную задачку. Впрочем, я старал- ся особо этим не увлекаться. Поскольку основная цель книги — научить чему-то новому, а не заставить решать задачи самостоятельно любой ценой, многие проекты снабжены подсказками и решениями. Разумеется, чтобы не раздувать код, везде приходилось предполагать корректность входных данных, наличие достаточного объёма свободной памяти и тому подобные иногда вступающие в противоречие с практикой вещи. У приводимых листингов тоже есть своя отличительная особенность: ис- пользуя язык C++, я старался не избегать его современных возможностей, таких как шаблоны и алгоритмы библиотеки STL. При описании решений алгоритмических задач авторы книг почему-то редко выходят за пределы программирования в стиле семидесятых годов (Pascal и С). Получается своего рода порочный круг: авторы намеренно упрощают листинги, чтобы их мог понять даже начинающий, а читатели затем пользуются программа- ми из книг в качестве эталонов хорошего стиля разработки. В итоге даже сейчас то и дело приходится сталкиваться с кодом, написанным в совершен- но устаревшем стиле и, соответственно, наделённым массой недостатков (ради искоренения которых и были разработаны современные технологии программирования). Поэтому решения — это не просто ответы на задачи, но и важная часть книги. Даже если вы сами можете справиться с задани- ем, загляните в решение. Если оно окажется лучше вашего, вы научитесь чему-то новому. Если хуже — порадуетесь своему успеху. Кстати, многие 12
Введение приведённые в книге алгоритмы довольно далеки от совершенства. Если можете поделиться лучшим подходом — напишите мне письмо, буду только рад возможности повысить качество решений в следующем издании книги. Адрес моей электронной почты — maxim_mozgovoy@hotbox.ru. Конечно, я не могу назвать себя автором всех задач сборника. Многие при- меры взяты из других книг или считаются классическими, авторство кото- рых вряд ли уже кто-либо сможет определить с достаточной степенью уве- ренности. По возможности я старался указывать источник, из которого та или иная задача взята. В заключение хочу поблагодарить Сергея Караковского, который не только сделал, несколько ценных замечаний по формулировке заданий, ио и помог мне вовремя подготовить решения (ему принадлежат программы «Авто- страда», «Лабиринт для лазера» и «Сортировка сайтов»), а также Марка Финкова, много сделавшего для повышения качества книги и по возмож- ности старавшегося ускорить её выход. Ему также принадлежит замеча- тельная фраза, в двух словах объясняющая идеологическую направлен- ность сборника: «Грубо говоря, изначально человек знает, что такое топор, и, в принципе, знает как им долбать. Мы же этому человеку показываем, какие вещи и как он может сделать». 13

Watattaus,^ ГЛАВА 1. СТРУКТУРЫ ДАННЫХ
В книге * Собор и базар» Эрик Рэймонд приводит цитату из Фредерика Брукса: “Если вы покажете мне код и скроете структуры данных, я ничего не пойму в вашей программе. Однако, если вы покажете мне структуры дан- ных, код скорее всего не понадобится. Он будет очевиден”1. Действительно, выбор структур данных во многом определяет вид программы. Представ- ление данных влияет на понимание задачи, на возможность использования того или иного алгоритма. Эта глава посвящена проектам, в которых структура данных играет обра- зующую роль. Вы увидите, как выбор представления данных может силь- но упростить реализацию необходимой функциональности («Математика на стеке»), оптимизировать алгоритм по скорости и требованиям к памяти («Разрешённые матрицы»), послужить удобной отправной точкой для раз- работки приложения («Расчёт сопротивления»). 1.1. МАТЕМАТИКА НА СТЕКЕ Вычисление формул без грамматического разбора Построение графика математической функции — задача важная и как уп- ражнение по программированию предлагалась во все времена и во всех учебных заведениях. Основная проблема заключается в способе задания функции. Если функцию жестко задать в коде, то практическая польза от такой про- граммы будет минимальной: для смены функции потребуется повторная компиляция. Если же разрешить пользователю вводить функцию с клавиа- туры, то придется писать процедуру грамматического разбора выражений. Когда речь не идет об одном из интерпретируемых языков, поддержива- ющих вычисление заданных в виде строк выражений, процедура разбора I Если быть точнее, в оригинале Брукс называет код «блок-схемами», а структуры данных — «таблица- ми», но сути дела это не меняет. 16
^alaHaus,»!. Глава 1. Структуры данных превосходит по сложности сам алгоритм построения графика и давать та- кую задачу в качестве учебного упражнения нецелесообразно. Разумеется, если целью является написание программного продукта, а не выполнение упражнения, скачать готовую библиотеку разбора выражений из интернета нетрудно. Возможный компромисс заключается в записи функций при помощи стеко- вых операций (табл. 1.1). Таблица 1.1. Примерный набор операций на стеке Операция Назначение DUP Копировать верхний объект стека (А -»АА) MUL Перемножить два верхних элемента стека (5 2 -> 10) SWAP Поменять местами два верхних элемента стека (АВ —> ВА) ADD Сложить два верхних элемента стека (5 2 -> 7) OVER Скопировать второй сверху объект стека на вершину (АВ -> ВАВ) SIN Заменить верхний объект стека на его синус (3.14 -> 0) COS Заменить верхний объект стека на его косинус (3.14-4 -1) На стек кладется значение аргумента функции, затем имитируется работа стековой машины для получения результата. Результат считывается с вер- шины стека. Так, если положить на стек некоторое значение X, а затем вы- полнить инструкции: DUP SIN SWAP COS ADD на вершине стека окажется значение sin(X) + со s(X). Таким образом, осталось написать программу, которая запрашивает у поль- зователя некоторую функцию одного аргумента (в виде последовательнос- ти стековых операций), а затем строит ее график на экране. 1.2. РАЗРЕЖЕННЫЕ МАТРИЦЫ Программирование разреженных структур данных Обычно матрица представляется в компьютере с помощью двумерного мас- сива, однако далеко не всегда такой подход будет самым экономным. На- пример, рабочий лист Excel состоит из 65536 строк и 256 столбцов; если 17
C++ мастер-класс. 85 нетривиальных проектов, решений и задач хранить его как двумерный массив, то понадобится выделить память для 16 ТП 216 элементов. На практике, разумеется, в памяти хранится содер- жимое лишь непустых ячеек листа. В математике тоже нередко встречаются матрицы, состоящие в основном из нулей. Они называются разреженными. Хранить разреженную матрицу в обычном двумерном массиве слишком дорого, поэтому элементы запи- сывают в какую-нибудь структуру данных вроде связного списка или ас- социативного массива. Каждому элементу сопоставляется пара чисел (i, j), обозначающая его местоположение в разреженной матрице. Для работы с разреженными матрицами потребуется небольшой набор функций. Необязательно определять новый тип данных «разреженная мат- рица» в стиле ООП, но у пользователя должна быть возможность: • создавать новую разреженную матрицу указанной размерности; • считывать и записывать отдельные элементы матриц (начальные значения каждого элемента новой матрицы равны нулю); • складывать и умножать разреженные матрицы. Решение Разобьем решение задачи на два этапа. На первом мы реализуем структуру данных, служащую для представления разреженной матрицы, а на втором разработаем алгоритмы, соответствующие операциям сложения и умноже- ния. С первой подзадачей легко справиться, если использовать класс шар из библиотеки C++. class SparseMatrix { private: int height, width; // количество строк и столбцов матрицы map<pair<int, int>, int> data; // каждый элемент матрицы //представлен своими коорди- //натами и значением public: SparseMatrix(int h, int w) : height(h), width(w) {} int Height() const { return height; } int Width() const { return width; } 11 записать элемент void SetElement(int row, int col, int element) { data[pair<int, int>(row, col)] = element; } 18
^ataHaus,^. Глава 1. Структуры данных // прочитать значение элемента int GetElement(int row, int col) const { map<pair<int, int>c int>::const_iterator p = data.find(pair<int, int>(row, col)); // если элемент (row, col) не существует, вернуть нуль // иначе — значение элемента return (р == data.endO) ? О : p->second; } SparseMatrix operator+(const SparseMatrix^ rhs) const; SparseMatrix operator*(const SparseMatrixb rhs) const; }; С операциями дело обстоит несколько сложнее. Теоретически можно за- программировать их точно таким же образом, как и в случае обычных, не разрежённых матриц. Однако эффективность такого решения крайне низ- ка. Представьте, что вы складываете две матрицы размером 100x100, каж- дая из которых содержит лишь десять ненулевых элементов. Классическое поэлементное сложение потребует 10 000 операций, хотя изначально по- нятно, что в результирующей матрице будет не более двадцати ненулевых элементов. Очевидное решение — рассматривать лишь ненулевые элементы каждой из матриц: SparseMatrix SparseMatrix::operator+(const SparseMatrix& rhs) const { // копируем левую матрицу в результат SparseMatrix result = *this; for(map<pair<int, int>, int>::const—iterator p = rhs.data.begin(); p != rhs.data.end(); p++) { // извлекаем координаты и значение очередного элемента //правой матрицы int row = p->first.first; int col = p->first.second; int value = p->second; // прибавляем значение к элементу результата //с теми же координатами result.SetElement(row, col, value + result.GetElement(row, col)); } return result; } 19
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Я не проверяю совпадение размеров матриц, входящих в сумму, лишь ради экономии места; в реальных же программах это необходимо. С умножением дело обстоит несколько сложнее. Как и для сложения, вне- шним циклом здесь будет перебор всех элементов одной из матриц, напри- мер, левой. Пусть очередной элемент левой матрицы расположен в позиции (i, к) и равен т: for(map<pair<int, int>, int>::const-iterator p = data.begin(); p !- data.endO; p++) { int i - p->first.first; int к - p->first.second; int m = p->second; } В соответствии с формулой произведения матриц элемент (i, j) матрицы-ре- зультата равен сумме произведений значения ш и значений элементов к-й строки правой матрицы. Таким образом, наша следующая задача - перебор всех элементов, находящихся в k-й строке правой матрицы. К сожалению, ассоциативный массив не позволяет быстро извлечь строку матрицы целиком, поэтому придется изучить все ее элементы по очереди2. Итоговая версия алгоритма умножения приведена ниже. SparseMatrix SparseMatrix: .’operator* (const SparseMatrix& rhs) const { // результирующая матрица содержит столько строк, сколько левая // и столько столбцов, сколько правая SparseMatrix result(Height(), rhs.Width()); // цикл по элементам левой матрицы for(map<pair<int, int>, int>::const.iterator p = data.begin(); p != data.endO; p++) { // значение элемента по адресу (i, к) равно m int i = p->first.first; int k = p->first.second; int m = p->second; // цикл по элементам правой матрицы for(map<pair<int, int>, int>::const-iterator rp = rhs.data.begin(); rp != rhs.data.end(); rp++) { // значение очередного элемента по адресу (b_k, j) равно n int b_k = rp->first.first; 2 Можно воспользоваться болев сложной структурой данных. Правда, обычно выигрыш по скорости достигается за счёт большего расхода памяти. 20
ftataHausiiik. Глава 1. Структуры данных int j - rp->first.second; int n - rp->second; // если строка k равна столбцу b_k, // увеличиваем сумму по адресу (i, j) if(k == b_k) result.SetElement(i, j, result.GetElement(i, j) + m*ri) ; } } return result; } Описанное решение, конечно, можно ещё улучшить. Вопросам операций над матрицами (в том числе и над разрежёнными) посвящено много работ. В частности, можно порекомендовать статьи: • J.R. Gilbert, С. Moler, and R. Schreiber. Sparse matrices in Matlab: design and implementation (http://www.mathworks.com/access/helpdesk_rl3/ help/pdfdoc/otherdocs/simax.pdf). • R. Yuster, U. Zwick, Fast sparse matrix multiplication (http://www.cs.tau. ac.il/%7Ezwick/papers/sparse.pdf). 1.3. БИНАРНЫЕ ДЕРЕВЬЯ - ЭТО ТАКИЕ ДЕРЕВЬЯ... 1.3.1. Игра «Животные», или развлечение с бинарными деревьями Этот проект был впервые опубликован Г. Щегловым в журнале «Паука и жизнь» (№ 12,1986г.). Игра состоит в том, что компьютер пытается отгадать животное, задуманное человеком, задавая вопросы, на которые человек мо- жет отвечать «да» или «нет». Компьютер начинает игру, располагая единственным вопросом, выводящим на единственное животное: “оно мяукает?” - “кошка”. Эти данные можно представить в виде бинарного дерева (рис. 1.1). Рис. 1.1. Исходное бинарное дерево для игры «Животные» 21
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Компьютер задает вопросы, начиная с корня дерева и переходя на каждом шаге направо или налево в зависимости от ответа пользователя. По дости- жении терминального узла компьютер либо выдаёт догадку («кошка»), либо пишет «не знаю», если текущий узел не содержит информации о жи- вотном. Далее, если компьютер не сумел угадать животное, возможны два варианта. В первом случае пользователь наткнулся на узел «не знаю». Для начального дерева такая ситуация возникает после диалога: Компьютер: оно мяукает? Игрок: нет Компьютер: не знаю При возникновении такой ситуации предлагается ввести определение для нового животного: Компьютер: кто это? Игрок: собака Компьютер: как отличить это животное? Игрок: оно лает После ответа пользователя дерево животных приобретает вид, изображён- ный на рис. 1.2. Рис. 1.2. Преобразование дерева животных (первый случай) Во втором случае компьютер приходит к некоторому решению, но оно не удовлетворяет пользователя: Компьютер: оно мяукает? Игрок: да Компьютер: это кошка? Игрок: нет Компьютер: не знаю 22
^alattaus,^. Глава 1. Структуры данных В подобной ситуации придётся запросить задуманное животное и его отли- чие от догадки компьютера: Компьютер: кто это? Игрок: тигр Компьютер: чем оно отличается от животного "кошка"? Игрок: оно крупнее кошки В таком случае первоначальное дерево придётся преобразовать иным спо- собом (рис. 1.3). Рис. 1.3. Преобразование дерева животных (второй Случай) Так компьютер запоминает новых животных. Теперь можно приступить к реализации игры. Необходимо предусмотреть функции загрузки и сохране- ния построенных деревьев. 1.3.2. ГЕНЕРАТОР ФОРМУЛ Автоматическое создание дерева математического выражения В школак и в вузах очень любят типовые задачи. Задание у всех одно и то же, только какая-нибудь математическая функция разная. Например, у од- ного ученика f(x) = sin(x) + 5, а у другого - f(x) = х2 + 10 / х. Выдумывать функции для всего класса учителю приходится самому. Почему бы не пору- чить эту работу компьютеру? Итак, требуется написать программу, случайным образом генерирующую некоторую математическую функцию. Переменные, знаки операций (все операции считаются бинарными) и названия стандартных унарных функ- ций вводятся с клавиатуры. Константы перечисляются в том же списке, что и переменные. Длина генерируемой формулы, выраженная в количестве операций, выбирается пользователем. 23
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Поскольку о приоритете операций не делается никаких предположений, скобки в выражениях не опускаются. Пример ввода: переменные; х у 5 операции: + - * Л функции: sin cos количество операций: 3 Пример вывода: f = cos(sin(y Л cos(x -у)) * 5) Решение Любой человек, имеющий представление о задаче разбора математическо- го выражения, сразу же скажет, что выражение представляет собой древо- видную структуру, нетерминальными узлами которой являются операции, а терминальными — переменные и константы. Любому узлу может быть также сопоставлена унарная функция, такая как sin или cos (рис. 1.4). Об- ратите внимание: в наших обозначениях унарный минус также является функцией. Так, чтобы иметь возможность получить функцию вида f(x) = -х, придётся указать минус в списке унарных функций. Рис. 14. Дерево функции f = cos(sin(y a cos(x - у)) * 5) Таким образом, задача генерирования математического выражения сводит- ся к задаче генерирования случайного бинарного дерева. Можно предложить следующий простой алгоритм генерации функций дли- ной N операций: 24
^lalallaus^ Глава 1. Структуры данных создать дерево из одного терминального узла ЦИКЛ N раз выбрать случайный терминальный узел,. сделать его нетерминальным, добавив два терминальных узла-потомка КОНЕЦ ЦИКЛА приписать каждому .узлу случайно выбранную унарную функцию (или ничего) щнщмеать каждому терминальному узлу случайную переменную или константу приписать каждому нетерминальному узлу случайно выбранную операцию Займёмся теперь его реализацией. Поскольку дерево состоит из узлов, иам потребуется новый тип данных «узел»: struct TreeNode ( bool isTerminal; // является ли узел терминальным string Function; // унарная функция string Value, Operation; // содержимое узла: переменная либо // операция TreeNode *Left, *Right; // указатели на узлы-потомки TreeNode() : isTerminal(true) (} // узел создается как // терминальный }; Поскольку нам потребуется обходить все узлы дерева, удобнее будет де- ржать их в отдельной структуре данных - векторе Tree, а не писать рекур- сивные функции обхода. Пригодятся также ссылки на существующие тер- минальные узлы: vectorcTreeNode *> Tree; vector<TreeNode *> TerminaiNodes; Ещё три глобальных вектора предназначены для хранения переменных, операций и функций: vector<string> Variables, Operations, Functions; Эти векторы мы будем заполнять, вводя с клавиатуры строки, разделённые пробелами. Для их чтения служит функция Readvalues (): vector<string> Readvalues() { vector<string> result; string line, value; getlinefcin, line); // считать с клавиатуры строку 25
C++ мастер-класс. 85 нетривиальных проектов, решений и задач istringstream is(line); while(is » value) //в цикле считать каждое значение result.push_back(value); return result; } Последняя служебная функция предназначена для получения строкового представления дерева (то есть готовой записи сгенерированной математи- ческой функции): string PrintTree(TreeNode *it) // строковое представление дерева //с корнем it { // терминальный узел выводится без изменений // если задана унарная функция, добавляется имя функции //и круглые скобки if(it->isTenninal) return it->Function == ““ 2 it->Value : it->Function + •(" + it->Value + *')•'; // для нетерминального узла результат состоит из: // - унарной функции (если она есть); // - открывающей круглой скобки; // - строкового представления левого поддерева; // - знака операции; // - строкового представления правого поддерева; // - закрывающей круглой скобки return it->Function + •(* + PrintTree(it->Left) + " “ + it->Operation + " " + PrintTree(it->Right) + “)"; } Основная работа выполняется в функции main (), прямо следующей при- ведённому выше псевдокоду: int main(int argc, char* argv[]) { int N; // количество операций Variables = Readvalues(); // прочитать переменные Operations = Readvalues(); // прочитать операции Functions = ReadValues(); // прочитать функции // прочитать количество операций cin » N; // добавить в список функций ещё столько же «пустых» функций, // чтобы случайно выбирать «пустую» функцию с вероятностью 1/2 fill_n(back_inserter(Functions), Functions.size(), *•); // добавить в дерево первый узел и поместить его в список // терминальных Tree.push_back(new TreeNode()); 26
Глава 1. Структуры данных TerminalNodes.push_back(Tree.back()); randomizep; for(int i = 0; i < N; i++) { // выбрать случайный терминальный узел int г = random(TerminalNodes.size()); TreeNode *it = TerminalNodes[r]; // исключить его из списка терминальных TerminalNodes.erase(TerminalNodes.begin() + r); it->isTerminal - false; // добавить левый и правый узлы-потомки Tree.push_back{new TreeNode()); TerminalNodes.push_back(Tree.back{)); it->Left = Tree.backO; Tree.push_back(new TreeNode()); TerminalNodes.push_back(Tree.backО ); it->Right = Tree.backO; } // дерево сгенерировано. Осталось назначить узлам // переменные, операции и функции for (unsigned i = 0; i < Tree.sizeO; i++) { TreeNode *p = Tree[ i ] ; // выбрать функцию p->Function = Functions[random(Functions.size())] ; // для терминального узла выбрать переменную, // для нетерминального — операцию if(p->isTerminal) p->Value = Variables[random(Variables.size())]; else p->Operation = Operations[random(Operations.size())]; } // напечатать готовое выражение cout « 'f = “ « PrintTree(Tree[0]) « endl; for(unsigned i = 0; i < Tree.sizeO; i++) // очистить память delete Treefi]; return 0;
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 1.3.3. Расчет сопротивления, или электрическая цепь КАК БИНАРНОЕ ДЕРЕВО Коль скоро речь зашла о школьных типовых задачах, рассмотрим ещё одну — вычисление суммарного сопротивления электрической цепи. Конфигурация цепи, состоящей из резисторов, соединённых проводника- ми, задана в виде дерева (рис. 1.5). Дерево вводится в компьютер с помощью специально разработанного редактора, по возможности простого. Требуется вывести на экран получившуюся цепь и определить её суммарное сопротив- ление (для этого придётся написать рекурсивную функцию, вычисляющую сопротивление любого заданного поддерева). R3 R2 Рис 1.5. Представление электрической цепи в виде бинарного дерева Напомню, сопротивление участка цеци, состоящего из последовательно соединённых резисторов R1 и R2, равно Rl + R2. Сопротивление участка цепи, состоящего из параллельно соединённых резисторов R1 и R2, равно Rl * R2 / (Rl + R2). 28
^ataHaus,^. ГЛАВА 2. РЕШЕНИЕ МАТЕМАТИЧЕСКИХ ЗАДАЧ
Решению математических задач с помощью компьютера всегда уделялось особое внимание. Даже термин «математическое обеспечение ЭВМ» в со- ответствии с Большим энциклопедическим словарём используется в самом широком смысле: «Комплекс программ, описаний и инструкций, обеспечи- вающих автоматическое функционирование ЭВМ. Различают общее мате- матическое обеспечение (для организации вычислительного процесса на данной ЭВМ) и специальное математическое обеспечение (для решения конкретных задач)». В наши дни акценты сильно сместились, и большинс- тво пользователей вряд ли отнесут DVD-проигрыватель или графический редактор к «математическому» обеспечению. Однако задачи, пришедшие в программирование из математики, остаются. Причём отдельные подзадачи очень часто возникают при разработке, ка- залось бы, никак не связанных с математикой программ — например, при создании компьютерного бильярда или черчении «проволочных» объектов виртуального мира. Некоторыми подобными задачами мы сейчас и займёмся. 2.1. АЛГЕБРА И ГЕОМЕТРИЯ 2.1.1. Интерполяция по Лагранжу, или восстановление НЕДОСТАЮЩЕЙ ИНФОРМАЦИИ Пусть после года наблюдений за окружающей средой у вас осталась таб- лица из двенадцати чисел, соответствующих средней температуре воздуха на улице за каждый месяц. Теперь нужно соединить все двенадцать точек плавной линией, чтобы получился красивый график, который можно рас- печатать на принтере. Такого рода плавная кривая (называемая интерпо- лирующей) может быть построена автоматически. Существуют различные методики решения этой задачи. Рассмотрим одну из них: интерполяцию многочленом Лагранжа. 30
^ataHaus,^. гдеД.(х) Глава 2. Решение математических задач Теория гласит, что для любого множества точек на плоскости (хр у,), (х2, у2),..., (хп, уп) можно построить многочлен Рп(х), график которого прохо- дит через каждую точку множества. Для вычисления Рп(х) служит формула: Р„(х) = + А(*).У1 +••• + Ln(x)yn, (х-х13)(х-хх)...(х-х1_х)(х-хм)...(х-х„ (x,-x0)(xi-xx)...(xx-xi_x)(xi-xM)...(xi-xJ Поясню структуру этой формулы. Многочлен состоит из суммы п элемен- тов вида L(x)y.. Здесь у; — это известная ордината i-й точки множества, а элемент L(x) вычисляется по второй формуле. В числителе дроби L(x) находится произведение всех разностей вида (х - хк) для к = 1,2,..., п, за исключением элемента (х - х.). В знаменате- ле находится произведение всех разностей вида (х. - хк) для к = 1,2,.... п, за исключением элемента (х; - х(), равного нулю. Обратите внимание, что знаменатели всех дробей L.(x) не зависят от х, и их достаточно вычислить единственный раз. Компьютерная программа, выполняющая интерполяцию произвольного набора точек многочленом Лагранжа, может работать следующим образом. Пользователь вводит число п и список координат точек. Программа отме- чает точки на экране небольшими кружочками яркого цвета (чтобы их было заметно), а затем выводит график интерполяционного многочлена. Для тех, кто до Сих пор не был знаком с интерполяцией по Лагранжу, замечу заранее, что форма получившейся кривой может вас изрядно удивить. Пос- кольку алгоритм интерполяции не учитывает природы процесса (в данном случае процесса сезонного изменения температуры), график многочлена может показывать физически необоснованные скачки. Так, если средняя температура в июне была 25 градусов, а в июле — 30, график между этими точками теоретически может подскочить до 60 градусов или упасть до нуля. Гарантируется лишь, что все точки будут соединены плавной линией. Некоторые другие способы интерполяции (например, сплайновая) свобод- ны от этого недостатка. 2.1.2. Помощник химика Сведение химических уравнений к алгебраическим Обычная задача для школьного урока химии - уравнивание реакций, то есть нахождение таких коэффициентов в уравнении химической реакции, 31
C++ мастер-класс. 85 нетривиальных проектов, решений и задач чтобы для каждого элемента количество его атомов в реагентах равнялось количеству атомов в продуктах реакции. Было: KOH + H2SO4->K2SO4 + H2O Стало: 2КОН + H,SO, - K,SO, + 2Н,О Школьные алгоритмы нахождения коэффициентов носят, так сказать, эв- ристический характер. Им недостаёт системности. Тем не менее определить искомые коэффициенты можно автоматически (и отнюдь не только перебором). Сравнительно нетрудно написать программу, уравнивающую химические реакции. Поскольку разбор записи реакции не является основной целью задачи, можно считать, что формулы даны в наиболее простой для анализа форме. Достаточно удобны, к примеру, следующие соглашения. Каждое вещество записывается на отдельной строке. После названия очередного химическо- го элемента через пробел записывается его индекс (если индекса нет, запи- сывается единица). Пустая строка отделяет левую часть уравнения реакции от правой. Тогда реакция взаимодействия гидроксида калия с серной кислотой, при- ведённая выше, будет выглядеть так: К 1 О 1 Н 1 Н 2 S 1 О 4 К 2 S 1 О 4 Н 2 О 1 В качестве ответа следует вывести набор коэффициентов, соответствующих веществам в итоговой записи реакции. В рассматриваемом случае програм- ма должна напечатать строку: 2 112 Подсказка У химических уравнений много общего с уравнениями алгебраическими. Подумайте, где здесь неизвестные и какие алгебраические уравнения могут помочь их найти. Решение Эта задача сводится к решению системы линейных уравнений. Рассмотрим пример, приведённый в формулировке задания: кон + h2so4 -> k2so4 + н2о 32
Natattausl^!. Глава 2. Решение математических задач Поскольку нам требуется определить коэффициенты при четырех вещест- вах, итоговую формулу можно записать следующим образом: А*КОН + B*H2SO4 = C*K2SO, + d*h2o, где А, В, С и D - переменные, значения которых необходимо определить. Для их определения служат уравнения баланса, Количество которых равно количеству элементов, участвующих в реакции. Баланс элемента отражает тот факт, что число атомов этого элемента в левой и правой частях уравне- ния реакции должно совпадать: К: А = С*2 О: А + в*4 = С*4 + D Н: А + В*2 = D*2 S: В = С Получилось четыре уравнения с четырьмя неизвестными. В стандартной форме их можно записать следующим образом: 1*А + 0*В + (-2)*С + 0*D =' О 1*А + 4*В + (-4)*С + (-1)*D = О 1*А + 2*В + 0*С + (-2)*D = О 0*А + 0*В + (-1)*С + 0*D = О Поскольку решение системы линейных уравнений — задача хорошо извест- ная и многократно описанная, я не буду на ней останавливаться (например, можно воспользоваться методом Гаусса). К сожалению, сама природа химической реакции приводит к бесконечно- му множеству решений. Ведь для уравнения реакции важны не абсолютные количества веществ, а соотношения между ними: так, в нашем случае для того, чтобы реакция состоялась, требуется в два раза больше гидроксида ка- лия, чем серной кислоты. Никаких указаний о конкретном количестве реак- тивов схема реакции не содержит. Преодолеть это затруднение можно, например, зафиксировав один из коэф- фициентов. Пусть А = 1. Тогда из системы уравнений 1 = С*2 1 + В*4 = С*4 + D 1 + В*2 = D*2 В = С следует решение: В = 1/2, С = 1/2, D = 1. Умножая коэффициенты на на- именьшее общее кратное знаменателей полученных дробей (в данном слу- чае 2), получаем итоговый результат: А = 2, B=1,C = 1,D = 2. Общую схему решения предлагаю читателю вывести самостоятельно. 2 Заж.772 33
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 2.1.3. «Проволочная» графика Геометрические преобразования плоских фигур Школьную алгебру и химию мы вспомнили, перейдем теперь к геометрии. Точнее, к планиметрии. Требуется написать простейший редактор вектор- ной графики. Объекты, обрабатываемые программой, представляют собой плоские гео- метрические фигуры, задаваемые множеством составляющих их отрезков. Отрезки считываются из входного файла. Необходимо реализовать следующие функции: • вывод объекта на экран; • перенос объекта на вектор (Тх, Ту); • поворот объекта на угол а относительно заданной точки (X, Y); • сжатие/растяжение объекта относительно центра (X, Y) с коэффи- циентами (Sx, Sy). Пользователь с клавиатуры указывает выполняемое преобразование (перенос, поворот, растяжение/сжатие) и вводит параметры преобразова- ния. Компьютер выполняет операцию и рисует на экране получившуюся фигуру. Обратите внимание: эти функции — упрощённые (двумерные) аналоги опе- раций над геометрическими объектами, без которых не обходится ни одна современная трёхмерная игра1. Вы сможете найти им хорошее применение при решении задачи «Космическая дуэль» из пятой главы. Решение Здесь я приведу формулы, с помощью которых вы сможете реализовать все требуемые функции преобразования объекта. Перенос объекта на вектор (Тх, Ту) — самая простая операция. Требуется лишь прибавить значения Тх и Ту к соответствующим координатам точек объекта: ДЛЛ КАХ,.',-Л т . Хр' - Хр + Тх . .. Yp'.-Yp+'fr КЭН£Ц ЦЙКЛД . ’ 1 Конечно, в настоящее время все базовые графические функции реализуются на уровне аппа- ратуры видеокарты или стандартных библиотек (таких как OpenGL или DirectX). 34
^alattaus^k Глава 2. Решение математических задач Чтобы повернуть объект относительно центра (X, Y), необходимо сначала перенести в точку (X, Y) начало координат, то есть перенести все поворачи- ваемые точки на вектор (-Х, -Y), что уже рассмотрено. Затем выполняется собственно поворот по формуле: ДЛЯ КАЖДОЙ точки объекта Хр' = Xp*cos(a) - Yp*sin(a) Yp' = Xp*sin(a) + Yp*cos(cO '• z >> V '// " КОНЕЦ ЦИКЛА ' . ‘r, /. \ ' г Готовая фигура переносится назад на вектор (X, Y). При сжатии и растяжении тоже придётся сначала совместить центр (X, Y) с началом координат, затем выполнить преобразование и вернуть фигуру на место. Само преобразование сводится к умножению соответствующих координат на коэффициенты Sx и Sy: ДЛЯ КАЖДОЙ точки объекта • . Т ь'” л" ’• Хр' = Xp*Sx " ' . ' Yp' * Yp*Sy \ КОНЕЦ ЦИКЛА " ’ ' • - ' 2.2. РАСЧЁТ ТРАЕКТОРИЙ 2.2.1. Противотанковая оборона Расчёт упреждающего выстрела После алгебры, химии и геометрии давайте займемся кинематикой - разде- лом механики, посвященным движению тел вне связи с причинами, вызы- вающими это движение. Компьютерное моделирование движения тел инте- ресно не только как практикум, но и как составная часть программирования компьютерных игр. Из начальной точки с координатами (XT, YT) равномерно и прямолинейно с известной скоростью Va по экрану движется вражеский танк. Танк при- ближается к нашему охраняемому объекту (направление движения может задаваться, например, углом отклонения от оси абсцисс) явно с не самыми мирными намерениями. В точке (Хг, Yr) находится противотанковая пушка, способная мгновенно выстрелить в любом направлении. Пушечный снаряд летит со скоростью Vr. О соотношении скоростей танка и снаряда не делается никаких предполо- жений. Не исключено, что танк движется даже быстрее снаряда (такое воз- 35
C++ мастер-класс. 85 нетривиальных проектов, решений и задач можно, например, если снаряд представляет собой сравнительно медленное самоходное устройство с динамитным зарядом на борту). Задача заключается в моделировании системы автоматического управле- ния противотанковой пушкой. Все численные параметры задаются с клави- атуры либо назначаются случайным образом. Па экране появляется движущийся танк и пока что бездействующая пушка. Па красивой спрайтовой анимации не настаиваю, сгодятся и простые раз- ноцветные круги. При нажатии клавиши «пробел» пушка мгновенно про- изводит выстрел с тем расчётом, чтобы он поразил танк. Вывод анимации продолжается до момента попадания снаряда в танк. Понятно, что суть задачи заключается в определении правильного направ- ления выстрела. Поскольку скорость снаряда не бесконечно велика, прихо- дится стрелять на упреждение (в предположении что танк не изменит ско- рости и направления своего движения). Если существует несколько вариантов решения задачи, требуется выбрать направление пушки, приводящее к скорейшему поражению цели. Если на- оборот, попасть в танк нельзя (например, если танк движется по направле- нию от пушки, а скорость снаряда меньше скорости танка), следует сооб- щить об этом пользователю. Решение Поскольку математическое решение задачи является наиболее трудной час- тью (программирование в данном случае — дело техники), я остановлюсь лишь на выводе необходимых формул. Предположим, что поражаемая цель (танк) находится в точке (Ха, Ya) экра- на и движется со скоростью Va, разложенной на составляющие VXa (вдоль оси абсцисс) и VYa/вдоль оси ординат). Пушка находится в точке (Хг, Yr). Скорость снаряда равна Vr (соответс- твующие составляющие равны VXr и VYr). Наша задача состоит в поиске значений VXr и VYr. Для начала заметим, что поскольку при равномерном прямолинейном движении время в пути равно расстоянию, делённому на скорость, ракета должна поразить цель через t единиц времени после запуска: t = (Yr - Ya)/(VYa - VYr) = (Xr - Ха)I(VXa - VXr) Формула справедлива как для горизонтальной, так и для вертикальной со- ставляющей скорости. Расстояние вычисляется как разность координат, а скорость сближения равна разности скоростей танка и ракеты. 36
^ataHaus,^. Глава 2. Решение математических задач Из соотношения (Yr - Ya)/(VYa - VYr) = (Xr - Xa)/(VXa - VXr) следует уравнение (Yr - Ya)*(VXa - VXr) = (Xr - Xa)*(VYa - VYr) Раскрывая скобки и группируя подобные члены, получаем выражение VXr*(Ya - Yr) + (Yr*VXa - Ya*VXa) = VYr*(Xa - Xr) + Xr*VYa - Xa*VYa Если ввести обозначения a = Xa - Xr b = Xr*VYa - Xa*VYa c = Ya - Yr d = Yr*VXa - Ya*VXa, то выражение переписывается в гораздо более простом виде: c*VXr + d = a*VYr + b Отсюда VYr = (c*VXr + d - b)/a По теореме Пифагора вертикальная и горизонтальная составляющие ско- рости ракеты связаны со значением Vr соотношением Vr2 = VXr2 + VYr2 Подставляя в него значение VYr, получаем Vr2 = VXr2 + (c*VXr + d - b)2 / а2 Раскрыв скобки, приходим к обычному квадратному уравнению: (1 + c2/a2)*VXr2 + (2*c*(d - b)/a2)*VXr + ((d - b)2/a2 - Vr2) = 0 В более удобных обозначениях К = 1 + с2/а2 L = 2*c*(d - b)/a2 М = (d - b)2/a2 - Vr2 уравнение превращается в K*VXr2 + L*VXr + М = О Далее решения находятся по известной формуле irv -L + jL2-4KM VXr —------------------- 2 К Математически любому положительному значению подкоренного выра- жения соответствуют два решения квадратного уравнения. Физический 37
C++ мастер-класс. 85 нетривиальных проектов, решений и задач смысл, однако, имеют лишь те решения, для которых t > 0 (то есть пораже- ние цели происходит в будущем, а не в настоящем или в прошлом). 2.2.2. Столкновение Расчёт скоростей физических тел Эта модель призвана проиллюстрировать процесс упругого столкновения двух шаров. Два шара разной массы с разными скоростями равномерно и прямолинейно движутся навстречу друг другу. В конце концов происходит соударение, и шары разлетаются. Столкновение считается упругим, то есть потери энер- гии в момент удара отсутствуют. В этой задаче предполагается, что траектории обоих шаров лежат на одной прямой (рис. 2.1). Рис. 2.1. Центральное столкновение двух шаров Края экрана ограничены «стенками»: достигнув края экрана, шар отражает- ся от «стенки» и продолжает движение в противоположную сторону. Поте- ри энергии при движении также считаются нулевыми, поэтому моделиро- вание прекращается лишь по требованию пользователя. Теперь можно запрограммировать описанную модель и вывести анимацию. Входными данными являются массы и скорости шаров. Подсказка Рассчитать скорости шаров после соударения вам помогут законы сохране- ния импульса и энергии. Решение Реализация модели довольно проста, поэтому не будем на ней останавли- ваться. А вот над формулами придётся немного потрудиться. 38
^alaHaus,^. Глава 2. Решение математических задач Пусть массы шаров равны ml и m2, а скорости — vl и v2. Если знаки ско- ростей равны, шары летят в одну и ту же сторону, иначе — в противопо- ложные. Наша задача — определить скорости шаров vl’ и v2’ после столк- новения. Очевидно, что движение шаров подчиняется законам сохранения импульса и энергии. По закону сохранения импульса выполняется равенство: m1v1 + m2v2 = + m2v2' А по закону сохранения энергии: та^^/2 + m2v22/2 = та1Уг1,г/2 + m2v2'2/2 Вынеся массы за скобки, эти два равенства можно переписать следующим образом: mi(v1 - V/) = m2(v2' - V2) (2.5.1) mi(vi2 - v/2) = m2(v,'2 - v22) (2.5.2) Во втором равенстве можно разложить разность квадратов на множители: mJvi “ v/ ) <vi + v/) = m2(v2'2 - v?) (v/ + v,) (2.5.3) Разделив равенство (2.5.3) на (2.5.1), получаем: Vx + vj = V2' + v„ Отсюда V/ = V1 + V/- V2 (2.5.4) Подставляя полученную формулу для v2’ в равенство (2.5.1), получаем: mi(Vl “ V/)= m2<Vl + Vl' “ V2 “ V2^’ откуда v/ = (V1(m1 - m2) + 2m2v2)/(m2 + mJ (2.5.5) Вот и всё. Вычисляя выражение (2.5.5), можно получить итоговую скорость первого шара. Подставляя её в формулу (2.5.4), находим итоговую скорость второго шара. 39
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 2.2.3. Бильярд Расчёт траекторий шаров после столкновения Требуется реализовать игру в бильярд по правилам программы Video Pool (рис. 2.2). В начале игры на столе располагается «пирамида» из шести шаров и один бьющий шар. Игроку даётся пять попыток для того, чтобы загнать один из шаров пирамиды в любую лузу После этого количество попыток снова возрастает до пяти, а игрок получает бонус в размере НомерУровня х Но- мерШара х ЦенаЛузы очков (игра начинается с первого уровня). Управ- ление ударом сводится к заданию скорости и направления бьющего шара; реализовывать дополнительные возможности вроде «подкрутки» шара не требуется. Если игрок случайно загнал в лузу бьющий шар, либо не попал ни по од- ному нумерованному шару, либо израсходовал безрезультатно все пять по- пыток, у него отнимается одна «жизнь», и ход переходит к оппоненту. Каж- дому участнику в начале игры даётся всего три «жизни». При переходе на следующий уровень игрок премируется дополнительной «жизнью». Участ- ник, исчерпавший все «жизни», выбывает из игры. Как только все шесть нумерованных шаров оказываются в лузах, игра пе- реходит на следующий уровень, на котором игрокам даётся уже не по пять, а всего по четыре попытки. 40
Глава 2. Решение математических задач Игра заканчивается после прохождения всех пяти уровней (на пятом уров- не у каждого из игроков есть лишь одна попытка для того, чтобы провести шар в лузу), либо после исчерпания лимита «жизней» всеми игроками. По- бедителем объявляется игрок, набравший большее количество очков. Подсказка Основная сложность при написании бильярда — расчёт скоростей и траек- торий шаров после соударения. Для центрального столкновения эта про- блема уже была решена (см. п. 2.2.2 «Столкновение»), здесь же ситуация и проще, и сложнее одновременно. Проще потому, что массы шаров теперь одинаковы, сложнее потому, что кроме результирующих скоростей придёт- ся определять и результирующие траектории. Сразу замечу ещё одно отличие. В модели «Столкновение» потери энергии при движении считались нулевыми, поэтому моделирование прекращалось лишь по требованию пользователя. В бильярде движение каждого шара рано или поздно должно прекратиться. Мне кажется разумным просто ум- ножать на очередной итерации моделирования скорость каждого из шаров на какую-либо константу меньше единицы. Как только скорость становится совсем небольшой, шар считается остановившимся. Займёмся теперь расчётом траекторий. Предположим, произошло стол- кновение (рис. 2.3). Пусть в момент удара первый шар находится в точке (Хр Y,) и движется под углом А, к оси абсцисс. Скорость первого шара рав- на Vr Соответственно, второй шар располагается в точке (Х2, У2),чи траекто- рия его движения задана углом А2. Скорость второго шара равна V2. Рис 2.3. Схема столкновения бильярдных шаров
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Первым делом необходимо определить угол наклона а прямой, соединяю- щей центры шаров (обозначим её тп): double alpha = atan2(Y2 - Yl, X2 - Xl); Теперь повернём систему координат таким образом, чтобы ось абсцисс ста- ла параллельна прямой т, а ось ординат — параллельна перпендикуляру к т. Для этого нужно всего лишь вычесть а из углов, определяющих направ- ление движения шаров: Al -= alpha; А2 -= alpha; Скорость каждого шара раскладывается на две составляющие: радиальную (скорость движения вдоль прямой т) и тангенциальную (скорость движе- ния вдоль перпендикуляра к т). При ударе тангенциальная составляющая скорости каждого шара не меняется, а для радиальной работают законы сохранения энергии и импульса, в точности как в задаче < Столкновение». Если подставить в формулы задачи «Столкновение» одно и то же значение массы для обоих шаров, станет видно, что при соударении просто происхо- дит обмен скоростями. В нашем случае это означает обмен радиальными составляющими скоростей. Поскольку оси координат направлены вдоль прямой т и перпендикуляра к т, составляющие скоростей определяются просто как проекции на оси: double VX1 = Vl*cos(Al), VY1 = Vl*sin(Alb- double VX2 = V2*cos(A2), VY2 = V2*sin(A2); Теперь можно поменять местами радиальные составляющие скоростей: std::swap(VX1, VX2); Поскольку одна из составляющих скорости каждого шара изменилась, не- обходимо пересчитать значения V1 и V2: VI = sqrt(VXl*VXl + VY1*VY1); V2 = sqrt(VX2*VX2 + VY2*VY2); Новые значения углов А, и A2 можно определить с помощью арктангенсов (так же, как мы вычисляли угол а). Нужно только помнить о том, что шар может вовсе не двигаться. В этом случае обе составляющие скорости равны нулю, и функция atan2() завершится аварийно: double ALMOST_ZERO = 0.0001; // почти нуль Al = VI < ALMOST_ZERO ? 0 : atan2(VYl, VX1); А2 = V2 < ALMOST_ZERO ? 0 : atan2(VY2, VX2); 42
ftalaHausfik Глава 2. Решение математических задач Остаётся лишь повернуть оси координат в обратном направлении: Al += alpha; А2 += alpha; На этом Вся математика заканчивается, можно садиться и писать бильярд. 2.2.4. Баллистическая игра Расчёт траекторий снарядов В случайных (но достаточно удалённых друг от друга) точках случайным образом сгенерированной холмистой поверхности расположены две пуш- ки, принадлежащие двум игрокам. Игроки ходят по очереди. С помощью курсорных клавиш игрок определяет угол наклона ствола и силу выстрела. При нажатии клавиши Enter происходит выстрел. Снаряд летит по парабо- ле, определяемой углом и силой. Если снаряд сталкивается с поверхностью, происходит взрыв, и вся «земля» в радиусе R от места столкновения заме- няется пустым пространством. Цель каждого игрока — поразить противника, то есть сделать так, чтобы противник оказался не далее чем в R пикселях от места взрыва, после чего игра заканчивается. Если не ошибаюсь, первой игрой этого жанра была Scorched Earth. Подсказка Для генерации поверхности можно соединить плавной линией несколько точек на экране, выбранных случайным образом (см. задачу 2.1). Чтобы организовать движение снаряда по параболе, можно воспользовать- ся следующим методом. Пусть изначально снаряд находится в точке (X, У) и летит по экрану с горизонтальной скоростью VX и вертикальной скоро- стью VY. Введём новый параметр — g (ускорение свободного падения). Если скорость измеряется в пикселях за итерацию моделирования, то разумное значение для g может варьироваться примерно от 0.5 до 3. Теперь движение снаряда описывается псевдокодом: ЦИКЛ ' .• : . '; // обработать текущую ситуацию • '. . ‘ X --- X + VX Y = Ч.. +. VY VY = VY + д . КОНЕЦ ЦИКЛА -• ' ,Г" ? :4' 43
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Здесь предполагается, что сопротивление среды снаряду несущественно (поэтому горизонтальная составляющая скорости не меняется). 2.2.5. Лабиринт для лазера Угол ПАДЕНИЯ РАВЕН УГЛУ ОТРАЖЕНИЯ Вот еще одна задача на моделирование траекторий, но уже не физического тела, а луча. Пусть в точке (Хс, Ус) экрана находится лазерная пушка. Её направление (отклонение от оси абсцисс) задано углом а. Кроме того, задано множество отрезков, символизирующих плоские зеркала, каждое из которых отражает с обеих сторон. Каждый отрезок описывается точкой (Хк, Ук) его центра, длиной Ц. и углом ак поворота относительно оси абсцисс. Требуется отобразить на экране путь, проделанный лазером. Луч отражает- ся от зеркал согласно правилу «угол падения равен углу отражения». Объединив решение «Лабиринта для лазера» с формулами для задачи «Бильярд», можно разработать игру в бильярд на столе в форме произвольно- го многоугольника с различными (также многоугольными) препятствиями. Но можно также развить идею и написать компьютерную игру в стиле Deflektor (рис. 2.4). Рис. 2.4. Игра Deflektor PC 44
^lalatlaus^i Глава 2. Решение математических задач Смысл игры в том, чтобы, управляя зеркалами (то есть вращая их), разбить сначала лазером все серые шары, а затем установить «контакт» с приёмни- ком. На скриншоте приёмник находится прямо под лазером; его передняя сторона закрыта кирпичами, пока остаются неразбитые шары. Решение По большей части эта задача, конечно же, математическая, однако одной только математикой здесь не обойтись. Сначала рассмотрим общую идею решения, затем разберём каждый требующий того элемент по отдельности. Первая проблема связана с окончанием работы алгоритма. Дело в том, что лдзер может вообще не покинуть пределов экрана (вариант с выходом луча за экран как раз прост для анализа), бесконечно отражаясь от образующих замкнутый многоугольник зеркал. Выйти из затруднения можно двумя способами. Во-первых, вполне разум- но ограничить анализируемое число отражений. Если спустя N отражений лазер всё ещё не покинул пределы экрана, выполнение алгоритма следует прекратить. Во-вторых (и мы воспользуемся этим способом) можно запрог- раммировать пошаговый алгоритм. На главной форме приложения распола- гаются две кнопки. Первая отвечает за инициализацию программы, вторая вызывает процедуру черчения очередного отражения луча. Таким образом, пользователь сам сможет определять количество отображенных на экране отражений. Поскольку инициализация и поиск очередного отражения — процедуры не- зависимые, можно уже сейчас начать разработку программы. На главной форме F_Main располагаются две кнопки B_Initialize (ини- циализация) и B_Step (вывод очередного отражения луча), а также поле M lnitialize типа ТМето. В нём будет храниться информация о конфи- гурации лазера и зеркал. Например, шесть строк 20 700 60 440 310 135 50 450 520 15 150 240 320 35 150 540 120 35 150 340 120 35 150 указывают, что лазер находится в точке (20, 700) экрана и наклонён под углом в 60°. Следующие строки задают параметры зеркал (Х^, Уце|пра, угол_наклона, длина). Определим типы данных TLaser и TMirror, описывающие лазерный луч и зеркало соответственно: 45
C++ мастер-класс. 85 нетривиальных проектов, решений и задач typedef long double Idouble; struct TLaser { Idouble x, y; // отрезок задается выражением вида у = k*x + b Idouble angle, k; Idouble b; }; struct TMirror { Idouble x, y; Idouble angle, k; Idouble b; Idouble y_above, y_below; bool prev_iter_reflected; // крайние ординаты зеркала // отражало ли это зеркало // на предыдущей итерации int pos; // номер отразившего зеркала int length; // длина зеркала }; vector <TMirror> mirrors; // массив зеркал TLaser laser; // лазер Функция-обработчик события OnClick кнопки B_Initialize заполняет объекты laser и mirrors данными, считанными из поля M_Initialize: void___fastcall TF_Main::B_InitializeClick(TObject *Sender) { // очистка экрана // (рисование будет осуществляться прямо по канве формы) F_Main->Canvas->FillRect(Rect(0, 0, Width, Height)); // считывание в поток нулевой строки из ТМешо; istringstream is(H_Initialize->Lines->Strings[0].c_str()); 11 инициализация лазера is » laser.x; is » laser.y; is » laser.angle; // ось ординат направлена вниз laser.angle = (-1)*laser.angle*M_PI/180.; // составляем уравнение прямой лазера laser.k = tan(laser.angle); laser.b = laser.у - laser.k*laser.x; 46
NalaHaus'iiik Глава 2. Решение математических задач II заполняем массив зеркал и выводим зеркала синим цветом F_Main->Canvas->Pen->Color = clBlue; mirrors.clear(); TMirror mirr; for (int i = 1; i < M_Initialize->Lines->Count; i++) { istringstream is(M_Initialize->Lines->Strings[i].c_str()); is » mirr.x; is » mirr.у; is » mirr.angle; // ось ординат направлена вниз mirr.angle = -mirr.angle*M_PI/180.; is » mirr.length; mirr.k = tan(mirr.angle); mirr.b = mirr.у - mirr.k*mirr.x; // рисуем зеркала F_Main->Canvas->MoveTo(mirr.x - mirr.length*cos(mirr.angle), mirr.y_below = mirr.у - mirr.length*sin(mirr.angle)); F_Main->Canvas->LineTo(mirr.x + mirr.length*cos(mirr.angle), mirr.y_above = mirr.у + mirr.length*sin(mirr.angle)); mirr.prev_iter_reflected = false; mirrors.push_back(mirr); } // графически обозначим позицию и направление движения // луча лазера F_Main->Canvas->Pen->Color = clRed; F_Main->Canvas->Ellipse(laser.х - 4, laser.у - 4, laser.x + 4, laser.у + 4); F_Main->Canvas->Pen->Style = psDot; F_Main->Canvas->MoveTo(laser.x, laser.у); F_Main->Canvas->LineTo(laser.x + 50*cos(laser.angle), laser.у + 50*sin(laser.angle)); F_Main->Canvas->Pen->Style = psSolid; } Обработчик события OnClick кнопки B_Step выводит очередной отрезок лазерного луча. И зеркало, и луч представляют собой участки прямых, заданных линейны- ми уравнениями Y = K1X + B1hY = К2Х + В2. Эти прямые либо пересека- ются, и абсцисса точки пересечения равна (Bt - В2)/(К, - К2), либо парал- лельны (Kt = К2). Существование точки пересечения прямых ещё не гарантирует встречи луча с зеркалом, потому что эта точка может лежать за пределами зеркала. 47
C++ мастер-класс. 85 нетривиальных проектов, решений и задач К тому же при решении системы уравнений не учитывается направление лазерного луча. Простую проверку на попадание лазера в зеркало можно организовать, сравнивая ординату найденной точки пересечения прямых с ординатами крайних точек зеркала (рис. 2.5). Рис. 2.5. Проверка на принадлежность зеркалу точки пересечения прямых Функция GetlntersectionPoint(int indx, TMirror& mirror) проверяет, попадает ли лазер в зеркало mirrors [ indx], записывает точку пересечения в поля х и у переданного объекта mirror и возвращает true в случае успеха: bool GetlntersectionPoint(int indx, TMirror& mirror) { mirror.x = (laser.b - mirrors[indx].b)/(mirrors[indx].k - laser.k); mirror.у = mirror.x*laser.k + laser^b; return (mirror.у <= mirrors[indx].y_below && mirror.у >= mirrors[indx].y_above); } Эта Проверка не учитывает, во-первых, направление лазера (так, вызов GetlntersectionPoint () для зеркала, расположенного за лазерной пушкой, вернёт true), а во-вторых, что лазер изменит направление, встре- тившись с первым же зеркалом на своём пути. Поиск ближайшего зеркала — задача простая. Определив набор точек пе- ресечения, можно просто выбрать точку с ближайшей к лазеру абсциссой (этот критерий достаточен,-поскольку ордината точки пропорциональна её абсциссе — луч лазера ведь лежит на прямой линии). С направлением же дело обстоит несколько сложнее, но здесь можно не- много схитрить. Важность учёта направления луча иллюстрирует рис. 2.6. Если просто вы- брать ближайшее к точке последнего отражения лазера зеркало, луч «про- жжёт» только что отразившее его зеркало насквозь и устремится вверх. 48
‘NalaHausllik Глава 2; Решение математических задач точек соприкосновения Теперь — обещанная хитрость (там, где кончается наука — математика, на- чинается искусство — программирование). Всё, что надо сделать — это про- должить луч на небольшой отрезок и выяснить, приближается луч к зерка- лу или удаляется от него. Если зеркало выбрано правильно, луч, конечно же, будет двигаться в его сторону. Функция isDirectionCorrect (const TMirror& m) возвращает true, если зеркало m расположено по ходу луча: bool isDirectionCorrect(const TMirror& m) { return (sqrt( pow((laser.x + cos(laser.angle) - m.x), 2) + pow((laser.y + sin(laser.angle) - m.y), 2)) < sqrt( pow((laser.x - m.x), 2) + pow((laser.у - m.y), 2) )); } Здесь луч продолжается на отрезок единичной длины (можно продолжить и на меньшую длину, если зеркала расположены очень близко друг к другу). Затем расстояния между зеркалом и каждой из двух точек лазера (исход- ный «конец» луча, продолженный «конец» луча), вычисленные по формуле R - sqrt((X1 - Х2)2 + (У, - У2)2), сравниваются между собой. 49
C++ мастер-класс. 85 нетривиальных проектов, решений и задач После того, как стало ясно, от какого зеркала отражать, выясним теперь куда, собственно, отражать. Как уже говорилось, угол отражения луча света в однородной среде равен углу падения. Для горизонтального зеркала фор- мула очевидна: если угол падения равен а, то угол отражения вычисляется по формуле: а’ - 2л - а. Для угла mirror_angle в диапазоне [0, л/2] угол очередного отрезка луча равен: laser_angle = 2*M_PI - laser_angle + 2*mirror_angle А для угла в диапазоне [л/2, л]: laser_angle = -laser_angle - 2*(M_PI - mirror_angle) Поскольку все зеркала в нашей системе покрыты отражающим слоем с обе- их сторон, рассматривать углы наклона в диапазонах [л, Зл/2] и [Зл/2,2л] не требуется. Таким образом, функция отражения луча будет выглядеть так: void Reflect(ldouble& laser_angle, Idouble mirror_angle) { // строки /*!*/, /*2*/, /*3*/ нужны для того, чтобы математически / / полученные формулы не изменили своего вида из-заразнонаправленности // математической и экранной осей ординат /*1*/ laser_angle = -laser_angle; /*2*/ mirror_angle = -mirror_angle; if (mirror_angle >= 0 && mirror_angle <= M_PI_2) { laser_angle = (2*H_PI - laser_angle + 2*mirror_angle); } else if (mirror_angle >= M_PI_2 && mirror_angle <= M_PI) { laser_angle = (-laser_angle - 2*(M_PI - mirror_angle)); } else ( Application->MessageBoxA('He соблюдён диапазон", 'Неверное значение', МВ_ОК); return; } /*3*/ laser_angle = -laser_angle; } Теперь займёмся основной функцией — вычерчиванием очередного отрезка луча: void___fastcall TF_Main::B_StepClick(TObject *Sender) ( // зеркала, уравнения прямых которых имеют общие точки // с прямой лазера vector <TMirror> vm; 50
Глава 2. Решение математических задач ^ataHaus,^. TMirror m; int i ; static int rem_index; // номер зеркала, отразившего предыдущий // отрезок bool allow - false; // разрешение на отражение луча // собираем зеркала, которые луч пересекает for (i = 0; i < mirrors.size О; i++) { // если зеркалу можно отражать и уравнения имеют // общую точку if('mirrors Ii],prev_iter_refIccted && GctlntersectionPoint (i, m)) { m.pos = i; // запомнить позицию зеркала в // глобальном массиве vm.push_back(m); // поместить зеркало во временный // массив F_Main->Canvas->Ellipse(m.x - 4, m.y - 4, m.x + 4, m.y + 4); } } if (vm.sizeO > 0) // хотя бы одно зеркало пересе- // кается прямой луча ( //на следующей итерации это зеркало уже будет доступно mirrors[rem_index].prev_iter_reflected = false; //не забываем сбросить значение rem_index Idouble delta, d; for (delta = fabs(vm[rem_index = i = 0].x - laser.x); i < vm.sizeO; i + + ) { // ищем ближайшее удовлетворяющее направлению // движения луча if ( (d-= fabs(vm[i].x - laser.х)) <= delta && CorrectDirection(vm[i])) { delta = d; rem_index = i; // запоминаем его номер во временном // массиве allow = true;/ } } if (allow) { F_Main->Canvas->Pen->Color - clRed; F_Main->Canvas->MoveTo(laser.x, laser.у); 51
C++ мастер-класс. 85 нетривиальных проектов, решений и задач // рисуем отрезок луча и назначаем новую отправную // точку F_Main->Canvas->LineTo(laser.х = vm[rem_index].х, laser.у = vm[rem_index].у); // отражаем луч лазера от найденного зеркала и меняем // значение индекса зеркала, от которого был отражён // лазер Reflect (laser. ancpLe, mirrors[rem_index = vm[rem_index].pos].angle); // исключим возможность проверки пересечения луча с // текущим отражающим зеркалом на следующей итерации mirrors[rem_index].prev_iter_reflected = true; // обновляем уравнение прямой луча laser.k = tan(laser.angle); laser.b = laser.у - laser.k*laser.x; // пунктиром обозначим отражённый луч F_Main->Canvas->Pen->Style = psDot; F_Main->Canvas->LineTo(laser.x + 50*cos(laser.angle), laser.у + 50*sin(laser.angle)); F_Main->Canvas->Pen->Style = psSolid; } else Application->MessageBdxA("Луч дальше не пойдёт", "Ни одно зеркало не может отразить луч", 0); } else Application->MessageBoxA("Зеркал нет или они параллельны ходу луча", "Ни одно зеркало не может отразить луч", 0); } Скриншот работающей программы показан на рис. 2.7. Стоит отметить, что здесь нужно очень внимательно обходиться с типами. Если вы присмотритесь к окружностям, обозначающим общие точки урав- нений прямых лазера и зеркал, то увидите, что центры этих окружностей не лежат точно на одной прямой, а с небольшим смещением. Мало того, если вы измените все переменные типа long double на переменные типа double, вы заметите, что после девятой итерации луч лазера пойдёт не так, как раньше! По этому поводу с нескрываемым удовольствием приведу цитату из книги Герба Саттера2: «Вычисления с плавающей точкой сложны, трудны и почти всегда — неочевидны. Я бы сказал — с известной долей самомнения — что все программисты делятся на три группы: те, кто знают, что они не понима- 2 Herb Sutter. Exceptional C++ Style. Pearson Education, Inc., 2004 (Герб Саттер. Новые сложные задачи на C++. Вильямс, 2005). 52
^aluilaus^k Глава 2. Решение математических задач Рис. 2.7. Приложение «Лабиринт для лазера» ют вычисления с плавающей точкой (и они правы); те, кто думают, что они понимают их (но они ошибаются); и те немногие эксперты, которые совсем не уверены в том, что когда-либо полностью сумеют разобраться в вычисле- ниях с плавающей точкой (и это мудро)». 11а этом задание считается выполненным.

ГЛАВА 3. АЛГОРИТМЫ НА ГРАФАХ
Раздел, посвящённый графам, можно встретить практически в любом учеб- нике по компьютерной науке. Это неудивительно: графы используются при решении самых разных задач от планирования маршрута по карте города до разработки разумной стратегии при игре в шахматы. При этом, к сожа- лению, основные принципы иллюстрируются одними и теми же классичес- кими алгоритмами: Прима-Краскала (построение телефонной сети), Дей- кстры (поиск оптимального маршрута), обход графа в глубину / в ширину и так далее. Безусловно, знакомство с ними абсолютно необходимо, однако многие интересные задачи при таком подходе остаются за рамками книг. В этой главе мы рассмотрим интересные практические применения не- которых алгоритмов из теории графов. Например, задача «Планировщик СУБД» иллюстрирует поиск циклов, а «Сортировка сайтов» — выявление компонент связности. Большое внимание уделено также алгоритму волно- вой трассировки — очень интересному частному случаю алгоритма Дейкс- тры, используемому при поиске маршрута в лабиринте, а также при заливке замкнутого контура. 3.1. АНАЛИЗ ГРАФОВ 3.1.1. Решение неразрешимой задачи Анализ проблемы останова Заранее прошу прощения за слишком длинную формулировку, но задача, как мне кажется, того стоит. В теории вычислений доказывается, что так называемая задача останова не может быть решена никаким алгоритмом, реализованным на обычном ком- пьютере1. Формулировка задачи останова очень проста. Даётся описание 1 Точнее, этот результат доказывается для машины Тьюринга, принципиально отличающейся от компьютера наличием бесконечной памяти (и, таким образом, являющейся ещё более мощным устройс- твом). 56
Глава 3. Алгоритмы на графах произвольного алгоритма на каком-либо формальном алгоритмическом языке (например, на Паскале). За конечное время требуется определить, закончит ли этот алгоритм работу или будет продолжать выполняться бес- конечно долго. Многим кажется, что любой программист если не с первого, то со второ- го взгляда способен определить, «зависает» ли программа в бесконечном цикле или нет, но на самом деле это не так. Конечно, в простых случаях распознать «зависание» нетрудно, но существуют и задачки посложнее. На- пример, представьте себе такой алгоритм: ( ' > ПОКА N не является „совершенным числом- ; , у ' N i 2 ” ' / ’ конец'цикла Для справки: совершенными называются числа, равные сумме всех своих делителей, кроме себя. Например, 6 = 3 + 2+ 1,28 =14 + 7 + 4 + 2+1. Приведённый алгоритм заканчивает работу, найдя первое нечётное совер- шенное число (в предположении, что переменная N имеет потенциально неограниченный размер и может хранить любые, сколь угодно большие целые числа). Ситуация интересна тем, что на сегодняшний день никто не знает, существуют ли вообще нечётные совершенные числа. Поэтому дать правильный ответ на вопрос об останове программы их поиска весьма за- труднительно. Просто запустить программу и проанализировать её поведе- ние тоже не получится. Если окажется, что нечётных совершенных чисел не существует, мы не сумеем получить ответ за конечное время — программа будет перебирать все целые числа по очереди, а целых чисел, как известно, бесконечное множество. То, что задача останова не решается с помощью компьютера — факт дока- занный. Можно ли создать какое-либо более мощное решающее устройство — неизвестно. Сформулированный в 1936 году тезис Чёрча-Тьюринга ут- верждает, что нельзя. В целом научное сообщество к нему склоняется, но ни строгих доказательств, ни контрпримеров мы до сих пор не имеем. Однако обычные компьютерные программы обладают одним важным свойством, отличающим их от алгоритмов в математическом смысле этого слова. Дело в том, что любая реальная переменная или структура данных имеет ограниченный размер. Так, выше я заметил, что переменная N может хранить любое число. Если же рассмотреть более реалистичный сценарий и предположить, что N представляет собой беззнаковую 32-разрядную пе- ременную, то окажется, что как только значение N превысит 232 - 1, про- изойдёт переполнение. Таким образом, задача останова для обычных ком- 57
C++ мастер-класс. 85 нетривиальных проектов, решений и задач пьютерных программ решаема за конечное (хотя и, возможно, невероятно долгое) время. Задание заключается в решении задачи останова для произвольной про- граммы, записанной на простейшем языке программирования TinyPower. В языке предусмотрены лишь две алгоритмические конструкции — присва- ивание и ветвление: НомерСтроки asgn X Y goto НомерЦелевойСтроки НомерСтроки asgn X Y + Z goto НомерЦелевойСтроки НомерСтроки asgn X Y - Z goto НомерЦелевойСтроки НомерСтроки if X if-операция Y goto НомерСтроки! else НомерСтроки2 Операция присваивания имеет три разновидности. Первая соответствует простому присваиванию вида X - Y, где X — переменная, a Y — переменная, либо число. Переменные описывать в явном виде не требуется. Любая пере- менная считается целой со знаком, имеющей размер 16 разрядов (диапазон допустимых значений, таким образом, равен -32768...+32767). Начальное значение переменной равно нулю. Вторая и третья разновидности позволяют присвоить переменной X значе- ние выражения Y + Z или Y - Z, где Y и Z — целые числа или переменные. После выполнения операции компьютер переходит к строке, номер которой следует за словом goto. Ниже приведены примеры присваиваний: 10 asgn X 10 goto 20 15 asgn X 5 + К goto 30 20 asgn A Z - 10 goto 5 Инструкция if переходит к строке НомерСтроки! либо к строке НомерСтроки2 в зависимости от результата вычисления выражения X if-операция Y. В качестве if-операций допускаются операции =, <>, <, <=, > и >=. X и Y — целые переменные или константы. Пример: 10 if X > 40 goto 20 else 50 Специальная инструкция end заканчивает работу программы. В качестве примеров можно привести две программы. Первая вычисляет сумму чисел от 1 до 5 (результат записывается в переменную S): 10 asgn i i + 1 goto 20 20 asgn S S + i goto 30 30 if i < 5 goto 10 else 40 40 end. 58
^laiaHaus,^. Глава 3. Алгоритмы на графах Вторая, по существу, представляет собой обычный бесконечный цикл: 10 asgn С С + 1 goto 15 15 asgn С С - 1 goto 20 20 if 10 = 10 goto 10 else 30 30 end При возникновении любой нештатной ситуации (переполнение, синтакси- ческая ошибка, переход по недопустимому номеру строки) работа програм- мы немедленно завершается. Итак, для любой заданной программы на языке TinyPower требуется опре- делить, завершит ли она когда-нибудь свою работу или будет крутиться в бесконечном цикле. Подсказка Обратите внимание, что запись, содержащая текущие значения всех пере- менных программы и номер выполняемой строки, однозначно описывает состояние программы в данный момент времени. Если при выполнении ал- горитма некоторое состояние возникает во второй раз, это означает беско- нечный цикл. Решение Решение состоит из двух логически не связанных между собою шагов: вспо- могательного - реализации интерпретатора языка TinyPower - и собствен- но решения задачи останова. Начнём с интерпретатора TinyPower. В большинстве программ на языке TinyPower используются переменные. Их удобно хранить в ассоциативном массиве, сопоставляющем символи- ческому имени переменной её значение: map<string, int> Variables; Почти столь же фундаментальной для интерпретатора является функция Readvalue (), возвращающая числовое значение переменной или записан- ной в виде строки константы: int ReadValue(const string^ str) ( if(str == *0*) // константа «нуль» return 0; if(atoi(str.c_str()) != 0) // другая числовая константа return atoi(str.c_str()); 59
C++ мастер-класс. 85 нетривиальных проектов, решений и задач return Variables[str]; // имя переменной ) Назначение этой функции проще всего понять на примере. Рассмотрим инструкцию 10 asgn X 5 goto 20 Предположим, что синтаксический анализатор (пока ещё не написанный) разбил её на шесть подстрок — «10», «asgn», «X», «5», «goto» и «20». Как мы увидим в дальнейшем, уже знания самого количества под- строк достаточно, чтобы однозначно определить тип инструкции (в данном случае — простейший вариант присваивания). Третья подстрока («X») указывает имя переменной, которой присваивается значение, а четвёртая («5») — само значение. Но значение, в свою очередь, может представлять собой как числовую константу, так и имя переменной. Функция Readvalue () умеет обрабатывать оба случая. Отдельно рассмат- ривается строка, содержащая число 0, поскольку функция atoi () возвра- щает нуль при ошибке преобразования (таким образом, с помощью atoi () нельзя отличить константу 0 от имени переменной, но в нашей ситуации эта проблема решается легко). Обратите внимание, что при первом вызове инструкции return Variables[str]; когда переменной str ещё не существует, в ассоциативный массив запи- сывается (согласно определению operator [ ] () ) пара (str, int () ). В свою очередь, значение int () равно нулю. Программа на языке TinyPower состоит из инструкций. Поэтому нам пот- ребуется новый тип данных «инструкция»: class Statement { public: virtual void PerformO = 0; // выполнить инструкцию }; Текст программы — это ассоциативный массив, сопоставляющий номерам строк те или иные инструкции: map<int, Statement*> Program; Отдельная переменная служит для хранения номера текущей строки: int CurrentLine; Для каждой разновидности инструкции языка потребуется собствен- ный подкласс, переопределяющий чистую виртуальную функцию-член PerformO: 60
^ataHaus,^. Глава 3. Алгоритмы на графах // простое присваивание asgn X Y goto TargetLine class SimpleAsgn : public Statement { private: string' X, Y; int TargetLine; public: SimpleAsgn(const strings _X, const strings _Y, int —targetLine) : X(_X), Y(_Y), TargetLine(_targetLine) {} virtual void PerformO { int v = Readvalue(Y); // определить значение Y if(v < -32768 II v > 32767) // проверить выход за // пределы допустимого // диапазона throw overflow_error(* *) ; Variables[X] = v; //выполнить присваивание CurrentLine - TargetLine; ) }; // присваивание с операцией asgn X Y op Z goto TargetLine class OpAsgn : public Statement { private: string X, Y,• Z, op; int TargetLine; public: OpAsgn(const strings _X, const strings _Y, const strings _op, const strings _Z, int _targetLine) : X(_X), Y(_Y), Z(_Z), op(_op), TargetLine(_targetLine) {} virtual void PerformO { int sign = (op == ? 1 : -l)j // знак операции («+» // и «-») int v - ReadValue(Y) + sign * Readvalue(Z); //значение Y op Z // проверить выход за пределы допустимого диапазона if(v < -32768 |-I v > 32767) // проверить выход за // пределы допустимого // диапазона throw overflow_error("") ; Variables[X] = v; // выполнить присваивание CurrentLine = TargetLine; ) ) ; 61
C++ мастер-класс. 85 нетривиальных проектов, решений и задач // ветвление if X ifop Y goto TargetLinel else TargetLine2 class IfStatement : public Statement { private: string X, Y, ifop; int TargetLinel, TargetLine2; public: IfStatement(const strings _X, const strings _ifop, const strings _Y, int _tlinel, int _tline2) : X(_X), Y(_Y), ifop(_ifop), TargetLinel(_tlinel), TargetLine2(_tline2) {} virtual void Perform() { // найти значения X и Y int vl = ReadValue (X) , v2 = ReadValue(Y); // результат X ifop Y bool result = (ifop == "=" && vl =- v2) II (ifop ~= “<>" && vl != v2) II (ifop == "<" SS vl < v2) II (ifop == u<=" && vl <~ v2) II (ifop == ">" && vl > v2) I I (ifop =~ “>=” && vl >= v2); CurrentLine = (result ? TargetLinel : TargetLine2); } }; // инструкция end class Endstatement : public Statement { public: Endstatement() {} virtual void Perform() { throw exception(); } // выход из // программы }; Преобразованием строковой записи инструкции в объект типа Statement занимается функция ParseLine (): void ParseLine(const strings line) { vector<string> cur_s; istringstream is(line); string templine; while(!is.eof()) { // разбить строковую запись инструкции //на разделённые пробелами подстроки 62
^ataHaus,^. Глава 3. Алгоритмы на графах is » templine; cur_s.push_back(templine); } int CurLine = atoi(cur_s[0].c_str()); // номер строки // тип инструкции определяется по количеству подстрок switch(cur_s.size()) { case 2: Program [CurLine] = new EndStatementOr- break; case 6: Program[CurLine] - new SimpleAsgn(cur_s[2], cur_s[3], atoi (cur_s [5] .c_str() )) ; break; case 8: Program[CurLine] ~ new OpAsgn(cur_s[2] , cur_s[3], cur_s[4], cur_s[5], atoi(cur_s[7].c_str())); break; case 9: Program[CurLine] - new IfStatement(cur_s[2], cur_s[3], cur_s[4], atoi(cur_s[6].c_str()), atoi(cur_s[8].c_str())); break; } } Выполнение программы теперь сводится к вызову в бесконечном цикле функции Program[CurrentLine]->Perform(); Генерация исключения типа exception будет означать нормальное за- вершение работы (конечно, сопоставлять исключение наилучшему сцена- рию выполнения последовательности инструкций с точки зрения стиля не очень хорошо, но в данном случае это заметно упрощает код). Вторая проб- лема — выход значения переменной за границы допустимого диапазона — обнаруживается с помощью исключения типа overflow_error. Теперь займёмся выявлением бесконечных циклов. Как уже было сказано, «отловить» бесконечный цикл можно, если запоминать все уже встречав- шиеся ранее состояния выполняемой программы. Повторное возникнове- ние любого состояния означает «зависание». Состояние программы хранится в объекте типа Programstate: class Programstate ( private: map<string, int> Vars; // значения переменных int CurLine; // текущая строка public: // инициализация глобальными значениями Variables и CurrentLine 63
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Programstate() : CurLine(CurrentLine), Vars(Variables) {) // определим на множестве состояний отношение порядка bool operator<(const Programstate& rhs) const { if(CurLine == rhs.CurLine) return Vars < rhs.Vars; return CurLine < rhs.CurLine; } }; Возникающие в программе состояния записываются во множество States: set<ProgramState> States; Осталось лишь написать функцию main (): int main(int argc, char* argv[]) { string line; // ввод программы на языке TinyPower с клавиатуры while(getline(cin, line) && line != "") ParseLine(line); CurrentLine = Program.begin()->first; // первая инструкция try { while(1) { // текущее состояние Programstate curstate; // если состояние не найдено во множестве States if(States.find(curstate) == States.end()). // добавить curstate в States States.insert(curstate); else { // если состояние найдено, программа «зациклилась» cout « "Обнаружен бесконечный цикл!"; break; } // выполнить очередную инструкцию Program[CurrentLine]->Perform(); J } catch(overflow_error&) { cout « "Выход переменной за границы допустимого диапазона! \п" } 64
^laiattaus^i Глава 3. Алгоритмы на графах catch(...) // нормальное завершение работы { cout « "Нормальное завершение работы\п"; // печать значений всех переменных программы for(map<string, int>::iterator p = Variables.begin(); p ! = Variables.end(); p++) cout « p->first « * = * « p->second « endl; } for(unsigned i = 0; i < Program.size(); i++) delete Program[i]; return 0; } Примеры: D:\Projects>solveunsolvable 10 asgn i i + 1 goto 20 20 asgn s s + i goto 30 30 if i < 5 goto 10 else 40 40 end Нормальное завершение работы i = 5 s = 15 D:\Projects>solveunsolvable 10 asgn С C + 1 goto 15 15 asgn С C - 1 goto 20 20 if 10 = 10 goto 10 else 30 30 end Обнаружен бесконечный цикл! D:\Projects\>solveunsolvable 10 asgn i i + 1 goto 10 20 end Выход переменной за границы допустимого диапазона! Здесь уместно сделать несколько замечаний о теоретических и практичес- ких аспектах, связанных с задачей останова. • Задача останова для произвольного алгоритма, записанного на псев- докоде, неразрешима. • Задача останова для реальной компьютерной программы, занима- ющей заведомо конечный объём памяти, теоретически может быть 3 Заг 772 65
C++ мастер-класс. 85 нетривиальных проектов, решений и задач решена с помощью приведённой выше процедуры (в предположе- нии, что памяти компьютера хватит для хранения всего множества состояний). • На практике даже очень простой программе может соответствовать огромное количество различных состояний. Таким образом, при- ведённая методика для реальных, не игрушечных, программ не го- дится. Она лишь иллюстрирует принцип теоретической возможнос- ти решения, но никак не готовый для использования в повседневной практике рецепт. 3.1.2. Планировщик СУБД Разрешение взаимных блокировок Наверно, каждый из нас в той или иной степени знаком с базами данных. Кто-то искал телефон в справочнике, кто-то сам создавал такие справочни- ки, а иные, возможно, сочиняли SQL-запросы для извлечения информации из хранилищ данных сложной структуры. Но мало кому приходилось ре- шать проблемы, возникающие «по ту сторону» СУБД. Написать домашнюю картотеку довольно просто. Придумать оптимальный способ хранения больших объёмов данных сложнее. Реализовать подде- ржку языка SQL ещё сложнее, но СУБД промышленного уровня сталки- ваются и с проблемами совершенно иного рода, связанными, например, с организацией доступа к данным нескольким пользователям одновременно. Одной из таких проблем нам сейчас и предстоит заняться. Речь пойдёт о разрешении так называемых взаимных блокировок (deadlocks). Рассмотрим, например, такой сценарий (традиционно приводят примеры из сферы финансов, видимо, чтобы никого не оставить равнодушным). Два пользователя — муж и жена — имеют общий банковский счёт. Предполо- жим, муж решает положить на счёт десять тысяч рублей. В то же самое вре- мя жена (находящаяся в другой части города) желает положить на счёт сто рублей. Если база данных, хранящая информацию о содержимом счетов, поддержи- вает лишь операции чтения и записи (а это достаточно разумное допуще- ние), то процедура «положить S рублей на счёт А» будет состоять из трёх шагов: М = ДенегНаСчёте(А) М = М + S ЗаписатьНаСчёт(А, М) 66
^latattaus^ Глава 3. Алгоритмы на графах Поскольку таблица, в которую производится запись, всего одна, а пользова- телей несколько, фактическая последовательность выполняемых операций зависит от планировщика СУБД (то есть операции у нас выполняются не параллельно, а всё-таки последовательно2). Например, не исключён такой сценарий: // изначально на счету находится 20 000 рублей муж: М = ДенегНаСчёте (А) //' М = 20 000 жена: М = ДенегНаСчёте(А) // М = 20 000 муж: М = М + S1 // М = 20 000 + 10 муж: ЗаписатьНаСчёт(А, М) //на счету 30 000 ] жена: М = М + S2 // М = 20 000 + 100 жена: ЗаписатьНаСчёт(А, М) // на счету 20 100 ] // итого: на счету 20 100 рублей рублей ООО рублей В процессе денежных операций жены состояние счёта изменилось, но про- грамме ничего об этом не было известно. В результате десять тысяч рублей ушли «в никуда». i Для предотвращения таких потерь в промышленных СУБД предусмотрены средства, позволяющие запретить одновременный доступ к данным двум и более пользователям. В простейшем виде этот механизм можно проиллюс- трировать с помощью четырёх операций: • lock А- заблокировать таблицу А (если таблица уже заблокирова- на, ничего не делать); • unlock А - разблокировать таблицу А (если таблица уже разблоки- рована, ничего не делать); • read А - считать данные из А; • write А - записать данные в А. Любые операции с заблокированной таблицей может производить только процесс, эту таблицу заблокировавший. Если очередная инструкция како- го-либо другого процесса пытается получить доступ к чужой таблице, пла- нировщик просто не разрешит её выполнение (таким образом, другой про- цесс окажется приостановленным). Если предположить, что сведения о счетах хранятся в таблице Accounts, процедура «положить деньги на счёт» дополняется парой операций lock/unlock: lock Accounts М = ДенегНаСчёте(А) М = М + S 2 Точно такой же механизм используется, когда вы одновременно печатаете письмо, качаете файл с интернета и слушаете музыку на однопроцессорном компьютере. 67
C++ мастер-класс. 85 нетривиальных проектов, решений и задач ЗаписатьНаСчёт(А, М) unlock Accounts Теперь, пока одна операция не выполнится до конца, другая даже не начнёт считывать данные из таблицы. К сожалению, решая одни проблемы, мы приобретаем другие. Рассмотрим такую последовательность операций, возникающую в результате работы двух процессов pl и р2: pl: lock А р2: lock В Р1: lock В /7 процесс приостановлен до освобождения // таблицы В процессом р2 р2: lock А // процесс приостановлен до освобождения // таблицы А процессом pl В результате процессы pl и р2 блокируют друг друга, и программа «зависает». Сложившаяся ситуация называется взаимной блокировкой (deadlock). Чтобы разрешить взаимную блокировку, нужно аварийно завершить один из участвующих в ней процессов3. Произведённые процессом операции от- меняются, а пользователю выдаётся сообщение вида «ошибка сервера: пов- торите попытку позже». Отмена операций — отдельная задача, не связанная- с нашими нынешними заботами. Безусловно, наличие операций блокировки не гарантирует отсутствия оши- бок со стороны программиста. Но этот вопрос остаётся за рамками компе- тенции планировщика СУБД. Задача состоит в имитации работы планировщика. С клавиатуры вводится число N, обозначающее количество одновременно выполняемых процессов, и сами процессы (то есть наборы инструкций). Пример ввода: 2 lock a write a read a unlock а lock b write b read b unlock b // первый процесс // второй процесс Моделирование заканчивается после завершения всех процессов. Все про- исходящие действия следует записывать в журнал или каким-либо образом отображать на экране. 3 Проще всего из всех процессов-кандидатов выбрать случайный. В СУБД промышленного уровня может использоваться система приоритетов (например, запросы директора имеют приоритет над запросами простого сотрудника). 68
ftataHauslik Глава 3. Алгоритмы на графах ВЗАИМНЫХ БЛОКИРОВОК НЕТ Рис. 3.1. Использование графа для выявления взаимных блокировок ВЗАИМНАЯ БЛОКИРОВКА Подсказка Планировщик работает по следующему алгоритму: ПОКА существует хотя бы один выполняющийся процесс найти множество незаблокированных4 процессов Р ; , ' ,ЕСЛИ Р пусто ,(тр есть все процессы заблокированы) •... ПОКА в системе естьч взаимные блокировки прервать выполнение одного из процессов исключить, его из списка процессов, •• - • КОНЕЦ ЦИКЛА ч { ИНАЧЕ (есть незаблокированные .процессы): j \ . ’ случайным образом выбрать процесс- из. Р . ' ’ " выполнить, очередную.его инструкцию , " , ' • если процесс завершился, исключить его из списка процессов КОНЕЦ ЦИКЛА ' ч Для поиска взаимных блокировок придётся проанализировать граф, вер- шинами которого являются процессы, а дугами — блокировки (рис. 3.1). Дуга pl —* р2 существует, если процесс pl блокируется процессом р2. Решение Поскольку алгоритм решения в общих чертах уже был приведён выше, при- ступим непосредственно к реализации. Первым делом определим тип данных Statement, описывающий произ- вольную инструкцию каждого процесса, выполняемого на сервере баз дан- ных. Инструкция состоит из команды (read, write, lock, unlock) и имени таблицы, над которой производится действие: 4 То есть процессов, очередные инструкции которых могут быть выполнены. 69
C++ мастер-класс. 85 нетривиальных проектов, решений и задач struct Statement { string Command, Table; bool operator==(const Statement& rhs) const { return Command == rhs.Command && Table == rhs.Table; } }; Процесс представляет собой очередь инструкций: class Process : public queue<Statement> { private: int ProcNo; // идентификатор процесса public: Process(int pn) : ProcNo(pn) {} bool operator==(const Process& rhs) const { return ProcNo == rhs.ProcNo; } int GetProcNoO const { return ProcNo; } int BlockedByO const4; // получить идентификатор // блокирующего процесса void UnlockAllTables() const; //разблокировать все таблицы, // заблокированные процессом void Executestatement(); // выполнить очередную // инструкцию }; Операция сравнения, которая потребуется нам в дальнейшем для поис- ка конкретного процесса в списке, считает два процесса равными, если их идентификаторы равны. Теперь можно определить два глобальных объекта: ассоциативный массив заблокированных таблиц, где имени таблицы сопоставлен идентификатор блокирующего её процесса, и список выполняемых процессов. map<string, int> Locked; list<Process> Processes; Займемся реализацией функций-членов класса Process. Функция BlockedBy () возвращает идентификатор процесса, блокирую- щего текущий. Текущий процесс считается заблокированным, если табли- ца, указанная в его текущей инструкции, заблокирована каким-либо другим процессом. Если текущий процесс никем не заблокирован, то есть выпол- 70
Глава 3. Алгоритмы на графах няется нормально, функция возвращает нуль (таким образом, нуль нельзя использовать в качестве идентификатора процесса): int Process::BlockedBy() const { // блокирована ли таблица, с которой будет проведена операция? map<string, int>::iterator it = Locked.find(front().Table); if(it != Locked.end() && it->second != ProcNo) // если да, вернуть номер блокирующего процесса return it->second; return 0; } Функция-член UnlockAllTables () удаляет из массива Locked все таб- лицы, заблокированные текущим процессом: void Process::UnlockAllTables() const { map<string, int>::iterator it; while((it = find_if(Locked.begin(), Locked.end(), composel(bind2nd(equal_to<int>(), ProcNo), select2nd<map<string, int>::value_type>()))) != Locked.end()) Locked.erase (it) ; } Немного экстравагантный предикат, переданный в качестве третьего аргу- мента функции find_if (), возвращает true для всех элементов ассоциа- тивного массива Locked, поля «значение»5 которых равны ProcNo. Рассмотрим устройство предиката. Функция select2nd<map<string, int>::value_type>() для любого переданного объекта типа map<string, int>: : value type (то есть пары вида (string, int)) возвращает его правую (типа int) составляющую. Вспомогательная функция bind2nd () в контексте bind2nd(equal_to<int>(), ProcNo) превращает двухаргументную функцию equaltoc int > (int a, int b), сравнивающую два целых числа, в одноаргументную функцию, сравниваю- щую переданное число со значением ProcNo. 5 Здесь подразумевается, что элементами ассоциативного массива являются пары вида (ключ, значение). 71
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Функция composel () объединяет возвращаемые операциями bind2nd () и select2nd () функциональные объекты в композицию. Теперь в качест- ве сравниваемого с числом ProcNo объекта выступает второй элемент пары “ключ, значение”, возвращаемый функцией select2nd (). Последняя функция-член класса Process выполняет очередную инструк- цию процесса (в предположении, что это возможно): void Process::Executestatement() { // найти процесс, заблокировавший таблицу, указанную // в текущей команде map<string, int>::iterator it = Locked.find(front().Table); if(front().Command == 'unlock") { // разблокировать таблицу, если она заблокирована if(it != Locked.end()) Locked.erase(it); } else if(front().Command == "lock") { // заблокировать таблицу, если она еще не заблокирована if(it == Locked.end()) Locked[front().Table] = ProcNo; } // вывести выполняемую операцию cout « ProcNo « ": " « front() .Command « " " « f ront (). Table « endl; pop(); // удалить операцию из очереди // если операций больше нет if(empty()) { cout « ProcNo « нормальное завершение процесса* « endl; UnlockAllTables(); } } Функция main () реализует алгоритм на псевдокоде, приведенный в под- сказке. // вспомогательная функция для сравнения двух процессов. // Из двух процессов «больше» тот, который блокируется процессом // с большим идентификатором bool ProcCompare(const Process& Ihs, const Process& rhs) { return Ihs.BlockedBy() < rhs.BlockedBy(); } 72
^laiaHaus^. Глава 3. Алгоритмы на графах И---------------------------------------------------------------- int main(int argc, char* argv(J) { int N; // ввести количество процессов cin » N; randomize(); string line; getline(cin, line); // считать N строк, каждая из которых содержит инструкции // одного процесса. Например: lock a read a unlock а for(int i = 1; i <= N; i++) { getline(cin, line); istringstream is(line); // создать процесс с идентификатором i Process proc(i); while(!is.eof()) { // считать все инструкции из строки line Statement s; is » s.Command; is » s.Table; // добавить очередную инструкцию в процесс proc.push(s); } // добавить процесс в список процессов Processes.push_back(proc); ) // пока существует хотя бы один выполняющийся процесс while( ! Processes.empty()) { // посчитать количество незаблокированных процессов int NotBlocked = 0; for(list<Process>::iterator it = Processes.begin(); it != Processes.end(); it++) if(it->BlockedBy() == 0) NotBlocked++; // если все процессы заблокированы if(NotBlocked == 0) // разрешить взаимные блокировки ResolveDeadlocks(); else { 73
C++ мастер-класс. 85 нетривиальных проектов, решений и задач // сортировать по идентификатору блокирующего процесса, // чтобы все незаблокированные процессы (идентификатор // блокирующего равен нулю) оказались в начале списка Processes.sort(ProcCompare); // выбрать случайный незаблокированный процесс int г = random(NotBlocked); list<Process>::iterator it = Processes.begin(); for(int i = 0; i < r; i++) it+ + ; it->ExecuteStatement(); // выполнить операцию // если процесс завершился, удалить его из списка if(it->empty()) Processes.erase(it); } } return 0; } Мы переходим к центральному алгоритму программы: поиску и разреше- нию взаимных блокировок. Как уже было сказано, схему блокировок про- цессов можно представить в виде графа (рис. 3.1). Одним из методов выяв- ления циклов в графе является так называемый цветной поиск в глубину (colored depth-first search). Изначально все вершины графа помечаются белым цветом. Затем выполня- ется следующий алгоритм: ЦИКЛ' по всем вершинам- графа ' I' : tт • . ЕСЛИ текущая вершина помечена белым цветом пометить вершину серым цветом '• > \ ' чикл .. : пометить очередную вершину-потомок6 серым цветом, если помечаемая вершина уже серая, цикл в графе найден КОНЕЦ ЦИКЛА .У. пометить все рассмотренные вершины чёрным цветом КОНЕЦ ЦИКЛА ‘ . . 6 Поскольку здесь происходит поиск в глубину, рассматриваются как непосредственные потом- ки, так и потомки потомков. 74
^atatiaus^k Глава 3. Алгоритмы на графах Общий алгоритм разрешения взаимных блокировок выглядит так: цикл _ , deadlocks_left = false ' - ЕСЛИ некоторый процесс Р принадлежит', циклу -на графе блокировок завершить выполнение Р удалить Р из списка выполняемых процессов ‘ 4 ‘ deadlocks_left = true S ПОКА deadlocks_left ' Поиском циклов в графе будет заниматься функция Search (), реализую- щая цветной поиск в глубину (начиная с вершины v): enum COLOR { BLACK, WHITE, GREY }; // цвет меток вершин vector<COLOR> Marks; // метки вершин И------------------------------------------------------------------ int Search(int v) { Marks[v] = GREY; // пометить текущую вершину серым цветом // найти вершину, являющуюся непосредственным потомком int u = (find(Processes.begin(), Processes.end(), // (*) Process(v)))->BlockedBy(); if(u != 0) // если вершина существует { if(Marks[u] -- GREY) // и помечена серым, то цикл найден: return и; 7/ вернуть вершину, принадлежащую // циклу else if(Marks[u] == WHITE) { // если вершина помечена белым // продолжить поиск для подграфа int v = Search(и); if(v > 0) return v; } // все чёрные вершины уже рассмотрены ранее, } // для них никаких действий не требуется Marks[v] = BLACK; // пометить текущую вершину чёрным цветом return 0; // цикл не найден } Обратите внимание на строки функции Search (), отмеченные звёздочкой. Процедура поиска в глубину должна быть рекурсивно вызвана для каждо- го непосредственного потомка текущей вершины. Однако в нашем случае количество потомков всегда равно нулю или единице, так как никакой процесс не может одновременно блокироваться двумя или более другими 75
C++ мастер-класс. 85 нетривиальных проектов, решений и задач процессами. Таким образом, достаточно найти процесс, блокирующий теку- щий, и выполнить дальнейшие действия только для него. И вот, наконец, код функции, разрешающей взаимные блокировки: void ResolveDeadlocks() ( // выделить память под массив меток вершин // (нулевой элемент зарезервирован) Marks.resize(Processes.size() + 1); bool deadlocks_left; do { deadlocks_left = false; // пометить все процессы (вершины графа) белым цветом for(list<Process>::iterator it = Processes.begin(); it != Processes.end(); it++) Marks[it->GetProcNo()] = WHITE; int p; // цикл по всем белым вершинам for(list<Process>::iterator it = Processes.begin(); it != Processes.end(); it++) if(Marks[it->GetProcNo()] == WHITE) if((p = Search(it->GetProcNo())) != 0) { // если в графе найден цикл (р принадлежит циклу) // получить итератор на соответствующий объект // процесса list<Process>::iterator it = find(Processes.begin(), Processes.end () , Process(p)); cout « it->GetProcNo() « •: аварийное завершение процесса" « endl; it->UnlockAllTables(); Processes.erase(it); deadlocks_left = true; break; } } while(deadlocks_left); } Примеры работы программы-планировщика: D:\Projects>dbms 2 lock a write a read a unlock a lock b write b read b unlock b 1: lock a 76
l\ataUaus,ii> Глава 3. Алгоритмы на графах 2: lock Ь 2: write Ь 2: read b 1: write а 1: read а 1: unlock а 1: нормальное завершение процесса 2: unlock Ь 2: нормальное завершение процесса D:\Projects>dbms 2 lock a lock b unlock b unlock a lock b lock a unlock a unlock b 2: lock b 1: lock a 1: аварийное завершение процесса 2•. lock a 2: unlock a 2: unlock b 2: нормальное завершение процесса D:\Projects>dbms 2 lock a read a unlock a lock a read a unlock a 2: lock a 2: read a 2: unlock a 2: нормальное завершение процесса 1: lock a 1: read a 1: unlock a 1: нормальное завершение процесса 3.1.3. Сортировка сайтов ПОИСК КОМПОНЕНТ СВЯЗНОСТИ ГРАФА Некоторые не вполне адекватные пользователи ухитряются сваливать все скачанные из интернета файлы в одну и ту же папку на винчестере. Понять после этого, к какому сайту какой файл относится, практически нереаль- но. Приходится открывать все файлы подряд и вручную раскидывать их по разным папкам. К счастью, задачу можно попытаться автоматизировать. Если на какой-либо странице содержится ссылка на другую страницу, вероятно, обе эти страницы когда-то находились на одном сайте. 77
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Используя это наблюдение, можно написать программу, автоматически груп- пирующую страницы, скачанные с одного ресурса. На вход программе подаётся коллекция документов, на выходе формируется набор подкаталогов (каждому подкаталогу соответствует отдельный сайт). Решение В терминах теории графов условие задачи можно сформулировать короче: выделить компоненты связности в графе. Мы объединим отдельные стра- ницы в группы (кластеры7) по известному признаку — обязательному, хотя бы одностороннему, наличию ссылок друг на друга. Для начала граф страниц необходимо создать. Для этого можно завести гло- бальный массив указателей на страницы: struct THtmlPage { string name; // имя файла int cluster; // кластер, к которому относится страница vector <THtmlPage*> references; // массив ссылок на другие страницы }; vector <THtmlPage*> pages; // страницы, подлежащие кластеризации Каждая страница хранится в памяти в единственном экземпляре. Все ма- нипуляции производятся лишь с указателями и массивами указателей на объекты типа THtmlPage. Изначально каждая страница находится в своём собственном кластере: void LoadAl1Pages() { THtmlPage* pg; TSearchRec sr; int i = 0; // будут просмотрены *.htm и *.html файлы if (FindFirst(*data\\*.htm?“, 0, sr) == 0) { do { pg = new THtmlPage; pg->name = sr.Name.c_str(); // инициализация: каждая страничка - свой кластер pg->cluster = i++; pages.push_back(pg); 7 В более общем виде задача кластеризации рассматривается в шестой главе. 78
^lalaHaus,^. Глава 3. Алгоритмы на графах cout « pages[i—1]->name « " “ « pg->cluster; cout « endl; } while (FindNext(sr) =- 0) ; FindClose(sr); } } Теперь создадим граф на основе содержимого страниц. Логически вебсайт представляет собой ориентированный граф, однако поскольку направления ребёр нас не интересуют, мы будем создавать неориентированный граф, ко- торый и требуется для алгоритма кластеризации. void CreateGraph() { for ( int i = 0; i < pages.size(); i++) < 11 загружаем содержимое странички if stream in( ("dataW* + pages[i]->name).c_str()); string buf; int Ipos, rpos; TSearchRec sr; // выявляем наличие гиперссылок и проверяем, есть ли такие // среди имён в массиве pages while ( in » buf ) { // ищем строку, в которой есть гиперссылка if ((Ipos = buf.find("href=", 0)) +1) { // находим второй, он же крайний знак “ Ipos = rpos = buf.find('", 6); //6 == strlenf"href-”) + 1 while (buf[--lpos] != '/' && buf[lpos] != '"') f lpos++; // скопируем интересующее нас имя файла string file_name; copy(buf.begin() + Ipos, buf.beginO + rpos, back_inserter(file_name)); for ( int f = 0 ; f < pages.size(); f ++) if ( i != f) // проверяем на соответствие if (pages[f]->name == file_name) { II ссылка найдена, добавляем её // (собственно, создание графа) pages [i] preferences .push_back (pages [f] ) ; II делаем граф неориентированным pages [f] preferences .push_back(pages [i] ) ; } } 79
C++ мастер-класс. 85 нетривиальных проектов, решений и задач } } } Функция GetClusters() находит компоненты связности полученного графа: void GetClusters() { int i, j ; int new_cluster, old_cluster; bool changed; do { changed = false; for (i = 0; i < pages.size(); i++) for (j = 0; j < pages [i] preferences. size () ; j++) { // если кластеры ссылающейся странички и ссылки II еще не совпадают, помещаем их в один кластер if ((new_cluster = pages[i]->cluster) != (old_cluster = pages [i] preferences [j ]->cluster) ) pages [i] preferences [j ] pcluster = new_cluster; II в этот же кластер помещаем все странички, // ранее находившиеся в old_cluster for ( int k = 0; k < pages.size(); k++ ) if ( pages [k] pcluster == old_cluster ) pages[k]->cluster = new_cluster; changed = true; } } }while(changed); for ( i = 0; i < pages.size(); i++ ) cout « “page “ « pages[i]->name « " cluster " « pages [i] pcluster « '\n'; } Алгоритмически задачу можно считать решённой, однако не будем ленить- ся и сохраним каждый кластер в отдельный подкаталог: void SaveGraph() { TSearchRec sr; for ( int i = 0; i < pages.size(); i++) { // сохраняем каждый кластер в отдельный каталог if (FindFirst ((“dataW* + pages[i]->name).c_str(), 1, sr) == 0) 80
^alalfaus^ Глава 3. Алгоритмы на графах if (JDirectoryExists ("sortedW* + (AnsiSt-ring)pagesli] -^cluster) ) if (JCreateDir ("sortedW" + (AnsiString)pages [i]->cluster) ) throw Exception("Cannot create 'sorted' directory."); CopyFile ( ("dataW" + pages [i]->name) . c_str () , ("sortedW* + (AnsiString)pages[i]->cluster + "W" + sr.Name).c_str(), 0) ; FindClose(sr); Для наглядности напишем ещё одну вспомогательную функцию, выводя- щую результаты: void ShowGraph() { cout « '\n'; for ( int i = 0; i < pages.size(); i ++) { cout « "page name: " « pages[i]->name « endl; for ( int j = 0 ; j < pages [ i] preferences . size (); j ++) cout « " references: " « pages [i] preferences [j ]->name « endl; cout « '\n'; } } Теперь всё. Осталось написать функцию main (): int main(int argc, char* argv[]) { if (IDirectoryExists("sorted")) { if (!CreateDir("sorted")) throw Exception("Cannot create 'sorted' directory."); } // русский текст следует выводить в консоль // с помощью CharToOem()®. char buf[256]; // размер буфера зависит от размера исходной // строки ::CharToOem( "Файлы, подлежающие сортировке\п", buf ); cout « buf << endl; LoadAllPages(); CreateGraph(); 8 Вопрос о выводе кириллицы в консоль часто поднимается на различных форумах, поэтому я счел уместным привести здесь его простое решение. 81
C++ мастер-класс. 85 нетривиальных проектов, решений и задач ::CharToOem( "Перекластеризуем граф: \n", buf ); cout « buf « endl; GetClusters(); ShowGraph(); SaveGraph(); getch(); return 0; } Рассмотрим в качестве примера простую коллекцию страниц: 1 .html содержит строку «href=*/2.html"» 3 .html содержит строку «href="/2.html"» 4 . html содержит строки «href="/5.html"», «href="/6.html"» и «href="/7.html"» 2.html, 5.html, 6.html, 7.html не содержат никаких ссылок Программа разобьёт коллекцию на два кластера. В первом окажутся доку- менты l.html, 2.html и 3.html, во втором — 4.html, 5.html, 6.html и 7.html. 3.2. ВОЛНОВАЯ ТРАССИРОВКА 3.2.1. Волновая трассировка Поиск МАРШРУТА В ЛАБИРИНТЕ Имеется лабиринт, заданный прямоугольной матрицей из нулей и единиц. Нуль означает стену, единица — проход (рис. 3.2). Разумеется, крайние строки и столбцы матрицы будут состоять полностью из нулей (чтобы не выйти из лабиринта «в никуда»). Матрица считывается из файла. Задача состоит в том, чтобы автоматически построить кратчайший марш- рут между входом и выходом. Стартовой локацией лабиринта (^читается верхний левый угол, финишной — нижний правый. Лабиринт требуется отобразить на экране в виде плоской карты, отметив найденный маршрут ломаной красного цвета. 82
^atalLcius^k Глава 3. Алгоритмы на графах 0000000000 0111001110 0001111000 0111001110 0000000000 Рис. 3.2. Представление лабиринта с помощью текстового файла Существует несколько известных алгоритмов поиска пути в лабиринтах. В этой задаче предлагается воспользоваться так называемым методом вол- новой трассировки. Его можно описать, например, как моделирование по- ведения растекающейся воды. Представьте, что в стартовой локации лабиринта опрокинули бак с водой. Вода растекается во все доступные стороны, заполняя на очередной ите- рации моделирования все локации, граничащие с уже затопленными, пока выход из лабиринта не будет достигнут. Если на очередном шаге не удалось затопить ни одной новой локации, это означает, что решения не существует (то есть путь к выходу огорожен глухой стеной). Затем программа отсле- живает, каким путём вода попала в финишную локацию, и строит маршрут. Найденный таким образом маршрут и окажется кратчайшим. Решение Думаю, решение этой задачи вполне достаточно привести на псевдокоде. Все промежуточные операции будут выполняться в служебном двумерном целочисленном массиве, размеры которого совпадают с размерами лаби- ринта. Изначально элемент массива, соответствующий стартовой локации лаби- ринта, равен единице. Все остальные элементы равны нулю. Далее выпол- няются действия: N = 1 : ЦИКЛ • Р /Д’ ДЛЯ КАЖДОГО элемента массива, равного N - ЕСЛИ текущему элементу массива соответствует финишная локация г решение найдено; конец алгоритма ДЛЯ КАЖДОГО элемента, соседнего с текущим,, проверить условия: . 1) равен ли он нулю • ' . " у 2) есть ли стена между двумя локациями, соответствующими ' рассматриваемым элементам массива - ' '' .ЕСЛИ оба условия выполнены , ' .^присвоить соседнему элементу значение N + 1 - ' КОНЕЦ ЦИКЛА : •> ’ ' ’ .. 83
C++ мастер-класс. 85 нетривиальных проектов, решений и задач ! ЕСЛИ ни' одно значение-N +/ 1- "не" было присвоено решения не существует;'/.конец алгоритма in n + 1.s. . i КОНЕЦ ПИКЛА ‘ 7 Первые три итерации алгоритма иллюстрирует рис. 3.3. В нашем примере при N == 8 алгоритм завершит работу (рис. 3.4). Рис. 3.3. Работа алгоритма волновой трассировки 84
^aiaHaus,^. Глава 3. Алгоритмы на графах Теперь осталось восстановить путь от стартовой локации до финишной. Сделать это несложно: в финишную локацию (которой соответствует число N) мы попали из той соседней с ней локации, которой соответствует число N - 1; в свою очередь, в нее можно попасть из локации N - 2 и так далее до стартовой локации. Разумеется, на каждом шаге необходимо следить, чтобы движение осуществлялось по свободному проходу, а не сквозь стену. 3.2.2. Цветные линии Волновая трассировка в популярной игре В ««Цветные линии» (Color lines) будут играть и тогда, когда третий Doom станет антикварной редкостью, пылящейся на дальнем стеллаже коллек- ционера. Наверное, в рейтинге проектов, через которые проходит каждый программист, «цветные линии» попадут если не в первую десятку, то в пер- вую двадцатку точно. Сейчас я предлагаю вам написать свою версию этой замечательной игры. Процесс игры достаточно прост. На клетчатой доске размером 9><9 появля- ется три цветных шарика (цвета выбираются случайно из палитры в 6 цве- тов, рис. 3.5). Игрок может переместить любой шарик в любую доступную клетку (доступность определяется возможностью построить путь к ней от текущей клетки, перемещаясь по свободным клеткам в горизонтальном и вертикальном направлении). Рис. 3.5. Игра Color lines 85
C++ мастер-класс. 85 нетривиальных проектов, решений и задач После каждого хода в свободных ячейках поля появляются три новых шари- ка. Цель игрока — собрать вертикальную, горизонтальную или диагональ- ную линию из пяти или более шариков. Собранная линия уничтожается, а игрок получает призовые очки. Если поле оказывается заполненным до отказа, это означает конец игры. Подсказка Для построения маршрута движения шарика по игровому полю восполь- зуйтесь алгоритмом волновой трассировки (см. и. 3.2.1). 3.2.3. Игра Square Head Волновая трассировка плюс немного фантазии В Square Head играют на прямоугольном поле размером 32*20 клеток. В на- чальный момент клетки раскрашены случайными цветами (в палитре игры всего семь цветов). Первому игроку принадлежит верхний левый угол поля, второму — правый нижний. Очередным ходом игрок перекрашивает свою угловую клетку в какой-либо новый цвет. В результате перекрашивается вся область клеток цвета игрока, примыкающих к угловой клетке. Иначе го* воря, клетка перекрашивается, если до неё можно дойти из угловой клетки по клеткам одного цвета (ходить можно лишь вправо, влево, вверх и вниз). На рис. 3.6 показано перекрашивание левого верхнего угла (то есть области первого игрока) из белого в серый цвет. Рис. 3.6. Square Head: ход первого игрока Совершая ход, нельзя выбирать в качестве нового цвета области свой теку- щий цвет, а также текущий цвет противника. Как видно из рисунка, при перекрашивании область игрока расширяется (происходит «захват» соседних клеток). Игра заканчивается, как только всё поле будет раскрашено в два цвета, принадлежащие игрокам. Победителем объявляется тот игрок, чья область насчитывает больше клеток. 86
^aiaHaus,^. Глава 3. Алгоритмы на графах Задача заключается в реализации игры Square Head. В программе должны быть предусмотрены режимы игры между двумя людьми и между челове- ком и компьютером (разумеется, для компьютера придётся придумать ра- зумную стратегию). Подсказка Конечно, Square Head не шахматы, но даже здесь изобрести «идеальную» процедуру игры не так просто. Здесь я могу лишь предложить несколько разумных соображений. • Даже простой "жадный" алгоритм (на очередном ходе выбрать цвет, приводящий к наибольшему расширению области игрока) работает достаточно хорошо. Для оценки прироста области можно использо- вать уже знакомый вам алгоритм волновой трассировки. • Можно попробовать не только максимизировать свой выигрыш, но и минимизировать выигрыш оппонента. Предположим, что наиболь- ший прирост области (+10 клеток) даёт выбор красного цвета. При этом соперник выбирает синий цвет и добавляет к своей области, до- пустим, 15 клеток. Если же мы во время своего хода выберем синий, сопернику придётся довольствоваться каким-нибудь другим ходом. Таким образом, признаком лучшего хода будет не максимальная ве- личина прироста своей области, а максимальное значение разности ПриростСвоейОбласти - ПриростОбластиСоперника • Можно представить одиночную партию в Square Head как серию «мини-игр», состоящих из трёх-четырёх ходов. Цель каждой «мини- игры» — максимальное расширение своей области (иными словами, речь идёт об анализе на глубину в 3-4 хода). Такая программа ещё не будет слишком медленной, но сила её игры определённо возрастёт. Конечно, известная в шахматах «проблема горизонта»9 присутству- ет и здесь, но, как мне кажется, её значение в данном случае не очень велико. 3.2.4. Закраска контура, или необычное применение волновой трассировки Требуется написать процедуру закраски произвольного замкнутого конту- ра (начальная точка, откуда начинается закраска, передаётся процедуре). 9 Когда программа пропускает серьёзную атаку соперника, поскольку фиксированная глубина просмотра не позволяет её заметить. 87
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Заметьте, что эту задачу можно решить, моделируя растекание жидкости по всей фигуре из начальной точки, то есть пользуясь уже рассмотренным алгоритмом волновой трассировки (см. п. 3.2.1. “Волновая трассировка"). 88
ГЛАВА 4. РЕКУРСИЯ И ПЕРЕБОР С ВОЗВРАТАМИ. ЭВРИСТИЧЕСКИЙ ПОИСК
Если задачу не удаётся решить, скажем так, научным методом, остаётся лишь воспользоваться перебором вариантов, ибо как известно, полный пе- ребор всегда находит решение задачи. Организация перебора при этом вовсе не является технической формальностью. Как правило, в большинстве за- дач приходится использовать нетривиальные рекурсивные алгоритмы по- иска, да ещё и в сочетании со специальными оптимизациями (в противном случае алгоритм будет работать недопустимо медленно). Иногда скорость поиска можно повысить за счёт использования эвристических алгоритмов. Данная глава начинается с разминочной задачи, посвящённой рекурсивным объектам. Потренировавшись в сочинении рекурсивных процедур, мы пе- рейдём к алгоритмам перебора с возвратами (различного уровня сложнос- ти). Глава завершается изучением процедуры эвристического поиска А*. 4.1. РЕКУРСИВНЫЕ ОБЪЕКТЫ. ФРАКТАЛЬНЫЕ УЗОРЫ Рисование рекурсивных объектов По неформальному определению основоположника фрактальной геомет- рии Бенуа Мандельброта, фрактал — это «структура, состоящая из час- тей, которые в каком-то смысле подобны целому». Такие «самоподобные» объекты нередко встречаются в природе. К ним относятся растения (ветка куста «в каком-то смысле подобна» всему кусту), морские волны, облака. Задав небольшой набор параметров фрактала, можно получить очень жи- вописный, реалистичный рисунок. На практике фракталами действительно пользуются при моделировании пейзажей (например, для компьютерных мультфильмов). Возможно, вам также встречались в интернете галереи ди- ковинных, поразительно сложных и красивых фрактальных узоров. Существует множество приложений для работы с фрактальными объекта- ми. Разработаны методики описания фракталов, такие как системы Лин- 90
^atatiaus^ Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск денмайера (L-системы). Мы остановимся лишь на самых простых примерах фракталов, когда связь между частями и целым очень проста. Рассматриваемая задача заключается в рисовании на экране трёх различ- ных фрактальных объектов К-го порядка (значение К вводится с клавиату- ры) — кривой Коха, салфетки Серпинского и драконовой ломаной. Кривая Коха нулевого порядка представляет собой обыкновенный отрезок. Для получения кривой первого порядка отрезок делится на три равные час- ти, и средний интервал заменяется отрезками, длина каждого из которых равна трети длины исходного отрезка (рис. 4.1). Чтобы получить кривую второго порядка, следует повторить процесс для каждого из четырёх полу- ченных отрезков (рис. 4.2). Повторяя алгоритм, можно сгенерировать кри- вую Коха любого требуемого порядка. Рис. 4.1. Кривая Коха первого порядка Рис. 4.2. Кривая Коха второго порядка Салфетка Серпинского нулевого порядка состоит из закрашенного равно- стороннего треугольника (цветом закраски считается чёрный). Салфетка первого порядка получится, если разделить треугольник на четыре равные части и «извлечь» центральную (то есть снять с неё закраску, рис. 4.3). При повторении процесса для оставшихся закрашенными частей треугольника образуется салфетка второго порядка (рис. 4.4) и так далее. 91
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Рис. 4.3. Салфетка Серпинского первого порядка Драконова ломаная К-го порядка получит- ся, если согнуть полоску бумаги пополам К раз (сгиб всегда производится в одну и ту же сторону), а потом разогнуть так, чтобы каж- дый угол сгиба был равен 90 ~ градусам (рис. 4.5). Можно и v определить драконову лома- Л ную и рекурсивно (подумай- \ те как). ' Рис. 4.4. Салфетка Серпинского второго порядка Решение Рис. 4.5. Драконова ломаная второго порядка Поскольку эта задача требует графических построений, придётся выйти за рамки стандартного C++ и воспользоваться возможностями библиоте- ки VCL, входящей в состав Borland C++Builder. Предположим, что глав- ная форма приложения называется Main Form, а на ней расположен объект PaintSurface типа TImage. Для того чтобы постро- итькривую Коха, необхо- димо научиться чертить приведённую на рис. 4.6 фигуру для любых дан- ных точек (XI, Y1) и (Х2, Y2). Рис.. 4.6. Базовый элемент кривой Коха 92
^lalaHaus^i Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск Точки (Ха, Ya), (Xb, Yb) и (Хс, Yc) определяются по формулам: а = а1ап2(У 2 - У1, Х2 - JT1) R = у1(Х2 - XI)2 + (У2 - И)2 Xa = Xl + yCos(a),Rj = У1 + jsin(a) Xb = XI + у cos(a ), Yb = У1 + ~ sin(a) - - ЖЛ- Z \ TZ tZ z \ Хс - Ха + — cos(a ~у)» ^с= Кг + ysm(a-—) Напомню, что функция atan2 (у, х) возвращает угол между осью абсцисс и отрезком, соединяющим начало координат с точкой (х, у). На псевдокоде алгоритм построения кривой Коха порядка order на отрез- ке (XI, Yl, Х2, Y2) выглядит следующим образом: ЕСЛИ order, - jD-v просто построить отрезок (XI, ¥1,. Х2, Y2) ' Z '/«г ИНАЧЕ •. Г.\ . .. •/ •. построить кривую Коха порядка order - 1'на отрезке (XI,. Y1; Ха, Уй) ’построить кривую Коха порядка order * 1 на отрезке (Ха, Ya, Хс, YC) Построить кривую Коха порядка i - 1 на отрезке (Хс, Yc, Xb, Yb) построить кривую Коха зрэдрса 'jc.'.. t ~ 1 на отрезке (Xb, Yb, Х2, ¥2) Соответствующий код на C++ абсолютно прямолинеен: void Koch(int order, int XI, int Yl, int X2, int Y2) { if(order == 0) { // построить отрезок (Xl, Yl) - (X2, Y2) MainForm->PaintSurface->Canvas->MoveTo(XI, Yl); Ma.inForm->PaintSurf ace->Canvas->LineTo (X2, Y2) ; ) else { double alpha = atan2(Y2 - Yl, Х2 - Xl); double R = sqrt ( (X2 - X1)*(X2 - X1) + (Y2 - Y1)*(Y2 - YD); // вычислить значения Xa, Ya, Xb, Yb, Xc, Yc double Ха = Xl Ya = Yl + + R * R * cos(alpha) / 3, sin(alpha) / 3; double Xc = Xa + R * cos(alpha - M_PI / 3)/3 Yc = Ya + R * sin(alpha - M_PI / 3)/3 93
C++ мастер-класс. 85 нетривиальных проектов, решений и задач double Xb = XI + 2 * R * cos(alpha) * R * sin(alpha) / 3, / 3; Yb = Y1 + 2 Koch(order - 1, XI, Yl, Xa, Ya) ; Koch(order - 1, Xa, Ya, Xc, Yc) ; Koch(order - 1, Xc, Yc, Xb, Yb) ; Koch(order - 1, xb. Yb, X2, Y2) ; Построить кривую N-ro порядка теперь можно с помощью вызова: Koch(N, MainForm->PaintSurface->Width / 4, 2*MainForm->PaintSurface->Height I 3, 3*MainFonn->PaintSurface->Width I 4, 2*MainForm->PaintSurface->Height / 3); Треугольник Серпинского немного сложнее графически, но проще мате- матически. В чертеже треугольника со стороной 1еп, нижний левый угол которого находится в точке ( х, у), нам потребуется знать координаты ещё пяти точек (рис. 4.7). х + 1еп/2. у - len * sqrt(3)/2 х, у х + 1еп/2. у х + len. у Рис. 4.7. Базовый злемент треугольника Серпинского 94
^lalaUaus,^ Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск Следуя традиции, приведу сначала процедуру на псевдокоде: построить чёрный треугольник АВС ЕСЛИ order (порядок треугольника) больше нуля • , s, . . построить белый треугольник MNK построить треугольники Серпинского:порядка ordexf— 1 со стороной len/2 в точках А, м и К Следующая функция реализует приведенный алгоритм на C++: void Sierpinski(int order, int x, int y, int len) { const double s3d2 = sqrt(3) I 2; TCanvas *p = MainForm->PaintSurface->Canvas; X // построить чёрный треугольник ABC p->Pen->Color = clBlack; p->Brush->Color - clBlack; p->MoveTo(x, y); p->LineTo(x + len/2, у - len * s3d2); p->LineTo(x + len, y) ; p->LineTo(x, y); p->FloodFill(x + len/2, у - 1, clBlack, fsBorder); if(order > 0) ( // построить треугольник MNK p->Pen->Color = clWhite; p->Brush->Color = clWhite; p->MoveTo(x + len/2, y) ; p->LineTo(x + len/4, у - len * s3d2/2); p->LineTo(x + 3 * len/4, у - len * s3d2/2); p->LineTo(x + len/2, y); p->FloodFill(x + len/2, у - 1, clWhite, fsBorder); // построить треугольники порядка order - 1 Sierpinski(order - 1, x, y, len / 2); Sierpinski(order - 1, x + len/2, у, len/2); Sierpinski(order - 1, x + len/4, у - len * s3d2/2, len/2); } Для построения треугольника N-го порядка следует выполнить вызов: Sierpinski(N, MainForm->PaintSurface->Width I 4, 2*MainForm->PaintSurface->Height / 3, MainForm->PaintSurface->Width / 2); 95
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Начертить драконову ломаную, пожалуй, несколько труднее. Для начала рассмотрим, каким образом драконова ломаная N-ro порядка строится на основании ломаной порядка N-1 (рис. 4.8). Рис. 4.8. Построение драконовой ломаной При увеличении порядка ломаной каждый сегмент заменяется двумя от- резками, расположенными под прямым углом. Первая пара отрезков нахо- дится слева от сегмента ломаной предыдущего порядка, вторая — справа, третья снова слева и так далее. Построение ломаной удобно разбить на две функции. Первая возвращает массив точек, соответствующих углам ломаной: vector<TPoint> GetDragonPolyline(int order); Вторая (главная) запрашивает ломаную в виде массива точек и выводит её на экран: void Dragon(int order) { vector<TPoint> line = GetDragonPolyline(order); MainForm->PaintSurface->Canvas->MoveTo(line[0].x, line[0].y); for (unsigned i = 1; i < line.sizeO; i++) MainForm->PaintSurface->Canvas->LineTo(line[i].x, line[i].y); } Алгоритму функции GetDragonPolyline () запись на псевдокоде не до- бавит понятности, поэтому я сразу приведу окончательный код функции: vector<TPoint> GetDragonPolyline(int order) { 96
^ataHaus,^. Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск if(order == 0) { // ломаная нулевого порядка состоит из одного сегмента vector<TPoint> line; // добавить первую и последнюю точки line.push_back(TPoint(MainForm->PaintSurface->Width/4, MainForm->PaintSurface->Height/2)); line.push_back(TPoint(3*MainForm->PaintSurface->Width/4, MainForm->PaintSurface->Height/2)); return line; } // получить ломаную предыдущего порядка vector<TPoint> prevline = GetDragonPolyline(order - 1); vector<TPoint> lihe; // знак направления угла (1 — влево, -1 — вправо) int DirSign = 1; // начальная точка ломаной не изменяется line.push_back(prevline[0]); for(unsigned i = 0; i < prevline.size() - 1; i++) { // считать очередной сегмент ломаной TPoint pl = prevline[i]; TPoint p2 =.prevline[i + 1]; double alpha = atan2(p2.y - pl.y, p2.x - pl.x) - DirSign * M_PI / 4; double R = sqrt((pl.x - p2.x)*(pl.x - p2.x) + (pl.y - p2.y)*(pl.y - p2.y)) / sqrt(2); // найти новую точку ломаной (pc) TPoint pc(pl.x + R*cos(alpha), pl.y + R*sin(alpha)); // добавить pc и p2 в список точек текущей ломаной line.push_back(pc); 1ine.push—back(p2); // изменить направление на противоположное DirSign = -DirSign; } return line; 4 Зак. 772 97
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 4.2. ПРОСТОЙ ПОИСК В ИГРАХ И ГОЛОВОЛОМКАХ 4.2.1. Генератор кроссвордов Знакомство с перебором с возвратами В текстовом файле crossword.txt задана конфигурация кроссворда в следующем формате: ШИРИНА ВЫСОТА XI Y1 ДЛИНА1 НАПРАВЛЕНИЕ! Х2 Y2 ДЛИНА2 НАПРАВЛЕНИЕ2 XN YN ДЛИНАМ НАПРАВЛЕНИЕМ Пара координат (Xk, Yk) указывает расположение слова в сетке кроссворда. Параметр ДЛИНА задаёт длину, а НАПРАВЛЕНИЕ — направление слова (v — вертикальное, сверху вниз; h — горизонтальное, слева направо). На- пример, файл 13 4 2 0 4 v О 1 9 h 8 1 3 v 1 3 3 h 7 3 б h задаёт кроссворд, изображённый на рис. 4.9. Второй файл vocabulary.txt содержит словарь, то есть список раз- решённых для использования в кроссвордах слов. Программа должна выдать возможное решение кроссворда с использованием слов словаря либо сооб- щение о том, что решения не существует. 98
^alatfausi^i Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск Решение Эта задача хорошо иллюстрирует методику перебора с возвратами. Допус- тим, в кроссворде предусмотрено N позиций, предназначенных для вписы- вания слов (таким образом, решённый кроссворд будет состоять из N слов). Решение кроссворда сводится к вписыванию некоторого слова в первую свободную позицию и решению «подкроссворда» из N - 1 позиций. Цент- ральное место в алгоритме решения занимает функция Solve (К), заполня- ющая «подкроссворд» начиная с позиции К: bool'Solve(unsigned К) ’. //, корректны лишь позиции-0-. ZN-1 ’ ЕСЛИ К = N, return true ' . . У • -J < •; 7 Д: ЦИКЛ подвеем незанятым словам словаря ЕСЛИ очередное .слово W можно разместить ''на\позйции к Ы «г. V • > V Д*’’ разместить W на позиции К1 г > ЕСЛИ Solve (Кt + 1) > true, return ;txue ' снять слово W с позиции К* , ' * . х, ? КЬНЕЦ ЦИКЛА - ‘ return false > > \! \ Если теперь вызвать функцию Solve () для самой первой (то есть нуле- вой) позиции, мы получим полное решение кроссворда. Рассмотрим устройство этой функции подробнее; Позиция К = N соот- ветствует полностью заполненному кроссворду, поэтому функции остаётся лишь вернуть значение true (успех). Если же текущая позиция подлежит заполнению, алгоритм просматрива- ет все незанятые слова словаря в поисках подходящей кандидатуры. Если очередное слово можно разместить па текущей позиции (то есть оно подхо- дит по длине и не противоречит уже вписанным буквам), то алгоритм впи- сывает слово в кроссворд и вызывает функцию решения «подкроссворда», начинающегося с позиции К + 1. Если «подкроссворд» решить не удаётся (Solve (К + 1) возвращает false), алгоритм пытается выбрать другое подходящее слово для вписывания в позицию К. Займёмся теперь реализацией программы. Для начала нам потребуются структуры, описывающие отдельные буквы, вписываемые в кроссворд, и слова словаря: // буква со счётчиком struct CharAndCounter { char Char; 99
C++ мастер-класс. 85 нетривиальных проектов, решений и задач int Counter; CharAndCounter(char _char = ' ', int „counter = 0) : Char(„char), Counter(„counter) {} }; /z/ элемент словаря struct VocElement { string String; // слово bool Busy; // флаг «занято/не занято» VocElement(const stringb str = bool b = false) : String(str), Busy(b) {} }; Зачем букве счётчик? Дело в том, что на уровне псевдокода не видна одна проблема, связанная с операциями «разместить слово» и «снять слово». Предположим, в некоторую позицию кроссворда процедура Solve () решает вписать слово ПОРТ (рис. 4.10). Рис. 4.10. Размещение очередного слова в клетках кроссворда В итоге эта ветвь перебора заводит в тупик, и теперь алгоритм решения дол- жен снять слово ПОРТ с поля кроссворда. Как видно из рисунка, «снятие» не сводится к простой замене букв П, О, Р и Т пустыми клетками: буква О, входящая в состав ранее вписанного слова КРОТ, должна быть сохранена. Решить эту проблему поможет использование счётчиков. При вписывании очередного слова счётчик, сопоставленный каждой изменяемой клетке, уве- личивается на единицу (рис. 4.11). Рис. 4.11. Использование счетчиков букв 100
^ataHaus,^. Глава4. Рекурсия и переборе возвратами. Эвристический поиск При снятии слова с поля все соответствующие счётчики уменьшаются на единицу. Если счётчик равен нулю, буква заменяется пробельным симво- лом. Поле кроссворда представляет собой двумерный массив объектов типа CharAndCounter, а словарь — массив объектов VocElement: vector<vector<CharAndCounter> > Field; vector<VocElement> Vocabulary; Кроссворд состоит из элементов, описывающих расположение, направле- ние и длину каждого слова: struct WordCoords { static const char VERTICAL = 'v', HORIZONTAL = 'h'; int X, Y; // расположение слова char Dir; // направление слова (горизонтальное / // вертикальное) int Length; // длина слова WordCoords(int _x, int _y, int _len, char _dir) : X(_x), Y(_y), Dir(_dir), Length(_len) {} // горизонтальное и вертикальное смещение очередной // буквы слова относительно предыдущей int dx() { return (Dir == HORIZONTAL) ? 1 : 0; } int dy() { return (Dir == VERTICAL) ? 1 : 0; } vector<WordCoords> Crossword; // описание кроссворда Функция ReadData () считывает описание кроссворда из файла crossword.txt и словаря из файла vocabulary. txt: // служебная функция для сортировки слов по длине bool Less(const VocElementb Ihs, const VocElementb rhs) { return Ihs.String.length() < rhs.String.length(); } //----------------------------------------------------------------- void ReadData() ( ifstream crosswf“crossword.txt*), voc(“vocabulary.txt") ; string temp; // считать последовательно все слова словаря while(!voc.eof()) ( 101
C++ мастер-класс. 85 нетривиальных проектов, решений и задач voc » temp; Vocabulary.push_back(VocElement(temp, false)); } // отсортировать словарь по длине слов sort(Vocabulary.begin(), Vocabulary.end(), Less); // считать описание кроссворда int W, H, x, у, len; char dir; // ширина и высота поля crossw » W; crossw » H; for(;;) ( // считать очередной элемент описания crossw » х; crossw » у; crossw » len; crossw » dir; if(crossw.eof()) break; Crossword.push_back(WordCoords(x, y, len, dir)); } // заполнить всё поле пустыми символами for(int i = 0; i < W; i++) { vector<CharAndCounter> col(H); fill(col.begin(), col.endO, CharAndCounter()); Field.push_back(col); } } Сортировка словаря по длине слов поможет в дальнейшем ускорить работу программы. Впрочем, об этом позже, а сейчас рассмотрим три важные фун- кции — CanPlace(), PlaceWord () и RemoveWord(): // можно ли разместить слово word на позиции с? // (предполагается, что длина слова нас устраивает, требуется лишь // определить соответствие букв слова уже имеющимся на поле // буквам) bool CanPlace(WordCoords с, const string& word) ( for(unsigned i = 0; i < word.length(); i++) { // если очередная просматриваемая ячейка непуста //и при этом символ в ней не соответствует i-му символу слова if(Field[c.X + i*c.dx()][c.Y + i*c.dy()].Char !=''&& Field[c.X + i*c.dx()][c.Y + i*c.dy()].Char != word[i]) return false; // слово нельзя разместить на позиции с } return true; } 102
^aiattaus,^. Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск //------------------------------------------------------------------ // разместить слово word в позиции с (предполагается, что это // возможно) void PlaceWord(WordCoords с, const strings word) { forfunsigned i - 0; i < word.length(); i++) { Fi.eld[c.X + i*c.dx()][c.Y + i*c.dy () ] .Char = word[i]; Fieldfc.X + i*c.dx()][c.Y + i*c.dy()].Counter++; } } //------------------------------------------------------------------ // снять слово word с позиции c void RemoveWord(WordCoords c, const strings word) { for(unsigned i = 0; i < word.length(); i++) { if(--Field[c.X + i*c.dx()][c.Y + i*c.dy()].Counter == 0) Field[c.X + i*c.dx()][c.Y + i*c.dy()].Char = ' } } Основная функция решения Solve () соответствует псевдокоду, приведён- ному выше: bool Solve(unsigned CoordNo) { if(CoordNo == Crossword.size()) // если «подкроссворд» пуст return true; // получить диапазон слов, длина каждого из которых // равна Crossword[CoordNo].Length pair<vector<VocElement>::iterator, vector<VocElement>::iterator> range - equal_range(Vocabulary.begin(), Vocabulary.end (), string(Crossword[CoordNo].Length, ' '), Less); // цикл по словам словаря for(vector<VocElement>::iterator p = range.first; p != range.second; p++) if(!p->Busy SS CanPlace(Crossword[CoordNo], p->String)) { // если слово не занято // и его можно разместить на позиции Crossword[CoordNo] PlaceWord(Crossword[CoordNo], p->String); // разместить // слово p->Busy = true; // теперь слово занято if(Solve(CoordNo + 1)) // если «подкроссворд» решается return true; RemoveWord(Crossword[CoordNo], p->String); // снять // слово 103
C++ мастер-класс. 85 нетривиальных проектов, решений и задач p->Busy = false; // пометить слово как незанятое } return false; } Как указано в псевдокоде, алгоритм Solve () перебирает все незанятые слова словаря. Для ускорения работы программы я внёс небольшое улуч- шение: зачем перебирать все слова, если заведомо известно, что лишь слова длины Crossword [CoordNo] . Length могут быть успешно размещены в позиции Crossword [CoordNo ] кроссворда? С помощью функции equal range () из сортированного словаря мож- но «за один присест» извлечь поддиапазон слов, длины которых равны Crossword[CoordNo] .Length. Поскольку определённая выше фун- кция Less () сравнивает длины слов, в качестве третьего аргумента equal_range () может использоваться любая строка требуемой длины - например, строка, заполненная Crossword [CoordNo] . Length пробелами. Осталось лишь запрограммировать простую функцию main (): int main(int argc, char* argv[]) { // считать параметры кроссворда ReadData(); if(Solve(O)) { // если решение найдено, // распечатать содержимое Field for(unsigned у = 0; у < Field[0].size(); y++) { for(unsigned x = 0; x < Field.size(); x++) cout « Field[x][y].Char; cout << endl; } } else cout << “нет решений*; return 0; } Пример входных данных: содержимое файла crossword, txt: 13 4 2 0 4 v 0 1 9 h 8 1 3 v 1 3 3 h 7 3 6 h 104
4\aiatiaus^i Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск содержимое файла vocabulary, txt: компьютер омар бра рот утварь крот сервер аромат радио В результате работы программы получится следующий кроссворд: о компьютер а о бра утварь 4.2.2. Японские кроссворды Оптимизированный перебор с возвратами Требуется напи- сать программу, способную решать классические (чёр- но-белые) японские кроссворды. Суть разгадывания японского крос- сворда состоит в нахождении изобра- жения по числовым подсказкам. Изна- чально «игровое поле» — белая клет- чатая доска — пусто (на рис. 4.12 пока- зан уже решённый кроссворд). Рис. 4.12. Пример японского кроссворда 105
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Числа, расположенные за пределами поля, являются подсказками; они из- вестны заранее. Подсказки говорят, сколько наборов подряд идущих за- крашенных клеток находятся на данной горизонтали/вертикали, и какова длина каждой из них. Так, слева от самой верхней горизонтали написаны числа 2 и 2. Это означает, что горизонталь содержит два набора по две за- крашенные клетки в каждой. Программа должна считывать файл с подсказками (хранящийся в любой удобной форме) и выводить пользователю решение головоломки. Подсказка Решение «в лоб» заключается в программировании простой функции пе- ребора с возвратами (см. п. 4.2.1). По-видимому, любой разумный алгоритм так или иначе будет использовать перебор, однако оптимизировать его не так легко. Анализу приёмов сокращения перебора в «японских кроссвордах» пос- вящена статья Ильи Пйрублёва «Решения “японского кроссворда’’», расположенная по адресу http://algolist.manual.ru/misc/ japancross.php. 4.2.3. Игра «Королевская балда» Поиск НАИЛУЧШЕГО ХОДА В ИГРЕ Итак, требуется написать программу для игры в «королевскую балду». Иг- рает человек против компьютера. Начинает компьютер. В середине поля размером 5х5 клеток пишется слово из пяти букв. Человек должен приду- мать новое слово, состоящее из Находящихся на поле букв и обязательно одной новой (добавляемой на поле) буквы. На уровне интерфейса это выглядит так. Компьютер позволяет человеку поставить букву в любую свободную клетку, затем человек указывает (на- пример, последовательно «прощёлкивая» мышью клетки), каким именно образом новое слово следует читать. Слова могут образовываться из букв, расположенных последовательно в любом направлении от клетки к клетке вверх, вниз, влево или вправо, но не по диагонали и без самопересечений. Чем длиннее новое слово, тем больше очков за него даётся (по очку за бук- ву). Затем ход переходит к сопернику. Одинаковые слова не допускаются. Если игрок не в состоянии придумать очередное слово (это относится как к человеку, так и к компьютеру), он может пропустить ход. 106
NalaHausit Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск Разберём для примера типичное начало игры (рис. 4.13). Исходное слово («ВЫВОД») записывается в середине поля. Первый игрок добавляет букву О, тем самым образуя слово «ОВОД» (C2-C3-D3-E3). Второй игрок выкла- дывает на поле букву А, подразумевая слово «ВОДА» (C3-D3-E3-E4). Игра завершается при заполнении последней клетки поля либо при возник- новении ситуации, когда ни один из игроков не может сделать ход. Выигры- вает тот, у кого больше очков. Обычно в «балде» допускаются лишь существительные в именительном падеже, единственном числе. В программе должна быть предусмотрена функция пополнения словаря: если человек составляет слово, неизвестное компьютеру, компьютер добавляет его в свою базу данных. Для игры можно использовать простую «жадную стратегию»: на очередном ходе выбирается слово, даютцее наибольший прирост очков (даже если это грозит упущен- ными возможностями в будущем). Подсказка Очевидное решение заключается в использовании методики, описанной в задаче 4.2.1. На псевдокоде её адаптация выглядит следующим образом: ЦИКЛ по всем свободным клеткам поля, смежным с какой-либо заполненной клеткой - ' ' ЦИКЛ по всем буквам русского алфавита построить список цепочек клеток, включающих текущую клетку выбрать из цепочек корректные слова (из словаря игры) // (*) КОНЕЦ ЦИКЛА КОНЕЦ ЦИКЛА 107
C++ мастер-класс. 85 нетривиальных проектов, решений и задач В строке, помеченной звёздочкой, мы знаем позицию текущей клетки, теку- щую просматриваемую букву и список корректных слов, соответствующих данному варианту хода. Остаётся лишь выбрать ход, которому соответству- ет самое длинное слово. Несколько неочевидной может быть процедура получения всех цепочек клеток, включающих данную. Здесь опять используется переборный алго- ритм: начиная от текущей клетки нужно рекурсивно изучить каждую «до- рожку», начинающуюся сверху, слева, снизу и справа от текущей. Изучение очередной дорожки заканчивается либо в случае самопересечения, либо при выходе за пределы уже заполненных буквами клеток. Обратите внима- ние, что размещаемая игроком буква не обязана начинать или заканчивать слово, поэтому «дорожка» может продолжаться в две стороны от текущей клетки. Программа, прямолинейно реализующая приведённый псевдокод окажется очень медленной. Не хочу вас лишать удовольствия самостоятельно про- вести оптимизацию, но некоторые идеи лежат на поверхности. Например, можно попробовать сразу построить все находящиеся на игровом поле це- почки клеток, в которых не хватает одной буквы до полноценного слова. Да- лее с помощью словаря игры находится подходящее слово и, следовательно, буква, размещаемая на данном ходе. 4.2.4. Пентамино, или ещё одна задача НА ПЕРЕБОР С ВОЗВРАТАМИ С клавиатуры вводятся числа М и N. Требуется определить, можно ли пол- ностью замостить прямоугольник NxM фигурами пентамино (рис. 4.14). При решении можно использовать либо — ГП |~~[ |||| все 12 фигур, либо меньшее количество, — — ~~ но не допускается класть на доску одну — ~ ~ ~ — и ту же фигуру дважды. При установке — — —] — на поле фигуры пентамино разрешается Р-, поворачивать и переворачивать лицевой г—। гп _ ---1 стороной вниз (при этом получится зер- — ------ C_U-------------’ кальное отражение). | | II _ Компьютер должен выдавать решение (например, выводить прямоугольник, в —г—1 [— |—। гп котором различные фигуры закрашены I----'---------------г-, разными цветами) либо печатать сооб- । I-L..LJ 1—1 щение о том, что решения не существует. Рис. 4.14. Набор фигур пентамино 108
^alaHausrtk Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск 4.2.5. Кубики сома - трёхмерный аналог полимино Кубики сома — это трёхмерный аналог фигур полимино. Полный набор ку- биков сома состоит из семи фигур (одна из них составлена из трёх единич- ных кубиков, остальные шесть — из четырёх) и представлен на рис. 4.15. Переформулировка предыдущей задачи для кубиков сома звучит так: со- ставить из всех семи кубиков куб размером 3x3. Я предлагаю рассмотреть общий случай составления заданной трёхмерной фигуры из семи кубиков сома. Во входном файле хранится описание составляемой фигуры. Пожалуй, про- ще всего это сделать с помощью списка прямоугольных матриц, состоящих из нулей и единиц. Каждая матрица задаёт вид очередного слоя фигуры (от нижнего к верхнему). Программа находит решение (в отличие от предыдущей задачи требуется использовать все кубики) и выводит построенную фигуру на экран. Мож- но для наглядности изобразить её с нескольких сторон. Отдельные кубики сома выделяются разными цветами. Для затравки можно попытаться построить фигуры из книги Мартина Гар- днера «Математические головоломки и развлечения» (Москва, «Мир», 1999 г.) (рис. 4.16). Так, «Авианосец» задаётся набором матриц: 111111111 111111111 001111100 000000000 000111000 000000000 000010000 000000000 Рис. 4.15. Набор кубиков сома 109
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Рис. 4.16. Фигуры для сборки из кубиков сома 110
NataHaus^ Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск 4.2.6. Головоломка с домино - несколько более СЛОЖНЫЙ СЛУЧАЙ ПЕРЕБОРА Эту задачу я позаимствовал из книги Л.П. Мочалова «Головоломки: Книга для учащихся» (Москва, «Просвещение», 1996 г.) Требуется выстроить слово ИГРА (рис. 4.17) из всех 28 костей домино таким образом, чтобы на соприкасающихся половинках костей были напи- саны одинаковые числа (то есть по обычным правилам игры). Количество очков на каждой из букв должно быть одним и тем же. Рис. 4.17. Составление слова ИГРА из костей домино 4.2.7. Людоеды и миссионеры Поиск решения известной головоломки Написать программу, решающую классическую головоломку «людоеды и миссионеры». На одном из берегов реки находятся три людоеда и три миссионера. Их за- дача — переправиться на другой берег. В распоряжении у компании имеется лишь одна двухместная лодка, поэтому придётся совершить несколько рей- дов на другой берег и назад. Обратите внимание, что на двухместной лодке можно перевезти как двух, так и одного человека. Задача усложняется ещё одним не очень приятным фактом: если количест- во людоедов на одном из берегов в какой-то момент времени превысит ко- личество миссионеров, миссионеры будут немедленно съедены, чего допус- тить никак нельзя. Программа должна печатать последовательность перевозок, обеспечиваю- щую безопасную переправу. 111
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Решение С первого взгляда видно, что задача может быть решена перебором. Видно также, что перебор не так уж велик (не шахматы всё-таки). Вопрос в том, как грамотно его организовать. На практике алгоритм оказывается сравнительно простым, поэтому я ре- шил сразу привести листинг на C++ со всеми необходимыми комментари- ями. Для начала нам потребуются типы данных «расположение лодки» и «ситу- ация»: // лодка находится на одном из берегов enum BoatPos { LEFT, RIGHT }; struct Situation ( int LC, RC; // количество людоедов на левом и правом берегу int LM, RM; // количество миссионеров на левом и правом берегу BoatPos Pos; /I расположение лодки string Move; // «ход» — комментарий, объясняющий, каким образом // данная ситуация была достигнута Situation(int 1с, int rc, int Im, int rm, BoatPos pos, const strings move) : LC(lc), RC(rc), LM(lm), RM(rm), Pos(pos), Move(move) {} void Print() // вывести содержимое ситуации на экран ( cout << LC « " : " << LM « “ - " << RC << *':"<< RM « " *; cout « (Pos == LEFT ? “left" : "right") « " (" << Move « ")\n"; } // простая операция сравнения, игнорирующая значение Move bool operator==(const Situations rhs) const ( return LC == rhs.LC SS RC == rhs.RC SS LM == rhs.LM SS RM == rhs.RM SS Pos == rhs.Pos; } Единственным заслуживающим отдельного пояснения элементом структу- ры Situation является поле Move. Алгоритму перебора безразлично, ка- ким путём мы добрались до той или иной ситуации в головоломке. Однако при выводе решения на экран пользователю будет гораздо удобнее видеть конкретные ходы, а не только расположение объектов игрового мира. 112
ftalattausfik Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск Последовательность ходов, ведущая к решению, хранится в векторе Trace: vector<Situation> Trace; Основная работа выполняется функцией Solve (), производящей поиск решения подзадачи, исходное состояние которой задано передаваемыми аргументами: void Solve(int LC, int RC, int LM, int RM, BoatPos Pos, const string Move = "") { // условный оператор выявляет ситуацию, при которой продолжение // исследования текущей ветви не имеет смысла if((LC > LM && LM > 0) || (RC > RM &.& RM > 0) I I LC < 0 I I RC < 0 II LM < 0 || RM < 0 II find(Trace.begin(), Trace.end(). Situation(LC, RC, LM, RM, Pos, "")) != Trace.end()) return; // запомнить текущую ситуацию в истории ходов Trace.push_back(Situation(LC, RC, LM, RM, Pos, Move)); if(RC == 3 && RM == 3) { // если на правом берегу три людоеда и три // миссионера, задача решена, вывести решение for_each(Trace.begin(), Trace.end(), mem_fun_ref(&Situation::Prine)); //и завершить работу программы exit(0); } if(Pos == LEFT) // лодка находится на левом берегу { // виды перевозок: // два людоеда Solve(LC - 2, RC + 2, LM, RM, RIGHT, *CC"); // людоед и миссионер Solve(LC -1, RC+1, LM-1, RM+1, RIGHT, "CM"); // два миссионера Solve(LC, RC, LM - 2, RM + 2, RIGHT, "MM"); // один людоед Solve(LC - 1, RC + 1, LM, RM, RIGHT, "C"); // один миссионер SolvefLC, RC, LM - 1, LM + 1, RIGHT, "M") ; } else if(Pos == RIGHT) // лодка находится на правом берегу { // виды перевозок: // два людоеда Solve(LC + 2, RC - 2, LM, RM, LEFT, "CC"); 113
C++ мастер-класс. 8S нетривиальных проектов, решений и задач // людоед и миссионер Solve(LC + 1, RC - 1, LM + 1, RM - 1, LEFT, "CM” // два миссионера Solve(LC, RC, LM + 2, RM - 2, LEFT, , "MM"); // один людоед Solve(LC +1, RC - 1, LM, RM, LEFT, , "C"); } // один миссионер Solve(LC, RC, LM + 1, LM - 1, LEFT, "M"); // извлечь текущую ситуацию из истории ходов Trace.pop_back(); Функция main() всего лишь вызывает Solve () для начальной ситуации, в которой все миссионеры, все людоеды и лодка находятся на левом берегу реки: int main(int argc, char* argv[]) { Solve(3, 0, 3, 0, LEFT); return 0; } Первым делом с помощью громоздкого оператора i f .проверяется перспек- тивность анализируемой ветви. Ветвь неперспективна, если выполняется хотя бы одно из условий: • количество людоедов на одном из берегов превышает количество миссионеров1 (LC > LM && LM > 0 или RC > RM && RM > 0); • величина какого-либо параметра станет отрицательной вели- чиной — на берегу не могут находиться, например, «минус два мис- сионера» (LC < 01| RC < 01| LM < 011 RM < 0); • текущая ситуация уже встречалась в процессе работы алгорит- ма, то есть мы ходим по кругу (find (Trace.begin (•) , Trace, end(), Situation(LC, RC, LM, RM, Pos, "")) != Trace, end()). Если ситуация перспективна, следует испытать каждый возможный вари- ант переправы. Например, запись Solve(LC, RC, LM - 2, RM + 2, RIGHT, "MM"); означает перевозку двух миссионеров с левого берега на правый. Количес- тво людоедов на каждом берегу не меняется (LC, LR), зато два миссионе- 1 Обратите внимание, что количество миссионеров должно быть ненулевым, поскольку лишь в этом случае людоеды могут кого-то съесть. 114
ftalaHausiiik. Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск ра «вычитаются» с левого берега (LM - 2) и «добавляются» к правому (RM + 2 ). Лодка после этого действия находится на правом берегу. Вероятно, фрагмент с восемью вызовами Solve () можно легко ужать вдвое, но при этом, как мне кажется, пострадает ясность. Результат работы готовой программы показан ниже: 3:3 - 0:0 left О 1:3 - 2:0 right (СО 2:3 - 1:0 left (С) 0:3 -3:0 right (СО 1:3 - 2:0 left (С) 1:1 -2:2 right (ММ) 2:2 -1:1 left (СМ) 2:0 - 1:3 right (ММ) 3:0 - 0:3 left (С) 1:0 - 2:3 right (СС) 2:0 - 1:3 left (С) 0:0 - 3:3 right (СС) 4.2.8. Раскраска карт (последний пример ПЕРЕБОРА С ВОЗВРАТАМИ) Географическая карта состоит из N областей (государств или администра- тивных единиц). Каждая область идентифицируется при помощи порядко- вого номера в диапазоне 1...N. Входной файл содержит список пар областей, являющихся смежными, то есть имеющих общую границу. Программа должна приписать каждой области некоторый цвет таким об- разом, чтобы никакие две соседние области не были бы раскрашены одина- ково. Требуется использовать минимально возможное количество цветов. Доказано, что для любой плоской карты достаточно четырёх цветов (или меньше). На выходе печатается схема раскраски — список пар вида (номер__области, код_цвета). 4.3. ЭВРИСТИЧЕСКИЙ ПОИСК 4.3.1. Игра в 15, или эвристический поиск А* Суть задачи Игра в 15 — старая, очень известная головоломка. В коробке размером 4x4 находится 15 пронумерованных от 1 до 15 фишек. Шестнадцатая ячейка 115
C++ мастер-класс. 85 нетривиальных проектов, решений и задач коробки пуста. Цель игры состоит в том, чтобы с помощью последователь- ности сдвигов расположить фишки по возрастанию номеров (рис. 4.18). Требуется приспособить компьютер для решения этой головоломки. Чело- век вводит начальную конфигурацию кубиков коробки, а компьютер печа- тает список кубиков, сдвигаемых на каждом шаге. Не для всякой конфигурации решение существует. Например, если отсор- тировать фишки по возрастанию, а затем поменять местами номера 14 и 15, решить головоломку не удастся. Разумеется, в такой ситуации пользовате- ля придётся огорчить соответствующим сообщением2. Подсказка Идея, что задача представляет собой поиск вершины в графе (конфигура- ции — вершины, ходы — рёбра), лежит на поверхности. Полный перебор вершин (такой, как поиск в ширину), конечно, всегда находит решение, но в нашем случае граф задачи велик, и как-то ускорить этот процесс необхо- димо. Считаю уместным кратко описать здесь так называемый эвристический по- иск А*, который может пригодиться при решении задачи. В самом общем виде поиск в графе представляет собой нехитрую процедуру. Формируется список вершин, подлежащих рассмотрению. Изначально в него помещается только стартовая вершина графа. Далее по некоторому правилу из списка извлекается и просматривается очередная вершина, а её «соседи» — вер- шины, непосредственно связанные с нею рёбрами — добавляются в список. Поиск заканчивается, как только целевая вершина найдена. Если список 2 Надо признаться, что на практике для «Игры в 15» так вряд ли получится. Граф игры огромен, и его полный перебор потребует недопустимо долгого времени. 116
^alaHaus,^. Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск вершин пуст, это означает, что мы уже просмотрели весь граф и так и не достигли цели. Правило выбора очередной рассматриваемой вершины из списка и опреде- ляет вид поиска. Например, если всегда выбирать вершины, относящиеся к самому «глубокому» уровню, мы получим поиск в глубину (depth-first search). А если не переходить к соседям потомков до того, как будут полно- стью рассмотрены соседи предков, получится поиск в ширину (breadth-first search). Идея поиска А* заключается в выборе на очередном шаге такой вершины из списка, для которой сумма цены и эвристики была бы минимальной. Цена в нашем случае просто представляет собой количество ходов, которое пришлось сделать, чтобы добраться до этой вершины из стартовой конфи- гурации. Цена стартовой вершины равна нулю, цена любой соседней с ней — единице, цена соседа соседней — двойке и так далее. Обратите внимание, что из любой вершины, являющейся соседней для стартовой, можно также вернуться в саму стартовую вершину. Метод поиска не обязан запоминать, какие вершины уже просмотрены, поэтому стартовая вершина опять по- падёт список, но уже с ценой, равной двойке. Можно модифицировать поиск так, чтобы просмотренные вершины запо- минались. Забегая вперёд, скажу, что для решения задачи «Игра в 15» спи- сок просмотренных вершин был бы полезен. Эвристика является оценкой количества ходов от текущей позиции до вер- шины, соответствующей решению головоломки. Эвристика — оценка опти- мистичная, то есть её величина не должна превосходить истинного количес- тва ходов, требуемого для нахождения решения. Например, если заведомо известно, что за пять ходов головоломка решается, значение эвристики бу- дет числом в промежутке 0-5. При этом эвристика должна быть настолько близкой к истине, насколько возможно, иначе пользы от неё окажется мало. Так, эвристика, всюду равная нулю, допустима, но совершенно бесполезна. При использовании эвристического поиска в первую очередь рассматри- ваются наиболее перспективные ходы, ведущие прямо к цели. Быстрота нахождения решения напрямую зависит от качества эвристики. Заметьте, однако, что здесь требуется найти определённый компромисс: хорошая эвристика может требовать большего времени для вычисления и, следова- тельно, замедлять поиск. Поиск А* — очень полезная, широко используемая на практике методика. Например, с её помощью можно найти кратчайший путь между двумя точ- ками на карте. Для маленьких карт обычно применяют известный алгоритм Дейкстры, но его производительность для больших, разветвлённых графов 117
C++ мастер-класс. 85 нетривиальных проектов, решений и задач может оказаться неприемлемой. Нередко скорость работы алгоритма поис- ка пути критична сама по себе, например при расчёте маршрутов юнитов в стратегиях реального времени. Решение Рассмотрим идею А*-поиска на уровне псевдокода. Предположим, что в го- лове очереди с приоритетами Q всегда находится элемент с минимальной суммой цены и эвристики: добавить стартовое состояние в очередь Q ПОКА очередь Q не пуста V = головной элемент очереди Q извлечь.головной элемент из Q ЕСЛИ V является целевым состоянием "вывести решение на экран конец алгоритма ИНАЧЕ-' ; ' . ‘ •' добавить в Q вершины-потомки V КОНЕЦ ЦИКЛА , Повторюсь, хотя в общем случае процедура А* не обязывает программиста хранить все просмотренные вершины, для «игры в 15» это требуется, чтобы восстановить полный ход решения. Кроме того, список просмотренных вер- шин поможет исключить повторную обработку проанализированных ранее ситуаций. Чтобы определить ход решения, придётся приписать каждой рассматрива- емой вершине указатель на вершину-родитель (то есть вершину, из кото- рой мы перешли в текущую). Указатель, приписанный стартовой вершине, равен NULL. Если V — финишная вершина, то ход решения (от финишной вершины к стартовой) определяется с помощью несложного алгоритма: ПОКА V О NULL < , напечатать V V = вершина-родитель v s < КОНЕЦ ЦИКЛА Программирование поиска в графе особых творческих усилий не требует. Этого не скажешь об изобретении хорошей (быстрой и качественной) эв- ристической функции. Здесь я решил остановиться на очень простом алго- ритме, использующем понятие манхэттенского расстояния между кубиком (фишкой) и целью: расстояние = abs(X . - X ) + abs(Y , - Y ) кубика цели кубика цели 118
^laiattaus^. Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск Например, если кубик, помеченный единицей (Хцели - 0, Уцели - 0), находит- ся в ячейке (1,2) коробки, расстояние для него равно abs(l - 0) + abs(2 - 0) = 3. Для «игры в 15» манхэттенское расстояние всегда будет оптимистич- ной оценкой. Действительно, переместить кубик, помеченный единицей, в верхний левый угол коробки не удастся быстрее, чем за три хода. Сумма расстояний, вычисленных для каждого из кубиков, и будет состав- лять эвристику вершины. Рассмотрим теперь полное решение задачи. Для начала нам понадобится новый тип данных «вершина»: struct Vertex { int State[4][4]; // конфигурация кубиков Vertex *Ancestor; // указатель на вершину-родитель int Cost, Heuristics; // цена и эвристика // вычислить эвристику на основе данных State void RecalculateHeuristics(); // вывести на экран содержимое вершины void Print(); }; Вычисление эвристики производится по описанной выше схеме. Пустая клетка помечается нулём («нулевой кубик»): void Vertex::RecalculateHeuristics() { // строка и столбец каждого кубика (0, 1, 2, ..., 15) int RowOf[] = { 3, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3 }; int ColOf[] = { 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2 }; int г = 0; for(int i = 0; i < 4; i++) for(int j = 0; j < 4; j++) // для «нулевого кубика» расстояние не вычисляется .if (State [i] [j ] != 0) г += abs(RowOf[State[i][j] ] - i) + abs(ColOf[State[i][j ] ] - j); Heuristics = r; Функция Print () служит для печати состояния игры на очередном шаге: void Vertex::Print() { for(int i = 0; i < 4; i++) 119
C++ мастер-класс. 85 нетривиальных проектов, решений и задач { // вывести очередную строку copy(&State[i][0], &State[i][3] + 1, ostream_iterator<int>(cout, • ")); cout « endl; } cout << endl; } Теперь можно определить очередь с приоритетами Queue и множество всех известных вершин Vertices: // вспомогательная функция для сортировки элементов очереди struct PQSorter { bool operator()(vertex *lhs, Vertex *rhs) { return lhs->Cost + lhs->Heuristics > rhs->Cdst + rhs->Heuristics; } }; 11 функция для сравнения содержимого двух вершин, // адресуемых указателями struct CompareVPtrs : public binary_function<Vertex*, Vertex*, bool> { bool operator()(Vertex *lhs, Vertex *rhs) ( // сравнить содержимое массивов lhs->State и rhs-State return equal((int *)lhs->State, (int *)lhs->State + 16, (int *)rhs->State); } } CompareVP; priority_queue<Vertex*, deque<Vertex*>, PQSorter> Queue; set<Vertex*> Vertices; Функция Initialize () считывает с клавиатуры начальную конфигура- цию головоломки и создаёт стартовую вершину, с которой начинается по- иск решения: void Initialize() { Vertex* StartingState - new Vertex; string line; // считать с клавиатуры четыре строки for(int i = 0; i < 4; i++) { getline(cin, line); istringstream is(line); // каждая строка содержит четыре числа for(int j = 0; j < 4; j++) is » StartingState->State[i][j]; 120
^ataHaus,^. Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск } // цена стартовой вершины равна нулю StartingState->Cost = 0; StartingState->RecalculateHeuristics(); // вершины-родителя не существует StartingState->Ancestor = NULL; // добавить вершину в очередь с приоритетами Queue.push(StartingState); У/ и в список известных вершин Vertices.insert(StartingState); cout « endl; } Одна из наиболее важных процедур — получение вершин-потомков для лю- бой заданной вершины: // добавить в список вершины, соседние с GameState void AddNeighbours(Vertex* GameState) { int zi, z j ; // найти «нулевой кубик» и получить его координаты zi, zj for(int i = 0; i < 4; i++) for(int j =0; j <4; j++) if(GameState->State[i][j] == 0) { zi = i; zj = j; break; } int di[] = {-1, 0, 1, 0}, dj[] = {0, -1, 0, 1}; // «нулевой кубик» можно сдвинуть в любую //из четырёх сторон for(int k = 0; k < 4; k++) { // i, j — новые координаты «нулевого кубика» int i = zi + di [k] ; int j = zj + dj [k] ; // если «нулевой кубик» не выходит за пределы коробки if(i >= 0 && j >= 0 && i <= 3 && j <= 3) { // создать новую вершину Vertex* v = new Vertex; // скопировать в неё содержимое текущей сору((int*)GameState->State, (int*)GameState + 16, (int *)v->State); // новая позиция «нулевого кубика» v->State[i][j] = 0; // а на его месте — кубик (i, j) состояния GameState v->State[zi][zj] = GameState->StateLi][j]; 121
C++ мастер-класс. 85 нетривиальных проектов, решений и задач // цена = цена_вершины-родителя + 1 v->Cost = Gamestate->Cost + 1; v->RecalculateHeuristics(); v->Ancestor = Gamestate; // если вершина с эквивалентным содержимым не рассмот- // рена ранее (для сравнения вершин используется // функция CompагeVP) if(find_if(Vertices.begin(), Vertices.end(), bind2nd(CompareVP, v)) == Vertices.end()) { Queue.push(v); // добавить вершину в очередь Vertices.insert(v); //ив список известных вершин } else // удалить уже рассмотренную вершину delete v; Ради экономии места я использовал стандартную (медленную) версию функции поиска, чтобы определить, находится ли уже вершина, эквивален- тная текущей, во множестве Vertices. Лучше, конечно, использовать би- нарный поиск, но для этого придётся приложить дополнительные усилия. Чтобы обнаружить финишную вершину, нам потребуется функция IsGoal (): // является ли данная вершина финишной? bool IsGoal(Vertex* s) { // расположение кубиков в финишной вершине int Goal[4][4] = ( (1, 2, 3, 4}, {5, 6, 7, 8), {9, 10, 11, 12}, (13, 14, 15, 0} }; // сравнить содержимое вершины s с массивом Goal return equal((int *)s->State, (int *)s->State + 16, (int *)Goal); } Осталось запрограммировать несложную функцию main (): int main(int argc, char* argv[]) { Initialize();// считать начальную конфигурацию int с = 0; // счётчик итераций whi1е(!Queue.empty()) { 122
^lalaUaus^i Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск // извлечь головной элемент очереди Vertex* v = Queue.top(); Queue.pop(); C++; // если найдено решение if(IsGoal(v)) { // вывести все состояния на пути от финишной // конфигурации до стартовой while(v != NULL) v->Print(); v = v->Ancestor; cout « с « " вершин рассмотрено"; break; ) else // решение не найдено: добавить в очередь вершины-потомки AddNeighbours(v); // освободить память for(set<Vertex*>::iterator р = Vertices.begin(); p != Vertices.end(); p++) delete *p; return 0; Пример. Поиск решения головоломки для начального состояния 12 3 4 5 6 7 8 9 10 11 12 15 13 14 0 потребовал выполнения программой 670 итераций: 12 3 4 5 6 7 8 9 10 11 12 13 14 15 О 12 3 4 5 6 7 8 9 10 11 О 13 14 15 12 123
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 12 3 4 5 6 7 8 9 10 0 11 13 14 15 12 12 3 4 5 6 7 8 10 15 11 12 12 3 4 5 6 7 8 9 10 15 11 13 14 0 12 9 0 13 14 12 3 4 5 6 7 8 10 0 11 12 12 3 4 5 6 7 8 9 10 15 11 13 0 14 12 9 15 13 14 12 3 4 5 6 7 8 0 10 11 12 12 3 4 5 6 7 8 9 10 15 11 0 13 14 12 9 15 13 14 12 3 4 5 6 7 8 9 10 11 12 12 3 4 5 6 7 8 0 10 15 11 9 13 14 12 0 15 13 14 12 3 4 5 6 7 8 9 10 11 12 12 3 4 5 6 7 8 10 0 15 11 9 13 14 12 15 0 13 14 12 3 4 5 6 7 8 9 10 И 12 12 3 4 5 6 7 8 10 15 0 11 9 13 14 12 15 13 0 14 12 3 4 5 6 7 8 9 10 11 12 12 3 4 5 6 7 8 10 15 11 0 9 13 14 12 15 13 14 0 670 вершин рассмотрено 12 3 4 5 6 7 8 10 15 11 12 9 13 14 0 12 3 4 5 6 7 8 10 15 11 12 9 13 0 14 124
^alaHausrik Глава 4. Рекурсия и перебор с возвратами. Эвристический поиск 4.3.2. Игры со сдвигающимися блоками «Игра в 15» — не единственная головоломка, в которой требуется передви- гать блоки внутри прямоугольной коробки. Сняв ограничения на форму от- дельных блоков и немного переформулировав цель задачи, можно получить множество превосходных головоломок, давно ставших классикой жанра на- стольных игр. Рассмотрим, например, головоломку Moving Day (рис. 4.19). Цель головоломки — передви- нуть блок с роялем, изначально находящийся в верхнем левом углу коробки, в левый нижний угол. Как и в «игре в 15», для манёвров у вас имеется небольшое свободное пространство, од- нако использовать его грамотно теперь гораздо сложнее. Вообще цель большинства игр со сдвигающими- ся блоками состоит в перемещении какого-либо блока в заранее определённое место на доске. Например, в головоломке Gridloc #1 (рис. 4.20) требуется передвинуть блок с автомобилем из левого верхнего угла доски в правый нижний угол. Рис 4.20. Головоломка Gridloc #1 Рис. 4.19. Головоломка Moving Day Первое задание заключается в програм- мировании интерфейса, при помощи которого человек мог бы загружать и решать любую головоломку со сдвига- ющимися блоками. Головоломку удобно хранить в виде текстового файла, в кото- ром каждый блок кодируется набором латинских букв, а пустое пространство — нулями. Например, задаче Moving Day будет соответствовать матрица: ААВВ ААСС DE00 FGHH 125
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Где-то в файле необходимо также указать «главный» блок (то есть блок, пе- ремещение которого и составляет головоломку) и расположение целевой области в игровой доске. Для задачи Moving Day эта информация кодиру- ется двумя строками: А 3 1 Здесь указано, что целью головоломки является перемещение блока, зако- дированного буквами А, в область, верхний левый угол которой задаётся координатами (3,1) (третья строка, первый столбец). Второе, более амбициозное, задание состоит в реализации алгоритма, авто- матически решающего любую загруженную головоломку. 126
ГЛАВА 5. ВИЗУАЛИЗАЦИЯ И АНИМАЦИЯ
Ещё лет двадцать назад компьютеры называли электронно-вычислительны- ми машинами. Современный компьютер представляет собой нечто большее, чем просто усовершенствованный программируемый калькулятор. Изучая программирование, нельзя обойти стороной его мультимедийные возмож- ности, к которым относятся визуализация и анимация. В данной главе мы рассмотрим визуализацию, в частности, на примерах физической модели («Планетарная система») и нотной азбуки («Буквы и звуки»). Анимация реализуется в видеоиграх («Космическая дуэль») и в различного рода за- ставках («Скринсейвер»). С визуализацией также связан интересный алго- ритм «черепашьей» графики («Черепашья графика»). 5.1. ПЛАНЕТАРНАЯ СИСТЕМА. УНИВЕРСАЛЬНАЯ ДЕМОНСТРАЦИОННАЯ АСТРОНОМИЧЕСКАЯ МОДЕЛЬ Должно быть, вам приходилось видеть разного рода симпатичные анимиро- ванные схемы, иллюстрирующие движение планет Солнечной системы. Я предлагаю запрограммировать универсальную модель, пригодную для ви- зуализации любой планетарной системы, состоящей из небесных тел, вра- щающихся друг около друга. Входные данные программы записаны в текстовом файле. Каждая строка описывает одну планету с помощью пяти значений: ID BASEID P_RAD ORB_RAD SPEED Каждый элемент строки, кроме SPEED, является целым числом. Элемент SPEED представляет собой число вещественное. • Элемент ID — это уникальный идентификатор планеты. Планета с иден- тификатором 0 является центром системы: у неё могут быть спутники, но сама она не может быть спутником какой-либо другой планеты. 128
^laitMaus^. Глава 5. Визуализация и анимация • BASEID — это идентификатор «базовой» планеты, спутником которой является текущая (описываемая) планета. • P_RAD — радиус планеты (в пикселях). • ORB RAD — радиус орбиты планеты (в пикселях). Орбита считается круговой. • SPEED — угловая скорость планеты (градусов в секунду). Идеально точной скорости работы программы не требуется. В частности, можно обновлять экран десять раз в секунду, считая, что графический вывод происходит мгновенно. Значения BASEID, ORB_RAD и SPEED для центральной планеты системы игнорируются. Программа должна считать входной файл и вывести анимированную схе- му планетарной системы. Неподвижная центральная планета помещается в центр экрана. 5.2. «ЧЕРЕПАШЬЯ» ГРАФИКА - НЕСТАНДАРТНАЯ МОДЕЛЬ РИСОВАНИЯ Нередко задача становится гораздо проще, если взглянуть на неё под иным углом зрения. Рассмотрим, например, такую обычную операцию как вычер- чивание различных фигур на экране компьютера. Как правило, графическая библиотека поставляет функции вроде «провести отрезок», «нарисовать окружность», «закрасить область» и тому подобные. Если требуется начер- тить треугольник, квадрат или какую-либо сложную фигуру, состоящую из набора отрезков, удобство таких функций под сомнение не ставится. Одна- ко ситуация меняется, как только встаёт необходимость изобразить, напри- мер, одну из показанных на рис. 5.1 спиралей. Рис. 5.1. Спирали для рисования «черепашкой» 5 Заж .772 129
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Если хорошо подумать, можно, конечно, и здесь обойтись функциями выво- да точек и отрезков, однако можно и упростить себе задачу, если изменить взгляд на саму идеологию рисования на экране. Представьте себе, что точке (0, 0) экрана находится так называемая «чере- пашка» (при работе с «черепашкой» обычно считают началом координат левый нижний угол экрана, ось ординат направлена вверх). Эта «черепаш- ка» умеет ползать по экрану, и по первому вашему требованию направится, куда ни пожелаете. Кроме того, у неё в лапках есть карандаш, которым она может отмечать свой путь. Единого стандарта «черепашьего языка» не существует, по мы вполне мо- жем ограничиться следующими командами: • U - поднять карандаш, перейдя в режим простого движения (не ос- тавлять за собой следа); • D - опустить карандаш, перейдя в режим рисования; • Gn - пройти вперёд (по направлению движения) п пикселей; • Та - повернуться на угол а градусов (если угол положительный, то влево, иначе вправо). Изначально для «черепашки» установлен режим простого движения, «смот- рит» она строго вверх. В качестве простого примера программы на «черепашьем языке» можно привести процедуру рисования прямоугольника размером 100 х 50 пиксе- лей: DG50T-90G100T-90G50T-90G100 Если предположить, что функция Turtle () выполняет строку команд на «черепашьем языке», то спираль, изображённая на рисунке слева, будет на- рисована в результате выполнения довольно простой программы на C++: // вспомогательная функция преобразования числа в строку std::string ToString(int х) { std::ostringstream stream; stream « x; return stream.str(); } void main() { // перевести «черепашку» ближе к центру экрана Turtle("T-50G300D"); double len = 1; 130
^ataHaus,^. Глава 5. Визуализация и анимация for(int i = 0; i < 300; i++) { Turtle("G'’ + ToString(len) + "T10"); len += 0.1; } Идея «черепашьей графики» восходит, по крайней мере, к языку LOGO (1966 г.). Позже аналогичные возможности были добавлены в популярные версии Бейсика. Сейчас «черепаший» подход нередко используют, напри- мер, при вычерчивании фрактальных изображений. Теперь осталось реализовать функцию Turtle (). Основная программа должна предоставлять возможность пользователю вводить строку на «че- репашьем языке» и чертить соответствующий рисунок на экране. Все чис- ленные аргументы можно считать целыми. 5.3. КОСМИЧЕСКАЯ ДУЭЛЬ, ИЛИ «ПРОВОЛОЧНАЯ» ГРАФИКА В ДЕЙСТВИИ Итак, нужно запрограммировать игру для двух человек Space Duel. Суть игры заключается в следующем: в замкнутом пространстве (рис. 5.2) лета- ют два вооружённых космических корабля. Управление каждым кораблём осуществляется с помощью пяти клавиш: поворот вле- во, поворот вправо, ускоре- ние, торможение и выстрел. Изначально корабли на- ходятся на случайных (но непересекаюгцихся) пози- циях. Корабль, достигающий края экрана, отражается от него, сохраняя скорость. Угол па- дения равен углу отражения. Цель игры — выстрелом по- разить корабль соперника. 131
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Пока выпущенный игроком снаряд находится в полёте, следующий выстрел сделать нельзя (иначе игра превратится в сплошной обстрел). Игроку, поразившему соперника, засчитывается одно очко. Если корабли столкнулись между собой, оба корабля взрываются, очки никому не при- суждаются. Взорвавшийся корабль возникает в случайном месте экрана (но, естественно, не поверх корабля соперника). Игра заканчивается, как только один из соперников наберёт пять очков. Корабли проще всего нарисовать с помощью отрезков прямых. Можно вы- брать более простые формы, чем изображены на рисунке. Для движения и поворота кораблей используйте функции из п. 2.1.3 «Проволочная графи- ка». 5.4. ЭВРИСТИЧЕСКИЙ ПОИСК И СОКОБАН Непростая работа для А* Требуется написать классическую игру «Сокобан» (что по-японски означа- ет «кладовщик»). Цель игры — растолкать ящики по отмеченным на уровне позициям. Уровень условно состоит из клеток, каждая из которых либо пус- та, либо содержит непроходимую стену (рис. 5.3). Перемещения по уровню также дискретные: за один ход герой сдвигается на одну клетку в любую из четырёх сторон. Ящики можно лишь толкать (таким образом, затолкав ящик в угол, его не удастся оттуда вытащить). Два (или более) подряд стоящих ящика с места не столкнуть (слишком тяжело). На рис. 5.3 один из шести ящиков (в пра- вой части) уже находится в целевой позиции. 132
Глава 5. Визуализация и анимация Программа должна загружать уровень из файла, а затем передавать управ- ление пользователю. В случае победы выводится соответствующее сообще- ние. Должна быть также предусмотрена возможность начать игру заново, если играющий понимает, что он попал в безвыходное положение. Дополнительное задание. Попробуйте написать программу, которая решает уровни «Сокобана» автоматически. Подсказка Идеологически решение головоломки «Сокобан» похоже на «игру в 15» (п. 4.3.1), поэтому я советую воспользоваться эвристическим поиском А*. Правда, изобрести хорошую эвристику для данной игры гораздо труднее. Насколько мне известно, идеально работающего алгоритма ещё йе изобре- тено. 5.5. ВИЗУАЛИЗАЦИЯ ПРОСТОГО ТРЕХМЕРНОГО МИРА Трехмерный лабиринт Пусть у нас имеется лабиринт, заданный прямоугольной матрицей из нулей и единиц (как в п. 3.2.1). Нуль означает стену, единица — проход (рис.3.2). Стартовой локацией лабиринта снова считается верхний левый угол, фи- нишной — нижний правый. На сей раз автоматически решать лабиринт не требуется. Вместо этого предлагается написать игру «трёхмерный лаби- ринт», позволяющую блуждать по лабиринту игроку-человеку. Программа рисует на экране текущую локацию так, как её видит путешест- венник (рис. 5.4). Рис 5.4. Вид лабиринта изнутри 133
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Движение путешественника управляется курсорными клавишами: (пово- рот влево на 90°, поворот вправо на 90°, шаг на локацию вперёд, разворот на 180°). Если достигнута финишная локация, выводится сообщение о победе. Необязательно реализовывать настоящую трёхмерную графику, поскольку здесь нет ни плавных поворотов, ни непрерывных движений. Достаточно выводить статичный чертёж текущей локации, а после любого действия иг- рока обновлять содержимое экрана (дискретно, без анимации). Для простоты можно считать, что в лабиринте ист «залов», то есть прямо- угольных участков без стен размером 2x2 локации или больше. Наличие «залов» порождает новые проблемы. Например, если путешественник ви- дит прямо перед собой большое помещение, в дальнем правом углу которо- го располагается одна стена, эту стену придется как-то изобразить на экра- не. Если же «залов» пет, программе достаточно начертить: • стену лабиринта прямо по курсу; • набор стен и проходов по левую руку; • набор стен и проходов по правую руку. Таким образом, при вычерчивании текущего вида лабиринта вполне доста- точно изучить «коридор» до ближайшей стены по ходу движения и содер- жимое ближайших клеток слева и справа от «коридора». Решение Перед тем, как приступить к разработке, мне кажется разумным показать скриншот готовой программы, чтобы создать представление о том, что мы в конечном итоге должны получить (рис. 5.5). Рис. 5.5. Внешний вид приложения «Трехмерный лабиринт» 134
^atatiaus^i Глава 5. Визуализация и анимация Большую часть формы занимает трёхмерное изображение лабиринта. Ввер- ху расположено поле ТМето, содержащее карту лабиринта с отмеченной текущей позицией путешественника. Нулями обозначаются стены, едини- цами — проходы. Обозначение текущей позиции зависит от направления движения: • Л — вверх • v — вниз • < — влево • > — вправо С алгоритмической точки зрения программа не представляет собой ничего сложного, но технические нюансы имеются. Я предполагаю, что создано приложение с главной формой Main Form, на которой расположен занимающий всю форму элемент DrawingArea типа Т Image (на него будет производиться весь графический вывод), а также элемент MazeMap типа ТМето (карта лабиринта). В свойствах Мето-поля лучше установить какой-либо моноширинный шрифт (например, Courier New), чтобы ширина карты была одной и той же для каждой горизонта- ли. Кроме того, карта должна быть доступна только для чтения (свойство Readonly). Основным элементом лабиринта является боковая стена (рис. 5.6). FrontW Рис. 5.6. Боковая стена лабиринта 135
C++ мастер-класс. 85 нетривиальных проектов, решений и задач В программе стена представлена типом данных Wall и константами innerH, OuterH и FrontW, задающими размеры самой большой (ближайшей к гла- зам путешественника) стены: // размеры самой большой стены const int InnerH = 300; const int OuterH = 400; const int FrontW = 50; //----------------------------------------------------------------- struct Wall { // размеры стены и полуразность высот L, упрощающая рисование int InnerH, OuterH, FrontW, L; // направление enum WallType ( LEFTWALL, RIGHTWALL }; // создать стену с наружной высотой outerh, // используя пропорции «эталонной» стены Wall(int outerh) { double С = outerh I double(::OuterH); OuterH - outerh; InnerH = ::InnerH * C; FrontW = ::FrontW * C; // полуразность высот L = (OuterH - InnerH) / 2; } // изобразить стену типа type (левую или правую) в точке X, Y void Draw(int X, int Y, WallType type) { TCanvas *p = MainForm->DrawingArea->Canvas; TPoint points[] = { TPoint(X, Y), TPoint(X, Y + OuterH), TPoint(X + (type == LEFTWALL ? 1 : -1) * FrontW, Y + L + InnerH), TPoint(X + (type == LEFTWALL ? 1 : -1) * FrontW, Y + L) }; p->Polygon(points, 3); TRect r = (type == LEFTWALL) ? RectfO, Y, X + 1, Y + OuterH +1) : Rect(p->ClipRect.Width(), Y, X - 1, Y + OuterH + 1); p->Rectangle(r); } }; Обратите внимание, что все рисуемые фигуры закрашиваются белым цве- том, установленным по умолчанию в качестве цвета закраски. Дело в том, что стены, расположенные ближе к человеку, частично перекрывают стены 136
^lamttaus,^. Глава 5. Визуализация и анимация более далёкие. Прямоугольная часть каждой стены, изображающая проход, видна лишь тогда, когда проход реально существует. Если же на этом месте в карте лабиринта стоит нуль, более близкая стена будет нарисована прямо поверх прямоугольника. Такой, быть может, идеологически не самый красивый подход позволяет заметно сократить программу. Мы просто рисуем стены на месте нулей, не думая об изображении проходов. Направление движения путешественника сохраняется в переменной типа Direction: enum Direction { LEFT = 0, UP = 1, RIGHT = 2, DOWN = 3}; Для хранения информации о лабиринте и о текущей позиции путешествен- ника используются глобальные переменные: vector<string> Maze; // лабиринт (массив строк из нулей и единиц) int PosX = 1, PosY = 1; It начальная позиция человека (левый верхний угол) int TargetX, TargetY; // целевая позиция человека Direction Dir = RIGHT; // начальное направление человека (направо) При работе с направлением движения путешественника нам потребуются четыре служебные функции: // вернуть направление путешественника после пЬворота налево Direction LeftDiг(Direction d) { return (d - 1 < LEFT) ? DOWN : d - 1; } // вернуть направление путешественника после поворота направо Direction RightDir(Direction d) { return (d + 1 > DOWN) ? LEFT : d + 1; } // вернуть смещение по оси X при каждом шаге в сторону dir int GetDX(Direction dir) { int DX[] = { -1, 0, 1, 0 }; return DX[dir]; } // вернуть смещение по оси Y при каждом шаге в сторону dir int GetDY(Direction dir) ( int DY[] = { 0, -1, 0, 1 }; return DYfdir]; } Поскольку лабиринт загружается из файла, напишем функцию его загруз- ки LoadMaze(): void LoadMaze() { 137
C++ мастер-класс. 85 нетривиальных проектов, решений и задач string s; // загрузить лабиринт из файла maze.txt ifstream in("maze.txt"); while(in » s) // считать очередную строку Maze.push_back(s) ; // координаты правого нижнего угла TargetX = s.length О - 2; TargetY = Maze.size() - 2; } Самая сложная часть программы — вывод на экран трёхмерного чертежа ла- биринта. Дополнительная трудность заключается в том, что нам придётся рисовать, начиная с самых дальних стен, поскольку ближние стены должны их частично перекрывать. Основная работа будет выполняться функцией DrawLayers (), рисую- щей все стены на расстоянии Depth локаций от путешественника и даль- ше. На вход передаются Х-координаты левой и правой стен уровня Depth (XL и XR), их же Y-координата и высота OuterH (одинаковые для обеих стен), глубина Depth и максимальная просматриваемая человеком глубина Max Depth. На расстоянии Max Depth локаций выводится стена, располо- женная прямо по курсу движения. Рассмотрим код функции DrawLayers (): void DrawLayers(int XL, int XR, int Y, int outerh, int Depth, int MaxDepth) { // если мы добрались до самой дальней стены if(Depth == MaxDepth) ( // нарисовать её левую, правую и центральную секции // (три прямоугольника) MainForm->DrawingArea->Canvas->Rectangle(XL, Y, XR,’ Y + outerh); MainForm->DrawingArea->Canvas->Rectangle(0, Y, XL + 1, Y + outerh); MainForm->DrawingArea->Canvas~>Rectangle(XR - 1, Y, MainForm->Width, Y + outerh); } else { // левая и правая стена уровня Depth Wall lw(outerh), rw(outerh); // изобразить все более далёкие стены DrawLayers(XL + lw.FrontW, XR - rw.FrontW + 1, Y + Iw.L, lw.InnerH, Depth + 1, MaxDepth); 138
^laiaHaus,^. Глава 5. Визуализация и анимация // вычислить координаты локации в лабиринте, в которой // путешественник окажется через Depth шагов в текущем направлении int CurrentX = PosX + Depth*GetDX(Dir); int CurrentY = PosY + Depth*GetDY(Dir); // если слева от этой локации находится стена, изобразить её if(Maze[CurrentY + GeCDY(LefCDir(Dir))].aC(CurrenCX + GeCDX(LefCDir(Dir))) == '0') lw.Draw(XL, Y, Wall::LEFTWALL); // аналогично, по необходимости изобразить правую стену if(Maze[Currency + GeCDY(RighCDir(Dir))].aC(CurrenCX + GeCDX(RighCDir(Dir))) ~= '0') rw.Draw(XR, Y, WallRIGHTWALL) ; } } Основная функция рисования вычисляет просматриваемую глубину MaxDepth, вызывает алгоритм рисования DrawLayers (), а затем обнов- ляет содержимое карты Ma z eMap: void DrawMazeO { int MaxDepth = 1; // пока не встретили стену в текущем направлении while(Maze[PosY + GeCDY(Dir)*MaxDepth].at(PosX + GeCDX(Dir)*MaxDepth) == '1') MaxDepth++; // нарисовать все стены (начиная с глубины 1) DrawLayers(0, MainForm->Width, (MainForm->HeighC - OuterH), OuCerH, 1, MaxDepth); // изобразить карту лабиринта char DirChars[] = { '<', 'Л', '>', 'v' }; MainForm->MazeMap->Lines->Clear(); for(int i = 0; i < Maze.sizeO; i++) ( string s = Maze[i] // в точке PosX, PosY обозначить путешественника if(i == PosY) s.at(PosX) = DirChars[Dir]; MainForm->MazeMap->Lines->Add(s.c_str()); } } Осталось написать лишь две функции, выполняющие, соответственно, ини- циализацию программы и обработку событий клавиатуры. Инициализация выполняется в конструкторе формы: 139
C++ мастер-класс. 85 нетривиальных проектов, решений и задач __fastcall TMainForm::TMainForm(TComponent* Owner) : TForm(Owner) { LoadMaze(); DrawMaze(); ) Клавиши обрабатываются (событие OnKeyPress формы) довольно прямо- линейно1: void __fastcall TMainForm::FormKeyPress(TObject ‘Sender, char &Key) ( // используются кнопки w, a, s, d if(Key == 'w') // движение на одну локацию вперёд { // если впереди свободная локация, движемся if(Maze[PosY + GetDY(Dir)1.at(PosX + GetDX(Dir)) == '1') { PosX += GetDX(Dir); PosY += GetDY(Dir); ) } else if(Key == 's') // разворот на 180 градусов { Dir = LeftDir (LeftDir(Dir)) ; // то же, что и дважды // повернуть налево ) else if(Key == 'а') // поворот налево { Dir = LeftDir(Dir); ) else if(Key == 'd') // поворот направо { Dir = RightDir(Dir); } // очистить экран и нарисовать лабиринт DrawingArea->Canvas->Rectangle(0, 0, Width, Height); DrawMaze(); // если достигнута целевая локация if (PosX == TargetX &.& PosY == TargetY) { Application->MessageBox("Целевая локация достигнута!", "Победа!", 0); Application->Terminate(); } 1 Не забудьте только установить в true значение свойства KeyPreview формы, иначе все события будут относиться к Мето полю. 140
^ataUauslik. Глава 5. Визуализация и анимация 5.6. БУКВЫ И ЗВУКИ. ПРОСТОЙ МУЗЫКАЛЬНЫЙ РЕДАКТОР Одноголосую мелодию можно хранить в обычном текстовом файле, если воспользоваться, например, следующими обозначениями: • Сочетания ОО, О1 и 02 отвечают за выбор текущей октавы (малая, первая, вторая). • Команды Li, L2, L4, L8, L16, L32 и L64 задают длительность исполь- зуемых нот и пауз (начиная с данного момента). • Сами ноты кодируются стандартным образом: С — до, D — ре, Е — ми, F — фа, G—соль, А — ля, Н — си. Пауза обозначается буквой Р. Знаки диеза (#) и бемоля (Ь) ставятся сразу после ноты: С#, Gb. Пример мелодии: O1L8CDEFGAHO2C — гамма до-мажор. Знак октавы действует до тех пор, пока в строке не встретился другой знак октавы. Аналогично работает знак длительности. Диезы и бемоли относят- ся лишь к ноте, непосредственно предшествующей знаку. Требуется написать простой музыкальный редактор для одноголосых ме- лодий. В процессе редактирования пользователь наносит ноты прямо на нотный стан; преобразование мелодии из графического представления в текстовое производится лишь при сохранении файла на диск. Разумеется, должна быть предусмотрена возможность прокрутки нотоносца влево и вправо, чтобы не ограничивать пользователя шириной одного экрана. 5.7. ГЕНЕАЛОГИЧЕСКОЕ ДРЕВО (ПРЕДСТАВЛЕНИЕ И ВИЗУАЛИЗАЦИЯ ДРЕВОВИДНЫХ ДАННЫХ) Требуется написать простую программу для построения генеалогических древ. В режиме редактирования пользователь вносит в систему новых лю- дей, а также указывает отношения вида «родитель-ребёнок», то есть для каждого человека выбирает его родителей из списка. Количество родителей варьируется от нуля (если родители неизвестны) до двух (оба родителя из- вестны). Занесённая информация сохраняется в текстовом файле следующего фор- мата. Сначала записываются числовые идентификаторы и имена людей: 1 Пётр Иванов 2 Сергей Иванов 3 Александр Петров 141
г C++ мастер-класс. 85 нетривиальных проектов, решений и задач 4 Мария Озерова Следом располагаются пары вида (идентификатор_родителя, идентифи- катор_ребёнка): 12 43 В режиме отображения программа строит на экране генеалогическое древо для любого запрошенного пользователем человека, то есть выводит челове- ка и всех его известных предков в виде бинарного дерева. 5.8. СКРИНСЕЙВЕР - ДЕЛАЕМ ПРОСТУЮ, НО ЭФФЕКТНУЮ АНИМАЦИЮ Написать скринсейвер «фигуры», устроенный следующим образом. По экрану летают несколько (количество задаётся в конфигурацион- ном файле) шариков. Их начальные направления и скорости случайны; движение — равномерное, прямолинейное. Долетев до любого края экрана, шарик отражается от него (угол падения равен углу отражения) и продол- жает движение в новом направлении. Некоторые шарики соединены между собой отрезками одного цвета (что- бы получить не набор шариков, а настоящие фигуры). Отрезки являются как бы резиновыми: соединённые шарики летают независимо друг от друга и наличие связи никак не влияет на их поведение (линия растягивается и сжимается во время движения). Слово «скринсейвер» не стоит воспринимать буквально. Цель не в том, что- бы запрограммировать заставку по всем правилам Windows (хотя это при- ветствуется); достаточно создать приложение, выводящее на экран описан- ный анимированный узор. 142
ftalattausfii!. ГЛАВА 6. ОБУЧАЮЩИЕСЯ ПРОГРАММЫ
г Не всегда компьютерная программа способна грамотно распорядиться вход- ными данными без предварительной подготовки. Если требуется отсорти- ровать по возрастанию набор чисел, никакой подготовки не нужно; если же стоит задача определить, на что больше похож переданный объект — на круг или на прямоугольник, программе необходимо объяснить, чем круг отли- чается от прямоугольника. Иногда эти сведения можно явно задать в коде, но порою гораздо удобнее «научить* программу самостоятельно принимать решения, заранее «натренировав* её на типичных множествах кругов и пря- моугольников. Аналогично, программа может сама научиться играть в крес- тики-нолики, проведя с человеком серию партий. В данной главе мы познакомимся с алгоритмами, помимо чисто процедур- ных средств использующими «память* о каком-либо предшествующем опыте. Изучив общую задачу классификации и кластеризации, мы рассмот- рим, как обучение применяется, например, при выявлении авторства текста (п. 6.1.3) и распознавании языка документа (п. 6.1.4). Вторая часть главы посвящена самообучающимся алгоритмам, самостоятельно делающим вы- воды из собственных успехов и неудач. 6.1. КЛАССИФИКАЦИЯ И КЛАСТЕРИЗАЦИЯ 6.1.1. Классификация и кластеризация Алгоритмы KNN и C-Means Задача классификации и кластеризации — почти столь же общая и важная, как, скажем, сортировка или поиск. Тем не менее, почему-то авторы учебни- ков по информатике очень часто обходят её стороной. Даже начинающий программист обычно умеет отсортировать массив «пузырьком*, но пожи- мает плечами, услышав о классификации. Надеюсь, что в результате выпол- нения этого задания вы по достоинству оцените мощь и пользу описывае- мых здесь методов. 144
Глава 6. Обучающиеся программы Приятной особенностью алгоритмов сортировки и поиска является их обоб- щенность. Алгоритму сортировки всё равно, что сортировать: целые числа, апельсины или плюшевых медведей. Требуется лишь передать ему отноше- ние порядка для элементов сортируемой коллекции, то есть написать фун- кцию, определяющую, какой из двух данных объектов является большим, а какой — меньшим. То же самое можно сказать об универсальных методах классификации и кластеризации: достаточно ввести во множестве классифицируемых объек- тов метрику — и алгоритмы к вашим услугам. Осталось пояснить, что такое метрика. С точки зрения математики, это фун- кция f(x, у), сопоставляющая двум объектам множества некоторое число и обладающая четырьмя свойствами: • f(x,y)>0; • у) = f(y, х); • если f(x, у) = 0, то х совпадает с у и наоборот; • f(x, z) + f(z, у) > f(x, у), где z — любой объект классифицируемого множества. На практике термин «метрика» обычно можно считать синонимом слова «расстояние». Если вы будете думать о метрике именно в таком ключе, то изобретаемые вами функции автоматически будут удовлетворять всем тре- буемым условиям. Рассмотрим, например, расстояние между двумя точками (А и В) на плос- кости. Здесь и выдумывать ничего не надо, достаточно лишь воспользовать- ся известной формулой Евклида: /(А, В) = 7(^-B,)2+(A-V Как можно убедиться, она отвечает всем четырём требованиям, предъявля- емым к метрике: • расстояние между двумя точками всегда неотрицательно; • расстояние от А до В равно расстоянию от В до А; • если расстояние между точками равно нулю, то точки совпадают и наоборот; • путь от А до В по прямой не длиннее пути, проходящего через промежуточную точку Z. Определить расстояние между точками на плоскости или в пространстве нетрудно. Гораздо сложнее изобрести адекватную метрику, вычисляющую 145
C++ мастер-класс. 85 нетривиальных проектов, решений и задач «расстояние» между двумя текстовыми документами или, скажем, мелоди- ями. Впрочем, задали, в которых требуется йридумать свою собственную метрику, ещё встретятся в книге, а пока вернёмся к алгоритмам классифи- кации и кластеризации. Задача кластеризации формулируется следующим образом. Даётся мно- жество объектов и количество кластеров, то есть «ящиков», по которым элементы исходного множества можно распределять. Требуется «раски* дать» объекты по кластерам так, чтобы в одном кластере оказались элемен- ты, наиболее близкие друг к другу в соответствии с данной метрикой. Допустим, исходное множество состоит из телескопов, Чебурашек и велоси- педов. Допустим также, что изобретённая нами метрика гарантирует: любой телескоп будет «ближе» к другому телескопу, чем к любому Чебурашке или велосипеду. Аналогично, Чебурашки ближе к Чебурашкам, а велосипеды — к велосипедам. Таким образом, если попросить программу распределить объекты по трём кластерам, все телескопы окажутся в первом «ящике», Чебурашки во вто- ром, а велосипеды в третьем. Интересны (хотя и лишены особого смысла) ситуации с двумя или четырьмя кластерами. В первом случае алгоритму придётся поместить какие-то разнородные объекты в один и тот же клас- тер, а во втором, наоборот, однородные объекты окажутся помещёнными в разные «ящички» (впрочем, не исключён и случай пустого четвёртого кластера). В качестве примера можно привести результат кластеризации множества двумерных точек (рис. 6.1). Количество кластеров равно четырём. Рис. 6.1. Кластеризованное множество двумерных точек 146
4\atattaus^i‘ Глава 6; Обучающиеся программы Для кластеризации произвольной коллекции можно использовать, напри- мер, несложный алгоритм C-Means: сформировать М кластеров < г приписать каждый элемент коллекции случайному кластеру ЦИКЛ . ..... 4 найти центроид (центральный элемент) каждого кластера приписать каждый объект кластеру с. наиболее близким к объекту центроидом " ПОКА происходят изменения , Центроидом называется элемент кластера, «наилучшим образом» ему соот- ветствующий: сумма расстояний от центроида до других элементов кластера минимальна. Задача классификации заключается в помещении нового объекта в самый под- ходящий из уже существующих кластеров. Наверное, самым простым алгоритмом классификации является метод KNN (К nearest neighbors). В качестве параметра алгоритма задаётся число К. Для нового объекта определяются К ближайших элементов рассортиро- ванной по кластерам коллекции. Далее производится «голосование»: каж- дый элемент «голосует» за свой кластер. В итоге новый объект приписыва- ется к победившему кластеру. Для разминки можно запрограммировать алгоритмы KNN и C-Means для точек на плоскости. На первом шаге программа должна считывать из входного файла cmeans.txt координаты точек коллекции, принимать с клавиатуры число формируе- мых кластеров М и по алгоритму C-Means «раскидывать» точки по М клас- терам. Полученные кластеры следует визуализировать, как на рис. 6.1. Во входном файле knn.txt находятся координаты новых точек. Второй шаг программы состоит в классификации этих точек по методу KNN (значение К вводится с клавиатуры). Обратите внимание, что первая часть коллекции (передаваемая на вход ал- горитму C-Means) должна адекватно описывать всю коллекцию (то есть со- держать репрезентативную выборку точек). В противном случае результаты работы метода KNN вас разочаруют. Предположим, первая часть коллекции содержит лишь точки с координатами, лежащими в промежутке от нуля до четырёх. Задав М - 2, мы получим какие-то два кластера. Далее, допустим, вторая часть коллекции (классифицируемая методом KNN) сплошь содер- жит элементы, сосредоточенные вокруг точки (20,20) (рис.6.2). 147
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Рис 6.2. Неудачная классификация методом KNN По логике вещей, эти точки должны быть сгруппированы в свой собствен- ный кластер, но поздно! Кластеры сформированы, и всё, что может сделать KNN — это поместить новые элементы в уже существующие «ящики», даже если полученная классификация будет совершенно неадекватной. Решение Здесь мы рассмотрим только решение задачи кластеризации методом С-Means. Алгоритм KNN используется в задачах 6.3 и 6.4, и приводить же один и тот же фрагмент кода в двух местах книги мне кажется излишним. Хотя по условию задачи требуется написать программу, выполняющую кластеризацию двумерных точек, обобщённая версия алгоритма выглядит ненамного сложнее. Пусть элементы произвольного типа Туре находятся в некотором контей- нере. Считается, что в программе определена функция расстояния double Distance(const Туре& Ihs, const Type& rhs) возвращающая расстояние между любыми двумя объектами типа Туре. Иаша задача — написать функцию Cluster (), по заданным итераторам начала и конца диапазона объектов и количеству кластеров М формирую- щую требуемые кластеры: 148
^alattaus,^. Глава 6. Обучающиеся программы template <class Iterators vectorclistctypename Iterator::value_type> > Cluster(const Iterator begin, const Iterator end, int M) Возвращаемое значение представляет собой вектор, каждый элемент которого является отдельным кластером — списком объектов типа Iterator::value_type. Для начала нам потребуется несложная служебная функция Centroid (), возвращающая центроид набора объектов, задаваемого итераторами begin и end: template cclass Iterator> Iterator::value_type Centroid(const Iterator begin, const Iterator end) { double MinDist = numeric_limitscdouble>::max(); Iterator::value_type Element; // пробежаться по всем элементам диапазона for(Iterator current = begin; current != end; current++) ( // вычислить сумму расстояний между текущим // элементом и всеми остальными double Di st = ef- fort Iterator c = begin; c != end; C++) Dist += Distance(‘current, *c); // найти элемент, для которого эта сумма минимальна if(Dist с MinDist) { MinDist = Dist; Element = ‘current; } } return Element; ) Функция Cluster () реализует алгоритм, приведенный в постановке зада- чи на псевдокоде: template cclass Iterator> vectorclistctypename Iterator: :value_type> > Cluster (const Iterator begin., const Iterator end, int M) ( // создать M кластеров vectorclistclterator::value_type> > Clusters(M); // приписать каждый объект к случайному кластеру for(Iterator с = begin; с != end; C++) Clusters[random(М)].push_back(*c); vectorclterator::value_type> Centroids(M); 149
C++ мастер-класс. 85 нетривиальных проектов, решений и задач bool modified; do ( // найти центроиды forlint i = О; i < М; i++) Centroids[i] = Centroid(ClustersliJ .begin(), Clustersfi].end()); modified = false; vector<list<Iterator::value_type> > NewClusters(M); for(int OldCl « 0; OldCl < M; OldCl++) for(Iterator c = Clusters[OldCl].begin(); c != Clusters[OldCl].end(); C++) { double MinDist = numeric_limits<double>::max(); int ClusterNo; // найти кластер ближайшего к элементу центроида for(int centroid = 0; centroid < M; centroid++) { double Dist = Distance(*c, Centroids[centroid]); if(Dist < MinDist) { MinDist = Dist; ClusterNo = centroid; } } // если центроид принадлежит другому кластеру if(ClusterNo != OldCl) modified = true; NewClusters[ClusterNo].push_back(*c); ) Clusters = NewClusters; } while(modified); // пока происходят изменения return Clusters; } Чтобы применить функцию Cluster () к двумерным точкам, осталось описать структуру «точка на плоскости» и запрограммировать простые функции Distance () и main (): struct Point ( int X, у; }; // расстояние между точками на плоскости double Distance(const Pointt pl, const Point& p2) { return sqrt((pl.x - p2.x)*(pl.x - p2.x) + (pl.у - p2.y)*(pl.y - p2.y)); ) 150
^aiailaus<tii Глава 6. Обучающиеся программы int main(int argc, { randomize(); char* argv[]) int N, M; cin » N; // количество точек cin » M; // количество кластеров list<Point> Ip; for(int i = 0; i < N; i++) { Point p; cin » p.x; cin » p.y; Ip.push_back(p); } // считать список точек // выполнить кластеризацию vector<list<Point> > Clusters = Cluster(Ip.begin(), Ip. end (), M); // распечатать результаты (номер кластера, х, у) for(int i = 0; i < М; i++) for(list<Point>::iterator p = Clusters[i] .beginO ; p !- Clusters[i].end(); p++) cout « i « •: • « p->x « • • « p->y « endl; return 0; Результат разбиения тестовой коллекции точек на два (слева) и на четыре (справа) кластера показан на рис. 6.3. Рис. 6.3. Кластеризация коллекции точек на плоскости 151
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 6.1.2. Поисковая система и рубрикатор Классификация и кластеризация текстовых документов Полезность алгоритмов классификации и кластеризации, а также коллек- ций объектов с введённой метрикой (выражаясь математическим языком, метрических пространств) иллюстрирует следующая задача: требуется раз- работать настольную поисковую систему и рубрикатор для коллекции до- кументов, хранящейся на локальном диске пользователя. Поисковая система — это программа наподобие Яндекса: пользователь за- даёт в строке ввода «запрос», а компьютер возвращает сортированный по релевантности список найденных документов, некоторым образом «соот- ветствующих» запросу. Рубрикатор — это служба, называемая в терминах Яндекса «каталогом». Документы коллекции некоторым образом группируются в иерархическую структуру, предоставляемую для просмотра пользователю. Например, верх- ние уровни каталога могут называться «Работа», «Учёба», «Дом», «Бизнес» и так далее. Зайдя в раздел «Дом», человек попадает в подразделы вроде «Квартира», «Кулинария» или «Семья». Подобные системы, используемые мцогими из нас ежедневно, имеют самое непосредственное отношение к рассмотренным выше темам. Представьте, что на множестве документов определена метрика f(A, В), то есть функция, сопоставляющая паре документов «расстояние» между ними. Если считать пользовательский запрос Q одним из документов коллекции (его маленький размер ничего не меняет), то задача поиска превращается в простую распечатку списка файлов коллекции, наиболее близких к запросу в соответствии с метрикой: : ; L — ;пустой„. список ; ч. ,СДЛЯ КАЖДОГО документа коллекции О '••••• ‘‘ ЕСЛИ f(D, Q) < ПОРОГОВОЕ_ЗНАЧЕНИЕ_РЕЛЕВАНТНОСТИ добавить D в список L КОНЕЦ ЦИКЛА •отсортировать L по убыванию релевантности распечатать L ' ; ' 111 ' ' ' .^• |1 Подсказка Решение задачи рубрикации сводится к программированию алгоритма классификации. Исходные кластеры (рубрики) обычно задаёт человек — в данном случае это дело слишком ответственное, чтобы поручать его ком- 152
ftalatiausilik Глава 6. Обучающиеся программы пьютеру. Далее в каждый кластер вручную помещается некоторое количес- тво документов, ему соответствующих. Имея функцию расстояния и алго- ритм вроде KNN, раскидать оставшиеся документы по готовым кластерам ничего не стоит. Главная проблема заключается в изобретении хорошей метрики. По сущес- тву её ещё толком никто не решил — низкая релевантность извлекаемых существующими поисковыми системами документов наглядно этот факт демонстрирует. Порою алгоритмы дают сбои даже в самых простых слу- чаях. Например, на сегодняшний день на запрос «мыть окна» Яндекс вы- водит третьей ссылкой документ, содержащий фразу «свет в моем окне». Причина такого поведения, конечно, ясна: одна из форм слова «мыть» зву- чит как «моем» («мы моем окна»), однако это объяснение никак не может служить оправданием. Слова «моем» и «окне» заведомо не сочетаются, если «моем» — глагол. Не берусь рассуждать здесь о различных подходах к введению метрики на множестве документов — пусть её изобретение будет наиболее творческой частью задания. Для примера, однако, вкратце опишу так называемую мо- дель векторного пространства, придуманную ещё в 1974 году. Но сначала одно маленькое замечание. При работе с пространством доку- ментов обычно рассматривают не метрику f(A, В), а функцию близости sim(A, В). Расстояние между двумя любыми объектами может теоретически быть сколь угодно большим. В задачах же поиска и рубрикации мы сталкиваемся с ограниченным (в смысле, численно ограниченным) понятием релевант- ности. Документ может быть на 100% релевантным другому (любой доку- мент, например, идеально соответствует своей точной копии), но не может быть «бесконечно иррелевантным»: степень соответствия варьируется в ог- раниченном диапазоне от 0% до 100%, от нуля до единицы. Таким образом, функция sim(A, В) возвращает число в промежутке [0,1], трактуемое как степень соответствия. При желании можно определить мет- рику через близость: f(A,B) = 1-sim(A, В) Конечно, функция f(A, В) также будет ограничена сверху единицей, но это не противоречит определению метрики. Вернёмся теперь к модели векторного пространства. Идея заключается в представлении каждого документа (Dp D2,..., DN) системы (включая запрос пользователя) в виде набора (вектора) из Т чисел: Dj"(Wlj-W«....Wr>) 153
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Здесь Т — размер словаря, то есть общее количество различных слов (пос- кольку речь идёт о текстовых документах, под «словами» подразумеваются обычные слова русского языка), встречающихся в документах коллекции. Числа wljf w2j,..., wTj символизируют «веса» (или «значимость») отдельных слов в документе D.. Определение весов — это уже другая задача. Например, её классическое ре- шение базируется на следующих наблюдениях. Наблюдение первое. Вес того или иного слова должен быть пропорциона- лен количеству этих слов в документе. Если в тексте десять раз встречается слово «принтер» и ни разу «автомобиль», то, вероятно, документ относится скорее к принтерам, чем к автомобилям. Вес зависит также от наибольшей частоты слова, найденной в процессе ана- лиза документа. Если слово «принтер» встретилось в тексте десять раз — это много или мало? Зависит от частоты других слов. Например, если самое по- пулярное слово («винчестер») использовалось пятнадцать раз, то вес терма «принтер» в данном тексте достаточно велик. Если же слово «винчестер» встретилось сорок раз, то текст относится скорее к винчестерам, чем к при- нтерам, и десятикратное вхождение слова «принтер» уже не впечатляет. Используя стандартные обозначения, это наблюдение можно записать с по- мощью формул: f. = частота (количество вхождений) слова i1 в документе j tf. = f. / max. (f. ) 4 ij ' k ' kj' Последняя формула подводит итог первому наблюдению: вес слова i в доку- менте j (tfj.) пропорционален его частоте и обратно пропорционален макси- мальной частоте других слов, обнаруженной в документе j. Наблюдение второе. Вес слова снижается, если оно часто встречается во всей коллекции. Например, слово «принтер» довольно редко встречается в текстах, посвя- щённых автомобилям. Поэтому искать документ по слову «принтер» име- ет смысл: вряд ли поисковая система завалит вас тоннами содержащих его текстов. С другой стороны, если все документы коллекции посвящены ком- пьютерной технике, запрос «принтер» даст мало что: вероятно, каждый вто- рой текст содержит это слово. Конечно, любой документ, в котором гово- рится о принтерах, будет соответствовать запросу, но в данном случае поиск будет напоминать розыск преступника по фотороботу, обычно похожему на всех людей в мире и ни на кого персонально. 1 Не забывайте, что любое слово в коллекции можно закодировать числом от единицы до Т. 154
^aJallauSt^i Глава 6. Обучающиеся программы Для воплощения в жизнь второго наблюдения нам потребуются ещё две формулы: df = количество документов, содержащих слово i idf - log2 (N / df.), где N — общее количество документов в коллекции Величина idf, называемая обратной документной частотой (inverse docu- ment frequency, в русскоязычной литературе термин ещё не устоялся), тем выше, чем меньше в коллекции файлов, содержащих слово i. Итоговый вес слова i в документе] определяется по формуле: w.. = tf. * idf ч ч • Если вы не забыли, определение веса слов документа — лишь часть нашей задачи. Теперь необходимо решить, каким образом будет производиться вычисление функции подобия для векторов D. и D.. Здесь всё просто, хотя и громоздко: (£),., D.) *=1 Гт Гт 4«1 V *=1 Поскольку документы представляются в виде векторов, мы можем приме- нить известную в математике формулу скалярного произведения (числи- тель дроби). Знаменатель нормализует полученный результат, «загоняя» его в промежуток [0,1]. Модель векторного пространства — алгоритм во многом эвристический, критика в его адрес всегда существовала. Хотя основная цель задачи состо- ит в реализации систем поиска и рубрикации, разработка собственной фун- кции близости также весьма приветствуется. 6.1.3. Определение авторства Классификация произведений различных авторов Предположим, некто содержит большую библиотеку электронных текстов. Благодарные читатели со всего света посылают владельцу библиотеки txt- версии понравившихся им книг, пополняя основную коллекцию. Допус тим, один из читателей прислал весьма интересный рассказ, но вот незадача — забыл указать (или просто не знает) его автора. Скорее всего, в 155
C++ мастер-класс. 85 нетривиальных проектов, решений и задач библиотеке уже есть произведения этого писателя, но поможет ли наличие существующих текстов определить авторство вновь поступившего расска- за? Эта задача входит в компетенцию методов стилометрии, то есть компью- терного анализа стиля писателя. Как и в задаче информационного поиска, главное здесь — изобретение адекватной метрики для множества докумен- тов. Имея кластеризованную (распределённую по авторам) коллекцию и метрику, с помощью метода KNN можно найти наиболее подходящий клас- тер для нового текстового файла. В предыдущей задаче функция подобия sim () считала «похожими» доку- менты, похожие по содержанию. На сей раз содержание не играет никакой роли — важен стиль изложения, язык автора. Хотя понятие стиля на первый взгляд кажется неуловимым, в действитель- ности из текста можно извлечь довольно много информации, так или иначе характеризующей его создателя: • разные авторы обычно используют предложения разной длины; • лексиконы писателей различаются (особенно интересно изучить «любимые» слова — то есть слова, в целом по коллекции встречаю- щиеся гораздо реже, чем у данного конкретного автора); • в дополнение к частоте использования отдельных слов, интересно изучить употребление писателем словосочетаний, состоящих из двух-трёх слов. Это только некоторые идеи, которые могут лечь в основу хорошей метрики. Идеального же алгоритма, отличающегося стопроцентным попаданием, на сегодняшний день не существует. Итак, задача заключается в разработке соответствующей метрики для мно- жества документов и программировании системы, определяющей автора нового текста на основании известной кластеризованной коллекции про- изведений. Решение Сразу оговорюсь: решение, которое здесь приводится, очень далеко от совер- шенства. Придумать хорошую метрику для задачи определения авторства, пожалуй, не проще, чем создать сильную шахматную программу. Мой под- ход — всего лишь возможная стартовая точка для гораздо более серьёзных методов. Впрочем, для использовавшейся небольшой тестовой коллекции он показал вполне удовлетворительные результаты. 156
Глава 6. Обучающиеся программы Запрограммировать метод KNN — дело техники, поэтому займёмся сначала самым важным — функцией расстояния. В её основе лежит простое наблю- дение: даже в схожих ситуациях разные люди склонны употреблять разные слова и разные словосочетания. Это, по-видимому, в особенности относит- ся к словосочетаниям. Вероятно, описывая солнце, корову или табуретку, большинство писателей так и напишут: «солнце», «корова», «табуретка». В словосочетаниях простора для фантазии больше. Говоря об одном и том же летнем дне, можно сказать «горячее солнце», «пекущее солнце», «жаркое солнце»... Разные авторы подберут разные эпитеты. Если же в двух различ- ных произведениях наблюдается сравнительно частое использование одних и тех же словосочетаний, можно сделать вывод: произведения принадлежат перу одного и того же писателя. Практическая реализация функции расстояния навеяна методикой вычис- ления подобия документов из задачи «поисковая система и рубрикатор», но следует ей лишь частично. Первым делом любой паре слов (wl, w2) каждого документа коллекции сопоставляется вещественное число — вероятность встретить слово w2 не- посредственно за словом wl. Например, если в анализируемом документе слово «солнце» встречается после слова «жаркое» в каждом пятом случае, то паре (жаркое, солнце) сопоставляется число 1 /5. Далее для двух сравниваемых документов определяется список пар слов, найденных в каждом документе по отдельности. Если в одном документе пара «жаркое солнце» найдена, а в другом нет, она не будет участвовать в вычислении расстояния. Соответствующие парам вероятности записываются в два различных век- тора (вектор первого документа D1, вектор второго документа D2), после чего расстояние вычисляется с помощью формулы нормализованного ска- лярного произведения: Distance (D1# DJ = 1 - (D,, D2) / IID1II - IID2II Теперь приступим к программированию. Здесь в первую очередь потребует- ся реализация алгоритма KNN. Функция Classify() принимает в качестве параметров новый элемент коллекции, описание существующих кластеров и число N, а возвращает номер кластера, соответствующий классифициру- емому элементу. Описание кластеров представляет собой вектор списков элементов. К-й элемент вектора является К-м кластером коллекции. // служебная функция для сортировки пар (расстояние, кластер) bool Less(pair<double, int> Ihs, pair<double, int> rhs) { return Ihs.first < rhs.first; } 157
C++ мастер-класс. 85 нетривиальных проектов, решений и задач template <class Туре> int Classify(const Type& Object, const vector<list<Type> >& Clusters, int N) { // список «голосующих» элементов (расстояние, номер кластера) list<pair<double, int> > VotingList; // для каждого элемента коллекции *р вычислить расстояние до // Object и поместить пару (расстояние, кластер элемента) в // список VotingList for(unsigned i = 0; i < Clusters.size(); i++) for(list<Type>::const_iterator p = Clusters[i].begin(); p != Clusters[i].end(); p++) VotingList. pushjback (pai r<doubl.e, int>(Distance(Object, *p) , i)); // отсортировать список по возрастанию расстояния VotingList.sort(Less); vector<int> Votes(Clusters.size()); list<pair<double, int> >::iterator p = VotingList.begin(); // подсчитать количество голосов первых N членов // (Votes[k] — количество голосов за кластер к) for(int i = 0; i < N; i++) { Votes[p->second]++; p++; } // определить номер победившего кластера return max_element (Votes.begin() , Votes.endO) - Votes .begin() ; } Следующий этап — реализация типа данных -«документ» и функции рас- стояния: struct Document { // сопоставляет паре (wl, w2) вероятность появления w2 после wl map<pair<string, string>, double> Prob; // загрузить документ из файла Document(const string& filename) { map<pair<string, string>, int> Freq; // частота пары (wl, w2) map<string, int> Count; // частота слова wl ifstream file(filename.c_str()); string wl, w2; file » wl; while(file » w2) // пока не конец файла { // обновить Count и Freq Count[wl]++; Freq[make_pair(wl, w2)]++; 158
WalaHausi^ Глава 6. Обучающиеся программы wl = w2; } // преобразовать частоту в вероятность: // вероятность встретить слово w2 после wl равна // частоте встречаемости пары (wl, w2) , // делённой на частоту встречаемости слова wl for(map<pair<string, string», int>::iterator p = Freq.beginf); p 1= Freq.endO; p+ + ) Prob[p-»first] = p->second / double(Count[p-»first.first]); } double Distance(const Document& dl, const Document& d2) { // список словосочетаний, встречающихся в обоих документах set<pair<string, string» > Pairs; // цикл по всем парам первого документа for(map<pair<string, string», double»::const_iterator p = dl.Prob.begin();p != dl.Prob.end(); p++) // если пара найдена и во втором документе if(d2.Prob.find(p-»first) != d2.Prob.end()) Pairs.insert(p-»first); // если документы вообще не пересекаются, // расстояние между ними максимально if(Pairs.empty()) return 1; 11 вычислить скалярное произведение векторов (Р) double Р = 0; for(set<pair<string, string» »::iterator p - Pairs.begin(); p != Pairs.end(); p++) P += dl.Prob.find(*p)->second * d2.Prob.find(*p)->second; // вычислить квадрат нормы каждого из векторов double si = 0, s2 = 0; for(set<pair<string, string» >::iterator p = Pairs.begin(); p != Pairs.endO; p++) { si += dl.Prob.find(*p)->second * dl.Prob.find(*p)->second; s2 += d2.Prob.find(*p)->second * d2.Prob.find(*p)->second; } return 1 - P / (sqrt(si)*sqrt(s2)); 159
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Функция main() формирует кластеры и классифицирует некоторый новый документ методом KNN: int main(int argc, char* argv[]) { vector<list<Document> > Clusters(4); Clusters[O],push_back(Document('Library\\Bulychev\\BOILER.TXT')); Clusters[O].push_back(Document('Library\\Bulychev\\CHOICE.txt")); //... Clusters[3] .push_back (Document ("Library\\SimakWTALISMANl.txt") ) ; Clusters[3].push_back(Document('Library\\Simak\\TALISMAN2.txt')); Document dl ("Library WBulychev-RESERVE.txt" ) ; cout « Classify(dl. Clusters, 3) « endl; return 0; } В книге невозможно привести приемлемые входные данные, занимающие сотни килобайт. Я перечислю только документы, входившие в мою тесто- вую коллекцию — небольшую подборку фантастической литературы, ска- чанную из интернет-библиотеки www .lib.ru: • Кир Булычёв: «Котёл», «Выбор», «Смерть этажом ниже, часть I», «Смерть этажом ниже, часть II», «Доказательство», «Лиловый шар»; • Артур Конан Дойл: «Затерянный мир, часть I», «Затерянный мир, часть II», «Дезинтеграционная машина», «Страна туманов, часть I», «Страна туманов, часть II», «Когда Земля вскрикнула»; • Станислав Лем: «Футурологический конгресс», «Кибериада», «Пат- руль», «Следствие», «Терминус», «Испытание»; • Клиффорд Саймак: «Строительная площадка», «Империя, часть I», «Империя, часть II», «На Юпитере», «Братство талисмана, часть I», «Братство талисмана, часть II». В качестве классифицируемых рассматривались четыре документа: • «Заповедник сказок» Булычёва, • «Патруль» Лема, • «Магистраль вечности» и «Зачарованное паломничество» Саймака. В результате «Магистраль вечности» была приписана программой Конан Дойлу, остальные же документы оказались классифицированы правильно. 160
Глава 6. Обучающиеся программы 6.1.4. Распознавание языка документа Классификация документов на разных языках Эта задача завершает тему разработки метрики для множества текстовых документов. На сей раз смысл задачи заключается в разработке системы, автоматически определяющей язык входного текстового файла. Особенной широты охвата не требуется: достаточно научить компьютер восьми-десяти наиболее рас- пространённым европейским языкам. Больше к формулировке задания мне добавить нечего. Однако некоторые замечания технического характера, думаю, будут уместными. В предыдущих задачах приходилось сравнивать между собой текстовые до- кументы. Здесь, конечно, вам тоже потребуется провести анализ коллекции файлов для каждого конкретного языка, но во время работы программы эти коллекции уже не нужны. Метрика, скорее всего, будет учитывать какие- то общие черты, присущие всему языку (средняя длина слова, популярные буквосочетания, частота использования тех или иных букв), а не какому-то конкретному документу. Таким образом, в готовой программе будет при- сутствовать лишь таблица со значениями, выявленными на этапе анализа коллекций. Теперь что касается работы с документами на разных языках. Сейчас уже почти везде доступна поддержка стандарта Unicode, то есть символов, за- кодированных не одним байтом, как это было раньше, а двумя. Считается, что уж в два байта можно запихнуть едва ли не все символы, когда-либо изобретённые человечеством, и, тем самым закрыть тему сложностей, свя- занных с многоязыковой поддержкой. Так это или нет, покажет время. Тем не менее, от поддержки старого ASCII-формата в обозримом будущем вряд ли откажутся. Я предлагаю для простоты ограничиться как раз ASCII-фай- лами. Напомню, как устроена однобайтная таблица символов. Первые 128 элементов стандартны: им соответствуют цифры, скобки, знаки препинания, некоторые специальные символы (вроде #, & или =), а также заглавные и строчные латин- ские буквы. Вы можете быть уверены, что заглавная латинская буква А в любом тексте, будь то шведский, английский или русский, закодирована числом 65. Вторая поло- вина таблицы отдана на откуп символам национальных алфавитов. В европейс- ких языках к ним относятся забавные буквы наподобие б, а или 1. В русском же «национальными» считаются вообще все символы кириллицы. б Зак 772 161
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Таким образом, в документе никак не закодирована информация о языке. Пра- вильный вывод символов — проблема программы, текст отображающей, а не самого текста. Например, что такое символ с кодом 228? Если документ русскоязычный, это буква «д» (стандарт Windows-1251), а если, скажем, финноязычный, то буква «а» (стандарт Latin-1, ISO-8859-1). Различные программы решают сложившее- ся затруднение по-разному. Текстовый редактор, скорее всего, выберет язык, ус- тановленный в системе по умолчанию. А, скажем, хороший браузер попытается определить язык автоматически (если вы ещё не забыли, распознавание языка и составляет суть задачи), но при этом оставит возможность пользователю вы- брать правильную кодировку из списка. Решение Эта задача несравнимо проще предыдущей. В исходном коде мало что меня- ется, поэтому приводить его здесь не буду. Функция расстояния представляет собой нормированное скалярное произве- дение векторов, состоящих из вероятностей всех возможных сочетаний из двух букв алфавита. Например, если каждое сотое буквосочетание в языке у “аЬ", то соответствующему элементу вектора сопоставляется число 1/100. Пос- кольку ASCII-таблица состоит из 256 символов, количество элементов в каждом векторе будет равно 256*256 = 65536. Можно и сократить объем занимаемой памяти, потому что большинство двухбайтных сочетаний ни- когда не встречаются в текстовых файлах независимо от их языка. По крайней мере, для небольшого количества языков эта'методика работает весьма неплохо. 6.1.5. Дерево принятия решений Разработка простой экспертной системы Нередко программной системе приходится принимать решения в сложных, неоднозначных ситуациях. В таких случаях непросто придумать хорошую стратегию, разумно работающую в любых обстоятельствах. Например, тя- жело явным образом запрограммировать правильную реакцию пилота са- молёта на те или иные показания приборов. То же самое можно сказать о реакции управляемого компьютером каратиста на действия соперника в игре-файтинге. Если придумать свою стратегию затруднительно, можно пойти другим путём — проанализировать действия человека в различных возникающих ситуациях и вывести из них закономерности. 162
^ataHaus,^. Глава 6. Обучающиеся программы Один из возможных способов поиска закономерностей состоит в построе- нии дерева принятия решений. Рассмотрим, например, такую задачу. Тре- буется выбрать наилучший способ проведения досуга в зависимости от времени года, погоды и настроения. Результаты опроса людей записаны в текстовом файле: зима солнечно отличное зима солнечно хорошее зима пасмурно отличное весна пасмурно плохое лето солнечно хорошее лето солнечно плохое осень солнечно отличное на_каток в_гости в_гости в_бар на_пляж в_бар на_футбол На основе статистического анализа файла компьютер может построить де- рево, изображённое на рис. 6.4. Рис. 6.4. Дерево принятия решений для задачи проведения досуга Заметьте, дерево позволяет определить «правильное» решение для любой введённой тройки атрибутов (время года, погода, настроение), независи- мо от того, присутствует ли она в исходной базе данных или нет. Компью- тер начинает с корня дерева. Значение атрибута, записанного в очередном узле, направляет поиск по соответствующей ветви. Каждый терминальный узел дерева содержит в себе решение, наилучшим образом подходящее для введённой ситуации. 163
C++ мастер-класс. 85 нетривиальных проектов, решений и задач вг Деревья принятия решений могут использоваться и для выявления зако- номерностей в существующей базе данных. Например, проанализировав информацию о возрасте, росте, весе и национальности призёров междуна- родных соревнований по бегу, можно выяснить, каким набором атрибутов обычно характеризуются победители, и даже какова степень важности того или иного атрибута. Итоговая задача заключается в разработке алгоритма, находящего дерево принятия решений для любого входного файла с описаниями ситуаций. В каждой строке файла записано N слов, первые N - 1 из которых зада- ют сложившееся положение, а последнее — реакцию человека. Начальные N - 1 строк файла содержат имена используемых атрибутов. На выходе программа выводит дерево в любом читабельном виде, а также позволяет пользователю определить принятое решение для любого введён- ного с клавиатуры набора атрибутов. Подумайте над алгоритмом построения дерева самостоятельно. Разумный алгоритм может попытаться уменьшить общее количество узлов дерева, но это скорее пожелание, чем обязательное требование. Вполне возможно, что некоторые данные из базы будут друг другу проти- воречить (человеку никто не мешает пойти в одних и тех же обстоятельс- твах один раз на футбол, а другой раз — в гости; аналогично, даже не попа- дающий в число претендентов на победу с точки зрения статистики бегун может всё-таки выиграть). В этом случае итоговое решение выбирается го- лосованием: в дерево будет записано более часто используемое в исходном файле решение. Неплохо бы также вывести, скольким ситуациям (в процентах) из исходной базы данных с помощью построенного дерева сопоставляются правильные с точки зрения человека решения. Подсказка С точки зрения программирования эта задача мне представляется неслож- ной, поэтому я не привожу ее полного решения. Алгоритмические же заме- чания будут совсем не лишними. Итак, предположим, что исходный файл состоит из строк, в каждой из кото- рых записан набор атрибутов (Ар А2,..., AN) и сопоставленное ему значение V. Предположим, атрибут А может принимать одно из К; значений (так, ат- рибут «время года» из примера может принимать одно из четырёх значений — «зима», «весна», «лето» или «осень»). 164
ftatatLausi^ Глава 6. Обучающиеся программы Первым делом необходимо избавиться от противоречивой информации с помощью «голосования»: из файла выбираются строки с одинаковыми на- борами атрибутов, но разными значениями V Производится «голосование», и все проигравшие строки удаляются. Далее можно воспользоваться очевидным «наивным» алгоритмом построе- ния дерева. В корень записывается атрибут Аг Непосредственными потом- ками корня будут К1 узлов, каждому из которых сопоставляется множество записей с одинаковым значением атрибута Аг Возвращаясь к примеру, предположим, что атрибутом At считается настрое- ние. Тогда у корневого элемента будет три потомка (для отличного, хороше- го и плохого настроения). Соответственно, все записи окажутся разбитыми на три группы в соответствии со значением атрибута Аг Аналогично, для каждого из узлов-потомков корня создаётся К2 дочерних узлов с атрибутом А2. Как только все записи, сопоставленные некоторому узлу дерева, будут со- держать одно и то же значение V, обработка ветви считается завершённой. В худшем случае все N атрибутов окажутся задействованными, то есть бу- дет создана ветвь глубиной в N узлов. Например, в нашем случае отличное настроение и солнечная погода ещё не позволяют определить наилучший Способ времяпрепровождения: требуется информация и из третьего атрибу- та «время года». Если же настроение плохое, все записи базы данных пред- лагают V = «в бар», и продолжать анализ ветви нет смысла. Недостаток «наивного» алгоритма в том, что полученное дерево может ока- заться куда «ветвистее», чем того требует задача. Например, выбрав в ка- честве корня атрибут «настроение», мы сразу же нашли решение для всех ситуаций с плохим настроением, избавившись от необходимости классифи- кации по двум оставшимся атрибутам. Вполне вероятно, что выбор атри- бута «погода» в качестве корневого мог бы привести к созданию итогового дерева большего объёма. Таким образом, при необходимости разделить множество ситуаций на под- множества, мы всякий раз сталкиваемся с задачей выбора «наилучшего» атрибута. Хотя оптимального решения для этой задачи не существует, было предложено несколько эвристических критериев, неплохо работающих на практике. Здесь я опишу идею Р. Куин лена2, использующуюся, например, в популяр- ном алгоритме построения деревьев принятия решений С4.5. 2 J. Ross Quinlan. С4.5: Programs for Machine Learning. Morgan Kaufmann Publishers, 1993 165
C++ мастер-класс. 85 нетривиальных проектов, решений и задач В качестве используемого атрибута при очередном разбиении множества ситуаций Т на подмножества выбирается атрибут X, для которого значе- ние Gain = Info(T) - InfoX(Т) максимально. Запись Info(T) обозначает меру информации (энтропию) множества Т, вычисляемую по формуле Info(T) = - I Т | * (p(V,)*log? p(VJ + p(V,)*log, p(VJ + ... + p(V)*log_, p(VJ) Здесь: • | т | — количество элементов во множестве Т; • v2, ..., yn — различные значения результата V, встретивши- еся во множестве, • р (V.) — вероятность встретить значение V. Поясню сказанное на примере. Допустим, разбиваемое множество содер- жит строки: зима солнечно отличное на_каток зима солнечно хорошее в_гости зима пасмурно отличное весна пасмурно плохое в_гости в_бар В этих четырёх строках встречаются только три различных значения V — «на_каток», «в_гости» и «вбар». Вероятность встретить значение «на каток» равна 1/4 (встретилось в одном случае из четырёх). Аналогич- но, р(«В_гости») = 1/2, р(«в_бар») = 1/4. Таким образом, Info(T) = -4 * (1/4 * log2 1/4 + 1/2 * log2 1/2 + 1/4 * log, 1/4) = 4 * (1/2 + 1/2 + 1/2) = 6 Чем сильнее варьируется значение V во множестве, тем больше энтропия. Если всем элементам множества сопоставлено одно и то же значение V («в_ бар», например), то энтропия равна нулю (поскольку p(V) = 1, a log21 = 0). Значение Infox (Т) (энтропия множества Т, разбитого на подмножества с помощью атрибута X) вычисляется несколько сложнее: Infox(T) = '^~Info(Ti) Здесь Тр Т2,..., Тк — это подмножества, образующиеся в результате разби- ения множества Т атрибутом X (если атрибут X может принимать К раз- 166
Глава 6. Обучающиеся программы i личных значений, мы получим К новых подмножеств). Энтропия каждого подмножества умножается на его «вклад», равный | Т. | / | Т |. Чем больше рассматриваемое подмножество, тем более значима его энтропия для обще- го результата. Итак, значение Gain представляет собой разность между мерой информа- ции исходного множества и мерой информации множества после его разби- ения атрибутом X. Предположим, некоторый атрибут идеально разбивает исходное множество на подмножества, в каждом из которых встречается только одно значение V. Таким образом, Infox(T) = 0, и значение Gain максимально. Описанная методика является «жадной», выбирая на каждом шаге наилуч- шее разбиение (даже если это удачное разбиение впоследствии приведёт к десяти неудачным). 6.2. САМООБУЧАЮЩИЕСЯ ПРОГРАММЫ 6.2.1. Самообучающиеся крестики-нолики Компьютер учится на собственном опыте Требуется создать самообучающуюся программу для игры в крестики-но- лики. Компьютер начинает, ставя крестик в одной из возможных девяти клеток. Человек отвечает ноликом, затем ход опять переходит к компьютеру. Если кто забыл, цель игры состоит в выстраивании ряда (вертикального, гори- зонтального или диагонального) из трёх одинаковых знаков. Если получил- ся ряд из трёх крестиков, побеждает компьютер, если из трёх ноликов — че- ловек. Если всё поле застроено, а готовых рядов всё ещё нет, фиксируется ничья. Изначально компьютер использует «рандомную стратегию», то есть на каж- дом шаге ставит крестик в случайно выбранную клетку. Если партия завер- шилась победой компьютера, то последовательность ходов, приведшая к победе, «поощряется» путём увеличения частоты её использования. Обсудим процесс обучения подробнее. Для любого расположения крести- ков и ноликов у компьютера есть столько вариантов хода, сколько свобод- ных клеток осталось на игровом поле. Как уже было сказано, первоначально для компьютера нет разницы, какой ход выбрать, но в процессе обучения разным решениям приписывается разный вес. 167
C++ мастер-класс. 85 нетривиальных проектов, решений и задач X о О X X О Рис. 6.5. Крестики-нолики: типичная игровая ситуация Вес — это просто целое чйсло, в начале работы программы равное InitWeight (выбор InitWeight остаётся за вами; можно посоветовать взять значение около 100) для каждого возможного хода. Если какой-то ход ведёт к победе компьютера, его вес следует повысить. Рассмотрим типичную ситуацию, возникшую в игре (рис. 6.5). Компьютер может поставить крестик в любой из трёх свободных углов. Первоначально никаких предпочтений не существует, вес каждого хода равен InitWeight. Предположим теперь, что компьютер случай- ным образом выбрал правый нижний угол и по- бедил в игре. Теперь надо поощрить использо- вание этого хода, увеличив его вес на значение Learningstep (разумное его значение лежит в диапазоне 10...30). В процессе выбора хода случайный фактор не исключается, но ходы с боль- шими весовыми коэффициентами выбираются чаще. Если вес одного хода в два раза выше веса другого хода, то первый ход используется в два раза чаще. Разумеется, нельзя забывать, что любой ход неотделим от игровой ситуа- ции, поэтому наборы весовых коэффициентов будут определяться отдельно для каждого сложившегося расположения крестиков и ноликов. Для эконо- мии памяти и времени можно учесть, что многие ситуации получаются из других путём простого поворота или отражения игрового поля. Если некоторый ход ведёт к поражению компьютера, его вес уменьшается. Минимально возможный вес равен нулю (ходы с нулевыми весами никогда не будут выбраны). В случае ничьей компьютер не поощряется и не нака- зывается. Разумна идея поощрять или наказывать не только последний ход, непос- редственно приведший к победе или поражению, но и всю игровую стра- тегию, то есть все ходы, предшествующие победному. Можно предложить простую методику. Вес хода, выбранного на предыдущем шаге, повышается (в случае победы компьютера) на значение LearningStep*StepCoeff, где значение StepCoeff лежит в интервале от нуля до единицы. Вес пред- шествующего ему хода увеличивается на Learningstep*StepCoeff2 и так далее. Например, если компьютер побеждает на четвёртом ходу, веса ходов, со- ставляющих выигрышную последовательность, изменяются следующим образом: 168
^alattaus,^. Глава 6. Обучающиеся программы вес первого: + LearningStep*StepCoeff3 вес второго: + LearningStep*StepCoeff2 вес третьего: + LearningStep*StepCoeff вес четвёртого: + Learningstep Поскольку значение StepCoef f меньше единицы, веса более ранних ходов слабее меняются в процессе обучения. При поражении компьютера веса ходов уменьшаются аналогичным обра- зом. Важцая часть задания — исследование процесса обучения. Требуется вывес- ти динамику побед/ничьих/поражений за время сыгранных N партий (где N может варьироваться от десятков до сотен). Можно запрограммировать вторую процедуру, играющую ноликами, и свести программы друг с другом. Вторая процедура может быть необучаемой (то есть всегда использовать рандомную стратегию). Решение Поскольку процесс обучения был описан достаточно подробно, я перейду непосредственно к реализации программы. Начнём с описания глобальных объектов и пары служебных функций: const int InitWeight = 100; // начальный вес const int PrecCoeff =50; // точность генератора случайных чисел const double StepCoeff = 0.65; // "коэффициент обучения" const int Learningstep =20; // "шаг обучения" // Игровое поле (3 х 3, построчно). Может содержать три вида II символов — крестик ('х'), нолик ('о') и пробел char GameField[9]; // текущее состояние игры: выиграли крестики, выиграли нолики, // ничья, игра ещё не завершилась enuin OUTCOME { Xs, Os, DRAW, UNFINISHED }; OUTCOME GetOutcome() { // все восемь победных расположений трёх символов игрока int V[8][3] = { {0, 1, 2}, {3, 4, 5}, {6, 7, 8}, {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, (0, 4, 8}, {2, 4, 6} }; II если обнаружена победная комбинация for(int i = 0; i < 8; i + + ) if(GameField[V[i][0]] == GameField[V[i][1]] && GameField[V[i][0]] == GameField[V[i][2]] && GameField[V[i][0]] != ' ') 169
C++ мастер-класс. 85 нетривиальных проектов, решений и задач return GameField[V[i] [0] ] == 'х' ? Xs : Os; // если победных комбинаций не обнаружено, то результат // зависит от наличия на игровом поле пробельных символов // (если пробелов нет, игра завершилась ничьей) return (find(GameField, GameField +9, ' ') == GameField + 9) ? DRAW : UNFINISHED; } // вывести текущее состояние игрового поля void PrintFieldO { cout « GameField!0] « * I " << GameField(1] « “I* cout << GameField[3 I « "I" « GameField[4] << "I" cout « GameField[61 « "I" « GameField[7] « "I* cout « endl; } GameField[2’ GameField[51 GameField|8] « endl; « endl; « endl; В процессе обучения компьютерный игрок будет иметь дело с коллекцией объектов, представляющих собой различные состояния игрового поля, и с матрицей весов, соответствующих тем или иным ходам: // состояние игрового поля struct TField { char Field[9]; // конструктор: просто скопировать текущее содержимое // реального поля TField() { copy(GameField, GameField + 9, Field); } // для объединения в коллекцию требуется функция // упорядочивания bool operator<(const TFieldb rhs) const { return lexicographical_compare(Field, Field +9, rhs.Field, rhs.Field + 9); } } ; // матрица весов struct TWeight { int Weight[9]; // изначально заполняется значением веса InitWeight TWeight() { fill(Weight, Weight + 9, InitWeight); } }; 170
^laiaHaus,^. Глава 6. Обучающиеся программы Для полноценного экспериментирования нам потребуется поддержка трёх видов игроков: • игрок-человек (ходы вводятся с клавиатуры); • компьютерный игрок, использующий рандомную стратегию; • обучаемый компьютерный игрок. Единый интерфейс для всех типов игроков обеспечивается абстрактным базовым классом Player: class Player { protected: // используемый игроком символ (крестик или нолик) char Symbol; public: Player(char _symbol) : Symbol(_symbol) {} // сделать очередной ход virtual void MakeMove() = 0; // выполнить одну итерацию обучения virtual void Learn(int) {} }; Функция-член для обучения реально нужна только в классе обучаемого компьютерного игрока (в остальных местах она предназначена лишь для совместимости интерфейсов). Классы первых двух типов игроков очень просты: // игрок-человек class HumanPlayer : public Player ( public: HumanPlayer(char _symbol) : Player(_symbol) {} virtual void MakeMove() { int Cell; // прочитать номер клетки и сделать ход // (корректность хода не проверяется) cin » Cell; GameField[Cell] = Symbol; } }; // компьютерный игрок, использующий рандомную стратегию class RandomPlayer : public Player 171
C++ мастер-класс. 85 нетривиальных проектов, решений и задач { public: RandomPlayer(char „symbol) : Player(„symbol) {} virtual void MakeMove() < vectorcint> v; // запомнить номера всех пустых клеток for(int i = 0; i < 9; i++) if (GameFielddl == ' ') v.push_back(i); // записать символ в случайно выбранную пустую клетку GameField[v[random(v.size())]] = Symbol; } ); Класс обучаемого игрока содержит несколько дополнительных закрытых функций-членов и полей данных: class Smartplayer : public Player { private: // элемент «истории» текущей игры struct HistoryElement { mapcTField, TWeight>::iterator Situation; // позиция int Move; // сделанный ход HistoryElement(mapcTField, TWeight>::iterator s, int m) : Situation(s), Move(m) {} ); stack<HistoryElement> History; // «история» сделанных ходов mapcTField, TWeight> Database; // полная база знаний игрока // поиск в базе знаний текущей игровой ситуации mapcTField, TWeight>::iterator GetSituation(); 11 генератор случайных чисел, учитывающий матрицу весов int GetWRandom(mapcTField, TWeight>::iterator s) ; public: SmartPlayer(char „symbol) : Player(„symbol) {} virtual void MakeMove(); void Learn(int step); }; Обучаемый компьютерный игрок действует следующим образом. База зна- ний Database сопоставляет каждой известной игроку ситуации некоторую 172
^alaifaus^. Глава 6. Обучающиеся программы матрицу, указывающую веса (то есть предпочтительность выбора) возмож- ных ходов. Изначально база знаний, естественно, пуста. При выборе хода игрок «консультируется» с базой знаний. Ситуация, впер- вые встретившаяся в игре, вносится в базу. При этом все допустимые ходы изначально будут считаться одинаково предпочтительными. Все ходы игрока Smart Player в текущей партии записываются в стек History. В процессе обучения, которому соответствует вызов функции Learn (), записанные ходы будут поощряться или наказываться. Остановимся пока на этом и рассмотрим функции-члены GetSituation (), GetWRandom() и MakeMove(): II найти в базе знаний ситуацию, сложившуюся на игровом поле mapcTField, TWeight>iterator SmartPlayer: .-GetSituation () { // поиск в базе объекта TField, инициализированного содержимым GameField mapcTField, TWeight>::iterator p = Database.find(TField()); // если объект не найден if(р == Database.end()) { TWeight w; // создать новую матрицу весов for(int i = 0; i < 9; i++) if(GameField!ij != ' ') // если клетка игрового поля занята w.Weight[i] =0; // ход невозможен (его вес равен нулю) Database[TField()] = w,- // записать текущую ситуацию в базу return Database.find(TField()) ; } return p; } /I возвращает случайное число, соответствующее матрице весов s->second int SmartPlayer: -.GetWRandom(mapcTField, TWeight>::iterator s) { // найти сумму всех весов матрицы int sum = accumulate(s->second.Weight, s->second.Weight +9, 0); // если все ходы одинаково плохи (их веса равны нулю) if(sum =- 0) // выбираем первую попавшуюся пустую клетку return find(GameField, GameField +9, ' ') - GameField; vector<int> coords; // генерация случайного числа for (int i = 0; i < 9; i++) // (см. комментарий после // листинга) fill_n(back_inserter(coords), PrecCoeff*s->second.Weight[i] / sum, i); return coords[random(coords.size())]; } 173
C++ мастер-класс. 85 нетривиальных проектов, решений и задач // сделать очередной ход void SmartPlayer::MakeMove() { // извлечь ситуацию из базы знаний map<TField, TWeight>::iterator s - GetSituation(); int move = GetWRandom(s); // сгенерировать ход GameField[move] = Symbol; // сделать ход History.push(HistoryElement(s, move)); // запомнить ход в «истории» } Пожалуй, единственным фрагментом, нуждающимся в пояснениях, являет- ся генерация случайного числа на основе таблицы весов. Рассмотрим конк- ретный пример таблицы: О 50 80 200 0 70 100 100 100 Действия алгоритма логически (хотя на уровне исходного кода всё выгля- дит проще) делятся на четыре шага. На первом шаге производится норма- лизация весов, то есть каждый вес делится на сумму всех весов таблицы (в данном случае 700): 0 0.07 0.11 0.29 0 0.1 0.14 0.14 0.14 На втором шаге полученные элементы умножаются назначение Р г есСое f f, представляющее собой точность генератора случайных чисел. Дробная часть результата отбрасывается. Чем больше PrecCoef f, тем лучше гене- рируемые числа будут соответствовать весам из таблицы. За точность, од- нако, приходится расплачиваться скоростью работы. Допустим, PrecCoef f = 50. Тогда таблица после умножения приобретёт следующий вид: 0 3 5 14 0 5 7 7 7 На третьем этапе генерируется массив, содержащий столько значений К, сколько записано в К-й клетке матрицы весов (значение К лежит в преде- лах от нуля до восьми включительно): 1112222233333333333333555556666666 777777788888888 На последнем этапе из массива извлекается случайный элемент и использу- ется в качестве результата работы генератора. 174
^latattaus,^. Глава 6. Обучающиеся программы Рассмотрим теперь, как работает функция обучения с параметрами Learningstep и StepCoef f (поощрение и наказание ходов выполняются по одной схеме, действие зависит лишь от знака числа Learningstep). Последний ход поощряется или наказывается «на полную катушку»: соот- ветствующий вес в матрице весов изменяется на значение Learningstep. Затем действие повторяется для предыдущего хода, но на сей раз вес из- менится на меньшее по модулю значение Learningstep * StepCoef f. Для следующего шага изменение окажется ещё менее существенным и так далее. На уровне исходного кода всё выглядит ненамного сложнее: void SmartPlayer::Learn(int step) { // если история ходов уже пуста if(History.empty()) return; // итерация обучения завершена II извлечь очередной ход HistoryElement h = History.top(); History.pop(); // ссылка на матрицу весов TWeight& w = h.Situation->second; Il изменить вес хода w.Weight[h.Move] += step; if(w.Weight[h.Move] < 0) // нижний предел веса — нуль w.Weight[h.Move] = 0; // выполнить обучение для предыдущего хода Learn(step * StepCoeff); ) Самая сложная часть работы уже позади. Осталось лишь написать функ- цию, отвечающую за проведение игры, и главную функцию main (): // провести одну игру между игроками Хр (крестики) и Ор (нолики) // параметр Verbose указывает, следует ли выводить на экран // игровое поле (во время обучения печать лишь замедляет процесс) void PlayGame(Player *Хр, Player *Ор, bool Verbose) < Player *Cp[] = { Xp, Op }; // номер текущего хода int move = 0; fill(GameField, GameField + 9, ' '); // очистить поле // пока игра не закончилась while(GetOutcome() == UNFINISHED) 175
C++ мастер-класс. 85 нетривиальных проектов, решений и задач { // сделать очередной ход Cp[move++ % 2]->MakeMove(); // при необходимости вывести игровое поле на экран if(Verbose) PrintFieldf); } // выполнить итерацию обучения // (поощряем выигравшую сторону, наказываем проигравшую) if(GetOutcome() == Xs) { Xp->Learn(Learningstep); Op->Learn(-Learningstep); } else if(GetOutcome() == Os) { Xp->Learn(-Learningstep); Op->Learn(Learningstep); } int main(int argc, char* argv[J) { randomize(); RandomPlayer *r = new Randomplayer('o') ; HumanPlayer *h = new HumanPlayer('o'); SmartPlayer *s = new SmartPlayer('x'); /I сыграть 50000 партий между игроками биг for(int k = 0; k < 50000; k++) PlayGame(s, r, false); // сыграть партию между s и человеком PlayGame(s, h, true); delete s; delete h; delete r; return 0; После обучения в виде пятидесяти тысяч партий с «рандомным» игроком игрок s кое-чему способен научиться. Ниже приведена типичная партия между человеком и обученным компьютерным соперником (компьютер иг- рает крестиками, человек — ноликами): I I |Х| I I 0 о| I |х| I I 176
Глава 6. Обучающиеся программы х NalaHausf^!. 2 о X X о о I X I о X 7 О I X |х X I о о о I X 1х X I о о X 3 о о X х|хо х|х о I о I X о I X X I о о X X Качество обучения определяется прежде всего значениями параметров Learningstep и StepCoef f. Я советую с ними поэкспериментировать. 6.2.2. Самообучающаяся программа для игры в ним. Автоматический поиск выигрышной стратегии В ним играют на доске с 12 фишками, расположенными в три ряда. Началь- ная позиция приведена на рис. 6.6. Игроки по очереди забирают одну или более фишек из любого ряда (брать фишки из разных рядов запрещено). Выигрывает тот, кто сделает последний ход. Требуется написать самообучающуюся программу для игры в ним, исполь- зуя идеи из предыдущего п. 6.2.1. Поскольку для игры в ним существует выигрышная стратегия для первого игрока, компьютер должен в итоге найти её. 177
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Рис. 6.6. Начальное расположение фишек для игры в ним 178
^laiaVaus^. ГЛАВА 7. МОДЕЛИРОВАНИЕ ВЕРОЯТНОСТНЫХ ПРОЦЕССОВ
Как заметил писатель Том Клэнси, в жизни нет гарантий, существуют одни вероятности. Действительно, на практике очень часто встречаются задачи, в которых так или иначе фигурируют случайные величины. В этой главе мы обсудим несколько различных типов задач, по-разному связанных с ве- роятностями. В разделе «Рандомизированные алгоритмы» обсуждаются процедуры, формирующие частично случайные выходные данные. Напри- мер, если вы желаете создать газетный заголовок или рекламный слоган по шаблону, программа должна выдавать разные результаты, а не один и тот же набор слов при каждом запуске. Под заголовком «Компьютерные эксперименты» объединены задачи, пос- вящённые моделированию какого-либо реального процесса, имеющего ве- роятностную природу. Например, мы можем прикинуть, сколько прибыли принесёт интернет-провайдеру установка прокси-сервера (см. п. 7.2.4), но не в состоянии произвести точный подсчёт. В секции «биологические моде- ли» описаны две псевдобиологические имитации, в которых без случайного элемента тоже обойтись нельзя. 7.1. РАНДОМИЗИРОВАННЫЕ АЛГОРИТМЫ 7.1.1. ГЕНЕРАЦИЯ ЗАГОЛОВКОВ Формирование предложений по шаблону Недавно в интернете я наткнулся на заметку о том, что некто bugzzz и mixedb за несколько часов сочинили для конкурса, проводимого «Экспресс-газе- той», десятки интригующих заголовков статей. Вот некоторые из них: Илью Лагутенко воспитал бородатый краб Бомжи с Манежной площади построили ракету из банок «Старого Мельника* Московское метро вырыли крысы Магазинная колбаса содержит плутоний 180
yatallau^i!. Глава 7. Моделирование вероятностных процессов Коттеджный поселок на Рублевке оккупировала стая ежей В тайге найдена канарейка Дзержинского Вероятно, такие заголовки с не меньшим успехом сможет выдумывать и компьютер. Задача заключается в реализации системы генерации заголовков. Каждый заголовок создаётся по случайно выбранному шаблону Любой шаблон, в свою очередь, представляет собой строку, состоящую из названий частей речи и звёздочек, например: ПРИЛ* СУПиИП ГЛАГ ПРИЛ* СУЩ_ВП Здесь ПРИЛ* означает произвольное количество прилагательных (вклю- чая нуль), СУЩ ИП — существительное в именительном падеже, ГЛАГ — глагол, а СУЩ ВП — существительное в винительном падеже. Та- кому шаблону, в частности, соответствует заголовок «Магазинная колбаса содержит плутоний». Части речи берутся из словаря, поставляемого пользователем. Естествен- но, поскольку разговор идёт о русскоязычных текстах, придётся хранить в словаре различные формы одного и того же слова (чтобы не было проблем с падежами, родами и числами). 7. 1.2. ГЕНЕРАТОР ТЕКСТА Применение цепей Маркова Тему автоматической генерации текста компьютером продолжает следую- щая классическая задача. Используя входной текстовый файл в качестве образца для подражания, сгенерировать несколько осмысленных предло- жений. Традиционно решение связывается с использованием цепей Маркова. Вкратце идея выглядит следующим образом. Предварительная фаза работы заключается в том, чтобы, проанализировав текстовый файл, построить таблицу, сопоставляющую любым К по/ряд идущим словам список слов, после них встречающихся, и вероятность каж- дого конкретного варианта. Так, таблица солцне светит ярко 0.6 0.3 0.1 иван родил жарко 0.3 тускло 0.1 девчонку 1.О 181
C++ мастер-класс для К = 2 означает, что после фразы «солнце светит» может встретиться одно из трёх слов: «ярко», «жарко» или «тускло», причём, скорее всего (с вероятностью 0.6) мы встретим слово «ярко». За фразой же «иван родил» всегда следует слово «девчонку». Знаки препинания в данном случае тоже считаются словами, поэтому они будут включены в таблицу. Параметр К влияет на стилистику и разнообразие генерируемых програм- мой фраз. Разумно поэкспериментировать с небольшими значениями — К= 1,К = 2,К = 3. Итак, требуется создать текст, состоящий из N (N вводится с клавиатуры) слов. Изначально берётся любая последовательность К слов из таблицы и случайно генерируется следующее слово (при этом вероятность генерации того или иного слова должна соответствовать вероятностям из таблицы). Теперь текст содержит К + 1 слово. Далее берутся К последних слов текста (то есть только что сгенерированное слово и К - 1 предшествующих ему) и операция повторяется для них. Процесс продолжается, пока итоговый текст нс будет содержать N слов. Программа должна считывать с клавиатуры значения К и N, а затем печа- тать N слов связного текста на основе входного текстового файла. Мне встречались программы, таблицы вероятностей которых были постро- ены на основе каких-то научных текстов. Сгенерированные этими програм- мами предложения вполне годились в качестве «наукообразной воды» для всякого рода рефератов, дипломных работ и тому подобных отчётов. Решение Решение этой задачи состоит из двух этапов. На первом этапе создаётся таб- лица вероятностей для произвольного К, на втором генерируется выходной текст. // таблица вероятностей: сопоставляет списку из К слов // К+1-е слово и вероятность его появления map<list<string>, map<string, double» > Prob; int К, N; // входные параметры задачи const int PrecCoeff = 50; // точность генератора случайных чисел // функция заполняет таблицу вероятностей по данным файла filename void Analyze(const string& filename) { 11 таблица частот map<list<string>, map<string, int> > Freq; 182
Глава?. Моделирование вероятностных процессов // частоты К-элементных списков слов map<list<string>, int> Count; ifstream file(filename.c_str()); // очередной список из К слов list<string> Key; string temp; // считать первые К слов for(int i = 0; i < К; i++) { file » temp; Key.push_back(temp); } // пока не достигнут конец файла while(file » temp) { // обновить таблицы частот Count[Key]++; Freq[Key][temp]++; // очередное слово идёт в конец списка, а первый элемент // списка удаляется Key.push_back(temp); Key.pop_front(); } // таблица вероятностей создаётся на основе данных таблиц частот. // если сочетание Key из К слов встречается в тексте Count[Key] // раз, а сочетание (Key, word) — Freq[Key][word] раз, // то Prob[Key][word] - Freq[Key][word] / CountfKey] for(map<list<string>, map<string, int> >::iterator p - Freq.begin(); p != Freq.endO; p+ + ) for(map<string, int>::iterator q = p->second.begin(); q != p->second.end(); q++) Prob[p->first][q->first] = q->second / double(Count[p->first]); } Для генерации текста необходимо уметь выбирать очередное случайное слово с учётом вероятностей, записанных в таблицу. Этим занимается фун- кция GetRandomWord(): // выбрать случайное слово на основе таблицы // (слово1, вероятность!), (слово2, вероятность2), // (словоЗ, вероятность!),..., (словом, вероятностьМ) string GetRandomWord(const map<string, double>& wordtable) { vector<string> words; for(map<string, double>::const_iterator p = wordtable.begin(); p != wordtable.end(); p++) fill_n(back_inserter(words), PrecCoeff*p->second, p->first); if(words.empty()) { cout « "Невозможно сгенерировать очередное слово!"; exit(0); } 183
C++ мастер-класс return words[random(words.size())]; } Здесь используется уже знакомая вам идея из решения п. 6.2.1 («Самообучающиеся крестики-нолики»): в массив words записывается несколько копий каждого слова таблицы в пропорциях, соответствующих отношениям вероятностей, а затем из массива выбирается случайный эле- мент. Генерацией текста занимается функция main (): int m^infint argc, char* argv[]) { string filename; randomize(); // считать входные параметры cin » К; cin » N; cin » filename; // создать таблицу вероятностей Analyze(filename); // выбрать случайный элемент таблицы (список из К слов) map<list<string>, map<string, double> >::iterator start - Prob.begin{); advance(start, random(Prob.size())); // сделать его стартовым list<string> CurKey = start->first; copy(CurKey.begin(), CurKey.end{), ostream_iteratcr<string>(cout, “ ")); // сгенерировать и вывести на экран оставшиеся N - К слов for(unsigned i = 0; i < N - К; i++) { string temp = GetRandomWord(Prob[CurKey]); CurKey.push_back(temp); CurKey.pop_front(); cout « temp « " "; } return 0; } В качестве примера приведу фрагмент текста, сгенерированного програм- мой на основе книги «Так говорил Заратустра» Фридриха Ницше (К = 2): 184
^aiaHaus^. Глава 7. Моделирование вероятностных процессов До сих пор, что человеку нужно его самое злое так ничтожно! Ах, его самое злое для его несовершенного Творца, — таким стоял мир на моей горе Елеонской; в освещенном солнцем уголку моей горы Елеонской пою и смеюсь я над моей суровою гостьей и благодарен ей еще за то, что каждый вносит в него, даже внутренняя скотина. Поэтому отговариваю я многих от одиночества. 7.2. КОМПЬЮТЕРНЫЕ ЭКСПЕРИМЕНТЫ 7.2.1. Экспериментальное определение числа л. Использование метода Монте-Карло Значение числа л с некоторой степенью точности можно определить с помо- щью эксперимента. Возьмём квадрат со стороной 2г и впишем в него круг. Теперь нанесём на чертёж большое количество точек в случайных местах квадрата. При достаточно большом их количестве отношение общего числа точек (N) к числу точек, попавших внутрь круга (No), будет примерно равно отношению площадей квадрата и круга: N/N * S /S О квадрата круга Поскольку $квадрата = (2г)2, a SKpyra = лг2, значение л можно получить по фор- муле: П ~ 4* No/N Задача заключается в экспериментальном определении числа л этим спо- собом. Подобная имитация случайных процессов называется методом Монте-Кар- ло и широко применяется при решении самых разных задач в физике, эко- номике и в других науках. 7.2.2. Доска Гальтона. Моделирование распределения Гаусса Случайные величины, с которыми приходится иметь дело при анализе за- дач, подчиняются разным законам распределения. Бросая кубик, мы ожи- даем, что каждое из шести указанных на гранях чисел выпадает с одной и той же вероятностью. Бросая два кубика одновременно, разумно ожидать, что сумма выпавших чисел будет чаще равна трём и реже — двум (потому что в первом случае годятся варианты 1-2 и 2-1, а во втором — лишь 1-1). Для демонстрации очень часто возникающего на практике нормального (га- уссовского) распределения (рис. 7.1) случайных величин можно использо- вать доску Гальтона. 185
C++ мастер-класс Рис. 7.1. Нормальное распределение случайной величины Это устройство представляет собой гладкую поверхность (доску), на кото- рой расположено несколько рядов клинышков. Первый ряд состоит лишь из одного клинышка, второй из двух, третий из трёх и так далее (рис. 7.2). Хотя здесь изображена доска из четырёх рядов, вообще говоря, их коли- чество ограничивается лишь трудолюбием автора. Наша промышленность, кстати, выпускает такие доски для нужд учебных заведений (стоимость од- ной доски составляет более 44 000 (!) рублей). Доска размещается вертикально. Сверху опускается круглая фишка или монетка. С вероятностью 50% она падает налево или направо, после чего попадает на один из клинышков второго ряда. Процесс продолжается, пока фишка не достигнет одного из желобков, расположенных на нижнем краю доски (если доска состоит из N рядов, в итоге фишка может закончить паде- ние в одном из N+1 положений). Рис. 7.2. Доска Гальтона 186
Глава 7. Моделирование вероятностных процессов При достаточно большом количестве экспериментов распределение фишек в желобках будет в той или иной степени соответствовать нормальному рас- пределению. Подозреваю, что далеко не все имеют желание покупать и устанавливать у себя в кабинете такую штуковину. Поэтому предлагается смоделировать доску Гальтона на компьютере. Пользователь вводит с клавиатуры количество рядов доски и число про- водимых испытаний. Компьютер выполняет моделирование (желательно вывести анимированную схему), а затем выводит гистограмму, иллюстри- рующую распределение фишек в желобках на краю доски. 7,2.3. Автобусная остановка Экспериментальная проверка гипотезы Представьте себе картину: холодный ноябрьский вечер, вы стоите на авто- бусной остановке, переминаясь с ноги на ногу, и мечтаете поскорее попасть домой, где вас уже ждёт горячий чай с конфетами или, быть может, что- нибудь покрепче. Наконец вы замечаете автобус, но, как назло, едет он в противоположную сторону. Вероятно, люди, профессионально разбирающиеся в психологии, скажут, что мы склонны обращать внимание на происшествия неприятные, в то время как мелкие удачи проходят незамеченными. Таким образом, всевоз- можные «законы бутерброда» порождены скорее нашим воображением, чем истинным положением вещей. Тем не менее, в случае с автобусной остановкой закон бутерброда действи- тельно работает, если сделать несколько нехитрых предположений. Итак, предположим, что единственный автобус курсирует между пунктами А и В с постоянной скоростью. Достигнув конечного пункта, автобус разво- рачивается и едет по той же самой дороге в обратном направлении. В слу- чайный момент времени человек выходит на остановку, расположенную в случайной точке С маршрута автобуса. Остановка, куда собирается ехать человек, находится в другой случайной точке маршрута — назовём её D. Утверждается, что проезжающий мимо ав- тобус с большей вероятностью направляется в сторону, противоположную интересующей. Эту задачу можно решить и математически, но прибережём такое решение для задачника по математике. Здесь же я предлагаю написать программу, моделирующую приведённую ситуацию. Пользователь вводит длину марш- 187
C++ мастер-класс рута (N) в километрах, длительность дня (то есть длину промежутка време- ни, в течение которого пассажир может выйти на остановку1) и количество экспериментов. Скорость автобуса считается единичной (один километр в единицу вре- мени). Компьютер печатает, сколько раз пассажиру повезло, а сколько раз — нет. Решение Решается эта задача довольно просто. Самое главное, что нам потре- буется — это формула, определяющая положение автобуса на маршруте в любой данный момент времени t: X(t) = t - int(t/2*N)*2*N Поскольку автобус курсирует по одному и тому же маршруту длиной 2 *N, его положение в момент времени t совпадает с положением в момент вре- мени t + 2*N (не забывайте, что автобус движется с единичной скоростью). Количество совершённых автобусом полных кругов (А —> В —> А) равно int (t /2*N), а соответствующее им расстояние — int (t / 2*N) *2*N. Вычитая из t эту величину, получаем текущее положение автобуса на мар- шруте. Теперь обсудим, как определить, совпадает ли направление автобуса, при- ближающегося к остановке, с желаемым. Здесь потребуется рассмотреть два варианта в зависимости от того, куда направляется пассажир (рис.7.3). Если человек едет в сторону пункта В (то есть С < D), то «удачному» поло- жению автобуса соответствует отрезок [А, С]. Аналогично для случая С > D «удачным» отрезком будет [С, В]. Осталось только прямолинейно запрограммировать процедуру моделиро- вания: int N, DayLen, Tries; cin » N; // длина маршрута cin » DayLen; // длительность дня cin » Tries; // количество итераций моделирования int succ_count =0; // количество удачных попыток randomize(); for(int i = 0; i < Tries; i++) { // определить исходную точку int С = random(N + 1); 1 Если длина дня равна DayLen, то время выхода на остановку можно выбрать как random(DayLen). 188
Глава 7. Моделирование вероятностных процессов Рис. 7.3. Возможные направления движения пассажира int D; // определить точку назначения while((D = random(N +1)) == С) int t = random(DayLen); // C ! = D // время выхода на остановку int bus_pos = t - (t / (2*N)) * 2 * N; // положение автобуса // если автобус расположен удачно if((С < D && bus_pos <= С) II (С > D && bus_pos >= С)) succ_count++; } // вывод общего количества удачных и неудачных попыток, а также // их отношения друг к другу в процентах cout « succ_count « " / " « (Tries - succ_count); cout « " (" « 100*succ_count/Tries « "% / " « (100 - 100*succ_count/Tries) « "%)"; Пример входных и выходных данных: 50 10 5000 2160 / 2840 (43% / 57%) 7ь2.4. Прокси-сервер Использование неравномерно распределённых случайных величин Рассмотрим вполне реалистичную ситуацию из жизни.Интернет-провай- дер покупает трафик по цене X копеек за мегабайт, а продаёт своим клиен- там по цене Y копеек за мегабайт (ясное дело, Y > X). Чтобы увеличить свои доходы, провайдер решил установить у себя прокси-сервер, кэширующий запрашиваемые пользователями документы. Если кто-то попытается ска- чать файл, хранящийся на прокси-сервере, провайдеру уже не придётся за- прашивать его из интернета: достаточно вернуть пользователю локальную копию. Провайдер ничего не заплатит за эту операцию, а с клиента возьмут по полной таксе — Y копеек за мегабайт. 189
C++ мастер-класс Требуется проимитировать работу системы и оценить примерный выигрыш провайдера от установки прокси-сервера. Попробуем формализовать ситуацию. Пусть к провайдеру подключено N пользователей. Их активность неравномерна: большинство делает запросы сравнительно редко, а активное меньшинство — очень часто (можно ис- пользовать нормальное распределение вероятностей, рис. 7.1). Распределение активности среди множества пользователей считается пос- тоянным. Так, если пользователь с идентификатором 12345 очень активен, он останется активным на протяжении всего процесса моделирования (хотя частота запросов и может несколько мепяться).Точпо так же распределение запросов по сайтам не является равномерным. С большей вероятностью пользователь заходит на какой-либо популярный ресурс (будем считать, что всего в интернете располагается S сайтов) и за- прашивает с него М мегабайт данных. Как и в случае с активностью пользо- вателей, мы полагаем, что популярность ресурса — величина в целом посто- янная (хотя и подверженная колебаниям). Вероятность того, что человек скачивает малый объём данных, высока. Ве- роятность скачивания большого объёма мала. Здесь можно воспользоваться правой частью кривой стандартного нормального распределения. Средний объём скачиваемой информации за один запрос оставляю на ваше усмотре- ние, но он, вообще говоря, не так уж и важен. Моделируя такую ситуацию, можно оценить прибыль компании без прок- си-сервера. Если добавляется сервер, ситуация меняется следующим образом. Изначально кэш сервера пуст. Первая итерация моделирования состоит из обработки Q запросов пользователей (значение Q выбирается произволь- но), после чего прокси-сервер скачивает Р наиболее популярных страниц. Значение Р представляет собой объём сервера, измеряемый для простоты в страницах, а не в мегабайтах. Рекомендую поэкспериментировать с различ- ными значениями Р. Каждая последующая итерация состоит в обработке очередной порции из Q запросов. При поступлении очередного запроса производятся действия (не забывайте заодно считать доходы провайдера): 1. Адрес запрошенного ресурса запоминается (чтобы получить статисти- ку по всем Q запросам). 2. Если сайт не находится в кэше, пользователю возвращается содержи- мое сайта из глобальной сети. 190
Глава 7. Моделирование вероятностных процессов 3. Если сайт находится в кэше, определяется, устарела ли информация2 (вероятность устаревания выше для популярных сайтов — действи- тельно, если сайт редко обновляется, он не будет популярным). 4. Если информация устарела, сайт заново скачивается из интернета. 5. Если не устарела, пользователю возвращается локальная копия из кэша. После обработки всех Q запросов содержимое кэша обновляется в соответс- твии с текущей популярностью ресурсов. Опять-таки, если сайт не устарел, его скачивать не требуется. В результате моделирования программа должна выводить отношение но- вой прибыли провайдера к старой (до установки прокси-сервера). Подсказка Как видно из условия задачи, решение будет скорее громоздким, чем слож- ным. Поэтому я решил ограничиться подсказкой лишь в одном затрудне- нии — в генерации случайных чисел, имеющих нормальное распределение. Конкретный вид графика зависит от двух параметров — стандартного от- клонения (standard deviation) и среднего (mean). Стандартное отклонение влияет на крутизну кривой, а среднее соответствует наиболее часто встре- чающейся случайной величине. Итак, предположим, что требуется получить случайную величину, распре- делённую нормально с параметрами Mean и StdDev. Сделать это можно с помощью следующего алгоритма: х = случайное число в промежутке [0, 1) у = случайное число в промежутке [0, 1) u - sqrt(-2 * Log(x)) * Cos(2 * Pi * у) результат = u * StdDev + Mean Если провести несколько тысяч экспериментов, график частот появления различных генерируемых случайных величин будет соответствовать нор- мальному распределению (рис. 7.4). 7.2.5. Автострада 2 Считается, что эта операция достаётся провайдеру бесплатно (что недалеко от истины: про- вайдер просто запрашивает дату последнего изменения страницы, то есть скачивает считанные байты информации). 191
C++ мастер-класс Рис. 7.4. Распределение сгенерированных случайных чисел Моделирование автомобильных пробок Эту задачу я заимствовал у Ч. Уэзерелла3. Условие задачи здесь изложено несколько подробнее, чтобы не допускать разночтений. Автомобильные пробки — не самое приятное явление. Думаю, жители крупных городов легко со мной согласятся. Центр обычно и так перегру- жен транспортом, а ведь и какую-нибудь улицу могут перекрыть или авария посреди дороги произойдёт. Однако не только в городе случаются пробки. Оживлённая скоростная автострада тоже может стать источником заторов. Но если в центре пробки можно сократить при помощи грамотного управ- ления потоками автомобилей, то на загородном шоссе большое значение приобретает контроль скорости. На первый взгляд скорость движения транспорта по автостраде мало связа- на с вероятностью возникновения пробок, но на самом деле это не так. Рассмотрим для простоты узкую однополосную дорогу. Если машин немно- го, ехать по такой трассе одно удовольствие. Если даже машин предостаточ- но, они спокойно проезжают, выстроившись в колонну и держа предусмот- ренную правилами дистанцию (маловероятно в наших условиях, но пусть будет так). Теперь представьте себе, что на дорогу выбежала собака. Или человек. Или у водителя что-то забарахлило в двигателе, и он начал резко сбавлять скорость, чтобы съехать на обочину. Короче говоря, предположим, 3 Charles Wetherell. Etudes for programmers. Prentice-Hall, NJ, 1978 (русский перевод: Ч. Уээе- релл. Этюды для программистов. Москва, Мир, 1982). 192
Глава 7. Моделирование вероятностных процессов что одному из водителей по какой-либо причине пришлось притормозить на несколько секунд. Если скорость движения машин на автостраде невелика, а между автомоби- лями соблюдается разумная дистанция, ничего страшного не произойдёт. Пройдёт ещё несколько секунд, и ритм полностью восстановится. Если же машины проносятся на скорости, приближающейся к первой космической, любая случайная задержка в пути может привести к серьёзным заторам. Притормозившая машина образует за собой огромный «хвост». Со време- нем плотность этого «хвоста» (то есть количество машин на единицу рас- стояния) уменьшается, а длина — растёт. Спустя какое-то время плотность машин в окрестности затора приближается к нормальной плотности движе- ния, и пробка рассасывается. Наша задача — проследить зависимость пропускной способности автостра- ды (машин в минуту) от «стандартной» скорости движения автомобилей. Пользователь вводит скорость, а компьютер моделирует ситуацию на доро- ге в течение некоторого времени и выводит результаты. Разберёмся теперь с деталями нашей модели. Все численные параметры мо- гут либо задаваться жёстко в программе, либо (что предпочтительно) вво- диться с клавиатуры. В программе моделируется фиксированный участок автострады, скажем, Length километров (типичное значение — 10-15). С помощью «генерато- ра автомобилей» в начале трассы создаётся очередной автомобиль каждые Interval секунд (5-10). Машина движется со скоростью Speed км/ч (тут уж фантазию можно проявить в полной мере!) «Генератор помех» раз в ТInterval секунд с какой-то вероятностью ус- траивает «помеху» случайно выбранному автомобилю. Помеха приводит к тому, что скорость этого автомобиля равномерно снижается до нуля (за каждую секунду машина сбавляет 15 км/ч), а затем возрастает до Speed (опять же, с каждой секундой скорость растёт на 15 км/ч). Если водитель какого-либо автомобиля замечает, что впереди идущая ма- шина замедляет ход, и расстояние между двумя машинами не превышает Distance (20-40) метров, он тоже начинает тормозить. Естественно, дела- ет он это не моментально: человек всё-таки не робот, И 0.2 секунды у него уходит на оценку ситуации и правильное на неё реагирование. Если ско- рость движения по автостраде высока, а допустимая дистанция мала, по- нятно, к чему такая политика может привести: задний автомобиль не успеет вовремя затормозить, и произойдёт авария. В этом случае моделирование прекращается, и пользователю выводится сообщение о непригодности дан- ных правил дорожного движения. 7 Зах 772 193
C++ мастер-класс Когда впереди идущий автомобиль начинает разгон, водитель следующей машины реагирует на это, и спустя 0.2 секунды тоже увеличивает скорость, если расстояние между автомобилями составляет Distance метров или больше. Если же автомобили находятся слишком близко, водитель сначала дожидается, пока впереди идущая машина не отъедет на расстояние Dis- tance, и лишь после этого начинает разгон. Автомобиль, благополучно доехавший до конца участка, снимается с трас- сы. Помимо численных результатов моделирования можно постоянно выво- дить на экран карту дороги с автомобилями, отмечаемыми точками. Решение Как известно, ни одна модель не может описать физический процесс точно, так как мы просто не в состоянии учесть все влияющие на ход событий фак- торы. В каждой модели присутствуют существенные, мало существенные и несущественные параметры. Кроме оговоренных в постановке задачи, по ходу разбора решения я буду указывать на допущения, которые здесь име- ют место. Основные мы рассмотрим сейчас. Во-первых, мы полагаем, что поломка у машины длится фиксированное время. Во-вторых, время реагирования у любого водителя одно и то же. В- третьих, скорость изменения скорости всех машин всегда одна и та же. Перейдём теперь к программной реализации. Для начала введём некоторые глобальные переменные: int highway_length = 10000; int car_interval = 500; int trouble_interval = 1000; double max_speed = 140; float car_distance = 20.; int trouble_counter = 0; int counter = 0; int car_number =0; // int minute_counter = 0; int minute_interval = 600; int minutes_passed = 0; int cars_on_way = 0; int cars_passed = 0; float traffic = 0; int modeling_speed = 1; // длина участка в метрах // sec/100 // sec/100 // km/h // m // счётчик генератора помех // счётчик генератора автомобилей номерной знак авто (идентификатор) счётчик долей минут количество итераций таймера для истечения одной минуты счётчик прошедших минут машин на трассе машин прошло трассу пропускная способность скорость моделирования И II И И И И И И Тип данных * Автомобиль» представлен классом ТСаг: class ТСаг { 194
^laiaiLaus^ Глава 7. Моделирование вероятностных процессов public: bool timer_on; // включён/выключен счётчик TCarState state; // текущее состояние машины (см. ниже) TColor color; 11 цвет (меняется в зависимости от // состояния) int number; // номер машины int reaction_time; // время реакции водителя на изменения //на дороге bool state_changed; // с помощью этой переменной мы // узнаём, что состояние изменилось и // нужно менять поведение int width; // ширина автомобиля long double speed; // текущая скорость машины long double speed_up; // скорость изменения скорости // (ускорение) float position; // позиция на трассе TCar(const double speed, const int run) ; -TCar(){} void UpDateO; // В этой функции будут происходить все // изменения void MakeTrouble(); // Создать помеху автомобилю ); Стоит пояснить назначение переменных timer_on и reaction_time. Как было сказано в постановке задачи, водитель реагирует не сразу, а спустя некоторое время (в условии указано конкретное значение — 0.2 секунды). Флаг timer_on включает обратный отсчёт времени, спустя которое води- тель отреагирует на уже произошедшие изменения на дороге. Переменная reaction time представляет собой длительность реагирования, обрат- ный счётчик. Как только его значение станет равным нулю, водитель отреа- гирует на события на дороге. Объявим теперь возможные состояния автомобиля на нашей автостраде: enum TCarState {csNormal, csTroubleSolved, csTrouble, csTroubleNoticed, csOvertake}; Кратко поясню каждое: • csNormal — оно и есть нормальное, ничего не делаем; • csTrouble — что-то случилось с автомобилем (работа генератора по- мех); • csTroubleSolved — проблема решилась; • csTroubleNoticed — машина впереди сбрасывает скорость; 195
C++ мастер-класс • csOvertake — расстояние до ближайшей впереди машины превы- сило минимальную допустимую дистанцию. Функция обновления TCar: : UpDate () выглядит следующим образом: void TCar::UpDate() { if (state_changed) { switch (state) { case csTrouble : speed -= speed_up; if (speed <= 0) { speed = 0; state = csTroubleSolved; color = clYellow; } break; case csTroubleSolved: speed += speed_up; if (speed >= max_speed) { speed = max_speed; state = csNormal; color = clGreen; state_changed = false; } break; case csTroubleNoticed: if ((reaction_time) { speed -= speed_up; if (speed < 0) speed = 0; //чтобы назад не поехал } else --reaction_time; //приближаем момент реагирования break; case csOvertake: if (!reaction_time) { speed += speed_up; if (speed > max_speed) { speed - max_speed; //не допускаем превышения скорости state = csNormal; } 196
^lataUaus^. Глава 7. Моделирование вероятностных процессов } else -~reaction_time; break; } } position += speed; } Здесь используется механизм наподобие конечного автомата4: в зависимос- ти от текущего состояния выполняем некоторые действия и при определён- ных условиях переходим к другому состоянию. Стоит отметить, что бездействие есть «выполнение ничего». Так, в состоя- нии csNormal машина ничего не изменяет кроме позиции, а позиция об- новляется всегда, поэтому нормальное состояние даже не рассматривается. Однако в нашей задаче состояния могут меняться не только «изнутри» са- мим автоматом, но и «снаружи» генератором помех посредством функции MakeTrouble(): void TCar::MakeTrouble() ( state_changed = true; state = csTrouble; color = clRed; } Разобравшись с автомобилями, займёмся главным окном нашей программы (рис. 7.5). На главную форму F_Main поместим объект типа TPanel для размещения всех управляющих элементов, придав свойству Align значение alBottom. На эту панель поместим пять объектов типа TLabeledEdit: LE_Length: длина моделируемого участка автострады; LE_CarInterval: частота генератора автомобилей; LE_TroubleIntеrva1: частота генератора помех; LE_Speed: максимальная допустимая скорость; LE_Distance: минимальная допустимая дистанция. На форму следует поместить ещё несколько элементов управления: B_Initialize: кнопка инициализации; SE_ModelingSpeed: элемент типа TSpinEdit для выставления скорости моделирования; B_StartStop: кнопка запуска/прекращения моделирования; ST_CarsOnWay: элемент типа TStaticText для отображения количества машин на трассе в текущий момент времени; ST_CarsPassed: количество машин, успешно проехавших участок; ST_Traffic: пропускная способность автострады; 4 Пользуясь случаем, прорекламирую книгу, в которой тема конечных автоматов обсуждается куда подробнее: М. Мозговой. Классика программирования: алгоритмы, языки, автоматы, компиляторы. Практический подход. СПб, «Наука и Техника», 2006. 197
C++ мастер-класс MainTimer: таймер (объект типа TTimer), работающий с частотой 100 «тиков» в секунду (значение свойства Interval таймера равно 10). Рис. 7.5. Главное окно программы «Модель автострады» На главную форму поместим ещё два объекта типа TImage: I_Bkg и I_Buffer, сделав их невидимыми (Visible - false) и растянутыми на всю область формы (Align = alClient). Последний элемент формы — объект PB Screen типа TPaintBox. Значе- ние его свойства Align также должно быть равно alClient. Инициализация параметров программы производится по нажатии кнопки B_Initialize: void___fastcall TF_Main::B_InitializeClick(TObject *Sender) { cqueue.clear(); cars_on_way = 0; traffic = 0; cars_passed = 0; car_number = 0; minutes_passed = 0; // скорость моделирования; если modeling_speed = 1, // моделирование происходит в режиме реального времени modeling_speed = SE_ModelingSpeed->Value;* // для удобства переведём все величины в метры highway_length = LE_Length->Text.Tolnt()*1000; counter = car_interval - LE_CarInterval->Text.Tolnt () *100/modeling_speed; trouble_interval = LE_TroubleInterval->Text.Tolnt()*100/modeling_speed; max_speed = LE_Speed->Text.ToDouble()/360*modeling_speed; car_distance = LE_Distance->Text.ToDouble(); minute_interval = 6000/modeling_speed; randomize(); } 198
Глава 7. Моделирование вероятностных процессов Нажатие кнопки B StartStop запускает и останавливает моделирование: void __fastcall TF_Main::B_StartStopClick(TObject *Sender) { if (MainTimer->Enabled) { MainTimer->Enabled = false; B_StartStop->Caption = "Пуск"; } else { MainTimer->Enabled = true; B_StartStop->Caption = ''Стоп*; Теперь следует организовать работу со списком машин и отобразить проис- ходящее на экране. Находящиеся на автостраде автомобили удобно хранить в объекте класса TCarQueue, расширяющего стандартный дек: template <class Т> class TCarQueue : public deque<T> { public: void Updated { for (TCarQueue<T>::iterator p = begin(); p != end(); p ++) { if(end()-1 != p) { TCarQueue<T>::iterator r = p; // потенциально опасное сближение: // включаем маневр сброса скорости. // Обратите внимание на очередность вычитания // или используйте функцию fabs() if (((p->position - r->position - r->width/2 - p->width/2) < car_distance) && (p->speed < r->speed) && (r->state == csNormal I I r->state == csOvertake)) r->timer_on = true; // пошёл обратный отсчёт r->state_changed = true; // изменяется состояние // машина впереди замедлила ход и расстояние // между машинами меньше допустимого r->state = csTroubleNoticed; r->color = clAqua; } // расстояние больше допустимой дистанции: // включаем маневр набора скорости if ((p->position - r->position - r->width/2 - p->width/2) > 199
C++ мастер-класс car_distance && r->speed < p->speed && (r->state == csNormal I I r->state == csTroubleNoticed)) { r->timer_on = true; r->stat^_changed = true; r->state = csOvertake; // едем догонять r->color = clLime; ) // проверка на столкновение if ((p->position - r->position - r->width/2 - p->width/2) < 0) { F_Main->B_StartStopClick(F_Main); AnsiString errjness = "Столкнулись машины " + (AnsiString)r->number + " и " + (AnsiString)p->number; Application->MessageBoxA(err_mess.c_str(), "Произошла авария", 0); F_Main->B_InitializeClick(F_Main); return; } } // обновление всей очереди машин: обновление каждой машины p->Update(); // если машина дошла до конца дороги, снимаем её с трассы if (p->position > highway_length + car_distance) { cars_on_way--; cars_pas sed+ + ; pop_front(); } } } }; TCarQueue<TCar> cqueue; Результаты моделирования отображаются на экране с помощью функции Draw (). Чтобы избежать мерцания при выводе графики, воспользуемся механизмом двойной буферизации5 (её суть заключается в том, что весь вы- вод сначала производится на скрытую графическую поверхность, а затем «за один присест» копируется на основной экран). void __fastcall TF_Main::Draw() { // стираем предыдущее содержимое экрана I_VBuffer->Canvas->CopyRect(Rect(0,0, PB_Screen->Width, 5 Последняя реклама на сегодня: М. Мозговой. Занимательное программирование. СПб, «Питер», 2004. 200
ftalatiautfik Глава 7. Моделирование вероятностных процессов PB_Screen->Height), I_Bkg->Canvas, Rect(0,0, PB_Screen->Width, PB_Screen->Height)); // рисуем на виртуальном экране все машины for (u_int i = 0; i < cqueue.size(); i ++) { I_VBuffer->Canvas->Brush->Color = cqueue[i].color; I_VBuffer->Canvas->Ellipse(cqueue[i].position/highway_length * PB_Screen->Width - 5, 10, cqueue[i].position/highway_length*PB_Screen->Width + 5, 16); } // копируем содержимое буфера на основной экран PB_Screen->Canvas->CopyRect(Rect(0, 0, PB_Screen->Width, PB_Screen->Height), I_VBuffer->Canvas, Rect(0, 0, PB_Screen->Width, PB_Screen->Height)); ) He забудьте объявить открытую функцию-член Draw () в описании класса формы. Собственно моделирование поведения списка машин происходит в обра- ботчике события OnTimer объекта Ma inTime г: void __fastcall TF_Main: .-MainTimerTimer (TObject *Sender) { if (++counter > car_interval) { TCar c = TCar(max_speed/2, car_number++); cqueue,push_back(c); counter = 0; cars_on_way++; } if (++trouble_counter > trouble_interval && cqueue.size() > 1) { cqueue[random(cqueue.size()-1)].MakeTrouble(); trouble_counter = 0; } if (++minute_counter > minute_interval) { ST_CarsOnWay->Caption = 'Машин на трассе: ' + (AnsiString)cars_on_way; ST__CarsPassed->Caption = 'Прошло машин: " + (AnsiString)cars_passed; if (cars_passed > 0) { minutes_passed++; ST_Traffic->Caption = 'Пропускная способность: ' + (AnsiString)(float)cars_passed/minutes_passed + ' машин в минуту"; } minute_counter = 0; } 201
C++ мастер-класс cqueue.Update(); Draw(); } Очень интересную картину можно наблюдать при следующих начальных данных: допустимая скорость = 80 (км/ч) частота генератора автомобилей = 5 (сек) допустимая дистанция = 40 (м) частота генератора помех = 15 (сек) Если у какой-либо машины случается поломка, то ещё в течение несколь- ких итераций в этом месте можно наблюдать сгущение машин (собственно, пробку), которое с течением времени рассасывается (рис. 7.6). Рис. 7.6. Возникновение пробки на дороге 7.2.6. Змейки-лесенки: экспериментальный анализ игры Эта игра (рис. 7.7) в том или ином виде существует уже не первую сотню лет. Уверен, многие из вас немало времени потратили на что-либо подобное в детстве. В игре участвует сколько угодно человек. Изначально фишки игроков ста- вятся на клетку с номером 1. Затем участники по очереди бросают кубик и передвигают свои фишки на выпавшее на кубике количество клеток впе- рёд. Если фишка остановилась на клетке, с которой начинается «лесенка» (например, 2, 4, 10; конечные клетки «лесенок» вроде 22 или 37 к таковым не относятся), она автоматически переносится на клетку, в которую «лесен- ка» ведёт. «Змейка», наоборот, переносит игрока назад (от головы к хвосту). Игра заканчивается, как только один из игроков достиг финишной клетки (даже если на кубике выпало больше очков, чем требуется). Он и объявля- ется победителем. Теперь нам предстоит провести компьютерный анализ игры. Задача доста- точно проста. Требуется сыграть достаточное количество партий (несколь- ко сотен), в которых участвует лишь один человек. Затем следует вывести 202
ftataHausiixli Глава 7. Моделирование вероятностных процессов гистограмму, иллюстрирующую распределение количества ходов, необхо- димых для завершения игры. Так, высота столбца с номером к показывает, сколько партий закончилось на k-м ходу. 7.3. БИОЛОГИЧЕСКИЕ МОДЕЛИ 7.3.1. Волчий остров. Классическая биологическая модель Эта и следующая задачи описаны у Д. Ван Тассела6. Где-то в океане находится Волчий остров, имеющий форму квадрата, разделенного на 20x20 = 400 кле- ток. Население острова составляют кролики, волки и волчицы. Изначально остров случайным образом заселяется представителями каждого вида (по нескольку особей). Обратите внимание, что в любом квадрате может одно- временно находиться несколько животных. На каждом шаге моделирования производятся следующие действия: • Каждый кролик случайным образом перемещается в одну из восьми соседних клеток (выход за пределы острова, естественно, невозмо- жен) или остаётся на месте. Вероятность любого исхода одинакова. • С вероятностью 0.2 каждый кролик «размножается» (видимо, деле- нием), превращаясь в двух кроликов. 6 Dennie Van Tassel. Program style, design, efficiency, debugging, and testing, 2nd ed. Prentice-Hall, NJ, 1978 (русский перевод: Д. Ван Тассел. Стиль, разработка, эффективность, отладка и испытание про- грамм, 2-е изд. Москва, Мир, 1985). 203
г C++ мастер-класс • Каждая волчица передвигается случайным образом, если в ближай- ших клетках нет кроликов. Если же в одной из восьми соседних кле- ток обнаруживается кролик, волчица начинает на него «охоту», то есть целенаправленно перемещается в клетку, содержащую кролика. Если волчица оказывается в одной клетке с кроликом, она его съе- дает и получает одну единицу энергии. Любой «холостой» ход без поедания добычи отнимает у волчицы 0.1 единиц энергии. • Волк ведёт себя аналогично волчице. Если ни в одной из соседних клеток нет кроликов, но есть волчица, волк направляется за ней. Если волк и волчица оказываются в одной клетке, не содержащей кролика, они производят потомка случайного пола (характерно, что еда имеет приоритет перед продолжением рода). Изначально каждый волк и каждая волчица имеют по одной единице энер- гии. Животные, растерявшие всю свою энергию, погибают (исчезают с ос- трова). Задание заключается в моделировании жизни на Волчьем острове в течение некоторого промежутка времени. Автор модели заметил, что обычно волки и волчицы довольно быстро ис- требляют всю популяцию кроликов, после чего погибают от голода (вот к чему приводит жадность). Модель можно сделать более сбалансированной, если пометить небольшой квадрат внутри острова как «кроличью ферму», куда ход волкам запрещён. 7.3.2. Инфекция стригущего лишая Ещё одна модель из биологии Несмотря на зловещее и, быть может, даже отпугивающее название, с точки зрения исследования эта модель очень интересна. Задача заключается в моделировании процесса распространения инфекции стригущего лишая по участку кожи размером N*N клеток (N — нечётное число). Инфекция начинается с центральной клетки участка. В каждый момент вре- мени поражённая инфекцией клетка с вероятностью 1/2 заражает любую из восьми соседних здоровых клеток. Через шесть итераций моделирования заражённая клетка становится невосприимчивой к инфекции, а ещё через четыре итерации — здоровой. В процессе моделирования требуется выводить на экран схему участка кожи, отмечая разными цветами заражённые, здоровые и невосприимчивые к инфекции клетки. 204
Глава 7. Моделирование вероятностных процессов ^aiaHaus,^. Решение Начнём с описания участка кожи размером N*N клеток: enum STATE { HEALTHY, INFECTED, IMMUNE }; // состояние клетки struct Cell // клетка { STATE State; // её состояние int Count; //и счётчик итераций Cell(STATE _state = HEALTHY, int _count = 0) : State(_state), Count(_count) {} }; int N; vector<vector<Cell> > Skin; // участок кожи Счётчик итераций помогает отследить момент времени, когда клетка стано- вится невосприимчивой к инфекции или здоровой. Поскольку задача связана с выводом графики, я предполагаю, что из прило- жения доступна главная форма (MainForm), на которой находится элемент DrawingArea типа TImage, две кнопки btnReset и btnStep, а также поле ввода edSize. Кнопка btnReset инициализирует участок кожи N*N с инфицированной центральной клеткой: void___fastcall TMainForm::btnResetClick(TObject *Sender) { // размер участка кожи N = atoi(edSize->Text.c_str()); Skin.resize(N + 2); for(int i = 0; i <= N + 1; i++) ( Skin[i].resize(N + 2); // все клетки здоровы for(int j = 1; j <= N; j + + ) Skin[i][j].State = HEALTHY; } // центральная клетка заражена Skin[N / 2 + 1][N / 2 + 1] = Cell(INFECTED, 6); // обновить содержимое экрана UpdateScreen(); randomize(); } Чтобы не возникало проблем с выходом за границы массива при обра- ботке близких к краям клеток, я искусственно расширил размеры с NxN 205
C++ мастер-класс до (N + 2) х (N + 2) (при этом реально будут использоваться координаты 1...N]). Функция обновления экрана UpdateScreen () перерисовывает текущее состояние участка кожи, закрашивая клетки цветами: здоровая — белый, инфицированная — красный, невосприимчивая — зелёный. void UpdateScreen() { // размеры клетки в пикселях double Сх = MainForm->DrawingArea->Width / (double)N, Су = MainForm->DrawingArea->Height I (double)N; for(int i = 1; i <= N; i++) for(int j = 1; j <= N; j++) { TColor c = Skin[i][j].State == HEALTHY ? clWhite : (Skin[i][j].State == INFECTED ? clRed : clGreen); // нарисовать закрашенный прямоугольник MainForm->DrawingArea->Canvas->Brush->Color = с; MainForm >DrawingArea--^Canvas >FillRect(Rect((i 1)*Cx, (j-1)*Cy, i*Cx, j*Cy)); //и чёрную кайму вокруг MainForm->DrawingArea->Canvas->Brush->Color = clBlack; MainForm->ErawingArea->Canvas->FrameRect(Rect((i-1)*Cx, (j 1)*Cy, i*Cx, j*Cy)); } } Нажатие кнопки btnStep запускает процедуру шага моделирования: void___fastcall TMainForm::btnStepClick(TObject ‘Sender) { // цикл по всем клеткам for(int i = 1; i <= N; i++) for(int j = 1; j <= N; j++) { // если клетка невосприимчива и ей пора выздороветь, // делаем клетку здоровой if(Skin[i][j].State == IMMUNE) if(--Skin[i][j].Count == 0) Skin[i][j].State = HEALTHY; // если клетка заражена и должна стать невосприимчивой, // делаем клетку невосприимчивой на четыре итерации if(Skin[i][j].State == INFECTED) if(--Skin[i][j].Count == 0) Skin[i][j] = Cell(IMMUNE, 4); } 206
Глава 7. Моделирование вероятностных процессов // список клеток, подлежащих заражению list<pair<int, int> > Tolnfect; // цикл.по всем клеткам for(int i = 1; i <= N; i++) for(int j = 1; j <= N; j++) if(Skinfi][j].State == INFECTED) { // если клетка заражена, рассматриваем соседние: // с вероятностью 1/2 каждая соседняя здоровая клетка // попадает в список Tolnfect for(int х - i - 1; x <= i + 1; x++) for(int у = j - 1; у <= j + 1; y++) if(Skin[x][y].State == HEALTHY && random(2) == 0} Tolnfect.push_back(pair<int, int>(x, y)) ; } // делаем каждую клетку из списка Tolnfect зараженной // на б итераций for(list<pair<int, int> >::iterator p = Tolnfect.begin(); p != Tolnfect.end(); p++) Skin[p->first][p->second] = Cell(INFECTED, 6); UpdateScreen(); Результат работы программы после пятнадцати итераций моделирования показан на рис. 7.8. Рис. 7.8. Модель инфекции стригущего лишая

ГЛАВА 8. ОПЕРАЦИИ С ТЕКСТОВЫМИ ДАННЫМИ
Обработка текстовых данных — тематика почти столь же старая, что и ре- шение математических задач. В последнее время анализ строк связывается ещё и с актуальными проблемами генетики (в частности, последовательнос- ти генов ДНК описываются текстовыми строками). Алгоритмам на строках посвящают целые книги1. В этой главе описывается несколько жизненных, но сравнительно редко встречающихся в литературе примеров, связанных с обработкой текстов. Наиболее интересной задачей главы, на мой взгляд, является «Проверка правописания», в которой описывается алгоритм вычисления расстояния Левенштейна, позволяющий оценить «близость» двух текстовых строк. 8.1. В КАЧЕСТВЕ РАЗМИНКИ - ПОИСК АНАГРАММ Ничего, кроме смекалки Анаграммами называются слова, которые можно получить путём переста- новки одних и тех же букв (например, агротехник — оргтехника). Требуется решить классическую задачу поиска в переданном на вход текс- товом файле групп слов, являющихся анаграммами. Практическая ценность ее сомнительна, но мне нравится идея её решения. Пример ввода: кот брак бра ток крот бар краб Программа должна вывести: брак барк краб бра бар крот ток кот 1 См., например, Д. Гасфилд. Строки, деревья и последовательности в алгоритмах: информати- ка и вычислительная биология. СПб.: БХВ, 2003. 210
^latattaus,^. Глава 8. Операции с текстовыми данными Решение Идея алгоритма решения этой задачи очень проста. Каждому слову из ис- ходного списка следует сопоставить строку, в которой буквы слова располо- жены в алфавитном порядке: (кот, кот) (брак, абкр) (бра, абр) (ток, кот) (барк, абкр) (крот, корт) (бар, абр) (краб, абкр) Если теперь отсортировать полученный список пар, используя второй эле- мент каждой пары в качестве ключа, то все анаграммы окажутся рядом друг с другом: (брак, абкр) (барк, абкр) (краб, абкр) (бра, абр) (бар, абр) (крот, корт) (ток, кот) (кот, кот) Осталось лишь пройтись по списку и распечатать найденные анаграммы. 8.2. ПРОВЕРКА ПРАВОПИСАНИЯ. ИСПОЛЬЗОВАНИЕ РАССТОЯНИЯ ЛЕВЕНШТЕЙНА В текстовом файле задан набор слов («словарь»). Считайте, что в словаре записаны все формы слова, поэтому думать о падежах, числах и родах нет необходимости. Требуется для любого входного документа вывести список слов этого доку- мента, не найденных в словаре. Для каждого такого слова напечатать спи- сок предлагаемых вариантов похожих на него корректных слов. Например, если входной документ содержит фразу: тарелка стояла на стле. 211
C++ мастер-класс. 85 нетривиальных проектов, решений и задач то программа должна напечатать: Не найдено: стле. Варианты: стиле, столе, стуле. Похожесть слов определяется с помощью расстояния Левенштейна. Проце- дура определения расстояния между входными строками s и t приведена на псевдокоде: int LevenshteinDistance(char s[l..n], char t[l..m]) int d[0..n, 0..m]; int i, j, cost; for i := 0 to n d[i,0] := i; for j := 0 to m d[O,j] := j; for i := 1 to n for j := 1 to m if s[i] = t[j] then cost := 0; else cost : = 1; d[i,j] := minimum(d[i-l,j ] + 1, // вставка d[i, j-1] +1, // удаление d[i-l,j-l] + cost); // замена return d[n,m]; Алгоритм возвращает количество операций редактирования (вставки, за- мены или удаления символа), требуемое для получения второй строки из первой. Если расстояние равно нулю, строки равны, единице — похожи, двум и более — непохожи. 8.3. БАННЕРОРЕЗАЛКА, ИЛИ ПОИСК СТРОК ПО ШАБЛОНУ Наверно, многие из вас пользуются теми или иными программами, фильтру- ющими рекламу. Когда браузер не отображает рекламные баннеры, заметно возрастает скорость загрузки страниц и уменьшается трафик (разумеется, включать фильтр стоит, только если вас действительно не интересует рек- лама). Как система фильтрации отличает рекламный баннер от обычной картин- ки? Очень просто. Дело в том, что большинство баннеров располагаются не на просматриваемом сайте, а на страницах баннерообменных систем. Если запретить загрузку изображений с этих страниц, баннеры отображаться не будут. 212
^laiaHaus^. Глава 8. Операции с текстовыми данными Можно также использовать «эвристические» методы. Например, если неко- торая картинка или flash-анимация находится в подкаталоге banners или adverts, загружать её, вероятно, не стоит. Конечно, ни одна система фильтрации не даёт стопроцентного результата, но на практике распознавание и девяти баннеров из десяти — уже весьма неплохой показатель. Задача заключается в программировании ядра баннсрорезалки. На вход программе поступает список фильтров (о нём ниже) и обрабатываемая HTML-страница. На выходе создаётся страница с вырезанными баннерами. Хорошие системы фильтрации обычно замещают баннеры пустыми прямо- угольниками, поэтому внешний вид страницы не очень страдает. В нашем случае для простоты можно вместо ссылки на баннер поставить ссылку на локальный компьютер http://127.0.0.1. Например, фрагмент: ciframe src="http://sj7.ru/cgi-bin/iframe/news240?248" width=240 height=400 marginwidth=0 marginheight=O scrolling=no frameborder=0> будет заменён на: ciframe src="http://127.0.0.1" width=240 height=400 marginwidth=0 marginheight=O scrolling=no frameborder=0> В браузере при этом отобразится рамка, сигнализирующая об отсутствии ресурса (рис. 8.1). [х] CAPhost banner Рис. 8.1. «Вырезанный» баннер Элементами списка фильтров являются шаблоны строк, описывающие ад- реса баннеров. Например: */adframe.* */ads??.* ‘/banner/* ♦counter*.bravenet.* Символы подстановки * и ? несут стандартную смысловую нагрузку: знак * означает произвольное количество любых символов (в том числе пустую строку), знак ? замещает ровно один произвольный символ. 213
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Так, ссылка http: //myserver. ru/banner/bannerl. jpg соответству- ет третьему элементу списка и, следовательно, распознаётся как баннер. Работа с символами подстановки не так проста, как кажется на первый взгляд. Полноценный механизм распознавания соответствия строки шаб- лону требует использования перебора с возвратами. Рассмотрим простой (хотя и несколько искусственный) пример. Требу- ется определить, соответствует ли строка аа_Ы_аа_Ь1_Ь2_аа шаблону *Ы?Ь2*. Соответствие, конечно, существует, но как его определить? Простейший жадный алгоритм найдёт сначала подстроку Ы, затем пропустит символ подчёркивания, соответствующий подстановочному знаку ?. Теперь в стро- ке ожидается подстрока Ь2, но на входе окажется другая последователь- ность — аа_Ы_Ь2_аа. Чтобы соответствие всё-таки было найдено, алго- ритму придётся начать поиск со второй подстроки Ы. Таким образом, мы приходим к перебору с возвратами. В этой задаче достаточно ограничиться «жадной» реализацией механизма распознавания символов подстановки (без возвратов). 8.4. ТРАНСЛИТЕРАЦИЯ DLYATEH, КТО NE MOZHET PISAT’ PO-RUSSKI Думаю, каждый хотя бы раз сталкивался с этим жутковатым способом за- писи русских слов с помощью символов латиницы. Надеюсь, со временем необходимость печатать латиницей русскоязычные тексты полностью ис- чезнет, но пока что существуют устройства, не позволяющие печатать ки- риллицей (либо установить соответствующее программное обеспечение по каким-либо причинам нельзя); встречаются также системы, кириллицу не отображающие. Таким образом, транслит жив и в ближайшем будущем ни- куда не денется. Учитывая это, я предлагаю написать программу, выполня- ющую прямую и обратную транслитерацию текстов на русском языке. Прямая транслитерация — это запись русскоязычного документа с помо- щью латинских букв («Иван ушел домой» —> “Ivan ushel domoy”). Обратите внимание, что большинству русских букв обычно соответствует одна буква латинского алфавита («р» —> “г”, «д» —> “d”), но некоторым буквам может соответствовать несколько латинских символов («ш» —♦ “sh”, «ю» —> “уи”), а иным и ни одного (твёрдый знак). Обратная транслитерация — это перевод транслитерированного текста в обычную запись кириллическим шрифтом. Здесь есть свои сложности. 214
Глава 8. Операции с текстовыми данными По идее, конечно, последовательная транслитерация и обратная трансли- терация некоторого текста не должна вносить в него никаких изменений («Иван» —> “Ivan” —♦ «Иван»), но на практике достичь этого результата не- просто. Например, слово «шкаф» будет записано латиницей как “shkaf”, а слово «схема» — как “shema”. Как теперь процедура обратной транслитерации может догадаться, что в первом случае сочетание “sh” требуется преобразовать в «ш», а во втором — в «сх»? Можно попытаться выкрутиться, кодируя «х» не буквой h, а соче- танием “kh”, но по существу это ничего не меняет. В кириллическом алфавите букв больше, чем в латинском, поэтому какие- то различные русские буквы неизбежно будут переданы одними и Геми же латинскими символами. Не забывайте, к тому же, что результирующий текст должен быть ещё и читабельным. 8.5. АББРЕВИАТОР, ИЛИ КАК ПРАВИЛЬНО ПРОИЗНЕСТИ «КД-ПЗУ»? В мире уже давно ведутся разработки систем синтеза речи (text-to-speech engines). Хотя существуют и коммерческие продукты, на мой взгляд, они ещё далеки от идеала (особенно если речь идёт о русском языке). Аббреви- атор — это один из модулей такой системы. Чтобы произнести любое слово, надо уметь определять его транскрипцию, то есть переводить буквы и буквосочетания в звуки, которые можно запи- сать, например, с помощью символов транскрипции IPA (International Pho- netic Alphabet). Если для обычных слов эта процедура как-то работает, то для аббревиатур требуется отдельный подход. В частности, аббревиатура может произно- ситься по буквам (МГУ, СПбГУ), чего никогда не требуется для слов, не являющихся аббревиатурами. Конечно, можно написать отдельный алгоритм перевода аббревиатур не- посредственно в звуки, но проще воспользоваться уже готовой процедурой транскрибирования обычных слов, “скормив” ей слово, полученное из аб- бревиатуры: МИФИ —* мифи СПбГУ —* эспэбэгэу Для преобразования аббревиатур в произносимые «слова» и предназначен модуль аббревиатора. 215
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Хочу ещё раз обратить ваше внимание на то, что слова «мифи» и «эспэбэ- гэу» не являются транскрипциями, так как не содержат описаний произно- симых звуков. Они лишь представляют собой соответствующие аббревиа- турам «обычные» слова, читаемые по правилам русского языка. Задача заключается в разработке такого аббревиатора. Попробуйте выявить правила, определяющие способ произношения аббре- виатуры: как обычное слово или по буквам. 11апримср, известно такое на- блюдение: если первые две буквы аббревиатуры являются согласными, сло- во с высокой вероятностью произносится по буквам. Чем больше правил вы найдёте, тем лучше; хотя без списка исключений, наверное, всё равно не обойтись. 8.6. ВЫРАВНИВАНИЕ ПО ШИРИНЕ. КРАСИВОЕ ФОРМАТИРОВАНИЕ ТЕКСТА Следующая задача относится к разряду классических. Её решение вы види- те каждый раз, когда работаете с любым современным текстовым редакто- ром. Дан текстовый файл с обычным для таких файлов форматированием: сим- вол возврата каретки/перевода строки разделяет отдельные абзацы, в пре- делах же одного абзаца слова разделены пробелами. Для заданного числа N требуется сформировать выходной текстовый файл, в котором каждая строка будет содержать не более N символов, после чего записывается символ перевода строки (предполагается, что значение N достаточно велико, чтобы вместить хотя бы одно слово). Текст в итоговом файле должен быть выровнен по ширине. Пример входного файла: Мы постарались сделать наш продукт по возможности простым и удобным в использовании. При возникновении каких-либо затруднений обратитесь к справочной системе. Если же её содержимое Вас не удовлетворит, обратитесь в службу поддержки. Наши специалисты будут рады Вам помочь. * Пример выходного файла для N = 40: Мы постарались сделать наш продукт по возможности простым и удобным в использовании. При возникновении каких-либо затруднений обратитесь к справочной системе. Если же её содержимое Вас не удовлетворит, обратитесь в службу поддержки. Наши специалисты будут рады Вам помочь. 216
Nafattausiiik Глава 8. Операции с текстовыми данными Программа должна расставлять дополнительные пробелы между словами равномерно, но не совершенно одинаковым образом в каждой строке. К примеру, если в две подряд идущие строки требуется добавить по одному пробелу, не стоит производить добавление после первого слова каждой из строк. Равномерность должна сочетаться с некоторой случайностью при выборе позиций вставляемых пробелов, чтобы готовый текст выглядел кра- сивее. 8.7. РАССТАНОВКА ПЕРЕНОСОВ Экскурс в правила русского языка Мы изучали перенос. Вот как слова я перенёс: «Едва» я перенёс «е-два». И получил за это «два». «Укол» я перенёс «у-кол» И получил за это «кол». «Опять» я перенёс «о~пять». Теперь, наверно, будет «пять»?! А. Шибаев Расстановка переносов — ещё одна задача, возникающая при работе с тек- стами. Задача заключается в том, чтобы разработать алгоритм, автомати- чески расставляющий переносы в русских словах. С клавиатуры вводится слово, а компьютер выдаёт все возможные переносы в нём. Пример: телевизор —»те-ле-ви-зор. Подсказка При решении задачи следует руководствоваться правилами русской ор- фографии, официально утверждёнными ещё в 1956 году (их можно найти, например, на странице http: //www.primavista.ru/pravila). Здесь я приведу только самые простые нормы, касающиеся расстановки переносов. Их реализация не должна вызвать у вас особых затруднений. § 117. При переносе слов нельзя ни оставлять в конце строки, ни пере- носить на другую строку часть слова, не составляющую слога; например, нельзя переносить просмо-тр, ст-рах. § 118. Нельзя отделять согласную от следующей за ней гласной. Неправильно: люб-овь, дяд-енька, реб-ята, паст-ух. 217
г C++ мастер-класс. 85 нетривиальных проектов, решений и задач Правильно: лю-бовь, дя-денька и дядень-ка, па-стух и пас-тух. Примечание. Если после приставки стоит буква ы, то переносить часть слова, начинающуюся с ы, не разрешается. Неправильно: раз-ыскать. Правильно: ра-зыскать, разыс-кать. § 119. Кроме правил, изложенных ⧧117и118, необходимо руководс- твоваться ещё следующими правилами: Нельзя отрывать буквы ь и ъ от предшествующей согласной. Неправильно: под-ъезд, бол-ьшой, бул-ъон. Правильно: подъ-езд, боль-шой, буль-он и бу-льон. Нельзя отрывать букву й от предшествующей гласной. Неправильно: во-йна, сто-йкий, фе-йерверк, ма-йор. Правильно: вой-на, стой-кий, фей-ерверк и фейер-верк, май-ор. Нельзя оставлять в конце строки или переносить на другую строку одну букву. Неправильно: а-кация, акаци-я. Правильно: ака-ция. Нельзя оставлять в конце строки или переносить в начало следую- щей две одинаковые согласные, стоящие между гласными. Неправильно: жу-жжать, ма-сса, ко-нный. Правильно: жуж-жать, мас-са, кон-ный. 218
^aiaHaus,^. ГЛАВА 9. РАЗЛИЧНЫЕ АЛГОРИТМЫ
Козьма Прутков настойчиво напоминает: нельзя объять необъятное! Почти каждая задача из этой книги иллюстрирует какой-либо принцип, достой- ный отдельной главы. Однако объём книги не беспределен, да и кругозор автора тоже. Поэтому неудивительно, что некоторые вполне самостоятель- ные темы оказываются представленными всего лишь двумя-тремя задача- ми. Таким темам и посвящена эта глава. Описываемые задачи объединены в четыре группы: «стратегии для игр», «анализ и обработка изображений», «стеганография» и «специализирован- ные алгоритмы». В разделе «стратегии для игр» Мы изучим устройство оптимальных алгорит- мов компьютерного игрока в различных известных играх, таких как Мас- термайнд, шахматы (разыгрывание простого эндшпиля) и Minesweeper. Часть, озаглавленная «анализ и обработка изображений» посвящена неко- торым специальным алгоритмам, работающим с чёрно-белыми изображе- ниями. Речь пойдёт, в частности, о преобразовании изображений (обрезка и поворот), их анализе (см. п. 9.2.3 «Жесты мыши») и сжатии. Под заголовком «стеганография» объединены задачи, посвящённые инте- ресному методу сокрытия секретной информации — стеганографии. Ду- маю, многие читатели услышат о нём впервые. В секции «специализированные алгоритмы» обсуждаются методы специ- ального вида, предназначенные для решения нестандартных задач. Иног- да они базируются на весьма общих принципах, широко используемых на практике (например, в п. 9.4.1 «Оптимальное вычисление» применяется принцип динамического программирования). 220
JVafatfaws;^* Глава 9. Различные алгоритмы 9.1. СТРАТЕГИИ ДЛЯ ИГР 9.1.1. Мастермайнд Разработка стратегии для классической игры Написать программу, способную играть в классическую логическую игру «Мастермайнд» на стороне любого из противников. Загадывающий игрок набирает ряд из четырёх цветных фишек (цвета в ряду могут повторяться сколько угодно раз; всего доступно шесть различ- ных цветов). Отгадывающему игроку предоставляется десять попыток, чтобы определить набранный соперником ряд. Во время очередного хода отгадывающий выставляет на доске свою версию ряда, а загадывающий с помощью чёрных и белых отметок указывает, насколько отгадывающий приблизился к правильному ответу. Белая отметка указывает, что в текущем ряду отгадывающего есть фишка требуемого цвета, но она находится на неверной позиции (рис. 9.1). подсказки ряд отгадывающего ряд загадывающего Рис. 9.1. Мастермайнд: белая отметка В ряду загадывающего есть белая фишка (это правильно), есть и чёрная (тоже правильно), но находятся они на неверных позициях. Заметьте, что расположение чёрно-белых отметок — произвольное, оно не объясняет, ка- кие именно фишки найдены в загаданном ряду. Чёрная отметка указывает, что в текущем ряду отгадывающего есть фиш- ка требуемого цвета, и она находится на корректной позиции (чёрная, рис. 9.2). Пожалуй, единственный неоднозначный момент в расстановке подсказок связан с повторяющимися цветами в ряду отгадывающего. Предположим, 221
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Рис. 9.2. Мастермайнд: черная отметка загадан ряд из трёх белых фишек и одной чёрной. Отгадывающий ставит три чёрные фишки на неверных местах и одну серую. Сколько белых под- сказок должно быть установлено? Здесь действует такая логика: поскольку в решении нужна всего одна чёрная фишка, мы считаем, что соперник уга- дал её существование, но не угадал позицию (то, что он не угадал три раза, сути дела не меняет). Таким образом, будет установлена всего одна белая подсказка. Конечно, Мастермайнд — игра хорошо изученная. Для отгадывающего иг- рока существуют хорошие стратегии. Однако я не буду лишать вас удоволь- ствия поразмыслить над ними самостоятельно. Подсказка Хотя ещё в семидесятых годах Дональд Кнут предложил стратегию, гаран- тировано разгадывающую любую комбинацию фишек за пять ходов, аль- тернативные решения предлагаются по сей день. Дело в том, что некоторые алгоритмы могут давать лучшие результаты в среднем, допуская при этом редкие «осечки». Так, К. Коуаша и Т. W. Lai разработали алгоритм, требу- ющий в среднем 4.340 хода, но на некоторые комбинации затрачивающий целых шесть ходов. Здесь я опишу на псевдокоде решение, приведённое в Википедии1. По-ви- димому, алгоритм наподобие этого и был предложен Кнутом, но точной ин- формации на этот счёт у меня нет. Первым делом следует представить в виде множества S набор всех допус- тимых секретных комбинаций цветных фишек. Поскольку фишек всего че- 1 На данный момент этот алгоритм удалён из статьи об игре в Мастермайнд. 222
ftalaHausf'k Глава 9. Различные алгоритмы тыре, а разных цветов шесть, число комбинаций равно 64 = 1296, что для современных компьютеров совсем немного. Предположим теперь, что отгадывающий (то есть компьютер) уже сделал ряд попыток, и мы располагаем их результатами (конфигурациями белых и чёрных отметок). В начале игры число сделанных ходов, конечно, равно нулю. На первом шаге алгоритма требуется определить все комбинации, противо- речащие полученным ранее подсказкам, и удалить их из множества S. На втором шаге печатается ответ, если множество S содержит единствен- ный элемент. Если множество S пусто, это значит, что в процессе игры зага- дывающий допустил какую-либо ошибку при установке подсказок, и задача более не имеет решений. На третьем шаге (до которого алгоритм дойдёт, только если множество S содержит более одного элемента) некоторым образом выбирается следую- щий ход отгадывающего из множества S, а управление переходит к первому шагу. Если первый и второй шаги более-менее реализуемы, третий скрывает в себе всю суть алгоритма игры. Как уже упоминалось выше, существуют различные схемы выбора «наилучшего» хода. Разумно выбрать комбина- цию, приводящую к максимальному уменьшению множества S. Для этого придётся просмотреть все не противоречащие текущей ситуации попытки в поиске наилучшей кандидатуры. Впрочем, как показывают опыты, даже простейший случайный выбор способен разгадать комбинацию в среднем за 4.6 хода, что мне представляется вполне удовлетворительным решени- ем. Автор этого подхода, впрочем, указывает, что случайный выбор может привести к партии длиной даже в 8 ходов (по его оценке вероятность такого исхода не превышает 0.05%). 9.1.2. Эндшпиль Формализация алгоритма матования Требуется написать программу, способную заматовать одинокого короля, управляемого человеком, с помощью ладьи и короля. Первый ход предо- ставляется чёрным. Начальная расстановка фигур фиксирована: чёрный король на Е5, белый король на D1, белая ладья на Н2. Дополнительное задание: решить задачу для произвольной расстановки фигур, вводимой с клавиатуры. 223
C++ масУер-класс. 85 нетривиальных проектов, решений и задач Решение Здесь я приведу решение лишь для жёстко заданной начальной расстанов- ки фигур, указанной в условии: белый король на D1, белая ладья на Н2, чёрный король на Е5. Общей процедурой решения шахматных эндшпилей является так называе- мый ретроградный анализ — просмотр позиций в обратном порядке (от ма- товых к позициям «мат в один ход» и так далее до того, пока не встретится текущее расположение). Однако эндшпиль «король-ладья-король» очень прост, и стрельба из пушки по воробьям здесь вряд ли оправдана. К счастью, существует совершенно чёткий алгоритм, приводящий к победе белых. На неформальном уровне он выглядит так. Для начала следует выбрать любой из краёв доски, к которому будет оттес- няться чёрный король. В моём решении используется верхний край, то есть восьмая горизонталь. Теперь ладья должна перекрыть чёрному королю дорогу к противополож- ному краю доски, переместившись на горизонталь, предшествующую гори- зонтали короля (чёрные: Кр f5, белые: Л Ь4; рис. 9.3). Рис. 9.3. Оттеснение черного короля Следующий шаг — продвижение белого короля на третью горизонталь (предшествующую горизонтали ладьи). Разумеется, в случае возникнове- нии угрозы ладье со стороны чёрного короля ладью необходимо перемес- тить в безопасную клетку на той же горизонтали. Далее, действуя совместно, белые должны добиться возникающей после хода чёрных ситуации оппозиции, то есть расположения разделённых од- ной свободной клеткой королей на одной вертикали (рис. 9.4). 224
^afafiaus;^* Глава 9. Различные алгоритмы Рис. 9.4. Оппозиция королей Очередным ходом (Л h5) белые вынуждают чёрного короля сдвинуться ближе к краю доски. Если король уже находится на восьмой горизонтали, игра заканчивается победой белых. Осталось запрограммировать этот алгоритм, содержащий некоторое коли- чество подводных камней. Начнём с описания начального расположения фигур на поле: pair<char, int> ВК('е', 5), WK('d', 1) , WR('h', 2); // чёрный король /I белый король 11 белая ладья Для удобства работы с программой нам потребуется функция, выводящая на экран текущую ситуацию на доске: void PrintBoardO { // белую клетку изображает пробел, черную - символ OxDB - // закрашенный прямоугольник char S[] = { char(OxDB), ' ', char(OxDB) }; // цикл по горизонталям for(int i = 8; i >= 1; i--) { cout « i; 11 цикл по вертикалям for(char j = 'a'; j <= 'h'; j++) { pair<char, int> c(j, i); if(WK == c) cout « "WK"; // else if(BK -- c) cout « "BK"; else if(WR == c) cout « "WR"; // номер горизонтали /I текущая позиция если там белый король // чёрный король // белая ладья 225
C++ мастер-класс. 85 нетривиальных проектов, решений и задач // пустая клетка else cout « S[j % 2 + i % 2] « S[j % 2 + i % 2] ; } cout « endl; } cout « " a b c d e f g h* « endl « endl; Немного загадочное выражение] % 2 + i % 2 определяет символ (пробел или OxDB), соответствующий текущей клетке. Результат вызова Print Board () для начального расположения фигур показан на рис. 9.5. Рис. 9.5. Вид шахматной доски на экране компьютера Поскольку мы собираемся поставить мат чёрному королю на восьмой гори- зонтали, проверка на матовую ситуацию оказывается очень простой: // возвращает true, если чёрным поставлен мат bool CheckMate() { return ВК.second 8 // чёрный король на восьмой горизонтали && WK.second -- 6 // белый король на шестой горизонтали && ВК.first == WK.first // короли находятся в оппозиции && WR.second =- 8; // белая ладья на восьмой горизонтали } Теперь можно заняться самой важной функцией, выполняющей очередной ход белых. С точки зрения программирования в ней нет ничего сложного, но последовательность проверки различных условий и приоритетность принимаемых решений далеко не всегда очевидны. Не скрою, первые версии этой функции нередко вели себя очень глупо и неоптимально. Быть может, изъянов не лишена и текущая реализация, но мне их обнаружить не удалось. 226
^alallaas^i!. Глава 9. Различные алгоритмы void WhiteMove() { if (BK. first 1 =- WR. first) // если король угрожает ладье слева { WR.first ~ 'а'; return; } // переводим ладью на левый фланг if(ВК.first - 1 == WR.first) // если король угрожает ладье справа { WR.first - 'h'; return; } // переводим ладью на правый фланг // если чёрный король отошёл от ладьи ближе к краю доски if(ВК.second > WR.second + 1) // двигаем ладью ближе к королю { WR.second = ВК.second - 1; return; } // если чёрный король находится на вертикали а, а белый — на // вертикали b делаем «выжидательный» ход ладьёй, вынуждая // следующим ходом чёрного короля стать в оппозицию if(ВК.first ~~ 'а' && WK.first == 'b' && ВК.second - WK.second -- 2) { WR.first--; return; } // аналогично для правой вертикали if(ВК.first == 'h' && WK.first == 'g' bt BK.second - WK.second == 2) { WR.first++; return; } // если короли находятся в оппозиции, делаем шах if(WK.first == ВК.first && ВК.second - WK.second == 2) { WR.second++; return; } pair<char, int> OldWK = WK; // если белый король находится по крайней мере на две клетки // правее чёрного, двигаем белого короля влево if(WK.first - 1 > ВК.first) WK.first--; 'else if(WK.first + 1 < BK.first) // если левее - двигаем вправо WK.first++; // если белый король находится на слишком далёкой от if(ВК.second - WK.second > 2) WK.second++; // двигаем белого короля вверх чёрного горизонтали (то есть вперёд) if (WK != OldWK) // если белый король был сдвинут, ход белых окончен return; // по возможности возвращаем белую ладью // на ближайшую к ней крайнюю вертикаль if(WR.first != 'а' && WR.first <= 'd') ( WR.first = 'a'; return; } if(WR.first != 'h' && WR.first >= 'e') { WR.first = 'h'; return; } // если нет ходов получше, // просто сдвигаем ладью ближе к середине доски 227
C++ мастер-класс. 65 нетривиальных проектов, решений и задач if(WR.first <= 'd') WR.first++; else WR.first--; } Осталось написать простую функцию main (), отвечающую за разыгрыва- ние эндшпиля между человеком и компьютером: int main(int argc, char* argv[]) { PrintBoard(); while(JCheckMate()) // пока не поставили мат { string move; // считать ход чёрных с клавиатуры cin » move; // (ошибки не проверяются) ВК = pair<char, int>(move[0], atoi (move, substr (1). c_str ())) ; PrintBoard(); // выполнить ход белыми WhiteMove(); PrintBoard(); ) return 0; } 9.1.3. Чемпион no Minesweeper Сложная стратегия для простой игры Наверно, все знают эту замечательную маленькую игрушку, входящую в со- став ОС Windows (рис. 9.6). На прямоугольном поле расставлены мины (их количество зависит от сложности уровня). Цель игры — щёлкая мышью по клеткам, открыть все незаминированные области поля. Если под очередной клеткой окажется мина, вам засчитывается поражение. Если же клетка пуста, в ней отображается число мин, расположенных по соседству (у каж- дой клетки восемь соседних). Используя эту информацию, можно продвигаться по полю с меныпим риском. Например, единичка рядом с единственной закрытой клеткой однозначно сигнализирует: там мина! Рис. 9.6. Игра Minesweeper (Сапер) 228
Глава 9. Различные алгоритмы Для удобства закрытые клетки можно метить флажками (если вы точно знаете, что туда лезть не следует, просто поставьте флажок на память). При щелчке по клетке, не имеющей соседних мин, в стандартной реализа- ции Minesweeper для Windows открывается вся безопасная область до пер- вых ненулевых чисел включительно, а вместо нуля отображается пустая клетка. Реализация игры — дело нехитрое. Будем считать это разминочной за- дачей. Интереснее запрограммировать алгоритм, достойно играющий в Minesweeper вместо человека. Подсказка Первый этап анализа игры очевиден. Пока все клетки закрыты, алгоритму ничего не остаётся, кроме как сделать один-два хода наудачу. Далее, если количество закрытых клеток, соседних с некоторой открытой, совпадает с числом, записанным в открытой клетке, можно сделать вывод: закрытые клетки содержат мины. Если количество разведанных мин, примыкающих к открытой клетке, рав- но числу, записанному в ней, оставшиеся закрытые клетки можно смело от- крывать — мин там нет. Этот алгоритм играет, в лучшем случае, на уровне новичка. Нередко игроку приходится делать ходы в условиях недостатка информа- ции. При этом далеко не все опасные ходы опасны одинаково: попытаться открыть одну из восьми клеток, окружающих единицу, гораздо разумнее, чем одну из восьми клеток, окружающих четвёрку или пятёрку. О стратегиях для игры в Minesweeper пишут целые статьи, и вставлять в книгу многочисленные страницы, посвящённые анализу этой головоломки, мне кажется не совсем уместным. Лучше обратиться к первоисточникам. Для начала могу предложить две интересные ссылки: • Статья Sean Barret. “Minesweeper: Advanced Tactics” www.planet-minesweeper.com/advanced..php • Truffle-Swine Keeper - программа-аналог Сапера (co встроен- ным решателем), исходный код которой доступен по адресу http://people.f reenet.de/hskopp/swinekeeper.html 229
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 9.2. АНАЛИЗ И ОБРАБОТКА ИЗОБРАЖЕНИЙ 9.2.1. Обработка сканированного изображения Коррекция сканированной картинки: обрезка и поворот При переводе документов в электронный вид с помощью сканера возника- ют небольшие, но досадные проблемы, от которых было бы неплохо изба- виться перед дальнейшими действиями. В частности, сканированные доку- менты часто приходится подвергать повороту и обрезке. Поскольку практически невозможно идеально ровно вложить лист бумаги в сканер, полученное изображение будет повёрнуто на некоторый (скорее всего, небольшой) угол. Это искажение несложно исправить автоматичес- ки, повернув картинку таким образом, чтобы границы текста сканирован- ного документа были параллельны краям изображения. Бумажные документы обычно имеют поля по краям, нередко довольно ши- рокие. Поля же в отсканированном изображении приносят больше вреда, чем пользы. Избавиться от них при печати не удастся, указать свои собс- твенные поля — тоже. Наилучшее решение состоит в обрезке сканирован- ного документа, в результате чего на изображении остается только текст. Итак, задача состоит в разработке программы, выполняющей поворот и об- резку сканированных изображений. На вход подаётся чёрно-белая картин- ка (двухцветная, без градаций серого), содержащая отсканированную стра- ницу. На выход направляется она же, только повёрнутая и обрезанная. Обратите внимание, что для выполнения каждого действия вам потребуется правильно определить границы текста на изображении. В принципе, пред- полагая чёрный текст на белом фоне, можно найти четыре крайних чёрных пикселя со всех сторон и считать их границами области. Однако при скани- ровании не исключено возникновение случайных чёрных пикселей за пре- делами текста. Лучше рассматривать изображение не на уровне отдельных пикселей, а, скажем, на уровне квадратов размером 3x3. Если такой квадрат содержит 3-4 чёрных точки, его уже можно считать краем области текста. Подсказка Если с обрезкой дела обстоят довольно просто, то функция поворота мо- жет вас несколько смутить. В действительности точки изображения ни- чем не отличаются от точек любой двумерной фигуры, и для операций с ними можно воспользоваться формулами, приведёнными в решении п. 2.1.3 («Проволочная» графика). 230
Глава 9. Различные алгоритмы 9.2.2. Архиватор монохромных изображений Алгоритм RLE В предыдущей задаче нам пришлось столкнуться с чёрно-белыми изобра- жениями, представляющими собой отсканированные страницы с текстом. Поскольку содержимое этих картинок весьма специфично (чёрный текст на белом фоне), можно попытаться разработать специализированный архива- тор для их сжатия. Задача заключается в разработке алгоритма и програм- мировании архиватора, умеющего выполнять сжатие и распаковку файлов, содержащих сканированный текст. Формат изображения не играет роли. Если кому-то удобнее работать в BMP, пусть будет BMP, если PNG — тоже нет возражений. Единственное ограни- чение — отсутствие потерь качества при сжатии. Поэтому форматы вроде JPEG, предназначенные в первую очередь для фотографий, не годятся. Подсказка В качестве хорошей базы для экспериментов могу порекомендовать алго- ритм RLE (Run-Length Encoding). Он заменяет каждую последовательность из N одинаковых элементов чис- лом N, благодаря чему достигается сжатие. В случае монохромных изобра- жений эта методика работает особенно эффективно. Пусть белый цвет (фон) кодируется нулём, а чёрный (тон) — единицей. Тог- да последовательность: 00000000111000011111 будет заменена набором чисел: 8 3 4 5 Перед тем, как двигаться дальше, придётся сделать пару замечаний. Во- первых, мы считаем изображение потоком чисел 0 и 1, игнорируя его дву- мерную структуру (строки картинки хранятся друг за другом). Поэтому в сжатом файле необходимо сохранить информацию о ширине исходного изображения, чтобы распаковщик смог правильно восстановить оригинал. Во-вторых, нет необходимости явно указывать, какой именно цвет соот- ветствует набору из восьми, трёх, четырёх или пяти пикселей. За последо- вательностью белых точек всегда идёт последовательность чёрных и наобо- рот. Первый (верхний левый) пиксель картинки всегда считается белым. В противном случае первое число генерируемого набора будет равно нулю («нуль белых точек»). 231
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Для практического применения RLE потребуется также ограничить макси- мально допустимую длину последовательности точек одного цвета. Можно порекомендовать какое-нибудь число, предшествующее «круглому», на- пример, 15 (значения в промежутке 0-15 помещаются в четыре бита). Если в картинке встретится, скажем, двадцать подряд идущих белых пикселей, ситуацию придётся закодировать последовательностью 15 0 5 Полученная последовательность чисел записывается непосредственно в выходной файл. Конечно, описанная методика очень проста, и достичь высокой степени сжатия с её помощью не удастся. Каждый пиксель исходной (несжатой) картинки кодируется одним битом, а каждый элемент сгенерированной алгоритмом RLE последовательности — четырьмя. Поэтому файл умень- шится в размере, лишь если в нём будут преобладать длинные строки из пикселей одного цвета. В качестве дополнительного задания можно запрограммировать какой-ни- будь алгоритм оптимального хранения чисел последовательности (напри- мер, метод Хаффмана или арифметическое кодирование). 9.2.3. Жесты мыши Модный СПОСОБ ВВОДА КОМАНД Эта методика ввода команд стала довольно популярной в последнее вре- мя. Она используется, в частности, в таких разных программах, как игра Darwinia и браузер Maxthon. Суть методики заключается в следующем: вы зажимаете правую кнопку мыши и «рисуете» на экране какую-либо фигуру. Компьютер сравнивает её со списком имеющихся у него фигур и, в случае успеха, выполняет со- поставленную фигуре операцию. Например, жест «вверх-вниз» в Maxthon соответствует обновлению текущей страницы. В жесте важна не только фигура, но и процесс её рисования. Так, треуголь- ник, заданный движением мыши по часовой стрелке, отличается от треу- гольника, начерченного в обратном направлении. Задача заключается в программировании ядра механизма, обрабатывающе- го жесты мыши. Система должна работать в двух режимах: запись и распоз- навание. В режиме записи пользователю предлагается «начертить» мышью фигуру и сохранить её в памяти под каким-либо именем. В режиме распоз- 232
Глава 9. Различные алгоритмы навания фигура, изображённая пользователем, сравнивается с содержимым памяти. Если рисунок определён, выводится соответствующее сообщение. Конечно, изобретать полноценный метод распознавания образов не требу- ется. Для анализа типичной фигуры будет вполне достаточно представить экран в виде сетки размером 3><3 и определить, по каким из девяти образо- вавшихся клеток проходят линии рисунка. Решение Решение этой задачи одинаково связано как с алгоритмическими вопроса- ми, так и с разработкой приемлемого интерфейса пользователя. Создавае- мое приложение будет состоять из главной формы, на которой располага- ются две кнопки TButton, список записей TLi st Box и область рисования TPaintBox (рис. 9.7). Свойства элементов интерфейса перечислены в табл. 9.1. Рис. 9.7. Главная форма приложения «Жесты мыши» 233
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Таблица 9.1. Элементы управления приложения «Жесты мыши» Элемент Свойства Назначение Главная форма Name = MainForm Главная форма приложения Первая кнопка Name = btnRecord Caption = “Запись” Кнопка,позволяющая пользователю нарисовать на экране фигуру и записать её в память компьютера Вторая кнопка Name = btnRecognition Caption = “Распознавание” Кнопка, по которой производится рисование новой фигуры и ей сравнение с ранее записанными в память рисунками Список записей Name = IsRecords Список, соответствующий запи- санным в память рисункам Область черчения Name = DrawingArea Область для вычерчивания фигур мышью Жестам мыши сопоставлены их текстовые описания: «треугольник», «квадрат», «буква Z» и т.п. Максимально возможное количество хранимых фигур равно десяти. В списке Is Records содержится десять элементов, начальное значение каждго из которых равно строке «пусто». Щелчком по любой из кнопок («Запись» или «Распознавание») пользователь переходит в режим рисования. Зажав левую кнопку мыши, человек вычерчи- вает фигуру. Далее действия программы зависят от того, какой режим выбран. Если мы находимся в режиме записи, на экране появляется диалоговое окно с предложением ввести название только что вычерченного жеста, которое зано- сится в текущий элемент списка IsRecords. Если активирован режим рас- познавания, компьютер сравнит новую фигуру со всеми рисунками в памя- ти и выведет название распознанного жеста. Если новый жест не похож ни на один из записанных, компьютер выносит вердикт: «не распознано». Начнем с алгоритмической части. В процессе записи нам ничего не остаётся делать, кроме как сохранять все вычерченные мышью точки без изменений. Результатом будет длинный список пар (X, Y) координат посещённых то- чек. Затем фигура покрывается сеткой размером 3x3 клетки (рис. 9.8). Заметьте, не вся область рисования, а только реально использованная её часть. Это небесспорное решение я принял исходя из своих представлений о том, как должна работать система распознавания жестов мыши. Мне кажется, что размер и расположение фигуры не имеют никакого значения. Маленькая буква Z, находящаяся в левом нижнем углу экрана должна распознаваться 234
^ataHaus,^. Глава 9. Различные алгоритмы Рис. 9.8. Наложение сетки на вычерченную фигуру так же, как и буква Z, занимающая всю область рисования. Вполне вероят- но, что в иной задаче дела будут обстоять совсем по-другому Каждая клетка задается парой координат в пределах от (0, 0) до (2, 2). Те- перь запись движений мыши можно «сжать», оставив в ней лишь информа- цию о посещенных клетках сетки. Здесь, однако, тоже есть своё затрудне- ние. Посмотрите на клетки (0, 1) (первый столбец, вторая строка) и (2, 1) (третий столбец, вторая строка). Линии рисунка по этим клеткам проходят, но с тем же успехом они могли бы пройти чуть выше или чуть ниже. Понят- но, что клетка, лишь немного «зацепленная» линией, не может считаться полноценной составляющей фигуры. Но что значит «немного»? Я предлагаю следующий простой критерий. Если проведённая линия захватывает горизонтальный участок больше половины ширины клетки или вертикальный участок больше половины её высоты, клетка считается входящей в состав фигуры. Перейдем к разработке программы. Для начала потребуется описать не- сколько глобальных объектов: // вектор записей (десять записей, каждая из которых // представляет собой список координат (X, Y) отмеченных клеток сетки) vector<vector<pair<int, int> > > Records(10); II запись распознаваемой фигуры vector<pair<int, int> > Testlmage; // указатель на текущую фигуру vector<pair<int, int> > *CurrentDrawing; // нажата ли левая кнопка мыши bool IsMouseDown = false; 235
C++ мастер-класс. 85 нетривиальных проектов, решений и задач // режим работы программы enum { RECORD, RECOGNITION } Mode; B конструкторе формы текущим элементом списка устанавливается пер- вый, а также увеличивается толщина линий рисунка, чтобы чертёж выгля- дел приятнее: __fastcall TMainForm::TMainForm(TComponent* Owner) : TForm(Owner) { lsRecords->ItemIndex = 0; DrawingArea->Canvas->Pen->Width = 5; } Щелчок по кнопке «Запись» переводит программу в режим записи: void __fastcall TMainForm::btnRecordClick(TObject '*Sender) { // очистка экрана DrawingArea->Canvas->FillRect(Rect(0, 0, DrawingArea->Width, DrawingArea->Height)); II запись будет производиться в элемент lsRecords->ItemIndex // вектора Records CurrentDrawing - ^Records[lsRecords->ItemIndex]; Mode = RECORD; } Аналогично обработчик нажатия на кнопку «Распознавание» включает режим распознавания: void __fastcall TMainForm: .-btnRecognitionClick (TObject *Sender) { 11 очистка экрана DrawingArea->Canvas->FillRect(Rect(0, 0, DrawingArea->Width, DrawingArea->Height)); // запись будет производиться в объект TestImage CurrentDrawing = &TestImage; Mode ~ RECOGNITION; } При нажатии на левую кнопку мыши выполняется функция-обработчик события OnMouseDown области рисования: void___fastcall TMainForm::DrawingAreaMouseDown(TObject *Sender, TMouseButton Button, TShiftState Shift, int X, int Y) { IsMouseDown = true; // левая кнопка мыши нажата CurrentDrawing->clear () ; //очистить содержимое записываемого рисунка // сохранить текущее положение мыши CurrentDrawing->push_back(make_pair(X, Y)); } 236
^ataHaus,^. Глава 9. Различные алгоритмы Обработка события OnMouseMove сводится к добавлению очередной пары координат (X, Y) в состав записи рисунка: void___fastcall TMainForm:: Dr awingAreaMous eMove(TObject *Sender, TShiftState Shift, int X, int Y) { // если режим рисования активен i f(IsMous eDown) { // изобразить на экране отрезок от предыдущей точки записи // до текущей (вывод фигуры в виде набора отдельных точек, // а не отрезков смотрится менее симпатично) int OldX = CurrentDrawing->back().first; int OldY = CurrentDrawing->back().second; DrawingArea->Canvas->MoveTo(OldX, OldY); DrawingArea->Canvas->LineTo(X, Y); // добавить (X, Y) в запись рисунка CurrentDrawing->push_back(make_pair(X, Y)); } } Основная часть алгоритма выполняется при отпускании левой кнопки мыши, то есть в обработчике события области рисования OnMouseUp. Рассмотрим сначала три небольших служебных функции. Функция GetBlock() возвращает координаты клетки сетки, соответствую- щей переданным координатам точки и набору координат углов рисунка: // рисунок находится в прямоугольнике (MinX, MinY) - (МахХ, MaxY) // Element — точка рисунка pair<int, int> GetBlock(paircint, int> Element, int MinX, int MaxX, int MinY, int MaxY) { return make_pair(3*(Element.first - MinX) I (MaxX - MinX + 1), 3*(Element.second - MinY) I (MaxY - MinY + 1)); ) Предикаты LessX и LessY служат для сортировки пар координат по значению X и Y: // возвращает true, если X-составляющая Ihs меньше Х-составляющей rhs bool LessX(pair<int, int> Ihs, paircint, int> rhs) ( return Ihs.first < rhs.first; } // возвращает true, если Y-составляющая Ihs меньше Y-составляющей rhs bool LessY(paircint, int> Ihs, paircint, int> rhs) { return Ihs.second c rhs.second; } Код обработчика события OnMouseUp приведен ниже. 237
C++ мастер-класс. 85 нетривиальных проектов, решений и задач void___fastcall TMainForm::DrawingAreaMouseUp(TObject ‘Sender, TMouseButton Button, TShiftState Shift, int X, int Y) { II выйти из режима рисования IsMouseDown = false; // определить границы (MinX, MinY, MaxX, MaxY) рисунка int MinX = min_element(CurrentDrawing->begin(), CurrentDrawing->end(), LessX)->first; int MinY - min_element(CurrentDrawing->begin(), CurrentDrawing->end(), LessY)->second; int MaxX = max_element(CurrentDrawing->begin(), CurrentDrawing->end(), LessX)->first; int MaxY = max_element(CurrentDrawing->begin(), CurrentDrawing->end(), LessY)->second; 11 ширина и высота клеток получившейся сетки int BlockW = (MaxX - MinX + 1) /3, BlockH - (MaxY - MinY + 1) I 3; // «сжатая» запись фигуры vector<pair<int, int> > Path; vector<pair<int, int> >::iterator c = CurrentDrawing->begin(); do ( // определить клетку очередной точки рисунка paircint, int> CurBlock = GetBlock(*c, MinX, MaxX, MinY, MaxY); int MaxBX = MinX, MinBX = MaxX, MaxBY = MinY, MinBY = MaxY; // пока мы находимся в той же самой клетке // и рисунок не кончился while(GetBlock(*с, MinX, MaxX, MinY, MaxY) == CurBlock && c != CurrentDrawing->end()) { MinBX = min(c->first, MinBX); // найти крайние точки MaxBX = max(c->first, MaxBX); // линии рисунка MinBY = min(c->second, MinBY); // в пределах MaxBY =•max(c->second, MaxBY); // текущей клетки C++; // перейти к следующей точке рисунка } // если линия проходит по «существенной» части клетки, // добавить клетку к записи if(MaxBX - MinBX + 1 > BlockW / 2 II MaxBY - MinBY + 1 > BlockH /2) Path.push_back(CurBlock); } while(c != CurrentDrawing->end()); // пока рисунок не кончился // в режиме записи if(Mode == RECORD) 238
^alaHaus,^. Глава 9. Различные алгоритмы { // ввести название жеста lsRecords->Items->Strings[IsRecords->ltemlndex] = InputBox("*, “Название жеста", ""); // записать фигуру в вектор Records Records [lsRecords->IteinIndex] = Path; } // в режиме распознавания else if(Mode == RECOGNITION) { Il найти в векторе Records объект, равный текущей записи (Path) for(unsigned i - 0; i < Records.size(); i++) if(Records[i] == Path) { // если объект найден, сообщить об успешном распознавании Application->MessageBox( lsRecords->Iteins->Strings [i] .c_str () , 0) ; return; } // сообщить о неудаче Application->MessageBox("Не распознано", 0) ; } 9.3. СТЕГАНОГРАФИЯ, ИЛИ МАСКИРОВКА НАЛИЧИЯ ПРИСУТСТВИЯ 9.3.1. Стеганография в тексте Пересылка секретных сообщений Передача секретных сообщений — задача давно известная и по-прежнему актуальная. В новостях регулярно сообщается как об изобретении новых криптографических алгоритмов, так и о нахождении уязвимостей в сущес- твующих. Однако криптография — не единственный метод защиты секретной инфор- мации от посторонних глаз. С давних пор популярна также стеганография, то есть сокрытие самого факта наличия секретного сообщения. Если вЫ посылаете другу письмо, состоящее из непонятных цифр, словесной абра- кадабры и странных крючков, перехвативший его злоумышленник сразу поймёт, что имеет дело с шифровкой (и, вероятно, приложит все усилия для её разгадывания). Если же в письме сообщается о здоровье вашей тё- тушки и её небывалых успехах в деле выращивании редиски, потенциаль- ный злоумышленник вряд ли заподозрит что-нибудь. А просветить письмо 239
C++ мастер-класс. 85 нетривиальных проектов, решений и задач ультрафиолетовыми лучами, чтобы увидеть написанное на полях, надо ещё догадаться. Стеганография в электронных документах тоже возможна. В качестве «пись- ма о тётушке», в котором скрывается истинное секретное сообщение, может выступать обычный текстовый документ, изображение или трЗ-файл. В этой задаче предлагается спрятать сообщение внутри текстового файла. Как это сделать? Например, можно использовать систему «один пробел/ два пробела». Каждая пара соседних слов исходного файла будет кодиро- вать один бит секретного сообщения. Если между словами находится один пробел, то этот бит равен нулю, если два — единице. Так, фраза В * огороде''моей’тётушки'растёт’огромная *’редиска! скрывает двоичное число 010001 (пробелы обозначены символами °). Таким образом, восемь пар слов позволяют закодировать один байт информации. Итак, задача заключается в программировании системы стеганографии. Система работает в двух режимах: кодирование и декодирование. В режиме кодирования на вход программе подаются два текстовых файла: секретное сообщение и подставное письмо. Первым делом система опреде- ляет, достаточен ли размер подставного письма для кодирования секрет- ной информации. Если недостаточен, пользователю предлагается выбрать файл побольше. Затем производится кодирование, и на выходе генериру- ется письмо с внедрённым секретным сообщением. Обратите внимание, что первым делом следует закодировать размер сообщения, иначе на этапе декодирования вы не сможете определить, что все данные уже извлечены, и процесс пора прекратить. В режиме декодирования на вход поступает лишь письмо с внедрёнными данными. Программа его анализирует и печатает на экране секретное сооб- щение. Решение Общая идея решения уже была описана в формулировке задачи, так что мне остаётся лишь привести реализацию с комментариями. Программа будет работать в двух различных режимах. В режиме кодирова- ния (команда е) текст сообщения внедряется в текст подставного письма, и результат выводится в файл encoded.txt: steganograph.exe е <файл_письма> <файл_секретного_сообщения> 240
Глава 9. Различные алгоритмы В режиме декодирования (команда d) из переданного на вход письма извле- кается секретное сообщение и записывается в файл decoded.txt: steganograph.exe d <файл_письма> Таким образом, функция main () вызывает в зависимости от переданной команды либо функцию кодирования, либо функцию декодирования: int main(int argc, char* argv[]) { if(argv[1][0] == 'e') Encode(argv[2], argv[3]); else if(argv[1][0] == 'd') Decode(argv[2]); return 0; } Рассмотрим сначала процесс кодирования. Необходимо предварительно вычислить длину кодируемого сообщения и выяснить, достаточен ли раз- мер письма для его хранения. В этом нам помогут функции CountChars () и Countspaces(). Функция Соиn-tChars () определяет количество символов в переданном входном файле. Мы не можем пользоваться для этой цели встроенной фун- кцией istream: :tellg(), возвращающей длину файла в байтах, пос- кольку символ возврата каретки / перевода строки занимает два байта и, следовательно, для файла из нескольких строк функция tellg() будет возвращать неправильный результат. int CountChars(char *filename) { ifstream in(filename); int Count = 0; char c; while (in.get (с) ) // пока из файла можно считать очередной символ Count++; return Count; } Функция Count Spaces () возвращает количество пробелов (точнее, про- межутков между словами, состоящих из одного или более пробельных сим- волов) в переданном файле: int Countspaces(char *fname) { // количество пробелов равно количеству слов минус единица int Spaces = -1; string temp; ifstream letter(fname); 9 Зак. 772 241
C++ мастер-класс. 85 нетривиальных проектов, решений и задач while(letter » temp) Spaces++; return Spaces; } Основную работу по выводу закодированного файла выполняет функция OutByte (). Она считывает очередные восемь слов из входного файла letter, расставляет между ними пробелы в соответствии с содержимым байта с и выводит результат в выходной файл outfitе: void OutByte(unsigned char c, ifstreamb letter, ofstream& outfile) { for(int i = 0; i < 8; i++) { string s; letter » s; // считать очередное слово письма outfile « s « * "; // вывести слово с одним пробелом if(с & 1) // если очередной бит байта с равен // единице outfile « * *; // вывести и второй пробел с »= 1; // перейти к следующему биту } } Значение выражения (с & 1) равно единице тогда и только тогда, когда младший бит байта с установлен; если же бит сброшен, результат операции равен нулю. Теперь займёмся функцией Encode () . .внедряющей в текст письма длину и тело сообщения: void Encode(char *let_fn, char *msg_fn) { // количество пробелов в письме int Spaces = Countspaces(let_fn); // размер сообщения int msg_size = CountChars(msg_fn); // считается, что длина сообщения не превышает 65535 символов //и может быть закодирована двумя байтами // таким образом, количество пробелов в письме должно быть не // меньше msg_size * 8 (размер сообщения в битах) // плюс 2*8 бит для кодирования длины if(msg_size * 8 + 2 * 8 > Spaces) { cout « “Message too long" « endl; exit(0); } ifstream letter(let_fn), message(msg_fn); ofstream outfile("encoded.txt*); 242
4\alaHausi^l Глава 9. Различные алгоритмы // вывести старший и младший байт длины кодируемого сообщения OutByte(msg_size I 256, letter, outfile); OutByte(msg_size % 256, letter, outfile); char C; // вывести сообщение while(message.get(c)) OutBytefc, letter, outfile); // в оставшемся фрагменте письма расставить пробелы случайным // образом (в противном случае окончание сообщения очень // хорошо заметно в закодированном файле) randomize(); string s; // вывести очередное слово, затем один или два пробела while(letter » s) outfile « s « (random(2) == 0 ? " " : * "); } На этом разработка кодировщика заканчивается, можно приступать к бло- ку декодирования. Алгоритм декодирования использует служебную функцию InByte () для извлечения очередного закодированного во входном файле символа: unsigned char InByte(ifstream^ letter) { unsigned char result = 0; for(int i = 0; i < 8; i++) { char c; while(letter.get(с) && c >= ' ') // найти очередной пробел ; , //во входном файле letter.get(с); // считать следующий символ int к = (с == ' ') ; // если считан пробел, к = 1, иначе к = 0 result »= 1; // перейти к следующему разряду результата result 1= (128*к); // записать в старший бит значение к ) return result; ) На каждом шаге в переменную result записывается значение операции (result | 128) или (result | 0). Наложение нуля не меняет значе- ния result, а наложение числа 128 (двоичное 10000000) приводит к уста- новке старшего бита. Запись битов каждого числа в закодированный файл производилась от младшего к старшему. При чтении осуществляется обратный процесс. 243
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Первый полученный бит записывается в старший бит result, но после восьми итераций (и, соответственно, семи сдвигов result вправо) оно ока- зывается уже на месте младшего бита, а последнее считанное значение — на месте старшего. Функция Decode () совершенно прямолинейна: void Decode(char *enc_file) ifstream letter(enc_file); ofstream outfile(“decoded.txt"); int kl - int k2 = int size InByte(letter); InByte(letter); = 256 * kl + k2; // старший байт длины сообщения // младший байт длины сообщения // длина сообщения II вывести size байт в outfile for(int i = 0; i < size; i++) outfile « (char)InByte(letter); Напоследок для примера можно привести фрагмент письма с внедрённым сообщением “hello!”, сгенерированного программой: Спотыкаясь,“мчались ° они ° в ° гору,° взмахами ° рук ° поддерживая ° равное есие, °но°“машина0°все°не°отставала°от°них. °Прыгая“по“шатким“к амням”° высохшего“потока,““они"°достигли“расщелины““в“отвесных“° скалах“и,“увидев0 ° высоко0“вверху“черное“отверстие“пещеры,°“поле зли”°туда°что°“было0“сил,“хоть °камни°шатались °“и““осыпались“у““ них““под°ногами.°“Из°“большого0“отверстия0“в°скале °“веяло““холо дом“и““тьмой.“Они°поспешно“влезли“внутрь,° °пробежали°еще“нескол ько““шагов“и““остановились.““ 9.3.2. Стеганография в изображениях - более надёжный СПОСОБ ПЕРЕДАЧИ СЕКРЕТНЫХ СООБЩЕНИЙ Текст, в котором неравномерность пробелов сразу бросается в глаза, — не лучший кандидат на роль подставного письма. Гораздо надежнее можно спрятать сообщение в файле с картинкой. Возьмём любое достаточно большое 24-битное (true color) изображение. В нём выберем какой-либо сравнительно часто встречающийся цвет (назовём его «базовым»). Для простоты можно просто взять пиксель из левого вер- хнего угла, при условии, что он не белый. Если угловой пиксель оказался белым, можно заменить его цвет на сероватый: для невооруженного глаза картинка от этого не изменится. 244
Глава 9. Различные алгоритмы Обозначим RGB компоненты базового цвета как (Rb, Gb, Вь). Дополнитель- ным цветом назовем цвет (Rh + 1, Gb + 1, Bb + 1). Теперь надо пройтись по картинке и перекрасить каждый пиксель дополни- тельного цвета в базовый цвет. Поскольку дополнительный цвет практичес- ки неотличим от базового, качество изображения не пострадает. В результате выполненных действий мы получим картинку, полностью свободную от пикселей дополнительного цвета. Это означает, что можно использовать дополнительный цвет в качестве источника информации. Например, пусть каждый пиксель базового цвета кодирует нуль, а пиксель дополнительного цвета — единицу. Осталось пройтись по картинке ещё раз и изменить где требуется базовый цвет на дополнительный в соответствии с содержимым секретного сообщения. Думаю, теперь понятно, почему белый цвет, RGB компоненты которого равны (255, 255, 255), не годится в качестве базового: дополнительный цвет для него подобрать нельзя. Вашим заданием будет написание полноценной системы стеганографии, аналогичной программе из предыдущей задачи. 9.4. СПЕЦИАЛИЗИРОВАННЫЕ АЛГОРИТМЫ 9.4.1. Оптимальное вычисление Принцип динамического программирования В процессе решения математических задач нередко приходится вычислять произведения матриц. В результате умножения матрицы А размерностью m х к на матрицу В размерностью к х п (если количество столбцов первой матрицы не совпадает с количеством строк второй, умножение производить нельзя) получается матрица С размерностью m х п, значения элементов ко- торой находятся по формуле: С = А *В.. + А.9*В„. + ... + А *В. ij н 1) 12 2j ik kj Вычисление произведения двух матриц — довольно дорогостоящая опе- рация, требующая m х k х п операций умножения отдельных элементов (е прямолинейной реализации). Поскольку А х (В х С) = (А х В) х С, произведение трёх и более матрии Aj х А2 х ... х AN можно вычислять в любом порядке. Последовательность выбора очередной пары матриц для перемножения не влияет на конечный результат, но влияет на скорость работы алгоритма.
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Рассмотрим вычисление выражения А( х А2 х А3, где размерность А] равна 5x10, размерность А2 — 10x6, а размерность А3 — 6x2. Если сначала вычис- лить значение А] х А2, это потребует 300 (5x10x6) операций умножения. Полученная матрица 5x6 умножается на А3, что потребует ещё 60 (5x6x2) умножений. В результате получаем 360 операций. Если же первым делом вычислить значение А2 х А3, а лишь затем умножить А] на полученную мат- рицу, алгоритм потребует лишь 220 (120 + 100) умножений. Задача заключается в определении оптимального порядка вычисления про- изведения N матриц At х А2 х ... х AN. На вход подаётся число N и размер- ность каждой участвующей в выражении матрицы. На выходе программа должна печатать цепочку перемножаемых матриц с расставленными скоб- ками, соответствующими оптимальному вычислению произведения. Решение Решение «в лоб» очевидно: перебор всех возможных вариантов умноже- ния с подсчётом количества выполняемых операций. К счастью, существу- ет и более эффективный алгоритм, требующий порядка N3 действий, где N — количество матриц в цепочке (полный перебор имеет экспоненциальную сложность). Рассмотрим произведение N матриц А] х А2 х ... х AN. В конечном счёте, в любой операции умножения участвует только два элемента, только две мат- рицы. Вопрос лишь в том, как расставить скобки, чтобы разделить N матриц на две группы. Здесь возможно всего N - 1 вариантов: А] х (А2 х ... х An), (А, х А2) х (А3 х ... х AN),.... (At х ... х AN ,) х AN Как уже сказано, умножение матрицы размерностью m х г на матрицу раз- мерностью г х п требует m х г х п операций. Размерности обеих матриц в произведении мы знаем: у матрицы А. х ... х Aj столько же строк, сколько у А, и столько же столбцов, сколько у Л. Обозначив через к индекс, на кото- ром заканчивается первая матрица, можно записать произведение в общем виде: (Aj х ... х Ak) х ( Ak + 1 х ... х An) Количество операций, требуемое для умножения этих матриц, будет выра- жаться формулой: Ops = Rows(A1)*Columns(Ak)*Columns(AN), где Rows(A) — количество строк, a Columns(A) — количество столбцов в матрице А. 246
^lalaHaus,^. Глава 9. Различные алгоритмы Чтобы получить общее количество операций, необходимое для вычисления всего произведения, к величине Ops придётся прибавить операции, затрачи- ваемые при получении каждой из матриц-'Множителей (поскольку эти мат- рицы, в свою очередь, тоже могут быть произведениями других матриц). Таким образом, постепенно вырисовывается схема алгоритма: искомое ко- личество операций для случая N матриц выражается через решения задач, включающих меньшее количество матриц. Поскольку текст на C++ в данном случае выглядит ненамного сложнее псевдокода, я сразу буду приводить фрагменты окончательной программы. Пусть в массивах vector<int> Rows, Columns; хранятся размерности матриц исходного произведения (так, Rows[l], Columns [ 1 ] — размерность первой матрицы). Ради удобства нотации я не стал использовать нулевые элементы массивов. Тогда количество операций, требуемое для вычисления цепочки А1 х ... х А, можно найти с помощью рекурсивной функции GetOps (): int GetOps(int i, int j) { if(i == j) // одна матрица return 0; // максимальное целое число int minOps = numeric_limits<int>().max(); // находим наименьшее количество операций, требуемое для // вычисления выражения A[i]*...*А[к]*...*А[j] for(int к = i; к < j; к++) { int ops = GetOps(i, k) + Rows[i]*Columns[k]*Columns[j] + GetOps(k + 1, j); if(ops < minOps) minOps = ops; } return minOps; } Эта функция является ядром программы. При вызове GetOps (1, N) будет найдено решение исходной задачи. Однако помимо количества операций в задаче требуется также опреде- лить последовательность выполняемых умножений. Для этого функцию GetOps () придётся доработать; кроме того, в неё можно внести одно ма- ленькое, но важное дополнение, значительно повышающее скорость работы программы. 247
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Чтобы определить фактически выполняемую последовательность умноже- ний, которой соответствует минимальное количество операций, достаточно сохранять для каждой передаваемой на вход функции Ge tops () пары чи- сел (i, j ) найденный «разделитель» к. Второе усовершенствование функции заключается в использовании допол- нительного «кэша». Обратите внимание, что Ge tops () может многократно вызываться для одних и тех же значений (i, j ). Поэтому сохранять од- нажды найденные значения, чтобы избежать повторного вычисления, весь- ма полезно. Функции кэша и хранилища разбиений задач па подзадачи выполняют ас- социативные массивы Cache и Subproblems: map<pair<int, int>, int> Cache; // nape (i, j) сопоставляется // значение ops map<pair<int, int>, int> Subproblems; // nape (i, j) сопоставля- // ется величина k Итоговая версия функции Ge tops () приведена ниже. int GetOps(int i, int j) ( if (i == j) return 0; 11 поиск пары (i, j) в кэше map<pair<int, int>, int>::iterator p - Cache.find(pair<int, int>(i, j)); // если пара найдена, возвращается значение, извлечённое из кэша if(p 1= Cache.end()) return p->second; // иначе находим наименьшее требуемое количество операций и // соответствующий ему индекс•разбиения minK int minOps = numeric_limits<int>::max(), minK; for(int k = i; k < j; k++) { int ops = GetOps(i, k) + Rows[i]*Columns[k]*Columns[j] + GetOps (k + 1, j ) ,- if(ops < minOps) { minOps = ops; minK = k; } } // занести значение minK в массив разбиений, а значение GetOps(i, j) в кэш Subproblems[paircint, int>(i, j)] = minK; Cache[paircint, int>(i, j)] = minOps; return minOps; } 248
NataHausi^ Глава 9. Различные алгоритмы Для вывода на экран решения подзадачи Ai х ... х Aj потребуется вспомога- тельная функция RepresentSolution (): string RepresentSolution(int i, int j) { I/ одна матрица if(i == j) { // вернуть строковое представление её порядкового номера ostringstream s; s « i; return s.str(); } else { // найти разбиение задачи на две подзадачи int k = Subproblems[pair<int, int>(i, j)]; string si = RepresentSolution(i, к); // решение первой // подзадачи если оно состоит из более // чем одной матрицы, заключить в скобки if(si.length() > 1) si = *(* + si + // аналогично для второй подзадачи string s2 = RepresentSolution(k + 1, j); if(s2.length() > 1) s2 = “ (“ + s2 + return si + + s2; } } Осталось написать лишь нехитрую функцию main(): int main(int argc, char* argv[]) ( int N; // количество матриц cout « "N = "; cin » N; Rows.push_back(0); // заполнить нулевые элементы массивов Columns.push_back(0); // (они нам не понадобятся) // считать значения Rows[i] и Columns[i] for(int i - 1; i <= N; i++) { int h, w; cout « 'Rows(“ « i « ") = “; cin » h;
C++ мастер-класс. 85 нетривиальных проектов, решений и задач cout « "Columns(" « i « ") cin » w; Rows,push_back(h); Columns.push_back(w); cout « endl « GetOps(1, N) « endl; cout « RepresentSolution(1, N); return 0; Пример: N = 4 Rows(1) = 10 Columns(1) = 20 Rows(2) = 20 Columns(2) = 50 Rows(3) = 50 Columns(3) = 1 Rows(4) =1 Columns(4) = 100 2200 (1*(2*3))*4 9.4.2. Календарь чемпионата Составление расписания игр Дан список из N команд (число N считается чётным). Требуется сгенери- ровать распечатку туров для проведения чемпионата по круговой системе. В каждом туре всегда играют N/2 пар команд (каждой команде находится пара для игры в текущем туре). Чемпионат состоит из N - 1 туров, поскольку любая команда в итоге долж- на сыграть с каждой из оставшихся N - 1 команд. Ответные матчи не игра- ются. Например, календарь для четырёх команд “Спартак”, “Зенит”, “Локомотив” и “Рубин” может выглядеть так: Тур 1 Спартак — Зенит Локомотив — Рубин 250
^laiattaus,^. Глава 9. Различные алгоритмы Тур 2 Спартак — Локомотив Зенит — Рубин Тур 3 Спартак -- Рубин Зенит — Локомотив Успех решения полностью завйсит от того, сумеете ли вы придумать хоро- ший алгоритм распределения команд по турам. Когда алгоритм известен он кажется очень простым, но найти его не так легко. Попытки решить за- дачу «в лоб» («берём две случайные команды и добавляем пару в очередной тур»), а также переборные алгоритмы здесь терпят фиаско. Первый подход часто приводит к тому, что под конец чемпионата из остав шихся команд уже невозможно составить корректный набор пар. Это неоче видно, так что приведу пример. Предположим, что в чемпионате участвуй шесть команд: “Спартак”, "Алания”, "Зенит”, “Локомотйв”, “Рубин” и “Динамо” Первые два тура генерируются случайным алгоритмом без проблем: Спартак — Алания Зенит — Локомотив Рубин — Динамо Спартак — Динамо Зенит — Рубин Алания — Локомотив Далее, предположим, что в третьем туре алгоритм выбрал первые две napi команд: Спартак — Рубин Зенит — Динамо Теперь в нашем распоряжении лишь две команды — “Локомотив” “Алания”, но они уже играли между собой! Можно попытаться заранее сгенерировать пары играющих команд, а зате из них набирать туры, но проблема не исчезнет. Теперь вы можете рано ил поздно столкнуться с ситуацией, когда оставшиеся в списке пары не мог} составить тур, поскольку одна и та же команда участвует в двух-трёх ма' чах. Эти затруднения приводят к мысли о переборе с возвратами, но больше количество матчей любого реального чемпионата делает этот подход npai тически непригодным. Очевидно, задача сводится к тому, чтобы придумать хорошую схему выбо! команд, участвующих в очередном туре. 2
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Решение Алгоритм генерации туров на самом деле несложен. Представьте себе круг, в центре которого записана первая команда, а по окружности (как цифры на циферблате часов) — все остальные. Первая команда отрезком соединяется с командой, имеющей номер N/2 + 1. Остальные команды попарно соедине- ны хордами, как изображено на рис. 9.9. N/2+1 Рис. 9.9. Генерация туров чемпионата Соединённые пары команд формируют первый тур чемпионата. Повернув круг так, чтобы первая команда оказалось соединённой с командой, имею- щей номер N/2 + 2, мы получим все пары второго тура и так далее. Через N - 1 итераций календарь чемпионата будет готов. Для полноты картины не помешает привести листинг на C++: int main(int argc, char* argv(J) { int N; // количество команд string line, teaml; // первая команда list<string> Teams; // все остальные команды cin » N; // считать количество команд cin » teaml; // считать первую команду // считать остальные команды и добавить их в список Teams for(int i = 0; i < N - 1; i++) { cin » line; Teams.push_back(line); cout « endl; for(int i = 0; i < N - 1; i++) { // pl, p2 — итераторы на начало и конец списка команд list<string>::iterator pl = Teams.begin(); 252
ftatatiausi Глава 9. Различные алгоритмы list<string>::iterator р2 =• Teams.end О; p2--; cout « 'Tour “ « (i + 1) « endl; 11 номер очередного тура // пока итераторы не сравняются в нижней части круга while(pl 1= р2) ( cout « *pl « " - “ « *р2 « endl; // переходим к следующей паре Р1+ + ; р2-~ ; } cout « teaml « “ - ' « *pl « endl « endl; // пара teaml — *pl // сдвигаем команды по кругу Teams.push_back(Teams.front()) ; Teams.pop_front(); } return 0; 9.4.3. Исследование структуры спроса Если вы хоть раз покупали книги в интернет-магазинах, то наверняка об- ращали внимание на раздел с названием наподобие «с этим товаром часто покупают» (рис. 9.10). Для покупателя этот сервис (совет в выборе книги) выглядит достаточно ненавязчиво; в то же время, как показывают исследо- вания, его использование интернет-магазином приводит к заметному росту продаж. Серия. Тема: старая тема Издание ISBN: Формат Объем: Переплет Срок выхода: Цела: Занимательное программирование: Самоучитель Раздел. Компьютерная литература Самоучитель Программирование. Общие вопросы Общие вопросы программирования 1-е, 2004 год S 94723-853 5 17x24 см 208 стр. мягкая обложка в продаже 108 руб. [варианты доставки и оплаты] Цена для членов клуба Профессионал. 92 руб [Специальное предложение] содержание | отрывок файлы к книге * отзывы » оценка читателей: А/озговой М. в. С этой книгой чаще всего заказывают» Интернет, протоколы безопасности. Учебный курс У Блэк дена; 20 руб. Эле»'тронный магазин на Java и XMl. Библиотека тр7»граммиста (т-CD) 5. Брогден, К Минник цена: 32 руб. Наварро Рис. 9.10. Страница интернет-магазина издательства «Питер» 253
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Естественно, информация о совершаемых совместно покупках извлекается из реально поступивших заказов. В обычных супермаркетах тоже прово- дятся подобные изучения структуры продаж. Когда кассир пробивает вам товары, информация о ваших покупках поступает в общую базу данных. Результаты исследований могут быть использованы, например, для более грамотного размещения товаров на полках магазина, а это непосредственно влияет на прибыль. Человек, забежавший на минутку за молоком, вполне может прикупить и кукурузных хлопьев, если они окажутся поблизости, но не пойдёт ради них в другой конец зала. Задача заключается в разработке программы, анализирующей структуру покупок. На вход поступает файл, в котором перечислены товары магазина и некоторое количество операций покупки. Например, входные данные для трёх покупок могут выглядеть так: 1 хлеб 2 молоко 3 бананы 4 кукурузные хлопья 5 картофель 12 3 2 4 5 12 Здесь первый покупатель приобрёл хлеб, молоко и бананы, второй — моло- ко и кукурузные хлопья, а третий — картофель, хлеб и молоко. На выходе программа печатает список самых популярных сочетаний това- ров. Для приведённых выше данных они таковы: хлеб, молоко (2/3) // то есть сочетание встречается в двух // покупках из трёх хлеб, бананы (1/3) молоко, бананы (1/3) молоко, кукурузные хлопья (1/3) хлеб, картофель (1/3) молоко, картофель (1/3) хлеб, молоко, бананы (1/3) хлеб, молоко, картофель (1/3) Популярность, по существу, представляет собой вероятность встретить со- четание среди всех совершённых покупок. В нашем случае эти вероятности равны 2/3,1/3 и 0 (например, для сочетания «бананы, картофель»). Мини- мально допустимая популярность, интересующая пользователя, вводится с клавиатуры. 254
Глава 9. Различные алгоритмы Определённый интерес представляют популярные сочетания из большого количества товаров: их знание может вывести торговлю на качественно но- вый уровень. Например, сейчас во многих магазинах электроники можно приобрести готовый комплект из часто покупаемых совместно корпуса, па- мяти, процессора, винчестера, клавиатуры и ещё некоторых устройств. Этот комплект называется «персональным компьютером». Обратите внимание, что для проведения полноценного исследования нет надобности изучать все возможные сочетания товаров. Начните с пар. Если популярность сочетания двух товаров уже ниже требуемой, добавление третьего товара ситуацию к лучшему не изменит. То же верно и для любо- го набора из К товаров: изучать их сочетание с каким-либо новым товаром имеет смысл только тогда, когда популярность исходного набора не ниже минимально допустимой популярности в системе.

ГЛАВА 10. АРХИТЕКТУРА ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ, ИЛИ О ЧЕМ ПОДУМАТЬ НА СОН ГРЯДУЩИЙ
В большинстве книг по программированию (и эта - не исключение) задачи предназначены для иллюстрации какого-либо принципа, метода или алго- ритма. Однако мир программирования на этом не заканчивается. Как отме- тил Ричард Хэмминг ещё в 1969 году, «Пародируя наши нынешние методы преподавания программирования, мы даём новичкам грамматику и словарь и говорим им, что они теперь великие писатели. Мы редко, если когда-либо вообще, проводим хоть сколько-нибудь серьёзное обучение стилю». Задача может иллюстрировать просто интересную ситуацию из жизни, в которой компьютер при умелом подходе может оказаться полезным инструментом. Таким образом, успешное решение задачи определяется разработкой гра- мотной архитектуры программного продукта, хорошим стилем программи- рования. Данная глава посвящена именно таким задачам. В них вы не найдёте ничего алгоритмически сложного, но как проекты приведённые задачи интересны. Я не берусь превращать книгу в учебник хорошего стиля программирова- ния; кроме того, стиль во многом определяется используемой парадигмой, и мне не хотелось бы заранее склонять читателей к тому или иному подходу. Поэтому все задачи этой главы даются без решений. 10.1. ИГРЫ и головоломки 10.1.1. Классический puzzle - простая игра на составление изображения Требуется написать программу для игры в puzzle (составление изображения из фрагментов) по упрощённой схеме. Пользователь задаёт размеры головоломки (N элементов по вертикали, М элементов по горизонтали) и файл, содержащий входное изображение. Изображение разрезается на N*M прямоугольных кусочков, затем эти ку- сочки перемешиваются случайным образом и каждый из них с вероятнос- тью 1/2 поворачивается на 180°. 258
^ataHaus^k Глава 10. Архитектура программного обеспечения В итоге пользователь видит перед собой изображение того же размера, что и исходное, но составленное из произвольно совмещённых между собой фрагментов. Цель игры — меняя местами фрагменты картинки и поворачивая их на 180°, получить исходное изображение. Как только цель достигнута, выводится сообщение о победе. 10.1.2. Puzzle со сдвигами - составление изображения по НЕОБЫЧНЫМ ПРАВИЛАМ Требуется запрограммировать игру «puzzle со сдвигами». Входное изобра- жение разрезается на прямоугольные фрагменты по тому же принципу, что и в предыдущей задаче, но перемешивание фрагментов происходит по бо- лее сложному сценарию, состоящему из некоторого количества операций «прокрутки» строк и столбцов картинки (рис. 10.1). Игрок теперь не может и произвольно переставлять местами фрагменты изображения: в его силах лишь «прокручивать» отдельные строки и столб- цы. Поскольку исходная картинка была преобразована именно таким обра- зом, вернуть её в первоначальное состояние всегда можно. Как только игрок восстанавливает исходное изображение, печатается сооб- щение о победе. 10.1.3. «Шахматная головоломка» - подсчёт атакуемых полей Игра «Шахматная головоломка» по стилю чем-то напоминает «Сапера» (п. 9.1.3). Однако на сей раз целью будет программирование игры, а реше- ние головоломки мы предоставим человеку. Итак, компьютер выбирает некоторое количество любых шахматных фигур и «в уме» расставляет их на доске. Человеку лишь указывают, какие клетки заняты, но сами фигуры остаются скрытыми. 259
C++ мастер-класс. 85 нетривиальных проектов, решений и задач Далее игрок может «открывать» любые незанятые клетки доски. При от- крытии в клетке отображается количество фигур, её бьющих. Цель игры — выявить расположение фигур компьютера, открыв как мож- но меньше клеток. Набранное в итоге количество очков тем меньше, чем больше клеток пришлось открыть. Неверная догадка означает немедленное поражение в игре. 10.1.4. Ball Game - взаимодействие с объектами игрового МИРА В текстовом файле хранится описание прямоугольного уровня, условно разбитого на клетки. В каждой клетке может располагаться стена (#), приз (*), отражатель первого типа (/) или отражатель второго типа (\). Пример уровня: ############################################## # \ # # # ### # # * / \# # \ * / *# # # ############################################## Игра начинается с того, что шарик вылетает из верхнего левого угла уровня и летит вправо. Если на пути встречается приз, он съедается, и шарик про- должает движение. Стена отражает шарик в направлении, противоположном текущему. Отра- жатели меняют движение шарика по закону «угол падения равен углу отра- жения». Угол наклона каждого отражателя считается равным 45°. Задача заключается в моделировании (с анимацией) поведения шарика. Допускается использование текстового режима для вывода графики, хотя полноценная графика была бы приятнее. Моделирование заканчивается, когда шарик достигает правого нижнего угла уровня. Затем печатается со- общение: удалось ли собрать все призы на уровне или нет. Должна быть также предусмотрена возможность для прерывания работы программы. Дополнительная задача. Этот «движок» можно превратить в полноценную игру, если разработать игровой редактор. Каждый уровень изначально со- 260
Глава 10. Архитектура программного обеспечения стоит лишь из стенок и призов. Цель играющего — расставить (в редакторе) отражатели так, чтобы шарик при движении съел все призы и достиг право- го нижнего угла экрана. Количество отражателей, естественно, ограничива- ется (для каждого уровня должны предусматриваться свои ограничения). Построенный уровень передаётся на вход программе, моделирующей дви- жение шарика. При победе происходит переход на следующий уровень — в общем, всё как в любой обычной компьютерной игре. 10.2. ХРАНЕНИЕ И ОБРАБОТКА ДАННЫХ 10.2.1. Логические схемы - инструмент для изучающих электронику Требуется разработать программу, облегчающую создание логических схем. Программа должна дать пользователю возможность задать N входов и М выходов схемы, а затем позволить поместить на схему произвольное коли- чество блоков И, ИЛИ и НЕ (рис. 10.2, вверху) и соединить их проводника- ми. Детали редактирования оставляются на усмотрение разработчика. Кроме логических блоков очень полезны «разветвители», превращающие один сигнал в набор из двух или трёх сигналов (рис. 10.2, внизу). Должна быть предусмотрена функция печати логической таблицы для пос- троенной схемы. В этой таблице для каждого возможного набора входных сигналов (сигнал кодируется числом 0 или 1) печатается соответствующий ему набор выходных сигналов. РАЗВЕТВИТЕЛИ Рис. 10.2. Логические схемы и разветвители
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 10.2.2. Футбольный органайзер - электронный справочник БОЛЕЛЬЩИКА Требуется написать систему, помогающую следить за ходом различных фут- больных турниров. Должны быть предусмотрены функции: • Добавления, удаления и редактирования турниров. При добавлении нового турнира определяется его вид: чемпионат проводится по кру- говой системе, кубок — по олимпийской. Здесь пользователь также выбирает, играются ли ответные матчи. • Ввода (в удобной для пользователя форме) календаря турнира. Об- ратите внимание, что для кубков изначально известны лишь матчи первого тура, а также общая схема турнира (рис. 10.3). Участники последующих матчей определяются в ходе проведения игр кубка. За- несения результатов сыгранных матчей. • Распечатки текущей турнирной таблицы любого чемпионата или кубка. • Вывода некоторых статистических данных о той или иной команде. Например, программа может определить максимальную беспроиг- рышную серию команды, график, показывающий место команды в турнирной таблице после каждого тура (если речь идёт о чемпиона- те), количество крупных побед и так далее. Рис. 10.3. Схема проведения турнира по олимпийской системе 1 0.2.3. В ПОМОЩЬ ШАХМАТИСТУ - СИСТЕМА ДЛЯ АНАЛИЗА ШАХМАТНЫХ ПАРТИЙ Написать программу, помогающую любителям шахмат разыгрывать этюды и партии. В системе должны быть предусмотрены функции: 262
Глава 10. Архитектура программного обеспечения • вывода на экран шахматной доски; • манипуляции фшурами на доске с возможностью сохранения спис- ка ходов в стандартной шахматной нотации; • ручного редактирования списка ходов каждого из противников; • загрузки и сохранения партий; • воспроизведения сохранённой партии на доске. В программе обязательно должна присутствовать защита от недопустимых по правилам шахмат ходов. 10.3. АНАЛИЗ ТЕКСТОВОЙ ИНФОРМАЦИИ 10.3.1. Контекстная подсказка - разработка простой СПРАВОЧНОЙ СИСТЕМЫ Разработать справочную систему с возможностью выдачи контекстной под- сказки. В простейшем виде задача формулируется так. Набор текстовых документов составляет справочную систему. Отдельным файлам соответствуют отдельные статьи. Некоторые слова (или целые предложения) в документах могут быть окружены символами < и >. Сразу за знаком > следует уникальный идентификатор понятия. Например: Чтобы начать работу с устройством, вставьте его в <СОМ-порт>сощ_рогб и дождитесь установки программного обеспечения. Текстовый файл context.hlp содержит расшифровки понятий. Каждая стро- ка состоит из идентификатора понятия и его описания: com_port Последовательный порт компьютера. Предназначен для подключения различных внешних устройств. fat32 Стандартная файловая система семейства ОС Windows 9х. Справочная система выводит на экран любые запрошенные пользовате- лем статьи. Символы <, > и уникальный идентификатор понятия никак не отображаются. Само же понятие, описанное в файле context.hlp, должно быть каким-либо образом выделено (например, жирным шрифтом или под- чёркиванием). Если пользователь подводит курсор к выделенному слову и нажимает определённую клавишу (например, F1), выводится контекстная подсказка.
C++ мастер-класс. 85 нетривиальных проектов, решений и задач 10.3.2. Алфавитный указатель. Автоматическое СОЗДАНИЕ УКАЗАТЕЛЯ Дан файл, в котором некоторые слова или понятия заключены в фигурные скобки. Например: Теперь рассмотрим понятие конечного автомата{Конечный автомат}. Текст внутри фигурных скобок не предназначен для отображения па экра- не; он служит для формирования алфавитного указателя. Задача заключается в том, чтобы исключить служебную разметку из исход- ного файла и разбить файл на страницы. Пользователь задаёт количество столбцов и строк в странице, а программа формирует выходной файл вида: - страница 1 - текст страницы 1 - страница 2 - текст страницы 2 Слова, «выезжающие» за правый край (то есть не помещающиеся в задан- ное количество столбцов), должны быть перенесены на следующую строку. Организовывать выравнивание по ширине не требуется.. В конце файла должен находиться алфавитный указатель, то есть список ключевых понятий (в алфавитном порядке) с указанием номеров страниц, где понятие встречается. Пример указателя: Автомат 10, 40, 85 Алгоритм 110 АСУ 5, 45 Хочу обратить внимание, что для указателя берутся лишь вхождения, за- ключённые в фигурные скобки. Так, из указателя, приведённого в примере, следует, что на страницах 10, 40 и 85 автор явным образом использовал за- пись {Автомат}. Слово “Автомат” (без скобок) могло встретиться и на дру- гих страницах, но в указателе это никак не отражено. 10.3.3. Спонсорские ссылки. Рекламный модуль для вашего сайта На многих интернет-сайтах существует интересная систе- ма рекламы. Спонсор пересылает вебмастеру список троек вида 264
Глава 10. Архитектура программного обеспечения (ключеваястрока | url | описание). Ключевая строка представляет собой слово или выражение, которому, по мнению спонсора, соответствует рек- ламная ссылка url. Элемент «описание» является произвольной текстовой строкой, поясняющей рекламируемый ресурс. Например: windows I http://www.winall.ru I Мир Microsoft Windows компьютерная игра I http://www.game-exe.ru I Онлайн-версия журнала Game.exe книги I http://www.ozon.ru I Интернет-магазин: книги, видео, музика, софт Любая запрашиваемая пользователем страница сайта сначала пропускает- ся через особую программу, заменяющую все ключевые строки гиперссыл- ками, ведущими на рекламируемые ресурсы. Если пользователь подводит мышь к ссылке, в строке состояния браузера отображается описание целе- вой страницы, взятое из файла спонсора. Задача состоит в реализации системы спонсорской рекламы. На вход про- грамме подаётся файл с описаниями рекламируемых ресурсов и преобра- зуемая веб-страница. Система заменяет ключевые строки гиперссылками и возвращает страницу, готовую для передачи пользователю. Поскольку изучение JavaScript не является целью задачи, можно сразу ука- зать конструкцию, подставляемую вместо строки “ключеваястрока”: <а href=url onMouseover="window.status='описание';return true;* onMouseout="window.status=window.defaultstatus;return true;"> ключевая_строка</a > Например, строка “windows” будет заменена выражением <а href=http://www.winall.ru onMouscover="window.status='Mnp Microsoft Windows';return true;" onMouseout="window.status=window.defaultStatus;return true;"> windows</a> 265
Группа подготовки издания: Зав. редакцией компьютерной литературы: М.В. Финков Редактор: О. И. Березкина Корректоры: Е.Е. Кириллов, Н.Б. Сиразитдинова ООО «Наука и Техника» Лицензия №000350 от 23 декабря 1999 года. 198097, г. Санкт-Петербург, ул. Маршала Говорова, д. 29. Подписано в печать 26.09.2006. Формат 70x100 1/16. Бумага газетная. Печать офсетная. Объем 17 п. л. Тираж 3000. Заказ 772 Отпечатано с готовых диапозитивов в ОАО «Техническая книга» 190005, Санкт-Петербург, Измайловский пр., 29. Отсканировано специально для nataliaus.ru