Книга
Книга
ПРЕДИСЛОВИЕ РЕДАКТОРА ПЕРЕВОДА
Программирование богато и многообразно. Ведь кажется *нет такой сферы человеческой деятельности, где нельзя было 'бы с пользой применить вычислительную машину для оценки, информационно-справочного обслуживания, планирования, моделирования и т. п. И это многообразие задач переходит в многообразие программ, которые должны разрабатывать программисты. Они пытаются справиться с этим многообрази- <ем, «заключив» его в проблемно-ориентированные языки программирования. Языки вбирают в себя специфические черты конкретных сфер программирования — характерные структуры данных, принципы организации типичных процессов, соответствующую терминологию — и таким образом делают сам процесс программирования более универсальным. Одновременно они освобождают программистов от необходимости детализировать программы до уровня слишком мелких машинных команд и даже от необходимости знать особенности конкретных вычислительных машин. Болес того, операционные системы призваны превратить вычислительные машины из предмета постоянного беспокойства в «существа», которые -сами заботятся о программисте и готовы оказывать всяческие услуги ему и его программе.
И тем не менее, после того, как любая более или менее сложная задача сформулирована (пусть даже в адекватных и удобных терминах) и машина выбрана (пусть даже в самом деле готовая к всевозможным услугам), каждый программист снова и снова остается один на один со своей собственной задачей: ему нужно составить программу! Выбрать, как именно следует расположить и связать данные в памяти, понять, какая именно последовательность операторов, — способных сделать все что угодно и оттого одновременно и податливых и опасных — выполнит поставленную задачу. И как организовать эти операторы в никл, который будет с каждым шагом приближать машину к намеченной цели. Выбрать, понять, изобрести. проверить, усомниться и повторить все сначала.
Показательно, что хотя в таких «внешних» разделах программирования, как языки и трансляторы, операционные системы и базы данных, широко развивается и используется теория (от математической лингвистики и логики до статистики и теории массового обслуживания), во внутренних, собственных разделах программирования господствуют умение и интуиция или в лучшем случае «полезные советы». Есть, правда, некоторые исследования, касающиеся уже готовых программ, но нет никакой теории самого процесса программирования.
Предлагаемая книга представляет собой один из первых шагов в этом направлении. В ней с каждым оператором, с их комбинациями и, в том числе, с циклами связываются преобразования предикатов, являющиеся формальным выражением их свойств, и определение свойств программы в целом превращается в задачу логического вывода. Особенно важно, что автор объясняет и демонстрирует на примерах, как этим формализмом можно пользоваться для практического программирования. При этом он, разумеется, не дает ответа на вопрос о том, как написать любую программу, — ответа на этот вопрос вообще не существует. Он предлагает нам инструмент, позволяющий проверять наши гипотезы в процессе проектирования программ, а иногда и подсказывающий те или иные варианты решений.
Л это совсем не мало. Представьте себе человека, живущего в эпоху зарождения математического анализа, которому нужно находить неопределенные интегралы от весьма громоздких подынтегральных выражении и который знает только определение первообразной функции и нс знает никаких приемов интегрирования. Каким для него было бы подспорьем, если бы ему сообщили о правиле интегрирования по частям или о таком приеме, как замена переменных! Его работа нс перестанет быть творческой, но насколько расширятся его возможности и как много перейдет из области находок в область техники. То, что было сложным, станет простым, то, что было непосильным, станет только сложным. Систематическое использование, обобщение и расширение этих приемов позволит постепенно перейти к формализации, алгоритмизации, а затем и к автоматизации отдельных этапов решения или полного решения специальных классов задач.
Автор книги, Э. Дейкстра, не нуждается в представлении, — его работы хорошо известны советским программистам. Вместе с Ч. Хоаром они недавно совершили поездку по крупнейшим городам нашей страны, во время которой выступили с лекциями перед многочисленными аудиториями.
О структуре и стиле книги достаточно полно сказано в предисловии автора. Там же он предупреждает, что читать его книгу трудно. Причина этого заключается в сложности самих программ, послуживших для нее материалом. Я хотел бы добавить, что они сложны для нас только сейчас, когда теория программирования делает свои первые шаги. Придет время, и такие программы сможет составлять (или выводить) прямо на урокс каждый школьник. И чтобы это время приблизить, надо осваивать, внедрять и развивать теорию. А этот процесс легко не проходит.
Перевод предисловия и глав 1—7 выполнен В. В. Мартынюком, глав 8—21 — И. X. Зусман, глав 22—27— Л. В. Уховым.
3. Любимский
ПРЕДИСЛОВИЕ
Историки таких древних интеллектуальных дисциплин, как поэзия, музыка, живопись и наука, высоко оценивают роль выдающихся практиков, чьи достижения обогатили опыт и расширили представления поклонников этих дисциплин, пробудили и укрепили таланты последователей. То новое, что ими внесено, основывается на сочетании виртуозного практического мастерства и проницательного осмысливания фундаментальных принципов. Во многих случаях влияние этих людей усиливалось благодаря их высокой культуре, богатству и выразительности их речи.
В этой книге в присущем ее автору утонченном стиле представлена принципиально новая точка зрения на существо программирования. Исходя из этой точки зрения, автор разработал совокупность новых методов программирования и средств обозначения, которые демонстрируются и проверяются на многочисленных изящных и содержательных примерах. Этот труд, несомненно, будет признан одним из выдающихся достижений в разработке новой интеллектуальной дисциплины— программирования для вычислительных машин.
А. Р. Хоар
ОТ АВТОРА
Уже давно мне хотелось написать книгу такого рода. Я знал, что программы могут очаровывать глубиной своего логического изящества, но мне постоянно приходилось убеждаться, что большинство из них появляются в виде, рассчитанном на механическое исполнение, но совершенно непригодном для человеческого восприятия — где уж там говорить об изяществе. Меня не удовлетворяло и то, что алгоритмы часто публикуются в форме готовых изделий, почти без упоминания тех рассмотрений, которые проводились в процессе разработки и служили обоснованием для окончательного вида завершенной программы. Сначала я задумал изложить некоторое количество изящных алгоритмов таким способом, чтобы читатель смог прочувствовать их красоту; для этого я собирался каждый раз описывать истинный или воображаемый процесс построения, который приводил бы к получению искомой программы. Я не изменил своему первоначальному намерению в том смысле, что основой этой монографии остается длинная последовательность глав, в каждой из которых ставится и решается новая задача. Тем не менее окончательный вариант книги существенно отличается от ее первоначального замысла, поскольку возложенная мною на себя обязанность представлять решения в естественной и убедительной манере повлекла за собой гораздо большее, чем я ожидал, и я навсегда сохраню чувство благодарности судьбе за то, что взялся за эту работу.
Когда начинаешь писать подобную книгу, сразу возникает проблема: каким языком программирования пользоваться? И это не только вопрос представления! Наиболее важным, но в то же время и наиболее незаметным свойством любого инструмента является его влияние на формирование привычек людей, которые имеют обыкновение им пользоваться. Когда этот инструмент — язык программирования, его влияние, независимо от нашего желания, сказывается на нашем способе мышления. Проанализировав в свете этого влияния все известные мне языки программирования, я пришел к выводу, что ни они сами, ни их подмножества не подходят для моих целей. С другой стороны, я считал себя настолько не подготов-
ленным к созданию нового языка программирования, что дал зарок не заниматься этим в ближайшее пятилетие, и я твердо знаю, что этот срок еще не вышел. (Прежде, помимо всего прочего, мне нужно было написать эту монографию.) Я попытался выбраться из этого тупика, создав лишь подходящий для моих целей мини-язык и включив в него только те элементы, которые представляются совершенно необходимыми и достаточно обоснованными.
Эти мои колебания и самоограничение, если их неправильно понять, могут разочаровать многих потенциальных читателей. Наверняка разочаруются все те, кто отождествляет трудность программирования с трудностью изощренного использования громоздких и причудливых сооружений, известных под названием «языки программирования высокого уровня» или — еще хуже! — «системы программирования». Если они сочтут себя обманутыми из-за того, что я вовсе не касаюсь всех этих погремушек и свистулек, могу ответить им только одно: «А вполне ли вы уверены, что все эти погремушки и свистульки, все эти потрясающие возможности ваших, так сказать, «мощных» языков программирования имеют отношение к процессу решения, а нс к самим задачам?» Мне остается лишь надеяться, что, несмотря на употребление мною мини-языка, они все же прочтут предлагаемый текст. Тогда они, возможно, признают, что помимо погремушек и свистулек имеется очень богатое содержание и возникнет вопрос, стоило ли большинство из них вообще придумывать. А всем читателям, интересующимся преимущественно разработкой языков программирования, я могу только принести извинения в связи с тем, что еще не могу высказаться на эту тему более определенно. Тем временем эта монография, возможно, наведет их на некоторые размышления и поможет им избежать ошибок, которые они могли бы совершить, если бы нс прочли ее.
Процесс работы над книгой, который явился для меня непрерывным источником удивления и вдохновения, привел к появлению текста, основательно отличающегося от первоначального замысла. Сначала у меня было вполне понятное намерение представить построение программ с помощью аппарата, чуть более формального чем тот, который я имел обыкновение использовать в своих вводных лекциях, где семантика обычно вводилась интуитивно, а доказательства правильности представляли собой смесь строгих рассуждений, жестикуляции и красноречия. Разрабатывая необходимые основы для более формального подхода, я обнаружил два неожиданных обстоятельства.
1 Первая неожиданность состояла в том, что так называемые «преобразователи предикатов», выбранные мною в качестве средства изъяснения, позволили прямо определять связь между начальным и конечным состояниями без каких-либо ссылок на промежуточные состояния, которые могут возникать во время выполнения программы. Я обрадовался тому, что это дает возможность провести четкое разграничение двух основных проблематика программирования: проблематики математической корректности (речь идет о проверке, определяет ли программа правильное соотношение между начальным и конечным состояниями — и преобразователи предикатов обеспечивают нам формальное средство для такого исследования без рассмотрения вычислительного процесса) и инженерной проблематики эффективности (благодаря разграничению становится очевидным, что последняя проблематика определена только в связи с реализацией). Пожалуй, самое полезное открытие состоит в том, что один и тот же текст программы всегда допускает две (в известном смысле дополняющие друг друга) интерпретации. Интерпретация в виде кода преобразователей предикатов, которая представляется более подходящей для нас, противостоит интерпретации в виде кода для выполнения — ее я предпочитаю оставлять машинам!
Второй неожиданностью оказалось то, что самые естественные и систематизированные «коды преобразователей предикатов», какие я мог себе представить, потребовали бы недетерминированной реализации, если рассматривать их как «коды для выполнения». Вначале я содрогался от мысли, что придется ввести недетерминированность уже в однопрограммном режиме (слишком хорошо мне были известны сложности, возникающие из-за этого в мультипрограммировании); однако потом я понял, что интерпретация текста как кода преобразователя предикатов имеет право на независимое существование. (Оглядываясь назад, мы можем отмстить, что многие проблемы мультипрограммирования, ставившие нас прежде в тупик, являются всего лишь следствием априорной тенденции придавать детерминированности слишком большое значение.) В конце концов я пришел к тому, что стал считать недетерминированность естественной ситуацией, при этом детерминированность свелась к довольно банальному частному случаю.
Установив эти основы, я приступил, как и намеревался, к решению длинной последовательности задач. Это занятие оказалось неожиданно увлекательным. Я убедился в том, что формальный аппарат позволяет мне ухватывать существо дела гораздо четче, чем раньше. Я получил удовольствие, обнаружив, что явная постановка вопроса о завершимости может
иметь большое эвристическое значение; тут я даже начал сожалеть об излишне распространенной склонности к частичной корректности. Но самое приятное состояло в том, что большинство решенных мною ранее задач теперь увенчалось более изящными решениями. Я воспринял это как весьма ободряющее свидетельство того, что разработанная методика и в самом деле улучшила мои программистские возможности.
Как следует изучать эту монографию? Лучшее, что я могу посоветовать: прерывайте чтение, как только усвоите постановку задачи, и пытайтесь сначала решить ее самостоятельно, затем продолжайте чтение. Попытка самостоятельного решения задачи представляется единственным способом прочувствовать, насколько она трудна; кроме того, вы можете сравнивать мое решение с вашим и получить удовлетворение, если ваше окажется лучше. Предупреждаю заранее: не огорчайтесь, когда увидите, что этот текст читается отнюдь не легко. Те, кто изучали его в рукописи, часто испытывали затруднения (но вполне вознаграждались за это). Впрочем, каждый раз при анализе их затруднений мы совместно убеждались в том, что «виновным» оказывался вовсе не текст (т. е. способ изложения), а сам излагаемый материал. Мораль этого может быть только такова: нетривиальный алгоритм и вправду нетривиален, а его окончательная запись на языке программирования слишком лаконична по сравнению с рассуждениями, обосновывающими его разработку; эта краткость окончательного текста не должна нас дезориентировать. Один из моих сотрудников внес предложение (а я довожу его до вашего сведения, поскольку оно может оказаться полезным), чтобы небольшие группы студентов изучали книгу вместе. (Здесь я должен добавить в скобках замечание по поводу «трудности» этого текста. Посвятив немало лет своей научной жизни тому, чтобы прояснить задачи программиста и сделать их более подвластными нашему интеллекту, я обнаружил с удивлением (и раздражением), что мое стремление внести ясность приводит к систематическим обвинениям в том, что я «внес в программирование трудности». Но эти трудности всегда в нем были; и только сделав их видимыми, мы сможем надеяться, что научимся разрабатывать программы с высокой степенью надежности, а не просто «лепить команды», т. е. выдавать тексты, основанные на неубедительных предположениях, несостоятельность которых может выявиться после первого же противоречащего примера. Незачем и говорить, что ни одна программа из этой монографии не проверялась на ма* шине.)
Я должен объяснить читателю, почему я пользуюсь миниязыком, столь ограниченным, что в нем нет даже процедур и рекурсий. Поскольку каждое следующее расширение языка добавляло бы к этой книге еще несколько глав, тем самым соответственно увеличивая ее стоимость, отсутствие большинства возможных расширений (таких, как, например, мультипрограммирование) не нуждается в дополнительных оправданиях. Однако процедуры всегда занимали такое важное место, а рекурсия для вычислительной науки в такой степени считалась признаком академической респектабельности, что некоторое разъяснение представляется необходимым.
Прежде всего эта книга предназначена не для начинающих, и я рассчитываю, что мои читатели уже знакомы с указанными понятиями. Во-вторых, книга не является вводным текстом по какому-то конкретному языку программирования, так что отсутствие в ней этих конструкций и примеров их употребления не следует объяснять моей неспособностью или нежеланием ими пользоваться или же воспринимать как намек на то, что вообще лучше воздерживаться от их применения. Просто они нс потребовались мне для разъяснения моей главной мысли о том, насколько существенно тщательное разграничение проблематик для разработки всесторонне высококачественных программ; скромные средства мини-языка предоставляют нам более чем достаточный простор для нетривиальных и в то же время вполне приемлемых разработок.
Можно обойтись этим объяснением, но оно все же не является исчерпывающим. Я все равно считал себя обязанным ввести повторение как самостоятельную конструкцию, поскольку мне представлялось, что это следовало сделать уже давно. Когда языки программирования зарождались, «динамическая» природа оператора присваивания казалась не очень приспособленной к «статической» природе традиционной математики. Из-за отсутствия соответствующей теории математики ощущали некоторые затруднения, связанные с этим оператором, а поскольку именно конструкция повторения создает необходимость в присваиваниях переменным, математики ощущали затруднения и в связи с повторением. Когда были разработаны языки без присваивания и без повторений — такие, как чистый ЛИСП, — многие почувствовали значительное облегчение. Они снова ощутили под ногами знакомую почву и увидели проблеск надежды превратить программирование в занятие с твердой и солидной математической основой. (До сего времени среди склонных к теоретизированию специалистов по машинной математике все еще широко распространено мнение, что рекурсивные программы «более естественны», чем программы с повторениями.)
Другого выхода из положения путем надежного и действенного математического обоснования пары понятий «повторение» и «присваивание переменной» нам предстояло ждать еще десять лет. А выход, как показано в этой монографии, заключался в том, что семантику конструкции повторения можно описать с помощью рекуррентных отношений между предикатами, тогда как для описания семантики общей рекурсии требуются рекуррентные отношения между преобразователями предикатов. Отсюда совершенно очевидно, почему я считаю общую рекурсию на порядок более сложной конструкцией, чем простое повторение; и поэтому мне больно смотреть, как семантику конструкции повторения
«while В do S»определяют как семантику обращения«whiledo (В, S)»
к рекурсивной процедуре (описанной в синтаксисе языка АЛГОЛ 60):*
procedure whiledo (условие, оператор);
begin if условие then begin оператор;
whiledo (условие, оператор) end end
Несмотря на формальную правильность, это мне неприятно, потому что я не люблю, когда из пушки стреляют по воробьям, вне зависимости от того, насколько эффективно пушка может справляться с такой работой. Для поколения теоретиков машинной математики, которые подключались к этой тематике в течение шестидесятых годов, приведенное выше рекурсивное определение часто является не только «естественным», но даже «самым правильным». Однако ввиду того, что без понятия повторения мы не можем даже описать поведение машины Тьюринга, представляется необходимым произвести некоторое восстановление равновесия.
По поводу отсутствия библиографии я не предлагаю ни объяснений, ни извинений.
Благодарности. Следующие лица оказали непосредственное влияние на разработку этой книги, либо приняв участие в обсуждении ее предполагаемого содержания, либо высказав замечания относительно готовой рукописи или ее частей: К. Брон, Р. Берсталл, У. Фейсн, Ч. Хоар, Д. Кнут, М. Рем, Дж. Рейнольдс, Д. Росс, К. Шолтен, Г. Зигмюллер, Н. Вирт и
М. Вуджер. Я считаю честью для себя возможность публично выразить им мою признательность за сотрудничество. Кроме того, я весьма обязан корпорации Burroughs, создавшей мне благоприятные условия и предоставившей необходимые средства, и благодарен моей жене за неизменную поддержку и одобрение.
Э. В. Дейкстра
Нейен
Нидерланды
о АБСТРАКЦИЯ ИСПОЛНЕНИЯ
1
Абстракция исполнения лежит в основе всего понятия «алгоритма» настолько глубоко, что обычно ее считают само собой разумеющейся и оставляют без внимания. Ее назначение в том, чтобы сопоставлять между собой различные вычисления. Иначе говоря, она предоставляет нам способ осмысливания конкретного вычисления как элемента большого класса различных вычислений; мы можем отвлечься от взаимных отличий элементов такого класса и, руководствуясь определением класса в целом, высказывать утверждения, применимые к каждому его элементу, а следовательно, справедливые и для конкретного вычисления, которое мы хотим рассматривать.
Чтобы разъяснить, что подразумевается под «вычислением», я опишу сейчас невычислительную конструкцию «получения» (преднамеренно не употребляю термина «вычисление»), например, наибольшего общего делителя чисел 111 и 259. Она состоит из двух картонных карточек, расположенных одна поверх другой. На верхней карточке написан текст «НОД (111,259) =». Чтобы получить от конструкции ответ, мы поднимаем верхнюю карточку и кладем се слева от нижней, на которой теперь можно прочесть текст «37».
Простота карточной конструкции является большим достоинством, но она омрачается двумя недостатками — мелким и крупным. Мелкий недостаток состоит в том, что хотя эту конструкцию можно в самом деле использовать для получения наибольшего общего делителя чисел 111 и 259, но помимо этого она мало на что пригодна. Однако крупный недостаток в том, что, как бы тщательно мы ни проверяли устройство конструкции, наша вера в то, что она вырабатывает правильный ответ, может основываться только на нашем доверии к ее создателю: он мог ошибиться либо при проектировании своей машины, либо при изготовлении нашего конкретного экземпляра.
Чтобы преодолеть меньшее затруднение, мы могли бы рассмотреть изображение на огромном листе картона большого
прямоугольного массива из сетевых точек с целыми координатами х и у, удовлетворяющими отношениям 0^х^500 и О ^^/^500. Для каждой такой точки (х, у^ с положительными координатами (т. е. за исключением точек на осях) мы можем выписать в соответствующей позиции значение НОД (х, у); предлагается двумерная таблица из 250 000 элементов. С точки зрения полезности это значительное усовершенствование: вместо конструкции, способной выдавать наибольший общий делитель единичной пары чисел, мы имеем теперь «конструкцию», способную выдавать наибольший общий делитель для любой пары из 250 000 различных пар чисел. Это много, но особенно радоваться нечему, так как указанный ранее второй недостаток (почему мы должны верить, что конструкция выдает правильный ответ?) помножился на те же самые 250 000, и теперь от нас требуется уже совсем безграничное доверие к ее изготовителю.
Поэтому перейдем к рассмотрению другой конструкции. На таком же листе картона с сетевыми точками написаны только числа, пробегающие значения от 1 до 500 вдоль обеих осей. Кроме того, начерчены следующие прямые линии:
вертикальные линии (с уравнением х = константа);
горизонтальные линии (с уравнением у= константа);
диагонали (с уравнением х + // = константа);
«линия ответа» с уравнением х = у.
Чтобы работать на этой машине, мы должны следовать следующим инструкциям («играть по следующим правилам»). Когда хотим найти наибольший общий делитель двух чисел X и Y, мы помещаем фишку — также поставляемую изготовителем— в сетевую точку с координатами х = Х и y=Y. Коль скоро фишка нс находится на «линии ответа», рассматриваем наименьший равнобедренный прямоугольный треугольник, у которого вершина прямого угла совпадает с фишкой, а один из концов гипотенузы (либо ниже фишки, либо слева от нес) находится на одной из осей. (Поскольку фишка не лежит на линии ответа, такой прямоугольный треугольник будет иметь на осях только одну вершину.) Затем фишка перемещается в сетевую точку, совпадающую с другим концом -гипотенузы. Такое перемещение повторяется до тех пор, пока фишка не достигнет линии ответа. После этого х-координата (или ^/-координата) окончательного положения фишки является искомым ответом.
Как нам убедиться в том, что эта машина будет выдавать правильный результат? Если (х, у) —любая из 249 500 точек не на линии ответа и (х', у') —точка, в которую передвинется фишка за один шаг игры, то либо хЛ = х и у'= у—х, либо
Х'^х—у и у' — у- Нетрудно доказать, что НОД(х, £/)=НОД ^xf, у'У Важный момент здесь состоит в том, что одно и то же рассуждение применяется одинаково верно к любому из 249 500 возможных шагов! Кроме того, — и опять-таки без труда— мы можем доказать для любой точки (х, у), где х = у (т. е. (х, у) является одной из 500 точек на линии ответа), что НОД(х, у) = х. И снова важный момент в том, что одинаковое рассуждение применимо к любой из 500 точек на линии ответа. В-третьих,— и вновь это нс составит труда — нам нужно показать, что при любом исходном положении (X, У) конечное число шагов в самом деле перенесет фишку на линию ответа, и опять важно отмстить, что одно и то же рассуждение одинаково применимо к любому из 250 000 исходных положений (X, У). Три простых рассуждения, пространность которых не зависит от числа сетевых точек: эта миниатюра показывает, насколько велики возможности математики. Если обозначить через (х, у) произвольное положение фишки на протяжении игры, начатой в положении (X, У), то первая наша теорема позволяет утверждать, что во время этой игры отношение
НОД(х, у)=НОД(Х, У)
будет всегда справедливо, или — выражаясь на соответствующем жаргоне — «оно сохраняет инвариантность». Далее, вторая теорема гласит, что мы можем интерпретировать х-коор- динату окончательного положения фишки как требуемый ответ, а третья теорема гласит, что такое окончательное положение существует (т. е. будет достигнуто за конечное число шагов). Й этим завершается анализ того, что мы могли бы назвать «нашей абстрактной машиной».
Теперь нам остается убедиться в том, что лист, поступивший от изготовителя, является на самом деле правильной моделью абстрактной машины. Для этого нужно проверить нумерацию вдоль обеих осей, а также проверить, правильно ли проведены все прямые линии. Это несколько затруднительно, так как предстоит исследовать объекты, число которых пропорционально N, где N (в нашем примере 500)—длина стороны квадрата, но все же предпочтительнее, чем X2, число возможных вариантов вычисления.
Другая машина могла бы работать не с огромным листом картона, а с двумя девятибитовыми регистрами, в каждом из которых можно запомнить двоичное число от 0 до 500. При этом мы могли бы использовать один регистр для запоминания значения х-координаты, а другой — для запоминания значения у-коордпнаты, соответствующей «текущему положению фишки». Перемещение тогда соответствует уменьшению содержи-
мого одного регистра на содержимое другого. Мы могли бы реализовать арифметику самостоятельно, но, разумеется, лучше, если машина сможет делать это за нас. Если мы захотим полагаться на полученный ответ, то нам нужно уметь убеждаться в том, что машина правильно выполняет операции сравнения и вычитания. В уменьшенном масштабе повторяется та же история: мы выводим единожды и на все случаи, т. с. для любой пары /г-разрядных двоичных чисел, уравнения для устройства двоичного вычитания, а затем удостоверяемся в том, что наша физическая машина правильно моделирует это абстрактное устройство.
Если это устройство параллельного вычитания, то число проверок — пропорциональное числу элементов и их взаимосвязей— пропорционально значению zz = log2M. В последовательной машине сделан еще один шаг на пути упрощения оборудования за счет расхода времени.
Теперь я попытаюсь, хоть бы для своего собственного просвещения, уловить основной смысл приведенного примера.
Вместо того чтобы рассматривать одиночную проблему вычисления НОД (111, 259), мы обобщили се и подошли к ней как к частному случаю более широкого класса проблем вычисления НОД (X, У). Стоит отметить, что мы могли бы обобщить проблему вычисления НОД(111, 259) по-разному: можно было бы рассматривать эту задачу как частный случай иного, более широкого класса задач, например вычисления НОД(111,259), НОК(Ш,259), 111X259, 111+259, 111/259, 1 1 1—2 59, 1 1 1 259, дня недели для 111-го дня 259-го года нашей эры и т. д. В результате мог бы появиться «процессор для 111 и 259», и для того, чтобы он выдал упомянутый выше ответ, нам следовало бы дать на его вход команду «НОД, пожалуйста». Вместо этого мы предложили «НОД-вычисли- тсль», которому для получения этого ответа потребуется задать на вход пару чисел «111, 259», и это совсем другая машина!
Другими словами, когда требуется выработать один или несколько результатов., обычная практика состоит в том, чтобы обобщить проблему и рассматривать эти результаты как частные случаи некоего более широкого класса. Однако мало радости, если ограничиться утверждением, что любой предмет является частным случаем чего-то более общего. Если мы хотим следовать этому подходу, то на нас возлагаются две обязанности:
Мы должны иметь полную ясность относительно способа обобщения, т. с. должны тщательно выбрать и явно опреде-
лить более широкий класс, поскольку наши рассуждения должны применяться ко всему этому классу.
Мы должны выбрать («изобрести», если вам угодно) такое обобщение, которое окажется полезным для наших целей.
В нашем примере я, разумеется, отдаю предпочтение «НОД-вычислителю», а не «процессору для 111 и 259», и сравнение этих двух конструкций даст нам намек на то, какие характеристики делают обобщение «полезным для наших целей». Машина, которая по команде может вырабатывать в качестве ответа значения всех видов забавных функций от 111 и 259, становится все более неудобной для проверки по мере того, как растет набор функций. В этом явный контраст с нашим «НОД-вычислителем».
НОД-вычислитель был бы столь же плох, если бы он представлял собой таблицу из 250 000 записей, содержащих «заготовленные» ответы. Его характерное отличие в том, что он может быть задан в форме компактного набора «правил игры», который, если играть в соответствии с этими правилами, обеспечит выдачу нужного ответа.
Огромный выигрыш состоит в том, что единое рассуждение применительно к этим правилам позволяет нам доказывать существенные утверждения о результатах любого варианта игры. Это достигается ценой того, что при каждом из 250 000 конкретных применений этих правил мы получаем ответ «не сразу»: каждый раз игра должна быть сыграна в соответствии с правилами!
Тот факт, что мы в состоянии дать столь компактную формулировку правил игры, когда единое рассуждение позволяет нам выводить заключения о любых вариантах игры, непосредственно связан с систематизированным расположением 250 000 узловых точек. Мы оказались бы беспомощными, если бы лист картона содержал беспорядочный случайный разброс точек, исключающий какую-либо систематизацию. В нашем же случае мы могли бы разделить свою фишку на две половинки и двигать одну половинку вниз, пока она нс ляжет на горизонтальную ось, а другую половинку влево, пока она не ляжет на вертикальную ось. Вместо того чтобы воспроизводить с одной фишкой 250 000 возможных положений, мы могли бы добиться того же с двумя фишками, для каждой из которых нужно только 500 возможных положений, т. е. всего 1000 положений в общей сложности. Мы бы достигли того же уровня в 250 000 позиций, используя то обстоятельство, что любое из 500 положений одной половинки фишки может комбинироваться с любым из 500 положений другой половинки: число положений неразделенной фишки равно произведению
числа положений одной половинки на число положений другой. На принятом жаргоне мы говорим, что «общее пространство состояний рассматривается как декартово произведение пространств состояний переменных х и у».
Возможность замены одной фишки с двумерной свободой выбора положения на две половинки с одномерной свободой используется в предложенной выше двухрегистровой машине. С точки зрения технической реализации это представляется весьма заманчивым: требуется только построить регистры, способные различать 500 разных случаев («значений»), а за счет простого объединения этих двух регистров общее число разных случаев возводится в квадрат! Это перемножительное правило позволяет нам различать громадное число возможных общих состояний с помощью ограниченного числа компонентов, у каждого из которых только ограниченное число возможных состояний. По мере добавления таких компонентов размер пространства состояний возрастает экспоненциально, но нам следует иметь в виду, что это допустимо только при условии, что обоснование нашего нововведения остается весьма компактным; если такое обоснование тоже возрастает экспоненциально, то вообще нет никакого смысла создавать такую машину.
Замечание. Убедительную иллюстрацию к сказанному выше можно найти в изобретении, возраст которого уже превысил десять веков: в десятичной системе счисления! Она обладает тем поистине пленительным свойством, что число необходимых цифр возрастает всего лишь пропорционально логарифму максимального из чисел, которые должны быть представлены. Двоичная система счисления — это то, что получается, когда вы забываете, что на каждой руке имеется по пяти пальцев. (Конец замечания.)
Выше мы занимались одним аспектом множественности, а именно большим числом позиций фишки ( = возможных состояний). Имеется еще одна аналогичная множественность, а именно большое число различных игр ( = вычислений), которые могут состояться в соответствии с нашими правилами игры: по одной игре для каждого начального положения, если говорить точнее. Наши правила игры являются очень общими в том смысле, что они применимы к любому начальному положению. Но мы настаиваем на компактности обоснования правил игры, а это означает, что и сами правила должны быть компактными. В нашем примере это достигалось следующим приемом: вместо перечисления «сделай это, сделай то» мы задали правила игры в виде правил для выполнения «шага» в сочетании с критерием того, должен ли «шаг» быть выполнен
в следующий раз. (На самом деле шаг должен повторяться, пока не будет достигнуто состояние, в котором он становится неопределенным.) Иначе говоря, даже всю игру от начала до конца можно произвести с помощью повторяемых применений одного и того же «подправила».
Это очень продуктивный прием. Один алгоритм заключает в себе проект определенного класса вычислений, которые могут выполняться под его управлением; благодаря условному повторению «шага», вычисления из такого класса могут иметь весьма различные протяженности. Этим объясняется, как короткий алгоритм может занимать машину в течение длительного времени. С другой стороны, в этом можно усмотреть начальный намек на то, зачем нам могут понадобиться особенно быстрые машины.
Меня завораживает мысль, что эта глава могла писаться еще в те времена, когда Евклид мог смотреть на нее из-за моего плеча.
1 РОЛЬ ЯЗЫКОВ ПРОГРАММИРОВАНИЯ
В главе «Абстракция исполнения» я дал неформальные описания различных «машин», предназначенных для вычисления наибольшего общего делителя двух положительных (и нс слишком больших) чисел. Одно описание было выражено с помощью фишки, передвигаемой по листу картона, другое— с помощью двух половинок фишки, каждая из которых двигалась вдоль своей оси, а последнее — с помощью двух регистров, каждый из которых мог содержать целое число (не превышающее некоторой границы). Физически эти три «машины» весьма различны, однако математически они очень сходны: основная часть доказательства их способности вычислять наибольший общий делитель совпадает для всех трех машин. Это объясняется тем, что они представляют собой всего лишь различные воплощения одного и того же набора «правил игры», и это именно тот набор правил, который составляет сущность реального изобретения, известного как «алгоритм Евклида».
В предыдущей главе алгоритм Евклида описывался словесно, нс совсем формальным образом. Однако мы отмстили, что число соответствующих ему возможных вычислений столь велико, что нужно доказать его корректность. Коль скоро алгоритм дан только не формально, он не является вполне подходящим объектом для формального рассмотрения. Для дальнейшего нам потребуется описание этого алгоритма в некоторой удобной формальной записи.
Такая формальная запись может обладать многочисленными преимуществами. Любой способ записи подразумевает, что всякий описываемый с его помощью предмет задается как конкретный представитель (часто бесконечного) класса объектов, которые могут быть описаны этим способом. Наш способ описания должен, разумеется, обеспечивать изящное и точное описание алгоритма Евклида, но когда мы этого достигнем, алгоритм Евклида окажется в действительности заданным как представитель огромного класса всех видов алгоритмов.
И в описаниях некоторых из этих других алгоритмов мы сможем найти более интересные применения нашего способа записи. В случае алгоритма Евклида есть основания утверждать, что он настолько прост, что можно обойтись его неформальным описанием. Сила формальной записи должна проявиться в таких достижениях, которых без нее мы никогда нс смогли бы добиться!
Второе преимущество формального способа записи состоит в том, что он даст нам возможность изучать алгоритмы как математические объекты; при этом формальное описание алгоритма служит основой, позволяющей нам интеллектуально охватить этот алгоритм. Благодаря этому мы сумеем доказывать теоремы о классах алгоритмов, например пользуясь тем, что их описания обладают некоторым общим структурным свойством.
Наконец, такой способ записи позволит нам описывать алгоритмы настолько точно, что если будет задан описанный так алгоритм и заданы значения аргументов (вход), то не будет никаких сомнений относительно того, какими должны быть соответствующие ответы (выход). При этом можно считать, что вычисление выполняется автоматом, который, получив (формально описанный) алгоритм и аргументы, порождает ответы без дальнейшего вмешательства человека. Такие автоматы, способные обеспечивать взаимное воздействие алгоритма и аргумента, и в самом деле были построены. Они называются «автоматическими вычислителями». Алгоритмы, предназначенные для автоматического выполнения такими вычислителями, называются «программами», и с конца пятидесятых годов формальные способы, используемые для записи программ, называются «языками программирования». (Введение термина «язык» применительно к способам записи программ вызывает смешанные чувства. С одной стороны, оно оказалось весьма полезным, поскольку существующая теория лингвистики обеспечила естественную основу и устоявшуюся терминологию («грамматика», «синтаксис», «семантика» и т. д.) для рассмотрений применительно к этой новой тематике. С другой стороны, необходимо отметить, что аналогия с (ныне именуемыми так) «естественными языками» часто оказывалась дезориентирующей, так как для естественных языков, не формальных по существу, источником как их слабости, так и их силы является присущая им неопределенность и неточность).
В историческом плане этот последний аспект, т. е. возможность использования языков программирования в качестве средства общения с существующими автоматическими вычислителями, в течение долгого времени рассматривался как их наиболее важное свойство. Эффективность, с которой сущест-
вующие автоматические вычислители могли бы выполнять программы, написанные на конкретном языке, становилась главным критерием качества этого языка. Как огорчительное следствие мы нередко обнаруживаем, что аномалии существующих вычислительных машин старательно воспроизводятся в языках программирования, причем это происходит в ущерб интеллектуальной управляемости программ, выражаемых на таком языке (как будто программирование и без этих аномалий не было уже достаточно трудным!). В рамках нашего подхода мы постараемся восстановить равновесие и поэтому будем относиться к возможности фактического выполнения наших алгоритмов вычислительной машиной только как к счастливой случайности, которая не должна занимать центрального места в наших рассмотрениях. (В одном недавно опубликованном учебном тексте по программированию на PL/1 можно найти настойчивый совет избегать обращений к процедурам, насколько это возможно, «потому что они делают программу столь неэффективной». В свете того, что процедуры в языке PL/1 являются одним из основных средств описания структуры, этот совет выглядит ужасно, настолько ужасно, что вряд ли можно называть упомянутый текст «учебным». Если вы убеждены в полезности понятия процедуры и соприкасаетесь с приложениями, где дополнительные затраты на аппарат процедур обходятся слишком дорого, то порицайте эти неподходящие приложения, а не возводите их на уровень стандартов. Равновесие и в самом деле надлежит восстановить!)
Я рассматриваю язык программирования преимущественно как средство для описания (потенциально весьма сложных) абстрактных конструкций. Как показано в главе «Абстракция исполнения», первейшим достоинством алгоритма является потенциальная компактность рассуждений, на которых может основываться наше проникновение в его сущность. Как только эта компактность потеряна, алгоритм в значительной мере теряет «право на существование», и поэтому мы будем стремиться к сохранению такой компактности. Соответственно и наш выбор языка программирования будет подчинен той же цели.
2 СОСТОЯНИЯ и ИХ ХАРАКТЕРИСТИКА
В течение многих столетий человек оперирует натуральными числами. Мне представляется, что в доисторические времена, когда перед нашими предками впервые забрезжило понятие числа, они изобретали индивидуальные имена для каждого числа, на которое у них обнаруживалась потребность сослаться. Им приходилось иметь имена для чисел точно так же, как мы имеем имена «один, два, три, четыре и так далее».
Это поистине «имена» в том смысле, что при исследовании последовательности «один, два, три» никакое правило не поможет нам умозаключить, что следующим именем будет «четыре». Вы, конечно же, должны это знать. (В том возрасте, когда я вполне удовлетворительно умел считать — по-голландски,— мне пришлось учиться счету по-английски и во время экзамена самое изощренное знание слов «семь» и «девять» не смогло помочь мне установить, как писать слово «восемь», не говоря уж о том, как произносить его.)
Очевидно, что такое несистематизированное разнообразие позволяет нам выделять индивидуально только весьма ограниченное количество различных чисел. Чтобы избежать подобного ограничения, каждый язык в цивилизованном мире вводит (более или менее) систематизированное именование натуральных чисел, и обучение счету в значительной степени сводится к обнаружению системы, лежащей в основе этого именования. Когда ребенок учится считать до тысячи, ему не приходится заучивать наизусть эту тысячу имен (в их точном порядке!); он руководствуется определенными правилами: наступает момент, когда ребенок узнает, как переходить от любого числа к следующему, например от «четыреста двадцать девять» к «четыреста тридцать».
Легкость обращения с числами сильно зависит от выбранной нами для них систематизации. Гораздо труднее установить, что
дюжина дюжин = гросс одиннадцать плюс двенадцать = двадцать три XLVII + IV=LI
чем установить, что
"12X12=144
11 + 12 = 2347 + 4-51
так как последние три ответа можно получить, применив простой набор правил, которыми может руководствоваться любой восьмилетний ребенок.
При механических манипуляциях над числами преимущества десятичной системы счисления проявляются гораздо более отчетливо. Уже в течение столетий мы располагаем механическими сумматорами, показывающими ответ в окошке, позади которого имеется несколько колес с десятью различными положениями, причем каждое колесо в любом своем положении показывает одну десятичную цифру. (Теперь уже не является проблемой показать «00000019», прибавить 4 и затем показать «00000023»; было бы проблемой — по крайней мере если пользоваться чисто механическими средствами — показать вместо этого «девятнадцать» и «двадцать три».)
Существенное свойство такого колеса состоит в том, что оно ■обладает десятью различными устойчивыми положениями. Терминологически это выражается разными способами. Например, колесо называется «переменной с десятью значениями», и если мы хотим большей точности, то даже перечисляем эти значения: от 0 до 9. Здесь каждое «положение» колеса отождествляется со «значением» переменной. Колесо называется «переменной», потому что, хотя его положения и устойчивы, колесо может повернуться в другое положение: «значение» может измениться. (Я вынужден отметить, что этот термин неудачен по крайней мере в двух отношениях. Во-первых, такое колесо, которое (почти) всегда находится в одном из своих десяти положений и, следовательно, (почти) всегда представляет некое значение, является понятием, весьма отличным от того, что математики называют «переменной», поскольку, как правило, математическая переменная вообще не представляет какого-либо конкретного значения. Если мы говорим, что для любого целого числа п утверждение /г2^0 истинно, то здесь п является переменной совсем другого рода. Во-вторых, в нашем контексте мы используем термин «переменная» для того, что существует во времени и чье значение, если его нс трогать, остается постоянным! Термин «изменяемая константа» подошел бы лучше, но мы не станем вводить его, а будем придерживаться прочно установившейся традиции.)
Другой способ терминологически отразить сущность такого колеса, которое (почти) всегда находится в одном из деся-
ти различных положений или «состояний», состоит в том, чтобы поставить этому колесу в соответствие «пространство состояний из десяти точек». Здесь каждое состояние (положение) связывается с «точкой», а собрание этих точек называется — в соответствии с математической традицией — пространством или, если мы хотим большей точности, «пространством состояний». Вместо того чтобы говорить, что переменная обладает {почти) всегда одним из своих возможных значений, мы можем теперь выразить то же, сказав, что система, состоящая из •единственной переменной, находится (почти) всегда в одной из точек своего пространства состояний. Пространство состояний описывает степень свободы системы; больше ей некуда переходить.
Отвлечемся теперь от отдельно взятого колеса и сосредоточим наше внимание на регистре с восемью такими колесами в строке. Поскольку каждое из этих колес находится в одном из десяти различных состояний, этот регистр, рассмотренный как целое, находится в одном из 100 000 000 возможных различных состояний, каждое из которых естественно идентифицируется числом (а точнее, строкой из восьми цифр), показываемым в окошке.
Если заданы состояния всех колес, то состояние регистра в целом однозначно определено; и обратно, по любому состоянию регистра в целом однозначно определяется состояние каждого отдельного колеса. В этом случае мы говорим (в предыдущей главе нами уже использован этот термин), что получаем (или строим) пространство состояний регистра в целом, формируя «декартово произведение» пространств состояний восьми отдельных колес. Общее число точек в этом пространстве состояний является произведением чисел точек в тех пространствах состояний, из которых оно построено (именно поэтому оно называется декартовым произведением).
В зависимости от того, что нас интересует в этом предмете, мы либо рассматриваем такой регистр как единую переменную с 108 различными возможными значениями, либо как составную переменную, составленную из восьми различных переменных, называемых «колесами», с десятью значениями. Если мы интересуемся только показываемыми значениями, то будем, по-видимому, рассматривать регистр как неделимое понятие, тогда как инженер-эксплуатационник, которому нужно заменить колесо со сломанным зубцом, будет, конечно, рассматривать регистр как составной объект.
Л1ы наблюдали другой пример построения пространства состояний как декартова произведения более мелких пространств состояний, когда обсуждали алгоритм Евклида и обнаружили, что положение фишки где-то на листе можно пол-
ностыо идентифицировать с помощью двух полуфишек, каждая из которых находится где-то на своей оси, т. с. с помощью комбинации (а точнее, упорядоченной пары) двух переменных «х» и «у». (Идея идентификации положения точки на плоскости значениями се координат х и у идет от Декарта, который разработал аналитическую геометрию и в честь которого названо декартово произведение.) Фишка на листе была введена для демонстрации того факта, что развивающийся вычислительный процесс — такой, как выполнение алгоритма Евклида,— можно рассматривать как систему, движущуюся по своему пространству состояний. В соответствии с этой метафорой начальное состояние именуется также «исходной точкой».
В этоВ книге мы будем преимущественно — а может быть, даже исключительно — заниматься системами, пространства состояний которых будут в конечном итоге рассматриваться как некие декартовы произведения. Разумеется, нс следует усматривать в этом мое мнение, будто бы пространства состоя- iinii, построенные с помощью декартовых произведений, являются общим и окончательным решением всех наших проблем; я отлично знаю, что это нс так. Из дальнейшего станет очевидным, почему они заслуживают столь большого нашего внимания и в то же время почему это понятие играет столь главенствующую роль во многих языках программирования.
Прежде чем продолжить рассуждения, отмечу одну проблему, с которой нам придется столкнуться. Когда мы образуем пространство состояний, строя декартово произведение, то вовсе нс обязательно, что нам окажутся полезными все его точки. Типичным примером этому является характеристика дней данного года с помощью пары (месяц, день), где «месяц» — переменная с 12 значениями (от «янв» до «дек»), а «день» — переменная с 31 значениями (от 1 до 31). В этом случае мы образуем пространство состояний из 372 точек, тогда как никакой год нс содержит более чем 366 дней. Что нам делать, скажем, с парой (июн, 31)? Либо мы запрещаем се, вводя понятие «невозможных дат» и тем самым создавая в некотором смысле возможность внутренних противоречий в системе, либо мы разрешаем се как другой вариант имени для одного из «истинных» дней, например, приравнивая се к (июл, 1). Феномен «неиспользуемых точек пространства состояний» возникает всякий раз, когда число различных возможных значений, которые мы желаем распознавать, оказывается простым числом.
Систематизация, которая автоматически вводится, когда мы строим пространство состояний как декартово произведение, позволяет нам идентифицировать отдельно взятую точку. Например, я могу утверждать, что мой день рождения — это
(май, 11). Однако благодаря Декарту нам известен теперь и другой способ фиксации этого факта: мой день рождения приходится на ту дату (месяц, день), которая соответствует решению уравнения1)
(мссяц=ман) and (день=11)
Приведенное выше уравнение имеет только одно решение и поэтому представляет собой несколько усложненный способ указания этого единственного дня в году. Однако преимущество использования уравнения состоит в том, что оно позволяет нам охарактеризовать множество всех его решений, и такое множество может содержать значительно больше чем одну точку. Тривиальным примером может служить определение рождества:
(месяц = дек) and ((день = 25) or (депь = 26))
Более примечательным примером является определение множества дат, в которые выплачивается мое ежемесячное жалованье:
(дснь = 23)
и разумеется, это гораздо более компактное описание, чем перечисление вида «(янв, 23), (фев, 23), (мар, 23)» и т. д.
Из сказанного выше явствует, что степень легкости, с которой мы используем такие уравнения для характеристики множеств состояний, зависит от того, насколько множества, которые мы хотим охарактеризовать, «соответствуют» структуре пространства состояний, т. е. «соответствуют» введенной системе координат. Например, в рассмотренной выше системе координат было бы несколько неудобно охарактеризовать множество дней, которые приходятся на тот же день педели, что и (янв, 1). Во многих случаях программисту приходится при решении задач вводить пространства состояний с подходящими для его целей системами координат; причем дополнительные требования часто вынуждают его вводить также пространства состояний, в которых число точек во много раз больше, чем число различных возможных значений, которые он должен распознавать.
Другой пример использования уравнения для характеристики множества состояний встречался в нашем описании картонной машины для вычисления НОД (X, У), где
Здесь и далее в математических формулах используются для обозначения логических операций символы and (кон ыоикция), ог (дизъюнкция) и поп (отрицание). — Прим, перев.
характеризует все точки того, что мы назвали «линией ответа»; это множество конечных состояний, т. с. вычисление прекращается тогда и только тогда, когда достигнуто какое- нибудь состояние, удовлетворяющее уравнению х=у.
Помимо координат пространства состояний, т. с. переменных, в терминах значений которых выражается ход вычислительного процесса, мы встречали в наших уравнениях константы (такие, как «май» или «23»). Кроме того, можно пользоваться так называемыми «свободными переменными», которые можно представить себе как «неспецифицированные константы». Мы применяем их специально для обозначения связей различных состояний, которые могут встречаться на последовательных шагах одного и того же вычислительного процесса. Например, во время некоего конкретного выполнения алгоритма Евклида с начальной точкой (X, У) все состояния (х, у) будут удовлетворять формуле
НОД(х, у)=НОД(Х, У) and 0<x^X and 0<y^Y
Здесь X и У не являются такими переменными, как х и у. Они представляют собой «их начальные значения». Это константы с точки зрения конкретного вычисления, но они не специфицированы в том смысле, что мы могли бы запустить алгоритм Евклида с любой точкой сетки в качестве начального положения нашей фишки.
В заключение некоторые терминологические замечания. Я буду называть такие уравнения «условиями» или «предикатами». (Я мог бы, а возможно, и должен бы различать эти понятия, зарезервировав термин «предикат» для формального выражения, обозначающего «условие». Тогда мы имели бы возможность сказать, например, что два разных предиката «х=у» и «у = х» обозначают одно и то же условие. Однако, зная себя, я не надеюсь преуспеть в такой манере изложения.) Я буду считать синонимичными такие выражения, как «состояние, для которого предикат истинен», «состояние, которое удовлетворяет условию», «состояние, в котором условие удовлетворяется», «состояние, в котором условие справедливо» и т. д. Если система заведомо достигнет состояния, удовлетворяющего условию Р, то мы будем говорить, что система заведомо «обеспечит истинность Р».
Предполагается, что всякий предикат определен в каждой точке рассматриваемого пространства состояний: в каждой точке значением предиката является либо «истина», либо «ложь», и предикат служит для характеристики множества всех точек, для которых (или в которых) этот предикат истинен.
Мы будем называть два предиката Р и Q равными (формально «P = Q»)) если они обозначают одно и то же условие, т. е. характеризуют одно и то же множество состояний.
Два предиката будут играть особую роль, и мы резервируем для них имена «7» и «Р».
Т — это предикат, который истинен во всех точках рассматриваемого пространства состояний; ему соответствует полное множество состояний.
Г— предикат, который ложен во всех точках пространства состояний; он соответствует пустому множеству.
ХАРАКТЕРИСТИКА СЕМАНТИКИ
Нас интересуют преимущественно такие системы, которые, приступив к работе в некоем «начальном состоянии'», завершат ее в каком-то «конечном состоянии», которое, как правило, зависит от выбора начального состояния. Этот подход несколько отличается от концепции автомата с конечным числом состояний, который, с одной стороны, воспринимает поток входных символов, а с другой стороны, порождает поток выходных символов. Чтобы перевести это в нашу схему, мы должны предположить, что значение входа (т. е. аргумент) отражается в выборе начального состояния и что значение выхода (т. е. ответ) отражается в выборе конечного состояния. Наш подход избавляет нас от всевозможных непринципиальных сложностей.
Первая часть этой главы посвящена почти исключительно так называемым «детерминированным машинам», тогда как во второй части (которую можно пропустить при первом чтении) речь идет о так называемых «недетерминированных машинах». Различие между этими двумя понятиями состоит в том, что для детерминированной машины событие, которое произойдет после запуска конструкции, полностью определяется ее начальным состоянием. Если она запускается дважды в одинаковых начальных состояниях, то произойдут одинаковые события: поведение детерминированной машины полностью воспроизводимо. Для недетерминированной машины, напротив, запуск в заданном начальном состоянии приведет к какому-то одному событию из класса возможных событий; начальное состояние только фиксирует этот класс в целом.
Теперь я предполагаю, что проектирование такой системы является целенаправленной деятельностью, т. е. что мы желаем достичь чего-то с помощью этой системы. Например, если мы хотим создать машину, способную вычислять наибольший общий делитель, то можем потребовать, чтобы конечное состояние удовлетворяло условию
% = НОД(Х, У) (1)
В рассмотренной ранее машине мы будем иметь также у = НОД(Х, У), потому что игра заканчивается при х=у, но это отнюдь не часть наших требований, когда мы решаем принять'конечное значение х в качестве ответа.
Мы называем условие (1) (желаемым) «постусловием» — «пост», потому что оно налагается на то состояние, в котором система должна оказаться после своей работы. Заметим, что постусловие может удовлетворяться многими возможными состояниями. В таком случае мы естественно считаем каждое из них одинаково удовлетворительным, так что нет оснований требовать, чтобы конечное состояние было однозначной функцией от начального состояния. (Читатель увидит из дальнейшего, что именно здесь проявляется потенциальная полезность недетерминированного устройства.) •
Для того чтобы использовать эту машину, когда мы хотим получить от нее ответ (например, хотим «чтобы она достигла конечного состояния, удовлетворяющего постусловию (1) для заданного набора значений X и У»), нам желательно знать множество соответствующих начальных состояний, а более точно, множество таких начальных состояний, при которых запуск обязательно приведет к событию правильного завершения, причем система останется в конечном состоянии, удовлетворяющем постусловию. Если мы можем привести систему без вычислительных усилий в одно из таких состояний, то мы уже знаем, как использовать эту систему для получения желаемого ответа. Приведем пример для евклидовой игры на картоне: мы можем гарантировать конечное состояние, удовлетворяющее постусловию (1) для любого начального состояния, удовлетворяющего условию
НОД (х, t/) =НОД (X, У) and 0<х^500 and 0<у^500 (2)
(Верхние границы добавлены, чтобы учесть ограниченный размер листа картона. Если мы начинаем с парой (X, У), такой, что НОД(Х, У) =713, то не существует пары (х, у), удовлетворяющей условию (2), т. е. для таких значений X и У условие (2) сводится к предикату F; а это означает, что рассматриваемая машина не может быть использована для вычисления НОД (X, У) применительно к этой паре значений X и У).
При многих комбинациях (X, У) многие состояния удовлетворяют условию (2). В случае 0<Х^500 и 0<У^500 тривиальным выбором является х = Х и y=Y. Этот выбор может быть произведен без какого-либо вычисления функции НОД и даже без учета того факта, что это симметричная функция от своих аргументов.
Условие, характеризующее множество всех начальных состояний, при которых запуск обязательно приведет к событию
правильного завершения, причем система останется в конечном состоянии, удовлетворяющем заданному постусловию, называется «слабейшим предусловием, соответствующим этому постусловию». (Мы называем его «слабейшим», поскольку, чем слабее условие, тем больше состояний удовлетворяют ему, а мы стремимся здесь охарактеризовать все возможные начальные состояния, которые неизбежно приведут к желаемому конечному состоянию.)
Если система (машина, конструкция) обозначается через 5, а желаемое постусловие — через R, то соответствующее слабейшее предусловие мы обозначим !)
wp(S, R)
Если начальное состояние удовлетворяет wp(S, R), то конструкция обязательно обеспечит в конце концов истинность R. Поскольку wp(S, 7?)—это слабейшее предусловие, мы знаем также, что если начальное состояние не удовлетворяет wp(S, R), то такой гарантии дать нельзя, т. е. ход событий может привести к завершению работы в конечном состоянии, не удовлетворяющем R, а может даже и вообще помешать достижению какого бы то ни было конечного состояния (как мы увидим далее, либо потому, что система оказывается вовлеченной в выполнение незавершимого задания, либо потому, что система попадает в тупик).
Мы считаем, что нам достаточно хорошо известно, как работает конструкция S, если можем вывести для любого постусловия R соответствующее слабейшее предусловие wp (S, R), поскольку тем самым мы уловили, что эта конструкция способна сделать для нас; это называется «ее семантикой».
Здесь уместны два замечания. Во-первых, множество возможных постусловий, вообще говоря, столь огромно, что эта информация в табличной форме (т. е. в виде таблицы, где каждому постусловию R соответствует запись, в которой содержится соответствующее предусловие wp(S, R)), оказалась бы совершенно необозримой, а следовательно, бесполезной. Поэтому определение семантики той или иной конструкции всегда дается другим способом — в виде правила, описывающего, как для любого заданного постусловия R можно вывести соответствующее слабейшее предусловие wp(S, RV Для фиксированной конструкции S такое правило, которое по заданному предикату R, означающему постусловие, вырабатывает предикат wp(S, R), означающий соответствующее слабейшее предусловие, называется «преобразователем предика-
]) По начальным буквам английских слов weakest pre-condition. — Прим, перев.
тов». Когда мы просим, чтобы нам сообщили описание семантики конструкции S, то в сущности речь идет о соответствующем этой конструкции преобразователе предикатов.
Во-вторых,— и я чувствую искушение добавить «благодарение судьбе» — часто нас не интересует полная семантика конструкции. Это объясняется тем, что мы стремимся использовать конструкцию S только для конкретной надобности, а точнее, для того, чтобы обеспечить истинность весьма конкретного постусловия R, ради которого производилась разработка конструкции. И даже применительно к этому конкретному постусловию R нас часто не интересует точный вид wp(S, 7?); зачастую мы удовлетворяемся более сильным условием Р, т. е. таким условием, для которого можно показать, что утверждение
Р=^ wp(S, 7?) для всех состояний (3) справедливо. (Предикат «P=>Q» (читается «из Р следует Q») ложен только в тех точках пространства состояний, где условие Р справедливо, a Q не справедливо; во всех остальных точках он истинен. Требуя, чтобы утверждение «Р =£> wp(S, 7?)» было справедливо для всех состояний, мы тем самым требуем, чтобы всякий раз когда Р— истина, условие wp(S, R) тоже было истиной; тогда Р— достаточное предусловие. В теоретико-множественной терминологии это означает, что множество состояний, характеризуемое условием Р, является подмножеством того множества состояний, которое характеризуется условием wp(S, 7?).) Если для заданных Р, S и R отношение (3) выполняется, это часто можно доказать, нс прибегая к точной формулировке,— или, если вам так больше нравится, «вычислению» или «выводу» — предиката wp(S, R). И это отрадное обстоятельство, поскольку, за исключением тривиальных случаев, следует ожидать, что явная формулировка условия wp(S, R) превысит по крайней мере резерв нашей писчей бумаги, нашего терпения или нашей (аналитической) изобретательности (или какую-то комбинацию этих резервов).
Смысл wp(S, R), т. е. «слабейшего предусловия для начального состояния, при котором запуск обязательно приведет к событию правильного завершения, причем система S останется в конечном состоянии, удовлетворяющем постусловию R», позволяет нам установить, что преобразователь предикатов, рассматриваемый как функция от постусловия R, обладает рядом определенных свойств.
Свойство 1. Для любой конструкции S мы имеем
wp(S, F)=F (4)
Предположим, что оказалось не так; при этом нашлось бы хоть одно состояние, удовлетворяющее условию wp(S, F). Возьмем такое состояние в качестве начального состояния для конструкции S. Тогда, согласно нашему определению, запуск привел бы к событию правильного завершения, причем система осталась бы в конечном состоянии, удовлетворяющем предикату F. Но здесь возникает противоречие, так как по определению предиката F не существует состояний, удовлетворяющих F; тем самым доказано отношение (4). Свойство 1 известно также под названием «закон исключенного чуда».
Свойство 2. Для любой конструкции S и любых постусловий Q и R, таких, что
Q =^R для всех состояний(5)
справедливо также отношение
wp(S, Q) =>wp(S, 7?) для всех состояний (6)
В самом деле, при любом начальном состоянии, удовлетворяющем wp(S, Q), согласно определению, по окончании работы системы будет обеспечена истинность Q. С учетом отношения (5) тем самым будет обеспечена и истинность R. Следовательно, данное начальное состояние будет одновременно удовлетворять и условию wp(S, /?), как записано в (6). Свойство 2— это свойство монотонности.
Свойство 3. Для любой конструкции S и для любых постусловий Q и R справедливо
(wp(S, Q) and wp(S, 7?)) = wp(S, Q and 7?)(7)
В любой точке пространства состояний из левой части отношения (7) логически следует его правая часть, потому что для любого начального состояния, удовлетворяющего и wp(S, Q), и wp(S, 7?), мы знаем в совокупности, что установится конечное состояние, удовлетворяющее и Q, и R. Далее, поскольку, в силу определения,
(Q and R) —> Q для всех состояний свойство 2 позволяет нам заключить, что
wp(S, Q and 7?)=>wp(S, Q) для всех состояний
и аналогично
wp(S, Q and 7?) => wp (S, 7?) для всех состояний
Однако из А => В и А => С, согласно исчислению предикатов, можно вывести, что А =Ф (В and С). Поэтому правая часть отношения (7) логически следует из его левой части в любой
точке пространства состояний. Поскольку обе части всюду следуют друг из друга, они должны быть равны, а тем самым доказано свойство 3.
' Свойство 4. Для любой конструкции S и любых постусловий Q и R справедливо
(wp(S, Q) or wp(S, 7?)) =^ wp(S, Q or R) для всех состояний (8) Поскольку, в силу определения,
Q => (Q or 7?) для всех состояний
свойство 2 позволяет нам прийти к заключению, что
wp(S, Q) wp(S, Q or R) для всех состояний
и аналогично
wp(S, 7?) =>wp(S, Q or R) для всех состояний
. Но, согласно исчислению высказываний, из А С и В=^> С можно заключить, что (Д or В) С, и этим доказана справедливость соотношения (8). Вообще говоря, следование в обратном направлении не имеет места. Так про женщину, которая ждет ребенка, неверно утверждение, что она родит обязательно сына и неверно утверждение, что она родит обязательно дочь; однако с абсолютной уверенностью можно утверждать, что она родит сына или дочь. Впрочем, для детерминированных конструкций справедливо следующее, более сильное свойство.
Свойство 4'. Для любой детерминированной конструкции S i любых постусловий Q и R справедливо
(wp (S, Q) or wp (S, R)) = wp (S, Q or 7?)
Нам нужно показать, что следование выполняется и в левую сторону. Рассмотрим какое-то начальное состояние, удовлетворяющее wp(S, Q or R); этому начальному состоянию соответствует единственное конечное состояние, удовлетворяющее либо Q, либо 7?, либо обоим условиям; следовательно, данное начальное состояние должно удовлетворять либо wp(S, Q), либо wp(S, 7?), либо обоим условиям соответственно, т. е. оно должно удовлетворять (wp(S, Q) or wp(S, 7?)). II этим доказано свойство 4х.
В этой книге я буду — и это может оказаться одной из ее отличительных особенностей — рассматривать недетерминированность как правило, а детерминированность как исключение: детерминированная машина будет рассматриваться как частный случай недетерминированной, как конструкция, для
которой справедливо свойство 4', а не только более слабое свойство 4. Такое решение отражает коренное изменение в моем мировоззрении. В 1958 г. я был одним из первых, кто разрабатывал базовое программное обеспечение для машины с прерываниями ввода-вывода, и невоспроизводимость поведения такой во всех отношениях недетерминированной машины явилась горестным обстоятельством. Когда впервые была предложена идея прерываний ввода-вывода, меня настолько пугала сама мысль о необходимости разрабатывать надежное программное обеспечение для такого неукротимого зверя, что я оттягивал принятие решения о допущении таких прерываний по крайней мере в течение трех месяцев. И даже после того, как я сдался (мое сопротивление сломили лестью), чувствовал я себя весьма неуютно: ведь ошибка в программе способна вызвать несуразное поведение системы, столь сходное с невоспроизводимым машинным сбоем. Кроме того,— и это в то время, когда для детерминированных машин мы все еще полагались на «отладку»,— с самого начала было совершенно очевидно, что тестирование программ оказывалось теперь совсем непригодным средством для достижения должного уровня надежности.
В течение многих лет впоследствии я относился к невоспро- изводимости поведения недетерминированной машины как к добавочному осложнению, которого следует избегать любыми способами. Прерывания были для меня не чем иным, как злыми кознями инженеров по аппаратуре против бедных разработчиков программного обеспечения. Из этого моего страха родилась дисциплина «гармонически взаимодействующих последовательных процессов». Несмотря на ее успех, мои опасения сохранялись, поскольку наши решения — хоть бы и с доказанной правильностью — представлялись частными решениями проблемы «укрощения» (именно так мы это воспринимали!) конкретных видов недетерминированности. Основанием для моего страха было отсутствие общей методологии.
С тех пор два обстоятельства изменили картину. Первое возникло с пониманием того, что даже в случае полностью детерминированных машин полезность тестирования программ оказывается сомнительной. Как я уже много раз говорил и во многих местах писал: тестирование программы может вполне эффективно служить для демонстрации наличия в ней ошибок, но, к сожалению, непригодно для доказательства их отсутствия. Другим прояснившимся тем временем обстоятельством явилось обнаружение необходимости того, чтобы всякая дисциплина проектирования должным образом учитывала тот факт, что само проектирование конструкции, предназначенной для какой-то цели, тоже должно быть целенаправленной
деятельностью. В нашем конкретном случае это означает естественность предположения, что отправной точкой для проектных рассмотрений будет служить заданное постусловие. В каком-то смысле мы будем «работать вспять». Действуя так, мы обнаружим, что весьма существенным оказывается следование из свойства 4, тогда как от равенства из свойства 4' мы получим очень мало пользы.
Как только мы разработали математический аппарат, позволяющий проектировать недетерминированные конструкции, достигающие цели, недетерминированная машина перестает нас пугать. Напротив! Мы научимся даже ценить ее как важную ступень на пути разработки проекта в конечном итоге полностью детерминированной конструкции.
(Последующая часть этой главы может быть пропущена при первом чтении.) Ранее мы договорились, что представляем себе функционирование конструкции S достаточно хорошо, если только знаем, как соответствующий ей преобразователь предикатов wp(S, R) действует над любым постусловием R. Если нам известно также, что данная конструкция детерминирована, то знание этого преобразователя предикатов полностью определяет возможное поведение конструкции. Для детерминированной конструкции S и некоторого постусловия R всякое начальное состояние попадает в одно из трех непе- ресекающихся множеств в соответствии со следующими взаимно исключающими возможностями:
Запуск конструкции S приведет к конечному состоянию, удовлетворяющему R.
Запуск конструкции S приведет к конечному состоянию, удовлетворяющему non R.
Запуск конструкции S не приведет к какому бы то ни было конечному состоянию, т. е. работа не сможет завершиться правильным образом.
Первое множество характеризуется выражением wp(S, R), второе множество — выражением wp(S, non R), их объединение — выражением
(wp(S, R^ or wp(S, non R)) = wp(S, R or non R) = wp(S, T) а следовательно, третье множество характеризуется выражением non wp (S, Г).
Труднее дать полную семантическую характеристику недетерминированной системы. По отношению к любому заданному постусловию R мы снова имеем три возможных типа собы- 1ий, как перечислено выше в пунктах (а), (б) и (в). Однако в случае недетерминированной системы одно и то же началь-
ное состояние не обязательно приводит к единственному событию, которое по определению относилось бы к одному из трех взаимно исключающих типов; для любого начального состояния возможные события могут теперь относиться к одному, двум или даже ко всем трем типам.
Чтобы описывать такие ситуации, мы можем использовать понятие «свободного предусловия». Ранее мы рассматривали такие предусловия, при которых гарантировалась достижимость «правильного результата», т. е. конечного состояния, удовлетворяющего R. Свободное предусловие является более слабым: оно гарантирует только, что система не выдаст неправильного результата, т. е. не достигнет такого конечного состояния, которое не удовлетворяло бы R, однако не исключается возможность незавершения работы системы. Аналогично и для свободных предусловий мы можем ввести понятие «слабейшего свободного предусловия»; обозначим его через wlp(S, R). При этом пространство начальных состояний подразделяется в принципе на семь взаимно непересекающихся областей, ни одна из которых не обязана быть пустой. (Их семь, потому что из трех объектов можно сделать семь непустых выборок.) Все они легко характеризуются тремя предикатами: wlp(S, R), wlp(S, non 7?) и wp(S, Т).
wp(S, 7?) = (wlp(S, 7?) and wp (S, 7))
Запуск обеспечит истинность R.
wp(S, non R) = (wlp(S, non 7?) and wp(S, 7))
Запуск обеспечит истинность non R.
wlp (S, F) = (wlp (S, 7?) and wlp (S, non R))
Запуск не сможет привести к правильно завершаемой работе.
(аб) wp(S, 7) and non wlp(S, 7?) and non wlp(S, non 7?)
Запуск приведет к завершаемой работе, но по начальному состоянию нельзя определить, будет ли конечное состояние удовлетворять условию R или нет.
(ав) wlp(S, 7?) and non wp(S, 7)
Если запуск приводит к какому-то конечному состоянию, такое конечное состояние будет удовлетворять R, но по начальному состоянию нельзя определить, завершится ли работа системы.
(бв) wlp (S, non 7?) and non wp (S, 7)
Если запуск приводит к какому-то конечному состоянию, такое конечное состояние не будет удовлетворять R, но по начально-
му состоянию нельзя определить, завершится ли работа системы.
(абв) non (wlp (S, R) or wlp(S, non R) or wp(S, T))
По начальному состоянию нельзя определить, приведет ли запуск к завершаемой работе, а в случае завершаемое™ нельзя определить, будет ли удовлетворяться условие R.
Последние четыре возможности существуют только для недетерминированных машин.
Из определения wlp(S, /?) следует, что
wlp(S, П=Т
Ясно также, что
(wlp (S, F) and wp (S, T^=F
Если бы было не так, то существовало бы начальное состояние, для которого можно было бы гарантировать и завер- шимость, и незавершимость работы.
На рис. 3.1 дано графическое представление пространства состояний, причем внутренние части прямоугольников удовлетворяют wlp(S, R"), wlp(S, non 7?) и wp(S, Т) соответственно.
Этот анализ приведен для полноты, а также потому, что на практике понятие свободного предусловия оказывается весьма полезным. Если, например, кто-то реализует
язык программирования, то он не станет доказывать, что при этой реализации любая правильная программа выполняется правильно. Он был бы удовлетворен и счастлив, если бы твердо знал, что никакая правильная программа не будет выполняться неправильно без соответствующего предупреждения,— разумеется, при условии, что класс программ, которые на самом деле будут выполняться правильно, достаточно велик для того, чтобы эта реализация представляла какой-то ин
терес.
Однако пока мы не будем рассматривать понятие свободного предусловия, а сосредоточим свое внимание на способе
характеристики тех начальных состояний, которые гарантируют получение правильного результата. Когда такой способ будет разработан, мы посмотрим, как можно его видоизменить таким образом, чтобы это позволило нам рассуждать о свободных предусловиях в той мере, в которой они нас интересуют.
4 СЕМАНТИЧЕСКАЯ ХАРАКТЕРИСТИКА ЯЗЫКА ПРОГРАММИРОВАНИЯ
В предыдущей главе мы выдвинули тезис, что знаем семантику конструкции S достаточно хорошо, если знаем ее «преобразователь предикатов», т. с. правило, указывающее нам, как вывести по любому постусловию /? соответствующее слабейшее предусловие (которое мы обозначили через wp(S, /?)), характеризующее те начальные состояния, при которых запуск приведет к событию правильного завершения, причем система останется в конечном состоянии, удовлетворяющем постусловию /?. Вопрос в том, как выводить wp(S, R) для заданных 5 и R.
Оставим пока вопрос об одиночной конкретной конструкции 5. Программа, написанная на хорошо определенном языке программирования, может рассматриваться как некая конструкция, такая конструкция, которую мы знаем достаточно хорошо, если знаем соответствующий преобразователь предикатов. Однако язык программирования полезен только при том условии, что его можно применять для записи многих различных программ, и для всех этих программ нам желательно знать соответствующие им преобразователи предикатов.
Каждая такая программа задается своим текстом на хорошо определенном языке программирования, и поэтому ее текст должен служить для нас отправной точкой. Но теперь перед нами неожиданно открываются два совершенно различных назначения такого текста программы. С одной стороны, текст программы предназначен для машинной интерпретации, если мы хотим, чтобы она могла выполняться автоматически, если мы хотим, чтобы по ней для нас был произведен какой-либо конкретный расчет. С другой стороны, желательно, чтобы текст программы говорил нам о том, как строить соответствующий преобразователь предикатов, как производить преобразование предикатов, чтобы выводить предусловие wp(S, 7?) для любого данного постусловия R, которое нас почему-либо заинтересовало. Это замечание позволяет понять, что подразумевается под «хорошо определенным языком программиро-
ваипя» с нашей точки зрения. Когда семантика конкретной конструкции (или программы) задается ее преобразователем предикатов, мы рассматриваем семантическую характеристику языка программирования как набор правил, которые позволяют любой программе, написанной на этом языке, поставить в соответствие преобразователь предикатов. С такой точки зрения мы можем рассматривать программу как «код» для соответствующего преобразователя предикатов.
При желании можно подойти к проблеме проектирования языка программирования с такой позиции. Можно руководствоваться (довольно формально) тем, что правила построения преобразователей предикатов должны быть такими, чтобы, применяя их, нельзя было построить ничего другого, кроме как преобразователя предикатов, обладающего свойствами 1—4 из предыдущей главы. В самом деле, если правила не дают такой гарантии, то это означает, что вы деформируете предикаты таким образом, что они уже не могут интерпретироваться как постусловия и соответствующие слабейшие предусловия.
Сразу напрашиваются два примера весьма простых преобразователей предикатов, которые обладают требуемыми свойствами.
Начнем с тождественного преобразования, т. е. с конструкции S, такой, что для любого постусловия R мы имеем wp(S, R^=R. Эту конструкцию знают и любят все программисты: она известна им под названием «пустой оператор», и в своих программах они часто используют ее, оставляя пропуск в том месте текста, где синтаксически требуется какой- то оператор. Это не слишком похвальный прием (компилятор должен считать, что он «видит» этот оператор, на том основании, что он ничего не видит, и поэтому мы дадим этой конструкции наименование, скажем, «пропустить'». Итак, семантика оператора «пропустить» определяется следующим образом:
wp (пропустить, R) —R для любого постусловия R
(Как и все, я буду пользоваться термином «оператор», потому что он прочно вошел в жаргон. Когда люди сообразили, что «команда» могла бы оказаться более подходящим термином, было уже слишком поздно б.)
° По традиции мы переводим английский термин «statement» (утверждение, предложение) термином «оператор», введенным в программирование А. А. Ляпуновым, и таким образом, русский читатель оказывается в более выгодном положении, чем английский. — Прим. ред. ' 4 *
Замечание. Тем, кто считает расточительством символов введение явного имени «пропустить» для пустого оператора, когда «Пусто» столь красноречиво выражает его семантику, следует осознать, что десятичная система счисления оказалась возможной только благодаря введению символа «О» для понятия «ничто». {Конец замечания.)
Прежде чем продолжить наши рассуждения, мне хотелось бы не упустить возможность отметить, что тем временем мы уже определили некий язык программирования. Остается добавить только одно: это однооператорный язык, в котором можно описать только одну конструкцию, причем единственное, что способна сделать для нас данная конструкция, это «оставить все, как есть» (или «ничего не делать», но такое негативное употребление языка представляет опасность, см. следующий абзац).
Другой простой преобразователь предикатов приводит к постоянному слабейшему предусловию, которое вовсе не зависит от постусловия R. Мы имеем два предиката-константы, Т и F. Конструкция S, для которой wp(S, R) = T при всех R, не может существовать, потому что она нарушила бы закон исключенного чуда. Однако конструкция S, для которой wp(S, R)=F при всех R, обладает преобразователем предикатов, удовлетворяющим всем необходимым свойствам. Этому оператору мы тоже присвоим имя, назовем его «отказать». Итак, семантика оператора «отказать» задается следующим образом:
wp {отказать, R) =F для любого постусловия R
Этот оператор не может даже «ничего не делать» в смысле «оставить все, как есть»; он вообще ни на что не способен. Если мы полагаем R = T, т. е. не накладываем на конечное состояние никаких дополнительных требований, кроме самого факта его существования, даже тогда не найдется соответствующего начального состояния. Если запустить конструкцию по имени «отказать», она не сможет достичь конечного состояния: сама попытка ее запуска является гарантией неудачи. (Нас не должно занимать теперь (а также и впоследствии) то, что позднее мы введем структуру операторов, в которой содержатся как частные случаи семантические эквиваленты для «пропустить» и «отказать».)
Теперь мы располагаем неким (все еще весьма зачаточным) двухоператорным языком программирования, в котором можем описать две конструкции; одна из них ничего не делает, а вторая всегда терпит неудачу. Со времени опубликования знаменитого «Сообщения об алгоритмическом языке АЛГОЛ 60»
в 1960 г. никакой уважающий себя ученый, занимающийся программированием, не позволит себе обойтись на этом этапе без формального определения синтаксиса столь далеко продвинутого языка программирования в системе обозначений, называемой «НФБ» (сокращение от «Нормальная форма Бэкуса»), а именно:
<о перат о р>\\ = пропустить | отказать
(Читается так: «Элемент синтаксической категории, именуемой «оператор» (именно это обозначают забавные скобки «<» и «>»), определяется как (это обозначает знак «::=») «пропустить» или (это обозначает вертикальная черта «|») «отказать». Колоссально! Но не беспокойтесь; более впечатляющие применения НФБ в качестве способа записи последуют в надлежащее время.)
Один класс безусловно более интересных преобразователей предикатов основывается на подстановке, т. е. на замене всех вхождений некоей переменной в формальном выражении для постусловия R на (одно и то же) «что-нибудь другое». Если в предикате R все вхождения переменной х заменяются некоторым выражением (£), то мы обозначаем результат этого преобразования через Re^x- Теперь мы можем рассмотреть для заданных х и Е такую конструкцию, чтобы для любых постусловий R получалось wp(S, /?) =Re-*x; здесь х — «координатная переменная» нашего пространства состояний, а Е — выражение соответствующего типа.
Замечание. Такое преобразование путем подстановки удовлетворяет свойствам 1—4 из предыдущей главы. Мы не станем пытаться демонстрировать это и предоставим самому читателю решать в зависимости от своего вкуса, будет ли он относиться к этому как к тривиальному или же как к глубокому математическому результату. {Конец замечания.)
Таким способом вводится целый класс преобразователей предикатов, целый класс конструкций. Они обозначаются оператором, который называется «оператор присваивания»; такой оператор должен определять три вещи:
наименование переменной, подлежащей замене;
тот факт, что правило, соответствующее преобразованию предикатов, есть подстановка;
выражение, которым должно заменяться всякое вхождение этой переменной в постусловии.
Если переменная х должна быть заменена выражением (£), то обычная запись такого оператора выглядит следующим
образом:
х : = Е
(где таЧ называемый знак присваивания «: = » следует читать как «становится»).
Сказанное можно суммировать, определив
wp(«x : —£», J?) =Re^x для любого постусловия R
При желании это можно рассматривать как семантическое определение оператора присваивания для любой координатной переменной х и любого выражения Е соответствующего типа.
Наслаждаясь, по нашему обыкновению, употреблением НФБ, мы можем расширить наш формальный синтаксис, чтобы он читался так:
<оператор> :: = пропустить|отказать|
<оператор присваивания>
<оператор присваивания> :: = <переменная> : = <выражение>
причем последнюю строчку следует читать так: «Элемент синтаксической категории, именуемой «оператор присваивания», определяется как элемент синтаксической категории, именуемой «переменная», за которым следуют сначала знак присваивания «: = », а затем элемент синтаксической категории, именуемой «выражение».
Перед тем как продолжить рассуждение, представляется разумным убедиться в том, что этим формальным описанием семантики оператора присваивания в самом деле охватывается наше интуитивное понимание оператора присваивания — если оно у нас есть! Рассмотрим пространство состояний с двумя целыми координатными переменными «а» и «Ь». Тогда
wp(«a: = 7», а = 7) = {7 = 7}
и, поскольку логическое выражение в правой части является истиной для всех значений а и Ь, т. е. для всех точек пространства состояний, мы можем упрощенно записать это так:
wp(«a: = 7», а = 7) = Т
Иначе говоря, всякое начальное состояние гарантирует, что присваивание «а: = 7» обеспечит истинность «а=7». Аналогично
wp («а: =7», а = 6) = {7 = 6}
и, поскольку это логическое выражение является ложью для всех значений а и Ь, мы получаем
wp(«a: = 7», а = 6)
Это означает, что не существует никакого начального состояния, для которого мы могли бы гарантировать, что присваивание «а: = 7» обеспечит истинность «а = 6». (Это находится в соответствии с нашим предыдущим результатом, что все начальные состояния обеспечат конечную истинность «а = 7», а ■следовательно, конечную ложность «а^7».) Далее,
wp(«a: = 7», Ь = ЬО) = {6 = 60}
т. е. если мы хотим гарантировать, что после присваивания «а: =7» переменная 6 имеет некоторое значение 60, то нужно, чтобы 6 имела это значение еще в начальном состоянии. Другими словами, все переменные, за исключением «а», не изменяются, они сохраняют те значения, которые имели раньше; присваивание «а: = 7» перемещает в пространстве состояний точку, соответствующую текущему состоянию системы, параллельно оси а так, что обеспечивается конечное выполнение равенства «а = 7».
Вместо того чтобы брать в качестве выражения Е константу, мы могли бы взять какую-то функцию от начального состояния. Это иллюстрируется следующими примерами:
wp(«a: = 2*6+l», й= 13) = {2*6 +1 = 13} = {6=6}
wp («я: = я + 1», a> 10) = {^ + 1 > 10} = {^>9}
wp^«a: = a—6», a>b) = {a—6>6} = {^>2* 6}
Возникает небольшое осложнение, если мы разрешаем выражению Е быть частично определенной функцией начального состояния, т. е. такой функцией, попытка вычисления которой для начального состояния, не входящего в область определения, не приведет к правильно завершаемой работе. Если мы хотим предусмотреть и эту ситуацию, то нам нужно уточнить наше определение семантики оператора присваивания и записать
wp(«x: = E», Е) = ф(Е) cand RE-^
Здесь предикат D{E) означает «в области определения Е»; логическое выражение «51 cand 52» (так называемая «условная конъюнкция») имеет то же значение, что и «51 and 62», если оба операнда определены, но помимо этого по определению оно имеет значение «ложь», если 61 является «ложью»; последнее справедливо вне зависимости от того, определен ли операнд 62. Обычно условие D(E) не подчеркивается явно либо потому, что оно = 7, либо потому, что мы исходим из предположения, что оператор присваивания никогда не будет запущен в начальных состояниях, не принадлежащих области определения выражения Е.
\
V \
Естественным обобщением оператора присваивания является излюбленное некоторыми программистами так называемое «одновременное присваивание». В этом случае возможна одновременная замена для нескольких различных переменных. Оператор одновременного присваивания обозначается списком различных переменных), подлежащих замене (эти переменные разделяются запятыми), слева от знака присваивания и столь же протяженным списком выражений (также разделяемых запятыми) справа от знака присваивания. Итак, разрешается писать
xl, х2: = £1, £2
xl, х2, хЗ: = £1, £2, £3
Заметим, что f-я переменная из списка левой части должна быть заменена на 1-е выражение из списка правой части, так. что, например, при заданных xl, х2, £1 и £2 оператор ;xl, х2: =£1, £2
семантически эквивалентен оператору
х2, xl: =£2, £1,
Одновременное присваивание позволяет нам предписать, чтобы две переменные х и у обменялись своими значениями с помощью оператора
х, у.—у^х
В иной записи эта операция выглядела бы более громоздкой. Легкость реализации одновременного присваивания и возможность с его помощью избегать некоторой избыточности записи являются причинами популярности таких операторов. Заметим, что если списки становятся слишком длинными, то получаемая программа становится весьма неудобочитаемой.
Истинный любитель НФБ расширит свой синтаксис, обеспечив две различные формы для оператора присваивания, следующим образом:
• <оператор присваивания>:: = <переменная>: =
.<выражение>| <пере
менная>, <оператор присваивания >, <выражение>
Это так называемое «рекурсивное определение», поскольку одна из альтернативных форм для синтаксической единицы, именуемой «оператор присваивания» (а именно вторая форма), содержит в качестве одного из своих компонентов снова
эту же синтаксическую единицу, именуемую «оператор присваивания», т. е. ту самую синтаксическую единицу, которую мы определяем! На первый взгляд такое циклическое определение выглядит ужасающе, но после более внимательного изучения мы можем убедиться в том, что, по крайней мере с синтаксической точки зрения, в этом нет ничего неправильного. Например, поскольку, согласно первой альтернативе,
х2: = Е1
является примером оператора присваивания, то формула
xl, х2: = Е1, Е2
допускает разбор вида
xl, <оператор присваивания>, Е2
а следовательно, согласно второй альтернативе, также является оператором присваивания. Однако с семантической точки зрения это отвратительно, потому что получается, что Е2 ассоциируется с xl вместо того, чтобы ассоциироваться с х2.
В сравнении с двухоператорным языком, содержащим только «пропустить» и «отказать», наш язык с оператором присваивания выглядит значительно более богатым: уже нет какой бы то ни было верхней границы для числа различных примеров синтаксической единицы «оператор присваивания». Но он все же явно недостаточен для наших целей; нам нужна возможность строить более изощренные программы, более сложные конструкции. Для построения потенциально сложных конструкций мы пользуемся схемой, которую можно рекурсивно описать так:
<конструкция>:: = <простая конструкция> |
<правильная композиция записей вида: <конструкция>>
Для того чтобы эта схема могла принести хоть какую-нибудь пользу, необходимо выполнение двух условий: в нашем распоряжении должны иметься «простые конструкции», чтобы было с чего начать, и, кроме того, мы должны знать, как строить «правильные композиции». Введенные раньше операторы можно взять в качестве простых конструкций; оставшаяся часть этой главы посвящена именно процессу правильной композиции некоей новой конструкции из заданных конструкций. Новая конструкция в свою очередь может выступать в роли части еще более сложного составного объекта.
После того как объект был образован композицией частей, мы можем рассматривать его двумя способами. Во-пер-
вых, мы можем считать его «нераздельным целым», воспринимая его свойства в большей или меньшей степени как магические (или принимая их на веру или как постулаты). При таком подходе существенны только свойства конструкции; не имеет никакого значения, каким образом она образована из своих частей. С такой точки зрения любые две конструкции, обладающие одинаковыми свойствами, эквивалентны. Или же мы рассматриваем конструкцию как «составной объект», образованный так, что мы можем понять, почему она обладает объявленными свойствами. При этом мы воспринимаем части как «малые» нераздельные целые, для которых принимаются в расчет только их общие свойства. Второй подход вносит ясность в то, что мы понимаем под «композицией». Композиция должна определять, как свойства целого следуют из свойств частей.
После этих общих замечаний вернемся к нашим конкретным конструкциям, свойства которых, как мы считаем, выражаются их преобразователями предикатов. Точнее говоря, если заданы две конструкции 51 и 52, чьи преобразователи предикатов известны, можем ли мы представить себе правило вывода нового преобразователя предикатов из двух заданных? Если да, то мы можем считать результирующий преобразователь предикатов описанием свойств составного объекта, построенного специальным образом из частей 51 и 52.
Одним из простейших способов получения новой функции из двух заданных является так называемая «функциональная композиция», т. е. предоставление значения одной функции Ъ качестве аргумента для другой. Составной объект, соответствующий такому преобразователю предикатов, принято обозначать через «51; 52», и мы определяем
wp(«51; 52», R) = wp(51, wp(52, R))
что при желании можно рассматривать как семантическое определение точки с запятой.
Замечание. Из того факта, что преобразователи предикатов для 51 и 52 обладают свойствами 1—4 из предыдущей главы, можно заключить, что и определенный выше преобразователь предикатов для «51; 52» также обладает этими четырьмя свойствами. Например, поскольку для 51 и 52 справедлив закон исключенного чуда:
wp(Sl, F^-F и wp(52, F)=F
мы заключаем, подставляя F вместо R в верхнее определение, что
wp («51; 52», F) = wp (51, wp (52, F))
= wp(Sl, F)= F
Читателю предоставляется в качестве упражнения проверить, что остальные три свойства тоже сохраняются. ^Конец замечания.)
Перед тем как продолжить наши рассуждения, убедимся в том, что этим формальным описанием семантики точки с запятой охватывается наше интуитивное представление о ней (если оно у нас есть!), т. е. что составная конструкция «51; 52» может быть реализована по правилу «сначала запустить конструкцию 51 и по окончании ее работы запустить 52». В самом деле, в нашем определении wp(«Sl; 52», /?) мы представляем R— постусловие для составной конструкции — как постусловие к преобразователю предикатов для 52, и этим отражается то, что общая работа конструкции «51; 52» может закончиться с окончанием работы 52. Соответствующее слабейшее предусловие для 52, т. е. wp(S2, R), представляется как постусловие к преобразователю предикатов для 51; тем самым мы явно отождествляем начальное состояние для 52 с конечным состоянием для 51. Однако именно так и бывает, если работа 51 непосредственно предшествует во времени за: пуску 52.
Чтобы удостовериться в этом, рассмотрим пример. Пусть «51; 52» представляет собой
«а\ = а-уЬ\ Ь: = а*Ь» и пусть нашим постусловием является некоторый предикат R(a, b); в таком случае
wp(S2, R(a, b)) = wp(«b: = a *6», R(a, b)) = R(a, a*b) и
wp(«Sl; 52», R(at b)) =wp(51, wp(52, R(a, b)))
= wp (51, R(a, a*b))
= wp(«n: = n + Z?», R(a, a * b)) —R^a + b, (a + b)*b)
т. e. мы можем гарантировать выполнение отношения R между конечными значениями а и b при условии, что первоначально то же отношение выполняется между аА-Ь и (п + &)* b соответственно.
И наконец, поскольку функциональная композиция обладает свойством ассоциативности, то не имеет значения, будем ли мы разбирать «SI; 52; S3» как «[51; 52]; S3» или же как «51; [52; S3]». Иначе говоря, мы имеем полное право трактовать точку с запятой как символ сочленения; поэтому не возникает никакой неопределенности, когда мы выписываем операторный список вида «51; 52; S3; ...; 5П», и мы будем без стеснения поступать так, когда для этого представятся подходящие случаи.
Упражнение
Проверьте, что конструкции
«%1: = £1; %2:=£2» и «%2:=£2; х1:=£1»
семантически эквивалентны, если переменная %1 не встречается в выражении £2, а переменная %2 не встречается в выражении £1. На самом деле, в таком случае обе эти конструкции семантически эквивалентны одновременному присваиванию «х1, %2:=£1, £2». (Эта эквивалентность является одним из аргументов в пользу одновременного присваивания; ее применение позволяет нам избежать избыточности последовательной записи, более того, при одновременном присваивании становится очевидным, что два выражения £1 и £2 могут вычисляться одновременно, и это последнее обстоятельство представляет интерес при некоторых методиках реализации. Помимо того, имеется, быть может, более интересная возможность, что конструкция «%1, %2: = £1, £2» не окажется семантически эквивалентной ни конструкции «х1:=£1; %2: = £2», ни конструкции «х2: = £2; %/:=£!».) (Конец упражнения.)
До введения точки с запятой мы могли писать только однооператорные программы; с помощью точки с запятой мы обрели способность писать программы в виде сочленения из п (п>0^ операторов: «51; 52; S3; ...; Sn». Если исключить промежуточную незавершенность, выполнение каждой программы всегда означает временную последовательность выполнений п операторов, сначала S1, потом S2 и так далее до Sn включительно. Однако из нашего примера игры на листе картона, реализующей алгоритм Евклида, мы знаем, что должны уметь описывать более широкий класс «правил игры»: всякая игра будет состоять из последовательности перемещений, где каждое перемещение имеет вид либо «х:=х—у», либо «у'=у—х», но способ чередования этих перемещений во времени и даже их общее число будут изменяться от игры к игре; он зависит от начального положения фишки, он зависит от начального состояния системы. Если точка с запятой является единственным
нашим средством для составления нового целого из заданных частей, то мы не в состоянии этого выразить, и поэтому нам необходимо искать нечто новое.
Пока точка с запятой остается единственным имеющимся ь нашем распоряжении средством соединения, единственных! обстоятельством, при котором происходит запуск одной из составляющих конструкций Si (i>l), является правильное завершение работы (лексикографически) предшествующей конструкции. Чтобы добиться нужной нам гибкости, необходимо обеспечить возможность запуска той или иной (под) конструкции в зависимости от текущего состояния системы. Для этого мы введем — в два этапа — понятие «охраняемой команды», синтаксис которой задается следующим образом:
Сохраняющий заголовок>:: = <логическое выражение>->
<оператор>
Сохраняемая команда>:: = <охраняющий заголовок> {;
<оператор>}
где фигурные скобки «{» и «}» следует читать так: «сопровождается любым числом (быть может, нулем) экземпляров содержимого скобок».
(Другой вариант синтаксиса охраняемой команды moi бы выглядеть так:
Ссписок операторов>::= Соператор> {;Соператор>} Сохраняемая команда>:: = Слогическое выражение>->
Ссписок операторов>
Однако по причинам, которые нс должны нас теперь интересовать, я предпочитаю синтаксис, в котором вводится понятие охраняющего заголовка).
В этом сочетании логическое выражение, предшествующее стрелке, называется «предохранителем». Идея состоит в том, что следующий за стрелкой список операторов будет выполняться лишь при том условии, что обеспечена начальная истинность значения соответствующего предохранителя. Предохранитель позволяет нам избежать выполнения списка операторов при тех первоначальных обстоятельствах, когда это выполнение оказалось бы нежелательным или (если употребляются частично определенные операции) невозможным.
Истинность значения предохранителя является необходимым предварительным условием для выполнения охраняемой команды как целого; это условие, разумеется, не является достаточным, поскольку тем или иным способом — с двумя такими способами мы встретимся — до него еще должна дойти потенциальная «очередь». Именно поэтому охраняемая команда не рассматривается как оператор: оператор безогово-
рочно выполняется, когда наступает его очередь, а охраняемая команда может использоваться в качестве строительного блока для оператора. Если говорить точнее, мы предложим два различных способа составления оператора из набора охра няемых команд.
После некоторого осмысливания рассмотрение набора охраняемых команд представляется вполне естественным. Предположим, что нам требуется построить конструкцию, такую, чтобы при условии, что начальное состояние удовлетворяет Q, конечное состояние удовлетворяло R. Предположим далее, что нам не удается найти единый список операторов, который выполнял бы эту работу в любых случаях. (Если бы такой список операторов существовал, то мы именно его бы и использовали и не возникло бы потребности в охраняемых командах.) Однако нам может удасться найти несколько списков операторов, каждый из которых будет выполнять нужную работу для некоего подмножества возможных начальных состояний. К каждому из этих списков операторов мы можем присоединить в качестве предохранителя логическое выражение, характеризующее то подмножество, для которого этот список подходит, и если у нас имеется вполне достаточный набор предохранителей, такой, что из истинности Q всегда логически следует истинность значения хотя бы одного предохранителя, то для каждого начального состояния, удовлетворяющего Q, мы располагаем конструкцией, которая переведет систему в состояние, удовлетворяющее условию R, причем этой конструкцией служит одна из охраняемых команд, чей предохранитель имел начальное значение «истина».
Чтобы выразить это, определим сначала
<набор охраняемых команд>:: = <охраняемая команда> {□ <охраняемая команда>}
где символ «О» выступает в роли разделителя вариантов, в остальном не упорядоченных. Один из способов формирования оператора из набора охраняемых команд состоит в том, чтобы заключить такой набор в пару скобок «if ... fi», при этом наш синтаксис для синтаксической категории, именуемой «оператор», пополняется следующей формой:
<onepaTop>:: = if<Ha6op охраняемых команд> fi
Таким образом, мы получаем возможность объединить несколько охраняемых команд в новую конструкцию. Мы можем представить себе работу, которая произойдет после запуска этой конструкции, следующим образом. Выбирается одна из
охраняемых команд, чей предохранитель является истиной, и запускается ее список операторов.
Прежде чем мы перейдем к изложению формального описания семантики нашей новой конструкции, уместно сделать три замечания.
Предполагается, что все предохранители определены; если это не так, т. е. вычисление какого-то предохранителя может привести к работе без правильного завершения, то допускается, что и вся конструкция не сможет правильно завершить свою работу.
Вообще говоря, наша конструкция приведет к недетерминированности при тех начальных состояниях, для которых истинны значения более чем одного предохранителя, поскольку остается неопределенным, какой из соответствующих списков операторов будет тогда выбираться для запуска. Никитой недетерминированности не возникает, если все предохранители попарно исключают друг друга.
Если начальное состояние таково, что ни один из предохранителей не является истиной, то мы встречаемся с начальным состоянием, для которого не подходит ни один из зарн- антов, а следовательно, и вся конструкция в целом. Запуск при таком начальном состоянии приведет к отказу.
Замечание. Если мы допускаем также и пустой набор охраняемых команд, то оператор «if fi» семантически эквивалентен нашему прежнему оператору «отказать». {Конец замечания.)
(В следующем формальном определении слабейшего предусловия для конструкции if-fi мы ограничимся случаем, когда все предохранители являются полностью определенными функциями. Если это не так, то нужно переписать выражение с использованием символа cand при дополнительном требовании, чтобы начальное состояние принадлежало области определения всех предохранителей.)
Обозначим через «IF» оператор
if В^ЗЦ П^^Тг □ О Bn-^SLn fi
Тогда для любого постусловия R
wp(IF, R) = (Е/: 1^/^и:В,) and
(А / : 1^/^« : В, =^ wp(SL;, /?))
Эту формулу следует читать так: wp(IF, R') является истиной для каждой такой точки в пространстве состояний, где
в SL — аббревиатура для Statement List — список операторов.— Прим, перев.
существует хотя бы одно значение / из отрезка X^i^n, такое, что Bj является истиной, и где, кроме того, для всех / из отрезка l^j^CiC таких, что Bj — истина, значение wp(SL,, /?) также является истиной. Используя символ «...», как мы уже делали при описании самого оператора IF, мы можем предложить иную форму определения:
wp (IF, 7?) = (Bi or B2 or or Bn) and
(В} =ф wp(SLb R)) and (B2^>wp(SB2, B)) and ... and (Bn wp (SLn, /?))
Понимание этих формул не представляет особого труда. Требованием, чтобы значение хотя бы одного предохранителя являлось истиной, отражается факт отказа в случае, когда значения для всех предохранителей ложны. Кроме того, мы требуем для каждого начального состояния, удовлетворяющего wp(IF, В), чтобы выполнялось Bj=^wp(SLj, В) для всех /. Для тех значений /, при которых Bj — ложь, это следование истинно независимо от значения wp(SLj, R), т. е. для таких значений /, разумеется, безразлично, что будет делать SLj. При нашей реализации это учитывается в том, что для запуска не берется SLj с ложным начальным значением предохранителя Bj. При тех значениях /, для которых Bj — истина, данное следование может быть истиной только в том случае, когда wp (SL;, R) также является истиной. Поскольку наше формальное определение требует истинности следования при всех значениях /, то реализация на самом деле имеет свободу выбора, когда более чем один предохранитель является истиной.
Конструкция if-fi представляет собой только один из двух возможных способов построения оператора из набора охраняемых команд. В конструкции if-fi состояние, при котором все предохранители имеют ложные значения, ведет к отказу. В нашей второй форме мы разрешаем, чтобы состояние, при котором нет ни одного предохранителя с истинным значением, приводило к правильному завершению; поскольку в этой ситуации не запускается никакой список операторов, вполне естественно, что при этом возникает семантическая эквивалентность с пустым оператором. Однако это разрешение на правильное завершение, когда нет ни одного предохранителя с истинным значением, дополняется тем, что работе не разрешается завершаться, пока хотя бы одни предохранитель является истиной. Итак, после запуска проверяются все предохранители. Если нет ни одного истинного значения предохранителя, то работа завершается; если же имеются предохранители с истинными значениями, то один из соответствующих списков
операторов запускается, а после его завершения реализация снова начинает общую проверку всех предохранителей. Эта вторая конструкция обозначается путем заключения списка охраняемых команд в пару скобок «do ... od».
Формальное определение слабейшего предусловия для конструкции do-od является более сложным, чем для конструкции if-fi; дело в том, что первое выражается через второе. Мы сначала приведем это формальное определение, а затем объясним его. Обозначим через «DO» оператор
do BX-^SLX □ B2-+SUH ...□ B^SU od
и через «IF» оператор, получаемый путем заключения того же набора охраняемых команд в пару скобок «if ... fi». Если условия Нь(Н) задаются в виде
H0(R") = R and non (E j : 1 ^/^/г: B?)
и при &>0
Hk(R) = wp(IF, Hh-x^R)) or H0(R)
TO
wp(DO, /?) = (Ek:k>0:H4R))
Здесь интуитивно мы понимаем Hh(R) следующим образом: это слабейшее предусловие, такое, что конструкция do-od завершит свою работу после не более чем к выборок охраняемых команд, причем система останется в конечном состоянии, удовлетворяющем постусловию R.
При ^ = 0 требуется, чтобы конструкция do-od завершила работу, не производя выборки какой-либо охраняемой команды, т. е. не допускается существования какого-либо предохранителя с истинным значением, что и выражено вторым логическим сомножителем. При этом начальная истинность R очевидным образом является необходимым условием для конечной истинности R, что и выражено первым логическим сомножителем.
При ^>0 нам нужно различать два случая: либо ни один из предохранителей не имеет истинного значения, но тогда должно выполняться условие R, и это приводит нас ко второму логическому слагаемому; либо хотя бы один предохранитель является истиной, и тогда события развиваются так, как при однократном запуске оператора «IF» (в начальном состоянии, которое не может привести к немедленному отказу вследствие отсутствия истинных значений предохранителей). Однако после такого выполнения, при котором производилась выборка одной охраняемой команды, нам необходима гарантия попадания в состояние, такое, чтобы потребовалось не более
чем (6—1) дальнейших выборок для достижения завершения работы в конечном состоянии, удовлетворяющем R. В соответствии с нашим определением, таким постусловием для этого оператора «IF» служит Hk-i(R).
Согласно последней строке, определяющей wp(DO, R), должно существовать такое значение к, чтобы потребовалось не более чем к выборок для обеспечения завершения работы в конечном состоянии, удовлетворяющем постусловию R.
Замечание. Если мы допускаем также и пустой набор охраняемых команд, то в силу сказанного оператор «do od» семантически эквивалентен нашему прежнему оператору «пропустить». (Конец замечания.)
5 ДВЕ ТЕОРЕМЫ
В этой главе мы выводим две теоремы об операторах, которые строятся из наборов охраняемых команд. Первая теорема касается конструкции выбора if-fi, а вторая—конструкции повторения do-od. В этой главе мы будем рассматривать Конструкции, выводимые из набора охраняемых команд
B\-^SL\ L B^SL^ Б ... □ Bn-^SLn
Будем обозначать через «IF» и «DO» операторы, получаемые заключением этого набора охраняемых команд в пары скобок «if ... fi» и «do ... od» соответственно. Мы будем также использовать сокращение
SS=(E / : \^i^n\B^
Теорема. (Основная теорема для конструкции выбора)
Используя введенные только что обозначения, мы можем сформулировать основную теорему для конструкции выбора:
Пусть конструкция выбора IF и пара предикатов Q и R таковы, что
Q=^BB(1)
и
(A j: 1^/^/г: (Q and В^ wp(SLj, R^ (2) одновременно справедливы для всех состояний. Тогда
Q ^wp(IF, 7?)(3)
справедливо также для всех состояний.
Поскольку, в силу определения,
wp(IF, 7?) = ВВ and (А / : 1^/^/z: Bj^wp(SLj, 7?))
и, согласно (1), из Q логически следует первый член правой части, то отношение (3) доказывается, если на основании (2) мы можем сделать вывод, что
Q (А /: 1 ^/Х" : В^ wp №, R))(4)
справедливо для всех состояний. Для любого состояния, при
котором Q является ложью, отношение (4) истинно в силу определения логического следования. Для любого состояния, при котором Q является истиной, и для любого значения / мы можем различать два случая: либо Sj является ложью, но- тогда Bj==>wp(SLj, 7?) является истиной в силу определения следования, либо В^ является истиной, но тогда, согласно (2), w p(SLj, R) является истиной, а следовательно, B^wp (SLj, R) тоже является истиной. В результате мы доказали отношение* (4), а следовательно, и (3).
Замечание. В частном случае бинарного выбора (п = 2) и при S2 = non Вх мы имеем ВВ=Т, и слабейшее предусловие преобразуется так:
(Вх =^> wp (SLb R)) and (non Вх=^ wp (SL^ R)) =
(non Bx or wp (SBh 7?)) and (Bx or wp (SL^, R)) =
(Bx and wp (SLb 7?)) or (non Bx and wp (SB2, 7?))(5)
Последнее преобразование возможно потому, что из четырех перекрестных произведений член Вх and non BX = F может быть отброшен, а член wp(SLh В) and wp(SL2, 7?) тоже может быть отброшен по следующей причине: в любом состоянии, где он истинен, обязательно является истиной какой-то один из двух членов формулы (5), и поэтому его самого можно исключить из дизъюнкции. Формула (5) имеет прямое отношение к предложенному Хоаром способу описания семантики конструкции if-then-else из языка АЛГОЛ 60. Поскольку здесь ВВ = Т логически следует из чего угодно, мы можем вывести (3) при более слабом предположении:
((QandBI)=»wp(SB1,7?)) and ((Qand non Д) =>wp(SB2, R))
(Конец замечания.)
Теорема для конструкции выбора представляет особую важность в случае, когда пара предикатов Q и R может быть записана в виде
R = P
Q = P and ВВ
В этом случае предпосылка (1) выполняется автоматически,, а поскольку (ВВ and Bj) = Bj, предпосылка (2) сводится к виду
(А /: K/^л: (Р and Bj)^\xp(SLb Р))(6)
из чего мы можем вывести, согласно (3),
(Р and ВВ) =>wp(IF, Р) для всех состояний (7)
Это отношение послужит предпосылкой для нашей следующей теоремы.
Теорема. (Основная теорема для конструкции повторения) Пусть набор охраняемых команд с построенной для пего конструкцией выбора IF и предикат Р таковы, что
(Р and 55) ^wp(IF, Р)(7)
справедливо для всех состояний. Тогда для соответствующей конструкции повторения DO можно вывести, что
(Р and wp(DO, Т)) => wp(DO, Р and non 55)(8)
для всех состояний.
Эту теорему, которая известна также под названием «основная теорема инвариантности для циклов», на интуитивном уровне не трудно понять. Предпосылка (7) говорит нам, что если предикат Р первоначально истинен и одна из охраняемых команд выбирается для выполнения, то после ее выполнения Р сохранит свою истинность. Иначе говоря, предохранители гарантируют, что выполнение соответствующих списков операторов не нарушит истинности Р, если начальное значение Р было истинным. Следовательно, вне зависимости от того, как часто производится выборка охраняемой команды из имеющегося набора, предикат Р будет справедлив при любой новой проверке предохранителей. После завершения всей конструкции повторения, когда ни один из предохранителей не является истиной, мы тем самым закончим работу в конечном состоянии, удовлетворяющем Р and non ВВ. Вопрос в том, завершится ли работа правильно. Да, если условие wp(DO, Т) справедливо и вначале; поскольку любое состояние удовлетворяет Т, то wp(DO, Т) по определению является слабейшим предусловием для начального состояния, такого, что запуск оператора DO приведет к правильно завершаемой работе.
Формальное доказательство основной теоремы для конструкции повторения основывается на формальном описании семантики этой конструкции (см. предыдущую главу), из которого мы выводим
Н0(Т) =non ВВ(9)
при й>0:Hh(T) =wp(\F, Hk-x(T)) or поп В В(10)
Но (Р and non ВВ) = Р and non ВВ(11)
при /г>0:Ни (Р and non ВВ) =wp(IF, Нк-\ (Рand
non ВВ)) or Р and non ВВ(12)
Начнем с того, что докажем посредством математической индукции, что предпосылка (7) гарантирует справедливость при^^О:(Р and Нк(Т)) =4Нк(Р and non SS)(13),
для всех состояний.
В силу отношений (9) и (11) отношение (13) справедливо при ^ = 0. Мы покажем, что отношение (13) может быть доказано при & = 7<(7<>О) на основании предположения, что (13) справедливо при к = К—1.
Р and НК(Т)=Р and wp(IF, НК-\(Т)) or Р and non ВВ — Р and ВВ and wp(IF, НК-\(Т)) or Р and non ВВ
=^wp(IF, P) and wp(IF, HK-\(T^ or P and non BB
= wp(IF, P) and HK-\(T^ or P and non BB=>wp(IF, Нк-\ (P and non BB)) or P andnon BB
— HK (P and non BB)
Равенство в первой строке следует из (10), равенство во второй строке следует из того, что всегда wp(IF, R) =^ВВ, логическое следование в третьей строке вытекает из (7), равенство в четвертой строке основывается на свойстве 3 преобразователей предикатов, следование в пятой строке вытекает из свойства 2 преобразователей предикатов и из индуктивного предположения (13) для к = К—1, и последняя строка следует из (12). Итак, мы доказали (13) для к = К> а следовательно, для всех значений й^О.
Наконец, для любой точки в пространстве состояний мы имеем, в силу (13),
Р and wp(DO, Т) - (Е k;k^0:P and НЦТ))
=Ф (Е k : к^О : Н^ {Р and non В В) ) = wp(DO, Р and non ВВ)
и тем самым доказана основная теорема (8) для конструкции повторения. Ценность основной теоремы для конструкции повторения основывается на том, что ни в предпосылке, ни в ее следствии не упоминается фактическое число выборок охраняемой команды. Поэтому она применима даже в тех случаях, когда это число не определяется начальным состоянием.
6 О ПРОЕКТИРОВАНИИ
ПРАВИЛЬНО ЗАВЕРШАЕМЫХ КОНСТРУКЦИЙ
Основная теорема для конструкции повторения применительно к условию Р, сохраняемому инвариантно истинным, утверждает, что
(6 and wp(DO, 7)) => (DO, Р and non 66)
Здесь член wp(DO, 7) представляет собой слабейшее предусловие, такое, что конструкция повторения завершится. Если задана произвольная конструкция DO, то в общем случае очень трудно (а может быть, невозможно) определить wp(DO, 7). Поэтому я предлагаю проектировать наши конструкции повторения, постоянно помня о требовании заверши- мости, т. е. предлагаю искать подходящее доказательство за- вершимости и строить программу таким способом, чтобы она удовлетворяла предположениям, на которых основывается это доказательство.
Предположим опять, что Р— отношение, которое сохраняется инвариантно истинным, т. е.
(6 and 66) => wp(IF, В) для всех состояний (1)
Пусть t— конечная целочисленная функция от текущего состояния, такая,что
(Р and 66) => (/>0) для всех состояний (2) и, кроме того, для любого значения /0 pi для всех состояний
^Р and ВВ and /^/0+1) м> wp(IF, /^/0)(3)
Тогда мы докажем, что
Р =^ wp(DO, 7) для всех состояний(4)
Сопоставив этот факт с основной теоремой для повторения, мы можем заключить, что имеем для всех состояний
Р => wp (DO, Р and non 66)(5)
Мы покажем это, доказав сначала методом математической индукции, что
(Р and Z^fe) => Hk(T) для всех состояний (6) справедливо при всех ^^0. Начнем с обоснования истинности (6) при ^ = 0. Поскольку Н0(Т) =non SS, нам требуется показать, что
(Р and t^O) non ВВ для всех состояний (7)
Однако (7) — это просто другая форма записи выражения (2): оба они равны выражению
non Р or поп ВВ or (/>0)
и поэтому (6) справедливо при й = 0.
Предположим теперь, что (6) справедливо при к = К\ тогда
(Р and ВВ and /^/<+1) => wp(IF, Р and =>wp(IF, ВДу,
(P and non BB and /^^+l) non BB
И эти два логических следования можно объединить (из А=^С и В=^> D мы можем заключить, что справедливо (Л or S) => (С or /))):
(Р and /^^+l) => wp(IF, Як(7)) or HQ(T) = НК^ (7)
и тем самым истинность (6) доказана для всех ^^0. Поскольку t— ограниченная функция, мы имеем
(Е^:й^0:/^^) и
(Е к : /г^О : Р and t^k) (Ек:к^0:Нк(Т^
= wp(DO, 7)
и тем самым доказано (4).
Интуитивно теорема совершенно ясна. С одной стороны, Р останется истиной, а следовательно, /^0 тоже останется истиной; с другой стороны, из отношения (3) следует, что каждая выборка охраняемой команды приведет к эффективному уменьшению t по крайней мерс па 1. Неограниченное количество выборок охраняемых команд уменьшило бы значение t ниже любого предела, что привело бы к противоречию.
Применимость этой теоремы основывается на выполнении условий (2) и (3). Отношение (2) является достаточно простым, отношение (3) выглядит более запутанным. Наша ос-
новная теорема для конструкции повторения при
Q= (В and ВВ and /^/0 + 1)'
В=(^0)
(присутствие свободной переменной /0 в обоих предикатах является причиной того, что мы говорили о «паре предикатов») позволяет нам заключить, что условие (3) справедливо, если
(А/ : 1 ^/^/z: (Р and В3 and /^/0 + 1) =>wp (SL3, /^/0))
Иначе говоря, нам нужно доказать для всякой охраняемой команды, что выборка приведет к эффективному уменьшению L Помня о том, что / является функцией от текущего состояния, мы можем рассмотреть
wp(SL3J^tO^(8)
Это предикат, включающий, помимо координатных переменных пространства состояний, также и свободную переменную /0. До сих пор мы рассматривали такой предикат как предикат, характеризующий некое подмножество состояний. Однако для любого заданного состояния мы можем также рассматривать предикат как условие, налагаемое на /0. Пусть № = tmin представляет собой минимальное решение уравнения (8) относительно /0; тогда мы можем интерпретировать значение tmin как наименьшую верхнюю границу для конечного значения t. Если вспомнить, что, подобно функции /, tmin также является функцией от текущего состояния, то можно интерпретировать предикат
tmin^t—1
как слабейшее предусловие, при котором гарантируется, что выполнение SL3 уменьшит значение t по крайней мере на 1. •Обозначим это предусловие, где — мы повторяем — второй аргумент является целочисленной функцией от текущего состояния, через
wdec(SLj, /)
При этом инвариантность Р и эффективное уменьшение t гарантируются, если мы имеем при всех значениях /
(Р and В^ ==> (wp(SL3, В) and wdec(SLj, /))(9)
Обычно практический способ отыскания подходящего предохранителя В; состоит в следующем. Уравнение (9) относится к типу
(В and Q) ==> R
где (практически вычислимое!) значение Q нужно найти для заданных значений Р и R. Мы замечаем, что
Q = R является решением.
Если Q= (QI and Q2) является решением и Р=^- Q2, то Q1 тоже является решением.
Если Q= (QI or Q2) является решением и Р=^ non Q2 (или, что сводится к тому же самому, (Р and Q2) = F), то Q1 тоже является решением.
Если Q является решением и QI =>Q, то Q1 тоже является решением.
Замечание 1. Если, действуя таким образом, мы приходим к кандидатуре Q для В;, такой, что Р=^поп Q, то эта кандидатура может быть далее упрощена (в соответствии с предыдущим наблюдением 3, поскольку при любом Q мы имеем Q = = (ложь or Q)) к виду 5 = ложь; это означает, что рассматриваемая охраняемая команда введена неудачно; ее можно исключить из набора, потому что она никогда не будет выбираться. (Конец замечания 1.)
Замечание 2. Часто на практике расщепляют уравнение (9) на два уравнения:
(В and Bj) =>wp (SLb P)(9a)
и
(P and Bj) =^>wdec (SLj, t)(96)
и рассматривают их по отдельности. Тем самым разделяются две задачи: (9а) относится к тому, что остается инвариантным, тогда как (96) относится к тому, что обеспечивает продвижение вперед. Если, имея дело с уравнением (9а), мы приходим к решению Bj, такому, что Р =$Bj, то тогда очевидно, что это условие не будет удовлетворять уравнению (96), поскольку при таком Bj инвариантность Р привела бы к недетерминированности. (Конец замечания 2.)
Таким образом, мы можем построить конструкцию DO, такую, что
р=^ wp(DO, Р and non ВВ)
Наши условия Bj должны быть достаточно сильными, чтобы удовлетворялись следования (9); в результате этого новое гарантируемое постусловие Р and non ВВ может оказаться слишком слабым и не обеспечить нам желаемого постусловия R. В таком случае мы все-таки не решили нашу проблему и нам следует рассмотреть другие возможности.
7 ПЕРЕСМОТРЕННЫЙ АЛГОРИТМ ЕВКЛИДА
Рискуя наскучить моим читателям, я посвящу еще одну главу алгоритму Евклида. Полагаю, что к этому времени некоторые из читателей уже закодируют его в виде
х, у: = Х, У;
do x=#y^-if х>у-^-х:=х—у
□ У>х-*у: = у—хfi
od;
печатать(х)
где предохранитель конструкции повторения гарантирует, что конструкция выбора не приведет к отказу. Другие читатели обнаружат, что этот алгоритм можно закодировать более просто следующим образом:
х, у.=Х, Y;
do х>у-^х\^х—у ^y>x-*y; = y—x od;
печатать(х)
Попробуем теперь забыть игру на листе картона и попытаемся изобрести заново алгоритм Евклида для отыскания наибольшего общего делителя двух положительных чисел X и Y. Когда мы сталкиваемся с такого рода проблемой, в принципе всегда возможны два подхода.
Первый состоит в том, чтобы пытаться следовать определению требуемого ответа настолько близко, насколько это возможно. По-видимому, мы могли бы сформировать таблицу делителей числа X; эта таблица содержала бы только конечное число элементов, среди которых имелись бы 1 в качестве наименьшего и X в качестве наибольшего элемента. (Если А=1, то наименьший и наибольший элементы совпадут.) За-
тем мы могли бы сформировать также аналогичную таблицу делителей У. Из этих двух таблиц мы могли бы сформировать таблицу чисел, присутствующих в них обеих. Она представляет собой таблицу обшдх делителей чисел X и Y и обязательно является непустой, так как содержит элемент 1. Следовательно, из этой третьей таблицы мы можем выбрать (поскольку она тоже конечная!) максимальный элемент, и он был бы наибольшим общим делителем.
Иногда близкое следование определению, подобное обрисованному выше, является лучшим из того, что мы можем сделать. Существует, однако, и другой подход, который стоит испробовать, если мы знаем (или можем узнать) свойства функции, подлежащей вычислению. Может оказаться, что мы знаем так много свойств, что они в совокупности определяют эту функцию; тогда мы можем попытаться построить ответ, используя эти свойства.
В случае наибольшего общего делителя мы замечаем, например, что, поскольку делители числа — х те же самые, что и для самого числа х, НОД(х, у) определен также для отрицательных аргументов и не меняется, если мы изменяем знак аргументов. Он определен и тогда, когда один из аргументов равен нулю; такой аргумент обладает бесконечной таблицей делителей (и поэтому нам не следует пытаться строить эту таблицу!), но поскольку второй аргумент (=И=0) обладает конечной таблицей делителей, таблица общих делителей является все же непустой и конечной. Итак, мы приходим к заключению, что НОД (х, у) определен для всякой пары (х, у), такой, что (х, у) #= (0, 0). Далее, в силу симметрии понятия «общий» наибольший общий делитель является симметричной функцией своих двух аргументов. Еще одно небольшое умственное усилие позволит нам убедиться в том, что наибольший общий делитель двух аргументов не изменяется, если мы заменяем один из этих аргументов их суммой или разностью. Объединив все эти результаты, мы можем записать: для (х, z/)^(0, 0)
НОД(х, у) = НОД (у, х).
НОД (х, у)=НОД(-х, у).
НОД(х, у) =НОД(х+у, у) =НОД(х—у, у) и т. д.
НОД(х, у^ — abs(x), если х = у.
Предположим для простоты рассуждений, что этими четырьмя свойствами исчерпываются наши познания о функции НОД. Достаточно ли их? Вы видите, что первые три отношения выражают наибольший общий делитель чисел х и у через НОД для другой пары, а последнее свойство выражает
его непосредственно через х. И в этом уже просматриваются контуры алгоритма, который для начала обеспечивает истинность
Р=(НОД(Х, У)=НОД(х, у))
(это легко достигается путем присваивания «х, у; = Х, У»), после чего мы «утрамбовываем» пару значений (х, у) таким образом, чтобы в соответствии с (а), (б) или (в) отношение Р оставалось инвариантным. Если мы можем организовать этот процесс утрамбовки так, чтобы достигнуть состояния, удовлетворяющего х = у, то, согласно (г), мы находим искомый ответ, взяв абсолютное значение х.
Поскольку наша конечная цель состоит в том, чтобы обеспечить при инвариантности Р истинность х = у, мы могли бы испытать в качестве монотонно убывающей функции функцию t = abs^x—у).
Чтобы упростить наш анализ (всегда похвальная цель!), мы отмечаем, что если начальные значения х и у неотрицательные, то ничего нельзя выиграть введением отрицательного значения: если присваивание х:=Е обеспечило бы х<0, то присваивание х; =—Е никогда не привело бы к получению большего конечного значения t (потому что у^О). Поэтому мы усиливаем наше отношение Р, которое должно сохраняться инвариантным:
Р= (Pl and Р^ при
Р1=(НОД(Х, У)=НОД(х, у)) и
Р2 = (х^О and у^О)
Это означает, что мы отказываемся от всякого использования операций х:=—х и у: =—у, т. е. преобразований, допустимых согласно свойству (б). Нам остаются операции
из (а):х, у: = у, х
из (в):х:=х + у у:=У + х
х\ = х—у у: = у—х х; = у—х у; = х—у
Будем заниматься ими по очереди и начнем с рассмотрения х, у ; = у, х:
wp («х, у; = у, х», abs (х—у) ^/0) = (abs (у—х) ^/0) поэтому
tmin (х, у) = abs (у—%)
следовательно
wdec («х, у; = у, х», abs (х—у)) = (^abs ^у—х)^abs^x—у) — 1) = F
И здесь — для тех, кто не поверил бы без формального вывода,— мы доказали (или, если угодно, обнаружили) с помощью нашего исчисления, что преобразующая операция X, У'-~У, х не представляет интереса, так как она не может привести к желаемому эффективному уменьшению выбранной нами функции t.
Следующей испытывается операция x;=x-Vy, и мы находим, снова применяя исчисление из предыдущих глав: wp(«x:=x + y», abs(x—у) ^/0) = (abs^x) ^/0) tmin(x, у) = abs(x) = х
(мы ограничиваемся состояниями, удовлетворяющими Р)
wdec(«x:=x + y», abs^x—у)) = ^tmin^x, у) ^Цх, у) — 1)
= ^x^abs^x—у)— 1)
= Vx-V\^abs^x—y^
= (х +1 ^х—у or х+ 1 ^у—х)
Поскольку из Р следует отрицание первого члена и к тому же Р =^wp(«x: = х+у», Р), то уравнение нашего предохранителя
(Р and Bj) => (wp (SLj, P) and wdec ^SLj, /))
удовлетворяется последним членом, и мы нашли нашу первую, а также (из соображений симметрии) нашу вторую охраняемую команду:
х + 1 ^у—х^х;=х + у и
у+1<х—у^у: = у + х
Аналогично мы находим (формальные манипуляции предоставляются в качестве упражнения прилежному читателю)
1 ^у and 3 * у^2 * х—1-^х: =х—у и
l^x and 3 * х^2 * у—1^у:=у—х
и
х+ 1 Су—х-^х: = у—х
и
у + 1 ^ х-у-^у: = х—у
Разобравшись в том, чего мы достигли, мы вынуждены прийти к досадному заключению, что способом, намеченным в конце предыдущей главы, нам нс удалось решить свою задачу: из Р and non ВВ не следует, что х = у. (Например, при (х, У) = (5,7) значения всех предохранителей оказываются ложными.) Мораль сказанного, разумеется, в том, что наши шесть шагов не всегда обеспечивают такой путь из начального состояния в конечное состояние, при котором abs^x—у) монотонно уменьшается. Поэтому нам нужно испытать другие возможности.
Для начала заметим, что не повредит несколько усилить условие Р2:
Р2 = (х>0 and у>0)
так как начальные значения х и у удовлетворяют такому условию, и, кроме того, нет никакого смысла в генерации равного нулю значения, поскольку это значение может возникнуть только при вычитании в состоянии, где х = у, т. е. когда уже достигнуто конечное состояние. Но это только малая модификация; основная модификация должна быть связана с введением новой функции /, и я предлагаю взять такую функцию /, которая только ограничена снизу в силу инвариантности Р. Очевидным примером является
t = X-Vy
Мы выясняем, что для одновременного присваивания wdec(«x, у:=у, х», х+у) = Р
и поэтому одновременное присваивание отвергается.
Мы находим для присваивания х : = х+у
wdec(«x: = x + y», х+у) = (у<0)
Истинность этого выражения исключается истинностью инвариантного отношения Р, а следовательно, такое присваивание (наряду с присваиванием у; = у + х) также отвергается.
Однако для следующего присваивания х:^ х—у мы находим
wdec(«x: = x—у», х + у) = (у>0)
т. е. условие, которое логически следует из условия Р (усиленного мною ради этого).
Окрыленные надеждой, мы изучаем
wp(«x: = x—у», Р) = (НОД(Х, У) =
= НОД(х—у, у) and х—у>0 and у>0)
Крайние члены можно отбросить, потому что они следуют из Р, и у нас остается средний член; итак, мы находим
х>у^х:=х—у и
у>х-^у\=у—х
И на этом мы могли бы прекратить исследование, так как, когда значения обоих предохранителей становятся ложными, выполняется желаемое нами отношение х=у. Если мы продолжим, то найдем третий и четвертый варианты:
х>у—х and у>х-^х\ = у— х и
у>х—у and х>у^у.=х~у
но не ясно, что можно выиграть их включением.
Упражнения
Исследуйте при том же Р выбор t = max(x, у).
Исследуйте при том же Р выбор / = х + 2 * у.
Докажите, что при Х>0 и У>0 следующая программа, оперирующая над четырьмя переменными,
х, у, и, У, У, X;
do х>у->-х, v: = x—у, и + и
□ у>х-+у, и*. = у~X, U + U od;
печатать ((х +у) 1'2); печатать ((zz + u)/2)
печатает наибольший общий делитель чисел X и У, а следом за ним их наименьшее общее кратное. (Конец упражнений.)
Наконец, если наш маленький алгоритм запускается при паре (X, У), которая не удовлетворяет предположению Х>0 and У>0, то произойдут неприятности: если (X, У) = (0, 0), то получится неправильный нулевой результат, а если один из аргументов отрицательный, то запуск приведет к бесконечной работе. Этого можно избежать, написав
if Х>0 and У>0^
х, у.=Х, У;
do х>у->х:=х—у Q У>х^у. = у—х od; печатать(х) fi
Включив только один вариант в конструкцию выбора, мы явно выразили условия, при которых ожидается работа этой маленькой программы. В таком виде это хорошо защищенный и довольно самостоятельный фрагмент, обладающий тем приятным свойством, что попытка запуска вне его области определения приведет к немедленному отказу.
8 ФОРМАЛЬНОЕ РАССМОТРЕНИЕ НЕСКОЛЬКИХ НЕБОЛЬШИХ ПРИМЕРОВ
В этой главе я проведу формальное построение нескольких небольших программ для решения простых задач. Не следует понимать эту главу как предложение строить программы только так и не иначе: такое предложение было бы довольно смехотворным. Я полагаю, что многим из моих читателей знакомо большинство примеров, а если нет, они, вероятно, не задумываясь смогут написать любую из этих программ.
Следовательно, построение программ проводится здесь совсем по другим причинам. Во-первых, это ближе познакомит нас с формализмом, развитым в предыдущих главах. Во-вторых, убедит нас в том, что по крайней мере в принципе, формализм способен сделать ясным и совершенно строгим то, что часто объясняется при помощи энергичной жестикуляции. В-третьих, многие из нас настолько хорошо знают эти программы, что уже забыли, каким образом давным-давно мы убедились в их правильности: в этом отношении настоящая глава напоминает начальные уроки планиметрии, которые по традиции посвящаются доказательству очевидного. В-четвертых, мы можем случайно обнаружить, к своему удивлению, что маленькая знакомая задачка не такая уже знакомая. Наконец, процесс построения программ может пролить свет на осуществимость, трудности и возможности автоматического составления программ или использования машины в процессе программирования. Это может оказаться важным, даже если автоматическое составление программ не вызывает у нас ни малейшего интереса, потому что может помочь нам лучше оценить ту роль, которую могут или должны играть наши изобретательские способности.
В моих примерах я буду формулировать требования задачи в форме «для фиксированных х, у, ...», что является сокращенной записью формы «для любых значений хО, уО, ... постусловие вида х = хО and у = уО and ... должно вызывать предусловие, из которого следует, что х = хО and у = уО and ...». Эта связь между постусловием и предусловием будет гарантирована тем, что мы будем относиться к таким величинам как к
«временным константам»; они не будут встречаться в левых частях операторов присваивания.
Первый пример
Для фиксированных хну обеспечить истинность отношения R(m^:
(m = x or т = у) andm^xandm^y
Для любых значений х и у отношение т = х может стать истинным только в результате присваивания т: = х; следовательно, истинность Vm = x or т = у) обеспечивается только выполнением либо т: = х, либо т\ = у. В виде блок-схемы:
Рис. 8.1
Все дело в том, что на входе нужно сделать правильный выбор, который обеспечит истинность /?(т) после завершения вычислений. Для этого мы «проталкиваем постусловие через альтернативы»:
Рис. 8.2
и мы получили предохранители! Так как
R(x^ = ((х—х or x=y)and х^х and x^z/) = (х^у) и
R(y) = ((у = х or у = у> and у^х and у^у> = (у>х)
мы приходим к нашему решению:
if x^y^m\ = x Q у^х-^- m:=y fi
Поскольку (x^y or y^x^T, отказа никогда нс произойдет (попутно мы получили доказательство существования: для любых значений х и у существует т, удовлетворяющее /?(т)). Поскольку (х^у and y^x^^F, наша программа не обязательно детерминирована. Если первоначально х = у, то оказывается неопределенным, какое из двух присваиваний будет выбрано для исполнения; такая недетерминированность совершенно корректна, так как мы показали, что выбор не имеет значения.
Замечание. Если бы среди доступных примитивов имелась функция «тах», мы могли бы написать программу m : = тах(х, у), поскольку R(max(x, у))=Т. (Конец замечания.)
Полученная программа не производит очень сильного впечатления; с другой стороны, мы видим, что в процессе вывода программы из постусловия на долю нашей изобретательности почти ничего не осталось.
Второй пример
Для фиксированного значения п(п>0) задана функция f(0 для 0^i<n. Обеспечить истинность R:
0^k<n and (К i :0^i<n : f
Поскольку наша программа должна работать при любом положительном значении п, трудно себе представить, как можно обеспечить истинность R, не прибегая к помощи цикла; поэтому мы ищем отношение Р, которое легко можно сделать истинным в начале программы и такое, что в конце (Р and non ВВ) =^R. Следовательно, отыскивая Р, мы ищем отношение, более слабое, чем R; иначе говоря, мы хотим получить обобщение нашего конечного состояния. Стандартный способ обобщения отношения — это замена константы переменной (возможно, в ограниченном интервале), и здесь мой опыт подсказывает мне, что нужно заменить константу п новой переменной, например /, и взять в качестве Р
O^k<j^n and (А i: 0^z</: f (к) ^f(O)
где условие j^n добавлено для того, чтобы область определения функции f была конечной. Теперь, имея такое обобщение, мы легко получаем
(Р and j = n) =^R
Чтобы подтвердить возможность использования таким образом выбранного Р, мы должны располагать легким средством для обеспечения истинности Р в начале программы. Поскольку
(^ = 0 and /=1) =^Р
мы отваживаемся предложить следующую структуру для нашей программы (комментарии даются в скобках):
к, /: = 0, 1 {Р стало истинным};
do /#=/г-^ некий шаг к j=n при инвариантности Р od
^R стало истинным}
Снова мой опыт подсказывает мне выбрать в качестве монотонно убывающей функции / текущего состояния функцию t=^n—/), которая и в самом деле такова, что Р^= (t^O). Чтобы гарантировать монотонное убывание Л я предлагаю увеличивать / на 1, после чего мы можем написать
wp(«/:=/+ 1», Р") =
O^k<j+ 1 ^/2 and (А 2: 0^2</ + 1 ; ^k^^^i^ =
O^k<j+l^n and (A z: 0^z</:/(6) ^/(z)) and iU^^iU)
Первые два члена следуют из Р and j^n (потому что (j^n and jv^n)^ (j+i^^), н именно поэтому мы решили увеличить / только на 1). Следовательно,
(Р and i^=n and f (^) ^ f (/)) =^ wp («/: = /+!», Р)
и мы можем использовать последнее условие в качестве предохранителя. Программа
к, j: = 0, 1;
do j^п->if /(^)^f(/) -^/: = /+l fi od
действительно даст правильный ответ, если она завершится нормально. Однако нормальное завершение не гарантировано, поскольку конструкция выбора может привести к отказу — и это действительно так и будет, если к = 0 не удовлетворяет R.
Если неверно, что /(^)^/(/), мы можем сделать это отношение верным при помощи присваивания k;= j и продолжить наши исследования:
wp(«£, /:=/, / + 1», Р) =
0^/</+ 1 ^/г and (А i: 0^i</+ 1 :/(/) ^/(0) =
0^/</ + 1^п and (A i: O^Kj: ЦП ^f(i))
С огромным облегчением мы замечаем, что
(Р and j#=n and ЦЦ ^ЦЩ =^wp(«&, j: = j, / + 1», Р)
и следующая программа будет работать, не подвергаясь опасности отказа:
^,/: = 0, 1;
do j^n-^if f(^)^/(/)+1
□ iW ^Ш) г-=ь/ + 1 fi <>d
Следует сделать несколько примечаний. Первое: поскольку предохранители конструкции выбора не обязательно исключают друг друга, программа таит в себе внутреннюю недетерминированность того же типа, что и в первом примере. Эта недетерминированность может проявиться и внешне. Функция / могла оказаться такой, что конечное значение k неоднозначно; в этом случае наша программа может выработать любое допустимое значение!
Второе примечание: то, что мы построили правильную программу, нс означает, что мы справились с задачей. Программирование в равной степени является как математической, так и инженерной дисциплиной; мы должны заботиться о правильности в той же мере, как скажем, и об эффективности. Исходя из предположения, что на вычисление значения функции f для данного аргумента затрачивается относительно много времени, хороший инженер должен обратить внимание на то, что в программе, по всей вероятности, будет многократно вычисляться Цк) для одного и того же значения к. В этом случае рекомендуется пойти на сделку и пожертвовать некоторым объемом памяти, чтобы сэкономить часть времени, затрачиваемого на вычисление. Но при этом наши усилия, направленные на повышение эффективности программы и уменьшение затрат времени, ни в коей мере не должны служить извинением за внесение беспорядка в программу. (Это очевидная вещь, но я хочу подчеркнуть эту мысль, потому что очень час-
то запутанность программы оправдывается ссылками на эффективность. Однако при ближайшем рассмотрении такое оправдание всегда оказывается необоснованным, должно быть, потому, что запутанность программы всегда неоправданна.) Чтобы аккуратно реализовать обмен времени вычислений на память, можно воспользоваться методом введения одной или нескольких дополнительных переменных, чье значение можно использовать, так как сохраняется инвариантным некоторое отношение. В данном примере было замечено, что возможны частые повторные вычисления Цк^ для одного и того же к, а это наводит на мысль о введении дополнительной переменной, скажем max, и расширении инвариантного отношения за счет дополнительного члена
max=f(k)
Это отношение должно стать истинным, когда к получит начальное значение, и должно сохраняться инвариантным (при помощи явного присваивания значения переменной тах^ после изменения к. Мы приходим к следующей программе:
к, j, max: = Q, 1, /(0);
do /т^и-^if max^f (j) ->- j; = j-r 1
max^f(j) ->-k, j, max; = j, / +1, Щ) fi od
Вероятно, эта программа гораздо более эффективна, чем наша предыдущая версия. В таком случае хороший инженер на этом не остановится, потому что теперь он обнаружит, что мог бы распорядиться повторными вычислениями /(/) для одного и того же /. Для этого можно ввести дополнительную переменную, скажем h, и обеспечить инвариантность отношения
Однако мы не может сделать это на том же глобальном уровне, как для нашего предыдущего члена: значение j = n не исключено, а при этом значение /(/) нс обязательно определено. Следовательно, истинность отношения Л=Д(/) должна заново обеспечиваться после каждой проверки, показывающей, что ]=^Щ по завершении внешней охраняемой команды (так сказать, «как раз перед od») мы имеем к = ^Ц—1), но нас это
не должно беспокоить, и пусть все остается так, как есть.
k, j, max: = 0, 1, f(0);
do /4/?-^/г.=/(/);
if max^h-^- j: = j+ 1
max^h-^k, j, max:^j, /41, h, fi od
Последнее примечание касается не столько нашего решс- ния задачи, сколько наших соображений о подходе к решению. Мы говорили о математическом подходе, мы говорили об инженерном подходе и провели разделение между этими подходами, сосредоточивая внимание то на одном, то на другом аспекте. Хотя разделение подходов абсолютно необходимо, когда речь идет о более сложных задачах, я должен подчеркнуть, что, сосредоточивая внимание на одном из аспектов, не следует полностью игнорировать другие аспекты. Разрабатывая математическую часть проекта, мы должны помнить, что даже математически правильная программа может погибнуть, если она плоха с инженерной точки зрения. Аналогично, осуществляя «сделку» между памятью и временем, мы должны делать это аккуратно и систематически, чтобы по неряшливости не внести ошибок в программу; кроме того, хотя предварительно был проведен математический анализ, мы должны к тому же достаточно хорошо разбираться в задаче, чтобы судить о том, приведут ли предполагаемые изменения к значительному улучшению программы.
Замечание. До того как я начал пользоваться этими формальными построениями, я всегда использовал «j<n» в качестве предохранителя в этой конструкции повторения, теперь я должен отучиться от этой привычки, так как в случае, подобном данному, конечно, предпочтительнее предохранитель «j^n». Для этого имеются две причины. Предохранитель «j^n» позволяет нам сделать вывод, что по завершении программы j = n, нс ссылаясь при этом на инвариантное отношение Р; тем самым упрощается дискуссия о том, что даст нам вся конструкция в целом по сравнению с предохранителем «j<n». Гораздо важнее, однако, что предохранитель «j^n» ставит завершение программы в зависимость от (части) инвариантного отношения, а именно j^n, и поэтому ему следует отдать предпочтение, так как его использование приведет к усилению конструкции. Если в результате выполнения /: = /+1 значение / так возрастет, что станет справедливым отношение j>n, предохранитель «j<n» не поднимет тревогу, тогда
как предохранитель «/^и» по крайней мере не допустит нормального завершения. Такой аргумент представляется вполне весомым, даже если не принимать во внимание сбой машины. Пусть в последовательности х0, М, х2, ... значение х0 задается, а значение Xt для />0 определяется как Xi=f{x-i-i}, где f — некоторая вычислимая функция. Будем внимательно и точно следить за тем, чтобы сохранялось инвариантным отношение X = xt. Предположим, что в программе имеется монотонно возрастающая переменная п, такая, что для некоторых значений п нас интересуют хп. При условии что п^ц мы всегда можем сделать истинным отношение Х = хп при помощи
do i^n-^i, X: = i + 1, f (X) od
Если же отношение n^i нс обязательно имеет место (может быть, последующие изменения в программе привели к тому, что в процессе вычислений п будет не только возрастать), приведенная выше конструкция (к счастью!) нс придет к завершению, в то время как конструкция
do2</2-^r, X:=u+1, f (X) od
не обеспечит истинности отношения Х = хп. .Мораль этой истории такова, что при прочих равных условиях мы должны выбирать наши предохранители как можно более слабыми. {Конец замечания.}
Третий пример
Для фиксированных а{а^0} и d{d>0} требуется обеспечить истинность R;
0^r<d and d\ {а—г}
(Здесь вертикальная черта «|» заменяет собой слова «является делителем».) Иначе говоря, нам требуется вычислить наименьший неотрицательный остаток г, полученный в результате деления а на d. Чтобы эта задача действительно была задачей, мы должны ограничить использование арифметических операций только операциями сложения и вычитания. Поскольку условие d\ {а—г} выполняется при г^а и при этом, так как а^О, верно, что О^г, предлагается выбрать в качестве инвариантного отношения Р;
O^r and d\ {а—г}
В качестве функции /, убывание которой должно обеспечить завершение работы программы, мы выбираем само г. Поскольку изменение г должно быть таким, чтобы неизменно выполнялось условие d\ (а—г}. г можно изменять только на чис-
ло, кратное d, например на само d. Таким образом, нам, как оказалось, предлагается вычислить
wp(«r: = r—d», Р) and wdec («г: = г—d», r) =
O^r—d and d\ (a—r + d) and d>0
Поскольку член d>0 можно было бы добавить к инвариантному отношению Р, обоснования требует только первый член; мы находим соответствующий предохранитель «r^d» и пробуем составить такую программу:
if а^О and d>0->-
r: = a;
do r^d-^r.^r—d od
fi
По завершении программы стало истинным отношение Р and non r^d, из чего следует истинность R, и, таким образом, задача решена.
Предположим теперь, что нам, кроме того, потребовалось бы присвоить q такое значение, чтобы по окончании программы было бы также верно, что
a = d *q + r
иначе говоря, требуется найти не только остаток, но и частное. Попробуем добавить этот член к нашему инвариантному отношению. Поскольку
^a^d * q + r^ => (a = d * (^+1) 4- (r—d^
мы приходим к программе
if а^О and d>0->-
q, r: = 0, a;
do r^d->- q, r: = q+l, r—d od
fi
На выполнение приведенных выше программ будет, конечно, затрачиваться очень много времени, если частное велико. Можем ли мы сократить это время? Очевидно, этого можно добиться, если уменьшать г на величину, кратную и большую d. Вводя для этой цели новую переменную, скажем dd, мы получаем условие, которое должно неизменно выполняться на протяжении всей работы программы:
d\dd and dd^d
Мы можем ускорить нашу первую программу, заменив «г:=г—d» уменьшением, возможно повторным, г на dd, предоставляя в то же время возможность dd, первоначально равному d, довольно быстро возрастать, например каждый раз удваивая dd. Тогда мы приходим к следующей программе:
if а 0 and d > 0 —*
г :=а\
do r^d —>
dd ; = d\
do dd —> r *.=r — dd\ dd *.=dd-\-dd od od
fi
Ясно, что отношение O^r and d\ (a—г) сохраняется инвариантным, и поэтому программа обеспечит истинность Р, если она завершится нормально, но так ли это? Конечно, так, поскольку внутренний цикл, который завершается при dd>0, возбуждается только при начальных состояниях, удовлетворяющих условию r^dd, и поэтому уменьшение г:=г—dd выполняется по крайней мере один раз для каждого повторения внешного цикла.
Но такое рассуждение (хотя и достаточно убедительное!) является весьма неформальным, а поскольку в этой главе мы говорим о формальном построении программ, мы можем попробовать сформулировать и доказать теорему, которой интуитивно пользовались.
Пусть IF, DO и ВВ понимаются в обычном смысле, и Р — это отношение, сохраняющееся инвариантным, т. е.
(PandPP)n> wp(IF, Р) для всех состояний (1) и пусть t— целочисленная функция, такая, что для любого значения /0 и для всех состояний
(Р and ВВ and t < /0-]- 1) => wp (IF, /</0)(2)
или, что то же,
(Р and 55)=^ wdec (IF, /) для всех состояний (3)
Тогда для любого значения /0 и для всех состояний
(Р and ВВ and wp(DO, 7) and /^/Оф 1)^ wp(DO, /<70) (4) или, что то же,
(Р and ВВ and wp(DO, 7))^wdec(DO, /)(5)
В словесной формулировке: если сохраняемое инвариантным отношение Р гарантирует, что каждая выбранная для исполнения охраняемая команда вызывает действительное уменьшение /, то конструкция повторения вызовет действительное уменьшение /, если она нормально завершится после по крайней мере одного выполнения какой-либо охраняемой команды. Теорема настолько очевидна, что было бы досадно, если бы се доказательство оказалось трудным, но, к счастью, это не так. Мы покажем, что из (1) и (2) следует, что для любого значения /0 и для всех состояний
(Р and ВВ and Hk(T) and t < /0+ \)=^Hk(t^ /0) (6)
для всех /?^0. Это справедливо для ^ = 0, поскольку (ВВ and H^T^^F, и, исходя из предположения, что (6) справедливо для к = К, мы должны показать, что (6) справедливо и для k = KA-V
(Р and ВВ and НК^Х(Т) and /</0+1)
^wp(IF, Р) and wp(IF, ЯА^(Г)) and wp(lF, /</0)
= wp(IF) р and НК(Т) and / </0)
^wp((!F, (P and В В and HK(T) and /</0+1) or (/</0
and non BB))
^wp(IF, /«/</0) or //0(/</0))
= wp(IF, ^(/</0))
=^wp(IF, //^(/</0)) or //0(/</0)
= ^A’ + l(/</0)
Первая импликация следует из (1), определения НК-н(Т) и (2); равенство в третьей строке очевидно; импликация в четвертой строке выводится при помощи присоединения (ВВ or non ВВ) и последующего ослабления обоих членов; импликация в пятой строке следует из (6) при k = K и определения Ho(t^tO); остальное понятно. Таким образом, мы доказали справедливость (6) для всех А^0, а отсюда немедленно вытекают (4) и (5).
Упражнение
Измените нашу вторую программу таким образом, чтобы она вычисляла также и частное, и дайте формальное доказательство правильности вашей программы. (Конец упражнения.)
Предположим теперь, что имеется маленькое число, скажем 3, на которое нам позволено умножать и делить, и что эти операции выполняются достаточно быстро, так что имеет смысл воспользоваться ими. Будем обозначать произведение «т * 3» (или «3 * т») и частное «т,3»; последнее выражение будет использоваться только при условии, что первоначально справедливо 3\т. (Мы ведь работаем с целыми числами, не так ли?)
И опять мы пытаемся обеспечить истинность желаемого отношения R при помощи конструкции повторения, для которой инвариантное отношение Р выводится путем замены константы переменной. Заменяя константу d переменной dd, чьи значения будут представляться только в виде d * (степень 3), мы приходим к инвариантному отношению Р:
О ^r<dd and dd | (а —-г) and (Е /: / ^ 0 : dd = d *3Z)
Мы обеспечим истинность этого отношения и затем, сохраняя его инвариантным, постараемся достичь состояния, при котором d = dd.
Чтобы обеспечить истинность Р, нам понадобится еще одна конструкция повторения: сначала мы обеспечим истинность отношения
0<> and dd | (а — г) and (Е/: I ^ 0 : dd = d * 3Z)
а затем будем увеличивать dd, пока его значение не станет достаточно большим и таким, что r<dd. Получим следующую программу:
if я^О and d^O —>
г, dd a, d;
do г^ dd —* dd : = dd *3 od;
do dd^= d dd : = dd, 3;
do r^dd —»r\= r — dd od
od
Упражнение
Измените приведенную выше программу таким образом, чтобы она вычисляла также и частное, и дайте формальное доказательство правильности вашей программы. Это доказательство должно наглядно показывать, что всякий раз, когда вычисляется dd[3, имеет место 3|dd. (Конец упражнения.)
Для приведенной выше программы характерна довольно распространенная особенность. На внешнем уровне две конструкции повторения расположены подряд; когда две или больше конструкций повторения на одном и том же уровне следуют друг за другом, охраняемые команды более поздних конструкций оказываются, как правило, более сложными, чем команды предыдущих конструкций. (Это явление известно как «закон Дейкстры», который, однако, не всегда выполняется.) Причина такой тенденции ясна: каждая конструкция повторения добавляет свое «and non ВВ» к отношению, которое она сохраняет инвариантным, и это дополнительное отношение следующая конструкция также должна сохранять инвариантным. Если бы не внутренний цикл, второй цикл был бы в точности противоположен первому; и функция дополнительного оператора
do г ^ dd —> г : = г — dd od
именно в том и состоит, чтобы восстанавливать возможно нарушенное отношение r<dd, достигаемое при выполнении первого цикла.
Четвертый пример
Для фиксированных QL Q2, Q3 и Q4 требуется обеспечить истинность R, причем R задается как /?1 and R2, где
/?1: последовательность значений (^1, ^2, ^3, ^4) есть некоторая перестановка значений (QI, Q2, Q3, Q4)
R2; ^1^^2^^3^^4
Если мы возьмем R1 в качестве сохраняемого инвариантным отношения Р, получим такое возможное решение:
ql, q2. q^. 74:= QI, Q2, Q3, Q4;
do ^1 >^2—><71, q2*— q2, q\
^q2>qS — q2, q3;=q3, q2
□ ^3>74~*73, q\\ =q^ qS od
Очевидно, что после первого присваивания Р становится истинным, и ни одна из охраняемых команд не нарушает его истинности. По завершении программы мы имеем non ВВ, а это есть отношение R2. В том, что программа действительно придет к завершению, каждый из нас убеждается по-разному в зависимости от своей профессии: математик мог бы заметить, что число перестановок убывает, исследователь операций
будет интерпретировать это как максимизацию #1+2* #2 + + 3 * #3 + 4 * #4, а я — как физик — сразу «вижу», что центр тяжести смещается в одном направлении (вправо, если быть точным). Программа примечательна в том смысле, что, какие бы предохранители мы ни выбрали, никогда не возникнет опасности нарушения истинности Р: предохранители, используемые в нашем примере, являются чистым следствием необходимости завершения программы.
Замечание. Заметьте, что мы могли бы добавить также и другие варианты, такие, как
#1 >#3-^#1,#3:= #3, #1
причем их нельзя использовать для замены одного из трех перечисленных в программе. {Конец замечания.)
Это хороший пример того, какого рода ясности мы можем достичь при нашей недетерминированности; излишне говорить, однако, что я не рекомендую сортировать большое количество значений аналогичным способом.
Пятый пример
Нам требуется составить программу аппроксимации квадратного корня; более точно: для фиксированного п {п^О) программа должна обеспечить истинность
К:а2^п and (^z-[- 1)2>я
Чтобы ослабить это отношение, можно, например, отбросить один из логических сомножителей, скажем последний, и сосредоточиться на
Р;а2 ^п
Очевидно, что это отношение верно при а = 0, поэтому выбор начального значения не должен нас беспокоить. Мы видим, что если второй член не является истинным, то это вызывается слишком маленьким значением а, поэтому мы могли бы обратиться к оператору «я: = я+1». Формально мы получаем
wp {«а : = а — 1»,=I)2 ^ ^)
Используя это условие в качестве (единственного!) предохранителя, мы получаем {Р and non ВВ) ==К и приходим к следующей программе:
if п>0—’
д:=0 \Р стало истинным};
do 1)2^: — ^Т'1 \Р осталось истинным} od
{Р стало истинным)
fi \R стало истинным}
При составлении программы мы исходили из предположения, что она завершится, и это действительно так, поскольку корень из неотрицательного числа есть монотонно возрастающая функция: в качестве / мы можем взять функцию п—а2.
Эта программа довольно очевидна, к тому же она и не очень эффективна: при больших значениях п она будет работать довольно долго. Другой способ обобщения R— это введение другой переменной (скажем, b— и снова в ограниченном интервале изменения), которая заменит часть R, например,
Р*.а2 < п and 62 > п and 0 < а <4 b
Благодаря такому выбору Р обладает тем приятным свойством, что
(Р and (я 4-1 = 6))=^/?
Таким образом, мы приходим к программе, представленной в такой форме (здесь и далее конструкция if п^0-> ...U опускается):
а,Ь := O,zz + 1 \Р стало истинным};
do а-[-1 т^б—>уменьшить Ь — а при инвариантности Pod
{Р стало истинным}
Пусть d будет величиной, на которую уменьшается разность b—а при каждом выполнении охраняемой команды. Разность может уменьшаться либо за счет уменьшения Ь, либо за счет увеличения а, либо за счет и того и другого. Не теряя общности, мы можем ограничиться рассмотрением таких шагов, когда изменяется либо а, либо Ь, но не а и b одновременно: если а слишком мало и b слишком велико и на одном шаге только уменьшается Ь, тогда па следующем шаге можно увеличить а. Эти соображения приводят нас к программе в следующей форме:
а,Ь: — 0, /г4"1 \Р стало истинным};
do a-V^ b-^
d : = ... \d имеет соответствующее значение и Р по-прежнему выполняется);
if. . • —^а : =ct^-d {Р осталось истинным}
□ ...—>b:=b — d (P осталось истинным)
fi (P стало истинным)
od {/? стало истинным)
Теперь
wp(«a : = а4"^», Р) = ((# + ^)2<^ я and №">п^
Поскольку истинность второго члена следует из истинности Р, мы можем использовать первый член в качестве нашего первого предохранителя; аналогично выводится второй предохранитель, и мы получаем очередную форму нашей программы:
а,Ь : = 0,^-|- 1;
do а 4-1 Ф b —>d : = ...;
if ^a — d^- ^п —>а*.= a-\-d U(b-dy>n-^b;= b — d fi {P осталось истинным) od {/? стало истинным)
Нам остается еще соответствующим образом выбрать d. Поскольку мы взяли b—а (на самом деле, b—а—1) в качестве нашей функции /, эффективное убывание должно быть таким, чтобы d удовлетворяло условию d>0. Кроме того, последующая конструкция выбора не должна приводить к отказу, т. с. по крайней мере один из предохранителей должен быть истинным. Это значит, что из отрицания одного, (a+d^^n, должен следовать другой, (6—dy>n; это гарантируется, если
d 4- d <4 b — d или
2 * d <, b — а
Наряду с нижней границей мы установили также и верхнюю границу для d. Мы могли бы взять d=\, но чем больше d, тем быстрее работает программа, поэтому мы предлагаем
а,Ь : = 0, /г 4* 1;
do а-у\ ^b-^d := ^b — a) div 2;
if ^a-^dy < n-^a : = а-У-d
и ^b — dy ^> n-^ b : = b — d
fi
od
где п div 2 есть /г/2, если 21 лг, и (/г—1)/2, если 2| (/г—1).
Использование действия div побуждает нас разобраться в том, что произойдет, если мы навяжем себе ограничение на b—а, полагая, что b—а четно при каждом вычислении d. Вводя с = Ь—а и исключая 6, мы получаем инвариантное отношение
Р; а2<п and (а-усУ^п and (EZ: / > 0 : г = 2;)
и программу (в которой с играет роль d)
а^ с : = 0, 1; do г2 ^ п с : = 2 * с od;
do с ^ 1 -^с : = г/2;
if [a -j-r)2 < п -^а : = а™ с
□ (а-у-су^п-^ пропустить fi
od
Замечание. Эта программа очень похожа на последнюю программу для третьего примера, где вычислялся остаток в предположении, что мы имеем право умножать и делить на 3. В приведенной выше программе можно было бы заменить конструкцию выбора на
do (а-^сУ < п->-а : = аУ~с od
Если условие, которому должен удовлетворять остаток, О^г<д, записать как r<d and (r + d^^d, сходство станет еще более отчетливым. (.Конец замечания.)
Понимая, что так можно окончательно «замучить» этот маленький пример, я бы хотел тем не менее предложить последний вариант преобразования программы. Мы написали программу, исходя из предположения, что операция возведения числа в квадрат входит в состав имеющихся операций; но предположим, что это нс так и что единственные операции типа умножения, имеющиеся в нашем распоряжении,— это умножение и деление на (малые) степени 2. Тогда наша последняя программа оказывается не такой уж хорошей, т. е. она нехороша, если мы предполагаем, что значения переменных, с которыми непосредственно оперирует машина, отождествлены со значениями переменных а и с. как это было бы при «абстрактных» вычислениях. Другими словами, мы можем рассматривать а и с как абстрактные переменные, чьи значения представляются (в соответствии с соглашением, предусматривающим более сложные условия, чем просто тожде-
ствепность) значениями других переменных, с которыми в действительности оперирует машина при выполнении программы. Мы можем позволить машине работать не непосредственно с а и с, а с р, q, г, такими, что
р=а *с q = c- г—п — а2
Мы видим, что это — преобразование координат, и каждой траектории в нашем (а, с)-пространстве соответствует траектория в нашем ^р, q, г)-пространстве. Обратное не всегда верно, так как значения р, q и г не независимы: в терминах Р, q и г мы получаем избыточность и, следовательно, потенциальную возможность, пожертвовав некоторым объемом памяти, не только выиграть время вычислений, но даже избавиться от необходимости возводить в квадрат! (Совершенно ясно, что преследовалась именно эта цель, когда строилось преобразование, переводящее точку в (а, с)- пространстве в точку в (р, q, г)-пространстве.) Теперь мы можем попытаться перевести все булевы выражения и перемещения в (а, с)-пространстве в соответствующие булевы выражения и перемещения в (р, q, г)-пространстве. Если бы нам удалось сделать это в терминах допустимых операций, наша задача была бы успешно решена. Предлагаемое преобразование и в самом деле отвечает нашим требованиям, и результатом его является следующая программа (переменная 1г была введена для очень локальной оптимизации):
РЛ, г 1, п; do q < n-^q *—q * 4 od;
do q \q : = q 4; h, pq; p • = p)2 \h. = 2, * pq\;
if r ^ h -> p, r : = p -]- q, r — it
У r<yh-> пропустить fi
od \p имеет значение, требуемое для а\
Этот пятый пример был включен благодаря истории его создания (правда, несколько приукрашенной). Когда младшей из двух наших собак было всего несколько месяцев, я однажды вечером пошел погулять с ними обеими. Назавтра мне предстояло читать лекции студентам, которые всего лишь несколько недель назад начали знакомиться с программированием, и мне нужна была простая задача, чтобы на ее примере я мог «массировать» решения. В течение часовой прогул-
ки я продумал первую, третью и четвертую программы, правда, правильно ввести h в последней программе я смог только при помощи карандаша и бумаги, когда вернулся домой. Ко второй программе, той, которая оперирует с а и b и которая была здесь представлена как переходная ступень к нашему третьему решению, я пришел лишь несколько недель спустя — и она была тогда в гораздо менее элегантной форме, чем представлена здесь. Вторая причина для ее включения в число представленных программ — это соотношение между третьей и четвертой программами: по отношению к четвертой программе третья представляет собой наш первый пример так называемой «абстракции представления».
Шестой пример
Для фиксированных Х(Х>1) и У(У^О) программа должна обеспечить истинность отношения
R;z = XY
при очевидном предположении, что операция возведения в степень не входит в набор доступных операций. Эта задача может быть решена при помощи «абстрактной переменной», скажем h; при решении задачи мы будем пользоваться циклом, для которого инвариантным является отношение
Р-h^z^XY
и наша (в равной степени «абстрактная») программа могла бы выглядеть так:
h, z \=Х} , 1{Р стало истинным);
do h + 1-> сжимать И. при инвариантности Р od
{А? стало истинным)
Последнее заключение справедливо, поскольку ^Р and h=\^>R. Приведенная выше программа придет к завершению, если после конечного числа применений операции «сжимания» h станет равным 1. Проблема, конечно, в том, что мы не можем представить значение h значением конкретной переменной, с которым непосредственно оперирует машина; если бы мы могли так сделать, мы могли бы сразу присвоить г значение XY, не затрудняя себя введением 1г. Фокус в том, что для представления текущего значения 1г мы можем ввести две — на этом уровне конкретные — переменные, скажем х и у, и наше первое присваивание предлагает в качестве соглашения об этом представлении
h=xy
Тогда условие «1г#=\» переходит в условие «у#=0», и наша следующая задача состоит в том, чтобы подыскать выполнимую операцию «сжимания». Поскольку при этой операции произведение h* z должно оставаться инвариантным, мы должны делить 1г на ту же величину, на которую умножается z. Исходя из представления 1г, наиболее естественным кандидатом на эту величину можно считать текущее значение х. Без дальнейших затруднений мы приходим к такому виду нашей абстрактной программы:
х, у, z\ = X, Y, 1 (Р стало истинным];
do у^О-^у, z: = y--Y z ^ х \Р осталось истинным) od
(Р стало истинным)
Глядя на эту программу, мы понимаем, что число выполнений цикла равно первоначальному значению У, и можем задать себе вопрос, нельзя ли ускорить программу. Ясно, что задачей охраняемой команды является сведение у к нулю; не изменяя значения h, мы можем проверить, нельзя ли изменить представление этого значения в надежде уменьшить значение у. Попытаемся воспользоваться тем фактом, что конкретное представление значения h, заданное как ху, вовсе нс является однозначным. Если у четно, мы можем разделить у на 2 и возвести х в квадрат, при этом значение 1г совсем не изменится. Непосредственно перед операцией сжимания мы вставляем преобразование, приводящее к наиболее привлекательному представлению 1г, и получаем следующую программу:
х, у, z \ = Х. Y, 1;
do у =7^0-^do 2]у-^х, у :=х*х, у ,2 od;
У, Z\=y—1, 2* X
od \R стало истинным)
Существует только одна величина, которую можно бесконечно делить пополам и она не станет нечетной, эта величина— нуль; иначе говоря, внешний предохранитель гарантирует нам, что внутренний цикл придет к завершению.
Я включил этот пример по разным причинам. Меня поразило открытие, что если просто вставить в программу что-то, что на абстрактном уровне действует как пустой оператор, можно так изменить алгоритм, что число операций, которое раньше было пропорционально Y, станет пропорциональным только log (У). Это открытие было прямым следствием того,, что я заставил себя думать в терминах отдельной абстрактной
переменной. Программа возведения в степень, которую я знал, была следующей:
х, у, z: =Х, У, 1;
do у 0-> if non 2[y->-z/, z\=y — 1, z* x
□ 21 пропустишь fi;
x, у :=x* x, у 2 od
Эта последняя программа очень хорошо известна, многие из нас пришли к ней независимо друг от друга. Поскольку последнее возведение х в квадрат, когда у стал равным нулю, уже .излишне, на эту программу часто ссылались как на пример, подтверждающий необходимость иметь то, что мы назвали бы «промежуточными выходами». Принимая во внимание нашу вторую программу, я прихожу к заключению, что этот довод слаб.
Седьмой пример
Для фиксированного значения п (/г^О) задана функция f(i) для О^СКп. Присвоить булевой переменной «всешесть» такое значение, чтобы в конечном результате стало справедливым
/? :бсешесшь = (М :0 <.i<^n: / (/) = 6)
(Этот пример обнаруживает некоторое сходство со вторым примером данной главы. Заметим, однако, что в этом примере допускается равенство п пулю. В таком случае интервал возможных значений для квантора «А» пуст и должно быть верным, что всешесть = истина.) По аналогии со вторым примером введем инвариантное отношение
Р: (всешесть = (Х 1: О <3<J • / (/) = 6)) and 0 < j <, п
поскольку это отношение легко сделать истинным при / = 0 и к тому же (Р and j = n) =^Р. Единственное, что мы должны сделать, это понять, как увеличивать / при инвариантности Р. Поэтому берем
wp («/: = /+1», Р)=
^всешесть = ^Х I : 0 < I < у-ф 1 : / (/) = 6)) and 0 < у-{- 1 < п
Справедливость последнего члена следует из справедливости Р and j=7^n; и в этом нет никакой проблемы, так как мы уже решили, что условие j^n, взятое в качестве предохранителя, является достаточно слабым, чтобы делать выводы о значе-
нии R по завершении программы. Слабейшим предусловием, таким, что присваивание
всешесть \ = всешесть and/(у) = 6
сделает истинным другой член, является
{всешесть and / (у) = 6) —(А /: 0 < / < у-{-1: /(/) = 6) условие, которое следует из Р. Таким образом, мы приходим к программе:
всешесть. j '. — истина. 0;
do j ^г п-^ всешесть \ — всешесть and / (у) = 6;
(Мы не воспользовались одновременным присваиванием в охраняемой команде просто так, без особых на то причин.)
К моменту чтения этой программы — или даже раньше — у нас должно было возникнуть чувство беспокойства по поводу того, что, как только будет найдено значение функции =^=6, не будет смысла продолжать работу программы. И в самом деле, хотя (Р and j = n) =^R. мы могли бы воспользоваться более слабым утверждением
{Р and {j — n or non всешесть^ R приводящим к более сильному предохранителю «j^n and всешесть» и к программе
всешесть. j \= истина. 0;
do j^n and всешесть-^ всешесть. j : =/(у) = 6, у +1 od
(Отметим упрощение присваивания значения всешесть. упрощение, которое объясняется использованием более сильного предохранителя.)
Упражнение
Для той же задачи дать доказательство правильности для if п = 0-^-всешесть \ = истина
□ «>0 -*у : = 0;
do j-fii- 1 and /(у) = 6-* у : = /+ 1 od; всешесть : / (у) = 6
а также для более «хитрой» программы (которая избавилась от необходимости обращаться к функции / более чем из одного места в программе)
У: = 0;
do у Ф п cand /(;) = 6->у : =У+ 1 od;
всетестъ : = j=n
(Операция условной конъюнкции «cand» была здесь применена для того, чтобы учесть тот факт, что значение f(/z) нс обязательно должно быть определено.) Эта последняя программа некоторым очень нравится. (Конец упражнения.)
Восьмой пример
Прежде чем я смогу сформулировать нашу следующую задачу, я должен дать некоторые определения и сформулировать теорему. Пусть р = (р0, р{, ..., Рп-\) обозначает перестановку п (^>1) различных значений pt (О^Кп), т. е. (1=7^/) =^ (Рг=^Рз). Пусть q=(qQ, q\, .... ^n-i) обозначает другую перестановку той же совокупности п значений. По определению «перестановка р предшествует перестановке q в алфавитном порядке» в том и только том случае, если для минимального значения к, такого, что pu^qk, мы имеем Pk<qb
Так называемый «алфавитный индексп» перестановки п различных значений есть порядковый номер, сообщаемый перестановке, когда мы перенумеровываем п\ возможных перестановок, расположенных в алфавитном порядке, от 0 до п\—1. Например, для /?=3 и совокупности значений 2, 4, 7 мы имеем
индексу (2, 4, 7) = 0 индекс3 (2, 7, 4) = 1 индекс3 (4, 2, 7) = 2 индексу (4, 7, 2)=3 индексу (7, 2, 4)=4 индексу (7, 4, 2) = 5
Пусть (р0 § pi § ... § pn-i) обозначает перестановку п различных значений в порядке их монотонного возрастания, т. е. индексп((ро§ Pi§ ... § Pn-i))=0. (Например, (4 § 7 § 2) = = (2, 4, 7), но и (7 § 2 § 4) = (2, 4, 7).) .
Пользуясь введенными выше обозначениями, мь можем сформулировать следующую теорему для п>\;
индекс„(А» А,---, A»-i) =
индекс^, (А §•-^/^Минде^ A,.. ,A-i)
(например, индекс3(4, 7, 2) =индекс3(4, 2, 7) + индекс, (7, 2) = = 2+1=3).
Словесная формулировка: индекс^ перестановки л различных значений есть индекс первой по алфавиту перестановки с тем же самым левым значением, увеличенный на и{дексп-1 перестановки оставшихся правых п—1 значений. Следствие: из
Рп—к < Рп—£+1 < • • * < Рп—\
вытекает, что индексп ^р^ рь . Pn-i) кратен й!, и наоборот.
Теперь, после всех этих приготовлений, мы можем изложить нашу задачу. Имеется ряд из п позиций (и> 1), перенумерованных слева направо с 0 до п—1; в каждой позиции находится карточка, на которой написано некоторое число, причем все эти числа различны.
Если в любой момент сг-(0^1</г) обозначает число на карточке, находящейся в позиции I, то в начальный момент мы имеем
(т. е. карточки разложены в порядке возрастания написанных на них чисел). Для заданного значения г (О^г</г!) иы должны так переупорядочить карточки, чтобы выполнялось условие
R:индексл(с0, с^..., сп_^=г
Чтобы иметь возможность задавать действия над карточками, нам придется ввести оператор
переставить^. у) при O^z, у <^п
который будет менять местами карточки в позициях i и /, если i#=j (и ничего не будет делать, если 1 = П-
Чтобы выполнить заданное преобразование, мы должны найти класс состояний (причем все они должны удовлетворять соответствующему условию Р\), такой, что и начальное, и конечное состояния являются специфическими представителями этого класса. Введем новую переменную, скажем s, и гредста-
вим в качестве очевидного кандидата для jP 1 условие индексу, съ..., cn_^ = s
поскольку его легко удовлетворить в самом начале (а именно при помощи «s: = 0») и поскольку (Pl and s = r) =^Р.
Теперь мы можем подумать об ограничении интервала значений 5, и, учитывая начальное значение s, можно было бы попробовать
/Ч :индексЛ(с0, с^..., ^^„^ = 5 and 0<is<>
что привело бы к программе в такой форме:
s: — 0 (Pl стало истинным};
do s у= г (Pl and s < г}
увеличить s на соответствующую величину при инвариантности Р\ (Р1 остается истинным}
od {/? стало истинным}
Наша следующая забота — выбор «соответствующей величины». Поскольку увеличение s должно сопровождаться пере- упорядочиванием карточек, призванным сохранить инвариантность Р\, было бы благоразумным проверить, нельзя ли найти такие условия, при которых отдельная операция переставить соответствовала бы известному приращению s. Пусть для к, такого, что \^к<п, имеет место
п—к < п—£ + 1 < • • • < п—1
Это предположение эквивалентно предположению, что £!|s (т. е. s делится нацело на й!). Пусть 1 = п—к—1, т. е. Ct— это значение на карточке, находящейся непосредственно слева от этой последовательности. Далее, пусть ct<cn_x, и пусть с^ будет минимальным значением для / в интервале п—k^j<n, таким, что Сг<с^ (т. е. с^ есть наименьшее значение справа от Ct, превосходящее CiY В этом случае операция переставить (i, j) оставляет правые к значений в том же самом порядке, и наша теорема о перестановках и их индексах говорит о том, что к\ есть соответствующее приращение s. Она также говорит нам, что когда, кроме А! ]s, мы имеем
s < г
величины от с^ до сп_к_х достигли своих конечных значений. Поэтому я предлагаю усилить наше первоначальное инвариантное отношение Р\ дополнительным отношением Р2
(фиксируя функцию новой переменной 6),
Р2:1<£<я and k\\s and r<^s-\-k\
которое означает, что значения на правых к карточках еще монотонно возрастают, и в то же время левые п—к карточек уже находятся в своих окончательных позициях. Мы выбрали «крупные шаги», которыми мы будем двигаться к нашей цели. Чтобы найти «соответствующую величину» для крупного шага, машина сначала определяет самое маленькое значение к, для которого условие r<s + k\ уже не выполняется (это означает, что с^ при i = n—к—1 еще слишком мало, но слева от него уже все в порядке), и затем увеличивает s на минимальную величину, кратную 6!, которая требуется для того, чтобы условие r<s-V-k\ опять выполнялось; для этого используются «мелкие шаги», равные к\, и одновременно увеличивается с^ за счет карточек, находящихся справа. В следующей программе мы вводим дополнительную переменную kfac, удовлетворяющую условию
переменные i и /,переменные i и /,i<j<n and Cj>Cii<j<n and Cj>CiРЗ: kfac — k\
переменные i и /,
переменные i и /,
i<j<n and Cj>Ci
i<j<n and Cj>Ci
и для второго внутреннего цикла вводим такие, что i = n—к—1 и либо j = n, либо and сj—। ^^^ с%.
s : = 0 [Pl стало истинным};
kfac, к\ = \Д {РЗ тоже стало истинным};
do k ^n-^kfac, к; = к/ас ДкД-Г), ^-}-1 od
{Р2 тоже стало истинным};
do s^r-^ls^r, т. е. по крайней мере одна и, следовательно, по крайней мере две карточки еще не заняли своих окончательных позиций}
do г <^8Д- к/ас-^ kfac, k'-=kfac\k, к — 1 od
{Pl и РЗ остались истинными, но в Р2 последний член заменяется на
s-^kfac ^.r^s-^k-^V} *ffac]-,
j: — ?t — k—y n — k\
do s-^kfac ^.r-^n — k-C j <C ^}
s;=s-\-kfa^; ^перестхзить^, j*y j ;= j-\-l
od (P2 опять восстановлено: Pl and P2 and P3}
od (P стало истинным}
Упражнение
Убедитесь в том, что и следующая, довольно похожая на предыдущую, программа справится с нашим заданием:
5 : = 0; kfac, к : = 1, 1;
do к #= п-^к/ас,к\ = к/ас *(к-\-\\к-у\ od;
do к 1
кfас к \=к /ас]к, к—• 1;
j \ = n — к — 1, п — к\
do s-\-kfac ^r-^>-
s : — s-fkfac\ переставить (Z, у); y: = y-|-l od
od
(Совет: в качестве монотонно убывающей функции Z^O для внешнего цикла возьмите функцию t^r—s + к— 1.) (Конец упражнения.^
9 КОГДА НЕДЕТЕРМИНИРОВАННОСТЬ ОГРАНИЧЕНА
Это опять очень формальная глава. В главе «Характеристика семантики» мы говорили о четырех свойствах, которыми должно обладать условие wp(S, 7?), рассматриваемое как функция 7? для любой конструкции S, чтобы его можно было интерпретировать как слабейшее предусловие, обеспечивающее истинность R. (Для недетерминированных конструкций четвертое свойство было прямым следствием второго.)
В следующей главе «Семантическая характеристика языка программирования» мы дали средства для построения новых преобразователей предикатов, указав при этом, что такое построение должно приводить только к преобразователям предикатов, обладающим вышеупомянутыми свойствами (т. е. чтобы имело смысл продолжать всю эту процедуру). Для каждого основного оператора («пропустить», «отказать» и операторов присваивания) нужно получать подтверждение, что он обладает упомянутыми свойствами; для каждого способа построения новых операторов из составляющих операторов (точка с запятой, конструкции выбора и конструкции повторения) нужно показывать, что полученные составные операторы также обладают этими свойствами, при этом можно исходить из предположения, что составляющие операторы этими свойствами обладают. Мы проверили правильность этого предположения вплоть до закона исключенного чуда для точки с запятой, предоставив проверку остальных свойств читателям в качестве упражнения. Оставим теперь эти свойства; в данной главе мы докажем, что наши конструкции обладают более глубоким свойством, причем на этот раз явно проверим правильность нашего утверждения и для конструкций выбора, и для конструкций повторения. (Способом выполнения этих проверок можно воспользоваться как образцом для проведения пропущенных доказательств.) Свойство, о котором пойдет речь, известно как «свойство непрерывности».
Свойство 5. Для любой конструкции 5 и любой бесконечной последовательности предикатов Со, С\, С^ ..., таких, что
для г^О : Сг=^Сг+1 для всех состояний (1) мы имеем для всех состояний
wp (5, (Е г : г 0 : Cr^ = (Es : s 0 : wp (5, Cs)).(2)
Для операторов «пропустить», «отказать» и для операторов присваивания истинность (2) непосредственно вытекает из их определений, и даже можно обойтись без предположения (1). Для точки с запятой мы получаем
wp(«Sl; 52», (Е г * г ^ 0 : Сг)) =
(по определению семантики точки с запятой)
wp (51, wp (52, (Er : г >0 : Сг))) =
(поскольку предполагается, что 52 обладает свойством 5)
wp(51, (Er': rf 0 : wp (52, 6»)) =
(поскольку предполагается, что 52 обладает свойством 2, так что wp(S2, Cr)=>wp(S2, CrVh и предполагается, что 51 обладает свойством 5)
(Е 0 : wp (51, wp(52, CJ)) —
(по определению семантики точки с запятой)
(Es:s>0: wp(«51; 52», CJ)Ч.Т.Д.
Для конструкции выбора мы разбиваем доказательство истинности (2) на два шага. Сначала легко показать, что левая часть (2) следует из его правой части. Для этого рассмотрим произвольную точку X в пространстве состояний, такую, что правая часть (2) выполняется, т. е. существует неотрицательная величина, скажем s', такая, что в точке X выполняется условие wp(S, GJ. Но так как Cs/ => (Е г : г^О : Сг) и любая конструкция 5 обладает свойством 2, мы приходим к выводу, что в точке X выполняется также
wp (5, (Е г : г ^ 0 : Сг))
Поскольку X было произвольным состоянием, для которого справедлива правая часть (2), значит, справедлива и левая часть (2). В ходе этого рассуждения не использовалось предположение (1), но оно понадобится нам для доказательства того, что правая часть (2) следует из его левой части.
wp(IF, (Er :r>0:Cr^ =
(по определению семантики конструкции выбора)
В В and (А у : 1 < j < п : В^ wp (SAy, (Е г : г 0 : Сг))) = (поскольку предполагается, что SL( обладает свойством 5)
В В and (А у : 1 <; j <. п : Bj => (Е s : s 0 : wp ^SLj, CJ)) (3)
Рассмотрим произвольное состояние X, для которого (3) истинно, и пусть /'— это такое значение /, при котором В (X) =истина; тогда в точке X мы имеем
(Е s : s 0 : wp (SZ,y/, С5))(4)
Исходя из (1) и того факта, что SLj* обладает свойством 2, мы приходим к выводу, что
wp(SA,-, C$)=^wp(5£^, С5Ч1)
и, учитывая (4), мы получаем, что в точке X справедливо также
(Е s': s' ^ 0 : (A s : s ^ s': wp (SLj,, Cs^(5)
Пусть s' —sz(y')—минимальное значение, удовлетворяющее (5). Определим smax как максимальное значение s'(/') по реем значениям /' (таких значений самое большее п, и, следовательно, максимум существует!), для которых Вj' (X) = истина. Тогда из (3) и (5) следует, что в точке X
В В and (А/ :l<y</z:Sy=^wp(SZ,v, Csmn^ =
(по определению семантики конструкции выбора)
wp(IF, Csma^
Но отсюда следует, что для состояния X справедливо и (Es:s>0: wp(IF, CJ'
Но поскольку X было произвольным состоянием, удовлетворяющим (3), то для S=IF доказано, что правая часть (2) следует из его левой части и тем самым что конструкция выбора также обладает свойством 5. Заметим, что существенную роль в доказательстве сыграли предположение (1) и тот факт, что набор охраняемых команд является конечным набором охраняемых команд.
Для доказательства того, что конструкция повторения обладает свойством 5, воспользуемся методом математической индукции.
Основная предпосылка: Но обладает свойством 5.
H^Er;r>0:Cr^ =
(Ег :r^O :Cr) and non ВВ=
(Es:s^>0:Cs and non В В) =
(Es:s>0:/70OЧ. Т. Д
Переход по индукции: из предположения, что Нк и Но обладают свойством 5, следует, что им обладает и Ни+\.
Нкл^Ег;г>0:Сг) =
(из определения /7 мд)
wp(IF, Нк(Е г : г ^0 ; Cr^ or H^Er-.r>0'Q =
(из предположения, что Нк и Но обладают свойством 5)
wp(IF, (ЕН -.г^О-.Н^Сг^ or (Es:s>O://o(Cs)) =
(поскольку конструкция выбора обладает свойством 5 и Нк обладает свойством 2)
(Es:s>0: wp(IF, Н^С^ or (Е s: s> 0 : Но (CJM
(Es:s>0: wp(IF, Hk^Cs^ or HO^CS^ =
(из определения Нк-н)
(Es:s>0:Hk+1(C^Ч. T. Д.
Таким образом, пользуясь методом индукции, мы показали, что все Нк обладают свойством 5, а отсюда
wp(DO, (Е г : г 0 : Сг)) —
(по определению семантики конструкции повторения)
(Е к : к > 0 : Нк (Е г : г > 0 : CJ) =
(поскольку все Нк обладают свойством 5)
(Е к : к > 0 : (Е s : s > 0 : Нк (Cs^ = (поскольку этим выражается существование (к, $)-пары)
(Е $ : 5 > 0 : (Е к : к > 0 : Нк (Cs^ =
(по определению семантики конструкции повторения)
(Es:s>0: wp(DO, Q)Ч. Т. Д.
Свойство 5 приобретает особое значение благодаря семантике конструкции повторения
wp (DO, R) = (Е к : к > 0 : Нк (R^
в соответствии с которой предусловие могло бы быть постусловием для другого оператора. Поскольку
для к ^ 0 : Н к (7?) =Ф(7?) для всех состояний
(это легко доказывается по индукции), условия, нужные для существования свойства 5, удовлетворяются. Мы можем, например, доказать, что во всех начальных состояниях, для которых ВВ выполняется, конструкция
do S^S^D B^SL^ .. .0 Bn^SLn od эквивалентна конструкции
if В. - SL, □ B^ - SL2 □ ... П вп - SLn fi;
do B^SL^B^SL2n UBn->SLnod
(В начальных состояниях, для которых ВВ нс выполняется, первая программа будет действовать как «пропустить», вторая— как «отказать».) Иначе говоря, мы должны доказать, что
(ВВ and wp(DO, R)) = (BB and wp(IF, wp(DO, 7?)))
В В and wp(IF, wp(DO, R)) =
(из-за семантики конструкции повторения)
ВВ and wp (IF, (E к : к > 0 : Hk (R))) =
(поскольку IF обладает свойством 5)
В В and (Es : s> 0 : wp(IF, Hs (/?))) =
(так как (ВВ and Ho(R)) = F)
В В and (Es : s> 0 : wp(IF, HS(R)) or HQ (R)) =
(в силу рекуррентного отношения для Hk(RH
ВВ and (Es:s>0:775+1 (/?)) =
(поскольку (BB and H0(R)) =F)
BB and (Ek:k>0:Hk(R)) = (из-за семантики конструкции повторения)
ВВ and wp(DO, R)Ч. T. Д.
В конце мне бы хотелось привлечь ваше внимание к совсем другому следствию, вытекающему из того факта, что все паши конструкции обладают свойством 5. Мы могли бы попробовать составить программу S: «установить х равным любому положительному целому», обладающую свойствами:
wp(S, х>0) = Т
(As:s>0:wp(S, 0<x<x<s)=F)
Здесь свойство (а) выражает требование, чтобы программа S пришла к завершению с х, равным некоторому положительному числу, а свойство (б) говорит о том, что S есть конструкция неограниченной недетерминированности, т. е. что a priori не существует верхней границы для окончательного значения х. Для такой программы S мы могли бы, однако, получить теперь
7 = wp(S, х>0)
= wp (S, (Е г : г ^ 0 : 0 < х < г))
= (Е s: s ^ 0 : wp (5, 0 <С х < s))
= (Es : s^O : F) = F
Мы пришли к противоречию; это означает, что для конструкции S «установить х равным любому положительному целому» не существует никакой программы!
Следовательно, любые попытки написать программу «установить х равным любому положительному целому» обречены на неудачу. Например, мы могли бы рассмотреть такую программу:
продолжение *.=истина; х: = 1;
do продолжение—^х‘—х-\-1
□ продолжение —> продолжение : =ложь od
Эта программа будет продолжать увеличение х до тех пор, пока действует первая альтернатива; как только произойдет переход ко второй альтернативе, программа немедленно прекратится. По завершении программы х действительно может быть «любым положительным целым» в том смысле, что мы не можем представить себе такое положительное значение X, для которого по завершении программы было бы невозможным отношение х = Х. Но завершение программы вовсе не гарантировано! Мы можем вызвать принудительное завершение: используя некоторую большую положительную константу Л7, мы можем написать:
продолжение *• = истина; х\ — \\
do продолжение'avtdi x<^N —*х;=х3Г\
U продолжение —+ продолжение : —ложь od
но тогда программа больше не обладает свойством (б).
То, что программы «установить х равным любому положи-
тельному целому» не существует, успокаивает нас по двум причинам. Во-первых, если бы такая программа могла существовать, наше определение семантики конструкции повторения следовало бы, мягко выражаясь, подвергнуть сомнению. Если взять
S: do .г>0—>х:=х— 1
□ x<0—* «установить х равным любому положительному целому»
od
наш формализм для конструкции повторения приведет к тому, что wp(S, 7) = (х^0), в то время как большинство моих читателей, я надеюсь, придет к заключению, что в предположении существования программы «установить х равным любому положительному целому» для х<0 завершение программы было бы гарантировано. Но тогда трактовка wp (S, 7) как слабейшего предусловия, гарантирующего завершение, перестает быть справедливой. Однако, когда мы подставляем в качестве программы нашу первую, так сказать, ее реализацию и получаем
5: do х>0 —>х\=х — 1
х <0 продолжение := истича\ х: = Х;
do продолжение х -.=х—Х
продолжение —> продолжение: =ложъ od
od
то равенство wp(S, Т) = (х^0) абсолютно верно, как интуитивно, так и формально.
Вторая причина имеет совсем другую природу. Конструкция неограниченной недетерминированности, тем не менее заведомо приходящая к завершению, могла бы за конечное время производить выбор из бесконечно большого числа возможностей: если бы такую конструкцию можно было описать на нашем языке программирования, то сам по себе этот факт представлял бы непреодолимый барьер на пути к возможности реализации этого языка программирования.
Благодарность. Я хотел бы выразить свою огромную признательность Джону К. Рейнольдсу за то. что он обратил мое внимание на ту роль, которую играет свойство 5, и на то, что несуществование конструкции «установить х равным любому положительному целому» очень важно для интуитивного оправдания семантики конструкции повторения. Естественно, что он не несет никакой ответственности за все вышеизложенное. (Конец благодарности.)
10 РАЗМЫШЛЕНИЯ НА ТЕМУ: «ОБЛАСТЬ ДЕЙСТВИЯ ПЕРЕМЕННЫХ»
Прежде чем приступить к выяснению того, что могло бы или должно было бы означать понятие «область действия переменных», имеет смысл поставить сначала предварительный вопрос, а именно: «Почему мы вообще ввели понятие переменных?». Этот вопрос не так уж бессодержателен, как могло бы показаться тому, кто имел дело только с программированием. Например, когда разрабатывается последовательное соединение логических элементов машины, то довольно часто сначала проектируется автомат с конечным числом состояний, причем различные возможные состояния проектируемого автомата просто перенумеровываются «0, 1, 2, ...» в том порядке, в котором конструктор обнаруживает желательность их включения. В ходе проектирования конструктор строит так называемую «таблицу переходов», которая определяет для каждого состояния переход к следующему состоянию в зависимости от входного символа, поступающего для обработки. Только когда формирование таблицы переходов закончено, конструктор вспоминает, что в его распоряжении есть только двоичные переменные (он называет их триггерами) и что если число состояний оказалось между 33 и 64 (включая границы), то ему потребуется по крайней мере шесть таких переменных. Эти шесть переменных охватывают пространство состояний из 64 точек, и конструктор может по своему усмотрению выбирать способ представления различных состояний своего автомата в виде точек в 64-точечном пространстве состояний. То, что этот выбор сказывается на проектируемой конструкции, становится ясно сразу же, как только мы поймем, что переходы автомата с конечным числом состояний из одного состояния в другое должны представляться как перевычисление комбинаций булевых переменных в зависимости от тех или иных булевых значений. Конструкторам логических схем этот выбор известен как «задача назначения состояний», и благодаря техническим ограничениям или стремлению к оптимизации задача может оказаться запутанной, столь запутанной, что возникает искушение
усомниться в пригодности методологии проектирования, вызывающей такие проблемы. Однако критическое обсуждение методологии проектирования логических схем лежит за пределами моей компетентности и, следовательно, выпадает из круга вопросов, рассматриваемых в данной книге; мы упомянули об этом только для того, чтобы показать, что существуют такие традиции проектирования, для которых введение «переменных» в самом начале разработки проекта не является, по-видимому» естественным.
По двум причинам программист живет в другом мире, чем конструктор логических схем, в особенности конструктор старой школы. Было время, когда технические соображения оказывали очень сильное влияние на стремление конструкторов минимизировать число используемых триггеров, и если при создании механизма с конечным числом состояний, не превышающим 64, использовалось больше шести триггеров, это рассматривалось если не как преступление, то уж по меньшей мере как полный провал. Поскольку триггеры дороги, конструкторы логических схем занимаются проектированием (под) механизмов, у которых число возможных состояний куда более скромно, чем число внутренних состояний у конструкций, предлагаемых здесь вниманию программистов. Программист живет в мире, где биты обходятся дешевле, и отсюда вытекают два следствия: во-первых, его конструкции могут иметь во много раз больше внутренних состояний, во-вторых, «неиспользованная» часть пространства состояний у него может быть большей, чем у конструктора логических схем. Вторая причина, по которой мир программиста отличается от мира конструктора схем, заключается в том, что большее число изменений (групп) битов приводит к большему времени вычислений: самое дешевое средство, которое может в этом случае прописать программист,— оставлять большие области памяти незатронутыми!
Сравнивая мир программиста с миром конструктора схем» который первоначально просто «именует» состояния своей конструкции, сообщая им порядковые номера по мере ввода новых состояний, мы сразу же понимаем, почему программисту хочется с самого начала ввести переменные, которые он рассматривает как декартовы координаты в пространстве состояний стольких измерений, сколько он чувствует необходимым ввести: число различных вводимых состояний так невероятно велико, что несистематизированная терминология сделала бы проектирование совершенно не поддающимся контролю. Можно ли (и если можно, то как?) сохранить «управляемость» проектирования, вводя переменные посредством «номенклатуры»,— это, конечно, решающий вопрос.
Основным средством изменения состояния служит оператор присваивания, устанавливающий (вплоть до следующего присваивания) новое значение одной переменной. В пространстве состояний ему соответствует движение, параллельное одной из осей координат. Его можно «принарядить», введя одновременное присваивание; из небольшого числа операторов присваивания мы можем таким образом сформировать программную компоненту, но число переменных, на которые будет воздействовать эта компонента, оказывается всегда весьма скромным. Чистый результат выполнения такой программной компоненты можно описать в терминах движения в подпространстве, охватываемом несколькими переменными, на которые воздействует эта компонента (если угодно, в терминах проекции на это подпространство). Тогда она становится абсолютно независимой от всех остальных переменных в системе: мы можем «понимать» ее, не принимая во внимание никакие другие переменные, даже не сознавая их существования. Возможность такого разделения, известного также как «факторизация», неудивительна, если учесть, что наше полное пространство состояний было построено прежде всего как декартово «произведение». Важно заметить, что это разделение, так необходимое нам, чтобы мы могли охватить (мысленно) всю программу в целом, становится тем более нужным, чем больше общее число переменных, используемых в программе. Вопрос заключается в том, должно ли такое «разделение» (и если должно, то как?) отразиться более явно на текстах наших программ.
Наши первые «автокодировщики» (позже некоторые из них были неправильно названы «системами автоматического программирования» или — даже хуже — «языками программирования высокого уровня»), конечно, не обладали средствами для реализации таких возможностей. Они были задуманы в то время, когда, по общему мнению, программы были предназначены для того, чтобы командовать машинами, тогда как в наше время все больше и больше людей склоняются к мнению, что это машины предназначены для выполнения программ. В те далекие дни было вполне естественным, что разнообразные свойства машины находили непосредственное отражение в записи программ. Так, например, поскольку машины имели так называемые «команды передачи управления», в программах использовались «операторы перехода». Аналогично, поскольку машины располагали памятью постоянного размера, считалось, что вычисления происходят в пространстве состояний с постоянным числом измерений, т. е. что в вычислениях участвуют значения постоянного набора переменных. Аналогично, поскольку для памяти с произвольной выборкой каж-
дая ячейка памяти равнодоступна, программисту было позволено ссылаться на любую переменную программы в любом месте текста программы.
Все это было хорошо в те далекие дни, когда из-за ограниченных размеров памяти тексты программ были короткими и число различных переменных, используемых в программе, было невелико. Однако с ростом размеров и сложности программ стали возникать сомнения в достоинствах такой однородности. Конечно, с точки зрения гибкости и широкой применимости память с произвольной выборкой является блестящим изобретением, но наступает момент, когда мы должны ясно понять, что любая гибкость, любая общность наших средств требует дисциплины для их использования. Этот момент настал. Давайте прежде всего примемся за «свободный доступ».
В первой версии ФОРТРАНа было два типа переменных, целые переменные и переменные с плавающей запятой, тип переменной определялся — в соответствии с фиксированным •соглашением — по первой букве ее имени, и любое появление имени переменной в каком-либо месте текста программы предполагало постоянное существование переменной с этим именем на протяжении всего времени выполнения программы. На практике это оказалось очень опасным: если в программе, оперирующей с переменной, названной «TEST», встречалась единственная описка, где вместо имени «TEST» было написано «TETS», никакого сигнала об ошибке не выдавалось, просто заводилась другая переменная с именем «TETS». В АЛГОЛе 60 была принята идея так называемых «описаний», и в том, что касается ловли таких глупых описок, она оказалась крайне ценной формой избыточности. Основная идея явного описания переменных заключается в том, что операторы могут ссылаться только на те переменные, о существовании которых было явно объявлено; в этом случае ошибочное обращение к переменной «TETS» обнаруживается автоматически, так как о существовании такой переменной не было объявлено. У описаний АЛГОЛа 60 было и второе назначение: кроме того, что они представляли собой «словарь» имен переменных, допустимых для использования в операторах, они ставили в соответствие каждому введенному имени тип переменной, отменяя тем самым первоначальное соглашение, принятое в ФОРТРАНе, что тип переменной определяется по первой букве ее имени. Поскольку люди все больше и больше стремились использовать мнемонику при выборе имен переменных, такая свобода наименований была высоко оценена.
Еще большим отклонением от ФОРТРАНа была принятая в АЛГОЛе 60 так называемая «концепция блока», которая
использовалась для установления границ так называемой «текстуальной области действия» описаний. «Блок» в АЛГОЛе 60 простирается от открывающей скобки «begin» до соответствующей закрывающей скобки «end», и эта пара скобок отмечает границы текстуального уровня перечня наименований. За открывающей скобкой «begin» следует описание одного или более новых имен (когда имен больше одного, они все должны быть различными) с указанием того, что означает каждое имя, и эти имена в их новом значении считаются «локальными» для данного блока: употребление этих имен в их новом значении должно быть сосредоточено в операторах, находящихся между предшествующим «begin» и соответствующим «end». Если вся наша программа — единственный блок, к каждой переменной можно обратиться из любого места текста, и единственная выгода от использования описаний — эю защита против описок (о чем говорилось в предыдущем абзаце). Блок, однако,— это одна из возможных форм операторов, следовательно, могут встречаться вложенные блоки, и здесь описания дают возможность осуществить некоторую защиту переменных, поскольку переменные, локализованные во внутреннем блоке, недоступны для внешнего блока.
Правила области действия в АЛГОЛе 60 защищают локальные переменные внутреннего блока от внешнего воздействия; в другом направлении, однако, они не обеспечивают никакой защиты. Для внутреннего блока в принципе доступно все, что находится,снаружи, т. е. доступно все, что находится в текстуально объемлющем блоке. Я сказал «в принципе», потому что существует одно исключение,— исключение, которое в то время рассматривалось как крупное изобретение, а теперь, 15 лет спустя, при ближайшем рассмотрении оказывается больше похожим на логическую заплату. Я имею в виду так называемый «повторно описанный идентификатор». Если в заголовке внутреннего блока описано имя — в АЛГОЛе 60 имена называются «идентификаторами»,— которое (случайно) уже было употреблено в объемлющем блоке, тогда внешнее значение этого идентификатора остается текстуально скрытым для внутреннего блока, чье локальное описание того же идентификатора перекрывает его внешнее описание. Идея, лежащая за этим «приоритетом самых внутренних описаний», заключается в том, что составителю внутреннего блока нужно знать только те глобальные имена в окружающем контексте, на которые он действительно ссылается, а все другие глобальные имена не должны ограничивать его свободу выбора своих собственных имен с их локальным значением. Тот факт, что благодаря этому соглашению вписывание внутренних блоков в «разрастающееся» окружение проходило
гладко (например, программы пользователей целиком вставлялись в уже имеющийся внешний блок, содержащий обращение к стандартным библиотечным процедурам), в течение длительного времени рассматривался как вполне достаточное объяснение причин использования соглашения о приоритете внутренних описаний. Оправданию этого соглашения способствовало и то, что незадолго до его принятия было обнаружено, как однопроходный ассемблер может использовать стек для обработки текстуально вложенных различных значений одного и того же имени.
Однако, с точки зрения пользователя, это соглашение не так уж привлекательно, ибо делает переменные, описанные во внешнем блоке, крайне уязвимыми. Если пользователь с ужасом обнаруживает, что значение одной из этих переменных испорчено, причем непонятно, когда и каким образом, он должен в принципе тщательно просмотреть все внутренние блоки, включая и те, которые и вовсе не должны обращаться к этой переменной, чтобы обнаружить то место, где происходит это ошибочное обращение. Если предположить, что программисту не обязательно приходится обращаться из любого места к любой переменной, то лучше было бы предложить ему более явные средства ограничения текстуальной области действия имен, чем более или менее случайное повторное описание.
В качестве первого шага в этом направлении, сохраняющего идею текстуально вложенных контекстов, предлагается следующее: для каждого блока мы постулируем для его уровня (т. е. для его текста, за исключением входящих в него внутренних блоков) текстуальный контекст, т. е. постоянную номенклатуру, в которой все имена имеют уникальное значение. Имена, встречающиеся в текстуальном контексте блока, могут быть либо «глобальными», т. е. доставшимися ему вместе с их значениями по наследству от непосредственного окружения, либо «локальными», т. е. имеющими смысл только для текста данного блока. Предлагается помещать вслед за открывающей скобкой блока перечень имен (с соответствующими разделителями), составляющих вместе текстуальный контекст, например, сначала перечислить все глобальные имена (если таковые имеются), затем все локальные (если они есть); очевидно, что все имена должны быть различными.
Признание. Нужно сказать, что я решился на это предложение после длительных колебаний и поисков альтернативных предложений, которые позволили бы программисту вводить в блок одно или больше локальных имен, не вынуждая его при этом перечислять все используемые в блоке глобальные имена, т. е. таких предложений, которые дали бы ему возмож-
ность указывать (с той же компактностью) изменения в номенклатуре блока АЛГОЛа 60 (без «повторного описания идентификаторов»), т. е. в чистом виде расширять номенклатуру. Это предоставило бы программисту возможность указывать и на сужение номенклатуры, т. е. на ограниченное использование имен из непосредственного окружения, но не вынуждало бы его делать это в обязательном порядке. И некоторое время такая позиция избавления программиста от «отеческого попечения» казалась мне правильной для разработчика языка; я чувствовал также, что такая возможность сделает мои правила области действия более привлекательными, поскольку я опасался, что многие программисты будут возражать против необходимости перечислять все используемые глобальные переменные всякий раз, когда у них возникнет потребность ввести локальную переменную. Так продолжалось до тех пор, пока я страшно не рассердился сам на себя: столько проектов языков загублено из опасения, что они не найдут одобрения, а ведь я знаю только одного программиста, который собирается писать программы на этом языке, и этот программист — я сам! А я в достаточной степени пуританин и могу заставить себя явно указывать унаследованные глобальные переменные. Более того, не только мои внутренние блоки будут ссылаться только на явно унаследованные глобальные переменные, но и среди унаследованных переменных не будут упоминаться глобальные переменные, к которым во внутренних блоках нет обращения. Перечень унаследованных переменных даст полное описание возможных нарушений, вызываемых блоком в пространстве состояний, определенном для окружения блока, не больше и не меньше! Когда я обнаружил, что мое желание «доставить удовольствие своей публике»— желание, я считаю, вполне благородное — повлияло не только на способ представления, но и на существо дела, я испугался, разозлился на себя и устыдился. (Конец признания.)
Кроме имени переменные обладают уникальным свойством иметь некоторое значение, которое может изменяться. Сразу же возникает вопрос: каким будет значение локальной переменной при входе в блок? Были предложены различные ответы. АЛГОЛ 60 постулирует, что при входе в блок значения сто локальных переменных не определены, т. е. для любой попытки узнать их значение до того, как им будет присвоено какое-то значение, результат не определен. Очень распространенной ошибкой является использование в цикле локальных переменных, которым перед входом в цикл не было присвоено начальное значение, поэтому контроль за использованием
неопределенных значений локальных переменных, осуществляемый во время выполнения программы, хотя и обходится дорого, во многих случаях оказывается отнюдь не роскошью. Такой контроль является, конечно, прямой реализацией ответа чистого математика на вопрос о том, что делать с переменной, чье значение не определено, а именно дополнить интервал значений специальным значением, называемым «НЕОПРЕДЕЛЕННОЕ» и при входе в блок придавать это уникальное, специальное значение каждой локальной переменной. Тогда любая попытка использовать переменную с этим уникальным, специальным значением приведет к прекращению работы программы и выдаче сообщения об ошибке.
Однако при ближайшем рассмотрении оказывается, что это простое предложение приводит к логическим проблемам; например, становится невозможным копировать значения любого набора переменных. Чтобы совладать с такой ситуацией, можно, например, проверять, определено значение переменной или нет. Но возможность манипулировать со специальным значением, например со значением «НИЧТО» для указателя, никуда не указывающего, легко приводит к неразберихе и противоречию: так и про двух холостяков можно сказать, что мы имеем дело с двоемужием, поскольку можно считать, что у них одна и та же жена — «никто».
Другим выходом из положения, упраздняющим переменные с неопределенными значениями, было неявное придание переменным некоторого значения при входе в блок, причем не очень специального, а очень распространенного, почти «нейтрального» значения (скажем, «нуль» для всех целых и «истина» для всех булевых переменных). Но это, конечно, всего лишь игра в прятки с самим собой; теперь обнаружить очень распространенную программистскую ошибку стало невозможно, поскольку разнообразные лишенные смысла программы были искусственно превращены в программы дозволенные. (Чтобы смягчить это предложение, было принято соглашение о том, что «нейтральное» начальное значение будет присваиваться переменной только «по умолчанию», т. е. если нет других указаний, однако ясно, что такое соглашение об умолчании — это всего лишь латание дыр.)
Другой попыткой решить задачу обнаружения использования переменных с еще не определенными значениями было проведение (автоматического) анализа текста, позволявшего по крайней мере предупредить программиста, что в каких-то местах переменные будут — или могут — использоваться до того, как им будет присвоено некоторое значение. Можно считать, что в каком-то смысле мое предложение было инспи-
рировано этим подходом. Я предлагаю такую жесткую дисциплину, что
анализ текста становится тривиальным;
ни в одном месте программы, где происходит обращение к переменной, не может существовать неопределенности, касающейся того, было ли присвоено начальное значение этой переменной.
Одним из способов достижения этого была бы обязательная инициализация всех локальных переменных при входе в блок; боюсь, что в сочетании с желанием не использовать в качестве начальных значений «бессмысленные» значения,— желанием, которое подразумевает, что локальные переменные должны вводиться только на той стадии, когда доступны их осмысленные начальные значения,— это приведет к слишком уж глубокой вложенности текстуальных областей действия. К тому же нам пришлось бы «распределять» вход в блок по различным охраняемым командам конструкции выбора всякий раз, когда инициализация должна была бы выполняться одной из охраняемых команд из некоторого набора. Оба эти соображения вынудили меня искать альтернативу, которая требовала бы меньшего количества (и более однозначных) границ блока. Следующее предложение, как мне кажется, отвечает этим требованиям.
Прежде всего мы настаиваем, чтобы при входе в блок перечислялись все имена (как унаследованные, так и личные). Кроме оператора присваивания, который уничтожает текущее значение переменной, присваивая ей новое значение, у нас будут инициализирующие операторы, которые распознаются синтаксически и которые сообщают личной переменной ее первое после входа в блок значение. (Если угодно, мы можем считать, что выполнение инициализирующего оператора совпадает по времени с «порождением» переменной; тогда более раннее упоминание ее имени во входе в блок можно рассматривать как «резервирование идентификатора для переменной».)
Иначе говоря, текстуальная область действия личной (т. е. локальной) переменной блока — это область от открывающего «begin» блока до соответствующего закрывающего «end», за исключением текстов тех внутренних блоков, которые не наследуют эту переменную. Мы предлагаем разделить текстуальную область действия переменной на «пассивную область действия», где обращение к переменной не допускается и ее нельзя рассматривать как координату в локальном пространстве состояний, и «активную область действия», где разрешено обращение к переменной. Пассивная и активная области дей-
ствия переменной всегда будут отделены друг от друга инициализирующим оператором для этой переменной, и эти операторы должны быть размещены таким образом, чтобы независимо от значений предохранителей
после входа в блок и перед соответствующим выходом из блока выполнялся в точности один инициализирующий оператор;
между входом в блок и выполнением инициализирующего оператора не мог выполняться ни один оператор из активной области действия переменной.
Выполнение вышеизложенных требований гарантирует следующая дисциплина: прежде всего мы рассматриваем блок как синтаксическую структуру, где за перечнем его личных имен следует список операторов, отделенных друг от друга точкой с запятой. Такой список операторов должен обладать следующими свойствами:
Он должен содержать единственный оператор, инициализирующий данную личную переменную.
(Б) Операторы, предшествующие инициализирующему оператору (если таковые имеются), находятся в пассивной области действия этой переменной, и если они представляют собой внутренние блоки, то они не имеют права наследовать эту переменную.
Операторы, следующие за инициализирующим оператором (если таковые имеются), находятся в активной области действия переменной, и если они являются внутренними блоками, могут наследовать эту переменную.
(Г) Для самого инициализирующего оператора имеются три возможности:
Это простой инициализирующий оператор.
Это внутренний блок. В этом случае он наследует переменную, о которой идет речь, и список операторов, следующий за перечнем имен внутренней номенклатуры, также обладает свойствами (А), (Б), (В) и (Г).
Это конструкция выбора. В этом случае все списки операторов, следующие за ее предохранителями, обладают свойствами (А), (Б), (В) и (Г).
Для приверженцев НФБ может быть полезным следующий синтаксис (где инициализация относится к одной личной переменной) :
<блок>:: = Ье£т < номенклатур а >; < список инициализирующих операторов> end
<список инициализирующих операторов>::= {<пасспвный оператор>; } Синициализирующий оператор> {; Активный оператор>}’
Синициализирующий оператор>:: = <простой инициализирующий оператор>| begin <номенклатура>; < список инициализирующих операторов> end|
if <прсдохранитель> -^ <список инициализирующих операторов> {П <предохранитель><список инициализирующих операторов>} fi
Замечание. При составлении программ на АЛГОЛе 60 необходимость полностью выписывать скобки «begin» и «end» вызывает неудобство; упраздняя составные операторы АЛГОЛа 60, мы рассчитываем уменьшить потребность в этих скобках. (Конец замечания^
Соответствующая конструкция повторения нс включена в число допустимых форм инициализирующего оператора, поскольку ее включение привело бы к нарушению нашего первого требования: независимо от последовательности действий инициализация должна происходить в точности один раз. Такое ограничение не встречается в языках программирования типа АЛГОЛ 60, где просто (динамически) первое присваивание рассматривается как «инициализация». За большую свободу в языке типа АЛГОЛ нам приходится расплачиваться тем, что для программ, написанных на таком языке, мы не можем в каждом месте программы, где стоит точка с запятой, решать статически (т. с. для всех вычислений), значения каких переменных определены, что видно из следующих примеров па языке АЛГОЛ 60.
Если В — глобальная булева переменная,
begin integer х, у; if В then х; = \ else у: = 2;...
и если К— глобальная целая переменная,
begin integer /, х; for /: = 1 step 1 until TV do x : = 1;...
В первом примере значение х определено только при условии, что В — истина; во втором примере значение х определено только при N^\. Очевидно, что ни одна из этих двух конструкций не может быть переведена на наш язык, и мы должны убедить себя, что сможем жить при этих ограничениях, которые мы сами же себе и навязали, или даже что без таких начал блоков, как в двух приведенных примерах, не только можно обойтись, но в каком-то смысле они просто бесцельны. Интуитивным обоснованием того, что внутри конструкции повторения нам никогда не понадобится выполнять динамически первое присваивание значения той переменной, чье значение при завершении конструкции несущественно, может служить следующее рассуждение: такая переменная должна входить
либо в инвариантное отношение, либо в предохранители, либо и в отношения, и в предохранители, и поэтому лучше было бы придать ей определенное значение и в начальном состоянии конструкции повторения. (В следующей главе мы увидим, что такая позиция заставит нас пересмотреть понятие «переменной».)
Поскольку мы ограничили себя рассмотрением такого языка, что при подходе к каждой точке с запятой статически известно, какие переменные составляют текущее пространство состояний, нам нет нужды сомневаться — если мы пишем наши программы на языке типа АЛГОЛа — в том, является ли данное присваивание значения личной переменной динамически первым с момента входа в блок, и поэтому требование, чтобы синтаксическая форма <простой инициализирующий оператор> отличалась от синтаксической формы обычного оператора присваивания, не является дополнительным ограничением. По зрелом размышлении представляется, что было бы даже нечестно использовать одинаковые обозначения, поскольку эти операторы весьма различны: в то время как инициализирующий оператор является в каком-то смысле созидающим оператором (он порождает новую переменную с начальным значением), обычный оператор присваивания является оператором разрушающим в том смысле, что он уничтожает информацию, а именно предыдущее значение переменной.
Замечание. Нас не должна очень удивлять специальная роль конструкции повторения, так как, если бы мы имели дело с языком, в котором такая конструкция не предусмотрена, нам никогда не понадобилось бы настоящее присваивание; мы могли бы обойтись только инициализацией: без повторения мы могли бы писать программы в терминах констант, которым один раз придается начальное значение. Только с введением повторения у нас возникает необходимость иметь в полном смысле слова переменные, т. е. нам нужно, чтобы на различных шагах процесса повторения при ссылке на одно и то же имя мы получали разные значения. Именно понятие повторения влечет за собой понятие переменной, чье значение можно изменять при помощи оператора присваивания. Именно в процессе повторения нам действительно нужно присваивать значения переменным, существующим вне данного блока, и именно для повторения мы не можем разрешить инициализацию таких переменных. ^Конец замечания.)
Нам еще осталось решить несколько вопросов. Первый — где и как указывать тип личных переменных. Представляются возможными два места: либо — как в АЛГОЛе 60 — в начале
блока, для которого переменная является личной и ее имя встречается где-то в перечне имен, либо в простом инициализирующем операторе (или операторах).
Существуют, однако, две причины, по которым я чувствую необходимость отойти от соглашения, принятого в АЛГОЛе 60, и ограничить функцию перечисления имен в начале блока только определением текстуальной области действия имен, не отягощая этот перечень информацией о типе переменных. Мы дадим обоснование этому отдельно для унаследованных и личных имен.
Если мы потребуем, чтобы каждое унаследованное имя в номенклатуре сопровождалось явным указанием типа, во что нам это обойдется и что мы выиграем? Нам придется расплачиваться не только удлинением текстов, но и тем, что характеристика внешней среды, а именно тип глобальной переменной, распространится по всему тексту. Во всех номенклатурах, содержащих унаследованное имя и рассеянных по всему тексту, мы будем находить копию информации о типе переменной. Все это выглядит особенно непривлекательно в случае необходимости сменить тип такой глобальной переменной на другой, для которого определен тот же набор функций и операций. Кроме того, мы решили, что явное указание унаследованных переменных выполняет функцию защиты от внешнего мира, служит сжатым выражением того, как внутренний блок может взаимодействовать со своим окружением, которое, как предполагается, нам известно. Неоднократное повторение типа не скажет нам в сжатой форме чего-нибудь нового или интересного о внутреннем блоке. Единственный выигрыш, полученный от включения в блок информации о типе глобальной переменной, по-видимому, состоит в том, что при изучении такого блока изолированно от всей программы в нем будет легче разобраться. Но я нс совсем уверен, что нужная нам информация об окружении может быть сведена только к типу глобальных переменных: могут оказаться существенными и предполагаемые отношения между начальными значениями. Если мы хотим понять, что делает какой-либо внутренний блок, нам нужно более полное описание интерфейса, и до тех пор, пока мы не собираемся автоматизировать доказательства правильности программы, более подходящим средством кажутся представленные в удобной форме комментарии. (Читателя, который сейчас восклицает: «А как же быть с независимой компиляцией?» — мы просим понять, что это замечание относится только к реализации очень специфического характера, и предлагаем ему вообразить себя, как это делаю я, находящимся в такой обстановке, когда этот вопрос абсолютно лишен смысла.)
Вернемся теперь к личным переменным. Если тип переменной указывается в перечне имен, каждая активизация блока вызовет инициализацию переменной этого типа. Мы видели, однако, что если инициализирующий оператор является конструкцией выбора, то при каждой активизации блока будет выполняться один из множества простых инициализирующих операторов. Я допускаю, что таким образом переменная может получать начальные значения различных типов, и никто не будет возражать против этого, если только над такими типами определены одни и те же функции и операции. Полезна ли такая свобода выбора типа личной переменной или нет — это довольно трудно обсуждать на данном этапе. Во всяком- случае, было бы неразумно принять сейчас такое соглашение об обозначениях, которое лишало бы нас возможности решить этот вопрос в будущем. (Как заметит внимательный читатель, возможность того, что тип личной переменной может быть различным при разных активизациях блока, для которого эта переменная является личной, служит еще одной причиной для возражений против копирования информации о типах при описании наследуемых переменных, т. с. во внутренних блоках.)
Следующий вопрос, который мы должны решить, — это вопрос о том, нужно ли при входе в блок давать указания о природе воздействия блока на унаследованные переменные. Я напоминаю, что одной из причин включения в начало блока перечня переменных из окружающего контекста, к которым блок обращается, было стремление сократить объем текста, который приходится изучать, когда одна из таких переменных оказывается непостижимым образом испорченной. Если такая переменная при каждой активизации внутреннего блока действует как «глобальная константа», нам хотелось бы прямо в начале блока видеть, что он нс может испортить значение этой переменной — более точно, что он может только выбирать, но нс изменять это значение.
Давайте на минуту сосредоточим внимание на внутреннем блоке (с контекстом «ВНУТРИ»), полностью находящемся в активной области действия переменной, которую он наследует от непосредственно объемлющего его блока (с контекстом «ВНЕ»).
Если на уровне контекста ВНЕ переменная может изменяться, то, как мы видели, должно быть два возможных способа наследования переменной: либо она может также изменяться и в контексте ВНУТРИ, т. е. внутренний блок может содержать операторы присваивания, которые придают этой переменной новые значения, либо внутренний блок получает эту переменную как глобальную константу. Этот факт, однако.
имеет последствия для обоих контекстов. Для контекста ВНЕ он означает, что, когда нам интересно узнать, что может воздействовать на значение переменной, мы можем пропустить текст внутреннего блока. Если, однако, мы хотим читать текст внутреннего блока (более или менее в отрыве от его окружения) и при этом понимать, как он будет выполняться, тогда тот факт, что такая глобальная константа есть константа, а не переменная, приобретает решающее значение. Уже в первом примере главы «Формальное рассмотрение нескольких небольших примеров» мы начинали с такой постановки задачи: «Для фиксированных х и у обеспечить истинность отношения R(m)
(m = x or m = y} and m^>x and m^y»
To что x и у считаются фиксированными, весьма существенно для данной задачи.
Если переменная является константой уже на уровне контекста ВНЕ, тогда она может передаваться внутреннему блоку только одним способом: тоже как константа. Для случая., когда внутренний блок целиком находится в активной области действия наследуемой переменной, все обошлось благополучно.
Случай, когда внутренний блок целиком находится в пассивной области действия переменной, также не представляет никакой трудности: блок нс может наследовать эту переменную из своего окружения.
Нашего внимания заслуживает третий случай, когда блок начинается в пассивной области действия переменной, а кончается в ее активной области действия; это означает не более и не менее как то, что блок обязан инициализировать переменную. Ему передали в наследство то, что мы могли бы назвать «нетронутой переменной», чтобы он выполнил инициализацию. Как для контекста ВНУТРИ, так и для контекста ВНЕ нас может интересовать вопрос о возможности изменения переменной в ее активной области. Когда наследуется нетронутая переменная, эти два вопроса полностью независимы; то обстоятельство, что на текстуальном уровне контекста ВНЕ значение, полученное переменной после инициализации, не будет меняться, не исключает возможность, что сама инициализация (во внутреннем блоке) представляет собой многошаговую операцию, а это так и есть, когда мы рассматриваем инициализацию не как единый, неделимый акт, но — интересуясь деталями — как последовательный процесс формирования начального значения.
После всех этих рассуждений пришла пора быть как можно более точным. Напомним, что мы ввели название «текстуальный контекст» блока для постоянной номенклатуры (в ко-
торой все имена имеют единственное значение), принадлежащей «уровню» блока, т. с. его тексту, за исключением его внутренних блоков. Рассмотрим теперь два блока: внутренний блок (с контекстом, называемым «ВНУТРИ») и объемлющий его внешний блок (с контекстом, называемым «ВНЕ»). По отношению к контексту ВНУТРИ контекст ВНЕ будет выступать как «окружающий контекст».
Имена, используемые в контексте, могут быть двух типов: либо они являются личной собственностью блока, т. е. не имеют отношения к тому, что расположено снаружи блока, либо они «получены по наследству» от окружающего контекста. В этом случае мы должны различать две возможности: имя переменной может передаваться контексту ВНУТРИ от контекста ВНЕ с обязательством или без обязательства инициализировать эту переменную при активизации блока. Мы будем различать все эти три вида переменных при помощи следующих обозначений:
pri(«private» — личная переменная, т. е. переменная,
не имеющая отношения к окружающему контекс
.ту);
vir(«virgin» — нетронутая переменная, т. е. перемен
ная, полученная от окружающего контекста с обязательством инициализировать эту переменную);
gio («global» — глобальная переменная, т. е. переменная, полученная от окружающего контекста без обязательства и без разрешения ее инициализации) .
Переменная может принадлежать более чем одному текстуальному контексту: прежде всего она принадлежит текстуальному контексту того блока, для которого она является личной, и, кроме того, она принадлежит текстуальным контекстам всех внутренних блоков, которые наследуют ее от своих окружающих контекстов. Область действия переменной распространяется на уровни всех блоков, чьим текстуальным контекстам принадлежит эта переменная. Область действия переменной всегда подразделяется на две части: на пассивную область действия и активную область действия, и возможности размещения в тексте инициализирующих операторов для переменных были ограничены таким образом, чтобы гарантировать для каждой переменной своевременное выполнение последовательности действий:
вход в блок, для которого переменная является личной; нуль или более выполнений операторов из ее пассивной области действия;
одна инициализация переменной;
нуль или более выполнений операторов из ее активной области действия;
соответствующий выход из блока.
Когда речь идет о правах и обязанностях блока по отношению к некоторой переменной, не имеет значения, была она перечислена под заголовком «pri» или под заголовком «vir»; в обоих случаях блок обязан инициализировать эту переменную. Единственное различие между этими случаями состоит в том, что переменная, перечисленная под заголовком «pri», не имеет отношения к контексту ВНЕ, в то время как переменная, перечисленная под заголовком «vir», должна встречаться в контексте ВНЕ, и внутренний блок должен начинаться в пассивной области действия этой переменной и кончаться в ее активной области действия. Переменная, перечисленная под заголовком «gio», должна встречаться в контексте ВНЕ, и внутренний блок должен целиком находиться в активной области действия этой переменной.
Кроме внешних характеристик используемых в блоке переменных, которые задаются при помощи «pri», «vir» или «gio», имеются и внутренние характеристики, а именно указания о том, может ли на уровне блока изменяться значение переменной в ее активной области действия. Если значение переменной может изменяться, это указывается при помощи «var» («variable» — переменная); если нет — для указания используется «соп» («constant» — константа). Каждая из трех внешних характеристик может комбинироваться с каждой из двух внутренних характеристик, образуя шесть возможностей: «privar», «pricon», «virvar», «vircon», «glovar» и «glocon». Для шести заголовков в контексте ВНУТРИ мы приводим допустимые заголовки в контексте ВНЕ:
ВНУТРИВНЕ
privar, pricon glovar
не применяется
privar, virvar (только если внутренний блок целиком лежит в активной области действия) или glovar (без ограничений)
glocon
privar, pricon, virvar, vircon (только если внутренний блок целиком лежит в активной области действия) или glovar, glocon (без ограни
virvar, vircon
чений)
privar, pricon, virvar, vircon (только если внутренний блок начинается в пассивной области действия)
Замечание 1. Из изложенного выше следует, что характеристика «соп» в контексте ВНЕ исключает возможность изменения значения этой переменной во внутренних блоках после се инициализации. (Конец замечания /.)
Замечание 2. Характеристика «соп» в контексте ВНЕ не исключает инициализацию переменной во внутреннем блоке, на уровне которого переменная может обладать характеристикой «var»: в контексте ВНЕ мы можем иметь переменную «pricon table», инициализация которой передается внутреннему блоку, в чьем контексте будет создана переменная «vir- var table» и будет сформировано се значение. После того как один раз выполнится такой инициализирующий внутренний блок, значение переменной «table» останется постоянным на протяжении выполнения внешнего блока (и других его внутренних блоков, если таковые имеются). (Конец замечания 2.)
Решение оставшихся вопросов, хотя и достаточно важных (они определяют, какими должны быть наши тексты, насколько легко писать их и читать), в меньшей степени чревато последствиями; эти вопросы касаются только синтаксиса. Мы должны представить синтаксические определения для понятий <номенклатура> и <простой инициализирующий оператор>. Я предлагаю дать определение номенклатуры, аналогичное определению понятия <заголовок блока> в АЛГОЛе 60, — определению, к которому у меня никогда нс возникало никаких претензий.
<номенклатура>:: = <элемент помепклатуры> {;<элемент номснклатуры>}
<элемент номенклатуры> ::= <заголовок номенклатуры> <псременная> {,<псременпая>}
<заголовок номснклатуры>:: = рпуаг | pricon | virvar | vircon | glovar | glocon
Допуская возможность последующего расширения, я предлагаю вывести определение инициализирующего оператора из определения оператора присваивания, добавив после переменной в левой части специальный символ «vir»— признак того, что мы имеем дело с нетронутой переменной, — за которым следует наименование ее типа:
<простой инициализирующий оператор> ::= <переменная> vir <тип> := <выраженис>
Что касается типа, то до сих пор мы ограничивались только целыми и булевыми переменными:
<тип>:: = цел | б^л
Замечание 1. Расширение до одновременной инициализации нескольких переменных и одновременных инициализации и присваивания предоставляется честолюбивому читателю. (Конец замечания /.)
Замечание 2. Выражение (выражения) в правой части должно рассматриваться как еще находящееся в пассивной области действия инициализируемых переменных. (Конец замечания 2.)
Приведем в качестве примера внутренний блок, который инициализирует глобальную целую переменную х, придавая ей значение НОД (X, У), где X и У по отношению к внутреннему блоку являются положительными константами. Блок использует личную переменную, называемую у.
begin glocon X, Y; virvar x; privar y;
x vir цел, у vir цел : = X, Y;
do x > у — > x : = x — у
□ y>x->y : = y — x od
end
Хочу заметить, что мое решение включать указатели «var» или «соп» в номенклатуру было довольно произвольным. С одной стороны, кому-то это указание может показаться излишним. Ну что же, если он захочет отменить это соглашение, он волен это сделать; он может просто сократить заголовки до «pri», «vir» и «gio», опустив все var и соп в моих программах. Если мои программы были правильными в соответствии с моими соглашениями, его версии программ также будут правильными в соответствии с его соглашениями. С другой стороны, у кого-то может появиться желание дать более точную информацию, чем это позволяют предлагаемые мной средства. Он мог бы захотеть, например, указать,
что значение переменной может только возрастать;
что от начального значения глобальной переменной будет зависеть только ее собственное конечное значение, но не значение других переменных;
что из всего набора способов изменения значения глобальной переменной будет использоваться только его подмножество.
Указание типа (1) представляется слишком специальным, поскольку оно применимо только к тем переменным, чьи значения от природы упорядочены. Указание типа (2) тоже ка-
жется слишком специфическим. Как быть, например, с парой глобальных переменных, чьи начальные значения влияют только на собственные конечные значения и на конечные значения друг друга? А вот указание типа (3) могло бы иметь смысл. В этом случае наше указание «соп» можно трактовать как указание о том, что подмножество допустимых модификации пусто.
11 ВЕКТОРНЫЕ ПЕРЕМЕННЫЕ
Меня приучили рассматривать массив в смысле АЛГОЛа 60 как конечное множество одиночных, последовательно перенумерованных переменных, чьи «идентификаторы» могут «вычисляться». Однако такая точка зрения меня больше не удовлетворяет, и объясняется это двумя причинами.
Первая причина—мое неприятие переменных с неопределенными значениями. В предыдущей главе мы разрешили эту проблему, введя для каждой переменной пассивную и активную области действия, разделенные синтаксически распознаваемой инициализацией этой переменной. Но когда мы рассматриваем массив как совокупность переменных (с индексами), это решение уже не годится.
Вторая причина носит комбинаторный характер и является более основательной. В АЛГОЛе 60 составной оператор, который заставляет переменные х и у обменяться своими значениями, нуждается в дополнительной переменной, скажем А,
h:=x;x;= y;y.= h
что выглядит громоздко и даже безобразно по сравнению с одновременным присваиванием
х, у:= у, х
Мы потребовали, чтобы при одновременном присваивании все переменные в левой части были различными: в самом деле, глупо было бы вкладывать в «х, х\ =1, 2» какой-то иной смысл, чем «ошибка». Тем не менее я не сразу решился принять одновременное присваивание из-за проблем, возникающих в случаях, подобных такому:
МД Д [у] := X, у
Следует ли считать такое присваивание допустимым, koi- да i=^h но не когда 1 = р Или, может быть, оно допустимо при
= Г если при этом х=у, так же, как, например, и в случае
Л [Z], Л[У] := Л [/Ь л [Z]
Если мы пойдем по такому пути, то, очевидно, будем громоздить одну логическую заплату на другую. Однако я теперь пришел к заключению, что виновником всего этого является не одновременное присваивание, а понятие переменной с индексом. В аксиоматическом определении оператора присваивания через «подстановку переменной» нельзя позволить себе — как, я полагаю, и во всех областях логики — никакой неопределенности по поводу того, являются ли две переменные одной и той же переменной или нет.
Мораль всего этого такова, что мы должны рассматривать весь массив в целом как одну переменную, так называемую «векторную переменную», в противоположность рассматриваемым до сих пор «скалярным переменным». В дальнейшем я ограничусь векторными переменными, являющимися аналогом одномерных массивов.
Мы можем рассматривать переменную (ее значение) целого типа как целочисленную функцию без аргументов (т. е. как функцию, область определения которой состоит из единственной безымянной точки), которая сохраняет свое значение, если не задано явно его изменение (обычно при помощи присваивания). Вероятно, покажется необычным рассматривать функцию без аргументов, но мы встали на такую точку зрения ради проведения аналогии. Потому что подобным же образом мы можем рассматривать переменную (ее значение) типа «целый массив» как целочисленную функцию с одним аргументом, определенную на множестве целых чисел, — функцию, которая также сохраняет свое значение, пока не будет явно задано его изменение.
Но значение переменной типа «целый массив» не может быть любой целочисленной функцией, определенной на множестве целых чисел, так как я ограничусь рассмотрением таких типов, что для любых двух переменных этого типа мы можем составить алгоритм, определяющий, имеют ли эти переменные одно и то же значение. Если х и у являются скалярными переменными целого типа, тогда этот алгоритм сводится к бу- левому выражению х = у, т. е. обе функции вычисляются в единственной (безымянной) точке их области определения, после чего сравниваются полученные целочисленные значения. Аналогично, если ах и ау — две переменные типа «целый массив», их значения равны тогда и только тогда, когда они как функции имеют одну и ту же область определения и в каждой точке этой области значения этих функций совпадают. Чтобы можно было выполнить все эти сравнения, мы должны
ограничиться рассмотрением конечных областей определения. Более того, эти области, кроме того, что они конечны, должны быть тем или иным способом доступны для алгоритма сравнения значении векторных переменных ах и ау.
Из практических соображений я буду рассматривать только области определения, состоящие из последовательных целых чисел (когда область не пуста). Но и при таком ограничении возникает по крайней мере две возможности. В АЛГОЛе 60 область определения фиксируется при помощи задания в описании нижней и верхней границ для значения индекса, например boolean array Л [1 : 10], В [1 : 5]». Так как тип определяет класс возможных значений для переменной этого типа, мы должны сделать вывод, что в приведенном примере массивы Л и В являются массивами разных типов: Л может иметь 1024 различных значения, а В — только 32.
В АЛГОЛе 60 мы имеем столько различных типов булевых массивов, сколько имеется граничных пар (а так как граничная пара может содержать выражения, то тип определяется, вообще говоря, только при входе в блок). Кроме того, должны существовать и другие средства, позволяющие получить необходимые знания об области определения: не располагая дополнительной информацией, невозможно написать на языке АЛГОЛ 60 внутренний блок, определяющий, равны ли два глобальных булевых массива Л и 5!
В качестве альтернативы предлагается ввести только один тип «целый массив» и только один тип «булев массив» и рассматривать «область определения» как часть (аспект) любого значения такого типа; тогда мы должны уметь извлекать этот аспект из любого такого значения. Пусть ах — векторная переменная; в ее активной области действия я предлагаю извлекать из ее значения границы области определения при помощи двух целочисленных функций, обозначаемых соответственно через «ах.ниг» и «ах.вег», полагая при этом, что функция «ах^» определена для всех целых /, таких, что
ах. наг < / < ах. вег
Кроме этих двух функций, я предлагаю ввести третью (зависимую) функцию «ах.обл», равную числу точек в области определения переменной. Три функции связаны соотношением ах.обл=ах.вег — ах.ниг-\-\ *>0
(Заметим, что даже пустая область определения, обл = 0, занимает свое место на числовой оси; наг и вег по-прежнему определены и удовлетворяют соотношению вег=наг— 1.)
Мы применили здесь новое обозначение, использовав точку в «ах.ниг», «ах.вег» и «ах.обл». Имена, следующие за точкой,
называются именами, подчиненными типу топ переменной, чье имя предшествует точке. За точкой, следующей за именем переменной, могут следовать только имена, подчиненные типу этой переменной, и их значение будет определяться по отношению к этому типу.
Примечание 1. В других контекстах, т. с. не вслед за точкой, те же имена могут использоваться в совершенно другом значении. Мы могли бы ввести векторную переменную с именем «обл» и обращаться в ее активной области действия к функциям «обл.ниг», «обл.вег» и даже «обл.обл»\ Однако подобные выкрутасы не рекомендуются, поэтому я старался найти такие подчиненные имена, которые, хотя и имеют некоторый мнемонический смысл, вряд ли могут быть придуманы самим программистом. {Конец примечания /.)
Примечание 2. Еще одна причина, заставившая нас отказаться от обычного обозначения функции, например «обл{ах^» и т. д., и воспользоваться точкой, заключается в том, что если мы при этом не введем различные наборы имен для этих функций применительно к булевым массивам и целым массивам (что выглядело бы довольно неуклюже), мы будем вынуждены ввести функции от аргумента, который может быть величиной более чем одного типа, чего я хотел бы по мере возможности избегать. {Конец примечания 2.)
Примечание 3. Выражение «ах{1)» используется для обозначения значения функции в точке I. Необходимость в определении аргумента i возникает только тогда, когда нам требуется значение «ах{1)», при этом должно выполняться соотношение
ах.ниг < ^ < ах.вег
Исходя из обозначения функции с использованием точки,, мы могли бы рассматривать «ах{Ц» как сокращение от «ах.знач{^», где «знач»— подчиненное имя, служащее указанием, что нужно вычислить значение функции в точке, определяемой значением последующего аргумента I. Для каждого типа такое сокращение можно вводить только один раз! Заметим, что тип «целый» тоже мог бы иметь подчиненное имя «знач», что позволило бы нам писать немного более явно
х: = у .зная i
вместо обычной и несколько неаккуратной формы х;=у. {Конец примечания 3.)
Введем для удобства две дополнительные функции; для векторной переменной ах они определены при ах.обл>0. Это функции
ах.низ, равная по определению ах(ах.ниг) и
ах. верх, равная ио определению ах (ах. вег)
Они обозначают значения функции соответственно в самой нижней и самой верхней точках области определения. Они нс представляют собой ничего действительно нового и определены в терминах уже известных понятий; при определении семантики операций над значениями массивов не обязательно явно упоминать об их влиянии на значения этих функций.
Как сказано выше, скалярную переменную можно рассматривать как функцию (без аргумента), значение которой можно изменить при помощи присваивания ей нового значения: такое присваивание полностью уничтожает информацию, представлявшую собой «ее старое значение». Нам нужны также операции, позволяющие изменять значение векторной переменной (без таких операций мы всегда имели бы векторную константу!), но присваивание переменной нового значения, т. е. значения, совершенно не связанного с ее старым значением, уже не будет играть такую центральную роль. И не потому, что присваивание значения векторной переменной вызывает какие-то логические трудности — совсем наоборот, мне хочется сказать — просто такая операция была бы неэкономичной. Если область определения велика, объем информации, представляющей собой «значение векторной переменной», может быть весьма значительным, и тогда ни копирование, ни уничтожение информации такого объема нельзя считать «хорошими» операциями.
С другой стороны, для многих задач программирования суть проблемы состоит в том, что значение массива формируется постепенно, т. е. за ряд шагов, каждый из которых можно считать «хорошей» операцией, «хорошей» в том смысле, что новое значение массива можно рассматривать как «приятную» производную от его старого значения. Будут ли такие операции «хорошими» или «приятными», существенно зависит от двух моментов: во-первых, отношение между старым и новым значениями должно поддаваться математическому выражению, иначе эти операции становятся слишком громоздкими для использования; во-вторых, их реализация не должна быть слишком дорогой для того оборудования, которое мы собираемся использовать для выполнения нашей программы. Вопрос о том, в какой мере мы хотим учитывать накладываемые оборудованием ограничения, является вопросом политики, а не
научным вопросом, и поэтому я не чувствую себя обязанным давать развернутое обоснование своим предложениям. Из соображений удобства я позволю себе быть более либеральным, чем многие программисты, в особенности те, кому приходится ежедневно работать на машине, которая создавалась десять или больше лет тому назад; с другой стороны, мне кажется, что, я сознаю возможные технические последствия своих предложений в достаточной степени, так что они остаются если не реалистическими, то по крайней мере не совсем нереальными.
Наша первая операция над значением векторной переменной, скажем ах, нс изменяет ни размера области определения, ни набора значений функции, ни их порядка; она только сдвигает область определения на некоторое число позиций, скажем к, вверх по числовой оси. (Если £<0, происходит сдвиг на к позиций в другом направлении; если к = 0, мы имеем тождественное преобразование, семантически эквивалентное операции «пропустить».^ Обозначим нашу операцию как
ах: сдвинуть (А)
Мы ввели здесь двоеточие «:». Нижняя точка, как обычно, указывает на то, что следующее имя является подчиненным для типа упомянутой слева переменной; верхняя точка служит просто украшением (на что нас вдохновила операция присваивания «: = »), указывающим, что значение упомянутой слева переменной подлежит переопределению.
Сразу же возникает вопрос, можем ли мы дать аксиоматическое определение преобразователя предикатов wp ^«ах : сдвинуть (£*)», R). Ясно, что это должен быть преобразователь предикатов, аналогичный преобразователю из аксиомы о присваивании значения скалярной переменной, но более сложный; то же справедливо и для всех других операций над значением массива, поскольку значение скалярной переменной полностью определяется одним (элементарным) значением, в то время как значение векторной переменной включает в себя и саму область определения, и значение функции для всех точек этой области. Так как значение векторной переменной ах полностью определяется
значением ах.наг значением ах.обл и значением ах\Д для ах .ни? <Д -^ах.ниг-^ах .обл мы можем, по крайней мере в принципе, ограничиться рассмотрением постусловий R, ссылающихся на значение массива только в терминах «ах.ниг», «ах.обл» и «ах^арг)», где арг — любое целочисленное выражение. Для такого постусловия R
соответствующее слабейшее предусловие
wp («ах : сдвинуть (£)», R)
выводится из R при помощи одновременной замены
всех вхождений ах.ниг на (ах.ниг-V (£)) и
всех вхождений (под) выражений вида ах(арг) на ах Царг) — (f)).
Замечание. Если Е само зависит от значения ах, самое безопасное— вычислить сначала для данного R, использовав в нем совсем другое имя, скажем К, wp («ах : сдвинуть (К)», R), а затем подставить вместо К фактическое значение R. Мы уже сталкивались с таким же осложнением, когда применяли аксиому о присваивании к операторам вида х:=х + /(л'). (Конец замечания.)
Приведем несколько примеров. Пусть R— это отношение ах.ниг = 10, тогда
wp («ах: сдвинуть (ах. ниг)», R)=(ax.ниг4- ах. ниг = 10) = (ах.ниг = 5)
Пусть R — это (М:0^л<;ах.обл: ах(ах.ниг-Н)=£); тогда wp («ах : сдвинуть (7)», R) = (K 1: 0<J <ах.обл : ах (ах.ниг-V +?+/-?)=/)=/?
Можно записать это слабейшее предусловие по-другому: wp («ах : сдвинуть (Е)», R) = Rax,_ax
(т. е. копия R, в которой каждое вхождение ах заменено на ах'), где
ах'. ниг=ах. ниг -j- Е
ах'. обл = ах. обл
ах'(арг)=ах(арг — Е) ixjvsy любого значения арг
Из трех этих определений следует, что
ах'. вег = ах.вег-\-Е
ах'. низ = ах. низ
ах' .верх=ах.верх
Замечание. Из этих равенств следует, что, если правая часть не определена, левая часть также не определена. (Конец замечания.)
При определении дальнейших операций мы будем использовать этот последний прием: он позволяет описать в более
ясной форме, как конечное значение ах' зависит от начального значения ах.
Следующие операции расширяют область определения на одну точку либо с верхнего, либо с нижнего ее конца. Значение функции в новой точке задается как параметр, тип которого должен быть так называемым «основным типом» массива, т. е. булевым для булева массива и т. д. Операции задаются в виде
ах : вверх (х) или ах : вниз (х)
Семантическое определение вверх задается соотношением wp («ах : вверх (х)», R) = Rax,_>ax
где
ах' .наг = ах.наг
ах'. вег = ах. вег -{- 1
ах' .обл = ах .обл^ 1
ax' (арг)=хдля арг = ах .вег-VX
= ах(арг) для арг ^ах.вег-^-1
Семантическое определение вниз задается в виде wp («ах : вниз (х)», R) = Rax, _>ах где
ах' .наг = ах .наг — 1
ах'. вег =ах.вег
ах' .обл~ах. обл -j-1
ах' (арг) = х для арг = ах .наг — 1 = ах (арг) для арг^ах.ниг—\
Замечание. Наше предыдущее примечание о том, что и пустая область определения имеет свое место на числовой оси, должно служить гарантией, что операции расширения вверх и вниз определены и для векторной переменной, для которой обл = 0. (Конец замечания.)
Следующие две операции удаляют точку из области определения либо с верхнего, либо с нижнего ее конца. Они определены только в том случае, когда перед их выполнением обл>0; когда они применяются к массиву, для которого обл^О, это вызывает отказ. Эти операции уничтожают информацию в том смысле, что одно из значений функции утрачивается.
Семантическое определение сверху задается соотношением wp («ах : сверху», R^ = (ax.oбл >0 and Rax'^ax} где.
ах' .ниг = ах. ниг
ax' .вег = ах.вег — \
ах' .обл = ах .обл — 1
ах' (арг) не определено для арг = ах.вег ~ах(арг) для арг^ах.вег
Семантическое определение снизу задается соотношением wp («ах : снизу», R) = (ax .обл^>0 and Rax'->axA
где
ах'.наг ^ах.ни2-Г\
ах’ .вег = ах. ее г
ах'.обл =ах.обл—\
ах’ \ар^ не определено для арг^ах.ниг = ах(арг^ для арг^ах.ниг
Введем для удобства еще две операции, семантика которых может быть выражена в терминах уже введенных функций и операций. Это операции
х, ах*, векон, семантически эквивалентная «х : = ах .верх; ах'.сверху»
и
х, ах \ никои, семантически эквивалентная «х*.= ах.низ; ах: снизу»
Обозначение этих операций напоминает обозначение операции одновременного присваивания; имя, следующее за «:», должно быть подчиненным именем для типа переменной, имя которой непосредственно предшествует «:». Очевидно, что другая переменная х должна иметь основной тип векторной переменной ах.
Все описанные выше операции изменяют область определения функции, либо только смещая ее вдоль числовой оси, либо изменяя при этом и се размер. Будут введены еще две операции, которые сохраняют область определения и только воздействуют на одно или два значения функции.
Очень важной является операция, которая не порождает новых значений функции, а только переупорядочивает имею-
щиеся значения. Операция задается в виде ах : переставить (/, у)
Если I и / не лежат оба в области определения функции, операция вызовет отказ. Семантика операции задается соотношением
wp(«6zx : переставить (/, у)», /?) — {ах.лиг <; / < ах. вег and ах. ниг ^ у <. ах. вег and Rax' -+ах) где
ах’ .ниг =ах.ниг ах’.вег — ах. вег ах’ .обл — ах. обл ах’ (арг}—ах(у)
для
арг = 1
= ах (/)
для
арг = у
— ах (ар?)
для
арг^Ы and арг =£j
Замечание. Не требуется, чтобы перед выполнением операции 1=7^/: если i = r значение векторной переменной останется прежним. {Конец замечания?)
Наша последняя операция позволяет изменить одно значение функции; она задается в виде
ах: изм{1, х")
Операция приводит к отказу, если I не лежит в области определения; второй параметр х должен иметь основной тип векторной переменной. Семантика операции задается соотношением
wp {«ах : изм {К x)»,R^ = {ax.Hm^i ^ах.вег and Rax' -*ах^
где
ах' .ниг =ах.ниг
ах'.вег = ах. ее г
ах’ .обл = ах. обл
ах’ {арг^=хдля арг = 1
= ах{арг^ для арг^1
Операция, обозначенная выше как «ах;изм{1, х)», семантически эквивалентна операции, известной тем, кто программирует на ФОРТРАНе и АЛГОЛе 60, как «присваивание зна-
чения переменной с индексом». (Они соответственно написали бы в этом случае «АХ Ц) =Х» и «ax[i]: = х».) Я ввел эту операцию в форме «ах: изм(ц х)», чтобы подчеркнуть, что такая операция оказывает воздействие на массив ах в целом: если две функции с одной и той же областью определения отличаются хотя бы в одной точке этой области, то такие функции различны. Однако «официальное» — или, если вам так больше нравится, «пуританское» — обозначение «ах \ изм(ц х)», даже на мой взгляд, слишком громоздко и слишком непривычно, поэтому я предлагаю (и у меня есть свои слабости!) использовать вместо него обозначение
ах: ^=х
которое и короче, и похоже на столь знакомый нам оператор присваивания, и своим началом «ах:» говорит о том, что эта операция направлена на векторную переменную ах. (Решение использовать форму «ах\^=х» не очень отличается от решения использовать форму «ах^» вместо более помпезной формы «ах.знач (О»-)
Ии одна из предыдущих операций не может быть использована для инициализации. Эти операции могут только изменять значение массива при условии, что он уже обладает каким-то значением; они могут встречаться только в активной области действия векторной переменной. Мы еще не ввели присваивание
ах: = Ьх
-—конструкцию, которая могла бы выполнить инициализацию. Я, однако, никак не могу на это решиться, потому что во всей своей общности «присваивание значения» подразумевает обычно «копирование значения», и если область определения функции Ьх велика, то при существующей технологии такую операцию присваивания нельзя считать «хорошей». Не могу сказать, что я абсолютно несклонен к введению «неприятных» операций, но если уж я это делаю, то мне бы не хотелось, чтобы на бумаге они выглядели как невинные операции. Язык программирования, в котором операция «х\=у» считается «хорошей», а операция «ах: = Ьх» расценивается как «неприятная», ввел бы нас в заблуждение; по крайней мере он ввел бы в заблуждение меня. Выход из этой дилеммы состоит в допущении, что в правой части такого оператора присваивания значения векторной переменной можно только перечислять константы, например в форме
«целое)>[, ^значение основного типаХ)
так что, например, в результате присваивания
Ьх;—К5, истина, истина, ложь, истина)
мы получим
Ьх. ниг = 5Ьх (5) = истина
Ьх. бег =8 Ьх (6) = истина
Ьх.обл = 4: Ьх (7) = ложь
Ьх (8) = истина
Вследствие такого ограничения использование при присваивании или инициализации значения с областью определения большого размера не может пройти незамеченным. Я предполагаю, что в большинстве случаев при инициализации будут использоваться значения, для которых обл = 0.
Теперь несколько заключительных замечаний.
Начнем с вопроса об экономичности операций. Я исхожу из того, что выполнение всех упомянутых в этой главе опера- цнй стоит примерно одинаково. Нужно высказать некоторые предположения о природе этих затрат, ибо без этого задача программирования теряет смысл. Например, вместо ах : (5) = 7
мы могли бы написать внутренний блок
begin glovar ах; privar Ьх;
if ах.ниг <^о and о^ах.вег^-
bx vir цел array : — (0);
do ах.вег ^ Ь^Ьх;вверх^ах.веэх\\ ах; вверху od;
ах : сверху; ах : вверх(1);
do Ьх.обл =^0-^-ах\бверхфх.верх); Ьх; сверху od
fi
end
но я бы забраковал этот внутренний блок, так как это худшая замена, и не только из-за длинного текста, но и из-за неэффективности. Я даже не стану рассматривать «ах: (5) =7» как сокращенную запись этого внутреннего блока.
Я предполагаю, в частности, что стоимость выполнения всех операций, за исключением, может быть, присваивания перечисленных значений, не зависит от значений аргументов, входящих в операцию: стоимость выполнения ах : сдвинуть (k)
не будет зависеть от значения 1г, стоимость выполнения ах: переставить (i, j) не будет зависеть от значений i и / и т. д. При современной технологии такие предположения не так уж нереальны.
Принимая во внимание соображения эффективности, мне следует дать обоснование моей готовности вводить дополнительные избыточные имена; мы могли бы ограничиться функциями ах.ниг и ах.об л, потому что всякий раз, когда нам понадобилась бы функция ах.вег, мы могли бы вместо нее писать
ах. наг -д- ах. обл — 1
но тогда эффективное использование ах.вег обошлось бы «вдвое дороже» эффективного использования ах.ниг, и то, что мы осознаем это, легко может повлиять на наш способ мышления (и, что гораздо хуже, действительно, влияет).
Я говорил об одинаковых затратах. При этом я имею в виду, что они одинаковы по сравнению с затратами на операции, которые мы считаем примитивными. Если бы операции над массивами обходились дороже, чем другие операции, мы могли бы, например, заменить
ах; переставить^, j^
на
f / ^у ах; переставить^, у)П/ = У -^пропустить fi
и сразу перед нами возникла бы необходимость знать точное соотношение затрат и очень хорошо оценить вероятность случая «i^j», чтобы мы могли решить, действительно ли мы получим повышение эффективности, если заменим ах: переставить^, j) конструкцией выбора. Я знаю математиков, которые упиваются такими задачами оптимизации, полагая иногда, что эти задачи занимают центральное место при составлении программ для вычислительных машин. Я с радостью предоставляю им решение этих задач, раз уж они получают от этого такое удовольствие; операции, которые мы предпочитаем считать примитивными, не должны приводить нас к таким конфликтам. Я полагаю, у нас есть более важные проблемы, заслуживающие нашего внимания.
Последнее замечание о реализации. При инициализации векторной переменной ах в принципе можно указывать некоторые ограничения: ограничение снизу для ах.ниг, или ограничение сверху для ах.вег, или оба этих ограничения, или, может быть, только ограничение сверху для ах.обл. Если такие «подсказки компилятору» включаются в программу, то становится возможным использовать все богатство традицион-
ных приемов управления памятью. Я предпочитаю, однако, не считать такие «подсказки компилятору» частью программы. Они могут только удешевить реализацию (и то для какого-то оборудования!); они разрешают (но не заставляют!) при конкретных реализациях выдавать отказ, когда происходит нарушение указанных ограничений.
12 ТЕОРЕМА О ЛИНЕЙНОМ ПРОСМОТРЕ
Обозначим через В булеву функцию, определенную на множестве целых чисел, и рассмотрим следующее начало блока:
begin privar Z;
i vir цел: =0;
do В (Z) -^i: = i-\-\ od;
Для этой конструкции повторения мы можем сформулировать инвариантное отношение
P<iy.(А у :0<j<i :BU^
Доказательство. Так как Р^ истинно, Р(1) становится истинным при инициализации. Далее,
(P^i) and S(Z)) = P(Z+1)
= wp(«Z: = Z+1»,P(Z))Ч.Т.Д.
Без дополнительных знаний о булевой функции В мы не сможем доказать, что конструкция повторения завершится. Если, однако,
(Е j : j > 0 : non В (у))
то, предположив, что конструкция не завершится, мы придем к обычному противоречию: достаточно существования по крайней мере одного значения /^0, такого, что
поп В (у)
истинно, чтобы гарантировать завершение конструкции. Мы знаем, однако, что по завершении
P(Z) and non В^ =
(Ay : 0 < j <Д: В (у)) and non B^
т. е. i является минимальным значением ^0, при котором поп В (г) истинно. Иначе говоря, когда мы ищем минимальное значение (нс меньшее, чем некоторая нижняя граница), удовлетворяющее некоторому критерию, наша программа просматривает значения (начиная с нижней границы) в порядке их возрастания. Такой просмотр превращает первое встретившееся значение, удовлетворяющее данному критерию, в наименьшее из существующих значений, удовлетворяющих данному критерию. Аналогично, когда мы ищем максимальное значение, мы ведем просмотр в порядке убывания значений.
Очень часто эти два оператора имеют форму
х\ = хн^лъ;
do В ^-^х :=F (х) od
Такая программа осуществляет поиск среди последовательности значений, определенной как
Х0 = ХН\'ЛЬ
для 1>0:
х^Е^х-^
такого значения Хг с минимальным значением t(^0), при котором попВ(Хг) истинно. (Более формальные доказательства мы оставляем в качестве упражнения трудолюбивому читателю, имеющему склонность к таким доказательствам.)
На результаты, полученные в этой главе, ссылаются как на «теорему о линейном просмотре». В следующей главе мы будем их использовать в наших рассуждениях, направленных на решение реальных задач. Оказывается, что, несмотря на свою простоту, теорема о линейном просмотре имеет большое эвристическое значение.
13 ЗАДАЧА О СЛЕДУЮЩЕЙ ПЕРЕСТАНОВКЕ
Нам требуется написать внутренний блок, выполняющий действие над глобальной целой векторной переменной, именуемой с, при
с .наг — 1 и с .вег^п
для некоторого постоянного значения п^п>\у Кроме того, известно, что упорядоченная последовательность значений ^(1), с(2), ..., с^ является некоторой перестановкой значений от 1 до /z, но не последней в алфавитном порядке: /z, п—1, ..., 1. Внутренний блок должен преобразовать последовательность i(l), с(2), ..., с(п) в последовательность, являющуюся ее непосредственным следующим по алфавиту преемником. (По поводу понятия «алфавитный порядок» обратитесь к последнему примеру в главе «Формальное рассмотрение нескольких небольших примеров».) Например, при п = 9 последовательность
146295873
должна быть преобразована в последовательность
146297358
Как видно из приведенного примера, ряд значений функции на нижнем конце может остаться неизмененным. Выполняемое преобразование сводится к перестановке значений на верхнем конце, и первое, что мы должны сделать,— это найти место раздела, т. е. определить значение I, такое, что
с (#) сохраняется для 1<&<j
с^к^ изменяется для к = 1
Значение I определяется как максимальное значение i(<n^ такое, что
г (z)<r(Z + 1)
(Это значение не может быть большим, потому что тогда мы были бы вынуждены переставлять значения в первоначально монотонно убывающей последовательности, а такая операция не может привести к получению последовательности, стоящей выше в алфавитном порядке; оно также не должно быть меньшим, так как в этом случае мы никогда бы не получили непосредственного следующего по алфавиту преемника.)
Замечание. Тот факт, что исходная последовательность не является последней! в алфавитном порядке, гарантирует нам существование i (0<t<n), такого, что c(i) <c(t+l). (Конец замечания.)
После того как I найдено, мы должны отыскать в «хвосте», т. е. среди значений сЦ) при i+X^j^n, новое значение с^. Так как мы ищем непосредственного преемника, мы должны найти такое значение / в интервале i+X^j^n, что с(/) есть наименьшее значение, удовлетворяющее условию
с (у) > С (/)
После того как / найдено, мы можем позаботиться о том, чтобы с^ получило свое новое значение, для чего используем операцию «с: переставить (I, /)». Дополнительным преимуществом этой операции является то, что после ее выполнения вся последовательность продолжает оставаться перестановкой чисел от 1 до п; заключает всю цепочку действий переупоря- дочивание значений в хвосте последовательности, чтобы они монотонно возрастали. Общая структура нашей программы представляется теперь в таком виде:
определение Z;
определение у; с : переставить (/, у); сортировка хвоста
(В нашем примере t = 6, / = 8 и окончательный результат будет достигаться через промежуточную последовательность 1 4 6 2 9 7 8 5 3.)
При определении I мы ищем максимальное значение I; теорема о линейном просмотре говорит нам, что мы должны просматривать потенциально возможные значения I в порядке убывания.
При определении / мы ищем минимальное значение c(j); теорема о линейном просмотре говорит нам, что мы должны исследовать значения cU) в порядке их возрастания. Поскольку хвост представляет собой монотонно убывающую функцию
(из-за способа определения ty из этого следует, что мы должны будем вести просмотр значений cV^ в порядке убывания /.
Операция «с : переставить (I, /)» не нарушает монотонности значений функции в хвосте (докажите это!), и поэтому «сортировка хвоста» сводится к расположению значений в обратном порядке. (При выполнении сортировки наша программа пользуется переменными I и р которые уже сослужили свою службу. Заметим, что способ сортировки одинаково применим и для четного, и для нечетного числа значений в хвосте.)
begin glovar г; privar z, у;
i vir цел с .вег — 1; do с (z) > с (i + 1) —> i := i — 1 od;
j vir цел : = с .вег; do с (у) < c (z) —* у : = у — 1 od;
c : nepecmaeumb (z, y);
Z: = Z —|— 1; / • = г .вег;
do i <J Г *. переставить (z, y); z, j : — I1, j — 1 od end
Примечание 1. Мы нигде не использовали тот факт, что все значения с(1), с(2), ..., с^п) различны. Следовательно, можно было бы предположить, что наша программа будет правильно преобразовывать исходную последовательность в следующую по алфавиту последовательность и в том случае, когда некоторые значения входят в последовательность более чем один раз. И это действительно так, поскольку при определении i и j мы формировали наши предохранители путем «механического» отрицания требуемых условий c(z)<c(z'+l) и с^)>с^ соответственно. Однажды в университете я продемонстрировал эту программу аудитории, которая наотрез отказалась принять мои предохранители с включенным в них равенством. Слушатели настаивали на том, чтобы я писал
do r(z)>r?(z4-l)—^. • • и
do с (у) < с^-^. . .
если известно, что все значения в последовательности различны. Они были непоколебимы в своем убеждении, что проверка также и равенства обойдется значительно дороже. Я уступил, но при этом мне было интересно, каким же оборудованием должен располагать университет, чтобы у студентов возникли такие превратные представления. (Конец примечания 1.)
Примечание 2. Программисты, не знакомые с теоремой о линейном просмотре, часто совершают ошибку, представляя
«определение /» в следующем виде:
j vir цел : = / + 1; do с {j -J- 11 > г (/) —* j : = j + 1 od
Они утверждают, что эта программа будет присваивать / значение / + 1 только после того, как будет установлено, что это новое значение допустимо с точки зрения сЦ^>с^. Проанализируйте, почему их версия «определения /» может работать неправильно. (Конец примечания 2.)
Примечание 3. Однажды мне пришлось выполнять неприятную обязанность и экзаменовать студента, который, как мне было известно, обладал весьма ограниченными изобретательными способностями. Так как он был знаком с описанной выше программой, я попросил его написать программу, преобразующую исходную последовательность, про которую известно, что она не является первой в алфавитном порядке, в последовательность, непосредственно предшествующую исходной в алфавитном порядке. Я надеюсь, что время, которое вы потратите на это упражнение, будет значительно меньше часа, который потребовался моему студенту. (Конец примечания 3.)
Примечание 4. Эта программа мне особенно близка, потому что я помню, как я бился над ней в мои студенческие годы, в «каменном веке» программирования, когда программы писались в коде машины (даже без индекс-регистров: в добрых старых традициях фон Неймана программы должны были изменять свои собственные команды, хранящиеся в памяти машины!). И я помню также, что после тщетной борьбы, продолжавшейся более двух часов, я сдался! И это в то время, когда я уже был опытным программистом! Несколько лет назад, когда мне понадобился пример для моей лекции, я вдруг вспомнил эту старую задачу и решил ее без всяких затруднений (и мог даже объяснить ее на следующее утро довольно неопытной аудитории за двадцать минут). То обстоятельство, что теперь можно за двадцать минут объяснить неопытной аудитории решение задачи, которое двадцать лет назад не мог найти опытный программист, свидетельствует о поразительных успехах искусства программирования (до такой степени, что теперь даже трудно поверить, что тогда я не мог решить эту задачу!). (Конец примечания 4.)
Примечание 5. Эквивалентом нашего критерия для I («максимальное значение I ^<п^ такое, что с(t) <с(^4-1)») является критерий «максимальное значение Ц<п^ такое, что (Е j: i<j^n : с(г) <с^^». Однако последний критерий более труден для применения, и тот, кто начнет с него, придет к первому критерию (тем или иным путем). (Конец примечания 5.)
14 ЗАДАЧА О ГОЛЛАНДСКОМ НАЦИОНАЛЬНОМ ФЛАГЕ
Имеется ряд сосудов, перенумерованных от 1 до N. Заданы два условия Р1 и Р2.
Р1: в каждом сосуде лежит один камень.
Р2; каждый камень либо красный, либо белый, либо синий.
Перед сосудами расположена мппи-машина, которую нужно запрограммировать таким образом, чтобы она переложила (если нужно) камни в порядке цветов голландского национального флага, т. е. в сосудах в порядке возрастания их номеров должны располагаться сначала красные камни, затем белые и, наконец, синие. Чтобы иметь возможность выполнить эту программу, мини-машина должна быть снабжена одной командой вывода, которая может воздействовать на расположение камней, а именно
«сосуд: поменять (Z, у)» для 1^Z<:^ и l^y^Af:
для i = j\ камни остаются на прежних местах, для i^j*. две управляемые мини-машиной «руки» вынимают камни из сосудов с номерами I и j соответственно и затем меняют их местами. (Эта операция сохраняет отношения Р\ и Р2 инвариантными.)
и одной командой ввода, которая может проверять цвет камня, а именно
«сосуд (Z)» для l^Z^A/’:
когда программа задает вычисление этой функции типа «цвет», подвижный «глаз» направляется на сосуд с номером Z и передает мини-машине в качестве значения функции цвет (т. е. красный, белый или синий) камня, лежащего в сосуде, содержимое которого только что проверял «глаз».
Константа N — это глобальная константа из того контекста, в который наша программа должна входить как внутрен-
ний блок. Наша программа, однако, должна отвечать трем специальным требованиям:
Она должна уметь справляться со всеми возможными формами «вырождения», т. е. отсутствия какого-то цвета: сосуды могут быть наполнены камнями только двух цветов, или только одного цвета, или вообще никакого цвета (если ^ = 0).
Л1пни-машина обладает очень малой памятью по сравнению с теми значениями N, с которыми она должна уметь справляться, поэтому мы не разрешаем вводить какие бы то ни было массивы, а только фиксированное число переменных целого типа и/или переменных типа «цвет». (Под переменными целого типа мы здесь будем понимать переменные, которые не могут принимать много больше, чем N различных значений.)
Программа может направлять «глаз» на каждый камень не более одного раза (предполагается, что на операцию ввода затрачивается столько времени, что смотреть дважды на один и тот же камень означает вызвать недопустимую трату времени).
Кроме того, среди программ одинаковой степени сложности предпочтительней та, при выполнении которой происходит (в среднем) меньше перекладываний камней.
Хотя все наши камни могут быть только трех различных цветов, тот факт, что наш «глаз» может проверять только один камень за один «взгляд», в сочетании с требованием (3) приводит к тому, что в процессе переупорядочивания камней мы должны различать камни четырех различных категорий, а именно: «оказавшийся красным» (ОК), «оказавшийся белым» (ОБ), «оказавшийся синим» (ОС) и «еще не проверенный» (X). Требование (2) исключает возможность того, что камни, относящиеся к этим различным категориям, могут лежать вперемежку: мы не можем хранить в памяти мини-машины информацию о том, «что есть что». Единственный выход — разделить все сосуды на фиксированное число (может быть, пустых) зон, состоящих из последовательно перенумерованных сосудов, причем каждая зона отводится для камней определенной категории. Поскольку таких зон может быть минимум четыре, попробуем для начала ввести ровно четыре зоны. Но в каком порядке? Я обнаружил, что многие программисты склонны, нс особенно задумываясь, принять порядок «ОК», «ОБ», «ОС», «X», хотя такое решение явно опрометчиво. Как только кому-нибудь покажется привлекательным расположить зону «ОК» р нижнем конце, соображения симметрии сделают таким же прг^ чательным расположение зоны «ОС» в верхнем конце. Придерживаясь нашего прежнего решения ввести ровно четыре зоны, мы, таким образом, приходим к заключе-
нию, что зоны «ОБ» и «X» должны находиться в середине в том или ином порядке (убедитесь, что в этом случае порядок несуществен!), например,
«ОК», «X», «ОБ», «ОС»
Раз мы выбрали описанную выше «общую ситуацию», наша задача в сущности решена, так как мы имеем общую ситуацию, для которой как начальное состояние (все сосуды в зоне «X»), так и конечное состояние (зона «X» пуста) являются особыми случаями! Мы можем принять эти условия, и тогда конструкция повторения должна уменьшать размер зоны «X», сохраняя при этом общую ситуацию. В пашей мини-машине нам понадобятся три переменные целого типа, например «к», «б» и «с», по которым можно будет следить за границами зон:
\^1<^к; i-й сосуд находится в зоне «ОК» (число сосудов к-\ >0)
к<.1<.б; i-u сосуд находится в зоне «X» (число сосудов б-к^\>^
6<J^f- i-й сосуд находится в зоне «ОБ» (число сосудов с-б>0)
c<j<.N: i-й сосуд находится в зоне «ОС» (число сосудов N-c>^
Обеспечить истинность отношения Р, которое должно сохраняться инвариантным, значит придать трем этим переменным такие начальные значения, чтобы «все сосуды были в зоне «X» », и наша программа могла бы иметь следующую структуру:
begin glovar сосуд; glocon N; privar к, б, с;
к vir цел, б vir цел, с vir цел; = 1, N, N;
{Р стало истинным}
do б^ к-> «уменьшить число сосудов в зоне «X» при инвариантности Р» od
end
Сразу же возникает вопрос: на сколько охраняемый оператор должен уменьшать число сосудов в зоне «X»? Есть три довода — и, как заметит читатель, довольно общего характе-
pa — в защиту того, чтобы сначала проверить возможность «уменьшить число сосудов в зоне «X» на 1». Эти доводы таковы:
Уменьшения на 1 достаточно.
Так как мы выбрали в качестве предохранителя условие «б^к», мы можем гарантировать, что в зоне «X» находится по крайней мере один сосуд. Чтобы гарантировать наличие двух сосудов, нам понадобился бы предохранитель «б>к».
Проверка одного камня даст нам три различных результата, проверяя два камня, мы получим уже девять различных случаев; такое размножение числа подлежащих рассмотрению случаев следует расценивать в принципе как высокую плату за тот выигрыш, который мы можем получить, уменьшая зону «X» больше чем на 1.
Следующий вопрос: на какой из еще не проверенных камней мы будем смотреть? Вопрос не такой уж праздный, поскольку за это время в расположении зон «ОК», «X», «ОБ», «ОС» возникла асимметрия. Ни один опытный программист не станет проверять произвольный камень, а обратится к камням в нижнем или верхнем концах. При том, что все три цвета равновероятны, проверяя камень в к-м сосуде, мы получим число перекладываний, равное (0 + 1 + 2)/3 = 1, проверяя же камень в б-м сосуде, мы получим только (1 + 0 + 1)/3 = 2/3, что и определяет наш выбор. Таким образом, мы приходим к следующей! программе:
begin glovar сосуд; glocon N; privar к, б, с;
к vir цел^ б vir цел, с vir цел : = 1, N, N;
do б>-к-^
begin glovar сос^д, к, б, с; pricon цв;
цв vir цвет : = сосуд (б);
if цв = красныйсосудпо менять (к, бу к'.= к+\
Q цв = белый — б: = б— 1
□ цв —синий —> сосуд : поменять (б, с); б, с : = 6—1, с — 1
fi
end od end
Замечание. Программа разумна в том смысле, что она приведет к отказу, если ей придется работать с неправильными данными, например если один из камней окажется зеленым. (Конец замечания.)
В том случае, когда все камни красные и не требуется никаких перекладывании, наша программа потребует выполнения N перекладываний, и, как сознательные программисты, мы должны провести исследования, чтобы понять, насколько более сложным окажется более утонченное решение: может быть, отклонив его раньше, мы проявили трусость. (В качестве общего подхода я бы рекомендовал не браться за более утонченное решение, пока не испробовано более простое; такой подход позволяет нам получить нс только работающую программу, но и довольно недорогое указание того, с чем должна конкурировать такая утонченная программа.) Я всегда считал приведенное выше решение абсолютно удовлетворительным и до сих пор никогда не рассматривал более сложного решения. Возьмемся же за него теперь!
Можно расширить проверку одного камня до «проверки одного или двух» или до «проверки стольких камней, сколько нам будет удобно расположить». Для случая «все камни красные» последний вариант оказывается в чем-то предпочтительнее. Прежде чем проверять непроверенный камень в верхнем конце, мы могли бы попытаться передвинуть границу, заданную к, насколько возможно ближе к верхнему концу, нс перекладывая при этом камни, потому что было бы обидно заменять один красный камень, находящийся в совершенно правильной позиции, другим красным камнем. Внешняя конструкция повторения могла бы тогда иметь следующее начало: do б^к —
begin glovar сосуд, к, б, с; privar цвк;
цбк vir цбет\ = сосуд (тс):
do цвк=красный and к < б-^ к : = к-р-1; цвк *. = сосуд ^ od;
Внутренняя конструкция повторения прекращает работу либо если проверены все камни (№б), либо если мы наткнулись на некрасный камень. Случай к=б, где цвк может иметь одно из трех различных значений, сводится к конструкции выбора предыдущей программы, правда, благодаря тому, что оператор «сосуд \ поменять (к, б)» можно опустить, поскольку к и б равны. Случай к<б означает, что цвк^красный; теперь мы просто должны проверить другой камень, иначе наше решение сведется к тому решению, при котором всегда проверяется камень в нижнем конце зоны «X», а мы знаем, что такое решение дает в среднем больше перекладываний, чем наш первый вариант. Поскольку к<б, другой непроверенный камень действительно есть, и первым кандидатом на проверку является, очевидно, камень в б-м сосуде.
И снова при цвк=белый кажется обидным менять местами камень в ^м сосуде и белый камень в б-м сосуде, поэтому для
случая к<б предпочтительнее ввести новый внутренний блок begin glovar сосуд, к, б, с; glocon цвк; privar цвб;
цвб vir цвет: —сосуд {б};
do цвб=белый and б^>к-^ 1 —*б\= б— 1; цвб’—сосуд (5) od;
Теперь мы имеем два возможных значения для цвк— белый или синий, три возможных значения для цвб — красный, белый или синий и набор непроверенных камней, может быть, пустой. На какое-то мгновение я испугался, что мне придется разбирать 12 различных случаев! Но после длительного обдумывания (и после одного фальстарта) я понял, что сначала надо поместить подлежащий сейчас проверке камень в б-й сосуд и проследить за тем, чтобы во всех трех случаях камень, первоначально находившийся в к-м сосуде, остался в (новом) б-м сосуде. Тогда можно объединить три возможности и составить отдельную часть программы, выполняющую единообразные действия над вторым камнем, цвет которого определяется переменной цвк.
if цвб=красный-^ сосуд : по менять {к, б); к:=к + 1 ^цвб= белый —>б'.=б— 1
^цвб= синий —> сосуд : поменять (б, с\, б, с’.=б—\, с— 1; сосуд : поменять (к, б)
Н;
if цвк = белый —> б : = б— 1
Q цвк = синий —♦ сосуд : поменять {б, г); б, с\ — б~\, с — 1
fi
Замечание 1. Мы получили приятный результат, состоящий в том, что соединение двух конструкций выбора обеспечивает работу программы для 2x3 комбинаций цветов! (Конец замечания 1.)
Замечание 2. Убедитесь, что случай цвб = белый был разобран правильно, поскольку в этом случае первоначально б = к+1. {Конец замечания 2.)
Примечание. Из педагогических соображений я немного жалею, что случай «два проверенных камня лежат не в тех сосудах» не оказался более сложным; может быть, я должен был подавить искушение выполнить даже эту кропотливую работу как можно лучше. (Конец примечания.)
Я предоставляю читателю довести до конца нашу вторую программу, если ему еще не расхотелось (я надеюсь, что рас-
хотелось!); думаю, что мораль уже ясна. Я довел анализ возможных случаев до этой стадии, чтобы стало ясно, что такого анализа, известного также под названием «комбинаторный взрыв», нужно всегда избегать, как чумы. Он удлиняет текст программы и может легко привести к снижению эффективности! Такой анализ всегда ухудшает надежность программы, потому что взваливает на плечи бедного программиста такую тяжесть, которой тот обычно не выдерживает (не в последнюю очередь из-за того, что работа становится очень нудной). Читатель может рассматривать вышесказанное как намек руководству на то, что не стоит оценивать труд программистов по числу строк текста, которые они производят за месяц; я бы скорее заставил их оплачивать из своего кармана использованные ими перфокарты.
Я часто разбирал эту задачу со студентами, располагая зоны в порядке «ОК», «X», «ОБ», «ОС» или в порядке «ОК», «ОБ», «X», «ОС». Когда я спрашивал, какой камень нужно проверять, они всегда предлагали «самый левый». У меня возникла мысль, что такое предпочтение объясняется нашей привычкой читать слева направо. Позже я столкнулся со студентами, которые предложили начинать проверку с самого правого камня: один был специалистом по машинной математике из Израиля, другой — сирийцем по происхождению. Есть что-то пугающее в том, какими неисповедимыми путями наши привычки влияют на наш способ мышления!
На этом я заканчиваю рассмотрение задачи о голландском национальном флаге,— задачи, которой я обязан Фейену.
15 ОБНОВЛЕНИЕ ПОСЛЕДОВАТЕЛЬНОГО ФАЙЛА
Когда появились охраняемые команды и о них заговорили, один студент, занимавшийся главным образом решением коммерческих задач на машине, выразил сомнение, имеет ли наш подход какое-то значение вне (как он выразился) области научных или технических прикладных задач. В подтверждение своих сомнений он ссылался на задачу, которую считал типичной коммерческой прикладной задачей, а именно на обновление последовательного файла. (Более точная формулировка задачи будет приведена ниже.) Он показал нам блок-схему программы, предназначавшейся для решения этой задачи, но стрелки, которые вели от одного квадратика к другому, были так беспорядочно расположены, что мы оба решили не рассматривать это решение (если оно было решением, что мы так и не смогли выяснить!). С некоторой гордостью он показал нам таблицу решений — его таблицу, — которая, как он считал, могла решить задачу, но размер этой таблицы ужаснул нас. Однако, поскольку нам была брошена перчатка, нам не оставалось ничего другого, как поднять ее. Наши первые два варианта, хотя и более аккуратные чем то, что мы видели, все же не удовлетворили Фейена, чье решение я приведу в этой главе. Оказалось, что наши первые попытки были относительно безуспешными, потому что задача была представлена таким образом, что мы поверили, будто этот крепкий орешек будет трудно расколоть. Но это совсем не так. Я включил задачу об обновлении файла в настоящую книгу по трем причинам. Во-первых, это дает мне возможность опубликовать изящное решение, полученное Фейеном для задачи общего типа, увешенной гроздьями весьма неаккуратных решений. Во- вторых, потому что решение было найдено благодаря тем же методам, которые привели к элегантному решению задачи о голландском национальном флаге. Наконец, эта задача поз-
воляет нам рассеять некоторые сомнения, вызванные тем, что коммерческие программы являются чем-то особенным. (Если уж говорить о чем-то особом, то, скорее, о характере коммерческих программистов... )
Имеется файл, т. е. упорядоченная последовательность записей, или, более точно, значений типа «запись». Если х— переменная (ее значение) типа запись, тогда определены булева функция х.норм и целая функция х.ключ, такие, что для некоторой константы инф
х.норм =4»[х.ключ <2 инф)
(поп х.норм)=^ [х.ключ = инф)
Данный файл записей называется «старфайл», и записи в нем располагаются в порядке монотонного возрастания значений их «ключей»; только для последней записи старого файла «х.норм» есть ложь.
Имеется, кроме того, еще один файл, называемый «измен- файл» и представляющий собой упорядоченную последовательность изменений, или более точно, значений типа «изменение». Если у — переменная (ее значение) типа изменение, определены булева функция у.норм и целая функция у.ключ, такие, что для той же константы инф
у.норм, ^[у.ключ х^инф)
(поп у .норм) =^[у .ключ = инф)
Изменения в файле располагаются в порядке монотонного неубывания значений их «ключей», только последнее изменение не является нормальным, и для него у.ключ = инф. Если у.норм есть истина, определены еще три булевы функции, а именно «у.обн», «у.искл» и «у.вкл», причем эти функции таковы, что всегда в точности одна из них есть истина.
Далее, для переменной х типа запись и переменной у типа изменение, такой, что у.норм есть истина, определены три операции, изменяющие х:
х: обновить [у) определена только в случае истинности х.норм and у.обн and [х.ключ=у.ключ); по завершении операции сохраняется истинность х.норм and (х.ключ = у.ключ)
х : исключить (у) определена только в случае истинности х.норм and у.искл and [х. ключ — =у .ключ); по завершении операции х.норм = ложь
х \ включить ^ определена только в случае истинности поп х.норм and у.вкл; по завершении операции становится истинным х.норм and (х. ключ = у. ключ]
Для переменной х типа запись имеется дополнительная операция «х : анорм», в результате которой х.норм = ложь.
Программа должна сгенерировать файл записей, называемый «новфайл», окончательное значение которого зависит от начального значения исходных файлов «старфайл» и «измен- файл» следующим образом. Мы можем объединить записи старого файла и изменения файла изменений, располагая их в порядке неубывания ключей таким образом, что если данное значение ключа встречается и в старом файле (один раз!), и в файле изменений (может быть, больше одного раза), то в объединенной последовательности запись с этим ключом предшествует изменению (изменениям) с тем же ключом. В процессе объединения не должен нарушаться внутренний порядок следования изменений с одним и тем же ключом. Объединенная последовательность подвергается описанному ниже преобразованию до тех пор, пока в ней не останется ни одного изменения; оставшаяся последовательность, состоящая только из записей, и есть окончательное значение нового файла. Преобразование выполняется следующим образом:
Пусть у — первое изменение в объединенной последовательности и х — непосредственно предшествующая ему запись (если она есть), тогда
если функция у.обн истинна и имеется предшествующая у запись х, такая, что х.ключ^у.ключ, то запись модифицируется при помощи операции х: обновить (у) и у удаляется из последовательности;
если функция у.искл истинна и имеется предшествующая у запись хэ такая, что х.ключ = у.ключ, то и х, и у удаляются из последовательности;
если функция у.вкл истинна и нет предшествующей у записи х, такой, что х.ключ = у.ключ, то у заменяется записью, определенной операцией включения записи;
если поп у.норм есть истина, изменение у удаляется из последовательности;
если первое изменение не удовлетворяет ни одному из перечисленных четырех критериев, оно удаляется из последовательности по операции «сообщение об ошибке», которую мы не будем здесь описывать более подробно.
Представим модель программы в виде внутреннего блока, оперирующего с тремя глобальными массивами:
запись array старфайл,
к которому можно обращаться
изменение array изменфайл,
только по операции «никои» и который в конце концов становится пустым (т. е. старфайл. обл = 0^
к которому можно обращаться
запись array новфайл,
только по операции «никои» и который в конце концов становится пустым (т. е. изменфайА. о6а = 0)
к которому можно обращаться
только по операции «вверх» и который первоначально пуст (т. е. новфайл. обл = 0)
Мы видим, что задача усложняется, если объединенная последовательность содержит последовательность изменений с одинаковыми ключами и характеризующие изменения функции со значением «истина» следуют друг за другом, например, в таком порядке:
вКЛ, вКА, Обн, UCKA, UCKA, Оби, вКЛ, обн, UCKA
В этом случае второе «вкл», второе «иска» и второе «обн» приведут к сообщению об ошибке и вся последовательность ничего не дает для получения окончательного значения нового файла; за такой последовательностью может следовать и может не следовать нормальная запись.
Поскольку длины файлов неизвестны, наша программа будет состоять из вступительной части, одной или нескольких конструкций повторения и, может быть, части (не повторяемой), которая завершает новый файл записью, не являющейся нормальной. В общем случае сгенерировать новую запись для нового файла можно только при условии, что мы уже «просмотрели» запись со следующим по порядку ключом из старого файла и изменение со следующим по порядку ключом из файла изменений. (Подумайте о возможности извлечения следующей записи только из файла изменений.) В связи с этим мы вводим переменную х типа «запись» и переменную у типа «изменение», значением которых будут соответственно запись и изменение с этими следующими по порядку ключами; после создания записи для нового файла «запись х и следующие за ней оставшиеся записи старого файла» означают еще необработанные записи, а «изменение у и следующие за ним оставшиеся изменения файла изменений» означают еще необработанные изменения. Мы пользуемся этим, когда к новому файлу добавляется новая нормальная запись; послед-
нюю, не являющуюся нормальной запись добавит к файлу заключительная часть программы. Поскольку неизвестно, сколько будет сгенерировано новых записей, пока не исчерпается один из исходных файлов, предлагается такая общая структура нашей программы:
begin glovar старфайл, изменфайл, новфайл; privar х, у; х vir запись, старфайл никоя;
у vr изменение, изменфайл : никоя;
do х.норм or у. норм-^
«обработать по крайней мере одну запись или одно изменение, такие, чтобы их обработка обеспечивала продвижение в создании нового файла» od;
«добавить к новому файлу запись, не являющуюся нормальной»
end
При составлении конструкций повторения решающее значение имеет синхронизация прогресса в вычислениях с продвижением по циклу, т. е. определение того, какой объем работы будет выполнен каждым списком охраняемых операторов, выбираемым для исполнения. Если мы в этом примере будем определять конец цикла по концу старого файла или файла изменений, могут возникнуть неприятности, так как один из файлов может уже быть исчерпанным; аналогичные неприятности возникают и в том случае, когда мы считаем концом цикла завершение генерации записей для нового файла, потому что, хотя ни один из исходных файлов не исчерпан, мы могли уже сгенерировать последнюю нормальную запись для нового файла. Эта проблема должна решаться тем же способом, как и для задачи о голландском национальном флаге, когда нужно было определить, сколько камней подлежит проверке: охраняемый оператор будет выполнять в точности такой объем работы — не больше и не меньше, — сколько нужно и можно сделать, когда предохранитель имеет значение «истина».
Если наш предохранитель х.норм or у.норм есть истина, мы можем сделать единственное заключение, а именно что необработанные части исходных файлов содержат запись и (пли) изменения по крайней мере для одного значения ключа, такого, что ключ<инф: следовательно, список охраняемых операторов будет осуществлять обработку всех записей и (или) изменений с этим значением ключа!
Первый оператор — конструкция выбора — определяет это значение ключа и присваивает его локальной константе «те- ключ» (сокращение от «текущий ключ»). Кроме того, он пни-
диализирует переменную «хх» типа запись; значение именно этой переменной будет выбираться в качестве новой записи, если потребуется сформировать такую запись. Если текущий ключ извлекается из старого файла, обрабатывается единственная запись с этим ключом, в противном случае старый файл останется нетронутым.
Второй оператор — конструкция повторения — обрабатывает все изменения с этим значением ключа; поскольку обработка изменений целиком выполняется этой конструкцией повторения, в ней учтены различия между разнообразными видами изменений.
Третий оператор — конструкция выбора — может добавлять новую запись к новому файлу.
begin glovar старфайл, изменфайл, новфайл; privar х, у;
х vir запись, старфайл: никоя;
у vir изменение, изменфайл : никоя;
do х.норм or у.норм-+
begin glovar старфайл, изменфайл, новфайл, х, у; pricon теключ; privar хх;
if х.ключ <,у .к 1юч —> теключ vir цел : = х.ключ;
хх vir запись :=х; х, старфайл : никоя
□ х .ключ^ у .ключ —> теключ vir цел : = у. ключ; хх vir запись : анорм
fi {теключ <^инф and теключ <^х.ключ and теключу. ключ};
do у .ключ = теключ—> {у .ключ <^инф, т. е. у .норм} if у.обн and хх.норм—*хх*. обновить (у) J у.искл and хх.норм—^хх: исключить (у) О у.вкл and non хх.норм—> хх : включить^ П у .вкл — хх. норм—* сообщение об ошибке fi; у, изменфайл‘.никоя
od {теключ <х.ключ and теключ <у .ключ};
if хх .нормновфайл: вверх (хх) □ поп хх.норм —> пропустить
fi'
end
od; новфайл :вверх(х) {новый файл замыкается не являющейся нормальной записью}
end
Примечание 1. Избранный нами способ представления этой задачи предусматривал в качестве одного из возможных значений переменной х типа запись такое значение, при котором х.норм=ложь; при постановке задачи это значение использовалось в качестве маркера конца старого файла. Благодаря
такому соглашению оказалось возможным ограничить обращение к старому файлу операцией «никоя»; если бы, однако, нам была доступна также функция «старфайл. обл», такое использование в качестве маркера конца файла записи, не являющейся нормальной, стало бы ненужным и вся задача могла бы формулироваться в терминах переменных типа «нормальная запись». Тогда, чтобы составить такую программу, нам понадобились бы языковые средства, позволяющие ввести тип «запись» в том смысле, в каком он используется в нашем решении. Языки программирования, лишенные таких средств, вынуждают своих пользователей удовлетворять эту потребность, вводя, например, пару переменных, скажем переменную хх типа «нормальная запись» и булеву переменную «ххнорм», и затем в явном виде употреблять в программе эти переменные; тогда ххнорм указывает, является ли значение хх осмысленным. Но такой «условный смысл» несовместим с нашей идеей явного присвоения переменным осмысленных начальных значений. В случае х.ключ^у.ключ как хх, так и ххнорм могут получить осмысленные начальные значения, в случае же х.ключ>у.ключ, действительно имеет смысл только
ххнорм vir бул'-= ложь
Но тогда наши соглашения заставляют нас присвоить переменной хх в качестве начального значения некоторое пустое значение. Предположив, что переменная типа запись может иметь и значение, не являющееся нормальным, мы обошли эту проблему, отсрочив тем самым дискуссию о языковых средствах, необходимых для введения новых типов. (Конец примечания 1.)
16 ЕЩЕ РАЗ О ЗАДАЧАХ СЛИЯНИЯ
В двух предыдущих главах мы вели рассуждения в стиле, несколько отличном от наших более ранних формальных рассуждений, когда мы получали предохранители, как только были выбраны инвариантное отношение и изменяющаяся функция (для конструкции повторения) или постусловие (для конструкции выбора) и список охраняемых операторов. Однако, решая задачу о голландском национальном флаге и задачу обновления файла, мы начинали с рассуждения о конструкции повторения с предохранителем и далее обсуждали, каким может быть охраняемый оператор, т. е. придерживались более классического подхода. Что касается программы обновления файла, могу себе представить, что некоторые мои читатели, изучив более ранние главы, стали настолько подозрительными, что они удивляются, как это я осмеливаюсь верить в правильность программы, приведенной в последней главе; против всех правил я даже не упомянул об инвариантном отношении! Настоящая глава призвана восполнить эти пробелы, и это одна из причин ее включения в книгу; одновременно она предоставит нам возможность решать более широкий класс задач в более абстрактной форме.
Пусть х, у и z есть некоторые наборы (не теряя общности, мы можем ограничиться наборами целых чисел — их вполне достаточно!). Напомним, что из определения понятия «набор» следует, что все его элементы отличны друг от друга; в этом отношении «набор целых чисел» отличается от «совокупности чисел, которая может содержать одинаковые числа». Два набора равны (х = у) тогда и только тогда, когда каждый элемент одного набора входит в другой набор, и наоборот.
Обозначим объединение двух наборов символом « + », т. е., если г = %+^ это значит, что набор z содержит некоторый элемент тогда и только тогда, когда этот элемент входит либо в набор х, либо в набор у, либо в оба набора.
Обозначим пересечение двух наборов символом « *», т. е., если z = x* у, это значит, что набор z содержит некоторый эле-
мент тогда и только тогда, когда этот элемент входит и в набор х, и в набор у.
Обозначим пустой набор символом «0», т. е. 2 = 0 тогда и только тогда, когда г вообще не содержит элементов.
Рассмотрим теперь задачу вычисления для фиксированных наборов X и Y значения Z, такого, что
Z^X-VY
(В ходе обсуждения X и Y, а следовательно, и Z рассматриваются как константы: Z есть искомое окончательное значение некоторой переменной, которая будет введена позже.) Наша программа должна выполнить эту задачу, производя поэлементно действия над наборами — проверку, изменение и т. д.
Прежде чем приступить к более детальному продумыванию алгоритма, мы должны осознать, что в какой-то момент вычислительного процесса некоторые элементы Z уже будут найдены, а некоторые нет, т. с. существует разделение набора Z на два поднабора 21 и 22:
Z =г2
Здесь символ <Д» используется для сокращенной записи Z = zl + z2 and zl*z2^Z
(Мы можем рассматривать z\ как набор элементов, чья принадлежность к Z уже установлена, и z2 — как набор остальных элементов Z.)
Аналогично в некоторый момент вычислительного процесса наборы X и Y могут быть представлены в виде
Х = х\ -г х2 и Y = у 1 Z у2
(Здесь мы можем рассматривать наборы х1 и у\ как те элементы X и Y соответственно, которые больше не нужно принимать во внимание, поскольку они уже были успешно обработаны.)
Такая интерпретация смысла разделения ZД и У будет интересовать нас позже, а сейчас мы сначала докажем, совершенно независимо от того, что может произойти во время выполнения нашей программы, теорему о таких разделениях.
Теорема
Если Z = Z+7(1)
Х=х\ + х2(2)
Y=yl + у2(3)
г1=х14-у!№
z2=x2-Vy2(5)
то Z = zl + z244(xl * у2 = Э and i/l *х2 = 0)(6)
Доказательство. Чтобы показать, что правая часть (6) следует из его левой части, воспользуемся тем, что
'Z=z\ + z2=^z\ * z2 — 0
Подставляя (4) и (5), получаем
(xl + i/l)*(x2 + z/2) =
(xl * х2)-{-(^1 * //2) + (у1 * х2)-^(у\ * у2) = 0
откуда следует правая часть (6). Чтобы показать, что левая часть (6) следует из его правой части, мы должны показать, что из нее следует
zl*z2=0 and Z=z\3rz2
z\ *z2 = (л;14-у1) Цх2^у2)
= (xl * x2)3r(xl * y2)-^(y\ *x2)4-(z/l * y^ =0+0+0+0=0
^l + ^2 = (xl + yi) + (x2 + y2)
= x\-Vx2-Vy\-Yy2
= X 4-К — Z(Конец доказательства.)
Если имеют место отношения с (1) по (5), то правая часть (6) и, следовательно, равенство
Z = z\ + z2
вытекают также из того, что
z\*x2=0 andzl*y2=0(7)
и из отношений с (1) по (5) и (7) следует, что, если было выбрано разделение Z, два других разделения однозначно определены.
Вооруженные этими знаниями, мы возвращаемся к исходной задаче, где наша программа должна обеспечить истинность
Р:z=Z
Используя тот же подход, что и в предыдущих задачах, введем две переменные х и у и попробуем взять в качестве инвариантного отношения
Которое обладает тем приятным свойством, что его истинность тривиально обеспечивается при помощи
РО:г—0 and х=Х and y=Y
и в то же время при (х+у) = 0 из него следует истинность R.
Наша теорема предлагает нам отождествить х с х2, у с у2 и 2 с 21 (такая асимметрия вызвана тем, что X и Y— известные наборы, a Z предстоит вычислить). После такого отождествления отношения с (2) по (5) определяют все наборы в терминах х и у. Если мы теперь будем синхронно сокращать х и у таким образом, чтобы неизменно сохранялась истинность правой части (6) или
г*х=0 and z*y=2)(7')
то, как мы знаем,
Z = X-^Y = z\ 0 z2
Так как 21^22 есть разделение постоянного набора Z, то добавление элемента е к 2 ( = 21) означает удаление этого элемента е из х + у ( = 22). Чтобы такой элемент существовал, объединение не должно быть пустым, т. е. (х+у) +=0 или, что то же, «х^0 or у+=0»; элемент е может принадлежать х, но не принадлежать у, или он принадлежит у, но не принадлежит х, или принадлежит и х, и у и должен удаляться соответственно либо из х, либо из у, либо и из х, и из у. Структура программы представляется в следующем виде:
х, у, z\ = X, Y, 0;
do х =^0 or у 0 —► «перевести элемент из (х + у) в 2» od
Предположим теперь, что элементы набора х представляются значениями ax(i) при ах.ниг^Ках.вег и элементы набора у представляются значениями ау(1) при ау.ниг^Кау.вег, где ах и ау— это монотонно возрастающие целые функции, и ах.верх = ау.верх = инф. Использование дополнительной переменной инф дает нам то преимущество, что, даже если х или у пусты, все равно определены ах.низ и ау.низ. Преимущество от монотонного возрастания значений ах и ау состоит в том, что для непустого набора х значение ах.низ равно минимальному элементу х, и то же для у. В результате, если не оба набора пусты, существует один элемент из их объединения, для которого очень легко определить, принадлежит ли он х, у или обоим наборам, а именно
min (ах. низ, ау.низ)
Если, например, ах.низ<ау.низ, этот элемент равен ах.низ и входит в х, но не может входить в у, так как все элементы набора у больше, чем этот элемент; его удаление из набора х осуществляется при помощи операции ах: снизу, при этом ос-
тавшиеся элементы набора х по-прежнему представляются монотонно возрастающей функцией.
Если представление элементов набора z должно отвечать тому же соглашению, мы выбираем для их представления значения az(i) при az.Hue^i^az.eee; тогда последнее значение инф можно добавить только в конце вычислений. Полученная в результате функция az будет монотонно возрастающей, если ее значение изменяется только при помощи операции az : вверх (К) и при этом либо uz.o6a=^, либо az.oбл>0 and az.eepx<K. Наша программа будет удовлетворять этим ограничениям.
Если ах и ау— это правильно инициализированные векторные переменные (т. е. ах.верх = ау.верх = инф) и az — векторная переменная с az.o6A = 0, то мы можем получить желаемое преобразование при помощи следующих двух операторов:
do ах. низ инф or ау .низ Финф—* \mln[ax .низ, ау.низ)<^ инф\
if ах.низ <ау .низ—>az \ вверх [ах.низ); ах', снизу
ах.низ^ау.низ —*az; вверх\[ау.низ); ау : снизу
ах.низ =ау.низ—»az*, вверх [ах.низу ах*, снизу; ау :снизу
fi \az.ee рх <^min[ax .низ, ау.низ)>
od {az .обл = 0 cor [az .облу>0 and аг.верх<^инфУ;
az -.вверх [инф)
Упражнения
Изменить эту программу таким образом, чтобы она обеспечила также истинность u = U, где U = X* Y.
Составить аналогичную программу, которая обеспечит истинность z^Z, где Z=W+X4-Y.
Составить аналогичную программу, которая обеспечит истинность z = Z, где Z=W+(X*Y).
Составить программу, обеспечивающую истинность z = X+Y, но без предположения о наличии (и без введения!) значения «инф», отмечающего верхние концы областей определения; пустые наборы можно обнаруживать по ах.обл = 0 и ау.обл = 0 соответственно. [Конец упражнений.)
Ценой еще большей формализации мы могли бы еще успешнее сыграть в нашу формальную игру.
Пусть для любого предиката P(z) семантика «z: =% + у» задается слабейшим предусловием
wp(«z:=x + у», P[z)) = [x*y^0 and P[x-Vy))
где первый член означает, что пересечение х и у должно быть пустым, чтобы было определено х + у.
Пусть для любого предиката P(z) семантика «г; = х —у» задается слабейшим предусловием
wp(«z ;=х~у», P(z^ = (x*y = y cand Р^х~у^
где первый член означает, что у должен целиком содержаться в х, чтобы было определено х — у, и тогда х—у представляет собой единственное решение уравнения (х~у)+у = х.
Исключая xl, х2, у\, у2, 21 и z2, мы получим следующие отношения между х, у и z:
Pl:Р2:РЗ:Р4:Р5:Pl:Р2:РЗ:Р4:Р5:х *Х=х
Pl:
Р2:
РЗ:
Р4:
Р5:
Pl:
Р2:
РЗ:
Р4:
Р5:
У*У=У г=(Х~хШУ-У^ х*(У с^у)=0
У*<Х z^x) = 0
и мы можем задаться вопросом, при каких начальных условиях выполнение операции
z, х: = z + {ej, х ~ {е}
сохранит эти отношения инвариантными для некоторого элемента е. Начнем с выяснения условий, при которых определено это одновременное присваивание, т. е.
wp(S, T\ = \z * {е} = 0 and х*(е] —{е})
Так как
(Pl and х* (е) = {е}) => (X ~ х) * {е} = 0
(Р2 and Р4 and х * (е) = {е}) => (У с^у) * {е) = 0
Q=»wp(S, Т) при
Q = (P1 and Р2 and РЗ and Р4 and х*{^} = [^])
Теперь нетрудно установить следование
Q=>wp(S, Pl and Р2 and РЗ and Р^
Однако
wp(S, P5) = (wp(S, Р) and у * (X с^ (х ~ {^}))= 0)
= (wp (5, Г) and у * ((Л" ~ х) + {е}) = 0)
= (wp(5, Т) and Р5 and у * ^е^ = 0)
Й, следовательно, предохранитель для S, обеспечивающий инвариантность отношений с Pl по Р5,— это
х * {^} = {с9} and у^Це\ = 2)
т. е. е должен быть элементом х, но не у, и т. д.
Вышесказанное завершает мою ревизию задач слияния. В двух последних главах я проводил рассуждения с различной степенью формальности, и какую из этих степеней предпочтет мой читатель, зависит настолько же от его потребностей, насколько и от его настроения. Но мне казалось поучительным проделать все это хоть один раз! (По всей вероятности, заметно, что при написании этой главы возникло гораздо больше трудностей, чем предполагалось заранее. Это по крайней мере пятая версия, что само по себе служит достаточным основанием для ее включения в настоящую монографию.)
17 УПРАЖНЕНИЕ, ПРИПИСЫВАЕМОЕ Р. У. ХЭММИНГУ
Эта задача известна мне в такой формулировке: «Генерировать в порядке возрастания последовательность 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, ..., состоящую из всех чисел, не делящихся ни на какие простые числа, кроме 2, 3 и 5». Другое определение того, какие значения являются элементами этой последовательности, задается при помощи трех аксиом:
Аксиома 1. Значение 1 является элементом последовательности.
Аксиома 2. Если х является элементом последовательности, то2*х, 3 *х и 5 * х также являются элементами этой последовательности.
Аксиома 3. Последовательность не содержит других значений, кроме тех, которые принадлежат ей согласно аксиомам 1 и 2.
(Предоставляем специалистам по теории чисел установить эквивалентность двух этих определений.)
Мы включили это упражнение, потому что его структура довольно типична для большого класса задач. Поскольку нас интересуют только заканчивающиеся программы, будем составлять программу, генерирующую, скажем, лишь первые 1000 значений последовательности.
Пусть
Р0(п, q) означает: значение «д» представляет упорядоченный набор первых «п» значений последовательности.
Тогда, согласно аксиоме 1, 1 входит в последовательность, и так как 2 * х, 3 * х и 5 * х — это функции, чье значение больше х при х>0, то, согласно аксиоме 2, 1 есть минимальное из всех значений, чья принадлежность к последовательности может быть установлена на основании первых двух аксиом. Согласно аксиоме 3, 1 есть минимальное значение, входящее
в последовательность, и поэтому истинность P0(n, q) легко обеспечивается для п=1: «q» тогда содержит только значение 1. Очевидная структура программы такова:
«обеспечить истинность РО^п, q) для п=\»;
do п ч^ 1000 —*
«увеличить п на 1 при инвариантности РО^п, q)» od
В предположении, что мы можем дополнить последовательность значением «хслед», если это значение известно, основная проблема, возникающая при реализации операции «увеличить п на 1 при инвариантности Р0(п, ^)», состоит в том, как определить значение «хслед». Поскольку значение 1 уже принадлежит q, хслед>1 и принадлежность хслед последовательности должна обосновываться аксиомой 2. Назовем максимальное значение, входящее в q, «q.eepx», тогда хслед—■ это минимальное значение, большее q.eepx, которое можно представить в виде 2 * х, 3* х и 5 * х, где х— элемент после* довательности. Но так как 2 * х, 3*х и 5* х— это функции, чье значение больше х при х>0, то значение х должно удовлетворять условию х<хслед; далее, х не может удовлетворять условию x>q.eepx, так как мы тогда имели бы
q .верх^х*^ хслед
а это противоречит тому, что хслед есть минимальное значение, большее q.eepx, т. е. х должен уже входить в q. (Именно ради проведенного анализа мы инициализировали P0(n, q) для п=\; так же просто можно было сделать это для п = 0, но тогда значение q.eepx не было бы определено.)
Если мы хотим получить простую реализацию описанного выше анализа, мы можем ввести набор qq, состоящий из всех значений xx>q.eepx, таких, что хх можно представить как л:л:=2*л:, где х— элемент q, или как
хх=3*х, где х —элемент q,
или как'
хх=5*х, где х — элемент q.
Набор qq не пуст, и хслед есть в этом случае минимальное входящее в qq значение. Но при ближайшем рассмотрении такой путь оказывается не очень привлекательным, так как корректировка qq могла бы привести (в терминах предыдущей главы) к операции
qq ^qq ~\хслед\)-\-^2* хслед, 3* хслед, 5* хслед)
ъде «+» означает «образовать объединение двух наборов». Так как мы должны определять минимальное значение, входящее в qq, было бы хорошо, если бы элементы q были упорядочены; но тогда образование объединения при такой корректировке qq привело бы к значительной перетасовке элементов, чего нам хотелось бы избежать.
Однако, поразмыслив немного, мы приходим к выводу, что нам не нужно следить за всем набором qq, а можно выбрать .в качестве хслед минимальное значение, входящее в гораздо меньший набор
где
х2
есть
минимальное
значение>^.ве/7х,
такое,
что
х2
= 2 * х
и х входит в q,
хЗ
есть'
минимальное
значение >q.eepx.
такое,
что
хЗ
= 3 * х
и х входит в q,
и
х5
есть
минимальное
значение >q.eepx,
такое,
что
х5
= 5 * х
и х входит в q.
. Эти отношения между q, х2, хЗ и х5 обозначаются как Pl (q, х2, хЗ, х5).
Поэтому очередной вариант нашей программы выглядит так:
«обеспечить истинность РО^п, q^ для п — 1»;
do п^ь 1000 ->-
«обеспечить истинность РХ (9, х2, хЗ, х5) для текущего значения q»;
«увеличить /г на 1 при инвариантности P0(n, q^, т. с. добавить mln (х2, хЗ, х5) к q»
od
Такая программа была бы во всех отношениях правильной, но теперь пришлось бы выполнять операцию «обеспечить истинность Pl (q, х2, хЗ, х5) для текущего значения q», операцию весьма неприятную, даже если предположить, что мы имеем такой доступ к элементам упорядоченного набора q, какой пожелаем. Справиться с этим можно стандартным образом: вместо того чтобы каждый раз, когда нам нужны х2, хЗ и х5, заново вычислять их как функцию q, мы можем, понимая, что значение q изменяется «медленно», пытаться «корректировать» значения, являющиеся функцией q, только тогда, когда q изменяется. Этот прием настолько стандартен, что стоит дать ему специальное название; назовем его «вынесением отношения (из конструкции повторения)». Применение этого приема
отражено в следующей структуре программы:
«обеспечить истинность P0(n, q) для /2=1»;
«обеспечить истинность Р1(д, х2, хЗ, х5) для текущего значения q»;
do п^ 1000
«увеличить п на 1 при инвариантности P0(n, q), т. е. добавить к q min (%2, хЗ, х5)»;
«восстановить истинность Р1 (д, х2, хЗ, хЗ) для нового значения q»
od
Теперь мы должны следить за тем, чтобы Pl Vq^ %2, хЗ, х5) было истинным после расширения q, т. е. после добавления q.eepx; в результате корректировка %2, хЗ и х5 есть либо пустая операция, либо их увеличение, а именно замена соответствующим кратным следующего значения х из q. Представляя упорядоченный набор q в виде массива aq, т. е. в виде монотонно возрастающих значений ц^(1), aq^), ..., aq^, мы вводим три индекса Z2, Z3 и Z5 и расширяем Р1, добавляя к нему ...and х2 = 2* aq (Z2) and х3 = 3 * aq (Z3) and x5 = 5 * aq (Z5)
Тогда наш внутренний блок, придающий глобальной векторной переменной aq в качестве начального значения нужное нам конечное значение, будет выглядеть следующим образом:
begin virvaraq', privar Z2, Z3,Z5,x2, x3, x5;
aq virцелarray:—(1,1);(истинность P0обеспечена}
12 virцел,13 vir цел,15 vir цел\ = 1, 1,1;
x2 virцел,хЗ vir цел,x5vir цел*. =2, 3, 5;(истин
ность Pl обеспечена} do ац.облч^ 1000—>
if хЗ ^ x2 < x5 —> aq*. вверх <x2A □ x2 x3 < x5—>aq: вве px (x3) [J x2 x5 ^x3—^aq\ вверх (x5) fi \aq.oбл увеличилась на 1 при инвариантности
Р0};
do х2 ^ aq.eepx —> 12 : = Z2-}- 1; х2 : = 2 * a^(Z2)od; do хЗ <. aq.eepx —*13 : = Z3+ h хЗ : =3 ^ a^(Z3)od; do x5 ^ aq.eepx -^>i5 : — Z5-{- 1; x5 : = 5 * tz^(Z5)od
(вновь обеспечена истинность Pl} od
end
В этой версии ясно выражено, что после того, как заново обеспечена истинность Р1, мы имеем х2>aq.eepx and x3>aq.eepx and x5>aq.eepx. Заметим, что мы могли бы с тем же успехом использовать «...== aq.eepx» вместо «...^aq.eepx».
Замечание 1. В трех последних внутренних конструкциях повторения каждый список охраняемых операторов будет выбираться для выполнения самое большее один раз. Поэтому мы могли бы представить их в таком виде:
if x2 = aq .верх —^12 := Z2 + 1; х2 : = 2 *aq (Z2)
П х2 2> aq. верх —* пропустить fi; и т. д.
Когда я начинаю думать о такой возможности, я прихожу к выводу, что явно предпочитаю конструкции повторения, так как не вижу ничего настолько особенного в том, что повторение может прекратиться после нуля шагов или одного шага, чтобы ради этого стоило использовать специальные синтаксические средства. Всякие колебания по поводу того, можно ли считать «нуль или один раз» частным случаем «самое большое к раз», вызываются, по-видимому, нашим языковым наследием, так как все западные языки делают различие между формами единственного и множественного числа. (Если бы мы были классическими греками (т. е. мыслили бы также и категориями двойственного числа), мы, вероятно, чувствовали бы себя обязанными ввести специальное дополнительное синтаксическое средство для выражения завершения конструкции повторения после самое большее двух шагов!) В этом смысле было бы, вероятно, «честнее» закончить главу «Обновление последовательного файла» конструкцией
do хх.норм —^новфайл’- вверх [хх]\ хх\ анорм od вместо
if хх.норм новфайл* вверх [хх] Qnon хх.норм-^ пропустить fi
так как при этом удовлетворяется условие завершения конструкции, выраженное через хх.норм. (Конец замечания 1).
Замечание 2. Три последние внутренние конструкции повторения можно было бы объединить в одну:
do x2^aq .верх —н2 : = Z24"1; x2*aq(i2) fJ хЗ <; aq. еерх —* 13 : = Z3 + 1; хЗ * aq (Z3) □ х5 < aq.eepx —^15 : =Z54~ 1; x5*aq<^ od
Однако я предпочитаю не делать этого и не объединять охраняемые команды в один набор, когда выполнение одного списка охраняемых команд не может повлиять на истинность других предохранителей из этого набора. Тот факт, что при этом три конструкции повторения, разделенные точкой с запя-
А
т]ой, предстают в порядке, выбранном произвольно, меня не беспокоит: это обычная форма избыточной спецификации, с которой мы всегда сталкиваемся в последовательных программах, описывающих последовательно действия, которые могли 6^1 выполняться параллельно. ^Конец замечания 2.)
Упражнение, разобранное в этой главе, является частным случаем более общей задачи, а именно задачи о генерации первых Лг значений последовательности, определенной следующими аксиомами:
Аксиома 1. Значение 1 является элементом последовательности.
Аксиома 2. Если х есть элемент последовательности, элементами последовательности являются также Цх), g(x) и 1Цх), где /, g и h — монотонно возрастающие функции, такие, что f(x)>x, g^>x и h (%) >х.
Аксиома 3. Последовательность не содержит других значений кроме тех, которые принадлежат ей согласно аксиомам 1 и 2.
Заметим, что если бы о функциях /, g и h не было ничего известно, задача не могла бы быть решена!
Упражнения
Решить эту задачу, если аксиома 2 формулируется по- другому:
Аксиома 2. Если х есть элемент последовательности, то элементами последовательности являются также Цх) и £(х), такие, что/(%)>% и g^>x.
Решить задачу, если аксиома 2 формулируется так: Аксиома 2. Если х и у принадлежат последовательности, то ей принадлежит и Цх, у^, где f обладает свойствами:
Нх> У^ >х
(yl>z/2)у1)>/(^ У2)
(Конец упражнений.)
Пытливый читатель, который успешно справился с этими упражнениями, может придумывать дальнейшие варианты сам.
18 ЗАДАЧА ПОИСКА ПО ОБРАЗЦУ
Задача, которая решается в этой главе, широко известна, за ее решение принимались независимо многие программисты. Однако мы надеемся, что наш подход доставит некоторое удовольствие даже тем читателям, которые считают, что они достаточно хорошо знакомы с этой задачей и ее различными решениями.
Даны две последовательности значений
/?(0), р(1),..., p^N — 1) при и
х(0), х(1),..., х(М—Л) при Л4>0
(считается обычно, что М во много раз больше /V).
Требуется определить, сколько раз «образец», заданный первой последовательностью, входит во вторую последовательность.
Используя форму
(NZ: 0 <; i С т : В (Z))
для обозначения «числа различных значений i в интервале О^Кт, для которых выполняется В (Г)», мы получаем более точное выражение конечного отношения R, истинность которого должна быть обеспечена нашей программой.
R;счет = (N Z: 0 Z /И — N : совпадение (Z))
где функция совпадение^) определяется следующим образом:
для 0<Z<Tl — N: совпадение (Z) = (A/ :0 < /: p(j) =
xVi-yj^ для Z<<0 or i^M-N*. совпадение {i) = ложь
(To что вне интервала O^Z^M—N мы придаем функции ложное значение, делая ее тем самым полностью определенной, является вопросом удобства.)
\ Если мы возьмем в качестве инвариантного отношения
Pl :c4em = ^N i :0 <^Z<^r : совпадение ^ and r^O
то его истинность тривиально обеспечивается оператором «счет, г: = 0, 0» и, кроме того, получаем, что
(Pl and r>M-N^R
(Здесь мы опираемся на то, что выше было названо «вопросом удобства».) Получаем некоторый набросок программы:
счет, г := 0, 0;
do г ^ М — N «увеличить г при инвариантности Р1» od fi читателю предлагается разработать для себя более деталь- .ный вариант, где г всегда увеличивается на 1; в самом плохом случае время, потраченное на выполнение этой программы, -будет пропорционально М * Л7.
Однако в зависимости от образца иногда становится возможным увеличивать г на число, гораздо большее чем 1; если, например, образцом является последовательность (1, 2, 3, 4, 5) и функция совпадение(г) истинна, то операция «счет, г: = счет+1, г + 5» сохранит инвариантность Р1! Рассматривая инвариантное отношение
Р2 :(А/ : 0 у <k : р^ = х^г-т /)) and 0 к N
(которое, как можно предположить, сыграет роль в конструкции повторения, вычисляющей совпадение (г)), мы можем посмотреть, что мы выиграем, если вынесем это условие из конструкции повторения, для чего рассмотрим такой вариант программы:
счет, г, к : = 0, 0, 0;
do г <;7И — N «увеличить г при инвариантности Р\ and Р2» od
(при ^ = 0 Р2 удовлетворяется из-за пустоты множества).
Исходя из истинности отношения Р2 и формулы для совпадение^, самым естественным будет начать повторяемый оператор с попытки определить значение функции совпадение (г); так как истинность совпадение (г) следует из Р2 and •k = N, мы считаем, что нужно увеличивать к до тех пор, пока это необходимо и возможно:
do к ^ N cand р^ = х^гАх-к^-^к ; = к— 1 od (1)
По завершении этой конструкции, а завершение гарантировано, мы имеем
Р2 and ^k = N сог р^ -^х^гл_^
откуда мы можем заключить, что совпадение (г) = (^ = .V).
Таким образом, известно, будет увеличение г на 1 сопровождаться оператором «счет; = счетЛЛ» или нет. Нам бы хотелось знать, насколько можно увеличивать г, не увеличивая счет и не принимая во внимание последующие х-значения. (Эти значения принимаются во внимание в операторе (1); учитывать их является его специальным назначением! Л1ы же хотим здесь использовать только свойства постоянного образца.)
Если /г = 0, мы делаем вывод (поскольку М>0), что совпа- дение(г) =ложь; отношение Р1 позволяет увеличить / па 1 (Р1 сохраняется инвариантным, поскольку счет не изменяется), но Р2 не допускает увеличения г больше чем на 1 и к остается равным нулю (при этом Р2 истинно, так как соответствующее множество пусто).
Для любого к будем рассуждать следующим образом. Определим для Q<i^,k^N булеву функцию
difU, ^) = (Еу :0< j <k — i; p(j) р^Д_ j^
Отсюда следует, что di^k, к^ =ложъ. Если, однако, dif(i, k) =истина, мы заключаем — поскольку 0^л-Н<&— что благодаря истинности Р2
(EjVO<j<k — i*.pU) =^x(r4-/ + y))
т. е. dif Ц, к^=^поп совпадение (г -Ft). Следовательно, переменная «счет» не нуждается в последующей корректировке (кроме одного раза, что вызвано значением функции совпадение (г)), когда г увеличивается на diky где d^ есть .минимальное решение для i ^<i^k^ уравнения dif (i, к^=ложь, или
(Ay :0<у <k—i \р (j} = p(i-± j))(2)
Из того, что d^ есть решение (2), следует
(А у : 0 < j < к — d (к): р (у) = р (d (^) + у))
что с учетом Р2 равносильно
(Ay :0<у <к — d (к) ;pU^ = x^r^-d № + /)) и в результате (помимо корректировки «счет», подразумеваемой значением совпадение(г)) при помощи «г, k\~r + d(k)y к—d^» обеспечивается сохранение инвариантности как Р\, так и Р2.
Поскольку минимальное решение (2) зависит только от к и р, мы получаем:
begin glocon р, N, х, М; virvar счет; privar г, к; pricon d);
«инициализировать d»;
счет vir цел, г vir цел, к vir цел : = 0,^0, 0;
do r<.M-N-^
do k^N cand p ^ = х^г-\-к^-^к : = & +1 °d;
if k = N -> счетсчет-у-1; г, к ; = r-^d ^к\
k-d^
^0<k<N-*r,k;= r-Vd^, k-d^
Пк = 0-^г := г -у- 1
fi od
end
Единственное, что нам осталось сделать,— это инициализировать векторную переменную d, т. е. найти для каждого к (l^&^TV) минимальное решение уравнения (2) относительно L Согласно теореме о линейном просмотре, мы должны проверять t-значения в порядке их возрастания. Полезно, однако, ясно представлять себе, что это минимальное значение для t должно быть определено для всей последовательности ^-значений. Пусть &1>&2 и пусть d^kl^—минимальное решение уравнения (2) относительно I при к = к\. Из
(Ау : 0 С у < ^1 —^Z (/fel): /2(7)=/? (ti< (>fel)4-/)) and k\>k^
следует
VM^<j<^-ci^-.p^pvavk\W PA
т. e. для £ = &2, d^kl") также является решением (2) относительно t, но не обязательно наименьшим! Отсюда мы заключаем, что d^k) есть монотонно неубывающая функция к. Следовательно, мы должны исследовать значения t в порядке возрастания и проверять каждый раз, выполняется ли условие t = d(&) для одного или для нескольких значений к. Более точно, пусть /(0 для данного t будет максимальным значением ^^—t, таким, что
(А/ : 0 < j < j (Z): р (у) = р <i -f-у))
тогда d(k")=i для всех к, таких, что к—i^-j^ (или А^/(0+0, и Для которых не существует решения d(k^<L
Так как мы будем исследовать все значения I в порядке их возрастания и значение, являющееся минимальным решением, будет представлять очередное значение монотонно неубывающей функции d, к удовлетворяет условию
d.вег <^kj (0 +
и мы получаем следующую программу «инициализировать d»:
begin glocon р, N\ virvar d; privar i;
d vir цел array; i vir цел := (l),0;
do d.eez N
begin glocon p, N; glovar d, Z; privar y;
j vir цел-=0; Z: — Z-yl;
do j <N — I cand pU^P (Z + /W *=J-r 1 od;
do d.eez <J-vi -^-d : вверху od
end
od end
Упражнения
Дать формальное доказательство правильности приведенной выше инициализации.
Используя «г, к: = r-Vd^k), г—d(fe)» для 0<к, наш алгоритм корректирует г и к, не изменяя r-Vk. Исследуйте незначительный выигрыш, который можно получить, для 0<k<N, если известно, что все х могут иметь только два различных значения. (^Конец упражнений.)
Примечание. Я считаю, что время выполнения нашего последнего алгоритма будет расти пропорционально M-VN. Если задаться целью найти, если возможно, алгоритм с такой производительностью, то его действительная разработка не потребует никаких чрезмерных усилий; решающим моментом представляется отказ удовлетвориться (без дальнейших исследований) очевидным алгоритмом с временем выполнения, пропорциональным M*N, разработку которого я предоставил читателям в качестве упражнения. Однако небольшое изменение формулировки задачи позволит нам разглядеть здесь использование общего принципа, который можно было бы
назвать «поиском малого супернабора». Предположим, что нам нужно было не подсчитывать число вхождений одной последовательности в другую, а сгенерировать последовательность г-значсний, для которых функция совпадение (г) истинна.
Когда программа должна генерировать элементы набора Л, возникают в первом приближении только две ситуации. Либо мы располагаем простой и определенной «функцией следующего», при помощи которой можно получить следующий элемент Л,— и тогда генерация всего набора выполняется тривиально посредством повторного применения этой функции — либо мы не располагаем подобной функцией. В этом случае обычно прибегают к генерации элементов некоторого набора В, такого, что
Каждый элемент Л является также элементом В.
Существует генератор последовательных элементов В.
Существует способ проверки, принадлежит ли элемент В также и набору Л.
Алгоритм генерирует и проверяет по очереди все элементы В.
Чтобы, пользуясь этим методом, получить удовлетворительную производительность, необходимо выполнение трех условий:
Генерация элементов набора В должна быть разумно эффективной.
Проверка принадлежности элемента набора В набору А также должна быть разумно эффективной (особенно в том случае, когда элемент В не принадлежит Л, так как В обычно на порядок больше Л).
Набор В не должен быть излишне большим.
Тот, кто имеет большой опыт решения подобных задач, будет, исходя из вышесказанного, искать набор В, меньший, чем это представляется очевидным. В данном примере таким очевидным набором является набор всех r-значений, удовлетворяющих условию О^г^М—N. Заметим, что в предыдущей главе замена набора «qq» гораздо меньшим набором «qqq» была еще одним применением принципа поиска малого супернабора. Г! наряду с «вынесением отношения из конструкции повторения» это иллюстрирует второе стратегическое сходство между решениями, представленными в настоящей и предыдущей главах. ^Конец примечания.)
19 ПРЕДСТАВЛЕНИЕ ЧИСЛА
В ВИДЕ СУММЫ ДВУХ КВАДРАТОВ
Предположим, что нам требуется составить программу, которая для любого заданного г^О будет получать все существенно различные формы представления г в виде суммы двух квадратов, или, более точно, будет генерировать все пары (х, у), такие, что
л:24-у2 = г and х^у^Ол:24-у2 = г and х^у^О(1)(1)
л:24-у2 = г and х^у^О
л:24-у2 = г and х^у^О
(1)
(1)
Ответом должны быть две векторные переменные ху и уи, такие, что для I от хс.ниг ( = уе.ниг) до хи.вег ( = уе.вег) пары (ху(0, уу(0) будут представлять все решения (1). Чтобы быть уверенным, что наш последовательный алгоритм получает все решения (1), воспользуемся стандартным способом упорядочивания решений по какому-либо признаку. Я предлагаю располагать их в порядке возрастания значения х (поскольку никакие два различных решения не имеют одного и того же значения х, такое упорядочивание однозначно). Мы предлагаем сохранять инвариантным следующее отношение:
Р1: ху (г) будет монотонно возрастающей функцией с той же областью определения, что и монотонно убывающая функция уу(0> причем эти функции таковы, что пары (ху(г), уу(0) представляют собой все решения (1) при xy(t)<x
Начальная истинность Р\ легко обеспечивается, если инициализировать ху и уи так, что их область определения пуста, и выбрать х не слишком большим. Если пара (xy(t), уу(0) является решением (1), мы всегда будем иметь 2 * ху (г)2^ху (t)2+yy (t)2 = r, и, следовательно, так как xy(t)<x, наименьшее значение х^б, такое, что 2 *х2^г не слишком велико. Наименьшее значение х можно установить при помощи теоремы о линейном просмотре. Однако, поскольку для каждого xy(t) будет выполняться условие лу(/)2^г, мы знаем, что из истинности Pl and х2>г следует,
что все решения получены. Поэтому наш первый вариант может быть таким:
begin glocon г; virvar хч), yv\ privar х;
х vir цел\ — 0; do 2*л2<г->х:=х-р1 od;
хч) vir цел array, уч) vir цел array : = (1), (1);
do x2 <; r-> «увеличить x при инвариантности Р1» od end
Из этой программы видно, что инвариантным отношением является в действительности более сильное отношение
РГ:Pl and ^^x^^r
Наивно было бы надеяться, что для каждого значения х можно определить такое значение у, что x2-Vy^ = r, так как такое значение вовсе не обязательно существует. Что мы действительно можем сделать — это обеспечить истинность отношения
x^-Vy^^r and ^с2 + (у+1)2> г
Отсюда мы можем заключить не только то, что если х2 + у2 = г, значит решение (1) было найдено, но также и то, что если х2 + у2<г, значит для данного значения х не существует значения у, которое могло бы быть парой для х. Принимая отношение
Р2:x4m2>r
в качестве инвариантного отношения для внутренней конструкции повторения, получаем программу
«увеличить х при инвариантности Р\'»;
begin glocon г; glovar хч). yv, х\ privar у;
у vir цел := х; {благодаря истинности PV стало истинным Р2}
do х2-^-у2^ гу := у — 1 od; \х2 -(- у2 г and Р2\
if х2-\-у2 = г -^хч): вверх^х^ уч)*.вверх ^\ х'. = х-^\
□ х2-^-у2<^гх: = х-Х1 fi end
Замечая, однако, что последняя конструкция выбора не нарушит справедливости Р2, мы можем значительно повысить
эффективность этой программы, если вынесем отношение Р2 из внешней конструкции повторения:
begin glocon г; virvar хч), yv, privar х, у;
х vir цел, у vir цел'.= 0, 0;
do х^-^у1^ г-^-х, у : = х-у 1, у 4-1 od;
хч) vir цел array, уч) vir цел array := (1), (1);
do х2 < r-^do х^х- у-~> rу ■. = у — 1 od;
if х24- у2 = г —>ху : вверх (х); уч): вверх (уУ
х :=х4~ 1
□ х24-у2<г-*х:=х4-1 fi
od end
Последнее усовершенствование есть результат применения поиска малого супернабора для у; оно было достигнуто благодаря вынесению отношения из конструкции повторения, а именно отношения Р2.
Замечание. В качестве упражнения предлагается внести такие очевидные усовершенствования, как проверка справедливости г mod 4 = 3 и использование рекуррентного отношения (х+ 1)2 = х2-г (2 * х+ 1). (Конец замечания.)
Примечание. Приведенная выше программа, которой мы обязаны Фейену, явно превосходит программу, составленную нами несколько лет назад, когда, например, чтобы показать, что ни одного решения не пропущено, всегда требовалось прибегать к рисунку. (Конец примечания.)
20 ЗАДАЧА О НАИМЕНЬШЕМ ПРОСТОМ МНОЖИТЕЛЕ БОЛЬШОГО ЧИСЛА
В этой главе мы займемся задачей определения наименьшего простого множителя большого числа М>1 (под «большим» я понимаю здесь число порядка, скажем, 1016), исходя из предположения, что программа предназначается для малой машины, которая выполняет операции сложения и сравнения гораздо быстрее, чем произвольные операции умножения и деления. (В наше время такое предположение вполне реально для большинства так называемых «мини-машин»; описываемый алгоритм был разработан несколько лет назад для устройства, которое, несмотря на его физический размер, сегодня можно было бы назвать «микромашиной».)
Из непосредственного применения теоремы о линейном просмотре следует, что мы должны исследовать простые числа в качестве возможных множителей в порядке их возрастания. Поскольку составное число имеет по крайней мере один простой множитель, не превосходящий корень квадратный из этого числа, наше исследование должно вестись не дальше этого корня; если множитель еще не будет найден, это означает, что число N простое. На этих рассуждениях основан следующий алгоритм:
begin glocon TV; virvar p; privar /;
/ vir цел*— 2;
do TVmod/^O and /2<TV->
«увеличить / до следующего простого числа» od;
if TVmod/ ч^О-^p vir цел\ = Ы
□ TVmod/—0-> p vir цел := /
fi
end
В этом алгоритме, однако, есть еще над чем подумать, потому что пока неясно, как мы собираемся увеличивать f до следующего простого числа.
Мы предположили, что составляем программу для малой машины, а это исключает возможность хранения таблицы последовательных простых чисел до 108. (Если хранить такую таблицу, не прибегая ни к каким ухищрениям, потребовалось бы 5 * 107 битов, а это совсем не то, что называется — даже сегодня! — «малой памятью».) Вместо того чтобы определять следующее простое число по хранящейся в памяти таблице, его можно было бы вычислять; но если идти обычным путем, пришлось бы, вообще говоря, проверять последовательность
/ + Ь / + 2, / + 3,...
пока не встретится первое простое число. Но проверка, является ли число простым, сводится обычно к вопросу о том, равно ли оно своему наименьшему простому множителю!
Существует возможность очень просто разрешить эту дилемму. Для ^>l наименьший простой множитель числа N является также наименьшим натуральным числом ^2, на которое делится N. Это свойство позволяет нам найти наименьший простой множитель числа ^, не обращаясь больше к концепции «простого числа»:
begin glocon N; virvar p; privar /;
/ vir цел : = 2;
do TV mod/^0 and (/+ 1)2< N -^ f : = / + 1 od;
if N mod / =^0 —♦ p vir цел ; = N
□ N mod/=0—> p vir цел : = / fi
end
Основная неприятность, связанная с этим алгоритмом, объясняется тем, что только операции сложения и сравнения мы считали быстрыми, но допустили, что вычисления N mod f И (f+1)2 могут быть такими медленными, что их по возможности следует избегать во внутреннем цикле.
Единственный выход из положения, как кажется, состоит в том, чтобы найти способ применить прием «вынесения отношения из конструкции повторения», т. е. попытаться хранить такую информацию, чтобы после вычисления r=N mod f можно было на ее основе вычислить следующее значение г (для f+1). Что же мы можем хранить?
Для начала можно воспользоваться тем, что r = N mod f есть решение уравнения
N=f*q-Vr and 0<^г</
и мы могли бы хранить q. Тогда мы знаем, что
и, вообще говоря, можем рассчитывать на «выигрыш» в том смысле, что (г—q) будет ближе к нулю, чем исходное N, и, следовательно, от него «легче» получить остаток по модулю (/+1). Но для меньших значений / (и г) и, следовательно, для больших значений q мы не можем рассчитывать на большой выигрыш.
Что касается нового значения г, а именно (г—q) mod (f+1), то нас, оказывается, совсем не интересует само значение q\ Нам точно также подошло бы любое меньшее значение, равное q по модулю (/+1), а уменьшение г на это значение причинило бы нам меньшее беспокойство. Иначе говоря, мы предпочли бы уменьшать г не на q, а, скажем, на q mod (/+1). Тогда почему бы нам не хранить это значение? Повторяя наши рассуждения, приходим к следующим уравнениям:
qt) = (/ + 1) * qx -г Гхдля 0 < i < п
91 = (/~Г2)*^2"ГГ2
^«-1 = (/+«)*^+гл qn о
Исключая все q, получаем
^ =г04-
/*п +
/* (/+ 1) * г2 +
/ *(/ + 1)*(/ + 2)*гз +
Z*(Z+O * (Z4-2)* ...*(/+«-l)*r„(1)
откуда ясно видно, как N полностью определяется через f и конечную последовательность г.
Заменяя f на f+1 и компенсируя приращение, появляющееся в каждой строке, уменьшением предшествующего ей члена, мы получаем другое представление N:
N = V0-riH
(Z+ 0 * (z'i — 2*г2) +
(/+1)*(/+2)*(г2-3*г3) +
(Z+1) * (Z+2)* (/ + З)*(г3-4*г4) +
(/+ 1)*(/+2) * (/4-3) *...*(/+/?)* гя
Это .му преобразованию соответствует программа
/: — /-'-!; / := 0;
do i << ri —► rt: = ri — (/ — 1) *Z:=Z-pl od
но, однако, при этом могут нарушиться неравенства 0<rz<^/H-Z
так как г может стать отрицательным. Но это дело поправимое, поскольку из (1) видно, что приращение г0: = г0-Н можно компенсировать уменьшением /д: = /'i—1. В общем случае ri: = ri+(f + Z) компенсируется при помощи г^х^г^х— 1. В результате полное преобразование правильно описывается программой
/:=/+!; /:= 0;
do iп —♦
П r/-(Z + 1) *^+f, do rz <0 —>
= rt + (/ + /); r;+1:= r;+1-l
od;
od;
do rn=0 —»n := n — 1 od
В предположении, что умножение малых целых чисел не представляет серьезной проблемы — оно может выполняться путем повторного сложения,— вычисление последовательных значении N mod / может быть реализовано при помощи набора допустимых операций. Кроме того, проверка (f2<ZV в нашей первой версии и (/+1)2^AZ в следующей версии), имеет ли смысл продолжать поиск или мы уже достигли значения корня квадратного из N, может быть заменена проверкой п>1, так как (п^1)=> (Л/"< (/+1)2)-
Полагая аг(^)=гд для О^к^аг.вег, мы приходим к следующей программе:
begin glocon N; virvar p; privar /, ar;
begin glocon N; virvar ar; privar x, y;
ar vir цел array := (0); xvir цел, у vir цел: =
N, 2;
do x N= 0 —>• ar : вверх (x mod у); x, у : = x div y,
yA"1 od
end (выполнена инициализация аг\;
/ vir цел:— 2 (отношение (1) стало истинным};
do art^^O and аг.вег^>1 —* begin glovar /, ar-, privar i;
/ := /+1; i vir цел:=0;
do i N= ar.вег —>
begin glocon /; glovar ar, I; pricon j;
у vir цел := i-^ 1; ar : (Z) = ar(Z) — j*ar (y);
do ar(Z)<0—>ar: ^ = аг^ +/-ri;
ar-.U^ar (у) — 1 od;
/: = y" end od
end;
do ar,верх=0—^ ar: сверху od od;
if ar (0) — 0—»p vir цел:= /
Q ar (0) =^= 0 —> p vir цел :— N
fi end
Примечание 1. Некоторые могут подумать, что можно вдвое ускорить программу, если рассматривать отдельно четные и нечетные значения N. Тогда для нечетных N мы могли бы ограничиться последовательностью /-значений 3, 5, 7, 9, 11, 13, 15,...; операция
г. := rz-(/+l)*r;+1
превращается тогда в операцию
rz:=rz-2»(Z4-l)*rz+1
и это насилие над п приведет к тому, что число прохождений по следующему за операцией циклу, который вновь приводит rt к нужному интервалу, возрастет в среднем в два раза. Так что мы вряд ли получим улучшение программы, но зато испортим формулу. (Конец примечания 1.)
Примечание 2. Хорошо известный способ обнаружения тех мест в программе, где можно приложить энергию, направленную на оптимизацию программы, состоит в том, что при составлении программы в ней предусматриваются такие действия, которые позволят по завершении программы получить распечатку полной гистограммы, указывающей, сколько раз выполнялся каждый оператор; тем самым определяется, где
нужно оптимизировать программу. Если применить этот способ к нашей программе, мы увидим, что значительную часть времени поглощает внутренний цикл, приводящий аг^ к нужному интервалу, особенно когда относительно много времени уходит на операции над векторными переменными и на получение значений функций от этих переменных. Для данного примера такая информация не только тривиальна, но и вредна в том смысле, что этот внутренний цикл и не нужно оптимизировать! (Оптимизируя его, сокращая количество «индексов», что легко можно сделать, если ввести две скалярные переменные art и агр равные соответственно аг^Ц и ar(j)f мы получим более длинный и более беспорядочный текст.) Дело в том, что вычисления поглощают много времени только тогда, когда N— простое число или когда его наименьший простой множитель близок к его квадратному корню. В этом случае на протяжении основной части времени вычислений мы будем иметь аг.вег = 2 (а именно начиная с того момента, когда f минует значение корня кубического из N). Мы можем заменить предохранитель внешнего цикла на
аг (0) ^ 0 and аг .вег > 2
и воспользоваться на заключительном этапе тремя скалярными переменными гО, г\ и г2. В составление этой заключительной части программы можно вложить всю свою изобретательность. (^Конец примечания 2.)
Примечание 3. Если вычисления дают нам p=N, этот результат очень трудно проверить. Даже если программу выполняет машина, которую нельзя назвать сверхнадежной, наш алгоритм позволяет значительно повысить степень уверенности в полученном результате: после завершения вычислений должно иметь место отношение N = f * rx-V-r0\ Если какая-то из арифметических операций была выполнена неверно, то очень маловероятно, что, несмотря на это, последнее равенство будет иметь место. С точки зрения надежности этот алгоритм, где новое значение г всегда вычисляется как функция старого значения, имеет существенное преимущество перед алгоритмом, где очередное простое число, проходящее проверку в качестве возможного множителя, выбирается из таблицы: таблица может испортиться, и, кроме того, каждая проверка на делимость представляет собой совершенно отдельные вычисления. Стоит отметить, что такой огромный выигрыш в надежности стал возможным благодаря «вынесению отношения из конструкции повторения» и ничему больше. Алгоритм, описанный в этой главе, использовался не только для получения высоконадежного разложения на множители (типа p = N^ но
Задача о наименьшем простом множителе большого числа 193 также и для проверки надежности арифметического устройства машины. ^Конец примечания 3.)
Утешение. Тем из моих читателей, кому эта глава показалась трудной, приятно будет узнать, что даже моим ближайшим сотрудникам понадобилось больше часа, чтобы «переварить» ее: программы могут быть очень компактными. ^Конец утешения.)
21 ЗАДАЧА О САМЫХ УДАЛЕННЫХ СЕЛЕНИЯХ
Рассмотрим п селений (я>1), перенумерованных с 0 до п—1; для 0^Д<п и O^j<n задана вычислимая функция f(i, j), удовлетворяющая для некоторой заданной положительной константы М условию
для / =^ у : 0 < / (/, у) < М
для i = j'f(h j) = M
Для t-го селения степень его удаленности «id(Z)» задается какid (/) = минимум /(/, у) = минимум /(/, у)
(Здесь f(z, /) можно интерпретировать как расстояние от i до /; правило f(t, j)=M было добавлено с целью упрощения.)
Требуется определить набор максимально удаленных селений, т. е. набор значений к, таких, что
(A/z:O</z<n:id(/zXid (А))
Предполагается, что программа представит этот набор значений в виде последовательности
miv (miv .ниг^,..., miv(miv.eez^
Заметим, что, вообще говоря, возможны все значения 1 ^ miv. об л ^п.
Очень простая и бесхитростная программа вычисляет последовательно п степеней удаленности и сохраняет максимальное из полученных значений. В соответствии с границами f(z, /) мы можем взять в качестве минимума для пустого набора значение М и в качестве максимума для пустого набора — 0.
begin glocon п. М; virvar miv; privar max. i;
miv vir цел array:— (0); max vir цел. i vir цел;— 0, 0;
do I n
begin glocon п, М\ glovar miu, max, i; privar min, j;
min vir цел, j vir цел; = M, 0;
do j ^ n -►
do / (i, j) <_ min ->- min := / (i, j) od;
j • = j +
od \min = \A (z)];
if max^ min -> пропустить
max= min -> miv : вверх {i)
max<Zmin—^miv : — (0, i\, max := min
fi;
i : = /+1
end
od
end
Приведенная программа очень безыскусна: в самом внутреннем цикле значение min при каждом прохождении по циклу монотонно не возрастает, и следующая за ним конструкция выбора будет одинаково реагировать на любое значение min, удовлетворяющее условию min<max. Из двух этих замечаний мы делаем вывод, что продолжать внутренний цикл имеет смысл только до тех пор, пока minimax. Следовательно, мы можем заменить строчку «do j^n-*» на
«do j ^ п and min ^ max-*»
и комментарий, следующий за соответствующим od, на {id (z) •< min < max or id ^=min i> max]
Назовем эту модификацию «оптимизация 1».
Совсем по-другому можно оптимизировать программу, если известно, что
/(Л j) = f(h
и тогда, учитывая, что на вычисление f затрачивается много времени, не будем вычислять f(i, j) для тех значений аргументов, для которых уже вычислено значение /(/, I). Мы можем так усовершенствовать первоначальную программу, что для каждой неупорядоченной пары аргументов соответствующее /-значение будет вычисляться только один раз. Мы достиг-
нем этого, если вместо начального значения, равного нулю, будем каждый раз придавать / значение /4-1, т. е. будем, так сказать, просматривать только верхний треугольник симметричной матрицы расстояний. В этом случае программа обеспечит правильное вычисление min только при условии, что начальным значением min будет не Л1, а
минимум /(/, /г)
0<П<1
Чтобы реализовать такую инициализацию, введем массив, скажем Ь, такой, что для к, удовлетворяющего условию 1^к<п,
для Z = 0: Ь^ = М
для />0 : 6(£) = минимум Дк, к)
0<h<i
(В словесной формулировке: &(&) есть минимальное из связанных с селением к расстояний, вычисленных к настоящему моменту.)
Программа, полученная в результате оптимизации 2, также довольно бесхитростна.
qegin glocon п, М; virvar mzv; privar max, i, b;
mi/u vir цел array ; = (0); max vir цел, i vir цел; = 0, 0;
b vir цел array := [0); do Ь.обл =# n—> b ; ввеpx tM) od;
do i =# a—♦
begin glocon zz; glovar mro, max, i, b; privar min, j;
min vir цел, b; никои; j vir z^vz: = z-Ll;
do j#= n-^
begin glocon i; glovar min, j, b; privar //;
// vir цел;= У);
do // <^min—*min: = // od;
do //<Ь^-»ЬЦЛ = // od;
, У:=У+1end
od\min=id (z)j;
if maximinпропустить
max=min —> min : вверх (z)
max <^ min —, miv • = (0, z); max;—min
fi;1
z: = z *-]- 1
end
od end
При попытке объединить обе эти оптимизации возникает некоторая проблема. При оптимизации 1 сканирование строки матрицы расстояний прекращается, когда min становится достаточно малым; при оптимизации 2, однако, сканирование строки есть также сканирование столбца, и это сделано, чтобы хранить последние полученные значения b^Y Давайте применим оптимизацию 1 и заменим строчку «do/^/г-^» па
<do у ^ п and min3> max --»>
Теперь caMwfi внутренний цикл может завершиться при ]<п; значения Ь^ для j^k<n, изменение которых еще может представлять интерес,— это значения, удовлетворяющие условию b (/г) ^тах, остальные уже достаточно малы. Мы получим желаемый результат, если вставим в программу следующую конструкцию: do п —
if b (у) < max у : = у + 1 □ b (у) > max —>
begin glocon i; glovar у, b; privar //;
// vir цел^/^Y У);
do ff <bVj\--b:Vj^ = /J od;
end fi od
Лучше всего вставить эту конструкцию непосредственно перед «Z:= I + 1», но после корректировки max; чем больше max, тем больше вероятность, что корректировка Ь^ больше не потребуется.
Мы объединили две оптимизации, природа которых весьма различна. Оптимизация 2 состоит в том, что мы избегаем повторного выполнения работы, про которую известно, что она уже сделана, и эффективность этой оптимизации известна заранее. Оптимизация 1, однако,— это прием, эффективность которого зависит от неизвестных значений /; это один из многих возможных однотипных приемов.
Мы ищем те строки матрицы расстояний, чей минимальный элемент S превосходит минимальные элементы остальных строк, и идея оптимизации 1 состоит в том, что для этой цели нам не нужно вычислять действительный минимум для остальных строк, если мы можем найти для каждой строки верхнюю границу В? ее минимума, такую, что Bt<S. На некоторой промежуточной стадии вычислений для некоторой строки (строк) минимум S известен, поскольку все ее (их) элементы уже вычислены; для других строк известна только верхняя граница
Вг. Теперь совершенно ясно, что нам нужно выработать определенную стратегию. Должны ли мы сначала вычислить наименьшее число дополнительных элементов матрицы, которые еще потребуются для определения нового минимума, в надежде, что он окажется большим, чем тот минимум, которым мы располагаем, и следовательно, превысит некоторые В? Или мы должны сначала вычислить неизвестные элементы в строках с большим В в надежде добиться дешевой ценой снижения этой верхней границы? Или нужно как-то объединить эти подходы?
В моей первоначальной версии, объединявшей два подхода, «обновление оставшихся Ь^» откладывалось на некоторый более поздний момент в надежде, что тем временем max станет больше, но сомнительно, чтобы эта программа была более эффективной, чем та, которая приведена в настоящей главе. Конечно, она была более сложной, ей требовался еще один массив для хранения последовательности номеров селений. Я пришел к публикуемой версии только тогда, когда уже писал эту главу.
Глядя в прошлое, я понимаю, что зря потратил свою изобретательность на мою первоначальную программу: если она и была «более эффективной», то это могло быть только «в среднем». Но что значит в среднем? Определить это можно только при условии, что мы постулируем (совершенно произвольно!) распределение вероятности для матрицы расстояний ^i, j). Но в мои намерения совсем не входила подгонка алгоритма под специальный подкласс матриц расстояний!
Мораль такова: составляя универсальную программу, мы должны удерживаться от искушения применить хитроумную стратегию, дающую повышение производительности в случаях, которые могут никогда не встретиться, если такая стратегия значительно усложняет программу: простота программы — это гораздо менее расплывчатая цель. (Сложность в том, что мы часто так гордимся своей хитроумной стратегией, что отказ от нее причиняет нам боль.)
Примечание. Наша окончательная программа сочетает в себе две идеи, и мы пришли к ней, рассматривая сначала, так сказать, в качестве «переходных ступеней» две программы, каждая из которых основывалась только на одной из идей. Во многих случаях я нахожу такие переходные ступени весьма полезными. (Конец примечания.)
22 ЗАДАЧА О КРАТЧАЙШЕМ ПОКРЫВАЮЩЕМ ДЕРЕВЕ
Две точки можно соединить одной простой связью; три точки можно соединить вместе двумя простыми связями, в общем случае К точек могут быть соединены в одно целое N—1 простой связью. Такое множество взаимосвязей называется «деревом», или, если мы хотим подчеркнуть, что оно соединяет все N точек друг с другом, «покрывающим деревом», а сами связи называются «его ветвями». Кейли первым доказал, что количество различных деревьев для заданных N точек равно NN~2. (Проверьте истинность данного утверждения при ^ = 4, не советую проверять для N = 5^
Далее будем предполагать, что задана длина каждой из возможных ^* (N—1)/2 ветвей. Определив длину дерева как сумму длин его ветвей, мы можем задать вопрос: как найти самое короткое дерево для заданных N точек? (Пока будем считать, что заданные длины таковы, что кратчайшее дерево единственно.)
Замечание. Рассматриваемые точки не обязательно должны принадлежать евклидовой плоскости, а заданным длинам разрешается не иметь никакого отношения к евклидову расстоянию. (Конец замечания.)
Одно из очевидных и бесхитростных решений заключается в том, чтобы последовательно получить все деревья для N точек, определить их длину и отобрать самое короткое. Но, согласно теореме Кейли, с увеличением N этот метод быстро становится очень невыгодным, а затем и совершенно неприемлемым. Нам бы хотелось найти более эффективный алгоритм.
Сталкиваясь с проблемами, подобными этой (а нам с такой проблемой уже пришлось иметь дело при решении задачи о голландском национальном флаге), часто оказывается плодотворным, учитывая то, что мы пытаемся построить последовательный алгоритм, внимательно рассмотреть промежуточные состояния, которые могут возникнуть в процессе вычисления. Так как конечный результат является набором из N—1 ветвей, принадлежность которых самому короткому дереву
будет — мы надеемся — устанавливаться поочередно, нам следует разобраться в том, что можно сказать, когда уже известно некоторое количество ветвей искомого дерева.
Сразу же необходимо решить, будем ли мы иметь дело с общим случаем, когда известно произвольное подмножество этих ветвей, или ограничимся только некоторыми типами подмножеств, надеясь, что они окажутся как раз теми подмножествами, которые встретятся в наших вычислениях, и надеясь также, что это ограничение упростит наш анализ. Такой выбор представляется но Kpaiineii мере тогда, когда мы имеем в виду «естественный» тип подмножеств. В нашем примере напрашивается естественный тип подмножества, определяемый так, что выбранные ветви распределены между точками не случайным образом, а тоже образуют дерево. Так как этот специальный случай и на самом деле может оказаться проще общего случая, мы зададим наш вопрос в другой форме. Можно ли извлечь какую-нибудь пользу из того, что нам известно некоторое поддерево искомого кратчайшего дерева?
В дальнейшем будем предполагать, что ветви уже определенного поддерева и точки, связываемые ими, выкрашены в красный цвет, а все оставшиеся точки выкрашены в синий цвет. Можем ли мы найти еще одну ветвь, которая также должна принадлежать кратчайшему дереву? Так как результирующее дерево соединяет все точки друг с другом, оно должно содержать по крайней мере одну ветвь, соединяющую красную точку с синей. Назовем ветвь между красной и синей точкой «фиолетовой ветвью». Напрашивается очевидное предположение о том, что кратчайшая фиолетовая ветвь принадлежит и кратчайшему дереву.
Правильность этого предположения нетрудно доказать. Рассмотрим некоторое дерево Т для -V точек, которое содеп жит все эти красные ветви, по не содержит самую короткую фиолетовую ветвь. Добавим к нему самую короткую фиолетовую ветвь. При этом образуется цикл, содержащий по крайней мере одну красную и одну синюю точку, т. е. цикл, который, следовательно, содержит ио Kpaiineii мере еще одну (более длинную) фиолетовую ветвь. Удалим из цикла такую более длинную фиолетовую ветвь. Получающийся в результате граф также является деревом. В дереве Т мы заменили некоторую фиолетовую ветвь более короткой, и поэтому дерево Т не могло быть кратчайшим. (Дерево для N точек есть граф, обладающий следующими тремя свойствами:
Оно соединяет друг с другом N точек.
У него N—1 ветвей.
У него нет циклов.
Из любых этих двух свойств следует, что граф является деревом и, следовательно, обладает и третьим свойством.)
Но теперь мы можем представить общин контур нашего алгоритма при условии, что нам удастся найти начальное красное дерево. Как только мы это сделаем, мы можем выбрать самую короткую фиолетовую ветвь, покрасить ее и синюю точку на се конце в красный цвет и т. д. до тех пор, пока красное дерево не станет таким большим, что не останется ни одной синей точки. Чтобы начать этот процесс, достаточно покрасить произвольную точку в красный цвет:
покрасить произвольную точку в красный цвет, а оставшиеся точки в синий;
do число красных точек ^N->-
выбрать самую короткую фиолетовую ветвь;
покрасить ее и синюю точку на ее конце в красный цвет od
Теперь нашей главной задачей становится «выбор самой короткой фиолетовой ветви», так как число фиолетовых ветвей может быть очень большим, а именно k * (N—^), где к — число красных точек. Если «выбор самой короткой фиолетовой ветви» выполнять как отдельную операцию, потребуется в среднем число сравнений, пропорциональное №, и объем работы, выполняемой всем алгоритмом, будет пропорционален №. Впрочем, замечая, что операция «выбрать самую короткую фиолетовую ветвь» одна не встречается, а лишь как компонента конструкции повторения, зададим себе вопрос, нельзя ли применять метод «вынесения отношения из конструкции повторения», т. е. нельзя ли организовать программу так, чтобы последующие выполнения оператора «выбрать самую короткую фиолетовую ветвь» могли воспользоваться результатом предыдущего. Имеются веские соображения в пользу того, что это возможно, так как каждое множество фиолетовых ветвей тесно связано с последующим: множество фиолетовых ветвей определяется тем, как были разделены точки на красные и синие, и изменение этого разбиения каждый раз связано с тем, что одна синяя точка делается красной.
Желание сильно сократить время поиска при выборе самой короткой ветви из некоторого множества означает сокращение объема этого множества; другими словами, мы ищем подмножество фиолетовых ветвей — назовем их «ультрафиолетовыми»,— которое будет содержать самую короткую ветвь и которое можно использовать для передачи полезной информации от одного выбора к следующему. Мы представляем себе программу такой структуры:
покрасить произвольную точку в красный цвет, а остальные в синий;
определить множество ультрафиолетовых ветвей;
do количество красных точек ^N->-
выбрать самую короткую ультрафиолетовую ветвь; покрасить ее и синюю точку на ее конце в красный цвет; перевычислить множество ультрафиолетовых ветвей
od
где «ультрафиолетовый цвет» выбирается так, что
Гарантируется, что самая короткая фиолетовая ветвь находится среди ультрафиолетовых.
Множество ультрафиолетовых ветвей в среднем гораздо меньше множества фиолетовых.
Операция «перевычислить множество ультрафиолетовых ветвей» относительно недорогая.
(Мы требуем выполнения первого свойства, поскольку тогда наш новый алгоритм будет также правилен; мы требуем выполнения второго и третьего свойств, так как хотим, чтобы новый алгоритм был эффективнее прежнего.)
Можем ли мы найти такое определение понятия «ультрафиолетовый»? За неимением других сведений, я могу только предложить попробовать. Учитывая, что множество фиолетовых ветвей, идущих из к красных точек в N—к синих точек, содержит к * ^N—к^ элементов и что должен соблюдаться наш первый критерий, немедленно напрашиваются такие два очевидных возможных подмножества:
Для каждой красной точки кратчайшую фиолетовую ветвь, оканчивающуюся в ней, сделайте ультрафиолетовой; следовательно, множество ультрафиолетовых ветвей имеет к членов.
Для каждой синей точки кратчайшую фиолетовую ветвь, оканчивающуюся в ней, сделайте ультрафиолетовой; следовательно, множество ультрафиолетовых ветвей имеет N—к членов.
Нам нужно, чтобы ультрафиолетовое подмножество было небольшим, но мы не можем сделать выбор исходя из размера: в первом случае он будет меняться в пределах от 1 до N—1, во втором — от N—1 до 1. Поэтому если мы и можем на что-то решиться, то с учетом цены операции «перевычислить множество ультрафиолетовых ветвей».
Впрочем, не испытывая различные способы перевычисле-
ния, можно сразу же высказать одно соображение в пользу второго варианта. В первом варианте различные ультрафиолетовые ветви могут идти к одной и той же синей точке, и тогда мы знаем a priori, что самое большее одна из них будет покрашена в красный цвет; во втором варианте каждая синяя точка связана с красным деревом одним способом, т. е. красные и ультрафиолетовые ветви все время образуют покрывающее дерево для N точек. Поэтому давайте внимательно рассмотрим следствия второго определения понятия «ультрафиолетовый».
Рассмотрим состояние, когда у нас есть красное поддерево R и когда из множества соответствующих ультрафиолетовых ветвей (в соответствии со вторым определением — я больше не буду повторять это) самая короткая ветвь вместе со своей первоначально синей точкой Р покрашены в красный цвет. Число ультрафиолетовых ветвей, как и следовало ожидать, уменьшилось на 1. Но правильны ли оставшиеся ветви? Для каждой синей точки они представляют самую короткую связь с первоначальным красным деревом R, и следовало бы представлять самую короткую связь с новым красным деревом R + P. Этот вопрос решается одним простым сравнением для каждой синей точки В: если ветвь ВР короче ультрафиолетовой ветви, соединяющей В с R, последняя должна быть заменена на ВР, в противном случае рост красного дерева не ведет к новому кратчайшему пути между ним и точкой В. В результате мы видим, что и выбор кратчайшей ультрафиолетовой ветви, и перевычисление множества ультрафиолетовых ветвей являются операциями, стоимость которых пропорциональна N, стоимость всего алгоритма растет как N2, и введение понятия «ультрафиолетовый» в самом деле позволило нам получить экономию, на которую мы надеялись.
Упражнение
Убедитесь, что нерассмотренная альтернатива для понятия «ультрафиолетовый» менее плодотворна. (Конец упражнения.)
Так как операция «перевычислить множество ультрафиолетовых ветвей» предполагает для каждой ультрафиолетовой ветви проверку, нужно ли эту ветвь заменить или сохранить, заманчиво объединить этот процесс и выбор самой короткой ветви нового множества ультрафиолетовых ветвей в одном цикле. Вместо того чтобы передавать следующему повторению модифицированное множество ультрафиолетовых ветвей, будем передавать немодифицированное множество вместе с
«пкц», номером точки, которая была последней выкрашена в красный цвет. Задача инициализации может быть легко решена, если известна верхняя граница длин всех ветвей инф; будем считать, что эта константа нам известна, ультрафиолетовым ветвям немодифицированного множества зададим начальную длину, равную инф (и соединим синие точки с воображаемой точкой с номером 0).
Мы предполагаем, что заданные точки пронумерованы числами от 1 до М, а длина ветви между точками р и q задается симметрической функцией «расст(р, q^». Ответ нужно получить в виде дерева, состоящего из N—1 ветвей, причем ветви идентифицируются номерами своих конечных точек; то есть ответ — это (неупорядоченное) множество (неупорядоченных) пар чисел. Будем представлять их двумя массивами «из» и «в» соответственно таким образом, что для l^/z^^—1 «из^» и «6^» есть две конечные точки (их порядковые номера) h-й ветви. В нашем окончательном ответе все ветви будут упорядочены (по /г); единственный порядок, который имеет смысл,— порядок их окрашивания в красный цвет. Это наводит па мысль во время вычисления использовать массивы «из» и «в» для хранения и ультрафиолетовых ветвей с помощью следующих условий:
для 1 <С h < k : h-я ветвь красная;
для k^lK^N’dH ветвь ультрафиолетовая.
Чтобы не перевычислять длины ультрафиолетовых ветвей, введем локальный массив уфд («ультрафиолетовая длина»), т. е.
для k ^ h < 'N : уфд (Л) -- длина к-н ветви.
Далее, поскольку существует однозначное соответствие между ультрафиолетовыми ветвями и синими точками, то для того, чтобы определить, какие точки этих ветвей синие, можно договориться, что h-я ультрафиолетовая ветвь соединяет красную точку «из(Ь)» с синей точкой «e(h)».
В приводимой программе точка N— это произвольная точка, покрашенная вначале в красный цвет.
Замечание. По отношению к массиву «из» и скалярной переменной «куф» можно сделать упрек, что нам не удалось избежать бессмысленных инициализаций; однако они относятся к виртуальной точке 0, находящейся на расстоянии «инф» от всех других точек, и к такой же виртуальной нулевой ультрафиолетовой ветви. (Конец замечания.)
begin glocon N, инф; virvar из, в; privar уфд, к, пкц*, из vir цел array := (1); в vir цел array := (1);
уфд vir цел array := (1);
do из.обл ^ N — \
из*, вверх (0); в : вверх [из.обл); уфд : вверх ^инф) od;*
k vir цел, пкц vir цел*. = \, N;
do k ^ N —
begin glocon N, инф*, glovar из, в, уфд, к, пкц; privar куф, min, h;
куф vir цел, min vir цел, h vir цел:=0, инф, k\ do h^N
begin glocon N, в, пкц; glovar из, уфд, куф, mln, h;
privar длн;
для vir цел *.= рас rm (пкц, B(h}};
if длн < уфд (/;) —>уфд : ^ = длн; из : ^=пкц
□ для^, уфд (А) —»длн \ = уфд (Л)
fi [длн = уфд (Л)};
do длн <у min —> mln : = для; куф *. — р od {min= уфд(к\ф^\;
end
od {ветвь с номером куф есть кратчайшая ультрафиолетовая ветвь);
из *. переставить^, куф}\ в -.переставить (к, куф\, уфд*. переставить (к, куфу
пкц*. = в (к); к*. = к-ф\; уфд*. снизу
end
od end
Несмотря на простоту конечной программы — собственно говоря, всего лишь цикл в цикле,— описываемый ею алгоритм нельзя считать тривиальным. По существу это весьма эффективный алгоритм как в отношении использования памяти, так и в отношении количества сравнений длин ветвей. Поэтому стоит еще раз перечислить основные моменты, приведшие к его обнаружению.
Первый решающий шаг был сделан тогда, когда мы попытались ограничиться такими промежуточными состояниями, в которых красные ветви, т. е. ветви конечного результата, сами образуют дерево. Преимущественный выигрыш состоит в том, что тогда количество красных точек превышает количество красных ветвей ровно на единицу и мы имеем право сделать
вывод, что ветвь из одной красной точки в другую не может никогда стать красной: произошло бы запретное замыкание цикла. (А теперь становятся видны контуры еще одного алгоритма: рассортировать все ветви по возрастанию их длин и обрабатывать их в этом порядке; причем обработка заключается в том, что, если обрабатываемая ветвь вместе с красными ветвями образует цикл, ее надо отбросить, иначе ее надо сделать красной. Очевидно, что этот алгоритм получает элементы конечного результата в порядке увеличения длин красных ветвей. Такой алгоритм менее привлекателен, поскольку, во-первых, нам нужно рассортировать все ветви, и, во-вторых, проверка, не замыкает ли новая ветвь цикл, также не слишком привлекательна. Эта проблема является темой следующей главы.) Отсюда можно сделать вывод, что попытка достижения конечной цели посредством «простых» промежуточных состояний обычно окупается!
Вторым решающим шагом явилась формулировка нашего предположения, что самая короткая фиолетовая ветвь может быть сделана красной. И здесь это предположение появилось не на пустом месте; если мы хотим «вырастить» красное дерево, нам нужно обращаться именно к фиолетовой ветви, а тогда то, что вероятным кандидатом будет ветвь самая короткая, вряд ли может показаться удивительным.
Наше решение не довольствоваться А3-алгоритмом — решение, которое привело к понятию «ультрафиолетовый»,— может показаться самым неожиданным. Нам могут возразить: «Предположим, что мне не пришло в голову исследовать, не могу ли я найти алгоритм получше?» Что же, это решение пришло тогда, когда у нас уже был алгоритм, и математический анализ первоначальной задачи по существу завершен. Мы занялись всего лишь оптимизацией, для которой никакого более глубокого знания, например теории графов, не требовалось! Кроме того, эта оптимизация следовала хорошо известному образцу: вынесение отношения из цикла. Мораль такова: когда алгоритм сделан, не считайте, что работа над ним уже закончена, а проверьте, нельзя ли его еще утрамбовать. Трудно представить, чтобы понятие «ультрафиолетовый» могло ускользнуть от внимания тех, у кого вошло в привычку проводить такую работу.
Замечание. Из анализа алгоритма следует, что самое короткое покрывающее дерево единственно, если длины любых двух различных ветвей неодинаковы. Проверьте, что если существует более одного кратчайшего покрывающего дерева, то наш алгоритм может построить любое из них. (Конец замечания.)
Существует и совершенно другой алгоритм, который помещает ветви в произвольном порядке, но каждый раз происходит проверка, не образуется ли при этом цикл, и тогда из него удаляется самая длинная ветвь (или одна из самых длинных).
23 АЛГОРИТМ РЕМА ВЫДЕЛЕНИЯ КЛАССОВ ЭКВИВАЛЕНТНОСТИ
В общем случае точки графа (не обязательно являющегося деревом) называются «вершинами», а связи между ними — «ребрами», а не ветвями. Граф называется «связанным» тогда и только тогда, когда он либо содержит только одну вершину, либо между любыми двумя различными вершинами существует по крайней мере один путь, проходящий по его ребрам. Так как многие из возможных ребер графа с N вершинами могут отсутствовать, граф нс всегда связан. Но каждый граф, как связанный, так и несвязанный, можно всегда однозначно разделить на связанные подграфы, т. е. все вершины графа можно разделить на такие подмножества, что любая пара вершин из одного и того же подмножества связана, а для любых двух вершин, взятых из двух различных подмножеств, связывающего их пути не существует. (Для математиков: «связанность» есть рефлективное, симметричное и транзитивное отношение, которое, следовательно, порождает классы эквивалентности.)
Мы будем рассматривать N вершин, занумерованных от О до /V—1, предполагая, что N достаточно велико (скажем, 10 000), и последовательность графов Go> Gh G2, ..., которые получаются при связывании этих вершин ребрами множеств £0, Ех, Еъ ... , где £0 пусто, а Енх = £; + {ej; во, ех, е% ... — есть некоторая заданная последовательность ребер. Ребра е^,ех^ е% ... должны обрабатываться в данном порядке, и после обработки п ребер (т. е. когда последнее из обработанных ребер, если такое было, есть ребро en-i) для любой пары вершин мы должны иметь возможность определить, является ли она связанной в графе Gn или нет. Основная задача, которую нужно решить в этой главе, заключается в следующем: «Как хранить в памяти всю существенную для нас информацию, которую мы получаем, когда знаем последовательность ребер во, ех, ... , еп_х?»
Конечно, мы могли бы хранить саму последовательность ребер «е0, ех, ... , еп_х», но такое решение не очень хорошо с
практической точки зрения, так как, во-первых, одновременно мы запоминаем и много не относящейся к делу информации; например, если мы уже обработали ребра {7, 8} и {12, 7}, то новое ребро {12, 8} не говорит нам ничего нового! И, во-вторых, ответ на вопрос: «Связаны ли вершины р и q?»— наш главный вопрос!— спрятан слишком глубоко.
Как пример другой крайности мы могли бы хранить полностью всю матрицу связности, т. е. N2 битов (или, если использовать то, что эта матрица симметрична, N* (М+1)/2 битов), так что ответом на вопрос, связаны ли вершины р и q, является булевское значение, хранимое как элемент матрицы соппР,г/. (Нас не должно смущать то, что мы говорим о матрице, хотя введенные нами векторные переменные более похожи па векторы; мы могли бы ввести, скажем, переменную «асоп» и воспользоваться следующим равенством:
to\\\\piq = acon Ш * р-тЯ} Для 0 N и 0 < N)
Я назвал это другой крайностью, потому что здесь на самом деле запоминаются готовые ответы на все возможные вопросы вида: «Связаны ли вершины р и 9»?; но запоминается чересчур много, так как большинство из тех ответов, вообще говоря, зависят друг от друга. Решение хранить информацию в такой избыточной форме — достаточно серьезное дело, так как, чем больше избыточной информации мы храним, тем большие преобразования информации могут потребоваться при обработке нового ребра.
Болес компактный способ представления того, какие вершины связаны друг с другом, состоит в указании (например, перечислении) тех подмножеств, которые соответствуют текущему разделению. Тогда вопрос о том, являются ли связанными вершины р и q, сводится к вопросу об их принадлежности к одному и тому же подмножеству, т. е. к вычислению значения логического выражения
под множество^р) — под множество^])
Так как имеется самое большее N различных подмножеств, потребуется не больше чем N * log2Af битов (а не N2 битов полной матрицы связности).
Для ответа на вопрос о том, связаны ли вершины р и q, было бы очень полезно ввести массив для хранения значений функции «подмножество», но ... ее перевычисление может оказаться утомительной процедурой! В самом деле, предположим, например, что подмножество(р) = Р, подмножество^) = Q и P^Q, и предположим, далее, что очередное ребро, которое нам нужно обработать, есть ^р, qY В конечном счете оба эти подмножества должны быть объединены в одно, а это, если
сами подмножества уже достаточно велики, потребует значительных модификаций. Кроме того, если мы работаем только с векторной переменной «подмножество», то как нам найти, например, все значения к, такие, что подмножество(к) = Q (предполагая, что Р станет именем нового подмножества, полученного в результате выполненного объединения), не перебирая всю область определения функции «подмножество»?
Способ разрешения этой дилеммы заключается в том, что* бы хранить не функцию «подмножество», а некоторую другую функцию, скажем «f», обладающую следующими свойствами:
Для заданного значения р знание функции / позволяет, по крайней мере в среднем, легко вычислить значение подмножество^).
При обработке нового ребра {р, q) обеспечена, по крайней мере в среднем, легкость перевычисления функции /.
Можно ли придумать такую функцию?
Учитывая, что основная информация, которая обрабатывается при введении нового ребра, состоит в том, что впредь обе вершины будут принадлежать одному и тому же подмножеству, напрашивается такое определение функции f:
«вершина пт.к принадлежит тому же подмножеству, что и вершина nr.f{k)»
т. е. как аргумент f, так и значение функции / интерпретируются как номера вершин.
Так как в начале работы Ео пусто, то каждая вершина является единственным членом подмножества, которому она принадлежит, и, следовательно, наша функция должна быть инициализирована следующим образом:
/ (&) = к для 0 < к << N
и самым естественным наименованием каждого из N различных подмножеств будет, таким образом, номер той единственной вершины, которую это подмножество содержит. Очевидное обобщение на случай подмножеств, состоящих из большего числа вершин, заключается в отождествлении каждого подмножества с номером одной из содержащихся в нем вершин — с которой именно, нас пока не интересует; принимая это условие, мы можем рассмотреть функцию f, такую, что
если f(k)=k, то вершина пг.к принадлежит подмножеству пт.к, и
если f(k)^k, то вершина пг.к принадлежит тому же подмножеству, что и вершина nr.f(k).
Последовательно применяя вычисление функции f, будем
переходить от одной вершины подмножества к некоторой другой вершине того же самого подмножества. Если мы будем следить за тем, чтобы этот процесс не стал циклическим, прежде чем мы найдем «идентифицирующую вершину подмножества», т. е. ту вершину, номер которой унаследован самим подмножеством, то знание функции / действительно позволит нам вычислить подмножество(р) для любого значения р.
Более точно, в формальных обозначениях:
/Чр)—р и для 1>0
для каждой вершины р, принадлежащей подмножеству ps, существует значение j, такое, что
для I < у : р (р) ^ ps
для i> j; р [р) = ps
Отсюда следует, что для 0^/1 </2^/ : fj} (р) =Pfj2(p) и конструкция
«ps vir цел : = р; do ps ^ f [ps) ps : = / [ps) od» завершит свою работу при ps= подмножество (р).
Тот факт, что функция / дает — возможно, после повторных применений — для всех точек подмножества nr.qs одну и ту же идентифицирующую вершину nr.qs, позволяет объединить это подмножество с подмножеством nr.ps и идентифицировать результат точкой nr.ps с помощью всего лишь очень небольших изменений в функции /; вместо равенства Pqs)=qs должно выполняться равенство f(qs)=ps. Обработка ребра {р, q^, следовательно, может быть сделана следующим оператором:
begin glocon р, q; glovar /; privar ps, qs;
ps vir цел\ = p; do ps^ f [ps)-^ ps\ = f [ps) od;
\ps = под множество (p)}
qs vir цел\ = q; do qs ^ f[qs)-^qs; = / [qs) od; [qs = подмножество [q)\
/;[qs) = ps end
Приведенный выше способ обработки нового ребра, хотя и правилен, но не очень привлекателен, поскольку в худшем случае может стать слишком неэффективным: эти циклы может потребоваться повторять очень много раз. Кажется полезным
произвести «чистку дерева»; как для вершины р, так и для вершины q мы теперь знаем текущие идентифицирующие вершины и было бы жалко вновь и вновь прослеживать связанные с ними пути. Чтобы исправить эту ситуацию, предлагаем следующий внутренний блок (мы также включили в него попытку уменьшить число вычислений функций f):
begin glocon р, q; glovar /; pricon ps;
begin glocon p, f; virvar ps; privar psO;
psO vir цел, ps vir цел p, J VpV
do psO ^ ps->- psQ, ps : = ps, / ^ps) od
end (ps — подмножество (p)};
begin glocon p, ps; glovar j; privar psO, psi;
psO vir цел, ps\ vir цел\ = p, f (p);
do psO ^psi/ : (p$0) = ps; psO, psi : = psi, / (psi) od
end {«подчистка» вершин, которые встречаются после
пг.ру,
begin glocon q, ps; glovar /; privar qsO, qsl;
qsO vir цел, qsl vir цел\=у, f [p);
do qsO Ф qsl ->-/ : ^qsO) = ps; qsO, qsl := qsl, / (^sl) od;
/ PAqs^ — ps
end («подчистка» вершин, которые встречаются после
nr.q, и объединение всего подмножества с nr.ps\ end
Упражнения
Дайте более формальное доказательство правильности приведенного выше алгоритма.
Мы совершенно произвольно решили, что подмножество (р) не изменяется и что подмножество (q) переопределяется. Можете ли вы с выгодой использовать эту свободу?
^Конец упражнений.)
Рассмотренный алгоритм был включен не из-за его красоты. На самом деле, я бы не удивился, если бы большинство моих
читателей остались им неудовлетворены. Например, очень неприятно, что совсем непросто произвести оценку того, что мы выиграли, заменив его первую версию второй. Этот алгоритм был включен по двум другим причинам.
Во-первых, это дало мне прекрасную возможность обсудить различные способы представления информации и проиллюстрировать некоторые соображения экономичности. Во-вторых, интересно отметить, что мы допускаем в качестве промежуточного состояния неоднозначное представление текущего разбиения (однозначного) и позволяем откладывать ликвидацию неопределенности и производить ее тогда, когда это будет удобно. Я могу припомнить не один случай, когда использование неоднозначного представления приводило к находкам, которые вряд ли можно было обнаружить другим способом.
Когда Рем прочитал этот текст, он задумался — мое решение показалось ему далеко неудовлетворительным — и вскоре показал мне другое решение. В некоторых отношениях алгоритм Рема настолько красив, что я не избежал соблазна и включил его также. (Следующие рассуждения являются результатом совместного обсуждения, в котором принимал участие и Фейен.)
В первом решении плохо то, что, начиная с р, путь к вершине дерева проходится дважды: один раз для определения ps и второй раз для чистки дерева. Кроме этого, разрушается симметрия между р и q.
Два просмотра пути из р были необходимы, так как мы хотели произвести полную чистку дерева. Впрочем, не зная номера идентифицирующей вершины, мы могли бы произвести по крайней мере частичную чистку за один просмотр, если бы знали направление в сторону «более чистого дерева». Поэтому предлагается использовать отношение порядка между номерами вершин и для каждого подмножества выбрать в качестве идентифицирующего номера, скажем, минимальное значение; в таком случае, чем меньше /-значение, тем чище дерево. Так как в начале /(&)=& для всех k, а наша задача превратилась в задачу уменьшения /-значений, то нам следует ограничиться представлениями разбиений, удовлетворяющим неравенству
/ (А) < k для 0 <; k < N
У этого ограничения есть еще одно преимущество, заключающееся в том, что становится очевидным, что единственно возможные циклы — это стационарные точки, для которых /(й) = ^ (т. е. идентифицирующие вершины).
Для того чтобы иметь возможность выражаться более точно, введем функцию «разб» и двуместную операцию « $ ».
разб(П обозначает разбиение, представляемое функцией f. разб (f)$ (р, q) обозначает разбиение, получающееся из разбиения разбф в результате объединения в одно подмножество подмножества, содержащего р, и подмножества, содержащего q. Тогда и только тогда, когда в разбиении разб(П вершины р и q уже содержатся в одном и том же подмножестве, справедливо разб= разб ф ^(р, q)
Обозначая начальное значение f через f„aq, обработку ребра (р, q) можно описать как обеспечение истинности отношения
R:разб (/) = разб (/„амШ^- ^)
Для этого (как обычно) вводятся две локальные переменные, скажем рО и ^0 (или, если вам больше нравится, локальное ребро), удовлетворяющие отношению
Р:разб(/)$(рО, q0) = pa36(fmq)$(p, q^
что тривиально обеспечивается при такой инициализации
рО vir цел, q0 vir цел: = р, q
Далее, после обеспечения отношения Р, алгоритм должен пересчитывать /, рО и q0 при инвариантности Р, пока мы не сможем заключить, что выполняется условие
Q:разб (/) = разб (/)$(р0, ^0)
так как (Q and Р) R.
Уравнение R в общем случае относительно / имеет много решений, нам бы хотелось получить «самое чистое»; поэтому предлагается искать такой процесс, при котором на каждом шаге уменьшается по крайней мере одно /-значение. В этом случае гарантируется, что процесс завершится (в качестве монотонно убывающей функции можно взять сумму Л//-значе- ний), и мы должны попытаться найти достаточное количество шагов, таких, что условие ВВ, дизъюнкция предохранителей, настолько слабо, что (non ВВ) Q.
Можно изменить значение функции / в точке рО, например, так:
/|ДрО) = нечто
но, чтобы обеспечить эффективное убывание вариантной функции, это «нечто» должно быть меньше первоначального значения f (рО), т. е. меньше чем pl, если мы введем
р^‘р1=/^ and ?1 = /(^0)
(Введение второго члена объясняется соображениями симметрии.)
Так как разб(() $ (рО, ^0) должно оставаться постоянным, очевидными кандидатами на это «нечто» являются q0 и ql; но, учитывая, что gl^gO, выбор qi в общем случае более эффективен, и нам нужно рассмотреть
qX<pX-*/"ApO3=q\
где предохранитель полностью обеспечен требованием эффективного уменьшения вариантной функции. Следующий вопрос такой: можем ли мы после изменения f воссоединить( рО, ^0) так, чтобы восстановилось, возможно нарушенное, условие Р. Так как связь (из рО) с pl удалена, надежным воссоединением будет возобновление (возможно) нарушенной связи с pl. После изменения f оператором f: (р0)=</1 мы знаем, что
разб(/)$(р1, х^разб^/^Н^Р, q^
для х, равных рО, q0 или q\. Условие Р легко восстанавливается (так как в качестве х можно выбрать д0) с помощью оператора «р0: = р1», который с учетом РХ кодируется как
рО, р\ := p\,fVp\^
Таким образом, мы приходим к следующей программе, где вторая охраняемая команда введена по соображениям симметрии:
begin glocon р, q; glovar /; privar pO, pl, qO, ^1;
pO vir цел, qQ vir цел;—р, q \P обеспечено};
pl vir цел, q\ vir цел;=/(pO), f^\P\ обеспечено}
do q\ < pl : (p0) = ^l; pO, pl := pl, /(pl)
□ P1<^1-*/:(90)=pl; q0, q\:=q\, /(^1) od
end
Конструкция повторения построена таким образом, что ее окончание не вызывает сомнений: при завершении мы можем заключить, что pl = ql, откуда при учете Р1 вытекает ДрО) = =f(^0) и откуда в свою очередь следует Q!
Замечание. При «контролируемой» разработке этой программы— и даже при последующем доказательстве правильности— введение обозначений «разб», «$ » или других подобных обозначений, по-видимому, очень существенно. Тем читате-
лям, которые сомневаются в правильности этого утверждения, предлагается попытаться самим провести это доказательство в терминах N значений f(A), а не в терминах удачно подмеченного свойства функции / как целого, как это сделали мы. (Конец замечания.)
Совет. Те читатели, которые не вполне оценили все очарование алгоритма Рема, пусть очень внимательно прочтут эту главу еще раз. ( Конец совета.)
24 ЗАДАЧА О ВЫПУКЛОЙ ОБОЛОЧКЕ В ТРЕХМЕРНОМ ПРОСТРАНСТВЕ
Чтобы отвести от себя обвинение в том, что я привожу только такие примеры, которые допускают простые и наглядные решения и в известном смысле не требуют сложной программы, сейчас мы займемся задачей, которая, как я уверен, окажется значительно более трудной. (К сведению моих читателей: когда я начинал работать над этой главой, я еще не видел ни одной программы, решающей задачу, которой мы собираемся заниматься.)
Пусть на прямой линии задано несколько различных точек, скажем с помощью их координат, и требуется выбрать такие точки Р, что все остальные точки лежат по одну сторону от Р. Эта задача проста: просмотрев один раз все координаты, мы определим их максимальное и минимальное значения.
Когда несколько различных точек с помощью их координат х, у заданы на плоскости, нас могут попросить выбрать те точки Р, через которые можно провести прямые линии, такие, что все остальные точки лежат по одну сторону от каждой из этих прямых. Такие точки Р являются вершинами того, что называют «выпуклой оболочкой заданных точек». Сама выпуклая оболочка — это циклически упорядоченная последовательность точек, обладающая тем свойством, что все остальные точки лежат по одну сторону от прямых, проходящих через пары ее последовательных точек. Выпуклая оболочка — это кратчайшая замкнутая линия, такая, про каждую точку которой известно, что она лежит либо на этой линии, либо внутри нес.
В данной главе мы будем заниматься аналогичной задачей в трехмерном пространстве. Своими координатами х, у и г задаются N различных точек (7V большое), таких, что никакие четыре различные точки нс лежат в одной плоскости. Требуется выбрать все точки Р, через которые можно «провести» плоскость, такую, что все остальные точки лежат по одну сторону от этой плоскости. Эти точки являются вершинами выпуклой оболочки для заданных N точек, т. с. минимальной замкнутой поверхности, обладающей тем свойством, что каж-
дая точка лежит либо на ней, либо внутри нес. (Ограничение, чтобы в каждой плоскости находилось не более трех точек, введено для упрощения задачи; в результате все грани выпуклой оболочки будут треугольниками.)
На время мы оставим открытым вопрос о том, следует ли получать выпуклую оболочку как объединение треугольников, являющихся ее гранями, или как граф из ее вершин и ребер, где ребро — это линия, проведенная через две различные вершины, так что через эту линию можно провести плоскость, причем все оставшиеся точки окажутся по одну сторону от нее.
Причина, по которой мы откладываем принятие этого решения,— это та же самая причина, по которой задача о трехмерной выпуклой оболочке является такой неудобной. В двумерном случае выпуклая оболочка одномерна, и ее «обработка» (просмотр, формирование и т. д.) вполне естественно выполняется последовательным алгоритмом над линейной памятью. Однако в трехмерном случае ни представление «двумерного» ответа с помощью линейной памяти, ни «установка очередности» при работе с ним далеко не самоочевидны.
Все известные мне алгоритмы для решения соответствующих двумерных задач можно считать частными случаями такой абстрактной программы:
построить выпуклую оболочку для двух или трех точек;
do существует точка вне текущей оболочки выбрать некоторую точку вне текущей оболочки;
модифицировать текущую оболочку так, чтобы она включала и выбранную точку
od
Известные алгоритмы отличаются один от другого в нескольких отношениях. В одном из подклассов этих алгоритмов в операторе «выбрать некоторую точку вне текущей оболочки» выбираются только такие точки, которые являются вершинами конечного ответа. Эти алгоритмы разделяются на два подподкласса: в одном подподклассе конечному ответу будет принадлежать не только новая вершина, но также и одно и новых ребер; во втором подподклассе новая вершина для ответа выбирается так, чтобы по возможности уменьшить число точек, все еще находящихся вне оболочки. Эффективность такой стратегии в худшем и лучшем случаях зависит, однако, от положения заданных точек, а их «средняя эффективность» определяется только по отношению к совокупности множеств точек, из которых наше входное множество является случайной выборкой. (Вообще говоря, алгоритм с резко различающейся производительностью в лучшем и худшем случаях не
очень гщивле .юиен, беда состоит в том, что часто эти границы можно сблизить, лишь сделав эффективность одинаково низкой во о< / ' случаях.)
Второй аспект, по которому различаются известные алгоритмы для двумерного случая, — это вопрос о том, какие точки лежат внутри текущей оболочки (и поэтому могут быть исключены из рассмотрения). Чтобы определить, лежит ли произвольная точка внутри текущей оболочки, мы можем просмотреть все ребра (например, последовательно): если для всех ребер верно, что точка находится во внутренней полуплоскости, то ючка находится внутри выпуклой оболочки. Вместо того чтобы просматривать все вершины, можно использовать то свойство, что точка лежит внутри выпуклой оболочки, только когда она лежит внутри какого-либо треугольника, вершины которого являются вершинами оболочки, и мы можем попытаться найти такую тройку вершин в соответствии с некоей стратегией «наибыстрейшего включения». Некоторые из этих стратегий могут быть в среднем значительно убыстрены с помощью хранения дополнительной информации и экономии вычислительного времени за счет памяти машины.
Из всего этого следует ожидать, что для трехмерного случая набор алгоритмов, заслуживающих определения «разумный», будет достаточно разнообразен. Было бы тщетно пытаться исследовать этот класс с достаточной полнотой, и уверяю вас, что я буду более чем счастлив, если мне удастся найти один, возможно, два «разумных» алгоритма, которые не покажутся излишне запутанными.
Лично я считаю такое глобальное исследование потенциальных трудностей, как приведенное выше, очень полезным, так как из него следует, что, решая задачу, я должен учитывать свои скромные возможности. В данном случае из него следует, что я должен учитывать свои очень скромные возможности и, по-видимому, нс должен увлекаться сложными рассуждениями, пока мы не обнаружим — если обнаружим! — что, в конце концов, наша задача не так плоха, как показалось с первого взгляда. Самое радикальное упрощение, которое я могу придумать,— это ограничиться топологией и воздержаться от всех стратегических соображений, основанных на математических ожиданиях числа точек внутри данных объемов.
По-видимому, самым разумным будет рассмотреть различные способы решения двумерной задачи и разобраться в способах их обобщения на случай трех измерений.
Одно из простых решений двумерной задачи связано с произвольно упорядоченными точками; начиная с выпуклой оболочки для трех точек, последовательно получаются выпуклые
оболочки для п первых точек. Всегда, когда в рассмотрение включается следующая точка, необходимо решить две задачи:
Нужно узнать, где лежит новая точка: внутри или вне выпуклой оболочки.
Если она лежит вне текущей оболочки, то последнюю нужно модифицировать.
Один из способов состоит в нахождении множества к смежных (!) ребер, таких, что новая точка лежит вне их. Эти к ребер (и к—1 точек) должны быть удалены при модификации; они будут заменены одной новой точкой и двумя ребрами. Если в процессе поиска нам не удалось найти такое множество, то новая точка лежит внутри.
Исходя из предположения, что текущие вершины циклически упорядочены, т. е. для каждой вершины можно найти предшествующую и последующую вершины, наименее «хитрая» программа будет исследовать ребра по порядку. В трехмерной задаче эквивалентом ребер являются грани-треугольники, эквивалентом множества смежных ребер — множество связанных треугольников, которое топологически эквивалентно кругу. Будем называть его «шапкой».
Выпуклая оболочка для двумерной задачи должна быть разделена на две «секции»: множества ребер, которые отвергаются и которые сохраняются соответственно; секции разделяются двумя точками. В трехмерной задаче выпуклая оболочка должна быть разделена на две шапки, разделенные циклом из точек и ребер.
Мы можем начать с сопоставления выбранной новой точки с произвольной гранью и считать ее начальной гранью одной из двух шапок; к шапке может добавляться каждый раз не более чем по одной грани, и процесс прекратится либо потому, что шапка содержит все грани (а вторая шапка пуста; новая точка лежит внутри выпуклой оболочки), либо потому, что она полностью окружена гранями, принадлежащими второй шапке.
Ключевая задача, состоит, по-видимому, в том, чтобы организовать изменяющийся набор граней таким образом, что если нам дана шапка, то мы всегда можем найти грань, присоединив которую, мы вновь получим шапку. Мы можем это сделать, если будем хранить (в циклическом порядке) точки (и пебра), принадлежащие границе шапки. У грани, которую можно добавить к шапке, должны быть общее с границей шапки ребро и, следовательно, две общие с ней точки: если в границе шапки окажется и третья точка этой грани, то в старой границе она должна быть соседней с одной из двух первых.
Мне бы не хотелось, чтобы поиск грани, расширяющей шапку, включал в себя просмотр всех оставшихся граней, т. е., если мне задается ребро границы, я бы предпочел иметь возможность быстрого доступа к необходимым данным, определяющим другую грань, которой принадлежит это ребро. Из нашего желания свести данные в таблицу — привычка работать г массивами — вытекает, что нам нужно также вести учет и группы перенумерованных ребер.
Тот факт, что ребро определяет две точки так же, как и две грани, и что грань равно хорошо определяется своими тремя точками и тремя ребрами, наводит нас на мысль постараться выбрать в качестве основных управляющих параметров ребра. По-видимому, наиболее симметричным наш аппарат будет тогда, когда мы занумеруем каждое неориентированное ребро дважды, т. с. будем считать его суперпозицией двух занумерованных, ориентированных ребер.
Пусть z— номер ориентированного ребра выпуклой оболочки; тогда мы можем определить
инв^ = номер ориентированного ребра, которое соединяет те же самые точки, что и ориентированное ребро nr.i, но в обратном направлении.
Далее, если мы связываем с каждой гранью три ориентированных ребра ее границы в порядке обхода по часовой ^стрелке (правосторонней границы), то каждое ориентированное ребро является ребром в точности одной грани и вся топология полностью определяется с помощью еще одной функции след^ = номер ориентированного ребра, являющегося следующим по часовой стрелке ребром той грани, к правосторонней границе которой принадлежит ребро nr.i; слово «следующий» означает, что ребро «след^» начинается там, где ребро «^кончается.
В результате мы имеем, что «Ь, «след^» и «след \след^^»— это номера ребер, образующие в данном порядке границу грани. Так как все грани — треугольники, справедливо
след [след [след[Г^ = 1
Функции «инв» и «след» определяют полное топологическое описание выпуклой оболочки в терминах имен ориентированных ребер. Если мы хотим перейти к номерам точек, мы можем ввести третью функцию
конец (i) = номер точки, в которой оканчивается ориентированное ребро nr.i.
Тогда верно ре земство конец (инв (I) ) = конец (след (след* (0)), так как оба выражения обозначают номер точки, в которой начинаете'.; оркимированное ребро пг.ц левое выражение предпочтительнее не столько из-за того, что оно проще, сколько потому, что оно не зависит от предположения, что все грани — треугольники.
Производить поиск множества ребер, которые оканчиваются в точке, скажем пг.к, очень неудобно, и его следует избегать. Вместо того чтобы запоминать значение «к», нам нужно запоминать «ек», такое, что конец (ек) — к. Тогда, например^ оператор
ек : —- айв -^след ^ек\\
присвоит ек номер следующего ребра, оканчивающегося в к; выполняя повторно данное преобразование, мы сможем «прокрутить» ек по всем ребрам, которые заканчиваются в точке пг.к (и таким образом мы получим доступ ко всем iраням. \ которых точка пг.к является вершиной).
Замечание. Так как инв(инв(1^) =3 и я думаю, что мы достаточно свободны в выборе номеров для ребер, мы чожеч выбирать номера 5^0 и ввести условие инв(1) =— i; в гаком случае нам вообще нс нужно хранить функцию инь. (Конец замечания.)
Теперь мы попытаемся разделить текущую оболочке на две шапки по отношению к новой вершине. Я стою на той точке зрения, что обнаружение, ио какую сторону грани лежит новая вершина, является трудоемкой операцией, и мне бы хотелось сопоставлять новую точку с каждой гранью самое большее один раз (а с некоторыми гранями, может быть, к не сопоставлять вообще).
Пользуясь языком метафор, мы можем считать новую точку лампой, освещающей все грани, на которые свет от нее падает снаружи. Назовем их «светлыми» гранями, а все остальные «темными» гранями. Светлые грани должны быть удалены; только тогда, когда точка находится внутри текущей оболочки, все грани остаются темными.
После того как окрашена первая грань, мне, естественно, хочется покрасить новые грани, являющиеся соседними к покрашенной. Теперь передо мной возникают два основных вопроса:
Считаем ли мы, что, пока мы обнаруживаем грани только оттого цвета, каждая следующая рассматриваемая грань должна образовывать шапку с уже покрашенными гранями, или доп; екаем наличке <"/:ыр»? Дело в юм, что проверка, повлечет
ли добавление новой грани появление дыры, — операция, не очень привлекательная.
После того как оказались использованными оба цвета, мы знаем, что новая точка лежит вне текущей оболочки, и нам также известно ребро, разделяющее две различно покрашенные грани. Нужно ли нам изменить стратегию и отныне пытаться прослеживать границу между темными и светлыми гранями, вместо того чтобы продолжать красить все грани первоначальным цветом?
Мне потребовалось много времени, чтобы найти подходящий ответ на эти вопросы. Так как мы встали на ту точку зрения, что будем сопоставлять каждую грань с новой точкой самое большее один раз, у нас в общем случае встречается три вида граней: «темные», «светлые» и «еще неизвестно какие». И нам необходимо запомнить определенное количество информации об этом, чтобы не повторять сопоставления одних и тех же граней с нашей новой точкой. Но у граней нет имен! Про грань мы знаем только то, что «она расположена вдоль ребра z», т. е. у нее на границе при обходе по часовой стрелке име- *ется ребро /, а для каждой грани существует три таких ребра. Поэтому кажется более выгодным в качестве основного элемента рассматривать не грани, а ребра, тем более что на первом этапе мы ищем ребро между светлой и темной гранью.
Для того чтобы было удобнее сопоставлять новую точку с гранями, введем две константы: «правильный» и «неправильный»; «правильный» — это цвет (соответственно темный или «светлый) первой (произвольно выбранной) грани, которая сопоставлялась с новой точкой, «неправильный» (соответственно светлый или темный) —это противоположный ей цвет.
Пусть К — множество ребер Щ такое, что
грань вдоль ребра i правильная (и это уже установлено) ;
грань вдоль ребра — I еще нс проверялась.
Таким образом, начальное состояние К содержит три ребра правосторонней границы первой сопоставленной грани. Когда множество К нс пусто, мы выбираем из пего произвольное ребро, скажем ребро х, и грань вдоль ребра —х сопоставляем (впервые, так как ребро х удовлетворяет критерию (2)) с новой точкой. Если новая грань неправильная, то найдено первое ребро между двумя различно окрашенными гранями и мы переходим ко второму этапу, обсуждать который будем позднее. Если новая грань тоже правильная, мы выбираем ребра у = — х, у-=след(—х) и у = след(след(—х^У т. е. ребра правосторонней границы новой правильной грани, и для каждого из
этих трех значений выполняем следующие действия:
if ребро — у в /<-> удалить ребро — у из К
□ ребро — у не в К -> добавить ребро у в К fi
В первом случае ребро —у больше не удовлетворяет критерию (2), во втором случае ребро у (которое не могло принадлежать К, так как не удовлетворяло критерию (1)) теперь удовлетворяет критериям (1) и (2) (поскольку, если бы грань вдоль —у была проверена раньше, ребро — у все еще было бы в К).
Тем временем мы уже выработали некоторое мнение, по крайней мере предварительное, по поводу вопросов 1 и 2: мы не настаиваем на том, чтобы грани, правильность которых установлена, всегда образовывали шапку, и мы действительно намереваемся менять стратегию при обнаружении первого ребра, принадлежащего границе между шапками. (Эти решения были приняты, когда я бросил писать и отправился на вечернюю прогулку по нашему поселку. Сначала я заметил себе, что прослеживание границы между двумя шапками, после того как найдено одно из ее ребер, сводит двумерный поиск к одномерному и, следовательно, надо постараться использовать эту возможность. Затем я осознал, что коль скоро я принял эту стратегию, то, по-видимому, нет особого смысла добиваться на первом этапе, чтобы грани, правильность которых установлена, всегда образовывали шапку. Далее некоторое время я пытался представить себе, как выполнить первый этап в терминах граней, являющихся правильными или непроверенными, пока не вспомнил то, что временно забыл, а именно что я уже решил описывать структуру в терминах ребер. С уверенностью, что теперь все должно получиться, я вернулся за стол и написал то, что вы сейчас прочитали.)
Теперь нам нужно заняться вторым этапом. Пусть х— ребро правосторонней границы правильной шапки .Как найти, например, следующее ребро этой границы? Мы видели, как можно перебирать грани с общей точкой; неизвестно одно — достаточно ли знание множества К для соблюдения принципа, что никакая грань не должна сопоставляться с новой точкой больше одного раза.
Ну что ж, по-видимому, на это рассчитывать не приходится, так как когда мы находим какую-либо неправильную грань, то, с одной стороны, мы по можем гарантировать, что мы уже точно знаем, что надо делать с ней, т. е. со всеми соседними с ней гранями, а с другой стороны, нам бы не хотелось сопостав-
лять эту неправильную грань с новой точкой более одного раза. Простейший выход, вероятно, состоит в том, чтобы ввести аналогичный аппарат для классификации ребер по отношению к неправильным граням, а именно пусть Н — множество ребер L такое, что
установлено, что грань вдоль ребра i неправильная;
грань вдоль ребра — I еще не проверялась.
Достаточно ли нам этого? Замечаем, что пересечение К и Н пусто, так как никакое ребро i нс может быть элементом и К, и Н.
Можно ли перевычислить оба множества К и Я, когда новая грань правильная и когда новая грань неправильная? Пусть х— ребро из множества К, и снова рассмотрим три ребра у, где у = — х, у = след^—х^ и у=след(след(—х)). Пусть в дальнейшем В — множество обнаруженных ребер правосторонней границы правильной шапки.
Если новая грань правильная, то каждое из ее трех ребер у обрабатывается так:
if ребро — у в К удалить ребро — у из К
□ ребро —у в Нудалить ребро —у из Н и
добавить ребро у к В
□ ребро —у не в (Н 4- К) добавить ребро у к К fi
Если новая грань неправильная, каждое из ее трех ребер у должно быть обработано так:
if ребро —у в Яудалить ребро —у из К и
добавить ребро —у к В
□ ребро —у в Н удалить ребро —у из Н
□ ребро —у не в (Н -^К^ -> добавить ребро у к Н fi
Глядя на эти формулы, я делаю одно из удивительных открытий, которыми так полна жизнь! Когда Н все еще пусто, а новая грань правильная, перевычисление сводится — и это не очень удивительно — к перевычислению из первого этапа. Но мы же нигде не пользовались тем, что х— элемент К: если бы х был элементом Я, этот алгоритм работал бы так же хорошо. Мы требуем только, чтобы х был элементом объединения н+к, так как хотим избежать повторного сопоставления и
тем самым обеспечить завершаемость работы! С точки зрения логики нам достаточно в качестве начального состояния К выбрать ребра правосторонней границы первой грани, а в качестве начального состояния Н и В — пустые множества. Тогда, до тех пор пока это возможно, мы будем выбирать произвольное ребро х из объединения Н-\-К и подвергать последовательные ребра у грани, расположенной вдоль ребра —х, описанной выше обработке. Этот процесс заканчивается, когда множество H-VK становится пустым; новая точка находится внутри текущей оболочки тогда и только тогда, когда но окончании работы В по-прежнему пусто!
Прежде чем продолжить рассуждения, мне кажется уместным сделать несколько ретроспективных замечаний. Я ввел ограничение, согласно которому каждая грань сопоставляется с новой точкой только один раз, из соображений эффективности, сделав логически необоснованное предположение, что для выполнения этой операции может потребоваться много времени. Мне не следовало бы заботиться об эффективности еще на первом этапе; мне следовало бы сосредоточить свое внимание скорее на логических требованиях, гарантирующих окончание работы. Если бы я это сделал, мне нс пришлось бы так долго возиться с «шапками» — этой навязчивой идеей — и я не стал бы считать оба этапа логически различными! (Я должен был бы более подозрительно отнестись к тому, что слишком долго выбираю ответы на вопросы (1) и (2)!) К чему все сводится? Мы замечаем, что если ребра в непустом множестве В образуют цикл, то можно прекратить работу, даже если Н-^-К все еще нс пусто, и зная это, можно попытаться использовать наше право выбора произвольного х из Н-\-К, так чтобы найти по возможности быстрее все ребра этого цикла. Поскольку мы пытаемся сделать все как можно проще, естественно отложить добросовестное прослеживание границы между двумя шапками как оптимизацию, которую мы, по всей видимости, можем, без труда ввести на более поздней стадии.
Введение понятий «правильный» и «неправильный», по-ви- димому, также было ошибкой, если учесть то, как мы выбираем ребро х из объединения Н+К. (Это было не только ошибкой, это было глупой ошибкой. Я долго колебался, перед тем как пойти на это, у меня были необычайные трудности с выбором подходящих имен для них и с формулировкой их определения, но я не внял этим предостережениям, убеждая себя, что я нашел нечто «хитрое».) Предадим забвению эти понятия и будем работать с нашими первоначальными терминами «светлый» и «темный». На этом пути у нас должна появиться возможность сделать три упрощения:
Нам незачем по-особому обрабатывать первую проверяемую грань; ее три ребра у могут обрабатываться так же, -как и ребра любой друго11 грани.
Обе приведенные выше различные конструкции обработки трех ребер новой проверяемой грани (для правильной грани и для неправильной грани соответственно) можно отобразить на одну конструкцию выбора.
Нам нс нужно менять направление ребер из множества В, если оно окажется правосторонней границей темной шапки.
Некоторые другие частности мы обсудим позднее, когда исследуем операцию модификации выпуклой оболочки для включения новой точки.
После обнаружения непустой границы нам нужно удалить внутренние ребра светлой шапки, если таковые имеются, и добавить ребра, соединяющие новую точку с точками границы этой шапки. Так как количество удаляемых ребер в общем случае никак не связано с количеством добавляемых ребер, мы предлагаем решать эти две задачи раздельно. В еще неде- тализированный процесс поиска границы, при котором проверяются все грани, нетрудно было бы ввести одновременное удаление всех внутренних ребер светлой шапки. Но мне хотелось бы сохранить за собой право на оптимизацию поиска границы, и поэтому нам надо выбрать метод нахождения множества внутренних ребер светлой шапки, когда задана ее непустая правосторонняя граница В, независимо от того, как эта граница была найдена.
В виду того что нахождение множества внутренних ребер светлой шапки является частным случаем (не таким уж специальным) более общей проблемы (ее более специальные случаи— это задача Хэмминга и обход двоичного дерева), мы займемся сначала этой общей проблемой (которая по существу является задачей о нахождении транзитивного замыкания).
Задано (конечное) множество элементов. Для каждого элемента х определяются несколько (или ни одного) других элементов, называемых «преемниками х». Для любого множества В множество 3(B) задается следующими аксиомами:
Аксиома 1. Каждый элемент В принадлежит 3(8).
Аксиома 2. Если х принадлежит 3(B), то преемники х, если таковые имеются, также принадлежат 3(6).
Аксиома 3. Множество 3(B) содержит только те элементы, которые принадлежат ему согласно аксио- .мам 1 и 2.
Для заданного В требуется обеспечить истинность
R; V = S[B)
Идея алгоритма очень проста: инициализировать V: = B и до тех пор, пока в V имеется элемент х, преемники которого не принадлежат V, дополнять V этими элементами; когда все такие элементы будут исчерпаны, остановиться. Алгоритм можно улучшить, если учесть следующее: после того как установлено, что для данного элемента у все его преемники уже находятся в множестве V, такое положение будет сохраняться и в дальнейшем. Это верно, потому что единственная модификация V состоит в расширении V новыми элементами. Следовательно, оптимизация должна состоять в том, чтобы следить за подмножеством из таких элементов у, поскольку их преемников рассматривать больше не нужно. Таким образом, множество V разбивается на две части, которые мы назовем С иУ~С. Здесь V ~С содержит все элементы, для которых было установлено, что их преемники уже находятся в множестве V, а С содержит все оставшиеся элементы, у которых могут найтись преемники, не содержащиеся в V. Алгоритм обрабатывает каждый раз один элемент из С. Пусть с — элемент из С; он удаляется из С (и, следовательно, добавляется к 1/с^С), а все его преемники, не принадлежащие V, заносятся в V и С. Алгоритм прекращает работу, когда С пусто. Окончание работы гарантируется конечностью множества 5(B), так как, хотя число элементов С может и увеличиться, число элементов V~C увеличивается на единицу на каждом шаге работы, и это число ограничено сверху числом элементов множества 5(B). (Количество шагов работы алгоритма не зависит от порядка выбора элемента с из множества С!)
Данное неформальное описание предполагаемого вычислительного процесса может удовлетворить часть моих читателей—меня оно удовлетворяло более пятнадцати лет. Я надеюсь, однако, что некоторые из читателей не будут удовлетворены. Самое большее, что мы можем сказать, не формулируя инвариантного отношения и не проводя четкого индуктивного доказательства: «Я не понимаю, как этот вычислительный процесс может не дать желаемого результата».
В доказательстве должно быть ясно выражено то, что подразумевается в интуитивном замечании о бессмысленности обработки преемников данного элемента более чем один раз. Для этого мы введем функции Sc(B"), которые определяются теми же самыми аксиомами, что и множество 5(B), но для другого множества преемников, а именно
если х принадлежит С, то у х нет преемников;
если х не принадлежит С, то у х тс же преемники, что и в первоначальном определении.
Так как S^(B) = S(B), то в качестве инвариантных отношений предлагается выбрать следующие отношения:
Р\ : 1/ = 5с(5) и Р2: С есть подмножество V которые легко обеспечиваются с помощью присваивания V, С: = В, В.
Функция Sck^ обладает следующими критическими свойствами:
Sc^B^—монотонная функция от С; это означает, что если С2 есть подмножество С1, то Sci(B) есть подмножество Scs^By
Sc(B^ не меняется, если С увеличивается за счет элементов, нс принадлежащих Sc (В).
Пусть с — произвольный элемент С; расширение С преемниками с, не принадлежащими V, не изменяет истинности отношения Р\ ввиду второго свойства. Последующее удаление с из С означает, что, в силу первого свойства, все элементы V остаются в множестве V, которое (с учетом свойств Р\ и Р2^ модифицируется оператором:
V := V4~ (множество преемников г)
(где « + » означает объединение множеств). Образование этого объединения сводится к расширению V преемниками с, не принадлежащими I/, т. е. элементами, которые только что были добавлены к С; но, так как эти новые элементы «не имеют преемников», модификация на этом заканчивается.
Замечание. Алгоритм транзитивного замыкания сам по себе достаточно интересен, и не в последнюю очередь, из-за того, что он показывает, почему обычное рекурсивное решение задачи об обходе двоичного дерева является его частным случаем. В роли С тогда выступает стек, и проверку, принадлежит ли V преемник с, можно опустить, так как отсутствие циклов в дереве обеспечивает то, что каждый узел будет встречаться только один раз. В связи с этим наблюдением возникают сомнения в правильности некоторых положений, так высоко оцениваемых некоторыми специалистами, занимающимися искусственным интеллектом. Одно из них заключаемся в том, что рекурсивные решения, которые считаются в некотором смысле «более основными», кажутся нам более естественными, чем решения с конструкциями повторения; в связи с этим положением возникла область (полу) автоматических систем переделки программ, которые заменяют ре-
курсивные решения на (как считается, более эффективные) конструкции повторения. Второе положение состоит в том, что в случае поиска в конечной части потенциально бесконечного множества необходимо лингвистическое средство рекурсивных сопрограмм. Имеется в виду, что из непустого множества С в качестве с можно выбрать любой элемент, а каждый разра- бо1чик операционных систем знает, что стратегия «последним пришел — первым ушел» чревата для индивидуального пользования опасностью быть «замороженным»; каждый разработчик операционных систем к тому же знает, как избежать этой опасности. Нс исключено, что будущие поколения решат, что за последние пятнадцать лет рекурсивные решения были возведены на слишком высокий пьедестал. Если так и произойдет, то само это явление будет объясняться тем, что до последнего времени у нас не было соответствующего математического аппарата для работы с повторениями и присваиваниями переменным. {Конец замечания.)
В нашем примере в качестве В (из приведенной выше общей трактовки) мы можем взять В, используемое при решении задачи о выпуклой оболочке, т. е. ребра правосторонней границы светлой шапки. «Преемники ребра I» могут быть определены так:
ничего, если след{1) принадлежит В;
след{1) и — след{1), если след{1) нс принадлежит В.
Конечным значением V тогда будет Вб- {множество внутренних ребер светлой шапки}.
Удаление внутренних ребер светлой шапки ставит перед нами задачу меньшей трудности. Так как мы хотим иметь таблицы функций след{1) и конец {I), нам бы хотелось занумеровать ребра выпуклой оболочки — исключая пуль — последовательными числами от —п до +//; разумеется, мы можем начать делать это (увеличивая массивы с обоих концов по мерс роста п), по при удалении ребер в нашей системе номеров начинают появляться «дыры». Представляется два пути выхода из этого положения: либо мы перенумеровываем оставшиеся ребра выпуклой оболочки и в дальнейшем опять используем последовательные номера, либо мы ведем учет «дыр» и используем их — когда они есть — как имена для добавляемых ребер. Решение перенумеровывать оставшиеся ребра, в общем, нс очень привлекательно, так как оно означает, чю нужно модифицировать все ссылки па перенумеровываемые ребра. Хотя в нашем случае эта модификация, по-види- мому, вполне осуществима, я бы предпочел воспользоваться
белее общим методом учета «дыр», т. е. неиспользованных ^имен в диапазоне от — п до + /?.
Стандартным методом использования дыр является метод типа «последним пришел — первым ушел»; для организации стека дыр используется одна из имеющихся функций, например след. Болес точно:
z = 0 постоянно является самой ранней дырой;
если /(¥=0) есть дыра, то след (z) —следующая по возрасту дыра.
И наконец, мы введем одну целую переменную, скажем пд («самая поздняя дыра»). Удаление ребра z, т. е. получение вместо него дыры, выполняется следующими действиями:
след ДД = пд; пд := /;
если других дыр, кроме нуля, нет, то пд = 0, иначе пд^О, и присваивание I значения самой поздней дыры выполняется операторами:
/: = пд; пд след Д)
Чтобы не усложнять управление данными, мы с одной дырой z будем связывать пару ребер { + б —Д.
По-видимому, наступила пора писать саму программу и зафиксировать способы задания аргументов и формы полученных ответов. Задается группа из .V^4 точек с помощью трех массивов констант лу у и г, так что
х. ни?. = у. наг = г. наг = 1
х. еег = у. вег = г. вег = N
и хДД уДД г(0 для l^i^N есть соответственно координаты X, у И 2 точки пгл.
Выпуклую оболочку мы предлагаем описывать с помощью двух массивов, «след» и «конец», и одной! целой переменной «старт» так, что
след. ^ иг = конец. ниг = — след .вег — — конец. вег и
старт — это номер некоторого ориентированного ребра выпуклой оболочки.
Если I — номер ориентированного ребра выпуклой оболочки, то
—z есть номер этого ориентированного ребра в обратном направлении;
конец Д) — номер точки, на которую указывает ориентированное ребро I;
след (t) —номер «следующего» ориентированного ребра в правосторонней границе грани, расположенной «вдоль» ребра I (определение понятий «следующий» и «вдоль» дано гораздо раньше).
Допущение дыр предполагает, что, возможно, имеются (и даже должны быть) значения t, такие, что в конце работы O^abs(i) ^след.вег, но i не есть номер ребра из конечного результата; введение переменной «старт» позволяет нам не брать обязательств в отношении значения след (I) и конец (I) для соответствующего значения I. Символически мы можем теперь описать функцию внутреннего блока, который нам предстоит разработать, следующим образом:
«(след, конец, ctnapm} vir оболоч := выпуклая оболочка (х, у, z)»
Если иметь в виду только наши внешние обязательства, то мы могли бы ограничиться введением одной личной переменной, скажем п, инвариантного отношения
Р1: (след, конец, старт) = выпуклая оболочка первых п точек (х, у, г) и блока
«(след, конец, старт) vir оболочка : = выпуклая оболочка (х, у, z)»
begin glocon х, у, z; virvar след, конец, старт*, privar п «инициализировать Р\ при небольшом значении и»*, do п ^х. вег -> «увеличить п на 1, соблюдая инвариантность Р\» od
end
Но этого недостаточно по двум причинам. Во-первых, мы хотим использовать дыры внутри повторяемого оператора, и поэтому нам нужно ввести переменную пд для обозначения самой последней дыры. Во-вторых, увеличение значения н предполагает просмотр (вдоль граней) ребер выпуклой оболочки. Мы могли бы ввести в повторяемый оператор массив, который каждый раз мог бы инициализироваться некоторым специальным значением для каждого ребра, означающим «еще нс сопоставлялось с новой точкой». С логической точки зрения все было бы совершенно правильно, но более эффективным кажется вынесение этого массива за границы повторяемого оператора с последующим контролем того, чтобы просмотр кончался теми же самыми нейтральными значениями, с которыми он начался. Мы назовем этот массив «набор», а в качестве нейтрального значения выберем 0. Обобщая сказанное, мы вводим следующее инвариантное отношение:
Р2:\след, конец, старт} = ъъ\тклъя оболочка первых п
।точек (х, у, z)
and /?д = самая поздняя дыра and (z есть дыра^0)=^ ^лед(/) —следующая по очередности дыра and (/есть ребро (^лсд, конец, старт^набор U^ = 0 а вариант, с которого я предлагаю начать, имеет вид «^'лед, конец, старт) vir оболоч : = выпуклая оболочка (х, у, z)»:
begin glocon х, у, z; virvar след, конец, старт; privar п, nd, набор;
«инициализировать Р2 при небольшом значении п»;
do п^ х. вег > «увеличить п на 1, соблюдая инвариантность Р2» od end
Инициализация выполняется очень просто, так как достаточно сделать это для /2 = 3, треугольника с двумя гранями: «инициализировать Р2 при небольшом значении п»;
begin virvar след, конец, старт, п, пд, набор;
след vir цел array := ( —3, — 2, — 1, —3, 0, 2, 3, 1); конец vir цел array := ( —3, 1, 3, 2, 0, 3, 1, 2); старт vir цел : — 1; п vir цел 3; nd vir цел := 0; набор v г цел array := ( — 3, 0, 0, 0, 0, 0, 0, 0)
end
Детализация оператора «увеличить п на 1, соблюдая инвариантность Р2» — единственное, что нам осталось сделать,— более сложное дело. Увеличить п и выбрать точку п в качестве новой точки (для ее координат будут введены переменные их, пу и nz) очень легко. Последующими шагами нужно определить границу и, если она есть, модифицировать оболочку. На первом шаге мы будем использовать значения набор (z) = ± 1 для обозначения «полуисследованных» ребер, т. е.
набор (/) = +1 означает: установлено, что грань вдоль ребра /.светлая, грань вдоль ребра —/ еще не сопоставлялась с текущей новой точкой;
набор (/)=— 1 означает: установлено, что грань вдоль ребра / темная, грань вдоль ребра —/ еще не сопоставлялась с текущей новой точкой.
набор (/) = +2 означает: установлено, что ребро / принадлежит правосторонней границе светлой шапки.
Для ребер выпуклой оболочки, не принадлежащих ни к одной из данных категории, во время поиска границы набор (О^О. Когда этот поиск будет завершен, всем ребрам I, для которых было набор (Ц = ± 1, будут снова соответствовать набор (I), равные 0 или 2.
Кроме фиксирования границы как «ребра, у которых нц- бо/?(/)=2», полезно иметь список номеров этих ребер в циклическом порядке, чтобы они оказывались под рукой, когда придется добавлять ребра, идущие в новую точку и выходящие из нее. Поскольку нами принята оптимизация, состоящая в переключении на линейный поиск после обнаружения первого- ребра границы и выборе ребер в циклическом порядке, в нашей версии такой список будет получаться автоматически. Мы предлагаем фиксировать номера ребер правой границы в некотором массиве, скажем «Ь»; тогда условие Ь.обл = 0 можно считать показателем того, что никакой границы не найдено. Наш грубый набросок программы теперь имеет вид «увеличить п на 1, соблюдая инвариантность Р2\ begin glocon х, у, z; glovar след, конец, старт, п, пд^ набор;
pricon пх, пу, nz; privar b;
пл = п -j- 1;
пх vir цел, пу vir цел, nz vir цсл\=х^п\ у\п\г^п\ b vir цел, array : = (0);
«занести границу в набор и Ь»;
if b .обл = 0 пропустить
■ b.обл >0 —«модифицировать оболочку» fi
end
Занесение границы в «набор» и «Ь» будет выполняться в два шага: на первом шаге все грани сопоставляются с новой точкой и граница фиксируется в массиве «набор»; на втором шаге нам необходимо просмотреть границу и занести ее ребра в синеок «Ь» в циклическом порядке. Сначала я намеревался дойти до этого места и предоставить читателям как упражнение по оптимизации переход к более линейному поиску при нахождении первого ребра границы, теперь я изменяю свое решение потому, что обнаружил трудность, о которой раньше нс подозревал: при нервом исследовании какой-либо грани для выбора ребра границы таких ребер может обнаружиться несколько. Если грани—треугольники, то ребра будм смежными ребрами границы, ио отсутствие граней с большим числом ребер объясняется лишь ограничением, что у нас никакие четыре точки нс лежат в одной плоскости. Я не придавал этому ограничению большого значения и поэтому отказываюсь
использовать эту более или менее случайную смежность ребер границы, которые могут быть обнаружены при исследовании новой грани. Л возможное «одновременное» обнаружение несмежных ребер границы я как раз и не учел; смежность может иметь значение, если мы хотим выбирать ребра границы в циклическом порядке, т. е. в циклическом порядке заносить номера ребер в массив b ^«Ь\вверх»у Отсюда, видимо, следует, что нужно отделить «обнаружение» ребра границы при просмотре ребер впервые исследхемой грани от построения значения массива «/ж. Так как обнаруженные ребра границы должны быть разделены на ребра «обработанные», т. е. зарегистрированные в массиве «6», и ребра еще не обработанные, в массиве «набор» пхжно хранить некоторую добавочную информацию. Я прсдла1аю следующее:
нобор(1) = \ and набор(- 1^0 установлено, что грани, вдоль ребра i светлая, грань вдоль ребра — I еще нс сопоставлялась с чеку щен новой точкой;
набор(1) =— 1 and набор(—ф = 0 установлено, что грань вдоль ребра i темная, грань вдоль ребра —I еще не сопоставлялась с текущей новой точкой;
набор(1) = 1 and набор(——1 ребро i— это необработанное ребро правосторонней границы светлой шапки;
набор(1) =2 and набор(—ф = 0 ребро i — это обработанное ребро правосторонней границы светлой шапки;
Haoop(i) =0 and набор(—i) = 0 либо грани вдоль ребер in —I нс исследовались, либо установлено, что они одного цвета.
Мы придерживаемся нашего первоначального принципа — исследовать только грани вдоль (в обратном направлении) полуисследованиого ребра, скажем вдоль — хх (т. е. набор (хх) = ± 1 and набор (—хх)=0); тогда мы можем использовать значение хх = 0 как указание того, что проверки бочыпе не нужны. Отношение «Ь.обл = 0» указывает, что к этому моменту ребер границы нс было обнаружено; первое обнаруженное ребро границы будет помещено в b (и таким образом приведет к 6.00.1 = 1^ а остальные ребра границы бхдхт за iexi помещаться в осторожно увеличиваемый массив Ь. Первый набросок программы (на этом уровне все еще слишком грубый) имеет вид
«занести границу в набор и Ьч
begin glocon х, у, z; след, конец, старт, пх, ну, nz; glovar набор, b; privar хх; хх vir цел\= fтарт; do хх ^ 0 —>
«исследовать грань вдоль — хх>;
if Ь.обл ^>0 —> «расширить b и обновить хх»
[\Ь.обл — 0 —> «перевычислить хх»
fi od end
(Различные названия операторов «обновить хх» и «перевычислить хх» указывают на то, что при этом применяются различные стратегии.)
Этот набросок я назвал слишком грубым, так как оператор «перевычислять хх» должен присваивать хх номер полу- исследованного ребра (если таковое можно найти, в противном случае — нуль). Выло бы очень неприятно, если бы это потребовало просмотра всей текущей оболочки и, следовательно, введения аппарата, предотвращающего повторный выбор одного и того же ребра во время поиска! Поэтому мы введем массив с («кандидаты») и обеспечим следующее:
если I—номер полуисследованного ребра, то либо с встречается среди значений векторной функции с, либо i = хх.
(Заметим, что значения функции с—которые всегда будут номерами ребер— могут также равняться номеру ребра, которое уже полностью обработано.) Принимая во внимание, что последнее значение, присваиваемое величине хх, есть нуль, удобно занести это значение в нижний конец с при инициализации (это эквивалентно введению «виртуального ребра» с номером нуль, что является обычной программистской уловкой). Наша новая версия принимает вид
«занести границу в набор и Ь»;
begin glocon х, у, z, след, конец, старт, пх, пу, nz;
glovar набор, b; privar хх, с;
хх vir цел := старт*, г vir цел array (0, 0);
do хх^ 0—> хисследовать грань вдоль —хх»;
if b.обл >0 —^«расширить b и обновить хх»
^Ь .обл.=0 —> «перевычислить хх» fi od end
Поскольку при «перевычислить хх» ребро хх первоначально не полуисследованное [т. к. грань вдоль ребра—хх только
что была проверена!) и
abs (набор (хх)) = 1 and набор ( — хх) = 0 есть условие полуисследованности, то последний подалгоритм кодируется совсем просто:
«перевычислить хх»;
do ххФ 0 and non (abs (набор (хх)) = 1 and набор (~хх\ = ^—^хх, схвекон od
Алгоритм для «расширить b и обновить хх» более сложен. Если мы сконцентрируем внимание на поиске ребра, которое затем занесем в 6, то обнаружим, что ищем ребро, скажем хх, такое, что
Р1: оно начинается там, где кончается ребро Ь.верх, т. е. конец (Ь.верх) =конец (—хх)
Р2: установлено, что грань вдоль ребра хх светлая
РЗ; установлено, что грань вдоль ребра —хх темная
Так как оператор хх; = след (Ь.верх) обеспечивает истинность первых двух условий, мы выбираем в качестве инварианта Р\ and Р2. Теперь можно выделить четыре (вот почему этот алгоритм более сложный) различных случая.
набор (хх) = 1 and набор (—хх) =— 1: в этом случае хх есть следующее ребро правосторонней границы светлой шапки, которое должно быть обработано; после его обработки оператор хх; = след (хх) восстанавливает условие Р\ and Р2.
набор (хх) =0 and набор (—хх)=0: в этом случае, поскольку установлено, что грань вдоль ребра хх светлая, грань вдоль ребра —хх также светлая; следовательно, оператор хх: = след (—хх) определяет новое ребро хх, удовлетворяющее условию Р\ and Р2.
Операция (1) может встретиться только конечное число раз, так как существует лишь конечное число ребер правосторонней границы светлой шапки, которую нужно обработать. Операция (2) повторится лишь конечное число раз, потому что конец (—хх) есть вершина грани, про которую известно, что она темная. Когда ни одна из этих операций нс имеет места, может быть один из следующих двух случаев:
набор (хх) = 1 and набор (—хх)=0: в этом случае [рань вдоль ребра —хх еще не исследовалась и значение хх годится для управления исследованием следующей грани.
набор (хх)=2 and набор (—хх)=0: в этом случае хх— обработанное ребро границы и цикл должен быть уже закрыт:
у нас должно выполняться равенство хх^Ь.низ и, следовательно, поиск завершен, т. с. хл' = 0— это единственно приемлемое конечное значение хх. Однако необходимо следить за тем, чтобы не осталось пи одного ребра, у которого набор (t) = ± 1.
В следующей программе в целях безопасности выбраны более сильные предохранители, чем это, строго говоря, необходимо:
«расширить b и обновить хх :
хх := след верху,
do набор (хх\ = 1 and набору —ххА = — \
набор Дхх^2; набор д — xx\ — Q; b \ вверх ухх\,
хх : = след (хх) □ набор ^xxx\^0 and набору— xxWO —^хх := след ( — хх) od;
if набор (хх) = 1 and набор^ — хх^ — 0 пропустить
С набор ухх^ = 2 and набор ( — хх) = 0 and хх = Ь .низ —* хх, с : векон;
do хх^ 0
do abs ^-’абор^ххб=Л —>• набор :(хх) —О od;
хх, с : векон od
fi
В предположении, что оператор «вычислить осе» инициализирует осе значением +1, когда грань вдоль —хх светлая и — 1, когда она темная, кодирование оператора «исследовать грань вдоль ребра —хх» становится совсем простым, «исследовать грань вдоль ребра — ххэ:
begin glocon х, у, z, след, конец, пх, пу, nz, хх;
glovar набор, b, д pricon осе; privar уу, круг;
«вычислить осе»;
у у vir цел \ = — хх; круг vir бул :— ложь;
do non круг-^
if набор ( — yyV-^0 ->набор Дуу^осв; с\eeepx{yy) lj набор{ — уу^осв —>набор : ( — yy} = Q
Ц набор ( — уу} — — осе —»
набор: ^УУг--осв;
do b.обл^Оb \ вверх ^осв ^ уу\ od
fi;
уу :^= след (уу); круг := (уу = — хх)
od end
Программирование вычисления «осе» несложно, но утомительно: оно сводится к вычислению объема (со знаком)
тетраэдра с вершинами п, конец (—хх), конец (след (—хх)) и конец (след (след (—хх))). В нижеследующем мы учитываем, в какую сторону вращаются стрелки наших часов.
«вычислить or в»:
begin glocon х, у, z, след, конец, пх, пу, nz, хх;
vircon осв; privar тчк;
pricon об, xl, уХ^ z\, х2, у2, z2, хЗ, уЗ, z3;
тчк vir цел конец ( —хх);
xl vir цел, у\ vir цел, z\ vir цел : =
х (тчк) — пх, у (тчк) — пу, z (тчк) — nz;
тчк : = конец (след (— хх));
х2 vir цел, у2 vir и,ел, z2 vir цел\ =
х (тчк) — пх,у (тчк) — ny,z (тчк) — nz;
тчк : = конец (глед (глед (— хх)));
хЗ vir цел, уЗ vir цел, z3 vir цел\ =
х (тчк) — пх,у (тчк) — ny,z (тчк) — nz;
об vir цел xl * (у2* z3 — уЗ * z2)-^-
х2 * (уЗ * z 1 — у 1 * гЗ) -р
хЗ*(у\ * z2 — у2 * zl);
if об^О осв vir цел;= — \
□ об<20 —* осв vir цел X
fi end
Нам осталось детализировать оператор «модифицировать оболочку», причем ребра В правосторонней границы светлой шапки заданы двумя способами:
В циклическом порядке как значение векторной функции Ь; это представление позволяет прослеживать границу, выполняя какую-нибудь работу для всех ребер В, и т. д.
набор (I) =2 выполняется тогда и только тогда, когда ребро I принадлежит В; это позволяет нам быстро ответить на вопрос: принадлежит ли В ребро /?
Так как число ребер, которые должны исчезнуть (внутренние ребра светлой шапки), и число добавляемых ребер (ребер, соединяющих новую точку с вершинами правосторонней границы) никак нс связаны, представляется целесообразным разделить эти два вида деятельности:
«модифицировать оболочку»:
«удаление ребер»;
«добавление ребер»
В первом операторе нам кажется нс слишком привлекательным объединять идентификацию внутренних ребер и их удаление: в процессе идентификации нужно просматривать светлую шапку текущей оболочки, и я бы нс стал изменять струк-
ТУРУ оболочки прежде, чем полностью закончится весь просмотр. (Не следует считать, что это невыполнимо, я просто хочу сказать, что у меня сейчас нет желания исследовать эту возможность.)
Так как ввиду нашей концепции «дыр» внутреннее ребро I и внутреннее ребро —I должны удаляться одновременно, достаточно построить список номеров «неориентированных» ребер, т. е. значение I будет указывать, что должны исчезнуть оба ребра I и —I. Назовем этот список ур (удаленные ребра); мы получаем
«удаление ребер»:
begin glocon b; glovar след, nd, набор; privar ур; «инициализировать ур списком внутренних ребер»; «удаление ребер, перечисленных в ур»
end
В соответствии с нашей прежней договоренностью о дырах второй оператор кодируется совсем просто:
«удаление ребер, перечисленных в ур»:
do ур.обл >0 —-> след : [ур. верх) = пд; пд, ур;векон od
С учетом нашего анализа инициализация ур не представляет трудности. Отношение набор (I) = набор (—Z)=3 будет использоваться для представления того факта, что «было обнаружено, что I и —I есть внутренние ребра»; это позволит нам проверять, принадлежит ли ребро I множеству, которое мы назвали V (граница + внутреиние ребра), с помощью неравенства набор (f)^2. Здесь мы снова используем векторную переменную массива «с» для хранения списка кандидатов.
«инициализировать ур списком внутренних ребер»:
begin glocon Ь, след; glovar набор; virvar ур; privar с, k;
с vir цел arrays (0); k vir цел\ = Ь.ниг;
do с . обл^ b. обл —> с : вверх (Ь (£)); k\=k-Ar\ od;
ур vir цел arrays (0);
do с .обл >0 —*
begin glocon след; glovar набор* УР. с; pricon к;
h vir цел след (с. верх}; с*, сверху;
if набор (h)^ 2 —* кропусти^ь
Цнабор (/z) = 0 — > набор : (к}^^ набор*. ( — /z) = 3;
ур : вверх (к}; с;ввСРх(^У с -.вверх( — к) fi
end
od
«end
И наконец, нам нужно детализировать «добавление ребер». Если мы будем обрабатывать по одному ребру границы, соединяя его конец с новой точкой, то возникают трудности с обработкой первого треугольника: номер ребра, соединяющий его начало с новой точкой, еще не был определен. В начале мы можем поступать так, как если бы это было виртуальное ребро пг.нуль, и соответственно инициализировать t («проба»), а в конце замкнуть цикл. Прежде чем локальная константа е инициализируется значением номера самой ранней дыры, алгоритм проверяет, есть ли такая дыра на самом деле, в противном случае такая дыра заводится путем расширения соответствующих массивов с обоих концов. В конце работы алгоритма нужно удостовериться, что значение переменной «старт» снова есть номер ребра выпуклой оболочки. (Вот типичный пример оператора, который можно легко упустить из виду; и это, между прочим, произошло со мной, так как вначале я намеревался включить данное переопределение в оператор «удаление ребер», потому что это как раз то действие, при котором величина «старт» может легко принять значение номера ребра, не являющегося больше частью выпуклой оболочки. Мое внимание было привлечено к этому упущению, когда я исследовал окружение «добавления ребер», которым является окружение «модифицировать оболочку», где я обнаружил старт среди глобальных переменных} Так как мне было лень вносить изменение в уже отпечатанную часть рукописи, я поддался соблазну прибегнуть к типичной программистской «заплате»!)
«добавление ребер»:
begin glovar след, конец, старт, пд, набор*, glocon п, Ь;
privar t, k;
t vir цел, k vir цел\=^, Ь.миг*,
do k-C Ь.вег->-
begin glovar след, конец, nd, набор t, A; glocon n, b; pricon <?;
do пд = О-*след;вверх\Оу след : вниз (0);
конец : вверх (0); конец : вниз (0); набор : вверх уду набор : в из (0;; пд : = след. вег
od;
е vir цел\=пд; nd \= след (е);
след : (6 ^ = е; набор : (6(£)) = 0;
след ;(e^ — f; конец *Де^ = п; набор :(е) = 0;
след : / = /;(/); конец : ^^ = конец^ — Ь^; набор :(/)=0;
/: = _С; к\=к-^\;
end
od;
след : ^след ^Ь .низ^ = 1;
след : ^ = Ь .низ; конец : ^ = конец ( — Ь.низ);
набор : (/) = 0;
след :(0) = 0; конец : (0) = 0; набор : (0) = 0;
старт : = b .низ
end
Этим завершается история создания первого алгоритма, который я написал для построения выпуклой оболочки в трехмерном пространстве. Это моя первая попытка, и алгоритм, который получился в результате, по-видимому, не очень эффективный; разумеется, причина его публикации заключается ке в его «качестве». (Я с удовольствием оставлю читателям как упражнение разработку более эффективных алгоритмов. Я же на этом останавливаюсь, так как, в конце концов, эта книга нс есть трактат по алгоритмам построения выпуклых оболочек.) Включение этой главы в книгу связано с другими причинами.
Первая причина была сформулирована в начале главы: избежать обвинения в том, что я занимаюсь только легкими, небольшими задачами.
Вторая причина состояла в том, что мне хотелось показать, как к таким алгоритмам можно прийти при помощи процесса пошаговой детализации. Если говорить совсем откровенно, я хотел показать большее, а именно что метод пошаговой детализации является, по-видимому, наиболее многообещающим способом разработки программ (весьма) значительной, но еще преодолимой сложности. Надеюсь, что в разобранном примере мне это удалось сделать. Я думаю, что этот пример заслуживает того, чтобы его назвали примером «значительной, но еще преодолимой сложности», так как конечная программа состоит из тринадцати именованных блоков с «иерархией вложенности» (назовем это так), равной пяти! Это можно увидеть
из следующей таблицы; отступления в начале строк означают новый уровень:
0) [след, конец, старт} vir [оболочка} := выпуклая оболочка (х, у, z]
инициализировать Р2 при небольшом значении н
увеличить п на 1, соблюдая инвариантность Р2
занести границу в набор и b
исследовать грань вдоль— хх
вычислить осе
расширить b и обновитьхх
перевычислить хх
модифицировать оболочку
удаление ребер
инициализировать ур списком внутренних ребер
удаление ребер, перечисленных в ур
добавление ребер
Метод пошаговой детализации разработай как сознательная попытка учесть незначительность объема наших человеческих черепов. Из него следует, что, когда нам нужно разработать алгоритм, который будет представлен большим текстом, нам достаточно выработать понятия, позволяющие описывать алгоритм на таком уровне абстракции, на котором описание становится достаточно коротким и, следовательно, интеллектуально управляемым. И что касается этой цели, метод пошаговой детализации ей, по-видимому, соответствует. Разумеется, он не дает гарантии получения программы, во всех отношениях высококачественной!
Третья причина включения в книгу этой главы заключалась в том, что мне хотелось, по возможности, ничего не утаивать и, следовательно, быть убедительным. Первым примером, который я привел для демонстрации метода пошаговой детализации, была разработка программы получения таблицы первой тысячи простых чисел. Хотя во многих отношениях это был привлекательный и компактный пример, его убедительность снижалась тем, что каждый знал, что я уже умел генерировать первую тысячу простых чисел, прежде чем начал писать эссе на эту тему. Вог поэтому я и написал данную главу; у меня нс было пи малейшего представления, чем за-
кончится моя попытка, когда я ее начал. Написав несколько страниц, я их перепечатывал, прежде чем идти дальше.
Замечание 1. Когда были перепечатаны последние страницы, мы обнаружили в тексте программы три ошибки: была пропущена точка с запятой, в операторе «удаление ребер, перечисленных в ур» я написал «след: (пд) =ур.верх» и в операторе «инициализировать ур списком внутренних ребер» строка «с : вверх (h); с: вверх(—Л)» была пропущена. Стоит обратить внимание на то, что только самая тривиальная из них могла бы быть обнаружена при проверке синтаксиса. Стоит также обратить внимание на то, что две другие ошибки произошли в разделах, про которые я написал, что они «теперь программируются совсем легко» и «не представляет больше трудности» соответственно! (Конец замечания /.)
Замечание 2. Никоим образом не исключается, что эффективность моей программы может быть значительно увеличена даже без привлечения стратегий, основанных на математическом ожидании числа точек внутри данного объема: если точка уже находится внутри выпуклой оболочки, ее не нужно сопоставлять со всеми гранями; если она находится снаружи, то должны быть (правда, в среднем!) более дешевые способы нахождения первого ребра границы светлой шапки. Если, например, выпуклая оболочка содержит более пяти точек, существует по крайней мере одна пара точек, связь между которыми проходит внутри, и мы могли бы попытаться найти пересечение выпуклой оболочки и плоскости, проходящей через новую точку и две точки такой пары. В этом случае наши просмотры стали бы линейными. (Конец замечания 2.)
25 НАХОЖДЕНИЕ МАКСИМАЛЬНО СИЛЬНЫХ КОМПОНЕНТ В ОРИЕНТИРОВАННОМ ГРАФЕ
Задан ориентированный граф, т. е. множество вершин и множество ориентированных ребер, идущих от одной вершины к другой; требуется разбить множество вершин на так называемые «максимально сильные компоненты». Сильная компонента— это множество таких вершин, что ребра между ними обеспечивают ориентированный путь от произвольной вершины множества к любой другой вершине множества, и наоборот. Одна-единственная вершина есть специальный случай сильной компоненты; следовательно, путь может быть пустым. Максимально сильная компонента — это такая сильная компонента, к которой нельзя добавить других вершин.
Чтобы производить описанное разбиение, нам необходимо уметь доказывать два вида утверждений: утверждение, что вершины принадлежат одной и той же сильной компоненте, и — так как нам нужно найти максимально сильные компоненты— утверждение, что вершины не принадлежат одной и той же сильной компоненте.
Для первого типа утверждений нам может пригодиться следующая теорема.
Теорема 1. Циклически связанные вершины принадлежат одной сильной компоненте.
Кроме (ориентированных) связей между конкретными вершинами мы можем говорить об ориентированных связях между различными сильными компонентами; мы говорим, что имеется связь от сильной компоненты А к сильной компоненте В, если существует ориентированное ребро от некоторой вершины А к некоторой вершине В. Так как А и В — сильные компоненты, то, следовательно, имеется путь от любой вершины /1 к любой вершине В. В результате обобщением теоремы 1 является
Теорема /Л. Вершины циклически связанных сильных компонент принадлежат одной сильной компоненте.
Следствие 1. Непустой граф имеет по крайней мере одну максимально сильную компоненту без выходящих ребер.
Достаточно о теоремах, утверждающих, что вершины принадлежат одной и том же сильной компоненте. Так как между различными точками, принадлежащими одной и той же сильной компоненте, должны существовать пути в обоих направлениях, утверждения, что вершины не принадлежат одной и той же сильной компоненте, могут быть сделаны с учетом того, что утверждает
Теорема 2. Если вершины подразделены на два множества мв Л и мв В так, что не существует ребер, начинающихся в вершине множества .ив А и оканчивающихся в вершине множества мв В, то, во-первых, множество сильных компонент не зависит от того, есть или пет ребра, начинающиеся в вершине множества мв В и оканчивающиеся в вершине множества .мв Л, и, во-вторых, ни одна сильная компонента нс содержит вершин обоих множеств.
Из теоремы 2 следует, что, как только найдена сильная компонента без выходящих ребер, мы можем взять ее вершины в качестве множества мв А и заключить, что эта сильная компонента является максимально сильной компонентой и что все входящие в мв А ребра в дальнейшем могут не учитываться. Отсюда следует
Теорема 2А. Сильная компонента, у которой выходящие ребра, если они есть, все являются входящими ребрами максимально сильных компонент, сама является максимально сильной компонентой.
Пли, иначе говоря, как только найдена первая максимально сильная компонента без выходящих ребер,— существование которой обеспечивает следствие I,— остальные максимально сильные компоненты можно найти, решив нашу задачу для графа, состоящего из оставшихся вершин и только тех ребер, которые проходят между ними. Или, говоря еще иначе, максимально сильные компоненты графа могут быть упорядочены по «возрасту» так, что каждая максимально сильная компонента обладает выходящими ребрами, идущими только к более «старым» компонентам.
Чтобы иметь возможность делать несколько более строгие утверждения, введем следующие обозначения:
мв: заданное множество вершин (константа) мр: заданное множество ребер (константа) рзв: разбиение вершин множества мв
Тогда отношение, которое должно выполняться в конечном счете, можно записать так:
R :рзв = МСК ^мр^
где для фиксированного множества мв функция МСК, т. е. разбиение на максимально сильные компоненты, рассматривается как функция от множества ребер мр.
Соответствующее инвариантное условие получается при помощи стандартной замены константы переменной, скажем мр\, значением которой всегда будет подмножество множества мр:
Р:/73# = МСК VMP^
Условие Р легко инициализируется при мр\, равном пустому множеству, т. е. каждая вершина мв сама является максимально сильной компонентой. Так как мр\ ограничено сверху множеством мр, монотонный рост мр\ обеспечивает завершае- мость процесса; если нам удастся сделать это, соблюдая инвариантность Р, то условие R выполнится, когда мр\=мр. В наших рассуждениях мы будем использовать имя мр2 для обозначения множества оставшихся ребер, т. е. мр = мр! +мр2.
Очевидно, что наша задача состоит в отыскании самого удобного порядка добавления ребер к мр1; здесь под «удобством» понимается легкость, с которой обеспечивается выполнение условия Р. Это, как мы знаем, можно выразить так: каково наше общее промежуточное состояние, какие типы рзв-зиа- чений мы допускаем? Для описания такого общего промежуточного состояния представляется практически удобным сгруппировать вершины мв в непересекающиеся подмножества (как мы сделали для ребер мр\ и мр2у В конце концов, ведь нас интересует разбиение вершин!
Общее промежуточное состояние должно быть обобщением как начального, так и конечного состояний. В начале мы ни про какую вершину не знаем, к какой максимально сильной компоненте в МСК ^мр^ она принадлежит, в конце нам известно это про любую вершину. Подобно множеству мр\, мы можем ввести (вначале пустое, а в конце содержащее все вершины) множество лш1, где
лш! содержит все вершины из мв, для которых уже известна максимально сильная компонента в MCKCw?), которой они принадлежат.
Для определения того, что сильная компонента является максимальной, мы б\дем использовать теорему 2Л, т. е. будем проверять все се выходящие ребра. Если у нас
мр\ — множество всех обработанных ребер и мр2— множество всех необработанных ребер, т. е. ребер, которые еще не принимались в расчет, то мы имеем
PV. все ребра, выходящие из вершин, принадлежащих мвХ, принадлежат мр\.
Впрочем, группировать все оставшиеся вершины в одно множество лш2 не очень выгодно. Из того, как определено множество л!в\, вытекает, что всякий раз, когда определяется новая максимально сильная компонента МСК Цир^Ь все вершины этой максимально сильной компоненты должны быть одновременно перенесены в лгв!. Между двумя такими перенесениями, вообще говоря, должно быть обработано (т. е. перенесено из мр2 в мр\^ некоторое количество ребер, и для описания промежуточных состояний с учетом «обработки по одному ребру» все оставшиеся вершины должны быть разделены на группы несколько более дифференцированно, а именно на два непересекающихся подмножества, скажем мв2 и лшЗ (мв = аш\-- aig2vл/вЗ), где люЗ содержит никак не обработанные вершины,
Р2: ни одно ребро из Aip\ не начинается и не кончается в вершине из л/вЗ
(л/вЗ в начале равняется мв, а в конце пусто).
Перенесение вершин из л/вЗ в жв1 может выполняться в два этапа: из мвЗ в мв2 (по одному) и из ме2 в мв! (вместе со всеми другими вершинами из той же самой определенной максимально сильной компоненты).
Другими словами, из вершины множества лщ2 мы попытаемся построить (увеличивая Л1р1) следующую максимально сильную компоненту МСКСмр) для перенесения в жв1. Максимально сильные компоненты в МСК(ж/Д) (обратите внимание на это рассуждение!) таковы, что они включат либо только вершины из Л1в\, либо только вершины из л/в2, либо одну (единственную) вершину из aib3. Мы предлагаем пользоваться следующим ограничением на связи, определяемые ребрами из мр\ между максимально сильными компонентами в МСК (лфХ), которые содержат вершины только из лш2\ между этими максимально сильными компонентами ребра из мр\ должны образовывать один и только один ориентированный путь, ведущий из самой «старой» компоненты в самую младшую. Мы будем называть такие максимально сильные компоненты «элементами цепочки». Этот выбор объясняется следующими соображениями.
Во-первых, мы ищем циклический путь, который позволит
нам применить теоремы 1 или 1А к решению вопроса о том, что некоторые различные вершины принадлежат одной и той же максимально сильной компоненте. В предположении, что мы имеем право предписывать порядок добавления следующих ребер к мр\, по-видимому, не имеет большого смысла вводить в МСК (^pl) разобщенные максимально сильные компоненты, построенные из вершин множества лш2.
Во-вторых, направленный путь из «старейшей» в «самую младшую» компоненту цепочки — как цикл «in statu nas- cendi»— строится довольно легко, как показывает следующий анализ.
Предположим, что в мр2 содержится ребро, выходящее из одной из вершин самой младшей максимально сильной компоненты цепочки. Тогда это ребро «е» переносится из мр2 в мр\ и «статус кво» легко поддерживается:
Если е ведет в вершину из лю1, то его можно игнорировать на основании теоремы 2.
Если е ведет в одну из вершин из лщ2, то самый младший элемент цепочки можно объединить с нулем или несколькими более старшими элементами и получить новый, самый младший элемент цепочки. Более точно, если е ведет в вершину из самого младшего элемента, то его можно игнорировать; если оно ведет к более старшему элементу цепочки, то обнаруживается цикл между сильными компонентами, и тогда теорема 1А говорит, что несколько младших элементов цепочки нужно объединить в один элемент, уменьшая таким образом длину цепочки, измеряемую числом входящих в нее элементов.
Если е ведет в вершину из люЗ, то эта вершина переносится в Л1б2 и как самый младший элемент (она сама по себе является максимально сильной компонентой в МСК (лф/)) добавляется к цепочке, длина которой таким образом увеличивается на единицу.
Если пи одного такого ребра «е» не существует, имеется две возможности. Либо цепочка не пуста, но тогда теорема 2А говорит, что максимально сильная компонента в МСК (мр\^ есть также максимально сильная компонента в МСК Умру. самый младший элемент удаляется из цепочки и его вершины переносятся из лгв2 в лщ1. Либо цепочка пуста: если мвЗ нс пусто, то в мв2 может быть перенесен произвольный элемент из мвЗ, в противном случае вычисление закончено.
На рассматриваемом уровне детализации мы можем описать наш алгоритм следующим образом:
мр\, мр2, Aie\, мв2, ивЗ := пусто, мр, пусто, пусто, we;
do мвЗ^ пусто {цепочка пуста}
перенести некоторую вершину -у из меЗ в мв2 и инициализировать цепочку начальным множеством {^); do мв2^ пустот [цепочка не пуста)
do мр2 содержит ребро, начинающееся в одной из вершин самого младшего элемента цепочки-> перенести это ребро е из мр2 в мр\\
if е ведет в вершину -у из мв\пропустить
О в ведет в вершину -у из л/б2-> объединение
□ е ведет в вершину -у из ,оЗ-> расширить цепочку и перенести -у из л/аЗ в мв2 fi
od; [цепочка не пуста)
удалить самый младший элемент и перенести его вершины из мв2 в мв\
od [цепочка снова пуста)
Замечание 1. После перенесения вершин из мв2 в мв\ входящие в них ребра (если таковые имеются), все еще находящиеся в мр2, могут быть одновременно перенесены из мр2 в мр\, но ценой такой «улучшенной» обработки (выгода от которой несколько сомнительна) будет необходимость уметь выбирать для любой вершины множество входящих в нее ребер. Для описанного алгоритма нам нужно уметь находить для каждой вершины только выходящие ребра. Отсюда и вытекает описанная выше организация. {Конец замечания /.)
Замечание 2. Завершаемость самой внутренней конструкции повторения гарантируется уменьшением числа ребер в мр2; завершаемость следующей обрамляющей! конструкции повторения гарантируется уменьшением числа вершин в мв2 + мвЗ; завершаемость внешней конструкции повторения гарантируется уменьшением числа вершин в мвЗ. Наше смешанное рассуждение, иногда в терминах ребер и иногда в терминах вершин, является показателем нетривиальное™ разрабатываемого алгоритма. {Конец замечания 2.^
На том уровне детализации, на котором мы описали наш алгоритм, каждое ребро только один раз переносится из мр2 в мр\ и каждая вершина только один раз переносится из мвЗ через мв2 в л/в1; пока что наш алгоритм предполагает объем работы, линейно зависящий от числа ребер и вершин. При дальнейшей детализации алгоритма мы попытаемся не нарушить это приятное свойство, что мы сделали бы, например, если бы проверка, принадлежит ли у мв\, мв2 или мвЗ (которая производится в самой внутренней конструкции повторения!), требовала поиска со временем, пропорциональным числу вершин. Для этой цели можно использовать тот факт, что нам требуются только ограниченные виды работ с нашими множествами вершин, в частности, что вершины присоединяются к цепочке и удаляются из нее в режиме «последним пришел — первым ушел».
Мы считаем, что наши вершины последовательно занумерованы, и организуем табличную функцию ранг (у), где и принимает значение всех номеров вершин; пусть КВ обозначает количество вершин:
ранг^ =0 означает: вершина пг.и принадлежит мвЗ ранг (у)>0 означает: вершина пг.и принадлежит мв1 + мв2
(Множества мв2 и мв1 для начала соединены: одна из возможных форм объединения — пропуск!)
Если квц равняется «количеству вершин в цепочке», т. е. количеству вершин в мв2, то
^.ранг^ ^.квц означает: вершина у в мв2;
ранг (у) ^КВ +1 означает: вершина а в мв\.
У всех вершин в мв2 значения функции ранг различны, и что касается величин ранг и квц, перенесение вершины а из лгвЗ в мв2 кодируется так:
«квц := квц-V 1; ранг : ^=квц»
т. е. вершины цепочки «ранжированы» в порядке уменьшения «возраста в цепочке». Последняя договоренность позволяет нам очень эффективно представлять то, как вершины мв2 разделены на сильные компоненты: вершины, принадлежащие одному и тому же элементу цепочки, имеют последовательные значения функции ранг; а если говорить о самих элементах, то ранг самой старой вершины элемента есть убывающая функция его возраста. Введем сс^ для обозначения ранга самой старой вершины r-го по старшинству элемента цепочки (тогда у нас сс.обл = количество элементов в цепочке). Теперь что касается величин ранг, квц и сс, то закодировать приведенную
выше конструкцию выбора (объединяя первые две альтернативы) можно следующим образом:
if ранг(а) >0->do сс. верх>ранг(у)-^сс: сверху od
[] ранг (и) =0-^квц : = квц +1; ранг; ^=квц; сс: сверху (квц) fi
Тем временем мы до некоторой степени перестали следить за идентичностью вершин в цепочке. Если, например, мы захотели бы перенести вершины самого младшего элемента цепочки из мв2 в мв\, то нам пришлось бы просматривать значения функции ранг по всем вершинам в поисках таких значений у, которые удовлетворяют неравенствам сс.верх^ ^ранг(с) ^.квц. Нам бы не хотелось этого делать, но благодаря тому, что по крайней мере для вершин из мв2 все значения ранг (у) различны, мы можем хранить обратную функцию для
1 <; г С квц : ранг ^ = г о гнар (г) = -и
Пока хватит об организации работы с вершинами; обратимся к ребрам. Здесь критическим вопросом является, безусловно, вопрос о предохранителе самой внутренней конструкции повторения: «мр2 содержит ребро, начинающееся в одной из вершин самого младшего элемента цепочки». Этот предохранитель легко вычисляется, если есть список ребер, принадлежащих мр2 и выходящих из вершин самого младшего элемента цепочки. Однако одной из возможностей изменения самого младшего элемента в цепочке является объединение; чтобы поддерживать этот список, нам, таким образом, нужно иметь также соответствующие списки и для старших элементов цепочки. Поскольку для таких ребер нас интересует лишь их «целевая вершина», в аппарат управления цепочкой мы вводим еще две векторные переменные — с областью определения, равной нулю, если цепочка пуста — «цв» (целевые вершины») и «цвг» («цв-границы»).
Область определения функции цвг содержит по одной точке для каждого элемента цепочки: ее значение равняется числу ребер, принадлежащих мр2 и выходящих из вершин более старших элементов цепочки (область цвг все время равна области сс, где также хранится одно значение для каждого элемента цепочки). Каждый раз, когда новая вершина и переносится из мвЗ в ,мв2, массив цвг расширяется вверх значением цв.обл, после чего цв расширяется вверх целевыми вершинами ребер, выходящих из и. Обозначая эту последнюю операцию «расширить цв целями от и», мы получаем следующий вид внутреннего цикла (с учетом гнар, цв и цвг):
«внутренний цикл»:
do цв.обл >цвг .верх-*-
-и, цв: векон;
if ранг (-и) )> 0 ->
do сс.верх^ ранг ^-^ сс: сверху; цвг: сверху od
□ ранг (^) = 0 ->
квц := квц-\-1; ранг : ^ = квц; гнар*. вверх^;
сс : вверх^квц^; цвг : вверх ^цв.облУ «расширить цв целями от -и» fi
od
Мы договорились, что для вершин у из мв\ ранг (v)^ КВ. Можно ввести и более сильную договоренность, а именно занумеровать максимально сильные компоненты номерами 1, 2 и т. д. (в том порядке, в котором они отбираются) и условиться, что для каждой вершины и из мв! будет
ранг (о) = КВ-р номер максимально сильной компоненты, которой принадлежит и
Введя переменную «скном» (начальное значение 0), мы можем теперь закодировать
«средний цикл»:
do сс .обл">0
-внутренний цикл»;
скном : = скном-V 1;
do квц ^ сс.верх->-
квц :— квц — 1; ранг : ^гнар.верх^ = КВА-скном;
гнар : сверху; мвХсчет : = мв\счет-\-1
od;
сс : сверху; цвг : сверху
od
(Переменная мвХсчет, начальное значение = 0, является счет-
чиком числа вершин в лш1; критерием конца работы будет служить мв\счет—КВ.)
Мы предполагаем, что номера вершин меняются от 1 до КВ, а ребра задаются двумя массивами констант «ребро» и «реброг» таким образом, что для l^i^KB и для реб- рог[1) ^j <реброг [i-V 1) значения pe6po[j) являются номерами вершин, в которые идут ребра, выходящие из вершины nr.i. Затем мы кодируем.
«расширить цв целями от -и»:
begin glocon ребро, реброг, -и; glovar цв*, privar у;
у vir цел*.— реброг [ч)ф\);
do j > реброг [ч))-^] • -— У — 1; ЦВ : вверх [ребро (/)) od end
И последнее — это выбор произвольной вершины и из мвЗ для инициализации цепочки. Если каждый раз поиск будет начинаться с вершины пгЛ, время вычислений будет пропорционально КВ2, но и здесь этого можно избежать, если вынести условие за границы конструкции повторения и ввести на внешнем уровне переменную «канд» (начальное значение=1), обладающую свойством:
мвЗ не содержит вершин и, таких, что и < канд
begin glocon ребро, реброг, КВ; virvar ранг; privar
мвТсчепг, канд, скном;
ранг vir цел, array: = (l); do ранг.обл ч^КВ->ранг*, вверх [ty od;
мвХсчет vir цел, канд vir цел, скном vir цел := 0,1,0;
do мв\счет ф КВ-+
begin glocon ребро, реброг, КВ; glovar ранг*
мвХсчет, канд, скном;
privar ч), сс, цв, цвг, гнар, квц;
do ранг [канд) 5^0 -> канд : = кандф 1 od;
vir цел := канд;
квц vir цел*. = \; ранг : (^) = 1; гнар vir цел array : = (1, чз);
сс vir цел array :— (1, 1); цвг vir цел array : = (1, 0);
цв vir цел array :=(1);
«расширить цв целями от т)»;
«средний цикл»
end od end
Замечание 1. Очень похожий алгоритм независимо был разработан Робертом Тарьяном. {Конец замечания /.)
Замечание 2. Теперь мы видим, что переменная «квц» излишня, так как квц = гнар.обл. {Конец замечания 2.)
Замечание 3. Операция «расширить цв целями от и» используется дважды. {Конец замечания 3.)
Примечание 1. Читатель, должно быть, заметил, что в этом примере фактическая разработка программы происходила в порядке, обратном по сравнению с разработкой программы для выпуклой оболочки в трехмерном пространстве. Я думаю, что причина этого заключается в следующем. В случае выпуклой оболочки представление данных было исследовано очень тщательно как часть логического анализа проблемы. В последнем примере логический анализ в основном был уже завершен, когда перед нами встала задача выбора представления, допускающего эффективную реализацию предполагаемого алгоритма. Поэтому было естественно сосредоточиться сначала на самой существенной части, т. е. самом внутреннем цикле. {Конец примечания /.)
Примечание 2. Следует обратить внимание на отдельные шаги получения нашего решения. На первом этапе нас беспокоило то, чтобы каждое ребро обрабатывалось только один раз, и мы временно не учитывали зависимость времени обработки от числа вершин. И это совершенно правильно, так как в общем случае следует ожидать, что число ребер на порядок больше числа вершин. (Между прочим, первое решение этой задачи, нс приведенное в данной главе, линейно зависело от числа ребер, но квадратично — от числа вершин.) И только на втором этапе мы начали заботиться о линейной зависимости от числа вершин. Насколько эффективным оказалось это «разделение аспектов», убедительно показывает тот факт, что на втором этапе нам совершенно не потребовалось обращаться к теории графов! {Конец примечания 2.)
26 О РУКОВОДСТВАХ И РЕАЛИЗАЦИЯХ
В пятидесятых годах я написал для нескольких вычислительных машин голландского производства то, что я тогда называл «функциональными описаниями». Позднее документы такого рода мне встречались под названием «справочных руководств». Отчетливо помню, с каким трудом я добивался того, чтобы эти функциональные описания были недвусмысленны, полны и аккуратны: я считал своей обязанностью описывать все свойства машины, которые существенны для программиста. Однако с тех пор моя оценка того, как соотносятся машина и руководство по ее использованию, претерпела изменение.
По существу, имеются две «машины». С одной стороны, есть физическая машина, которая устанавливается в машинном зале, может давать сбои, требует питания, кондиционирования воздуха, обслуживания и которую показывают гостям. С другой стороны, есть абстрактная машина, как она определена в руководстве, та «воображаемая» машина, для которой программист пишет программы и по отношению к которой решается вопрос о правильности программы.
В начале мне казалось, что главной функцией абстрактной машины является правильное отображение физической реальности. Позднее, однако, я пришел к мысли, что именно абстрактная машина является «истинной», той единственной машиной, которую мы можем «вообразить»; назначение физической машины — быть «работающей моделью», достаточно (как мы надеемся) аккуратно имитировать истинную, абстрактную машину.
В соответствии с этим изменилась и терминология: вместо «науки о вычислительных машинах» стали использовать термин «наука о вычислениях», и мы больше не читаем лекций по курсу «Программирование для электронных вычислительных машин» — нас совершенно не интересует, работает ли машина электрически, пневматически или чудом! Но, конечно, это не только игра со словами; это показатель того, что программирование как профессия постепенно взрослеет. Раньше назна-
чение программ заключалось в управлении нашими вычислительными машинами, теперь назначение вычислительных машин состоит в исполнении наших программ.
Мы стали осознавать всю важность понятия интерфейса, и несоответствие между абстрактной и физической машиной перестало рассматриваться как неточность в руководстве. Наоборот, стали говорить, что работа физической модели не соответствует спецификациям. Это, разумеется, предполагает, что спецификации не только совершенно однозначны, но и настолько «понятны» (и на много порядков проще инженерной документации машины), что никто не сомневается, что они описывают то, что нужно. Я помню время, когда по отношению к спецификации оборудования такие интерфейсы разрабатывались с достаточной для всех ясностью: конструкторы аппаратуры знали, что от них требуется, а программист мог полностью владеть своим инструментом, не обращаясь к «экспериментальным» программам для обнаружения его свойств. Такая необходимая всем ясность была присуща спецификациям не только оборудования, ио и базового программного обеспечения, такого, как загрузчики, подпрограммы ввода и вывода и т. д., объемом не превышавшего нескольких сотен, возможно, тысячи команд. (Если мне не изменяет память, кошмарный термин «software» еще ждал своего изобретения.)
Печальное замечание. С тех пор мы были очевидцами быстрого размножения замысловатых, плохо определенных и поэтому неустойчивых систем программного обеспечения. Теперь программисты лишены формального инструмента, который требуется для решения их задач; они живут в мире мифов и преданий, в неопределенном и полном неприятных неожиданностей мире, где они никогда не могут быть совершенно уверенными в том, что сделает система с их программами. В таких достойных сожаления обстоятельствах само понятие правильной программы — тем более программы, про которую доказано, что она правильная, — становится пустым звуком. Как появление множества таких систем повлияло на мораль программистского общества, описать я уже не в силах. (Конец печального замечания.}
В предыдущих главах мы ввели предикаты для характеристики множеств состояний и показали, каким образом тексты программ могут интерпретироваться как коды преобразователей предикатов, устанавливающих связь между «конеч-
^ Software — программное обеспечение, буквально — бумажное оборудование (англ.). — Прим, перев.
ным и начальным состояниями». В соответствии с таким подходом работа программиста представляется как построение (кода) преобразователя предикатов, устанавливающего желаемую связь между этими двумя состояниями.
Вводя различные способы построения новых преобразователей предикатов из существующих (точку с запятой, конструкции выбора и повторения), мы немного говорили о том, как они могут быть реализованы. Впрочем, хотелось бы подчеркнуть, что, хотя эти возможности реализации являлись некоторым стимулом, они ни в коей мерс не несли функций определения. Утверждение, что тексты программ допускают различные интерпретации как «выполнимые коды», играло определенную роль при выборе направлений в нашей работе, но никак не влияло на определение семантики языка программирования: наши определения семантики не основываются на какой-либо «модели вычислений».
Замечание 1. Стремясь к определению семантики, не зависящей от «истории» вычислений, мы имели в виду совершенно определенную цель, а именно «разделение аспектов», которое кажется мне чрезвычайно важным для всей программистской деятельности. Аспекты, которые нужно разделить, могут быть названы «математическими аспектами» и «инженерными аспектами». К математическим аспектам я отношу правильность программы; к инженерным аспектам я отношу главным образом различные аспекты стоимости (такие, как память и время вычисления), которые связываются с выполнением программы. Эти аспекты стоимости определяются лишь по отношению к реализации, и наоборот, реализация (или, более строго, интерпретация текста программы как выполнимого кода) должна приниматься во внимание тогда, когда обсуждаются аспекты стоимости. Если же нас интересует только правильность программ, то достаточно интерпретировать текст как код преобразователя предикатов и не к чему учитывать, что он может также интерпретироваться как выполнимый код. Наоборот! Вот почему мы предпочли для семантики аксиоматическую систему, не зависящую от какой-либо вычислительной модели и не определяющую конечное состояние как «выход» вычислительного процесса, которому на «входе» задано начальное состояние. (Конец замечания /.)
Замечание 2. Простота представленной формальной системы и то обстоятельство, что до некоторой степени ее можно рассматривать как средство формального вывода программ определенного типа, являются для меня ее самой привлекательной чертой. Вопрос о том, насколько широко (или узко)
можно (или целесообразно) охватить на этом пути возможности вычислительных машин, выходит за рамки данной работы. Пока что та часть, которую можно охватить, кажется нам достаточно богатой, чтобы продолжать ее исследование. ^Конец замечания 2.)
Определение семантики независимо от какой бы то ни было вычислительной модели ставит вопрос о том, можно ли вообще надеяться реализовать наш язык программирования. Мы могли бы воспарить в небеса и попросту отмахнуться от этого вопроса, объявив, что это не наше дело, что пусть об этом заботятся те, кто занимается реализацией, и что поскольку мы не требуем исполнения наших программ, то такой вопрос вообще неуместен. Но отмахнуться от этого вопроса не входит в мои намерения.
Реальные машины имеют память конечной емкости и, будучи предоставленными самим себе, в принципе ведут себя как конечные автоматы, которые после достаточно большого числа шагов возвращаются в состояние, в котором находились прежде. (Если к тому же машина детерминированная, то весь процесс с этого момента начнет повторяться.) Для полного описания поведения современных вычислительных машин теория конечных автоматов, к сожалению, имеет лишь ограниченное применение, так как число возможных состояний машины настолько громадно, что она может работать в течение многих лет, нс возвращаясь в состояние, в котором она была прежде. Мы можем опустить требование конечного объема памяти и взяться за изучение «бесконечных автоматов». По- видимому, мы как раз так и поступили: мы ввели конечное число переменных, но при введении переменных типа «integer» не наложили ограничения на число их возможных значений и, следовательно, нс ограничили числа возможных состояний. Наши программы написаны для Неограниченной Машины «НМ», а это то, что ни один инженер не может нам предоставить!
Впрочем, он может предоставить нам Достаточно Большую Машину «ДБМ». При загрузке в нее программы 3 она будет имитировать поведение машины НМ, когда та начнет выполнять программу 3 с начального состояния, удовлетворяющего условию wp(S, Ту Благодаря тому что для каждого начального состояния, удовлетворяющего условию wp(S, Ту как недетерминированность, так и количество шагов вычисления ограничены, множество целых значений, с которыми имеет дело машина НМ, конечно, и поэтому все они оказываются в конечной области. При условии что машина ДБМ может обрабатывать целые числа из этой конечной области, она сможет имитировать поведение машины НМ в данном вычислитель-
ном процессе (мы надеемся, достаточно быстро, чтобы эта имитация представляла интерес).
Замечание 3. Начиная с Тьюринга, у математиков вошло в привычку рассматривать ДБМ, т. е. машину, выполняющую вычислительные шаги с конечной скоростью в ограниченном пространстве состояний, как «осуществимую». В этой связи уместно вспомнить, что инженер должен моделировать ДБМ с помощью аналоговых средств, и самые теоретические физики, по-видимому, полагают, что вероятность ошибок при таком моделировании отлична от нуля и увеличивается с увеличением скорости выполнения операций. ^Конец замечания 3.}
Реализация недетерминированности требует специального рассмотрения. Возьмем такую часть программы:
do продолжение -^х : = xJ- 1
^продолжение продолжение := ложь od
Для всех й^О наш формализ дает НЦТ) =поп продолжение и, следовательно,
wp(S, Г) = поп продолжение
Мы представляем себе, что наша гипотетическая машина НМ при выполнении этой конструкции повторения вычисляет все предохранители и останавливается, если они ложные; в противном случае она выбирает одну из альтернатив с истинным предохранителем и выполняет ее. Затем, т. е. после успешного завершения этой операции, процесс повторяется. Если в начале выполняется условие поп продолжение, т. е. продолжение = ложь, то наш формализ говорит, что вначале предикат wp(S, Т) удовлетворяется, и это полностью согласуется с нашим желанием гарантировать окончание работы: собственно говоря, машина НМ немедленно прекратит выполнять повторения. Если, однако, в начале продолжение = истина, то предикат wp(S, Г) не удовлетворяется, и если мы хотим интерпретировать wp(S, Т) как слабейшее предусловие, гарантирующее завершение, мы должны отвергнуть любую предлагаемую НМ, в которой свобода выбора в отношении различных вариантов обеспечивается настолько, что каждый возможный вариант будет рано или поздно выбран. Мы могли бы представить себе какое-нибудь игральное устройство, например бросание монеты: герб для первой и решка для второй альтернативы. Так как никакая «правильная» монета не обязана когда-нибудь упасть гербом, наше требование было бы
удовлетворено. Но теперь мы в некотором смысле переопределили НМ. Беда в том, что первоначально естественное предположение о монете заставит нас заключить, что, хотя завер- шаемость не полностью гарантирована, вероятность незавер- шаемости равна нулю и что математическое ожидание величины, на которую увеличится х, равно 1. Такие вероятностные расчеты довольно скоро становятся очень трудными, но, к счастью, мы ими и не будем заниматься, так как весь подход не совсем верен: мы допускаем недетерминированность в тех случаях, когда нам безразлично, какой выбор сделает НМ, но тогда нам и не следует беспокоиться о том, какой она сделает выбор! Самый эффективный способ выйти из этого положения заключается в предположении, что НМ снабжена не правильной монетой, а очень вредным демоном; наличие такого демона превращает все вероятностные вопросы a priori в пустой звук. (Машина с таким демоном нас вполне устраивает; если нам существенно, какой из вариантов будет выбран и когда, то нам лучше усилить предохранители.)
Можно подумать, что моделирование НМ с ее вредным демоном представляет серьезную трудность для инженера, который разрабатывает моделирующую ДБМ. Это действительно было бы трудно, если бы требовалось реализовать истинно вредный выбор — в чем бы он ни состоял. Но так как демон всего лишь метафора, символизирующая наше незнание, его реализация не должна представлять ни малейшей трудности. Если у нашего конструктора ДБМ на полке десять различных «квазидемонов», то мы предоставляем ему полную свободу вмонтировать в ДБМ любой из них, и не будем возражать, если он вмонтирует в нее некий одиннадцатый, который он за это время придумает. Пока мы не знаем и отказываемся знать его свойства, все обстоит так, как надо.
Теперь мы достигли такой стадии, что если у нас есть программа и начальное состояние, то мы можем, по крайней мере в принципе, выбрать достаточно большую машину ДБМ. Но такой порядок действий не совсем реалистичен по двум причинам. Во-первых, непрактично держать на складе всю последовательность увеличивающихся по мощности ДБМ; во-вторых, в общем случае определение для произвольной программы и данного начального состояния размера ДБМ, удовлетворяющей нашим требованиям, является достаточно трудным делом. Легче «попробовать».
Более реальной является такая ситуация, когда вместо последовательности ДБМ у нас имеется одна «по-видимому достаточно большая машина» ПДБМ. ПДБхМ выполняет две функции. Кроме того, что она является наибольшей ДБМ, которую мы можем себе позволить, она может проверять во вре-
мя выполнения программы, является ли эта ДБМ достаточно большой для выполнения текущих вычислений. Если так, то она продолжает имитировать поведение НМ, в противном случае она отказывается их продолжать. Машина ПДБМ позволяет нам «пробовать», т. е. экспериментально определять, является ли реализуемая ею ДБМ на самом деле достаточно большой для выполнения текущих вычислений. Если ПДБМ доводит вычисления до конца, то мы можем заключить, что выбранная ДБМ оказалась достаточно большой.
Отсюда ясно, что самой существенной особенностью ПДБМ является умение явно отказываться выполнять работу, превышающую ее возможности: это необходимо, чтобы мы могли провести наш эксперимент. К большому сожалению, существуют машины, в которых постоянная проверка того, что имитация поведения НМ не выходит за границы их возможностей, не производится: из предполагаемых соображений эффективности, даже когда ее возможности не позволяют правильно продолжить вычисления, машина продолжает работать — хотя и неправильно. Очень трудно пользоваться такой машиной как надежным инструментом, так как подтверждение правильности получаемых ответов требует, кроме доказательства правильности программы, доказательства того, что машина способна выполнить заданные вычисления; по сравнению с первым второе доказательство во много раз сложнее и труднее. Нам бы потребовалось (аксиоматическое) определение возможных событий в НМ, тогда как до сих пор было достаточно предписывать только результаты; кроме того, часто очень трудно сформулировать точные ограничения, накладываемые конечностью конкретной машины. Поэтому машины, которые не контролируют свою возможность правильной имитации НМ, мы считаем непригодными и в дальнейшем исключаем из рассмотрения.
Благодаря тому что машина явно сообщает о своем отказе продолжать работу, ПДБМ является надежным средством, но это средство окажется не очень полезным, если отказы будут происходить слишком часто! На практике программист не только хочет написать программу, исполняя которую НМ выработает желаемый результат, но он также хочет уменьшить вероятность (или даже исключить возможность) получения отказа от ПДБМ, моделирующей НМ. Если конкретная ПДБМ накладывает в связи с этим на программиста большие обязательства (или, например, программисту очень трудно оценить, насколько эффективным окажутся меры, которые он собирается принять), то такая ПДБЛА просто неудобна в работе.
Теперь мы обратимся к понятию так называемого «свободного» предусловия, которое обсуждалось в конце главы «Ха-
рактеристика семантики». Свободное предусловие гарантирует выполнимость постусловия, если известно, что вычислительный процесс завершится правильно, но не гарантирует самого правильного завершения. В последующих главах нам не пришлось пользоваться этим понятием, ввиду того что мы определили семантику как отношение между начальным и конечным состояниями независимо от выполнения программы; только в связи с выполнением программы это понятие становится содержательным. Понятие выполнения программы нам понадобилось лишь тогда, когда мы ввели неограниченную машину НМ. Но от НМ мы требовали только выполнения наших программ, т. е. чтобы для любого (неупомянутого) постусловия R машина перешла в состояние, удовлетворяющее R, при условии, что мы запустили ее в начальном состоянии, удовлетворяющем предикату wp(S, R). В частности, мы требуем правильного завершения, только если при запуске удовлетворялось wp(S, Т). Мы нс позаботились предписать поведение НМ для начальных состояний, которые не удовлетворяют wp(S, 7), и поэтому понятие свободного предусловия к НМ не приложимо. Но мы ввели это понятие по отношению к ПДБМ, когда потребовали, чтобы она прекратила работу, если ее возможности оказались недостаточными. Другими словами, мы принимаем ПДБМ, умеющую правильно имитировать только некоторое подмножество вычислений, для которого гарантируется правильное завершение на НМ.
Замечание 4. Понятие свободного предусловия введено здесь в связи с ограниченностью ПДБМ. Оно резко контрастирует с очень похожим понятием «частичной правильности», которое было введено по отношению к неограниченным машинам (таким, как машины Тьюринга) в связи с неразрешимостью проблемы применимости. [Конец замечания 4.)
Можно задать вопрос — но я на него отвечать не буду — о том, как будет вести себя НМ, когда она начнет работать с начального состояния, про которое мы не знаем, удовлетворяет ли оно предикату wp(S, 7). Моя позиция (с философской точки зрения, по-видимому, очень шаткая) состоит в том, что, поскольку мы не доказали, что начальное состояние удовлетворяет wp(S, 7), НМ может вести себя как угодно в том смысле, что мы не имеем права предъявлять ей какие-либо претензии. (Представим следующую ситуацию. Чтобы НМ начала работать, пользователь должен нажать на кнопку «пуск», но чтобы иметь возможность сделать это, он должен находиться в машинном зале. В связи с режимом кондиционирования НМ может начать работу только тогда, когда все двери машинного зала закрыты, и в целях безопасности НМ (у которой нет
кнопки «стоп») во время работы не допускает, чтобы двери открывались. Такая ситуация показывает, насколько опасно запускать машину, не доказав, что она завершит работу!)
Если мы разрешаем НМ вести себя как угодно, коль скоро не доказана истинность wp(S, 7) в начальном состоянии, то из самого факта окончания работы мы не можем вывести каких- либо следствий. Мы можем попытаться с помощью машины найти опровержение предположения Гольдбаха, что каждое натуральное число п^2 есть среднее двух простых чисел. Но можно ли это сделать с помощью следующей программы: begin privar п, опровергнуто;
п vir цел\ = 1; опровергнуто vir 6t лев ; = ложь;
do non опровергнуто ->-
begin glovar n, опросе ргнуто; privar x, y;
n*.= n -p 1;
x vir цел, у vir ^x:=2, 2 * n;
do x <^y and x — у < 2, 2 * zz ->-
x -.^наименьшее простое, большее (x)
uX<y and x-]- У > 2 * n ->-
y :-— наибольшее простое, меньшее (у) od;
опровергнуто : = (х-^у =^2 * п)
end
od;
печатьбулев (рпровергнуто) end
Так как я не доказал, что предположение Гольдбаха не верно, я не доказал, что первоначальное значение wp(S, 7) истинно; следовательно, НМ может поступать, как ей нравится, а, следовательно, я не могу заключить, что предположение Гольдбаха не верно из того, что машина напечатала «истина» и остановилась. Я бы мог сделать это удивительное заключение, впрочем, если бы третья строка программы имела вид
«do non опровергнуто and п < 1 000 000 ->»
Немного подумав, я даже предпочитаю модифицированную программу первому варианту, так как она выглядит более «честной»: никто не начинает вычислений, не установив верхней границы для времени, в течение которого он готов ожидать ответ.
27 РЕТРОСПЕКТИВЫ
Появление автоматических вычислительных машин означало появление не только нового средства, но и новых проблем; и поскольку это средство оказалось беспрецедентным, такими же оказались и проблемы. Эти проблемы имели (и до сих пор имеют) два аспекта.
Во-первых, перед нами стоит задача поиска новых (разумных) сфер применения; это не так просто, потому что сфера применения может быть такой же революционизирующей, как и само средство. Спросите любого специалиста в области программирования: «Если бы в вашем распоряжении оказалась машина стоимостью в 10 миллиардов долларов, какие проблемы во имя блага человеческого стали бы вы на ней решать?»,— и вы обнаружите, что специалисту понадобится много времени, чтобы найти сколько-нибудь разумный ответ. Эта серьезная задача требует от нас большой фантазии и всей силы воображения. Мы говорим о ней здесь для полноты картины; в данной книге она не рассматривается.
Во-вторых, когда (будем надеяться, разумная) сфера применений определена, мы сталкиваемся с задачей программирования, т. е. с задачей приспособления нашего общего средства к данной специфической цели. Для сравнительно небольших и медленных машин задача программирования не являлась слишком серьезной, но когда широко доступными стали машины, в тысячи раз более мощные, соответственно возросли и запросы общества по их применению и задача программирования переросла в беспрецедентную интеллектуальную проблему. Именно это и явилось побудительным стимулом для написания настоящей книги.
С одной стороны, математическая основа программирования очень проста. Конечная последовательность нулей и единиц подвергается конечному числу простых операций, и в некотором смысле программирование должно быть тривиальным. С другой стороны, память объемом в много миллионов битов настолько велика, а обработка этих битов происходит
с такой невообразимо большой скоростью, что возможные вычислительные процессы — т. е. те процессы, которые мы изобретаем,— превосходят уровень тривиальности на несколько порядков. Именно это уникальное единство простоты и изощренности характерно для задачи программирования.
Что означает это единство, можно понять, если сравнить программиста, например, с хирургом, делающим сложную операцию. Оба должны быть в высшей степени аккуратны и внимательны. Однако в этом отношении хирург выполнил свои обязанности, если принял все известные меры предосторожности, и затем ему позволено уповать на то, что внешние обстоятельства, не находящиеся под его контролем, не испортят его работу. Хирурга не порицают за то, что его умение ограничено: неисчерпаемая сложность человеческого тела — общепризнанный факт. Но программист едва ли может аппелировать к неисчерпаемой сложности своей программы, так как она создана им самим! Программист должен платить за то, что все находится в его полном распоряжении: это следствие элементарности основ программирования.
Здесь следует упомянуть одно из следствий возросшей мощи современных вычислительных машин. В иерархических системах нечто, рассматриваемое как неделимое и неанализируе- мое на одном уровне, считается составным на следующем, более низком уровне детализации; следовательно, естественная единица времени или пространства, соответствующая каждому уровню, уменьшается на порядок каждый раз, когда мы переходим с одного уровня на другой, более низкий. Отсюда максимальное число уровней, которые содержательно можно выделить в иерархической системе, более или менее пропорционально логарифму отношения между единицами самого высшего и самого низшего уровней, и, следовательно, много уровней можно ожидать только тогда, когда это отношение очень велико. В программировании наш основной строительный блок, команда, выполняется меньше чем за микросекунду, а наша программа может потребовать часы и часы машинного времени. Кроме программирования, я не знаю другой технологии, имеющей дело с отношением 1010 или даже большим. Автоматическая вычислительная машина из-за своей фантастической скорости явилась первым устройством, предоставляющим достаточно «места» для развитой иерархической системы. И в этом отношении проблема программирования, по-видимому, на самом деле не имеет прецедента. Для интересующихся способностями человека решать трудные интеллектуальные задачи (избегая непреодолимую сложность) задача программирования является идеальным испытательным стендом.
Когда неспециалист просит объяснить, что в программировании понимается под «модуляризацией», самое первое, что приходит в голову, — это, по-видимому, то, как в мире науки из всей суммы знаний и умений выкристаллизировались отдельные научные дисциплины. Научные дисциплины имеют некоторый «размер», определяемый человеческими константами: необходимая сумма знаний должна умещаться в человеческой голове, число необходимых умений должно быть не больше того, чему человек может научиться и в дальнейшем не терять полученных навыков. С другой стороны, научная дисциплина не должна быть слишком маленькой или слишком уз- Koii, так чтобы она нс исчерпалась в течение по крайней мере одного поколения. Но не любой набор отрывочных фактов и небывалых умений, даже достаточного размера, составляет живую научную дисциплину! Есть еще два требования. Внутреннее требование соответствия: умение должно позволять накапливать знания, а знания должны позволять совершенствовать умение. И, наконец, есть внешнее требование — мы назовем его «узким интерфейсом» — это чтобы сущность дисциплины можно было изучать в достаточно высокой степени изолированности, чтобы она нс зависела в каждый момент критически от развития других областей науки.
Эта аналогия не только полезна при объяснении термина «модуляризация», но она дает нам и ключ к пониманию того, как мы должны пытаться думать во время программирования. При программировании перед нами встают те же самые проблемы размера и разнообразия. (Даже программируя на самом высоком уровне наших способностей, мы иногда ничего не можем поделать с текстом программы, он становится таким большим, что проблемой становится сама его длина. Возможные варианты вычислений могут стать такими длинными или настолько изменчивыми, что нам трудно их представить. У нас могут быть конфликтующие цели, такие, как высокая скорость прохождения задачи и маленькое время реакции, и т. д.). Мы не можем решить эти проблемы, просто решив, что программа будет разбита на «модули».
Главная характерная черта культурного мышления, по моему мнению, заключается в том, что человек может и хочет глубоко изучать некоторый аспект изолированно, ради его собственного содержания, сознавая в то же самое время, что он занимается только одним из аспектов. Другие аспекты должны ждать своей очереди, потому что наши головы так малы, что не могут без путаницы работать со всеми аспектами одновременно. Именно это я понимаю под «фиксацией внимания на определенном аспекте»; это не значит, что другие аспекты полностью игнорируются, но временно мы про них
забываем настолько, насколько они не существенны для текущей темы. Такое разделение, даже если оно и не полностью осуществимо, есть единственно доступный нам метод эффективного упорядочивания наших мыслей, который мне известен.
Обычно я называю этот метод «разделением аспектов», так как мы пытаемся поочередно разделаться с нашими трудностями, обязанностями, желаниями и ограничениями. Когда это достижимо, то мы добиваемся более или менее полного разделения объектов рассуждения — и это разделение находит свое отражение в результирующем разделении программы на «модули», — но мне бы хотелось отметить, что это разделение объектов рассуждения является только результатом, а не целью. Цель мышления заключается в уменьшении детализованных рассуждений до выполнимого количества, и разделение аспектов — это способ, с помощью которого мы собираемся добиться этого уменьшения.
Критическим моментом здесь является, конечно, выбор тех аспектов, которые подлежат «изолированному» изучению; как распутать аморфный клубок обязанностей, ограничений и целей и выделить множество «аспектов», допускающих разумное и эффективное разделение. Чтобы успешно разделить аспекты в области новых и трудных проблем, почти всегда потребуется долгая и кропотливая работа; по-видимому, нереально надеяться на другое. Но даже если мы не располагаем простыми правилами, по которым производится это разделение (мы ведь не пишем брошюру на тему «Как научиться принимать важные решения за десять простых уроков»), знание цели «разделения аспектов» очень полезно: по крайней мере мы начинаем понимать, что нам нужно.
К тому же у нас есть одно простое правило! Оно говорит; не смешивайте аспекты, которые были разделены в начале! Этим правилом мы воспользовались, когда начали работать над данной книгой. Вначале мы боялись, как бы у нас не получилась такая ненадежная система, которая либо будет производить неверные результаты, либо вообще не будет работать. Если такая система является комбинацией оборудования и программного обеспечения, то в идеале программное обеспечение будет безошибочным, оборудование безупречным и работа системы совершенной. Если это не так, то либо неверно программное обеспечение, либо неверно функционирует оборудование, либо и то и другое. Оба этих различных источника ошибок могут вызвать почти одинаковые эффекты: если из-за случайной ошибки испорчена команда в памяти машины или из-за постоянной неправильной работы все время неправильно интерпретируется некоторая команда, конечный ре-
зультат очень похож на работу с неотлаженной программой. И все же причины этих двух неправильностей весьма различны. Даже совершенное оборудование подвержено износу и нуждается в обслуживании; программное обеспечение нуждается в исправлениях (но тогда оно было неправильно с самого начала) или модификациях, если изменились наши требования к программе. Наше простое правило требует, чтобы мы не смешивали эти два момента. С одной стороны, мы можем заниматься повышением уровня надежности наших программ (предполагая, что они выполняются совершенной машиной). С другой стороны, можно исследовать вопрос о вычислениях на не вполне надежной машине, и тогда нам лучше предположить, что наши программы совершенны. В данной книге мы имеем дело с первым из этих аспектов.
В упомянутом случае наше простое правило, по-видимому, корректно: не разделив оборудование и программное обеспечение, нам бы пришлось прибегнуть к статистическим методам, возможно с использованием понятия СВМС («среднее время между сбоями», где «среднее время между очевидными ошибками» было бы более правильным), и описанная в книге теория никогда не могла бы быть разработана.
Прежде чем взяться за эту работу, я провел дальнейшее разделение аспектов. Цитирую отрывок из письма одного из моих коллег:
«В программировании имеется еще и третий момент: после подготовки «текста программы как статического, в некотором смысле формального, математического объекта» и после того как рассмотрена инженерная сторона вычислительных процессов, определяемых программой в конкретной ситуации, лично для меня самым трудным является фактический переход к выполнению программы: перевод удобного для чтения человеку текста, содержащего труднообнаруживасмые глазом (который «видит то, что хочет видеть») ошибки и описки, в текст, удобный для чтения машине; и затем еще нужно как-то убедиться, что мы при этом ничего не потеряли».
(Из того, что мой коллега называет этот третий момент «самым трудным», можно заключить, что он очень опытный программист, а также и честный! Я мог бы добавить, хотя и не знаю, имеет ли это какое-нибудь отношение к делу, что его почерк оставляет желать лучшего.) Этот третий момент в данной книге не обсуждается не потому, что мы считаем его неважным, а потому, что его можно (и поэтому нужно) отделить от других аспектов; здесь применяются совсем другие, специфические средства (считывание, многократное дублирование и другие формы избыточности). Я упомянул этот третий момент, потому что обнаружил, что другой мой коллега — инженер по профессии — настолько им одержим, что не может заставить себя рассматривать два другие аспекта изолированно
и поэтому отбрасывает саму идею доказательства правильности программы как совершенно несущественную. Следует отчетливо сознавать, что действия, направленные на разделение аспектов, почти всегда вызывают противодействие, часто сопровождаемое замечанием, что «решаются не настоящие задачи». Мы не будем пытаться понять или объяснить это явление. Такое сопротивление присуще, разумеется, не только инженерам-практикам, как показывает следующее высказывание Бертрана Рассела: «Преимущества метода постулирования огромны — это тс же преимущества, что у воровства перед честным трудом».
Следующее разделение аспектов осуществляется в самой книге: это разделение математической стороны дела, относящейся к правильности, и инженерной стороны, относящейся к выполнению. И мы провели это разделение настолько полно, что дали аксиоматическое определение семантики наших языков программирования, что позволяет нам, если мы этого хотим, игнорировать возможность выполнения программы. Это сделано в самой книге по той простой причине, что, исторически говоря, такое разделение не следовало из нашего простого правила; наоборот, в шестидесятых годах доминировал операционный подход, характеризуемый тем, что «сама семантика задается некоторым интерпретатором, который описывает, как изменяется вектор состояния во время вычисления». (Джон Мак-Карти, 1965.) Первыми от этого метода начали отходить Р. У. Флойд (1967) и Ч. Хоар (1969).
Такое разделение требует гораздо больше времени, потому что даже после того, как становится очевидным его возможность и желательность, существует много путей его реализации. В зависимости от темперамента, способности, оценки предстоящих трудностей можно либо загореться желанием аксиоматизировать как можно более универсальный язык программирования, либо осторожно попытаться найти самые эффективные ограничения. Я безоговорочно выбрал второе, и отказ от процедур (и в чистом виде, а также от таких понятий, как параметры, и даже — результат) является, по-видп- мому, эффективным упрощением, настолько решительным, что некоторые из моих читателей могут потерять интерес к оставшимся «тривиальным» деталям.
Оставалось решить следующие главные вопросы:
выводить ли слабейшее предусловие из желаемого постусловия или выводить сильнейшее постусловие из данного предусловия;
взять за основу слабейшее предусловие—как сделали мы-- или слабейшее свободное предусловие;
включить ли недетерминированность;
должен ли «демон» быть вредным или в некотором смысле «справедливым».
Как решать эти вопросы? То что вывод слабейшего предусловия вместо сильнейшего постусловия ведет к более гладкому формализму, возможно, для многих и очевидно, я же дошел до этого, только испытав оба варианта. Более естественным казалось начинать с желаемого постусловия, на что я и решился, тем более что в этом отражался тот факт, что программирование — это целенаправленная деятельность.
Мне потребовалось больше времени, чтобы остановиться просто на предусловиях, а не на свободных предусловиях. Мне хотелось этого, так как до тех пор, пока вывод преобразователями предикатов слабейшего свободного предусловия является для нас единственным путем определения семантики, нам никогда не удастся гарантировать завершаемости, но такая система не может привлечь нас, так как она слишком слаба. Вопрос был решен, когда выяснилась возможность определения wp (DO, R) в терминах wp (IF, 7?).
К решению ввести недетерминированность я пришел постепенно. Аналогия между синхронизирующими условиями в мультипрограммировании и упорядочивающими условиями в последовательном программировании подсказала переход к охраняемым наборам команд и подготовила меня к введению недетерминированности в последовательные программы; моя растущая неудовлетворенность асимметричностью выражения «if В then 31 else 32 fi», в котором 32 трактуется как умолчание,— а я научился не доверять умолчанию — доделала остальное. Симметрия и элегантность выражения
if у fi
и то, что я смог систематично вывести эту программу, явилось последним аргументом при решении этого вопроса.
Некоторое время (и в непосредственной связи с моими опытами мультипрограммирования, где обычно стараются не допускать «замораживания отдельных процессов») я считал необходимым постулировать, что демон должен работать «справедливо случайно», т. е. не допуская постоянного игнорирования одной из возможных альтернатив. Этот «справедливо случайный порядок» постулировался на этапе, когда я только вырабатывал операционное описание того, как я намеревался реализовать конструкцию повторения. На следующий день, когда я рассмотрел формальное описание ее семантики, я увидел свою ошибку и демон окончательно был объявлен совершенно вредным.
Короче говоря, после необходимых исследовательских экс-
периментов вопросы (1) — (4) были решены в основном с помощью одного и того же критерия: формальной простоты.
Мой интерес к формальным доказательствам правильности был и остается вторичным. Я был свидетелем многих обсуждений языков программирования и стиля программирования, которые были удручающе неубедительными. Причина, по которой спорящие с большим трудом приходили к соглашениям, заключалась в том, что не было эффективных критериев, в уместность которых мы бы все верили. (Слишком много мы пытались решить во имя удобства пользователя, но слишком часто мы путали «удобное» с «привычным», а этот последний критерий в слишком большой степени зависит от личного конкретного опыта.) Во время этой неразберихи как дар с небес к нам пришла такая мысль: «элегантным» мы почти всегда называли то, что допускало красивое короткое доказательство; это было немедленно принято как разумная гипотеза, а эффективность этой гипотезы превратила се в лелеемый критерий. И, помимо всего прочего, длина формального доказательства— это объективный критерий; эта объективность, по-видимому, стала тем средством, которое позволило нам быстрее приходить к взаимопониманию, гораздо быстрее, чем под влиянием только красноречия. Главным для меня было не формальное доказательство правильности, а научная дисциплина, которая помогала бы нам делать наши программы разумными, понятными и разумно управляемыми.
С разными примерами я работал с разной степенью формальности. Эти отклонения были преднамеренными, так как мне не хотелось, чтобы мои читатели думали, что некая определенная, фиксированная степень формальности является «правильной». Я предпочитаю смотреть на формальные методы как на средство, использование которого может оказаться полезным.
Я попытался представить программирование скорее как научную дисциплину, а не как ремесло. С давних времен нам известны два главных способа передачи знаний и умений следующим поколениям. Один метод цеховой: молодой ученик семь лет работает с мастером, знание передастся неявно, ученик поглощает его, так сказать, осмотически, пока сам не становится мастером. (Эта неявная передача для знания невыгодна: потеряно столько старых ремесел!) Второй метод применяется в университетах, расцвет которых совпал (не случайно!) с распространением книгопечатания; здесь мы пытаемся формулировать наши знания и тем самым делаем их общественным достоянием. (Наше фактическое обучение в университетах часто занимает промежуточное место: в математике, например, результаты публикуются и им учат вполне
явно; обучение же тому, как делать математику, часто оставляется осмосу. И причина заключается не в нашей скрытности, а в том, что мы чувствуем себя неспособными сказать про это «как» что-нибудь более научное, чем обычные родительские пожелания.)
Разбирая примеры, я старался как можно более ясно передать весь ход своих размышлений (хотя, конечно, мне не всегда удавалось плавно объяснить неожиданную находку); все примеры были лишь средством для демонстрации этой ясности.
Мы сформулировали ряд теорем о конструкциях выбора и повторения. Это была самая легкая часть, так как она касается знания. С помощью примеров мы попытались показать, как целенаправленная попытка применить это знание может помочь нам в процессе программирования; это было трудной частью, так как она относится уже к умению. (Я думаю, например, о том, как знание теоремы о линейном просмотре помогало нам решать задачу о следующей перестановке.) Мы попытались четко описать несколько стратегий, таких, как Поиск Малого Супермножества, и несколько методов «утрамбовки» программы, таких, как вынесение условия из конструкции повторения. Но эти методы тесно связаны с программированием (нашей формой программирования).
Возможно, что читатель уловил между строк несколько более общих соображений. Первое соображение заключается в том, что не достаточно разработать механизм, который, как мы надеемся, будет отвечать нашим требованиям; надо разработать его в таком виде, чтобы мы могли убедить себя (и вообще говоря, любого другого), что он на самом деле будет им отвечать. И, следовательно, вместо того, чтобы сначала написать программу, а потом пытаться доказывать ее правильность, мы одновременно разрабатываем и программу, и доказательство ее правильности. (Фактически доказательство правильности несколько опережает программу: выбрав форму доказательства правильности, мы пишем программу так, чтобы она удовлетворяла требованиям доказательства.) Из этого следует, что если все идет по составленному плану, то разработка остается «интеллектуально управляемой». Второе соображение состоит в том, что, если этот конструктивный подход к проблеме правильности программы мы берем как основу нашего планирования, нам следует проследить за тем, чтобы соответствующая ей интеллектуальная работа нс превысила наших ограниченных возможностей; следует заметить, что немногие разработки удовлетворяют этому требованию. В задаче о голландском национальном флаге, например, мы чуть не увлеклись анализом вариантов, где число вариантов, кото-
рые необходимо различать, росло экспоненциально: если бы мы пошли по этому пути, то быстро пришли бы к такому числу вариантов, проанализировать которые были бы уже не в силах. В задаче о кратчайшем покрывающем дереве мы видели, как ограничение класса допустимых промежуточных состояний («красные» ветви всегда образуют дерево) может значительно упростить анализ. Но самым полезным — его можно считать примером разделения аспектов — является метод пошаговой детализации, когда мы пытаемся преодолевать возникающие перед нами препятствия по очереди. В задаче о кратчайшем покрывающем дереве к тому времени, когда нас начало беспокоить время вычисления, М2-алгоритм мы нашли как оптимизацию N3-aлгopитмa. В задаче о максимально сильной компоненте мы сначала нашли алгоритм, линейно зависящий от числа ребер, и только следующее уточнение гарантировало нам и фиксированное максимальное время обработки каждой вершины. В задаче о самых удаленных селениях наше грубое решение было подвергнуто двум независимым сильно отличающимся оптимизациям, их объединение после этого уже не представляло труда.
Как было замечено выше, цель мышления состоит в уменьшении детализированных рассуждений до выполнимого количества. Наиболее волнующий вопрос здесь: можно ли понимаемому в этом смысле «мышлению» научить? Если я отвечу на этот вопрос: «Нет», — то меня сразу же могут спросить, зачем я стал писать эту книгу; если на этот вопрос я отвечу: «Да», — то буду выглядеть дураком, и мне остается ответить одно: «До некоторой степени...» По-видимому, мягко выражаясь, тщетно надеяться написать книгу, которую можно дать молодому человеку со словами: «Прочти ее, и ты сможешь мыслить эффективно». Наша надежда остается столь же тщетной, если заменить такую книгу красивой диалоговой системой обучения с помощью машины.
Но когда люди пытаются понять (сначала подсознательно), выявить и обойти непреодолимую сложность, я верю, что им можно помочь. Прежде всего им нужно объяснить, что человек неспособен «заниматься всем: королями и капустой» (по крайней мерс в каждый конкретный момент), и нужно научить их видеть, в чем и когда сложность проявляется. В той же мере, в какой профессор консерватории помогает своим ученикам знакомиться с элементами гармонии и ритма и с тем, как их можно комбинировать, вполне возможно научить студентов чувствовать образцы рассуждений и способы их комбинирования. Эта аналогия вовсе не притянута за волосы: четко проведенным рассуждением можно восторгаться так же, как и адажио Моцарта.
ОГЛАВЛЕНИЕ
Предисловие редактора перевода 3
Предисловие 3
От автора 9
0. Абстракция исполнения17
Роль языков программирования24
Состояния и их характеристика27
Характеристика семантики34
Семантическая характеристика языка программирования . . . '15
Две теоремы62
О проектировании правильно завершаемых конструкций66
Пересмотренный алгоритм Евклида70
Формальное рассмотрение нескольких небольшихпримеров . . . 77
Когда недетерминированность ограничена ,104
Размышления па тему: «Область действия переменных» . . . . 111
И. Векторные переменные131
Теорема о линейном просмотре145
Задача о следующей перестановке147
Задача о голландском национальном флаге151
Обновление последовательного файла158
Еще раз о задачах слияния165
Упражнение, приписываемое Р. У. Хэммингу172
Задача поиска по образцу178
Представление числа в виде суммы двух квадратов184
Задача о наименьшем простом множителе большого числа . . . 187
Задача о самых удаленных селениях194
Задача о кратчайшем покрывающем дереве199
Алгоритм Рема выделения классов эквивалентности208
Задача о выпуклой оболочке в трехмерном пространстве . . . . 217
Нахождение максимально сильных компонент в ориентированном графе245
О руководствахи реализациях256
Ретроспективы265
УВАЖАЕМЫЙ ЧИТАТЕЛЬ!
Ваши замечания о содержании книги, ее оформлении, качестве перевода и другие просим присылать по адресу: 129820, Москва, И-110, ГСП, 1-й Рижский пер., д. 2, издательство «Мир».
Э. ДейкстраДИСЦИПЛИНАПРОГРАММИРОВАНИЯ
Редактор Л. Н. Бабынина Художник В. М. Новоселова
Художественный редактор В. И. Шаповалов Технический редактор Н. И. Борисова Корректор В. И. Киселева
ИБ № 1232
Сдано в набор 21.12.77. Подписано к печати 22.08.78.
Формат 60Х90'/|6Бумага типографская № 1.
Гаршнура латинская. Печать высокая.
Объем 8.75 бум. л. Усл. печ. л. 17,50 Уч.-изд. л. 14,03 Изд. № 1/9552 Тираж 25 000 экз. Зак. 6. Цена 1 р. 20 к.
Издательство «МИР»
Москва, 1-й Рижский пер., 2
Московская типография № 8 Союзполиграфпрома при Государственном комитете Совета Министров СССР по делам издательств, полиграфии и книжной торговли,
Хохловский пер., 7.
A DISCIPLINE OF PROGRAMMING
EDSGER W. DIJKSTRA
Burroughs Research Fellow, Professor Extraordinarius, Technological University, Eindhoven
PRENTICE-HALL, INC. ENGLEWOOD CLIFFS, N. J. 1976
МАТЕМАТИЧЕСКОЕ ОБЕСПЕЧЕНИЕ ЭВМ
Э.Дейкстра
ДИСЦИПЛИНА ПРОГРАММИРОВАНИЯ
Перевод с английского
И. X. Зусман, В. В. Мартынюка и Л. В. Ухова
Под редакцией
Э. 3. Любимского
ИЗДАТЕЛЬСТВО
«МИР»
Москва 1978
УДК 681.142.2
Книга написана одним из крупнейших зарубежных специалистов в области программирования, известным советскому читателю по переводам с[о книг на русский язык (например, «Структурное программированием «Мир», 1972). Она посвящена фунда- мсчнальным вопросам конст руировання корректных и изящных программ для ЭВМ. В ней предлагаемся методика формального вывода программы из математической постановки задачи. При эюм прослеживается развитие алюритмов вплоть до создания программ. Материал излагается в форме остроумных и поучительных задач но программированию.
Книга представлявI значительный интерес для широкого круга программистов.
Редакция литературы по математическим наукам
Original English language edition published by PrenticeHall Inc.. Englewood (Jiffs New Jersey USA Copyright tM976 by Preu t icc-I I al 1 Inc. All Rights Reserved
т 20204-033 ’,l 041 (01 )-78т 20204-033 ’,l 041 (01 )-7833-7833-78(С Перевод на русский язык, «Мир», 1978(С Перевод на русский язык, «Мир», 1978
т 20204-033 ’,l 041 (01 )-78
т 20204-033 ’,l 041 (01 )-78
33-78
33-78
(С Перевод на русский язык, «Мир», 1978
(С Перевод на русский язык, «Мир», 1978