Текст
                    Структ ры
данных
для персональных
Й.ЛЭНГКЖМ
МОтистайн
АТвненбаум
здательство^МирГ


Структуры данных для персональных ЭВМ
DATA STRUCTURES FOR PERSONAL COMPUTERS Y. Langsam M. Augenstein A. Tenenbaum Department of Computer and Information Science Brooklyn College of The City University of New Yorfc Prentice-Hall, Inc. Englewood Cliffs
Й.Лэнгсам, М. Огенсгайн, АТененбаум Структуры данных для персональных ЭВМ Перевод с английского канд. техн. наук Л. П. Викторова, канд. физ.-мат. наук С. А. Усова и Д. Б. Шехватова Москва «Мир» 1989
ББК 32.973 Л92 УДК 681.142.2 Лэнгсам Й., Огенстайн М., Тененбаум А. Л92 Структуры данных для персональных ЭВМ: Пер. с англ.—М.: Мир, 1989.—568 с, ил. ISBN 5-03-000538-2 В книге американских специалистов подробно излагаются зопросы организации структур данных на основе использования рекурсии, методы сортировки » поиска информации, принципы работы со стеками и очередями, а также с деревьями и графами. Приводятся примеры реализации рекомендуемых методов программирования на основе языка Бейсик применительно к персональным компьютерам. Для научных сотрудников, инженеров и студентов вузов, осваивающих персональные ЭВМ. „2404040000-050 Л — 140-89, ч. 1 ББК 32.973 041(01)-89 Редакция литературы по информатикеЪ робототехнике ISBN 5-03-000538-2 (русск.) © 1985 by Prentice-Hall, Inc. ISBN 0-13-196221-3 (англ.) © перевод на русский язчк, «Мир», 1989
Предисловие к русскому изданию С появлением персональных компьютеров значительно расширился круг людей, имеющих доступ к средствам вычислительной техники. Пользователи персональных компьютеров обычно используют готовые программы или даже целые пакеты программ, специально написанные так, чтобы с ними можно было работать, не будучи программистом. Однако иногда возникают задачи, для решения которых нет в наличии готовых программ. На каждой персональной ЭВМ имеется язык Бейсик— очень простой и удобный язык программирования, особенно для решения небольших задач. У пользователя, не знакомого с программированием, часто создается иллюзия (до первой написанной им программы), что для решения своей задачи ему нужно просто написать программу на языке Бейсик. Но при этом сразу же возникают проблемы представления в программе структур данных, адекватных решаемой задаче, разработки и отладки программы. Эти вопросы методически весьма удачно изложены в данной книге. Читатель как бы вцдит перед собой персональный компьютер, «говорящий» на языке Бейсик, и перед ним постепенно раскрываются основные проблемы программирования. Это и структуры данных, модульное и структурное программирование, работа с очередями, списками, стеками и т. д. Кроме того, даются примеры решения известных задач программирования. Приводимые алгоритмы и программы на языке Бейсик можно использовать при работе на любом персональном компьютере. Книга, несомненно, представляет большой интерес для специалистов, применяющих персональные компьютеры в различ-· ных областях современной деятельности человека. Она может быть использована и в качестве учебного пособия для студентов вузов соответствующих специальностей. Перевод выполнен Д. Б. Шехватовым (предисловие, гл. 1— 4), Л. П. Викторовым (гл. 6, 7) и С. А. Усовым (гл. 5, 8, 9). С. А. Усов
Посвящается нашим женам Вивиен Эстер (И. Л.) и Гейл (М. О.) и моему сыну Безалелю (А. Т.) Предисловие Эта книга ориентирована на две группы читателей. Одна группа состоит из программистов, которые уже имеют достаточный опыт в программировании предпочтительно на языке Бейсик. Этот уровень может быть достигнут путем изучения какого-либо вводного курса по программированию на языке Бейсик в сочетании с практическими упражнениями на персональном компьютере. Достигнутый таким образом опыт программирования зачастую представляет собой бессистемный набор знаний, и при решении более сложных задач программист сталкивается с необходимостью изучения приемов программирования на более высоком уровне. Изучение структур данных и более сложных приемов программирования является следующим шагом в освоении искусства программирования. Ко второй группе относятся те, кто изучает программирование академически. С внедрением персональных ЭВМ обучение программированию становится все более популярным даже в школах, в которых ранее читались только один-два вводных курса по программированию. Это в основном школы с двухгодичным обучением; к данной группе относится также ряд колледжей с четырехгодичным обучением, финансовые расходы на программирование в которых сравнительно невелики. В таких организациях язык Бейсик используется наиболее часто. Цель данной книги — ознакомить читателя с элементарными концепциями структур данных и помочь освоить более сложные приемы программирования. На протяжении ряда лет мы читали курс лекций по структурам данных студентам, прослушавшим семестровый курс по программированию на языках высокого уровня и семестровый курс по программированию на языке ассемблера. Мы обнаружили, что значительное время приходится отводить обучению именно приемам программирования, поскольку студенты не имеют достаточного навыка в программировании и не могут реализовать свои абстрактные структуры. Более способные студенты обычно легко усваивают материал. Для менее одаренных эта проблема так и остается открытой. Исходя из этого, мы пришли к твердому убеждению, что первый курс по структурам данных должен даваться параллельно со вторым курсом по программированию. Данная работа и является результатом этого убеждения. В этой книге вводятся абстрактные концепции и показывается, каким образом они могут быть использованы при решении задач и реализованы применительно к используемому языку программирования. Одинаковое внимание уделяется как абстрактному, так и конкретному аспекту концепций, чтобы студент мог изучить концепцию, познакомиться с ее приложением и реализацией. В книге используется язык программирования Бейсик. Несмотря на то что для представления абстрактных структур данных имеется несколько языков программирования лучших, нежели Бейсик, мы по ряду причин все- таки выбрали именно этот язык. На сегодняшний день Бейсик является наиболее широко распространенным языком программирования высокого уровня благодаря его доступности на персональных компьютерах. В самых 6
широких кругах наблюдается растущий интерес к вычислительной технике, и многие интересуются структурами данных, однако, не обладая достаточными знаниями и навыками в программировании на другом языке высокого уровня, они располагают небольшим числом источников информации. Более того, хотя язык Бейсик весьма далек от полного признания среди специа-г листов (и это, по всей видимости, никогда не произойдет), он все шире используется при составлении научных программ (как уже отмечалось, особенно это касается небольших организаций). Хотя язык Бейсик и критиковали за сложность написания на нем корректных программ, его тем не менее можно применять достаточно эффективно. Для студентов, приступающих к изучению этой книги, необходимый предварительный объем знаний может ограничиваться односеместровым курсом по программированию на языке Бейсик. Для читателей, не знакомых с языком Бейсик, приводится список работ, позволяющий выбрать для себя один из вводных курсов по этому языку. В гл. 1 дается введение в структуры данных. В разд. 1.1 приводятся концепция абстрактной структуры данных и концепция ее реализации. В разд. 1.2 рассматриваются массивы, их применение и реализация; в разд. 1.3 — наборы данных и их представление в языке Бейсик. В гл. 2 обсуждаются принципы структурного программирования и соответствующие им алгоритмические структуры. Эти принципы определяют стиль программирования, используемый на протяжении всей книги. В гл. 3 рассматриваются стеки и их реализация в языке Бейсик. Поскольку это первая вводимая структура данных, то значительное место отведено разбору возможных конфликтов и неоднозначностей. В разд. 3.4 рассматриваются постфиксные, префиксные и инфиксные записи. В гл. 4 описываются очереди и связные списки, а также их реализация с использованием массива доступных элементов. В гл. 5 рассматриваются рекурсия и ее применение. Поскольку большинство версий языка Бейсик не поддерживает рекурсию, то описываются также методы моделирования рекурсии. В гл. 6 рассматриваются вопросы работы с деревьями, а в гл. 7 — с графами. Глава 8 посвящена сортировке, а гл. 9 — поиску. В конце книги приводится список литературы, включающий работы по структурам данных и программированию на языке Бейсик, рекомендуемый читателю для дальнейшего изучения. При односеместровом курсе гл. 7 и некоторые разделы гл. 1, 2, 6, 8 и 9 могут быть опущены. Данная книга подходит для курса II Curriculum 68 (Communications of the ACM, March 1968), курсов UC1 и UC8 по информационным системам (Communications of the ACM, March 1979) и частей курсов CS7 и CS13 для Curriculum 78 (Communications of the ACM, March 1979). Она также частично или полностью включает темы Р1, Р2, РЗ, Р4, Р5, S2, D1 и D6 из Curriculum 78. Алгоритмы (приводимые в гл. 2) представляют собой некоторый промежуточный вариант между описанием на английском языке и программами на языке Бейсик. Они состоят из конструкций на языке высокого уровня и перемежаются английским текстом. Эти алгоритмы позволяют читателю целиком сфокусировать внимание на методе решения задач, не беспокоясь об описании переменных и не учитывая особенностей реального языка. При переводе алгоритма в программу эти требования уточняются с целью устранения возможных возникающих неоднозначностей. Для лучшего усвоения материала нами введена специальная идентификация для алгоритмов и программ на языке. Эта идентификация рассматривается в гл. 2. Для того чтобы различить алгоритмы и программы, первые даются строчными буквами, а последние — прописными. Большинство рассматриваемых концепций иллюстрируется несколькими примерами. Некоторые из этих примеров сами по себе являются отдельными важными темами (например, постфиксная нотация, арифметика над строками и т. д.) и могут быть рассмотрены как таковые. Другие примеры иллюст- 7
рируют различные методы реализаций (например, последовательное хранение деревьев). При использовании данной работы для односеместрового курса преподаватель может выбрать любое число примеров по своему усмотрению. Примеры могут быть предложены также студентам для самостоятельного изучения. Предполагается, что преподаватель сможет достаточно подробно разобрать все примеры в течение односеместрового или двухсеместрового курса. Мы убеждены, что в процессе освоения студентом данного курса значительно важнее разобрать подробно небольшое число примеров, чем бегло просмотреть несколько тем. Упражнения сильно варьируются по типу и сложности: одни служат для закрепления пройденного материала, в других модифицируются использованные в тексте программы и алгоритмы, третьи знакомят читателя с новыми концепциями и могут быть довольно сложными. Зачастую последовательность взаимосвязанных примеров порождает отдельную новую тему, которая может быть положена в основу курсовой работы или дополнительной лекции. Преподаватель должен распределять задания в соответствии с уровнем знаний студентов. Мы считаем, что за семестр студент обязательно должен выполнить несколько (от пяти до 12 в зависимости от сложности) заданий по программированию. Упражнения включают в себя несколько примеров данного типа. Преподаватель может найти много дополнительных упражнений и проектов в сборнике упражнений в одной из наших более ранних работ, а также в книге Data Structures and PL/I Programming (Prentice-Hall, 1979). Хотя большинство приведенных в этой работе упражнений использует язык программирования ПЛ/I, они легко могут быть перенесены на Бейсик. Одна из трудностей, с которой пришлось столкнуться при написании данной книги, заключалась в выборе подходящего диалекта языка Бейсик. Для выполнения приводимых в книге программ на большинстве моделей персональных ЭВМ желательно выбрать «наименьший общий знаменатель» для всех наиболее распространенных диалектов языка Бейсик. С другой стороны, использование в наших программах небольшого подмножества языка Бейсик не позволяет воспользоваться преимуществами «стандартных» свойств Бейсика, поддерживаемых большинством современных персональных ЭВМ. В данной книге мы решили остановиться на языке Бейсик уровня II персональной ЭВМ фирмы Radio Shack, языке Бейсик-80 фирмы Microsoft и на Бейсике для IBM PC. Из этих трех версий языка язык Бейсик уровня II представляет собой подмножество двух остальных, однако сохраняя в себе те возможности, которые мы находим существенными. Одно из ограничений языка Бейсик уровня II заключается в том, что переменные в нем различаются только по первым двум буквам их имени, запрещается также использовать зарезервированные ключевые слова. Эти же ограничения относятся и к Бейсику фирмы Applesoft. Нам стоило больших усилий придерживаться этих ограничений, используя при этом осмысленные имена. Разумеется, в тех версиях Бейсика, в которых эти ограничения отсутствуют, программист может использовать более подходящие имена. Мы сознательно не пользовались расширенными возможностями языка Бейсик для IBM PC и Бейсик-80 фирмы Microsoft (например, конструкцией WHILE-END, встроенной функцией MOD и т. д.), поскольку они не поддерживаются большинством языков Бейсик, доступных в настоящий момент для персональных компьютеров. Однако мы знакомим читателя с этими конструкциями в гл. 2 и действительно используем их при составлении алгоритмов. Одно из свойств, которое мы могли опустить, — это оператор ELSE в конструкции IF-THEN. Без использования конструкции IF-THEN-ELSE программы становятся более громоздкими, и их педагогическая ценность сильно уменьшается. К сожалению, язык Бейсик фирмы Applesoft не поддерживает оператор ELSE. Программист, использующий язык Бейсик фирмы Applesoft, может эмулировать операторы ELSE способами, описанными в гл. 2. Для описания типов переменных мы пользуемся оператором DEF и не используем специальных символов типа. Это неверно для языка Бейсик фирмы Applesoft, однако легко может быть исправлено вставкой соответст- 8
вующих символов, определяющих требуемый тип. Все остальные используемые в данной книге свойства приложимы также и к Бейсику фирмы Applesoft. Каждая приводимая в книге программа (или подпрограмма) была проверена на третьей модели персональной ЭВМ фирмы Radio Shack с использованием Бейсика уровня II, персональной ЭВМ фирмы Apple II Plus с платой, содержащей Бейсик-80, и на IBM PC с Бейсиком на кассетах. Мы хотели бы поблагодарить Имрана Хана, Линду Лауб, Диану Ломбарди, Джоула Плаута и Криса Унгехейера за большую помощь в нашей работе и ценные предложения. Разумеется, ответственность за любые оставшиеся ошибки целиком лежит на авторах данной книги. Мы искренне признательны Линде Лауб, Карлу Марковичу и Крису Унгехейеру, затратившим много времени на перепечатку рукописи, за терпение, с которым они относились к постоянно вносимым нами в книгу добавлениям и исправлениям, и ответственность, проявленную ими на всех стадиях создания этой книги. Нам хотелось бы поблагодарить также Марию Аргиро, Миррел Эссен- берг, Биверли Хеллер, Гана Кима, Амалию Клецки, Шолома Кришера, Линду Лауб, Диану Ломбарди, Хаима Марковича, Джоула Плаута, Барбару Резник, Криса Унгехейера и Шерли Йе за их неоценимую помощь. Мы выражаем признательность сотрудникам вычислительного центра Университета г. Нью-Йорка, предоставившим в наше распоряжение все имеющиеся возможности вычислительного центра, а также Юлио Бергеру, Лоуренсу Швейцеру и другим сотрудникам вычислительного центра Бруклинского колледжа. Нам хотелось бы поблагодарить редакторов, сотрудников и обозревателей издательства «Прентис-Холл» за высказанные ими полезные замечания и предложения. И наконец, мы благодарим наших жен Вивиен Лэнгсам, Гейл Огенстайн и Мириам Тененбаум за советы и поддержку, оказываемую ими в течение долгой и кропотливой работы по созданию данной книги. И. Лэнгсам М. Огенстайн А. Тененбаум
Глава 1 Введение в структуры данных Компьютер — это машина, которая обрабатывает информацию. Изучение науки об ЭВМ предполагает изучение того, каким образом эта информация организована внутри ЭВМ, как она обрабатывается и как может быть использована. Следовательно, для изучения предмета студенту особенно важно понять концепции организации информации и работы с ней. 1.1. ИНФОРМАЦИЯ И ЕЕ СМЫСЛ Если вычислительная техника базируется на изучении информации, то первый возникающий вопрос заключается в том, что такое информация. К сожалению, несмотря на то что концепция информации является краеугольным камнем всей науки о вычислительной технике, на этот вопрос не может быть дано однозначного ответа. В этом контексте понятие «информация» в вычислительной технике сходно с понятием «точка», «прямая» и «плоскость» в геометрии — всё это неопределенные термины, о которых могут быть сделаны некоторые утверждения и выводы, но которые не могут быть объяснены в терминах более элементарных понятий. В геометрии можно говорить о длине прямой, несмотря на тот факт, что сама концепция прямой неопределенна. Длина прямой — это некоторая мера количества. Аналогичным образом в вычислительной технике мы можем измерять количество информации. Базовой единицей информации является бит, который может принимать два взаимоисключающих значения. Например, если выключатель лампочки может быть установлен в одно из двух положений, но не в оба одновременно, то тот факт, что выключатель находится либо в положении «включено», либо в положении «выключено», соответствует однобитовой информации. Если устройство может находиться более чем в двух состояниях, то тот факт, что оно находится в одном из этих состояний, уже требует нескольких битов информации. Например, если переключатель рассчитан на восемь положений, то факт установки переключателя в четвертой позиции оставляет еще семь различных возможных положений, ю
в то время как установка выключателя лампочки в положение «включено» оставляет только одно положение. Можно взглянуть на это несколько иначе. Предположим, что у нас имеются только выключатели на два положения, однако их число не ограничено. Сколько потребуется выключателей, чтобы реализовать переключатель на восемь положений? Очевидно, что один переключатель может реализовать только два положения (рис. 1.1.1, а). Два переключателя позволяют реализовать четыре различных состояния (рис. 1.1.1, б), а для реализации восьми различных позиций понадобятся три переключателя (рис. 1.1.1,в). В общем случае η переключателей могут реализовать 2П различных возможностей. Для представления двух возможных состояний некоторого бита используются двоичные цифры — нуль и единица [слово «бит» (английское bit) есть сокращение от английских слов «двоичная цифра» (binary digit)]. Для представления наших установок при помощи η битов используется строка из η нулей и единиц. Например, строка 101011 представляет шесть выключателей, первый из которых Выключатель 1 ВЫКЛ ВКЛ Выключатель 1 Выключатель Ζ | выкл выкл выкл ВКЛ 1 | ВКЛ ВКЛ выкл ВКЛ I Выключатель 1 Выключатель Ζ Выключатель 3 ВЫКЛ выкл выкл выкл выкл \ вкл' | выкл выкл ВКЛ ВКЛ выкл | ВКЛ | 1 вкл ВКЛ ВЫКЛ ВЫКЛ выкл 1 вкл 1 1 ВКЛ ВКЛ ВКЛ ВКЛ выкл ВКЛ Рис. 1.1.1. Один выключатель (два состоя* лия) (а); два выключателя (четыре состояния) (б); три, выключателя (восемь состояний) (в). 11
(начиная слева) находится в состоянии «включено» (1), второй— в состоянии «выключено» (0), третий — «включено», четвертый — «выключено» и пятый и шестой — «включено». Как мы уже видели, для представления восьми состояний достаточно трех битов. Восемь возможных сочетаний этих трех битов (000, 001, 010, 011, 100, 101, 110, 111) могут быть использованы для представления целых чисел от 0 до 7. Закон соответствия может быть произвольным. Необходимо только, чтобы любым двум различным числам не назначалась одна и та же комбинация битов. После того как такое присвоение сделано, каждый бит может однозначно рассматриваться как соответствующее ему целое число. Рассмотрим несколько распространенных способов интерпретации битовых комбинаций как целых чисел. Используемые для микроЭВМ интерпретаторы языка Бейсик представляют числа несколько более сложным образом, однако подробности этих представлений не существенны. Важно отметить то, что непротиворечивый способ представления целых чисел в виде битовых строк позволяет освободить пользователя от знания подробностей его реализации на конкретной вычислительной машине. Двоичные и целые десятичные числа Наиболее широко распространенным методом представления неотрицательных чисел в виде группы битов является двоичная система счисления. В этой системе каждая позиция бита представлена степенью двойки. Крайняя правая позиция бита представлена числом 2°, которое равно единице; следующая позиция слева представлена числом 21, которое равно 2; следующая позиция — 22, которое равно 4, и т. д. Целое число представляется суммой степеней двойки. Строка из всех нулей представляет число 0. Если в какой-либо позиции бита появляется единица, то в сумму включается степень двойки, представленная данной позицией. Если в позиции находится 0, то степень двойки в сумму не включается. Например, группа битов 00100110 содержит единицы в позициях 1,2 и 5 (считая справа налево, при этом крайняя правая позиция считается нулевой позицией). Таким образом, группа 00100110 представляет целое число 21 + 22 + 25 = 2 + 4 + 32 = 38. При такой интерпретации любая строка битов длиной η представляет собой уникальное целое неотрицательное число в интервале от 0 до 2П— 1, а любое целое неотрицательное число в интервале от 0 до 2П — 1 может быть однозначно представлено строкой битов длиной п. Для представления отрицательных двоичных чисел имеются два широко распространенных метода. В первом из них, называемом обратным кодом, отрицательное число реализуется инвертированием абсолютного значения каждого бита. Например, так как число 00100110 представляет число 38, то последова- 12
тельность 1.1011001 попользуется для представления числа —38. Это означает, что первый 4шт числа больше не используется для представления степени двойки, а резервируется под знак числа. Строка битов, начинающаяся с нуля, представляет положительное число, а единица в первой позиции битовой строки обозначает отрицательное число. Строкой из η битов можно представить числа в интервале от —2п_1+1 (единица, за которой следует η — 1 нулей) до 2П-1 — 1 (нуль, за которым следует η—1 единиц). Отметим, что при таком подходе нуль может быть представлен двумя способами: «положительный нуль», состоящий из всех нулей, и «отрицательный нуль», состоящий из всех единиц. Второй способ представления отрицательных двоичных чисел называется дополнительным кодом. При таком способе к отрицательному числу, полученному первым способом, прибавляется единица. Например, так как последовательность 11011001 есть —38 при использовании первого способа, то в дополнительном коде последовательность битов для числа —38 будет ПОПОЮ. Строкой из η битов можно представить числа в интервале от —2п~1 (единица, за которой следует η—1 нулей) до 2П-1—1 (нуль, за которым следует η—1 единиц). Отметим, что число —2П-1 может быть только в дополнительном коде, но не в обратном. Однако абсолютное значение числа 2я""1 не может быть представлено строкой из η битов ни одним из двух приведенных выше способов. Отметим также, что при способе, использующем дополнительный код, для числа нуль имеется только одно его представление — строкой из η битов. Чтобы продемонстрировать это, рассмотрим 0, представленный восемью битами: 00000000. Обратный код этого числа имеет вид 11111111, что при таком способе есть «отрицательный нуль». Добавив единицу для получения двоичного дополнения, получим последовательность 100000000 длиной 9 бит. Поскольку допускается только 8 бит, то крайний левый бит (или «переполнение») отсекается, давая 00000000 как минус 0. Двоичная система счисления не является единственным методом использования битов для представления целых чисел. Например, строка битов может быть использована для представления чисел в десятичной системе счисления следующим образом. Четыре бита могут быть использованы для записи десятичных цифр от 0 до 9 согласно вышеописанной нотации. Строка битов произвольной длины может быть разбита на группы по четыре бита, каждая из которых представляет отдельную цифру. Например, в такой системе строка битов 00100110 разбивается на две строки по четыре бита в каждой: О010 и ОНО. Первая строка представляет десятичную цифру 2, а вторая — десятичную цифру 6; следовательно, вся строка содержит десятичное целое число 26. Такое представление называется двоично-десятичным. 13
Одной из важных особенностей двоично-десятичного представления неотрицательных чисел является то, что не все битовые комбинации являются значимыми представлениями десятичных цифр. Четыре бита могут быть использованы для представления одного из 16 возможных значений, поскольку для набора из 4 бит имеется 16 различных комбинаций. Однако при двоично-десятичном представлении используются только 10 из этих 16 сочетаний. Это означает, что такие коды, как 1010 и 1100, десятичные значения которых есть 10 или больше, являются неверными представлениями двоично-десятичного числа. Действительные числа Обычно в ЭВМ действительные числа представлены в виде чисел с плавающей запятой. Существует много различных вариантов такого представления, каждый из которых имеет свои характерные особенности. Базовая концепция такого метода заключается в том, что действительное число представляется в виде числа, называемого мантиссой, умноженного на основание, которое возводится в целую степень, называемую порядком. Основание обычно фиксировано, а мантисса и порядок изменяются в соответствии с представляемым действительным числом. Например, если основание равно фиксированному числу 10, то число 387,53 может быть представлено как 38753, умноженное на 10 в степени —2. (Вспомните, что 10~2 равно 0,01). Мантисса равна 38753, а порядок соответствует —2. Другими возможными представлениями являются 0,38753· 103 и 387,53-10°. Мы выберем такое представление, при котором мантисса выражается целым числом без нулей в правой части. В описываемом нами представлении для плавающей запятой (которое не должно быть обязательно реализовано на какой-нибудь вычислительной машине) действительное число представляется 32-битовой строкой, содержащей 24-битовую мантиссу, за которой следует 8-битовый порядок. Основание фиксировано и равно 10. Мантисса и порядок представляют собой двоичные числа в дополнительном коде. Например, 24- битовое двоичное представление целого числа 38753 есть 000000001001011101100001 и 8-битовый дополнительный код—2 есть 11111110; следовательно, представление для 38753 есть 00000000100101110110000111111110. Другие примеры действительных чисел и их представлений з виде чисел с плавающей запятой: 0 00000000000000000000000000000000 100 00000000000000000000000100000010 £ 00000000000000000000010111111111 ,000005 00000000000000000000010111111010 12000 ' 00000000000000000000110000000011 —387,63 11111111011010001001111111111110 —1200U 11111111111111111111010000000011 14
Преимущество представления чисел с плавающей» запятой заключается в том, что оно может быть использовано для представления чисел с очень большими или очень малыми абсолютными значениями. Например, для приводимого выше представления наибольшее представляемое таким образом число есть (223—1)·10127, что является весьма большим значением. Наименьшее положительное число, которое можно представить таким образом, есть 10~128, что в свою очередь есть очень небольшая величина. Фактором, ограничивающим точность представления чисел в конкретной вычислительной машине, является число значащих двоичных цифр мантиссы. Не все числа в диапазоне между самым большим и самым малым числами могут быть выражены подобным образом. Наше представление допускает только 23 значащих бита. Так, число 10 миллионов плюс 1, для которого требуются 24 значащие двоичные цифры мантиссы, будет округлено до числа 10 миллионов (МО7), для которого требуется только одна значащая цифра. Символьные строки Как известно, информация не всегда выражается цифрами. В вычислительной машине должны также каким-то образом быть представлены такие элементы, как имена, адреса и наименования работ. Для возможности представления нечисловых объектов существует еще один метод интерпретации битовых строк. Подобная информация обычно представляется в виде символьной строки. Например, в некоторых вычислительных машинах 8 бит 00100110 используются для представления символа «&». Еще одна 8-битовая последовательность используется для представления символа «А», другая — для «В», третья — для «С» и так для каждого символа, имеющего свое представление в некоторой конкретной ЭВМ. Вычислительные машины, выпускаемые, например, в СССР, используют битовые комбинации, выражающие буквы русского алфавита, а израильские используют битовые комбинации для представления букв еврейского алфавита. (В действительности используемые символы инвариантны по отношению к ЭВМ; набор символов может быть изменен путем использования другого набора в генераторе символов или печатающем устройстве.) Если для представления символа используется 8 бит, то возможно указание 256 комбинаций, поскольку для 8 бит допускают 256 различных сочетаний. Если для представления символа «А» используется строка 01000001, а для символа «В» —строка 01000010, то символьная строка «АВ» будет представлена битовой последовательностью 0100000101000010. В общем случае символьная строка представляется сцеплением битовых строк, которые представляют отдельные символы в этой строке. 15
Как и в случае целых чисел, не существует никаких требований, которые делали бы одну битовую строку, представляющую некоторый символ, более предпочтительной. Присвоение битовых строк символам может быть абсолютно произвольным. Из соображений удобства может быть также введено некоторое правило присвоения битовых строк символам. Например, две битовые строки могут быть поставлены в соответствие двум буквам таким образом, что битовой строке с меньшим значением будет назначена та буква, которая встречается в алфавитной последовательности первой. Это правило, однако, введено исключительно в целях удобства. Никакого обязательного соответствия битовых комбинаций буквам не существует. В действительности ЭВМ отличаются друг от друга даже по числу битов, отводимых для кодирования символов. Некоторые машины используют 7 бит (и, следовательно, допускают кодирование только 128 символов), некоторые используют 8 (до 256 символов) и некоторые 10 бит (до 1024 символов). Число битов, необходимых для кодирования символа в конкретной вычислительной машине, называется размером байта, а группа битов в этом числе называется байтом. Размер байта в большинстве ЭВМ равен 8. Отметим, что использование 8 бит для представления символа допускает представление 256 символов. Машины с таким большим набором символов встречаются редко (хотя возможно включение в символьный набор букв верхнего и нижнего регистров, курсива, специальных символов, жирного шрифта и других символов, а некоторые персональные ЭВМ используют некоторые из 256 комбинаций для представления графических символов), так что большинство из 8-битовых комбинаций кодов для кодирования символов не используется. Некоторые коды используются не для представления печатных или отображаемых символов, а для специальных управляющих кодов, используемых при коммуникациях и управлении устройствами ввода-вывода. Большинство ЭВМ кодирует символы в коде ASCII. Код ASCII (American Standard Code for Information Interchange — американский стандартный код для обмена информацией) является стандартным, принятым изготовителями вычислительной техники для кодирования различных букв и символов, с тем чтобы ЭВМ, выпущенная одной фирмой, могла работать с печатающими устройствами (и другими ЭВМ), изготовленными другой фирмой. Итак, мы видим, что информация сама по себе не имеет никакого смысла. С некоторой конкретной битовой комбинацией может быть связано любое смысловое значение, если только при этом соблюдается условие непротиворечивости. Именно интерпретация битовой комбинации придает ей заданный смысл. Например, битовая строка 00100110 может быть интер- 16
претирована как число 38 (двоичное), число 26 (двоично-десятичное) или символ «&». Метод интерпретации битовой комбинации часто называется типом данных. Мы рассмотрели несколько типов данных: двоичные целые числа, двоично-десятичные неотрицательные числа, действительные числа и символьные строки. Основной вопрос теперь заключается в том, каким образом определить типы данных, разрешенные для интерпретации битовых комбинаций, и какие типы данных использовать для интерпретации, конкретной битовой комбинации. Программная и аппаратная части Память вычислительной машины представляет собой совокупность битов (переключателей). В любой момент функционирования в ЭВМ каждый из битов памяти имеет значение 0 или 1 (сброшен или установлен). Состояние бита называется его» значением или содержимым. Биты в памяти ЭВМ группируются в элементы большего размера, например в байты. В некоторых ЭВМ несколько байтов объединяются в группы, называемые словами. Каждому такому элементу (слову или байту в зависимости от типа ЭВМ) назначается адрес, который представляет собой имяг идентифицирующее конкретный элемент памяти среди аналогичных элементов. Этот адрес обычно числовой, поэтому мы можем говорить о байте 746 или о слове 937. Адрес часто называется ячейкой, а содержимое ячейки есть значения битов,, которые ее составляют. Каждая ЭВМ имеет свой «родной» набор типов данных. Это означает, что она создана с механизмом манипуляции битовыми комбинациями в соответствии с объектами, которые ими представлены. Например, предположим, что в ЭВМ имеется команда сложения двух двоичных чисел, помещающая результат в заданную ячейку памяти для последующей работы с ней. В ЭВМ имеется встроенный механизм для: 1. Извлечения битовых комбинаций операнда из двух заданных ячеек. 2. Получения третьей битовой комбинации, представляющей собой целое двоичное число, которое является суммой двух целых двоичных чисел, представленных двумя операндами. 3. Сохранение результата в заданной ячейке. ЭВМ «знает», каким образом интерпретировать битовые комбинации в заданных ячейках как целые двоичные числа, поскольку аппаратная часть, которая выполняет заданную инструкцию, создана с учетом этих требований. Это аналогично тому, что свет «знает», что он должен гореть, когда выключатель находится в положении «включено». 17
Если эта ЭВМ имеет также инструкцию для сложения двух действительных чисел, то в ней должен быть отдельный механизм интерпретации операндов как действительных чисел. Для этих двух операций требуются две отдельные инструкции, каждая из которых содержит встроенный механизм идентификации типов ее операндов и их адресов. Следовательно, перед выбором нужной инструкции программист обязан знать, какой тип данных содержится в каждой ячейке (например, сложение целых чисел или чисел с плавающей запятой). Программирование на языке высокого уровня в значительной степени облегчает эту задачу. Для ссылки к некоторой ячейке памяти вместо числового адреса используется идентификатор (или имя переменной), что значительно удобнее для программиста. В языке Бейсик идентификаторы записываются в виде последовательности букв и цифр, начиная с буквы. (Примечание. Хотя большинство версий языка Бейсик допускают указание имен переменных любой длины, в некоторых .версиях значимыми являются только два первых символа. Таким образом, переменные с именами SUB, SUM и SU будут рассматриваться как одна и та же переменная. Кроме этого, 'большинство версий языка Бейсик накладывает суровые ограничения на выбор имен переменных, заключающиеся в том, что леременная не может содержать зарезервированного «ключевого слова». Например, использовать имя переменной BEFORE не допускается, поскольку оно содержит зарезервированное сло- яо FOR. В других версиях языка Бейсик имена переменных ограничены только двумя символами. Мы обсудим это подробнее в разд. 2.1) Если программист, работающий с языком Бейсик, напишет -операторы 10 DEFINTX,Y 20 DEFDBL Α,Β то любая переменная, начинающаяся с буквы X или Υ, будет интерпретироваться как целочисленная, а любая переменная, начинающаяся с буквы А или В, будет рассматриваться как действительное число с двойной точностью (т. е. число с плавающей запятой и мантиссой удвоенной длины). Таким образом, содержимое ячеек, отведенных под XVAR и YVAR, будет интерпретировано как целые числа, а содержимое AVAR и BVAR — как действительные числа. Интерпретатор, отвечающий за перевод операторов Бейсика в машинный язык, переведет « + » в операторе 100 X=X+Y т операцию целочисленного сложения, а « + » в предложении 200 А=А+В 18
в операцию сложения действительных чисел. Оператор «+»> является в некотором смысле родовым оператором, поскольку он имеет различные значения в зависимости от контекста. Интерпретатор освобождает программиста от необходимости указания типа выполняемой операции, анализируя контекст и выбирая необходимый вариант. [Примечание. В некоторых диалектах Бейсика (например, фирмы Applesoft) «тип» переменной модсет быть указан только посредством подсоединения к имени переменной символа «объявления типа». Так, Х$ представляет символьную строку, а Х% рассматривается как целочисленная переменная. Много других версий языка Бейсик: (например, TRS 80 Level II) позволяют в операторе DEF указывать спецификацию типа, сохраняя при этом возможность использования символов объявления типа. Это описано более- подробно в разд. 2.1. Читателю рекомендуется уточнить метод, спецификации типов данных в используемой им версии языка Бейсик.] Важно осознавать роль, выполняемую спецификацией типа в языках высокого уровня. Именно посредством подобных объявлений программист указывает на то, каким образом содержимое памяти ЭВМ интерпретируется программой. Эти объявления детерминируют объем памяти, необходимый для размещения отдельных элементов, способ интерпретации этих элементов и другие важные детали. Объявления также сообщаюг интерпретатору точное значение используемых символов операций. Концепция реализации До сих пор мы рассматривали типы данных как метод интерпретации содержимого памяти ЭВМ. Набор типов данных*, поддерживаемый данной ЭВМ, определяется функциями, заложенными в его аппаратную часть. Однако мы можем рассмотреть концепцию «типа данных» с совершенно иной точки зрения — не в терминах того, что может делать некоторая ЭВМ„ а в терминах того, что необходимо самому пользователю. Например, если кто-то хочет получить сумму двух целых чисел,, то он (или она) не должен беспокоиться о подробностях механизма выполнения этой операции. Этот человек предпочитает работать с математической концепцией «целого числа», а не· с аппаратной реализацией битов. Аппаратная часть ЭВМ может использоваться для представления целого числа и существенна постольку, поскольку адекватно реализует это представление. С того момента, как концепция типа данных отделена or аппаратных возможностей ЭВМ, появляется возможность рассмотрения неограниченного числа типов данных. Тип данных представляет собой абстрактную концепцию, определяемую набором логических возможностей. Как только абстрактный титт 19
щанных и допустимые, связанные с ним операции определены, можно реализовать этот тип данных (или его ближайшую аппроксимацию). Реализация может быть аппаратной, при которой для выполнения требуемых операций разрабатываются специальные электронные схемы, являющиеся частью самой ЭВМ. Или же это может быть программная реализация, при которой программа, состоящая из существующих аппаратных инструкций, интерпретирует битовые строки требуемым способом. Программная реализация включает в себя спецификацию того, каким образом объект с данными нового типа представлен объектами уже существующих типов данных, а также спецификацию того, каким образом при помощи определенных для такого объекта операций осуществляется работа с ним. Далее <в этой книге под термином «реализация» следует понимать «программная реализация». Пример Проиллюстрируем эти концепции на примере. Предположим, что аппаратная часть ЭВМ содержит инструкцию MOVE (SOURCE,DEST,length) которая копирует символьную строку фиксированной длины ж length байтов из адреса, указанного в SOURCE, по адресу, указанному в DEST. Мы будем указывать аппаратные инструкции и ячейки памяти прописными латинскими буквами. Длина .должна быть задана целочисленной константой, и по этой причине мы указываем ее строчными буквами. SOURCE и DEST могут быть заданы идентификаторами, определяющими ячейки памяти. Примером такой инструкции является инструкция MOVE (А, В, 3), которая копирует три байта, начинающиеся •с ячейки А, в три байта, начинающиеся с ячейки В. Отметим различие в функциях, выполняемых в этой операции идентификаторами А и В. Первый операнд в инструкции MOVE есть содержимое ячейки, заданное идентификатором А. Однако второй операнд не является содержимым ячейки В, поскольку это содержимое не имеет отношения к выполнению инструкции. В данном случае сама ячейка является операндом, поскольку она задает адрес пересылки символьной строки. Хотя идентификатор всегда определяет адрес ячейки, его принято использовать как ссылку на содержимое данной ячейки. Ή3 контекста всегда ясно, ссылается ли идентификатор на адрес данной ячейки или же на ее содержимое. Идентификатор, выступающий в инструкции MOVE в качестве первого операнда, ссылается к содержимому памяти, а идентификатор, выступающий в качестве второго операнда, ссылается к адресу ячейки. 20
Мы также полагаем, что аппаратная часть ЭВМ включает в себя инструкции обычных арифметических операций и операций перехода, которые указываем в терминах языка Бейсик. Например, инструкция Z=X+Y интерпретирует байты содержимого ячеек X и Υ как целые двоичные числа, вычисляет их сумму и подставляет двоичное представление этой суммы в байт по адресу Ζ. (Мы не оперируем целыми числами, большими чем один байт, и игнорируем возможность переполнения.) Как и ранее, здесь X и Υ используются для ссылки к содержимому памяти, a Z используется для ссылки к адресу ячейки памяти. Соответствующая их интерпретация очевидна из контекста. Иногда желательно добавить к адресу некоторую величину, получая при этом другой адрес. Например, пусть А есть адрес ячейки памяти, а нам необходимо адресоваться к ячейке, отстоящей от нее на 4 байт. Мы не можем сослаться к ней как А+4, поскольку это обозначение зарезервировано под сумму ♦содержимого ячейки А и числа 4. Поэтому для ссылки к такому адресу введем новое обозначение — А (4). Введем также обозначение А(Х)—ссылку к адресу, получаемому сложениеАм щелого двоичного числа из байта по адресу X с адресом А. Определенная выше инструкция MOVE требует от программиста указания длины копируемой строки. Она работает с операндом, представляющим собой символьную строку фиксированной длины (т. е. длина строки должна быть известна). Строжа фиксированной длины и целое двоичное число размером 1 байт должны рассматриваться как «естественные» инструкции данной ЭВМ. Предположим, что необходимо реализовать на нашей машине работу с символьными строками переменной длины, т. е. мы хотим дать программистам возможность работы с инструкцией MOVEVAR (SOURCE,DEST) для пересылки символьной строки из ячейки SOURCE в ячейку DEST без указания длины этой строки. Для реализации этого нового типа данных мы должны сначала решить, каким образом это будет представлено в памяти, -а затем указать, как необходимо работать с таким представлением. Очевидно, что для выполнения такой инструкции необходимо знать, сколько байтов должно пересылаться. Поскольку инструкция MOVEVAR не задает этого числа, оно должно содержаться внутри самого представления символьной строки. 'Символьная строка переменной длины размером J может быть представлена как непрерывный набор 1+1 байтов (К256). Первый байт содержит двоичное представление длины 1, а ос- 21
1 14 9 5 Η fc L L 0 α £ V Ε R Υ Β 0 D ! Υ ό Η Ε L L Ο Ε V Ε R Υ Β ο D Υ J 6 Рис. 1.1.2. MOVEVAR, может есть вспомогательная тавшиеся байты содержат представления символов в строке. Представления трех таких строк иллюстрируются на рис. 1.1.2. [Отметим, что цифры 5 и 9 в этих представлениях не соответствуют битовым комбинациям символов «5» и «9», а имеют коды 00000101 и 00001001, которые соответствуют цифрам пять и девять. Аналогично число 14 на рис. 1.1.2, в имеет битовую комбинацию 00001110.] Программа, реализующая операцию быть записана следующим образом (I ячейка памяти): forI=ltoDEST MOVE(SOURCE(I),DEST(I),l) next I Точно так же мы можем ввести операцию CONCATVAR(Cl„ С2,СЗ) для сцепления двух символьных строк переменной длины с адресами С1 и С2 и размещения результата по адресу СЗ. На рис. 1.1.2, в иллюстрируется сцепление двух строк, приведенных на рис. 1.1.2, α и б: 'переслать длину Z=C1+C2 MOVE(Z,C3,l) 'переслать первую строку for 1=1 toCl MOVE(Cl(I),C3(I),l) next I for 1=1 to C2 X=C1+I MOVE(C2(I),C3(X),l) next I Однако, если операция MOVEVAR уже была определена, операция CONCATVAR может быть реализована с ее использованием следующим образом: MOVEVAR (C2,C3 (C1)): MOVEVAR (С 1.СЗ): Z = C1+C2: MOVE(Z,C3,l) 'переслать вторую строку 'переслать первую строку 'обновить длину результата 22
CI ι 1Г1 \ ι ί] i > Η Ε L L Ο €2 1 J < л > Ε V Ε R Υ Β Ο D Υ СЪ СЗ(С1) ' A Ι 9 Ε V Ε R Υ Β Ο D Υ С ' 3 ' 15 Η Ε L L Ο Ε V Ε R Υ Β Ο D Υ 14 СЪ HHELLOE VERYBODY Рис. 1.1.3. α — M0VEVAR(C2,C3(C1)); б —MOVEVAR(Ci,C3); β — Ζ= «C1+C2; MOVE(Z,C3,1). На рис. 1.1.3 иллюстрируются фазы работы этой операции со строками из рис. 1.1.2. Хотя последняя версия является более короткой, она в действительности не является более эффективной, поскольку все инструкции, используемые при реализации MOVEVAR, выполняются каждый раз при использовании MOVEVAR. 23
Особый интерес в обоих алгоритмах представляет предложение Z = C1 + C2. Эта инструкция сложения выполняется независимо от назначения операндов (в данном случае частями символьных строк переменной длины). Данная инструкция рассматривает операнды как однобайтовые целые числа вне зависимости от дальнейших возможных операций, выполняемых над ними программистом. Аналогично ссылка к СЗ(С1) делается; к ячейке, адрес которой получается сложением содержимого байта по адресу С1 с адресом СЗ. Предполагается, что байт С1 содержит двоичное целое число, хотя он также является началом символьной строки переменной длины. Это иллюстрирует тот факт, что тип данных есть метод рассмотрения содержимого памяти и что это содержимое не имеет независимого самостоятельного значения. Отметим, что такое представление символьных строк переменной длины допускает использование только таких строк,, длина которых меньше или равна наибольшему целому двоичному числу, записываемому в один байт. Если байт содержиг 8 бит, то максимальная длина такой строки составляет 255 символов (что равно 28—1). Для работы со строками большей длины необходимо использовать другое представление и другой набор программ. Если мы воспользуемся этим представлением для строк переменной длины, то результат для операции сцепления двух строк, суммарная длина которых превышает 255 символов, будет ошибочным. Поскольку результат такой операции не определен, то разработчик может задать набор операций, выполняемых при попытке ее выполнения. Одной из возможностей является использование только первых 255 символов результата. Другой вариант—полное игнорирование операции и запрет выполнения операции пересылки в поле результата. Можно также остановиться на печати предупредительного сообщения или предположить о том, что пользователь хочет довольствоваться тем результатом, который выбрал разработчик. После того как для объектов заданного типа было установлено некоторое представление, а для работы с ними были написаны соответствующие программы, программист может использовать этот тип данных для решения своих задач. Исходное аппаратное обеспечение ЭВМ плюс программы, реализующие более сложные типы данных, могут рассматриваться как машина «лучшего» типа, чем машина, имеющая только лишь аппаратно реализованный набор инструкций. Программист, работающий на «исходной» машине, не должен беспокоиться о том, каким образом она спроектирована и какие электронные схемы используются для выполнения каждой инструкции. Ему необходимо знать только доступный набор инструкций и правила их работы. Аналогичным образом программист, работающий на «расширенной» машине (содержа- 24
щей программную и аппаратную части), не должен заботиться о подробностях реализации различных типов данных. Все, что должен знать программист,— это то, как работать с этими данными. В последующих двух разделах данной главы мы рассмотрим композитную структуру данных, уже имеющуюся в Бейсике (массив), и ее использование для представления однородных наборов данных. Сфокусируем наше внимание на абстрактных определениях этих структур данных и на том, каким образом они могут оказаться полезными при решении задач. Рассмотрим также способ их реализации в языке Бейсик. В оставшейся части книги (кроме гл. 2, где рассматривается техника программирования на Бейсике) мы введем более сложные типы данных и покажем удобства их использования при решении различных задач. Продемонстрируем также то> каким образом реализовать эти типы данных, используя типы, уже имеющиеся в языке Бейсик. Поскольку проблемы, возникающие при попытке реализации структур данных высокого уровня, довольно сложны, это также позволит нам более подробно исследовать язык Бейсик и приобрести значительный опыт работы с ним. Довольно часто ни программная, ни аппаратная реализация не в состоянии полностью смоделировать математическую концепцию. Например, в ЭВМ невозможно представить произвольно большие целые числа, поскольку размер памяти машины ограничен. Следовательно, тип «целое», представляемое вычислительной машиной, есть скорее тип «целое между X и Y», где X и Υ суть наименьшее и наибольшее целые числа, которые могут быть представлены в данной машине. Важно осознавать ограничения, накладываемые конкретной реализацией. Очень часто можно реализовать несколько представлений одного и того же типа данных, каждый со своими достоинствами и недостатками. Одна выбранная реализация может оказаться лучше другой при решении некоторой конкретной задачи, и программист должен учитывать возможные возникающие компромиссы. Одним из существенных соображений любой конкретной реализации является ее эффективность. Причиной, по которой в Бейсик не встроены обсуждаемые нами типы данных высокого уровня, в действительности является резкое снижение эффективности работы. Для микроЭВМ разработано много языков значительно более высокого уровня, чем Бейсик, со встроенными в них различными типами данных. Эффективность обычно оценивается по двум факторам — пространству и времени. Если некоторая прикладная программа значительно использует в своей работе структуры данных высокого уровня, то скорость выполнения всей программы будет определяться скоростью работы с этими структурами. Ана- 25
логично, если программа использует большое число таких структур, то та реализация, которая использует для их представления значительный объем памяти, окажется неэффективной. К сожалению, оптимальное сочетание этих двух параметров отсутствует, поэтому более быстро работающая реализация использует больший объем памяти, чем та, которая работает медленнее. Выбор реализации в этом случае предполагает тщательную оценку оптимальных сочетаний среди различных возможностей. Упражнения 1. В рассмотренных разделах была проведена аналогия между длиной строки и числом бит информации в битовой строке. В каком смысле данная аналогия неадекватна? 2. Уточните аппаратный набор инструкций, доступный на вашей ЭВМ, и типы операций, выполняемые ими. 3. Докажите, что для η двухпозиционных переключателей имеется 2П различных сочетаний. Предположим, что нам требуется m сочетаний. Сколько переключателей необходимо? 4. Выразите приводимые ниже битовые последовательности как целые двоичные числа и как целые двоично-десятичные числа. Если последовательность не может быть выражена как двоично-десятичное число, то объясните почему: (а) 10011001 (б) 1001 (в) 000100010001 (г) 01110111 (д) 01010101 (е) 100000010101 5. Бейсик фирмы Microsoft является одной из наиболее распространенных версий языка Бейсик, в которой целые числа представлены в дополнительном коде. Каждое целое число (положительное или отрицательное) занимает 2 байт (16 бит), причем за младшим байтом следует старший байт (т. е. обратно общепринятому порядку). Так, число 38 будет иметь вид 0010011000000000, а число —38 будет вида 1101110011111111. Как в Бейсике фирмы Microsoft будут представлены следующие числа: (а) 32 (б) 258 (в) —47 (г) —32 (д) —32768 (е) 32767 6. Бейсик фирмы Microsoft представляет действительные числа с одинарной точностью, используя представление чисел с плавающей запятой. Действительное число представляется 32 бит, содержащими 24-битовук> мантиссу (3 байт), за которой следует 8-битовый (1 байт) порядок. Действительное (десятичное) число сначала преобразуется в свой двоичный эквивалент с двоичным основанием. Например, 49 (десятичн.)= 0,11000100 (двоичных 2б. Мантисса выбирается таким образом, чтобы первая цифра была равна единице. Затем порядок складывается с числом 128 и результат выражается в двоичном коде. Так. 6+128=134 (десятичн.) = 10000110 (двоичн.). Поскольку первая цифра мантиссы равна 1, она может быть опущена, и освободившийся бит может быть использован для указания знака числа (0 — положительное и 1—отрицательное). В нашем примере мантисса равна 11000100, что внутри ЭВМ представляется как 01000100, где первый бит указывает на то, что число положительное (число —49 имело бы вид 11000100). Три байта, выражающие мантиссу, упорядочены от младшего к старшему, поэтому 24-битовое представление мантиссы имеет вид 0000000О 00000000 01000100. Объединяя мантиссу с порядком, получим 32-битовое представление для числа 49: 00000000 00000000 01000100 100001100. Как в Бейсике фирмы Microsoft будут представлены следующие действительные числа с одинарной точностью: (а) 100 (б) 12000 (в) —12000 (г) 32768 (д) 32 (е) —258 26
7. Напишите на языке Бейсик три программы, каждая из которых рассматривает две битовые строки (битовой строкой называется символьная строка, состоящая только из символов «О» и «1») длиной 16 бит как положительные двоичные числа и печатает двоичную строку, представляющую собой соответственно сумму, разность и произведение этих двух чисел. Программы не должны преобразовывать битовые строки в целые числа. 8. Разработайте представление для целых чисел в диапазоне от 0 до 255 ■битовыми строками длиной 8 бит так, что при переходе от одного числа к следующему изменяется только один бит. Напишите на языке Бейсик программу, которая получает на входе целое число и выдает битовую строку в вышеуказанном представлении, и другую программу, которая получает битовую строку и выдает представляемое ей целое число. Напишите на Бейсике третью программу, которая получает две такие битовые строки и выдает битовую строку, представляющую собой сумму двух целых чисел, лредставленных двумя полученными битовыми строками. 9. Рассмотрим ЭВМ с основанием системы счисления, равным трем. В ней базовой единицей памяти будет «трит» (третичная цифра), а не бит. Такой трит может иметь три возможных состояния (0, 1 и 2), а не обычных два (0 и 1). Покажите, как в троичной системе обозначений могут быть представлены неотрицательные числа, используя для этого аналогию с дво- ячиым представлением с помощью битов. Имеется ли такое неотрицательное целое число, которое может быть выражено в троичной системе, но не может быть выражено в двоичной? Имеются ли такие числа, которые могут быть выражены в двоичной системе, но не могут быть выражены в троичной? Почему ЭВМ с двоичной системой счисления более популярны, чем с троичной? 10. Напишите на языке Бейсик программы, преобразующие двоичные числа в троичные и наоборот (см. упражнение 9). При преобразовании двоичного числа в троичное на входе должна быть битовая строка, а на выходе — символьная строка, содержащая символы «0», «1» и «2». Для обратного преобразования на входе должна быть символьная строка, а на выходе — битовая. 11. Напишите на языке Бейсик программу, которая вводит две символьные строки, представляющие троичные неотрицательные числа, как в упраж- «ении 10, и выводит символьные строки, представляющие соответственно их сумму, разность и произведение. 12. Какие наибольшие и наименьшие неотрицательные числа могут быть представлены словом длиной в η трит в системе с основанием, равным трем? Сколько трит требуется для представления целого неотрицательного числа ίΐι? Если целое число может быть представлено при помощи к десятичных цифр, то сколько бит и сколько трит потребуется для его представления? 13. Почему при реализации операции CONCATVAR в терминах операции MOVEVAR, как это было показано в тексте, вторая строка пересылается с область результата перед первой? 1.2. МАССИВЫ В БЕЙСИКЕ В данном разделе мы рассмотрим хорошо известную структуру данных — массив. Массив представляет собой пример композитной структуры. Это означает, что он создан из более простых, уже существующих в языке типов данных. Изучение композитной структуры предполагает анализ того, каким образом происходит организация такой структуры из более простых структур, а также того, каким образом из композитной структуры происходит извлечение какого-либо компонента. Простейшая форма массива — одномерный массив. Абстрактно он может быть определен как конечный упорядоченный 27
набор однородных элементов. Под «конечным» мы понимаем наличие в массиве конкретного числа элементов. Это число может быть большим или маленьким, однако оно обязательна должно существовать. Под «упорядоченным» подразумевается тот факт, что все элементы массива упорядочены таким образом, что имеется первый элемент, второй, третий и т. д. «Однородный» означает, что все элементы массива принадлежат к одному и тому же типу данных. Например, массив может содержать целые числа или символьные строки, однако он не может содержать одновременно и те и другие. Над одномерным массивом можно выполнять две базовые операции. Первой из них является извлечение из массива некоторого заданного элемента. Исходными параметрами для выполнения такой операции являются сам массив и указание того, к какому из его элементов производится доступ. Это указание дается в виде целого числа, называемого индексом. Так* операция extract (а,5) извлечет из массива а элемент с номером 5. Вторая операция помещает элемент в массив. Например, операция store (а,5,х) поместит значение переменной χ в элемент с номером 5 данного массива. До сих пор мы говорили об абстрактной структуре данных и двух абстрактных операциях. Бейсик включает в себя реализацию такой структуры данных и вышеупомянутые операции. Для объявления одномерного массива А из ста целочисленных элементов программист может написать 10 DEFINT А 10 DIM А(ЮО) Функция extract (a,5) записывается на языке Бейсик как А(5), что равносильно ссылке к элементу с номером 5 в массиве А. Операция store(a,5,x) записывается как оператор 100 А(5)=Х Наименьший индекс массива называется нижней границей, а наибольший — верхней границей массива. Верхнюю границу массива можно указать в операторе DIM, однако нижняя граница всегда фиксирована. Некоторые интерпретаторы и компиляторы языка Бейсик имеют значение нижней границы, равное 0; для других это значение равно единице. Некоторые компиляторы также позволяют установить для выбранной программы одно из вышеназванных значений. (Из соображений унификации значение нижней границы для всех программ в данной книге принято равным единице. Это позволяет выполнять программу независимо от соглашений, установленных 28
для конкретной версии языка Бейсик.) Число элементов в одномерном массиве, называемое размером массива, на единицу превышает разность между значениями для верхней и нижней" границ. Если 1 есть нижняя граница, и — верхняя граница, а г — размер массива, то r=u—1 + 1. Так, для версии языка Бейсик, в котором нижняя граница установлена равной нулю, массив А, заданный оператором DIM A(10) содержит 11 элементов (поскольку 10—0+1 = 11), а в версшр со значением нижней границы, равным 1, массив А содержит- 10 элементов (поскольку 10—1 + 1 = 10). Одной из важных особенностей массива в языке Бейсик является то, что созданный массив является статическим. Это* означает, что его верхняя граница (а следовательно, и его размер) не может быть изменена. Попытки изменить размер массива приведут к ошибке. Следовательно, в течение всего* времени своего существования массив в языке Бейсик содержит фиксированное число элементов. Размер массива должеге быть установлен до записи в него каких-либо значений. Работа с одномерными массивами Одномерный массив используется для хранения в памяти» большого числа элементов и при необходимости унифицированного обращения к ним. Рассмотрим, как эти два требования* реализуются на практике. Предположим, что нам необходимо прочитать 100 элементов, вычислить их среднее значение и определить, насколько» каждое из значений отличается от среднего. Эти операции реализуются приводимой ниже программой. (В приводимых в данной книге программах на языке Бейсик мы используем имена· переменных, содержащие дополнительные символы, хотя в некоторых версиях языка Бейсик это не допускается. Более подробное обсуждение соглашений, принятых при работе с языком Бейсик, приводится в разд. 2.1.) 10 'вычисление среднего значения 20 DIMNUM(IOO) 30 SUM=0 40 'запись чисел в массив и вычисление их суммы 50 FOR 1 = 1 ТО 100 60 READ NUM(I) 70 SUM=SUM+NUM(I) 80 NEXT I 90 'В этой точке переменная SUM содержит сумму чисел 100 AVG=SUM/100 ПО 'печать заголовков 120 PRINT «NUMBER», «DIFFERENCE» 130 'печать каждого числа и разности 140 FOR 1=1 ТО 100 29
150 DEVIAT=NUM(I) -AVG .160 PRINT NUM(I),DEVIAT 170 NEXT I 180 'печать среднего значения 190 PRINT: PRINT «AVERAGE IS»; AVG 200 END 500 DATA ... Эта программа использует две группы по 100 чисел. Первая группа представляет собой набор чисел, задаваемый массивом NUM, а вторая — набор разностей, представляющих со- -бой последовательные значения, присваиваемые переменной DEVIAT в цикле с номерами операторов 140—170. В связи -с этим возникает вопрос: почему, используя массив для одновременного хранения в нем всех элементов первой группы, для хранения значений из второй группы используется одна переменная? Ответ довольно прост. Каждая разность вычисляется и печатается. В дальнейшем хранении ее значения нет никакой необходимости. Поэтому переменная DEVIAT может быть использована для вычисления разности между следующим числом и средним значением. Однако исходные числа, которые являются значениями элементов массива NUM, должны постоянно находиться в памяти. Хотя каждое число может прибавляться к SUM при вводе, оно должно быть сохранено в памяти и после вычисления среднего значения, с тем чтобы программа могла вычислить разность между ним и средним значением. Именно для этой цели и необходим массив. Разумеется, для хранения чисел могли быть использованы 100 отдельных переменных. Преимущество использования массива заключается в том, что программисту требуется ввести только одну переменную, имея при этом возможность обращения к нескольким ячейкам. Кроме этого, в сочетании с циклом FOR-NEXT это также дает возможность программисту производить унифицированное обращение к каждому элементу из группы,.а не использовать оператор 60 READN1,N2,N3,... Конкретный элемент массива извлекается при помощи индекса. Например, предположим, что некоторая фирма использует программу, в которой объявлен массив 10 DIMSALES(IO) Массив будет содержать значения цен за десятилетний пери- юд. Предположим, что каждый оператор DATA программы содержит целое число от 1 до 10, представляющее год, а также значения цен в этот год. При этом необходимо просчитать значение цены в соответствующий элемент массива. Это может *быть сделано выполнением в цикле следующего оператора: 100 READ YR, SALES (YR) 30
В этом операторе осуществляется непосредственное обращение к каждому элементу массива, для чего используется его* индекс. Рассмотрим ситуацию, в которой происходит объявление 10 переменных SI, S2, S3,..., S9, SO. Тогда даже после выполнения оператора READ YR, устанавливающего в переменную YR целое число, представляющее год, значение цены· не может быть записано в соответствующую переменную иначе, как 100 IF YR= 1 THEN READ SI 180 IF YR=9 THEN READ S9 190 IF YR= 10 THEN READ SO Это неудобно уже и для десяти элементов. Представьте себе неудобства, которые бы возникли, если бы элементов было* 100 или 1000. Реализация одномерных массивов Одномерный массив легко реализуем. В языке Бейсик объявление вида 10 DIM В (100) резервирует 100 последовательных участков памяти (мы предполагаем значение нижней границы равным единице), каждый из которых имеет размер, достаточный для хранения одного» числа. Адрес первого из этих участков называется базовым адресом массива В и будет в дальнейшем обозначаться как- base (В). Предположим, что размер каждого элемента массива есть esize. Тогда ссылка к элементу В(1) есть ссылка к элементу по адресу base (В), ссылка к элементу В (2) есть ссылка к по адресу base (В) + esize, а ссылка к элементу В(3) —ссылка по адресу base(B) + (I—l)*esize. Таким образом, по заданному индексу можно обращаться к любому элементу массива.- [Разумеется, если нижняя граница массива равна нулю, то* ссылка к элементу В(0) есть ссылка к элементу по адресу база (В), ссылка к элементу В(1) есть ссылка по адресу base (В)+esize (размер) и в общем случае ссылка к В(1) есть- ссылка к элементу по адресу base(B)+I*esize.] Если длина элементов массива не фиксирована, то такой метод реализации массива не пригоден. (Примером может служить массив символьных строк, длина каждой из которых может изменяться.) Это обусловлено использованием описанного- выше метода вычисления адреса конкретного элемента массива, при котором вычисление базируется на том факте, что размер предыдущего элемента фиксирован. Если не все элементы массива имеют одинаковую длину, то необходимо использовать- другой метод. 31
Другой способ реализации массива с элементами переменкой длины предполагает хранение в памяти последовательного -непрерывного набора адресов. Содержимое каждой ячейки памяти представляет собой адрес имеющего переменную длину элемента массива, хранимого в какой-то другой области памяти. Например, на рис. 1.2.1, α показан массив из пяти символь- 10 10 HELLO -Hg|o|o|d|1)|n|i|g|ht J COMPUTER ν ο h h h h h h> гь h A τ 6 Рис. 1.2.1. 32
ных строк переменной длины, созданный с использованием данной реализации. Стрелки на приведенной диаграмме указывают на адреса в других областях памяти. Символ «Ь» перечеркнутое обозначает пробел. Поскольку длина каждого адреса фиксирована, местоположение адреса конкретного элемента может быть вычислено аналогично тому, как это делается для массива с элементами фиксированной длины, рассмотренного в предыдущих примерах. Как только известен адрес ячейки, ее содержимое может быть использовано для определения местоположения самого элемента массива. Это, разумеется, увеличивает «косвенность» адресации при обращении к элементу, ведущую к дополнительным обращениям к памяти, что в свою очередь снижает производительность. Однако это является сравнительно небольшой платой за те удобства, которые предоставляет возможность работы с подобным массивом. Методом, близким к вышеописанному, является реализация массива переменной длины, при которой в одном непрерывном участке памяти хранятся все фиксированные части элементов вместе с адресами, указывающими на их части переменной длины. Например, при реализации символьных строк, рассмотренных в предыдущем разделе, каждая такая строка содержит часть фиксированной длины (поле длиной 1 байт) и часть переменной длины (символьную строку). В одной реализации массива из символьных строк содержатся длина строки и адрес, как это показано на рис. 1.2.1,6. Преимущество такого метода заключается в том, что части элемента, имеющие фиксированную длину, могут обрабатываться с использованием минимального числа обращений к памяти. Например, функция LEN для символьных строк может выполниться за один просмотр памяти. Информация фиксированной длины, относящаяся к элементу, имеющему переменную длину, часто называется заголовком. Двумерные массивы Массив не обязательно должен быть линейным набором однородных элементов. Он может быть также и многомерным. Двумерный массив представляет собой такой набор данных, в котором доступ к любому из элементов осуществляется по двум индексам — номеру строки и номеру столбца. На рис. 1.2.2 показан такой двумерный массив, объявленный следующим оператором языка Бейсик: 10 DIM A (3,5) Полагая нижнюю границу равной единице, получаем, что ссылка к заштрихованному элементу на рис. 1.2.2 есть А (2,4), поскольку он расположен в строке 2 и в столбце 4. Как и для 33
Столбец Столбец Столбец Столбец Столбец Ι Ζ 3 Ь 5 Строка 1 Стропа Ζ Строка 3 Рис. 1.2.2. случая с одномерным массивом, нижняя граница для каждого измерения есть по определению 1 или 0. Число строк или столбцов равно значению верхней границы минус значение нижней границы плюс единица. Это число называется размером по данному измерению. В приведенном выше массиве А этот размер есть 3—1 + 1 (полагая значение нижней границы равным единице), что равно 3, а по другому измерению есть 5—1 + 1, что равно 5. Таким образом, массив А имеет три строки и пять столбцов. Число элементов в двумерном массиве равно произведению числа строк на число столбцов. Следовательно, массив А содержит 3X5=15 элементов. [Если нижняя граница равна нулю, то массив будет содержать (четыре строкиX шесть столбцов) 24 элемента.] Двумерный массив хорошо иллюстрирует различие между физическим и логическим представлениями данных. Двумерный массив представляет собой логическую структуру данных, которая удобна для программирования и решения задач. Например, такой массив может оказаться полезным при описании объекта, который является двумерным физически, например карта или шахматная доска. Он также полезен при организации набора значений, зависящих от двух параметров. Например, в программе для торговой организации, в которой имеется 20 отделений и каждое занимается продажей 30 различных видов товарных единиц, может быть использован двумерный массив вида 10 DIM SALES (20,30) Каждый элемент SALES (I,J) представляет собой количества товара типа J, продаваемое отделением I. Однако, хотя для программиста и удобно рассматривать элементы такого массива, организованные в виде двумерной таблицы, а к тому же языки программирования включают в себя средства работы с ними как с двумерными массивами, тем не менее аппаратная часть большинства ЭВМ не поддерживает такие возможности. Массив должен храниться в памяти ЭВМ, а эта память обычно имеет линейную организацию. Под линейной организацией в данном случае мы подразумеваем, что память ЭВМ представляет собой одномерный массив. Для из- шИ 34
/ Число строи Число столбцов Столбец, 1 < Столбец Ζ < Столбец 3 < Столбец 4 < Столбец 5 < 5 I 3 | А(7,1) | А (2,1) J A(3,1) "| AU,Z) | А (2, 2) 1 /1(3,2) "1 /1(/,J) 1 A{2t3) к | *»,л Π -ία-*)· I /4(2,4) I I A (3,4) 4 | f| Λ(7,«5) ! Ι A(Z,S) [\ A(3f5) •> Заголовок base (A) Рис. 1.2.3 влечения какого-либо элемента из памяти используется один адрес (который может рассматриваться как индекс одномерного массива). Для реализации двумерного массива необходимо разработать метод расположения его элементов в одномерном массиве и метод преобразования двух- координатной ссылки к линейной. Одним из способов представления двумерного массива в памяти является отображение по столбцам. При таком представлении первый столбец массива занимает первую отведенную под массив группу ячеек памяти, второй столбец занимает следующую группу и т. д. Несколько ячеек в начале массива могут быть отведены под заголовок, содержащий верхние границы обоих измерений. (Не следует путать этот заголовок с обсуждавшимися ранее заголовками отдельных элементов массива.) На рис. 1.2.3 приводится представление двумерного массива А, объявленного выше и проиллюстрированного на рис. 1.2.2. (Нижняя его граница полагается равной 1.) Альтернативный способ предполагает хранение заголовка отдельно от массива. При этом он должен содержать адрес первого элемента массива. Кроме того, если 35
элементы двумерного массива представляют собой объекты переменной длины, то элементы непрерывной области могут содержать адреса этих объектов в той же форме, в какой это делается для линейных массивов на рис. 1.2.1. Предположим, что имеется двумерный массив, хранящийся в памяти по столбцам, как это показано на рис. 1.2.3, и предположим также, что для массива AR адресом первого элемента массива является base(AR). Таким образом, если массив AR объявлен как 10 DIMAR(U1,U2) где U1 и U2 — соответственно целочисленные значения для верхних и нижних границ (предполагается, что нижняя граница равна 1), base(AR) есть адрес AR(1,1). Определим rl как диапазон изменения по первому измерению. Положим также, что esize есть размер каждого элемента массива. Вычислим адрес произвольного элемента AR (11,12). Поскольку элемент находится в столбце 12, его адрес получается путем вычисления адреса первого элемента столбца 12 и сложения его с величиной (II—l)*esize (эта величина определяет, насколько «глубоко» по столбцу 12 находится элемент из строки И). Однако для доступа к первому элементу столбца 12 [который есть элемент AR (1,11) ] необходимо пройти через (12—1) полных столбцов, каждый из которых содержит rl элементов (поскольку в каждой из строк для каждого столбца имеется один элемент), поэтому адрес первого элемента столбца 12 есть base(AR) + (I2— l)*rl*esise. Следовательно, адрес AR(11,12) есть base(AR) + [(I2— l)*rl + (Il—l)]*esize В качестве примера рассмотрим массив А, показанный на рис. 1.2.2, представление которого дается на рис. 1.2.3. В этом массиве Ul=3, a U2 = 5, поэтому base (А) есть адрес А (1,1) и R1 равно 3. Предположим также, что каждый элемент массива требует для своего хранения один элемент памяти, и, следовательно, esize равен 1. (Это не обязательно может быть так: однако для простоты мы примем это допущение.) Тогда адрес А (2,3) может быть вычислен следующим способом. Для перемещения к столбцу 3 мы должны пропустить столбцы 1 и 2. Каждый из этих столбцов содержит три элемента, каждый из которых представлен одной ячейкой памяти. Первый элемент в столбце 3 [который является А (1,3)1 отстоит на шесть элементов от А (1,1), который есть base (А). Элемент А (2,3) есть элемент, следующий за А (1,3). Приведенная выше формула дает для А (2,3) base(A) + [(3—1)*3+(2—1)]*1 или 36
base(A)+6+l=base(A)+7. Взглянув на рис. 1.2.3, можно удостовериться в том, что А (2,3) отстоит на семь элементов от base (А). Приведенные выше вычисления предполагают, что нижняя граница равна единице. В версиях языка Бейсик, для которых нижняя граница равна нулю, формула вычисления адреса AR (11,12) примет вид base(AR) + [ (I2*rl) +11] *esize Вывод этой формулы предлагается читателю в качестве упражнения. Многомерные массивы Язык Бейсик допускает работу с массивами, размерность которых больше двух. Например, трехмерный массив может быть объявлен следующим образом: 10 DIM С (3,2,4) Это иллюстрируется на рис. 1.2.4, а. Элемент в таком массиве адресуется тремя индексами, например С (2,1,3). Первый индекс задает номер матрицы, второй — номер строки и третий — номер столбца. Такой массив полезен, если некоторое значение определяется тремя параметрами. Например, массив температур может быть проиндексирован по широте, долготе и высоте. По очевидным причинам при выходе за третье измерение геометрическая аналогия невозможна. Однако Бейсик позволяет задавать массивы с произвольным числом размерностей. Например, шестимерный массив может быть объявлен следующим образом: 10 DIM D (2,8,5,3,15,7) Для ссылки к элементу такого массива потребуется шесть индексов, например D(2,7,l,1,14,3). Диапазон изменения индекса в заданной позиции индекса (размер по какому-либо измерению) равен значению верхней границы для данного измерения минус значение для нижней границы плюс единица. Число элементов в массиве есть произведение размеров по всем измерениям. Например, приводимый ранее массив С содержит 3X2X4=24 элемента, а массив D содержит 2χ8χ5χ3χΐ5χ X 7 = 25 200 элементов (нижняя граница предполагается равной единице). Отображение массива в памяти по столбцам может быть расширено и на массивы с размерностью, большей двух. На рис. 1.2.4,6 показано представление массива С, изображенного на рис. 1.2.4, а. Элементы описанного выше шестимерного массива D располагаются в памяти в следующем порядке: 37
Плоскост Плоское Плоскость I — Строка 7 Строка 2 / S* У S ^"Т 1 ^ \ ^ ! ^ ! S%- -у-^К- ^-<>~ -*<J- ^. ^1 ^т ^Г-^- > ^<ί />' ' Столбец Столбец Столбец Столбец 7 2 3 4 Заголовок < Строка 7< Столбец 1 <Г Строка 7 < Столбец 2 < Строка Ζ < {Строка 7 < Стол6ецЗ\ \Строка2< Строка К Столбец 4 < Гг{ \Строка2 < CU, 7,/) с(г, 7,/) С(3,7, 7) си, ζ, η С(2,2,1) С(3. 2, 7) С{1, 1,2) С(2, 7, 2) С(3,1,2) С{7,2,2) С(2, 2,2) С(3,2,2) С( 1,1,3) 0(2,1,3) 0(3,1,3) 0(1,2,3) С(2,2,3) 0(3,2,3) С(1,1,4) 0(2,1,4) 0(3,1,4) С{ 1,2,4) С(2,2,4) 0(3,2,4) base (С) Рис. 1.2.4.
0(1,1,1,1,1,1) D(2,1,1,1,1,D D(l,2,l,l,l,l) D(2,2,1,1,1,D D(1,3,1,1,1,D • · · D (1,6,5,3,15,7) 0(2,6,5,3,15,7) D(l,7,5,3,15,7 D (2,7,5,3,15,7) D(l,8,5,3,15,7) D (2,8,5,3,15,7) Первый индекс изменяется наиболее быстро. Индекс не увеличивается до тех пор, пока не будут перебраны все возможные комбинации индексов слева от него. Каким образом осуществляется доступ к элементу произвольного многомерного массива? Предположим, что AR есть многомерный массив, объявленный следующим образом: 10 DIM AR(UbU2,...,Un) который располагается в памяти по столбцам. Предполагается, что каждый элемент массива AR занимает некоторое заданное число ячеек памяти esize, a base(AR) определяется как адрес первого элемента массива, который есть AR(1,1,...,1). При этом нижняя граница 1 есть либо 1, либо 0 в зависимости от принятого способа реализации; г определяется как Ui—1 + 1 для всех i от 1 до п. Таким образом, для доступа к элементу AR(Ii,I2,..., In), последний индекс которого есть 1п, необходимо сначала пройти через (1п—1) «гиперплоскостей», каждая из которых состоит из Γι*Γ2* ... *rn-i элементов. Для доступа к первому элементу AR, два последних индекса которого есть соответственно (Ιη-ι—I) добавочных групп по ri*r2* ... *гп-г элементов. Аналогичный процесс должен быть проделан и для остальных измерений, и так до тех пор, пока не будет достигнут последний элемент, последние η—1 индексов которого совпадают с соответствующими индексами отыскиваемого элемента. Наконец, для доступа к отыскиваемому элементу необходимо пройти (Ιι—1) добавочных элементов. Итак, адрес AR(IbI2,..., In) может быть записан как base(AR) + esize*[(In — l)*ri*r2* . ..* rn-i + (In-i — l)*ri*r2* ... ... *rn-2+... + (I2—l)*ri+(Ii—1)], который может быть вычислен более эффективно при помощи эквивалентной формулы base(AR) + esize*[^—l+n* ((I2—l)+г2* (...+гп_2* (Ιη-ι — -1+rn-^dn-l))...))] Эта формула может быть вычислена по следующему алгоритму (в предположении, что переменная 1 используется для хранения значения нижней границы, а массивы i и г размером η содержат соответственно индексы и размерности): 39
offset=О for j = n to 1 step-1 offset=r (j) *offset+ (Hi) -1) next j addr=base (AR) +esize*of fset Обработка ошибок, связанных с неправильной индексацией Предположим, что программист ошибочно указал индекс, выходящий за границы массива. Надо сказать, что такая ситуация возникает довольно часто. Например, программист ссылается к А(1), где А есть массив с индексами, изменяющимися в диапазоне от 1 до 100, а текущее значение I есть 101. Такие ошибки довольно часты в тех случаях, когда в качестве индексов используются выражения, а также внутри цикла FOR-NEXT, когда цикл повторяется на один раз больше требуемого. Поскольку такая ссылка не разрешена, результат такого обращения в языке Бейсик не определен. Во многих версиях языка Бейсик такая ссылка приводит к ошибке, вызывающей в свою очередь остановку программы. Рассмотрим несколько возможных действий, которые могут быть предприняты при выходе индекса за границы массива. Простейшим случаем является отсутствие каких-либо действий. Это означает, что при возникновении ссылки к элементу массива А(1) машина продолжает вычислять адрес элемента по приведенной выше формуле независимо от того, является ли указанный индекс разрешенным. Например, если размер каждого элемента массива равен одной ячейке памяти и массив объявлен с границами 1 и 100, то ссылка к элементу с индексом 101 приведет к получению адреса, отстоящего вперед на 100 элементов от первого элемента массива. Этот адрес находится за границами самого массива и может даже находиться за границами памяти, отведенной под программу. Система в этом случае может предпринять любое подходящее действие. Если адрес лежит за пределами памяти, отведенной программе, то таким действием может быть печать сообщения об ошибке и остановка программы. Однако сообщение о такой ошибке не обязательно подразумевает неверную индексацию. Оно может также означать попытку обращения к несуществующей ячейке памяти или же к памяти, не отведенной для данной программы. Может случиться так, что вычисленный адрес находится в области памяти, отведенной программе, однако информация по этому адресу не соответствует формату элемента адресуемого массива. При попытке интерпретировать эту информацию как элемент массива система выдаст сообщение о том, что информация записана в неверном формате. В этом случае также не будет никаких указаний на то, что ошибка произошла вследствие указания индекса, выходящего за границы массива. 40
Получение программистом неточных сообщений такого рода не исключает возможности возникновения и других ситуаций. Гораздо худшая ситуация может возникнуть в том случае, если вычисленный адрес ячейки находится внутри программной области, а содержащаяся в ней информация находится в требуемом формате. В этом случае система воспользуется этой информацией без выдачи каких-либо сообщений об ошибке и на основании этой информации будет выдавать неправильные результаты. В этом случае программист не получит никаких сообщений о том, что результаты неверны. В других случаях он может понять, что результат, очевидно, неверный, не зная при этом, в каком месте большой программы произошла ошибка. Во всех перечисленных выше случаях реализация языка зависит от имеющейся системы обнаружения ошибок, которая может быть реализована аппаратно или же программно через операционную систему. В этом случае контроль на нахождение индекса внутри границ массива не производится. Поскольку индекс может быть переменной или выражением, его принадлежность указанному диапазону невозможно определить без явной проверки такого соответствия. Такая проверка должна производиться всякий раз при выполнении оператора. Так, если оператор в цикле выполняется 1000 раз, то это предполагает проведение 1000 проверок. Проверка включает в себя не только вычисление адреса, но и контроль на значимость. Это резко снижает эффективность работы программы. Помимо этого для проверки значимости индекса необходимо постоянно хранить в памяти значение верхней границы. Имеется другой способ, при котором на первый план выдвигаются удобство работы и легкость отладки программы. Массив в этом случае представлен не одними лишь входящими в него элементами. Каждый массив содержит также заголовок, в котором указаны границы массива. Этот заголовок может быть помещен в начале непрерывной области, в которой находятся элементы массива, или может располагаться в виде отдельной единицы и содержать базовый адрес массива и значения его границ. При ссылке к элементу массива в процессе работы программы производится проверка на принадлежность индекса допустимой области. Эта проверка осуществляется до вычисления адреса элемента. Если индекс лежит за границами допустимой области, то выдается подробное сообщение об ошибке, содержащее имя массива и неверное значение индекса. Как уже говорилось, во многих реализациях языка Бейсик эффективность принесена в жертву надежности программы. В этих реализациях проверка значимости индекса производится- в течение всего времени работы программы. При возникновении запрещенной ссылки создается условие ERROR и выполнение- программы прекращается. Для тех версий языка Бейсик, кото-. 41
рые поддерживают оператор ON ERROR, последовательность операций, выполняющихся при возникновении такой ошибки, может быть задана программистом. Упражнения 1. Напишите на языке Бейсик программу, которая упорядочивает элементы одномерного массива по возрастанию. 2. Медианой массива элементов называется элемент m этого массива такой, что половина оставшихся элементов массива больше или равна т, а другая половина меньше или равна т, если массив содержит нечетное число элементов. Если число элементов четное, то медиана массива есть среднее двух элементов ml и т2 такое, что половина оставшихся элементов больше или равна ml и т2, а половина элементов меньше или равна ml и т2. Напишите на языке Бейсик программу, вычисляющую медиану массива. 3. Модой массива элементов называется число т, которое встречается в массиве наиболее часто. Если в массиве имеется несколько наиболее часто встречающихся чисел и число их вхождений совпадает, то считается, что массив не имеет моды. Напишите на языке Бейсик программу, которая либо вычисляет моду массива, либо устанавливает, что последний ее не имеет. 4. Напишите на языке Бейсик программу, которая преобразует одномерный массив чисел таким образом, что первый элемент массива становится последним, второй — предпоследним и т. д. 5. Массив элементов а размерностью ηχη называется симметричным, если элемент a(i, j) равен a(j, i) для всех i и j в интервале от 1 до п. Напишите программу, вводящую элементы массива размерностью 5x5, упорядоченного по столбцам, напечатайте этот массив в виде таблицы, а также напечатайте сообщение о том, является ли этот массив симметричным. 6. Напишите на языке Бейсик программу, считывающую набор значений температур. Каждая выборка содержит два числа: число в интервале от —90 до 90, представляющее широту, на которой была измерена температура, и значение температуры на данной широте. Напечатайте таблицу, содержащую значение каждой из широт и среднее значение температуры на этой широте. Если на какой-либо широте значения температуры отсутствуют, то «место среднего значения напечатайте сообщение NO DATA. Затем напечатайте среднюю температуру на Северном и Южном полушариях. (Северное полушарие включает широты от 1 до 90, а Южное полушарие — широты ότ —1 до —90). (Эта средняя температура может быть вычислена как среднее полученных средних значений, а не исходных данных.) Определите также, какое из полушарий более теплое. Для этого воспользуйтесь средней температурой всех тех широт, для которых имеются данные о температуре для обоих полушарий. (Например, если для широты 57 температура известна, а для широты —57 нет, то при определении того, какое из полушарий теплее, средняя температура для широты 57 не должна учитываться.) 7. Предположим, что вы пишите программу для 20 различных торговых отделов, каждый из которых продает товары 10 различных наименований. Начальник каждого отдела ежемесячно передает информацию, содержащую номер отдела (число от 1 до 20), номер товара (от 1 до 10) и выручку ^меньше 100 000 долл.), представляющую собой общую вырученную сумму по данному отделу. Некоторые начальники отделов могут не сообщать данные о некоторых товарах (т. е. некоторые товары продаются не всеми отделами). Вы должны написать на языке Бейсик программу, считывающую эти данные и печатающую таблицу из 12 столбцов. Первый столбец должен содержать номера отделов от 1 до 20 и слово TOTAL (общее количество) в последней строке. Следующие 10 столбцов должны содержать. значения цен для каждого из 10 видов товара по каждому отделу и общую стоимость всего объема продажи по каждому виду. Последний столбец должен содержать общую стоимость продажи по каждому из 20 отделов для всех това- 42
ров и суммарную стоимость всего объема продажи в правом нижнем углу. Каждый столбец должен иметь соответствующий заголовок. Если для какого-либо отдела или вида товара данные отсутствуют, то печатаются нули. Входные данные никак не упорядочены. 8. (а) Покажите, как доска для игры в шашки может быть представлена в массиве на языке Бейсик. Покажите, как представить текущее состояние партии в любой заданный момент. Напишите на языке Бейсик программу, которая печатает все возможные ходы, которые могут сделать черные в любой заданной позиции. (б) Выполните то же самое, что и в п. (а), для шахматной доски. 9. Напишите программу, печатающую метод размещения на шахматной доске восьми королев, чтобы при этом любые две королевы не находились одновременно на одной строке, диагонали или столбце. Программа должна, выдавать восемь строк, каждая из которых содержит восемь символов. Каждый символ есть либо символ звездочки (*), обозначающий пустую, позицию, либо 1, обозначающая позицию, занимаемую королевой. 10. Предположим, что каждый элемент массива А, упорядоченный шх столбцам, занимает четыре ячейки памяти. Вычислите адрес указанного- элемента массива А, если массив А объявлен одним из перечисленных ниже способов и адрес первого элемента массива А есть 100. (Для всех случаеа нижняя граница полагается равной 1.) (а) DIM A(100) адрес А(10) (б) DIM A(10,20) адрес А(1,1) (в) DIM A(10,20) адрес А(5,1) (г) DIM A(10,20) адрес А(1,10) (д) DIM A(10,20) адрес А(2, 10) (е) DIM A(10,20) адрес А(10,20) (ж) DIM A (5,6,4) адрес А(3,2, 4) 11. Массив может храниться в памяти упорядоченным по строкам. При этом за элементами первой строки следуют элементы второй строки и т. д. (а) Напишите программу, считывающую элементы массива 5x5, упорядоченного по столбцам, и напечатайте их, упорядочив предварительно по строкам. (б) Напишите программу, считывающую элементы массива 5x5, упорядоченного по строкам, и напечатайте их, упорядочив предварительно по столбцам. 12. По аналогии с имеющимися в тексте формулами и алгоритмами разработайте формулы и алгоритмы для доступа к элементу массива, упорядоченного по строкам (см. упражнение 11). 13. Нижним треугольным массивом а размерностью ηχη называется такой массив, у которого a(i, j)=0, если i<j. Каково максимальное число ненулевых элементов в таком массиве? Как такие элементы могут быть последовательно расположены в памяти? Разработайте алгоритм для доступа к a(i, j), где i>j. Определите верхний треугольный массив аналогичным образом и проделайте с ним такие же операции. 14. Строго нижним треугольным массивом называется массив а размерностью ηχη, у которого a(i, j)=0 если i^j. Ответьте на вопросы к упражнению 13 применительно к такому массиву. 15. Пусть а и b есть соответственно два ηχη нижних треугольных массива (см. упражнение. 13 и 14). Покажите, как массив с размерностью πχ(η+1) может быть использован для хранения ненулевых элементов этих двух массивов. Какие элементы массива с будут содержать элементы a(i, j) и b(i, j)? 16. Трехдиагональным массивом а размерностью пХп называется массив, в котором a(i, j)=0, если абсолютное значение i—j больше чем 1. Каково максимальное число ненулевых элементов в таком массиве? Как эти элементы могут быть последовательно записаны в памяти? Разработайте алгоритм для доступа к a(i, j), если абсолютное значение i—j меньше или равно единице. Проделайте то же самое для массива а, в котором a(i, j) = =0, если абсолютное значение i—j больше чем к. 43
17. Разработайте метод реализации неоднородного массива, т. е. массива, содержащего данные разного типа. Можно ли расширить синтаксис языка Бейсик для работы с подобной структурой? 1.3. ОРГАНИЗАЦИЯ ДАННЫХ В ЯЗЫКЕ БЕЙСИК Часто бывает полезно рассматривать набор данных как единое целое. Например, предположим, что нам необходимо сохранять информацию о некотором сотруднике. Если данные об этом сотруднике включают в себя его имя, отчество и фамилию, то эти данные могут быть инициализированы следующим образом: 10 DEFSTR F,L, M 20 READ FIRST, MIDINIT, LAST При таком методе какая-либо связь между этими тремя компонентами отсутствует. Другой метод предполагает организацию имен в группу из трех компонентов следующим образом: 10 DEFSTR N 20 DIMNAME(3) 30 FIRST=1 40 MIDINIT=2 50 LAST=3 60 READ NAME (FIRST), NAME (MIDINIT), NAME (LAST) При таком представлении NAME (FIRST) ссылается к имени, NAME (MIDINIT) —к отчеству и NAME (LAST) —к фамилии. Преимущество такого представления заключается в том, что при необходимости мы можем обращаться либо к полному имени сотрудника (через NAME), либо к индивидуальным компонентам имени. Такое представление может быть дополнено случаем, при котором необходимо хранить информацию о нескольких сотрудниках. Например, предположим, что нам необходимо хранить имена 50 сотрудников. Мы можем написать следующую программу: 10 DEFSTR N 20 DIM NAME (50,3) 30 FIRST= 1 40 MIDINIT=2 50 LAST=3 60 FOR 1 = 1 TO 50 70 READ NAME(I,FIRST), NAME(I.MIDINIT), NAME(I,LAST) 80 NEXT I Разумеется, массив NAME может быть представлен тремя массивами—FIRST (50), MIDINIT(50) и LAST(50), однако взаимосвязь между ними может быть при этом потеряна. Отметим, что в обоих приведенных примерах мы сгруппировали вместе переменные с одним и тем же типом данных 44
(в данном случае символьные строки). Переменные с различными типами данных не могут быть сгруппированы вместе таким способом. Они должны быть перечислены отдельно. Например, если мы хотим сохранить об имеющихся 50 сотрудниках дополнительную информацию, то можно сгруппировать все взаимосвязанные компоненты в нескольких отдельных двумерных массивах следующим образом: 10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 DEFSTR H,N,P,R,W 'записи о сотрудниках DIM NAME (50,3) DIM DIM FIRST=1 MIDINIT=2 LAST=3 RESIDENCE (50,4) ADDR=1 CITY=2 STATE=3 ZIP=4 POSITN(50,2) DEPTNO=l JOBTITLE=2 DIM SALARY(50) DIM DEPENDENTS (50) DIM HEALTHPLAN(50) DIM WHENHIRED(50) Используя такое представление, мы можем ссылаться к имени сотрудника с номером I через NAME(IJFIRST) и к его (ее) должности через POSITN(I,JOBTITLE). Фамилии сотрудников и их оклады могут быть напечатаны следующим образом: 200 FOR 1 = 1 ТО 50 210 PRINT NAME(I.LAST), SALARY(I) 220 NEXT I Мы можем напечатать фамилии и места жительства всех сотрудников следующим образом: 200 FOR 1 = 1 ТО 50 210 PRINT NAME(I,LAST) 220 FORJ=lT0 4 230 PRINT RESIDENCE(I,J) 240 NEXT J 250 PRINT: 'пропустить строку 260 NEXT I Совокупность связанных элементов данных, сгруппированных в отдельную единицу, называется набором данных или записью. Некоторые языки программирования высокого уровня (например, Паскаль, ПЛ/1 и Кобол) поддерживают операции по организации различных элементов в одной переменной. В большинстве версий языка Бейсик такая возможность отсутствует (если только элементы не имеют одинаковые атрибуты, что позволяет организовать их в виде отдельного массива). Группирование взаимосвязанной информации в отдельный мас- 45
сив резко облегчает понимание программы. Однако переменные с различными типами данных, например SALARY и HEALTHPLAN, не могут быть объединены в одном массиве. Организация других структур данных В дальнейшем мы будем использовать массивы для представления более сложных структур данных, подлежащих изучению. Организация данных в специальные агрегаты весьма полезна, поскольку это позволяет группировать объекты в отдельные наборы и давать этим объектам имена в соответствии с выполняемыми ими функциями. В качестве примера работы с данными, организованными подобным способом, рассмотрим проблемы, связанные с представлением рациональных чисел и многомерных массивов. Рациональные числа Рассмотрим следующую концепцию наборов данных для представления рациональных чисел. Рациональным числом называется любое число, которое может быть представлено в виде отношения двух целых чисел. Так, 1/2,_3/4, 2/3 и 2 (т. е. 2/1) являются рациональными числами, a f2 и π такими числами не являются. В ЭВМ рациональное число обычно выражается посредством десятичного приближения. Если мы потребуем от ЭВМ напечатать число 1/3, то будет напечатано число 0,333333. Хотя это и достаточно точное приближение (разница между 0,333333 и 1/3 составляет всего одну трехмиллионную), оно тем не менее не является точным. Если нам потребуется вычислить сумму 1/3+1/3, то результат будет 0,666666 (что равно 0,333333+0,333333), а результат печати числа 2/3 будет 0,666667. Это означает, что проверка равенства 1/3+1/3 = 2/3 даст неверный результат! В большинстве случаев десятичное приближение является удовлетворительным, однако в ряде случаев это не так. Следовательно, желательно иметь такое представление рациональных чисел, при котором можно выполнять арифметические операции без потери точности. Каким образом мы можем представить десятичное число без потери точности? Поскольку рациональное число состоит из числителя и знаменателя, мы можем представить рациональное число RTNL с помощью следующего агрегата данных: 10 DEFINTR 20 DIMRTNL(2) 30 NMRTR=1 40 DNMNTR=2 Мы ссылаемся к числителю как к RTNL(NMRTR), а к знаменателю как к RTNL(DNMNTR). 46
Может показаться, что мы уже можем определить арифметические операции для нового введенного представления рациональных чисел, однако при этом возникает следующая проблема. Предположим, что мы определили два рациональных числа R1 и R2 следующим образом: 50 DIMR1(2),R2(2) и присвоили им некоторые значения. Как, однако, мы можем проверить равенство этих двух чисел? Предположим, что потребовалось закодировать 100 IF R1(NMRTR)=R2(NMRTR) AND R1(DNMNTR)=R2(DNMNTR) THEN ... To есть если числители и знаменатели равны, то рациональные числа также равны. Однако возможна ситуация, при которой числители и знаменатели не равны, но при этом сами рациональные числа равны между собой. Например, числа 1/2 и 2/4 равны между собой, хотя их числители (1 и 2) и знаменатели (2 и 4) не равны. Следовательно, нам необходим другой способ проверки. Так почему же числа 1/2 и 2/4 равны между собой? Потому, что они представляют собой одно и то же отношение: одна вторая и две четверти обе представляют собой одну вторую. Для проверки рациональных чисел на равенство нам необходимо сначала привести их к несократимым дробям. После приведения рациональных чисел к несократимым дробям мы можем осуществить проверку их на равенство между собой путем простого сравнения их числителей и знаменателей. Определим несократимое рациональное число как такое рациональное число, для которого не существует целого числа, большего единицы, на которое числитель и знаменатель делятся без остатка. Так, 1/2, 2/3 и 10/1 являются несократимыми, а 4/8, 12/18 и 15/6 таковыми не являются. В нашем примере 2/4 сокращается до 1/2, и поэтому оба числа равны между собой. Для приведения любой дроби к несократимой может быть использована процедура, известная как алгоритм Евклида. Эта процедура может быть описана следующим образом: 1. Пусть а есть наибольшее число из двух чисел — числителя и знаменателя, a b — наименьшее. 2. Разделим а на Ь, найдем частное q и остаток г (т. е. a = q*b+r). 3. Пусть а = Ь и b=r. 4. Повторять шаги 2 и 3 до тех пор, пока b не станет нулем. 5. Разделим числитель и знаменатель на последнее значение а. В качестве примера сократим дробь 1032/1976. 47
Шаг 0 Шаг 1 Шаг 2 Шаг 3 Шаги 4 и 2 Шаг 3 Шаги 4 и 2 Шаг 3 Шаги 4 и 2 Шаг 3 Шаги 4 и 2 Шаг 3 Шаги 4 и 2 Шаг 3 Шаги 4 и 2 Шаг 3 Шаг 5 Следовательно числитель =1032 а=197б а =1976 а-1032 а=1032 а=944 а = 944 а=88 а = 88 а=64 а = 64 а = 24 а=24 а=1б а=1б а = 8 1032/8= ι, Дробь 129 знаменатель= Ь=1032 Ь=1032 Ь=944 Ь=944 Ь = 88 Ь=88 Ь=64 Ь=64 Ь=24 Ь=24 Ь=1б Ь=1б Ь=8 Ь = 8 Ь = 0 1032/1976 может = 1976 быть q=l r-944 q=l r=88 q=10 r=64 q=l r=24 q = 2 r=16 q=l r = 8 q=2 r=0 1976/8=247 сокращена до 129/247. Напишем подпрограмму, сокращающую рациональное число RTNL. Перед обращением к этой подпрограмме RTNL не обязательно должно быть в виде несократимой дроби, а после выхода из подпрограммы RTNL будет сокращено. 2000 'подпрограмма reduce 2010 'шаг 1 — Выяснение, что больше — числитель или знаменатель 2020 IFRTNL(NMRTR)>RTNL(DNMNTR) THEN A=RTNL(NMRTR): В=RTNL (DNMNTR): GO TO 2040 2030 В = RTNL (NMRTR): A=RTNL(DNMNTR) 2040 Q=INT(A/B) 'шаг 2 2050 R=A-Q*B 2060 A=B 'шаг 3 2070 B = R 2080 IF R>0 THEN GO TO 2040: 'шаг 4 2090 RTNL (NMRTR) = RTNL (NMRTR) /А: 'шаг 5 2100 RTNL (DNMNTR) = RTNL (DNMTR)/A 2110 RETURN 2120 'конец подпрограммы reduce Используя подпрограмму reduce, мы можем написать другую подпрограмму — equal, которая определяет, равны или нет между собой числа R1 и R2. Если они равны, то переменная EQUAL устанавливается в единицу. В противном случае переменная EQUAL устанавливается в нуль. 1000 'подпрограмма equal 1010 'привести R1 и R2 к несократимой дроби 1020 RTNL (NMRTR) =R1 (NMRTR): RTNL(DNMNTR)=R1 (DNMNTR) 1030 GOSUB 2000: 'сократить Rl 1040 Rl (NMRTR) =RTNL(NMRTR): Rl (DNMNTR) =RTNL(DNMNTR) 1050 RTNL (NMRTR) =R2(NMRTR): RTNL(DNMNTR) =R2(DNMNTR) 1060 GOSUB 2000: 'сократить R2 1070 R2 (NMRTR) = RTNL (NMRTR): R2 (DNMNTR) = RTNL (DNMNTR) 1080 'проверка сокращенных чисел на равенство j 1090 EQUAL=0 48
1100 IF Rl (NMRTR)=R2(NMRTR) AND R1(DNMNTR)=R2(DNMNTR) THENEQUAL=1 1110 RETURN Теперь мы можем написать программы, выполняющие арифметические операции с рациональными числами. Приведем программу, умножающую два рациональных числа, и предложим читателю в качестве упражнения написание программ сложения, вычитания и деления таких чисел. Входными данными для программы умножения являются два рациональных числа, а на выходе получается третье. Для представления этих трех рациональных чисел мы немного изменим наше представление, введя агрегат данных, содержащий все эти три числа. 10 DEFINTR 20 DIMRTNL(3,2) 30 NMRTR^l 40 DNMNTR-2 50 FIRST=1 60 SECND = 2 70 RESULT=3 Агрегат данных позволяет ссылаться к знаменателю первого операнда как к RTNL(FIRST,DNMNTR) и к числителю результата как к RTNL (RESULT, NMRTR). Вспомним, что (a/b)*(c/d) = (a*c)/(b*d) Однако, поскольку числа а*с и b*d могут быть большими, перед завершением программы умножения мы сократим результат до несократимой дроби. Ниже лриводится полная программа для многократного введения двух дробей и печати их произведения. Программа прекращает работу в том случае, если в качестве знаменателя одной из дробей вводится 0. Отметим, что подпрограмма reduce была модифицирована с тем, чтобы сократить рациональное число, представленное RTNL(RESULT,NMRTR) и RTNL(RESULT,DNMNTR). 10 DEFINTR 20 DIM RTNL (3,2) 30 NMRTR =1 40 DNMNTR = 2 50 FIRST=1 60 SECND = 2 70 RESULT=3 80 PRINT «ВВЕДИТЕ ЧИСЛИТЕЛЬ И ЗНАМЕНАТЕЛЬ»: «ПЕРВОГО РАЦИОНАЛЬНОГО ЧИСЛА» 90 INPUT RTNL(FIRST.NMRTR), RTNL (FIRST,DNMNTR) 100 IF RTNL(FIRST,DNMNTR) =0 THEN GO TO 200 110 PRINT «ВВЕДИТЕ ЧИСЛИТЕЛЬ И ЗНАМЕНАТЕЛЬ»; «ВТОРОГО РАЦИОНАЛЬНОГО ЧИСЛА» 120 INPUT RTNL(SECND.NMRTR), RTNL(SECND.DNMNTR) 130 IF RTNL(SECND.DNMNTR) =0 THEN GO TO 200 140 'умножить два числа 150 GOSUB 1000 49
160 'напечатать сокращенное число 170 PRINT «СОКРАЩЕННОЕ ПРОИЗВЕДЕНИЕ ЕСТЬ» 180 PRINT RTNL(RESULT,NMKTR); «/»; RTNL(RESULT,DNMNTR) 190 GOTO 80 200 PRINT «НУЛЕВОЙ ДЕЛИТЕЛЬ ПРЕРЫВАЕТ ВЫПОЛНЕНИЕ ПРОГРАММЫ» 210 END 1000 'подпрограмма multiply 1010 'умножить числители 1020 RTNL(RESULT.NMRTR) =RTNL(FIRST,NMRTR) * RTNL(SECND.NMRTR) 1030 'умножить знаменатели 1040 RTNL (RESULT,DNMNTR) = RTNL (FIRST.DNMNTR) * RTNL (SECND.DNMNTR) 1050 'сократить результат 1060 GOSUB 2000 1070 RETURN 1080 'конец подпрограммы multiply 2000 'подпрограмма reduce 2010 'шаг 1 2020 IF RTNLtRESULT,NMRTR)>RTNL(RESULT,DNMNTR) THEN A=RTNL(RESULT,NMRTR): B = RTNL(RESULT,DNMNTR): GOTO 2040 2030 B = RTNL(RESULT,NMRTR): A=RTNL (RESULT, DNMNTR) 2040 Q=INT(A/B) 'шаг 2 2050 R=A-Q*B 2060 A=B: 'шаг 3 2070 B = R 20θ0 IF R>0 GOTO 2040: 'шаг 4 2090 RTNL (RESULT.NMRTR) = RTNL (RESULT, NMRTR)/A: 'шаг 5 2100 RTNL(RESULT.DNMNTR) =RTNL(RESULT, DNMNTR)/A 2110 RETURN 2120 'конец подпрограммы reduce Многомерные массивы Как уже говорилось в разд. 1.2, многомерные массивы реализованы в действительности в одномерной линейной памяти. Посмотрим, как можно реализовать такие массивы в системе, допускающей использование только двумерных массивов. В то же время мы обеспечим пользователю возможность самому устанавливать нижнюю границу массива, игнорируя при этом установленное по умолчанию значение 0 или 1. Проиллюстрируем реализацию трехмерных массивов. Читателю будет понятна по аналогии реализация массивов любой другой размерности. Для трехмерного массива можно выделить две основные операции — помещение в заданную позицию массива некоторого значения и извлечение значения из указанной позиции массива. Обозначим эти операции следующим образом: store (a,sl,s2,s3,v) 50
и extract (a,sl,s2,s3) В обоих случаях а есть структура данных, представляющая* массив, a si, s2 и s3 являются индексами. Для операции store переменная ν задает значение, записываемое в указанную позицию. Операция extract представляет собой функцию, которая извлекает значение из указанной позиции и присваивает его переменной extract. Предположим, что нам необходимо реализовать трехмерный массив, который имеет по первому измерению значение нижней границы, равное 5, а верхней границы, равное 10. Значение нижней границы для второго измерения равно 1, а значение верхней равно 7. Для третьего измерения значения для нижней и верхней границ есть соответственно 2 и 4. Такой массив будет содержать 126 элементов. Это может быть сделано? введением следующего агрегата данных: 10 DIM BOUNDS (2,3) 20 LO=l: 'BOUNDS (LO,I) содержит нижнюю границу для 1-го измерения 30 HI = 2: 'BOUNDS(HI,I) содержит верхнюю границу для 1-го измерения 40 DIM ELEMENT(126): 'массив ELEMENT содержит элементы трех- 'мерного массива 50 'инициализация верхней и нижней границ 60 BOUNDS(LO,l)=5 70 BOUNDS (LO,2) = l 80 BOUNDS (LO,3) = 2 90 BOUNDS(HI,1) = 10 100 BOUNDS (HI,2) =7 110 BOUNDS(HI,3)=4 Значения элементов BOUNDS (LO,I) есть значения для нижних границ трех измерений, а значения элементов? BOUNDS (HI,I)—значения для верхних границ. Элементы самого массива содержатся в массиве ELEMENT. Размер массива ELEMENT равен 126, что равно числу элементов нашего трехмерного массива, поскольку (10—5+1) * (7—1 + 1) * (4—2+ + 1) = 126. Элементы упорядочены по столбцам таким образом, что ELEMENT (1) соответствует элементу ARRAY (5,1,2), ELEMENT (2) соответствует ARRAY (6,1,2) и т. д. Важно отметить, что массив ELEMENT не всегда может быть объединен с массивом BOUNDS в один массив, поскольку ELEMENT может содержать символы (если мы реализуем массив строк символов), a BOUNDS всегда содержит только целые числа. Для обращения к массиву подпрограммы store и extract выполняют вычисление смещения. Это смещение выступает в качестве индекса в одномерном массиве ELEMENT. Программа store (помещающая значение V в позицию массива с индексами SI, S2 и S3) может быть записана следующим образом: 51
1000 'подпрограмма store 1010 'проверка значимости индексов 1020 IF SKBOUNDS(LO,l) OR Sl>BOUNDS(HI,l) OR S2<BOUNDS(LO,2) OR S2>BOUNDS(HI,2) OR S3<BOUNDS(LO,3) OR S3>BOUNDS(HI,3) THEN PRINT «НЕВЕРНЫЙ ИНДЕКС»: STOP 1030 OFFST= (S3-BOUNDS(LO,3))* (BOUNDS (HI,2) - BOUNDS (LO,2) + l) 1040 OFFST= OFFST+ (S2 - BOUNDS (LO,2))) * (BOUNDS(HI,l)-BOUNDS(LO,l)+l) 1050 OFFST=OFFST+(Sl-BOUNDS(LO,l)) 1060 ELEMENT (OFFST)=V 1070 RETURN 1080 'конец подпрограммы store Подпрограмма extract (которая присваивает переменной EXTRACT значение элемента массива в позициях SI, S2 и S3) может быть записана следующим образом: 2000 'подпрограмма extract 2010 'проверка значимости индексов 2020 IF SKBOUNDS(LO,l) OR Sl>BOUNDS(HI,l) OR S2<BOUNDS(LO,2) OR S2>BOUNDS(HI,2) OR S3<BOUNDS(LO,3) OR S3>BOUNDS(HI,3) THEN PRINT «НЕВЕРНЫЙ ИНДЕКС»: STOP 2030 OFFST= (S3-BOUNDS(LO,3)) * (BOUNDS (HI,2) - BOUNDS (LO,2) + l) 2040 OFFST= (OFFST+(S2-BOUNDS(LO,2)))* (BOUNDS(HI,l) -BOUNDS(LO,l)+l) 2050 OFFST=OFFST+ (SI- BOUNDS (LO, 1)) 2060 EXTRACT= ELEMENT (OFFST) 2070 RETURN 2080 'конец подпрограммы extract Для вычисления смещения заданного элемента в многомерном массиве в этих подпрограммах используются формулы, полученные в разд. 1.2. В упражнениях предлагается обобщить эти программы таким образом, чтобы число измерений массива также являлось входным параметром подпрограммы. Упражнения 1. Обобщите приведенные в тексте программы store и extract таким образом, чтобы они воспринимали четыре входные переменные — A, N, SUB и V, где А — агрегат данных, представляющий многомерный массив размерностью N, SUB — одномерный массив размера N такой, что SUB(I) равен размеру 1-го измерения, а V есть значение, извлекаемое или помещаемое в массив. 2. Комплексным числом называется такое число, которое состоит из мнимой и действительной частей и удовлетворяет следующим требованиям: если число cl имеет действительную и мнимую части соответственно rl и И, а число с2 имеет действительную и мнимую части г2 и i2, то (а) Сумма cl и с2 имеет действительную часть (rl+r2) и мнимую часть (il+i2). (б) Разность cl и с2 имеет действительную часть (rl—г2) и мнимую часть (И—i2). (в) Произведение cl и с2 имеет действительную часть (rl*r2—il*i2) и мнимую часть (rl»i2+r2*il). 52
Реализуйте комплексные числа, определив агрегат данных с мнимой и действительной частями, и напишите программы сложения, вычитания и умножения таких комплексных чисел. 3. Числом с фиксированной запятой называется число, количество цифр которого слева и справа от десятичной запятой не изменяется. Предположим, что число с фиксированной запятой имеет пять десятичных цифр и представлено следующим образом: 10 DEFINT F 20 DIM FIXEDEC(2) 30 LEFT=1 40 RIGHT=2 где FIXEDEC(LEFT) и FIXEDEC (RIGHT) представляют собой соответственно цифры слева и справа от десятичной запятой. Например, число 1,00002, представляется через FIXEDEC(l), равный 1, и FIXEDEC(2), равный 2, а число 1,2 представляется через FIXEDEC (1), равный 1, и FIXEDEC (2), равный 20,000. (а) Напишите программу для чтения числа с фиксированной точкой из оператора DATA и создайте агрегат данных, представляющий это число. (б) Напишите три подпрограммы, вводящие два таких агрегата данных и устанавливающие значение третьего агрегата данных равным сумме, разности и произведению этих двух агрегатов. 4. Используя приведенное в тексте рациональное число, напишите программы сложения, вычитания и деления таких чисел. 5. В тексте имеется подпрограмма equal, которая проверяет два рациональных числа R1 и R2 на равенство между собой, сначала приводя эти числа к несократимым дробям, а затем сравнивая их. Альтернативным способом может быть умножение Rl (NMRTR) на R2(DNMNTR) и R2(NMRTR) на Rl(DNMNTR), а затем проверка полученных произведений на равенство между собой. Напишите подпрограмму equal2, реализующую данный алгоритм. Какой из двух описанных методов предпочтительнее?
Глава 2 Программирование на языке Бейсик 2.1. БЕЙСИК ДЛЯ МИКРОЭВМ Язык Бейсик был разработан в Дартмутском колледже в 1972 г. Причиной его создания явилась необходимость обучения студентов программированию на достаточно простом языке. Обладая изначально ограниченными возможностями, Бейсик стал одним из наиболее распространенных языков программирования в мире. К моменту появления в 1975 г. микроЭВМ Бейсик уже был мощным языком, доступным почти всем пользователям персональных микроЭВМ. Несмотря на свои значительно выросшие возможности, Бейсик по-прежнему остался простым языком, легким при изучении. Интерпретаторы и компиляторы Как и все языки высокого уровня, Бейсик не может выполняться непосредственно на ЭВМ. ЭВМ может «понимать» инструкции только в том случае, если они представлены на машинном языке. Машинный язык — это язык самого низкого уровня, состоящий только из строк единиц и нулей, которые представляют операции, выполняемые данной вычислительной машиной. На большинстве микроЭВМ операторы языка Бейсик переводятся в машинные инструкции с помощью интерпретатора. Интерпретатор — это программа, которая анализирует строку из языка Бейсик и выдает директивы ЭВМ для выполнения указанных операций. Подобная декодировка осуществляется построчно. Альтернативный способ предполагает использование таких версий языка Бейсик, в которых имеется компилятор, переводящий программу на Бейсике в язык данной машины. В отличие от интерпретатора компилятор сначала транслирует всю программу, называемую исходной, в программу на машинном языке, называемую объектной. После трансляции всей программы полученная объектная программа может быть выполнена на ЭВМ. (Надо отметить, что большинство интерпретаторов работают также в два этапа: на первом этапе — этапе трансляции — исходная программа на Бейсике переводится в представление на промежуточном языке, на втором этапе — этапе интерпретации — происходит поочередное выполнение 54
операторов промежуточного языка. Этот промежуточный язык больше похож на язык Бейсик, чем на машинный язык, поэтому весь процесс правильнее называть интерпретацией, а не компиляцией.) У каждого из описанных выше способов имеются свои преимущества. Интерпретируемые языки транслируют и выполняют строки программы поочередно, позволяя тем самым пользователю модифицировать и перезапускать программу без необходимости повторной трансляции (или компиляции) ее целиком. Такие интерпретаторы языка Бейсик популярны среди студентов и программистов, занимающихся разработкой программ. Такой способ позволяет также останавливать программу в любой точке, анализировать или модифицировать любые ее переменные и возобновлять выполнение в этой же точке. К сожалению, подобная гибкость обходится дорогой ценой. Поскольку каждая строка выполняется по мере ее нахождения и обработки интерпретатором, строка, выполняемая внутри цикла, будет транслироваться многократно. По этой причине интерпретаторы языка Бейсик работают относительно медленно. С другой стороны, компилятор транслирует каждый оператор языка только один раз вне зависимости от того, сколько раз этот оператор выполняется в программе, что ведет к повышению эффективности работы программы, правда, в ущерб указанной выше гибкости. Имеется и другое соображение. Откомпилированные программы имеют большие размеры и требуют большего объема памяти, чем соответствующие интерпретируемые программы. Как уже говорилось, язык Бейсик прогрессировал от своего начального вида до языка, доступного на большинстве персональных ЭВМ. В процессе его эволюции вопросам стандартизации языка уделялось мало внимания, поскольку каждый разработчик старался улучшить язык таким образом, чтобы использовать его наиболее эффективно на конкретной аппаратной реализации вычислительной машины. К сожалению, это привело к появлению Вавилонской башни различных версий языка Бейсик. В программах, приводимых в оставшейся части книги, мы приложили значительные усилия, чтобы избежать использования выражений, сильно зависящих от конкретной аппаратной реализации или от конкретной версии языка Бейсик. С другой стороны, читатель имеет возможность переписать представленные здесь программы с учетом конкретных особенностей используемой им версии языка Бейсик. В последующих разделах мы рассмотрим некоторые элементарные концепции языка Бейсик, которые будут использованы в последующих главах. Данный материал не служит введением в язык Бейсик или в программирование. Скорее это попытка ввести некоторую унифицированную структуру, позволяющую создавать расширенные программные структуры. 55
Предполагается, что если приводимые рассуждения покажутся читателю непонятными, то он обратится к руководству по языку Бейсик, прилагаемому к используемой им персональной ЭВМ, и к имеющимся учебным пособиям по данному языку. Строки, операторы и комментарии Программа на языке Бейсик состоит из одной или нескольких строк. Каждая строка начинается с номера, задающего порядок, в котором данная строка должна выполняться (или должны выполняться операторы в этой строке). Номера строк должны быть целыми числами в интервале от 0 до 64000 (в зависимости от используемой версии языка). Оператор не может занимать более одной строки, однако строка может содержать несколько операторов. Если в строке содержится несколько операторов, то они должны быть отделены друг от друга двоеточием. Строка в языке Бейсик должна оканчиваться нажатием символа возврата каретки (или клавиши ENTER) независимо от того, занимает ли она физически больше чем одну строку на экране монитора или другого устройства отображения. Существует максимальное число символов, которое может появиться в строке (обычно 255). Например, 30 PRINT 5+3 : PRINT «ПОКА» 10 REM эта строка будет напечатана второй 20 PRINT «ПРИВЕТ» будет обработана в следующей последовательности: 10 REM эта строка будет напечатана второй 20 PRINT «ПРИВЕТ» 30 PRINT 5+3 : PRINT «ПОКА» и выдаст следующий результат: ПРИВЕТ 8 ПОКА Обратите внимание в приведенной программе на строку, начинающуюся с ключевого слова REM. Этот оператор называется оператором комментария и транслятором игнорируется. Комментарии служат для документирования программы или ее частей. О важности комментариев мы поговорим позднее. Во многих версиях языка Бейсик комментарий может также начинаться с символа одиночной кавычки ('). Комментарий должен всегда быть последним или единственным оператором в строке, поскольку после обнаружения оператора комментария оставшаяся часть строки при трансляции игнорируется. 56
Переменные в Бейсике Переменные в языке Бейсик имеют вид строки из одного или нескольких алфавитно-цифровых символов, причем первым символом должна быть буква. Несмотря на то что большинство версий языка Бейсик допускает использование имен переменных любой длины, в некоторых реализациях (например, фирмы Applesoft и TRS-80 Level II) значащими являются только первые два символа. Это означает, что два имени переменных, два первых символа у которых совпадают, указывают на одну и ту же переменную. Следовательно, SUM, SUB и SU в этом смысле идентичны. Кроме того, каждая версия Бейсика имеет свой список «зарезервированных» слов, которые используются для служебных целей и не могут быть использованы в качестве имен переменных (например, слово STOCK не может быть использовано в качестве имени переменной, поскольку в нем использовано зарезервированное слово ТО). В данной книге мы будем придерживаться этих ограничений. Это означает, что в приводимых программах не используются переменные, у которых первые две буквы имени совпадают, а также переменные, в имена которых входят зарезервированные слова. По этим причинам задача использования осмысленных имен переменных не всегда разрешается наилучшим образом. Примитивные типы данных Каждый машинный язык поддерживает набор базовых типов данных. Эти типы могут быть базовыми по отношению либо к самой вычислительной машине, на которой выполняются программы, либо к компилятору, либо к интерпретатору, транслирующему эти программы. Например, для большинства версий языка Бейсик базовыми типами данных являются целые и действительные числа, а также строки символов. К целым числам относятся числа (не содержащие десятичной точки), принадлежащие некоторому заданному диапазону (часто в диапазоне от —32768 до 32767). Положительные или отрицательные числа, не являющиеся целыми, называются действительными числами. Эти числа могут быть представлены либо в виде чисел с плавающей запятой, либо в виде чисел с фиксированной запятой. В формате для фиксированной запятой десятичная запятая располагается в числе в соответствующей позиции. Например, 4.2, 0.003 и —452.6378 представляют собой действительные числа в формате с фиксированной запятой. Для очень больших и очень малых чисел удобно использовать представление с плавающей запятой. Число с плавающей запятой состоит из целого или действительного числа, которому может предшествовать знак, и буквы Е, за которой также следует целое число (представляющее собой показатель 57
степени числа 10), которому также может предшествовать знак. Например, 1.86Е05 представляет собой число 1,86· Ю5, что есть 186 000, а —4.056Е—03 представляет собой числа —4,056· 10~3, что есть —0,004056. На большинстве персональных ЭВМ порядок должен быть в диапазоне от —38 до +38. Число цифр в числе, которое может поддерживать данная ЭВМ, называется точностью этого числа. Большинство версий Бейсика имеет точность от 7 знаков (TRS-80, Бейсик фирмы IBM и Бейсик-80 фирмы Microsoft) до 10 знаков (фирмы Applesoft). Такие действительные числа называются действительными числами с одинарной точностью. Некоторые версии Бейсика могут работать с числами, имеющими 16 знаков. Такие числа называются действительными числами с двойной точностью и отличаются в своем обозначении наличием буквы D вместо буквы Е. Некоторые версии Бейсика работают также с числами в шестнадцатеричном и восьмеричном представлениях. Символьные строки представлены в Бейсике последовательностью символов, заключенных в двойные кавычки. Они могут содержать до 255 алфавитно-цифровых символов. Бейсик для персональной ЭВМ TRS-80 требует для работы с символьными строками отдельно выделяемой памяти. В начале работы с Бейсиком в системе TRS-80 производится выделение 50 байт для хранения символьных строк. Для выделения дополнительного пространства под символьные строки используется оператор CLEAR η (где η есть неотрицательное целое число), по которому для хранения символьных строк резервируется π байт. В других версиях языка Бейсик (фирмы Applesoft и Бейсик-80 фирмы Microsoft) пространство под символьные строки выделяется автоматически. Оператор CLEAR в других версиях языка Бейсик работает несколько иначе, поэтому читатель должен уточнить его функции применительно к конкретной версии Бейсика на конкретной ЭВМ. Тип переменной (т. е. тот тип данных, который может быть присвоен данной переменной) может быть указан несколькими способами. Одним из способов является подсоединение к имени переменной символа объявления типа. Обычно для указания типа переменной используется следующий набор символов: Тип Символ объявления типа Целочисленный % Действительный с обычной точностью !(или по умолчанию) Действительный с двои- # ной точностью Строковый $ 58
Например, в Бейсике фирмы Applesoft и Бейсике фирмы IBM F# представляет собой действительное число с двойной точностью, а А$ представляет собой символьную строку. Отметим, что А$ и А! при таких соглашениях обозначают различные переменные. В некоторых версиях Бейсика допускается объявление типа переменной: Тип Оператор DEF Целочисленный DEFINT Действительный с обычной точностью DEFSNG Действительный с двойной DEFDBL точностью Строковый DEFSTR С помощью операторов DEF имеется возможность определять и идентифицировать типы переменных по их первой букве. Например, если программист записал на языке Бейсик следующие строки: 10 DEFINTX,Y 20 DEFDBL Α,Β то любая переменная, начинающаяся с букв X или Υ, будет рассматриваться как целое число, а любая переменная, начинающаяся с букв А или В, — как действительное число с двойной точностью. Таким образом, числа в ячейках, отведенных под XVAR и YVAR, будут рассматриваться как целые, а числа в ячейках, отведенных под AVAR и BVAR, — как действительные. Языковый процессор (интерпретатор или компилятор), ответственный за трансляцию программы на языке Бейсик на машинный язык, переведет знак « + » в операторе X=X + Y в целочисленное сложение, а знак «+» в операторе А=А+В в операцию для сложения действительных чисел. Оператор « + » называется родовым, поскольку он имеет несколько различных значений в зависимости от контекста. Транслятор освобождает программиста от необходимости указания требуемого в данном контексте типа операции сложения, анализируя типы операндов и подставляя соответствующую операцию. В некоторых диалектах языка Бейсик (например, фирмы Applesoft) «тип» переменной может быть указан только при помощи символов объявления типа. Другие версии языка Бейсик (например, TRS-80 Level II, Бейсик фирмы IBM и Бей- 59
сик-80 фирмы Microsoft) позволяют указывать спецификацию типа либо с помощи символов объявления, либо с помощью операторов DEF. Читателю рекомендуется уточнить, какие способы объявления допускаются в имеющейся у него версии языка. Псевдокоды В гл. 1 мы рассмотрели некоторые причины, заставившие нас подробно остановиться на изучении способов реализации и определения некоторых более сложных структур данных. Одной из причин является возможность более простого описания решения проблемы в терминах более сложных структур, чем те, которые доступны в некоторой конкретной версии языка (например, языка Бейсик). Следовательно, если мы можем реализовать при помощи используемого языка некоторую более сложную структуру данных, то проблема, описанная в терминах таких структур, может быть немедленно решена на имеющемся оборудовании. Мы фактически расширили имеющийся арсенал доступных типов структур данных. Аналогичным образом возможно расширение управляющих структур языка за границы, поддерживаемые семантикой этого языка. Все языки высокого уровня снабжены набором таких управляющих структур. Эти управляющие структуры в противоположность простым операторам управления данными управляют последовательностью, в которой происходит выполнение операторов. Например, 10 READX 20 PRINT Y 30 А = В + С являются примерами операторов управления данными, а 40 GOTO 1000 есть пример оператора управления. Поскольку решения рассматриваемых в данной книге проблем довольно сложные, представляется весьма полезным иметь набор доступных управляющих структур высокого уровня, позволяющих описывать эти решения. Такие решения часто легко можно выразить в терминах таких сложных управляющих структур. Хотя реализация их решений возможна и при помощи более ограниченного набора структур, получаемые выражения зачастую чересчур громоздки и иногда приводят к проблемам обнаружения и локализации ошибок. Следовательно, желательно иметь набор доступных управляющих структур высокого уровня. Много новых версий языка Бейсик для микроЭВМ дополнены сложными управляющими структурами, имеющимися в бо- 60
лее мощных языках программирования высокого уровня. В других версиях такие возможности отсутствуют. Однако даже в языках, поддерживающих более сложные структуры, имеется несогласованность в их синтаксических выражениях и даже в их семантическом значении. Следовательно, ставя своей целью обеспечение независимости от конкретной версии языка Бейсик и в то же время избегая ограничений, налагаемых набором доступных управляющих структур, мы остановимся на промежуточном варианте. Представим проблему на некотором промежуточном описательном языке, служащем мостом между языком Бейсик и английским языком. Назовем такой промежуточный язык псевдокодом. Этот псевдокод имеет свои преимущества в том, что его можно читать подобно английскому языку, и в том, что он схож с наиболее мощными версиями языка Бейсик и другими языками программирования высокого уровня. По этой причине он весьма полезен как мощное средство при описании решений проблем. С другой стороны, поскольку псевдокод не состоит только из одних операторов языка Бейсик, он не может быть введен для своей обработки непосредственно в машину. Задача, записанная на псевдокоде, должна быть сначала переведена в более простые операторы Бейсика. В последующих разделах мы рассмотрим несколько различных видов управляющих структур, написанных с использованием псевдокода. Эти стандартные управляющие структуры будут использованы нами для решения различных задач на протяжении всей книги. В некоторых случаях мы будем приводить решения как на псевдокоде, так и на языке Бейсик; в других случаях будем приводить решение, написанное только на псевдокоде или только на языке Бейсик. Для отличия псевдокода от программ на языке Бейсик последние набраны прописными буквами, а программы на псевдокоде — строчными. Ниже приведен пример программы, написанной на языке Бейсик: 10 Х=А+В 20 IFX>100THENZ=1 30 FOR 1=1 ТО X 40 S=S+I 50 NEXT I Эта же программа на псевдокоде будет иметь следующий вид: х=а+ь if х>100 then z=l endif for i=l to χ s=s+i next i Отметим, что в псевдокоде номера операторов опущены и что псевдокод мало отличается от языка Бейсик (в данном примере оператором endif и расположением then). Эти отличия и пояснения к ним приводятся ниже. Решения задач, написан- 61
аые на псевдокоде, часто называются алгоритмами, и хотя они не являются программами, лепосредственно выполняемыми на ЭВМ, тем не менее они являются точными описаниями процесса, происходящего при выполнении такой задачи на ЭВМ. Для каждой из структур, написанных на псевдокоде и обсуждающихся ниже, мы будем давать саму структуру, пример •ее использования и метод перевода псевдокода на язык Бейсик. Как мы уже говорили, для возможности выполнения программ под разными версиями языка Бейсик мы ограничились •его минимальными возможностями. Пользователи, разумеется, могут переводить эти управляющие структуры в конструкции •с менее жесткими ограничениями, если это, конечно, позволяют имеющаяся в их распоряжении микроЭВМ и версия языка Бейсик. Читателям также рекомендуется исследовать такие •возможности в качестве упражнения. Управляющий лоток Мы начали рассматривать .несколько структур программ и .их представление в языке Бейсик. Выполняемые операторы языка Бейсик делятся на две базовые категории — простые операторы, выполняющие одну операцию или набор операций, и составные операторы, объединяющие несколько операторов •в единую управляющую структуру. Ниже приведено несколько простых операторов: •10 INPUT X 20 Х=Х + 1 .30 PRINT «X= »:Х Каждый из приведенных выше операторов является «простым» в том смысле, что он выполняет одну задачу. Задача может состоять из двух частей .{например, оператор «Х= »; X, который печатает два элемента), однако каждая такая задача в целом представляет собой одну операцию. Такие операторы выполняются последовательно один за другим в порядке возрастания связанных с ними номеров операторов. Примеры простых операторов на псевдокоде получаются прямой трансляцией с языка Бейсик. Например, последовательность операторов на псевдокоде для рассмотренного примера будет иметь следующий вид: input χ х=х+1 print «х= »; χ Ко второй базовой категории выполняемых операторов языка Бейсик относятся операторы, управляющие выполнением других операторов Бейсика: 62
10 IF A=B THEN PRINT X 10 FOR 1=1 TO X 20 PRINT I 30 NEXT I Такие конструкции определяют последовательность выполнения входящих в них простых операций. В таких конструкциях простые операторы не обязательно выполняются в порядке их появления. Последовательность их выполнения не всегда определяется анализом кодовой последовательности; она зависит от условий (например, А=В,1>Х)<, которые проверяются в процессе выполнения программы. Последовательность, в которой происходит выполнение операторов, называется управляющим потоком программы. Мы рассмотрим несколько управляющих потоков, их представление в псевдокоде и способы реализации на языке Бейсик. Последовательный поток «Последовательный поток» предполагает, что операторы выполняются последовательно в порядке их появления. Последовательный поток можно получить, расположив операторы в том порядке, в каком они должны быть выполнены (с номерами строк, расположенными в возрастающей последовательности). Как мы увидим при более подробном рассмотрении этого вопроса в настоящей главе, программу легче прочесть и понять, если каждый оператор располагается на отдельной строке и все операторы начинаются с одного и того же столбца. Если визуальный просмотр последовательности операторов не дает четкого представления о выполняемой ими функции, то в программу необходимо включить комментарии, объясняющие назначение и результат выполнения каждой группы операторов. Условный поток Условный поток «почти» совпадает с последовательным, поскольку, если операторы и выполняются, то только в порядке их появления. Однако в некоторых случаях группа операторов' может либо выполняться, либо не выполняться. Примером такого типа потока может служить оператор IF следующего вида: IF условие THEN оператор Если условие выполняется, то оператор также выполняется; если условие не выполняется, то оператор не выполняется. В любом случае следующий выполняемый оператор есть оператор, следующий за конструкцией IF THEN, если, конечно, такой оператор имеется. На псевдокоде такой тип условного потока будет иметь следующий вид: 63
if условие then оператор endif Необходимость использования endif становится очевидной, если рассмотреть возможность включения в предложение then нескольких операторов. В большинстве версий языка Бейсик для микроЭВМ для отделения операторов внутри оператора THEN используется двоеточие. Например, можно написать 100 IFX>0THENX = X — 1 : PRINTX,Xf2 : COUNT = COUNT+1 На псевдокоде мы можем располагать каждый оператор на отдельной строке: if x>0 then х=х—1 print x,xf2 count=count+l endif Ключевое слово endif указывает на то, что все операторы между ключевым словом then и ключевым словом endif выполняются только при истинности логического условия (Х>0). Иногда операторы, входящие в предложение THEN, либо слишком многочисленны, либо слишком велики, чтобы уместиться на одной строке программы на Бейсике. В этом случае мы должны каким-то образом разрешить эту проблему, написав некоторое подобие следующих действий: 100 IF X< = 0 THEN GOTO 150 110 'операторы 120—140 выполняются только, если Х>0 120 Х=Х-1 130 PRINT X,Xf2 140 COUNT=COUNT+l 150 ... Это очень неудобно. В псевдокоде мы не ограничены длиной строки, поскольку каждый компонент может располагаться на отдельной строке, а весь список завершается ключевым словом endif. Это делает программу легко читаемой и понимаемой, а также аккуратной. [Теперь нам необходимо сказать несколько слов о представлении в этой книге программ, написанных на языке Бейсик. В общем случае большинство версий языка Бейсик накладывают ограничения на количество символов в строке. Мы полагаем, что этот предел равен 240, хотя он может быть равен 80. Большинство устройств отображения (например, дисплеи или печатающие устройства) не допускают работу с физическими строками такой длины, поскольку имеют длину строки, равную 80 или 40. Следовательно, одна строка на языке Бейсик может занимать несколько строк на конкретном устройст- 64
ве. По этой причине строка на языке Бейсик может иметь несколько строк продолжения, как, например, 100 IF X<0 THEN PRINT «X ОТРИЦАТЕЛЕН»: Y=X+Af2: A-A+l: X=X+Y: PRINT «Χ- »; X,«A= »; A,«Y= »; Υ При использовании в нашей книге такого оператора мы стараемся привести его в наиболее понятном виде, используя для этого отступы. На практике такой прием не всегда применим, поскольку различные устройства отображения имеют различные физические длины строк. Поэтому число пробелов в строке и точек разделения строк, подходящее для одного устройства, может оказаться неприемлемым для другого. В общем случае мы не используем для строки на языке Бейсик более трех строк текста, предполагая, что строка языка допускает длину до 240 символов, а устройство отображения имеет длину строки, равную 80. В тех случаях, когда необходимо использовать более трех строк, мы разбиваем конструкцию языка на несколько строк, используя для этого оператор GOTO, как было показано ранее. Разумеется, для псевдокода такие ограничения отсутствуют, и предложение then может содержать любое число операторов. Это одна из причин, делающая псевдокод весьма удобным средством.] Другая конструкция условного потока (которая доступна в некоторых версиях Бейсика) может быть записана на псевдокоде следующим образом: if условие then оператор1 else оператор2 endif или (для некоторых версий Бейсика) 100 IF условие THEN оператор 1 ELSE оператор2 Если условие выполняется, то выполняется оператор 1, а не оператора. Если условие не выполняется, то выполняется опе- ратор2, а оператор! не выполняется. После выполнения оператора 1 или оператора2 происходит выполнение оператора, следующего сразу же за конструкцией IF—THEN—ELSE. Бейсик уровня II фирмы TRS-80 и Бейсик персональной ЭВМ IBM PC допускают использование предложения ELSE. Бейсик фирмы Applesoft не имеет такой возможности. Отметим, что, поскольку оператор ELSE не имеет своей собственной пронумерованной строки, ограничение на длину строки относится ко всему оператору IF, включая ELSE и THEN. По этой причине с целью резервирования разрешенного пространства оператор IF пишется с отступом: 100 IF условие THEN оператор 1 ELSE оператор2 65
или даже 100 IF условие THEN оператор 1 ELSE оператор2 Разумеется, оба предложения THEN и ELSE могут содержать несколько операторов, например 100 IF Х>0 THEN PRINT «X ПОЛОЖИТЕЛЕН»: Х^Х+\ ELSE PRINT «X ПОЛОЖИТЕЛЕН»: Χ=Χ-1 На псевдокоде это будет записано следующим образом: if x>0 then print «χ положителен» х=х+1 else print «x отрицателен» χ=χ—1 endif В тех версиях Бейсика, которые не поддерживают оператор ELSE, или в тех случаях, когда предложение ELSE делает строку слишком длинной, оператор может быть реализован с использованием GOTO: 100 IF X>0 THEN PRINT «X ПОЛОЖИТЕЛЕН»: Х=Х+1: GOTO 140 110 'иначе выполняются операторы 120—130 12Q . PRINT «X ОТРИЦАТЕЛЕН» 130 Х=Х-1 140 или если предложение THEN также слишком длинное, то 100 IF X<=0 THEN GOTO 150 110 'для THEN выполняются операторы 120—130 120 PRINT «X ПОЛОЖИТЕЛЕН» 130 Х=Х+1 140 GOTO 180: 'обход предложения THEN 150 'для ELSE выполнение операторов 160—170 160 PRINT «X ОТРИЦАТЕЛЕН» 170 Х=Х-1 180 В оставшейся части книги мы будем считать, что наша версия допускает использование оператора ELSE. Если в вашей версии языка Бейсик использование предложения ELSE не допускается, то вы должны воспользоваться описанной выше техникой. При программировании очень часто возникает необходимость использования вложенных операторов IF. Это означает, что в результате некоторой проверки необходимо выполнить еще одну проверку. Как мы увидим, это легко реализуется на псевдокоде, однако достаточно затруднительно на языке Бейсик. Рассмотрим для примера программу управления банковским автоматом: input type, amount if type=«вклад» then balance=balance+amount print «вклад принят в размере = »; amount 66
print «благодарим вас за вклад денег в наш банк> else if type=«снятие» then if amount <—balance then balance=balance—amount print «сумма снятия = >, amount else print «недостаточная сумма на счете» endif else print «неопознанная операция» endif endif Отметим, что благодаря отступам и структурированию такой псевдокод легко читаем и понимаем. В большинстве версий языка Бейсик использование вложенных циклов запрещено, т. е. запрещена следующая конструкция: 100 IFA>10THENIFB>10THENX=X+1 Однако ограничение на длину предложения в Бейсике делает .непрактичным использование вложенных операторов IF в ситуациях, подобных описанной выше. По этой причине мы обычно реализуем вложенные операторы IF через операторы GOTO. Версия данного алгоритма на языке Бейсик будет иметь следующий вид: , 100 DEFSTR Τ ПО INPUT TYPE, AMOUNT 120 IF TYPE < > «ВКЛАД» THEN GOTO 170 130 BALANCE=ВALANCE+AMOUNT 140 PRINT «ВКЛАД ПРИНЯТ В РАЗМЕРЕ^ »; AMOUNT 150 PRINT «БЛАГОДАРИМ ВАС ЗА ВКЛАД ДЕНЕГ В НАШ БАНК» 160 GOTO 250 170 'предложение else 180 IF TYPE < > «СНЯТИЕ» THEN GOTO 220 190 IF AMOUNT< = BALANCE THEN BALANCE-BALANCE-AMOUNT PRINT «СУММА СНЯТИЯ = », AMOUNT ELSE PRINT «НЕДОСТАТОЧНАЯ СУММА НА СЧЕТЕ» 200 'endif 210 GOTO 240 220 'оператор else 230 PRINT «НЕОПОЗНАННАЯ ОПЕРАЦИЯ» 240 'endif 250 'endif 260 END Отметим, что в программу не обязательно включать комментарий 'endif. В программе достаточно указать следующий за ним оператор. Хотя комментарий 'endif иногда полезно указывать из соображения построения легко читаемой программы, мы, как правило, не будем его указывать. Надо отметить, что при решении многих задач можно легко обойтись без использования вложенных операторов IF. Например, предположим, что нам необходимо присвоить переменной л значение 10 при условии, что значение переменной А нахо- 67
дится в диапазоне от 100 до 200. Одним из вариантов может быть, например, такой: 100 IF А> = 100 THEN IF A< =200 THEN X= 10 Такая кодировка довольно сложна. При одиночных проверках использовать вложенный оператор IF нет необходимости. Можно воспользоваться составной проверкой: 100 IF (A> = 100) AND (A<=200) THEN Х = 10 Вторая версия более четко отражает задачу. В общем случае операторы, использующие составные проверки, легче чи-, тать (если эти проверки связаны одними и теми же вложенными логическими операторами), чем разбираться во вложенных управляющих структурах. Составные проверки имеют один общий недостаток. Напри-N мер, предположим, что А есть массив, объявленный следующим, образом: 10 DIMA(10) и при этом требуется определить, не выходит ли индекс I за, границы массива и не является ли А(1) отрицательным числом. Начинающий программист, возможно, написал бы следующую программу: 100 IF (I> = 1) AND (1< = 10) AND (A(I)<0) THEN... Однако такая строка неправильна, поскольку в том случае, если индекс I выходит за границы, ссылка А(1) неопределенна. Предположим, например, что I равно 12. Тогда выражение (1> = 1) принимает значение «истина», а выражение (К = 10—значение «ложь». Но вычисление выражения (А(1)<0) приводит к возникновению ошибки, поскольку А(1) в этом случае не существует. Правильно записанная строка будет иметь следующий вид: 100 IF (I> = 1) AND (К = 10) THEN IF A(I) <0THEN . . . Надо отметить, что в некоторых версиях языка Бейсик и некоторых других языках программирования первый оператор выполняется правильно. В последовательности проверок, объединенных операций «AND», невыполнение условия истинности1 хотя бы для одного операнда делает необязательной проверку для остальных. Аналогичным образом, если хотя бы один компонент составной проверки из строки операндов, связанных операцией «OR» принимает значение «истина», то все выражение считается «истинным» и дальнейшая проверка не производится. При написании программ для таких версий языка Бейсик правильно составленная последовательность проверок делает операторы, подобные приведенным выше, выполнимы- 68
ми. Однако в других версиях использование таких проверок приведет к возникновению ошибки. Выбор между логическими проверками и вложенными операторами IF зависит от каждого конкретного случая. Однако в рамках корректно составленной программы необходимо стремиться к созданию наиболее понятной и легко читаемой версии. Логические данные Отметим, что все формы оператора IF предполагают наличие некоторого условия. Это условие использует операторы сравнения для сравнения двух значений. Например, в выражении А + В>7 операция сравнения «>» (больше) сравнивает значение суммы А + В с числом 7, а в выражении χ< = Β + 12*4 операция сравнения «<=» (меньше или равно) сравнивает значение переменной X и выражение В+ 12*4. Результатом операции сравнения является это логическое значение, т. е. либо «истина», либо «ложь». Оператор IF анализирует логическое значение. Если оно истинно, то выполняется предложение THEN. Если оно ложно, то выполняется предложение ELSE или следующий оператор. Как мы уже говорили в гл. 1, каждый элемент данных должен быть представлен некоторой битовой последовательностью. Это требование сохраняется и для логических значений. В большинстве версий языка Бейсик для представления логического значения используются целые числа. В некоторых версиях целое число 0 обозначает логическое значение «ложь», а целое число 1 — «истина». В других версиях для значения «ложь» используется 0, а для значения «истина» используется —1 (в дополнительном коде это число состоит из всех единиц). Имеется ряд версий языка Бейсик, использующих другие представления. (В некоторых версиях допускается явное задание программистом представлений для значений логических пере- менных при помощи операторов, например PRINT 1 = 1 или PRINT 1=0.) Однако, как уже отмечалось в гл. 1, конкретный вид^представления обычно не играет существенной роли. Иногда бывает полезно присвоить переменной логическое значение. Некоторые версии языка Бейсик, использующие для представления логических значений целые числа, позволяют использовать следующий оператор: ЮО Х=А>В 69
по которому переменной X присваивается целое число, используемое для представления логического значения «истина», если А больше В, и целое число, используемое для представления логического значения «ложь», если А не больше В. Однако в большинстве версий такие переменные не могут быть использованы в составе условного выражения в операторе IF. Например, 110 IFXTHENB = B + 1 Если бы мы знали, что для представления логического значения «истина» используется 1, мы могли бы написать ПО IFX = 1THENB = B + 1 Однако это довольно непонятная запись, которая может к тому же оказаться некорректной применительно к другим версиям языка Бейсик, использующим другое представление. По этой причине полезно использовать описанную ниже программную реализацию логических переменных, Каждая программа, работающая с логическими переменными, должна начинаться следующими операторами: 10 TRUE = 1 20 FALSE=0 Переменные TRUE и FALSE будут в дальнейшем рассматриваться как константы (т. е. они не будут появляться в левой части оператора присваивания или в операторах .READ или INPUT). После того как эти «константы» были инициализированы, они могут присваиваться логическим переменным и анализироваться как логические переменные. Например, 50 IF At2+Bf2=Ct2 THEN RIGHT=TRUE: 'правильный треугольник 60 'другие операторы 100 IF RIGHT=TRUE THEN ... Такая конструкция позволяет нам задавать вопросы о том, является ли рассматриваемый треугольник правильным или нет, без проведения дублирующих проверок. Мы можем скомбинировать логические операции следующим образом. Предположим, что имеется следующая программа: • 10 ABIG=FALSE 20 IF A>B THEN ABIG=TRUE 30 CBIG=FALSE 40 IF C>D THEN CBIG=TRUE Тогда можно написать 50 IF (ABIG=TRUE) OR (CBIG = FALSE) THEN ... или 70
60 IF (ABIG=TRUE) AND (CBIG=FALSE) THEN... В дальнейшем для представления логических переменных мы будем пользоваться таким представлением. Повторяющийся поток Другой базовой управляющей структурой является повторяющийся поток, при котором оператор или группа операторов многократно выполняется до тех пор, пока не будет выполнено некоторое завершающее условие. Структура такого типа называется циклом. Вычисления для большинства программ (идет ли речь о вычислении числа π с точностью до 500 значащих цифр или подсчете и распечатке заработной платы для нескольких тысяч служащих) обычно сводятся к многократно повторяющемуся процессу. Большинство языков программирования высокого уровня поддерживает некоторую базовую структуру управления циклом. Рассмотрим базовые структуры циклов, используемые в программировании, и их реализацию на языке Бейсик. Наиболее общей конструкцией является цикл, который выполняется до тех пор, пока удовлетворяется некоторое условие. На псевдокоде он имеет следующий вид: while условие do 'тело цикла endwhile При любом очередном выходе на оператор while условие проверяется. Если условие удовлетворяется, .то выполняются операторы в теле цикла. При выходе на оператор endwhile происходит возврат к оператору while, и процесс повторяется. Каждое очередное выполнение операторов в^теле цикла называется итерацией. Число итераций в цикле может быть равно 0, 1, 2 или 5000. Тело цикла выполняется до тех пор, пока выполняется (истинно) условие в предложении while. В некоторый момент условие принимает значение «ложь». При этом управление передается к оператору, немедленно следующему за оператором endwhile. (Разумеется, ответственность за то, чтобы цикл не выполнялся бесконечно, возлагается на программиста.) Например, приводимый ниже цикл печатает все неотрицательные степени двойки, меньшие чем tp: power =1 while power<tp do print power power=power* 2 endwhile Отметим, что значение power изменяется в процессе выполнения цикла таким образом, что в какой-то момент power ста- 71
новится большим или равным tp и выполнение цикла прекращается. Одним из требований нормального завершения цикла while является требование изменения значения некоторой переменной в условии while таким образом, чтобы это условие в какой-то момент приняло значение «ложь». Условие в предложении while проверяется всякий раз перед очередным выполнением цикла. Конструкция while может быть легко реализована на языке Бейсик следующим образом: 10 IF NOT условие THEN GOTO 110 20 'тело цикла 100 GOTO 10 110 'оставшаяся часть программы Теперь мы можем написать программу на Бейсике, печатающую все степени двойки, меньшие чем ТР (где ТР> = 1): 100 POWER=l ПО IF POWER>=TP THEN GOTO 150 120 PRINT POWER 130 POWER = POWER*2 140 GOTO 110 150 END В отдельных версиях языка Бейсик имеются некоторые другие формы оператора while. Например, в Бейсике-80 и Бейсике для IBM PC можно написать следующие строки: 10 WHILE условие 20 'тело цикла 100 WEND " До тех пор, пока выражение ненулевое (т. е. «истинно»), происходит повторное выполнение операторов между WHILE и WEND. Однако в большинстве версий языка Бейсик конструкция while не поддерживается, поэтому в данной книге мы не будем ее использовать в программах. Язык Бейсик содержит распространенную и полезную конструкцию цикла, в которой используется счетчик, автоматически уменьшающий или увеличивающий свое значение после каждого очередного выполнения тела цикла. После того, как счетчик становится больше (или меньше) некоторого значения, выполнение цикла прекращается. Такой цикл имеет следующий вид: 10 FOR 1 = начало ТО конец STEP приращение 20 'тело цикла 100 NEXT I На псевдокоде этот же цикл выглядит следующим образом: 72
for i=начало to конец step приращение 'тело цикла • · · next i Переменная I (называемая индексом или управляющей переменной цикла) инициализируется значением «начало» и сравнивается со значением «конец». Если результат проверки есть «истина», то тело цикла выполняется. Если результат есть «ложь», то цикл пропускается. Тип проверки зависит от значения приращения. Если приращение положительно, то проверяется условие 1<= конец. Если приращение отрицательно, то проверяется условие 1> = конец. При выходе на оператор NEXT переменная I переустанавливается в значение 1 +приращение и снова производится сравнение ее со значением «конец». Если проверка дает отрицательный («ложный») результат (даже если при этом тело цикла не выполнялось ни одного раза), выполнение возобновляется с оператора, немедленно следующего за оператором NEXT. Если часть STEP приращение опущена, то предполагается, что приращение равно 1. Необходимо отметить, что значение управляющей переменной цикла может изменяться внутри цикла (хотя делать этого не рекомендуется), что может повлиять на число выполняемых итераций. Однако изменения значений приращения или конца не влияют на число итераций цикла. (Возможно, имеются версии языка Бейсик, для которых это не так.) Этот тип цикла может быть использован для контроля числа выполнений некоторого процесса. Он может также комбинироваться и с другими типами циклов. Рассмотрим, например, альтернативный метод печати всех степеней двойки, меньших чем ТР: 100 FOR 1 = 0 ТО 1Е30 STEP 1 110 IF 2fI>=TP THEN GOTO 140 120 PRINT 2fl 130 NEXT I 140 END Значение I инициализируется нулем и увеличивается на единицу. Затем для него печатается соответствующая степень двойки и так до тех пор, пока эта степень остается меньшей чем ТР. Отметим, что предложение ТО содержит необычно большое число. По этой причине значение I увеличивается до бесконечности, не имея ограничений сверху. Цикл завершается только тогда, когда степень двойки становится больше чем ТР. Управляющая переменная в цикле часто используется для ссылки к элементам массива. Например, предположим, что первые N элементов массива А расположены в этом массиве по возрастанию. Требуется разместить в этом массиве значение переменной X в соответствующую позицию. Это выполняет следующая программа: 73
100 'поиск соответствующей позиции для X ПО FOR 1 = 1 TON 120 IF X<=A(I) THEN GOTO 140 130 NEXT I 140 'в этой точке X< = A(I). Следовательно, Х должен быть непосредственно перед А(1) 150 N=N+1 160 'перемещение оставшихся элементов и размещение X в соответствую- 'щей позиции . . 170 FOR J=N+1 TO 1+1 STEP-1: 'переместить в 'массиве каждый элемент, больший чем X 180 A(JHA(J-1) 190 NEXT J • 200 A(I)=X Внимательно разберитесь в манипуляции с индексами в каждом из двух приведенных выше циклов. Приемы такого рода представляют собой стандартные элементы программирования, использующиеся в большинстве программ. с ' Читатель, по-видимому, заметил существенную разницу между циклом while и циклом for-next. Цикл while представляет собой многократно выполняющуюся управляющую структуру; работа которой завершается после того, как некоторое заданное' условное выражение примет значение «ложь». Такой цикл может повторяться один, два или несколько раз в зависимости от логического значения условного выражения после каждой итерации. С другой стороны, цикл for:next повторяется заданное число раз согласно исходным условиям — начальному з!ШёниЮ} шагу « конечному значению, заданным в предложения' for. ' с ;/ Некоторые циклы завершаются не по условию в предложении "while. Вместо этого внутри цикла производятся логические проверки, которые и вызывают завершение. В таких случаях нам необходим цикл, выполняющийся бесконечно до тех пор, пока он не будет прерван из своего же тела. Примером такого цикла может быть следующий: while 1 = 1 do 'тело цикла endwhile Такой цикл выполняется бесконечное число раз, поскольку условие 1 = 1 всегда выполняется, т. е. имеет значение «истина» (true). Более простым способом является использование в качестве условия непосредственно логического значения true. Такой цикл, записанный на псевдокоде, имеет вид while true do 'тело цикла endwhile Эта управляющая структура реализуется на языке Бейсик следующим образом: 74
100 'тело цикла 200 GOTO 100 Разумеется, программист сам должен позаботиться об обеспечении выхода из такого цикла (обычно при помощи оператора GOTO). Подпрограммы Подпрограмма является наиболее полезным средством программирования, позволяющим существенно сократить объемы больших задач. Используемый при этом подход предполагает разбиение задачи на ряд подзадач. Это дает возможность программисту рассматривать каждую задачу независимо от остальных. После отладки всех подзадач они могут быть объединены в одну программу. В оставшейся части данного раздела мы рассмотрим различные приемы, использующиеся при написании подпрограмм на псевдокоде и языке Бейсик. В следующем разделе мы рассмотрим принципы разбиения больших программ на подпрограммы. Для удобства идентификации программ и подпрограмм им присваиваются имена. Это облегчает распознавание тех программ, которые используются в одной задаче многократно или вызываются другими программами. Поскольку язык Бейсик не содержит специального синтаксического метода для поименова- ния программ или подпрограмм, то для этих целей используются комментарии. Следовательно, программа может быть идентифицирована следующим образом: 10 программа progl . . . подпрограммы progl ... а подпрограмма — в виде 1000 'подпрограмма subl . . . подпрограммы subl . . . Предположим, например, что мы хотим написать основную программу, которая вызывает подпрограмму, печатающую целые числа и квадратные корни из них для всех чисел от 1 до 10. Напишем сначала алгоритм на псевдокоде: print «число», «корень» sqprint 'подпрограмма sqprint печатает числа от 1 до 10 'и квадратные корни из этих чисел print «конец» Вторая строка в алгоритме (sqprint) представляет собой вызов подпрограммы sqprint, которая и выполняет необходимые дей- 75
ствия. Алгоритм работы подпрограммы sqprint, написанный на псевдокоде, имеет следующий вид: subroutine sqprint for i=l to 10 print i, sqr(i) next i return Полный текст программы на языке Бейсик будет иметь следующий вид: 10 'программа printroots 20 PRINT «ЧИСЛО», «КОРЕНЬ» 30 COSUB 100: 'подпрограмма sqprint печатает требуемую таблицу 40 PRINT «КОНЕЦ» 50 END 60 ' 70 ' 100 'подпрограмма sqprint 110 'локальные переменные: I 120 'Эта подпрограмма печатает 10 строк. Каждая строка содержит це- 130 'лое число от 1 до 10 и квадратный корень из этого числа. 140 FOR 1 = 1 ТО 10 150 PRINT I, SQR(I) 160 NEXT I 170 RETURN 180 'конец подпрограммы Если выполнить приведенную программу, то мы получим требуемый результат. Рассмотрим обозначения, использованные в этой программе. В строке 30, где происходит вызов подпрограммы, мы указываем в комментарии имя подпрограммы и краткое описание ее функций. При вызове подпрограммы управление передается в строку с номером 100. Перед обращением к подпрограмме адрес следующего оператора сохраняется операционной системой для последующего перехода к нему по оператору RETURN. Это означает, что система языка Бейсик помнит, что подпрограмма вызывается из строки с номером 30, так что после завершения подпрограммы управление передается к строке с номером 40. Поскольку строки 120—135 представляют собой комментарии, то первый выполняемый оператор подпрограммы находится в строке 140. Строки 140—160 печатают требуемую таблицу, а затем управление передается по адресу возврата (строка 40), который был сохранен оператором GOSUB. При любом вызове подпрограммы управление после ее выполнения передается оператору, следующему за оператором, вызвавшем данную подпрограмму. Строки 100—135 содержат только комментарии и не оказывают влияния на выполнение программы. В строке 100 указано имя подпрограммы, что удобно для указания начала программы. Эта строка вместе с комментарием «конец подпрограммы» отделяет 76
подпрограмму от оставшейся части программы. Такое выделение полезно при необходимости использования подпрограммы, уже существующей в другой программе. Если в программе имеются соответствующие неиспользованные номера строк, то достаточно скопировать операторы подпрограммы. Если данные номера уже заняты, то строки необходимо перенумеровать, а затем скопировать. В любом случае наличие строки комментария в начале подпрограммы и ограничителя «конец подпрограммы» в конце облегчает ее локализацию. Помимо выделения тела самой подпрограммы указание ее имени полезно при спецификации вызова подпрограммы. Отметим комментарий в строке 30 с оператором GOSUB, указывающий имя вызываемой подпрограммы. Это позволяет читателю прочесть программу без проверки номеров строк. Указание имени в начале подпрограммы и завершение ее комментарием «конец подпрограммы» не освобождают программиста от необходимости соблюдения обычных правил вызова подпрограммы и выхода из нее. Это означает, что подпрограмма должна вызываться оператором GOSUB 100 (оператор GOSUB SQPRINT является неправильным), а возврат из нее должен осуществляться по оператору RETURN (комментарий «конец подпрограммы» возврат из подпрограммы не осуществляет). Однако имеются версии языка Бейсик, которые позволяют присваивать подпрограммам имена и вызывать их по именам. В строке ПО содержится список «локальных переменных» подпрограммы. Мы определяем переменную как локальную, если выполнены следующие три условия: 1. Подпрограмма не использует значение, которое было присвоено данной переменной перед вызовом этой подпрограммы. 2. Переменной присваивается значение внутри подпрограммы (оператором присваивания, оператором FOR, оператором READ или INPUT). 3. Значение, присваиваемое переменной внутри подпрограммы, не используется вызывающей программой после возврата из этой подпрограммы. Переменная I удовлетворяет всем этим условиям. Переменная I не используется в подпрограмме до строки с номером 140, в которой происходит первое присваивание ей значения. Переменная I не появляется ни в одной из строк основной программы (строки 10—50). Конструкция FOR-NEXT присваивает переменной I начальное значение, чем удовлетворяется второе условие. Третье условие также выполняется, поскольку после возврата из подпрограммы переменная I ни разу не используется (строки 40—50). Главная причина указания списка локальных переменных в комментарии заключается в стремлении облегчить программисту распознавание потенциальных конфликтов в результате многократного использования одной и той же переменной (иногда неосознанно). Если в данном примере 77
переменная I имела бы перед обращением к подпрограмме в строке 30 некоторое значение, то оно было бы потеряно после возврата из подпрограммы, поскольку подпрограмма изменяет значение I. В таких случаях необходимо изменять имена переменных либо в подпрограмме, либо в главной программе. Использование локальных обозначений в комментариях позволяет программисту идентифицировать имена тех переменных, значения которых могут быть модифицированы в результате включения в основную программу отдельных подпрограмм. Параметры в языке Бейсик Подпрограмма sqprint, приведенная выше, состоит из набора операторов, которые могут размещаться в любом месте той или иной программы, выполняя при этом одну и ту же функцию. Фактически, если оператор RETURN не указывать, данную подпрограмму можно рассматривать как отдельную программу. Однако очень часто бывает необходимо поддерживать связь между подпрограммой и той средой, в которой она выполняется (т. е. вызывающей программой). Например, предположим, что нам необходимо написать подпрограмму, которая взаимно изменяет значения двух переменных — А и В. При этом переменная А получает значение переменной В, а переменная В — значение переменной А. Однако при необходимости изменения двух других переменных, например С и D, непосредственно эта подпрограмма не может быть использована. (Без предварительного сохранения содержимого переменных А и В» последующего копирования содержимого переменных С и D в А и В, вызова подпрограммы, копирования А и В в С и D и, наконец, восстановления значений А и В.) В таких случаях очень полезной оказалась бы обобщенная программа, которая меняла бы местами содержимое любых двух числовых переменных и которую можно было бы вызвать как для переменных А и В, так и для переменных С и D. Хотя такие механизмы встроены в другие языки программирования высокого уровня, большинство версий языка Бейсик их не поддерживает. Поэтому мы опишем метод реализации такой функции. На псевдокоде мы можем записать подпрограмму следующим образом: subroutine swap (pl,p2) temp=pl Pl=p2 p2=temp return Переменные pi и р2 являются параметрами этой подпрограммы, что отмечается размещением их имен в скобках после 78
имени подпрограммы в первой строке. Когда в программе на псевдокоде появляется swap (a, b) то значения а и b (называемые аргументами) автоматически подставляются в pi и р2, после чего подпрограмма начинает выполняться. При возврате из подпрограммы а получает окончательно значение переменной pi, a b — переменной р2. Следовательно, перестановка значений переменных pi и р2 приведет к перестановке значений а и Ь. Аналогично оператор swap (b,c) который вызывается с аргументами b и с, приведет к взаимйой перестановке значений переменных b и с. Рассмотрим пример использования такой подпрограммы. Предположим, что нам необходимо прочесть три числа и расположить их в возрастающем порядке. Рассмотрим следующий алгоритм: 'прочесть и отсортировать три числа read a, b, с if a>b then swap(a,b) endif if b>c then swap(b,c) endif if a>b then swap(a,b) endif print a, b, с В приведенном выше алгоритме на псевдокоде подпрограмма swap вызывается три раза, каждый раз с различными параметрами. Написанный на псевдокоде оператор swap(a,b) приводит к вызову подпрограммы swap. При этом pi имеет значение а, а р2 — значение Ь. Следовательно, значения а д b поменяются местами. Оператор swap (b,c) приводит к вызову подпрограммы swap со значением для pi, равным значению переменной Ь, и значением для р2, равным значению переменной с. Следовательно, значения b и с поменяются местами. Читатели могут убедиться в том, что программа располагает значения переменных в возрастающем порядке вне зависимости от исходной последовательности. Отметим, что параметры pi и р2 являются для подпрограммы swap как входными, так и выходными. Входным параметром называется такой параметр, который получает начальное 79
значение от соответствующей переменной из вызывающей программы, при этом данное значение используется внутри подпрограммы. Выходным параметром называется параметр, значение которого устанавливается в подпрограмме для дальнейшего использования в вызывающей программе после возврата из подпрограммы. В подтверждение того, что pi является входным параметром, рассмотрим оператор temp = pl который использует значение pi без присваивания начального значения внутри подпрограммы. Аналогично оператор Р1=р2 подтверждает тот факт, что р2 является входным параметром. Этот же оператор указывает на то, что pi есть выходной параметр, поскольку его значение устанавливается внутри подпрограммы для дальнейшего использования вне подпрограммы. Точно так же оператор p2=temp указывает на то, что р2 есть выходной параметр. Переменная temp не является ни входным, ни выходным параметром. Помимо того факта, что она не появляется в заголовке подпрограммы, temp не может быть входным параметром еще и потому, что ее значение устанавливается оператором temp = pi до использования имевшегося значения этой переменной. Тот факт, что она не является входным параметром, несмотря на установку ее значения в приведенном выше операторе, следует из того, что ее значение не требуется вне подпрограммы. Параметры pi и р2 и соответствующие переменные (а и b в двух вызовах; b и с в третьем вызове) меняют свои значения в подпрограмме. Переменная temp выступает в качестве вспомогательной в процессе перестановки значений. После завершения этого процесса ее значение больше не требуется. Как мы уже видели, temp является локальной переменной, используемой в данной подпрограмме. При переводе псевдокода на язык Бейсик мы перечислим в комментариях все входные, выходные и локальные переменные подпрограммы. При вызове подпрограммы мы должны явно присвоить входные значения соответствующим переменным. Ниже приводится полная программа сортировки трех чисел на языке Бейсик, использующая подпрограмму swap: 10 'программа sort 20 'считывает три переменные и располагает их в возрастающем порядке 30 READ А, В, С 40 IF A>B 80
THEN P1=A: P2=B: GOSUB 1000: A-Pl: B = P2: 'подпрограмма swap взаимно меняет содержимое PI и Р2 50 IFB>C THEN P1 = B: P2=C: GOSUB 1000: B = P1: C=P2 60 IFA>B THEN P1 = A: P2=B: GOSUB 1000: A-Pl: B = P2 70 PRINT «ОТСОРТИРОВАННАЯ ПОСЛЕДОВАТЕЛЬНОСТЬ СОСТАВИТ»; А; В; С 80 END 90 DATA . . . 100 ' ПО ' 1000 'подпрограмма swap 1010 'входы: PI, P2 1020 'выходы: PI, P2 1030 'локальные переменные: TEMP 1040 'swap меняет местами содержимое Р1 и Р2 1050 ТЕМР=Р1 1060 Р1 = Р2 1070 Р2=ТЕМР 1080 RETURN 1090 'конец подпрограммы Исходный алгоритм содержит оператор if a>b then swap(a.b) endif Рассмотрим, каким образом это транслируется в соответствующие операторы языка Бейсик в строке с номером 40. На первом шаге перед вызовом подпрограммы входным переменным присваиваются соответствующие значения. Это делается операторами Р1=А и Р2=В. После инициализации входных переменных вызывается подпрограмма (GOSUB 1000:), переставляющая значения Р1 и Р2. После завершения работы подпрограммы управление возвращается к вызывающей программе (через оператор RETURN). В этом месте необходимо присвоить выходные значения соответствующим переменным вызывающей программы. Это осуществляется при помощи операторов А=Р1 и В = = Р2. Трансляция оставшихся операторов if производится аналогично. Отметим также, что первое предложение в алгоритме, написанном на псевдокоде, является выполняемым оператором (не комментарием) и включает в себя входные и выходные параметры, заключенные в скобки. В языке Бейсик такая конструкция отсутствует. Поэтому мы указываем на начало подпрограммы в первом операторе комментария, а также на входные, выходные и локальные переменные в последующих комментариях. Читатели могут отметить, что мы ввели приведенные выше соглашения для удобства и стандартизации подпрограмм. Во многих языках (и даже в некоторых версиях языка Бейсик) имеются возможности, позволяющие программисту обращаться к подпрограмме непосредственно через оператор, аналогичный 81
SWAP(A,B), без необходимости явного присвоения значений А и В параметрам Р1 и Р2 и обратно. Функции в языке Бейсик Функцией называется подпрограмма, содержащая один выходной параметр. Это означает, что она вычисляет одно значение, которое затем возвращается вызывающей программе. При написании алгоритмов, выполняющих различные процессы, очень часто удобно пользоваться подпрограммами, составленными в виде функций. При таком представлении имя функции, за которым следует список ее аргументов, заключенных в скобки, указывает на вызов этой функции с данными аргументами и представляет собой результат этого вызова. Например, SQR есть функция (поддерживаемая языком Бейсик)* Если приводимые ниже операторы псевдокода перевести в программу на языке Бейсик, то ссылка к SQR(NUMB) укажет на вызов функции SQR (извлечения квадратного корня) с аргументом 64. Результат работы этой функции со значением аргумента 64 есть 8, поэтому оператор PRINT напечатает числа 64 и 8. numb = 64 root=sqr (numb) print numb, root . Предположим, что имеется другая функция — cbr, вычисляющая кубический корень из числа. Тогда мы можем расширить алгоритм следующим образом: numb = 64. spoot = sqr(numb) croot = cbr (numb) print numb, sroot, croot В результате выполнения этой программы будут напечатаны числа 64, 8 и 4, что есть соответственно число, его квадратный корень и кубический корень. Поскольку SQR является встроенной функцией (доступной как часть самого языка Бейсик), то для нее не требуется написание отдельного алгоритма или программы на языке Бейсик. Однако функция cbr в этом языке не определена, поэтому она должна быть написана программистом. Ниже приводится реализация этой функции на псевдокоде: function cbr (nmb) cbr=nmbf(l/3) return Имя функции (в данном случае cbr) используется в качестве выходной переменной внутри определения функции. Эта переменная содержит значение, возвращаемое функцией. 82
В некоторых версиях языка Бейсик (например, в Бейсике-80 и Бейсике фирмы IBM) допускается прямое определение функции в виде формулы, вычисляющей некоторое значение. Например, мы можем определить рассмотренную функцию извлечения кубического корня при помощи оператора языка Бейсик следующим образом: 10 DEFFNCBR(NMB)=NMBf(l/3) Имя такой функции должно иметь форму FNx, где χ есть любое допустимое имя переменной. Переменная ΝΜΒ является входным параметром, который не нуждается в явном присвоении значения. После того как функция определена, к ней можно обращаться следующим образом: 100 CROOT=FNCBR(X) Переменная CROOT примет значение кубического корня из X независимо от того, какое значение имеет переменная NMB. Значение переменной NMB не меняется при вызове функции. Однако в некоторых версиях языка Бейсик (например, в Бейсике TRS-80 Level II) использование таких функций не допускается. При этом довольно часто возникает необходимость использования функций, в которые входит более одной формулы. По этой причине мы часто реализуем функции при помощи подпрограмм. Например, функция cbr может быть записана следующим образом: 1000 'подпрограмма cbr 1010 'входы: NMB 1020 'выходы: CBR 1030 'локальные переменные: нет 1040 'подпрограмма вычисляет кубический корень числа 1050 CBR = NMBf(l/3) 1060 RETURN 1070 'конец подпрограммы Мы договорились использовать имя функции в качестве имени входной переменной. Если по каким-либо причинам это невозможно (например, первые буквы имени обозначают другой тип или другую, уже существующую в программе переменную с такими же первыми буквами в имени, что возможно в некоторых версиях языка Бейсик), то в качестве входной переменной используется переменная, чье имя наименее отличается от имени функции, использованной в качестве входной переменной. Ниже приводится полный текст программы, печатающей таблицу чисел и их квадратных и кубических корней для чисел от 1 до 10. 10 'программа table on /Эта пР0гРамма печатает список чисел от 1 до 10, Ж а также значения квадратных и кубических корней из них 83
40 FOR NUMB=1 TO 10 50 SROOT= SQR (NUMB) 60 NMB = NUMB 70 GOSUB 1000: 'подпрограмма cbr устанавливает переменную 'CBR 80 CROOT=CBR 90 PRINT NUMB, SROOT, CROOT 100 NEXT NUMB 110 END 1000 'подпрограмма cbr 1010 'входы: NMB 1020 'выходы: CBR 1020 'локальные переменные: нет 1040 'подпрограмма вычисляет кубический корень числа 1050 CBR=NMBf(l/3) 1060 RETURN 1070 'конец подпрограммы Упражнения 1. Напишите программу NUMVAC вычисления числа дней отпуска для сотрудника по следующим правилам. Пусть SICK есть число дней текущего года, в течение которых сотрудник был болен. Предположим, что сотруднику предоставляется 10 дней отпуска. Если число дней, пропущенных по болезни, выше 10, то эти дни вычитают из числа дней, отведенных под отпуск. Если же будем считать, что сотрудник проболел менее 5 дней, тогда ему дополнительно полагается 2 дня отпуска. Число дней отпуска не может быть отрицательным. 2. Перепишите приведенные ниже куски программ без использования в них циклов FOR-NEXT. Вместо этого воспользуйтесь операторами IF и GOTO. (а) (б) (в) (г) 10 20 30 40 10 20 30 40 10 20 30 40 50 60 10 20 30 40 50 60 70 80 90 00 FOR I = ATOB STEP 10 READ Χ, Υ Ζ=Ζ+Χ*Υ NEXT I FOR I=A TO N STEP 10 READ Χ, Υ Z=Z+X*Y NEXT I FOR I = A TO N STEP 10 FOR J= 1 TO 3000 READ Χ, Υ Z=Z+X+Y NEXT J NEXT I FOR 1=1 TO 10 FOR J=3TOI-l STEP3 SUM=SUM+J READ X IFX>100GOTO80 IF X>50 GOTO 100 SUM=SUM-X NEXT J SUM=SUM+1 NEXT I 3. Определите назначение каждого из приведенных ниже кусков программы и перепишите их таким образом, чтобы они не содержали операто- 84
ров GOTO (а) (б) 10 20 30 4Ώ 50 60 10 20 30 40 50 60 70 80 90 100 ПО 120 130 140 150 160 170 Х=1 IF X>500 THEN GOTO 60 PRINT X; SQR(X); X+500; SQR(X+500) Χ=Χ+1 GOTO 20 'оставшаяся часть программы 1=0 IF I>10 THEN GOTO 60 PRINT I; 1=1+1 GOTO 20 1=1 IF I > 10 THEN GOTO 170 PRINT I; J=l IF J>10 GOTO 140 PRINT I»J J=J+1 GOTO 100 PRINT 1=1+1 GOTO 70 END 4. Некоторая компания по перевозкам на верблюдах недавно установила компьютер, обеспечивающий безопасное прохождение маршрутов погонщикам верблюжьих караванов. Эксперты компании, отвечающие за перевозки грузов на животных, определили, что верблюд может безопасно для жизни перевезти до 7469 прутьев. Предводитель каравана имеет набор данных для каждого проводимого им каравана. Набор данных представляет собой группу строк. Первая строка содержит имя погонщика. Следующая строка содержит число верблюдов в караване. Помимо этого для каждого верблюда имеется строка данных, в которой записаны число перевозимых верблюдом корзин и число прутьев в каждой корзине. Каждая корзина состоит из 137 прутьев. Напишите программу, считывающую наборы из таких строк, и напечатайте для каждого каравана имя погонщика, сопровождаемое списком верблюдов, несущих недопустимо большой груз, а также массу такого груза. Если все верблюды находятся в безопасности, то напечатайте сообщение об этом. Например, одним из типичных ответов может быть следующий: БОБ СМИТ ВСЕ ВЕРБЛЮДЫ В ПОРЯДКЕ ДЖОН ДЖОНС СЛЕДУЮЩИЕ ВЕРБЛЮДЫ В ОПАСНОСТИ: ВЕРБЛЮД 3 ПЕРЕНОСИТ 8467 ПРУТЬЕВ ВЕРБЛЮД б ПЕРЕНОСИТ 7541 ПРУТЬЕВ 5. Предположим, что данные для программы состоят из числа N, за которым следует N наборов данных, используемых для инициализации массива, объявленного следующим образом: DIM X(100, 100) Каждый из N наборов данных состоит из номера строки R и неопределенного числа пар целых чисел. Каждая пара целых чисел состоит из номера столбца С и значения V. Значение X(R, С) устанавливается g V. Каждый набор строк данных заканчивается парой нулей. Если номер строки R не лежит в интервале от 1 до 100, то весь набор данных игнорируется. Если номер столбца не лежит в интервале от 1 до 100, то пара этих целых чисел 8Ь
игнорируется (если только оба числа С и V не равны нулю, что предполагает конец набора данных). Все элементы массива, которым не присваивается никаких значений, должны быть установлены в 0. Если одному и тому же элементу массива присваиваются два различных значения, то выдается сообщение об ошибке. Напишите программу, осуществляющую инициализацию массива вышеописанным образом. 6. Рассмотрим следующую программу, которая сортирует массив X размерности N: 10 FOR ТР= N ТО 2 STEP-1 20 FORI=lTOTP-l 30 IFX(I)>X(I+1) THEN TEMP=X(I): X(I)=X(I+1): X(I+1)=TEMP 40 NEXT I 50 NEXT TP (а) Объясните, каким образом происходит сортировка массива X. (б) Модифицируйте данную программу, используя логический фраг, таким образом, что если условие Х(1)>Х(1+1) никогда не выполняется внутри всего цикла FOR-NEXT по переменной I, то цикл FOR-NEXT по переменной ТР сразу же заканчивается. (в) Объясните, почему модифицированная таким образом программа также осуществляет сортировку массива X. 7. Напишите алгоритм, вычисляющий квадратный корень из числа χ (большего чем 4) со степенью точности, равной err, по следующему способу. Пусть est есть оценка для квадратного корня. Изначально est равно х/4. Если разность между est и x/est меньше чем err, то тогда est есть квадратный корень из данного числа. В противном случае установите в est среднее значение между est и x/est. He пользуйтесь операторами GOTO. 8. Положительное число, большее единицы, называется простым числом, если оно не делится ни на какое целое число, отличное от себя самого и единицы. Примерами простых чисел могут служить числа 2, 11, 37 и 43. Числа 15 и 24 не являются простыми. Напишите программу prime, возвращающую значение true, если число является простым, и false в противном случае. 9. Совершенным числом называется число, большее единицы, которое есть сумма всех своих делителей, исключая само это число. Например, б есть совершенное число, поскольку 6=1+2+3, и 28 также есть совершенное число, так как 28=1+2+4+7+14. Напишите программу, отыскивающую наименьшее совершенное число, большее 28. 10. Рассмотрим два массива. Массив X содержит пять различных элементов, расположенных по возрастанию, а массив Υ содержит шесть различных элементов, расположенных по убыванию. Также объявлен массив Ζ из 11 элементов. Напишите на языке Бейсик программу, которая помещает элементы из массивов X и Υ в массив Ζ, упорядочивая их по возрастанию. 11. Напишите программу на языке Бейсик, отыскивающую наименьшее простое число, большее чем заданное целое число X. 12. (а) Напишите функцию fact(n), вычисляющую произведение всех целых чисел от 1 до η включительно. В математике fact(n) записывается как п!. (б) Предположим, что в комиссии работают η человек и при этом к иг них могут быть выбраны в подкомиссию. Пусть comm(n, k) есть число различных сформированных подкомиссий. Покажите, что comm (n, k) равно n!/(k!*(n—к)!). Напишите функцию comm(n,k), вычисляющую это значение. (в) Если в урне содержится ρ черных и ν белых шаров и из урны извлекается b+w шаров, то пусть prob (ρ, ν, b, w) есть вероятность извлечения из урны b черных и w белых шаров. Покажите, что prob(p, v, b, w) может быть вычислена по формуле (comm(p, b)*comm(v, w))/comm(p+v, b+v). Напишите программу, которая считывает наборы данных, каждый из которых содержит целые числа р, v, b и w и которая вычисляет вероятность 86
prob(p, v, b, w). Для каждого входного набора данных программа печатает значения р, v, b и w и вероятность prob(p, v, b, w). (г) Модифицируйте программу из п. (в) упражнения таким образом, чтобы после вывода на печать всех данных программа печатала также число обращений к функции fact. (д) Перепишите программу из п. (в) упражнения, не используя при этом какие-либо подпрограммы. 2.2. МЕТОДИКА ПРОГРАММИРОВАНИЯ В предыдущем разделе мы рассмотрели некоторые программные структуры, используемые в языке Бейсик, и показали, каким образом они могут быть расширены на другие управляющие структуры, позволяющие получать логичные, корректные и легко читаемые программы. Рассмотрим теперь отдельные приемы, полезные при написании программ. Разработка программы Мы все имеем интуитивную идею о взаимосвязи между проблемой и ее решением. Мы рассматриваем проблему как формулировку поставленного вопроса, а решение как ответ на этот вопрос. Сложные задачи, использующие большие наборы данных или требующие многократного выполнения одного и того же процесса, решаются при помощи ЭВМ (позволяя человеку освободить себя от рутинной работы). Функция программиста заключается главным образом в формулировке решения таким образом, чтобы задача могла быть решена на вычислительной машине. Вычислительная техника еще не достигла такого уровня, чтобы непосредственно давать ответ на вопрос типа: «Каковы мои накладные расходы?» или «Чему равно значение числа π с точностью до 5000 десятичных знаков?». Для того чтобы машина могла ответить на эти вопросы, необходимо, следовательно, написание соответствующих программ. Программа является промежуточным звеном, позволяющим получить ответы на те или иные вопросы. В процессе решения поставленной задачи программист проходит в своей работе следующие стадии: 1) формулировка проблемы, 2) выбор алгоритма и структур данных и 3) написание программы. Рассмотрим каждую из этих стадий в отдельности. Формулировка проблемы Формулировка проблемы является весьма важным этапом. Как было отмечено, формулировка проблемы уже представляет собой половину решения. После того как проблема сформулирована более детально, становится ясно, какие средства необхо- 87
димы для ее решения и то, каким образом эти средства должны, быть использованы. Например, предположим, что колледж пригласил программиста для разработки компьютезированной системы хранения данных о студентах. Хотя название самой системы может быть и достаточной информацией для задания спецификации по ее программированию, однако этого явно недостаточно для написания программы. Проблема должна быть сформулирована так, чтобы программист точно мог знать, какие данные для программы являются входными и какие — выходными. Эти данные должны быть сформулированы руководством колледжа таким образом, чтобы программист смог либо написать требуемую программу, либо отказаться от работы. С другой стороны, программист может и сам внести изменения в указанную заказчиком спецификацию. В этом случае возможность внесения изменений в исходную спецификацию облегчает программисту поставленную задачу и в конечном итоге ведет к созданию улучшенного варианта программы и с точки зрения заказчика. Например, может быть установлено, что некоторая требующая больших вычислений часть информации необходима только на определенной стадии проекта, на которой получить ее уже значительно легче. Или же может быть установлено, что эта информация не требуется вовсе. Аналогичным образом может оказаться, что некоторая процедура или процесс не эффективны для получения желаемого результата, и в то же время имеется иной, менее трудоемкий процесс, дающий такой же или похожий результат. В любом случае окончательная формулировка проблемы является результатом совместной работы как заказчика, так и программиста. Необходимо отметить, что не следует пытаться что-либо программировать вплоть до окончательной формулировки проблемы. Преждевременное написание программ ведет к их постоянной коррекции в процессе формулировки окончательных требований, а это ведет к большим потерям времени. Что еще хуже, такие программы приходится постоянно исправлять вследствие неверных исходных предположений, обусловленных неполным пониманием проблемы. Время, затрачиваемое программистом на написание и коррекцию условий задачи и ее решения, с лихвой окупается благодаря существенному сокращению процедур отладки, реорганизации и переписывания частей программ, написанных преждевременно. Ниже приводятся входные и выходные данные рассматриваемой задачи: Входная информация Число студентов Для каждого студента Номер в системе социального страхования Фамилия 88
Число курсов Для каждого курса Оценка Выходная информация Для каждого студента Номер в системе социального страхования Фамилия Средний балл студента Средний балл по классу Алфавитный список студентов и их средние баллы, а также номера в системе социального страхования Практика явного указания входных и выходных данных чрезвычайно полезна. Помимо выполнения функции спецификации программы подобный список позволяет сфокусировать внимание на том, может ли быть получен некоторый требуемый результат из указанного набора входных данных. Зачастую некоторые необходимые входные данные забывают включить в список. Подобные упущения должны быть обнаружены до этапа написания программы. Разработка алгоритма Создание списка входных и выходных данных естественным образом ведет к следующей фазе разработки программы — к выбору алгоритма и структур данных. Алгоритм представляет собой набор инструкций, при помощи которых входные данные преобразуются в выходные. Хороший программист отлично знает, что никакое решение не будет окончательно сформулировано до тех пор, пока для него не будет разработан алгоритм. При разработке алгоритма программист должен знать, каким образом выходные данные могут быть получены из входных, т. е. какие выходные данные получаются непосредственно из входных и какие требуют промежуточных вычислений. Например, номер в системе социального страхования для каждого студента и фамилия этого студента могут быть получены непосредственно из входных данных. Средний балл для каждого студента должен быть вычислен по его оценкам, которые являются входными данными. Средний балл для класса должен быть получен из средних баллов всех студентов, причем средние баллы студентов сами уже являются выходными данными. Это означает, что вычисление средних баллов для студентов должно производиться до вычисления среднего балла по всему классу. В процессе написания и модификации программы хороший программист должен обнаружить недостающие данные в спецификации программы. В общем случае программирование не является последовательной деятельностью, при которой возможен последовательный переход от одного шага к другому. Наоборот, на каждом шаге часто бывает необходимо возвращаться к предыдущим и иногда модифицировать их. На более поздних эта- 89
пах также принимаются решения, сознательно отложенные на ранних стадиях. Например, для увеличения эффективности программы спецификация в процессе программирования может быть модифицирована. Разумеется, любое изменение в спецификации должно быть согласовано с пользователем и программистом. Первая попытка создания алгоритма для программы учета студентов колледжа может бцть следующей: прочитать число студентов для каждого студента *- ' ' прочитать номер в системе социального страхования прочитать фамилию прочитать оценки студента вычислить средний балл студента напечатать номер студента в системе социального страхования, его фамилию и средний балл вычислить средний балл по классу составить список студентов класса в алфавитном порядке напечатать в алфавитном порядке фамилии, номера в системе социального страхования и средние баллы студентов Хотя приведенный алгоритм и представляет собой логическое решение задачи, он не может быть использован для непосредственного написания программы. Для написания программы на языке Бейсик по такому алгоритму необходимо перевести каждое предложение алгоритма в эквивалентные им операторы языка. Для этого необходимы две вещи: описание организации данных (т. е. каким образом используются структуры данных) и описание используемых вычислений. Мы вскоре вернемся к проблеме перевода алгоритма в операторы языка Бейсик. А сейчас рассмотрим структуры данных, необходимые для решения проблемы. Выбор структуры данных Выбор структуры данных оказывает большое влияние на сложность алгоритма, необходимого для решения проблемы, а также на легкость реализации данного алгоритма. В самом деле, главной задачей данной книги является выбор соответствующей структуры данных, необходимой для решения проблемы. В приведенном выше примере выбор структур данных очевиден. Каждый из необходимых программе элементов может быть сохранен в специально созданной для этой цели отдельной переменной. В общем случае переменные, которые считываются как единая группа (например, оценки отдельного студента), не обязательно должны записываться в массив, если только не требуется их совместная обработка, например сортировка. Повторное использование одной и той же переменной вместо накопления разных значений переменной в массиве ведет к экономии па- 90
мяти. С другой стороны, ряд вычислений гораздо удобнее производить с использованием массива, а не набора отдельных переменных. Например, фамилии студентов, их средние баллы и номера в системе социального страхования удобнее держать в массивах, с тем чтобы осуществлять их сортировку и печать в алфавитном порядке. Поскольку нам потребуется вычисление среднего для набора значений в двух отдельных местах, воспользуемся для этого подпрограммой. Чтобы использовать данную программу для вычисления среднего балла каждого отдельного студента, необходимо сохранять набор оценок каждого студента в массиве. Хотя это и не обязательно и к тому же требует дополнительного пространства памяти, программирование при такой организации значительно облегчается. Рассмотрим основную программу: 10 'программа college 20 'эта программа вводит номер каждого студента в системе социального страхования, фамилию и оценки 30 'производит вычисление и печатает следующую информацию 40 ' номер каждого студента в системе социального страхо- 7 вания 50 ' фамилию каждого студента 60 ' средний балл каждого студента 70 ' средний балл по классу θ0 'средние баллы печатаются в алфавитном порядке 90 DEFSTR N 100 DIM ARR(100), NAM(100), NSEC(IOO), STAVG(IOO) 110 READSNUM 120 FOR 1=1 TO SNUM 130 READ NSEC(I), NAM(I) 140 READ CNT 150 FORJ=lTOCNT 160 READARR(J) 170 NEXT J 180 GOSUB 1000: 'подпрограмма avg воспринимает ARR и GNT 'и устанавливает переменную AVG 190 STAVG(I)=AVG 200 PRINTNSEC(I),NAM(I),STAVG(I) 210 NEXT I 220 'были напечатаны фамилии и средние баллы 230 'отдельных студентов 240 FORI=lTOSNUM 250 ARR(I) = STAVG(I) 260 NEXT I 270 CNT=SNUM 280 GOSUB 1000: 'подпрограмма avg 290 CLASAVG=AVG 300 PRINT «СРЕДНИЙ БАЛЛ В КЛАССЕ РАВЕН»; CLASAVG 310 GOSUB 2000: 'подпрограмма sort принимает SNUM, NAM, NSEC ΛΛΛ m. 'и STAVG и упорядочивает список по алфавиту 320 PRINT «СПИСОК СТУДЕНТОВ В КЛАССЕ ПО АЛФАВИТУ» 330 FOR 1=1 ТО SNUM 340 PRINT NSEC(I), NAM(I), STAVG(I) 350 NEXT I V ' 360 END $00 DATA . . . 91
1000 'здесь располагается подпрограмма avg 2000 'здесь располагается подпрограмма sort • · а Основная программа выполняет только функции управления. Другими словами, она является менеджером, который организует необходимую для выполнения работу и разбивает ее на компоненты, передаваемые на выполнение подпрограммам на более нижнем уровне. В свою очередь каждая такая подпрограмма также разбивает переданный ей кусок программы на части, передаваемые на выполнение подпрограммам на еще более низком уровне. Этот процесс выполняется до тех пор, пока не будут написаны программы самого нижнего уровня. Такие программы выполняют свои функции без обращения к другим программам. Затем программист приступает к решению различных подзадач. На этом этапе необходимо выделить такие подзадачи более конкретно. В частности, необходимо точно определить, к какой информации имеет доступ подпрограмма при своем вызове и какие действия она должна совершать. После того как функции подпрограммы окончательно уточнены, ее можно записать на алгоритмическом языке, пользуясь при этом такими же приемами, как и при написании вызывающей программы. Этот процесс продолжается до тех пор, пока алгоритм не будет реализован до конца. Подпрограмма avg, вычисляющая среднее значение, очевидна. 1000 'подпрограмма avg 1010 'входы: ARR, CNT 1020 'выходы: AVG 1030 'локальные переменные: К, SUM 1040 'подпрограмма avg устанавливает в AVG среднее для значений от 'ARR(l) до ARR (CNT) 1050 SUM=0 1060 FOR K= 1 ТО CNT 1070 SUM=SUM+ARR(K) 1080 NEXT К 1090 AVG=SUM/CNT 1100 RETURN 1110 'endsub Рассмотрим задачу сортировки списка. В процессе сортировки в алфавитном порядке списка мы должны помнить, что информация, связанная с конкретным студентом, должна храниться вместе с остальной информацией о нем (т. е. недостаточно упорядочить только фамилии; точно так же должны быть переупорядочены номера в системе социального страхования и средние баллы). После уточнения функций, выполняемых подпрограммой, мы можем записать ее на языке Бейсик в следующем виде: 92
3000 'подпрограмма sort ЗОЮ 'входы: NAM, NSEC, SNUM, STAVG 3020 'выходы: NAM, NSEC, STAVG 3030 'локальные переменные: К, L, NTMP, TEMP 3040 'подпрограмма sort переупорядочивает в алфавитном порядке пер- 'вые SNUM элементов. 3050 FOR K=l TO SNUM-1 3060 FOR L=K+1 TO SNUM 3070 IF NAM(K)< = NAM(L) THEN GOTO 3120 3080 'иначе выполняются операторы 3090—3110; переста- 'новка данных для студента К с данными для студен- 'таЬ 3090 TEMP=STAVG (К): STAVG (К) = STAVG (L): STAVG (L)-TEMP 3100 NTMP = NAM (K): NAM(K) = NAM(L): NAM(L)=NTMP 3110 NTMP = NSEC (K): NSEC (K)= NSEC (L): NSEC (L)= NTMP 3120 NEXT L 3130 NEXT К 3140 RETURN 3150 'конец подпрограммы Отметим, что для инициализации входов для подпрограммы avg используются операторы 240—270, а инициализация входов для подпрограммы avg перед вызовом в строке 180 отсутствует. Это обусловлено тем, что входы (массивы ARR и CNT) были инициализированы непосредственно входным потоком. Аналогичным образом при вызове подпрограммы sort инициализация не требуется, поскольку она непосредственно использует переменные из главной программы. Возможно также сокращение излишней инициализации для тех случаев, когда несколько программ имеют одинаковые входы. Однако при этом очень важно следить за тем, чтобы входы не изменили сбое значение между последовательными вызовами. Зачастую при необходимости эффективной работы программы инициализации входных данных для подпрограммы отнимает слишком много времени. Это очевидно, в частности, когда осуществляется многократное обращение к одной и той же подпрограмме (в отличие от нашего примера, в котором инициализация массива ARR по данным из массива STAVG должна осуществляться только один раз, поскольку среднее значение по классу вычисляется также только один раз). В таких случаях рекомендуется не пользоваться подпрограммой, а размещать необходимую последовательность операторов непосредственна в вызывающей программе. Наиболее рекомендуемой практикой, при которой предполагается сохранение структурности программы, является использование массива, который уже содержит данные в вызывающей программе, для представления его в качестве входных данных для подпрограммы. Это и было сделано для подпрограммы sort. Такой способ позволяет использовать несколько копий одной и той же программы, каждая из 93
которых работает со своим отдельным массивом входных данных. Это же относится к тому случаю, когда массив является выходным по отношению к подпрограмме. (Другие языки программирования используют иной механизм передачи параметров в виде массива, при котором массив не копируется целиком.) Отметим также, что NAM, NSEC и STAVG являются как входными, так и выходными параметрами для подпрограммы sort» поскольку значения в массивах переупорядочиваются. Наконец, интересно будет отметить тот факт, что подпрограмма sort упорядочивает данные только в алфавитном порядке. При необходимости упорядочивания в ином порядке (например, по возрастанию среднего значения) следует использовать другую подпрограмму. Очень часто для реализации одинаковых или сходных операций над различными данными используются отдельные подпрограммы. Одним из вопросов, на который необходимо дать ответ в процессе решения каждой проблемы, является вопрос распределения функций между основной программой и подпрограммами. Этот вопрос не имеет прямого ответа и должен решаться программистом отдельно для каждого конкретного случая. Можно, однако, выделить два общих приема, полезных при выборе задач, выполняемых в основной программе, и задач, выполняемых подпрограммами. Первый принцип заключается в том, что части программы, содержащие большое число второстепенных с точки зрения решения задачи подробностей, должны помещаться в подпрограмме. При написании и последующем прочтении программы программист не должен заботиться о подробностях выполнения определенных подзадач. Он просто хочет быть уверенным в том, что возлагаемые на них функции выполняются. К этой категории относятся также задачи, чьи функции могут быть позднее модифицированы. Может оказаться, что улучшить программу в дальнейшем весьма затруднительно, хотя изначально ставилась именно такая задача. С другой стороны, в программе, разбитой на отдельные компактные подпрограммы, каждую из подпрограмм легко модифицировать и проверить отдельно таким образом, чтобы вызывающая программа при этом не изменялась. После всех модификаций новая подпрограмма заменяет старую и вся программа целиком выполняется правильно. Концепция возможности замены одной версии программы на другую называется модульным программированием, а индивидуальные подпрограммы — модулями. Программирование по модульному принципу, при котором каждая программа является отдельной легко заменяемой целостной единицей, позволяет эффективно и безошибочно осуществлять различные модификации без возможных побочных эффектов, вызванных заменой одной части программы на другую. В дальнейшем при составлении програм- 94
мы мы будем пользоваться именно этим принципом. Второй принцип заключается в выделении некоторой специфической операции или набора операций в отдельную подпрограмму, если эта операция используется другими частями программы или подпрограммами. Например, если некоторый процесс (например, сортировку) необходимо осуществить в нескольких участках программы, то этот процесс удобно оформить в виде отдельной подпрограммы и пользоваться им через обращение к этой подпрограмме. Это дает возможность не повторять один и тот же участок программы несколько раз и, следовательно, не отлаживать его несколько раз. Аналогичным образом, если пользователь разработал отдельную программу сортировки, то она может быть использована в любой программе, где это необходимо. (Разумеется, все входы должны быть соответствующим образом инициализированы в основной программе.) Макет программы В дополнение к сложной задаче разработки программы имеется ряд простых приемов, позволяющих создать легко читаемые, модифицируемые и корректные программы. К первому из- таких приемов относится макетирование программы. Рассмотрим следующий сегмент программы: Ф 10 INPUT А, В, С: IF A<B THEN GOTO 20 ELSE IF B<C THEN GOTO 30 ELSE D=C: GOTO 50: 'безусловный переход 20 IF A<C THEN GOTO 40 ELSE D=C: GOTO 50: 'безусловный переход 30 D = B: GOTO 50: 'безусловный переход 40 D=A: 'присвоить А значение D 50 PRINT D Перед тем как читать дальше, посмотрите, можете ли понять, что делает данный программный сегмент. Приведенная выше программа считывает три числа — А, В и С, присваивает наименьшее из них числу D и печатает число D. Хотя эта программа и выполняет требуемую функцию, ее вряд ли захочется анализировать и модифицировать. Во-первых, структура программы очень неорганизованна. Хотя в языке Бейсик имеется мало ограничений на формат программы, хорошие программисты используют форматы, позволяющие легко читать и понимать программу. В частности, на одной строке редко используется более одного оператора. Помимо этого предложение ELSE никогда не используется, если предложение THEN оканчивается оператором GOTO. Операторы в предложении ELSE будут выполнены только в том случае, если условие будет ложным независима от наличия самого оператора ELSE. Если условие истинно, та оператор GOTO выполнит соответствующий переход. 95
Ниже приводится улучшенная версия: 10 INPUT А, В, С 20 IF A<B THEN GOTO 60: 'переход на метку 60 30 IF В <С THEN GOTO 90 40 D=C 50 GOTO 120: 'безусловный переход 60 IF A<C THEN GOTO 110 70 D = C 80 GOTO 120: 'безусловный переход 90 D=B 100 GOTO 120: 'безусловный переход 110 D=A: 'присвоить А значение D 120 PRINT D Разумеется, вторая версия лучше, чем первая, хотя она также далека от «хорошей» программы. Одной из сложностей, возникающей при чтении данной программы, является трудность понимания роли функций, выполняемых отдельными кусками программы. Имеется ряд приемов, помогающих улучшить разбор и понимание программы. Осмысленные имена переменных Очень разумно использовать имена переменных, позволяющих понят^ их назначение (ограничения, накладываемые на имена переменных в языке Бейсик, обсуждались в разд. 2.1). Если программист не может изменять имена переменных А, В и С (они могли быть переданы из какой-либо другой части программы), он тем не менее может выбрать для переменной D более осмысленное имя, каким-либо образом обозначающее наименьшее число. Например, указав имя SMALL, программист даст этим хорошее указание смысла данной переменной. В качестве другого примера предположим, что переменные программы в соответствии со своими значениями могут быть названы PRINC, АМТ, YEARS и RATE, а не А, В, С и D. Такие обозначения значительно сокращают время, необходимое на уяснение функций, выполняемых программой. В некоторых случаях программисту может показаться, что использование «простых» имен удобнее, например X и Y. Однако дополнительное время, необходимое в дальнейшем для определения того, что представляется этими переменными, может значительно превысить время, затрачиваемое на поиск осмысленных имен для таких переменных. Заметим, однако, что в циклах в качестве индексов обычно принято использовать простые имена для переменных, например буквы I, J и К. Документирование программы Другим способом, позволяющим программисту облегчить читателю понимание написанных им программ, является создание хорошего описания последних. В самом общем случае докумен- 96
тация программы включает в себя (помимо текста самой программы) описание всех использованных программистом вспомогательных материалов, имеющих своей целью разъяснение пользователю или любому другому программисту содержания программы. Это позволит им в дальнейшем вносить при необходимости в программу различные изменения. Сюда входят блок- схемы, описания форматов входных данных и комментарии к выходным данным. В более узком контексте документирование предполагает включение комментариев в саму программу. По поводу комментариев внутри программы можно сделать несколько замечаний. Если программа написана хорошо, то не нужно снабжать ее комментариями на каждом элементарном шаге. Хорошо написанная программа легко понимаема, поэтому читателю нет надобности в чтении множества строк комментария для понимания функции, выполняемой небольшим куском программы. Проанализируем комментарии, приведенные во второй версии рассмотренной нами ранее программы. Самый плохой комментарий— это бесполезный комментарий. К этой категории относятся три комментария нашей программы. Комментарий 'переход на метку 60 сообщает нам ту же самую информацию, что и предшествующий ему оператор GOTO 60 То же самое относится и к комментарию 'присвоить А значение D Комментарии подобного рода только загромождают программу и могут только запутать читателя. Аналогично комментарий 'безусловный переход полезен только начинающему программисту, который абсолютно не знаком с терминологией программирования. Загромождение программы бесполезными комментариями только отвлекает читателя. Как в таком случае должны выглядеть комментарии? Во- первых, как уже говорилось, программу желательно писать таким образом, чтобы комментарии были излишни. Одной из техник, служащих этой цели, является использование осмысленных идентификаторов, а также конструкций типа while и if. Однако даже при выполнении этих требований в начале программы или отдельного ее блока необходимо поместить комментарий. Этог комментарий должен быть кратким и в то же время достаточно полным, так чтобы читатель знал функцию, выполняемую данной программой. Кроме этого, комментарий 97
обычно помещается в начале цикла. В нем указываются назначение цикла и, если это не самоочевидно, условия выхода из данного цикла. Наконец, те части программы, функции которых не очевидны, должны быть документированы полностью. Эти замечания должны рассматриваться как рекомендации, а не как строгие правила. Программист сам должен решать, какие части программы должны быть прокомментированы целиком. Другой полезный прием документирования предполагает вывод пояснительных сообщений. Программа, печатающая страницу цифр, часто оказывается бесполезной. Пишите программу так, чтобы она выводила на печать пояснительные сообщения, идентифицирующие окончательный результат. Для большей ясности в текст программы должны быть вставлены пустые строки, разделяющие операторы или группы операторов. С учетом этих требований рассматриваемый пример может быть записан следующим образом: 10 'вычисление наименьшего из трех чисел 20 PRINT «ВВЕДИТЕ ТРИ ЧИСЛА» 30 INPUT А, В, С 40 IF A<B THEN GOTO 90 50 IF B<C THEN GOTO 140 60 SMALL=С 70 GOTO 200 80 ' 90 'проверка, что меньше: А или С 100 IF A<C THEN GOTO 180 110 SMALL=C 120 GOTO 200 130 ' 140 'В есть наименьшее 150 SMALL=В 160 GOTO 200 170 ' 180 Ά есть наименьшее 190 SMALL=A 200 ' 210 PRINT «НАИМЕНЬШЕЕ ЧИСЛО РАВНО»; SMALL Сокращение числа бесполезных передач управления К сожалению, хотя мы и улучшили структуру программы и снабдили ее комментариями, трудно сделать более понятной плохо написанную программу. Основная проблема, касающаяся рассматриваемой программы, связана с ее структурой и организацией. В зависимости от истинности или ложности некоторого условия управление передается в другую часть программы. После завершения работы второй части программы управление снова передается вокруг третьей части программы. По мере возрастания сложности программы эта проблема становится все более существенной, и так до тех пор, пока окончательный вариант становится абсолютно непонятным сплетением операторов. 98
При каждом использовании оператора GOTO для передачи управления из одной части программы в другую программа ухудшает свою структуру и организацию. Однако полный отказ от оператора GOTO не всегда оправдан. Как уже говорилось в предыдущем разделе, оператор GOTO часто используется для реализации различных структур более высокого уровня (например, while, if-then-else). Вместо передачи управления из одной части программы в другую оператор GOTO служит в данном случае для создания унифицированной программной структуры. В этом случае оператор GOTO позволяет приблизиться к наиболее предпочтительному структурному программированию. Проблема бесструктурности или организации программы в виде клубка «спагетти» относится к одной из тех проблем, которые не могут быть легко разрешены путем внесения изменений в программу. Для этого необходимо тщательное планирование с начального этапа создания программы. Наименьшее число из некоторого данного набора чисел должно быть меньше или равно каждому числу в этом наборе. Однако в сравнении этого числа с каждым из чисел в наборе нет необходимости. Предположим, что просматривается данный набор чисел и программа отслеживает наименьшее число, запоминая его в переменной SMALL. Каждый раз, когда рассматривается новое число, оно сравнивается только с переменной SMALL. Если новое число меньше, чем переменная SMALL, то оно, следовательно, меньше всех ранее просмотренных чисел. Если новое число больше, чем SMALL, то SMALL по-прежнему продолжает оставаться наименьшим. Согласно приведенному анализу, решение может быть одним из следующих двух: 10 PRINT «ВВЕДИТЕ ТРИ ЧИСЛА» 20 INPUT А, В, С 30 'в SMALL устанавливается наименьшее из А, В и С 40 IF A<=B THEN SMALL-A ELSE SMALL-В 50 IF C<SMALL THEN SMALL=C 60 PRINT «НАИМЕНЬШЕЕ ЧИСЛО РАВНО»; SMALL ИЛИ 10 PRINT «ВВЕДИТЕ ТРИ ЧИСЛА» 20 INPUT А, В, С 30 'в SMALL устанавливается наименьшее из А, В и С 40 SMALL=A 50 IF B<SMALL THEN SMALL=B 60 IF C<SMALL THEN SMALL=C 70 PRINT «НАИМЕНЬШЕЕ ЧИСЛО РАВНО»; SMALL Сравните одну из этих версий с тремя предшествующими версиями. Эти новые версии решают проблему, непосредственно используя описанные выше зависимости. Как только возни- кает условие, требующее выполнения некоторых действий, происходит выполнение соответствующих операторов, расположенных в непосредственной близости от данного условия. Опера- 99
торы перехода не используются. Даже неподготовленный читатель без труда разберется в приведенном тексте, поскольку ярограмма выполняется последовательно. В комментариях, поясняющих запутанные части, нет необходимости, поскольку такие части отсутствуют. Напротив, ранние версии содержат дополнительное сравнение и пять операторов перехода. Разумеется, при работе программы не все эти переходы выполняются. Однако, для того чтобы понять работу программы, читателю необходимо проанализировать все возможные последовательности выполнения операторов. Удобочитаемость программы Для того чтобы сделать программу более пригодной для чтения, у программиста имеется еще ряд дополнительных возможностей. Одной из них является разбиение программы на части. Для этого в текст программы помещаются пустые строки, разбивающие операторы на группы. Такой способ позволяет читателю выделять логические секции в программы. Помимо использования пустых строк отдельные программы должны иметь номера строк, легко отличаемые от номеров строк, им предшествующих. Каждая подпрограмма, например, может начинаться с новой «тысячи» так, чтобы главная программа начиналась в строке 10, а последующие подпрограммы — в строках 1000, 2000 и т. д. Разделение программы на логические части и использование различных способов нумерации являются простым способом визуализации программы. Имеется еще один прием повышения удобочитаемости, заключающийся в использовании отступов. Язык Бейсик допускает свободный формат позиционирования операторов и его частей на строке. Например, оператор может занимать две или более строк и, наоборот, два или более операторов могут быть размещены в одной строке. Некоторые программисты используют эту свободу далеко не лучшим образом, не обращая внимания на позиции операторов в строках. Эта небрежность усложняет понимание программы. В разд. 2.1 мы выделили ряд принципов использования отступов, которые следуют структурам, написанным на элементарном псевдокоде. Эти структуры сохранятся до конца книги. «Хитрое» программирование Наконец, программист должен избегать всяческого «хитрого» программирования. Например, два приведенных ниже оператора вычисляют наибольшее и наименьшее из чисел А и В: 100 BIG=(A + B + ABS(A—B))/2 110 SMALL=(A+B—ABS(A— B))/2 100
Это пример программирования, которого следует избегать. Рассмотрим, как выполняются эти операции. А+В есть сумма большего и меньшего чисел. ABS(A—В) есть абсолютное значение разности между этими двумя числами, которое равно большему числу за вычетом меньшего. Если BIG представляет собой большее число, a SMALL — меньшее, то тогда А+В равно BIG + SMALL, a ABS(A—В) равно BIG—SMALL, что есть 2*BIG, и А+В—ABS(A—В) равно (BIG +SMALL) — (BIG—SMALL), что равно 2*SMALL. Следовательно, деление на 2 дает соответственно BIG и SMALL. Следовательно, оба оператора выполняются правильно. Программа, перегруженная «хитрыми» операторами и нуждающаяся в модификации, обречена на неудачу. Ее невозможно расшифровать, ести только сам составитель не помнит, как она работает. К сожалению, на сегодняшний день имеется множество программистов, использующих «хитрости», понять которые может только автор программы. Хотя подобная тактика делает создателя программы незаменимым при модификации программы (что гарантирует ему пожизненную занятость на данной работе) , она не приемлема в тех ситуациях, где используются корректно сконструированные и модифицируемые программы. Более простым вариантом рассмотренной выше программы может быть, например, такой: 100 IF A>B THEN BIG=A: SMALL^B ELSE BIG-B: SMALL=A Эта версия более понятна, чем предыдущая, и не требует никаких дополнительных операторов. Сообщение о конце набора данных Программисту удобно иметь набор различных доступных приемов, позволяющих разрешать специфические проблемы или некоторые их части. Это полезно при разбиении программы на ряд подзадач, которые уже были написаны. К таким приемам относятся сообщение о конце набора данных при поиске элемента в массиве и тому подобные сообщения. ^Предположим, что нам необходимо считывать пары значений из строк DATA и помещать второе число из каждой пары в позицию массива, указанную первым элементом пары. Рассмотрим следующий цикл: while true do read i, a(i) endwhile Поскольку данный цикл выполняется бесконечно, мы неизбежно придем к ситуации, в которой будет сделана попытка чтения несуществующих данных. Естественно, это приведет к возникновению ошибки, и выполнение программы прекратится. Имеется два способа сигнализации о выходе на конец набора Дании
ных без возникновения аварийной ситуации. Эти способы называются методом заголовка и методом концевика. Если число элементов данных известно заранее, то перед набором данных помещается заголовок, содержащий точное число элементов данных в наборе. Используя это число, мы можем выполнить оператор READ в цикле точно с необходимым числом шагов. В качестве примера предположим, что нам необходимо прочесть пять имен и поместить эти имена в массив. Используя метод заголовка, мы можем написать 10 DEFSTRA 20 DIM A(100) 30 READ N 40 FOR 1=1 ТО Ν 50 READ К, А (К) 60 NEXT I 70 END 500 DATA 5 510 DATA 2, «ГЕЙЛ» 520 DATA 1, «ВИВЬЕН» 530 DATA 5, «КРИС» 540 DATA 3, «МИРИАМ» 550 DATA 4, «ЛИНДА» Другой способ предполагает использование «недопустимого» значения, сигнализирующего о конце набора данных. Этот способ может быть использован в том случае, если число данных в наборе неизвестно. Модифицируя предыдущий пример, предположим, что нам необходимо прочесть неопределенное число имен и записать их в массив. Используя для этого концевик 0,«ХХХ», сигнализирующий о конце набора данных, мы можем написать 10 DEFSTR А, Р 20 DIMA(100) 30 READ К, PRSN 40 IF K=0 THEN GOTO 70 50 А (К)-PRSN 60 GOTO 30 70 END 500 DATA 2, «ГЕЙЛ» 510 DATA 1, «ВИВЬЕН» 520 DATA 5, «КРИС» 530 DATA 3, «МИРИАМ» 540 DATA 4, «ЛИНДА» 550 DATA 0, «XXX» Заключение В данном разделе мы отметили ряд моментов, составляющих перечень того, что надо делать и не надо делать при написании программы. Подведем итоги. 1. Используйте в одной строке только один оператор. 102
2. Используйте осмысленные идентификаторы. 3. Документируйте программу, используя соответствующие комментарии и пояснительные сообщения для пользователя. 4. Используйте пустые строки и четкую нумерацию строк. 5. Используйте отступы (для FOR, IF-THEN-ELSE и т. д.). 6. Избегайте ненужных передач управления. 7. Избегайте хитроумных приемов программирования. 8. Используйте стандартные приемы. Эти правила должны рассматриваться как основополагающие, однако они не абсолютны. Программист должен сам принять решение, когда эти правила можно нарушить и когда необходимо сделать исключение. Программирование требует инициативности и оригинальности. Однако стиль программирования, базирующийся на вышеупомянутых приемах, делает программу удобочитаемой, легко отлаживаемой и легко модифицируемой при возникающей в этом необходимости. Упражнения 1. Напишите программу, считывающую последовательность чисел и печатающую самую длинную возрастающую последовательность из этих чисел. 2. Напишите программу, которая считывает денежную сумму (меньше 1 руб.) и печатает достоинство монет, необходимых для получения данной суммы при помощи наименьшего числа монет (например, 0,43 руб.=20 коп.+ +20 коп.+З коп.). После обработки всех чисел напечатайте общее использованное число монет. 3. Сделайте упражнение 2, но при этом выдайте все возможные сочетания, а не только сочетание с наименьшим числом монет. 4. Стандартная формула для вычисления сложного процента имеет вид a=p»(l+r/n)t(n*t), где ρ — первоначальный вклад, г — ежегодный процент прироста вклада, π — число лет, для которых вычисляется сложный процент, t — число лет, в течение которого вклад находится в банке, и а — величина, на которую вырос первоначальный вклад. (а) Вычислите итоговую сумму для 100 долл., вложенную в качестве 5%-ного вклада за период 25 лет. Воспользуйтесь явно заданным циклом и вышеприведенной формулой. (б) Выполните п. (а), воспользовавшись последовательными значениями п= 1,2, ...,365 (годовой сложный процент по отношению к проценту, рассчитываемому по дням), и проанализируйте результаты. Можете ли вы вывести формулу для «непрерывного» процента? 5. Одной из распространенных задач, которая может быть решена на вычислительной машине, есть решение π уравнений с η неизвестными по методу Гаусса. Например, система из трех уравнений с тремя неизвестными может иметь следующий вид: а(1, l)»x+a(l,2)*y+a(l,3)»z=b(l) а (2, 1)»х+а(2, 2)»у+а(2, 3)»z=b(2) а(3, l)»x+a(3,2)#y+a(3,3)»z=b(3) Алгоритм нахождения х, у и ζ имеет следующий вид: (а) Перепишите (если это требуется) уравнения таким образом, чтобы а(1, 1) не было равно 0. (По крайней мере хотя бы один первый коэффициент не должен быть равен нулю. Почему?) 103
(б) Исключите коэффициент χ из второго уравнения, заменив его новым по следующему правилу: умножьте первое уравнение на а (2, 1)/а(1, 1) и вычтите результат из второго уравнения. Проделайте то же самое для третьего уравнения, умножив его на этот раз на а(3, 1)/а(1, 1). (в) Исключите коэффициент у из третьего уравнения, проделав описанный процесс над вторым и третьим уравнениями. (г) После выполнения указанных действий уравнения будут иметь вид (символы звездочки обозначают ненулевые коэффициенты) *x+*y+*z=c(l) •y-f-»z=c(2) *z=c(3) где каждый первый коэффициент в уравнении ненулевой, а с(1), с(2) и с(3)—соответствующие константы. Значение для ζ есть с(3), поделенное на коэффициент для ζ в последнем уравнении, а значения для χ и у могут быть получены путем подстановок в предыдущие уравнения. Напишите программу, считывающую двумерный массив а и одномерный массив Ь, которая вычисляет значения х, у и ζ. 6. Напишите программу, отыскивающую проход по лабиринту. Форма лабиринта такова, что каждый квадрат либо открыт, либо закрыт. Если квадрат открыт, то в него можно войти с любой стороны (но не с угла). Если квадрат закрыт, то вход в него запрещен. Программа считывает размеры массива, за которыми следуют серии нулей и единиц, обозначающих статус квадратов (0 обозначает открытый квадрат, а 1—закрытый квадрат). Программа находит проход через лабиринт (если таковой существует), двигаясь от верхнего левого квадрата к правому нижнему квадрату. Оба этих квадрата должны быть открыты. После отыскания прохода программа печатает лабиринт, обозначая символом звездочки каждый закрытый квадрат и символом пробела каждый открытый. Затем программа перепечатывает лабиринт, обозначая символами единицы проделанный путь. 7. Некоторая фирма производит три вида товара. Стоимость каждого из них содержится в массиве DIM COST(2,3). Стоимость COST(l, I) есть стоимость товара I со скидкой для постоянного покупателя, а стоимость COST (2,1) есть стоимость товара I для обычного покупателя. Фирма поддерживает список своих покупателей в следующем агрегате данных: DEFSTR N 'сведения о покупателях DIM NAME (20): 'информация о фамилии DIM ONORD(20, 3) 'информация о заказах DIM BAL(20): 'денежный баланс покупателя NAME(I) содержит фамилию 1-го покупателя, ONORD(I, J) есть количество товара J, покупаемого 1-м покупателем, и BAL(I)—сумма, которую должен заплатить 1-й покупатель. Напишите основную программу и несколько подпрограмм, выполняющих следующие действия: (а) Считать массивы COST и NAME. Инициализировать массив ONORD и массив BAL нулями. (б) Считать набор данных в операторе DATA, каждая строка которого содержит фамилию покупателя, его класс (постоянный или обычный) и три целых числа, представляющих собой изменения в массиве ONORD для данного покупателя. Если изменения положительны (покупателем заказаны дополнительные товары), то соответствующая сумма для покупателя данного класса (выгодный или обычный) используется для обновления массива BAL. Если изменения отрицательны (заказ был отменен), то используется меньшая стоимость. Покупатель не может отменить покупку большего числа товаров, чем он заказал. Если произведены все три изменения и новый баланс меньше, чем старый баланс, то к разности добавляются дополнительные 10% процентов накладных расходов. Соответствующие поля в поле записи о покупателе обновляются программой с именем UPDATE. Запись о поку- 104
пателе, которая подлежит обновлению, определяется программой FIND, входными данными для которой является фамилия покупателя. Эта программа определяет индекс записи о покупателе в массиве NAME. (в) Если в операторе DATA в месте, отведенном под имя покупателя, встречается пустая строка, то печатаются имена всех покупателей, все значения ONORD и BAL, после чего программа прекращает свое выполнение. 2.3. НАДЕЖНОСТЬ ПРОГРАММЫ Произведем обзор этапов создания программы. Начнем с, возможно, нечеткой постановки задачи. Постановка задачи уточняется в процессе выяснения необходимых ей входных и выходных параметров. Выбирается алгоритм решения. Алгоритм может быть хорошо известен или же может быть одним из тех, которые программист создает сам. Алгоритм содержит несколько предложений, которые могут быть непосредственно переведены на алгоритмический язык программирования. Другие его предложения не поддаются прямой трансляции и указывают лишь на те подзадачи, которые необходимо выполнить. Такие предложения требуют дальнейшего уточнения и обычно разрабатываются в подалгоритме. Подалгоритм может в свою очередь породить другие подалгоритмы. Этот процесс продолжается до тех пор, пока подалгоритмы нижнего уровня не будут переведены в операторы языка программирования. В процессе развития каждого алгоритма определяются структуры, которые необходимо использовать. После написания алгоритмов они переводятся в операторы языка. Если программист будет следовать указаниям, приведенным в предыдущих разделах, то программа получится ясной, легко понимаемой и модифицируемой. Программист может поздравить себя с хорошо выполненной работой. Однако, может быть, эти поздравления преждевременны? Нам кажется, что программист должен быть сперва в состоянии ответить на следующий вопрос: работает ли написанная им программа? Если она работает, то это прекрасно независимо от того, насколько корректно она разрабатывалась, как хорошо документировалась и красиво ли распечатываются выдаваемые ей результаты. Ответ «да» на этот вопрос предполагает, что программа работает всегда, а не только в нескольких конкретных случаях. Программа, выполняющаяся неверно для нескольких отдельных наборов входных данных, неизбежно столкнется в своей работе с такими ситуациями и именно такими наборами и, разумеется, выполнится неверно. Даже если программа и работает, возникает вопрос: правильно ли она работает? Если программе, подсчитывающей финансовые расходы и требующей для своего выполнения 8 ч машинного времени, отведено для работы только 2 ч машинного времени, то пользы от нее, разумеется, не будет никакой. Эффективность кажется порой чисто академической проблемой. 105
однако при программировании реальных задач на реальных машинах этот вопрос может вырасти в критический. Вопросы, связанные с эффективностью и корректностью работы программы, относятся к проблеме надежности программы. В оставшейся части данного раздела мы рассмотрим некоторые вопросы, касающиеся надежности программ. Рассмотреть все аспекты этой темы не представляется возможным, поскольку для заданных временных и пространственных ограничений не существует общего способа определения того, делает ли произвольная программа со всеми допустимыми для нее наборами входных данных именно то, что от нее требуется. Однако стремление программиста создать корректную программу и учет им возможных трудностей повышают вероятность написания правильной программы и сокращают общее время, затрачиваемое на ее создание. В данном разделе мы рассмотрим три основных вопроса надежности программ: правильно ли составлен алгоритм; выдает ли программа ожидаемые результаты; достаточно ли эффективна данная программа? Корректность программы Логическая ошибка наносит значительный ущерб при программировании и не может быть отнесена к этапу перевода алгоритма в текст программы. В качестве простого примера рассмотрим следующую проблему и ее решение как в алгоритмической, так и в программной форме. Предположим, что нам необходимо прочесть два числа из оператора DATA, вычислить и напечатать их сумму. Предлагается следующий алгоритм: readnl,n2 ans = nl*n2 print ans Выражая этот алгоритм в терминах языка Бейсик, получим 10 программа solution, 20 READ N1,N2 30 ANS = N1*N2 40 PRINT «ОТВЕТ РАВЕН»; ANS 50 END 60 DATA ... Очевидно, что приведенное выше решение неправильно, однако при этом важно понять, где допущена ошибка. Случайный читатель, ознакомившийся с постановкой задачи и программой solution, может сказать, что ошибка заключается в неправильно набранном символе (был напечатан символ «*» вместо «+»). Однако, читатель, который просматривал эти решения с начала и до конца, может заметить, что программа является коррект- 106
ной реализацией приведенного выше алгоритма. В действительности программа составлена правильно, но она решает «неверную задачу». Ошибка в данном случае допущена в алгоритме: вместо сложения двух чисел производится их умножение. Такая ошибка относится к наиболее трудно обнаруживаемым. (Если программист использует при тестировании этой задачи значения для входных данных, равные 2 и 2, то он даже не зарегистрирует эту ошибку.) Как можно убедиться в том, что программа составлена логически корректно? На этот вопрос нет прямого ответа. В некоторых случаях ошибку можно отыскать непосредственным анализом текста алгоритма. Большинство программ, которые выполняют некоторую заданную последовательность вычислений, могут быть проверены на правильность именно таким образом. Однако большая часть программ ирпользует циклы и условные выражения. Эти управляющие структуры довольно сложно анализировать, особенно в тех случаях, когда число итераций в цикле является переменной величиной. Имеются формальные методы определения корректности программы. Однако они довольно громоздки и зачастую более сложны, чем сама программа. По этой причине они редко применяются на практике. Следовательно, необходимо пользоваться такими приемами, которые менее совершенны для проверки правильности программы. Эти приемы в сочетании с хорошими принципами программирования и здравым смыслом позволяют сократить число логических ошибок. Программист должен стремиться кодировать все логические части программы в виде отдельных единиц. Каждая из таких единиц должна сопровождаться комментарием, в котором описывается состояние дел до момента выполнения данной части программы. Это особенно существенно для циклов. Каждая часть программы должна быть написана достаточно ясно, с тем чтобы читатель, понимающий ситуацию до момента выполнения данной части программы, смог понять ее и после выполнения этой части. Если функция какого-либо участка программы не очевидна, она должна быть документирована более полно с подробным объяснением того, какие дей-ι ствия выполняются. Если программист придерживается этих правил с начала и до конца составления программы, то программа будет включать в себя в качестве комментариев ряд утверждений относительно значений используемых в ней переменных. Текст программы можно рассматривать как доказательство того, что каждое очередное утверждение истинно на основании предыдущего утверждения и текста программы, заключенного между этими утверждениями. Это позволяет читателю отследить все преобразования переменных от начала и до конца программы. Если в процессе преобразований выясняется, что получаемый программой ре- 107
зультат неправилен, то это указывает на логическую ошибку в программе. Мы указали еще на одно преимущество составления простых программ. Теоретически необходимо доказывать корректность любой программы, предположительно составленной правильно. Однако на практике это не всегда возможно. Если программа коротка и проста, то ее легче анализировать. Даже если доказать корректность программы не представляется возможным, всегда имеется возможность интуитивно оценить пригодность отдельных ее частей по отношению к окончательному решению. Эти интуитивные оценки и выводы выступают как комментарии, разделяющие программу на ряд базовых сегментов. Они служат предпосылками для более формальных решений и должны рассматриваться также серьезно, как и сами операторы программы. И если отдельные части программы не поддаются оценке, то окончательный результат скорее всего окажется неверным. Тестирование и отладка Вопрос о том, выполняет ли программа возложенные на нее функции, остается и после анализа логики программы. Подход может быть правильным с логической точки зрения, однако фактическая реализация может оказаться некорректной. Ошибка может быть сделана в самых различных местах. Ответственность за отладку программы до того момента, когда можно будет сказать, что программа составлена правильно, возлагается на программиста. Хотя и не всегда можно с уверенностью сказать, являются ли произведенные проверки достаточными, тем не менее можно выделить ряд полезных практических приемов. Тестирование — это процесс выявления ошибок в программе. Отладка — это процесс исправления программы таким образом, что при этом исправляются существующие ошибки и не вносятся новые. На практике исправление ошибок без оказания при этом влияния на остальные части программы оказывается достаточно сложной задачей. Необходимо провести важное различие между симптомом ошибки и ее причиной. Например, предположим, что массив А имеет верхнюю границу, равную 10, и делается попытка обратиться к элементу массива А(1) со значением I, равным 11. Ошибку такого рода можно исправить, изменив значение верхней границы для массива А на 11 и инициализировать А (И) нулем. Однако такая модификация редко приводит к исправлению ошибки. Она просто устраняет ее симптом. Действительной причиной ошибки может быть выполнение цикла на единицу больше, чем необходимо. Правильным действием будет модификация цикла, а не оператора объявления массива. Для программиста важно научиться по индикации ошибки отличать симптом от причины. В любом случае отладка должна преследовать сво- 108
ей целью устранение причины ошибки, а не ее симптома. В последующих примерах мы рассмотрим различные симптомы возникновения ошибок и их возможные причины. Мы не будем рассматривать подробно логику задачи, поскольку рассматриваться будут только изолированные сегменты программы. Однако программист должен обращать свое внимание главным образом на ошибки, исходящие от метода решения задачи, а не только на ошибки в уже написанной программе. Как определить, имеются ли в программе ошибки? В самом общем случае имеется три возможности: или выдается сообщение, указывающее на ошибку (например, сообщение о делении на нуль или системное сообщение о том, что размер программы превышает отведенное ей пространство), или же никаких сообщений об ошибке не выдается, однако результат, очевидно, неверный (например, программа, вычисляющая сумму квадратов, выдала отрицательное число), или же никаких следов ошибки не обнаруживается. В первых двух случаях очевидно, что что-то не так. Остается только определить, что именно. Выяснение причины ошибки может оказаться непростым делом, однако это, без сомнения, существенно легче, нежели в третьем случае, когда программист не подозревает о существовании ошибки. Наиболее опасной из всех ошибок является та, проявление которой незаметно. Это свидетельствует о том факте, что этап тестирования не был доведен до конца. К сожалению, существует множество программ, которые «работают» довольно продолжительное время, прежде чем при некотором наборе данных обнаруживается ошибка, никогда ранее не встречавшаяся. Хотя программист редко может быть полностью уверенным в безошибочности составленной им программы, он должен сделать все возможное для исключения всех вероятных ошибок. Рассмотрим теперь несколько наиболее распространенных типов ошибок, встречающихся в программистской практике. Синтаксические ошибки и ошибки этапа выполнения Синтаксические ошибки — это ошибки, обнаруживаемые на этапе трансляции программы. Обычно они легко исправимы, если только не связаны с какой-либо достаточно редко используемой конструкцией алгоритмического языка. Довольно часто возможный источник ошибки указывается в части системного сообщения. Однако слепое следование указанному в сообщении предположению может исключить дальнейшее появление данного сообщения, но при этом не устранить саму ошибку, а возможно, породить .еще одну. Хотя сообщения об ошибках и помогают локализовать ошибку, окончательное решение о методе ее исправления должен сделать сам программист. Большинство ошибок, с которыми программист сталкивается в процессе отладки программы, относятся к ошибкам этапа 109
выполнения, т. е. ошибкам, происходящим в процессе работы программы. Эти ошибки не всегда легко локализовать и обычно еще труднее ибправить. Рассмотрим несколько наиболее распространенных ошибок этапа выполнения, характерных для языка Бейсик. Повторное использование имен переменных К наиболее распространенной ошибке в программах на языке Бейсик относится использование одного и того же имени переменной для различных целей. Наиболее простым примером такой ошибки может служить ошибочная установка в одну и ту же переменную двух различных значений. Программист может ошибочно полагать, что прежнее значение переменной уже больше не требуется, или же он может забыть о том, что переменной уже было присвоено значение в начале программы. Такая ошибка часто происходит в том случае, когда программист непреднамеренно вводит две переменные, два первых символа имени которых совпадают, а используемая им версия языка Бейсик однозначно идентифицирует переменные только по первым двум символам. Имеются несколько способов, позволяющих уменьшить ошибки подобного рода. Во-первых, программист должен описать в программе назначение каждой переменной. Это описание должно быть вставлено в начало программы как комментарий. Во- вторых, программист должен воспользоваться распечаткой перекрестных ссылок, вырабатываемых специальными служебными программами, имеющимися в большинстве версий языка Бейсик. Эта распечатка содержит номера всех операторов, ссылающихся к каждой переменной внутри программы. Тщательная проверка всех ссылок позволит выделить те операторы, которые не должны ссылаться к некоторой переменной. Существует еще одна проблема, которая может не приводить к появлению сообщения об ошибке, хотя выдаваемые при этом программой результаты неверны. Такая ситуация возникает в том случае, когда локальная переменная в подпрограмме не была определена. Например, рассмотрим программу, вычисляющую денежное накопление, если при этом вклад PRINC был сделан под процент RATE за период YEARS. Предположим, что данное вычисление повторяется для переменного числа входных наборов данных. Программа может иметь следующий вид: 10 'программа prog 20 READ NUMBER 30 FOR 1=1 ТО NUMBER 40 READ PRINC, RATE, YEARS 50 GOSUB 1000: 'подпрограмма final устанавливает переменную AMT 110
60 PRINT PRINC; RATE; YEARS; AMT 70 NEXT 1 80 END 500 DATA . . . • · · 1000 'подпрограмма final 1010 'входы: PRINCE, RATE, YEARS 1020 'выходы: AMT 1030 AMT= PRINC 1040 FOR 1=1 TO YEARS 1050 AMT=AMT*(1+RATE) 1060 NEXT I 1070 RETURN 1080 'конец подпрограммы С логической точки зрения программа составлена корректно. Однако запись ее на языке Бейсик сделана неправильно. Проблема заключается в том, что переменная I в подпрограмме final уже была использована в основной программе. Поэтому вместо ввода и обработки входных данных от 1 до NUMBER программа может требовать бесконечного ввода данных. Это обусловлено тем, что при возврате управления от подпрограммы основной программе значение I установлено в значение YE- ARS+1. Разумеется, значение YEARS различно для каждого ввода, поэтому контроль числа вводимых чисел отсутствует. Если значение YEARS всегда меньше, чем NUMBER —1, то значение I после возврата из подпрограммы final всегда меньше, чем NUMBER, и по этой причине цикл FOR будет повторяться бесконечное число раз. Это пример бесконечного цикла (который заканчивается, когда закончатся данные для программы). С другой стороны, если значение YEARS из входного набора превысит NUMBER—1, то цикл прекратится после возврата из подпрограммы final и программа завершит свою работу преждевременно. Проблема заключается в том, что в программе и подпрограмме используется одна и та же переменная. Программист предполагает, что цикл в основной программе выполнится за NUMBER проходов и цикл в подпрограмме выполнится за YEARS проходов. Ошибочное использование одной и той же переменной для обоих этих циклов привело к возникновению вышеуказанной ошибки. Все переменные, использованные в подпрограмме, должны были быть перечислены в ее шапке, что позволило бы избежать такой ситуации. Если бы подпрограмма содержала комментарий 1025 'локальные переменные: I то программист мог бы заметить, что переменная I использована для других целей. 111
Ошибки в счетчиках К другому типу ошибок принадлежат ошибки, связанные с неверно заданным числом итераций в цикле. К числу распространенных случаев относится такой, при котором цикл выполняется на один раз больше, чем требуется (или меньше). Такая ошибка может приводить, а может и не приводить к появлению соответствующего сообщения. Однако даже при появлении сообщения оно имеет мало отношения к управлению циклом. В качестве простого примера рассмотрим проблему нахождения среднего среди произвольного числа ненулевых элементов, записанных в начале массива. Предварительно массив был обнулен. Можно предложить, например, такую программу: 10 DIM(IOO) 100 FOR 1=1 ТО 100 110 IF A(I)=0 THEN GOTO 140 120 SUM=SUM+A(I) 130 NEXT I 140 AVERAGE» SUM/I К сожалению, эта программа работает неправильно. Ошибка состоит в том, что индекс I увеличивается до того момента, когда А(1) проверяется на нулевое значение. Например, если I устанавливается в 5, то проверяется А (5). Тогда I устанавливается в 6 и проверяется А (6) и т. д. Если в массиве имеется 20 ненулевых элементов, то I установится в 21 до выхода из цикла, и это его значение, используемое для нахождения среднего в строке с номером 140, окажется неверным. Правильное (и легко понимаемое) решение выглядит следующим образом: 10 DIM(100) 100 FOR 1=1 ТО 100 110 IF A(I)=0 THEN GOTO 140 120 SUM=SUM+A(I) 130 NEXT I 143 'значение I есть позиция первого нулевого элемента; всего имеется I — 1 ненулевых элементов 150 CNT=I-1 160 AVERAGE=SUM/CNT Точность числовых результатов Даже после того, как программа была написана и проверена и был установлен факт правильности выполнения цикла соответствующее число раз, возможность появления ошибки сохраняется. К ней относится рассмотренное выше повторное или 112
некорректное использование идентификаторов для различных целей. Еще одна ситуация связана с точностью получаемых результатов. Рассмотрим, например, следующий сегмент программы, который при помощи теоремы Пифагора определяет, является ли рассмотренный треугольник прямоугольным: 10 PRINT «ВВЕДИТЕ ТРИ ЧИСЛА» 20 INPUT Χ, Υ, Ζ 30 IF Xf2+Yt2-Zf2 THEN PRINT Χ; Υ; Ζ; «ОБРАЗУЮТ ПРЯМОУГОЛЬНЫЙ ТРЕУГОЛЬНИК* ELSE PRINT Χ; Υ; Ζ; «HE ОБРАЗУЮТ ПРЯМОУГОЛЬНОГО ТРЕУГОЛЬНИКА» Выполняя эту программу «вручную» для значений 5, 12 и 13, следует ожидать получения правильного ответа. Попытайтесь запустить программу, и вы получите иной результат. Проблема заключается в методе, используемом в языке Бейсик для операции возведения в степень. Сложные вычисления часта дают весьма малые погрешности (порядка 0,000001), поэтому результат возведения в степень может не быть целым числом,, даже если оба операнда целые числа. Если напечатать обе стороны равенства из оператора 30 при помощи оператора 25 PRINT Χ|2+Υ|2; Ζ|2 то дважды напечатается значение 169. Однако проверка на равенство на большинстве микроЭВМ даст значение «ложь». Это обусловлено тем фактом, что числа представлены внутри ЭВМ с большей точностью, чем с той, с которой они печатаются, и,, следовательно, обе части выражения не равны между собой,, хотя внешне в обоих случаях печатается число 169. Важно помнить, что ЭВМ устанавливает точность результата по строго заданным правилам, которые не всегда совпадают с интуицией программиста. Эти правила часто зависят от конкретной реализации вычислительной машины, что еще больше усложняет контроль над ними. Даже если проверку на равенство в операторе с номером 30 заменить на X*X + Y*Y=Z*Z то программа может не выдать требуемого результата. (Хотя программа может работать правильно со значениями 5, 12 и 13, она не выдаст нужного результата для значений 0,5, 1,2 и 1,3. Это еще раз иллюстрирует трудность тестирования программы в полном объеме.) Этот случай распространяется и на управление числом проходов цикла для индекса в цикле FOR. В качестве примера рассмотрим следующую программу: Ю si=0 20 S2=0 30 FOR I 20 TO 20 STEP 2 113
40 S1 = S1+1 50 NEXT I 60 FOR X= -2 TO 2 STEP .2 70 S2=S2+1 80 NEXTX 90 PRINT SI, S2 Можно предположить, что при выполнении оператора PRINT значения S1 и S2 будут совпадать, поскольку каждый цикл выполняется равное число раз (21). Любопытно отметить, что это не так. Первый цикл выполняется правильно, поэтому значение S1 есть 21 (в формате числа с плавающей запятой). Однако значение S2 есть 20, и это указывает на то, что второй цикл выполнялся 20 раз. Причина этой аномалии заключается в способе представления чисел с плавающей запятой. Внутреннее представление числа в формате с плавающей запятой может быть очень близким приближением к нему. Например, число 5 может быть представлено в виде 4,99999. Иногда эту разницу можно игнорировать. В тех случаях, когда требуется точный результат, как это было в приведенном примере, представление с плавающей запятой может привести к неверному результату. Там, где решение может быть наиболее просто выражено при помощи целых чисел, числа с плавающей запятой использовать не рекомендуется. Числа с плавающей запятой также не могут проверяться на точное равенство. Вместо этого они должны проверяться на некоторую заданную точность, как, например, в операторе IF ABS(X—Y)< = DELTA THEN... Величина DELTA может быть сделана сколь угодно малой, однако она не может быть нулем. Следовательно, если в программе, осуществляющей проверку треугольника на прямой угол, оператор с номером 30 заменить на оператор 30 IF ABS((Xf2+Yf2) —(Zf2))<=lE—06 THEN... то программа выдаст правильный результат. В общем случае можно сказать, что для некоторых приложений использование чисел с плавающей запятой необходимо, а для некоторых просто нежелательно. Ответственность за установку правильных атрибутов для конкретной переменной возлагается на программиста. Тестирование Наличие некоторых упомянутых выше ошибок распознается по соответствующему сообщению об ошибке. В других случаях зациклившаяся программа может прервать свое выполнение по времени или по выходу за допустимые границы памяти. В большинстве случаев ошибка остается незамеченной до тех пор, пока не встретится вызывающая ее комбинация входных данных. 114
По этой причине перед использованием программы по назначению необходимо произвести ее тщательное тестирование. Хот* метода тестирования, позволяющего выявить все возможные ошибки, не существует, тем не менее имеется ряд принципов, следование которым позволяет выявить большинство вышеупомянутых ошибок. Эти принципы ни в коей мере не являются полными и исчерпывающими; каждая программа должна тестироваться с учетом присущих именно ей особенностей. Разумеется, сначала должна быть произведена проверка с простыми наборами данных, решения для которых легко могут быть проверены вручную. Если решение, выданное программой, не совпадает с независимо подсчитанными результатами, та очевидно, что в программе имеются ошибки. Однако при наличии известных правильных ответов для некоторых наборов данных существует искушение скорректировать программу таким образом, чтобы она выполнялась правильно именно для них, т. е. «подогнать» ее под известные результаты. Но такой способ не обязательно приводит к исправлению ошибки. Возможно, что ошибка проявится при других наборах данных. Необходимо отследить все промежуточные результаты программы от начала до конца вычислений и определить, на каком шаге произошла ошибка. Только после этого программист может считать, что источник ошибки обнаружен. В действительности даже при получении правильных результатов для простых наборов данных необходимо проверять и промежуточные результаты, с тем чтобы быть уверенным в том, что программа выполняется правильно на всех этапах своего выполнения. Другое тестирование предполагает проверку на «граничные» условия. Например, предположим, что закон о налогах освобождает всех граждан с доходом, меньшим чем 500 долл., от уплаты налога, а зарабатывающие больше 500 долл. должны платить налог в размере 4%. Важно проверить правильность работы программы для значения дохода в 500 долл., убедившись, что налог вычисляется правильно. В некоторых случаях контроль на граничные значения может быть осуществлен простой проверкой того, выполняются ли требуемые действия при равенстве входных данных этим значениям. В других случаях может потребоваться проследить правильность выполнения всех промежуточных шагов программы. Помимо индивидуальной проверки правильности работы программы для отдельных граничных значений входных данных может понадобиться проверка при их различных комбинациях. После того как программист убедился в том, что при простых значениях входных данных, а также при граничных значениях программа выполняется верно, он может начать проверку программы с теми значениями, которые считаются неправильными. Очень часто эффективность работы программы определяется ее способностью не только правильно выполняться при 115
допустимых значениях входных данных, но и защищать себя от недопустимых входных значений. Даже если пользователь полагает, что все подаваемые на вход программы данные являются корректными, в программе должен быть также предусмотрен соответствующий контроль. Если имеется программа, проверяющая набор входных данных, то этот факт должен быть отмечен в комментарии к основной программе. Это освободит программиста от необходимости проверки входных данных на значимость. Должны также проверяться входные данные для каждой из подпрограмм. Каждая программа должна проверять входные данные на соответствие предполагаемым значениям. Важно также проверить, не влияют ли некорректные данные на правильность вычислений и последующие вводимые данные. Наконец, программа должна быть проверена с большим набором значений, полученным равномерной выборкой из допустимого набора значений. В некоторых случаях имеется возможность сравнить результаты с уже имеющимися значениями, полученными от существующих (но, может быть, менее эффективных) программ. В тех случаях, когда это невозможно, некоторые тесты должны быть проделаны вручную, хотя это и может оказаться весьма трудоемким делом. Любое время, потраченное на отладку программы при ее составлении, с лихвой окупит время на устранение ошибок, выявленных на этапе работы программы. Как должно осуществляться тестирование, после того как мы задали значения, по которым требуется производить эту проверку? Если программа написана по принципу «сверху вниз», то каждая входящая в нее подпрограмма должна быть проверена в отдельности, а затем проверяется вся программа целиком. Программы могут проверяться по принципу «сверху вниз». Это означает, что первой должна проверяться главная программа, затем вызываемые из нее подпрограммы и, наконец, вся система целиком. При проверке системы на некотором уровне предполагается, что вызываемые подпрограммы уже существуют. Для того чтобы программу можно было запустить, программист должен добавить к ней фиктивные подпрограммы. Рассмотрим, например, следующую программу: 1000 'подпрограмма rout 1010 'входы: Р1 1020 'выходы: Р1, X 1030 'локальные переменные: Ql, Q2 1040 'группа 1 операторов, присваивающих переменной X значение 1200 Q1 = P1 1210 Q2=X 1220 GOSUB 2000: 'подпрограмма subl, модифицирующая Ql и Q2 1230 P1 = Q1 116
1240 X=Q2 1250 7группа2 операторов, модифицирующих PI и X 1400 RETURN 1410 'конец подпрограммы Тестирование вышеприведенной программы заключается в определении правильности работы операторов в rpynnel и груп- пе2. Однако для того, чтобы подпрограмма rout смогла выполниться, вместо настоящей подпрограммы subl должна быть создана фиктивная подпрограмма. Например, 2000 'подпрограмма subl 2010 'входы: Ql, Q2 2020 'выходы: Ql, Q2 2030 Ql = 8 2040 Q2=9 2050 RETURN 2060 'конец подпрограммы Перед тем как подпрограмма subl будет написана, значения» «вычисляемые» фиктивной подпрограммой, могут быть сознательно изменены таким образом, чтобы протестировать все возможные варианты. После того как программа rout будет проверена, могут быть написаны и проверены программы, подобные subl. Методом тестирования, при котором сначала проверяются программы, написанные на верхних уровнях, а затем на нижних, называется тестирование сверху-вниз. Другой метод тестирования, называемый тестированием «снизу-вверху производится в обратном порядке. С логической точки зрения программа должна создаваться сверху-вниз (нельзя сказать, что должна делать подпрограмма до тех пор, пока не написана вызывающая ее программа). Однако после того, как все программы будут написаны, программы на нижних уровнях могут быть написаны и проверены первыми. Такой тип тестирования гораздо проще, поскольку тестируемая программа проверяется уже на базе проверенных подпрограмм. По этой причине такая методика получила более широкое распространение среди программистов. Единственный недостаток такого тестирования заключается в том, что вызывающие программы могут быть проверены не полностью. Например, если подпрограмма возвращает только положительные числа, то вызывающая программа никогда не проверяется с отрицательными числами. Если в дальнейшем подпрограмма модифицируется таким образом, что она возвращает в качестве результата также и отрицательные числа, то в вызывающей программе может возникнуть необнаруженная ранее ошибка. Впрочем, оба этих Метода могут быть использованы достаточно эффективно, если тестирование является всесторонним. 117
Трассировка выполнения программы важна как в общем тестировании (при этом проверяются промежуточные результаты), так и для выявления различных типов ошибок. Например» программы могут выдавать неправильный результат по непонятной причине. Обнаружение ошибки в зациклившейся программе удобнее всего производить, осуществляя трассировку промежуточных результатов цикла. Одним из лучших способов считается печать промежуточных значений критических переменных. В каждом отладочном операторе печати должно указываться та место, в котором эта печать происходит. Например, 150 PRINT «В ОПЕРАТОРЕ 150; Х = »; X По такой последовательности отладочной печати можно проследить порядок выполнения групп операторов и значений переменной X в процессе ее вычисления. Это позволяет локализовать источник ошибки и выяснить ее точное местоположение. Трассировка может быть также осуществлена при помощи различных отладочных средств, встроенных в интерпретатор (например, при использовании режима TRON). Эти средства могут оказаться весьма полезными. К одному из недостатков такой отладки относится порой слишком большой выдаваемый объем информации, на фоне которого незначительная ошибка может оказаться незамеченной. Тем не менее эти средства весьма эффективны. Эффективность Даже корректно составленная программа не может считаться надежной, если для нее требуется чрезмерно большой объем машинных ресурсов. Например, если для некоторой задачи отведено 2 ч машинного времени и 2000 ячеек памяти, а написанная программа требует для своего выполнения 25 ч или 25 000 ячеек памяти, то такая программа не может считаться приемлемой. Разумеется, программа может быть весьма эффективной» однако все ресурсы ЭВМ при этом исчерпываются. Может оказаться и так, что переделка такой программы даст лучший результат. Хорошие программисты должны принимать во внимание фактор эффективности работы программы уже на первом этапе разработки программы. Правильный выбор исходного обобщенного метода решения задачи, как правило, оказывает гораздо больший эффект на эффективность работы программы, нежели окончательная запись ее на алгоритмическом языке. В оставшейся части данного раздела мы остановимся главным образом на эффективных методах решения задач. Существуют несколько способов, позволяющих повысить эффективность программы. Рассмотрим, например, следующий сегмент программы: 118
10 FOR 1 = 1 TO 1000 20 READ A(I) 30 NEXT I 40 FOR 1=1 TO 1000 50 IF A(I) >0 THEN X=X+A(I) 60 NEXT I Этот сегмент может быть заменен на более эффективный: 10 FOR 1 = 1 ТО 1000 20 READ A(I) 30 IF A(I) >0 THEN X=X+A(I) 40 NEXT I В каждой итерации любого цикла имеются хотя бы один переход, одна проверка и, возможно, одно вычисление функции. В рассмотренном случае нет причины дублировать эти операции, используя второй цикл для выполнения функций, которые могут быть выполнены в первом цикле. Программист должен внимательно проанализировать написанную программу, делая ее по возможности максимально эффективной. Имеется ряд нетривиальных случаев повышения эффективности программы. Рассмотрим, например, следующую програм- 10 READ А, В 20 READ Χ, Υ 30 IF X=0 THEN GOTO 70 40 W= (X+Y) * (A+B) /SQR (10) 50 PRINT X, Y, W 60 GOTO 20 70 END Значение квадратного корня из 10 заново вычисляется в каждой итерации цикла. Однако это значение может быть вычислено только один раз, поскольку оно не изменяется за все время выполнения цикла. Аналогичным образом не изменяется значение (А+В), поэтому оно может быть вычислено за пределами цикла. Более эффективная версия данной программы имеет следующий вид: 10 ROOT=SQR(10) 20 READ А, В 30 APLUSB=A+B 40 READ Χ, Υ 50 IF X=0 THEN GOTO 90 60 W= (X+Y) * APLUSB/ROOT 70 PRINT X, Y, W 80 GOTO 40 90 END Если цикл выполняется 1000 раз (перед окончанием по ошибке OUT OF DATA), то такое исправление исключает 999 операций сложения и 999 выполнений подпрограммы SQR. В общем случае любое вычисление, которое может быть выполнено вне цикла, не должно в нем появляться. 119
Другой пример повышения эффективности: 10 FOR 1=1 ТО 100 20 READ Χ, Υ 30 W=3*I*(X+Y) 40 PRINT X, Υ, W 50 NEXT I В этом примере I умножается на 3 при каждом очередном проходе цикла. Следовательно, (Χ+Υ) умножается на 3, 6, 9, ... ..., 300. Переменная I используется в цикле только в этом операторе. Данная программа может выполняться более эффективно, если ее записать следующим образом: 10 FOR 1 = 3 ТО 300 STEP 3 20 READ Χ, Υ 30 W=I*(X+Y) 40 PRINT X, Υ, W 50 NEXT I Это исключает 100 операций умножения. Другой путь повышения эффективности предполагает исключение ненужных ссылок к элементам массива. Например, рассмотрим следующий фрагмент программы: 10 FOR 1=1 ТО 99 STEP 2 20 READ A(I), A(I+1) 30 Χ=(Α(Ι)+Α(Ι+1))/2 40 Y=(A(I)-A(I+l))/2 50 A(I)=X 60 A(I+1)=Y 70 NEXT I Каждый раз при обращении к элементу массива должны быть выполнены вычисления (сложение базы и смещения). Вышеприведенная программа предполагает 8X50 таких вычислений. Сравните ее с другим вариантом: 10 FOR 1=1 ТО 99 STEP 2 20 READ Χ, Υ 30 Α(Ι) = (Χ+Υ)/2 40 Α(Ι+1) = (Χ+Υ)/2 50 NEXT I В этом случае происходит только 2X50 операций вычисления адреса. К сожалению, часто процесс повышения эффективности работы программы делает ее также и менее понимаемой. Большинство рассмотренных ранее приемов построения хороших структур программ требует большего времени для выполнения программы, поэтому использование этих приемов применительно ко всей программе может привести к снижению эффективности ее работы. По этой причине часть программистов считает, что хорошие структуры не следует использовать, если задачу можно записать другим способом, повышающим эффективность ее работы. 120
Такая позиция уводит нас от принципов хорошего программирования. Можно сказать, что не существует более эффективного программирования задачи, чем запись ее на языке ассемблера с последующей оптимизацией. Однако основной целью языков высокого уровня, подобных языку Бейсик, служит предоставление программисту возможности решения задач, не вникая при этом в подробности выполнения операций на низшем уровне. После того как для решения задачи был выбран именно язык высокого уровня, программисту следует изучить все возможности данного языка, что облегчит ему решение задачи. Если программирование ведется на языке Бейсик, то следует руководствоваться вышеописанными правилами структурирования программы. Выполнение этих правил позволяет сделать программу более легко модифицируемой и адаптируемой. Как мы уже говорили, программа может быть оттранслирована либо интерпретатором, либо компилятором. Хотя интерпретатор и является более эффективным средством на этапе разработки программы, поскольку при этом облегчается выявление ошибок, программа выполняется более эффективно, если она обработана компилятором, т. е. в том случае, когда она полностью переводится на машинный язык. Программы, обработанные компилятором, выполняются в 5—30 раз быстрее, чем программы, обработанные интерпретатором. Однако поскольку компилируемые программы занимают, как правило, больший объем, чем интерпретируемые, то недостаточный объем памяти может ограничить область применения компилятора только небольшими задачами. В программе могут быть отдельные сегменты, которые работают неэффективно. Перед тем как принять решение об их переделке, должен быть получен ответ на следующий важный вопрос: какая часть общего времени работы программы тратится на выполнение данного участка? Если вклад этого сегмента составляет только 5% общего времени выполнения программы, а переделка его сократит время его выполнения вдвое, то общий выигрыш по времени составит не 50%, а только 2,5%. При выборе алгоритма решения задачи эффективность должна быть основополагающим фактором, после того как будет достигнута уверенность в правильности данного алгоритма. После того как алгоритм выбран, программа должна быть записана на алгоритмическом языке и протестирована в соответствии с подходом «сверху-вниз». Программа должна модифицироваться с целью сокращения времени ее работы только в том случае, если вносимые коррекции не затруднят в дальнейшем ее модифицируемость, или же в том случае, если установлено, что новая версия программы значительно сокращает общее время ее выполнения. 121
Упражнения 1. Напишите программу, в которой объявляется массив размером 100X100, обнуляются все его элементы и многократно вводятся группы по три целых числа в каждой. Третье целое число из введенной тройки присваивается элементу массива, строка и столбец которого заданы первыми двумя числами. Если столбец или строка выходит за границы массива, та вся тройка игнорируется. В конце программы напечатайте число групп данных, для которых только номер строки вышел за границы массива, затем число таких групп, для которых это произошло только для номера столбца, н, наконец, число групп, в которых оба этих значения вышли за границы массива. Затем напечатайте сам массив. 2. Найдите ошибку в приведенной ниже программе: 10 20 30 40 50 60 70 80 100 110 120 130 140 150 160 170 180 DIM FACT(10) FOR 1=1 ТО 10 X=I GOSUB 100 FACT (I) = PROD PRINT I, FACT (I) NEXT I 'конец 'подпрограмма fact 'входы: Х 'выходы: PROD 'локальные переменные: I PROD-1 FOR I=XT0 2 STEP-1 PROD=PROD* I NEXT I RETURN 190 'конец подпрограммы 3. Покажите, как можно сделать более эффективными приведенные ниже участки программы: (а) 100 FOR 1=1 ТО 10 ПО В(1)=А(1)+А(3) 120 NEXT I (б) 100 FOR 1=1 ТО 10 ПО Х=Х+5*1 120 NEXT I (в) 100 FOR 1=100 ТО 1 STEP-1 ПО ТЕМР=А(1) 120 А(1)=А(1-1) 130 А(1-1)=ТЕМР 140 NEXT I (г) 100Х=А/2+В/2
Глава 3 Стек Одной из весьма важных и полезных концепций в вычислительной технике является концепция стека. В данной главе мы рассмотрим эту на первый взгляд простую структуру и покажем, почему она играет такую важную роль в программировании и языках программирования; определим абстрактную концепцию стека и покажем, как эта концепция может быть превращена в конкретное и ценное средство решения различных проблем. В первом разделе дается определение стека как некоторой абстрактной структуры данных. Для его описания используются операции псевдокода. Во втором разделе рассматривается реализация стека на языке Бейсик. В третьем и четвертом разделах приводятся различные примеры использования стеков. 3.1. ОПРЕДЕЛЕНИЕ И ПРИМЕРЫ Стеком называется упорядоченный набор элементов, в котором размещение новых элементов и удаление существующих производятся только с одного его конца, называемого вершиной стека. Рассмотрим, что означает такое определение. Пусть в стеке имеется два элемента, один из которых расположен «выше», чем другой. Мы можем изобразить такой стек графически, как показано на рис. 3.1.1. Элемент F расположен выше всех остальных элементов. Элемент D расположен выше, чем элементы А, В и С, но ниже, чем элементы Ε и F. Вы можете возразить, сказав, что если на рис. 3.1.1 изображение перевернуть, то будет наблюдаться очень похожая картина, однако наверху будет располагаться элемент А, а не F. Если бы стек был статическим и неизменным объектом, то такое замечание было бы правильным. Однако стек предполагает вставку и удаление из него элементов, так что стек является динамической, постоянно меняющейся структурой. На рис. 3.1.1 приведено состояние стека только в какой-то заданный момент времени. Для получения полного представления о стеке необходимо представить его меняющимся во времени. 123
Возникает следующий вопрос: как меняется стек? Из определения стека следует, что один конец стека считается его вершиной. В вершину стека может быть помещен новый элемент (в этом случае вершина стека перемещается вверх, с тем чтобы опять соответствовать новому самому верхнему элементу). Для ответа на вопрос «В какую сторону растет стек?» мы должны решить, какой конец стека соответствует его вершине, т. е. с какого его конца будут добавляться и удаляться элементы. Рис. 3.1.1. Стек, со- Изобразив на рис. 3.1.1. элемент F выше держащий шесть* эле- всех остальных элементов стека, мы тем ментов самым предполагаем, что F является в данный момент текущим верхним элементом. Если в стек помещаются новые элементы, то они будут размещены выше элемента F, а при удалении F будет первым удаляемым элементом. Это указывается вертикальными линиями, продолженными в направлении вершины стека. Разумеется, стек можно изобразить различными способами, как это показано на рис. 3.1.2. При этом обязательно нужно указывать его вершину. Обычно стек изображается так, как это показано на рис. 3.1.1, т. е. с вершиной, направленной вверх. Рассмотрим теперь стек в динамике, с тем чтобы понять, как он расширяется и сжимается во времени. Иллюстрация этого дается на рис. 3.1.3. На рис. 3.1.3, а стек показан в состоянии, приведенном на рис. 3.1.1. На рис. 3.1.3, б к стеку добавляется элемент G. Согласно определению, в стеке имеется только одно место для размещения новых элементов — его вершина. Теперь верхним элементом стека стал элемент G. По мере того как стек проходит состояния в, г и д, мы видим, что элементы Η, Ι и J последовательно добавляются в стек. Отметим, что последний размещенный элемент (в данном случае элемент J) находится в вершине стека. Начиная с состояния е, стек начинает уменьшаться. При этом происходит последовательное удаление элементов I, H, G и F. Каждый раз удаляется верхний элемент, поскольку удаление производится только с вершины стека. Элемент G не может быть удален из стека до тех пор, пока не будут удалены элементы J, I и Н. Это иллюстрирует наиболее важное свойство стека — последний размещенный в стеке элемент удаляется первым. Следовательно, J удаляется перед I, поскольку элемент J был записан после элемента I. По этой причине стек иногда называется списком с организацией «последний размещенный извлекается первым» (LIFO). При переходе из состояния к в состояние л стек снова начинает расти. К нему добавляется элемент К. На этом его рост 124
Размещаемые элементы Удаляемые элементы Вершима В Размещаемые О элементы Удаляемые . элементы а - Удаляемые элементы Вершина Удаляемые элементы В Вершина β Размещаемые элементы Вершина Размещаемые элементы Рис. 3.1.2. Четыре различных взгляда на один и тот же стек. опять прекращается, и стек начинает уменьшаться вплоть до трех элементов (состояние о). Отметим, что состояния а и и ничем не отличаются друг от друга. В обоих случаях стек содержит те же самые элементы, расположенные идентично, и имеет такую же вершину. Какое- либо указание на то, что в процессе перехода от состояния а к состоянию и в стек были помещены, а затем удалены четыре элемента, отсутствует. То же самое можно сказать про состояния г и е или к и м. Если необходимо поддерживать информацию о промежуточных состояниях стека, то она должна размещаться вне стека. Внутри самого стека эта информация не поддерживается. 125
^5 Ο Οή p; * I Ι ι * Q О eJ ^ * kj Q υ gqN: * * kj Q υ 0Q Ί ♦ к* Q о 0Q Ί ♦ u. kj i <α QQ 4 * <S k, kj Q о 0Q ^ A a: <S k. kj Q υ 0Q ^ * **. a: <S k, kj ~Q υ QQ ^ * ■** **. a: ^5 u. kj -^ υ 0Q Τ I **. a: <S u. kj Q <ο 0Q ^ + a: <S к kj Q ο 0Q Χ ♦ ° к, kj Q ο 0Q ^ A к, ^ Q ο 0Q 4 Мы рассмотрели все содержимое стека при различных его со- £ стояниях. Реально стек рассматривается только по отношению к его вершине, а не ко всему содержи- ° мому. С этой точки зрения состояния з и η не отличаются друг от друга. В обоих случаях верхним * элементом стека является элемент G. Хотя мы знаем, что содержимое стека в состоянии з не совпадает с ? содержимым в состоянии я, единственным способом подтверждения этого является последовательное ξ удаление и сравнение элементов из обоих стеков. Для лучшего понимания мы рассмотрели содержимое * стеков целиком, однако надо помнить, что реальной необходимости в этом нет. а Примитивные операции ** Операции, выполняемые над стеком, имеют свои специальные названия. При добавлении элемента в стек мы говорим, что элемент помещается в стек (push). Для стека s и элемента i определена * операция push(s, i), по которой в стек s помещается элемент i. Аналогичным образом определяется ^ операция выборки из стека — pop(s), по которой из стека удаляется верхний элемент и возвращаешь ется в качестве значения функции. Следовательно, операция присваивания i = pop(s) удалит элемент из вершины стека ^ и присвоит его значение переменной i. Например, если s есть стек, изоб- * 33 Рис. 3.L3. Изменение состояния стека во времени. 126
раженный на рис. 3.1.3, то при переходе от состояния а к состоянию б мы выполняем операцию push(s, G). Затем выполняются следующие операции: push(s,H) push(s.I) push(s,J) pop(s) pop(s) pop(s) pop(s) pop(s) push(s,K) pop(s) pop(s) pop(s) push(s.G) состояние (в)] состояние (г)] состояние (д)] состояние (е)1 состояние (ж)] состояние (з)] состояние (и)1 состояние (к)] состояние (л)] состояние (м)1 состояние (н)1 состояние (о)] состояние (п)1 Иногда стек называют также списком, растущим вниз, из-за операции push, которая добавляет элементы в стек. Число элементов в стеке не ограничено, поскольку в определении стека не содержится никаких указаний на это. Добавление нового элемента только увеличивает список. Однако если стек содержит единственный элемент и этот элемент удаляется, то стек в результате не содержит ни одного элемента и называется пустым стеком. Хотя операция выборки применима к любому элементу стека, она не может быть применена к пустому стеку, поскольку в нем отсутствуют элементы, которые можно было бы извлечь. Следовательно, перед тем как выполнить над стеком операцию выборки, необходимо убедиться в том, что стек не пустой. Для этого имеется специальная операция empty (s), которая проверяет, является ли стек s пустым. Если стек пуст, то операция empty(s) возвращает значение «истина». В противном случае она возвращает значение «ложь». Другой операцией, выполняемой над стеком, является операция определения верхнего элемента стека без его удаления. Эта операция называется stacktop(s) (вершина стека). Она» возвращает значение верхнего элемента стека. Операция stack- topis) не является принципиально новой операцией, поскольку она может быть получена комбинацией операций pop и push: i = stacktop(s) эквивалентно i = pop(s) push(s, i) Аналогично операции pop(s) операция stacktop(s) не определен на для пустого стека. Результатом попытки выполнения операции pop(s) или stacktop(s) над пустым стеком является возникновение ошибки типа underflow (потеря значимости). Такой ситуации следует избегать, и перед выполнением операций 127
pop(s) и stacktop(s) надо выполнять операцию empty (s) и убедиться в том, что она возвращает значение «ложь». Пример После того как мы определили стек и выполняемые над ним операции, мы можем использовать его для решения различных задач. Рассмотрим математическое выражение, в котором имеется несколько уровней вложенных скобок, например 7- ((X* ((Х+Y) / (J-3)) + Y) / (4-2.5)) и мы хотим удостовериться, что скобки расставлены правильно. Следовательно, мы должны убедиться в том, что 1) число левых и правых скобок одинаково; 2) каждой правой (закрывающей) скобке предшествует левая (открывающая) скобка. Выражения ((А+В) или А+В( нарушают первое условие, а выражения )А+В(—С или (А+В)) —(C+D нарушают второе условие. Для решения этой проблемы рассмотрим каждую левую скобку как открывающую некоторую область, а каждую правую— как закрывающую ее. Глубиной вложения некоторой точки данного выражения называется число областей, которые к этому были открыты, но еще не были закрыты. Это соответствует числу встретившихся при просмотре выражения левых скобок, а соответствующие им правые скобки еще не были обнаружены. Определим счетчик скобок в некотором месте выражения как число левых скобок минус число правых скобок, которые были обнаружены при просмотре выражения слева вплоть до этой точки. Если счетчик скобок неотрицателен, то он совпадает с глубиной вложения. Для того чтобы скобки в выражении образовывали разрешенную комбинацию, необходимо выполнение следующих двух условий: 1. Счетчик скобок при просмотре всего выражения должен быть равен нулю. Это означает, что ни одна из областей не осталась открытой, или что число левых скобок в выражении равно числу правых скобок. 2. Значение счетчика скобок в любой точке выражения неотрицательно. Это означает, что не было обнаружено ни одной правой скобки до того, как была обнаружена соответствующая ей левая скобка. На рис. 3.1.4 приведено значение счетчика для каждой точки в нескольких выражениях. Поскольку только первая строка удовлетворяет сформулированным выше требованиям, она пред- 128
7-((XM(X + Y)/<J-3))+Y)/(4-2,5)) 00122234444334444322211 222 2 10 ( ( A + В ) 12 2 2 2 1 A + B ( 0 0 0 1 ) A + В ( - С -1 -1 -1 -10 0 0 ( A + B ) ) - ( С +D 11110-1-10000 Рис. 3.1.4. ставляет единственное правильно составленное выражение из пяти приведенных выражений. Теперь немного изменим проблему и предположим, что имеется три различных типа скобок — квадратные («[«и»]»), круглые («(«и»)») и фигурные («{«и»}). Закрывающая скобка должна принадлежать к тому же скобочному типу, что и открывающая. Следовательно, выражения (А+В],[(А + В]),{А-(В]> составлены неправильно. Необходимо не только следить за числом открытых областей, но и за типами скобок. Эта информация необходима, поскольку при обнаружении закрывающей скобки ее корректность может быть подтверждена только при знании типа скобки, которой была открыта данная область. Для слежения за типами скобок можно воспользоваться стеком. При обнаружении открывающей скобки она записывается в стек. При обнаружении закрывающей скобки анализируется содержимое стека. Если стек пуст, то, следовательно, открывающая скобка отсутствует и строка составлена неправильно. Однако, если стек не пуст, мы выбираем элемент из стека и проверяем, соответствует ли он требуемому типу закрывающей скобки. При совпадении процесс продолжается. При отсутствии совпадения строка считается составленной неправильно. При выходе на конец строки мы должны убедиться в том, что стек пуст. Ниже приводится алгоритм для данной процедуры. На рис. 3.1.5 приведено состояние стека после последовательного чтения частей следующего выражения: 129
{*+(. {я+(у-[а+А]... {x+(y-[a+fi])... Сх+(у-[«+Ь])*с-[(. ( (a:+(y-La+6])*c-[(rf+e)]}... {лг + (у-[о+Ь])*с-[(й + е11}/(/г-С7-(Л-[- ( ,{*+(y-[o+b])*c-[(rf+eaj/(/F-(/-(fr-[£-n]))... (лг+(у-[a+ 6]) * c-[(rf+ ^ }/(Λ-ϋ4*-[ΐ-π]))) Рис. 3.1.5. Состояние стека со скобками, хранящего скобки на различных этапах обработки. {x+(y-[a+b])*c-[(d+e)]}/(h-(j-(k-[l-n]))) valid = true s=пустой стек while (вся строка еще не прочитана) and (valid=true) do read следующий символ (symb) в строке if symb=«(» or symb=«[» or symb=«{» then push (s.symb) endif 130
if symb=*«)» or symb=«]» or symb=«}» then if empty (s) then valid=false elsei=pop(s) if i не соответствует открывающей скобке для symb then valid=false endif endif endif endwhile if empty (s)= false then valid=false endif if valid=true then print («Строка составлена правильно») else print («Строка составлена неправильно») endif Рассмотрим теперь, почему для решения этой проблемы понадобился стек. Тип последней открывающей скобки должен совпадать с типом закрываемой. Это в точности имитируется стеком, в котором последний размещаемый элемент удаляется первым. Каждый элемент стека представляет скобку, которая была открыта, но еще не была закрыта. Запись элементов в стек соответствует открытию области, а удаление элемента из стека — ее закрытию. Отметим соответствие между числом элементов в стеке в данном примере и счетчиком скобок в предыдущем. Когда стек пуст (счетчик скобок равен нулю) и обнаруживается закрывающая скобка, это означает попытку закрыть скобку, которая не была открыта; следовательно, выражение было составлено неправильно. В первом примере это регистрируется отрицательным значением счетчика скобок, а во втором — невозможностью извлечения элемента из стека. Причина невозможности использования счетчика скобок во втором примере обусловлена необходимостью запоминать также и тип скобки. Это может быть реализовано при помощи стека. Отметим, что каждый раз мы анализируем только верхний элемент в стеке. Расположение нижних элементов в стеке в текущий момент не играет особой роли. Мы рассматриваем последующий элемент стека только после того, как был извлечен верхний. В общем случае стек может быть использован в любой ситуации, для которой применим принцип «последний размещенный извлекается первым». В последующих разделах этой главы мы рассмотрим ряд примеров с использованием стека. Упражнения 1.. При помощи операций push, pop, stacktop и empty реализуйте следующие операции: * j * * (а) Установить в i второй элемент стека, считая от его вершины, удалив из него два верхних элемента. 131
(б) Установить в i второй элемент стека, считая от его вершины, оставив содержимое стека без изменения. (в) Для заданного η установить в i n-й элемент стека, считая от его вершины, и удалить из стека верхние η элементов. (г) Для заданного η установить в i n-й элемент стека, считая от его вершины, оставив содержимое стека без изменения. (д) Установить в i нижний элемент стека и удалить из него все элементы. (е) Установить в i нижний элемент стека, оставив стек без изменения. (Указание. Используйте другой, вспомогательный стек.) (ж) Установить в i третий элемент стека, считая от его дна. 2. Имитируйте действия рассмотренного в данном разделе алгоритма для приведенных ниже выражений, указывая содержимое стека в каждой точке. (а) (А+В}) (б) р+ВН(С-Ш] (в) (A+B)-ic+D}-[F+G] (в) ((H«{([J+K])}) (г) (((А)))) 3. Напишите алгоритм, определяющий, имеет ли вводимая символьная строка вид χ С у где χ есть строка, состоящая из букв А и В, а у — строка, обратная строке χ (т. е. если х=АВАВВА, то у должен равняться АВВАВА). При чтении можно считывать только каждый следующий символ строки. 4. Напишите алгоритм, определяющий, имеет ли вводимая символьная строка следующую форму: aDbDcD...Dz где каждая строка а, Ь, с, ..., ζ имеет форму строки, определенной в упражнении 3. (Следовательно, строка имеет правильную форму, если она состоит из любого числа подобных строк, разделенных символом «D».) При чтении можно считывать только каждый следующий символ строки. 5. Разработайте алгоритм, не использующий стека, который считывает последовательность операций pop и push и определяет, произошла или не произошла потеря значимости при каждом выполнении операции pop. Напишите алгоритм на языке Бейсик. 6. Какой набор условий необходим и достаточен для последовательности операций pop и push над некоторым стеком (изначально пустым) для того, чтобы стек остался пустым и не возникало потери значимости? Какой набор условий необходим, чтобы содержимое непустого стека не изменялось? 3.2. РЕАЛИЗАЦИЯ СТЕКА В ЯЗЫКЕ БЕЙСИК Перед написанием программы, в которой необходимо использовать стек, мы должны решить, каким образом реализовать стек для работы со структурами данных, имеющимися в нашем языке. Как мы увидим, для языка Бейсик имеется несколько способов построения стека. Рассмотрим самый простой. В последующих разделах данной книги будут описаны все возможные представления стека. Однако каждая из них является реализацией концепции, описанной в разд. 3.1. Каждый способ имеет свои преимущества и недостатки с точки зрения того, насколько близко он отражает абстрактную концепцию стека и как много усилий требуется от программиста и ЭВМ для его использования. 132
Стек представляет собой упорядоченный набор данных, и вг· языке Бейсик уже имеется тип данных с такой характеристикой— массив. Если для решения какой-либо задачи необходим стек, то возникает желание начать программу с объявления массива с именем STACK. К сожалению, стек и массив представляют собой совершенно различные вещи. Число элементов в массиве фиксировано и устанавливается при объявлении данного массива. В общем случае пользователь не может изменять это число. С другой стороны, стек представляет собой динамическую структуру, размер которой непрерывно изменяется по мере того, как в него добавляются или из него удаляются элементы. Однако, хотя массив и не может быть стеком, он может быть для него некоторой базой. Это означает, что массив может быть объявлен с размером, достаточно большим для перекрытия максимального размера стека. В процессе выполнения программы стек будет увеличиваться и уменьшаться в пределах отведенного пространства. В одном конце массива будет располагаться фиксированное дно стека, а вершина стека будет постоянно изменяться по мере удаления и добавления элементов. Необходима переменная, которая в каждый момент выполнения программы будет отслеживать текущее положение вершины стека. Следовательно, стек в языке Бейсик может быть объявлен и инициализирован при помощи некоторого массива SITEM, содержащего элементы стека, и целочисленной переменной ТР, указывающей текущую позицию вершины стека в этом массиве. Это может быть сделано с помощью следующих операций: 10 MAXSTACK=100 20 DIM SITEM(MAXSTACK) 30 ТР=0 Мы используем переменную MAXSTACK для хранения значения максимального размера стека и предполагаем, что в любой момент времени стек содержит не более данного числа элементов, расположенных в ячейках с SITEM(l) no SITEM(MAX- STACK). В данном примере максимальный размер стека устанавливается равным 100. Для совместимости с различными версиями языка Бейсик элемент SITEM(O) не используется. Мы также используем переменную MAXSTACK для того, чтобы при изменении максимального размера стека достаточно было изменить только одно число — значение переменной MAXSTACK. Если бы SITEM был непосредственно объявлен с размером 100, то при изменении максимального размера стека константу 100 приходилось бы изменять. Чем больше модификаций делается в программе, тем меньше вероятность того, что программа будет успешно работать. Программы должны изначально записываться таким образом, чтобы они были 133
легко модифицируемы. В некоторых версиях языка Бейсик задание размерности массива через переменную не допускается, и в этом случае вместо переменной MAXSTACK необходимо явное задание размерности массива. Однако и в этом случае необходимо использовать переменную MAXSTACK для сокращения числа изменений до двух при всех прочих ссылках к величине максимального размера стека. Мы также полагаем, что элементами стека являются числа с обычной точностью. Разумеется, нет необходимости ограничивать применение стека только числами с обычной точностью. Массив SITEM мог бы с успехом быть объявлен для целых чисел, чисел с двойной точностью и символьных строк при помощи соответствующих операторов DEFINT, DEFBL и DEFSTR. Однако значение ТР должно быть целым в диапазоне от 0 до 100, поскольку это значение задает позицию верхнего элемента в массиве SITEM. (Мы не объявляем переменную ТР как целочисленную при помощи оператора DEFINT, поскольку это сделает целочисленными все переменные в программе, начинающиеся с буквы Т.) Итак, если значение ТР равно 5, то стек содержит пять элементов. Они есть соответственно SITEM(l), SITEM(2), SITEM(3), SITEM(4) и SITEM(5). При выборке элемента из стека значение ТР установится в 4, указывая этим, что в стеке остались только четыре элемента и что элемент SITEM(4) расположен на верху стека. С другой стороны, если в стек записывается новый элемент, то значение ТР должно быть увеличено на 1, чтобы получить значение 6. При этом новый элемент помещается в SITEM(6). Пустой стек не содержит элементов, что соответствует нулевому значению ТР. Для того чтобы установить стек в «пустое» состояние, достаточно выполнить оператор ТР=0. (К хорошим приемам в программировании относится явное присвоение начальных значений всем переменным, а не использование значений, устанавливаемых по умолчанию системой или языком.) Чтобы узнать в процессе выполнения задачи, является ли стек пустым, достаточно проверить условие ТР=0. Это может быть выполнено при помощи оператора IF следующим образом: 100 IFTP-0 THEN 'стек пуст ELSE 'стек не пуст Эта проверка соответствует операции empty(s), которая была описана в разд. 3.1. При другом способе, предполагая, что переменная TRUE была установлена в 1, а переменная FALSE — в 0, мы можем написать подпрограмму, которая устанавливает некоторую переменную TRUE, если стек пуст, и FALSE, если стек не пуст. Такая подпрограмма может иметь следующий вид: 134
3000 'подпрограмма empty ЗОЮ 'входы: ТР 3020 'выходы: EMPTY 3030 'локальные переменные: нет 3040 IF TP=0 THEN EMPTY=TRUE ELSE EMPTY=FALSE 3050 RETURN 3060 'конец подпрограммы С такой подпрограммой проверка на пустой стек может быть- записана следующим образом: 100 GOSUB 3000: 'подпрограмма empty устанавливает переменную ΈΜΡΤΥ ПО IF EMPTY=TRUE THEN 'стек пуст ELSE 'стек не пуст Читатель может спросить, почему мы создаем целую подпрограмму empty, вместо того чтобы писать IF ТР = 0 всякий раз, когда это необходимо. Ответ заключается в том, что мы хотим сделать наши программы как можно более понятными и получить возможность работы со стеком независимо от его реализации. После того как мы поняли концепцию стека, предложение «EMPTY=TRUE» является более понятным, чем предложение «ТР = 0». Если в дальнейшем мы воспользуемся такой более удачной реализацией стека, что при этом выражение «ТР = 0» потеряет всякий смысл, то будем вынуждены изменить каждую ссылку к идентификатору ТР во всей нашей программе. С другой стороны, предложение «EMPTY= FALSE» по-прежнему сохранит свое значение, поскольку оно имеет отношение к концепции стека, а не к его конкретной реализации. Для использования в нашей программе новой реализации стека нам потребуется изменить объявление стека в основной программе и переписать подпрограмму empty. А группирование зависящих от версии языка участков программы в отдельные, легко распознаваемые единицы делает программу легко понимаемой и модифицируемой. Такой подход, при котором отдельные функции изолированы на нижнем уровне в отдельные модули с выделенными характерными свойствами, называется принципом модульности. Модули нижнего уровня могут быть использованы в более сложных программах, для которых конкретные особенности работы таких модулей не играют большой роли. В свою очередь эти более сложные программы также могут быть рассмотрены как модули с точки зрения других модулей, находящихся по отношению к ним на более высоком уровне. Для реализации операции pop необходимо учесть возможность появления ситуации потери порядка, поскольку пользователь может ненамеренно попытаться извлечь элемент из пустого стека. Разумеется, такая попытка считается запрещенной, и ее следует избегать. Однако при ее возникновении пользователю должно быть сообщено о возникновении ситуации 135
потери порядка. В связи с этим мы введем функцию pop, выполняющую следующие три операции: 1. Если стек пуст, печатается сообщение об ошибке и выполнение прекращается. 2. Из стека извлекается верхний элемент. 3. Извлечений элемент делается доступным вызывающей программе. 2000 'подпрограмма pop 2010 'входы: SITEM, ТР 2020 'выходы: POPS, TP 2030 'локальные переменные: нет 2040 GOSUB 3000: подпрограмма empty устанавливает переменную 'EMPTY 2050 IF EMPTY=TRUE THEN PRINT «ВЫБОРКА ИЗ ПУСТОГО СТЕКА»: STOP ELSE POPS=SITEM(TP): TP-TP-1 2060 RETURN 2070 'конец подпрограммы Отметим, что выходная переменная для pop называется POPS, поскольку в некоторых версиях языка Бейсик POP является зарезервированным словом. Проверка на исключительные ситуации Рассмотрим функцию pop более подробно. Если стек не пуст, то верхний элемент стека сохраняется в качестве возвращаемого значения. Затем этот элемент удаляется из стека оператором ТР = ТР—1. Предположим, что при вызове подпрограммы pop переменная ТР имела значение 87. Это означает, что в стеке имеется 87 элементов. Было возвращено значение SITEM(87), а значение ТР изменилось на 86. Отметим, что SITEM (87) сохранил свое старое значение; обращение к pop не изменяет значения элементов массива SITEM. Однако сам стек изменился, поскольку теперь он содержит только 86 элементов, а не 87. Вспомним, что массив и стек представляют собой два различных объекта. Массив только лишь является хранилищем для стека. Сам стек содержит только элементы, заключенные между первым элементом массива и элементом с номером ТР. Следовательно, уменьшение значения переменной ТР на единицу приводит к удалению элемента из стека. Это верно, несмотря на тот факт, что массив SITEM(87) сохраняет свое старое значение. Для вызова подпрограммы pop программист может написать: ЮС GOSUB 2000: 'подпрограмма pop устанавливает переменную POPS 200 X=POPS После этого переменная X будет содержать значение, выбранное из стека. Если вызов подпрограммы pop делался с целью 136
не извлечения элемента из стека, а его удаления, то переменную X можно не использовать. Разумеется, перед обращением к подпрограмме pop программист сначала должен убедиться в том, что стек не пуст. Для проверки состояния стека программист может написать 100 GOSUB 3000: 'подпрограмма empty устанавливает переменную ΈΜΡΤΥ 200 IF EMPTY< >TRUE THEN GOSUB 2000: X=POPS ELSE 'действия по исключению аварийной 'ситуации Если программист по ошибке выполнил операцию pop над пустым стеком, то эта подпрограмма печатает сообщение об ошибке «ВЫБОРКА ИЗ ПУСТОГО СТЕКА» и выполнение программы прерывается. Хотя это и не совсем удачный выход из положения, он тем не менее гораздо лучше той ситуации, которая бы произошла, если бы оператор IF в программе pop вообще отсутствовал. В этом случае значение ТР стало бы равным нулю и была бы сделана попытка обращения к неустановленному (или несуществующему) элементу SITEM(O). Программист всегда должен учитывать все возможные ошибки. Это делается путем включения в программу различной диагностики. При возникновении ошибки это дает возможность локализовать место ее возникновения и предпринять соответствующие действия. Заметим, что при потере значимости в процессе обращения к стеку прерывание работы программы может оказаться необязательным. Вместо этого может потребоваться только лишь информирование об этом вызывающей программы. Вызывающая программа, приняв такое сообщение, выполняет соответствующие корректирующие операции. Назовем программу, выбирающую элемент из стека, а при возникновении в процессе выборки ситуации потери значимости возвращающую сообщение об этом, popandtest. 7000 'подпрограмма popandtest 7010 'входы: SITEM, ТР 7020 'выходы: POPS, ТР, UND 7030 'локальные переменные: нет 7040 GOSUB 3000: 'подпрограмма empty устанавливает переменную ΈΜΡΤΥ 7050 IF EMPTY=TRUE THEN UND=TRUE ELSE UND=FALSE: POPS= SITEM(TP): TP=TP-1 7060 RETURN 7070 'конец подпрограммы В вызывающей программе программист может написать ПО GOSUB 7000: 'подпрограмма popandtest устанавливает переменную 10_ 'UND 'и, возможно, POPS 120 IF UND=TRUE THEN 'действия по исключению аварийной ситуации ELSE X=POPS: 'X есть элемент, выбранный из стека 137
Реализация операции push Рассмотрим теперь операцию push. Ее легко реализовать, используя представление стека на базе массива. Предположим, что переменная X содержит значение, которое требуется записать в стек. В этом случае одним из вариантов реализации операции push может служить следующая подпрограмма: 1000 'подпрограмма push 1010 'входы: ТР, X 1020 'выходы: SITEM, ТР 1030 'локальные переменные: нет 1040 ТР = ТР+1 1050 SITEM(TP)=X 1060 RETURN 1070 'конец подпрограммы Эта программа сначала отводит место для записываемого в стек элемента X, а затем помещает элемент X в массив SITEM. Эта подпрограмма непосредственно реализует операцию push, описанную в предыдущем разделе. Однако она не вполне корректна. Эта программа допускает возникновение ошибки, обусловленной использованием массива в качестве представления стека. Перед тем как читать дальше, попробуйте обнаружить эту ошибку. Вспомним, что стек является динамической структурой, постоянно изменяющей свой размер. С другой стороны, массив является структурой с фиксированными размерами. Вполне вероятно, что в какой-то момент размер стека может превысить размер отведенного под него массива. Это может произойти в том случае, когда массив целиком заполнен и при этом делается попытка разместить в нем еще один элемент. В результате возникнет ситуация, называемая переполнением. Предположим, что массив целиком заполнен и при этом было сделано обращение к подпрограмме push. Заполненность массива индицируется условием ТР=100, при этом 100-й (и последний) элемент массива расположен на вершине стека. При вызове операции push переменная ТР увеличивается на единицу и делается попытка разместить элемент X в 101-й позиции массива SITEM. Разумеется, массив SITEM содержит только 100 элементов, поэтому подобная попытка приведет к ошибке и выдаче соответствующего сообщения. В контексте используемого алгоритма это сообщение будет абсолютно бессмысленным, поскольку оно не указывает на место ошибки в программе. Оно указывает не на ошибку в алгоритме, а скорее на ошибку в ЭВМ, реализующей данный алгоритм. По этой причине программисту желательно учесть заранее возможность переполнения и при возникновении подобной ошибки выдавать осмысленное сообщение. 138
Программа, учитывающая подобные соображения, приведена ниже: 1000 'подпрограмма push 1010 'входы MAXSTACK. ТР, X 1020 'выходы: SITEM, ТР 1030 'локальные переменные: нет 1040 IF TP = MAXSTACK THEN PRINT «ПЕРЕПОЛНЕНИЕ СТЕКА»: STOP 1050 ТР=ТР+1 1060 SITEM(TP)=X 1070 RETURN 1080 'конец подпрограммы В этой подпрограмме перед размещением в стеке очередного элемента происходит проверка массива на заполненность. Массив полон, если TP=MAXSTACK. Можно отметить, что при обнаружении ситуации переполнения в процессе выполнения операции push выполнение прекращается сразу же после печати сообщения об ошибке. Как и в случае с подпрограммой pop, такая реакция на ошибку может оказаться нежелательной. В некоторых случаях может оказаться более разумным вызывать подпрограмму push из другой программы следующим образом: pushandtest (overflow, stack, x) if overflow=true then 'было зарегистрировано переполнение, х не был помещен в стек. 'Выполняются действия по исключению аварийной ситуации. else 'χ был успешно помещен в стек. Выполнение программы продолжается. Это позволит программе после возврата из подпрограммы pushandtest продолжить свое выполнение вне зависимости о г того, было ли зарегистрировано переполнение. Реализация подпрограммы pushandtest предлагается читателю в качестве упражнения. Полезно сравнить подпрограмму push с созданной ранее подпрограммой pop. Несмотря на то что в обеих подпрограммах ситуации переполнения и потери значимости обрабатываются аналогичным образом, между ними имеется существенное различие. Потеря значимости означает, что операция pop не может быть выполнена над стеком, что указывает на ошибку в алгоритме или в данных. Никакая другая реализация стека не может исключить подобную ошибку. Необходимо пересматривать заново всю задачу. (Разумеется, программист может использовать ситуацию потери значимости как сигнал окончания одного процесса и начала второго. В таком случае, однако, следует использовать подпрограмму popandtest, а не подпрограмму pop.) 139
Переполнение, однако, не является условием, присущим стеку как абстрактной структуре данных. Как мы уже видели в предыдущем разделе, в стек всегда можно записать новый элемент, поскольку это упорядоченный набор данных без ограничения на число элементов. Возможность переполнения возникает в том случае, когда стек, реализованный на базе массива, имеющего конечное число элементов, не может содержать элементов больше, чем это число. Вполне вероятно, что использованный программистом алгоритм составлен правильно. Он только не учел тот факт, что стек может быть довольно большим. В некоторых случаях решением проблемы, связанной с возникновением переполнения, является внесение изменений в процедуру инициализации стека таким образом, чтобы массив SITEM содержал большее число элементов. Это может быть сделано простым увеличением начального значения для MAXSTACK. В подпрограмме push никаких изменений не производится. Это иллюстрирует еще одно преимущество использования стека — модульность и переносимость. Одна и та же подпрограмма push может использоваться вне зависимости от конкретного размера массива SITEM. Однако более часто переполнение указывает на ошибку в программе, которая не может быть связана с нехваткой стекового пространства. Программа может зациклиться таким образом, что в этом цикле в стек постоянно добавляются новые элементы, а старые не извлекаются. Это неизбежно приведет к переполнению стека вне зависимости от его размеров. Перед тем как увеличивать размер стека, программист должен убедиться в том, что ситуация отличается от описанной. Очень часто максимальный размер стека может быть определен по программе и вводимым данным, поэтому переполнение стека в такой ситуации указывает на ошибку в алгоритме. Рассмотрим теперь последнюю операцию над стеком — stacktop(s), которая возвращает верхний элемент из стека, но при этом не удаляет его. Как уже говорилось в предыдущем разделе, функция stacktop не является примитивной операцией, поскольку она может быть составлена из двух операций: x = pop(s) push (s, x) Однако это довольно неудобный способ извлечения из стека верхнего элемента. Почему бы не извлечь требуемое значение непосредственно? Разумеется, проверки на пустой стек и потерю значимости должны быть заданы явно, поскольку программа pop не используется. Приводим программу stacktop на языке Бейсик. Она устанавливает в переменную STKTP значение верхнего элемента стека, не удаляя при этом данный элемент. 140
4000 'подпрограмма stacktop 4010 'входы: SITEM, TP 4020 'выходы: STKTP 4030 'локальные переменные: нет 4040 GOSUB 3000: 'подпрограмма empty устанавливает переменную 'EMPTY 4050 IF EMPTY=TRUE THEN PRINT «ВЫБОРКА ИЗ ПУСТОГО СТЕКА»: STOP 4060 STKTP = SITEM (TP) 4070 RETURN 4080 'конец подпрограммы Читатель может спросить, почему мы не ограничились прямой ссылкой к SITEM (TP), а создали отдельную подпрограмму stacktop. Для этого имелось несколько причин. Во-первых, программа stacktop осуществляет проверку на потерю значимости, поэтому при пустом стеке не может произойти нераспознаваемой ошибки. Во-вторых, она позволяет программисту работать со стеком, не заботясь о его внутренней структуре. В-третьих, в случае использования другого представления для стека программисту не потребуется вносить исправления во все участки программы, в которых делается ссылка к SITEM(TP). Ему достаточно будет изменить только подпрограмму stacktop. Вооруженные рассмотренным выше набором подпрограмм, мы можем приступить к рассмотрению задач, для решения которых удобно использовать стек. Мы сделаем это в последующих разделах. В следующей главе мы рассмотрим другие представления реализации стека. Упражнения 1. Напишите на языке Бейсик программу для реализации операций из упражнения 3.1.1, использующую подпрограммы, описанные в данной главе. 2. Для заданной последовательности операций pop и push, а также целого числа, задающего размер массива, используемого под стек, создайте алгоритм, регистрирующий возникновение ситуации переполнения. В алгоритме не должен использоваться стек. Запишите алгоритм на языке Бейсик. 3. Напишите алгоритмы из упражнений 3.1.3 и 3.1.4 на языке Бейсик. 4. Покажите, как можно реализовать стек, содержащий целые числа, с помощью массива S, где S(0) (а не отдельная переменная ТР) используется для хранения индекса верхнего элемента стека, а элементы массива с S(l) no S(MAXSTACK) содержат элементы самого стека. Для данной реализации напишите необходимые объявления, а также подпрограммы pop, push, empty, popandtest, stacktop и pushandtest. 5. Используя представление стека с помощью массива, напишите на языке Бейсик программу, которая считывает символьную строку, содержащую три набора скобок («(« и »)»), («<« и »>») и («[« и »», и проверяет, правильно ли расставлены в этой строке скобки. 6. Проанализируйте язык, в котором среди типов данных отсутствуют массивы, но имеются стеки, т. е. в этом языке разрешена запись DEFSTACK S Операции pop, push, empty, popandtest и stacktop определены как часть языка. Покажите, каким образом двумерный массив может быть представлен в этом языке с помощью двух стеков. 141
7. Разработайте метод поддержания в одном линейном массиве s двух стеков, при котором нн один из стеков не переполняется до тех пор, пока весь массив не будет заполнен. При этом стек никогда не перемещается внутри массива на другие позиции. Напишите на языке Бейсик программы, реализующие операции pushl, push2, popl и рор2, манипулирующие обоими стеками. (Указание. Стеки растут навстречу друг другу.) 8. Гаражная стоянка имеет одну стояночную полосу и может разместить до 10 автомашин. В одном конце полосы имеется единственный въезд и выезд. Если владелец автомашины приходит забрать свой автомобиль, а последний не является ближайшим к выходу, то все машины, загораживающие проезд, удаляются, машина данного владельца выводится со стоянки, а другие машины расставляются в исходном порядке. Напишите программу обработки группы входных строк. Каждая входная строка содержит символ «А» для прибывающей машины и «D» для отъезжающей, а также номер каждой автомашины. Предполагается, что машины прибывают и отъезжают в порядке, заданном входными строками. Программа должна напечатать сообщение при прибытии или отъезде любой машины. При прибытии машины сообщение должно информировать о том, имеется ли место на стоянке. Если места нет, то машина уезжает и на стоянку не принимается. При выезде машины со стоянки сообщение должно содержать число раз, которое машина удалялась со стоянки для обеспечения выезда других автомобилей. 9. Фирма XYZ по хранению и сбыту бытовых инструментов и приспособлений получает грузы с оборудованием по различным ценам. Фирма продает их затем с 20% -ной надбавкой, причем товары, полученные позднее, продаются в первую очередь (поскольку товары, получаемые позднее, стоят дороже — стратегия «последний полученный первым продается»). Напишите на языке Бейсик программу, считывающую записи о торговых операциях двух типов: операции по закупке и операции по продаже. Запись о продаже содержит префикс «S» и количество товара, а также стоимость данной партии. Запись о закупке содержит префикс «R», количество товара, стоимость одного изделия и общую стоимость всей партии. После считывания записи о закупке напечатайте ее. После считывания записи об операции продажи напечатайте ее и сообщение о цене, по которой были проданы изделия. Например, если фирмой были проданы 200 единиц оборудования, в которые входили 50 единиц с закупочной ценой 1,25 долл., 100 единиц с закупочной ценой 1,1 долл. и 50 единиц с закупочной ценой 1,00 долл., то напечатается (вспомните о 20%-ной надбавке) ФИРМА XYZ ПРОДАЛА 200 ИЗДЕЛИЙ 50 ШТУК ПО 1.50 ДОЛЛАРОВ КАЖДЫЙ НА СУММУ: 75.00 100 ШТУК ПО 1.32 ДОЛЛАРОВ КАЖДЫЙ НА СУММУ: 132.00 50 ШТУК ПО 1.20 ДОЛЛАРОВ КАЖДЫЙ НА СУММУ: 60.00 ВСЕГО ПРОДАНО НА СУММУ: 267.00 Если на складе отсутствует требуемое в заказе число изделий, то продайте все имеющиеся, а затем напечатайте ОСТАЛЬНОЙ ЧАСТИ ИЗДЕЛИЙ XXX НЕТ НА СКЛАДЕ 3.3. ВЛОЖЕННЫЕ ОПЕРАТОРЫ: ОБЛАСТЬ ВИДИМОСТИ Постановка задачи Для демонстрации преимуществ, предоставляемых стеками, рассмотрим правила вложения циклов FOR-NEXT в языке Бейсик. Оператор Бейсика FOR открывает область цикла, а оператор NEXT закрывает ее. Циклы FOR-NEXT могут вкладываться один в другой. Каждый вложенный цикл должен целиком вхо- 142
10 F0RI=lTO10 20 FOR J = 1 TO 5 30 FOR К = 1 TO 7 40 NEXT К 50 F0RL=1T0 12 60 NEXT L 70 NEXT 1 80 NEXT 1 Рис. З.З.1. Часть программы на языке Бейсик, иллюстрирующая вложенные циклы FOR-NEXT. дить в окружающий его цикл. Для слежения за порядком вложения циклов каждый оператор NEXT может содержать переменную, соответствующую переменной в операторе FOR. Мы можем рассматривать эту переменную как идентификатор данного цикла, к которому обращаются операторы FOR-NEXT. Если оператор NEXT не содержит имени переменной в слове NEXT, то этот оператор закрывает самый последний открытый цикл, который еще не был закрыт. Хотя использование переменной в операторе NEXT не является обязательным, при ее указании в операторе NEXT она должна соответствовать самому нижнему (наиболее недавно открытому) циклу, который еще не был закрыт. (В большинстве версий языка Бейсик допускается использование одного оператора NEXT с несколькими переменными, который закрывает одновременно несколько вложенных циклов. Переменные должны быть указаны в таком же порядке, в каком были открыты соответствующие им циклы. В других версиях использование оператора NEXT без переменной не допускается.) Циклы закрываются в порядке, противоположном тому, в котором они были открыты. Для иллюстрации этих правил рассмотрим сегмент программы на рис. 3.3.1. В строке 10 программы открывается цикл с переменной I, а в строке 20 — с переменной М. В строке 30 открывается еще один цикл (с переменной К), поэтому к этому моменту открытыми оказываются сразу три цикла. В строке 143
40 цикл К закрывается. В строке 50 открывается новый цикл (L). В этот момент одновременно открытыми оказываются три цикла — L, I, J. Цикл L закрывается в строке 60, цикл J —в строке 70 и цикл I — в строке 80. Отметим, что строки 30 и 50 находятся на одинаковом уровне (имеют одинаковый отступ от левого края), поскольку обе они находятся внутри одного цикла, открытого в строке 20, но не содержатся один в другом. Интерпретатор игнорирует все отступы и обрабатывает программу, исходя только из последовательности появления операторов FOR и NEXT, однако читателю (включая программиста) легче разобраться в программе, если циклы выделены отступами так, как это показано на рисунке. Мы бы хотели написать на языке Бейсик программу, связывающую оператор NEXT, завершающий цикл, с оператором FOR, который его начинает. Для упрощения процесса ввода предположим, что вводимые данные заданы в операторах DATA, каждый из которых содержит символьную строку в одном из двух следующих форматов: FOR переменная или NEXT переменная где переменная есть либо допустимый идентификатор языка Бейсик, либо пробел. Например, вводимые данные для структуры FOR-NEXT на рис. 3.3.1 имеют следующий вид: 300 DATA «FOR I» 310 DATA «FOR J» 320 DATA «FOR K» 330 DATA «NEXT K» 340 DATA «FOR L» 350 DATA «NEXT L» 360 DATA «NEXT» 370 DATA «NEXT I» Сначала программа должна прочесть и напечатать первую символьную строку. Если строка содержит оператор FOR, то программа должна напечатать сообщение в виде ЦИКЛ ПО переменная ОТКРЫТ Если строка содержит оператор NEXT, то программа должна напечатать сообщение ЦИКЛ ПО переменная ЗАКРЫТ Для соответствия структуре, приведенной на рис. 3.3.1, выводимые сообщения должны иметь следующий вид: 144
FORI ЦИКЛ I ОТКРЫТ FOR J ЦИКЛ J ОТКРЫТ FORK ЦИКЛ К ОТКРЫТ NEXT К ЦИКЛ К ЗАКРЫТ FOR L ЦИКЛ L ОТКРЫТ NEXTL ЦИКЛ L ЗАКРЫТ NEXT J ЦИКЛ J ЗАКРЫТ NEXT I ЦИКЛ I ЗАКРЫТ Необходимо также, чтобы при возникновении несоответствия переменной в операторе NEXT требуемому типу программа печатала соответствующее сообщение об ошибке. Алгоритм решения Мы можем написать следующий алгоритм: 1. while имеются входные данные do 2. read stmt 3. print stmt 4. scope=первое слово stmt 5. vrble=второе слово stmt 6. if scope=«for» 7. then print соответствующее сообщение 8. сохранить vrble 9. else if scope=«next» 10. then if vrble=« » 11. then print сообщение о закрытии последнего цикла 12. else if vrble—переменная самого последнего открытого цикла 13. then print сообщение, закрывающее этот цикл 14. else print сообщение об ошибке и прерывание выполнения программы 15. endif 16. endif 17. else print сообщение об ошибке и выполнение соответствующих операций по восстановлению 18. endif 19. endif 20. endwhile Этот набросок алгоритма не является окончательным и не может быть непосредственно переведен на язык Бейсик. Он скорее является попыткой выделить операторы спецификации и организовать их в структуру, вокруг которой будет создаваться программа. При таком подходе легко выделить возникающие неоднозначности. (Попытайтесь обнаружить неоднозначности в 145
приведенном выше примере.) После создания такой структуры каждую ее часть можно рассмотреть в отдельности и корректировать вплоть до того момента, когда станет возможной ее прямая трансляция на язык Бейсик. В процессе этой коррекции читатель может обнаружить, что некоторые части спецификации были опущены или должны быть записаны более точно. В этом случае макет алгоритма должен быть пересмотрен и весь процесс — повторен заново. Взаимное соответствие операторов языка Бейсик английским словам бывает довольно сложно установить, поскольку английский язык и язык Бейсик сильно отличаются друг от друга. Использование макета алгоритма в качестве моста между двумя языками делает это соответствие более явным. Такой процесс изолирования и последующего улучшения стал весьма важным инструментом при написании программ, позволив резко сократить затраты как машинного времени, так и рабочего времени самого программиста. Улучшение макета программы Приступим к улучшению макета программы. Строка 1 открывает цикл, который завершается при отсутствии входных данных. Предположим, что конец потока данных завершается опознавателем конца. Поэтому главный цикл программы может быть записан следующим образом: 10 'программа scope 30 DEFSTR S 90 READ STMT 100 IF STMT=«FINISH» THEN GOTO 320 110 PRINT STMT 310 GO TO 90 320 END 500 DATA . . . Разумеется, переменная STMT должна быть объявлена как символьная строка (при помощи оператора DEFSTR S). В строках 4 и 5 алгоритма нам необходимо извлечь первое и второе «слово» из строки STMT. Поскольку это может оказаться довольно сложной операцией (слова могут быть отделены друг от друга произвольным числом пробелов, или же мы захотим также выполнять проверку на принадлежность этих слов множеству значимых идентификаторов языка Бейсик), то лучше всего изолировать эту процедуру в отдельную подпрограмму. Поэтому мы будем считать, что существует подпрограмма word со следующими характеристиками. Входными данными для нее являются символьная строка X и целое число N. Подпрограмма word устанавливает в переменную WRD N-e слово в X или нулевую строку, если N-e слово отсутствует. 146
Мы можем, следовательно, перевести строки 4 и 5 макета программы на язык Бейсик: 30 DEFSTR S, V, W, X 120 /scope=wor d (stmt, 1) 130 N=1 140 X=STMT 150 GOSUB 8000: 'подпрограмма word устанавливает переменную WRD 160 SCOPE=WRD 170 /vrble=word(stmt,2) 180 N=2 190 X=STMT 200 GOSUB 8000: 'подпрограмма word устанавливает переменную WRD 210 VRBLE=WRD Разумеется, подпрограмма word должна быть запрограммирована на языке Бейсик. Однако, выделяя ее в отдельную подпрограмму, мы можем отложить рассмотрение детальных подробностей извлечения символов из строк и сфокусировать свое внимание на основных функциях программы. После того как будет завершена основная программа, мы можем вернуться к этой подпрограмме. Это будет следующим шагом в нашем процессе макетирования, при котором программа разбивается на отдельно отлаживаемые модули. Перенесем теперь наше внимание на строки 6—19 макета программы, которые составляют ее ядро. В строке 8 дается указание «сохранить VRBLE». Отметим намеренную неясность этой инструкции. Где мы будем сохранять эту переменную? Каким образом мы будем ее извлекать? Проведенный нами ранее анализ показал, что цикл FOR-NEXT реализует принцип «последний размещенный извлекается первым», т. е. последний открытый цикл должен быть закрыт первым. Следовательно, для решения этой проблемы наиболее подходящей структурой является стек. (Читатель, вероятно, догадался об этом еще раньше.) Итак, мы можем объявить стек, содержащий символьные строки, следующим образом: 30 DEFSTR S, V, W, X 60 MAXSTACK=100 70 DIM SITEM(MAXSTACK) 80 ТР-0 Полагаем, что одновременно может быть открыто не более 100 циклов (большинство интерпретаторов языка Бейсик накладывают ограничения на количество вложенных циклов FOR-NEXT). Следовательно, предложение «сохранить VRBLE» переведется в «записать VRBLE в стек». Перед тем как продолжить написание программы, уточним еще одну неясность. В строке 7 говорится о «соответствующем сообщении», которое должно быть напечатано после закрытия цикла. Из постановки задачи следует, что программа должна напечатать 147
ЦИКЛ ПО переменная ОТКРЫТ Мы можем теперь переписать строки 6—8 макета программы следующим образом: 220 IF SCOPE=«FOR» THEN PRINT «ЦИКЛ ПО; VRBLE; «ОТКРЫТ»: X=VRBLE: GOSUB 1000: GOTO 310: 'push(sitem,vrble) Перейдем теперь к строкам 9—13 макета программы. Сообщение, печатаемое операторами в строках 11 и 13, должно ссылаться к переменной из последнего открытого цикла. Эта переменная может быть извлечена при помощи обращения к стеку. Поэтому программа может быть продолжена следующим образом: 230 'в противном случае выполняются операторы с номерами 240—300 240 'если scope=«NEXT» то выполнить pop(sitem) 'в противном случае напечатать сообщения об ошибке 250 IF SCOPE=«NEXT» THEN GOSUB 2O00: VB=POPS ELSE GOTO 290 260 IF VRBLE=« » OR VRBLE=VB THEN PRINT «ЦИКЛ ПО»; VB; «ЗАКРЫТ»: GOTO 310 Строка 14 учитывает тот случай, когда метка в операторе NEXT не совпадает с меткой, идентифицирующей последний открытый цикл. Это указывает на некорректное вложение циклов FORnNEXT и должно приводить к прекращению выполнения программы. Это может быть записано при помощи следующих операторов: 270 'в противном случае выполнить оператор с номером 280 280 PRINT «ОШИБКА. NEXT БЕЗ СООТВЕТСТВУЮЩЕГО FOR»: STOP Строка 17 учитывает случай, когда прочитанный оператор содержит инструкцию, которая не является ни FOR, ни NEXT. Мы должны решить, что печатать при возникновении такой ошибки и какие действия предпринимать при ее обнаружении. Наверное, наиболее простым решением будет печать следующего сообщения: ОШИБКА, НЕВЕРНАЯ ИНСТРУКЦИЯ, ОПЕРАТОР ИГНОРИРУЕТСЯ Затем надо пропустить этот оператор и продолжить обработку, как если бы никакой ошибки не происходило. Это может быть выполнено при помощи операторов 290 'оператор не является ни оператором FOR, ни NEXT 300 PRINT «ОШИБКА. НЕВЕРНАЯ ИНСТРУКЦИЯ, ОПЕРАТОР ИГНОРИРУЕТСЯ» 148
Полный текст программы Соберем теперь все части программы вместе, добавим необходимые объявления и рассмотрим всю программу целиком. 10 'программа scope 20 'оператор CLEAR 100 необходим для микроЭВМ TRS-80 30 DEFSTR Р, S, V, W, X 40 TRUE=1 50 FALSE=0 60 MAXSTACK=100 70 DIM SITEM(MAXSTACK) 80 ТР=0 90 READ STMT 100 IF STMT=«FINISH» THEN GOTO 320 110 PRINT STMT 120 'scope=word (stmt, 1) 130 N=1 140 X=STMT 150 GOSUB 8000: 'подпрограмма word устанавливает переменную WRD 160 SCOPE=WRD 170 ,vrble=word(stmt,2) 180 N = 2 190 X=STMT 200 GOSUB 8000: 'подпрограмма word 210 VRBLE=WRD 220 IF SCOPE=«FOR» THEN PRINT «ЦИКЛ ПО»; VRBLE; «ОТКРЫТ»: X=VRBLE: GOSUB 1000: GOTO 310: 'push (sitem,vrble) 230 'в противном случае выполняются операторы с номерами 240—300 240 'если scope=«NEXT», то выполнить pop(sitem) 'в противном случае напечатать сообщение об ошибке 250 IF SCOPE=«NEXT» THEN GOSUB 2000: VB=POPS ELSE GOTO 290 ■ 260 IF VRBLE=« » OR VRBLE=VB THEN PRINT «ЦИКЛ ПО»; VB; «ЗАКРЫТ»: GOTO 310 270 'в противном случае выполнить оператор с номером 280 280 PRINT «ОШИБКА. NEXT БЕЗ СООТВЕТСТВУЮЩЕГО FOR»: STOP 290 'оператор не является ни оператором FOR, ни NEXT 300 PRINT «ОШИБКА. НЕВЕРНАЯ ИНСТРУКЦИЯ, ОПЕРАТОР ИГНОРИРУЕТСЯ» 310 GO TO 90 320 END 500 DATA . . . 1000 'подпрограмма push 2000 'подпрограмма pop 3000 'подпрограмма empty 8000 'подпрограмма word Разумеется, мы должны написать подпрограмму word и версии подпрограмм pop и push, которые могут работать со стеком, содержащим символьные строки. Решение этой задачи 149
мы оставляем студентам в качестве упражнения. Читателю рекомендуется использовать структуру, приведенную на рис. 3.1.1, и учесть следующие замечания: 1. Программа выдает правильные результаты для указанных входных данных. 2. В каждой точке программы стек содержит переменные тех циклов, которые были открыты, но еще не были закрыты. Отметим, что приведенная программа содержит минимальную защиту от ошибок. Одним из основных правил программирования является обязательный учет всех возможных ошибок при вводе исходных данных. После считывания оператора FOR печатается сообщение об открытии цикла и переменная записывается в стек для сравнения ее в дальнейшем с переменной, идентифицирующей оператор NEXT. Предположим, однако, что оператор FOR ошибочно не содержит идентифицирующей его переменной. После обнаружения оператора NEXT, который должен закрывать этот цикл, программа (согласна строке 14 алгоритма) напечатает сообщение об ошибке и прекратит свое выполнение без уведомления о том, что ошибка произошла в открывающем данный цикл операторе FOR. Гораздо более серьезная проблема возникает в том случае, если соответствующий оператор NEXT также не содержит идентифицирующей переменной. В таком случае условие, задаваемое в строке 12, окажется истинным, а программа продолжит свою работу, не выдав никаких сообщений о произошедшей ошибке. Мы должны решить, какое действие необходимо предпринять в таком случае. После считывания оператора FOR, не содержащего идентифицирующей переменной, разумно будет печатать сообщение об ошибке, игнорировать данный оператор и продолжать обработку. Другая возможная ошибка может произойти в том случае, если циклы остаются открытыми и после того, как входной поток данных завершился. Это может произойти тогда, когда во входном потоке число операторов NEXT меньше числа операторов FOR. Для оповещения об этой ошибке достаточно будет простого сообщения, в котором перечисляются все открытые циклы. Упражнения 1. Напишите подпрограмму word, которая устанавливает в переменную WRD N-й идентификатор Бейсика в строке STR или « », если строка STR не содержит N-ro идентификатора. 2. Напишите подпрограммы pop, push, empty и popandtest для стеков, содержащих символьные строки. Заметьте, что если в программе имеются два независимых стека — один для целых чисел и другой для символьных строк, то в ней должны присутствовать две версии подпрограмм pop, push, empty и popandtest. 3. Предположим, что операторы FOR имеют следующий формат: ##FOR var=init TO final STEP step 150
где ф# есть номер строки; var — идентификатор языка Бейсик, a init, final и step — либо целые числа, либо идентификаторы языка Бейсик. Предполагается, что значение STEP положительно. Напишите программу, которая считывает входные данные, состоящие из таких операторов FOR и операторов NEXT, и переводит их в операторы присваивания, операторы IF и операторы GOTO. Например, входной набор данных 10 FOR 1=1 ТО N STEP 3 20 FOR J=N TO 500 STEP 1 30 NEXT J 40 NEXT I 50 FOR 1=1 TO 5 STEP К 60 NEXT I должен быть переведен в 10 1 = 1 20 IF I>N THEN GOTO 90 30 J=N 40 IF J>500 THEN GOTO 70 50 J=J+1 60 GOTO 40 70 1 = 1+3 80 GOTO 20 90 1=1 100 IF I>5 THEN GOTO 130 110 I = I+K 120 GOTO 100 130 'оставшаяся часть программы {Указание. Воспользуйтесь стеками для переменных, номеров строк и приращений.) 4. Предположим, что один оператор NEXT, содержащий несколько переменных, может завершать одновременно несколько вложенных циклов, если переменные указаны в правильном порядке. Модифицируйте программу из данного раздела таким образом, чтобы при обнаружении оператора, имеющего вид 50 NEXT Χ, Υ, Ζ закрывались циклы, идентифицируемые переменными Χ, Υ и Ζ, и печатались сообщения в виде ЦИКЛ X ЗАКРЫТ ЦИКЛ Υ ЗАКРЫТ ЦИКЛ Ζ ЗАКРЫТ Ваша программа должна обнаруживать некорректно вложенные циклы FOR-NEXT. 5. Рассмотрим язык, в котором оператор NEXT, содержащий переменную, закрывает все циклы, вложенные в цикл, идентифицируемый данной переменной. В этом случае все циклы, которые были открыты после содержащего данную переменную оператора FOR, но которые, как и данный цикл, еще не были закрыты, заканчиваются одним и тем же оператором NEXT. Модифицируйте программу из данного раздела таким образом, чтобы содержащий переменную оператор NEXT закрывал все такие циклы, печатая при этом различные сообщения о том, какие циклы были закрыты. 151
3.4. ПРИМЕР: ПОСТФИКСНАЯ, ПРЕФИКСНАЯ И ИНФИКСНАЯ ЗАПИСИ Базовые определения и примеры В этом разделе мы рассмотрим основное применение стеков. Это применение, однако, не является единственным. Причина, по которой мы рассматриваем данную задачу, заключается в том, что она очень хорошо иллюстрирует различные типы стеков и выполняемых над ними операций. Данный пример является также весьма важным с точки зрения науки о вычислительной технике в целом. Перед тем как перейти к алгоритмам и программам, необходимо проделать некоторую подготовительную работу. Рассмотрим сумму чисел А и В. Будем говорить о применении операции « + » к операндам А и В и будем записывать сумму как А+В. Такое представление называется инфиксной записью. Для представления суммы чисел А и В имеются два других обозначения, также использующих символы А, В и +. Они имеют следующий вид: + А В префиксная запись А В — инфиксная запись Префиксы «пре», «пост» и «ин» относятся к относительной позиции оператора по отношению к обоим операндам. В префиксной записи операция предшествует обоим операндам, в постфиксной записи операция следует за двумя операндами, а в инфиксной записи операция разделяет два операнда. В действительности префиксная и постфиксная записи не столь не наглядны, как это кажется при первом рассмотрении. Например, в большинстве версий языка Бейсик мы можем вызвать встроенную функцию FNADD, возвращающую сумму двух аргументов А и В, написав T = FNADD (А, В). В данном случае операция предшествует операндам А и В. Рассмотрим еще несколько примеров. Вычисление выражения А+В*С, записанное в стандартной инфиксной записи, требует знания того, какая из двух операций выполняется первой. В случае + или * мы «знаем», что умножение выполнится раньше сложения (при отсутствии скобок). Следовательно, выражение А+В* С интерпретируется как А+(В*С). Мы говорим, что умножение имеет более высокий приоритет, чем сложение. Предположим, что мы хотим записать выражение А+В*С в постфиксной записи. Учитывая правила приоритетности операций, мы сначала преобразуем ту часть выражения, которая вычисляется первой, — умножение. Выполняя преобразования поэтапно, получим А+(В*С) скобки для выделения А+(ВО) преобразование операции умножения 152
А(ВО)+ преобразование операции сложения АВО+ постфиксная форма Единственным правилом, используемым в процессе преобразования, является то, что операции с высшим приоритетом преобразуются первыми, а после того, как операция преобразована к постфиксной форме, она рассматривается как один единый операнд. Рассмотрим теперь тот же самый пример, в котором приоритетность выполнения намеренно изменена при помощи скобок: (А+В)*С инфиксная форма (АВ+) *С преобразование операции сложения (AB-j-)O преобразование операции умножения АВ+О постфиксная форма В приведенном примере сложение преобразуется перед умножением из-за наличия скобок. В преобразовании выражения (А+В)*С к (АВ + )*С, А и В являются операндами, заявляется оператором. В преобразовании выражения (АВ+)*С к (АВ + )С* (АВ+) и С являются операндами, а * является операцией. Если известна приоритетность выполнения операций, то правила преобразования инфиксного представления в постфиксное просты. Мы рассмотрим пять бинарных операций: сложение, вычитание, умножение, деление и возведение в степень. Эти операции обозначаются привычными значками +, —, *, / и |. Для этих бинарных операций установлен следующий порядок вычислений (от высшего к низшему): возведение в степень, умножение/деление, сложение/вычитание. Этот порядок можно изменить при помощи скобок. Приведем несколько дополнительных примеров преобразования инфиксного представления в постфиксное. Перед тем как продолжить чтение, читатель должен убедиться в том, что он понял каждый из этих примеров (и может повторить их). Мы придерживаемся соглашения о том, что при просмотре строки, не содержащей скобок, предполагается, что вычисления производятся слева направо для операций с одинаковым приоритетом, за исключением случая возведения в степень, когда вычисления производятся справа налево. Следовательно, А—В—С означает (А—В)—С, a AtBtC означает At(BtC). Инфиксное представление Постфиксное представление А+В АВ+ А+В—С АВ+С— (А+В) * (С—D) AB+CD—» AfB*C—D+E/F/(G+H) ABfOD—EF/GH+/+ ((А+ В) *С— (D—Ε)) f (F+G) AB+ODE—FG+ A—B/(C*DfE) ABCDEf*/— 153
Правила приоритетности для преобразования из инфиксной в префиксную форму аналогичны. Единственное отличие от постфиксного преобразования состоит в том, что операция помещается перед операндами, а не после них. Ниже мы приводим постфиксные формы рассмотренных ранее выражений. Читатель может также попробовать выполнить эти преобразования самостоятельно. Инфиксное представление Префиксное представление А+В +АВ А+В—С —+АВС (А+В)* (С—D) * + АВ—CD AfB*C—D + E/F/(G+H) +—*ABCD/EF + GH ((A+B)*C— (D—E))f(F+G) f—* + ABC—DE + FG A—B/(C*DfE) —A/B*CfDE Отметим, что префиксная форма для сложного выражения не является зеркальным отображением постфиксной формы, что видно на втором примере (А+В—С). В дальнейшем мы будем рассматривать постфиксные преобразования и оставим читателю большую часть работы, связанную с префиксной записью. Очевидное отличие постфиксной формы от всех остальных заключается в том, что она не содержит скобок. Рассмотрим два выражения: А+(В*С) и (А+В)*С. Если в первом из двух выражений скобки не являются обязательными [согласно преобразованию А+В*С=А+(В*С)], то во втором они необходимы во избежание путаницы с первым случаем. Постфиксные формы для этих выражений есть Инфиксная форма Постфиксная форма А+(В*С) АВО + (А+В)*С АВ + С* В обоих преобразованных выражениях скобки отсутствуют. Внимательное рассмотрение этих преобразований говорит о том, что порядок операций в постфиксных выражениях определяет действительный порядок операций при вычислении выражения, делая скобки ненужными. При переходе от инфиксной формы к префиксной мы жертвуем возможностью рассмотрения операндов вместе с относящейся к ним операцией, однако приобретаем при этом возможность записи всего выражения без использования скобок. Читатель может возразить, что постфиксная форма, возможно, и выглядит проще, однако ее трудно вычислять. Например, если в рассмотренных выше примерах А=3, В=4 и С=5, то откуда мы знаем, что 345* + равно 23, а 34+5* равно 35? 154
Вычисление выражения, записанного в постфиксной форме Ответ на этот вопрос дает алгоритм вычисления выражения, записанного в постфиксной форме. Каждая операция в постфиксной строке ссылается к предшествующим двум операндам этой строки. (Разумеется, один из этих двух операндов может быть результатом выполнения предыдущей операции.) Предположим, что всякий раз, когда мы прочитываем операнд, мы записываем его в стек. Когда мы достигаем операции, то относящиеся к ней операнды будут двумя верхними элементами стека. Затем можно извлечь эти два операнда и осуществить над ними указанную операцию, а затем записать результат в стек. Тем самым он станет доступен в качестве операнда применительно к следующей операции. Приведенный ниже алгоритм вычисляет выражение в постфиксной записи именно таким способом. инициализировать стек s, обнулив его 'выполнять просмотр входной строки, считывая за один раз по одному 'элементу в переменную symb while во входной строке еще имеются непросмотренные символы do symb=следующий считанный символ if symb есть операнд then push(s.symb) else secoper=pop (s) operl = pop(s) value=результат применения symb к operl и secoper push (s.value) endif endwhile result=pop(s) Рассмотрим теперь пример. Предположим, что нам необходимо вычислить следующее выражение, представленное в постфиксной нотации: 623 + —382/+ *2t3 + Покажем содержимое стека операндов s и переменных symb, орег 1, secoper и value после каждого очередного шага цикла. Вершина стека расположена справа. Отметим, что s есть стек для операндов. Каждый операнд записывается в стек по мере появления. Следовательно, максимальный размер стека ограничен числом операндов, имеющихся во входном выражении. Однако при работе с большинством постфиксных выражений фактический размер стека оказывается меньшим, чем максимальный, поскольку операция удаляет операнды из стека. В предыдущем примере стек никогда не содержал более четырех операндов, несмотря на тот факт, что общее их число в выражении равнялось восьми. 155
symb орег 1 орег 2 value s 6 2 3 + 3 8 2 / + * 2 t 3 + 2 6 6 6 6 8 3 1 1 7 7 49 3 5 5 5 5 2 4 7 7 2 2 3 5 1 1 1 1 4 7 7 7 49 49 52 6 6, 2 6, 2, 3 6, 5 1 1, 3 1, 3, 8 1, 3, 8, 2 1, 3, 4 1, 7 7 7, 2 49 49, 3 52 Программа, вычисляющая постфиксное выражение Теперь мы можем составить программу для вычисления выражения в постфиксной записи. Перед ее созданием необходимо ответить на ряд вопросов. Первым соображением, касающимся всех программ, будет точное определение формы и ограничений, накладываемых на входные данные. Обычно программисту задается форма входных данных и при этом требуется создать программу, работающую с этим типом данных. Мы находимся в несколько лучшей ситуации, имея возможность самостоятельно задать эту форму. Это позволяет создать программу, не перегруженную проблемами преобразования, заслоняющими основное ее назначение. Имея дело с данными, представленными в неудобной для работы форме, мы могли бы обратиться к подпрограммам, осуществляющим необходимые преобразования, и использовать полученные с их помощью данные в качестве исходных для основной программы. В «реальном мире» распознавание и преобразование входных данных обычно составляют основную проблему. Предположим, что каждое входное выражение состоит из строки цифр и символов операций. Будем считать, что операнды являются неотрицательными цифрами (т. е. О, 1, 2, ...9). Например, входная строка может иметь вид «345*+»· Мы хотели бы написать программу, которая считывает выражения в данном формате и для каждого выражения печатает исходную введенную строку и результирующее вычисленное выражение. Так как вводимые данные рассматриваются как символы, нам необходимо преобразовать символы операндов в числа, а символы операций в операции. Например, нам необходимо иметь способ, позволяющий переводить символ «5» в число 5 и символ «+» в операцию сложения. В языке Бейсик операция 156
преобразования символа в целое число реализуется достаточно просто. Если Х$ есть символьное представление числа, то функция VAL(X$) возвращает числовое значение данной строки. [Аналогичным образом функция STR$(Y) может быть использована для преобразования числа Υ в его символьное представление.]! Для преобразования символа операции в соответствующую операцию воспользуемся подпрограммой apply, которая имеет в качестве входных данных символьное представление операции и двух операндов. Далее в тексте будет приведено тело этой подпрограммы. Основная часть программы представлена ниже. Она представляет сабой реализацию на языке Бейсик алгоритма вычисления с учетом специфики программного окружения, формата входных данных и вычисляемых результатов: 10 'программа evaluate 20 'оператор CLEAR 100 необходим для микроЭВМ TRS-80 30 DEFSTR А, О, Р, S, X 40 TRUE=1 50 FALSE-0 60 MAXSTACK=100 70 DIM SITEM(MAXSTACK): 'содержит элементы стека 1—100 80 TR = 0 90 INPUT «ВВЕДИТЕ СТРОКУ»; STRING 100 FOR CHAR=1 TO LEN(STRING) 110 SYMB=MID $(STRING,CHAR,1): 'извлечь следующий символ 120 'если SYMB есть цифра, то записать ее в стек 130 IF SYMB> = «0» AND SYMB< = «9» THEN X=SYMB: GOSUB 1000: GOTO 230 140 'в противном случае выполняются операторы с номерами 150— '220 150 GOSUB 2000: 'подпрограмма pop устанавливает переменную ФОР 160 SECOPER=POPS 170 GOSUB 2000: 'подпрограмма pop 180 OPERl = POPS 190 GOSUB 6000: 'подпрограмма apply устанавливает переменную 'APPLY 200 'мы применяем операцию к двум верхним элементам стека 'и помещаем результирующее значение назад в стек на их 'место 210 X=APPLY 220 GOSUB 1000: 'подпрограмма push 230 NEXT CHAR 240 GOSUB 2000: 'подпрограмма pop 250 RESULT=VAL(POPS) 260 PRINT STRING; «=»; RESULT 270 GOTO 80: 'повторить для другого выражения 280 END 1000 'подпрограмма push 2000 'подпрограмма pop 3000 'подпрограмма empty 6000 'подпрограмма apply 157
Подпрограмма apply проверяет, является ли SYMB значимой операцией, и, если это так, определяет результаты этой операции над операндами OPER1 и SECOPER: 6010 'входы: OPERl, SECOPER, SYMB 6020 'выходы: APPLY 6030 'локальные переменные: Υ 6040 IF NOT (SYMB = «+» OR SYMB=«-» OR SYMB=«*» OR SYMB = «A OR SYMB = «f») THEN PRINT «НЕВЕРНАЯ ОПЕРАЦИЯ»: STOP 6050 IF SYMB = «+» THEN Y=VAL(OPERl)+VAL(SECOPER) 6060 IF SYMB = «-» THEN Y=VAL(OPERl)-VAL (SECOPER) 6070 IF SYMB=«*» THEN Y=VAL(OPERl)*VAL(SECOPER) 6080 IF SYMB = «/» THEN Y=VAL(OPERl)/VAL (SECOPER) 6090 IF SYMB = «f» THEN Y=VAL(OPERl)IVAL(SECOPER) 6100 APPLY=STR$(Y) 6110 RETURN 6120 'конец подпрограммы Ограничения, накладываемые программой Отметим некоторые недостатки этой программы. Понимание того, что программа не может сделать, так же важно, как и понимание того, что она может делать. Очевидно, что бессмысленно использовать программу для решения тех задач, для которых она не предназначена. Еще худшим окажется случай, когда делается попытка решения проблемы при помощи некорректно составленной программы, что приводит к получению неверных результатов без выдачи каких-либо сообщений об ошибках. В такой ситуации программист не имеет никаких сведений о том, что полученные результаты являются неверными, и на их основании может прийти к неверным выводам. По этой причине программисту важно знать все ограничения, накладываемые программой. Основной недостаток данной программы состоит в том, что она не выполняет никаких операций по выявлению и исправлению ошибок. Если входные данные для этой программы записаны в стандартной постфиксной записи, то она выполняется правильно. Предположим, что одна из входных строк содержит слишком много операций или операндов либо их последовательность составлена неправильно. Эти проблемы могут возникнуть в результате применения данной программы к выражениям в постфиксной записи, содержащим двузначные · целые числа, что приведет к избыточному числу операндов. Может случиться и так, что работающий с программой пользователь предположил, что последняя может работать и с отрицательными числами, содержащими перед собой знак минус, т. е. такой же знак, какой используется для обозначения операции вычитания. Эти минусы будут рассмотрены как операции вычитания, что приведет к избыточному общему числу операций. В зависимости от типа ошибки ЭВМ может предпринять одно 158
из нескольких возможных действий (например, прервать выполнение, напечатать ошибочные результаты и т. п.). В качестве другого примера предположим, что при выполнении последнего оператора программы стек оказывается не пустым. В этом случае никаких сообщений об ошибке выдано не будет (поскольку такая ситуация нами не предусматривалась), а полученное числовое значение будет результатом изначальна неверно составленного выражения. Предположим, что при одном из обращений к подпрограмме pop произошла потеря значимости. Поскольку для извлечения элементов из стека мы не использовали программу popandtest, то выполнение программы будет прервано. Это представляется неразумным, поскольку наличие неверных данных в одном выражении не должна предотвращать обработку остальных выражений. Разумеется, это не единственные проблемы, которые могут возникнуть. В качестве упражнения читатель может написать программы, которые накладывают на входные данные не такие жесткие требования, а также программы, обнаруживающие некоторые из перечисленных выше ошибок. Преобразование выражения из инфиксной записи в постфиксную До сих пор мы рассматривали программы, вычисляющие выражения, представленные в постфиксной записи. Хотя мы и обсуждали метод преобразования инфиксной записи в постфиксную, сам алгоритм приведен не был. Сейчас мы рассмотрим именно эту задачу. После создания такого алгоритма мы будем иметь возможность чтения выражения в инфиксной записи и вычисления последнего путем преобразования его сначала в постфиксную форму, а затем вычислением уже полученного постфиксного выражения. В предыдущем разделе мы упомянули о том, что части выражения, заключенные в скобки на самом нижнем уровне скобочных вложений для данного выражения, должны быть сначала преобразованы в постфиксную форму, с тем чтобы их можно было рассматривать как один операнд. При таком подходе преобразование всего выражения приведет к полному исключению из него скобок. Последняя открываемая в группе скобок скобочная пара содержит первое преобразуемое выражение в этой группе. Принцип «последнее открываемое выражение вычисляется первым» предполагает использование стека. Рассмотрим два выражения в инфиксной форме: А+В*С и (А+В)*С и соответствующие им постфиксные формы АВО+ и АВ + С*. В каждом случае порядок следования операндов в этих формах совпадает с порядком следования операндов в исходных выражениях. При просмотре первого выражения (А+В*С) первый операнд А может быть сразу же 159
помещен в постфиксное выражение. Очевидно, что символ «+» не может быть помещен в это выражение до тех пор, пока туда не будет помещен второй, еще не просмотренный операнд. Следовательно, он (т. е. символ «+») должен быть сохранен, а впоследствии извлечен и помещен в соответствующую позицию. После просмотра операнда В этот символ записывается вслед за операндом А. К этому моменту просмотренными оказываются уже два операнда. Что мешает извлечь и разместить символ «+»? Разумеется, ответ на этот вопрос заключается в том, что за символом «+» следует символ «*», имеющий более высокий приоритет. Во втором выражении наличие скобок обусловливает выполнение операции «+» в первую очередь. Вспомним, что в отличие от инфиксной формы в постфиксной записи операция, появляющаяся первой в строке, выполняется первой. Так как при преобразовании инфиксной формы в постфиксную правила приоритета играют существенную роль, для их учета введем функцию prcd(operl, secoper), где operl и seco- рег — символы, обозначающие операции. Эта функция возвращает значение true, если operl имеет более высокий приоритет, чем secoper, и operl располагается слева от secoper в бесскобочном выражении, представленном в инфиксной форме. В противном случае функция prcd(operl, secoper) возвращает значение false. Например, значения функций prcd(«*», «+») и prcd(«+», «+»)—«истина», a prcd(« + », «*»)—«ложь». Рассмотрим теперь макет алгоритма для преобразования строки, представленной в инфиксной форме и не содержащей скобок, в постфиксную строку. Поскольку мы считаем, что во входной строке скобки отсутствуют, единственным признаком порядка выполнения операций является их приоритет. 1. установить в постфиксную строку « » 2. обнулить стек с именем opstk 3. while на входе еще имеются символы do 4. read symb 5. if symb есть операнд 6. then добавить символ к постфиксной строке 7. else 'символ есть операция 8. while(empty(stack))=false) and (prcd (stacktop (opstk) ,symb) = true) do 9. smbtp=pop (opstk) 'smbtp имеет приоритет больший, чем symb, поэтому 'она может быть добавлена к постфиксной строке 10. 'добавить smbtp к постфиксной строке 11. endwhile 'в этой точке либо opstk пуст, либо symb имеет приоритет 'над stacktop (opstk). Мы не можем поместить symb в пост- 'фиксную строку до тех пор, пока не считаем следующую 'операцию, которая может иметь более высокий приоритет. 'Следовательно, мы должны сохранить symb. 12. push (opstk,symb) 13. endif 14. endwhile 160
'к этому моменту строка оказывается просмотренной целиком. Мы 'должны поместить оставшиеся в стеке операции в постфиксную 'строку. 15. while empty (opstk)«= false do 16. smbtp=pop (opstk) 17. добавить smbtp к постфиксной строке 18. endwhile Проверьте алгоритм со строками «A*B + OD» и «А+В* *CtDtE» (где prcd(«t», «t»)=false] и убедитесь в том, что он выполняется правильно. Отметим, что в любой момент операция в стеке имеет более низкий приоритет, чем все операции перед ним. Это обусловлено тем, что изначально пустой стек удовлетворяет данному условию и операция помещается в стек (строка 12) только в том случае, если находящаяся в данный момент в вершине стека операция имеет более низкий приоритет, чем считываемая. Мы можем также отметить ту относительную свободу, с которой сформулировано условие в восьмой строке: (empty(stack)) = false) and (prcd (stacktop (opstk), symb) = true) Читателю следует убедиться в том, что ему понятно, почему данное условие не может быть непосредственно использовано в настоящей программе. Какие изменения должны быть внесены в алгоритм для обеспечения возможности работы со скобками? Ответ на этот вопрос весьма прост. После считывания открывающей скобки она записывается в стек. Это может быть выполнено путем установки правила prcd(op, «(»)=false для любого символа операции, отличного от символа правой (закрывающей) скобки. Мы также определим prcd («(»,op)—false для того, чтобы символ операции, появляющийся после левой скобки, записывался в стек. После считывания закрывающей скобки все операции вплоть до первой, открывающей скобки должны быть прочитаны из стека и помещены в постфиксную строку. Это может быть сделано путем установки prcd (ор.,«)») =true для всех операций ор, отличных от левой скобки. После считывания этих операций из стека и закрытия открывающей скобки необходимо выполнить следующую операцию. Открывающая скобка должна быть удалена из стека и отброшена вместе с закрывающей скобкой. Обе скобки не помещаются затем ни в постфиксную строку, ни в стек. Установим функцию prcd(«(»,«)») равной false. Это гарантирует нам то, что при достижении открывающей скобки цикл, начинающийся в строке 8, будет пропущен, а открывающая скобка не будет помещена в постфиксную строку. Выполнение продолжится со строки 12. Однако, поскольку открывающая скобка не должна помещаться в стек- строка 12 заменяется оператором 161
12. if (empty (opstk) = true) or (symb <>»)») then push(opstk, symb) else smbtp = pop(opstk) С учетом приведенных соглашений для функции prcd, a также исправлений для строки 12 рассмотренный алгоритм может быть использован для преобразования любой строки, записанной в инфиксной форме, в постфиксную. Подытожим правила приоритетности для скобок: prcd («(»,op)= false для любой операции ор prcd (ор,«(») = false для любой операции ор, отличной от с)» prcd (op,«)»)= true для любой операции ор, отличной от «(» prcd («) »,ор) = неопределенно для любой операции ор, (попытка сравнения двух указанных операций означает ошибку) Проиллюстрируем этот алгоритм несколькими примерами: Пример 1: А+В»С Приводится содержимое symh, постфиксной строки и opsik после про· смотра каждого символа. Вершина opstk находится справа. - . Постфиксная . . Строка feymb строка °Ptsk 1 А 2 + 3 В 4 · 5 С 6 7 А А АВ АВ ABC ABC* АВС* + + + +· +· + Строки 1, 3 и 5 соответствуют просмотру операнда таким образом, что символ (symb) немедленно помещается в постфиксную строку. В строке 2 была обнаружена операция, а стек оказался пустым, поэтому операция помещается в стек. В строке 4 приоритет нового символа (·) больше, чем приоритет символа, расположенного в вершине стека (+), поэтому новый символ помещается в стек. На 6-м и 7-м шагах входная строка пуста, поэтому из стека считываются элементы, которые затем помещаются в постфиксную строку. Пример 2: (А+В)*С . Постфиксная , , eymb строка °Ptek ( А + В ) * С А А АВ АВ+ АВ + АВ + С АВ + С» ( ( ( + ( + • • 162
В этом примере при обнаружении правой скобки из стека начинают извлекаться элементы до тех пор, пока не будет обнаружена левая скобка, после чего обе скобки отбрасываются. Использование скобок для изменения приоритетности выполнения операций приводит к последовательности их расположения в постфиксной строке, отличной от последовательности в примере 1. Пример 3: ((A—(B+C))»D)f(E+F) ]symb ( ( А — ( В + С ) ) * D ) t ί + F ) Постфиксная строка А А А АВ АВ ABC АВС+ АВС+— АВС+— АВС+ — D АВС+—D# АВС+—D» АВС+—D» ABC+~D»E ABC+—D»E ABC+-iD»EF ABC+—D»EF+ ABC+—D*EF+f Почему алгоритм преобразования столь сложен, в то время как алгоритм вычисления кажется довольно простым? Ответ заключается в том, что первый преобразует строку с одним порядком приоритетности выполнения операций (управляемым функцией prcd и скобками) к естественному порядку (т. е. операция, выполняемая первой, появляется первой). Для учета большого числа комбинаций элементов на вершине стека (непустого) с очередным просматриваемым символом требуется большое число операторов. С другой стороны, во втором алгоритме операторы появляются точно в таком же порядке, в каком они должны быть выполнены. По этой причине операнды могут быть записаны в стек и сохраняться там до того момента, пока не будет найдена операция. В этот момент и происходит выполнение данной операции. Причина использования данного алгоритма преобразования заключается в стремлении извлекать операнды именно в том порядке, в каком они должны выполняться. При решении этой задачи вручную нам пришлось бы руководствоваться довольно неясными указаниями, требующими от нас постепенного преобразования выражения «изнутри наружу». Это может быть выполнено человеком, вооруженным листком бумаги и каранда- ( (( (( ((- ((-( ((-( ((-(+ ((-(+ ((- ( (· (· К t( t(+ t(+ t 163
шом (разумеется, если он не будет при этом ошибаться). Однако при написании программы или алгоритма эти указания должны быть сформулированы четко. Мы не можем быть уверены в том, что достигли самого нижнего скобочного уровня, до тех пор, пока не будет просмотрено несколько добавочных символов. В этой точке мы должны будем вернуться назад. Чтобы не делать ряд обратных проходов, воспользуемся стеком, «запоминая» в нем просмотренные ранее операции. Если просматриваемая операция имеет больший приоритет, чем операция, расположенная в вершине стека, то эта новая операция записывается в стек. Это означает, что при окончательной выборке из стека всех элементов и записи их в строку в постфиксной форме эта новая операция будет предшествовать операции, ранее расположенной перед ним (что является правильным, поскольку она имеет более высокий приоритет). С другой стороны, если приоритет новой операции меньший, чем у операции из стека, то операция, находящаяся на вершине стека, должна быть выполнена первой. Следовательно, она извлекается из стека и помещается в выходную строку, а рассматриваемый символ сравнивается со следующим элементом, занимающем теперь вершину стека, и т. д. Помещая во входную строку скобки, мы можем изменить последовательность вычислений. Так, при обнаружении левой скобки она записывается в стек. При обнаружении соответствующей ей правой скобки все операции между этими скобками помещаются в выходную строку, поскольку они выполняются прежде любых других операций, расположенных за этими скобками. Программа преобразования выражения из инфиксной формы в постфиксную Перед написанием программы нам необходимо: во-первых, точно определить формат входных и выходных данных; во-вторых, создать или по крайней мере определить те программы, на которых будет базироваться основная программа. Мы предполагаем, что вводимые данные состоят из строк символов. Конец строки отмечен символом пробела. Для простоты будем считать, что все операнды состоят из одного символа, который может быть буквой или цифрой. Выходные данные представляют собой строки символов, построенные таким образом, что они могут быть непосредственно использованы для программы вычисления, в предположении, что все односимвольные операнды в исходной строке, выраженной в инфиксной форме, являются цифрами. Для перевода алгоритма преобразования в программу на языке Бейсик воспользуемся несколькими программами. К ним относятся программы pop, empty, push и popandtest, соответст- 164
вующим образом модифицированные для работы со стеком, содержащим символьные элементы. Отметим, что мы не можем использовать переменную PRCD в качестве выходной переменной в подпрограмме prcd, поскольку все переменные, начинающиеся с буквы Р, были определены как символьные строки. Это необходимо по той причине, что обе подпрограммы pop и popandtest выдают символьную строку в переменной POPS. По этой причине мы используем в качестве выходной переменной в программе prcd переменную ZPRCD. Подпрограмма prcd воспринимает в качестве входных данных две односимвольные операции и устанавливает в переменную ZPRCD значение TRUE, если первая операция имеет более высокий приоритет, чем вторая, и она появляется слева от второго операнда в инфиксной строке. В противном случае в переменную ZPRCD устанавливается значение FALSE. Разумеется, эта подпрограмма должна учитывать рассмотренные ранее соглашения относительно использования скобок. Аналогичным образом в подпрограмме stacktop переменная STKTP также не может быть использована в качестве выходной, поскольку в большинстве версий языка Бейсик для различения переменных используются только два первых символа их имени. Следовательно, переменные STKTP и STRING будут рассмотрены как одна переменная. По этой причине в подпрограмме stacktop в качестве выходной переменной используется XSTKTP. После написания вспомогательных подпрограмм мы можем написать основную программу. Мы полагаем, что программа вводит строку, содержащую выражение, записанное в инфиксной форме, осуществляет процедуры преобразования и печатает исходную и преобразованную строки. Тело программы имеет следующий вид: 10 'программа postfix 20 'оператор CLEAR 100 необходим для микроЭВМ TRS-80 30 DEFSTR О, Р, S, X 40 TRUE=1 50 FALSE - 0 60 MAXSTACK=100 70 DIM SITEM(MAXSTACK): 'содержит элементы optsk 1—100 80 ТР=0 90 PSTFX=«» 100 'стек изначально пуст 110 INPUT «ВВЕДИТЕ СТРОКУ»; STRING 120 'начать просмотр символов по одному 130 'строка 3 алгоритма преобразования 140 FOR CHAR=1 TO LEN(STRING) 150 'строка 4 160 SYMB=MID$(STRING,CHAR,1): 'извлечь следующий входной символ 170 'проверить, является ли SYMB операндом 180 'строки 5 и 6 190 IF SYMB>=«0» AND SYMB<«=«9» THEN PSTFX=PSTFX+SYMB: GOTO 310 165
200 'иначе, выполнить операторы с номерами 210—300 210 'строки с 8 по 11 220 GOSUB 3000: 'подпрограмма empty 230 IF EMPTY=TRUE THEN GOTO 290 240 GOSUB 4000: 'подпрограмма stacktop устанавливает иеремен- 'ную XSTKTP 250 OPERl=XSTKTP 260 SECOPER-SYMB 270 GOSUB 8000: 'подпрограмма prcd устанавливает ZPRCD 280 IF ZPRCD=TRUE THEN GOSUB 2000: SMBTP=POPS: PSTFX=PSTFX+SMBTP: GOTO 210 290 'строка 12 (исправленная) 300 IF (EMPTY-TRUE) OR (SYMB <>«)») THEN X=SYMB: GOSUB 1000 ELSE GOSUB 2000 - 310 NEXT CHAR 320 'строки с 15 no 18 330 GOSUB 3000: подпрограмма empty 340 IF EMPTY=TRUE THEN GOTO 380 350 GOSUB 2000: 'подпрограмма pop 360 PSTFX=PSTFX+POPS 370 GOTO 330 380 PRINT «ИНФИКСНАЯ СТРОКА» »; STRING 390 PRINT «ПОСТФИКСНАЯ СТРОКА» »; PSTFX 400 PRINT 410 GOTO 80: 'считать следующую входную строку 420 END 1000 'подпрограмма push 2000 'подпрограмма pop 3000 'подпрограмма empty 4000 'подпрограмма stacktop 8000 'подпрограмма prcd 8010 'входы: OPER1, SECOPER 8020 'выходы: ZPRCD 8030 'локальные переменные: нет 8040 ZPRCD=TRUE 8050 IF (OPERl=«(» OR SECOPER=«)») THEN ZPRCD=FALSE 8060 IF SECOPER=«t» THEN ZPRCD=FALSE 8070 IF (OPERl=«+» OR OPERl=«-») AND (SECOPER=«*> OR SECOPER - «/») THEN ZPRCD=FALSE 8080 RETURN 8090 'конец подпрограммы Программа имеет один серьезный недостаток — она не проверяет, является ли входная строка корректным выражением в инфиксной форме. Читателю рекомендуется проверить работу этой программы на примере правильно составленной входной строки. В качестве упражнения можно написать программу, проверяющую, является ли входная строка правильно составленным выражением в инфиксной форме. Теперь мы можем написать программу, считывающую стро- 156
ку в инфиксной форме и вычисляющую ее числовое значение· Если вводимые строки состоят из операндов, выраженных одной цифрой и не содержащих однобуквенных операндов, то результирующие программы могут быть получены объединением выхода процедуры postfix для каждой вводимой строки со входом процедуры evaluate. Для обеих процедур преобразования и вычисления может быть определен единый набор подпрограмм работы со стеком. В этом разделе основное внимание было отведено преобразованиям, использующим выражения в постфиксной форме. Алгоритм преобразования выражения из инфиксной формы в постфиксную предполагает просмотр выражения слева направо и при необходимости запись в стек и чтение их из стека. Для преобразования из инфиксной формы в префиксную строка в инфиксной форме должна просматриваться справа налево, а соответствующие символы помещаться в префиксную строку также справа налево. Поскольку большинство алгебраических выражений просматривается слева направо, постфиксная форма представляет собой наиболее предпочтительный вариант. Приведенные программы представляют собой только один из многих типов программ, которые могут быть использованы для манипуляции и вычисления постфиксных выражений. Они не являются наилучшими или уникальными. С тем же успехом можно использовать и другие варианты таких программ. Некоторые компиляторы языков высокого уровня используют для вычисления алгебраических выражений программы, сходные с программами evaluate и postfix. За последнее время для решения этих задач был разработан ряд более сложных программ· Упражнения 1. Преобразуйте каждое из приведенных ниже выражений в префиксную и постфиксную формы: (а) А+В-С (б) (A+BWC—D)fE*F (в) {A+BWCf(D-E)+F)-G (г) А+ (((В-С) · (D-E) +F)/G) f (H-J) 2. Преобразуйте каждое из приведенных ниже префиксных выражений в инфиксную форму: (а) +-АВС (б) +А—ВС (в) ++А—f#BCD/+EF»GHI (г) +—fABOD*EFG 3. Преобразуйте каждое из приведенных постфиксных выражений в инфиксную форму: (а) АВ+С— (б) АВС+— (в) АВ—C+DEF—+f (г) ABCDE—+t»EF#— 4. Используйте приведенный в тексте алгоритм вычисления выражений для вычисления следующих выражений, представленных в постфиксной форме (предполагается, что А=1, В=2, С=3): 167
(a) AB+C—BA+Cf— * (6) ABC+*CBA—+· 5. Модифицируйте программу преобразования инфиксной формы в постфиксную таким образом, чтобы входная символьная строка, состоящая из операции и операндов постфиксного выражения, преобразовывалась в инфиксную форму с необходимыми скобками. Например, выражение АВ+ должно быть преобразовано в (А+В), а АВ+С должно быть преобразовано в ((А+В)—С). 6. Напишите программу, вычисляющую выражение, записанное в инфиксной форме. Следует воспользоваться двумя стеками — одним для операндов и другим для операторов. Не следует преобразовывать сначала инфиксную строку в постфиксную, и затем вычислять постфиксное выражение, а следует вычислять ее без всякого преобразования. 7. Напишите программу prefix, считывающую входную строку в инфиксной форме и преобразующую ее в префиксную форму. Предполагается, что строка считывается справа налево, а префиксная строка создается также справа налево. 8. Напишите на языке Бейсик программу, преобразующую: а) строку в префиксной форме в строку в постфиксной форме; б) строку в постфиксной форме в строку в префиксной форме; в) строку в префиксной форме в строку в инфиксной форме; г) строку в постфиксной форме в строку в инфиксной форме. 9. Напишите на языке Бейсик программу, считывающую строку в инфиксной форме и формирующую эквивалентную строку в инфиксной форме с удаленными необязательными скобками. Может ли это быть сделано без использования стека? 10. Представим себе вычислительную машину, в которой имеются один регистр и шесть инструкций: LD А помещает операнд А в регистр ST А помещает содержимое регистра в переменную А AD А прибавляет содержимое переменной А к регистру SB А вычитает содержимое переменной А из регистра ML А умножает содержимое регистра на переменную А DV А делит содержимое регистра на переменную А Напишите программу, считывающую выражение в постфиксной форме, которое состоит из однобуквенных операндов и операций +, —, * и /. Она должна напечатать последовательность инструкций, необходимых для вычисления выражения, и оставить результат в регистре. Используйте переменные в виде Τη в качестве временных. Например, постфиксное выражение ABO+DE—/ даст такую распечатку инструкций: LD ML ST LD AD ST В С ΤΙ А ΤΙ Τ2 LD SB ST LD DV ST D Ε T3 T2 T3 T4
Глава 4 Очереди и списки В этой главе рассматриваются очереди — важные структуры данных, часто используемые для моделирования ситуаций из реального мира. Концепции стека и очереди используются затем для введения еще одной новой структуры — списка. Рассматриваются различные формы списков, операции, связанные с ними, и их использование. 4.1. ОЧЕРЕДЬ И ЕЕ РЕАЛИЗАЦИЯ В ВИДЕ ПОСЛ ЕДОВАТЕЛ ЬНОСТИ Очередью называется упорядоченный набор элементов, которые могут удаляться с одного ее конца (называемого началом очереди) и помещаться в другой конец этого набора (называемого концом очереди). На рис. 4.1.1, а приведена очередь, содержащая три элемента — А, В и С. Элемент А расположен в начале очереди, а элемент С — в ее конце. На рис. 4.1.1, б из очереди был удален один элемент. Поскольку элементы могут удаляться только из начала очереди, то удаляется элемент А, а в начале очереди теперь находится элемент В. На рис. 4.1.1, β в очередь добавляются два новых элемента— D и Е, которые помещаются в ее конец. Поскольку элемент D был помещен в очередь перед элементом Е, то он будет удален раньше него. Первый помещаемый в оче- Начало \ А В а Начало \ В С \ Коней, С Конец Начало в Рис.4.1.1. Очередь. Конец 169
редь элемент удаляется первым. По этой причине очередь часто называют списком, организованным по принципу «первый размещенный первым удаляется» в противоположность принципу стековой организации — «последний размещенный первым удаляется». В реальном мире имеется множество примеров очередей. Примером могут служить очередь в банке или на автобусной остановке и очередь задач, обрабатываемых вычислительной машиной. Для очереди определены три примитивные операции. Операция insert (q,x) помещает элемент χ в конец очереди q. Операция х=remove(q) удаляет элемент из начала очереди q и ^присваивает его значение переменной х. Третья операция, ♦empty(q), возвращает значение true или false в зависимости ют того, является ли данная очередь пустой или нет. Очередь на рис. 4.1.1 может быть реализована при помощи следующей последовательности операций (мы предполагаем, что очередь ^изначально является пустой): insert (q,A)^ insert (q,B) insert (q,C) [рис. 4.1.1,α] χ=remove(q) [рис. 4.1.1,6; χ устанавливается в А] insert (q,D) insert (q,E) [рис. 4.1.1,б] Операция insert может быть выполнена всегда, поскольку на количество элементов, которые может содержать очередь, никаких ограничений не накладывается. Операция remove, однако, применима только к непустой очереди, поскольку невозможно удалить элемент из очереди, не содержащей элементов. Результатом попытки удалить элемент из пустой очереди является возникновение исключительной ситуации потеря значимости. Операция empty, разумеется, выполнима всегда. Каким образом очередь может быть реализована в языке Бейсик? Первое, что приходит в голову, это использование для данной цели массива, в котором будут располагаться элементы очереди, а также две переменные — FRNT и REAR, которые будут содержать позиции массива, занимаемые первым и последним элементами очереди. Изначально REAR устанавливается в 0, a FRNT —в 1, и очередь всегда пуста, если REAR<FRNT. Число элементов в очереди в любой момент времени равно значению REAR—FRNT+1. Пустая очередь может быть объявлена следующим образом: !0 MAXQUEUE=100 20 DIMQITEMS(MAXQUEUE) 30 FRNT=1 40 REAR = 0 Разумеется, использование для представления очереди массива порождает возможность его переполнения, если очередь содержит больше элементов, чем их было отведено в массиве. 170
Игнорируя возможность потери значимости и переполнения/ операцию insert (q, x) можно реализовать при помощи следующих операторов: 3000 REAR = REAR +1 ЗОЮ QITEMS(REAR)=X а операцию x='remove(q)—при помощи операторов 2000 X=QITEMS(FRNT) 2010 FRNT = FRNT+1 Рассмотрим, что произойдет при таком представлении. На рис. 4.1.2 показан массив из пяти элементов (мы по-прежнему QITEMS QITEMS 5 4 3 2 1 5 4 3 2 1 а QITEMS С в FRNT = 1 REAR = 0 FRNT = REAR = 3 Рис. 4.1.2. 5 4 3 2 1 5 4 3 2 1 С в А 6 QITEMS Ε D С г REAR = 3 FRNT = 1 REAR=5 FRNT = 3 игнорируем элемент с индексом 0), используемый для представления очереди (т. е. MAXQUEUE = 5). Изначально (рис· 4.1.2, а) очередь пуста. На рис. 4.1.2, б в очереди находятся элементы А, В и С. На рис. 4.1.2,0 два элемента были удалены, а на рис. 4.1.2, г были добавлены два новых элемента — D и Е. Значение FRNT равно 3, а значение REAR равно 5, поэтому в очереди имеется только 5—3+1=3 элемента. Поскольку массив содержит пять элементов, для очереди должно существовать дополнительное пространство для возможности расши* 171
рения очереди без опасности переполнения. Однако для размещения в очереди элемента F переменная REAR должна быть увеличена на 1 (получив при этом значение 6), а в элемент QITEMS(6) должно быть помещено значение F. Но массив QITEMS содержит только пять элементов, поэтому данная вставка невозможна. Итак, возможно возникновение абсурдной ситуации, при которой очередь является пустой, однако новый элемент разместить в ней нельзя (рассмотрите последовательность операций удаления и вставки, приводящую к такой ситуации). Ясно, что реализация очереди при помощи массива является неприемлемой. Одним из решений возникшей проблемы может быть модификация операции 'remove таким образом, что при удалении очередного элемента вся очередь смещается к началу массива. Операция χ = remove (q) может быть в этом случае реализована следующим образом (мы по-прежнему не учитываем возможность возникновения ситуации потери значимости): 2000 X=QITEMS(1) 2010 FOR 1 = 1 ТО REAR-1 2020 QITEMS(I)=QITEMS(I+1) 2030 NEXT I 2040 REAR=REAR-1 Переменная FRNT больше не требуется, поскольку первый элемент массива всегда является началом очереди. Пустая очередь представлена очередью, для которой значение REAR равно нулю. На рис. 4.1.3 показана очередь для рис. 4.1.2 при таком новом представлении. Однако этот метод весьма непроизводителен. Каждое удаление требует перемещения всех оставшихся в очереди элементов. Если очередь содержит 500 или 1000 элементов, то очевидно, что это весьма неэффективный способ. Далее, операция удаления элемента из очереди логически предполагает манипулирование только с одним элементом, т. е. с тем, который расположен в начале очереди. Реализация данной операции должна отражать именно этот факт, не производя при этом множества дополнительных действий. Более эффективная альтернатива рассмотрена в конце раздела. Другой способ предполагает рассматривать массив, который содержит очередь в виде замкнутого кольца, а не линейной последовательности, имеющей начало и конец. Это означает, что первый элемент очереди следует сразу же за последним. Это означает, что даже в том случае, если последний элемент занят, новое значение может быть размещено сразу же за ним на месте первого элемента, если этот первый элемент пуст. Рассмотрим пример. Предположим, что очередь содержит три элемента — в позициях 3, 4 и 5 пятиэлементного массива. Этот случай, показанный на рис. 4.1.2, г, повторно воспроизведен на рис. 4.1.4, а. Хотя массив и не заполнен, последний эле- 172
QITEMS QITEMS 5 4 3 2 1 5 4 3 2 1 α QITEMS с в 5 4 3 2 1 REAR = 0 5 4 3 2 REAR = 1 1 Рис. 4.1.3. С В А 6 QITEMS Ε D С г REAR = 3 REAR = 3 мент очереди занят. Если теперь делается попытка поместить в очередь элемент F, то он будет записан в первую позицию массива, как это показано на рис. 4.1.4, б. Первый элемент очереди есть QITEMS (3), за которым следуют элементы QITEMS(4), QITEMS(5) и QITEMS(l). На рис. 4.1.4, в — д показано состояние очереди после того, как из нее были удалены первые два элемента — С и D, затем помещен элемент G и, наконец, удален элемент Е. К сожалению, при таком представлении довольно трудно определить, когда очередь пуста. Условие REAR<FRNT больше не годится для такой проверки, поскольку на рис. 4.1.4,6 и в показаны случаи, при которых данное условие выполняется, но очередь при этом не является пустой. Одним из способов решения этой проблемы является введение соглашения, при котором значение FRNT есть индекс элемента массива, немедленно предшествующего первому элементу очереди, а не индексу самого первого элемента. В этом случае, поскольку REAR содержит индекс последнего элемента очереди, условие FRNT = REAR подразумевает, что очередь пуста. Очередь из чисел может быть объявлена и инициализирована следующим образом: 173
QITEMS a QITEMS 5 4 3 2 1 Ε D С REAR = 5 FRNT = 3 5 4 3 2 1 Ε D С F FRNT = 3 REAR = 1 QITEMS QITEMS 5 4 3 2 1 Ε F FRNT=5 REAR = 1 5 4 3 2 1 £ G F FRNT = 5 REAR = 2 QITEMS 5 4 3 2 1 G F д Рис. 4.1> REAR = 2 FRNT = 1 1. 10 MAXQUEUE=100 20 DIM QITEMS (MAXQUEUE) 30 FRNT=MAXQUEUE 40 REAR=MAXQUEUE Отметим, что в FRNT и REAR устанавливается значение последнего индекса массива, а не 0 и 1, поскольку при таком представлении очереди последний элемент массива немедленно предшествует первому элементу. Поскольку REAR = FRNT, то очередь изначально пуста. 174
Подпрограмма empty может быть записана следующим образом: 1000 'подпрограмма empty 1010 'входы: FRNT, REAR 1020 'выходы: EMPTY 1030 'локальные переменные: нет 1040 IF FRNT=REAR THEN EMPTY=TRUE ELSE EMPTY = FALSE 1050 RETURN 1060 'конец подпрограммы Операция remove может быть записана следующим образом: 2000 'подпрограмма remove 2010 'входы: FRNT, MAXQUEUE, QITEMS 2020 'выходы: RMOVE 2030 'локальные переменные: EMPTY 2040 GOSUB 1000: 'подпрограмма empty устанавливает переменную 'EMPTY 2050 IF EMPTY=TRUE THEN PRINT «ВЫБОРКА ИЗ ПУСТОЙ ОЧЕРЕДИ»: STOP 2060 IF FRNT=MAXQUEUE THEN FRNT=1 ELSE FRNT-FRNT+1 2070 RMOVE=QITEMS (FRNT) 2080 RETURN 2090 'конец подпрограммы Отметим, что значение FRNT должно быть модифицировано до момента извлечения элемента. Разумеется, зачастую ситуация потери порядка имеет вполне осмысленное значение и служит сигналом для новой фазы обработки. Мы можем захотеть использовать подпрограмму removeatidtest в строке с номером 9000, которая вызывается следующим образом: 100 GOSUB 9000: 'подпрограмма removeandtest устанавливает 'переменные RMOVE и UND 110 IF UND=TRUE THEN 'выполнить действия по исправлению ситуации ELSE 'RMOVE есть удаляемый из очереди элемент Подпрограмма removeandtest устанавливает в переменную UND значение FALSE и в переменную RMOVE элемент, удаляемый из очереди, если при этом очередь не пуста, и устанавливает в переменную UND значение TRUE при возникновении ситуации потери значимости. Пользователю предлагается написать эту подпрограмму самостоятельно. Операция вставки Для того чтобы запрограммировать операцию вставки, должна быть проанализирована ситуация, при которой возникает переполнение. Переполнение происходит в том случае, 175
если весь массив уже занят элементами очереди и при этом делается попытка разместить в ней еще один элемент. Рассмотрим, например, очередь на рис. 4.1.5, а. В ней находятся три элемента — С, D и Е, соответственно расположенные в QITEMS(3), QITEMS(4) и QITEMS(5). Поскольку последний элемент в очереди занимает позицию QITEMS(5), значение REAR равно 5. Так как первый элемент в очереди находится в QITEMS(3), значение FRNT равно 2. На рис. 4.1.5,6 и в в очередь помещаются элементы F и G, что приводит к соответству- QITEMS REAR = 5 FRNT = 2 QITEMS D FRNT = 2 REAR = 1 QITEMS FRNT = REAR = 2 в Рис. 4.1.5. ющему изменению значения REAR. В этот момент массив становится целиком заполненным, и попытка произвести еще одну вставку приводит к переполнению. Это регистрируется тем фактом, что FRNT=REAR, а это как раз и указывает на переполнение. Очевидно, что при такой реализации нет возможности сделать различие между пустой и заполненной очередью. Разумеется, такая ситуация удовлетворить нас не может. Одно из решений состоит в том, чтобы пожертвовать одним элементом массива и позволить очереди расти до объема на единицу меньшего максимального. Так, если массив из 100 элементов объявлен как очередь, то очередь может содержать до 99 элементов. Попытка разместить в очереди 100-й элемент 176
приведет к переполнению. Подпрограмма insert может быть записана следующим образом: 3000 'подпрограмма insert ЗОЮ 'входы: FRNT, MAXQUEUE, QITEMS, REAR, X 3020 'выходы: QITEMS, REAR 3030 'локальные переменные: нет 3040 'выделить пространство для нового элемента 3050 IF REAR=MAXQUEUE THEN REAR^l ELSE REAR=REAR+1 3060 'проверка на переполнение 3070 IF REAR=FRNT THEN PRINT «ПЕРЕПОЛНЕНИЕ ОЧЕРЕДИ»: STOP 3080 QITEMS (REAR) =X 3090 RETURN 3100 'конец подпрограммы Проверка на переполнение в подпрограмме insert производится после установления нового значения для REAR, в то время как проверка на потерю значимости в подпрограмме remove производится сразу же после входа в подпрограмму до момента обновления значения FRNT. Альтернативная реализация на языке Бейсик Альтернативный способ реализации очереди на языке Бейсик предполагает использование массивов QUEUE и QITEMS и инициализации переменных FRNT, REAR и массивов QITEMS и QUEUE следующим образом: 10 MAXQUEUE=100 20 DIM QITEMS (MAXQUEUE) 30 DIMQUEUE(2) 40 FRNT^l 5Q REAR=2 60 QUEUE (FRNT) =MAXQUEUE 70 QUEUE (REAR) =MAXQUEUE При таком представлении на конец и начало очереди в отличие от FRNT и REAR указывают значения QUEUE (FRNT) и QUEUE (REAR). Преимущество такого представления заключается в том, что оно позволяет держать оба указателя в одном элементе (QUEUE). Однако при этом подпрограммы insert и remove становятся более громоздкими. Упражнения 1. Напишите подпрограмму removeandtest, которая устанавливает в переменную UND значение FALSE и в переменную А элемент, удаляемый иа непустой очереди, и устанавливает в UND значение TRUE, если очередь пуста. 2. Какой набор условий необходим и достаточен для последовательно* сти операций insert и remove над одной пустой очередью, чтобы очередь осталась пустой и не возникла ситуация потери значимости? Какой набор· условий необходим и достаточен для такой последовательности, чтобы исходно непустая очередь осталась неизмененной? 12—212 177
3. Если считать, что массив не кольцевой, то предполагается, что каждая операция REMOVE должна сдвигать вниз каждый оставшийся в очереди элемент. Альтернативный метод предполагает откладывание этой операции до тех пор, пока значение REAR не будет равно значению последнего индекса в массиве. При возникновении такой ситуации попытка размещения элемента в очереди вызывает смещение всей очереди вниз таким образом, что первый элемент очереди находится в первой позиции массива. Каковы преимущества этого метода по сравнению с методом, при котором сдвиг элементов производится после каждой операции remove? Каковы его недостатки? Напишите подпрограммы remove, insert и empty, использующие данный метод. 4. Покажите, как последовательность вставок и удалений из представленной линейным массивом очереди может вызвать переполнение при попытке разместить элемент в пустой очереди. 5. Мы можем не жертвовать одним элементом очереди, если к представлению очереди добавить переменную EMPTY. Покажите, как это может быть сделано, и перепишите подпрограммы манипуляции с очередью при таком представлении. 6. Каким образом можно реализовать очередь из стеков? Стек из очередей? Очередь очередей? Напишите программу, реализующую соответствующие операции для каждой из рассмотренных структур данных, 7. Покажите, как реализовать очередь из целых чисел на языке Бейсик (предполагая, что массивы начинаются с индекса 0) при помощи массива QITEMS, где элемент QITEMS(O) используется для указания начала очереди, элемент QITEMS (MAXQUEUE+1)—для указания ее конца, а элементы с QITEMS(l) no QITEMS(MAXQUEUE)—для хранения элементов очереди. Покажите, как инициализировать такой массив для представления пустой Очереди, и напишите подпрограммы remove, insret и empty для данной реализации. 8. Покажите, как реализовать в языке Бейсик очередь, каждый элемент которой состоит из трех целых чисел. 9. Пусть deque есть упорядоченный набор элементов, которые могут удаляться и добавляться с обоих концов. Назовем концы набора deque соответственно left и right. Как можно представить deque при помощи массива в Бейсике? Напишите четыре программы на языке Бейсик: remvleft, remvright, insrtleft, insrtright для удаления элементов с левого и правого концов очереди deque. Удостоверьтесь в том, что эти подпрограммы работают правильно с пустой очередью и что они обнаруживают переполнение и потерю значимости. 10. Определим очередь, ограниченную изнутри, как очередь deque (см. упражнение 9), для которой значимыми являются только операции remvleft, remvright, insrtleft и instright. Покажите, каким образом каждая из них может быть использована для представления как стека, так и очереди. 11. Автостоянка содержит одну полосу, на которой может быть размещено до 10 автомашин. Машины въезжают с южного конца стоянки и выезжают с северного. Если автомобиль владельца, пришедшего на стоянку забрать его, не расположен севернее всех остальных, то все автомобили, стоящие севернее его, удаляются из гаража, затем выезжает его машина и оставшиеся машины помещаются назад в том же порядке. Если машина покидает гараж, то все машины, расположенные южнее, сдвигаются вперед столько раз, сколько имеется свободных позиций в северной части. Напишите программу, которая считывает группу строк с оператором DATA. Каждая строка содержит «А» для прибытия и «D» для отправления, а также номер машины. Предполагается, что машины прибывают и убывают в порядке, задаваемом этим списком строк. Программа должна выдавать сообщение при каждом прибытии или отправлении машины. При прибытии машины в нем должно говориться, имеется ли на стоянке свободное место. Если свободное место отсутствует, машина ждет до тех пор, пока оно не освободится, или до момента считывания строки, требующей отправления 178
данной автомашины. При появлении "свободного места должно выдаваться другое сообщение. При отправлении автомашины сообщение должно содержать в себе число перемещений машины внутри гаража (включая ее отъезд, но не прибытие; это число равно 0, если машина была отправлена во время нахождения в режиме ожидания свободного места). 12. Фирма ABC по хранению и сбыту бытовых инструментов и приспособлений получает грузы с оборудованием по различным ценам. Фирма продает их затем с 20%-ной надбавкой, причем товары, полученные ранее, продаются в первую очередь (стратегия «первым полученный — первым продается»). Оборудование из первой партии грузов продается по цене, на 20% превышающей" закупочную. После того как вся первая партия целиком распродана, приступают к продаже второй партии, также по увеличенной на 20% цене, и т. д. Напишите программу, считывающую записи о торговых операциях двух типов: операции по закупке и операции по продаже. Запись о продаже содержит префикс «S» и количество товара, а также стоимость данной партии. Запись о закупке содержит префикс cR», количество товара, стоимость одного изделия и общую стоимость всей партии. После считывания записи о закупке напечатайте ее. После считывания записи об операции продажи напечатайте ее и сообщение о цене, по которой были проданы изделия. Например, если этой фирмой были проданы 200 единиц оборудования, в которые входили 50 единиц с закупочной ценой 1 долл., 100 единиц с закупочной ценой 1,1 долл. и 50 единиц с закупочной ценой 1,25 долл., то напечатается (вспомните о 20%-ной добавке) ФИРМА ПРОДАЛА 200 ШТУК 50 ШТУК ПО ЦЕНЕ 1.20 ДОЛЛАРА НА СУММУ 60.00 ДОЛЛАРОВ 100 ШТУК ПО ЦЕНЕ 1.32 ДОЛЛАРА НА СУММУ 132.00 ДОЛЛАРОВ 50 ШТУК ПО ЦЕНЕ 1.50 ДОЛЛАРА НА СУММУ 75.00 ДОЛЛАРОВ ОБЩАЯ СТОИМОСТЬ 267.00 ДОЛЛАРОВ Если количество товара на складе не достаточно для выполнения заказа, то продается все имеющееся и печатается сообщение (XXX ЕДИНИЦ ТОВАРА ОТСУТСТВУЕТ НА СКЛАДЕ) 4.2. СВЯЗАННЫЕ СПИСКИ Каковы последствия использования структур с последовательным хранением данных для представления стеков и очередей? К одному из главных недостатков относится то, что стеку или очереди отводится неизменяемый фиксированный объем памяти, даже если структура использует не весь отведенный объем или же совсем его не использует. Далее, невозможность расширения однажды отведенного объема создает вероятность возникновения переполнения. Предположим, что программа использует два стека, реализованных при помощи двух отдельных массивов — SI ITEMS и и S2ITEMS. Далее предположим, что каждый из этих массивов содержит 100 элементов. Таким образом, несмотря на тот факт, что для обоих стеков доступны 200 элементов, каждый из них не может содержать элементов больше чем 100. Даже если первый стек содержит только 25 элементов, второй все равно не может содержать их больше чем 100. Одним из решений проблемы является использование одного массива SITEMS из 200 элементов. Первый стек займет позиции SITEMS (1), 179
SITEMS(2), ..., SITEMS(Tl), а второй стек будет расположен в другом конце массива, заняв позиции SITEMS(200), SITEMS(199), ..., SITEMS(T2) (где ТКТ2). Таким образом, пространство, не занятое одним из стеков, может занять другой стек. Разумеется, при этом необходимы два различных набора подпрограмм — pop, push и empty, поскольку один стек растет с увеличением Т1, а другой — с уменьшением Т2. К сожалению, такой способ, позволяющий двум стекам пользоваться одной общей областью, не имеет простого решения для случая, когда число стеков равно трем или более, или даже для двух очередей. Необходимо отслеживать основания и вершины (или начала и концы) всех структур, совместно использующих пространство одного большого массива. Каждый раз, когда увеличение одной структуры грозит привести к наложению на область, занятую в данный момент другой структурой, все структуры внутри массива должны быть сдвинуты, чтобы освободить пространство, необходимое для данного расширения. При последовательном представлении элементы стека или очереди неявно естественно упорядочены последовательным порядком хранения. Так, если QITEMS(X) представляет собой элемент очереди, то следующим элементом будет QITEMS(X+ + 1) [или QITEMS(l), если X=MAXQUEUE]. Предположим, что элементы стека или очереди были явным образом упорядочены. Это означает, что каждый элемент содержит внутри себя адрес следующего элемента. Такое явное упорядочивание приводит к структуре данных, изображенной на рис. 4.2.1, которая info ptrnxi info ptrnxi info ptrnxi info ptrnxi Isi- null Узел Узел Узел Рис. 4.2.1. Линейный связанный список. Узел носит название линейный связанный список. Каждый элемент списка, называемый также узлом, содержит два поля — поле информации (info) и поле следующего адреса (pt'rnxt). Поле информации содержит фактический элемент списка. Поле следующего адреса содержит адрес следующего элемента списка. Такой адрес, используемый для доступа к следующему элементу, называется указателем. Доступ ко всему связанному списку осуществляется через внешний указатель 1st, который указывает на первый элемент в списке (содержит адрес этого элемента). (Под «внешним» указателем мы понимаем тот, который не содержится внутри элемента. Его значение может быть 180
получено ссылкой на некоторую переменную.) Поле следующего адреса последн^р6\ элемента содержит так называемое пустое, или нулевое, значение — null. Это значение не является значимым адресом. Нулевой указатель используется для указания конца списка. Список, не содержащий элементов, называется пустым или нулевым списком. Значение внешнего указателя 1st для такого списка равно значению нулевого указателя. Следовательно, список может быть сделан пустым при помощи операции 1st = -null. Введем теперь некоторые обозначения, используемые в алгоритмах (но не в программах на языке Бейсик). Если ρ есть указатель на элемент списка, то node(p) ссылается к элементу, на который указывает р, info(p)—к информационной части этого элемента, a ptrnxt(p)—к полю следующего адреса данного элемента, являясь тем самым указателем. Так, если ptrnxt(p) не нулевой, то info(ptrnxt(p)) ссылается к информационной части элемента, который следует за элементом node(p) данного списка. Вставка и удаление элементов из списка Список представляет собой динамическую структуру данных. Число элементов списка может сильно изменяться по мере того, как элементы помещаются в список или удаляются из него. Динамическая природа списка может быть противопоставлена статической природе массива, чей размер остается неизменным. Например, предположим, что у нас имеется список целых чисел, показанный на рис. 4.2.2, а, и мы хотим поместить целое число 6 в начало этого списка. Это означает, что мы хотим изменить список, придав ему вид, показанный на рис. 4.2.2, е. На первом шаге необходимо получить элемент, в котором будет храниться наше целое число. Если список может уменьшаться и сжиматься, то, очевидно, должен существовать механизм создания пустых элементов, добавляемых к списку. Отметим, что в отличие от массива список не является наперед заданным набором ячеек, в который помещаются элементы. Предположим, что существует механизм создания пустых элементов. Операция p = getnode получает пустой элемент и помещает в переменную с именем ρ адрес этого элемента. Это означает, что ρ является указателем на этот заново распределенный элемент. На рис. 4.2.2, б приведены список и новый элемент после выполнениЯ^операции getnode. Подробности того, как может быть реализована эта операция, будут рассмотрены ниже. 181
info ptrnxt info ptrnxt info ptrnxt lst-+ p—► info Ist—^ P-+ info 6 1st ► A™ ш ^ 1st *- info 6 5 ptrnxt info 5 ptrnxt info 5 ptrnxt « /5/ — info ptrnxt 6 info ptrnxt 6 ptrnxt ptrnxt >±. info ' 5 info 5 info 5 3 α 8 MM// w/o ptrnxt 3 6 info ptrnxt 8 MM// info ptrnxt 3 5 p/rwjt/ г д ptrnxt info ptrnxt 8 null info ptrnxt 3 info ptrnxt 3 w/b p/rw.v/ 3 info 8 info 8 info 8 ptrnxt r ptrnxt null ptrnxt null Рис. 4.2.2. Добавление элемента к началу списка. На следующем шаге необходимо поместить целое число 6 в часть info созданного списка. Это делается при помощи операции info (р) =6 Результат операции показан на рис. 4.2.2, в. После заполнения части info в элементе node(p) необходимо заполнить часть ptrnxt данного элемента. Поскольку элемент node(p) помещается в начало списка, следующий элемент должен быть текущим первым элементом списка. Так как переменная 1st содержит адрес этого первого элемента, то node(p) может быть добавлен к списку при помощи операции ptrnxt (р) «1st 182
Эта операция помещает значение 1st (которое есть адрео первого элемента в списке) в поле ptrnxt элемента node(p). Рис. 4.2.2, г иллюстрирует результат этой операции. В данный момент ρ указывает на список, к которому добавлен дополнительный элемент. Однако поскольку 1st является по отношению к необходимому нам списку внешним указателем, то его значение должно быть заменено на адрес нового первого элемента этого списка. Это может быть осуществлено при помощи следующей операции: 1st —ρ которая устанавливает значение 1st равным значению р. Результат этой операции проиллюстрирован на рис. 4.2.2, д. Отметим, что рис. 4.2.2, д и г идентичны, за исключением того, что на рис. 4.2.2, е не показано значение р. Это обусловлено тем, что ρ используется как вспомогательная переменная в процессе модификации списка, однако ее значение не существенно по отношению к состоянию списка до и после этого процесса. После выполнения указанных операций значение ρ может быть изменено без каких-либо изменений состояний данного списка. Собрав все шаги вместе, мы получим алгоритм размещения числа 6 в начале списка 1st: p = getnode info (ρ) =6 ptrnxt (ρ) = 1st 1st —ρ Этот алгоритм может быть обобщен таким образом, чтобы в начало списка 1st помещался любой объект X. Это делается заменой операции info(p)=6 операцией info (ρ)—χ. Убедитесь в том, что алгоритм работает правильно даже в том случае, если список изначально пуст (1st=null). На рис. 4.2.3 показан процесс удаления первого элемента из непустого списка и сохранение извлеченного из поля info значения в переменной х. Исходный вид списка приведен на рис. 4.2.3, а, а окончательный — на рис. 4.2.3, е. Сам процесс почти в точности противоположен процессу добавления элемента к началу списка. Для получения рис. 4.2.3, г из рис. 4.2.3, α выполняются следующие операции (их функции должны быть понятны): p=lst Грис. 4.2.3,6] 1st=ptrnxt (р) [рис. 4.2.3,в] x=into(p) [рис. 4.2.3,г] К этому моменту алгоритм выполнял требуемые функции: первый элемент был удален из 1st и в χ было установлено требуемое значение. Однако алгоритм еще не завершен до конца. На рис. 4.2.3, г ρ по-прежнему указывает на элемент, кото- 183
1st' info 7 ptmxt info 5 ptrnxt info 9 ptrnxt null ! 7 5 9 null 7 4*< 1st — 5 9 null x = 7 p- 7 * to — ^ 5 9 null χ = 7 ρ — *■ x*7 1st* 5 e 5 9 null 9 ли// Рис. 4.2.3. Удаление элемента из начала списка. рый раньше был первым элементом списка. Однако теперь этот элемент не нужен, поскольку он больше не находится в списке, а информация из него помещена в х. [Элемент отсутствует в списке, несмотря на тот факт, что ptrnxt (р) указывает на элемент списка, так как нет возможности доступа к node(p) через внешний указатель 1st.]! Переменная ρ использовалась в процессе удаления элемента из списка как вспомогательная. Начальная и конечная конфигурации списка не содержат ссылок к р. По этой причине разумно предположить, что ρ будет использовано для каких-либо других целей вскоре после завершения данной операции. Однако, поскольку значение ρ изменилось, какой-либо доступ к элементу исключается, так как его адрес не содержится ни во внешнем указателе, ни в поле ptrnxt. Следовательно, этот элемент в данный момент является бесполезным и не может быть использован. Однако он занимает оперативную память. Г84
Желательно было бы иметь какой-нибудь механизм, позволяющий повторно использовать элемент node(p) даже в том случае, если значение указателя ρ изменилось. Это осуществляет операция freenode(p) (рис. 4.2.3, д) После выполнения этой операции ссылаться к node(p) запрещено, поскольку данный элемент больше не является распределенным. Так как значение ρ есть указатель на освобожденный элемент, то всякая ссылка к этому значению также запрещается. Однако элемент может быть повторно распределен и указатель на него переустановлен в ρ при помощи операции р = = getnode. Отметим, что мы говорим, что узел «может быть> перераспределен, поскольку операция getnode возвращает указатель на какой-то вновь распределенный элемент. Нет никакой гарантии в том, что этот новый элемент окажется элементом, который был только что освобожден. Операции getnode и freenode можно трактовать следующим образом. Операция getnode создает новый элемент, а операция freenode уничтожает существующий. При таком подходе элементы не рассматриваются как используемые и повторно используемые, а скорее рассматриваются как создаваемые и уничтожаемые. Перед тем как поговорить об операциях getnode и freenode, а также о представляемых ими концепциях более подробно, сделаем одно интересное наблюдение. Реализация связанных стеков Операция добавления элемента к началу связанного списка аналогична записи его в стек. В обоих случаях новый добавленный элемент является единственным текущим доступным элементом из всего набора. Доступ к стеку осуществляется только через его верхний элемент, а доступ к списку — только через указатель на его первый элемент. Аналогичным образом операция удаления первого элемента из связанного списка сходна с удалением элемента из стека. В обоих случаях из набора элементов удаляется единственный доступный элемент, а доступным становится следующий за ним. Мы обнаружили еще один способ представления стека. Стек может быть представлен линейным связанным списком. Первый элемент этого списка является вершиной стека. Если на такой связанный список указывает внешний указатель stack, то операция push (stack, x) может быть записана следующим образом: ρ = getnode info(p)=x 185
pfrnxt(p)=stack stack=ρ Операция empty (stack) в данном случае является простой проверкой равенства stack нулевому значению (null). Операция х=рор (stack) есть операция удаления первого элемента из непустого списка и выдачи сообщения о возникшей потере значимости, если список пуст: if empty (stack) = true then print «выборка из пустого стека» stop endif ρ=stack stack=ptrnxt(p) x=info(p) freenode(p) Стек Начало Начало б 4 4 Стек 5 5 1 1 -»- 3 3 7 7 α 6 в 8 8 9 9 -^ 7 null 7 null Конец 3 null 3 Конец \ 6 null Рис. 4.2.4. Стек и очередь, представленные линейными связанными списками. На рис. 4.2.4, а показан стек, реализованный в виде связанного списка, а на рис. 4.2.4, б показан тот же стек после раз· мещения в нем еще одного элемента. Операции getnode и freenode Вернемся теперь назад к обсуждению операций getnode и freenode. В абстрактном идеальном мире можно рассматривать бесконечное число неиспользованных элементов, доступных для абстрактных алгоритмов. Операция getnode находит один из таких элементов и делает его доступным для алгоритма. Альтернативный подход предполагает рассматривать операцию getnode как машину, производящую элементы и никогда не останавливающуюся. Каждый раз при выполнении операции 186
getnode она предоставляет пользователю новый элемент, отличный от всех ранее использованных элементов. При таком идеализированном представлении операция freenode становится ненужной. Зачем использовать старый «отработавший» элемент, если простое обращение к функции getnode дает возможность получения нового, неиспользованного ранее элемента? Единственный вред, который может быть причинен неиспользуемым элементом, заключается в возможности уменьшения числа доступных элементов, однако, поскольку имеется бесконечный запас элементов, это ограничение не имеет никакого смысла. Следовательно, нет никаких оснований для повторного использования элемента. К сожалению, мы живем в реальном мире. Вычислительные машины не располагают бесконечно большим объемом памяти и не могут предоставить ее всегда в требуемом количестве. Следовательно, число доступных элементов конечно и в любой момент времени не может быть превышено. Если требуется использовать большее число элементов, то некоторые из них должны быть использованы повторно. Операция freenode делает элемент, не используемый более в текущем контексте, доступным для использования в другом контексте. Мы можем рассматривать конечный набор (пул) изначально существующих пустых элементов. Доступ программиста к этому пулу производится только через операции getnode и freenode. Операция getnode удаляет элемент из пула, а операция freenode возвращает элемент обратно в него. Поскольку неиспользованные элементы не отличаются друг от друга, то не имеет никакого значения, какие именно элементы извлекаются операцией getnode или же в какое место пула помещает элемент операция freenode. Одним из наиболе естественных способов реализации данного пула является представление его в виде связанного списка, действующего как стек. Элементы такого списка связаны между собой через имеющееся в каждом из них поле pt'rnxt. Операция getnode удаляет первый элемент из этого списка и делает его доступным для пользователя. Операция freenode добавляет элемент к началу списка, делая его доступным для перераспределения последующей операцией getnode. Что происходит, если список доступных " элементов пуст? Это означает, что все элементы в текущий момент использованы и распределение дополнительных элементов невозможно. Если программа обращается к операции getnode, а список доступных элементов при этом пуст, то это означает, что объем памяти, отведенный под структуры данных этой программы, слишком мал. Следовательно, возникает переполнение. Это аналогично ситуации, при которой стек, реализованный на базе массива, выходит за границы последнего. 187
До тех пор пока структуры данных рассматриваются в абстрактном бесконечном пространстве, возможность переполнения исключена. Она возникает только при переходе к реализации на реальных объектах в ограниченном пространстве. Предположим, что внешний указатель avail указывает на список доступных элементов. Тогда операция ρ = getnode реализуется следующим образом: if avail=null then print «переполнение» stop endif ρ=avail avail=ptrnxt (avail) Поскольку возможность переполнения относится к операции getnode, то такое переполнение не надо учитывать в реализации операции push посредством списка. Если стек переполняет все доступные элементы, то оператор ρ = getnode и так приведет к переполнению. Реализация операции freenode очевидна: pt'rnxt(p)= avail avail = ρ Преимущество реализации посредством списка заключается в том, что все используемые программой стеки могут обращаться к единственному списку доступных элементов. Если стеку необходим элемент, то он может получить его из одного списка доступных элементов. Если стеку элемент больше не требуется, то он возвращает этот элемент обратно в данный список. До тех пор пока общий необходимый стекам объем памяти меньше, чем общий изначально отведенный им объем, каждый стек может расти и уменьшаться в любых пределах. Никакого пространства заранее под стек не отводится, и в то же время ни один из стеков не блокирует не занятого им пространства. Отметим, что и другие структуры, например очереди, могут совместно использовать один и тот же набор элементов. Реализация связанных очередей Посмотрим теперь, как можно реализовать очередь в виде связанного списка. Вспомним, что элементы удаляются из начала очереди и помещаются в ее конец. Пусть указатель списка» указывающий на первый элемент списка, обозначает начало очереди. Указатель на последний элемент списка обозначает конец очереди. Это проиллюстрировано на рис. 4.2.4, в. На риа 4.2.4,г показана та же самая очередь после размещения в ней нового элемента. 188
Пусть очередь queue состоит из списка и двух указателей — frnt и rear, тогда операции empty (queue) и χ=remove (queue) аналогичны операциям empty (stack) и х=рор (stack) с указателем frnt, заменяющим stack. Особое внимание надо обратить на случай, при котором из очереди удаляется последний элемент. В этом случае в переменную rear должно быть установлено значение null, поскольку в пустой очереди значения frnt и rear равны null. Тогда операция χ=remove (queue) может быть записана следующим образом: if empty (queue) =true then print «выборка из пустой очереди» stop endif ρ=frnt x=info(p) frnt=ptrnxt(p) if frnt^null then rear=null endif freenode(p) Операция insert (queue, χ) может быть реализована следующим образом: p=getnode info(p)=x ptrnxt(p)=null if rear=null then frnt=ρ else ptrnxt(rear)=p endif rear=ρ Каковы недостатки представления очереди или стека при помощи связанного списка? Очевидно, что элемент связанного списка занимает больше места, чем соответствующий элемент массива, поскольку для каждого элемента списка требуется два поля (info и ptrnxt), в то время как при использовании массива необходим только один элемент. Однако пространство, занимаемое списком, обычно гораздо меньше, чем удвоенный объем массива, поскольку элементы такого списка обычно содержат записи больших размеров. Например, если каждый элемент стека представляет собой запись из 10 слов, то добавление 11-го слова, содержащего указатель, приводит к увеличению общего объема лишь на 10%. Помимо этого во многих машинных языках имеется возможность сжатия указателя до» размера одного слова, что приводит к более экономному использованию пространства. Другим недостатком является дополнительное время, необходимое для работы со списком. Каждое добавление и удаление элемента из стека или очереди предполагает соответствующее удаление или добавление элемента к списку доступных элементов. 189
Преимущество использования связанных списков заключается в том, что все стеки и очереди программы могут иметь доступ к одному и тому же списку доступных элементов. Элементы, не использующиеся одной программой, могут быть использованы другой до тех пор, пока общее число одновременно используемых элементов не больше общего числа доступных элементов. Связанный список как структура данных Связанные списки представляют интерес не только с точки зрения реализации стеков и очередей, но и как самостоятельные структуры данных. Доступ к элементу связанного списка осуществляется путем просмотра списка с его начала. Реализация посредством массива позволяет осуществлять доступ к n-му элементу группы при помощи одной операции, в то время как реализация посредством списка требует выполнения η операций. Для доступа к n-му элементу необходимо перед этим просмотреть первые η — 1 элементов, поскольку связь между адресом ячейки, которую занимает данный элемент, и •его позицией в списке отсутствует. Преимущество использования списка обнаруживается в том случае, когда появляется необходимость размещения элемента внутри группы других элементов. Например, предположим, что необходимо вставить элемент χ между третьим и четвертым элементами массива, имеющего размерность 10 и содержащего в данный момент семь элементов. Элементы с четвертого по седьмой должны быть передвинуты на одну позицию, а затем β освободившуюся позицию с номером 4 помещается новый элемент. Этот процесс показан на рис. 4.2.5а. В этом случае размещение одного элемента приводит к необходимости перемещения четырех других элементов. Если массив содержит 500 яли 1000 элементов, то соответственно должно быть передвинуто большее число элементов. Аналогично при удалении элемента из массива все элементы, расположенные после него, должны быть передвинуты на одну позицию. С другой стороны, если элементы расположены в списке, а ρ есть указатель на данный элемент списка, то размещение нового элемента после элемента node(p) предполагает выделение нового элемента, запись в него информации и установку двух указателей. Объем работы не зависит от размера списка. Это проиллюстрировано на рис. 4.2.56. Пусть insafter(p,x) обозначает операцию вставки элемента χ в список вслед за элементом, адресованным указателем р. Эта операция может быть реализована следующим образом: q = getnode info(q)=x 190
ptrnxt(q)=ptrnxt(p) pfrnxt(p)=q Новый элемент может быть размещен только после адресованного указателем элемента, а не перед ним. Это обусловлена тем, что переход от данного элемента к предшествующему ему возможен только путем просмотра всего списка с начала. Для х\ XI хз ХА XS Х6 ΧΊ Х\ Х2 ХЗ ХА Х5 Х6 XI XI хг хз X ХА XS Х6 ΧΊ Рис. 4.2.5, а. ш х\ XI \χλ Х2 fF Ρ У "г хз Г>Г" ДГ4 +44 Ή*6 -KM -Kl·6 ~Ύ _j- ΧΊ XT null nullt ρ и Рис. 4.2.5, б. размещения какого-либо элемента перед элементом node(p) поле ptrnxt предшествующего ему элемента должно быть из· менено. Новое его значение указывает на новый распределен· ный элемент, а по заданному ρ отыскать предшествующий элемент невозможно. Однако эффект размещения нового элемента перед старым может быть достигнут следующим образом. Но- 191
вый элемент размещается, как и прежде, после старого, а затем содержимое информационных полей старого и нового элементов меняется местами. Аналогичным образом для удаления элемента из линейного списка наличия только значения указателя на этот элемент недостаточно. Обусловлено это тем, что поле ptrnxt предшествующего элемента должно быть изменено таким образом, чтобы оно указывало на следующий элемент, а прямой переход от данного элемента к его предшественнику невозможен. Лучшее, что можно сделать, это удалить элемент, следующий за данным элементом. (Однако можно сохранить содержимое последующего элемента, удалить его, а затем заменить содержимое данного элемента сохраненной информацией. Этим достигается эффект удаления заданного элемента.) Пусть delafter(p,x) обозначает операцию удаления элемента, следующего за элементом node(p), и присвоения его содержимого переменной х. Эта операция может быть реализована следующим образом: q = ptrnxt (p) x=info(q) ptrnxt (ρ) = ptrnxt (q) freenode(q) Освобожденный элемент помещается обратно в список свободных элементов и может быть использован в дальнейших операциях. Примеры операций со списком Проиллюстрируем на простых примерах эти две операции работы со списком, а также операции pop и push. В первом примере из списка 1st удаляются все вхождения в него числа 4. Список просматривается при поиске всех элементов, содержащих в своих полях info число 4. Каждый такой элемент удаляется из списка. Но для удаления элемента из списка необходима информация об элементе, предшествующем ему. По этой причине использованы два указателя — ρ и q. Указатель ρ используется для просмотра списка, a q всегда указывает на элемент, предшествующий р. Для удаления элементов из начала списка в алгоритме используется операция pop, а для удаления элементов из его середины — операция delafte'r: q=null p=lst while p < > null do if info (p) =4 then if q=null then 'удалить первый элемент из списка x=pop(lst) freenode(p) p=lst 192
else 'передвинуть р и удалить элемент, следующий за элементом node(q) p=ptrnxt(p) delafter(q.x) endif else 'продолжить просмотр списка 'продвинуть ρ и q q=p p=ptrnxt(p) endif endwhile Использование двух следующих один за другим указателей является распространенным приемом при работе со списками. Этот же прием используется и в следующем примере. Предположим, что список 1st упорядочен таким образом, что элементы с меньшими значениями предшествуют большим. Требуется разместить в соответствующей позиции этого списка элемент х. Реализующий это алгоритм использует операцию push для добавления элемента к началу списка и операцию insafter для размещения элемента внутри списка: q=nuli ρ-1st while (p< >null) and (x>info(p)) do q=p p=ptrnxt(p) endwhile 'в этой точке должен быть размещен узел, содержащий χ if q=null then 'поместить х в начало списка push(lst,x) else insafter (q,x) endif Это довольно распространенная операция. Мы будем обозначать ее как place (1st, х). Списки в языке Бейсик Как в языке Бейсик можно реализовать линейные списки? Поскольку такой список представляет собой простой набор данных, то само собой напрашивается использование массива элементов. Однако элементы не могут быть упорядочены как элементы массива; каждый элемент должен содержать внутри себя указатель на последующий элемент. Но в языке Бейсик нет возможности ссылаться на элемент с двумя полями (не считая массива из таких элементов), поэтому мы объявим два массива—INFO и PTRNXT — следующим образом: 10 DIM INFO (500) 20 DIM PTRNXT (500) По такой схеме указатель на элемент есть целое число в интервале от 1 до 500. Нулевой указатель (null) представлен 193
INFO PTRNXT 1 2 3 L4 = 4 L2-5 6 7 8 9 10 11 L3= 12 13 14 15 16 Ll = 17 18 19 20 21 22 23 24 25 26 27 26 11 5 1 17 13 19 14 4 31 6 37 3 32 7 15 12 , 18 "δ Ί 10 16 25 1 2 19 13 22 8 3 24 21 0 9 0 0 6 Рис. 4.2.6. Массивы элементов, содержащие четыре связанных списка. значением 0 (целочисленным значением). Введем: обозначение «node(P)», где Ρ есть переменная языка Бейсик, используемая как указатель для представления набора {INFO(P), PTRNXT (Ρ)}. INFO (P) представляет собой информацию, содержащуюся в элементе node(P), a PTRNXT (P) — указатель на элемент, следующий за node(P) (или 0). Поскольку в языке Бейсик нельзя работать с целыми элементами, мы не можем пользоваться в программах обозначением «NODE(P)». Просто в алгоритмах и обсуждениях удобнее ссылаться на node(P). Надо отметить, что, хотя INFO и PTRNXT являются независимыми переменными в языке Бейсик, сохранение их логической взаимосвязи в программах, работающих со связанными списками, возлагается на программиста. Пусть переменная LST представляет собой указатель на список. Предположим, что переменная LST имеет значение 7. Тогда INFO (7) есть первый элемент данных в списке. Предположим, что зна- Тогда INFO (385) есть второй и PTRNXT (385) указыва- чение PTRNXT (7) равно 385. элемент данных в списке ет на третий элемент. Элементы списка могут быть разбросаны по массиву в произвольном порядке. Каждый элемент содержит внутри себя адрес следующего за ним. Поле PTRNXT последнего элемента списка содержит значение 0, тем самым являясь нулевым указателем. Связь между содержимым элемента и указателем на него отсутствует. Указатель Ρ обозначает только элемент, к которому делается ссылка. Информация в этом элементе представлена через INFO(P). На рис. 4.2.6 показана часть массивов INFO и PTRNXT, содержащих четыре связанных списка. Список L1 начинается с node(17) и содержит целые числа 3, 7, 14, 6, 5, 37, 12. Элемен- 194
ты, которые содержат эти числа в своих полях INFO, разбросаны по всему массиву. Соответствующее поле PTRNXT каждого элемента содержит индекс массива, содержащего следующий элемент списка. Последний элемент списка есть node (24), который содержит в своем поле INFO целое число 12 и нулевой указатель (0) в поле PTRNXT, указывая этим, что он является последним элементом данного списка. Аналогично L2 начинается в node (5) и содержит целые числа 17 и 26; L3 начинается в node (12) и содержит целые числа 31, 19 и 32; L4 начинается 8 node(4) и содержит целые числа 1, 18, 13, 11, 4 и 15. Переменные LI, L2, L3 и L4 есть целые числа, представляющие собой внешние указатели на рассмотренные четыре списка. Тот факт, что переменная L2 имеет значение 5, означает, что указываемый ею список начинается в node (5). Изначально все элементы являются незадействованными, поскольку списки еще не были сформированы. Следовательно, все они должны быть помещены в список доступных элементов. Если в качестве указателя для списка доступных элементов используется переменная AVAIL, то список может быть изначально организован следующим образом: 50 AVAIL^l 60 FOR 1 = 1 ТО 499 70 PTRNXT (I) =1+1 80 NEXT I 90 PTRNXT (500) =0 Изначально все 500 элементов расположены в естественном порядке, так что PTRNXT(I) указывает на node(I+l). Следовательно, node(l) является первым элементом в списке доступных элементов, node(2) —вторым и т. д. Элемент node(500) есть последний элемент в списке, поскольку значение PTRNXT (500) равно 0. Помимо общих соображений удобства нет никаких причин для переупорядочивания данной последовательности. С тем же успехом мы могли бы установить PTRNXT (1) в 500, PTRNXT (500) в 2, PTRNXT (2) в 499 и т. д. до тех пор, пока PTRNXT (250) не будет установлен в 251, а PTRNXT (251)—в 0. Важно отметить, что по отношению к самим элементам упорядочивание является внешним атрибутом. При необходимости использования элемента в каком-нибудь списке он должен быть извлечен из списка доступных элементов. Аналогичным образом, если элемент больше не является необходимым, он возвращается в список доступных элементов. Эти две операции реализованы в языке Бейсик подпрограммами getnode и freenode. Подпрограмма getnodeесть функция, удаляющая элемент из списка доступных элементов и возвращающая указатель на этот элемент: 1000 'подпрограмма getnode 1010 'входы: AVAIL, PTRNXT 1020 'выходы: AVAIL, GTNODE 195
1030 'локальные переменные: нет 1040 IF AVAIL=0 THEN PRINT «ПЕРЕПОЛНЕНИЕ»: STOP 1050 GTNODE=AVAIL 1060 AVAIL = PTRNXT (AVAIL) 1070 RETURN 1080 'конец подпрограммы Если при вызове данной программы выходное значение AVAIL равно 0, то это означает, что доступные элементы отсутствуют, т. е. списковые структуры программы заняли все отведенное им пространство. Подпрограмма freenode воспринимает указатель FRNODE на некоторый элемент и возвращает данный элемент в список доступных элементов: 2000 'подпрограмма freenode 2010 'входы: AVAIL, FRNODE 2020 'выходы: AVAIL, PTRNXT 2030 'локальные переменные: нет 2040 PTRNXT (FRNODE) = AVAIL 2050 AVAIL = FRNODE 2060 RETURN 2070 'конец подпрограммы Для оставшихся в этой главе подпрограмм мы будем считать, что переменные INFO, AVAIL и PTRNXT были установлены в основной программе и, следовательно, могут быть использованы в любой подпрограмме. Мы не указываем поэтому эти три переменные в списках внешних входов и выходов наших подпрограмм работы со списками. Примитивные операции над списками получаются непосредственным переводом соответствующих алгоритмов на язык Бейсик. Подпрограмма insafter воспринимает в качестве параметров указатель на элемент PNTR и элемент X (сначала она проверяет, не является ли значение PNTR нулевым, а затем помещает X в элемент, следующий за элементом, указываемым PNTR): 3000 'подпрограмма insafter ЗОЮ 'входы: PNTR, X 3020 'выходы: нет 3030 'локальные переменные: GTNODE, Q 3040 IF PNTR = 0 THEN PRINT «ВСТАВКА ЗАПРЕЩЕНА»: RETURN 3050 GOSUB 1000: 'подпрограмма getnode устанавливает переменную 'GTNODE 3060 Q=GTNODE 3070 INFO(Q)=X 3080 PTRNXT (Q) = PTRNXT (PNTR) 3090 PTRNXT(PNTR)=Q 3100 RETURN 3110 'конец подпрограммы 196
Подпрограмма delafter воспринимает указатель PNTR и удаляет следующий элемент [т. е. элемент, указываемый посредством PTRNXT(PNTR)], сохраняя его содержимое в X: 4000 'подпрограмма delafter 4010 'входы: PNTR 4020 'выходы: X 4030 'локальные переменные: FRNODE, Q 4040 IFPNTR=0THEN PRINT «УДАЛЕНИЕ ЗАПРЕЩЕНО»: RETURN^ 4050 IF PTRNXT(PNTR) =0 THEN PRINT «УДАЛЕНИЕ ЗАПРЕЩЕНО»: RETURN 4060 Q=PTRNXT(PNTR) 4070 X=INFO(Q) 4080 PTRNXT(PNTR) =PTRNXT(Q) 4090 FRNODE=Q 4100 GOSUB 2000: 'подпрограмма freenode принимает переменную 'FRNODE 4110 RETURN 4120 'конец подпрограммы Перед вызовом подпрограммы insafter мы должны убедиться в том, что значение PNTR не равно нулю. Перед вызовом подпрограммы delafter необходимо убедиться в том, что ни PNTR, ни PTRiNXT(PNTR) не равны нулю. Очереди в языке Бейсик, представленные при помощи списков Рассмотрим теперь программы на языке Бейсик, которые работают с очередью, представленной в виде линейного списка, предоставив читателю в качестве упражнений составление программ работы со стеком. Очередь может быть представлена следующим образом: 10 DIMQUEUE(2) 20 FRNT=1 30 REAR = 2 QUEUE (FRNT) и QUEUE (REAR) являются указателями на первый и последний элементы очереди, представленной в виде списка. (Такое представление сходно с альтернативным методом представления очереди, о котором шла речь в конце предыдущего раздела.) В пустой очереди QUEUE (REAR) и QUEUE (FRNT) равны 0, что соответствует нулевому указателю. Подпрограмма empty должна проверять только один из этих указателей, поскольку в непустой очереди ни QUEUE (/REAR), ни QUEUE (FRNT) не могут быть равны нулю. (Поскольку значения FRNT и REAR в процессе выполнения операций с очередью остаются постоянными, мы не будем указывать их в списках входов подпрограмм.) 5000 'подпрограмма empty 5010 'входы: QUEUE 5020 'выходы: EMPTY 197
5030 'локальные переменные: нет 5040 IF QUEUE(FRNT)=0 THEN EMPTY=TRUE ELSE EMPTY=FALSE 5050 RETURN 5060 'конец подпрограммы Подпрограмма постановки элемента в очередь может быть записана следующим образом: 6000 'подпрограмма insert 6010 'входы: QUEUE, X 6020 'выходы: QUEUE 6030 'локальные переменные: GTNODE, Ρ 6040 GOSUB 1000: 'подпрограмма getnode устанавливает переменную 'GTNODE 6050 Ρ=GTNODE 6060 INFO(P)=X 6070 PTRNXT(P)=0 6080 IF QUEUE (REAR) =0 THEN QUEUE (FRNT) = P ELSE PTRNXT (QUEUE (REAR)) = Ρ 6090 QUEUE (REAR) - Ρ 6100 RETURN 6110 'конец подпрограммы Функция remove, удаляющая первый элемент из очереди и возвращающая его значение, может быть записана следующим образом. (Отметим, что мы не можем использовать переменную FRNODE в качестве входной для подпрограммы freenode, поскольку в некоторых версиях языка Бейсик переменные FRNODE и FRNT будут рассматриваться как одна и та же переменная. По этой причине в качестве входной переменной подпрограммы freenode мы воспользуемся переменной ZFRNODE.) 7000 'подпрограмма remove 7010 'входы: QUEUE 7020 'выходы: QUEUE, RMOVE 7030 'локальные переменные: Р, ZFRNODE 7040 GOSUB 5000: 'подпрограмма empty устанавливает переменную 'EMPTY 7050 IF EMPTY=TRUE THEN PRUNT «ВЫБОРКА ИЗ ПУСТОЙ ОЧЕРЕДИ»: STOP 7060 Ρ - QUEUE (FRNT) 7070 RMOVE = INFO (Ρ) 708O QUEUE (FRNT) = PTRNXT (P) 7090 IF QUEUE (FRNT) =0 THEN QUEUE (REAR) = 0 7100 ZFRNODE=P 7110 GOSUB 2000: 'подпрограмма freenode принимает переменную 'ZFRNODE 7120 RETURN 7130 'конец подпрограммы Примеры операций со списками в языке Бейсик Рассмотрим более сложные операции со списками, реализованные в языке Бейсик. Мы определили операцию place (1st, х), где 1st указывает на упорядоченный линейный список, а х есть 198
элемент, помещаемый в соответствующую позицию этого списка. Обычно выполняющий эту операцию алгоритм легко переводится на язык Бейсик. Однако этот алгоритм содержит строку while (р О null) and (x > info(p)) do Если Р равно 0 (что при используемой нами реализации списков в языке Бейсик является нулевым указателем), то значение INFO(P) неопределенно (в тех версиях языка Бейсик, у которых массивы начинаются не с нулевого индекса) или не было установлено явно (в некоторых версиях языка Бейсик), поэтому ссылки к INFO(O) следует избегать. Следовательно, мы должны избежать вычисления второго условия в операторе while для того случая, когда Ρ равно 0. Мы предполагаем, что мы уже выполнили над стеком операцию push, подпрограмма для которой начинается с оператора с номером 9000, которая работает с указателем списка STACK и элементом X. Программа, реализующая операцию place, может быть записана следующим образом: 8000 'подпрограмма place 8010 'входы: LST, X 8020 'выходы: LST 8030 'локальные переменные: Р, Q 8040 P=LST 8050 Q=0 8060 'поисковая часть подпрограммы 8070 IF P=0 THEN GOTO 8130 8080 IF X< = INFO(P) THEN GOTO 8130 8090 'иначе продвинуть указатели Р и Q 8100 Q=P 8110 P = PTRNXT(P) 8120 GOTO 8070 8130 'размещение элемента 8140 'если Q=0, то подпрограмма push помещает X в начало списка, 'иначе подпрограмма insafter помещает X вслед за node(Q) 8150 IF Q-0 THEN STACK=LST: GOSUB 9000: LST= STACK ELSE PNTR = Q: GOSUB 3000 8160 RETURN 8170 'конец подпрограммы Списки, состоящие не только из целых чисел Разумеется, элемент списка может содержать не только целые числа. Например, для реализации стека из символьных строк необходимы элементы, которые могут содержать в своих полях INFO символьные строки. Такие элементы могут быть объявлены следующим образом: Ю DEFSTRI 20 DIM INFO (500) 30 DIM PTRNXT(500) 199
В некоторых реализациях могут понадобиться элементы, содержащие несколько единиц информации. Например, каждый элемент списка студентов может содержать следующую информацию: фамилия студента, его регистрационный номер в колледже, адрес, номер курса и т. д. Элементы такого списка могут быть объявлены следующим образом: 10 DEFSTR A, I, M, S 20 DIM STUDENT (500) 30 DIM ID (500) 40 DIM ADDRESS (500) 50 DIMNMBR(500) 60 DIM PTRNXT(500) Массивы STUDENT, ID, ADDRESS и NMBR составляют части «info» элементов списка. Эти массивы вместе с массивом PTRNXT составляют полный набор элементов. Для работы со списками, состоящими из элементов различных типов, необходим свой набор программ. Элементы заголовка Иногда желательно помещать в начале списка дополнительный элемент. Такой элемент называется заголовком списка. Часть INFO этого элемента может не использоваться, как это показано на рис. 4.2.7, а. Но, как правило, часть INFO данного элемента используется для хранения информации обо всем списке. Например, на рис. 4.2.7, б приведен список, в котором часть INFO заголовка содержит число элементов в списке (не считая самого заголовка). В такой структуре данных для добавления или удаления элемента из списка требуется выполнить большее число операций, поскольку необходимо также корректировать и счетчик элементов. Однако такая структура позволяет сразу узнать число элементов в списке по его заголовку, исключая необходимость просмотра всего списка. Другой пример применения заголовков списка заключается в следующем. Предположим, что завод собирает машины из набора более мелких узлов. Машина собирается из некоторого числа конкретных деталей (с номерами В841, К321, А087, J492, G593). Этот набор может быть представлен в виде списка, подобного списку на рис. 4.2.7, в, где каждый элемент списка представляет отдельный компонент, а заголовок списка представляет весь иабор. Пустой список представляется не пустым указателем, а списком, состоящим из одного заголовка, как это показано на рис. 4.2.7, г. Разумеется, с учетом наличия заголовка большинство программ усложнится, но некоторые упростятся, например программа insert, поскольку внешний указатель списка никогда не 200
1st 4 Л 746 87 Ι £841 42 К321 65 4087 21 null /492 C593 null 1st null 1st j v- „ 2 3 7 my// Рис. 4.2.7. Списки с элементами заголовка. принимает нулевого значения. Мы оставляем написание этих программ,в качестве упражнения для читателя. Программы insafte'r и delafter изменять не требуется. В действительности вместо программ pop и push можно использовать программы insafte'r и delafter, поскольку первый элемент в таком списке появляется после заголовка и не является тем самым первым элементом списка. Если часть info элемента содержит указатель (что возможно при реализации в языке Бейсик списка целых чисел, где указатель представлен целым числом), то это дает дополнительные возможности по использованию заголовка списка. Например, часть info заголовка списка может содержать указатель на последний элемент списка, как это показано на рис. 4.2.7, д. При такой реализации представление очереди упрощается. До сих пор при представлении очереди списком требовались два указателя — queue (frnt) и queue (rear). Теперь можно обойтись одним внешним указателем q, указывающим на заголовок списка. Тогда ptrnxt(q) будет указывать на начало очереди, a info (q) — на ее конец. Другая возможность применения части INFO в заголовке списка предполагает хранение там указателя на «текущий» 201
элемент списка в процессе просмотра последнего. Это исключает необходимость во внешнем указателе в процессе просмотра списка. Упражнения 1. Напишите набор программ, работающих с несколькими очередями и стеками, находящимися в одном массиве. 2. Какие преимущества и недостатки дает представление группы элементов в массиве по сравнению с линейным связанным списком? 3. Приведите четыре способа реализации очереди очередей при помощи списка и очередей, реализованных на базе массива. Напишите для каждой реализации следующие программы: remvq удаляет очередь из очереди очередей qq и присваивает ее q insrtq помещает очередь q в qq remvonq удаляет элемент из первой очереди в qq и присваивает его χ instronq помещает элемент χ в первую очередь qq Определите аналогичные операции для стека из стеков и стека из очередей, а также для очереди из стеков. 4. Напишите алгоритм и программу на языке Бейсик, выполняющую следующие операции: (а) Добавление элемента к концу списка. (б) Сцепление двух списков. (в) Освобождение всех элементов в списке. (г) Инвертирование списка, при котором первый элемент становится последним и т. д. !д) Удаление последнего элемента из списка, е) Удаление η-го элемента из списка. (ж) Объединение двух упорядоченных списков в один упорядоченный список. (з) Создание списка, представляющего собой объединение (по операции ИЛИ) элементов двух списков. (и) Создание списка, содержащего элементы, общие для двух других списков, (к) Вставка элемента после η-го элемента списка. (л) Удаление из списка каждого второго элемента, (м) Размещение элементов списка в возрастающем порядке, (н) Вычисление суммы целочисленных значений элементов списка, (о) Вычисление числа элементов в списке. (п) Перемещение элемента node(p) на η позиций вперед по списку, (р) Создание копии списка. 5. Напишите алгоритм и программу на языке Бейсик, выполняющую каждую из операций упражнения 4 над группой элементов, занимающих непрерывное пространство в массиве. 6. Напишите на языке Бейсик программу, меняющую местами n-й и т-й элементы списка. 7. Напишите программу inssub, помещающую элементы списка 12, начиная с элемента i2, общим числом len в список 11, начиная с позиции Π в нем. Ни один из элементов списка 11 не удаляется и не заменяется. Если П> > length (11) + 1 [где length (11) означает число элементов в списке 11], или если i2+len — l>length(12), или если il<l, или если i2<l, то напечатайте сообщение об ошибке. Список 12 изменяться не должен. 8. Напишите на языке Бейсик программу с именем search, воспринимающую указатель L на список целых чисел и целое число X и возвращающую указатель на элемент X, если он существует, а в противном случае нулевой указатель. Напишите другую программу — srchinsrt, добавляющую 202
Χ κ L, если он не найден, и всегда возвращающую указатель на элемент, содержащий X. 9. Напишите на языке Бейсик программу, считывающую группу строк из операторов DATA, каждый из которых содержит одно слово. Напечатайте каждое вводимое слово и число его появлений. 10. (а) Рассмотрим фабрику, изготавливающую изделия из более мелких узлов. Назовем элементарной деталью такой узел, который не является композицией более мелких. Напишите программу, которая считывает набор строк из операторов DATA, содержащих четырехсимвольные номера деталей. Первый такой номер в строке обозначает неэлементарную деталь, а оставшиеся числа обозначают детали, из которых состоит данная неэлементарная часть. Эти составные детали могут быть элементарными, но могут также состоять из других частей (в этом случае их номера появляются первым номером в какой-либо строке с оператором DATA). Программа создает список с элементом заголовка для каждой неэлементарной детали. Заголовок содержит имя неэлементарной детали и указатель на список элементов, описывающих составляющие части так, как это было рассмотрено в конце данного раздела. Указатели на заголовки списков последовательно записываются в массив PARTS. Затем программа печатает все неэлементарные детали. (б) Напишите программу, которая считывает массив PARTS и набор списков, созданных предыдущей задачей из части (а). Эта программа печатает для каждой неэлементарной детали список всех элементарных деталей, из которых она состоит. (Например, если часть А содержит части В, С и D и при этом В содержит элементарные части Ε и F, С является элементарной деталью, a D содержит G, которая в свою очередь содержит элементарные части Η и I, и при этом D также содержит элементарную часть J, то тогда А содержит элементарные части С, Е, F, Η, Ι и J.) (в) Покажите, как часть (б) может быть упрощена, если каждый узел включает в себя дополнительное поле указателя. Объясните выгодность использования дополнительного поля для данной задачи и перепишите программу для частей (а) и (б) с использованием этого поля. 4.3. ПРИМЕР: МОДЕЛИРОВАНИЕ С ПРИМЕНЕНИЕМ СВЯЗАННЫХ СПИСКОВ Наиболее удобно использовать очереди и связанные списки в задачах моделирования. Моделирующая программа имитирует ситуацию из реального мира, позволяя получить о последней различную информацию. Каждый объект и каждое действие из реальной ситуации имеют свои аналоги в программе. Если моделирование проводится достаточно точно, т. е. если программа успешно отражает реальную ситуацию, то полученные с ее помощью результаты будут отражать результат действий из реальной ситуации. Таким образом, имеется возможность проанализировать ситуацию из реального мира без фактического наблюдения за ней. Рассмотрим пример. Пусть имеется банк с четырьмя кассирами. Посетитель заходит в банк в некоторое время tl, желая осуществить какую-то финансовую операцию с одним из кассиров. Эта операция может потребовать для своего выполнения некоторого времени t2. Если кассир свободен, то он может немедленно начать обслуживать посетителя, и последний поки- 203
нет банк сразу же после завершения операции, т. е. во время tl+t2. Общее время, проведенное посетителем в банке, в точности равно продолжительности выполнения финансовой операции t2. Однако может случиться так, что все кассиры окажутся заняты обслуживанием посетителей, пришедших в банк ранее. В этом случае к окошку каждого кассира образуется очередь. Очередь к конкретному кассиру может состоять из одного человека, обслуживаемого в данный момент, или же быть довольно длинной. Посетитель становится в конец самой короткой очереди и ждет завершения обслуживания посетителей, стоящих впереди него. После этого он может приступить к выполнению своих дел. Посетитель покидает банк через t2 единиц времени после достижения им окна кассира. В этом случае время, проведенное им в банке, есть t2 плюс время, проведенное в очереди. Нам хотелось бы узнать, каково среднее время, проводимое посетителем в банке. Одним из способов является опрос посетителей при входе в банк, запись времени их прибытия и убытия, вычитание первого из второго и вычисление среднего для всех посетителей. Вместо этого мы напишем программу, имитирующую действия посетителей. Каждая часть ситуации из реального мира имеет свой аналог в программе. Каждая строка с оператором DATA соответствует одному посетителю. Действия прибывшего в банк посетителя имитируются считываемой строкой с оператором DATA. По прибытию посетителя известны два факта — время прибытия и продолжительность выполнения банковской операции (предполагается, что прибывший посетитель знает, с какой целью он пришел). Каждый оператор DATA содержит два числа: время своего прибытия (в минутах с момента открытия банка) и время, необходимое для выполнения операции (также в минутах). Строки с операторами DATA упорядочены по возрастанию времени прибытия. Поток входных данных завершается строкой, в которой время прибытия и время выполнения операции равны нулю. Четыре очереди в банке представлены четырьмя очередями в программе. Каждый элемент очереди имитирует посетителя, стоящего в очереди, а элемент в начале очереди имитирует посетителя, обслуживаемого в данный момент кассиром. Что вызывает изменение состояния очереди? Либо в банк заходит новый посетитель и в этом случае к одной из очередей добавляется дополнительный элемент, либо посетитель, стоящий первым в одной из очередей, завершает соответствующие действия и покидает очередь. Следовательно, возможны четыре действия, вызывающие изменение состояния очереди (вход в банк нового посетителя и четыре возможные ситуации выхода посетителя из очереди). Назовем каждое из этих действий событием. 204
Процесс моделирования сводится к обнаружению очередного события и изменению состояния очередей, соответствующих очередям в банке, согласно с произошедшим событием. Для слежения за событиями в программе используется список событий. Этот список содержит максимум пять элементов, каждый из которых соответствует грядущему появлению одного из возможных пяти событий. Следовательно, один элемент соответствует вновь прибывшему посетителю, а четыре остальных — находящимся в началах очередей четырем посетителям, которые закончили свои дела и покидают банк. Разумеется, может оказаться так, что одна или более очередей в банке могут оказаться пустыми или что банк уже закрывается и новые посетители не прибывают. В таких случаях список событий может содержать менее пяти элементов. Элемент, отражающий прибытие посетителя, называется элементом прибытия, а элемент, отражающий уход посетителя из банка, — элементом отправления. В процессе моделирования в каждый момент времени необходимо знать, какое событие произойдет следующим. По этой причине список событий упорядочен по возрастанию времени таким образом, что первый элемент списка событий отражает событие, происходящее первым. Событие, происходящее первым, отражает прибытие первого посетителя. Следовательно, список событий инициализируется первым считываемым оператором DATA. При этом элемент прибытия, отражающий прибытие первого посетителя, помещается в список событий. Разумеется, изначально все очереди пусты. Процесс моделирования происходит следующим образом: первый элемент удаляется из списка событий, а в очередях производятся необходимые изменения. Как мы скоро увидим, эти изменения приводят к появлению дополнительных событий, помещаемых в список событий. Процесс удаления первого элемента из списка событий и внесение вызванных этим изменений повторяются до тех пор, пока список событий не оказывается пустым. При удалении из списка событий элемента прибытия элемент, отражающий прибывшего посетителя, помещается в наиболее короткую очередь из четырех имеющихся. Если этот посетитель является единственным в очереди, то элемент, отражающий прибытие этого посетителя, также помещается в список событий, поскольку этот посетитель находится в начале очереди. В это же время происходит считывание следующего оператора DATA, и в список событий помещается время прибытия следующего посетителя. Этот элемент будет всегда элементом прибытия (до тех пор пока не исчерпаются входные данные, что означает отсутствие новых посетителей), поскольку после удаления одного элемента прибытия из списка событий в этот список помещается другой элемент прибытия. 2#5
После удаления элемента прибытия из списка событий из начала одной из четырех очередей удаляется элемент, отражающий покидающего банк посетителя. В этот момент происходит подсчет времени, затраченного посетителем в банке. Это время прибавляется затем к суммарному времени. В конце моделирования суммарное время делится на число посетителей, в результате чего вычисляется среднее время, проведенное посетителем в банке. После того как из начала очереди был удален элемент, соответствующий одному посетителю, кассир начинает обслуживать следующего посетителя (если он имеется), а к списку событий добавляется элемент отправления для этого посетителя. Этот процесс продолжается до тех пор, пока список событий не станет пустым, после чего происходят подсчет и печать среднего времени. Отметим, что сам список событий не соответствует никакой части ситуации из реального мира. Он используется для управления всем процессом. Подобного рода моделирование, при котором изменения в имитируемой ситуации производятся в ответ на возникновение одного или нескольких событий, называется моделированием, управляемым событием. Рассмотрим теперь структуры данных, необходимые программе. Элементы очередей отражают посетителей банка и, следовательно, помимо поля PTRNXT должны содержать поля для времени прибытия и времени совершения банковской операции. Элементы списка событий отражают события и помимо поля PTRNXT должны содержать время возникновения события, тип события и другую, связанную с этим событием информацию. Может показаться, что для представления таких структур необходимы два различных пула элементов. Это потребует две программы — getnode и freenode, а также два набора программ управления списком. Для того чтобы избежать дублирующего набора программ, мы постараемся обойтись одним типом элементов, одинаковым для событий и для посетителей. Объявим пул элементов следующим образом: 20 DIM INFO (500,3) 30 TIME=1 40 ELAPSEDTIME = 2 50 TYPE-3 60 DIM PTRNXT(500) Для элемента с посетителем I поле INFO(I,TIME) содержит врсшя прибытия, а поле INFO(I,ELAPSEDTIME)—продолжительность выполнения операции. Для элемента посетителя поле INFO(I,TYPE) не используется. Поле PTRNXT используется как указатель для связи элементов в очереди между собой. Для элемента, соответствующего событию I, поле INFO(I,TIME) используется для хранения времени возникновения события. Поле INFO(I,ELAPSEDTIME) используется для хранения времени продолжительности банковской операции с прибывшим посе- 206
тителем и не используется для элемента отправления. Поле INFO(I,TYPE) содержит целое число от 0 до 4 в зависимости от того, отражает ли событие факт прибытия посетителя fINFO(I,TYPE)=0] или отправление из первой, второй, третьей или четвертой очереди [INFO(IJYPE) = 1, 2, 3 или 4]. Поле PTRNXT содержит указатель, связывающий элементы списка событий между собой. (Отметим, что мы помещаем время прибытия, продолжительность и тип в один массив INFO, поскольку все эти величины представляются целыми числами, но при этом массив целых чисел PTRNXT представляет собой отдельный массив. Причина этого заключается в том, что мы хотим провести явное разделение между информационной частью элемента и полем его указателя. Если бы информационная часть содержала поля с различающимися по типам данными, то нам потребовалось бы разбить массив INFO на несколько отдельных массивов, как это было сделано в примере из предыдущего раздела, в котором элементы содержали информацию о студентах.) Определяемые массивами Q и NUM четыре очереди вводятся следующими операторами: 80 DIMQ(4,2) 90 FRNT-1 100 REAR=2 ПО DIM NUM(4) Элементы Q(I,FRNT) и Q(I,REAR) содержат указатели начала и конца 1-й очереди, a NUM(I) —число посетителей в 1-й очереди. Дополнительная переменная EVLST указывает на начало списка событий, а переменная TTLTIME используется для хранения суммарного времени, затраченного посетителями. Третья переменная — COUNT — используется для хранения числа посетителей, обслуженных банком. Вспомогательный массив AUXINFO используется для хранения промежуточной информации. Сначала программа объявляет все перечисленные выше глобальные переменные, инициализирует все очереди и списки и циклически удаляет очередной элемент из списка событий, имитируя этим реальную ситуацию. Так продолжается до тех пор, пока список событий не станет пуст. Программа вызывает подпрограмму popaux, удаляющую первый элемент из списка событий и помещающую информацию из него в AUXINFO. Эти программы эквивалентны рассмотренным ранее подпрограммам place и pop, за тем исключением, что они ссылаются к массиву AUXINFO, а не к переменным X и POPS. Основная программа также обращается к подпрограммам arrive и depart, отражающим изменения в списке событий и очередях, произошедшие вследствие прибытия или отправления посетителя. Подпрограмма arrive отражает факт прибытия посетителя в момент времени ΑΤΙΜΕ с продолжительностью DUR 207
пребывания его в банке. Подпрограмма depart отражает факт ухода посетителя из очереди QINDX в момент времени DTIME. Ниже приводятся тексты этих программ: 10 'программа bank 20 DIM INFO(500,3) 'информационная часть элемента списка 30 Т1МЕ=1 40 ELAPSEDTIME«2 50 TYPE=3 60 DIM PTRNXT(500): 'адрес следующего элемента списка 70 DIM AUXINFO (3): 'вспомогательный массив для временного 'хранения значений переменных, являющих,- 'ся входными для подпрограмм insafter, 'insert, placeaux, push и выходными для 'подпрограмм popaux и remove 80 DIM Q(4,2): 'указатели на очереди к кассирам 90 FRNT-1 100 REAR=2 110 DIM NUM(4) 'число элементов в очередях к кассирам 120 DIM QUEUE (2): 'используется как входной и выходной па- 'раметр программ insert и remove 130 'инициализация переменных и списков 140 TRUE=1 150 FALSE=0 160 EVLST=0: 'указатель на список событий 170 COUNT=0: 'число посетителей 180 TTLTIME=0: 'общее время, затраченное всеми посетителями 190 'инициализация списка доступных элементов 200 AVAIL-1 210 FOR 1 = 1 ТО 499 220 PTRNXT(I)=I+1 230 NEXT I 240 PTRNXT(500)=0 250 'инициализация очередей 260 FOR 1 = 1 ТО 4 270 Q(I,FRNT)=0 280 Q(I,REAR)=0 290 NUM(I)=0 300 NEXT I 310 'инициализация списка событий первым посетителем 320 READ AUXINFO(TIME), AUXINFO(ELAPSEDTIME) 330 AUXINFO(TYPE)=0 340 LST=EVLST 350 GOSUB 8000: 'подпрограмма placeaux может переустано- 'вить переменную LST 360 EVLST=LST 370 'начало моделирования, управляемого событиями 380 IF EVLST=0 THEN GOTO 540 390 STACK = EVLST 400 GOSUB 4000: 'подпрограмма popaux переустанавливав> 'переменные STACK и AUXINFO 410 EVLST= STACK 420 'проверить, относится ли следующее событие к прибытию или 'к отправлению 430 IF AUXINFO (TYPE) >0 THEN GOTO 490 440 'прибытие 450 ATIME=AUXINFO (TIME) 460 DUR=AUXINFO(ELAPSEDTIME) 208
470 GOSUB 9000: 'подпрограмма arrive воспринимает переменные ΑΤΙΜΕ и OUR н переустанавливает переменные EVLST, NUM и Q 480 GOTO 3β0 490 'отправление 500 QINDX=AUXINFO(TYPE) 510 DTIME=AUXINFO(TIME) 520 GOSUB 10000: 'подпрограмма depart принимает перемен- 'ные QINDX и DTIME и переустанавливает 'переменные EVLST, NUM, Q, COUNT и» 'TTLTIME 530 GOTO 380 540 PRINT «СРЕДНЕЕ ВРЕМЯ РАВНО»; TTLTIME/COUNT 550 END 600 DATA 610 DATA ...,.., 700 DATA 0, 0 1000 'подпрограмма getnode 2000 'подпрограмма freenode 3000 'подпрограмма insafter 4000 'подпрограмма popaux 5000 'подпрограмма empty (для стекм) 6000 'подпрограмма insert 7000 'подпрограмма remove 8000 'подпрограмма placeaiix 9000 'подпрограмма arrive 10000 'подпрограмма depart 11000 'подпрограмма push 12000 'подпрограмма empty (для очереди) Подпрограмма arrive модифицирует очереди и список событий, отражая новое прибытие в момент времени ΑΤΙΜΕ и операцию с продолжительностью DUR. Она помещает элемент с новым посетителем в конец самой короткой очереди, вызывая для этого подпрограмму insert, которая должна быть соответствующим образом модифицирована, для того чтобы иметь возможность работать с описанными здесь типами элементов. Подпрограмма arrive затем должна увеличить поле NUM этой очереди на 1. Если посетитель в очереди единственный, то элемент, отражающий его отправление, добавляется к списку событий посредством обращения к подпрограмме placeaux. Затем считываете следующий элемент данных (если таковой имеется), а элемент, отражающий прибытие, помещается в список собы- 209
тий и заменяет только что «обслуженное» прибытие. Если дан- *ные на входе отсутствуют (что отмечается появлением двух ^нулей), то подпрограмма arrive не добавляет нового элемента си основная программа обрабатывает оставшиеся элементы отправления списка событий: 9000 'подпрограмма arrive 9010 'входы: ΑΤΙΜΕ, DUR, EVLST, NUM. Q 9020 'выходы: EVLST, NUM, Q 9030 'локальные переменные: I, J, SMALL 9040 'поиск кратчайшей очереди 9050 J=l 9060 SMALL=NUM (1) 9070 FOR 1=2 TO 4 9080 IF NUM(I) <SMALL THEN SMALL=NUM(I): J~I 9090 NEXT I 9100 'Очередь J является кратчайшей. Размещение нового элемента, от- 9110 'ражающего прибытие очередного посетителя 9120 AUXINFO (TIME) - ΑΤΙΜΕ 9130 AUXINFO (ELAPSEDTIME)-DUR 9140 AUXINFO (TYPE) = J 9150 QUEUE (FRNT) - Q (J.FRNT) 9100 QUEUE (REAR) - Q (J,REAR) 9170 GOSUB 6000: 'подпрограмма insert модифицирует массив QUEUE 9180 Q(J,FRNT)= QUEUE (FRNT) 9190 Q (J,REAR) - QUEUE (REAR) 9200 NUM (J)-NUM (J)+ 1 '9210 'Проверить, единственный ли это элемент в очереди. Если это так, 9220 'то в список событий должен быть помещен элемент отправления. '9230 IF NUM(J) < >1 THEN GOTO 9290 9240 'иначе выполняются операторы 9250—9280 9250 AUXINFO (TIME) - ATIME+DUR 92Θ0 LST=EVLST 9270 GOSUB 8000: 'подпрограмма placeaux может переустановить пере- 9280 EVLST=LST 'менную LST 9290 'Считать новую строку с данными о прибытии. Поместить элемент 'прибытия в список событий. 9300 READ AUXINFO (TIME), AUXINFO (ELAPSEDTIME) 9310 IF AUXINFO(TIME) =0 AND AUXINFO (ELAPSEDTIME) =0 THEN RETURN: 'строка нулевых данных, закрывающая входной набор 9320 AUXINFO (TYPE) =0 9330 LST=EVLST 9340 GOSUB 8000: 'подпрограмма placeaux 9350 EVLST^LST 9360 RETURN 9370 'конец подпрограммы Подпрограмма depart модифицирует очередь QINDX и список событий, отражая отправление первого посетителя из очереди в момент времени DTIME. Посетитель удаляется из очереди посредством обращения к подпрограмме remove, которая должна быть модифицирована, с тем чтобы работать с типом элементов, использованным в данном примере. Затем подпрограмма «depart должна увеличить поле NUM на 1. Элемент отправления для следующего посетителя из очереди (если он имеется) заме- 210
няет элемент отправления, который был только что удален из списка событий: 10000 'подпрограмма depart 10010 'входы: COUNT, DTIME, EVLST, NUM. Q, QINDX, TTLTIME 10020 'выходы: COUNT, EVLST, NUM, Q, TTLTIME 10030 'локальные переменные: Р 10040 'удалить элемент из очереди и собрать статистику 10050 QUEUE (FRNT) «Q (QINDX, FRNT) 10060 QUEUE (REAR) = Q (QINDX, REAR) 10070 GOSUB 7000: 'подпрограмма remove модифицирует массив QUEUE 1008O Q (QINDX, FRNT) = QUEUE (FRNT) 10090 Q(QINDX, REAR) =QUEUE(REAR) 10100 NUM(QINDX)=NUM(QINDX)-1 10110 TTLTIME=TTLTIME+ (DTIME- AUXINFO (TIME)) 10120 COUNT=COUNT+l 10130 'если в очереди отсутствуют посетители, то элемент отправления? 10140 'следующего посетителя помещается в список событий после вы- 10150 'числения времени его отправления 10160 IF NUM(QINDX) =0 THEN RETURN 10170 P=Q (QINDX, FRNT) 10180 AUXINFO(TIME)=DTIME+INFO(P, ELAPSEDTIME) 10190 AUXINFO (TYPE) = QINDX 10200 LST= EVLST 10210 GOSUB 8000: 'подпрограмма placeaux может переустановить переменную LST 10220 EVLST=LST 10230 RETURN 10240 'конец подпрограммы Моделирующие программы широко используют списковые структуры. Читателю рекомендуется использовать для задач моделирования язык Бейсик, а также специализированные языки моделирования. Упражнения 1. В рассмотренной программе, моделирующей работу банка, элемент- отправления в списке событий представляет собой того же самого посетителя, что и первый элемент в очереди посетителей. Можно ли использовать для обслуживаемого в данный момент посетителя только один элемент? Перепишите программу в тексте таким образом, чтобы при этом использовался только один такой элемент. Имеет ли какое-нибудь преимущества использование двух элементов? 2. Приведенная в тексте программа работает с типом элементов, одинаковых как для элементов типа «посетитель», так и для элементов типа «событие». Перепишите программу так, чтобы в ней использовались различные типы элементов. Приводит ли это к экономии памяти? 3. Исправьте приведенную в тексте программу так, чтобы она определяла среднюю длину четырех очередей. 4. Стандартным отклонением группы из η чисел называется величина τΣ(Χί_πι)2· 211
уде х есть отдельные числа, am — их усредненное значение. Модифицируйте программу, моделирующую работу банка, таким образом, чтобы она вычисляла стандартное отклонение для времени, проводимого посетителем в бан- же. Напишите еще программу, моделирующую одну очередь ко всем четырем акассирам, причем посетитель, находящийся в начале очереди, направляется к первому освободившемуся кассиру. Сравните средние значения и стандартные отклонения для обоих методов. 5. Модифицируйте программу, моделирующую работу банка, таким образом, чтобы в том случае, если длина одной очереди превосходит длину другой более чем в два раза, то посетитель, стоящий последним в более длинной очереди, переходил бы в более короткую. 6. Напишите на языке Бейсик программу, моделирующую вычислительную систему с несколькими пользователями, следующим образом. Каждый ■пользователь имеет свой уникальный идентификационный номер ID и желает выполнить на ЭВМ ряд операций. В любой момент времени ЭВМ может выполнять одновременно только одну операцию. Каждая входная строка соответствует одному пользователю и содержит его ID, за которым следует «время начала работы, а за ним — набор целых чисел, представляющих продолжительность каждой из выполняемых пользователем операций. Входные данные упорядочены по возрастанию времени начала работы. Все времена заданы в секундах. Будем считать, что пользователь не выдает запрос на проведение следующей операции до тех пор, пока не закончится предыдущая, а ЭВМ выполняет операции по принципу «первый поступивший обслуживается первым». Программа должна имитировать работу системы и печатать сообщения, содержащие ID пользователя и время начала и конца проведенной операции. В конце процесса моделирования программа должна печатать среднее время ожидания для каждой операции. (Временем ожидания называется разность между тем временем, когда был выдан запрос на зыполнение операции, и началом ее выполнения.) 7. Многие моделирующие системы имитируют события, задаваемые не входными данными, а некоторым вероятностным распределением. Поясним это на примерах. В большинстве ЭВМ имеется функция, генерирующая случайные числа, например, RND(X). (Название и параметры функции могут изменяться в зависимости от ЭВМ; RND взято только в качестве примера.) X устанавливается в некоторое значение от 0 до 1, называемое начальным. Оператор X=RND(X) переустанавливает в переменную X случайное действительное число, равномерно распределенное в интервале от 0 до 1. Под этим мы понимаем, что при выборе любых двух интервалов равной длины «а отрезке [0, 1] и достаточно большом числе выполнений данного оператора число попаданий значений переменной X в один интервал будет близко к числу попаданий в другой. Вероятность попадания значения X в интервал длиной 1< —1 равно 1. Уточните имя такой функции, имеющейся на вашей ЭВМ, и убедитесь в том, что сказанное выше верно. Располагая генератором случайных чисел RND, рассмотрим следующие операторы: 100 X=RND(X) ПО Y=(B-A)*X+A (а) Покажите, что для двух интервалов одинаковой длины, расположенных внутри интервала [А, В], и достаточно большом числе повторений приведенных выше операторов соответствующие значения Υ также будут попадать в каждый из двух интервалов. Переменная Υ называется переменной стандартного равномерного распределения. Чему соответствует среднее для значений Υ в терминах А и В? (б) Перепишите приведенную в тексте программу, имитирующую работу банка, предполагая, что длительность банковской операции есть величина, равномерно распределенная в интервале от 0 до 15. Каждая входная строка содержит только информацию о времени прибытия посетителя. После считывания входной строки сгенерируйте время, затрачиваемое данным посетителем, вычисляя для этого следующее значение описанным выше методом. 8. Значения Υ, генерируемые последовательностью приводимых ниже 212
операторов, называются нормально распределенными со средним значением Μ и стандартным отклонением S. (В действительности они лишь приближаются к истинному нормальному распределению, однако это приближение достаточно точно.) 10 DEFDBL М, S, Υ, Χ 20 DEFINT I 30, DIM X(15) 40 'здесь расположены операторы, устанавливающие начальные значе- 50 'ния S, Μ и массива X 60 SUM=0 70 FOR 1=1 ТО 15 80 X(I)=RND(X(I)) 90 SUM=SUM+X(I) 100 NEXT I 110 Y=S*(SUM-7.5)/SQR(1.25)+M 120 'здесь располагаются операторы, использующие значение Υ 130 IF условие THEN GOTO 60 140 END (а) Убедитесь в том, что среднее для значений Υ (среднее значение распределения) равно М, а стандартное отклонение (см. упражнение 4) равно S. (б) Некоторая фабрика выпускает изделия по следующей технологии: изделия собираются и полируются. Время сборки равномерно распределено β интервале [100,300] секунд, а время полировки равномерно распределено со средним значением 20 с и стандартным отклонением, равным 7 с (однако значения, меньшие 5 с, отбрасываются). После сборки очередного изделия должна вступить в работу полировочная машина, а рабочий не может собирать следующее изделие до тех пор, пока не будет отполировано только что собранное. Всего имеются 10 рабочих и только одна полировочная машина. Если машина занята, то рабочие, закончившие сборку, должны ждать ее освобождения. Вычислите среднее время ожидания для каждого изделия, создав программу, имитирующую работу фабрики. Проделайте то же самое, полагая, что имеется две (три) полировочные машины. 9. (а) Фирма ΧΥΖ расширилась. Помимо продажи высококачественных бытовых инструментов и приспособлений она теперь продает также головоломки и игры. Эта фирма по-прежнему продает товары с 20%-ной надбавкой, но для повышения заинтересованности покупателей в новых товарах, игры и головоломки продаются только с 15%-ной надбавкой. Если объем некоторого товара находится ниже некоторого числа (называемого критическим числом), то фирма заказывает дополнительное количество данного продукта (называемого критическим объемом). После заказа данного товара требуется некоторое число дней (называемое критическим периодом), необходимое для доставки товаров на склад. Однако если покупатели затребовали объем, превосходящий критический, то заказывается объем, равный критическому, плюс объем, запрошенный покупателями. Если другие покупатели дополнительно запрашивают товар после того, как заказ на его пополнение на складе уже был выдан, то фирмой выдается новый заказ на данный товар. Количество товара, указываемое в дополнительном заказе, равно критическому объему плюс общий запрошенный объем, минус уже заказанный объем. Напишите программу, считывающую критическое число, критический объем, критический период и начальную фабричную цену для каждого из трех видов товара. Изначально предположите, что в первый день был заказан критический объем каждого из товаров. Затем считайте группу из двух типов коммерческих операций: операцию, проводимую фирмой с покупателем, начинающуюся с символа «С» и содержащую его имя и три числа, представляющие объем каждого из товаров, которые хочет купить покупатель; закупочную операцию, проводимую фирмой с фабрикой, начинающуюся 213
с символа «Ρ» и содержащую три числа, соответствующих новым фабричным денам для каждого из товаров, продаваемого фирмой. Каждая запись также содержит календарную дату. Записи упорядочены по возрастанию календарных дат. Если на складе имеется некоторый товар, на который были установлены различные цены, то при его продаже используется стратегия «последний полученный первым продается» (т. е. первым продается товар с более высокой ценой). Выходные данные программы представляют собой серии сообщений,, упорядоченные по возрастанию календарной даты. Первое сообщение содержит информацию о том, какое количество каждого товара и по каким ценам было заказано в первый день. (Стоимость заказа определяется ценой, установленной в день его выдачи, а не в день фактической доставки товаров на склад.) Сообщения печатаются по мере выдачи заказов, получения товаров,, продажи их покупателю и высылки их последнему. Если получения товар» ожидает более чем один покупатель, то обслуживание происходит по принципу «первым пришел — первым обслужен». Если может быть выполнена только часть заказа, то на продажу поставляется только эта часть, а оставшаяся поставляется после получения недостающего количества. После высылки всего товара по некоторой цене производится подсчет общей стоимости и печать сообщения. (б) Какие коррективы должны быть сделаны в программах при изменении следующих условий? (1) Фирма пользуется стратегией «первым пришел — первым обслужен»,, а не «последний пришел — первым обслужен» (т. е. ранее полученные товары продаются в первую очередь). (2) Фирма продает в первую очередь товары с более высокими ценами^ а затем с более низкими независимо от времени их поступления. (3) Если получения товара дожидаются сразу несколько покупателей^ то товар продается в первую очередь тому покупателю, у которого общая стоимость закупки максимальна. 4.4. ДРУГИЕ СПИСКОВЫЕ СТРУКТУРЫ Хотя структура в виде связанного списка является весьма полезной, у нее имеется ряд недостатков. В этом разделе мы рассмотрим другие методы организации списка и использование их с целью устранения этих недостатков. Циклические списки Один из недостатков линейных списков заключается в том„ что, зная указатель ρ на элемент списка, мы не имеем доступа к элементам, предшествующим элементу node(p). Если производится просмотр списка, то для повторного обращения к нему исходный указатель на начало списка должен быть сохранен. Предположим, что в структуре линейного списка было сделано изменение, при котором поле ptrnxt последнего элемента содержит указатель назад на первый элемент, а не нулевой указатель. Такой список называется циклическим. Он проиллюстрирован на рис. 4.4.1. Из любого элемента списка можно· достичь любого другого элемента. Отметим, что циклический список не имеет первого и последнего элементов. Мы должны, 214
следовательно, ввести такие элементы. Удобно использовать внешний указатель, указывающий на последний элемент, что автоматически делает следующий за ним элемент первым, как с 3-^J-L^-L^J) Рис. 4.4.1. Циклический список. Первый элемент 1st Последний \ элемент СЁ 38463 \72106 76349 Ь59 Ь Рис. 4.4.2. Первый и последний элементы циклического списка. это показано на рис. 4.4.2. Мы можем также ввести соглашение,, по которому нулевой указатель представляет пустой циклический список. Стек, организованный в виде циклического списка Циклический список может быть использован для реализации стека или очереди. Пусть stack есть указатель на последний элемент циклического списка, и пусть первый элемент является вершиной стека. Ниже приводится программа на языке Бейсик, записывающая в стек число X в предположении, что имеются набор элементов и вспомогательная программа getnode, начинающаяся оператором с номером 1000, как это было рассмотрено в предыдущих разделах. Подпрограмма push вызывает подпрограмму empty, начинающуюся в строке с номером 4000, которая проверяет STACK на равенство нулю: 5000 'подпрограмма push 5010 'входы: STACK, X 5020 'выходы: STACK 5030 'локальные переменные: EMPTY, GTNODE, Ρ 5040 GOSUB 1000: 'подпрограмма getnode устанавливает переменную 'GTNODE 5050 Ρ = GTNODE 5060 INFO(P)=X 5070 GOSUB 4000: 'подпрограмма empty устанавливает переменную 'EMPTY 5080 IF EMPTY^TRUE THEN STACK=P ELSE PTRNXT(P)=PTRNXT (STACK) 5090 PTRNXT(STACK)=P 5100 RETURN 5110 'конец лодцрограмм ы 215
Отметим, что подпрограмма push немного сложнее для циклических списков, чем для линейных. Подпрограмма pop для стека чисел приведена ниже. Она вызывает подпрограмму freenode в строке 2000. Эта подпрограмма приводилась ранее. 6000 'подпрограмма pop 6010 'входы: STACK 6020 'выходы: POPS, STACK 6030 'локальные переменные: EMPTY, P 6040 GOSUB 4000: 'подпрограмма empty устанавливает переменную 'EMPTY 6050 IF EMPTY-TRUE THEN PRINT «ПЕРЕПОЛНЕНИЕ СТЕКА»: STOP 6060 P=PTRNXT(STACK) 6070 POPS-INFO (P) 6080 'если Р«STACK, то в стеке только один элемент 6090 IF Ρ = STACK THEN STACK=0 ELSE PTRNXT (STACK) = PTRNXT(P) 6100 FRNODE=P 6110 GOSUB 2000: 'подпрограмма freenode 6120 RETURN 6130 'конец подпрограммы Очередь, представленная циклическим списком Очередь удобнее представлять циклическим списком, а не линейным. При представлении линейным списком очередь задается двумя указателями, один из которых указывает на ее начало, а другой — на конец. Однако при использовании циклического списка очередь может быть задана только одним указателем QUEUE на этот список. Элемент, на который указывает QUEUE, есть последний элемент в очереди, а элемент, следующий за ним, является первым в ней. Подпрограмма remove (работающая с переменной QUEUE) идентична подпрограмме pop (работающей с переменной STACK), за исключением того, что все ссылки к STACK заменяются на QUEUE, а все ссылки к POPS — на RMOVE. Подпрограмма empty должна быть модифицирована для приема в качестве входной переменной не STACK, a QUEUE. Программа insert может быть записана на языке Бейсик следующим образом: 7О00 'подпрограмма insert 7010 'входы: QUEUE, X 7020 'выходы: QUEUE 7030 'локальные переменные: GTNODE, Ρ 7040 GOSUB 1000: 'подпрограмма getnode устанавливает переменную 'GTNODE 7050 P=GTNODE 7060. INFO(P)=X 7070 GOSUB 4000: 'подпрограмма empty устанавливает переменную ΈΜΡΤΥ 7080 IF EMPTY=TRUE THEN QUEUE=P ELSE PTRNXT (P) = PTRNXT (QUEUE) 7090 PTRNXT (QUEUE) = Ρ 216
7100 QUEUE=P 7110 RETURN 7120 'конец подпрограммы Отметим, что это эквивалентно следующему: 90 STACK=PTRNXT (QUEUE) 100 Χ='записываемый элемент 110 GOSUB 9O00: 'подпрограмма push 120 QUEUE=PTRNXT (QUEUE) Итак, для постановки элемента в конец очереди он должен быть помещен в ее начало, а затем указатель очереди перемещается вперед на один элемент и при этом новый элемент становится последним. Примитивные операции для циклических списков Подпрограмма insafter, помещающая элемент, содержащий X, после элемента node(PNTR), и подпрограмма delafter, удаляющая элемент, следующий за элементом node(PNTR), и сохраняющая его содержимое в X, аналогичны соответствующим подпрограммам для линейных списков, приведенным в разд. 4.2. Рассмотрим теперь подпрограмму delafter более подробно. Взглянув на соответствующую подпрограмму для линейных списков, приведенную в разд. 4.2, мы можем проанализировать ее работу с циклическим списком. Пусть PNTR указывает на единственный элемент в списке. Если список линейный, то в этом случае PTRNXT(PNTR) имеет нулевое значение, и операция удаления невозможна. Однако для циклического списка PTRNXT(PNTR) указывает на элемент node(PNTR), поэтому элемент node (PNTR) следует за самим собой. Возникает вопрос, нужно ли в этом случае удалять элемент node(PNTR) из списка. Маловероятно, что мы захотим это сделать, поскольку операция delafter обычно вызывается при наличии указателей на каждый из двух элементов, следующих один за другим. При этом требуется удалить второй. Операция delafter для циклических списков реализуется следующим образом: 8000 'подпрограмма delafter 8010 'входы: PNTR 8020 'выходы: X 8030 'локальные переменные: FRNODE, Q 8040 'если PNTR=0, то список пуст 8050 IF PNTR=0 THEN PRINT «УДАЛЕНИЕ ЗАПРЕЩЕНО»: RETURN 8060 'если PNTR=PTRNXT(PNTR), то список содержит только один 'элемент 8070 IF PNTR = PTRNXT(PNTR) THEN PRINT «УДАЛЕНИЕ ЗАПРЕЩЕНО»: RETURN 8080 Q = PTRNXT(PNTR) 8090 X=INFO(Q) 8100 PTRNXT(PNTR) =PTRNXT(Q) 8110 FRNODE=Q 217
8120 GOSUB 2000: 'подпрограмма freenode 8130 RETURN 8140 'конец подпрограммы Отметим, что подпрограмму insafter нельзя использовать для размещения элемента вслед за последним элементом циклического списка, а подпрограмма delafter не может быть использована для удаления последнего элемента циклического списка. Эти программы могут быть модифицированы, с тем чтобы принимать LST в качестве дополнительного параметра и при необходимости изменять его значение. Альтернативой для таких случаев может стать написание отдельных подпрограмм insend и dellast. (Операция insend идентична операции insert для очереди, представленной циклическим списком.) Вызывающая программа должна быть в состоянии определить, какую из подпрограмм необходимо вызвать. Другая альтернатива предполагает возложение на вызывающую программу ответственности за коррекцию внешнего указателя LST. Мы оставляем анализ этих возможностей читателю. Легче освободить все элементы циклического списка, чем линейного. При работе с линейным списком необходимо просматривать весь список целиком, возвращая по одному элементу в список доступных элементов до тех пор, пока не будет пройден последний элемент, после чего весь список присоединяется к списку доступных элементов. При работе с циклическим списком мы можем написать программу freelist, которая быстра освобождает весь список, не просматривая его: 9000 'подпрограмма freelist 9010 'входы: LST 9020 'выходы: LST 9030 'локальные переменные: Ρ 9040 P = PTRNXT(LST) 9050 PTRNXT(LST) = AVAIL 9060 AVAIL=0 9070 LST = 0 9080 RETURN 9090 'конец подпрограммы Аналогичным образом мы можем написать программу concat, которая сцепляет два списка, т. е. подсоединяет список, заданный указателем L2, к концу другого списка, заданного указателем L1: 10000 'подпрограмма concat 10010 'входы: LI, L2 10020 'выходы: L1 10030 IF L2=0 THEN RETURN 10040 IF LI =0 THEN LI =L2: RETURN 10050 P=PTRNXT(L1) 10060 PTRNXT (L1) = PTRNXT (L2) 10070 PTRNXT (L2)= Ρ 218
10080 L1=L2 10090 RETURN 10100 'конец подпрограммы Задача Джозефуса Рассмотрим задачу, которая не может быть разрешена непосредственным применением циклического списка. Эта задача носит имя Джозефуса. В ней рассматривается группа солдат, окруженная превосходящими силами противника. Надежда на победу без подкрепления исключается, однако для прорыва из лагеря имеется только одна доступная лошадь. Солдаты решают выбрать одного человека и послать его за помощью. Они становятся в круг и из шляпы выбирается число п. Также выбирается одно из их имен. Производится счет по часовой стрелке по кругу, начиная с солдата с выбранным именем. Когда счетчик достигает п, соответствующий солдат удаляется из круга, а счет продолжается снова, начиная со следующего солдата. Разумеется, после того как солдат был удален из круга, он больше не принимает участия в счете. Последний оставшийся в круге солдат посылается за подмогой. Ставится задача: при заданном порядке расположения солдат в круге, известном числе η и известном солдате, с которого начинается счет, определить солдата, который должен отправиться за подмогой. Входными данными для программы являются число η и список имен, определяющий расположение солдат при просмотре круга по часовой стрелке, начиная с того солдата, с которого начинается счет. Последняя входная строка содержит строку «END», обозначая этим конец списка. Программа должна напечатать имена солдат в порядке их удаления из списка и имя солдата, посылаемого за подмогой. Например, предположим, что η равно 3 и имеются пять солдат с именами А, В, С, D и Е. Мы начинаем счет с солдата А, поэтому первым удаляется солдат С. Затем счет продолжается с солдата D, проходит Ε и возвращается к А, поэтому следующим удаляется солдат А. Затем считаются В, D и Ε (С уже был удален) и, наконец, остаются В и D. Солдат В удаляется, следовательно, последним остается солдат D. Очевидно, что циклический список, каждый элемент которого соответствует одному солдату, является наиболее подходящей структурой для решения этой задачи. Любой элемент такого списка достижим из любого другого путем перемещения по кругу. Удаление солдата из круга эквивалентно удалению соответствующего ему элемента списка. Результат будет определен тогда, когда в списке останется один элемент. Предварительный набросок программы может иметь следующий вид: read n read soldier 219
while soldier не равен «end» do вставить soldier в циклический список read soldier endwhile while в списке имеется более одного элемента do выполнить счет для п— 1 элементов в списке print имя солдата в n-м элементе удалить n-й элемент endwhile print имя солдата в одном оставшемся элементе списка Мы предполагаем, что во входных данных присутствует хотя бы одно имя. Программа использует подпрограммы insert, de- lafter и freenode: 10 'программа Джозефуса 20 DEFSTR I, S, X 30 DIM INFO (500) 40 DIM PTRNXT(500) 50 TRUE=1 60 FALSE=0 70 LST=0 80 AVAIL =1 90 FORK=l TO 499 100 PTRNXT(K)=K+1 110 NEXT К 120 PTRNXT(500)=0 130 READ N 140 PRINT «ПОРЯДОК, В КОТОРОМ ИСКЛЮЧАЮТСЯ СОЛДАТЫ»: 150 'прочесть имена, помещая каждое в конец списка 160 READ SOLDIER 170 IF SOLDIER = «END» THEN GOTO 230 1θ0 QUEUE=LST 190 X= SOLDIER 200 GOSUB 7000: 'подпрограмма insert принимает переменную 'QUEUE и X и переустанавливает QUEUE 210 LST=QUEUE 220 GOTO 160 230 'повторять до тех пор, пока в списке не останется только один элемент 240 IF LST=PTRNXT(LST) THEN GOTO 350 250 'иначе выполняются операторы с номерами 260—330 260 FORJ=lTON-l 270 LST=PTRNXT(LST) 280 'в этой точке LST указывает на J-й просматриваемый элемент 290 NEXT J 300 'PTRNXT(LST) указывает на N-й элемент; удалить этот элемент 310 PNTR = LST 320 GOSUB 800O: 'подпрограмма delafter устанавливает переменную X 330 PRINT X 340 GOTO 240 350 'печать единственного оставшегося в списке имени и освобождение соответствующего ему элемента 360 PRINT «СОЛДАТ, КОТОРЫЙ СПАССЯ»; INFO(LST) 370 FRNODE=LST 380 GOSUB 2000: 'подпрограмма freenode 390 END 500 DATA . . . 220
990 1000 2000 4000 7000 8000 DATA «END» 'подпрограмма getnode 'подпрограмма freenode 'подпрограмма empty 'подпрограмма insert 'подпрограмма delafter Элементы заголовка Предположим, что нам необходимо просмотреть циклический список. Это может быть сделано путем повторного выполнения оператора P = PTRNXT(P), где Ρ изначально является указателем на начало списка. Однако, поскольку список циклический, мы не можем знать, когда он будет пройден целиком, если прет этом другой указатель LST не указывает на первый элемент и не производится проверка на равенство P = LST. λ Элемент заголовка Ж ь Рис. 4.4.3. Циклический список с элементом заголовка. Альтернативный подход предполагает размещение заголовка в первом элементе циклического списка. Заголовок списка распознается по специальному коду, задаваемому в поле INFO. Это поле не должно содержать осмысленного значения в контексте решаемой задачи. Оно может иметь флаг, указывающий на принадлежность данного поля заголовку. В этом случае список может просматриваться при помощи одного указателя. Просмотр заканчивается по достижению элемента заголовка. Внешний указатель на список указывает на элемент заголовка этого списка, что проиллюстрировано на рис. 4.4.3. Это означает^ что элемент не может быть просто добавлен в конец такога циклического списка, как это было в том случае, когда внешний, указатель указывал на последний элемент списка. Разумеется,, можно ввести дополнительный указатель, указывающий на последний элемент циклического списка, и поместить его в элемент заголовка или держать дополнительный внешний указатель на последний элемент. Если кроме указателя, используемого для просмотра списка,, имеется постоянный внешний указатель на этот список, то заголовок может не содержать специальный флаг, а использовать- 221
ся точно так же, как и элемент заголовка в линейном списке, т. е. для хранения справочной информации об этом списке. Конец просмотра определится равенством значения указателя просмотра значению внешнего постоянного указателя. Сложение длинных положительных целых чисел при помощи циклических списков Рассмотрим теперь пример использования циклических списков, имеющих заголовки. Аппаратная часть большинства вычислительных машин позволяет работать с целыми числами, не превышающими некоторую установленную длину. Предположим, что мы работаем с положительными целыми числами произвольной длины и нам необходима функция, которая вычисляет сумму двух таких чисел. Для сложения двух чисел их цифры просматриваются справа налево и соответствующие пары складываются друг с другом, возможно, с переносом, полученным при сложении ^предыдущей пары. Это предполагает хранение длинных целых чисел в списке с размещением их цифр справа налево так, что первый элемент списка содержит последнюю значащую цифру (крайнюю правую), а последний элемент содержит первую значащую цифру (крайнюю левую). Однако для экономии пространства мы будем хранить по пять цифр в каждом элементе. Мы можем объявить набор элементов следующим образом: 20 DIM INFO(500) 30 DIM PTRNXT(500) с i / -ι St ψ 98463 72106 76349 459 b Рис. 4.4.4. Длинное целое число, представленное в виде циклического списка. Поскольку в процессе сложения мы хотим просматривать список, но при этом сохранять исходные значения указателей списка, то мы воспользуемся циклическими списками с заголовками. Заголовок списка отличается от остальных элементов наличием в поле INFO значения —1. Например, целое число 459763497210698463 будет храниться в списке так, как это показано на рис. 4.4.4. Напишем теперь программу addnum, которая воспринимает в качестве входных значений указатели на два списка, предоставляющих целые числа, создает список, содержащий сумму этих чисел, и возвращает указатель на этот список. Оба списка -просматриваются параллельно с одновременным формированием шятизначных сумм. Если сумму двух пятизначных чисел обо- 222
значить как SUM, то перенос CARRY есть INT (SUM/100000).. Пять младших значащих цифр SUM есть тогда соответственно* SUM— 100000*CARRY. По достижению конца списка перенос передается к оставшимся цифрам в другом списке. Подпрограмма использует подпрограмму getnode, начинающуюся в строке* с номером 1000, и подпрограмму insafter, начинающуюся в строке с номером 11000. 20000 'подпрограмма addnum 20010 'входы: PLST, QLST 20020 'выходы: ADDNUM 20030 'локальные переменные: CARRY, GTNODE, HUNTHOU, PPTR, 'QPTR, S, SUM 20040 HUNTHOU-100000 20050 'PLST и QLST есть указатели на элементы заголовков двух 'списков, представляющих длинные целые числа 20060 'установить PPNTR и QPNTR на элементы, следующие за заго- 'ловкамк- 20070 PPNTR=PTRNXT (PLST) 20080 QPNTR=PTRNXT (QLST) 20090 'установить элемент заголовка для суммы 20100 GOSUB 1000: 'подпрограмма getnode устанавливает переменную 'Gtnode: 20110 S=GTNODE 20120 INFO(S) = -l 20130 PTRNXT(S)=S 20140 'изначально перенос отсутствует 20150 CARRY-0 20160 'секция просмотра 20170 IF INFO(PPNTR) = -l OR INFO (QPNTR) = -1 THEN GOTO 20310» 20180 'сложить поля info двух элементов и добавить перенос 20190 SUM=INFO(PPNTR)+INFO(QPNTR)+CARRY 20200 'определить, есть ли перенос 20210 CARRY=INT(SUM/HUNTHOU) 20220 'определить, пять младших значащих цифр SUM 20230 X=SUM-HUNTHOU*CARRY 20240 'поместить в список, переместить указатели просмотра 20250 PNTR=S 20260 GOSUB 11000: 'подпрограмма insafter принимает PNTR и X 20270 S=PTRNXT(S) 20280 PPNTR=PTRNXT (PPNTR) 20290 QPNTR=PTRNXT (QPNTR) 20300 GOTO 20170 20310 'в этой точке в одном из списков PLST или QLST могут остаться* 20320 'элементы; просмотр остатка PLST 20330 IF INFO (PPNTR) - -1 THEN GOTO 20420 20340 SUM= INFO (PPNTR) +CARRY 20360 CARRY=INT(SUM/HUNTHOU) 20360 X= SUM-HUNTHOU»CARRY 20370 PNTR=S 20380 GOSUB 11000: 'подпрограмма insafter 20390 S=PTRNXT(S) 20400 PPNTR = PTRNXT (PPNTR) 20410 GOTO 20320 20420 'просмотр оставшейся части QLST 20430 IF INFO (QPNTR) 1 THEN GOTO 20520 20440 SUM=INFO (QPNTR)+CARRY 20450 CARRY=INT(SUM/HUNTHOU) 223
50460 X=SUM-HUNTHOU*CARRY 20470 PNTR=S 20480 GOSUB 11000: 'подпрограмма insafter 20490 S=PTRNXT (S) 20500 QPNTR - PTRNXT (QPNTR) 20510 GOTO 20430 20520 'проверить, есть ли перенос из группы первых пяти цифр 20530 IF CARRY=0 THEN GOTO 20690 20540 'иначе выполняются операторы с номерами 20550—20580 20550 PNTR=S 20560 Х=CARRY 20570 GOSUB 11000: 'подпрограмма insafter 20580 S = PTRNXT (S) 20590 'S указывает на последний элемент в сумме 20600 ADDNUM=PTRNXT (S) 20610 RETURN 20620 'конец подпрограммы Двунаправленные связанные списки Хотя циклический список имеет свои преимущества перед линейным, он имеет также и ряд недостатков. Такой список нельзя просматривать в обратном направлении. Располагая только значением указателя для данного элемента, удалить последний невозможно. При необходимости иметь такую возможность можно воспользоваться соответствующей структурой данных, называемой двунаправленным связанным списком. Каждый элемент такого списка содержит два указателя. Один указатель указывает на предшествующий элемент, а другой — на последующий. В действительности в контексте двунаправленного связанного списка понятия предшествующего и последующего элемента бессмысленны, поскольку список полностью симметричен. Двунаправленные связанные списки могут быть линейными и циклическими и могут содержать или не содержать элемент заголовка, как это показано на рис. 4.4.5. Мы будем считать, что элементы двунаправленного связанного списка содержат три поля: поле info, содержащее информацию, хранимую в элементе, и левое и правое поля, содержащие указатели на соседние элементы. Можно определить переменные для представления таких элементов следующим образом: 30 DIM INFO(500) 40 DIM LEFT (500) 50 DIM RIGHT (500) Указатели LEFT(I) и RIGHT(I) указывают на элементы, расположенные соответственно справа и слева от элемента node(i). Мы можем задать набор таких элементов и по-другому: 30 DIM INFO (500) 40 DIM PTRNXT (500,2) 50 LEFT=1 60 RIGHT = 2 224
a CF 4) Элемент заголовка с [—— 1 Рис. 4.4.5. Двунаправленные связные списки. а __ двунаправленный связанный линейный список; б — двунаправленный циклический без заголовка; в — циклический двунаправленный список с заголовком. При таком представлении указатели PTRNXT(I,LEFT) и PTRNXT(I,RIGHT) указывают на элементы, соответственно расположенные слева и справа от элемента node(I). Это последнее представление мы и будем использовать в дальнейшем. Отметим, что список доступных элементов для такого набора не должен быть двунаправленным, поскольку он не просматривается в обоих направлениях. Список доступных элементов может быть связан при помощи указателя PTRNXT(I,LEFT) или указателя PTRNXT(I,RIGHT). Разумеется, должны быть написаны соответствующие программы getnode и freenode. Приведем программы для работы с двунаправленными связанными списками. Удобным свойством такого списка является то, что если ρ есть указатель на любой элемент двунаправленного связанного списка, то left (right (p))=p = right (left (p)) Операция, которая может быть выполнена над двунаправленным списком, но не может быть выполнена над обычными связанными списками, есть операция по удалению данного элемента по заданному для этого элемента значению указателя. Приводимая ниже программа на языке Бейсик удаляет из двунаправленного связанного списка элемент, указываемый PNTR, и сохраняет его содержимое в X: 15000 'подпрограмма delete (для двунаправленного связанного списка) 15010 'входы: PNTR 15020 'выходы: X 225
15030 'локальные переменные: FRNODE, Q, R 15040 IF PNTR = 0 THEN PRINT «УДАЛЕНИЕ ЗАПРЕЩЕНО»: RETURN 15060 X=INFO(PNTR) 15060 Q=PTRNXT(PNTR, LEFT) 15070 R = PTRNXT(PNTR, RIGHT) 15080 PTRNXT(Q, RIGHT) =R 15090 PTRNXT(R, LEFT) =Q 15100 FRNODE=PNTR 15110 GOSUB 2000: 'подпрограмма freenode 15120 RETURN 15130 'конец подпрограммы Подпрограмма insertright помещает узел с информационным полем X справа от элемента node (PNTR) двунаправленного связанного списка: 16000 'подпрограмма insertright 16010 'входы: PNTR, X 16020 'выходы: нет 16030 'локальные переменные: GTNODE, Q, R 16040 IF PNTR=0 THEN PRINT «УДАЛЕНИЕ ЗАПРЕЩЕНО»: RETURN 16050 GOSUB 1000: 'подпрограмма getnode устанавливает переменную 'GTNODE 16060 Q^GTNODE 16070 INFO(Q)=X 16080 R = PTRNXT (PNTR, RIGHT) 16090 PTRNXT(R, LEFT)-Q 16100 PTRNXT(Q, RIGHT) =R 16110 PTRNXT (Q, LEFT) - PNTR 16120 PTRNXT (PNTR, RIGHT) =Q 16130 RETURN 16140 'конец подпрограммы Подпрограмма insertleft, вставляющая элемент с информационным полем X слева от элемента node (PNTR) в двунаправленный связанный список, сходна с приведенной выше и оставляется читателю в качестве упражнения. При программировании для микроЭВМ экономия памяти часто является весьма важным соображением. Программа может оказаться не в состоянии поддерживать список, каждый элемент которого содержит два указателя. Имеется ряд приемов сжатия правого и левого указателей в одно поле. Например, одно поле указателя PTR в каждом элементе может содержать в себе сумму указателей на правый и левый элементы. Имея два внешних указателя Ρ и Q на два соседних элемента таких, что P = LEFT(Q), RIGHT (Q) может быть вычислено как PTR(Q)—Р, a LEFT(P) —как PTR(P)—Q. Зная Ρ и Q, можно либо удалить элемент, либо переместить указатель с него на последующий или предшествующий ему элемент. Можно также разместить элемент слева от элемента node(P) или справа от элемента node(Q) либо вставить элемент между node(P) и node(Q) и переустановить или Р, или Q на вновь размещенный элемент. При использовании такой схемы важно всегда поддерживать значения двух внешних указателей на два соседних элемента в списке. 226
Сложение длинных целых чисел при помощи двунаправленных связанных списков Рассматривая применение двунаправленных связанных списков, расширим реализацию операции сложения длинных целых чисел отрицательными и положительными числами. Элемент заголовка циклического списка, представляющий целое число, будет содержать указание на то, является ли оно положительным или отрицательным. Если мы хотим сложить два положительных целых числа, то просматриваем их, начиная с младших значащих цифр. Однако для сложения положительного и отрицательного чисел необходимо вычесть меньшее абсолютное значение из большего, а результату присвоить знак того числа, чье абсолютное значение больше. Следовательно, необходим некоторый метод, позволяющий проверить, какое из двух чисел, представленных в виде циклических списков, имеет большее абсолютное значение. Первый критерий, который может быть использован для определения числа с большим абсолютным значением, есть длина этих чисел (предполагается, что в начале их отсутствуют нули). Следовательно, мы можем сосчитать число элементов в каждом списке и список, имеющий больше элементов, представляет собой целое число с большим абсолютным значением. Однако такой подсчет предполагает дополнительный просмотр списка. Вместо подсчета числа элементов это число может быть записано в заголовке списка, к которому при необходимости делается ссылка. Однако если оба списка имеют одинаковое число элементов, то для определения того, какое из чисел имеет большее абсолютное значение, необходимо осуществлять их просмотр, начиная со старшей значащей цифры и кончая младшей. Отметим, что направление просмотра противоположно направлению, используемому при выполнении операции сложения. По этой причине для представления таких чисел используются двунаправленные связанные списки. Рассмотрим формат заголовка. Помимо правого и левого указателей заголовок должен содержать длину списка и указание о том, какой знак имеет данное число. Эти два элемента информации могут быть записаны одним целым числом, чье абсолютное значение соответствует длине списка и чей знак соответствует знаку представляемого данным списком числа. Однако при таком представлении исключается возможность идентификации заголовка списка по наличию —1 в поле INFO. При новом представлении элемент заголовка может содержать в поле INFO число 5, что является разрешенным значением и для любого другого элемента списка. Эту проблему можно решить несколькими способами. Одним из способов является добавление к каждому элементу дополни- 227
тельного поля, в котором содержится указание на принадлежность данного элемента к заголовку. Такой флаг может содержать значение 1, если данный элемент есть элемент заголовка, и 0 в противном случае. Это, разумеется, означает, что каждый элемент будет занимать больший объем памяти. Другой способ предполагает исключение из заголовка счетчика цифр и хранения в поле INFO значения —1 для положительных чисел и —2 для отрицательных. Элемент заголовка будет в этом случае идентифицироваться отрицательным значением поля INFO. Однако это приведет к увеличению времени, необходимому для сравнения двух чисел, поскольку потребуется подсчет числа цифр в них. Такие пространственно-временные соглашения весьма распространены в программистской практике. Необходимо принять решение, что должно быть принесено в жертву, а что сохранено. В данном случае мы остановимся на третьем варианте, который предполагает поддержание дополнительного внешнего указателя на заголовок списка. Дополнительный указатель Ρ указывает на заголовок, если его значение равно значению основного внешнего указателя. В противном случае элемент node(P) не является заголовком. На рис. 4.4.6 приведены пример элемента и представление четырех целых чисел в виде двунаправленных связанных списков. Отметим, что четыре последние значащие цифры находятся справа от заголовка, а счетчики в элементах заголовка не учитывают в себе сам заголовок. Используя рассмотренное выше представление, мы можем написать программу compare, которая сравнивает абсолютные значения двух целых чисел, представленных двунаправленными связанными списками. Выходная переменная COMPARE устанавливается: в 1, если первое число имеет большее абсолютное значение; в —1, если второе число имеет большее абсолютное значение; в 0, если числа равны. 30000 'подпрограмма compare 30010 'входы: PPNTR, QPNTR 30020 'выходы: COMPARE 30030 'локальные переменные: R, S 30040 'сравнение счетчиков 30050 IF ABS(INFO(PPNTR))>ABS(INFO(QPNTR)) THEN COMPARE=I: RETURN 30060 IF ABS (INFO (PPNTR) )<ABS (INFO (QPNTR)) THEN COMPARE=-l: RETURN 30070 'счетчики равны 30080 R = PTRNXT (PPNTR, LEFT) 30090 S = PTRNXT (QPNTR, LEFT) 30100 'просмотр списка, начиная со старшей значащей цифры 30110 IF R-PPNTR THEN GOTO 30170 30120 IF INFO(R)>INFO(S) THEN COMPARED: RETURN 30130 IF INFO(R)<INFO(S) THEN COMPARE=-l: RETURN 30140 R=PTRNXT(R, LEFT) 30150 S=PTRNXT(S, LEFT) 30160 GOTO 30110 228
Левый \иказЬтет\ info Правый ш Указатель С Заголовок -з 49762 27978 324 Ό С Заголовок Ll 2 L 76947 -^ 6 * о Заголовок О Рис. 4.4.6. Целые числа, представленные двунаправленными списками. а —пример элемента списка; б — целое число — 3 242 197 849 762; в — целое число 676 941; г — целое число 0. 30170 'абсолютные значения равны 30180 COMPARE=0 30190 RETURN 30200 'конец подпрограммы Теперь мы можем написать подпрограмму oppsignadd, которая принимает два указателя на списки, представляющие длинные целые числа с противоположными знаками, и при этом абсолютное значение первого числа не меньше абсолютного значения второго. Подпрограмма oppsignadd выдает на выходе указатель на список, представляющий собой сумму этих чисел. Для просмотра списка мы воспользуемся переменной ZEROPTR, удаляя элементы, содержащие ведущие нули. В этой программе указатель PPNTR указывает на целое число с большим абсолютным значением, a QPNTR — на число с меньшим абсолютным значением. Значения этих переменных не изменяются. Для просмотра этих списков используются дополнительные переменные Р1 и Q1. Сумма формируется в списке q указателем RPNTR. 229
35000 'подпрограмма oppsignadd 35010 'входы: PPNTR, QPNTR 35020 'выходы: OPPSIGNADD 35030 'локальные переменные: BRROW, CNTR, GTNODE, HUNTHOU, Ф1, PNTR, Ql, RPNTR, X, ZEROPTR 35040 HUNTHOU =100000 35050 CNTR=0: 'счетчик числа элементов в результате 35060 BRROW«0: Ί, если был перенос, 0 в противном случае; изначаль- 'но перенос отсутствует 35070 'генерация элемента заголовка для суммы 35080 GOSUB 1000: 'подпрограмма getnode устанавливает переменную 35090 RPNTR=GTNODE 3510Q PTRNXT (RPNTR, LEFT) = RPNTR 35110 PTRNXT (RPNTR, RIGHT) = RPNTR 35120 'просмотр обоих списков 35130 PI = PTRNXT (PPNTR, RIGHT) 35140 Ql «PTRNXT(QPNTR, RIGHT) 35150 IF Ql =QPNTR THEN GOTO 36250 35160 X=INFO(Pl) -BRROW-INFO (Ql) 35170 IF X>=0 THEN BRROW=0 ELSE X=X+HUNTHOU: BRROW= 1 35180 'генерация элемента и размещение его в сумме слева от заголовка 35190 PNTR = RPNTR 35200 GOSUB 16600: 'подпрограмма insertleft воспринимает перемен- 'ные PNTR и X 35210 CNTR=CNTR+1 35220 PI = PTRNXT (PI, RIGHT) 35230 Ql = PTRNXT (Ql, RIGHT) 35240 GOTO 35150 35250 'просмотр конца списка PPNTR 35260 IF P1 = PPNTR THEN GOTO 35340 35270 X= INFO (PI) -BRROW 35280 IF X>0 THEN BRROW=0 ELSE X=X+HUNTHOU: BRROW=l 35290 PNTR=RPNTR 35300 GOSUB 16500: 'подпрограмма insertleft 35310 CNTR=CNTR+1 35320 P1 = PTRNXT(P1, RIGHT) 35330 GOTO 35260 35340 'удалить ведущие нули 35350 ZEROPTR=PTRNXT(RPNTR, LEFT) 35360 IF INFO (ZEROPTR) < >0 OR ZEROPTR = RPNTR THEN GOTO 35420 35370 PNTR=ZEROPTR 35380 ZEROPTR=PTRNXT (ZEROPTR, LEFT) 35390 GOSUB 15000: 'подпрограмма delete воспринимает PNTR 35400 CNTR = CNTR—1 35410 GOTO 35360 35420 'поместить в заголовок счетчик и знак 35430 IF INFO (PPNTR) >0 THEN INFO (RPNTR) = CNTR ELSE INFO (RPNTR)--CNTR 35440 OPPSIGNADD-PRNTR 35450 RETURN 35460 'конец подпрограммы 230
Мы можем также написать подпрограмму samesignadd, начиная со строки с номером 25000, которая складывает два числа с одинаковыми знаками. Она очень похожа на подпрограмму addnum из предыдущего примера, за исключением того, что она работает с двунаправленным связанным списком и должна следить за числом элементов в списке, содержащем сумму. Используя эти две подпрограммы, мы можем написать новую версию подпрограммы addnum, которая складывает два целых числа, представленных двунаправленными связанными списками. 20000 'подпрограмма addnum 20010 'входы: PLST, QLST 20020 'выходы: ADDNUM 20030 'локальные переменные: COMPARE, OPPSIGNADD, PPTR, QPTR, 'TEMP 20040 PPNTR=PLST 20050 QPNTR=QLST 20060 'проверить, имеют ли числа одинаковый знак; 'если это так, то вызвать подпрограмму samesignadd 2007Ό IFINFO(PPNTR)*INFO(QPNTR)>0 THEN GOSUB 25000: ADDNUM= SAMESIGNADD: RETURN 20080 'проверить, какое из чисел имеет большее абсолютное значение 20090 GOSUB 30000: 'подпрограмма compare устанавливает переменную 'COMPARE 20100 'при необходимости изменить указатели так, чтобы PPNTR указы- 'вал на большее целое 20110 IFCOMPARE<0 THEN TEMP=PPNTR: PPNTR-QPNTR: QPNTR=TEMP 20120 GOSUB 35000: 'подпрограмма oppsignadd 20130 ADDNUM=OPPSIGNADD 20140 RETURN 2O150 'конец подпрограммы Мультисписки Иногда бывает желательно держать один и тот же элемент в нескольких списках, не повторяя его при этом в нескольких элементах. Например, рассмотрим обзор состояния здоровья населения и влияния на него курения и употребления алкоголя. Исследователи собрали истории болезней большого числа людей, каждый из которых был охарактеризован как некурящий, слабо курящий, сильно курящий, непьющий, пьющий мало и пьющий много. Исследователи хотели бы хранить эту информацию таким образом, чтобы они могли получать различные статистические данные или последовательности для различных подгрупп (например, какова степень подверженности некоторой болезни среди сильно курящих, которые при этом также мнсго пьют?). Одним из возможных решений является хранение каждой истории болезни в элементе, содержащемся в двух списках: один хранит информацию об объеме выкуриваемых сигарет, а другой — информацию об объеме потребляемого алкоголя. 231
Для того чтобы хранить элемент в нескольких списках, этот элемент должен иметь указатель на каждый список, которому он принадлежит. Структура данных, состоящая из таких элементов, называется мультисписком. В приведенном примере элемент может содержать в своей информационной части фамилию человека и его историю болезни, указатель на следующий элемент с тем же объемом выкуриваемых сигарет и другой указатель на элемент с равным объемом потребления алкоголя. Также удобно иметь в элементе поля, указывающие на то, какой список содержит этот элемент, что позволяет просмотром одного списка выявить и другие списки, содержащие этот элемент. Такой элемент для приводимого примера показан на рис. 4.4.7. Уровень потребления алкоголя Степень курения Имя История болезни Указатель на следующий элемент с таким же уровнем потребления алкоголя Указатель на следующий элемент с таким же показателем степени курения Рис. 4.4.7. Пример мультисписка. На рис. 4.4.8 показана часть мультисписка для данного примера, состоящая из восьми элементов. В приводимой ниже таблице даны характеристики людей, хранящиеся в этих элементах: Фамилия Уровень потребления алкоголя Степень курения А В С D Ε F G Η нет высокий низкий нет высокий низкий нет высокий нет нет высокая нет высокая низкая высокая низкая В этой таблице каждый список упорядочен в алфавитном порядке. Для удобства все указатели помечены на рис. 4.4.8. Метки обозначают соответствующие списки, к которым принад- 232 НАИН- Нет Нет А ... -SBA + ТЯГ* Высокий Нет В ВА -. ч* ) НК^ Нет Нет D ™ НА ~\ w \нк тшкийрысокий Μ А ■ ВК- шкюкий [Высокий] МА Μ * ВК ВА В7< Нет фысокий »НА »ВК МК- Низкийтизкий фысокищНизкий -Л//Г МК И В А ♦ НК Рис. 4.4.8. Часть мультисписка. лежат эти элементы (например, МА означает малый уровень потребления алкоголя, НК обозначает «некурящий» и т. д.). Например, список мало курящих содержит элементы F и Н. Следовательно, если мы хотим определить всех мало курящих, которые при этом потребляют много алкоголя, мы можем просмотреть либо список мало курящих и проверить, является ли каждый курильщик также и сильно пьющим, или просмотреть список сильно пьющих и выявить среди них людей, курящих мало. В обоих случаях результатом будет элемент Н. Списки в мультисписке могут быть линейными или циклическими, однонаправленными или двунаправленными. Разуме- 233
ется, если списки двунаправленные, то необходимо вдвое большее число указателей. Списки также могут содержать или не содержать элемент заголовка. Например, предположим, что каждый список имеет свой заголовок, содержащий число элементов в списке. Тогда, если мы хотим отыскать всех много пьющих, которые курят мало, мы должны проверить заголовки двух списков, определив, какой список меньше. Просмотр меньшего списка значительно сократит время поиска. Упражнения 1. Напишите алгоритм и программу на языке Бейсик, выполняющую каждую из операций упражнения 4.2.4 применительно к циклическим спискам. Какой из них более эффективен для циклических списков и какой менее? 2. Перепишите подпрограмму place из разд. 4.2 так, чтобы она помещала новый элемент в упорядоченный циклический список. 3. Напишите программу, решающую задачу Джозефуса, использующую вместо циклического списка массив. Почему циклический список более эффективен? 4» Рассмотрим другую разновидность задачи Джозефуса. Группа людей стоят в кругу, и каждый выбирает целое положительное число. Выбираются одно из их имен и положительное число п. Производится счет по часовой стрелке, начиная с человека с выбранным именем. При этом n-й человек исключается из круга. Выбранное этим человеком положительное число используется для продолжения счета. Каждый раз при удалении очередного человека выбранное им число используется для определения следующего удаляемого человека. Например, предположим, что имеется пять человек — А, В, С, D и Ε и они выбирали числа 3, 4, 6, 2 и 7 соответственно. Также изначально были выбраны число 2 и человек А. Тогда, если счет был начат с А, порядок исключения людей из круга будет В, А, Е, С. Последним в круге останется D. Напишите программу, считывающую группу строк с операторами DATA. Каждый оператор DATA, за исключением первого, содержит имя участника и выбранное им положительное целое число. Порядок имен в наборе данных соответствует порядку их расположения в круге при просмотре по часовой стрелке. Счет начинается с участника, следующего первым во входном наборе. Первая входная строка содержит число людей в круге. Последняя входная строка содержит положительное целое число, задающее первый отсчет. Программа печатает порядок удаления людей из круга. 5. Напишите на языке Бейсик программу, которая печатает целое число произвольной длины, заданное списком. Отметим, что если узел содержит меньше пяти цифр, то перед печатью его содержимое должно быть дополнено ведущими нулями; в противном случае эти цифры будут считаться самыми старшими значащими цифрами. 6. Напишите на языке Бейсик программу multnum, умножающую два целых числа произвольной длины, представленных однонаправленными связанными списками. 7. Напишите алгоритм и программу на языке Бейсик, которая выполняет операции, описанные в упражнении 4.2.4, для двунаправленных связанных списков. Для каких операций более эффективно использовать двунаправленные списки и для каких — однонаправленные? 8. Предположим что единственное поле указателя в каждом из элементов двунаправленного связанного списка содержит сумму указателей на последующий и предшествующий элементы, как это было описано в тексте. Для заданных указателей Ρ и Q на соседние элементы напишите на языке Бейсик программы, помещающие элемент справа от элемента node(Q), 234
слева от элемента node(P) и между элементами node(Q) и node(P), изменяя значение указателя Ρ так, чтобы он указывал на вновь размещенный элемент. Напишите дополнительную программу, удаляющую элемент node(Q) и устанавливающую значение Q на предшествующий элемент. 9. Предположим, что FIRST и LAST есть внешние указатели на первый и последний элементы в двунаправленном связанном списке, как это описано в упражнении 8. Напишите на языке Бейсик программы, реализующие операции из упражнения 4.2.4 для такого списка. 10. Напишите программу samesignadd для сложения двух длинных чисел одного знака, представленных двунаправленными связанными списками. 11. Перепишите программу oppsignadd для двунаправленного связанного списка из упражнения 8. 12. Напишите на языке Бейсик подпрограмму multnum, умножающую два длинных целых числа, представленных двунаправленными связанными списками. 13. К^к можно представить полином с тремя переменными (х, у и ζ) в циклическом списке? Каждый элемент должен представлять член полинома и содержать степени х, у и ζ, а также коэффициент при этом члене. Элементы должны быть упорядочены сначала по уменьшению степени х, затем по уменьшению степени у, а затем по ζ. Напишите на языке Бейсик программу, выполняющую следующие функции: а) сложения двух полиномов; б) умножения двух полиномов; в) нахождение частной производной полинома по любой переменной; г) вычисление полинома по заданным значениям х, у и ζ; д) Деление одного полинома на другой, формирование частного и остатка; е) интегрирование полинома по любой из его переменных; ж) печать представления такого полинома; з) для четырех заданных полиномов: f(x,y,z), g(x,у,ζ), h(x,y,z) и i(x,у,ζ) вычислите полином f(g(x,y,z),h(x,y,z),i(x,y,z)). 14. Напишите на языке Бейсик программу, которая считывает две группы строк с операторами DATA. Каждая строка в первой группе содержит имя, степень потребления табака и уровень потребления алкоголя. Эта первая группа заканчивается строкой, которая содержит имя END. После считывания первой группы программа должна составить мультисписок, описанный в тексте. Затем программа должна считать вторую группу, каждая строка в которой содержит степень потребления табака и алкоголя. Для каждого оператора DATA из второй группы программа должна напечатать число людей с показателями потребления табака и алкоголя, заданными в этом операторе.
Глава 5 Рекурсия В этой главе вводится рекурсия — некоторый инструмент для решения задач, который является одним из наиболее мощных и в то же самое время одним из наименее понимаемых студентами, начинающими изучать программирование. Мы определим рекурсию, рассмотрим несколько примеров и покажем, как рекурсия может быть эффективным методом решения задач. В некоторых языках программирования, которые являются более мощными, чем язык Бейсик, рекурсия реализуется как часть самого языка. Но в языке Бейсик это не так. Мы, следовательно, определим, как рекурсивные алгоритмы могут быть реализованы в языке Бейсик с помощью стеков. И наконец, мы обсудим преимущества и недостатки использования рекурсии при решении задач. 5.1. РЕКУРСИВНОЕ ОПРЕДЕЛЕНИЕ И ПРОЦЕССЫ Многие объекты в математике определяются при помощи представления некоторого процесса, чтобы породить данный объект. Например, число π определяется как отношение длины окружности некоторого круга к его диаметру. Это эквивалентно следующему множеству команд: возьмите окружность некоторого круга и его диаметр, разделите первую величину на вторую и результат обозначьте как число π. Ясно, что в результате заданного процесса действий получится некоторый определенный результат. Функция вычисления факториала Другим примером определения, заданного при помощи некоторого процесса, является функция вычисления факториала — некоторая функция, которая играет важную роль в математике и статистике. Если задано некоторое положительное целое число п, то факториал η определяется как произведение всех целых чисел от 1 до п. Например, факториал 5 равен 5*4*3*2*1 = 120, а факториал 3 равен 3*2*1=6. Факториал 0 определяется как 1. В математике для определения функции вычисления факториа- 236
ла часто используется восклицательный знак (!). Мы можем, следовательно, написать определение этой функции в следующем виде: п! = 1, если η - О, η!=η*(η-1)»(η-2)»...»1, если п>0. Отметим, что три точки являются некоторым сокращенным обозначением произведения всех чисел от η—3 до 2 (в предположении, что п>5). Для того чтобы избежать этого сокращения в определении п!, нам пришлось бы выписывать некоторую формулу для п! отдельно для каждого значения п, а именно в следующем виде: 0! = 1 1! = 1 2! = 2*1 31 = 3*2*1 4! = 4*3*2*1 Конечно, мы не можем надеяться выписать формулу вычисления факториала для каждого целого числа. Для того чтобы избежать любых сокращений и избежать бесконечного множества определений и все же точно определить данную функцию, мы можем представить некоторый алгоритм, который берет некоторое целое число η и вычисляет значение п! в некоторой переменной fact: χ=η prod=l while x>0 do prod = x* prod x=x—1 endwhile fact=prod return Такой алгоритм называется итерационным, поскольку οι- требует явного повторения некоторого процесса до тех пор, покг не удовлетворяется некоторое конкретное условие. Такой алго ритм можно представить как некоторую программу на «идеаль ной» машине без каких-либо практических ограничений реально!* ЭВМ, и, следовательно, он может быть использован для опре деления некоторой математической функции. Хотя приведенные выше алгоритм может быть просто представлен как некоторая подпрограмма на языке Бейсик, эта подпрограмма не можег служить в качестве определения функции вычисления факторна ла из-за таких ограничений, как точность и конечный разме] некоторой реальной машины. Давайте посмотрим более внимательно на определение п! ц котором выписана отдельная формула для каждого значения г Мы можем заметить, например, что 4! равно 4*3*2*1, что ι 237
свою очередь равно 4*3!. В действительности для любого п>0 мы видим, что п! равен п*(п—1)!. Умножение η на произведение всех целых чисел от 1 до η—1 дает произведение всех целых чисел от 1 до п. Мы можем, следовательно, определить 0! = 1 1! = 1*0! 2! = 2*1! 3! = 3*2! 4! = 4*3! или, используя математическое обозначение, введенное ранее, η 1 = 1 если п=0, η! = η*(η — 1)!, если п>0. Это определение может показаться несколько странным, поскольку оно определяет функцию вычисления факториала в терминах ее же самой. Кажется, что это определение является циклическим и что оно полностью неприемлемо до тех пор, пока мы не поймем, что данное математическое обозначение является просто точным способом записи бесконечного числа определений, необходимых для того, чтобы определить п! для каждого п. Факториал 0! определяется непосредственно как 1. Поскольку О! уже был определен, определение 1! как 1*0! совсем не является циклическим. Аналогичным образом, когда был определен 1!, определение 2! как 2*1! является в такой же степени правомочным. Может быть доказано, что последнее обозначение является более точным, чем определение п! как п*(п—1 )*...! для п>0 потому, что оно не содержит трех точек, смысл которых должен быть понят читателем в надежде на его логическую интуицию. Такое определение, которое задает некоторый объект в терминах некоторого более простого случая этого же объекта, называется рекурсивным определением. Давайте посмотрим, как рекурсивное определение функции вычисления факториала может быть использовано для вычисления 5!. Определение гласит, что 5! равно 5*4!. Таким образом, прежде чем вычислить 5!, мы должны сперва вычислить 4!. Используя данное определение еще раз, находим, что 4! = 4*3L Следовательно, мы должны вычислить 3!. Повторяя этот процесс, имеем 1. 5!- 5*4! 2. 4!- 4*3! 3. 3!= 3*2! 4. 2!= 2*1! 5. 1!= 1*0! 6. 0! = 1 Каждый шаг сводится к некоторому более простому случаю до тех пор, пока мы не достигнем случая 01, который, конечно-, 238
равен К В строке 6 мы имеем некоторое значение, которое определяется непосредственно, а не как факториал другого числа. Мы мо*кем, следовательно, вернуться от строки 6 к строке 1, используя значение, вычисленное в одной строке, для того чтобы вычисли^ результат предыдущей строки. Это дает нам 6'. 5'. 4'. 3'. 2'. Г. 0! = 1 1! = 1»0!=1»1 = 1 2! = 2*1!=2*1 = 2 3! = 3*2! = 3*2=6 4!=4*3!=4*6=24 5! = 5*4! = 5«24=120 Давайте попытаемся представить этот процесс в виде некоторого алгоритма. И снова мы хотим, чтобы данный алгоритм брал некоторое неотрицательное целое число η в качестве входной информации и выдавал в некоторой переменной fact неотрицательное число, которое равно факториалу числа п!: 1. ifn=0 2. then fact =1 3. return 4. else 5. x=n—1 6. найти значение х! и назвать его у. 7. fact=n»y 8. return 9. endif Этот алгоритм отображает процесс, используемый для вычисления п! по рекурсивному определению. Ключевой для данного алгоритма является, конечно, строка 6, в которой нам говорят «найти значение х!>. Мы можем рассматривать этот шаг как временную приостановку выполнения данного алгоритма при входном значении η на машине, которую мы в данный момент используем, и затем инициацию выполнения того же самого алгоритма на некоторой другой машине с χ в качестве входного параметра (т. е. переменной η присваивается значение χ на второй машине до того, как начнется выполнение данного алгоритма). В процессе вычисления факториала числа χ вторая машина может обратиться еще к некоторой третьей машине и т. д. В конце концов, вторая машина завершит свою задачу. Когда она это сделает, она вычислит результат факториала числа χ и затем пошлет этот результат назад в первую машину. Первая машина, установив в у данное полученное значение, и продолжит вычисления. Для того чтобы понять, что этот процесс в конце концов прекратится, следует отметить, что в начале строки 6 х равнялся η—1. Каждый раз, когда данный алгоритм выполняется на некоторой другой машине, вход в него на единицу меньше, чем в предыдущий раз, так что в конце концов в качестве входа будет 0 (поскольку первоначальное входное зна- 239
чение п было некоторым неотрицательным целым /шелом). В этот момент данный алгоритм будет просто устанавливать значение 1 в переменную fact. Это значение выдаемся в некоторую предыдущую машину в строке 6, которая просила вычислить 0!. На этой предыдущей машине затем/выполняется умножение у (=1) на η ( = 1) и выдается результат. Эта последовательность умножений и возвратов продолжается до тех пор, пока не будет вычислено первоначальное значение п!. Конечно, предположение о наличии произвольного числа машин для вычисления некоторой, по-видимому, простой проблемы является и непрактичным, и нереалистичным. В следующем разделе мы рассмотрим, как преобразовать этот процесс в некоторую программу на языке Бейсик, которая может быть просчитана на одной машине. Отметим, что намного проще и понятнее использовать итерационный метод для вычисления функции вычисления факториала. Мы представляем рекурсивный метод как некоторый простой пример для введения рекурсии, а не как некоторый более эффективный метод решения этой конкретной проблемы. В самом деле, все проблемы в первой части этого раздела могут быть решены более эффективно при помощи итераций. Однако позднее в этом разделе и в последующих главах мы встретим примеры, которые решаются проще при помощи рекурсивных методов. Перемножение натуральных чисел Другим примером рекурсивного определения является определение умножения натуральных чисел. Произведение а*Ь, где а и b являются положительными целыми числами, может быть определено как а, прибавленные к самому себе b раз. Это некоторое итерационное определение. Эквивалентное ему рекурсивное определение состоит в следующем: а*Ь=а, если Ь=1, a*b=a*(b—1)+а, еслиЬ>1. Для того чтобы вычислить 6*3 по этому определению, мы должны сперва вычислить 6*2 и затем прибавить 6. Для того чтобы вычислить 6*2, мы должны сперва вычислить 6*1 и добавить 6. Но 6*1 равно 6 согласно первой части данного определения. Таким образом, 6*3=6*2+6= 6*1 + 6+6=6 + 6+6=18. Читателю предлагается преобразовать данное выше определение в некоторый рекурсивный алгоритм в качестве простого упражнения. Отметим характерные черты, которые присутствуют в рекурсивных определениях. Простой случай определяемого термина 240
задаете^ явно (в случае вычисления факториала 0! определялся как 1, в о^учае перемножения а*1 определялось как а). Другие случаи определяются при помощи применения некоторой операции к результату вычисления более простого случая. Так, п! определяется в терминах (п—l)!v a a*b — в терминах а*(Ь—1). Последовательные упрощения любого конкретного случая должны в конце концов привести к явно определенному тривиальному случаю. В случае функции вычисления факториала последовательное вычитание 1 из η дает в конце концов 0. В случае перемножения последовательное вычитание 1 из b дает в конце концов 1. Если бы этого не произошло, то данное определение было бы неверным. Например, если бы мы определили n!=(n+l)!/(n+l) или a*b = a*(b+l)—a мы бы были не в состоянии определить значения 5! или 6*3. (Читателю предлагается сделать попытку определить эти значения, используя вышеприведенные определения.) Это справедливо, несмотря на тот факт, что эти два уравнения математически верны. Непрерывное добавление 1 к η или к b не дает никакого явно определенного случая, Даже если бы 100! было явно определено, то как бы могло быть определено значение 101!? Последовательность Фибоначчи Давайте рассмотрим некоторый менее знакомый пример. Последовательность Фибоначчи является последовательностью целых чисел 0, 1, 1,2,3, 5,8, 13, 21,34,... Каждый элемент в этой последовательности после первых двух элементов является суммой двух предшествующих элементов (например, 0+1 = 1, 1 + 1 = 2, 1+2 = 3, 2 + 3 = 5, ...). Если мы положим fib(0) =0, fib(l) = l и т. д., то можем определить последовательность Фибоначчи при помощи следующего рекурсивного определения: fib(n)=n если п = 0 или 1 fib(n)=fib(n-2)+fib(n-l) если п>=2 Для того чтобы вычислить fib (6), например, мы можем применить данное определение рекурсивно и получим fib (6) = fib (4) +f ib (5) = fib (2) +f ib (3) +f ib (5) = fib (0) +f ib (1) +f ib (3) +f ib (5) = 0+l+fib(3)+fib(5) = l+fib(l)+fib(2)+fib(5) = l + l+fib(0)+fib(l)+fib(5) = 2+0+l+fib(5) = 3+fib(3)+fib(4) 241
= 3+fib(l)+fib(2)+fib(4) = 3+l+fib(0)+fib(l)+fib(4) = 4+0+l+fib(2)+fib(3) = 5+fib(0)+fib(l)+fib(3) = 5+0+l+fib(l)+fib(2) = 6+l+fib(0)+fib(l) =7+0+1-8 Заметим, что рекурсивное определение чисел Фибоначчи отличается от рекурсивных определений функции вычисления факториала и перемножения. Рекурсивное определение fib ссылается на само себя дважды. Например, fib (6) == fib (4) + fib (5), так что при вычислении fib (6) функция fib должна быть применена рекурсивно дважды. Однако часть вычисления fib (5) включает определение fib (4), так что в реализации данного определения происходят большие избыточные вычисления. В приведенном выше примере fib (3) вычислялось отдельно три раза. Было бы намного более эффективным «запомнить» значение fib (3) в первый раз, когда оно было вычислено, и повторно его использовать каждый раз, когда это необходимо. Некоторый итерационный метод вычисления fib(n), такой как приведенный далее, является намного более эффективным (результат помещается в переменную fib): if n<=l then fib=n else lofib=0 hifib—1 for i«2 to η x=lofib lofib=hifib hifib=x+lofib next i fib—hifib endif return Существенно, что этот алгоритм последовательно вычисляет все числа Фибоначчи в переменной hifib. Сравните число сложений (не считая увеличения индекса переменной i), которые выполняются при вычислении fib (6) при помощи этого алгоритма и использования рекурсивного определения. В случае функции вычисления факториала одинаковое число умножений должно быть совершено при вычислении п! при помощи рекурсивного и итерационного методов. То же самое справедливо для числа сложений в двух методах вычисления перемножений. Однако в случае с числами Фибоначчи рекурсивный метод значительно более дорогостоящий, чем его итерационный вариант. Бинарный поиск Читатель, возможно, получил неверное представление, что рекурсия является очень удобным инструментом для определения математических функций, но не имеет никакого приклад- 242
ного значения в практических областях вычислительной науки.. Следующие пример проиллюстрирует некоторое применение рекурсии к одному из наиболее общих практических приложений вычислительной науки — к поиску. Рассмотри^ некоторый массив элементов, в котором объекты, были помещены в некотором порядке. Например, некоторый словарь или телефонную книгу можно представить как некоторый массив, чьи элементы расположены в алфавитном порядке. Некоторый файл заработной платы служащих фирмы может быть упорядочен по номерам социального страхования служащих. Предположим, что существует такой массив и мы хотим найти в нем некоторый конкретный элемент. Например, мы хотим обнаружить некоторую фамилию в телефонной книге, некоторое слово в словаре или некоторого конкретного служащего в файле персонала. Процесс, который используется для того,, чтобы найти такой элемент, называется поиском. Поскольку поиск является настолько общим приложением в вычислениях,, желательно найти для его выполнения некоторый эффективный метод. Возможно, самым грубым методом поиска является последовательный, или линейный поиск, при котором каждый элемент массива проверяется по очереди и сравнивается с тем элементом, который ищется до тех пор, пока не произойдет совпадение. Если построение данного списка является неупорядоченным, или случайным, то линейный поиск может быть единственным способом что-нибудь в нем найти (если только, конечно, данный список сперва не переупорядочивается). Однако» такой метод никогда бы не использовался при поиске некоторой фамилии в телефонной книге. Напротив, данная книга открывается на некоторой произвольной странице и проверяются фамилии на этой странице. Поскольку фамилии упорядочены в алфавитном порядке, такая проверка обнаружила бы, следует ли продолжать поиск в первой или второй части данной книги. Давайте применим эту идею к поиску в некотором массиве. Если данный массив содержит только один элемент, то проблема является тривиальной. В противном случае сравним элемент,, который ищется, с элементом в середине данного массива. Если они равны, то поиск успешно закончен. Если средний элемент больше, чем элемент, который ищется, то поиск повторяется в первой половине данного массива (поскольку, если данный элемент вообще где-нибудь появится, он появится в первой половине). В противном случае данный процесс повторяется во второй половине. Отметим, что каждый раз, когда делается некоторое сравнение, число оставшихся элементов, среди которых будет организовываться поиск, делится пополам. Для больших массивов этот метод превосходит последовательный поиск,. в котором каждое сравнение уменьшает число оставшихся элементов, среди которых будет организовываться поиск, только на единицу. Из-за деления массива, в котором организуется 243
поиск, на две равные части этот метод поиска называется бинарным поиском. Отметим, что мы достаточно естественно определили бинарный поиск рекурсивно. Если элемент, который ищется, не равен среднему элементу в массиве, то дальнейшие действия состоят в том, чтобы искать в некотором подмассиве, исйользуя тот же самый метод. Таким образом, данный метод поиска определяется в терминах самого себя с некоторым массивом меньшего размера в качестве входа. Мы уверены, что данный процесс закончится, потому что входные массивы становятся все меньше и меньше, а поиск в массиве с одним элементом может быть сделан нерекурсивно, поскольку данный средний элемент такого массива является его единственным элементом. Мы теперь представим некоторый рекурсивный алгоритм для поиска некоторого элемента χ между элементами a (low) и a (high) в некотором отсортированном массиве а. Данный алгоритм помещает в переменную binsrch некоторый индекс массива а, такой что a (binsrch) =х, если такой индекс существует между low и high. Если χ не найден в этой части массива, то в переменную binsrch устанавливается 0. (Мы предполагаем, что low больше нуля.) 1. if low>high 2. then binsrch=0 3. return 4. endif 5. mid=int((low+high)/2) 6. if x=a(mid) 7. then binsrch=mid 8. else if x<a(mid) 9. then искать х в диапазоне от a (low) до a (mid—1) 10. else искать χ в диапазоне от a(mid+l) до a (high) 11. endif 12. endif 13. return Поскольку не исключается возможность неуспешного поиска (т. е. данный элемент может не существовать в этом массиве), то тривиальный случай был несколько изменен. Поиск в массиве с одним элементом (когда low=high) не определяется непосредственно как данный соответствующий индекс. Напротив, этот элемент (элемент a (mid), где mid = low=high) сравнивается с элементом, который ищется. Если эти два элемента не равны, то поиск продолжается в «первой» или «второй» половине, каждая из которых не содержит элементов. Этот тривиальный случай отсутствия элементов указывается при помощи условия low>high, и его результат определяется непосредственно как 0. Давайте применим этот алгоритм к некоторому примеру. Предположим, что массив а содержит элементы 1, 3, 4, 5, 17, 18, 31, 33 в указанном порядке и мы хотим найти элемент 17 244
(т. е. п*=17) между первым элементом и восьмым элементом (т. е. low=l, high = 8). Применяя данный алгоритм, мы получим Строка 1: low>high? Это не верно, так что продолжаем со строки 5. Строка 5: mid=int((l+8)/2)=4. Строка б: х=а(4)? 17 не равно 5, так что выполняем оператор else. Строка 8: х<а(4)? 17 не меньше чем 5, так что выполняем оператор else в строке 10. Строка 10: Повторяем данный алгоритм с low=mid+l=5 и high=high=8 (т. е. организуем поиск в верхней половине данного массива). Строка 1: 5>8? Нет, так что продолжаем со строки 5. Строка 5: mid=int((5+8)/2)=6. Строка б: х=а(б)? 17 не равно 18, так что выполняем оператор else. Строка 8: х<а(6)? Да, поскольку 17<18, так что выполняется оператор then. Строка 9: Повторяем данный алгоритм с low = low=5 и high= =mid—1=5. Мы изолировали χ между пятым и пятым элементами массива а. Строка 1: 5>5? Нет, так что продолжаем со строки 5. Строка 5: mid=int((5+5)/2)=5. Строка 6: Поскольку а (5) = 17, то в переменную binsrch устанавливается 5. 17 в действительности является пятым элементом данного массива. Отметим картину вызовов и возвратов в данном алгоритме. На рис. 5.1.1 представлена диаграмма, отслеживающая эту картину. Сплошные стрелки указывают на передачи управления Вход Строка I Строка 5 Строка 6 Строка 8 Выход I I I С— Ответ Строка 1 Строка 5 Строка 6 Строка 8 Строка 1 Строка 5 Строка 6 Ответ найден U- Ответ Рис. 5.1.1. Диаграмма, представляющая алгоритм бинарного поиска. 245
в алгоритме и рекурсивные вызовы. Штриховые линии указы· вают на возвраты. Давайте рассмотрим, как данный алгоритм работает при поиске некоторого элемента, которого нет в массиве. Предположим, что массив а является таким же, как и в предыдущем примере, и предположим, что мы ищем элемент χ = 2. Строка 1: low>high? 1 не больше чем 8, так что продолжаем со строки 5. Строка 5: mid=int((l+8)/2)=4. Строка 6: х=а(6)? 2 не равно 5, так что выполняется оператор else. Строка 8: х<а(б)? Да, 2<5, так что выполняется оператор then. Строка 9: Повторяем данный алгоритм с low=low=l и high = =mid—1=3. Если 2 окажется в данном массиве, то оно должно появиться между а(1) и а(3) включи· тельно. Строка 1: 1>3? Нет, продолжаем со строки 5. Строка 5: mid=int((l+3)/2)=2. Строка 6: 2=а (2)? Нет, выполняется оператор else. Строка 8: 2<а(2)? Да, поскольку 2<3. Выполняется оператор then. Строка 9: Повторяем данный алгоритм с low=low=l и high = =mid—1 = 1. Если χ существует в массиве а, то он должен быть первым элементом. Строка 1: 1>1? Нет, продолжаем со строки 5. Строка 5: mid-int((l + l)/2)-l. Строка 6: 2=а(1)? Нет, выполняется оператор else. Строка 8: 2<а(1)? 2 не меньше чем 1, так что выполняется оператор else. Строка 10: Повторяется данный алгоритм с low=mid+l=2 и high=high=l. Строка 1: Jow>high? 2 больше чем 1, так что binsrch равно 0. Элемент 2 не существует в данном массиве. Этот пример иллюстрирует значение рекурсивных методов при решении задач. Хотя рекурсивное решение может быть более дорогостоящим, чем некоторое итерационное решение, часто проще обнаружить рекурсивное решение при помощи идентификации некоторого тривиального случая и формулирования решения некоторого сложного случая в терминах одного или нескольких более простых случаев. Когда рекурсивное решение сформировано, некоторый рекурсивный алгоритм может быть создан достаточно естественно. Как мы увидим в следующем разделе, программа для такого рекурсивного алгоритма може*г быть разработана путем использования нескольких простых методов. Хотя такая программа получается достаточно сложной, она часто может быть упрощена, для того чтобы получить некоторое более эффективное итерационное решение. В следующем разделе мы рассмотрим, как реализовать некоторый рекурсивный алгоритм в виде программы на языке Бейсик и как упростить эту программу впоследствии. В данный момент, однако, давайте рассмотрим еще один пример разработки некоторого решения проблемы при помощи использования рекурсии. 246
Задача «Ханойские башни» Давайте посмотрим на другую проблему, которая может <быть решена логично и элегантно при помощи использования рекурсии. Это задача «Ханойские башни», начальная позиция которой показана на рис. 5.1.2. Имеется три колышка — А, В и С. На колышек А помещены четыре диска с различными диаметрами так, что больший диск всегда находится ниже меньшего диска. Задача состоит в том, чтобы переместить за некоторую последовательность шагов эти четыре диска на колышек С, используя колышек В как вспомогательный. За один шаг может перемещаться с одного произвольного колышка на другой только верхний диск, и больший диск никогда не может располагаться над меньшим диском. Посмотрим, сможем ли мы построить решение. В самом деле, даже не очевидно, что существует некоторое решение этой задачи. г j_ Рис. 5.1.2. Начальная позиция в задаче «Ханойские башни». Давайте поймем, можем ли мы создать некоторое решение. Вместо того чтобы фокусировать наше внимание на решении для четырех дисков, рассмотрим общий случай с η дисками. Предположим, что у нас есть некоторое решение для η—1 дисков и мы можем сформулировать решение для η дисков в терминах данного решения для η—1 дисков. Тогда эта проблема была бы решена. Это верно, поскольку в тривиальном случае одного диска решение является простым (непрерывно вычитая 1 из п, мы в конце концов получим 1). Надо просто переместить единственный диск с колышка А на колышек С. Следовательно, мы создадим некоторое рекурсивное решение, если сможем сформулировать решение для η дисков в терминах η—1 дисков. Посмотрим, сможем ли мы найти такое соответствие. В конкретном случае для четырех дисков предположим, что мы знаем, как переместить верхние три диска с колышка А на другой колышек, не нарушая правил. Как бы мы смогли затем завершить данную работу по перемещению всех четырех дисков? Вспомним, что имеется три колышка. Предположим, что мы смогли переместить три диска с колышка А на колышек С. Тогда мы могли бы также просто 247
переместить их на колышек В, используя С как вспомогательный. Это дало бы в результате ситуацию, изображенную на рис. 5.1.3, а. Мы могли бы затем переместить самый большой диск с колышка А на колышек С (рис. 5.1.3,6). Таким образом, мы можем установить некоторое рекурсивное решение задачи «Ханойские башни» в следующем виде. JL а в 6 в ( с ( ( ) ) ) ) Рис. 5.1.3. Рекурсивное решение задачи «Ханойские башни». Для того чтобы переместить η дисков с колышка А на колышек С, используя колышек В как вспомогательный, необходимо: 1. Если п=1, переместить единственный диск с колышка А на колышек С и остановиться. 2. Переместить верхние η—1 дисков с колышка А на колышек В, используя колышек С как вспомогательный. 3. Переместить оставшийся диск с колышка А на колышек С. 4. Переместить η—1 дисков с колышка В на колышек С, используя колышек А как вспомогательный. 248
Мы уверены, что этот метод даст некоторое корректное решение для любого значения п. Если п=1, шаг 1 даст в результате корректное решение. Если η = 2, то мы знаем, что уже имеем решение для η—1 = 1, так что шаги 2 и 4 будут выполняться правильно. Аналогичным образом, когда п = 3, мы уже получили некоторое решение для η—1 = 2, так что шаги 2 и 4 могут быть выполнены. Таким же образом мы можем показать, что данное решение работает для п=1, 2, 3, 4, 5, ... вплоть до любого значения, для которого нам нужно некоторое решение. Отметим, что мы создали данное решение при помощи описания некоторого тривиального случая (п=1) и некоторого решения для общего сложного случая (п) в терминах более простого случая (п—1). Как это решение может быть преобразовано в некоторый алгоритм? Мы больше не имеем дела с некоторой математической функцией, такой как факториал, а имеем дело с некоторыми конкретными действиями, такими как «переместить некоторый диск». Как мы должны представить такие действия алгоритмически? Проблема не является полностью специфицированной. Какая информация вводится? Что должно выводиться? Когда разработчика программы просят написать некоторый алгоритм, он должен получить конкретные инструкции о том, что в точности ожидается от этого алгоритма. Такое описание проблемы, как «Решить задачу «Ханойские башни»», совсем недостаточно. Когда специфицируется такая задача, то обычно имеется в виду, что должны быть указаны не только сам алгоритм, но и входная и выходная информация, причем таким образом, чтобы ее представление соответствовало описанию данной проблемы. Создание входных и выходных параметров является важной фазой некоторого решения, и на это надо обращать столько же внимания, сколько и на саму программу, по двум следующим причинам. Первая состоит в том, что пользователь (который должен, в конце концов, оценить данную работу и составить некоторое суждение о ней) не будет видеть элегантного метода, который разработчик вставил в свой алгоритм, но будет бороться изо всех сил, чтобы расшифровать выходные данные или адаптировать входные данные к конкретным представлениям в данной программе. Отсутствие раннего соглашения о деталях входной и выходной информации часто бывает причиной страданий и программистов, и пользователей. Вторая причина заключается в том, что небольшое изменение в формате ввода или вывода может упростить разработку алгоритма. Теперь займемся определением входных и выходных параметров для этого алгоритма. На первый взгляд кажется, что единственным необходимым входным параметром является значение η — число дисков. Разумной формой для вывода была бы некоторая последовательность таких предложений, как 249
«переместить диск nnn с колышка ууу на колышек zzz»^ где nnn является номером диска, который должен быть перемещен, а ууу и zzz являются именами задействованных колышков. Решением тогда была бы последовательность действий по* выполнению каждого выводного предложения в том порядке,, в котором оно появляется при выводе. Программист затем решает написать некоторый алгоритм towers (сознательно не заботясь в этот момент о входных параметрах) для того, чтобы напечатать выходные предложения^ приведенные выше. Данный алгоритм вызывался бы при помощи towers (inputs) Предположим, что пользователь был бы удовлетворен, если диски назвать 1, 2, 3, ..., п, а колышки — А, В и С. Какие должны быть входные переменные для алгоритма towers? Ясно,, что они-должны включать η — число дисков, которые должны быть перемещены. Это не только включает информацию о том,, сколько имеется дисков, но также и о том, какие у них имена. Программист затем заметит, что в рекурсивном алгоритме необходимо переместить η—1 дисков, используя некоторое рекурсивное обращение к towers. Таким образом, при рекурсивном обращении первой входной переменной towers будет η—1. Но» это предполагает, что верхние η—1 дисков нумеруются 1, 2,. 3, ..., η—1 и самый маленький диск нумеруется как 1. Это хороший пример удобства программирования, определяющего» представление задачи. Нет никакой причины apriori обозначать самый маленький диск как 1. С точки зрения логики самый большой диск мог бы быть обозначен 1, а маленький — п. Однако мы выберем такое обозначение дисков, при котором самый маленький диск имеет самое маленькое число, поскольку это ведет к более простому и непосредственному подходу к задаче. Какие другие входные переменные нужны для towers? На первый взгляд может показаться, что дополнительные входные переменные не нужны, поскольку колышки обозначаются по умолчанию А, В и С. Однако более пристальный взгляд на рекурсивное решение приведет нас к пониманию, что при рекурсивных вызовах диски будут перемещаться не с колышка А на С, используя колышек В как дополнительный, а с колышка А на В, используя колышек С как промежуточный (шаг 2), или с колышка В на С, используя колышек А (шаг 4). Мы, следовательно, включим в towers три дополнительные входные переменные. Первая (source) представляет тот колышек, с которого мы перемещаем диски. Вторая (dest) представляет тот колышек, на который мы будем перемещать диски. И третья (aux)1 представляет вспомогательный колышек. Эта ситуация является достаточно типичной для рекурсивных подпрограмм. Дополнительные входные переменные необходимы для того, чтобы обрабатывать ситуацию с рекурсивным вызовом. Мы уже виде- 250
ли один пример этого в алгоритме бинарного поиска, где переменные low и high были необходимы, несмотря на тот факт, что у первоначального вызова переменная low всегда равна 1, а переменная high равна размеру массива, в котором осуществляется поиск. Таким образом, наша конкретная задача «Ханойские башни» была бы решена при помощи вызова towers (4, «А», «С», «В») Полный алгоритм для решения задачи «Ханойские башни», практически соответствующий первоначальному рекурсивному решению, может быть записан следующим образом: subroutine towers (n, source, dest, aux) 'первоначально в нашем примере source равен A, dest равен С и aux 'равен В 'если только один диск, то сделать перемещение и вернуться if n=l then print «переместить диск 1 с колышка»; source; «на колышек»; dest return endif 'переместить верхние η—1 дисков с А на В, используя С как вспомогательный колышек lowers (n—1, source, aux, dest) 'переместить оставшийся диск с А на С print «переместить диск»; п; «с колышка»; source; «на колышек; dest 'переместить п—1 дисков с В на С, используя А как вспомогательный 'колышек towers (n — 1, aux, dest, source) return Проследим действия вышеприведенного алгоритма, когда в него вводятся значение 4 для переменной п, «А» для source, «С» для dest и «В» для aux. Следует внимательно отслеживать изменяющиеся значения входных переменных source, aux и dest. Проверим, что алгоритм дает правильный вывод: переместить диск 1 с колышка А на колышек В переместить диск 2 с колышка А на колышек С переместить диск 1 с колышка В на колышек С переместить диск 3 с колышка А на колышек В переместить диск 1 с колышка С на колышек А переместить диск 2 с колышка С на колышек В переместить диск 1 с колышка А на колышек В переместить диск 4 с колышка А на колышек С переместить диск 1 с колышка В на колышек С переместить диск 2 с колышка В на колышек А переместить диск 1 с колышка С на колышек А переместить диск 3 с колышка В на колышек С переместить диск 1 с колышка А на колышек В переместить диск 2 с колышка А на колышек С переместить диск 1 с колышка В на колышек С Проверьте, что приведенное выше решение действительно работает и не нарушает какое-либо из правил. 251
Свойства рекурсивных определений и алгоритмов Давайте суммируем, из чего состоит рекурсивное определение или алгоритм. Одно важное требование того, чтобы некоторый рекурсивный алгоритм был правильным, состоит в том, чтобы он не создавал бесконечную последовательность вызовов самого себя. Ясно, что любой алгоритм, который в действительности генерирует такую последовательность, никогда не закончится. По крайней мере для одной входной переменной или группы входных переменных некоторый рекурсивный процесс ρ должен быть определен в терминах, которые не содержат р. Должен быть некоторый «выход наружу» из последовательности рекурсивных вызовов. В примерах этого раздела нерекурсивные части в определениях были следующие: Факториал: 0! = 1 Перемножение: а*1 = а Последовательность Фибоначчи: fib(0)=0, fib(l) = l Бинарный поиск: if low>high then binsrch = 0 if x=a(mid) then binsrch=mid Ханойские башни: if n=l then print «переместить диск 1 с колышка»; source; «на колышек»; dest Без такого нерекурсивного выхода не может быть вычислена никакая рекурсивная функция. Второй составляющей некоторого рекурсивного определения или алгоритма является возможность представить некоторый сложный случай в терминах более простого случая. В примерах этого раздела этими представлениями были такие случаи: Факториал: п! = п*(п—1)! для п>0 Перемножение: a*b=a*(b — l)+a для Ь>1 Последовательность Фибоначчи: fib(n) =fib(n— l)+fib(n—2) для n>=2 Бинарный поиск: искать χ в диапазоне от a (low) до a (mid— 1) для x<a(mid) искать χ в диапазоне от a(mid+l) до a (high) для x>a(mid) Ханойские башни: towers (η—1, source, aux, dest) и towers (η —1, aux, dest, source) для п>1 Любое применение некоторого рекурсивного определения или вызов некоторого рекурсивного алгоритма должны содержать общее представление сложного случая в терминах некоторого более простого случая, и оно должно в конце концов сводиться к некоторым манипуляциям с одним или несколькими простыми нерекурсивными случаями. Упражнения 1. Напишите итерационный и рекурсивный алгоритмы для вычисления а*Ь, используя сложение, где а и b являются неотрицательными целыми числами. 2. Пусть а будет некоторым массивом целых чисел. Представить рекурсивные алгоритмы для вычисления: а) максимального элемента в данном массиве; б) минимального элемента в данном массиве; в) суммы элементов данного массива; г) произведения элементов данного массива; д) среднего значения для элементов данного массива. 252
3. Вычислите следующие примеры, используя и итерационные и рекурсивные определения: (а) 6! (б) 9! (в) 100»3 (г) 6*4 (д) fib(10) (e) fib(11) 4. Предположим, что некоторый массив из 10 целых чисел содержит следующие элементы: 1, 3, 7, 15, 21, 22, 36, 78, 95, 106. Используйте рекурсивный бинарный поиск для того, чтобы найти следующие элементы в данном массиве (если они существуют): (а) 1; (б) 20; (в) 36; (г) 200. 5. Напишите некоторую итерационную версию алгоритма бинарного поиска. (Указание. Изменяйте прямо значения low и high.) 6. Функция Аккермана определяется рекурсивно для неотрицательных целых чисел следующим образом: aim,n)=n+l, если т=0; а(т, п)=а(т—1,1), если т< >0, п=0; а(т, п)=а(т— 1,а(т, п— 1)), если т< >0, п< >0. (а) Используя приведенное выше определение, покажите, что а (2, 2) =7. (б) Докажите, что а(т, п) определено для всех неотрицательных целых чисел тип. (в) Можно ли найти некоторый итерационный метод вычисления а(т, п)? 7. Подсчитайте число сложений, необходимых для вычисления fib(n) для 0< = п< = 10 при помощи итерационного и рекурсивного методов. Выясняется ли некоторая закономерность? 8. Если некоторый массив содержит η элементов, то каким является максимальное число рекурсивных вызовов, сделанных алгоритмом бинарного поиска? 9. Разработайте рекурсивные алгоритмы для того, чтобы а) найти сумму всех целых чисел в некотором связанном линейном списке; б) обратить некоторый связанный линейный список так, чтобы первый элемент стал последним, второй — предпоследним и т. д. 5.2. РЕАЛИЗАЦИЯ РЕКУРСИВНЫХ АЛГОРИТМОВ НА ЯЗЫКЕ БЕЙСИК В этом разделе мы исследуем механизмы, используемые для реализации рекурсии. Некоторые языки программирования (такие как Алгол, Паскаль и ПЛ/1) позволяют программам иметь рекурсии, так что некоторая подпрограмма может вызвать саму себя. Другие языки (такие как Бейсик, Фортран и Кобол) не имеют встроенной рекурсии как некоторого механизма соответствующего языка. Следовательно, для того чтобы реализовать некоторое рекурсивное решение в таком языке программирования, необходимо промоделировать механизмы реализации рекурсии, используя нерекурсивные методы. Такая задача, как «Ханойские башни», чье решение может быть получено и записано достаточно просто при использовании рекурсивных методов, может быть запрограммирована на этих языках путем моделирования данного рекурсивного решения с использованием более элементарных операций. Если мы знаем, что данное рекурсивное решение правильное (а доказать, что такое решение правильно, часто бывает достаточно просто), и установили метод преобразования некоторого рекурсивного решения в некоторое нерекурсивное, то можем создать некоторое правильное 253
решение в нерекурсивном языке программирования. Для программиста не является чем-то необычным задание решения некоторой задачи в форме рекурсивного алгоритма. Возможность создания нерекурсивного решения по этому алгоритму является •совершенно необходимой, если используется язык программирования, который не поддерживает рекурсию. Рассмотрим более подробно рекурсивный алгоритм для -функции вычисления факториала, для того чтобы определить, почему он не может быть непосредственно реализован на языке Бейсик. Повторяем алгоритм для этого процесса: 1. ifn=0 2. then fact=l 3. return 4. else 5. x=n— 1 6. найти значение х! и назвать его у. 7. fact=n*y 8. return 9. endif Представляя этот алгоритм в разд. 5.1, мы описывали его работу как временную его приостановку на данной вычислительной машине, когда он достигнет строки 6 (рекурсивное обращение), и как начало работы с входной переменной η на ?второй машине, инициализированной по значению χ на первой машине. Причина такого концептуального подхода заключается в том, что в языке Бейсик имеется только одна переменная -с именем п. Следовательно, если бы в η устанавливалось значение χ на первой машине, то старое значение было бы потеряно навсегда. Но когда значение х! будет вычислено, старое значение η снова понадобится (в строке 7) для того, чтобы помножить его на х! и получить значение п!. Следовательно, •одновременно должны храниться различные значения п, т. е. одно значение для каждого рекурсивного обращения. Самый простой способ построения такой концепции — это считать, что каждое рекурсивное выполнение осуществляется на своей собственной машине. В этом случае совершенно очевидно, что надо иметь различные переменные с названием η — одну на каждой машине. Однако отметим, что в любой момент времени мы должны лметь доступ только к одной копии η — той копии, которая существует внутри данного обращения, т. е. только одна из наших -«машин» активна в любой заданный момент времени. Другие машины ожидают, чтобы активная машина завершила свои вычисления факториала и возвратила свой результат. Более того, когда рекурсивное обращение закончилось, значения его переменных больше не требуется. Это описание предполагает использование некоторого стека для того, чтобы хранить последовательно создаваемые переменные. Каждый элемент стека представляет собой новую машину, 254
выполняющую некоторое рекурсивное обращение, и он состоит из переменных данного алгоритма, выполняющегося на данной новой машине. Каждый раз, когда организуется вход в рекурсивную подпрограмму, на вершину стека помещается новое значение ее переменных. Любая ссылка к некоторой переменной в этой подпрограмме проходит через текущую вершину стека. Когда управление возвращается из этой подпрограммы, делается выборка из данного стека, верхушка распределения переменных освобождается и предыдущее распределение становится ||21*|* 3 * * Μ 2 * 4 I I 43* 43* 1 * ι I 1 i ' III» 1 1 1 (а)(первоначально) (о) fact (4) η χ у (в) fad (3) η χ у (г) fact (г) I ' I I ° I * I * I I Μι* Ι ι I о I * Ι Ι ι Ι ο Ι ι Ι [21* 2 1· 2 Μ * 2 Μ И [32· 32* 32* 3 2· 43· 43j· 43* 43* (д) fact (7) >п χ у (е) fact (О) (ж) установитьО! (j)установить!! в У з у 322 43* 4Эб 4 4 ' "lii I 4 I η χ у η χ у η χ у (и)установить 2! {к)установитьЗ! (л) вернуть значение 4! β у в у Рис. 5.2.1. Стек в различные моменты во время выполнения алгоритма- (Звездочка указывает на значение, которое не было инициализировано.) текущей вершиной стека, которая будет использоваться для ссылки на переменные. Это реализует некоторую машину, которая вычислила значение своего факториала, возвратила это значение в предыдущую машину и остановила свое выполнение^, когда предыдущая машина возобновила свое выполнение. Рисунок 5.2.1 содержит серию мгновенных снимков стеков, для переменных η, χ и у по мере того, как продвигается выполнение алгоритма fact. Первоначально стеки являются пу- 255
стыми, как это показано на рис. 5.2.1, а. После первого обращения к fact от вызывающей процедуры ситуация показана на рис. 5.2.1,6, где η=4. Копии переменных χ и у существуют, но они не были инициализированы. Поскольку η не равно 0, то в χ устанавливается цифра 3 и происходит обращение к fact(3) (рис. 5.2.1, в). Новое значение η снова не равно 0, так что в χ устанавливается 2 и происходит обращение к fact (2) (рис. 5.2.1, г). Это продолжается до тех пор, пока η не станет равным 0 (рис. 5.2.1, е). В этот момент значение 1 выдается после обращения к fact(O). Выполнение возобновляется с той точки, в которой было обращение к fact (0), в результате чего выданное значение присваивается копии у, описанной в fact (1). Это иллюстрируется при помощи состояния стека, показанного на рис. 5.2.1, ж, где переменные, распределенные для fact(0), освобождаются иву устанавливается 1. Затем выполняется оператор fact = n*y, в котором перемножаются верхние значения η и у, получается 1, и это значение возвращается как fact (2) (рис. 5.2.1, з). Этот процесс повторяется еще два раза до тех пор, пока наконец значение у в fact (4) не станет равным 6 (рис. 5.2.1, к). Оператор fact = n*y выполняется еще один раз. Произведение 24 выдается в вызывающую программу. Отметим, что каждый раз, когда управление возвращается из некоторой рекурсивной подпрограммы, оно возвращается в точку, которая непосредственно следует за тем местом, откуда произошло данное обращение. Таким образом, рекурсивное обращение к fact (3) возвращает управление в точку присваивания результата у внутри fact (4), а рекурсивное обращение к fact (4) возвращает управление оператору в вызывающей программе или главной программе, из которой оно было вызвано. Отметим, что, когда мы представляли реализацию рекурсии, все три переменные (η, χ и у), используемые в данном рекурсивном алгоритме, находились в стеке. Однако в стек необходимо поместить только п. Для того чтобы понять, почему это так, вспомним основную причину использования стека. Необходимо поместить старое значение η в некоторый стек, потому что это старое значение потребуется после того, как мы вернемся из рекурсивного вызова, в котором значение η переустанавливается. Это иллюстрирует тот факт, что оба следующих условия должны быть справедливыми, для того чтобы требовать помещения некоторой переменной в стек. 1. Данной переменной должно быть присвоено некоторое значение до того, как будет иметь место некоторый рекурсивный вызов. (Переменной η было присвоено такое значение благодаря тому, что она является некоторой входной переменной для данного алгоритма.) 2. Значение, присвоенное до обращения к рекурсии, должно быть использовано внутри данного алгоритма после воз- 256
врата из данного рекурсивного обращения. (Значение п, которое было введено, используется в строке 7.) Для того чтобы определить, должны ли χ и у быть помещены в стек, мы должны проверить, справедливы ли оба эти условия для этих двух переменных. В случае χ условие 1 определенно справедливо. В χ устанавливается некоторое значение в строке 5 до того, как делается рекурсивное обращение в строке 6. Однако условие 2 не справедливо для х. Нигде в строках 7—9 предыдущее значение χ не используется. Следовательно, значение χ может быть модифицировано рекурсивным обращением без посылки в стек старого значения, поскольку последнее никогда снова не используется. В случае у мы имеем обратную ситуацию. В у никогда не присваивается значение до рекурсивного обращения. Он не является входной переменной, и ему не присваивается какое-либо значение в строках 1—5. Следовательно, у не удовлетворяет условию 1 и не должен помещаться в стек. ( В действительности, даже если бы у присваивалось какое-то значение в строках 1—5, его все равно не надо было бы помещать в стек, поскольку он не удовлетворяет также и условию 2. Хотя значение у используется в строке 7, оно является тем значением, которое было присвоено в строке 6 после рекурсивного обращения. Для того чтобы удовлетворить условию 2, значение, которое должно быть использовано после рекурсивного обращения, должно быть тем, которое было присвоено этой переменной до данного обращения.) Для того чтобы проверить тот факт, что переменные χ и у нет надобности помещать в стек, посмотрим действия данного алгоритма для η=4, как это изображено на рис. 5.2.1, но игнорируя стеки для переменных χ и у. Предположим, что χ и у являются одиночными переменными, чьи значения модифицируются, не взирая на рекурсивные обращения. Конечно, η по-прежнему помещается в стек. Заметим, что выполнение алгоритма при этих предположениях дает те же результаты, что и в случае, когда χ и у помещались в стек. Факториал на языке Бейсик Мы только что описали действие рекурсивного алгоритма вычисления факториала, использующее некоторый стек для того, чтобы представить последовательные значения переменных. Мы должны, следовательно, отобразить эти действия на языке Бейсик. Однако остается одна проблема. Многие версии языка Бейсик не допускают того, чтобы некоторая подпрограмма вызывала сама себя. То есть группа выполняющихся операторов, следующих за выполнением некоторого оператора GOSUB и до выполнения некоторого оператора RETURN, не может содержать какой-либо оператор GOSUB для перехода на другой опе- 257
ратор внутри этой группы. Другие версии языка Бейсик не налагают этого ограничения (или явно не заставляют следовать ему), но они имеют другие ограничения (которые мы вскоре будем обсуждать), существенно ограничивающие рамки такого применения оператора GOSUB, Таким образом, реализовать некоторое рекурсивное обращение, используя оператор GOSUB для обращения к рекурсивной подпрограмме, может быть невозможным или непрактичным, поскольку данный оператор GOSUB содержится внутри группы операторов, составляющих эту подпрограмму. Для простоты представления, однако, мы в данный момент будем игнорировать все такие ограничения и представим некоторый метод реализации рекурсивного алгоритма вычисления факториала на языке Бейсик, используя в действительности рекурсивные операторы GOSUB. Мы сделаем еще одну дальнейшую модификацию по сравнению с рис. 5.2.1. Вместо того чтобы использовать вершину стека в качестве текущего значения переменной N, мы используем некоторую отдельную переменную CN, которая помещается в стек и выбирается из него и которая содержит все предыдущие значения этой переменной. Сам стек находится в некотором массиве PARAM и описывается следующим образом: 10 MXSTACK=50 20 DIM PARAM (MXSTACK) Подпрограмма push (в операторе с номером 1000) принимает переменную CN и помещает ее значение в стек. Подпрограмма pop (в операторе с номером 2000) делает выборку из данного стека и устанавливает в переменную POPS выбранное значение. Подпрограмма, моделирующая вычисление факториала (называемая simfact), начинает свою работу, инициализируя пустое значение для стека, устанавливая в переменную CN введенное значение N и помещая в стек некоторую пустую область данных, для того чтобы отобразить первоначальное обращение из главной программы. (Это необходимо для того, чтобы последний возврат управления в главную программу не обнаружил, что стек пуст.) Рекурсивное обращение реализуется при помощи помещения переменной CN в стек, переустановки в CN нового введенного значения и выполнения оператора GOSUB. Возврат управления реализуется при помощи выборки переменной из стека в CN и выполнения оператора RETURN. Выход данной подпрограммы сохраняется в переменной SIMFACT. 10000 'подпрограмма simfact 10010 'входы: N 10020 'выходы: SIMFACT 10030 'локальные переменные: CN, POPS, ТР, Χ, Υ 10040 'инициализация 10050 ТР = 0: 'стек первоначально пустой 10060 CN=N 10070 'поместить некоторую пустую область данных в стек 258
10080 GOSUB 1000: 'подпрограмма push воспринимает переменную CN 10090 'это начало моделирующей подпрограммы 10100 IF CN=0 THEN SIMFACT=l: GOTO 10190: строки 1—2 данного алгоритма 10110 X=CN-1 10120 'обратиться к fact рекурсивно (строка 6) 10130 GOSUB 1000: 'подпрограмма push 10140 CN=X 10150 GOSUB 10090: 'реальное обращение 10160 'возврат в эту точку после рекурсивного обращения 10170 Y=SIMFACT: 'вторая половина строки 6 10180 SIMFACT=CN*Y: 'строка 7 10190 'далее следует некоторое моделирование возврата в строках 3 и 8 10200 GOSUB 2000: 'подпрограмма pop устанавливает переменную POPS 10210 CN=POPS 10220 RETURN: 'возврат к строке 10160 или к главной программе 10230 'конец подпрограммы Читателю рекомендуется прокрутить действия этой подпрограммы для N=4 для того, чтобы увидеть, как она отображает данный рекурсивный алгоритм. Механизм обращения и возврата Теперь, когда мы увидели, как справиться с многократным распределением переменных в рекурсивных алгоритмах, обратимся к вопросу о механизме рекурсивного обращения и возврата и к вопросу о том, как он может быть смоделирован. Мы должны это сделать потому, что многие версии языка Бейсик запрещают рекурсивные обращения, такое как заданное в строке 10150 предыдущей программы. Это обращение рекурсивное, поскольку между строкой 10090 (в которую оператор GOSUB передает управление) и строкой 10220 (в которой задан оператор RETURN) данная программа может снова выполнить строку 10150 (которая содержит оператор GOSUB, передающий управление в ту же самую группу операторов). Некоторые версии языка Бейсик разрешают такое обращение, но ограничивают глубину вложенных вызовов подпрограммы. Эта глубина вложения равна числу тех операторов GOSUB, которые были выполнены, а соответствующие им операторы RETURN еще не были выполнены. Глубина вложений большинства программ остается намного меньше этого максимума, поскольку глубина вложений всегда меньше, чем число подпрограмм, содержащихся в программе. (В самом деле, редко когда глубина вложений равна этому числу, поскольку одна подпрограмма непосредственно обращается ко многим другим подпрограммам, так что управление вернется из одной подпрограммы до того, как будет обращение к другой.) Но программа, содержащая рекурсивную подпрограмму, может просто превысить эту глубину вложений, если к ней обращаются с некоторым большим входным значением, поскольку эта подпрограмма повторно обращается сама к себе. Таким образом, имеются ситуации, где 259
использование непосредственной рекурсии в языке Бейсик невозможно. Если оператор GOSUB запрещен как некоторое средство реализации рекурсии, мы должны найти какой-либо другой механизм. Для того чтобы обнаружить какой-либо метод, рассмотрим, как реализованы обычные операторы GOSUB и RETURN. Когда делается обращение к некоторой подпрограмме, она в конце концов должна вернуть управление на оператор, следующий за GOSUB. Это означает, что об этой ячейке должна храниться некоторая информация, называемая адресом возврата. Если было сделано обращение к нескольким подпрограммам, но управление из них не вернулось, то для каждой из них должен храниться некоторый адрес возврата. Таким образом, если главная программа выполняет оператор GOSUB xxx, подпрограмма в строке ххх выполняет оператор GOSUB yyy и подпрограмма в строке ууу выполняет оператор GOSUB zzz, то эти три адреса возврата должны сохраняться. Ими являются: адрес lz оператора, следующего за оператором GOSUB zzz, в который должна вернуться подпрограмма с номером строки zzz; адрес 1у оператора, следующего за оператором GOSUB ууу, в который должна вернуться подпрограмма с номером строки ууу; адрес 1х оператора, следующего за оператором GOSUB xxx, в который должна вернуться подпрограмма с номером строки ххх. Эти адреса возвратов могут быть сохранены в стеке. Оператор GOSUB помещает адрес оператора, следующего за ним, в стек адресов возврата и передает управление (выполняется некоторой оператор GOTO) на указанный в нем оператор. Когда выполняется оператор GOSUB xxx, то 1х помещается в вершину стека, и управление передается оператору ххх. Когда выполняется оператор GOSUB ууу, то 1у помещается в стек поверх 1х, и управление передается на оператор ууу. И наконец, когда выполняется оператор GOSUB zzz, то lz помещается в стек поверх 1у и lz, и управление передается на оператор zzz. На рис. 5.2.2., а показаны эта ситуация и соответствующий стек адресов возвратов. Если некоторая подпрограмма выполняет оператор RETURN, то организуется выборка из стека адресов возвратов и выполняется передача управления по адресу возврата, выбранному из стека. Таким образом, когда подпрограмма со строкой zzz выполняет оператор RETURN, то lz выбирается из стека, и программа передает управление по адресу lz, который следует за оператором GOSUB zzz в подпрограмме с номером строки ууу. Это показано на рис. 5.2.2,6. Когда эта подпрограмма в свою очередь возвращает управление, то 1у выбирается из стека, и программа передает управление по адресу 1у, который следует за оператором GOSUB ууу в подпрограмме с номером строки ххх (рис. 5.2.2,в). Затем эта подпрограмма возвращает уп- 260
равление при помощи выборки из стека 1х и передачи управления по адресу 1х, который следует за оператором GOSUB ххх. Отметим, что адрес возврата для некоторой подпрограммы определяется не этой подпрограммой, а программой, которая к ней обращается. К одной и той же подпрограмме могут обращаться из нескольких различных мест в нескольких различных подпрограммах, и адрес возврата определяется тем местом, откуда было это обращение. Тот же самый механизм может быть использован для некоторого рекурсивного обращения и возврата. Мы можем рассматривать область данных, которая должна быть помещена в некоторый стек при моделировании рекурсивного обращения и выбрана из стека при моделировании возврата, как содержащую некоторый адрес возврата, а также значения переменных программы, которые должны быть сохранены. Как мы можем манипулировать с адресами возвратов на языке Бейсик? Мы не можем получить доступ к самим реальным адресам и не можем поместить номера операторов в стек. Вместо этого мы используем некоторую переменную, называемую индикатором возврата, целое значение которой указывает на то место, куда должна возвратить управление подпрограмма. Например, функция вычисления факториала может вернуть управление в одно из двух мест — на присваивание в переменную Υ факториала X! или на оператор в программе, который первоначально обратился к fact. Предположим, что некоторая переменная CRETADDR, используемая как некоторый индикатор возврата, может принимать значения 1 или 2. Значение 1 указывает, что текущее обращение к рекурсивной подпрограмме является первоначальным обращением и что после завершения данная подпрограмма должна вернуть управление в главную программу. При первоначальном входе в подпрограмму вычисления факториала в переменную CRETADDR, следовательно, устанавливается 1. Значение 2 указывает, что текущее обращение является некоторым рекурсивным обращением, которое должно вернуть управление к предыдущему обращению. Когда подпрограмма рекурсивно обращается сама к себе, значение переменной CRETADDR сохраняется в стеке. Таким образом, когда управление возвращается из данного обращения, предыдущее значение переменной CRETADDR должно быть восстановлено. Тем самым управление возвращается в вызывающую программу в нужное место. После того как текущее значение переменной CRETADDR было помещено в стек, в переменную CRETADDR устанавливается значение 2, которое указывает, что новое обращение является рекурсивным. В самом деле, мы можем рассматривать индикатор возврата как некоторую неявную входную переменную для рекурсивного алгоритма, которая будет использоваться для управления возвратом. Таким образом, кроме стека перемен^ 261
GOSUB XXX LX END Главная программа GOSUB YYY LY . RETURN Подпрограмма XXX GOSUB ZZZ LZ RETURN RETURN Подпрограмма YYY Подпрограмма ZZZ Стек адресов возвратов GOSUB YYY LY Подпрограмма XXX Подпрограмма YYY Подпрограмма ZZZ GOSUB ZZZ LZ RETURN Стек адресов возвратов Рис. 5.2.2, α, б. ных требуется некоторый стек для индикаторов возврата. Этот стек может быть описан следующим образом: 30 DIM RETADDR(MXSTACK) Отметим, что одно значение переменной ТР может быть использовано для стеков PARAM и RETADDR, поскольку в оба стека переменные помещаются и выбираются в одно и то же время — при моделировании рекурсивного обращения и рекурсивного возврата. Ближе к реальной ситуация, когда эти два стека можно рассматривать как один стек областей данных, 262
GOSUB XXX \lx \ END OOSUB YYY LY RETURN GOSUB ZZZ LZ RETURN RETURN Главная программа Подпрограмма XXX Подпрограмма YYY Подпрограмма ZZZ Стек адресов возвратов Рис. 5.2.2., в. который содержит два элемента или поля — некоторое сохраненное значение параметра и некоторое сохраненное значение индикатора возврата. Имеется также текущая область данных, состоящая из переменных CN и CRETADDR. Таким образом, мы можем использовать одну специально спроектированную подпрограмму push, которая воспринимает текущую область данных (CN и CRETADDR) и помещает ее в стек, и одну специально спроектированную подпрограмму pop, которая выбирает некоторую область данных из стека в текущую область данных. Таким образом, моделирование рекурсивного обращения к вычислению факториала состоит из таких операторов: 10135 'сохранение значений старых параметров в стеке GOSUB 1000: 'подпрограмма push 'инициализация новых входных значений CN-X CRETADDR=2: 'смоделированное обращение должно вернуть 'управление к предыдущему обращению 'моделирование реального рекурсивного обращения GOTO . . .: 'начало моделируемой программы Моделирование возврата из подпрограммы рекурсивного вычисления факториала состоит из установки в переменную SIMFACT результата вычисления факториала и затем выполнения следующих операторов: 10220 1=CRETADDR: 'сохранение текущего индикатора возврата GOSUB 2000: 'подпрограмма pop переустанавливает переменные 'CN и CRETADDR IF 1 = 1 THEN RETURN: 'на главную программу IF 1 = 2 THEN GOTO . . .: 'в точку, следующую за рекурсивным 'обращением 10140 10146 10150 10160 10165 10170 10230 10240 10250 263
Представим теперь полное моделирование рекурсивной подпрограммы вычисления факториала: 10000 'подпрограмма simfact 10010 'выходы: N 10020 'выходы: SIMFACT 10030 'локальные переменные: CN, CRETADDR, ТР, Χ, Υ 10040 'инициализация 10050 ТР=0: 'стек первоначально пустой 10060 CN=N 10070 CRETADDR=1 10080 'поместить некоторую фиктивную область данных в стек 10090 GOSUB 1000: 'подпрограмма push воспринимает переменные CN 'и CRETADDR 10100 'это начало моделирующей подпрограммы 10110 IF CN=0 THEN SIMFACT=l: GOTO 10210: 'возврат 10120 X=CN-1 10130 'обращение к fact рекурсивно 10140 GOSUB 1000: 'подпрограмма push 1Q150 CN=X 10160 CRETADDR=2 10170 GOTO 10100 10180 'возврат в эту точку после рекурсивного обращения 10190 Y= SIMFACT 10200 SIMFACT=CN*Y 10210 'далее следует моделирование возврата 10220 I-CRETADDR 10230 GOSUB 2000: 'подпрограмма pop переустанавливает переменные 'CN и CRETADDR 10240 IF 1 = 1 THEN RETURN: 'к главной программе 10250 IF I=»2 THEN GOTO 10180: 'в точку, следующую за рекурсивным 'обращением 10260 'конец подпрограммы Мы следуем тому соглашению, что операторы для моделирования возврата (операторы 10210—10250) всегда помещаются в конец подпрограммы. Любой возврат, который должен быть выполнен внутри тела подпрограммы (такой как оператор с номером 10110), моделируется при помощи передачи управления на этот блок операторов. Хотя эта программа достаточно сложная, она была получена при помощи прямого применения механического процесса, который может быть применен к любому рекурсивному алгоритму. Позднее в этом разделе мы увидим, как упростить эту сложную подпрограмму и сделать ее более доступной. Для полноты картины рассмотрим также подпрограммы push и pop, которые требуются для подпрограммы simfact. 1000 'подпрограмма push 1010 'входы: CN, CRETADDR, MXSTACK, ТР 1020 'выходы: PARAM, RETADDR, ТР 1030 'локальные переменные: нет 1040 IF TP=MXSTACK THEN PRINT «ПЕРЕПОЛНЕНИЕ СТЕКА»: STOP 1050 ТР=ТР+1 1060 PARAM (TP)=CN 1070 RETADDR=CRETADDR 264
1080 RETURN 1090 'конец подпрограммы 2000 'подпрограмма pop 2010 'входы: PARAM, RETADDR, ТР 2020 'выходы: CN, CRETADDR, ТР 2030 'локальные переменные: EMPTY 2040 GOSUB 3000: 'подпрограмма empty устанавливает переменную 'EMPTY 2050 IF EMPTY=TRUE THEN PRINT «ВЫБОРКА ИЗ ПУСТОГО СТЕКА»: STOP 2060 CN = PARAM (TP) 2070 CRETADDR = RETADDR (ТР) 2080 TP-TP-1 2090 RETURN 2100 'конец подпрограммы Задача «Ханойские башни» на языке Бейсик Давайте теперь посмотрим на более сложный пример рекурсии—задачу «Ханойские башни», представленную в разд. 5.1, и смоделируем ее рекурсию, для того чтобы создать некоторую нерекурсивную программу на языке Бейсик. Приведем снова этот рекурсивный алгоритм из разд. 5.1. subroutine towers (n, source, dest, aux) 'первоначально в нашем примере source равен A, dest равен С и aux 'равен В ' если только один диск, то сделать перемещение и вернуться if п=1 then print «переместить диск 1 с колышка»; source; «на колышек»; dest return endif 'переместить верхние η—1 дисков с А на В, используя С как вспомогательный колышек towers (n—1, source, aux, dest) 'переместить оставшийся диск с А на С print «переместить диск»; п; «с колышка»; source; «на колышек»; dest 'переместить п—1 дисков с В на С, используя А как вспомогательный 'колышек towers (n— 1, aux, dest, source) return Читателю следует убедиться, что он понимает эту проблему и ее рекурсивное решение, прежде чем он продолжит дальнейшее изучение. Если он не понимает ее, ему следует перечитать конец разд. 5.1. В этой подпрограмме имеются четыре входные переменные, каждая из которых изменяется при рекурсивном обращении. Следовательно, область данных должна содержать элементы, представляющие все четыре переменные. Имеются три возможные точки, в которые эта подпрограмма возвращает управление при различных обращениях,— вызывающая программа и операторы, следующие за двумя рекурсивными обращениями. Следовательно, индикатор возврата может принимать три возможных 265
значения. Индикатор возврата кодируется как некоторое целое число (или 1, или 2, или 3) внутри каждой области данных. Далее приводится пример программы с некоторым нерекурсивным моделированием алгоритма towers. Мы можем использовать переменные CSOURCE, CAUX и CDEST как текущие значения переменных алгоритма source, aux и dest. Это означает, что переменные программы, начинающиеся с буквы С, являются строками, так что текущие значения переменной N и адреса возврата обозначаются NC и ZRETADDR (вместо CN и CRETADDR, как в примере вычисления факториала). Аналогичным образом стеки для переменных, обозначающих диски, называются PSOURCE, PDEST и PAUX, резервируя букву Ρ как начальную букву для строковых переменных, так что стеки для N и адреса возврата обозначаются NPARAM и RETADDR. 100 'главная программа 110 DEFSTR А, С, D, Р, S 120 DIM NPARAM (50): 'стек для значений N 130 DIM PSOURCE (50): 'стек для значений SOURCE 140 DIM PDEST (50): 'стек для значений DEST 150 DIM PAUX (50): 'стек для значений AUX 160 DIM RETADDR (50): 'стек для индикаторов возврата 170 INPUT N 180 SOURCE=«A» 190 DEST=«C» 200 AUX=«B» 210 GOSUB 10000: 'подпрограмма simtowers 220 END 230 ' 240 ' 250 ' 1000 'подпрограмма push начинается здесь -2000 'подпрограмма pop начинается здесь 10000 'подпрограмма simtowers 1Q0J0 /входы: AUX, DEST, N, SOURCE 10020 'выходы: нет 10030 'локальные переменные: CAUX, CDEST, CSOURCE, CTEMP, NC, 'ТР, ZRETADDR 10040 'инициализация 10050 ТР=0 10060 'установить нужные значения во входные переменные и 10070 'в адрес возврата текущей области данных 10080 NC=N: 'текущее значение N . 10090 CSOURCE- SOURCE: 'текущее значение SOURCE 10100 CDEST=DEST: 'текущее значение DEST 10110 CAUX=AUX: 'текущее значение AUX 10120 ZRETADDR=1: 'текущее значение индикатора возврата 10130 'поместить фиктивную область данных в стек 10140 GOSUB 1000: 'подпрограмма push помещает в стек переменные 'NC, CSOURCE, CDEST, CAUX и ZRETADDR 10150 'это начало моделирующей подпрограммы 10160 IF NC= l THEN PRINT «ПЕРЕМЕСТИТЬ ДИСК 1 С КОЛЫШКА»; CSOURCE; «НА КОЛЫШЕК»; CDEST: GOTO 10360 266
10170 'это первое рекурсивное обращение 10180 GOSUB 1000: 'подпрограмма push 10190 NC=NC-1 10200 CTEMP = CAUX: 'поменять местами CAUX и CDEST 10210 CAUX=CDEST 10220 CDEST=CTEMP 10230 ZRETADDR = 2 10240 GOTO 10150 10250 'мы возвращаемся в эту точку после первого рекурсивного обращения* 10260 PRINT «ПЕРЕМЕСТИТЬ ДИСК»; NC; «С КОЛЫШКА»; CSOURCE; «НА КОЛЫШЕК»; CDEST 10270 'это второе рекурсивное обращение 10280 GOSUB 1000: 'подпрограмма push 10290 NC = NC-1 10300 СТЕМР = CSOURCE: 'поменять местами CAUX и CSOURCE 10310 CSOURCE=CAUX 10320 CAUX=CTEMP 10330 ZRETADDR = 3 10340 GOTO 10150 10350 'возврат в эту точку после второго рекурсивного обращения 10360 'моделирование возврата 10370 I = ZRETADDR 10380 GOSUB 2000: 'подпрограмма pop переустанавливает переменные 'NC, CSCURCE, CDEST, CAUX, TP и ZRETADDR 10390 IF 1=1 THEN RETURN 10400 IF 1 = 2 THEN GOTO 10250 10410 IF 1 = 3 THEN GOTO 10350 10420 'конец подпрограммы Улучшение подпрограммы моделирований Имеется ряд методов, которые мы можем часто использовать для того, чтобы упростить моделирование рекурсии. При обсуждении моделирования подпрограммы вычисления факториала мы уже столкнулись с одним из этих методов — не все переменные некоторого рекурсивного алгоритма необходимо помещать в стек. Теперь рассмотрим некоторые дополнительные методы, которые могут исключить ряд сложностей рекурсивного обращения и уменьшить или даже совсем исключить необходимость стека для индикаторов возврата. Давайте повторно рассмотрим второе моделирование рекурсивного алгоритма вычисления факториала. В тексте имеется только одно рекурсивное обращение к подпрограмме вычисления факториала (в алгоритме — это строка 6, а в программе — операторы с номерами 10130—10170), так что внутри подпрограммы simfact имеется только один адрес возврата (на оператор с номером 10180). Другим адресом возврата является возврат на главную программу, которая первоначально обратилась к подпрограмме simfact. Но предположим, что некоторая фиктивная область данных не была помещена в стек при инициализации данного моделирования. Тогда область данных помещается в стек только при моделировании некоторого рекурсивного обращения. Когда данный стек выбирается при возвра- 267
те из некоторого рекурсивного обращения, эта область удаляется из стека. Однако, когда делается попытка выборки из стека при моделировании возврата в главную процедуру, произойдет исчезновение стека. Мы можем проверять это исчезновение, используя подпрограмму popandtest вместо подпрограммы pop, и, когда это действительно произойдет, можем непосредственно вернуться наружу к вызывающей программе, а не использовать для этого индикатор возврата. Это означает, что один из адресов возврата может быть исключен. Поскольку в результате остается только один возможный адрес возврата, то его нет надобности помещать в стек. Таким образом, текущая область данных была сведена к тому, что она содержит одну-единственную переменную CN, а стек превратился в один массив PARAM. Эта программа теперь стала совсем компактной и понятной: 10000 'подпрограмма simfact 10010 'входы: N 10020 'выходы: SIMFACT 10030 'локальные переменные: CN, ТР, UND, Χ, Υ 10040 'инициализация 10050 ТР=0 10060 CN=N 10070 'это начало подпрограммы моделирования 10080 IF CN=G THEN SIMFACT=l: GOTO 10170: 'возврат 10090 X=CN-1 10100 'рекурсивное обращение к fact 10110 GOSUB 1000: 'подпрограмма push воспринимает CN 10120 CN=X 10130 GOTO 1007Q 10140 'возврат в эту точку после рекурсивного обращения 10150 Y= SIMFACT 10160 SIMFACT=CN*Y 10170 'операторы с номерами 10180—10200 моделируют возврат 10180 GOSUB 4000: 'подпрограмма popandtest переустанавливает пе- 'ременные CN и UND 10190 IF UND=FALSE THEN GOTO 10140: 'в точку после 'рекурсивного обращения 10200 IF UND^TRUE THEN RETURN: 'в главную программу 10210 'конец подпрограммы Отметим, что мы выделили операторы с номерами 10090— 10120 и 10150—10180, задав для них абзац, для того чтобы показать, что данная программа в действительности состоит из двух циклов, хотя мы первоначально не разрабатывали ее таким образом. Вскоре мы объясним важность этого. Исключение операторов GOTO Хотя вышеприведенная программа безусловно проще, чем такая же предыдущая программа, она все еще далека от некоторой «идеальной» программы. Если бы читателю пришлось смотреть на эту программу, не зная ее происхождения, то со- 268
мнительно, что он бы смог распознать ее как подпрограмму вычисления функции факториала. Опергторы 10130 GOTO 10070 и 10190 IF UND = FALSE THEN GOTO 10140 особенно раздражают, поскольку они прерывают поток мыслей в тот момент, когда читатель, казалось, приходит к некоторому пониманию, что делается в программе. Давайте посмотрим, сможем ли мы преобразовать эту программу в некоторую еще более «читабельную» версию. В две переменные X и CN присваиваются значения друг из друга, и они никогда одновременно не используются, так что могут быть скомбинированы и заменены на одну переменную X. Аналогичное утверждение может быть сделано относительно переменных SIMFACT и Y; они могут быть скомбинированы и заменены на одну переменную Y, которой присваивается значение выходной переменной SIMFACT только при возврате в главную программу. Выполнив эти преобразования, придем к следующей версии подпрограммы simfact: 10000 'подпрограмма simfact 10010 'входы: N 10020 'выходы: SIMFACT 10030 'локальные переменные: ТР, UND, Χ, Υ 10040 'инициализация 10050 ТР=0 10060 Χ=Ν 10070 'это начало подпрограммы моделирования 10080 IF X=0 THEN Y-1: GOTO 10150: 'возврат 10Θ90 'рекурсивное обращение к fact 10100 GOSUB 1000: 'подпрограмма push воспринимает X 10110 Х=Х-1 10120 GOTO 10070 10130 'возврат в эту точку после рекурсивного обращения 10140 Y=X*Y 10150 'далее следует моделирование возврата 10160 GOSUB 4000: 'подпрограмма popandtest переустанавливает пе- 'ременные X и UND 10170 IF UND=TRUE THEN SIMFACT=Y: RETURN 10180 GOTO 10130: 'возврат в точку, следующую за рекурсивным обра- 'щением 10190 'конец подпрограммы Мы теперь начинаем приближаться к некоторой «читабельной» программе. Данная программа имеет два цикла. 1. Цикл вычитания, который состоит из операторов с номерами 10070—10120. Выход из этого цикла происходит тогда, когда Х = 0, причем в этот момент в переменную Υ устанавливается 1 и выполнение продолжается с оператора с номером 10150. 269
2. Цикл умножения, который начинается на операторе с номером 10130 и заканчивается выполнением GOTO 10130 в операторе с номером 10180. Из этого цикла выход осуществляется тогда, когда стек становится пустым и происходит его исчезновение, причем в этот момент и выполняется возврат. Давайте более внимательно рассмотрим эти два цикла. Переменная X начинается со значения входного параметра N и уменьшается каждый раз на 1, когда повторяется цикл вычитания. Каждый раз, когда в X устанавливается некоторое новое значение, старое значение X запоминается в стеке. Это продолжается до тех пор, пока X не станет равным 0. Таким образом, после того как выполняется первый цикл, стек содержит сверху вниз целые числа от 1 до N. Цикл умножения просто удаляет из стека каждое из этих значений и устанавливает в переменную Υ произведение выбранного из стека значения и старого значения Υ. Поскольку мы знаем, что содержит стек в начале цикла умножения, то зачем обременять себя выборкой из стека? Мы можем использовать эти значения непосредственно. Мы можем полностью исключить стек и первый цикл и заменить цикл умножения на некоторый цикл, в котором Υ по очереди умножается на каждое из целых чисел от 1 до N. Это дает в результате такую программу: 10000 'подпрограмма simfact 10010 'входы: N 10020 'выходы: SIMFACT 10030 'локальные переменные: Χ, Υ 10040 Y=l J0050 FORX=lTON 10060 Y=Y*X 10070 NEXT X 10080 SIMFACT-Y 10090 RETURN 10100 'конец подпрограммы Но эта программа является непосредственной реализацией на языке Бейсик итерационной версии функции вычисления факториала, как она представлена в разд. 5.1. Единственное отличие состоит в том, что X изменяется от 1 до N, а не от N до 1. Таким образом, последовательность упрощений привела нас от грубого моделирования некоторого рекурсивного алгоритма к некоторой простой и эффективной программе решения этой проблемы. Хотя эта простая программа является очевидной для алгоритма вычисления факториала, имеется много других проблем, для которых такая простая программа не является очевидной, но для которых имеются рекурсивные решения. Представленные нами методы образуют мощный инструментарий для реализации решений этих проблем. Мы еще раз проиллюстрируем эти методы упрощения для более сложной проблемы — задачи «Ханойские башни». 270
Упрощение задачи «Ханойские башни» Давайте повторно рассмотрим подпрограмму simtowers, представленную ранее для решения задачи «Ханойские башни». Отметим сперва, что были использованы три значения индикатора возврата — по одному для каждого рекурсивного обращения и одного для возврата в главную программу. Однако возврат в главную программу может быть сигнализирован при помощи исчезновения стека, так же как и во второй версии подпрограммы simfact. Это дает нам два значения индикатора возврата. Если бы мы могли исключить еще одно такое значение, то больше не надо было бы помещать в стек индикатор возврата, поскольку осталась бы только одна точка, в которую должно быть передано управление, если выборка из стека сделана успешно. Сфокусируем наше внимание на втором рекурсивном обращении данного алгоритма и следующих операторах: towers (n—l,aux,dest,source) return Действия, которые выполняются при моделировании этого обращения, следующие: 1. Поместить текущую область данных А1 в стек. 2. Установить в параметры новой текущей области данных А2 их соответствующие значения: η—1, aux, dest и source. 3. Установить индикатор возврата в текущей области данных А2, для того чтобы указать на адрес того оператора, который непосредственно следует за данным обращением. 4. Передать управление на начало подпрограммы моделирования. После того как подпрограмма моделирования завершится, она готова к возврату. При этом происходят следующие действия. 5. Восстановить индикатор возврата i из текущей области данных А2. 6. Сделать выборку из стека и установить выбранную область данных А1 в текущую область данных. 7. Передать управление на оператор, указанный значением в i. Но оператор, указанный значением в i, является оператором return, поскольку за вторым рекурсивным обращением к towers непосредственно следует оператор return. Таким образом, следующий шаг состоит в том, чтобы опять сделать выборку из стека и выполнить еще раз возврат. Мы никогда снова не используем информацию в текущей области данных А1, поскольку она сразу же разрушается при выборке из стека, как только она будет восстановлена. Поскольку нет никакого смысла снова использовать эту область данных, то не имеет смысла сохранять ее в стеке при моделировании обращения. Данные необходимо сохранять в стеке, только если они должны быть повторнс 271
использованы. В этом случае, следовательно, данное обращение может быть просто смоделировано следующим образом: 1. Изменение параметров в текущей области данных, чтобы они приняли соответствующие значения. 2. Передача управления на начало подпрограммы моделирования. Когда подпрограмма моделирования возвращает управление, она может вернуть управление непосредственно на подпрограмму, которая обратилась к текущей версии. Нет никакого смысла выполнять некоторый возврат на текущую версию только для того, чтобы вернуться сразу же на предыдущую версию. Поскольку остается только одно возможное значение индикатора возврата, то нет необходимости хранить его в области данных, чтобы не помещать его в стек и не выбирать его с остальной частью данных. Когда выборка из стека происходит успешно, имеется только один адрес, по которому может быть передано управление,— оператор, следующий за первым обращением. Если произойдет исчезновение стека, то подпрограмма возвращает управление в вызывающую программу. Наша главная программа и нерекурсивное моделирование алгоритма towers преобразуются в следующий вид: 100 'главная программа 110 DEFSTR А, С, D, P, S 120 DIM NPARAM (50) 130 DIM PSOURCE (50) 140 DIM PDEST (50) 150 DIM PAUX (50) 160 TRUE=1 170 FALSE=0 180 INPUT N 190 SOURCE=«A» 200 DEST=«C» 210 AUX=«B» 220 GOSUB 10000: 'подпрограмма simtowers 230 END 240 ' 250 ' 260 ' 10000 'подпрограмма simtowers 10010 'входы: AUX, DEST, N, SOURCE 10020 'выходы: нет 10030 'локальные переменные: CAUX, CDEST, CSOURCE, CTEMP, NC, 'TP, UND 10040 'инициализация 10050 TP=0 10060 NC=N 10070 CSOURCE»SOURCE 10080 CDEST=DEST 10090 CAUX-AUX 10100 'здесь начинается подпрограмма моделирования 10110 IF NC=1 THEN PRINT «ПЕРЕМЕСТИТЬ ДИСК 1 С КОЛЫШКА»; CSOURCE; «НА КОЛЫШЕК»; CDEST: GOTO 10270 272
10120 'моделирование первого рекурсивного обращения 10130 GOSUB 1000: 'подпрограмма push 10140 NC=NC-1 10150 СТЕМР=CDEST 10160 CDEST=CAUX 10170 CAUX=CTEMP 10180 GOTO 10100 10190 'это точка возврата из первого рекурсивного обращения 10200 PRINT «ПЕРЕМЕСТИТЬ ДИСК»; NC; «С КОЛЫШКА»; CSOURCE; «НА КОЛЫШЕК»; CDEST 10210 'моделирование второго рекурсивного обращения 10220 NC=NC-1 10230 СТЕМР=CSOURCE 10240 CSOURCE=CAUX 10250 CAUX=CTEMP 102Θ0 GOTO 10100 10270 'моделирование возврата 10280 GOSUB 4000: 'подпрограмма popandtest устанавливает перемен- 'ные NC, CSOURCE, CDEST, CAUX и UND 10290 IF UND=TRUE THEN RETURN: 'возврат к главной программе 10300 'в противном случае переход на точку после рекурсивного обра- 'щения 10310 GOTO 10190 10320 'конец подпрограммы Читателю рекомендуется проследить действия этой программы и посмотреть, как она отражает действия первоначальной рекурсивной версии. Дополнительные соображения При реализации рекурсивных функций следует обратить внимание еще на одно дополнительное соображение. В нашей наивной реализации функции вычисления факториала мы использовали одну-единственную переменную simfact, которая содержит результат оценки факториала в каждой точке рекурсивного процесса. Причина того, что это могло быть сделано, состоит в том, что никогда не было необходимости поддерживать более чем одно значение факториала, так что было достаточно одной переменной. В отличие от этого в алгоритме вычисления функции Фибоначчи с входным параметром η могла бы содержаться такая строка: fib = fib(n—2)+fib(n—1) То есть при некотором заданном значении п, для которого мы хотим вычислить функцию Фибоначчи, сперва надо вычислить функцию Фибоначчи для входного значения η—2, затем вычислить функцию Фибоначчи для входного значения η—1 и затем сложить вместе эти два значения и использовать сумму в качестве результата функции Фибоначчи для входного значения п. Если бы мы использовали только одну переменную simfib для хранения результата функции Фибоначчи, то в эту переменную было бы установлено значение при обращении 273
к fib (η—2), но после обращения к fib (η—1) это значение было бы переустановлено, тем самым нарушив значение fib (η—2). Следовательно, необходимо реализовывать рекурсии так, как если бы алгоритм был написан следующим образом: x = fib(n—2) y = fib(n—1) fib = x+y При реализации этой версии одна переменная simfib может быть использована для результата функции Фибоначчи, поскольку ее значение для fib(n—2) сохраняется в переменной χ до того, как переустанавливается при вычислении fib (η—1). Конечно, переменную χ пришлось бы запоминать в стеке, поскольку некоторое рекурсивное обращение вклинивается между ее определением и использованием. (Отметим, что переменная у не удовлетворяет этим условиям, и ее не надо запоминать в стеке.) Другим соображением о рекурсивных алгоритмах и их реализации является то, что ошибки в таких процессах являются достаточно частыми, и их очень трудно бывает отслеживать. Причина этого состоит в том, что рекурсивный процесс действует так, что обращается последовательно сам к себе для все более простых входных параметров до тех пор, пока он не достигнет входного параметра, для которого результаты определяются явно. Если, однако, для процесса был задан неверный входной параметр, то он может непрерывно пытаться «упростить» этот неверный входной параметр и никогда не достигнет входного значения, для которого функция определяется явно. Например, если для функции вычисления факториала было задано в качестве входного значения некоторое отрицательное число, то подпрограмма может непрерывно вычитать 1 и обращаться сама к себе для все уменьшающихся отрицательных чисел и никогда не достигнет 0, для которого функция вычисления факториала явно определена. Однако причину такой ошибки может быть очень трудно определить, поскольку она проявляется в том, что ЭВМ будет бесконечно работать в цикле до тех пор, пока число не станет слишком большим по абсолютной величине. Следовательно, для рекурсивной подпрограммы крайне важным является предохранение против неверного ввода. В нашем примере при реализации алгоритма вычисления факториала вначале надо поместить такие операторы: if n<0 then print «ввод отрицательного числа для функции вычисления факториала» stop endif Упражнения 1. Предположим, что к задаче «Ханойские башни» добавлено дополнительное условие: один диск не может располагаться на другом диске, кото- 274
рый больше первого более чем на один размер (т. е. диск 1 может располагаться только на диске 2 или на земле, диск 2 —только на диске 3 или на земле и т. д.). Почему решение, приведенное в тексте, не будет работать? Что неправильного в логике, которая приводит к этому решению при этих новых правилах? 2. Докажите, что число перемещений, выполненных подпрограммой simtowers для η дисков, равно 2П—1. Можно ли найти некоторый метод решения задачи «Ханойские башни» за меньшее число перемещений? Или найдите такой метод для некоторого п, или докажите, что его не существует. 3. Напишите некоторую нерекурсивную модель рекурсивной процедуры бинарного поиска и преобразуйте ее в некоторую итерационную процедуру. 4. Напишите некоторую нерекурсивную модель функции fib. Можно ли ее преобразовать в некоторый итерационный метод? 5. Определите, что вычисляет следующий рекурсивный алгоритм. Напишите некоторую итерационную подпрограмму, которая выполняет ту же самую функцию: function func(n) if n = 0 then func=0 else func=n+func(n— 1) endif return 6. Выражение mod(m, n) обозначает остаток при делении m на п. Определим наибольший общий делитель (gcd) двух целых чисел χ и у следующим образом: gcdfx, у)=у, если у<=х и mod(x, у)=0, gcd(x,y)=gcd(y,x), если х<у, gcd(x, y)=gcd(y, mod(x, у)) в противном случае. Напишите на языке Бейсик некоторую подпрограмму, которая моделирует рекурсивный алгоритм вычисления gcd(x, у). Найдите некоторый итерационный метод для вычисления этой функции. 7. Пусть comm(n, к) представляет собой число различных групп из к человек, которые могут быть сформированы из заданного числа η человек. Например, comm (4,3) =4, поскольку для четырех заданных человек А, В, С и D имеются четыре возможные группы по три человека — ABC, ABD, ACD и BCD. Докажите справедливость следующего равенства: comm (n, k) =comm (η— 1, k) +comm (η— 1, k— 1) Напишите и протестируйте подпрограмму на языке Бейсик, которая моделирует рекурсивный алгоритм вычисления comm (η, к) для η, k> = l. 8. Определим обобщенную последовательность чисел Фибоначчи для fO и И как последовательность gfib(fO, fl, 0), gfib(f0, fl, 1), gfib(f0, fl, 2), ..., где gfib(f0,fl,0)=f0 gfib(f0,fl, l)=fl gfib(f0, fl, n) =gfib(f0, fl, n-1) +gfib(f0, fl, n-2), если п>1 Напишите на языке Бейсик подпрограмму, которая моделирует рекурсивный алгоритм вычисления gfib(f0, fl, n). Найдите некоторый итерационный метод для вычисления этой подпрограммы. 9. Матрица порядка η является некоторым массивом из ηχη чисел. Например, (3) является некоторой матрицей 1x1, (-■/) 275
является некоторой матрицей 2x2 и 13 4 6 2-5 0 8 3 7 6 4 2 0 9-1 является некоторой матрицей 4x4. Определим минор некоторого элемента χ в некоторой матрице как подматрицу, сформированную при помощи удаления строки и столбца, содержащих х. В примере матрицы 4X4 минором элемента 7 является матрица 3X3 1 4 6 2 0 8 2 9-1 Ясно, что порядок некоторого минора любого элемента на 1 меньше, чем порядок первоначальной матрицы. Обозначим минор некоторого элемента a (i, j) как minor (a (i, j)). Определим рекурсивно определитель некоторой матрицы а (записывается как det(а)) следующим образом: (1) Если а является некоторой матрицей 1X1 (х), то det(a)=x. (2) Если а имеет порядок больше чем 1, то определитель а вычисляется следующим образом: (а) Выберем любую строку или столбец. Для каждого элемента a(i, j) в этой строке или столбце сформируем произведение (_ l)i+J*a(i,j)»det(minor(a(i,j))) где i и j являются номерами строки и столбца выбранного элемента, a(i, j) является выбранным элементом, a det (minor (a (i,j))) является определителем минора элемента a(i, j). (б) det(a)=cyMMe всех этих произведений. Более точно, если η является порядком матрицы а, тогда η det (a) = 2_ι (—1) i+J»a (i, j)»det(minor (a (i, j))) для любого j ί=1 или η det(a)=^(—l)l+J* a(i, j)*det(minor(a(i, j))) для любого i. j=l Напишите на языке Бейсик программу, которая будет читать матрицу А, печатать А в матричной форме и печатать det (А), где det является некоторой подпрограммой, которая вычисляет определитель матрицы. 5.3. СОЗДАНИЕ РЕКУРСИВНЫХ ПРОГРАММ В предыдущем разделе мы увидели, как преобразовывать некоторое рекурсивное определение или алгоритм в программу на языке Бейсик. Более сложной задачей является разработка рекурсивного решения некоторой проблемы, алгоритм которой не задан. Должны быть разработаны не только программа, но и первоначальные определения и алгоритмы. В общем случае, когда стоит задача создания некоторой программы для решения проблемы, нет никакого смысла обращаться к рекурсивному решению. Большинство проблем может быть решено простым 276
способом — с использованием нерекурсивного метода. Однако некоторые проблемы могут быть логически и наиболее элегантно решены при помощи рекурсии. В этом разделе мы попытаемся идентифицировать некоторые проблемы, которые могут быть решены рекурсивно, а также разработать метод нахождения рекурсивных решений и представить несколько примеров. Рассмотрим еще раз функцию вычисления факториала. Факториал, вероятно, является основным примером некоторой проблемы, которая не должна быть решена рекурсивно, поскольку итерационное решение является таким ясным и простым. Однако давайте рассмотрим те элементы, которые позволяют реализовать рекурсивное решение. Сперва мы можем распознать большое число различных случаев, которые надо решить, т. е. мы хотим написать программу для вычисления 0!, 1!, 2! и т. д. Мы можем также идентифицировать «тривиальный» случай, для которого нерекурсивное решение может быть получено явно. Это случай 0!, который определяется как 1. Следующим шагом является нахождение некоторого метода для решения «сложного» случая в терминах «более простого» случая. Это позволит свести сложную задачу к более простой. Преобразование сложного случая в более простой должно в конце концов дать в результате тривиальный случай. Это означало бы, что сложный случай в конечном счете определяется в терминах тривиального случая. Рассмотрим, что это означает в применении к функции вычисления факториала. Факториал 4! является более «сложным» случаем, чем 3!. Преобразование, которое применяется к числу 4, чтобы получить число 3, заключается просто в вычитании 1. Последовательно вычитая 1 из 4, получим в конце концов в результате 0, что является «тривиальным» случаем, Таким образом, если мы в состоянии определить 4! в терминах 3! и в общем случае п! в терминах (п—1)!, то будем в состоянии вычислить 4!, сперва пройдя вниз до 0! и затем пройдя назад до 4!, используя определение п! в терминах (п—1)!. В случае функции вычисления факториала мы имеем как раз такое определение, поскольку n! = n*(n— 1)! Таким образом, 4! = 4*3! = 4*3*2!= 4*3*2 *1!= 4*3*2*1*01 = 4*3 *2*1 *1 = 24. Это существенная составляющая некоторого рекурсивного алгоритма — возможность определения некоторого «сложного» случая в терминах «более простого» случая, а также наличие некоторого явно решаемого (нерекурсивного) «тривиального» случая. Когда это сделано, то можно получить некоторое решение сложного случая, используя предположение, что более простой случай уже был решен. Рекурсивный алгоритм функции 277
вычисления факториала предполагает, что (п—1)! определен, и использует его значение для вычисления п!. Теперь посмотрим, как эти идеи применяются к другим примерам в разд. 5.1. При рекурсивном определении а*Ь случай Ь=1 является тривиальным, поскольку здесь а*Ь определяется как а. В общем случае а*Ь может быть определено в терминах а*(Ь—1) при помощи такого определения: a*b = a*(b—1)+а. Опять сложный случай преобразуется в более простой при помощи вычитания 1, что в конце концов приведет к тривиальному случаю Ь=1. Здесь рекурсия образуется на одном втором параметре Ь. В случае с функцией Фибоначчи были определены два тривиальных случая — fib(0)='0 и fib(l) = l. Сложный случай fib(n) затем сводится к двум более простым: fib(n—2) и fib (η—1). Два тривиальных явно определенных случая необходимы из-за определения fib(n) как fib (η—2) + fib (η—1); fib С1) не может быть определено как fib(0)+fib(—1), так как функция Фибоначчи не определена для отрицательных чисел. Функция бинарного поиска является интересным примером рекурсии. Рекурсия основана на числе элементов в массиве, в котором должен быть осуществлен поиск. Каждый раз, когда к программе обращаются рекурсивно, число элементов, среди которых будет осуществляться поиск, уменьшается наполовину (приблизительно). Тривиальным случаем является тот, когда или нет элементов, среди которых будет осуществляться поиск, или элемент, который ищется, находится в середине массива. Если low>high, то выполняется первое из этих двух условий и выдается 0. Если x = a(mid), то выполняется второе условие и в качестве ответа выдается mid. В более сложном случае — с числом элементов high—low+1, среди которых осуществляете» поиск,— данный поиск сводится к поиску в одном из двух под- регионов: 1. Первая половина массива — от low до mid—1. 2. Вторая половина массива — от mid+1 до high. Таким образом, некоторый сложный случай (некоторая большая область, в которой осуществляется поиск) сводится к некоторому более простому случаю (к области, в которой будет осуществляться поиск, с размером, составляющим примерна половину от первоначальной области). Это в конце концов приведет к сравнению с одним элементом (a(mid)) или к поиску в массиве, в котором нет элементов. Преобразование из префикса в постфикс с помощью рекурсии Давайте рассмотрим теперь проблему, для которой рекурсивное решение является наиболее ясным и элегантным. Это проблема конвертирования префиксного выражения в постфиксное- 278
Префиксная и постфиксная записи были обсуждены в гл. 3. Вкратце записи префикса и постфикса являются методами записи математических выражений без скобок. В префиксной записи за каждым оператором непосредственно следуют его операнды. В постфиксной записи перед каждым оператором непосредственно записываются его операнды. Здесь приводятся для напоминания несколько обычных математических выражений (инфикс) и их префиксные и постфиксные эквиваленты: Инфикс Префикс Постфикс А+В + АВ АВ+ А+В*С +А*ВС АВО + А*(В + С) *А+ВС АВС+» А*В + С +*АВС АВ*С+ A+B*C+D—E*F —h+A*BCD*EF ABO + D + EF*— (A+B)*(C+D—E)*F ** + AB—+ CDEF AB+CD+E— *F* Наиболее удобным способом определения постфикса и префикса является использование рекурсии. При условии что операндами являются переменные, заданные одной буквой, префиксным выражением является либо одна буква, либо некоторая операция, за которой следуют два префиксных выражения. Постфиксное выражение может быть определено аналогично, как одна буква или некоторая операция, перед которой заданы два постфиксных выражения. Приведенные выше определения предполагают, что все операции являются бинарными (т. е. что каждая операция требует двух операндов). Примерами таких операций являются сложение, вычитание, умножение, деление и возведение в степень. Просто будет расширить вышеприведенные определения префикса и постфикса для того, чтобы включить унарные операции, такие как изменение знака или факториал, но в интересах простоты мы этого здесь не будем делать. Проверьте, что каждое из заданных выше префиксных и постфиксных выражений является справедливым, показав, что оно удовлетворяет данным определениям, и убедитесь, что можете идентифицировать два операнда каждой операции. Мы вскоре используем эти рекурсивные определения, но сперва вернемся к нашей проблеме. Как мы можем преобразовать некоторое заданное префиксное выражение в постфиксное выражение? Мы можем сразу же идентифицировать тривиальный случай. Если некоторое префиксное выражение состоит только из одной переменной, то это выражение является своим собственным постфиксным эквивалентом, т. е. такое выражение, как А, справедливо и как некоторое префиксное выражение, и как некоторое постфиксное выражение. 279
Теперь рассмотрим некоторую более длинную префиксную строку. Если бы мы знали, как преобразовать любую более короткую префиксную строку в постфиксную, то могли бы мы преобразовать эту более длинную префиксную строку? Ответ утвердительный, но при одном условии: каждая префиксная строка, более длинная, чем одна переменная, содержит некоторую операцию, первый операнд и второй операнд (вспомним, что мы предполагаем только бинарные операции). Предположим, что мы в состоянии идентифицировать первый и второй операнды, которые обязательно короче, чем первоначальная строка. Мы можем затем преобразовать длинную префиксную строку в постфиксную, сперва преобразуя первый операнд в постфикс, затем преобразуя второй операнд в постфикс и добавляя его в конец первого преобразованного операнда и, наконец, добавляя первоначальную операцию в конец результирующей строки. Таким образом, мы создали некоторый рекурсивный алгоритм для преобразования префиксной строки в постфиксную при условии, что мы должны задать некоторый метод идентификации операндов в префиксном выражении. Мы можем суммировать этот алгоритм следующим образом. 1. Если префиксная строка состоит из одной переменной, то она является своим постфиксным эквивалентом. 2. Пусть ор будет первой операцией в префиксной строке. 3. Найдем первый операнд opndl в данной строке. Преобразуем его в постфикс и обозначим как postl. 4. Найдем второй операнд opnd2 в данной строке. Преобразуем его в постфикс и обозначим как post2. 5. Требуемая строка формируется при помощи добавления одного за другим postl, post2 и op. Прежде чем трансформировать данный алгоритм преобразования в некоторую программу на языке Бейсик, рассмотрим ее входы и выходы. Мы хотим создать функцию convert, которая воспринимает некоторую строку символов. Строка представляет собой префиксное выражение, в котором все переменные являются одиночными буквами, а допустимые операции следующие: « + », «—», «*», «/» и «|». Эта функция выдает некоторую строку, которая является постфиксным эквивалентом введенной префиксной строки. Предположим, что существует другая функция — find, которая воспринимает некоторую строку и некоторую позицию и выдает некоторое целое число, которое равно длине самого длинного префиксного выражения, содержащегося внутри данной входной строки, начиная с этой позиции. Например, find(«a + cd»,l) выдает 1, поскольку «а» является самой длинной префиксной строкой, начиная с позиции 1 в строке «a + cd». Функция find(« + *abed + gh»,l) выдает 5, поскольку « + *abc» является самой длинной префиксной строкой, начиная с начала строки « + *abcd + gh». функция find(«a + cd»,2) выдает 3, по- 280
скольку « + cd» является самой длинной префиксной строкой, начиная с позиции 2 в строке «a + cd». Если во входной строке, начиная с заданной позиции, не существует префиксной строки, то функция find выдает 0. (Например, и find(«* + ab»,l), и find(« + *a—c*d»,6) выдадут 0.) Эта функция используется для того, чтобы идентифицировать первый и второй операнды некоторой префиксной операции. Предполагая, что функция find существует, некоторый алгоритм для подпрограммы преобразования, которая воспринимает некоторую префиксную строку prefix и устанавливает в переменную convert ее постфиксный эквивалент, может быть записан в следующем виде: function convert (prefix) if len (prefix) = 1 then 'проверить переменную if prefix состоит из одной буквы then convert=prefix else print «неверная строка префикса» convert=« » endif return endif 'префиксная строка длиннее, чем один символ; 'извлечь операцию и длины двух операндов op=mid$ (prefix, l, 1) m=find (prefix, 2) η=find (prefix, m+2) if (op не является операцией) or (m=0) or (n=0) or ((m+n-l)< >len (prefix)) then print «неверная строка префикса» convert=« » return endif opndl «mid S (prefix, 2, m) opnd2=mid S (prefix, m+2, n) post 1 = convert (opnd 1) post2=convert (opnd2) convert=postl+post2+op return Отметим, что мы используем соглашение о представлении некоторой функции в псевдокоде, которое было введено в конце разд. 2.1. В соответствии с этим соглашением имя функции (в данном случае это convert) используется в качестве значения ее возврата. В рекурсивной функции определение функции включает и рекурсивное обращение к ней. Отметим также, что в данный алгоритм было вставлено несколько проверок для того, чтобы гарантировать, что входная информация представляет собой некоторую верную строку префикса. Одними из наиболее трудных для распознавания ошибок являются те, которые получаются в результате неверных входных параметров и пренебрежения программиста при проверке их правильности. 281
Теперь обратим наше внимание на функцию find, которая воспринимает некоторую строку символов и начальную позицию и выдает длину самой длинной строки префикса, содержащейся в этой входной строке, начиная с этой позиции. Термин «самая длинная» в этом определении является избыточным, поскольку имеется самое большее одна подстрока, начиная с некоторой заданной позиции некоторой заданной строки, которая является верным префиксным выражением. Сперва покажем, что имеется самое большее одно правильное префиксное выражение, начиная с начала некоторой строки. Чтобы понять, отметим, что это является тривиальной истиной в некоторой строке длиной 1. Предположим, что это верно для короткой строки. Тогда длинная строка, которая содержит некоторое префиксное выражение в качестве начальной подстроки, должна начинаться или с некоторой переменной (и в этом случае переменная является требуемой подстрокой), или с некоторой операции. При удалении начальной операции оставшаяся строка становится короче первоначальной строки и, следовательно, может иметь самое большее одно начальное префиксное выражение. Это выражение является первым операндом начальной операции. Аналогичным образом оставшаяся подстрока (после удаления первого операнда) может иметь только одну начальную подстроку, которая является некоторым префиксным выражением. Это выражение должно быть вторым операндом. Следовательно, мы уникальным образом идентифицировали операцию и операнды префиксного выражения, начинающегося с начала некоторой произвольной строки, если такое выражение существует. Поскольку существует самое большее одна верная префиксная строка, начинающаяся с начала любой строки, то имеется самое большее одна такая строка, начиная с любой позиции в любой произвольной строке. Это очевидно, когда мы рассматриваем подстроку заданной строки, начиная с заданной позиции. Заметим, что это доказательство дало нам некоторый рекурсивный метод нахождения некоторого префиксного выражения в строке. Теперь применим этот метод в функции find, которая находит длину подстроки в некоторой строке prefix, начиная с позиции у, что дает некоторое верное префиксное выражение. function find (prefix, x) if x>len(prefix) then find=0 return endif first = mid S (prefix, x, 1) if first является некоторой буквой then 'первый символ является требуемой префиксной подстрокой find=l return endif 'найти два операнда mm=find (prefix, x+1) 282
nn=find (prefix, x+mm+1) if (mm=0) or (nn=0) or ((mm+nn+l)>len(prefix)) then find=0 else find=mm+nn+l endif return Следует убедиться в том, что понятно, как работают эти алгоритмы при помощи трассировки их действий как для верных, так и для неверных входных выражений. Еще более важно убедиться в том, что понятно, как они были разработаны и как логический анализ привел к естественному рекурсивному решению. Программы преобразования на языке Бейсик Теперь рассмотрим подпрограммы на языке Бейсик, которые реализуют приведенные выше алгоритмы с помощью методов предыдущего раздела. В функции convert переменными, которые должны быть помещены в стек при рекурсивных обращениях в этом алгоритме, являются переменные op, opnd2 и postl, поскольку всем им до рекурсивного обращения присваивается некоторое значение, которое затем используется. Текущие значения этих переменных алгоритма будут находиться в программных переменных СОР, C20PND и CPST1, а стеки с их значениями для предыдущих обращений буд^Мг находиться в массивах SOP, ,S20PND и SPST1. Значение переменной алгоритма opndl будет находиться в переменной C10PND, хотя ее не надо помещать в стек. Значение переменной алгоритма post2 будет находиться в переменной PTFX2. (Мы используем эти имена, поскольку полагаем, что существенны два первых символа в имени переменной, что зарезервированное слово, такое как POS, не может быть задано в качестве имени переменной и что массив и переменная не могут иметь одинаковые имена, хотя многие версии языка Бейсик не имеют этих ограничений. Мы также предполагаем, что оператор DEFSTR C,P,S задается в начале программы, так что мы начинаем все имена строковых переменных с одной из этих трех букв.) Вершина стека будет находиться в переменной ТР, а подпрограммы pushl и popandtestl с номерами операторов соответственно 1000 и 4000 будут использоваться для помещения в стек и для выборки из стека. Выходная переменная convert будет обозначаться PCNVERT (чтобы не конфликтовать с переменной СОР, и, кроме того, поскольку ON является зарезервированным словом, его нельзя включать в имя переменной во многих версиях языка Бейсик). Отметим, что convert содержит два рекурсивных обращения и никакое из них не может быть исключено. Таким образом, хотя для обслуживания рекурсивного стека используется версия подпрограммы popandtestl, а не pop, все же необходимо хра- 283
нить некоторый индикатор возврата. Текущее значение этого индикатора хранится в переменной ZRETADDR, а стек предыдущих значений — в массиве RETADDR. Таким образом, подпрограмма pushl помещает в стек переменные СОР, C20PND, CPST1 и ZRETADDR, а подпрограмма popandtestl переустанавливает значения всех этих переменных из стека, если стек не является пустым. Подпрограмма popandtestl устанавливает также в переменную UND значение TRUE, если происходит исчезновение стека (т. е. стек пустой и к нему не может быть обращений), и значение FALSE в противном случае. Мы используем значения 2 и 3 индикатора возврата для того, чтобы указать на возврат из первого и второго рекурсивных обращений к convert. Мы используем также две подпрограммы — ltr и optr — с номерами операторов 5000 и 6000 для того, чтобы определить, является ли некоторый символ буквой (операндом) или некоторым символом операции. Символ вводится в переменную PP. В переменную LTR устанавливается значение TRUE, если в РР находится буква, и значение FALSE в противном случае. В переменную OPTR устанавливается значение TRUE, если в РР находится символ некоторой операции, и значение FALSE в противном случае. Мы используем также некоторую вспомогательную переменную PAUX для того, чтобы хранить значение входной префиксной строки так, чтобы не изменять переменную PREFIX, которая является входной в данную подпрограмму моделирования. Переменная PAUX, а не переменная PREFIX используется как первый входной параметр для подпрограммы find. Таким образом, предполагая, что действуют приведенные выше соглашения и присутствуют соответствующие подпрограммы, подпрограмма convert может быть написана на языке Бейсик следующим образом: 20000 'подпрограмма convert 20010 'входы: PREFIX 20020 'выходы: PCNVERT 20030 'локальные переменные: СОР, CPST1, ClOPND, C20PND, FIND, 'LTR, Μ, Ν, OPTR, PAUX, PP, PTFX2, TP, UND, X, ZRETADDR 20040 ТР=0 20050 PAUX=PREFIX 20060 IF LEN (PAUX) > 1 THEN GOTO 20120 20070 'проверить на наличие переменной 20080 PP = PAUX 20090 GOSUB 5000: 'подпрограмма ltr воспринимает РР и устанавливает переменную LTR 20100 IF LTR=TRUE THEN PCNVERT=PAUX ELSE PRINT «НЕВЕРНАЯ ПРЕФИКСНАЯ СТРОКА»: PCNVERT=« » 20110 GOTO 20410: 'возврат из рекурсивной подпрограммы 20120 'префиксная строка состоит из некоторой операции и двух операндов 20130 'извлечь операцию и длины двух операндов 284
20140 COP=MID $ (PAUX, 1,1) 20150 X=2 20160 GOSUB 30000: 'подпрограмма find воспринимает PAUX и X и устанавливает FIND 20170 M=FIND 20180 X=M+2 20190 GOSUB 30000: 'подпрограмма find 20200 N=FIND 20210 PP=COP 20220 GOSUB 6000: 'подпрограмма optr воспринимает РР и устанавливает переменную OPTR 20230 IF OPTR = FALSE OR M=0 OR N=0 OR M+N+K > LEN(PAUX) THEN PRINT «НЕВЕРНАЯ ПРЕФИКСНАЯ СТРОКА»: PSTFX=« »: GOTO 20410 20240 C10PND=MID$(PAUX,2,M) 20260 C20PND=MID $ (PAUX, M+2, N) 20260 ZRETADDR=2: 'возврат в точку после первого рекурсивного обращения 20270 GOSUB 1000: 'подпрограмма pushl помещает переменные СОР, 'C20PND, CPST1 и ZRETADDR в стек 20280 'установить вход в первое рекурсивное обращение 20290 PAUX=C10PND 20300 GOTO 20060: 'первое рекурсивное обращение 20310 'это точка возврата из первого рекурсивного обращения 20320 CPST1 = PCNVERT 20330 'установить второе рекурсивное обращение 20340 ZRETADDR=3: 'возврат в точку после второго рекурсивного обращения 20350 GOSUB 1000: 'подпрограмма pushl 20360 PAUX=C20PND 20370 GOTO 20060: 'второе рекурсивное обращение 20380 'это точка возврата из второго рекурсивного обращения 20390 PTFX2=PCNVERT 20400 PCNVERT=CPSTl+PTFX2+COP 20410 'это точка возврата из рекурсивной подпрограммы 20420 GOSUB 4000: 'подпрограмма popandtestl переустанавливает переменные СОР, C20PND, CPST1, ZRETADDR и UND 20430 IF UND=TRUE THEN RETURN: 'возврат в главную подпрограмму 20440 IF ZRETADDR=2 THEN GOTO 20310 20450 IF ZRETADDR=3 THEN GOTO 20380 20460 'конец подпрограммы (Читатель может отметить, что нет необходимости помещать в стек все переменные СОР, C20PND и CPST1 для обоих рекурсивных обращений. Действительно, в переменную CPST1 не устанавливается никакого значения до первого рекурсивного обращения, а значение в переменной C20PND не используется после второго рекурсивного обращения. Таким образом, переменную CPST1 не нужно помещать в стек при первом обращении, а переменную C20PND не нужно помещать в стек при втором обращении. Так что можно было бы иметь три раздельных стека: первый стек состоял бы из массивов SOP и RETADDR, второй — из массива S20PND, а третий—из массива SPST1. Понадобились бы две различные подпрограммы push. Первая подпрограмма, используемая в первом обращении, по- 285
мещала бы переменную СОР в стек SOP, переменную ZRETADDR в стек RETADDR и переменную C20PND в стек S20PND. Вторая подпрограмма, используемая во втором обращении, помещала бы переменную СОР в стек SOP, переменную ZRETADDR в стек RETADDR и переменную CPST1 в стек SPST1. Требуется только одна подпрограмма popandtestl. Она бы сперва выбирала из стеков SOP и RETADDR для того, чтобы определить, возвращаемся ли мы из первого или второго обращения, и на основе этого она выбирала бы или из стека S20PND, или из стека SPST1. Хотя это может сэкономить некоторое пространство, с точки зрения машинного времени или времени программирования это смысла не имеет. Читатель может также отметить, что переменная PTFX2 может быть исключена, и операторы с номерами 20390 и 20400 можно свести к одному оператору PCNVERT = CPST1 + PCNVERT + СОР.) В подпрограмме find переменными, которые надо помещать в стек при рекурсивных обращениях в данном алгоритме, являются переменные χ и mm (опять переменную χ надо помещать в стек только при первом обращении, а переменную mm — только при втором обращении, но мы будем помещать в стек обе переменные при обоих обращениях). Для их текущих значений мы используем программные переменные ZX и ZMM, а массивы XX и ММ — для их стеков. Отметим, что мы используем раздельные стеки для программ find и convert. Вершина стека для подпрограммы find хранится в переменной ТТР, а подпрограммы push2 и popandtest2 с номерами операторов 1200 и 4200 используются для того, чтобы помещать в этот стек и выбирать из этого стека. Как и в случае с подпрограммой convert, подпрограмма find содержит два рекурсивных обращения, и никакое из них не может быть исключено. Следовательно, требуется использование некоторого индикатора возврата. Текущее значение этого индикатора находится в переменной Z2RETADDR, а в качестве стека используется массив R2RETADDR. Таким образом, подпрограмма push2 помещает в стек переменные ZX, ZMM и Z2RETADDR, л подпрограмма popandtest2 переустанавливает эти переменные из стека. Снова мы используем значения 2 и.З индикатора возврата. Подпрограмма find использует также подпрограмму ltr, обсуждавшуюся ранее: 30000 'подпрограмма find 30010 'входы: PAUX, X 30020 'выходы: FIND 30030 'локальные переменные: LTR, NN, PFIRST, РР, ТТР, UND, 'ZRETADDR, ZX 30040 ТТР = 0 30050 ZX=X 30060 IF ZX>LEN(PAUX) THEN FIND = 0: GOTO 30280: возврат 30070 PFIRST=MID$(PAUX, ZX, 1) 30080 PP=PFIRST 286
30090 30100 30110 30120 30130 30140 30150 30160 30170 30180 30190 30200 30210 30220 30230 30240 30250 30260 30270 30280 30290 30300 30310 30320 30330 GOSUB 5000: 'подпрограмма ltr воспринимает РР и устанавливает переменную LTR IF LTR = TRUE THEN FIND=1: GOTO 30280: 'возврат 'найти два операнда 'подготовить первый рекурсивный вызов Z2RETADDR=2 GOSUB 1200: 'подпрограмма push2 помещает в стек переменные- 'ZX, ZMM, Z2RETADDR 'установить вход и выдать первое рекурсивное обращение ZX=ZX+1 GOTO 30060: 'первое рекурсивное обращение 'возврат в эту точку после первого рекурсивного обращения ZMM=FIND 'подготовить второе рекурсивное обращение Z2RETADDR=3 GOSUB 1200: 'подпрограмма push2 ZX=ZX+ZMM+1 GOTO 30060: 'второе рекурсивное обращение 'возврат в эту точку после второго рекурсивного обращения NN=FIND IF NN=0 OR ZMM=0 OR ZMM+NN+l>LEN(PAUX) THEN FIND=0 ELSE FIND=ZMM+NN+1 'возврат из рекурсивной подпрограммы GOSUB 4200: 'подпрограмма popandtest2 восстанавливает переменные ZX, ZMM, Z2RETADDR и UND» IF UND=TRUE THEN RETURN: 'возврат в вызывающую программу IF Z2RETADDR=2 THEN GOTO 30180 IF Z2RETADDR=3 THEN GOTO 30250 'конец подпрограммы Рекурсивная обработка списков Одним важным приложением рекурсии является управление сложными структурами данных. Например, список целых чисел может быть определен рекурсивно как пустой список или единственный узел, содержащий некоторое целое число и указатель на другой список целых чисел. Таким образом, некоторый список, содержащий одно-единственное число, квалифицируется как список целых чисел, потому что его единственный узел содержит некоторое целое число и указатель на пустой список. Список из двух целых чисел имеет свой первый узел, содержащий некоторое целое число и указатель на некоторый список из одного целого числа. И список из η целых чисел состоит из первого узла, содержащего некоторое целое число и указательна список из η—1 целых чисел. Мы можем использовать это рекурсивное определение для того, чтобы построить некоторый алгоритм reverse, который переворачивает некоторый список 1st так, что его последний элемент становится первым и т. д. Метод базируется на том наблюдении, что обращение пустого списка или списка из однога элемента является самим этим списком. Если список имеет более чем один элемент, то обращение может быть выполнено» 287
при помощи обращения списка, образованного всеми узлами, за исключением первого, и затем добавления первого узла в конец списка. 1. function reverse (1st) 2. if lst=null 3. then reverse — 1st 4. return 5. endif 6. p=ptrnxt(lst) 7. if p=null 8. then reverse = 1st 9. return 10. endif 11. q=reverse (p) 12. ptrnxt (1st) = null 13. ptrnxt(p)=lst 14. reverse=q 15. return Для того чтобы понять этот алгоритм, рассмотрим строки 2—10 и увидим, что они гарантируют, что обращение пустого списка и списка из одного элемента является самим этим списком. Обращение списка из двух элементов проиллюстрировано на рис. 5.3.1. Поскольку список 1st не пуст (не null), p устанавливается в строке 6 так, чтобы указывать на второй узел в списке. Эта ситуация отображена на рис. 5.3.1, а. Поскольку ρ не пуст, алгоритм продолжает работать со строки 11, вызывая себя рекурсивно. (Отметим, что во время рекурсивного обращения значения 1st и ρ помещаются в стек, в 1st устанавливается р, в ρ устанавливается null, а в reverse устанавливается р. Когда осуществляется возврат из рекурсивного обращения, в q устанавливается reverse, а в 1st и ρ восстанавливаются их первоначальные значения.) Когда рекурсивное обращение делается для некоторого списка из одного элемента, то оно выдает некоторый указатель на этот список, который запоминается в переменной q в строке И. Переменные 1st и ρ не изменяются. Это отражается на рис. 5.3.1,6. Строки 12 и 13 помещают первый узел списка в его конец, как показано на рис. 5.3.1, е. И наконец, в строках 14 и 15 выдается обратный список. Отметим, что при возврате переменная 1st указывает на тот же самый узел, на который она указывала до обращения, но этот узел теперь находится в конце, а. не в начале списка. Для того чтобы установить в переменную 1st первый узел обращенного списка, мы могли бы выполнить оператор 1st = reverse (1st). На рис. 5.3.1, г—е показано обращение списка из четырех элементов. Отметим, что после возвращения из рекурсивного обращения (строка 11) переменная ρ указывает на последний узел обратного подсписка, который был первоначально вторым узлом во входном списке. Для читателя было бы полезно протрассировать данные рекурсивные обращения более подробно, 288
5 I 3 null 1st- J 5 Ρ Я щ I 3 Пц[[ 1st И 5 null 1st- 8 \ 1 7 2 null 1st И 8 1st-—Η 8 «"// Рис. 5.3.1. Обращение некоторого списка. о —после строки 6; б — после строки 11; в — после строки 13; г — после строки 6; д — после строки 11; е — после строки 13. включая и информацию в рекурсивном стеке для переменных 1st и р. Мы должны отметить, что нерекурсивный алгоритм обращения списка, хотя и не является таким интуитивным, является достаточно ясным. function reverse (1st) if lst=null
then reverse^lst return endif ρ=ptrnxt (1st) if p = null then reverse=1st return endif ptrnxt (1st) = null q=lst 'q находится на один шаг позади ρ г=ptrnxt (ρ) 'г находится на один шаг впереди ρ while r< >null do ptrnxt (ρ) =q q=p p=r r=ptrnxt (p) endwhile ptrnxt (p)=q reverse=ρ return Читателю предлагается подтвердить, что этот алгоритм обращает списки на рис. 5.3.1, α и г. Читателю также предлагается написать программы на языке Бейсик для того, чтобы реализовать рекурсивный и нерекурсивный алгоритмы и вывести один из другого. Хотя большинство рекурсивных алгоритмов для простых списков может быть реализовано нерекурсивным образом без использования стека, в следующей главе мы введем более сложные структуры данных, где использование рекурсии является существенным. Рекурсивные цепи Для некоторого рекурсивного алгоритма нет необходимости обращаться к самому себе непосредственно. Наоборот, он может обращаться к себе косвенно, как в следующем примере: 'алгоритм а 'алгоритм b 'обращение к алгоритму b 'обращение к алгоритму а 'конец алгоритма а 'конец алгоритма b 290
В этом примере алгоритм а обращается к алгоритму Ь, ко· торый в свою очередь обращается к а, который может опять обратиться к Ь< Таким образом, и а и b являются рекурсивными алгоритмами, поскольку они косвенно обращаются сами к себе. Однако тот факт, что они рекурсивные, не является очевидным при анализе отдельно тела любой из этих подпрограмм. Кажется, что алгоритм а обращается к другому алгоритму Ь, и при анализе одного алгоритма а невозможно определить, что он будет косвенно обращаться сам к себе. Рекурсивную цепь могут образовывать более двух алгоритмов. Таким образом, алгоритм а может обращаться к Ь, который обращается к с, ..., который обращается к ζ, который обращается к а. Каждый алгоритм в этой цепи потенциально может обращаться сам к себе, и он, следовательно, является рекурсивным. Программист должен гарантировать, что такая система не будет генерировать бесконечную последовательность рекурсивных обращений. Конечно, при преобразовании некоторой цепи рекурсивных алгоритмов в некоторую программу на языке Бейсик программист должен также гарантировать, что переменные и индикаторы возврата правильно помещаются в стек, так что смоделированное выполнение каждого оператора RETURN будет восстанавливать в эти элементы их соответствующие значения. Рекурсивное определение алгебраических выражений Рассмотрим некоторый пример такой рекурсивной цепи алгоритмов и преобразуем эти алгоритмы в программу на языке Бейсик. Рассмотрим следующую рекурсивную группу определений: 1. Выражение является некоторым термом, за которым следуют знак плюс и терм, или одним термом. 2. Терм является некоторым множителем, за которым следуют звездочка и множитель, или одним множителем. 3. Множитель является или некоторой буквой, или некоторым выражением, заключенным в скобки. Прежде чем рассматривать какие-либо примеры, отметим, что никакой из трех приведенных выше элементов не определяется непосредственно в терминах самого себя. Однако каждый из них определяется в терминах самого себя косвенным образом. Выражение определяется в терминах некоторого терма, терм определяется в терминах некоторого множителя, а множитель определяется в терминах некоторого выражения. Аналогичным образом множитель определяется в терминах некоторого выражения, которое определяется в терминах некоторого терма, который определяется в терминах некоторого множителя. Таким образом, весь набор определений составляет некую рекурсивную цепь. й9\
Теперь приведем несколько примеров. Самой простой "форткой множителя является буква. Таким образом, буквы А, В, С, Q, Ζ и Μ все являются множителями. Они также являются термами, поскольку некоторый терм может быть одним множителем. Они также являются выражениями, поскольку выражение может быть одним термом. Поскольку А является некоторым выражением, то (А) является множителем и, следовательно, термом так же, как и выражением; А+В является примером некоторого выражения, которое не является ни термом, ни множителем; (А+В), однако, является,всеми тремя; А*В является термом и, следовательно, некоторым выражением, но не множителем; А*В + С является выражением, но не является ни термом, ни множителем; А*(В + С) является термом и выражением, но не множителем. Каждый из приведенных выше примеров является некоторым верным выражением. Это может быть показано при помощи применения определения выражения к каждому из них. Рассмотрим, однако, строку А + *В. Она не является ни выражением, ни термом, ни множителем. Для читателя было бы полезно попытаться применить определения выражения, терма и множителя для того, чтобы увидеть^ что ни одно из них не описывает строку А+*В. Аналогичным образом (А+В»)С,и А+В + С не являются верными выражениями в соответствии с вышеприве1· денными определениями. Напишем некоторый алгоритм, который читает строку символов, печатает эту строку и затем печатает «верно», если она является правильным выражением, и «неверно» — в противном случае. Мы будем использовать три отдельные функции для распознавания соответственно выражений, термов и множителей. Сперва, однако, мы представим некоторый алгоритм для вспомогательной подпрограммы getsymb, которая имеет два входных параметра — str и pos. Параметр str содержит входную строку символов, а параметр pos является позицией в str следующего символа, который мы хотим обрабатывать. При входе в подпрограмму getsymb параметр pos сравнивается с длиной данной строки. Если pos< = len(str), то подпрограмма getsymb выдает символ из str в позиции pos и pos увеличивается на 1. Если pos>len(str), то подпрограмма getsymb выдает пробел. function getsymb (str, pos) if pos>len(str) then getsymb=« » else getsymb=mid $ (str, pos, 1) pos=pos+l endif return Функция, которая распознает выражения, называется expr. Она также имеет входные параметры str и pos. Функция ехрг выдает значение true, если в позиции pos в строке str начинается 292
некоторое выражение, в противном случае она выдаст false. Она также переустанавливает параметр pos в позицию, следующую за самым длинным выражением, которое она может найти. Функции factor и term очень похожи на функцию ехрг, за исключением того, что они ответственны за распознавание соответственно множителей и термов. Они также перемещают pos в позицию, следующую за самым длинным множителем или термом внутри строки str, которые они могут найти. Мы можем написать алгоритм главной программы следующим образом: read str print str pos=l ok=ехрг (str, pos) if ok=true and pos>len(str) then print «верно* else print «неверно» 'Данное условие может быть не выполнено по одной из двух причин (или обеим причинам). 'Если ok равно false, то нет верного выражения, начиная с позиции pos. 'Если pos<=len(str), то может быть некоторое верное выражение, 'начиная с начала строки str, но оно не занимает всю строку. endif Алгоритмы для функций ехрг, term и factor близко придерживаются определений, данных ранее. Каждая из этих подпрограмм пытается удовлетворить одному из критериев для того элемента, который она распознает. Если один из этих критериев выполняется, то выдается значение true. Если ни один из этих критериев не выполняется, то выдается значение false. function expr (str, pos) 'поиск некоторого терма ok=term (str, pos) if ok=false then expr= false return endif 'проверка следующего символа c=getsymb(str, pos) if c< >c+» then 'Мы нашли самое длинное выражение (один единственный терм). 'Передвигаем переменную pos так, что она ссылается на позицию, 'сразу же следующую за данным выражением pos« pos—1 ехрг=true return endif 'В этой точке мы нашли некоторый терм и знак плюс. 'Мы должны искать другой терм. ok=term (str, pos) if ok=true then expr«true else expr=false endif return 293
Подпрограмма term, которая распознает термы, очень похожа на эту, и мы приведем ее без комментариев: function term (str, pos) ok=factor (str, pos) if ok=false then term = false return endif c=getsymb(str, pos) if c< >«*» then pos=pos— 1 term=true return endif ok=factor (str, pos) if ok = true then term=true else term=false endif return Подпрограмма factor распознает множители, и она теперь должна быть совсем понятна. Она использует некоторую подпрограмму ltr, которая выдает значение true, если ее символьный параметр есть некоторая буква, и значение false в противном случае. function factor (str, pos) c^getsymbfstr, pos) if c< >«(» then 'проверка на букву factor=ltr <c) return endif 'данный множитель является некоторым выражением в скобках ok=expr(str, pos) if ok=false then factor=false return endif c=getsymb(str, pos) if c< >«)» then factor= false else fastor=true endif return Отметим, что в каждом из трех алгоритмов (expr, term и factor) входная переменная str никогда не модифицируется, так что ее не нужно помещать в стек. Входная и выходная переменная pos модифицируется, но, поскольку ее модифицированное значение используется впоследствии каждой из вызывающих программ (из-за того, что она является выходной переменной), ее старое значение не нужно сохранять, так что ее тоже не надо помещать в стек. Значения переменных с и ok также не нужно помещать в стек, потому что значения, присвоенные 294
этим переменным до рекурсивного обращения, никогда не используются после возврата (отметим, что в данном случае рекурсивное обращение происходит не на подпрограмму с тем же самым именем, а на одну из других подпрограмм, которая затем может обратиться к этой вызывающей подпрограмме). Аналогичным образом выходным переменным expr, term и factor никогда не присваиваются значения до рекурсивного обращения, так что и их не надо помещать в стек. Таким образом, мы имеем необычную ситуацию, при которой нет переменных, которые надо помещать в стек в рекурсивной подпрограмме. Поэтому заманчиво реализовать эти подпрограммы, используя реальные рекурсивные операторы GOSUB так, как они представлены в начале разд. 5.2, так что нам нет необходимости помещать в стек и индикаторы возврата. Теперь представим такую программу на языке Бейсик. Следует подчеркнуть, однако, что данная программа не будет работать на всех реализациях языка Бейсик или она может работать при простых входных выражениях, для которых рекурсия не является слишком сложной, но не будет работать для более сложных выражений. Далее приведена законченная программа, которая обрабатывает некоторое выражение в соответствии с приведенными выше правилами. При реализации данных алгоритмов мы используем переменную PS вместо pos, FCTR вместо factor, XPR вместо ехрг и GTSYMB вместо getsymb. 10 'программа findexp 20 DEFSTR А, С, S 30 TRUE^l 40 FALSE-0 50 PS=1 60 INPUT STR 70 PRINT STR 80 GOSUB 3000: 'подпрограмма ехрг устанавливает переменную XPR 90 OK=XPR 100 IF OK-TRUE AND PS>LEN(STR) THEN PRINT «ВЕРНО» ELSE PRINT «НЕВЕРНО» ПО 'Данное условие может быть не выполнено по одной из двух при- 'чин (или по обеим причинам). 120 'Если ОК равно FALSE, то нет верного выражения, начинающегося 130 'с позиции PS. Если PS< = LEN(STR), то может быть некоторое 140 'верное выражение, начиная с позиции PS, но оно не занимает всю 160 END ,СТР°КУ* 170 ' 180 ' 190 ' 2000 'подпрограмма getsymb 2010 'входы: PS, STR 2020 'выходы: GTSYMB, PS 2030 'локальные переменные: нет 2040 IF PS>LEN(STR) THEN GTSYMB=« » 2050 RE?fcSGTSYMB-MID $ (STR> PS> 1): PS=PS+1 295
2060 'конец подпрограммы 2070 ' 2080 ' 2090 ' 3000 'подпрограмма ехрг ЗОЮ 'входы: PS, STR 3020 'выходы: PS, XPR 3030 'локальные переменные: С, GTSYMB, OK, TERM 3040 GOSUB 4000: 'подпрограмма term устанавливает переменную TERM 3050 OK-TERM 3060 IF OK^TRUE THEN GOTO 3100 3070 'иначе выполняются операторы с номерами 3080—3090 3080 XPR-FALSE 3090 RETURN 3100 GOSUB 2000: 'подпрограмма getsymb устанавливает переменную GTSYMB 3110 С=GTSYMB 3120 IF C=«+» THEN GOTO 3171? ■£ 3130 'иначе выполняются операторы с номерами 3140—3160 3140 PS=PS-1 3150 XPR=TRUE 3160 RETURN 3170 GOSUB 4000: 'подпрограмма term 3180 OK^TERM 3190 IF OK^TRUE THEN XPR^TRUE ELSE XPR=FALSE 3200 RETURN 3210 'конец подпрограммы 3220 ' 3230 ' 3240 ' 4000 'подпрограмма term 4010 'входы: PS, STR 4020 'выходы: PS, TERM 4030 'локальные переменные: С, FCTR, GTSYMB, OK 4040 GOSUB 5O00: 'подпрограмма factor устанавливает переменную FCTR 4050 OK=FCTR 4060 IF OK=TRUE THEN GOTO 4100 4070 'иначе выполняются операторы с номерами 4080—4090 4080 TERM=FALSE 4090 RETURN 4100 GOSUB 2000: 'подпрограмма getsymb устанавливает переменную GTSYMB 4110 C-GTSYMB 4120 IF C=«»» THEN GOTO 4170 4130 'иначе выполняются операторы с переменными 4140—4160 4140 PS=PS-1 4160 TERM=TRUE 4160 RETURN 4170 GOSUB 5000: 'подпрограмма factor 4180 OK^FCTR 4190 IF OK=TRUE THEN TERM=TRUE ELSE TERM=FALSE 4200 RETURN 4210 'конец подпрограммы 4220 ' 4230 ' 4240 ' 5000 'подпрограмма factor 296
5Q1Q 'входы: PS, STR 5029 'выходы: FCTR, PS 503Θ 'локальные переменные: С, GTSYMB, LTR, OK 504Θ GOSUB 2000: 'подпрограмма getsymb устанавливает переменную GTSYMB 5050 C=GTSYMB 5060 IF C=«(» THEN GOTO 5110 5070 'иначе выполняются операторы с номерами 5080—5100 5080 GOSUB 6000: 'подпрограмма ltr воспринимает переменную С и устанавливает переменную LTR 5090 FCTR=LTR 5100 RETURN vm 5110 GOSUB 3000: 'подпрограмма ехрг устанавливает переменную XPR 5120 OK=XPR 5130 IF OK^TRUE THEN GOTO 5170 5140 'иначе выполняются операторы с номерами 5150—5160 5150 FCTR=FALSE 5160 RETURN 5170 GOSUB 2000: 'подпрограмма getsymb 5180 С-GTSYMB 5190 IF C=«)» THEN FCTR=TRUE ELSE FCTR=FALSE 5200 RETURN 5210 'конец подпрограммы 5220 ' 5230 ' 5240 ' 6000 'подпрограмма ltr 6010 'входы: С 6020 'выходы: LTR 6030 'локальные переменные: ALPH, I 6040 ALPH=«ABCDEFGHIJKLMNOPQRSTUVWXYZ» 6050 FOR 1 = 1 ТО 26 6060 IF MID $ (ALPH, 1, 1)=C THEN LTR=TRUE: RETURN 6070 NEXT I 6080 LTR=FALSE 6090 RETURN 6100 'конец подпрограммы Все три подпрограммы являются рекурсивными, поскольку каждая может косвенно обратиться сама к себе. Например, если мы станем трассировать действия программы findexp для входной строки «(A*B*OD) + (F*(F) Ч-G)», то найдем, что каждая из подпрограмм ехрг, term и factor обратится сама к себе. Предоставляем читателю реализовать эту программу без использования рекурсивных операторов GOSUB, но с помощью стека индикатора возврата. Упражнения 1. Определите постфиксное и префиксное выражения так, чтобы включить возможность использования унарных операций. Напишите программу для преобразования в постфиксное выражение некоторого префиксного выражения, возможно содержащего унарную операцию отрицания (представленную символом «(g)»). 2. Перепишите подпрограмму find, заданную в тексте, так, чтобы она была нерекурсивной и вычисляла длину префиксной строки при помощи подсчета числа операций и однобуквенных операторов. 297
3. Напишите рекурсивный алгоритм, который воспринимает некоторое префиксное выражение, состоящее из бинарных операций и одноцифровых целых операндов, и выдает значение данного выражения. Создайте реализацию этого алгоритма на языке Бейсик. 4. Модифицируйте рекурсивный и нерекурсивный алгоритм reverse, приведенный в тексте для того, чтобы обращать односвязный циклический список. 5. Напишите подпрограмму на языке Бейсик для того, чтобы реализовать рекурсивный алгоритм reverse, представленный в тексте. Затем упростите данную подпрограмму так, чтобы она не использовала стек. 6. Разработайте рекурсивный алгоритм для того, чтобы найти сумму всех чисел в некотором списке целых чисел. 7. Перепишите подпрограмму findexp так, чтобы она не использовала рекурсивные операторы GOSUB. 8. Напишите подпрограмму на языке Бейсик, которая моделирует рекурсивный алгоритм вычисления числа последовательностей η двоичных чисел, которые не содержат подряд двух 1. (Указание. Вычислите, сколько существует таких последовательностей, которые начинаются с 0, и сколько существует тех, которые начинаются с 1.) 9. Напишите программу на языке Бейсик, которая моделирует рекурсивный алгоритм сортировки некоторого массива А в следующем виде. (а) Пусть К будет индексом среднего элемента в данном массиве. (б) Отсортируйте элементы до элемента А (К) и включая его. (в) Отсортируйте элементы после А (К). (г) Соедините эти два подмассива в один отсортированный массив. Этот метод называется сортировкой слиянием. 10. Разработайте рекурсивный метод вычисления числа различных способов, которыми некоторое число к может быть записано как сумма, причем каждый из соответствующих операндов должен быть меньше чем п. Запрограммируйте этот метод. 11. Разработайте рекурсивный метод для печати в алфавитном порядке всех возможных перестановок букв, хранящихся в некотором символьном массиве размером п. Запрограммируйте этот метод. 12. Напишите подпрограмму на языке Бейсик, которая моделирует некоторый рекурсивный алгоритм нахождения к-го наименьшего элемента в некотором массиве чисел а при помощи выборки произвольного элемента a(i) из а и разделения а на элементы, которые меньше чем a(i), равны ему и больше его. 13. Задача «Восемь ферзей» состоит в том, чтобы поместить на шахматную доску восемь ферзей так, чтобы ни один из них не нападал на другого. Далее приводится некоторый рекурсивный алгоритм решения данной проблемы. Переменная board является массивом 8 χ 8, который представляет шахматную доску. Элемент board (i, j) равен значению true, если некоторый ферзь находится в позиции (i, j), и значению false в противном случае. Функция good (board) является некоторой функцией, которая выдает значение true, если никакие два ферзя не нападают друг на друга на шахматной доске, и значение false в противном случае. В конце программы состояние board представляет некоторое решение данной проблемы. program queens for i = 1 to 8 for j = 1 to 8 board (i, j) = false next j next i b=try(l) end function try(n) if n>8 then try=true 298
fetuftt else for i= 1 to 8 board (n, i)= true if good (board)= true and try(n+l)=true then try=true return else board (n, i) = false endif next i try = false return endif Рекурсивная подпрограмма try выдает значение true, если при заданном расположении на шахматной доске board в момент обращения к ней можно добавить какое-либо число ферзей в строках с n-й по 8-ю для того, чтобы: получить некоторое решение. Функция try выдает значение false, если нет решения, при котором имеются ферзи в позициях на шахматной доске board, которые уже содержат значение true. Если выдается значение true, то* подпрограмма также добавляет ферзей в строках с n-й по 8-ю для того, чтобы получить некоторое решение. Реализуйте эти алгоритмы и проверьте, что данная программа дает некоторое решение. [Идея, положенная в основу данного решения, состоит в следующем: шахматная доска board представляет глобальную ситуацию во время попытки найти некоторое решение. Следующий шаг в направлении получения некоторого решения выбирается произвольно (ферзь помещается в следующей неопробованной позиции в строке п) и рекурсивно проверяется, можно ли получить некоторое решение, которое включает этот шаг. Если это так, то организуется возврат. Если это не так, то происходит возвращение из попытки сделать следующий шаг (board (n, i) = false) и делается попытка другого возможного шага. Этот метод называется возвращением (backtracking).] 14. Некоторый массив maze из 0 и 1 размером 10X10 представляет некоторый лабиринт, в котором путешественник должен найти путь от maze(l, 1) до maze (10,10). Путешественник может перемещаться из некоторого квадрата в любой соседний квадрат в той же самой строке или том же столбце, но не может перескакивать через какие-либо квадраты или двигаться по диагонали. Кроме того, путешественник не может перемещаться в квадрат, который содержит 1. Элементы maze (1,1) и maze (10,10) содержат 0. Напишите подпрограмму, которая воспринимает некоторый массив maze и печатает или сообщение, что через данный лабиринт проход не существует, или список позиций, представляющий некоторый путь от (1,1) до (10,10). 15. Преобразуйте следующую схему рекурсивной программы в некоторую итерационную версию, которая не использует стек. Функция f(n) является некоторой функцией, которая выдает логическое значение, основанное на значении n, a g(n) является некоторой функцией, которая выдает значение того же самого типа, что и п, но без модификации п. subroutine rec (n) if not f (n) then 'любая группа операторов которая не изменяет значения η rec(g(n)) endif return Обобщите свой результат на случай, в котором гее является некоторой 16. Пусть f(n) будет некоторой функцией, принимающей логические значения, a g(n) и h(n) будут функциями, которые выдают некоторое зна- 299
чеиие того же самого типа, что и п, не модифицируя самого п. Пусть (stmts) представляет произвольную группу операторов, которые не модифицируют значения п. Покажите, что рекурсивный алгоритм гее эквивалентен итерационному алгоритму iter: subroutine rec(n) if not f (η) then (stmts) rec(g(n)) я rec(h(n)) endif return subroutine iter(n) push (s, n) while not empty (s) do n=pop(s) if not f (n) then (stmts) push(s, h(n)) push(s, g(n)) endif endwhile return Покажите, что оператор if в подпрограмме iter может быть заменен на такой цикл. while f (n) «false do (stmts) push(s, h(n)) n=g(n) endwhile
Глава 6 Деревья В этой главе мы сосредоточим наше внимание на структуре данных, которая оказывается чрезвычайно полезной во многих приложениях. Эта структура — дерево. Мы определим различные формы этой структуры данных и покажем, как они могут быть представлены на языке Бейсик и как их использовать для решения широкого круга задач. 6.1. БИНАРНЫЕ ДЕРЕВЬЯ Бинарное дерево — это конечное множество элементов, которое либо пусто, либо содержит один элемент, называемый корнем дерева, а остальные элементы множества делятся на два непересекающихся подмножества, каждое из которых само является бинарным деревом. Эти подмножества называются левым и правым поддеревьями исходного дерева. Каждый элемент бинарного дерева называется узлом дерева. На рис. 6.1.1 показан общепринятый способ изображения бинарного дерева. Это дерево состоит из девяти узлов, А — корень дерева. Его левое поддерево имеет корень В, а правое — корень С. Это изображается двумя ветвями, исходящими из А: левым — к В и правым — к С. Отсутствие ветви обозначает пустое поддерево. Например, левое поддерево бинарного дерева с корнем С и правое поддерево бинарного дерева с корнем Ε оба пусты. Бинарные деревья с корнями D, G, Η и I имеют пустые левые и правые поддеревья. На рис. 6.1.2 приведены некоторые структуры, не являющиеся бинарными деревьями. Читателю следует убедиться, что он понимает, почему каждое из них не соответствует данному ранее определению бинарного дерева. Если А — корень бинарного дерева и В — корень его левого или правого поддерева, то говорят, что А — отец В, а В—левый или правый сын А. Узел, не имеющий сыновей (такие как узлы D, G, Η и I на рис. 6.1.1), называется листом. Узел nl — предок узла п2 (а п2 — потомок nl), если nl—либо отец п2, либо отец некоторого предка п2. Например, в дереве из рис. 6.1.1 А — предок G и Η — потомок С, но Ε не является ни предком, 301
ни потомком С. Узел п2— левый потомок узла nl, если п2 является либо левым сыном nl, либо потомком левого сына nl. Похожим образом может быть определен правый потомок. Два узла являются братьями, если они сыновья одного и того же отца. Если каждый узел бинарного дерева, не являющийся листом, имеет непустые правые и левые поддеревья, то дерево называется строго бинарным деревом. Таким образом, дерево на рис. 6.1.3 строго бинарное, в то время как дерево на рис. 6.1.1 — Рис. 6.1.3. Строго бинарное Рис. 6.1.2. Структуры, не являют дерево щиеся бинарными деревьями. 302
нет (поскольку узлы С и Ε имеют по одному сыну). Строго бинарное дерево с η листами всегда содержит 2п—1 узлов. Доказательство этого факта оставляется читателю в качестве упражнения. Уровень узла в бинарном дереве может быть определен следующим образом. Корень дерева имеет уровень 0, и уровень любого другого узла дерева имеет уровень на 1 больше уровня своего отца. Например, в бинарном дереве на рис. 6.1.1 узел Ε — узел уровня 2, а узел Η — уровня 3. Глубина бинарного дерева— это максимальный уровень листа дерева, что равно длине самого длинного пути от корня к листу дерева. Стало быть, глубина дерева на рис. 6.1.1 равна 3. Полное бинарное дерево уровня η — это дерево, в котором каждый узел уровня η является листом и каждый узел уровня меньше η имеет непустые левое и правое поддеревья. На рис. 6.1.4 приведен пример полного бинарного дерева уровня 3. Мы определяем также почти полное бинарное дерево как бинарное дерево, для которого существует неотрицательное целое к такое, что 1. Каждый лист в дереве имеет уровень к или к+1. 2. Если узел дерева имеет правого потомка уровня к+1, тогда все его левые потомки, являющиеся листами, также имеют уровень к+1. Строго бинарное дерево из рис. 6.1.5, α не является почти полным, поскольку оно содержит листы уровней 1, 2 и 3, нарушая тем самым условие 1. Строго бинарное дерево на рис. 6.1.5,6 удовлетворяет условию 1, так как каждый лист имеет уровень 2 или 3. Однако при этом нарушается условие 2, поскольку А имеет не только правого потомка уровня 3 (J), но также и левого потомка; являющегося листом уровня 2 (Е). Строго бинарное дерево на рис. 6.1.5, β удовлетворяет обоим условиям и, следовательно, является почти полным бинарным деревом. Бинарное дерево на рис. 6.1.5, г — также почти полное бинарное дерево, но оно не является строго бинарным, поскольку узел Ε имеет лишь левого сына. (Мы должны отметить, что во многих работах такое дерево называют «полным бинарным деревом», а не «почти полным бинарным деревом». В других работах для обозначения «строго бинарного дерева» используют термины «полное» и «полностью бинарное». Мы, используем термины «строго бинарное», «полное» и «почти полное» так, как мы определили их.) Узлы почти полного бинарного дерева могут быть занумерованы так, что корню назначается номер 1, левому сыну — удвоенный номер его отца, а правому — удвоенный номер отца плюс единица. Рис. 6.1.5,5 иллюстрирует нумерацию узлов дерева, показанного на рис. 6.1.5, е. При такой схеме нумерации каждому узлу почти полного бинарного дерева присвоен уникальный номер, который определяет позицию узла внутри дерева. 303
Рис. 6.1.4. Полное бинарное дерево уровня 3. А Рис. 6.1.5.
Почти полное строго бинарное дерево с η листами имеет, как и любое другое строго бинарное дерево с η листами, 2п—1 узлов. Почти полное бинарное дерево с η листами, не являющееся строго бинарным, имеет 2п узлов. Существует два различных,, почти полных бинарных дерева с η листами: одно из них строго бинарное, а другое — нет. Например, оба дерева на рис. 6.1.5,0 и г почти полные и имеют по пять листьев; однако дерево на рис. 6.1.5, β строго бинарное, тогда как дерево на рис. 6.1.5, г — нет. Существует только одно почти полное бинарное дерево с η узлами. Оно является строго бинарным, если и только если η нечетно. Таким образом, дерево на рис. 6.1.5, β — это единственное почти полное бинарное дерево с девятью узлами, и оно же является строго бинарным, потому что 9—нечетное число. В то же время дерево на рис. 6.1.5, г — единственное почти полное бинарное дерево с Ш узлами, и оно не является строго бинарным, так как число 10 четное. Операции над бинарными деревьями К бинарным деревьям применим ряд примитивных операций. Если ρ — указатель узла nd бинарного дерева, то функция info(p) возвращает содержимое узла nd. Функции left (ρ), roght(p), father (ρ) и brother (p) возвращают соответственно указатели на левого сына nd, правого сына nd, отца nd и брата nd. Функции возвращают пустой указатель, если nd не имеет левого сына, правого сына, отца или брата. Наконец, логические функции isleft(p) и isright (р) возвращают значение «истина» (true), если nd является соответственно левым или правым сыном некоторого узла дерева, и значение «ложь» (false) — в противном случае. Заметьте, что функции isleft(p), isright (p) и brother (p) могут быть реализованы с помощью функций left (ρ), right (p) и father(p). Функция isleft может быть реализована следующим образом: function isleft (p) q=father(p) if q=null then isleft= false 'p указывает на корень else if left(q)=p then isleft=true else isleft=false endif endif return Похожим образом (или путем вызова isleft) может быть реализована функция isright. Функцию brother (р) можно реализовать с помощью функции isleft или isright следующим образом: 305
function brother (ρ) if father(p)=null then brother=null 'p указывает на корень else if isleft(p) then brother=right (father (p)) else brother=Left (father<p)) endif endif return При создании бинарного дерева полезны операции maketree, setleft и setright. Функция maketree (x) создает новое бинарное дерево, состоящее из одного узла с информационным полем х, и возвращает указатель этого узла. Функция setleft (ρ,χ) имеет входные параметры: ρ — указатель узла nd бинарного дерева, не имеющего левого сына, и х. Создается новый левый сын nd с информационным полем х. Функция sertight(p,x) аналогична функции setleft, за исключением того, что она создает правого сына nd. Применения бинарных деревьев Бинарное дерево — полезная структура данных в тех случаях, когда в каждой точке процесса должно быть принято одно решение из двух возможных. Например, предположим, что мы хотим найти все дубликаты в списке чисел. Один способ выполнения этой задачи состоит в сравнении каждого числа с предшествующими. Однако для этого требуется большое число сравнений. Путем использования бинарного дерева число сравнений может быть уменьшено. Считывается первое число и помещается в узел, который становится корнем бинарного дерева с пустыми левым и правым поддеревьями. Затем каждое последующее число из списка сравнивается с числом, находящимся в корне дерева. Если значения совпадают, то это дубликат. Если новое число меньше значения в корне, то процесс повторяется для левого поддерева, а если больше, то для правого. Так продолжается до тех пор, пока не встретится дубликат или пока мы не достигнем пустого поддерева. В последнем случае число помещается в новый узел данного места дерева. Ниже приведен алгоритм: 'прочитать первое число и поместить его в бинарное дерево, состоящее из 'одного узла read number tree=maketree (number) while еще есть числа на вводе do read number q=tree ρ=tree while (q< >null) and (number< >info(p)) do if number<info(p) then q=left(p) 306
else q=right (ρ) endif endwhile if number = info (p) then print number, « — дубликат» else if number<infо (p) then setleft (p, number) else setright (p, number) endif endif endwhile На рис. 6.1.6 приведено дерево, которое было бы создано при вводе следующих чисел: 14 15 4 9 7 18 3 5 16 4 20 17 9 14 5. Будет отпечатано, что числа 4, 9, 14 и 5 — дубликаты. Еще одна обычная операция — прохождение бинарного дерева, т. е. надо обойти все дерево, отметив каждый узел один раз. Можно было бы печатать содержимое каждого узла, когда мы отмечаем его, или обработать его каким- либо другим способом. В любом случае мы говорим о «посещении» узлов бинарногодерева. Очевидный порядок прохода узлов линейного списка — от первого к последнему. Однако для узлов дерева не существует такого «естественного» линейного порядка. Стало быть, в различных случаях для прохождения используют различные способы упорядочивания. Мы определим три метода прохождения. В каждом из этих методов не требуется никаких действий для прохождения пустого бинарного дерева. Методы определяются рекурсивно так, что прохождение бинарного дерева требует посещения корня и прохождения его левого и правого поддеревьев. Методы отличаются лишь порядком, в котором выполняются эти три действия. Чтобы пройти непустое бинарное дерево в прямом порядке (известном также как просмотр «в глубину»), мы выполняем следующие три операции: 1. Попасть в корень. 2. Пройти в прямом порядке левое поддерево. 3. Пройти в прямом порядке правое поддерево. Для прохождения непустого бинарного дерева в симметричном порядке: Рис. 6.1.6. Бинарное дерево, построенное для нахождения дубликатов. 307.
1. Пройти в симметричном порядке левое поддерево. 2. Попасть в корень. 3. Пройти в симметричном порядке правое поддерево. Для прохождения непустого бинарного дерева в обратном порядке: 1. Пройти в обратном порядке левое поддерево. 2. Пройти в обратном порядке правое поддерево. 3. Попасть в корень. На рис. 6.1.7 приведены два бинарных дерева и показано их прохождение тремя различными методами. Многие алгоритмы и процессы, использующие бинарные деревья, распадаются на две фазы. В первой фазе строится бинарное дерево, а во второй оно проходится. В качестве примера подобного алгоритма рассмотрим следующий метод сортировки. Входной файл содержит список чисел, и мы должны отпечатать числа в возрастающем порядке. По мере того как мы считываем числа, они могут быть помещены в бинарное дерево, такое как показанное на рис. 6.1.6. Однако в отличие от предыдущего алгоритма совпадающие значения также помещаются в дерево. При сравнении числа с содержимым узла дерева выбирается левое поддерево, если данное число меньше содержащегося в узле, и правое, если оно больше или равняется содержащемуся в узле. Стало быть, если входной список равен 14 15 4 9 7 18 3 5 16 4 20 17 9 14 5, то будет построено бинарное дерево, показанное на рис. 6.1.8. Такое бинарное дерево обладает тем свойством, что содержимое каждого узла в левом поддереве узла η меньше, чем содержимое узла п, и содержимое каждого узла в правом поддереве узла η больше или равно содержимому узла п. Таким образом, если дерево проходится в симметричном порядке (левое поддерево, корень, правое поддерево), то числа печатаются в возрастающем порядке. (Читателю предлагается доказать это утверждение в качестве упражнения.) Использование бинарных деревьев для сортировки и поиска будет рассмотрено подробнее в гл. 8 и 9. Назовем операцию прохождения бинарного дерева в симметричном порядке и печати содержимого каждого узла intrav(tree). Тогда алгоритм сортировки может быть написан так: read number tree^maketree (number) while еще есть числа на вводе do read number q=tree while q< >null do pssq if number<info(p) then q=left(p) else q=right(p) endif endwhile 308
Прямой порядок: ABDGCEHIF Симметричный порядок: DGBAHE1CF Обратный порядок: GDBHIEFCA Прямой порядок: ABCE1FJDGHKL Симметричный порядок: EICFJBGDKHLA Обратный порядок: IEJFCGKLHDBA Рис. 6.1.7. Прохождение бинарных деревьев. 20 Рис. 6.1.8. Бинарное дерево, построенное для сортировки.
if number < info (ρ) then setleft(p, number) else setright(p, number) endif endwhile 'прохождение дерева intrav (tree) В качестве другого применения бинарных деревьев рассмотрим следующий метод представления выражения, содержащего операнды и бинарные операторы, в виде строго бинарного дерева. Корень такого бинарного дерева содержит оператор, который должен быть применен к результату вычисления выражений, представленных левым и правым поддеревьями. Узел, представляющий оператор, имеет два непустых поддерева, а узел, представляющий операнд,— два пустых поддерева. На рис. 6.1.9 показаны примеры выражений и их представления в виде деревьев. Отметьте, что при этом не требуется скобок, так как структура дерева определяет порядок операций. Посмотрим, что происходит при прохождении этих бинарных деревьев. Прохождение в прямом порядке означает, что оператор (корень) предшествует своим операндам (поддеревьям). Стало быть, прохождение в прямом порядке дает префиксную форму выражения. (См. определение префиксной и постфиксной форм арифметического выражения в разд. 3.3 и 5.3). Это действительно так. Прохождение бинарных деревьев на рис. 6.1.9 дает префиксные формы +А*ВС (рис. 6.1.9,а) *+АВС (рис. 6.1.9,6) +A*-BCfD*EF (рис. 6.1.9,6) f+A*BO+ABC (рис. 6.1.9,г) Аналогично прохождение такого бинарного дерева в обратном порядке помещает оператор после двух его операндов, так что прохождение в обратном порядке дает постфиксную запись выражения. Стало быть, прохождение бинарных деревьев на рис. 6.1.9 в обратном порядке дает постфиксные формы АВО+ (рис. 6.1.9, а) АВ+С* (рис. 6.1.9,6) ABC-DEF*f*+ (рис. 6.1.9,6) ABC*+AB+C*t (рис. 6.1.9,г) Что происходит, когда такие бинарные деревья проходятся в симметричном порядке? Поскольку корень (оператор) проходится после узлов левого поддерева (первый операнд) и до узлов правого поддерева (второй операнд), мы можем ожидать получения инфиксной формы выражения. Действительно, при прохождении бинарного дерева рис. 6.1.9,. α получается инфиксное выражение А + В*С. Однако, поскольку бинарное дерево не содержит скобок, выражение, в инфиксной форме которого требуются скобки, чтобы отменить обычные правила старшин- 310
A + (В - С) * D t (£ * F) Θ (А+В*С)1((А+В)* С) г Рис. 6.1.9. Выражения и их представление с помощью бинарных деревьев. ства операций, не может быть восстановлено простым прохождением в симметричном порядке. Прохождение в симметричном порядке деревьев на рис. 6.1.9 дает выражения А+В*С (рис. 6.1.9,а) А+В*С (рис. 6.1.9,6) A+B-C*DtE*F (рис 6Лдв) A+B*CfA+B*C (рис. 6.1.9,г) которые корректны, если не учитывать скобки. 311
Упражнения 1. Докажите, что корень бинарного дерева является предком любого· узла дерева, за исключением себя. 2. Докажите, что узел бинарного дерева имеет не более одного отца. 3. Сколько предков имеет узел уровня η бинарного дерева? Докажите. 4. Чему равно максимальное число узлов уровня η в бинарном дереве? 5. Составьте алгоритм определения того, что бинарное дерево является (а) строго бинарным, (б) полным, (в) почти полным. 6. Докажите, что строго бинарное дерево с η листами содержит 2п—I узлов. 7. Дано строго бинарное дерево с η листами. Пусть level (i) для i от 1 до η равен уровню i-ro листа. Докажите, что сумма (V2) flevel(i) для всех i от 1 до η равна 1. 8. Докажите, что узлы почти полного бинарного дерева с η узлами могут быть пронумерованы от 1 до η таким образом, что номер, присвоенный левому сыну узла с номером i, равен 2i, а номер, присвоенный нравому сыну узла с номером i, равен 2i+l. 9. Два бинарных дерева подобны, если они оба пусты, либо они оба непусты и их левые поддеревья подобны и правые поддеревья подобны. Составьте алгоритм для определения, подобны ли два бинарных дерева. 10. Два бинарных дерева зеркально подобны, если они оба пусты, либо они оба непусты и левое поддерево каждого дерева подобно правому поддереву другого. Составьте алгоритм для определения, являются ли зеркально подобными два бинарных дерева. 11. Составьте алгоритмы для определения, является ли истинным то, что одно бинарное дерево подобно или зеркально подобно какому-либо поддереву другого. 12. Разработайте алгоритм для нахождения дубликатов в списке чисел, не использующий бинарное дерево. Сколько раз в вашем алгоритме два числа должны проверяться на равенство, если в списке η различных чисел? А если все η чисел равны? 13. Составьте алгоритм, получающий указатель на бинарное дерево, представляющее выражение, и возвращающий выражение в инфиксной записи, содержащей только необходимые скобки. 6.2. ПРЕДСТАВЛЕНИЯ БИНАРНЫХ ДЕРЕВЬЕВ В этом разделе мы исследуем различные методы реализации бинарных деревьев на языке Бейсик и приведем программы построения и прохождения бинарных деревьев. Мы рассмотрим также некоторые приложения бинарных деревьев. Узловое представление бинарных деревьев Так же как и узлы списка, узлы дерева могут быть расположены в элементах массива. В типичном случае каждый узел содержал бы поля INFO, LEFT, RIGHT и FTHER. (Мы исполь* зуем имя FTHER, а не FATHER, чтобы избежать возможных совпадений с переменной FALSE в тех версиях языка, в которых переменные распознаются по первым двум символам.) Поля узлов LEFT, RIGHT и FTHER указывают соответственно на левого сына узла, правого сына и отца. Мы можем написать л 312
10 MXNODE=500 20 DIM INFO (MXNODE) 30 DIM LEFT (MXNODE) 40 DIM RIGHT (MXNODE) 50 DIM FTHER (MXNODE) Если используется такое представление, то операции info(p), left (p), right (ρ) и father (p) были бы реализованы непосредственным обращением к элементам INFO(P), LEFT (P), RIGHT (Ρ) и FTHER (Ρ). Можно представить узлы и в другом виде: 10 MXNODE=500 20 DIM INFO (MXNODE) 30 DIM PTR(MXNODE,3) 40 LEFT» 1 50 RIGHT=2 60 FTHER = 3 Если используется данное представление, то операции info(p), left (p), right (ρ) и father (ρ) были бы реализованы непосредственным обращением к элементам INFO(P), PTR(P,LEFT), PTR(P,RIGHT) и PTR(PJFTHER). Далее в этом разделе мы используем именно такое представление. Операции isleft(p), isright(p) и brother (p) могут быть реализованы в терминах операций left (p), right (р) и father (р), как описано в предыдущем разделе. Для более эффективной реализации isleft и isright мы можем включить дополнительное поле ISLEFT, содержащее значение TRUE (или 1), если данный узел является левым сыном какого-то узла, и значение FALSE (или 0), если он является правым сыном какого-то узла или корнем дерева. Конечно же, корень однозначно определяется пустым (нулевым) значением поля FTHER. Эти операции могут быть реализованы и иначе: путем установки отрицательного знака у поля FTHER, если узел — левый сын, и положительного языка, если он — правый сын, и использования функции языка Бейсик SGN. Тогда указатель на узел отца равен абсолютному значению поля FTHER. Для более эффективной реализации brother (р) мы можем включить в каждый узел дополнительное поле BROTHER. В следующем фрагменте мы создаем список свободных элементов, из которого можно получать узлы бинарного дерева: 70 AVAIL=1 80 FOR I = 1 ТО MXNODE-1 90 PTR(I,LEFT)=I+1 100 NEXT I 110 PTR(MXNODE,LEFT)=0 Программы getnode и freenode просты и оставляются читателю в качестве упражнений. Отметьте, что список свободных элементов— это не бинарное дерево, а линейный список, узлы которого связаны друг с другом полем LEFT. Назовем только что 313
описанное представление узловым представлением бинарного дерева. В узловом представлении бинарного дерева операция make- tree, которая получает узел и превращает его в корень бинарного дерева с пустыми левым и правым поддеревьями, может быть реализована следующим образом (в программе maketree и последующих подпрограммах мы явно не указываем, что переменные MXNODE, INFO, PTR, AVAIL, LEFT, RIGHT » FTHER могут быть входными и выходными, поскольку это подразумевается в данном контексте): 13000 'подпрограмма maketree 13010 'входы: X 13020 'выходы: MAKETREE 13030 'локальные переменные: GTNODE, РХ 1Э040 GOSUB 1000: 'подпрограмма getnode устанавливает переменную GTNODE 13050 РХ=GTNODE 13060 INFO(PX)=X 13070 PTR(PX,LEFT)=0 13080 PTR(PX,RIGHT)=0 13090 PTR(PX,FTHER)=0 13100 MAKETREE=PX 13110 RETURN 13120 'конец подпрограммы Подпрограмма setleft устанавливает узел с содержимым X в качестве левого сына узла node(P). 14000 'подпрограмма setleft 14010 'входы: Ρ, Χ 14020 'выходы: нет 14030 'локальные переменные: MAKETREE, QX 14040 IF Р = 0 THEN PRINT «ЗАПРЕЩЕННАЯ ВСТАВКА»: STOP 14050 IF PTR(P, LEFT)<>0 THEN PRINT «НЕВЕРНАЯ ВСТАВКА»* STOP 14060 GOSUB 13000: 'подпрограмма maketree устанавливает переменную MAKETREE 14070 QX=MAKETREE 14080 PTR (P, LEFT) =QX 14090 PTR (QX, FTHER)-Ρ 14100 RETURN 14110 'конец подпрограммы Подпрограмма setright, которая создает правого сына узла node(P) с содержимым X, похожа на setleft и оставлена чита~ телю в качестве упражнения. Использование полей FTHER, LEFT и RIGHT не всегда необходимо. Если дерево всегда проходится сверху вниз (от корн» к листьям), операция father никогда не используется; в этом случае не нужно поле FTHER. Аналогично если дерево всегда проходится снизу вверх (от листьев к корню), никогда не используются операции left и right и не нужны поля LEFT ·» RIGHT. При этом остается возможность выполнения операций isleft и isright с помощью указателя со знаком в поле FTHER; 314
что обсуждалось выше: правый сын содержит положительное значение FTHER, а левый — отрицательное. Конечно, программы maketree, setleft и setright должны быть соответствующим образом изменены. Обычно мы предполагаем, что присутствуют все три поля (FTHER, LEFT и RIGHT), но можем сохранить пространство, исключив не требующиеся в конкретной ситуации поля. Следующая программа использует бинарное дерево для нахождения совпадающих чисел (она близка к алгоритму, приведенному в разд. 6.1): 10 'программа dup 20 DEFINT A, F, I, L, М, Р, Q, R, Τ 30 'сформировать множество узлов дерева 40 MXNODE=500: 'максимальное число узлов 50 DIM INFO(MXNODE), PTR(MXNODE,3) 60 LEFT=1 70 RIGHT=2 80 FTHER=3 90 'инициализировать список свободных узлов 100 AVAIL=1 ПО FOR 1=1 ТО MXNODE-1 120 PTR(I,LEFT)=I+1 130 NEXT I 140 PTR(MXNODE,LEFT)=0 150 'инициализировать дерево первым значением ввода 160 READX 170 GOSUB 13000: 'подпрограмма maketree устанавливает переменную MAKETREE 180 TREE-MAKETREE 190 'начало поиска дубликатов 200 READ NUMBER 210 IF NUMBER=-99 THEN STOP 220 PPNTR^TREE 230 QPNTR=TREE 240 'просмотр вниз по дереву 250 IF QPNTR^O OR NUMBER=INFO(PPNTR) THEN GOTO 290 260 PPNTR=QPNTR 270 IF NUMBER<INFO(PPNTR) THEN QPNTR=PTR(PPNTR,LEFT) ELSE QPNTR-PTR(PPNTR,RIGHT) 280 GOTO 250 290 'если число содержится в дереве, это — дубликат 300 IF NUMBER=INFO (PPNTR) THEN PRINT NUMBER; « — ДУБЛИКАТ»: GOTO 200 310 'в противном случае, вставить число в дерево 320 X-NUMBER 330 Р-PPNTR 340 'вызвать setleft (14000) или setright (15000); для обеих подпрограмм данные в Ρ и X 350 IF NUMBER<INFO(PPNTR) THEN GOSUB 14000 ELSE GOSUB 15000 360 GOTO 200 370 END 500 DATA . . . 315
Почти полное представление бинарных деревьев с помощью массивов Вспомните цз разд. 6.1, что узлы почти полного бинарного дерева могут быть занумерованы таким образом, что номер, назначенный левому сыну, равен удвоенному номеру, присвоенному его отцу, а номер, назначенный правому сыну, на единицу больше удвоенного номера, присвоенного его отцу. Для того чтобы представить почти полное бинарное дерево, нам не требуются связи с левым и правыми сыновьями и ς отцом. Вместо этого узел с назначенным ему номером ρ является отцом узлов с номерами 2р и 2р+1. Дерево с η листами представляется массивом info размером 2п—1, если дерево строго бинарное, и размером 2п, если нет. Следовательно, указателем узла служит целое число в интервале от 1 до 2п. Корень дерева находится в позиции 1. Левый сын узла, находящегося в позиции р, находится в позиции 2р, а правый сын — в позиции 2р + 1. Стало быть, операция left (р) может быть преобразована в 2р, a right (р)—в 2р+1. Аналогично, если левый сын находится в позиции р, то правый сын — в позиции р + 1. Значение father (р) может быть получено путем округления до целого значения р/2. Номер ρ указывает на левого сына в том и только в том случае, если ρ кратно 2 (четно). Следовательно, проверка, указывает ли ρ на левого сына (подпрограмма isleft), срстоит просто в сравнении 2*int(p/2) с р. На рис. 6.2.1 приведены массивы, представляющие почти полные бинарные деревья на рис. 6.1.5,.в и г* Мы можем расширить такое представление почти полных бинарных деревьев с помощью массивов до общего представления бинарных деревьев. Сделаем это путем определения такого почти полного бинарного дерева, которое содержит требуемое бинарное дерево. На рис. 6.2.2, а показаны два (не почти полных) бинарных дерева, и на рис. 6.2.2,6 показано наименьшее почти полное бинарное дерево, содержащее их. Наконец, на рис. 6.2.2, β показано представление с помощью массивов этих почти полных бинарных деревьев, полученных путем расширения исходных бинарных деревьев. При таком представлении элемент массива выделяется вне зависимости от того, будет ли он содержать узел дерева. Следовательно, мы должны пометить неиспользуемые элементы массива как пустые узлы дерева. Это может быть выполнено одним из двух методов. В первом требуется поместить специальное значение в те позиции массива, которые представляют пустые узлы дерева. Такое значение не должно соответствовать никакому возможному допустимому значению, которое может храниться в узле дерева. Например, в дереве, содержащем положительные числа, пустой узел можно указать каким-либо отрицательным числом. При использовании второго метода мы можем объявить сопутствующий массив flag, содержащий I 316
I: INFO: 1 A 2 В 3 с 4 D 5 £ 6 F 7 G 8 Я 9 / INFO: A В С D Ε F G Η I J Рис. 6.2.1. Представление почти полных бинарных деревьев с помощью массивов, в позициях, соответствующих действительным узлам дерева, и 0 в позициях, соответствующих пустым узлам дерева. Теперь приведем программу, которая использует бинарное дерево для нахождения совпадающих чисел во входном списке чисел. Используются рассматриваемое в этом разд. представление бинарных деревьев, а также программы maketree и setlefL 10 'программа dup2 20 MXNODE-500 30 DIM INFO (MXNODE), FLAG (MXNODE) 40 READX 50 GOSUB 13000: 'подпрограмма maketree инициализирует корень дерева 317
1 I л I LU 2 В τ I 3 с 3 J 4 5 6 I ! ! D ι 4 /: 5 6 I ι : i 7 £ 7 8 8 9 9 10 10 Μ 11 12 F 13 G Рис. 6.2.2. л —два бинарных дерева; б —почти полные расширения; в — представление с помощью массивов. 60 READ NUMBER 70 IF NUMBER=-99 THEN STOP 80 PPNTR-1 90 QPNTR=1 100 IF QPNTR>MXNODE THEN GOTO 150 110 IF FLAG(QPNTR) =0 OR NUMBER=INFO(PPNTR) THEN OOTO 160 120 PPNTR=QPNTR 130 IF NUMBER<INFO(PPNTR) THEN QPNTR=2*PPNTR ELSE QPNTR=2»PPNTR+1 140 GOTO 100 150 7если число содержится в дереве, это —дубликат 318
160 IF NUMBER=INFO(PPNTR) THEN PRINT NUMBER; « — ДУБЛИКАТ»: GOTO 60 170 'в противном случае вставить число в дерево 180 Х=NUMBER 190 Ρ=PPNTR 200 'вызвать setleft (14000) или setright (15000) 210 IF NUMBER< INFO (PPNTR) THEN GOSUB 14000 ELSE GOSUB 15000 220 GOTO 60 230 END 500 DATA 13000 'подпрограмма maketree 13010 'входы: X 13020 'выходы: нет (дерево всегда имеет корень в узле 1) 13030 'локальные переменные: II 13040 FOR 11=2 ТО MXNODE 13050 FLAG (II)-0 13060 NEXT II 13070 INFO(l)=X 13080 FLAG(1)-1 13090 RETURN 13100 'конец подпрограммы 14000 'подпрограмма setleft 14010 'входы: Ρ, Χ 14020 'выходы: нет 14030 'локальные переменные: РХ 14040 РХ=2*Р 14050 IF PX<1 OR PX>MXNODE THEN PRINT «ПЕРЕПОЛНЕНИЕ МАССИВА»: STOP 14060 IF FLAG(PX)<>0 THEN PRINT «НЕВЕРНАЯ ВСТАВКА»: STOP 14070 INFO(PX)-X 14080 FLAG(PX) = 1 14090 RETURN 14100 'конец подпрограммы 15000 'подпрограмма setright Обратите внимание, что при таком представлении программа maketree используется для инициализации массивов INFO и FLAG таким образом, чтобы они представляли дерево с одним узлом. Переменная MAKETREE не нужна, поскольку единственное бинарное дерево, представленное массивами INFO и FLAG, всегда имеет корень в узле 1. Вот почему переменная PPNTR получает значение 1 в операторе с номером 80. Отметьте также, что при движении вниз по дереву при таком представлении всегда необходимо проверять, не была ли превышена граница массива (MXNODE) (см. операторы 100 и 14050). Выбор представления бинарного дерева Какое представление бинарных деревьев предпочтительнее? На этот вопрос не существует общего ответа. Почти полное представление в виде массива в чем-то проще, хотя при этом 319
необходимо проверять, яе выходят ли указатели за границы массива. Ясно, что такое представление экономит память для тех деревьев, про которые известно, что они почти полные, поскольку отпадает необходимость в массивах LEFT, RIGHT и FTHER и даже не требуется массив FLAG. Оно также экономно для деревьев, которые отличаются от почти полных на малое -число узлов, или в тех случаях, когда из исходного почти поллого дерева последовательно исключаются узлы, хотя, возможно, что тогда потребуется массив FLAG. Однако почти полное представление в виде массива может ♦быть использовано только в тех случаях, когда требуется единственное дерево или когда число деревьев заранее фиксировано. Но в последнем случае для каждого дерева требуются отдель- <ные массивы; неиспользуемое пространство одного массива не может быть использовано для дерева, находящегося в другом массиве. Далее, для каждого дерева требуются отдельные программы (например, maketree, setleft и др.), если только большие массивы или переменные, указывающие соответствующий массив, не передаются как входные или выходные. В отличие от почти полного представления узловое представление требует дополнительных массивов LEFT, RIGHT и FTHER (хотя мы видели, что один или два из них в определенных ситуациях могут быть исключены), но при этом дает гораздо более гибкое ^использование набора узлов. При узловом представлении узел может быть помещен в любое место дерева, тогда как при почти полном представлении в виде массива узел может быть использован только в том случае, если он необходим в конкретном месте конкретного дерева. Стало быть, узловое представление предпочтительнее в случае общей, динамической ситуации многих деревьев непредсказуемой формы. Программа, которая находит дубликаты, является хорошей иллюстрацией возможных компромиссов. Программе dup, использующей узловое представление бинарных деревьев, кроме «массива INFO, требуются массивы LEFT и RIGHT. (Массив FTHER, на самом деле, не требуется данной программе.) Программа dup2, использующая почти полное представление в виде массивов, требует лишь одного дополнительного массива FLAG (и он также мог бы быть исключен, если бы на вводе были разрешены только положительные числа или целые числа, так ^чтобы пустой узел дерева мог бы быть представлен определенным отрицательным или нецелым значением INFO). В данном «примере можно использовать почти полное представление в виде массивов, поскольку требуется только одно дерево. Однако программа dup2 не может работать для всех случаев, когда работает программа dup. Например, предположим, что во вход- ήομ потоке числа упорядочены в возрастающем порядке. Тогда в получающемся дереве все левые поддеревья пустые (читателю предлагается проверить, что это действительно так, имитируя 320
работу программы). В этом случае единственными занятыми элементами INFO являются элементы с номерами 1, 3, 7, 15 и т. д. (каждый следующий номер на единицу больше удвоенного предыдущего). Если значение MXNODE равно 500, то максимально возможное число различных чисел, расположенных на вводе в возрастающем порядке, равно 16 (последнее будет помещено в элемент с номером 255). Сравните этот результат с программой dup, которая допускает ввод любых различных. 500 чисел в любом порядке, прежде чем будет заполнена вся доступная для узлов дерева память. Прохождение бинарных деревьев Методы прохождения бинарных деревьев лучше всего описываются рекурсивными алгоритмами, отражающими определения методов прохождений. Три алгоритма pretrav, intrav и postrav печатают содержимое бинарного дерева при прохождении соответственно в прямом, симметричном и обратном порядке. Во всех алгоритмах входная переменная — указатель на корень бинарного дерева. subroutine pretrav (tree) if tree=null then return endif print info (tree) pretrav (left (tree)) pretrav (right (tree)) return subroutine intrav (tree) if tree=null then return endif intrav (left (tree)) print info (tree) intrav<right(tree)) return subroutine postrav (tree) if tree=null then return endif postrav (left (tree)) postrav (right(tree)) print info (tree) return Читателю предлагается проимитировать действие этих алгоритмов на деревьях, приведенных на рис. 6.1.7 и 6.1.8. Конечно же, программы на языке Бейсик, осуществляющие эти действия, должны явным образом выполнять операции работы со стеком. Например, программа на языке Бейсик, выполняющая прохождение бинарного дерева в симметричном порядке и использующая узловое представление, может быть написана в следующем виде (мы используем переменную STMAX для 321
хранения максимального размера стека, чтобы не спутать ее с MXNODES и MAKETREE): 16000 подпрограмма intrav 16010 входы: TREE 16020 выходы: нет 16030 локальные переменные: EMPTY, Ρ, POPS, SITEM, STMAX, TP, X 16040 объявление стека для рекурсии 16050 STMAX=500 16060 DIM SITEM (STMAX) 16070 TP=0 16080 Ρ = TREE: 'начать с корня дерева 16090 'спуститься вниз, насколько возможно, по левым ветвям, сохраняя указатели на пройденные узлы 16100 IF P=0 THEN GOTO 16150 16110 Х=Р 16120 GOSUB 1000: 'подпрограмма push помещает X в стек 16130 P=PTR(P,LEFT) 16140 GOTO 16100 16150 'проверка конца 16160 GQSUB 3000: 'подпрограмма empty устанавливает значение переменной EMPTY 16170 IF EMPTY=TRUE THEN RETURN 16180 'в этот момент левое поддерево пусто, попасть в корень 16190 GOSUB 2000: 'подпрограмма pop устанавливает значение переменной POPS 16200 P=POPS 16210 PRINT INFO (P) 16220 'пройти правое поддерево 16230 P-PTR(P.RIGHT) 16240 GOTO 16090 16250 'конец подпрограммы Нерекурсивные программы прохождения бинарного дерева в прямом и обратном порядке, а также нерекурсивное прохождение бинарных деревьев с использованием почти полного представления бинарных деревьев в виде массивов оставлены читателю в качестве упражнений. Прошитые бинарные деревья Поскольку прохождение бинарного дерева — ст/оль распространенная операция, было бы полезно найти более эффективный метод ее выполнения. Исследуем программу intrav с целью выяснения, для чего нам нужен стек. Происходит выталкивание из стека, когда Ρ равен null (в узловой реализации — 0). Это возможно в двух случаях. Во-первых, когда цикл, состоящий из операторов 16090—16140, прекращается, будучи выполненным один или большее число раз. Отсюда следует, что программа прошла вниз по левым ветвям, записывая узлы по мере их прохождения в стек до тех пор, пока не был достигнут пустой указатель. Стало быть, верхний элемент стека — это значение Ρ перед тем, как Ρ стало нулем. Если дополнительный указатель Q хранит предыдущее значение Р, то можно непосредственно использовать его, а не выбирать значение из стека. 322
Во-вторых, Р раэ- но 0 в том случае, когда цикл, состоя- щий из оператороэ 16090—16140, не исполняется совсем. Это происходит после прихода в узел с пустым правым поддеревом, выполнения опер а тор а 16230 [P=PTR(P,- RIGHT)], который может установить Ρ в 0 и возврата на оператор 16090, а затем на оператор 16150. В этот момент мы бы «сбились с пути», если бы не было стека: верхний элемент стека указывает на тот узел, левое поддерево которого мы только что прошли. Предположим, однако, что узел с пустым пра· вым поддеревом содержал бы вместо пустого указателя указатель на узел, который находился бы на вершине стека в данной точке алгоритма. Тогда не бμлo бы больше нужды в стеке, поскольку узел непосредственно указывает на своего преемника при просмотре в симметричном порядке. Такой указатель называется нитью; его следует отличать от того указателя в дереве, который используется для связи узла с его левым или правым поддеревом. На рис. 6.2.3 показаны бинарные деревья из рис. 6.1.7 со связями-нитями, заменяющими пустые указатели в узлах с пустыми правыми поддеревьями. Нити нарисованы штриховыми линиями, чтобы отличать их от указателей дерева. Отметьте, что самый правый узел каждого дерева все-таки имеет пустой правый указатель, поскольку у этого узла нет преемника при данном порядке просмотра. Такие деревья называются бинарными деревьями, симметрично прошитыми справа. Как могут быть представлены связи-нити при реализации бинарных деревьев на языке Бейсик? При узловом представлении нить может быть представлена отрицательным значением PTR(P,RIGHT). Абсолютная величина отрицательного значения ч, _• Pic. 6.2.3. Симметрично прошитые справа бинарные деревья. 323
PTR(P,RIGHT) есть индекс узла, являющегося преемником узла node(P) при просмотре в симметричном порядке. Знак PTR(P,RIGHT) показывает, представляет ли абсолютная величина нить (знак минус) или указатель на непустое поддерево (знак плюс). Следующая программа проходит симметрично прошитое справа бинарное дерево с описанной выше реализацией нитей в симметричном порядке: 16000 'подпрограмма intrav2 16010 'входы: TREE 16020 'выходы: нет 16030 'локальные переменные: Р, Q 16040 Ρ = TREE 16050 'спуститься вниз по левым указателям, сохраняя в Q предыдущее значение Ρ 16060 Q=0 16070 IF P = 0 THEN GOTO 16110 16080 Q=P 16090 P = PTR(P,LEFT) 16100 GOTO 16070 16110 IF Q=0 THEN RETURN: 'проверка конца 16120 PRINT INFO(Q) 16130 P=PTR(Q,RIGHT) 16140 IF P>=0 THEN GOTO 16050: 'если node(Q) имеет правое подде- 16150 'рево, пройти его по нити к преемнику Q 16160 Q=-P 16170 PRINT INFO(Q) 16180 P = PTR(Q,RIGHT) 16190 GOTO 16140 16200 'конец подпрограммы При почти полном представлении бинарных деревьев в виде массивов для хранения нитей может использоваться массив FLAG. Как и раньше, FLAG(I) равен 0, если I представляет пустой узел. Если I представляет узел с правым сыном, то FLAG(I) равен 1, и правый сын узла находится в элементе с номером 2*1 + 1. Однако если I представляет узел, не имеющий правого сына, то FLAG(I) содержит отрицательный номер индекса преемника узла при просмотре в симметричном порядке. (Отметьте, что мы должны использовать отрицательные числа для того, чтобы отличать узел, имеющий правого сына, от узла, преемник которого при данном порядке просмотра есть корень дерева.) Если I — самый правый узел дерева и у него нет преемников, то FLAG(I) может содержать значение —(MXNODE + + 1). Мы оставляем читателю реализацию алгоритма прохождения дерева при таком представлении в качестве упражнения. В дереве, симметрично прошитом справа, может быть эффективно найден преемник любого узла при просмотре в симметричном порядке. Такое дерево может также быть просто построено. При узловом представлении функция maketree остается такой же, как и для непрошитой версии, а программы setleft и setright имеют следующий вид: 324
14000 'подпрограмма setleft 14010 'входы: Ρ, Χ 14020 'выходы: нет 14030 'локальные переменные: GTNODE, QX 14040 IF P=0 THEN PRINT «ПУСТАЯ ВСТАВКА»: STOP 14050 IF PTR(P,LEFT)<>0 THEN PRINT «НЕВЕРНАЯ ВСТАВКА»: STOP 14060 GOSUB 10000: 'подпрограмма getnode устанавливает переменную 'GTNODE так, что она указывает на вновь выделенный узел 14070 QX=GTNODE 14080 INFO(QX)=X 14090 PTR(P.LEFT) =QX 14100 PTR(QX,RIGHT) = -P: 'преемник узла node(QX) — node(P) 14110 PTR(QX,LEFT)=0 14120 PTR(QX,FTHER)=P 14130 RETURN 14140 'конец подпрограммы 15000 'подпрограмма setright 15010 'входы: Ρ, Χ 15020 'выходы: нет 15030 'локальные переменные: GTNODE, QX, RX 15040 IF P=0 THEN PRINT «ЗАПРЕЩЕННАЯ ВСТАВКА»: STOP 15050 IF PTR(P,RIGHT)>0 THEN PRINT «НЕВЕРНАЯ ВСТАВКА»: STOP 15060 GOSUB 10000: 'подпрограмма getnode устанавливает переменную GTNODE так, лто она указывает на вновь выде- • 'ленный .узел 15070 QX=GTNODE 15080 INFO(QX)=X 15090 RX=PTR(P,RIGHT): 'сохранить преемника узла node(P) 15100 PTR(P,RIGHT)=QX 15110 PTR(QX,LEFT)=0 15120 PTR(QX,RIGHT)=RX: 'преемник узла node (QX)—предыдущий 'преемник узла node (P) 15130 PTR(QX,FTHER)=P 15140 RETURN 15150 'конец подпрограммы Мы оставляем читателю написание программ maketree, setleft и setright для симметрично прошитых справа бинарных деревьев при почти полном представлении в виде массивов. Похожим образом может быть определено бинарное дерево, симметрично прошитое слева: дерево, в котором каждый пустой левый указатель изменен так, что он содержит нить — связь к предшественнику данного узла при просмотре в симметричном порядке. Симметрично прошитое бинарное дерево — это бинарное дерево, которое симметрично прошито слева и симметрично прошито справа. Однако бинарное дерево, симметрично прошитое слева, не дает тех преимуществ, которые имеет бинарное дерево, симметрично прошитое справа. Мы можем также определить прямопрошитые слева и справа бинарные деревья; в них пустые правые и левые указатели узлов заменены соответственно на их преемников и предшественников при прямом порядке просмотра. Прямопрошитое справа дерево может эффективно проходиться в прямом порядке без использования стека. Сим- 325
Рис. 6.2.4. Бинарное дерево, представляющее выражение 3+4· (6—7)/5+3. метрично прошитое справа дерево может также проходиться в прямом порядке без использования стека. Алгоритмы прохождения оставляются читателю в качестве упражнений. Разнородные бинарные деревья Часто информация, содержащаяся в различных узлах бинарного дерева, имеет отличающиеся атрибуты. Например, представляя выражение с числовыми операндами, мы можем ис- 326
пользовать бинарное дерево так, нто его листы содержат числа, а узлы, не являющиеся листами,— символы, представляющие операторы. На рис. 6.2.4, α приведено такое бинарное дерево. Для представления такого дерева на языке Бейсик мы можем поместить в поле INFO каждого узла указатель — индекс в массиве типа, соответствующего данному узлу. Например, если узел должен представлять целые числа, то указатель должен задавать элемент целого массива. В каждом узле дерева дол· жен содержаться также признак типа объекта, на который ука- зывает поле INFO. На рис. 6.2.4,6 показано такое представление бинарного дерева из рис. 6.2.4, а. На рисунке показаны два массива: OPER— для хранения возможных операторов и NUM — для хранения операндов. Каждый узел дерева содержит два поля (в дополнение к указателям левого сына, правого сына и отца). Первое поле содержит символ, представляющий тип узла. Если это символ «N», то узел представляет операнд, и второе поле узла задает позицию операнда в массиве NUM. Если же это символ «О», то узел представляет оператор, и второе поле узла задает позицию оператора в массиве OPER. В этом примере мы можем заранее задать значение массива символов, содержащего пять арифметических операций, и использовать дополнительный массив, содержащий значения операндов. 10 DEFSTRO 20 DIM INFO (500): 'указывает на массив NUM или OPER 30 DIM OTYPE(500): ЧЫ^операнд, «0»«оператор 40 DIM PTR (500,3) 50 LEFT-1 60 RIGHT-2 70 FTHER-3 80 DIM OPER (5): 'содержит операторы 90 OPER(l)=«+» 100 OPER(2)=«-» 110 OPER(3)=«»» 120 OPER(4)=«/» 130 OPER (5) ~«f» 140 DIM NUM (500): 'содержит операнды Отметьте, что в массиве OPER содержится только одна копия каждого оператора (поскольку нам заранее известны все операторы, которые будут использованы), но в NUM могут находиться несколько копий операнда, например три. (Это связано с тем, что число возможных операндов слишком велико, чтобы было оправданно постоянное хранение в массиве только одной копии каждого операнда. Более того, просмотр всего массива для определения положения уже находящегося в массиве операнда может быть также слишком обременительным.) Мы можем представить операторы и другим способом: ввести строковую переменную OPER, инициализировать ее строкой «+ — */|» и использовать для получения требуемого оператора функцию MID$. Следующий способ — использовать часть поля 327
INFO узла для хранения в зависимости от содержимого OTYPE либо самого числового операнда, либо указателя на соответствующий оператор в OPER (при условии что все операнды и промежуточные результаты — целые числа). Таким образом, была бы сохранена память, требующаяся для массива NUM. Еще один способ заключается в хранении как операторов, так и операндов в виде строки символов в части поля INFO; для преобразования операнда в числовую форму используется функция VAL. В этом случае не требуются ни OPER, ни NUM. Напишем на языке Бейсик подпрограмму evbtree, которая получает указатель на дерево, представляющее выражение, и вычисляет значение этого выражения. Используется вспомогательная подпрограмма apply. Первая входная переменная apply — символ, представляющий оператор, и остальные две — числовые операнды. Подпрограмма apply возвращает число — результат применения оператора к данным двум операндам. Лучше всего определять evbtree рекурсивно: в виде алгоритма функции, получающей указатель на дерево, вычисляющей левое и правое поддеревья и затем применяющей к этим двум результатам оператор, содержащийся в корне. Мы используем первое из описанных выше представлений. Однако заметим, что отдельное поле OTYPE не является необходимым, поскольку узел содержит операнд в том и только в том случае, когда он есть лист. Стало быть, проверка, содержит ли node(p) операнд, эквивалентна проверке, равен ли указатель ptr(p.left) значению null. function evbtree (tree) if ptr (tree,left)= null then 'выражение — единственный операнд evbtree=num (info (tree)) return else 'вычислить левое поддерево frsoper=evbtree (ptr (tree,left)) 'вычислить правое поддерево secoper=evbtree (ptr (tree.right)) 'выделить оператор osymb=oper (info (tree)) 'применить оператор и возвратить результат evbtree=apply (osymb.f rsoper.secoper) return endif Используя приемы, описанные в гл. 5, мы можем реализовать этот алгоритм в виде программы на языке Бейсик. 21000 'подпрограмма evbtree 21010 'входы: TREE 21020 'выходы: EVBTREE 21030 'локальные переменные: APPLY, CFRSOPEN, CPARAM, 'CRETADDR, FRSOPER, I, OSYMB, PARAM, Ql, Q2, RETADDR, 'SECOPER 21040 'определить стек для рекурсии; каждый элемент стека состоит из 'указателя на дерево, индикатора адреса возврата и значения пер- 'вого операнда 328
21050 21060 21070 21080 21090 21100 21110 21120 21130 21140 21150 21160 21170 21180 21190 21200 21210 21220 21230 21240 21250 21260 21270 21280 21290 21300 21310 21320 21330 21340 21350 21360 21370 21380 21390 21400 21410 21420 21430 21440 DIM PARAM(50), RETADDR(50), FRSOPER(50) TP=0: 'инициализировать пустой стек 'поместить в стек начальную запись CPARAM=TREE: 'CPARAM — указатель текущего дерева CRETADDR=1: 'CRETADDR — индикатор текущего адреса возврата: Ί — возврат в главную программу '2 — возврат после первого рекурсивного вызова '3 — возврат после второго рекурсивного вызова GFRSOPER=0: 'CFRSOPER — текущее значение первого операнда; инициализировано в 0 GOSUB 1000: 'подпрограмма push сохраняет CPARAM, 'CRETADDR и CFRSOPER в стеке 'начало имитируемой рекурсивной программы 'операторы 21160—21210 проходят левую сторону дерева до тех 'пор, пока не найден лист (операнд) 'когда найден лист, вернуть значение операнда в переменной 'EVBTREE IF PTR(CPARAM,LEFT)-0 THEN EVBTREE=NUM(INFO(CPARAM)): GOTO 21380 'имитация рекурсивного вызова evbtree(ptr(tree,left)) GOSUB 1000: 'подпрограмма push CR ET ADDR=2 CPARAM= PTR (CPARAM,LEFT) GOTO 21130 'возврат в данную точку после вычисления evbtree(ptr(tree,left)) CFRSOPER=EVBTREE 'первый операнд найден; вычислить второй операнд путем вызова 'evbtree (ptr (tree,right)) GOSUB 1000: 'подпрограмма push CRETADDR-3 CPARAM=PTR (CPARAM,RIGHT) GOTO 21130 'возврат в данную точку после вычисления evbtree (ptr (tree,right)) SECOPER=EVBTREE 'применить оператор к двум операндам OSYMB^OPER (INFO (CPARAM)): 'оператор Ql=CFRSOPER: 'первый операнд Q2=SECOPER: 'второй операнд GOSUB 6000: 'подпрограмма apply получает OSYMB, Q1 и Q2 'и устанавливает переменную APPLY EVBTREE=APPLY 'имитировать возврат после вычисления evbtree (tree), когда tree 'указывает на узел с операндом 'имитация возврата из evbtree I = CRETADDR: 'сохранить текущий адрес возврата GOSUB 20Q0: 'подпрограмма pop выбирает из стека и устанавливает переменные CPARAM, CRETADDR и 'CFRSOPER IF 1=1 THEN RETURN IF 1-2 THEN GOTO 21220 IF 1 = 3 THEN GOTO 21290 'конец подпрограммы Отметьте, что в стеке запоминаются не все переменные, использующиеся в алгоритме (например, secoper); переменные записываются в стек, только если их значение может быть изменено последующими рекурсивными вызовами. 329
Упражнения 1. Напишите подпрограмму на языке Бейсик, которая получает указатель узла и возвращает значение TRUE, если этот узел является корнем действительно бинарного дерева, и FALSE в противном случае. 2. Напишите подпрограмму на языке Бейсик, которая получает указатель узла бинарного дерева и возвращает уровень узла в дереве. 3. Напишите подпрограмму на языке Бейсик (для обоих рассмотренных в данном разделе представлений), которая получает указатель узла бинарного дерева и создает новое бинарное дерево, являющееся зеркальным отражением первого (т. е. все левые поддеревья становятся правыми поддеревьями и наоборот; см. упражнения 6.1.10 и 6.1.11). 4. Напишите подпрограммы на языке Бейсик, которые преобразуют бинарное дерево, представленное исключительно массивом FTHER (в котором поле FTHER левого сына содержит указатель на его отца со знаком минус, а поле FTHER правого сына — указатель на его отца), в представление с использованием массивов LEFT и RIGHT и наоборот. 5. Напишите программу на языке Бейсик для выполнения следующего эксперимента. Сгенерируйте 100 различных случайных чисел. По мере генерации чисел вставляйте их в сперва пустое бинарное дерево так, чтобы все числа в левом поддереве узла были меньше числа в узле, которое в свою очередь меньше чисел в правом поддереве узла. После того как вставлены все 100 чисел, напечатайте уровень листьев с наибольшим и наименьшим уровнями. Повторите процесс 50 раз. Напечатайте таблицу, сколько раз из 50 запусков программы разница между максимальным и минимальным уровнями листьев была равна 1, 2, 3 и т. д. 6. Напишите подпрограмму на языке Бейсик, которая получает два указателя на некорневые узлы бинарного дерева и возвращает указатель на самого молодого общего предка этих узлов. 7. Напишите подпрограммы на языке Бейсик для прохождения бинарного дерева в прямом и обратном порядках, используя узловое представление дерева. 8. Напишите подпрограммы на языке Бейсик для прохождения бинарного дерева в симметричном, прямом и обратном порядках, используя почти полное представление в виде массива. 9. Напишите подпрограмму на языке Бейсик, которая создает бинарное дерево по (а) информации о прохождении дерева в прямом и симметричном порядках; (б) информации о прохождении дерева в прямом и обратном порядках. Каждая - подпрограмма получает в качестве входа две строки символов. Создаваемое дерево должно содержать в каждом узле по одному символу. 10. Как бы вы объяснили схожесть приведенной в этом разделе нерекурсивной подпрограммы для прохождения дерева в симметричном порядке и нерекурсивной программы для решения задачи о «Ханойских башнях» из разд. 5.2? 11. Предметный указатель учебника состоит из упорядоченных по алфавиту основных терминов, каждый из которых сопровождается множеством номеров страниц и множеством подтерминов. Подтермины печатаются на следующих за основным термином строках: они упорядочены по алфавиту внутри основного термина. Каждый подтермин сопровождается множеством номеров страниц. Разработайте структуру данных для представления такого предметного указателя и напишите следующую программу на языке Бейсик для печати предметного указателя по входным данным. Каждая входная строка начинается либо с символа Μ (основной термин), либо с символа S (подтермин). -Строка с символом Μ содержит вслед за ним основной термин и число η (возможно, равное 0), за которым следуют η номеров страниц, на которых встречается основной термин. Строка с символом S строится аналогично, за исключением того, что она содержит не основной термин, а подтермин. Входные строки не появляются на вводе в каком-либо конкретном порядке: 330
единственное условие: каждый подтермин считается подтермином последнего предшествующего основного термина. Для одного основного термина или подтермина может быть несколько входных строк (все номера страниц, появляющиеся для конкретного термина на любой строке, должны быть отпечатаны вместе с этим термином). Предметный указатель надо отпечатать по одному термину на строке, за термином идут в порядке возрастания номера всех строк, на которых встречается термин. Основные термины должны быть отпечатаны в алфавитном порядке. Подтермины должны следовать непосредственно за их основным термином также в алфавитном порядке. Подтермины должны печататься за основным термином с отступом. Множество основных терминов следует организовать в виде бинарного дерева. Каждый узел дерева содержит (кроме левого и правого указателей и самого основного термина) указатели на два других бинарных дерева. Одно из них представляет множество номеров страниц, на которых встречается основной термин, а другое представляет множество подтерминов основного термина. Каждый узел в бинарном дереве подтермина содержит (кроме левого и правого указателей и самого подтермина) указатель на бинарное дерево, представляющее множество номеров страниц, на которых встречается подтермин. 12. Определите тернарное дерево и распространите иа него понятия двух предыдущих разделов. 6.3. ПРИМЕР: АЛГОРИТМ ХАФФМЁНА Рассмотрим следующую проблему. Предположим, что у нас есть алфавит из η символов и длинное сообщение} состоящее из символов этого алфавита. Мы хотим закодировать сообщение в виде длинной строки битов следующим образом (бит определяем как единицу, содержащую 0 или 1). Присвоим каждому символу алфавита определенную последовательность битов (код). Затем соединим отдельные коды символов, составляющих сообщение, и получим кодировку сообщения; Например, предположим, что алфавит состоит из четырех символов — А, В, С и D — и что символам назначены следующие коды: Символ А В С D Код 010 100 000 111 Сообщение ABACCDA кодируется как 01010001000000011101Q· Однако такая кодировка была бы неэффективной, поскольку для каждого символа используются 3 бит, так что для кодировки всего сообщения требуется 21 бит. Предположим, что каждому символу назначен 2-битовый код: Символ А в с D Код 00 01 10 И 331
Тогда кодировка сообщения была бы 00010010101100; требуется лишь 14 бит. Мы хотим найти код, который минимизирует длину закодированного сообщения. Рассмотрим наш пример еще раз. Каждая из букв В и D появляется в сообщении только один раз, а буква А — три раза. Стало быть, если выбран код, в котором букве А назначена более короткая строка битов, чем буквам В и D, то длина закодированного сообщения будет меньше. Это происходит от того, что короткий код (представляющий букву А) появляется более часто, чем длинный. В самом деле, коды могут быть назначены следующим образом: Символ А в с D Частота 3 1 2 1 Код 0 ПО 10 111 При использовании этого кода сообщение ABACCDA кодируется как 0110010101110, что требует лишь 13 бит. В очень длинных сообщениях, которые содержат символы, встречающиеся чрезвычайно редко, экономия существенна. Обычно коды создаются не на основе частоты вхождения символов в отдельно взятом сообщении, а на основе их частоты во всем множестве сообщений. Для каждого сообщения используется один и тот же код. Например, если сообщения состоят из английских слов, могут использоваться известные данные о частоте появления символов алфавита в английском языке. Отметьте, что если используются коды переменной длины, то код одного символа не должен совпадать с началом кода другого символа. Такое условие должно выполняться, если раскодирование происходит слева направо. Если бы код символа χ—с(х) совпадал с началом кода символа у—с (у), то, когда встречается код с(х), неясно, является ли он кодом символа χ или началом кода с (у). В нашем случае битовая строка сообщения просматривается слева направо. Если первый бит равен 0, то это символ А; в противном случае это В, С или D и проверяется следующий бит. Если второй бит равен 0, то это символ С, иначе это В или D и надо проверить третий бит. Если он равен 0, значит это В, а если 1, то D. Как только распознан первый символ, процесс повторяется для нахождения второго символа, начиная со следующего бита. Такая операция подсказывает метод реализации оптимальной схемы кодирования, если известны частоты появления каждого символа в сообщении. Находим два символа, появляющихся наименее часто. В нашем примере это В и D. Будем различать их по последнему биту кодов: 0—В, 1 — D. Соединим эти сим- 332
волы в единый символ BD, появление которого означает, что это либо символ В, либо символ D. Частота появления этого нового символа равна сумме частот двух составляющих символов. Стало быть, частота BD — 2. Теперь у нас есть три символа: А (частота 3), С (частота 2) и BD (частота 2). Снова выберем два символа с наименьшей частотой, т. е. С и BD. Будем различать их по последнему биту кодов: 0 — С, 1 — BD. Затем два символа объединяются в один символ CBD с частотой 4. К этому времени осталось только два символа — А и CBD. Они объединяются в один символ ACBD. Будем различать их по последнему биту кодов: 0 — А, 1 — CBD. Символ ACBD содержит весь алфавит, ему присваивается в качестве кода пустая строка битов нулевой длины. Это означает, что вначале раскодирования до момента проверки какого- либо бита известно, что этот символ содержится в ACBD. Двум символам, составляющим ACBD (А и CBD), присваиваются соответственно коды 0 и 1: если встречается 0, значит закодирован символ А, а если 1, то это С, В или D. Аналогично двум символам, составляющим CBD (С и BD), назначаются соответственно коды 10 и 11. Первый бит указывает, что символ входит в группу CBD, а второй позволяет отличить С и BD. Символам, составляющим BD (В и D), назначаются соответственно коды ПО и 111. Следуя этому процессу, символам, которые появляются в сообщении часто, присваиваются более короткие коды, чем тем, которые встречаются редко. Операция объединения двух символов в один предполагает использование структуры бинарного дерева. Каждый лист представляет символ исходного алфавита. Каждый узел, не являющийся узлом, представляет соединение символов из листов — потомков данного узла. На рис. 6.3.1, α приведено бинарное дерево, построенное с использованием предыдущего примера. Каждый узел содержит символ и его частоту. На рис. 6.3.1,6 показано бинарное дерево, построенное по данному методу для приведенной на рис. 6.3.1, β таблицы символов алфавита и частот. Такие деревья называют деревьями Хаффмена, по имени изобретателя этого метода кодирования. Как только дерево Хаффмена построено, код любого символа алфавита может быть определен просмотром дерева снизу вверх, начиная с листа, представляющего этот символ. Начальное значение кода — пустая строка. Каждый раз, когда мы поднимаемся по левой ветви, к коду слева приписывается 0; каждый раз при подъеме по правой ветви к коду слева приписывается 1. Часть info узла дерева содержит частоту появления символа, представленного этим узлом. Входы алгоритма: число символов исходного алфавита η и frqncy—массив размера не меньше п, такой что frqncy (i) —относительная частота i-ro символа. В алгоритме должны присваиваться значения массиву code размером не меньше n; code(i) содержит код, присвоенный i-му символу. 333
С ACBD,7^\ Символ А В С Частота 15 6 7 Код 111 0101 1101 Символ D Ε F Частота 12 25 4 Код ОН 10 01001 Символ С, Η I Частота 6 1 15 Под 1100 01000 00 Рис. 6.3.1. Деревья Хаффмена» В алгоритме также строится массив pstn размера не меньше п; pstn(i) указывает на узел, представляющий i-й символ. Этот массив необходим для идентификации точки дерева, с которой надо начинать составление кода для конкретного символа алфавита. Как только дерево построено, для определения того, какой бит (0 или 1) должен подставляться слева в текущее значение кода при «подъеме» по дереву, может использоваться введенная ранее операция isleft. 334
Используя эти программы, мы можем написать алгоритм Хаффмена в следующем виде (множество rootnodes содержит указатели на корни частичных бинарных деревьев, которые не являются еще левыми или правыми поддеревьями): rootnodes β пустое множество 'построить узел для каждого символа for i=l to n ρ=maketree (frqncy (i)) pstn(i)=p добавить р к rootnodes next i 'построить дерево while rootnodes содержит больше одного элемента do р1=элемент в rootnodes с наименьшим значением info удалить pi из rootnodes р2=элемент в rootnodes с наименьшим значением info удалить р2 из rootnodes 'объединить pi и р2 как ветви одного дерева ρ=maketree (info (pi) +info (p2)) установить pi и р2 в качестве сыновей node (ρ) добавить р к rootnodes endwhile 'дерево создано; использовать его для нахождения кодов root=единственный элемент rootnodes for i= 1 to n code (i) =~< » p = pstn(i) 'пройти вверх по дереву while p< >root do if isleft(p) then code(i)=«0»+code(i) else code(i)=«l»+code(i) endif p=father (p) endwhile next i Отметьте, что дерево Хаффмена строго бинарное. Следовательно, если в алфавите η символов, то дерево Хаффмена (у которого η листьев) может быть представлено массивом узлов размером 2п—1. Поскольку размер памяти, требуемой под дерево, известен, она может быть выделена заранее. Отметим также, что дерево Хаффмена проходится от листьев к кор- нк}. Отсюда следует, что не требуются поля LEFT и RIGHT, и для представления структуры дерева достаточно одного поля FTHER. Знак поля FTHER может использоваться для определения, является ли узел левым или правым сыном, а абсолютное значение поля служит указателем отца узла. Левый сын имеет отрицательное значение поля FTHER, а правый — положительное. Стало быть, узлы дерева могут быть описаны так: 80 DIM INFO(2*N—1), FTHER(2*N—1) Давайте напишем программу для кодирования символов сообщения, использующую алгоритм Хаффмена. Вход програм- 335
мы — это число N, являющееся числом символов алфавита, за которым следует множество из N пар; каждая пара состоит из символа и его относительной частоты. Вначале программа строг ят строку ALPHA, состоящую из всех символов алфавита, и массив CODE, в котором CODE(I) равно коду —назначенному 1-му символу в ALPHA. Затем программа печатает каждый символ, его относительную частоту и код. Отметьте, что нет необходимости строить массив PSTN, поскольку при используемом представлении node(I) представляет 1-й символ алфавита. 10 'программа findcode 20 'для микроЭВМ TRS-80 требуется оператор CLEAR 100 30 DEFSTR А, С, S 40 DEFINT F, I, J, N, P, Q, Τ 50 TRUE=1 60 FALSE=0 70 READ N 80 DIM CODE (Ν), FRQNCY(N) 90 DIM INFO(2*N-l), FTHER(2*N-1) 100 FOR 1 = 2 TO 2·Ν— 1: 'инициализировать массивы 110 INFO(I)=0 120 FTHER(I)=0 130 NEXT I 140 FOR 1=1 TO N: 'инициализировать алфавит и частоты 150 READ SYMB, FRQNCY(I) 160 ALPHA=ALPHA+SYMB 170 NEXT I 180 'построить дерево Хаффмена 190 FOR 1=1 TO Ν 200 INFO(I) =FRQNCY(I) 210 NEXT I 220 FOR I = N+1 TO 2*N-1 230 Ί — следующий свободный узел: поиск среди всех предшествующих узлов для двух корневых узлов Р1 и Р2 с наименьшими частотами 240 Л =9999 250 J2=9999 260 Р1 = 0 270 Р2=0 280 FORQ=lTOI-l 290 IFFTHER(Q)=0 THEN IF INFO(Q)<Jl THEN P2=P1: J2=J1: P1 = Q: Jl = INFO(Q) ELSE IF INFO(Q)<J2THEN P2=Q: J2=INFO(Q) 300 NEXTQ 310 P=I: 'выделить node(P) 320 INFO(P)=Jl+J2 330 'установить Ρ1 на левое поддерево Ρ, а Р2 — на правое 340 FTHER(P1) = -P 350 FTHER(P2) = P 360 NEXT I 370 'выделить коды из дерева 380 FOR 1 = 1 ТО N 390 CODE(I)=«» 400 Р=1 410 IF FTHER(P)=0 THEN GOTO 450 420 IF FTHER(P) <0 THEN CODE(I) =«0»+CODE(I) 336
ELSE CODE(I)=«b+CODE(I) 430 P=ABS(FTHER(P)) 440 GOTO 410 450 NEXT I 460 FOR 1=1 TON 470 PRINT MID $ (ALPHA,I,1), INFO(I), CODE(I) 48Q N£XT I 490 END 800 DATA . . . Читатель отсылается к разд. 9.4, в котором предложены дальнейшие улучшения этой программы. Мы оставляем читате- лю написание подпрограммы encode, которая получает строку ALPHA и массив CODE, созданные в вышеприведенной программе, и сообщение MSGE и возвращает кодировку сообщения* в битовой строке. Исходное сообщение (при наличии кодировки сообщения » дерева Хаффмена) может быть восстановлено следующим способом. Начинаем с корня дерева. Каждый раз, когда встречается 0, двигаемся по левой ветви, и каждый раз, когда встречается 1, двигаемся по правой ветви. Повторяем этот процесс до тех пор, пока не дойдем до листа. Новый символ исходного сообщения есть символ, соответствующий этому листу. Проверьте,, можете ли вы раскодировать строку 1110100010111011, используя дерево Хаффмена из рис. 6.3.1,6. При раскодировании необходимо двигаться от корня дерева вниз к листам. Это означает, что для хранения левых и правых сыновей узла требуются поля LEFT и RIGHT. Массивы LEFT и RIGHT можно просто создать из массива FTHER. Можно также создать массивы LEFT и RIGHT и непосредственно из информации о частоте символов алфавита, используя подход, аналогичный примененному при создании массива FTHER. (Конечно, если массивы должны быть идентичными, то в обоих методах пары символ — частота должны появляться в том же самом порядке.) Мы оставляем эти алгоритмы, а также алгоритм раскодирования читателю в качестве упражнений. Упражнения 1. Напишите на языке Бейсик подпрограмму encode, которая получает* строку ALPHA и массив CODE, созданные приведенной в тексте программой findcode, а также сообщение MSGE. Подпрограмма выдает сообщение в кодировке Хаффмена. 2. Напишите на языке Бейсик подпрограмму decode, которая получает строку ALPHA, созданную приведенной в тексте программой findcode, массивы LEFT и RIGHT, использующиеся для представления дерева Хаффмена, и строку MSGE. Подпрограмма должна выдавать раскодированное сообщение. 3. Могут ли существовать два различных дерева Хаффмена для одного- набора символов с данными частотами? Либо приведите пример существования двух таких деревьев, либо докажите, что может быть только одно* такое дерево. 337
4. Определите бинарное дерево Фибоначчи порядка η следующим образом. Если п=0 или п=1, дерево состоит из единственного узла. Если п>1, дерево состоит из корня с бинарным деревом Фибоначчи порядка η—1 в ^качестве левого поддерева и бинарным деревом Фибоначчи порядка η—2 »*в качестве правого поддерева. ' . г (а) Напишите на языке Бейсик подпрограмму, возвращающую указатель на бинарное дерево Фибоначчи порядка п. (б) Является ли дерево строго бинарным? (в) Чему равно число листьев в бинарном дереве Фибоначчи порядка п? (г) Чему равна глубина бинарного дерева Фибоначчи порядка П? 5. Дано бинарное дерево t. Его расширение определяется как бинарное .дерево e(t), полученное из t добавлением нового узла к каждому пустому левому и правому указателю в t. Эти новые узлы называются внешними, а исходные узлы — внутренними, е (t) называется расширенным бинарным .деревом. (а) Докажите, что расширенное бинарное дерево строго бинарно. (б) Если t имеет η узлов, сколько узлов в e(t)? (в) Докажите, что все листья расширенного бинарного дерева—внешние узлы. (г) Напишите на языке Бейсик подпрограмму расширения бинарного .дерева t. (д) Докажите, что любое строго бинарное дерево с более чем одним узлом является расширением одного и только одного бинарного дерева. (е) Напишите на языке Бейсик подпрограмму, которая получает указа* тель на строго бинарное дерево tl, содержащее более одного узла, и удаляет %из tl узлы, создавая такое бинарное дерево t2, что tl=e(t2). (ж) Покажите, что полное бинарное дерево порядка η является п-м расширением бинарного дерева, состоящего из одного узла. 6. Дано строго бинарное дерево t, в котором η листьев помечены как узлы с номерами от 1 до п. Пусть level(i)—уровень узла i, и пусть frq(i)— некоторое целое число, назначенное узлу i. Определим взвешенную длину пути дерева t как сумму frq(i)»level (i) по всем листьям t. (а) Напишите на языке Бейсик подпрограмму вычисления взвешенной длины пути, если даны поля frq и fther. (б) Покажите, что дерево Хаффмена — это строго бинарное дерево с минимальной взвешенной длиной пути, если frqncy(i) интерпретируются как frq(i). 6.4. ПРЕДСТАВЛЕНИЕ СПИСКОВ В ВИДЕ БИНАРНЫХ ДЕРЕВЬЕВ Над списком элементов может быть выполнено несколько операций. Среди них добавление нового элемента в начало или конец списка, удаление первых или последних элементов списка, лолучение k-го или последнего элемента списка, включение элемента за данным элементом или перед ним и удаление предшественника или преемника данного элемента. Часто требуется дополнительная операция построения списка с данными элементами. В зависимости от выбранного для списка представления некоторые из этих операций могут или не могут быть выполнены «одинаково эффективно. Например, список может быть представлен в последовательных элементах массива или узлами в связанной структуре. Включение элемента вслед за данным элементом — относительно эффективная операция в связанном 338
списке (она включает изменение немногих указателей помимо собственно вставки). Однако эта же операция неэффективна» для массива (она требует сдвига всех последующих элементов- массива на одну позицию). В то же время нахождение k-го элемента списка намного более эффективно в массиве (требуется* лишь вычисление смещения), чем в связанной структуре (требуется просмотр первых к—1 элементов). Аналогично невозможно удалить конкретный элемент односвязного списка, имея* только указатель этого элемента. Это можно сделать лишь для кольцевого односвязного списка и то неэффективно (просмотром всего списка для нахождения предыдущего элемента; только- после этого возможно удаление элемента). Однако та же сама» операция эффективно выполняется для двусвязного (линейного- или кольцевого) списка. В этом разделе мы введем представление линейного списка* в виде дерева; при таком представлении операции нахождения! k-го элемента и удаления конкретного элемента относительно- эффективны. Возможно также создание списка с данными элементами. Вкратце мы рассмотрим и операцию включения элемента. Представления списка в виде бинарного дерева показаны* на рис. 6.4.1: рис. 6.4.1, а показывает список в обычном виде,, а рис. 6.4.1,6 и в показывают представления списка в виде двух бинарных деревьев. Элементы исходного списка представлены листьями дерева (на рисунке — квадраты), а узлы, не являющиеся листьями (на рисунке—кружки), представлены как часть внутренней структуры дерева. С каждым листом связано содержимое соответствующего элемента списка. С каждым узлом,, не являющимся листом, связан счетчик числа листьев в левом» поддереве узла. (Хотя значение счетчика может быть вычислено,, исходя из структуры дерева, он включен как элемент данных во избежание его многократного перевычисления.) Элементы1 списка в исходной последовательности присваиваются листьям дерева в симметричном порядке просмотра. Отметьте, что и$ рис. 6.4.1 следует, что несколько бинарных деревьев могут представлять один и тот же список. Нахождение k-го элемента списка Чтобы объяснить, почему для представления списка используется столько лишних узлов, мы приведем алгоритм нахождения k-го элемента списка, представленного в виде дерева. Пусть tree указывает на корень дерева, и пусть lcount(p) представляет счетчик, связанный с узлом, не являющимся листом и указываемым p[lcount(p)—число листьев в дереве с корнем в node (left (p))]. Следующий алгоритм устанавливает переменную find так, что она указывает на лист, содержащий k-й элемент списка. В алгоритме используется переменная г, содержащая зза
Рис. 6.4.1. Список и два соответствующих бинарных дерева. число еще не подсчитанных (из к) элементов списка. Вначале алгоритма г инициализируется в к. В каждом узле р, не являющемся листом, алгоритм определяет по значениям г и lcount(p), находится ли нужный лист в левом или правом поддереве. Если лист находится в левом поддереве, то поиск ведется по этому поддереву без изменения значения г, а если в правом, то перед поиском по этому поддереву значение г уменьшается на lcount(p). r=k ρ = tree while ρ — не лист do 340
if r< = lcount(p) then p=left(p) else r=r—lcount(p) ρ - right (p) endif endwhile find=p На рис. 6.4.2, а показано нахождение пятого элемента списка в дереве из рис. 6.4.1,6, а на рис. 6.4.2,6 — нахождение восьмого элемента дерева из рис. 6.4.1, е. Штриховые линии обозначают путь алгоритма вниз по дереву к соответствующему листу. Зна- Рис. 6.4.2. Нахождение m-го элемента в списке, представленном в виде дерева. чение г (оставшееся число еще не подсчитанных элементов) приведено рядом с каждым узлом, пройденным алгоритмом. Заметим, что число узлов дерева, просмотренных при нахождении k-го элемента списка, меньше или равно глубине дерева минус 1 (глубина дерева — длина самого долгого tfjifti от корня дерева к листу). Таким образом, для нахождения пятого элемента списка на рис. 6.4.2, а проверяются четыре узла и столько 341
же для нахождения восьмого элемента на рис. 6.4.2,6. Если список представлен связанной структурой, то для нахождения пятого элемента проходятся четыре узла [т. е. операция p=next(p) была бы выполнена четыре раза] и для нахождения восьмого — семь. В данном случае мы имеем несущественную экономию. Но представим себе список с 1000 элементов. Для представления такого списка достаточно бинарного дерева глубиной 10. Стало быть, нахождение k-го элемента с использованием бинарного дерева (безотносительно от значения к: 3, 253, 708 или 999) потребовало бы проверки не более 11 узлов. Поскольку число листьев бинарного дерева растет как 2 в степени глубина дерева, такое дерево представляет относительно эффективную структуру данных для нахождения k-го элемента списка. Удаление элемента Как можно удалить элемент из списка, представленного деревом? Само удаление относительно несложно: оно включает лишь установку пустого указателя в поле левого или правого указателя у отца удаляемого узла dn. Однако, чтобы обеспечить последующий доступ, должны быть модифицированы счетчики во всех предках dn. Изменение состоит в вычитании 1 из lcount в каждом узле nd, для которого dn был левым потомком, поскольку теперь число листьев в левом поддереве nd на единицу меньше. В то же время, если брат dn — лист, то он может быть передвинут вверх по дереву на место своего отца. После этого мы можем передвинуть его и выше, если в новом месте у него нет братьев. Такое изменение может уменьшить глубину дерева, слегка ускоряя последующие обращения. Стало быть, мы можем представить алгоритм удаления из дерева листа, указываемого ρ (и, следовательно, элемента иэ списка), в следующем виде. (Номера строк нужны нам для последующих ссылок.) 1. if p=tree 2. then tree=null 3. freenode(p) 4. return 5. endif 6. f=father (p) 7. 'удалить node (ρ) и установить в Ь указатель на своего брата 8. if p=Ieft(f) 9. then left(f)=null 10. b=right(f) 11. icount(f)=lcount(f)-l 12. else right(f) «null 13. b=left(f) 14. endif 15. if node(b) —это лист 16. then 'переставить node(b) на место его отца и освободить node(b) 17. info (f) = info (b) 18. left(f)-niill 342
19. right (f)= null 20. lcount(f)=0 21. freenode(b) 22. endif 23. freenode(p) 24. 'идти вверх по дереву 25. q=f 26. while q< >tree do 27. f=father(q) 28. if q=left(f) 29. then 'удаленный лист был левым потомком node(f) 30. lcount(f)=lcount(f)-l 31. b=right(f) 'node(b) — брат node(q) 32. else b=left(f) 'node(b) — брат node(q) 33. endif 34. if b=nuli and node(q) —это лист 35. then 'переставить node (q) на место отца и освободить node(q) 36. info(f)=info(q) 37. left (f)-null 38. right (f)=null 39. lcount(f)=0 40. freenode(q) 41. endif 42. q=f 43. endwhile 44. return На рис. 6.4.3 приведены результаты работы алгоритма для случая, когда из дерева последовательно удаляются узлы С, D и В. Убедитесь, что вы можете проследить за действиями алгоритма. Отметьте, что алгоритм (для согласованности) ведет и нулевой счетчик в листах, хотя для них он не требуется. Заметьте также, что алгоритм не передвигает вверх узел, не являющийся листом (хотя это и могло бы быть сделано). [Например, на рис.6.4.3,б не передвинут вверх отец узлов А и В.] Можно было бы изменить алгоритм (это несложное действие оставлено читателю), но мы не делаем этого по причинам, которые скоро станут ясны. Алгоритм включает проверку до двух узлов на каждом уровне (предка удаляемого узла и брата этого предка). Таким образом, операция удаления k-го элемента списка, представленного деревом (что включает в себя нахождение и затем удаление элемента), требует числа обращений к узлам, приблизительно равного утроенной глубине дерева. Если удаление из связного списка требует обращения всего к трем узлам (удаляемому, предшествующему и последующему), то удаление k-го элемента требует в совокупности к+2 обращений (из них к—1 для нахождения узла, предшествующего к-му). Следовательно, для больших списков представление в виде дерева более эффективно. Аналогично мы можем отдать представлению в виде дерева преимущество в эффективности и по сравнению с представлением списка в виде массива. Если список из η элементов хранится последовательно в первых η элементах массива, то нахождение k-го элемента включает только одно обращение к мас- 343
Рис. 6.4.3. Алгоритм удаления. сиву, но его удаление требует сдвига η—к элементов, следующих за удаляемым элементом. Если же разрешить пустые мест» в массиве, с тем чтобы эффективно выполнялась операция удаления (без сдвига: путем установки специального признака в- позиции массива, соответствующей удаленному элементу), то нахождение k-го элемента потребует по меньшей мере к обращений. Это происходит потому, что теперь невозможно заранее узнать положение в массиве к-го элемента списка, поскольку между элементами массива могут существовать пустые места.. [Следует, однако, заметить, что если порядок элементов списка безразличен, то k-й элемент в массиве может быть эффективно 344
удален путем перезаписи его n-м элементом (последним элементом) и изменением числа элементов на п=1. Однако маловероятно, что мы захотели бы удалять k-й элемент из такого списка, поскольку, если порядок элементов безразличен, нет смысла удалять именно k-й элемент, а не какой-либо другой.] Вставка в список, представленный в виде дерева, нового к-го элемента (между элементом с номером к—1 и предыдущим к-м элементом) — также относительно эффективная операция. Вставка состоит из нахождения k-го элемента, замены его новым узлом, не являющимся листом и имеющим в качестве левого сына лист, содержащий новый элемент, и в качестве правого сына лист, содержащий старый k-й элемент, и настройки соответствующих счетчиков у его предков. Подробности мы оставляем читателю. (Однако следует отметить, что повторяющаяся вставка новых k-χ элементов таким методом вызвала бы построение весьма несбалансированного дерева: ветвь, содержащая k-й элемент, стала бы непропорционально длинной по сравнению с остальными. Это означает, что эффективность нахождения k-го элемента не так высока, как в сбалансированном дереве, в котором все ветви примерно одной длины. Читателю предлагается найти стратегию, устраняющую эту проблему. Несмотря на этот недостаток, если вставки в дерево ведутся случайным образом так, что вставка элемента в любую позицию одинаково вероятна, то получающееся дерево остается достаточно сбалансированным, а нахождение k-го элемента — эффективным.) Реализация списков, представленных в виде дерева, на языке Бейсик При использовании узлового представления бинарных деревьев реализация на языке Бейсик алгоритмов поиска и удаления проста. Однако такое представление требует для каждого узла дерева полей INFO, LCOUNT, FTHER, LEFT и RIGHT, а представление в виде связного списка требует лишь полей INFO и NXT. Если учесть тот факт, что представление в виде дерева требует примерно вдвое больше узлов, чем представление в виде связного списка, то подобные требования на пространство могут сделать непрактичным использование представления в виде дерева. Однако при почти полном представлении в виде массива требования на пространство совсем не такие жесткие. Если предположить, что не потребуются вставки и известен размер исходного списка, то мы можем оставить массив для хранения представления списка в виде почти полного строго бинарного дерева. Как мы скоро увидим, всегда можно построить такое представление списка. Как только дерево построено, нам требуются лишь поля INFO, LCOUNT и поле FLAG, которое по- 345
называет, представляет ли элемент массива существующий или удаленный узел дерева. Кроме того, как мы отметили ранее, поле LCOUNT требуется только для узлов дерева, не являющихся листами. Можно также исключить поле FLAG ценой некоторой потери эффективности (см. упражнения б и 7). Мы предполагаем, что имеется следующее описание (N — число элементов в списке): 10 DEFSTR Ε 30 DIM EINFO(2*N— 1): 'список содержит строковые элементы 40 DIM LCOUNT (N— 1): 'только для узлов, не являющихся листьями 50 DIM FLAG(2*N— 1): 'присутствует или нет узел node(I) в дереве Можно определить, что узел не является листом по значению поля EINFO, равному пустой строке (« »). Функции father (р), left(р) и right(р) могут быть реализованы стандартным способом как INT(P/2), 2*P и 2*Р+1 соответственно. Следующая программа на языке Бейсик находит k-й элемент: 1000 'подпрограмма findelement 1010 'входы: EINFO, К, LCOUNT 1020 'выходы: FIND 1030 'локальные переменные: Р, R 1040 R=K 1050 P^l 1060 IF EINFO(P) <>« » THEN GOTO 1090 1070 . IF R<=LCOUNT(P) THEN P=2*P ELSE R=R-LCOUNT(P): P=2*P+I 1080 GOTO 1060 1090 FIND=Ρ 1100 RETURN 1110 'конец подпрограммы Программа на языке Бейсик, для удаления листа, указываемого Р, которая использует почти полное представление в виде дерева, в чем-то проще, чем приведенный выше алгоритм. Не требуются установки пустого указателя (строки 2, 9, 18, 19, 37 и 38), поскольку указатели не используются. Также не требуется присвоение нуля полю lcount (строки 20 и 39), поскольку такое присвоение является частью преобразования узла, не являющегося листом, в лист. Однако в нашем представлении на языке Бейсик, как отмечалось ранее, листы не содержат поля LCOUNT. Можно узнать, что узел является листом (строки 15 и 34), по непустому значению поля EINFO, а пустое значение указателя В (строка 34) — по нулевому значению в поле FLAG (В). Освобождение узла (строки 3, 21 и 40) осуществляется записью нуля в поле FLAG. 2000 'подпрограмма delete 2010 'входы: EINFO, FLAG, LCOUNT, P 2020 'выходы: EINFO, FLAG, LCOUNT 2030 'локальные переменные: В, F, Q 2040 IF P= 1 THEN FLAG(P) =0: RETURN: 'строки алгоритма 1—5 346
2050 F= INT(P/2): 'строка алгоритма 6 2060 IF F-P/2 THEN B=2*F+1: LCOUNT(F) =LCOUNT (F) -1 ELSE B=2*F: 'строки алгоритма 7—14 2070 IF EINFO(B)< >« » THEN EINFO (F)= EINFO (B): FLAG(B)=0; 'строки алгоритма 15—22 2080 FLAG (Ρ) =0: 'строка алгоритма 23 2090 Q=F: 'строка алгоритма 25 2100 IF Q-1 THEN RETURN 2110 F=INT(Q/2); 'строка алгоритма 27 2120 IF F=Q/2 THEN LCOUNT (F)= LCOUNT (F)-l: B=2»F+1 ELSE B=2*F: 'строки алгоритма 27—35 2130 IF FLAG(B)=0 AND EINFO(Q)<>«» THEN EINFO(F)=EINFO(Q): FLAG(Q)=0: 'строки алгоритма 34—41 , 2140 Q=F: 'строка алгоритм а„42 2150 GOTO 2100 2160 'конец подпрограммы Использование почти полного представления в виде массива объясняет причину, по которой при удалении мы не передвигаем узел, не являющийся листом и не имеющий брата, вверх πα дереву. Процесс подвижки вверх включал бы копирование содержимого всех узлов поддерева внутри массива, тогда как при узловом представлении он включает изменение лишь одного указателя. Построение списка, представленного в виде дерева Теперь мы вернемся к утверждению, что для списка из η элементов можно построить почти полное строго бинарное дерево, представляющее этот список. В разд. 6.1 мы видели, что можно построить почти полное строго бинарное дерево с η листьями и 2п—1 узлами. Листья такого дерева занимают узлы с номерами от η до 2п—1. Если d — наименьшее целое число, такое, что 2**d больше или равно п, то d равно глубине дерева и 2**d— это номер, присвоенный первому узлу нижнего уровня дерева. Первые элементы списка назначаются узлам с номерами от 2**d до 2п—1, а оставшиеся (если они есть) — узлам с номерами от η до 2**d—1. Следовательно, мы можем назначить элементы списка в данной последовательности полям EINFO листьев дерева и присвоить значение пустой строки полям EINFO узлов, не являющихся листьями и пронумерованных от 1 до η—1. Также несложно присвоить начальное значение 1 полю FLAG во всех узлах с номерами от 1 до 2п—1. Более сложна инициализация значений массива LCOUNT. Могут быть использованы два метода: первый требует для исполнения больше времени, а второй — памяти. В первом методе все поля LCOUNT инициализируются в нуль. Затем дерево проходится снизу вверх по очереди от каждого листа до корня. Каждый раз, когда происходит переход от левого сына к его отцу, к значению поля LCOUNT отца прибавляется 1. После 347
того как этот процесс выполнен для каждого листа, значения всех полей LCOUNT установлены требуемым образом. Следующая программа использует этот метод для построения дерева из вводимого списка: 3000 'подпрограмма buildtree ЗОЮ 'входы: N 3020 'выходы: EINFO, FLAG, LCOUNT Г030 'локальные переменные: D, F, I, P, POWER, SIZE 3040 'вычислить глубину дерева D и значение 2\Ό 3050 D=0 3060 POWER^l: 'значение POWER равно 2fD 3070 IF POWER> = N THEN GOTO 3110 3080 D-D+l 3090 POWER = POWER*2 3100 GOTO 3070 3110 'присвоить элементы списка и признаки узлам дерева и инициализировать LCOUNT в 0 для всех «неузлов» 3120 SIZE-2*N-1 3130 FOR I = POWER TO SIZE 3140 READ EINFO (I) 3150 FLAG (I) = 1 3160 NEXT I 3170 FOR I = N TO POWER-1 3180 READ EINFO(I) 3190 FLAG (I) = 1 3200 NEXT I 3210 FOR 1=1 TON-1 3220 FLAG (I) = 1 3230 LCOUNT (I)-0 3240 EINFO(I)=«» 3250 NEXT I 3260 'установить значение поля LCOUNT 3270 FOR I=N TO SIZE: 'начать с каждого листа и до корня 3280 Р=1 3290 IF P-1 THEN GOTO 3340 3300 F=INT (P/2) 3310 IF F=P/2 THEN LCOUNT (F) = LCOUNT (F) +1 3320 P=F 3330 GOTO 3290 3340 NEXT I 3350 RETURN 3360 'конец подпрограммы Во втором методе используется дополнительный массив RCOUNT для хранения числа листьев в правом поддереве каждого узла, не являющегося листом. Этому полю, а также полю LCOUNT присваивается значение 1 для каждого узла, не являющегося листом и имеющего в качестве сыновей два листа. Кроме того, если N нечетно, так что существует узел [с номером INT(N/2)], являющийся отцом листа и не листа, то поле LCOUNT этого узла устанавливается в 2, а поле RCOUNT — в 1. Затем в обратном порядке проходятся оставшиеся элементы массива; при этом в LCOUNT записывается сумма значений LCOUNT и RCOUNT левого сына узла, а в RCOUNT —сумма значений LCOUNT и RCOUNT правого сына узла. Ниже приведена программа на языке Бейсик: 348
3000 'подпрограмма buildtree 3010 'входы: Ν 3020 'выходы: EINFO, FLAG, LCOUNT 3030 'локальные переменные: D, I, NN, POWER, SIZE 3040 'вычислить глубину дерева D и значение 2fD 3050 D-0 3060 POWER-1: 'значение POWER равно 2fD 3070 IF POWER > = N THEN GOTO 3110 3080 D=D+1 3090 POWER=POWER*2 3100 GOTO 3070 3110 'присвоить элементы списка и признаки 3120 SIZE=2»N-1 3130 FOR 1= POWER TO SIZE 3140 READ EINFO(I) 3150 FLAG (I) = 1 3160 NEXT I 3170 FOR I = N TO POWER-1 3180 READ EINFO(I) 3190 FLAG (I) = 1 3200 NEXT I 3210 'установить поля LCOUNT и RCOUNT у отцов листьев 3220 'предполагается, что есть описание: DIM RCOUNT(N—1) 3230 NN-INT (N/2) 3240 FOR Ι = ΝΝΤΟΝ-1 3250 LCOUNT(I)-l 3260 RCOUNT (I)-1 3270 FLAG (I) = 1 3280 EINFO(I)=«» 3290 NEXT I 3300 IF NN< >N/2 THEN LCOUNT (NN) =2 3310 I=NN-1 3320 IF 1=0 THEN RETURN 3330 LCOUNT(I)=LCOUNT(2*I)+RCOUNT(2*I) 3340 RCOUNT(I)=LCOUNT(2»I+l)+RCOUNT(2*I+l) 3350 FLAG (I) = 1 3360 EINFO(I)=«» 3370 1 = 1 — 1 3380 GOTO 3320 3390 'конец подпрограммы Отметим, что массив RCOUNT не указан в качестве выхода? подпрограммы buildtree, поскольку после построения дерева он» не нужен; он требуется лишь внутри подпрограммы для уста· новки правильных значений массива LCOUNT. На самом деле, если используется поле FLAG, то оно может использоваться* для хранения значения поля RCOUNT, так как значения* LCOUNT устанавливается и затем сбрасывается в 1 после того, как вычислены все значения LCOUNT. Это устранило бы необходимость отдельного массива RCOUNT. Однако, как отмечено ранее, а также в упражнениях, поле FLAG в действительности не требуется, следовательно, данный метод все-таки влечет дополнительные затраты памяти. Заметим также, что на самом деле нам не требуется значение D, так что можно удалить операторы 3050 и 3080 из обеих: программ. Эти операторы были введены в программы для большей ясности. 349
Еще раз о задаче Джозефуса Задача Джозефуса из разд. 4.4 — прекрасный пример полез- шости представления списка в виде бинарного дерева. В этой .задаче надо было много раз находить и затем удалять М-й элемент списка. Именно эти операции могут быть выполнены эффективно при представлении списка в виде дерева. Если С равно текущему числу элементов списка, то позиция -М-го узла (считая по порядку от только что удаленного из позиции К узла) на единицу больше остатка от деления К—2+Μ та С. Например, если в списке пять элементов, удален третий элемент и мы хотели бы найти четвертый элемент, считая от удаленного, то С=4, К=3 и Μ=4. Остаток от деления К—2+Μ (что равно 5) на С (что равно 4, поскольку один элемент уда- .лен) равен 1; следовательно, позиция узла равна 2. (После удаления 3-го элемента мы считаем элементы так: 4, 5, 1 и 2.) "Стало быть, мы можем написать на языке Бейсик программу "follower, которая находит М-й узел, считая от только что удавленного из позиции К узла, и устанавливает значение К, равное этой позиции (программа вызывает приведенную ранее программу f indelement): 4000 'подпрограмма follower 4010 'входы: С, EINFO, К, LCOUNT, Μ 4020 'выходы: FIND, К 4030 'локальные переменные: Dl, D2, J 4040 J=K-2+M 4050 D1=J/C 4060 D2=INT(D1) 4070 K=J-D2*C+1 4080 GOSUB 1000: 'подпрограмма findelement устанавливает значение 'переменной FIND 4090 RETURN 4100 'конец подпрограммы Следующая программа считывает число людей в круге, шаг удаления и имена людей и определяет порядок удаления людей шз круга: 10 'программа josephus 20 DEFSTR Ε 30 READ Ν, Μ: 'число людей и шаг удаления 40 DIM EINFO(2*N-l), LCOUNT(N-l), FLAG(2*N-1) 50 GOSUB 3000: 'подпрограмма buildtree инициализирует EINFO, LCOUNT, FLAG 60 K= N+1: 'вначале мы «удалили» (N+1) -го человека 70 C=N: 'вначале в списке N человек 80 'повторять, пока не останется один человек 90 IF C-1 THEN GOTO 160 100 GOSUB 4000: 'подпрограмма follower устанавливает значение 'переменной FIND и переустанавливает значение 'переменной К ПО P=FIND 120 PRINT EINFO(P) 350
130 GOSUB 2000: 'подпрограмме delete нужна переменная Р 140 С=С-1 150 GOTO 90 160 PRINT EINFO(l) 170 END 1000 'здесь идут программы buildtree, follower, findelement и delete 9000 DATA . . . Упражнения 1. Докажите, что при схеме нумерации из разд. 6.1 самому левому узлу уровня η в почти полном строго бинарном дереве присваивается номер 2**п. 2. Докажите, что глубина почти полного строго бинарного дерева равна* наименьшему целому d, такому что 2»»d больше или равно числу листьев. 3. Какое из утверждений упражнений 1 и 2 остается верным для почти? полного бинарного дерева, не являющегося строго бинарным? 4. Докажите, что расширение почти полного бинарного дерева (см. упражнение 6.3.5) почти полно. 5. Для каких значений пит приведенное в данном разделе решение- задачи Джозефуса требует меньше времени для исполнения, чем решение,, приведенное в разд. 4.4? Почему это так? 6. Объясните, как мы можем исключить необходимость наличия поля FLAG, если мы решим не передвигать вверх вновь созданный лист, не имеющий брата. 7. Объясните, как мы можем исключить необходимость наличия поля* FLAG, если мы установим LCOUNT в —1 для того узла, не являющегося листом, который преобразуется в лист, и переустановим значение пустой? строки (« ») в поле EINFO удаляемого узла. 8. Покажите, как можно представить связный список в виде почти полного бинарного дерева, в котором каждый элемент списка представлен?» одним узлом дерева и не требуется дополнительных узлов дерева. Напишите- программу на языке Бейсик, возвращающую указатель К-го элемента списка.. 6.5. ДЕРЕВЬЯ И ИХ ПРИЛОЖЕНИЯ В этом разделе мы рассмотрим общие деревья и их представления, а также изучим некоторые приложения деревьев. Дерево — это непустое конечное множество элементов, из- которых один называется корнем, а остальные делятся на несколько непересекающихся подмножеств, каждое из которых, само является деревом. Каждый элемент дерева называется? узлом дерева. На рис. 6.5.1 иллюстрируются некоторые деревья. Каждый узел является корнем дерева, имеющего нулевое или большее- число поддеревьев. Узел без поддеревьев называется листом. Мы используем термины отец, сын, предок, потомок, уровень к глубина в том же смысле, что и для бинарных деревьев. Два узла, имеющие одного отца, называются братьями. Мы определяем также степень узла дерева как число его сыновей. Таким» образом, на рис. 6.5.1, а узел С имеет степень 0 (и, следовательно, он — лист), узел D имеет степень 1, узел В — степень 2 ш узел А — степень 3. Степень узлов дерева сверху не ограничена^. 351
в Рис. 6.5.1. Примеры деревьев. Сравним деревья на рис. 6.5.1, а и в. Эти деревья эквивалентны. Каждое имеет корень А и три поддерева. Одно из этих поддеревьев имеет корень С и не имеет своих поддеревьев, другое— корень D и одно поддерево с корнем G, а третье — ко- ,рень В и два поддерева с корнями Ε и F. Единственное различие— это порядок расположения поддеревьев. В определении дерева не различаются поддеревья общего дерева; это отличает *его от бинарного дерева, где существует разница между левым и правым поддеревьями. Определим упорядоченное дерево как дерево, в котором поддеревья каждого узла образуют упорядоченное множество. Следовательно, в упорядоченном дереве мы .можем говорить о первом, втором и последнем сыновьях конкретного узла. Первого сына узла упорядоченного дерева часто «называют старшим сыном узла, а последнего — младшим. Хотя 352
деревья на рис. 6.5.1, α и в эквивалентны как неупорядоченные деревья, они различаются как упорядоченные деревья. До конца главы мы используем для «упорядоченного дерева» термин «дерево». Лес — это упорядоченное множество упорядоченных деревьев. Возникает вопрос: является ли бинарное дерево деревом? Каждое бинарное дерево, исключая пустое бинарное дерево, действительно является деревом. Однако не каждое дерево есть бинарное дерево, поскольку узел дерева может иметь более двух сыновей, что невозможно для узла бинарного дерева. Но даже дерево, узлы которого имеют не более двух сыновей, не обязательно является бинарным деревом. Дело в том, что единственный сын в общем дереве не определен как «левый» или «правый», тогда как в бинарном дереве каждый сын должен быть либо «левым», либо «правым». Фактически, хотя непустое бинарное дерево — это дерево, определения левого и правого (сыновей) не имеют смысла в контексте понятия дерева (исключая, возможно, случай упорядочения двух поддеревьев у узлов с двумя сыновьями). Непустое бинарное дерево — это дерево, каждый узел которого имеет не более двух поддеревьев с дополнительными указаниями о них: «левое» или «правое». Представление деревьев на языке Бейсик Упорядоченное дерево может быть представлено на языке Бейсик в виде массива узлов дерева. Однако какова должна быть структура каждого отдельного узла? В представлении бинарного дерева каждый узел содержит информационное поле и три указателя: на двух сыновей и на отца. Но сколько указателей должно быть у узла дерева? Число сыновей узла — переменная величина и может иметь любое значение. Если мы произвольно опишем дерево как 10 DIM INFO (500) 20 DIMFTHER(500) 30 DIM SN (500,20) то тем самым ограничиваем число сыновей узла двадцатью. (Мы не используем для массива сыновей имя SON, поскольку внутри имени содержится ключевое слово ON, что не допускается в некоторых версиях языка Бейсик.) Хотя и верно, что в большинстве случаев двадцати сыновей достаточно, такое ограничение скажется при необходимости динамического создания узла с 21 или 100 сыновьями. Намного серьезнее, чем данный редкий случай, тот факт, что в каждом узле дерева отводится 20 единиц памяти даже тогда, когда узел может в действительности иметь только одного или двух (или даже ни одного) сыновей. Это очень расточительно. 353
SN INFO NXT II Ί" / II II I 1 / £ II и 1 I С II и / / F II II I 1 1 D II II / / 1 II и I I G II II 1 I 1 A A 1 В \ II 1 / F ч и I I V I \ | 1 w I/ / / С G Μ Ρ η и ι ι η ι ι η и Ι ι II и ι ι D Η Ν ν η и ι ι η и Ι Ι η II ι ι Ε ι Ο η и Ι ι η и Ι ι η и ι ι •^ η II Ι ι J η и ι ι Κ η и ι ι L η и Ι ι Α и " / / η и ι ι С D Β η и ι ι η и ι ι с и и ι ι и 11 ι ι ι η и ι ι ι η 11 ι ι Рис. 6.5.2. Представления деревьев.
Альтернативный подход—объединить всех сыновей узла в линейный список. Таким образом, множество доступных узлов можно описать следующими операторами: 10 DIM INFO (500) 20 DIMFTHER(500) 30 DIM SN (500) 40 DIM NXT(500) Поле SN(P) указывает на старшего сына узла node (P), a NXT(P) — на следующего «по возрасту» младшего брата node(P). Конечно, поле FTHER не нужно, если дерево будет проходиться только от узла к сыновьям. Даже если есть необходимость в переходе от сыновей к отцу, поле FTHER можно исключить, поместив в поле NXT самого молодого сына указатель на отца (а не пустой указатель). Отрицательное значение поля будет показывать, что поле NXT содержит указатель отца узла, а не указатель брата; абсолютное значение поля NXT дает действительное значение указателя. Такое представление аналогично представлению нитей в бинарных деревьях. На рис. 6.5.2 показаны представления деревьев из рис. 6.5.1 в предположении, что обращение от сына к отцу не потребуется. Отметьте, что при такой реализации каждый узел содержит два указателя — SN и NXT. Если представлять себе, что SN соответствует левому указателю узла бинарного дерева, a NXT — правому, то таким методом можно представить в виде бинарного дерева любое общее упорядоченное дерево. Мы можем рассматривать такое бинарное дерево как исходное дерево, повернутое по часовой стрелке на 45°; при этом удалены все связи от отцов к сыновьям, кроме связи от узла к самому старшему сыну, и добавлены связи от каждого узла к следующему по старшинству его младшему брату. Рис. 6.5.3 иллюстрирует бинарные деревья, соответствующие деревьям из рис. 6.5.1. На самом деле бинарное дерево может использоваться для представления всего леса, поскольку указатель поля NXT корня дерева может использоваться для указания следующего дерева леса. На рис. 6.5.4 приведен лес и соответствующее ему бинарное дерево. Прохождение деревьев Методы прохождения бинарных деревьев приводят к соответствующим методам прохождения лесов. Прохождение леса в прямом, симметричном и обратном порядках может быть определено как прохождение в прямом, симметричном и обратном порядках бинарного дерева, соответствующего лесу. Если лес представлен, как указано выше, множеством узлов с указателями sn и nxt, можно написать рекурсивный алгоритм печати узлов при симметричном порядке прохождения в следующем виде: 355
Рис. 6.5.3. Бинарные деревья, соответствующие деревьям на рис. 6.5.1· subroutine intrav(p) if p=null then return endif intrav(sn(p)) print info(p) intrav(nxt(p)) return Алгоритмы прохождения в прямом и обратном порядках схожи. Методы прохождения леса могут быть определены непосредственно: Прямой порядок. 1. Попасть в корень первого дерева леса. 2. Пройти в прямом порядке лес, образованный поддеревьями первого дерева, если они есть. 356
Рис. 6.5.4. Лес (а) и соответствующее бинарное дерево (б). 3. Пройти в прямом порядке лес, образованный оставшимися деревьями леса, если они есть. Симметричный порядок. 1. Пройти в симметричном порядке лес, образованный поддеревьями первого дерева, если они есть. 2. Попасть в корень первого дерева леса. 3. Пройти в симметричном порядке лес, образованный оставшимися деревьями леса, если они есть. Обратный порядок. 1. Пройти в обратном порядке лес, образованный поддеревьями первого дерева, если они есть. 2. Пройти в обратном порядке лес, образованный оставшимися деревьями леса, если они есть. 357
3. Попасть в корень первого дерева леса. Узлы леса на рис. 6.5.4, а могут быть перечислены в прямом порядке как ABCDEFGHIJKLMPRQNO, в симметричном—как BDEFCAIJKHGRPQMNOL и в обратном —как FEDCBKJIHRQPONMLGA. Назовем прохождение бинарного дерева бинарным прохождением, а прохождение упорядоченного общего дерева общим прохождением. Представление выражений общего вида с помощью деревьев Упорядоченное дерево может точно так же использоваться для представления выражений общего вида, как бинарные деревья— для представления бинарных выражений. Поскольку у узла может быть любое число сыновей, узлы, не являющиеся листьями, могут представлять не только бинарные операторы, но и операторы с любым числом операндов. На рис. 6.5.5 показаны два выражения и их представления в виде деревьев. Для представления унарного оператора взятия отрицательного значения используется символ «%», чтобы избежать путаницы с бинарной операцией вычитания, представляемой знаком минус. Обращение к функции, такое как f(G,H,I,J), рассматривается как применение оператора f к операндам G, Η, Ι и J. Прохождение деревьев на рис. 6.5.5 в прямом порядке дает в результате строки *%+АВ— +С log+D ! Ef G Η ΙJ и q+AB sinOX+YZ соответственно. Эти строки представляют выражения в префиксной записи. Таким образом, мы видим, что прохождение дерева, представляющего выражение, в прямом порядке дает префиксную запись выражения. Прохождение выражения в симметричном порядке дает соответственно строки AB+%CDE! + log+GHIJf-* и AB + CsinXYZ + *q, которые являются постфиксной записью двух выражений. Тот факт, что прохождение в симметричном порядке дает постфиксную запись выражения, на первый взгляд может показаться удивительным. Однако его причина становится ясной после рассмотрения преобразований, имеющих место при представлении общего упорядоченного дерева в виде бинарного дерева. Рассмотрим упорядоченное дерево, в котором каждый узел имеет либо двух сыновей, либо ни одного. На рис. 6.5.6, а показано такое дерево, а на рис. 6.5.6,6 — эквивалентное бинарное дерево. Бинарное прохождение бинарного дерева из рис. 6.5.6,6 — это то же самое, что и общее прохождение упорядоченного дерева из рис. 6.5.6, а. Однако дерево, подобное дереву на рис. 6.5.6, а, может само рассматриваться как бинарное, а не как упорядоченное. Стало быть, можно непосредственно выполнить бинарное прохождение (а не общее прохождение) дерева на рис. 6.5.6, а. Ниже этого рисунка приводятся бинар- 358
a -(Λ +B)*(C + log(D + £!) -/(С, Н, /, У)) 6 q(A + 5,5/л(С), Χ · (К + Ζ)) Рис. 6.5.5. Представление арифметического выражения в виде дерева. ные прохождения данного дерева, а ниже рис. 6.5.6,6 приведены бинарные прохождения дерева на рис. 6.5.6,6, которые совпадают с общими прохождениями дерева на рис. 6.5.6, а. Отметим, что бинарные прохождения в прямом порядке одинаковы для этих двух деревьев. Следовательно, если бинарное прохождение дерева, представляющего бинарное выражение, дает префиксную запись выражения, то общее прохождение дерева, представляющего общее выражение, содержащее лишь бинарные операторы, дает тоже префиксную запись данного выражения. Однако бинарные прохождения деревьев в обратном порядке не совпадают. Вместо этого бинарное прохождение в симметричном порядке второго дерева (которое совпадает с сим- 359
Прямой порядок :+*АВ+* CDE Симметричный порядок: A*B+C*D+E Обратный порядок: AB*CD*E++ а Прямой порядок: +*АВ+*С£Е Симметричный порядок: AB*CD*E+ + Обратный порядок: ВАВСЕ*+* + метричным общим прохождением первого) совпадает с бинарным прохождением первого дерева в обратном порядке. Стало быть, общее прохождение в симметричном порядке упорядоченного дерева, представляющего бинарное выражение, эквивалентно бинарному прохождению в обратном порядке бинарного дерева, представляющего это выражение, что дает постфиксную запись. Предположим, что требуется вычислить выражение, все операнды которого — числовые константы. Такое выражение может быть представлено на языке Бейсик с помощью дерева, узлы которого описываются следующим образом: 10 DIM INFO(500) 20 DIM SN (500) 30 DIMNXT(500) Указатели SN и NXT используются для связи узлов дерева, что иллюстрировалось выше. Поскольку узел может содержать ин- 360
SN τ INFO 3 NXT null SN INFO NXT null формацию двух типов — либо число (операнд), либо строку символов (оператор),— информационное поле узла указывает либо на элемент массива операторов OPER, либо на элемент массива операндов NUM. Эти массивы описываются так: 10 DEFSTR О 20 DIMOPER(20) 30 DIM NUM(500) Мы предполагаем, что существует максимум 20 различных операторов и 500 операндов. Так же как и в случае выражений, представленных бинарными деревьями, узлы с операндами и узлы с операторами можно отличить друг от друга: операнды находятся в листах (и, следовательно, имеют в поле SN значение пустого указателя), а операторы — не в листах. Мы хотим написать на языке Бейсик подпрограмму evtree, которая по указателю на такое дерево возвращает значение выражения, представленного этим деревом. Приведенная в разд.6.2 программа evbtree выполняет похожую операцию над деревьями, представляющими бинарные выражения. Программа evbtree использует подпрограмму apply, которая по символу оператора и двум числовым операндам вычисляет и возвращает результат применения оператора к операндам. Однако в случае общего выражения мы не можем использовать такую функцию, поскольку число операндов (и, стало быть, Рис. 6.5.7. Дерево выражения, число аргументов) меняется в зависимости от оператора. Следовательно, мы вводим новый вариант подпрограммы apply, который получает указатель на дерево выражения, содержащее единственный оператор и его числовые операнды, и возвращает результат применения оператора к операндам. Например, результат вызова программы apply с входной переменной Р, указывающей на дерево на рис. 6.5.7, равен 24. Если корень дерева, передаваемого таким образом программе evtree, представляет оператор, каждое из его поддеревьев должно быть заменено узлами дерева, представляющими числовые результаты их вычисления, с тем чтобы можно было бы вызвать подпрограмму apply. Как только OPER NUM "+" 1 4 2 «_» 2 6 SN null 3 4 «*»» МГ INFO Л 5 "Г* NXT null 361
выражение вычислено, узлы дерева, представляющие операнды, должны быть освобождены, а «операторные» узлы должны быть преобразованы в узлы с операндами. Отметим, что программа evtree в отличие от программы evbtree портит дерево выражения во время вычисления. Теперь приведем программу replace, которая получает указатель на дерево, представляющее выражение, и заменяет дерево единственным узлом, содержащим числовой результат вычисленного выражения. Естественнее всего написать программу replace в виде рекурсивного алгоритма, который затем может быть преобразован, используя методы гл. 5, в программу на языке Бейсик. В алгоритме вызывается операция apply, рассмотренная ранее, и операция newop. Функция newop получает операнд, вставляет его в массив num и возвращает указатель на его позицию в num. Таким образом, функция newop создает новый операнд. subroutine replace (pp) q=sn(pp) if q=null then return endif 'пройти список сыновей, заменяя каждый оператор на результат его выполнения while q< >null do if sn(q)< >null then replace(q) endif q=nxt(q) endwhile 'теперь все сыновья — операнды 'найти результат применения оператора в корне дерева к его операндам 'и заменить оператор результатом info(pp) =newop (apply (pp)) 'освободить всех сыновей корня дерева rl-sn(pp) sn(pp)=null while r 1< >null do r2=rl rl=nxt(rl) freenode(r2) endwhile return Ниже приводятся тексты подпрограмм replace и newop на языке Бейсик. 23000 'подпрограмма replace 23010 'входы: РР 23020 'выходы: нет 23030 'локальные переменные: APPLY, CPARAM, CQ, CRETADDR, FRNODE, I, NWOP, Ρ ARAM, PX, QQ, Rl, R2, RETADDR, TP 23040 'определить стек для рекурсии; каждый элемент стека содержит 'указатель дерева, значение переменной q рекурсивного алгоритма 'и адрес возврата 23050 DIM PARAM(50), RETADDR (50), QQ(50) 362
23060 ТР=0: 'инициализация пустого стека 23070 'вставить в стек начальную запись 23080 CPARAM=0: 'CPARAM — текущий указатель дерева 23090 CRETADDR=0: 'CRETADDR — текущий адрес возврата Ί— означает возврат в вызывающую программу '2 — означает возврат после рекурсивного вызова 23100 CQ=0: 'CQ — это указатель сына CPARAM, соответствующего переменной q алгоритма 23110 GOSUB: 1000: 'подпрограмма push 23120 'инициализация значений переменных из внешнего вызова 23130 CPARAM-PP 23140 CRETADDR-1 23150 'здесь начинается тело подпрограммы replace 23160 CQ=SN (CPARAM) 23170 IF CQ=0 THEN GOTO 23440: 'возврат 23180 'имитация первого цикла while 23190 IF CQ=0 THEN GOTO 23290 23200 IF SN(CQ) =0 THEN GOTO 23270 23210 'имитация рекурсивного вызова replace(q) 23220 GOSUB 1000: 'подпрограмма push 23230 CPARAM=CQ 23240 CRETADDR=2 23250 GOTO 23150 23260 'сюда происходит возврат после рекурсивного вызова 23270 CQ=NXT(CQ) 23280 GOTO 23190 23290 'теперь все сыновья node (CPARAM) операнды 23300 PX=CPARAM 23310 GOSUB 6000: 'подпрограмма apply получает РХ и устанавливает 'значение переменной APPLY 23320 X=APPLY 23330 GOSUB 24000: 'подпрограмма newop получает X устанавливает 'значение переменной NWOP 23340 INFO (CPARAM) =NWOP 23350 'освободить сыновей узла дерева 23360 R1 = SN (CPARAM) 23370 SN (CPARAM)-0 23380 IF R 1=0 THEN GOTO 23440: 'возврат 23390 R2=R1 23400 R1=NXT(R1) 23410 FRNODE-R2 23420 GOSUB 12000: 'подпрограмма freenode получает переменную 'FRNODE 23430 GOTO 23380 23440 'имитация возврата из подпрограммы replace 23450 I=CRETADDR 23460 GOSUB 2000: 'подпрограмма pop восстанавливает из стека значе- 'ния переменных CPARAM, CRETADDR и СО 23470 IF 1 = 1 THEN RETURN 23480 IF 1=2 THEN GOTO 23260: 'возврат после рекурсивного вызов» 'внутри replace 23490 'конец подпрограммы 24000 'подпрограмма newop 24010 'входы: LAST, NUM, X 24020 'выходы: LAST, NUM, NWOP 24030 локальные переменные: нет 24040 'в переменной LAST (которая была инициализирована при созда- 'нии дерева) хранится последняя занятая позиция в массиве NUM; увеличить LAST и вставить операнд X 363
24050 LAST^LAST+l 24060 IF LAST>500 THEN PRINT «СЛИШКОМ МНОГО ОПЕРАНДОВ»: STOP 24070 NUM(LAST)=X 24080 NWOP = LAST 24090 RETURN 24100 'конец подпрограммы [Отметим, что содержимое массива NUM всегда увеличивается, но никогда не сжимается. Это оправданно, если мы строим единственное дерево и вычисляем его, а затем строим другое дерево и заново используем массив NUM. Однако если одновременно строится и вычисляется несколько деревьев, так что в любой момент существует произвольное число деревьев, то массив NUM быстро переполнится. Возможное решение — хранить в LAST указатель первого неиспользующегося элемента массива NUM, а в каждом неиспользующемся элементе — указатель на следующий неиспользующийся элемент. Тогда добавление вновь освобожденного элемента массива NUM к списку свободных элементов происходило бы в программе freenode, а в подпрограмме newop перед вставкой вместо оператора LAST = LAST +1 выполнялся бы оператор LAST=NUM(LAST). В основной программе LAST инициализировалась бы в 1, a NUM (I) — в 1+1 для каждого I.] Теперь можно написать программу evtree: 22000 'подпрограмма evtree 22010 'входы: TREE 22020 'выходы: EVTREE 22030 'локальные переменные: FRNODE, PP, QQ 22040 PP=TREE 22Q50 GOSUB 23000: 'подпрограмма replace 22060 QQ-INFO (TREE) 22070 EVTREE=NUM(QQ) 22080 FRNODE-TREE 22090 GOSUB 12000: 'подпрограмма freenode 22100 RETURN 22110 'конец подпрограммы Другие операции над деревьями При создании дерева часто используются несколько операций. Одна из них—setsons; на вход подаются узел дерева, не имеющий сыновей, и линейный список узлов, связанных вместе через поле NXT. Операция setsons устанавливает узлы списка в качестве сыновей данного узла дерева. Соответствующая программа на языке Бейсик проста (мы предполагаем, что не требуется указателей на поле отца, так что исключается поле 1FTHER, а поле NXT самого младшего узла содержит пустой указатель; в противном случае программы были бы несколько более сложными и менее эффективными): 364
22000 'подпрограмма setsons 22010 'входы: Q, LST 22020 'выходы: нет 22030 'локальные переменные: нет 22040 IF Q=0 THEN PRINT «ЗАПРЕЩЕННАЯ ВСТАВКА»: STOP 22050 IF SN(Q)<>0 THEN PRINT «НЕВЕРНАЯ ВСТАВКА»: STOP 22060 SN(Q)-LST 22070 RETURN 22080 'конец подпрограммы Другая популярная операция — addson: добавление узла, содержащего X, в качестве самого младшего сына node(Q), где Q — указатель узла дерева. Ниже приведена программа addson на языке Бейсик (программа вызывает вспомогательную программу getnode, которая удаляет узел из списка доступных узлов и возвращает указатель на него): 23000 'подпрограмма addson 23010 'входы: Q, X 23020 'выходы: нет 23030 'локальные переменные: GTNODE, P, R 23040 IF Q-04THEN PRINT «НЕВЕРНАЯ ВСТАВКА»: STOP 23050 R = 0 23060 P = SN(Q) 23070 IF P = 0 THEN GOTO 23110 23080 R = P 23090 P=NXT(P) 23100 GOTO 23070 23110 GOSUB 1000: 'подпрограмма getnode устанавливает значение переменной GTNODE 23120 Ρ «GTNODE 23130 INFO(P)-X 23140 NXT(P)=0 23150 IF R = 0 THEN SN(Q) -P ELSENXT(R)=P 23160 RETURN 23170 'конец подпрограммы Отметим, что для добавления к узлу нового сына требуется прохождение списка существующих сыновей. Поскольку добавление сына — общеупотребительная операция, то часто используется представление, обеспечивающее более эффективное исполнение этой операции. При таком представлении список сыновей упорядочивается от самого младшего сына к самому старшему, а не наоборот. Стало быть, SN(Q) указывает на самого младшего сына node(Q), a NXT(Q)—на его следующего старшего брата. При таком представлении программа addson может быть написана в следующем виде: 23000 'подпрограмма addson 23010 'входы: Q, X 23020 'выходы: нет 23030 'локальные переменные: GTNODE, P 23040 IF Q=0 THEN PRINT «НЕВЕРНАЯ ВСТАВКА»: STOP 23050 GOSUB 10U0: 'подпрограмма getnode устанавливает значение переменной GTNODE 23060 Ρ-GTNODE 365
23070 INFO (P)-X 23080 NXT(P)=SN(Q) 23090 SN(Q)=P 23100 RETURN 23110 'конец подпрограммы Упражнения 1. Сколько может быть построено различных деревьев с η узлами? 2. Сколько может быть построено различных деревьев с η узлами и максимальным уровнем т? 3. Докажите, что если m полей указателей в каждом узле общего дерева оставлено для указания не более чем m сыновей и если число узлов в дереве равно п, то число полей, содержащих для сына пустой указатель, равно n(m—1) + 1. 4. Пусть лес представлен с помощью бинарного дерева так, как описано в тексте. Покажите, что число пустых правых ссылок на 1 больше, чем число узлов леса, не являющихся листьями. 5. Определите порядок следования в ширину узлов общего дерева: корень, за ним следуют все узлы уровня 1, за ними —все узлы уровня 2 и т. д. Внутри каждого уровня узлы должны быть упорядочены так, чтобы дети одного отца появлялись в том же порядке, в каком они появляются в дереве, и если узлы nl *и п2 имеют разных отцов, то nl появляется перед п2 в том случае, когда отец nl появляется впереди отца п2. Обобщите данное определение для леса. Напишите программу на языке Бейсик, которая проходит лес, представленный бинарным деревом, в описанном порядке следования. 6. Рассмотрите следующий метод преобразования общего дерева gt в строго бинарное дерево bt. Каждый узел дерева gt представлен листом дерева bt. Если дерево gt состоит из единственного узла, то дерево bt тоже состоит из единственного узла. В противном случае дерево bt состоит из нового корневого узла и левого поддерева It и правого поддерева rt. Поддерево It — это строго бинарное дерево, сформированное рекурсивно из самого старшего поддерева дерева gt, a rt — это строго бинарное дерево, сформированное рекурсивно из дерева gt без его самого старшего поддерева» Напишите программу на языке Бейсик для преобразования общего дерева в такое строго бинарное дерево. 7. Напишите на языке Бейсик программу compute, которая получает указатель на дерево, представляющее выражение с операндами-константами, и возвращает в переменной COMPUTE результат вычисления выражения (дерево при этом не портится). 8. Напишите на языке Бейсик программу для перевода инфиксного выражения в дерево выражения. Предполагается, что все небинарные операторы стоят перед своими операндами. Пусть входное выражение представлено следующим образом: операнд представлен символом «N», за которым следует число; оператор представлен символом «Т», за которым следует символ оператора; функция представлена символом «F», за которым следует имя функции. 9. Рассмотрите определения выражения, терма и множителя, приведенные в конце разд. 5.3. Пусть дана строка из букв, знаков «+», звездочек и скобок, которые образуют правильное выражение. Для этой строки может быть сформировано дерево грамматического разбора: подобное дерево приведено на рис. 6.5.8 для строки «(A+B)»(C+D)». Каждый узел дерева представляет собой подстроку и содержит букву (Е для выражения, Τ для терма, F для множителя и 3 для символа) и два целых числа. Первое из них — позиция во входной строке, с которой начинается подстрока, представленная этим узлом, а второе — длина подстроки. (На рисунке подстрока, представленная узлом, показана ниже узла.) Все листья —узлы типа S; 366
Рис. 6.5.8. Дерево разбора строки «(A+B)»(C+D)». они представляют по одному символу входной строки. Корень дерева должен быть узлом типа Е. Сыновья любого узла п, не имеющего тип S, представляют подстроки, составляющие грамматический объект, представленный узлом п. Напишите на языке Бейсик программу, получающую такую строку и составляющую по ней дерево грамматического разбора. 6.6. ПРИМЕР: ДЕРЕВЬЯ ИГР Одно из приложений деревьев — игры с компьютером. Мы проиллюстрируем это приложение и напишем программу на языке Бейсик, которая находит «наилучший» ход в данной позиции игры в крестики-нолики на доске 3X3. Предположим, что есть функция evaluate, которая получает позицию игры и указание, чем играет игрок (X или 0), а возвращает числовое значение, представляющее, насколько «хорошей» кажется позиция для данного игрока (чем больше возвращаемое значение, тем лучше позиция). Выигрышная позиция дает, конечно, наибольшее возможное значение, а проигрышная — наименьшее. Пример такой оценочной функции — сумма числа строк, столбцов и диагоналей, остающихся открытыми для одного игрока (т. е. те, которые могут быть заняты целиком одним игроком), за вычетом числа строк, столбцов и диагоналей, остающихся открытыми для его соперника (для выигрышной позиции возвращается 9, а для проигрышной — значение —9). Эта функция не ведет «просчет вперед», чтобы рассмотреть все возможные 367
позиции, которые могут возникнуть из текущей, — она просто оценивает статическую позицию. Если дана позиция игры, то наилучший ход может быть найден путем перебора всех возможных ходов и получающихся позиций. Выбирается тот ход, который ведет к позиции с наибольшим значением оценки. Однако, как видно из рис. 6.6.1, такой анализ не всегда дает наилучший ход. На этом рисунке приведена позиция и пять возможных ходов, которые можно сделать в этой позиции крестиками. Применяя функцию оценки ко всем пяти получающимся позициям, получим показанные значения. Четыре хода дают одно и то же максимальное значение, хотя первые три из них явно хуже четвертого. (Четвертая позиция определенно ведет к победе для X, а в остальных трех игра может быть сведена для 0 к ничьей.) Фактически ход с минимальным значением оценки так же хорош или даже лучше, чем Рис. 6.6.1. ходы с большим значением. Такая статическая оценочная функция недостаточно хороша для предсказания исхода игры. Хотя для описываемой игры легко можно предложить более точную оценочную функцию (хотя бы с помощью метода «грубой силы»— полного перебора всех позиций и всех возможных ответов), большинство игр слишком сложно для того, чтобы можно было найти статическую оценочную функцию, позволяющую найти наилучший ход. Предположим, что есть возможность просматривать вперед на несколько ходов. Тогда выбор хода может быть существенно улучшен. Определим уровень просмотра как число будущих рассматриваемых ходов. Начиная с любой позиции, можно построить дерево возможных позиций, получающихся после каждого хода. Такое дерево называется деревом игры. Дерево игры для начальной позиции игры в крестики-нулики с уровнем просмотра 2 приведено на рис. 6.6.2. (На самом деле, существуют и другие позиции, но они с точностью до симметрии совпадают с показанными.) Отметим, что в таком дереве максимальный уровень узлов (называемый глубиной) равен уровню просмотра. Обозначим игрока, который ходит в начальной (корневой) позиции игры, знаком плюс, а его соперника знаком минус. Мы попытаемся найти наилучший ход для игрока «плюс» в кор- т
невой позиции игры. Оставшиеся узлы дерева могут быть обозначены как плюсовые или минусовые узлы в зависимости от того, кто из соперников должен ходить в данной позиции. Каждый узел на рис. 6.6.2 отмечен соответствующим знаком. Предположим, что игровые позиции всех сыновей плюсового узла.были оценены для игрока «плюс». Тогда ясно, что игроку «плюс» следует выбрать ход с максимальной оценкой. Стало быть, значением плюсового узла для игрока «плюс» является максимальное из значений у сыновей данного узла. С другой стороны, как только игрок «плюс» сделал свой ход, игрок «минус» выберет ход с минимальной оценкой для игрока «плюс». Поэтому значением минусового узла для игрока «плюс» является минимальное из значений у сыновей данного узла. 1 о 1 0 -1 1 2 -1 0 -1 0 -2 Рис. 6.6.2. Дерево для игры в крестики-нолики. Следовательно, для того чтобы выбрать наилучший ход для игрока «плюс» в начальной позиции (в корне дерева), надо оценить для этого игрока с помощью статической оценочной функции позиции в листьях дерева. Затем эти значения передвигаются вверх по дереву игры; при этом каждому плюсовому узлу присваивается максимальное значение из значений его сыновей, а каждому минусовому — минимальное в предположении, что игрок «минус» выберет наихудший для игрока «плюс» ход. Значения, присвоенные с помощью описанной процедуры каждому узлу дерева, показаны на рис. 6.6.2 снизу от узлов. Игроку «плюс» следует выбрать в исходной позиции, представленной в корне, тот ход, который соответствует максимуму. Таким образом, начальным ходом для X должен быть ход в центр, что видно из рис. 6.6.2. На рис. 6.6.3 показано определение наилучшего ответа для 0. Отметим, что обозначения «плюс» и «минус» зависят от того, чей ход вычисляется в данный момент. Так, на рис. 6.6.2 X обозначен как игрок «плюс», а на рис. 6.6.3 — 0. При применении к позиции статической оценочной функции вычисляется значение позиции для данного игрока «плюс». Такой метод называется методом минимакса, так как 369
по мере продвижения вверх по дереву попеременно используются функции максимума и минимума. Наилучший ход игрока в данной позиции может быть найден следующим образом. Вначале строится дерево игры и к позициям в листьях дерева применяется статическая оценочная функция. Затем эти значения переносятся вверх по дереву, используя минимум и максимум соответственно в минусовых и плюсовых узлах. Каждый узел дерева игры должен содержать представление позиции и признак, какой это узел: плюс или минус. Массив узлов может быть описан так: -з -2 -з -4 -з Рис. 6.6.3. Определение ответа для 0. -з 10 DEFSTR В 20 DIM BOARD (500,3,3) 30 DIM TURN (500) 40 DIM SN (500) 50 DIMNXT(500) Элемент BOARD (P,ROW,COL) .имеет значения «Χ», «0» или « » в зависимости от того, занят ли квадратик в строке ROW и столбце COL позиции, заданной в узле node(P), каким-либо игроком или никем не занят. Элемент TURN(P) имеет значение 1 или —1, что обозначает для узла node(P) соответственно узел плюс и узел минус. Остальные два поля узла используются для указания положения узла в дереве: поле SN(P) задает старшего сына узла node(P), а поле NXT(P) указывает на его следующего по старшинству брата. Мы предполагаем, что был создан список свободных узлов и. написаны соответствующие программы getnode и freenode. Написанная на языке Бейсик программа nextmove имеет три входные переменные: BRD, DEPTH и ХО — и вычисляет наилучший следующий ход. BRD — это массив 3X3, представляющий текущую позицию, DEPTH — желаемая глубина про- 370
смотра, а ХО — признак, чей ход вычисляется («X» или «О»). Выход программы nextmove — массив В, представляющий наилучшую достижимую позицию для данного игрока из позиции BRD. В программе nextmove используются две вспомогательные программы — buildtree и bestbranch. Программа buildtree строит дерево игры с корнем, соответствующим позиции BRD, и возвращает указатель на корень дерева. Программа bestbranch получает указатель узла ND и вычисляет значения двух выходных переменных: переменная HIGH содержит указатель на тога сына узла node(ND), который представляет наилучший ход, и переменная VLUE содержит оценку хода по методу минимак- са. Ниже мы приведем эти программы. Во всех программах этого раздела мы не описываем явно массивы BOARD, TURN, SN и NXT как входы и выходы, хотя большинство программ именно так их и использует. 2000 'подпрограмма nextmove 2010 'входы: BRD, DEPTH, XO 2020 'выходы: В 2030 'локальные переменные: COL, ND, ROW 2040 GOSUB 3000: 'подпрограмма buildtree устанавливает значение пе- 'ременной TREE 2050 ND=TREE 2060 GOSUB 6000: 'подпрограмма bestbranch устанавливает значение пе- 'ременной HIGH 2070 FOR ROW =1 ТО 3 2080 FOR COL-1 ТО 3 2090 В (ROW.COL) = BOARD (HIGH.ROW.COL) 2100 NEXT COL 2110 NEXT ROW 2120 RETURN 2130 'конец подпрограммы Подпрограмма buildtree возвращает указатель на корень дерева игры. Входные переменные — это переменная BRD, представляющая позицию, и переменная DEPTH, задающая глубину создаваемого дерева. Подпрограмма buildtree устанавливает в переменной TREE указатель на вновь созданное дерево соответствующей глубины с позицией в корне дерева BRD. Подпрограмма buildtree использует вспомогательную подпрограмму getnode, которая получает узел из списка свободных узлов и возвращает указатель на него, а также программу expand. Эта программа получает указатель узла в дереве игры Ρ и глубину DEPTH того дерева игры, которое требуется создать. Программа expand создает поддерево требуемой глубины с корнем в Р. 3000 'подпрограмма buildtree ЗОЮ 'входы: BRD, DEPTH 3020 'выходы: TREE 3030 'локальные переменные: COL, P, ROW 3040 GOSUB 1000: 'подпрограмма getnode устанавливает значение переменной GTNODE 3050 TREE=GTNODE: 'инициализация корня дерева 3060 F0RR0W=1T0 3 371
3070 FOR COL-1 TO 3 3080 BOARD (TREE.ROW.COL) - BRD (ROW.COL) 3090 NEXT COL 3100 NEXT ROW 3110 TURN (TREE) = 1: 'по определению корень — узел плюс 3120 SN(TREE)=0 3130 NXT(TREE)=0 3140 P=TREE 3150 GOSUB 4000: 'подпрограмма expand создает остаток дерева игры 3160 RETURN 3170 'конец подпрограммы Подпрограмма expand может быть написана так, чтобы она вызывала вспомогательный рекурсивный алгоритм expand2, имеющий дополнительную входную переменную level — уровень узла node(P) в дереве. Подпрограмма expand2 генерирует все позиции, которые могут быть получены из позиции в узле node(P), и записывает их в дерево игры в качестве сыновей Р. Затем она рекурсивно вызывает себя; при этом в качестве корня последовательно используются все созданные сыновья. Процедура повторяется до тех пор, пока не будет достигнута желаемая глубина. Подпрограмма expand2 использует вспомогательную подпрограмму generate, которая получает Ρ и возвращает указатель списка узлов, содержащих те позиции, которые могут быть получены из позиции в узле node(P). Элементы списка связаны через поле NXT. Мы оставляем написание подпрограммы generate читателю в качестве упражнения. Теперь мы приведем рекурсивный алгоритм для программы expand, а вслед за ним — программу на языке Бейсик, реализующую данный алгоритм. subroutine expand (p.depth) level=0 'уровень узла node (p) expand2 (p,level,depth) return subroutine expand2 (p,level,depth) if levels depth then 'p уже на максимальном уровне return endif q=generate (ρ) sn(p)=q 'прохождение списка узлов while q< >0 do turn (q) = --turn (p) sn(q)=0 expand2(q,level+l,depth) q=nxt(q) endwhile return Перед рассмотрением программы на языке Бейсик следует отметить ряд моментов. Прежде всего, поскольку программа на языке Бейсик сама по себе не рекурсивна, нет смысла в отдельной программе expand2. Значения переменной алгоритма 372
level (содержащиеся в программе в стеке с тем же именем, а также переменная с ее текущим значением CLEVEL) инициализируются, помещаются в стек и выбираются из стека в самой программе expand. Необходимо также отметить, что программа содержит два места с имитацией рекурсии: одно — в программе expand и другое— в программе bestbranch, приводимой ниже. Каждой из этих программ нужен отдельный стек для рекурсии. Следовательно, требуются два разных набора программ работы со стеком. Поэтому мы предполагаем, что программы pushl и popl (с начальными номерами операторов 10000 и 20000 соответственно) вызываются программой expand, а программы push2 и рор2 (с номерами 11000 и 21000) —программой bestbranch. Помимо этого, поскольку никакая из этих программ не вызывает другую, они могут совместно использовать один массив RETADDR для двух стеков, содержащих адреса возврата. В противном случае потребовались бы два отдельных массива. (На самом деле могли бы разделяться и остальные стеки рекурсии, но это создало бы некоторую путаницу между именами стеков и именами переменных, содержащих текущие значения.) Следовательно, мы предполагаем, что массив RETADDR описан в основной программе и совместно используется двумя подпрограммами. Теперь приведем реализацию подпрограммы expand на языке Бейсик: 4000 'подпрограмма expand 4010 'входы: DEPTH, MAXSTACK, Ρ 4020 'выходы: нет 4030 'локальные переменные: CLBVEL, CP, CQ, CRETADDR, I, PI, Q, ТР 4040 'определение стека рекурсии; каждый 1-й элемент стека содержит 'указатель на дерево Р1 (I), уровень этого узла LEVEL(I) и значе- 'ние Q(I) переменной CQ; стек для адресов возврата RETADDR 'описан в главной программе 4050 DIM Ρ1 (MAXSTACK), LEVEL (MAXSTACK), Q (MAXSTACK) 4060 TP=0 4070 CRETADDR =1 4080 CP=P 4090 CLEVEL=0 4100 CQ=0 4110 GOSUB 1000O: 'подпрограмма pushl 4120 'если CLEVEL=DEPTH, то СР уже на максимальном уровне 4130 IF CLEVEL=DEPTH THEN GOTO 4300 4140 GOSUB 5000: 'подпрограмма generate получает СР и устанавливает значение переменной GNERATE 4150 CQ^GNERATE 41-60 SN(CP)=CQ 4170 IF CQ=0 THEN GOTO 4300 4180 'если CQ< >0, то пройти список узлов 4190 TURN (CQ) = -TURN (CP) 4200 SN(CQ)=0 4210 'имитация рекурсивного вызова expand2(q,level+l,depth) 4220 GOSUB 10000: 'подпрограмма pushl 4230 CP=CQ 373
4240 CLEVEL=CLEVEL+1 4250 CRETADDR=2 4260 GOTO 4120 4270 'точка возврата после рекурсивного вызова 4280 CQ=NXT(CQ) 4290 GOTO 4170 4300 'имитация возврата из EXPAND 4310 I=CRETADDR 4320 GOSUB 20000: 'подпрограмма popl 4330 IF 1=1 THEN RETURN 4340 IF 1=2 THEN GOTO 4270 4350 'конец подпрограммы Как только построено дерево игры, подпрограмма bestbranch оценивает узлы дерева. Когда подпрограмме bestbranch передается указатель на лист дерева, она вызывает подпрограмму evaluate, которая статически оценивает соответствующую этому листу позицию, для того игрока, чей ход мы находим. Написание программы evaluate оставлено в качестве упражнения. Когда подпрограмме bestbranch передается указатель на узел, не являющийся листом, она рекурсивно вызывает себя для каждого сына узла, а затем присваивает узлу максимальное значение, если это узел плюс, и минимальное, если это узел минус. Подпрограмма bestbranch также запоминает, какой из сыновей дает это максимальное или минимальное значение. Если значение TURN(P) равно —1, то узел node(P)—узел минус, и ему должно быть присвоено минимальное значение из оценок, присвоенных сыновьям. Однако если значение TURN(P) равно +1, то узел node(P)—узел плюс, и ему должно быть присвоено максимальное значение из оценок, присвоенных его сыновьям. Если min(x,y) суть минимум из χ и у, а тах(х,у) — максимум, то min(x,y)=—тах(—х,—у) (читателю предлагается доказать это тривиальное соотношение). Стало быть, правильный максимум или минимум может быть найден следующим образом. Для узла плюс вычисляем максимум; для узла минус вычисляем максимум значений оценок, взятых со знаком минус, и затем меняем знак у результата. Эти идеи заложены в подпрограмму bestbranch. Когда вычисляется оценка сына узла node(ND), значение умножается на TURN(ND) (+1 или —1 в зависимости от того, узел ND — узел плюс или узел минус). Если результат больше, чем наибольшее значение, полученное для остальных сыновей узла ND, то этот сын считается наилучшим ходом после хода ND. Чтобы получить значение этого хода для данного игрока, мы снова умножаем на TURN(ND), вычисляя тем самым соответственно либо максимум, либо минимум. Входные переменные подпрограммы bestbranch — это переменная ND — указатель узла дерева, для которого мы определяем наилучший следующий ход, и переменная ХО — признак игрока, для которого мы находим лучший ход. Выходные переменные— это переменная HIGH, являющаяся указателем на того сына узла node(ND), который максимизирует или миними- 374
зирует оценку узла, и переменная VLUE, содержащая значение оценки этого сына, которое затем становится значением оценки узла node(ND). Ниже мы приводим рекурсивный алгоритм программы best- branch, а затем реализацию на языке Бейсик. subroutine bestbranch(nd,xo) if sn(nd)=0 then vlue= evaluate (nd,xo) high=nd return endif 'определить оценку старшего сына p=sn(nd) bestbranch(p,xo) 'присваивает vlue оценку node (ρ) 'В переменной tvlue хранится наибольшая оценка из всех до сих пор просмотренных сыновей, а в переменной temp хранится указатель на сына 'с данной оценкой; начальные значения взяты такими, чтобы старший сын 'давал наилучший ход из всех просмотренных до сих пор сыновей tvlue—turn (nd) »vlue temp—ρ 'пройти оставшихся сыновей и переустановить tvlue и temp. p=nxt(p) while p< >0 do bestbranch (ρ,χο) 'присваивает vlue оценку node(p) v2—turn (nd)* vlue if v2> tvlue then tvlue=v2 temp=ρ endif p=nxt(p) endwhile vlue=turn(nd)»tvlue high=temp return Далее следует текст подпрограммы bestbranch на языке Бейсик. Мы предполагаем, что действует оператор DEFSTR X и подпрограмма evaluate получает переменные CND и ХО и устанавливает переменную EVLUATE. 6000 'подпрограмма bestbranch 6010 'входы: MAXSTACK, ND, XO 6020 'выходы: HIGH, VLUE 6030 'локальные переменные: CND, CP, CRETADDR, I, NSTACK, 'PSTACK, TEMP, TSTACK, TVLUE, V2, VSTACK 6040 'определение стека рекурсии; каждый 1-й элемент стека содержит 'указатель на дерево NSTACK(I) и значения переменных CP, TVLUE 'и TEMP (их значения хранятся соответственно в PSTACK(I), 'VSTACK(I) и RSTACK(I)); стек для хранения адресов возврата — 'RETADDR — описан в основной программе 6050 DIM NSTACK (MAXSTACK), P STACK (MAX STACK) 6060 DIM TSTACK (MAXSTACK), VSTACK (MAXSTACK) 6070 'поместить в стек начальную запись 6080 CRETADDR«1 6090 CND=ND 6100 СР=0 6110 ТЕМР-0 6120 TVLUE=0 375
. 6130 GOSUB 110Q0: 'подпрограмма push2 6140 'если SN(CND)=0, то узел суть лист; вызвать подпрограмму 'evaluate (номер строки 7000) 6150 'IF SN(CND)-0 THEN GOSUB 7000: VLUE=EVLUATE: HIGH^CND: GOTO 6410 6160 'узел — не лист; пройти его сыновей 6170 CP-SN(CND) 6180 'имитация первого рекурсивного вызова подпрограммы 'bestbranch(p,xo) 6190 GOSUB 11000: 'подпрограмма push2 6200 CND=CP 6210 CRETADDR=2 6220 GOTO 6140 6230 'точка возврата после первого рекурсивного вызова 6240 'если CND — узел минус, умножить на — 1 6250 TVLUE=TURN(CND)*VLUE 6260 ТЕМР-СР 6270 CP=NXT(CP) 6280 IF СР«0 THEN GOTO 6390 6290 'имитация второго рекурсивного вызова подпрограммы 'bestbranch (ρ,χο) 6300 GOSUB 11000: 'подпрограмма push2 6310 CND=CP 6320 CRETADDR=3 6330 GOTO 6140 6340 'точка возврата после второго рекурсивного вызова 6350 V2-TURN(CND) *VLUE 6360 IF V2>TVLUE THEN TVLUE«V2: TEMP=CP 6370 CP=NXT(CP) 6380 GOTO 6280 6390 VLUE=TURN (CND) *TVLUE 6400 HIGH=TEMP 6410 'имитация возврата из bestbranch 6420 I=CRETADDR 6430 GOSUB 21000: 'подпрограмма рор2 6440 IF 1 = 1 THEN RETURN 6450 IF 1-2 THEN GOTO 6230 6460 IF 1 = 3 THEN GOTO 6340 6470 'конец подпрограммы Упражнения 1. Напишите на языке Бейсик введенные ранее подпрограммы generate и evaluate. 2. Перепишите программы данного и предыдущего разделов в предположении, что каждый узел дерева включает поле FTHER, содержащее указатель отца узла. Стали ли программы при такой реализации более эффективными? 3. Измените программу bestbranch так, чтобы освобождались те узлы, которые больше не нужны. 4. Объедините процессы построения дерева игры и оценки его узлов в единый процесс так, чтобы в любой конкретный момент не требовалось полное дерево игры и освобождались те узлы, которые больше не требуются. 5. Измените программу из упражнения 3 так, что если оценка узла минус больше минимума значений старших братьев его отца, то в программе не строятся поддеревья младших братьев этого узла, а если оценка узла плюс меньше максимума значений старших братьев его отца, то в программе не строятся поддеревья младших братьев этого узла. Такой метод называется мини-максным методом альфа-бета. Объясните, почему он корректен. 376
6. Правила игры в калах следующие. У каждого игрока есть семь ям, шесть из которых просто ямы, а седьмая — калах. Они расположены так: Игрок 1 к яяяяяя яяяяяя к Игрок 2 Вначале в каждой яме находится по шесть камешков, а в калахах — ни одного. Стало быть, начальная позиция выглядит так: 0 666666 666666 О Игроки ходят по очереди. За один раз можно сделать один или большее· #число ходов. Чтобы сделать ход, игрок выбирает одну из своих непустых *ям. Камешки удаляются из этой ямы и распределяются против часовой стрелки в ямы и калахи этого игрока (калах соперника пропускается) по одному камешку до тех пор, пока не будут распределены все камешки из выбранной ямы. Например, если первым ходит игрок 1, то после его первого хода возможна следующая позиция: 1 777770 666666 0 Если последний камешек игрока кладется в его калах, игрок получает право еще на один ход. Если последний камешек кладется в одну из его пустых ям, то этот камешек и камешки из ямы соперника, находящейся напротив данной ямы, переносятся в калах этого игрока. Игра заканчивается, когда у какого-либо игрока не останется камешков в его ямах. В этом момент все камешки соперника из ям переносятся в его калах. Выигрывает тот, у кого окажется больше камешков в калахе. Напишите программу, которая получает позицию и признак, чей сейчас ход, а также находит наилучший для данной позиции ход. 7. Как бы вы изменили идею программы в крестики-нолики, чтобы найти наилучший ход в игре, содержащей элемент неопределенности, такой как триктрак? 8. Почему ЭВМ отлично играют в крестики-нолики и не так хорошо в шахматы или шашки? 9. Правила игры в ним следующие. Берется кучка спичек. Двое игроков по очереди берут из нее одну или две спички. Тот, кто возьмет последнюю, проиграл. Напишите для этой игры на языке Бейсик программу нахождения лучшего хода.
Глава 7 Графы и их приложения В этой главе мы рассмотрим новую структуру данных — граф. Мы определим несколько терминов, связанных с графами, и покажем, как реализуются графы в программах на языке Бейсик. Будет рассмотрено также несколько приложений графов. 7.1. ГРАФЫ Граф состоит из множества уздов (или вершин) и множества дуг. Каждая дуга в графе указывается парой узлов. На рис. 7.1.1, а показан граф. Множество узлов — это {A,B,C,D,E,F,G,H), а множество дуг —{(А,В), (A,D), (A,C), (C,D), (C,F), (E,G), (Α,Α)}. Если пары узлов, образующих дугу, упорядочены, то граф называют направленным графом. На рис. 7.1.1,6—г показаны три направленных графа. Стрелки между узлами обозначают дуги. Голова каждой стрелки указывает на второй узел упорядоченной пары узлов, составляющей дугу, а хвост — на первый узел пары. Множество дуг графа на рис. 7.1.1, б-это {<А,В>, <А,С>, <A,D>, <C,D>, <F,C>, <E,G>, <A,A>}. Мы используем круглые скобки, чтобы показать неупорядоченные пары, и угловые скобки для упорядоченных пар. Далее в этой главе мы будем рассматривать только направленные графы. Отметим, что граф не обязан быть деревом (рис. 7.1.1, α и б), но дерево — это обязательно граф, в котором мы можем рассматривать указатель от отца к сыну в качестве дуги графа (рис. 7.1.1, в). Отметим также, что узел не обязан иметь связанных с ним дуг (узел Η на рис. 7.1.1,α и б). Узел η называется инцидентным дуге х, если η — это один из двух узлов упорядоченной пары, составляющей дугу х. (Мы говорим также, что дуга χ инцидентна узлу п.) Степень узла — это число дуг, инцидентных узлу. Полустепень захода узла η — это число дуг, для которых узел η является головой стрелки, а полустепень исхода узла η — это число дуг, для которых узел η является хвостом стрелки. Например, узел А на рис. 7.1.1, г имеет полустепень захода, равную 1, полустепень исхода 2 исте- 378
G3 Ό: Θ Θ Θ Θ Θ -Θ Θ Θ θ θ θ: ;θ Θ Θ Θ Θ Ι . Θ Рис. 7.1.1. Примеры графов. пень 3. Узел η называется смежным с узлом т, если существует дуга из m в п. Отношение R на множестве А — это множество упорядоченных пар элементов А. Если пара <х,у> — член отношения R, то говорят, что χ находится с у в отношении R. Например, если А — это множество {3,5,6,8,10,17}, то множество {<3,10>, <5,6>, <5,8>, <6,17>, <8,17>, < 10,17» является отношением на А. Это отношение может быть описано так: χ и у находятся в данном отношении, если и х, и у принадлежат А, х меньше чем у и остаток от деления у на χ нечетный. Элемент <8,17> является членом описываемого отношения, поскольку 8 меньше чем 17 и остаток от деления 17 на 8 равен 1, т. е. нечетному числу. Отношение мо- 379
жет быть представлено графом, в котором узлы представляют основное множество, а дуги — упорядоченные пары отношения. На рис. 7.1.2, α показан граф, представляющий описанное выше отношение. С каждой дугой графа может быть связано некоторое значение, как это показано на рис. 7.1.2,6. В данном случае с каждой дугой связан остаток от деления целого числа, находящегося в узле — голове стрелки, на число в узле — хвосте стрелки. Такой граф, у которого с каждой дугой связано какое-либо значение, называется взвешенным графом или сетью. Связанное с каждой дугой значение называется весом. Определим несколько простых операций, полезных при работе с графами. Операция join(a,b) добавляет к графу дугу от узла а к узлу Ь, если такой дуги не было. Операция joinwt(a,b,x) добавляет к взвешенному графу дугу с весом χ от узла а к узлу Ь. Операции remv(a,b) и remvwt(a,b,x) удаляют дугу от узла а к узлу Ь, если такая дуга существует (операция remvwt также присваивает χ значение веса дуги). Хотя нам надо определить операции добавления и удаления узлов, мы отложим их обсуждение до следующего раздела. Функция adjacent (a,b) возвращает значение «истина», если узел b смежен с узлом а, и значение «ложь» в противном случае. Путь длиной к от узла а до узла b определяется как последовательность из к+1 узлов пь п2,..., Пк+ι, такая что щ = а9 nk+i = b и значение функции adjacent(γπ,αζι+ι) равно значению «истина» для всех i между 1 и к. Если для какого-то г существует путь длиной к от узла а до узла Ь, то существует путь от узла а до узла Ь. Путь от узла κ самому себе называется циклом. Если граф содержит цикл, то такой граф называется циклическим, а в противном случае—ациклическим. Рассмотрим граф на рис. 7.1.3. В нем существует путь длиной 1 от узла к узлу С, два пути длиной 2 от узла В к узлу G и один путь длиной 3 от узла А к узлу F. Существуют также циклы от узла В к узлу В, от узла F к узлу F и от узла Η к узлу Н. Убедитесь, что вы можете найти в этом графе все пути с длиной меньше 9 и все циклы. Представление графов на языке Бейсик Теперь обратимся к вопросу, как можно представить графы на языке Бейсик. Предположим, что число узлов в графе постоянно; могут добавляться и удаляться дуги, но не узлы. Стало быть, граф с пятью узлами можно описать следующим образом: 10 N = 5 20 DIM INFO (N) 30 DIMADJ(N,N) 380
'—В Θ' 4 Рис. 7.1.2. Отношения и графы. & Θ Θ Θ Θ Рис. 7.1.3. Каждый узел графа представляется целым числом от 1 доЫ, и элемент массива INFO(I) представляет данные, связанные с узлом I. Значение элемента ADJ(I,J) равно «истина» или «ложь» (т. е. 1 или 0) в зависимости от того, смежен ли узел J 381
с узлом I. Двумерный массив ADJ называется матрицей смежности. Порядок такой матрицы смежности определяется числом узлов в графе (что равно N). В случае взвешенного графа с каждой дугой связывается информация, которая хранится в двумерном массиве, объявленном как 40 DIM WEIGHT (Ν,Ν) Часто узлы невзвешенного графа нумеруются от 1 до N и с ними не связывается никакая информация. В этом случае мы интересуемся только существованием дуг между узлами; нам не важны никакие данные об узлах и дугах. Тогда граф мог бы быть описан просто как 20 DIMADJ(N,N) Действительно, граф полностью описывается своей матрицей смежности. Мы приводим текст программ, реализующих описанные выше операции, для случая когда граф описан его матрицей смежности: 1000 'подпрограмма join 1010 'входы: ADJ, N1, N2 1020 'выходы: ADJ 1030 'локальные переменные: нет 1040 'добавить дугу от узла N1 к узлу N2 1050 ADJ(N1,N2)=TRUE 1060 RETURN 1070 'конец подпрограммы 2000 'подпрограмма remv 2010 'входы: ADJ, N1, N2 2020 'выходы: ADJ 2030 'локальные переменные: нет 2040 'удалить дугу от узла N1 к узлу N2 2050 ADJ(N1,N2)=FALSE 2060 RETURN 2070 'конец подпрограммы 3000 'подпрограмма adjacent ЗОЮ 'входы: ADJ, N1, N2 3020 'выходы: AJACENT 3030 'локальные переменные: нет 3040 'проверить, есть ли дуга от узла N1 к узлу N2 3050 IF ADJ(N1,N2) =TRUE THEN AJACENT-TRUE ELSE AJACENT-FALSE 3060 RETURN 3070 'конец подпрограммы Взвешенный граф с фиксированным числом узлов может <5ыть описан так: 20 DIMADJ(N,N) 30 DIM WEIGHT (Ν,Ν) Ниже приводится текст программы joinwt, которая добавляет дугу с весом WT от узла N1 к узлу N2: 4000 'подпрограмма joinwt 4010 'входы: ADJ, WEIGHT, N1, N2, WT 382
4020 "выходы: ADJ, WEIGHT 4030 'локальные переменные: нет 4040 ADJ(N1,N2)=TRUE 4050 WEIGHT (N1,N2)=WT 4060 RETURN 4070 'конец подпрограммы Написание программы remvwt мы оставляем читателю в качестве упражнения. Матрицы путей При работе с переменными, принимающими только два значения— true (истина) и false (ложь), — полезны две бинарные операции: and (и) и or (или), называемые также логическим» операциями. Если χ и у — переменные, принимающие значения «истина» и «ложь», то выражение χ and у принимает значение «истина» тогда и только тогда, когда как х, так и у имеют значение «истина»; в противном случае это выражение имеет значение «ложь». Выражение χ or у принимает значение «истина* тогда и только тогда, когда либо х, либо у, либо обе переменные имеют значение «истина». Стало быть, χ or у имеет значение «ложь» тогда и только тогда, когда как х, так и у имеют значение «ложь». Операция and называется конъюнкцией, а операция or — дизъюнкцией. Давайте предположим, что граф с η узлами полностью описан его матрицей смежности adj (т. е. с узлами не связана какая-либо информация и граф не является взвешенным). Рассмотрим логическое выражение adj (i, k) and adj (k, j). Его значение— «истина» тогда и только тогда, когда как adj (i, k), так и adj (k, j) имеют значение «истина». А это означает, что существует дуга из узла i в узел к и дуга из узла к в узел j. Следовательно, выражение adj (i, к) and adj (к, j) имеет значение «истина» тогда и только тогда, когда существует путь длиной 2 из- узла i в узел j через узел к. Теперь рассмотрим выражение (adj(i, 1) and adj(1,j)) or (adj(i,2) and adj(2,j)) or... or (adj(i,n) and adj(n,j)) Значение выражения равно «истина» тогда и только тогда, когда существует путь длиной 2 из узла i в узел j либо через: узел 1, либо через узел 2, ..., либо через узел п. Другими словами, выражение имеет значение «истина» тогда и только тогдаг когда существует какой-либо путь длиной 2 из узла i в узел j. Рассмотрим такой двумерный массив adj*2, что элемент adJ2 (i, j) равен вышеприведенному выражению. Такой массив adj*2 называется матрицей путей длиной 2. Значение adj2(i, j) показывает^ существует или нет путь длиной 2 между узлами i и j. Если 383
вы знакомы с умножением матриц, то можете заметить, что adj2 равно произведению adj на себя; при этом.умножение заменяется конъюнкцией, а сложение — дизъюнкцией. Гоборят, что матрица adj2 равна булеву произведению матрицы adj на себя. На рис. 7.1.4, α показаны граф и его матрица смежности («истина» представлена как 1 и «ложь» — 0). На рис. 7.1.4,6 изображены булево произведение матрицы на себя и, следовательно, матрица путей, длиной 2 для графа. Убедитесь, что 1 стоит в строке i и столбце j матрицы на рис. 7.1.4,6 тогда и только тогда, когда в графе существует путь длиной 2 из узла i в узел j. Аналогично определим adJ3 — матрицу путей длиной 3 — как булево произведение матрицы adj2 на матрицу adj. Элемент adJ3(i, j) равен 1 тогда и только тогда, когда существует путь длиной 3 из узла i в узел j. В общем случае, чтобы вычислить матрицу путей длиной 1, надо умножить матрицу путей длиной 1—1 на матрицу смежности. На рис. 7.1.5 приведены матрицы adj3 и adj4 для графа, показанного на рис. 7.1.4, а. А В С D Ε А 0 0 0 0 0 В 0 0 0 0 0 с 0 0 0 0 0 D 1 1 1 0 1 Ε 1 1 1 1 0 adj3 А В С D ε А 0 0 0 0 0 в 0 0 0 0 0 с 0 0 0 0 0 D 1 1 1 1 0 Ε 1 1 1 0 1 ad/4 Рис. 7.1.5. Мы можем написать программу prod, вычисляющую булево произведение С матриц А и В (пусть матрица А имеет размерность А1 на А2, а матрица В—В1 на В2; тогда матрица С имеет размерность А1 на В2): 6000 'подпрограмма prod 6010 'входы: Α, ΑΙ, Α2, В, Bl, B2 6020 'выходы: С А В С D Ε А 0 0 0 0 0 В 0 0 0 0 0 с 1 1 0 0 0 D 1 0 1 0 1 Ε 0 0 1 1 0 adj А В С D Ε А 0 0 0 0 о в 0 0 0 0 0 с 0 0 0 0 0 D 1 1 1 1 0 Ε 1 1 1 0 1 adj2 Рис. 7.1.4. 384
6030 6040 6050 6060 6070 6080 6090 6100 6110 6120 6130 6140 6150 'локальные переменные: CI J, II, JJ, KK IF A2< >B1 THEN PRINT «ПРОИЗВЕДЕНИЕ HE ОПРЕДЕЛЕНО»: STOP FOR 11=1 TOA1 FOR JJ=1 TOB2 CIJ=FALSE FOR KK= 1 TO A2 IF A(II,KK)=TRUE AND B(KK,JJ)=TRUE THEN CIJ=TRUE NEXT KK C(II,JJ)=CIJ NEXT JJ NEXT II RETURN 'конец подпрограммы Можно также написать программу, которая получает матри- смежности ADJ, ее размерность N, целое число К и вычисля- матрицу adjk в двумерном массиве APROD: 7000 'подпрограмма adjprod 7010 'входы: ADJ, К, Ν 7020 'выходы: APROD 7030 'локальные переменные: A, Al, A2, В, Bl, B2, С, I, J, NUM 7040 'инициализировать В и С матрицей ADJ 7050 FOR 1 = 1 TON 7060 FORJ=*lTON 7070 B(I,J)=ADJ(I,J) 7080 C(I,J)=ADJ(I,J) 709Q NEXT J 7100 NEXT I 7110 'инициализировать остальные входы подпрограммы prod 7120 A1=N 7130 A2=N 7140 B1«N 7150 B2«N 7160 'вызвать подпрограмму prod К—1 раз 7170 NUM-1 7180 IF NUM=K THEN GOTO 7280 7190 'переслать матрицу С в матрицу А 7200 FOR 1 = 1 TON 7210 FORJ=lTON 7220 A(I,J)=C(I,J) 7230 NEXT J 7240 NEXT I 7250 GOSUB 6000: 'подпрограмма prod вычисляет в С булево произ- 'ведение матриц А и В 7260 NUM-NUM+1 7270 GOTO 7180 7280 'переслать С в APROD 7290 FOR 1 = 1 TON 7300 FOR J= 1 TO Ν 7310 APROD(I,J)=C(I,J) 7320 NEXT J 7330 NEXT I 7340 RETURN 7350 'конец подпрограммы 385
Транзитивное замыкание Предположим, что мы хотим узнать, существует ли между двумя узлами графа путь длиной 3 или меньше. Если такой путь существует между узлами i и j, то он должен иметь длину 1, 2 или 3. Но это значит, что должно быть истинным значение выражения adj(i,j) or adj2(i, j) or adj3(i,j). На рис. 7.1.6 показана матрица, полученная с помощью логического сложения матриц adj, adj2 и adj3. В этой матрице содержится 1 в строке i и столбце j тогда и только тогда, когда между узлами i и j существует путь длиной 3 или меньше. Представим, что мы хотим построить матрицу path такую, что элемент path(i, j) равен 1 тогда и только тогда, когда существует какой-либо путь из узла i в узел j (любой длины). Ясно, что path(i,j)= adj(i,j) or adj2(i,j) or.... Однако это выражение нельзя использовать для вычисления матрицы path, поскольку оно описывает бесконечный процесс. Но если граф имеет η узлов, то path(i,j) = adj(i,j) oradj2(i,j) or ... or adjn(i, j). A В С D Ε A 0 0 0 0 0 В 0 0 0 0 0 с 1 1 0 0 0 D Ε A В С D Ε A 0 0 0 0 0 в 0 0 0 0 0 с 1 1 0 0 0 D Ε Рис. 7.1.6. Рис. 7.1.7. Это справедливо, поскольку, если существует путь из узла i в узел j (например, i, i2, i3, ..., im, j) и его длина m>n, то должен существовать другой путь из узла i к узлу j с длиной, меньшей или равной п. Чтобы показать, почему это так, отметим, что граф состоит из η узлов и, следовательно, по крайней мере один узел к должен встретиться в пути дважды. Путь из узла i в узел j может быть укорочен за счет удаления цикла из узла к в него же. Подобная процедура повторяется до тех пор, пока в пути не останется одинаковых узлов (возможно, исключая i и j); стало быть, путь имеет длину ή или меньше. На рис. 7.1.7 приведена матрица path для графа на рис. 7.1.4, а. Матрицу path часто называют транзитивным замыканием матрицы adj. Мы можем написать на языке Бейсик программу, которая получает матрицу смежности ADJ и вычисляет ее транзитивное замыкание PATH (в ней используется вспомогательная подпрограмма prod): 3 86
5000 'подпрограмма transclose SOI 0 'входы: AD J, N 5020 'выходы: PATH 5030 'локальные переменные: Α, ΑΙ, Α2, В, Bl, B2, С, I, J, R 5040 'предполагается, что матрицы были описаны ранее 5050 'DIM A(N,N), B(N,N), C(N,N), ΡΑΤΗ(Ν,Ν) 5060 FOR 1=1 TO Ν 5070 FORJ=lTON 508O C(I,J)=ADJ(I,J) 5090 PATH(I,J)=APJ(I,J) 5100 B(I,J)=ADJ(I,J) 5110 NEXT J 5120 NEXT I 5130 A1 = N 'переменные Al, A2, Bl, B2 используются подпрограммой 'prod 5140 A2=N 5150 B1=N 5160 B2=N , - 5170 FORR=lTON-l 5180 'в переменной R хранится степень, в которую была возведена 'матрица ADJ, чтобы получить матрицу С 5190 'в этот момент матрица PATH описывает все пути 5200 'длиной R и меньше; присвоить матрице С значение булева 'произведения матриц С и ADJ, присвоив матрице "А значение 'матрицы С и вызвав подпрограмму prod для получения в С 'произведения матриц А и В 5210 FOR 1=1 TON 5220 FORJ=lTON 5230 A(I,J)=C(I,J) 5240 NEXT J 5250 NEXT I 526Q GOSUB 6000: 'подпрограмма prod присваивает матрице С зна- 'чение булева произведения А и В 5270 'присвоить PATH значение PATH OR С 5280 FOR 1=1 TON 5290 FORJ=lTON 5300 IF C(I,J) =TRUE THEN PATH(I.J) =TRUE 5310 NEXT J 5320 NEXT I 5330 NEXT R 5340 RETURN 5350 'конец подпрограммы Во многих реальных случаях можно несколько улучшить эффективность этой программы путем проверки, осталась ли матрица PATH неизменной после повторения цикла FOR, состоящего из операторов с номерами 5170—5330. Однако в интересах простоты и ясности данная проверка в текст программы не включена. Алгоритм Уоршела Описанный выше метод очень неэффективен. Давайте посмотрим, нельзя ли найти более эффективный метод вычисления матрицы path. Определим матрицу pathk так, чтобы элемент pathk(i, j) был равен значению «истина» тогда и только тогда, когда существует путь из узла i в узел j, не проходящий через какой-либо узел с номером больше к (возможно, исключая 25· 387
сами узлы i и j). Как может быть получено значение pathk+i (i, j) из матрицы pathk? Ясно, что для любых i или j, таких что pathk(i, j) равно значению «истина», pathk+i(i, j) должен тоже иметь значение «истина» (почему?). Единственная возможность, когда pathk+ι (i, j) может быть истинным, a pathk(i, j) ложным— это наличие пути из узла i в узел j, проходящего через узел к+1, и отсутствие пути из узла i в узел j, проходящего только через узлы с номерами от 1 до к. Но это означает, что должен существовать путь из узла i в узел к+1, проходящий только через узлы с номерами от 1 до к, и похожий путь из узла к+1 в узел j. Стало быть, pathk+i(i, j) истинно тогда и только тогда, когда справедливо одно из двух условий: 1. Истинно pathk(i,j). 2. Истинно pathk(i, k+1) и истинно pathk(k+l,j). Это означает, что pathk+i(i, j)= pathk (i,j) or (pathk (i, k+1) and pathk (k+1, j)). На этом основан следующий алгоритм получения матрицы pathk из матрицы pathk-i: for i = 1 to η for j = 1 to η pathk(i,j)=pathk-i(i,j) or (pathk-i(i,k+l) and pathk-i(k+l,j)) next j next i Этот алгоритм может быть логически упрощен и сделан более эффективным: pathk=pathk-i for i = 1 to η if pathk-i(i,k) then for j = 1 to η pathk(i,j)=pathk-i(i,j) or pathk-i(k,j) next j endif next i Ясно, что path0(i,j) = adj(i,j), поскольку единственный путь из узла i в узел j, не проходящий ни через какой узел,— это непосредственный путь (дуга) из узла i в узел j. Далее, pathn (i, j) = path (i, j), поскольку любой путь из узла i в узел j проходит через узлы с номерами от 1 до п. Следовательно, для получения транзитивного замыкания может использоваться следующая программа: 4000 'подпрограмма transclose 4Q10 'входы: ADJ, N 4020 'выходы: PATH 4030 'локальные переменные: I, J, К 4040 'инициализация матрицы PATH матрицей ADJ 4050 FOR 1=1 ТО N 4060 FORJ=lTON 4070 PATH (I,J)= ADJ (I,J) 4080 NEXT J 388
4090 NhXT I 4100 FOR K=l TO N 4110 'вычисление следующих значений PATH 4120 FOR 1=1 TO Ν 4130 IF ΡΑΤΗ(Ι,Κ) =FALSE THEN GOTO 4170 4140 FORJ=lTON 4150 IF PATH(K,J) =TRUE THEN PATH(I,J) =TRUE 4160 NEXT J 4170 NEXT I 4180 NEXT К 4190 RETURN 4200 'конец подпрограммы Этот метод нахождения транзитивного замыкания часто называют по имени его создателя алгоритмом Уоршела. Упражнения 1. Для графа на рис. 7.1.1 а) найдите матрицу смежности; б) найдите матрицу путей, используя степени матрицы смежности; в) найдите матрицу путей с помощью алгоритма Уоршела. 2. Нарисуйте направленные графы, соответствующие следующим отношениям среди целых чисел от 1 до 12: а) х связан отношением с у, если х—у делится нацело на 3; б) χ связан отношением с у, если х+10*у<х*у; в) χ связан отношением с у, если остаток от деления χ на у равен 2. Вычислите для каждого из этих графов матрицу смежности и матрицу путей. 3. Узел nl достижим из узла п2, если nl равен п2 или существует путь из nl в п2. Напишите на языке Бейсик подпрограмму reach, которая получает матрицу смежности adj и два целых числа i и j и определяет для направленного графа, достижим ли узел j из узла i. 4. Напишите на языке Бейсик программу, которая по матрице смежности и двум узлам графа вычисляет а) число путей данной длины между данными двумя узлами; б) общее число путей между данными двумя узлами; в) длину кратчайшего пути между данными двумя узлами. 5. Отношение на множестве S рефлексивно (и соответствующий направленный граф рефлексивен), если каждый элемент S связан этим отношением с самим собой. (а) Что должно быть верно для направленного графа, если он представляет рефлексивное отношение? (б) Приведите пример рефлексивного отношения и нарисуйте соответствующий ему направленный граф. (в) Что должно быть верно для матрицы смежности рефлексивного направленного графа? (г) Напишите на языке Бейсик программу, которая получает матрицу смежности и определяет, представляет ли направленный граф рефлексивное отношение. 6. Отношение на множестве S нерефлексивно (и соответствующий направленный граф нерефлексивен), если никакой элемент S не связан этим отношением с самим собой. (а) Что должно быть верно для направленного графа, если он представляет нерефлексивное отношение? (б) Приведите пример нерефлексивного отношения и нарисуйте соответствующий ему направленный граф. (в) Существует ли отношение, которое не рефлексивно и не нерефлексивно? (См. упражнение 5.) (г) Что должно быть верно для матрицы смежности нерефлексивного направленного графа? 389
(д) Напишите на языке Бейсик программу, которая получает матрицу смежности и определяет, представляет ли направленный граф нерефлексивное отношение. 7. Отношение на множестве S симметрично (и соответствующий направленный граф симметричен), если для любых двух элементов S таких, что χ связан этим отношением с у, у тоже связан с х. (а) Что должно быть верно для направленного графа, если он представляет симметричное отношение? (б) Приведите пример симметричного отношения и нарисуйте соответствующий ему направленный граф. (в) Что должно быть верно для матрицы смежности симметричного направленного графа? (г) Напишите на языке Бейсик программу, которая получает матрицу смежности и определяет, представляет ли направленный граф симметричное отношение. 8. Отношение на множестве S антисимметрично (и соответствующий направленный граф антисимметричен), если для любых двух элементов S таких, что χ связан этим отношением с у, у не связан с х. (а) Что должно быть верно для направленного графа, если он представляет антисимметричное отношение? (б) Приведите пример антисимметричного отношения и нарисуйте соответствующий ему направленный граф. (в) Существует ли отношение, которое симметрично и антисимметрично? (См. упражнение 7.) (г) Что должно быть верно для матрицы смежности антисимметричного направленного графа? (д) Напишите на языке Бейсик программу, которая получает матрицу смежности и определяет, представляет ли направленный граф антисимметричное отношение. 9. Отношение на множестве S транзитивно (и соответствующий направленный граф транзитивен), если для любых трех элементов х, у и ζ множества S таких, что χ связан этим отношением с у, а у — с ζ, верно, что χ связан этим отношением с ζ. (а) Что должно быть верно для направленного графа, если он представляет транзитивное отношение? (б) Приведите пример транзитивного отношения и нарисуйте соответствующий ему направленный граф. (в) Что должно быть верно для булева произведения матрицы смежности транзитивного направленного графа на себя? (г) Напишите на языке Бейсик программу, которая получает матрицу смежности и определяет, представляет ли направленный граф транзитивное отношение. (д) Докажите, что транзитивное замыкание любого направленного графа транзитивно. (е) Докажите, что наименьший транзитивный направленный граф, содержащий все узлы и дуги данного направленного графа, является транзитивным замыканием этого направленного графа. 10. Дан направленный граф. Докажите, что можно так перенумеровать его узлы, что получившаяся матрица смежности будет нижней треугольной (см. упражнение 1.2.13) в том и только в том случае, если направленный граф ациклический. Напишите на языке Бейсик программу lowtri, которая получает матрицу смежности ADJ ациклического графа и создает нижнюю треугольную матрицу смежности LTADJ, представляющую тот же самый граф. Программа должна также устанавливать значения одномерного массива PERM размером N так, чтобы в элементе PERM(I) был новый номер, присвоенный узлу, имевшему в матрице ADJ номер I. 390
7.2. ЗАДАЧА О ПОТОКАХ В этом разделе мы рассмотрим реальную практическую задачу и приведем ее решение, основанное на использовании взвешенного графа. Задача может быть сформулирована по-разному, а ее решения имеют широкую область применения. Здесь мы приведем только одну постановку задачи; другие формулировки могут быть найдены в литературе. Рис. 7.2.1. Задача о потоках (а); функция потока (б); функция потока (в). Предположим, что имеется система водопроводных труб (рис. 7.2.1,а). Каждая дуга представляет трубу, а число над дугой — пропускную способность данной трубы в галлонах в минуту (3,8 л/мин). Узлы представляют места соединения труб, т. е. места, где вода переходит из одной трубы в другую. 391
Узлы S и Τ являются соответственно источником воды и стоком. Это означает^ что вода из узла S должна быть передана по водопроводной системе в узел Т. Вода течет по водопроводной системе только в одном направлении (для обеспечения этого могут использоваться вентили, препятствующие обратному потоку); кроме того, в узел S не входит, а из узла Τ не выходит ни одна труба. Взвешенный направленный граф, такой как на рис. 7.2.1, а, представляет идеальную структуру данных для моделирования подобной ситуации. Мы хотим максимизировать объем воды, протекающий от источника к стоку. .Хотя источник может производить большой объем воды, а сток — потреблять примерно такой же объем, водопроводная система может не обеспечивать достаточную пропускную способность. Стало быть, критическим фактором всей системы является пропускная способность водопровода. Многие другие реальные задачи похожи по своей природе на описываемую. Реальными системами могли бы быть электрическая сеть, железнодорожная система, коммуникационная сеть и любая другая распределенная система, в которой надо максимизировать поток, проходящий из одной точки в другую. Определим функцию пропускной способности с(а, Ь), где а и b — узлы, таким образом: если истинно adjacent (a, b) (т. е. существует труба из узла а в узел Ь), то значение с(а,Ь) равно пропускной способности этой трубы. Если же трубы из узла а в узел b нет, то с(а, Ь)=0. Через каждую трубу протекает определенный объем воды (может быть нулевой). Определим функцию потока f (а, Ь), где а и b — узлы, так: значение функции равно 0, если узел b не смежен с узлом а; в противном случае значение функции равно объему воды, протекающей по трубе из узла а в узел Ь. Ясно, что f (a, b)^0 для любых узлов а и Ь. Более того, для любых узлов а и b f (a, b)^c(a, b), поскольку по трубе не может протекать воды больше, чем обеспечивает ее пропускная способность. Пусть ν обозначает объем воды, который проходит по системе труб от узла S к узлу Т. Тогда общий объем воды, вытекающей из узла S по всем трубам, равен общему объему воды, поступающей в узел Τ через все трубы, и оба этих значения равны v. Это может быть выражено равенством 2f(S, χ) = ν= ΣΚ*> Τ). по всем узлам х по всем узлам χ Никакой узел, кроме S, не поставляет воду, и никакой узел, кроме Т, ее не потребляет. Стало быть, объем воды вытекающей из любого узла, не совпадающего с узлами S и Т, равен объему воды, поступающему в этот узел. Это может быть записано так: 2f(x» У) = 2*(У> χ) Для всех X^S> Τ· по всем узлам у по всем узлам у 392
Определим входной поток узла χ как сумму потоков, поступающих в узел х, и выходной поток узла χ как сумму потоков, выходящих из узла х. Тогда условия могут быть переписаны в следующем виде: выходной поток (S) = входной поток (Т) = ν, входной поток (х) = выходной поток (х) для всех χ Φ S, Т. Для данного графа и данной функции пропускной способности может существовать несколько функций потока. На рис. 7.2.1,6 и в приведены две возможные функции потока для графа, показанного на рис. 7.2.1, а. Убедитесь, что вы понимаете, почему обе функции действительно являются функциями потока и почему обе они удовлетворяют приведенным выше соотношениям. Мы хотим найти функцию потока, максимизирующую значение ν — объема воды, протекающего из узла S в узел Т. Такая функция потока называется оптимальной. Ясно, что функция потока на рис. 7.2.1,6 лучше, чем функция потока на рис. 7.2.1, в, поскольку для первой из них ν равно 7, а для второй равно 5. Проверьте, можете ли вы найти функцию потока лучше показанной на рис. 7.2.1,6. Одна допустимая функция потока может быть получена установкой f (а, Ь) в 0 для всех узлов а и Ь. Конечно, эта функция самая неоптимальная, поскольку в этом случае из узла S в узел Τ вообще не поступает вода. Возможно, что данная функция потока может быть улучшена так, что поток воды из узла S в узел Τ возрастет. Однако улучшенная версия должна удовлетворять всем условиям допустимой функции потока. В частности, если поток, поступающий в какой-либо узел (исключая узлы S и Т), увеличивается или уменьшается, то соответственно должен измениться поток, выходящий из этого узла. Стратегия нахождения оптимальной функции потока такова: начать с нулевой функции потока и последовательно улучшать ее до тех пор, пока не будет достигнута оптимальная функция потока. Улучшение функции потока Пусть дана функция потока f. Существует два пути ее улучшения. Во-первых, можно стараться найти такой путь S = =хь х2,..., Χη=Τ из узла S в узел Т, что поток по каждой дуге пути строго меньше ее пропускной способности [т. е. f(xk-i,Xk)< <c(xk-i,Xk) для всех к от 2 до п]. Поток может быть увеличен на каждой дуге такого пути на величину, равную минимуму выражения c(xk-i, Xk)—f(xk-i,Xk) по всем к от 2 до η [так что, когда поток увеличен по всему пути, существует по крайней мере одна дуга <Xk-i,Xk>, для которой f (xk-i,Xk) = c(xk-i, Xk), и на ней поток не может быть увеличен]. 393
Такой метод может быть проиллюстрирован графом на рис. 7.2.2, а. Для каждой дуги указаны соответственно пропускная способность и текущий поток. Существует два пути из узла S в узел Τ с положительным значением потока: (S,A,C,T) и (S,B,D,T). Однако каждый из них содержит одну дугу (<А,С> и <B,D>), в которой поток равен пропускной способности. Стало быть, поток по этим путям не может быть улучшен. В то же Рис. 7.2.2. Увеличение потока в графе. время путь (S,A,D,T) таков, что пропускная способность каждой дуги пути больше, чем текущий поток. Максимальная величина, на которую может быть увеличен поток по этому пути, равна 1, поскольку поток по дуге <D,T> не может превышать 3. Получившаяся в результате функция потока показана на рис. 7.2.2,6. Общий поток из узла S в узел Τ теперь увеличен с 5 до 6. Чтобы убедиться в допустимости функции потока, отметим, что для каждого узла (исключая Т), у которого был 394
увеличен входной поток, на ту же величину был увеличен и выходной поток. Есть ли другие пути, по которым может быть увеличен поток? В данном примере — нет. Однако, исходя из графа, приведенного на рис. 7.2.2, а, мы могли бы выбрать для улучшения путь (S,B,A,D,T). Получившаяся в результате функция потока показана на рис. 7.2.2,0. Эта функция также обеспечивает величину суммарного потока из узла S в узел Т, равную 6, и, следовательно, не лучше и не хуже функции потока на рис. 7.2.2,6. Даже если не существует пути, поток по которому может быть улучшен, можно найти другой метод улучшения суммарного потока от источника к стоку. Метод проиллюстрирован на рис. 7.2.3. На рис. 7.2.3, α не существует пути из узла & Рис. 7.2.3. Увеличение потока в графе. в узел Т, поток по которому может быть улучшен. Но если уменьшить поток из узла X в узел Υ, то может быть увеличен поток из узла X в узел Т. Для компенсации уменьшения входного потока в узел Υ может быть увеличен поток из узла S в узел Υ. Результатом этих изменений является увеличение общего потока из узла S в узел Т. Поток из узла X в узел Υ может быть перенаправлен в узел Τ так, как показано на рис. 7.2.3,6, и суммарный поток из узла S в узел Τ может, следовательно, быть увеличен с 4 до 7. Мы можем обобщить этот второй метод следующим образом. Предположим, что существуют: путь из узла S в некоторый узел у, путь из некоторого узла χ в узел Τ и путь из узла х в узел у с положительным потоком. Тогда поток по пути из узла χ в узел у может быть уменьшен, а потоки из узла х 395
в узел Τ и из узла S в узел у могут быть увеличены на ту же величину. Величина изменения равна минимуму из потока из узла χ в узел у и разностей между пропускной способностью и потоками на путях из узла S в узел у и из узла χ в узел Т. Эти два метода могут быть объединены. Величина объема воды, идущей из узла S в узел Т, может быть увеличена на какую-либо величину (поскольку мы не предполагали существование ограничения на производительность источника) только в том случае, если сеть труб из узла S в узел Τ может обеспечить такое увеличение. Предположим, что пропускная способность труб от узла S к узлу χ допускает увеличение объема воды, поступающей в узел х, на величину а. Тогда, если узел у смежен с узлом χ (т. е. существует дуга <х,у>), объем воды, протекающей из узла χ в узел Т, может быть увеличен на величину, равную минимуму из а и неиспользуемой пропускной способности дуги <х,у>. Это осуществляется с помощью первого метода. Аналогично, если узел χ смежен с некоторым узлом у (т. е. существует дуга <у,х>), объем воды, протекающей из узла у в направлении узла Т, может быть увеличен на величину, равную минимуму из а и существующего потоком из узла у в узел х. Это достигается путем сокращения потока из узла у в узел χ по второму методу. Получившееся уменьшение входного потока в узел χ может быть компенсировано, поскольку пропускная способность труб из узла S в узел χ допускает увеличение входного потока в узел χ на величину а. Продвигаясь так от узла S к узлу Т, можно определить величину, на которую может быть увеличен поток в узел Т. Определим полупуть из узла S в узел Τ как последовательность узлов S = x, X2, ..., Хп=Т такую, что для всех l<i<n либо <Xi_i,Xi>, либо <χι,χι_ι> является дугой. Используя вышеописанную процедуру, мы можем описать алгоритм нахождения такого полупути из узла S в узел Т, при котором поток в каждом узле полупути может быть увеличен. Это делается путем достройки уже найденных частичных полупутей из узла S. Если последний узел в найденном частичном полупути из узла S есть узел х, то в алгоритме рассматривается возможное дополнение полупути таким узлом у, чтобы либо пара <х,у>, либо пара <у,х> была дугой. Частичный полупуть дополняется узлом у только в том случае, если такое расширение может быть выполнено с увеличением входного потока узла у. Как только частичный полупуть дополнен узлом у, этот узел удаляется из рассмотрения как возможное дополнение некоторых других частичных полупутей (т. к. в этот момент мы пытаемся найти единственный полупуть из узла S в узел Т). В алгоритме, конечно же, отслеживаются величина, на которую может быть увеличен входной поток узла у, и тот факт, происходит ли увеличение вследствие рассмотрения дуги <х,у> или дуги <у,х>. Процесс продолжается до тех пор, пока некоторый частич- 396
ный полупуть из узла S не завершается узлом Т. Затем осуществляется проход по полупути в обратном направлении да достижения узла S, при этом корректируются все потоки. (Мы вскоре проиллюстрируем это на примере.) Затем весь процесс повторяется в надежде найти еще один такой полупуть из узла S в узел Т. В том случае, когда никакой частичный полупуть не может быть дополнен, поток не может быть увеличен, и существующий поток является оптимальным. (Читателю предлагается доказать этот факт в качестве упражнения.) Пример Проиллюстрируем этот процесс на примере. Рассмотрим дуги и пропускную способность взвешенного графа, показанного на рис. 7.2.4. Начнем с потока, равного 0, и попытаемся найти оптимальный поток. На рис. 7.2.4, α показана исходная ситуация. Два числа у каждой дуги показывают соответственно пропускную способность и текущий поток. Мы можем рассмотреть полупути из S: (S,X) и (S,Z). Поток из S в X может быть увеличен до 4, а поток из S в Ζ — до 6. Полупуть (S,X) может быть дополнен до (S,X,W) и (S,X,Y) с соответствующим увеличением потока в W и Υ до 3 и 5. Полупуть (S,X,Y) может быть дополнен до (S,X,Y,T) с увеличением потока в Τ до 4. [Отметим, что в этот момент мы могли бы выбрать для продолжения полупуть (S,X,W) и получить (S,X,W,T). Аналогично мы могли бы выбрать (S,Z) и затем (S,Z,Y), а не (S,X) и затем (S,X,W) и (S,X,Y). Выбор в данном случае произвольный.] Поскольку мы достигли Τ по полупути (S,X,Y,T) с общим увеличением 4, мы отмечаем увеличение потока по каждой дуге на эту величину. Результат показан на рис. 7.2.4, б. Повторим проведенный процесс для потока, показанного на рис. 7.2.4,6. Из S можно идти только по полупути (S,Z), поскольку поток по дуге <S,X> равен пропускной способности. Общее увеличение потока в Ζ по этому полупути равно 6. Полупуть (S,Z) может быть дополнен до (S,Z,Y) с увеличением в Υ на 4. Полупуть (S,Z,Y) не может быть дополнен до (S,Z,Y,T), поскольку поток по дуге <Y,T> равен пропускной способности. Однако он может быть дополнен до полупути (S,Z,Y,X) с увеличением потока в X на 4. (Заметим, что, поскольку этот полупуть включает «обратную» дугу <Y,X>, как следствие может потребоваться ограничение на 4 потока из X в Υ.) Полупуть (S,Z,Y,X) может быть дополнен до (S,Z,Y,X,W) с увеличением потока в W на 3 (неиспользуемая пропускная способность дуги <X,W». Этот полупуть может быть дополнен до (S,Z,Y,X,W,T) с увеличением потока в Τ на 3. Поскольку мы достигли Τ с увеличением 3, мы возвращаемся назад по этому полупути. Так как дуги <W,T> и <X,W> проходятся в правильном направлении, поток по ним может быть увеличен на 3. Поскольку дуга 397
г Рис. 7.2.4. Получение оптимального потока. <Y,X> «обратная», поток по дуге <Χ,Υ> уменьшается на 3. Дуги <Ζ,Υ> и <S,Z> проходятся в правильном направлении, и поток по ним может быть увеличен на 3. Результат показан на рис. 7.2.4, е. Попытаемся повторить процесс еще раз. Полупуть (S) может быть дополнен до полупути (S,Z) с увеличением потока в Ζ на 3, полупуть (S,Z) может быть дополнен до (S,Z,Y) с увеличением потока в Υ на 1, и полупуть (S,Z,Y) может быть дополнен до (S,Z,Y,X) с увеличением потока в X на 1. Однако, поскольку дуги <S,X>, <Y,T> и <X,W> имеют поток, совпадающий с пропускной способностью, ни один из полупутей не может быть дополнен, и был найден оптимальный поток. Отметьте, что оптимальный поток может быть не единственным. На рис. 7.2:4,г показан другой оптимальный поток для того же 398
графа; этот поток получен из рис. 7.2.4, α рассмотрением полупутей (S,X,W,T) и (S,Z,Y,T). Алгоритм и программа Пусть дан взвешенный граф (матрица смежности и матрица пропускной способности) с источником S и стоком Т. Алгоритм нахождения оптимальной функции потока для этого графа может быть описан так: 1) инициализировать функцию потока для каждой дуги в 0; 2) попытаться найти такой полупуть из S в Т, который увеличивает поток в Τ на а>0; 3) если такой полупуть не может быть найден, то возврат; 4) увеличить поток в каждом узле полупути (исключая S) на величину а; 5) перейти к шагу 2. Ясно, что ядро алгоритма — шаг 2. Как только узел помещен на конкретный полупуть, он не может использоваться для дополнения другого полупути. Стало быть, в алгоритме используется массив opath такой, что opath(node) истинно или ложно в зависимости от того, находится ли узел node на каком-либо полупути. Требуется также указывать, какие узлы являются конечными в частичных полупутях, с тем чтобы можно было дополнять частичные полупути смежными узлами. Элемент epath(node) показывает, является или нет узел node конечным в частичном полупути. Для каждого узла полупути в алгоритме необходимо отслеживать, какой узел предшествует данному на этом полупути и направление дуги. Элемент precede (node) указывает на узел, предшествующий узлу node на полупути, а элемент forward (node) истинен в том и только в том случае, когда дуга идет от узла precede (node) к узлу node. Значение improve (node) показывает, на сколько поток в узле node может быть увеличен на этом полупути. Алгоритм нахождения такого полупути из узла S в узел Т, на котором может быть увеличен поток, запишем в следующем виде [мы предполагаем, что с(х,у) —пропускная способность дуги от узла χ к узлу у и f(x,y) —текущий поток от узла χ к узлу у]: присвоить значение false элементам opath (node) и epath(node) для всех узлов epath(S)=true opath (S)= true 'вычислить максимальный поток из узла S, который допускается дугами improve (S)=cyMMa c(S,node) по всем узлам node while (opath (T) = false) and (существует такой узел nd, что epath(nd)=true) do epath(nd)= false while существует такой узел i, что (adjacent(nd,i)=true and opath (i)= false and f (nd,i)<c(nd,i)) do 'поток из узла nd в узел i можно увеличить; включить узел i 'в полупуть opath (i)= true epath(i)=true 399
precede (i)=nd forward (i)= true temp«c(nd,i)-f(nd,i) if improve (nd)<temp then improve (i)= improve (nd) else improve (i)= temp endif endwhile while существует такой узел i, что (adjacent(i,nd)= true and opath(i)= false and f(i,nd)>0) do 'поток из узла i в узел nd можно уменьшить; включить узел i 'в полупуть opath(i)=true epath(i)=true precede (i)=nd forward (i)= false if improve (nd) <f (i,nd) then improve (i)= improve (nd) else improve (i)=f(i,nd) endif endwhile endwhile if opath(T)=true then мы нашли полупуть из узла S в узел Τ else поток уже оптимален endif Как только полупуть из узла S в узел Τ найден, поток по этому полупути может быть увеличен (п. 4) по следующему алгоритму: а=improve (T) nd=T while nd<>S do pd=precede (nd) if forward (nd)= true then f(pd,nd)=f(pd,nd)+a else f(nd,pd)=f(nd,pd)—a endif nd=pd endwhile Этот метод решения задачи о потоках называется по имени его создателей алгоритмом Форда — Фулкерсона. Преобразуем этот алгоритм в программу на языке Бейсик с именем maxflow. Массив САР является входным, он представляет функцию пропускной способности, определенную для графа, и описан так: 20 DIM CAP(N,N): 'Ν —число узлов графа Входные переменные S и Τ представляют источник и сток, двумерный массив FLOW является выходным массивом и представляет максимальную функцию потока. Выходная переменная TTLFLOW содержит значение потока от узла S к узлу Τ при функции потока, заданной FLOW. Предыдущие алгоритмы могут быть легко преобразованы в программы на языке Бейсик. Нам потребуются массивы 400
ЕРАТН и ΟΡΑΤΗ, элементы которых содержат логические значения (истина и ложь), а также два массива целых чисел — PRECEDE и IMPROVE. Массив forward алгоритма может быть объединен с массивом precede в одном массиве PRECEDE. Элемент PRECEDE (ND) содержит положительное или отрицательное значение d зависимости от того, истинно или нет значение forward (nd) в алгоритме. Абсолютное значение элемента PRECEDE (ND)—это узел, предшествующий узлу ND в полупути. Аналогично можно определить, смежен ли узел J с узлом I, проверив, выполняется ли равенство CAP(I,J)=0. Ниже представлена программа, являющаяся простой реализацией алгоритма: 1000 'подпрограмма maxflow 1010 'входы: CAP, N, S, Τ 1020 'выходы: FLOW, TTLFLOW 1030 'локальные переменные: А, ЕРАТН, I, IMPROVE, J, К, ND, ЕРАТН, 'PD, PRECEDE 1040 'инициализация 1050 FOR 1=1 TON 1060 FORJ=lTON 1070 FXOW(I,J)=0 1080 NEXT J 1090 NEXT I 1100 TTLFLOW=0 1110 'попытка найти полупуть из S в Т 1120 IMPROVE (S)=0 ИЗО FORK^l TON 1140 EPATH(K)=FALSE 1150 OP ΑΤΗ (K)= FALSE 1160 IMPROVE (S) = IMPROVE (S) +CAP (S,K) 1170 NEXT К 1180 ЕРАТН (S)=TRUE 1190 OPATH(S)=TRUE 1200 IF OPATH(T)=TRUE THEN GOTO 1400: 'полупуть найден 1210 F0RND=1T0N 1220 IF EPATH(ND) «TRUE THEN GOTO 1250 1230 NEXT ND 1240 GOTO 1400 1250 EP ΑΤΗ (ND) - FALSE 1260 FOR 1=1 TO N: 'объединение двух циклов алгоритма в один 1270 IF OPATH(I) =TRUE OR FLOW(ND.I) = CAP(ND,I) THEN GOTO 1330 1280 ΟΡΑΤΗ (I) «TRUE 1290 EPATH(I)«TRUE 1300 PRECEDE (I) =ND 1310 TEMP«CAP(ND,I) -FLOW(ND.I) 1320 IF IMPROVE (ND) <TEMP THEN IMPROVE (I) « IMPROVE (ND) ELSE IMPROVE (I) «TEMP 1330 IF OPATH(I) «TRUE OR FLOW(I.ND) «0 THEN GOTO 1380 1340 ΟΡΑΤΗ (I) «TRUE 1350 EPATH(I)«TRUE 1360 PRECEDE (I) «-ND 1Э70 IF IMPROVE (ND) <FLOW(I,ND) THEN IMPROVE (I) = IMPROVE (ND) ELSE IMPROVE(I)=FLOW(I,ND) 401
.1380 NEXT.I .1390 GOTO 1200 1400 'если полупуть не был найден, то поток оптимален 1410 IF OPATH(T) -FALSE THEN RETURN 1420 A=IMPROVE(T) 1430 TTLFLOW=TTLFLOW+A 1440 ND=T 1450 IF ND=S THEN GOTO 1110: 'попытка найти другой полупуть из '5 в Τ 1460 PD=PRECEDE (ND) 1470 JF PD>0 THEN FLOW(PD,ND)=FLOW(PD,ND)+A ELSE PD=-PD: FLOW(ND,PD)= FLOW(ND,PD) -A 1480 ND = PD 1490 GOTO 1450 1500 'конец подпрограммы Для больших графов, содержащих много узлов, массивы IMPROVE и ЕРАТН могут быть слишком большими и не умещаться в памяти. Более того, просмотр всех узлов для нахождения узла, удовлетворяющего условию ЕРАТН (ND) = TRUE, может потребовать очень много времени. Можно заметить, что значение IMPROVE требуется только для тех узлов ND, для которых ЕРАТН (ND) = TRUE. Узлы графа, находящиеся в конце полупутей, могли бы храниться в списке, описанном следующим образом: 10 DIM GRAPHNODE(IOO) 20 DIM IMPROVE(100) 30 DIM NXT(100) Когда требуется узел, находящийся в конце полупути, удаляется первый элемент списка. Аналогично можно избавиться от массива PRECEDE, используя отдельный список узлов для каждого полупути. Однако выгода от этого решения с точки зрения экономии памяти проблематична, поскольку почти все узлы будут находиться на каком-либо полупути. В качестве упражнения читателю предлагается написать программу maxflow с указанными изменениями. Упражнения 1. Найдите максимальные потоки для графа, показанного на рис. 7.2.1, используя метод Форда — Фулкерсона (пропускные способности дуг приведены рядом с дугами). 2. Пусть даны граф и функция пропускной способности. Определим вырезку χ как любое множество узлов, содержащее узел S, но не содержащее узел Т. Определим пропускную способность вырезки χ как сумму пропускных способностей всех дуг, выходящих из множества х, за вычетом суммы пропускных способностей всех дуг, ведущих в множество х. (а) Покажите, что для любой функции потока f значение суммарного потока ν меньше или равно пропускной способности любой вырезки. (б) Покажите, что равенство в п. (а) достигается, когда поток максимален и вырезка имеет минимальную пропускную способность. 3. Докажите, используя утверждения упражнения 2, что алгоритм Форда — Фулкерсона дает оптимальную функцию потока. 402
4. Перепишите программу maxflow, используя, как предлагается в тексте, для хранения узлов, находящихся в конце полупути, связный список. 5. Предположим, что для каждой дуги помимо пропускной способности» задана функция стоимости cost, показывающая стоимость единицы потока» из узла а в узел Ь. Измените программу maxflow так, чтобы она находила функцию потока, максимизирующую суммарный поток от источника к стоку с наименьшей стоимостью (т. е. из двух функций потока, имеющих одинаковый суммарный поток, выбирается функция потока с меньшей стоимостью). 6. Пусть задана функция стоимости, как в упражнении 5. Напишите программу, находящую функцию потока с наибольшим отношением суммарного потока к стоимости. 7. В вероятностном направленном графе с каждой дугой связана некоторая вероятность. Сумма вероятностей всех дуг, выходящих из любого· узла, равна 1. Рассмотрим ациклический вероятностный граф, представляющий систему туннелей. Человек стоит в каком-либо узле этой системы. В каждом узле он выбирает с соответствующей вероятностью туннель, по которому будет продолжен путь. Напишите программу вычисления для каждого узла графа вероятности прохождения человека через этот узел. Что будет в случае циклического графа? 8. Напишите на языке Бейсик программу, получающую следующую входную информацию об электрической сети: а) N — число проводов в сети; б) величину тока, поступающего по первому проводу и выходящего по N-му проводу; в) сопротивление каждого провода (со 2-го до N—1-го); г) множество упорядоченных пар <I, J>, показывающих, что провод I соединен с проводом J и что ток идет через провод I к проводу J. Программа должна вычислить по законам Ома и Кирхгофа величину тока, протекающего через каждый провод (со 2-го до N—1-го). Закон Кирхгофа утверждает, что сумма величин токов, входящих в узел, равна сумме величин токов, выходящих из узла. По закону Ома, если между двумя узлами существует два пути, то сумма произведений величин токов на каждом проводе на величины сопротивлений этого провода по всем проводам первого пути равна аналогичному произведению по второму пути. 7.3. СВЯЗАННОЕ ПРЕДСТАВЛЕНИЕ ГРАФОВ Представление графа с помощью матрицы смежности зачастую неудобно, поскольку число узлов требуется знать заранее. Если граф должен создаваться или изменяться во время исполнения программы, то для каждого добавления или удаления узла надо строить новую матрицу. В языке Бейсик такая процедура непроста и очень неэффективна, особенно в тех ситуациях, когда в графе более 100 узлов. Кроме того, даже если граф содержит малое число дуг и матрица смежности (а также матрица весов для взвешенного графа) состоит в основном иа нулей, память должна быть отведена для всех возможных дуг вне зависимости от того, существуют ли они. Если граф содержит η узлов, то должна быть отведена память для Пг элементов. Как и следовало ожидать, возможное решение — использовать связанную структуру: узлы берутся (распределяются) из общего пула и возвращаются в общий пул. Аналогичные методы были использованы для представления динамических бинарных и общих деревьев. При связанном представлении дерева 403
каждый распределенный узел соответствует узлу дерева. Это возможно потому, что каждый узел дерева является сыном только одного (другого) узла дерева и, следовательно, содержится в единственном списке сыновей. Однако в графе может существовать дуга между любыми двумя узлами. Можно, конечно, поддерживать список смежности для каждого узла графа (в таком списке содержались бы все узлы, смежные с данным); в этом случае узел может находиться в нескольких различных списках смежности (в списке смежности каждого узла, с которым он смежен). Но такой способ требует, чтобы в каждом распределенном узле содержалось различное число указателей в зависимости от числа узлов, с которыми он смежен. Очевидно, такое решение непрактично, что мы видели, пытаясь представить общие деревья так, чтобы узлы содержали указатели на каждого своего сына. Альтернативный подход предполагает создание следующей структуры со многими связями. Узлы графа представляются связанным списком заголовочных узлов, каждый из которых содержит три поля: info, nxtnode и arcptr. Если ρ указывает на заголовочный узел, представляющий узел а графа, то info(p) содержит информацию, связанную с этим узлом графа. Поле nxtnode (р) содержит указатель на заголовочный узел, представляющий следующий узел графа (если такой узел есть) в списке заголовочных узлов. Каждый заголовочный узел является головой списка узлов второго типа, называемых списочными узлами или дуговыми узлами. Такой список называется списком смежности. Каждый узел списка смежности представляет дугу графа. Поле arcptr(р) указывает на список смежности, представляющий дуги, выходящие из узла а графа. Каждый узел списка смежности содержит два поля: ndptr и пехагс. Если q указывает на списочный узел, представляющий дугу <а,Ь>, то поле ndptr (q) указывает на заголовочный узел, представляющий узел b графа. Поле пехагс (q) указывает на списочный узел, представляющий следующую дугу, выходящую из узла а графа (если такая дуга есть). Каждый списочный узел содержится в единственном списке смежности, представляющем все дуги, выходящие из данного узла графа. Термин распределенные узлы используется как для заголовочных, так и для списочных узлов структуры со многими связями, представляющей граф. Такое представление показано на рис. 7.3.1. Если с каждым узлом графа связана некоторая информация, а с его дугами — нет ('поскольку граф не взвешенный), то требуются два типа распределенных узлов: один для заголовочных узлов (узлов графа) и другой для узлов списка смежности (дуг). Это показано на рис. 7.3.1, а. Каждый заголовочный узел содержит поле info и два указателя. Первый из них указывает на список смежности дуг, выходящих из узла графа, а второй — на следующий 404
arcptr info nx mode ndptr nexarc D η и ι ι Ε η и ι ι <Α,Β> <А,С> <α,ε> Рис. 7.З.1. Связанное представление графа. а — заголовочный узел, представляющий узел графа (слева) и дуговой узел (справа); б — граф; в — связанное представление графа. заголовочный узел в списке узлов графа. Каждый дуговой узел содержит два указателя: на следующий дуговой узел в списке смежности и на заголовочный узел, представляющий тот узел графа, которым заканчивается дуга. На рис. 7.3.1,6 показан граф, а на рис. 7.3.1,0 —его связанное представление. Отметим, что заголовочные узлы и дуговые узлы имеют различные форматы и должны быть представлены различными 405
множествами переменных языка Бейсик, что, в свою очередь, влечет необходимость поддержки двух списков нераспределенных элементов. Даже в случае взвешенного графа, в котором каждый дуговой узел содержит поле info для хранения значения веса дуги, наличие в заголовочных узлах не числовой информации требует для узлов двух различных форматов. Однако для простоты мы слегка изменим представление и предположим, что как заголовочные, так и дуговые узлы имеют одинаковый формат: два указателя и одно информационное поле для хранения целых значений. Узлы описаны следующим образом: 10 MAXNODES=100 20 DIM INFO(MAXNODES) 30 DIM PNT(MAXNODES) 40 DIM NXT(MAXNODES) Если Р указывает на заголовочный узел, то node(P) представляет узел А графа. В поле INFO(P) содержится информация, связанная с узлом А графа. Поле NXT(P) содержит указатель на следующий узел графа в списке узлов графа, а поле PNT(P)—указатель на первый дуговой узел, представляющий дугу, выходящую из узла А. Если Ρ указывает на дуговой узел, то node(P) представляет дугу <А,В>, поле INFO(P)—вес дуги, поле NXT(P) —указатель на следующую в списке смежности узла А дугу, выходящую из узла А, а поле PNT(P) —указатель на заголовочный узел, представляющий узел В графа. В оставшейся части раздела мы используем именно эту реализацию и предполагаем существование программ getnode и free- node. Теперь приведем реализацию простейших операций над графами, используя связанное представление. Подпрограмма joinwt получает указатели Ρ и Q на два заголовочных узла и создает дугу между ними с весом WT (если дуга уже существует, ее вес устанавливается равным WT): 1000 'подпрограмма joinwt 1010 'входы: Р, Q, WT 1020 'выходы: нет 1030 'локальные переменные: GTNODE, R, R2 1040 'поиск в списке дуг дуги, выходящей из узла node(P) и ведущей 'в узел node(Q) 1050 R2=0 1060 R = PNT(P) 1070 IF R=0 THEN GOTO 1120 1080 IF PNT(R)=Q THEN INFO(R)^WT: RETURN 1090 R2=R 1100 R=NXT(R) 1110 GOTO 1070 1120 'не существует дуги из узла node (P) в узел node(Q), надо создать 'дугу ИЗО GOSUB 6000: 'подпрограмма getnode устанавливает значение переменной GTNODE П40 R^GTNODE 406
1150 PNT(R)=Q 1160 NXT(R)=0 1170 INFO(R)=WT 1180 IF R2=0 THEN PNT(R) = R ELSENXT(R2)-R 1190 RETURN 1200 'конец подпрограммы Реализацию операции join для невзвешенного графа мы оставляем читателю в качестве упражнения. Подпрограмма remv получает указатели Ρ и Q на два заголовочных узла и удаляет дугу между ними, если она существует: 2000 'подпрограмма remv 2010 'входы: Р, Q 2020 'выходы: нет 2030 'локальные переменные: FRNODE, R, R2 2040 R2=0 2050 R=PNT(P) 2060 IF R=0 THEN RETURN: 'не существует дуги между узлами 'node(P) и node(Q) 2070 IF PNT(R) < >Q THEN R2=R: R=NXT(R): GOTO 2060 2080 'указатель PNT(R) равен Q, значит R указывает на дугу от узла 'node (P) к узлу node(Q) 2090 IF R2-0 THEN PNT(P) «NXT(R) ELSE NXT(R2)=NXT(R) 2100 FRNODE=R 2110 GOSUB 7000: 'подпрограмма freenode использует переменную 'FRNODE в качестве входа 2120 RETURN 2 ISO 'конец подпрограммы Мы оставляем читателю в качестве упражнения реализацию операции remvwt, которая присваивает переменной X значение веса дуги взвешенного графа, ведущей от узла node(P) к узлу node(Q), и затем удаляет из графа эту дугу. Подпрограмма adjacent получает указатели Ρ и Q двух заголовочных узлов и определяет, смежен ли узел node(Q) с узлом node(P): 3000 'подпрограмма adjacent ЗОЮ 'входы: Р, Q 3020 'выходы: AJACENT 3030 'локальные переменные: R 3040 R = PNT(P) 3050 IF R=0 THEN GOTO 3090 3060 IF PNT(R) =Q THEN AJACENT=TRUE: RETURN 3070 R = NXT(R) 3080 GOTO 3050 3090 AJACENT=FALSE 3100 RETURN 3110 'конец подпрограммы Полезна также подпрограмма findnode, возвращающая указатель на заголовочный узел с информационным полем X, если такой узел существует, и пустой указатель (0) в противном случае: 407
4000 'подпрограмма findnode 4010 'входы: GRAPH, X 4020 'выходы: FINDNODE 4030 'локальные переменные: РР 4040 РР=GRAPH 4050 IF PP=0 THEN FINDNODE=PP: RETURN 4060 IF INFO(PP) =X THEN FINDNODE=PP: RETURN 4070 PP=NXT(PP) 4080 GOTO 4050 4090 'конец подпрограммы Подпрограмма addnode добавляет к графу узел с информационным полем X и возвращает указатель на этот узел: 5000 'подпрограмма addnode 5010 'входы: CRAPH, X 5020 'выходы: ADDNODE, GRAPH 5030 'локальные переменные: GTNODE, РР 5040 GOSUB 6000: 'подпрограмма getnode устанавливает значение переменной GTNODE 5050 РР = GTNODE 5060 INFO(PP)=X 5070 ΡΝΤ(ΡΡ)=0 5080 NXT(PP)=GRAPH 5090 GRAPH=PP 5100 ADDNODE-PP 5110 RETURN 5120 'конец подпрограммы Читатель должен ясно представлять другое важное различие между представлениями графа в виде матрицы смежности и связанным представлением. При представлении с помощью матрицы неявно включена возможность прохождения по строке или столбцу матрицы. Просмотр строки эквивалентен нахождению всех дуг, выходящих из данного узла, что может быть эффективно реализовано при связанном представлении путем прохождения списка всех дуговых узлов, начинающегося с данного заголовочного узла. Однако просмотр столбца матрицы смежности эквивалентен нахождению всех дуг, оканчивающихся в данном узле, а для этой процедуры нет соответствующего метода для связанного представления. Конечно, можно было бы включить в связанное представление два списка, начинающихся в каждом заголовочном узле: один — для дуг, выходящих из узла, и другой — для дуг, оканчивающихся в узле. Но это потребовало бы выделения для каждой дуги двух узлов, что усложнило бы добавление и удаление дуг. Можно было бы также поместить каждый дуговой узел в два списка. При этом подходе каждый дуговой узел содержал бы четыре указателя: первый— на следующую дугу, выходящую из этого же узла, второй — на следующую дугу, оканчивающуюся в этом узле, третий — на заголовочный узел, в котором оканчивается дуга, и четвертый — на заголовочный узел, из которого выходит^ дуга. Заголовочный узел содержал бы три указателя: первый — на следующий заголовочный узел, второй — на список дуг, выходя- 408
щих из узла, и третий — на список дуг, оканчивающихся в узле. Конечно, программист должен выбрать из этих представлений наиболее подходящее, исходя из требований конкретной задачи с учетом эффективности использования памяти и минимизации времени исполнения. Мы предлагаем читателю написать программу remvnode, которая получает два указателя — GRAPH, и Ρ — и удаляет заголовочный узел, указываемый переменной Ρ из графа, указываемого переменной GRAPH, используя различные рассмотренные нами представления графа. Конечно, при удалении узла из графа должны быть удалены и все дуги, выходящие из этого узла и оканчивающиеся в нем. При связанном представлении не существует простого способа удаления узла из графа, поскольку дуги, оканчивающиеся в узле, непосредственно не доступны. Применение для задач планирования Рассмотрим приложение, использующее связанное представление графов. Предположим, что шеф-повар получил заказ приготовить яичницу из одного яйца. Вся процедура ее приготовления может быть разбита на ряд отдельных подзадач: Вйять яйцо Разбить яйцо Взять жир Положить жир на Растопить жир Вылить яйцо на сковороду сковороду Ждать, пока яични- Снять яичницу ца не изжарится Некоторые из этих подзадач должны предшествовать другим (например, задача «взять яйцо» должна предшествовать задаче «разбить яйцо»). Ряд подзадач может выполняться параллельно (например, задачи «взять яйцо» и «растопить жир»). Шеф-повар хотел бы выполнить заказ как можно быстрее, при этом предполагается, что число его помощников не ограничено. Необходимо распределить работу среди помощников так, чтобы заказ был выполнен за минимально возможное время. Хотя этот пример может показаться легкомысленным, подобная задача возникает во многих ситуациях, связанных с планированием реальных действий. В больших вычислительных системах осуществляется планирование заданий с целью обеспечения минимального времени нахождения задания в системе, менеджер на заводе может планировать организацию работы конвейера, минимизирующую время производства продукции, и т. п. Все проблемы подобного рода тесно связаны между собой и могут быть решены с использованием графов. Давайте представим исходную задачу в виде графа. Каждый узел графа представляет подзадачу, а каждая дуга <х, у> представляет требование, что задача у не может выполняться до тех пор, пока не завершено выполнение задачи х. Граф G показан на рис. 7.3.2. 409
Рис. 7.3.2, Рассмотрим транзитивное замыкание графа Q — такай граф Т, что пара <х,у> является дугой графа Τ в том и только в том случае, если существует путь в графе G из узла х в узел у. Транзитивное замыкание показано на рис. 7.3.3. Рис. 7.3.3. Граф Т. В графе Τ существует дуга из узла χ в узел у тогда и только тогда, когда подзадача χ должна быть выполнена до подзадачи у. Отметим, что ни граф G, ни граф Τ не могут содержать циклов, поскольку, если бы существовал цикл из узла χ к само* му себе, подзадача χ не могла бы выполняться до тех пор, пока не завершена она сама. Ясно, что, исходя из условий задачи,, это невозможно. Поскольку граф G не содержит циклов, в нем должен существовать по крайней мере один узел, не имеющий предшественников. Чтобы показать это, предположим противное: у каждого узла графа есть предшественник. В частности, выберем узел ζ, имеющий предшественником узел у. Узел у не может совпадать с узлом ζ, поскольку это означало бы, что есть цикл от узла ζ к себе. Поскольку каждый узел имеет предшественника, то предшественник узла у — это узел х, не совпадающий ни 410
с узлом у, ни с узлом ζ. Продолжая рассуждения дальше, получим последовательность различных узлов ζ, у, х, w, ν, и, ... Если бы какие-либо узлы в последовательности совпадали, то это означало бы, что существует цикл из этого узла к себе. Однако граф содержит конечное число узлов; следовательно, какие-то два узла должны совпадать. Полученное противоречие доказывает, что должен существовать по крайней мере один узел, не имеющий предшественника. В графах на рис. 7.3.2 и 7.3.3 узлы А и F не имеют предшественников, и, следовательно, подзадачи, которые они представляют, могут выполняться сразу же и параллельно без ожидания завершения других подзадач. Все остальные подзадачи должны ждать завершения по крайней мере одной из этих подзадач. Как только эти первые две подзадачи завершены, соответствующие узлы и инцидентные дуги могут быть удалены из графа. Отметим, что получающийся в результате граф не содержит циклов, поскольку узлы и дуги удалялись из графа без циклов. Стало быть, новый граф также должен содержать по крайней мере один узел; не имеющий предшественников. В нашем примере существует два таких узла — В и Н. Значит, подзадачи В и Η могут выполняться параллельно во второй период времени. Продолжив построение дальше, мы найдем, что минимальное время, за которое может быть поджарена яичница, — шесть временных периодов (предполагая, что каждая подзадача требует ровно один период времени), а максимальное требующееся число помощников — два: Помощник 1 Помощник 2 1 Взять яйцо Взять жир 2 Разбить яйцо Положить жир на сковороду 3 Растопить жир 4 Вылить яйцо на сковороду 5 Ждать, пока яичница не изжарится 6 Снять яичницу Процесс может быть изложен в следующем виде: Шаг 1. Получить описание предшествования и построить граф. Шаг 2. Использовать граф для определения подзадач, которые могут быть выполнены параллельно. Давайте точнее определим эти два шага. При уточнении первого шага надо принять два ответственных решения: первое — 411
решить вопрос о формате ввода, второе — определить представление графа. Ясно, что на вводе должна содержаться информация о том, какие подзадачи предшествуют и каким подзадачам. Наиболее удобный способ ее представления — упорядоченные пары подзадач. Каждая строка ввода содержит имена двух подзадач; первая в строке предшествует второй. Конечно, данные должны быть допустимыми: никакая подзадача не может предшествовать самой себе (в графе не разрешены циклы). Предполагается, что верны только те отношения предшествования, которые заданы на вводе и следуют из транзитивного замыкания графа. Подзадача может быть представлена символьной строкой, такой как «взять яйцо», или номером. Мы выбрали представление подзадач в виде символьной строки, как наиболее наглядное. Если в момент начала исполнения число подзадач известно, то для представления графа может использоваться матрица смежности с начальными значениями всех элементов — «ложь». По мере чтения входных строк — пар предшествования — в соответствующие элементы матрицы записывается значение «истина». Однако давайте предположим, что информация об этом недоступна в начале исполнения. Нам требуется предусмотреть возможность использования программы для графа с произвольным числом узлов; следовательно, мы используем связанное представление графа. Какую информацию надо хранить в узлах графа? Для вывода результата работы программы необходимы имена подзадач, представленных узлами графа. Имена хранятся в виде строк символов. Нужна ли дополнительная информация? Ответ зависит от того, как будет использоваться граф, а это прояснится только после уточнения шага 2. Шаг 2 может быть представлен следующим алгоритмом: while граф не пуст do определить узлы, не имеющие предшественников распечатать эту группу узлов с указанием, что эти подзадачи могут быть выполнены параллельно в следующий период времени удалить из графа данные узлы и инцидентные дуги endwhile Как можно определить, что у узла нет предшественников? Один из способов — хранить в каждом узле графа поле count, содержащее число узлов, предшествующих данному. Отметим, что нас не интересует, какие узлы предшествуют данному: нам нужно только их число. Если поле count узла равно 0, то этот узел не имеет предшественников и может быть помещен в выходной список. При этом просматривается список дуг, выходящих из него, и уменьшается на единицу поле count всех смежных узлов. В каждый моделируемый период времени просматривается весь граф и находятся узлы, у которых равно 0 поле count. Стало быть, в уточненном виде шаг 2 выглядит так: 412
period =1 while graph< >null do 'инициализация выходного списка пустым списком output = null 'найти в графе узлы для выходного списка ρ=graph while p< >null do if count (ρ) =0 then удалить из графа узел node (p) и поместить его в выходной список: endif установить в ρ указатель на следующий узел графа endwhile if output=null then ошибка — каждый узел графа имеет предшественника, следовательно, в графе есть цикл* stop endif print period 'пройти выходной список ρ = output while p< >null do print info(p) пройти список дуг, выходящих из узла node(p), уменьшить поле count каждого конечного узла на 1, удалить дугу q = следующий узел в выходном списке freenode(p) p = q endwhile period=period+l endwhile Кстати отметим, что удаление узлов в данном случае выполняется эффективно, несмотря на связанное представление, потому, что удаляются только те узлы, которые не имеют предшественников. Программа на языке Бейсик Теперь мы можем описать структуру узлов графа. Заголовочные узлы, представляющие узлы графа, содержат следующие поля: SUBTASK — имя подзадачи, представленной узлом COUNT — число предшественников данного узла графа ARCPTR — указатель на список дуг, выходящих из узла NXTNODE — указатель следующего узла графа или выходного списка Каждый узел списка, представляющего дуги, содержит два поля с указателями: NDPTR — указатель на конечный узел дуги NEXARC — указатель на следующую дугу в списке смежности Стало быть, требуются узлы двух типов: один — для представления узлов графа, второй — для дуг. Используя представление списков в виде массивов, опишем требуемые элементы: 413
30 DEFSTR S 40 NMAX=100: 'максимальное число узлов 50 DIM SUBTASK(NMAX) 60 DIM COUNT(NMAX) 70 DIM ARCPTR (NMAX) 80 DIM NXTNODE(NMAX) 90 AMAX=200: 'максимальное число дуг 100 DIM NDPTR(AMAX) 110 DIM NEXARC(AMAX) Конечно, должны быть два списка доступных элементов •(с указателями NAVAIL и AAVAIL) и два набора подпрограмм «(getnode, freenode и getarc, fneearc) для выделения и возвращения узлов из списков. Мы предполагаем также, что существует функция find для поиска в списке узлов графа, указываемом переменной GRAPH, узла с полем SUBTASK, равным значению переменной STASK. Если такого узла не существует, то ^функция find получает новый узел графа ND и устанавливает •в поле SUBTASK (ND) значение переменной STASK, а в поля COUNT (ND) и ARCPTR (ND) устанавливает 0. Затем узел ND добавляется к списку узлов графа. В любом случае функция find возвращает указатель на узел графа, содержащий STASK. Используется также описаняая ранее программа join (модифицированная под выбранное представление узлов). Теперь мы можем написать программу планирования действий на языке Бейсик: 10 'программа schedule 20 'для микроЭВМ TRS-80 требуется оператор CLEAR 100 30 DEFSTR S 40 NMAX=100 50 DIM SUBTASK (NMAX) 60 DIM COUNT (NMAX) 70 DIM ARCPTR (NMAX) 80 DIM NXTNODE (NMAX) 90 AMAX-200 100 DIM NDPTR(AMAX) 110 DIM NEXARC(AMAX) 120 'инициализация списков свободных элементов 130 NAVAIL=1 140 AAVAIL-1 150 FOR 1=1 TO NMAX-1 160 NXTNODE (I) -.1+1 170 NEXT I 180 NXTNODE (NMAX) =0 190 FOR 1 = 1 TO AMAX-1 200 NEXARC(I)-!+l 210 NEXT I 220 NEXARC(AMAX)=0 230 GRAPH=0 240 'построение графа 250 'читать строку с описанием пары подзадач и создать дугу 260 READ S1TASK.S2TASK 270 IF SlTASK=«hINISH» THEN GOTO 380 2β0 STASK^SITASK 414
290 GOSUB 1000: 'подпрограмма find устанавливает в FIND указатель ка узел графа с полем SUBTASK, равным STASK 300 P=FIND 310 STASK-S2TASK 320 GOSUB 1000: 'подпрограмма find 330 Q=FIND 340 GOSUB 1500: 'подпрограмма join получает Р и Q 350 'увеличить счетчик входящих дуг (Предшественников) 360 COUNT (Q) - COUNT (Q) +1 370 GOTO 250 380 'граф создан 390 PERIODS 400 IF GRAPH-0 THEN GOTO 780 410 OTPT=0: ΌΤΡΤ — указатель на выходной список 420 Р=GRAPH 430 Q=0 440 'при прохождении графа Q «отстает от» Ρ на один узел 450 IF P=0 THEN GOTO 550 460 R=NXTNODE (P) 470 IF COUNT(P) < >0 THEN Q=P: GOTO 530 480 'удалить из графа узел, указываемый Ρ 490 IF Q=0 THEN GRAPH^R ELSE NXTNODE (Q)=R 500 'поместить в выходной еписок узел; указываемый Ρ 510 NXTNODE (Р) = ОТРТ 520 ОТРТ^Р 530 P=R 540 GOTO 450 550 IF ОТРТ-0 THEN PRINT «ОШИБКА ЕГО* ВХОДНЫХ ДАННЫХ —ГРАФ» СОДЕРЖИТ ЦИКЛ»: STOP 560 PRINT «ПЕРИОД», PERIOD 57Q 'пройти выходной список 580 Ρ «ОТРТ 590 IF P=0 THEN GOTO 760 600 PRINT SUBTASK(P) 610 'пройти дуги, выходящие из узла, указываемого Ρ 620 Q=ARCPTR(P) 630 IF Q=0 THEN GOTO 710 640 T=NDPTR(Q) 650 COUNT (T) = COUNT (T) -1 660 R-NEXARC(Q) 670 FRARC-Q 680 GOSUB 20O0: 'подпрограмма freearc получает переменную 'FRARC 690 Q=R 700 GOTO 630 ' 710 R=NXTNODE (P) 720 FRNODE^P 730 GOSUB 2500: 'подпрограмма freenode получает переменную 'FRNODE 740 P-R 750 GOTO 590 760 PERIOD=PERIOD+I 770 GOTO 400 780 END 900 DATA . . . 4*5
1000 'подпрограмма find 1500 'подпрограмма join 2000 'подпрограмма freearc 2500 'подпрограмма freenode Улучшение программы Хотя вышеприведенная программа правильна, она чрезвычайно неэффективна. Проверьте, сможете ли вы сами, не читая дальше, определить причину этого. Рассмотрим реальную ситуацию: могут быть сотни подзадач, но в то же время только три или четыре из них можно выполнять в один период времени. Значит, для завершения всей программы может потребоваться 100 или большее число временных периодов. Отсюда следует, что цикл из операторов с номерами 400—770 будет повторяться много раз. При каждом повторении для нахождения нескольких узлов, у которых поле COUNT равно 0, надо просмотреть 8 среднем список из 50 узлов, а это чрезвычайно неэффективно. (Средняя оценка в 50 узлов сделана в предположении, что в графе изначально 100 узлов. В качестве упражнения обоснуйте эту оценку.) При моделировании каждого временного периода можно определить узлы, представляющие те подзадачи, которые могут быть выполнены в следующий период времени. Это те узлы, поле COUNT которых после уменьшения на 1 становится равным 0. Почему бы в этот момент не удалить узел из списка узлов графа и не поместить его в новый список тех узлов, которые станут выходными? В следующий период времени для печати результата можно просмотреть только этот список. Читателю предлагается разобраться, почему мы не используем этот, казалось бы, простой способ. Рассмотрим метод, который мог бы быть использован для удаления узла из списка узлов графа. Поскольку этот список линейно связан, мы не можем удалить из него узел, если не знаем предшественника узла. Однако когда мы определяем по оканчивающейся в узле дуге, что этот узел имеет нулевое поле COUNT, то мы знаем лишь указатель на сам узел, а не на его предшественника. Для того чтобы дойти до предшественника, мы должны просмотреть весь список сначала, что и было источником неэффективности первого варианта программы. Можно предложить несколько решений данной проблемы. Один из способов рассматривается в упражнении 9.4.2, после того как мы определим необходимые понятия. Другое решение, которое вдумчивый читатель, наверное, уже нашел, — представить граф двусвязным, а не односвязным списком так, чтобы предшественник узла был доступен из самого узла без просмотра всего списка. 416
Даже если узлы графа объединены в двусвязный список, выходной список может оставаться односвязным. После выполнения шага 1 — построения графа — один раз проходится двусвязный список узлов графа для ^инициализации выходного списка, в который включаются узлы, не имеющие предшественников. В дальнейшем по мере отработки каждого временного периода проходится выходной список, созданный в предыдущий период времени, и выводятся подзадачи, представленные узлами этого списка. Затем уменьшается поле COUNT у каждого узла, смежного с узлом выходного списка, и, если значение этого поля становится равным нулю, данный узел помещается в выходной список следующего выходного периода. Стало быть, нам требуется вести два выходных списка: для текущего периода (список создан в предыдущий период и проходится в настоящий период времени) и для следующего периода. Уточнение шага 2 при этой реализации может быть представлено следующим алгоритмом: 'пройти список узлов графа и поместить все узлы с полем count, равным О, 'в исходный выходной список ρ = graph output=null while p< >null do q=nextnode(p) if count(p)=0 then удалить узел node (ρ) из графа поместить узел node(p) в выходной список endif p=q endwhile 'моделировать временные периоды period =1 while output< >null do print period 'инициализировать выходной список следующего периода nextout^null 'пройти выходной список ρ = output while p< >null do print info (ρ) 'пройти список дуг, выходящих из узла node(p) r=arcptr(p) while r< >null do 'уменьшить поле count у конечных узлов дуг t=nodeptr(r) count(t) =count(t) — 1 if count (t)=0 then удалить узел node(t) из графа 'добавить узел mode(t) к выходному списку следующего 'периода nextnode(t) =nextout nextout=t endif rr=nextarc(r) freearc(r) r=rr 417
endwhile q=nextnode(p) freenode(p) P = q endwhile output=nextout period=period+l endwhile if graph< >null then ошибка — граф содержит цикл stop endif Для создания двусвязного списка узлов необходимо включить дополнительное поле PREVNODE, в котором будет храниться указатель предыдущего узла графа в списке. Следовательно, узлы графа можно описать так: 40 NMAX= 100 50 DIM SUBTASK(NMAX) 60 DIM COUNT(NMAX) 70 DIM ARCPTR (NMAX) 80 DIM PREVNODE (NMAX) 90 DIM NXTNODE(NMAX) Список свободных узлов и два выходных списка останутся односвязными, поэтому для узлов этих списков не требуется поле PREVNODE. Подпрограмма find должна быть изменена для работы с двусвязным списком, а подпрограмма join остается прежней. Ниже приведен улучшенный вариант программы на языке Бейсик: 10 'программа schedule (улучшенный вариант) 20 'для микроЭВМ TRS-80 требуется оператор CLEAR 100 30 DEFSTR S 40 NMAX=100: 'максимальное число узлов 50 DIM SUBTASK(NMAX) 60 DIM COUNT (NMAX) 70 DIM ARCPTR (NMAX) 80 DIM PREVNODE (NMAX) 90 DIM NXTNODE (NMAX) 100 AMAX=200: 'максимальное число дуг 110 DIMNDPTR(AMAX) 120 DIMNEXARC(AMAX) 130 'инициализация списков свободных элементов 140 NAVAIL=1 150 FOR 1=1 ТО NMAX-1 160 NXTNODE (I) «1+1 170 NEXT I 180 NXTNODE (NMAX) =0 190 AAVAIL=1 200 FOR I -1 TO AMAX-1 210 NEXARC(I)=I+1 220 NEXT I 230 NEXARC(AMAX)=0 418
240 'построение графа 250 GRAPH=0 260 READ S1TASK, S2TASK 270 IF S1TASK=«FINISH» THEN GOTO 380 280 'подпрограмма find настраивает все нужные прямые и обратные ссылки и поле count во вновь включаемом узле с полем 'SUBTASK, равным STASK 290 STASK = S1TASK 300 GOSUB 1000: 'подпрограмма find устанавливает в FIND указатель на узел графа с полем SUBTASK, равным 'STASK 310 P=FIND 320 STASK=S2TASK 330 GOSUB 1000: 'подпрограмма find 340 Q=FIND 350 GOSUB 1500: 'подпрограмма join получает Ρ и Q 360 COUNT (Q) - COUNT (Q) +1 370 GOTO 260 380 'пройти список узлов графа и поместить все узлы с полем count, 'равным 0, в выходной список 390 ОТРТ=0 400 Ρ=GRAPH 410 IF P = 0 THEN GOTO 540 420 Q=NXTNODE(P) 430 IF COUNT(P) < >0 THEN GOTO 510 449 'удалить узел графа, указываемый Р 450 R=PREVNODE(P) 460 IF Q< >0 THEN PREVNODE(Q) = R 470 IF R=0 THEN GRAPH=Q ELSENXTNODE(R)=Q 48(0 'поместить в выходной список узел node(P) 490 NXTNODE (Ρ) = ΟΤΡΤ 500 ΟΤΡΤ=Ρ 510 'перейти к следующему узлу 520 P=Q 530 GOTO 410 540 'моделировать временные периоды 550 PERIOD=l 560 IF OTPT=0 THEN GOTO 950 570 PRINT «ПЕРИОД», PERIOD 580 'инициализировать выходной список следующего периода и прой- 'ти выходной список текущего периода 590 NEXOT=0 600 Ρ=ΟΤΡΤ 610 IF P=0 THEN GOTO 910 620 PRINT SUBTASK(P) 630 'пройти дуги, выходящие из узла node (P) 640 R=ARCPTR(P) 650 IF R=0 GOTO 860 660 'уменьшить поле count конечного узла дуги 670 T=NDPTR(R) 680 COUNT (T) = COUNT (Τ) -1 690 IF COUNT (Τ) < >0 THEN GOTO 790 700 'как только подзадача, представленная узлом node (P), выполнена, может выполняться подзадача, представленная 'узлом node(T) 710 'удалить узел, указываемый Τ 4П
720 V=NXTNODE(T) 730 W=PREVNODE(T) 740 IF V< >0 THEN PREVNODE(V) =W 750 IF W< >0 THEN NXTNODE(W) =V ELSE GRAPH=V 760 'внести узел node (Τ) в новый выходной список 770 NXTNODE (Τ) = ΝΕΧΟΤ 780 ΝΕΧΟΤ=Τ 790 'освободить дуговой узел, указываемый R, и продолжить прохождение списка дуговых узлов 800 RR=NEXARC(R) 810 FRARC=R 820 GOSUB 2000: 'подпрограмма freearc получает переменную FRARC 830. R = RR 840 GOTO 650 850 'продолжить прохождение выходного списка 860 Q=NXTNODE(P) 870 FRNODE=P 880 GOSUB 2500: 'подпрограмма freenode получает переменную» FRNODE 890 P=Q 900 GOTO 610 910 'установить выходной список следующего периода 920 OTPT=NEXOT 930 PERIOD=PERIOD+l 940 GOTO 560 950 IF GRAPHO0 THEN PRINT «ОШИБКА —ГРАФ СОДЕРЖИТ цикл> 960 END 970 DATA . . . 1000 'подпрограмма find • · · 1500 'подпрограмма join 2000 'подпрограмма freearc 2500 'подпрограмма freenode Упражнения 1. Опишите такую реализацию графа с помощью связанных списков, в которой каждый заголовочный узел является головой двух списков. Первый список содержит дуги, выходящие из узла графа, а второй — дуги, оканчивающиеся в узле. 2. Опишите реализацию графа с кольцевыми списками заголовочных узлов и дуговых узлов. 3. Опишите реализацию графа с использованием списка списков смежности. Граф из η узлов состоит из π заголовочных узлов, каждый из которых содержит целое число от 1 до π и указатель на список «списковых» узлов. Каждый списковый узел содержит номер узла, смежного с узлом графа, представленным заголовочным узлом. 4. Может существовать несколько способов выполнения подзадач за минимальное число периодов времени. Например, подзадачи, показанные на рцс. 7.3.2, могут быть завершены за шесть временных периодов одним из трех способов: 420
Период Метод 1 Метод 2 Метод 3 A, F В, Η I С D Ε F А, Н В, I С D Ε A, F Η В, I С D Ε Напишите программу, генерирующую все возможные способы выполнения подзадач за минимальное число периодов времени. 5. Рассмотрим граф на рис. 7.3.4. Программа schedule печатает следующую таблицу: Время 1 А, В С 2 D, Ε 3 F 4 G Рис. 7.3.4. Такое решение требует трех помощников (для первого периода времени). Можете ли вы найти метод организации подзадач, требующий в любой период времени лишь двух помощников и выполняющий всю последовательность подзадач за те же четыре периода времени? Напишите программу, печатающую способ выполнения подзадач за минимальное время с минимальным числом помощников. 6. Если существует только один исполнитель, то для завершения всей задачи, состоящей из к подзадач, требуется к периодов времени. Напишите программу, печатающую порядок выполнения подзадач. Отметим, что эта программа проще, чем программа schedule, поскольку в ней не требуется выходного списка: как только поле COUNT подзадачи становится равным О, можно отпечатать ее название. Процесс преобразования множества пар предшествования в единый линейный список, в котором никакой элемент не встречается раньше предшествующих, называется топологической сортировкой. 7. Сеть ПЕРТ — это взвешенный ациклический направленный граф, в котором каждая дуга представляет действие, а вес дуги —время, требующееся для его исполнения. Если в сети есть дуги <А, В> и <В, О, то действие, представляемое дугой <А, В>, должно быть завершено до начала выполнения действия, представленного дугой <В, О. Каждый узел сети χ представляет время, к которому могут быть завершены все действия, задаваемые дугами, оканчивающимися в узле х. (а) Напишите на языке Бейсик программу, получающую представление такой сети и присваивающую каждому узлу χ значение самого раннего момента времени, к которому могут быть завершены все действия. Назовем эту величину et(x). [Указание. Присвойте величину 0 всем узлам, не имеющим предшественников. Если всем предшественникам узла χ было присвоено время, то значение et(x) равно максимальной (по всем предшественникам) сумме времени, присвоенного предшественнику, и веса дуги от этого предшественника до узла х.]. 421
(б) В условиях п. (а) напишите программу на языке Бейсик для вычисления и присвоения каждому узлу χ значения самого позднего момента времени, к которому могут быть завершены все действия, оканчивающиеся в узле х, без ожидания завершения всех остальных действий. Назовем эту величину It (χ). [Указание. Присвойте значение et(x) всем узлам х, не имеющим последователей. Если всем последователям узла χ было назначено время, то величина It (x) равна минимальной (по всём последователям) разности между временем, присвоенным последователю, и весом дуги от узла χ до него.] (в) Докажите, что в графе существует по крайней мере один путь из узла, не имеющего предшественников, в узел, не имеющий последователей, такой, что et(x)=lt(x) для каждого узла χ пути. Такой путь называется критическим. (г) Объясните важность критического пути, показав, что сокращение времени выполнения действия по каждому критическому пути ведет к уменьшению величины самого раннего момента времени, к которому может быть выполнена вся задача. (д) Напишите на языке Бейсик программу нахождения всех критических путей в сети ПЕРТ. (е) Найдите критические пути в сетях на рис. 7.3.5. 8. Напишите на языке Бейсик программу, которая получает приведенное выше представление сети ПЕРТ и вычисляет величину самого раннего момента времени, к которому может быть завершена вся задача, если параллельно может выполняться любое необходимое число действий. Программа должна печатать начальное и конечное время выполнения каждого действия. Напишите на языке Бейсик еще одну программу, которая планирует действия таким образом, чтобы вся задача была завершена к самому раннему возможному моменту времени при условии, что параллельно могут выполняться самое большее m действий. сн-о—о о—о—о 6 о^о—о сн-сн-о Рис. 7.3.5. Некоторые сети ПЕРТ.
Глава 8 Сортировка Сортировка и поиск являются наиболее общими составными частями систем программирования. В первом разделе этой главы мы обсудим некоторые общие положения, касающиеся сортировки. В остальной части этой главы мы рассмотрим некоторые из наиболее общепринятых методов сортировки, а также преимущества и недостатки одного метода перед другим. В гл. 9 мы обсудим поиск и некоторые его применения. 8.1. ОБЩИЕ ПОЛОЖЕНИЯ Концепция упорядоченного множества элементов является одной из тех концепций, которые имеют существенное влияние на нашу повседневную жизнь. Рассмотрим, например, процесс нахождения некоторого номера телефона в телефонном справочнике. Этот процесс, который называется поиском, упрощается значительно из-за того факта, что фамилии в справочнике приведены в алфавитном порядке. Представьте себе те трудности, которые мы имели бы при попытке отыскать номер телефона, если бы фамилии были перечислены в том порядке, в котором телефонная станция удовлетворяла заявки на установку телефона. В этом случае можно было бы считать, что фамилии были введены в произвольном порядке. Поскольку элементы отсортированы в алфавитном порядке, а не в хронологическом, то процесс поиска упрощается. Или рассмотрим случай поиска книги в библиотеке. Поскольку книги на полках располагаются в некотором определенном порядке (по системе библиотеки конгресса, десятичной системы Дивея или аналогичной системе), то каждая книга занимает конкретную позицию относительно других книг, и она может быть извлечена за некоторое приемлемое время (если она там находится). Или рассмотрим некоторое множество чисел, отсортированных последовательно в памяти ЭВМ. Как мы увидим в гл. 9, обычно проще найти некоторый конкретный элемент этого множества, если числа хранятся в отсортированном виде. В общем случае некоторое множество элементов хранится в отсортированном виде или для того, чтобы организовать вывод информации (чтобы упростить ручное 423
извлечение информации, как это имеет место в телефонной книге или на полках в библиотеке), или для того, чтобы сделать машинный доступ к данным более эффективным. Введем теперь некоторую основную терминологию. Будем называть файлом размером η некоторую последовательность из η элементов г(1), г (2), ..., г(п). Каждый элемент в этом файле называется записью. (Использование в этом контексте термина «файл» отличается от стандартного использования этого термина в языке Бейсик.) С каждой записью r(i) связывается некоторый ключ k(i). Ключом обычно является некоторое поле внутри записи (правда, это бывает не всегда). Говорят, что файл отсортирован по ключу, если для i<j следует, что k(i) предшествует k(j) при некоторой упорядоченной последовательности по ключам. Для примера с телефонным справочником файл состоит из всех строчек этого справочника. Каждая строчка является записью. Ключом, по которому отсортирован этот файл, является поле фамилии в записи. Каждая запись содержит также поля для адреса и номера телефона. Сортировка может быть классифицирована как внутренняя, если записи, которые сортируются, находятся в оперативной памяти, или как внешняя, если некоторые из записей, которые сортируются, находятся во вспомогательной памяти. Мы ограничим наше рассмотрение внутренними сортировками. Вполне возможно, что две записи в некотором файле имеют одинаковый ключ. Метод сортировки называется устойчивым, если для всех записей i и j таких, что k(i)=k(j), выполняется условие, что в отсортированном файле r(i) предшествует r(j), если r(i) предшествует г (j) в первоначальном файле. Сортировка выполняется или над самими записями, или над указателями некоторой вспомогательной таблицы. Например, рассмотрим рис. 8.1.1, а, на котором представлен некоторый файл с пятью записями. Если этот файл отсортировать в возрастающем порядке по указанному числовому ключу, то результирующий файл будет таким, как показано на рис. 8.1.1,6. В этом случае были отсортированы сами записи. Предположим, однако, что объем данных, помещенных в каждую из записей файла, отображенного на рис. 8.1.1, а, такой большой, что перемещение самих записей является нецелесообразным из-за больших накладных расходов. В этом случае может быть использована вспомогательная таблица указателей, так что вместо перемещения самих данных перемещаются эти указатели, как это показано на рис. 8.1.2. (Это называется сортировкой таблицы адресов.) Таблица в центре рисунка является файлом, а таблица слева — начальной таблицей указателей. Элемент в позиции j таблицы указателей указывает на запись j. Во время процесса сортировки элементы таблицы указателей перестраиваются таким образом, что окончательная таблица выглядит так, как показано справа на этом рисунке. Первоначаль- 424
Ключ Другие поля вались 1 Запись Ζ Запись 3 Запись 4 Запись 5 4 г 1 5 3 DDD ВВВ AAA ЕЕЕ ССС I Ζ 3 4 5 AAA ВВВ ССС DDD ЕЕЕ Файл а Файл 6 Рис. 8.1.1. Сортировка самих записей. а — первоначальный файл; б — отсортированный файл. Первоначальная таблица указателей Файл Отсортированная таблица указателей Запись 7 Запись Ζ Запись 3 Запись 4 Запись б 4 г 1 5 3 DDD ВВВ AAA ЕЕЕ ССС Рис. 8.1.2. Сортировка, использующая вспомогательную таблицу указателей. но первый указатель указывает на первую запись файла; После завершения сортировки первый указатель указывает на четвертую запись в файле. Отметим, что ни одна из записей первоначального файла не перемещается. В большинстве программ этой главы мы будем реализовывать методы сортировки самих записей. Расширение этих методов для сортировки таблицы адресов является очевидным и предоставляется читателю в качестве упражнения. (В действительности в примерах, представленных в этой главе, мы ради простоты сортируем только ключи. Читателю предлагается самому модифицировать эти программы так, чтобы сортировать полные записи.) Из-за взаимосвязи между сортировкой и поиском первым вопросом, который стоит перед прикладной программой, является вопрос о необходимости сортировки некоторого файла. Иногда при непосредственном поиске конкретного элемента в некото- 425
ром множестве элементов затрачивается меньше работы, чем сперва при сортировке всего данного множества и затем при извлечении необходимого элемента. С другой стороны, если в целях извлечения конкретных элементов требуется частое использование данного файла, то сортировка этого файла может привести к большей эффективности. Это происходит из-за того, что накладные расходы на следующие один за другим поиски могут существенно превысить накладные расходы на выполнение один раз сортировки данного файла и последующие извлечения элементов из отсортированного файла. Таким образом, нельзя сказать, что более эффективно: выполнять сортировку или не выполнять ее. Программист должен принять соответствующее решение на основе анализа конкретных обстоятельств. Когда принимается решение выполнить сортировку, должны быть приняты другие решения о том, что надо сортировать и какие использовать для этого методы. Не существует какого-либо одного метода сортировки, который бы во всем превосходил все другие методы. Программист должен тщательно изучить свою проблему и понять, какие он хочет получить результаты, прежде чем принимать решения по этим важным вопросам. Вопросы эффективности Как мы увидим в этой главе, имеется большое число методов, которые можно, использовать для сортировки файла. Программист должен знать ряд взаимосвязанных и часто конфликтующих аспектов эффективности для того, чтобы сделать разумный выбор того метода сортировки, который больше всего подходит к его конкретной задаче. Тремя наиболее важными из этих аспектов являются следующие: продолжительность времени, которое может запросить программист на составление некоторой конкретной программы сортировки, объем машинного времени, необходимый для работы этой программы, и объем пространства, необходимый для этой программы. Если файл является небольшим, то усовершенствованные методы сортировки, разработанные для того, чтобы минимизировать требования на пространство и время, обычно ведут себя хуже или незначительно лучше для достижения эффективности, чем более простые и в общем случае менее эффективные методы. Аналогичным образом, если некоторая конкретная программа сортировки должна проработать только один раз и для ее работы имеется достаточно машинного времени и пространства, было бы нелепо для программиста затратить несколько дней на исследование наилучших методов для получения последнего «грамма» эффективности. В таких случаях время, которое должен затратить программист, является более приоритетным фактором при определении нужного метода сортировки. Однако здесь должно быть сделано серьезное предупреждение. Времен- 426
ные затраты на программирование никогда не являются оправданием использования некорректной программы. Сортировка, которая должна проработать только один раз, может позволить себе роскошь использования некоторого неэффективного метода, но она не может себе позволить использовать неверный метод. Прикладная программа может предполагать, что используемые ею данные являются отсортированными, причем это предположение об упорядоченности данных может быть критичным для ее работы. Программист должен быть, однако, в состоянии распознать тот факт, что некоторая конкретная сортировка является неэффективной, и должен уметь оправдать ее использование в некоторой конкретной ситуации. Слишком часто программисты выбирают простой путь и программируют некоторую неэффективную сортировку, которая затем вставляется в более крупную систему, где сортировка является ключевым компонентом. После этого проектировщики данной системы удивляются на неадекватность своего детища. Для того чтобы максимизировать свою эффективность, программист должен быть знаком с широким диапазоном методов сортировки и должен осознавать преимущества и недостатки каждого из них, так что, когда возникает необходимость в некоторой сортировке, он сможет применить ту, которая лучше всего подходит к конкретной ситуации. Это приводит нас к двум другим аспектам эффективности — времени и пространству. Как и в большинстве прикладных программ на ЭВМ, программист должен часто оптимизировать один из этих аспектов за счет другого. При рассмотрении времени, необходимого для сортировки некоторого файла размером п, мы не будем касаться реальных временных единиц, поскольку они изменяются от одной машины к другой, от одной программы к другой и от одной совокупности данных к другой. Вместо этого мы будем интересоваться изменением времени, необходимого для сортировки некоторого файла, в зависимости от некоторого изменения в файле размером п. Давайте попробуем представить эту концепцию поточнее. Мы говорим, что у пропорционален х, если отношение между у и χ такое, что умножение χ на некоторую константу дает умножение у на ту же константу. Таким образом, если у пропорционален х, то удвоение χ удвоит и у, а умножение χ на 10 приведет к умножению у на 10. Аналогичным образом, если у пропорционален χ в квадрате, удвоение χ даст умножение у на 4, а умножение χ на 10 приведет к умножению у на 100. Часто мы измеряем эффективность некоторой сортировки по времени не в затраченных единицах времени, а по числу выполненных критических операций. Примерами таких критических операций являются операции сравнения ключей (т. е. сравнение ключей двух записей в файле для того, чтобы определить, какой 427
из них больше), операции перемещения записей или указателей на записи, а также операции перемены местами двух записей. Выбранными критическими операциями являются те, на которые затрачивается наибольшее время. Например, сравнение ключей может быть сложной операцией, особенно если сами ключи длинные или упорядочивание ключей является нетривиальным. Так, сравнение ключей требует намного больше времени, чем, скажем, простое увеличение переменной индекса в некотором цикле «for-next». Помимо этого число требуемых простых операций обычно пропорционально числу сравнений ключей. По этой причине число сравнений ключей является удобным измерением эффективности сортировки по времени. Имеется два способа определения временных затрат сортировки, причем ни один из них не дает результатов, которые применимы для всех случаев. Один метод состоит в том, чтобы провести анализ различных случаев (т. е. наилучший случай, наихудший случай, средний случай), что иногда приводит к тонким и сложным математическим выкладкам. Результатом этого а = 0,01л2 Ь=10л й + ь а + Ъ 10 1 100 101 1,01 50 25 500 525 0,21 100 100 1000 1100 0,И 500 2 500 5 000 7 500 0,03 1000 10000 10 000 20 000 0,02 5 000 250 000 50 000 300 000 0,01 10 000 1000 000 100 000 1100 000 0,01 50 000 25 000 000 500 000 25 500 000 0,01 100 000 100 000 000 1000 000 101000 000 0,01 500 000 2 500 000 000 5 000 000 2 505 000 000 0,01 Рис. 8.1.3. анализа часто является некоторая формула, задающая среднее время (или число операций), затрачиваемое некоторой конкретной сортировкой, как некоторую функцию от размера файла п. (В действительности время, затрачиваемое некоторой сортировкой, зависит также от факторов, отличных от размера файла. Однако мы здесь будем касаться только зависимости от размера файла.) Предположим, что математический анализ некоторой конкретной программы сортировки дает в результате заключение, что для выполнения этой программы нужно 0,01п2+Юп единиц времени. Первый и четвертый столбцы на рис. 8.1.3 показывают время, необходимое для сортировки при различных значениях п. Читатель заметит, что для малых значений η составляющая 10п (третий столбец на рис. 8.1.3) подавляет составляющую 0,01η2 (второй столбец). Это происходит из-за того, что разница между п2 и η невелика для малых значений п, и они более чем компенсируются разницей между 10 428
и 0,01. Таким образом, для малых значений η увеличение π в два раза (например, с 50 до 100) приведет к увеличению необходимого времени для сортировки тоже примерно в два раза (с 525 до 1100). Аналогичным образом увеличение η в 5 раз (например, с 10 до 50) увеличит время сортировки примерно в 5 раз (с 101 до 525). Однако, когда η становится больше, разница между п2 и η увеличивается так быстро, что она в конце концов более чем компенсирует разницу между 10 и 0,01. Так, когда η равняется 1000, две составляющие дают равный вклад во время, затрачиваемое программой. По мере того как η становится еще больше, составляющая 0,01η2 подавляет составляющую 10п и вклад составляющей 10п становится почти несущественным. Таким образом, для больших значений η увеличение η в два раза (например, с 50 000 до 100 000) даст в результате увеличение времени сортировки примерно в 4 раза (с 25,5 миллионов до 101 миллиона), а увеличение η в 5 раз (например, с 10 000 до 50 000) увеличивает время сортировки примерно в 25 раз (с 1,1 миллиона до 25,5 миллиона). В самом деле, по мере того как η становится все больше и больше, время сортировки становится все более близко пропорциональным п2, как это ясно иллюстрируется в последнем столбце рис. 8.1.3. Из-за этого мы говорим, что время для такой сортировки имеет порядок п2, что записывается как 0(п2). Таким образом, для большого значения η время, затрачиваемое на сортировку, почти пропорционально п2. Конечно, для небольших значений η сортировка может дать различные характеры поведения (как на рис. 8.1.3), что надо учитывать при анализе ее эффективности. Используя эту концепцию порядка сортировки, мы можехМ сравнить различные методы сортировки и классифицировать их в общих терминах как «хорошие» или «плохие». Кто-нибудь может попытаться открыть «оптимальную» сортировку, которая имеет порядок О(п). К сожалению, однако, может быть показано, что такой полезной во всех отношениях сортировки не существует. Большинство классических сортировок, которые мы будем рассматривать, имеют временные затраты, которые варьируются от O(nlogn) до 0(п2). [Логарифмом некоторого числа η по основанию m называется то число раз, которое m должно быть умножено само на себя для того, чтобы получить п, и оно записывается как logm п. Так, logio 1000 равен 3, поскольку 10*10*10= 103, что равно 1000, a log2 1000 немного меньше 10, поскольку 210 равно 1024. Читателю предлагается показать в качестве упражнения, что основание логарифма не существенно при определении порядка некоторой сортировки O(nlogn).] Для первой сортировки умножение размера файла на 100 приведет к умножению времени сортировки на величину, меньшую чем 200; для второй — умножение размера файла на 1С0 умножает время сортировки на некоторый множитель, рав- 429
ный 10 000. На рис. 8.1.4 показано сравнение величин η log n и η2 для некоторого диапазона значений п. Из этого рисунка можно видеть, что для больших η по мере увеличения η величина п2 увеличивается с гораздо большей скоростью, чем величина η log п. Однако какая-либо сортировка не должна выбираться просто потому, что она имеет порядок O(nlogn). Должна быть установлена связь между размером файла η и другими элементами, составляющими реальное время сортировки. Более того, элементы, которые играют несущественную роль для больших значений п, могут играть сильно доминирующую роль для небольших значений п. Все эти рассуждения должны быть учтены до того, как будет сделан разумный выбор некоторой Вторым методом определения временных затрат некоторого метода сортировки являются запуск программы на ЭВМ и измерения ее эффективности (или при помощи измерения в абсолютных единицах времени, или в числе выполненных операций). Для того чтобы использовать эти результаты для измерения эффективности некоторой сортировки, данный тест должен быть пропущен на «многих» выбранных файлах. И даже когда статистика будет собрана, применение этой сортировки для некоторого конкретного файла может дать результаты, которые не соответствуют общей картине. Специфические атрибуты исследуемого файла могут приводить к тому, что время сортировки будет иметь значительные отклонения. В сортировках, приводимых в следующих далее разделах, мы будем давать некоторое интуитивное объяснение того, почему конкретная сортировка классифицируется как 0(п2) или O(nlogn). Мы оставляем математический анализ и сложное тестирование эмпирических данных в качестве упражнений для более «дотошных» читателей. В большинстве случаев время, необходимое для некоторой сортировки, зависит от первоначальной последовательности данных. Для некоторых сортировок входные данные, которые, представлены почти в отсортированном виде, могут быть полностью отсортированы за время О(п), а входные данные, представленные в обратном порядке по сравнению с отсортированным видом, требуют времени, которое составляет 0(п2). Для других сортировок временные затраты представляют собой O(nlogn) независимо от первоначального расположения данных. Таким сортировки. и 1 xlO1 5 χ ΙΟ1 1 xlO2 5 χ ΙΟ2 1 χ ΙΟ3 5 χ ΙΟ3 1 χ ΙΟ4 5 χ ΙΟ4 1 xlO5 5 χ ΙΟ5 ΙχΙΟ6 5 χ ΙΟ6 1 χ ΙΟ7 Рис. 8.1.4. η log η и η2 η lOgjQ П 1,0 χ 101 8,5 χ ΙΟ1 2,0 χ ΙΟ2 1,3 χ 103 3,0 χ 103 1,8 χ ΙΟ4 4,0 χ ΙΟ4 2,3 χ 10s 5,0 χ 105 2,8 χ ΙΟ6 6,0 χ ΙΟ6 3,3 χ 107 7,0 χ 107 Сравнение η2 1,0 χ 102 2,5 χ 103 1,0 χ ΙΟ4 2,5 χ ΙΟ5 1,0 χ ΙΟ6 2,5 χ 107 1,0 χ 108 2,5 χ ΙΟ9 1,0 χ 1010 2,5 χ 1011 1,0 χ 1012 2,5 χ 1013 1,0 χ ΙΟ14 величин ! для различных значений η. 430
образом, если у нас имеется некоторая информация о первоначальной последовательности данных, то можно принять более обоснованное решение о том, какой метод сортировки использовать. С другой стороны, если у нас нет такой информации, можно выбрать некоторую сортировку, основываясь на самом худшем возможном варианте или на «среднем» варианте. В любом случае единственным общим замечанием, которое можно сделать о методах сортировки, является то, что не существует общего «наилучшего» метода сортировки. Выбор какой-либо сортировки должен по необходимости зависеть от конкретных обстоятельств. Когда выбран некоторый конкретный метод сортировки, программист должен заставить соответствующую программу работать как можно эффективнее. Во многих приложениях по программированию часто необходимо пожертвовать эффективностью ради простоты. Для сортировки обычно верно обратное. Когда написана и протестирована некоторая программа сортировки, главной задачей программиста является увеличение скорости ее работы, даже если она станет менее понятной. Причина этого заключается в том, что сортировка может давать основной вклад в эффективность работы программы, так что любые улучшения в сортировке существенно влияют на общую эффектив* ность. Другой причиной является то, что сортировка порой используется достаточно часто, так что небольшое улучшение в скорости ее выполнения сэкономит много времени ЭВМ. Обычно бывает полезно удалять обращения к подпрограммам, особенно во внутренних циклах, и заменять их на операторы соответствующих подпрограмм, поскольку механизм обращений и возвратов языков программирования может быть чрезвычайно неэффективным в смысле времени исполнения. Во многих программах мы этого не делаем для того, чтобы не затемнять смысл программы большими блоками операторов. Кроме того, механизм обращений и возвратов в языке Бейсик более эффективен, чем во многих других языках программирования. Эта происходит из-за того, что обращения и возвраты в языке Бейсик обрабатываются при помощи передачи некоторого адреса возврата. Передача параметров выполняется при помощи явных операторов присваивания. В соответствии с нашими соглашениями о передаче параметров в языке Бейсик мы можем даже избежать явного присваивания параметров, особенно параметров, которые являются массивами. Так, дублирование операторов подпрограммы для работы с различными аргументами может быть более эффективным, чем повторяющееся присваивание одних аргументов во входные параметры подпрограммы, а выходных параметров в другие аргументы. В других языках программирования обращение к подпрограмме может состоять из выделения памяти для локальных переменных, что иногда требует обращения к операционной системе. 431
Ограничения по пространству обычно являются менее важными, чем вопросы скорости. Одна из причин этого состоит в том, что для большинства программ сортировок объемы требуемого пространства находятся ближе к О(п), чем к 0(п2). Вторая причина заключается в том, что если требуется больше пространства, то оно почти всегда может быть найдено во вспомогательной памяти. Конечно, обычное соотношение между временем и пространством справедливо и для алгоритмов сортировки, а именно: те программы, которые расходуют меньше времени, обычно расходуют больше пространства и наоборот. В последующих разделах мы исследуем некоторые из наиболее популярных методов сортировки и укажем на их преимущества и недостатки. Упражнения 1. Выберите любой метод сортировки, с которым вы знакомы. (а) Напишите программу для данной сортировки. (б) Является ли данная сортировка устойчивой? (в) Определите временные затраты данной сортировки как некоторую функцию от размера файла, причем и математически, и эмпирически. (г) Каким является порядок данной сортировки? (д) При каком размере файла наиболее доминирующий элемент начинает перекрывать все остальные? 2. Покажите, что если некоторая сортировка имеет порядок 0(nlog2n), то он также является и 0(п logi0n), и наоборот. 3. Предположим, что некоторые временные затраты задаются формулой a*n2+b*n*log2n, где а и b являются константами. Ответьте на следующие вопросы и при помощи математического доказательства своих результатов, и при помощи создания некоторой программы, чтобы проверить данные результаты эмпирически. (а) Для каких значений η (представленных в терминах а и Ь) первое слагаемое доминирует над вторым? (б) Для какого значения η (представленного в терминах а и Ь) два заданных слагаемых равны? (в) Для каких значений η (представленных в терминах а и Ь) второе слагаемое доминирует над первым? 4. Покажите, что любой процесс, который выполняет сортировку некоторого файла, может быть расширен так, чтобы находить все повторяющиеся записи в данном файле. 5. Деревом выбора для сортировки называется некоторое бинарное дерево, которое представляет некоторый метод сортировки, основанный на сравнениях. На рис. 8.1.5 показано такое дерево выбора для некоторого файла из трех элементов. Каждый узел такого дерева, отличный от листа, представляет сравнение двух элементов. Каждый лист представляет некоторый полностью отсортированный файл. Левая ветвь от узла дерева, отличного от листа, указывает, что первый ключ был меньше, чем второй. Правая ветвь указывает, что он был больше. (Мы предполагаем, что все элементы в данном файле имеют различные ключи.) Например, дерево на рис. 8.1.5 представляет некоторую сортировку трех элементов — х(1), х(2), х(3),— которая выполняется следующим образом. Сравниваются х(1) и χ(2). Если х(1)<х(2), то х(2) сравнивается с х(3), и если х(2)<х(3), то элементы отсортированного файла представляют собой х(1), χ(2), х(3). В противном случае, если х(1)<х(3), то отсортированный порядок элементов есть х(1), х(3), х(2). А если х(1)>х(3), то отсортированный порядок элементов равен х(3), х(1), х(2). Если х(1)>х(2), то надо аналогичным образом пройти вниз по правому поддереву. 432
(а) Покажите, что некоторое дерево выбора для сортировки, которая никогда не делает избыточных сравнений (т. е. никогда не сравнивает x(i) и x(j), если соотношение между x(i) и x(j) известно), имеет п! листьев. (б) Покажите, что глубина такого дерева принятия решений составляет по крайней мере log2(nl). (в) Покажите, что nn^n!^(n/2)n/2, так что высота такого дерева составляет О (a log n). (г) Объясните почему верно, что любой метод сортировки, который использует сравнения для некоторого файла размером п, должен сделать по крайней мере O(nlogn) сравнений. 6. Пусть дерево выбора для сортировки задано для некоторого файла так же, как в упражнении 5. Покажите, что если данный файл содержит несколько равных элементов, то результат применения этого дерева к этому файлу (где выбирается или левая, или правая ветвь, когда два элемента равны) есть некоторый отсортированный файл. 7. Распространите концепцию бинарного дерева выбора из упражнений 5 и б на тернарное дерево, которое включает в себя возможность равенства. Желательно определить, какие элементы файла равны помимо упорядочивания различающихся элементов файла. Сколько сравнений необходимо выполнить? Рис. 8.1.5. Дерево выбора для некоторого файла из трех элементов. 8. Покажите, что если к является наименьшим целым числом, большим или равным n+log2n—2, то к сравнений являются необходимым и достаточным числом сравнений для нахождения наибольшего элемента и второго по величине элемента в некотором множестве из η различных элементов. 9. Сколько необходимо выполнить сравнений для нахождения наибольшего и наименьшего элементов в некотором множестве из π различных элементов? 8.2. ОБМЕННЫЕ СОРТИРОВКИ Сортировка «методом пузырька» Первая сортировка, которую мы рассмотрим, является, по-видимому, наиболее широко известной среди студентов, начинающих изучать программирование, — это сортировка «методом пузырька». Одна из характеристик этой сортировки заключается в том, что она проста для усвоения и программирования. Но из всех сортировок, которые мы будем изучать, она, наверное, самая неэффективная. 433
В каждом из последующих примеров χ будет являться некоторым массивом целых чисел, из которых первые η чисел должны быть отсортированы таким образом, чтобы x(i)^x(j) для l^i^j^n. Это простое представление можно естественным образом расширить так, чтобы использовать для сортировки η записей, каждая из которых имеет некоторое подполе ключа к. В языке Бейсик это могло бы быть реализовано при помощи хранения других полей записи в отдельных массивах. Если некоторая операция будет выполняться над k(i), то соответствующая операция выполнялась бы и над каждым из этих полей. Сравнения применялись бы, конечно, только к полю ключа. Идея, лежащая в основе сортировки «методом пузырька», состоит в том, чтобы просмотреть файл последовательно несколько раз. Каждый просмотр состоит из сравнения каждого элемента файла со следующим за ним элементом (x(i) сравнивается с x(i+l)) и «обмена» (или транспозиции) этих двух элементов, если они располагаются не в нужном порядке. Рассмотрим следующий файл: 25 57 48 37 12 92 86 33 На первом просмотре делаются следующие сравнения: х(1)сх(2) (25с57) нет обмена х(2) сх(3) (57 с 48) обмен х(3) сх(4) (57 с 37) обмен х(4) сх(5) (57с 12) обмен х(5)сх(б) (57 с 92) нет обмена х(6) сх(7) (92 с 86) обмен χ (7) сх(8) (92 с 33) обмен Таким образом, после первого просмотра элементы данного файла будут располагаться в таком порядке: 25 48 37 12 57 86 33 92 Отметим, что после этого первого просмотра наибольший элемент (в данном случае 92) находится в нужной позиции внутри массива. В общем случае элемент х(п—i+Ι) будет находиться в нужной позиции после итерации i. Этот метод называется «методом пузырька» потому, что каждое число медленно всплывает как «пузырек» вверх на свою нужную позицию. После второго просмотра элементы данного файла будут располагаться так: 25 37 12 48 57 33 86 92 Отметим, что число 86 теперь добралось до своей позиции второго по величине числа. Поскольку каждая итерация помещает новый элемент в нужную позицию, то для сортировки некоторого файла, состоящего из η элементов, требуется не более п—1 итераций. Полный набор итераций выглядит следующим образом: итерация 0 25 57 48 37 12 92 86 33 434
(первоначальный файл) итерация 1 25 48 37 12 57 86 33 92 итерация 2 25 37 12 48 57 33 86 92 итерация 3 25 12 37 48 33 57 86 92 итерация 4 12 25 37 33 48 57 86 92 итерация 5 12 25 33 37 48 57 86 92 итерация б 12 25 33 37 48 57 86 92 итерация 7 12 25 33 37 48 57 86 92 Основываясь на предыдущем обсуждении, мы могли бы приступить к программированию сортировки «методом пузырька». Однако для этого метода имеется несколько очевидных улучшений. Во-первых, поскольку все элементы в позициях, больших или равных η—i+1, уже находятся после итерации i на нужных местах, то их нет необходимости рассматривать при последующих итерациях. Так, при первом просмотре делается η—1 сравнений, на втором просмотре — η—2 сравнений и на (п—1)-м просмотре делается только одно сравнение (сравниваются х(1) и х(2)). Следовательно, данный процесс ускоряется с каждым просмотром. Мы показали, что для сортировки некоторого файла размером η достаточно выполнить η—1 просмотров. Однако в вышеприведенном примере файла из восьми элементов этот файл был уже отсортирован после пяти итераций, что делает две последние итерации ненужными. Для того чтобы исключить ненужные просмотры, надо иметь возможность обнаруживать тот факт, что файл уже отсортирован. Но это простая задача, поскольку в отсортированном файле при любом просмотре не делается ни одной транспозиции. Сохраняя информацию о том, были ли сделаны какие-либо транспозиции на некотором заданном просмотре, можно определить, нужны ли следующие просмотры. При использовании этого метода при последнем просмотре не делается транспозиций, если данный файл может быть отсортирован за число просмотров меньше η—1. Используя эти усовершенствования, мы создадим подпрограмму bubble, для которой задаются две переменные — X и N. Переменная X является некоторым массивом чисел, а N — некоторое целое число, представляющее число тех элементов, которые должны быть отсортированы (N может быть меньше, чем верхняя граница массива X): 5000 'подпрограмма bubble 5010 'входы: Ν, Χ 5020 'выходы: X 5030 'локальные переменные: J, P, PASS, SWITCH 5040 'инициализация 5050 SWITCH=TRUE 5060 FOR PASS=1 TO N—1: 'внешний цикл управляет числом проходов 5070 IF SWITCH-FALSE THEN RETURN 5080 'в противном случае выполнить операторы с номерами 5090— 5090 SWITCH=FALSE: 'первоначально на этом просмотре не было 'сделано транспозиций 435
5100 FOR J=l TO N-PASS: 'внутренний цикл управляет каждым отдельным просмотром 5110 IFX(J)>X(J+1) THEN SWITCH=TRUE: HOLD=X(J): X(J)-X(J+1): X(J+l)=HOLD 5120 'элементы были не в нужном порядке, необходима транспозиция 5130 NEXT J 5140 NEXT PASS 5150 RETURN 5160 'конец подпрограммы Что можно сказать об эффективности сортировки «методом пузырька». В случае если в эту сортировку не включать два вышеупомянутых усовершенствования, анализ ее прост. Имеется η—1 просмотров и η—1 сравнений на каждом просмотре. Таким образом, общее число сравнений равно (п—1)*(п—1)=п2— —2п+1, что составляет 0(п2). Конечно, число транспозиций зависит от первоначального расположения элементов в файле. Однако число транспозиций не может быть больше, чем число сравнений. По-видимому, при выполнении данного алгоритма большую часть времени отнимает выполнение сравнений, а не выполнение транспозиций. Давайте теперь рассмотрим, как введенные нами усовершенствования влияют на скорость работы сортировки «методом пузырька». Число сравнений на итерации i равно η—i. Таким образом, если имеется К итераций, то общее число сравнений равно (п—1) + (п—2) + (п—3) +... + (п—к), что равно (2кп— —к2—к)/2. Может быть показано, что среднее число итераций К представляет собой О(п), так что общая формула по-прежнему имеет порядок 0(п2), хотя постоянный сомножитель теперь меньше, чем раньше. Однако при этом имеются дополнительные накладные расходы на проверку и инициализацию переменной SWITCH (один раз на просмотр) и на установку в нее значения TRUE (один раз на каждую итерацию). Единственным утешительным свойством сортировки «методом пузырька» является то, что для нее требуется мало дополнительного пространства (одна дополнительная запись для временного хранения той записи, для которой выполняется транспозиция, и нескольких простых целых переменных) и что она является сортировкой порядка О(п) в том случае, если файл полностью отсортирован). Это следует из того факта, что для того, чтобы убедиться, что файл отсортирован, требуется один просмотр с η—1 сравнениями (и без транспозиций). Для улучшения сортировки «методом пузырька» имеются некоторые другие способы. Один из них состоит в том, что число проходов, необходимых для сортировки файла, является наибольшим расстоянием, на которое должно переместиться некоторое число к началу данного массива. В нашем случае, например, число 33, которое находилось в первоначальном массиве β позиции 8, в конце концов добралось до позиции 3 после пя- 436
той итерации. Сортировка «методом пузырька» может быть ускорена при помощи выполнения следующих один за другим просмотров в противоположных направлениях, так что небольшие по величине элементы быстро перемещаются в начало файла таким же способом, как большие элементы перемещаются в конец файла. Это приводит к уменьшению необходимого числа просмотров. Реализация этой версии предоставляется читателю в качестве упражнения. «Быстрая сортировка» Следующей сортировкой, которую мы будем рассматривать, является обменная сортировка с разделением (или «быстрая сортировка»). Пусть χ будет некоторым массивом, an — числом элементов в этом массиве, которые надо отсортировать. Выберем некоторый элемент а в некоторой конкретной позиции внутри данного массива (например, а может быть первым элементом, так что а = х(1)). Предположим, что элементы массива χ переупорядочены таким образом, что а помещается в позицию j и выполняются следующие условия: 1. Каждый из элементов в позициях с 1-й по j—1-ю меньше или равен а. 2. Каждый из элементов в позициях с j+1-й по n-ю больше или равен а. Отметим, что если эти два условия выполнены для некоторого конкретного а и j, то а является j-м наименьшим элементом в массиве х, и а остается в позиции j, когда данный массив будет полностью отсортирован. (Читателю предлагается доказать этот факт в качестве упражнения.) Если вышеприведенный процесс повторяется для подмассивов от х(1) до x(j—1) и от x(j+l) до х(п), а также для всех подмассивов, созданных в результате данного процесса при последовательных итерациях, то окончательным результатом является некоторый отсортированный файл. Проиллюстрируем «быструю сортировку» на некотором примере. Если первоначальный массив задается следующим образом: 25 57 48 37 12 92 86 33 и первый элемент (25) помещается в соответствующую ему позицию, то результирующий массив будет таким: 12 25 57 48 37 92 86 33 В этот момент элемент 25 находится в своей позиции в массиве (на месте х(2)), каждый элемент левее этой позиции (элемент 12) меньше или равен 25 и каждый элеШт правее этой позиции (элементы 57, 48, 37, 92, 86 и 33) больше или равны 25. Поскольку элемент 25 находится в своей окончательной пози- 437
ции, то первоначальная проблема была декомпозирована в проблему сортировки двух подмассивов (12) и (57 48 37 92 86 33) Для сортировки первого из этих подмассивов ничего не надо делать. Для того чтобы отсортировать второй подмассив, данный процесс повторяется и этот подмассив делится дальше. Весь массив теперь можно рассматривать как 12 25 (57 48 37 92 86 33) где в скобки заключены подмассивы, которые еще должны быть отсортированы. Повторяя этот процесс над подмассивом с элементами от х(3) до х(8), получим 12 25 (48 37 33) 57 (92 86) а дальнейшие повторения дают следующее: 12 25 (37 33) 48 57 (92 86) 12 25 (33) 37 48 57 (92 86) 12 25 33 37 48 57 (92 86) 12 25 33 37 48 57 (86) 92 12 25 33 37 48 57 86 92 Заметим, что последний массив является отсортированным. К этому моменту читатель уже, наверное, заметил, что «быстрая сортировка» может быть определена более удобно как некоторая рекурсивная процедура. Мы можем описать алгоритм quick (lb,ub) для сортировки всех элементов в некотором массиве χ между позициями lb и ub (lb является нижней границей, ub — верхней границей) следующим образом: if lb>ub then return 'массив отсортирован endif rearrange (lb,ub,j) 'переупорядочить элементы подмассива так, чтобы 'один из элементов (возможно, χ (lb)) находился те- 'перь на месте x(j) (j является некоторым выходным 'параметром) и чтобы: Ί. x(j)<x(j) для lb<i<j '2. x(i)>x(j) для j<i<ub 'x(j) теперь находится на своей окончательной по- 'зиции quick (lb,j —1) 'рекурсивно отсортировать подмассив между пози- 'циями lb и j —1 quick(j+l,ub) 'рекурсивно отсортировать подмассив между пози- 'циями j+Ι и ub Теперь имеется две проблемы. Мы должны создать некоторый механизм для реализации алгоритма rearrange и создать некоторый метод реализации всего процесса нерекурсивно. Цель алгоритма rearrange состоит в том, чтобы позволить некоторому конкретному элементу найти свое место по отношению к другим элементам в данном подмассиве. Отметим, что 438
способ, которым это переупорядочивание выполняется, не существен для данного метода сортировки. Все, что требуется для сортировки, это то, чтобы заданные элементы были разделены надлежащим образом. В вышеприведенном примере элементы в каждом из двух подфайлов остаются в таком же относительном порядке, как они были в первоначальном файле. Однако такой метод переупорядочивания при реализации является относительно неэффективным. Один способ эффективного влияния на переупорядочивание состоит в следующем. Пусть а = х(1Ь) будет элементом, для которого осуществляется поиск окончательной позиции. (При выборе первого элемента в подмассиве в качестве элемента, который вставляется в свою окончательную позицию, не достигается никакого заметного выигрыша эффективности. Просто это позволяет упростить программирование некоторых программ.) В начале в два указателя — up и down — устанавливаются соответственно верхняя и нижняя границы подмассива. На любом этапе выполнения каждый элемент в некоторой позиции правее up больше или равен а, а каждый элемент в позиции левее down меньше или равен а. Указатели up и down движутся навстречу друг другу следующим образом. Выполнение начинается при помощи увеличения указателя down каждый раз на одну позицию до тех пор, пока не выполнится условие x(down)>a. После этого указатель up уменьшается каждый раз на одну позицию до тех пор, пока не выполнится условие х(ир)^а. Если up все еще больше, чем down, то x(down) и х(ир) меняются местами. Данный процесс повторяется до тех пор, пока не будет выполнено условие up ^down, и в этот момент х(ир) меняется местами с х(1Ь) (которое равно а), для которого осуществлялся поиск окончательной позиции, и в j устанавливается значение, равное позиции up. Проиллюстрируем этот процесс на простом файле, отображая позиции up и down по мере того, как они изменяются. Направление сканирования показано стрелочкой при том указателе, который перемещается. Звездочка указывает на то, что сделана взаимная транспозиция элементов (см. рисунок на с. 440). В этот момент элемент 25 находится в своей окончательной позиции (позиция 2), каждый элемент левее его — меньше или равен 25, а каждый элемент правее его — больше или равен 25. Можно было бы теперь продолжить сортировку двух подмас- сивов (12) и (48 37 57 92 86 33), применяя этот же самый метод. Алгоритм для реализации подпрограммы rearrange состоит в следующем (х, lb и ub являются входными переменными, а j и χ — выходными переменными): а = х(1Ь) 'а является тем элементом, для которого 'организуется поиск окончательной позиции up=ub 439
a = x(lb) = 25 downi 25 25 25 25 25 25 25 25 25 25 25 25 25 12 57 down 57 down 57 down 57 down 57 down 57 down 12 down% 12 12 12 12 12 up 12 up 25 48 48 48 48 48 48 48 48 down 48 down 48 down 48 4up, down 48 48 48 37 37 37 37 37 37 37 37 37 37 iup 37 37 37 37 12 12 12 12 12 up 12 up 57 up 57 up 57 iup 57 57 57 57 57 92 92 92 92 iup 92 92 92 92 92 92 92 92 92 92 86 86 86 iup 86 86 86 86 86 86 86 86 86 86 86 up 33 up 33 iup 33 33 33 33 33 33 33 33 33 33 33 33 down=lb while down<up do while x(down)< = a do down=down+l 'переместить направо указатель down в массиве endwhile while x(up)>a do up = up—1 'переместить налево указатель up в массиве endwhile if down<up then поменять местами χ (down) и х (up) endif endwhile x(lb)=x(up) 'x(up) меняется местами с x(lb)=a x(up) = a j = up Отметим, что если k равно ub—lb+1, так что мы переупорядочиваем некоторый подмассив размером к, то данная подпрограмма делает к сравнений ключей (для χ (down) с а и х(ир) с а), для того чтобы выполнить данное разбиение. Сегмент программы для rearrange представляет собой следующее: 440
6000 'подпрограмма rearrange 6010 'входы: LB, UB, X 6020 'выходы: J, X 6030 'локальные перемещенные: A, DOWN, TEMP, UP 6040 A=X(LB) 6050 UP=UB 6060 DOWN = LB 6070 'переместиться направо по массиву 6080 IF X(DOWN)>A THEN GOTO 6110 6090 DOWN=DOWN+l 6100 IF DOWN<UP THEN GOTO 6080 6110 'переместиться налево по массиву 6120 IF X(UP)<=A THEN GOTO 6160 6130 UP=UP-1 6140 GOTO 6120 6150 IF DOWN<UP THEN TEMP=X(DOWN): X(DOWN)=X(UP): X(UP)=TEMP: GOTO 6070 6160 X(LB)=X(UP) 6170 X(UP)=A 6180 J=UP 6190 RETURN 6200 'конец подпрограммы Отметим, что данная программа является некоторой немного модифицированной версией этого алгоритма, для того чтобы гарантировать правильность работы программы, когда переменная DOWN равна N — последнему элементу в массиве. Эту программу можно сделать более эффективной, исключив некоторые избыточные проверки. Читателю предлагается сделать это в качестве упражнения. Хотя рекурсивный алгоритм «быстрой сортировки» относительно ясен в терминах того, что и как надо делать, в языке Бейсик не существует рекурсии. Кроме того, в таких программах, как сортировки, желательно избегать накладных расходов, связанных с обращением к подпрограммам, так как для них вопросы эффективности являются крайне важными. Рекурсивные обращения к подпрограммам QUICK могут быть просто исключены при помощи использования стека, как это сделано в гл. 5. Когда подпрограмма rearrange выполнится, текущие параметры для QUICK больше не нужны, за исключением вычисления аргументов для двух следующих рекурсивных обращений. Таким образом, вместо того чтобы при каждом рекурсивном обращении помещать текущие параметры в стек, мы можем вычислять и помещать в стек новые параметры для каждого из двух рекурсивных обращений. При этом подходе стек в любой момент времени содержит нижнюю и верхнюю границы всех подмассивов, которые должны быть отсортированы. Более того, поскольку второе рекурсивное обращение непосредственно предшествует возврату в вызывающую программу (как в задаче «Ханойские башни»), то оно может быть полностью исключено и заменено на передачу управления. И наконец, поскольку порядок, в котором выполняются эти два рекурсивных обращения, не существен в этой задаче, мы принимаем решение в каждом 441
случае помещать в стек больший подмассив и сразу же обрабатывать меньший подмассив. Как мы вскоре увидим, этот метод приводит к тому, что размер стека сводится к минимуму. Мы можем теперь написать некоторую программу, реализующую «быструю сортировку». Как и в случае с сортировкой «методом пузырька», входными переменными являются массив X и число элементов в массиве X, которые мы хотим отсортировать (N). Подпрограмма pushbounds помещает в стек LB и UB, подпрограмма popbounds выбирает их из стека, а подпрограмма empty определяет, является ли стек пустым. 5000 'подпрограмма quicksort 5010 'входы: Ν, Χ 5020 'выходы: X 5030 'локальные переменные: J, LB, EMPTY, TEMP, TP, UB 5040 ТР=0: 'инициализация стека 5050 LB=1 5060 UB=N 5070 GOSUB 1000: 'подпрограмма pushbounds помещает в стек LB и UB 5080 'повторить столько раз, сколько имеется в стеке неотсортированных 'подмассивов 5090 GOSUB 3000: 'подпрограмма empty устанавливает переменную 'EMPTY 5100 IF EMPTY^TRUE THEN RETURN 5110 GOSUB 2000: 'подпрограмма popbounds выбирает из стека LB 'и UB 5120 IF UB< = LB THEN GOTO 5090 5130 'обработать следующий подмассив, переупорядочив и разделив 'его на два 5140 GOSUB 6000: 'подпрограмма rearrange устанавливает переменную J 5150 'поместить в стек больший подмассив 5160 IF J-LB>UB-J THEN TEMP=UB: UB=J-1: GOSUB 1000: LB=J+1: UB^TEMP: GOTO 5120 5170 'поместить в стек подмассив с меньшими значениями 'и обработать подмассив с большими значениями 5180 'иначе выполнить операторы с номерами 5190—5240, поместив 'в стек подмассив с большими значениями 5190 TEMP^LB 5200 LB-J+1 6210 GOSUB 1000: 'подпрограмма pushbounds 5220 'обработать подмассив с меньшими значениями 5230 UB=J-1 5240 LB=TEMP 5250 GOTO 5120 5260 'конец подпрограммы 6000 'подпрограмма rearrange Операторы подпрограмм rearrange, empty, popbounds и pushbounds могут быть вставлены в текст программы для большей эффективности. Протрассируйте работу программы quicksort на файле примера. Какова эффективность «быстрой сортировки»? Предположим, что размер файла η является некоторой степенью числа 2, скажем n=2m, так что m=log2n. Предположим также, что нуж- 442
ной позицией элемента х(1Ь) всегда оказывается в точности середина подмассива. В этом случае будет примерно η сравнений (в действительности η—1) на первом просмотре, после чего этот файл разделяется на два подфайла, каждый из которых имеет размер примерно п/2. Для каждого из этих двух файлов выполняется примерно п/2 сравнений и формируются всего четыре файла, каждый из которых имеет размер п/4. Каждый из этих файлов требует п/4 сравнений, приводя к созданию всего восьми файлов размером п/8 каждый. После деления пополам подфайлов m раз получается η файлов размером 1. Таким образом, общее число сравнений для всей сортировки составляет примерно п+2* (п/2) +4* (п/4) +8* (п/8) +.. .+п* (п/п) или п+п+п+п+.. .+п (т раз) сравнений. Имеется m слагаемых, поскольку файл делится на две части m раз. Таким образом, общее число сравнений составляет 0(n*m) или O(nlogn) (напомним, что m=log2n). Так, если описанные выше свойства выполняются для файла, то «быстрая сортировка» имеет порядок O(nlogn), что является достаточно эффективным. Вышеприведенный анализ предполагает, что первоначальный массив и все результирующие подмассивы являются неотсортированными, так что элемент х(1Ь) всегда находит свою нужную позицию в середине подмассива. Предположим, что вышеприведенные условия не выполняются и первоначальный массив является отсортированным (или почти отсортированным). Если, например, х(1Ь) находится на своем правильном месте, то первоначальный файл разбивается на подфайлы, имеющие размеры 0 и η—1. Если этот процесс будет продолжаться, то всего будет отсортировано η—1 подфайлов; первый файл — размером п, второй — размером η—1, третий — размером η—2 и т. д. Предположим, что для переупорядочивания некоторого файла размером к требуется к сравнений; тогда общее число сравнений для выполнения сортировки всего файла будет составлять η+(η-1) + (η-2)+...+2 что составляет 0(п2). Аналогичным образом, если первоначальный файл отсортирован в порядке уменьшения элементов, то конечной позицией для элемента х(1Ь) является ub, и данный файл опять разбивается на два подфайла, которые сильно не- сбалансированны (имеют размеры п—1 и 0). Таким образом, «быстрая сортировка» имеет такое, казалось бы, абсурдное свойство, что она работает наилучшим образом для файлов, которые «полностью неотсортированные», и наихудшим образом 443
для файлов, которые полностью отсортированные. Эта ситуация полностью противоположна ситуации с сортировкой «методом пузырька», которая работает наилучшим образом для отсортированных файлов и наихудшим образом для неотсортированных файлов. Анализ случая, когда размер файла не равен в точности степени числа 2, является аналогичным, но немного более сложным. Результаты, однако, остаются такими же. Может быть показано, что в среднем (по всем файлам размером п) «быстрая сортировка» делает O(nlogn) сравнений. Даже эффективность при наихудшем случае может быть улучшена путем использования методов, рассматриваемых далее в упражнении 11. Требование на пространство для «быстрой сортировки» зависит от числа вложенных рекурсивных обращений или от размера стека. Ясно, что стек не может никогда стать больше, чем число элементов в первоначальном файле. Насколько стек меньше, чем п, зависит от числа сгенерированных файлов и от их размеров. Размер стека поддерживается на некотором приемлемом уровне, когда больший из двух подмассивов всегда помещается в стек, а алгоритм применяется к меньшему из двух подмассивов. Это приводит к тому, что меньшие подмассивы разделяются перед большими подмассивами и в любой момент времени в стеке находится меньшее число элементов. Причина этого заключается в том, что некоторый подмассив меньшего размера будет разделяться меньшее число раз, чем некоторый подмассив большего размера. Конечно, больший подмассив будет в конце концов обработан ц разделен на подмассивы, но это произойдет после того, как подмассивы меньшего размера уже будут отсортированы и, следовательно, удалены из стека. Упражнения 1. Докажите, что число необходимых просмотров в сортировке «методом пузырька», выполненных до того, как файл будет располагаться в отсортированном порядке (не включая последний просмотр, при котором обнаруживается, что файл отсортирован), равняется наибольшему расстоянию, на которое должен переместиться некоторый элемент с некоторым большим индексом в некоторый малый индекс. 2. Перепишите программу bubble так, чтобы последующие просмотры выполнялись в противоположных направлениях. (Эта сортировка известна как шейкер-сортировка.) 3. Докажите, что в сортировке упражнения 2, если для двух элементов не выполняются транспозиции во время двух последующих просмотров в противоположных направлениях, они находятся на своих окончательных позициях. 4. Сортировка методом подсчета выполняется следующим образом. Заводится некоторый массив count и элементу count (i) присваивается значение числа элементов, которые меньше или равны x(i). Затем элемент x(i) помещается в позицию count(i) в выходном массиве. (Однако остерегайтесь возможности существования одинаковых элементов.) Напишите программу для сортировки массива χ размером п, используя этот метод. 444
5. Предположим, что некоторый файл содержит целые числа в диапазоне от а до Ь, причем многие числа повторяются несколько раз. Сортировка методом распределяющего подсчета выполняется следующим образом. Заводится некоторый массив number размером b—а+1 и элементу number χ X(i—а+1) присваивается значение, равное тому, сколько раз число i появляется в данном файле. Затем значения в файле соответственно переустанавливаются. Напишите программу для сортировки некоторого массива χ размером п, содержащего целые числа в диапазоне от а до Ь, используя этот метод. 6. Сортировка методом «четных и нечетных транспозиций» выполняется следующим образом. Выполните несколько раз просмотры файла. На первом просмотре сравнивайте x(i) с x(i+l) для всех нечетных i. На втором просмотре сравнивайте x(i) с x(i+l) для всех четных i. Каждый раз, когда x(i)>x(i+l), выполняйте транспозицию этих двух элементов. Продолжайте эти просмотры до тех пор, пока файл не будет отсортирован. (а) Что является условием окончания этой сортировки? (б) Напишите на языке Бейсик программу, реализующую эту сортировку. (в) Какова в среднем эффективность этой сортировки? 7. Перепишите программу для «быстрой сортировки», начав с рекурсивного алгоритма и применив затем методы гл. 5 по созданию некоторой нерекурсивной версии. 8. При каких условиях оператор с номером 6100 в программе rearrange может быть изменен с 6100 IF DOWN < UB THEN GOTO 6080 на 6100 GOTO 6080 Какая версия является более эффективной и почему? 9. Могут ли операторы с номерами 6080—6100 в подпрограмме rearrange быть изменены с 6080 IF X(DOWN) >A THEN GOTO 6110 6090 DOWN=DOWN+l 6100 IF DOWN<UB THEN GOTO 6080 на 6080 DOWN = DOWN+l 60.90 IF DOWN<UB THEN IF X(DOWN)<=A THEN GOTO 6080 Каковы преимущества или недостатки этого изменения? 10. Модифицируйте программу «быстрой сортировки», приведенной в тексте, так, чтобы использовалась сортировка «методом пузырька», если некоторый подмассив является небольшим. Определите, использовав реальные просчеты на ЭВМ, каким малым должен быть подмассив, так, чтобы эта смешанная стратегия была более эффективной, чем обычная «быстрая сортировка». 11. Модифицируйте подпрограмму rearrange так, чтобы для разделения массива использовалось среднее значение из x(Tb), x(ub) и x(int((ub+lb)/2)). В каких случаях «быстрая сортировка», использующая этот метод, более эффективна, чем версия, приведенная в разд. 8.2? В каких случаях она менее эффективна? 12. Оцените эффективность каждого из следующих методов сортировки в отношении скорости работы и требований на пространство. (а) Сортировка «методом пузырька», использующая π—1 просмотров, в которой каждый просмотр проходит через все элементы файла. (б) Сортировка «методом пузырька», в которой каждый просмотр делает на одно сравнение меньше, чем предыдущий просмотр. (в) Сортировка «методом пузырька», приведенная в тексте, но модифицированная так же, как и в упражнении 2. (г) Сортировка методом подсчета, как в упражнении 4. 445
(д) Сортировка методом распределяющего подсчета, как в упражнении 5. (е) Сортировка методом «четных и нечетных транспозиций», как в упражнении 6. (ж) «Быстрая сортировка», модифицированная так же, как в упражнении 10. (з) «Быстрая сортировка», модифицированная так же, как в упражнении 11. 13. (а) Перепишите программы для сортировки «методом пузырька» и «быстрой сортировки», которые приведены в разд. 8.1 и сортировки из упражнения 12 таким образом, чтобы накапливалась информация о действительном числе сравнений и действительном числе сделанных транспозиций. (б) Напишите генератор случайных чисел (или используйте некоторый существующий генератор, если на вашей ЭВМ такой имеется), который генерирует целые числа в диапазоне от 0 до 9999. (в) Используйте генератор из упражнения (б) и сгенерируйте несколько файлов размером 10, 100 и 1000. Примените программы сортировки из упражнения (а) для того, чтобы измерить затраты времени на каждую из этих сортировок для каждого из этих файлов. (г) Сравните результаты упражнения (в) с теоретическими значениями, приведенными в разд. 8.1. Согласуются ли они? Если нет, то объясните это. Кроме того, переупорядочьте файлы таким образом, чтобы они были полностью отсортированы в обратном порядке, и посмотрите, как данные сортировки ведут себя с такой входной информацией. 8.3. СОРТИРОВКА ПОСРЕДСТВОМ ВЫБОРА И ПОСРЕДСТВОМ ВЫБОРА ИЗ ДЕРЕВА Сортировка посредством простого выбора Сортировка посредством выбора является такой сортировкой, в которой из файла выбираются последовательные элементы и помещаются в их соответствующие позиции. Приведенная далее программа является некоторым примером сортировки посредством простого выбора. Наибольшее число сперва помещается в N-ю позицию, следующее перед ним по величине число помещается в позицию N—1 и т. д. 5000 5010 5020 5030 5040 5050 5060 5070 5080 509О 5100 5110 5120 5130 5140 5150 'подпрограмма select 'входы: Ν, Χ 'выходы: X 'локальные переменные: I, IDX, J, LARGE FOR I = NT0 2STEP-1 'поместить наибольшее число от Х(1) до Х(1) в LARGE=X(1) IDX=1 FOR J=2 TO I IFX(J)>LARGETHEN LARGE=X(J): IDX=J NEXT J X(IDX)=X(I) X(I) «LARGE: 'поместить LARGE в позицию I NEXT I RETURN 'конец подпрограммы LARGE, а его 'индекс в IDX 446
Эта сортировка известна также как сортировка посредством приоритетной очереди. Анализ сортировки посредством простого выбора достаточно очевиден. На первом просмотре делается η—1 сравнений, на втором просмотре делается η—2 сравнений и т. д. Следовательно, всего имеется (η-1) + (η-2) + (η-3)+...+1=η*(η-1)/2 сравнений, что составляет 0(п2). Число транспозиций всегда равно N—1 (если только не будет добавлена некоторая проверка, чтобы предотвратить транспозицию элемента с самим собой). Требуется мало дополнительной памяти (за исключением того, чтобы хранить несколько временных переменных). Данная сортировка может быть, следовательно, характеризована как имеющая порядок 0(п2), хотя она быстрее, чем сортировка «методом пузырька». Не наблюдается никакого улучшения, если входной файл является полностью отсортированным или неотсортированным, поскольку проверки выполняются до конца независимо от характера элементов в файле. Несмотря на тот факт, что эту сортировку просто запрограммировать, маловероятно, чтобы сортировка посредством простого выбора использовалась для каких-либо файлов, за исключением только тех, у которых мало значение п. Сортировка с использованием бинарных деревьев В оставшейся части данного раздела мы разберем несколько- сортировок посредством выбора, которые используют бинарные деревья. До этого, однако, проанализируем сортировку с использованием бинарного дерева, приведенную в разд. 6.1. Читателю- рекомендуется еще раз просмотреть эту сортировку, прежде чем двигаться дальше. Данный метод состоит в просмотре каждого элемента входного файла и помещении элемента в соответствующую позицию в некотором бинарном дереве. Для того чтобы найти соответствующую позицию некоторого элемента у, в каждом узле происходит выбор левой или правой ветви в зависимости от того,. у меньше, чем элемент в этом узле, либо больше или равен ему. Когда каждый входной элемент находится на своем соответствующем месте в дереве, отсортированный файл может быть получен при помощи просмотра этого дерева в симметричном порядке. Мы приводим алгоритм этой сортировки, модифицируя его для того, чтобы он функционировал правильно в том случае,, когда входной массив уже существует. Перевод этого алгоритма в некоторую программу на языке Бейсик является очевидным: 'установить первый элемент в качестве корневого tree=maketree (x (1)) 447
повторить для каждого последующего элемента for i=2 to n y=x(i) q=tree p = q 'пройти вниз по дереву до тех пор, пока не будет достигнут какой-либо лист while p< >null do q=p if у < info (ρ) then ρ=left (ρ) else ρ — right (ρ) endif endwhile if y<info(q) then setleft(q,y) else setright(q,y) endif next i 'дерево построено, организуйте прохождение его в симметричном порядке 'intrav(tree) Для того чтобы преобразовать вышеприведенный алгоритм в некоторую подпрограмму сортировки массива, необходимо преобразовать процедуру intrav таким образом, чтобы прохождение некоторого узла означало перемещение содержимого этого узла в следующую позицию первоначального массива. Относительная эффективность этого метода зависит от первоначального порядка расположения данных. Если первоначальный массив полностью отсортирован (или отсортирован в обратном порядке), то результирующее дерево представляется в виде некоторой последовательности только из правых (или левых) связей, как это показано на рис. 8.3.1. В этом случае Первоначальные данные 4 8 12 17 26 Число сравнений: 14 а Первоначальные данные 26 17 12 8 4 Число сравнении: /4 6 Рис. 8.3.1. 448
вставка первого узла совсем не требует сравнений, второй узел требует два сравнения, третий узел — три сравнения и т. д. Таким образом, общее число сравнений составляет 2+3+.. .+п=п* (п+1)/2— 1 что представляет собой 0(п2). С другой стороны, если данные в первоначальном массиве организованы так, что примерно половина чисел, следующих за любым заданным числом а в этом массиве, меньше, чем а, и половина больше, чем а, то получаются в результате деревья, похожие на те, которые представлены на рис. 8.3.2. В этом слу- Переоначальные данные Первоначальные данные ' 12 8 17 4 26 17 8 12 4 26 Число сравнений: 10 Число сравнений: 10 а 6 Рис. 8.3.2. чае глубина результирующего бинарного дерева является наименьшим числом d, которое больше или равно log2(n+l)—1. Число узлов на любом уровне 1 (за исключением, возможно, последнего) составляет 21, а число сравнений, которые необходимо выполнить для того, чтобы поместить некоторый узел на уровень 1 (за исключением того случая, когда 1=0), равно 1+1. Таким образом, общее число сравнений заключено между d + S^O+l) и 2210+1). 1=1 1=1 Может быть показано, что результирующие суммы имеют порядок O(nlogn) (читателям, склонным к математике, может быть интересно доказать этот факт в качестве примера). Конечно, когда дерево уже создано, на прохождение его тратится время. (Отметим, что, если дерево является прошитым в момент его создания, время его прохождения сильно уменьшается.) Эта сортировка требует, чтобы для каждого элемента массива был зарезервирован один узел дерева. В зависимости от метода, используемого для реализации такого дерева, для указателей и нитей дерева, если они имеются, может понадобиться пространство. 449
Сортировка методом «турнира с выбыванием» Следующая сортировка с использованием дерева, которую мы рассмотрим, часто называется сортировкой методом «турнира с выбыванием», поскольку ее действия отражают процесс разыгрывания некоторого турнира, где его участники состязаются друг с другом для определения наилучшего игрока. (Эта сортировка также называется сортировкой посредством выбора из дерева.) Рассмотрим некоторый турнир, в котором определяемая наилучший игрок из такого набора игроков: Эд, Гейл, Кейс, Джордж, Джек, Пэт, Барбара, Фрэнк. Результаты турнира можно представить некоторым бинарным деревом, как это сделано на рис. 8.3.3. Каждый лист дерева представляет некоторого игрока в турнире. Каждый узел, не являющийся листом, представляет результаты некоторой встречи между игроками, представленными двумя сыновьями этого листа. Из рис. 8.3.3, α ясно, что Гейл является чемпионом этого турнира. Но представим, что желательно также определить игрока, занявшего второе место. Пэт не обязательно занимает второе место, несмотря на то что она встречалась с Гейл во время турнира. Для того чтобы определить игрока, занявшего второе место, Кейсу (который проиграл Гейл в четвертьфинале) потребовалось бы сыграть с Джорджем (который проиграл Гейл в полуфинале), а победителю этой встречи — сыграть с Пэт. Давайте обозначим некоторого игрока, объявленного победителем, звездочкой в узле с листом, соответствующим этому игроку. Ясно, что если некоторый узел помечен звездочкой, то соответствующий игрок не участвует в дальнейших соревнованиях. Если у некоторого узла оба его сына имеют звездочки, то эти сыновья представляют игроков, которые завершили тур· нир, и, следовательно, узел, являющийся отцом, также помечается звездочкой и больше не участвует в дальнейших соревнованиях. Если у некоторого узла только один сын помечен звездочкой, то игрок, представляющий другого сына, продвигается вверх в узел, являющийся отцом. Например, когда лист, содержащий Гейл на рис. 8.3.3, а, помечен звездочкой, то имя Кейс перемещается вверх, чтобы заменить Гейл в качестве отца этого листа. Турнир затем переигрывается с этой точки, где Джордж играет с Кейсом (Джордж побеждает) и Джордж играет с Пэт (Пэт побеждает). Так получается дерево, представленное на рис. 8.3.3,6. Пэт в самом деле является игроком, занявшим второе место. Этот процесс может быть продолжен (на рис. 8.3.3, β показано, что Джордж занимает третье место) до тех пор, пока все узлы дерева не будут помечены звездочками. Этот же самый метод используется в сортировке методом «турнира с выбыванием». Каждому первоначальному элементу приписывается некоторый узел с листом в некотором бинарном дереве. Это дерево строится снизу вверх от узлов с листьями 450
Рис. 8.3.3. Турнир. до корня следующим образом. Выберем два узла с листьями и положим, что они являются сыновьями некоторого заново созданного узла, представляющего отца. Содержимое узла, являющегося отцом, устанавливается равным наибольшему из двух 451
чисел, представляющих его сыновей. Этот процесс повторяется до тех пор, пока не останется один лист или их совсем не останется. Если останется один лист, то этот узел надо переместить вверх на следующий уровень. Теперь этот процесс надо повторить с заново созданными узлами, представляющими отцов: (плюс с тем оозможным узлом, у которого не было партнера на предыдущем уровне), до тех пор, пока самое большое число не будет помещено в узел, являющийся корнем бинарного дерева, чьи листья содержат все числа из первоначального файла. Содержимое этого узла, являющегося корнем, может быть за* тем выведено как самый большой элемент. Узел, являющийся листом и содержащий значение в корне, затем заменяется на некоторое число, которое меньше, чем любое число в первоначальном файле (это соответствует тому, что оно помечено звездочкой). Содержимое всех его предков затем повторно вычисляется от листьев к корню. Этот процесс повторяется до тех пор, пока не будут выведены все первоначальные элементы. На рис. 8.3.4, α показано первоначальное дерево для файла, представленного в разд. 8.2: 25 57 48 37 12 92 86 33 После того как выводится число 92, это дерево трансформируется в дерево, представленное на рис. 8.3.4,6, где число 92 заменяется на —1, а 86 передвигается вверх в позицию корня. Отметим, что необходимо повторно вычислять содержимое трех узлов, которые были предками первоначального узла с листом, содержащим число 92. На рис. 8.3.4, в показано это дерево после того, как число 57 переместилось в корень. Отметим, что» в этом примере в качестве наименьшего значения используется число —1, поскольку все сортируемые числа положительны. Читателю предлагается завершить данный процесс, пока не будут выведены все элементы первоначального файла. Прежде чем писать программу для реализации сортировки методом «турнира с выбыванием», мы должны решить, как представлять это дерево на языке Бейсик. Может быть использовано представление в виде массива из разд. 6.2. Однако эффективность программы может быть увеличена при помощи использования того представления, при котором некоторое почти полное бинарное дерево представляется как массив. При этом представлении, если индекс i ссылается на некоторый узел, то на left (i) ссылается индекс 2*i, на riqht(i) ссылается индекс 2*i+l, а на father(i) ссылается индекс int(i/2). Для того чтобы упростить программирование, будем использовать полное бинарное дерево. В таком дереве листьями являются только те узлы, которые расположены на максимальном уровне. Такое дерево с четырьмя листьями показано на рис. 8.3.4. В общем случае число листьев в полном бинарном дереве является некоторой степенью числа 2. Если первоначальный файл имеет раз- 452
Рис. 8.3.4. мер η, который не является степенью числа 2, то число листьев является наименьшей степенью числа 2, большей, чем п, а в лишние листья при инициализации устанавливается число —1. Второй вопрос состоит в том, каким должно быть содержимое узлов дерева. Предположим, что мы допустим, чтобы узлы дерева содержали реальные элементы, которые должны быть отсортированы, как на рис. 8.3.4. Затем, когда произойдет вывод корня, узел с листом, соответствующий этому корневому элементу, должен быть заменен на некоторое очень маленькое число, и содержимое всех его предков должно быть переупорядочено. Но для того, чтобы локализовать узел с листом, соот- 453
ветствующий корню, имея только данный корень, необходимо пройти от корня вниз к листу, делая сравнения на каждом уровне. Более того, этот процесс должен быть повторен для каждого нового корневого узла. Если бы можно было непосредственно от корня перейти к листу, который соответствует его содержимому, то это было бы определенно более эффективно. Поэтому мы строим полное бинарное дерево следующим образом. Каждый лист будет содержать некоторый элемент первоначального массива х. Каждый узел, не являющийся листом, будет содержать индекс узла с листом, соответствующего тому элементу массива, который представляет данный узел, не являющийся листом. Если содержимое tree(i) некоторого узла с листом i перемещается вверх в некоторый узел j, не являющийся листом, то tree(j) устанавливается равным i (индексу в дереве узла с листом, соответствующего данному элементу), а не tree(i) (не сам элемент). Содержимое некоторого узла, не являющегося листом (которое является индексом соответствующего узла с листом), перемещается впоследствии вверх по дереву непосредственно. Так, если i является некоторым узлом •с листом, то tree(i) содержит реальный элемент, который этот узел i представляет. Если i является некоторым узлом, не являющимся листом, то tree(i) ссылается на индекс некоторого узла с листом и, следовательно, tree (tree (i)) ссылается на реальный элемент, который представляет узел i. Например, массив tree, представляющий дерево, показанное на рис. 8.3.4, а, инициализируется следующим образом (узлы с 8-го до 15-го являются листьями, узлы с 1-го по 7-й не являются листьями): i 1 2 3 4 5 С 7 8 9 10 11 12 13 14 15 tree(i) 13 9 13 9 10 13 14 25 57 48 37 12 92 86 33 Мы теперь можем запрограммировать подпрограмму этой сортировки следующим образом (предполагая, что п>1): 454
5000 'подпрограмма tournament 5010 'входы: Ν, Χ 5020 'выходы: X 5030 'локальные переменные: I, К, SIZE, SMALL, TREE 5040 SIZE=1 5050 SMALL = — 9999 5060 IF SIZE<N THEN SIZE=SIZE*2: GOTO 5060 5070 'SIZE является числом листьев, необходимых в полном бинарномг 'дереве* 5080 'число узлов составляет 2*SIZE—1 5090 'предполагаем, что массив TREE имеет размерность, достаточную^ 'чтобы вместить по крайней мере 2*SIZE—1 5100 GOSUB 6000: 'подпрограмма initialize 5110 'initialize создает начальное дерево так, как это описано выше 5120 'теперь после того, как дерево построено, повторяем операцию перемещения элемента, представленного корнем, в следующую позицию* 'с меньшим индексом в массиве X и переупорядочиванием дерева*. 5130 FOR K=N TO 2 STEP—1 5140 I = TREE(1) 'I является индексом узла с листом, соответствующего корню 5150 X(K)=TREE(I) 'поместить элемент, на который ссылается корень, в позицию К 5160 TREE(I) = SMALL 5170 GOSUB 7000: 'подпрограмма readjust 'переупорядочивает дерево в связи с но- 'вым содержимым TREE(I) 5180 NEXT К 5190 X(1)=TREE(TREE(1)) 5200 RETURN 5210 'конец подпрограммы Теперь приведем подпрограммы initialize и readjust (отметим, что уровень, непосредственно находящийся над листьями, должен обрабатываться иначе, чем другие уровни): 6000 'подпрограмма initialize 6010 'входы: N, SIZE, SMALL, X 6020 'выходы: TREE 6030 'локальные переменные: J, К 6040 'инициализируются листья дерева, соответствующие элементам мас- 'сива 6050 FOR J= 1 ТО N 6060 TREE(SIZE+J-1)=X(J) 6070 NEXT J 608Ό 'инициализация оставшихся листьев 6090 FOR J= SIZE+N TO 2*SIZE- 1 6100 TREE (J) = SMALL 6110 NEXT J 6120 'вычисление верхних уровней дерева 6130 'уровень, непосредственно находящийся над листьями, обрабатывается отдельна 6140 FOR J=SIZE TO 2*SIZE-1 STEP 2 6150 IF TREE(J)>=TREE(J+1) THEN TREE (INT (J/2)) = J ELSE TREE(INT(J/2))=J+1 6160 NEXT J 6170 'вычисление оставшихся уровней 6180 K=INT(SIZE/2) 6190 IF K<= 1 THEN RETURN 6200 FOR J=K TO 2*K- 1 STEP 2 6210 IF TREE(TREE(J))> = TREE(TREE(J+1)) THEN TREE(INT(J/2))=TREE(J) ELSE TREE (INT (J/2) )= TREE (J+l) 455
€220 NEXT J €230 K=INT(K/2) 6240 GOTO 6190 6250 'конец подпрограммы 7000 'подпрограмма readjust 7010 'входы: I 7020 'выходы: TREE 7030 'локальные переменные: J 7040 'теперь, когда TREE(I) имеет некоторое новое значение (SMALL), 'мы переупорядочиваем всех его предков 7050 'настроить узел, являющийся отцом 7060 IF I'NT(I/2) = I/2 THEN TREE(INT(I/2)) = I+1 ELSE TREE (INT (1/2)) =1-1 7070 'продвижение к корню 7080 I = INT(I/2) 7090 IF I< = 1 THEN RETURN 7100 'установить в J брата I 7110 IF INT(I/2) = I/2 THEN J=I+1 ELSE J=I-1 7120 IF TREE(TREE(I))>TREE(TREE(J)) THEN TREE(INT(I/2))=TREE(I) ELSE TREE(INT(I/2))=TREE(J) 7130 I = INT(I/2) 7140 GOTO 7090 7150 'конец подпрограммы Измерение временных затрат и требований на пространство для этой сортировки является очевидным. Заметим, что после того, как первоначальное дерево было создано и был выведен корень, требуется d сравнений, для того чтобы переупорядочить дерево и переместить некоторый новый элемент в позицию корня, где d является высотой данного дерева. Поскольку d примерно равно log2(n+l) и для дерева должно быть сделано η—1 настроек, то число сравнений составляет приблизительно (п—1) log2(n+l), что имеет порядок O(nlogn). Конечно, сравнения делаются при создании первоначального дерева, но число таких сравнений равно О(п), и поэтому слагаемое O(nlogn) доминирует над ним. Требование на пространство помимо временных переменных составляет 2*size—1 единиц памяти, зарезервированных для массива tree, где size является наименьшей степенью числа 2, которая больше или равна п. Поскольку в этой программе мы придерживались полного бинарного дерева, то может пропадать много пространства, если, например, значение η равно 33 или 129. Конечно, если используется связанная реализация дерева, то для связей требуется дополнительное пространство. Пирамидальная сортировка Хотя вышеприведенная программа кажется относительно эффективной во всех случаях, она имеет один серьезный недостаток, который очень просто исправить. Верхние уровни дерева содержат указатели, а действительные данные хранятся только 456
на самом нижнем уровне. Побочным эффектом этого является то, что во многих узлах на нескольких уровнях дерева содержится повторяющаяся информация. Из-за этого затрачивается значительная работа по перемещению какого-либо элемента от листа к корню. Большая часть этой работы не является необходимой на более поздних стадиях сортировки, когда большинство листьев (и косвенно многие из верхних уровней) содержит значение SMALL, приводя к тому, что делаются ненужные сравнения. Этот недостаток может быть исправлен при помощи «пирамидальной сортировки». В этой сортировке для каждого элемента первоначального файла резервируется только один узел. Это приводит к исключению большого объема пространства, распределяемого в сортировке методом «турнира с выбыванием», и исключению излишних сравнений на последних стадиях этой сортировки. В действительности первоначальный массив χ используется в пирамидальной сортировке как некоторое рабочее пространство, так что дополнительное пространство требуется только для хранения временных переменных. Определим пирамиду размером η как некоторое почти полное бинарное дерево с η узлами, такое что содержимое каждого узла меньше или равно содержимому его отца. Если для реализации почти полного бинарного дерева используется массив, как было сделано при реализации сортировки методом «турнира с выбыванием», то это условие сводится к такому неравенству для всех j от 1 до п: info(j) ^info(k) для k=int(j/2) Из этого определения пирамиды ясно, что корень дерева (или первый элемент массива) является наибольшим элементом в пирамиде. Предполагая, что подпрограмма createheap(i) создает некоторую пирамиду размером i, состоящую из первых i элементов массива х, метод сортировки мог бы быть реализовав следующим образом: for i=n to 2 step—1 createheap(i) поменять местами х(1) и x(i) next i Как мы увидим, однако, нет необходимости на каждой итерации создавать заново всю пирамиду. Мы можем переупорядочить пирамиду, созданную на предыдущей итерации, так, что она останется пирамидой даже после взаимной транспозиции двух элементов. Таким образом, пирамидальная сортировка имеет следующий алгоритм: createheap(n) for i=n to 2 step—1 поменять местами х(1) и x(i) 457
создать пирамиду псгрядка i—1 при помощи переупорядочивания позиции х(1) next i Мы должны рассмотреть две задачи: как создавать первоначальную пирамиду и как переупорядочивать промежуточные пирамиды? Для того чтобы создать первоначальную пирамиду, начнем с пирамиды размером 1, состоящей только из элемента х(1), и попытаемся создать некоторую пирамиду размером 2, состоящую из элементов х(1) и х(2). Это может быть сделано совсем просто: при помощи транспозиции элементов χ (2) и х(1), если х(1) меньше или равен χ(2). В общем случае, для того чтобы создать некоторую пирамиду размером i при помощи вставления узла i в некоторую существующую пирамиду размером i—1, надо сравнить узел i с его отцом. Если узел i больше, то надо поменять местами эти два элемента, a i изменить так, чтобы оно указывало на отца. Этот процесс надо повторять до тех пор, пока содержимое отца узла i не станет больше или равно содержимому узла i или пока i не достигнет корня пирамиды. Таким образом, алгоритм создания некоторой пирамиды порядка К может быть представлен следующим образом: for node=2 to К 'вставить χ (node) в пирамид\ i = node j = int(i/2) 'j является отцом i while (i>l) and (x(j)<-=x(i)) do поменять местами x(i) и x(j) i = j 'продвижение вверх по дереру j = int(i/2) 'j является отцом i endwhile next node Для того чтобы решить вторую задачу — нахождение в дереве нужного места для х(1), которое удовлетворяет требованиям пирамиды (за исключением корня), — надо установить 1 в i и последовательно заменять содержимое большего из двух его сыновей, пока его содержимое не станет больше, чем содержимое обоих его сыновей, и переустановить i так, чтобы оно указывало на большего сына. Этот алгоритм переупорядочивания пирамиды порядка К может быть представлен следующим образом: 'вычислить большего из двух сыновей для i и поместить его в j j = 2 If (K> = 3) and (x(3)>x(2)) then j = 3 endif while (j< = K) and (x(j)>x(i)) do поменять местами x(i) и x(j) i = j 'установить в j большего из сыновей \ i = 2*i if (j+K = K) and (x(j+l)>x(j)) 458
then j = j + l endif endwhile В этот момент мы должны отметить, что последний алгоритм не может быть реализован непосредственно так, как он приведен. Если условие j< = K в цикле while не выполняется, то последующая оценка x(j)>x(i) может дать в результате то, что x(j) выйдет за границы массива. Таким образом, реализация составного условия в цикле while должна выполняться в два шага. Аналогичным образом последний оператор if придется реализовать так: и i+κ-κ then if x(j + l)>x(i) then j = j —1— I endif endif потому что мы должны опять гарантировать, что ссылки на χ (j Ч-1) и x(j) находятся внутри границ массива. Упущение при рассмотрении таких возможностей является частым источником ошибок программирования. На рис. 8.3.5 показано создание пирамиды размером 8 из. такого первоначального файла: 25 57 48 37 12 92 86 33 Штриховые линии на этом рисунке указывают на то, чта два элемента поменялись местами. На рис. 8.3.6 показана настройка пирамиды по мере того, как элемент х(1) перемещается в позицию в первоначальном файле, соответствующую его значению, и до тех пор, пока не будут обработаны все элементы пирамиды. Отметим, что после того, как некоторый элемент был «удален» из пирамиды, он сохраняет свою позицию в массиве. Этот элемент просто игнорируется при дальнейшей обработке. Отметим также, что преобразования, показанные на рис. 8.3.6, иллюстрируют некото^ рый турнир, в котором после того, как элемент вставляется в некоторый узел, являющийся отцом, его сыновья, которые располагаются ниже, продвигаются вверх по дереву, для тога чтобы занять свои позиции. Это приводит к исключению избыточных узлов и избыточных проверок сортировки методом «турнира с выбыванием». Ниже в программе мы приводим реализацию пирамидальной сортировки. Операторы этой программы отражают описание, приведенное выше, за исключением того, что не все требуемые транспозиции выполняются сразу же. Значение, для которого имеется правильная позиция, хранится в некоторой временной переменной у. Продвижения вверх или вниз по дереву выполняются при помощи изменения указателей. 459
Рис. 8.3.5. Создание пирамиды размером 8. Приведем программу, реализующую пирамидальную сортировку. Предполагается что N больше или равно 3. 5000 'подпрограмма heap 5010 'входы: Ν, Χ 5020 'выходы: X 5030 'локальные переменные: I, J, К, Υ 5040 'создание первоначальной пирамиды 5050 FORK=2TON 5060 'вставить Х(К) в существующую пирамиду размером К—1 5070 1=К 5080 Y=X(K) 5090 J=INT (1/2): 'J является отцом I 5100 IF J< = 0 THEN GOTO 5160 5110 IF Y<-X(J) THEN GOTO 5160 5120 X(I)=X(J) 5130 I=J 5140 J-INT (1/2) 5150 GOTO 5100 5160 X<I)=Y 460
Рис. 8.3.6. Настройка пирамиды. ^ — первоначальное дерево; б — элементы х(1) и χ(8) меняются местами; в — переупорядочивается пирамида с элементами от х(1) до х(7); г — эле- •менты х(1) и х(7) меняются местами; д — переупорядочивается пирамида с элементами от х(1) до х(6); е — элементы х(1) и χ (6) меняются местами; ж — переупорядочивается пирамида с элементами от х(1) до χ(5); з — элементы х(1) и χ (5) меняются местами; и — переупорядочивается пирамида с элементами от х(1) до χ(4); к — элементы х(1) и χ(4) меняются местами; л — переупорядочивается пирамида с элементами от х(1) до х(3); м — элементы х(1) и х(3) меняются местами; н — переупорядочивается пирамида «с элементами от х.(1) до χ (2); о — элементы х(1) и χ (2) меняются местами. Массив отсортирован.
". v-y о Рис. 8.3.6 (продолжение)
5170 NEXT К 5180 'мы удаляем ХП) и помещаем его в массиве в позицию, соответствующую его значению; затем настраиваем пирамиду 5190 FOR K=N TO 2 STEP-1 5200 Y=X(K) 5210 Х(К)=Х(1) 5220 'переупорядочивание пирамиды порядка К—1; перемещение Υ 'в низ пирамиды на место, соответствующее его значению 5230 1=1 5240 J=2 5250 IF (K-l> = 3) AND (X(3)>X(2)) THEN J=3 5260 'J является большим сыном I в пирамиде размером К— 1 5270 IF J>K-1 THEN GOTO 5340 5280 IF Χ(J)< = Υ THEN GOTO 5340 5290 X(I)=X(J) 5300 I=J 5310 J=2*J 5320 IF J+K = K-1 THEN IF X(J+1)>X(J) THEN J=J+1 5330 GOTO 5260 5340 X(I)=Y 5360 NEXT К 5360 RETURN 5370 'конец подпрограммы Анализируя пирамидальную сортировку, отметим, что полное бинарное дерево с η узлами (где η на 1 меньше, чем некоторая степень числа 2) имеет log2(n+l) уровней. Таким образом, если бы каждый элемент в массиве был листом, что потребовало бы его протаскивания через все дерево и при создании пирамиды, и при ее настройке, то данная сортировка все равно была бы порядка O(nlogn). Однако очевидно, что не каждый элемент должен пройти через все дерево. Так, хотя данная сортировка имеет порядок O(nlogn), постоянные множители не такие большие, как для сортировки методом «турнира с выбыванием». Наихудший случай для пирамидальной сортировки имеет порядок O(nlogn), но это не очень эффективно для малых значений п. (Почему?) Требование на пространство для пирамидальной сортировки (помимо индексов массива) составляет только одну дополнительную запись (Y) для временного хранения записи в связи с переключением при условии, что для почти полного бинарного дерева используется реализация в виде массива. Упражнения 1. Объясните, почему сортировка посредством простого выбора более эффективна, чем сортировка «методом пузырька». 2. Рассмотрим следующую сортировку методом квадратичного выбора. Разделим η элементов в файле на in групп по Уп элементов в каждой группе. Найдем наибольший элемент в каждой группе и поместим его в некоторый вспомогательный массив. Найдем наибольший элемент в этом вспомогательном массиве. Этот элемент является наибольшим элементом в данном файле. Затем заменим этот элемент в массиве на следующий по величине элемент в той группе, из которой он поступил. Снова найдем наибольший 463
элемент во вспомогательном массиве. Этот элемент — второй по величине в данном файле. Повторим этот процесс до тех пор, пока файл не будет отсортирован. Напишите программу на языке Бейсик, которая как можно более эффективно реализует сортировку методом квадратичного выбора. 3. Перепишите сортировку методом «турнира с выбыванием» из этого раздела, используя для хранения бинарного дерева связанный массив. 4. Модифицируйте подпрограммы сортировки методом «турнира с вы* быванием» так, чтобы и узлы, являющиеся листьями, и узлы, не являющиеся листьями, содержали реальные элементы первоначального файла. Когда организуется вывод содержимого корня, перемещайтесь вниз по дереву, чтобы найти тот лист, чьи предки должны быть модифицированы. 5. Модифицируйте подпрограмму readjust в сортировке методом «турнира с выбыванием» так, чтобы, когда в содержимое некоторого листа устанавливалось значение small, содержимое его брата перемещалось вверх по дереву. (Отметим, что при этой модификации узел, не являющийся листом, может содержать индекс некоторого узла, также не являющегося листом; например, массив tree для дерева рис. 8.3.4, в был бы следующим: i 1 2 3 4 5 б 7 8 9 10 11 12 13 14 15 tree(i) 9 9 7 9 10 12 33 25 57 48 37 12 -1 -1 33 Оба элемента — tree (6) и tree (7)—содержат реальные значения, a tree(3> содержит индекс некоторого узла, не являющегося листом.) Почему этот метод более эффективен? 6. Модифицируйте сортировку методом «турнира с выбыванием» следующим образом. Когда создается первоначальное дерево и содержимое некоторого узла, являющегося листом, перемещается вверх, содержимое данного· листа сразу же изменяется на значение small. Когда содержимое некоторого узла, не являющегося листом, перемещается вверх, победитель среди его двух сыновей перемещается вверх, чтобы занять его место. Каждый раз,, когда выводится корень дерева, переместите вверх его самого большого сына,, затем переместите вверх самого большого сына этого сына и т. д. до тех пор, пока не будет перемещено вверх значение small. 7. Используйте сортировку методом «турнира с выбыванием» для слияния η входных файлов, каждый из которых отсортирован в возрастающем порядке, в один выходной файл следующим образом. Дерево строится так^ что ключ, представляющий каждый узел, является наименьшим из ключей его двух сыновей. Каждый лист обозначается как некоторая входная область для одного файла. Вспомогательная подпрограмма inp(i) читает следующее входное значение из i-ro входного файла в соответствующий лист. Когда все элементы файла 1 будут введены, inp(i) выдает некоторое значение, кото· рое больше, чем любое значение во всех входных полях. Вспомогательная подпрограмма writeroot выводит элемент из корня дерева в выходной файл. Каждый узел дерева содержит некоторый элемент и номер входного файла,. из которого этот элемент поступил. В каждый момент времени некоторый элемент содержится только в одном узле дерева. Когда некоторый элемент перемещается из некоторого узла nd к его отцу, другой элемент снизу пере· мещается в nd. Когда некоторый элемент перемещается вверх из листа,, происходит обращение к подпрограмме inp с соответствующим параметром для того, чтобы прочитать некоторое новое входное значение в данный лист. 8. Определим почти полное тернарное дерево как некоторое дерево,, в котором каждый узел имеет максимум трех сыновей, и такое, что его узлы могут быть переименованы от 1 до η так, что сыновья узла node(i) являются узлами node(3*i—1), node(3»i) и node(3»i+l). Определим тернарную пира* миду как некоторое почти полное тернарное дерево, в котором содержимое каждого узла больше или равно содержимому всех его потомков. Напишите программу сортировки, аналогичную пирамидальной сортировке, используя* тернарную пирамиду. 464
9. Напишите подпрограмму combine (x), входной информацией для ко- торой является некоторый массив х, где поддеревья с корнями в χ (2) и х(3> являются пирамидами, и которая модифицирует массив χ так, что он представляет одну-единственвую пирамиду. 8.4. СОРТИРОВКА ВСТАВКАМИ Простые вставки Сортировка вставками сортирует некоторое множество записей при помощи вставки записей в некоторый существующий отсортированный файл. Примером сортировки простыми вставками является следующая подпрограмма: 5000 'подпрограмма insert 5010 'входы: Ν, Χ 5020 'выходы: X 5030 'локальные переменные: I, К 5040 'первоначально х(1) можно себе представить как некоторый отсор* 'тированный файл, состоящий из одного элемента. После каждого 'прохождения следующего цикла элементы от х(1) до χ (к) упорядочены· 5050 FOR К=2 ТО N 5060 'вставить χ (к) в отсортированный файл 5070 Y=X(K) 5080 'переместить на одну позицию все числа, большие чем Υ 5090 FOR I = K-1 TO 1 STEP-1 5100 IF Y>=X(I) THEN GOTO 5130 5110 X(I+1)=X(I) 5120 NEXT I 5130 'вставить Υ в соответствующую позицию 5140 Χ(Ι+1)=Υ 5150 NEXT К 5160 RETURN 5170 'конец подпрограммы Если первоначальный файл отсортирован, то на каждом просмотре делается только одно сравнение, так что эта сортировка имеет порядок О(п). Если файл первоначально отсортирован в обратном порядке, то данная сортировка имеет порядок 0(п2), поскольку общее число сравнений равно (п—1) + (п—2) + ...'+3+2+1 = (п—1) *п/2 что составляет 0(п2). Однако сортировка простыми вставками' все же лучше, чем сортировка «методом пузырька». Чем ближе файл к отсортированному виду, тем более эффективной становится сортировка простыми вставками. Среднее число сравнений в сортировке простыми вставками (рассматривая все возможные перестановки во входном массиве) также составляет 0(п2). Требования на пространство для этой сортировки состоят только из одной временной переменной у. Скорость этой сортировки может быть несколько увеличена при помощи использования бинарного поиска (см. разд. 5.1, 5.2 и 9.1) для нахождения нужной позиции для χ (к) в отсортированном файле х(1), ..., х(к—1). Это уменьшает общее число» 465
сравнений с О (η2) до O(nlogn). Однако, даже если найдена правильная позиция i для (к) за O(logn) шагов, каждый из элементов x(i + l), ..., χ (к—1) должен быть перемещен на одну позицию. Эта последняя операция, выполненная η раз, требует 0(п2) замен. Поэтому, κ сожалению, метод бинарного поиска существенно не улучшает общие временные затраты этой сортировки. Другое улучшение сортировки простыми вставками может быть сделано при использовании вставок в список. В этом методе имеется массив указателей link, по одному указателю на каждый элемент первоначального массива. Первоначально iink(i)=i+l для l^i<n и link(n)=0. Таким образом, данный массив можно представить как некоторый линейный список, на который указывает некоторый внешний указатель first, первоначально равный 1. Для того чтобы вставить k-й элемент, организуется прохождение данного связанного списка до тех пор, пока не будет найдена нужная позиция для χ (к) или пока не будет достигнут конец списка. В этот момент χ (к) может быть вставлен в этот список просто при помощи настройки указателей списка без какого-либо сдвига элементов в массиве. Это уменьшает время, требуемое для вставки, но не время, необходимое для поиска нужной позиции. Требование на пространство также возрастает из-за дополнительного массива link. Число сравнений по-прежнему равно 0(п2), хотя число замен в массиве link составляет О(п). Читателю предлагается запрограммировать в качестве упражнения сортировку методом бинарных вставок и сортировку вставками в список. Сортировка Шелла Наиболее существенного улучшения можно достигнуть, используя сортировку Шелла (или сортировку с убывающим шагом), названную так в честь ее изобретателя. В этом методе в первоначальном файле сортируются отдельные подфайлы. Значение К называется шагом. Например, если К равно 5, то первым сортируется подфайл, состоящий из элементов х(1), х(6), х( 11), .... Пять подфайлов, каждый содержащий одну пятую элементов первоначального файла, сортируются аналогичным образом. Эти подфайлы следующие (их надо рассматривать по горизонтали): subfile 1 { subfile 2 I subfile 3 I subfile 4 l subfile 5 1 ► jc(1) ► jc(2) ► Jt(3) ► *(4) ► *(5) JC(6) *(7) *(8) jc(9) jc(10) jc(11) *(12) *(13) Jt(14) *(15) В общем случае, i-й элемент в j-м подфайле представляет собой x((i—l)*5+j). Если выбирается произвольный шаг К, то К под- 466
файлов разделяются так, что i-й элемент в j-м подфайле составляет x((i—l)*K+j). После того как первые К подфайлов будут отсортированы (обычно при помощи метода простых вставок), выбирается некоторое новое меньшее значение К и данный файл снова разделяется на новый набор подфайлов. Каждый из этих больших подфайлов сортируется, и данный процесс повторяется опять с еще меньшим значением К. В конце концов К принимает значение 1, и тогда сортируется подфайл, состоящий из всего файла. Последовательность уменьшающихся шагов фиксируется в начале всего процесса. Последним значением этой последовательности должна быть 1. Например, если первоначальный файл имеет вид 25 57 48 37 12 92 86 33 и выбрана последовательность шагов (5, 3, 1), то при каждой, итерации сортируются следующие подфайлы: Первая итерация (шаг=5) (х(1),х(6)) (х(2),х(7)) (х(3),х(8)) Вторая итерация (шаг = 3) (х(1),х(4),х(7)) (х(2),х(5),х(8)) (х(3),х(6)) Третья итерация (шаг=1) (х(1), х(2), х(3), х(4), х(5), х(6), х(7), х(8)) На рис. 8.4.1 показана работа сортировки Шелла на этом, файле примера. Линии под каждым массивом объединяют отдельные элементы в своих подфайлах. Каждый из подфайлов. сортируется при помощи сортировки простыми вставками. Приведем подпрограмму, реализующую сортировку Шелла (в дополнение к обычным параметрам X и N она требует некоторого массива INCRMNTS, содержащего последовательность убывающих шагов для сортировки, и переменной NUMINC — числа элементов в массиве INCRMNTS): 5000 'подпрограмма shell 5010 'входы: INCRMNTS, N, NUMINC, X 5020 'выходы: X 5030 'локальные переменные: I, J, K, SPAN 5040 FOR 1 = 1 TO NUMINC 505O SPAN = INCRMNTS (I): 'SPAN является значением шага 5060 FOR J = SPAN+I TO N 5070 'вставить элемент X (J) в позицию, соответствующую его 'значению в его подфайле 467
.Лервоначальный файл 25 57 48 37 12 92 86 33 Просмотр I, таг = 5 25 57 48 37 12 92 86 33 I J .Просмотр 2, uiaa~J Просмотр 3, таг = / 25 1 25 L L 57 L 12 L 33 L 33 _1_ 37 | 37 _1_ 12 1 , 48 92 92 J 86 ι 86 _l 48 -J 57 ^Отсортированный 12 25 33 37 48 57 86 92 файл Рис. 8.4.1. 5080 Y-X(J) 5090 FOR K=J-SPAN TO 1 STEP-SPAN 5100 IF Y>=X(K) THEN GOTO 5130 5110 X(K+SPAN)=X(K) 5120 NEXT К 5130 X(K+SPAN)=Y .5140 NEXT J 5150 NEXT I .5160 RETURN 5170 'конец подпрограммы Читатель должен быть уверен, что он может протрассировать действия этой программы на файле из примера, приведенного на рис. 8.4.1. Заметим, что на последней итерации, где переменная SPAN равна 1, данная сортировка сводится к сортировке простыми вставками. Идея, лежащая в основе сортировки Шелла, является простой. Мы уже отметили, что сортировка простыми вставками очень эффективна для файла, который почти упорядочен. Важно также понимать, что когда размер файла η мал, то сортировка порядка 0(п2) часто более эффективна, чем сортировка порядка O(nlogn). Причина этого заключается в том, что сортировку порядка 0(п2) в общем случае достаточно просто запрограммировать, и программа имеет мало других действий на каждом просмотре, помимо сравнений и обменов. Из-за этих -малых накладных расходов константа пропорциональности яв- 468
ляется достаточно небольшой. Сортировка порядка О (n log n) в общем случае является достаточно сложной и содержит большое число дополнительных операций на каждом просмотре для того, чтобы уменьшить работу на последующих просмотрах. Таким образом, ее константа пропорциональности больше. Когда η велико, то п2 существенно превосходит nlogn, так что константы пропорциональности не играют заметной роли в определении, какая сортировка быстрее. Однако когда η невелико, то п2 не намного больше nlogn, так что большая разница в этих константах часто приводит к тому, что сортировка с порядком 0(п2) быстрее. Поскольку первый используемый в сортировке Шелла шаг является большим, отдельные подфайлы являются достаточно малыми, так что сортировка простыми вставками над этими лодфайлами работает достаточно быстро. Сортировка каждого подфайла приводит к тому, что весь файл становится ближе к отсортированному виду. Таким образом, хотя последующие просмотры в сортировке Шелла используют шаги с меньшими значениями и, следовательно, имеют дело с большими подфай- лами, эти подфайлы уже почти отсортированы из-за действий на предыдущих просмотрах. Поэтому сортировки вставками для этих подфайлов также достаточно эффективны. В этой связи существенно отметить, что если некоторый файл является частично отсортированным с использованием шага К и затем сортируется частично с использованием шага j, то данный файл останется частично отсортированным по шагу К, т. е. последующие частичные сортировки не нарушают результатов предыдущих сортировок. Анализ эффективности сортировки Шелла математически достаточно сложен и выходит за пределы данной книги. Реальные временные затраты для некоторой конкретной сортировки зависят от числа элементов в массиве incrmnts и от значений элементов в этом массиве. Было показано, что порядок сортировки Шелла может быть аппроксимирован величиной 0(n(logn)2), если используется подходящая последовательность шагов. Одно требование, которое интуитивно ясно, состоит в том, что элементы в массиве incrmnts должны быть взаимно простыми числами (т.е. не иметь общих делителей, кроме 1). Это гарантирует нам, что последующие итерации перемешивают подфайлы так, что весь файл в самом деле является почти отсортированным, когда шаг равняется 1 на последней итерации. Сортировка с вычислением адреса В качестве последнего примера сортировки вставками рассмотрим следующий метод, называемый сортировкой с вычислением адреса (иногда она называется сортировкой методом хеширования). В этом методе к каждому ключу применяется не- 469
которая функция f. Результат этой функции определяет, в какой, из подфайлов будет помещена данная запись. Эта функция должна иметь то свойство, что если х<у, то f(x)<f(y). Такая функция называется функцией, сохраняющей порядок. Так, все записи в одном подфайле будут иметь ключи, которые меньше или равны ключам записей в другом подфайле. Элемент помещается в подфайл в правильной последовательности при помощи использования любого метода сортировки. Часто используется сортировка простыми вставками. После того как все элементы первоначального файла будут помещены в подфайлы,. данные подфайлы могут быть соединены для того, чтобы получить отсортированный результат. Рассмотрим опять файл примера: 25 57 48 37 12 92 86 33 Давайте создадим 10 подфайлов, один для каждой из 10 возможных первых цифр числа. Первоначально каждый из этих, подфайлов пуст. Объявляется некоторый массив из указателей f(9), который имеет нижнюю границу 0 и верхнюю границу 9, где f (i) указывает на первый элемент в файле, чья первая цифра равна i. (Большинство версий языка Бейсик присваивают* индексы в массиве, начиная с 0. В тех версиях языка Бейсик^ где это не так, необходимо описать массив f (10) и настраивать все ссылки по индексам, прибавляя 1 или используя f (10) вместо f(0).) После просмотра первого элемента (25) он помещается в файл, головой которого является f(2). Каждый из подфайлов строится как некоторый отсортированный связанный список элементов первоначального массива. После обработки каждого элемента из первоначального файла подфайлы выглядят так, как показано на рис. 8.4.2. Представим подпрограмму, реализующую сортировку с вычислением адреса. В данной подпрограмме предполагается наличие некоторого массива с двузначными цифрами и первая цифра каждого числа используется для помещения этого числа в некоторый подфайл. Данная подпрограмма использует подпрограмму place для того, чтобы вставить переменную Ζ в позицию, соответствующую ее назначению в упорядоченном списке LST: 5000 'подпрограмма addr 5010 'входы: Ν, Χ 5020 'выходы: X 5030 'локальные переменные: AVAIL, F, FIRST, I, INFO, J, LST, P, 'PTRNXT, Ζ 5040 DIM INFO(N), PTRNXT (N) 5050 DIM F(9) 'F(I) указывает на первый элемент в файле, чья пер- 'вая цифра равна I 5060 'инициализация имеющегося списка 5070 AVAIL=I 5080 FOR 1 = 1 TON-1 5090 PTRNXT (I) = 1+1 470
/(0) = null /О) /(2) /О) /(4) /(5) 12 null 25 null 33 48 null 57 null 37 null /(6) = null /(7) = null /(8) /(9) 86 null 92 null Рис. 8.4.2. Сортировка с вычислением адреса. 5100 5110 5120 5130 5140 5150 5160 5170 5180 5190 5200 5210 5220 5230 5240 NEXT I PTRNXT(N)=0 'инициализация указателей FOR 1 = 0 ТО 9 F(I)-0 NEXT I FOR 1 = 1 TON 'мы последовательно вставляем каждый элемент в его соответствующий подфайл, используя список вставок Z=X(I) FIRST=INT(Z/10): 'находим первую цифру некоторого двузначного числа 'поиск в связанном списке LST=F(FIRST) GOSUB 8000: 'подпрограмма place вставляет Ζ в позицию 'связанного списка, соответствующую его 'значению. На список ссылается переменная 'LST 'входы place: AVAIL, INFO, LST, PTRNXT, 'Ζ 'выходы place: AVAIL, PRTNXT и, возмож- Ήο, LST 471
5250 F(FIRST) =LST: 'этот оператор необходим в том случае, ког- 'да подпрограмма place модифицирует LST 5260 NEXT I 5270 'копируем числа назад в массив X 5280 1=0 5290 FOR J=0 TO 9 5300 P=F(J) 5310 IF P=0 THEN GOTO 5360 5320 1 = 1+1 5330 Χ (Ι) = INFO (Ρ) 5340 P = PTRNXT(P) 5350 GOTO 5310 5360 NEXT J 5370 RETURN 5380 'конец подпрограммы 8000 'подпрограмма place Требование на пространство со стороны сортировки с вычислением адреса составляет приблизительно 2п (оно используется для массивов INFO и PTRNXT) плюс некоторые узлы заголовка и временные переменные. Заметим, что если первоначальные данные задаются в форме некоторого связанного· списка, а не в виде последовательного массива, то нет необходимости использовать и массив X, и связанную структуру данных, состоящую из INFO и PTRNXT. Для того чтобы оценить временные затраты этой сортировки, отметим следующее. Если η первоначальных элементов приблизительно равномерно распределены по m подфайлам и значение n/m примерно равно 1, то время, затрачиваемое этой сортировкой, составляет около О(п), поскольку функция направляет каждый элемент в его соответствующий файл, а для размещения этого элемента внутри самого подфайла требуется немного дополнительной работы. С другой стороны, если значение n/m намного больше 1 или если первоначальный файл неравномерно распределен по m подфайлам, то для вставки элемента в его соответствующий подфайл требуется значительный: объем работы, и, следовательно, временные затраты находятся ближе к 0(п2). Упражнения 1. Сортировка двухпутевыми вставками является некоторой модификацией сортировки простыми вставками в следующем виде. Отдельно отводится некоторый выходной массив размером п. Этот выходной массив действует как некоторая циклическая структура, аналогичная структуре в разд. 4.1. Элемент х(1) помещается в средний элемент этого массива. Когда непрерывная группа элементов находится в этом массиве, место для нового элемента отводится при помощи сдвига всех элементов с меньшими значениями на один шаг влево или элементов с большими значениями на один шаг вправо. Выбор, в какую сторону выполнять сдвиг, зависит от того, какой 472
<из них приведет к меньшему количеству сдвигов. Напишите подпрограмму на языке Бейсик, реализующую этот метод. 2. Сортировка слиянием вставок выполняется следующим образом: Шаг 1: Для всех нечетных i от 1 до η—1 сравните x(i) и x(i+l). Поместите большее из них в следующую позицию некоторого массива large, я меньшее в следующую позицию некоторого массива small. Если η нечетно, то поместите х(п) в последнюю позицию массива small. (Массив large имеет размер int(n/2), массив small — размер int(n/2) или int(n/2) + l в зависимости от того, является ли η четным или нечетным.) Шаг 2: Выполните сортировку массива large, используя рекурсивно сортировку слиянием вставок. Когда некоторый элемент large (j) перемещается в large (к), элемент small (j) также перемещается в small (к). [В конце этого шага large (i)^ large (i+1) для всех i, меньших int(n/2), и small (i)^ large (i) для всех i, меньших или равных int(n/2).] Шаг 3: Скопируйте элемент small (1) и все элементы массива large в элементы от х(1) до x(int(n/2) + l). Шаг 4: Определите целое число num(i) как (21+1+(—1)1)/3. Начиная с i=l и продвигаясь с шагом 1, пока выполняется num(i)<int(n/2) + l» вставляйте по очереди элементы от small(num(i+1)) до smal,l(num(i) +1) в массив х, используя метод бинарных вставок. [Например, если п=20, то последовательные значения num следующие: пшп(1) = 1, пшп(2)=3, num(3)=5, num(4) = 11, что равно int(n/2) + l. Так, элементы массива small вставляются в следующем порядке: small (3), small (2); затем small1(5), small (4); затем small (10), small (9), small (8), small (7), small (6). В этом примере нет элемента small(11).] Напишите подпрограмму на языке Бейсик, чтобы реализовать этот метод. 3. Модифицируйте быструю сортировку из разд. 8.2 так, чтобы она использовала сортировку простыми вставками, когда размер подфайла меньше некоторого числа s. Определите при помощи экспериментов, какое значение s должно быть использовано для получения максимальной эффективности. 4. Докажите, что если файл отсортирован с использованием некоторого шага j в сортировке Шелла, то он останется частично отсортированным по этому шагу, даже если он будет частично отсортирован по другому шагу К. 5. Объясните, почему желательно выбирать все шаги в сортировке Шелла так, чтобы они были взаимно простыми числами. 6. Найдите число сравнений и обменов (в терминах размера файла п), выполненных каждым из методов сортировки, перечисленных далее для таких файлов: 1) отсортированный файл; 2) файл, который отсортирован в обратном порядке (т. е. от большего к меньшему); 3) файл, в котором элементы х(1), х(3), х(5), ... являются наименьшими элементами и располагаются в отсортированном порядке, а элементы χ(2), χ(4), χ(6), ... являются наибольшими элементами и располагаются в порядке, обратном отсортированному (т. е. х(1) является наименьшим числом, χ (2)—наибольшим числом, х(3)—следующим по величине за наименьшим числом, χ (4)—предыдущим ήο величине перед наибольшим числом и т. д.); 4) файл, в котором элементы от х(1) до x(int(n/2)) являются наименьшими элементами и отсортированы, а элементы от x(int(n/2) + l) до х(п) являются наибольшими и располагаются в порядке, обратном отсортированному; 5) файл, в котором элементы х(1), х(3), х(5), ... являются наименьшими элементами в отсортированном порядке и элементы χ(2), χ(4), χ(6), ... являются наибольшими элементами в отсортированном порядке. Используйте следующие методы сортировки: а) сортировка простыми вставками; б) сортировка вставками, использующая бинарный поиск; в) сортировка вставками в список; г) сортировка двухпутевыми вставками из упражнения 1; д) сортировка слиянием вставок из упражнения 2; е) сортировка Шелла, использующая шаги 2 и 1; ж) сортировка Шелла, использующая шаги 3, 2 и 1; з) сортировка Шелла, использующая шаги 8, 4, 2 и 1; и) сортировка Шелла, использующая шаги 7, 5, 3 и 1; к) сортировка с вычислением адреса, представленная в разделе. 473
7. При каких обстоятельствах вы бы рекомендовали использовать каждую из следующих сортировок перед другими сортировками? (а) Сортировка Шелла из этого раздела. (б) Пирамидальная сортировка из разд. 8.3. (в) Быстрая сортировка из разд. 8.2. 8. Определите, какая из следующих сортировок наиболее эффективная? (а) Сортировка простыми вставками из этого раздела. (б) Сортировка посредством простого выбора из разд. 8.3. (в) Сортировка «методом пузырька» из разд. 8.2. 8.5. СОРТИРОВКА СЛИЯНИЕМ И ПОРАЗРЯДНАЯ СОРТИРОВКА Сортировка слиянием Слияние является процессом объединения двух или более отсортированных файлов в некоторый третий отсортированный файл. Далее приводится пример некоторой подпрограммы, которая воспринимает два отсортированных массива А и В с количеством элементов соответственно AN и BN и объединяет их в некоторый третий массив, содержащий CN элементов: 5000 'подпрограмма mergearr 5010 'входы: Α, ΑΝ, Β, ΒΝ, CN 5020 'выходы: С 5030 'локальные переменные: APNT, BPNT, CPNT 5040 IF AN+BN>CN THEN PRINT «ГРАНИЦЫ МАССИВОВ НЕСОВМЕСТИМЫ»: STOP 5050 ΆΡΝΤ, BPNT и CPNT являются индикаторами того, насколько мы 'продвинулись соответственно по массивам А, В и С 5060 APNT=1 5070 BPNT=1 5080 CPNT=1 5090 IF APNT>AN OR BPNT>BN THEN GOTO 5130 5100 IF A(APNT)<B(BPNT) THEN С (CPNT) = A (APNT): APNT=APNT+I ELSE С (CPNT) =B (BPNT): BPNT=BPNT+1 5110 CPNT=CPNT+1 5120 GOTO 5090 5130 'копируем любые оставшиеся элементы из А в С 5140 IF APNT>AN THEN GOTO 5190 5150 С (CPNT) = A (APNT) 5160 CPNT = CPNT+1 5170 APNT=APNT+1 5180 GOTO 5130 5190 'копируем любые оставшиеся элементы из В в С 5200 IF BPNT>BN THEN GOTO 5250 5210 С (CPNT) =B (BPNT) 5220 CPNT=CPNT+1 5230 BPNT=BPNT+1 5240 GOTO 5190 5250 RETURN 5260 'конец подпрограммы Мы можем использовать этот метод для сортировки файла следующим образом. 474
Разделим файл на η подфайлов размером 1 и будем объединять соседние (необъединенные) пары файлов. Мы тогда будем иметь примерно п/2 файлов размером 2. Будем повторять этот процесс до тех пор, пока не останется только один файл размером п. На рис. 8.5.1 показано, как выполняется этот процесс на некотором файле примера. Каждый отдельный файл на рисунке заключен в скобки. Представим программу, реализующую описанную выше сортировку простым слиянием. Для хранения результатов слияния двух подмассивов массива X требуется некоторый вспомогательный массив XAUX размером N. Переменная SIZE содержит Первоначальный файл Лросчотр I Просмотр Ζ Просмотр 3 Рис. 8.5.1. Последовательные просмотры при сортировке слиянием. размер подмассивов, которые объединяются. Поскольку в любой момент времени два объединенных файла являются под- массивами массива X, то для указания этих файлов массива X, объединяемых вместе, требуются нижняя и верхняя границы. Переменные ALO и ΑΗΙ представляют нижнюю и верхнюю границы первого файла, а переменные BLO и ΒΗΙ — соответственно нижнюю и верхнюю границы второго файла. Переменные ΑΡΝΤ и ΒΡΝΤ используются для ссылки на элементы исходных файлов, которые объединяются, а переменная CPNT является индексом для файла XAUX. Эта подпрограмма имеет следующий вид: 5000 'подпрограмма msort 5010 'входы: Ν, Χ 5020 'выходы: X 5030 'локальные переменные: AHI, ALO, APNT, BHI, BLO, BPNT, CPNT, 'SIZE, XAUX 5040 'предполагаем наличие объявления DIM XAUX (N) 475 [25J [57] [48] [37] [12] [92] [86] [33] Υ Υ Υ Υ [25 57] [37 48] [12 92] [33 86} 25 37 48 57] [12 33 86 92] [12 25 33 37 48 57 86 92]
5050 SIZE=1 5060 IF SIZE> = N THEN GOTO 5480 5070 ALO=l 'инициализация нижней границы первого файла 5080 CPNT= l 'CPNT является индексом для вспомогательного 'массива ; 5090 IF ALO+SIZE>N THEN GOTO 5350: 'проверка, что остался 'только один подфайл 5100 'вычисление оставшихся индексов 5110 BLO=ALO+SIZE 5120 AHI = BLO-l 5130 IF BLO+SIZE-KN THEN BHI=AHI+SIZE ELSE BHI = N 5140 'продвигаемся через два подфайла 5150 APNT=ALO 5160 BPNT=BLO 5170 IF APNT>AHI OR BPNT>BHI THEN GOTO 5220 5180 'вводим меньшее значение в массив XAUX 5190 IF X(APNT)<=X(BPNT) THEN XAUX(CPNT)=X(APNT): APNT=APNT4-1 ELSE XAUX(CPNT)=X(BPNT): BPNT=BPNT+1 5200 CPNT=CPNT+1 5210 GOTO 5170 5220 'в этот момент один из двух подфайлов исчерпывается 'вставляем оставшуюся часть другого файла 5230 IF APNT>AHI THEN GOTO 5280 5240 XAUX (CPNT) = X (APNT) 5250 APNT=APNT+1 5260 CPNT=CPNT+1 5270 GOTO 5230 5280 IF BPNT>BHI THEN GOTO 5330 5290 XAUX (CPNT) = X (BPNT) 5300 BPNT=BPNT+1 5310 CPNT=CPNT+1 5320 GOTO 5280 5330 ALO=BHI+l: 'продвигаем ALO, чтобы начать следующую 'пару файлов 5340 GOTO 5090 5350 'копируем один оставшийся файл 5360 APNT=ALO 5370 IF CPNT>N THEN GOTO 5420 5380 XAUX (CPNT) = X (APNT) 5390 CPNT=CPNT4-1 5400 APNT=APNT+1 5410 GOTO 5370 5420 'настраиваем X и SIZE 5430 FOR APNT=1 TO N 5440 X (APNT) =XAUX (APNT) 5450 NEXT APNT 5460 SIZE=SIZE*2 5470 GOTO 5060 5480 RETURN 5490 'конец подпрограммы В описанной выше процедуре имеется один недостаток, который просто исправляется, если эта программа будет использоваться для практической сортировки больших массивов. Вместо того чтобы объединять каждый набор подфайлов во вспомогательный массив XAUX и затем повторно копировать массив XAUX в X, могут быть выполнены альтернативные объеди- 476
нения из X в XAUX и из XAUX в X. Эта модификация предоставляется читателю в качестве упражнения. Временные затраты этой сортировки составляют 0(nlogn)r поскольку очевидно, что имеется не более log2n просмотров·. Однако этой сортировке в действительности требуется некоторый вспомогательный массив XAUX, в котором могут храниться: объединенные файлы· Для вышеприведенной процедуры имеются две модификации, которые могут дать в результате более эффективную сортировку. Первая из них называется естественным слиянием, В сортировке простым слиянием все файлы имеют одинаковый размер (за исключением, возможно, последнего файла). Можно, однако, использовать любой порядок, который уже может существовать среди элементов, а подфайлы определить как самые длинные подмассивы из возрастающих элементов. Читателю предлагается написать такую подпрограмму в качестве упражнения. Вторая модификация использует связанное распределение вместо последовательного распределения. Потребность во втором массиве XAUX может быть исключена, если добавить к каждой записи одно-единственное поле указателя. Это может быть сделано при помощи явной связи входного и выходного* подфайлов. Эта модификация может применяться и для сортировки простым слиянием, и для сортировки естественным слиянием. Читателю предлагается реализовать эти модификация в упражнениях. Поразрядная сортировка Следующий метод сортировки, который мы рассмотрим, называется поразрядной сортировкой. Этот метод основан на свойстве значений действительных чисел в позиционном представлении сортируемых чисел. Например, число 235 в десятичной системе записывается так, что имеет 2 в позиции сотен, 3 в позиции десятков и 5 в позиции единиц. Большее из двух таких чисел, имеющих одинаковую длину, может быть определено» следующим образом. Надо начать со старшей цифры и продвигаться к младшим цифрам, пока соответствующие цифры двух чисел совпадают. Число с большей цифрой в первой позиции, в которой цифры этих двух чисел не совпадают, является большим из данных двух чисел. Конечно, если все цифры обоих чисел совпадают, то эти числа равны. Можно написать подпрограмму сортировки на основе приведенного выше метода. Например, при использовании десятичного основания числа могут быть отсортированы на 10 групп по их самой старшей цифре. (Для простоты мы предполагаем, что все числа имеют одинаковое число цифр, что при необходимости выполняется при помощи добавления незначащих лидирующих нулей.) Таким образом, каждый элемент в группе «0»· 477
меньше, чем любой элемент в группе «1», а все элементы этой группы меньше, чем любой элемент из группы «2» и т.д. Затем мы можем выполнять сортировку внутри отдельных групп по следующей значащей цифре. Этот процесс повторяется до тех пор, пока каждая подгруппа не будет разделена по самой младшей значащей цифре. В этот момент первоначальный файл будет отсортирован. (Заметим, что разделение подфайла на группы с одинаковой цифрой в некоторой заданной позиции аналогично операции rearrange в быстрой сортировке, в которой тюдфайл разделяется на две группы на основе сравнивания •с некоторым конкретным элементом.) Этот метод иногда называется поразрядной обменной сортировкой. Его программирование представляется читателю в виде упражнения. Теперь рассмотрим альтернативный метод к методу, приведенному выше. Из обсуждения вопроса ясно, что для постоянного деления файлов и распределения содержимого их на под- файлы по значению конкретных цифр требуется значительное число операций. Было бы определенно проще, если бы мы могли обрабатывать весь файл как единое целое, а не иметь дело со многими отдельными файлами. Предположим, что мы выполняем следующие действия над файлом для каждой цифры, начиная с самой младшей цифры и кончая самой старшей цифрой. Берем каждое число в том порядке, в котором оно появляется в файле, и помещаем его в одну из 10 очередей в зависимости от значений цифры, которая в данный момент обрабатывается. Затем восстанавливаем каждую очередь к виду первоначального файла, начиная с очереди чисел с цифрой 0 и кончая очередью чисел с цифрой 9. Когда эти действия будут выполнены для каждой цифры, начиная с самой младшей и кончая самой старшей, данный файл -будет отсортирован. Этот метод сортировки называется поразрядной сортировкой. Заметим, что при этой схеме сортировка сперва выполняется для младших значащих цифр. Таким образом, когда все числа будут отсортированы по некоторой старшей цифре, числа, которые имеют одинаковую цифру в этой позиции, но различные дифры в младших позициях, уже будут отсортированы по этой младшей позиции. Это πoзвoляef обрабатывать весь файл, не подразделяя его на подфайлы и не отслеживая, где начинается и кончается каждый подфайл. На рис. 8.5.2 показано действие этой сортировки над нашим файлом примера: 25 57 48 37 12 92 86 33 Читатель должен быть уверен, что может следовать за действиями этой сортировки, выполняющейся за два просмотра на рис. 8.5.2. Мы можем, следовательно, описать алгоритм вышеприведенной сортировки в таком виде: 478
Первоначальный файл 25 57 48 37 Очереди, организованные по M3U. 12 92 86 33 Начало queue (0) queue (1) queue (2) queue (3) queue (4) queue (5) queue (6) queue (7) <7ыеые(8) queue (9) 12 33 25 86 57 48 Конец 92 37 После первого просмотра 12 92 33 25 86 Очереди, организованные по СЗЦ 57 37 48 queue (0) queue (1) queue (2) <7ыеые (3) queue (4) <7ыеые (5) queue (6) <7иеие (7) <7ыеие(8) queue (9) Omcoptnupp- , „ ванный файл 12 Начало 12 25 33 48 57 Конец 37 86 92 25 33 37 48 57 86 92 Рис. 8.5.2. Иллюстрация работы поразрядной сортировки (МЗЦ — младшая значащая цифра; СЗЦ — старшая значащая цифра). for k=младшая значащая цифра to старшая значащая цифра for i = 1 to n y=x(i) 1* = к-я цифра числа у поместить у в конец очереди queue (j) next i for qu=0 to 9 поместить элементы очереди queue (qu) в следующую последовательную позицию массива χ next qu next k Представим теперь программу, которая реализует вышеупомянутую сортировку для чисел, имеющих m цифр. Для того· чтобы сэкономить значительный объем работы при обработке очередей (особенно в том шаге, где мы возвращаем элементы очереди в первоначальный файл), напишем данную программу* используя связанное распределение. Если в качестве первоначальной входной информации для этой программы задается не- 479
который массив, то он сперва преобразуется в некоторый связанный список. Если входная информация уже представлена в связанном формате, то этот шаг не нужен и, кроме того, будет сэкономлено пространство. Это та же самая ситуация, что и в подпрограмме addr (сортировка с вычислением адреса) в разд. 8.4. Как и в предыдущих программах, мы не делаем каких-либо внутренних обращений к подпрограммам, а выполняем все действия внутри программы. Мы опять используем нулевой элемент в массивах на языке Бейсик. 5000 'подпрограмма radix 5010 'входы: Μ, Ν. Χ 5020 выходы: X 5030 'локальные переменные: FIRST, FRNT, I, INFO, JTEMP, К, Ρ, 'PTRNXT, Q, REAR, Υ 5040 'предполагаем наличие глобальных объявлений DIM INFO (N), PTRNXT (N), FRNT (9), REAR (9) 5050 'FRNT(I) и REAR (I) определяют начало и конец 1-й очереди для 'всех значений I от 0 до 9 5060 'инициализация связанного списка 5070 FOR 1=1 ТО N-1 5080 INFO(I)=X(I) 5090 PTRNXT=I+1 5100 NEXT I 5110 INFO(N)=X(N) 5120 PTRNXT(N)=0 5130 FIRST=1: 'FIRST указывает на голову связанного списка 5140 'предполагаем, что мы имеем числа, имеющие Μ цифр 5150 FOR K=l ТОМ 5160 'инициализация очередей 5170 FOR 1 = 0 ТО 9 5180 REAR(I)=0 5190 FRNT(I)=0 5200 NEXT I 5210 'обрабатываем каждый элемент списка 5220 IF FIRST>0 THEN GOTO 5330 5230 P=FIRST 5240 FIRST=PTRNXT(FIRST) 5250 Y=INFO(P) 5260 'извлекаем k-ю цифру и помещаем ее в J 5270 JTEMP=INT(Y/10t(K-l)) 5280 J=JTEMP- 10*INT(JTEMP/10) 5290 Q=REAR(J) 5300 IF Q=0 THEN FRNT (J) =P ELSE PTRNXT (Q)= Ρ 5310 REAR (J) = Ρ 5320 GOTO 5220 5330 'В этот момент каждая запись находится в очереди, соответствующей значению ее цифры К. Мы теперь сформируем один 'список из всех элементов в очереди 5340 'найдем первый элемент 5350 FORJ=0TO9 5360 IF FRNT (J) < >0 THEN GOTO 5380 5370 NEXT J 5380 FIRST=FRNT(J) 5390 'связываем оставшиеся очереди 5400 IF J> = 9 THEN GOTO 5480: 'проверка, если закончили 5410 'найдем следующий элемент 480
5420 FOR I=J+1 TO 9 5430 IF FRNT(I) < >0 THEN GOTO 5450 5440 NEXT I 5450 IF I<=9 THEN P=I: PTRNXT(REAR(J)) =FRNT(I) 5460 J=I 5470 GOTO 5400 5480 PTRNXT(REAR(P)) =0 5490 NEXT К 5500 'копируем назад в первоначальный массив 5510 FOR 1 = 1 TON 5520 Χ (Ι) = INFO (FIRST) 5530 FIRST- PTRNXT (FIRST) 5540 ΝΕΧΓ I 5550 RETURN 5560 'конец подпрограммы Временные затраты на метод поразрядной сортировки, очевидно, зависят от числа цифр (т) и числа элементов в файле (п). Поскольку внешний цикл FORK=l TO M... выполняется m раз (один раз для каждой цифры), а внутренний цикл выполняется η раз (один раз на каждый элемент в файле), то порядок данной сортировки составляет примерно 0(m*n). Таким образом, эта сортировка относительно эффективна, если число цифр в ключе не слишком большое. Следует отметить, однако, что многие ЭВМ имеют аппаратные возможности для упорядочивания цифр в числе (особенно если они заданы в двоичной системе), которые гораздо более быстрые, чем выполнение сравнения двух полных ключей. Следовательно, неразумно сравнивать оценку 0(m*n) с некоторыми другими результатами, которые были получены в этой главе. Отметим также, что если ключи являются плотными (т. е. если почти каждое число, которое может быть некоторым ключом, в действительности является ключом), то m аппроксимирует logn, так что 0(m*n) аппроксимирует О(nlogn). Эта сортировка в действительности требует еще пространства для хранения указателей на начало и конец очередей помимо дополнительного поля в каждой записи, которое будет использоваться как некоторый указатель в связанных списках. Если число цифр большое, то иногда более эффективно отсортировать данный файл, сперва применяя поразрядную сортировку к старшим значащим цифрам, а затем используя простые вставки для переупорядоченного файла. В случае когда большинство записей в файле имеет различные старшие значащие цифры, этот процесс исключает лишние просмотры по младшим значащим цифрам. Упражнения 1. Напишите алгоритм для подпрограммы merge(x, Ibl, ubl, ub2), который предполагает, что элементы от χ(lbl) до χ (ubl) и от x(ubl+l) до x(ub2) отсортированы, и который объединяет эти два подмассива в один от х(1Ь1) до x(ub2). 481
2. Рассмотрим следующую рекурсивную версию сортировки слиянием, которая использует подпрограмму merge из предыдущего упражнения. Она вводит массив х, константу 1 и переменную п. Перепишите эту подпрограмму, исключив рекурсию и сделав упрощения. Насколько результирующая подпрограмма отличается от подпрограммы, приведенной в тексте? subroutine msort2(x,lb,ub) if lb< >ub then mid=int((ub+lb)/2) msort2(x,lb,mid) msort2 (x,mid+1 ,ub) merge (x,lb,mid,ub) endif return 3. Пусть а (11, 12) будет средним числом сравнений, необходимых для объединения двух отсортированных массивов с длинами 11 и 12 соответственно, причем элементы массивов выбираются случайно из 11+12 элементов. (а) Чему равны значения а (11,0) и а(0,12)? (б) Покажите, что для 11>0 и 12>0 а(11,12) равно (11/(11 + 12))» »(1+а(11-1, 12)) + (12/(11+12))»(1+а(11, 12)-1). (Указание. Выразите среднее число сравнений в терминах среднего числа сравнений после первого сравнения.) (в) Покажите, что а(11,12) равно (11»12»(11+12+2))/((11 + 1)»(12+ +1)). (г) Проверьте формулу из п. (в) для двух массивов — один размером 2, а другой размером 1. 4. Рассмотрим следующий метод объединения массивов а и b в массив с. Выполним бинарный поиск элемента Ь(1) в массиве а. Если Ь(1) находится между a(i) и a(i+l), то выведите элементы от а(1) до a (i) в массив с, затем выведите элемент Ь(1) в массив с. Далее выполните бинарный поиск элемента Ь(2) в подмассиве с элементами от a(i+l) до а (1а) (где 1а является числом элементов в массиве а) и повторите процесс вывода. Повторите эту процедуру для каждого элемента в массиве Ь. (а) Напишите подпрограмму на языке Бейсик, реализующую этот метод. (б) В каких случаях этот метод более эффективен, чем метод, приведенный в тексте? В каких случаях он менее эффективен? 5. Рассмотрим следующий метод объединения отсортированных массивов а и b в массив с (называемый бинарным слиянием). Пусть 1а и lb будут числом элементов в массивах а и b соответственно, и предположим, что la ^ ^1Ь. Разделим массив а на lb+Ι примерно равных подмассивов. Сравним 6(1) с наименьшим элементом во втором подмассиве массива а. Если Ь(1) меньше, тогда найдем a(i) такой, что a(i)^b(l)^a(i+l), при помощи бинарного поиска в первом подмассиве. Выведем в массив с все элементы первого подмассива до элемента a(i) включительно, а затем выведем в массив с элемент Ь(1). Повторим этот процесс для элементов b(2), b(3), ..., b(j), где b(j) оказывается элементом большим, чем наименьший элемент во втором подмассиве. Выведем в массив с все оставшиеся элементы из первого подмассива и первый элемент из второго подмассива. Затем сравним b(j) с наименьшим элементом в третьем подмассиве массива а и т. д. а) Напишите программу, реализующую бинарное слияние. б) Покажите, что если 1а=1Ь, то бинарное слияние работает как сортировка слиянием, описанная в тексте. (в) Покажите, что если 1Ь=1, то бинарное слияние работает как сортировка слиянием, описанная в упражнении 4. 6. Определите число сравнений (как некоторую функцию от η и ш), которые выполняются при объединении отсортированных файлов а и b с размером соответственно пит при помощи каждого из следующих методов сортировок слиянием для каждого из указанных наборов отсортированных файлов: 482
(1) Методы сортировок слиянием: а) метод сортировки слиянием, представленный в тексте; б) сортировка слиянием из упражнения 4; в) сортировка бинарным слиянием из упражнения 5. (2) Наборы файлов: (а) m=n и a(i)<b(i)<a(i+l) для всех i (б) m=n и а(п)<Ь(1) (в) m=n и a(int(n/2))<b(l)<b(m)<a(int(n/2) + l) (г) n=2*m и a(i)<b(i)<a(i+l) для всех i от 1 до m (Д) n=2*m и a(m+i)<b(i)<a(m+i+l) для всех i от 1 до m (е) n=2»m и a(2»i)<b(i)<a(2*i+l) для всех i от 1 до m (ж) т=1 и b(l)=a(int(n/2)) (3) т=1 и Ь(1)<а(1) (и) т=1 и a(n)<b(l) 7. Сгенерируйте два случайно заданных файла размером 100 и объедините их при помощи каждого из методов, приведенных в упражнении б, отслеживая число сделанных сравнений. Сделайте то же самое для двух файлов размером 10 и двух файлов размером 1000. Повторите данный эксперимент 10 раз. Что показывают результаты о средней эффективности данных методов слияния? 8. Напишите подпрограмму, которая сортирует файл, сперва применяя поразрядную сортировку для г старших значений цифр (где г является некоторой заданной константой), а затем использует простые вставки для сортировки всего файла. Это исключает излишние просмотры младших цифр, которые, возможно, не нужны. 9. Напишите программу, которая печатает все наборы из шести положительных целых чисел: al, a2, аЗ, а4, а5 и аб, такие что al < а2 < аЗ < 20 al< а4 < а5 < аб < 20 и сумма квадратов al, а2 и аЗ равна сумме квадратов а4, а5 и аб. (Указание. Сгенерируйте все возможные суммы квадратов трех чисел и используйте процедуру сортировки для нахождения равных значений.)
Глава 9 Поиск В этой главе мы рассмотрим проблему поиска некоторой единицы информации в большом объеме данных. Как мы увидим, некоторые методы организации данных позволяют выполнить процесс поиска более эффективно. Поскольку в программировании поиск является общеизвестной задачей, то знание решений ее играет большую роль в становлении хорошего программиста. 9.1. ОСНОВНЫЕ МЕТОДЫ ПОИСКА Прежде чем рассматривать конкретные методы поиска, определим некоторые термины. Таблица или файл являются некоторой группой элементов, каждый из которых называется записью. (Мы здесь используем термины «файл» и «запись» в их общем смысле. Не следует их путать с такими же терминами, когда они относятся к конкретным конструкциям языка Бейсик.) С каждой записью ассоциируется некоторый ключ, который используется для того, чтобы отличить одну запись от другой. Соответствие между записью и ключом может быть простым или сложным. В простейшем случае ключ является некоторым полем внутри записи, располагающимся с некоторым конкретным сдвигом от начала записи. Такой ключ называется внутренним ключом или встроенным ключом. В других случаях ключом является относительная позиция записи внутри файла или имеется некоторая отдельная таблица ключей, которая содержит указатели на записи. Такие ключи называются внешними ключами. Для каждого файла имеется по крайней мере один набор ключей (возможно и несколько таких наборов), которые являются уникальными (т. е. никакие две записи в файле не имеют одинакового значения ключа). Такой ключ называется первичным ключом. Например, если файл хранится как некоторый массив, то индекс некоторого элемента в этом массиве является уникальным внешним ключом для этого элемента. Однако поскольку любое поле записи может служить в качестве ключа в каком-либо конкретном приложении, то ключи не всегда должны быть уникальными. Например, если в некотором файле 484
с фамилиями и адресами название города используется как ключ для некоторого поиска, то он, возможно, не будет уникальным, так как в файле могут быть дзе записи с названием одного и того же города. Такой ключ называется вторичным ключом. Некоторые из рассматриваемых нами алгоритмов предполагают наличие уникальных ключей, а другие позволяют использовать повторяющиеся ключи. При адаптации некоторого алгоритма для конкретного приложения программист должен знать, будут ли ключи уникальными, и убедиться, что данный алгоритм на это рассчитан. Алгоритмом поиска является некоторый алгоритм, который воспринимает некоторый аргумент а и пытается локализовать некоторую запись, ключ которой равен а. Данный алгоритм может возвратить всю данную запись или чаще всего может возвратить некоторый указатель на эту запись. Успешный поиск часто называется извлечением. Однако возможно, что поиск некоторого конкретного аргумента в таблице является неудачным, т.е. в данной таблице не существует записи с этим аргументом в качестве ключа. В этом случае такой алгоритм может возвратить некоторую специальную «пустую запись» или некоторый пустой указатель. Однако чаще такое условие приводит к появлению некоторой ошибки или к установке во флаге некоторого конкретного значения, которое указывает, что данная запись отсутствует. Если поиск закончился неудачно, то часто бывает желательно добавить некоторую новую запись с этим аргументом в качестве ключа. Алгоритм, который выполняет эту функцию, называется алгоритмом поиска и вставки. В некоторых случаях бывает желательно вставить некоторую запись с первичным ключом key в некоторый файл без первоначального поиска другой записи с этим же самым ключом. Такая ситуация могла бы возникнуть, если бы уже было определено, что такой записи нет в файле. В дальнейшем мы будем исследовать и обсуждать относительную эффективность различных алгоритмов. В таких случаях читатель должен отмечать себе, относятся ли эти рассуждения к поиску, к вставке или к поиску со вставкой. Заметьте, что ничего не было сказано о том, как организованы таблица или файл. Это может быть массив записей, связанный список, дерево или даже граф. Поскольку различные методы поиска могут соответствовать различным организациям таблиц, то таблица часто строится, исходя из соображений конкретного метода поиска. Такая таблица может полностью располагаться в оперативной памяти, или во вспомогательной памяти, или там и там. Ясно, что для разных организаций таблиц необходимы различные методы поиска. Методы поиска, при которых вся таблица постоянно находится в оперативной памяти, называются методами внутреннего поиска, а те методы, для которых большая часть таблицы хранится во вспомогательной 485
памяти (такой, как диск или лента), называются методами внешнего поиска. Как и для сортировки, мы будем рассматривать только внутренний поиск и предоставляем читателю самому исследовать такую крайне важную тему, как внешний поиск. Последовательный поиск Простейшей формой поиска является последовательный поиск. Этот поиск применяется к таблице, которая организована или как массив, или как связанный список. Предположим, что к есть некоторый массив из η ключей, а г—некоторый массив записей, такой что k(i) является ключом для r(i). Предположим также, что key является некоторым аргументом поиска. Мы хотим установить в переменную search наименьшее целое число i, такое что k(i)=key, если такое i существует, и 0 в противном случае. Алгоритм выполнения этих действий следующий: for i—ι to n if key=k(i) then search=i return endif next i search=0 return Данный алгоритм проверяет по очереди каждый ключ. При нахождении ключа, который совпадает с аргументом поиска, выдается его индекс (который выступает в роли некоторого указателя на запись). Если совпадение не найдено, то выдается 0. Этот алгоритм может быть просто модифицирован для того, чтобы добавлять некоторую запись гее с ключом key в таблицу, если ключ key не находится в данной таблице. Следующие операторы замещают последние два оператора, приведенные выше: п—п+1 'увеличиваем размер таблицы k(n)—key 'вставляем новый ключ и запись r(n)*=rec search=η return Отметим, что если вставки делаются при помощи использования данного измененного алгоритма, то никакие две записи не могут иметь одинаковый ключ. Когда этот алгоритм будет реализовываться на языке Бейсик, мы должны гарантировать, что увеличение переменной η на 1 не приведет к тому, что ее значение выйдет за верхнюю границу массива. Для того чтобы использовать последовательный поиск со вставкой для некоторого массива, для этого массива предварительно должно быть выделено достаточно памяти. Представление таблицы в виде некоторого связанного списка имеет то преимущество, что размер такой таблицы может 486
быть по мере необходимости динамически увеличен. Предположим, что таблица организована как некоторый линейный связанный список, на который указывает переменная table, а связь осуществляется по некоторому полю указателя nxt. Затем предполагая, что переменные k, r, key и гее определяются так же, как и в предыдущем случае, последовательный поиск со вставками для связанного списка может быть представлен в следующем виде: q=null ρ=table while (p< >null) do if k(p)=key then search=ρ return endif q-p p=nxt(p) endwhile 'запись должна быть вставлена s=getnode k(s)=key r(s)=rec nxt(s)=null if q=null then tablets else nxt(q)«s endif searches return Другим преимуществом представления таблицы в виде связанного списка, а не массива является то, что из связанного списка проще удалить запись. Удаление элемента из массива требует в среднем перемещения половины его элементов. (Почему?) Одним методом улучшения эффективности удаления записи из массива является добавление некоторого поля flag(i) к каждой записи. Первоначально, когда в позиции i нет записи, флаг flag(i) не установлен. Когда в позицию i вставляется некоторая запись, данный флаг устанавливается. Когда запись в позиции i удаляется, ее флаг сбрасывается. Новые записи вставляются в конец массива. Если выполняется существенное число вставок, то все пространство в массиве вскоре будет исчерпано. Если делается попытка вставить некоторую новую запись; когда в массиве уже больше нет места, то данный массив уплотняется при помощи затирания всех записей, у которых сброшены флаги. Это дает нам некоторый массив, который содержит в начале все правильные записи, а в конце место для новых записей. Тогда может быть вставлена новая запись. (Конечно, надо быть уверенным, что никакая другая программа не зависит от местоположения записей, поскольку их позиции теперь изменились.) Если записи не должны быть расположены в том по- 487
рядке, в котором они были вставлены, то удаление может быть выполнено просто при помощи замены записи, которая должна быть удалена, на последнюю запись в массиве и уменьшения на единицу числа последовательных позиций, имеющихся в данный момент в файле. Имеется другой метод, при котором нет необходимости периодически уплотнять массив, но который дает меньшую эффективность при отдельных вставках. В этом методе вставка заключается в просмотре массива последовательно и поиске некоторой записи, которая отмечена как удаленная. Новая запись вставляется поверх первой записи, у которой сброшен флаг. Еще один метод состоит в том, чтобы связать вместе все записи со сброшенными флагами. Это не требует дополнительного пространства, поскольку информация, находящаяся в удаленной записи, несущественна и, следовательно, может быть использована для указания на следующую удаленную запись. Этот список свободных записей может быть организован как стек для того, чтобы сделать более эффективным процесс вставки в такой список. Однако эти методы не приемлемы, если записи должны располагаться в том порядке, в котором они вставлялись. Более того, если вставка выполняется только после некоторого поиска, то эти методы не дают выигрыша в эффективности, поскольку по всей таблице должен осуществляться поиск некоторой записи с таким же ключом. Предоставим читателю в качестве упражнений реализацию этих идей в алгоритмы и программы. Эффективность последовательного поиска Насколько эффективен последовательный поиск? Давайте исследуем число сравнений, которые должны быть сделаны при последовательном поиске некоторого заданного ключа. Если мы предположим, что не осуществляется ни вставок, ни удалений, так что поиск осуществляется по всей таблице с постоянным размером п, то число сравнений зависит от того, где в таблице располагается запись с ключом, равным аргументу поиска. Если данная запись является первой в таблице, то выполняется только одно сравнение. Если эта запись является последней в таблице, то необходимо η сравнений. Если равновероятно, что аргумент попадает в любую заданную позицию таблицы, то успешный поиск (в среднем) будет выполняться за (п+1)/2 сравнений, а неуспешный поиск потребует η сравнений. В любом случае число сравнений имеет порядок О(п). Однако часто бывает, что некоторые аргументы задаются для алгоритма поиска чаще других. Например, в файлах информационной системы колледжа более вероятно, что обращения будут к записям о студенте старшего курса, подготавливающем документы в аспирантуру, или о первокурснике, для которого 488
подновляется информация об окончании школы, чем о среднем второкурснике или студенте третьего курса. Аналогичным образом более вероятно, что из файлов бюро по учету автомобилей или налогового управления записи будут извлекаться чаще σ нарушителях закона и людях, уклоняющихся от налогов, чем о гражданах, уважающих законы. (Как мы увидим позже в этой главе, эти примеры являются нереалистичными, поскольку невероятно, чтобы последовательный поиск использовался для таких больших файлов. Но на данный момент предположим, что используется последовательный поиск.) Тогда, если часто используемые записи поместить в начало файла, среднее число сравнений сильно уменьшится, поскольку поиск записей, к которым наиболее часто осуществляется доступ, занимает наименьшее время. Предположим, что p(i) есть вероятность того, что извлечена запись i. (p(i) является некоторым числом от 0 до 1 таким, что если для файла выполнено m поисков, то m*p(i) из них будет для записи i.) Предположим также, что р(1)+р(2)+... + р(п) = 1, так что ключ со значением аргумента не может отсутствовать в таблице. Тогда среднее число сравнений, которые делаются для поиска некоторой записи, составляет р(1)+2*р(2)+3*р(3) + ...+п*р(п) Ясно, что это число минимизируется, если р(1)^р(2)^р(3)^...^р(п) (Почему?) Таким образом, для некоторого заданного большого и неизменяющегося файла переупорядочивание этого файла в порядке уменьшения вероятности доступа дает большую степень эффективности каждый раз, когда осуществляется поиск по этому файлу. Конечно, этот метод подразумевает, что в каждой записи имеется некоторое добавочное поле для хранения р, которое задает вероятность доступа к этой записи, или что ρ может быть вычислено на основании некоторой другой информации в каждой записи. Переупорядочивание списка для достижения максимальной эффективности Если в таблице должно быть сделано много вставок и удалений, то списочная структура предпочтительнее массива. Однако даже в списке было бы лучше поддерживать соотношение р(1)>р(2)>р(3)>...>р(п) для обеспечения более эффективного поиска. Это может быть сделано проще всего, если новый элемент вставляется в список на соответствующее ему место. Это означает, что если prob является вероятностью того, что некоторая запись с некоторым 489
заданным ключом будет аргументом поиска, то эта запись должна быть вставлена между записями r(i) и r(i+l), где i такое, что p(i)S*prob>p(i+l) К сожалению, вероятности p(i) редко известны заранее. Хотя, как правило, некоторые записи извлекаются чаще, чем другие, почти невозможно бывает идентифицировать эти записи заранее. Кроме того, вероятность, с которой некоторая заданная запись будет извлекаться, может измениться со временем. Используя приведенный выше пример с информационной системой колледжа, видим, что некоторый студент начинает учиться как первокурсник (высокая вероятность поиска), затем становится второкурсником и студентом третьего курса (низкая вероятность), а затем студентом последнего курса (высокая вероятность). Поэтому было бы полезно иметь некоторый алгоритм, который бы непрерывно переупорядочивал таблицу так, что записи, доступ к которым осуществляется более часто, передвигались бы к началу, а записи, доступ к которым осуществляется менее часто, передвигались бы к концу. Для реализации этого может быть использовано несколько способов. Один из них известен как метод перемещения в начало, который является эффективным только для таблицы, организованной как список. В этом методе, когда поиск выполняется успешно (т. е. когда найдено, что аргумент совпадает с ключом некоторой записи в списке), извлеченная запись удаляется из ее текущей позиции в списке и помещается в голову списка. Другим методом является метод транспозиции, при котором извлеченная запись меняется местами с записью, которая ей предшествует. Приведем алгоритм реализации метода транспозиции для таблицы, представленной в виде некоторого связанного списка. Алгоритм устанавливает переменную search так, чтобы она указывала на извлеченную запись или на пустой указатель, если запись не найдена. Как и раньше, key является аргументом поиска, а к и г — таблицами ключей и записей. Переменная table является указателем на первый узел в списке. potable q=*null 'q находится на один шаг позади ρ s=null 's находится на два шага позади ρ while (p< >null) do if k(p)=key then 'Мы нашли запись. Меняем местами записи, на которые указывают ρ и q if q = null then 'Мы нашли, что ключ находится в первой позиции таблицы, так что транспозиция не нужна search=ρ return endif 490
nxt(q)=nxt(p) nxt(p)=q if s=null then table=p else nxt(s)«*p endif search « ρ return endif s=q q=p , p=nxt(p) endwhile search=null return Предоставляем читателю реализовать в качестве упражнений метод транспозиции для массива и метод перемещения в начало для массива и списка. Оба этих метода основаны на том наблюдении, что запись, которая только что была извлечена, вероятно, будет извлечена' снова. При перемещении таких записей в начало таблицы последующие поиски этих записей делаются более эффективными. Рассуждения в пользу метода перемещения в начало состоят в том, что, поскольку данная запись, вероятно, опять будет извлечена, ее следует поместить в ту позицию в таблице, для которой поиск будет осуществляться наиболее эффективно. Однако противоположное замечание в пользу метода транспозиции заключается в том, что одно-единственное обращение еще не предполагает, что данная запись будет часто извлекаться. Помещая ее в начало таблицы, мы уменьшаем эффективность поиска для всех остальных записей, которые до этого находились перед ней. Продвигая извлекаемую запись каждый раз только на одну позицию, мы гарантируем, что она продвинется в начало поиска, если только к ней будут часто обращаться. В самом деле, показано, что в общем случае метод транспозиции в конце концов дает более эффективный поиск, чем метод перемещения в начало списка для тех списков, в которых вероятность доступа к некоторому конкретному элементу остается постоянной по времени. Однако метод транспозиции требует большего времени для достижения максимальной эффективности, чем метод перемещения в начало. Поэтому может быть рекомендована некоторая смешанная стратегия, при которой первоначально используется метод перемещения в начало для того, чтобы быстро переупорядочить список, а затем используется метод транспозиции для поддержания данного списка в примерно относительном порядке. Другим преимуществом метода транспозиции перед методом перемещения в начало является то, что он может быть одинаково эффективно применен к таблицам, представленным и в виде массива, и в виде списочной структуры. Транспозиция 491
двух элементов в массиве является достаточно эффективной операцией, в то время как перемещение некоторого элемента из середины массива в его начало приводит (в среднем) к перемещению половины элементов массива. (Однако в этом случае среднее число перемещений не является таким большим, поскольку элемент, который должен быть перемещен, часто находится в первой половине массива.) Поиск в упорядоченной таблице Если таблица упорядочена по уменьшающемуся или увеличивающемуся значению ключа записей, то существует несколько методов, которые могут быть использованы для увеличения эффективности поиска. Это особенно справедливо для таблиц с фиксированным размером. Одним явно очевидным преимуществом поиска в отсортированном файле перед поиском в неотсортированном файле является тот случай, когда запись с ключом, равным аргументу, отсутствует в файле. В этом случае для обнаружения такой ситуации в неотсортированном файле необходимо выполнить η сравнений. В случае отсортированного файла, предполагая, что значения аргумента ключа равномерно распределены в диапазоне ключей в файле, необходимо выполнить (в среднем) только (п+1)/2 сравнений. Это происходит из-за того, что мы знаем, что запись с некоторым заданным ключом отсутствует в файле, который отсортирован по увеличивающемуся значению ключа, когда в файле встречается ключ, который больше, чем значение аргумента. Предположим, что можно собрать некоторое большое число запросов на поиск до того, как они будут обработаны. Например, во многих приложениях ответ на запрос информации может быть отсрочен до следующего дня. В таких случаях все запросы за некоторый день могут быть собраны вместе и реальный поиск может быть осуществлен в течение ночи, когда новые запросы не поступают. Если и таблица, и список запросов отсортированы, то может быть выполнен последовательный поиск при одновременном продвижении и по таблице, и по списку запросов, начиная поиск каждого следующего элемента списка запросов в том месте, где окончился предыдущий поиск. Таким образом, нет необходимости выполнять поиск по всей таблице для каждого запроса на поиск. В действительности, если многие такие запросы равномерно распределены по всей таблице, для каждого запроса потребуется только несколько просмотров (если число запросов меньше, чем число элементов в таблице) или возможно только одно сравнение (если число запросов больше, чем число элементов в таблице). В таких ситуациях последовательный поиск, вероятно, является наилучшим методом. 492
Из-за простоты и эффективности последовательной обработки отсортированного файла может иметь смысл отсортировать файл прежде, чем осуществлять в нем поиск. Это особенно справедливо для ситуации, описанной в предыдущем абзаце, где мы имели дело с некоторым «главным» файлом и с большим файлом «транзакций», состоящим из запросов. Индексно-последовательный поиск Для увеличения эффективности поиска в отсортированном файле существует другой метод, но он приводит к увеличению требуемого пространства. Этот метод называется индексно-по- следовательным методом поиска. В дополнение к отсортированному файлу заводится некоторая вспомогательная таблица, называемая индексом. Каждый элемент индекса состоит из ключа kindex и указателя на запись в файле, соответствующую этому ключу kindex. Элементы в индексе, так же как и элементы в файле, должны быть отсортированы по этому ключу. Если индекс имеет размер, составляющий одну восьмую от размера файла, то каждая восьмая запись в файле представлена первоначально в индексе. Это показано на рис. 9.1.1. к г Ключ Запись 8 14 26 38 72 115 306 321 329 387 409 512 540 567 583 | 592 602 611 618 741 798 811 814 \ 876 ^ч_ Рис. 9.1.1. Индексно-последовательный файл. 493 Индекс kindex pindex
Алгоритм, используемый для поиска в индексно-последова- тельном файле, прост. Пусть г, к и key определяются, как и раньше, пусть kindex будет массивом ключей в индексе, и пусть pindex будет массивом указателей внутри индекса на действительные записи в файле. Предположим, что данный файл представлен в виде массива, что η является размером этого файла и что indxsze является размером индекса: i-1 while (i<*= indxsze) and (kindex (i)<=key) do endwhile 'установить lowlim на наименьшую возможную позицию элемента в таблице if i=l then lowlim =1 else lowlim=pindex(i—1) endif 'установить hilim на наибольшую возможную позицию элемента в таблице if i=indxsze+l then hilim =»n else hilim=pindex (i) — 1 endif 'поиск в таблице от позиции lowlim до позиции hilim for j = lowlim to hilim if k(j)=key then search=j return endif next j search=0 return Отметим, что в случае нескольких записей с одним и тем же ключом вышеприведенный алгоритм не обязательно выдает указатель на первую такую запись в файле. Действительным преимуществом индексно-последовательного метода является то, что элементы в таблице могут быть проверены последовательно, если доступ должен быть осуществлен ко всем записям в файле, а для доступа к некоторому конкретному элементу время поиска сильно сокращено. Последовательный поиск выполняется по меньшему индексу, а не по большой таблице. Когда найден правильный индекс, второй последовательный поиск выполняется по небольшой части записей самой таблицы. Индекс применяется для отсортированной таблицы, представленной и в виде связанного списка, и в виде массива. Использование связанного списка приводит к несколько большим накладным расходам по пространству для указателей, хотя вставки и удаления могут быть выполнены проще. Может также использоваться некоторая смешанная реализация, при которой все записи между двумя соседними элементами индекса хранятся в небольшой отдельной таблице, которая также содержит указатель на следующую такую же таблицу. 494
Если таблица является такой большой, что даже использование индекса не дает достаточной эффективности (или из-за того, что индекс большой, чтобы уменьшить последовательный поиск по таблице, или из-за того, что индекс маленький, так что соседние ключи в индексе находятся далеко друг от друга в таблице), то может быть использован индекс второго уровня. Индекс второго уровня действует как индекс для первичного индекса, который указывает на элементы в последовательной таблице. Это показано на рис. 9.1.2. Удаления из индексно-последовательной таблицы могут быть сделаны наиболее простым способом — при помощи отметки удаленных записей флагом. При последовательном поиске по таблице удаленные записи игнорируются. Отметим, что если некоторый элемент удален, то, даже если его ключ находится в индексе, ничего не надо делать с индексом, а надо отметить флагом данный элемент первоначальной таблицы. Вставка в индексно-последовательную таблицу является более трудной, поскольку между двумя уже существующими элементами таблицы может не быть свободного места, что приводит к необходимости сдвигать большое число элементов таблицы. Однако если некоторый недалеко расположенный элемент в таблице был отмечен флагом как удаленный, то необходимо сдвинуть только несколько элементов и поверх удаленного элемента может быть записана новая информация. Это в свою очередь может привести к необходимости изменения индекса, если элемент, на который указывал некоторый элемент индекса, был сдвинут. В общем случае когда инициализируется некоторая таблица, по всей таблице расставляются пустые записи, чтобы оставить место для вставок. Другой метод состоит в том, чтобы в некотором другом месте иметь некую область переполнения, а вставляемые записи связывать между собой. Однако для этого потребуется дополнительное поле указателя в каждой записи первоначальной таблицы. Возможное решение этой проблемы состоит в том, чтобы иметь только один указатель после каждой группы записей, вставлять новую запись в ее соответствующее место, а все записи после вставленной записи сдвигать вперед на одну позицию. Если последняя запись данной группы сдвигается, то она помещается в область переполнения, на которую указывает один указатель в группе. Читателю предлагается исследовать эти возможности в качестве упражнения. Бинарный поиск Наиболее эффективным методом поиска в упорядоченном массиве без использования вспомогательных индексов или таблиц является бинарный поиск. Читатель должен быть знаком с этим методом поиска по разд. 5.1 и 5.2. Упрощенно этот метод состоит в том, что аргумент сравнивается с ключом сред- 495
Последовательная таблица Ключ Запись 706 Рис. 9.1.2. Использование вторичного индекса.
него элемента в таблице. Если они равны, то поиск успешно закончился. В противном случае поиск должен быть осуществлен аналогичным образом в верхней или нижней половине таблицы. В гл. 5 отмечалось, что бинарный поиск наилучшим образом может быть определен рекурсивно. В результате для бинарного поиска были представлены рекурсивное определение, рекурсивный алгоритм и некоторая программа, моделирующая рекурсию. Однако большие накладные расходы, которые связаны с рекурсией, делают ее неподходящей для использования в практических ситуациях, в которых эффективность является главным фактором. Поэтому мы представим следующую нерекурсивную версию алгоритма бинарного поиска: low=l hi = n while (low<=hi) do mid = int((low+hi)/2) if key=k(mid) then search=mid return endif if key <k (mid) then hi=mid — 1 else low=mid+l endif endwhile search=0 return Каждое сравнение в бинарном поиске уменьшает число возможных кандидатов в два раза. Таким образом, максимальное число сравнений ключа, которые будут сделаны, составляет приблизительно log2n. [В действительности оно составляет 21og2n, поскольку в языке Бейсик каждый раз в цикле делается два сравнения ключа: key=k(mid) и key<k(mid). Однако на языке ассемблера или Фортране при использовании оператора «арифметический IF» делается только одно сравнение.] Поэтому мы можем сказать, что алгоритм бинарного поиска имеет порядок O(logn). Отметим, что бинарный поиск может быть использован вместе с индексно-последовательной организацией таблицы, упоминавшейся ранее. Вместо поиска по индексу последовательно может быть использован бинарный поиск. Бинарный поиск может быть также использован при поиске в основной таблице, когда идентифицированы две граничные записи. Однако размер этого сегмента таблицы, вероятно, будет настолько малым, что бинарный поиск не более эффективен, чем последовательный поиск. К сожалению, алгоритм бинарного поиска может быть использован только в том случае, если таблица хранится в виде упорядоченного массива. Это происходит потому, что данный алгоритм использует тот факт, что индексами элементов масси- 497
ва являются последовательные целые числа. По этой причине бинарный поиск практически бесполезен в ситуациях, где имеется много вставок или удалений, так что структура массива является несоответствующей этому поиску. Упражнения 1. Модифицируйте алгоритмы поиска и вставки из этого раздела так,, чтобы они стали алгоритмами корректировки. Если какой-либо алгоритм находит некоторое i такое, что key=k(i), то измените значение r(i) на гее. 2. Реализуйте на языке Бейсик алгоритмы последовательного поиска и последовательного поиска и вставки для массивов и связанных списков. 3. Сравните эффективность поиска ключа key в упорядоченной последовательной таблице размером пив неупорядоченной таблице с таким же размером: а) если нет записи с ключом key; б) если присутствует одна запись с ключом key и осуществляется поиск только одной записи; в) если присутствует более одной записи с ключом key и желательно найти толька первую такую запись; г) если присутствует более одной записи с ключом key и желательно найти все такие записи. 4. Предположим, что упорядоченная таблица хранится в виде некоторого циклического списка с двумя внешними указателями — table и other. Указатель table всегда указывает на узел, содержащий запись с наименьшим ключом. Указатель other первоначально равен указателю table, но каждый раз, когда выполняется поиск, он переустанавливается так, чтобы указывать- на запись, которая извлечена. Если поиск был неудачным, то указатель other переустанавливается так, что он указывает на table. Напишите подпрограмму на языке Бейсик, которая воспринимает входную информацию TABLE» OTHER и KEY, реализует этот метод, переустанавливает переменную· OTHER, как было описано, и устанавливает переменную SEARCH так, чтобы указывать на извлеченную запись или на некоторый пустой указатель, если поиск был неудачным. Объясните, каким образом использование указателя OTHER может уменьшить среднее число сравнений при поиске. 5. Рассмотрим некоторую упорядоченную таблицу, представленную как массив или как список с двумя связями, так что поиск в данной таблице может быть осуществлен последовательно вперед или назад. Предположим, что некоторый указатель ρ указывает на последнюю, успешно извлеченную запись. Поиск всегда начинается с записи, на которую указывает р, но он может продолжаться в любом направлении. Напишите подпрограмму для извлечения записи с ключом key и соответствующей модификации ρ для массива и для списка с двумя связями. Сравните число сравнений ключа для случаев успешного и неудачного поиска с методами из упражнения 4, где таблица может просматриваться только в одном направлении, но процесс просмотра может начинаться в одной из двух точек. 6. Модифицируйте индексно-последовательный поиск так, чтобы в случае с несколькими записями, имеющими одинаковые ключи, он выдавал первую такую запись в таблице. 7. Рассмотрим следующую реализацию индексно-последовательного файла на языке Бейсик: 10 DIM INDX(100,2) 20 KINDEX=1 30 PINDEX=2 40 DIM TABLE(1000,3) 50 K=l 60 R=2 70 FLAG=3 Напишите на языке Бейсик подпрограмму create, которая инициализирует такой файл по входным данным. Каждая входная строка содержит ключ и запись. Входная информация сортируется по возрастающему значению 498
ключа. Каждый элемент индекса соответствует 10 элементам таблицы. В переменную FLAG устанавливается значение TRUE для занятого элемента таблицы и значение FALSE для незанятого элемента. Два из каждых 10 элементов остаются незанятыми, чтобы позволить рост в будущем. 8. Пусть дан некоторый индексно-последовательный файл, как в упражнении 7! Напишите на языке Бейсик подпрограмму search, которая печатает запись в файле с ключом KEY, если она имеется в файле, и некоторое указание на то, что данная запись в файле отсутствует, если записи с таким ключом не существует. (Как вы можете гарантировать, что неудачный поиск эффективен в максимально возможной степени?) Кроме того, напишите подпрограмму insert для вставки некоторой записи REC с ключом KEY и подпрограмму delete для удаления записи с ключом KEY. 9. Рассмотрим следующую версию бинарного поиска, которая предполагает, что некоторый специальный элемент К(0) меньше, чем любой возможный ключ: mid=int((n+l)/2) len=int(n/2) finish«==false while (key< >k(mid)) and (finish=false) do if key <k (mid) then mid=int(mid-(len+l)/2) else mid=int(mid+(len+I)/2) endif if len-1 then finish=true else len=int(len/2) endif endwhile if key «k (mid) then search=mid else search=0 endif return Докажите, что этот алгоритм правильный. Какие преимущества и (или) недостатки у этого метода перед методом, приведенным в тексте? 10. Следующий алгоритм поиска в отсортированном массиве известен как фибоначчиев поиск из-за использования чисел Фибоначчи. (Что касается определения чисел Фибоначчи и функции fib, см. разд. 5.1.) j = l while fib(j)<n+l do j=j+l endwhile mid-n-fib(j-2) + l fl—fib(j—2) f2=fib(j—3) finish=false while (key< >k(mid)) and (finish=false) do if (mid<-0) or (key>k(mid)) then if f 1 = 1 then finish=true else mid=mid+f2 fl—fl—f2 f2=f2—fl endif else if f2=0 then finish=true else mid=mid—12 t=fl —f2 499
fl-f2 f2-t endif endif endwhile if finish then search=0 else search=mid endif Объясните, как работает этот алгоритм. Сравните число сравнений ключа а данном случае с числом сравнений, использованных при бинарном поиске. 11. Модифицируйте бинарный поиск, заданный в тексте, так, чтобы в случае неудачного поиска он выдавал индекс i такой, что k(i)<key<k(i+l). Если key<k(l), то он выдает 0, а если key>k(n), то он выдает п. Сделайте то же самое для упражнений 9 и 10. 9.2. ПОИСК ПО ДЕРЕВУ В предыдущем разделе мы обсуждали операции поиска в некотором файле, который организован как массив или список. В этом разделе мы рассмотрим несколько способов организации файлов в виде деревьев и соответствующие им алгоритмы поиска. В разд. 6.1 и 8.3 мы давали некоторый метод использования бинарного дерева для хранения файла, с тем чтобы сделать сортировку этого файла более эффективной. В этом методе все левосторонние потомки некоторого узла с ключом key имеют ключи, которые меньше ключа key, а все правосторонние потомки имеют ключи, которые больше или равны ключу key. Прохождение такого бинарного дерева в симметричном порядке дает файл, упорядоченный по возрастающему значению ключа. Такое дерево может также быть использовано для бинарного поиска. Алгоритм поиска ключа key в бинарном дереве представляется следующим образом (мы предполагаем, что каждый узел содержит четыре поля: поле к, в котором хранится значение ключа данной записи; поле г, в котором хранится сама запись; поля left и right, которые являются указателями на поддеревья): ρ=tree while p< >null do if key=k(p) then search=ρ return endif if key<k(p) then p=left(p) else ρ=right (p) endif endwhile search=null return Отметим, что бинарный поиск из разд. 9.1 фактически использует отсортированный массив как некоторое неявное дере- 500
во бинарного поиска. Средний элемент этого массива можно представить как корень такого дерева, нижнюю половину массива (все те элементы, которые меньше, чем средний элемент) можно рассматривать как левое поддерево, а верхнюю половину (все те элементы, которые больше, чем средний элемент) — как правое поддерево. Отсортированный массив может быть получен из дерева бинарного поиска при помощи прохождения этого дерева в симметричном порядке и вставки каждого элемента последовательно в некоторый массив по мере того, как он встречается в дереве. С другой стороны, для некоторого заданного отсортированного массива имеется много соответствующих ему деревьев бинарного поиска. Рассматривая средний элемент массива как корень некоторого дерева и рассматривая рекурсивно оставшиеся элементы как левые и правые поддеревья, мы получим некоторое относительно сбалансированное дерево бинарного поиска (рис. 9.2.1,а). Рассматривая первый элемент массива в качестве корня дерева, а каждый последующий элемент как правого сына его предшественника, мы получим сильно разбалансирован- ное дерево (рис. 9.2.1,6). Преимущество от использования некоторого дерева бинарного поиска перед отсортированным массивом заключается в том, что структура дерева позволяет выполнять эффективно операции поиска, вставки и удаления. Если используется массив, то для операции вставки или удаления требуется перемещение примерно половины элементов массива. (Почему?) Для вставки или удаления в дереве поиска, с другой стороны, требуется настройка только нескольких указателей. Вставка в дерево бинарного поиска Приводимый далее алгоритм организует поиск по некоторому бинарному дереву и выполняет вставку новых записей в такое дерево, если поиск был неудачным. (Мы предполагаем существование подпрограммы maketree, которая воспринимает некоторое значение, строит бинарное дерево, состоящее из одного узла, информационное поле которого содержит данное значение, и выдает некоторый указатель на это бинарное дерево. Эта подпрограмма описана в разд. 6.1. Однако в нашей данной версии мы предполагаем, что подпрограмма maketree получает два значения — запись и ключ.) q=null ρ=tree while p< >null do if key=k(p) then search = ρ return endif q=p 501
30 47 86 95 115 130 138 159 166 184 206 212 219 224 237 258 296 307 314 «0) «(2) «(3) «(4) «(5) «(6) e(7) «(8) «(9) «(10) «(И) «(12) «(13) «(14) «(15) «(16) «(17) «(18) «(19) Рис. 9.2.1. Отсортированный массив и два его представления в виде бинарного дерева. if key<k(p) then ρ=left (p) else ρ=right (p) endif endwhile v=maketree(rec,key) 502
Рис. 9.2.1 (продолжение) if q=null then tree=v else if key<k(q) then Ieft(q)=v else right (q)=v endif 503
endif search=ν return Отметим, что после того, как будет вставлена некоторая новая запись, данное дерево сохранит то свойство, что при прохождении его в симметричном порядке получится отсортированный массив. Удаление из дерева бинарного поиска Приведем теперь алгоритм, который удаляет некоторый узел с ключом key из дерева бинарного поиска, после чего это дерево остается некоторым деревом бинарного поиска. Надо рассмотреть три случая. Если узел, который должен быть удален, не имеет сыновей, то он может быть удален без дальнейшей настройки дерева. Это показано на рис. 9.2.2, а. Если узел, который должен быть удален, имеет только одно поддерево, то его единственный сын может быть помещен вверх, чтобы занять его место. Это показано на рис. 9.2.2, б. Если, однако, узел р, который должен быть удален, имеет два поддерева, то его преемник s в симметричном порядке (или его предшественник в симметричном порядке) должен занять его место. Потомок в симметричном порядке не может иметь левого поддерева (поскольку, если бы он имел его, левый потомок был бы преемником ρ в симметричном порядке). Таким образом, правый сын элемента s может быть перемещен вверх, чтобы занять место s. Это показано на рис. 9.2.2, в, где узел с ключом 12 замещает узел с ключом 11, который в свою очередь замещается узлом с ключом 13. В приводимом далее алгоритме дерево остается неизменным, если в нем не существует узла с ключом key: ρ=tree q=null 'Поиск узла с ключом key. Установить ρ так, чтобы оно указывало на дан- 'ный узел, a q — на его отца, если такой существует. while (p< >niill) and (k(p)< >key) do q=P if key<k(p) then p-left(p) else ρ=right (p) endif endwhile if p=null then 'Этот ключ не существует в данном дереве. 'Дерево надо оставить неизменным. return endif 'Установить в переменную ν узел, который заменит node(p). Первые два 'случая — узел, который должен быть удален, — имеют максимум одного 'сына, if left(р)«= null 504
Рис. 9.2.2. Удаление узлов из дерева бинарного поиска, удаление узла с ключом 15; б — удаление узла с ключом 5; в — удаление узла с ключом 11. then v=right (p) else if right (ρ) = null then v=left(p) else 'Третий случай — узел node (ρ) —имеет двух сыновей. Установить в переменную ν преемника элемента ρ в симметричном порядке, а в переменную t — отца переменной v. 505
t-P v=right(p) s=left(v) 's является левым сыном ν while s< >null do t=v v=s s=left(v) endwhile 'В этот момент переменная ν является преемником узла ρ 'в симметричном порядке if t< >p then 'ρ не является отцом переменной ν, и v=left(t). 'Удалить узел node(v) из его текущей позиции и заменить его на правого сына узла node(v). left (t)= right (v) 'Настроить сыновей ν так, чтобы они были сыновьями ρ right(v)=right(p) endif left (v) = left (p) endif endif 'Вставить узел node (ν) в позицию, которую первоначально занимал узел 'node(p) if q=null then 'Узел node (ρ) был корнем дерева tree=v else if p=left(q) then left(q)=v else right (q)=v endif endif freenode(p) return Эффективность поиска по бинарному дереву Как мы уже видели в разд. 8.3 (рис. 8.3.1 и 8.3.2), время, необходимое для поиска по дереву бинарного поиска, изменяется в диапазоне от O(logn) до О(п) в зависимости от структуры самого дерева. Если элементы вставляются в дерево при помощи алгоритма выполнения вставок, описанного выше, то структура дерева зависит от порядка, в котором вставляются записи. Если записи вставляются в отсортированном порядке (или в порядке, обратном отсортированному), то результирующее дерево будет иметь все левые связи (или правые связи) пустыми, так что поиск по дереву сводится к последовательному поиску. Если, однако, записи вставляются так, что половина записей, вставленных после любой заданной записи г с ключом к, имеют ключи меньше чем к, а половина записей имеют ключи больше чем к, то получается некоторое сбалансированное дерево, в котором для извлечения элемента достаточно выполнить примерно log2n сравнений ключей. (Опять следует отметить, что проверка некоторого узла в нашем алгоритме вставок требует двух сравнений — одно на равенство, а другое на значение 506
«меньше чем». Однако при использовании машинных языков программи рования и некоторых компиляторов эти сравнения могут быть объединены в одно-единственное сравнение.) Если записи представляются в случайном порядке (т. е. любая перестановка из η элементов равновероятна), то результатом чаще будет сбалансированное дерево, чем несбалансиро ванное, так что в среднем время поиска остается O(logn). Однако константа пропорциональности будет в среднем больше, чем в специальном случае некоторого равномерно сбалансированного дерева. Во всех предыдущих рассуждениях предполагается, что вероятность равенства аргумента поиска любому ключу в таблице одинакова. Однако в реальной практике обычно имеет место тот случай, когда некоторые записи извлекаются очень часто, некоторые извлекаются достаточно часто, а некоторые почти никогда не извлекаются. Предположим, что записи вставляются в дерево так, что те записи, к которым доступ осуществляется более часто, предшествуют записям, доступ к которым осуществляется не так часто. Тогда более часто извлекаемые записи будут ближе к корню дерева и среднее время успешного поиска будет уменьшено. (Конечно, это предполагает, что переупорядочивание ключей в порядке уменьшения частоты доступа не приведет к серьезной разбалан- сировке бинарного дерева. Если же оно к этому приведет, то уменьшение числа сравнений для записей, доступ к которым осуществляется наиболее часто, может быть перевешено увеличением числа сравнений для подавляющего большинства записей.) Если извлекаемые элементы сформировали некоторое постоянное множество без вставок или удалений, то может быть выгодным настроить дерево бинарного поиска так, чтобы сделать последующие поиски более эффективными. Например, рассмотрим деревья бинарного поиска, изображенные на рис. 9.2.3. Оба дерева на этом рисунке содержат три элемента — ΚΙ, К2 и КЗ, где К1<К2<КЗ, и оба они являются верными деревьями бинарного поиска для этого множества элементов. Однако извлечение элемента КЗ требует двух сравнений для рис. 9.2.3, а, Рис. 9.2.3. Два дерева бинарного поиска. Ожидаемое число сравнений: (а) 2р1+р2 + 2рЗ + + 2q0 + 2ql + 2q2 + 2q3, (б) 2pl + Зр2 +рЗ + 2q0+ + 3ql+3q2 + q3. 507
а для рис. 9.2.3,6 требует только одного сравнения. Конечна, для этого множества ключей существуют еще другие верные деревья бинарного поиска. Число сравнений ключей, которые необходимо сделать для извлечения некоторой записи, равно уровню этой записи в дереве бинарного поиска плюс 1. Так извлечение записи К2 требует одного сравнения в дереве на рис. 9.2.3, а, но требует трех сравнений в дереве на рис. 9.2.3, б. Неудачный поиск некоторого аргумента, лежащего непосредственно между двумя ключами а и Ь, требует столько же сравнений ключей, как максимальное число сравнений, необходимое для успешного поиска или элемента а, или элемента Ь. (Почему?) Это число равно 1 плюс максимальный из уровней а и Ь. Например, поиск некоторого ключа, лежащего между К2 и КЗ, требует двух сравнений на рис. 9.2.3, α и трех сравнений на рис. 9.2.3,6. А поиск некоторого ключа, большего чем КЗ, требует двух сравнений на рис. 9.2.3, а, но только одного сравнения на рис. 9.2.3, б. Предположим, что pi, р2 и рЗ являются вероятностями того, что аргумент поиска равен соответственно ΚΙ, Κ2 и КЗ. Предположим, что qO есть вероятность того, что аргумент поиска меньше чем К1, ql есть вероятность того, что он заключен между К1 и К2, q2 есть вероятность того, что он заключен между К2 и КЗ, и q3 есть вероятность того, что он больше чем КЗ. Тогда pl + + p2 + p3+q0 + ql+q2 + q3=l. Ожидаемое число сравнений в некотором поиске есть сумма произведений вероятности того, что данный аргумент имеет некоторое заданное значение, на число сравнений, необходимых для извлечения этого значения, где сумма берется по всем возможным значениям аргумента поиска. Например, ожидаемое число сравнений при поиске в дереве на рис. 9.2.3, а составляет 2pl+p2 + 2p3+2q0 + 2ql+2q2+2q3 а ожидаемое число сравнений при поиске в дереве на рис. 9.2.3, б составляет 2pl+3p2 + p3+2q0+3ql+3q2 + q3 Это ожидаемое число сравнений может быть использовано как некоторая мера того, насколько «хорошо» конкретное дерево бинарного поиска подходит для некоторого данного множества ключей и некоторого заданного множества вероятностей. Так, для вероятностей, приведенных далее слева, дерево из рис. 9.2.3, α является более эффективным, а для вероятностей, приведенных справа, дерево из рис. 9.2.3, б является более эффективным: pl=0,l р1=0,1 р2=0,3 р2=0,1 рЗ=0,1 рЗ=0,3 qO=0,l qO=0,l ql=0,2 ql=0,l 508
q2=0,l q2=0,f q3=0,l q3 = 0,2 Ожидаемое число сравнений Ожидаемое число сравнений для рис. 9.2.3,а= 1,7 для рис. 9.2.3,а= 1,9 Ожидаемое число сравнений Ожидаемое число сравнений для рис. 9.2.3,6=2,4 для рис. 9.2.3,6=1,8 Дерево бинарного поиска, которое минимизирует ожидаемое число сравнений некоторого заданного множества ключей и вероятностей, называется оптимальным. Хотя алгоритм создания дерева может быть очень трудоемким, дерево, которое он создает, будет работать эффективно при всех последующих поисках. К сожалению, однако, заранее вероятности аргументов поиска редко известны. Сбалансированные деревья Как было отмечено выше, если вероятность поиска некоторого ключа в таблице является одинаковой для всех ключей, то сбалансированное бинарное дерево дает наиболее эффективный поиск. К сожалению, алгоритм поиска и вставки, приведенный ранее, не гарантирует, что данное дерево остается сбалансированным. Степень баланса зависит от последовательности, в которой ключи вставляются в это дерево. Нам бы хотелось иметь некоторый алгоритм эффективного поиска и вставки, который сохраняет дерево поиска в виде сбалансированного бинарного дерева. Давайте сперва определим точно термин «сбалансированное» дерево. Высотой некоторого бинарного дерева является максимальный уровень его листьев (иногда это называется глубиной дерева). Для удобства высота пустого дерева определяется как —1. Баланс некоторого узла определяется как высота его левого поддерева минус высота его правого поддерева. Сбалансированным бинарным деревом (иногда оно называется деревом AVL) является такое бинарное дерево, у которого абсолютное значение баланса каждого узла меньше или равно 1. На рис. 9.2.4, а показано некоторое сбалансированное бинарное дерево. Каждый узел в сбалансированном бинарном дереве имеет баланс, равный 1, —1 или 0, в зависимости от того, что высота его левого поддерева больше, меньше или равна высоте его правого поддерева. Баланс каждого узла показан на рис. 9.2.4, а. Предположим, что задано некоторое сбалансированное бинарное дерево и мы используем алгоритм поиска и вставки, приведенный выше, чтобы вставить новый узел ρ в данное дерево. Тогда результирующее дерево может остаться сбалансированным, а может и не остаться им. На рис. 9.2.4, б показаны все возможные вставки, которые могут быть сделаны в дерево, приведенное на рис. 9.2.4, а. Каждая вставка, которая дает сбалансированное дерево, указана буквой В. Несбалансированные 509
I \ I \ I \ I \ 1/5 Vb Ul 6'8 U9 U\Q U\\ I'M 6 Рис. 9.2.4. Сбалансированное бинарное дерево и возможные к нему добавления. вставки указаны буквой U и занумерованы от 1 до 12. Ясно видно, что дерево становится несбалансированным, если только заново вставленный узел является левым потомком некоторого узла, который ранее имел баланс, равный 1 (на рис. 9.2.4, б это имеет место для случаев с U1 до U8), или если он является правым потомком некоторого узла, который ранее имел баланс, равный —1 (случай с U9 по U12). На рис. 9.2.4,6 самый младший предок, который становится несбалансированным при каждой вставке, указан числами, содержащимися в трех узлах. Давайте проверим дальше поддерево, имеющее корень в самом младшем предке, который стал несбалансированным в результате некоторой вставки. Проиллюстрируем случай, где ба- 510
ланс этого поддерева ранее был 1, представляя остальные случаи на самостоятельный разбор читателю. На рис. 9.2.5 показа» этот случай. Назовем несбалансированный узел узлом А. Поскольку узел А имеет баланс, равный 1, то его левое поддерево непусто. Мы можем, следовательно, обозначить его левого сына как В. Поскольку узел А является самым младшим предком, который стал несбалансированным из-за нового узла, то узел В· должен был иметь баланс, равный 0. (Читателю предлагается доказать этот факт в качестве некоторого упражнения.) Таким образом, узел В должен был иметь (до вставки) левое и правое поддеревья равной высоты η (где, возможно, п=—1). Поскольку баланс узла А был 1, то правое поддерево А должно также было быть высотой п. Теперь надо рассмотреть два случая, показанные на рис. 9.2.5. На рис. 9.2.5, α заново созданный узел вставляется в левое поддерево, изменяя баланс узла В на 1, а баланс узла А на 2. На рис. 9.2.5,6 заново созданный узел вставляется в правое поддерево В, изменяя баланс узла В на —1, а баланс узла А на 2. Чтобы получить сбалансированное дерево, необходимо выполнить некоторую трансформацию данного дерева так, чтобы выполнялось следующее: 1. Прохождение трансформированного дерева в симметричном порядке должно быть таким же, как для первоначального дерева (т. е. трансформированное дерево остается деревом бинарного поиска). 2. Трансформированное дерево должно быть сбалансированным. Рассмотрим деревья, представленные на рис. 9.2.6. Говорят, что дерево, представленное на рис. 9.2.6,6, будет некоторым правым поворотом дерева, имеющего корень в узле А на рис. 9.2.6, а. Аналогичным образом говорят, что дерево, представленное на рис. 9.2.6, в, будет некоторым левым поворотом дерева, имеющего корень в узле А на рис. 9.2.6, а. Алгоритм реализации левого поворота некоторого поддерева, имеющего корень в Р, имеет следующий вид: q = right(p) hold = left(q) left(q)=p right(p)=hold Назовем эту операцию leftrotation(p). Операция rightrotati- оп(р) может быть определена аналогичным образом. Конечно, при любом повороте значение указателя на корень поддерева, для которого осуществляется поворот, также должно быть изменено, чтобы указывать на новый корень. [В случае левого поворота, приведенного выше, этот новый корень есть node(q).] Отметим, что порядок узлов при прохождении в симметричном порядке сохраняется при правом и левом поворотах. Поэтому 511
(Дерево ТЗ^ высотой \ η [Дерево Т4У высотой, η {Дерево ΤΖ * высотой , л- / [Дерево ТЗ) высотой J п-1 Рис. 9.2.5. Первоначальная вставка (все балансы приведены до вставки).
Первоначальное дерево Правый поворот Рис. 9.2.6. Простой поворот дерева. отсюда следует, что для получения некоторого сбалансированного дерева может быть выполнено любое число поворотов (левых или правых) над несбалансированным деревом без нарушения порядка узлов в некотором прохождении в симметричном порядке. Вернемся теперь к деревьям на рис. 9.2.5. Предположим, что правый поворот выполнен для поддерева, имеющего корень в узле А на рис. 9.2.5, а. Результирующее дерево показано на рис. 9.2.7, а. Отметим, что дерево на рис. 9.2.7, а дает такое же прохождение в симметричном порядке, как и дерево на рис. 9.2.5, а, и также является сбалансированным. Кроме того, поскольку высота поддерева на рис. 9.2.5, а была до вставки равна п+2, а высота поддерева на рис. 9.2.7, α после вставки равна п+2, то баланс каждого предка узла А в первоначальном дереве остается неизменным. Таким образом, замена поддерева на рис. 9.2.5, α на поддерево с правым поворотом на рис. 9.2.7, а гарантирует, что сохраняется сбалансированное дерево бинарного поиска. 513
Рис. 9.2.7. После перебалансировки (все балансы приведены после вставки). Теперь вернемся к дереву на рис. 9.2.5, б, где заново созданный узел был вставлен в правое поддерево узла В. Пусть узел С будет сыном узла В. (Имеется три случая — узел С может быть заново вставленным узлом, и в этом случае п = — 1, или заново вставленный узел может быть в левом или правом поддереве узла С. На рис. 9.2.5,6 показан случай, когда он находится в левом поддереве. Анализ другого случая выполняется аналогичным образом.) Предположим, что за левым поворотом поддерева, корень которого находится в узле В, следует правый поворот поддерева, корень которого находится в узле А. На рис. 9.2.7,6 показано результирующее дерево. Проверим, что прохождение этих двух деревьев в симметричном порядке является одинаковым и что дерево на рис. 9.2.7,6 является сбалансированным. Высота дерева на рис. 9.2.7,6 равна η+2, так же как и высота дерева на рис. 9.2.5, б до вставки и перебаланси- 514
ровки, так что баланс двух предков узла А не изменился. Следовательно, сбалансированное дерево поиска сохраняется при помощи замены дерева на рис. 9.2.5, б на дерево на рис. 9.2.7, б тогда, когда происходит вставка. Представим теперь алгоритм поиска и вставки в некоторое непустое сбалансированное бинарное дерево. Каждый узел такого дерева содержит пять полей; поля к и г, в которых хранятся соответственно ключ и запись; поля left и right, которые являются указателями соответственно на левое и правое поддеревья; поле bal, которое содержит значения 1, —1 и 0 в зависимости от баланса данного узла. В первой части алгоритма некоторый новый узел вставляется в дерево бинарного поиска без рассмотрения баланса, если в дереве не найден нужный ключ. В этой первой фазе также отслеживается самый младший предок уа, который может стать несбалансированным из-за вставки. Данный алгоритм использует подпрограмму maketree, описанную выше, и подпрограммы rightrotation и leftrotation, каждая из которых воспринимает указатель на корень некоторого поддерева и выполняет необходимую операцию поворота соответственно вправо и влево: 'Часть 1: поиск и вставка в бинарное дерево s=null ρ=tree v=null ya=p 'уа указывает на самого младшего предка, который может стать несбалансированным, 'ν указывает на отца уа, a s указывает на отца р. while p< >niill do if key=k(p) then search=ρ return endif if key<k(p) then q=left(p) else q=right(p) endif if q< >null then if bal(q)< >0 then v=p ya=q endif endif s=p P=q endwhile 'вставляем некоторую новую запись q=maketree (rec,key) bal(q)=0 if key<k(s) then left(s) =q else right (s)=q endif 'баланс всех узлов между узлами node (уа) и node(q) должен быть изменен так, чтобы он не равнялся 0. 515
if key<k(ya) then s=left(ya) else s=right (ya) endif while p< >q do if key<k(p) thenbal(p)-l p-left(p) else bal(p) = — 1 ρ=right (p) endif endwhile 'Часть 2: проверка — сбалансировано или нет данное дерево. Если оно не 'сбалансировано, то q является заново вставленным узлом, уа является 'его самым молодым несбалансированным предком, ν является отцом уа, 'a s является сыном уа в направлении разбаланса. if key<k(ya) then imbal=l else imbal= —1 endif if bal (ya)=0 then 'Еще один уровень был добавлен к данному дереву. 'Дерево остается сбалансированным. bal(ya)=imbal search=q return endif if bal(ya)< >imbal then 'Добавленный узел был помещен в направлении, противоположном 'разбалансу. 'Дерево остается сбалансированным. bal(ya)=*0 search=q return endif 'Часть З: дополнительный узел привел к разбалансу дерева. Выполняем 'перебалансировку его при помощи выполнения нужных поворотов и за- 'тем настройки баланса у затронутых узлов. if bal(s)=imbal then 'уа и s были разбалансированы в одном направлении, см. рис. 9.2.5,а, где уа=А и s=B p=s if imbal^l then rightrotation (ya) else leftrotation(ya) endif bal(ya)=0 bal(s)=0 else 'уа и s разбалансированы в разных направлениях, см. рис. 9.2.5,6, где уа=А и s=B if imbal=l then p=right (s) leftrotation(s) left(ya)=p rightrotation (ya) else p=left(s) rightrotation (s) 516
right(ya)=p leftrotation(ya) endif 'Настройка поля bal для затронутых узлов. if bal(p)=0 then 'узел р был вставлен bal(ya)=0 bal(s)=0 else if bal(p)=imbal then 'См. рис. 9.2.5,6 и 9.2.7,6 bal(ya) = — imbal bal(s)=0 else 'См. рис. 9.2.5,6 и 9.2.7,6, но предполагается, что новый 'узел был вставлен в ТЗ bal(ya)=0 bal(s)=imbal endif endif bal(p)=0 endif 'Настройка указателя на повернутое поддерево; ν является отцом уа if v^null then tree=ρ else if ya=right(v) then right(v)=p else left(v)=p endif endif search=q return Алгоритм удаления узла из некоторого сбалансированного дерева бинарного поиска с сохранением баланса дерева является еще более сложным и предоставляется читателю в качестве упражнения. Деревья цифрового поиска Другой метод использования деревьев для ускорения поиска состоит в формировании некоторого общего дерева, основанного на символах, из которых состоят ключи. Например, если ключи являются числовыми, то каждая позиция цифры определяет одного из 10 возможных сыновей заданного узла. Лес, представляющий один такой набор ключей, показан на рис. 9.2.8. Если ключи состоят из символов алфавита, то каждая буква алфавита определяет некоторую ветвь дерева. Отметим, что каждый узел, являющийся листом дерева, содержит специальный символ еок, который представляет конец некоторого ключа. Такой узел, являющийся листом, должен также содержать некоторый указатель на запись, которая запоминается. Если лес представлен некоторым бинарным деревом, как в разд. 6.5, то каждый узел бинарного дерева содержит три поля: поле symbol, которое содержит некоторый символ ключа; поле son, которое является указателем на самого старшего сына 517
Рис. 9.2.8. Лес, представляющий таблицу ключей. данного узла в первоначальном дереве; поле brother, которое является указателем на следующего, более молодого брата данного узла в первоначальном дереве. На первое дерево в лесу указывает некоторый внешний указатель tree, а корни других деревьев в таком лесу связаны между собой в линейный список при помощи поля brother. Поле son некоторого листа в первоначальном лесу указывает на некоторую запись. Конкатенация 518
всех полей symbol в пути узлов от корня до листа в первоначальном лесу является ключом данной записи. Мы сделаем еще два упрощения, которые будут ускорять процесс поиска и вставки для такого дерева. Каждый список братьев организован в бинарном дереве в порядке увеличивающегося значения поля symbol. Считается, что символ еок больше, чем любой другой символ. Используя это представление бинарного дерева, мы можем рассмотреть некоторый алгоритм поиска и вставки в такое непустое цифровое дерево. Переменная key является ключом, для которого мы осуществляем поиск, а переменная агес является указателем на запись, которую мы будем вставлять, если не будет найден ключ key. Определим также, что key(i) будет i-м символом данного ключа. Если ключ имеет η символов, то мы также предположим, что кеу(п-И) равно еок. Данный алгоритм использует операцию getnode для того, чтобы при необходимости выделить некоторый новый узел дерева. В этом алгоритме в переменную search устанавливается указатель на ту запись» поиск которой осуществляется. ρ = tree father=null 'father является отцом р for i=l to n+1 q=nuli 'q указывает на старшего брата р while (p< >null) and (symbol(p)<key(i)) do q=p ρ=brother (p) endwhile if (p=null) or (symbol(p)>key(i)) then 'вставить i-й символ ключа s= getnode symbol (s)= key (i) brother (s) = ρ if tree=null then tree=s else if q< >null then brother (q)=s else if father=null then tree=s else son (father) =s endif endif endif 'вставить оставшиеся символы ключа for j=l to n+1 if key(j)=eok then son (s) = arec search=son (s) return endif father=s s=getnode symbol (s) = key (j+1) son (father) =s brother (s)= null next j 519
endif 'в этот момент symbol (p) равен key(i) if key(i)=eok then search=son(p) return else father=ρ ρ=son(ρ) endif next i Отметим, что, представляя таблицу ключей в виде некоторо- рого общего дерева, поиск необходимо организовывать только по небольшому списку сыновей для того, чтобы узнать, появляется ли заданный символ в некоторой заданной позиции внутри ключей в таблице. Однако данное дерево можно сделать даже еще меньше, исключив те узлы, из которых можно достичь только одиночных листьев. Например, в ключах на рис. 9.2.8 при распознавании символа «7» единственным ключом, который может ему соответствовать, является ключ 768. Аналогичным образом после распознавания символов «1» и «9» единственным подходящим ключом является 195. Таким образом, лес, представленный на рис. 9.2.8, может быть сокращен до леса, представленного на рис. 9.2.9. На этом рисунке прямоугольник обозначает некоторый ключ, а круг указывает на некоторый узел дерева. Штриховая линия используется для представления указателя от узла дерева на ключ. Между деревьями, представленными на рис. 9.2.8 и 9.2.9, имеются существенные различия. Путь от корня к листу на рис. 9.2.8 представляет весь ключ, так что нет необходимости повторять сам ключ. На рис. 9.2.9 ключ может быть распознан, однако, только по его первым символам. В тех случаях, когда поиск выполняется для ключа, для которого известно, что он находится в данной таблице, при нахождении некоторого листа может быть осуществлен доступ к записи, соответствующей данному ключу. Однако более вероятно, что неизвестно, находится ли данный ключ в таблице, и надо получить подтверждение, что данный ключ в самом деле правильный. Таким образом, в записи должен храниться также и весь ключ. Более того, узел с листом в дереве на рис. 9.2.8 может быть распознан потому, что он содержит символ еок. Поэтому его указатель son может использоваться вместо этого для того, чтобы указывать на запись, которую данный лист представляет. Однако узел с листом на рис. 9.2.9 может содержать любой символ. Поэтому для того, чтобы использовать указатель son листа как указатель на запись, требуется наличие дополнительного флага в каждом узле для указания на то, является ли данный узел некоторым листом. Мы оставляем представление леса, приведенного на рис. 9.2.9, и реализацию алгоритма поиска и вставки в него читателю в качестве упражнения. 520
Ι 27 Ι 1 231 I I 284 I I 285 I I 2861 I 287 1 I 288 I 217493 21749 Φ φ Рис. 9.2.9. Уплотненный лес, представляющий таблицу ключей. Представление леса в виде таблицы ключей является эффективным, если каждый узел имеет сравнительно небольшое число сыновей. Например, только один узел на рис. 9.2.9 имеет шесть сыновей (из 10 возможных), а большинство узлов имеет одного, двух или трех сыновей. Поэтому процесс поиска списка сыновей на совпадение со следующим символом ключа является относительно эффективным. Однако если набор ключей является плотным внутри множества всех возможных ключей (т. е. если почти любая возможная комбинация символов в действительности представлена как некоторый ключ), то большинство узлов 521
•будет иметь большое число сыновей и накладные расходы на процесс поиска станут неприемлемыми. Например, если файлы © налоговом управлении имели бы в качестве ключа номер в •системе социального страхования, то накладные расходы на поиск по цифровому дереву были бы неимоверно большими. «Бор» Некоторая модификация цифрового дерева оказалась достаточно эффективной тогда, когда набор ключей в таблице является плотным. Вместо того чтобы хранить таблицу как дерево, эта таблица* представляется как некоторый двумерный массив. Каждая строка этого массива представляет один из возможных символов, который может появиться в ключе, а каждый столбец представляет узел в цифровом дереве. Каждый элемент такого массива является указателем или на другой столбец в этом массиве, или на некоторый ключ и его запись. При поиске некоторого ключа key key(l) используется для индексирования первого столбца массива. Элемент, который находится в строке key(l) и столбце 1, является или указателем на некоторый ключ и запись (и в этом случае в данной таблице имеется только один ключ, который начинается с символа key(l)), или указателем на другой столбец данного массива, скажем столбец j. Столбец j представляет все ключи в данной таблице, которые начинаются с символа key(l). Key(2) используется как некоторый номер строки для индексирования столбца j для того, чтобы определить или единственный ключ в данной таблице, начинающийся с символов key(l) и key(2), или столбец, представляющий все ключи в таблице, начинающиеся с этих символов. Аналогичным образом каждый столбец в массиве представляет множество всех ключей, которые начинаются с одинаковых начальных символов. Такой массив называется бором1* (как сокращение от слова retrieval — поиск). На рис. 9.2.10 показан бор, содержащий ключи из рис. 9.2.9. Указатель на ключ и соответствующую ему запись обозначается числом, не взятым в скобки, которое является в действительности ключом, а указатель на другой столбец задается числом в скобках. В реализации на ЭВМ для различия указателей этих двух типов понадобился бы некоторый дополнительный флаг. Например, предположим, что должен быть осуществлен поиск по бору, представленному на рис. 9.2.10, некоторой записи, ключ которой равен 274. В этом случае key(l)=2, key(2)=7, a key(3)=4. Key(l) используется для индексации столбца 1. Строка 2 столбца 1 указывает на столбец 4. Поэтому столбец 4 представляет все клю- 1) В оригинале trie (искаженное tree). Этот термин был введен Э. Фрадкиным в 1960 г. — Прим. перев. 522
0 1 Ί 3 4 5 6 7 8 9 eok • ι (2) (4) 307 768 2 (3) 195 3 180 185 1867 4 207 (5) 226 (9) (И) 294 5 (6) 6 (7) 217 7 (8) 2174 8 217493 21749 9 274 278 (10) 27 10 2796 279 11 281 284 285 286 ' 287 288 12 13 14 Рис. 9.2.10. Бор. чи, чей первый символ равен 2. Тогда key(2) используется для индексации столбца 4. Строка 7 столбца 4 указывает на столбец 9. Поэтому столбец 9 представляет все ключи, чьи первые символы равны соответственно 2 и 7. Тогда кеу(З) используется для индексации столбца 9, в котором строка 4 содержит ключ 274. В этот момент поиск успешно заканчивается. В действительности, поскольку массив, из которого формируется бор, является динамическим (когда вставляются новые записи, должны быть добавлены столбцы), лучшей реализацией бора является общее дерево, в котором каждый узел имеет некоторое фиксированное число сыновей. Каждый узел в таком общем дереве представляет некоторый столбец бора. Читатель заметит, что бор на рис. 9.2.10 содержит большое количество неиспользованного пространства. Это происходит из-за того, что набор ключей в этом примере не был плотным, так что имеется много цифр во многих позициях, которых нет в каком-либо ключе. Если набор ключей является плотным, то большинство элементов в бору будет заполнено. Причина того, что бор является таким эффективным, заключается в том, что для каждого символа ключа необходимо выполнить только один просмотр таблицы, а не прохождение некоторого списка. Упражнения 1. Напишите алгоритм эффективной вставки в дерево бинарного поиска для того, чтобы вставить новую запись, для которой известно, что ее ключ не существует в данном дереве. 2. Покажите, что можно получить некоторое дерево бинарного поиска, в котором существует только один лист, даже если элементы этого дерева вставляются не строго в возрастающем или убывающем порядке. 3. Проверьте при помощи моделирования, что если записи поступают в алгоритм поиска и вставки в бинарное дерево в случайном порядке, то число сравнений будет иметь порядок O(logn)^ 523
4. Докажите, что не каждое дерево бинарного поиска с η узлами является равновероятным (предполагая, что элементы вставляются в случайном порядке) и что сбалансированные деревья более вероятны, чем деревья с прямыми ветвями. 5. Напишите алгоритм для удаления узла из бинарного дерева, который будет заменять этот узел на его предка в симметричном порядке, а не на его преемника в симметричном порядке. 6. Предположим, что узлы некоторого дерева бинарного поиска определяются следующим образом: 10 DIM INFO(100,2) 20 K=l 30 R = 2 40 DIM PTR( 100,2) 50 LEFT=1 60 RIGHT=2 Элементы INFO (I, К) и INFO (I, R) содержат ключ и запись узла I, а элементы PTR(I, LEFT) и PTR(I, RIGHT) являются указателями соответственно на левого и правого сыновей данного узла. Напишите на языке Бейсик подпрограмму sinsert для поиска и вставки некоторой записи REC с ключом KEY в некоторое дерево бинарного поиска, на которое указывает переменная TREE. 7. Напишите на языке Бейсик подпрограмму sdelete для поиска и удаления некоторой записи с ключом KEY из некоторого дерева бинарного поиска, на которое указывает переменная TREE (реализованного так же, как в упражнении 6). Если такая запись найдена, то подпрограмма выдает значение ее поля R. Если она не найдена, то подпрограмма выдает 0. 8. Напишите на языке Бейсик подпрограмму delete для удаления всех записей с ключами в диапазоне от ключа KEY1 до KEY2 (включительно) из некоторого дерева бинарного поиска, чьи узлы описываются так же, как и в упражнениях б и 7. 9. Рассмотрим деревья поиска на рис. 9.2.11. (а) Сколько перестановок целых чисел от 1 до 7 произвели бы деревья -бинарного поиска, представленные соответственно на рис. 9.2.11, а—в? Дерево строится при помощи вставки по очереди каждого элемента перестановки в некоторое дерево, которое первоначально было пустым. (б) Сколько перестановок целых чисел от 1 до 7 произвели бы деревья бинарного поиска, которые аналогичны деревьям на рис. 9.2.11, а—в? (См. упражнение 6.1.6.) (в) Сколько перестановок целых чисел от 1 до 7 произвели бы деревья бинарного поиска с таким же числом узлов на каждом уровне, что и соответственно деревья на рис. 9.2.11, α—β? (г) Найдите распределение вероятностей для первых семи положительных чисел в качестве аргументов такого поиска, при котором деревья на рис. 9.2.11, а—в будут оптимальными. 10. Дерево 3.2 является таким деревом, в котором каждый узел имеет двух или трех сыновей и содержит один или два ключа. Если какой-либо узел имеет двух сыновей, то он содержит один ключ, причем все ключи в левом поддереве данного узла меньше этого ключа, а все ключи в правом поддереве больше этого ключа. Если какой-либо узел имеет трех сыновей, то он содержит два ключа. Все ключи в левом поддереве данного узла меньше, чем левый ключ, который в свою очередь меньше, чем все ключи в среднем поддереве. Все ключи в среднем поддереве меньше, чем правый ключ, который в свою очередь меньше, чем все ключи в правом поддереве. На рис. 9.2.12, а показано такое дерево. (Все поддеревья 2 или 3 для листа являются пустыми.) Ключ вставляется в такое дерево следующим образом. Сперва находится лист, в который данный ключ был бы вставлен, если бы для узла не было ограничения на число ключей. Например, на рис. 9.2.12, б показано, как ключ 25 был вставлен в лист, а на рис. 9.2.12, в показано, как ключ 40 524
Рис. 9.2.11. был вставлен в лист. Дерево, представленное на рис. 9.2.12, б, является правильным деревом 3-2, так что ключ 25 был вставлен правильно. Дерево, представленное на рис. 9.2.12, в, не является правильным деревом 3-2, поскольку один узел содержит 3 ключа. В этом случае процесс вставки продолжается при помощи перемещения среднего из трех ключей в узел, являющийся отцом, и расщепления двух других ключей на два различных узла, как это показано на рис. 9.2.12, г. Поскольку результирующее дерево является деревом 3-2, то процесс вставки заканчивается. На рис. 9*2.12, д показано дерево 3-2, которое получилось в результате вставки ключа 16 в дерево, представленное на рис. 9.2.12, г, а на рис. 9.2.12, е показано дерево, которое получилось в результате вставки ключа 85 в дерево, представленное на рис. 525
9.2.12, д. Разработайте реализацию деревьев 3-2 на языке Бейсик и напишите для них алгоритмы поиска, вставки и удаления. 11. В-деревом порядка m называется обобщение дерева 3-2, описанного в упражнении 10. Такое дерево определяется как некоторое общее дерево, которое удовлетворяет следующим условиям: (1) Каждый узел содержит максимум m—1 ключей. (2) Каждый узел, за исключением корневого, содержит по крайней мере int((m—1)/2) ключей. (3) Корень имеет по крайней ;мере двух сыновей, если только он не является некоторым листом. (4) Все листья находятся на одном и том же уровне. (5) Узел с π ключами, который не является листом, имеет п+1 сыновей. На рис. 9.2.13 показано некоторое В-дерево порядка 5. Отметим, что каждый узел может быть представлен как некоторый упорядоченный набор (pi, кь р2, к2, ..., kn-ь Рп) где pi является указателем (возможно, пустым, если данный узел является листом), a ki является некоторым ключом. Все ключи в узле, на которые указывает pi, находятся между κι-! и ki, а внутри каждого узла выполняется неравенство ki< <k2<...<k„_1. (а) Разработайте алгоритм поиска и вставки в некоторое В-дерево порядка т. (6) Преобразуйте свой алгоритм из п. (а) упражнения в некоторую программу на языке Бейсик. (в) Почему В-деревья особенно полезны при внешнем поиске? 9.3. ХЕШИРОВАНИЕ В двух предыдущих оазде- лах мы предполагали, что запись, для которой организуется поиск, хранится в некото- Рис. 9.2.13. В-дерево порядка 5. null 30 nuU К 87 | null 1 1 null 1 110 null 127 1 null J 1 *~uli 1 135 null 140 null 145 I" null 1 n~ull 168 null 210 1 null 1 n~ull 268 null 290 J null 1 n~uil 345 null 386 | null 1 n~ul 398 null 406 1 null 1 null · 418 null All 1 null 1 "null 435 null 440 null 451 1 null j n~u7l 470 null 510 1 null 1 null 590 null 614 | null 1 "null 627 null 640 I null \ s—\ \ ( \ 100 130 150 / ^"^ \ ч О ' 315 397 2 4 s' w V V / \ / \ / 1 N^4^ / ^v^ Г s* 430 456 580 1 620 s w , S 1/ ]/ γ
рой таблице и что прежде, чем найти требуемую запись, необходимо организовать просмотр некоторого количества ключей. Организация файла (последовательная, индексно-последователь- ная, в виде бинарного дерева и т. д.) и порядок, в котором вставляются ключи, определяют то число ключей, которое должно быть проверено до получения нужного ключа. Очевидно, что эффективными методами поиска являются те методы, которые минимизируют число этих сравнений. В идеале мы бы хотели иметь такую организацию таблицы, при которой не было бы ненужных сравнений. Посмотрим, возможна ли такая организация. Если каждый ключ должен быть извлечен за один доступ, то положение записи внутри такой таблицы может зависеть только от данного ключа. Оно не может зависеть от расположения других ключей, как это имеет место в дереве. Наиболее эффективным способом организации такой таблицы является массив, т.е. каждая запись хранится по некоторому конкретному смещению по отношению к базовому адресу таблицы. Если ключи записей являются целыми числами, то сами ключи могут использоваться как индексы в массиве. Рассмотрим некоторый пример такой системы. Предположим, что некоторая фирма- производитель имеет файл с наименованиями производимых изделий, состоящий из 100 позиций, причем каждая позиция имеет уникальный номер из двух цифр. Тогда обычным способом хранения этого файла является описание некоторого массива: 10 DIM PART(99) где PART(I) (и, возможно, дополнительные поля, индексируемые по I) представляет запись, номер позиции которой равен I. (Здесь и в дальнейшем в этом разделе мы предполагаем, что нижней границей массива является 0.) В этой ситуации номера изделий являются ключами, которые используются как индексы в данном массиве. Та же самая структура может использоваться для организации файла производимых изделий, даже если на складах фирмы накопилось до 1000 наименований изделий (при условии что ключи по-прежнему состоят из двух цифр). Хотя многие ячейки в массиве PART тогда бы соответствовали несуществующим ключам, эти потери компенсируются преимуществом прямого доступа к каждому из существующих изделий. К сожалению, однако, такая система не всегда имеет практический смысл. Например, предположим, что фирма имеет некоторый файл производимых изделий, состоящий из более 100 пунктов, и ключ каждой записи .ярляется номером изделия из семи цифр. Для применения прямой индексации с использованием полного семизначного ключа потребовался бы массив из 100 млн. элементов. Ясно, ттто это привело бы к потере неприемлемо большого пространства, поскольку совершенно невероятно, что какая-либо фирма может иметь больше чем несколько тысяч наименований изделий. 528-
Поэтому необходим некоторый метод преобразования ключа в какое-либо целое число внутри ограниченного диапазона. В идеале в одно и то же число не должны преобразовываться два различных ключа. К сожалению, такого идеального метода обычно не существует. Попытаемся разработать методы, которые приближаются к идеальным, и определить, какие действия надо предпринять, когда идеальный случай не достигается. Рассмотрим опять пример с файлом наименований изделий фирмы, в котором каждая запись задается ключом из семизначного номера изделия. Предположим, что фирма имеет менее 1000 наименований изделий и что для каждого изделия используется только одна запись. Тогда для хранения всего файла будет достаточно массива из 1000 элементов. Этот массив индексируется целым числом в диапазоне от 0 до 999 включительно. В качестве индекса записи об изделии в этом массиве используются три последние цифры номера изделия. Это показано на рис. 9.3.1. Отметим, что два ключа, которые близки друг к другу как числа (такие как 4618396 и 4618996), могут распо- Позиция 0 1 2 3 395 396 397 398 399 400 401 990 991 992 993 994 995 996 997 998 999 Ключ 4967000 | 8421002 *"^ | 4618396 4957397 1286399 0000*90 0000991 1200992 0047993 9846995 4618996 4967997 0001999 Запись Рис. 9.3.1. Записи об изделиях, хранящиеся в массиве· 529
лагаться дальше друг от друга в этой таблице, чем два ключа, которые значительно различаются как числа (такие как 0000991 и 9846995). Это происходит из-за того, что для определения позиции записи используются только три последние цифры ключа. Функция, которая трансформирует ключ в некоторый индекс в таблице, называется хеш-функцией. Если h является некоторой хеш-функцией, a key — некоторый ключ, то h(key) называется значением хеш-функции от ключа key и является индексом, по которому должна быть помещена запись с ключом key. Если мы обозначим остаток от деления χ на у как mod(x, у), то хеш- функция для вышеприведенного примера есть h (key) = mod (key, 1000). Значения, которые выдает функция h, должны покрывать все множество индексов в таблице. Например, функция mod(x, 1000) может дать любое целое число в диапазоне от 0 до 999 в зависимости от значения х. Как мы вскоре увидим, хорошей идеей является таблица, размер которой немного больше, чем число вставляемых записей. Это иллюстрируется на рис. 9.3.1, где несколько позиций таблицы не используются. Этот метод имеет один недостаток. Предположим, что существуют два ключа kl и к2 такие, что h(kl)=h(k2). Когда запись с ключом kl вводится в таблицу, она вставляется в позицию h(kl). Но когда хешируется ключ к2, получаемая позиция является той же позицией, в которой хранится запись с ключом kl. Ясно, что две записи не могут занимать одну и ту же позицию. Такая ситуация называется коллизией при хешировании или столкновением. В примере с изделиями на рис. 9.3.1 коллизия при хешировании произойдет, если в таблицу будет добавлена запись с ключом 0596397. Далее мы будем исследовать возможности, как найти решение в такой ситуации. Следует отметить, однако, что хорошей хеш-функцией является такая функция, которая минимизирует коллизии и распределяет записи равномерно по всей таблице. Поэтому и желательно иметь массив с размером больше, чем число реальных записей. Чем больше диапазон хеш-функции, тем менее вероятно, что два ключа дадут одинаковое значение хеш-функции. Конечно, при этом возникает компромисс между временем и пространством. Наличие пустых мест в массиве неэффективно с точки зрения использования пространства, но при этом уменьшается необходимость разрешения коллизий при хешировании, что, следовательно, является более эффективным в смысле временных затрат. Разрешение коллизий при хешировании методом открытой адресации Посмотрим, что произойдет, если мы захотим ввести в таблицу на рис. 9.3.1 некоторый новый номер изделия 0596397. Используя хеш-функцию mod (key, 1000), мы найдем, что 530
h (0596397) =397 и что запись для этого изделия должна находиться в позиции 397 в массиве. Однако позиция 397 уже занята, поскольку там находится запись с ключом 4957397. Следовательно», запись с ключом 0596397 должна быть вставлена в таблицу в другом месте. Самым простым методом разрешения коллизий при хешировании является помещение данной записи в следующую свободную позицию в массиве. Например, на рис. 9.3.1 запись с ключом 0596397 помещается в ячейку 398, которая пока свободна, поскольку 397 уже занята. Когда эта запись будет вставлена, другая запись, которая хешируется в позицию 397 (с таким ключом, как 8764397) или в позицию 398 (с таким ключом, как 2194398), вставляется в следующую свободную позицию, которая в данном случае равна 400. Этот метод называется линейным опробованием, л он является примером некоторого общего метода разрешения коллизий при хешировании, который называется повторным хешированием или открытой адресацией. В общем случае функция повторного хеширования rh воспринимает один индекс в массиве и выдает другой индекс. Если ячейка массива h(key) уже занята некоторой записью с другим ключом, то функция rh применяется к значению h(key) для того, чтобы найти другую ячейку, куда может быть помещена эта запись. Если ячейка rh(h(key)) также занята, то хеширование выполняется еще раз и проверяется ячейка rh(rh(h(key))). Этот процесс продолжается до тех пор, пока не будет найдена пустая ячейка. Таким образом, мы можем написать алгоритм поиска и вставки, используя хеширование, следующим образом. Мы предполагаем, что h является хеш-функцией, a rh — функцией повторного хеширования. Для указания пустой записи используется специальное значение nullkey, а для указания индекса вставляемой записи — переменная index. i=h(key) 'хешируем ключ while (k(i)< >key) and (k(i)< >nullkey) do i—rh(i) 'мы должны выполнить повторное хеширование endwhile if k(i)=nullkey then 'вставляем запись в пустую позицию k(i)=key r(i)=rec endif index=i return В примере на рис. 9.3.1 h(key) является функцией mod (key, 1000), a rh(i) —функцией mod(i+l, 1000) т.е. повторное хеширование какого-либо индекса есть следующая последовательная позиция в данном массиве, за исключением того случая, что повторное хеширование 999 дает 0). Рассмотрим данный алгоритм более подробно, чтобы понять, 531
можно ли определить свойства некоторой «хорошей» функций повторного хеширования. Сфокусируем наше внимание на цикле, поскольку число итераций определяет эффективность поиска. Выход из этого цикла может быть в одном из двух случаев* Или переменная i принимает такое значение, что k(i) равно key (и в этом случае найдена запись), или переменная i принимает такое значение, что k(i) равно nullkey (и в этом случае найдена пустая позиция и запись может быть вставлена). Может случиться, однако, что данный цикл будет выполняться бесконечно. Для этого существуют две возможные причины. Во-первых, таблица может быть полной, так что вставить какие-либо новые записи невозможно. Эта ситуация может быть обнаружена при помощи счетчика числа записей в таблице. Когда этот счетчик равен размеру таблицы, не надо проверять дополнительные позиции. Возможно, однако, что данный алгоритм приведет к бесконечному зацикливанию, даже если имеются некоторые пустые позиции (или даже много таких позиций). Предположим, например, что в качестве функции повторного хеширования используется функция rh(i) =mod(i + 2, 1000). Тогда любой ключ, который хешируется в нечетное целое число, повторно хе- шируется в следующие за ним нечетные целые числа, а любой ключ, который хешируется в четное число, повторно хешируется в следующие за ним четные целые числа. Рассмотрим ситуацию, при которой все нечетные позиции в таблице заняты, а все четные свободны. Несмотря на тот факт что половина позиций в массиве свободна, невозможно вставить новую запись, чей ключ хешируется в нечетное число. Конечно, маловероятно, что заняты все нечетные позиции, а ни одна из четных позиций не занята. Однако если использовать функцию повторного хеширования rh(i) = mod(i + 200, 1000), то каждый ключ может быть помещен только в одну из пяти позиций (поскольку mod(x, 1000) = mod(x +1000, 1000)), и вполне возможно, что эти пять позиций будут заняты, а большая часть таблицы будет пустой. Свойство хорошей функции повторного хеширования состоит в том, что для любого индекса i последовательно выполняемые повторные хеширования rh (i), rh (rh (i)),... располагаются на максимально возможное число целых чисел от 0 до m—1 (где m является числом элементов в таблице), причем в идеале — на все эти числа. Функция повторного хеширования rh(i) =mod(i+l, 1000) обладает этим свойством. И действительно, любая функция rh(i) =mod(i+c,m), где с — некоторая константа, такая что сит являются взаимно простыми числами (т. е. они одновременно не могут делиться нацело ни на какое число, кроме 1), выдает последовательные значения, которые распространяются на всю таблицу. Читателю предлагается подтвердить этот факт, выбрав несколько примеров, а доказательство остается в качестве упражнения. 532
Имеется другая мера пригодности функции хеширования. Рассмотрим функцию повторного хеширования mod(i+l, m). Предполагаем, что функция хеширования выдает индексы, которые равномерно распределены в интервале от 0 до m—1 (т. е, вероятность того, что функция h(key) будет равна какому-либо конкретному числу в этом диапазоне, одинакова для всех чисел). Тогда первоначально, когда весь массив пуст, равновероятно, что некоторая произвольная запись будет помещена в любую заданную пустую позицию в массиве. Однако, когда записи будут вставлены и будет разрешено несколько коллизий при хешировании, это уже не будет справедливо. Например, обращаясь к рис. 9.3.1, видно, что помещение записи в позицию 994 в пять раз более вероятно, чем в позицию 401. Это происходит из-за того, что любая запись, чей ключ хешируется в позиции 990, 991, 992, 993 или 994, будет помещена в 994, а в позицию 401 будет помещена только та запись, чей ключ хешируется в эту позицию. Это явление, при котором два ключа, которые хешируются в разные значения, конкурируют друг с другом при повторных хешированиях, называется скучиванием. Это же явление происходит в случае использования функции повторного хеширования rh(i) =mod(i + c, m). Например, если* m = 1000, с=21 и позиции 10, 31, 52, 73 и 94 заняты, то любая запись, чей ключ является одним из этих целых чисел, будет помещена в позицию 115. В действительности любая функция повторного хеширования, зависящая только от индекса, который надо повторно хешировать, будет вызывать скучивание. Одним способом исключения скучивания является использование двойного хеширования, которое состоит в использовании двух хеш-функций — hi (key) и h2(key). Функция hi, которая называется первичной хеш-функцией, используется первой при определении той позиции, в которую должна быть помещена запись. Если эта позиция занята, то последовательно используется функция повторного хеширования rh(i) =mod(i + h2(key), m) до тех пор, пока не будет найдена пустая позиция. Записи с ключами keyl и кеу2 не будут соревноваться за одну и ту же позицию, если h2(keyl) не равно h2(key2). Это справедливо, несмотря на ту возможность, что hi (keyl) может в действительности равняться hl(key2). Функция повторного хеширования* зависит не только от индекса, который надо повторно хешировать, но также от первоначального ключа. Отметим, что значение h2(key) не нужно еще раз вычислять при каждом повторном хешировании: его необходимо вычислить только один раз для каждого ключа, который надо повторно хешировать. Следовательно, в оптимальном случае функции hi и h2 должны быть выбраны так, чтобы они выполняли хеширование и повторное хеширование равномерно в интервале от 0 до m—1, а также минимизировали скучивание. Такие функции не всегда просто* найти. 533
Другой подход заключается в том, чтобы сделать функцию повторного хеширования зависящей от числа раз, которые эта функция применяется к некоторому конкретному значению хеширования. При этом подходеrh является некоторой функцией от двух аргументов. Функция rh(i, j) выполняет повторное хеширование целого числа i, если для данного ключа повторное хеширование выполняется j-й раз. Одним примером такой функции является rh(i, j)=mod(i+j, m). Первое повторное хеширование дает rhl =rh(h(key), l) =mod (h (key) + 1, m), второе дает rh2=mod(rhl+2, m), третье хеширование дает rh3=mod(rh2 + + 3, m) и т.д. Разрешение коллизий при хешировании методом цепочек Имеется несколько причин, почему повторное хеширование может быть неадекватным методом для обработки коллизий при хешировании. Во-первых, оно предполагает фиксированный размер таблицы. Если число записей превысит этот размер, то их невозможно вставлять без выделения таблицы большего размера и повторного вычисления значений хеширования для ключей всех записей, находящихся уже в таблице, используя новую хеш-функцию. Более того, из такой таблицы трудно удалить запись. Например, предположим, что в позиции ρ находится запись rl. При добавлении некоторой записи г2, чей ключ к2 хешируется в р, эта запись должна быть вставлена в первую свободную позицию rh(p), rh(rh(p)), .... Предположим, что rl затем удаляется, так что позиция ρ становится свободной. Поиск записи г2 начинается с позиции h(k2), что равно р. Но поскольку эта позиция уже свободна, процесс поиска может ошибочно сделать вывод, что записи г2 нет в таблице. Одно возможное решение этой проблемы состоит в маркировании удаленной записи как «удаленная», а не «свободная» и продолжении поиска, когда встречается такая «удаленная» позиция. Но это реально, если только выполняется небольшое число удалений. В противном случае при неудачном поиске придется организовать поиск по всей таблице, потому что большинство позиций будет отмечено как «удаленные», а не «свободные». Другой метод разрешения коллизий при хешировании называется методом цепочек. Он представляет собой организацию связанного списка из всех записей, чьи ключи хешируются в одно и то же значение. Предположим, что хеш-функция выдает значения в диапазоне от 0 до m—1. Тогда описывается некоторый массив bucket, имеющий размер m и состоящий из узлов заголовков. Элемент bucket (i) указывает на список всех записей, чьи ключи хешируются в i. При поиске записи осуществляется доступ к заголовку списка, который занимает пози- 534
цию i в массиве узлов. Если запись не найдена, то она вставляется в конец списка. На рис. 9.3.2 показан метод цепочек. Предположим, что имеется массив из 10 элементов и что хеш- функция равна mod (key, 10). Ключи на этом рисунке представлены в таком порядке: 75 66 42 192 91 40 49 87 67 16 417 130 372 227 null к 40 г nxt 91 null 130 null 192 372 null +-75 null 66 16 null +4 227 null 49 null Рис. 9.З.2. Разрешение коллизий при хешировании методом цепочек. Можно написать алгоритм поиска и вставки, используя метод цепочек с хеш-функцией h и массивом узлов bucket (узлы содержат поля — поле к для ключа, поле г для записи и поле nxt в качестве указателя на следующий узел в списке): i=h(key) q=null ρ=bucket (i) while p< >null do if k(p)=key then search = ρ return endif q=p p=nxt(p) endwhile 'ключ не найден, вставляем новую запись s=*getnode 535
k(s)=key r(s)=rec nxt(s)=null if q=null then bucket (i)=s else nxt(q)=s endif search = s return Удаление узла из таблицы, которая построена по методу депочек, заключается просто в исключении узла из связанного списка. Удаленный узел никак не влияет на эффективность алгоритма поиска. Алгоритм будет работать так, как если бы этот узел никогда не вставлялся в таблицу. Отметим, что эти списки могут быть динамически переупорядочены для получения большей эффективности поиска при помощи методов, описанных в разд. 9.2. Основным недостатком метода цепочек является то, что для узлов указателей требуется дополнительное пространство. Однако в алгоритмах, которые используют метод цепочек, первоначальный массив меньше, чем в алгоритмах, которые используют повторное хеширование. Это происходит из-за того, что при методе цепочек не так катастрофично, если весь массив становится заполненным. Всегда имеется возможность выделить дополнительные узлы и добавить их к различным спискам. Конечно, если эти списки станут очень длинными, то теряет смысл вся идея хеширования — прямая адресация и в результате эффективный поиск. Выбор хеш-функции Обратимся теперь к вопросу о том, как выбрать хорошую хеш-функцию. Ясно, что эта функция должна создавать как .можно меньше коллизий при хешировании, т. е. она должна равномерно распределять ключи на имеющиеся индексы в массиве. Конечно, нельзя определить, будет ли некоторая конкретная хеш-функция распределять ключи правильно, если эти ключи заранее не известны. Однако, хотя до выбора хеш-функции редко известны сами ключи, некоторые свойства этих ключей, которые влияют на их распределение, обычно известны. Например, наиболее изнестная хеш-функция (которую мы использовали в примерах этого раздела) использует метод деления, при котором некоторый целый ключ делится на размер таблицы и остаток от деления берется в качестве значения хеш- -функции. Эта хеш-функция обозначается h (key) = mod (key, m). Предположим, однако, что m равно 1000 и что все ключи оканчиваются на три одинаковые цифры (например, последние три цифры номера изделия могут обозначать номер фабрики и 536
программа пишется для этой фабрики). Тогда остаток от деления на 1000 для всех ключей будет одним и тем же, так что для всех записей, кроме первой, будет происходить коллизия при хешировании. Ясно, что при таком наборе ключей должна использоваться другая хеш-функция. Было найдено, что наилучшие результаты для метода деления получаются тогда, когда размер таблицы m является простым числом (т. е. m не делится ни на какое положительное целое число, кроме 1 и т). При использовании другого метода, известного как метод середины квадрата, ключ умножается сам на себя и в качестве индекса используется несколько средних цифр этого квадрата. Если данный квадрат рассматривается как десятичное число, то размер таблицы должен быть некоторой степенью 10, а если он рассматривается как двоичное число, то размер таблицы должен быть степенью 2. (Причиной возведения числа в квадрат до извлечения средних цифр является то, что все цифры первоначального числа дают свой вклад в значение средних цифр квадрата.) При методе свертки ключ разбивается на несколько сегментов, над которыми выполняется операция сложения или нетождественности для формирования хеш-функции. Например, предположим, что внутреннее представление некоторого ключа в виде последовательности разрядов имеет вид 010111001010110 и для индекса отводится пять разрядов. Над тремя последовательностями разрядов 01011, 10010 и 10110 выполняется операция нетождественности, что дает 01111 или двоичное представление числа 15. (Операция нетождественности двух разрядов дает 1, если значения этих двух разрядов различны, и 0, если значения их равны.) Имеется много других хеш-функций, каждая со своими преимуществами и недостатками в зависимости от набора хе- шируемых ключей. При выборе хеш-функции важна эффективность ее вычисления, так как поиск некоторого объекта за одну попытку не будет эффективнее, если на эту попытку затрачивается больше времени, чем на несколько попыток при альтернативном методе. Если ключи не являются числами, то они должны быть преобразованы в целые числа перед применением описанных выше хеш-функций. Для этого имеется несколько способов. Например, для строки символов в качестве двоичного числа может интерпретироваться внутреннее двоичное представление кода каждого символа. Недостатком этого является то, что для большинства ЭВМ двоичные представления всех букв или цифр очень похожи друг на друга. Если ключи состоят только из одних букв, то индекс каждой буквы в алфавите может использоваться для создания некоторого целого числа. Так, первая буква алфавита (А) представляется цифрами 01, а 14-я буква (N) представляется цифрами 14. Ключ «HELLO» представляется целым числом 0805121215. Когда существует некото- 537
рое целое представление строки символов, то для сведения его к приемлемому размеру может быть использован метод свертки или середины квадрата. Упражнения 1. Реализуйте на языке Бейсик функцию mod(x, у). 2. Напишите на языке Бейсик программу search, которая организует поиск по таблице хеширования TBLE некоторой записи с ключом KEY. Входной информацией для данной программы являются некоторый целый ключ и некоторая таблица, описанная следующими операторами: 10 MAXTBLE=99 20 DIM TBLE (MAXTBLE,3) 30 K=l 40 R=2 50 F=3 Элементы TBLE (I, К) и TBLE (I, R) являются соответственно 1-м ключом и записью. Элемент TBLE(I, F) принимает значение FALSE, если 1-я позиция в таблице свободна, и значение TRUE, если она занята. Если запись с ключом KEY присутствует в таблице, то эта программа выдает некоторое число в диапазоне от 0 до MAXTBLE. Если такой записи не -существует, то выдается значение —1. Предполагается наличие некоторой подпрограммы хеширования h и подпрограммы повторного хеширования rh, каждая из которых выдает целые числа в диапазоне от 0 до MAXTBLE. 3. Напишите на языке Бейсик программу sinsert для поиска и вставки в некоторую таблицу хеширования, описанную так же, как в упражнении 2. 4. Разработайте некоторый механизм для обнаружения ситуаций, когда был осуществлен поиск по всем возможным позициям повторного хеширования, для некоторого заданного ключа. Включите этот метод в программы search и sinsert из упражнения 2 и 3. 5. Предположим, что равновероятно, что некоторый ключ будет любым целым числом в диапазоне от а до Ь. Предположим, что для получения некоторого двоичного числа в диапазоне от 0 до 2к—1 при хешировании используется метод середины квадрата. Равновероятно ли, что результат будет любым целым числом в этом же диапазоне? Почему? 6. Пусть дана некоторая программа на языке Бейсик, которая реализует некоторую хеш-функцию h(key) для таблицы размером т. (а) Напишите на языке Бейсик некоторую программу моделирования для определения следующих величин после того, как будет сгенерировано 0,8т случайных ключей. Ключи должны быть случайными целыми числами, состоящими из цифр. (1) Процент целых чисел в диапазоне от 0 до ш—1, которые не равны h(key) для некоторого сгенерированного ключа. (2) Процент целых чисел в диапазоне от 0 до т—1, которые равны h(key) для более чем одного сгенерированного ключа. (3) Максимальное число ключей, которые хешируются в одно значение в диапазоне от 0 до т—1. (4) Среднее число ключей, которые хешируются в значения в диапазоне от 0 до m—1, не считая те значения, в которые не хешируется ни один ключ. (б) Запустите данную программу, чтобы проверить равномерность каждой из следующих хеш-функций: (П h(key)=mod(key, m) для m, равного некоторому простому числу. (2) h(key)=mod(key, m) для m, равного некоторой степени 2. (3) Метод свертки, использующий операцию нетождественности для создания индексов размером пять разрядов, где ш=32. (4) Метод середины квадрата, использующий десятичную арифметику для создания индексов размером четыре цифры, где т=10 000. 538
7. Если некоторая таблица хеширования содержит m позиций и в данный момент в таблице находится η записей, то коэффициент загрузки определяется как n/m. Покажите, что если некоторая хеш-функция имеет равномерное распределение ключей в m позициях таблицы и если If есть коэффициент загрузки этой таблицы, то (п—1) *lf/2 ключей из η ключей в таблице при вставке будет подвержено коллизии с некоторым ранее введенным ключом. 8. Предположим, что в некоторой таблице хеширования из m элементов заняты q случайных позиций и что для создания индекса в таблице используются функции хеширования и повторного хеширования, которые дают одинаковую вероятность для любого индекса. Чему равно в терминах m и η среднее число сравнений, которые надо выполнять для того, чтобы вставить некоторый новый элемент? Объясните, почему линейное опробование не удовлетворяет этому условию. 9.4. ПРИМЕРЫ И ПРИЛОЖЕНИЯ Мы рассмотрим в этом разделе несколько проблем, частично обсуждавшихся в предыдущих главах, для того чтобы увидеть, как могут применяться методы поиска для создания более эффективного решения задачи. Будут рассмотрены компромиссы по времени и пространству для разных решений и показано, какую важную роль играет поиск при решении задач. Пример 9.4.1. Алгоритм Хаффмена Первым примером является алгоритм Хаффмена из разд. 6.3. Читателю рекомендуется еще раз прочитать этот раздел, чтобы вспомнить представленное там решение. Сфокусируем наше внимание на программе findcode, в особенности на цикле поиска узлов с наименьшим значением FREQ, который выполняется при помощи оператора FORI=N + l TO 2*N— 1 Узлы строго бинарного дерева с N листьями представляются целыми числами в диапазоне от 1 до 2*N—1. Массив FTHER содержит указатели на отцов этих узлов в дереве, а массив INFO содержит информацию, ассоциированную с узлами. Мы начинаем с INFO(I), определенного для I в диапазоне от 1 до N, и с FTHER(I), равного 0 для всех I. То есть нам даются частоты первоначальных символов, каждый из которых является некоторым корнем своего собственного бинарного дерева, состоящего из одного элемента. Эти узлы надо объединить в одно бинарное дерево. Незанятые узлы (от N + 1 до 2*N—1) можно представить себе как некоторый доступный список узлов. Алгоритм последовательно просматривает этот список, устанавливая, что каждый узел является отцом предыдущих двух выделенных узлов. При выборе двух предыдущих выделенных узлов и установки их как сыновей некоторого заново выделенного узла эта программа организует поиск двух узлов с наименьшими значениями FREQ по множеству узлов, не имеющих отцов. Мы пов- 539
торяем ту часть программы, которая реализует этот алгоритм. Переменная I является индексом заново выделенного узла, переменные Р1 и Р2 устанавливаются так, чтобы указывать на два узла, которые найдены в процессе поиска, а переменные Л и J2 соответственно являются относительными частотами node(Pl) и node(P2): 240 Л =9999 250 J2=9999 260 Р1=0 270 Р2=0 280 FOR Q=l TO 1-1 290 IFFTHER(Q)=0 THEN IF INFO(Q)<Jl THEN P2=P1: J2=J1: Pl-Q: Jl = INFO(Q) ELSE IF INFO(Q)<J2 THEN P2=Q: J2=INFO(Q) 300 NEXTQ l ' Когда два узла PI и Р2 идентифицированы, они задаются как сыновья узла I при помощи следующих операторов: 310 Р=1: 'выделить узел node(P) 320 INFO(P)=Jl+J2: 'вычислить частоту нового узла 330 'установить Р1 и Р2 так, чтобы Р1 указывало на левое поддерево Р, 'а Р2 — на правое поддерево Ρ 340 FTHER(P1) = -P 350 FTHER(P2)=P Данный процесс поиска является неэффективным, потому что каждый раз, когда выделяется новый узел, все предыдущие узлы должны быть проверены при поиске двух корневых узлов с наименьшей частотой. Первое улучшение, которое может быть сделано, состоит в том, чтобы иметь отдельный список корневых узлов (т. е. узлов Q таких, что FTHER(Q)=0). Если это сделано, то нет необходимости организовывать поиск по всем выделенным узлам, а только по тем, у которых нет отца. Помимо этого из цикла можно исключить проверку FTHER(Q) на равенство 0. Эти улучшения не достаются даром: требуется дополнительное пространство для указателей, которые связывают вместе список корневых узлов, а для добавления элемента в список или для удаления из списка требуется дополнительное время. Это общие недостатки, которые должны быть учтены при переходе от представления данных в виде массива к представлению в виде списка, — в массиве элементы упорядочены неявно, а в списке они должны быть явно связаны. (Однако в этом случае можно поле FTHER во всех корневых узлах использовать для связи элементов в списке. Ради простоты мы не будем далее развивать эту возможность, а оставляем ее в качестве упражнения читателю.) Таким образом, мы можем добавить переменную FIRST- ROOT и массив NXTROOT, определенный оператором 10 DIM NXTROOT (2*N—1) 540
Элемент NXTROOT(I) не определен, если I не является корневым узлом. Если I — корневой узел, то NXTROOT(I) является следующим корневым узлом после I в списке корневых узлов. Если I является последним корневым узлом в списке, то NXTROOT(I) равно 0. Переменная FIRSTROOT является индексом первого корневого узла в списке. Эти переменные инициализируются следующим образом: Ю0 FIRSTROOT=l 110 FOR 1 = 1 ТО N-1 120 NXTROOT(I)=I+l 130 NEXT I 140 NXTROOT(N)=0 Данный поиск тогда может быть переписан следующим образом. Переменная К остается на один шаг позади переменной Ρ при прохождении списка корневых узлов. Переменные К1 и К2 указывают на узлы, непосредственно предшествующие соответственно Р1 и Р2 в списке корневых узлов. Их значения будут использоваться тогда, когда из списка удаляются узлы Р1 и Р2: 300 Л =9999 310 J2=9999 320 P1=Q 330 Р2=0 340 К1=0 360 К2=0 360 К=0 370 Ρ-FIRSTROOT 380 IF P=0 THEN GOTO 440 390 'в противном случае просматриваем список корневых узлов 400 IF INFO(P)<Jl THEN P2=P1: J2=J1: P1 = P: Jl = INFO(P): K2-K1: Kl=K ELSE IF INFO(P)<J2 THEN P2=P: J2=INFO(P): K2-K 410 K=P 420 P-NXTROOT(P) 430 GOTO 380 440 ... Программа для удаления узлов Р1 и Р2 из списка корневых узлов, вставки их в бинарное дерево и вставки нового корневого узла I в список корневых узлов становится более сложной. Следующий раздел программы выполняет эти задачи, вставляя I на место Р2 и удаляя Р1 полностью из списка: 440 'вставляем I в бинарное дерево 450 FTHER(P1) = -I 460 FTHER(P2) = I 470 INFO(I)=Jl+J2 480 'заменяем узел node(P2) в списке корневых узлов на I 490 NXTROOT(I)=NXTROOT(P2) 500 IF K2=0 THEN FIRSTROOT^I ELSE NXTROOT(K2)=I 510 IF NXTROOT(I)=Pl THEN K1 = I 520 'удаляем узел PI из списка корневых узлов 530 IF K1=0 THEN FIRSTROOT=NXTROOT(Pl) ELSE NXTROOT(Kl)=NXTROOT(Pl) 541
Этот участок программы может быть несколько упрощен и сделан более эффективным, если список корневых узлов представляется в виде некоторого циклического списка. Оставим читателю эту реализацию в качестве упражнения. Дальнейший выигрыш в эффективности можно получить, если держать список корневых узлов отсортированным, причем по возрастанию значения поля INFO. Тогда исключается поиск двух узлов с наименьшей частотой: они являются первыми двумя узлами в таком списке. Таким образом, целый цикл поиска может быть заменен на два таких оператора: 300 Pl=FIRSTROOT 310 P2 = NXTROOT(Pl) Однако, для того чтобы поддерживать данный список отсортированным, сперва должны быть отсортированы N первоначальных символов, используя один из методов сортировки из гл. 8. Кроме того, каждый раз, когда в список корневых узлов вставляется новый узел I, он должен быть вставлен в соответствующую для него позицию. Участок программы для вставки I в бинарное дерево и в упорядоченный список корневых узлов становится поэтому следующим: 320 'вставить узел node(I) в бинарное дерево 330 FTHER(P1) = -I 340 FTHER(P2)-I 350 INFO(I)=INFO(Pl)+INFO(P2) 360 'удалить узлы node (PI) и node(P2) из списка корневых узлов и вставить их в этот список 370 FIRSTROOT=NXTROOT(P2) 380 К=0 390 P=FIRSTROOT 400 IF P-0 THEN GOTO 460 410 IF INFO(P)> = INFO(I) THEN GOTO 450 420 K=P 430 P=NXTROOT(P) 440 GOTO 400 450 IF K=0 THEN FIRSTROOT= I ELSE NXTROOT(K)=I 460 NXTROOT(I)=P Таким образом, процесс поиска (нахождение двух узлов с наименьшей частотой) был перенесен из первого шага во второй (вставка нового узла на соответствующее ему место). Однако во втором шаге нет необходимости вести поиск по всему списку корневых узлов, а только до того места, пока не найдена нужная позиция для нового узла. Заметно это быстрее или нет, зависит от первоначального распределения частот. Например, если первоначальные частоты являются последовательными целыми числами, начиная с N, то каждый новый выделенный узел должен быть помещен в конце списка. В большинстве случаев, однако, время поиска уменьшается в два раза. Эта экономия должна быть взвешена с затратами на перво- 542
начальную сортировку N исходных символов, которые могут быть достаточно большими. По мере возрастания N экономия на времени поиска становится все значительней, но затраты на сортировку возрастают. Предоставляем читателю определить, какой метод является более эффективным для различных значений N. Упражнения 1. Перепишите программу, реализующую алгоритм Хаффмена, используя в качестве списка корневых узлов неупорядоченный циклический список. 2. Реализуйте алгоритм Хаффмена с некоторым упорядоченным списком корневых узлов, используя различные методы сортировки из гл. 8 для создания первоначально упорядоченного списка. 3. Как будет изменяться эффективность реализаций в упражнении 2 в зависимости от распределения начальных частот? Можно ли найти некоторое распределение начальных частот, такое что заново выделенный узел всегда помещается в конец списка корневых узлов? Можно ли найти некоторое распределение, такое что заново выделенный узел всегда помещается в начало списка? Объясните это. 4. Модифицируйте программу Хаффмена так, чтобы поле FTHER использовалось для связи между собой всех корневых узлов. Поле NXTROOT больше не нужно. Пример 9.4.2. Проблема планирования Нашим следующим примером применения методов поиска является задача планирования из разд. 7.3. И снова читателю рекомендуется перечитать этот раздел, чтобы освежить в памяти данную задачу и приведенные там ее решения. Основная проблема поиска в алгоритме планирования заключается в поиске по узлам некоторого графа, представленного связанной структурой данных. Этот поиск осуществляется в двух различных местах решения, представленного в разд. 7.3. (Мы теперь сфокусируем наше внимание на первом решении, представленном в разд. 7.3, в котором для узлов графа используется список с одной связью. Это решение представлено в том разделе как первая программа на языке Бейсик с именем schedule.) 1. Когда вводится отношение предшествования, указывающее, что задача STASK должна быть выполнена раньше задачи S2TASK, от задачи S1TASK к задаче S2TASK должна быть проведена дуга. Поиск узлов, содержащих соответственно S1TASK и S2TASK, должен быть осуществлен по всем узлам данного графа. Если узлов, содержащих S1TASK и S2TASK, не существует, то они должны быть выделены и добавлены к списку узлов графа. Этот поиск и вставка выполняются при помощи подпрограммы find, к которой обращаются дважды внутри цикла, состоящего из операторов с номерами 250—370. (В действительности мгновенное улучшение эффек- 543
тивности получилось бы, если прохождение списка узлов графа выполнялось бы только один раз, для каждой пары входных задач, чтобы искать одновременно S1TASK и S2TASK.) 2. На фазе вывода из программы (цикл, состоящий из операторов с номерами 400—770) каждый раз, когда надо найти те узлы, у которых поле COUNT равно 0, должен быть организован поиск по всему списку узлов графа. Эти узлы удаляются из данного графа и помещаются в другой список, из которого они потом печатаются. Как отмечалось в разд. 7.3 единственной причиной необходимости этого поиска является то, что узел не может быть удален из списка с одной связью без указателя на его предшественника. Это не позволяет нам поместить какой-либо узел в выходной список в то время, когда его счетчик уменьшился до 0, потому что в это время у нас имеется только указатель на сам узел, а не на его предшественника. Одним способом исключения этого поиска, как отмечаладь в разд. 7.3, является представление списка узлов графа в виде списка с двойными связями, так что любой узел содержит указатель на своего предшественника помимо указателя на своего преемника. Тщательный анализ программы schedule дает то интересное наблюдение, что нет никаких причин хранить узлы графа в некотором списке помимо выполнения двух поисков, указанных выше. Никогда не делается прохождение списка узлов графа по какой-либо причине, отличной от поиска конкретных узлов в одной из двух точек программы. Но поскольку поиск по неупорядоченному списку очень неэффективен, то другой способ организации узлов графа должен существенно улучшить эффективность поиска без отрицательных эффектов на остальную часть программы. Какие структуры данных следует нам использовать для представления узлов графа? При добавлении новых узлов к графу (п. 1 выше) должна быть возможность доступа к узлу графа из строки с именем задачи его представляющей. Так, поле SUBTASK в узле графа выступает в роли ключа записи, которая сама является узлом. Самым непосредственным способом доступа к узлу по его ключу является использование хеш- функции. Если будет использоваться метод хеширования, то мы должны определить, как будут обрабатываться коллизии при хешировании. Это приводит нас непосредственно к вопросу о числе допустимых узлов графа. Если коллизии при хешировании разрешаются при помощи повторного хеширования, то число узлов графа ограничено числом позиций в таблице хеширования. С другой стороны, если коллизии разрешаются методом цепочек, то допускается неограниченное число узлов графа. Поскольку в разд. 7.3 была использована реализация в виде массива, то мы будем придерживаться этой реализации и будем использовать для разрешения коллизий метод повторного хеши- 544
рования. Множество узлов графа описывается следующими операторами: 30 DEFSTR S 40 NMAX = 100 50 DIM SUBTASK(NMAX) 60 DIM COUNT(NMAX) 70 DIM ARCPTR (NMAX) 80 DIM NXTNODE (NMAX) Узлы графа больше не связаны между собой в списке. Указатель NXTNODE в каждом узле не используется до тех пор, пока данный узел не будет помещен в выходной список. Предполагаем существование некоторой хеш-функции hash, которая преобразует строку символов в некоторое целое число в диапазоне от 1 до 100, и некоторой функции повторного хеширования rehash, которая воспринимает некоторое целое число в этом диапазоне и выдает целое число в том же диапазоне. Тогда подпрограмма find для поиска и вставки узла в граф может быть переписана следующим образом: 1000 'подпрограмма find 1010 'входы: STASK 1020 'выходы: FIND 1030 'локальные переменные: CNT, HASH, I, INDEX, J, REHASH 1040 GOSUB 8000: 'подпрограмма hash воспринимает переменную STASK 'и устанавливает переменную HASH 1050 I-HASH 1060 IF SUBTASK(I) =« » THEN SUBTASK(I)=STASK: COUNT (I)-0: FIND=I: RETURN 1070 IF SUBTASK (I) = STASK THEN FiN©-"iT~RETURN 1080 CNT=0 1090 INDEX=I 1100 GOSUB 9000: 'подпрограмма rehash воспринимает переменную 'INDEX и устанавливает переменную REHASH 1110 J=REHASH 1120 IF J-1 OR CNT=NMAX THEN GOTO 1180 1130 IF SUBTASK(J)=«» THEN SUBTASK (J)-STASK: COUNT (J) =0: FIND=J: RETURN 1140 IF SUBTASK (J) -STASK THEN FIND-J: RETURN 1150 INDEX-J 1160 CNT-CNT+1 1170 GOTO 1100 1180 PRINT «ОШИБКА-»; STASK; «HE МОЖЕТ БЫТЬ ВСТАВЛЕН В ГРАФ» 1190 STOP 1200 'конец подпрограммы Используя эту функцию find, цикл ввода может быть переписан с совсем небольшими изменениями по сравнению с тем, как он представлен в разд. 7.3. Первая проблема сразу же решается при представлении графа с использованием таблицы хеширования. Посмотрим, как может быть решена вторая проблема. Сперва отметим, что, ког- 545
да некоторый узел идентифицирован как кандидат на вывод (т.е., когда его поле COUNT становится равным 0), он может быть помещен в выходной список. Однако теперь его больше не надо удалять из списка узлов графа, поскольку такого списка не существует. Из-за того что фазы ввода и вывода в программе разделены и в процессе обработки новые узлы не добавляются, нет необходимости удалять какие-либо узлы из таблицы хеширования. (Если бы было необходимо удалять узлы, то для разрешения коллизий при хешировании метод цепочек был бы предпочтительнее метода повторного хеширования.) Таким образом, нашей второй проблемы, которая приводила к необходимости прохождения всего набора узлов графа для того, чтобы удалить некоторый конкретный узел, не существует. Мы можем, следовательно, написать откорректированную версию программы планирования в следующем виде: 10 'программа schedule (скорректированная) 20 DEFSTR S 30 NMAX-100 40 DIMSUBTASK(NMAX) 50 DIM COUNT(NMAX) 60 DIM ARCPTR (NMAX) 70 DIM NXTNODE(NMAX) 80 AMAX-200 90 DIM NDPTR(AMAX) 100 DIM NEXARC(AMAX) 110 AAVAIL-1 120 FOR 1 = 1 TO AMAX-I 130 NEXARC(I)=I+1 140 NEXT I 150 NEXARC(AMAX)=0 160 'строим граф 170 READ SI TASK, S2TASK 180 IF S1TASK=«FINISH» THEN GOTO 280 190 STASK^SITASK 200 GOSUB 1000: 'подпрограмма find устанавливает переменную 'FIND 210 P=FIND 220 STASK=S2TASK 230 GOSUB 1000: 'подпрограмма find 240 Q*=FIND 250 GOSUB 1500: 'подпрограмма join воспринимает переменные Р 'и Q 260 COUNT (Q) = COUNT (Q) +1 270 GOTO 170 280 Траф построен. ^ 'Организуем прохождение таблицы хеширования и помещаем все 'узлы графа с нулевым счетчиком в выходной список. 290 ОТРТ=0: ΌΤΡΤ указывает на выходной список 300 FOR P=l TO NMAX 310 IF SUBTASK(P) =« » THEN GOTO 330 320 IF COUNT (P) =0 THEN NXTNODE(P) =OTPT: OTPT=P 330 NEXT Ρ 340 'Моделируем временные периоды 350 PERIOD-1 360 IF OTPT=0 THEN GOTO 630 370 PRINT «ПЕРИОД», PERIOD 546
380 'Инициализируем выходной список для следующего периода 390 OPNX-0 400 'Организуем прохождение выходного списка 410 Р=ОТРТ 420 IF P=0 THEN GOTO 590 430 PRINT SUBTASK(P) 440 'Организуем прохождение дуг, исходящих из узла графа 'graphnode(P) 450 R=ARCPTR(P) 460 IF R=0 THEN GOTO 570 470 RR=NEXARC(R) 480 'Уменьшаем счетчик в концевом узле 490 T=NDPTR(R) 500 COUNT (Τ) = COUNT (Τ) -1 510 'Если счетчик узла node (Τ) равен 0, то помещаем его в вы- 'ходной список следующего периода 520 IF COUNT (Τ) -0 THEN NXTNODE(T) =OPNX: OPNX-T 530 FRARC=R 540 GOSUB 2000: 'подпрограмма freearc воспринимает пере- 'менную FRARC 550 R=RR 560 GOTO 460 570 P = NXTNODE(P) 580 GOTO 420 590 'Переустанавливаем выходной список для следующего периода 600 OTPT^OPNX 610 PERIOD=PERIOD+l 620 GOTO 360 630 END 700 DATA . . . 1000 'подпрограмма find 1500 'подпрограмма join 2000 'подпрограмма freearc В этой программе следует отметить два момента. Поскольку список узлов графа был исключен, то больше нет возможности проверять циклы. Кроме того, если для разрешения коллизий при хешировании используется метод цепочек, то список всех узлов графа, содержимое которых хешируется в одно и тоже значение, должен быть связан при помощи использования поля; NXTNODE. Таким образом, мы снова получим проблему удаления некоторого узла графа из списка перед вставкой его в выходной список. Она может быть решена при помощи списка с двумя связями, как это указывалось в разд. 7.3, или при помощи добавления к каждому узлу еще одного поля, как это делается в упражнении 2 ниже. Альтернативный метод заключается в прохождении списка всех узлов, которые хешируются в одно и то же значение, из первоначального узла хеширования. Этот список должен быть относительно коротким и, следовательно, не будет приводить к таким же накладным расходам, как если бы организовывался поиск по всему; списку узлов графа. 547
Упражнения 1. Перепишите программу schedule из разд. 7.3 так, чтобы два прохождения списка, представленные обращениями к подпрограмме find в цикле ввода, были объединены в одно прохождение, представленное операторами внутри программы. 2. Одним возможным решением проблемы прохождения списка узлов графа для того, чтобы найти те узлы, у которых поле COUNT равно О, является добавление к каждому узлу графа еще одного поля CUNXT и использование его для связи узлов в выходном списке. Это снимает необходимость удалять узел из списка графа для того, чтобы поместить его в выходной список. Реализуйте это решение в виде программы на языке Бейсик. 3. Модифицируйте программу schedule из этого раздела так, чтобы она обнаруживала циклы в первоначальном графе. Пример 9.4.3. Система резервирования билетов на авиалиниях Нашим следующим примером является применение методов поиска в системе резервирования билетов на авиалиниях. Рассмотрим проблемы программирования в некоторой системе резервирования билетов на авиалиниях. Входная информация состоит из управляющей группы данных, содержащих данные о рейсе, которые используются для инициализации системы, за которой следует группа данных о пассажирах, содержащая данные о резервировании билетов пассажирами. Управляющая группа состоит из одной входной строки, содержащей одно число— число рейсов в этот день, и набора входных строк (по одной на каждый рейс), каждая из которых содержит номер рейса и количество мест для этого рейса. Пример управляющей группы показан на рис. 9.4.1, а. ДАННЫЕ 4 ДАННЫЕ 153 10 ДАННЫЕ 097 50 ДАННЫЕ 860 175 ДАННЫЕ 214 95 а ДАННЫЕ «РЕЗЕРВИРОВАНИЕ» «JOAN DOE» 097 ДАННЫЕ «РЕЗЕРВИРОВАНИЕ» «JOE JACKSON» 153 ДАННЫЕ «СПРАВКА» «JOE JACKSON» ДАННЫЕ «АННУЛИРОВАНИЕ» «JOAN DOE» 097 б Рис. 9.4.1. Когда упраЭДйющая группа данных прочитана, то для каждого запроса Ийбсажира на обслуживание читается отдельная 548
строка. Эти запросы могут быть трех типов — резервирование, аннулирование и справка. Тип каждого запроса задается словом РЕЗЕРВИРОВАНИЕ, АННУЛИРОВАНИЕ и СПРАВКА. В запросе на резервирование или аннулирование задаются фамилия пассажира и номер рейса. В справке задается только фамилия пассажира. (Предполагается, что справка для пассажира дается по всем рейсам конкретного маршрута следования, но пассажир может аннулировать конкретный рейс данного маршрута.) На рис. 9.4.1,6 приведен пример группы данных о запросах пассажиров. Мы должны написать некоторую программу, которая обрабатывает эти две группы входной информации. Для каждого запроса от пассажира на обслуживание будет печататься некоторое сообщение о предпринятых действиях. Прежде чем мы будем продолжать рассматривать этот пример, необходимо высказать некоторое предупреждение. Из-за того что реально работающая система резервирования должна хранить огромное количество информации, данные обычно хранятся в некоторой внешней файловой системе. Поэтому такая система должна быть запрограммирована с использованием методов внешнего поиска, которые в этой книге не обсуждаются. Более того, большая часть такой системы состоит из системных программ, обрабатывающих доступ к общей базе данных от удаленных терминалов. Этот тип программ также находится вне сферы этой книги. Прежде чем разрабатывать программу, надо более точно определить постановку данной задачи. В частности, должно быть определено, какие действия должны быть предприняты по каждому из возможных запросов от пассажира. В случае резервирования информация о данном пассажире должна быть помещена в список рейсов для данного рейса, если он не заполнен. Если данный рейс заполнен, то информация о данном пассажире должна быть помещена в список ожидания на этот рейс, если для этого рейса будут какие-либо аннулирования. В случае аннулирования информация о пассажире должна быть удалена из списка рейсов, если для него зарезервирован билет на этот рейс, и информация о первом пассажире в списке ожидания (если такой пассажир имеется) должна быть помещена в список рейсов. Если при аннулировании информация о пассажире находилась в списке ожидания, то он должен быть удален из этого списка. И наконец, в случае справки будет напечатан список всех рейсов, билеты на которые были зарезервированы для данного пассажира или которые находятся в очереди ожидания. Теперь, когда определено, какие действия должны быть предприняты для различных запросов, мы можем рассмотреть организацию данных, которая для этого потребуется. Поскольку номера рейсов фиксированы, основные данные о рейсе будут 549
представляться в виде набора массивов. Эта базовая информация включает номер рейса, количество мест в нем и другие данные, которые не относятся к резервированию билетов для пассажиров. Кроме того, для каждого рейса требуются два списка — список пассажиров, билеты для которых зарезервированы на данный рейс, и список ожидания на данный рейс. Список пассажиров не имеет ограничений, касающихся того, куда может быть вставлена информация о пассажире или удалена. Список ожидания, однако, должен быть очередью, так что если произойдет аннулирование, то первый пассажир в списке ожидания будет первым на получение билета на рейс. Помимо этого мы должны иметь возможность удаления информации о пассажире из середины списка ожидания (в случае аннулирования). Для каждого рейса в такой системе будет необходимо сохранять указатели на каждый из двух списков. Посмотрим, какие требуются поиски при этой организации данных для обслуживания наших запросов. Для запроса о резервировании должен быть выполнен последовательный поиск в массиве номеров рейсов и затем фамилия пассажира должна быть добавлена к списку пассажиров или к очереди ожидания. Последовательный поиск в массиве (который выполняется при помощи нахождения индекса данного рейса внутри массива номеров рейсов) неэффективен, но не чрезмерно, если число рейсов невелико. Для того чтобы увеличить скорость поиска, рейсы могли бы храниться в некотором массиве, отсортированном по номеру рейса, что позволяет применить бинарный поиск. Вставка в соответствующий список является единственной операцией, которая не содержит поиска. Таким образом, можно сказать, что операция резервирования при этой организации данных будет эффективна в средней степени. Для запроса об аннулировании должен быть выполнен последовательный поиск в массиве номеров рейсов и затем один или два последовательных поиска должны быть сделаны по списку пассажиров и списку ожидания. Эти последовательные поиски достаточно неэффективны. Справка является наименее эффективной операцией при организации данных, описанной выше. При поиске конкретной фамилии должен быть выполнен последовательный поиск по одному списку пассажиров и многим спискам ожидания. Для того чтобы напечатать список пассажиров и список ожидания для конкретного рейса, последовательный поиск должен быть осуществлен по массиву номеров рейсов для локализации заданного рейса. Затем должно быть организовано прохождение двух списков для этого рейса. Любая реализация этой операции достаточно эффективна, поскольку прохождение списка является частью спецификации данной задачи. Мы оставляем программирование системы резервирования на авиалиниях с ис- 550
пользованием описанных выше структур данных читателю в качестве упражнения. Нам бы хотелось разработать структуры данных, которые улучшат эффективность операций аннулирования и справки. Для того чтобы исключить последовательный поиск номера рейса в массиве, таблица рейсов может быть представлена в виде некоторого дерева бинарного поиска, как это описано в разд. 9.2. Баланс данного дерева зависит от числа рейсов того порядка, в котором они задаются при вводе информации, и частоты, с которой осуществляются поиски конкретного рейса. Вопрос здесь заключается в том, что стоит ли эффективность поиска в сбалансированном дереве дополнительной работы, связанной со вставкой элементов в такое дерево. Если имеется η рейсов, то представление их в виде дерева, а не массива уменьшает время поиска с О(п) до О (log n). Мы оставляем читателю написание главной программы (которая вставляет рейсы в дерево) и подпрограммы treesearch (она выдает указатель на узел, который представляет рейс с номером рейса F в дереве, на которое указывает переменная TREE) в качестве упражнения. Какую информацию следует хранить в каждом узле, представляющем рейс? В дополнение к двум упомянутым выше указателям на списки необходимо также иметь указатели на левое и правое деревья. (Если используется сбалансированное дерево, то необходимо также некоторое поле, содержащее баланс.) По- прежнему необходимо иметь список пассажиров, исходящий из каждого узла, представляющего рейс, так что можно организовать прохождение данного списка при построении списка пассажиров. Однако поскольку аннулирование выполняется при помощи доступа к узлу, представляющему пассажира, через фамилию пассажира, то список пассажиров должен быть списком с двойными связями для того, чтобы была возможность удалить некоторый узел, представляющий пассажира, только по заданному указателю на этот узел. Аналогичным образом необходимо, чтобы списки ожидания имели двойные связи, что может быть организовано как очередь, так что первый пассажир, информация о котором была помещена в список ожидания, будет первым на получение билета в случае аннулирования. Необходимо также для каждого рейса иметь указание на количество мест и текущий счетчик пассажиров. Таким образом, мы можем описать некоторый узел, представляющий рейс, при помощи таких операторов: 30 МАХ=100 40 DIM NUMFLT(MAX): 'номер рейса 50 DIM FLIGHT(MAX,2): CAPACITY=1: COUNT=2 'информация о 'каждом рейсе 60 DIM PSLST(MAX): 'указатель на список пассажиров 70 DIM WAITLST(MAX,2): FRNT-1: REAR=2'указатели на список 'ожидания 80 DIM PTRTREE(MAX,2): LFT=1: RGHT=2 'указатели на деревья 551
Для того чтобы сделать аннулирование и получение справки более эффективными, должна быть возможность доступа к узлу, представляющему пассажира, непосредственно по фамилии пассажира, а не при помощи прохождения списка пассажиров. Для того чтобы это сделать, весь список пассажиров представляется в виде некоторой таблицы хеширования. Поскольку должна быть возможность удаления пассажиров из списка пассажиров в случае аннулирования и поскольку заранее неизвестно, сколько будет пассажиров, то коллизии при хешировании разрешаются методом цепочек, а не повторным хешированием. Каждый узел, представляющий пассажира, содержит фамилию пассажира, а также три указателя — по одному указателю на следующий узел в том же самом списке пассажиров, на предыдущий узел в том же самом списке пассажиров и на следующий узел, который хешируется в то же самое значение. Одно из этих полей указателей выступает также в роли указателя на следующий доступный свободный узел (если данный узел, представляющий пассажира, находится в списке доступных узлов). Необходимо также в узле, представляющем пассажира, иметь некоторое указание на то, с каким списком рейсов связан конкретный пассажир и зарезервирован ли для нега билет или он находится в списке ожидания к данному рейсу. Это необходимо для того, чтобы при выдаче справки о рейсах* которые забронировал некоторый конкретный пассажир, соответствующие сообщения можно было бы напечатать прямо из узла, представляющего данного пассажира. Отметим, что эта информация не является необходимой, если доступ к узлу, пред· ставляющему пассажира, происходит только через узел, представляющий рейс, а не непосредственно через хеширование. Мы можем поэтому описать узел, представляющий пассажира, при помощи следующих операторов: 70 MPASS^IOOO: 'максимальное число узлов, представляющих пасса- 'жиров. 80 DIM ZPASS(MPASS): 'фамилия пассажира 90 DIM PFLT(MPASS): 'номер рейса пассажира 100 DIM BOOK(MPASS): 'TRUE, если зарезервирован, FALSE, если в 'очереди ожидания ПО DIM PASSPTR(MPASS,3): NXTPASS=1: PREVPASS-2: 'HASHNXT=3 В действительности имеется гораздо больше информации,, связанной с каждым пассажиром, а именно: адрес пассажира,, номер его телефона, его заказ специальных блюд на время полета и т. д. но мы здесь эти детали будем опускать. Следующее решение, которое мы должны сделать, является5 основополагающим для многих применений методов поиска — выбор некоторого ключа для наших записей. Мы используем' в качестве ключа фамилию пассажира. Применяя некоторую функцию хеширования hash к фамилии пассажира, мы получим 552
индекс в массиве узлов. Со сдвигом по этому индексу находится указатель на некоторый список всех узлов, представляющих пассажиров (связанных между собой при помощи поля HASHNXT), у которых фамилии пассажиров хешируются в это же самое значение. Когда поиск делается по конкретной фамилии пассажира на конкретном рейсе (как в случае аннулирования), то фамилия пассажира хешируется и организуется прохождение этого списка с поиском элемента с данной фамилией пассажира. Все зарезервированные билеты некоторого заданного пассажира находятся в одном и том же списке. Это означает, что может быть несколько записей с одинаковым ключом, а это в свою очередь почти гарантирует, что будут иметь место коллизии при хешировании. Таким образом, операция аннулирования несколько неэффективна, поскольку поиск по цепочке должен быть выполнен последовательно. (Такая же неэффективная работа происходит тогда, когда запрашивается информация о конкретном пассажире на конкретном рейсе.) Возможным избавлением от этой неэффективности является совмещение поля фамилии пассажира и номера рейса в одном ключе. Тогда при поиске конкретного пассажира на конкретном рейсе можно хешировать эту комбинацию непосредственно. Однако в данной ситуации такое расширение ключа непрактично. При обработке справки о списке всех рейсов для некоторого заданного пассажира было бы крайне неэффективно комбинировать заданную фамилию пассажира с каждым возможным номером рейса для того, чтобы получить некоторый набор ключей для хеширования. Напротив, одна фамилия пассажира хешируется для получения доступа к списку всех рейсов, в которых появляется эта фамилия (этот список может также содержать лишние узлы, представляющие других пассажиров, чьи фамилии тоже хешируются в это же значение, но эти узлы могут быть пропущены). Число рейсов, для которых средний пассажир резервирует билеты, является достаточно небольшим, так что для случая аннулирования это не представляет таких накладных расходов, которые были бы достаточными, чтобы перевесить альтернативные расходы для случая выдачи справки. Это иллюстрирует общее явление при выборе ключа поиска — задание большей информации в ключе делает проще процедуру нахождения некоторого достаточно конкретного элемента, но затрудняет выполнение общего запроса. Рассмотрим теперь таблицу узлов. Число элементов в этой таблице должно быть немного больше (примерно на 10%), чем число фамилий пассажиров, хранящихся в любое заданное время. Это устраняет списки фамилий, которые хешируются в один и тот же индекс. Размер данной таблицы должен быть также некоторым простым числом, поскольку было найдено, что остаток от деления на некоторое простое число дает хорошее распределение значений хеширования. Мы произвольно полагаем 553
примерно 900 различных фамилий пассажиров и 1000 узлов, представляющих пассажиров, которые позволяют пассажиру зарезервировать билет на некоторый рейс или ждать в очереди к этому рейсу (это — очень маленькое число для реальной системы). Опишем таблицу узлов таким оператором: 120 DIM TBLE(1009) :'Таблица хеширования Представим теперь две подпрограммы, которые выполняют запросы на обслуживание, оставляя читателю главную программу и другие подпрограммы в качестве упражнения. Первая подпрограмма cancel воспринимает фамилию пассажира и номер рейса и удаляет забронированный билет данного пассажира с этого рейса. Мы предполагаем, что сделаны следующие описания переменных: 20 DEFSTR Ζ 30 МАХ=100 40 DIM NUMFLT(MAX): 'номера рейсов 50 DIM FLIGHT (MAX.2): CAPACITY^l: COUNT=2: 'информация о 'каждом рейсе 60 DIM PSLST(MAX): 'указатель на список пассажиров 70 DIM WAITLST(MAX,2): FRNT-1: REAR=2: 'указатель на список 'ожидания 80 DIM PTRTREE(MAX,2): LFT=1: RGHT=2: 'указатели на деревья 90 MPASS=100O: 'максимальное число узлов, представляющих пассажиров 100 DIM ZPASS(MPASS): 'фамилия пассажира ПО DIM PFLT(MPASS): 'номер рейса для пассажира 120 DIM BOOK(MPASS): 'TRUE, если зарезервирован, FALSE, если 'в очереди ожидания 130 DIM PASSPTR(MPASS,3): NXTPASS=1: PREVPASS=2: HASHNXT=3: 'указатель между узлами, представляющими 'пассажиров 140 DIM TBLE( 1009): 'таблица хеширования 150 NULL=0 160 TRUE-1 170 FALSE^O Помимо этого мы предполагаем наличие описанных выше подпрограмм hash и treesearch, а также существование некоторой вспомогательной подпрограммы обработки списков в строке с номером 7000, называемой delete, которая воспринимает два указателя — XPTR и Υ (первый — на некоторый узел, представляющий рейс, а второй — на некоторый узел, представляющий пассажира)—и удаляет узел node(Y) или из списка пассажиров, или из очереди ожидания [в зависимости от значения BOOK(Y)], исходящих из узла node(XPTR) без освобождения узла node(Y). Кроме того, мы используем подпрограмму free- node, которая воспринимает указатель FRNODE на некоторый узел, представляющий пассажира, и возвращает этот узел в список доступных узлов. 554
3000 'подпрограмма cancel ЗОЮ 'входы: F, ZNAM: 'номер рейса и фамилия пассажира 3020 ' выходы: нет 3030 'локальные переменные: FPTR, FRNODE, R, S, Т, V, XPTR, Υ 3040 'найти узел, представляющий рейс 3050 GOSUB 5000: 'подпрограмма treesearch устанавливает переменную* 'FPTR 3060 IF FPTR-0 THEN PRINT «НЕВЕРНЫЙ НОМЕР РЕЙСА»: RETURN 3070 'хешируем фамилию пассажира и организуем поиск в списке хешированиям 3080 GOSUB 6000: 'подпрограмма hash воспринимает переменную ZNAM. 'и устанавливает переменную К 3090 R-0 3100 S=TBLE(H) 3110 'поиск в списке пассажиров тех рейсов, которые используют спи- 'сок хеширования* 3120 IF S-0 THEN GOTO 3170 3130 IF ZPASS(S) =ZNAM AND PFLT(S) -F THEN GOTO 3180 3140 R=S 3150 S=PASSPTR (S.HASHNXT) 3160 GOTO 3120 3170 PRINT «НЕТ ТАКОГО ПАССАЖИРА ДЛЯ ЭТОГО РЕЙСА»: RETURN 3180 'В этот момент S указывает на узел, представляющий пассажира. 'Удаляем из таблицы хеширования узел, представляющий данного- 'пассажира, на который указывает S 3190 IF R=0 THEN TBLE(H) =PASSPTR (S,HASHNXT) ELSE PASSPTR (R,HASHNXT) = PASSPTR (S.HASHNXT) 3200 'удаляем узел, представляющий данного пассажира, из списка пассажиров или из очереди ожидания 3210 XPTR=FPTR 3220 Y=S 3230 GOSUB 7000: 'подпрограмма delete удаляет узел, представляющий* 'пассажира, на который указывает Y, из рейса, на который указывает XPTR 3240 'узел, представляющий данного пассажира, был в списке ожидания 3250 IFBOOK(S)=FALSE THEN PRINT ZNAM; «УДАЛЕН ИЗ ОЧЕРЕДИ ОЖИДАНИЯ К РЕЙСУ»; F: GOTO 3420 3260 'в противном случае выполнить операторы с номерами 3270—3410 3270 'узел был в списке пассажиров 3280 PRINT ZNAM; «УДАЛЕН ИЗ РЕЙСА»; F 3290 T=WAITLST(FPTR,FRNT) 3300 IF T=0 THEN FLIGHT (FPTR.COUNT) = FLIGHT(FPTR,COUNT)-l: GOTO 3420 3310 'удалить первого пассажира из списка ожидания и вставить его 'в список пассажиров 3320 XPTR = FPTR 3330 Y=T 3340 GOSUB 7000: 'подпрограмма delete 3350 BOOK (Τ) = TRUE 3360 V=PSLST(FPTR) 3370 PSLST(FPTR)=T 3380 PASSPTR (T,PREVPASS)=0 3390 PASSPTR (T,NXTPASS) =T 3400 IF V< >0 THEN PASSPTR(V.PREVPASS) =T 3410 PRINT ZPASS(T); «ТЕПЕРЬ ПОЛУЧИЛ РЕЗЕРВИРОВАНИЕ НА РЕЙС»; F 555
3420 'освободить узел, представляющий пассажира, и вернуть его в список доступных узлов 3430 FRNODE=S 3440 GOSUB 1000: 'подпрограмма freenode воспринимает переменную 'FRNODE 3450 RETURN 3460 'Конец подпрограммы Следующая подпрограмма, которую мы представим, предназначена для выдачи справки. Мы хотим распечатать все рейсы, в которых имеется некоторая заданная фамилия пассажира. Ее алгоритм очевиден. 4000 'подпрограмма inquire 4010 'входы: ZNAM 4020 'выходы: нет 4030 'локальное переменное: Н, S 4040 PRINT ZNAM; «ИМЕЕТСЯ В СЛЕДУЮЩИХ РЕЙСАХ»: 4050 GOSUB 6000: 'подпрограмма hash воспринимает переменную ZNAM 'и устанавливает переменную Η 4060 S=TBLE(H) 4070 'поиск по рейсам, использующим указатели в таблице хеширования 4080 IF S=0 THEN GOTO 4130 4090 IF ZNAM< >ZPASS(S) THEN GOTO 4110 4100 IF BOOK(S)= TRUE THEN PRINT «ЗАРЕЗЕРВИРОВАН БИЛЕТ НА РЕЙС»: FPLT(S) ELSE PRINT «ОЖИДАЕТ В ОЧЕРЕДИ К РЕЙСУ»; PFLT(S) 4110 S=PASSPTR(S,HASHNXT) 4120 GOTO 4080 4130 PRINT «КОНЕЦ СПИСКА» 4140 RETURN 4150 'Конец подпрограммы Упражнения 1. Напишите на языке Бейсик программу, которая реализует систему резервирования билетов на авиалиниях и в которой списки пассажиров и списки ожидания представлены в виде линейных списков. 2. Напишите на языке Бейсик программу, которая читает управляющую группу данных о рейсе, описанную в тексте, и строит из узлов, представляющих рейсы, дерево бинарного поиска. Модифицируйте эту программу так, чтобы она строила сбалансированное бинарное дерево. 3. Напишите на языке Бейсик подпрограмму treesearch, описанную в тексте. 4. Напишите подпрограмму delete, описанную в тексте. 5. Напишите на языке Бейсик программу, которая воспринимает некоторую фамилию пассажира и аннулирует для этого пассажира все зарезервированные билеты и все элементы в очередях ожидания. Какое бы поле вы изменили в узле, представляющем пассажира, для того чтобы сделать эту операцию более эффективной? Перепишите подпрограммы из этого раздела с ^четом этих модификаций. 6. Напишите на языке Бейсик подпрограмму, которая воспринимает фамилию пассажира и номер рейса и резервирует билет для этого пассажира на этот рейс или помещает информацию о нем в список ожидания к этому рейсу, если данный рейс заполнен.
Литература Приводимая ниже литература содержит названия книг по двум темам — алгоритмы и структуры данных и введение в программирование на языке Бейсик. Конечно, для пользователя, использующего микроЭВМ, при определении конкретного диалекта языка Бейсик и особенностей этого языка, реализованных для данного диалекта, существенно руководство по этому языку для данной микроЭВМ. 1. Алгоритмы и структуры данных 1. A. Aho, J. Hopcroft, J. Ullman. Data Structures and Algorithms. Addison- Wesley, Reading, Mass., 1982. 2. A. Aho, J. Hopcroft, J. Ulmann. The Design and Analysis of Computer Algorithms. Addison-Wesley, Reading, Mass., 1974. [Имеется перевод: Ахо Α., Χοπκροφτ Дж., Ульман Дж. Построение и анализ вычислительных алгоритмов. Пер. с англ. —М.: Мир, 1979.] 3. М. J. Augenstein, A. M. Tenenbaum. Data Structures and PL/1 Programming. Prentice-Hall, Englewood Cliffs, N. J., 1979. 4. S. Baase. Computer Algorithms: Introduction to Design and Analysis. Addison-Wesley, Reading, Mass., 1978. 5. T. E. Bailey, K. Lundgaard. Programming Design with Pseudocode. Brooks/Cole, Monterey, Calif., 1983. 5a. B. J. Baron, L. G. Shapiro. Data Structures and Their Implementation. Prindle, Weber & Schmidt, Boston, 1980. 6. J. Beidler. An Introduction to Data Structures. Allyn and Bacon, Boston, 1982. 7. A. T. Berztiss. Data Structures, Theory and Practice. 2nd ed., Academic Press, New York, 1977. 8. P. C. Brillinger, D. J. Cohen. Introduction to Data Structures and Non- numeric Computation. Prentice-Hall, Englewood Cliffs, N. J., 1972. 9. D. Coleman. A Structured Programming Approach to Data. Macmillan, London, 1978. 10. N. Deo. Graph Theory with Applications to Engineering and Computer Science. Prentice-Hall, Englewood Cliffs, N. J., 1974. 11. R. S. Ellzey. Data Structures for Computer Information Systems. Science Research Associates, Palo Alto, Calif., 1982. 12. M. Elson. Data Structures. Science Research Associates, Palo Alto, Calif., 1975. 13. S. Even. Graph Algorithms. Computer Science Press, Rockville, Md., 1978. 14. I. Flores. Data Structures and Management. Prentice-Hall, Englewood Cliffs, N. J., 1970. [Имеется перевод: Флорес И. Структуры и управление данными. Пер. с англ. — М.: Финансы и статистика, 1982.] 15. S. F. Goodman, S. Т. Hedetniemi. Introduction to the Design and Analysis of Algorithms. McGraw-Hill, New York, 1977. ГИмеется перевод: Гудман С, Хидетниеми С. Введение в разработку и анализ алгоритмов. Пер. с англ.— М.: Мир, 1981.] 16. С. С. Gotlieb, L. В. Gotlieb. Data Types and Structures. Prentice-Hall, Englewood Cliffs, N.J., 1978. 17. J. P. Grillo, J. D. Robertson. Data Management Techniques. Wm. С Brown, Dubuque, Iowa, 1982. 18. M. С Harrison. Data Structures and Programming. Scott Foresman Glenville, III., 1973. * 19. F. Horowitz, S. Sahni. Algorithms: Design and Analysis. Computer Science Press, Rockville, Md., 1977. 557
20. F. Horowitz, S. Sahni. Fundamentals of Data Structures. Computer Science Press, Rockville, Md., 1975. 21. E. Kernighan, P. J. Plauger. Software Tools. Addison-Wesley, Reading, Mass 1976 22. D. E. Knuth. Fundamental Algorithms. 2nd ed., Addison-Wesley, Reading, Mass., 1973 [Имеется перевод: Кнут Д. Искусство программирования для ЭВМ, т. 1, Основные алгоритмы. Пер. с англ. — М.: Мир, 1976.] 23. D. Е. Knuth. Sorting and Searching. Addison-Wesley» Reading, Mass.r 1973. [Имеется перевод: Кнут Д. Искусство программирования для ЭВМ, т. Зг Сортировка и поиск. Пер. с англ. — М.: Мир, 1978.] 24. Т. G., Lewis, Μ. Ζ. Smith. Applying Data Structures. Houghton Mifflin, Boston, 1976.' 25. H. A. Maurer. Data Structures and Programming Techniques. Prentice- Hall, Englewood Cliffs, N. J., 1977. 26. A. Nijenhuis, H. S. Wilf. Combinatorial Algorithms. Academic Press, New York, 1975. 27. E. S. Page, L. B. Wilson. Information Representation and Manipulation in a Computer. Cambridge University Press, Cambridge, 1973. 28. J. L. Pfaltz. Computer Data Structures. McGraw-Hill, New York, 1977. 29. G. Polya. How to Solve it. Doubleday, New York, 1957. [Имеется перевод: Пойа Д. Как решать задачу. Пер. с англ. — М.: Учпедгиз, 1959.] 30. Е. М. Reingold, W. J. Hansen. Data Structures. Little, Brown, Boston,. 1983. 31. E. M. Reingold, J. Nievergelt, N. Deo. Combinatorial Algorithms: Theory and Practice. Prentice-Hall, Englewood Cliffs, N. J., 1977. [Имеется перевод: Рейнгольд Э., Нивергельт Ю., Део Н. Комбинаторные алгоритмы. Теория и практика. Пер. с англ. — М.: Мир, 1980.] 32. J. С. Reynolds. The Craft of Programming. Prentice-Hall, Englewood Cliffs, N.J., 1981. 33. R. P. Rich. Internal Sorting Methods with PL/I Programs. Prentice- Hall, Englewood Cliffs, N. J., 1972. 34. B. Sedgewick. Algorithms. Addison-Wesley, Reading, Mass., 1983. 35. T. A. Standish. Data Structure Techniques. Addison-Wesley, Reading* Mass., 1980. 36. H. Stone. Introduction to Computer Organization and Data Structures. McGraw-Hill, New York, 1072. 37. A. M. Tenenbaum, M. J. Augenstein. Data Structures Using Pascal. Prentice-Hall, Englewood Cliffs, N. J., 1981. 38. J. P. Tremblay, P. G. Sorenson. An Introduction to Data Structures with Applications. McGraw-Hill, New York, 1976. 39. W. M. Waite. Implementing Software for Non-numeric Applications^ Prentice-Hall, Englewood Cliffs, N. J., 1973. 40. N. Wirth. Algorithms+Data Structures=Programs. Prentice-Hall, Englewood Cliffs, N. J., 1976. [Имеется перевод: Вирт Η. Алгоритмы+структу- ры=программы. Пер. с англ. —М.: Мир, 1985.] 41. N. Wirth. Systematic Programming: An Introduction. Prentice-Hall, Englewood Cliffs, N. J., 1973. [Имеется перевод: Вирт Н. Систематическое? программирование. Введение. Пер. с англ. — М.: Мир, 1977.] 2. Язык Бейсик 1. В. Albrecht, L. Finkel, J. R. Brown. BASIC for Home Computers:.A Self- teaching Guide. Wiley, New York, 1978. 2. W. Amsbury. Structured BASIC and Beyond. Computer Science Press, Rockville, Md., 1980. 3. J.. F. Clark, W. O. Drum. BASIC Programming: A Structured Approach: South-Western, Cincinnati, Ohio, 1983. 4. J. P. Grillo, J. D. Robertson. Technique of BASIC. Wm C/ Brown Dubuque, Iowa, 1982. 558
5. J. Hennefeld. Using BASIC: An Introduction to Computer Programming. 2nd ed., Prindle, Weber & Schmidt, Boston, 1981. 6. Ε. Β. Koffman, F. L. Friedman. Problem Solving and Structured Programming in BASIC. Addison-Wesley, Reading, Mass., 1979. 7. H. F. Ledgard. Programming Proverbs. Hayden, Rochelle Park, N. J., 1975. 8. D. A. Lien. The BASIC Handbook: An Encyclopedia of the BASIC Computer Language. Compusoft Publishing, San Diego, Calif., 1978. 9. S. Marateck. BASIC. 2nd ed., Academic Press, New York, 1982. 10. J. M. Nevison. The Little Book of BASIC Style. Addison-Wesley, Reading, Mass., 1978. 11. G. B. Shelly, T. J. Cashman. Introduction to BASIC Programming. Anaheim Publishing, Brea, Calif., 1982.
Предметный указатель Адрес 17, 21 Алгоритм 62, 89 — итерационный 237 — корректировки 498 — поиска 485 и вставки 485 — рекурсивный 252 обработки списков 287 реализация на языке Бейсик 253 свойства 252 — Форда — Фулкерсона 400 — Хаффмена 331, 539 Аргументы 79 Байт, значение 17 — размер 16 — содержимое 17 Баланс узла бинарного дерева 509 Бинарное слияние 482 Бит 16 «Бор» 622 Брат 302, 351 Вложение, глубина 128 Вспомогательная таблица указателей 425 Выбор хеш-функции 536 Вырезка 402 Граф ациклический 380 — взвешенный 380 — направленный 378 вероятностный 403 — циклический 380 Данные, агрегат 49 — входные 88 — выбор структуры 90 — выходные 88 — набор 45 — организация в языке Бейсик 44 — представление 34 логическое 34 физическое 34 — тип 17 Двоично-десятичный 13 Делитель, наибольший общий 275 Дерево бинарного поиска 500 оптимальное 509 — бинарное 301 брат узла 302 выбор представления 319 высота 509 глубина 303 зеркально подобное 312 операции 305 подобное 312 полное 303 потомок узла 302 почти полное 303 предок узла 302 прохождение 321 прошитое 322 прямопрошитое 325 расширенное 338 сбалансированное 509 симметрично прошитое 325 Фибоначчи 338 — братья 351 — выбора для сортировки 432 — выражения 361 — глубина 351 — грамматического разбора 366 — игры 367 — отец 351 — приложения 351 — степень узла 351 — строго бинарное 302 — сын 351 младший 353 старший 353 — тернарное 331 почти полное 464 — упорядоченное 352 — Хаффмена 323 560
— цифровое 519 — AVL 509 — В-дерево порядка m 527 — 3.2 524 Дизъюнкция 383 Заголовок 102 Задача Джозефуса 234, 350 — о потоках 391 — планирования 409, 543 — «Ханойские башни» 247 на языке Бейсик 265 упрощение 271 Игра калах 377 — крестики-нолик и 367 — ним 377 идентификатор 18 Излечение 485 Имя переменной 18 осмысленное 96 повторное использование 111 Индекс 493 Интерпретатор 18, 54 Информация 10 Итерация 112, 434 Ключ 424 — внешний 484 — внутренний 484 — встроенный 484 '— вторичный 485 — первичный 484 Код 12 — дополнительный 13 — обратный 12 Комбинация битовая 13, 15 Комментарий 56, 97 Компилятор 54 Концевика код 102 Конъюнкция 383 Корень дерева 351 бинарного 301 Левый поворот дерева 511 Лес 353 — уплотненный 521 Лист 301 Логарифм 429 Логические выражения 69 — данные 69 — значения 70 — операции 383, 70 — ошибки 106 — переменные 70 — представление данных 34 — сложение матриц 386 Локальная переменная 111 Мантисса 14 Массив 27 — базовый адрес 31 — граница 28 верхняя 28 нижняя 28 — двумерный 33 — заголовок 35 — медиана 42 — мода 42 — многомерный 37, 50 — одномерный 27 — отображение 34 по столбцам 35 строкам 43 — переполнение 170 — размер 29, 34 — симметричный 42 — ссылка к элементу 31 — строк символов 51 — треугольный 43 строго нижний 43 — трехдиагональный 43 Математический анализ 428 Матрица, определитель 286 — порядка 275 — путей 383 Медиана 42 Метод Гаусса 103 — заголовка 102 — концевика 102 — поиска 484 ■ бинарный 495 индексно-последовательный 493 последовательный 486 — разрешения коллизий при хешировании открытой адресации 530 ■ цепочек 531 — сортировки 423 квадратичным выбором 463 ■ подсчетом 444 пузырька 433 распределяющим подсчетом 445 турниром с выбыванием 450 хешированием 469 четными и нечетными транспозициями 445 Методика программирования 87 МикроЭВМ 54 Минимакса метод 369 Модуль 34, 135 — нижнего уровня 135 — принцип 135 Модульность 94 Модуляризация 135 Мультисписок 232 — двунаправленный 233 — линейный 233 — однонаправленный 233 — циклический 233 561
Набор данных 45 *- сообщение о конце 101 Наибольший общий делитель 275 Направленный граф 378 Натуральное число 240 Начало очереди 169 Нахождение дубликатов 306 Неотрицательные целые числа 12 Нерефлексивное отношение 389 Несократимое число 47 Нетождественность 537 Нижнего уровня модуль 135 Нижняя граница массива 28 Нормальное распределение 213 Нулевой список 181 — указатель 181 — синтаксическая 109 — этапа выполнения 109 Память 17 Параметр входной 79 — выходной 80 Перебалансировка дерева Переменная 18, 57 — имя 57 — логическая 70 ·— локальная 111 — критическая 118 Перемещение в начало 490 Переполнение массива 170 Пирамида 457 — тернарная 464 Поворот дерева левый 511 правый 511 Повторное хеширование 531 Поддерево 301 Подпрограмма 82 — Джозефуса 220 — функция 82 — addnode 408 — addnum 231, 223 — addr 470 — addson 365 — adjacent 407 — adjprod 385 — apply 158 — arrive 210 — avg 32 — bank 208 — bestbranch 375 — bubble 435 — buildtree 348, 349, 371 — cancel 555 — cbr 82 — college 91 — compare 228 — concat 218 — convert 281, 284 — delafter 197, 218 — delete 225 — depart 211 — dup 315 — dup2 317 — empty 175, 135 — equal 48 — evaluate 157 — evbrtree 328 — evtree 364 — expand 373 — expr 293, 296 — extract 52 — factor 294 — fellower 350 — find 282 — findelement 346 — iindexpr 295 Обменная сортировка 433 Обратный код 12 Объектная программа 54 Одномерный массив 27 Однонаправленный мультисписок 283 Окружающий цикл 143 Ома закон 403 Операнд 152 Оператор 19, 56 — вложенный 142 — комментария 56 — область видимости 142 — родовой 19, 59 — GOTO, исключение 209 Операция 132 — вставки 175 — над бинарными деревьями 305 — логическая 383 — реализация 31, 332 push 138 — со списком 192, 198 — AND 383 — freenode 186 — getnode 186 — insert 177 — OR 383 Определение рекурсивное 238 — свойства 252 Оптимальная сортировка 428 — функция потока 493 Отец 301, 351 Отображение массива 27 Открытая адресация 530, 531 Открытой адресации метод 530 Отладка 108 Отношение 379 — антисимметричное 390 — нерефлексивное 389 — рефлексивное 389 — симметричное 390 — транзитивное 390 Ошибка логическая 106
- findnode 408 - freelist 218 - freenode 196 - get node 195 - getsymb 292 - heap 460 - initialize 455 - inquire 556 - insafter 196, 224 - insert 177, 465 - insertrright 226 - intrav 321, 322 - intrav2 324 - join 382 - joinwt 382, 406 - Itr 297 - maketree 314, 319 - maxflow 401 - mergearr 474 - msort 475 - multiply 50 - newop 363 - nextmove 371 - oppsignadd 230 - place 199 - pop 136, 216, 265 - popandtest 137 - postfix 165 - postrav 321 - prcd 166 - pretrav 321 - printroots 76 - prod 384 - push 215, 264, 273 - pushandtest 139 - queens 281 - quicksort 422 - radix 48 - readjust 456 - rearrange 441 - rec 282 - reduce 48 - remove 175, 198 replace 352 ■ rmovandtest 177 ■ rmv 407 rout 116 scope 149 select 446 setleft 314, 324 setright 325 setsons 365 shell 467 simfact 258, 264 simtowers 266 sort 93 sqprnt 76 stacktop 141 store 52 swap 81 term 294, 296 — tounament 455 — towers 265 — trnsclose 387, 388 — word 150 Поиск 423, 484 — бинарный 242, 244, 495 — внешний 486 — в упорядоченной таблице 492 — индексно-последовательный 493 — линейный 243 — основные методы 484 — по дереву 500 эффективность 506 — последовательный 243, 486 эффективность 488 — фиббоначиев 499 Полное бинарное дерево 303 Полупуть 396 — захода узла 378 — исхода узла 378 Последовательного поиска метод 486 Последовательность Фибоначчи 241 Полустепень захода узла 378 — исхода узла 378 Поток входной 93 — последовательный 63 — узла входной 393 выходной 393 — управляющий 62 — условный 63 Потомок 301 — узла бинарного дерева 302 Почти полное бинарное дерево 303 Пояснительное сообщение .98 Предок 301 — узла бинарного дерева 302 Представление бинарных деревьев 312 почти полных с помощью массивов 316 — узловое 312 — выражений с помощью деревьев 358 — графов на языке Бейсик 380 — данных 34 — деревьев на языке Бейсик 363 — деревьев с помощью бинарных деревьев 355 — списков в виде бинарных деревьев 338 Преобразование инфиксной записи в постфиксную 152 — общего дерева в строго бинарное 366 — префиксной записи в постфиксную 152 Применения бинарных деревьев 306 Принцип модульности 135 Программа, адаптируемость 121 — документирование 96, 97 — инициализация входа 93 563
— исходная 54 — корректность 106 — логическая часть 100 — макет 95, 146 — модификация 89 — надежность 41, 105, 106 — объектная 54 — разработка 87 — рекурсивная 276 — сегмент 143 — структура 87, 99 — удобочитаемость 100 — унифицированная 99 — dup 315 — dup2 317 — findnote 336 — josephys 350 — schedule 414, 418, 546 Программирование, методика 87 — модульное 94 — «хитрое» 100 Программная реализация 20 Промежуточный язык 54 Просмотр в глубину 307 ширину 366 Прохождение бинарного дерева 307 в обратном порядке 308 прямом порядке 307 симметричном порядке 308 — бинарное 358 — деревьев 355 в обратном порядке 357 прямом порядке 356 симметричном порядке 357 — общее 358 Псевдокод 61, 65, 82 Путь 380 — взвешенная длина 338 — критический 422 Работа с одномерным массивом 29 Размер массива 29 Разнородные бинарные деревья 326 Разработка программы 87 Разрешение коллизий при хешировании — методом открытой адресации 530 цепочек 534 Распределение нормальное 213 Распределяющего подсчета метод 445 Расширенное бинарное дерево 338 Рациональное число 46 — несократимое 47 Реализация 20, 25 — аппаратная 19 — концепция 19 — одномерного массива 31 — программная 20 — списка в виде дерева 345 — эффективность 25 Резервирования билетов система 548- Резервированные слова 57 Рекурсивность 236 Рекурсия 236 — адрес возврата 259 — алгоритм 252 — бинарный поиск 242 — обработки списка 287 — обращение 254, 255, 259 — определение 238 алгебраических выражений 291 — перемножение натуральных чисел 240 — последовательность Фибоначчи 241 — преобразование из префикса в постфикс 278 — программа 276 — реализация на языке Бейсик 253 — свойства определений и алгоритмов 252 — факториал 254 — цепь 290 — «Ханойские башни» 265 Сбалансированное бинарное дерево 509 Свойства алгоритма 252 — операций 252 Связанное представление графов 403 Сегмент программы 143 Сеть 380 Символ алфавитно-цифровой 57 — возврата каретки 56 — объявления типа 58 — одиночной кавычки 56 Симметричный массив 42 Синтаксическая ошибка 109 Система счисления 12 — троичная 27 Скучивание 533 Сложение матриц логическое 386 Событие 204 — управляющее 206 Сообщение о конце мультисписка 101 Сортировка внешняя 424 — внутренняя 424 — вопросы эффективности 426 — вставками 465 — двухпутевыми вставками 456 — естественным слиянием 477 — методом квадратичного выбора 463 подсчета 444 пузырька 433 распределяющего подсчета 445 турнира с выбыванием 450 хеширования 469 четных и нечетных транспозиций 445 — обменная 433 564
с разделением 437 — пирамидальная 456 — поразрядная 477 обменная 478 — порядок 429 — посредством выбора из дерева 450 простого выбора 446 — с вычислением адреса 469 использованием бинарных деревьев 447 убывающим шагом 466 — слиянием 474 вставок 473 — таблицы адресов 424 — топологическая 421 — шейкер-сортировка 444 — Шелла 466 Список 169 — в языке Бейсик 193 — вставка и удаление элементов 181 — моделирование с помощью 203 — нахождение k-го элемента 339 — нечисловой 199 — нулевой 181 — операции 192, 198 — реализация в виде дерева в языке Бейсик 345 — рекурсивная обработка 287 — связанный 179 двунаправленный 224 как структура данных 190 — — линейный 180 информации 180 следующего адреса 180 растущий вниз 127 реализация 185 событий 205 узел 180 указатель 180 — смежности 404 — сортировка 52 — удаление 343 Ссылка к элементу массива 31 Стек 123 — вершина 123, 127 — концепция 123 — операции 126 — пустой 127 — реализация в языке Бейсик 132 Степень узла графа 378 дерева 351 Столкновения при хешировании 530 Строка 56 — битовая 27 — символьная 15, 21, 58 Структура данных 27 — композитная 27 Счетчик итераций 112 — скобок 128 Сын 301 — дерева 351 Таблица 484 — адресов, сортировка 421 Тестирование 108, 114 — сверху вниз 117 — снизу вверх 117 Тернарный, дерево 331 — пирамида 464 Тип данных 19 абстрактный 20 базовый 57 действительный 59 с двойной точностью 59 обычной точностью 59 примитивный 57 целочисленный 59 Топологическая сортировка 421 Точность одинарная 26 Транзакция 493 Транзитивное замыкание графа 386 — отношение 390 Трансляции этап 54 Транспозиция 490 Трассировка 118 Треугольный массив 43 Трехдиагональный массив 43 Трит 27 Троичная система счисления 27 — цифра 27 — ЭВМ 27 Узел дерева 301 внешний '6'6Ь внутренний 338 входной поток 393 выходной поток 393 ' уровень 303 — достижимый 389 — дуговой 404 — заголовочный 404 — минус 368 — плюс 368 — потомок 302 — предок 302 — представление 312 — распределенный 404 — списочный 404 Указатель нулевой 181 Уоршела алгоритм 389 Упорядоченная таблица 497 Упорядоченное дерево 352 Упорядоченные пары отношения 380 Управление, передача 99 Управляемое событие 206 Упрощение задачи «Ханойские башни» 271 Уровень просмотра 368 565
Условия граничные 115 Условный поток 63 Файл 424 — бинарного дерева 528 — внешний 549 — индексно-последовательный 528 — последовательный 528 Факториал 236 — на языке Бейсик 257 — рекурсивный 254 •Фибоначчи бинарное дерево 338 — поиск 499 — последовательность 241 — функция 273 обобщенная 273 — число 241, 499 Физическое представление данных 34 -Форда — Фулкерсона алгоритм 400 Фортран 497 Функция 82 — Аккермана 253 — подпрограмма Ь2 — потока оптимальная 493 — сохраняющая порядок 470 — факториала 273 — Фибоначчи 273 — хеш 530 первичная 533 — brother 306 — evbtree 328 — father (p) 305 — intrav 321 — isleft 305 — postrav 321 — pretrav 321 — val 328 «Ханойские башни», задача 248, 274 Хоффмана алгоритм 331 Хеш-функция 530 — первичная 533 Хеширование 527 — двойное 533 — коэффициент загрузки 539 — метод 469 «Хитрое» программирование 100 Целочисленный тип данных 59 Щепь рекурсивная 290 Цикл вложенный 143 — окруженный 143 Циклический мультисписок 233 Цифровое дерево 519 Числитель 47 Число двоичное 12 — действительное 58 с одинарной точностью 58 двойной точностью 58 — десятичное 12 — натуральное 240 перемножение 240 — представление 57 с плавающей запятой 57 фиксированной запятой 57 — простое 86 — рациональное 46 действительная часть 52 комплексная часть 52 мнимая часть 52 несократимое 47 — сложение длинных чисел 227 — совершенное 86 — с плавающей запятой 14, 15 — Фибоначчи -241 Шейкер-сортировка 444 Шелла сортировка 466 Этап интерпретации 54 — трансляции 54 Эффективность 118 — бинарного поиска по дереву 506 — временная 25 — операции 23 — отладки 117 — последовательного поиска 488 — программы 118 — пространственная 25 — реализации 25 — сортировки 426 бинарного дерева 452, 448 — удаления 487 Язык машинный 55 — промежуточный 54
Оглавление Предисловие к русскому изданию 5 Предисловие 6 Глава 1. Введение в структуры данных 10 1.1. Информация и ее смысл 10 1.2. Массивы в Бейсике 27 1.3. Организация данных в языке Бейсик 44 Глава 2. Программирование на языке Бейсик 54 2.1. Бейсик для микроЭВМ 54 2.2. Методика программирования 87 2.3. Надежность программы 105 Глава 3. Стек 123 3.1. Определение и примеры 123 3.2. Реализация стека в языке Бейсик 132 3.3. Вложенные операторы: область видимости . . 142 3.4. Пример: постфиксная, префиксная и инфиксная записи 152 Глава 4. Очереди и списки 169· 4.1. Очередь и ее реализация в виде последовательности . 169- 4.2. Связанные списки 179 4.3. Пример: моделирование с применением связанных списков 203 4.4. Другие списковые структуры 214 Глава 5. Рекурсия 236 5.1. Рекурсивное определение и процессы 236 5.2. Реализация рекурсивных алгоритмов на языке Бейсик 253 5-3. Создание рекурсивных программ 276· Глава 6. Деревья 301 6.1. Бинарные деревья 301 6.2. Представления бинарных деревьев .... 312" 6.3. Пример: алгоритм Хаффмена 331 6.4. Представление списков в виде бинарных деревьев . 338 6.5. Деревья и их приложения 351 6.6. Пример: деревья игр 367 Глава 7. Графы и их приложения 378 7.1. Графы 378 7.2. Задача о потоках 391 7.3. Связанное представление графов 403 567
Глава 8. Сортировка 423 8.1. Общие положения 423 8.2. Обменные сортировки 433 8.3. Сортировка посредством выбора и посредством выбора из дерева 446 8.4. Сортировка вставками 465 8.5. Сортировка слиянием и поразрядная сортировка . 474 Глава 9. Поиск 484 9.1. Основные методы поиска 484 9.2. Поиск по дереву 500 9.3. Хеширование 527 9.4. Примеры и приложения 539 Литература 557 1. Алгоритмы и структуры данных 557 2. Язык Бейсик 558 Предметный указатель 560 Научное издание Лэнгсам И., Огенстайн М., Тененбаум А. СТРУКТУРЫ ДАННЫХ ДЛЯ ПЕРСОНАЛЬНЫХ ЭВМ Зам. заведующего редакцией Э. Н. Бадиков Старший научный редактор Н. В. Серегина Младший редактор Т. Б. Вахнюк Художник А. В. Захаров Художественный редактор Η. Μ. Иванов Технические редакторы Л. П. Емельянова, И. И. Володина Корректор М. А. Смирнов ИБ № 6549 -Сдано в набор 25.03.88. Подписано к печати 28.il.88. Формат 60X90Vie. Бумага тип. Μ 1 Печать высокая. Гарнитура литературная. Объем 17,75 бум. л. Усл. печ. л. 35,5. Усл. кр.-отт. 35,5. Уч.-изд. л. 37,19. Изд. № 6/5742. Тираж 30 000 экз. Зак. 212. Цена 3 р. 10 к. ИЗДАТЕЛЬСТВО «МИР» В/О «Совэкспорткнига» Государственного комитета СССР по делам издательств, полиграфии и книжной торговли. Московская типография № 11 Союзполиграфпрома при Государственном комитете СССР .по делам издательств, полиграфии и книжной торговли. 113105, Москва, Нагатинская ул., д. 1.