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


Структуры данных для персональных ЭВМ
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
выполняющую некоторое рекурсивное обращение, и он состоит из переменных данного алгоритма, выполняющегося на данной новой машине. Каждый раз, когда организуется вход в рекурсивную подпрограмму, на вершину стека помещается новое значение ее переменных. Любая ссылка к некоторой переменной в этой подпрограмме проходит через текущую вершину стека. Когда управление возвращается из этой подпр