Файл: Алгоритмы и структуры данныхНовая версия для Оберона cdмосква, 2010Никлаус ВиртПеревод с английского под редакцией.pdf

ВУЗ: Не указан

Категория: Не указан

Дисциплина: Не указана

Добавлен: 30.11.2023

Просмотров: 228

Скачиваний: 3

ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.

СОДЕРЖАНИЕ

Алгоритмы и структуры данныхНовая версия для Оберона + CDМосква, 2010Никлаус ВиртПеревод с английского под редакциейдоктора физмат. наук, Ткачева Ф. В. УДК 32.973.26018.2ББК 004.438В52Никлаус ВиртВ52Алгоритмы и структуры данных. Новая версия для Оберона + CD / Пер.с англ. Ткачев Ф. В. – М.: ДМК Пресс, 2010. – 272 с.: ил.ISBN 9785940745846В классическом учебнике тьюринговского лауреата Н.Вирта аккуратно, на тщательно подобранных примерах прорабатываются основные темы алго%ритмики – сортировка и поиск, рекурсия, динамические структуры данных.Перевод на русский язык выполнен заново, все рассуждения и програм%мы проверены и исправлены, часть примеров по согласованию с автором переработана с целью максимального прояснения их логики (в том числе за счет использования цикла Дейкстры). Нотацией примеров теперь служитОберон/Компонентный Паскаль – наиболее совершенный потомок старогоПаскаля по прямой линии.Все программы проверены и работают в популярном варианте Оберона –системе Блэкбокс, и доступны в исходниках на прилагаемом CD вместе с самой системой и дополнительными материалами.Большая часть материала книги составляет необходимый минимум знаний по алгоритмике не только для программистов%профессионалов, но и любых других специалистов, активно использующих программирование в работе.Книга может быть использована как учебное пособие при обучении буду%щих программистов, начиная со старшеклассников в профильном обуче%нии, а также подходит для систематического самообразования.Содержание компактдиска:Базовая конфигурация системы Блэкбокс с коллекцией модулей, реализующих программы из книги.Базовые инструкции по работе в системе Блэкбокс.Полный перевод документации системы Блэкбокс на русский язык.Конфигурация системы Блэкбокс для использования во вводных курсах програм%мирования в университетах.Конфигурация системы Блэкбокс для использования в школах (полная русифика%ция меню, сообщений компилятора, с возможностью использования ключевых слов на русском и других национальных языках).Доклады участников проекта Информатика%21 по опыту использования системыБлэкбокс в обучении программированию.Оригинальные дистрибутивы системы Блэкбокс 1.5 (основной рабочий) и 1.6rc6.Инструкции по работе в Блэкбоксе под Linux/Wine.Дистрибутив оптимизирующего компилятора XDS Oberon (версии Linux и MSWindows).OberonScript – аналог JavaScript для использования в Web%приложениях.ISBN 0%13%022005%9 (анг.)© N. Wirth, 1985 (Oberon version: August 2004).© Перевод на русский язык, исправления и изменения, Ф. В. Ткачев, 2010.ISBN 978%5%94074%584%6© Оформление, издание, ДМК Пресс, 2010 СодержаниеО новой версии классического учебникаНиклауса Вирта....................................................................... 5Предисловие.......................................................................... 11Предисловие к изданию 1985 года............................. 15Нотация..................................................................................... 16Глава 1. Фундаментальные структуры данных..... 11 1.1. Введение .............................................................................. 18 1.2. Понятие типа данных ............................................................ 20 1.3. Стандартные примитивные типы .......................................... 22 1.4. Массивы ............................................................................... 26 1.5. Записи .................................................................................. 29 1.6. Представление массивов, записей и множеств .................... 31 1.7. Файлы или последовательности ........................................... 35 1.8. Поиск .................................................................................... 49 1.9. Поиск образца в тексте (string search) .................................. 54Упражнения.................................................................................. 65Литература .................................................................................. 67Глава 2. Сортировка........................................................... 69 2.1. Введение .............................................................................. 70 2.2. Сортировка массивов ........................................................... 72 2.3. Эффективные методы сортировки ....................................... 81 2.4. Сортировка последовательностей ....................................... 97Упражнения................................................................................ 128Литература ................................................................................ 130Глава 3. Рекурсивные алгоритмы.............................. 131 3.1. Введение ............................................................................ 132 3.2. Когда не следует использовать рекурсию .......................... 134 3.3. Два примера рекурсивных программ ................................. 137 3.4. Алгоритмы с возвратом ...................................................... 143 3.5. Задача о восьми ферзях ..................................................... 149 Содержание4 3.6. Задача о стабильных браках ............................................... 154 3.7. Задача оптимального выбора ............................................. 160Упражнения................................................................................ 164Литература ................................................................................ 166Глава 4. Динамические структуры данных........... 167 4.1. Рекурсивные типы данных .................................................. 168 4.2. Указатели ........................................................................... 170 4.3. Линейные списки ................................................................ 175 4.4. Деревья .............................................................................. 191 4.5. Сбалансированные деревья ............................................... 210 4.6. Оптимальные деревья поиска ............................................. 220 4.7. Б<деревья (BУпражнения................................................................................ 250Литература ................................................................................ 254Глава 5. Хэширование..................................................... 255 5.1. Введение ............................................................................ 256 5.2. Выбор хэш<функции ........................................................... 257 5.3. Разрешение коллизий ........................................................ 257 5.4. Анализ хэширования .......................................................... 261Упражнения................................................................................ 263Литература ................................................................................ 264Приложение A. Множество символов ASCII.......... 265Приложение B. Синтаксис Оберона......................... 266Приложение C. Цикл Дейкстры................................... 269 О новой версииклассического учебникаНиклауса ВиртаНовая версия учебника Н. Вирта «Алгоритмы и структуры данных» отличается от английского прототипа [1] сильнее, чем просто исправлением многочисленных опечаток и огрехов, накопившихся в процессе тридцатилетней эволюции книги.Объясняется это целями автора и переводчика при работе над книгой в контексте проекта «Информатика%21» [2], который, опираясь на обширный совокупный опыт ряда высококвалифицированных специалистов (см. списки консультантов и участников на сайте проекта [2]), ставит задачу создания единой системы ввод%ных курсов информатики и программирования, охватывающей учащихся пример%но от 5%го класса общей средней школы по 3%й курс университета. Такая система должна иметь образцом и дополнять уникальную российскую систему матема%тического образования. Это предполагает наличие стержня общих курсов, состав%ляющих единство без внутренних технологических барьеров (которые приводят,среди прочего, к недопустимым потерям дефицитного учебного времени) и лишь варьирующихся в зависимости от специализации, вместе с надстройкой из профессионально ориентированных курсов, опирающихся на этот стержень в от%ношении базовых знаний учащихся. Такая система подразумевает наличие каче%ственных учебников (первым из которых имеет шанс стать данная книга),«говорящих» на общем образцовом языке программирования. Естественный кан%дидат на роль такого общего языка – Оберон/Компонентный Паскаль. Подроб%ней об Обероне речь пойдет ниже, здесь только скажем, что Паскаль (использо%ванный в первом издании данной книги 1975 г.), Модулу%2 (использованную во втором издании, переведенном на русский язык в 1989 г. [3]) и Оберон (использо%ванный в данной версии) логично рассматривать соответственно как альфа%, бета%и окончательную версию одного и того же языка. Использование Оберона – самое очевидное отличие данной версии книги от предыдущего издания.В контекст идеи о единой системе вводных курсов вписывается и узкая задача,решавшаяся новой версией учебника, – дать небольшое продуманное пособие, в котором аккуратно, но не топя читателя в болоте второстепенных деталей, прора%батывались бы традиционные темы классической алгоритмики, для полного обсуждения которых нет времени в спецкурсе, читаемом переводчиком с 2001 г.на физфаке МГУ в попытке обеспечить хотя бы минимум культуры программиро%вания у будущих аспирантов. Здесь требуется «отлаженный» текст, пригодный для самостоятельной работы студентов. С точки зрения содержания, лучшим кандидатом на эту роль оказался прототип [1]. О новой версии классического учебника Никлауса Вирта6Что двойное переделывание программ и рассуждений в тексте (с Паскаля наМодулу%2 и затем на Оберон) не прошло безнаказанно, само по себе неудиви%тельно. Однако затруднения, возникшие при верификации программ и текста,хотя и были преодолены, все же показались чрезмерными. Поэтому, и ввиду учеб%ного назначения книги, встал ребром вопрос о необходимости доработки примеров.Предложения переводчика были одобрены автором на совместной рабочей сессии в апреле сего года и реализованы непосредственно в данном переводе (при первой возможности соответствующие изменения будут внесены и в прототип [1]).Во%первых, алгоритмы поиска образца в тексте переписаны в терминах циклаДейкстры (многоветочный while [4]). Эта фундаментальная и мощная управля%ющая структура поразительным образом до сих пор не представлена в распро%страненных языках программирования, поэтому ей посвящено новое приложениеC. Раздел 1.9, в который теперь выделены эти алгоритмы, будет неплохой иллю%страцией реального применения цикла Дейкстры. Вторая группа заметно изме%ненных программ – алгоритмы с возвратом в главе 3, в которых теперь экспли%цировано применение линейного поиска и, благодаря этому, тривиализована верификация. Такое прояснение рекурсивных комбинаторных алгоритмов явля%ется довольно общим. Обсуждались – но были признаны в данный момент неце%лесообразными – модификации и некоторых других программ.Надо заметить, что программистский стиль автора вырабатывался с конца1950%х гг., когда проблема эффективности программ висела над головами про%граммистов дамокловым мечом, и за несколько лет до того, как Дейкстра опубли%ковал систематический метод построения программ [4]. В старых версиях книги заметна рефлекторная склонность к оптимизации до полного прояснения логики программ, что затрудняло эффективное применение формальной техники. Это легко объяснить: Н. Вирт осваивал только еще формирующиеся систематические методы, непосредственно участвуя в процессе создания программирования как академической дисциплины, версия за версией улучшая свои учебники.Но и через четверть века после последней существенной переделки учебника автором аналогичная склонность к преждевременной оптимизации при не просто не вполне уверенной, а напрочь отсутствующей формальной технике – и, как следствие, запутанные циклы, – характерные черты стиля «широких програм%мистских масс»! В профессиональных интернет%форумах до сих пор можно найти позорные дискуссии о том, нужно ли учиться писать циклы по Дейкстре, – и это в лучшем случае. Если же вообразить себе весь окружающий нас непрерывно рас%тущий массив софта, от которого наша жизнь зависит все больше, то впору впасть в депрессию: Quo usque tandem, Catilina? – Сколько еще нужно десятилетий, что%бы система образования вышла, наконец, на уровень, давным%давно достигнутый наукой? Во всяком случае, ясно, что едва ли не главная причина проблемы – хаос,царящий в системе ИТ%образования, тормозящий создание и распространение качественных методик и поддерживаемый, среди прочего, корыстными интереса%ми «монстров» индустрии.Здесь уместно сказать о языке Оберон/Компонентный Паскаль, пропаганди%руемом в качестве общей платформы для предполагаемой единой системы курсов О новой версии классического учебника Никлауса Вирта7программирования. Оберон – последний большой проект Никлауса Вирта, выда%ющегося инженера, ученого и педагога, вместе с Бэкусом, А. Ершовым, Дейкст%рой, Хоором и другими пионерами компьютерной информатики превратившего программирование в систематическую дисциплину и лучше всего известного со%зданием серии все более совершенных языков программирования – Паскаля(1970), Модулы%2 (1980) и наконец Оберона (1988, 2007). В этих языках отража%лось все более полное понимание проблематики эффективного программирова%ния. Языки эти сохраняют идейную и стилевую преемственность, и коммерсант,озабоченный сохранением доли рынка, не назвал бы их по%разному (ср. зоопарк бейсиков). Чтобы подчеркнуть эту преемственность, самому популярному диа%лекту Оберона было возвращено законное фамильное имя – Компонентный Пас%каль.Оберон/Компонентный Паскаль унаследовал лучшие черты старого доброгоПаскаля и добавил к ним промышленный опыт Модулы%2 (на которой програм%мируются, например, российские спутники связи [5]), а также выверенный мини%мум средств объектно%ориентированного программирования. Принципальное до%стижение – удалось наконец добиться герметичности системы типов (теперь ее нельзя обойти средствами языка даже при работе с указателями). Это обеспечило возможность автоматического управления памятью (сбора мусора; до Оберона сбор мусора оставался прерогативой динамических языков – функциональных,скриптовых и т. п.) В результате диапазон эффективного применения Оберона,похоже, шире, чем у любого другого языка: это и вычислительные приложения, и системы управления любого масштаба (от беспилотников весом в 1 кг до гранди%озных каскадов ГЭС), и, например, задачи символической алгебры с предельно динамичными структурами данных.Особо следует остановиться на минимализме Оберона. Традиционно разра%ботчики сосредоточиваются на том, чтобы снабдить свои языки, программы, биб%лиотеки «богатым набором средств» – ведь так легче привлечь клиента, надеюще%гося побыстрее найти готовое решение для своих прикладных нужд. Погоня за«богатым набором средств» оборачивается ущербом качеству и надежности сис%темы. Вместе с коммерческими соображениями это приводит к тому, что полу%чается большая закрытая сложная система с вроде бы богатым набором средств,но хромающей надежностью и ограниченной расширяемостью, так что если поль%зователь сталкивается с нестандартной ситуацией в своих приложениях (что слу%чается сплошь и рядом – ведь разнообразие реального мира превосходит любое воображение писателей библиотек), то он оказывается в тупике.Н. Вирт еще со времен Паскаля, созданного в пику фантазийному Алголу%68[6], пошел другим путем. Его гамбит заключался в том, чтобы, отказавшись от включения в язык максимума средств на все случаи жизни, тщательнейшим обра%зом выделить минимум реально ключевых средств, – обязательно включив в этот минимум все, что нужно для безболезненной, неограниченной расширяемости программных систем, – и добиться высоконадежной реализации такого ядра.Этот замысел был с блеском реализован Н. Виртом и его соратником Ю. Гуткнех%том в проекте Оберон [7]. Минимализм и уникальная надежность Оберона О новой версии классического учебника Никлауса Вирта8заставляют вспомнить автомат Калашникова. При этом вся мощь Оберона оказы%вается открытой даже программистам%непрофессионалам – физикам, инженерам,лингвистам.., занятым программированием изрядную долю своего рабочего времени.Для преподавателя важно, что в Обероне достигнуты ортогональность и сво%бодная комбинируемость языковых средств, смысловая прозрачность, а также беспрецедентно малый для столь мощного языка размер (см. полное описание синтаксиса в приложении B, а также обсуждение в [8]). В этом отношении Оберон побеждает за явным преимуществом традиционные промышленные языки, пре%словутая избыточная сложность которых оказывается источником своего рода ренты, взимаемой с остального мира. Оберон скромно уходит в тень при рассмо%трении любой языково%неспецифичной темы – от введения в алгоритмику до принципов компиляции и программной архитектуры. А после постановки базо%вой техники программирования на Обероне изучение промышленных языков за%частую сводится к изучению способов обходить дефекты их дизайна. Если уже старый Паскаль оказался настолько удачной платформой для обучения програм%мированию, что принес своему автору высшую почесть в компьютерной инфор%матике – премию им. Тьюринга, то понятно, что буквально вылизанный Оберон/Компонентный Паскаль называют уже «практически идеальной» платформой для обучения программированию.Имея в виду исключительные педагогические достоинства Оберона, для всех примеров программ, приведенные в книге, обеспечена воспроизводимость в сис%теме программирования для Компонентного Паскаля, известной как Блэкбокс(BlackBox Component Builder [9]). Это пулярный вариант Оберона, созданный для работы в распространенных операционных системах. Конфигурации Блэк%бокса для использования в школе и университете доступны на сайте проекта «Ин%форматика%21» [2]. Открытый, бесплатный и безупречно современный Блэкбокс оказывается естественной заменой устаревшему Турбо Паскалю – заменой тем более привлекательной, что, несмотря на минимализм и благодаря автоматиче%скому управлению памятью, это более мощный инструмент, чем промышленные системы программирования на диалектах старого Паскаля. Краткое описание возможностей Блэкбокса с точки зрения использования в школьных курсах мож%но найти в статье [10].Важное приложение к книге – полный комплект программ, представленных в тексте учебника, в виде, готовом к выполнению. Программы оформлены в отдель%ных модулях вместе с необходимыми вспомогательными процедурами, и все та%кие модули собраны в папке ADru/Mod/, которая должна лежать внутри основной папки Блэкбокса (следует иметь в виду, что файлы с расширением *.odc должны читаться из Блэкбокса). Читатель без труда разберется с компиляцией и запуском программ по комментариям в модулях, читая модули в том порядке, в каком они встречаются в тексте книги (или в лексикографическом порядке имен файлов).В тексте книги в начальных строках каждого законченного программного приме%ра справа указано имя соответствующего модуля. Например, комментарий(*ADruS18_*) означает, что данная программа содержится в модуле О новой версии классического учебника Никлауса Вирта9ADruS18_, который в соответствии с правилами Блэкбокса хранится в фай%ле ADru/Mod/S18_.odc. При этом речь идет о программе из раздела 1.8,а необязательный суффикс "_" служит удобству ориентации. Вся папкаADru в составе Блэкбокса имеется на диске, если диск приложен к книге, либо может быть скачана с адреса [11].Наконец, несколько слов о собственно переводе. Старый перевод [3] был вы%полнен, что называется, из общих соображений. Но совсем другое дело – иметь в виду конкретных студентов, не обязательно будущих профессиональных программистов, пытающихся за минимальное время овладеть основами програм%мирования. Поэтому в новом переводе были предприняты особые усилия, чтобы избежать размывания смысла из%за неточностей, неизбежно вкрадывающихся при неполном понимании переводчиком оригинала (ср. примечание на с. 110в главе о сортировках в [3], где выражена надежда, что «сам читатель разберется,что хотел сказать автор»). Например, при более%менее прямолинейной пофразо%вой интерпретации малейшая неточность способна развалить смысл лаконичного текста Вирта из%за того, например, что после перевода могут перестать быть одно%коренными слова, благодаря которым только и обеспечивалась смысловая связь между предложениями в оригинале. Поэтому добиться полного сохранения смыс%ла при переводе оказалось проще, выполнив его с нуля.В отношении терминологии переводам специалистов было отдано должное.Вслед за Д. Б. Подшиваловым [3] мы используем прилагательные «массивовый»,«последовательностный» и «записевый». Решающий довод в пользу таких прила%гательных – они естественно вписываются в грамматическую систему русского языка, чем обеспечивается необходимая гибкость выражения.Однако даже в отношении терминологии переводы по компьютерной тематике часто демонстрируют неполное понимание существенных деталей английской грамматики. Например, при использовании существительного в качестве опреде%ления в препозиции (что, кстати, не эквивалентно русской конструкции, выража%емой родительным падежом) множественное число может нейтрализоваться, и при переводе на русский его иногда нужно восстанавливать. Так, path length дол%жно переводиться не как «длина пути», а как «длина путей», что, между прочим,прямо соответствует математическому определению и ощутимо помогает пони%мать рассуждения. Optimal search tree – «оптимальное дерево поиска», а не «дере%во оптимального поиска». Advanced sort algorithms – «эффективные алгоритмы сортировки», потому что буквальное значение advanced в данном случае давно нейтрализовано. Переводить на русский язык двумя словами специфичные для стилистики английского языка синонимичные пары вроде «methods and tech%niques» обычно неразумно. И так далее. Масса подобных неточностей снижает удобочитаемость текста и затемняет и без того непростой смысл оригинала.Хотя по конкретным стилистическим вопросам копья можно ломать до беско%нечности, все же хочется надеяться, что предпринятые усилия в основном достиг%ли цели – не потерять точный смысл английского «исходника» этого выдержав%шего проверку временем прекрасного учебника.Троицк, Московская обл., июль 2009Ф. В. Ткачев О новой версии классического учебника Никлауса Вирта10[1] Wirth N. Algorithms and Data Structures. Oberon version: 2004 //http://www.inr.ac.ru/info21/pdf/AD.pdf[2] Информатика%21: Международный общественный научно%образователь%ный проект // http://www.inr.ac.ru/info21/[3] Н. Вирт. Алгоритмы и структуры данных / пер. с англ. Д. Б. Подшивалова. –М.: Мир, 1989.[4] Дейкстра Э. Дисциплина программирования. – М.: Мир, 1978.[5] Koltashev A. A., in: Lecture Notes in Computer Science 2789. – Springer%Verlag,2003.[6] Кто такой Никлаус Вирт? // http://www.inr.ac.ru/info21/wirth/wirth.htm[7] Wirth N. and Gutknecht J. Project Oberon. – Addison%Wesley, 1992.[8] Свердлов С. В. Языки программирования и методы трансляции. – СПб.:Питер, 2007.[9] http://www.oberon.ch/blackbox.html[10] Ильин А. С. и Попков А. И. Компонентный Паскаль в школьном курсе ин%форматики // http://inf.1september.ru/article.php?ID=200800100[11] http://www.inr.ac.ru/info21/ADru/ ПредисловиеВ последние годы признано, что умение создавать программы для вычислитель%ных машин является залогом успеха во многих инженерных проектах и что дис%циплина программирования может быть объектом научного анализа и допускает систематическое изложение. Программирование из ремесла превратилось в ака%демическую дисциплину. Первые выдающиеся результаты на этом пути получе%ны Дейкстрой (E. W. Dijkstra) и Хоором (C. A. R. Hoare). «Заметки по структурно%му программированию» Дейкстры [1] позволили взглянуть на программирование как на объект научного анализа, бросающий вызов человеческому интеллекту,а слова структурное программирование дали название «революции» в програм%мировании. Работа Хоора «Аксиоматические основы программирования» [2]продемонстрировала, что программы допускают точный анализ, основанный на математических рассуждениях. И обе статьи убедительно доказывают, что мно%гих ошибок в программах можно избежать, если программисты будут систе%матически применять методы и приемы, которые ранее применялись лишь инту%итивно и часто неосознанно. Эти статьи сосредоточили внимание на построении и анализе программ, или, точнее говоря, на структуре алгоритмов, представленных текстом программы. При этом вполне очевидно, что систематический научный подход к построению программ уместен прежде всего в случае больших, непрос%тых программ, работающих со сложными наборами данных. Отсюда следует, что методология программирования должна включать в себя все аспекты структури%рования данных. В конце концов, программы суть конкретные формулировки аб%страктных алгоритмов, основанные на конкретных представлениях и структурах данных. Выдающийся вклад в наведение порядка в огромном разнообразии тер%минологии и понятий, относящихся к структурам данных, сделал Хоор в статье«О структурной организации данных» [3]. В этой работе продемонстрировано,что нельзя принимать решения о структуре данных без учета того, какие алгорит%мы применяются к данным, и что, обратно, структура и выбор алгоритмов часто сильно зависят от стуктуры обрабатываемых данных. Короче говоря, задачу пост%роения программ нельзя отделять от задачи структурирования данных.Но данная книга начинается главой о структурах данных, и для этого есть две причины. Во%первых, интуитивно ощущается, что данные предшествуют алгорит%мам: нужно иметь некоторые объекты до того, как можно будет что%то с ними де%лать. Во%вторых, эта книга предполагает, что читатель знаком с основными поня%тиями программирования. Однако в соответствии с разумной традицией вводные курсы программирования концентрируют внимание на алгоритмах, работающих с относительно простыми структурами данных. Поэтому уместно посвятить ввод%ную главу структурам данных.На протяжении всей книги, включая главу 1, мы следуем теории и термино%логии, развитой Хоором и реализованной в языке программирования Паскаль [4].Сущность теории – в том, что данные являются прежде всего абстракциями реальных явлений и их предпочтительно формулировать как абстрактные струк% Предисловие12туры безотносительно к их реализации в распространенных языках программиро%вания. В процессе построения программы представление данных постепенно уточняется – в соответствии с уточнением алгоритма, – чтобы все более и более удовлетворить ограничениям, налагаемым имеющейся системой программи%рования [5]. Поэтому мы постулируем несколько основных структур данных, на%зываемых фундаментальными. Очень важно, что это конструкции, которые дос%таточно легко реализовать на реальных компьютерах, ибо только в этом случае их можно рассматривать как истинные элементарные составляющие реального представления данных, появляющиеся как своего рода молекулы на последнем шаге уточнения описания данных. Это запись, массив (с фиксированным разме%ром) и множество. Неудивительно, что эти базовые строительные элементы соответствуют математическим понятиям, которые также являются фундамен%тальными.Центральный пункт этой теории структур данных – разграничение фундаментальных и сложных структур. Первые суть молекулы, – сами построенные из ато%мов, – из которых строятся вторые. Переменные, принадлежащие одному из таких фундаментальных видов структур, меняют только свое значение, но никогда не ме%няют ни свое строение, ни множество своих допустимых значений. Как следствие –размер занимаемой ими области памяти фиксирован. «Сложные» структуры, на%против, характеризуются изменением во время выполнения программы как своих значений, так и строения. Поэтому для их реализации нужны более изощренные методы. В этой классификации последовательность оказывается гибридом. Конеч%но, у нее может меняться длина; но такое изменение структуры тривиально. По%скольку последовательности играют поистине фундаментальную роль практичес%ки во всех вычислительных системах, их обсуждение включено в главу 1.Во второй главе речь идет об алгоритмах сортировки. Там представлено не%сколько разных методов, решающих одну и ту же задачу. Математическое изу%чение некоторых из них показывает их преимущества и недостатки, а также под%черкивает важность теоретического анализа при выборе хорошего решения для конкретной задачи. Разделение на методы сортировки массивов и методы сорти%ровки файлов (их часто называют внутренней и внешней сортировками) демон%стрирует решающее влияние представления данных на выбор алгоритмов и на их сложность. Теме сортировки уделяется такое внимание потому, что она пред%ставляет собой идеальную площадку для иллюстрации очень многих принципов программирования и ситуаций, возникающих в большинстве других приложе%ний. Похоже, что курс программирования можно было бы построить, используя только примеры из темы сортировки.Другая тема, которую обычно не включают во вводные курсы программиро%вания, но которая играет важную роль во многих алгоритмических решениях, –это рекурсия. Поэтому третья глава посвящена рекурсивным алгоритмам. Здесь показывается, что рекурсия есть обобщение понятия цикла (итерации) и что она является важным и мощным понятием программирования. К сожалению, во мно%гих учебниках программирования она иллюстрируется примерами, для которых было бы достаточно простой итерации. Мы в главе 3, напротив, сосредоточим внимание на нескольких задачах, для которых рекурсия дает наиболее естествен%ную формулировку решения, тогда как использование итерации привело бы к за% Предисловие13путанным и громоздким программам. Класс алгоритмов с возвратом – отличное применение рекурсии, но самые очевидные кандидаты для применения рекур%сии – это алгоритмы, работающие с данными, структура которых определена ре%курсивно. Эти случаи рассматриваются в последних двух главах, для которых,таким образом, третья закладывает фундамент.В главе 4 рассматриваются динамические структуры данных, то есть такие,строение которых меняется во время выполнения программы. Показывается, что рекурсивные структуры данных являются важным подклассом часто использу%емых динамических структур. Хотя рекурсивные определения возможны и даже естественны в этих случаях, на практике они обычно не используются. Вместо них используют явные ссылочные или указательные переменные. Данная книга тоже следует подобному подходу и отражает современный уровень понимания предме%та: глава 4 посвящена программированию с указателями, списками, деревьями и содержит примеры с даже еще более сложно организованными данными. Здесь речь идет о том, что обычно (хотя и не совсем правильно) называют обработкой списков. Немало места уделено построению деревьев и, в частности, деревьям по%иска. Глава заканчивается обсуждением так называемых хэш%таблиц, которые ча%сто используют вместо деревьев поиска. Это дает возможность сравнить два принципиально различных подхода к решению часто возникающей задачи.Программирование – это конструирование. Как вообще можно учить изобре%тательному конструированию? Можно было бы попытаться из анализа многих примеров выделить элементарные композиционные принципы и представить их систематическим образом. Но программирование имеет дело с задачами огромно%го разнообразия и часто требует серьезных интеллектуальных усилий. Ошибочно думать, что обучить ему можно, просто дав некий список рецептов. Но тогда в на%шем арсенале методов обучения остаются только тщательный подбор и изложе%ние образцовых примеров. Естественно, не следует ожидать, что изучение приме%ров будет равно полезным для разных людей. При таком подходе многое зависит от самого учащегося, от его прилежания и интуиции. Это особенно справедливо для относительно сложных и длинных примеров программ. Такие примеры включены в книгу не случайно. Длинные программы доминируют в практике программирования, и они гораздо больше подходят для демонстрации тех труд%но определяемых, но существенных свойств, которые называют стилем и хоро%шей структурой. Они также должны послужить упражнениями в искусстве чте%ния программ, которым часто пренебрегают в пользу написания программ. Это главная причина того, почему в качестве примеров используются целиком до%вольно большие программы. Читатель имеет возможность проследить постепен%ную эволюцию программы и увидеть ее состояние на разных шагах, так что про%цесс разработки предстает как пошаговое уточнение деталей. Считаю, что важно показать программу в окончательном виде, уделяя достаточно внимания деталям,так как в программировании дьявол прячется в деталях. Хотя изложение общей идеи алгоритма и его анализ с математической точки зрения могут быть увлека%тельными для ученого, по отношению к инженеру%практику ограничиться только этим было бы нечестно. Поэтому я строго придерживался правила давать оконча%тельные программы на таком языке, на котором они могут быть реально выполне%ны на компьютере. Предисловие14Разумеется, здесь возникает проблема поиска нотации, которая одновременно позволяла бы выполнить программу на вычислительной машине и в то же время была бы достаточно машинно независимой, чтобы ее можно было включать в по%добный текст. В этом отношении не удовлетворительны ни широко используемые языки, ни абстрактная нотация. Язык Паскаль представляет собой подходящий компромисс; он был разработан именно для этой цели и поэтому используется на протяжении всей книги. Программы будут понятны программистам, знакомым с другими языками высокого уровня, такими как Алгол 60 или PL/1: смысл нота%ции Паскаля объясняется в книге по ходу дела. Однако некоторая подготовка все же могла бы быть полезной. Книга «Систематическое программирование» [6]идеальна в этом отношении, так как она тоже основана на нотации Паскаля. Одна%ко следует помнить, что настоящая книга не предназначена быть учебником язы%ка Паскаль; для этой цели есть более подходящие руководства [7].Данная книга суммирует – и при этом развивает – опыт нескольких курсов программирования, прочитанных в Федеральном политехническом институте(ETH) в Цюрихе. Многими идеями и мнениями, представленными в этой книге,я обязан дискуссиям со своими коллегами в ETH. В частности, я хотел бы поблагодарить г%на Г. Сандмайра за внимательное чтение рукописи, а г%жу ХайдиТайлер и мою жену за тщательную и терпеливую перепечатку текста. Я должен также упомянуть о стимулирующем влиянии заседаний рабочих групп 2.1 и 2.3ИФИПа, и в особенности многих дискуссий, которые мне посчастливилось иметь с Э. Дейкстрой и Ч. Хоором. Наконец, нужно отметить щедрость ETH, обеспечив%шего условия и предоставившего вычислительные ресурсы, без которых подго%товка этого текста была бы невозможной.Цюрих, август 1975Н. Вирт[1]Dijkstra E. W., in: Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Prog%ramming. F. Genuys, Ed., New York, Academic Press, 1972. Р. 1–82 (имеется перевод: Дейкстра Э. Заметки по структурному программированию, в кн.:Дал У., Дейкстра Э., Хоор К. Структурное программирование. – М.: Мир,1975. С. 7–97).[2]Hoare C. A. R. Comm. ACM, 12, No. 10 (1969), 576–83.[3]Hoare C. A. R., in Structured Programming [1]. Р. 83%174 (имеется перевод:Хоор К. О структурной организации данных, в кн. [1]. С. 98–197).[4]Wirth N. The Programming Language Pascal. Acta Informatica, 1, No. 1 (1971),35–63.[5]Wirth N. Program Development by Stepwise Refinement. Comm. ACM, 14, No. 4(1971), 221–27.[6]Wirth N. Systematic Programming. Englewood Cliffs, N. J. Prentice%Hall, Inc.,1973 (имеется перевод: Вирт Н. Систематическое программирование. Вве%дение. – М.: Мир, 1977).[7]Jensen K. and Wirth N. PASCAL%User Manual and Report. Berlin, Heidelberg,New York; Springer%Verlag, 1974 (имеется перевод: Йенсен К., Вирт Н. Пас%каль. Руководство для пользователя и описание языка. – М.: Финансы и ста%тистика, 1988). Предисловиек изданию 1985 годаВ этом новом издании сделано много улучшений в деталях, а также несколько бо%лее серьезных модификаций. Все они мотивированы опытом, приобретенным за десять лет после первого издания. Однако основное содержание и стиль текста не изменились. Кратко перечислим важнейшие изменения.Главное изменение, повлиявшее на весь текст, касается языка программирова%ния, использованного для записи алгоритмов. Паскаль был заменен на Модулу%2.Хотя это изменение не оказывает серьезного влияния на представление алгорит%мов, выбор оправдан большей простотой и элегантностью синтаксиса Модулы%2,что часто приводит к большей ясности представления структуры алгоритма. Кро%ме того, было сочтено полезным использовать нотацию, которая приобретает по%пулярность в довольно широком сообществе по той причине, что она хорошо под%ходит для разработки больших программных систем. Тем не менее тот очевидный факт, что Паскаль является предшественником Модулы, облегчает переход. Для удобства читателя синтаксис Модулы суммирован в приложении.Как прямое следствие замены языка программирования был переписан раз%дел 1.11 о последовательной файловой структуре. В Модуле%2 нет встроенного файлового типа. В пересмотренном разделе 1.11 понятие последовательности как структуры данных представлено в более общем виде, и там также вводится набор программных модулей, которые явно реализуют идею последовательности конк%ретно в Модуле%2.Последняя часть главы 1 является новой. Она посвящена теме поиска и, начи%ная с линейного и двоичного поиска, подводит к некоторым недавно изобретен%ным быстрым алгоритмам поиска строк. В этом разделе подчеркивается важность проверок промежуточных состояний (assertions) и инвариантов цикла для дока%зательства корректности представляемых алгоритмов.Новый раздел о приоритетных деревьях поиска завершает главу, посвященную динамическим структурам данных. Эта разновидность деревьев была неизвестна во время выхода первого издания. Такие деревья допускают экономное представление и позволяют выполнять быстрый поиск по множествам точек на плоскости.Целиком исключена вся пятая глава первого издания. Это сделано потому, что тема построения компиляторов стоит несколько в стороне от остальных глав и заслуживает более подробного обсуждения в отдельной книге.Наконец, появление нового издания отражает прогресс, глубоко повлиявший на издательское дело в последние десять лет: применение компьютеров и изощ%ренных алгоритмов для подготовки и автоматического форматирования докумен%тов. Эта книга была набрана и сформатирована автором с помощью компьютераLilith и редактора документов Lara. Без этих инструментов книга не только стала бы дороже, но, несомненно, даже еще не была бы закончена.Пало Альто, март 1985 г.Н. Вирт НотацияВ книге используются следующие обозначения, взятые из работ Дейкстры.В логических выражениях литера & обозначает конъюнкцию и читается как«и». Литера обозначает отрицание и читается как «не». Комбинация литер or обозначает дизъюнкцию и читается как «или». Литеры AAAAA и EEEEE, набранные жирным шрифтом, обозначают кванторы общности и существования. Нижеследующие формулы определяют смысл нотации в левой части через выражение в правой.Интерпретация символа «...» в правых частях оставлена интуиции читателя.AAAAAi: m ≤ i < n : Pi Pm & Pm+1 & ... & Pn–1Здесь Pi – некоторые предикаты, а формула утверждает, что выполняются всеPi для значений индекса i из диапазона от m до n, но не включая само nEEEEEi: m ≤ i < n : Pi Pm or Pm+1 or ... or Pn–1Здесь Pi – некоторые предикаты, а формула утверждает, что выполняются некоторые из Pi для каких%то значений индекса i из диапазона от m до n, но не включая само nSSSSSi: m ≤ i < n : x i = x m + x m+1 + ... + x n–1MIN i: m ≤ i < n : x i = минимальное среди значений (x m, ... , x n–1)MAX i: m ≤ i < n : x i = максимальное среди значений (x m, ... , x n–1)   1   2   3   4   5   6   7   8   9   ...   22

1.3.2. Тип REALТип REAL представляет подмножество вещественных чисел. Если для арифмети%ческих операций с операндами типа INTEGER предполагается, что они дают точные результаты, то арифметические операции со значениями типа REAL могут быть неточными в пределах ошибок округления, вызванных тем, что вычисления про%изводятся с конечным числом значащих цифр. Это главная причина для того, что%бы явно различать типы INTEGER и REAL, как это делается в большинстве языков программирования.Стандартные примитивные типы Фундаментальные структуры данных24Стандартные операции – четыре основные арифметические операции: сложе%ние (+), вычитание (–), умножение (*) и деление (/). Сущность типизации дан%ных – в том, что разные типы становятся несовместимыми по присваиванию. Ис%ключение делается для присваивания целочисленных значений вещественным переменным, так как семантика в этом случае однозначна. Ведь целые числа со%ставляют подмножество вещественных. Однако присваивание в обратном на%правлении запрещено: присваивание вещественного значения целой переменной требует отбрасывания дробной части или округления. Стандартная функция пре%образования ENTIER(x) дает целую часть величины x. Тогда округление величины x выполняется с помощью ENTIER(x + 0.5)Многие языки программирования не содержат операций возведения в степень.Следующий алгоритм обеспечивает быстрое вычисление величины y = x n, где n –неотрицательное целое:y := 1.0; i := n;(* ADruS13 *)WHILE i > 0 DO (* x0n = x i * y *)IF ODD(i) THEN y := y*x END;x := x*x; i := i DIV 2END1.3.3. Тип BOOLEANДва значения стандартного типа BOOLEAN обозначаются идентификаторами TRUEи FALSE. Булевские операции – это логические конъюнкция, дизъюнкция и отри%цание, которые определены в табл. 1.1. Логическая конъюнкция обозначается символом &, логическая дизъюнкция – символом OR, а отрицание – символом Заметим, что операции сравнения всегда вычисляют результат типа BOOLEANПоэтому результат сравнения можно присвоить переменной или использовать как операнд логической операции в булевском выражении. Например, для булев%ских переменных p и q и целых переменных x = 5, y = 8, z = 10 два присваивания p := x = y q := (x ≤ y) & (y < z)дают p = FALSE и q = TRUEp qp & q p OR qpTRUETRUETRUETRUEFALSETRUEFALSETRUEFALSEFALSEFALSETRUETRUEFALSETRUEFALSEFALSEFALSEFALSETRUEТаблица 1.1. Таблица 1.1. Таблица 1.1. Таблица 1.1. Таблица 1.1. Булевские операцииВ большинстве языков программирования булевские операции & (AND) и ORобладают дополнительным свойством, отличающим их от других бинарных опера%ций. Например, сумма x+y не определена, если не определен любой из операндов x 25или y, однако конъюнкция p&q определена, даже если не определено значение q, при условии что p равно FALSE. Такое соглашение оказывается важным и полезным. По%этому точное определение операций & и OR дается следующими равенствами:p & q= если p, то q, иначе FALSEp OR q= если p, то TRUE, иначе q1.3.4. Тип CHARСтандартный тип CHAR представляет литеры, которые можно напечатать.К сожалению, нет общепринятого стандартного множества литер для всех вычислительных систем. Поэтому прилагательное «стандартный» в этом случае может привести к путанице; его следует понимать в смысле «стандартный для вычислительной установки, на которой должна выполняться программа».Чаще всего используется множество литер, определенное Международной организацией по стандартизации (ISO), и, в частности, его американский вариантASCII (American Standard Code for Information Interchange). Поэтому множествоASCII приведено в приложении A. Оно содержит 95 графических (имеющих изоб%ражение) литер, а также 33 управляющие (не имеющих изображения) литеры,используемые в основном при передаче данных и для управления печатающими устройствами.Чтобы можно было создавать алгоритмы для работы с литерами (то есть со значениями типа CHAR), которые не зависели бы от вычислительной системы,нужно иметь возможность сделать некоторые минимальные предположения о свойствах множества литер, а именно:1. Тип CHAR содержит 26 заглавных латинских букв, 26 строчных букв, 10 де%сятичных цифр, а также некоторые другие графические символы, например знаки препинания.2. Подмножества букв и цифр упорядочены и между собой не пересекаются:("A" ≤ x) & (x ≤ "Z")подразумевает, что x – заглавная буква;("a" ≤ x) & (x ≤ "z")подразумевает, что x – строчная буква;("0" ≤ x) & (x ≤ "9")подразумевает, что x – десятичная цифра.3. Тип CHAR содержит непечатаемые символы пробела и конца строки, кото%рые можно использовать как разделители.Для написания машинно независимых программ особенно важны две стандартные функции преобразования между типами CHAR и INTEGER. Назовем их ORD(ch) (дает порядковый номер литеры ch в используемом множестве литер)Рис. 1.1. Представление текстаСтандартные примитивные типы Фундаментальные структуры данных26и CHR(i) (дает литеру с порядковым номером i). Таким образом, CHR является обратной функцией для ORD и наоборот, то естьORD(CHR(i)) = i(если CHR(i) определено)CHR(ORD(c)) = cКроме того, постулируем наличие стандартной функции CAP(ch). Ее значе%ние – заглавная буква, соответствующая ch, если ch – буква.если ch – строчная буква, то CAP(ch) – соответствующая заглавная буква если ch – заглавная буква, то CAP(ch) = ch1.3.5. Тип SETТип SET представляет множества, элементами которых являются целые числа из диапазона от 0 до некоторого небольшого числа, обычно 31 или 63. Например,если определены переменныеVAR r, s, t: SETто возможны присваивания r := {5}; s := {x, y .. z}; t := {}Здесь переменной r присваивается множество, состоящее из единственного элемента 5; переменной t присваивается пустое множество, а переменной s – мно%жество, состоящее из элементов x, y, y+1, … , z–1, zСледующие элементарные операции определены для операндов типа SET:*пересечение множеств+объединение множеств–разность множеств/симметрическая разность множествINпринадлежность множествуОперации пересечения и объединения множеств часто называют умножением и сложением множеств соответственно; приоритеты этих операций определяются так, что приоритет пересечения выше, чем у объединения и разности, а приори%теты последних выше, чем у операции принадлежности, которая вычисляет логи%ческое значение. Ниже даются примеры выражений с множествами и их версии с полностью расставленными скобками:r * s + t= (r*s) + t r – s * t= r – (s*t)r – s + t = (r–s) + t r + s / t = r + (s/t)x IN s + t = x IN (s+t)1.4. МассивыВероятно, массив – наиболее широко используемая структура данных; в некото%рых языках это вообще единственная структура. Массив состоит из компонент,имеющих одинаковый тип, называемый базовым; поэтому о массиве говорят как 27об однородной структуре. Массив допускает произвольный доступ, так как можно произвольно выбрать любую компоненту, и доступ к ним осуществляется одинаково быстро. Чтобы обозначить отдельную компоненту, к имени всего массива нужно присоединить индекс, то есть номер компоненты. Индекс должен быть целым числом в диапазоне от 0 до n–1, где n – число элементов, или длина массива.TYPE T = ARRAY n OF T0ПримерыTYPE Row= ARRAY 4 OF REALTYPE Card= ARRAY 80 OF CHARTYPE Name = ARRAY 32 OF CHARКонкретное значение переменнойVAR x: Row у которой все компоненты удовлетворяют уравнению xi = 2–i, можно представить как на рис. 1.2.Отдельная компонента массива выбирается с помощью индекса. Если имеется переменнаямассив x, то соответствующий селектор (то есть конструкцию для выбора отдельной компоненты) будем изображать посредством имени массива, за которым следует индекс соответствующей компоненты i, и будем писать xi или x[i]Первое из этих обозначений принято в математике, поэтому компоненту массива еще называют индексированной переменной.Обычный способ работы с массивами, особенно с большими, состоит в том,чтобы выборочно изменять отдельные компоненты, вместо того чтобы строить новое составное значение целиком. Для этого переменнуюмассив рассматривают как массив переменныхкомпонент и разрешают присваивания отдельным компонентам, например x[i]:=0.125. Хотя при выборочном присваивании меняется только одна компонента, с концептуальной точки зрения мы должны считать, что меняется все составное значение массива.Тот факт, что индексы массива (фактически имена компонент массива) суть целые числа, имеет весьма важное следствие: индексы могут вычисляться. Вместо индексаконстанты можно поставить общее индексное выражение; тогда подразумевается, что выражение вычисляется, а его значение используется для выбора компоненты. Такая общность не только дает весьма важное и мощное средство программирования, но и приводит к одной из самых частых ошибок в программах:вычисленное значение индекса может оказаться за пределами диапазона индексов данного массива. Будем предполагать, что «приличные» вычислительные системы выдают предупреждение, если имеет место ошибочная попытка доступа к несуществующей компоненте массива.Мощность стуктурированного типа, то есть количество значений, принадлежащих этому типу, равна произведению мощностей его компонент. Поскольку все компоненты массивового типа T имеют одинаковый базовый тип T0, то получаемРис. 1.2. Массив типаRow, в котором xi = 2–iМассивы Фундаментальные структуры данных28card(T) = card(T0)nЭлементы массивов сами могут быть составными. Переменная%массив, у кото%рой все компоненты – тоже массивы, называется матрицей. Например,M: ARRAY 10 OF Row является массивом, состоящим из десяти компонент (строк), причем каждая ком%понента состоит из четырех компонент типа REAL. Такой массив называется мат%рицей 10×4 с вещественными компонентами. Селекторы могут приписываться один за другим, так что Mij и M[i][j] обозначают j%ю компоненту строки Mi, которая,в свою очередь, является i%й компонентой матрицы M. Обычно используют сокра%щенную запись M[i,j], и аналогично для объявленияM: ARRAY 10 OF ARRAY 4 OF REALможно использовать сокращенную записьM: ARRAY 10, 4 OF REALЕсли нужно выполнить некоторое действие со всеми компонентами массива или с группой идущих подряд компонент, то удобно подчеркнуть этот факт, ис%пользуя оператор цикла FOR, как показано в следующих примерах, где вычисляет%ся сумма и находится максимальный элемент массива a:VAR a: ARRAY N OF INTEGER;(* ADruS14 *)sum := 0;FOR i := 0 TO N–1 DO sum := a[i] + sum ENDk := 0; max := a[0];FOR i := 1 TO N–1 DOIF max < a[i] THEN k := i; max := a[k] ENDENDВ следующем примере предполагается, что дробь f представляется в десятич%ном виде с k–1 цифрами, то есть массивом d, таким, что f = SSSSSi: 0≤ i < k: d i * 10–i или f = d0 + 10*d1 + 100*d2 + … + 10k–1*d k–1Предположим теперь, что мы хотим поделить f на 2. Это делается повторением уже знакомой операции деления для всех k–1 цифр di, начиная с i=1. Каждая циф%ра делится на 2 с учетом остатка деления в предыдущей позиции, а возможный остаток от деления в данной позиции, в свою очередь, запоминается для следую%щего шага:r := 10*r +d[i]; d[i] := r DIV 2; r := r MOD 2Этот алгоритм используется для вычисления таблицы отрицательных степеней числа 2. Повторные деления пополам для вычисления величин 2–1, 2–2, ... , 2–N снова удобно выразить оператором цикла FOR, так что в итоге получается пара вложен%ных циклов FOR 29PROCEDURE Power (VAR W: Texts.Writer; N: INTEGER);(* ADruS14 *)(* 2*)VAR i, k, r: INTEGER;d: ARRAY N OF INTEGER;BEGINFOR k := 0 TO N–1 DOTexts.Write(W, "."); r := 0;FOR i := 0 TO k–1 DOr := 10*r + d[i]; d[i] := r DIV 2; r := r MOD 2;Texts.Write(W, CHR(d[i] + ORD("0")))END;d[k] := 5; Texts.Write(W, "5"); Texts.WriteLn(W)ENDEND PowerВ результате для N = 10 печатается следующий текст:.5.25.125.0625.03125.015625.0078125.00390625.001953125.00097656251.5. ЗаписиСамый общий способ строить составные типы заключается в том, чтобы объединять в некий агрегат элементы произвольных типов, которые сами могут быть составными типами. Примеры из математики: комплексные числа, составленные из пары веще%ственных, а также координаты точки, составленные из двух или более чисел в соот%ветствии с размерностью пространства. Пример из обработки данных: описание лю%дей посредством нескольких свойств, таких как имя и фамилия, дата рождения.С точки зрения математики, такой составной тип (compound type) является прямым (декартовым) произведением составляющих типов (constituent types):множество значений составного типа состоит из всевозможных комбинаций зна%чений, каждое из которых взято из множества значений соответствующего со%ставляющего типа. Поэтому число таких комбинаций, называемых также nками(n%tuples), равно произведению чисел элементов каждого составляющего типа;другими словами, мощность составного типа равна произведению мощностей со%ставляющих типов.В обработке данных сложные типы, такие как описания людей или объектов,обычно хранятся в файлах или базах данных и содержат нужные характеристики человека или объекта. Поэтому для описания составных данных такой природыЗаписи Фундаментальные структуры данных30стал широко употребляться термин запись, и мы тоже будем его использовать вместо термина «прямое произведение». В общем случае записевый тип (record type) T с компонентами типов T1, T2, ... , Tn определяется следующим образом:TYPE T = RECORD s1: T1; s2: T2; ... s n: Tn ENDcard(T) = card(T1) * card(T2) * ... * card(Tn)ПримерыTYPE Complex = RECORD re, im: REAL ENDTYPE Date =RECORD day, month, year: INTEGER ENDTYPE Person =RECORD name, firstname: Name;birthdate: Date;male: BOOLEANENDКонкретные значения записей, например, для переменных z: Complex d: Date p: Person можно изобразить так, как показано на рис. 1.3.Рис. 1.3. Записи типов Complex, Date и PersonИдентификаторы s1, s2, ..., sn, вводимые в определении записевого типа, суть имена, данные отдельным компонентам переменных этого типа. Компоненты за%писи называются полями (field), а их имена – идентификаторами полей (field identifiers). Их используют в селекторах, применяемых к переменным записевых типов. Если дана переменная x:T, ее i%е поле обозначается как x.s i. Можно выпол%нить частичное изменение x, если использовать такой селектор в левой части опе%ратора присваивания:x.s i := e где e – значение (выражение) типа Ti. Например, если имеются записевые пере%менные z, d, и p, объявленные выше, то следующие выражения суть селекторы их компонент:z.im(типа REAL)d.month(типа INTEGER) 31p.name(типа Name)p.birthdate(типа Date)p.birthdate.day(типа INTEGER)p.mail(типа BOOLEAN)Пример типа Person показывает, что компонента записевого типа сама может быть составной. Поэтому селекторы могут быть применены один за другим. Есте%ственно, что разные способы структурирования тоже могут комбинироваться.Например, i%я компонента массива a, который сам является компонентой записе%вой переменной r, обозначается как r.a[i], а компонента с именем s из i%го элемента массива a, состоящего из записей, обозначается как a[i].sПрямое произведение по определению состоит из всевозможных комбинаций элементов своих составляющих типов. Однако нужно заметить, что в реальных приложениях не все они могут иметь смысл. Например, определенный выше типDate допускает значения 31 апреля и 29 февраля 1985, которых в календаре нет.Поэтому приведенное определение этого типа отражает реальную ситуацию не вполне верно, но достаточно близко, а программист должен обеспечить, чтобы при выполнении программы бессмысленные значения никогда не возникали.Следующий фрагмент программы показывает использование записевых пере%менных. Его назначение – подсчет числа лиц женского пола, родившихся после2000 г., среди представленных в переменной%массиве:VAR count: INTEGER;family: ARRAY N OF Person;count := 0;FOR i := 0 TO N–1 DOIF family[i].male & (family[i].birthdate.year > 2000) THEN INC(count) ENDENDИ запись, и массив обеспечивают произвольный доступ к своим компонентам.Запись является более общей в том смысле, что ее составляющие типы могут быть разными. Массив, в свою очередь, допускает большую гибкость в том отношении,что селекторы компонент могут вычисляться (задаваться выражениями), тогда как селекторы компонент записи суть идентификаторы полей, объявленные в определении типа записи.1   2   3   4   5   6   7   8   9   ...   22

1.6. Представление массивов,записей и множествСмысл использования абстракций в программировании – в том, чтобы обеспе%чить возможность спроектировать, понять и верифицировать программу, исходя только из свойств абстракций, то есть не зная о том, как абстракции реализованы и представлены на конкретной машине. Тем не менее профессиональному программисту нужно понимать широко применяемые способы представления фундаментальных структур данных. Это может помочь принять разумные реше%Представление массивов, записей и множеств Фундаментальные структуры данных32ния о построении программы и данных в свете не только абстрактных свойств структур, но и их реализации на конкретной машине с учетом ее специфических особенностей и ограничений.Проблема представления данных заключается в том, как отобразить абстракт%ную структуру на память компьютера. Память компьютера – это, грубо говоря,массив отдельных ячеек, которые называются байтами. Они интерпретируются как группы из 8 бит каждая. Индексы байтов называются адресами.VAR store: ARRAY StoreSize OF BYTEПримитивные типы представляются небольшим числом байтов, обычно 1, 2, 4или 8. Компьютеры проектируются так, чтобы пересылать небольшие группы смежных байтов одновременно, или, как говорят, параллельно. Единицу одновре%менной пересылки называют словом.1.6.1. Представление массивовПредставление массива – это отображение (абстрактного) массива, компоненты ко%торого имеют тип T, на память компьютера, которая сама есть массив компонент типаBYTE. Массив должен быть отображен на память таким образом, чтобы вычисление адресов его компонент было как можно более простым (и поэтому эффективным).Адрес i для j%й компоненты массива вычисляется с помощью линейной функции i = i0 + j*s,где i0 – адрес первой компоненты, а s – количество слов, которые занимает одна компонента. Принимая, что слово является наименьшей единицей пересылки содержимого памяти, весьма желательно, чтобы s было целым числом, в простей%шем случае s = 1. Если s не является целым (а это довольно обычная ситуация), то s обычно округляется вверх до ближайшего большего целого S. Тогда каждая ком%понента массива занимает S слов, тогда как S–s слов остаются неиспользованными(см. рис. 1.4 и 1.5). Округление необходимого числа слов вверх до ближайшего це%лого называется выравниванием (padding). Эффективность использования памя%ти u определяется как отношение минимального объема памяти, нужного дляРис. 1.4. Отображение массива на память компьютера 33представления структуры, к реально использованному объему:u = s / (s, округленное вверх до ближайшего целого).Поскольку проектировщик компилятора должен стремиться к тому, чтобы эффективность использования памяти была как можно ближе к 1, но при этом доступ к частям слова громоздок и относительно неэффективен, приходится идти на компромисс. При этом учитываются следующие соображения:1. Выравнивание уменьшает эффективность использования памяти.2. Отказ от выравнивания может повлечь необходимость выполнять неэф%фективный доступ к частям слова.3. Доступ к частям слова может вызвать разбухание скомпилированного кода и тем самым уменьшить выигрыш, полученный из%за отказа от выравнивания.На самом деле соображения 2 и 3 обычно настолько существенны, что компи%ляторы автоматически используют выравнивание. Заметим, что эффективность использования памяти всегда u > 0.5, если s > 0.5. Если же s ≤ 0.5, то эффек%тивность использования памяти можно сильно увеличить, размещая более одной компоненты массива в каждом слове. Это называется упаковкой (packing). Если в одном слове упакованы n компонент, то эффективность использования памяти равна (см. рис. 1.6)u = n*s / (n*s, округленное вверх до ближайшего целого).Рис. 1.5. Представление записи с выравниваниемРис. 1.6. Упаковка шести компонент в одном словеДоступ к i%й компоненте упакованного массива подразумевает вычисление ад%реса слова j, в котором находится нужная компонента, а также позиции k%ой ком%поненты внутри слова:j = i DIV n k = i MOD nВ большинстве языков программирования программист не имеет возможно%сти управлять представлением структур данных. Однако должна быть возмож%ность указывать желательность упаковки хотя бы в тех случаях, когда в одно сло%во помещается больше одной компоненты, то есть когда может быть достигнутПредставление массивов, записей и множеств Фундаментальные структуры данных34выигрыш в экономии памяти в два раза и более. Можно предложить указывать желательность упаковки с помощью символа PACKED, который ставится перед символом ARRAY (или RECORD) в соответствующем объявлении.1.6.2. Представление записейЗаписи отображаются на память компьютера простым расположением подряд их компонент. Адрес компоненты (поля) ri относительно адреса начала записи r на%зывается смещением (offset) поля ki. Оно вычисляется следующим образом:k i = s1 + s2 + ... + s i–1k0 = 0где s j – размер (в словах) j%й компоненты. Здесь видно, что равенство типов всех компонент массива имеет приятным следствием, что ki = i × s. К несчастью, общ%ность записевой структуры не позволяет использовать простую линейную функ%цию для вычисления смещения компоненты; именно поэтому требуют, чтобы компоненты записи выбирались с помощью фиксированных идентификаторов.У этого ограничения есть то преимущество, что смещения полей определяются во время компиляции. В результате доступ к полям записи более эффективен.Упаковка может привести к выигрышу, если несколько компонент записи мо%гут поместиться в одном слове памяти (см. рис. 1.7). Поскольку смещения могут быть вычислены компилятором, смещение поля, упакованного внутри слова, то%же может быть определено компилятором. Это означает, что на многих вычисли%тельных установках упаковка записей в гораздо меньшей степени снижает эффек%тивность доступа к полям, чем упаковка массивов.Рис. 1.7. Представление упакованной записи1.6.3. Представление множествМножество s удобно представлять в памяти компьютера его характеристической функцией C(s). Это массив логических значений, чья i%я компонента означает, что i присутствует в s. Например, множество небольших чисел s = {2, 3, 5, 7, 11, 13}представляется последовательностью или цепочкой битов: 35C(s) = (… 0010100010101100)Представление множеств характеристическими функциями имеет то преиму%щество, что операции вычисления, объединения, пересечения и разности двух множеств могут быть реализованы как элементарные логические операции. Сле%дующие тождества, выполняющиеся для всех элементов i множеств x и y, связыва%ют логические операции с операциями на множествах:i IN (x+y) = (i IN x) OR (i IN y)i IN (x*y) = (i IN x) & (i IN y)i IN (x–y) = (i IN x) & (i IN y)Такие логические операции имеются во всех цифровых компьютерах, и, более того, они выполняются одновременно для всех элементов (битов) слова. Поэтому для эффективной реализации базовых операций множества их следует пред%ставлять небольшим фиксированным количеством слов, для которых можно вы%полнить не только базовые логические операции, но и операции сдвига. Тогда проверка на принадлежность реализуется единственным сдвигом c последующей проверкой знака. В результате проверка вида x IN {c1, c2, ... , c n} может быть реализована гораздо эффективнее, чем эквивалентное булевское выражение(x = c1) OR (x = c2) OR ... OR (x = c n)Как следствие множества должны использоваться только c небольшими чис%лами в качестве элементов, из которых наибольшее равно длине слова компьюте%ра (минус 1).1.7. Файлы или последовательностиЕще один элементарный способ структурирования – последовательность. Обыч%но это однородная структура, подобная массиву. Это означает, что все ее элемен%ты имеют одинаковый тип – базовый тип последовательности. Будем обозначать последовательность s из n элементов следующим образом:s = 0, s1, s2, ... , s n–1>Число n называется длиной последовательности.Эта структура выглядит в точности как массив. Но существенная разница в том, что у массива число элементов зафиксировано в его определении, а у после%довательности – нет. То есть оно может меняться во время выполнения програм%мы. Хотя каждая последовательность в любой момент времени имеет конкретную конечную длину, мы должны считать мощность последовательностного типа бес%конечной, так как нет никаких ограничений на потенциальную длину последова%тельностей.Прямое следствие переменной длины последовательностей – невозможность отвести фиксированный объем памяти под переменные%последовательности. По%этому память нужно выделять во время выполнения программы, в частности ког%да последовательность растет. Соответственно, когда последовательность сокра%Файлы или последовательности Фундаментальные структуры данных36щается, освобождающуюся память можно утилизовать. В любом случае нужна некая динамическая схема выделения памяти. Подобными свойствами обладают все структуры переменного размера, и это обстоятельство столь важно, что мы характеризуем их как «сложные» (advanced) структуры, в отличие от фундамен%тальных, обсуждавшихся до сих пор.Тогда почему мы обсуждаем последовательности в главе, посвященной фунда%ментальным структурам? Главная причина – в том, что стратегия управления памятью для последовательностей оказывается достаточно простой (в отличие от других «сложных» структур), если потребовать определенной дисциплины исполь%зования последовательностей. И тогда можно обеспечить довольно эффективный механизм управления памятью. Вторая причина – в том, что едва ли не в каждой задаче, решаемой с помощью компьютеров, используются последовательности.Например, последовательности обычно используют в тех случаях, когда данные пересылаются с одного устройства хранения на другое, например с диска или лен%ты в оперативную память или обратно.Упомянутая дисциплина состоит в том, чтобы ограничиться только последова%тельным доступом. Это подразумевает, что доступ к элементам последовательности осуществляется строго в порядке их следования, а порождается последователь%ность присоединением новых элементов к ее концу. Немедленное следствие –невозможность прямого доступа к элементам, за исключением того элемента, ко%торый доступен для просмотра в данный момент. Именно такая дисциплина дос%тупа составляет главное отличие последовательностей от массивов. Как мы уви%дим в главе 2, дисциплина доступа оказывает глубокое влияние на программы.Преимущество последовательного доступа, который все%таки является серьез%ным ограничением, – в относительной простоте необходимого здесь способа управ%ления памятью. Но еще важнее возможность использовать эффективные методыбуферизации (buffering) при пересылке данных между оперативной памятью и внешними устройствами. Последовательный доступ позволяет «прокачивать»потоки данных с помощью «каналов» (pipes) между разными устройствами хране%ния. Буферизация подразумевает накопление данных из потока в буфере и последу%ющую пересылку целиком содержимого всего буфера, как только он заполнится.Это приводит к весьма существенному повышению эффективности использова%ния внешней памяти. Если ограничиться только последовательным доступом, то механизм буферизации довольно прост для любых последовательностей и любых внешних устройств. Поэтому его можно заранее предусмотреть в вычислитель%ной системе и предоставить для общего пользования, освободив программиста от необходимости включать его в свою программу. Обычно здесь речь идет о файловойсистеме, где для постоянного хранения данных используют устройства последова%тельного доступа большого объема, в которых данные сохраняются даже после вык%лючения компьютера. Единицу хранения данных в таких устройствах обычно на%зывают (последовательным) файлом. Мы будем использовать термин файл (file)как синоним для термина последовательность (sequence).Существуют устройства хранения данных, в которых последовательный дос%туп является единственно возможным. Очевидно, сюда относятся все виды лент.Но даже на магнитных дисках каждая дорожка представляет собой хранилище, 37допускающее только последовательный доступ. Строго последовательный доступ характерен для любых устройств с механически движущимися частями, а также для некоторых других.Отсюда следует, что полезно проводить различие между стуктурой данных, то есть последовательностью, с одной стороны, и механизмом доступа к ее элементам – с другой. Первая объявляется как структура данных, а механизм доступа обычно реализуется посредством некоторой записи, с которой ассоциированы не%которые операторы, – или, используя современную терминологию, посредством объекта доступа или «бегунка» (rider object). Различать объявление данных и ме%ханизм доступа полезно еще и потому, что для одной последовательности могут одновременно существовать несколько точек доступа, в которых осуществляется последовательный доступ к разным частям последовательности.Суммируем главное из предшествующего обсуждения следующим образом:1. Массивы и записи – структуры, допускающие произвольный доступ к сво%им элементам. Их используют, размещая в оперативной памяти.2. Последовательности используются для работы с данными на внешних устройствах хранения, допускающих последовательный доступ, таких как диски или ленты.3. Мы проводим различие между последовательностью как структурой дан%ных и механизмом доступа, подразумевающим определенную позицию в ней.1.7.1. Элементарные операции с файламиДисциплину последовательного доступа можно обеспечить, предоставляя набор специальных операций, помимо которых доступ к файлам невозможен. Поэтому хотя в общих рассуждениях можно использовать обозначение si для i%го элемента последовательности s, в программе это невозможно.Последовательности, то есть файлы, – это обычно большие, динамические структуры данных, сохраняемые на внешних запоминающих устройствах. Такое устройство сохраняет данные, даже если программа заканчивается или отключа%ется компьютер. Поэтому введение файловой переменной – сложная операция,подразумевающая подсоединение данных на внешнем устройстве к файловой пе%ременной в программе. Поэтому объявим тип File в отдельном модуле, опреде%ление которого описывает тип вместе с соответствующими операциями. Назовем этот модуль Files и условимся, что последовательностная или файловая перемен%ная должна быть явно инициализирована («открыта») посредством вызова соответствующей операции или функции:VAR f: File f := Open(name)где name идентифицирует файл на внешнем устройстве хранения данных. В не%которых системах различается открытие существующего и нового файлов:f := Old(name)f := New(name)Файлы или последовательности Фундаментальные структуры данных38Нужно еще потребовать, чтобы связь между внешней памятью и файловой пе%ременной разрывалась, например посредством вызова Close(f)Очевидно, набор операций должен содержать операцию для порождения (за%писи) последовательности и еще одну – для ее просмотра (чтения). Потребуем,чтобы эти операции применялись не напрямую к файлу, а к некоторому объекту%бегунку, который сам подсоединен к файлу (последовательности) и который реа%лизует некоторый механизм доступа. Дисциплина последовательного доступа обеспечивается ограничением набора операций доступа (процедур).Последовательность создается присоединением элементов к ее концу после подсоединения бегунка к файлу. Если есть объявлениеVAR r: Rider то бегунок r подсоединяется к файлу f операторомSet(r, f, pos)где pos = 0 обозначает начало файла (последовательности). Вот типичная схема порождения последовательности:WHILE DO x; Write(r, x) ENDЧтобы прочитать последовательность, сначала к ней подсоединяется бегунок,как показано выше, и затем производится чтение одного элемента за другим. Вот типичная схема чтения последовательности:Read(r, x);WHILE r.eof DO x; Read(r, x) ENDОчевидно, с каждым бегунком всегда связана некоторая позиция. Обозначим ее как r.pos. Далее примем, что бегунок содержит предикат (флажок) r.eof,указывающий, был ли достигнут конец последовательности в предыдущей опера%ции чтения(eof – сокращение от англ. «end of file», то есть «конец файла» – прим.перев.). Теперь мы можем постулировать и неформально описать следующий на%бор примитивных операций:1a.New(f, name)определяет f как пустую последовательность.1b.Old(f, name)определяет f как последовательность, хранящуюся на внеш%нем носителе с указанным именем.2.Set(r, f, pos)связывает бегунок r с последовательностью f и устанавлива%ет его в позицию pos3.Write(r, x)записывает элемент со значением x в последовательность,с которой связан бегунок r, и продвигает его вперед.4.Read(r, x)присваивает переменной x значение элемента, на который указывает бегунок r, и продвигает его вперед.5.Close(f)регистрирует записанный файл f на внешнем носителе (с не%медленной записью содержимого буферов на диск).Замечание. Запись элемента в последовательность – это часто достаточно сложная операция. С другой стороны, файлы обычно создаются присоединением новых элементов в конце. 39Замечание переводчика. В примерах программ в книге используются еще две операции:6.WriteInt(r, n)записывает целое число n в последовательность, с которой связан бегунок r, и продвигает его вперед.7. ReadInt(r, n)присваивает переменной n целое число, на которое указывает бегунок r, и продвигает его вперед.Чтобы дать более точное представление об операциях последовательного дос%тупа, ниже приводится пример реализации. В нем показано, как можно выразить операции, если последовательности представлены массивами. Этот пример наме%ренно использует только понятия, введенные и обсужденные ранее, и в нем нет ни буферизации, ни последовательных устройств хранения данных, которые, как указывалось выше, делают понятие последовательности по%настоящему нужным и полезным. Тем не менее этот пример показывает все существенные свойства простейших операций последовательного доступа независимо от того, как после%довательности представляются в памяти.Операции реализуются как обычные процедуры. Такой набор объявлений ти%пов, переменных и заголовков процедур (сигнатур) называется определением(definition). Будем предполагать, что элементами последовательностей являются литеры, то есть что речь идет о текстовых файлах, чьи элементы имеют тип CHARОбъявления типов File и Rider являются хорошими примерами применения запи%сей, так как в дополнение к полю, обозначающему массив, представляющий дан%ные, нужны и другие поля для обозначения текущей длины файла и позиции, то есть состояния бегунка:DEFINITION Files;(* ADruS171_Files *)TYPE File; (* *)Rider = RECORD eof: BOOLEAN END;PROCEDURE New(VAR name: ARRAY OF CHAR): File;PROCEDURE Old(VAR name: ARRAY OF CHAR): File;PROCEDURE Close(VAR f: File);PROCEDURE Set(VAR r: Rider; VAR f: File; pos: INTEGER);PROCEDURE Write(VAR r: Rider; ch: CHAR);PROCEDURE Read(VAR r: Rider; VAR ch: CHAR);PROCEDURE WriteInt(VAR r: Rider; n: INTEGER);PROCEDURE ReadInt(VAR r: Rider; VAR n: INTEGER);END Files.Определение представляет собой некоторую абстракцию. Здесь нам даны два типа данных, File и Rider, вместе с соответствующими операциями, но без каких%либо дальнейших деталей, раскрывающих их реальное представление в памяти.Что касается операций, объявленных как процедуры, то мы видим только их заго%ловки. Детали реализации не показаны здесь намеренно, и называется это упрятыванием информации (information hiding). О бегунках мы узнаем только то, чтоФайлы или последовательности Фундаментальные структуры данных40у них есть свойство с именем eof. Этот флажок получает значение TRUE, когда опе%рация чтения достигает конца файла. Позиция бегунка скрыта, и поэтому его ин%вариант не может быть разрушен прямым обращением к его полям. Инвариант выражает тот факт, что позиция бегунка всегда находится в пределах, соответ%ствующих данной последовательности. Истинность инварианта первоначально устанавливается процедурой Set, а в дальнейшем требуется и поддерживается процедурами Read и Write (а также ReadInt и WriteInt – прим. перев.).Операторы, реализующие описанные процедуры, а также все детали реализа%ции типов данных содержатся в так называемом модуле (module). Представить данные и реализовать процедуры можно многими способами. В качестве про%стого примера приведем следующий модуль (с фиксированной максимальной длиной файла):MODULE Files;(* ADruS171_Files *)CONST MaxLength = 4096;TYPEFileFileFileFileFile = POINTER TO RECORDlen: INTEGER;a: ARRAY MaxLength OF CHAREND;RiderRiderRiderRiderRider = RECORD (* 0 <= pos <= f.len <= Max Length *)f: File; pos: INTEGER; eof: BOOLEANEND;PROCEDURE New New New New New (name: ARRAY OF CHAR): File;VAR f: File;BEGINNEW(f); f.len := 0; f.eof := FALSE;(* ! *)RETURN fEND New;PROCEDURE Old Old Old Old Old (name: ARRAY OF CHAR): File;VAR f: File;BEGINNEW(f); f.eof := FALSE; (* *)RETURN fEND Old;PROCEDURE Close Close Close Close Close (VAR f: File);BEGINEND Close;PROCEDURE Set Set Set Set Set (VAR r: Rider; f: File; pos: INTEGER);BEGIN (* # f # NIL*)r.f := f; r.eof := FALSE;IF pos >= 0 THENIF pos <= f.len THEN r.pos := pos ELSE r.pos := f.len ENDELSE 41r.pos := 0ENDEND Set;PROCEDURE Write Write Write Write Write (VAR r: Rider; ch: CHAR);BEGINIF (r.pos <= r.s.len) & (r.pos < MaxLength) THENr.f.a[r.pos] := ch; INC(r.pos);IF r.pos > r.f.len THEN INC(r.f.len) ENDELSEr.eof := TRUEENDEND Write;PROCEDURE Read Read Read Read Read (VAR r: Rider; VAR ch: CHAR);BEGINIF r.pos < r.f.len THENch := r.f.a[r.pos]; INC(r.pos)ELSEr.eof := TRUEENDEND Read;PROCEDURE WriteInt WriteInt WriteInt WriteInt WriteInt (VAR r: Rider; n: INTEGER);BEGIN (* !*)END WriteInt;PROCEDURE ReadInt ReadInt ReadInt ReadInt ReadInt (VAR r: Rider; VAR n: INTEGER);BEGIN (* !*)END ReadInt;END Files.В этом примере максимальная длина, которую могут иметь файлы, задается произвольно выбранной константой. Если какая%то программа попытается со%здать более длинную последовательность, то это будет не ошибкой программы,а недостатком данной реализации. С другой стороны, попытка чтения за текущим концом файла будет означать ошибку программы. Здесь флаг r.eof также исполь%зуется операцией записи, чтобы сообщить, что выполнить ее не удалось. Поэтому условие r.eof является предусловием как для Read, так и для Write (предусло%вие – это логическое выражение, которое должно быть истинным для корректно%го выполнения некоторой операции – прим. перев.).1   2   3   4   5   6   7   8   9   ...   22

1.7.2. Буферизация последовательностейКогда данные пересылаются со внешнего устройства хранения или на него, от%дельные биты передаются потоком. Обычно устройство налагает строгие времен%ные ограничения на пересылку данных. Например, если данные записываются на ленту, лента движется с фиксированной скоростью, и нужно, чтобы данные пере%давались ей тоже с фиксированной скоростью. Когда источник данных исчерпан,Файлы или последовательности Фундаментальные структуры данных42движение ленты прекращается, и ее скорость падает быстро, но не мгновенно.Поэтому на ленте остается промежуток между уже записанными данными и дан%ными, которые поступят позже. Чтобы добиться высокой плотности данных, нуж%но, чтобы число промежутков было мало, и для этого данные передают относи%тельно большими блоками, чтобы не прерывать движения ленты. Похожие требования имеют место при работе с магнитными дисками, где данные размеща%ются на дорожках с фиксированным числом блоков фиксированного размера. На самом деле диск следует рассматривать как массив блоков, причем каждый блок читается или записывается целиком и обычно содержит 2k байтов с k = 8, 9, … 12Однако в наших программах не соблюдается никаких временных ограничений.Чтобы обеспечить такую возможность, передаваемые данные буферизуются. Они накапливаются в переменной%буфере (в оперативной памяти) и пересылаются, ког%да накапливается достаточно данных, чтобы собрать блок нужного размера. Клиент буфера имеет к нему доступ только посредством двух процедур deposit и fetch:DEFINITION Buffer;PROCEDURE deposit (x: CHAR);PROCEDURE fetch (VAR x: CHAR);END Buffer.Буферизация обладает тем дополнительным преимуществом, что она позволя%ет процессу, который порождает/получает данные, выполняться одновременно с устройством, которое пишет/читает данные в/из буфера. На самом деле удобно рассматривать само устройство как процесс, который просто копирует потоки данных. Назначение буфера – в какой%то степени ослабить связь между двумя процессами, которые будем называть производителем (producer) и потребителем(consumer). Например, если потребитель в какой%то момент замедляет работу, он может нагнать производителя позднее. Без такой развязки часто нельзя обеспе%чить полноценное использование внешних устройств, но она работает, только если скорость работы производителя и потребителя примерно равны в среднем,хотя иногда и флуктуируют. Степень развязки растет с ростом размера буфера.Обратимся теперь к вопросу представле%ния буфера и для простоты предположим по%ка, что элементы данных записываются в него(deposited) и считываются из него (fetched)индивидуально, а не поблочно. В сущности,буфер представляет собой очередь, организо%ванную по принципу «первым пришел – пер%вым ушел» (first%in%first%out, или fifo). Если он объявлен как массив, то две индексные пере%менные (скажем, in и out) отмечают те пози%ции, куда должны писаться и откуда должны считываться данные. В идеале такой массив должен быть бесконечным. Однако вполне до%Рис. 1.8. Кольцевой буфер с индексами in и out 43статочно иметь конечный массив, учитывая, что прочитанные элементы больше не нужны. Занимаемое ими место может быть использовано повторно. Это приво%дит к идее кольцевого буфера.Операции записи и считывания элемента реализуются в следующем модуле,который экспортирует эти операции как процедуры, но скрывает буфер и его ин%дексные переменные – и тем самым механизм буферизации – от процесса%потреби%теля. В таком механизме еще нужна переменная n для подсчета количества элемен%тов в буфере в данный момент. Если N обозначает размер буфера, то очевидным инвариантом является условие 0≤n≤N. Поэтому операция считывания (проце%дура fetch) должна охраняться условием n>0 (буфер не пуст), а операция записи(процедура deposit) – условием n<N (буфер не полон). Невыполнение первого условия должно считаться ошибкой программирования, а нарушение второго –недостатком предложенной реализации (буфер слишком мал).MODULE Buffer; (* ! *)CONST N = 1024; (* ! *)VAR n, in, out: INTEGER;buf: ARRAY N OF CHAR;PROCEDURE deposit deposit deposit deposit deposit (x: CHAR);BEGINIF n = N THEN HALT END;INC(n); buf[in] := x; in := (in + 1) MOD NEND deposit;PROCEDURE fetch fetch fetch fetch fetch (VAR x: CHAR);BEGINIF n = 0 THEN HALT END;DEC(n); x := buf[out]; out := (out + 1) MOD NEND fetch;BEGIN n := 0; in := 0; out := 0END Buffer.Столь простая реализация буфера приемлема, только если процедуры deposit и fetch вызываются единственным агентом (действующим то как производитель, то как потребитель). Но если они вызываются независимыми процессами, работаю%щими одновременно, то такая схема оказывается слишком примитивной. Ведь тог%да попытку записи в полный буфер или попытку чтения из пустого буфера следует рассматривать как вполне законные. Просто выполнение таких действий должно быть отложено до того момента, когда снова будут выполнены соответствующиеохраны (guarding conditions). В сущности, такие задержки и представляют собой необходимый механизм синхронизации между параллельными (concurrent) про%цессами. Можно представить эти задержки следующими операторами:REPEAT UNTIL n < NREPEAT UNTIL n > 0которые нужно подставить вместо соответствующих двух условных операторов,содержащих оператор HALTФайлы или последовательности Фундаментальные структуры данных441.7.3. Буферизация обмена междупараллельными процессамиОднако представленное решение нельзя рекомендовать, даже если известно, что два процесса исполняются двумя независимыми агентами. Причина в том, что два процесса должны обращаться к одной и той же переменной n и, следовательно,к одной области оперативной памяти. Ожидающий процесс, постоянно проверяя значение n, мешает своему партнеру, так как в любой момент времени к памяти может обратиться только один процесс. Такого рода ожиданий следует избегать, и поэтому мы постулируем наличие средства, которое, в сущности, скрывает в себе механизм синхронизации. Будем называть это средство сигналом (signal) и при%мем, что оно предоставляется в служебном модуле Signals вместе с набором при%митивных операций для сигналов.Каждый сигнал s связан с охраной (условием) Ps. Если процесс нужно приостановить, пока не будет обеспечена истинность Ps (другим процессом), то он должен, прежде чем продолжить свою работу, дождаться сигнала s. Это выража%ется оператором Wait(s). С другой стороны, если процесс обеспечивает истинностьPs, то после этого он сигнализирует об этом оператором Send(s). Если для каждого оператора Send(s) обеспечивается истинность предусловия Ps, то Ps можно рас%сматривать как постусловие для Wait(s)DEFINITION Signals;TYPE Signal;PROCEDURE Wait (VAR s: Signal);PROCEDURE Send (VAR s: Signal);PROCEDURE Init (VAR s: Signal);END Signals.Теперь мы можем реализовать буфер в виде следующего модуля, который дол%жен правильно работать, когда он используется независимыми параллельными процессами:MODULE Buffer;IMPORT Signals;CONST N = 1024; (* ! *)VAR n, in, out: INTEGER;nonfull: Signals.Signal; (*n < N*)nonempty: Signals.Signal; (*n > 0*)buf: ARRAY N OF CHAR;PROCEDURE deposit deposit deposit deposit deposit (x: CHAR);BEGINIF n = N THEN Signals.Wait(nonfull) END;INC(n); buf[in] := x; in := (in + 1) MOD N;IF n = 1 THEN Signals.Send(nonempty) ENDEND deposit; 45PROCEDURE fetch fetch fetch fetch fetch (VAR x: CHAR);BEGINIF n = 0 THEN Signals.Wait(nonempty) END;DEC(n); x := buf[out]; out := (out + 1) MOD N;IF n = N–1 THEN Signals.Send(nonfull) ENDEND fetch;BEGIN n := 0; in := 0; out := 0; Signals.Init(nonfull); Signals.Init(nonempty)END Buffer.Однако нужно сделать еще одну оговорку. Данная схема разрушается, если по случайному совпадению как производитель, так и потребитель (или два произво%дителя либо два потребителя) одновременно обращаются к переменной n, чтобы изменить ее значение. Непредсказуемым образом получится либо значение n+1,либо n–1, но не n. Так что нужно защищать процессы от опасных взаимных помех.Вообще говоря, все операции, которые изменяют значения общих (shared) пере%менных, представляют собой потенциальные ловушки.Достаточным (но не всегда необходимым) условием является требование, что%бы все общие переменные объявлялись локальными в таком модуле, для проце%дур которого гарантируется, что они взаимно исключают исполнение друг друга.Такой модуль называют монитором (monitor) [1.7]. Условие взаимного исключе%ния (mutual exclusion) гарантирует, что в любой момент времени только один про%цесс сможет активно выполнять какую%либо процедуру монитора. Если другой процесс попытается вызвать некую процедуру того же монитора, его выполнение будет автоматически задержано до того момента, когда первый процесс завершит выполнение своей процедуры.Замечание. Слова «активно выполнять» означают, что процесс выполняет лю%бой оператор, кроме оператора ожидания.Наконец, вернемся к задаче, в которой производитель или потребитель (или оба) требует, чтобы данные к ним поступали блоками определенного размера.Показанный ниже модуль является вариантом предыдущего, причем предполага%ется, что размер блоков данных равен Np элементов для производителя и Nc эле%ментов для потребителя. В этом случае обычно выбирают размер буфера N так,чтобы он делился на Np и Nc. Чтобы подчеркнуть симметрию между операциями записи и считывания данных, вместо единственного счетчика n теперь исполь%зуются два счетчика, ne и nf. Они показывают соответственно число пустых и за%полненных ячеек буфера. Когда потребитель находится в состоянии ожидания, nf показывает число элементов, нужных для продолжения работы потребителя; а когда производитель находится в состоянии ожидания, то ne показывает число элементов, необходимых для продолжения работы производителя. (Поэтому ус%ловие ne + nf = N выполняется не всегда.)MODULE Buffer;IMPORT Signals;CONST Np = 16; (* *)Nc = 128; (* *)Файлы или последовательности Фундаментальные структуры данных46N = 1024; (* ! , Np Nc*)VAR ne, nf: INTEGER;in, out: INTEGER;nonfull: Signals.Signal; (*ne >= 0*)nonempty: Signals.Signal; (*nf >= 0*)buf: ARRAY N OF CHAR;PROCEDURE deposit deposit deposit deposit deposit (VAR x: ARRAY OF CHAR);BEGINne := ne – Np;IF ne < 0 THEN Signals.Wait(nonfull) END;FOR i := 0 TO Np–1 DO buf[in] := x[i]; INC(in) END;IF in = N THEN in := 0 END;nf := nf + Np;IF nf >= 0 THEN Signals.Send(nonempty) ENDEND deposit;PROCEDURE fetch fetch fetch fetch fetch (VAR x: ARRAY OF CHAR);BEGINnf := nf – Nc;IF nf < 0 THEN Signals.Wait(nonempty) END;FOR i := 0 TO Nc–1 DO x[i] := buf[out]; INC(out) END;IF out = N THEN out := 0 END;ne := ne + Nc;IF ne >= 0 THEN Signals.Send(nonfull) ENDEND fetch;BEGINne := N; nf := 0; in := 0; out := 0;Signals.Init(nonfull); Signals.Init(nonempty)END Buffer.1.7.4. Ввод и вывод текстаПод стандартным вводом и выводом мы понимаем передачу данных в ту или иную сторону между вычислительной системой и внешними агентами, например чело%веком%оператором. Достаточно типично, что ввод производится с клавиатуры,а вывод – на экран дисплея. Для таких ситуаций характерно, что информация представляется в форме, понятной человеку, и обычно состоит из последователь%ности литер. То есть речь идет о тексте. Отсюда еще одно усложнение, характерное для реальных операций ввода и вывода. Кроме передачи данных, в них выполняет%ся еще и преобразование представления. Например, числа, обычно рассматривае%мые как неделимые сущности и представленные в двоичном виде, должны быть преобразованы в удобную для чтения десятичную форму. Структуры должны представляться так, чтобы их элементы располагались определенным образом, то есть форматироваться.Независимо от того, что это за преобразование, задача заметно упрощается,если снова привлечь понятие последовательности. Решающим является наблюде% 47ние, что если набор данных можно рассматривать как последовательность литер,то преобразование последовательности может быть реализовано как последова%тельность (одинаковых) преобразований элементов:T(0, s1, ... , s n–1>) = 0), T(s1), ... , T(s n–1)>Исследуем вкратце действия, необходимые для преобразования представле%ний натуральных чисел для ввода и вывода. Математическим основанием послу%жит тот факт, что число x, представленное последовательностью десятичных цифр d = , ... , d1, d0>, имеет значение x = SSSSSi: i = 0 .. n–1: d i * 10i x = d n–1× 10n–1 + d n–2× 10n–2 + … + d1× 10 + d0x = (… (d n–1× 10 + d n–2) × 10 + … + d1) × 10 + d0Пусть теперь нужно прочесть и преобразовать последовательность d,а получившееся числовое значение присвоить переменной x. Следующий простой алгоритм останавливается при считывании первой литеры, не являющейся циф%рой (арифметическое переполнение не рассматривается):x := 0; Read(ch);(* ADruS174.% '- *)WHILE ("0" <= ch) & (ch <= "9") DOx := 10*x + (ORD(ch) – ORD("0")); Read(ch)ENDВ случае вывода преобразование усложняется тем, что разложение значения xв набор десятичных цифр дает их в обратном порядке. Младшая значащая цифра порождается первой при вычислении x MOD 10. Поэтому требуется промежуточ%ный буфер в виде очереди типа «первым пришел – последним вышел» (то есть стека). Будем представлять ее массивом d с индексом i и получим следующую программу:i := 0;(* ADruS174.-'% *)REPEAT d[i] := x MOD 10; x := x DIV 10; INC(i)UNTIL x = 0;REPEAT DEC(i); Write(CHR(d[i] + ORD("0")))UNTIL i = 0Замечание. Систематическая замена константы 10 в этих алгоритмах на поло%жительное целое B даст процедуры преобразования для представления по основа%нию B. Часто используется случай B = 16 (шестнадцатеричное представление),тогда соответствующие умножения и деления можно реализовать простыми сдвигами двоичных цифр.Очевидно, было бы неразумным детально описывать в каждой программе та%кие часто встречающиеся операции. Поэтому постулируем наличие вспомога%тельного модуля, который обеспечивает чаще всего встречающиеся, стандартные операции ввода и вывода для чисел и цепочек литер. Этот модуль используется в большинстве программ в этой книге, и мы назовем его Texts. В нем определенФайлы или последовательности Фундаментальные структуры данных48тип Text, а также типы объектов%бегунков для чтения (Reader) и записи (Writer)в переменные типа Text, а также процедуры для чтения и записи литеры, целого числа и цепочки литер.Прежде чем дать определение модуля Texts, подчеркнем существенную асим%метрию между вводом и выводом текстов. Хотя текст порождается последова%тельностью вызовов процедур вывода целых и вещественных чисел, цепочек ли%тер и т. д., ввод текста посредством вызова процедур чтения представляется сомнительной практикой. Дело здесь в том, что хотелось бы читать следующий элемент, не зная его типа, и определять его тип после чтения. Это приводит к поня%тию сканера (scanner), который после каждой попытки чтения позволяет прове%рить тип и значение прочитанного элемента. Сканер играет роль бегунка для фай%лов. Однако тогда нужно наложить ограничения на синтаксическую структуру считываемых текстов. Мы определим сканер для текстов, состоящих из последо%вательности целых и вещественных чисел, цепочек литер, имен, а также специаль%ных литер. Синтаксис этих элементов задается следующими правилами так назы%ваемой расширенной нотации Бэкуса–Наура (EBNF, Extended Backus Naur Form;чтобы точнее отразить вклад авторов нотации в ее создание, аббревиатуру еще раскрывают как Extended Backus Normal Form, то есть «расширенная нормальная нотация Бэкуса» – прим. перев.):item =integer | RealNumber | identifier | string | SpecialChar.integer =[“–”] digit {digit}.RealNumber = [“–”] digit {digit} “.” digit {digit} [(“E” | “D”)[“+” |“–” digit {digit}].identifier =letter {letter | digit}.string =‘”’ {any character except quote} ‘”’.SpecialChar =“!” | “?” | “@” | “#” | “$” | “%” | “^” | “&” | “+” | “–” |“*” | “/” | “\” | “|” | “(” | “)” | “[” | “]” | “{” | “}” |“<” | “>” | “.” | “,” | “:” | “;” | “”.Элементы разделяются пробелами и/или символами конца строк.DEFINITION Texts; (* ADruS174_Texts *)CONST Int = 1; Real = 2; Name = 3; Char = 4;TYPE Text, Writer;Reader = RECORD eot: BOOLEAN END;Scanner = RECORD class: INTEGER;i: INTEGER;x: REAL;s: ARRAY 32 OF CHAR;ch: CHAR;nextCh: CHAREND;PROCEDURE OpenReader (VAR r: Reader; t: Text; pos: INTEGER);PROCEDURE OpenWriter (VAR w: Writer; t: Text; pos: INTEGER);PROCEDURE OpenScanner (VAR s: Scanner; t: Text; pos: INTEGER);PROCEDURE Read (VAR r: Reader; VAR ch: CHAR); 49PROCEDURE ReadInt (VAR r: Reader; VAR n: INTEGER);PROCEDURE Scan (VAR s: Scanner);PROCEDURE Write (VAR w: Writer; ch: CHAR);PROCEDURE WriteLn (VAR w: Writer); (* v *)PROCEDURE WriteString (VAR w: Writer; s: ARRAY OF CHAR);PROCEDURE WriteInt (VAR w: Writer; x, n: INTEGER); (* x n .  n v , € , *)PROCEDURE WriteReal (VAR w: Writer; x: REAL);PROCEDURE Close (VAR w: Writer);END Texts.(Выше добавлена отсутствующая в английском оригинале процедура ReadInt, ис%пользуемая в примерах программ – прим. перев.)Мы требуем, чтобы после вызова процедуры Scan(S) для полей записи S выпол%нялось следующее:S.class = Int означает, что прочитано целое число, его значение содержится в S.i;S.class = Real означает, что прочитано вещественное число, его значение со%держится в S.x;S.class = Name означает, что прочитана цепочка литер, она содержится в S.s;S.class = Char означает, что прочитана специальная литера, она содержится в S.ch;S.nextCh содержит литеру, непосредственно следующую за прочитан%ным элементом, которая может быть пробелом.1   2   3   4   5   6   7   8   9   ...   22

1.8. ПоискЗадача поиска – одна из наиболее часто встречающихся в программировании.Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%данное значение, фиксирован. Будем предполагать, что этот набор N элементов представлен массивом, скажем a: ARRAY N OF ItemОбычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x, которое еще называют аргументом поиска. Найденный индекс i, удовлетворяющий условию a[i].key = x, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип Item состоит только из ключа, то есть сам является ключом.Поиск Фундаментальные структуры данных501.8.1. Линейный поискЕсли нет никакой дополнительной информации о данных, то очевидное реше%ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%стно как линейный поиск. Поиск прекращается при одном из двух условий:1. Элемент найден, то есть ai = x2. Просмотрен весь массив, но элемент не найден.Приходим к следующему алгоритму:i := 0;(* ADruS18_ *)WHILE (i < N) & (a[i] # x) DO INC(i) ENDОтметим, что порядок операндов в булевском выражении важен.До и после каждого шага цикла выполняется следующее условие:(0 ≤ i < N) & (AAAAAk: 0 ≤ k < i : a k≠ x)Такое условие называется инвариантом. В данном случае инвариант означает,что для всех значений k, меньших, чем i, среди ak искомого значения x нет. Заме%тим, что до и после каждого шага цикла значения i – разные. Сохранение инвари%анта при изменении i имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами WHILE и DO).Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%ного фрагмента):((i = N) OR (a i = x )) & (AAAAAk: 0 ≤ k < i : a k≠ x)Это условие не только является искомым результатом, но еще и подразумева%ет, что если найден элемент, равный x, то это первый такой элемент. При этом i = Nозначает, что искомого значения не найдено.Окончание цикла гарантировано, так как величина i на каждом шаге увели%чивается и поэтому обязательно достигнет границы N после конечного числа ша%гов; на самом деле после N шагов, если искомого значения в массиве нет.На каждом шаге нужно вычислять булевское выражение и увеличивать ин%декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%печить, помещая дополнительный элемент со значением x в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%ствует выходу поиска за границу массива. Теперь массив a объявляется так:a: ARRAY N+1 OF INTEGER, 51и алгоритм линейного поиска с барьером выражается следующим образом:a[N] := x; i := 0;(* ADruS18_ *)WHILE a[i] # x DO INC(i) ENDВ итоге получается условие, выведенное из того же инварианта, что и ранее:(a i = x) & (Ak: 0 ≤ k < i : a k≠ x)Очевидно, из i = N следует, что искомое значение не встретилось (не считая значения%барьера).1.8.2. Поиск делением пополамПонятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%пользует информацию о том, что массив a упорядочен, то есть что выполняется условиеAAAAAk: 1 ≤ k < N : a k–1≤ a kКлючевая идея – в том, чтобы выбрать наугад элемент, скажем am, и сравнить его с искомым значением x. Если он равен x, то поиск прекращается; если он мень%ше x, то можно заключить, что все элементы с индексами, равными или меньшими m, можно игнорировать в дальнейшем поиске; а если он больше x, то можно игно%рировать все значения индекса, большие или равные m. Это приводит к следую%щему алгоритму, который носит название поиск делением пополам (binary search);в нем используются две индексные переменные L и R, отмечающие в массиве aлевый и правый концы отрезка, в котором искомое значение все еще может быть найдено:L := 0; R := N–1;(* ADruS18_ *)m := € L R;WHILE (L <= R) & (a[m] # x) DOIF a[m] < x THENL := m+1ELSER := m–1END;m := € L RENDПодчеркнем фундаментальное структурное подобие этого алгоритма и алго%ритма линейного поиска в предыдущем разделе: роль i теперь играет тройка L, m,R. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной mПоиск Фундаментальные структуры данных52Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:(AAAAAk: 0 ≤ k < L : a k < x) & (AAAAAk: R < k < N : a k > x)откуда выводим для цикла такое постусловие:((L > R) OR (a m = x)) & (AAAAAk: 0 ≤ k < L : a k < x ) & (AAAAAk: R < k < N : a k > x)Из него следует, что((L > R) & (AAAAAk: 0 ≤ k < N : a k≠ x)) OR (a m = x)Выбор m, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log2N, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%кальное улучшение по сравнению с линейным поиском, где среднее число срав%нений равно N/2Как уже упоминалось, можно заняться устранением дублирования инструк%ции присваивания m. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%нений. Напомним, что число шагов не превышает log NБолее быстрое решение основано на следующем инварианте:(AAAAAk: 0 ≤ k < L : a k < x) & (AAAAAk: R ≤ k < N : a k≥ x)а поиск продолжается, пока два отрезка не будут покрывать весь массив:L := 0; R := N;(* ADruS18_ *)WHILE L < R DOm := (L+R) DIV 2;IF a[m] < x THEN L := m+1 ELSE R := m ENDENDМожно сказать, что теперь ищется не элемент a[m] = x, а граница, отделяющая все элементы, меньшие x, от всех прочих.Условие окончания – L ≥ R. Есть ли гарантия его достижения? Чтобы убедить%ся в этом, нужно показать, что в любых обстоятельствах разность R–L умень%шается на каждом шаге. Условие L < R справедливо в начале каждого шага. Тогда 53среднее арифметическое удовлетворяет условию L≤ m < R. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1 пере%менной L (что увеличивает L), либо значению m переменной R (что уменьшает R),и цикл прекращается при L = RОднако выполнение инварианта вместе с условием L = R еще не гарантирует успеха поиска. Разумеется, если R = N, то искомого значения в массиве нет. В про%тивном случае нужно учесть, что элемент a[R] еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x. В отличие от первого решения, дан%ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.1.8.3. Поиск в таблицеПоиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%дний случай встречается часто; массивы литер называют цепочками литер(string), или словами. Определим тип String так:String = ARRAY M OF CHARи пусть отношение порядка для цепочек определено так, что для цепочек x и y:(x = y) ≡ (AAAAAj: 0 ≤ j < M : x j = y j)(x < y) ≡ EEEEEi: 0 ≤ i < N : ((AAAAAj: 0 ≤ j < i : x j = y j) & (x i < y i))Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,достаточно искать пару неравных литер. Отсутствие такой пары означает равен%ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%ше 30, и будем использовать линейный поиск.В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения M. Такая схема оставляет достаточно гибкос%ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.Обычно для этой цели используют литеру 0X,не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%ка литер s имеет вид s = s0, s1, s2, ... , sN–1где s1 ... sN–1 суть фактические литеры цепочки, а s0 = CHR(N). Преимуще%ство этого способа – в том, что длина доступна непосредственно, а недоста%Поиск Фундаментальные структуры данных54ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 ENDЗдесь концевая литера играет роль барьера, а инвариант цикла имеет видAAAAAj: 0 ≤ j < i : x j = y j≠ 0X,и в результате выполняется следующее условие:((x i = 0X) OR (x i≠ y i)) & (Aj: 0 ≤ j < i : x j = y j≠ 0X).Отсюда следует совпадение цепочек x и y, если xi = yi; при этом x < y, если xi < y iТеперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.Например, пусть таблица T и аргумент поиска x определены так:T: ARRAY N OF String;x: StringПредполагая, что N может быть большим и что таблица упорядочена по алфа%виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:i := –1;(* ADruS183 *)L := 0; R := N;WHILE L < R DOm := (L+R) DIV 2;i := 0;WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;IF T[m,i] < x[i] THEN L := m+1 ELSE R := m ENDEND;IF R < N THENi := 0;WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 ENDEND(* (R < N) & (T[R,i] = x[i]) *)1.9. Поиск образца в тексте(string search)Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s из N элементов и массив p из M элемен%тов, где 0 < M < N: 55s: ARRAY N OF Item p: ARRAY M OF ItemТребуется найти первое вхождение p в s. Обычно элементы этих массивов(Item) являются литерами; тогда s можно считать текстом, а p – образцом(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,с помощью которых осуществляются просмотры, определяется данными. Коррект%ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант циклаWHILE. Поскольку эта фундаментальная и мощная управляющая структура пред%ставлена не во всех языках программирования, ее описанию посвящено приложе%ние C.Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%смотре базовой идеи простого алгоритма.1.9.1. Простой поиск образца в текстеПрежде чем думать об эффективности, опишем простейший алгоритм поиска.Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p длины M, сопоставляемый с текстом sдлины N в позиции i. При этом индекс j нумерует элементы образца, и элементу образца p[j] сопоставляется элемент текста s[i+j]Рис. 1.9. Образец длины M, сопоставляемый с текстом s в позиции iПредикат R(i), описывающий полное совпадение образца с литерами текста в позиции i, формулируется так:R(i) = AAAAAk: 0 ≤ j < M : p j = s i+jПоиск образца в тексте (string search) Фундаментальные структуры данных56Допустимые значения i, в которых может реализоваться совпадение, – от 0 доN–M включительно. Вычисление условия R естественным образом сводится к пов%торным сравнениям отдельных пар литер. Если применить теорему де Моргана к R, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:R(i) = (AAAAAj: 0 ≤ j < M : p j = s i+j) = (EEEEEj: 0 ≤ j < M : p j≠ s i+j)Поэтому предикат R(i) легко реализуется в виде процедуры%функции, постро%енной по схеме линейного поиска:PROCEDURE R (i: INTEGER): BOOLEAN;VAR j: INTEGER;BEGIN(* 0 <= i < N *)j := 0;WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;RETURN (j < M)END RПусть искомым результатом будет значение индекса i, указывающее на первоевхождение образца в тексте s. Тогда должно удовлетворяться условие R(i). Но так как требуется найти именно первое вхождение образца, то условие R(k) должно быть ложным для всех k < i. Обозначим это новое условие как Q(i):Q(i) = AAAAAk: 0 ≤ k < i : R(k)Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):i := 0;(* ADruS191_ *)WHILE (i <= N–M) & R(i) DO INC(i) ENDИнвариантом этого цикла является предикат Q(i), который выполняется как до инструкции INC(i), так и – благодаря второму операнду охраны – после нее.Достоинство этого алгоритма – легкость понимания логики благодаря чет%кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%цедуры%функции R. Однако это же свойство может обернуться и недостатком:во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%сматриваемые ниже, используют информацию, получаемую во внутреннем цик%ле, для увеличения i во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%ся от процедуры вычисления R, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i. Од%нако логика взаимодействия двух вложенных циклов через логическую пере%менную теряет исходную прозрачность, что чревато ошибками при эволюции программы. 57В подобных случаях удобно использовать так называемый цикл Дейкстры –цикл WHILE с несколькими ветвями, каждая из которых имеет свою охрану (см.приложение C). В данном случае две ветви будут соответствовать шагам по i и jсоответственно. Вспомним рис. 1.9 и введем предикат P(i, j), означающий совпаде%ние первых j литер образца с литерами текста, начиная с позиции i:P(i, j) = AAAAAk: 0 ≤ k < j : s i+k = p kТогда R(i) = P(i, M)Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%ется значениями пары переменных i и j. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%ле каждого шага при новых i или j) можно выбрать так: в позициях до i совпадений образца нет, а в позиции i имеется частичное совпадение первых j литер образца,что формально записывается следующим образом:Q(i) & P(i, j)Очевидно, j = M означает, что имеет место искомое вхождение образца в текст в позиции i, а i > N – M означает, что вхождений нет вообще.Очевидно, для поиска достаточно пытаться увеличивать j на 1, чтобы расши%рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%вую позицию, увеличив i на 1, и сбросить j в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:i := 0; j := 0;WHILE € v # DOINC( j )ELSIF € DOINC( i ); j := 0END;Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (i ≤ N–M) & (j < M) & (s i+j = p j),гаранти%рующее P(i, j) после увеличения j. Для второй ветки последний операнд конъюн%кции должен содержать неравенство вместо равенства, что влечет R(i) и гаранти%рует Q(i) после увеличения i. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:i := 0; j := 0;(* ADruS191_ *)WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DOINC( j )ELSIF (i <= N–M) & (j < M) DOINC( i ); j := 0END;Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%ран, то есть (i > N–M) OR (j >= M), причем из структуры шагов цикла дополнитель%Поиск образца в тексте (string search) Фундаментальные структуры данных58но следует, что два операнда не могут быть истинны одновременно, а j не может превысить M. Тогда i > N–M означает, что вхождений образца нет, а j = M – что справедливо Q(i) & P(i, M), то есть найдено искомое полное вхождение в позиции iАнализ простого поиска в тексте. Этот алгоритм довольно эффективен,если предполагать, что неравенство литер обнаруживается лишь после небольшо%го числа сравнений литер, то есть при небольших j. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%ет тревогу. Например, рассмотрим текст, состоящий из N–1 букв A, за которыми следует единственная буква B, и пусть образец состоит из M–1 литер A, за которы%ми следует одна B. Тогда нужно порядка N*M сравнений, чтобы обнаружить совпа%дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%ды, которые в этом худшем случае ведут себя гораздо лучше.1.9.2. Алгоритм Кнута, Морриса и ПраттаОколо 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%ко порядка N сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:Hoola–Hoola girls like Hooligans.Hooligan Hooligan Hooligan Hooligan Hooligan Hooligan HooliganЗдесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i; переменная j, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10). 59Центральным пунктом алгоритма является сравнение элементов s[i] и p[j], при равенстве i и j одновременно сдвигаются вправо на единицу, а в случае неравен%ства должен быть сдвинут образец, что выражается присваиванием j некоторого меньшего значения D. Граничный случай j = 0 показывает, что нужно предусмо%треть возможность сдвига образца целиком за текущую позицию сравнения (что%бы элемент p[0] оказался выравнен с s[i+1]). Для такого случая удобно положитьD = –1. Главный цикл алгоритма приобретает следующий вид:i := 0; j := 0;WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DOINC( i ); INC( j )ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)j := DEND;Эта формулировка не совсем полна, так как еще не определено значение сдви%га D. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это Q(i–j) & P(i–j, j)Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%ражение (j >= M) OR (i >= N), но реально могут реализоваться только равенства.Если алгоритм останавливается при j = M, то имеет место совпадение с образцом в позиции i–M (член P(i–j, j) инварианта влечет P(i–M, M) = R(i)). В противном слу%чае остановка происходит с i = N, и так как здесь j < M, то первый член инварианта,Q(i–j), влечет отсутствие совпадений с образцом во всем тексте.Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,инвариант выполнен в начале цикла при значениях i = j = 0. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i и j на 1. В самом деле, Q(i–j) не нарушается, так как разность i–j не меняется.Не нарушается и P(i–j, j) при увеличении j благодаря равенству в охране ветки (ср.определениеP). Что касается второй ветки, то мы просто потребуем, чтобы значе%ние D всегда было таким, чтобы замена j на D сохраняла инвариант.Присваивание j := D при условии D < j означает сдвиг образца вправо на j–Dпозиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать D как можно меньше. Это иллюстрируется на рис. 1.11.Рис. 1.10. В обозначениях алгоритма КМПпозиция в тексте первой литеры образца равна i–j(а не i, как в простом алгоритме)Поиск образца в тексте (string search) Фундаментальные структуры данных60Если мы хотим, чтобы инвариант P(i–j, j) & Q(i–j) выполнялся после присваива%ния j := D, то до присваивания должно выполняться условие P(i–D, D) & Q(i–D)Это предусловие и будет ключом к поиску подходящего выражения для D наряду с условием P(i–j, j) & Q(i–j), которое предполагается уже выполненным перед при%сваиванием (к этой точке программы относятся все дальнейшие рассуждения).Решающее наблюдение состоит в том, что истинность P(i–j, j) означает p0 ... p j–1 = s i–j ... s i–1(ведь мы только что просмотрели первые j литер образца и установили их совпа%дение с соответствующими литерами текста). Поэтому условие P(i–D, D) при D < j,то есть p0 ... pD–1 = s i–D ... s i–1превращается в уравнение для D:p0 ... pD–1 = p j–D ... p j–1Что касается Q(i–D), то это условие следует из Q(i–j), если R(i–k) для k = D+1 ... j. При этом истинность R(i–k) для j = k гарантируется неравенством s[i] # p[j]. Хотя условия R(i–k) ≡ P(i–k, M) для k = D+1 ... j–1 нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия P(i–k,k). Раскрывая их с учетом уже найденных равенств между элементами s и p, получаем следующее условие:p0 ... p k–1≠ p j–k ... p j–1 для всех k = D+1 ... j–1То есть D должно быть максимальным решением вышеприведенного уравнения.Рисунок 1.12 показывает, как работает этот механизм сдвигов.Если решения для D нет, то совпадение с образцом невозможно ни в какой по%зиции ниже i+1. Тогда полагаем D := –1. Такая ситуация показана в верхнем при%мере на рис. 1.13.Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%чуть улучшить: если бы литера pj была равна A вместо F, то мы знали бы, что соот%ветствующая литера в тексте никак не может быть равна A, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с D = –1 (ср. нижнюю часть рис. 1.13). Поэтому при поиске D можно сразу наложить дополнительное ограничение pD≠ p j. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций 61Рис. 1.12. Частичные совпадения образца и вычисление DРис. 1.13. Сдвиг образца за позицию последней литерыПоиск образца в тексте (string search) Фундаментальные структуры данных62Важный итог состоит в том, что значение D определяется только образцом и значением j, но не зависит от текста. Обозначим D для заданного j как dj. Таблицу d можно построить до начала собственно поиска, что можно назвать предкомпи%ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (M << N). Если нужно найти несколько вхож%дений одного и того же образца, то таблицу d можно использовать повторно.Величина dj < j является длиной максимальной последовательности, удовлет%воряющей условию p0 ... p d[j]–1 = p j–d[j] ... p j–1с дополнительным ограничением pd[j]≠ p j. Проход построенного цикла по самому образцу p вместо s последовательно находит максимальные последовательности,что позволяет вычислить dj:PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);(* ADruS192_‚„ *)(* p M s N; M <= Mmax*)(* p , r # s, r = –1*)VAR i, j, k: INTEGER;d: ARRAY Mmax OF INTEGER;BEGIN(* d p*)d[0] := –1;IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;j := 1; k := 0;WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DOk := d[k]ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)INC( j ); INC( k );IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );END;(* *)i := 0; j := 0;WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DOj := d[j];ELSIF (j < M) & (i < N) DOINC(i); INC(j);END;IF j = M THEN r := i–M ELSE r := –1 ENDEND SearchАнализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок M+N, что гораздо лучше, чем M*N в простом поиске. Они также указали на то приятное обстоятельство, что указатель i всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%жет привести к трудностям, если текст считывается из внешней памяти, так как 63в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%ные данные буферизуются, образец может оказаться таким, что потребуется воз%врат за пределы буфера.1.9.3. Алгоритм Бойера и МураХитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%наруживается после частичного совпадения некоторого фрагмента. Только в та%ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%этому выигрыш от использования КМП%стратегии оказывается не слишком су%щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d. Для каждой литеры x из всего множества литер обозначим как dx расстояние от конца образца до ее самого пра%вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%стом и образцом. Тогда образец можно сразу сдвигать вправо на dp[M–1] позиций,и весьма вероятно, что эта величина окажется больше, чем 1. Если pM–1 вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:Hoola–Hoola girls like Hooligans.Hooligan Hooligan Hooligan Hooligan HooliganТак как литеры теперь сравниваются справа налево, то будет удобнее исполь%зовать следующие слегка модифицированные версии предикатов P, R и Q:P(i, j) = AAAAAk: j ≤ k < M : s i–M+k = p kR(i)= P(i, 0)Q(i)= AAAAAk: M ≤ k < i : R(k)В этих терминах инвариант цикла будет иметь прежний вид: Q(i) & P(i, j). Мы еще введем переменную k = i–M+j. Теперь можно дать следующую формулировкуБМ%алгоритма:i := M; j := M; k := i;WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DODEC(k); DEC(j)ELSIF (j > 0) & (i <= N) DOi := i + d[ORD(s[i–1])]; j := M; k := i;ENDПоиск образца в тексте (string search) Фундаментальные структуры данных64Индексы удовлетворяют ограничениям 0 ≤ j ≤ M, M ≤ i, 0 < k ≤ i. Остановка алгоритма с j = 0 влечет P(i, 0) = R(i), то есть совпадение в позиции k = i–M. Для остановки c j > 0 нужно, чтобы i > N; тогда Q(i) влечет Q(N) и, следовательно, от%сутствие совпадений. Разумеется, нам еще нужно убедиться, что Q(i) и P(i, j)действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку Q(M) и P(x, M) всегда истинны.Сначала рассмотрим первую ветку. Одновременное уменьшение k и j никак не влияет на Q(i), и, следовательно, поскольку уже установлено, что sk–1 = p j–1, а P(i, j)выполняется до операции уменьшения j, то P(i, j) выполняется и после нее.Во второй ветке достаточно показать, что присваивание i := i + d s[i–1] не нару%шает инвариант Q(i), так как после остальных присваиваний P(i, j) выполняется автоматически. Q(i) выполняется после изменения i, если до него выполняетсяQ(i+d s[i–1]). Поскольку мы знаем, что выполняется Q(i), достаточно установить, чтоR(i+h) для h = 1 .. d s[i–1]–1. Вспомним, что величина dx определена как расстояние от конца до крайнего правого вхождения x в образце. Формально это выражается в виде:AAAAAk: M–d x≤ k < M–1 : p k≠ xДля x = s i–1 получаемAAAAAk: M–d s[i–1] † k < M–1 : s i–1≠ p k= AAAAAh: 1 ≤ h ≤ d s[i–1]–1 : s i–1≠ pM–1–h⇒ AAAAAh: 1 ≤ h ≤ d s[i–1]–1 : R(i+h)В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);(* ADruS193_‡„ *)(* p M s N*)(* p , r # s, r = –1*)VAR i, j, k: INTEGER;d: ARRAY 128 OF INTEGER;BEGINFOR i := 0 TO 127 DO d[i] := M END;FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;i := M; j := M; k := i;WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DODEC(k); DEC(j)ELSIF (j > 0) & (i <= N) DOi := i + d[ORD(s[i–1])]; j := M; k := i;END;IF j <= 0 THEN r := k ELSE r := –1 ENDEND SearchАнализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем N, сравнений, за исключением специ% 65ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно N/MАвторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%ния деталей, так как дополнительная сложность вычисления таблиц и самого по%иска, по%видимому, не приводит к существенному выигрышу в эффективности.Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.Замечание переводчика. Три алгоритма поиска образца в тексте – простой,КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построениеБМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%явные, инстинктивные предположения – еще до первых попыток составить кон%кретное решение, когда внимание уже будет поглощено не исходной задачей,а красотой и эффективностью предполагаемого решения, ярко демонстрирую%щего остроумие и изобретательность программиста, а также его познания в ме%тодах оптимизации.Упражнения1.1. Обозначим мощности стандартных типов INTEGER, REAL и CHAR как cint, creal иc char. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров: Complex, Date, Person, Row, Card, Name?Упражнения Фундаментальные структуры данных66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)соответствуют следующим операциям:a) чтение и запись для элементов упакованных записей и массивов;b) операции для множеств, включая проверку принадлежности числа мно%жеству?1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%ных в виде последовательностей, а не массивов?1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%вов, записей или последовательностей, которое было бы удобно для опреде%ления времени прибытия и отправления для заданных станции и направле%ния поезда.1.5. Пусть даны текст T, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов A и B. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%мальной длины. Напишите программу, преобразующую текст T в текст S за%меной каждого вхождения слова Ai соответствующим словом Bi1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%лагается, что определены константа N > 0 и следующие переменные:VAR i, j, k, x: INTEGER;a: ARRAY N OF INTEGER;Программа A:i := 0; j := N–1;REPEATk := (i+j) DIV 2;IF a[k] < x THEN i := k ELSE j := k ENDUNTIL (a[k] = x) OR (i > j)Программа B:i := 0; j := N–1;REPEATk := (i+j) DIV 2;IF x < a[k] THEN j := k–1 END;IF a[k] < x THEN i := k+1 ENDUNTIL i > jПрограмма C:i := 0; j := N–1;REPEATk := (i+j) DIV 2;IF x < a[k] THEN j := k ELSE i := k+1 ENDUNTIL i > j 67Подсказка. Все программы должны заканчиваться с ak = x, если такой эле%мент присутствует, или ak≠ x, если элемента со значением x нет.1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%сен. Песни нумеруются числами от 1 до N (пусть N = 30). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.Подсказка: Используйте процедуры Read и ReadInt для чтения данных опроса.TYPE hit = INTEGER;reponse = RECORD name, firstname: Name;male: BOOLEAN;age: INTEGER;choice: ARRAY 5 OF hitEND;VAR poll: Files.FileЭтот файл содержит входные данные для программы, вычисляющей следую%щие результаты:1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.Перед каждым из пяти списков должен идти подходящий заголовок.Литература[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,Хоор К. Структурное программирование. – М.: Мир, 1975).[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4(1971), 221–27.[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).Литература Фундаментальные структуры данных68[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,6, No. 4, (1974) 247–259.[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.ACM, 17, 10 (Oct. 1974), 549–557.[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAMJ. Comput., 6, 2, (June 1977), 323–349.[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,10 (Oct. 1977), 762–772. 1   2   3   4   5   6   7   8   9   ...   22

Анализ быстрой сортировки. Чтобы изучить эффективность быстрой сорти%ровки, нужно сначала исследовать поведение процесса разделения. После выбора разделяющего значения x просматривается весь массив. Поэтому выполняется в точности n сравнений. Число обменов может быть определено с помощью сле%дующего вероятностного рассуждения.Если положение разделяющего значения фиксировано и соответствующее значение индекса равно u, то среднее число операций обмена равно числу элемен%тов в левой части сегмента, а именно u, умноженному на вероятность того, что элемент попал на свое место посредством обмена. Обмен произошел, если элемент принадлежал правой части; вероятность этого равна (n–u)/n. Поэтому среднее число обменов равно среднему этих значений по всем возможным значениям u:M = [SSSSSu: 0 ≤ u ≤ n–1 : u*(n–u)]/n2= n*(n–1)/2n – (2n2 – 3n + 1)/6n= (n – 1/n)/6Если нам сильно везет и в качестве границы всегда удается выбрать медиану,то каждый процесс разделения разбивает массив пополам, и число необходимых для сортировки проходов равно log(n). Тогда полное число сравнений равно n*log(n), а полное число обменов – n*log(n)/6Разумеется, нельзя ожидать, что c выбором медианы всегда будет так везти,ведь вероятность этого всего лишь 1/n. Но удивительно то, что средняя эффек%тивность алгоритма Quicksort хуже оптимального случая только на множитель2*ln(2), если разделяющее значение выбирается в массиве случайно.Однако и у алгоритма Quicksort есть свои подводные камни. Прежде всего при малых n его производительность не более чем удовлетворительна, как и для всех эф%фективных методов. Но его преимущество над другими эффективными методами заключается в легкости подключения какого%нибудь простого метода для обработки коротких сегментов. Это особенно важно для рекурсивной версии алгоритма.Однако еще остается проблема наихудшего случая. Как поведет себя Quicksort тогда? Увы, ответ неутешителен, и здесь выявляется главная слабость этого алго%Эффективные методы сортировки Сортировка92ритма. Например, рассмотрим неудачный случай, когда каждый раз в качестве разделяющего значения x выбирается наибольшее значение в разделяемом сег%менте. Тогда каждый шаг разбивает сегмент из n элементов на левую часть из n–1элементов и правую часть из единственного элемента. Как следствие нужно сде%лать n разделений вместо log(n), и поведение в худшем случае оказывается по%рядка n2Очевидно, что ключевым шагом здесь является выбор разделяющего значения x. В приведенном варианте алгоритма на эту роль выбирается средний элемент.Но с равным успехом можно выбрать первый или последний элемент. В этих слу%чаях наихудший вариант поведения будет иметь место для изначально упоря%доченного массива; то есть алгоритм Quicksort явно «не любит» легкие задачки и предпочитает беспорядочные наборы значений. При выборе среднего элемента это странное свойство алгоритма Quicksort не так очевидно, так как изначально упорядоченный массив оказывается наилучшим случаем. На самом деле если вы%бирается средний элемент, то и производительность в среднем оказывается немного лучшей. Хоор предложил выбирать x случайным образом или брать ме%диану небольшой выборки из, скажем, трех ключей [2.10] и [2.11]. Такая предос%торожность вряд ли ухудшит среднюю производительность алгоритма, но она сильно улучшает его поведение в наихудшем случае. Во всяком случае, ясно, что сортировка с помощью алгоритма Quicksort немного похожа на тотализатор,и пользователь должен четко понимать, какой проигрыш он может себе позво%лить, если удача от него отвернется.Отсюда можно извлечь важный урок для программиста. Каковы последствия поведения алгоритма Quicksort в наихудшем случае, указанном выше? Мы уже знаем, что в такой ситуации каждое разделение дает правый сегмент, состоящий из единственного элемента, и запрос на сортировку этого сегмента сохраняется на стеке для выполнения в будущем. Следовательно, максимальное число таких за%просов и, следовательно, необходимый размер стека равны n. Конечно, это совер%шенно неприемлемо. (Заметим, что дело обстоит еще хуже в рекурсивной версии,так как вычислительная система, допускающая рекурсивные вызовы процедур,должна автоматически сохранять значения локальных переменных и параметров всех активаций процедур, и для этого будет использоваться скрытый стек.) Выход здесь в том, чтобы сохранять на стеке запрос на обработку более длинной части,а к обработке короткой части приступать немедленно. Тогда размер стека M мож%но ограничить величиной log(n)Соответствующее изменение локализовано в том месте программы, где на сте%ке сохраняются новые запросы на сортировку сегментов:IF j – L < R – i THENIF i < R THEN (* *)INC(s); low[s] := i; high[s] := REND;R := j (*€ *)ELSEIF L < j THEN (* *) 93INC(s); low[s] := L; high[s] := jEND;L := i (*€ *)END2.3.4. Поиск медианыМедиана (median) n элементов – это элемент, который меньше (или равен) поло%вины n элементов и который больше (или равен) элементов другой половины.Например, медиана чисел16 12 99 95 18 87 10равна 18. Задача нахождения медианы обычно связывается с задачей сортировки, так как очевидный метод определения медианы состоит в сортировке n элементов и вы%боре среднего элемента. Но использованная выше процедура разделения дает потен%циальную возможность находить медиану гораздо быстрее. Метод, который мы сей%час продемонстрируем, легко обобщается на задачу нахождения k%го наименьшего из n элементов. Нахождение медианы соответствует частному случаю k = n/2Этот алгоритм был придуман Хоором [2.4] и работает следующим образом.Во%первых, операция разделения в алгоритме Quicksort выполняется с L = 0 иR = n–1, причем в качестве разделяющего значения x выбирается ak. В результате получаются значения индексов i и j – такие, что1.a h < x для всех h < i2.a h > x для всех h > j3.i > jЗдесь возможны три случая:1. Разделяющее значение x оказалось слишком мало; в результате граница между двумя частями меньше нужного значения k. Тогда операцию разде%ления нужно повторить с элементами ai ... aR (см. рис. 2.9).Рис. 2.9. Значение x слишком мало2. Выбранное значение x оказалось слишком велико. Тогда операцию разде%ления нужно повторить с элементами aL ... a j (см. рис. 2.10).3.j < k < i: элемент ak разбивает массив на две части в нужной пропорции и поэтому является искомым значением (см. рис. 2.11).Операцию разделения нужно повторять, пока не реализуется случай 3. Этот цикл выражается следующим программным фрагментом:Эффективные методы сортировки Сортировка94L := 0; R := n;WHILE L < R–1 DOx := a[k]; # (a[L] ... a[R–1]);IF j < k THEN L := i END;IF k < i THEN R := j ENDENDЗа формальным доказательством корректности алгоритма отошлем читателя к оригинальной статье Хоора. Теперь нетрудно выписать процедуру Find це%ликом:PROCEDURE Find (k: INTEGER);(* ADruS2_Sorts *)(* a , a[k] k– *)VAR L, R, i, j: INTEGER; w, x: Item;BEGINL := 0; R := n–1;WHILE L < R–1 DOx := a[k]; i := L; j := R;REPEATWHILE a[i] < x DO i := i+1 END;WHILE x < a[j] DO j := j–1 END;IF i <= j THENw := a[i]; a[i] := a[j]; a[j] := w;i := i+1; j := j–1ENDUNTIL i > j;IF j < k THEN L := i END;IF k < i THEN R := j ENDENDEND FindРис. 2.10. Значение x слишком великоРис. 2.11. Значение x оказалось правильным 95Если предположить, что в среднем каждое разбиение делит пополам размер той части массива, в которой находится искомое значение, то необходимое число сравнений будет n + n/2 + n/4 + ... + 1 ≈ 2n то есть величина порядка n. Это объясняет эффективность процедуры Find для нахождения медиан и других подобных величин, и этим объясняется ее превос%ходство над простым методом, состоящим в сортировке всего массива с последую%щим выбором k%го элемента (где наилучшее поведение имеет порядок n*log(n)).Однако в худшем случае каждый шаг разделения уменьшает размер множества кандидатов только на единицу, что приводит к числу сравнений порядка n2. Как и ранее, вряд ли имеет смысл использовать этот алгоритм, когда число элементов мало, скажем меньше 10.2.3.5. Сравнение методов сортировки массивовЧтобы завершить парад методов сортировки, попробуем сравнить их эффектив%ность. Пусть n обозначает число сортируемых элементов, а C и M – число сравне%ний ключей и пересылок элементов соответственно. Для всех трех простых ме%тодов сортировки имеются замкнутые аналитические формулы. Они даны в табл. 2.8. В колонках min, max, avg стоят соответствующие минимальные, мак%симальные значения, а также значения, усредненные по всем n! перестановкам nэлементов.Таблица 2.8.Таблица 2.8.Таблица 2.8.Таблица 2.8.Таблица 2.8. Сравнение простых методов сортировки min avg maxПростыеC = n–1(n2 + n – 2)/4(n2 – n)/2 – 1вставкиM = 2(n–1)(n2 – 9n –10)/4(n2 – 3n – 4)/2ПростойC = (n2 – n)/2(n2 – n)/2(n2 – n)/2выборM = 3(n–1)n*(ln(n) + 0.57)n2/4 + 3(n–1)ПростыеC = (n2–n)/2(n2–n)/2(n2–n)/2обменыM = 0(n2–n)*0.75(n2–n)*1.5Для эффективных методов простых точных формул не существует. Основные факты таковы: вычислительные затраты для Shellsort оцениваются величиной порядка c*n1.2, а для сортировок Heapsort и Quicksort – величиной c*n*log(n), где c – некоторые коэффициенты.Эти формулы дают только грубую оценку эффективности как функцию параметра n, и они позволяют классифицировать алгоритмы сортировки на примитивные, простые методы (n2) и эффективные, или «логарифмические»,методы (n*log(n)). Однако для практических целей полезно иметь эмпирические данные о величинах коэффициентов c, чтобы можно было сравнить разные ме%тоды. Более того, формулы приведенного типа не учитывают вычислительныхЭффективные методы сортировки Сортировка96затрат на операции, отличные от сравнений ключей и пересылок элементов, та%кие как управление циклами и т. п. Понятно, что эти факторы в какой%то степе%ни зависят от конкретной вычислительной системы, но тем не менее для ориен%тировки полезно иметь какие%нибудь эмпирические данные. Таблица 2.9показывает время (в секундах), затраченное обсуждавшимися методами сорти%ровки, при выполнении на персональном компьютере Лилит (Lilith). Три ко%лонки содержат время, затраченное на сортировку уже упорядоченного массива,случайной перестановки и массива, упорядоченного в обратном порядке. Табли%ца 2.9 содержит данные для массива из 256 элементов, таблица 2.10 – для масси%ва из 2048 элементов. Эти данные демонстрируют явное различие между квад%ратичными (n2) и логарифмическими методами (n*log(n)). Кроме того, полезно отметить следующее:1. Замена простых вставок (StraightInsertion) на двоичные (BinaryInsertion)дает лишь незначительное улучшение, а в случае уже упорядоченного мас%сива приводит даже к ухудшению.2. Пузырьковая сортировка (BubbleSort) – определенно наихудший из всех сравниваемых здесь методов. Даже его усовершенствованная версия, шей%кер%сортировка (ShakerSort), все равно хуже, чем методы простых вставок(StraightInsertion) и простого выбора (StraightSelection), за исключением патологического случая сортировки уже упорядоченного массива.3. Быстрая сортировка (QuickSort) лучше турнирной (HeapSort) на множи%тель от 2 до 3. Она сортирует обратно упорядоченный массив практически с такой же скоростью, как и просто упорядоченный.Таблица 2.9.Таблица 2.9.Таблица 2.9.Таблица 2.9.Таблица 2.9. Время выполнения процедур сортировки для массивов из 256 элементовŠ ‹ Œ StraightInsertion0.02 0.82 1.64BinaryInsertion0.12 0.70 1.30StraightSelection0.94 0.96 1.18BubbleSort1.26 2.04 2.80ShakerSort0.02 1.66 2.92ShellSort0.10 0.24 0.28HeapSort0.20 0.20 0.20QuickSort0.08 0.12 0.08NonRecQuickSort0.08 0.12 0.08StraightMerge0.18 0.18 0.18 97Таблица 2.10.Таблица 2.10.Таблица 2.10.Таблица 2.10.Таблица 2.10. Время выполнения процедур сортировки для массивов из 2048 элементовŠ ‹ Œ StraightInsertion0.22 50.74 103.80BinaryInsertion1.16 37.66 76.06StraightSelection58.18 58.34 73.46BubbleSort80.18 128.84 178.66ShakerSort0.16 104.44 187.36ShellSort0.80 7.08 12.34HeapSort2.32 2.22 2.12QuickSort0.72 1.22 0.76NonRecQuickSort0.72 1.32 0.80StraightMerge1.98 2.06 1.982.4. Сортировка последовательностей2.4.1. Простые слиянияК сожалению, алгоритмы сортировки, представленные в предыдущей главе,неприменимы, когда объем сортируемых данных таков, что они не помещаются целиком в оперативную память компьютера и хранятся на внешних устройствах последовательного доступа, таких как ленты или диски. В этом случае будем счи%тать, что данные представлены в виде (последовательного) файла, для которого характерно, что в каждый момент времени непосредственно доступен только один элемент. Это очень сильное ограничение по сравнению с теми возможностями,которые дают массивы, и здесь нужны другие методы сортировки.Самый важный метод – сортировка слияниями (для всех вариантов сортиров%ки слияниями Вирт употребляет родовое наименование Mergesort – прим. перев.).Слиянием (merging, collating) называют объединение двух (или более) упорядо%ченных последовательностей в одну, тоже упорядоченную последовательность повторным выбором из доступных в данный момент элементов. Слияние – гораз%до более простая операция, чем сортировка, и эту операцию используют в каче%стве вспомогательной в более сложных процедурах сортировки последовательно%стей. Один из способов сортировки на основе слияний – простая сортировкаслияниями (StraightMerge) – состоит в следующем:1. Разобьем последовательность на две половины, b и c2. Выполним слияние частей b и c, комбинируя по одному элементу из b и cв упорядоченные пары.3. Назовем получившуюся последовательность a, повторим шаги 1 и 2, на этот раз выполняя слияние упорядоченных пар в упорядоченные четверки.4. Повторим предыдущие шаги, выполняя слияние четверок в восьмерки,и будем продолжать в том же духе, каждый раз удваивая длину сливаемыхСортировка последовательностей Сортировка98подпоследовательностей, пока вся последовательность не окажется упоря%доченной.Например, рассмотрим следующую последовательность:44 55 12 42 94 18 06 67На шаге 1 разбиение последовательности дает две такие последовательности:44 55 12 42 94 18 06 67Слияние одиночных элементов (которые представляют собой упорядоченные последовательности длины 1) в упорядоченные пары дает:44 94 ' 18 55 ' 06 12 ' 42 67Снова разбивая посередине и выполняя слияние упорядоченных пар, получаем06 12 44 94 ' 18 42 55 67Наконец, третья операция разбиения и слияния дает желаемый результат:06 12 18 42 44 55 67 94Каждая операция, которая требует однократного прохода по всему набору дан%ных, называется фазой (phase), а наименьшая процедура, из повторных вызовов которой состоит сортировка, называется проходом (pass). В приведенном примере сортировка состояла из трех проходов, каждый из которых состоял из фазы разби%ения и фазы слияния. Чтобы выполнить сортировку, здесь нужны три ленты, по%этому процедура называется трехленточным слиянием (three%tape merge).На самом деле фазы разбиения не дают вклада в сортировку в том смысле, что элементы там не переставляются; в этом отношении они непродуктивны, хотя и составляют половину всех операций копирования. Их можно вообще устранить,объединяя фазы разбиения и слияния. Вместо записи в единственную последова%тельность результат слияния сразу распределяется на две ленты, которые будут служить источником исходных данных для следующего прохода. В отличие от вышеописанной двухфазной (two%phase) сортировки слиянием, такой метод назы%вается однофазным (single%phase), или методом сбалансированных слияний(balanced merge). Он явно более эффективен, так как нужно вдвое меньше опера%ций копирования; плата за это – необходимость использовать четвертую ленту.Мы детально разберем процедуру слияния, но сначала будем представлять данные с помощью массивов, только просматривать их будем строго последова%тельно. Затем мы заменим массивы на последовательности, что позволит срав%нить две программы и показать сильную зависимость вида программы от используемого представления данных.Вместо двух последовательностей можно использовать единственный массив,если считать, что оба его конца равноправны. Вместо того чтобы брать элементы для слияния из двух файлов, можно брать элементы с двух концов массива%источ%ника. Тогда общий вид объединенной фазы слияния%разбиения можно проил%люстрировать рис. 2.12. После слияния элементы отправляются в массив%прием% 99ник с одного или другого конца, причем переключение происходит после каждой упорядоченной пары, получающейся в результате слияния в первом проходе, пос%ле каждой упорядоченной четверки на втором проходе и т. д., так что будут равно%мерно заполняться обе последовательности, представленные двумя концами единственного массива%приемника. После каждого прохода два массива меняют%ся ролями, источник становится приемником и наоборот.Дальнейшее упрощение программы получается, если объединить два концеп%туально разных массива в единственный массив двойного размера. Тогда данные будут представлены так:a: ARRAY 2*n OF Индексы i и j будут обозначать два элемента из массива%источника, а k и L – две позиции в массиве%приемнике (см. рис. 2.12). Исходные данные – это, конечно,элементы a0 ... a n–1. Очевидно, нужна булевская переменная up, чтобы управлять направлением потока данных; up будет означать, что в текущем проходе элементы a0 ... a n–1 пересылаются «вверх» в переменные an ... a2n–1, тогда как up будет указывать, что an ... a2n–1 пересылаются «вниз» в a0 ... a n–1. Значение up переклю%чается перед каждым новым проходом. Наконец, для обозначения длины сливае%мых подпоследовательностей вводится переменная p. Сначала ее значение равно1, а затем оно удваивается перед каждым следующим проходом. Чтобы немного упростить дело, предположим, что n всегда является степенью 2. Тогда первый вариант простой сортировки слияниями приобретает следующий вид:PROCEDURE StraightMerge;VAR i, j, k, L, p: INTEGER; up: BOOLEAN;BEGINup := TRUE; p := 1;REPEAT ;IF up THENi := 0; j := n–1; k := n; L := 2*n–1ELSEk := 0; L := n–1; i := n; j := 2*n–1END; p- i- j- k- L- ;up := up; p := 2*pUNTIL p = nEND StraightMergeРис. 2.12. Простая сортировка слияниями с двумя массивамиСортировка последовательностей Сортировка100На следующем шаге разработки мы должны уточнить инструкции, выделен%ные курсивом. Очевидно, что проход слияния, обрабатывающий n элементов, сам является серией слияний подпоследовательностей из p элементов (pнаборов).После каждого такого частичного слияния приемником для подпоследователь%ности становится попеременно то верхний, то нижний конец массива%приемника,чтобы обеспечить равномерное распределение в обе принимающие «последова%тельности». Если элементы после слияния направляются в нижний конец массива%приемника, то индексом%приемником является k, и k увеличивается после каждой пересылки элемента. Если они пересылаются в верхний конец массива%приемни%ка, то индексом%приемником является L, и его значение уменьшается после каж%дой пересылки. Чтобы упростить получающийся программный код для слияния,k всегда будет обозначать индекс%приемник, значения переменых k иa L будут об%мениваться после каждого слияния p%наборов, а переменная h, принимающая зна%чения 1 или –1, будет всегда обозначать приращение для k. Эти проектные реше%ния приводят к такому уточнению:h := 1; m := n; (*m = *)REPEATq := p; r := p; m := m – 2*p; q i- r j- ; – k, k h;h := –h; k LUNTIL m = 0На следующем шаге уточнения нужно конкретизировать операцию слияния.Здесь нужно помнить, что остаток той последовательности, которая осталась не%пустой после слияния, должен быть присоединен к выходной последовательности простым копированием.WHILE (q > 0) & (r > 0) DOIF a[i] < a[j] THEN i- k- ; i k; q := q–1ELSE j- k- ; j k; r := r–1ENDEND; i- ; j- Уточнение операций копирования остатков даст практически полную про%грамму. Прежде чем выписывать ее, избавимся от ограничения, что n является степенью двойки. На какие части алгоритма это повлияет? Нетрудно понять, что справиться с такой более общей ситуацией проще всего, если как можно дольше действовать старым способом. В данном примере это означает, что нужно продол%жать сливать p%наборы до тех пор, пока остатки последовательностей%источников 101не станут короче p. Это повлияет только на операторы, в которых устанавливают%ся значения длины сливаемых последовательностей q и r. Три оператора q := p; r := p; m := m –2*p нужно заменить на приведенные ниже четыре оператора, которые, как читатель может убедиться, в точности реализуют описанную стратегию; заметим, что mобозначает полное число элементов в двух последовательностях%источниках, ко%торые еще предстоит слить:IF m >= p THEN q := p ELSE q := m END;m := m–q;IF m >= p THEN r := p ELSE r := m END;m := m–rКроме того, чтобы обеспечить завершение программы, нужно заменить усло%вие p = n, которое управляет внешним циклом, на p ≥ n. После этих изменений весь алгоритм можно выразить в виде процедуры, работающей с глобальным мас%сивом из 2n элементов:PROCEDURE StraightMerge;(* ADruS24_MergeSorts *)VAR i, j, k, L, t: INTEGER; (* a is 0 .. 2*n–1 *)h, m, p, q, r: INTEGER; up: BOOLEAN;BEGINup := TRUE; p := 1;REPEATh := 1; m := n;IF up THENi := 0; j := n–1; k := n; L := 2*n–1ELSEk := 0; L := n–1; i := n; j := 2*n–1END;REPEAT (* i- j- k- *)IF m >= p THEN q := p ELSE q := m END;m := m–q;IF m >= p THEN r := p ELSE r := m END;m := m–r;WHILE (q > 0) & (r > 0) DOIF a[i] < a[j] THENa[k] := a[i]; k := k+h; i := i+1; q := q–1ELSEa[k] := a[j]; k := k+h; j := j–1; r := r–1ENDEND;WHILE r > 0 DOa[k] := a[j]; k := k+h; j := j–1; r := r–1END;WHILE q > 0 DOa[k] := a[i]; k := k+h; i := i+1; q := q–1END;Сортировка последовательностей Сортировка102h := –h; t := k; k := L; L := tUNTIL m = 0;up := up; p := 2*pUNTIL p >= n;IF up THENFOR i := 0 TO n–1 DO a[i] := a[i+n] ENDENDEND StraightMerge1   ...   5   6   7   8   9   10   11   12   ...   22

Анализ простой сортировки слияниями. Поскольку p удваивается на каждом проходе, а сортировка прекращается, как только p > n, то будет выполнено ⎡log(n)⎤проходов. По определению, на каждом проходе все n элементов копируются в точ%ности один раз. Следовательно, полное число пересылок в точности равноM = n × ⎡log(n)⎤Число сравнений ключей C даже меньше, чем M, так как при копировании остатков никаких сравнений не требуется. Однако поскольку сортировка слия%ниями обычно применяется при работе с внешними устройствами хранения дан%ных, вычислительные затраты на выполнение пересылок нередко превосходят затраты на сравнения на несколько порядков величины. Поэтому детальный ана%лиз числа сравнений не имеет практического интереса.Очевидно, сортировка StraightMerge выглядит неплохо даже в сравнении с эффективными методами сортировки, обсуждавшимися в предыдущей главе.Однако накладные расходы на манипуляции с индексами здесь довольно велики,а решающий недостаток – это необходимость иметь достаточно памяти для хране%ния 2n элементов. По этой причине сортировку слияниями редко применяют для массивов, то есть для данных, размещенных в оперативной памяти. Получить представление о реальном поведении алгоритма StraightMerge можно по числам в последней строке табл. 2.9. Видно, что StraightMerge ведет себя лучше, чемHeapSort, но хуже, чем QuickSort2.4.2. Естественные слиянияЕсли применяются простые слияния, то никакого выигрыша не получается в том случае, когда исходные данные частично упорядочены. Длина всех сливаемых подпоследовательностей на k%м проходе не превосходит 2k, даже если есть более длинные, уже упорядоченные подпоследовательности, готовые к слияниям. Ведь любые две упорядоченные подпоследовательности длины m и n можно сразу слить в одну последовательность из m+n элементов. Сортировка слияниями,в которой в любой момент времени сливаются максимально длинные последова%тельности, называется сортировкой естественными слияниями.Упорядоченную подпоследовательность часто называют строкой (string). Но так как еще чаще это слово используют для последовательностей литер, мы вслед за Кнутом будем использовать термин серия (run) для обозначения упорядочен%ных подпоследовательностей. Подпоследовательность ai ... a j, такую, что(a i–1 > a i) & (AAAAAk: i ≤ k < j : a k≤ a k+1) & (a j > a j+1) 103будем называть максимальной серией, или, для краткости, просто серией. Итак,в сортировке естественными слияниями сливаются (максимальные) серии вмес%то последовательностей фиксированной предопределенной длины. Серии имеют то свойство, что если сливаются две последовательности по n серий каждая, то получается последовательность, состоящая в точности из n серий. Поэтому пол%ное число серий уменьшается вдвое за каждый проход, и необходимое число пере%сылок элементов даже в худшем случае равно n*log(n), а в среднем еще меньше.Однако среднее число сравнений гораздо больше, так как, кроме сравнений при выборе элементов, нужны еще сравнения следующих друг за другом элементов каждого файла, чтобы определить конец каждой серии.Наше очередное упражнение в программировании посвящено разработке ал%горитма сортировки естественными слияниями в той же пошаговой манере, кото%рая использовалась при объяснении простой сортировки слияниями. Вместо мас%сива здесь используются последовательности (представленные файлами, см.раздел 1.7), а в итоге получится несбалансированная двухфазная трехленточная сортировка слияниями. Будем предполагать, что исходная последовательность элементов представлена файловой переменной c. (Естественно, в реальной ситуа%ции исходные данные сначала из соображений безопасности копируются из неко%торого источника в c.) При этом a и b – две вспомогательные файловые перемен%ные. Каждый проход состоит из фазы распределения, когда серии из c равномерно распределяются в a и b, и фазы слияния, когда серии из a и b сливаются в c. Этот процесс показан на рис. 2.13.Рис. 2.13. Фазы сортировки и проходыПример в табл. 2.11 показывает файл c в исходном состоянии (строка 1) и пос%ле каждого прохода (строки 2–4) при сортировке этим методом двадцати чисел.Заметим, что понадобились только три прохода. Сортировка прекращается, как только в c остается одна серия. (Предполагается, что исходная последователь%ность содержит по крайней мере одну непустую серию.) Поэтому пусть перемен%ная L подсчитывает число серий, записанных в c. Используя тип Rider («бегу%Сортировка последовательностей Сортировка104нок»), определенный в разделе 1.7.1, можно сформулировать программу следую%щим образом:VAR L: INTEGER;r0, r1, r2: Files.Rider; (*. 1.7.1*)REPEATFiles.Set(r0, a, 0); Files.Set(r1, b, 0); Files.Set(r2, c, 0);distribute(r2, r0, r1); (*c a b*)Files.Set(r0, a, 0); Files.Set(r1, b, 0); Files.Set(r2, c, 0);L := 0;merge(r0, r1, r2) (*a b c*)UNTIL L = 1Таблица 2.11.Таблица 2.11.Таблица 2.11.Таблица 2.11.Таблица 2.11. Пример сортировки естественными слияниями17 31' 05 59' 13 41 43 67' 11 23 29 47' 03 07 71' 02 19 57' 37 61 05 17 31 59' 11 13 23 29 41 43 47 67' 02 03 07 19 57 71' 37 61 05 11 13 17 23 29 31 41 43 47 59 67' 02 03 07 19 37 57 61 71 02 03 05 07 11 13 17 19 23 29 31 37 41 43 47 57 59 61 67 71Двум фазам в точности соответствуют два разных оператора. Их нужно теперь уточнить, то есть выразить с большей детализацией. Уточненные описания шагов distribute (распределить из бегунка r2 в бегунки r0 и r1) и merge (слить из бегун%ков r0 и r1 в r2) приводятся ниже:REPEATcopyrun(r2, r0);IF r2.eof THEN copyrun(r2, r1) ENDUNTIL r2.eofREPEATmergerun(r0, r1, r2); INC(L)UNTIL r1.eof;IF r0.eof THENcopyrun(r0, r2); INC(L)ENDПо построению этот способ приводит либо к одинаковому числу серий в a и b,либо последовательность a будет содержать одну лишнюю серию по сравнению с файлом b. Поскольку сливаются соответствующие пары серий, эта лишняя се%рия может остаться только в файле a, и тогда ее нужно просто скопировать. Опе%рации merge и distribute формулируются в терминах уточняемой ниже операции mergerun (слить серии) и вспомогательной процедуры copyrun (копировать се%рию), смысл которых очевиден. При попытке реализовать все это возникает серь%езная трудность: чтобы определить конец серии, нужно сравнивать два последо%вательных ключа. Однако файлы устроены так, что каждый раз доступен только один элемент. Очевидно, здесь нужно «заглядывать вперед» на один элемент, по% 105этому для каждой последовательности заводится буфер, который и должен содер%жать очередной элемент, стоящий в последовательности за текущим, и который представляет собой нечто вроде окошка, скользящего по файлу.Уже можно было бы выписать все детали этого механизма в виде полной про%граммы, но мы введем еще один уровень абстракции. Этот уровень представлен новым модулем Runs. Его можно рассматривать как расширение модуля Files из раздела 1.7, и в нем вводится новый тип Rider («бегунок»), который можно рас%сматривать как расширение типа Files.Rider. С этим новым типом не только мож%но будет выполнять все операции, предусмотренные для старого типа Rider, а так%же определять конец файла, но и узнавать о конце серии, а также «видеть» первый элемент в еще не прочитанной части файла. Этот новый тип вместе со своими опе%рациями представлен в следующем определении:DEFINITION Runs;(* ADruS242_Runs *)IMPORT Files, Texts;TYPE Rider = RECORD (Files.Rider) first: INTEGER; eor: BOOLEAN END;PROCEDURE OpenRandomSeq (f: Files.File; length, seed: INTEGER);PROCEDURE Set (VAR r: Rider; VAR f: Files.File);PROCEDURE copy (VAR source, destination: Rider);PROCEDURE ListSeq (VAR W: Texts.Writer; f: Files.File);END Runs.Выбор процедур требует некоторых пояснений. Алгоритмы сортировки, обсуж%даемые здесь и в дальнейшем, основаны на копировании элементов из одного файла в другой. Поэтому процедура copy замещает отдельные операции read и writeДля удобства тестирования в последующих примерах мы дополнительно вве%ли процедуру ListSeq, которая печатает файл целых чисел в текст. Кроме того, для удобства введена еще одна процедура: OpenRandomSeq создает файл с числами в случайном порядке. Эти две процедуры будут служить для проверки обсуждае%мых ниже алгоритмов. Значения полей eof и eor являются результатами операции copy аналогично тому, как ранее eof был результатом операции readMODULE Runs;(* ADruS242_Runs *)IMPORT Files, Texts;TYPE Rider*Rider*Rider*Rider*Rider* = RECORD (Files.Rider) first first first first first: INTEGER; eor eor eor eor eor: BOOLEAN END;PROCEDURE OpenRandomSeq* OpenRandomSeq* OpenRandomSeq* OpenRandomSeq* OpenRandomSeq* (f: Files.File; length, seed: INTEGER);VAR i: INTEGER; w: Files.Rider;BEGINFiles.Set(w, f, 0);FOR i := 0 TO length–1 DOFiles.WriteInt(w, seed); seed := (31*seed) MOD 997 + 5END;Files.Close(f)END OpenRandomSeq;PROCEDURE Set* Set* Set* Set* Set* (VAR r: Rider; f: Files.File);BEGINСортировка последовательностей Сортировка106Files.Set(r, f, 0); Files.ReadInt (r, r.first); r.eor := r.eofEND Set;PROCEDURE copy* copy* copy* copy* copy* (VAR src, dest: Rider);BEGINdest.first := src.first;Files.WriteInt(dest, dest.first); Files.ReadInt(src, src.first);src.eor := src.eof OR (src.first < dest.first)END copy;PROCEDURE ListSeq* ListSeq* ListSeq* ListSeq* ListSeq* (VAR W: Texts.Writer; f: Files.File;);VAR x, y, k, n: INTEGER; r: Files.Rider;BEGINk := 0; n := 0;Files.Set(r, f, 0); Files.ReadInt(r, x);WHILE r.eof DOTexts.WriteInt(W, x, 6); INC(k); Files.ReadInt(r, y);IF y < x THEN (* *) Texts.Write(W, "|"); INC(n) END;x := yEND;Texts.Write(W, "$"); Texts.WriteInt(W, k, 5); Texts.WriteInt(W, n, 5);Texts.WriteLn(W)END ListSeq;END Runs.Вернемся теперь к процессу постепенного уточнения алгоритма сортировки естественными слияниями. Процедуры copyrun и merge уже можно выразить явно, как показано ниже. Отметим, что мы обращаемся к последовательностям(файлам) опосредованно, с помощью присоединенных к ним бегунков. Отметим кстати, что у бегунка поле first содержит следующий ключ в читаемой последова%тельности и последний ключ в записываемой последовательности.PROCEDURE copyrun (VAR x, y: Runs.Rider); (* *)BEGIN (* x y*)REPEAT Runs.copy(x, y) UNTIL x.eorEND copyrun(*merge: r0 r1 r2*)REPEATIF r0.first < r1.first THENRuns.copy(r0, r2);IF r0.eor THEN copyrun(r1, r2) ENDELSE Runs.copy(r1, r2);IF r1.eor THEN copyrun(r0, r2) ENDENDUNTIL r0.eor OR r1.eorПроцесс сравнения и выбора ключей при слиянии пары серий прекращается,как только одна из серий исчерпывается. После этого остаток серии (которая еще не исчерпана) должен быть просто скопирован в серию%результат. Это делается посредством вызова процедуры copyrun 107По идее, здесь процедура разработки должна завершиться. Увы, вниматель%ный читатель заметит, что получившаяся программа не верна. Программа некор%ректна в том смысле, что в некоторых случаях она сортирует неправильно. Напри%мер, рассмотрим следующую последовательность входных данных:03 02 05 11 07 13 19 17 23 31 29 37 43 41 47 59 57 61 71 67Распределяя последовательные серии попеременно в a и b, получим a = 03 ' 07 13 19 ' 29 37 43 ' 57 61 71'b = 02 05 11 ' 17 23 31 ' 41 47 59 ' 67Эти последовательности легко сливаются в единственную серию, после чего сортировка успешно завершается. Хотя этот пример не приводит к ошибке, он пока%зывает, что простое распределение серий в несколько файлов может приводить к меньшему числу серий на выходе, чем было серий на входе. Это происходит пото%му, что первый элемент серии номер i+2 может быть больше, чем последний эле%мент серии номер i, и тогда две серии автоматически «слипаются» в одну серию.Хотя предполагается, что процедура distribute запитывает серии в два файла в равном числе, важное следствие состоит в том, что реальное число серий, запи%санных в a и b, может сильно различаться. Но наша процедура слияния сливает только пары серий и прекращает работу, как только прочитан файл b, так что ос%таток одной из последовательностей теряется. Рассмотрим следующие входные данные, которые сортируются (и обрываются) за два последовательных прохода:Таблица 2.12.Таблица 2.12.Таблица 2.12.Таблица 2.12.Таблица 2.12. Неправильный результат алгоритма MergeSort17 19 13 57 23 29 11 59 31 37 07 61 41 43 05 67 47 71 02 03 13 17 19 23 29 31 37 41 43 47 57 71 11 59 11 13 17 19 23 29 31 37 41 43 47 57 59 71Такая ошибка достаточно типична в программировании. Она вызвана тем, что осталась незамеченной одна из ситуаций, которые могут возникнуть после выпол%нения простой, казалось бы, операции. Ошибка типична также в том отношении,что ее можно исправить несколькими способами и нужно выбрать один. Обычно есть две возможности, которые отличаются в одном принципиальном отношении:1. Мы признаем, что операция распределения запрограммирована неправиль%но и не удовлетворяет требованию, чтобы число серий отличалось не боль%ше, чем на единицу. При этом мы сохраняем первоначальную схему про%граммы и исправляем неправильную процедуру.2. Мы обнаруживаем, что исправление неправильной процедуры будет иметь далеко идущие последствия, и тогда пытаемся так изменить другие части программы, чтобы они правильно работали с данным вариантом процедуры.В общем случае первый путь кажется более безопасным, ясным и честным, обес%печивая определенную устойчивость к последствиям незамеченных, тонких побоч%ных эффектов. Поэтому обычно рекомендуется именно этот способ решения.Сортировка последовательностей Сортировка108Однако не всегда следует игнорировать и второй путь. Именно поэтому мы хотим показать решение, основанное на изменении процедуры слияния, а не про%цедуры распределения, из%за которой, в сущности, и возникла проблема. Подразу%мевается, что мы не будем трогать схему распределения, но откажемся от условия,что серии должны распределяться равномерно. Это может понизить эффек%тивность. Но поведение в худшем случае не изменится, а случай сильно неравно%мерного распределения статистически очень маловероятен. Поэтому соображе%ния эффективности не являются серьезным аргументом против такого решения.Если мы отказались от условия равномерного распределения серий, то про%цедура слияния должна измениться так, чтобы по достижении конца одного из файлов копировался весь остаток другого файла, а не только одна серия. Такое изменение оказывается очень простым по сравнению с любыми исправлениями в схеме распределения. (Читателю предлагается убедиться в справедливости это%го утверждения.) Новая версия алгоритма слияний дана ниже в виде процедуры%функции:PROCEDURE copyrun (VAR x, y: Runs.Rider);(* ADruS24_MergeSorts *)(* *)BEGIN (* x y*)REPEAT Runs.copy(x, y) UNTIL x.eorEND copyrun;PROCEDURE NaturalMerge (src: Files.File): Files.File; (* *)VAR L: INTEGER; (* *)f0, f1, f2: Files.File;r0, r1, r2: Runs.Rider;BEGINRuns.Set(r2, src);REPEATf0 := Files.New("test0"); Files.Set(r0, f0, 0);f1 := Files.New("test1"); Files.Set (r1, f1, 0);(* r2 r0 r1*)REPEATcopyrun(r2, r0);IF r2.eof THEN copyrun(r2, r1) ENDUNTIL r2.eof;Runs.Set(r0, f0); Runs.Set(r1, f1);f2 := Files.New(""); Files.Set(r2, f2, 0);(*merge: r0 r1 r2*)L := 0;REPEATREPEATIF r0.first < r1.first THENRuns.copy(r0, r2);IF r0.eor THEN copyrun(r1, r2) ENDELSERuns.copy(r1, r2); 109IF r1.eor THEN copyrun(r0, r2) ENDENDUNTIL r0.eor & r1.eor;INC(L)UNTIL r0.eof OR r1.eof;WHILE r0.eof DO copyrun(r0, r2); INC(L) END;WHILE r1.eof DO copyrun(r1, r2); INC(L) END;Runs.Set(r2, f2)UNTIL L = 1;RETURN f2END NaturalMerge;2.4.3. Сбалансированные многопутевые слиянияЗатраты на последовательную сортировку пропорциональны необходимому чис%лу проходов, так как на каждом проходе по определению копируется весь набор данных. Один из способов уменьшить это число состоит в том, чтобы исполь%зовать больше двух файлов для распределения серий. Если сливать r серий, кото%рые равномерно распределены по N файлам, то получится последовательность r/N серий. После второго прохода их число уменьшится до r/N2, после третьего –до r/N3, а после k проходов останется r/Nk серий. Поэтому полное число проходов,необходимых для сортировки n элементов с помощью N%путевого слияния, равно k = logN(n). Поскольку каждый проход требует n операций копирования, полное число операций копирования в худшем случае равно M = n × logN(n)В качестве следующего упражнения в программировании мы разработаем про%грамму сортировки, основанную на многопутевых слияниях. Чтобы подчеркнуть отличие этой программы от приведенной выше программы естественных двух%фазных слияний, мы сформулируем многопутевое слияние в виде однофазного сбалансированного слияния. Это подразумевает, что на каждом проходе есть рав%ное число файлов%источников и файлов%приемников, в которые серии распре%деляются по очереди. Если используется 2N файлов, то говорят, что алгоритм ос%нован на N%путевом слиянии. Следуя принятой ранее стратегии, мы не будем беспокоиться об отслеживании слияния двух последовательных серий, попавших в один файл. Поэтому нам нужно спроектировать программу слияния, не делая предположения о строго равном числе серий в файлах%источниках.Здесь мы впервые встречаем ситуацию, когда естественно возникает структу%ра данных, представляющая собой массив файлов. На самом деле удивительно,насколько сильно наша следующая программа отличается от предыдущей из%за перехода от двухпутевых к многопутевым слияниям. Главная причина этого –в том, что процесс слияния теперь не может просто остановиться после исчерпа%ния одной из серий%источников. Вместо этого нужно сохранить список еще актив%ных, то есть до конца не исчерпанных, файлов%источников. Другое усложнение возникает из%за необходимости менять роли файлов%источников и файлов%при%емников. Здесь становится видно удобство принятого способа косвенного досту%па к файлам с помощью бегунков. На каждом проходе данные можно копироватьСортировка последовательностей Сортировка110с одной и той же группы бегунков r на одну и ту же группу бегунков w. А в конце каждого прохода нужно просто переключить бегунки r и w на другие группы файлов.Очевидно, для индексирования массива файлов используются номера файлов.Предположим, что исходный файл представлен параметром src и что для про%цесса сортировки в наличии имеются 2N файлов:f, g: ARRAY N OF Files.File;r, w: ARRAY N OF Runs.RiderТогда можно написать следующий эскизный вариант алгоритма:PROCEDURE BalancedMerge (src: Files.File): Files.File;(* *)VAR i, j: INTEGER;L: INTEGER; (* *)R: Runs.Rider;BEGINRuns.Set(R, src); (* R w[0] ... w[N–1]*)j := 0; L := 0; # w ! g;REPEAT R w[j];INC(j); INC(L);IF j = N THEN j := 0 ENDUNTIL R.eof;REPEAT (* r w*) # r ! g;L := 0; j := 0; (*j = ! - *)REPEATINC(L); €# w[j];IF j < N THEN INC(j) ELSE j := 0 ENDUNTIL ;UNTIL L = 1(* ! w[0]*)END BalancedMerge.Связав бегунок R с исходным файлом, займемся уточнением операции первич%ного распределения серий. Используя определение процедуры copy, заменим фразу R w[j] на следующий оператор:REPEAT Runs.copy(R, w[j]) UNTIL R.eorКопирование серии прекращается, когда либо встретится первый элемент сле%дующей серии, либо будет достигнут конец входного файла.В реальном алгоритме сортировки нужно уточнить следующие операции:(1) # w ! g;(2) €# w j; 111(3) # r ! g;(4) Во%первых, нужно аккуратно определить текущие последовательности%источ%ники. В частности, число активных источников может быть меньше N. Очевидно,источников не может быть больше, чем серий; сортировка прекращается, как только останется единственная последовательность. При этом остается возмож%ность, что в начале последнего прохода сортировки число серий меньше N. Поэто%му введем переменную, скажем k1, для обозначения реального числа источников.Инициализацию переменной k1 включим в операцию # сле%дующим образом:IF L < N THEN k1 := L ELSE k1 := N END;FOR i := 0 TO k1–1 DO Runs.Set(r[i], g[i]) ENDЕстественно, в операции (2) нужно уменьшить k1 при исчерпании какого%либо источника. Тогда предикат (4) легко выразить в виде сравнения k1 = 0. Однако операцию (2) уточнить труднее; она состоит из повторного выбора наименьшего ключа среди имеющихся источников и затем его пересылки по назначению, то есть в текущую последовательность%приемник. Эта операция усложняется необходимостью определять конец каждой серии. Конец серии определяется, ког%да (a) следующий ключ меньше текущего или (b) досгигнут конец последователь%ности%источника. В последнем случае источник удаляется уменьшением k1;в первом случае серия закрывается исключением последовательности из дальней%шего процесса выбора элементов, но только до завершения формирования теку%щей серии%приемника. Из этого видно, что нужна вторая переменная, скажем k2,для обозначения числа источников, реально доступных для выбора следующего элемента. Это число сначала устанавливается равным k1 и уменьшается каждый раз, когда серия прерывается по условию (a).К сожалению, недостаточно ввести только k2. Нам нужно знать не только количество еще используемых файлов, но и какие именно это файлы. Очевидное решение – ввести массив из булевских элементов, чтобы отмечать такие файлы.Однако мы выберем другой способ, который приведет к более эффективной про%цедуре выбора, – ведь эта часть во всем алгоритме повторяется чаще всего. Вместо булевского массива введем косвенную индексацию файлов с помощью отображе%ния (map) индексов посредством массива, скажем t. Отображение используется таким образом, что t0 ... t k2–1 являются индексами доступных последовательнос%тей. Теперь операция (2) может быть сформулирована следующим образом:k2 := k1;REPEAT , t[m] – , v ;Runs.copy(r[t[m]], w[j]);IF r[t[m]].eof THEN ELSIF r[t[m]].eor THENСортировка последовательностей Сортировка112 ENDUNTIL k2 = 0Поскольку число последовательностей на практике довольно мало, для алго%ритма выбора, который требуется уточнить на следующем шаге, можно приме%нить простой линейный поиск. Операция подра%зумевает уменьшение k1 и k2, а операция – уменьшение только k2,причем обе операции включают в себя соответствующие перестановки элементов массива t. Детали показаны в следующей процедуре, которая и является резуль%татом последнего уточнения. При этом операция # была рас%крыта в соответствии с ранее данными объяснениями:PROCEDURE BalancedMerge (src: Files.File): Files.File; (* ADruS24_MergeSorts *)(* *)VAR i, j, m, tx: INTEGER;L, k1, k2, K1: INTEGER;min, x: INTEGER;t: ARRAY N OF INTEGER; (* € *)R: Runs.Rider; (* *)f, g: ARRAY N OF Files.File;r, w: ARRAY N OF Runs.Rider;BEGINRuns.Set(R, src);FOR i := 0 TO N–1 DOg[i] := Files.New(""); Files.Set(w[i], g[i], 0)END;(* src ! g[0] ... g[N–1]*)j := 0; L := 0;REPEATREPEAT Runs.copy(R, w[j]) UNTIL R.eor;INC(L); INC(j);IF j = N THEN j := 0 ENDUNTIL R.eof;REPEATIF L < N THEN k1 := L ELSE k1 := N END;K1 := k1;FOR i := 0 TO k1–1 DO (* # - *)Runs.Set(r[i], g[i])END;FOR i := 0 TO k1–1 DO (* # - *)g[i] := Files.New(""); Files.Set(w[i], g[i], 0)END;(* r[0] ... r[k1–1] w[0] ... w[K1–1]*)FOR i := 0 TO k1–1 DO t[i] := i END;L := 0; (* *)j := 0;REPEAT (* w[j]*) 113INC(L); k2 := k1;REPEAT (* v *)m := 0; min := r[t[0]].first; i := 1;WHILE i < k2 DOx := r[t[i]].first;IF x < min THEN min := x; m := i END;INC(i)END;Runs.copy(r[t[m]], w[j]);IF r[t[m]].eof THEN (* *)DEC(k1); DEC(k2);t[m] := t[k2]; t[k2] := t[k1]ELSIF r[t[m]].eor THEN (* *)DEC(k2);tx := t[m]; t[m] := t[k2]; t[k2] := txENDUNTIL k2 = 0;INC(j);IF j = K1 THEN j := 0 ENDUNTIL k1 = 0UNTIL L = 1;RETURN g[0]END BalancedMerge1   ...   6   7   8   9   10   11   12   13   ...   22

2.4.4. Многофазная сортировкаМы обсудили необходимые приемы и приобрели достаточно опыта, чтобы иссле%довать и запрограммировать еще один алгоритм сортировки, который по произ%водительности превосходит сбалансированную сортировку. Мы видели, что сба%лансированные слияния устраняют операции простого копирования, которые нужны, когда операции распределения и слияния объединены в одной фазе. Воз%никает вопрос: можно ли еще эффективней обработать последовательности. Ока%зывается, можно. Ключом к очередному усовершенствованию является отказ от жесткого понятия проходов, то есть более изощренная работа с последователь%ностями, нежели использование проходов с N источниками и с таким же числом приемников, которые в конце каждого прохода меняются местами. При этом по%нятие прохода размывается. Этот метод был изобретен Гильстадом [2.3] и называ%ется многофазной сортировкой.Проиллюстрируем ее сначала примером с тремя последовательностями. В лю%бой момент времени элементы сливаются из двух источников в третью последо%вательность. Как только исчерпывается одна из последовательностей%источни%ков, она немедленно становится приемником для операций слияния данных из еще не исчерпанного источника и последовательности, которая только что была принимающей.Так как при наличии n серий в каждом источнике получается n серий в прием%нике, достаточно указать только число серий в каждой последовательности (вме%Сортировка последовательностей Сортировка114сто того чтобы указывать конкретные ключи). На рис. 2.14 предполагается, что сначала есть 13 и 8 серий в последовательностях%источниках f0 и f1 соответ%ственно. Поэтому на первом проходе 8 серий сливается из f0 и f1 в f2, на втором проходе остальные 5 серий сливаются из f2 и f0 в f1 и т. д. В конце концов, f0содержит отсортированную последовательность.Рис. 2.14. Многофазная сортировка с тремя последовательностями, содержащими 21 сериюРис. 2.15. Многофазная сортировка слиянием 65 серий с использованием 6 последовательностейВторой пример показывает многофазный метод с 6 последовательностями.Пусть вначале имеются 16 серий в последовательности f0, 15 в f1, 14 в f2, 12 в f3 и8 в f4. В первом частичном проходе 8 серий сливаются на f5. В конце концов,f1 содержит отсортированный набор элементов (см. рис. 2.15). 115Многофазная сортировка более эффективна, чем сбалансированная, так как при наличии N последовательностей она всегда реализует N–1%путевое слияние вместо N/2%путевого. Поскольку число требуемых проходов примерно равно log N n, где n – число сортируемых элементов, а N – количество сливаемых серий в одной операции слияния, то идея многофазной сортировки обещает существен%ное улучшение по сравнению со сбалансированной.Разумеется, в приведенных примерах на%чальное распределение серий было тщательно подобрано. Чтобы понять, как серии должны быть распределены в начале сортировки для ее правильного функционирования, будем рассуж%дать в обратном порядке, начиная с окончатель%ного распределения (последняя строка на рис. 2.15). Ненулевые числа серий в каждой строке рисунка (2 и 5 таких чисел на рис. 2.14 и2.15 соответственно) запишем в виде строки таблицы, располагая по убыванию (порядок чи%сел в строке таблицы не важен). Для рис. 2.14 и2.15 получатся табл. 2.13 и 2.14 соответственно.Количество строк таблицы соответствует числу проходов.La0(L)a1(L)Sum a i(L)0 10 11 11 22 21 33 32 54 53 85 85 13 613 821Таблица 2.13.Таблица 2.13.Таблица 2.13.Таблица 2.13.Таблица 2.13. Идеальные распределения серий в двух последовательностяхТаблица 2.14.Таблица 2.14.Таблица 2.14.Таблица 2.14.Таблица 2.14. Идеальные распределения серий в пяти последовательностяхLa0(L)a1(L)a2(L)a3(L)a4(L) Sum a i(L)0 10 00 01 11 11 11 52 22 22 19 34 44 32 17 48 87 64 33 516 15 14 12 865Для табл. 2.13 получаем следующие соотношения для L > 0:a1(L+1) = a0(L)a0(L+1) = a0(L) + a1(L)вместе с a0(0) = 1, a1(0) = 0. Определяя fi+1 = a0(i), получаем для i > 0:f i+1 = f i + f i–1, f1 = 1, f0 = 0Это в точности рекуррентное определение чисел Фибоначчи:f = 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ...Сортировка последовательностей Сортировка116Каждое число Фибоначчи равно сумме двух своих предшественников. Следова%тельно, начальные числа серий в двух последовательностях%источниках должны быть двумя последовательными числами Фибоначчи, чтобы многофазная сорти%ровка правильно работала с тремя последовательностями.А как насчет второго примера (табл. 2.14) с шестью последовательностями?Нетрудно вывести определяющие правила в следующем виде:a4(L+1) = a0(L)a3(L+1) = a0(L) + a4(L) = a0(L) + a0(L–1)a2(L+1) = a0(L) + a3(L) = a0(L) + a0(L–1) + a0(L–2)a1(L+1) = a0(L) + a2(L) = a0(L) + a0(L–1) + a0(L–2) + a0(L–3)a0(L+1) = a0(L) + a1(L) = a0(L) + a0(L–1) + a0(L–2) + a0(L–3) + a0(L–4)Подставляя fi вместо a0(i), получаем fi+1= f i + f i–1 + f i–2 + f i–3 + f i–4для i > 4f4= 1f i= 0 для i < 4Это числа Фибоначчи порядка 4. В общем случае числа Фибоначчи порядка pопределяются так:f i+1(p) = f i(p) + f i–1(p) + ... + f i–p(p) для i > p fp(p)= 1f i(p)= 0 для 0 ≤ i < pЗаметим, что обычные числа Фибоначчи получаются для p = 1Видим, что идеальные начальные числа серий для многофазной сортировки сN последовательностями суть суммы любого числа – N–1, N–2, ... , 1 – после%довательных чисел Фибоначчи порядка N–2 (см. табл. 2.15).Таблица 2.15.Таблица 2.15.Таблица 2.15.Таблица 2.15.Таблица 2.15. Числа серий, допускающие идеальное распределениеL \ N:3 45 67 81 23 45 67 23 57 911 13 35 913 17 21 25 48 17 25 33 41 49 513 31 49 65 81 97 621 57 94 129 161 193 734 105 181 253 321 385 855 193 349 497 636 769 989 355 673 977 1261 1531 10 144 653 1297 1921 2501 3049 11 233 1201 2500 3777 4961 6073 12 377 2209 4819 7425 9841 12097 13 610 4063 9289 14597 19521 24097 14 987 7473 17905 28697 38721 48001 117Казалось бы, такой метод применим только ко входным данным, в которых число серий равно сумме N–1 таких чисел Фибоначчи. Поэтому возникает важ%ный вопрос: что же делать, когда число серий вначале не равно такой идеальной сумме? Ответ прост (и типичен для подобных ситуаций): будем имитировать существование воображаемых пустых серий, так чтобы сумма реальных и вообра%жаемых серий была равна идеальной сумме. Такие серии будем называть фиктивными (dummy).Но этого на самом деле недостаточно, так как немедленно встает другой, более сложный вопрос: как распознавать фиктивные серии во время слияния? Прежде чем отвечать на него, нужно исследовать возникающую еще раньше проблему распределения начальных серий и решить, каким правилом руководствоваться при распределении реальных и фиктивных серий на N–1 лентах.Чтобы найти хорошее правило распределения, нужно знать, как выполнять слияние реальных и фиктивных серий. Очевидно, что выбор фиктивной серии из i- й последовательности в точности означает, что i- я последовательность игно%рируется в данном слиянии, так что речь идет о слиянии менее N–1 источников.Слияние фиктивных серий из всех N–1 источников означает отсутствие реальной операции слияния, но при этом в приемник нужно записать фиктивную серию.Отсюда мы заключаем, что фиктивные серии должны быть распределены по n–1последовательностям как можно равномернее, так как хотелось бы иметь актив%ные слияния с участием максимально большого числа источников.На минуту забудем про фиктивные серии и рассмотрим проблему распределе%ния некоторого неизвестного числа серий по N–1 последовательностям. Ясно, что числа Фибоначчи порядка N–2, указывающие желательное число серий в каждом источнике, могут быть сгенерированы в процессе распределения. Например,предположим, что N = 6, и, обращаясь к табл. 2.14, начнем с распределения серий,показанного в строке с индексом L = 1 (1, 1, 1, 1, 1); если еще остаются серии,переходим ко второму ряду (2, 2, 2, 2, 1); если источник все еще не исчерпан,распределение продолжается в соответствии с третьей строкой (4, 4, 4, 3, 2) и т. д.Индекс строки будем называть уровнем. Очевидно, что чем больше число серий,тем выше уровень чисел Фибоначчи, который, кстати говоря, равен числу прохо%дов слияния или переключений приемника, которые нужно будет сделать в сор%тировке. Теперь первая версия алгоритма распределения может быть сформули%рована следующим образом:1. В качестве цели распределения (то есть желаемых чисел серий) взять числаФибоначчи порядка N–2, уровня 1.2. Распределять серии, стремясь достичь цели.3. Если цель достигнута, вычислить числа Фибоначчи следующего уровня;разности между ними и числами на предыдущем уровне становятся новой целью распределения. Вернуться на шаг 2. Если же цель не достигнута, хотя источник исчерпан, процесс распределения завершается.Правило вычисления чисел Фибоначчи очередного уровня содержится в их определении. Поэтому можно сосредоточить внимание на шаге 2, где при задан%Сортировка последовательностей Сортировка118ной цели еще не распределенные серии должны быть распределены по одной в N–1 последовательностей%приемников. Именно здесь вновь появляются фик%тивные серии.Пусть после повышении уровня очередная цель представлена разностями di,i = 0 ... N–2, где di обозначает число серий, которые должны быть распределены в i%ю последовательность на очередном шаге. Здесь можно представить себе, что мы сразу помещаем di фиктивных серий в i%ю последовательность и рассматрива%ем последующее распределение как замену фиктивных серий реальными, при каждой замене вычитая единицу из di. Тогда di будет равно числу фиктивных се%рий в i- й последовательности на момент исчерпания источника.Неизвестно, какой алгоритм дает оптималь%ное распределение, но очень хорошо зарекомен%довало себя так называемое горизонтальноераспределение (см. [2.7], с. 297). Это название можно понять, если представить себе, что серии сложены в стопки, как показано на рис. 2.16 дляN = 6, уровень 5 (ср. табл. 2.14). Чтобы как мож%но быстрее достичь равномерного распределе%ния остаточных фиктивных серий, последние заменяются реальными послойно слева напра%во, начиная с верхнего слоя, как показано на рис. 2.16.Теперь можно описать соответствующий ал%горитм в виде процедуры select, которая вызы%вается каждый раз, когда завершено копирова%ние серии и выбирается новый приемник для очередной серии. Для обозначения текущей принимающей последовательности используется переменная ja i и di обозначают соответственно идеальное число серий и число фиктивных серий для i%й последо%вательности.j, level: INTEGER;a, d: ARRAY N OF INTEGER;Эти переменные инициализируются следующими значениями:a i = 1,d i = 1для i = 0 ... N–2aN–1 = 0,dN–1 = 0(принимающая последовательность)j = 0,level = 0Заметим, что процедура select должна вычислить следующую строчку табл. 2.14, то есть значения a0(L) ... aN–2(L), при увеличении уровня. Одновременно вычисляется и очередная цель, то есть разности di = a i(L) – a i(L–1). Приводимый алгоритм использует тот факт, что получающиеся di убывают с возрастанием ин%декса («нисходящая лестница» на рис. 2.16). Заметим, что переход с уровня 0 на уровень 1 является исключением; поэтому данный алгоритм должен стартоватьРис. 2.16. Горизонтальное распределение серий 119с уровня 1. Процедура select заканчивается уменьшением dj на 1; эта операция соответствует замене фиктивной серии в j%й последовательности на реальную.PROCEDURE select; (* *)VAR i, z: INTEGER;BEGINIF d[j] < d[j+1] THENINC(j)ELSEIF d[j] = 0 THENINC(level);z := a[0];FOR i := 0 TO N–2 DOd[i] := z + a[i+1] – a[i]; a[i] := z + a[i+1]ENDEND;j := 0END;DEC(d[j])END selectПредполагая наличие процедуры для копирования серии из последовательно%сти%источника src с бегунком R в последовательность fj с бегунком rj, мы можем следующим образом сформулировать фазу начального распределения (предпола%гается, что источник содержит хотя бы одну серию):REPEAT select; copyrunUNTIL R.eofОднако здесь следует остановиться и вспомнить о явлении, имевшем место при распределении серий в ранее обсуждавшейся сортировке естественными слияниями, а именно: две серии, последовательно попадающие в один приемник,могут «слипнуться» в одну, так что подсчет серий даст неверный результат. Этим можно пренебречь, если алгоритм сортировки таков, что его правильность не за%висит от числа серий. Но в многофазной сортировке необходимо тщательно от%слеживать точное число серий в каждом файле. Следовательно, здесь нельзя пренебрегать случайным «слипанием» серий. Поэтому невозможно избежать дополнительного усложнения алгоритма распределения. Теперь необходимо удерживать ключ последнего элемента последней серии в каждой последователь%ности. К счастью, именно это делает наша реализация процедуры Runs. Для при%нимающих последовательностей поле бегунка r.first хранит последний записан%ный элемент. Поэтому следующая попытка написать алгоритм распределения может быть такой:REPEAT select;IF r[j].first <= R.first THEN € END;copyrunUNTIL R.eofСортировка последовательностей Сортировка120Очевидная ошибка здесь в том, что мы забыли, что r[j].first получает значение только после копирования первой серии. Поэтому корректное решение требует сначала распределить по одной серии в каждую из N–1 принимающих последо%вательностей без обращения к first. Оставшиеся серии распределяются следую%щим образом:WHILE R.eof DOselect;IF r[j].first <= R.first THENcopyrun;IF R.eof THEN INC(d[j]) ELSE copyrun ENDELSEcopyrunENDENDНаконец, можно заняться главным алгоритмом многофазной сортировки. Его принципиальная структура подобна основной части программы N%путевого слия%ния: внешний цикл, в теле которого сливаются серии, пока не исчерпаются источ%ники, внутренний цикл, в теле которого сливается по одной серии из каждого ис%точника, а также самый внутренний цикл, в теле которого выбирается начальный ключ и соответствующий элемент пересылается в выходной файл. Главные отли%чия от сбалансированного слияния в следующем:1. Теперь на каждом проходе вместо N приемников есть только один.2. Вместо переключения N источников и N приемников после каждого прохо%да происходит ротация последовательностей. Для этого используется косвенная индексация последовательностей при посредстве массива t3. Число последовательностей%источников меняется от серии к серии; в нача%ле каждой серии оно определяется по счетчикам фиктивных последова%тельностей di. Если di > 0 для всех i, то имитируем слияние N–1 фиктивных последовательностей в одну простым увеличением счетчика dN–1 для пос%ледовательности%приемника. В противном случае сливается по одной се%рии из всех источников с di = 0, а для всех остальных последовательностей di уменьшается, показывая, что число фиктивных серий в них уменьши%лось. Число последовательностей%источников, участвующих в слиянии,обозначим как k4. Невозможно определить окончание фазы по обнаружению конца после%довательности с номером N–1, так как могут понадобиться дальнейшие сли%яния с участием фиктивных серий из этого источника. Вместо этого теоре%тически необходимое число серий определяется по коэффициентам ai. Эти коэффициенты были вычислены в фазе распределения; теперь они могут быть восстановлены обратным вычислением.Теперь в соответствии с этими правилами можно сформулировать основную часть многофазной сортировки, предполагая, что все N–1 последовательности с начальными сериями подготовлены для чтения и что массив отображения ин%дексов инициализирован как ti = i 121REPEAT (* t[0] ... t[N–2] t[N–1]*)z := a[N–2]; d[N–1] := 0;REPEAT (* *)k := 0;(* *)FOR i := 0 TO N–2 DOIF d[i] > 0 THENDEC(d[i])ELSEta[k] := t[i]; INC(k)ENDEND;IF k = 0 THENINC(d[N–1])ELSE t[0] ... t[k–1] t[N–1]END;DEC(z)UNTIL z = 0;Runs.Set(r[t[N–1]], f[t[N–1]]); € t; a[i] # ;DEC(level)UNTIL level = 0(* f[t[0]]*)Реальная операция слияния почти такая же, как в сортировке N%путевыми слияниями, единственное отличие в том, что слегка упрощается алгоритм исклю%чения последовательности. Ротация в отображении индексов последовательно%стей и соответствующих счетчиков di (а также перевычисление ai при переходе на уровень вниз) не требует каких%либо ухищрений; детали можно найти в следую%щей программе, целиком реализующей алгоритм многофазной сортировки:PROCEDURE Polyphase (src: Files.File): Files.File;(* ADruS24_MergeSorts *)(* #! *)VAR i, j, mx, tn: INTEGER;k, dn, z, level: INTEGER;x, min: INTEGER;a, d: ARRAY N OF INTEGER;t, ta: ARRAY N OF INTEGER; (* € *)R: Runs.Rider; (* *)f: ARRAY N OF Files.File;r: ARRAY N OF Runs.Rider;PROCEDURE select; (**)VAR i, z: INTEGER;BEGINIF d[j] < d[j+1] THENINC(j)Сортировка последовательностей Сортировка122ELSEIF d[j] = 0 THENINC(level);z := a[0];FOR i := 0 TO N–2 DOd[i] := z + a[i+1] – a[i]; a[i] := z + a[i+1]ENDEND;j := 0END;DEC(d[j])END select;PROCEDURE copyrun; (* src f[j]*)BEGINREPEAT Runs.copy(R, r[j]) UNTIL R.eorEND copyrun;BEGINRuns.Set(R, src);FOR i := 0 TO N–2 DOa[i] := 1; d[i] := 1;f[i] := Files.New(""); Files.Set(r[i], f[i], 0)END;(* *)level := 1; j := 0; a[N–1] := 0; d[N–1] := 0;REPEATselect; copyrunUNTIL R.eof OR (j = N–2);WHILE R.eof DOselect; (*r[j].first = , f[j]*)IF r[j].first <= R.first THENcopyrun;IF R.eof THEN INC(d[j]) ELSE copyrun ENDELSEcopyrunENDEND;FOR i := 0 TO N–2 DOt[i] := i; Runs.Set(r[i], f[i])END;t[N–1] := N–1;REPEAT (* t[0] ... t[N–2] t[N–1]*)z := a[N–2]; d[N–1] := 0;f[t[N–1]] := Files.New(""); Files.Set(r[t[N–1]], f[t[N–1]], 0);REPEAT (* *)k := 0;FOR i := 0 TO N–2 DOIF d[i] > 0 THENDEC(d[i]) 123ELSEta[k] := t[i]; INC(k)ENDEND;IF k = 0 THENINC(d[N–1])ELSE (* t[0] ... t[k–1] t[N–1]*)REPEATmx := 0; min := r[ta[0]].first; i := 1;WHILE i < k DOx := r[ta[i]].first;IF x < min THEN min := x; mx := i END;INC(i)END;Runs.copy(r[ta[mx]], r[t[N–1]]);IF r[ta[mx]].eor THENta[mx] := ta[k–1]; DEC(k)ENDUNTIL k = 0END;DEC(z)UNTIL z = 0;Runs.Set(r[t[N–1]], f[t[N–1]]); (* *)tn := t[N–1]; dn := d[N–1]; z := a[N–2];FOR i := N–1 TO 1 BY –1 DOt[i] := t[i–1]; d[i] := d[i–1]; a[i] := a[i–1] – zEND;t[0] := tn; d[0] := dn; a[0] := z;DEC(level)UNTIL level = 0 ;RETURN f[t[0]]END Polyphase2.4.5. Распределение начальных серийНеобходимость использовать сложные программы последовательной сортировки возникает из%за того, что более простые методы, работающие с массивами, можно применять только при наличии достаточно большого объема оперативной памяти для хранения всего сортируемого набора данных. Часто оперативной памяти не хватает; вместо нее нужно использовать достаточно вместительные устройства хранения данных с последовательным доступом, такие как ленты или диски. Мы знаем, что развитые выше методы сортировки последовательностей практически не нуждаются в оперативной памяти, не считая, конечно, буферов для файлов и,разумеется, самой программы. Однако даже в небольших компьютерах размер оперативной памяти, допускающей произвольный доступ, почти всегда больше,чем нужно для разработанных здесь программ. Не суметь ее использовать опти%мальным образом непростительно.Сортировка последовательностей Сортировка124Решение состоит в том, чтобы скомбинировать методы сортировки массивов и последовательностей. В частности, в фазе начального распределения серий мож%но использовать вариант сортировки массивов, чтобы серии сразу имели длину L,соответствующую размеру доступной оперативной памяти. Понятно, что в после%дующих проходах слияния нельзя повысить эффективность с помощью сорти%ровок массивов, так как длина серий только растет, и они в дальнейшем остаются больше, чем доступная оперативная память. Так что можно спокойно ограничить%ся усовершенствованием алгоритма, порождающего начальные серии.Естественно, мы сразу ограничим наш выбор логарифмическими методами сортировки массивов. Самый подходящий здесь метод – турнирная сортировка,или HeapSort (см. раздел 2.3.2). Используемую там пирамиду можно считать фильтром, сквозь который должны пройти все элементы – одни быстрее, другие медленнее. Наименьший ключ берется непосредственно с вершины пирамиды,а его замещение является очень эффективной процедурой. Фильтрация элемента из последовательности%источника src (бегунок r0) сквозь всю пирамиду H в при%нимающую последовательность (бегунок r1) допускает следующее простое опи%сание:Write(r1, H[0]); Read(r0, H[0]); sift(0, n–1)Процедура sift описана в разделе 2.3.2, с ее помощью вновь вставленный эле%мент H0 просеивается вниз на свое правильное место. Заметим, что H0 является наименьшим элементом в пирамиде. Пример показан на рис. 2.17. В итоге про%грамма существенно усложняется по следующим причинам:1. Пирамида H вначале пуста и должна быть заполнена.2. Ближе к концу пирамида заполнена лишь частично, и в итоге она становит%ся пустой.3. Нужно отслеживать начало новых серий, чтобы в правильный момент из%менить индекс принимающей последовательности jПрежде чем продолжить, формально объявим переменные, которые заведомо нужны в процедуре:VAR L, R, x: INTEGER;src, dest: Files.File;r, w: Files.Rider;H: ARRAY M OF INTEGER; (* *)Константа M – размер пирамиды H. Константа mh будет использоваться для обозначения M/2; L и R суть индексы, ограничивающие пирамиду. Тогда процесс фильтрации разбивается на пять частей:1. Прочесть первые mh ключей из src (r) и записать их в верхнюю половину пирамиды, где упорядоченность ключей не требуется.2. Прочесть другую порцию mh ключей и записать их в нижнюю половину пи%рамиды, просеивая каждый из них в правильную позицию (построить пира%миду). 125 3. Установить L равным M и повторять следующий шаг для всех остальных элементов в src: переслать элемент H0 в последовательность%приемник.Если его ключ меньше или равен ключу следующего элемента в исходной последовательности, то этот следующий элемент принадлежит той же се%рии и может быть просеян в надлежащую позицию. В противном случае нужно уменьшить размер пирамиды и поместить новый элемент во вторую,верхнюю пирамиду, которая строится для следующей серии. Границу меж%ду двумя пирамидами указывает индекс L, так что нижняя (текущая) пи%рамида состоит из элементов H0 ... HL–1, а верхняя (следующая) – из HLHM–1. Если L = 0, то нужно переключить приемник и снова установить L рав%ным M4. Когда исходная последовательность исчерпана, нужно сначала установитьR равным M; затем «сбросить» нижнюю часть, чтобы закончить текущую се%рию, и одновременно строить верхнюю часть, постепенно перемещая ее в позиции HL ... HR–1 5. Последняя серия генерируется из элементов, оставшихся в пирамиде.Теперь можно в деталях выписать все пять частей в виде полной программы,вызывающей процедуру switch каждый раз, когда обнаружен конец серии и требу%ется некое действие для изменения индекса выходной последовательности. Вмес%то этого в приведенной ниже программе используется процедура%«затычка», а все серии направляются в последовательность destРис. 2.17. Просеивание ключа сквозь пирамидуСортировка последовательностей Сортировка126Если теперь попытаться объединить эту программу, например, с многофазной сортировкой, то возникает серьезная трудность: программа сортировки содержит в начальной части довольно сложную процедуру переключения между последо%вательностями и использует процедуру copyrun, которая пересылает в точности одну серию в выбранный приемник. С другой стороны, программа HeapSort слож%на и использует независимую процедуру select, которая просто выбирает новый приемник. Проблемы не было бы, если бы в одной (или обеих) программе нужная процедура вызывалась только в одном месте; но она вызывается в нескольких ме%стах в обеих программах.В таких случаях – то есть при совместном существовании нескольких процес%сов – лучше всего использовать сопрограммы. Наиболее типичной является ком%бинация процесса, производящего поток информации, состоящий из отдельных порций, и процесса, потребляющего этот поток. Эта связь типа производитель%потребитель может быть выражена с помощью двух сопрограмм; одной из них мо%жет даже быть сама главная программа. Сопрограмму можно рассматривать как процесс, который содержит одну или более точек прерывания (breakpoint). Когда встречается такая точка, управление возвращается в процедуру, вызвавшую со%программу. Когда сопрограмма вызывается снова, выполнение продолжается с той точки, где оно было прервано. В нашем примере мы можем рассматривать много%фазную сортировку как основную программу, вызывающую copyrun, которая оформлена как сопрограмма. Она состоит из главного тела приводимой ниже про%граммы, в которой каждый вызов процедуры switch теперь должен считаться точ%кой прерывания. Тогда проверку конца файла нужно всюду заменить проверкой того, достигла ли сопрограмма своего конца.PROCEDURE Distribute (src: Files.File): Files.File;(* ADruS24_MergeSorts *) CONST M = 16; mh = M DIV 2; (* *)VAR L, R: INTEGER;x: INTEGER;dest: Files.File;r, w: Files.Rider;H: ARRAY M OF INTEGER; (* *)PROCEDURE sift (L, R: INTEGER); (* *)VAR i, j, x: INTEGER;BEGINi := L; j := 2*L+1; x := H[i];IF (j < R) & (H[j] > H[j+1]) THEN INC(j) END;WHILE (j <= R) & (x > H[j]) DOH[i] := H[j]; i := j; j := 2*j+1;IF (j < R) & (H[j] > H[j+1]) THEN INC(j) ENDEND;H[i] := xEND sift;BEGINFiles.Set(r, src, 0); 127dest := Files.New(""); Files.Set(w, dest, 0);(*v # 1: *)L := M;REPEAT DEC(L); Files.ReadInt(r, H[L]) UNTIL L = mh;(*v # 2: € *)REPEAT DEC(L); Files.ReadInt(r, H[L]); sift(L, M–1) UNTIL L = 0;(*v # 3: *)L := M;Files.ReadInt(r, x);WHILE r.eof DOFiles.WriteInt(w, H[0]);IF H[0] <= x THEN(*x € € *) H[0] := x; sift(0, L–1)ELSE (* *)DEC(L); H[0] := H[L]; sift(0, L–1); H[L] := x;IF L < mh THEN sift(L, M–1) END;IF L = 0 THEN (* ; *) L := M ENDEND;Files.ReadInt(r, x)END;(*v # 4: € *)R := M;REPEATDEC(L); Files.WriteInt(w, H[0]);H[0] := H[L]; sift(0, L–1); DEC(R); H[L] := H[R];IF L < mh THEN sift(L, R–1) ENDUNTIL L = 0;(*v # 5: , *)WHILE R > 0 DOFiles.WriteInt(w, H[0]); H[0] := H[R]; DEC(R); sift(0, R)END;RETURN destEND DistributeАнализ и выводы. Какой производительности можно ожидать от многофазной сортировки, если распределение начальных серий выполняется с помощью алго%ритма HeapSort? Обсудим сначала, какого улучшения можно ожидать от введе%ния пирамиды.В последовательности со случайно распределенными ключами средняя длина серий равна 2. Чему равна эта длина после того, как последовательность профиль%трована через пирамиду размера m? Интуиция подсказывает ответ m, но, к счас%тью, результат вероятностного анализа гораздо лучше, а именно 2m (см. [2.7], раз%дел 5.4.1). Поэтому ожидается улучшение на фактор mПроизводительность многофазной сортировки можно оценить из табл. 2.15,где указано максимальное число начальных серий, которые можно отсортировать за заданное число частичных проходов (уровней) с заданным числом последова%тельностей N. Например, с шестью последовательностями и пирамидой размераСортировка последовательностей Сортировка128m = 100 файл, содержащий до 165’680’100 начальных серий, может быть отсорти%рован за 10 частичных проходов. Это замечательная производительность.Рассматривая комбинацию сортировок Polyphase и HeapSort, нельзя не удив%ляться сложности этой программы. Ведь она решает ту же легко формулируемую задачу перестановки элементов, которую решает и любой из простых алгоритмов сортировки массива.Мораль всей главы можно сформулировать так:1. Существует теснейшая связь между алгоритмом и стуктурой обрабатывае%мых данных, и эта структура влияет на алгоритм.2. Удается находить изощренные способы для повышения производительно%сти программы, даже если данные приходится организовывать в структуру,которая плохо подходит для решения задачи (последовательность вместо массива).Упражнения2.1.Какие из рассмотренных алгоритмов являются устойчивыми методами сор%тировки?2.2.Будет ли алгоритм для двоичных вставок работать корректно, если в опера%торе WHILE условие L < R заменить на L ≤ R? Останется ли он корректным,если оператор L := m+1 упростить до L := m? Если нет, то найти набор значе%ний a0 ... a n–1, на котором измененная программа сработает неправильно.2.3.Запрограммируйте и измерьте время выполнения на вашем компьютере трех простых методов сортировки и найдите коэффициенты, на которые нужно умножать факторы C и M, чтобы получались реальные оценки времени.2.4.Укажите инварианты циклов для трех простых алгоритмов сортировки.2.5.Рассмотрите следующую «очевидную» версию процедуры Partition и най%дите набор значений a0 ... a n–1, на котором эта версия не сработает:i := 0; j := n–1; x := a[n DIV 2];REPEATWHILE a[i] < x DO i := i+1 END;WHILE x < a[j] DO j := j–1 END;w := a[i]; a[i] := a[j]; a[j] := wUNTIL i > j2.6.Напишите процедуру, которая следующим образом комбинирует алгорит%мы QuickSort и BubbleSort: сначала QuickSort используется для получения(неотсортированных) сегментов длины m (1 < m < n); затем для заверше%ния сортировки используется BubbleSort. Заметим, что BubbleSort может проходить сразу по всему массиву из n элементов, чем минимизируются организационные расходы. Найдите значение m, при котором полное время сортировки минимизируется.Замечание. Ясно, что оптимальное значение m будет довольно мало. Поэто%му может быть выгодно в алгоритме BubbleSort сделать в точности m–1 про% 129ходов по массиву без использования последнего прохода, в котором прове%ряется, что обмены больше не нужны.2.7.Выполнить эксперимент из упражнения 2.6, используя сортировку простым выбором вместо BubbleSort. Естественно, сортировка выбором не может ра%ботать сразу со всем массивом, поэтому здесь работа с индексами потребует больше усилий.2.8.Напишите рекурсивный алгоритм быстрой сортировки так, чтобы сорти%ровка более короткого сегмента производилась до сортировки длинного.Первую из двух подзадач решайте с помощью итерации, для второй исполь%зуйте рекурсивный вызов. (Поэтому ваша процедура сортировки будет со%держать только один рекурсивный вызов вместо двух.)2.9.Найдите перестановку ключей 1, 2, ... , n, для которой алгоритм QuickSort демонстрирует наихудшее (наилучшее) поведение (n = 5, 6, 8).2.10. Постройте программу естественных слияний, которая подобно процедуре простых слияний работает с массивом двойной длины с обоих концов внутрь; сравните ее производительность с процедурой в тексте.2.11. Заметим, что в (двухпутевом) естественном слиянии, вместо того чтобы все%гда слепо выбирать наименьший из доступных для просмотра ключей, мы поступаем по%другому: когда обнаруживается конец одной из двух серий,хвост другой просто копируется в принимающую последовательность. На%пример, слияние последовательностей2, 4, 5, 1, 2, ...3, 6, 8, 9, 7, ...дает2, 3, 4, 5, 6, 8, 9, 1, 2, ...вместо последовательности2, 3, 4, 5, 1, 2, 6, 8, 9, ...которая кажется упорядоченной лучше. В чем причина выбора такой стра%тегии?2.12. Так называемое каскадное слияние (см. [2.1] и [2.7], раздел 5.4.3) – это ме%тод сортировки, похожий на многофазную сортировку. В нем используется другая схема слияний. Например, если даны шесть последовательностейT1T6, то каскадное слияние, тоже начинаясь с некоторого идеального рас%пределения серий на T1 ... T5, выполняет 5%путевое слияние из T1 ... T5 наT6, пока не будет исчерпана T5, затем (не трогая T6), 4%путевое слияние наT5, затем 3%путевое на T4, 2%путевое – на T3, и, наконец, копирование из T1на T2. Следующий проход работает аналогично, начиная с 5%путевого слия%ния на T1,и т. д. Хотя кажется, что такая схема будет хуже многофазной сортировки из%за того, что в ней некоторые последовательности иногда без%действуют, а также из%за использования операций простого копирования,она удивительным образом превосходит многофазную сортировку дляУпражнения Сортировка130(очень) больших файлов и в случае шести и более последовательностей. На%пишите хорошо структурированную программу на основе идеи каскадных слияний.Литература[2.1]Betz B. K. and Carter. Proc. ACM National Conf. 14, (1959), Paper 14.[2.2]Floyd R. W. Treesort (Algorithms 113 and 243). Comm. ACM, 5, No. 8, (1962),434, and Comm. ACM, 7, No. 12 (1964), 701.[2.3]Gilstad R. L. Polyphase Merge Sorting – An Advanced Technique. Proc. AFIPSEastern Jt. Comp. Conf., 18, (1960), 143–148.[2.4]Hoare C. A. R. Proof of a Program: FIND. Comm. ACM, 13, No. 1, (1970), 39–45.[2.5]Hoare C. A. R. Proof of a Recursive Program: Quicksort. Comp. J., 14, No. 4(1971), 391–395.[2.6]Hoare C. A. R. Quicksort. Comp. J., 5. No. 1 (1962), 10–15.[2.7]Knuth D. E. The Art of Computer Programming. Vol. 3. Reading, Mass.: Addi%son%Wesley, 1973 (имеется перевод: Кнут Д. Э. Искусство программирова%ния. 2%е изд. Т. 3. – М.: Вильямс, 2000).[2.8]Lorin H. A Guided Bibliography to Sorting. IBM Syst. J., 10, No. 3 (1971),244–254 (см. также Лорин Г. Сортировка и системы сортировки. – М.: На%ука, 1983).[2.9]Shell D. L. A Highspeed Sorting Procedure. Comm. ACM, 2, No. 7 (1959),30–32.[2.10] Singleton R. C. An Efficient Algorithm for Sorting with Minimal Storage (Algo%rithm 347). Comm. ACM, 12, No. 3 (1969), 185.[2.11] Van Emden M. H. Increasing the Efficiency of Quicksort (Algorithm 402).Comm. ACM, 13, No. 9 (1970), 563–566, 693.[2.12] Williams J. W. J. Heapsort (Algorithm 232) Comm. ACM, 7, No. 6 (1964),347–348. 1   ...   7   8   9   10   11   12   13   14   ...   22

Глава 3Рекурсивные алгоритмы3.1. Введение .......................... 132 3.2. Когда не следует использовать рекурсию ........... 134 3.3. Два примера рекурсивных программ ............ 137 3.4. Алгоритмы с возвратом .... 143 3.5. Задача о восьми ферзях ... 149 3.6. Задача о стабильных браках ...................................... 154 3.7. Задача оптимального выбора ..................................... 160Упражнения ............................. 164Литература .............................. 166 Рекурсивные алгоритмы1323.1. ВведениеОбъект называется рекурсивным, если его части определены через него самого.Рекурсия встречается не только в математике, но и в обычной жизни. Кто не видел рекламной картинки, которая содержит саму себя?Рис. 3.1. Рекурсивное изображениеРекурсия особенно хорошо являет свою мощь в математических определени%ях. Знакомые примеры – натуральные числа, древесные структуры и некоторые функции:1. Натуральные числа:(a) 0 является натуральным числом.(b) Число, следующее за натуральным, является натуральным.2. Древесные структуры:(a)∅ является деревом (и называется «пустым деревом»).(b) Если t1 и t2 – деревья, то конструкция, состоящая из узла с двумя по%томками t1 и t2, тоже является деревом (двоичным или бинарным).3. Факториальная функция f(n):f(0) = 1f(n) = n × f(n – 1) для n > 0Очевидно, мощь рекурсии заключается в возможности определить бесконеч%ное множество объектов с помощью конечного утверждения. Подобным же обра%зом бесконечное число расчетов может быть описано конечной рекурсивной программой, даже если программа не содержит явных циклов. Однако рекур%сивные алгоритмы уместны прежде всего тогда, когда решаемая проблема, вычис%ляемая функция или обрабатываемая структура данных заданы рекурсивным образом. В общем случае рекурсивная программа P может быть выражена как композиция PPPPP последовательности инструкций S (не содержащей P) и самой P:P ≡ PPPPP[S, P] 133Необходимое и достаточное средство для рекурсивной формулировки про%грамм – процедура, так как она позволяет дать набору инструкций имя, с помо%щью которого эти инструкции могут быть вызваны. Если процедура P содержит явную ссылку на саму себя, то говорят, что она явно рекурсивна; если P содержит ссылку на другую процедуру Q, которая содержит (прямую или косвенную) ссыл%ку на P, то говорят, что P косвенно рекурсивна. Последнее означает, что наличие рекурсии может быть не очевидно из текста программы.С процедурой обычно ассоциируется набор локальных переменных, констант,типов и процедур, которые определены как локальные в данной процедуре и не существуют и не имеют смысла вне ее. При каждой рекурсивной активации про%цедуры создается новый набор локальных переменных. Хотя у них те же имена,что и у переменных в предыдущей активации процедуры, их значения другие,и любая возможность конфликта устраняется правилами видимости идентифика%торов: идентификаторы всегда ссылаются на набор переменных, созданный по%следним. Такое же правило действует для параметров процедуры, которые по оп%ределению связаны с ней.Как и в случае операторов цикла, рекурсивные процедуры открывают возмож%ность бесконечных вычислений. Следовательно, необходимо рассматривать про%блему остановки. Очевидное фундаментальное требование состоит в том, чтобы рекурсивные вызовы процедуры P имели место лишь при выполнении условия B,которое в какой%то момент перестает выполняться. Поэтому схема рекурсивных алгоритмов точнее выражается одной из следующих форм:P ≡ IF B THEN PPPPP[S, P] ENDP ≡ PPPPP[S, IF B THEN P END]Основной метод доказательства остановки повторяющихся процессов состоит из следующих шагов:1) определяется целочисленная функция f(x) (где x – набор переменных) –такая, что из f(x) < 0 следует условие остановки (фигурирующее в операто%ре while или repeat);2) доказывается, что f(x) уменьшается на каждом шаге процесса.Аналогично доказывают прекращение рекурсии: достаточно показать, что каж%дая активация P уменьшает некоторую целочисленную функцию f(x) и что f(x) < 0влечет B. Особенно ясный способ гарантировать остановку состоит в том, чтобы ассоциировать передаваемый по значению параметр (назовем его n) с процедуройP, и рекурсивно вызывать P с n–1 в качестве значения этого параметра. Тогда, под%ставляя n > 0 вместо B, получаем гарантию прекращения. Это можно выразить следующими схемами:P(n) ≡ IF n > 0 THEN PPPPP[S, P(n–1)] ENDP(n) ≡ PPPPP[S, IF n > 0 THEN P(n–1) END]В практических приложениях нужно доказывать не только конечность глуби%ны рекурсии, но и что эта глубина достаточно мала. Причина в том, что при каж%дой рекурсивной активации процедуры P используется некоторый объем опера%Введение Рекурсивные алгоритмы134тивной памяти для размещения ее локальных переменных. Кроме того, нужно за%помнить текущее состояние вычислительного процесса, чтобы после окончания новой активации P могла быть возобновлена предыдущая. Мы уже встречали та%кую ситуацию в процедуре QuickSort в главе 2. Там было обнаружено, что при наивном построении программы из операции, которая разбивает n элементов на две части, и двух рекурсивных вызовов сортировки для двух частей глубина ре%курсии может в худшем случае приближаться к n. Внимательный анализ позво%лил ограничить глубину величиной порядка l og(n). Разница между n и log(n) дос%таточно существенна, чтобы превратить ситуацию, в которой рекурсия в высшей степени неуместна, в такую, где рекурсия становится вполне практичной.3.2. Когда не следует использоватьрекурсиюРекурсивные алгоритмы особенно хорошо подходят для тех ситуаций, когда ре%шаемая задача или обрабатываемые данные определены рекурсивно. Однако на%личие рекурсивного определения еще не означает, что рекурсивный алгоритм даст наилучшее решение. Именно попытки объяснять понятие рекурсивного ал%горитма с помощью неподходящих примеров стали главной причиной широко распространенного предубеждения против использования рекурсии в програм%мировании, а также мнения о неэффективности рекурсии.Программы, в которых следует избегать использования алгоритмической рекурсии, характеризуются определенной структурой. Для них характерно нали%чие единственного вызова P в конце (или в начале) композиции (так называемаяконцевая рекурсия):P ≡ IF B THEN S; P ENDP ≡ S; IF B THEN P ENDТакие схемы естественно возникают в тех случаях, когда вычисляемые значе%ния определяются простыми рекуррентными соотношениями. Возьмем извест%ный пример факториала fi = i!:i= 0, 1, 2, 3, 4, 5, ...f i= 1, 1, 2, 6, 24, 120, ...Первое значение определено явно: f0 = 1, а последующие – рекурсивно через предшествующие:f i+1 = (i+1) * f iЭто рекуррентное соотношение наводит на мысль использовать рекурсивный алгоритм для вычисления n%го факториала. Если ввести две переменные I и F для обозначения значений i и fi на i%м уровне рекурсии, то переход к следующим чле%нам пары последовательностей для i и fi требует такого вычисления:I := I + 1; F := I * F 135Подставляя эту пару инструкций вместо S, получаем рекурсивную программуP ≡ IF I < n THEN I := I + 1; F := I * F; P ENDI := 0; F := 1; PВ принятой нами нотации первая строка выражается следующим образом:PROCEDURE P;BEGINIF I < n THEN I := I + 1; F := I*F; P ENDEND PЧаще используется эквивалентная форма, данная ниже. P заменяется процеду%рой%функцией F, то есть процедурой, с которой явно ассоциируется вычисляемое значение и которая может поэтому быть использована как непосредственная со%ставная часть выражений. Тогда переменная F становится лишней, а роль I берет на себя явно задаваемый параметр процедуры:PROCEDURE F(I: INTEGER): INTEGER;BEGINIF I > 0 THEN RETURN I * F(I – 1) ELSE RETURN 1 ENDEND FЯсно, что в этом примере рекурсия может быть довольно легко заменена итера%цией. Это выражается следующей программой:I := 0; F := 1;WHILE I < n DO I := I + 1; F := I*F ENDВ общем случае программы, построенные по обсуждаемым частным рекурсив%ным схемам, следует переписывать в соответствии со следующим образцом:P ≡ [x := x0; WHILE B DO S END]Существуют и более сложные рекурсивные композиционные схемы, которые могут и должны приводиться к итеративному виду. Пример – вычисление чиселФибоначчи, определенных рекуррентным соотношением fib n+1 = fib n + fib n–1для n > 0и соотношениями fib1 = 1, fib0 = 0. Непосредственный наивный перевод на язык программирования дает следующую рекурсивную программу:PROCEDURE Fib (n: INTEGER): INTEGER;VAR res: INTEGER;BEGINIF n = 0 THEN res := 0ELSIF n = 1 THEN res := 1ELSE res := Fib(n–1) + Fib(n–2)END;RETURN resEND FibКогда не следует использовать рекурсию Рекурсивные алгоритмы136Вычисление fib n с помощью вызова Fib(n) вызывает рекурсивные активации этой процедуры%функции. Сколько происходит таких активаций? Очевидно, каж%дый вызов с n > 1 приводит к двум дальнейшим вызовам, то есть полное число вы%зовов растет экспоненциально (см. рис. 3.2). Такая программа явно непрактична.Рис. 3.2. Пятнадцать активаций при вызове Fib(5)К счастью, числа Фибоначчи можно вычислять по итерационной схеме без многократного вычисления одних и тех же значений благодаря использованию вспомогательных переменных – таких, что x = fib i и y = fib i–1i := 1; x := 1; y := 0;WHILE i < n DO z := x; x := x + y; y := z; i := i + 1 ENDОтметим, что три присваивания переменным x, y, z можно заменить всего лишь двумя присваиваниями без привлечения вспомогательной переменной z: x := x + y;y := x – yОтсюда мораль: следует избегать рекурсии, когда есть очевидное решение,использующее итерацию. Но это не значит, что от рекурсии нужно избавляться любой ценой. Как будет показано в последующих разделах и главах, существует много хороших применений рекурсии. Тот факт, что имеются реализации рекур%сивных процедур на принципиально нерекурсивных машинах, доказывает, что любая рекурсивная программа действительно может быть преобразована в чисто итерационную. Но тогда требуется явно управлять стеком рекурсии, и это часто затемняет сущность программы до такой степени, что понять ее становится весь%ма трудно. Отсюда вывод: алгоритмы, которые по своей природе являются рекур%сивными, а не итерационными, должны программироваться в виде рекурсивных процедур. Чтобы оценить это обстоятельство, полезно сравнить два варианта ал%горитма быстрой сортировки в разделе 2.3.3: рекурсивный (QuickSort) и нерекур%сивный (NonRecursiveQuickSort).Оставшаяся часть главы посвящена разработке некоторых рекурсивных про%грамм в ситуациях, когда применение рекурсии оправдано. Кроме того, в главе 4рекурсия широко используется в тех случаях, когда соответствующие структуры данных делают выбор рекурсивных решений очевидным и естественным. 1373.3. Два примера рекурсивных программСимпатичный узор на рис. 3.4 представляет собой суперпозицию пяти кривых.Эти кривые являют регулярность структуры, так что их, вероятно, можно изобра%зить на дисплее или графопостроителе под управлением компьютера. Наша цель –выявить рекурсивную схему, с помощью которой можно написать программу для рисования этих кривых. Можно видеть, что три из пяти кривых имеют вид, пока%занный на рис. 3.3; обозначим их как H1, H2 и H3. Кривая Hi называется гильбертовой кривой порядка i в честь математика Гильберта (D. Hilbert, 1891).Рис. 3.3. Гильбертовы кривые порядков 1, 2 и 3Каждая кривая Hi состоит из четырех копий кривой Hi–1 половинного размера,поэтому мы выразим процедуру рисования Hi в виде композиции четырех вызовов для рисования Hi–1 половинного размера и с соответствующими поворотами. Для целей иллюстрации обозначим четыре по%разному повернутых варианта базовой кривой как A, B, C и D, а шаги рисования соединительных линий обозначим стрел%ками, направленными соответственно. Тогда возникает следующая рекурсивная схема (ср. рис. 3.3):A:D←A↓A→BB:C↑B→B↓AC:B→C↑C←DD:A↓D←D↑CПредположим, что для рисования отрезков прямых в нашем распоряжении есть процедура line, которая передвигает чертящее перо в заданном направлении на заданное расстояние. Для удобства примем, что направление указывается целочисленным параметром i, так что в градусах оно равно 45 × i. Если длину от%резков, из которых составляется кривая, обозначить как u, то процедуру, соответ%ствующую схеме A, можно сразу выразить через рекурсивные вызовы аналогич%ных процедур B и D и ее самой:PROCEDURE A (i: INTEGER);BEGINIF i > 0 THEND(i–1); line(4, u);A(i–1); line(6, u);Два примера рекурсивных программ Рекурсивные алгоритмы138A(i–1); line(0, u);B(i–1)ENDEND AЭта процедура вызывается в главной программе один раз для каждой гильбер%товой кривой, добавляемой в рисунок. Главная программа определяет начальную точку кривой, то есть начальные координаты пера, обозначенные как x0 и y0,а также длину базового отрезка u. Квадрат, в котором рисуются кривые, помеща%ется в середине страницы с заданными шириной и высотой. Эти параметры, так же как и рисующая процедура line, берутся из модуля Draw. Отметим, что этот модуль помнит текущее положение пера.DEFINITION Draw;(* ADruS33_Draw *)CONST width = 1024; height = 800;PROCEDURE Clear; (* *)PROCEDURE SetPen(x, y: INTEGER); (* x, y*)PROCEDURE line(dir, len: INTEGER);(* len dir*45 # ;(* # *)END Draw.Процедура Hilbert рисует гильбертовы кривые H1 ... Hn. Она рекурсивно использует четыре процедуры A, B, C и D:VAR u: INTEGER;(* ADruS33_Hilbert *)PROCEDURE A (i: INTEGER);BEGINIF i > 0 THEND(i–1); Draw.line(4, u); A(i–1); Draw.line(6, u); A(i–1); Draw.line(0, u); B(i–1)ENDEND A;PROCEDURE B (i: INTEGER);BEGINIF i > 0 THENC(i–1); Draw.line(2, u); B(i–1); Draw.line(0, u); B(i–1); Draw.line(6, u); A(i–1)ENDEND B;PROCEDURE C (i: INTEGER);BEGINIF i > 0 THENB(i–1); Draw.line(0, u); C(i–1); Draw.line(2, u); C(i–1); Draw.line(4, u); D(i–1)ENDEND C;PROCEDURE D (i: INTEGER);BEGINIF i > 0 THENA(i–1); Draw.line(6, u); D(i–1); Draw.line(4, u); D(i–1); Draw.line(2, u); C(i–1)ENDEND D; 139PROCEDURE Hilbert (n: INTEGER);CONST SquareSize = 512;VAR i, x0, y0: INTEGER;BEGINDraw.Clear;x0 := Draw.width DIV 2; y0 := Draw.height DIV 2;u := SquareSize; i := 0;REPEATINC(i); u := u DIV 2;x0 := x0 + (u DIV 2); y0 := y0 + (u DIV 2);Draw.Set(x0, y0);A(i)UNTIL i = nEND Hilbert.Похожий, но чуть более сложный и эстетически изощренный пример показан на рис. 3.6. Этот узор тоже получается наложением нескольких кривых, две из ко%торых показаны на рис. 3.5. Si называется кривой Серпиньского порядка i. Какова ее рекурсивная структура? Есть соблазн в качестве основного строительного бло%ка взять фигуру S1, возможно, без одного ребра. Но так решение не получится.Главное отличие кривых Серпиньского от кривых Гильберта – в том, что первые замкнуты (и не имеют самопересечений). Это означает, что базовой рекурсивной схемой должна быть разомкнутая кривая и что четыре части соединяются связка%ми, не принадлежащими самому рекурсивному узору. В самом деле, эти связки состоят из четырех отрезков прямых в четырех самых внешних углах, показанных жирными линиями на рис. 3.5. Их можно считать принадлежащими непустой на%чальной кривой S0, представляющей собой квадрат, стоящий на одном из углов.Теперь легко сформулировать рекурсивную схему. Четыре узора, из которых со%ставляется кривая, снова обозначим как A, B, C и D, а линии%связки будем рисовать явно. Заметим, что четыре рекурсивных узора действительно идентичны, отлича%ясь поворотами на 90 градусов.Вот базовая схема кривых Серпиньского:S: A B C D А вот схема рекурсий (горизонтальные и вертикальные стрелки обозначают линии двойной длины):A: A B → D AB: B C ↓ A BC: C D ← B CD: D A ↑ C DЕсли использовать те же примитивы рисования, что и в примере с кривымиГильберта, то эта схема рекурсии легко превращается в рекурсивный алгоритм(с прямой и косвенной рекурсиями).Два примера рекурсивных программ Рекурсивные алгоритмы140Рис. 3.4. Гильбертовы кривые H1 … H5Рис. 3.5. Кривые Серпиньского S1 и S2 141PROCEDURE A (k: INTEGER);BEGINIF k > 0 THENA(k–1); Draw.line(7, h); B(k–1); Draw.line(0, 2*h);D(k–1); Draw.line(1, h); A(k–1)ENDEND AЭта процедура реализует первую строку схемы рекурсий. Процедуры для узо%ров B, C и D получаются аналогично. Главная программа составляется по базовой схеме. Ее назначение – установить начальное положение пера и определить длину единичной линии h в соответствии с размером рисунка. Результат выполнения этой программы для n = 4 показан на рис. 3.6.VAR h: INTEGER;(* ADruS33_Sierpinski *)PROCEDURE A (k: INTEGER);BEGINIF k > 0 THENA(k–1); Draw.line(7, h); B(k–1); Draw.line(0, 2*h);D(k–1); Draw.line(1, h); A(k–1)ENDEND A;PROCEDURE B (k: INTEGER);BEGINIF k > 0 THENB(k–1); Draw.line(5, h); C(k–1); Draw.line(6, 2*h);A(k–1); Draw.line(7, h); B(k–1)ENDEND B;PROCEDURE C (k: INTEGER);BEGINIF k > 0 THENC(k–1); Draw.line(3, h); D(k–1); Draw.line(4, 2*h);B(k–1); Draw.line(5, h); C(k–1)ENDEND C;PROCEDURE D (k: INTEGER);BEGINIF k > 0 THEND(k–1); Draw.line(1, h); A(k–1); Draw.line(2, 2*h);C(k–1); Draw.line(3, h); D(k–1)ENDEND D;PROCEDURE Sierpinski* (n: INTEGER);CONST SquareSize = 512;VAR i, x0, y0: INTEGER;BEGINДва примера рекурсивных программ Рекурсивные алгоритмы142Draw.Clear;h := SquareSize DIV 4;x0 := Draw.width DIV 2; y0 := Draw.height DIV 2 + h;i := 0;REPEATINC(i); x0 := x0-h;h := h DIV 2; y0 := y0+h; Draw.Set(x0, y0);A(i); Draw.line(7,h); B(i); Draw.line(5,h);C(i); Draw.line(3,h); D(i); Draw.line(1,h)UNTIL i = nEND Sierpinski.Элегантность приведенных примеров убеждает в полезности рекурсии. Пра%вильность получившихся программ легко установить по их структуре и по схемам композиции. Более того, использование явного (и уменьшающегося) параметра уровня гарантирует остановку, так как глубина рекурсии не может превысить nНапротив, эквивалентные программы, не использующие рекурсию явно, оказыва%ются весьма громоздкими, и понять их нелегко. Читатель легко убедится в этом,если попытается разобраться в программах, приведенных в [3.3].Рис. 3.6. Кривые Серпиньского S1 … S4 1431   ...   8   9   10   11   12   13   14   15   ...   22

3.4. Алгоритмы с возвратомВесьма интригующее направление в программировании – поиск общих методов решения сложных зачач. Цель здесь в том, чтобы научиться искать решения конк%ретных задач, не следуя какому%то фиксированному правилу вычислений, а мето%дом проб и ошибок. Общая схема заключается в том, чтобы свести процесс проб и ошибок к нескольким частным задачам. Эти задачи часто допускают очень естест%венное рекурсивное описание и сводятся к исследованию конечного числа подза%дач. Процесс в целом можно представлять себе как поиск%исследование, в ко%тором постепенно строится и просматривается (с обрезанием каких%то ветвей)некое дерево подзадач. Во многих задачах такое дерево поиска растет очень быст%ро, часто экспоненциально, как функция некоторого параметра. Трудоемкость поиска растет соответственно. Часто только использование эвристик позволяет обрезать дерево поиска до такой степени, чтобы сделать вычисление сколь%ни%будь реалистичным.Обсуждение общих эвристических правил не входит в наши цели. Мы сосредо%точимся в этой главе на общем принципе разбиения задач на подзадачи с приме%нением рекурсии. Начнем с демонстрации соответствующей техники в простом примере, а именно в хорошо известной задаче о путешествии шахматного коня.Пусть дана доска n × n с n2полями. Конь, который передвигается по шахмат%ным правилам, ставится на доске в поле , y0>. Задача – обойти всю доску, если это возможно, то есть вычислить такой маршрут из n2–1 ходов, чтобы в каждое поле доски конь попал ровно один раз.Очевидный способ упростить задачу обхода n2 полей – рассмотреть подзадачу,которая состоит в том, чтобы либо выполнить какой%либо очередной ход, либо обнаружить, что дальнейшие ходы невозможны. Эту идею можно выразить так:PROCEDURE TryNextMove; (* *)BEGINIF THEN ;WHILE ( v ) & ( € v # )DO ENDENDEND TryNextMove;Предикат € v # удобно выразить в виде про%цедуры%функции с логическим значением, в которой – раз уж мы собираемся за%писывать порождаемую последовательность ходов – подходящее место как для записи очередного хода, так и для ее отмены в случае неудачи, так как именно в этой процедуре выясняется успех завершения обхода.PROCEDURE CanBeDone ( ): BOOLEAN;BEGIN ;Алгоритмы с возвратом Рекурсивные алгоритмы144TryNextMove;IF THEN END;RETURN END CanBeDoneЗдесь уже видна схема рекурсии.Чтобы уточнить этот алгоритм, необходимо принять некоторые решения о пред%ставлении данных. Во%первых, мы хотели бы записать полную историю ходов.Поэтому каждый ход будем характеризовать тремя числами: его номером i и дву%мя координатами . Эту связь можно было бы выразить, введя специальный тип записей с тремя полями, но данная задача слишком проста, чтобы оправдать соответствующие накладные расходы; будет достаточно отслеживать соответствую%щие тройки переменных.Это сразу позволяет выбрать подходящие параметры для процедуры TryNextMoveОни должны позволять определить начальные условия для очередного хода, а так%же сообщать о его успешности. Для достижения первой цели достаточно указы%вать параметры предыдущего хода, то есть координаты поля x, y и его номер i. Для достижения второй цели нужен булевский параметр%результат со значением - v v . Получается следующая сигнатура:PROCEDURE TryNextMove (x, y, i: INTEGER; VAR done: BOOLEAN)Далее, очередной допустимый ход должен иметь номер i+1. Для его координат введем пару переменных u, v. Это позволяет выразить предикат € - v # , используемый в цикле линейного поиска, в виде вызова процедуры%функции со следующей сигнатурой:PROCEDURE CanBeDone (u, v, i1: INTEGER): BOOLEANУсловие может быть выражено как i < n2. А для условия v введем логическую переменную eos. Тогда логика алгоритма проясняется следующим образом:PROCEDURE TryNextMove (x, y, i: INTEGER; VAR done: BOOLEAN);VAR eos: BOOLEAN; u, v: INTEGER;BEGINIF i < n2 THEN ;WHILE eos & CanBeDone(u, v, i+1) DO END;done := eosELSEdone := TRUEENDEND TryNextMove; 145PROCEDURE CanBeDone (u, v, i1: INTEGER): BOOLEAN;VAR done: BOOLEAN;BEGIN ;TryNextMove(u, v, i1, done);IF done THEN END;RETURN doneEND CanBeDoneЗаметим, что процедура TryNextMove сформулирована так, чтобы корректно об%рабатывать и вырожденный случай, когда после хода x, y, i выясняется, что доска заполнена. Это сделано по той же, в сущности, причине, по которой арифметиче%ские операции определяются так, чтобы корректно обрабатывать нулевые значения операндов: удобство и надежность. Если (как нередко делают из соображений оп%тимизации) вынести такую проверку из процедуры, то каждый вызов процедуры придется сопровождать такой охраной – или доказывать, что охрана в конкретной точке программы не нужна. К подобным оптимизациям следует прибегать, только если их необходимость доказана – после построения корректного алгоритма.Следующее очевидное решение – представить доску матрицей, скажем h:VAR h: ARRAY n, n OF INTEGERРешение сопоставить каждому полю доски целое, а не булевское значение,которое бы просто отмечало, занято поле или нет, объясняется желанием сохра%нить полную историю ходов простейшим способом:h[x, y] = 0:поле еще не пройдено h[x, y] = i:поле пройдено на i%м ходу (0 < i ≤ n2)Очевидно, запись допустимого хода теперь выражается присваиванием hxy := i,а отмена – hxy := 0, чем завершается построение процедуры CanBeDoneОсталось организовать перебор допустимых ходов u, v из заданной позиции x, y в цикле поиска процедуры TryNextMove. На бесконечной во все стороны доске для каждой позиции x, y есть несколько ходов%кандидатов u, v, которые пока конкретизировать нет нужды (см., однако, рис. 3.7). Предикат для выбора допустимых ходов среди ходов%кандидатов выражается как логическая конъюнк%ция условий, описывающих, что новое поле лежит в пределах доски, то есть0 ≤ u < n и 0 ≤ v < n, и что конь по нему еще не проходил, то есть huv = 0. Деталь,которую нельзя упустить: переменная huv существует, только если оба значения u и v лежат в диапазоне 0 ... n–1. Поэтому важно, чтобы член huv = 0 стоял после%дним. В итоге выбор следующего допустимого хода тогда представляется уже зна%комой схемой линейного поиска (только выраженной через цикл repeat вместо while,что в данном случае возможно и удобно). При этом для сообщения об исчер%пании множества ходов%кандидатов можно использовать переменную eos. Офор%мим эту операцию в виде процедуры Next, явно указав в качестве параметров зна%чимые переменные:Алгоритмы с возвратом Рекурсивные алгоритмы146PROCEDURE Next (VAR eos: BOOLEAN; VAR u, v: INTEGER);BEGIN(*eos*)REPEAT - u, vUNTIL ( v ) OR((0 <= u) & (u < n) & (0 <= v) & (v < n) & (h[u, v] = 0));eos := v END Next;Инициализация перебора ходов%кандидатов выполняется внутри аналогич%ной процедуры First, порождающей первый допустимый ход; см. детали в оконча%тельной программе, приводимой ниже.Остался только один шаг уточнения, и мы получим программу, полностью выраженную в нашей основной нотации. Заметим, что до сих пор программа раз%рабатывалась совершенно независимо от правил, описывающих допустимые хо%ды коня. Мы сознательно откладывали рассмотрение таких деталей задачи. Но теперь пора их учесть.Для начальной пары координат x,y на бесконечной свободной доске есть восемь позиций%кандидатов u,v,куда может прыгнуть конь. На рис. 3.7 они пронумеро%ваны от 1 до 8.Простой способ получить u,v из x,y состоит в при%бавлении разностей координат, хранящихся либо в мас%сиве пар разностей, либо в двух массивах одиночных разностей. Пусть эти массивы обозначены как dx и dy иправильно инициализированы:dx = (2, 1, –1, –2, –2, –1, 1, 2)dy = (1, 2, 2, 1, –1, –2, –2, –1)Тогда можно использовать индекс k для нумерации очередного хода%кандидата. Детали показаны в программе, приводимой ниже.Мы предполагаем наличие глобальной матрицы h размера n × n, представляю%щей результат, константы n (и nsqr = n2), а также массивов dx и dy, представля%ющих возможные ходы коня без ограничений (см. рис. 3.7). Рекурсивная проце%дура стартует с параметрами x0, y0 – координатами того поля, с которого должно начаться путешествие коня. В это поле должен быть записан номер 1; все прочие поля следует пометить как свободные.VAR h: ARRAY n, n OF INTEGER;(* ADruS34_KnightsTour *)dx, dy: ARRAY 8 OF INTEGER;PROCEDURE CanBeDone (u, v, i: INTEGER): BOOLEAN;VAR done: BOOLEAN;BEGINh[u, v] := i;TryNextMove(u, v, i, done);IF done THEN h[u, v] := 0 END;Рис. 3.7. Восемь возможных ходов коня 147RETURN doneEND CanBeDone;PROCEDURE TryNextMove (x, y, i: INTEGER; VAR done: BOOLEAN);VAR eos: BOOLEAN; u, v: INTEGER; k: INTEGER;PROCEDURE Next (VAR eos: BOOLEAN; VAR u, v: INTEGER);BEGINREPEATINC(k);IF k < 8 THEN u := x + dx[k]; v := y + dy[k] END;UNTIL (k = 8) OR ((0 <= u) & (u < n) & (0 <= v) & (v < n) & (h[u, v] = 0));eos := (k = 8)END Next;PROCEDURE First (VAR eos: BOOLEAN; VAR u, v: INTEGER);BEGINeos := FALSE; k := –1; Next(eos, u, v)END First;BEGINIF i < nsqr THENFirst(eos, u, v);WHILE eos & CanBeDone(u, v, i+1) DONext(eos, u, v)END;done := eosELSEdone := TRUEEND;END TryNextMove;PROCEDURE Clear;VAR i, j: INTEGER;BEGINFOR i := 0 TO n–1 DOFOR j := 0 TO n–1 DO h[i,j] := 0 ENDENDEND Clear;PROCEDURE KnightsTour (x0, y0: INTEGER; VAR done: BOOLEAN);BEGINClear; h[x0,y0] := 1; TryNextMove(x0, y0, 1, done);END KnightsTour;Таблица 3.1 показывает решения, полученные для начальных позиций <2,2>,<1,3> для n = 5 и <0,0> для n = 6Какие общие уроки можно извлечь из этого примера? Видна ли в нем какая%либо схема, типичная для алгоритмов, решающих подобные задачи? Чему он нас учит? Характерной чертой здесь является то, что каждый шаг, выполняемый в попытке приблизиться к полному решению, запоминается таким образом, чтобыАлгоритмы с возвратом Рекурсивные алгоритмы148от него можно было позднее отказаться, если выяснится, что он не может привес%ти к полному решению и заводит в тупик. Такое действие называется возвратом(backtracking). Общая схема, приводимая ниже, абстрагирована из процедурыTryNextMove в предположении, что число потенциальных кандидатов на каждом шаге конечно:PROCEDURE Try; (* v *)BEGINIF v THEN v # ;WHILE (v # v ) & CanBeDone( v #) DO v #ENDENDEND Try;PROCEDURE CanBeDone ( v # ): BOOLEAN;(* v € , € # v #*)BEGIN v #;Try;IF v THEN v # END;RETURN v END CanBeDoneРазумеется, в реальных программах эта схема может варьироваться. В частно%сти, в зависимости от специфики задачи может варьироваться способ передачи информации в процедуру Try при каждом очередном ее вызове. Ведь в обсуж%даемой схеме предполагается, что эта процедура имеет доступ к глобальным пе%ременным, в которых записывается выстраиваемое решение и, следовательно,содержится, в принципе, полная информация о текущем шаге построения. Напри%Таблица 3.1.Таблица 3.1.Таблица 3.1.Таблица 3.1.Таблица 3.1. Три возможных обхода конем23 49 14 25 10 15 24 18 522 318 13 16 11 20 72 21 617 12 19 116 726 11 14 34 25 12 15 627 17 233 813 10 32 35 24 21 28 523 18 330 920 36 31 22 19 429 23 10 15 425 16 524 914 11 22 118 36 17 20 13 821 12 72 19 149мер, в рассмотренной задаче о путешествии коня в процедуре TryNextMove нужно знать последнюю позицию коня на доске. Ее можно было бы найти поиском в мас%сиве h. Однако эта информация явно наличествует в момент вызова процедуры,и гораздо проще ее туда передать через параметры. В дальнейших примерах мы увидим вариации на эту тему.Отметим, что условие поиска в цикле оформлено в виде процедуры%функцииCanBeDone для максимального прояснения логики алгоритма без потери обозри%мости программы. Разумеется, можно оптимизировать программу в других отно%шениях, проведя эквивалентные преобразования. Например, можно избавиться от двух процедур First и Next, слив два легко верифицируемых цикла в один. Этот единственный цикл будет, вообще говоря, более сложным, однако в том случае,когда требуется сгенерировать все решения, может получиться довольно прозрач%ный результат (см. последнюю программу в следующем разделе).Остаток этой главы посвящен разбору еще трех примеров, в которых уместна рекурсия. В них демонстрируются разные реализации описанной общей схемы.3.5. Задача о восьми ферзяхЗадача о восьми ферзях – хорошо известный пример использования метода проб и ошибок и алгоритмов с возвратом. Ее исследовал Гаусс в 1850 г., но он не нашел полного решения. Это и неудивительно, ведь для таких задач характерно отсут%ствие аналитических решений. Вместо этого приходится полагаться на огромный труд, терпение и точность. Поэтому подобные алгоритмы стали применяться почти исключительно благодаря появлению автоматического компьютера, который обла%дает этими качествами в гораздо большей степени, чем люди и даже чем гении.В этой задаче (см. также [3.4]) требуется расположить на шахматной доске во%семь ферзей так, чтобы ни один из них не угрожал другому. Будем следовать об%щей схеме, представленной в конце раздела 3.4. По правилам шахмат ферзь угро%жает всем фигурам, находящимся на одной с ним вертикали, горизонтали или диагонали доски, поэтому мы заключаем, что на каждой вертикали может нахо%диться один и только один ферзь. Поэтому можно пронумеровать ферзей по зани%маемым ими вертикалям, так что i%й ферзь стоит на i%й вертикали. Очередным шагом построения в общей рекурсивной схеме будем считать размещение очеред%ного ферзя в порядке их номеров. В отличие от задачи о путешествии коня, здесь нужно будет знать положение всех уже размещенных ферзей. Поэтому в качестве параметра в процедуру Try достаточно передавать номер размещаемого на этом шаге ферзя i, который, таким образом, является номером столбца. Тогда опреде%лить положение ферзя – значит выбрать одно из восьми значений номера ряда jPROCEDURE Try (i: INTEGER);BEGINIF i < 8 THEN j ;Задача о восьми ферзях Рекурсивные алгоритмы150WHILE (v ) & CanBeDone(i, j) DO jENDENDEND Try;PROCEDURE CanBeDone (i, j: INTEGER): BOOLEAN;(* v € , i-# ! j- *)BEGIN ! ;Try(i+1);IF v THEN ! END;RETURN v END CanBeDoneЧтобы двигаться дальше, нужно решить, как представлять данные. Напраши%вается представление доски с помощью квадратной матрицы, но небольшое раз%мышление показывает, что тогда действия по проверке безопасности позиций по%лучатся довольно громоздкими. Это крайне нежелательно, так как это самая часто выполняемая операция. Поэтому мы должны представить данные так, чтобы эта проверка была как можно проще. Лучший путь к этой цели – как можно более непосредственно представить именно ту информацию, которая конкретно нужна и чаще всего используется. В нашем случае это не положение ферзей, а информа%ция о том, был ли уже поставлен ферзь на каждый из рядов и на каждую из диаго%налей. (Мы уже знаем, что в каждом столбце k для 0≤ k < i стоит в точности один ферзь.) Это приводит к такому выбору переменных:VAR x: ARRAY 8 OF INTEGER;a: ARRAY 8 OF BOOLEAN;b, c: ARRAY 15 OF BOOLEANгде xi означает положение ферзя в i%м столбце;a j означает, что «в j%м ряду ферзя еще нет»;b k означает, что «на k%й /- диагонали нет ферзя»;c k означает, что «на k%й \- диагонали нет ферзя».Заметим, что все поля на /%диагонали имеют одинаковую сумму своих коорди%нат i и j, а на \%диагонали – одинаковую разность координат i-j. Соответствующая нумерация диагоналей использована в приведенной ниже программе QueensС такими определениями операция ! раскрывается следующим образом:x[i] := j; a[j] := FALSE; b[i+j] := FALSE; c[i-j+7] := FALSEоперация ! уточняется в a[j] := TRUE; b[i+j] := TRUE; c[i-j+7] := TRUE 151Поле безопасно, если оно находится в строке и на диагоналях, которые еще свободны. Поэтому ему соответствует логическое выражение a[j] & b[i+j] & c[i-j+7]Это позволяет построить процедуры перечисления безопасных значений j для i%го ферзя по аналогии с предыдущим примером.Этим, в сущности, завершается разработка алгоритма, представленного цели%ком ниже в виде программы Queens. Она вычисляет решение x = (0, 4, 7, 5, 2, 6,1, 3), показанное на рис. 3.8.Рис. 3.8. Одно из решений задачи о восьми ферзяхPROCEDURE Try (i: INTEGER; VAR done: BOOLEAN);(* ADruS35_Queens *)VAR eos: BOOLEAN; j: INTEGER;PROCEDURE Next;BEGINREPEAT INC(j);UNTIL (j = 8) OR (a[j] & b[i+j] & c[i-j+7]);eos := (j = 8)END Next;PROCEDURE First;BEGINeos := FALSE; j := –1; NextEND First;BEGINIF i < 8 THENFirst;WHILE eos & CanBeDone(i, j) DONextЗадача о восьми ферзях Рекурсивные алгоритмы152END;done := eosELSEdone := TRUEENDEND Try;PROCEDURE CanBeDone (i, j: INTEGER): BOOLEAN;(* v € , i-# ! j- *)VAR done: BOOLEAN;BEGINx[i] := j; a[j] := FALSE; b[i+j] := FALSE; c[i-j+7] := FALSE;Try(i+1, done);IF done THENx[i] := –1; a[j] := TRUE; b[i+j] := TRUE; c[i-j+7] := TRUEEND;RETURN doneEND CanBeDone;PROCEDURE Queens*;VAR done: BOOLEAN; i, j: INTEGER; (* # W*)BEGINFOR i := 0 TO 7 DO a[i] := TRUE; x[i] := –1 END;FOR i := 0 TO 14 DO b[i] := TRUE; c[i] := TRUE END;Try(0, done);IF done THENFOR i := 0 TO 7 DO Texts.WriteInt(W, x[ i ], 4) END;Texts.WriteLn(W)ENDEND Queens.Прежде чем закрыть шахматную тему, покажем на примере задачи о восьми ферзях важную модификацию такого поиска методом проб и ошибок. Цель моди%фикации – в том, чтобы найти не одно, а все решения задачи.Выполнить такую модификацию легко. Нужно вспомнить, что кандидаты дол%жны порождаться систематическим образом, так чтобы ни один кандидат не по%рождался больше одного раза. Это соответствует систематическому поиску по де%реву кандидатов, при котором каждый узел проходится в точности один раз. При такой организации после нахождения и печати решения можно просто перейти к следующему кандидату, доставляемому систематическим процессом порожде%ния. Формально модификация осуществляется переносом процедуры%функцииCanBeDone из охраны цикла в его тело и подстановкой тела процедуры вместо ее вызова. При этом нужно учесть, что возвращать логические значения больше не нужно. Получается такая общая рекурсивная схема:PROCEDURE Try;BEGINIF v THEN v # ; 153WHILE (v # v ) DO v #;Try; # v # v #ENDELSE v ENDEND TryИнтересно, что поиск всех возможных решений реализуется более простой программой, чем поиск единственного решения.В задаче о восьми ферзях возможно еще более заметное упрощение. В самом деле, несколько громоздкий механизм перечисления допустимых шагов, состоя%щий из двух процедур First и Next, был нужен для взаимной изоляции цикла линейного поиска очередного безопасного поля (цикл по j внутри Next) и цикла линейного поиска первого j, дающего полное решение. Теперь, благодаря упро%щению охраны последнего цикла, нужда в этом отпала и его можно заменить про%стейшим циклом по j, просто отбирая безопасные j с помощью условного операто%ра IF, непосредственно вложенного в цикл, без использования дополнительных процедур.Так модифицированный алгоритм определения всех 92 решений задачи о восьми ферзях показан ниже. На самом деле есть только 12 существенно различ%ных решений, но наша программа не распознает симметричные решения. Первые12 порождаемых здесь решений выписаны в табл. 3.2. Колонка n справа показы%вает число выполнений проверки безопасности позиций.Среднее значение часто%ты по всем 92 решениям равно 161.PROCEDURE write;(* ADruS35_Queens *)VAR k: INTEGER;BEGINFOR k := 0 TO 7 DO Texts.WriteInt(W, x[k], 4) END;Texts.WriteLn(W)END write;PROCEDURE Try (i: INTEGER);VAR j: INTEGER;BEGINIF i < 8 THENFOR j := 0 TO 7 DOIF a[j] & b[i+j] & c[i-j+7] THENx[i] := j; a[j] := FALSE; b[i+j] := FALSE; c[i-j+7] := FALSE;Try(i + 1);x[i] := –1; a[j] := TRUE; b[i+j] := TRUE; c[i-j+7] := TRUEENDENDELSEЗадача о восьми ферзях Рекурсивные алгоритмы154write;m := m+1 (* v *)ENDEND Try;PROCEDURE AllQueens*;VAR i, j: INTEGER;BEGINFOR i := 0 TO 7 DO a[i] := TRUE; x[i] := –1 END;FOR i := 0 TO 14 DO b[i] := TRUE; c[i] := TRUE END;m := 0;Try(0);Log.String(' # v : '); Log.Int(m); Log.LnEND AllQueens.Таблица 3.2.Таблица 3.2.Таблица 3.2.Таблица 3.2.Таблица 3.2. Двенадцать решений задачи о восьми ферзях x0x1x2x3x4x5x6x7 n0 47 52 61 3876 05 72 63 14 264 06 35 71 42 200 06 47 13 52 136 13 57 20 64 504 14 60 27 53 400 14 63 07 52 072 15 06 37 24 280 15 72 03 64 240 16 25 74 03 264 16 47 03 52 160 17 50 24 63 3363.6. Задача о стабильных бракахПредположим, что даны два непересекающихся множества A и B равного размера n. Требуется найти набор n пар – таких, что a из A и b из B удовлетворяют некоторым ограничениям. Может быть много разных критериев для таких пар;один из них называется правилом стабильных браков.Примем, что A – это множество мужчин, а B – множество женщин. Каждый мужчина и каждая женщина указали предпочтительных для себя партнеров. Если n пар выбраны так, что существуют мужчина и женщина, которые не являются мужем и женой, но которые предпочли бы друг друга своим фактическим супру%гам, то такое распределение по парам называется нестабильным. Если таких пар нет, то распределение стабильно. Подобная ситуация характерна для многих по%хожих задач, в которых нужно сделать распределение с учетом предпочтений, на% 155пример выбор университета студентами, выбор новобранцев различными родами войск и т. п. Пример с браками особенно интуитивен; однако следует заметить,что список предпочтений остается неизменным и после того, как сделано распре%деление по парам. Такое предположение упрощает задачу, но представляет собой опасное искажение реальности (это называют абстракцией).Возможное направление поиска решения – пытаться распределить по парам членов двух множеств одного за другим, пока не будут исчерпаны оба множества.Имея целью найти все стабильные распределения, мы можем сразу сделать набро%сок решения, взяв за образец схему программы AllQueens. Пусть Try(m) означает алгоритм поиска жены для мужчины m, и пусть этот поиск происходит в соот%ветствии с порядком списка предпочтений, заявленных этим мужчиной. Первая версия, основанная на этих предположениях, такова:PROCEDURE Try (m: man);VAR r: rank;BEGINIF m < n THENFOR r := 0 TO n–1 DO r- € m;IF THEN € ;Try( m); ENDENDELSE v ENDEND TryИсходные данные представлены двумя матрицами, указывающими предпоч%тения мужчин и женщин:VAR wmr: ARRAY n, n OF woman;mwr: ARRAY n, n OF manСоответственно, wmr m обозначает список предпочтений мужчины m, то есть wmr m,r – это женщина, находящаяся в этом списке на r%м месте. Аналогично, mwr w –список предпочтений женщины w, а mwr w,r– мужчина на r%м месте в этом списке.Пример набора данных показан в табл. 3.3.Результат представим массивом женщин x, так что xm обозначает супругу мужчины m. Чтобы сохранить симметрию между мужчинами и женщинами, вво%дится дополнительный массив y, так что yw обозначает супруга женщины w:VAR x, y: ARRAY n OF INTEGERНа самом деле массив y избыточен, так как в нем представлена информация,уже содержащаяся в x. Действительно, соотношения x[y[w]] = w, y[x[m]] = mЗадача о стабильных браках Рекурсивные алгоритмы156выполняются для всех m и w, которые состоят в браке. Поэтому значение yw мож%но было бы определить простым поиском в x. Однако ясно, что использование массива y повысит эффективность алгоритма. Информация, содержащаяся в мас%сивах x и y, нужна для определения стабильности предполагаемого множества браков. Поскольку это множество строится шаг за шагом посредством соединения индивидов в пары и проверки стабильности после каждого преполагаемого брака,массивы x и y нужны даже еще до того, как будут определены все их компоненты.Чтобы отслеживать, какие компоненты уже определены, можно ввести булевские массивы singlem, singlew: ARRAY n OF BOOLEANсо следующими значениями: истинность singlem m означает, что значение xm еще не определено, а singlew w – что не определено yw. Однако, присмотревшись к обсуждаемому алгоритму, мы легко обнаружим, что семейное положение мужчины k определяется значением m с помощью отношенияsinglem[k] = k < mЭто наводит на мысль, что можно отказаться от массива singlem; соответствен%но, имя singlew упростим до single. Эти соглашения приводят к уточнению, пока%занному в следующей процедуре Try. Предикат можно уточнить в конъюнкцию операндов single и , где предикат еще предстоит определить:PROCEDURE Try (m: man);VAR r: rank; w: woman;BEGINIF m < n THENFOR r := 0 TO n–1 DOw := wmr[m,r];IF single[w] & THENx[m] := w; y[w] := m; single[w] := FALSE;Try(m+1);Таблица 3.3.Таблица 3.3.Таблица 3.3.Таблица 3.3.Таблица 3.3. Пример входных данных для wmr и mwr r = 0 12 34 56 7r = 0 12 34 56 7m = 0 61 54 02 73w = 0 35 14 70 26 13 21 57 06 41 74 20 56 31 22 13 07 46 52 57 01 23 64 32 73 14 56 03 21 36 57 40 47 23 45 06 14 52 03 46 17 57 64 13 20 55 10 27 63 54 61 35 20 64 76 24 61 30 75 75 03 16 42 77 61 73 45 20 157single[w] := TRUEENDENDELSE v ENDEND TryУ этого решения все еще заметно сильное сходство с процедурой AllQueensКлючевая задача теперь – уточнить алгоритм определения стабильности. К не%счастью, свойство стабильности невозможно выразить так же просто, как при про%верке безопасности позиции ферзя. Первая особенность, о которой нужно пом%нить, состоит в том, что, по определению, стабильность следует из сравнений рангов (то есть позиций в списках предпочтений). Однако нигде в нашей коллек%ции данных, определенных до сих пор, нет непосредственно доступных рангов мужчин или женщин. Разумеется, ранг женщины w во мнении мужчины m вычис%лить можно, но только с помощью дорогостоящего поиска значения w в wmr m. По%скольку вычисление стабильности – очень частая операция, полезно обеспечить более прямой доступ к этой информации. С этой целью введем две матрицы:rmw: ARRAY man, woman OF rank;rwm: ARRAY woman, man OF rankПри этом rmw m,w обозначает ранг женщины w в списке предпочтений мужчи%ны m, а rwm w,m – ранг мужчины m в аналогичном списке женщины w. Значения этих вспомогательных массивов не меняются и могут быть определены в самом начале по значениям массивов wmr и mwrТеперь можно вычислить предикат , точно следуя его исходно%му определению. Напомним, что мы проверяем возможность соединить браком mи w, где w = wmr m,r, то есть w является кандидатурой ранга r для мужчины m. Про%являя оптимизм, мы сначала предположим, что стабильность имеет место, а потом попытаемся обнаружить возможные помехи. Где они могут быть скрыты? Есть две симметричные возможности:1) может найтись женщина pw с рангом, более высоким, чем у w, по мнению m,и которая сама предпочитает m своему мужу;2) может найтись мужчина pm с рангом, более высоким, чем у m, по мнению w,и который сам предпочитает w своей жене.Чтобы обнаружить помеху первого рода, сравним ранги rwm pw,m и rwm pw,y[pw]для всех женщин, которых m предпочитает w, то есть для всех pw = wmr m,i таких,что i < r. На самом деле все эти женщины pw уже замужем, так как, будь любая из них еще не замужем, m выбрал бы ее еще раньше. Описанный процесс можно сформулировать в виде линейного поиска; имя переменной S является сокраще%нием для Stability (стабильность).i := –1; S := TRUE;REPEATINC(i);Задача о стабильных браках Рекурсивные алгоритмы158IF i < r THENpw := wmr[m,i];IF single[pw] THEN S := rwm[pw,m] > rwm[pw, y[pw]] ENDENDUNTIL (i = r) OR SЧтобы обнаружить помеху второго рода, нужно проверить всех кандидатов pm,которых w предпочитает своей текущей паре m, то есть всех мужчин pm = mwr w,i с i < rwm w,m. По аналогии с первым случаем нужно сравнить ранги rmwp m,w иrmw pm,x[pm]. Однако нужно не забыть пропустить сравнения с теми xpm, где pm еще не женат. Это обеспечивается проверкой pm < m, так как мы знаем, что все мужчины до m уже женаты.Полная программа показана ниже. Таблица 3.4 показывает девять стабильных решений, найденных для входных данных wmr и mwr, представленных в табл. 3.3.PROCEDURE write;(* ADruS36_Marriages *)(* # ˆ W*)VAR m: man; rm, rw: INTEGER;BEGINrm := 0; rw := 0;FOR m := 0 TO n–1 DOTexts.WriteInt(W, x[m], 4);rm := rmw[m, x[m]] + rm; rw := rwm[x[m], m] + rwEND;Texts.WriteInt(W, rm, 8); Texts.WriteInt(W, rw, 4); Texts.WriteLn(W)END write;PROCEDURE stable (m, w, r: INTEGER): BOOLEAN; (* *)VAR pm, pw, rank, i, lim: INTEGER;S: BOOLEAN;BEGINi := –1; S := TRUE;REPEATINC(i);IF i < r THENpw := wmr[m,i];IF single[pw] THEN S := rwm[pw,m] > rwm[pw, y[pw]] ENDENDUNTIL (i = r) OR S;i := –1; lim := rwm[w,m];REPEATINC(i);IF i < lim THENpm := mwr[w,i];IF pm < m THEN S := rmw[pm,w] > rmw[pm, x[pm]] ENDENDUNTIL (i = lim) OR S;RETURN SEND stable; 159PROCEDURE Try (m: INTEGER);VAR w, r: INTEGER;BEGINIF m < n THENFOR r := 0 TO n–1 DOw := wmr[m,r];IF single[w] & stable(m,w,r) THENx[m] := w; y[w] := m; single[w] := FALSE;Try(m+1);single[w] := TRUEENDENDELSEwriteENDEND Try;PROCEDURE FindStableMarriages (VAR S: Texts.Scanner);VAR m, w, r: INTEGER;BEGINFOR m := 0 TO n–1 DOFOR r := 0 TO n–1 DOTexts.Scan(S); wmr[m,r] := S.i; rmw[m, wmr[m,r]] := rENDEND;FOR w := 0 TO n–1 DOsingle[w] := TRUE;FOR r := 0 TO n–1 DOTexts.Scan(S); mwr[w,r] := S.i; rwm[w, mwr[w,r]] := rENDEND;Try(0)END FindStableMarriagesЭтот алгоритм прямолинейно реализует обход с возвратом. Его эффектив%ность зависит главным образом от изощренности схемы усечения дерева реше%ний. Несколько более быстрый, но более сложный и менее прозрачный алгоритм дали Маквити и Уилсон [3.1] и [3.2], и они также распространили его на случай множеств (мужчин и женщин) разного размера.Алгоритмы, подобные последним двум примерам, которые порождают все воз%можные решения задачи (при определенных ограничениях), часто используют для выбора одного или нескольких решений, которые в каком%то смысле опти%мальны. Например, в данном примере можно было бы искать решение, которое в среднем лучше удовлетворяет мужчин или женщин или вообще всех.Заметим, что в табл. 3.4 указаны суммы рангов всех женщин в списках пред%почтений их мужей, а также суммы рангов всех мужчин в списках предпочтений их жен. Это величиныЗадача о стабильных браках Рекурсивные алгоритмы160rm = SSSSSm: 0 ≤ m < n: rmw m,x[m]rw = SSSSSm: 0 ≤ m < n: rwm x[m],mТаблица 3.4.Таблица 3.4.Таблица 3.4.Таблица 3.4.Таблица 3.4. Решение задачи о стабильных браках x0x1x2x3x4x5x6x7rm rw c0 63 27 04 15 824 21 11 32 70 46 514 19 449 21 32 06 47 523 12 59 35 32 70 46 118 14 62 45 32 06 47 127 747 55 23 70 46 121 12 143 65 23 06 47 130 547 72 53 70 46 126 10 758 82 53 06 47 135 334c = сколько раз вычислялся предикат (процедуры stable).Решение 0 оптимально для мужчин; решение 8 – для женщин.Решение с наименьшим значением rm назовем стабильным решением, опти%мальным для мужчин; решение с наименьшим rw – оптимальным для женщин.Характер принятой стратегии поиска таков, что сначала генерируются решения,хорошие с точки зрения мужчин, а решения, хорошие с точки зрения женщин, –в конце. В этом смысле алгоритм выгоден мужчинам. Это легко исправить путем систематической перестановки ролей мужчин и женщин, то есть просто меняя местами mwr и wmr, а также rmw и rwmМы не будем дальше развивать эту программу, а задачу включения в програм%му поиска оптимального решения оставим для следующего и последнего примера применения алгоритма обхода с возвратом.3.7. Задача оптимального выбораНаш последний пример алгоритма поиска с возвратом является логическим раз%витием предыдущих двух в рамках общей схемы. Сначала мы применили прин%цип возврата, чтобы находить одно решение задачи. Примером послужили задачи о путешествии шахматного коня и о восьми ферзях. Затем мы разобрались с поис%ком всех решений; примерами послужили задачи о восьми ферзях и о стабильных браках. Теперь мы хотим искать оптимальное решение.Для этого нужно генерировать все возможные решения, но выбрать лишь то,которое оптимально в каком%то конкретном смысле. Предполагая, что оптималь%ность определена с помощью функции f(s), принимающей положительные значе%ния, получаем нужный алгоритм из общей схемы Try заменой операции v инструкциейIF f(solution) > f(optimum) THEN optimum := solution END 161Переменная optimum запоминает лучшее решение из до сих пор найденных.Естественно, ее нужно правильно инициализировать; кроме того, обычно значе%ние f(optimum) хранят еще в одной переменной, чтобы избежать повторных вы%числений.Вот частный пример общей проблемы нахождения оптимального решения в некоторой задаче. Рассмотрим важную и часто встречающуюся проблему выбо%ра оптимального набора (подмножества) из заданного множества объектов при наличии некоторых ограничений. Наборы, являющиеся допустимыми реше%ниями, собираются постепенно посредством исследования отдельных объектов исходного множества. Процедура Try описывает процесс исследования одного объекта, и она вызывается рекурсивно (чтобы исследовать очередной объект) до тех пор, пока не будут исследованы все объекты.Замечаем, что рассмотрение каждого объекта (такие объекты назывались кандидатами в предыдущих примерах) имеет два возможных исхода, а именно:либо исследуемый объект включается в собираемый набор, либо исключается из него. Поэтому использовать циклы repeat или for здесь неудобно, и вместо них можно просто явно описать два случая. Предполагая, что объекты пронумерова%ны 0, 1, ... , n–1, это можно выразить следующим образом:PROCEDURE Try (i: INTEGER);BEGINIF i < n THENIF THEN i- ˆ ;Try(i+1); i- ˆ END;IF THENTry(i+1)ENDELSE ENDEND TryУже из этой схемы очевидно, что есть 2n возможных подмножеств; ясно, что нужны подходящие критерии отбора, чтобы радикально уменьшить число иссле%дуемых кандидатов. Чтобы прояснить этот процесс, возьмем конкретный пример задачи выбора: пусть каждый из n объектов a0, ... ,a n–1 характеризуется своим ве%сом и ценностью. Пусть оптимальным считается тот набор, у которого суммарная ценность компонент является наибольшей, а ограничением пусть будет некото%рый предел на их суммарный вес. Эта задача хорошо известна всем путешест%венникам, которые пакуют чемоданы, делая выбор из n предметов таким образом,чтобы их суммарная ценность была наибольшей, а суммарный вес не превышал некоторого предела.Теперь можно принять решения о представлении описанных сведений в гло%бальных переменных. На основе приведенных соображений сделать выбор легко:Задача оптимального выбора Рекурсивные алгоритмы162TYPE Object = RECORD weight, value: INTEGER END;VAR a: ARRAY n OF Object;limw, totv, maxv: INTEGER;s, opts: SETПеременные limw и totv обозначают предел для веса и суммарную ценность всех n объектов. Эти два значения постоянны на протяжении всего процесса вы%бора. Переменная s представляет текущее состояние собираемого набора объек%тов, в котором каждый объект представлен своим именем (индексом). Перемен%ная opts – оптимальный набор среди исследованных к данному моменту, а maxv –его ценность.Каковы критерии допустимости включения объекта в собираемый набор?Если речь о том, имеет ли смысл включать объект в набор, то критерий здесь – не будет ли при таком включении превышен лимит по весу. Если будет, то можно не добавлять новые объекты к текущему набору. Однако если речь об исключении, то допустимость дальнейшего исследования наборов, не содержащих этого элемен%та, определяется тем, может ли ценность таких наборов превысить значение для оптимума, найденного к данному моменту. И если не может, то продолжение по%иска, хотя и может дать еще какое%нибудь решение, не приведет к улучшению уже найденного оптимума. Поэтому дальнейший поиск на этом пути бесполезен. Из этих двух условий можно определить величины, которые нужно вычислять на каждом шаге процесса выбора:1. Полный вес tw набора s, собранного на данный момент.2. Еще достижимая с набором s ценность avЭти два значения удобно представить параметрами процедуры Try. Теперь ус%ловие можно сформулирловать так:tw + a[i].weight < limw а последующую проверку оптимальности записать так:IF av > maxv THEN (* , #*)opts := s; maxv := avENDПоследнее присваивание основано на том соображении, что когда все n объек%тов рассмотрены, достижимое значение совпадает с достигнутым. Условие - выражается так:av – a[i].value > maxvДля значения av – a[i].value, которое используется неоднократно, вводится имя av1, чтобы избежать его повторного вычисления.Теперь вся процедура составляется из уже рассмотренных частей с добавлени%ем подходящих операторов инициализации для глобальных переменных. Обра%тим внимание на легкость включения и исключения из множества s с помощью операций для типа SET. Результаты работы программы показаны в табл. 3.5. 163TYPE Object = RECORD value, weight: INTEGER END; (* ADruS37_OptSelection *)VAR a: ARRAY n OF Object;limw, totv, maxv: INTEGER;s, opts: SET;PROCEDURE Try (i, tw, av: INTEGER);VAR tw1, av1: INTEGER;BEGINIF i < n THEN(* *)tw1 := tw + a[i].weight;IF tw1 <= limw THENs := s + {i};Try(i+1, tw1, av);s := s – {i}END;(* *)av1 := av – a[i].value;IF av1 > maxv THENTry(i+1, tw, av1)ENDELSIF av > maxv THENmaxv := av; opts := sENDEND Try;Задача оптимального выбораТаблица 3.5.Таблица 3.5.Таблица 3.5.Таблица 3.5.Таблица 3.5. Пример результатов работы программы Selection при выборе из 10 объектов (вверху). Звездочки отмечают объекты из отпимальных наборов opts для ограничений на суммарный вес от 10 до 120 :10 11 12 13 14 15 16 17 18 19 : 18 20 17 19 25 21 27 23 25 24limw ↓maxv10*18 20*27 30**52 40***70 50****84 60*****99 70*****115 80******130 90******139 100*******157 110********172 120********183 Рекурсивные алгоритмы164PROCEDURE Selection (WeightInc, WeightLimit: INTEGER);BEGINlimw := 0;REPEATlimw := limw + WeightInc; maxv := 0;s := {}; opts := {}; Try(0, 0, totv);UNTIL limw >= WeightLimitEND Selection.Такая схема поиска с возвратом, в которой используются ограничения для предотвращения избыточных блужданий по дереву поиска, называется методомветвей и границ (branch and bound algorithm).Упражнения3.1. (Ханойские башни.) Даны три стержня и n дисков разных размеров. Диски могут быть нанизаны на стержни, образуя башни. Пусть n дисков первона%чально находятся на стержне A в порядке убывания размера, как показано на рис. 3.9 для n = 3. Задание в том, чтобы переместить n дисков со стержня A на стержень C, причем так, чтобы они оказались нанизаны в том же порядке.Этого нужно добиться при следующих ограничениях:1. На каждом шаге со стержня на стержень перемещается только один диск.2. Диск нельзя нанизывать поверх диска меньшего размера.3. Стержень B можно использовать в качестве вспомогательного хранилища.Требуется найти алгоритм выполнения этого задания. Заметим, что башню удобно рассматривать как состоящую из одного диска на вершине и башни,составленной из остальных дисков. Опишите алгоритм в виде рекурсивной программы.3.2. Напишите процедуру порождения всех n! перестановок n элементов a0, ..., a n–1in situ, то есть без использования другого массива. После порожде%ния очередной перестановки должна вызываться передаваемая в качестве па%раметра процедура Q, которая может, например, печатать порожденную пере%становку.Рис. 3.9. Ханойские башни 165Подсказка. Считайте, что задача порождения всех перестановок элементов a0, ..., a m–1 состоит из m подзадач порождения всех перестановок элементов a0, ..., a m–2, после которых стоит am–1, где в i%й подзадаче предварительно были переставлены два элемента ai и am–1 3.3. Найдите рекурсивную схему для рис. 3.10, который представляет собой су%перпозицию четырех кривых W1, W2, W3, W4. Эта структура подобна кривымСерпиньского (рис. 3.6). Из рекурсивной схемы получите рекурсивную про%грамму для рисования этих кривых.Рис. 3.10. Кривые W1 – W4 3.4. Из 92 решений, вычисляемых программой AllQueens в задаче о восьми фер%зях, только 12 являются существенно различными. Остальные получаются отражениями относительно осей или центральной точки. Придумайте про%грамму, которая определяет 12 основных решений. Например, обратите вни%мание, что поиск в столбце 1 можно ограничить позициями 1–4.3.5. Измените программу для задачи о стабильных браках так, чтобы она находи%ла оптимальное решение (для мужчин или женщин). Получится пример применения метода ветвей и границ, уже реализованного в задаче об опти%мальном выборе (программа Selection).3.6. Железнодорожная компания обслуживает n станций S0, ... , Sn–1. В ее планах –улучшить обслуживание пассажиров с помощью компьютеризованных информационных терминалов. Предполагается, что пассажир указывает свои станции отправления SA и назначения SD и (немедленно) получает расписа%Упражнения Рекурсивные алгоритмы166ние маршрута с пересадками и с минимальным полным временем поездки.Напишите программу для вычисления такой информации. Предположите,что график движения поездов (банк данных для этой задачи) задан в подхо%дящей структуре данных, содержащей времена отправления (= прибытия)всех поездов. Естественно, не все станции соединены друг с другом прямыми маршрутами (см. также упр. 1.6).3.7. Функция Аккермана A определяется для всех неотрицательных целых аргу%ментов m и n следующим образом:A(0, n) = n + 1A(m, 0) = A(m–1, 1) (m > 0)A(m, n) = A(m–1, A(m, n–1)) (m, n > 0)Напишите программу для вычисления A(m,n), не используя рекурсию. В ка%честве образца используйте нерекурсивную версию быстрой сортировки(программа NonRecursiveQuickSort). Сформулируйте общие правила для преобразования рекурсивных программ в итеративные.Литература[3.1] McVitie D. G. and Wilson L. B. The Stable Marriage Problem. Comm. ACM, 14,No. 7 (1971), 486–492.[3.2] McVitie D. G. and Wilson L. B. Stable Marriage Assignment for Unequal Sets.Bit, 10, (1970), 295–309.[3.3] Space Filling Curves, or How to Waste Time on a Plotter. Software – Practice and Experience, 1, No. 4 (1971), 403–440.[3.4] Wirth N. Program Development by Stepwise Refinement. Comm. ACM, 14,No. 4 (1971), 221–227. 1   ...   9   10   11   12   13   14   15   16   ...   22

Глава 4Динамические структурыданных4.1. Рекурсивные типы данных ..................................... 168 4.2. Указатели ......................... 170 4.3. Линейные списки .............. 175 4.4. Деревья ............................ 191 4.5. Сбалансированные деревья ................................... 210 4.6. Оптимальные деревья поиска ..................................... 220 4.7. Б<деревья (BУпражнения ............................. 250Литература .............................. 254 Динамические структуры данных1684.1. Рекурсивные типы данныхВ главе 1 массивы, записи и множества были введены в качестве фундаменталь%ных структур данных. Мы назвали их фундаментальными, так как они являются строительными блоками, из которых формируются более сложные структуры,а также потому, что на практике они встречаются чаще всего. Смысл определения типа данных, а затем определения переменных, имеющих этот тип, состоит в том,чтобы раз и навсегда фиксировать диапазон значений этих переменных, а значит,и способ их размещения в памяти. Поэтому такие переменные называют статическими. Однако есть много задач, где нужны более сложные структуры данных.Для таких задач характерно, что не только значения, но и структура переменных меняется во время вычисления. Поэтому их называют динамическими структурами. Естественно, компоненты таких структур – на определенном уровне разреше%ния – являются статическими, то есть принадлежат одному из фундаментальных типов данных. Эта глава посвящена построению, анализу и работе с динамиче%скими структурами данных.Надо заметить, что существуют близкие аналогии между методами структури%рования алгоритмов и данных. Эта аналогия, как и любая другая, не является пол%ной, тем не менее сравнение методов структурирования программ и данных по%учительно.Элементарный неделимый оператор – присваивание значения некоторой пе%ременной. Соответствующий член семейства структур данных – скалярный, не%структурированный тип. Эта пара представляет собой неделимые строительные блоки для составных операторов и для типов данных. Простейшие структуры,получаемые посредством перечисления, суть последовательность операторов и запись. И та, и другая состоят из конечного (обычно небольшого) числа явно пе%речисленных компонент, которые все могут быть различными. Если все компо%ненты идентичны, то их не обязательно выписывать по отдельности: в этом случае используют оператор for и массив, чтобы указать известное, конечное число по%вторений. Выбор между двумя или более элементами выражается условным опе%ратором и расширением записевых типов соответственно. И наконец, повторение с заранее неизвестным (и потенциально бесконечным) числом шагов выражается операторами while и repeat. Соответствующая структура данных – последова%тельность (файл) – это простейшее средство для построения типов с бесконечной мощностью.Возникает вопрос: существует ли структура данных, которая аналогичным образом соответствовала бы оператору процедуры? Естественно, в этом отно%шении самым интересным и новым свойством процедур является рекурсия.Значения такого рекурсивного типа данных должны содержать одну или более компонент, принадлежащих этому же типу, подобно тому как процедура может содержать один или более вызовов самой себя. Как и процедуры, определения ти%пов данных могли бы быть явно или косвенно рекурсивными.Простой пример объекта, который весьма уместно представлять рекурсивно определенным типом, – арифметическое выражение, имеющееся в языках про% 169граммирования. Рекурсия используется, чтобы отразить возможность вложений,то есть использования подвыражений в скобках в качестве операндов выражений.Поэтому дадим следующее неформальное определение выражения:Выражение состоит из терма, за которым следует знак операции, за которым следует терм. (Два этих терма – операнды операции.) Терм – это либо перемен%ная, представленная идентификатором, либо выражение, заключенное в скобки.Тип данных, значениями которого представляются такие выражения, может быть легко описан, если использовать уже имеющиеся средства, добавив к ним рекурсию:TYPE expression = RECORD op: INTEGER;opd1, opd2: termENDTYPE term =RECORDIF t: BOOLEAN THEN id: Name ELSE subex: expression ENDENDПоэтому каждая переменная типа term состоит из двух компонент, а именно поля признака t, а также, если t истинно, поля id, или в противном случае поля subex. Например, рассмотрим следующие четыре выражения:1.x + y2.x – (y * z)3.(x + y) * (z – w)4.(x/(y + z)) * wЭти выражения схематически показаны на рис. 4.1, где видна их «матрешечная»,рекурсивная структура, а также показано размещение этих выражений в памяти.Второй пример рекурсивной структуры данных – семейная родословная.Пусть родословная определена именем индивида и двумя родословными его ро%дителей. Это определение неизбежно приводит к бесконечной структуре. Реаль%ные родословные ограничены, так как о достаточно далеких предках информация отсутствует. Снова предположим, что это можно учесть с помощью некоторой условной структуры (ped от pedigree – родословная):TYPE ped = RECORD IF known: BOOLEAN THEN name: Name; father, mother: ped ENDENDЗаметим, что каждая переменная типа ped имеет по крайней мере одну компо%ненту, а именно поле признака known (известен). Если его значение равно TRUE,то есть еще три поля; в противном случае эти поля отсутствуют. Пример конкрет%ного значения показан ниже в виде выражения с вложениями, а также с помощью диаграммы, показывающей возможное размещение в памяти (см. рис. 4.2).(T, Ted, (T, Fred, (T, Adam, (F), (F)), (F)), (T, Mary, (F), (T, Eva, (F), (F)))Понятно, почему важны условия в таких определениях: это единственное средство ограничить рекурсивную структуру данных, поэтому они обязательноРекурсивные типы данных Динамические структуры данных170Рис. 4.1. Схемы расположения в памяти рекурсивных записевых структурРис. 4.2. Пример рекурсивной структуры данных сопровождают каждое рекурсивное определе%ние. Здесь особенно четко видна аналогия между структурированием программ и данных. Услов%ный оператор (или оператор выбора) обяза%тельно должен быть частью каждой рекурсивной процедуры, чтобы обеспечить завершение ее вы%полнения. На практике динамические структу%ры используют ссылки или указатели на свои элементы, а идея альтернативы (для завершения рекурсии) реализуется в понятии указателя, как объясняется в следующем разделе.4.2. УказателиХарактерное свойство рекурсивных структур,четко отличающее их от фундаментальных струк%тур (массивов, записей, множеств), – это их спо%собность менять свой размер. Поэтому невозмож%но выделить фиксированный участок памяти для размещения рекурсивно определенной структу%ры, и, как следствие, компилятор не может свя%зать конкретные адреса с компонентами таких переменных. Метод, чаще всего применяемый для решения этой проблемы, состоит в динами 171ческом распределении памяти (dynamic allocation of storage), то есть распределе%нии памяти отдельным компонентам в тот момент, когда они возникают при вы%полнения программы, а не во время трансляции. При этом компилятор отводит фиксированный объем памяти для хранения адреса динамически размещаемой компоненты вместо самой компоненты. Например, родословная, показанная на рис. 4.2, будет представлена отдельными – вполне возможно, несмежными – за%писями, по одной на каждого индивида. Эти записи для отдельных людей связаны с помощью адресов, записанных в соответствующие поля father (отец) и mother(мать). Графически это лучше всего выразить с помощью стрелок или указателей(рис. 4.3).Рис. 4.3. Структура данных, связанная указателямиВажно подчеркнуть, что использование указателей для реализации рекурсив%ных структур – это всего лишь технический прием. Программисту не обязательно знать об их существовании. Память может распределяться автоматически в тот момент, когда в первый раз используется ссылка на новую компоненту. Но если явно разрешается использование указателей, то можно построить и более общие структуры данных, чем те, которые можно описать с помощью рекурсивных опре%делений. В частности, тогда можно определять потенциально бесконечные или циклические структуры (графы) и указывать, что некоторые структуры исполь%зуются совместно. Поэтому в развитых языках программирования принято разре%шать явные манипуляции не только с данными, но и со ссылками на них. Это тре%бует проведения четкого различия на уровне обозначений между данными и ссылками на данные, а также необходимость иметь типы данных, значениями ко%торых являются указатели (ссылки) на другие данные. Мы будем использовать следующую нотацию для этой цели:TYPE T = POINTER TO T0Такое определение типа означает, что значения типа T – это указатели на дан%ные типа T0. Принципиально важно, что тип элементов, на которые ссылаетсяУказатели Динамические структуры данных172указатель, очевиден из определения T. Мы говорим, что T связан с T0. Эта связь отличает указатели в языках высокого уровня от адресов в машинном языке и яв%ляется весьма важным средством повышения безопасности в программировании посредством отражения семантики программы синтаксическими средствами.Значения указательных типов порождаются при каждом динамическом разме%щении элемента данных. Мы будет придерживаться правила, что такое событие всегда должно описываться явно, в противоположность механизму автоматичес%кого размещения элемента данных при первой ссылке на него. С этой целью вве%дем процедуру NEW. Если дана указательная переменная p типа T, то операторNEW(p) размещает где%то в памяти переменную типа T0, а указатель на эту новую переменную записывает в переменную p (см. рис. 4.4). Сослаться в программе на само указательное значение теперь можно с помощью p (то есть это значение ука%зательной переменной p). При этом переменная, на которую ссылается p, обозна%чается как p^. Обычно используют ссылки на записи. Если у записи, на которую ссылается указатель p, есть, например, поле x, то оно обозначается как p^.x. По%скольку ясно, что полями обладает не указатель, а только запись p^, то мы допус%каем сокращенную нотацию p.x вместо p^.xРис. 4.4. Динамическое размещение переменной p^Выше указывалось, что в каждом рекурсивном типе необходима компонента,позволяющая различать возможные варианты, чтобы можно было обеспечить ко%нечность рекурсивных структур. Пример семейной родословной показывает весь%ма часто встречающуюся ситуацию, когда в одном из двух случаев другие компо%ненты отсутствуют. Это выражается следующим схематическим определением:TYPE T = RECORDIF nonterminal: BOOLEAN THEN S(T) ENDENDS(T) обозначает последовательность определений полей, среди которых есть одно или более полей типа T, чем и обеспечивается рекурсивность. Все структуры типа, определенного по этой схеме, имеют древесное (или списковое) строение,подобное показанному на рис. 4.3. Его особенность – наличие указателей на ком%поненты данных, состоящие только из поля признака, то есть не несущие другой полезной информации. Метод реализации с явными укзателями подсказывает простой способ сэкономить память, разрешив включать информацию о поле при% 173знака в само указательное значение. Обычно для этого расширяют диапазон значе%ний всех указательных типов единственным значением, которое вообще не являет%ся ссылкой ни на какой элемент. Обозначим это значение специальным символомNIL и постулируем, что все переменные указательных типов могут принимать зна%чение NIL. Вследствие такого расширения диапазона указательных значений ко%нечные структуры могут порождаться при отсутствии вариантов (условий) в их(рекурсивных) определениях.Ниже даются новые формулировки объявленных ранее явно рекурсивных ти%пов данных с использованием указателей. Заметим, что здесь уже нет поля known,так как p.known теперь выражается посредством p = NIL. Переименование типа ped в Person (индивид) отражает изменение точки зрения, произошедшее благо%даря введению явных указательных значений. Теперь вместо того, чтобы сначала рассматривать данную структуру целиком и уже потом исследовать ее подструк%туры и компоненты, внимание сосредоточивается прежде всего на компонентах,а их взаимная связь (представленная указателями) не фиксирована никаким яв%ным определением.TYPE term =POINTER TO TermDescriptor;TYPE exp =POINTER TO ExpDescriptor;TYPE ExpDescriptor =RECORD op: INTEGER; opd1, opd2: term END;TYPE TermDescriptor = RECORD id: ARRAY 32 OF CHAR ENDTYPE Person =POINTER TO RECORDname: ARRAY 32 OF CHAR;father, mother: PersonENDЗамечание. Тип Person соответствует указателям на записи безымянного типа(PersonDescriptor).Структура данных, представляющая родословную и показанная на рис. 4.2 и 4.3,снова показана на рис. 4.5, где указатели на неизвестных лиц обозначены констан%той NIL. Получающаяся экономия памяти очевидна.В контексте рис. 4.5 предположим, что Fred и Mary – брат и сестра, то есть у них общие отец и мать. Эту ситуацию легко выразить заменой двух значений NILв соответствующих полях двух записей. Реализация, которая скрывает указателиРис. 4.5. Структура данных с указателями, имеющими значение NILУказатели Динамические структуры данных174или использует другие приемы работы с памятью, заставила бы программиста представить записи для родителей, то есть Adam и Eva, дважды. Хотя для чтения данных не важно, одной или двумя записями представлены два отца (или две ма%тери), разница становится существенной, когда разрешено частичное изменение данных. Трактовка указателей как явных элементов данных, а не как скрытых средств реализации, позволяет программисту четко указать, где нужно совмес%тить используемые блоки памяти, а где – нет.Другое следствие явных указателей – возможность определять и манипулиро%вать циклическими структурами данных. Разумеется, такая дополнительная гиб%кость не только предоставляет дополнительные возможности, но и требует от программиста повышенного внимания, поскольку работа с циклическими струк%турами данных легко может привести к бесконечным процессам.Эта тесная связь мощи и гибкости средств с опасностью их неправильного использования хорошо известна в программировании и заставляет вспомнить оператор GOTO. В самом деле, если продолжить аналогию между структурами программ и данных, то чисто рекурсивные структуры данных можно сопоста%вить с процедурами, а введение указателей сравнимо с операторами GOTO. Ибо как оператор GOTO позволяет строить любые программные схемы (включая циклы), так и указатели позволяют строить любые структуры данных (включая кольцевые). [Однако в отличие от операторов GOTO, типизированные указатели не нарушают структурированности соответствующих записей – прим. перев.]Параллели между структурами управления и структурами данных суммирова%ны в табл. 4.1.Таблица 4.1.Таблица 4.1.Таблица 4.1.Таблица 4.1.Таблица 4.1. Соответствия структур управления и структур данныхСхема построенияСхема построенияСхема построенияСхема построенияСхема построенияОператор программыОператор программыОператор программыОператор программыОператор программыТип данныхТип данныхТип данныхТип данныхТип данныхНеделимый элементПрисваиваниеСкалярный типПеречислениеОператорнаяЗапись последовательностьПовторение (числоОператор forМассив повторений известно)ВыборУсловный операторОбъединение типов(запись с вариантами)ПовторениеОператор while илиПоследовательностный тип repeatРекурсияПроцедураРекурсивный тип данныхОбщий графОператор переходаСтруктура, связанная указателямиВ главе 3 мы видели, что итерация является частным случаем рекурсии и что вы%зов рекурсивной процедуры P, определенной в соответствии со следующей схемой,PROCEDURE P;BEGINIF B THEN P0; P ENDEND 175где оператор P0 не включает в себя P и может быть заменен на эквивалентный опе%ратор циклаWHILE B DO P0 ENDАналогии, представленные в табл. 4.1, подсказывают, что похожая связь долж%на иметь место между рекурсивными типами данных и последовательностью.В самом деле, рекурсивный тип, определенный в соответствии со схемойTYPE T = RECORDIF b: BOOLEAN THEN t0: T0; t: T ENDENDгде тип T0 не имеет отношения к T, может быть заменен на эквивалентную после%довательность элементов типа T0Остальная часть этой главы посвящена созданию и работе со структурами дан%ных, компоненты которых связаны с помощью явных указателей. Особое внима%ние уделяется конкретным простым схемам; из них можно понять, как работать с более сложными структурами. Такими простыми схемами являются линейный список (простейший случай) и деревья. Внимание, которое мы уделяем этим средствам структурирования данных, не означает, что на практике не встречают%ся более сложные структуры. Следующий рассказ, опубликованный в цюрихской газете в июле 1922 г., доказывает, что странности могут встречаться даже в тех случаях, которые обычно служат образцами регулярных структур, таких как (генеа%логические) деревья. Мужчина жалуется на свою жизнь следующим образом:Я женился на вдове, у которой была взрослая дочь. Мой отец, который частонас навещал, влюбился в мою приемную дочь и женился на ней. Таким образом, мойотец стал моим зятем, а моя приемная дочь стала моей мачехой. Через несколькомесяцев моя жена родила сына, который стал сводным братом моему отцу и моимдядей. Жена моего отца, то есть моя приемная дочь, тоже родила сына, которыйстал мне братом и одновременно внуком. Моя жена стала мне бабушкой, так какона мать моей мачехи. Следовательно, я муж моей жены и в то же время ее приемный внук; другими словами, я сам себе дедушка.1   ...   10   11   12   13   14   15   16   17   ...   22

4.4. Деревья4.4.1. Основные понятия и определенияМы видели, что последовательности и списки удобно определять следующим об%разом. Последовательность (список) с базовым типом T – это:1) пустая последовательность (список);2) конкатенация (сцепление) некоторого элемента типа T и последовательно%сти с базовым типом TТем самым рекурсия используется как средство определения метода структу%рирования, а именно последовательности или итерации. Последовательности и итерации встречаются настолько часто, что их обычно рассматривают в качестве фундаментальных схем структурирования и поведения. Но нужно помнить, что их определить с помощью рекурсии можно, однако обратное неверно, тогда как рекурсию можно элегантно и эффективно использовать для определения гораздо более изощренных структур. Хорошо известный пример – деревья. Определим понятие дерева следующим образом. Дерево с базовым типом T – это либо:1) пустое дерево, либо2) узел типа T с конечным числом поддеревьев, то есть не соединяющихся меж%ду собой деревьев с базовым типом TСходство рекурсивных определений последовательностей и деревьев показы%вает, что последовательность (список) – это дерево, в котором у каждого узла не больше одного поддерева. Поэтому список можно считать вырожденным деревом.Есть несколько способов представить дерево. Например, рис. 4.17 показывает несколько таких представлений для дерева с базовым типом T = CHAR. Все эти представления изображают одну и ту же структуру и потому эквивалентны. Но именно представление в виде графа объясняет, почему здесь используют термин«дерево». Довольно странно, что деревья чаще изображают растущими вниз или –если использовать другую метафору – показывают корни деревьев. Однако последнее описание вносит путаницу, так как корнем (root) обычно называют верхний узел (A).Упорядоченное дерево – это такое дерево, в котором для ветвей каждого узла фиксирован определенный порядок. Поэтому два упорядоченных дерева на рис. 4.18 различны. Узел y, который находится непосредственно под узлом x, на%зывается (непосредственным) потомком узла x; если x находится на уровне i, то говорят, что y находится на уровне i+1. Обратно, узел x называется (непосредст%венным) предком узла y. Уровень корня по определению равен нулю. Максималь%ный уровень элементов в дереве называется его глубиной, или высотой.Деревья Динамические структуры данных192Рис. 4.17. Представление дерева посредством (a) вложенных подмножеств,(b) скобочного выражения, (c) текста с отступами, (d) графаРис. 4.18. Два разных упорядоченных дерева 193Если узел не имеет потомков, то он называется концевым (терминальным), илилистом; узел, не являющийся концевым, называется внутренним. Количество(непосредственных) потомков внутреннего узла – это его степень. Максимальная степень всех узлов называется степенью дерева. Число ветвей или ребер, по кото%рым нужно пройти, чтобы попасть из корня в узел x, называется его длиной пути.Длина пути корня равна нулю, его непосредственных потомков – 1 и т. д. Вообще,длина пути узла на уровне i равна i. Длина путей дерева определяется как сумма длин путей всех его компонент. Ее еще называют длиной внутренних путей. На%пример, у дерева на рис. 4.17 длина внутренних путей равна 36. Очевидно, сред%няя длина пути равнаPint = (SSSSSi: 1 ≤ i ≤ n: n i× i) / n где ni – число узлов на уровне i. Чтобы определить длину внешних путей, расши%рим дерево особыми дополнительными узлами всюду, где в исходном дереве от%сутствовало поддерево. Здесь имеется в виду, что все узлы должны иметь одина%ковую степень, а именно степень дерева. Такое расширение дерева равнозначно заполнению пустых ветвей, причем у добавляемых дополнительных узлов, конеч%но, потомков нет. Дерево с рис. 4.17, расширенное дополнительными узлами, по%казано на рис. 4.19, где дополнительные узлы показаны квадратиками. Длина вне%шних путей теперь определяется как сумма длин путей всех дополнительных узлов. Если число дополнительных узлов на уровне i равно mi, то средняя длина внешнего пути равнаPext = (SSSSSi: 1 ≤ i ≤ m i× i) / mДлина внешних путей дерева на рис. 4.19 равна 120.Число m дополнительных узлов, которые нужно добавить к дереву степени d,определяется числом n исходных узлов. Заметим, что к каждому узлу ведет в точ%ности одно ребро. Таким образом, в расширенном дереве m + n ребер. С другойРис. 4.19. Троичное дерево, расширенное дополнительными узламиДеревья Динамические структуры данных194стороны, из каждого исходного узла выходит d ребер, из дополнительных узлов –ни одного. Поэтому всего имеется d*n + 1 ребер, где 1 соответствует ребру, веду%щему к корню. Две формулы дают следующее уравнение, связывающее число mдополнительных узлов и число n исходных узлов: d×n + 1 = m + n, откуда m = (d–1) × n + 1Дерево заданной высоты h будет иметь максимальное число узлов, если у всех узлов есть d поддеревьев, кроме узлов на уровне h, у которых поддеревьев нет.Тогда у дерева степени d на уровне 0 есть 1 узел (а именно корень), на уровне 1 –d его потомков, на уровне 2 – d2 потомков d узлов уровня 1 и т. д. Отсюда получа%ем выражениеNd(h) = SSSSSi: 0 ≤ i < h: d iдля максимального числа узлов дерева высоты h и степени d. В частности, для d = 2 получаемN2(h) = 2h – 1Особенно важны упорядоченные деревья степени 2. Их называют двоичными(бинарными) деревьями. Определим упорядоченное двоичное дерево как конеч%ное множество элементов (узлов), которое либо пусто, либо состоит из корня(корневого узла) с двумя отдельными двоичными деревьями, которые называютлевым и правым поддеревом корня. В дальнейшем мы будем в основном зани%маться двоичными деревьями, поэтому под деревом всегда будем подразумеватьупорядоченное двоичное дерево. Деревья степени больше 2 называются сильно ветвящимися деревьями, им посвящен раздел 4.7.1.Знакомые примеры двоичных деревьев – семейная родословная, где отец и мать индивида представлены узлами%потомками (!); таблица результатов теннисного турнира, где каждому поединку соответствует узел, в котором запи%сан победитель, а две предыдущие игры соперников являются потомками;арифметическое выражение с двухместными операциями, где каждому оператору соответствует узел, а операндам – поддеревья (см. рис. 4.20).Рис. 4.20. Представление в виде дерева для выражения (a + b/c) * (d – e*f) 195Обратимся теперь к проблеме представления деревьев. Изображение таких рекурсивных конструкций в виде ветвящихся структур подсказывает, что здесь можно использовать наш аппарат указателей. Очевидно, нет смысла объявлять переменные с фиксированной древесной структурой; вместо этого фиксирован%ную структуру, то есть фиксированный тип, будут иметь узлы, для которых сте%пень дерева определяет число указательных компонент, ссылающихся на подде%ревья узла. Очевидно, ссылка на пустое дерево обозначается с помощью NILСледовательно, дерево на рис. 4.20 состоит из компонент определенного ниже типа и может тогда быть построено, как показано на рис. 4.21.TYPE Node =POINTER TO NodeDesc;TYPE NodeDesc = RECORD op: CHAR; left, right: Node ENDРис. 4.21. Дерево рис. 4.20, представленное как связная структура данныхПрежде чем исследовать, какую пользу можно извлечь, применяя деревья,и как выполнять операции над ними, дадим пример программы, которая строит дерево. Предположим, что нужно построить дерево, значениями в узлах которого являются n чисел, считываемых из входного файла. Чтобы сделать задачу инте%ресней, будем строить дерево с n узлами, имеющее минимальную высоту. Чтобы получить минимальную высоту при заданном числе узлов, нужно размещать мак%симальное возможное число узлов на всех уровнях, кроме самого нижнего. Оче%видно, этого можно достичь, распределяя новые узлы поровну слева и справа от каждого узла. Это означает, что мы строим дерево для заданного n так, как показа%но на рис. 4.22 для n = 1, ... , 7Правило равномерного распределения при известном числе узлов n лучше всего сформулировать рекурсивно:1. Использовать один узел в качестве корня.2. Построить таким образом левое поддерево с числом узлов nl = n DIV 2 3. Построить таким образом правое поддерево с числом узлов nr = n – nl – 1Деревья Динамические структуры данных196Это правило реализуется рекурсивной процедурой, которая читает входной файл и строит идеально сбалансированное дерево. Вот определение: дерево явля%ется идеально сбалансированным, если для каждого узла число узлов в левом и правом поддеревьях отличается не больше чем на 1.TYPE Node = POINTER TO RECORD(* ADruS441_BalancedTree *)key: INTEGER; left, right: NodeEND;VAR R: Texts.Reader; W: Texts.Writer; root: Node;PROCEDURE tree (n: INTEGER): Node;(* n *)VAR new: Node;x, nl, nr: INTEGER;BEGINIF n = 0 THEN new := NILELSE nl := n DIV 2; nr := n–nl–1;NEW(new); Texts.ReadInt(R, new.key);new.key := x; new.left := tree(nl); new.right := tree(nr)END;RETURN newEND tree;PROCEDURE PrintTree (t: Node; h: INTEGER);(* t h *)VAR i: INTEGER;BEGINIF t # NIL THENРис. 4.22. Идеально сбалансированные деревья 197PrintTree(t.left, h+1);FOR i := 1 TO h DO Texts.Write(W, TAB) END;Texts.WriteInt(W, t.key, 6); Texts.WriteLn(W);PrintTree(t.right, h+1)ENDEND PrintTree;Например, предположим, что имеются входные данные для дерева с 21 узлами:8 9 11 15 19 20 21 7 3 2 1 5 6 4 13 14 10 12 17 16 18Вызов root := tree(21) читает входные данные и строит идеально сбалансиро%ванное дерево, показанное на рис. 4.23. Отметим простоту и прозрачность этой программы, построенной с использованием рекурсивных процедур. Очевидно,что рекурсивные алгоритмы особенно удобны там, где нужно обрабатывать ин%формацию, структура которой сама определена рекурсивно. Этот факт снова про%является в процедуре, которая печатает получившееся дерево: если дерево пусто,то ничего не печатается, для поддерева на уровне L сначала печатается его левое поддерево, затем узел с отступом в L символов табуляции и, наконец, его правое поддерево.Рис. 4.23. Дерево, порожденное программой tree4.4.2. Основные операциис двоичными деревьямиЕсть много операций, которые может понадобиться выполнить с древесной струк%турой; например, часто нужно выполнить заданную процедуру P в каждом узле дерева. Тогда P следует считать параметром более общей задачи обхода всех уз%лов, или, как обычно говорят, обхода дерева. Если мы рассмотрим такой обход какДеревья Динамические структуры данных198единый последовательный процесс, то получится, что от%дельные узлы посещаются в некотором конкретном поряд%ке и как бы расставляются в линию. Описание многих алгоритмов сильно упрощается, если обсуждать последо%вательность обработки элементов в терминах какого%либо отношения порядка. Из структуры деревьев естественно определяются три основных отношения порядка. Их, как и саму древесную структуру, удобно выражать на языке ре%курсии. Имея в виду двоичное дерево на рис. 4.24, где Rобозначает корень, а A и B – левое и правое поддеревья, три упомянутых отношения порядка таковы:1. Прямой порядок (preorder):R, A, B (корень до поддеревьев)2. Центрированный порядок (inorder):A, R, B3. Обратный порядок (postorder):A, B, R (корень после поддеревьев)Обходя дерево на рис. 4.20 и записывая соответствующие литеры по мере посещения узлов, получаем следующие упорядоченные последовательности:1. Прямой порядок:* + a / b c – d * e f2. Центрированный порядок:a + b / c * d – e * f3. Обратный порядок:a b c / + d e f * – *Здесь можно узнать три формы записи выражений: прямой обход дерева выра%жения дает префиксную нотацию, обратный – постфиксную, а центрированный –обычную инфиксную, правда, без скобок, которые нужны для уточнения приори%тетов операций.Сформулируем теперь эти три метода обхода в виде трех конкретных про%грамм с явным параметром t, обозначающим дерево, которое нужно обработать,и с неявным параметром P, обозначающим операцию, применяемую к каждому узлу. Подразумевается следующее определение:TYPE Node = POINTER TO RECORD ... left, right: Node ENDТеперь три метода легко формулируются в виде рекурсивных процедур; этим снова подчеркивается тот факт, что операции с рекурсивно определенными структурами данных удобней всего определять в виде рекурсивных алгоритмов.PROCEDURE preorder (t: Node);BEGINIF t # NIL THENP(t); preorder(t.left); preorder(t.right)ENDEND preorderPROCEDURE inorder (t: Node);BEGINIF t # NIL THENinorder(t.left); P(t); inorder(t.right)ENDEND inorderРис. 4.24. Двоичное дерево 199PROCEDURE postorder (t: Node);BEGINIF t # NIL THENpostorder(t.left); postorder(t.right); P(t)ENDEND postorderЗаметим, что указатель t передается по значению. Этим выражается тот факт,что передается ссылка на рассматриваемое поддерево, а не переменная, чьим зна%чением является эта ссылка и чье значение могло бы быть изменено, если бы tпередавался как параметр%переменная.Пример обхода дерева – печать с правильным числом отступов, соответству%ющим уровню каждого узла.Двоичные деревья часто используют для представления набора данных, к эле%ментам которого нужно обращаться по уникальному ключу. Если дерево организовано таким образом, что для каждого узла ti все ключи в его левом подде%реве меньше, чем ключ узла ti, а ключи в правом поддереве больше, чем ключ ti, то такое дерево называют деревом поиска. В дереве поиска можно найти любой ключ,стартуя с корня и спускаясь в левое или правое поддерево в зависимости только от значения ключа в текущем узле. Мы видели, что n элементов можно организовать в двоичное дерево высоты всего лишь log(n). Поэтому поиск среди n элементов можно выполнить всего лишь за log(n) сравнений, если дерево идеально сбаланси%ровано. Очевидно, дерево гораздо лучше подходит для организации такого набора данных, чем линейный список из предыдущего раздела. Так как поиск здесь про%ходит по единственному пути от корня к искомому узлу, его легко запрограмми%ровать с помощью цикла:PROCEDURE locate (x: INTEGER; t: Node): Node;BEGINWHILE (t # NIL) & (t.key # x) DOIF t.key < x THEN t := t.right ELSE t := t.left ENDEND;RETURN tEND locateФункция locate(x, t) возвращает значение NIL, если в дереве, начинающемся с корня t, не найдено узла со значением ключа x. Как и в случае поиска в списке,сложность условия завершения подсказывает лучшее решение, а именно исполь%зование барьера. Этот прием применим и при работе с деревом. Аппарат указателей позволяет использовать единственный, общий для всех ветвей узел%барьер. Полу%чится дерево, у которого все листья привязаны к общему «якорю» (рис. 4.25). Барь%ер можно считать общим представителем всех внешних узлов, которыми было расширено исходное дерево (см. рис. 4.19):PROCEDURE locate (x: INTEGER; t: Node): Node;BEGINs.key := x; (* *)WHILE t.key # x DOДеревья Динамические структуры данных200IF t.key < x THEN t := t.right ELSE t := t.left ENDEND;RETURN tEND locateЗаметим, что в этом случае locate(x, t) возвращает значение s вместо NIL, то есть указатель на барьер, если в дереве с корнем t не найдено ключа со значением x4.4.3. Поиск и вставка в деревьяхВряд ли всю мощь метода динамического размещения с использованием указа%телей можно вполне оценить по примерам, в которых набор данных сначала стро%ится, а в дальнейшем не меняется. Интересней примеры, в которых дерево меня%ется, то есть растет и/или сокращается, во время выполнения программы. Это как раз тот случай, когда оказываются непригодными другие представления данных,например массив, и когда лучшее решение получается при использовании дерева с элементами, связанными посредством указателей.Мы сначала рассмотрим случай только растущего, но никогда не сокращаю%щегося дерева. Типичный пример – задача составления частотного словаря, кото%рую мы уже рассматривали в связи со связными списками. Сейчас мы рассмотрим ее снова. В этой задаче дана последовательность слов, и нужно определить число вхождений каждого слова. Это означает, что каждое слово ищется в дереве (кото%рое вначале пусто). Если слово найдено, то счетчик вхождений увеличивается;в противном случае оно включается в дерево со счетчиком, выставленным в 1. Мы будем называть эту задачу поиск по дереву со вставкой. Предполагается, что опре%делены следующие типы данных:Рис. 4.25. Дерево поиска с барьером (узел s) 201TYPE Node = POINTER TO RECORDkey, count: INTEGER;left, right: NodeENDКак и раньше, не составляет труда найти путь поиска. Однако если он заверша%ется тупиком (то есть приводит к пустому поддереву, обозначенному указателем со значением NIL), то данное слово нужно вставить в дерево в той точке, где было пустое поддерево. Например, рассмотрим двоичное дерево на рис. 4.26 и вставку имени Paul. Результат показан пунктирными линиями на том же рисунке.Рис. 4.26. Вставка в упорядоченное двоичное деревоПроцесс поиска формулируется в виде рекурсивной процедуры. Заметим, что ее параметр p передается по ссылке, а не по значению. Это важно, так как в случае вставки переменной, содержавшей значение NIL, нужно присвоить новое указа%тельное значение. Для входной последовательности из 21 числа, которую мы уже использовали для построения дерева на рис. 4.23, процедура поиска и вставок дает двоичное дерево, показанное на рис. 4.27; для каждого ключа k выполняется вызов search(k, root), где root – переменная типа NodePROCEDURE PrintTree (t: Node; h: INTEGER);(* ADruS443_Tree *)(* t h *)VAR i: INTEGER;BEGINIF t # NIL THENPrintTree(t.left, h+1);FOR i := 1 TO h DO Texts.Write(W, TAB) END;Texts.WriteInt(W, t.key, 6); Texts.WriteLn(W);PrintTree(t.right, h+1)ENDEND PrintTree;Деревья Динамические структуры данных202PROCEDURE search (x: INTEGER; VAR p: Node);BEGINIF p = NIL THEN (*x ; *)NEW(p); p.key := x; p.count := 1; p.left := NIL; p.right := NILELSIF x < p.key THEN search(x, p.left)ELSIF x > p.key THEN search(x, p.right)ELSE INC(p.count)ENDEND searchИ снова использование барьера немного упрощает программу. Ясно, что в на%чале программы переменная root должна быть инициализирована указателем на барьер s вместо значения NIL, а перед каждым поиском искомое значение x долж%но быть присвоено полю ключа барьера:PROCEDURE search (x: INTEGER; VAR p: Node);BEGINIF x < p.key THEN search(x, p.left)ELSIF x > p.key THEN search(x, p.right)ELSIF p # s THEN INC(p.count)ELSE (* *)NEW(p); p.key := x; p.left := s; p.right := s; p.count := 1ENDEND searchХотя целью этого алгоритма был поиск, его можно использовать и для сор%тировки. На самом деле он очень похож на сортировку вставками, а благодаря использованию дерева вместо массива исчезает необходимость перемещать ком%поненты, стоящие выше точки вставки. Древесную сортировку можно запрограм%Рис. 4.27. Дерево поиска, порожденное процедурой поиска и вставки 203мировать так, что она будет почти так же эффективна, как и лучшие известные методы сортировки массивов. Однако нужны некоторые предосторожности. Если обнаружено совпадение, новый элемент тоже нужно вставить. Если случай x = p.key обрабатывать так же, как и случай x > p.key, то алгоритм сортировки будет устой%чивым, то есть элементы с равными ключами будут прочитаны в том же относи%тельном порядке при просмотре дерева в нормальном порядке, в каком они встав%лялись.Вообще говоря, есть и более эффективные методы сортировки, но для прило%жений, где нужны как поиск, так и сортировка, можно уверенно рекомендовать алгоритм поиска и вставки. Он действительно очень часто применяется в компи%ляторах и банках данных для организации объектов, которые нужно хранить и искать. Хорошим примером здесь является построение указателя перекрестных ссылок для заданного текста; мы уже обращались к этому примеру для иллю%страции построения списков.Итак, наша задача – написать программу, которая читает текст и печатает его,последовательно нумеруя строки, и одновременно собирает все слова этого текста вместе с номерами строк, в которых встретилось каждое слово. По завершении просмотра должна быть построена таблица, содержащая все собранные слова в алфавитном порядке вместе с соответствующими списками номеров строк.Очевидно, дерево поиска (иначе лексикографическое дерево) будет прекрасным кандидатом для представления информации о словах, встречающихся в тексте.Теперь каждый узел будет не только содержать слово в качестве значения ключа,но и являться началом списка номеров строк. Каждую запись о вхождении слова в текст будем называть «элементом» (item; нехватка синонимов для перевода ком%пенсируется кавычками – прим. перев.).Таким образом, в данном примере фигури%руют как деревья, так и линейные списки. Программа состоит из двух главных час%тей, а именно из фазы просмотра и фазы печати таблицы. Последняя – простая адаптация процедуры обхода дерева; здесь при посещении каждого узла выполня%ется печать значения ключа (слова) вместе с соответствующим списком номеров строк. Ниже даются дополнительные пояснения о программе, приводимой далее.Таблица 4.3 показывает результаты обработки текста процедуры search1. Словом считается любая последовательность букв и цифр, начинающаяся с буквы.2. Так как длина слов может сильно меняться, их литеры хранятся в особом массиве%буфере, а узлы дерева содержат индекс первой буквы ключа.3. Желательно, чтобы в указателе перекрестных ссылок номера строк печата%лись в возрастающем порядке.Поэтому список «элементов» должен сохранять порядок соответствующих вхождений слова в тексте. Из этого требования вытекает, что нужны два указа%теля в каждом узле, причем один ссылается на первый, а другой – на последний«элемент» списка. Мы предполагаем наличие глобального объекта печати W,а также переменной, представляющей собой текущий номер строки в тексте.Деревья Динамические структуры данных204CONST WordLen = 32;(* ADruS443_CrossRef *)TYPE Word = ARRAY WordLen OF CHAR;Item = POINTER TO RECORD (*" "*)lno: INTEGER; next: ItemEND;Node = POINTER TO RECORDkey: Word;first, last: Item; (**)left, right: Node (* *)END;VAR line: INTEGER;PROCEDURE search (VAR w: Node; VAR a: Word);VAR q: Item;BEGINIF w = NIL THEN (* ; *)NEW(w); NEW(q); q.lno := line;COPY(a, w.key); w.first := q; w.last := q; w.left := NIL; w.right := NILELSIF w.key < a THEN search(w.right, a)ELSIF w.key > a THEN search(w.left, a)ELSE (* *)NEW(q); q.lno := line; w.last.next := q; w.last := qENDEND search;PROCEDURE Tabulate (w: Node);VAR m: INTEGER; item: Item;BEGINIF w # NIL THENTabulate(w.left);Texts.WriteString(W, w.key); Texts.Write(W, TAB); item := w.first; m := 0;REPEATIF m = 10 THENTexts.WriteLn(W); Texts.Write(W, TAB); m := 0END;INC(m); Texts.WriteInt(W, item.lno, 6); item := item.nextUNTIL item = NIL;Texts.WriteLn(W); Tabulate(w.right)ENDEND Tabulate;PROCEDURE CrossRef (VAR R: Texts.Reader);VAR root: Node;i: INTEGER; ch: CHAR; w: Word;(* # ˆ W*)BEGINroot := NIL; line := 0;Texts.WriteInt(W, 0, 6); Texts.Write(W, TAB);Texts.Read(R, ch);WHILE R.eot DOIF ch = 0DX THEN (* *)Texts.WriteLn(W); 205INC(line);Texts.WriteInt(W, line, 6); Texts.Write(W, 9X);Texts.Read(R, ch)ELSIF ("A" <= ch) & (ch <= "Z") OR ("a" <= ch) & (ch <= "z") THENi := 0;REPEATIF i < WordLen–1 THEN w[i] := ch; INC(i) END;Texts.Write(W, ch); Texts.Read(R, ch)UNTIL (i = WordLen–1) OR (("A" <= ch) & (ch <= "Z")) &(("a" <= ch) & (ch <= "z")) & (("0" <= ch) & (ch <= "9"));w[i] := 0X; (* *)search(root, w)ELSETexts.Write(W, ch);Texts.Read(R, ch)END;END;Texts.WriteLn(W); Texts.WriteLn(W); Tabulate(root)END CrossRef;Таблица 4.3.Таблица 4.3.Таблица 4.3.Таблица 4.3.Таблица 4.3. Пример выдачи генератора перекрестных ссылок0 PROCEDURE search (x: INTEGER; VAR p: Node);1 BEGIN2 IF x < p.key THEN search(x, p.left)3 ELSIF x > p^key THEN search(x, p.right)4 ELSIF p # s THEN INC(p.count)5 ELSE (*insert*) NEW(p);6 p.key := x; p.left := s; p.right := s; p.count := 1 7 END8 ENDBEGIN1ELSE5ELSIF3 4END7 8IF2INC4INTEGER0NEW5Node0PROCEDURE0THEN2 3 4VAR0count4 6insert5key2 3 6left2 6p0 2 2 3 3 4 4 5 6 6 6 6right3 6s4 6 6search0 2 3x0 2 2 3 3 6Деревья Динамические структуры данных2061   ...   12   13   14   15   16   17   18   19   ...   22

4.4.4. Удаление из дереваОбратимся теперь к операции, противоположной вставке, то есть удалению.Наша цель – построить алгоритм для удаления узла с ключом x из дерева с упоря%доченными ключами. К сожалению, удаление элемента в общем случае сложнее,чем вставка. Его несложно выполнить, если удаляемый узел является концевым или имеет единственного потомка. Трудность – в удалении элемента с двумя по%томками, так как нельзя заставить один указатель указывать в двух направлениях.В этой ситуации удаляемый элемент должен быть заменен либо на самый правый элемент его левого поддерева, либо на самый левый элемент его правого поддерева,причем оба этих элемента не должны иметь более одного потомка. Детали показаны ниже в рекурсивной процедуре delete. В ней различаются три случая:1. Отсутствует компонента с ключом, равным x2. У компоненты с ключом x не более одного потомка.3. У компоненты с ключом x два потомка.PROCEDURE delete (x: INTEGER; VAR p: Node);(* ADruS444_Deletion *)(* *)PROCEDURE del (VAR r: Node);BEGINIF r.right # NIL THENdel(r.right)ELSEp.key := r.key; p.count := r.count;r := r.leftENDEND del;BEGINIF p = NIL THEN (* *)ELSIF x < p.key THEN delete(x, p.left)ELSIF x > p.key THEN delete(x, p.right)ELSE(* p^:*)IF p.right = NIL THEN p := p.leftELSIF p.left = NIL THEN p := p.rightELSE del(p.left)ENDENDEND deleteВспомогательная рекурсивная процедура del активируется только в случае 3.Она спускается по крайней правой ветви левого поддерева элемента q^, который должен быть удален, и затем заменяет содержимое (ключ и счетчик) записи q^ на соответствующие значения крайней правой компоненты r^ этого левого подде%рева, после чего запись r^ более не нужна.Заметим, что мы не упоминаем процедуру, которая была бы обратной для NEWи указывала бы, что память больше не нужна и ее можно использовать для других 207целей. Вообще предполагается, что вычислительная система распознает ненуж%ную более переменную по тому признаку, что никакие другие переменные больше на нее не указывают, и что поэтому к ней больше невозможно обратиться. Такой механизм называется сборкой мусора. Это средство не языка программирования,а, скорее, его реализации.Рисунок 4.28 иллюстрирует работу процедуры delete. Рассмотрим дерево (a);затем последовательно удалим узлы с ключами 13, 15, 5, 10. Получающиеся дере%вья показаны на рис. 4.28 (b–e).Рис. 4.28. Удаление из дерева4.4.5. Анализ поиска по дереву со вставкамиВполне естественно испытывать подозрения в отношении алгоритма поиска по дереву со вставкой. По крайней мере, до получения дополнительных сведений о его поведении должна оставаться толика скептицизма. Многих программистов сначала беспокоит тот факт, что в общем случае мы не знаем, как будет расти дере%во и какую форму оно примет. Можно только догадываться, что скорее всего оно не получится идеально сбалансированным. Поскольку среднее число сравнений,нужное для отыскания ключа в идеально сбалансированном дереве с n узлами,примерно равно log(n), число сравнений в дереве, порожденном этим алгоритмом,будет больше. Но насколько больше?Прежде всего легко определить наихудший случай. Предположим, что все ключи поступают в уже строго возрастающем (или убывающем) порядке. ТогдаДеревья Динамические структуры данных208каждый ключ присоединяется непосредственно справа (слева) к своему предку,и получается полностью вырожденное дерево, то есть по сути линейный списк.Средние затраты на поиск тогда составят n/2 сравнений. В этом наихудшем слу%чае эффективность алгоритма поиска, очевидно, очень низка, что вроде бы под%тверждает наш скептицизм. Разумеется, остается вопрос, насколько вероятен этот случай. Точнее, хотелось бы знать длину an пути поиска, усредненную по всем nключам и по всем n! деревьям, которые порождаются из n! перестановок n различ%ных исходных ключей. Эта задача оказывается довольно простой и обсуждается здесь как типичный пример анализа алгоритма, а также ввиду практической важности результата.Пусть даны n различных ключей со значениями 1, 2, ... ,n. Предположим, что они поступают в случайном порядке.Вероятность для первого ключа – который становится корневым узлом – иметь значение i равна 1/n. В конечном счете его левое поддерево будет содержать i%1 узлов, а его правое поддерево – n%i узлов (см. рис. 4.29). Среднюю дли%ну пути в левом поддереве обозначим как ai–1, а в правом как an–i, снова предполагая, что все возможные перестанов%ки остальных n%1 ключей равновероятны. Средняя длина пути в дереве с n узлами равна сумме произведений уровня каждого узла, умноженного на вероятность обращения к нему. Если предположить, что все узлы ищутся с равной вероятностью, то an = (SSSSSi: 1 ≤ i ≤ n: p i) / n где pi – длина пути узла iВ дереве на рис. 4.29 узлы разделены на три класса:1)i–1 узлов в левом поддереве имеют среднюю длину пути ai–1;2) у корня длина пути равна 0;3)n–i узлов в правом поддереве имеют среднюю длину пути an–iПолучаем, что вышеприведенная формула выражается как сумма членов 1 и 3:a n(i) = ((i–1) * a i–1 + (n–i) * a n–i) / nИскомая величина an есть среднее величин an(i) по всем i = 1 ... n, то есть по всем деревьям с ключами 1, 2, ... , n в корне:a n= (SSSSSi: 1 ≤ i ≤ n: (i–1) a i–1 + (n–i) a n–i) / n2= 2 * (SSSSSi: 1 ≤ i ≤ n: (i–1) a i–1) / n2= 2 * (SSSSSi: 1 ≤ i < n: i * a i) / n2Это уравнение является рекуррентным соотношением вида an = f1(a1, a2, ... , a n-1). Из него получим более простое рекуррентное соотноше%ние вида an = f2(a n–1). Следующее выражение (1) получаетcя выделением после%днего члена, а (2) – подстановкой n–1 вместо n:Рис. 4.29. Распреде:ление весов по ветвям 209(1) a n = 2*(n–1) * a n–1 /n2 + 2 * (SSSSSi: 1 ≤ i < n–1: i * a i) / n2(2) a n–1 = 2 * (SSSSSi: 1 ≤ i < n–1: i * a i) / (n–1)2Умножая (2) на (n–1)2/n2, получаем:(3) 2 * (SSSSSi: 1 ≤ i < n–1: i * a i) / n2 = a n–1 * (n–1)2/n2Подставляя правую часть уравнения (3) в (1), находим:a n = 2 * (n–1) * a n–1 / n2 + a n–1 * (n–1)2 / n2 = a n–1 * (n–1)2 / n2В итоге получается, что an допускает нерекурсивное замкнутое выражение через гармоническую сумму:Hn = 1 + 1/2 + 1/3 + … + 1/n an = 2 * (Hn * (n+1)/n – 1)Из формулы Эйлера (используя константу Эйлера g = 0.577...)Hn = g + ln n + 1/12n2 + ...выводим приближенное значение для больших n:a n = 2 * (ln n + g – 1)Так как средняя длина пути в идеально сбалансированном дереве примерно равна an' = log n – 1то, пренебрегая константами, не существенными при больших n, получаем:lim (a n/a n') = 2 * ln(n) / log(n) = 2 ln(2) = 1.386...Чему учит нас этот результат? Он говорит нам, что усилия, направленные на то, чтобы всегда строить идеально сбалансированное дерево вместо случайного,позволяют нам ожидать – всегда предполагая, что все ключи ищутся с равной ве%роятностью, – среднее сокращение длины пути поиска самое большее на 39%.Нужно подчеркнуть слово среднее, поскольку сокращение может, конечно, быть намного больше в том неудачном случае, когда создаваемое дерево полностью выродилось в список, вероятность чего, однако, очень низка. В этой связи стоит отметить, что средняя длина пути случайного дерева тоже растет строго логариф%мически с ростом числа его узлов, несмотря на то что длина пути для наихудшего случая растет линейно.Число 39% накладывает ограничение на объем дополнительных усилий, кото%рые можно с пользой потратить на какую%либо реорганизацию дерева после вставки ключей. Естественно, полезная отдача от таких усилий сильно зависит от отношения числа обращений к узлам (извлечение информации) к числу вставок(обновлений). Чем выше это отношение, тем больше выигрыш от процедуры реорганизации. Число 39% достаточно мало, чтобы в большинстве приложений улучшение простого алгоритма вставки в дерево не оправдывало себя, за исклю%чением случаев, когда велики число узлов и отношение числа обращений к числу вставок.Деревья Динамические структуры данных2104.5. Сбалансированные деревьяИз предыдущего обсуждения ясно, что процедура вставки, всегда восстанавлива%ющая идеальную сбалансированность дерева, вряд ли может быть полезной, так как восстановление идеального баланса после случайной вставки – операция довольно нетривиальная. Возможный путь повышения эффективности – попытаться найти менее жесткое определение баланса. Такой неидеальный критерий должен приво%дить к более простым процедурам реорганизации дерева за счет незначительного ухудшения средней эффективности поиска. Одно такое определение сбалансиро%ванности было дано Адельсоном%Вельским и Ландисом [4.1]. Вот оно:Дерево называется сбалансированным в том и только в том случае, когда для каждого узла высота двух его поддеревьев отличается не более чем на 1.Деревья, удовлетворяющие этому условию, часто называют по именам их изобретателей АВЛдеревьями. Мы будем называть их просто сбалансирован%ными, так как этот критерий оказался в высшей степени удачным. (Заметим, что все идеально сбалансированные деревья также являются АВЛ%деревьями.)Это определение не только само простое, но и приводит к не слишком сложной процедуре балансировки, а средняя длина поиска здесь практически не отличает%ся от случая идеально сбалансированных деревьев. Следующие операции могут выполняться для сбалансированных деревьев за время порядка O(log n) даже в наихудшем случае:1) поиск узла с заданным ключом;2) вставка узла с заданным ключом;3) удаление узла с заданным ключом.Эти утверждения суть прямые следствия теоремы, доказанной Адельсоном%Вельским и Ландисом, которая гарантирует, что сбалансированное дерево не бо%лее чем на 45% превосходит по высоте его идеально сбалансированный вариант,независимо от числа узлов. Если обозначить высоту сбалансированного дерева с n узлами как hb(n), то log(n+1) < h b(n) < 1.4404*log(n+2) – 0.328Ясно, что оптимум достигается для идеально сбалансированного дерева с n = 2k–1. Но какова структура наихудшего АВЛ%дерева? Чтобы найти макси%мальную высоту h всех сбалансированных деревьев с n узлами, фиксируем высо%ту h и попытаемся построить сбалансированное дерево с минимальным числом узлов. Эта стратегия предпочтительна потому, что, как и в случае с минимальной высотой, это значение высоты может быть получено только для некоторых конк%ретных значений n. Обозначим такое дерево высоты h как Th. Очевидно, что T0 –пустое дерево, а T1 – дерево с одним узлом. Чтобы построить дерево Th для h > 1,присоединим к корню два поддерева, у которых число узлов снова минимально.Поэтому поддеревья принадлежат этому же классу T. Очевидно, одно поддерево должно иметь высоту h–1, а другое тогда может иметь высоту на единицу меньше,то есть h–2Рисунок 4.30 показывает деревья с высотой 2, 3 и 4. Поскольку прин% 211цип их построения сильно напоминает определение чисел Фибоначчи, их называ%ют деревьями Фибоначчи (см. рис. 4.30). Определяются они так:1. Пустое дерево есть дерево Фибоначчи высоты 0.2. Дерево с одним узлом есть дерево Фибоначчи высоты 1.3. Если Th–1 и Th–2 – деревья Фибоначчи высоты h–1 и h–2 соответственно, тоTh = h–1, x, Th–2> – дерево Фибоначчи.4. Других деревьев Фибоначчи нет.Рис. 4.30. Деревья Фибоначчи высоты 2, 3 и 4Число узлов в Th определяется следующим простым рекуррентным соотноше%нием:N0 = 0, N1 = 1Nh = Nh–1 + 1 + Nh–2Nh – это число узлов, для которого может реализоваться наихудший случай(верхний предел для h); такие числа называются числами Леонардо.4.5.1. Вставка в сбалансированное деревоТеперь посмотрим, что происходит, когда в сбалансированное дерево вставляется новый узел. Если r – корень с левым и правым поддеревьями L и R, то могут иметь место три случая. Пусть новый узел вставляется в L, увеличивая его высоту на 1:1.hL = hR: высота L и R становится неравной, но критерий сбалансирован%ности не нарушается.2.hL < hR: высота L и R становится равной, то есть баланс только улучшается.3.hL > hR: критерий сбалансированности нарушается, и дерево нужно пере%страивать.Рассмотрим дерево на рис. 4.31. Узлы с ключами 9 или 11 можно вставить без перестройки: поддерево с корнем 10 станет асимметричным (случай 1), а у дерева с корнем 8 баланс улучшится (случай 2). Однако вставка узлов 1, 3, 5 или 7 потре%бует перестройки для восстановления баланса.Сбалансированные деревья Динамические структуры данных212Пристально рассматривая ситуацию, обнаружи%ваем, что есть только две существенно разные кон%фигурации, требующие раздельного анализа. Ос%тальные сводятся к этим двум по соображениям симметрии. Случай 1 соответствует вставке ключей1 или 3 в дерево на рис. 4.31, случай 2 – вставке клю%чей 5 или 7.Эти два случая изображены в более общем виде на рис. 4.32, где прямоугольники обозначают подде%ревья, а увеличение высоты за счет вставки указано крестиками. Желаемый баланс восстанавливается простыми преобразованиями. Их результат показан на рис. 4.33; заметим, что разрешены только перемещения по вертикали, а относи%тельное горизонтальное положение показанных узлов и поддеревьев не должно меняться.Рис. 4.31. Сбалансированное деревоРис. 4.32. Нарушение баланса в результате вставкиРис. 4.33. Восстановление баланса 213Алгоритм вставки и балансировки критически зависит от способа хранения информации о сбалансированности дерева. Одна крайность – вообще не хранить эту информацию в явном виде. Но тогда баланс узла должен быть заново вычислен каждый раз, когда он может измениться при вставке, что приводит к чрезмерным накладным расходам. Другая крайность – в явном виде хранить соответствующий баланс в каждом узле. Тогда определение типа Node дополняется следующим об%разом:TYPE Node = POINTER TO RECORDkey, count, bal: INTEGER; (*bal = –1, 0, +1*)left, right: NodeENDВ дальнейшем балансом узла будем называть разность высоты его правого и левого поддеревьев и положим приведенное определение типа узла в основу по%строения алгоритма. Процесс вставки узла состоит из трех последовательных шагов:1) пройти по пути поиска, пока не выяснится, что данного ключа в дереве еще нет;2) вставить новый узел и определить получающийся баланс;3) вернуться по пути поиска и проверить баланс в каждом узле. Если нужно,выполнять балансировку.Хотя этот способ требует избыточных проверок (когда баланс установлен, его не нужно проверять для предков данного узла), мы будем сначала придерживаться этой очевидно корректной схемы, так как ее можно реализовать простым расшире%нием уже построенной процедуры поиска и вставки. Эта процедура описывает нуж%ную операцию поиска для каждого отдельного узла, и благодаря ее рекурсивной формулировке в нее легко добавить дополнительные действия, выполняемые при возвращении по пути поиска. На каждом шаге должна передаваться информация о том, увеличилась или нет высота поддерева, в котором сделана вставка. Поэтому мы расширим список параметров процедуры булевским параметром h со значени%ем . Ясно, что это должен быть параметр%перемен%ная, так как через него передается некий результат.Теперь предположим, что процесс возвращается в некий узел p^ (А на рис. 4.32 –прим. перев.) из левой ветви с указанием, что ее высота увеличилась. Тогда нужно различать три случая соотношения высот поддеревьев до вставки:1)hL < hR, p.bal = +1; дисбаланс в p исправляется вставкой;2)hL = hR, p.bal = 0;после вставки левое поддерево перевешивает;3)hL > hR, p.bal = –1; нужна балансировка.В третьем случае изучение баланса корня (B на рис. 4.32 – прим. перев.) левого поддерева (скажем, p1.bal) покажет, какой из случаев 1 или 2 на рис. 4.32 имеет место. Если у того узла тоже левое поддерево выше, чем правое, то мы имеем дело со случаем 1, иначе со случаем 2. (Убедитесь, что в этом случае не может встре%титься левое поддерево с нулевым балансом в корне.) Необходимые операции ба%Сбалансированные деревья Динамические структуры данных214лансировки полностью выражаются в виде нескольких присваиваний указателей.На самом деле имеет место циклическая перестановка указателей, приводящая к одиночной или двойной «ротации» двух или трех участвующих узлов. Кроме ротации указателей, нужно обновить и показатели баланса соответствующих уз%лов. Детали показаны в процедурах поиска, вставки и балансировки.Рис. 4.34. Вставки в сбалансированное деревоРабота алгоритма показана на рис. 4.34. Рассмотрим двоичное дерево (a), со%стоящее только из двух узлов. Вставка ключа 7 дает сначала несбалансированное дерево (то есть линейный список). Его балансировка требует одиночной RR%рота%ции, приводящей к идеально сбалансированному дереву (b). Дальнейшая вставка узлов 2 и 1 приводит к разбалансировке поддерева с корнем 4. Это поддерево ба%лансируется одиночной LL%ротацией (d). Cледующая вставка ключа 3 тут же на%рушает баланс в корневом узле 5. После чего баланс восстанавливается более сложной двойной LR%ротацией; результат – дерево (e). После следующей вставки баланс может нарушиться только в узле 5. В самом деле, вставка узла 6 должна приводить к четвертому случаю балансировки из описанных ниже, то есть двой%ной RL%ротации. Окончательный вид дерева показан на рис. 4.34 (f).PROCEDURE search (x: INTEGER; VAR p: Node; VAR h: BOOLEAN);VAR p1, p2: Node;(* ADruS45_AVL *)BEGIN(*h*)IF p = NIL THEN (* *)NEW(p); p.key := x; p.count := 1; p.left := NIL; p.right := NIL; p.bal := 0;h := TRUE;ELSIF p.key > x THENsearch(x, p.left, h); 215IF h THEN (* *)IF p.bal = 1 THEN p.bal := 0; h := FALSEELSIF p.bal = 0 THEN p.bal := –1ELSE (*bal = –1, *) p1 := p.left;IF p1.bal = –1 THEN (* LL– *)p.left := p1.right; p1.right := p;p.bal := 0; p := p1ELSE (* LR– *) p2 := p1.right;p1.right := p2.left; p2.left := p1;p.left := p2.right; p2.right := p;IF p2.bal = –1 THEN p.bal := 1 ELSE p.bal := 0 END;IF p2.bal = +1 THEN p1.bal := –1 ELSE p1.bal := 0 END;p := p2END;p.bal := 0; h := FALSEENDENDELSIF p.key < x THENsearch(x, p.right, h);IF h THEN (* *)IF p.bal = –1 THEN p.bal := 0; h := FALSEELSIF p.bal = 0 THEN p.bal := 1ELSE (*bal = +1, *) p1 := p.right;IF p1.bal = 1 THEN (* RR– *)p.right := p1.left; p1.left := p;p.bal := 0; p := p1ELSE (* RL– *) p2 := p1.left;p1.left := p2.right; p2.right := p1;p.right := p2.left; p2.left := p;IF p2.bal = +1 THEN p.bal := –1 ELSE p.bal := 0 END;IF p2.bal = –1 THEN p1.bal := 1 ELSE p1.bal := 0 END;p := p2END;p.bal := 0; h := FALSEENDENDELSE INC(p.count)ENDEND searchВозникает два особенно интересных вопроса касательно производительности алгоритма вставки в сбалансированные деревья:1. Если все n! перестановок n ключей встречаются с одинаковой вероятностью,какова будет средняя высота получающегося сбалансированного дерева?2. С какой вероятностью вставка приводит к необходимости балансировки?Задача математического исследования этого сложного алгоритма остается нере%шенной. Эмпирические тесты подтверждают предположение, что средняя высота сбалансированного дерева, построенного таким способом, равна h = log(n)+c, где c –Сбалансированные деревья Динамические структуры данных216небольшая константа (c ≈ 0.25). Это означает, что на практике АВЛ%деревья так же хороши, как и идеально сбалансированные деревья, хотя работать с ними го%раздо проще. Эмпирические данные также показывают, что в среднем одна балан%сировка нужна примерно на каждые две вставки. Здесь одиночные и двойные ро%тации равновероятны. Разумеется, пример на рис. 4.34 был тщательно подобран,чтобы показать наибольшее число ротаций при минимуме вставок.Сложность действий по балансировке говорит о том, что использовать сбалан%сированные деревья следует только тогда, когда поиск информации производится значительно чаще, чем вставки. Тем более что с целью экономии памяти узлы таких деревьев поиска обычно реализуются как плотно упакованные записи. По%этому скорость изменения показателей баланса, требующих только двух битов каждый, часто решающим образом влияет на эффективность балансировки. Эм%пирические оценки показывают, что привлекательность сбалансированных дере%вьев сильно падает, если записи должны быть плотно упакованы. Так что пре%взойти простейший алгоритм вставки в дерево оказывается нелегко.1   ...   14   15   16   17   18   19   20   21   22

4.5.2. Удаление из сбалансированного дереваНаш опыт с удалением из деревьев подсказывает, что и в случае сбалансиро%ванных деревьев удаление будет более сложной операцией, чем вставка. Это на самом деле так, хотя операция балансировки остается в сущности такой же, как и для вставки. В частности, здесь балансировка тоже состоит из одиночных или двойных ротаций узлов.Процедура удаления из сбалансированного дерева строится на основе алго%ритма удаления из обычного дерева. Простые случаи – концевые узлы и узлы с единственным потомком. Если удаляемый узел имеет два поддерева, мы снова будет заменять его самым правым узлом его левого поддерева. Как и в случае вставки, добавляется булевский параметр%переменная h со значением v . Необходимость в балансировке может возникнуть,только если h истинно. Это случается при обнаружении и удалении узла, или если сама балансировка уменьшает высоту поддерева. Здесь мы введем две процедуры для (симметричных) операций балансировки, так как их нужно вызывать из более чем одной точки алгоритма удаления. Заметим, что процедуры balanceL или balanceR вызываются после того, как уменьшилась высота левой или правой вет%ви соответственно.Работа процедуры проиллюстрирована на рис. 4.35. Если дано сбалансирован%ное дерево (a), то последовательное удаление узлов с ключами 4, 8, 6, 5, 2, 1, и 7приводит к деревьям (b)...(h). Удаление ключа 4 само по себе просто, так как соот%ветствующий узел концевой. Но это приводит к несбалансированному узлу 3. Его балансировка требует одиночной LL%ротации. Балансировка опять нужна после удаления узла 6. На этот раз правое поддерево для корня (7) балансируется оди%ночной RR%ротацией. Удаление узла 2, само по себе бесхитростное, так как узел имеет лишь одного потомка, требует применения сложной двойной RL%ротации.Наконец, четвертый случай, двойная LR%ротация, вызывается после удаления 217узла 7, который сначала замещается крайним правым элементом своего левого поддерева, то есть узлом 3.PROCEDURE balanceL (VAR p: Node; VAR h: BOOLEAN);(*ADruS45_AVL*)VAR p1, p2: Node;BEGIN(*h; v *)IF p.bal = –1 THEN p.bal := 0Рис. 4.35. Удаления из сбалансированного дереваСбалансированные деревья Динамические структуры данных218ELSIF p.bal = 0 THEN p.bal := 1; h := FALSEELSE (*bal = 1, *) p1 := p.right;IF p1.bal >= 0 THEN (* RR– *)p.right := p1.left; p1.left := p;IF p1.bal = 0 THEN p.bal := 1; p1.bal := –1; h := FALSEELSE p.bal := 0; p1.bal := 0END;p := p1ELSE (* RL– *)p2 := p1.left;p1.left := p2.right; p2.right := p1;p.right := p2.left; p2.left := p;IF p2.bal = +1 THEN p.bal := –1 ELSE p.bal := 0 END;IF p2.bal = –1 THEN p1.bal := 1 ELSE p1.bal := 0 END;p := p2; p2.bal := 0ENDENDEND balanceL;PROCEDURE balanceR (VAR p: Node; VAR h: BOOLEAN);VAR p1, p2: Node;BEGIN(*h; v *)IF p.bal = 1 THEN p.bal := 0ELSIF p.bal = 0 THEN p.bal := –1; h := FALSEELSE (*bal = –1, rebalance*) p1 := p.left;IF p1.bal <= 0 THEN (* LL– *)p.left := p1.right; p1.right := p;IF p1.bal = 0 THEN p.bal := –1; p1.bal := 1; h := FALSEELSE p.bal := 0; p1.bal := 0END;p := p1ELSE (* LR– *)p2 := p1.right;p1.right := p2.left; p2.left := p1;p.left := p2.right; p2.right := p;IF p2.bal = –1 THEN p.bal := 1 ELSE p.bal := 0 END;IF p2.bal = +1 THEN p1.bal := –1 ELSE p1.bal := 0 END;p := p2; p2.bal := 0ENDENDEND balanceR;PROCEDURE delete (x: INTEGER; VAR p: Node; VAR h: BOOLEAN);VAR q: Node;PROCEDURE del (VAR r: Node; VAR h: BOOLEAN);BEGIN 219(*h*)IF r.right # NIL THENdel(r.right, h);IF h THEN balanceR(r, h) ENDELSEq.key := r.key; q.count := r.count;q := r; r := r.left; h := TRUEENDEND del;BEGIN(*h*)IF p = NIL THEN (* *)ELSIF p.key > x THENdelete(x, p.left, h);IF h THEN balanceL(p, h) ENDELSIF p.key < x THENdelete(x, p.right, h);IF h THEN balanceR(p, h) ENDELSE (* p^*)q := p;IF q.right = NIL THEN p := q.left; h := TRUEELSIF q.left = NIL THEN p := q.right; h := TRUEELSEdel(q.left, h);IF h THEN balanceL(p, h) ENDENDENDEND deleteК счастью, удаление элемента из сбалансированного дерева тоже может быть выполнено – в худшем случае – за O(log n) операций. Однако нельзя игнориро%вать существенную разницу в поведении процедур вставки и удаления. В то время как вставка единственного ключа может потребовать самое большее одной рота%ции (двух или трех узлов), удаление может потребовать ротации в каждом узле пути поиска. Например, рассмотрим удаление крайнего правого узла дерева Фи%боначчи. В этом случае удаление любого одного узла приводит к уменьшению высоты дерева; кроме того, удаление крайнего правого узла требует максималь%ного числа ротаций. Здесь имеет место весьма неудачная комбинация – наихуд%ший выбор узла в наихудшей конфигурации сбалансированного дерева. А на%сколько вероятны ротации в общем случае?Удивительный результат эмпирических тестов состоит в том, что хотя одна ротация требуется примерно на каждые две вставки, лишь одна ротация потре%буется на пять удалений. Поэтому в сбалансированных деревьях удаление элемента является столь же легкой (или столь же трудоемкой) операцией, как и вставка.Сбалансированные деревья Динамические структуры данных2204.6. Оптимальные деревья поискаДо сих пор наш анализ организации деревьев поиска опирался на предположение,что частота обращений ко всем узлам одинакова, то есть что все ключи с равной вероятностью могут встретиться в качестве аргумента поиска. Вероятно, это луч%шая гипотеза, если о распределении вероятностей обращений к ключам ничего не известно. Однако бывают случаи (хотя они, скорее, исключения, чем правило),когда такая информация есть. Для подобных случаев характерно, что ключи все%гда одни и те же, то есть структура дерева поиска не меняется, то есть не выпол%няются ни вставки, ни удаления. Типичный пример – лексический анализатор(сканер) компилятора, который для каждого слова (идентификатора) определя%ет, является ли оно ключевым (зарезервированным) словом. В этом случае стати%стическое исследование сотен компилируемых программ может дать точную ин%формацию об относительных частотах появления таких слов и, следовательно,обращений к ним в дереве поиска.Предположим, что в дереве поиска вероятность обращения к ключу i равнаPr {x = k i} = p i, (SSSSSi: 1 ≤ i ≤ n : p i) = 1Мы сейчас хотим организовать дерево поиска так, чтобы полное число шагов по%иска – для достаточно большого числа попыток – было минимальным. Для этого изменим определение длины пути, (1) приписывая каждому узлу некоторый вес и(2) считая, что корень находится на уровне 1 (а не 0), поскольку с ним связано первое сравнение на пути поиска. Узлы, к которым обращений много, становятся тяжелыми, а те, которые посещаются редко, – легкими. Тогда взвешенная длинапутей (внутренних) равна сумме всех путей из корня до каждого узла, взвешен%ных с вероятностью обращения к этому узлу:P = SSSSSi: 1 ≤ i ≤ n : p i * h ih i – уровень узла i. Теперь наша цель – минимизировать взвешенную длину путей для заданного распределения вероятностей. Например, рассмотрим набор клю%чей 1, 2, 3 с вероятностями обращения p1 = 1/7, p2 = 2/7 и p3 = 4/7. Из этих трех ключей дерево поиска можно составить пятью разными способами (см. рис. 4.36).Из определения можно вычислить взвешенные длины путей деревьев (a)–(e):P(a) = 11/7, P(b) = 12/7, P(c) = 12/7, P(d) = 15/7, P(e) = 17/7Рис. 4.36. Деревья поиска с 3 узлами 221Таким образом, в этом примере оптимальным оказывается не идеально сбалан%сированное дерево (c), а вырожденное дерево (a).Пример сканера компилятора сразу наводит на мысль, что задачу нужно не%много обобщить: слова, встречающиеся в исходном тексте, не всегда являются ключевыми; на самом деле ключевые слова – скорее исключения. Обнаружение того факта, что некое слово k не является ключом в дереве поиска, можно рассма%тривать как обращение к так называемому дополнительному узлу, вставленному между ближайшими меньшим и большим ключами (см. рис. 4.19), для которого определена длина внешнего пути. Если вероятность qi того, что аргумент поиска xлежит между этими двумя ключами ki и ki+1, тоже известна, то эта информация может существенно изменить структуру оптимального дерева поиска. Поэтому,обобщая задачу, будем учитывать также и безуспешные попытки поиска. Теперь общая взвешенная длина путей равнаP = (SSSSSi: 1 ≤ i ≤ n : p i*h i) + (SSSSSi: 1 ≤ i ≤ m : q i*h'i)где(SSSSSi: 1 ≤ i ≤ n : p i) + (SSSSSi: 1 ≤ i ≤ m : q i) = 1и где hi – уровень (внутреннего) узла i, а h'j – уровень внешнего узла j. Тогда сред%нюю взвешенную длину пути можно назвать ценой дерева поиска, поскольку она является мерой ожидаемых затрат на поиск. Дерево поиска с минимальной ценой среди всех деревьев с заданным набором ключей ki и вероятностей pi и qi называ%ется оптимальным деревом.Чтобы найти оптимальное дерево, не нужно требовать, чтобы сумма всех чисел p или q равнялась 1. На самом деле эти вероятности обычно определяются из экс%периментов, где подсчитывается число обращений к каждому узлу. В дальнейшемРис. 4.37. Дерево поиска с частотами обращенийОптимальные деревья поиска Динамические структуры данных222вместо вероятностей pi и qj мы будем использовать такие счетчики и обозначать их следующим образом:a i= сколько раз агрумент поиска x был равен ki bj = сколько раз агрумент поиска x оказался между kj и kj+1По определению, b0 – число случаев, когда x оказался меньше k1, а bn – когда xбыл больше kn (см. рис. 4.37). В дальнейшем мы будем использовать P для обозна%чения полной взвешенной длины путей вместо средней длины пути:P = (SSSSSi: 1 ≤ i ≤ n : a i*h i) + (SSSSSi: 1 ≤ i ≤ m : b i*h'i)Таким образом, в дополнение к тому, что уже не нужно вычислять вероятности по измеренным частотам, получаем дополнительный бонус: при поиске оптималь%ного дерева можно работать с целыми числами вместо дробных.Учитывая, что число возможных конфигураций n узлов растет экспоненци%ально с n, задача нахождения оптимума для больших n кажется безнадежной. Од%нако оптимальные деревья обладают одним важным свойством, которое помогает в их отыскании: все их поддеревья также оптимальны. Например, если дерево на рис. 4.37 оптимально, то поддерево с ключами k3 и k4 также оптимально. Это свойство подсказывает алгоритм, который систематически строит все большие деревья, начиная с отдельных узлов в качестве наименьших возможных подде%ревьев. При этом дерево растет от листьев к корню, то есть, учитывая, что мы ри%суем деревья вверх ногами, в направлении снизу вверх [4.6].Уравнение, представляющее собой ключ к этому алгоритму, выводится так.Пусть P будет взвешенной длиной путей некоторого дерева, и пусть PL и PR – соот%ветствующие длины для левого и правого поддеревьев его корня. Ясно, что P рав%на сумме PL, PR, а также количества проходов поиска по ребру, ведущему к корню,что просто равно полному числу попыток поиска W. Будем называть W весом дере%ва. Тогда его средняя длина путей равна P/W:P = PL + W + PRW = (SSSSSi: 1 ≤ i ≤ n : a i) + (SSSSSi: 1 ≤ i ≤ m : b i)Эти соображения показывают, что нужно как%то обозначить веса и длины пу%тей поддеревьев, состоящих из некоторого числа смежных ключей. Пусть Tij –оптимальное поддерево, состоящее из узлов с ключами ki+1, ki+2, ... , kj. Тогда пусть wij обозначает вес, а pij – длину путей поддерева Tij. Ясно, что P = p0,n и W = w0,nВсе эти величины определяются следующими рекуррентными соотношениями:w ii= b i(0 ≤ i ≤ n)w ij= w i,j–1 + a j + b j(0 ≤ i < j ≤ n)p ii= w ii(0 ≤ i ≤ n)p ij= w ij + MIN k: i < k ≤ j : (p i,k–1 + p kj) (0 ≤ i < j ≤ n)Последнее уравнение прямо следует из определений величины P и оптималь%ности. Так как имеется примерно n2/2 значений pij, а операция минимизации в правой части требует выбора из 0 < j–i ≤ n значений, то полная минимизация потребует примерно n3/6 операций. Кнут указал способ сэкономить один фактор 223n (см. ниже), и только благодаря этой экономии данный алгоритм становится ин%тересным для практических целей.Пусть rij – то значение величины k, в котором достигается минимум pij. Оказы%вается, поиск rij можно ограничить гораздо меньшим интервалом, то есть умень%шить число j–i шагов вычисления. Ключевым здесь является наблюдение, что если мы нашли корень rij оптимального поддерева Tij, то ни расширение дерева добавлением какого%нибудь узла справа, ни уменьшение дерева удалением край%него левого узла не могут сдвинуть оптимальный корень влево. Это выражается соотношением ri,j–1≤ r ij≤ r i+1,j,которое ограничивает поиск решения для rij интервалом ri,j–1 ... r i+1,j. Это приво%дит к полному числу элементарных шагов порядка n2Мы готовы теперь построить алгоритм оптимизации в деталях. Вспомним следующие определения для оптимальных деревьев Tij, состоящих из узлов с клю%чами ki+1 ... k j:1.a i: частота поиска ключа ki2.b j: частота случаев, когда аргумент поиска x оказывается между kj и kj+1 3.w ij: вес Tij4.p ij: взвешенная длина путей поддерева Tij5.r ij:индекс корня поддерева TijОбъявим следующие массивы:a:ARRAY n+1 OF INTEGER; (*a[0] *)b:ARRAY n+1 OF INTEGER;p,w,r:ARRAY n+1, n+1 OF INTEGER;Предположим, что веса wij вычислены из a и b простейшим способом. Будем счи%тать w аргументом процедуры OptTree, которую нужно разработать, а r – ее резуль%татом, так как r полностью описывает структуру де%рева. Массив p можно считать промежуточным результатом. Начиная с наименьших возможных деревьев, то есть вообще не имеющих узлов, мы пере%ходим ко все большим деревьям. Обозначим ширину j–i поддерева Tij как h. Тогда можно легко определить значения pii для всех деревьев с h = 0 в соответствии с определением величин pij:FOR i := 0 TO n DO p[i,i] := b[i] ENDВ случае h = 1 речь идет о деревьях с одним узлом,который, очевидно, и является корнем (см.рис. 4.38):FOR i := 0 TO n–1 DOj := i+1; p[i,j] := w[i,j] + p[i,i] + p[j,j]; r[i,j] := jENDРис. 4.38. Оптимальное дерево поиска с единственным узломОптимальные деревья поиска Динамические структуры данных224Заметим, что i и j обозначают левый и правый пределы значений индекса в рас%сматриваемом дереве Tij. Для случаев h > 1 используем цикл с h от 2 до n, причем случай h = n соответствует всему дереву T0,n. В каждом случае минимальная дли%на путей pij и соответствующий индекс корня rij определяются простым циклом,в котором индекс k пробегает интервал для rij:FOR h := 2 TO n DOFOR i := 0 TO n–h DOj := i+h; k min = MIN k: i < k < j : (p i,k–1 + p kj), r i,j–1 < k < r i+1,j;p[i,j] := min + w[i,j]; r[i,j] := kENDENDДетали того, как уточняется предложение, набранное курсивом, можно найти в программе, приводимой ниже. Теперь средняя длина пути дерева T0,n дается от%ношением p0,n/w0,n, а его корнем является узел с индексом r0,nОпишем теперь структуру проектируемой программы. Ее двумя главными компонентами являются процедура для нахождения оптимального дерева поиска при заданном распределении весов w, а также прцедура для печати дерева при заданных индексах r. Сначала вводятся частоты a и b, а также ключи. Ключи на самом деле не нужны для вычисления структуры дерева; они просто используют%ся при распечатке дерева. После печати статистики частот программа вычисляет длину путей идеально сбалансированного дерева, заодно определяя и корни его поддеревьев. Затем печатается средняя взвешенная длина пути и распечатывает%ся само дерево.В третьей части вызывается процедура OptTree, чтобы вычислить оптималь%ное дерево поиска; затем это дерево распечатывается. Наконец, те же процедуры используются для вычисления и печати оптимального дерева с учетом частот только ключей, игнорируя частоты значений, не являющихся ключами. Все это можно суммировать так, как показано ниже, начиная с объявлений глобальных констант и переменных:CONST N = 100; (* . *)(* ADruS46_OptTree *)WordLen = 16; (* . *)VAR key: ARRAY N+1, WordLen OF CHAR;a, b: ARRAY N+1 OF INTEGER;p, w, r: ARRAY N+1, N+1 OF INTEGER;PROCEDURE BalTree (i, j: INTEGER): INTEGER;VAR k, res: INTEGER;BEGINk := (i+j+1) DIV 2; r[i, j] := k;IF i >= j THEN res := 0ELSE res := BalTree(i, k–1) + BalTree(k, j) + w[i, j]END;RETURN resEND BalTree; 225PROCEDURE ComputeOptTree (n: INTEGER);VAR x, min, tmp: INTEGER;i, j, k, h, m: INTEGER;BEGIN(* # : w, : p, r*)FOR i := 0 TO n DO p[i, i] := 0 END;FOR i := 0 TO n–1 DOj := i+1; p[i, j] := w[i, j]; r[i, j] := jEND;FOR h := 2 TO n DOFOR i := 0 TO n–h DOj := i+h; m := r[i, j–1]; min := p[i, m–1] + p[m, j];FOR k := m+1 TO r[i+1, j] DOtmp := p[i, k–1]; x := p[k, j] + tmp;IF x < min THEN m := k; min := x ENDEND;p[i, j] := min + w[i, j]; r[i, j] := mENDENDEND ComputeOptTree;PROCEDURE WriteTree (i, j, level: INTEGER);VAR k: INTEGER;(* # ˆ W*)BEGINIF i < j THENWriteTree(i, r[i, j]–1, level+1);FOR k := 1 TO level DO Texts.Write(W, TAB) END;Texts.WriteString(W, key[r[i, j]]); Texts.WriteLn(W);WriteTree(r[i, j], j, level+1)ENDEND WriteTree;PROCEDURE Find (VAR S: Texts.Scanner);VAR i, j, n: INTEGER;(* # ˆ W*)BEGINTexts.Scan(S); b[0] := SHORT(S.i);n := 0; Texts.Scan(S); (*: a, , b*)WHILE S.class = Texts.Int DOINC(n); a[n] := SHORT(S.i); Texts.Scan(S); COPY(S.s, key[n]);Texts.Scan(S); b[n] := SHORT(S.i); Texts.Scan(S)END;(* w a b*)FOR i := 0 TO n DOw[i, i] := b[i];FOR j := i+1 TO n DOw[i, j] := w[i, j–1] + a[j] + b[j]ENDEND;Оптимальные деревья поиска Динамические структуры данных226Texts.WriteString(W, " = ");Texts.WriteInt(W, w[0, n], 6); Texts.WriteLn(W);Texts.WriteString(W, "‘ # = ");Texts.WriteInt(W, BalTree(0, n), 6); Texts.WriteLn(W);WriteTree(0, n, 0); Texts.WriteLn(W);ComputeOptTree(n);Texts.WriteString(W, "‘ # = ");Texts.WriteInt(W, p[0, n], 6); Texts.WriteLn(W);WriteTree(0, n, 0); Texts.WriteLn(W);FOR i := 0 TO n DOw[i, i] := 0;FOR j := i+1 TO n DO w[i, j] := w[i, j–1] + a[j] ENDEND;ComputeOptTree(n);Texts.WriteString(W, " b"); Texts.WriteLn(W);WriteTree(0, n, 0); Texts.WriteLn(W)END Find;В качестве примера рассмотрим следующие входные данные для дерева с тре%мя ключами:20 1 Albert 10 2 Ernst 1 5 Peter 1b0 = 20a1 = 1key1 = Albert b1 = 10a2 = 2key2 = Ernst b2 = 1a3 = 4key3 = Peter b3 = 1Результаты работы процедуры Find показаны на рис. 4.39; видно, что структу%ры, полученные в трех случаях, могут сильно отличаться. Полный вес равен 40,длина путей сбалансированного дерева равна 78, а для оптимального дерева – 66.Рис. 4.39. Три дерева, построенные процедурой ComputeOptTreeИз этого алгоритма очевидно, что затраты на определение оптимальной струк%туры имеют порядок n2; к тому же и объем требуемой памяти имеет порядок n2Это неприемлемо для очень больших n. Поэтому весьма желательно найти более эффективные алгоритмы. Один из них был разработан Ху и Такером [4.5]; их алго%ритм требует памяти порядка O(n) и вычислительных затрат порядка O(n*log(n))Однако в нем рассматривается только случай, когда частоты ключей равны нулю,то есть учитываются только безуспешные попытки поиска ключей. Другой алго% 227ритм, также требующий O(n) элементов памяти и O(n*log(n)) вычислительных операций, описан Уокером и Готлибом [4.7]. Вместо поиска оптимума этот алго%ритм пытается найти почти оптимальное дерево. Поэтому его можно построить на использовании эвристик. Основная идея такова.Представим себе, что узлы (реальные и дополнительные) распределены на ли%нейной шкале и что каждому узлу приписан вес, равный соответствующей часто%те (или вероятности) обращений. Затем найдем узел, ближайший к их центру тя%жести. Этот узел называется центроидом, а его индекс равен величине(SSSSSi: 1 ≤ i ≤ n : i*a i) + (SSSSSi: 1 ≤ i ≤ m : i*b i) / W,округленной до ближайшего целого. Если вес всех узлов одинаков, то корень ис%комого оптимального дерева, очевидно, совпадает с центроидом. В противном случае – рассуждают авторы алгоритма – он будет в большинстве случаев на%ходиться вблизи центроида. Тогда выполняется ограниченный поиск для на%хождения локального оптимума, после чего та же процедура применяется к двум получающимся поддеревьям. Вероятность того, что корень лежит очень близко к центроиду, растет вместе с объемом дерева n. Как только поддеревья становятся достаточно малыми, оптимум для них может быть найден посредством точного алгоритма, приведенного выше.1   ...   14   15   16   17   18   19   20   21   22

4.7. БGдеревья (BGtrees)До сих пор наше обсуждение ограничивалось двоичными деревьями, то есть таки%ми, где каждый узел имеет не более двух потомков. Этого вполне достаточно,если, например, мы хотим представить семейные связи, подчеркивая происхож%дение, то есть указывая для каждого человека его двух родителей. Ведь ни у кого не бывает больше двух родителей. Но что, если возникнет необходимость указы%вать потомков каждого человека? Тогда придется учесть, что у некоторых людей больше двух детей, и тогда деревья будут содержать узлы со многими ветвями.Будем называть такие деревья сильно ветвящимися.Разумеется, в подобных структурах нет ничего особенного, и мы уже знакомы со всеми средствами программирования и определения данных, чтобы справиться с такими ситуациями. Например, если задан абсолютный верхний предел на число детей (такое предположение, конечно, немного футуристично), то можно представ%лять детей полем%массивом в записи, представляющей человека. Но если число де%тей сильно зависит от конкретного индивида, то это может привести к плохому ис%пользованию имеющейся памяти. В этом случае гораздо разумнее организовать потомство с помощью линейного списка, причем указатель на младшего (или стар%шего) потомка хранится в записи родителя. Возможное определение типа для этого случая приводится ниже, а возможная структура данных показана на рис. 4.40.TYPE Person = POINTER TO RECORDname: alfa;sibling, offspring: PersonENDБ<деревья (B Динамические структуры данных228При наклоне картинки на 45 градусов она будет выглядеть как обычное дво%ичное дерево. Но такой вид вводит в заблуждение, так как функциональный смысл двух структур совершенно разный. Обычно нельзя безнаказанно обра%щаться с братом как с сыном и не следует так поступать даже при определении структур данных. Структуру данных в этом примере можно усложнить еще больше, вводя в запись для каждого индивида дополнительные компоненты,представляющие другие семейные связи, например супружеские отношения между мужем и женой или даже информацию о родителях, ведь подобную ин%формацию не всегда можно получить из ссылок от родителей к детям и к брать%ям%сестрам. Подобная структура быстро вырастает в сложную реляционную базу данных, на которую можно отобразить несколько деревьев. Алгоритмы, ра%ботающие с такими структурами, тесно связаны с соответствующими определе%ниями данных, так что не имеет смысла указывать ни какие%либо общие прави%ла, ни общеприменимые приемы.Однако есть очень полезная область применения сильно ветвящихся деревьев,представляющая общий интерес. Это построение и поддержка больших деревьев поиска, в которых нужно выполнять вставки и удаления элементов, но опера%тивная память компьютера либо недостаточна по размеру, либо слишком дорога,чтобы использовать ее для длительного хранения данных.Итак, предположим, что узлы дерева должны храниться на внешних устрой%ствах хранения данных, таких как диски. Динамические структуры данных,введенные в этой главе, особенно удобны для того, чтобы использовать внешние носители. Главная новация будет просто в том, что роль указателей теперь будут играть дисковые адреса, а не адреса в оперативной памяти. При использовании двоичного дерева для набора данных из, скажем, миллиона элементов потребу%ется в среднем примерно ln 10 6 (то есть около 20) шагов поиска. Поскольку здесь каждый шаг требует обращения к диску (с неизбежной задержкой), то крайне желательно организовать хранение так, чтобы уменьшить число таких обраще%ний. Сильно ветвящееся дерево – идеальное решение проблемы. Если имеет мес%то обращение к элементу на внешнем носителе, то без особых усилий становится доступной целая группа элементов. Отсюда идея разделить дерево на поддеревья таким образом, чтобы эти поддеревья представлялись блоками, которые доступ%Рис. 4.40. Сильно ветвящееся дерево, представленное как двоичное дерево 229ны сразу целиком. Назовем такие поддеревья страницами. На рис. 4.41 показано двоичное дерево, разделенное на страницы по 7 узлов на каждой.Рис. 4.41. Двоичное дерево, разделенное на страницыЭкономия на числе обращений к диску – теперь обращение к диску соответ%ствует обращению к странице – может быть значительной. Предположим, что на каждой странице решено размещать по 100 узлов (это разумное число); тогда де%рево поиска из миллиона элементов потребует в среднем только log100(10 6) (то есть около 3) обращений к страницам вместо 20. Правда, при случайном росте де%рева это число все равно может достигать 10 4 в наихудшем случае. Ясно, что рос%том сильно ветвящихся деревьев почти обязательно нужно как%то управлять.4.7.1. Сильно ветвящиеся Б=деревьяЕсли искать способ управления ростом дерева, то следует сразу исключить требо%вание идеальной сбалансированности, так как здесь понадобятся слишком боль%шие накладные расходы на балансировку. Ясно, что нужно несколько ослабить требования. Весьма разумный критерий был предложен Бэйером и Маккрейтом в 1970 г. [4.2]: каждая страница (кроме одной) содержит от n до 2n узлов, где n –некоторая заданная константа. Поэтому для дерева из N элементов и с максималь%ным размером страницы в 2n узлов потребуется в худшем случае log nN обраще%ний к страницам; а именно на обращения к страницам приходятся основные уси%лия в подобном поиске. Более того, важный коэффициент использования памяти будет не меньше 50%, так как страницы всегда будут заполнены по крайней мере наполовину. Причем при всех этих достоинствах описываемая схема требует сравнительно простых алгоритмов для поиска, вставки и удаления элементов.В дальнейшем мы подробно изучим их.Такую структуру данных называют Бдеревом (также B%дерево; B%tree), число n – его порядком, и при этом предполагаются следующие свойства:1. Каждая страница содержит не более 2n элементов (ключей).2. Каждая страница, кроме корневой, содержит не менее n элементов.Б<деревья (B Динамические структуры данных230 3. Каждая страница либо является концевой, то есть не имеет потомков, либо имеет m+1 потомков, где m – число ключей на этой странице.4. Все концевые страницы находятся на одном уровне.Рисунок 4.42 показывает Б%дерево порядка 2, имеющее 3 уровня. На всех стра%ницах – 2, 3 или 4 элемента; исключением является корень, где разрешено иметь только один элемент. Все концевые страницы – на уровне 3. Ключи будут стоять в порядке возрастания слева направо, если Б%дерево «сплющить» в один слой так,чтобы потомки вставали между ключами соответствующих страниц%предков. Та%кая организация является естественным развитием идеи двоичных деревьев поис%ка и предопределяет способ поиска элемента с заданным ключом. Рассмотрим страницу, показанную на рис. 4.43, и пусть задан аргумент поиска x. Предполагая,что страница уже считана в оперативную память, можно использовать обычные методы поиска среди ключей k0 ... k m–1. Если m велико, то можно применить по%иск делением пополам; если оно мало, то будет достаточно обычного последова%тельного поиска. (Заметим, что время поиска в оперативной памяти будет, веро%ятно, пренебрежимо мало по сравнению со временем считывания страницы в оперативную память из внешней.) Если поиск завершился неудачей, то возника%ет одна из следующих ситуаций:1.k i < x < k i+1 для 0 < i < m–1Поиск продолжается на странице pi^2.k m–1 < xПоиск продолжается на странице pm–1^3.x < k0Поиск продолжается на странице p–1^Рис. 4.42. Б:дерево порядка 2Рис. 4.43. Б:дерево с m ключами 231Если в каком%то случае указатель оказался равен NIL, то есть страница%пото%мок отсутствует, то это значит, что элемента с ключом x во всем дереве нет, и по%иск заканчивается.Удивительно, но вставка в Б%дерево тоже сравнительно проста. Если элемент нужно вставить на странице с m < 2n элементами, то вставка затрагивает только содержимое этой страницы. И только вставка в уже заполненную страницу затро%нет структуру дерева и может привести к размещению новых страниц. Чтобы по%нять, что случится в этом случае, рассмотрим рис. 4.44, который показывает вставку ключа 22 в Б%дерево порядка 2. Здесь выполняются следующие шаги:1. Обнаруживается, что ключа 22 в дереве нет; вставка на странице C невоз%можна, так как C уже полна.2. Страница C разбивается на две (то есть размещается новая страница D).3.2n+1 ключей поровну распределяются между страницами C и D, а средний ключ перемещается на один уровень вверх на страницу%предок AРис. 4.44. Вставка ключа 22 в Б:деревоЭта очень изящная схема сохраняет все характеристические свойства Б%дере%вьев. В частности, разбиваемые страницы содержат в точности n элементов. Разу%меется, вставка элемента на странице%предке тоже может вызвать переполнение,так что снова нужно выполнять разбиение, и т. д. В крайнем случае, процесс раз%биений достигнет корня. И это единственный способ увеличить высоту Б%дерева.Странная манера расти у Б%деревьев: вверх от листьев к корню.Разработаем теперь подробную программу на основе этих набросков. Уже ясно, что самой удобной будет рекурсивная формулировка, так как процесс раз%биения может распространяться в обратном направлении по пути поиска. Поэто%му по общей структуре программа получится похожей на вставку в сбалансиро%ванное дерево, хотя в деталях будут отличия. Прежде всего нужно определить структуру страниц. Выберем представление элементов с помощью массива:TYPE Page = POINTER TO PageDescriptor;Item = RECORD key: INTEGER;p: Page;count: INTEGER (* *)END;PageDescriptor = RECORD m: INTEGER; (* 0 .. 2n *)p0: Page;e: ARRAY 2*n OF ItemENDБ<деревья (B Динамические структуры данных232Поле count представляет любую информацию, которая может быть связана с элементом, но оно никак не участвует собственно в поиске. Заметим, что каждая страница предоставляет место для 2n элементов. Поле m указывает реальное чис%ло элементов на данной странице. Так как m ≥ n (кроме корневой страницы), то гарантируется, что память будет использована по меньшей мере на 50%.Алгоритм поиска и вставки в Б%дерево сформулирован ниже в виде процедуры search. Его основная структура бесхитростна и подобна поиску в сбалансирован%ном двоичном дереве, за исключением того факта, что выбор ветви не ограничен двумя вариантами. Вместо этого поиск внутри страницы реализован как поиск делением пополам в массиве e.Алгоритм вставки для ясности сформулирован как отдельная процедура. Она вызывается, когда поиск показал, что нужно передать элемент вверх по дереву(в направлении корня). Это обстоятельство указывается булевским параметром%переменной h; его использование аналогично случаю алгоритма вставки в сбалан%сированное дерево, где h сообщал, что поддерево выросло. Если h истинно, то вто%рой параметр%переменная u содержит элемент, передаваемый наверх. Заметим,что вставки начинаются в виртуальных страницах, а именно в так называемых дополнительных узлах (см. рис. 4.19); при этом новый элемент сразу передается через параметр u вверх на концевую страницу для реальной вставки. Вот набро%сок этой схемы:PROCEDURE search (x: INTEGER; a: Page; VAR h: BOOLEAN; VAR u: Item);BEGINIF a = NIL THEN (*x , *) x u, h TRUE, , u ELSE x a.e;IF THEN ELSEsearch(x, descendant, h, u);IF h THEN (* *)IF a^ < 2n THEN u a^ h FALSEELSE ENDENDENDENDEND searchЕсли h равен TRUE после вызова процедуры search в главной программе, зна%чит, требуется разбить корневую страницу. Так как корневая страница играет осо%бую роль, то это действие следует запрограммировать отдельно. Оно состоит про%сто в размещении новой корневой страницы и вставке единственного элемента, 233задаваемого параметром u. В результате новая корневая страница содержит единственный элемент. Полный текст программы приведен ниже, а рис. 4.45 по%казывает, как она строит Б%дерево при вставке ключей из следующей последова%тельности:20; 40 10 30 15; 35 7 26 18 22; 5; 42 13 46 27 8 32; 38 24 45 25;Точки с запятой отмечают моменты, когда сделаны «снимки», – после каждого размещения страниц. Вставка последнего ключа вызывает два разбиения и разме%щение трех новых страниц.Рис. 4.45. Рост Б:дерева порядка 2Поскольку каждый вызов процедуры поиска подразумевает одно копирование страницы в оперативную память, то понадобится не более k = log n(N) рекурсив%ных вызовов для дерева из N элементов. Поэтому нужно иметь возможность раз%местить k страниц в оперативной памяти. Это первое ограничение на размер стра%ницы 2n. На самом деле нужно размещать больше, чем k страниц, так как вставка может вызвать разбиение. Отсюда следует, что корневую страницу лучше посто%Б<деревья (B Динамические структуры данных234янно держать в оперативной памяти, так как каждый запрос обязательно прохо%дит через корневую страницу.Другое достоинство организации данных с помощью Б%деревьев – удобство и экономичность чисто последовательного обновления всей базы данных. Каждая страница загружается в оперативную память в точности один раз.Операция удаления элементов из Б%дерева по идее довольно проста, но в дета%лях возникают усложнения. Следует различать две разные ситуации:1. Удаляемый элемент находится на концевой странице; здесь алгоритм уда%ления прост и понятен.2. Элемент находится на странице, не являющейся концевой; его нужно заме%нить одним из двух соседних в лексикографическом смысле элементов, ко%торые оказываются на концевых страницах и могут быть легко удалены.В случае 2 нахождение соседнего ключа аналогично соответствующему алго%ритму при удалении из двоичного дерева. Мы спускаемся по крайним правым указателям вниз до концевой страницы P, заменяем удаляемый элемент крайним правым элементом на P и затем уменьшаем размер страницы P на 1. В любом слу%чае за уменьшением размера должна следовать проверка числа элементов m на уменьшившейся странице, так как m < n нарушает характеристическое свойствоБ%деревьев. Здесь нужно предпринять дополнительные действия; это условие недостаточной заполненности (underflow) указывается булевским параметром hЕдинственный выход – позаимствовать элемент с одной из соседних страниц,скажем Q. Поскольку это требует считывания страницы Q в оперативную память, –что относительно дорого, – есть соблазн извлечь максимальную пользу из этой нежелательной ситуации и позаимствовать за один раз больше одного элемента.Обычная стратегия – распределить элементы на страницах P и Q поровну на обеих страницах. Это называется балансировкой страниц (page balancing).Разумеется, может случиться, что элементов для заимствования не осталось,поскольку страница Q достигла минимального размера n. В этом случае общее число элементов на страницах P и Q равно 2n–1, и можно объединить обе страни%цы в одну, добавив средний элемент со страницы%предка для P и Q, а затем пол%ностью избавиться от страницы Q. Эта операция является в точности обраще%нием разбиения страницы. Целиком процесс проиллюстрирован удалением ключа 22 на рис. 4.44. И здесь снова удаление среднего ключа со страницы%пред%ка может уменьшить размер последней ниже разрешенного предела n, тем са%мым требуя выполнения специальных действий (балансировки или слияния) на следующем уровне. В крайнем случае, слияние страниц может распространять%ся вверх до самого корня. Если корень уменьшается до размера 0, следует уда%лить сам корень, что вызывает уменьшение высоты Б%дерева. На самом деле это единственная ситуация, когда может уменьшиться высота Б%дерева. Рисунок4.46 показывает постепенное уменьшение Б%дерева с рис. 4.45 при последова%тельном удалении ключей25 45 24; 38 32; 8 27 46 13 42; 5 22 18 26; 7 35 15; 235Здесь, как и раньше, точки с запятой отмечают места, где делаются «снимки»,то есть там, где удаляются страницы. Следует особо подчеркнуть сходство струк%туры алгоритма удаления с соответствующей процедурой для сбалансированного дерева.Рис. 4.46. Удаление элементов из Б:дерева порядка 2TYPE Page = POINTER TO PageRec;(* ADruS471_Btrees *)Entry = RECORDkey: INTEGER; p: PageEND;PageRec = RECORDm: INTEGER; (* *)p0: Page;e: ARRAY 2*N OF EntryEND;VAR root: Page; W: Texts.Writer;Б<деревья (B Динамические структуры данных236PROCEDURE search (x: INTEGER; VAR p: Page; VAR k: INTEGER);VAR i, L, R: INTEGER; found: BOOLEAN; a: Page;BEGINa := root; found := FALSE;WHILE (a # NIL) & found DOL := 0; R := a.m; (* *)WHILE L < R DOi := (L+R) DIV 2;IF x <= a.e[i].key THEN R := i ELSE L := i+1 ENDEND;IF (R < a.m) & (a.e[R].key = x) THEN found := TRUEELSIF R = 0 THEN a := a.p0ELSE a := a.e[R–1].pENDEND;p := a; k := REND search;PROCEDURE insert (x: INTEGER; a: Page; VAR h: BOOLEAN; VAR v: Entry);(*a # NIL. ’ x ‡– a;(* x.(* € , # v.(*h := " "*)VAR i, L, R: INTEGER;b: Page; u: Entry;BEGIN(* h *)IF a = NIL THENv.key := x; v.p := NIL; h := TRUEELSEL := 0; R := a.m; (* *)WHILE L < R DOi := (L+R) DIV 2;IF x <= a.e[i].key THEN R := i ELSE L := i+1 ENDEND;IF (R < a.m) & (a.e[R].key = x) THEN (* v, #*)ELSE (* *)IF R = 0 THEN b := a.p0 ELSE b := a.e[R–1].p END;insert(x, b, h, u);IF h THEN (* u a.e[R]*)IF a.m < 2*N THENh := FALSE;FOR i := a.m TO R+1 BY –1 DO a.e[i] := a.e[i–1] END;a.e[R] := u; INC(a.m)ELSE (* ; a a, b v*)NEW(b);IF R < N THEN (* a*) 237v := a.e[N–1];FOR i := N–1 TO R+1 BY –1 DO a.e[i] := a.e[i–1] END;a.e[R] := u;FOR i := 0 TO N–1 DO b.e[i] := a.e[i+N] ENDELSE (* b*)DEC(R, N);IF R = 0 THENv := uELSEv := a.e[N];FOR i := 0 TO R–2 DO b.e[i] := a.e[i+N+1] END;b.e[R–1] := uEND;FOR i := R TO N–1 DO b.e[i] := a.e[i+N] ENDEND;a.m := N; b.m := N; b.p0 := v.p; v.p := bENDENDENDENDEND insert;PROCEDURE underflow (c, a: Page; s: INTEGER; VAR h: BOOLEAN);(*a = , # v (underflow),(*c = – ,(*s = # c*)VAR b: Page; i, k: INTEGER;BEGIN(*h & (a.m = N–1) & (c.e[s–1].p = a) *)IF s < c.m THEN (*b := a*)b := c.e[s].p;k := (b.m–N+1) DIV 2; (*k = b*)a.e[N–1] := c.e[s]; a.e[N–1].p := b.p0;IF k > 0 THEN (* k–1 b a*)FOR i := 0 TO k–2 DO a.e[i+N] := b.e[i] END;c.e[s] := b.e[k–1]; b.p0 := c.e[s].p;c.e[s].p := b; DEC(b.m, k);FOR i := 0 TO b.m–1 DO b.e[i] := b.e[i+k] END;a.m := N–1+k; h := FALSEELSE (* a b, b v € *)FOR i := 0 TO N–1 DO a.e[i+N] := b.e[i] END;DEC(c.m);FOR i := s TO c.m–1 DO c.e[i] := c.e[i+1] END;a.m := 2*N; h := c.m < NENDELSE (*b := a*)DEC(s);IF s = 0 THEN b := c.p0 ELSE b := c.e[s–1].p END;k := (b.m–N+1) DIV 2; (*k = b*)Б<деревья (B Динамические структуры данных238IF k > 0 THENFOR i := N–2 TO 0 BY –1 DO a.e[i+k] := a.e[i] END;a.e[k–1] := c.e[s]; a.e[k–1].p := a.p0;(* k–1 b a, c*) DEC(b.m, k);FOR i := k–2 TO 0 BY –1 DO a.e[i] := b.e[i+b.m+1] END;c.e[s] := b.e[b.m]; a.p0 := c.e[s].p;c.e[s].p := a; a.m := N–1+k; h := FALSEELSE (* a b, a v € *)c.e[s].p := a.p0; b.e[N] := c.e[s];FOR i := 0 TO N–2 DO b.e[i+N+1] := a.e[i] END;b.m := 2*N; DEC(c.m); h := c.m < NENDENDEND underflow;PROCEDURE delete (x: INTEGER; a: Page; VAR h: BOOLEAN);(* x ‡– a;(* v ,(* ;(*h := " v "*)VAR i, L, R: INTEGER; q: Page;PROCEDURE del (p: Page; VAR h: BOOLEAN);VAR k: INTEGER; q: Page; (*# a, R*)BEGIN k := p.m–1; q := p.e[k].p;IF q # NIL THEN del(q, h);IF h THEN underflow(p, q, p.m, h) ENDELSE p.e[k].p := a.e[R].p; a.e[R] := p.e[k];DEC(p.m); h := p.m < NENDEND del;BEGINIF a # NIL THENL := 0; R := a.m; (* *)WHILE L < R DOi := (L+R) DIV 2;IF x <= a.e[i].key THEN R := i ELSE L := i+1 ENDEND;IF R = 0 THEN q := a.p0 ELSE q := a.e[R–1].p END;IF (R < a.m) & (a.e[R].key = x) THEN (* v*)IF q = NIL THEN (*a — *)DEC(a.m); h := a.m < N;FOR i := R TO a.m–1 DO a.e[i] := a.e[i+1] ENDELSEdel(q, h);IF h THEN underflow(a, q, R, h) ENDENDELSE 239delete(x, q, h);IF h THEN underflow(a, q, R, h) ENDENDENDEND delete;PROCEDURE ShowTree (VAR W: Texts.Writer; p: Page; level: INTEGER);VAR i: INTEGER;BEGINIF p # NIL THENFOR i := 1 TO level DO Texts.Write(W, 9X) END;FOR i := 0 TO p.m–1 DO Texts.WriteInt(W, p.e[i].key, 4) END;Texts.WriteLn(W);IF p.m > 0 THEN ShowTree(W, p.p0, level+1) END;FOR i := 0 TO p.m–1 DO ShowTree(W, p.e[i].p, level+1) ENDENDEND ShowTree;Эффективность Б%деревьев изучалась весьма подробно, результаты можно найти в упомянутой статье Бэйера и Маккрейта. В частности, там обсуждается вопрос оптимального размера страницы, который сильно зависит от характерис%тик используемой вычислительной системы и памяти.Вариации на тему Б%деревьев обсуждаются у Кнута ([2.7], с. 521–525 перево%да). Заслуживает внимания наблюдение, что разбиение страницы следует откла%дывать, как следует откладывать и слияние страниц, и сначала пытаться сбалан%сировать соседние страницы. В остальном похоже, что выгода от предлагавшихся улучшений незначительна. Весьма полный обзор Б%деревьев можно найти в [4.8].1   ...   14   15   16   17   18   19   20   21   22

Глава 5Хэширование5.1. Введение .......................... 256 5.2. Выбор хэш<функции ......... 257 5.3. Разрешение коллизий ...... 257 5.4. Анализ хэширования ........ 261Упражнения ............................. 263Литература .............................. 263 Хэширование2565.1. ВведениеВ главе 4 подробно обсуждалась следующая основная проблема: если задан набор элементов, характеризующихся ключом (который определяет отношение поряд%ка), то как организовать этот набор, чтобы извлечение элемента с заданным клю%чом требовало наименьших усилий? Ясно, что в конечном счете доступ к каждому элементу в памяти компьютера осуществляется указанием его адреса в памяти.Поэтому вышеуказанная проблема по сути сводится к нахождению подходящего отображения H ключей (K) в адреса (A):H: K → AВ главе 4 это отображение реализовывалось с помощью различных алгоритмов поиска в списках и деревьях на основе разных способов организации данных.Здесь мы опишем еще один подход, простой по сути и во многих случаях очень эффективный. Затем мы обсудим и некоторые его недостатки.В этом методе данные организуются с помощью массива. Поэтому H является отображением, преобразующим ключи в индексы массива, откуда и происходит название преобразование ключей, нередко используемое для этого метода. Заме%тим, что здесь нам не понадобятся процедуры динамического размещения; массив является одной из фундаментальных, статических структур. Метод преобра%зования ключей часто используют в тех задачах, где с примерно равным успехом можно применить и деревья.Фундаментальная трудность при использовании преобразования ключей заключается в том, что множество возможных значений ключей гораздо больше,чем множество доступных адресов в памяти (индексов массива). К примеру,возьмем имена длиной до 16 букв в качестве ключей, идентифицирующих отдель%ных людей во множестве из тысячи человек. Здесь есть 26 16 возможных значений ключей, которые нужно отобразить на 10 3 возможных индексов. Очевидно, что функция H отображает несколько значений аргументов в одно значение индекса.Если задан ключ k, то первый шаг операции поиска состоит в вычислении соот%ветствующего индекса h = H(k), а второй – очевидно, обязательный – шаг состоит в проверке того, действительно ли элемент с ключом k соответствует элементу массива (таблицы) T с индексом h, то есть выполняется ли равенство T[H(k)].key = kМы сразу сталкиваемся с двумя вопросами:1. Какую функцию H надо взять?2. Что делать, если H не смогла вычислить адрес искомого элемента?Ответ на второй вопрос состоит в том, чтобы использовать метод, который даст альтернативную позицию, скажем индекс h', и если там по%прежнему нет иско%мого элемента, то третий индекс h", и т. д. (Такие попытки обозначаются ниже какпробы (probe) – прим. перев.) Ситуацию, когда в вычисленной позиции находится элемент, отличный от искомого, называют коллизией; задача порождения альтер%нативных индексов называется разрешением коллизий. Далее мы обсудим выбор функции преобразования ключей и методы разрешения коллизий. 2575.2. Выбор хэшGфункцииХорошая функция преобразования ключей должна обеспечивать как можно бо%лее равномерное распределение ключей по всему диапазону значений индекса.Других ограничений на распределение нет, но на самом деле желательно, чтобы оно казалось совершенно случайным. Это свойство дало методу несколько нена%учное название хэширование (hashing от англ. «превращать в фарш» и «мешани%на» – прим. перев.). H называется хэшфункцией. Очевидно, эта функция должна допускать эффективное вычисление, то есть состоять из очень небольшого числа основных арифметических операций.Предположим, что имеется функция преобразования ORD(k), которая вычис%ляет порядковый номер ключа k во множестве всех возможных ключей. Кроме того, предположим, что индекс массива i принимает значения в диапазоне целых чисел 0 .. N–1, где N – размер массива. Тогда есть очевидный вариант:H(k) = ORD(k) MOD NТакой выбор обеспечивает равномерное распределение ключей по диапазону индексов и поэтому является основой большинства хэш%функций. Это выраже%ние очень быстро вычисляется, если N есть степень 2, но именно этого случая сле%дует избегать, если ключи являются последовательностями букв. Предположе%ние, что все ключи равно вероятны, в этом случае неверно, и на самом деле слова,отличающиеся лишь немногими буквами, будут с большой вероятностью отобра%жаться на одно и то же значение индекса, так что получится весьма неоднородное распределение. Поэтому особенно рекомендуется в качестве значения N выбирать простое число [5.2]. Как следствие придется использовать полную операцию де%ления, которую нельзя заменить простым отбрасыванием двоичных цифр, но это не является проблемой на большинстве современных компьютеров, имеющих встроенную инструкцию деления.Часто используют хэш%функции, состоящие в применении логических опера%ций, таких как исключающее «или», к некоторым частям ключа, представленного как последовательность двоичных цифр. На некоторых компьютерах эти опера%ции могут выполняться быстрее, чем деление, но иногда они приводят к удиви%тельно неоднородному распределению ключей по диапазону индексов. Поэтому мы воздержимся от дальнейшего обсуждения таких методов.5.3. Разрешение коллизийЕсли оказывается, что элемент таблицы, соответствующий данному ключу, не яв%ляется искомым элементом, то имеет место коллизия, то есть у двух элементов ключи отображаются на одно значение индекса. Тогда нужна вторая проба с неко%торым значением индекса, полученным из данного ключа детерминированным способом. Есть несколько способов порождения вторичных индексов. Очевидный способ – связать все элементы с одинаковым первичным индексом H(k) в связный список. Это называют прямым связыванием (direct chaining). Элементы этого списка могут находиться в первичной таблице или вне ее; во втором случае об%Разрешение коллизий Хэширование258ласть памяти, где они размещаются, называется областью переполнения (overflow area). Недостатки этого метода – необходимость поддерживать вторичные спис%ки, а также что каждый элемент таблицы должен содержать указатель (или ин%декс) на список конфликтующих элементов.Альтернативный способ разрешения коллизий состоит в том, чтобы вообще отказаться от списков и просто перебирать другие элементы в той же таблице,пока не будет найден искомый элемент либо пустая позиция, что означает отсут%ствие указанного ключа в таблице. Такой метод называется открытой адресацией(open addressing [5.3]). Естественно, последовательность индексов во вторичных попытках должна быть всегда одной и той же для заданного ключа. Тогда алго%ритм поиска в таблице может быть кратко описан следующим образом:h := H(k); i := 0;REPEATIF T[h].key = k THEN ELSIF T[h].key = free THEN ELSE (* *)i := i+1; h := H(k) + G(i)ENDUNTIL ( )В литературе предлагались разные функции для разрешения коллизий. Обзор темы, сделанный Моррисом в 1968 г. [4.8], вызвал значительную активность в этой области. Простейший метод – проверить соседнюю позицию (считая таблицу циклической), пока не будет найден либо элемент с указанным ключом,либо пустая позиция. Таким образом, G(i) = i; в этом случае индексы hi, исполь%зуемые для поиска, даются выражениями h0= H(k)h i= (h i–1 + i) MOD N,i = 1 ... N–1Этот способ называется методом линейных проб (linear probing). Его недоста%ток – тенденция элементов к скучиванию вблизи первичных ключей (то есть клю%чей, не испытавших коллизии при вставке). Конечно, в идеале функция G должна тоже распределять ключи равномерно по множеству свободных позиций. Однако на практике это довольно сложно обеспечить, и здесь предпочитают компромисс%ные методы, которые не требуют сложных вычислений, но все же работают лучше,чем линейная функция. Один из них состоит в использовании квадратичной фун%кции, так что индексы для последовательных проб задаются формулами h0= H(k)h i= (h0 + i2) MOD N, i > 0Заметим, что при вычислении очередного индекса можно обойтись без возве%дения в квадрат, если воспользоваться следующими рекуррентными соотношени%ями для hi = i2 и di = 2i + 1:h i+1= h i + d id i+1= d i + 2, i > 0причем h0 = 0 и d0 = 1. Этот способ называется методом квадратичных проб(quadratic probing), и он, в общем, обходит упомянутую проблему скучивания, 259практически не требуя дополнительных вычислений. Незначительный недоста%ток здесь в том, что при последовательных пробах проверяются не все элементы таблицы, то есть при вставке можно не обнаружить свободной позиции, хотя в таблице они еще есть. На самом деле в методе квадратичных проб проверяется по крайней мере половина таблицы, если ее размер N является простым числом.Это утверждение можно доказать следующим образом. Тот факт, что i%я и j%я про%бы попадают в один элемент таблицы, выражается уравнением i2 MOD N = j2 MOD N(i2 – j2) ≡ 0 (modulo N)Применяя формулу для разности квадратов, получаем(i + j)(i – j) ≡ 0 (modulo N)и так как i ≠ j, то заключаем, что хотя бы одно из чисел i или j должно быть не меньше N/2, чтобы получить i+j = c*N с целым c. На практике этот недостаток не важен, так как необходимость выполнять N/2 вторичных проб при разрешении коллизий случается крайне редко, и только если таблица уже почти полна.В качестве применения описанной техники перепишем процедуру порожде%ния перекрестных ссылок из раздела 4.4.3. Главные отличия – в процедуре search и в замене указательного типа Node глобальной хэш%таблицей слов T. Хэш%функ%ция H вычисляется как остаток от деления на размер таблицы; для разрешения коллизий применяются квардатичные пробы. Подчеркнем, что для хорошей производительности важно, чтобы размер таблицы был простым числом.Хотя метод хэширования весьма эффективен в этом случае, – даже более эф%фективен, чем методы, использующие деревья, – у него есть и недостаток. Про%смотрев текст и собрав слова, мы, вероятно, захотим создать из них алфавитный список. Это несложно, если данные организованы в виде дерева, потому что прин%цип упорядоченности – основа этого способа организации. Однако простота теря%ется, если используется хэширование. Здесь и проявляется смысл слова «хэширо%вание». Для печати таблицы придется не только выполнить сортировку (которая здесь не показана), но оказывается даже предпочтительным отслеживать вставляе%мые ключи, явным образом связывая их в список. Поэтому высокая производитель%ность метода хэширования при поиске частично компенсируется дополнительны%ми операциями, необходимыми для завершения полной задачи порождения упорядоченного указателя перекрестных ссылок.CONST N = 997; (* , *)(*ADruS53_CrossRef*)WordLen = 32; (* *)Noc = 16; (* . *)TYPEWord = ARRAY WordLen OF CHAR;Table = POINTER TO ARRAY N OFRECORD key: Word; n: INTEGER;lno: ARRAY Noc OF INTEGEREND;VAR line: INTEGER;Разрешение коллизий Хэширование260PROCEDURE search (T: Table; VAR a: Word);VAR i, d: INTEGER; h: LONGINT; found: BOOLEAN;(* # line*)BEGIN(* v– h a*)i := 0; h := 0;WHILE a[i] > 0X DO h := (256*h + ORD(a[i])) MOD N; INC(i) END;d := 1; found := FALSE;REPEATIF T[h].key = a THEN (* *)found := TRUE; T[h].lno[T[h].n] := line;IF T[h].n < Noc THEN INC(T[h].n) ENDELSIF T[h].key[0] = " " THEN (* *)found := TRUE; COPY(a, T[h].key); T[h].lno[0] := line; T[h].n := 1ELSE (* *) h := h+d; d := d+2;IF h >= N THEN h := h–N END;IF d = N THEN Texts.WriteString(W," "); HALT(88)ENDENDUNTIL foundEND search;PROCEDURE Tabulate (T: Table);VAR i, k: INTEGER;(* # ˆ W*)BEGINFOR k := 0 TO N–1 DOIF T[k].key[0] # " " THENTexts.WriteString(W, T[k].key); Texts.Write(W, TAB);FOR i := 0 TO T[k].n –1 DO Texts.WriteInt(W, T[k].lno[i], 4) END;Texts.WriteLn(W)ENDENDEND Tabulate;PROCEDURE CrossRef (VAR R: Texts.Reader);VAR i: INTEGER; ch: CHAR; w: Word;H: Table;BEGINNEW(H); (* v– *)FOR i := 0 TO N–1 DO H[i].key[0] := " " END;line := 0;Texts.WriteInt(W, 0, 6); Texts.Write(W, TAB); Texts.Read(R, ch);WHILE R.eot DOIF ch = 0DX THEN (* *) Texts.WriteLn(W);INC(line); Texts.WriteInt(W, line, 6); Texts.Write(W, 9X); Texts.Read(R, ch)ELSIF ("A" <= ch) & (ch <= "Z") OR ("a" <= ch) & (ch <= "z") THENi := 0;REPEATIF i < WordLen–1 THEN w[i] := ch; INC(i) END;Texts.Write(W, ch); Texts.Read(R, ch)UNTIL (i = WordLen–1) OR (("A" <= ch) & (ch <= "Z")) & 261(("a" <= ch) & (ch <= "z")) & (("0" <= ch) & (ch <= "9"));w[i] := 0X; (* *)search(H, w)ELSE Texts.Write(W, ch); Texts.Read(R, ch)END;Texts.WriteLn(W); Texts.WriteLn(W); Tabulate(H)ENDEND CrossRef5.4. Анализ хэшированияПроизводительность вставки и поиска в методе хэширования для худшего случая,очевидно, ужасная. Ведь нельзя исключать, что аргумент поиска таков, что все пробы пройдут в точности по занятым позициям, ни разу не попав в нужные (или свободные). Нужно иметь большое доверие законам теории вероятности, чтобы применять технику хэширования. Здесь нужна уверенность в том, что в среднем число проб мало. Приводимые ниже вероятностные аргументы показывают, что это число не просто мало, а очень мало.Снова предположим, что все возможные значения ключей равновероятны и что хэш%функция H распределяет их равномерно по диапазону индексов таблицы.Еще предположим, что некоторый ключ вставляется в таблицу размера N, уже со%держащую k элементов. Тогда вероятность попадания в свободную позицию с первого раза равна (N–k)/N. Этой же величине равна вероятность p1 того, что будет достаточно одного сравнения. Вероятность того, что понадобится в точно%сти еще одна проба, равна вероятности коллизии на первой попытке, умноженной на вероятность попасть в свободную позицию на второй. В общем случае получа%ем вероятность pi вставки, требующей в точности i проб:p1= (N–k)/Np2= (k/N) × (N–k)/(N–1)p3= (k/N) × (k–1)/(N–1) × (N–k)/(N–2)………p i= (k/N) × (k–1)/(N–1) × (k–2)/(N–2) × … × (N–k)/(N–(i–1))Поэтому среднее число E проб, необходимых для вставки k+1%го ключа, равноEk+1= SSSSSi: 1 ≤ i ≤ k+1 : i × p i= 1 × (N–k)/N + 2 × (k/N) × (N–k)/(N–1) + ...+ (k+1) * (k/N) × (k–1)/(N–1) × (k–2)/(N–2) × … × 1/(N–(k–1))= (N+1) / (N–(k–1))Поскольку число проб для вставки элемента совпадает с числом проб для его поиска, этот результат можно использовать для вычисления среднего числа Eпроб, необходимых для доступа к случайному ключу в таблице. Пусть снова раз%мер таблицы обозначен как N, и пусть m – число ключей уже в таблице. ТогдаE = (SSSSSk: 1 ≤ k ≤ m : Ek) / m= (N+1) × (SSSSSk: 1 ≤ k ≤ m : 1/(N–k+2))/m= (N+1) × (HN+1 – HN–m+1)Анализ хэширования Хэширование262где H – гармоническая функция. H можно аппроксимировать как HN = ln(N) + g,где g – постоянная Эйлера. Далее, если ввести обозначение a для отношения m/(N+1), то получаемE = (ln(N+1) – ln(N–m+1))/a = ln((N+1)/(N–m+1))/a = –ln(1–a)/aВеличина a примерно равна отношению занятых и сво%бодных позиций; это отношение называется коэффициентом заполнения (load factor); a = 0 соответствует пустой таблице, a = N/(N+1) ≈ 1 – полной. Среднее число E проб для поиска или вставки случайного ключа дано в табл. 5.1как функция коэффициента заполнения.Числа получаются удивительные, и они объясняют ис%ключительно высокую производительность метода преоб%разования ключей. Даже если таблица заполнена на 90%, в среднем нужно только 2,56 пробы, чтобы найти искомый ключ или свободную позицию. Особо подчеркнем, что это число не зависит от абсолютного числа ключей, а только от коэффициента заполнения.Приведенный анализ предполагает, что применяемый метод разрешения коллизий равномерно рассеивает ключи по оставшимся пози%циям. Методы, используемые на практике, дают несколько худшую производи%тельность. Детальный анализ метода линейных проб дает следующий результат для среднего числа проб:E = (1 – a/2) / (1 – a)Некоторые численные значения E(a) приведены в табл. 5.2 [5.4].Результаты даже для простейшего способа разрешения коллизий настолько хороши, что есть соблазн рассматривать хэширование как панацею на все случаи жизни. Тем более что его производительность превышает даже самые изощрен%ные из обсуждавшихся методов с использованием деревьев, по крайней мере с точки зрения числа сравнений, необходимых для поиска и вставки. Но именно поэтому важно явно указать некоторые недостатки хэширования, даже если они очевидны при непредвзятом анализе.Разумеется, серьезным недостатком по сравнению с методами с динамическим размещением являются фиксированный размер таблицы и невозможность изме%нять его в соответствии с текущей необходимостью.Поэтому обязательно нужна достаточно хорошая ап%риорная оценка числа обрабатываемых элементов дан%ных, если неприемлемы плохое использование памяти или низкая производительность (или переполнение таблицы). Даже если число элементов известно точ%но, – что бывает крайне редко, – стремление к хорошей производительности заставляет выбирать таблицу не%много большего размера (скажем, на 10%).Второй серьезный недостаток методов «рассеянно%го хранения» становится очевидным, если ключи нуж%Таблица 5.1.Таблица 5.1.Таблица 5.1.Таблица 5.1.Таблица 5.1. Среднее число проб E как функция коэффици:ента заполнения aaE0.1 1.05 0.25 1.15 0.5 1.39 0.75 1.85 0.9 2.56 0.95 3.15 0.99 4.66Таблица 5.2.Таблица 5.2.Таблица 5.2.Таблица 5.2.Таблица 5.2. Среднее число проб для метода линейных проб aE0.1 1.06 0.25 1.17 0.5 1.50 0.75 2.50 0.9 5.50 0.95 10.50 263но не только вставлять и искать, но и удалять. Удаление элементов в хэш%табли%це – чрезвычайно громоздкая операция, если только не использовать прямое свя%зывание в отдельной области переполнения. Поэтому разумно заключить, что древесные способы организации по%прежнему привлекательны и даже предпоч%тительны, если объем данных плохо предсказуем, сильно меняется и даже может уменьшаться.1   ...   14   15   16   17   18   19   20   21   22


Алгоритмы и структуры данных
Новая версия для Оберона + CD
Москва, 2010
Никлаус Вирт
Перевод с английского под редакцией
доктора физмат. наук, Ткачева Ф. В.

УДК 32.973.26018.2
ББК 004.438
В52
Никлаус Вирт
В52
Алгоритмы и структуры данных. Новая версия для Оберона + CD / Пер.
с англ. Ткачев Ф. В. – М.: ДМК Пресс, 2010. – 272 с.: ил.
ISBN 9785940745846
В классическом учебнике тьюринговского лауреата Н.Вирта аккуратно, на тщательно подобранных примерах прорабатываются основные темы алго%
ритмики – сортировка и поиск, рекурсия, динамические структуры данных.
Перевод на русский язык выполнен заново, все рассуждения и програм%
мы проверены и исправлены, часть примеров по согласованию с автором переработана с целью максимального прояснения их логики (в том числе за счет использования цикла Дейкстры). Нотацией примеров теперь служит
Оберон/Компонентный Паскаль – наиболее совершенный потомок старого
Паскаля по прямой линии.
Все программы проверены и работают в популярном варианте Оберона –
системе Блэкбокс, и доступны в исходниках на прилагаемом CD вместе с самой системой и дополнительными материалами.
Большая часть материала книги составляет необходимый минимум знаний по алгоритмике не только для программистов%профессионалов, но и любых других специалистов, активно использующих программирование в работе.
Книга может быть использована как учебное пособие при обучении буду%
щих программистов, начиная со старшеклассников в профильном обуче%
нии, а также подходит для систематического самообразования.
Содержание компактдиска:
Базовая конфигурация системы Блэкбокс с коллекцией модулей, реализующих программы из книги.
Базовые инструкции по работе в системе Блэкбокс.
Полный перевод документации системы Блэкбокс на русский язык.
Конфигурация системы Блэкбокс для использования во вводных курсах програм%
мирования в университетах.
Конфигурация системы Блэкбокс для использования в школах (полная русифика%
ция меню, сообщений компилятора, с возможностью использования ключевых слов на русском и других национальных языках).
Доклады участников проекта Информатика%21 по опыту использования системы
Блэкбокс в обучении программированию.
Оригинальные дистрибутивы системы Блэкбокс 1.5 (основной рабочий) и 1.6rc6.
Инструкции по работе в Блэкбоксе под Linux/Wine.
Дистрибутив оптимизирующего компилятора XDS Oberon (версии Linux и MS
Windows).
OberonScript – аналог JavaScript для использования в Web%приложениях.
ISBN 0%13%022005%9 (анг.)
© N. Wirth, 1985 (Oberon version: August 2004).
© Перевод на русский язык, исправления и изменения, Ф. В. Ткачев, 2010.
ISBN 978%5%94074%584%6
© Оформление, издание, ДМК Пресс, 2010

Содержание
О новой версии классического учебника
Никлауса Вирта
....................................................................... 5
Предисловие
.......................................................................... 11
Предисловие к изданию 1985 года
............................. 15
Нотация
..................................................................................... 16
Глава 1. Фундаментальные структуры данных
..... 11 1.1. Введение .............................................................................. 18 1.2. Понятие типа данных ............................................................ 20 1.3. Стандартные примитивные типы .......................................... 22 1.4. Массивы ............................................................................... 26 1.5. Записи .................................................................................. 29 1.6. Представление массивов, записей и множеств .................... 31 1.7. Файлы или последовательности ........................................... 35 1.8. Поиск .................................................................................... 49 1.9. Поиск образца в тексте (string search) .................................. 54
Упражнения.................................................................................. 65
Литература .................................................................................. 67
Глава 2. Сортировка
........................................................... 69 2.1. Введение .............................................................................. 70 2.2. Сортировка массивов ........................................................... 72 2.3. Эффективные методы сортировки ....................................... 81 2.4. Сортировка последовательностей ....................................... 97
Упражнения................................................................................ 128
Литература ................................................................................ 130
Глава 3. Рекурсивные алгоритмы
.............................. 131 3.1. Введение ............................................................................ 132 3.2. Когда не следует использовать рекурсию .......................... 134 3.3. Два примера рекурсивных программ ................................. 137 3.4. Алгоритмы с возвратом ...................................................... 143 3.5. Задача о восьми ферзях ..................................................... 149

Содержание
4 3.6. Задача о стабильных браках ............................................... 154 3.7. Задача оптимального выбора ............................................. 160
Упражнения................................................................................ 164
Литература ................................................................................ 166
Глава 4. Динамические структуры данных
........... 167 4.1. Рекурсивные типы данных .................................................. 168 4.2. Указатели ........................................................................... 170 4.3. Линейные списки ................................................................ 175 4.4. Деревья .............................................................................. 191 4.5. Сбалансированные деревья ............................................... 210 4.6. Оптимальные деревья поиска ............................................. 220 4.7. Б<деревья (BУпражнения................................................................................ 250
Литература ................................................................................ 254
Глава 5. Хэширование
..................................................... 255 5.1. Введение ............................................................................ 256 5.2. Выбор хэш<функции ........................................................... 257 5.3. Разрешение коллизий ........................................................ 257 5.4. Анализ хэширования .......................................................... 261
Упражнения................................................................................ 263
Литература ................................................................................ 264
Приложение A. Множество символов ASCII
.......... 265
Приложение B. Синтаксис Оберона
......................... 266
Приложение C. Цикл Дейкстры
................................... 269

О новой версии
классического учебника
Никлауса Вирта
Новая версия учебника Н. Вирта «Алгоритмы и структуры данных» отличается от английского прототипа [1] сильнее, чем просто исправлением многочисленных опечаток и огрехов, накопившихся в процессе тридцатилетней эволюции книги.
Объясняется это целями автора и переводчика при работе над книгой в контексте проекта «Информатика%21» [2], который, опираясь на обширный совокупный опыт ряда высококвалифицированных специалистов (см. списки консультантов и участников на сайте проекта [2]), ставит задачу создания единой системы ввод%
ных курсов информатики и программирования, охватывающей учащихся пример%
но от 5%го класса общей средней школы по 3%й курс университета. Такая система должна иметь образцом и дополнять уникальную российскую систему матема%
тического образования. Это предполагает наличие стержня общих курсов, состав%
ляющих единство без внутренних технологических барьеров (которые приводят,
среди прочего, к недопустимым потерям дефицитного учебного времени) и лишь варьирующихся в зависимости от специализации, вместе с надстройкой из профессионально ориентированных курсов, опирающихся на этот стержень в от%
ношении базовых знаний учащихся. Такая система подразумевает наличие каче%
ственных учебников (первым из которых имеет шанс стать данная книга),
«говорящих» на общем образцовом языке программирования. Естественный кан%
дидат на роль такого общего языка – Оберон/Компонентный Паскаль. Подроб%
ней об Обероне речь пойдет ниже, здесь только скажем, что Паскаль (использо%
ванный в первом издании данной книги 1975 г.), Модулу%2 (использованную во втором издании, переведенном на русский язык в 1989 г. [3]) и Оберон (использо%
ванный в данной версии) логично рассматривать соответственно как альфа%, бета%
и окончательную версию одного и того же языка. Использование Оберона – самое очевидное отличие данной версии книги от предыдущего издания.
В контекст идеи о единой системе вводных курсов вписывается и узкая задача,
решавшаяся новой версией учебника, – дать небольшое продуманное пособие, в котором аккуратно, но не топя читателя в болоте второстепенных деталей, прора%
батывались бы традиционные темы классической алгоритмики, для полного обсуждения которых нет времени в спецкурсе, читаемом переводчиком с 2001 г.
на физфаке МГУ в попытке обеспечить хотя бы минимум культуры программиро%
вания у будущих аспирантов. Здесь требуется «отлаженный» текст, пригодный для самостоятельной работы студентов. С точки зрения содержания, лучшим кандидатом на эту роль оказался прототип [1].

О новой версии классического учебника Никлауса Вирта
6
Что двойное переделывание программ и рассуждений в тексте (с Паскаля на
Модулу%2 и затем на Оберон) не прошло безнаказанно, само по себе неудиви%
тельно. Однако затруднения, возникшие при верификации программ и текста,
хотя и были преодолены, все же показались чрезмерными. Поэтому, и ввиду учеб%
ного назначения книги, встал ребром вопрос о необходимости доработки примеров.
Предложения переводчика были одобрены автором на совместной рабочей сессии в апреле сего года и реализованы непосредственно в данном переводе (при первой возможности соответствующие изменения будут внесены и в прототип [1]).
Во%первых, алгоритмы поиска образца в тексте переписаны в терминах цикла
Дейкстры (многоветочный while
[4]). Эта фундаментальная и мощная управля%
ющая структура поразительным образом до сих пор не представлена в распро%
страненных языках программирования, поэтому ей посвящено новое приложение
C. Раздел 1.9, в который теперь выделены эти алгоритмы, будет неплохой иллю%
страцией реального применения цикла Дейкстры. Вторая группа заметно изме%
ненных программ – алгоритмы с возвратом в главе 3, в которых теперь экспли%
цировано применение линейного поиска и, благодаря этому, тривиализована верификация. Такое прояснение рекурсивных комбинаторных алгоритмов явля%
ется довольно общим. Обсуждались – но были признаны в данный момент неце%
лесообразными – модификации и некоторых других программ.
Надо заметить, что программистский стиль автора вырабатывался с конца
1950%х гг., когда проблема эффективности программ висела над головами про%
граммистов дамокловым мечом, и за несколько лет до того, как Дейкстра опубли%
ковал систематический метод построения программ [4]. В старых версиях книги заметна рефлекторная склонность к оптимизации до полного прояснения логики программ, что затрудняло эффективное применение формальной техники. Это легко объяснить: Н. Вирт осваивал только еще формирующиеся систематические методы, непосредственно участвуя в процессе создания программирования как академической дисциплины, версия за версией улучшая свои учебники.
Но и через четверть века после последней существенной переделки учебника автором аналогичная склонность к преждевременной оптимизации при не просто не вполне уверенной, а напрочь отсутствующей формальной технике – и, как следствие, запутанные циклы, – характерные черты стиля «широких програм%
мистских масс»! В профессиональных интернет%форумах до сих пор можно найти позорные дискуссии о том, нужно ли учиться писать циклы по Дейкстре, – и это в лучшем случае. Если же вообразить себе весь окружающий нас непрерывно рас%
тущий массив софта, от которого наша жизнь зависит все больше, то впору впасть в депрессию: Quo usque tandem, Catilina? – Сколько еще нужно десятилетий, что%
бы система образования вышла, наконец, на уровень, давным%давно достигнутый наукой? Во всяком случае, ясно, что едва ли не главная причина проблемы – хаос,
царящий в системе ИТ%образования, тормозящий создание и распространение качественных методик и поддерживаемый, среди прочего, корыстными интереса%
ми «монстров» индустрии.
Здесь уместно сказать о языке Оберон/Компонентный Паскаль, пропаганди%
руемом в качестве общей платформы для предполагаемой единой системы курсов

О новой версии классического учебника Никлауса Вирта
7
программирования. Оберон – последний большой проект Никлауса Вирта, выда%
ющегося инженера, ученого и педагога, вместе с Бэкусом, А. Ершовым, Дейкст%
рой, Хоором и другими пионерами компьютерной информатики превратившего программирование в систематическую дисциплину и лучше всего известного со%
зданием серии все более совершенных языков программирования – Паскаля
(1970), Модулы%2 (1980) и наконец Оберона (1988, 2007). В этих языках отража%
лось все более полное понимание проблематики эффективного программирова%
ния. Языки эти сохраняют идейную и стилевую преемственность, и коммерсант,
озабоченный сохранением доли рынка, не назвал бы их по%разному (ср. зоопарк бейсиков). Чтобы подчеркнуть эту преемственность, самому популярному диа%
лекту Оберона было возвращено законное фамильное имя – Компонентный Пас%
каль.
Оберон/Компонентный Паскаль унаследовал лучшие черты старого доброго
Паскаля и добавил к ним промышленный опыт Модулы%2 (на которой програм%
мируются, например, российские спутники связи [5]), а также выверенный мини%
мум средств объектно%ориентированного программирования. Принципальное до%
стижение – удалось наконец добиться герметичности системы типов (теперь ее нельзя обойти средствами языка даже при работе с указателями). Это обеспечило возможность автоматического управления памятью (сбора мусора; до Оберона сбор мусора оставался прерогативой динамических языков – функциональных,
скриптовых и т. п.) В результате диапазон эффективного применения Оберона,
похоже, шире, чем у любого другого языка: это и вычислительные приложения, и системы управления любого масштаба (от беспилотников весом в 1 кг до гранди%
озных каскадов ГЭС), и, например, задачи символической алгебры с предельно динамичными структурами данных.
Особо следует остановиться на минимализме Оберона. Традиционно разра%
ботчики сосредоточиваются на том, чтобы снабдить свои языки, программы, биб%
лиотеки «богатым набором средств» – ведь так легче привлечь клиента, надеюще%
гося побыстрее найти готовое решение для своих прикладных нужд. Погоня за
«богатым набором средств» оборачивается ущербом качеству и надежности сис%
темы. Вместе с коммерческими соображениями это приводит к тому, что полу%
чается большая закрытая сложная система с вроде бы богатым набором средств,
но хромающей надежностью и ограниченной расширяемостью, так что если поль%
зователь сталкивается с нестандартной ситуацией в своих приложениях (что слу%
чается сплошь и рядом – ведь разнообразие реального мира превосходит любое воображение писателей библиотек), то он оказывается в тупике.
Н. Вирт еще со времен Паскаля, созданного в пику фантазийному Алголу%68
[6], пошел другим путем. Его гамбит заключался в том, чтобы, отказавшись от включения в язык максимума средств на все случаи жизни, тщательнейшим обра%
зом выделить минимум реально ключевых средств, – обязательно включив в этот минимум все, что нужно для безболезненной, неограниченной расширяемости программных систем, – и добиться высоконадежной реализации такого ядра.
Этот замысел был с блеском реализован Н. Виртом и его соратником Ю. Гуткнех%
том в проекте Оберон [7]. Минимализм и уникальная надежность Оберона

О новой версии классического учебника Никлауса Вирта
8
заставляют вспомнить автомат Калашникова. При этом вся мощь Оберона оказы%
вается открытой даже программистам%непрофессионалам – физикам, инженерам,
лингвистам.., занятым программированием изрядную долю своего рабочего времени.
Для преподавателя важно, что в Обероне достигнуты ортогональность и сво%
бодная комбинируемость языковых средств, смысловая прозрачность, а также беспрецедентно малый для столь мощного языка размер (см. полное описание синтаксиса в приложении B, а также обсуждение в [8]). В этом отношении Оберон побеждает за явным преимуществом традиционные промышленные языки, пре%
словутая избыточная сложность которых оказывается источником своего рода ренты, взимаемой с остального мира. Оберон скромно уходит в тень при рассмо%
трении любой языково%неспецифичной темы – от введения в алгоритмику до принципов компиляции и программной архитектуры. А после постановки базо%
вой техники программирования на Обероне изучение промышленных языков за%
частую сводится к изучению способов обходить дефекты их дизайна. Если уже старый Паскаль оказался настолько удачной платформой для обучения програм%
мированию, что принес своему автору высшую почесть в компьютерной инфор%
матике – премию им. Тьюринга, то понятно, что буквально вылизанный Оберон/
Компонентный Паскаль называют уже «практически идеальной» платформой для обучения программированию.
Имея в виду исключительные педагогические достоинства Оберона, для всех примеров программ, приведенные в книге, обеспечена воспроизводимость в сис%
теме программирования для Компонентного Паскаля, известной как Блэкбокс
(BlackBox Component Builder [9]). Это пулярный вариант Оберона, созданный для работы в распространенных операционных системах. Конфигурации Блэк%
бокса для использования в школе и университете доступны на сайте проекта «Ин%
форматика%21» [2]. Открытый, бесплатный и безупречно современный Блэкбокс оказывается естественной заменой устаревшему Турбо Паскалю – заменой тем более привлекательной, что, несмотря на минимализм и благодаря автоматиче%
скому управлению памятью, это более мощный инструмент, чем промышленные системы программирования на диалектах старого Паскаля. Краткое описание возможностей Блэкбокса с точки зрения использования в школьных курсах мож%
но найти в статье [10].
Важное приложение к книге – полный комплект программ, представленных в тексте учебника, в виде, готовом к выполнению. Программы оформлены в отдель%
ных модулях вместе с необходимыми вспомогательными процедурами, и все та%
кие модули собраны в папке
ADru/Mod/
, которая должна лежать внутри основной папки Блэкбокса (следует иметь в виду, что файлы с расширением *.odc должны читаться из Блэкбокса). Читатель без труда разберется с компиляцией и запуском программ по комментариям в модулях, читая модули в том порядке, в каком они встречаются в тексте книги (или в лексикографическом порядке имен файлов).
В тексте книги в начальных строках каждого законченного программного приме%
ра справа указано имя соответствующего модуля. Например, комментарий
(*ADruS18_*)
означает, что данная программа содержится в модуле

О новой версии классического учебника Никлауса Вирта
9
ADruS18_
, который в соответствии с правилами Блэкбокса хранится в фай%
ле
ADru/Mod/S18_.odc
. При этом речь идет о программе из раздела 1.8,
а необязательный суффикс "_"
служит удобству ориентации. Вся папка
ADru в составе Блэкбокса имеется на диске, если диск приложен к книге, либо может быть скачана с адреса [11].
Наконец, несколько слов о собственно переводе. Старый перевод [3] был вы%
полнен, что называется, из общих соображений. Но совсем другое дело – иметь в виду конкретных студентов, не обязательно будущих профессиональных программистов, пытающихся за минимальное время овладеть основами програм%
мирования. Поэтому в новом переводе были предприняты особые усилия, чтобы избежать размывания смысла из%за неточностей, неизбежно вкрадывающихся при неполном понимании переводчиком оригинала (ср. примечание на с. 110
в главе о сортировках в [3], где выражена надежда, что «сам читатель разберется,
что хотел сказать автор»). Например, при более%менее прямолинейной пофразо%
вой интерпретации малейшая неточность способна развалить смысл лаконичного текста Вирта из%за того, например, что после перевода могут перестать быть одно%
коренными слова, благодаря которым только и обеспечивалась смысловая связь между предложениями в оригинале. Поэтому добиться полного сохранения смыс%
ла при переводе оказалось проще, выполнив его с нуля.
В отношении терминологии переводам специалистов было отдано должное.
Вслед за Д. Б. Подшиваловым [3] мы используем прилагательные «массивовый»,
«последовательностный» и «записевый». Решающий довод в пользу таких прила%
гательных – они естественно вписываются в грамматическую систему русского языка, чем обеспечивается необходимая гибкость выражения.
Однако даже в отношении терминологии переводы по компьютерной тематике часто демонстрируют неполное понимание существенных деталей английской грамматики. Например, при использовании существительного в качестве опреде%
ления в препозиции (что, кстати, не эквивалентно русской конструкции, выража%
емой родительным падежом) множественное число может нейтрализоваться, и при переводе на русский его иногда нужно восстанавливать. Так, path length дол%
жно переводиться не как «длина пути», а как «длина путей», что, между прочим,
прямо соответствует математическому определению и ощутимо помогает пони%
мать рассуждения. Optimal search tree – «оптимальное дерево поиска», а не «дере%
во оптимального поиска». Advanced sort algorithms – «эффективные алгоритмы сортировки», потому что буквальное значение advanced в данном случае давно нейтрализовано. Переводить на русский язык двумя словами специфичные для стилистики английского языка синонимичные пары вроде «methods and tech%
niques» обычно неразумно. И так далее. Масса подобных неточностей снижает удобочитаемость текста и затемняет и без того непростой смысл оригинала.
Хотя по конкретным стилистическим вопросам копья можно ломать до беско%
нечности, все же хочется надеяться, что предпринятые усилия в основном достиг%
ли цели – не потерять точный смысл английского «исходника» этого выдержав%
шего проверку временем прекрасного учебника.
Троицк, Московская обл., июль 2009
Ф. В. Ткачев

О новой версии классического учебника Никлауса Вирта
10
[1] Wirth N. Algorithms and Data Structures. Oberon version: 2004 //http://www.
inr.ac.ru/info21/pdf/AD.pdf
[2] Информатика%21: Международный общественный научно%образователь%
ный проект // http://www.inr.ac.ru/info21/
[3] Н. Вирт. Алгоритмы и структуры данных / пер. с англ. Д. Б. Подшивалова. –
М.: Мир, 1989.
[4] Дейкстра Э. Дисциплина программирования. – М.: Мир, 1978.
[5] Koltashev A. A., in: Lecture Notes in Computer Science 2789. – Springer%Verlag,
2003.
[6] Кто такой Никлаус Вирт? // http://www.inr.ac.ru/info21/wirth/wirth.htm
[7] Wirth N. and Gutknecht J. Project Oberon. – Addison%Wesley, 1992.
[8] Свердлов С. В. Языки программирования и методы трансляции. – СПб.:
Питер, 2007.
[9] http://www.oberon.ch/blackbox.html
[10] Ильин А. С. и Попков А. И. Компонентный Паскаль в школьном курсе ин%
форматики // http://inf.1september.ru/article.php?ID=200800100
[11] http://www.inr.ac.ru/info21/ADru/

Предисловие
В последние годы признано, что умение создавать программы для вычислитель%
ных машин является залогом успеха во многих инженерных проектах и что дис%
циплина программирования может быть объектом научного анализа и допускает систематическое изложение. Программирование из ремесла превратилось в ака%
демическую дисциплину. Первые выдающиеся результаты на этом пути получе%
ны Дейкстрой (E. W. Dijkstra) и Хоором (C. A. R. Hoare). «Заметки по структурно%
му программированию» Дейкстры [1] позволили взглянуть на программирование как на объект научного анализа, бросающий вызов человеческому интеллекту,
а слова структурное программирование дали название «революции» в програм%
мировании. Работа Хоора «Аксиоматические основы программирования» [2]
продемонстрировала, что программы допускают точный анализ, основанный на математических рассуждениях. И обе статьи убедительно доказывают, что мно%
гих ошибок в программах можно избежать, если программисты будут систе%
матически применять методы и приемы, которые ранее применялись лишь инту%
итивно и часто неосознанно. Эти статьи сосредоточили внимание на построении и анализе программ, или, точнее говоря, на структуре алгоритмов, представленных текстом программы. При этом вполне очевидно, что систематический научный подход к построению программ уместен прежде всего в случае больших, непрос%
тых программ, работающих со сложными наборами данных. Отсюда следует, что методология программирования должна включать в себя все аспекты структури%
рования данных. В конце концов, программы суть конкретные формулировки аб%
страктных алгоритмов, основанные на конкретных представлениях и структурах данных. Выдающийся вклад в наведение порядка в огромном разнообразии тер%
минологии и понятий, относящихся к структурам данных, сделал Хоор в статье
«О структурной организации данных» [3]. В этой работе продемонстрировано,
что нельзя принимать решения о структуре данных без учета того, какие алгорит%
мы применяются к данным, и что, обратно, структура и выбор алгоритмов часто сильно зависят от стуктуры обрабатываемых данных. Короче говоря, задачу пост%
роения программ нельзя отделять от задачи структурирования данных.
Но данная книга начинается главой о структурах данных, и для этого есть две причины. Во%первых, интуитивно ощущается, что данные предшествуют алгорит%
мам: нужно иметь некоторые объекты до того, как можно будет что%то с ними де%
лать. Во%вторых, эта книга предполагает, что читатель знаком с основными поня%
тиями программирования. Однако в соответствии с разумной традицией вводные курсы программирования концентрируют внимание на алгоритмах, работающих с относительно простыми структурами данных. Поэтому уместно посвятить ввод%
ную главу структурам данных.
На протяжении всей книги, включая главу 1, мы следуем теории и термино%
логии, развитой Хоором и реализованной в языке программирования Паскаль [4].
Сущность теории – в том, что данные являются прежде всего абстракциями реальных явлений и их предпочтительно формулировать как абстрактные струк%

Предисловие
12
туры безотносительно к их реализации в распространенных языках программиро%
вания. В процессе построения программы представление данных постепенно уточняется – в соответствии с уточнением алгоритма, – чтобы все более и более удовлетворить ограничениям, налагаемым имеющейся системой программи%
рования [5]. Поэтому мы постулируем несколько основных структур данных, на%
зываемых фундаментальными. Очень важно, что это конструкции, которые дос%
таточно легко реализовать на реальных компьютерах, ибо только в этом случае их можно рассматривать как истинные элементарные составляющие реального представления данных, появляющиеся как своего рода молекулы на последнем шаге уточнения описания данных. Это запись, массив (с фиксированным разме%
ром) и множество. Неудивительно, что эти базовые строительные элементы соответствуют математическим понятиям, которые также являются фундамен%
тальными.
Центральный пункт этой теории структур данных – разграничение фундамен
тальных и сложных структур. Первые суть молекулы, – сами построенные из ато%
мов, – из которых строятся вторые. Переменные, принадлежащие одному из таких фундаментальных видов структур, меняют только свое значение, но никогда не ме%
няют ни свое строение, ни множество своих допустимых значений. Как следствие –
размер занимаемой ими области памяти фиксирован. «Сложные» структуры, на%
против, характеризуются изменением во время выполнения программы как своих значений, так и строения. Поэтому для их реализации нужны более изощренные методы. В этой классификации последовательность оказывается гибридом. Конеч%
но, у нее может меняться длина; но такое изменение структуры тривиально. По%
скольку последовательности играют поистине фундаментальную роль практичес%
ки во всех вычислительных системах, их обсуждение включено в главу 1.
Во второй главе речь идет об алгоритмах сортировки. Там представлено не%
сколько разных методов, решающих одну и ту же задачу. Математическое изу%
чение некоторых из них показывает их преимущества и недостатки, а также под%
черкивает важность теоретического анализа при выборе хорошего решения для конкретной задачи. Разделение на методы сортировки массивов и методы сорти%
ровки файлов (их часто называют внутренней и внешней сортировками) демон%
стрирует решающее влияние представления данных на выбор алгоритмов и на их сложность. Теме сортировки уделяется такое внимание потому, что она пред%
ставляет собой идеальную площадку для иллюстрации очень многих принципов программирования и ситуаций, возникающих в большинстве других приложе%
ний. Похоже, что курс программирования можно было бы построить, используя только примеры из темы сортировки.
Другая тема, которую обычно не включают во вводные курсы программиро%
вания, но которая играет важную роль во многих алгоритмических решениях, –
это рекурсия. Поэтому третья глава посвящена рекурсивным алгоритмам. Здесь показывается, что рекурсия есть обобщение понятия цикла (итерации) и что она является важным и мощным понятием программирования. К сожалению, во мно%
гих учебниках программирования она иллюстрируется примерами, для которых было бы достаточно простой итерации. Мы в главе 3, напротив, сосредоточим внимание на нескольких задачах, для которых рекурсия дает наиболее естествен%
ную формулировку решения, тогда как использование итерации привело бы к за%

Предисловие
13
путанным и громоздким программам. Класс алгоритмов с возвратом – отличное применение рекурсии, но самые очевидные кандидаты для применения рекур%
сии – это алгоритмы, работающие с данными, структура которых определена ре%
курсивно. Эти случаи рассматриваются в последних двух главах, для которых,
таким образом, третья закладывает фундамент.
В главе 4 рассматриваются динамические структуры данных, то есть такие,
строение которых меняется во время выполнения программы. Показывается, что рекурсивные структуры данных являются важным подклассом часто использу%
емых динамических структур. Хотя рекурсивные определения возможны и даже естественны в этих случаях, на практике они обычно не используются. Вместо них используют явные ссылочные или указательные переменные. Данная книга тоже следует подобному подходу и отражает современный уровень понимания предме%
та: глава 4 посвящена программированию с указателями, списками, деревьями и содержит примеры с даже еще более сложно организованными данными. Здесь речь идет о том, что обычно (хотя и не совсем правильно) называют обработкой списков. Немало места уделено построению деревьев и, в частности, деревьям по%
иска. Глава заканчивается обсуждением так называемых хэш%таблиц, которые ча%
сто используют вместо деревьев поиска. Это дает возможность сравнить два принципиально различных подхода к решению часто возникающей задачи.
Программирование – это конструирование. Как вообще можно учить изобре%
тательному конструированию? Можно было бы попытаться из анализа многих примеров выделить элементарные композиционные принципы и представить их систематическим образом. Но программирование имеет дело с задачами огромно%
го разнообразия и часто требует серьезных интеллектуальных усилий. Ошибочно думать, что обучить ему можно, просто дав некий список рецептов. Но тогда в на%
шем арсенале методов обучения остаются только тщательный подбор и изложе%
ние образцовых примеров. Естественно, не следует ожидать, что изучение приме%
ров будет равно полезным для разных людей. При таком подходе многое зависит от самого учащегося, от его прилежания и интуиции. Это особенно справедливо для относительно сложных и длинных примеров программ. Такие примеры включены в книгу не случайно. Длинные программы доминируют в практике программирования, и они гораздо больше подходят для демонстрации тех труд%
но определяемых, но существенных свойств, которые называют стилем и хоро%
шей структурой. Они также должны послужить упражнениями в искусстве чте%
ния программ, которым часто пренебрегают в пользу написания программ. Это главная причина того, почему в качестве примеров используются целиком до%
вольно большие программы. Читатель имеет возможность проследить постепен%
ную эволюцию программы и увидеть ее состояние на разных шагах, так что про%
цесс разработки предстает как пошаговое уточнение деталей. Считаю, что важно показать программу в окончательном виде, уделяя достаточно внимания деталям,
так как в программировании дьявол прячется в деталях. Хотя изложение общей идеи алгоритма и его анализ с математической точки зрения могут быть увлека%
тельными для ученого, по отношению к инженеру%практику ограничиться только этим было бы нечестно. Поэтому я строго придерживался правила давать оконча%
тельные программы на таком языке, на котором они могут быть реально выполне%
ны на компьютере.

Предисловие
14
Разумеется, здесь возникает проблема поиска нотации, которая одновременно позволяла бы выполнить программу на вычислительной машине и в то же время была бы достаточно машинно независимой, чтобы ее можно было включать в по%
добный текст. В этом отношении не удовлетворительны ни широко используемые языки, ни абстрактная нотация. Язык Паскаль представляет собой подходящий компромисс; он был разработан именно для этой цели и поэтому используется на протяжении всей книги. Программы будут понятны программистам, знакомым с другими языками высокого уровня, такими как Алгол 60 или PL/1: смысл нота%
ции Паскаля объясняется в книге по ходу дела. Однако некоторая подготовка все же могла бы быть полезной. Книга «Систематическое программирование» [6]
идеальна в этом отношении, так как она тоже основана на нотации Паскаля. Одна%
ко следует помнить, что настоящая книга не предназначена быть учебником язы%
ка Паскаль; для этой цели есть более подходящие руководства [7].
Данная книга суммирует – и при этом развивает – опыт нескольких курсов программирования, прочитанных в Федеральном политехническом институте
(ETH) в Цюрихе. Многими идеями и мнениями, представленными в этой книге,
я обязан дискуссиям со своими коллегами в ETH. В частности, я хотел бы поблагодарить г%на Г. Сандмайра за внимательное чтение рукописи, а г%жу Хайди
Тайлер и мою жену за тщательную и терпеливую перепечатку текста. Я должен также упомянуть о стимулирующем влиянии заседаний рабочих групп 2.1 и 2.3
ИФИПа, и в особенности многих дискуссий, которые мне посчастливилось иметь с Э. Дейкстрой и Ч. Хоором. Наконец, нужно отметить щедрость ETH, обеспечив%
шего условия и предоставившего вычислительные ресурсы, без которых подго%
товка этого текста была бы невозможной.
Цюрих, август 1975
Н. Вирт
[1]
Dijkstra E. W., in: Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Prog%
ramming. F. Genuys, Ed., New York, Academic Press, 1972. Р. 1–82 (имеется перевод: Дейкстра Э. Заметки по структурному программированию, в кн.:
Дал У., Дейкстра Э., Хоор К. Структурное программирование. – М.: Мир,
1975. С. 7–97).
[2]
Hoare C. A. R. Comm. ACM, 12, No. 10 (1969), 576–83.
[3]
Hoare C. A. R., in Structured Programming [1]. Р. 83%174 (имеется перевод:
Хоор К. О структурной организации данных, в кн. [1]. С. 98–197).
[4]
Wirth N. The Programming Language Pascal. Acta Informatica, 1, No. 1 (1971),
35–63.
[5]
Wirth N. Program Development by Stepwise Refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[6]
Wirth N. Systematic Programming. Englewood Cliffs, N. J. Prentice%Hall, Inc.,
1973 (имеется перевод: Вирт Н. Систематическое программирование. Вве%
дение. – М.: Мир, 1977).
[7]
Jensen K. and Wirth N. PASCAL%User Manual and Report. Berlin, Heidelberg,
New York; Springer%Verlag, 1974 (имеется перевод: Йенсен К., Вирт Н. Пас%
каль. Руководство для пользователя и описание языка. – М.: Финансы и ста%
тистика, 1988).

Предисловие
к изданию 1985 года
В этом новом издании сделано много улучшений в деталях, а также несколько бо%
лее серьезных модификаций. Все они мотивированы опытом, приобретенным за десять лет после первого издания. Однако основное содержание и стиль текста не изменились. Кратко перечислим важнейшие изменения.
Главное изменение, повлиявшее на весь текст, касается языка программирова%
ния, использованного для записи алгоритмов. Паскаль был заменен на Модулу%2.
Хотя это изменение не оказывает серьезного влияния на представление алгорит%
мов, выбор оправдан большей простотой и элегантностью синтаксиса Модулы%2,
что часто приводит к большей ясности представления структуры алгоритма. Кро%
ме того, было сочтено полезным использовать нотацию, которая приобретает по%
пулярность в довольно широком сообществе по той причине, что она хорошо под%
ходит для разработки больших программных систем. Тем не менее тот очевидный факт, что Паскаль является предшественником Модулы, облегчает переход. Для удобства читателя синтаксис Модулы суммирован в приложении.
Как прямое следствие замены языка программирования был переписан раз%
дел 1.11 о последовательной файловой структуре. В Модуле%2 нет встроенного файлового типа. В пересмотренном разделе 1.11 понятие последовательности как структуры данных представлено в более общем виде, и там также вводится набор программных модулей, которые явно реализуют идею последовательности конк%
ретно в Модуле%2.
Последняя часть главы 1 является новой. Она посвящена теме поиска и, начи%
ная с линейного и двоичного поиска, подводит к некоторым недавно изобретен%
ным быстрым алгоритмам поиска строк. В этом разделе подчеркивается важность проверок промежуточных состояний (assertions) и инвариантов цикла для дока%
зательства корректности представляемых алгоритмов.
Новый раздел о приоритетных деревьях поиска завершает главу, посвященную динамическим структурам данных. Эта разновидность деревьев была неизвестна во время выхода первого издания. Такие деревья допускают экономное представление и позволяют выполнять быстрый поиск по множествам точек на плоскости.
Целиком исключена вся пятая глава первого издания. Это сделано потому, что тема построения компиляторов стоит несколько в стороне от остальных глав и заслуживает более подробного обсуждения в отдельной книге.
Наконец, появление нового издания отражает прогресс, глубоко повлиявший на издательское дело в последние десять лет: применение компьютеров и изощ%
ренных алгоритмов для подготовки и автоматического форматирования докумен%
тов. Эта книга была набрана и сформатирована автором с помощью компьютера
Lilith и редактора документов Lara. Без этих инструментов книга не только стала бы дороже, но, несомненно, даже еще не была бы закончена.
Пало Альто, март 1985 г.
Н. Вирт

Нотация
В книге используются следующие обозначения, взятые из работ Дейкстры.
В логических выражениях литера
&
обозначает конъюнкцию и читается как
«и». Литера

обозначает отрицание и читается как «не». Комбинация литер or обозначает дизъюнкцию и читается как «или». Литеры
A
A
A
A
A
и
E
E
E
E
E
, набранные жирным шрифтом, обозначают кванторы общности и существования. Нижеследующие формулы определяют смысл нотации в левой части через выражение в правой.
Интерпретация символа «...» в правых частях оставлена интуиции читателя.
A
A
A
A
Ai: m
≤ i < n : P
i
P
m
& P
m+1
& ... & P
n–1
Здесь
P
i
– некоторые предикаты, а формула утверждает, что выполняются все
P
i для значений индекса i
из диапазона от m
до n
, но не включая само n
E
E
E
E
Ei: m
≤ i < n : P
i
P
m or P
m+1
or ... or P
n–1
Здесь
P
i
– некоторые предикаты, а формула утверждает, что выполняются некоторые из
P
i для каких%то значений индекса i
из диапазона от m
до n
, но не включая само n
S
S
S
S
Si: m
≤ i < n : x i
= x m
+ x m+1
+ ... + x n–1
MIN i: m

i
<
n : x i
= минимальное среди значений
(x m
, ... , x n–1
)
MAX i: m

i
<
n : x i
= максимальное среди значений
(x m
, ... , x n–1
)

  1   2   3   4   5   6   7   8   9   ...   22


Глава 1
Фундаментальные
структуры данных
1.1. Введение ............................ 18 1.2. Понятие типа данных .......... 20 1.3. Стандартные примитивные типы ..................... 22 1.4. Массивы ............................. 26 1.5. Записи ............................... 29 1.6. Представление массивов,
записей и множеств ................... 31 1.7. Файлы или последовательности .................. 35 1.8. Поиск ................................. 49 1.9. Поиск образца в тексте
(string search) ............................. 54
Упражнения ............................... 65
Литература ................................ 67

Фундаментальные структуры данных
18
1.1. Введение
Современные цифровые компьютеры были изобретены для выполнения сложных и длинных вычислений. Однако в большинстве приложений предоставляемая та%
ким устройством возможность хранить и обеспечивать доступ к большим масси%
вам информации играет основную роль и рассматривается как его главная характеристика, а возможность призводить вычисления, то есть выполнять арифметические действия, во многих случаях стала почти несущественной.
В таких приложениях большой массив обрабатываемой информации является в определенном смысле абстрактным представлением некоторой части реального мира. Информация, доступная компьютеру, представляет собой специально подобранный набор данных, относящихся к решаемой задаче, причем предпо%
лагается, что этот набор достаточен для получения нужных результатов. Данные являются абстрактным представлением реальности в том смысле, что некоторые свойства реальных объектов игнорируются, так как они несущественны для этой задачи. Поэтому абстракция – это еще и упрощение реальности.
В качестве примера можно взять файл с данными о служащих некоторой ком%
пании. Каждый служащий (абстрактно) представлен в этом файле набором дан%
ных, который нужен либо для руководства компании, либо для бухгалтерских расчетов. Такой набор может содержать некоторую идентификацию служащего,
например имя и зарплату. Но в нем почти наверняка не будет несущественной информации о цвете волос, весе или росте.
Решая задачу с использованием компьютера или без него, необходимо выбрать абстрактное представление реальности, то есть определить набор данных, кото%
рый будет представлять реальную ситуацию. Этот выбор можно сделать, руко%
водствуясь решаемой задачей. Затем нужно определиться с представлением ин%
формации. Здесь выбор определяется средствами вычислительной установки.
В большинстве случаев эти два шага не могут быть полностью разделены.
Выбор представления данных часто довольно сложен и не полностью определя%
ется имеющимися вычислительными средствами. Делать такой выбор всегда нужно с учетом операций, которые нужно выполнять с данными. Хороший пример – пред%
ставление чисел, которые сами суть абстракции свойств некоторых объектов. Если единственное (или основное) действие, которое нужно выполнять, – сложение, то хорошим представлением числа n
может быть n
черточек. Правило сложения при та%
ком представлении – очевидное и очень простое. Римская нотация основана на этом принципе простоты, и правила сложения просты для маленьких чисел. С другой сто%
роны, представление арабскими цифрами требует неочевидных правил сложения
(для маленьких чисел), и их нужно запоминать. Однако ситуация меняется на проти%
воположную, если нужно складывать большие числа или выполнять умножение и деление. Разбиение этих операций на более простые шаги гораздо проще в случае арабской нотации благодаря ее систематической позиционной структуре.
Хорошо известно, что компьютеры используют внутреннее представление,
основанное на двоичных цифрах (битах). Это представление непригодно для использования людьми, так как здесь обычно приходится иметь дело с большим


19
числом цифр, но весьма удобно для электронных схем, так как два значения 0 и 1
можно легко и надежно представить посредством наличия или отсутствия элект%
рических токов, зарядов или магнитных полей.
Из этого примера также видно, что вопрос представления часто требует рассма%
тривать несколько уровней детализации. Например, в задаче представления по%
ложения объекта первое решение может касаться выбора пары чисел в, скажем,
декартовых или полярных координатах. Второе решение может привести к пред%
ставлению с плавающей точкой, где каждое вещественное число x
состоит из пары целых, обозначающих дробную часть f
и показатель e
по некоторому основанию
(например, x = f
×
2
e
). Третье решение, основанное на знании, что данные будут храниться в компьютере, может привести к двоичному позиционному представ%
лению целых чисел. Наконец, последнее решение может состоять в том, чтобы представлять двоичные цифры электрическими зарядами в полупроводниковом устройстве памяти. Очевидно, первое решение в этой цепочке зависит главным образом от решаемой задачи, а дальнейшие все больше зависят от используемого инструмента и применяемых в нем технологий. Вряд ли можно требовать, чтобы программист решал, какое представление чисел использовать или даже какими должны быть характеристики устройства хранения данных. Такие решения низ%
кого уровня можно оставить проектировщикам вычислительного оборудования,
у которых заведомо больше информации о существующих технологиях, чтобы сделать разумный выбор, приемлемый для всех (или почти всех) приложений, где играют роль числа.
В таком контексте выявляется важность языков программирования. Язык программирования представляет абстрактный компьютер, допускающий интер%
претацию в терминах данного языка, что может подразумевать определенный уровень абстракции по сравнению с объектами, используемыми в реальном вычислительном устройстве. Тогда программист, использующий такой язык вы%
сокого уровня, будет освобожден от заботы о представлении чисел (и лишен воз%
можности что%то сделать в этом отношении), если числа являются элементар%
ными объектами в данном языке.
Использование языка, предоставляющего удобный набор базовых абстракций,
общих для большинства задач обработки данных, влияет главным образом на на%
дежность получающихся программ. Легче спроектировать программу, опираясь в рассуждениях на знакомые понятия чисел, множеств, последовательностей и циклов, чем иметь дело с битами, единицами хранения и переходами управления.
Конечно, реальный компьютер представляет любые данные – числа, множества или последовательности – как огромную массу битов. Но программист может за%
быть об этом, если ему не нужно беспокоиться о деталях представления выбран%
ных абстракций и если он может считать, что выбор представления, сделанный компьютером (или компилятором), разумен для решаемых задач.
Чем ближе абстракции к конкретному компьютеру, тем легче сделать выбор представления инженеру или автору компилятора и тем выше вероятность, что единственный выбор будет подходить для всех (или почти всех) мыслимых при%
ложений. Это обстоятельство устанавливает определенные пределы на «высоту»
Введение


Фундаментальные структуры данных
20
используемых абстракций по сравнению с уровнем реального «железа». Напри%
мер, неразумно включать в язык общего назначения геометрические фигуры, так как из%за внутренне присущей им сложности их подходящее представление будет сильно зависеть от действий, выполняемых с ними. Однако природа и частота та%
ких действий неизвестна проектировщику языка программирования общего на%
значения и соответствующего компилятора, и любой выбор проектировщика мо%
жет оказаться плохим для некоторого класса приложений.
Эти соображения определили выбор нотации для описания алгоритмов и соот%
ветствующих данных в настоящей книге. Разумеется, нам хотелось бы использо%
вать знакомые понятия математики, такие как числа, множества, последователь%
ности и т. д., а не машинно зависимые сущности вроде строк битов. Но нам также хотелось бы использовать нотацию, для которой существуют эффективные компиляторы. Неразумно использовать язык, в сильной степени машинно зави%
симый, но также недостаточно и описывать программы в абстрактной нотации,
в которой проблемы представления остаются нерешенными. Язык программи%
рования Паскаль был спроектирован в попытке найти компромисс между этими двумя крайностями, а его наследники Модула%2 и Оберон учитывают опыт,
накопленный за десятилетия [1.3]. Оберон сохраняет базовые понятия Паскаля с некоторыми усовершенствованиями и добавлениями; он используется на протя%
жении этой книги [1.5]. Оберон был успешно реализован для ряда компьютеров,
при этом было продемонстрировано, что его нотация достаточно близка к реально%
му «железу», чтобы выбранные средства и их представления можно было объяс%
нить с полной ясностью. Язык также близок к другим языкам, так что уроки, усво%
енные здесь, могут быть с равным успехом применены и при их использовании.
1.2. Понятие типа данных
В математике переменные обычно классифицируются по некоторым важным ха%
рактеристикам. Проводится четкое различие между вещественными, комплекс%
ными и логическими переменными, или между переменными, представляющими отдельные значения, множества значений, множества множеств, или между фун%
кциями, функционалами, множествами функций и т. д. Такая классификация не менее, если не более, важна в обработке данных. Мы будем придерживаться того принципа, что каждая константа, выражение или функция имеет определенный
тип. В сущности, тип характеризует множество значений, к которому принад%
лежит константа, или которые может принимать переменная или выражение, или которые могут порождаться функцией.
В математических текстах тип переменной обычно можно определить просто по шрифту, без учета контекста; но это невозможно в компьютерных программах.
На вычислительной установке обычно доступен только один шрифт (латинские буквы). Поэтому часто следуют правилу явно вводить соответствующий тип в объявлении константы, переменной или функции, причем такое объявление должно предшествовать использованию этой константы, переменной или функ%
ции. Это правило тем более разумно, что компилятор должен выбрать пред%


21
ставление объекта в памяти компьютера. Очевидно, что объем памяти, отведен%
ной под переменную, должен быть выбран в соответствии с диапазоном значений,
которые может принимать переменная. Если эта информация доступна компиля%
тору, то можно избежать так называемого динамического размещения. Очень час%
то этот пункт оказывается ключевым для эффективной реализации алгоритма.
Сущность понятия типа, как оно используется в данном тексте и реализуется в языке программирования Оберон, выражается в следующих утверждениях [1.2]:
1. Тип данных определяет множество значений, которому принадлежит зна%
чение константы, или в котором принимает значения переменная или выра%
жение, или которому принадлежат значения, порождаемые операцией или функцией.
2. Тип значения, обозначенного константой, переменной или выражением,
может быть выведен из их объявлений и вида выражения без выполнения вычислений.
3. Каждая операция или функция требует аргументов определенных типов и дает результат некоторого, тоже определенного типа. Если операция допус%
кает аргументы нескольких типов (например, + используется для сложения как целых, так и вещественных чисел), то тип результата может быть опре%
делен на основе особых правил языка программирования.
Компилятор может использовать такую информацию о типах для проверки законности различных конструкций. Например, ошибочное присваивание булев%
ского (логического) значения арифметической переменной может быть обнару%
жено без выполнения программы. Подобная избыточность текста программы весьма полезна при ее разработке и может рассматриваться как главное преиму%
щество хороших языков высокого уровня по сравнению с машинным кодом (или кодом символического ассемблера).
Очевидно, в конечном итоге данные будут представлены огромным количест%
вом двоичных цифр независимо от того, была ли написана исходная программа на языке высокого уровня, использующего понятие типа, или на ассемблере, где ти%
пов нет. Для компьютера память представляется однородной массой битов без явной структуры. Но именно абстрактная структура позволяет человеку%програм%
мисту видеть смысл в монотонном пейзаже компьютерной памяти.
Теория, о которой идет речь в данной книге, и язык программирования Оберон дают некоторые способы определения типов данных. В большинстве случаев но%
вый тип данных строится из других типов, уже определенных (назовем их состав
ляющими). Значения такого типа – это обычно агрегаты значений%компонент,
принадлежащих ранее определенным составляющим типам, и такие значения на%
зываются составными,или структурированными. Если используется только один составляющий тип, то естьвсе компоненты принадлежат одному типу, то этот тип называют базовым. Число различных значений типа

называют его мощ
ностью. Мощность позволяет определить объем памяти для представления пере%
менной x
, имеющей тип
T
, что обозначается как x: T
Поскольку составляющие типы, в свою очередь, могут быть составными, то могут выстраиваться целые иерархии структур. Впрочем, очевидно, что наимень%
Понятие типа данных