Текст
                    Александр А. Степанов, Дэниэл Э. Роуз
От математики
к обобщенному
программированию


Alexander A. Stepanov, Daniel E. Rose From Mathematics to Generic Programming AAddison-Wesley Upper Saddle River, NJ • Boston • Indianapolis • San Francisco New York • Toronto • Montreal • London • Munich • Paris • Madrid Capetown • Sydney • Tokyo • Singapore • Mexico City
Александр А. Степанов, Дэниэл Э. Роуз От математики к обобщенному программированию км 5г;?^ ' I 1 ■■•-■а '1 » ц с Москва, 2015
Степанов Александр А., Роуз Дэниэл Э. С79 От математики к обобщенному программированию / пер. с англ. А. А. Слин- кина. - М.: ДМК Пресс, 2015. - 264 с: ил. В этой основательной и вместе с тем доступной книге авторы объясняют принципы обобщенного программирования и стоящее за ними понятие математической абстракции. Любой квалифицированный программист, умеющий логически мыслить, уже обладает достаточными знаниями для ее прочтения. Авторы на удивление доходчиво сообщают необходимые сведения из общей алгебры pi теории чисел. Они объясняют, какие проблемы должны были разрешить математики, и показывают, как найденные ими решения переводятся на язык обобщенного программирования и позволяют создать эффективный и элегантный код. Читая эту книгу, вы освоите мыслительный процесс, необходимый для правильного программирования, и научитесь обобщать найденные для частной задачи алгоритмы с целью расширить область их полезного применения без потери эффективности. Вы также постигнете, в чем состоит ценность математики для программирования, — и это понимание пригодится вне зависимости от того, на каком языке вы пишете и какую парадигму применяете. Authorized translation from the English language edition, entitled FROM MATHEMATICS TO GENERIC PROGRAMMING; ISBN by ALEXANDER STEPANOV and DANIEL ROSE; published by Pearson Education, Inc., publishing as Addison-Wesley Professional. Copyright© 2015. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc. RUSSIAN language edition published by DMK PUBLISHERS. Copyright © 2015. Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав. Материал, изложенный в данной книге, многократно проверен. Но поскольку вероятность технических ошибок все равно существует, издательство не может гарантировать абсолютную точность и правильность приводимых сведений. В связи с этим издательство не несет ответственности за возможные ошибки, связанные с использованием книги. Copyright © 2015 Pearson Education, Inc. © Оформление, перевод, ДМК Пресс, 2015
Содержание Благодарности 9 Об авторах 10 От авторов 11 Предисловие автора к русскому изданию 12 Глава 1. О чем эта книга 13 1.1. Программирование и математика 13 1.2. Исторические справки 14 1.3. Требования к читателю 15 1.4. План книги 15 Глава 2. Первый алгоритм 17 2.1. Египетское умножение 18 2.2. Улучшение алгоритма 21 2.3. Заключительные мысли 24 Глава 3. Теория чисел в Древней Греции 25 3.1. Геометрические свойства целых чисел 25 3.2. Просеивание простых чисел 28 3.3. Реализация и оптимизация кода 30 3.4. Совершенные числа 35 3.5. Пифагорейская программа 38 3.6. Фатальный изъян в программе 40 3.7. Заключительные мысли 44 Глава 4. Алгоритм Евклида 45 4.1. Афины и Александрия 45 4.2. Алгоритм Евклида нахождения наибольшей общей меры 47 4.3. Тысяча лет без математики 51 4.4. Странная история нуля 52 4.5. Алгоритмы нахождения частного и остатка 54 4.6. Повторное использование кода 57 4.7. Доказательство правильности алгоритма 60 4.8. Заключительные мысли 61 Глава 5. Зарождение современной теории чисел 62 5.1. Простые числа Мерсенна и Ферма 62 5.2. Малая теорема Ферма 66 5.3. Сокращение 69 5.4. Доказательство малой теоремы Ферма 72
6 ♦ Содержание 5.5. Теорема Эйлера 74 5.6. Применение арифметики по модулю 78 5.7. Заключительные мысли 79 Глава 6. Абстракция в математике 80 6.1. Группы 80 6.2. Моноиды и полугруппы 83 6.3. Некоторые теоремы о группах 86 6.4. Подгруппы и циклические группы 88 6.5. Теорема Лагранжа 90 6.6. Теории и модели 94 6.7. Примеры категоричных и некатегоричных теорий 97 6.8. Заключительные мысли 99 Глава 7. Вывод обобщенного алгоритма 102 7.1. Осмысление требований к алгоритму 102 7.2. Требования к А 103 7.3. Требования kN 106 7.4. Новые требования 108 7.5. От умножения к возведению в степень 109 7.6. Обобщение операции 111 7.7. Вычисление чисел Фибоначчи 114 7.8. Заключительные мысли 117 Глава 8. Еще об алгебраических структурах 118 8.1. Стевин, полиномы и НОД 118 8.2. Геттинген и немецкая математика 123 8.3. Нётер и рождение общей алгебры 128 8.4. Кольца '. 129 8.5. Умножение матриц и полукольца 132 8.6. Приложение: социальные сети и кратчайшие пути 134 8.7. Евклидовы кольца 136 8.8. Поля и другие алгебраические структуры 137 8.9. Заключительные мысли 138 Глава 9. Организация математических знаний 141 9.1. Доказательства 141 9.2. Первая теорема 144 9.3. Евклид и аксиоматический метод 147 9.4. Альтернативы евклидовой геометрии 148 9.5. Формалистический подход Гильберта 151 9.6. Пеано и его аксиомы 153 9.7. Построение арифметики 156 9.8. Заключительные мысли 159
Содержание ♦ 7 Глава 10. Основные понятия программирования 160 10.1. Аристотель и абстракции 160 10.2. Значения и типы 162 10.3. Концепции 163 10.4. Итераторы 166 10.5. Категории, операции и характеристики итераторов 167 10.6. Диапазоны 171 10.7. Линейный поиск 173 10.8. Двоичный поиск 174 10.9. Заключительные мысли 178 Глава 11. Алгоритмы перестановки 179 11.1. Перестановки и транспозиции 179 11.2. Обмен диапазонов 182 11.3. Циклическая перестановка 185 11.4. Использование циклов 188 11.5. Обращение 192 11.6. Пространственная сложность 196 11.7. Алгоритмы, адаптирующиеся к объему памяти 197 11.8. Заключительные мысли 198 Глава 12. Обобщения НОД 199 12.1. Аппаратные ограничения и более эффективный алгоритм 199 12.2. Обобщение алгоритма Штайна 202 12.3. Теорема Безу 204 12.4. Расширенный алгоритм Евклида 208 12.5. Применения НОД 212 12.6. Заключительные мысли 213 Глава 13. Реальное приложение 215 13.1. Криптология 215 13.2. Проверка простоты 217 13.3. Тест Миллера-Рабина 220 13.4. Алгоритм RSA: как и почему он работает 222 13.5. Заключительные мысли 225 Глава 14. Заключение 226 Дополнительная литература 228 Приложение А. Обозначения 233 Приложение В. Стандартные приемы доказательства 236 8.1. Доказательство от противного 236 8.2. Доказательство по индукции 237
8 ♦> Введение В.З. Принцип Дирихле 238 Приложение С. Язык C++ для программистов на других языках 239 С.1. Шаблонные функции 239 С.2. Концепции 240 С.З. Синтаксис объявлений и типизированные константы 241 С.4. Объекты-функции 241 С.5. Предусловия, постусловия и утверждения 242 Сб. Алгоритмы и структуры данных STL 243 С.7. Итераторы и диапазоны 244 С.8. Использование using для псевдонимов типов и функций типов в С++11 245 С.9. Списки инициализаторов в С++11 246 СЮ. Лямбда-функции в С++11 246 СИ. Замечание о ключевом слове inline 247 Библиография 248 Предметный указатель 252
Благодарности Мы благодарны всем, кто способствовал появлению этой книги. Руководство нашей компании A9.com активно поддерживало проект с самого начала. Билл Стейсиор предложил создать курс, легший в основу этой книги, и выбрал тему из предложенных нами вариантов. Брайан Пинкертон не только прослушал весь курс, но и всячески приветствовал идею превратить его в книгу. Мы благодарим также Мэта Маркуса, который вместе с Алексом читал похожий курс в компании Adobe в 2004-2005 годах. На протяжении всего процесса важную роль играли другие члены группы по фундаментальным структурам данных и алгоритмам поиска. Анил Ганголли (Anil Gangolli) помогал при отборе материала для курса, Райан Эрнст (Ryan Ernst) подготовил большую часть инфраструктуры программирования, а Парамжит Оберой (Paramjit Oberoi) высказывал ценнейшие замечания на этапе написания книги. Нам доставило истинное удовольствие работать с ними, и мы признательны им за помощь. Мы выражаем благодарность редакторам Питеру Гордону (Peter Gordon) и Грэ- гу Донку (Greg Doench), а также всему коллективу, собравшемуся под крышей издательства Addison-Wesley, в том числе главному редактору Джону Фуллеру, редактору по производству Мэри Кэсел Уилсон (Mary Kesel Wilson), выпускающему редактору Джилл Хоббс (Jill Hobbs), верстальщику и специалисту по LaTeX Лори Хьюз (Lori Hughes), за усилия по превращению рукописи в безупречную книгу Наконец, мы хотим поблагодарить наших друзей, семьи и коллег, которые прочли черновые варианты книги и поделились с нами замечаниями, исправлениями, предложениями, советами и т. д.: Гаспера Азмана (Gasper Azman), Джона Баннинга (John Banning), Синтию Дворк (Cynthia Dwork), Германа Эпельмана (Hernan Epelman), Райана Эрнста (Ryan Ernst), Анила Ганголли (Anil Gangolli), Сьюзан Груббер (Susan Gruber), Джона Кальба (Jon Kalb), Роберта Лера (Robert Lehr), Дмитрия Лещинера (Dmitry Leshchiner), Тома Лондона (Tom London), Марка Манасси (Mark Manasse), Пола Макджонса (Paul Mcjones), Николаса Николова (Nicolas Nicolov), Гора Нишанова (Gor Nishanov), Парамжита Обероя (Paramjit Oberoi), Шона Пэрента (Sean Parent), Фернандо Пелличиони (Fernando Pelliccioni), Джона Рейзера (John Reiser), Роберта Роуза (Robert Rose), Стефана Варгиаса (Stefan Vargyas) и Адама Юнга (Adam Young). Благодаря им книга стала намного лучше.
Об авторах Александр А. Степанов изучал математику в Московском государственном университете с 1967 по 1972 год. С 1972 года занимается программированием, сначала в Советском Союзе, а затем, после эмиграции в 1977 году, в США. Он принимал участие в программировании операционных систем, инструментальных средств программирования, компиляторов и библиотек. В работе по основаниям программирования ему оказывали поддержку компания Дженерал Электрик, Политехнический университет, компании Bell Labs, HP, SGI, Adobe, и - с 2009 года по сей день - A9.com, дочерняя компания Amazon, специализирующаяся на технологиях поиска. В 1995 году журнал «Dr. Dobb's Journal» присудил ему премию «За выдающиеся заслуги в программировании» за проектирование стандартной библиотеки шаблонов C++ (Standard Template Library). Дэниэл Э. Роуз - ученый-исследователь, занимал руководящие должности в компаниях Apple, AltaVista, Xigo, Yahoo и A9.com. Круг его научных интересов охватывает технологии поиска, от низкоуровневых алгоритмов сжатия индекса до вопросов взаимодействия машины и человека в процессе поиска в веб. Роуз руководил в компании Apple группой, разработавшей систему локального поиска для компьютера Macintosh. Он обладатель докторской степени по когнитивисти- ке и информатике, присужденной Калифорнийским университетом в Сан-Диего, а также степени бакалавра по философии, присужденной Гарвардским университетом.
От авторов Разделение информатики и математики сильно обедняет обе науки. Лекции, положенные в основу этой книги, - предпринятая мной попытка показать, что обе деятельности - древнюю, восходящую к истокам нашей цивилизации, и самую что ни на есть современную - можно соединить. Мне очень повезло, что мой друг Дэн Роуз, под руководством которого наша группа применяла принципы обобщенного программирования к проектированию поисковой системы, согласился преобразовать мои довольно бессвязные лекции в цельную книгу. Мы оба надеемся, что читателям понравится плод нашей совместной работы. -А. А. С Книга, которую вы держите в руках, основана на заметках к курсу лекций «Алгоритмические путешествия», прочитанному Алексом Степановым в компании A9.com в 2012 году. Но в ходе нашей с Алексом работы по переложению материала курса в форму книги мы пришли к выводу, что могли бы поведать более интересную историю - об обобщенном программировании и его математических основаниях. Это побудило нас существенно изменить организацию книги и исключить целый раздел, посвященный теории множеств и математической логике, который как-то не укладывался в этот рассказ. Пришлось также добавить и удалить ряд деталей, чтобы сделать изложение более связным и доступным читателям, не имеющим основательной математической подготовки. Алекс, в отличие от меня, получил математическое образование. Кое в чем мне было нелегко разобраться, но приложенные усилия позволили мне понять, что нуждается в дополнительном объяснении. Если иногда мы трактуем какие-то вопросы не так, как это сделал бы математик, или используем не вполне стандартную терминологию, или сознательно упрощаем изложение, то это целиком моя вина. -ЯЭ.Р.
Предисловие автора к русскому изданию Эта книга родилась из курсов, которые я читал в Америке, но корни ее ведут к моим русским учителям. О Лобачевском и Пуанкаре я узнал от Эрнеста Борисовича Винберга, когда он нам, десятиклассникам второй школы, рассказывал про созданную Пуанкаре модель геометрии Лобачевского. Общую алгебру я полюбил, слушая лекции Александра Геннадиевича Куроша на мехмате МГУ. Он уверял, что «алгебра шлифует головы», и действительно на моей голове есть несколько до блеска отшлифованных мест. О теореме Лагранжа, да и вообще о группах я узнал от Анны Петровны Мишиной, а про классификацию простых полей - от Юрия Ивановича Манина. Пониманием важности истории математики я обязан Владимиру Игоревичу Арнольду, а об основаниях арифметики узнал из книги «Теоретическая арифметика» его отца, Игоря Владимировича Арнольда. От моего учителя, Александра Самуиловича Завадье, идет моя любовь к грекам. Когда мне было 14 лет, он посоветовал мне читать Платона, особенно порекомендовав «Пир» в переводе Соломона Константиновича Апта. Спустя 50 лет я по- прежнему влюблен в Платона и считаю «Пир» сочинением почти божественным. В Америке я работал с целым рядом замечательных программистов и многому от них научился, но моим настоящим учителем программирования был Александр Михайлович Гуревич, главный конструктор управляющей вычислительной машины ТА-100. У него я научился необходимости разрабатывать ортогональные и минимальные интерфейсы и десятки раз переписывать код, добиваясь красоты и оптимальности. Я с некоторой робостью ожидаю появления этой книги в России, стране моих учителей, но надеюсь, что она донесет до молодых программистов хотя бы толику того чувства прекрасного, которым одарили меня мои наставники.
Глава I ФФттФттттФФ®ттт%®тФФФФтФФФФФФФФттФФ®т®тт®®®®^тт О чем зта книга Невозможно познать мир, не познав математику. Роджер Бэкон. Большое сочинение Эта книга о программировании, но она отличается от большинства книг на ту же тему Наряду с алгоритмами и кодом вы найдете в ней математические доказательства и исторические сведения о математических открытиях с античных времен до наших дней. Если быть точным, то эта книга посвящена обобщенному программированию, подходу сформировавшемуся в 1980-х годах и ставшему популярным после создания библиотеки Standard Template Library (STL) для языка C++ в 1990-х годах. Можно дать такое определение. Определение 1.1. Обобщенным программированием называется подход к программированию, в котором упор делается на проектирование таких алгоритмов и структур данных, которые работали бы в наиболее общей ситуации без потери эффективности. У тех, кому доводилось использовать STL, возможно, мелькнула мысль: «Как?! Вот это и есть обобщенное программирование? А как же шаблоны, характеристики итераторов и все прочее?» А это всего лишь языковые средства, обеспечивающие поддержку обобщенного программирования. И хотя, безусловно, следует знать, как использовать их эффективно, обобщенное программирование как таковое - это отношение к программированию, а не конкретный набор средств. Мы считаем, что такое отношение - стремление писать код самым общим способом - должны воспринять все программисты. Компоненты хорошо написанной обобщенной программы проще повторно использовать и модифицировать, чем компоненты программы, в которой структуры данных, алгоритмы и интерфейсы отягощены ненужными предположениями о конкретном применении. Обобщенная программа оказывается одновременно проще и эффективнее. 1.1. Программирование и математика Так откуда же проистекает обобщенное отношение к программированию и как ему научиться? Проистекает оно из математики, а точнее, из ее раздела, называемого общей алгеброй. Чтобы помочь вам разобраться в этом подходе, мы дадим краткое введение в общую алгебру, сосредоточившись на том, как рассуждать об объектах
14 ♦> О чем эта книга в терминах абстрактных свойств операций над ними. Обычно этот предмет изучается на математических факультетах университетов, но мы полагаем, что он исключительно важен для понимания обобщенного программирования. Вообще, многие фундаментальные идеи программирования берут начало в математике. Знания о том, как эти идеи возникли и развивались, поможет при обдумывании проекта программы. Например, «Начала» Евклида, написанные больше 2000 лет назад, и по сей день остаются одним из лучших примеров построения сложной системы из мелких, простых для понимания элементов. Хотя существо обобщенного программирования - абстрагирование, абстракции - не возникают готовыми из ничего. Чтобы понять, как построить нечто общее, начать следует с чего-то конкретного. В частности, чтобы выявить подходящие абстракции, необходимо понять особенности конкретной предметной области. Абстракции, рассматриваемые в общей алгебре, берут начало в конкретных результатах одного из самых старых разделов математики - теории чисел. Поэтому мы познакомимся с некоторыми ключевыми идеями теории чисел, которая занимается свойствами целых чисел и, в особенности делимостью. Мыслительный процесс, вырабатывающийся при изучении математики, поможет вам усовершенствоваться в программировании. Но мы также покажем, что иногда и сами математические результаты становятся фундаментом современных программных приложений. В частности, в конце книги мы увидим, как некоторые результаты такого рода применяются в криптографических протоколах, лежащих в основе конфиденциальности в сети и электронной коммерции. В этой книге мы постоянно переходим от математики к программированию и обратно. Важные математические идеи переплетаются с обсуждением как конкретных алгоритмов, так и методов обобщенного программирования. Некоторые алгоритмы мы упоминаем лишь вскользь, тогда как другие уточняем и обобщаем на протяжении всей книги. Две главы посвящены одной лишь математике, а две другие - исключительно программированию, но большая часть глав содержит материал, относящийся к тому и другому. 1.2. Исторические справки Нам всегда казалось, что изучение становится проще и интереснее, если материал представлен в историческом контексте. Что происходило в описываемое время? Кем были участники событий, как они пришли к своим идеям? Была ли работа одного человека основана на результатах другого или это была попытка опровержения предшествующих результатов? Поэтому, знакомя читателя с математическими идеями, мы стараемся рассказывать об их истории и о людях, которые их выдвинули. Во многих случаях мы приводим краткие биографии математиков, сыгравших главную роль в описываемой истории. Это не исчерпывающие энциклопедические статьи, а всего лишь попытка погрузить конкретных людей в исторический контекст. Хотя мы привержены историческому взгляду на вещи, это не означает, что книга задумана как история математики или что описанные в ней идеи представлены в
План книги ♦ 15 хронологическом порядке. Когда необходимо, мы свободно перемещаемся по векам и странам, но в любом случае стараемся представить все идеи на историческом фоне. 1.3. Требования к читателю Поскольку значительная часть книги посвящена математике, у вас может сложиться впечатление, что для ее понимания нужна основательная математическая подготовка. Но хотя умение логически мыслить предполагается (впрочем, это в любом случае необходимо, чтобы стать хорошим программистом), никакие знания сверх школьной программы по алгебре и геометрии не требуются. В двух разделах показаны приложения, в которых используются элементы линейной алгебры (векторы и матрицы), но их можно без ущерба для понимания пропустить, если эти понятия вам незнакомы. В приложении А объясняются используемые обозначения. Важная часть математики - умение строить формальное доказательство. В этой книге доказательств немало. Читать ее будет проще, если вы уже встречались с доказательствами - в школьной геометрии, в лекциях по теории автоматов в курсе информатики или математической логики. Мы описали несколько стандартных приемов доказательства - с примерами - в приложении В. Мы предполагаем, что раз вы читаете эту книгу, то уже являетесь программистом. И в частности, достаточно хорошо знакомы с каким-нибудь типичным императивным языком программирования, например С, C++ или Java. Все наши примеры написаны на C++, но мы ожидаем, что вы сможете их понять, даже если никогда раньше не программировали на этом языке. Конструкции, уникальные для C++, объяснены в приложении С. Мы убеждены, что обсуждаемые в книге принципы применимы к программированию в целом, а не только к языку C++. Многие рассматриваемые в этой книге вопросы обсуждаются под другим углом зрения и более формально в книге «Elements of Programming» Степанова и Мак- джонса. Для читателей, желающих более глубоко разобраться в теме, она станет полезным дополнением. Поэтому в некоторых местах мы отсылаем интересующихся читателей к соответствующим разделам книги «Elements of Programming». 1.4. План книги Перед тем как переходить к деталям, полезно составить общее представление о том, чего мы собираемся достичь. О В главе 2 излагается история древнего алгоритма умножения и рассказывается о том, как его можно улучшить. О В главе 3 мы опишем некоторые ранние наблюдения, касающиеся свойств чисел, и рассмотрим эффективную реализацию алгоритма поиска простых чисел. О В главе 4 мы познакомимся с алгоритмом нахождения наибольшего общего делителя (НОД), который ляжет в основу некоторых наших последующих абстракций и приложений.
16 ♦> О чем эта книга О Глава 5 посвящена математическим результатам, в том числе двум теоремам, важнейшая роль которых станет ясна ближе к концу книги. О В главе 6 приводится введение в общую алгебру, являющуюся источником самой идеи обобщенного программирования. О В главе 7 показано, как эти математические идеи позволяют обобщить алгоритм арифметического умножения чисел на различные программные приложения. О В главе 8 вводятся новые абстрактные математические структуры и объясняется, к каким новым приложениям они ведут. О Глава 9 посвящена аксиоматическим системам, теориям и моделям - все это элементы обобщенного программирования. О В главе 10 излагаются концепции обобщенного программирования и рассматриваются тонкости некоторых, на первый взгляд, простых задач. О В главе 11 продолжается изучение ряда фундаментальных задач программирования и показывается, как можно воспользоваться теоретическими знаниями о проблеме для построения различных практических реализаций. О В главе 12 рассматривается вопрос о том, как аппаратные ограничения могут стать стимулом для выработки нового подхода к старому алгоритму, и демонстрируются новые применения НОД. О В главе 13 математические и алгоритмические результаты совместно используются для построения важного криптографического приложения. О В главе 14 подытоживаются основные идеи, рассмотренные в книге. На протяжении всей книги математика переплетается с программированием, хотя в одной-двух главах следы того или другого могут ненадолго теряться. Но каждая глава играет свою роль в следующей цепочке рассуждений, которая подводит итог книге: Чтобы стать хорошим программистом, необходимо понимать принципы обобщенного программирования. Чтобы понимать принципы обобщенного программирования, нужно понимать абстракции. Чтобы понимать абстракции, нужно понимать лежащие в их основе математические идеи. Это и есть история, которую мы собираемся рассказать.
Глава £i Первый алгоритм Моисей быстро изучил арифметику и геометрию. ...Это знание он почерпнул у египтян, которые почитали математику превыше всех наук. Филон Александрийский. «Жизнь Моисея» Алгоритмом называется конечная последовательность шагов по нахождению решения вычислительной задачи. Алгоритмы настолько тесно ассоциируются с программированием компьютеров, что большинство людей, знакомых с этим словом, вероятно, считает, что и сама идея алгоритма возникла в информатике. На самом же деле алгоритмы существуют уже тысячи лет. Математика изобилует алгоритмами, и некоторыми из них мы пользуемся ежедневно. Даже изучаемый в начальных классах способ сложения многозначных чисел - алгоритм. Несмотря на долгую историю, понятие алгоритма существовало не всегда; его необходимо было изобрести. Мы не знаем, когда был изобретен первый алгоритм, но знаем, что в Древнем Египте они существовали по меньшей мере 4000 лет назад. * * * Древнеегипетская цивилизация сформировалась вокруг реки Нил, а сельское хозяйство в ней зависело от разливов, удобряющих почву. Проблема состояла в том, что каждый разлив Нила смывал все вешки, отмечающие границы земельных участков. Египтяне, использовавшие для измерения расстояний веревки, придумали процедуры, позволяющие справляться с записями и восстанавливать границы участков. За это отвечала особая группа жрецов, изучавших соответствующие математические методы; они назывались «натягивателями веревок», или гарпе- донаптами. Позже греки назвали их геометрами, то есть «измерителями Земли». К сожалению, сведений о математических знаниях египтян сохранилось немного. Лишь два относящихся к математике документа дошли до наших дней. Интересующий нас называется «Математический папирус Ринда» - по имени шотландского коллекционера XIX века, который купил его в Египте. Этот документ, написанный примерно в 1650 году до н. э. писцом по имени Ахмес, является сборником задач по арифметике и геометрии, а также включает ряд таблиц для вычислений. В числе прочего этот свиток содержит первые письменно зафиксированные алгоритмы - способы быстрого умножения и деления. Начнем с рассмотрения алгоритма быстрого умножения, который, как мы увидим ниже, и в наше время остается важной техникой вычислений.
18 ♦ Первый алгоритм 2.1. Египетское умножение В египетской системе счисления, как и в системах всех прочих древних цивилизаций, не использовалась позиционная нотация и отсутствовал способ для представления нуля. Поэтому умножение было чрезвычайно сложной операцией, доступной лишь немногим специально обученным людям (представьте, как бы вы стали перемножать большие числа, не имея ничего, кроме римской системы записи). Но как мы определяем умножение? Если говорить неформально, то «сложить нечто с самим собой определенное число раз». Формально же можно выделить два случая: умножение на 1 и умножение на число, большее 1. Умножение на 1 определяется так: 1а = а. (2.1) Далее нужно рассмотреть случай, когда мы хотим вычислить произведение уже вычисленного результата и еще одного экземпляра числа. Некоторые читатели узнают в этом процессе индукцию; позже мы опишем эту технику более формально. (п + \)а = па + а. (2.2) Один из способов умножить п на а состоит в том, чтобы п раз сложить а с самим собой. Однако если числа велики, то это может оказаться очень трудоемким делом, потому что необходимо п - 1 сложений. На C++ этот алгоритм можно записать так: int multiplyO(int n, int a) { if (n == 1) return a; return multiplyO(n - 1, a) + a; } Строки кода соответствуют выражениям (2.1) и (2.2). Оба числа аип должны быть положительны, а других чисел древние египтяне и не знали. Описанный Ахмесом алгоритм - древние греки называли его «египетским умножением», а многие современные авторы «алгоритмом русского крестьянина»1 - опирается на следующее тождество: 4а = ((а + а) + а) + а = (а + а) + (а + а). В основе этой оптимизации лежит правило ассоциативности сложения: а + (Ь + с) = (а + Ъ) + с. Многие специалисты по информатике узнали это название из книги Кнута «Искусство программирования», где говорится, что путешествующие по России XIX века видели, как крестьяне пользуются этим алгоритмом. Однако первое упоминание об этой истории встречается в изданной в 1911 году книге сэра Томаса Хита, который пишет: «Мне сообщали, что этот метод используется и сегодня (некоторые говорят, что в России, но я не смог это проверить)...»
Египетское умножение ♦ 19 Это позволяет нам вычислить сумму а + а только один раз и, значит, уменьшить количество сложений. Идея заключается в том, чтобы, повторно уменьшая вдвое п и удваивая а, вычислять сумму количества экземпляров, кратного степеням двойки. В то время алгоритмы не описывались в терминах переменных типа а и п\ автор просто приводил пример и говорил: «А для других чисел поступай точно так лее». Ахмес не был исключением; он продемонстрировал алгоритм, приведя следующую таблицу для умножения п = 41 на а = 59: 1 • 59 2 118 4 236 8 • 472 16 944 32 • 1888 Числа в левом столбце - степени 2, каждое число в правом столбце (кроме первого) вдвое больше стоящего прямо над ним (сложение числа с самим собой - сравнительно простая операция). Числа, помеченные галочкой в среднем столбце, соответствуют единичным битам в двоичном представлении числа 41. Приведенная таблица, по существу, означает: 41 х 59 = (1 х 59) + (8 х 59) + (32 х 59), причем каждое число в правой части можно получить удвоением 59 нужное число раз. Алгоритм должен проверять, является п четным или нечетным, поэтому мы можем предположить, что египтяне знали об этом различии, хотя прямых доказательств у нас нет. Однако древние греки, утверждавшие, что узнали о математике от египтян, без сомнения знали о нем. Вот как они определяли1, является число четным или нечетным (в современной нотации)2: п п 72 = — + — => even(ft); 2 2 W 72-1 72-1 л 72 = + + 1 => О 2 2 Мы также воспользуемся следующим свойством: odd(72) => half(/z) = half(72 - 1). 1 Это определение встречается в датируемой I веком работе Никомаха из Герасы «Введение в арифметику», книга I, глава VII. Он пишет: «Чётным называется число, которое разделяется на два равных и не содержит единицы в середине; а нечётное число не может разделяться на два равных из-за присутствия единицы в середине». 2 Символ => читается «влечет за собой». Сводка математических обозначений, используемых в этой книге, приведена в приложении А. dd(w).
20 ♦ Первый алгоритм Вот как можно выразить египетский алгоритм умножения на C++: int multiplyl(int n, int a) { if (n == 1) return a; int result = multiplyl(half(n), a + a); if (odd(n)) result = result + a; return result/ } Для реализации odd (x) достаточно проверить младший бит х, а для реализации half (x) - сдвинуть х на один разряд вправо: bool odd(int n) { return n & Oxl; } int half (int n) { return n » 1/ } Сколько сложений должна будет выполнить функция multiplyl? При каждом ее вызове выполняется сложение, обозначенное знаком + в выражении а + а. Поскольку в процессе рекурсии мы уменьшаем значение п вдвое, то всего функция будет вызвана log n раз1. А в некоторых случаях придется еще выполнить сложение, обозначенное знаком + в выражении result + а. Таким образом, общее число сложений равно: #+(w) = \_\ogn\ + (v(n)- 1), где v(n) - количество единиц в двоичном представлении п (вес Хэмминга). Следовательно, мы свели алгоритм со сложностью 0(п) к алгоритму со сложностью 0(log п). Является ли этот алгоритм оптимальным? Не всегда. Например, при умножении на 15 предыдущая формула дает результат: #+(15) = 3 + 4-1 = 6. Но можно умножить на 15, выполнив всего 5 сложений: int multiply_by_15(int a) { int b = (а + а) + а; // b == 3*а int с = b + b; // с == 6*а return (с + с) + Ь; // 12*а + 3*а } Такая последовательность операций называется цепочкой сложений. В данном случае мы нашли оптимальную цепочку сложений для умножения на 15. Тем не менее алгоритм Ахмеса достаточно хорош для большинства применений. Упражнение 2.1. Найти оптимальные цепочки сложений для всех п < 100. Возможно, читатель заметил, что вычисления можно ускорить, если обратить порядок аргументов в случае, когда первый больше второго (например, вычислить Всюду в этой книге под «log» понимается логарифм по основанию 2, если явно не оговорено противное.
Улучшение алгоритма ♦ 21 3x15 проще, чем 15x3). Это действительно так, и египтяне об этом знали. Но мы не станем сейчас добавлять эту оптимизацию, потому что в главе 7 этот алгоритм будет обобщен на случай, когда типы аргументов необязательно совпадают и их порядок, следовательно, небезразличен. 2.2. Улучшение алгоритма С точки зрения количества сложений наша функция multiplyl работает хорошо, однако она выполняет l_log n\ рекурсивных вызовов. Поскольку вызов функции обходится дорого, мы хотим изменить программу, избавившись от накладных расходов. При этом мы будем придерживаться принципа «часто много работы сделать проще, чем мало». Точнее, мы будем вычислять выражение г+ па, где в г аккумулируются частичные произведения па. Иными словами, мы будем выполнять операцию умножить и аккумулировать, а не просто умножить. Этот принцип справедлив не только в программировании, но и в проектировании программного обеспечения и в математике, где часто бывает проще доказать общий результат, чем частный случай. Вот как выглядит наша функция умножения с аккумулированием: int mult_accO(int r, int n, int a) { if (n == 1) return r + a; if (odd(n)) { return mult_accO(r + a, half(n), a + a); } else { return mult_accO(r, half(n), a + a); } } У этой функции есть инвариант: г + па = г0 + п0а0, где г0, щ ий0- начальные значения переменных. Мы можем еще улучшить этот код, упростив рекурсию. Отметим, что два рекурсивных вызова различаются только первым аргументом. Вместо двух рекурсивных вызовов для случаев четного и нечетного чисел мы можем просто изменить значение г перед рекурсией: int mult_accl(int r, int n, int a) { if (n == 1) return r + a; if (odd(n)) r = r + a; return mult_accl(r, half(n), a + a); } Теперь наша функция обладает свойством хвостовой рекурсии - рекурсия происходит только при возврате значения. Скоро мы воспользуемся этим фактом.
22 ♦> Первый алгоритм Сделаем два наблюдения: О п редко бывает равно 1; О если п четно, то не имеет смысла проверять, равно ли оно 1. Поэтому можно уменьшить количество сравнений с 1 вдвое, если проверять на нечетность сначала: int mult_acc2(int r, int n, int a) { if (odd(n)) { r = r + a; if (n == 1) return r; } return mult_acc2(r, half(n), a + a); } Некоторые программисты думают, что оптимизирующий компилятор сам произведет такие преобразования, но это редко оказывается правдой: компилятор не способен преобразовать один алгоритм в другой. То, что мы сейчас имеем, уже неплохо, но в конечном итоге мы хотим вообще устранить рекурсию и избавиться от накладных расходов на вызов функции. Это проще сделать, если функция обладает свойством строгой хвостовой рекурсии. Определение 2.1. Говорят, что функция обладает свойством строгой хвостовой рекурсии, если во всех рекурсивных вызовах формальные параметры совпадают с соответствующими аргументами. Чтобы добиться этого, мы просто присвоим нужные значения переменным до того, как передать их рекурсивному вызову: int mult_acc3(int r, int n, int a) { if (odd(n)) { r = r + a; if (n == 1) return r; } n = half (n); a = a + a; return mult_acc3(r, n, a); } Теперь не составляет труда преобразовать этот код в итеративный, заменив хвостовую рекурсию циклом while (true): int mult_acc4(int r, int n, int a) { while (true) { if (odd(n)) { r = r + a; if (n == 1) return r; } n = half(n); a = a + a; } }
Улучшение алгоритма ♦ 23 Имея оптимизированную таким образом функцию умножения с аккумулированием, мы можем написать новую версию функции умножения, в которой будет вызываться вспомогательная функция умножения с аккумулированием: int multiply2(int n, int a) { if (n == 1) return a; return mult_acc4(a, n - 1, a); } Отметим, что мы сэкономили на одном обращении к mult_acc4, сразу установив результат в а вместо 0. Это хорошо во всех случаях, кроме ситуации, когда п является степенью 2. Первым делом мы вычитаем 1, а это значит, что mult_acc4 передается число, двоичное представление которого содержит только единицы, то есть имеет место худший для алгоритма случай. Чтобы избежать этого, мы проделаем часть работы заранее, если п четно: будем делить на два (одновременно вдвое увеличивая а), пока п не станет нечетным: int multiply3(int n, int a) { while (!odd(n)) { a = a + a; n = half(n) ; } if (n == 1) return a; return mult_acc4(a, n - 1, a); } Но теперь функция mult_acc4 делает одну лишнюю проверку на п = 1, потому что при ее вызове п заведомо четно. Поэтому перед вызовом мы еще раз разделим на два второй аргумент, умножим на два третий и получим такую окончательную версию: int multiply4(int n, int a) { while (!odd(n)) { a = a + a; n = half (n); } if (n == 1) return a; // even(n -1) => n - 1 * 1 return mult_acc4(a, half(n - 1), a + a); } Переписывание кода Как мы видели на примере преобразований алгоритма умножения, переписывание кода - важный шаг. Никто не пишет хороший код с первой попытки; чтобы найти самый общий или самый эффективный способ решения задачи, требуется много итераций. Склад ума программиста не должен быть однопроходным. Настает момент, когда в голову закрадывается мысль: «Подумаешь, еще одна операция, какая разница?». Но, возможно, ваш код будет многократно использоваться на протяжении многих лет (на самом деле временные поделки очень часто живут дольше
24 ♦ Первый алгоритм всего). И кроме того, дешевая операция, которую вы сэкономили, в будущей версии программы вполне может быть заменена очень дорогостоящей. И еще польза от борьбы за эффективность заключается в том, что по ходу дела вы начинаете глубже понимать задачу. А чем глубже вы ее понимаете, тем эффективнее реализация - это круг, но только не порочный, а благодетельный. 2.3. Заключительные мысли В курсе элементарной алгебры студенты учатся преобразовывать выражения с целью упрощения. В наших последовательных реализациях египетского алгоритма умножения мы занимались похожим делом - преобразовывали код, стремясь сделать его более понятным и эффективным. Любой программист должен взять за правило продолжать преобразование кода, пока не получится устраивающий его результат. Мы видели, откуда в Древнем Египте взялись математики, что привело к появлению первого известного нам алгоритма. Впоследствии мы еще вернемся к этому алгоритму и обобщим его. А пока переместимся на тысячу с лишним лет вперед и познакомимся с некоторыми математическими открытиями, совершенными в античной Греции.
Глава -J Теория чисел в Древней Греции Пифагорейцы посвятили себя изучению математики. Они считали, что ее nvuriuunaM должно подчиняться все на свете. Аристотель. «Метафизика» В этой главе мы рассмотрим некоторые задачи, которые изучали математики Древней Греции. Их работы о закономерностях и «формах» чисел привели к открытию простых чисел и положили начало разделу математики, называемому теорией чисел. Они также открыли парадоксы, которые в конечном итоге привели к важнейшим прорывам в математике. Попутно мы изучим античный алгоритм нахождения простых чисел и посмотрим, как его можно оптимизировать. 3.1. Геометрические свойства целых чисел Пифагор, древнегреческий математик и философ, известный большинству из нас благодаря теореме, носящей его имя, ко всему прочему был человеком, который высказал идею, что понимание математики необходимо для понимания мира. Он также открыл много интересных свойств чисел, считая, что это знание самоценно безотносительно к его практической применимости. По словам Аристоксена, ученика Аристотеля, «он "придавал важнейшее значение изучению арифметики, которую продвинул вперед, исключив из сферы торгового интереса». Пифагор (приблизительно 570-490 гг. до н. э.) Пифагор родился на греческом острове Самос, который в то время обладал сильным военно-морским флотом. Он происходил из знатной семьи, но выбрал путь постижения мудрости, а не богатство. В юности он совершил путешествие в Милет, где учился у Фалеса, основателя философии (см. раздел 9.2), который порекомендовал ему отправиться в Египет и изучать тамошние математические секреты. Пока Пифагор учился за границей, персидская империя покорила Египет. Пифагор последовал за персами в Вавилон (располагавшийся на территории нынешнего Ирака), где изучал вавилонскую математику и астрономию. Там он повстречал пришельцев из Индии; нам известно лишь, что он воспринял и начал пропагандировать идеи, которые приня-
26 ♦ Теория чисел в Древней i рении то ассоциировать с индийскими религиями, в том числе переселение душ, вегетарианство и аскетизм. До Пифагора эти идеи были совершенно неизвестны в Греции. После возвращения в Грецию Пифагор основал поселение в Кротоне, греческой колонии в Южной Италии, где вокруг него собрались последователи - мужчины и женщины, разделявшие его идеи и аскетический образ жизни. Средоточием их жизни было изучение четырех вещей: астрономии, геометрии, теории чисел и музыки. Этот квадривиум оставался в фокусе европейского образования в течение 2000 лет. Все четыре дисциплины были связаны между собой: движение звезд можно было геометрически изобразить на карте, геометрию - выразить в числах, а числа порождали музыку. Вообще, Пифагор первым открыл числовые закономерности частот в музыкальных октавах. Его последователи говорили, что он мог «слышать музыку небесных сфер». После смерти Пифагора пифагорейцы рассеялись по нескольким греческим колониям и посвятили себя разработке математических идей. Однако свое учение они хранили в секрете, поэтому многие их результаты, возможно, утрачены. Кроме того, они отказались от конкуренции между собой и приписывали все открытия самому Пифагору, так что мы не знаем, кто конкретно чего добился. Хотя общины пифагорейцев спустя двести лет исчезли, их труды не утратили значимости. Даже в XVII веке Лейбниц (один из изобретателей математического анализа) причислял себя к пифагорейцам. Увы, из-за покрова тайны, окутывавшего работы Пифагора и его учеников, их письменные труды не уцелели. Однако от современников мы знаем о некоторых его открытиях. Часть их описана в датируемой I веком книге Никомаха из Гера- сы «Введение в арифметику». К ним относятся наблюдения над геометрическими свойствами чисел; пифагорейцы связывали числа с геометрическими фигурами. Например, треугольными назывались числа, получающиеся при размещении первых п чисел в виде укорачивающихся строк, образующих треугольники: а аа а аа ааа а аа ааа аааа а аа ааа аааа ааааа а аа ааа аааа ааааа аааааа а 1 3 6 10 15 21 Прямоугольные числа выглядят так: ааа осеа ааа ааа %№ %М: %Д> %Л? аааа аааа аааа аааа ааааа ааааа ааааа ааааа ааааа ааааа аааааа аааааа аааааа аааааа ошаааа аааааа аааааа а а а а аа 2 6 12 20 30 42 Легко видеть, что п-ое прямоугольное число представлено прямоугольником размером пх(п + 1):
Геометрические свойства иелых чисел ♦> 27 пп = п(п+ 1). Из геометрических соображений с очевидностью следует, что всякое прямоугольное число в два раза больше соответствующего треугольного. Поскольку мы уже знаем, что п-ое треугольное число равно сумме первых п натуральных чисел, то получается: Пл = 2дя=2£х = я(я + 1). ы Таким образом, геометрическое представление дает нам формулу суммы первых п натуральных чисел: п(п + 1) г = /=1 А,=1>' = Еще одно геометрическое наблюдение - последовательность нечетных чисел образует фигуру, которую греки называли гномоном (греческое слово, обозначающее плотницкий угольник, а также часть солнечных часов, по тени от которой определяется время). аа а ааа а а аааа а а а ааааа а а а а сшо а 1 3 5 7 9 И Объединение первых п гномонов дает хорошо знакомую фигуру - квадрат: Е& €& as aaa acta aaa 1Д 1Д 1Д 1Д aaaa saaa aaaa aaaaa aaaaa aaaaa aaaaa asaaa aaaaa aaccsaci aaa a aaaaaa asaeaa 1 4 9 16 25 36 Из этого рисунка легко вывести формулу для суммы первых п нечетных чисел: q,= E(2i-l) = n2. /=1 Упражнение 3.1. Найдите геометрическое доказательство следующего утверждения: если взять любое треугольное число, умножить его на 8 и прибавить 1, то получится квадратное число (эта задача взята из «Платоновских вопросов» Плутарха).
28 ♦ Теория чисел в Древней Греции 3.2. Просеивание простых чисел Пифагорейцы также заметили, что некоторые числа невозможно представить в виде нетривиального прямоугольника (у которого обе стороны больше 1). Такие числа, которые нельзя разложить в произведение меньших чисел, мы теперь называем простыми. 2,3,5,7,11,13... («Числами» древние греки называли только целые числа, других они не знали.) Самыми ранними наблюдениями над простыми числами мы обязаны Евклиду. Хотя обычно это имя ассоциируется с геометрией, в нескольких книгах, составляющих «Начала» Евклида, рассматриваются вопросы, которые мы теперь относим к теории чисел. Одним из его результатов является следующая Теорема 3.1 (Евклид VII, 32). Любое число либо является простым, либо делится па некоторое простое число. В доказательстве используется «невозможность бесконечного спуска»1. Доказательство. Рассмотрим число А. Если оно простое, то доказательство завершено. Если лее оно составное (то есть не простое), то должно делиться на некоторое меньшее число В. Если В простое, то доказательство завершено (поскольку если А делится на В и В простое, то А делится на простое число). Если же В составное, то оно должно делиться на некоторое меньшее число С и т. д. В конечном итоге мы либо найдем простое число, либо, как замечает Евклид в доказательстве предыдущего предложения, получим «бесконечную последовательность чисел, являющихся делителями исходного числа, каждое из которых меньше предыдущего, а это невозможно». Этот открытый Евклидом принцип, согласно которому любая убывающая последовательность натуральных чисел обрывается, эквивалентен аксиоме индукции для натуральных чисел, с которой мы встретимся в главе 9. * * * Еще один результат Евклида - установление бесконечности множества простых чисел - некоторые считают самой красивой теоремой в математике. Теорема 3.2 (Евклид IX, 20). Для любой последовательности простых чисел {pv ..., р}} существует простое число р, не принадлежащее этой последовательности. Доказательство. Рассмотрим число Доказательство Евклида (книга VII, предложение 32) опирается на его же предложение 31 из книги VII (любое составное число делится на какое-то простое), которое и содержит приведенное здесь рассуждение.
Просеивание простых чисел ♦ 29 где р( - i-e простое число в последовательности. Из самого построения числа q вытекает, что оно не делится ни на одно из рг Следовательно, либо q простое, и тогда оно само является простым числом, не входящим в данную последовательность, либо q делится на некоторое другое простое число, которое, по построению, не является членом последовательности. Таким образом, существует бесконечно много простых чисел. Один из самых известных способов нахождения простых чисел - решето Эра- тосфена. Эратосфен - древнегреческий математик, живший в III веке, - прославился, в частности, поразительно точным измерением длины окружности Земли. Образно идея решета Эратосфена состоит в том, чтобы «просеивать» все числа, так чтобы непростые «проваливались», а простые оставались в решете. Собственно, процедура заключается в том, чтобы сначала выписать все числа-кандидаты, а затем вычеркивать те, что заведомо не являются простыми (поскольку они кратны найденным ранее простым числам). Все числа, которые останутся, простые. В наши дни демонстрацию решета Эратосфена часто начинают со всех положительных целых чисел, не превосходящих заданного, однако Эратосфен уже знал, что четные числа простыми не являются, поэтому не включал их. Следуя принятому Эратосфеном соглашению, мы тоже будем включать только нечетные числа, поэтому наше решето находит все простые числа, большие 2. Первоначально в решете находятся все нечетные числа, не превосходящие заданного, и все они - потенциальные кандидаты в простые. Так, если мы хотим найти простые числа, не большие т = 53, то в начальный момент решето будет выглядеть так: 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31 33 35 37 39 41 43 45 47 49 51 53 На каждой итерации мы берем первое оставшееся число (которое обязано быть простым) и вычеркиваем все кратные ему, кроме него самого, которые не были вычеркнуты раньше. Числа, вычеркнутые на текущей итерации, будем обводить рамочкой. Вот как выглядит решето после вычеркивания кратных 3: 0 5 7 [71 11 13 И 17 19 И 23 25 [XJ 29 31 [#] 35 37 [g] 41 43 [Х] 47 49 [И] 53 Далее вычеркиваем кратные 5, которые не были вычеркнуты раньше: 3 0 7 $ 11 13 X 17 19 X 23 И X 29 31 X \Ш 37 X 41 43 \0\ 47 49 X 53 Затем - еще оставшиеся кратные 7: 3 5 0 $ 11 13 X 17 19 X 23 XX 29 31 X X 37 X 41 43 X 47 0 X 53 И повторяем этот процесс, пока не будут вычеркнуты все кратные чисел, меньших или равных L Vm\, где т - наибольший рассматриваемый кандидат. В нашем
30 ♦ Теория чисел в Древней Греши примере т = 53, так что на этом процесс завершается. Все невычеркнутые числа - простые: 3 5 7 $ 11 13 у$ 17 19 X 23 у$ yf 29 31 & У$ 37 У$ 41 43 Д^ 47 ^ X 53 Прежде чем перейти к реализации этого алгоритма, сделаем несколько наблюдений. Вернемся к состоянию решета в середине процесса (скажем, на этапе вычеркивания кратных 5) и добавим дополнительную информацию: индекс, или позицию в списке каждого рассматриваемого кандидата: индекс 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ... значение 3 @ 7 $ 11 13 у$ 17 19 X 23 \&\ ?7 29 31 У$ \^\ 37 & ... Отметим, что при рассмотрении кратных множителя 5 величина шага - количество элементов между двумя соседними вычеркиваемыми числами, например 25 и 35, - равна 5, то есть самому множителю. По-другому эту мысль можно выразить, сказав, что разность между индексами любых двух соседних кандидатов, вычеркиваемых на данной итерации, равна текущему множителю. Кроме того, поскольку список кандидатов содержит только нечетные числа, то разность между любыми двумя значениями в два раза больше разности между их индексами. Таким образом, разность между двумя числами, вычеркиваемыми на данной итерации (например, между 25 и 35), в два раза больше величины шага, или, что то же самое, в два раза больше текущего множителя. Легко видеть, что это правило выполняется для всех множителей в нашем примере. Наконец, заметим, что первое число, вычеркиваемое на каждой итерации, - квадрат текущего простого числа. То есть когда вычеркиваются кратные 5, первым ранее не вычеркнутым числом будет 25. Объясняется это тем, что все прочие кратные уже рассматривались при обработке предыдущих простых чисел. 3.3. Реализация и оптимизация кода На первый взгляд кажется, что для реализации алгоритма понадобятся два массива: в одном будут храниться просеиваемые числа-кандидаты - «значения», а в другом - булевы флаги, показывающие, вычеркнуто соответствующее число или еще нет. Но, немного поразмыслив, мы приходим к выводу, что значения хранить вообще не нужно. Большая их часть (точнее, все непростые числа) никогда не используется. Если нам понадобится значение, то его легко вычислить, зная позицию; нам известно, что первое значение равно 3, а каждое последующее на 2 больше предыдущего, то есть i-e значение равно 2z + 3. Поэтому мы будем хранить в решете только булевы флаги, считая, что true соответствует простому числу, a false - составному. Процесс «вычеркивания» составных чисел будем называть пометкой решета. Следующая функция помечает все непростые числа, кратные заданному множителю:
Реализашя и оптимизация кода ♦ 31 template <RandomAccessIterator I, Integer N> void mark_sieve(I first, I last, N factor) { // assert (first != last) *first = false; while (last - first > factor) { first = first + factor; *first = false; } } Мы пользуемся соглашением, по которому в «объявлениях» аргументов шаблона задаются требования к ним. Эти требования, называемые концепциями, мы подробно обсудим в главе 10, а пока интересующийся читатель может заглянуть в приложение С (если вы незнакомы с шаблонами в C++, то в этом же приложении найдете необходимые пояснения). Как мы вскоре убедимся, при вызове этой функции first указывает на булево значение, соответствующее первому «невычеркнутому» кратному factor, которое, как мы видели, всегда равно квадрату factor. Что же касается last, то мы следуем соглашению STL о передаче итератора, указывающего на позицию за последним элементом списка, поэтому last - first равно количеству элементов. * * * Прежде чем показать процедуру просеивания, сформулируем несколько лемм. О Квадрат наименьшего простого множителя составного числа с меньше или равен с. О Любое составное число, меньшее или равноер2, будет отсеяно при обработке простого числа, меньшегор (то есть вычеркнуто вследствие кратности ему). О Отметка решета для числа р начинается с числа р2. О Если мы хотим просеивать числа, не большие га, то можем остановиться, когда окажется, чтор2 > т. В ходе вычислений мы будем пользоваться следующими формулами: значение с индексом i: value(z) = 3 + 2z = 2z + 3; индекс значения v : index(a) = ; шаг между кратным k и кратным k + 1 значениями с индексом г\ step(z) = index((£ + 2)(2i + 3)) - index(£(2z + 3)) = index(2£z + 3n + 4z + 6) - index(2£z + 3n) _(2fez' + 3& + 4z' + 6)-3 (2ki + 3k)-3 "2 2 индекс квадрата значения с индексом z:
32 ♦ Теория чисел в Древней Греиии index(value(z)2) = _4i2+12i + 9-3 2 = 2z2+6z' + 3. Теперь мы можем представить первую попытку реализации решета: template <RandomAccessIterator I, Integer N> void siftO(I first, N n) { std::fill (first, first + n, true) ; N i(0); N index_square (3); while (index_square < n) { // инвариант: index_square = 2iA2 + 6i + 3 if (first [i]) { // если кандидат - простое число mark_sieve (first + index_square, first + n, II last i + i + 3); II множитель } ++i; index_square = 2*i*(i + 3) + 3; } } Может показаться, что мы должны передать ссылку на структуру данных, содержащую последовательность булевых значений, поскольку решето работает, только если просеивается вся последовательность. Но, передавая вместо этого итератор на начало диапазона и длину мы не налагаем никаких ограничений на конкретный характер структуры данных. Данные могли бы находиться в STL- контейнере или в непрерывном блоке памяти - нам это не важно. Обратите внимание, что мы используем размер таблицы п, а не максимальное значение просеиваемой последовательности т. Переменная index_square содержит индекс первого значения, которое мы хотим пометить, то есть квадрат текущего множителя. Следует отметить, что новый множитель (i + i + 3) и прочие величины (выделенные курсивом) вычисляются на каждой итерации цикла. Мы можем вынести общие подвыражения из цикла; изменения показаны полужирным шрифтом. template <RandomAccessIterator I, Integer N> void siftld first, N n) { I last = first + n; std: :fill (first, last, true) ; N i(0); N index_square(3); N factor(3); while (index_square < n) {
Реализаиия и оптимизация кола ♦ 33 // инвариант: index_square = 2iA2 + 6i + 3, // factor = 2i + 3 if (first[i]) { mark_sieve (first + index_square, last, factor); } ++i; factor = i + i + 3; index square = 2*i*(i + 3) + 3; Внимательный читатель обратит внимание, что вычисление factor стало несколько хуже, чем раньше, потому что теперь оно производится на каждой итерации цикла, а не только на тех, где проверка в предложении if завершается успешно. Однако ниже мы увидим, почему заведение отдельной переменной factor имеет смысл. Самая большая проблема - тот факт, что у нас по-прежнему осталась сравнительно дорогая операция - вычисление index_square, требующая двух умножений. Поэтому мы воспользуемся техникой, которая применяется при оптимизации компиляторов и называется снижение стоимости операций; идея в том, чтобы заменить дорогостоящие операции типа умножения эквивалентным кодом, в котором используются более дешевые операции, например сложение1. Если компилятор умеет делать это автоматически, то уж мы тем более сможем повторить то же самое вручную. Рассмотрим вычисления более пристально. Допустим, что мы заменили предложения: factor = i + i + 3; index_square = 3 + 2*i*(i + 3); такими factor += 5factor; index_square += 5index_sguare; где Ь/асГ0Ги bindex square - разности между соседними (z-м и z+1-м) значениями factor и index_square соответственно: o/act0,.:(2(i+l) + 3)-(2z + 3) = 2; 8м» ,,_: (2(х + I)2 + 6(i + 1) + 3) - (2z2 + 6t + 3) = 2гг + 4г + 2 + 6г + 6 + 3 - 2г2 - Ы - 3 = U + 8 = (2i + 3) + (2i + 2 + 3) = (2i + 3) + (2(г + 1) + 3) = factor(i) + factor(z +1). i В современных процессорах умножение необязательно выполняется медленнее сложения, но, вообще говоря, применение этой техники все равно может уменьшить количество операций.
34 ♦> Теория чисел в Древней Греиии С bjactor все просто: переменные взаимно уничтожаются, и остается константа 2. Но как упростить выражение для ЪМех square? Заметим, что, изменив порядок членов, мы можем выразить его через уже известную величину, f actor (i), и величину, которую все равно предстоит вычислять, factor (i + 1). (Если известно, что придется вычислять несколько величин, полезно посмотреть, нельзя ли выразить одну через другие. Иногда это позволяет сократить объем работы.) После этих подстановок мы получаем окончательную версию функции sift; улучшения снова выделены полужирным шрифтом. template <RandomAccessIterator I, Integer N> void sift (I first, N n) { I last = first + n; std: :fill (first, last, true) ; N i(0); N index_square (3); N factor(3); while (index_square < n) { // инвариант: index_square = 2iA2 + 6i + 3, // factor = 2i + 3 if (first [i]) { mark_sieve (first + index_square, last, factor); } ++i; index_square += factor; factor += N(2); index_square += factor; Упражнение 3.2. Измерьте время работы решета при разных размерах данных: один бит (когда используется std: :vector<bool>), uint8_t, uintl6_t, uint32_t, uint64_t. Упражнение 3.3. Пользуясь решетом, постройте график функции п(п) = количество простых чисел, меньших п для п, не превосходящих 107, и найдите для нее аналитическую аппроксимацию. Простые числа, которые одинаково читаются слева направо и справа налево, называются палиндромическими. Ниже показаны все такие числа, не большие 1000: Ш13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 ЩИ] 103 107 109 113 127 131 137 139 149 \Ш 157 163 167 173 179 I181J 19J 193 197 199 211 223 227 229 233 239 241 251 257 263 269 271 277 281 283 293 307 311 \Ш 317 331 337 347 3491353]359 3671373]379 [383J389 397 401 409 419 421 431 433 439 443 449 457 461 463 467 479 487 491 499 503 509 521 523 541 547 557 563 569 571 577 587 593 599 601 607 613 617 619 631 641 643 647 653 659 661 673 677 683 691 701 709 719 Ш733 739 743 751E57R61 769 773 Ш797 809 811 821 823 827 829 839
Совершенные числа ♦ 35 853 857 859 863 877 881 883 887 907 911 l9l9]J929]937 941 947 953 967 971 977 983 991 997 Интересно, что в диапазоне от 1000 до 2000 палиндромических простых чисел нет: 1009 1013 1019 1021 1031 1033 1039 1049 1051 1061 1063 1069 1087 1091 1093 1097 1103 1109 1117 1123 1129 1151 1153 1163 1171 1181 1187 1193 1201 1213 1217 1223 1229 1231 1237 1249 1259 1277 1279 1283 1289 1291 1297 1301 1303 1307 1319 1321 1327 1361 1367 1373 1381 1399 1409 1423 1427 1429 1433 1439 1447 1451 1453 1459 1471 1481 1483 1487 1489 1493 1499 1511 1523 1531 1543 1549 1553 1559 1567 1571 1579 1583 1597 1601 1607 1609 1613 1619 1621 1627 1637 1657 1663 1667 1669 1693 1697 1699 1709 1721 1723 1733 1741 1747 1753 1759 1777 1783 1787 1789 1801 1811 1823 1831 1847 1861 1867 1871 1873 1877 1879 1889 19011907 1913 1931 1933 1949 1951 1973 1979 1987 1993 1997 1999 Упражнение 3.4. Существуют ли палиндромические простые числа, большие 1000? Почему их нет в интервале [1000, 2000]? Что произойдет, если за основание системы счисления взять 16? А если произвольное число п? 3.4. Совершенные числа В разделе 3.1 мы видели, что древних греков интересовали самые разные свойства чисел. В частности, они ввели понятие совершенного числа - числа, равного сумме своих собственных делителей1. Им было известно четыре совершенных числа: 6 =1 + 2 + 3; 28 =1 + 2 + 4 + 7 + 14; 496 =1 + 2 + 4 + 8 + 16 + 31 + 62 + 124 + 248; 8128 =1 + 2 + 4 + 8 + 16 + 32 + 64 + 127 + 254 + 508 + 1016 + 2032 + 4064. Считалось, что совершенные числа тесно связаны с природой и устройством Вселенной. Например, 28 - это количество дней в лунном цикле. Греки очень хотели знать, можно ли как-то найти другие совершенные числа. Они изучили разложения известных им совершенных чисел на простые множители: 6 =2-3 = 2'- 3; 28 = 4 • 7 = 22 • 7; 496 = 16 -31 = 24- 31; 8128 = 64 • 127 = 26 • 127 и обнаружили следующую закономерность: 1 Собственным делителем числа п называется любой его делитель, отличный от него самого.
36 ♦ Теория чисел в Древней Греции 6 = 2 • 3 = 21 • (22 - 28 = 4 • 7 = 22 • (23 - 120 = 8 • 15 = 23 • (24 - 496 = 16 • 31 = 24 • (25 - 2016 = 32-63 = 25-(2б- 8128 = 64-127 = 26-(27- ); ) несовершенное; ); ) несовершенное; )■ Вычисленное по этой формуле число является совершенным, если второй сомножитель - простое число. Этот факт доказал Евклид примерно в 300 году до н. э. Теорема 3.3 (Евклид IX, 36). п п Если^21 - простое число, то 2"]Г 2' - совершенное число. ;=о /=о Полезные формулы Прежде чем приступать к доказательству, полезно вспомнить две алгебраические формулы. Первая называется разность степеней: х2 -у2-(х- у)(х + у) х3 - у3 = (х - у)(х2 +ху + у2) дИ+1 _ yn+l = (Х _ у)(ди + ди-ly + _ + хуп-l + упу (3.1) Этот результат легко вывести из следующих двух тождеств: х(х" + х!х~ху + ... + хуп~х + у") = х?1+х + хпу + хГ~ху2 + ... + ху11; (3.2) #(*" + х"~ху + ... + ху"'х + уп) = хРу + д^-у + ... + ху" + г/"+1. (3.3) Левые и правые части (3.2) и (3.3) равны в силу дистрибутивности. Если теперь вычесть (3.3) из (3.2), то получится (3.1). Вторая полезная формула - сумма нечетных степеней: х2>,+ \ + у2п+\ = (х + у^^Я _ х2«-1^ + _ ^2я-1 + у2и)в (3.4) Ее можно вывести, преобразовав сумму в разность и воспользовавшись предыдущим результатом: д21,+1 + у2и+1 = ^ii+i —г/2"+1 = Х2"+1-(-2/)2"+1 = (* - (-V))(^ + ^2""Ч-?/) + ... + (-У)2") = (х + г/)(х2" - дт2""1*/ + ... - ху2"'1 + у2"). Это доказательство проходит, потому что -1 в нечетной степени равно -1. В дальнейшем мы не раз будем пользоваться этими формулами. Далее, из тождества 2я - 1 = (2 - 1)(2'м + 2П~2 + ... + 2 + 1) следует, что для п > 0 ]Г2''=2'?-1 (3.5) /=о
Совершенные числа ♦ 37 (или просто подумайте, какое двоичное число получается при сложении степеней двойки). Упражнение 3.5. Применяя тождество (3.1), докажите, что если 2п- 1 - простое число, то п - простое число. Мы докажем теорему Евклида так, как это сделал великий немецкий математик Карл Фридрих Гаусс (к Гауссу мы еще вернемся в главе 8). Сначала, воспользовавшись тождеством (3.5), заменим обе суммы И^21 в теореме Евклида на 2п - 1; тогда теорема примет такой вид: Если 2п - 1 - простое число, то 2'7-1(272 - 1) - совершенное число. Далее, обозначим а(п) сумму делителей п. Если п следующим образом разлагается в произведение простых чисел то множество делителей п состоит из всевозможных комбинаций простых делителей, возведенных в степени, не превосходящие а{. Например, 24 = 23 • З1, поэтому его делители таковы: {2° • 3°, 21 • 3°, 22 • 3°, 2° • З1, 21 • З1, 22 • З1, 23 • З1}. Их сумма равна 2° • 3° + 21 • 3° + 22 • 3° + 2° • З1 + 2х • З1 + 22 • З1 + 23 • З1 = (2°+ 21 + 22 + 23)(3° + З1). То есть мы можем записать сумму делителей любого числа п в виде произведения сумм: т °(п) = Ц(1 + р}+р1+-- + Р?) т п _А /=1 Pi ~l ^ACA-W + A + Pf+'-' + tf) /=i P, ~ 1 =П^^. (3-6) /и А"1 где в последней строке для упрощения числителя используется формула разности степеней. (В этом и во всех последующих доказательствах предполагается, что буквой р обозначено простое число, если явно не оговорено противное.) Упражнение 3.6. Докажите, что если пит- взаимно простые числа (не имеющие общих простых делителей), то о(пт) = <з(п)о(т) (говорят также, что а - мультипликативная функция). Определим теперь аликвотную сумму: а(п) = а(п) - п.
38 ♦> Теория чисел в Древней Греиии Иными словами, аликвотная сумма равна сумме собственных делителей п - всех делителей, кроме самого п. Теперь мы готовы доказать теорему 3.3, сформулированную в предложении 36 из книги IX «Начал» Евклида: Если 2п - 1 - простое число, то 2?7_1(2'7 - 1) - совершенное число, Доказательство, Пусть q = 2*~1(2" - 1). Мы знаем, что 2 - простое число, и по условию теоремы 2п - 1 - простое, поэтому 2"~1(2/7 - 1) уже является разложением в произведение простых чисел вида п = pf pf ... р™, где т = 2, р] = 2, ах = п - 1, р2 = 2" - 1, а2 = 1. По формуле суммы делителей (3.6) получаем: 2("-1)+1-1 (2" -I)2 -1 o(q) = - = (Г-1) = (2я-1) 1 (2я-1)-1 (27?-1)2-1 (2"-1)2+1 (2л-1)-1 (2я-1) + 1 ((2,7-1)(2/7-1)-1)((2"-1) + 1) ((2/7-1)(2'?-1)-1) = (2/? -1)((2/? -1) + 1). Отсюда a(q) = o(q) - q = 2q - q = q. Таким образом, q - совершенное число. Теорема Евклида утверждает, что если число имеет определенный вид, то оно совершенное. Возникает интересный вопрос: верно ли обратное, то есть правда ли, что если число совершенное, то оно имеет вид 2*-1(2" - 1)? В XVIII веке Эйлер доказал, что всякое четное совершенное число действительно имеет такой вид. Но он не смог доказать более общий результат - что любое совершенное число имеет такой вид. Эта проблема и по сей день остается нерешенной; мы не знаем, существуют ли нечетные совершенные числа. Упражнение 3.7. Докажите, что любое четное совершенное число является треугольным. Упражнение 3.8. Докажите, что сумма чисел, обратных делителям совершенного числа, равна 2, например: < 1 1 1 о 2 3 6 3.5. Пифагорейская программа Для пифагорейцев математика не была абстрактным манипулированием символами, как ее часто представляют сегодня. Они рассматривали ее как науку о чис-
Пифагорейская программа ♦ 39 лах и пространстве - двух фундаментальных чувственно воспринимаемых аспектах реальности. Помимо стремления понять геометрические числа (квадратные, прямоугольные, треугольные и т. п.), они полагали, что структура пространства дискретна. Поэтому своей задачей они считали поиск числовых оснований геометрии - по существу, построение объединенной математической теории, основанной на целых числах. Реализуя эту программу, они выдвинули идею «измерения» одного отрезка прямой другим. Определение 3.1. Отрезок У называется мерой отрезка Л тогда и только тогда, когда А можно представить в виде отрезка У, отложенного конечное число раз. Мера должна быть настолько малой, чтобы заданный отрезок можно было составить из целого числа копий меры; «дробные» меры не допускаются. Разумеется, для разных отрезков можно использовать разные меры. Чтобы можно было использовать одну и ту же меру для двух отрезков, она должна быть их общей мерой. Определение 3.2. Отрезок У называется общей мерой отрезков А и В тогда и только тогда, когда он является мерой обоих. Пифагорейцы полагали, что для всех представляющих интерес объектов существует общая мера, а потому структура пространства дискретна. * * * Поскольку общих мер может быть много, они ввели также понятие наибольшей общей меры. Определение 3.3. Отрезок У называется наибольшей общей мерой отрезков А и В тогда и только тогда, когда он больше любой другой общей меры А и В. Пифагорейцам были известны некоторые свойства наибольшей общей меры (GCM), которые в современных обозначениях формулируются так: gcm(tf, a) = a (3.7) gcm(<2, b) = gcm(tf, a + b) (3.8) b<a=> gcm(<2, b) = gcm(<2- b, b) (3.9) gcm(tf, b) = gcm(6, a) (3.10) Пользуясь этими свойствами, они изобрели самую важную процедуру в греческой математике - а быть может, и вообще во всей математике: способ нахождения наибольшей общей меры двух отрезков. В своих вычислениях древние греки выполняли операции над отрезками с помощью линейки и циркуля. На языке C++, применяя тип line_segment, эту процедуру можно записать следующим образом: line_segment gcm(line_segment a, line_segment b) { if (a == b) return a; if (b < a) return gem(a - b, b); /* if (a < b) */ return gcm(a, b - a); }
40 ♦ Теория чисел в Древней Греиии Здесь используется закон трихотомии: если два значения аи b принадлежат одному и тому же вполне упорядоченному типу, то либо а = Ь, либо а < Ь, либо а >Ъ. Рассмотрим пример. Чему равно gem (196, 42) ? a b 196 > 42, gcm(196,42) = gcm(196-42, 42) = gcm(154,42) 154 > 42, gcm(154,42) = cm(154 - 42, 42) = gcm(112,42) 112 > 42, gcm(112,42) = gcm(112 -42, 42) = gcm(70, 42) 70>42, gcm(70,42) = gcm(70 - 42, 42) = gcm(28, 42) 28<42, gcm(28,42) = gcm(28, 42 - 28) = gcm(28, 14) 28>14, gcm(28, 14) = gcm(28 - 14, 14) = gcm(14, 14) 14=14, gcm(14, 14) = 14 Таким образом, gcm(196, 42) = 14. Разумеется, когда мы пишем gcm(196, 42), имеется в виду наибольшая общая мера отрезков длиной 196 и 42, но в примерах из этой главы для краткости будем использовать просто целые числа. В следующих главах нам встретятся различные варианты этого алгоритма, поэтому важно хорошо понимать, как он работает. Быть может, имеет смысл произвести вручную вычисления для нескольких примеров, чтобы обрести уверенность. З.Б. Фатальный изъян в программе Греческие математики обнаружили, что принцип полного упорядочения - тот факт, что любое множество натуральных чисел имеет наименьший элемент, - можно положить в основу эффективной техники доказательства. Чтобы доказать, что нечто не существует, нужно доказать, что если бы оно существовало, то существовало бы и нечто меньшее. Пользуясь этой логикой, пифагорейцы обнаружили доказательство, которое подрывало всю их программу1. Мы приведем реконструкцию этого доказательства, предложенную в XIX веке Джорджем Кристалом. Теорема 3.4. Не существует отрезка, который мог бы служить мерой для стороны квадрата и его диагонали. Доказательство. Предположим противное - что существует отрезок, которым можно измерить сторону и диагональ некоторого квадрата2. Возьмем наименьший квадрат для такого отрезка: 1 Мы не знаем, сам ли Пифагор совершил это открытие или кто-то из его ранних учеников. 2 Это пример доказательства от противного. Дополнительные сведения об этой технике доказательства см. в приложении В.1.
Фатальный изъян в программе ♦ 41 В А С D С помощью циркуля и линейки1 мы можем построить отрезок AF такой же длины, как АВ, а затем из точки провести отрезок, перпендикулярный АС. В А Е С D АВ =AF ЛАС ±EF. Теперь построим еще два перпендикулярных отрезка, CG и EG: С D AC ± CG Л EG ± EF. Мы знаем, что zCFE = 90° (по построению) и что zECF = 45° (потому что он равен ZBCA между стороной и диагональю квадрата, то есть половине прямого угла). Мы также знаем, что сумма углов треугольника равна 180°. Поэтому Современный читатель может подумать, что линейка используется для измерения расстояний. Однако для Евклида она служила лишь для проведения прямых линий. Точно так же и современный циркуль можно зафиксировать для откладывания равных отрезков, но во времена Евклида циркуль использовался лишь для рисования окружностей - он складывался после поднятия, поэтому не сохранял расстояние.
42 ♦ Теория чисел в Древней Греиии zCEF = 180° - zCFE- zECF= 180° - 90° - 45° = 45°. Таким образом, zCEF = zECF} поэтому CEF - равнобедренный треугольник, а значит, стороны, противолежащие равным углам, равны, то есть CF = ЕЕ. Наконец, проведем еще один отрезок BF: Треугольник ABF также равнобедренный, в нем zABF = zAFB, так как по построению АВ = AF. И zABC = zAFE = 90°, потому что отрезок FE строился как перпендикуляр к АС. Таким образом: ZABC - ZABF = ZAFE - zAFB; ZEBF_ = ZEFB; =>BE = EF. Теперь мы знаем, что АС измерим, потому что предположили это с самого начала, и что AF измерим, потому что он равен АВ, который измерим также по предположению. Следовательно, их разность CF = АС - AF тоже измерима. Поскольку мы только что показали, что треугольники CEFn BEF равнобедренные CF=EF = BE, и, по предположению, ВС измеримого, стало быть, CF, а значит, и BE измеримы. Следовательно, измерим и отрезок ЕС = ВС - BE. Теперь мы имеем меньший квадрат, сторона (EF) и диагональ (ЕС) которого измеримы нашей общей мерой. Но первоначальный квадрат был, по предположению, наименьшим, для которого это утверждение верно, - мы получили противоречие. Следовательно, наше первоначальное предположение было неверным, и не существует отрезка, которым можно было бы измерить сторону и диагональ квадрата. Поиск такого отрезка обречен продолжаться вечно, то есть наша процедура line_segment_gcm(a, b) никогда не завершится. Иными словами, отношение диагонали квадрата к его стороне невозможно выразить рациональным числом (отношением двух целых чисел). Сегодня мы сказали бы, что пифагорейцы открыли иррациональные числа, а точнее доказали, что число л/2 иррационально. Открытие иррациональных чисел стало глубочайшим потрясением. Оно подрывало всю программу пифагорейцев и означало, что числа не могут быть поло-
Фатальный изъян в программе ♦ 43 жены в основу геометрии. Поэтому пифагорейцы сделали то же самое, что и многие другие организации, сталкивающиеся с плохими известиями: все засекретили. Легенда гласит, что когда один из членов ордена рассказал кому-то эту историю, боги покарали его, потопив корабль, на котором он плыл вместе со всеми, кто был на борту * * * В конечном итоге последователи пифагорейцев придумали другую стратегию. Если невозможно построить математику на фундаменте чисел, то следует положить в ее основу геометрию. К этой идее восходят построения с помощью циркуля и линейки, которые до сих пор используются в школе при изучении геометрии; числа в них не используются и не требуются. Позже математики нашли другие, теоретико-числовые, доказательства иррациональности л/2. Одно из них включено в виде предложения 117 в некоторые издания книги X «Начал» Евклида. И хотя это доказательство было найдено еще до Евклида, в «Начала» оно было добавлено спустя некоторое время после выхода первого издания. Как бы то ни было, это доказательство важно. Теорема 3.5. Число V2иррационально. Доказательство. Предположим, что число л/2 рационально. Тогда его можно представить в виде отношения двух целых чисел тип, где дробь т/п несократима: ^ = 72; п (-1 =2; \п) т2 = 2п\ т2 четно, а значит, т тоже четно1, поэтому можно записать его в виде произве- ' дения 2 и некоторого числа и, подставить это выражение в предыдущее тождество и проделать алгебраические преобразования: т = 2и\ (2и)2 = 2т22; Аи2 = 2п2] 2йг = п2. п2 четно, а значит, п тоже четно. Но если тип четны, то дробь 772/77 сократима. Таким образом, наше предположение оказалось ложным: не существует представления числа л/2 в виде отношения двух целых чисел. 1 Это легко показать: произведение двух нечетных чисел нечетно, поэтому если бы т не было четным, то и т2 не могло бы быть четным. Евклид доказал этот и многие другие результаты, касающиеся четных и нечетных чисел, в предшествующих предложениях «Начал».
44 ♦ Теория чисел в Древней Греции 37. Заключительные мысли Восхищение, которое древние греки испытывали к «формам» чисел и прочим их свойствам, в частности простоте и совершенству, легло в основу раздела математики, получившего название теория чисел. Некоторые придуманные ими алгоритмы, например решето Эратосфена, по-прежнему кажутся весьма элегантными, хотя, как мы видели, их эффективность молшо повысить, применяя современные методы оптимизации. * * * В конце главы мы привели два доказательства иррациональности числа л/2, геометрическое и алгебраическое. Существование двух совершенно различных доказательств одного и того же результата - хороший признак. Вообще, математики стремятся искать разные доказательства одного и того же факта, поскольку это повышает уверенность в его правильности. Например, Гаусс на протяжении своей деятельности предложил несколько доказательств важной теоремы - закона взаимности квадратичных вычетов. Открытие иррациональных чисел стало плодом попыток пифагорейцев представить непрерывную реальность с помощью дискретных чисел. И хотя на первый взгляд их вера в достижимость этой цели может показаться наивной, компьютерщики и сегодня делают то же самое - мы аппроксимируем реальный мир двоичными числами. Вообще, противоположность непрерывного и дискретного остается одним из центральных вопросов математики в наши дни и, наверное, будет таковым всегда. Но это не столько проблема, сколько источник прогресса и революционных озарений.
Глава *т mmm®mmm®mmmm®mmmmm®®m9**mmG9®mmmm®mmmmmm®mmm<$mm Алгоритм Евклида Вся конструкция теории чисел покоится на общем фундаменте, алгоритме нахождения наибольшего общего делителя. Дирихле. «Лекции по теории чисел» В предыдущей главе мы познакомились с Пифагором и созданным им тайным обществом для изучения астрономии, геометрии, теории чисел и музыки. Хотя неудавшаяся попытка пифагорейцев найти общую меру стороны и диагонали квадрата положила конец мечтам о сведении мира к числам, идея наибольшей общей меры (GCM) оказалась важна для математики - и, в конечном итоге, для программирования. В этой главе мы рассмотрим античный алгоритм нахождения GCM. который будем изучать на всем протяжении книги. 4.1. Афины и Александрия Чтобы подготовить почву для открытия этого алгоритма, перенесемся в одно из самых удивительных мест и эпох в истории: в Афины V века до н. э. В течение 150 лет, последовавших за чудесным поражением персов в битвах при Марафоне, Саламине и Платеях, Афины превратились в центр культуры, образования и науки, заложивший основы многих сторон западной цивилизации. Именно в середине этого периода культурного господства Афин Платон основал свою знаменитую Академию. И хотя сегодня мы воспринимаем Платона как философа, вся программа Академии концентрировалась вокруг изучения математики. В числе открытий Платона были и пять так называемых Платоновых тел - единственные трехмерные выпуклые многогранники, все грани которых являются одинаковыми правильными многоугольниками. Платон (429-347 до н. э.) Платон принадлежал к древнему и знатному афинскому роду. Еще юношей он стал учеником Сократа, одного из основателей философии, который учился сам и учил других, задавая вопросы, особенно связанные с исследованием собственной жизни и гипотез. Уродливый, с глазами навыкате, неряшливо одетый Сократ в жизни был скромным каменотесом, но идеи его были поистине революционными. В то время самопровозглашенные мудрецы («софисты») учили своих последователей занимать любую сторону в споре и манипулировать избирателями. Сократ бросил вызов софистам, поставив под сомнение их так называемую мудрость
46 ♦> Алгоритм Евклида и выставив их на посмешище. Если софисты требовали за свои поучения немалую плату, то ученики Сократа занимались бесплатно. И в наши дни придуманный Сократом метод задавания вопросов с целью добраться до истины называется сократическим. Хотя были люди, восхищавшиеся Сократом, а некоторые его ученики достигли высоких постов, по общему мнению, он был неисправимым скандалистом, и в знаменитой комедии «Облака» Аристофан подверг его публичному осмеянию. В конце концов, в 399 году до н. э. Сократ предстал перед судом за развращение юношества и был приговорен к смерти путем принятия яда. На Платона Сократ оказал огромное влияние, и большая часть его собственных трудов написана в форме диалогов с Сократом и другими оппонентами. Платон был подавлен казнью Сократа, тем, что общество уничтожило своего мудрейшего и справедливейшего члена. В отчаянии он покинул Афины, некоторое время учился у египетских жрецов, а позже изучал математику у пифагорейцев в Южной Италии. Спустя примерно десять лет Платон вернулся в Афины и основал первый в мире университет в местечке Академия, названном в честь древнего героя Академуса. В отличие от тайных знаний пифагорейцев, программа Академии была открытой и доступной всем: мужчинам и женщинам, грекам и варварам, свободным и рабам. Многие диалоги Платона, в том числе «Апология», «Федон» и «Пир», написаны очень поэтично. И хотя сегодня наиболее известны труды Платона, относящиеся к различным этическим и метафизическим проблемам, математика играла центральную роль в учебном плане Академии. И действительно, при входе в Академию Платон распорядился выбить надпись «Да не войдет сюда не знающий геометрии». Он пригласил преподавать многих выдающихся математиков того времени и разработал общий курс обучения. Платон не оставил нам математических работ, но в диалогах рассыпаны многочисленные математические идеи, а один из них, «Менон», призван показать врожденность математических знаний («раб знает геометрию, хотя не изучал ее»). Несколько раз Платон отправлялся в Сиракузы, чтобы побудить местного правителя построить справедливое общество. Удача не сопутствовала ему; более того, в одну из таких поездок правитель так разгневался, что приказал продать Платона в рабство. По счастью, философа быстро выкупил его почитатель. Трудно преувеличить влияние Платона на развитие мысли в Европе. Выдающийся британский философ Уайтхед писал: «Наиболее правдоподобная общая характеристика европейской философской традиции состоит в том, что она представляет собой серию примечаний к Платону». Афинская культура распространилась по всему Средиземноморью, особенно во время правления Александра Великого. К числу свершений Александра можно отнести основание им в Египте города Александрии (названного в честь его самого), ставшего новым центром образования и исследований. Больше тысячи ученых работало в Музейопе - «Институте муз», - который сейчас принято считать научно-исследовательским институтом. Отсюда пошло слово «музей». Покровительствовали ученым греческие цари Египта, Птолемаиды, которые платили им жалованье и обеспечивали бесплатным жильем и пропитанием. Частью Музейона была Александрийская библиотека, которой было поручено собирать все знания мира. Предположительно в библиотеке хранилось 500 000 свитков, копированием, переводом и редактированием которых занималась целая армия писцов. * * * Именно в это время Евклид, бывший одним из ученых в Музейоне, написал свои «Начала» - одну из самых важных книг в истории математики. «Начала» содержат доказательства фундаментальных результатов в области геометрии и теории чисел, а также построения с помощью циркуля и линейки, которые школьники изучают по сей день.
Алгоритм Евклида нахождения наибольшей обшей меры ♦ 47 Евклид (достиг известности примерно в 300 году до н. э.) Мы очень мало знаем о Евклиде, даже точные годы его жизни неизвестны. Но мы знаем, что к геометрии он относился очень серьезно. Философ Прокл Диадох, один из преемников Платона на посту главы Академии, писал: «Птолемей (царь Египта) однажды спросил Евклида, существует ли более краткий путь познания геометрии, чем штудирование «Начал», на что Евклид ответил, что в геометрии нет царских дорог». Вполне возможно, что Евклид работал в Академии уже после смерти Платона и принес математику в Александрию. И хотя больше мы почти ничего не знаем о жизни Евклида, нам хорошо известен его труд. В «Начала» включены математические результаты и доказательства из различных существовавших в то время текстов. Внимательное прочтение позволяет обнаружить некоторые из этих слоев; например, еще с античных времен считалось, что теория пропорций, изложенная в книге V, основана на работах Евдокса, ученика Платона. Но именно Евклид объединил разрозненные идеи, выстроив тщательно проработанный, связный текст. Книга I начинается с описания базовых инструментов для геометрических построений циркулем и линейкой и заканчивается тем, что мы теперь называем теоремой Пифагора (предложение 1,47). В тринадцатой, и последней, книге демонстрируется построение пяти Платоновых тел и доказывается, что это единственные правильные многогранники (тела, все грани которых - конгруэнтные правильные многоугольники). Для «Начал» Евклида характерно уникальное в истории математики единство цели. У каждого предложения и доказательства есть причина, лишние результаты не представлены. Сколь бы красивой ни была теорема, она не включается, если не нужна для рассказа в целом. Евклид также предпочитает доказательства, в которых наибольшее количество полезных результатов достигается ценой наименьшего количества операций с помощью циркуля и линейки. Его подход сродни стремлению современных программистов к минимальным элегантным алгоритмам. С момента публикации примерно в 300 году до н. э. и до начала XX столетия «Начала» Евклида были основой математического образования. Евклида изучали не только ученые и математики; такие великие политические деятели, как Томас Джефферсон и Авраам Линкольн, восхищались «Началами» и штудировали их на протяжении всей жизни. Даже в наше время многие считают, что этот подход принес бы немало пользы студентам. 4.2. Алгоритм Евклида нахождения наибольшей обшей меры В книге X «Начал» Евклида вкратце затронуты несоизмеримые величины. Предложение 2. Если для двух неравных величин при постоянном попеременном вычитании меньшей из большей остающееся никогда не будет измерять своего предшествующего, то величины будут несоизмеримыми. По существу, Евклид говорит то, что мы видели в предыдущей главе: если процедура вычисления наибольшей общей меры никогда не завершается, то общей меры не существует.
48 ♦ Алгоритм Евклида Далее Евклид явно описывает алгоритм и доказывает, что он вычисляет GCM. Для понимания доказательства полезен следующий рисунок: A F в СЕ D I 1 1 1 1 1 Поскольку это первое в истории доказательство завершения алгоритма, приведем его текст полностью в переводе А. Д. Мордухай-Болтовского. Предложение 3. Для двух данных соизмеримых величин найти их наибольшую общую меру. Доказательство. Пусть данные две соизмеримые величины будут АВ и CD, из которых меньшая АВ; вот требуется для АВ, CD найти их наибольшую общую меру. Величина АВ или измеряет CD, или нет. Если теперь она измеряет, измеряет также и самоё себя, то, значит, АВ будет общей мерой АВ, CD; и ясно, что и набольшей. Ибо <величина>, большая величины АВ, не будет измерять АВ. Тогда пусть АВ не измеряет CD. И при постоянном попеременном вычитании меньшего из большего остаток когда-нибудь измерит предшествующий ему, вследствие того, что АВ, CD не являются несоизмеримыми (предложение 2); и пусть АВ, измеряя ED, оставит меньшую себя ЕС; ЕС же, измеряя IB, пусть оставит меньшую себя AI; AI же пусть будет измерять СЕ. Поскольку теперь AI измеряет СЕ, но СЕ измеряет IB, то, значит, и AI измерит IB. Она же измеряет и самоё себя; значит, AI измерит и всю АВ. Но АВ измеряет DE; значит, и AI измерит ED. Она же измеряет и СЕ; значит, измеряет и всю CD; значит, AI будет общей мерой АВ, CD. Вот я утверждаю, что и наибольшей. Действительно, если нет, то будет некоторая величина, большая AI, которая измерит АВ, CD. Пусть это будет Н. Поскольку теперь Н измеряет АВ, но АВ измеряет ED, то, значит, и Н измерит ED. Она же измеряет и всю CD; значит, Н измерит и остаток СЕ. Но СЕ измеряет IB; значит, и Н измерит IB. Она лее измеряет и всю АВ, и измерит остаток AI, большая - меньшую; это же невозможно. Значит, никакая величина, большая AI, не измерит АВ, CD; значит, AI будет для АВ, CD наибольшей общей мерой. Итак, для двух данных соизмеримых величин АВ, CD найдена наибольшая общая мера, что и требовалось доказать. Описанный метод «непрерывного вычитания» известен как алгоритм Евклида для нахождения наибольшей общей меры. Это итеративный вариант функции gem из главы 3. Как и раньше, мы покажем его реализацию в нотации C++: line_segment gcmO(line_segment a, line_segment b) { while (a != b) {
Алгоритм Евклида нахождения наибольшей обшей меры ♦ 49 if (b < а) а = а - Ь; else b = b - а; } return a/ } В мире Евклида не бывает нулевых отрезков, поэтому соответствующее предусловие можно опустить. Упражнение 4.1. Функция gcmO неэффективна, когда один отрезок намного длиннее другого. Предложите более эффективную реализацию. Помните, что запрещено использовать операции, которые нельзя выполнить с помощью циркуля и линейки. Упражнение 4.2. Докажите, что если некоторый отрезок измеряет два других отрезка, то он измеряет и их наибольшую общую меру. Движение к более эффективному варианту функции line_segment_gcm мы начнем с проверки условия Ь < а, пока оно не окажется ложным: line_segment gcml(line_segment a, line_segment b) { while (a != b) { while (b < a) a = a - b; std::swap (a, b); } return a; } Можно было бы избежать обращения к функции swap в случае, когда а = Ь, но для этого пришлось бы добавить дополнительную проверку, да и вообще мы пока не готовы к оптимизации кода. Вместо этого заметим, что внутренний цикл while вычисляет остаток от деления а на Ь. Вынесем эту функциональность в отдельную функцию: line_segment segment_remainder(line_segment a, line_segment b) { while (b < a) a = a - b; return a; } Откуда мы знаем, что этот цикл завершается? Это не так очевидно, как могло бы показаться. Например, если бы наше определение типа line_segment включало луч, начинающийся в произвольной точке и продолжающийся до бесконечности в одну сторону, то этот цикл не завершился бы. Необходимые предположения сформулированы в следующей аксиоме. Аксиома Архимеда. Для любых величин аиЬ существует такое натуральное число п, что а < nb. По существу, здесь утверждается, что не существует бесконечных величин.
50 ♦> Алгоритм Евклида Теперь функцию нахождения GCM можно переписать с использованием segment_remainder: line_segment gcm(line_segment a, line_segment b) { while (a != b) { a = segment_remainder(a, b); std::swap(a, b); } return a; } Пока что мы переработали код, но не увеличили его производительность. Большая часть работы производится в segment_remainder. Чтобы ускорить эту функцию, мы воспользуемся той же идеей, что и в египетском алгоритме умножения, - увеличение вдвое одной величины и уменьшение вдвое другой. Для этого необходимо кое-что знать о соотношении между удваиваемым отрезком и остатком. Лемма 4.1 (лемма о рекурсивном остатке). Если г = segment_remainder(tf, 2b), то . . . гч [г если r<b segment__remamder(a, /;) = < . [г-о если г>о Предположим, к примеру, что мы хотим найти остаток от деления числа п на 10. Попробуем сначала взять остаток от деления п на 20. Если результат меньше 10, то мы нашли искомое. Если же результат оказался между 11 и 20, то вычтем из него 10 - разность и будет нужным остатком. Применяя эту стратегию, мы можем написать более быструю функцию: line_segment fast_segment_remainder(line_segment a, line_segment b) { if (a <= b) return a/ if (a - b <= b) return a - b; a = fast_segment_remainder(a, b + b); if (a <= b) return a; return a - b; } Она рекурсивна, но в данном случае мы имеем интуитивно не столь очевидную форму восходящей рекурсии. Обычно мы, выполняя рекурсивный вызов, переходим от п к п - 1; здесь же при каждом вызове аргумент увеличивается - вместо п передается 2т Не вполне понятно, где именно выполняется нужное действие, но функция работает. Рассмотрим пример. Предположим, что имеется отрезок а длиной 45 и отрезок b длиной 6, и мы хотим найти остаток от деления а на Ь: а = 45, b = 6. а < Ь? (45 < 6?) Нет.
Тысяча лет без математики ♦ 51 а-Ь<Ь?(39<6?)Нет. Рекурсия: а = 45, 6= 12 а < 6? (45 < 12?) Нет. я-6<6?(33<12?)Нет. Рекурсия: а = 45, Ъ = 24 а < 6? (45 < 24?) Нет. а - Ъ < Ы (21 < 24?) Да, return a-6 = 21 а-21 а < 6? (21 < 12?) Нет. return a- b = 9 а < 6? (9 < 6?) Нет. return а-й = 9-6 = 3 Напомним, что поскольку у греков не было понятия отрезка нулевой длины, то остатки у них всегда оказывались в диапазоне [1, и]. Здесь присутствуют накладные расходы на рекурсию, поэтому в конечном итоге хотелось бы перейти к итеративному решению, но это мы отложим на потом. И наконец, мы можем включить этот код в функцию нахождения GCM, решив тем самым упражнение 4.1: line_segment fast_segment_gcm(line_segment a, line_segment b) { while (a != b) { a = fast_segment_remainder(a, b)/ std::swap (a, b); } return a; } Разумеется, каким бы быстрым ни был этот код, он все равно не завершится, если у а и b нет общей меры. 43. Тысяча лет без математики Как мы видели, Древняя Греция на протяжении нескольких веков была источником поразительных математических открытий. КIII веку до н. э. математика была процветающей отраслью науки, а самой заметной фигурой в ней был Архимед (сегодня из всех его открытий мы помним главным образом принцип плавучести тел, по преданию открытый им в ванне). К сожалению, возвышение Рима привело к упадку западной математики, которому суждено было продлиться почти 1500 лет. Хотя римляне создали немало инженерных шедевров, они не интересовались развитием математики, благодаря которой все эти постройки стали возможны. Великий римский оратор Цицерон писал в «Тускуланских беседах»:
52 ♦> Алгоритм Евклида Далее, выше всего чтилась у греков геометрия - и вот блеск их математики таков, что ничем его не затмить; у нас же развитие этой науки было ограничено надобностями денежных расчетов и земельных межеваний. И во времена владычества Рима в Греции работали математики, но, как ни удивительно, от той эпохи не осталось ни одного оригинального математического текста, написанного на латыни. Последовавший далее период не благоприятствовал когда-то великим европейским обществам. В Византии, восточном грекоговорящем осколке бывшей Римской империи, математику еще изучали, но любые новшества отметались. В шес- том-седьмом веке ученые еще читали Евклида, но, как правило, только первую книгу «Начал», в переводах на латынь даже доказательства опускались. К концу первого тысячелетия европеец, желавший изучать математику, должен был отправляться в такие города, как Каир, Багдад или Кордову - туда, где обитали арабы. Другие математические традиции В древности математика развивалась в разных частях света. Само существование цивилизации зависело от математики. Все крупные цивилизации разработали системы счисления, без чего невозможно было осуществлять две важнейшие общественные функции: сбор налогов и исчисление календаря для определения дат сельскохозяйственных работ. Также все основные цивилизации разработали общие математические понятия, в частности пифагоровы тройки (три целых числа а, Ь, с - таких, что а2 + Ь2 = с2). Некоторые ученые на основе этого делают вывод, что у математических знаний имеется общий источник, восходящий к эпохе неолита, из которого они распространились по всему миру, но подтверждений этой гипотезе не найдено. На данном этапе более вероятным кажется, что это просто эквивалент параллельной эволюции в биологии, благодаря которой одни и те же характеристики независимо развиваются у не связанных между собой видов. Тот факт, что одинаковые математические идеи независимо открывались вновь и вновь, - свидетельство в пользу их фундаментальной природы. Многие цивилизации развили важные математические традиции на том или ином этапе своей истории. Например, в Китае в III веке математик и поэт Лю Хуэй написал важные комментарии на более раннюю «Математику в девяти книгах» и развил изложенные в ней идеи. Среди прочего он доказал, что значение к должно быть больше 3, и предложил несколько геометрических методов землемерия. В Индии в V веке математик и астроном Ариабхата написал фундаментальный труд «Ариабхатия», содержавший алгоритмы вычисления квадратного и кубического корня, а также описания различных геометрических методов. Идеи индийских математиков были затем развиты арабскими, персидскими и еврейскими учеными, которые все писали на арабском, что, в свою очередь, немало способствовало возрождению европейской математики в начале XIII века. Из этой восставшей к новой жизни европейской математики развилась информатика, которая и интересует нас больше всего. Все мы, программисты, - наследники этой традиции. 4.4. Странная история нуля Дальнейшее развитие алгоритма Евклида потребовало того, чего у греков не было: нуля. Возможно, вам доводилось слышать, что в античных обществах не было понятия нуля и что изобретено оно было индусами или арабами. Но это верно лишь отчасти. На самом деле вавилонские астрономы использовали нуль, равно как и
Странная история нуля ♦ 53 позиционную систему счисления, еще в 1500 году до н. э. Но основание этой системы было равно 60. Во всех остальных общественных делах, например в торговле, использовалась система счисления с основанием 10, но без нуля и без позиционной нотации. Удивительно, но такое положение вещей сохранялось столетиями. Позже греческие астрономы переняли вавилонскую систему и применяли ее (по-прежнему с основанием 60) в тригонометрических расчетах, но точно так же использовалась она лишь для одной цели и была неизвестна обществу в целом. (Именно греческие астрономы начали использовать для представления нуля букву омикрон, похожую на нашу букву «О».) Что особенно поражает в отсутствии нуля всюду, кроме астрономии, так это тот факт, что абак был хорошо известен и широко применялся в торговле практически во всех древних цивилизациях. Абак состоял из камушков или бусинок, собранных в столбцы; столбцы соответствовали единицам, десяткам, сотням и т. д.. а каждая бусинка представляла одну единицу данной степени 10. Иными словами, в древних обществах использовалось приспособление для представления чисел в позиционной десятичной системе счисления, но общепринятое письменное начертание нуля появилось лишь 1000 лет спустя. Объединением письменного начертания нуля с десятичной позиционной нотацией мы обязаны ранним индийским математикам, и произошло это где-то в VI веке нашей эры. Затем, между VI и IX веком, эта идея проникла в Персию и была позаимствована арабскими учеными,, которые распространили ее по всей своей империи, от Багдада на востоке до Кордовы на западе. Нет никаких свидетельств в пользу того, что нуль был известен в Европе за пределами этой империи (даже в остальной части Испании); должно было пройти 300 лет, чтобы это новшество проникло из одной культуры в другую. Прорыв произошел в 1203 году, когда Леонардо Пизанский, известный также по имени Фибоначчи, опубликовал книгу Liber Abaci («Книга абака»). Помимо описания нуля и позиционной десятичной нотации, из этой поразительной книги европейцы впервые узнали о стандартных алгоритмах, которые мы теперь учим в начальной шкале: сложения, вычитания, умножения и деления многозначных чисел. Одним движением Леонардо вернул математику в Европу. Леонардо Пизанский (1170 - ок. 1240) Итальянский город Пиза, который ныне со всех сторон окружен сушей, в XII и XIII столетиях был крупным портом и морской державой. Он боролся с Венецией за место главного центра торговли на Средиземном море. Тысячи пизанских купцов бороздили моря, торгуя с Ближним Востоком, Византией, Северной Африкой и Испанией, а правительство Пизы направляло своих представителей в крупные города, чтобы поддержать их начинания. Один из таких представителей, Гильермо Боначчи, был послан в Алжир. Он взял с собой сына - решение, изменившее ход истории математики. Леонардо научился у арабов «индийским цифрам» и продолжал свои занятия во время деловых поездок в Египет, Сирию, Сицилию, Грецию и Прованс. В своей «Книге аба-
54 ♦ Алгоритм Евклида ка» он поведал Европе о том, что узнал (в том числе о нуле). Но «Книга абака» - не просто пересказ чужих достижений; это первоклассный математический трактат, содержащий немало новых фундаментальных открытий. Леонардо не остановился на этом и написал еще несколько книг по различным разделам математики, в них встречаются и важнейшие - впервые за несколько веков - математические результаты. Сам себя он называл Леонардо Пизанским, хотя начиная с XIX века больше известен под именем Фибоначчи - сокращение от filius Bonacci («сын Боначчи»). Слава Леонардо достигла императора Священной Римской империи, Фридриха II, умнейшего человека, знатока многочисленных языков и покровителя наук и математики. Свой двор он держал в Палермо, на острове Сицилия. Фридрих приехал в Пизу и организовал состязание Леонардо со своими придворными математиками. Леонардо выступил прекрасно и произвел сильное впечатление на прибывших сановников. На закате жизни Леонардо город Пиза назначил ему содержание в знак признания его великих заслуг. Более поздняя книга Леонардо Пизанского «Liber Quadratorum» («Книга квадратов»), опубликованная в 1225 году, является, пожалуй, самой значительной работой по теории чисел со времен Диофанта, жившего на 1000 лет раньше, и вплоть до великого французского математика Пьера Ферма, родившегося на 400 лет позже. Вот одна задача из этой книги. Упражнение 4.3 (легкое). Докажите, что ^16 + ^54 = ^250. Почему подобные задачи были трудными для греков? Они не располагали конечной процедурой вычисления кубических корней (на самом деле позже было доказано, что такой процедуры и не существует). Поэтому, с их точки зрения, постановка задачи начинается так: «Сначала выполни незавершающуюся процедуру..». Прозрение Леонардо покажется очевидным любому, кто знаком с алгеброй в объеме средней школы, но в XIII веке оно было революционным. По существу, он сказал: «Да, я не знаю, как вычислить \/2у но притворюсь, что знаю, и обозначу его произвольным символом». Вот еще два примера задач, решенных Леонардо. Упражнение 4.4. Докажите следующее предложение из «Книги квадратов»: для любого нечетного квадратного числа х найдется четное квадратное число г/, такое, что х + у- квадратное число. Упражнение 4.5 (трудное). Докажите следующее предложение из «Книги квадратов»: еслихиу - суммы двух квадратов, то таким же будет и их произведение ху (это важный результат, на который опирался Ферма). 4.5. Алгоритмы нахождения частного и остатка Нуль уже широко использовался в математике, но прошло еще несколько веков, прежде чем кому-то пришло в голову, что бывают отрезки нулевой длины - конкретно отрезок АЛ. Отрезки нулевой длины вынуждают нас переосмыслить процедуры нахождения GCM и остатка, поскольку аксиома Архимеда больше не верна - мы можем
Алгоритмы нахождения частного и остатка ♦ 55 бесконечно добавлять отрезок нулевой длины, никогда не превзойдя ненулевой отрезок. Поэтому мы разрешим первому аргументу а быть нулем, но добавим предусловие, требующее, чтобы второй аргумент Ъ был отличен от нуля. Наличие нуля также позволяет нам сдвинуть остатки в диапазон [0, п - 1], что чрезвычайно важно для арифметики по модулю и других вещей. Ниже приведен код. line_segment fast_segment_remainderl(line_segment а, line_segment b) { // precondition: b != 0 if (a < b) return a; if (a - b < b) return a - b; a = fast_segment_remainderl(a, b + b); if (a < b) return a; return a - b; } Единственное, что изменилось, - условия; всюду, где мы раньше писали а <= Ь, теперь проверяется условие а < Ь. Посмотрим, нельзя ли избавиться от рекурсии. При каждом рекурсивном вызове мы удваиваем Ъ, поэтому в итеративной версии надо было бы заранее вычислить, сколько удвоений нам понадобится. Мы можем определить функцию, которая находит первый результат повторного удвоения Ь, больший разности а - Ъ\ line_segment largest_doubling(line_segment a, line_segment b) { // precondition: b != О while (a - b >= b) b = b + b; return b; } Теперь итеративная функция должна проделать те же вычисления, что выполняются на пути возврата из рекурсии. При каждом возврате b имеет значение, которое было в момент последнего рекурсивного вызова (то есть самого недавнего удвоения). Чтобы смоделировать эту ситуацию, итеративная версия должна повторно «располовинивать» значение, для чего мы предназначим функцию half. Не забывайте, мы все еще «вычисляем», пользуясь только циркулем и линейкой. К счастью, у Евклида есть процедура деления отрезка пополам1, поэтому мы вправе пользоваться функцией half. Мы готовы написать итеративную версию нахождения остатка. line_segment remainder(line_segment a, line_segment b) { // precondition: b != 0 if (a < b) return a; line_segment с = largest_doubling(a, b); a = a - c; Нарисуйте окружность с центром в одном конце отрезка радиусом, равным отрезку, повторите то же самое для другого конца отрезка. С помощью линейки соедините точки пересечения окружностей. Получившаяся прямая делит исходный отрезок пополам.
56 ♦> Алгоритм Евклида while (с != Ь) { с = half(с); if (с <= а) а = а - с; } return a; } Первая часть функции, где ищется верхняя граница количества удвоений, исполняет роль рекурсивного «спуска», тогда как во второй части делается то, что происходит при возврате из рекурсивных вызовов. Снова рассмотрим пример нахождения остатка от деления 45 на 6, но теперь с применением новой функции remainder: а = 45, Ъ = 6 а < Ы (45 < 6?) Нет. с «- largest_doubling(45, 6) = 24 а-я-с = 45-24 = 21 loop: с ф Ы (24 ф 6)? Да, продолжаем. с *- half (с) - half (24) - 12 с < а? (12 < 21)? Да, а - а - с = 21 - 12 = 9 с ф Ы (12 ф 6)? Да, продолжаем. c<-half(c) = half(12) = 6 с < а? (6 < 9)? Да. а - а - с = 9 - 6 = 3 с Ф Ы (6 ф 6)? Нет, выйти из цикла loop return а = 3 Обратите внимание, что последовательные значения с в итеративной реализации совпадают со значениями b после каждого рекурсивного вызова в рекурсивной реализации. Сравните также эту трассу с трассой предыдущей версии алгоритма в конце раздела 4.2. Заметьте, что результаты, полученные здесь после первой части (с = 24и<2 = 21), такие же, как в самом глубоком рекурсивном вызове ранее. Этот алгоритм чрезвычайно эффективен, почти так же, как аппаратная реализация операции вычисления остатка в современных процессорах. * * * А что, если мы хотим вычислить не остаток, а частное! Оказывается, что код будет почти такой же. Нужны лишь несколько мелких изменений, выделенных ниже полужирным шрифтом. integer quotient(line_segment a, line_segment b) { // Предусловие: b > О if (a < b) return integer(O); line_segment с = largest_doubling(a, b); integer n(l)/ a = a - c; while (c != b) { с = half (с); n = n + n;
Повторное использование кода ♦ 57 if (с <= а) { а = а - с; n = n + 1; } } return n; } Частное - это сколько раз один отрезок умещается в другом, поэтому для представления этого значения мы используем тип integer. По существу, нам нужно посчитать количество кратных Ъ. Если а < Ь> то нет ни одного кратного Ь, и мы возвращаем 0. Если же а > Ь, то мы инициализируем счетчик единицей и удваиваем его при каждом уменьшении с вдвое, добавляя еще одно кратное для каждой итерации, на которой мы не вышли за пределы исходного отрезка. И снова пройдем пример вручную. На этот раз вместо нахождения остатка от деления 45 на 6 мы найдем частное. а = 45, Ъ = 6 а < Ы (45 < 6?) Нет. с <— largest_doubling(45, 6) = 24 п «- 1 а «- а -с = 45 -24 = 21 loop: с ф Ы (24 Ф 6)? Да, продолжаем. с <- half (с) = half (24) = 12\п^п + п= 1 +1 = 2 с<а?(12< 21)? Да, а - а - с = 21 - 12 = 9 п+-п+ 1 = 2 + 1 = 3 с ф Ы (12 ф 6)? Да, продолжаем. с - half (с) = half (12) = 6;я-я + я = 3 + 3 = 6 с < а? (6 < 9)? Да. а - а - с = 9 - 6 = 3 п-п+1=6+1=7 с ф Ы (6 ф 6)? Нет, выйти из цикла loop return n = 7 По существу, это алгоритм египетского умножения наоборот. И Ахмес знал это: в папирусе Ринда имеется примитивный вариант этого алгоритма, известный грекам как египетское деление. 4.6. Повторное использование кода Поскольку большая часть кода используется при вычислении как частного, так и остатка, то имеет прямой смысл объединить его в одной функции, возвращающей оба значения; сложность комбинированной функции такая же, как каждой функции в отдельности. Отметим, что стандарт С++11 позволяет использовать синтак- cpic инициализации списка {х, у} для построения возвращаемой функцией пары: std::pair<integer, line_segment> quotient_remainder(line_segment a, line_segment b) { // precondition: b > 0
58 ♦> Алгоритм Евклида if (a < b) return {integer(O), a}; line_segment с = largest_doubling(a, b)/ integer n(l); a = a - c; while (c != b) { с = half (c); n = n + n; if (c <= a) { a = a - c; n = n + 1; } } return {n, a}; } На самом деле любая функция - quotient или remainder - делает почти все, что нужно другой. Принцип программирования: закон полезного возврата Наша функция quotientremainder иллюстрирует важный принцип программирования, который мы назовем законом полезного возврата: Если вы уже проделали работу по получению некоего полезного результата, не выбрасывайте ее насмарку Верните вызывающей программе. Возможно, это позволит вызывающей программе получить что-то «задаром» (как в случае quotientjremainder) или вернуть данные, которые можно будет использовать при последующих вызовах функции. К сожалению, этот принцип соблюдается не всегда. Например, в языках программирования С и C++ имеются разные операторы вычисления частного и остатка; не существует способа получить оба результата за одно обращение - и это несмотря на то, что во многих процессорах реализована команда, которая возвращает то и другое. В большинстве вычислительных архитектур, будь то циркуль и линейка или современные процессоры, имеется простой способ вычисления функции half; в нашем случае это просто сдвиг на один разряд вправо. Но если вам вдруг встретится архитектура, не поддерживающая такую функциональность, то существует замечательный вариант функции нахождения остатка, предложенный Робертом Флойдом и Дональдом Кнутом, в котором деление пополам не используется. Он основан на последовательности чисел Фибоначчи - еще одном изобретении Леонардо Пизанского, которое мы обсудим в главе 7. Идея в том, чтобы получать следующее число не удвоением предыдущего, а сложением двух предыдущих1: line_segment remainder_fibonacci(line_segment a, line_segment b) { // precondition: b > 0 if (a < b) return a; line_segment с = b; do { line_segment tmp = с; с = b + c; b = tmp; } while (a >= c); Отметим, что первым членом последовательности является число Ь, так что она отличается от традиционной последовательности Фибоначчи.
Повторное использование кода ♦> 59 do { if (a >= b) a = а line_segment tmp } while (b < с); return a; } Первый цикл эквивалентен вычислению функции largest_doubling в предыдущем алгоритме. Второй цикл соответствует той части кода, в которой производится «располовинивание». Только вместо деления пополам мы используем вычитание, чтобы получить предшествующее число в последовательности Фибоначчи. Это возможно, потому что мы сохраняем предыдущее значение во временной переменной. Упражнение 4.6. Протрассируйте работу алгоритма remainderjibonacci по вычислению остатка от деления 45 на 6 так же, как это было сделано для алгоритма remainder в разделе 4.5. Упражнение 4.7. Разработайте функции quotientjibonacci и quotient_remainder_ fibonacci. Теперь, имея эффективную реализацию функции нахождения остатка, мы можем вернуться к исходной задаче: вычислению наибольшей общей меры. Пользуясь новой функцией remainder, приведенной на стр. 56, перепишем алгоритм Евклида следующим образом: line_segment gcm_remainder(line_segment a, line_segment b) { while (b != line_segment(0)) { a = remainder(a, b); std::swap(a, b)/ } return a; } Поскольку мы теперь разрешаем функции remainder возвращать нуль, условием завершения главного цикла является обращение Ъ (остатка, вычисленного на предыдущей итерации) в нуль, а не результат сравнения а и Ьу как раньше. Мы будем использовать этот алгоритм в последующих главах, не меняя структуру, но исследуя его применение к различным типам. Оставим в прошлом построения с помощью циркуля и линейки и реализуем алгоритм, ориентируясь на цифровой компьютер. Например, для целых чисел эквивалентная функция вычисляет наибольший общий делитель (НОД, или GCD). integer gcd(integer a, integer b) { while (b != integer(0)) { a = a % b; std::swap(a, b); } return a; } - b; : с - b; с = b; b = tmp;
60 ♦ Алгоритм Евклида Код точно такой же, только мы заменили тип line_segment на integer и воспользовались оператором деления по модулю \ для вычисления остатка. Поскольку у компьютеров имеется команда для вычисления остатка от деления целых чисел (и именно она вызывается при выполнении оператора деления по модулю в C++), то лучше использовать ее, а не заниматься удвоением и делением пополам. 4.7. Доказательство правильности алгоритма Как узнать, правильно ли работает алгоритм вычисления НОД целых чисел? Нам нужно доказать две вещи: во-первых, что алгоритм завершается и, во-вторых, что он действительно вычисляет НОД. Для доказательства завершения алгоритма мы воспользуемся тем, что О < (a mod b) < Ь. Поэтому на каждой итерации остаток уменьшается. Поскольку любая убывающая последовательность неотрицательных целых чисел конечна, алгоритм обязан завершаться. Чтобы доказать, что алгоритм вычисляет НОД, заметим, что на каждой итерации алгоритм вычисляет остаток от деления а на Ь, который по определению равен r= a- bq, где q - целое частное от деления а на Ь. Поскольку gcd(a, b) по определению делит как а, так и b (и потому bq), то он должен делить и г. Тогда выражение для остатка можно переписать в виде: а = bq + г. Рассуждая точно так же, мы заключаем, что раз gcd(b, г) по определению делит b (и потому bq) и г, то он должен делить и а. Поскольку у пар (я, Ь) и (6, г) имеются одни и те же общие делители, то их наибольшие общие делители также должны совпадать. Таким образом, мы показали, что a = bq + r^> gcd(a; b) = gcd(b; r). (4.1) На каждой итерации алгоритм заменяет gcd(a, b) на gcd(b, r), вычисляя остаток и меняя аргументы местами. Ниже приведен список остатков, начиная с а0 и Ь0 - исходных аргументов функции: г.{ = remainder(я0> 60); r2 = remainder(&0, г{); r3 = remainder (rv r2); rn = remainder^, rn_A). Пользуясь определением остатка, перепишем последовательность, вычисляемую алгоритмом, в следующем виде:
Заключительные мысли ♦> 61 r2 = b0- r,q2; гз = г\ ~ ггЧъ Гп ~~ Гп-2 ~~ Гп-\Чп- Утверждение (4.1) гарантирует, что при каждой такой операции НОД не изменяется. Иными словами: gcd(aQ, bQ) = gcd(b0, r{) = gcd(rv r2) = ... = gcd(rn_v rn). Но мы знаем, что остаток от деления гп_х на гп равен 0, потому что именно это приводит к завершению алгоритма. Agcd(x, 0) = х. Таким образом: gcd(a„ bQ) = ... = gcd(rn_v rn) = gcd(rn, 0) = гл> а это и есть значение, возвращаемое алгоритмом. Мы доказали, что алгоритм вычисляет НОД исходно переданных аргументов. 4.8. Заключительные мысли Мы видели, как античный алгоритм вычисления наибольшей общей меры двух отрезков можно превратить в современную функцию вычисления НОД целых чисел. Мы рассмотрели варианты алгоритма и убедились в наличии связи между ним и функциями для вычисления частного и остатка. Может ли алгоритм вычисления НОД работать для чего-то, кроме отрезков и целых чисел? Иначе говоря, можно ли сделать его более общим? Мы ответим на этот вопрос ниже.
Глава -J Зарождение современной теории чисел До сего дня математики тщетно пытались найти какие-нибудь закономерности в последовательности простых чисел, и есть основания полагать, что это тайна, непостижимая человеком. Леонард Эйлер В предыдущей главе мы видели, как еще не вышедшая из младенческого возраста теория чисел, очаровавшая древних греков, возродилась в средневековой Европе после долгого пребывания в забвении. Но теория чисел в том виде, в каком мы ее знаем, возникла спустя несколько столетий во Франции XVII века. В этой главе мы на время отвлечемся от программирования и рассмотрим некоторые результаты, открытые французскими математиками в XVII веке. Позже мы воспользуемся ими в важных программных приложениях. 5.1. Простые числа Мерсенна и Ферма Математики Возрождения не меньше древних греков были увлечены магией простых чисел. Они задавались вопросом, существуют ли в них какие-то закономерности. Особенно их интересовали простые числа вида 2п- 1, поскольку (как мы видели в разделе 3.4) они являются источником совершенных чисел. С XV до XVIII века европейские математики, как и греки до них, ощущали, что эти числа обладают особой важностью. В письмах Ферма, Мерсенна и Декарта немало упоминаний о совершенных числах, а также о тесно связанных с ними дружественных числах. И даже в XVIII веке великий математик Леонард Эйлер все еще считал эту тему исключительно важной. В главе 3 мы видели, что греки знали, как порождать совершенные числа из простых чисел вида 2п - 1. Они знали, что числа такого вида являются простыми для п = 2, 3, 5, 7 и, возможно, 13. В 1536 году Худальрикус Региус показал, что число, получающееся при п = И, не простое, а именно 211-1 = 2047 = 23x89.
Простые числа Мерсенна и Ферма ♦ 63 В 1603 году Пьетро Катальди пополнил список еще несколькими значениями: 17, 19, (23), (29), 31 и (37), но половина из них (те, что заключены в скобки) оказались неправильными. Пьер де Ферма обнаружил, что 223 - 1 = 8388607 = 47 х 178481; 237 - 1 = 137438953471 = 223 х 61631877. В вышедшей в 1644 году книге «Cogitata Physico Mathematica» французский математик Мерсенн высказал гипотезу, что при п < 257 число 2п - 1 является простым тогда и только тогда, когда п = 2, 3, 5, 7,13, 17,19, 31, (67), 117, (257). Два числа (в скобках) оказались неверными, а еще два - 89 и 107 - он пропустил. По имени автора предположения простые числа такого вида стали называть числами Мерсенна. Мы до сих пор не знаем, конечно их множество или нет, но и сейчас числа Мерсенна применяются для поиска больших простых чисел. Марен Мерсенн (1588-1648) Начиная с 1624 года, когда кардинал Ришелье стал первым министром, Франция начала наращивать военное, политическое, культурное и научное могущество. Пока ученые в классических университетах продолжали толковать античные труды Аристотеля, такие философы, как француз Декарт, работавшие вне университетской системы, совершали мировоззренческую революцию. Ришелье создал первое современное государство, с тщательно выстроенным централизованным бюрократическим и военным аппаратом, претендующее даже на официальный контроль над французским языком. К 1660 году Франция стала непререкаемым лидером Европы, а французский язык на ближайшие 250 лет утвердился в роли преобладающего языка дипломатии и аристократии в большинстве западных стран. Именно в это время француз Марен Мерсенн, человек энциклопедических познаний и член строгого монашеского ордена минимов, дал колоссальный толчок науке. Получивший образование у иезуитов и усовершенствовавшийся в классических науках и математике, Мерсенн предпочел крайний аскетизм минимов, которые не владели никакой собственностью (даже совместной), блюли строгую вегетарианскую диету и не употребляли спиртное. Смирение Мерсенна простиралось и на его профессиональную жизнь: если другие ученые провозглашали собственную значимость, то его призванием была помощь в распространении чужих трудов и изучении результатов, полученных другими. Мерсенн выполнил ряд важных работ по теории звука и в других областях, но его величайшим вкладом было создание научного сообщества. В то время еще не было научных журналов, но Мерсенн играл роль «виртуального научного журнала», поддерживая переписку с друзьями и информируя их о чужих результатах. В друзьях Мерсенна числились Галилей, Гюйгенс, Торричелли, Декарт, Ферма и Паскаль. Именно Мерсенн организовал опубликование трудов Галилея в протестантской Голландии, несмотря на осуждение его католической церковью. Позже ученые сходились в келье Мерсенна на своего рода неформальные еженедельные конференции. Опубликованные после его смерти письма, по существу, являлись первыми в мире научными записками.
64 ♦> Зарождение современной теории чисел В письме к Мерсенну от июня 1640 года Ферма писал, что найденное им разложение числа 237 - 1 на множители опиралось на три открытия: 1. Если п не простое, то 2п - 1 тоже не простое. 2. Если п простое, то 2п - 2 кратно 2п. 3. Если п простое, ар - простой делитель 2" - 1, тор - 1 кратно п. Вскоре мы приведем доказательство утверждения 1, а пока просто примем на веру, что все эти утверждения верны. Ферма рассуждал, что если число 237 - 1 не простое, то оно должно иметь простой множитель р, обязательно нечетный. В силу предложения 3, р - 1 кратно 37, или, что эквивалентно: p = 37w+ 1. Далее, так какр нечетно, тор - 1 = 37 и четно, следовательно, и тоже четно. Это означает, что и можно записать в виде 2v} откуда: р = 7Av + 1. Таким образом, Ферма сузил задачу факторизации, ограничившись только простыми числами указанного выше вида. Их можно уже перебрать последовательно: Как насчет v = 1? Нет, 75 не простое. Как насчет v = 2? Нет, 149 простое, но не является делителем 237 - 1. Как насчет v = 3? Да! 223 простое и является делителем 237 - 1. Итак, число 237 - 1 не простое. * * * Теперь рассмотрим данное Ферма доказательство предложения 1, которое мы сформулируем в контрапозитивной1 форме. Теорема 5.1. Если 2п - 1 - простое число, то п простое. Доказательство. Допустим, что п не простое. Тогда существуют такие сомножители UHV, ЧТО /2 = UV, U > 1, V > 1. Тогда 2" - 1 = 2т) - 1 = (2иУ - 1 = (2й - 1)((2иУ~{ + (2иУ~2 + ... + (2") + 1), (5.1) где на последнем шаге использована формула разности степеней (3.1). Поскольку и > 1, то верны следующие утверждения: К 2й- 1; 1 < (2иУ'{ + (2иУ~2 + ... + (2") + 1. 1 Импликация р => q эквивалентна своей контрапозиции ~^q => -7?. См. «Импликация и контрапозиция» в приложении А.
Простые числа Мерсенна и Ферма ♦ Б5 Тогда из (5.1) следует, что мы разложили 2" - 1 на два числа, больших 1. Но это тиворечит условию теоремы, согласно которому число 2" - 1 простое. Значит, ходное предположение ложно, и п должно быть простым. Что касается предложений 2 и 3, то Ферма не сообщил их доказательств. Своему другу Френиклю Ферма писал, что «прислал бы [доказательство], если бы оно -е было таким длинным». Чуть позже мы еще вернемся к этому вопросу. Пьер де Ферма (1601 -1665) Пьер де Ферма был юристом, советником парламента в Тулузе на юге Франции. Будучи человеком Возрождения в духе Монтеня, он интересовался самыми разными предметами, в том числе классической литературой, свободно владел латинским и греческим языками. Последний из великих математиков-любителей, Ферма заинтересовался теорией чисел, прочитав античный греческий трактат Диофанта «Арифметика» в переводе Баше. Он внес колоссальный вклад в математику, но лично с другими математиками никогда не общался. Мерсенн неоднократно приглашал его приехать в Париж, но, насколько нам известно, Ферма так и не сделал этого. Ферма часто похвалялся своими результатами, но методы держал в тайне. Не раз он говорил, что располагает доказательством какого-то факта, но всегда находился предлог, чтобы не предъявлять его. Если он все-таки публиковал результат, то старался сообщить как можно меньше о том, как он был достигнут. За всю жизнь Ферма не опубликовал ни одной работы, хотя состоял в переписке с Мер- сенном и другими учеными. После смерти Ферма его сын издал трактат Диофанта с заметками Ферма на полях. В заметках содержалось много теорем, которые другие математики постепенно доказывали в последующие годы. Последняя, долго не поддававшаяся решению, получила название Великой теоремы Ферма, в ней утверждалось, что уравнение ап + Ьп = сп не имеет решения в положительных целых числах при п > 2. В 1994 году ее наконец доказал Эндрю Уайлс. Широко известно, что рядом с формулировкой Великой теоремы Ферма написал, что «доказательство слишком длинное и не помещается на полях». Мы уже говорили, что это типичный для него поступок; он нередко в подобных выражениях уклонялся от демонстрации своих доказательств. Хотя все его гипотезы впоследствии были подтверждены, некоторые доказательства настолько сложные и длинные, что жившие позже математики, в частности Гаусс, сомневались, что Ферма действительно открыл их. Помимо теории чисел, Ферма внес крупный вклад и в другие разделы математики. Он изобрел аналитическую геометрию - изучение уравнений кривых - раньше Декарта, но изложил свою работу в неопубликованной рукописи. Кроме того, в ходе его продолжительной переписки с Блезом Паскалем были заложены основы теории вероятностей. Ферма высказал немало утверждений, которым не дал доказательств, но все они оказались верными, кроме одного: 2п + 1 простое <=> п = 2\ (Двойная стрелка читается «тогда и только тогда»; детали см. в приложении А.) С тех пор простые числа этого вида (22*+ 1) называются числами Ферма. Легко доказать одну часть этого утверждения.
6Б ♦ Зарождение современной теории чисел Теорема 5.2. 2" + 1 простое => п = 2\ Доказательство. Предположим, что п Ф 2\ Тогда один из сомножителей п должен быть нечетным, и мы можем записать его в виде 2q + 1. Эта величина > 1, поэтому ее можно представить также в виде п = m{2q + 1). Подставляя m(2q + 1) вместо п и применяя формулу суммы нечетных степеней (3.4), получаем разложение 2п + 1 на множители: 2я + 1 = 2Л|(2*+1) + 1 = 9^(2^+1) -J- 1^(2*7+1) = (2m)2q+* + 12^+1 = (2w)29+i + i)((2m)2^ - (27*)2*-1 +... +1). Но наличие у 2п + 1 разложения на множители противоречит условию теоремы; простое число не может иметь нетривиальных делителей. Следовательно, наше предположение ложно, и п = 2\ Что можно сказать о других простых числах вида 22' + 1? Ферма утверждал, что числа 3, 5, 17, 257, 65 537, 4 294 967 297 и 18 446 744 073 709 551 617 простые, а, значит, простыми являются и все остальные числа такого вида. К сожалению, он был не прав как в части двух примеров (простыми являются только первые пять из названных им чисел), так и в части утверждения в целом. В 1732 году Эйлер показал, что 232 + 1 = 4 294 967 297 = 641 х 6 700 417. На самом деле мы знаем, что для всех 5 < г < 32 числа Ферма составные. Существуют ли еще какие-нибудь простые числа Ферма, кроме первых пяти? Пока никто не знает. 5.2. Малая теорема Ферма Мы подошли к одному из наиболее важных результатов теории чисел. Теорема 5.3 (малая теорема Ферма). Еслир простое9 то ар~х - 1 делится нар для любого 0 < а<р. Ферма в 1640 году заявлял, что располагает доказательством этой теоремы, но не опубликовал его. Лейбниц нашел доказательство между 1676 и 1680, но тоже не опубликовал. Наконец, в 1742 и 1750 годах Эйлер опубликовал два разных доказательства. Ниже мы докажем эту теорему, но предварительно нам потребуется еще несколько результатов. Хотя, на первый взгляд, они никак не относятся к теореме Ферма, скоро мы увидим, что связь все-таки есть.
Малая теорема Ферма ♦ 67 Леонард Эйлер (1707-1783) Леонард Эйлер родился в Швейцарии в образованной семье, принадлежащей среднему классу. Талантливый студент, с широким кругозором и потрясающей памятью, он учился у Иоганна Бер- нулли, величайшего математика того времени и хорошего друга отца Эйлера. (Сам Бернулли был учеником Лейбница, одного из двух изобретателей математического анализа.) Значительная часть XVIII века была временем реформ, начатых Петром Первым и продолженных его преемниками. Они круто изменили - «европеизировали» - русское общество и его культуру. Одним из результатов реформ было создание Императорской академии наук в Санкт-Петербурге, приглашение работать в ней получали многие европейские ученые. Так, в 1727 году Эйлер, которому тогда было 20 лет, получил работу, заключавшуюся в проведении математических исследований. За 10 лет полученные результаты в области математики, механики и даже кораблестроения завоевали ему репутацию одного из лучших научных умов Европы. К 1741 году, когда Фридрих Великий уговорил его переехать в Берлин, Эйлер уже был международной суперзвездой. В те времена правители считали, что общение с учеными и другими интеллектуалами - верный способ повысить престиж самого государя. Пока Эйлер работал в Берлине, французский и русский двор всеми силами стремились переманить его к себе. Наконец, в 1766 году он вернулся в Санкт-Петербург, где и работал до конца жизни. Вклад Эйлера в математику и физику огромен. Он трудился во многих областях: основал современную теорию графов и совершил основополагающие открытия в теории чисел. Но важнейшим его достижением было превращение современного математического анализа, включая дифференциальные уравнения, из набора изолированных методов, изобретенных Ньютоном и Лейбницем, в систематическую дисциплину. Три написанные им книги («Введение в анализ бесконечных», «Дифференциальное исчисление» и «Интегральное исчисление») оставались авторитетными учебниками почти сто лет и до сих пор заслуживают внимательного изучения. Эйлер написал также первую научно-популярную книгу, «Письма к немецкой принцессе», в которой объясняет неспециалисту ньютоновский взгляд на мир. Его перу принадлежит и учебник по элементарной алгебре, адресованный нематематикам; он издается до сих пор. Наследие Эйлера настолько обширно, что после его смерти Российской академии наук понадобилось 60 лет для издания всего им написанного. В свое время он был признан величайшим математиком, и даже спустя 200 лет мы разделяем мнение Лапласа о томт что «он наш общий учитель». Для начала мы докажем еще одно предложение из «Начал» Евклида. Теорема 5.4 (Евклид VII, 30)- Произведение двух целых чисел, меньших простого числар, не делится нар. (По-другому то же самое можно выразить, сказав, что еслир простое иаиЬ меньше р, то ab не делится нар.) Если некоторое число х делится на другое число г/, то х является кратным у: х = ту. Если х не делится на г/, то при делении х на у получается остаток г.х = ту + г. Таким образом, это предложение можно переформулировать в виде:
68 ♦> Зарождение современной теории чисел р простое л 0 < a, b<p => ab = mp + r лО<г<р. Доказательство. Предположим противное, то есть что аЪ кратно р. Пусть для заданного а число Ь - наименьшее из всех, для которых аЪ = тр. Так как р простое, то при делении ршЬ образуется ненулевой остаток v < b: p = bu + vA0<v<b. Умножая обе части этого равенства на а и подставляя затем ab = тр, получаем: ар = abu + av ар - abu = av ар - три = av av = (а - ти)р л 0 < v < b. Но это означает, что мы нашли целое число v, меньшее b и такое, что av кратно р. Это противоречит предположению о том, что b - наименьшее такое число. Следовательно, наше исходное допущение было неверным, и ab не делится нар. Такой подход к доказательству типичен для древнегреческой математики: выбрать наименьшее, а затем показать, что при некоторых предположениях существует еще меньшее. * * * Далее мы докажем результат об остатках. Лемма 5.1 (лемма о перестановке остатков). Если р простое, то для любого 0<а<р а-{1у...ур-1} = {а,..., а(р - 1)} = {qxp + rv ..., qp_xp + rp_t}, где 0 <r{<p A i Ф] => г. ф г.у Иначе говоря, если взять все кратные а от \а до (р - 1)аи записать каждое из них в виде qp + г, то все остатки г будут различны, и множество остатков является перестановкой множества {1, ...,р - 1}. (Мы знаем, что каждый остаток меньше р, поэтому получаетсяр - 1 различных чисел из диапазона [1; р - 1].) Пример. Если р = 7 и а = 4, то лемма утверждает, что {4,8,12,16,20,24} = {0-7+ 4,1-7+ 1,1-7+ 5,2-7+ 2,2-7+ 6,3-7 + 3}, то есть остатки образуют множество {4,1, 5, 2, 6, 3}, являющееся перестановкой множества {1,..., 7 — 1}. Доказательство. Предположим, что г{ = т-} и г <jy то есть что какие-то два остатка совпадают. Тогда если взять разность соответствующих элементов множества, то остатки г- и г, взаимно уничтожатся:
Сокращение ♦ БЭ = (Qj ~ Ядр. Поскольку i-й и j-и элементы множества - не что иное, как произведения ai pi aj, то левую часть этого равенства можно записать в виде разности aj - ai: aj - ai = (q. - q()p] <*U - 0 = (Qj - ядр. Но это равенство имеет вид ah = тр, то есть получается, что произведение двух чисел, менынихр, делится нар. Поскольку это противоречит предложению VII, 30 Евклида, то мы только что доказали ложность исходного допущения, а значит, все остатки должны быть различны. 5.3. Сокращение Теперь рассмотрим несколько результатов, касающихся сокращения, или взаимного уничтожения. Если перемножить два числа х и z/, то они сокращаются (то есть их произведение оказывается равным 1), если являются взаимно обратными. Сокращение и арифметика по модулю Сокращение можно рассмотреть в контексте арифметики по модулю, введенной Карлом Фридрихом Гауссом, с которым мы встретимся в главе 8. Хотя Эйлер не пользовался этой техникой при доказательстве малой теоремы Ферма, она полезна для понимания его логики. Удачной аналогией арифметики по модулю могут служить стандартные часы с 12 делениями. Если часы показывают 10, и вы собираетесь сделать что-то, занимающее 5 часов, то закончите в 3 часа. То есть в каком-то смысле 10 + 5 = 3. Если быть точным, то (10 + 5) mod 12 = 3. (Кстати, математик назвал бы полдень «нулем».) Разумеется, модуль может быть любым. Вот несколько примеров в арифметике по модулю 7. (6 + 4) mod 7 = 3 (3 х 3) mod 7 = (3 + 3 + 3) mod 7 = 2
70 ♦ Зарождение современной теории чисел Отметим, что в последнем случае можно было бы вычислить произведение в обычном смысле, а затем выразить его в терминах частного и остатка от деления на модуль: (ЗхЗ) = 9 = (1х7) + 2. Иными словами, значение по модулю п эквивалентно остатку от деления на п. В элементарной арифметике (например, в арифметике рациональных чисел), если произведение двух членов х и у равно 1, то говорят, что они сокращаются, а числа х и у называются взаимно обратными. То же самое справедливо и для арифметики по модулю, только оба взаимно обратных числа целые. Например, (2 х 4) mod 7 = 1, поэтому 2 и 4 сокращаются и являются взаимно обратными. Отрицательное число х mod п равно положительному числу п - х\ это деление, на которое вы попали бы, «переведя стрелки назад» нах часов. В частности, -1 mod п = п - 1. Как и в элементарной арифметике, мы можем составить таблицы умножения в арифметике по модулю. Вот как выглядит такая таблица по модулю 7: х 1 2 3 4 5 6 1 2 3 4 5 . 6_ 2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 5 3 1 6 4 2 6 5 4 3 2 1 Сначала мы выражаем произведение в виде частного и остатка от деления на 7; произведение по модулю будет равно остатку. Например, 5x4 = 20 = (2x7)+ 6 = 6 mod 7, поэтому на пересечении строки 5 и столбца 4 мы находим число 6. Заметьте, что все строки являются перестановками друг друга и что в каждой строке есть число 1. Напомним, что если произведение равно 1, то сомножители называются взаимно обратными. Например, в таблице выше мы видим, что 2 и 4 взаимно обратны, потому что 2x4=1 mod 7. В приведенном ниже варианте той же таблицы в самом правом столбце показано число, обратное соответствующему множителю. х 1 2 3 4 5 6 1 2 3 4 5 6 2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 5 3 1 6 4 2 6 5 4 3 2 1 Формально говоря, для целого п > 1 и целого и > О мы говорим, что число v является обратным и относительно умножения по модулю п, если существует такое целое д, что uv = 1 + qn. Иными словами, uuv являются взаимно обратными, если их произведение дает остаток 1 при делении на п. Мы часто будем пользоваться этим в последующих доказательствах. * * * Следующий результат опирается на такое обобщенное понятие обратимости. Лемма 5-2 (правило обратимости). Еслир простое, то для любого О < а < р существует такое О < Ь <р} что ab = mp + 1.
Сокращение ♦ 71 Иными словами, а и Ь взаимно обратны по модулю р. Пример. Снова возьмем р = 7 и а = 4. Существует ли число Ь} удовлетворяющее уравнению ab = тр + 1? Будем перебирать все значения 6, пока не найдем подходящее: Ъ=\ 4- 1-7/И+1? Нет. b = 2 4-2 = 7га+1? Да, при от =1. Доказательство. По лемме о перестановке остатков мы знаем, что одно из произведений в множестве а-{1,...,р- 1} при делении нар дает остаток 1. В данном случае мы имеем/? - 1 различных остатков, больших нуля и меньших;?, так что один из них должен быть равен 1. Поэтому должно существовать число 6, обратное а. Отметим, что 1 ир - 1 - самообратимые элементы, то есть результат их умножения на себя равен 1 по модулю р, или, что эквивалентно, может быть представлен в виде тр+1. Очевидно, что 1 • 1 можно представить в таком виде: Ор + 1. А как насчет р - 1? (р - I)2 =р2 - 2р + 1 = (р - 2)р + 1 = тр + 1. На самом деле 1 ир - 1 - единственные самообратимые элементы, что мы сейчас и покажем. Лемма 5.3 (правило самообратимости). Для любого 0<a<pa2 = mp+l=>a=lVa=p-l. Доказательство. Предположим, что существует самообратимый элемент а} отличный от 1 и от р - 1: аФ1лаФр-1=>Ка<р-1. Перепишем условие леммы в виде а2 - 1 = тр. Разложим выражение в левой части на множители: (а- 1)(я+ 1) = тр. Но, по нашему предположению, 0<а-1,а+1<р, то есть мы имеем произведение двух целых чисел, меньших ру которое делится на ру что противоречит предложению Евклида VII, 30 (см. стр. 67). Следовательно, наше предположение неверно, и самообратимыми являются только элементы 1 ир - 1. Мы почти готовы доказать малую теорему Ферма, но нужен еще один результат: теорема Вильсона, о которой сообщил Эдвард Уоринг в 1770 году, отдав честь открытия своему ученику Джону Вильсону Одновременно Уоринг посетовал, что не может доказать теорему, так как не располагает подходящими идиомами, - в ответ на что Гаусс позже заметил «Нужны идеи, а не идиомы!».
72 ♦ Зарождение современной теории чисел Теорема 5.5 (теорема Вильсона). Еслир простое, то существует такое целое т, что (р - 1)! = тр + (р - 1), или, иными словами: (р - 1)! = (р - 1) mod p. Доказательство. По определению (р-1)! = 1-2.3...(р-1). По правилу обратимости, для любого числа а между 1ир - 1 существует обратное ему число Ь из того же диапазона. По правилу самообратимости, обратны сами себе только числа 1 ир - 1. Поэтому любой сомножитель произведения, кроме 1 и р - 1, сокращается с каким-то другим, то есть их произведение дает остаток 1 при делении на р. Иначе говоря, произведение всех сокращающихся членов, то есть всех членов, кроме 1 ир - 1, можно представить в виде пр + 1 для некоторого п. Не сократились только члены 1 ир - 1, так что произведение принимает вид: (р-1)! = 1-(ир+1)-(р-1) = пр -р -пр+ р - 1 = (пр - п)р + (р - 1). Положив т = пр - п, мы докажем теорему Упражнение 5.1. Докажите, что если п > 4, то (п - 1)! кратно п. 5.4. Доказательство малой теоремы Ферма Вот теперь, воспользовавшись только что полученными результатами, мы можем доказать малую теорему Ферма: Если р простое, то ар~х -1 делится на р для любого 0 < а<р. Доказательство. Рассмотрим выражение |~| аг. Если вынести все множители а за знак произведения, то получится р-1 Р-\ Y{ai = ap-lY[i. (5 2) Теорему Вильсона можно записать в виде: Р-\ J~|i = (p-l) + rap. /=i Подставив это в равенство (5.2), получим
Доказательство малой теоремы Ферма ♦> 73 Y[ai = ар'\(р -1) + тр) = ар-1р-ар-'+ар~1тр = (ар-{+ар-]т)р-ар-\ (5.3) Теперь вернемся к выражению |~| ш. Это произведение содержит все члены {а, 2а, За, ..., (р - 1)а}, а по лемме о перестановке остатков это то же самое, что {qxp + rv ..., qv_$ + г J. Поэтому можно записать /7-1 /7-1 /=1 /=1 Раскрыв скобки в правой части, мы получим сумму, содержащую много членов ср, и один, равный произведению всех г.. Сгруппируем все слагаемые, в которые входит р, вместе, получится некое кратное up. Кроме него, останется произведение всех ц /7-1 Р-\ Y[ai = up + Y[ri- Теперь еще раз применим теорему Вильсона к произведению справа и снова сгруппируем кратные р: ,7-1 Y\ai = up + vp + (p-l) /=i = wp-l, (5.4) где w = u + v+ 1. Правые части равенств (5.3) и (5.4) равны, и нужно произвести лишь простые преобразования: wp - 1 = (ар~х + ар~хт)р - ар~иу ар~х + wp- 1 = (ар~х + ар~хт)р\ ар-\ _ \ = (a/>-i + ар-]т)р - wp. Еще раз сгруппировав кратные р в правой части, мы получаем требуемый результат: ар~х - 1 = пр. Таким образом, ар~х - 1 делится нар. Заметим также, что число ар~2 является обратным к а, потому что ар~2 - а = ар~\ что, согласно малой теореме Ферма, равно тр + 1. (Напомним, что два числа являются взаимно обратными по модулю р, если их произведение дает остаток 1 при делении нар.) * * * А что можно сказать об утверждении, обратном малой теореме Ферма? Для его доказательства нам понадобится еще один промежуточный результат.
74 ♦ Зарождение современной теории чисел Лемма 5.5 (лемма о необратимости). Если п =uv Au,v> 1,тои не является обратимым по модулю п. Доказательство. Пусть п = uv и w - число, обратное и (то есть wu = mn +1). Тогда wn = wuv = (тп + l)v = mvn + v. wn - mvn = v. Поэтому если положить z = (w - mv)y то (w - mv)n = zn = v. Поскольку п > v, то zn > v, что противоречит равенству zn = v. Поэтому у и не может быть обратного элемента. Определение 5.1. Числа тип называются взаимно простыми, если gcd(m, n) = = 1. Иначе говоря, тип взаимно простые, если у них нет общих делителей, больших 1. По лемме о необратимости, в арифметике по модулю п, где число п непростое, бывают как обратимые, так и необратимые элементы; те элементы, которые не являются взаимно простыми с п} необратимы. Теорема 5.6 (обращение малой теоремы Ферма). Если для любого а,0<а<п ап~{ = 1 + qan, то п простое. Доказательство. Допустим, что п непростое, тогда п = uv. По лемме о необратимости отсюда следует, что и необратимо. Но по условию теоремы ип~] = ип~2и = = 1 + qun. Иначе говоря, ип~2 является обратным и - противоречие. Стало быть, п должно быть простым. 5.5. Теорема Эйлера Как всякий великий математик, Эйлер не удовлетворился одним лишь доказательством малой теоремы Ферма, он хотел понять, можно ли ее обобщить. Малая теорема Ферма справедлива только для простых чисел, поэтому Эйлера заинтересовало, существует ли аналогичный результат для составных чисел. Но в арифметике по составному модулю могут происходить странные вещи. Для иллюстрации рассмотрим таблицу умножения по модулю 10, дополнив ее правым столбцом, в котором будет выписывать число, обратное множителю в левом столбце.
Теорема Зйлера ♦ 75 1 1 2 3 4 5 6 7 8 9 2 2 4 6 8 0 2 4 6 8 3 3 6 9 2 5 8 1 4 7 4 4 8 2 6 0 4 8 2 6 5 5 0 5 0 5 0 5 0 5 6 6 2 8 4 0 6 2 8 4 7 7 4 1 8 5 2 9 6 3 8 8 6 4 2 0 8 6 4 2 9 9 8 7 6 5 4 3 2 1 X 1 2 3 4 5 6 7 8 9 Эта таблица очень напоминает обычную таблицу умножения 10x10, только от каждого произведения мы оставляем лишь последнюю цифру. Например, 7x9 = = 63, или 3 mod 10. Бросаются в глаза отличия от таблицы для простого числа 7 (стр. 70). Во-первых, строки больше нельзя получить друг из друга перестановкой. И что еще важнее, некоторые строки содержат 0. В случае умножения это проблема - как произведение двух чисел может быть равно 0? Это означает, что, однажды получив при умножении 0, мы уже не сможем от него избавиться - произведение результата на что угодно даст нуль. В случае простого модуля р мы также видели, что самообратимыми являются только элементы 1 ир - 1; это верно и для модуля 10, но отнюдь не для любого составного модуля (например, по модулю 8 есть четыре самообратимых элемента: 1,3,5,7). Взглянем еще раз на таблицу умножения по модулю 10, сосредоточив внимание на некоторых ее элементах. X hi 2 |з| 4 5 6 М 8 ш 1 1 2 3 4 5 6 7 8 9 2 2 4 6 8 0 2 4 6 8 3 3 6 9 2 5 8 1 4 ■;Х 4 4 8 2 6 0 4 8 2 6 5 5 0 5 0 5 0 5 0 5 6 6 2 8 4 0 6 2 8 4 7 ш 4 1 8 5 2 9 6 з 8 8 6 4 2 0 8 6 4 2 9 щ 8 7 6 5 4 Щ 2 %■■ 9 Строки, содержащие только «хорошие» произведения (без нулей), - те, для которых первый сомножитель в левом столбце обведен рамочкой, - являются также
7Б ♦> Зарождение современной теории чисел строками, для которых у этого сомножителя имеется обратный элемент (показан в правом столбце). И какие же строки обладают таким свойством? Те, в которых первый сомножитель взаимно прост с 10 (напомним, что два числа являются взаимно простыми, если у них нет общих делителей, больших 1). А нельзя ли просто использовать только хорошие строки, игнорируя остальные? Не совсем, потому что некоторые результаты в хороших строках приводят к плохим строкам, если умножить их на что-то другое (например, 3 - хорошая строка, но (3 х 5) х 2 = 0). Идея Эйлера заключалась в том, чтобы использовать только числа на пересечении хороших строк и хороших столбцов - им соответствуют серые клетки. Отметим, что эти числа обладают всеми замечательными свойствами простых чисел: числа в серых клетках каждой строки образуют перестановки, каждое множество таких чисел в одной строке содержит 1 и т. д. * * * Для обобщения малой теоремы Ферма на составные модели Эйлер использовать только числа, выделенные полужирным шрифтом. Сначала он определяет размер множества взаимно простых чисел. Определение 5-2. Функцией Эйлера целого положительного числа п называется количество целых положительных чисел, меньших п и взаимно простых с п. Она определяется формулой: ф(/?) = |{0 < г < п A coprime(z, n)}\. §(п) дает количество строк, содержащих серые клетки, в таблице умножения по модулю п. Например, как видно из приведенных выше таблиц умножения, ф(10) = 4,аф(7) = 6. Поскольку у простых чисел по определению нет простых общих делителей с меньшими числами, то функция Эйлера простого числа равна ф(р)=р-1. Иначе говоря, все числа, меньшие простого числа, взаимно обратны с ним. Эйлер понял, что показатель степени р - 1 в теореме Ферма - просто частный случай, а именно значение ф для простых чисел. А обобщение Эйлера малой теоремы Ферма формулируется следующим образом. Теорема 5.7 (теорема Эйлера). coprime(<2, n) <=> аф(77) -1 делится на п. Упражнение 5.2. Докажите теорему Эйлера, модифицировав доказательство малой теоремы Ферма. Вот перечень необходимых шагов: О Заменить лемму о перестановке остатков леммой о перестановке взаимно простых остатков (по существу, доказательство то же самое, только рассматривать надо лишь «хорошие» элементы). О Доказать, что у каждого взаимно простого остатка есть обратный элемент относительно умножения (выше мы показали, что остатки образуют перестановки, поэтому 1 должна встречаться где-то в перестановке).
Теорема Эйлера ♦ 77 О Использовать произведение всех взаимно простых остатков там, где в доказательстве малой теоремы Ферма фигурирует произведение всех ненулевых остатков. * * * Хотелось бы уметь вычислять функцию ф для любого целого числа. Поскольку мы можем выразить любое целое число в виде произведения степеней простых чисел, то начнем с вычисления функции Эйлера для произвольной степени простого числа р. Мы хотим знать, сколько есть чисел, взаимно простых cpw. Нам известно, что их заведомо не больше;/77 - 1, потому что ровно столько существует положительных целых чисел, меньших рт. Кроме того, мы знаем, что числа, которые делятся нар, не являются взаимно простыми, поэтому их количество нужно вычесть: <Ь(рт) = (р'"-1)-\{р,2р,->Рт-р}\ = (p"-l)-|{U...lp-,-l}| =(pm-l)-(pm-1-l) = р А чему равно ty(p"q"), где р и q - простые числа? Снова начнем с максимально возможного количества и вычтем все кратные. То есть мы должны сначала вычесть все кратныер и все кратные q, а затем прибавить кратные одновременно р и q, иначе они будут учтены дважды. (Этот общий прием, известный под названием принцип включения-исключения, часто применяется в комбинаторике.) Пусть п =p"qv: ф(га) = («-!)- *-1 ^Р п Л ( п --1 —-1 + \ря j п п = п + - р q pq ( = п = п \ 1---- + — V Р Я РЯ, '( 1- П р 1 с 1- = п\\- 1 ( =р" 1-1 . Р) V 1-1] Я) с р я1 1— V Я) = ФСр"Ж?").
78 ♦ Зарождение современной теории чисел Теперь мы знаем, что в частном случае произведения двух простых чисел р, ир2 Ф(№) = Ф(Р])Ф<>2)- (5-5) Например, поскольку 10 = 5 х 2, то ф(10)-ф(5)ф(2)-4. И хотя нас сейчас интересует именно этот результат, формулу можно обобщить на произведение произвольного количества степеней простых чисел. Например, в случае трех множителей p,qnr мы бы вычли кратные каждого из них, затем добавили счетчики попарных кратных pq, prnqrn напоследок еще раз вычли кратные pqr, чтобы «компенсировать излишнюю компенсацию». Обобщая на случай т простых множителей, мы приходим к следующей формуле (здесь п = J^^pf'"): ( т \ Ф(И) = Ф Ш' V /=1 ) 1— V PiJ =*п /=1 т =Пф(^) /=i Заинтересовавшись доказательством теоремы, Эйлер пришел к необходимости подсчитать количество взаимно простых чисел. А функция ф дала ему инструмент для эффективного вычисления этой величины в случае, когда известно разложение на простые множители. 5.Б. Применение арифметики по модулю В разделе 5.3 мы видели, как умножение по модулю связано с остатками. Обратимся еще к двум полученным ранее результатам и посмотрим, как выглядят примеры в арифметике по модулю 7. Теорема Вильсона утверждает, что для простого р существует такое т, что (р~ 1)! = (р- 1) + тр. По-другому это можно записать в виде (р- 1)! = (р - l)modp. Посмотрим, подтверждается ли этот результат для р = 7.р - 1 равно 6, поэтому разложим 6! на множители, сгруппируем их и с помощью таблицы умножения по модулю произведем сокращения: 6! =1x2x3x4x5x6 = 1х(2х4)х(3х5)х6 = (1x1x1x6) mod 7 = 6 mod 7.
Заключительные мысли ♦ 79 В полном соответствии с теоремой Вильсона. Аналогично воспользуемся умножением по модулю, чтобы проверить справедливость малой теоремы Ферма. В исходном виде она звучит так: Еслир простое, то аР'1 - 1 делится нар при любом 0 < а<р. Но, используя арифметику по модулю, мы можем перефразировать это утверждение: Если р простое, то ар~х -1 = 0 mod р при любом 0<а<р. или Если р простое, то аР~х = 1 mod p при любом 0<а<р. Снова возьмем р = 7 и проверим для а = 2. На этот раз мы развернем степень в произведение, умножим обе части на 6!, а затем воспользуемся \ лножение\ по модулю для сокращения взаимно обратных членов: 26 = (2х2х2х2х2х2); 26х6! = (2 х2х2х2х2х 2) х (1x2x3x4x5x6) = (2 х 1) х (2 х 2) х (2 х 3) х (2 х 4) х (2 х 5) х (2 х 6) = (2x4x6x1x3x5) mod7 = (1x2x3x4x5x6) mod7 = 6! mod 7; 26 = 1 mod 7, то есть именно то, что и утверждает малая теорема Ферма. 5.7. Заключительные мысли Выше мы видели, что древние греки проявляли интерес к совершенным числам. Никакой практической ценности это не имело - им просто было любопытно исследовать свойства некоторых категорий чисел. Но в этой главе мы убедились, что со временем поиск «бесполезных» совершенных чисел привел к открытию малой теоремы Ферма - одной из самых полезных теорем во всей математике. Почему она так полезна, мы узнаем в главе 13. В этой главе мы также впервые познакомились с процессом абстрагирования в математике. Эйлер проанализировал малую теорему Ферма и понял, что ее можно обобщить с частного случая (простые числа) на более общий (все целые числа). Он увидел, что показатель степени в теореме Ферма - частный случай более общего понятия, количества взаимно простых чисел. Такой же процесс абстрагирования лежит и в основе обобщенного программирования. Обобщение кода сродни обобщению теорем и их доказательств. Как Эйлер увидел возможность обобщить результат Ферма с одного типа математических объектов на другой, так и программист может взять функцию, спроектированную для одного типа программных объектов (скажем, векторов), и обобщить ее, так чтобы она с тем же успехом работала и для других объектов (скажем, связанных списков).
Глава \J Абстракция в математике Математик изучает не предметы, но лишь отношения между предметами, следовательно, для него вполне безразлично, будут ли данные предметы замещены какими-нибудь другими, лишь бы только не изменились при этом их отношения. Содержание не привлекает его внимание, ему интересна лишь форма. Пуанкаре. «Наука и гипотеза» История математики изобилует открытиями новых абстракций: поиском способов решения более общей задачи. Так, в главе 5 мы видели, как Эйлер обобщил малую теорему Ферма, так чтобы она была применима не только к простым, но и к составным числам. В конце концов, математики осознали, что можно выйти за пределы чисел и получать результаты об абстрактных сущностях, называемых алгебраическими структурами, - наборах объектов, удовлетворяющих определенным правилам. Это привело к развитию совершенно новой ветви математики - общей алгебры. В этой главе мы познакомимся с первыми примерами таких абстрактных сущностей и докажем некоторые их свойства. Как и в предыдущей главе, мы временно отвлечемся от программирования и займемся построением фундамента, на котором в главе 7 возведем здание обобщенного алгоритма. Б.1. Группы Первой и самой важной алгебраической структурой стали группы, открытые французским математиком Эваристом Галуа в 1832 году. Определение 6.1. Группой называется множество, на котором определены: операции: jo^r1 константа: е таким образом, что выполняются следующие аксиомы: х о (у о z) = (х о у) о z хо е = е ° х = х х о х-1 = х-1 о х = е ассоциативность нейтральность сокращение
Группы ♦ 81 Константа е называется нейтральным элементом (иногда его обозначают id) и в мультипликативном контексте часто записывается как 1. Операция х-1 называется обращением; применение групповой операции к элементу и обратному к нему дает нейтральный элемент, как следует из последней аксиомы. Групповая операция бинарна, то есть принимает два аргумента (никакого отношения к двоичному представлению чисел в компьютерах это не имеет). Символ о (который часто записывают как *) может представлять произвольную бинарную операцию, лишь бы она удовлетворяла аксиомам группы. Часто групповую операцию трактуют как умножение и даже говорят об «умножении» двух элементов группы, хотя в действительности имеется в виду групповая операция, в чем бы она ни состояла. Как и в случае умножения, символ операции часто опускают, то есть х о у можно записать в виде ху, а х о % = хх = х2. Групповая операция необязательно коммутативна (коммутативность означает, что Vx, у: х о у = у о х). Если нам нужна коммутативность, то следует определить специальный вид группы. Определение 6.2. Абелевой называется группа, в которой операция коммутативна. Частным случаем абелевой группы является аддитивная группа. Определение 6.3. Аддитивной называется абелева группа, в которой операцией является сложение. В аддитивных группах применяются другие соглашения об обозначениях. Операция обозначается символом +, а нейтральный элемент - символом 0. И хотя в названии «аддитивная группа» коммутативность не упоминается, предполагается, что она коммутативна. Группы замкнуты относительно своей операции. Это означает, что применение операции к любым двум элементам группы дает элемент той же группы. Точно так же группы замкнуты относительно обращения: элемент, обратный любому элементу группы, также принадлежит этой группе. Приведем несколько примеров групп. О Аддитивная группа целых чисел: элементами являются целые числа, а операцией - сложение. О Мультипликативная группа ненулевых остатков по модулю 7: элементами являются числа от 1 до 6, а операцией - умножение по модулю 7. О Группа перетасовок колоды карт: ее элементами являются перестановки множества карт, а операцией - композиция таких перестановок. О Мультипликативная группа обратимых матриц (с ненулевым определителем) с вещественными элементами: ее элементами являются матрицы, а операцией - умножение матриц. О Группа вращений плоскости: элементами являются повороты вокруг начала координат, а операцией - композиция таких поворотов.
82 ♦ Абстракция в математике Отметим, что целые числа не образуют мультипликативную группу потому что число, обратное целому числу в большинстве случаев не является целым. Иначе говоря, множество целых чисел не замкнуто относительно обращения операции умножения. Упражнение 6-1. Для скольких целых чисел обращение относительно умножения также является целым? Что это за числа? Рассмотрим один пример более пристально. В главе 5 была приведена таблица умножения по модулю 7. х 1 2 3 4 5 6 1 2 3 4 5 6 2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 5 3 1 6 4 2 ~б~| 5 4 3 2 1 Различные числа, встречающиеся в этой таблице, {1, 2, 3, 4, 5, 6} - они называются также «ненулевыми остатками по модулю 7», как мы видели выше, образуют мультипликативную группу. Что это означает? Поскольку группа мультипликативная, операцией в ней является умножение, а нейтральным элементом - 1. Глядя на первую строку и первый столбец таблицы, легко видеть, что 1 - действительно нейтральный элемент, потому что при умножении 1 на любой элемент х получается х. Поскольку группа замкнута относительно своей операции, перемножение любых двух элементов группы дает снова элемент той лее группы. Например: 2 о 5 = (2 х 5) mod 7 = 3; 4 о 3 = (4 х 3) mod 7 = 5; 5 о 2 = (5 х 2) mod 7 = 3. Ассоциативность и коммутативность умножения по модулю следуют из ассоциативности и коммутативности умножения целых чисел. Коммутативность, или абелевость, группы становится очевидной, если заметить, что таблица умножения симметрична относительно главной диагонали. Поскольку любая группа замкнута относительно обращения, то, взяв обратный элемент для любого элемента группы, мы получим другой элемент группы. (Напомним, что обратным для элемента х называется элемент, который при умножении на х дает 1. Имея таблицу умножения, пары взаимно обратных элементов можно найти, рассмотрев клетки, содержащие 1.) Например: 21 = 4 mod 7; 4"1 = 2 mod 7; 5"1 = 3 mod 7.
Моноиды и полугруппы ♦ 83 Эварист Галуа (1811 -1832) Начало теории групп было положено в работе Эва- риста Галуа, в юном возрасте исключенного из французского коллежа и примкнувшего к революционному движению. Это самая романтичная фигура в истории математики. В начале XIX века дух романтизма распространился в Европе; молодые люди боготворили английского поэта Байрона, погибшего, сражаясь за независимость Греции, и других героев, желавших отдать жизнь за правое дело. Наполеон, по их представлениям, был не тираном, а юным героем, положившим конец феодализму в Европе. В начале 1830-х годов Париж был объят пламенем революционной деятельности. Галуа, идеалист и забияка, пристал к революционному движению. Романтики мятежник, Галуа не захотел идти традиционным путем получения университетского образования. Не сумев поступить в одну школу и будучи исключен из другой, он начал изучить математику самостоятельно и стал специалистом по лагранжевой теории полиномов. Не раз его приговаривали к кратким тюремным срокам за разного рода протесты, например маршировку по улицам города в форме запрещенной национальной гвардии и с заряженным оружием, но и в тюрьме он продолжал заниматься математикой. В возрасте 20 лет Галуа, вступившись за честь дамы, с которой был едва знаком, послал (или получил) вызов на дуэль. В ночь перед дуэлью, будучи уверен в неминуемой гибели, он сочинил длинное письмо другу с описанием своих математических идей. В этой рукописи содержались зачатки теории групп, полей и их автоморфизмов (отображений на себя). Эти идеи легли в основу новой отрасли математики - общей алгебры. По словам математика Германа Вейля, «это письмо, если оценивать его по новизне и глубине содержащихся идей, является, быть может, самым значительным письменным памятником во всей истории литературы». На следующий день Галуа стрелялся на дуэли и умер от полученных ран. По странной иронии судьбы, в политике он лишь играл роль революционера, а в математике стал истинным революционером. Б.2. Моноиды и полугруппы Иногда нас интересуют алгебраические структуры, к которым предъявляется меньше требований, чем к группам (с некоторыми их приложениями мы познакомимся в следующей главе). Например, нам не всегда нужна операция обращения, но другие свойства группы хотелось бы сохранить. Такая структура называется моноидом. Определение 6-4. Моноидом называется множество, на котором определены: операция: х о у константа: е таким образом, что выполняются следующие аксиомы: х о (г/ о z) = (х о у) о z хо е = е о х = х ассоциативность нейтральность
84 ♦> Абстракция в математике Это определение буквально повторяет определение группы с тем исключением, что мы убрали операцию обращения и аксиому сокращения, в которой она используется. Как и в случае групп, мы можем определить специальные виды моноидов, конкретизировав операцию, например аддитивный моноид (в котором операцией является сложение) и мультипликативный моноид (где операцией является умножение). Приведем несколько примеров моноидов. О Моноид конечных строк (свободный моноид): элементами являются строки, операцией - конкатенация строк, нейтральным элементом - пустая строка. О Мультипликативный моноид целых чисел: элементами являются целые числа, операцией - умножение, нейтральным элементом - 1. Мы можем еще ослабить эти требования, отказавшись от нейтрального элемента. Тогда получится полугруппа. Определение 6-5- Полугруппой называется множество, на котором определена операция: х о у таким образом, что выполняется следующая аксиома: х о (у о г) = (х о у) о z ассоциативность И на этот раз мы просто убрали кое-что из предыдущего определения - в данном случае требование о наличии нейтрального элемента и аксиому, в которой он используется. Как и раньше, мы можем определить аддитивные и мультипликативные полугруппы. Приведем несколько примеров полугрупп. О Аддитивная полугруппа положительных целых чисел: элементами являются положительные целые числа, операцией - сложение. О Мультипликативная полугруппа четных чисел: элементами являются четные числа, операцией - умножение. Как мы уже отмечали, при повторном применении операции - будь то полугруппа, моноид или группа - употребляются такие же соглашения, как при обычном умножении, например: х о х о х = ххх = х3. Более формально возведение в степень в полугруппу определяется следующим образом: х°=\Х есшп = 1 . (6.1) [ххп 1 в противном случае Упражнение 6-2. Почему нельзя определить степень в полугруппе, начав с п = О? Согласно формуле (6.1), полугрупповая операция применяется слева (то есть ххп~\ а не х?~]х). А что, если применять ее с другой стороны? Тоже можно - и мы это сейчас докажем.
Моноиды и полугруппы ♦ 85 Лемма 6-1. Для п>2 хг""1 = х?г~{х. Доказательство. Мы докажем это утверждение по индукции1. База: п = 2. Очевидно, поскольку «y/y-l ^= 'VV ^— *)Г *)Г Допустим теперь, что это утверждение верно при п = k - 1: rr(£-1)- 1 = y-Yik-2) = ^/е-2)^ = yik-\)- lr и докажем, что оно верно также при п = k: xx(k-{) = х^хук-?.} по определению степени = x(xk~2x) по предположению индукции = (xxk~2)x в силу ассоциативности полугрупповой операции = {х^~х)х по определению степени Хотя полугруппа гарантирует только ассоциативность, но не коммутативность операции, оказывается, что степени любого элемента коммутируют, и это обобщение полученного выше результата легко доказать. Пожалуй, это самый важный результат, касающийся полугрупп. Теорема 6-1 (коммутативность степеней), х^х^ = х^х" = х"+т. Доказательство. Докажем индукцией по т. База: т= 1. х?1х = ххп по лемме 6.1 = х*1+{ по определению степени Шаг индукции. Предположим, что утверждение верно при т = k, и докажем, что оно верно также при т = k + 1: ^г^+i = ^(хд^) по определению степени = (xflx)xfi в силу ассоциативности полугрупповой операции = хР^х?1 по лемме 6.1 и по определению степени = x"+x+k по предположению индукции = x"+k+1 в силу коммутативности операции сложения Мы показали, что хпхт = xfl+m. Следовательно, верно и то, что xwxn = х?п+п. Так как сложение целых чисел коммутативно, то хР+т = х^+п, поэтому хРх"1 = xfnxf1. Полугруппа - самая слабая из интересных алгебраических структур. Единственное требование, которое можно ослабить, - аксиома ассоциативности. Если опустить ее, получится алгебраическая структура, называемая магмой, но она не слишком полезна. Поскольку не осталось никаких аксиом, то нельзя доказать никаких теорем. 1 Желающие освежить в памяти этот метод доказательства могут обратиться к приложению В.2.
86 ♦ Абстракция а математике Б.З. Некоторые теоремы о группах Теперь вернемся к группам и рассмотрим некоторые их свойства. Важное наблюдение заключается в том, что все группы являются группами преобразований. Другими словами, каждый элемент а группы G определяет преобразование G на себя: Например, в случае аддитивной группы целых чисел, если взять а = 5, получится операция «+5», которая преобразует каждый элемент х в элемент х + 5. Такие преобразования взаимно однозначны в силу аксиомы сокращения: а~\ах) ■— х. В нашем примере для обращения преобразования «+5» нужно применить преобразование, индуцированное обратным элементом -5. Теорема 6.2- Преобразование группы - взаимно однозначное соответствие1. Иначе это можно выразить, сказав, что для любого конечного множества S элементов группы G и любого элемента а, принадлежащего G, в множестве aS столько же элементов, сколько в S. Доказательство. Если S = {sv ..., sj, то aS = {asv ..., asn}. Мы знаем, что множество aS не может содержать больше различных элементов, чем S, но не может ли их быть меньше? (Так было бы, если бы какие-то два элемента S отображались в один и тот же элемент aS.) Предположим, что в aS есть два одинаковых элемента: as: = aSj. Тогда a~\as^) = a'^aSj) (a~]a)si = (a~{a)Sj в силу ассоциативности es{ = eSj в силу сокращения st = Sj в силу нейтральности Таким образом, если два результата преобразования ask равны, то их прообразы sk должны быть равны. Иначе говоря (контрапозиция предыдущего утверждения): если два элемента не равны, то и их образы при преобразовании не будут равны. Поскольку мы начали с п различных элементов, то получим п различных результатов. То есть в множестве aS столько же элементов, сколько в S. Приведем еще несколько простых результатов о группах. Теорема 6.3- Для каждого элемента существует только один обратный элемент. аЬ = е => Ъ = а~{. Доказательство. Предположим, что ah = е. Умножим обе части этого равенства на а-1 слева: Взаимно-однозначным соответствием называется отображение на, которое одновременно является взаимно-однозначным.
Некоторые теоремы о группах ♦ 87 аЬ = е\ a~\ab) = агхе\ (а~ха)Ь = а-1; еЪ = а~х\ Ь = аг\ Теорема 6.4. Элемент, обратный произведению, равен произведению обратных элементов в противоположном порядке. (ab)-{ = Ь-ха-\ Доказательство. Выражения равны тогда и только тогда, когда умножение одного на элемент, обратный другому, дает нейтральный элемент. Воспользуемся элементом, обратным (ab)~\ который по определению равен (ab), и умножим его на b~la~[\ (ab)(b-xa-x) = a(bb-x)a-' = аагх = е. Теорема 6.5. Степень обратного элемента равна элементу, обратному степени. {х~У = (х*)"1. Доказательство по индукции. База: п = 1. (x-i)i=x-i = (xi)-i Шаг индукции. Предположим, что утверждение верно при п = k - 1, то есть (х-1)*-1 = (я*-1)-1. Докажем, что оно верно и при п = k. Мы хотим показать, что (х-1)* = (xk)~\ то есть что обратным к х* является (х-1)*. Если это так, то при перемножении обоих элементов мы должны получить нейтральный элемент. Пользуясь определением степени и теоремой о коммутативности степеней, запишем х* в виде х*_1х, a (х-1)* в виде хгх{х~х)к~\ перегруппируем члены, так чтобы некоторые сократились, а затем подставим предположение индукции: xfXx~1)k = (xx*-])(x-\x-{y-{) = (^x)(x-{(x-{)k-x) = ^-х(хх-{)(х-ху-х = хк~х(х-{у-х = xk~x(xk-xy{ = е. Таким образом, (х")_1 = (х-1)". Упражнение 6.3 (очень легкое). Докажите, что в любой группе есть хотя бы один элемент. Определение 6.6. Если группа состоит из п > О элементов, то п называется порядком группы. Если в группе бесконечно много элементов, то ее порядок бесконечен.
88 ♦ Абстракция в математике Существует также понятие порядка элемента группы. Определение 6-7. Говорят, что элемент а имеет порядок п > О, если ап = е и для любого 0 < k < пу ak ф е (иными словами, порядок а - это наименьшая степень, при возведении в которую получается нейтральный элемент е). Если такого п не существует, то говорят, что порядок а бесконечен. Упражнение 6.4 (очень легкое). Чему равен порядок el Докажите, что е - единственный элемент, имеющий такой порядок. * * * Мы подошли к важной теореме о группах. Теорема 6-6- Каждый элемент конечной группы имеет конечный порядок. Доказательство. Если п - порядок группы, то для любого элемента а в множестве {а, а2у а3у..., ап+]} есть хотя бы два одинаковых элемента а1 и сб. Предположим, что 1 <i<j <п+ 1и что а} - первый повторяющийся элемент, а #> - его первое повторение. Тогда а1 = а1; aja~l = ala~l = e\ ан = е} и, следовательно, j - г > О - порядок а. В этом доказательстве используется вариант принципа Дирихле (подробнее о принципе Дирихле и его применении см. приложение В.З). Теорема гарантирует, что следующий простой алгоритм вычисления порядка элемента обязательно завершится: умножать элемент на себя, пока не получится е. Упражнение 6.5. Докажите, что если а - элемент порядка п} то а~х = ап~\ Б.4. Подгруппы и циклические группы Определение 6.8. Подмножество Я группы G называется подгруппой G, если еЕН'у а<ЕН=> а~{ G Я; aybeH^aobeH. Иными словами, чтобы быть подгруппой, Я должна быть одновременно подмножеством и группой. Из ассоциативности операции в G следует ее ассоциативность в Я, поэтому в этом определении не нужно явно постулировать ассоциативность. По тем же причинам не нужно явно повторять аксиомы нейтральности и сокращения. Например, аддитивная группа четных чисел является подгруппой аддитивной группы целых чисел, равно как и аддитивная группа чисел, кратных 5. У некоторых групп много подгрупп, но почти у всех есть по меньшей мере две: сама группа и группа, состоящая из одного элемента е. Эти две подгруппы называ-
Подгруппы и циклические группы ♦ 89 ются тривиальными (единственная группа, у которой нет даже двух подгрупп, - это группа, состоящая только из нейтрального элемента). Вернемся к мультипликативной группе {1, 2, 3, 4, 5, 6} ненулевых остатков по модулю 7 и таблице умножения в ней: х 1 2 3 4 5 6 1 2 3 4 5 6 2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 5 3 1 6 4 2 "б"| 5 4 3 2 1 У этой группы есть четыре мультипликативные подгруппы: {1}, {1,6}, {1,2, 4}, {1,2, 3,4, 5, 6}. Откуда мы это знаем? Чтобы быть подгруппой, нужно прежде всего быть подмножеством исходной группы. Понятно, что все перечисленные выше множества являются подмножествами {1, 2, 3, 4, 5, 6}. Далее, каждое подмножество содержит 1 (нейтральный элемент) и замкнуто относительно групповой операции (умножения по модулю 7) и операции обращения. Например, рассмотрим множество {1, 2, 4}: если умножить каждый его элемент на самого себя или на любой другой элемент произвольное число раз (по модулю 7), то произведение по-прежнему будет элементом множества. Упражнение 6.6. Найти порядок каждого элемента О мультипликативной группы остатков по модулю 7; О мультипликативной группы остатков по модулю 11. * * * Простейшим видом групп являются циклические группы. Определение 6.9. Конечная группа называется циклической, если в ней имеется такой элемент а, что для любого элемента Ь найдется целое число пу такое, что Ь = а". Иными словами, каждый элемент группы может быть получен возведением какого-то одного элемента в разные степени. Такой элемент называется порождающим; в группе может быть несколько порождающих элементов. Аддитивная группа остатков по модулю п - пример циклической группы. В примере группы остатков по модулю 7 порождающими являются элементы 3 и 5, потому что они не входят ни в какую нетривиальную подгруппу исходной группы.
90 ♦ Абстракция в математике Упражнение 6-7. Докажите, что любая подгруппа циклической группы циклическая. Упражнение 6-8- Докажите, что любая циклическая группа абелева. Лемма 6-2. Степени любого элемента конечной группы образуют подгруппу. Иначе говоря, каждый элемент конечной группы содержится в циклической подгруппе, порожденной этим элементом. Доказательство. Чтобы множество являлось подгруппой, оно должно быть непустым подмножеством группы и одновременно группой. Чтобы быть подмножеством, оно должно быть замкнуто относительно групповой операции. Рассматриваемое множество действительно замкнуто, потому что произведение любых степеней само является степенью. Чтобы быть группой, его операция должна быть ассоциативна, оно должно содержать нейтральный элемент и иметь обратную операцию. Ассоциативность операции, очевидно, наследует от исходной группы. Тот факт, что множество степеней данного элемента содержит нейтральный элемент, следует из теоремы 6.6, утверждающей, что порядок любого элемента конечной группы конечен1. А обратный элемент имеется потому, что для любой степени ak обратным к ней является степень an~k, где п - порядок а. Б.5. Теорема Лагранжа Общая алгебра замечательна тем, что мы можем доказывать результаты о таких структурах, как группы, ничего не зная ни о конкретных элементах группы, ни о ее операции. Чтобы продемонстрировать эту мысль, докажем для начала несколько простых результатов о смежных классах. Определение 6-10. Если G - группа и Я С G - подгруппа G, то для любого а Е G левым смежным классом аиоН называется множество aH={ge G\3heH:g=ah}. Иначе говоря, левый смежный класс аН - это множество всех элементов G, которые можно получить умножением элементов Я на а слева. В качестве примера рассмотрим аддитивную группу2 целых чисел Z и ее подгруппу чисел, кратных 4, 4Z (буква Z происходит от немецкого слова Zahlen, обозначающего «числа»). Она имеет четыре смежных класса: An, An + 1, An + 2 и 4п + 3. Прибавление любого другого целого числа даст значение, которое уже присутствует в одном из этих смежных классов; например, смежный класс An + 5 со- 1 Напомним определение: элемент а имеет порядок п, если ап = е. 2 Напомним, что в аддитивной группе роль группового «умножения» играет сложение. Поэтому смежный класс аН состоит из элементов G} которые молено получить сложением а с элементами Н.
Теорема Лагранжа ♦ 91 держит те же элементы, что смежный класс An + 1. (Левый смежный класс совпадает с правым в силу коммутативности сложения целых чисел.) Лемма 6-3 (размера смежных классов). В конечной группе G для любой ее подгруппы Яколичество элементов в смежном классе аНравно количеству элементов в самой подгруппе Я. Доказательство. Мы уже доказали, что отображение aS, где S - подмножество G, является взаимно однозначным соответствием. Поскольку подгруппа, по определению, является подмножеством, то отображение из Я в аН также является взаимно однозначным соответствием. Но если между двумя конечными множествами установлено взаимно однозначное соответствие, то их размер одинаков. Лемма 6.4 (полное покрытие смежными классами). Каждый элемент а группы G принадлежит какому-нибудь смежному классу подгруппы Я. Доказательство, a G аН. Это означает, что а принадлежит смежному классу аН, порожденному им самим, поскольку Я, будучи подгруппой, содержит нейтральный элемент. Лемма 6.5 (смежные классы не пересекаются или совпадают). Если у двух смежных классов аН и ЬН в группе G имеется хотя бы один общий элемент с, то аН = ЬН. Доказательство. Предположим, что общий элемент с равен aha в одном смежном классе и bhb в другом. aha = bhh. Умножив обе части справа на h~[, получим ahji? = bhbh~l] a = bhbh-{; a = b{hbh~axy Член в правой части равен й, умноженному на какой-то элемент Н (мы знаем, что он принадлежит Я, потому что hb и h~{ принадлежат Я, а Я, будучи подгруппой, замкнута относительно умножения). Теперь умножим обе части справа нах - произвольный элемент Н: ах = b(hbh~{)x. По определению, ах принадлежит смежному классу аН. Кроме того, член в правой части равен Ь, умноженному на какой-то элемент Я, и потому принадлежит смежному классу ЬН. Поскольку это можно проделать для любого х G Я, то аН Я ЬН. Точно такую же процедуру можно повторить с самого начала, взяв hbx вместо h~1. Тем самым мы покажем, что ЬН Я аН. Следовательно, ЬН = аН. Располагая этими результатами, мы можем сформулировать важную теорему из теории групп, которая иллюстрирует мощь абстрактных рассуждений. Если вы
92 ♦ Абстракция в математике хотите ограничиться какой-то одной теоремой из теории групп, возьмите эту Несмотря на простоту формулировки, она закладывает фундамент теории конечных Т>упп. Георема 6-7 (теорема Лагранжа). Порядок любой подгруппы Н конечной группы 3 делит порядок группы. Доказательство. 1. Группа G покрывается смежными классами Н (доказано в лемме 6.4). 2. Различные смежные классы не пересекаются (доказано в лемме 6.5). 3. Все они имеют одинаковый размер п, где п - порядок Н (доказано в лемме 6.3). В частности, это означает, что \аН\ = \Н\ для любого а, так что любой смежный класс имеет размер |#|, совпадающий с размером любого другого смежного класса. Поэтому порядок G равен пт, где т - количество различных смежных классов, а это означает, что порядок G делится на порядок Н. В качестве примера предположим, что группа G имеет два различных смежных класса по своей подгруппе Я. Каждый элемент G должен быть покрыт одним или другим классом (но не обоими), поэтому порядок Я должен быть равен |G| / 2. Интересно, что обращение теоремы Лагранжа неверно: если группа имеет порядок п, то необязательно, что для каждого делителя п имеется подгруппа соответствующего порядка. Жозеф Луи Лагранж (1736-1813) В конце XVIII века ведущим математиком Европы был Жозеф Луи Лагранж, ставший преемником Леонарда Эйлера как в плане интеллектуального превосходства, так и на посту руководителя физико-математического отделения Прусской академии наук в Берлине. Лагранж, родившийся в Турине, столице северной итальянской провинции Пьемонт, получил имя Джузеппе Луиджи Лагранджа. Это имя наводит на мысль, что в детстве он говорил по-итальянски, хотя семья заявляла о французских корнях. Лан- гранж открывал для себя математику в основном самостоятельно, обучаясь в Турине, и уже через несколько лет стал преподавателем и начал публиковать свои работы. В возрасте примерно 20 лет он стал переписываться с Эйлером, который в то время жил в Берлине. Находясь под глубоким впечатлением от работ Лагранжа, Эйлер согласился стать наставником юного математика и пропагандировать его открытия. Эйлер ходатайствовал о переводе Лагранжа в Берлин, но в 1766 году, когда этот план увенчался успехом, Эйлер уже вернулся в Россию. Поэтому Лагранж был назначен на должность своего наставника и вскоре получил признание как второй математик Европы. Следующие 20 лет Лагранж провел в Берлине, где выполнил свои самые значительные работы, внеся важный вклад в различные отрасли математики и физики. В своей «Аналитической механике», быть может, одной из 10 самых значимых книг во всей истории
Теорема Лагранжа ♦ 93 математики, он описал нисходящий подход к решению задач механики, более общий, чем применение механики Ньютона. Современная физика во многом опирается на труды Лагранжа. Он также занимался обширными исследованиями полиномиальных уравнений и пришел к выводу, что коэффициенты полинома можно выразить в виде функций от его корней, чем заложил основу для последующего прорыва Галуа. В теории чисел Лагранж установил, когда непрерывные дроби являются периодическими, дополнив более ранние работы Эйлера на ту же тему. После кончины патрона и друга Лагранжа, прусского короля Фридриха II, французский посол (действовавший по поручению короля Людовика XVI) стал убеждать великого математика перебраться во Францию. Лагранж согласился и прожил в Париже с 1786 до самой смерти. Несмотря на широчайшую известность, Лагранж был робким, скромным человеком, имел мало друзей и почти не участвовал в общественной жизни. Он был подвержен приступам депрессии и иногда годами почти не работал, особенно это характерно для первых лет жизни во Франции. Однако Французская революция пробудила в нем интерес к жизни, хотя он часто боялся, что придется бежать из страны, поскольку революционеры выдворяли многих иностранцев. Он принимал участие в разработке новой системы мер и весов и входил в комитет из пяти выдающихся ученых, проголосовавших за то, что мы теперь называем метрической системой. Лагранж снова начал заниматься преподаванием, хотя, по свидетельствам многих современников, не пользовался популярностью у студентов, не понимавших его идей и итальянского акцента. Позже Лагранж был отмечен новым императором Наполеоном Бонапартом, который и сам был математиком не из последних. Наполеон признал гений Лагранжа и по .ал i ему титул графа Французской империи. * * * Теперь докажем два следствия из теоремы Лагранжа. Следствие 6-7.1. Порядок любого элемента конечной группы делит порядок гр.; м . Доказательство. Степени элемента G образуют подгруппу G. Поскольку порядок элемента равен порядку этой подгруппы, а порядок подгруппы делит порядок группы, то порядок элемента делит порядок группы. (Напоминание: порядок элемента равен порядку циклической группы его степеней.) Следствие 6.7.2. Если группа G имеет порядок пи а- элемент G, то ап = е. Доказательство. Если порядок а равен т, то т делит п (согласно предыдущему следствию), то есть п = qm. ат = е (по определению порядка элемента). Следовательно, (am)q = е и ап = е. Отметим, что здесь не сказано, что порядок а равен п\ он может быть и меньше. Теорема Лагранжа позволяет доказать некоторые важные результаты из главы 5 проще, чем это было сделано раньше. Малая теорема Ферма. Если р простое, то аР~х - 1 делится на р при любом 0<а<р. Доказательство. Возьмем мультипликативную группу остатков по модулю р. Она содерлшт р - 1 ненулевых остатков. Поскольку порядок этой группы равен р - 1, то из следствия 6.7.2 сразу вытекает, что
94 ♦ Абстракция в математике а?-] = е. Поскольку нейтральным элементом в мультипликативной группе служит 1 (точнее, 1 mod/? в группе остатков по модулю), имеем ар~л = 1 modp; ар-\ - 1 = 0 modp, а это и означает делимость нар. Теорема Эйлера. Если аип - взаимно простые числа, причем 0<а<п,то аф(/° - 1 делится на п. Доказательство. Возьмем мультипликативную группу обратимых остатков по модулю п. Поскольку ф(/2), по определению, равно количеству взаимно простых с п чисел и каждое взаимно простое число обратимо, то §(п) оказывается равным порядку этой группы. Из следствия 6.7.2 сразу вытекает, что а^ = е\ яФОО = 1 moci п> или, эквивалентно, дФ(я) _1 = о mod /2. Логика точно такая же, как в предыдущем доказательстве. Упражнение 6.9 (очень легкое). Какие подгруппы имеет группа порядка 101? Упражнение 6.10. Докажите, что любая группа простого порядка циклическая. 6.6. Теории и модели Группы, моноиды и полугруппы - примеры того, что математики называют теориями. Слово «теория» употребляется в разных смыслах и часто означает «догадка» - недоказанное объяснение. Но в математике «теория» имеет абсолютно точный смысл, не подразумевающий недоказанности. Определение 6.11. Теорией называется множество истинных предложений. Начиная с этого момента, мы будем употреблять слово «теория» в этом математическом смысле. Вот несколько важных фактов, касающихся теорий: О теория может быть порождена набором аксиом, дополненным правилами вывода; О теория называется конечно аксиоматизируемой, если может быть порождена конечным набором аксиом; О набор аксиом называется независимым, если удаление из него любой аксиомы приводит к уменьшению множества истинных предложений; О теория называется полной, если для любого предложения она содержит либо само это предложение, либо его отрицание;
Теории и модели ♦ 95 О теория называется непротиворечивой, если не существует предложения, для которого теория содержит как его само, так и его отрицание. Рассмотрим конкретный пример: понятие группы, обсуждаемое в этой главе. Группа представляет собой теорию в только что определенном смысле. Точнее, при заданных операциях jopr1 и нейтральном элементе е набор аксиом этой теории выглядит так: х о (г/ о z) = (х о у) о z; хое = еох = х', ior1=r1oj= е. Отталкиваясь от них, мы можем вывести различные истинные предложения (теоремы), например: хоу = х^>у = е\ (jo г/)-1 = г/-1 о х~\ Мы не собираемся перечислять все истинные предложения, составляющие теорию групп. Вместо этого мы порождаем предложения, выводя их из аксиом и ранее доказанных предложений. Например, первую теорему можно доказать, умножив обе части равенства на х-1. Как базисные векторы в линейной алгебре, аксиомы образуют базис теории. И, как и в линейной алгебре, у одной и той же теории может быть более одного базиса. * * * С понятием теории тесно связано понятие модели. Как и в предыдущем случае, смысл этого слова в математике существенно отличается от повседневного. Определение 6-12. Множество элементов, для которого определены все операции теории и истинны все предложения теории, называется моделью теории. В каком-то смысле модель является конкретной реализацией теории. Модель, в отличие от теории, предоставляет конкретное множество элементов. Как у алгоритма может быть несколько реализаций, так и у теории может быть несколько моделей. Например, аддитивная группа целых чисел и мультипликативная группа ненулевых остатков по модулю 7 - модели теории абелевых групп. Чем больше1 предложений в теории, тем меньше ее различных моделей. Если мы порождаем предложения из аксиом и правил вывода, то чем меньше аксиом, тем меньше предложений, а значит, больше моделей. Это имеет интуитивный смысл: аксиомы и предложения можно рассматривать как ограничения теории; чем их больше, тем труднее им всем удовлетворить, и, стало быть, тем меньше моделей способны это сделать. Нас в данном случае интересует тот аспект понятия «больше», который воплощен в слове дополнительно. Если множество предложений теории А содержит все предложения теории В и некоторые дополнительные, то мы говорим, что в теории А больше предложений, даже если их количество в обеих теориях счетно бесконечно.
96 ♦ Абстракция в математике Наоборот, чем больше моделей существует для теории, тем меньше в ней предложений. Если существует больше способов что-то сделать, то должно быть меньше ограничений на способ сделать желаемое. Определение 6.13. Две модели называются изоморфными, если между ними можно установить взаимно однозначное соответствие, сохраняющее операции. Это означает, что отображение (или обратное к нему) молено применить как до, так и после операции - результат получится одинаковый. Отображение х, у х , у Операция \ \ Операция /(*, у) — ► f&, у') = {f{x, у)У Отображение Например, можно отобразить множество натуральных чисел в множество четных чисел, воспользовавшись отображением «умножить на 2» и взяв в качестве операции сложение. Если сложить два натуральных числа, а затем применить отображение (то есть умножить сумму на 2), то результат получится таким же, как если бы мы сначала умножили каждое число на 2, а затем сложили их. N »— even ЗА ► 6,8 f(x,y) = x + y \ \f(xyy)=x + y N ►— even Изоморфизм модели с самой собой называет автоморфизмом. Определение 6-14. Непротиворечивая теория называется категоричной, или унивалентной, если все ее модели изоморфны1. 1 Это оригинальное определение Освальда Веблена. В современной логике применяется понятие ^-категоричной теории, для которой все модели мощности к изоморфрш. Пол- нос рассмотрение современной теории моделей выходит за рамки настоящей книги.
Примеры категоричных и некатегоричных теорий ♦ 97 У противоречивой теории вообще нет моделей - невозможно удовлетворить всем предложениям, не придя к противоречию. Категоричные теории и STL Долгое время считалось, что для программирования подходят только категоричные модели. Когда впервые была представлена библиотека Standard Template Library (STL) для C++, многие специалисты по информатике выступили против нее на том основании, что многие фундаментальные концепции, например Iterator, были определены не полностью. На самом деле именно эта недоопределенность и придает библиотеке общность. Хотя связанные списки и массивы с вычислительной точки зрения не изоморфны, многие алгоритмы STL определены для итераторов ввода и работают для обеих структур. Если можно обойтись меньшим числом аксиом, то спектр реализаций оказывается шире. Б.7. Примеры категоричных и некатегоричных теорий Рассмотрим пример категоричной теории с двумя изоморфными моделями: циклические группы порядка 4. Первой моделью будет Z4, аддитивная группа остатков по модулю 4 (состоит из множества {0, 1, 2, 3}), второй - (Zg, x), мультипликативная группа остатков по модулю 5 (состоит из множества {1, 2, 3, 4}). Ниже приведены таблицы «умножения» для этих групп: 0 1 i 2 3 0 0 1 2 3 1 1 2 3 0 2 2 3 0 1 3 3 0 1 2 1 2 3 4 1 1 2 3 4 2 2 4 1 3 3 3 1 4 2 4| 4 з 2 1 % Фь> х ) Хотя числа в них различны, обе модели изоморфны - элементы одной можно отобразить на элементы другой. Теоретически возможно 4! = 12 отображений: 0 из первой модели можно было бы отобразить на 1, 2, 3 или 4 из второй, затем 1 - на любой из трех оставшихся элементов и т. д. Но в данном случае количество возможностей гораздо меньше. Заметим, что в первой модели числа 1 и 3 являются порождающими элементами группы - если взять любой из них и возводить в степень, последовательно применяя групповую операцию, то получатся все остальные элементы. Во второй модели порождающими являются элементы 2 и 3. Это позволяет сузить выбор: порождающий элемент одной группы должен отображаться на порождающий элемент другой, так что у нас остается всего два разных отображения. Например, мы можем сказать «роль 1 в первой модели будет играть 2 во второй, а 3 в первой модели будет соответствовать 3 во второй».
98 ♦> Абстракция в математике Как быть с двумя другими значениями? Из таблиц умножения мы знаем, что в первой модели 0 - нейтральный элемент, а во второй модели эту роль играет 1. Наконец, мы знаем, что 2 в первой модели отображается на 4 во второй, потому что в обоих случаях это единственный отличный от нейтрального элемент, который, будучи «возведен в квадрат», дает нейтральный элемент. Таким образом, имеются два возможных отображения: Значение в 0 1 2 3 ^4 Значение в 1 2 4 3 (К х) Значение в 0 1 2 3 ^ Значение в (Zg, x) 1 3 4 2 Как узнать, что эти отображения порождают вторую модель? Один из способов - посмотреть, будут ли преобразовывать таблицу умножения Z4 в таблицу умножения (Z'5, x). Проверим это на примере второго отображения. Сначала заменим все значения в таблице Z4 теми, на которые они отображаются. В результате получается: 1 3 \% \л 1 1 3 '-.Й г2. 3 3 4 X ■л\ § ф :-% Ж,; !С к 12 1 l.: ■Ж Затем переставим строки и столбцы, так чтобы заголовки шли в правильном порядке. Начнем с перестановки двух последних столбцов и двух последних строк (в предыдущей таблице закрашены): 1 Д;№ 4 1 1 1 1 3:1 2'f~4 3.81 3|| III4I2 2'jW'iT44.3 4 4 ИГ I; I 1 Наконец, переставим средние строки и столбцы: 1 2 | 3 | 4 1 1 1 I 2 1 3 |4 2 [2_\ 4 I lj 3 3 3 1 I 4 I 2 4 I 4 I 3 i 2 I 1
Заключительные мысли ♦ 99 Полнилась как раз таблица умножения для (li'5, х), показанная ранее, что нам и нужно. * * * Теперь рассмотрим пример некатегоричной теории: все группы порядка 4. Существует лишь по одной неизоморфной группе порядков 1, 2 и 3, но две неизоморфные группы порядка 4: циклическая группа Z4, которую мы только что описали, и так называемая группа Клейна. Для группы Клейна есть две важные модели: мультипликативная группа единиц по модулю 8 (состоит из множества {1,3, 5,7}) и группа изометрий, переводящих прямоугольник в себя (тожественное преобразование, симметрия относительно вертикальной оси, симметрия относительно горизонтальной оси и поворот на 180°). Ниже приведены таблицы умножения для этих двух групп. Поскольку мы не знаем отдельных элементов теории, то будем обозначать нейтральный элемент буквой е, а остальные три - буквами а,Ьис: I е I а | b | с j I e I a J b J с е J е I а | b I с I e e I a b | с a I a I b I с I е | | а | а | е | с | b b j b | с j е I а | j b I b I с I e I a cceab с с b a e Циклическая группа Z4 Группа Клейна В левой таблице операцией является сложение, и мы можем считать, что «е» - это 0 (аддитивный нейтральный элемент), а а, Ъ и с соответствуют числам 1, 2 и 3. Например, ао£=1 + 2 = 3 = с, поэтому значение на пересечении строки а и столбца Ъ равно с. Действительно ли эти группы существенно различны (то есть не изоморфны) или существует способ отобразить одну на другую, как мы сделали выше? Иными словами, существует ли предложение, различающее группы порядка 4? Да, предложение Vx Е G: х2 = е истинно для группы Клейна, но ложно для Z4. Убедиться в этом можно, взглянув на диагональ таблицы умножения. Еще один способ доказать различие - заметить, что в циклической группе есть два порождающих элемента, а в группе Клейна ни одного. Б.8. Заключительные мысли В этой главе мы познакомились с идеей алгебраической структуры - абстрактного множества элементов, обладающего определенными свойствами. Мы рассмотрели понятие группы, наиболее важной из такого рода структур, а также более слабых
100 ♦> Абстракция в математике сородичей группы: моноиды и полугруппы. Все они сведены в следующую таблицу которую мы в дальнейшем будем пополнять. Структура Операции Элементы Аксиомы Полугруппа х о у % о (у о г) = (х о у) о z Пример: положительные целые числа с операцией сложения Моноид х о у X о (у о z) = (X о у) о Z х о е = е о х Пример: строки с операцией конкатенации Группа х о у Х~Л X о (у о z) = (X о у) о z х о е = е о х х о х~{ = х~л о х= е Пример: обратимые матрицы с операцией умножения Абелева группа х о у х~] X о (у о z) = (X о у) о z х° е = е ох х о х-1 = х'1 о х= е X о у = у о х Пример: двумерные векторы с операцией умножения Каждая следующая строка таблицы включает все свойства предыдущей, с одним или несколькими дополнениями. Связи между этими структурами можно изобразить, как показано ниже:
Заключительные мысли ♦ 101 Например, из этой диаграммы видно, что моноид - это полугруппа, в которой дополнительно имеется нейтральный элемент (и выполняется аксиома нейтральности). Дополнительные структуры, которые мы упоминали, проще всего определить в терминах других структур. Структура Аддитивная полугруппа Аддитивный моноид Подгруппа Циклическая группа ; Определение Полугруппа, в которой операция называется + и (по соглашению) является коммутативной ] Аддитивная полугруппа с нейтральным элементом 0 Группа, являющаяся подмножеством другой группы Группа, все элементы которой можно получить путем возведения одного (по крайней мере) какого-то элемента в различные степени Мы также видели, как можно выводить свойства групп (например, утверждаемое теоремой Лагранжа), ничего не зная об их конкретных элементах. Иными словами, мы видели, как получить результаты о теориях, не задавая конкретной модели. Теперь мы готовы применить алгебраические структуры на практике.
Глава / Вывод обобшенного алгоритма Превратить нечто во всеобщее значит мыслить его. Гегель. «Философия права» В этой главе мы возьмем алгоритм египетского умножения, описанный в главе 2, и, применяя математические абстракции из предыдущей главы, обобщим его на широкий круг задач, далеко выходящих за пределы простой арифметики. 7.1. Осмысление требований к алгоритму Чтобы написать хороший код, необходимы два шага. Первый - правильно выбрать алгоритм. Второй - понять, для какого рода вещей (типов) он работает. Возможно, вы думаете, что тип уже известен - int, float или еще какой-то, встретившийся в конкретной задаче. Но это не обязательно так - все в мире изменяется. В будущем году кому-то захочется применить ваш код к unsigned int, double или чему-то совсем непохожему. Код следует проектировать так, чтобы его можно было использовать в разных ситуациях. Давайте еще раз рассмотрим вариант умножить и аккумулировать алгоритма египетского умножения из главы 2. Напомним, что мы умножаем п на а и аккумулируем результат в г, кроме того, у алгоритма есть предусловие: пиа должны быть отличны от нуля: int mult_acc4{int г, int n, int a) { while (true) { if (odd(n)) { r = r + a; if (n == 1) return r; } n = half(n); a = a -t a; } }
Требования к А ♦ 103 Часть кода набрана курсивом, а часть - полужирным шрифтом. Обратите вни- ание, что курсивная и полужирная части не пересекаются; ни в одном месте «курсивная переменная» и «полужирная переменная» никак не взаимодействуют. Это означает, что требования к курсивным и полужирным переменным не обязаны "ыть одинаковыми, или - в терминах языка программирования - эти переменные могут принадлежать разным типам. Но каковы же требования к переменным каждого вида? До сих пор мы объявляли их как int, но, похоже, алгоритм мог бы работать и для многих других подобных типов. Курсивные переменные гиа должны принадлежать типу, поддерживающему сложение, - мы могли бы назвать его типом «plusable». Полужирная переменная п должна допускать проверку на нечетность, сравнение с 1 и деление на 2. Отметим, что деление на 2 - гораздо более ограничительная операция, чем деление вообще. Например, угол можно разделить на 2 с помощью построения циркулем и линейкой, а разделить его на 3 таким способом не получится. Мы установили, что гиа принадлежат одному и тому же типу, который будем обозначать шаблонным именем А. Кроме того, мы поняли, что п принадлежит другому типу, который мы назовем N. Итак, не настаивая на том, чтобы все аргументы имели тип int, мы можем представить программу в более общем виде: template <typename A, typename N> A multiply_accumulate(A г, N п, А а) { while (true) { if (odd(n)) { г = г + a; if (n == 1) return r; } n = half (n); a = a + a; } } Это упрощает задачу - мы можем размышлять о требованиях к А отдельно от требований к N. Рассмотрим эти типы более пристально, начав с того, что попроще. 7.2. Требования к А Каковы синтаксические требования к А? Иначе говоря, какие операции можно производить над объектами, принадлежащими типу А? Глядя на то, как используются в коде переменные этого типа, мы можем выделить три операции: О их можно складывать (в C++ для них должен быть определен operator*); О их можно передавать по значению (в C++ для них должен существовать копирующий конструктор); О им можно присваивать значение (в C++ для них должен быть определен operator=).
104 ♦ Вывод обобшенного алгоритма Необходимо также определить семантические требования. То есть мы должны сказать, что эти операции означают. Основное наше требование - ассоциативность операции + можно выразить следующим образом: А(Т) => Vfl, й,сеГ:а + (Нс) = (а + й) + с. (Часть перед двоеточием читается так: «Если тип Т совпадает с А, то для любых значений а, Ь, с типа Т справедливо следующее:...».) Хотя теоретически (и вообще в математике) операция + ассоциативна, с компьютерами дело обстоит не так просто. На практике существуют случаи, когда ассоциативность сложения не соблюдается. Рассмотрим, к примеру, такие две строки кода: w = (х + у) + z/ w = х + (у + z) Предположим, что х, у, z имеют тип int и z отрицательно. Тогда может случиться, что для некоторых очень больших значений сложением + у вызовет переполнение, хотя этого не случилось бы, если мы сначала вычисляли сумму у + z. Проблема возникает из-за того, что сложение не определено корректно для всех возможных значений типа int; мы говорим, что + является частичной функцией. Для решения этой проблемы уточним требования. Мы требуем, чтобы аксиомы выполнялись только в области определения, т. е на множестве значений, для которых функция определена1. * * * На самом деле мы не упомянули еще двух синтаксических требований. Они вытекают из конструирования копированием и присваивания. Например, конструирование копированием означает, что копия равна оригиналу Для этого требуется возможность сравнивать объекты типа А на равенство: О их можно сравнивать на равенство (в C++ для них должен быть определен operator==); О их можно сравнивать на неравенство (в C++ для них должен быть определен operator!=). Эти синтаксические требования сопровождаются семантическими, касающимися свойств равенства; отношение равенства для типа Т должно удовлетворять ожиданиям: О неравенство является отрицанием равенства: (аФ Ь) <=> -i(a = b); О отношение равенства рефлексивно, симметрично и транзитивно: а = а; а = Ь => Ь = а; a = b/\b = c^>a = c] Более строгое обсуждение этого вопроса см. в разделе 2.1 книги Stepanov, Mcjones «Elements of Programming».
Требования к А ♦ 105 О равенство подразумевает взаимозаменяемость для любой функции/на Т а = Ъ -> f{a) = f{b). Три аксиомы во втором пункте (рефлексивность, симметричность и транзитивность) в совокупности называются эквивалентностью, но от равенства мы ожидаем гораздо более сильных свойств, поэтому добавили требование о взаимозаменяемости. Для типов, которые ведут себя «обычным образом», есть специальное название - регулярные типы. Определение 7.1. Тип Т называется регулярным, если связи между конструированием, присваиванием и равенством для него такие же, как для встроенных типов, например int. Примеры: О Т a (b); assert (a == b); unchanged (b);; О а = b; assert (a == b); unchanged (b);; О T a (b); эквивалентно Та; а = b;. Подробное рассмотрение регулярных типов см. в главе 1 книги «Elements of Programming». Все типы, встречающиеся в этой книге, регулярны. * * * Теперь мы в состоянии формализовать требования к А: О регулярный тип; О предоставляет ассоциативную операцию +. В главе 6 мы видели, что алгебраические структуры, обладающие бинарной ассоциативной операцией, называются полугруппами (см. определение 6.5). Кроме того, регулярность типа гарантирует возможность сравнения двух значений на равенство, что необходимо для формулировки аксиомы ассоциативности. Поэтому можно сказать, что А образует полугруппу. Операцией в ней является сложение, поэтому возникает искушение назвать эту полугруппу аддитивной. Однако напомним, что по соглашению аддитивные полугруппы предполагаются коммутативными. Поскольку для нашего алгоритма коммутативность не нужна, будем говорить, что А является некоммутативной аддитивной полугруппой. Это означает, что коммутативность не является обязательной, но и не запрещена. Иными словами, любая (коммутативная) аддитивная полугруппа является также некоммутативной аддитивной полугруппой. Определение 7.2. Некоммутативной аддитивной полугруппой называется полугруппа, наделенная ассоциативной бинарной операцией +. В качестве примеров некоммутативных аддитивных полугрупп можно назвать положительные четные числа, отрицательные целые числа, вещественные числа, полиномы, векторы на плоскости, булевы функции и прямолинейные отрезки. Так получилось, что все они являются также аддитивными полугруппами, но это не-
106 ♦ Вывод обобщенного алгоритма обязательно. Как мы увидим, операция + может иметь различные интерпретации для разных типов, но она всегда ассоциативна. На протяжении многих веков символ «+» по общепринятому соглашению обозначал не только ассоциативную, но и коммутативную операцию. Но во многих языках программирования (в том числе C++, Java, Python) знак + применяется также для операции конкатенации строк - некоммутативной. Это отход от стандартной принятой в математике практики, поэтому идею не назовешь удачной. Принятое в математике соглашение подразумевается следующее: О если в множестве имеется одна бинарная операция, являющаяся ассоциативной и коммутативной, то она обозначается +; О если в множестве имеется одна бинарная операция, являющаяся ассоциативной, но не коммутативной, то она обозначается *. Стивен Клини, живший в XX веке и занимавшийся математической логикой, ввел нотацию ah для конкатенации строк (так как в математике знак * обычно опускают). Принцип именования Встречаясь с необходимостью назвать что-то или воспользоваться существующим названием для других целей, мы должны придерживаться следующих рекомендаций: 1. Если существует устоявшийся термин, воспользоваться им. 2. Не использовать устоявшийся термин способом, не совместимым с его общепринятым значением. В частности, имя оператора или функции следует перегружать, только если сохраняется существующая семантика. 3. Если имеются противоречивые примеры употребления, отдавать предпочтение более устоявшемуся. Название вектор в STL заимствовано из предшествующих языков программирования: Scheme и Common Lisp. К сожалению, оно не согласуется с куда более старым значением этого термина в математике и нарушает правило 3; эту структуру данных следовало бы назвать массивом. Как это ни печально, если вы сделаете ошибку и нарушите вышеупомянутые принципы, то результат зачастую остается очень надолго. 7.3. Требования к N Теперь, зная, что А должен быть некоммутативной ассоциативной полугруппой, мы можем указать это в шаблоне вместо безликого typename: template <NoncommutativeAdditiveSemigroup A, typename N> A multiply_accumulate(A r, N n, A a) { while (true) { if (odd(n)) { r = r + a; if (n == 1) return r; } n = half (n); a = a + a; } }
Требования к N ♦ 107 Здесь NonCommutativeAdditiveSemigroup - это концепция C++, то есть набор требований к типу Концепции мы будем обсуждать в главе 10. Вместо typename мы задаем имя концепции, которую хотим использовать. Поскольку на момент написания этой книги концепции еще не поддерживались в языке, мы немного смошенничаем, воспользовавшись препроцессором: ^define NonCommutativeAdditiveSemigroup typename С точки зрения компилятора, А - не более чем typename, но для нас это NonComm utativeAdditiveSemigroup. Этот трюк мы будем использовать и дальше, когда понадобится задать требования к типу в шаблонах. Хотя в абстрактной математике это поведение не нужно, в программировании переменные должны допускать конструирование и присваивание, что гарантируется принадлежностью к регулярному типу. Начиная с этого момента, при использовании алгебраической структуры в роли концепции мы будем предполагать, что наследуются все требования к регулярному типу. Так что же можно сказать о требованиях к типу другого аргумента, N? Начнем с синтаксических требований. N должен быть регулярным типом, в котором реализованы следующие операции: О half; О odd; О ==0; О ==1. А вот семантические требования к N: О even(n) => half(n) + half(n) = п\ О odd(n) => even(n - 1); О odd(n)=>half(n-l) = half(n); О аксиома: п < 1 v half(w) = 1 v half(half(n)) = 1 v ... Какие типы C++ удовлетворяют этим требованиям? Их несколько: uint8_t, int8_t, uint64_t и т. д. Концепция, которой они удовлетворяют, называется Integer. * * * Теперь, наконец, можно написать обобщённую версию функции умножения с аккумулированием, задав правильные требования к обоим типам: template <NoncommutativeAdditiveSemigroup A, Integer N> A multiply_accumulate_semigroup(A г, N п, А а) { // precondition(п >= 0); if (n == 0) return r; while (true) { if (odd(n)) { г = г + а; if (n == 1) return r; } n = half (n); a = a + a; } }
108 ♦> Вывод обобщенного алгоритма Мы добавили в код еще одну строку, которая возвращает г, если п равно нулю. Так мы поступили, потому что в случае, когда п равно нулю, нам и делать ничего не надо. Однако для умножения это уже неверно, как мы скоро увидим. Вот как выглядит функция умножения, из которой вызывается показанная выше функция; требования к ней те же самые: template <NoncommutativeAdditiveSemigroup A, Integer N> A multiply_semigroup(N n, A a) { // precondition(n > 0); while (!odd(n)) { a = a + a; n = half (n); } if (n == 1) return a; return multiply__accumulate_semigroup(a, half(n - 1), a + a) ; } Мы можем также модифицировать вспомогательные функции odd и half, так чтобы они работали с любым значением типа Integer: template <Integer N> bool odd(N n) { return bool(n & Oxl); } template <Integer N> N half(N n) { return n » 1; } 7.4. Новые требования Предусловие для функции multiply гласит, что п должно быть строго больше нуля. (Раньше мы предполагали это молча, потому что греки знали только положительные числа, но теперь должны выразить требование явно.) Что должна вернуть функция умножения в аддитивной полугруппе, когда п равно нулю? Это должно быть значение, которое не изменяет результат в случае применения полугрупповой операции - сложения. Иными словами, это должен быть нейтральный элемент относительно сложения. Но наличие нейтрального элемента в аддитивной полугруппе необязательно, поэтому мы не можем полагаться на это свойство. То есть мы не можем предполагать существование эквивалента нуля. (Напомним, что а уже не целое число, это может быть любой объект типа NoncommutativeAdditiveSe migroup, например положительное целое число или непустая строка.) Поэтому-то п и не может быть равно нулю. Но есть альтернатива: вместо того чтобы налагать ограничение на данные, требуя выполнения условия п > 0, мы можем потребовать, чтобы любой используемый тип знал, как поступать с нулем. Для этого мы изменим требование концепции для п и будем считать, что это не просто аддитивная полугруппа, а моноид. Напомним (см. главу 6), что в дополнении к ассоциативной бинарной операции моноид содержит нейтральный элемент еу для которого имеет место аксиома нейтральности:
От умножения к возведению в степень ♦ 109 Конкретно мы будем использовать некоммутативный аддитивный моноид, в котором нейтральный элемент обозначается «О»: х+0 = 0 + х = х. Ниже приведена функция умножения для моноидов: template <NoncommutativeAdditiveMonoid A, Integer N> A multiply_monoid(N n, A a) { // precondition(n >= 0); if (n == 0) return A(0); return multiply_semigroup(n, a); 1 Что, если мы захотим разрешить умножение отрицательных чисел? Нужно гарантировать, что «умножение на отрицательное число» имеет смысл для любого предполагаемого к использованию типа. Оказывается, что это эквивалентно требованию о поддержке типом операции обращения. И снова обнаруживается, что текущее требование - некоммутативный аддитивный моноид - не гарантирует наличия этого свойства. Поэтому нам нужна группа. Группа, как было сказано в главе 6, включает все операции и аксиомы моноида плюс операцию обращения х~\ удовлетворяющую аксиоме сокращения: х о х~х = х-1 о х = е. В нашем случае нужна некоммутативная аддитивная группа, в которой операцией обращения является унарный минус, а аксиома сокращения имеет вид: х + -х = -х + х = 0. Усилив требования к типу, мы можем исключить предусловия на п, разрешив отрицательные значения. Как и раньше, обернем последнюю версию нашей функции новой: template <NoncommutativeAdditiveGroup A, Integer N> A multiply_group(N n, A a) { if (n < 0) { n = -n; a = -a; } return multiply_monoid(n, a); } 7.5. От умножения к возведению в степень Итак, мы обобщили наш код, так что он может работать с любой аддитивной полугруппой (или моноидом, или группой), и можем сделать любопытное наблюдение: Если заменить + на* (то есть удвоение на возведение в квадрат), то тот же самый алгоритм можно будет использовать для вычисления а11 вместо п * а.
ПО ♦ Вывод обобщенного алгоритма Вот как выглядит функция на C++, получающаяся в результате применения такого преобразования к коду multiply_accumulate__semigroup: template <MultiplicativeSemigroup A, Integer N> A power_accumulate_semigroup(A r, A a, N n) { // precondition(n >= 0); if (n == 0) return r; while (true) { if (odd(n)) { r = r * a; if (n == 1) return r; } n = half (n); a = a * a; } } Новая функция вычисляет гап. Изменившиеся места выделены полужирным шрифтом. Отметим, что мы изменили порядок аргументов а и п, чтобы он соответствовал стандартной математической записи (мы пишем па, но ап). Следующая функция вычисляет степень: template <MultiplicativeSemigroup A, Integer N> A power_semigroup(A a, N n) { // precondition (n > 0); while (!odd(n)) { a = a * a; n = half (n); } if (n == 1) return a; return power_accumulate_semigroup(a, a * a, half(n - 1)); } А вот обернутые версии функций для мультипликативного моноида и группы: template <MultiplicativeMonoid A, Integer N> A powerjnonoid(A a, N n) { // precondition(n >= 0); if (n == 0) return A(l); return power_semigroup(a, n); } template <MultiplicativeGroup A, Integer N> A power_group(A a, N n) { if (n < 0) { n = -n; a = multiplicative_inverse(a); } return power_monoid(a, n);
Обобщение операши ♦ 111 Как для функции умножения в моноиде нужен был нейтральный элемент относительно сложения (0), так для функции возведения в степень нужен нейтральный элемент относительно умножения (1). И если раньше для умножения в группе требовалось аддитивное обращение (унарный минус), то теперь - для возведения в степень - необходимо мультипликативное обращение. В C++ нет встроенной операции мультипликативного обращения (нахождение обратного числа), но ее легко написать: template <MultiplicativeGroup A> A multiplicative_inverse(А а) { return A(l) / а; } 7.Б. Обобшение операции Мы видели примеры двух полугрупп - аддитивной и мультипликативной - со своими операциями (соответственно + и *). Тот факт, что в обоих случаях можно использовать один и тот же алгоритм, примечателен, но раздражает необходимость писать для них разные версии кода. На самом деле существует много разных полугрупп со своими ассоциативными операциями (например, умножение по модулю 7) над одним и тем же типом Т. Вместо того чтобы писать новую версию кода для каждой встречающейся операции, мы можем обобщить саму операцию - точно так же, как раньше обобщили типы аргументов. На самом деле есть немало ситуаций, когда алгоритму необходимо передавать операцию; надо полагать, вы сталкивались с подобными примерами в STL. Ниже представлена версия функции возведения в степень для произвольной полугруппы. Мы по-прежнему называем то, что она вычисляет, «степенью», хотя многократно применяемая операция не обязана быть умножением. template <Regular A, Integer N, SemigroupOperation 0p> // requires (Domain<0p, A>) A power_accumulate_semigroup(A r, A a, N n, Op op) { // precondition(n >= 0); if (n == 0) return r; while (true) { if (odd(n)) { r = op(r, a); if (n == 1) return r; 1 n = half(n); a = op(a, a); } } Обратите внимание на комментарий «requires», который говорит, что областыс определения операции Ор должен быть тип А. Если будущие версии C++ начну! поддерживать концепции, то этот комментарий можно будет превратить в пред-
112 ♦ Вывод обобщенного алгоритма ложение (по аналогии с утверждением assert), которое компилятор сможет использовать для проверки соотношений между типами. Ну а пока программист сам должен вызывать эту функцию только с такими аргументами шаблона, которые удовлетворяют требованию. Кроме того, поскольку мы больше не знаем о характере полугруппы, образующей А, - она может быть аддитивной, мультипликативной или еще какой-то, все зависит от Ор - то можем требовать лишь, чтобы А был регулярным типом. Упоминание о полугруппе стало теперь требованием к операции Ор - она должна иметь тип SemigroupOperation. Мы можем использовать эту функцию, чтобы написать версию power для произвольной полугруппы: template <Regular A, Integer N, SemigroupOperation 0p> // requires (DomairKOp, A>) A power_semigroup(A a, N n, Op op) { // precondition(n > 0); while (!odd(n)) { a = op(a, a); n = half (n); } if (n == 1) return a; return power_accumulate_semigroup(a, op(a, a), half (n - 1), op); } Как и раньше, эту функцию можно обобщить на моноиды, добавив нейтральный элемент. Но поскольку мы не знаем заранее, какая будет передана операция, то должны получать нейтральный элемент от самой операции: template <Regular A, Integer N, MonoidOperation Op> // requires(Domain<0p, A>) A power_monoid(A a, N n, Op op) { // precondition(n >= 0); if (n == 0) return identity_element(op); return power_semigroup(a, n, op); } Вот примеры функций identity__element для операций + и *: template <NoncommutativeAdditiveMonoid T> Т identity_element(std::plus<T>) { return T(0); } template <MultiplicativeMonoid T> T identity_element(std::multiplies<T>) { return T(l); } Обе функции задают тип объекта, который ожидают получить, но не указывают его имени, потому что сам объект использоваться не будет. Первая говорит: «Нейтральный относительно сложений элемент - 0». Разумеется, для других моноидов
Обобщение операции ♦ 113 нейтральные элементы будут другими - например, для операции min это будет максимальное значение типа Т. * * * Чтобы обобщить функцию power на группы, нам нужна операция обращения, которая сама является функцией от заданной операции типа GroupOperation: Template <Regular A, Integer N, GroupOperation 0p> // requires(Domain<0p, A>) A power_group(A a, N n, Op op) { if (n < 0) { n = -n; a = inverse_operation(op)(a); } return power_monoid(a, n, op); л / Вот два примера функции inverse_operation: template <AdditiveGroup T> std::negate<T> inverse_operation(std::plus<T>) { return std::negate<T>(); } template <MultiplicativeGroup T> reciprocal<T> inverse_operation(std::multiplies<T>) { return reciprocal<T>(); } В STL уже есть функция negate, но (по недосмотру) нет функции reciprocal. Поэтому напишем ее самостоятельно. Мы будем использовать объект-функцию - объект C++, который предоставляет функцию, объявленную оператором operator (), и вызывается, как функция, - с указанием имени объекта в качестве имени функции. Подробнее об объектах-функциях см. приложение С. template <MultiplicativeGroup T> struct reciprocal { Т operator() (const T& х) const { return T(l) / х; } }; Это всего лишь обобщение функции multiplicative_inverse, написанной нами в предыдущем разделе1. 1 На этот раз нам не нужно предусловие, запрещающее х быть равным нулю, потому что MultiplicativeGroup не содержит необратимого нулевого элемента. Если на практике нам встретится, например, тип double, который удовлетворял бы требованиям MultiplicativeGroup, если бы не содержал нуля, то можно будет добавить предусловие, исключающее этот случай.
114 ♦ Вывод обобщенного алгоритма Редукция Алгоритм возведения в степень - не единственный важный алгоритм, определенный на полугруппах. Очень важен также алгоритм редукции, который по очереди применяет бинарную операцию к каждому элементу последовательности и предыдущему результату. Два хорошо известных из математики примера - функция суммирования (2) для аддитивных полугрупп и произведения (П) - для мультипликативных. Но мы можем обобщить этот алгоритм на произвольную полугруппу. Такой обобщенный вариант редукции был предложен в 1962 году Кеном Айверсоном в разработанном им языке APL. В терминологии APL символ / обозначал оператор редукции. Например, суммирование последовательности выражалось в виде + /1 23 С тех пор идея редукции вновь и вновь возникала в разных контекстах. В 1977 году Джон Бэкус, изобретатель первого языка программирования высокого уровня, включил похожий оператор под названием insert в свой язык FP (он называл операторы «функциональными формами»). В 1981 году в ранней работе по обобщенному программированию «Operators and Algebraic Structures» Капур, Мюссер и Степанов распространили эту идею на параллельную редукцию и уточнили ее связь с ассоциативными операциями. В язык Common Lisp, который в 1980-х годах был очень популярен для разработки приложений искусственного интеллекта, была включена функция reduce. Созданная Google система MapReduce и ее вариант Hadoop с открытым исходным кодом - современное практическое воплощение этих идей. 7.7. Вычисление чисел Фибоначчи Примечание. В этом разделе предполагается знание основ линейной алгебры. Остальная часть книги не зависит от изложенного здесь материала, поэтому его можно опустить без ущерба для понимания. В главе 4 мы познакомились с Леонардо Пизанским, математиком, жившим в начале XIII века и известным сегодня по имени Фибоначчи. Наибольшую известность ему принесла следующая задача: если в начальный момент имеется одна пара кроликов, то сколько пар окажется по прошествии определенного числа месяцев? Чтобы упростить задачу, Леонардо ввел некоторые допущения: исходная пара кроликов и каждый приплод состоят из одного самца и одной самочки, самкам требуется один месяц для достижения половой зрелости, и после этого они приносят приплод один раз в месяц, кролики живут вечно. Первоначально есть 1 пара кроликов. В начале месяца 2 кролики спариваются, но пара по-прежнему одна. В начале месяца 3 самка приносит потомство, поэтому мы получаем 2 пары. В начале месяца 4 первая самка второй раз приносит потомство, так что пар уже 3. В начале месяца 5 первая самка еще раз приносит потомство, как и самка, родившаяся в месяце 3, так что пар становится 5. И так далее. Если считать, что в месяце 0 (до начала эксперимента) было 0 кроликов, тс количество пар в каждом месяце описывается такой последовательностью: 0,1,1,2,3,5,8,13,21,34... Для получения размера популяции в каждом месяце нужно просто сложить размеры популяции в каждом из двух предшествующих месяцев. Сегодня мы называем элементы этой последовательности числами Фибоначчи и формальнс определяем ее следующим образом:
Вычисление чисел Фибоначчи ♦ 115 Fo-0; Сколько времени нужно для вычисления я-то числа Фибоначчи? «Очевидный» ответ - /2 - 2, но этот ответ неверен. Наивная реализация на C++ могла бы выглядеть так: int fibO (int n) { if (n == 0) return 0; if (n == 1) return 1; return fib0(n - 1) + fibO (n - 2); \ j Однако этот код снова и снова выполняет одни и те же действия. Вот как выглядит вычисление fib0 (5): F5-F< + F3 -W + FJ + ft + FJ = ((F2 + F,) + (F{ + F0)) + ((F, + F0) + F%) = (№ + F0) + FJ + (Fx + F0)) + ((F, + F0) + Ft). Даже в этом крохотном примере вычисление потребовало 17 сложений, и одна лишь величина Fx + F0 повторно вычислялась 5 раз. Упражнение 7.1. Сколько сложений необходимо для вычисления fib0 (п) ? Повторное вычисление одного и того лее снова и снова неприемлемо, этому не может быть никаких оправданий. Этот код легко исправить - нужно лишь запоминать два предыдущих результата: int fibonacci_iterative(int n) { if (n == 0) return 0; std::pair<int, int> v = {0, 1}; for (int i = 1; i < n; ++i) { v = {v.second, v.first + v.second}; } return v.second; i Это решение приемлемо, потому что требует 0(п) операций. Более того, если мы хотим найти п-ыи элемент последовательности, оно может даже показаться оптимальным. Удивительно, однако, то, что п-ое число Фибоначчи молшо вычислить за 0(log n) операций, для большинства практических целей эта величина меньше 64. Давайте представим вычисление следующего числа Фибоначчи по двум предыдущим с помощью следующего матричного уравнения1: 1 Краткие сведения об умножении матриц приведены в начале раздела 8.5.
116 ♦ Вывод обобщенного алгоритма z /т1 V. 1 1 1 О I г,- I Тогда ;2-ое число Фибоначчи можно получить в виде: vn Jn-\. 1 1 1 О -1Л-1 Иными словами, для вычисления ю-го числа Фибоначчи нужно возвести некоторую матрицу в степень. Как мы впоследствии увидим, умножение матриц используется при решении многих задач. Матрицы образуют мультипликативный моноид, поэтому у нас уже имеется алгоритм со сложностью 0(log n) - наш алгоритм power из раздела 7.6. Упражнение 7.2. Реализуйте вычисление чисел Фибоначчи с помощью алгоритма power. Это изящное применение нашего алгоритма power, но вычисление чисел Фибоначчи - не единственное, что можно сделать. Если заменить + произвольной линейной рекуррентной функцией, то этот же прием можно использовать для вычисления любой линейной рекуррентной последовательности. Определение 7.3. Линейной рекуррентной функцией порядка k называется функция / такая, что k-i /=о Определение 7.4. Линейной рекуррентной последовательностью называется последовательность, порождаемая линейной рекуррентной функцией по k начальным значениям. Последовательность чисел Фибоначчи - это линейная рекуррентная последовательность второго порядка. Для любой линейной рекуррентной последовательности мы можем вычислить ю-ый шаг, произведя умножение матриц с помощью нашего алгоритма power: X п Хп-\ Хп-2 \_Xn-k+\ _ — 2Q 1 0 0 fl, 0 1 0 а2 0 0 0 а k-2 О о 1 а k-l 0 0 0 n-k+л Xk-\ Xk-2 Xk-3 x0 Строка единиц под главной диагональю описывает поведение «сдвига», благодаря которому каждый член последовательности зависит от k предыдущих.
Заключительные мысли ♦ 117 7.8. Заключительные мысли Мы начали эту главу с анализа требований к коду из главы 2, абстрагировав алгоритмы с целью использования ассоциативной операции над произвольными типами. Мы смогли переписать код, определив его в терминах алгебраических структур: полугрупп, моноидов и групп. Затем мы продемонстрировали, что алгоритм можно обобщить - сначала с умножения на возведение в степень, а затем на произвольные операции в алгебраической структуре. Этим обобщенным алгоритмом мы еще не раз воспользуемся в книге. Путь, по которому мы прошли, - взять эффективный алгоритм, обобщить его (без потери эффективности), так чтобы он работал для абстрактных математических концепций, а затем применить к разнообразным ситуациям - это и есть суть обобщенного программирования.
Глава \J Ф<&®@Ф®шштФФтт®ФФ®®ФФ®®ШФФФ®тФФтт®ФшшттФФштФШФшт Еше об алгебраических структурах Для Эмми Нётер связи между числами, функциями и операциями становились прозрачными, поддающимися обобщению и полезными только после того, как они были оторваны от конкретных объектов и сведены к общим концептуальным взаимосвязям. Б. Л. Ван дер Варден Алгоритм Евклида, с которым мы познакомились в главе 4, применялся для вычисления наибольшей общей меры отрезков. Впоследствии мы показали, как распространить его на целые числа. Но работает ли он и для других математических сущностей? Именно этим вопросом мы и займемся в этой главе. Как мы увидим, попытки ответить на него стали побудительным мотивом для важных открытий в общей алгебре. Мы также расскажем о некоторых новых алгебраических структурах и продемонстрируем их применение в программах. 8.1. Стевин, полиномы и НОА Некоторыми из важнейших открытий математика обязана не столь известной фигуре, фламандскому математику XVI века Симону Стевину. Помимо вклада в технику, физику и музыку, Стевин революционно изменил способы рассуждения о числах и операциях над ними. Как писал Бартель Ван дер Варден в «Истории алгебры»: Одним ударом были сметены классические ограничения, согласно которым «числа» бывают только целыми или рациональными дробями. Введенное Стевином общее понятие вещественного числа было принято всеми жившими после пего учеными. В 1585 году в брошюре «De Thiende» («Десятая»), которая была опубликована на английском языке под названием «Disme: The Art of Tenths, or, Decimal] Arithmetike»1, Стевин вводит в рассмотрение десятичные дроби и объясняет по- Disme: искусство десятых долей, или десятичная арифметика. - Прим. перев.
Стевин, полиномы и НОА ♦ 119 рядок работы с ними. Он был первым европейцем, предложившим использовать десятичную нотацию в обратном направлении - для обозначения десятых, сотых и т. д. Disme (произносится «дайм») стала одной из самых читаемых книг в истории математики. Она была в числе любимых книг Томаса Джефферсона, именно благодаря ей в денежной системе США появилась монета под названием «dime» (десять центов), и сама она устроена по десятичному принципу, а не состоит из британских фунтов, шиллингов и пенсов, имевших хождение в то время. Симон Стевин (1548-1620) Симон Стевин родился в городе Брюгге, во Фландрии (ныне часть Бельгии), но впоследствии перебрался в голландский город Лейден. В то время Нидерланды (включавшие Фландрию и Голландию) были частью испанской империи, которой правила династия Габсбургов, а скрепляла непобедимая профессиональная армия. В 1568 году голландцы, объединенные общей культурой и языком, начали войну за независимость, которая завершилась созданием собственной империи. Стевин, патриот и военный инженер по специальности, присоединился к восстанию и подружился с его вождем, принцем Морицем Оранским. Не в последнюю очередь благодаря спроектированным Стевином укреплениям и хитроумной конструкции шлюзов, позволяющей затопить наступающие испанские войска, восстание увенчалось успехом - образовалось независимое голландское государство, принявшее название Республика Соединенных Провинций Нидерландов. Это стало началом голландского «Золотого века», когда страна обрела высочайшее культурное, научное и торговое значение, о котором сегодня напоминают работы таких великих художников, как Рембрандт и Вермеер. Стевин был истинным человеком Возрождения, его интересы отнюдь не ограничивались военно-инженерным делом. Хотя на протяжении большей части карьеры Стевин официально занимал пост генерал-квартирмейстера армии, на практике он стал советником принца Морица по науке. Помимо изобретения десятичных дробей и работ по полиномам и другим разделам математики, Стевин внес заметный вклад в физику. Он изучал статику и понял, что силы можно складывать, пользуясь тем, что мы сегодня называем «параллелограммом сил». Его труды проложили дорогу Ньютону и идущим вслед за ним. Он открыл связь между частотами соседних нот в 12-тоновой музыкальной системе. Стевин даже продемонстрировал постоянное ускорение падающих предметов - на несколько лет раньше Галилея. Стевин был также страстным защитником голландского языка, который до того времени считался второстепенным диалектом немецкого. Он помог принцу Морицу организовать инженерную школу, где сам преподавал на голландском языке, на нем же он писал учебники. С помощью частотного анализа и длины слов он «доказывал», что это лучший (самый эффективный) язык, в том числе для научных исследований. Стевин настаивал на публикации своих результатов на голландском, а не на латинском языке, быть может, поэтому он был так плохо известен за пределами своей родины. """-У*?£иг. №. ■ В Disme Стевин обобщил понятие числа с целых и дробей на «выражающее долю всякой вещи». По существу, Стевин изобрел понятие вещественного числа и числовой оси. На числовой оси можно представить любое количество, включая
120 ♦ Еше об алгебраических структурах отрицательные числа, иррациональные числа и то, что он называл «необъяснимыми» числами (возможно, имея в виду трансцендентные числа). Разумеется, у десятичного представления Стевина были собственные недостатки, например для записи простого значения ему требовалось бесконечного много цифр: У7 = 0.142857142857142857142857142857... Представление Стевина позволило решить ряд дотоле неразрешимых задач. Например, он показал, как вычислять кубические корни, доставлявшие столько неприятностей грекам. Его рассуждение походило на то, что впоследствии получило название «теорема о промежуточном значении» (см. врезку «Истоки двоичного поиска» в разделе 10.8), которая утверждает, что если непрерывная функция принимает отрицательное значение в одной точке и положительное в другой, то должна существовать промежуточная точка, в которой функция обращается в нуль. Идея Стевина заключалась в том, чтобы сначала найти отрезок, концами которого являются соседние целые числа, где функция меняет знак, затем разделить этот отрезок на десять частей и повторять этот процесс. Он понял, что за счет «увеличения масштаба» любую подобную задачу можно решить с произвольной точностью, или, как он сам выражался, «можно получить столько десятичных знаков [истинного значения], сколько желательно, и подойти к нему бесконечно близко». Хотя Стевин показал, как представить любое число на прямой, он не сделал следующего шага: представления точки на плоскости парой чисел. Это изобретение - которое мы сегодня называем декартовыми координатами - принадлежит великому французскому философу и математику Рене Декарту (по латыни Ренату с Картезиус). * * * Следующим крупным достижением Стевина было изобретение полиномов от одной переменной, которые он также ввел в рассмотрение в 1585 году в книге «Арифметика». Рассмотрим следующее выражение: АхА + lxi-x1 + 21 х - 3. До Стевина такое число можно было построить, только выполнив следующий алгоритм: взять число, возвести его в четвертую степень, умножить результат на 4 и т. д. То есть фактически нужен был новый алгоритм для каждого полинома. Стевин осознал, что полином - это всего лишь конечная последовательность чисел: в примере выше {4, 7, -1,27, -3}. На языке современной информатики мы могли бы сказать, что Стевин первым понял, что код можно рассматривать как данные. Благодаря озарению Стевина мы можем передавать полиномы как данные обобщенной функции вычисления. Мы напишем такую функцию на основе схемы Горнера, построенной так, чтобы не приходилось вычислять степени х выше 1 (это гарантируется ассоциативностью): 4х/1 + 7х3-х2 + 27х-3 = (((4х + 7)х-1)х + 27)х-3. Для полинома степени п требуется п умножений ип-т сложений, где т - количество нулевых коэффициентов. Обычно мы соглашаемся на п сложений, по-
Стевин, полиномы и НОЛ ♦ 121 тому что проверка необходимости каждого сложения обходится дороже, чем его безусловное выполнение. Ниже показано, как с помощью этого правила реализовать функцию вычисления полинома; аргументы first и last задают границы последовательности коэффициентов полинома: template <InputIterator I, Semiring R> R polynomial_value (I first, I last, R x) { if (first == last) return R(0); R sum(*first); while (++first != last) { sum *= x; sum += * first; } return sum; } Давайте подумаем, каковы должны быть требования к типам I и R. I - итератор, поскольку мы хотим обойти последовательность коэффициентов1. Но тип значения итератора (тип коэффициентов полинома) не обязан совпадать с полукольцом2 R (тип переменной х в полиноме). Например, из того, что коэффициенты полинома ах2+Ь - вещественные числа, вовсе не следует, что и х должно быть вещественным числом; это может быть нечто совершенно другое, например матрица. Упражнение 8-1 - Какие требования предъявляются к R и типу значений итератора? Иными словами, каковы требования к коэффициентам полинома и их значениям? Революционный прорыв Стевина позволил рассматривать полиномы как числа и производить над ними обычные арифметические операции. Для сложения или вычитания полиномов нужно просто сложить или вычесть их коэффициенты. Чтобы перемножить полиномы, необходимо вычислить все попарные произведения коэффициентов. То есть если а{ и Ь{ - г-е коэффициенты перемножаемых полиномов (начиная с члена самого низкого порядка), а с. - z-йкоэффициент результата, то с0 = а0и0 сх =а0Ь1 +аД с2 = а0Ь2 + ахЬх + a2bQ ck=H ai*>j k=i+j 1 Формальное обсуждение итераторов мы отложим до главы 10, а пока можете считать их обобщенными указателями. 2 Полукольцом называется алгебраическая структура, элементы которой можно складывать и умножать с соблюдением закона дистрибутивности. Формальное определение будет дано в разделе 8.5.
122 ♦ Еше об алгебраических структурах Для деления полиномов нам понадобится понятие степени. Определение 8-1- Степенью полинома deg(p) называется индекс самого старшего ненулевого коэффициента (иначе говоря, самая старшая присутствующая степень переменной). Например: deg(5)= 0; deg(x + 3) = l; deg(x3 + х - 7) = 3. Теперь можно определить деление с остатком. Определение 8.2. Полином а делится на полином Ь с остатком г, если существуют такие полиномы диг, что а = bq + r A deg(r) < deg(&). (Здесь q представляет частное от деления а на Ь.) Деление полиномов с остатком производится точно так же, как деление многозначных целых чисел: х- Зх2 -2|3х3 Зх3 + 2х- -Ах2 -6х2 2х2- 2х2- 2 -6х + -6х -4х -2х + -2х + 10 10 4 6 Упражнение 8-2. Докажите, что для любых двух полиномов р(х) и q{x)\ \.р{х) = q(x) -(x-xQ) + r^> p(x0) = г. 2. р(х0) = 0 => р(х) = q(x) • {х - х0). * * * Стевин понял, что для вычисления НОД двух полиномов можно использовать все тот же алгоритм Евклида (с которым мы познакомились в конце раздела 4.6); нужно только изменить типы: polynomial<real> gcd(polynomial<real> a, polynomial<real> b) { while (b != polynomial<real>(0)) { a = remainder(a, b); std::swap(a, b); } return a;
Геттинген и немецкая математика ♦ 123 Наша функция remainder реализует алгоритм деления полиномов, хотя частное не представляет для нас интереса. НОД полиномов широко используется в компьютерной алгебре для решения таких задач, как символическое интегрирование. Реализация Стевина - это квинтэссенция обобщенного программирования: алгоритм из одной предметной области может быть применен в другой похожей предметной области. Как и в разделе 4.7, мы должны показать, что этот алгоритм корректен, то есть что он завершается и действительно вычисляет НОД. Чтобы показать, что алгоритм завершается, мы должны убедиться, что он вычисляет НОД за конечное число шагов. Поскольку мы повторно вычисляем остаток от деления полиномов, то по определению 8.2: deg(r) < deg(6). Поэтому на каждом шаге степень г уменьшается. Так как степень - неотрицательное целое число, то убывающая последовательность степеней должна быть конечна. Чтобы доказать, что алгоритм действительно вычисляет НОД, мы можем воспользоваться тем же рассуждением, что в разделе 4.7, - оно применимо к полиномам точно так же, как к целым числам. Упражнение 8.3- Найдите НОД следующих полиномов: 1. 16л4-56л3-88л2+ 278^+105; 16л4 - 64х3 - 44л2 + 232л + 70. 2. 7х4 + 6л3-8л2-6л+1; 11л4+15х3~2л2-5л+1. 3. nxn+i-(n + l)xn+ 1; хп - пх+ (п - 1). 8.2. Геттинген и немецкая математика В XVIII и XIX веках, задолго до объединения Германии, начался расцвет немецкой культуры. Композиторы - Бах, Моцарт, Бетховен, поэты - Гёте и Шиллер, философы - Кант, Гегель и Маркс - создавали творения необычайной красоты и глубины. В немецких университетах сформировалась уникальная роль профессоров как государственных служащих, связанных присягой служить истине. В конечном итоге эта система породила величайших математиков и физиков своего времени, многие из которых учились или преподавали в Геттингенском университете. Геттингенский университет Центром немецкой математики стало, на первый взгляд, неподобающее место: Геттингенский университет. В отличие от многих европейских университетов, существовавших сотни лет еще со времен Средневековья, Геттингенский был относительно молод - основан в 1734 году. Да и сам город Геттинген был невелик и не слишком значителен. Тем не менее Геттингенский университет стал родным домом для поразительно боль-
124 ♦ Еще об алгебраических структурах шого числа выдающихся математиков: Гаусса, Римана, Дирихле, Дедекинда, Клейна, Минковского, Гильберта. С некоторыми из них мы еще встретимся на страницах этой книги. К началу XX века не менее впечатляющей была и когорта физиков - одни основатели квантовой механики Макс Борн и Вернер Гейзенберг чего стоят! Славе Геттингена пришел конец в 1933 году, когда нацисты изгнали из профессорско- преподавательского состава и студенческого корпуса всех евреев, включая многих физиков и математиков первой величины. Спустя несколько лет нацистский министр образования спросил великого немецкого математика Давида Гильберта: «Как теперь математика в Геттингене, после того как она освободилась от еврейского влияния?» Гильберт ответил: «Математика в Геттингене? Ее больше нет». Пожалуй, самым значительным математиком, вышедшим из стен Геттингена, был Карл Фридрих Гаусс, основатель немецкой математики в ее современном понимании. В числе его многочисленных достижений основополагающая работа по теории чисел, представленная в 1801 году в книге «Disquisitiones Arithmeticae» (Арифметические исследования). Для теории чисел эта книга Гаусса - все равно, что «Начала» Евклида для геометрии, - фундамент, на котором возводилось все позднейшее здание. Среди прочих результатов она содержит основную теорему арифметики, утверждающую, что всякое целое число может быть разложено на простые множители единственным образом. Карл Фридрих Гаусс (1777-1855) Карл Фридрих Гаусс вырос в немецком городе Брауншвейге и уже в раннем детстве прослыл вундеркиндом. Широко известна легенда о том, как учитель в начальной школе, желая занять детей на долгое время, задал сложить все числа от 1 до 100. Девятилетний Гаусс ответил через несколько секунд: заметив, что сумма первого и последнего числа равнялось 101, равно как сумма второго и предпоследнего и т. д., он просто умножил 101 на 50. Слух о талантах Гаусса достиг ушей герцога Бра- уншвейгского, который оплачивал обучение юного студента, начиная с 14 лет, сначала в его родном городе, а потом и в Геттингенском университете. Поначалу Гаусс подумывал об изучении классической филологии, которая, в отличие от математики, была в то время сильной стороной университета. Но он продолжал заниматься математикой самостоятельно и в 1796 году решил задачу, которая не давалась математикам со времен Евклида: как построить правильный 17-угольник с помощью циркуля и линейки? На самом деле Гаусс не остановился на этом и доказал, что построить правильный р-уго/]ьник для простого р можно, только если р - простое число Ферма, то есть имеет вид 22 + 1. Это достижение убедило его продолжить карьеру в математике. Он был так горд этим открытием, что завещал выгравировать на своей могиле правильный 17-угольник. В своей докторской диссертации Гаусс доказал теорему, которая ныне называется основной теоремой алгебры и утверждает, что любой отличный от константы полином с комплексными коэффициентами имеет комплексный корень. Свой гениальный трактат по теории чисел «Арифметические исследования» Гаусс написал еще в студенческие годы и опубликовал в 1801 году, когда ему было всего 24 года. Хотя в области теории чисел на протяжении веков работали такие великие математики,
Геттинген и немецкая математика ♦ 125 как Евклид, Ферма и Эйлер, именно Гаусс первым систематизировал этот раздел и заложил под него формальные основания, введя в рассмотрение арифметику по модулю. «Исследования» изучают и по сей день, и, более того, некоторые важные направления математики, развитые в XX веке, стали результатом внимательного прочтения труда Гаусса. Гаусс добился мировой известности в 1801 году, когда предсказал местоположение астероида Церера, применив разработанный им метод наименьших квадратов. За это он впоследствии был назначен директором астрономической обсерватории в Геттинге- не. Подобный интерес к практическим задачам вдохновлял его математический гений на протяжении всей карьеры. Работы по геодезии (науке об измерении Земли) привели его к созданию нового раздела науки - дифференциальной геометрии. Наблюдения над погрешностями подсказали идею гауссова распределения в статистике. На протяжении всей своей деятельности Гаусс очень придирчиво относился к качеству работы и публиковал только то, что считал наилучшими результатами, - малую долю всего полученного им. Нередко он откладывал публикацию, иногда на несколько лет, пока не находил совершенный способ доказательства результата. Его девизом было «немного, но зрело». За широту и глубину своих достижений Гаусс был признан «королем математиков». Еще одной новой идеей Гаусса было понятие комплексных чисел. Математики пользовались мнимыми числами (xi, где г1 = -1) больше 200 лет, но плохо понимали природу этих чисел и старались их избегать. Так было и на протяжении первых 30 лет карьеры Гаусса; из его записных книжек мы знаем, что он пользовался мнимыми числами для вывода некоторых результатов, но затем перестраивал доказательство, так чтобы в опубликованном варианте i не упоминалось (в одном письме он писал «метафизика i очень сложна»). Но в 1831 году Гаусса посетило гениальное озарение: он понял, что числа вида z = x + yi можно рассматривать как точки (х, у) на декартовой плоскости. И такие комплексные числа были ничуть не менее законными и непротиворечивыми, чем любые другие. Вот несколько определений и свойств комплексных чисел, которыми мы будем пользоваться: О комплексное число: z = х + z/z; О сопряженное комплексное число: z" = х - уг\ О вещественная часть: Re(z) = lA(z + z~) = х; О мнимая часть: Im(z) = lAi(z + z~) = z/; О норма: ||z|| = zz = x2 + z/2; О модуль: \z\ = Vj|z| = Vx2 + z/2; О аргумент: arg(z) = угол ф такой, что 0<ф<2яи — = соз(ф) + шп(ф). \z\ Модуль комплексного числа z - это длина вектора z на комплексной плоскости, а аргумент - угол между вектором z и вещественной осью. Например, |z| = 1, a arg(z) = 90°. Как Стевин для полиномов, так Гаусс для комплексных чисел продемонстрировал, что они в полной мере поддерживают обычные арифметические операции: О сложение: zt + z2 = (х{ + х2) + (ух + у2)г,
126 ♦ Еще об алгебраических структурах О вычитание: zx-z2 = (х{ -х2) + (у{- y2)i; О умножение: z{z2 = {ххх2 - уху2) + {х2ух + х{у2)г, ^ 1 J х у О деление: — - - л 2 2 х +у х2 + г/2 * \\Z\ Перемножить два комплексных числа можно и по-другому: сложив аргументы и умножив модули. Например, Сбудет иметь модуль 1 и аргумент 45° (потому что Ы = 1и45 + 45 = 90). Гаусс открыл также то, что мы сегодня называем гауссовыми целыми числами - комплексные числа с целыми коэффициентами. У гауссовых целых чисел есть ряд интересных свойств. Например, гауссово целое 2 не является простым, потому что равно произведению двух других гауссовых целых: 1 + г и 1 - г. Не существует корректно определенной операции деления гауссовых целых чисел, но можно определить деление с остатком. Для вычисления остатка от деления z{ на 22 Гаусс предложил следующую процедуру: 1. Построить на комплексной плоскости сетку, порожденную z2, zz2, -iz2 и -z2. 2. Найти на этой сетке квадрат, содержащий zv 3. Найти вершину w этого квадрата, ближайшую к zv 4. z1 - w и есть остаток. Гаусс понял, что при таком определении остатка можно применить алгоритм Евклида для нахождения НОД к комплексным числам, как показано ниже: complex<integer> gcd(complex<integer> a, complex<integer> b) { while (b != complex<integer>(0)) {
Геттинген и немецкая математика ♦ 127 а = remainder(а, Ъ) / std::swap(a, b); } return a; } Единственное, что мы изменили, - типы. * * * Работу Гаусса обобщил другой геттингенский профессор, Петер Густав Ле- жен-Дирихле. В терминологии Дирихле гауссовы комплексные числа имели вид t + wV^T, и Дирихле рассматривал их как частный случай чисел вида t + n^a} где а не обязательно равно 1. Он обнаружил, что такие числа имеют различные свойства в зависимости от а. Так, стандартный алгоритм нахождения НОД работает при a = 1, но отказывает при a = 5, поскольку в этом случае разложение на множители неоднозначно. Например: 21 = 3 ■ 7 = (1 + 2V^) ■ (1 - 2<-Ъ). Оказалось, что если алгоритм Евклида работает, то существует единственное разложение на множители. Поскольку в данном случае разложение не единственное, то алгоритм Евклида не работает. Важнейшим результатом Дирихле было доказательство того, что если aи Ь - взаимно простые числа (то есть gcd(a, Ъ) = 1), то существует бесконечно много простых чисел вида ak + Ъ. Большинство результатов Дирихле описано во второй величайшей книге по теории чисел, «Vorlesungen iiber Zahlentheorie» (Лекции по теории чисел). В этой книге сформулировано важное прозрение, которое мы взяли в качестве эпиграфа к главе 4: Вся конструкция теории чисел покоится на общем фундаменте, алгоритме нахождения наибольшего общего делителя двух чисел. Все последующие теоремы... - не более чем простые следствия результата этого исходного исследования... На самом деле эта книга была написана и опубликована уже после смерти Дирихле его более молодым коллегой по Геттингену, Ричардом Дедекиндом, на основе записей лекций Дирихле. Дедекинд оказался настолько скромен, что издал книгу от имени Дирихле, хотя в более поздние редакции поместил немало своих результатов. К несчастью, скромность Дедекинда повредила его карьере: он не смог получить постоянную должность профессора в Геттингене и вынужден был преподавать на факультете второстепенного технического университета. Дедекинд обратил внимание, что гауссовы целые числа и их обобщения Дирихле - частные случаи более общего понятия алгебраических целых чисел - линейных целочисленных комбинаций корней приведенных полиномов (у которых коэффициент при старшем члене равен 1) с целыми коэффициентами. Говорят, что такие полиномы порождают множества алгебраических целых чисел. Например:
128 ♦ Еше об алгебраических структурах О х2 + 1 порождает гауссовы целые числа а + Ьч-1; О Xs - 1 порождает целые числа Эйзенштейна а + Ъ ; О х2 + 5 порождает целые числа вида а + iV-^5. Работа Дедекинда по алгебраическим целым числам содержала почти все основные элементы современной общей алгебры. Но понадобились усилия еще одного великого математика, Эмми Нётер, чтобы совершить прорыв и довести абстрагирование до логического завершения. 83. Нётер и рождение обшей алгебры Революционная идея Эмми Нётер заключалась в том, что возможно получать результаты о некоторых математических сущностях, ничего не зная о самих сущностях. В терминах программирования мы могли бы сказать, что Нётер поняла, что в алгоритмах и структурах данных можно использовать концепции, ничего не зная о конкретных используемых типах. В некотором очень существенном смысле Нётер построила теорию того, что мы сейчас называем обобщенным программированием. Нётер научила математиков всегда искать самую общую формулировку теоремы. Так и в обобщенном программировании мы стремимся определять алгоритм в терминах наиболее общих концепций. Эмми Нётер (1882-1935) ______ Эмми Нётер родилась в близкой к академическим ©%^Щ^^< кругам немецко-еврейской семье. Ее отец был &^]$р** t0Q;?i ^4*;; заслуженным профессором математики в Эрлан- *$/$? -'jf^u-: L **L** *Л Ф Р )?v с,л«>^ генском университете. Хотя тогда это было очень g: ^'#х ' \ - %^ необычно для женщины, Нётер сумела поступить $; /^}>^€(' v*% в университет и в 1907 году защитила докторскую "''" ''""*' л диссертацию по математике. Затем она несколько лет продолжала работать в Эрлангене, помогая отцу и занимаясь преподаванием, не имея ни должности, ни зарплаты. В течение многих столетий академическая карьера была недоступна женщинам. За исключением уроженицы России Софьи Ковалевской, ставшей профессором математики в Стокгольме в 1884 году, в то время среди преподавателей математики в университетах женщин не было. Два величайших математика своего времени, Феликс Клейн и Давид Гильберт, признали талант Нётер и пришли к выводу, что она заслуживает должности преподавателя. Они также считали принципиальным не изгонять женщин из математики и устроили так, что в 1915 году Нётер поступила на работу в Геттинген. К сожалению, она не получила официального разрешения преподавать - профессорско-преподавательский состав противился ее назначению. В течение последующих четырех лет все курсы, которые читала Нётер, значились за Гильбертом; она считалась чем-то вроде неофициального заместителя. Даже в 1919 году, когда она наконец получила право преподавать от собственного имени, это была неоплачиваемая должность приват-доцента, некоего подобия адъюнкт-профессора.
Кольца ♦> 129 За время своей работы в Геттингене Нётер внесла огромный вклад в физику и математику. В физике ей принадлежит теорема Нётер, установившая фундаментальную связь между определенными симметриями и физическими законами сохранения (например, сохранения углового момента). Альберт Эйнштейн выражал восхищение теоремой Нётер, ставшей одним из самых значительных результатов в теоретической физике и заложившей основы многих ее разделов - от квантовой механики до теории черных дыр. В математике Нётер создала общую алгебру. Хотя ранее такие математики, как Коши и Галуа, работали с группами, кольцами и другими алгебраическими объектами, они интересовались исключительно конкретными экземплярами. Прорыв, совершенный Нётер, состоял в осознании того, что эти структуры можно изучать абстрактно, не обращая внимания на конкретные реализации. Нётер была выдающимся преподавателем, на ее лекции стекались студенты со всего мира. Под ее руководством эти молодые исследователи (которых часто называют «мальчики Нётер») создавали новую математику. В 1933 году, когда нацисты изгнали всех евреев из университетов, Нётер nepeexaj a в США. Несмотря на то что она была одним из величайших мировых математиков, ни один крупный университет не предложил ей работы, прежде всего из-за пола. В результате она устроилась на должность преподавателя небольшого женского ко ~е__ - в Брин-Море. В 1935 году в возрасте 53 лет Эмми Нётер умерла после неудачной операции по удалению кисты яичников. Ее вклад в математику считается фундаментальным и поистине революционным. Нётер всегда была готова прийти на помощь студентам и дарила им свои идеи для опубликования, но сама публиковалась довольно редко. По счастью, молодой голландский математик Бартель Ван дер Варден записал ее курс и издал книгу на основе ее лекций (о чем сообщил на титульной странице). Эта книга, «Современная алгебра», была первой, где описывался разработанный Нётер абстрактный подход. Книга «Современная алгебра» заставила принципиально по-новому взглянуть на математику. Изложенный в ней революционный подход - идея о том, что теоремы следует выражать в наиболее абстрактном виде, - заслуга Нётер. Большая часть математики - а не только алгебра - изменилась в результате ее работ, она научила людей думать по-другому. 8.4. Кольиа Одним из самых значительных вкладов Нётер стала разработка теории алгебраических структур, называемых кольцами}. Определение 8-3- Кольцом называется множество, в котором определены операции : х + z/, -х, ху\ константы: 0Ю 1R Термин «кольцо», предложенный Гильбертом, задумывался как метафора сообщества людей, объединенных общим предприятием, например шайки (ring) преступников. Ничего общего с ювелирными кольцами он не имеет.
130 ♦ Еше об алгебраических структурах и выполняются следующие аксиомы: X+(y+Z) = (x + y)+ 2\ х + 0 = 0+х = х; х + -х = -х + х = 0; х + у = у + х; x(yz) = (xy)z; \ф 0; Ох = хО = 0; Х(У + z)= ху + xz\ (у + z)x = ух + zx. Кольца1 обладают свойствами, которые мы привыкли ассоциировать с арифметикой целых чисел, - операторы выступают в роли сложения и умножения, причем сложение коммутативно, а умножение дистрибутивно относительно сложения. И действительно, кольца можно представлять себе как абстракцию целых чисел, а каноническим примером кольца как раз и является множество целых чисел Z. Отметим также, что любое кольцо является аддитивной группой, а потому и абелевой группой. Операция «сложения» должна быть обратима, от операции «умножения» этого не требуется. На практике математики записывают нули без индексов, как мы сделали в аксиомах. Например, при обсуждении кольца матриц «0» обозначает не целое число «нуль», а нулевую матрицу - нейтральный элемент относительно сложения. Помимо целых чисел, кольцами являются также: О множество матриц размерности пхпс вещественными коэффициентами; О множество гауссовых целых чисел; О множество полиномов с целыми коэффициентами. Говорят, что кольцо коммутативно, если ху = ух. Некоммутативные кольца обычно встречаются в линейной алгебре, где умножение матриц - некоммутативная операция. Напротив, кольца полиномов и алгебраических целых чисел коммутативны. Эти два типа колец дали начало двумя ветвям общей алгебры: коммутативной и некоммутативной алгебре. Говоря о кольцах, часто опускают слово «коммутативное» или «некоммутативное», считая, что оно подразумевается контекстом. За исключением разделов 8.5 и 8.6, мы в этой книге будем иметь дело только с коммутативной алгеброй - той, которой занимались Дедекинд, Гильберт и Нётер, - поэтому будем предполагать, что все наши кольца коммутативны. Определение 8.4. Элемент х кольца называется обратимым, если существует такой элемент х-1, что Некоторые математики определяют кольцо без нейтрального элемента относительно умножения (1) и относящихся к нему аксиом, называя кольца, в которых они имеются, унитарными кольцами] мы такого различия проводить не будем.
Кольца ♦ 131 В каждом кольце есть по крайней мере один обратимый элемент: 1. Но их может быть и больше; например, в кольце целых чисел Z обратимы элементы 1 и -1. Определение 8-5- Обратимый элемент кольца называется единицей этого кольца. Упражнение 8-4 (очень легкое). Какое кольцо содержит в точности один обратимый элемент? Каковы единицы в кольце гауссовых целых чисел? Теорема 8-1- Множество единиц замкнуто относительно умножения (то есть произведение двух единиц снова является единицей). Доказательство. Предположим, что а и b - единицы. Тогда (по определению единицы) аагх = 1 и ЬЬ~Х = 1. Следовательно: 1 = аагх =а-1 • а~] = a{bb~x)a~x = (ab){b~xa~x). Аналогично а~ха = 1 и b~xb = 1, следовательно: 1 = Ъ~ХЪ = b-{-l-b = b~\a-'d)b = (b-]a-[)(ab). Как видим, имеется элемент, который при умножении на ab с любой стороны дает 1, он и является обратным к ab: (ab)~x = Ь~{а~К Таким образом, ab является единицей. Упражнение 8-5- Докажите, что: О 1 является единицей; О элемент, обратный единице, является единицей. Определение 8-6- Элемент х кольца называется делителем нуля, если: 1) х*0; 2) существует такой элемент у Ф 0, что ху = 0. Например, в кольце Z6 остатков по модулю 6 элементы 2 и 3 являются делителями нуля. Определение 8-7- Коммутативное кольцо, не имеющее делителей нуля, называется областью целостности. Слово «целостность» напоминает о том, что его элементы ведут себя как целые числа - при умножении двух ненулевых элементов невозможно получить нуль. Вот несколько примеров областей целостности: О целые числа; О гауссовы целые числа; О полиномы над множеством целых чисел; х2 +1 О рациональные функции над множеством целых чисел, например (ра- х3-1 циональной функцией называется отношение двух полиномов). Кольцо остатков по модулю 6 не является областью целостности (кольцо остатков будет областью целостности, если модуль - простое число).
132 ♦ Еще об алгебраических структурах Упражнение 8.6 (очень легкое). Докажите, что делитель нуля не является единицей. 8.5. Умножение матриц и полукольца Примечание. В этом и следующем разделе предполагается знание основ линейной алгебры. Остальная часть книги не зависит от изложенного здесь материала, поэтому эти разделы можно опустить без ущерба для понимания. В предыдущей главе мы объединили алгоритм возведения в степень с умножением матриц для вычисления линейного рекуррентного соотношения. Как выясняется, эту технику можно применить и ко многим другим алгоритмам, если воспользоваться более общим понятием умножения матриц. Краткий обзор понятий линейной алгебры Вспомним некоторые основные операции над векторами и матрицами. Скалярное произведение двух векторов: 77 /=1 Иными словами, скалярное произведение равно сумме произведений соответственных элементов. Результатом скалярного произведения всегда является скаляр. Произведение матрицы на вектор: wi=lLxijvj' Умножение матрицы размерности п х ал на вектор длины m дает вектор длины п. При этом /-М элементом результирующего вектора является скалярное произведение /-и строки матрицы на исходный вектор. Произведение двух матриц: Если в произведении АВ = С матрица А имеет размерность k хт, а матрица В - размерность m х п, то С будет матрицей размерности к х п. Элемент на пересечении строки / и столбца/ матрицы С равен скалярному произведению /-й строки матрицы А иу-го столбца матрицы В. Отметим, что умножение матриц не коммутативно: не гарантируется, что АВ = ВА. Более того, часто корректно определено только одно из двух произведений АВ и ВА, потому что число столбцов первого сомножителя должно быть равно числу строк второго. Но даже и тогда, когда оба произведения определены, они почти всегда различны.
Умножение матриц и полукольиа ♦ 133 Точно так же, как раньше мы обобщили функцию возведения в степень на произвольную операцию, так теперь обобщим умножение матриц. Обычно мы рассматриваем умножение матрицы как вычисление нескольких сумм произведений в соответствии с приведенной выше формулой. Но с точки зрения математики важно лишь, чтобы были две операции: одна «типа плюс» - ассоциативная и коммутативная (обозначается знаком ©), другая «типа умножить» - ассоциативная (обозначается знаком ® ), причем вторая операция должна быть дистрибутивна относительно первой: а® (Ь 0 с) = а®Ь® а® с; (b®c)®a = b®a®c®a. Чуть выше мы видели как раз такую алгебраическую структуру - кольцо. Однако требования, предъявляемые к кольцу, для нас чрезмерны, точнее, нам ни к чему обратная относительно сложения операция. Нам нужно всего лишь полукольцо, кольцо без операции вычитания (-). Определение 8-8- Полукольцом называется множество, в котором определены: операции: х + у,ху константы: 0Ю 1R и выполняются следующие аксиомы: * + (У + z) = (х + у) + z; х+0 = 0+х = х] х + у = у + х] x(yz) = (xy)z\ 1* 0; Л /■»/• ^z: 'У Л ^= /у** Ох = хО = 0; х(у + z)=xy + xz] {у + z)x = yx + zx. В этом определении мы следуем принятому в математике соглашению обозначать операции знаками + и-,ане©и®. Но, как во всех алгебраических структурах, этими символами могут обозначаться любые две операции, лишь бы они удовлетворяли аксиомам. Канонический пример полукольца - множество натуральных чисел N. Хотя для натуральных чисел не определено обращение операции сложения, матрицы с целыми неотрицательными коэффициентами вполне можно перемножать. (На самом деле можно было бы еще ослабить требования, исключив нейтральные относительно сложения и умножения элементы и относящиеся к ним аксиомы, матричное умножение1 все равно будет определено корректно. Полукольцо без 0 и 1 можно было бы назвать слабым полукольцом?) Здесь предполагается прямолинейный алгоритм умножения матриц; для более быстрых алгоритмов необходимы более сильные теории.
134 ♦ Еше об алгебраических структурах 8.Б. Приложение: социальные сети и кратчайшие пути Полукольца можно использовать для решения самых разных задач. Предположим, к примеру, что имеется граф друзей, как в социальной сети, и вы хотите найти всех людей, связанных с вами каким-либо путем. Иными словами, требуется найти друзей, друзей друзей, друзей друзей друзей и т. д. Задача нахождения всех таких путей называется построением транзитивного замыкания графа. Чтобы вычислить транзитивное замыкание, возьмем булеву матрицу размерности п х п, в которой элемент xtj равен 1, если между г nj есть связь (в данном случае если человек i дружит с человеком/), и 0 в противном случае; будем также предполагать, что каждый человек дружит сам с собой. Вот небольшой пример: Ari Bev Cal Don Eva Fay Gia Ari 1 1 0 1 0 0 0 Bev 1 1 0 0 0 1 0 Cal 0 0 1 1 0 0 0 Don 1 0 1 1 0 1 0 Eva 0 0 0 0 1 0 1 Fay 0 1 0 1 0 1 0 Gia 0 0 0 0 1 0 1 Эта матрица показывает, кто с кем дружит. Мы можем применить к ней обобщенное матричное умножение, подставив вместо Ф операцию логического ИЛИ (V), а вместо <8> - операцию логического И (л). Говорят, что такое умножение порождено булевым или {V, /\}-полуколъцом. Умножив матрицу саму на себя с помощью этих операций, мы найдем друзей ваших друзей. Выполнив такое умножение п-\ раз, мы найдем всех членов каждой сети друзей. Поскольку многократное умножение матрицы на себя - не что иное, как возведение в степень, мы можем воспользоваться алгоритмом power для его эффективного вычисления. Разумеется, эта идея применима к вычислению транзитивного замыкания любого отношения. Упражнение 8.7. Применяя алгоритм возведения в степень из главы 7 к умножению матриц над булевым полукольцом, напишите функцию построения транзитивного замыкания графа. Воспользуйтесь этой функцией для нахождения социальной сети каждого человека в предыдущей таблице. Еще один пример классической задачи, которую можно решить таким способом, - нахождение кратчайшего пути между двумя вершинами в ориентированном графе, например:
Приложение: социальные сети и кратчайшие пути ♦ 135 Как и раньше, представим граф в виде матрицы размерности п х п - на этот раз ее элементы a(j будут представлять расстояние от узла i до узла j. Если между двумя узлами нет ребра, то первоначально в соответствующий элемент запишем бесконечность. А В С D E F G Го~ 00 7 | 00 | 00 1 оо 1 оо 6 0 00 оо 00 00 9 00 00 0 5 00 6 00 3 00 00 0 00 00 00 00 2 00 00 0 7 00 00 10 00 4 00 0 00 00 | 00 | оо | оо 3 8 0 В этом случае мы воспользуемся матричным умножением, порождаемым i o- пическим, или {тт;+}-полуколъцом. Ь};=тт(а}к+ац). Таким образом, операцией © здесь является min, а операцией ® - +. Как и раньше, мы возводим матрицу в степень п - 1. В результате мы узнаем длину любого кратчайшего пути, не превышающего п - 1 шагов. Упражнение 8.8- Применяя алгоритм power из главы 7 к умножению матриц над тропическим полукольцом, напишите программу нахождения длины кратчайшего пути в графе. Упражнение 8.9. Модифицируйте программу из упражнения 8.8, так чтобы она возвращала не только длину кратчайшего пути, но и сам кратчайший путь (последовательность ребер).
136 ♦ Еше об алгебраических структурах 8.7. Евклидовы кольца Мы начали эту главу с обобщения евклидова алгоритма нахождения НОД сначала на полиномы, затем на комплексные числа и т. д. Как далеко может завести такое обобщение? Иными словами, каковы самые общие математические сущности, для которых работает алгоритм вычисления НОД {область определения алгоритма)? Разработанные Нётер абстракции наконец-то позволили найти ответ на этот вопрос: область определения алгоритма вычисления НОД Нётер назвал евклидовым кольцом. Определение 8-9- Множество Е называется евклидовым кольцом, если: Е - область целостности; в Е определены операции quotient и remainder, такие, что Ьф О => а = quotient(a, b) • b + remainder(a, b); в Е определена неотрицательная норма ||лг||: Е — N, удовлетворяющая следующим условиям: ||а|| = 0 <=> а = 0; Ь*0=>\\аЬ\\>\\а\\; (remainder^, b)\\ < \\b\\. Здесь термин «норма» означает меру величины, его не следует путать с евклидовой нормой, возможно, знакомой вам из курса линейной алгебры. Для целых чисел норма - это абсолютная величина, для полиномов - степень полинома, для гауссовых целых - комплексная норма. Важно, что при вычислении остатка норма уменьшается и в конечном итоге обращается в нуль, поскольку ее значениями являются неотрицательные целые числа. Это свойство необходимо, чтобы гарантировать завершение алгоритма Евклида. * * * Вот теперь мы можем написать полностью обобщенную версию алгоритма вычисления НОД: template <EuclideanDomain E> Е gcd(E a, E b) { while (b != Е(0)) { а = remainder (a, b)/ std::swap(a, b); } return a; } Путь, по которому мы прошли, трансформируя алгоритм НОД из работающего только для отрезков прямой до чего-то, способного работать для весьма разнообразных типов, иллюстрирует следующий важный принцип:
Поля и другие алгебраические структуры ♦> 137 Чтобы обобщить нечто, не нужно добавлять новые механизмы. А нужно убрать ограничения и свести алгоритм к голой сути. 8.8. Поля и другие алгебраические структуры Полех - еще одна важная абстракция. Определение 8.10. Область целостности, в которой каждый ненулевой элемент обратим, называется полем. Если каноническим примером кольца являются целые числа, то каноническим примером поля - рациональные числа (Q). Приведем еще несколько важных примеров полей: О вещественные числа М; О поле остатков по простому модулю Ър\ О комплексные числа С. Простым полем называется поле, не содержащее истинного подполя (не совпадающего с самим полем). Как выясняется, у любого поля есть только одно из двух видов простых подполей: Q и Z . Характеристика поля равна р, если его простое подполе совпадает с Ър (полем целочисленных остатков по модулюр), и 0, если его простое подполе совпадает с Q. * * * Любое поле можно получить, начав с простого поля и добавляя элементы с соблюдением аксиом поля. Это называется расширением поля. В частности, поле можно алгебраически расширить, добавив дополнительный элемент, являющийся корнем некоторого полинома. Например, молшо расширить поле Q элементом л/2, не являющимся рациональным числом, поскольку это корень полиномах2 - 2. Можно также расширить поле топологически, «заполнив дыры». Рациональные числа оставляют лакуны на числовой оси, но у вещественных чисел лакун нет, поэтому поле вещественных чисел является топологическим расширением поля рациональных чисел. Молшо также расширить это поле на два измерения с помощью комплексных чисел. Удивительно, но других конечномерных полей, содержащих вещественные числа, не существует2. Все рассмотренные до сих пор алгебраические структуры строились над одним множеством значений. Но существуют также структуры, определенные над несколькими множествами. Например, важная структура, именуемая модулем, со- Термин поле этимологически связан с полем для исследований, а не с пшеничным полем. Существуют похожие на поля четырех- и восьмимерные структуры, которые называются кватерниопами и октонионами. Но это не совсем поля, потому что они не удовлетворяют некоторым аксиомам; как кватернионам, так и октонионам недостает коммутативности умножения, а в случае октонионов умножение еще и не ассоциативно. Других конечномерных расширений поля вещественных чисел не существует.
138 ♦ Еше об алгебраических структурах держит главное множество (аддитивную группу G) и вспомогательное множество (кольцо коэффициентов R) с дополнительной мультипликативной операцией RxG — G, которая удовлетворяет следующим аксиомам: a, b ER Ах, у Е G: (а + Ъ)х = ах + Ъх а(х + у) = ах + ау. Если кольцо R является еще и полем, то такая структура называется векторным пространством. Хорошим примером векторного пространства может служить двухмерное евклидово пространство, где векторы образуют аддитивную группу, а вещественные коэффициенты являются полем. 8.9. Заключительные мысли В этой главе мы проследили, как исторически обобщалась идея «числа» и вслед за ней алгоритм вычисления НОД. На этом пути мы встретились с несколькими новыми алгебраическими структурами, часть из которых использовали для обобщения умножения матриц и применили к решению важных для информатики задач теории графов. Расширим таблицу, начатую в разделе 6.8, добавив в нее новые структуры, введенные в этой главе. Отметим, что каждая следующая строка таблицы включает все аксиомы из предыдущих. (В случае полуколец и колец операция «умножить» наследует все аксиомы моноидов, а операция «сложить» - все аксиомы абелевых групп.) Чтобы подчеркнуть эту мысль, мы выделили светлым шрифтом операции, элементы и аксиомы, которые встречались в таблице раньше. Структура Операции Элементы Полугруппа х о у Аксиомы X о (у о z) = (X о у) о z Пример: положительные целые числа с операцией сложения | Моноид х о у е Пример: строки с операцией конкатенации Группа х о ц е х-1 X о (у о 7) = (;\: о у) о 7 х° е = е ° х х о (г/ о ;) = (л: о у) о г х о е =■ е ° х х о х~х = х~] о х= е Пример: обратимые матрицы с операцией умножения Абелева группа х о у е X о (у о z) = {X о у) о 7 х о е =* е о х х о д-1 = х"1 * х = е X о у = у о х | Пример: двумерные векторы с операцией умножения
Заключительные мысли ♦ 139 о iy кольцо х + У ху \ Пример: натуральные числа Кольцо х -ь у -х ху j Пример: целые числа о* и о„ i, х + (у + г) = (х + у) + z X + 0 - 0 + X = Д: х + // =• г/ + х x(ijz) ~ (.г7/)г 1 * 0 О.г = хО - 0 х{у + г) = ху + xz (у + Z)X = yX + ZX x + (y + z)~(x+ у) i z 1 х + 0 = 0 + х = .г х + -х = -х + х = 0 х + у = // + л: x(uz) = (.;\:i/?2 1 *= 0" Lr = xl ■=• д: (X-r = л-0 - 0 д:(# ^ 2) = д:?/ + xz (у + Z)X = yX + ZX Как и раньше, мы можем определить уточненные структуры в терминах уже имеющихся. Структура Область целостности Евклидово кольцо Шоле Простое поле Модуль Векторное пространство Определение Коммутативное кольцо без делителей нуля (отличных от 0 элементов, произведение которых равно 0) Область целостности, снабженная операциями quotient и remainder, а также нормой, которая убывает при вычислении остатка Область целостности, в которой каждый ненулевой элемент обратим (пример: рациональные числа) Поле, не имеющее истинных подполей Состоит из главного множества - аддитивной группы G - и вспомогательного множества - кольца коэффициентов R, а также дистрибутивной операции умножения коэффициентов на элементы G Модуль, в котором кольцо R является еще и полем
140 ♦ Еше об алгебраических структурах На диаграмме ниже показаны связи между наиболее важными из рассмотренных в этой главе структурами. Аддитивный моноид Мультипликативный моноид Область целостности Поле Когда впервые сталкиваешься с алгебраическими структурами, может показаться, что различных свойств так много, что запомнить их невозможно. Однако они укладываются в удобную таксономию, проясняющую связи между ними. Эта таксономия способствовала мощному прогрессу в математике на протяжении последних веков.
Глава -^ шФФШшш^ФФФтФФФтшФФФ®ШФФшттФФтттФФШФФФШФштФФттшФ Организация математических знаний Все математические истины взаимосвязаны, а любые способы их открытия равнодопустимы. Лежандр Сейчас мы рассмотрим некоторые базовые способы организации знаний, особенно "аний математических. Начнем с исследования понятия доказательства и идеи еорем. Затем познакомимся с важными примерами попыток построить корпус знаний на основе аксиом. Математики уже тысячи лет размышляют над тем, как организовать знания. Мы, программисты, можем воспользоваться разработанными ими принципами в присущей нам сфере алгоритмов и структур данных. 9.1. Доказательства Открывать и использовать математические результаты люди стали задолго до того, как начали доказывать их. И тем не менее математические доказательства - на удивление давнее изобретение. В течение многих веков математики полагались на наглядные доказательства. Позже древние греки поняли, что с помощью естественных для нас пространственных рассуждений можно доказывать алгебраические факты. Вот несколько примеров наглядных доказательств. Коммутативность сложения: а + b = b + а. Если приложить друг к другу две бумажные полоски, то общая длина будет одна и та же вне зависимости от того, какая полоска справа, а какая - слева. Этс видно из того, что правый рисунок - зеркальное отражение левого.
142 ♦ Организация математических знаний Ассоциативность сложения: (а + b) + c = a + (b + с). Если приложить друг к другу три бумажные полоски, то не имеет значения, в каком порядке это делать: сначала сложить первые две и добавить к ним третью, или сначала - две последние, а потом первую. В любом случае в итоге получится полоска одной и той же длины. Коммутативность умножения: аЪ = Ьа. У прямоугольника есть длина и ширина. Повернув его на бок, мы поменяем длину и ширину местами, но прямоугольник, очевидно, останется тем же самым. На самом деле такое же, по существу, рассуждение имеется в книге, написанной Дирихле в XIX веке, где он говорит, что как бы ни построить солдат - рядами или колоннами, их количество не изменится. Ассоциативность умноэюения: (ab)c = а{Ьс). / / / / ~7ZZ- и и к м И W м МММ V И и /L ;/ / / / /- ш и
Доказательства ♦ 143 Вдоль какой бы оси не разрезать этот прямоугольный параллелепипед, если потом сложить ломтики вместе, объем окажется одним и тем же. {а + Ь)2 = а2 + 2аЪ + Ь2: а а b Ь Очевидно, что прямоугольник в левом нижнем углу имеет такую же площадь, как прямоугольник в правом верхнем. Мало того что площади равны ab, так еще и сами прямоугольники конгруэнтны; чтобы убедиться в этом, нужно вырезать один, повернуть на бок и положить на другой. п > 3: Здесь мы вписали правильный шестиугольник (все стороны которого равны 1) в единичную окружность. Очевидно, что периметр шестиугольника меньше длины окружности, потому что отрезок прямой - кратчайшее расстояние между двумя точками, то есть любая сторона шестиугольника короче соединяющей ее вершины дуги окружности. Поскольку треугольники, на которые разбит шестиугольник, равносторонние, то длины их сторон равны 1, поэтому диаметр окружности равен 2. Таким образом, отношение длины окружности к ее диаметру (то есть п) больше отношения периметра шестиугольника (6) к диаметру (2). Упражнение 9-1 - Придумайте наглядные доказательства следующих тождеств: - - 2 = а2 - 2аЪ + Ь2]
144 ♦ Организация математических знаний а2 - Ь2 = (а + Ь)(а - Ь); (а + bf = а3 + Ъа1Ъ + ЗаЬ2 + Ь3; (а - bf = а3 - За2Ь + ЗаЬ2 - Ь\ Упражнение 9.2. С помощью наглядного доказательства найдите верхнюю границу я. * * * Как бы ни были полезны наглядные доказательства, этой техники недостаточно для доказательства всех математических предложений, а некоторые подобные доказательства уже не считаются достаточно строгими. В распоряжении современных математиков имеются разнообразные приемы доказательства, некоторые из них используются в этой книге и сведены воедино в приложении В. Доказательство демонстрирует связь между различными истинами. Но из чего именно состоит доказательство? В настоящее время используется такое определение. Определение 9.1. Доказательство предложения обладает следующими свойствами: О это рассуждение; О оно принято математическим сообществом; О оно устанавливает истинность предложения. Про второй пункт часто забывают: доказательство - это общественный процесс, а он со временем изменяется. Наша уверенность в правильности доказательства тем больше, чем больше людей понимают и соглашаются с ним. В то же время то, что считается убедительным доказательством сегодня, возможно, не будет казаться таковым через 300 лет; даже на некоторые доказательства, которые Эйлер - величайший математик XVIII века - считал достаточными, сегодня смотрят с неодобрением. А теперь обратимся к другому базовому элементу математического знания - теоремам. 9.2. Первая теорема В главе 2 мы упоминали, что античные цивилизации Средиземноморья считали источником математических знаний египтян. Когда греческая цивилизация только зарождалась, египетская существовала уже тысячи лет, поэтому неудивительно, что выдающиеся мыслители Древней Греции ездили в Египет учиться у жрецов и постигать их тайны. Первым таким человеком, имя которого дошло до нас, был Фалес Милетский. Фалес учился у египтян геометрии, но пошел дальше них. У египтян были алгоритмы, а Фалес изобрел теорему - предложение, выводимое
Первая теорема ♦ 145 к. ^,«ц> ■—Л'-'Л. к 2'Ь ,3 ч. ■■'.--:\й*г из других предложений. Ныне Фалес считается основателем западной философии и может также рассматриваться как первый математик. Фалес Милетский (начало VI века до н. э.) Примерно в 750 году до н. э. в различных прибрежных областях Средиземноморья и дальше на север, к Черному морю, начало формироваться новое общество. Сами себя они именовали эллинами, мы же называем их греками. Они происходили из небольшой гористой страны, где сама география препятствовала образованию крупного объединенного царства, как в других местах. Греки жили в небольших независимых городах-государствах, объединенных не центральной властью, а общим языком и культурой. Когда население города вырастало настолько, что его ресурсов переставало хватать, город посылал своих граждан основывать колонию - новый практически независимый город на берегу какой-нибудь удобной речной бухты. За 200 лет греки обосновались вокруг Средиземного моря, словно «лягушки вокруг пруда», по выражению Платона. Году в 600 до н. э. греческие колонии в Малой Азии (ныне территория Турции) разбогатели. Но вместо того чтобы тратить появившиеся в избытке деньги на роскошь, некоторые решили направить их на поддержку умственных занятий. Впервые в истории они стали искать ответы на вечные вопросы, например как устроен мир, за пределами мифологии. Первым, кто решил подвести под это прочные основания, стал Фалес Милетский. Фалес изобрел то, что античные греки позже назвали философией, а мы называем наукой. Он хотел найти естественные, не мифологические объяснения бытия. Он предположил, что все, что мы видим, сделано из единой материи: воды. Поэтому вся наблюдаемая реальность пребывает в одном из трех состояний - газ, жидкость, твердое тело, - и между этими состояниями происходят переходы. Во время своего пребывания в Египте Фалес собрал коллекцию геометрических алгоритмов и, вероятно, познакомился с астрономическими познаниями вавилонян. Геродот пишет, что Фалес умел предсказывать полное солнечное затмение за год. Аристотель - обычно считающийся надежным источником - сообщает, что Фалес сумел предсказать особенно богатый урожай олив, наблюдая за изменениями погоды, и, выкупив право использования всех имеющихся в округе давильных прессов, сделал себе состояние. Существуют и другие рассказы о его достижениях, например об открытии статического электричества. Что из этого правда, мы не знаем, но нет сомнений, что Фалес собрал много научных знаний и умел применять их к практическим задачам. Свои познания он не унес в могилу, ученики продолжили его дело. Но важнее всех его конкретных открытий был подход к постижению миру, который до сих пор лежит в основе всей науки. Теорема 9.1 (теорема Фа леса). Для любого вписанного в круг треугольника ABC, опирающегося на диаметр АС, угол при вершине В, противоположной диаметру (ZABC), равен 90°.
146 ♦ Организация математических знаний Доказательство. Рассмотрим треугольники, получающиеся при соединении точки В с центром круга D: Так как DA и DB - радиусы круга, то они равны, и треугольник ADB равнобедренный. То же самое верно в отношении DB, DC и треугольника ВВС. Поэтому ZDAB = zDBA; zDCB = zDBC; ZDAB + ZDCB = ZDBA + zDBC, где третье равенство получается сложением первых двух. Известно также, что сумма углов треугольников равна 180°, а, как легко видеть, zCBA равен сумме углов ZDBA и ZDBC, поэтому zDAB + zDCB + zDBA + zDBC= 180°. Подставляя сюда полученное выше равенство, мы можем написать: (zDBA + zDBC) + (zDBA + zDBC) = 180°; 2 (ZDBA + ZDBC) = 180°; ZDBA + ZDBC = 90°; ZCBA =90°.
Евклид и аксиоматический метод ♦ 147 Почему открытие Фалеса было настолько важно? Дело в том, что он осознал взаимосвязанность истин. Он увидел, что, имея один кусочек знания, можно с его помощью отыскать другой. К тому же теоремы неотъемлемы от идеи абстракции - ценность теоремы в том и состоит, что она применима ко всем сущностям, обладающим определенными свойствами. 9.3. Евклид и аксиоматический метод Доказательства и теоремы, безусловно, необходимы для построения системы знаний. Но нужен также набор начальных предположений, или аксиом, образующий основу такой системы. Впервые аксиоматический метод, позволивший построить полную математическую систему на базе немногих формальных принципов, появился в «Началах» Евклида. На самом деле многие века аксиомы Евклида оставались единственными известными примерами аксиом и применялись только в геометрии. Евклид разбил свои принципы на три группы: определения, постулаты и общие понятия. Он начал с 23 определений, относящихся к геометрическим фигурам. Вот некоторые из них1: 1. Точка есть то, что не имеет частей. 2. Линия же - длина без ширины. 23. Параллельные суть прямые, которые, находясь в одной плоскости и будучи продолжены в обе стороны неограниченно, ни с той, ни с другой «стороны» между собой не встречаются. Далее следуют пять «общих понятий»: 1. Равные одному и тому же равны и между собой. 2. И если к равным прибавляются равные, то и целые будут равны. 3. И если от равных отнимаются равные, то остатки будут равны. 4. И совмещающиеся друг с другом равны между собой. 5. И целое больше части. Сегодня мы выразили бы эти понятия в таком виде: 1. а = с Л b = с => a = b. 2. a = bAc = d^>a + c = b + d. 3. a = bAc = d=>a-c = b-d. 4. а = b => a = b. 5. а< а + b. Интересно, что, в отличие от 23 определений, понятия не ограничиваются геометрией, они в равной мере применимы и к целым числам. На самом деле эти общие понятия, например транзитивность отношения равенства, весьма существенны и для программирования2. 1 Перевод А. Д. Мордухай-Болтовского. 2 Определение регулярных типов в главе 7 базируется на понятиях Евклида.
148 ♦ Организация математических знаний Наконец, Евклид ввел пять знаменитых постулатов. Они сформулированы в терминах допустимых операций в «вычислительных механизмах» его геометрической системы. Допустим: 1. Что от всякой точки до всякой точки <можно> провести прямую линию. 2. И что ограниченную прямую <можно> непрерывно продолжать по прямой. 3. И что из всякого центра и всяким раствором <может быть> описан круг. 4. И что все прямые углы равны между собой. 5. И если прямая, падающая на две прямые, образует внутренние и по одну сторону углы, меньшие двух прямых, то продолженные эти две прямые линии, неограниченно, встретятся с той стороны, где углы меньшие двух прямых. Если бы мы формулировали систему Евклида сегодня, то назвали бы «общие понятия» и «постулаты» аксиомами - не требующими доказательства допущениями, на которых строится вся система. Пятый постулат Евклида, дающий средства для рассуждений о параллельных прямых, - самая важная аксиома в истории математики. Известный также как постулат о параллельных прямых, он выражает соотношение, изображенное на следующем рисунке: а + (3<180° Однако есть много эквивалентных формулировок этого постулата: О если дана прямая и точка, не лежащая на ней, то через эту точку можно провести не более одной прямой, параллельной данной1; О существует треугольник, сумма углов которого равна 180°; О существуют два подобных, но не конгруэнтных треугольника. 9.4. Альтернативы евклидовой геометрии Еще в те времена, когда Евклид сформулировал свои пять постулатов, математики чувствовали, что с пятым что-то не так. Интуитивно казалось, что первые четыре постулата более фундаментальны; быть может, пятый можно вывести из осталь- 1 Эта формулировка, в которой постулат о параллельных обычно дается в средней школе впервые была опубликована шотландским математиком Джоном Плейфэром в 1795 году и ее правильное название «аксиома Плейфэра».
Альтернативы евклидовой геометрии ♦ 149 ных, то есть он вообще не является аксиомой? Так начался поиск доказательства пятого постулата, продолжавшийся 2000 лет. Поучаствовали в нем такие знаменитости, как астроном (и математик) Птолемей (90-168), поэт (и математик) Омар Хайям (1050-1153) и итальянский иезуит Джованни Джироламо Саккери (1667-1733). Саккери написал книгу «Euclidus Vindicates» (Евклид, очищенный от пятен), в которой построил полную геометрическую систему, положив в ее основу предположение о ложности пятого постулата. Но затем провозгласил, что следствия получаются настолько странными, что этот постулат должен быть истинным. В XVIII веке большинство математиков не интересовалось аксиомами, но в XIX веке настроение изменилось. Математики обратили пристальное внимание на основания своей работы. Они пересмотрели геометрию - отказались считать Евклида неопровержимым и подвергли его предположения ревизии. Примерно в 1824 году русский математик Николай Лобачевский работал над этой проблемой. В какой-то момент он понял, что постулат о параллельных - лишь одно из возможных предположений, и противоположное ему имеет равное право на существование. Он исследовал, что получится, если видоизменить фразу «через точку можно провести не более одной прямой, параллельной данной», сказав «можно провести много прямых...». В отличие от Саккери, Лобачевский осознал, что получающаяся геометрическая система непротиворечива. Иными словами, изобрел совершенно новую неевклидову геометрию, которая иногда называется гиперболической. В геометрии Лобачевского подобные треугольники обязательно конгруэнтны. Представьте себе треугольники на поверхности сферы. Если треугольник небольшой, то занимаемая им часть поверхности почти плоская, поэтому сумма углов близка к 180°. Но когда треугольник увеличивается, увеличиваются и его углы - в силу кривизны поверхности. Модель Лобачевского похожа, только пространство искривлено в противоположную сторону, так что большим треугольникам соответствуют меньшие углы. Результаты Лобачевского, опубликованные в 1826 году, были приняты в штыки российским математическим сообществом, а сам Лобачевский подвергся остракизму. Единственным, кто признал обоснованность работы Лобачевского, был Гаусс, который выучил русский язык, чтобы прочитать его книгу в оригинале. Но в целом понадобилось много лет, прежде чем эта работа стала признанной частью математики. Сегодня открытие Лобачевского считается триумфальной поворотной точкой в истории математики. Николай Иванович Лобачевский (1792-1856) В начале XIX века Россия не была крупным математическим центром (несмотря на то что большая часть карьеры Эйлера прошла в Санкт-Петербурге). Выдающихся русских математиков не было. Но уже к середине XX века Россия стала математической сверхдержавой. Эта трансформация началась с первого великого русского математика Николая Ивановича Лобачевского. Лобачевский родился не в столицах и образование получил не в одном из двух основных университетов (Московском и Петербургском), его не посылали за границу учиться у
150 ♦> Организашя математических знаний ^^£У***2Ш,^ШЩ^С " <" ведущих мыслителей Европы. Он не происходил ни ** jA^:, & *ч? ' - • ,^^** .« из аристократии, ни из зажиточного среднего класса; вместе с братом он учился в местной школе на казенном содержании. Вырос он в Казани, провинциальном городе на Волге, в котором даже университета не было до 1805 года. Лобачевский поступил в недавно открывшийся университет в 1807 году. (Любопытно, что Лев Толстой и Ленин учились там ;V?^ же, но на несколько десятков лет позже.) Когда Лобачевский поступил в Казанский универ- *sV; .-/'* ич* - Г л-' . ситет, преподавателя математики там не было - -*"%£* студенты изучали предмет самостоятельно. К счастью, вскоре на работу был принят Мартин Бартельс, профессор, у которого учился Гаусс. г- ; ?* 4 J^f После получения степени магистра Лобачевский - - ■ * v;^>;; Ь '^:0- продолжил обучение у Бартельса частным обра- •^:.».«. . •;** Ч: зом и в 1814 году был назначен на должность адъюнкт-профессора. Большую часть своей карьеры он провел в этом университете, и в конце концов в 1827 году его избрали ректором. Несмотря на скромное происхождение, Лобачевский никогда не боялся бросить вызов устоявшимся мнениям. Его революционная работа по неевклидовой геометрии была представлена в 1826 году, но широкую известность получила только после издания книги в 1832 году. Книга подверглась публичному осмеянию Остроградским, известным русским математиком, который учился у Коши. Лобачевский продолжал работу над неевклидовой геометрией всю жизнь, шлифовал ее и издавал книги о ней на разных языках. В начале 1840-х годов Гаусс увидел важность работы и даже прочитал некоторые книги Лобачевского в оригинале. Гаусс рекомендовал избрать его членом-корреспондентом Геттингенского королевского научного общества - большая честь в то время. Но избрание не укрепило положения Лобачевского на родине, от остракизма со стороны российских коллег-математиков он страдал до конца своей карьеры. В последние годы жизнь Лобачевского приняла трагический оборот. Он потерял работу в университете, дом и большая часть имущества были проданы за долги, он пережил смерть двоих детей, а затем ослеп. Но даже в таких обстоятельствах он не бросил работу и диктовал свой новый большой труд «Пангеометрия» до самой смерти, наступившей в 1856 году. Часто новые идеи в математике или других науках независимо и почти одновременно приходят в голову разным людям. Так произошло и с неевклидовой геометрией. Примерно в то же время, когда Лобачевский работал в Казани, молодой венгерский математик Янош Больяи совершил аналогичное открытие. Через несколько лет его отец Фаркаш Больяи, хорошо известный профессор математики и друг Гаусса, включил результаты сына в приложение к собственной книге. Эту книгу Фаркаш отправил Гауссу И хотя в личных заметках Гаусс назвал юного Больяи гением, письмо, отправленное им Фаркашу, содержало обескураживающую весть: Если бы я начал с того, что не могу похвалить эту работу, ты, конечно, удивился бы. Но ничего другого я сказать не могу Хвалить ее значило бы хвалить самого себя. Дело в том, что все содержание работы, путь, избранный твоим сыном, результаты, к которым он пришел, - все почти полностью совпадает с размышлениями, время от времени занимавшими меня последние тридцать, а то и тридцать пять лет.
Формалистический подход Гильберта ♦ 151 Это письмо типично для Гаусса как в нежелании отдавать должное другим, так и в упорстве, с которым он отстаивал приоритет своих неопубликованных мыслей. (Сейчас мы знаем, что Гаусс действительно открыл многие из этих идей, но решил не публиковать их, опасаясь неблагоприятной реакции.) Почему он принял работу Лобачевского, но отверг труд Больяи, мы никогда не узнаем. Но какова бы не была причина, последствия оказались трагическими. Больяи был так подавлен ответом Гаусса, что больше никогда не пытался публиковать работы по математике. Хуже того, он повредился в уме. Наткнувшись впоследствии на книгу Лобачевского, он уверовал, что «Лобачевский» - псевдоним Гаусса, который, как он полагал, украл его идеи. * * * После открытия неевклидовой геометрии многие математики бились над вопросом, который казался им важным: какая математика действительно правильна, Евклида или Лобачевского? Гаусс воспринял этот вопрос совершенно серьезно и предложил остроумный эксперимент для проверки теории. Сначала найдем три горы, которые образуют треугольник и отстоят далеко друг от друга, но все же достаточно близко, чтобы человек, стоящий на вершине одной, мог в телескоп видеть две другие. Затем подготовим на каждой вершине геодезическое оборудование, способное точно измерить углы треугольника. Если сумма углов окажется равна 180°, то прав Евклид, если меньше 180° - то Лобачевский. На практике такой эксперимент так и не был проведен. Но со временем сам вопрос потерял актуальность. Другие математики в конце концов докажут независимость пятого постулата, показав тем самым, что если геометрия Евклида непротиворечива, то непротиворечива и геометрия Лобачевского. Ну а пока математики решили считать вопросы о соответствии реальности не относящимися к делу. Первоначально математику придумали, чтобы понять мир, в котором мы живем, но в конце XIX века ее стали рассматривать как чисто формальное упражнение. 9.5. Формалистический подход Гильберта Нужно добиться того, чтобы с равным успехом можно было говорить вместо точек, прямых и плоскостей о столах, стульях и пивных кружках. Давид Гильберт Давид Гильберт, пожалуй, величайший математик начала XX века, стоял во главе этого формалистического подхода. Он высказал точку зрения, которая в конечном итоге стала стандартом для математиков: если теория непротиворечива, то она истинна. Хотя все теоремы и доказательства Евклида правильны, по современным стандартам аксиомы выглядят несколько зыбко. Потребовалось 2400 лет, прежде чем нашелся человек, готовый подвести под геометрию более прочное основание. Десять лет Гильберт потратил на переосмысление Евклида и построение собствен-
152 ♦ Организация математических знаний ной аксиоматической системы для геометрии. Как следует из цитаты в эпиграфе, Гильберт полагал, что обоснованность аксиоматической системы не должна опираться на интуитивные геометрические соображения. Система Гильберта содержала гораздо больше аксиом, чем было у Евклида; многие положения, которые Евклид считал самоочевидными, сформулированы явно. Так, у Гильберта было: О 7 аксиом принадлежности (например: если две принадлежащие прямой точки принадлежат некоторой плоскости, то каждая принадлежащая этой прямой точка принадлежит указанной плоскости); О 4 аксиомы порядка (например: для любых двух точек на прямой существует точка, находящаяся между ними); О 1 аксиома параллельности; О 6 аксиом конгруэнтности (например: два треугольника конгруэнтны, если сторона и прилежащие к ней углы...); О 1 аксиома Архимеда; О 1 аксиома полноты. Геометрическая система Гильберта весьма сложна, она стала предметом нескольких читавшихся им курсов. К сожалению, к тому моменту, когда построение системы аксиом было завершено, у него уже не осталось сил для доказательства большого числа геометрических теорем. Труд Гильберта по аксиоматике евклидовой геометрии стал последней крупной работой на эту тему. Давид Гильберт (1862-1943) Давид Гильберт родился в немецком городе Кенигсберг (ныне Калининград, находится в России). Он изучал математику в Кенигсбергском университете, защитил докторскую диссертацию и в конечном итоге стал штатным сотрудником. В возрасте 33 лет он принял предложение стать профессором в Геттингенском университете. Там он и работал до конца своей карьеры. Выше, в разделе 8.2, мы видели, что Геттинген был центром математической вселенной, а Гильберт возглавил тамошнее математическое сообщество на пике славы факультета. Трудно передать отличающиеся поразительным разнообразием фундаментальные труды Гильберта и то глубочайшее влияние, которое они оказали на всю математику. ' *Ч:$:, *■■*', :i В своей ранней работе по теории инвариантов * "~ '*' Гильберт впервые применил неконструктивные доказательства - в то время это было совершенно неожиданной идеей. На самом деле сам подход прославил его не меньше, чем полученный результат. В наши дни неконструктивные доказательства считаются нормой. Когда его попросили сделать обзор алгебраической теории чисел (области, в которой работал Дедекинд), Гильберт написал том на 600 страниц под названием «Отчет о числах». В книге были собраны и снабжены пояснениями все сколько-нибудь существенные достижения в этой области. Хотя по большей части Гильберт только подвел итог трудам других ученых (с указанием авторства), предпринятая им систематизация дала толчок исследованиям и в конечном счете привела к работам Нётер по общей алгебре. »' *&'$'$"' jA<iH - .$ 'U '' , » \лу„. ' "£?-If \- '. f> -fc" v- >> '\;f'.*' ' г %' у Л ' " , »/'* '■•:Vi%- % * 1 , , '» . ! , - ffa *'/ ■ *1*'А " *V 'V/. •й>': 'х&\: ?J -. & ; у ' ;ч " V 11 ■*• * Л ^
Пеано и его аксиомы ♦ 153 В течение последних десяти лет, работая над геометрией, Гильберт пошел дальше Лобачевского и исследовал обоснованность всех аксиом Евклида. Его книга «Основания геометрии» не только ввела в оборот новую аксиоматику, но и впервые показала, как строить рассуждения и подвергать строгому анализу любую аксиоматическую систему. Гильберт трудился и в физике, он в значительной степени независимо от Эйнштейна и примерно в то же время разработал общую теорию относительности. Изобретение гильбертовых пространств - обобщение векторных пространств на бесконечное число измерений - стало важным элементом математических оснований квантовой механики. В 1900 году Гильберт прочитал в парижской Сорбонне лекцию, в которой перечислил 10 важных нерешенных математических проблем и призвал сообщество поработать над ними. Позднее перечень был расширен и в опубликованной статье включал уже 23 проблемы. Работа над этими проблемами, получившими название «проблемы Гильберта», во многом определила содержание математики в XX веке. Значительную часть своей 25-летней карьеры он посвятил формализации оснований математики - эта деятельность получила название «программа Гильберта». Хотя Курт Гёдель и Алан Тьюринг впоследствии доказали несостоятельность программы Гильберта, работа над ней привела к созданию современной теории вычислений. Гильберт был не только великим математиком, но и выдающимся педагогом, поддерживавшим более молодых коллег. После смерти своего лучшего друга, Германа Мин- ковского, Гильберт несколько лет потратил на редактирование и издание его работ. Он дал старт карьере Эмми Нётер (раздел 8.3). Он участвовал в совместных работах в нескольких областях; его лекции по физике легли в основу классического учебника, написанного в соавторстве с Ричардом Курантом. Слабостью Гильберта была его гордость немецкой культурой. Не без оснований он считал немецкую математику кульминацией 200-летнего развития. Он приглашал ученых со всего мира влиться в немецкое математическое сообщество. Но вместе с тем он полагал, что только немецкие исследования достойны внимания. Быть может, самый показательный пример - нежелание ни цитировать работу Джузеппе Пеано, выполненную в Италии, ни признавать ее революционное значение для оснований математики. В то же время взгляды Гильберта были полностью противоположны воззрениям нацистов (которые пришли к власти спустя несколько лет после его отставки в 1930 году), и он тратил немало времени на популяризацию работ многих коллег-евреев, включая своего лучшего друга Минковского и свою протеже Нётер. Грустно, что Гильберт стал свидетелем крушения всего, что так любил. Его друзья были изгнаны в ссылку, его когда-то великий факультет низведен до уровня посредственности, а его возлюбленная страна приняла идеологию, которую он презирал. Но его математическое наследство несли по всему миру ученики и соратники, оно живо и сегодня. 9.6. Пеано и его аксиомы Безусловно, каждый вправе выдвигать любые гипотезы, какие пожелает, и выводить логические следствия из этих гипотез. Но чтобы такая работа была достойна называться Геометрией, необходимо, чтобы эти гипотезы или постулаты выражали результаты более простых и элементарных наблюдений над физическими фигурами. Джузеппе Пеано Еще до того как Гильберт объявил о своей программе формализации математики, над аналогичными идеями работали другие люди. Одним из них был итальянский математик Джузеппе Пеано. Как видно из приведенной выше цитаты, Пеано все
154 ♦ Организаиия математических знаний еще занимали связи между математикой и реальным миром. В 1891 году он начал писать книгу «Formulario Mathematico» (Математический сборник)», которая должна была стать исчерпывающим трудом, содержащим все важнейшие математические теоремы, записанные в символической нотации, изобретенной Пеано. Многие предложенные им обозначения, в том числе символы кванторов и операций над множествами, используются по сей день. В 1889 году Пеано опубликовал набор аксиом, заложивших формальную основу арифметики. Их было пять, как и у Евклида. Существует множество N, называемое множеством натуральных чисел: 1. 30 GN. 2. VwgNiB/z'gN- называемый следующим. 3. V§cN:(0e§A \/п:пе§^п' e §)=>§ = N. 4. V/2, m е N : п' = т' => п = т. 5. VwGNrw'^O. По-русски это звучит так: 1. Нуль - натуральное число. 2. Для каждого натурального числа существует следующее за ним. 3. Если подмножество натуральных чисел содержит нуль и у каждого элемента этого подмножества есть следующий, то это подмножество совпадает со всем множеством натуральных чисел. 4. Если для двух натуральных чисел следующие одинаковы, то и сами эти числа одинаковы. 5. Нуль не является следующим ни для какого натурального числа. Особенно важна третья аксиома, называемая аксиомой индукции. Она говорит, что если взять любое подмножество S множества N, которое содержит нуль и для каждого элемента которого следующий за ним элемент также принадлежит Sy то S совпадает с N. Иначе говоря, не существует недостижимых натуральных чисел - если начать с нуля и на каждом шаге переходить к следующему числу, то рано или поздно мы доберемся до любого натурального числа. Во многих современных учебниках эту аксиому помещают последней, но мы придерживаемся порядка, установленного самим Пеано1. Аксиомы Пеано совершили переворот в арифметике. Вообще-то, он основывался на предшествующих работах Ричарда Дедекинда и Германа Грассмана, которые показали, как можно вывести некоторые базовые принципы арифметики. Но Пеано пошел дальше, его вклад настолько значителен, что с тех пор математики говорят об арифметике Пеано, а не просто об арифметике. Джузеппе Пеано (1858-1932) Джузеппе Пеано родился в крестьянской семье близ Турина на севере Италии примерно в то время, когда происходило объединение Италии в единое государство. Он учился в Туринском университете и впоследствии был принят в штат. Позже он стал также преподавать в Королевской военной академии. Из его достижений лучше всего известна В современных учебниках также часто считается, что натуральные числа начинаются с 1, а не с 0.
Пеано и его аксиомы ♦ 155 л кривая Пеано, целиком заполняющая область на плоскости, - непрерывное отображение одномерного отрезка на двумерный квадрат. Большую часть 1890-х годов Пеано работал над основаниями математики и своей знаменитой книгой «Formulario Mathematico» (Математический сборник). Formulario задумывалась как собрание всех математических результатов, представленных в формальной нотации. Это был шедевр, который не только закладывал основания математики, но и содержал материалы по самым разным темам со ссылками на источники на языке оригинала. Пеано подарил экземпляр книги английскому философу Бертрану Расселу, и она оказала сильное . ,mfg влияние на совместную с Уайтхедом работу Рас- „_ / ^Х. селу «Principia Mathematica» (Принципы математи- " *% ки), которая сыграла важную роль в становлении ранних теорий вычислений. Первоначально Пеано издал «Formulario Mathematico» на французском, но оказался недоволен неоднозначностью, присущей любому естественному языку. Кончилось это тем, что в 1900 году он решил, что единственный выход - изобрести не допускающий неоднозначных толкований универсальный язык науки и математики, а уже на нем писать тексты. Сконструированный Пеано языка назывался Latinesine Flexione («латынь без флексий»), но позже был переименован в «Interlingua». Идея заключалась в том, чтобы взять за основу латынь, но заменить все склонения, спряжения и неправильные слова набором простых и логичных правил. Пеано переписал «Formulario» на своем новом языке, это издание вышло в 1908 году. Вот как выглядят его знаменитые аксиомы на интерлингве: 0. Л/0 es classe, vel «numero» es nomen commune. 1. Zero es numero. 2. Si a es numero, tunc suo successivo es numero. 3. N0 es classe minimo, que satisfac ad conditione 0, 1, 2; [...] 4. Duo numero, que habe successivo aequale, es aequale inter se. 5. 0 non seque ullo numero. Пеано также начал использовать свою формальную нотацию в преподавании, что вряд ли снискало ему любовь студентов. Кроме того, он превратил все курсы, которые должен был читать, в обсуждение оснований математики, и в конечном итоге это стоило ему места в военной академии. Пеано мечтал, что другие ученые станут публиковать свои работы на интерлингве, но этого не произошло. Более того, лишь немногие вообще попытались прочесть книгу Пеано, и эта его работа прошла незамеченной. В конце жизни Пеано тратил почти все свое время на пропаганду интерлингвы и оказался почти забыт математическим сообществом, которое больше интересовали Гильберт и другие математики из Геттингена. Даже в наши дни, несмотря на принятие фундаментальных аксиом арифметики, сформулированных Пеано, большинство математиков не продвинулось дальше первой страницы его монументального труда. Эта книга давно уже не переиздается, а на английский вообще никогда не переводилась. Чтобы доказать необходимость всех аксиом, мы должны по очереди исключить каждую и продемонстрировать, что из оставшихся вытекают такие следствия, которые не отвечают нашим намерениям - в данном случае не соответствуют нашему представлению о натуральных числах.
15Б ♦ Организаиия математических знаний Исключение аксиомы о существовании нуля. Если исключить эту аксиому, то придется исключить и все аксиомы, в которых упоминается нуль. Поскольку нет начального элемента, то остальные аксиомы неприменимы и могут быть удовлетворены пустым множеством, которое, очевидно, не может служить моделью натуральных чисел. Исключение аксиомы о существовании следующего. Если исключить требование о существовании у каждого элемента следующего, то удовлетворять оставшимся аксиомам будут конечные множества, например {0} или {0, 1, 2}. Очевидно, что никакое конечное множество не отвечает нашему представлению о натуральных числах. (Однако в компьютерах мы от этой аксиомы отказываемся, потому что все наши типы данных конечны; например, с помощью типа uint64 можно представить только первые 264 целых чисел.) Исключение аксиомы индукции. Если исключить аксиому индукции, то мы окажемся в ситуации, когда сущностей, похожих на целые числа, больше, чем самих целых чисел. Эти «недостижимые» числа называются трансфинитными ординальными числами и обозначаются символом со. Так, мы могли бы получить множества вида {0,1, 2, 3,..., со, со + 1, со + 2...}, {0,1, 2, 3,..., со1? cot + 1, щ + 2,..., со2, со2 + 1, со2 + 2,...} и т. д. Исключение аксиомы обратимости следующего. Исключив требование о том, что для равных следующих равны предыдущие, мы допустим «р-образные» структуры, в которых у каждого элемента много предшествующих, одни из которых встречаются в последовательности раньше, а другие позже, например: {0,1,1,1,...}, {0,1,2,1,2,...} или {0,1,2,3,4,5,3,4,5,...}. Поскольку все такие структуры конечны, они, очевидно, не включают всех натуральных чисел. Исключение аксиомы «ни для одного элемента 0 не является следующим». Исключив эту аксиому, мы допустим структуры, циклически возвращающиеся к нулю, например: {0, 0,...} и {0,1, 0,1,...}. Эти структуры также конечны и не отвечают нашему представлению о натуральных числах. 9.7. Построение арифметики Установив, что все аксиомы Пеано независимы и, стало быть, необходимы для описания того, что понимается под целыми числами, мы можем построить арифметику на чисто теоретической основе. Для этого мы точно определим, что понимать под сложением и умножением двух натуральных чисел. Определение сложения: а + 0 = а; (9.1) а + V = (а + Ъ)'. (9.2) Мы не доказываем эти утверждения, они составляют само определение сложения. Из этого определения следуют все свойства сложения натуральных чисел. Например, вот как доказывается, что 0 - левый нейтральный элемент относительно сложения:
Построение арифметики ♦ 157 О + а = а. (9.3) база: 0 + 0 = 0. шаг индукции: 0 + а = а => 0 + а' = (0 + а)' = а'. База означает, что наше утверждение верно, когда а равно нулю. Мы знаем, что это так, из равенства (9.1) в определении сложения. На шаге индукции доказывается, что оно истинно и для любого а. В силу (9.2) мы знаем, что 0 + а' = (0 + а)'. Но по предположению индукции мы можем подставить а вместо 0 + а, получив а', поэтому 0 + а' = а'. Определение умножения: а-0 = 0; (9.4) a-b' = (a-b) + a. (9.5) Теперь мы можем доказать, что 0 • а = 0 - так же, как это было сделано для сложения: база: 0-0 = 0. шаг индукции: 0-<2 = <2=>0-<2' = 0-<2 + 0 = 0. Определение 1. Определим 1 как число, следующее за 0: 1 = 0'. (9.6) Как прибавить 1, мы знаем: а + 1 = а + 0' = {а + 0/ = а'. (9.7) Мы знаем также, как умножить на 1: <2-1=<2-0' = <2-0 + <2 = 0 + <2 = а. Мы также можем вывести из аксиом основные свойства сложения: Ассоциативность сложения: (а + Ь) + с = а + (Ь + с) база: (a + b) + 0 = a + b в силу (9.1) = а + (Ь + 0). в силу (9.1) шаг индукции: (а + Ъ) + с = а + (Ь + с) => (a + b) + d = ((a + b) + с)' в силу (9.2) = (а + (Ь + с)У по предположению индукцрш = а + (Ь + с/ в силу (9.2) = а + (Ь + с'). в силу (9.2) Доказательство коммутативности мы начнем с рассмотрения частного случая: а + 1 = 1 + а. (9.8) база: 0+1 = 1 в силу (9.3) = 1 + 0. в силу (9.1)
158 ♦ Организация математических знаний шаг индукции: а + 1 = 1 + а => d + 1 = d + 0' в силу (9.6) = (d + 0)' в силу (9.2) -((в+1) + 0)' в силу (9.7) = (в+ 1У в силу (9.1) = (1 + а)' по предположению индукции = 1 + а', в силу (9.2) Коммутативность сложения: а + b = b + a база: а + 0 = а в силу (9.1) = 0 + а. в силу (9.3) шаг индукции: a + b = b + a=> a + b' = a + (b+l) в силу (9.7) = (а + Ь) + 1 в силу ассоциативности сложения = (Ь + а) + 1 по предположению индукции = й + (я+1) в силу ассоциативности сложения = Ь + (1 + а) в силу (9.8) = (b+ 1) + а в силу ассоциативности сложения = Ь' + а. в силу (9.7) Упражнение 9.3. По индукции докажите: О ассоциативность и коммутативность умножения; О дистрибутивность умножения относительно сложения. Упражнение 9.4. Пользуясь индукцией, определите полный порядок на множестве натуральных чисел. Упражнение 9.5. Пользуясь индукцией, определите частичную функцию predecessor (предшественник) на множестве натуральных чисел. * * * Определяют ли аксиомы Пеано натуральные числа? Нет. Как писал сам Пеано, «число (целое положительное) невозможно определить (ввиду того, что идеи порядка, следования, агрегирования и т. д. столь же сложны, как идея числа)». Иными словами, если вы не знаете, что это такое, так аксиомы Пеано вам не помогут. Вместо этого они описывают имеющуюся у нас идею числа, формализуют наши представления об арифметике, давая возможность строить доказательства. Вообще, можно сказать, что любые аксиомы объясняют, но не определяют. Объяснение не обязано быть конструктивным, то есть оно может ничего не говорить о том, как достигнут результат. Даже если оно предлагает какой-то алгоритм, с вычислительной точки зрения он может быть крайне неэффективен. Ни один человек в здравом уме не станет производить сложение, последовательно вызывая функ-
Заключительные мыслм ♦ 159 цию получения следующего. Тем не менее у аксиом есть полезное назначение: они заставляют думать, какие свойства натуральных чисел существенны, а какие - нет. Именно под таким углом зрения полезно изучать документацию программного интерфейса. Для чего наложено это требование? Что произошло бы, если бы его не было? 9.8. Заключительные мысли Мы начали эту главу с рассмотрения понятия доказательства, формального - и в то же время социального - процесса демонстрации истинности предлоге. :.-. Мы видели, как с помощью доказательств устанавливаются связи межд/ i .т . - ми; системы доказательства - это способ организации знаний. Мы расс\ также открытие теорем и их важную роль - абстрагирование. Далее мы обсудили более развитый формализм для организации знании - аксиоматические системы. Мы видели, что геометрию и арифметику можно построить на чисто теоретической основе. Важнейшая роль аксиоматических систем состоит в том, что они уменьшают сложность знаний. Необязательно запоминать все истинные предложения, потому что их можно вывести из нескольких простых аксиом, пользуясь правилами вывода. Однако важно помнить, что исторически математика не начиналась с аксиом, из которых в дальнейшем выводились теоремы. Аксиомы были предложены только после того, как были хорошо осознаны взаимосвязи между теоремами и определены лежащие в их основе предположения. Такой же процесс имеет место и в программировании: для проектирования хороших абстракций нужно исследовать много реальных алгоритмов и понять, как они связаны между собой. Аксиоматические системы действительно помогают организовать знания, но в них предполагается, что уже наличествуют знания, требующие организации. Открытие теоремы важнее, чем ее доказательство, - не имеет смысла пытаться доказать некое утверждение, если нет причин полагать, что оно истинно. Иногда современные математики забывают об эмпирических источниках знания. В книге «Метод» великий древнегреческий математик Архимед писал о том, что любой способ добычи математических знаний допустим, в том числе измерение и эксперимент. Лишь после открытия математической истины следует пытаться строго доказать ее. Те же принципы применимы и к программированию: прежде чем пытаться доказывать правильность программы, нужно пытаться писать правильные программы - пусть даже методом проб и ошибок.
Глава lw $iifiiit§$iii$iii$iijii$i$$iiiiiiiii$iiiiiii000 Основные понятия программирования Всякий человек имеет естественное желание знать. Аристотель В этой главе мы познакомимся с некоторыми важными идеями обобщенного программирования, в том числе концепциями и итераторами. Мы рассмотрим также некоторые типичные задачи программирования, опирающиеся на эти идеи. Но сначала поговорим об истоках самого понятия абстракции. 101. Аристотель и абстракции На знаменитой картине Рафаэля, итальянского художника Возрождения, «Афинская школа» изображена толпа античных греческих философов (фрагмент картины показан на следующей странице). В центре стоят Платон и Аристотель, два самых главных философа античного мира. Платон показывает пальцем вверх, а его ученик Аристотель держит руку параллельно земле. Согласно распространенному истолкованию, Платон указывает на небо, подразумевая, что нам надлежит созерцать вечное, а жест Аристотеля означает, что мы должны изучать окружающий мир. На самом деле можно было бы сказать, что Платон изобрел математику, а Аристотель - изучение всего остального, особенно наук. Труды Аристотеля охватывают все на свете - от эстетики до зоологии. Аристотель (384-322 до н. э.) Аристотель родился в Стагире, городе на самом севере Греции. О его юношеских годах мы знаем очень мало, но известно, что в какой-то момент он решил отправиться в Афины в поисках мудрости. Он учился (а в какой-то период, возможно, и преподавал) в Академии Платона в течение примерно 20 лет. Вскоре после смерти Платона - возможно, раздосадованный тем, что тот не назначил его своим преемником, - Аристотель покинул Афины. В 343 году до н. э. царь Филипп Македонский поручил Аристотелю воспитание своего сына Александра и его друзей. Мы почти ничего знаем о взаимоотношениях Аристотеля с царевичем, но впоследствии, став царем и приступив к завоеваниям в Азии, которые при- *%- ; \>;*».. % j*r*^ >. "V
Аристотель и абстракции ♦ 161 несли ему эпитет «Великий», Александр посылал Аристотелю экзотические растения и животных для коллекции. Примерно в 335 году до н. э. Аристотель вернулся в Афины и основал собственную большую школу под названием Лицей. В последовавшие за тем 12 лет он явил миру самый поразительный свод знаний из всех, что мы знаем. Если его учитель Платон посвятил себя изучению вечных истин, то Аристотель стремился понять мир таким, каков он есть. Например, рассуждая о политике, Платон описывал, каким должно быть идеальное общество. А Аристотель в аналогичной ситуации просил учеников посетить каждый из важнейших греческих городов-государств и затем представить отчет об их государственном устройстве. Подход Аристотеля состоял в том, чтобы наблюдать, описывать и предлагать объяснения увиденному. Аристотель писал чуть ли не обо всем, что только можно вообразить. По свидетельствам заметных авторов того времени, слог Аристотеля был великолепен; увы, книги, которые он предназначал для публикации, например его диалоги, полностью утрачены. Вместо них до нас дошли краткие трактаты, скорее всего, заметки к лекциям. Тем не менее многие его работы, в том числе «Никомахова этика», «Политика» и «Метафизика», и в наши дни стоит прочитать. И даже безотносительно к стилю собрание его трудов составляет первый энциклопедический свод знаний. Хотя составленные Аристотелем научные описания содержат фактические ошибки, он был первым, кто стал практиковать научный подход к систематическому описанию мира. Его наблюдения включают даже такие детали, как способ размножения осьминогов.
1Б2 ♦ Основные понятия программирования Аристотель покинул Афины примерно в 322 году до н. э. и вскоре после этого умер. Другие античные философские учения (например, стоицизм, платонизм) надолго пережили своих основателей, но с Аристотелем этого не произошло. Греческая философия постепенно становилась все более обращенной вовнутрь и утрачивала интерес к аристотелевой идее изучения доступных для наблюдения явлений. Лицей быстро утратил свою значимость, и лишь немногие ученые во времена поздней Греции и Рима считали себя последователями Аристотеля. И даже когда его труды были заново открыты в Средние века, тогдашние ученые лишь рабски изучали написанное им, не пытаясь применить его методологию к наблюдению мира. Великое наследие Аристотеля - эмпирический подход, ставший основополагающим принципом всей современной науки. Кроме того, организация знаний, нашедшая отражение в делении современных университетов на факультеты, - прямое следствие таксономии, которую предложил Аристотель. Письменное наследие Аристотеля сохранялось до конца первого тысячелетия арабскими философами. В XII веке, когда христианские государи отвоевали значительную часть Испании у исламского государства Аль-Андалус, они обнаружили в городе Толедо библиотеку с огромным количеством книг на арабском языке, среди них были и переводы трудов Аристотеля. Впоследствии они были переведены с греческого на латынь, и Аристотель возродился в Европе. Аристотеля стали называть просто «Философ», а его работы стали частью общепризнанного знания. Великий андалусский философ Ибн Рушд (известный европейцам под именем Аверроэс) написал широко распространившиеся комментарии к Аристотелю, примиряющие его философию с учением ислама; он стал известен как «Комментатор». В XIII веке такие христианские ученые, как Фома Аквинский и Иоанн Дуне Скотт, показали, что Аристотель совместим и с христианством, это стали называть «очищением» Аристотеля. В итоге труды Аристотеля на сотни лет стали частью обязательной программы обучения в европейских университетах. Из важнейших работ Аристотеля отметим «Органон» - сборник из шести трактатов по различным аспектам логики, которые определили эту область знаний на последующие 2600 лет1. В «Категориях», первой части «Органона», Аристотель ввел понятие абстракции. Он писал о различии между индивидуальным, видовым и родовым. В наши дни эти понятия обычно воспринимаются как относящиеся к биологии, но Аристотель применял их ко всему Вид включает все «существенные» свойства типа вещей. Род может содержать несколько видов, каждый из которых характеризуется отличительными свойствами - признаками, которые отличают его от других видов в роде. Именно аристотелева идея рода (genus) и лежит в основе термина обобщенное (generic) программирование - это способ рассуждать о программировании на уровне родовых {genera - множественное число от genus), а не видовых свойств. 10.2. Значения и типы Теперь мы посмотрим, как некоторые из описанных выше идей соотносятся с компьютерным программированием. Для начала несколько определений. В отличие от других трудов Аристотеля, латинская версия «Органона» стала доступна европейцам гораздо раньше, Боэций перевел его в начале VI века.
Кониепиии ♦ 163 Определение 10.1. Элемент данных - это последовательность бит. 01000001 - пример элемента данных. Определение 10.2. Значение - это элемент данных вместе с его интерпретацией. Элемент данных без интерпретации не имеет смысла. Элемент данных 01000001 может быть интерпретирован как целое число 65, или как символ «А», или как что-то еще. Всякое значение должно быть связано с элементом данных в памяти; в таких языках, как C++ или Java, не существует способа сослаться на «бестелесные» значения. Определение 10-3. Тип значений - это множество значений, имеющих общую интерпретацию. Определение 10.4. Объект - это набор битов в памяти, содержащий значение определенного типа значений. Значение - это элемент данных вместе с его интерпретацией. Нигде в этом определении не говорится, что все биты объекта должны располагаться в непрерывной области памяти. На самом деле нередко бывает, что части объекта находятся в разных областях памяти, тогда они называются отдаленными частями. Объект называется неизменяемым, если его значение никогда не изменяется, и изменяемым в противном случае. Объект называется неограниченным, если он может содержать любое значение своего типа. Определение 10.5. Объектный тип - это единообразный метод хранения значений заданного типа значений в конкретном объекте и их извлечения из объекта при условии, что известен его адрес. То, что мы называем «типами» в языках программирования, - на самом деле объектные типы. C++, Java и другие языки программирования не предоставляют средств для определения типов значений1. Любой тип размещается в памяти и является объектным типом. 103. Концепции Весь смысл обобщенного программирования заключен в идее концепции. Концепция - это способ описания семейства взаимосвязанных объектных типов. Отношение между концепцией и типом точно такое же, как между теорией и моделью в математике или между родом и видом в научной таксономии, введенной Аристотелем. Характеристика итератора value_type в C++, вопреки своему имени, не возвращает тип значения в описанном здесь смысле. А возвращает она объектный тип значения, на которое указывает итератор.
164 ♦ Основные понятия программирования Естественные науки Род Вид Индивидуум Математика Теория Модель Элемент Программирование Концепция Тип или класс Экземпляр Примеры из программирования Integral, Character uint8 t,char 01000001 (65, 'A') Приведем несколько примеров концепций и некоторых принадлежащих им типов в C++: О Integral1: int8_t, uint8_t, intl6_t,... О Unsignedlntegral: uint8 t, uintl6 t,... О Signedlntegral: int8 t, int!6_t,... Концепции существуют во многих языках, но очень немногие предлагают механизм для их явного выражения2. Во многих языках программирования есть механизм для задания интерфейса типа, который должен быть реализован позже: абстрактные классы в C++, интерфейсы в Java и т. д. Однако эти механизмы определяют интерфейс полностью, включая строгие требования к типам аргументов и возвращаемых значений. Напротив, концепции позволяют специфицировать интерфейсы в терминах семейств родственных типов. Например, и в Java, и в C++ можно определить интерфейс, содержащий функцию size(), которая возвращает значение типа int32. В мире концепций может существовать интерфейс, содержащий функцию size (), которая возвращает значение любого целочисленного типа: uint8, intl6, int64 и т. д. Концепцию можно рассматривать как набор требований к типам или как предикат3, который проверяет, удовлетворяют ли типы этим требованиям. Требования могут относиться к: О операциям, которые обязан предоставлять тип; О семантике этих операций; О сложности в терминах времени или памяти. Говорят, что тип удовлетворяет концепции, если он отвечает этим требованиям. Впервые встречаясь с концепциями, программисты часто спрашивают, зачем включено третье требование - ограничения на сложность. Разве сложность не является всего лишь деталью реализации? Чтобы ответить на этот вопрос, рассмотрим реальный пример. Предположим, что мы определили абстрактный тип стека, но реализовали его в виде массива таким образом, что всякий раз, когда в стек что-то заталкивается, необходимо переместить каждый из уже хранящихся в нем элементов, чтобы освободить место для нового. Теперь операция заталкивания 1 Эта концепция относится к встроенным в C++ целочисленным типам. Существует более общая концепция Integer, которая включает все эти типы, а также другие представления целых чисел, например целые с неограниченной точностью. 2 Были поданы предложения о включении концепций в C++; эта работа сейчас ведется. Похожие на концепции средства имеются в некоторых языках функционального программирования, например в Haskell. 3 Предикатом называется функция, возвращающая true или false.
Кониешии ♦ 1Б5 в стек из быстрой (требующей постоянного времени) превратилась в медленную (требующую линейного времени). Это нарушает предположение программиста о том, как должен вести себя стек. В некотором смысле стек, не обладающий быстрыми операциями заталкивания и выталкивания, вообще не является стеком. Поэтому без этих очень общих ограничений на сложность идея об удовлетворении концепции будет неполна. * * * С концепциями связаны два полезных понятия: функции типа и атрибуты типа. Функция типа - это функция, которая получает тип и возвращает связанный с ним другой тип. Например, полезно было бы иметь такие функции типа: О value_type(Sequence); О coefficient_type (Polynomial); О ith_element_type (Tuple, size_t). К сожалению, в основных языках программирования функций типов нет. хотя реализовать их было бы несложно (в конце концов, компилятор уже знает, например, о типах элементов последовательности). Атрибут типа - это функция, которая получает тип и возвращает значение, представляющее один из его атрибутов, например: О sizeof; О alignment_of; О количество членов структуры; О имя типа. В некоторых языках имеются атрибуты типов, например sizeof () в С и C++. * * * Рассмотрим некоторые очень общие концепции. Первая - Regular1, мы рассматривали ее в главе 7. Грубо говоря, тип называется регулярным, если поддерживает следующие операции: О конструирование копированием; О присваивание; О сравнение на равенство; О уничтожение. Наличие копирующего конструктора влечет за собой наличие конструктора по умолчанию, потому что Т а (Ь) должно быть эквивалентно Та; а = Ь;. Чтобы описать семантику Regular, мы выразим эти требования в виде аксиом: У а УЬ Ус : Ta(b) => (b = с=> а = с); У а УЬУс.а Ъ => (Ь = с => а = с); V/ G Regular Function :a = b => f(a) = f(b). Первая аксиома означает, что если конструируем а копированием Ьу то все, что было равно Ь, будет теперь равно также и а. Вторая аксиома говорит, что если b По соглашению, имена концепций начинаются с заглавной буквы и набраны Таким Шрифтом.
1ББ ♦ Основные понятия программирования присвоить а, то все, что было равно Ь, будет теперь равно также и а. В третьей аксиоме используется понятие регулярной функции (не путайте с регулярным типом), которая дает одинаковые результаты при одинаковых выходных параметрах. Программист сам должен определить, какие функции предполагаются регулярными; только тогда другие программисты (или - в будущем - компилятор) смогут полагаться на тот факт, что эти функции сохраняют равенство. Требования к сложности в Regular заключаются в том, что любая операция не сложнее линейной в области объекта, где под областью понимается все пространство, занятое объектом - как его заголовком, так и отдаленными частями, как данными, так и их связями1. Концепция Regular универсальна - она так или иначе присутствует во всех языках программирования. В любом языке программирования тип считается универсальным, если удовлетворяет некоторым требованиям. С концепцией Regular тесно связана концепция Semiregular, отличающаяся тем, что равенство явно не определено. Это необходимо в тех немногих ситуациях, когда реализовать предикат сравнения на равенство очень трудно. Но даже в таких случаях предполагается, что равенство определено неявно, поэтому аксиомы, управляющие копированием и присваиванием, остаются справедливы. В конце концов, как мы видели, смысл присваивания Ь значения а заключается в том, что значение Ъ становится равным значению а после присваивания. 10.4. Итераторы Концепция итератора используется для того, чтобы указать, в каком месте последовательности мы находимся. На самом деле первоначально итераторы предполагалось назвать «координатами» или «позициями»; их можно рассматривать как обобщение указателей. В некоторых языках программирования итераторы - тяжеловесная конструкция, перенасыщенная функциональностью, но сама концепция итератора всего лишь выражает понятие позиции. Чтобы быть итератором, тип должен поддерживать следующие операции: О операции регулярного типа; О переход к следующему; О разыименование. Итератор можно представлять, в частности, как «нечто, позволяющее выполнять линейный поиск за линейное время». Весь смысл итератора заключается в понятии следующего. Собственно, итераторы прямо вытекают из аксиом Пеано, точнее, концепция Iterator - «это теория с понятием следующего». Однако наши концепции итераторов будут менее строгими, потому что аксиомы Пеано в полном объеме нам ни к чему. Например, в арифметике Пеано у каждого числа Более формальную трактовку концепции Regular см. в книге «Elements of Programming», раздел 1.5.
Категории, операиии и характеристики итераторов ♦ 1Б7 существует следующее за ним, тогда как в случае итераторов это необязательно - иногда мы достигаем конца данных. Пеано также требует: если следующие равны, то должны быть равны и предыдущие, и настаивает на отсутствии циклов. В программировании эти требования не всегда выполняются; нам разрешается иметь структуры данных, которые ссылаются на предшествующие элементы и образуют циклы. Иногда только таким способом и можно решить вычислительную задачу эффективно. Вторая операция итератора -разыменование - позволяет получить от итератора его значение. На разыменование налагается требование к сложности; предполагается, что это «быстрая» операция, то есть не существует более быстрого способа получить доступ к данным через итератор. Иногда итераторы по размеру больше обычных указателей - в тех случаях, когда для обеспечения быстрой навигации в них необходимо хранить дополнительную информацию. Итератор может поддерживать специальное значение, означающее, что он уже вышел за пределы объекта, а также особое значение, по аналогии с нулевым указателем, которое нельзя разыменовать. Нет ничего страшного в разыменовывании частичной функции (определенной не для всех значений); ведь математики не затрудняются в определении деления, хотя деление на нуль не определено. Операции разыменования и перехода к следующему тесно связаны1, и эта связь налагает следующие ограничения: О разыменование определено для итератора тогда и только тогда, когда определен следующий; О если итератор не находится в конце диапазона, то разыменование разрешено. Почему к итераторам предъявляется требование сравнения на равенства? Иными словами, почему итераторы должны быть регулярными, а не полурегулярными? Потому что нам нужно знать, когда один итератор «догоняет» другой. 105. Категории, операиии и характеристики итераторов Существует несколько видов итераторов, которые мы называем категориями. Ниже перечислены наиболее важные. О Итераторы ввода поддерживают обход в одном направлении и только один раз - как в однопроходных алгоритмах. Каноническая модель итератора ввода - позиция в потоке ввода. Поступающие по проводу байты мы можем обработать только один раз, после чего они становятся недоступны. В частности, для итераторов ввода из i == j не следует, что -+i == т+j; например, если вы уже потребили символ из потока ввода, то не можете потребить его еще раз с помощью другого итератора. Имейте в виду, что из того, что ал го- Подробнее о связи между разыменованием и переходом к следующему см. главу 6 книги «Elements of Programming».
1Б8 ♦> Основные понятия программирования ритм требует всего лишь итератора ввода, не следует, что он может применяться только к потокам ввода. О Однонаправленные итераторы тоже поддерживают только обход в одном направлении, но такой обход можно повторять сколь угодно много раз, как в многопроходных алгоритмах. Каноническая модель однонаправленного итератора - позиция в односвязном списке^. О Двунаправленные итераторы поддерживают обход в обоих направлениях с произвольным количеством повторов (то есть их тоже можно использовать в многопроходных алгоритмах). Каноническая модель двунаправленного итератора - позиция в двусвязном списке. У двунаправленного итератора функция перехода к следующему обратима: если у элемента х имеется следующий z/, то у элемента у имеется предыдущий. О Итераторы с произвольным доступом поддерживают алгоритмы с произвольным доступом, то есть позволяют обратиться к любому элементу за постоянное время, как бы «далеко» он ни был расположен. Каноническая модель - позиция в массиве. Существует и еще одна употребительная категория итераторов, которая ведет себя иначе, чем прочие. О Итераторы вывода поддерживают чередующиеся операция перехода к следующему (++) и разыменования (*), но результат разыменования итератора вывода может встречаться только в левой части оператора присваивания, а функция равенства не предоставляется. Каноническая модель итератора вывода - позиция в потоке вывода. Мы не можем определить равенство, потому что после того как элемент выведен, к нему даже доступа нет. Хотя в C++ включены только описанные выше итераторы, существуют и другие полезные концепции итераторов. О Связанные итераторы применяются в ситуации, когда следующий элемент может изменяться (например, для связанного списка, структура которого модифицируется). О Сегментированные итераторы предназначены для случаев, когда данные хранятся в несмежных сегментах, каждый из которых содержит непрерывные последовательности. Структура данных std:: deque, реализованная в виде сегментированного массива, получила бы от этого несомненный выигрыш; вместо того чтобы поручать операции перехода к следующему проверку достижения конца сегмента, итератор «верхнего уровня» мог бы находить следующий сегмент и определять его границы, а итератор «нижнего уровня» обходил бы этот сегмент. Реализовать подобные итераторы нетрудно. Из того что некая концепция не встроена в язык, еще не следует, что она бесполезна. Вообще говоря, STL следует рассматривать как набор тщательно подобранных примеров, а не как Мы предполагаем, что во время обхода структура списка не изменяется.
Категории, операиии и характеристики итераторов ♦ 1Б9 исчерпывающую коллекцию всех на свете полезных концепций, структур данных и алгоритмов. * * * Простая, но важная вещь, которая иногда бывает необходима, - найти расстояние между двумя итераторами. Для любого итератора ввода молено написать такую функцию distance (): template <InputIterator I> DifferenceType<I> distanced f, I 1, std: :input_iterator_tag) { // precondition: valid_range(f, 1) DifferenceType<I> n(0); while (f != 1) { ++f; ++n; } return n/ } В этом коде следует отметить три момента: использование функции типа DifferenceType, использование тега итератора std: :input_iterator_tag в качестве аргумента и предусловие. Вскоре мы их обсудим, но сначала сравним эту реализацию с другой - оптимизированной для итераторов с произвольным доступом: template <RandomAccessIterator I> DifferenceType<I> distanced f, I 1, std: :random_access_iterator_tag) { // precondition: valid_range(f, 1) return 1 - f; } Поскольку имеется произвольный доступ, нам не нужно повторно инкремен- тировать (с подсчетом количества таких операций) первый итератор, чтобы сравняться со вторым; мы можем для нахождения расстояния просто воспользоваться операцией с постоянным временем выполнения - вычитанием. Тип разности для итератора - это целочисленный тип, достаточно большой для представления максимально возможного диапазона. Например, если бы итераторы были указателями, то в качестве типа разности в C++ можно было бы взять ptrdiff_t. Но в общем случае мы не знаем заранее, каким будет тип итератора, поэтому должны получать тип разности от функции типа. Хотя в C++ нет общего механизма функций типа, у итераторов STL имеется специальный набор атрибутов, которые называются характеристиками (traits), и один из них как раз и дает тип разности. Перечислим все характеристики итераторов: О value_type; О reference; О pointer; О difference_type; О iterator_category.
170 ♦ Основные понятия программирования Мы уже упоминали функцию типа value_type; она возвращает тип значений, на которые указывает итератор. Характеристики reference и pointer в современных архитектурах используются редко1, но остальные две очень важны. Поскольку синтаксически обращение к характеристикам итератора довольно громоздко, мы реализуем собственную функцию типа для доступа к dif ference_ type с применением конструкции using из С++11 (дополнительные сведения о using см. в приложении С). template <InputIterator I> using DifferenceType = typename std::iterator_traits<I>::difference_type/ Это и есть функция типа DifferenceType, встретившаяся в показанном выше коде. Характеристика итератора iterator_category возвращает тип тега, представляющего категорию итератора, с которым мы имеем дело. Объекты подобных типов не содержат данных. Как и в случае DifferenceType, определим следующую функцию типа. template <InputIterator I> using IteratorCategory = typename std::iterator_traits<I>::iterator_category; Теперь мы можем вернуться к использованию тега итератора в качестве аргумента функции distance. Показанные в примерах теги итераторов (input_iterator_ tag и random_access_iterator_tag) - возможные значения характеристики iterator_ category, поэтому, включив их в качестве аргумента, мы сможем различить сигнатуры двух реализаций функции по типу (в главе 11 мы еще увидим применение этого приема). Это позволяет осуществить диспетчеризацию по категории для функций distance; то есть мы можем написать общую форму функции для каждой категории итераторов, а во время компиляции будет выбрана самая быстрая: template <InputIterator l> DifferenceType<I> distance(I f, I 1) { return distance(f, 1, IteratorCategory<I>()); } Отметим, что третий аргумент - это вызов конструктора, который создает экземпляр подходящего типа, поскольку передавать функциям типы синтаксис запрещает. Клиент, вызывающий distance(), пользуется показанным здесь вариантом с двумя аргументами. А уже этот вариант вызывает реализацию, соответствующую категории итератора. Вся эта диспетчеризация происходит на этапе компиляции, и общая функция встраивается, так что выбор правильной версии функции производится фактически вообще без издержек. 1 В ранних версиях архитектуры процессоров Intel существовали разные типы для коротких и длинных указателей, поэтому было валено знать, какой тип соответствует данному итератору. В наши дни если тип значения итератора Т, то характеристика pointer обычно совпадаете Т*.
Диапазоны ♦ 171 Использование типов тегов для различения вариантов функции может показаться избыточным, поскольку мы уже указали различные концепции в шаблонах. Вспомните, однако, что концепции у нас всего лишь играют роль документации для программиста - современные компиляторы C++ о концепциях ничего не знают. Вот когда концепции будут добавлены в язык, тогда заумный механизм тегов категорий итераторов станет не нужен. 10.6. Диапазоны Диапазон - это способ задания непрерывной последовательности элементов. Диапазоны могут быть полуоткрытыми или замкнутыми*] замкнутый диапазон [ij] включает концы г и), а полуоткрытый диапазон [i,j) включает г, но заканчивается непосредственно перед;. Как выясняется, для определения интерфейсов удобнее всего полуоткрытые диапазоны. Объясняется это тем, что алгоритм, работающий с последовательностью из п элементов, должен иметь возможность обращаться к/2+ 1 позициям. Например, существует п + 1 мест, куда можно вставить новый элемент: перед первым элементом, между любыми двумя элементами или после последнего элемента. Кроме того, с помощью полуоткрытого диапазона - в отличие от замкнутого - можно описать пустой диапазон. И нак не- , . у< .ф ты и пустой диапазон можно указать в качестве аргумента, он несет г *_ ,\ *юрма- ции, чем просто «nil» или пустой список. Диапазон можно задать двумя способами: ограниченный дна ~ ^ т ;. ^еляется _з\ мя итераторами (один указывает на его начало, а второй - на •>-*. ч- концом), а счетный диапазон - итератором, указывающим на начало, и - \ — l . \ 1. указывающим, сколько элементов в него включать. Таким о6р;г \_, - . . -- чается четыре разновидности диапазонов: Полуоткрытый Замкнутый Ограниченный: два итератора [ij) [ij] Счетный: итератор и целое [г, п) [г, п] (Для замкнутого счетного диапазона должно быть п > 0.) Как мы увидим, в одних ситуациях предпочтительнее ограниченные диапазоны, в других - счетные. В книгах по математике индекс первого элемента последовательности обычно равен 1, но в информатике принято считать его равным нулю, и мы будем следовать этому соглашению при работе с диапазонами. Интересно, что изначально индексация, начинающаяся с 0, служила способом задать смещение в памяти, но оказалось, что такое соглашение более естественно независимо от реализации, поскольку означает, что для последовательности из п элементов индексы находятся в диапазоне [0, п), и число шагов в любой итерации ограничено ее длиной. 1 В математике применяются также открытые диапазоны, но в программировании они не столь полезны, поэтому мы их здесь не рассматриваем.
172 ♦ Основнье гон!>ггля прсхтэаммирования * * * Теперь мы можем обратиться к третьей особенности функций distance: предусловию validrange. Было бы хорошо, если бы можно было написать такую функцию validrange, которая возвращала бы true, если диапазон задан двумя итераторами, допустим, и false в противном случае. Увы, это невозможно. Так, если два итератора представляют позиции в связанном списке, нет никакого способа узнать, существует путь от одного до другого или нет. И даже имея дело с простыми указателями, мы все равно не можем вычислить valid_range: ни в С, ни в C++ нет способа определить, что два указателя указывают на элементы одного и того же непрерывного блока памяти; в середине могут быть лакуны. Итак, мы не можем написать функцию validjrange, однако ничто не мешает использовать ее как предусловие. Вместо того чтобы гарантировать корректное поведение программно, мы воспользуемся аксиомами: если они удовлетворяются, то наша функция distance будет вести себя, как должно. Конкретно мы постулируем такие две аксиомы: container(c) => valid(begin(c), end(c)); valid(jtr, у) Л х ф у => valid(successor(x), у). Первая аксиома говорит, что если это контейнер, то диапазон от begin () до end () допустим. Вторая - что если [ху у) - непустой допустимый диапазон, то диапазон [successor(x), у) тоже допустим. Все контейнеры, совместимые с STL, а также массивы в C++ должны удовлетворять этим аксиомам. Это позволяет доказывать правильность алгоритмов. Например, вернемся к исходной функции distance для итераторов ввода из раздела 10.5; вторая аксиома гарантирует, что если начать с допустимого диапазона, то и диапазон, получающийся на каждой итерации цикла, тоже будет допустимым. * * * В дополнение к операциям перехода к следующему (++) и distance полезно иметь возможность перемещать итератор сразу на несколько позиций. Такую функцию мы назовем advance. Как и раньше, реализуем два ее варианта: один для итераторов ввода: template <InputIterator I> void advance(I& x, DifferenceType<I> n, std::input_iterator_tag) { while (n) { —n; ++x; } } а другой - для итераторов с произвольным доступом: template <RandomAccessIterator I> void advance(I& x, DifferenceType<I> n, std::random_access_iterator_tag) { x += n; }
Линейный поиск ♦ 173 Предоставим также функцию верхнего уровня для диспетчеризации: template <InputIterator I> void advance(I& x, DifferenceType<I> n) { advance(x, n, IteratorCategory<I>()); i / 107. Линейный поиск Линейный поиск - базовая задача, которую должны хорошо понимать все программисты. Простейшая идея - просматривать линейный список, пока не будет найден конкретный элемент. Но мы обобщим ее, написав функцию, которая просматривает список, пока не найдет элемент, удовлетворяющий заданному предикату. Таким образом, мы сможем искать не только конкретное значение, но и. скажем, первый нечетный элемент, или первый элемент, не содержащий гласных букв, или еще что-то. Разумеется, такого элемента может и не найтись, и тогда нам необходим какой-то способ сообщить об этом. Назовем нашу функцию findif - «найти, если существует»1: template <InputIterator I, Predicate P> I find_if (I f, I 1, P p) { while (f != 1 && !p(*f)) ++f; return f; } Для этой функции нужны операции равенства, разыменования и перехода к следующему. Если не существует ни одного элемента, удовлетворяющего предикату, то функция вернет значение f, совпадающее с 1 - итератором, указывающим на конец диапазона. Вызывающая функция может сравнить одно с другим и узнать, был найден подходящий элемент или нет. С и C++ гарантируют, что указатель на позицию, на единицу правее конца массива, действителен. Все контейнеры STL дают аналогичную гарантию для своих итераторов. У этой функции есть неявное семантическое предусловие: тип значения итератора и тип аргумента предиката должны быть одинаковы, иначе будет невозможно применить предикат к элементам диапазона. Ниже показан вариант функции линейного поиска для случая итератора ввода. Мы могли бы перегрузить имя, но вместо этого сознательно добавили суффикс _п, чтобы подчеркнуть, что используется счетный диапазон: template <InputIterator I, Predicate P> std: :pair<I, DifferenceType<I» find_if_n(I f, DifferenceType<I> n, P p) { while (n && !p(*f)) { ++f; —n; } return {f, n}; } Это имя заимствовано из языка программирования Common Lisp.
174 ♦> Основные понятия программирования Почему мы возвращаем пару? Разве не было бы достаточно вернуть итератор, указывающий на найденный элемент, как в предыдущем случае? Нет. В предыдущем случае у вызывающей программы был итератор на «последний», а сейчас его нет. Поэтому второе возвращаемое значение сообщает вызывающей программе, дошли ли мы конца, - если да, значит, элемент не найден и разыменовывать возвращенный итератор нельзя. Но не менее важно и то, что если подходящий элемент был найден, то с помощью второго возвращенного значения мы сможем возобновить поиск с того места, где остановились. Без этого мы смогли бы найти в диапазоне только первое вхождение искомого значения. Сказанное иллюстрирует важный момент: ошибки могут быть не только в коде, но и в определении интерфейса. Пример мы увидим в следующем разделе. 10.8. Двоичный поиск Если исходная последовательность отсортирована, то искать в ней можно намного эффективнее с помощью еще одного базового алгоритма: двоичного поиска. Эту функцию легко описать, но трудно корректно реализовать, а еще труднее корректно специфицировать (спроектировать интерфейс). Истоки двоичного поиска Идея двоичного поиска происходит от теоремы о промежуточном значении (ТПЗ), или теоремы Больцано-Коши: Если функция f непрерывна на отрезке [а, Ь] и f{a) < f(b), то Уu е [f(a), f(b)] существует с g [а, Ь] такое, что и = f(c). В доказательстве теоремы применяется двоичный поиск. Предположим, к примеру, что имеется непрерывная функция - такая, что f(a) равно -3, a f(b) равно 5. ТПЗ утверждает, что для любой точки, принадлежащей области значений функции, допустим 0, найдется точка с в ее области определения, для которой f(c) = 0. Как найти такую точку? Сначала возьмем точку xv лежащую на полпути от а к Ь, и вычислим f{xj. Если получится 0, то все доказано - мы нашли с. Если результат больше 0, повторим процедуру с точкой х2, лежащей посредине между а и xv Если меньше, возьмем точку посредине между х1 и Ь. Продолжая так действовать, мы будем асимптотически приближаться к с. Симон Стевин высказал аналогичную идею в 1594 году, когда открыл вариант ТПЗ для полиномов. Но поскольку Стевина больше интересовали десятичные дроби, то он делил отрезок не пополам, а на десять частей, и рассматривал все «десятые», пока не находил ту, что содержала нужное значение. Подход, основанный на двоичном поиске, впервые описал для полиномов Лагранж в 1795 году. Больцано и Коши обобщили ТПЗ на любую непрерывную функцию в начале XIX века, и предложенный ими вариант используется до сих пор. Двоичный поиск как технику программирования впервые рассмотрел в 1946 году физик и пионер вычислительной техники Джон Мокли, один из создателей первой ЭВМ общего назначения ЭНИАК. Однако многие детали остались неопределенными. Первый «корректный» алгоритм двоичного поиска опубликовал в 1960 году Д. Г. Лемер, математик, который позже работал на ЭНИАКе. Однако интерфейс
Двоичный поиск ♦ 175 версии Лемера был неудачным, и его ошибку повторяют вот уже несколько десятков лет. Этот пример ошибочного интерфейса и по сей день день остается в функции bsearch() в UNIX. Согласно стандарту POSIX1: Функция bsearch() возвращает указатель на найденный элемент массива или нулевой указатель, если элемент не найден. Если два или более элементов массива равны, то не определено, указатель на какой из них будет возвращен2. В этом интерфейсе два фундаментальных изъяна. Первый связан с тем, что отсутствие искомого элемента обозначается возвратом нулевого указателя. Часто поиск производится для того, чтобы вставить элемент, если его еще нет, в то место, где он должен был бы находиться. А при таком интерфейсе нам придется искать позицию вставки, но уже с помощью линейного поиска! Кроме того, существует немало приложений, где желательно найти ближайший к отсутствующему элемент или позицию, следующую за той, где тот должен был бы находиться: быть может, искомый элемент - просто префикс для целого семейства. Второй изъян относится к случаю, когда подходящих элементов несколько- Возможно, они являются ключами к данным, которые предстоит извлечь. II как же получить целый диапазон равных элементов, если не знать, на каком из них вы стоите? Придется производить линейный поиск назад и вперед, чтобы найти нижнюю и верхнюю границы последовательности. * * * Правильный способ реализации двоичного поиска основан на идее точки разбиения. Допустим, имеется последовательность [/,/)> устроенная так, что некоторый предикат равен true для элементов в диапазоне [/, т) и false для элементов в диапазоне [т, /)3. Тогда точкой разбиения называется позиция т. Формально говоря, предусловием для функции, которая вычисляет точку разбиения, будет: Зт G [/,/) : (Vi G [/, т) :p(i)) A (Vi G [m, /) : -p(i))f то есть элементы уже разделены, как описано выше. Постусловие для этой функции - возврат значения т, определенного в предусловии. Отметим, что / в этом выражении обозначает первый элемент диапазона, а не функцию. Для счетного диапазона алгоритм нахождения точки разбиения выглядит так: template <ForwardIterator I, Predicate P> I partition_point_n(I f, DifferenceType<I> n, P p) { while (n) { 1 POSIX - набор стандартов для UNIX-подобных операционных систем. Например, ОС Linux совместима с POSIX. 2 http://www.unix.com/man-page/POSIX/3posix/bsearch/. 3 Сейчас понятно, что лучше было бы поместить элементы, для которых предикат возвращает false, вперед, поскольку при сортировке булево значение false предшествует true, но, к сожалению, «неправильный» порядок уже зафиксирован в стандарте C++.
17Б ♦ Основные понятия программирования I middle(f); DifferenceType<I> half(n » 1); advance(middle, half); if (!p(*middle)) { n = half; } else { f = ++middle; n = n - (half + 1); } } return f; } Это чрезвычайно важный алгоритм, поэтому имеет смысл потратить некоторое время, чтобы как следует разобраться в нем. Здесь используется та же стратегия двоичного поиска, что в теореме о промежуточном значении. Напомним, что наша цель - вернуть первый «плохой» элемент, то есть первый элемент, для которого предикат равен false. Внешний цикл продолжается, пока п равно нулю. В этом цикле мы позиционируем итератор middle посредине между f и f + п. Если для элемента в этой позиции предикат равен false, то п делится пополам и действие повторяется. В противном случае - если предикат равен true, - мы устанавливаем в качестве начальной точки f следующее за средней точкой значение, корректируем п, чтобы оно отражало количество оставшихся элементов, и повторяем. По завершении цикла возвращаемое значение и будет точкой разбиения - той, что следует за последним значением, для которого предикат возвращал true. Обратите внимание, что в этой функции для перемещения итератора применяется функция advance. He зная категорию итератора, мы не можем предполагать, что сложение допустимо. Однако для итератора с произвольным доступом написанная ранее функция advance вызовет быструю реализацию. (Если итератора с произвольным доступом нет, то, возможно, придется сделать п шагов перемещения, но в любом случае никогда не понадобится более log n сравнений.) Если задан не счетный, а ограниченный диапазон, то мы сначала вычисляем расстояние, а затем вызываем вариант для счетного диапазона: template <ForwardIterator I, Predicate P> I partition_point(I f, I 1, P p) { return partition_point_n(f, distance(f, 1), p); } Теперь вернемся к общей задаче двоичного поиска. Для ее решения нам понадобится следующая лемма. Лемма 10.1 (лемма о двоичном поиске). Для любого отсортированного диапазона [г, j) и значения а (искомого элемента) существуют два итератора, нижняя граница bt и верхняя граница Ьи, такие, что: 1: V/г е [z, bj) \vk<a\
Двоичный поиск ♦ 177 2:Vke[bfibu):vk = a; 3:\/ke[buJ):vk>ay где vk - значение в позиции k. Эти границы существуют всегда, хотя в частном случае, когда искомый элемент не найден, b(= bu. Для понимания полезно представить данные в диапазоне графически: а а а Элементы < а Элементы > а Упражнение 10.1 - Докажите лемму о двоичном поиске. Теперь мы можем воспользоваться функцией нахождения точки разбиения для выполнения двоичного поиска. При наличии полного упорядочения любой отсортированный диапазон для любого значения а разбивается предикатом х < а. В STL уже есть несколько готовых функций, которые производят двоичный поиск в соответствии с поставленной задачей. Если требуется найти первую позицию для искомого элемента, то к нашим услугам функция lower_bound. Пользуясь возможностями, включенными в С++11, функцию lower_bound можно написать так: template <ForwardIterator I> I lower_bound(I f, I 1, ValueType<I> a) { return partition_point(f, 1, [=](ValueType<I> x) { return x < a; })/ } В последней строке определена анонимная функция (ее еще называют лямбда - выражением1), которая возвращает true, если ее аргумент меньше а, и эта функция передается в качестве предиката функции partition_point. Функция lower_bound возвращает позицию элемента а или ту, в которой он должен был бы находиться, если бы присутствовал в диапазоне. ValueType - это просто сокращенная функция типа для доступа к подходящей характеристике итератора, похожая на написанную ранее DifferenceType: template <InputIterator I> using ValueType = typename std::iterator_traits<I>::value_type; Если возвращенная позиция не равна 1, то нам все равно необходимо знать, найден элемент или нет. Для этого вызывающая программа должна проверить, совпадает ли разыменованное возвращенное значение с а. Если нам нужна не первая, а последняя позиция для искомого элемента, то следует воспользоваться функцией upperbound. Код почти такой же, только опреде- Подробнее об использовании лямбда-выражений см. приложение С.
178 ♦> Основные понятия программирования ляемый предикат должен проверить, что значение меньше или равно ау а не строго меньше а: template <ForwardIterator I> I upper_bound(I f, I 1, ValueType<I> a) { return partition_point(f, 1, [=](ValueType<I> x) {return x <= a;})/ } У читателя может возникнуть вопрос: какая же функция выполняет «настоящий» двоичный поиск. Ответ на него зависит от поставленной задачи. Если требуется найти позицию первого подходящего элемента, то «настоящий» двоичный поиск осуществляет lowerbound. Если требуется найти позицию последнего подходящего элемента, то это upper_bound. Если вас интересует весь диапазон подходящих элементов, то в STL есть функция equal_range. А если нужно только знать, имеется искомый элемент или нет, то пользуйтесь функцией binary_search, понимая, что она просто вызывает lowerbound и проверяет, равно разыменованное возвращенное значение этому элементу или нет. Очевидно, что дополнительная функциональность equalrange опирается на принятое в STL соглашение о полуоткрытых диапазонах. Даже если элемент не найден, функция возвращает пустой диапазон, указывающий на позицию, в которую элемент должен быть вставлен. 10.9. Заключительные мысли В начале этой главы мы видели, что введенные Аристотелем уровни абстракции (индивидуум, вид, род) соответствуют принятым в программировании понятиям экземпляра, типа и концепции. Именно понятие концепции позволяет обобщенной программе работать в различном окружении. Одна из основных задач программиста - выявить концепции, присутствующие в приложении. Затем часто приходится разрабатывать новые алгоритмы, реже - придумывать новую структуру данных, и совсем редко - определять новую концепцию. В этой редкой ситуации придется провести солидную работу, чтобы получилась настоящая концепция, а не набор не связанных между собой требований. Перефразируя принцип бритвы Оккама, можно сказать: «не умножай концепции без необходимости». Далее мы ввели в рассмотрение концепцию итераторов и познакомились с ее ролью в некоторых фундаментальных алгоритмах. Применяя диспетчеризацию по типу на этапе компиляции, мы можем гарантировать, что в любой ситуации будет выбрана самая эффективная реализация. Наконец, мы видели, что важно не только писать правильный код, но и правильно определять интерфейсы. Неудачный интерфейс может заметно снизить полезность функции, тогда как правильно спроектированный позволит использовать ее в различных ситуациях без потери эффективности.
Глава 11 t©#ii®i#i®ll@®eillfi#®t§®eiii©@S§if^tiiiiftii®©i© Алгоритмы перестановки Чтобы поверить в алгоритм, его нужно увидеть. Дональд Кнут Сложные компьютерные программы строятся из меньших частей, решающих стандартные базовые задачи. В предыдущей главе мы рассмотрели некоторые задачи, относящиеся к поиску данных. А в этой обсудим те, что связаны с перемещением данных на новое место, и покажем обобщенный способ их реализации. Мы увидим, что в конечном итоге все упирается в две идеи: общеалгебраические группы и нахождение наибольшего общего делителя (НОД) из теории чисел. Интересующие нас задачи - циклический сдвиг и изменение порядка на противоположный - позволят познакомиться с алгоритмами, которые решают одну и ту же задачу по-разному в зависимости от концепции итератора, к которому применяются. Помимо иллюстрации некоторых приемов обобщенного программирования, эти алгоритмы имеют немалую практическую ценность. В частности, алгоритм rotate - наверное, самый часто используемый в различных компонентах STL: от vector до stable_sort. 11.1. Перестановки и транспозиции Исследование алгоритма нахождения НОД привело нас к группам и другим алгебраическим структурам. Располагая этими знаниями, мы займемся изучением математических операций перестановки и транспозиции, которые играют важную роль в некоторых фундаментальных алгоритмах. Определение 11.1. Перестановкой называется отображение последовательности п объектов в себя. Формальное определение перестановки1 выглядит так: (12 3 4^ 1 Точно такая же нотация в математике используется для матриц; впрочем, из контекста обычно ясно, что имеется в виду.
180 ♦ Алгоритмы перестановки В первой строке представлены индексы (позиции) последовательности объектов; в математике нумерация начинается с 1. А во второй строке показано, в какую позицию перемещается объект после применения перестановки. В данном примере элемент, находившийся в позиции 1, оказывается в позиции 2, элемент в позиции 2 перемещается в позицию 4 и т. д. На практике при записи перестановок первую строку часто опускают: (2 4 1 3). Иначе говоря, в позиции г записывается номер позиции, в которую перемещается элемент, ранее находившийся в позиции г. Приведем пример применения перестановки: (2413): {а, Ь, с, d) = {с, а, d, b). Эту нотацию мы используем для определения симметричной группы. Определение 11.2. Множество всех перестановок п элементов составляет группу, называемую симметричной группой Sn. Симметричная группа обладает следующими свойствами группы: бинарная операция: композиция (ассоциативная) операция обращения: обратная перестановка нейтральный элемент: тождественная перестановка Это первый встретившийся нам пример, когда элементами группы являются функции, а групповая операция - это операция над функциями. Если имеется перестановка х, которая сдвигает каждый элемент на две позиции вправо, и другая перестановка z/, сдвигающая каждый элемент на три позиции вправо, то композиция х о у сдвигает каждый элемент на пять позиций вправо. Быть может, из всех групп это самая важная, потому что, согласно теореме Кэли, любая конечная группа является подгруппой симметричной группы. Упражнение 11.1. Докажите теорему Кэли. Упражнение 11.2. Чему равен порядок Sn? Теперь рассмотрим частный случай перестановки - транспозицию. Определение 11.3. Транспозицией (ij) называется перестановка, которая меняет местами i-й и^-й элементы (i =£/)> оставляя все остальные на месте. В нотации транспозиций указывается, какие два элемента меняются местами: (2 3) : {а, Ь} с, d) = {а, с, 6, d}. В программировании для транспозиции применяется более простое название: обмен (swap). В C++ эту операцию можно реализовать так: template <Semiregular T> void swap(T& x, T& у) {
Перестановки и транспозиции ♦ 181 Т tmp(x) ; х = у; у = tmp; } Для операции обмена нужно только, чтобы типы аргументов удовлетворяли концепции Semiregular1. Легко видеть, что для выполнения swap нужна возможность конструирования копированием, присваивания и уничтожения данных, поскольку эти операции используются в коде. Наличие явной операции сравнения на равенство необязательно, поэтому не требуется, чтобы типы удовлетворяли концепции Regular. При проектировании алгоритма нам необходимо знать, каким концепциям должны удовлетворять типы, но лишних ограничений мы налагать не хотим. * * * Следующая лемма о транспозиции демонстрирует принципиальную важность операции swap. Лемма 11.1 (лемма о транспозиции). Любая перестановка является произведением транспозиций. Доказательство. Одна транспозиция может поставить на место как минимум один элемент. Поэтому чтобы поставить все п элементов на места, определяемые перестановкой, понадобится не более п- 1 транспозиций. Почему нужно только п - 1 транспозиций? Потому что коль скоро п - 1 элементов заняли свои места, то и тг-ый по необходимости оказывается в предназначенном ему месте - просто ему больше некуда деваться. Упражнение 11.3. Докажите что при п > 2 группа Sn не является абелевой. Любая перестановка определяет ориентированный граф из п элементов. Если выполнить перестановку достаточное число раз, то любой заданный элемент вернется в исходную позицию, то есть в графе имеется цикл. Каждую перестановку можно разложить в произведение циклов. Рассмотрим, к примеру, перестановку (235614). Элемент в позиции 4 переходит в позицию 6, а элемент в позиции 6 - в позицию 4, то есть если применить эту перестановку дважды, то оба элемента окажутся там, где были изначально. То же самое справедливо для элементов в позициях 1, 2, 3, 5, хотя теперь для возврата в исходное положение понадобятся четыре операции. Мы говорим, что перестановка (235614) разлагается в произведение двух циклов, и графически представляем это следующим образом: СПЭ Обсуждение семантики операции перемещения в C++ выходит за рамки этой книги. V
182 ♦ Алгоритмы перестановки Записывается это в виде (2 3561 4) = (123 5)(4 6). Нотацию циклов в правой части можно трактовать как обобщение нотации транспозиций. И хотя здесь налицо неоднозначность (что это - цикл или транспозиция?), обычно из контекста понятно, что имеется в виду Отметим еще, что перестановки обязательно содержат все целые числа от 1 до п, а для циклов это необязательно. Циклы не пересекаются. Находясь в любой позиции, принадлежащей циклу, мы можем перейти в любую другую позицию из того же цикла. Поэтому если у двух циклов есть хотя бы одна общая позиция, то и все их позиции общие. А значит, циклы могут различаться, только если у них нет ни одной общей позиции. Определение 11.4. Цикл из одного элемента называется тривиальным. Упражнение 11.4. Сколько нетривиальных циклов может содержать перестановка п элементов? Теорема 11.1 (о числе присваиваний). Для выполнения произвольной перестановки на месте необходимо п - и + v операций присваивания, где п - число элементов, и - число тривиальных циклов, a v - число нетривиальных циклов. Доказательство. Каждый нетривиальный цикл длины k требует k + 1 присваиваний, потому что каждый элемент необходимо переместить и еще необходимо сохранить первое перезаписываемое значение. Поскольку для каждого нетривиального цикла необходимо одно дополнительное перемещение, то для всех v циклов понадобится v дополнительных перемещений. Элементы, принадлежащие тривиальным циклам, перемещать вообще не нужно, а таких у нас и. Следовательно, нужно переместить п - и элементов плюс сделать v дополнительных перемещений - по числу нетривиальных циклов. Часто встречающаяся перестановка, содержащая ровно я/2 циклов, называется обращением. В каком-то смысле это «самая трудная» перестановка, потому что требует наибольшего количества операций.присваивания. Обращение мы рассмотрим более детально в следующей главе. Упражнение 11.5. Спроектируйте алгоритм обращения на месте1 для однонаправленных итераторов. Это означает, что алгоритм должен работать для одно- связных списков без изменения связей. 11.2. Обмен диапазонов Иногда требуется обменять сразу несколько элементов за раз. Вообще, операция обмена всех значений из одного диапазона с соответственными значениями из другого (возможно, перекрывающегося) диапазона встречается в программировании довольно часто. Мы сделаем это в цикле, по одной операции обмена на каждой итерации: Мы будем обсуждать понятие алгоритма на месте в разделе 11.6; см. определение 11.6.
Обмен диапазонов ♦ 183 while (condition) std::swap(*iterO++, *iterl++); где iterO и iterl - итераторы, указывающие на соответственные значения в каждом диапазоне. Напомним (см. главу 10), что мы предпочитаем полуоткрытые диапазоны - в которые нижняя граница включается, а верхняя нет. При обмене двух диапазонов только один из них должен быть задан явно. Здесь firstO и lastO определяют границы первого диапазона, a firstl - начало второго диапазона: template <ForwardIterator Ю, Forwardlterator Il> // ValueType<lO> == ValueType<Il> II swap_ranges(lO firstO, 10 lastO, II firstl) { while (firstO != lastO) swap (*first0++, *firstl++) ; return firstl; } He имеет смысла задавать конец второго диапазона, потому что для корректной работы операции обмена необходимо, чтобы второй диапазон содержал как минимум столько же элементов, сколько первый. Почему мы возвращаем firs*!? Потому что это может быть полезно вызывающей программе. Например, если второй диапазон длиннее первого, то нам, возможно, будет интересно узнать, откуда начинается ^модифицированная часть второго диапазона. Этой информации у вызывающей программы нет, а нам вернуть ее почти ничего не стоит. Сейчас самое время вернуться к закону полезного возврата, который был сформулирован в разделе 4.6. Еще раз о законе полезного возврата При написании программы часто бывает так, что по ходу дела вычисляется значение, которое в настоящий момент вызывающей функции не нужно. Однако может статься, что в другой ситуации это значение окажется полезным. Именно поэтому рекомендуется соблюдать закон полезного возврата. Процедура должна возвращать всю потенциально полезную информацию, которую вычислила. Хорошим примером может служить алгоритм вычисления частного и остатка из главы 4: когда мы начинали его писать, нас интересовал только остаток, но в процессе работы мы сделали почти все необходимое для нахождения частного. Впоследствии мы видели другие приложения, где требовались оба значения. Этот закон не говорит, что следует производить ненужные дополнительные вычисления, и не призывает возвращать бесполезную информацию. Например, в приведенном выше коде бесполезно возвращать значение firstO, потому что алгоритм гарантирует, что в конце работы оно равно lastO, а это значение у вызывающей программы уже имеется. Зачем мне информация, которой я и так располагаю? Закон разделения типов Код функции swap_ranges иллюстрирует еще один важный принцип программирования, закон разделения типов: Не предполагайте, что два типа одинаковы, если они могут различаться. В объявлении нашей функции указаны два типа итераторов:
184 ♦ Алгоритмы перестановки template <ForwardIterator 10, Forwardlterator Il> // ValueType<lO> == ValueType<Il> II swap_ranges (10 firstO, 10 lastO, II firstl); то есть мы не предполагаем, что они имеют один и тот же тип, как в таком варианте: template <ForwardIterator I> I swap_ranges (I firstO, I lastO, I firstl); Первый способ более общий, он позволяет использовать функцию в ситуациях, когда второй был бы неприменим без дополнительных накладных расходов. Например, мы могли бы без труда обменять диапазон элементов связанного списка с диапазоном элементов массива. Но из того, что два типа различны, не следует, что между ними нет никакой связи. В случае swap_ranges, чтобы к данным можно было применить операцию swap, необходимо гарантировать, что тип значения 10 такой же, как у П. Современные компиляторы еще не в состоянии проверить это условие, но мы можем указать его в комментарии. В тех случаях, когда мы не уверены, что длина второго диапазона достаточна для выполнения всех необходимых обменов, мы можем задать оба диапазона явно и в цикле while проверять, что ни один итератор не вышел за границу диапазона: template <ForwardIterator 10, Forwardlterator Il> std::pair<l0, I1> swap_ranges(10 firstO, 10 lastO, II firstl, II lastl) { while (firstO != lastO && firstl != lastl) { swap(*first0++, *firstl++); } return {firstO, firstl}; } В этой версии мы возвращаем firstO и firstl, потому что один диапазон может оказаться короче другого, поэтому нет гарантии, что мы достигнем lastO и lastl. Обмен счетных диапазонов производится почти так же, только вместо проверки достижения конца диапазона мы уменьшаем п до 0: template <ForwardIterator Ю, Forwardlterator II, Integer N> std: :pair<l0, Il> swap_ranges_n (Ю firstO, II firstl, N n) { while (n != N(0)) { swap(*first0++, *firstl++); —n; } return {firstO, firstl}; Закон полноты Обратите внимание, что мы написали две версии: swap_ranges и swap_ranges_n. И хотя в конкретной ситуации программисту может понадобиться только одна из них, не исключено, что впоследствии возникнет необходимость и в другой. Отсюда следует закон полноты: При проектировании интерфейса рассмотрите возможность предоставления всех взаимосвязанных процедур.
Циклическая перестановка ♦ 185 Если существует несколько способов вызова алгоритма, предоставьте интерфейсы для соответствующих функций. В примере операции обмена мы предложили два взаимосвязанных интерфейса для ограниченных диапазонов и сделаем то же самое для счетных. Это правило не говорит, что необходимо иметь единственный интерфейс, рассчитанный на несколько случаев. Никто не мешает завести одну функцию для счетных диапазонов и другую для ограниченных. Особенно следует избегать определения одного интерфейса для различных операций. Например, из того, что контейнеры обязаны предоставлять обе операции «вставить элемент» и «стереть элемент», вовсе не следует, что в интерфейсе должна быть единственная функция «вставить_или_стереть». Счетные диапазоны проще для компилятора, потому что он заранее знает количество итераций - счетчик пробега. Это позволяет компилятору выполнить некоторые оптимизации, например раскрутку цикла или программный конвейер. Упражнение 11.6. Почему мы не предоставляем такой интерфейс: pair<lO, Il> swap_ranges_n(lO firstO, II firstl, NO nO, N1 nl) 11.3. Циклическая перестановка Один из важнейших алгоритмов, о котором вам, наверное, доводилось слышать, - rotate. Это фундаментальное средство, применяемое во многих типичных компьютерных приложениях, например для манипулирования буферами в текстовых редакторах. Оно реализует математическую операцию циклической перестановки. Определение 11 -5- Перестановка п элементов на ky где k > 0: (kmodn,k+ 1 mod n}..., k + n - 2 mod n,k + n - 1 mod n) называется циклической перестановкой п на k. Если представить, что п элементов расположены по окружности, то мы сдвигаем каждый элемент на k позиций по часовой стрелке. На первый взгляд может показаться, что циклическую перестановку можно реализовать с помощью сдвига по модулю, передав в качестве аргументов начало и конец диапазона, а также величину сдвига. Но выполнение арифметических операций по модулю на каждой итерации обойдется очень дорого. Кроме того, как выясняется, циклическая перестановка эквивалентна обмену блоков разной длины, а эта задача чрезвычайно полезна во многих приложениях. При таком взгляде удобно представить циклическую перестановку тремя итераторами: /, т и /, где [/, т) и [т, /) - допустимые диапазоны1. Тогда циклическая перестановка сводится к обмену диапазонов [/", т) и [т, /). Если клиент захочет циклически переставить k позиций в диапазоне [/, /), то должен будет передать в качестве т значение I- k. Например, чтобы циклически переставить на k = 5 позиций последовательность, заданную диапазоном [0, 7), мы зададим m = l-k = 7-5 = 2: Имена/, т и / - мнемонические обозначения first, middle и last.
18Б ♦ Алгоритмы перестановки 0 12 3 4 5 6 f m 1 и получим такой результат: 2 3 4 5 6 0 1 По существу, мы переместили каждый элемент на 5 позиций вправо с переходом в начало при достижении конца. Упражнение 11-7. Докажите, что вызов rotate (f, m, 1) выполняет циклическую перестановку distance(/, /) на distance^, /). Важный алгоритм rotate совместно разработали Дэвид Грис, профессор Кор- неллского университета, и исследователь из фирмы IBM Харлан Миллс. template <ForwardIterator I> void gries_mills_rotate(I f, I m, I 1] { /7 u = distance(f, m) && v = distance(m, 1) if (f == m || m == 1) return; // u == 0 || v == 0 pair<I, I> p = swap_ranges (f , m, m, lb- while (p.first != m || p.second != 1) { if (p.first == m) { // u < v f = m; m = p.second; // v = v - u } else { // v < u f = p.first; // u = u - v } p = swap_ranges(f, m, m, 1); } return; // u == v } Здесь впервые используется регулярная функция swap_ranges, которая переставляет столько элементов, сколько возможно, - столько, сколько их есть в более коротком диапазоне. В предложении if проверяется, какой диапазон исчерпался: первый или второй. В зависимости от результатов проверки мы устанавливаем начальные позиции /и т. Затем производится еще один обмен, и весь процесс повторяется, пока в обоих диапазонах не останется ни одного элемента. Проследим за работой этого алгоритма на примере. Посмотрим, как трансформируется диапазон и как сдвигаются итераторы. Начало: 0 12 3 4 5 6 f m l Переставляем [0, 1] и [2, 3]. Оба диапазона исчерпаны? Нет, только первый, поэтому устанавливаем f = m и m = p.second, то есть указываем на первый еще не перемещенный элемент последовательности: 2 3 0 14 5 6 f m l
Циклическая перестановка ♦ 187 Переставляем [0,1] и [4, 5]. Оба диапазона исчерпаны? Нет, только первый, поэтому снова устанавливаем f = m и m = p. second: 2 3 4 5 0 16 f ml Переставляем [0] и [6]. Оба диапазона исчерпаны? Нет, на этот раз только второй, поэтому устанавливаем f = p.first: 2 3 4 5 6 10 f m 1 Переставляем [1] и [0]. Оба диапазона исчерпаны? Да, все сделано. 2 3 4 5 6 0 1 f m 1 Теперь обратите внимание на комментарии в коде функции gries_~iills_rc:a:e (выделены полужирным шрифтом). Мы обозначили и длину первого диапазона [/", m), a v - длину второго диапазона [т, /). Можно заметить удивительную вещь: в комментариях повторена хорошо знакомая нам последовательность вычитаний в алгоритм нахождения НОД! В конце алгоритма u = v = НОД длин двух исходных диапазонов. Упражнение 11 -8- Внимательно изучив код swap_ranges, вы заметите, что производятся ненужные сравнения итераторов. Перепишите алгоритм так, чтобы никаких лишних сравнений не было. Оказывается, во многих приложениях было бы полезно, если бы алгоритм циклической перестановки возвращал новую середину: позицию, в которой оказался первый элемент. Если rotate возвращаетэто значение, то rotate (f, rotate (f, m, 1), 1) - тождественная перестановка. Во-первых, нам понадобится следующий алгоритм «вспомогательной циклической перестановки». template <ForwardIterator I> void rotate_unguarded(I f, I m, I 1) { // assert(f != m && m != 1) pair<I, I> p = swap_ranges(f, m, m, 1); while (p.first != m | | p.second != 1) { f = p. first; if (m == f) m = p.second; p = swap_ranges(f, m, m, 1); } } Основной цикл такой же, как в алгоритме Гриса-Миллса, только записан по- другому. (Мы могли бы написать его так и раньше, но хотели, чтобы были отчетливо видны вычисления cuhv.) Нам нужно найти т' - элемент, который отстоит от последнего на такое же расстояние, как т от первого. Это значение, которое возвращает первый вызов
188 ♦ Алгоритмы перестановки swap_ranges. Чтобы получить его, мы можем встроить обращение к rotate_unguarded в окончательную версию rotate, которая вызывается при условии, что итераторы однонаправленные. Как мы вскоре увидим, тип forward_iterator_tag в списке аргументов гарантирует, что этот вариант будет вызван только при соблюдении указанного условия. template <ForwardIterator I> I rotate (I f, I in, I 1, std: :forward_iterator_tag) { if (f == m) return 1; if (m == 1) return f; pair<I, I> p = swap_ranges(f, m, m, 1); while (p.first != m | | p.second != 1) { if (p.second == 1) { rotate_unguarded(p.first, m, 1); return p.first/ } f = m; m = p.second/ p = swap_ranges(f, m, m, 1); } return m; } Сколько работы выполняет этот алгоритм? На всех итерациях главного цикла, кроме последней, операция обмена ставит один элемент в нужное место, а еще один элемент убирает с дороги. Но в последнем обращении к swap_ranges оба диапазона имеют одинаковую длину, поэтому каждая операция обмена ставит оба обмениваемых элемента в окончательные позиции. По существу, при каждом обмене мы получаем одно дополнительное перемещение задаром. Общее количество обменов равно общему количеству элементов п минус число бесплатных обменов, сэкономленных на последнем шаге. Сколько именно обменов сэкономлено на последнем шаге? Длина обоих диапазонов в конце работь!, как мы видели, равна gcd(n -k,k) = = gcd(n, k), где п = distance(/, l)nk = distance(m, /). Поэтому общее число обменов равно п - gcd(n, k). Кроме того, поскольку для каждого обмена нужно три присваивания (tmp = а; а = b; b = tmp), общее число присваиваний равно 3(я - gcd(n, k)). 11.4. Использование циклов Можно ли найти более быстрый алгоритм циклической перестановки? Можно, если воспользоваться тем фактом, что циклические перестановки, как и любые другие, разлагаются в произведение циклов. Рассмотрим циклическую перестановку п = 6 элементов на k = 2 позиций: 0 12 3 4 5 4 5 0 12 3
Использование ииклов ♦ 189 Из позиции 0 элемент перемещается в позицию 2, из позиции 2 - в позицию 4, а из позиции 4 - обратно в позицию 0. Эти три элемента образуют цикл. Аналогично элемент 1 переходит в 3, элемент 3 - в 5, элемент 5 - в 1, то есть мы имеем еще один цикл. Таким образом, данная циклическая перестановка состоит из двух циклов. Вспомним (раздел 11.1), что для выполнения любой перестановки достаточно п - и + v присваиваний, где п - число элементов, и - число тривиальных циклов, v - число нетривиальных циклов. Поскольку тривиальных циклов обычно нет, всего необходимо п + v присваиваний. Упражнение 11.9- Докажите, что если циклическая перестановка п элементов имеет тривиальный цикл, то она имеет п тривиальных циклов (иначе говоря, циклическая перестановка либо перемещает все элементы, либо ни одного). Оказывается, что число циклов равно gcd(&, n), поэтому у нас должна быть возможность выполнить циклическую перестановку за п + gcd(&, и) присваиваний1 вместо 3(п - gcd(n, &)), необходимых в алгоритме Гриса-Миллса. К тому же на практике НОД очень мал, примерно в 60% случаев он вообще равен 1 (то есть имеется всего один цикл). Поэтому алгоритм циклической перестановки, построенный на основе циклов, всегда выполняет меньше присваиваний. Но есть и подводный камень: в алгоритме Гриса-Миллса требуется перемещение только на один шаг, он работает даже для односвязных списков. А чтобы воспользоваться преимуществами циклов, нам понадобится совершать длинные переходы. Такой алгоритм предъявляет более сильные требования к итераторам, а именно возможность произвольного доступа. Для новой функции циклической перестановки мы сначала напишем вспомогательную функцию, которая перемещает каждый элемент, входящий в цикл, в следующую позицию. Но вместо того чтобы спрашивать: «в какую позицию переместится элемент, находящийся в позиции х?» - мы поставим вопрос так: «в какой позиции находится элемент, который перейдет в позицию х?» И хотя с точки зрения математики эти операции симметричны, вторая более эффективна, потому что нуждается в сохранении только одной временной переменной на весь цикл, тогда как в первой приходится заводить по одной переменной для каждого подлежащего перемещению элемента (кроме последнего). Вот как выглядит вспомогательная функция: template <ForwardIterator I, Transformation F> void rotate_cycle_from(I i, F from) { ValueType<I> tmp = *i; I start = i; for (I j = from(i); j != start; j = from(j)) { *i = *j; i = j; } *i = tmp; } Доказательство см. в оазделе 10.4 книги «Elements of Programming».
190 ♦> Алгоритмы перестановки Обратите внимание на использование функции типа ValueType, которую мы определили в конце раздела 10.8. Откуда rotate_cycle_f rom узнает, из какой позиции приходит элемент? Эта информация будет инкапсулирована в объекте-функции from, передаваемом в качестве аргумента. Вызов from(i) словами можно сформулировать так: «вычислить, из какой позиции приходит элемент, оказывающийся в позиции z». Объект-функция, передаваемый функции rotate_cycle_from, является экземпляром класса rotate_transform: template <RandomAccessIterator I> struct rotate_transform { DifferenceType<I> plus; DifferenceType<I> minus; I ml; rotate_transform(I f, I m, I 1) : plus(m - f), minus(m - 1), ml(f + (1 - m)) {} //ml разделяет элементы, перемещающиеся вперед и назад I operator()(I i) const { return i + ((i < ml) ? plus : minus); } }; Идея заключается в том, что хотя концептуально мы циклически «вращаем» элементы, на практике некоторые элементы перемещаются вперед, а некоторые назад (поскольку иначе оказались бы за границей диапазона). Когда объект rotate_transform создается для данного набора диапазонов, конструктор заранее вычисляет, (1) на сколько сдвигать вперед элементы, перемещающиеся вперед; (2) на сколько сдвигать назад элементы, перемещающиеся назад; (3) где находится точка, отделяющая элементы, перемещающиеся вперед, от тех, что перемещаются назад. Вот теперь можно написать алгоритм циклической перестановки, в котором используются циклы; это вариант алгоритма, найденного Флетчером и Силвером в 1965 году: template <RandomAccessIterator I> I rotate (I f, I m, I 1, std::random_access_iterator_tag) { if (f == m) return 1; if (m == 1) return f; DifferenceType<I> cycles = gcd(m - f, 1 - m); rotate_transform<I> rotator (f, m, 1); while (cycles— > 0) rotate_cycle_from(f + cycles, rotator); return rotator.ml; } После рассмотрения тривиальных граничных случаев алгоритм сначала вычисляет количество циклов (НОД) и конструирует объект rotate_transform. Затем он
Использование ииклов ♦ 191 вызывает функцию rotate_cycle_from, которая сдвигает все элементы, принадлежащие одному циклу, и повторяет это для каждого цикла. Рассмотрим пример. Пусть требуется выполнить циклическую перестановку п = 6 элементов на k = 2 позиций, как в начале этого раздела. Для простоты предположим, что значениями являются целые числа, хранящиеся в массиве: 0 12 3 4 5 Предположим также, что итераторы - это целочисленные смещения от начала массива, находящегося в позиции с номером 0. (Не путайте значение в позиции с самой позицией.) Чтобы выполнить циклическую перестановку на k = 2 позиций, мы должны передать три итератора/= 0, т = 4, / = 6: 0 12 3 4 5 f ml Эта ситуация не совпадает ни с одним из граничных случаев в новом алгоритме rotate, поэтому начнем с вычисления количества циклов, которое равно gcd(ra -/, /- т) = gcd(4,2) = 2. Затем алгоритм конструирует объект rotator, инициализируя его переменные-члены следующим образом: plus <-- m - f = 4 - 0 = 4; minus *-m-l = 4-6 = -2; ml —/+ (1 - от) - 0 + (6 - 4) - 2. Главный цикл функции циклически переставляет все элементы одного цикла, а затем переходит к следующему циклу. Посмотрим, что происходит при вызове rotate_cycle_from. Сначала в первом аргументе мы передаем значение /+ d = 0 + 2 = 2. Следовательно, внутри функции i = 2. Мы сохраняем значение в позиции 2, которое также равно 2, в переменной tmp и записываем в start номер начальной позиции - 2. Далее мы крутимся в цикле, пока переменная j не станет равна start. На каждой итерации мы устанавливаем новое значение j, пользуясь объектом-функцией, переданным в аргументе from. По существу, этот объект просто прибавляет к своему аргументу заранее вычисленное значение plus или minus в зависимости от того, меньше аргумент запомненного значения ml или нет. Например, вызов from (0) вернет 0 + 4, то есть 4, поскольку 0 меньше 2. А вызов from (4) вернет 4 + (-2), то есть 2, так как 4 не меньше 2. Вот как меняются значения в массиве по мере выполнения цикла в функции rotate_cycle_from: i+-2;j<-from(2) = 0 0 12 3 4 5 j i 0 10 3 4 5 j i
192 ♦ Алгоритмы перестановки i^j = О;; «- from(0) = 4 0 10 3 4 5 i J Ч «_ *j 4 10 3 4 5 i J г ^j = A;j «- from(A) = 2, это значение равно start, поэтому цикл завершается 4 10 3 4 5 J i *i — imp 4 10 3 2 5 J i На этом завершается первое обращение к rotate_cycle_f rom в цикле while внутри функции rotate. Упражнение 11.10. Продолжите трассировку примера вплоть до завершения функции rotate. Отметим, что сигнатуры этой и предыдущей функции rotate отличаются типом последнего аргумента. В следующем разделе мы напишем обертку, которая будет автоматически вызывать ту реализацию, которая работает наиболее быстро в имеющейся ситуации. Когда алгоритм работает быстрее на практике? Мы видели пример, когда один алгоритм выполняет меньше присваиваний, чем другой. Значит ли это, что он работает быстрее? Не обязательно. На практике быстродействие в огромной степени зависит от возможности поместить все рабочие данные в кэш. Алгоритм, который обращается к данным, находящимся в памяти на большом удалении, так как характеризуется низкой локальностью ссылок, может оказаться медленнее алгоритма, который выполняет больше присваиваний, но характеризуется более высокой локальностью ссылок. 11.5. Обращение Еще один фундаментальный алгоритм - обращение - изменяет порядок элементов последовательности на противоположный. Говоря более формально, он переставляет список k элементов таким образом, что меняются местами элементы 0 и k- 1, 1 nk - 2 и т. д. Имея алгоритм reverse, мы можем реализовать rotate с помощью трех строк кода: template <BidirectionalIterator I> void three_reverse_rotate(I f, I m, I 1) { reverse(f, m)/ reverse(m, 1); reverse(f, 1); }
Обрашение ♦ 193 Например, предположим, что требуется произвести циклическую перестановку последовательности 012345 на ^ = 2 позиций. Тогда алгоритм должен выполнить следующие операции: f ml start 0 12 3 4 5 reverse (f, m) 3 2 10 4 5 reverse(m, 1) 3 2 10 5 4 reverse (f, 1) 4 5 0 12 3 Упражнение 11.11. Сколько присваиваний выполняет алгоритм циклической перестановки тремя обращениями? Этот элегантный алгоритм, автор которого неизвестен, работает для двунаправленных итераторов. Однако в нем есть одна проблема: он не возвращает новой средней позиции. Чтобы решить ее, мы разобьем окончательную версию reverse на две части. Нам понадобится новая функция, которая меняет порядок элементов, пока один из двух итераторов не дойдет до конца: template <BidirectionalIterator I> pair<I, I> reverse_until(I f, I m, I 1) { while (f != m && m != 1) swap(*f++, *—1) ; return {f, 1}; } В конце работы этой функции тот итератор, который не дошел до конца диапазона, будет указывать на новую середину. Теперь можно написать общую функцию rotate для двунаправленных итераторов. Дойдя до третьего вызова reverse, она вместо него вызывает reversejintil, сохраняет новую среднюю позицию, а затем завершает обращение оставшейся части диапазона: template <BidirectionalIterator I> I rotated f, I m, I 1, bidirectional_iterator_tag) { reverse(f, m); reverse(m, 1); pair<I, I> p = reverse_until(f, m, 1); reverse (p. first, p. second); if (m == p.first) return p.second; return p.first; } Мы познакомились с тремя разными реализациями rotate, оптимизированными для различных типов итераторов. Однако хотелось бы скрыть эту сложность от программиста, который будет эти функции использовать. Поэтому, как в случае с функциями distance в разделе 10.5, напишем более простую функцию, которая работает для итератора любого типа и с помощью диспетчеризации по категории дает компилятору возможность решить, какую реализацию вызывать:
194 ♦ Алгоритмы перестановки template <Рсг*ага~т:егагог 1> I rotate (I f, I m, I 1) { return rotate (f, гг., 1, IteratorCategory<I>()); } Программисту нужно вызывать только одну функцию rotate, а компилятор получит тип используемого итератора и вызовет подходящую реализацию. * * * Мы уже начали использовать функцию reverse, но как ее реализовать? В случае двунаправленных итераторов все достаточно просто - у нас есть указатель на начало, который сдвигается вперед, и указатель на конец, сдвигающийся назад, и мы продолжаем обменивать элементы, пока эти указатели не встретятся: template <BidirectionalIterator I> void reverse(I f, I 1, std::bidirectional_iterator_tag) { while (f != 1 && f != —1) std::swap(*f++, *1); } Упражнение 11.12. Объясните, почему в цикле while выше на каждой итерации нужны две проверки. Может показаться, что, согласно закону полезного возврата, надо было бы вернуть пару pair<I, I> (f, 1). Однако нет никаких свидетельств в пользу того, что эта информация действительно полезна, поэтому закон неприменим. Разумеется, если бы мы заранее знали, сколько раз придется выполнять цикл (счетчик пробега), то два сравнения не понадобились бы. Если передать счетчик пробега п нашей функции, то в реализации можно обойтись всего я/2 проверками: template <BidirectionalIterator I, Integer N> void reverse_n(I f, I 1, N n) { n »= 1; while (n— > N(0)) { swap(*f++, *—1); } } В частности, имея итератор с произвольным доступом, мы можем вычислить счетчик пробега за постоянное время и реализовать reverse с помощью reversen: template <RandomAccessIterator I> void reversed ff I 1, std::random_access_iterator_tag) { reverse_n(f, 1, 1 - f); } А если у нас есть только однонаправленные итераторы, а обратить последовательность все-таки хочется? Тогда можно воспользоваться вспомогательной рекурсивной функций, которая делит диапазон пополам (переменная h в коде). В аргументе п запоминается длина обращаемой последовательности.
Обращение ♦ 195 template <ForwardIterator I, Integer N> I reverse_recursive(I f, N n) { if (n == 0) return f/ if (n == 1) return ++f; N h = n » 1; I m = reverse_recursive(f, h); if (odd(n)) ++m; II = reverse_recursive(m, h) ; swap_ranges_n(f, m, h); return 1; } Упражнение 11-13. Используя последовательность {0,1,2,3,4,5,6,7,8} в качестве примера, проследите за работой алгоритма reverse_recursive. Эта функция возвращает конец диапазона, поэтому первый рекурсивный возвращает среднюю точку Затем мы сдвигаем среднюю точку вперед на 1 или на О в зависимости от того, четна длина или нечетна. Теперь можно написать функцию reverse для однонаправленных итераторов: template <ForwardIterator I> void reverse (I f, I 1, std::forward_iterator_tag) { reverse_recursive(f, distance(f, 1)); } И наконец, мы, как и раньше, можем написать обобщенную версию reverse, работающую для итераторов любого типа: template <ForwardIterator I> void reverse (I f, I 1) { reverse (f, 1, IteratorCategory<I> ()); } Закон уточнения интерфейса Какой интерфейс rotate правильный? Первоначально функция std: -.rotate возвращала void. После нескольких лет использования стало ясно, что возврат новой середины (позиции, в которую попадает первый элемент) упростил бы реализацию нескольких других алгоритмов STL, в частности in_place_merge и stable_partition. К сожалению, не сразу стало понятно, как вернуть это значение без дополнительной работы. И только после решения этой проблемы реализации появилась возможность перепроектировать интерфейс с целью возврата нужного значения. Затем потребовалось более 10 лет для внесения изменения в стандарт языка C++. Это иллюстрация к закону уточнения интерфейса: Проектирование интерфейсов, как и проектирование программ, требует нескольких проходов. Мы не можем спроектировать идеальный интерфейс, пока не увидим, как будет использоваться алгоритм, и не все сценарии использования очевидны сразу же. Кроме того, нельзя спроектировать идеальный интерфейс, пока неизвестно, какие реализации осуществимы.
19Б ♦ Алгоритмы перестановки 11.Б. Пространственная сложность Говоря о конкретных алгоритмах, программист должен учитывать их временную и пространственную сложность. Существует много уровней временной сложности (например, постоянное время, логарифмическое, квадратичное). Но в части пространственной сложности принято относить алгоритм к одной из двух категорий: выполняющие вычисления па месте и прочие. Определение 11.6- Говорят, что алгоритм выполняется на месте (или имеет полиномиально-логарифмическую пространственную сложность), если для любых входных данных длины п он потребляет не более 0((log n)k) дополнительной памяти, где k - константа. Первоначально в определении алгоритмов, выполняемых на месте, нередко фигурировала постоянная дополнительная память, но такое ограничение оказалось слишком строгим. Идея, заключающаяся в словах «на месте», подразумевала, что алгоритм не копирует входных данных. Однако многие алгоритмы, не копирующие данных, например быстрая сортировка, применяют технику «разделяй и властвуй», которая требует логарифмического объема дополнительной памяти. Поэтому определение было сформулировано так, чтобы включить и такие алгоритмы. Алгоритмы, не выполняемые на месте, потребляют больше памяти - обычно столько, сколько необходимо для создания копии входных данных. * * * Воспользуемся задачей об обращении, чтобы понять, почему алгоритм, не выполняющийся на месте, может работать быстрее, чем алгоритм на месте. Для начала нам понадобится вспомогательная функция, которая копирует элементы в обратном порядке, начиная с конца диапазона: template <BidirectionalIterator I, Outputlterator 0> 0 reverse_copy(I f, I 1, 0 result) { while (f != 1) *result++ = *—1; return result; } Далее можно написать алгоритм reverse, не выполняющийся на месте. Он копирует все данные в буфер, а затем - из буфера, но уже в обратном порядке: template <ForwardIterator I, Integer N, Bidirectionallterator B> 1 reverse_n_with_buffer(I f, N n, В buffer) { В buffer_end = copy_n(f, n, buffer); return reverse_copy(buffer, buffer_end, f); } Эта функция требует всего 2п присваиваний - вместо Зя, необходимых в реализациях, основанных на обмене.
Алгоритмы, адаптирующиеся к объему памяти ♦ 197 117. Алгоритмы, адаптируюшиеся к объему памяти На практике разделение алгоритмов на выполняющиеся на месте и не на месте не слишком полезно. Предположение о неограниченном объеме памяти, конечно, нереалистично, но не более реалистично и предположение о наличии полиномиально-логарифмического объема дополнительной памяти. Обычно имеется 25%, 10%, 5% или хотя 1% дополнительной памяти, и его-то можно использовать для достижения заметного прироста производительности. Алгоритмы должны адаптироваться к доступному объему памяти. Разработаем адаптирующийся к объему памяти алгоритм reverse. Он принимает буфер buffer, который может использовать как временную память, и размер этого буфера buf size. Алгоритм рекурсивный - на самом деле он почти повторяет функцию reverse_recursive из предыдущего раздела. Но рекурсия применяется, только когда размер блока данных велик, поэтому с издержками можно смириться. Идея проста: если обращаемая последовательность помещается в буфер, выполнить быстрое обращение с использованием буфера, иначе, применив рекурсию, разбить последовательность пополам. template <ForwardIterator I, Integer N, Bidirectionallterator B> I reverse_n_adaptive(I f, N n, В"buffer, N bufsize) { if (n == N(0)) return f/ if (n == N(l)) return ++f; if (n <= bufsize) return reverse_n_with_buffer(f, n, buffer); N h = n » 1; I m = reverse_n_adaptive(f, h, buffer, bufsize); advance(m, n & 1); 11= reverse_n_adaptive(m, h, buffer, bufsize); swap_ranges_n(f, m, h); return 1; } Программа, вызывающая эту функцию, должна узнать у системы, сколько доступно памяти, и передать это значение в качестве bufsize. Увы, такого системного вызова в большинстве операционных систем нет. Печальная история get_temporary_buffer Когда первый автор этой книги проектировал библиотеку STL для C++, он пришел к выводу, что хорошо бы иметь функцию get_temporary_buffer, которая принимает размер п и возвращает наибольший доступный временный буфер размера не более л, умещающийся в физической памяти. В качестве заглушки (поскольку для создания корректной версии нужно знать, о какой операционной системе идет речь) он написал примитивную и практически непригодную реализацию, которая повторно вызывала malloc, запрашивая сначала блок гигантского, а потом все уменьшающегося размера, пока не получил действительный указатель. Он поместил в код хорошо заметный комментарий такого вида «это негодная реализация, замените ее!». Спустя несколько лет он с удивлением обнаружил, что все крупные поставщики, предлагающие реализации STL, оставили эту кошмарную реализацию - но убрали комментарий.
198 ♦ Алгоритмы перестановки 11.8. Заключительные мысли В этой и в предыдущей главах была продемонстрирована важная вещь - простые вычислительные задачи предлагают богатые возможности для исследования различных алгоритмов и извлечения уроков. Принципы программирования, сформулированные на основе этих примеров, - законы полезного возврата, разделения типов, полноты и уточнения интерфейса - переносятся практически на любую возникающую в программировании ситуацию. В этой главе были также представлены примеры сочетания теории и практики в программировании. Наше знание теории перестановок - которая сама основана на теории групп - позволило построить более эффективный алгоритм циклической перестановки, основанный на свойствах, гарантируемых теорией. В то же время пример алгоритмов, адаптирующихся к объему памяти, показал, что практические соображения, в частности учет доступной памяти, могут оказать определяющее влияние на выбор алгоритма и его производительность. Теория и практика - две стороны одной медали, хороший программист должен знать то и другое.
Глава 1ь тттФштттттттФФтФФФттттФттттттттттттттФтттФттттФ Обобщения НОА Клянусь четом и нечетом. Коран, сура Аль-Фаджр Программисты часто предполагают, что коль скоро некая структура данных или алгоритм описаны в учебнике или используются уже много лет, то это самое лучшее решение задачи. Удивительно, но это не всегда так - даже если алгоритм используется тысячи лет и над ним поработали все, от Евклида до Гаусса. В этой главе мы рассмотрим пример нового решения старой задачи - вычисление НОД. Затем мы увидим, как доказательство одной теоремы из теории чисел привело к важной вариации этого алгоритма, используемой и по сей день. 12.1. Аппаратные ограничения и более эффективный алгоритм В 1961 году студент из Израиля, Джозеф «Иосси» Штайн, работал над докторской диссертацией, посвященной алгебре Рака. Необходимо было выполнять арифметические операции над рациональными числами, для чего требовалось сокращать дроби, то есть вычислять НОД. Но поскольку ему выделялось только ограниченное машинное время на медленном компьютере, у него появился стимул к поиску более оптимального решения. Вот что пишет он сам: Работа с «алгеброй Рака» требовала выполнения вычислений с числами вида а/Ь • Vc, где а, Ь, с - целые. Я написал программу для единственного имевшегося в то время в Израиле компьютера - WEIZAC в Институте Вейцмана. Сложение на нем занимало 57 микросекунд, деление - примерно 900 микросекунд. Операция сдвига выполнялась быстрее сложения... У нас не было ни компилятора, ни ассемблера, ни чисел с плавающей точкой, программировали мы в шестнадцатеричных кодах, и Раку с его студентами выделялось всего 2 часа машинного времени в неделю. Как видите, я находился в подходящих условиях для того, чтобы заняться поиском этого алгоритма. Умение быстро находить НОД означало выживание1. Штайн заметил, что в некоторых ситуациях НОД легко вычислить или выразить через другое выражение, содержащее НОД. Он рассматривал частные случаи, 1 Дж. Штайн, личное сообщение, 2003 год.
200 ♦> Обобщения НОА например вычисление НОД четного и нечетного числа или числа и его самого. В конечном итоге он выделил такой исчерпывающий перечень случаев: первый нуль: gcd(0, п) = п\ второй нуль: gcd(X 0) = п\ равные значения: gcd(n, n) = п\ четное, четное: gcd(2?2, 2т) = 2 • gcd(rc, га); четное, нечетное: gcd(2rc, 2га + 1) = gcd(n, 2га + 1); нечетное, четное: gcd(2?2 + 1, 2m) = gcd(2rc + 1, га); малое нечетное, большое нечетное: gcd(2?2 + 1, 2(п + &) + 1) = gcd(2?? + 1, k)\ большое нечетное, малое нечетное: gcd(2(n + k) + 1, 2n + 1) = gcd(2n + 1, &). Опираясь на эти наблюдения, Штайн сформулировал следующий алгоритм: template <BinaryInteger N> 1< stein_gcd(N га, N n] { if (m < N(0)) m = -m/ if (n < N(0)) n = -n; if (m == N(0)) return n; if (n == N(0)) return m; // m > 0 && n > 0 int d__m = 0; while (even(m)) { m »= 1/ ++d_m;} int d_n = 0; while (even(n)) { n »= 1; ++d n;} // odd(m) && odd(n) vr.ile (m != n) {t if (n > m) swap (n, m) ; m -= n; do m »= 1; while (even(m)); // m == n return m « min(d m, d n); Посмотрим, что делает этот код. Функция принимает два аргумента типа Binarylnteger, то есть такое представление целого числа, которое поддерживает быстрые операции сдвига и проверки четности, как целые числа в типичном компьютере. Первым делом она разбирает простые случаи, когда один из аргументов равен нулю, и изменяет знак, если какой-то аргумент отрицателен, так что дальше мы имеем дело только с двумя целыми положительными числами.
Аппаратные ограничения и более эффективный алгоритм ♦ 201 Затем рассматриваются случаи, когда один из аргументов четный: множители 2 устраняются (путем сдвига), и при этом запоминается, сколько их было. Для счетчика можно использовать обычное число типа int, потому что подсчитанное значение не превышает количества разрядов в исходных аргументах. Далее мы работаем с двумя нечетными числами. После этого начинается главный цикл. На каждой итерации мы вычитаем меньшее число из большего и, поскольку разность двух нечетных чисел четна, снова применяем сдвиг для устранения лишних степеней двойки1. По завершении цикла наши два числа будут равны. Поскольку на каждой итерации мы уменьшали число вдвое, то понадобилось не более log n итераций; сложность алгоритма ограничена числом встретившихся единиц. Наконец, мы возвращаем результат, применяя сдвиг для умножения числа на 2 столько раз, чему равен минимум из степеней двойки, удаленных в самом начале. О тех двойках, которые мы сократили в главном цикле, беспокоиться не надо, потому что к этому моменту задача уже сведена к поиску НОД двух нечетных чисел, а в их НОД двойка множителем входить не может. Приведем пример работы этого алгоритма. Допустим, мы хотим вычислить НОД(196, 42). Вычисления производятся в следующем порядке: т п d d„ сокращаем двойки: 196 98 49 49 итерации главного цикла: 49 28 14 7 21 14 7 Результат: 42 42 42 21 21 21 21 21 7 7 7 (вычитание п из т) (сдвиг wz) (сдвиг га) (перестановка тип) (вычитание п из га) (сдвиг га) 0 1 2 2 2 2 2 2 2 2 2 0 0 0 1 7 х 2"^) = 7 х 2 = 14 Как мы видели, Штайн обратил внимание на несколько частных случаев и превратил их в более быстрый алгоритм. Частные случаи касались четных и нечетных Мы используем цикл do-while, а не while, потому что на первом проходе проверять ничего не нужно, так как мы заведомо начинаем с четного числа и, следовательно, как минимум один сдвиг понадобится.
202 ♦ Обобщения НОА чисел и тех мест, где можно сократить на 2, - операция, которую компьютеры выполняют очень быстро. По этой причине алгоритм Штайна оказывается быстрее на практике. (Даже в наши дни, когда остаток можно вычислить аппаратно, это все равно гораздо медленнее, чем простые сдвиги.) Но что это - просто изобретательный трюк, или за ним стоит нечто большее? Это получилось только потому, что в компьютерах применяется двоичная арифметика? Работает ли алгоритм Штайна только для целых чисел или мы можем обобщить его, как поступили с алгоритмом Евклида? 12.2. Обобшение алгоритма Штайна Чтобы ответить на эти вопросы, рассмотрим некоторые вехи в истории развития алгоритма нахождения НОД, описанного Евклидом: О положительные целые числа: древние греки (V век до н. э.); О полиномы: Стевин (приблизительно 1600); О гауссовы целые числа: Гаусс (приблизительно 1830); О алгебраические числа: Дирихле, Дедекинд (приблизительно 1860); О обобщенный вариант: Нётер, Ван дер Варден (приблизительно 1930). Понадобилось больше 2000 лет, чтобы обобщить алгоритм Евклида с целых чисел на полиномы. К счастью, в случае алгоритма Штайна времени потребовалось куда меньше. На самом деле уже спустя 2 года после его публикации Кнут знал о варианте для полиномов от одной переменной над полем ¥[х]. Удивительное озарение заключается в том, что в случае полиномов х может играть ту же роль, что 2 для целых чисел. То есть мы можем сначала сократить на степени х и т. д. Если продолжить аналогию, то мы увидим, что полином х2 + х (и вообще любой полином, который делится нах) «четный», ах2 + х + 1 (и вообще любой полином с ненулевым свободным членом) - нечетный. Как деление на 2 для двоичных чисел проще, чем деление в общем случае, так и деление на х проще общей операции деления для полиномов - в обоих случаях нужен лишь сдвиг. (Напомним, что полином - это просто последовательность коэффициентов, поэтому деление на х сводится к сдвигу этой последовательности.) «Частные случаи» Штайна для полиномов выглядят так: gcd(p,0) = gcd(0,p)=p; gcd(p,p)=p; gcd(xp, xq) = x- gcd(p, q); gcd(xp, xq + c) = gcd(p, xq + c); gcd(xp + c, xq) = gcd(xp + c, q); ( c \ deg(p) > deg(#) => gcd(xp + c,xq + d) = gcd p-—q, xq + d ; К d J deg(p) < deg(q) => gcd(xp + c,xq + d) = gcd Г d Л xp + c,q—q . (12.1) (12.2) (12.3) (12.4) (12.5) (12.6) (12.7)
Обобщение алгоритма Штайна ♦ 203 Обратите внимание, что в последних двух правилах исключается один из свободных членов, то есть мы преобразуем случай «нечетный, нечетный» в «нечетный, четный». Чтобы доказать эквивалентность в правиле (12.6), мы опираемся на два факта. Во-первых, если есть два полинома и и v, то gcd(z/, v) = gcd(z/, av), где а - ненулевой коэффициент. Поэтому если мы умножим второй аргумент на коэффициент c/d, то НОД не изменится: ( с Л gcd(xp + c,xq + d) = gcd xp + с, —(xq + d) Во-вторых, gcd(w, v) = gcd(w, v - u)} что мы отмечали, когда впервые рассматривали алгоритм нахождения НОД (равенство (3.9)). Поэтому, если вычесть модифицированный второй аргумент из первого, то НОД останется таким же: (с ^ gcd(xp + с, xq + d) = gcd xp + с -—(xq + d), xq + d \ d ( с ^ = gcd xp + c-—xq + c,xq + d \ d ( с ^ = gcd xp-—xq,xq + d Наконец, мы можем воспользоваться тем фактом, что если один из аргументов функции gcd делится нах, а другой - нет, то х можно опустить, потому что НОД не будет содержать такого множителя. Поэтому мы «выдвигаем» х, что дает (с ^ gcd(xp + с, xq + d) = gcd p —-q,xq + d \ и а это именно то, что мы и хотели. Мы также видим, что при каждом преобразовании норма - в данном случае степень полинома - уменьшается. Вот как этот алгоритм вычисляет gcd(x3 - Зх - 2, xL 4): т лгО -ч'У л?3 - 0.5л:2 - х2 - 0.5л: - 2 • Ъх -3 0.25х2 - 0.5х х2- 2х х- 2 х-2 х- 2 п х2-А х2- 4 х2-4 х2-А х2- А х2- А х2- 2х х-2 Операция га-(0.5л:2-2) shift(77z) т- (0.75л:2 -3) normalize(w) shift(m) п - (2л: - 4) shift(m) НОД:*-2
204 ♦> Обобщения НОА Во-первых, мы видим, что отношение свободных коэффициентов (с и d в равенствах (12.6) и (12.7)) равно 1/2, поэтому мы умножаем п на 1/2 и вычитаем результат из т (первая строка таблицы). Новый т показан во второй строке. Затем мы «сдвигаем» т, сокращая нах, что дает третью строку. И так далее. В 2000 году Андре Вейлерт (Andre Weilert) обобщил алгоритм Штайна на гауссовы целые числа. На этот раз роль двойки играет 1 + г, а операция «сдвига» - деление на 1 + i В 2003 году Дамго (Damgard) и Франдсен (Frandsen) обобщили его на целые числа Эйзентштейна. В 2004 году Агарвал (Agarwal) и Франдсен продемонстрировали, что существует кольцо, не являющееся евклидовым, в котором алгоритм Штайна тем не менее работает. Иными словами, существуют случаи, когда алгоритм Штайна работает, а алгоритм Евклида - нет. Если область определения алгоритма Штайна - не евклидово кольцо, то что она собой представляет? На момент написания книги эта проблема оставалась нерешенной. Но что нам точно известно, так это то, что алгоритм Штайна опирается на понятие четного и нечетного; обобщением четного является делящееся на наименьшее простое, где р считается наименьшим простым, если остаток от деления любого элемента нар либо равен 0, либо является необратимым элементом. (Наименьших простых в кольце может быть несколько. Например, в случае гауссовых целых числа 1+z, 1-z, —1 +ги—1 — г являются наименьшими простыми.) Почему мы сокращаем на 2 при вычислении НОД целых чисел? Потому что, деля на 2, мы в конечном итоге получим в остатке 1, то есть нечетное число. Имея два нечетных числа (два числа, которые по модулю 2 равны единице), мы можем продолжить нахождение НОД, воспользовавшись вычитанием. Сокращение остатков работает, потому что 2 - наименьшее целое простое число. Аналогично х - наименьшее простое для полиномов, а г + 1 - для гауссовых целых1. Деление на наименьшее простое всегда дает в остатке нуль или единицу, потому что единица - число с наименьшей ненулевой нормой. Таким образом, 2 годится для целых чисел, потому что это наименьшее простое, а не потому что в компьютерах применяется двоичная арифметика. Алгоритм отражает фундаментальные свойства целых чисел, а не конкретную аппаратную реализацию, хотя эффективен он, потому что в компьютерах применяется двоичная арифметика, из-за чего операция сдвига выполняется быстро. Упражнение 12.1. Сравните производительность алгоритмов Штайна и Евклида для целых чисел, случайно выбранных из диапазонов [0, 216), [0, 232) и [0, 264). 123. Теорема Безу Чтобы понять связь между НОД и кольцами, нам понадобится теорема Безу, которая заодно приведет к важному практическому алгоритму вычисления обратного элемента относительно умножения. Эта теорема говорит, что для любых двух Отметим, что в кольце гауссовых целых чисел 2 не является простым числом, потому что его можно разложить на множители (1 + i)(l - i).
Теорема Безу ♦ 205 элементов аиЬ евклидова кольца существуют такие коэффициенты, что соответствующая линейная комбинация дает НОД исходных элементов. Теорема 12.1 (теорема Безу). Va, b Зх, у: ха + yh = gcd(a, b). Например, если а = 196 и Ь = 42, то теорема Безу говорит, что найдутся такие хи г/, что 196х + 42г/ = gcd(196,42). Поскольку gcd(196,42) = 14, то в данном случае х = -1, у = 5. Ниже в этой главе мы увидим, как вычисляются х и у в общем случае. Как часто бывает в математике, этот результат назван не по имени его первооткрывателя. Французский математик XVIII века Этьен Безу действительно доказал его для полиномов, но для целых чисел он был открыт на сто лет раньше Клодом Баше. Клод Гаспар Баше де Мезирьяк (1581 -1638) Клод Гаспар Баше де Мезирьяк, известный просто как Баше, был французским математиком эпохи Возрождения. Хотя он подвизался во многих областях, наиболее известны два его достижения. Во-первых, он перевел «Арифметику» Диофанта с греческого на латынь, общепринятый язык европейской науки и философии того времени. На его перевод 1621 года опиралось большинство математиков, и именно на полях экземпляра этого перевода Ферма оставил знаменитую заметку с описанием своей последней теоремы. Во-вторых, Баше написал первую книгу по занимательной математике «Problemes Plaisants», первое издание которой вышло в 1612 году. Благодаря этой книге математика стала популярна в среде образованных людей во Франции, ее обсуждали и занимались ей на досуге. В «Problemes Plaisants» был описан метод построения магических квадратов, а также доказана теорема, которая ныне носит имя Безу. Баше был избран одним из первых членов Французской академии - учреждения, которое кардинал Ришелье основал, наделив правом окончательного решения в вопросах, касающихся норм французского языка, и поручив составить официальный словарь французского языка. Напомним, что кольцом называется алгебраическая структура, которая ведет себя как множество целых чисел; в ней есть операции, аналогичные сложению pi умножению, но обращение определено только для сложения (см. определение 8.3 в разделе 8.4). Для доказательства теоремы Безу мы должны убедиться в существовании коэффициентов х и у. Для этого введем в рассмотрение новую алгебраическую структуру - идеал. Определение 12.1. Идеалом / называется непустое подмножество кольца R - такое, что:
206 ♦> Обобщения НОА 1. Vx, у £ I: x + у £ I. 2. Ухе I, \/a eR:axeL Первое свойство означает, что идеал замкнут относительно сложения, то есть сумма любых двух элементов, принадлежащих идеалу, также является элементом идеала. Второе свойство несколько тоньше; оно говорит, что идеал замкнут относительно умножения на любой элемент кольца, а не только на элементы самого идеала. Примером идеала может служить множество четных чисел, образующее непустое подмножество кольца целых чисел. Сумма любых двух четных чисел четна. Умножение четного числа на любое число (необязательно четное) снова дает четное число. Другие примеры идеалов - полиномы от одной переменной с корнем 5 и полиномы от переменных х, у без свободного члена (например, х2 + Зт/2 + ху + х); вскоре мы увидим, почему так важен последний случай. Отметим, что не всякое подкольцо является идеалом. Целые числа образуют подкольцо гауссовых целых, но не являются в нем идеалом, потому что произведение целого числа на мнимую единицу i не является целым. Упражнение 12.2. 1. Докажите, что идеал / замкнут относительно вычитания. 2. Докажите, что / содержит 0. Лемма 12.1 (об идеале линейных комбинаций). Для любого кольца и для любых двух его элементов а и Ь множество элементов вида {ха + уЬ) образует идеал. Доказательство. Во-первых, это множество замкнуто относительно сложения: (хха + ухЬ) + (х2а + у2Ь) = (х{ + х2)а + (z/t + y2)b. Во-вторых, оно замкнуто относительно умножения на любой элемент: z{xa + уЪ) = (zx)a + (zy)b. Следовательно, оно образует идеал. Упражнение 12.3. Докажите, что все элементы идеала линейных комбинаций делятся на любой общий делитель а и Ь. Лемма 12.2 (об идеалах в евклидовых кольцах). Любой идеал евклидова кольца замкнут относительно операций взятия остатка и евклидова НОД. Доказательство. 1. Замкнутость относительно взятия остатка. По определению: remainder(a, b) = a- quotient(a, b) • b. Если b принадлежит идеалу, то по второй аксиоме идеала произведение любого элемента на b тоже принадлежит идеалу, поэтому quotient(#, b) - b - элемент идеала. В силу упражнения 12.2 разность любых двух элементов идеала принадлежит идеалу.
Теорема Безу ♦ 207 2. Замкнутость относительно НО Д. Поскольку алгоритм вычисления НОД заключается в повторном применении операции взятия остатка, то это сразу следует из 1. Определение 12.2. Идеал / кольца R называется главным идеалом, если существует такой элемент a £ R, называемый порождающим элементом /, что х G I <=> Зу £ R: х = ау. Иначе говоря, главный идеал порожден одним элементом. Примером главного идеала является множество четных чисел (2 - порождающий элемент). Еще пример - полиномы с корнем 5. С другой стороны, полиномы от х, у без свободного члена образуют идеал, но не главный. Помните полином х2 + Зу2 + ху + х} который мы привели в качестве примера элемента такого идеала? Так вот, его невозможно породить, начав с х (такие полиномы никогда не содержали бы z/), или наоборот. Упражнение 12.4. Докажите, что любой элемент главного идеала делится на порождающий элемент. Напомним, что областью целостности называется кольцо без делителей нуля (определение 8.7). Определение 12.3. Область целостности называется областью главных идеалов (ОГИ), если любой идеал в ней - главный. Например, кольцо целых чисел - ОГИ, а кольцо полиномов от нескольких переменных над кольцом целых чисел - нет. Теорема 12.2. ЕК => ОГИ. Любое евклидово кольцо является областью главных идеалов. Доказательство. Любой идеал / евклидова кольца содержит элемент т с минимальной положительной нормой («наименьший ненулевой элемент»). Возьмем произвольный элемент a G /; он либо является кратным т, либо при делении на т дает остаток г. а = qm + г, где 0 < ||г|| < \\т ||. Но элемент т - наименьший, поэтому остаток от деления не может быть меньше него, иначе мы получили бы противоречие. Следовательно, а делится на т без остатка: а = qm. Таким образом, все элементы порождаются одним, а это и есть определение ОГИ. Теперь мы можем доказать теорему Безу Она утверждает, что существуют такие значенияхиу, что удовлетворяется равенствоxa + yb = gcd(a, b). Это утверждение можно переформулировать, сказав, что множество всех линейных комбинаций вида ха + уЪ содержит интересующее нас значение. Теорема Безу в другой формулировке. Идеал линейных комбинаций I = {ха + уЪ) евклидова кольца содержит gcd(a, b).
208 ♦> Обобщения НОА Доказательство. Рассмотрим идеал линейных комбинаций / = {ха + уЬ). Элемент а принадлежит /, потому что а = \а + Ob. Аналогично b принадлелшт /, потому что b = Оа + lb. По лемме 12.2, любой идеал евклидова кольца замкнут относительно операции НОД, поэтому gcd(a, b) принадлежит I. Теорему Безу можно применить и для доказательства леммы об обратимости из главы 5. Лемма 5.4 (лемма об обратимости). Va, n G Z : gcd(a, п) = 1 =» Зх G Ъп: ах = ха = 1 mod п. Доказательство. По теореме Безу: Зх, у G Z : ха + уп = gcd(a, n). Поэтому если gcd(a, п) = 1, то ха + уп = 1. Следовательно, ха = -уп + 1 и ха = 1 mod n. Упражнение 12.5. С помощью теоремы Безу докажите, что еслир - простое число, то для любого 0 < а <р существует число, обратное а относительно умножения по модулю р. 12.4. Расширенный алгоритм Евклида Доказательство теоремы Безу, представленное в предыдущем разделе, отличается интересной особенностью. Оно показывает, что результат верен, но ничего не говорит о том, как найти коэффициенты. Это пример неконструктивного доказательства. В течение длительного времени математики спорили о том, молшо ли считать неконструктивные доказательства столь же убедительными, сколь конструктивные. Возражавших против неконструктивных доказательств называли конструктивистами, или интуиционистами. На рубеже XX века общее мнение, возглавляемое Давидом Гильбертом и Геттингенской школой, повернулось против конструктивистов. Оставшийся в одиночестве главный адвокат конструктивизма, Анри Пуанкаре, проиграл битву, и сегодня неконструктивные доказательства воспринимаются как должное. Анри Пуанкаре (1854-1912) Жюль Анри Пуанкаре был французским математиком и физиком. Он происходил из глубоко патриотичной семьи, а его двоюродный брат Раймон занимал пост премьер- министра Франции. Пуанкаре опубликовал более 500 работ по разным предметам, включая специальную теорию относительности, которую он разработал независимо от Эйнштейна и в ряде случае раньше него. Пуанкаре работал над многими практическими задачами, например определением часовых поясов, но науку он рассматривал прежде всего как средство для понимания устройства Вселенной. Он писал: Ученый не должен тратить время на достижение практических результатов. Без сомнения, он их получит, но пусть это будет попутно. Он никогда не должен забывать.
Расширенный алгоритм Евклида ♦ 209 что изучаемый им конкретный объект - всего лишь часть большого целого, изучение которого и должно быть единственной целью его деятельности. У науки есть замечательные применения, но наука, стремящаяся только к практическим применениям, перестала бы быть наукой, а превратилась бы просто в стряпню. Пуанкаре написал несколько значительных книг по философии науки и был избран членом Французской академии. Пуанкаре внес вклад чуть ли не во все разделы математики и положил начало нескольким подраз- , ■■* " делам, например алгебраической топологии. В те времена велись ожесточенные споры о том, кого считать величайшим математиком: Пуанкаре или Гильберта. Однако из-за критики теории множеств и формалистической программы Гильберта Пуанкаре оказался проигравшим в соперничестве между Францией и недавно объединившейся Германией. Неприятие формалистами интуиционистского подхода Пуанкаре стало большой потерей для математики XX века. Обе точки зрения дополняют друг друга. Но что бы там ни говорили приверженцы формалистической школы, с точки зрения программирования, очевидно, предпочтительнее иметь алгоритм, а не просто знать, что он существует. Поэтому сейчас мы дадим конструктивное доказательство теоремы Безу, то есть построим алгоритм нахождения таких х и z/, что xa + yb = gcd(a, Ъ). Для понимания этой процедуры полезно еще раз вспомнить, что происходит при выполнении алгоритма Евклида, вычисляющего НОД а и Ь. template <EuclideanDomain E> Е gcd(E a, E b) { while (b != Е(0)) { а = remainder(a, b); std::swap(a, b); } return a; } На каждой итерации цикла мы заменяем а остатком от деления а на Ь, а затем меняем местами а и Ь\ по выходе из цикла последний остаток будет содержать НОД. Итак, вычисляется такая последовательность остатков: i\ = remainder(a, b); r2 = remainder^, rx)\ r3 = remainder^ r2); rn = remainder(r„_2, rn_x).
210 ♦ Обобщения НОА Обратите внимание, что второй аргумент функции remainder на итерации k становится ее первым аргументом на итерации k + 1. Поскольку результат вычисления функции remainder с аргументами а и Ь - это то, что остается от а после деления а на Ь, мы можем переписать эту последовательность в виде: (12.8) П- Г2 h = а = Ь = г, -Ъ- ~гх ~г2 Qv •я» •<?3; где члены q - соответствующие частные. Мы можем решить каждое из этих уравнений относительно первого члена в правой части - первого аргумента функции remainder: a = h-q{ + 1\\ b=rrq2 + r2; r{ =r2-q3+ r3; Tn-2 = Tn~\ * Яп + Tn- В разделе 4.7 мы показали, что последний ненулевой остаток гп в этой последовательности равен НОД исходных аргументов. Для доказательства теоремы Безу мы должны показать, что rn = gcd(a, b) = xa + yb. Если бы какое-то из приведенных выше равенств можно было записать в виде линейной комбинации а и Ь, то это было бы справедливо и для следующего. В первых трех случаях все просто: а = 1-а + 0 Ъ\ (12.10) 6 = 0-а+1-6; (12.11) гх = 1 • а + (-q{) • Ь. Последнее равенство вытекает из исходного определения гх как первого остатка в последовательности - (12.8). Для получения следующей линейной комбинации нужно подставить выражение для гх и перегруппировать члены: r2=b- rxq2 = b - (a- qxb)q2 = b - q2a + qxq2b = -q2a + (l+q{q2)b. Далее мы имеем рекуррентное соотношение. Предположим, что уже известно, как представить два последовательных остатка в виде линейных комбинаций: rM=xMa + yi+1b.
Расширенный алгоритм Евклида ♦ 211 Воспользуемся равенством (12.9), показывающим, как найти следующий остаток, зная два предыдущих, подставим в него эти выражения и сгруппируем вместе коэффициенты при а и при Ъ\ Ti+2 ~ Г\ ~ ri+\Qi+2 = xta + yjb - (xi+xa + yi+ib)qi+2 = xta + y%b - xi+]qi+2a - yi+{qi+2b = xfl - xi+iqi+2a + ytb - yi+iqi+2b = (x( - xi+,qi+2)a + (z/f - yi+1qi+2)b. Мы видим, что каждый член последовательности можно представить в виде линейной комбинации аиЬ,и разработали процедуру, которая позволяет получить коэффициенты х и у на каждом шаге. Заметим далее, что коэффициенты при а определяются через предыдущие коэффициенты при а (то есть в их выражения входят только переменные х), а коэффициенты при b - через предыдущие коэффициенты при b (то есть в их выражения входят только переменные у). В частности, коэффициенты на итерации г + 2 равны xi+2=xi-xi+xqi+2, (12.12) У1+2 = Ух ~ yi+\tfi+2- Дойдя до конца, мы получим коэффициенты х и у - такие, что xa + yb = gcd(<7, b), к чему и стремились с самого начала. Мы также видели, что коэффициенты у не зависят от х, а х не зависят от z/. Коль скоро мы знаем представление ха + yb = gcd(a, b), то при условии, что b * 0, мы можем выразить у в виде __ gcd(a,b)-ax У~ Ь ' Это означает, что вычислять промежуточные значения у вообще ни к чему. Упражнение 12.6. Чему равны х и г/, если b = О? * * * Теперь, научившись вычислять коэффициент х в тождестве Безу, мы можем модифицировать алгоритм нахождения НОД, так чтобы он возвращал и это значение тоже. Новый алгоритм называется расширенный НОД (или расширенный алгоритм Евклида). Он, как и раньше, вычисляет последовательность остатков для нахождения НОД и дополнительно последовательность коэффициентов х из приведенных выше уравнений. Как мы видели, рекуррентное соотношение позволяет не хранить все коэффициенты, нам нужны только два предыдущих, обозначим их х0 и i,, Разумеется, чтобы начать вычисление, мы должны знать первые два значения, но, по счастью, они у нас есть - это коэффициенты при а в первых двух линейных комбинациях (12.10) и (12.11), а именно: 1 и 0. Тогда для вычисления каждого следующего зна-
212 ♦ Обобщения НОА чения xi+2 можно использовать равенство (12.12). При вычислении следующего х необходимо знать частное, так что нам понадобится соответствующая функция. Ну а кроме того, раз вы вычисляем НОД, то по-прежнему не обойтись без остатка. Коль скоро нам нужны и частное, и остаток, то можно воспользоваться обобщенной функцией quotient_remainder из раздела 4.6, которая возвращает пару, содержащую частное в первом элементе и остаток во втором. Вот код расширенного алгоритма НОД: template <EuclideanDomain E> std::pair<E, E> extended_gcd(E a, E b) { Е х0(1); Е xl (Ob- while (b != E(0)) { // вычислить новые г и х std::pair<E, E> qr = quotient_remainder(a, b); Е х2 = хО - qr.first * xl; // сдвинуть г и х хО = xl/ xl = х2; а = Ь; b = qr.second; } return {xO, a}; } По завершении цикла, когда Ь обратится в нуль, функция возвращает пару, состоящую из значения х, необходимого для тождества Безу, и НОД а и Ь. Упражнение 12.7. Разработайте вариант расширенного алгоритма НОД на основе алгоритма Штайна. 12.5. Применения НОА Подводя итог обсуждению НОД, рассмотрим некоторые важные применения этого алгоритма. Криптография. В следующей главе мы увидим, что современные криптографические алгоритмы зависят от возможности вычисления обратного элемента относительно умножения по модулю п для больших чисел, а расширенный алгоритм НОД позволяет это сделать. Из теоремы Безу мы знаем, что xa + yb = gcd(a, b), поэтому ха = gcd(a, b) - yb.
Заключительные мысли ♦ 213 Если gcd(a, b) = 1, то ха= 1 -тД Поэтому произведение х и а равно 1 плюс некоторое кратное Ь, или, иными словами: ха = 1 mod 6. Из главы 5 мы знаем, что два числа, произведение которых равно 1, являются взаимно обратными относительно умножения. Поскольку алгоритм extendedgcd возвращает х и gcd(a, b), то если НОД равен 1, значит, х является обратным к а по модулю Ь, нам даже у знать не нужно. Арифметика рациональных чисел. Арифметика рациональных чисел полезна во многих областях, но она невозможна без приведения дробей к каноническому виду, а для этого нужен алгоритм НОД. Символическое интегрирование. Один из основных компонентов символичес со- го интегрирования - разложение дробно-рациональной функции на npocrei'.iLiie дроби. Для этого вычисляется НОД полиномов над полем вещественных чисел. Алгоритмы циклической перестановки. В главе 11 мы видели, что НОД играет важную роль в алгоритме циклической перестановки. На самом деле функция std:: rotate в C++ опирается на эту связь. 12.6. Заключительные мысли В этой главе мы рассмотрели два примера того, как настойчивое изучение старого алгоритма может привести к новым открытиям. Наблюдения Штайна над закономерностями чета и нечета при вычислении НОД позволили разработать более эффективный алгоритм, обнаживший важные математические связи. Предложенное Баше доказательство теоремы о НОД дало нам возможность расширить алгоритм НОД и применить его к разнообразным задачам. Открытие алгоритма Штайна иллюстрирует несколько важных принципов программирования. 1. Любой полезный алгоритм основан на некотором фундаментальном математическом факте. Когда Штайн обратил внимание на полезные закономерности при вычислении НОД четных и нечетных чисел, он не думал о наименьших простых. На самом деле очень часто открыватель алгоритма не видит стоящего за ним математического обоснования. Нередко между открытием и полным осмыслением алгоритма проходит длительное время. Но от того его математический смысл никуда не девается. Поэтому всякая полезная программа достойна изучения, а за каждой оптимизацией стоит основательная математика.
214 ♦ Обобщения НОА 2. Даже у классической задачи, изученной величайшими математиками, моэюет найтись новое решение. Например, если кто-то скажет вам, что сортировку нельзя выполнить за время, меньшее п log n, не верьте. Для вашей конкретной задачи это может быть и не так. 3. Ограничения способствуют творчеству. Ограничения, с которыми Штайн столкнулся при работе на компьютере WEIZAC в 1961 году, заставили его искать альтернативы традиционному подходу. То же верно и во многих других ситуациях: нужда - мать изобретения.
Глава I—J ттттттттфттттттттттттшттттттттттттштштттшштттшт Реальное приложение Я превосходно знаком со всеми видами тайнописи и сам являюсь автором научного труда, в котором проанализировано сто шестьдесят различных шифров у однако я вынужден признаться, что этот шифр для меня - совершенная новость. Шерлок Холмс В этой книге мы видели примеры важных алгоритмов, берущих начало в . чисел. Мы были свидетелями того, как попытки обобщить эти > ате.\ ат!гчес.; е результаты стали стимулом для развития общей алгебры и как заи> ствован: - из нее идеи абстрагирования привели прямиком к принципам обобщенного программирования. Теперь соберем все вместе. Мы покажем, как математические результаты и обобщенные алгоритмы можно использовать для создания реального приложения: одной из систем безопасной передачи сообщений, которая называется криптосистемой с открытым ключом. 13.1. Криптология Криптология - это наука о секретной передаче сообщений. Криптография занимается разработкой кодов и шифров1, криптоанализ - их раскрытием. Идея посылать тайные сообщения возникла еще в античные времена, примеры криптографии мы встречаем во многих обществах, в том числе в Спарте и в Персии. Юлий Цезарь применял метод замены каждой буквы другой буквой в «сдвинутом» алфавите (сейчас он называется шифром Цезаря) для секретной переписки со своими военачальниками. В XIX веке криптография и «криптограммы» - загадки, в которых использовался простой подстановочный шифр, - занимали воображение публики. В 1830 году Эдгар Аллан По поместил в журнале статью, где брался расшифровать любое сообщение такого рода, присланное читателями. И, кажется, выполнил свое обещание. Через несколько лет он опубликовал рассказ «Золотой жук», где поведал, как взломать такой код. Несколькими десятками лет позже подстановочный шифр вновь громко заявил о себе - на сей раз в рассказе «Пляшущие человечки» Артура Конан-Дойля. Строго говоря, код - это система, в которой осмысленное понятие, например имя, место или событие, заменяется каким-то другим текстом, а шифр - система модификации текста на уровне его представления (в виде букв или битов). Но мы будем использовать эти термины как синонимы, в частности неформально считать, что слова кодировать и декодировать означают то же самое, что зашифровать и дешифрировать.
216 ♦ Реальное приложение Но значение криптографии далеко не исчерпывается досужими развлечениями. Коды и шифры играли важную роль в дипломатии, разведке и на войне. К началу XX столетия создание более качественных криптографических схем стало высшим приоритетом для вооруженных сил ведущих мировых держав. От умения раскрывать чужие коды мог зависеть исход сражения. Блетчли-парк и разработка компьютеров Во Второй мировой войне основная британская группа криптоаналитиков базировалась в особняке, расположенном в местечке Блетчли-парк. Тогда в немецком ВМФ применялся усовершенствованный вариант коммерческой шифровальной машины - Энигма. Первая версия Энигмы была взломана польским криптографом Марианом Реевским, который использовал электромеханическое устройство, параллельно перебирающее различные варианты схемы проводки роторов. В Блетчли-парк блестящий молодой математик Алан Тьюринг, чья предыдущая работа заложила основы того, что позже стало называться информатикой, спроектировал значительно улучшенный вариант устройства Реевского, названный «бомбой». Благодаря работе Тьюринга и многих других союзники получили возможность расшифровывать сообщения Энигмы, что немало способствовало победе в войне. Нацисты применяли и еще один механизм шифрования - машину Лоренца. Пытаясь взломать шифр Лоренца, британские криптографы поняли, что электромеханические бомбы работают недостаточно быстро. Тогда инженер Томми Флауэрс спроектировал гораздо более мощное устройство на электронных трубках, получившее название «Колосс». Колосс, который еще не был универсальным компьютером и программировался не в полной мере, все же можно считать первой в мире программируемой цифровой электронной вычислительной машиной. Криптосистема - это совокупность алгоритмов шифрования и дешифрирования данных. Исходные данные называются открытым текстом, зашифрованные - шифртекстом. Поведение алгоритмов шифрования и дешифрирования определяется ключами: шифртекст = encryption(&ez/0, открытый текст); открытый текст = decryption(^ez/1, шифртекст). Система называется симметричной, если key0 = keyv В противном случае она называется асимметричной. Ранние криптосистемы были по преимуществу симметричными, в них использовались секретные ключи. Проблема здесь в том, что отправитель и получатель должны заранее иметь ключи. Если ключ будет скомпрометирован и отправитель захочет перейти на новый ключ, то ему придется придумать, как втайне сообщить новый ключ получателю. * * * В криптосистеме с открытым ключом применяется схема шифрования с парой ключей: открытый ключ pub используется для шифрования, закрытый ключ pw - для дешифрирования. Если Алиса захочет передать сообщение Бобу, то она зашифрует его открытым ключом Боба. В этом случае шифртекст не сможет прочесть никто, кроме Боба, который расшифровывает сообщение своим закрытым ключом.
Проверка простоты ♦ 217 Для реализации криптосистемы с открытым ключом должны выполняться следующие требования: 1. Функция шифрования должна быть односторонней: ее вычислить легко, а обратную к ней трудно. Слово «трудно» в информатике означает, что необходимо экспоненциальное время - в данном случае имеется в виду экспоненциальная зависимость от длины ключа. 2. Обратную функцию должно быть легко вычислить, если известна дополнительная информация, которая называется лазейкой. 3. Алгоритмы шифрования и дешифрирования всем известны. Тем самым обеспечивается уверенность всех сторон в надежности применяемых методов. Функция, удовлетворяющая первым двум требованиям, называется односторонней функций с лазейкой. Пожалуй, самая известная и широко распространенная криптосистема с открытым ключом - алгоритм RSA, названным по именам его открывателей: Райвест (Rivest), Шамир (Shamir), Адлеман (Adleman). Как мы вскоре увидим, RSA осн - ван на математических свойствах простых чисел. Кто изобрел криптографию с открытым ключом? Многие годы считалось, что криптографию с открытым ключом изобрел в 1976 году профессор Стэнфордского университета Мартин Хеллман со своими двумя ас~ ~ - тами Уитфилдом Диффи и Ральфом Мерклом. Они предложили принцип работы такой системы и поняли, что для нее нужна односторонняя функция с лазейкой. К сожалению, они не привели пример такой функции - вся конструкция была чисто гипотетической. В 1977 году исследователи из Массачусетского технологического института Рон Райвест, Ади Шамир и Лен Адлеман предложили процедуру создания односторонней функции с лазейкой, которая стала известна как алгоритм RSA - по начальным буквам фамилий авторов. В 1997 году британское правительство обнародовало информацию о том, что один из научных сотрудников разведывательной службы, Клиффорд Кокс, открыл частный случай алгоритма RSA в 1973 году, но снять гриф с работы Кокса удалось только через 20 лет после публикации RSA. После этого адмирал Бобби Рэй Инман, бывший глава Агентства национальной безопасности США, заявил, что его агентство изобрело вариант криптографии с открытым ключом еще раньше - в 1960-х годах. Правда, никаких доказательств представлено не было. Кто знает, разведка какой еще страны предъявит свои притязания следующей? 13.2. Проверка простоты Как известно, задача различения простых и составных чисел является одной из самых важных и полезных в арифметике. К. Ф. Гаусс. Арифметические исследования В современной криптографии очень важно уметь определять, является ли целое число простым. Гаусс считал, что (1) вопрос о том, является число простым или составным, очень труден и (2) что столь же трудна задача о разложении числа на множители. Насчет пункта 1 он ошибался, как мы сейчас увидим. Что касается
218 ♦ Реальное приложение пункта 2, то на сегодняшний день считается, что он был прав, и это хорошо, потому что современные криптосистемы базируются на этом предположении. Чтобы узнать, является ли число п простым, нам пригодится предикат, сообщающий, делится ли оно на заданное число г. template <Integer I> bool divides(const I& i, const I& n) { return n % i == I(0); } Мы можем повторно вызывать эту функцию, пока не найдем наименьший делитель заданного числа п. Как и при реализации решета Эратосфена в главе 3, цикл проверки делителей начинается с 3, продвигается вперед на 2 и завершается, когда квадрат текущего кандидата оказывается больше или равен п: template <Integer I> I smallest_divisor (I n) { // precondition: n > 0 if (even(n)) return 1(2); for (I i(3); i * i <= n; i += 1(2)) { if (divides(i, n)) return i; } return n; } Теперь можно написать простую функцию для определения того, является п простым или нет: template <Integer l> I is_prime(const I& n) { return n > 1(1) && smallest_divisor(n) == n; } Эта функция работает математически корректно, но недостаточно быстро. Ее сложность оценивается как 0(ш) = 0(2(Iogw)/2). To есть она экспоненциально зависит от количества цифр. Чтобы проверить простоту числа, состоящего из 200 цифр, нам придется ждать намного дольше, чем существует Вселенная. Для преодоления этой трудности мы выберем другой подход, основанный на умении выполнять умножение по модулю. Напишем объект-функцию, который делает то, что нам нужно: template <Integer I> struct modulojrmltiply { I modulus; modulojrmltiply(const I& i) : modulus(i) {} I operator () (const I& n, const I& m) const { return (n * m) % modulus; } };
Проверка простоты ♦ 219 Еще нам понадобится нейтральный элемент: template <Integer I> I identity_element(const modulo_multiply<I>&) { return 1(1); } Теперь мы можем вычислить обратный элемент относительно умножения по простому модулю р. Для этого воспользуемся следствием из малой теоремы Ферма, приведенным в разделе 5.4: обратным к целому числу а, где 0 < а < р, является ар~2. Мы также будем использовать функцию power из главы 7: template <Integer I> I multiplicative_inverse_fermat(I a, I p) { // precondition: p is prime & a > 0 return power_monoid(a, p - 2, modulo_multiply<I>(p))/ } Все вместе позволяет применить малую теорему Ферма для проверки простоты числа п. Напомним, что утверждает малая теорема Ферма: Еслир - простое число, то ар~х - 1 делится нар для любого 0 < а <р. Эквивалентно: Еслир - простое число, то ар~{ = 1 mod р для любого 0 < а <р. Мы хотим узнать, является ли п простым. Возьмем произвольное число а, меньшее п, возведем его в степень /7—1, применяя умножение по модулю п, и проверим, равен ли результат 1 (такое число а называется свидетелем). Если результат не равен 1, то мы заведомо знаем (в силу контрапозитивной теоремы), что п непростое. Если результат равен 1, то есть шанс, что п простое, а если выполнить такую проверку для большого количества случайно выбранных свидетелей, то шансы п на простоту сильно повысятся. template <Integer I> bool fermat_test(I n, I witness) { // precondition: 0 < witness < n I remainder(power_semigroup(witness, n - 1(1), modulo_multiply<I>(n))); return remainder == 1(1); } На этот раз мы воспользуемся функцией power_semigroup, а не power monoid, потому что не собираемся ничего возводить в степень 0. Тест Ферма работает очень быстро, потому что у нас есть быстрый способ возведения в степень - обобщенный алгоритм египетского умножения из главы 7, имеющий временную сложность 0(log n). * * *
220 ♦ Реальное приложение И хотя тест Ферма в большинстве случаев работает отлично, существуют патологические ситуации, когда обманутыми оказываются все свидетели, взаимно простые с п: все они дают остаток 1, даже если являются составными. Такие числа называются числами Кармайкла. Определение 13.1. Составное число п > 1 называется числом Кармайкла, если \/Ь > 1 coprime(6, п) => Ъп~х = 1 mod п. Примером числа Кармайкла является 172081. Оно разлагается на следующие простые множители: 7 • 13 • 31 • 61. Упражнение 13.1. Реализуйте функцию bool is_carmichael(n) Упражнение 13.2. С помощью написанной в упражнении 13.1 функции найдите первые семь чисел Кармайкла. 133. Тест Миллера-Рабина Чтобы не думать о числах Кармайкла, мы воспользуемся улучшенным вариантом проверки простоты - тестом Миллера-Рабина; его пригодность также зависит от скорости работы нашего алгоритма возведения в степень. Мы знаем, что число п - 1 четное (было бы совсем уж глупо запускать тест простоты для четного п), поэтому п - 1 можно представить в виде произведения 2k • q. В тесте Миллера-Рабина используется последовательность квадратов w2i)q, w'lXq,..., wlkq, где w - случайно выбранное число, меньшее проверяемого. Последний показатель степени в этой последовательности равен п - 1, то есть совпадает со значением, используемым в тесте Ферма; ниже мы увидим, почему это важно. Нам также понадобится правило самообратимости (лемма 5.3), только мы изменим в нем имена переменных и будем подразумевать умножение по модулю: Для любого 0 < х < п Л prime(n) х2 = 1 mod п=> х= 1 V х= -1. Напомним, что в арифметике по модулю -1 mod n равно (п - 1) mod n, на этот факт мы будем опираться в коде. Если обнаружится число х, отличное от 1 и -1, для которого х2 = 1 mod п, то п не простое. Теперь сделаем два наблюдения. (1) если х2 = 1 mod n, то не имеет смысла еще раз возводить х в квадрат, потому что результат не изменится; если мы достигли 1, то работа завершена. (2)еслих2= 1 mod n и х не равно -1, топ заведомо не простое (потому что случай х = 1 мы исключили раньше). Приведенный ниже код возвращает true, если п с большой вероятностью простое, и false, если это заведомо не так. template <Integer I> bool miller_rabin_test(I n, I q, I k, I w) {
Тест Миллера-Рабина ♦ 221 // precondition п>1лп-1= (2**k)q л q нечетное modulo_multiply<I> mmult(n); I x = power_semigroup(w, q, mmult); if (x == 1(1) || x == n - 1(1)) return true; for (I i(D; i < k; ++i) { // инвариант х = w** (2*Mi-l)q) x = mmult(x, x); if (x == n - 1(1)) return true; if (x == 1(1)) return false; } return false; } Отметим, что q и k передаются в качестве аргументов. Мы собираемся вызывать функцию много раз с разными свидетелями, и не хотелось бы каждый раз заново искать представление п - 1 в нужном виде. Почему мы можем вернуть true в начале, если вызов powersemigroup возвращает 1 или -1? Потому что знаем, что возведение результата в квадрат даст 1, а возведение в квадрат эквивалентно умножению показателя степени на два, и если сделать это k раз, то получится показатель степени п - 1 - то самое значение, которое необходимо для выполнения малой теоремы Ферма. Иными словами, если Wl mod /2=1 или -1, то w2 q mod n = wn~x mod n = 1. Рассмотрим пример. Предположим, что нужно узнать, является ли п = 2793 простым. Выбираем случайного свидетеля w = 150. Представляем п - 1 = 2792 в виде 22 • 349, так что q = 349 и k = 2. Вычисляем х = jsfl mod n = 150349 mod 2793 = 2019. Поскольку результат отличен от 1 и -1, начинаем возводить х в квадрат: i = 1; х2 = 15021*49 mod 2793 = 1374; i = 2; х2 = 150223/i9 mod 2793 = 2601. Поскольку мы еще не дошли до 1 или -1, а г = k, то можно остановиться и вернуть false - 2793 непростое. Как и тест Ферма, тест Миллера-Рабина работает правильно в большинстве случаев. Но, в отличие от теста Ферма, для теста Миллера-Рабина можно дать доказуемую гарантию: результат получается правильным не менее чем в 75% случаев1 для случайно выбранного свидетеля w (на практике даже чаще). При выборе 100 случайных свидетелей вероятность ошибки составляет менее 1/2200. Как заметил Кнут, «вероятность того, что компьютер потеряет бит из-за космического излучения, гораздо выше». AKS: новый тест простоты В 2002 году Нирадж Каял и Нитин Саксена, два студента Индийского технологического института в Канпуре, вместе со своим научным руководителем Маниндра Агравалом 1 На самом деле эта гарантия имеет место, только если q нечетно.
222 ♦ Реальное приложение нашли и опубликовали детерминированный алгоритм проверки простоты с полиномиальным временем работы. Над этой проблемой теории чисел ученые трудились несколько столетий. Эндрю Грэнвилл написал очень ясную статью, в которой описывается предложенный метод. И хотя статья насыщена математикой, она доступна на удивление широкой аудитории. Это необычно -для понимания большей части математических результатов, опубликованных за последние несколько десятков лет, требуется долго учиться. Решительно настроенным читателям, готовым приложить серьезные интеллектуальные усилия, мы советуем прочесть ее. Хотя алгоритм AKS представляет собой замечательное математическое достижение, мы не будем его здесь использовать, потому что вероятностный алгоритм Миллера- Рабина все же работает значительно быстрее. 13.4. Алгоритм RSA: как и почему он работает Алгоритм RSA - одна из самых важных и широко распространенных на сегодня криптосистем. Он часто используется для аутентификации - доказательства того, что пользователь, компания, веб-сайт или еще что-то действительно являются тем, кем представляются. Он применяется также для обмена закрытыми ключами, используемыми в отдельной, более быстрой симметричной криптосистеме для шифрования передаваемых данных. Ниже перечислены некоторые коммуникационные протоколы, в которых находит применение RSA: IPSec Безопасность передачи данных на низком транспортном уровне РРТР Виртуальные частные сети SET Безопасные электронные транзакции (например, для оплаты кредитными картами) SSH Безопасный удаленный доступ к другому компьютеру SST/TLS Безопасный уровень передачи данных Многими из этих протоколов мы пользуемся ежедневно. Например, всякий раз посещая «безопасный» сайт (URL которого начинается префиксом https), вы полагаетесь на протокол SSL/TLS, который, в свою очередь, использует RSA или (в зависимости от реализации) какую-нибудь похожую криптосистему с открытым ключом. RSA опирается на описанные выше математические результаты, относящиеся к проверке простоты. Алгоритм RSА состоит из двух шагов: генерация ключа (выполняется редко) и кодирование-декодирование (выполняется при каждой отправке или получении сообщения). Опишем порядок генерации ключа. Сначала вычисляются следующие значения: О два больших случайных простых числа/?! ир2 (это возможно благодаря тесту Миллера-Рабина); О их произведение п = pj)2;
Алгоритм RSA: как и почему он работает ♦ 223 О функция Эйлера от их произведения по формуле (5.5) из главы 5: ф(р^2) = -(Pi-i)(p2-i); О случайный открытый ключ pub, взаимно простой с ФО?^)! О закрытый ключ pw, обратный к pub относительно умножения по модулю ФСР1Р2) (лля его вычисления используется расширенная функция нахождения НОД, написанная в главе 12). По завершении вычислений р1 и р2 уничтожаются, pub и п публикуются, a pw хранится в секрете. Теперь не существует простого способа разложить число п на множители, потому что и оно само, и его множители очень велики. Процедура шифрования и дешифрирования проста. Текст разбивается на блоки одинаковой длины, скажем по 256 байтов, которые интерпретируются как большие целые числа. Размер блока сообщения s следует выбирать так, чтобы п > 2\ Для шифрования открытого текста используется хорошо знакомый нам алгоритм возведения в степень: power_semigroup(plaintext_block, pub, modulo_multiply<I>(n)); Дешифрирование производится так: power_semigroup(ciphertext_block, prv, modulo_multiply<I>(n)); Отметим, что операции шифрования и дешифрирования в точности одинаковы. Разница только в подаваемом на вход тексте и ключе. * * * Как работает алгоритм RSA? Шифрование состоит в возведении сообщения т в степень pub; дешифрирование - в возведении результата в степень pw. Мы должны доказать, что последовательное применение этих двух операций восстанавливает исходное сообщение тп (по модулю п): (mpub)Prv = m mo(j n Доказательство. Вспомним, что по построению число pw является обратным к pub относительно умножения по модулю ФСр^Х т0 есть произведение pub n prv дает при делении на §(р{р2) некое частное q и остаток 1. Мы можем подставить это в показатель степени в правой части: /^pub\prv __ ^pubxprv = т(т№\Р2>уш Теперь применим теорему Эйлера из главы 5, согласно которой аф}) - 1 делится на п, то есть аф(/2) = 1 + vn. После подстановки получаем = /72(1 + vn)q. Если раскрыть скобки в выражении (1 + vn)q, то каждый член, кроме 1, будет кратным 77, поэтому мы можем собрать их вместе и написать просто 1 плюс какое- то кратное п:
224 ♦ Реальное приложение = m + wn = т mod n. Для применения теоремы Эйлера необходимо, чтобы т было взаимно просто с п =р]р2- Поскольку сообщение т может быть произвольным, откуда нам знать, что оно взаимно просто ср^р2? Так как^ ир2 - очень большие простые числа, то вероятность этого события практически неотличима от 1, и обычно никто по этому поводу не переживает. Но если вас все-таки беспокоит этот вопрос, то можно добавить в конец т один дополнительный байт. Этот байт не является частью сообщения, а лишь гарантирует взаимную простоту. При создании т мы проверяем, является ли оно взаимным простым с п. Если нет, то достаточно прибавить к этому байту 1. Почему RS А работает? Иначе говоря, почему мы верим в его безопасность? Потому что разложение на множители - трудная задача, а значит, вычислить ф практически невозможно. Быть может, если в будущем удастся построить квантовые компьютеры, можно будет параллельно запускать экспоненциально большое количество проверок делителей, и тогда разложение на множители станет решаемой задачей. Ну а пока мы можем полагаться на RSA во многих коммуникационных приложениях. Проект Упражнение 13.3. Реализуйте библиотеку для генерации ключей RSA. Упражнение 13.4. Реализуйте функцию шифрования и дешифрирования сообщения в RSA, которая принимает в качестве аргументов строку и ключ. Подсказки: О если ваш язык не поддерживает работу с целыми числами произвольной точности, то необходимо будет установить дополнительный пакет; О вспомните, что два числа называются взаимно простыми, если их НОД равен 1. Это пригодится на одном из шагов генерации ключей; О вам понадобятся результаты из главы 12, конкретно функции extended_gcd и multiplicative_inverse. Напомним, что функция extendedjgcd возвращает пару (х, у) - такую, что ах+пу = = gcd(tf, n). Эту функцию можно использовать для проверки взаимной простоты. Она также вызывается из функции multiplicative_inverse, которая возвращает обратный к а элемент относительно умножения по модулю п, если он существует, или 0 в противном случае. В отличие от функции multiplicative_inverse_fermat из главы 13, эта функция работает для любого п, а не только простого: template <Integer I> I multiplicative_inverse(I a, I n) { std::pair<I, I> p = extended_gcd(a, n); if (p.second != 1(1)) return 1(0); if (p.first < 1(0)) return p.first + n; return p.first; }
Заключительные мысли ♦ 225 Эта функция понадобится для получения закрытого ключа по открытому. 13.5. Заключительные мысли Вопросы аутентификации, конфиденциальности и безопасности приобретают все большую важность по мере того, как наши личные данные перебираются в сеть и личное общение ведется через Интернет. Как мы видели, многие протоколы обеспечения конфиденциальности и защиты данных от несанкционированного манипулирования опираются на RSA или аналогичную криптосистему с открытым ключом для аутентификации, обмена ключами шифрования и других функций безопасности. Все эти столь необходимые на практике средства своим существованием обязаны результатам, полученным в одной из самых, казалось бы, оторванных от реальности отраслей математики, теории чисел. Среди программистов бытует представление о математиках как о людях, ничего не знающих и не интересующихся практическими проблемами, а о математике, особенно о ее наиболее абстрактных разделах, - как о науке, не имеющей практической ценности. Но история показывает, что оба эти представления не соответствуют истине. Величайшие математики с энтузиазмом работали над самыми что ни на есть практическими задачами - например, Гаусс трудился над одним из первых электромеханических телеграфных устройств, а Пуанкаре потратил годы на разработку часовых поясов. Но еще важнее то, что заранее невозможно сказать, какие теоретические идеи принесут практические плоды.
Глава 1*т Заключение Самые сильные аргументы ничего не доказывают, если выводы из них не подтверждены опытом. Роджер Бэкон. «Третье сочинение» Мы начали эту книгу с характеристики обобщенного программирования как такого отношения к программированию, при котором основное внимание уделяется абстрагированию алгоритмов таким образом, чтобы они могли работать в самой общей ситуации без потери эффективности. По ходу изложения мы видели немало примеров подобного абстрагирования в математике и программировании. Мы видели, как старания математиков найти самую общую ситуацию, к которой применим алгоритм Евклида, привели к развитию общей алгебры, целой отрасли математики, посвященной абстрактным структурам и ставшей затем основой обобщенного программирования. Мы также видели, как применение тех же принципов абстрагирования к обобщению древнего алгоритма умножения положительных чисел позволило разработать быструю функцию возведения в степень на полугруппах, а это, в свою очередь, нашло применение в целом ряде приложений от вычисления чисел Фибоначчи до поиска кратчайшего пути в графе и шифрования данных в коммуникационных протоколах Интернета. Этот процесс - начать с конкретного эффективного решения и по возможности ослаблять требования - и составляет самую суть обобщенного программирования. Идея абстрагирования пришла в обобщенное программирование непосредственно из общей алгебры, но нам, программистам, небезразлична также и эффективность. Обобщенный алгоритм, который работает медленнее аналога, специализированного для конкретного типа, никто не станет использовать. Поэтому эффективность также входит в определение обобщенного программирования. В этой книге мы рассказали о различных приемах повышения эффективности: переписывание кода с целью снижения стоимости операций, применение алгоритмов, адаптирующихся к объему памяти, использование диспетчеризации по типу на этапе компиляции, позволяющее компилятору выбрать наиболее эффективную в данной ситуации реализацию. В общем и целом, мы убедились, что стремление к поиску обобщенных вариантов алгоритма часто приводит к более простым и эффективным решениям. Мы также видели, что правильность программного интерфейса может оказаться не менее важной, чем правильность самой программы. Если интерфейс удачен, то его можно использовать для более широкого спектра приложений. К тому же иногда это еще и выгодно с точки зрения эффективности - например, благодаря
Заключение ♦ 227 возврату результатов всех относящихся к делу вычислений (закон полезного возврата), чтобы не повторять одни и те же действия снова и снова. Напротив, плохо спроектированный интерфейс ограничивает возможности приложения, превращая его в калеку. Например, мы видели, что если функция find возвращает только булево значение, а не позицию найденного элемента, то с ее помощью невозможно узнать, существует ли еще один подходящий элемент. И точно так же, как мы многократно переделываем программу, чтобы заставить ее работать правильно, так и интерфейс следует подвергать переосмыслению; правильное решение обычно приходит только после того, как алгоритм уже реализован и апробирован в различных ситуациях. Еще одна важная для понимания обобщенного программирования идея - различие между типом и концепцией. Как аксиомы в математической теории - это требования, описывающие, что значит быть некоей математической сущностью (например, группой), так концепции в программировании - это требования к типам; они описывают, что значит быть определенной компьютерной сущностью. Выбор подходящих для алгоритма или структуры данных концепций - неотъемлемая часть хорошего программирования. Если концепция содержит слишком много требований, то мы без необходимости ограничиваем круг ситуаций, в которых можно использовать алгоритм. Если требований слишком мало, то невозможно определить алгоритм, который будет делать что-то полезное. В следующий раз, когда вы сядете писать программу, попробуйте взглянуть на нее обобщенно. Начните с конкретной реализации функций, а затем критически проанализируйте и улучшите их, сделав более общими и эффективными. По мере уточнения кода не упускайте из виду вопрос о том, как стыкуются его части и как определить интерфейс, который можно было бы использовать в будущем. Выбирайте концепции, которые точно отражают требования к данным и не делают избыточных предположений. И помните, что вы - наследник долгой математической традиции алгоритмического мышления. Придерживаясь принципов обобщенного программирования, вы пожинаете чп л оды трудов тех, кто прошел по этой дороге раньше вас - от Евклида до Стевина и Нётер. Проектируя красивые, общие алгоритмы, вы вносите свой посильный вклад в их труд.
Дополнительная литература Читателям, желающим узнать больше о вопросах, обсуждавшихся в этой книге, могут быть полезны упоминаемые ниже книги. Полные ссылки включены в раздел «Библиография». Глава 1 Обобщенное программирование. Язык Tecton, в котором впервые использовались идеи обобщенного программирования, описан в работе Kapur, Musser, Stepanov «Tecton: A Language for Manipulating Generic Objects» (1981). Библиотека на языке Ada описана в статье Musser, Stepanov «Generic Programming» (1988), а библиотека C++ STL - в статье Stepanov, Lee «The Standard Template Library» (1994). Все эти работы имеются на сайте www.stepanovpapers.com. Глава 2 История математики. Замечательное, полное изложение, относящееся не только к этой главе, но и ко всем экскурсам в историю математики, встречающимся в этой книге, можно найти в книге Katz «A History of Mathematics: An Introduction» (2009). В этом учебнике скрупулезность и математическая строгость сочетаются с доступностью неискушенному читателю. В книге Джона Стиллвелла «Математика и ее история» (Москва-Ижевск, Институт компьютерных исследований, 2004) с достойной восхищения проницательностью описывается историческое развитие некоторых важнейших математических идей. Папирус Ринда. Репродукцию и перевод папируса Ринда можно найти в работе Robins, Shute «The Rhind Mathematical Papyrus: An Ancient Egyptian Text by» (1987). Ван дер Варден включил обсуждение папируса Ринда в свою книгу «Geometry and Algebra in Ancient Civilizations» (1983). Глава 3 Египетская и греческая математика. Помимо книги Катца, есть еще два великолепных источника: Ван дер Варден «Пробуждающаяся наука» (1959) и двухтомник Thomas Heath «History of Greek Mathematics» (первое издание вышло в 1921 году, но в 1981 было перепечатано). Обе работы вполне доступны читателю-непрофессионалу Фигурные числа. Самое лучшее введение в пифагорейскую арифметику - книга Никомаха из Герасы, включенная в десятый том изданного Британской энциклопедией многотомника «Great Books of the Western World» под редакцией Мортимера Адлера. В этот том вошли также полные собрания работ Евклида и Архимеда. Основы теории чисел. Хорошее введение в основы теории чисел можно найти в третьей главе книги George Chrystal «Algebra: An Elementary Text-Book».
Дополнительная литература ♦ 229 Глава 4 Наибольшая общая мера. Лучшим источником по общей истории греческой математики, включая и темы, рассмотренные в этой главе, является уже упоминавшаяся книга Heath «A History of Greek Mathematics». Завораживающий и математически строгий рассказ об исследованиях в Академии Платона, в том числе о наибольшей общей мере, см. в книге David Fowler «The Mathematics of Plato's Academy, a New Reconstruction». Для интересующихся оригиналами полное собрание сочинений Платона в четырех томах под редакцией А. Ф. Лосева, В. Ф. Асмуса, А. А. Тахо-Годи вышло в издательстве «Мысль» в 1990-1994 гг. Хорошее объяснение НОД можно найти в третьей главе книги George Chrystal «Algebra: An Elementary Text-Book». Закат греческой пауки. Интересный рассказ о подъеме и закате греческой математики есть в книге Lucio Russo «The Forgotten Revolution: How Science Was Born in 300 ВС and Why It Had to Be Reborn». История нуля. Наш рассказ об истории нуля в основном взят из книги Ван дер Вардена «Пробуждающаяся наука». Леонардо Пизанский (Фибоначчи). Краткую автобиографию Леонардо Пи- занского перевел Ричард Гримм. Его замечательная работа «Liber Abaci» имеется в английском переводе Лоренса Сиглера. Краткое, но обстоятельное описание трактата Леонардо Пизанского по теории чисел имеется в статье McClenon «Leonardo of Pisa and His Liber Quadratorum» (1919). Читателям, интересующимся историей алгоритмов в арифметике, может быть небезынтересен оригинальный труд Леонардо Пизанского «Liber Quadratorum» в переводе на современный английский язык Л. Э. Сиглера. Частное и остаток. Полное изложение обобщения алгоритма вычисления НОД, возвращающего частное и остаток, см. в главе 5 книги Stepanov, Mcjones «Elements of Programming» (2009). Алгоритм нахождения остатка Флойда и Кнута приведен в их совместной статье «Addition Machines» (1990). Глава 5 Работы Ферма и Эйлера по теории чисел. Большая часть материалов этой главы взята из книги Andre Weil «Number Theory: An Approach through History from Hammurapi to Legendre». Хотя эта книга не предполагает обширных математических знаний, случайному читателю она, пожалуй, может показаться слишком детальной. Классические сочинения по теории чисел Гаусса (Арифметические исследования) и Дирихле (Лекции по теории чисел) все еще представляют значительную ценность, но интересны только серьезным ученым. Книги Эйлера. В нашей биографии Эйлера упомянуты также его основополагающие работы по математическому анализу. Не будучи напрямую связаны с темой этой книги, они тем не менее заслуживают внимательного прочтения. Первая книга Эйлера на эту тему «Введение в анализ бесконечных» доступна на русском языке, переведены на русский также «Дифференциальное исчисление» и «Интегральное исчисление». Есть в русском переводе и «Письма к немецкой принцессе».
230 ♦ Дополнительная литература Глава 6 Теория групп. Классическая книга по теории групп - Burnside «Theory of Groups of Finite Order». Впервые изданная в 1897 году, она все еще является непревзойденным введением в предмет теории групп и включает больше примеров, чем большинство современных учебников. В 2004 году она была переиздана Dover Press. Теория моделей. Как это ни печально, нам неизвестно введение в теорию моделей, доступное неискушенному читателю. Читателям с более основательным уровнем подготовки можно порекомендовать статью Н. Jerome Keisler «Fundamentals of Model Theory» в сборнике «Handbook of Mathematical Logic». Глава 7 Требования к типам. Многие затронутые в этой главе вопросы более формально обсуждаются в книге Stepanov, Mcjones «Elements of Programming». Редукция. Тема редукции обсуждается в статье Iverson «Notation as a Tool of Thought» (1980). Эта идея рассматривается также в работе Backus «Can Programming Be Liberated from the Von Neumann Style?» (1978). Применение редукции в параллельных вычислениях обсуждалось в работе Kapur, Musser, Stepanov «Operators and Algebraic Structures» (1981). Дин описывает редукцию в статье «MapReduce: Simplified Data Processing on Large Clusters» (2004). Глава 8 Симон Стевин. Несмотря на огромный вклад Стевина в математику и естественные науки, написано о нем очень мало. Неплохой обзор имеется в статье Sarton «Simon Stevin of Bruges». Деление и НОД полиномов. Желающим вспомнить о делении и НОД полиномов рекомендуем главы 5 и 6 книги Chrystal «Algebra». Истоки общей алгебры. Хорошее ведение втауссовы целые числа имеется в главе 6 книги Stillwell «Elements of Number Theory». Классическое сочинение, содержавшее введение в общее понятие алгебраических целых чисел, - книга Richard Dedekind «Theory of Algebraic Integers». В перевод Стилвелла включено великолепное введение, где приводится объяснение многих идей. Книга Leo Corry «Modern Algebra and the Rise of Mathematical Structures» содержит исчерпывающее систематическое рассмотрение развития общей алгебры от Дедекинда до Нё- тер и более поздние достижения. Общая алгебра. Читателям, желающим пойти дальше в понимании общей алгебры, рекомендуем серьезную, но все же доступную (и содержащую исторические отступления) книгу Stillwell «Elements of Algebra». Кольца. В книге Stillwell «Elements of Number Theory» эти вопросы рассматриваются обстоятельно и вместе с тем доступно. (Хотя название предмета наводит на мысль, что он должен рассматриваться в книге Stillwell «Elements of Algebra», на самом деле она посвящена теории Галуа, и потому кольца в ней не затрагиваются.)
Дополнительная литература ♦ 231 Глава 9 Общественная природа доказательства. Идея о том, что доказательство - общественный процесс, обсуждается в работе De Millo, Lipton, Perlis «Social Processes and Proofs of Theorems and Programs (1979). Евклид. Выполненный в 1950 году А. Д. Мордухай-Болтовским перевод «Начал» Евклида широко известен. В это издание включены обширнейшие комментарии переводчика. Имеется также новая репродукция уникального издания Оливера Бирна 1847 года, в котором все доказательства наглядно проиллюстрированы. Книга Robin Hartshorne «Geometry: Euclid and Beyond» рассчитана на людей с университетским математическим образованием, но первая глава (описывающая геометрию Евклида) вполне доступна. Аксиомы геометрии. Главы 1 и 2 книги Hartshorne «Geometry: Euclid and Beyond» содержат хорошее введение в аксиоматический метод Евклида и современный вариант аксиом Евклида, предложенный Гильбертом. «Основания геометрии» Гильберта по-прежнему считаются самым авторитетным сочинением на тему его аксиоматики геометрии. Неевклидова геометрия. Классический труд на эту тему - книга Roberto Bonola «Non-Euclidean Geometry: A Critical and Historical Study of Its Development». Современная (но все же доступная) математическая трактовка содержится в главе 7 «Геометрии» Хартсхорна. Арифметика Пеано. Читателям, интересующимся тем, как можно строго построить арифметику, начиная с самых основ, рекомендуем книгу Эдмунда Ландау «Основы анализа», в которой описывается построение целых, рациональных, вещественных и комплексных чисел, исходя из аксиом Пеано. Главный труд Пеано «Formulario Mathematico» охватывал многие области практической математики, а не только ставшие знаменитыми аксиомы. К сожалению, эта великая книга так и не была переведена с изобретенного автором языка. Подробнее о работе Пеано см. Hubert Kennedy «Twelve Articles on Giuseppe Peano». Глава 10 Аристотелева организация знаний. Хорошее введение в биографию и философию Аристотеля имеется в книге В. П. Зубова «Аристотель: Человек. Наука. Судьба наследия». На русском языке собрание сочинений Аристотеля в четырех томах выходило в издательстве «Мысль». Читатели, которых интересует только обсуждавшаяся в книге работа «Категории», могут найти ее в соответствующем томе. Концепции. В главе 1 книги Stepanov, Mcjones «Elements of Programming» эта тема обсуждается более формально и подробно. Итераторы и поиск. В главе 6 вышеупомянутой книги эта тема обсуждается более формально и подробно. Глава 11 Перестановки и транспозиции. Хорошее введение в теорию перестановок имеется в главе XXIII книги Chrystal «Algebra». В главе 1 книги Burnside «Theory of
232 ♦ Дополнительная литература Groups of Finite Order», уже упоминавшейся в списке литературы к главе 6, эта тема рассматривается более подробно. Циклические перестановки и обращение. Рассмотренные в этой главе алгоритмы более детально описываются в главе 10 книги «Elements of Programming». Глава 12 Алгоритм Штайна. Оригинальная работа Штайна о более быстром алгоритме нахождения НОД опубликована в статье «Computational Problems Associated with Racah Algebra». Кнут описывает этот алгоритм в разделе 4.5.2 тома 2 «Искусства программирования». Занимательная математика. Источником многих математических открытий стало изучение на первый взгляд легкомысленных задач, а многие знаменитые математики впервые проявили интерес к этой науке из-за пристрастия к математическим играм. Классическая книга на эту тему - У. Болл «Математические эссе и развлечения». Глава 13 Криптография. История криптографии изложена в увлекательной форме в книге David Kahn «The Codebreakers: The Comprehensive History of Secret Communication from Ancient Times to the Internet». Для желающих больше узнать о методах современной криптографии есть стандартный учебник Katz, Lindell «Introduction to Modern Cryptography: Principles and Protocols». Теория чисел. Хорошее современное введение в теорию чисел, включающее обсуждение алгоритма RSA, - книга John Stillwell «Elements of Number Theory» (2003). В ней также содержится материал, который мы рассматривали в главах 5 и 8. Алгоритм проверки простоты AKS. Детерминированный алгоритм проверки простоты с полиномиальным временем работы описан в статье Granville «It Is Easy to Determine Whether a Given Integer Is Prime» (2005).
Приложение Обозначения Ниже перечислены используемые в книге символы, возможно, незнакомые читателю без математической подготовки и не объясняемые в основном тексте (остальные символы объясняются по мере появления). Сначала мы перечислим сами символы, а затем приведем примеры их использования. Логическое отрицание. Читается «нер». Еслир истинно, то _ip ложно. И наоборот. р V q Логическая дизъюнкция. Читается «р или д». Высказывание р v q истинно, если истинно либор, либо q, либо оба высказывания одновременно. p/\q Логическая конъюнкция. Читается «р и q». Высказывание р A q истинно, если истинны одновременно р и q. p^q Логическая импликация. Читается «р влечет q» или «еслир, то q». Отметим, что высказывание р => q ложно, только если р истинно, a q ложно. Интуитивно не очевидно, что это высказывание истинно, когда р ложно, но можно дать такое объяснение «можно доказать все, что угодно, если начать с заведомо неверной посылки». Дополнительные сведения о логической импликации см. в разделе «Импликация и контрапозиция» в конце приложения. p<^q Логическая эквиваленция. Читается «р тогда и только тогда, когда q». Высказывание р <=> q истинно, когда р и q одновременно истинны или ложны. Это в точности то же самое, что (р => q) A (q => р). xeS Принадлежность множеству. Читается «х является элементом S» или «х принадлежит S». x$S Отрицание принадлежности множеству. Читается «х не является элементом S» или «х не принадлежит 5». А
234 ♦ Приложение А VxeS Квантор всеобщности. Читается «для всех х, принадлежащих S» или «для любого х, принадлежащего 5». Иногда принадлежность множеству S предполагается из контекста, и тогда пишут просто Vx. 3xeS Квантор существования. Читается «существует х, принадлежащий 5». Иногда принадлежность множеству S предполагается из контекста, и тогда пишут просто Эх. Su T Объединение множеств. Читается «объединение S и Г». Элемент х принадлежит объединению S и Г, если он принадлежит либо S, либо Г, либо обоим множествам. SnT Пересечение множеств. Читается «пересечение S и Г». Элемента принадлежит пересечению SnT, если он принадлежит одновременно ShT. S-{x\...} Определение множества. Читается «S - множество всех х таких, что ...» (где «...» может быть произвольным списком условий, налагаемых нах). N Множество натуральных чисел 0,1,2,3,...- тех, что применяются для подсчета (некоторые авторы не включают 0 в множество натуральных чисел). Z Множество целых чисел, включающее все натуральные числа и противоположные им. Z п Множество {0,1, 2,..., п - 1} остатков от деления на п. Q Множество \ — \ рациональных чисел (отношений двух целых чисел). R Множество вещественных чисел. С Множество комплексных чисел а + Ы, где аиЬ- вещественные числа, а г2 = -1. Примеры even(x) <=> -,odd(x) «х четно тогда и только тогда, когда х не является нечетным».
Обозначения ♦ 235 S = {х | х G Z Л even(x)} «5 - множество всех х таких, что х принадлежит множеству целых чисел и х четно», или короче: «S - множество всех четных целых чисел». \/х Зу : у = х + 1 «Для любого х существует у такой, что у равно х плюс 1». х G {S п Т} => х е {S U Т} «Если х принадлежит пересечению S и Г, то х принадлежит объединению S и Г». Импликация и контрапозиция Импликация р => q (которую называют также условным высказыванием) логически эквивалентна своей контрапозиции, то есть высказыванию вида: -q =» -p. Рассмотрим такой пример: Если и = 2, топ четно. Здесь суждение р - это «гс = 2», а суждение g - «п четно»; это условное высказывание истинно. Чтобы образовать контрапозицию, мы формируем логические отрицания обеих частей и меняем направление импликации на противоположное. Таким образом, контрапозиция предыдущего высказывания имеет вид: Если п не четно, топФ 2, что также истинно. Поскольку условное высказывание и его контрапозиция логически эквивалентны, то иногда мы заменяем одно на другое - если вторая форма чем-то удобнее. Не путайте контрапозицию импликации р => q с ее обращением - импликацией q => р. Из того, что импликация истинна, вовсе не следует, что истинно и ее обращение, эти два высказывания независимы. Продолжая предыдущий пример, отметим, что хотя исходное высказывание истинно, обратное к нему Еслипчетно, топ =2, очевидно, ложно.
Приложение ш^шшшшФШщштФШФФФФФттФФФФФФФФФФФттттФФтФШФФФтшФФ Стандартные приемы доказательства Существует несколько стандартных приемов доказательства, которые часто встречаются в математике и в информатике, в том числе на страницах этой книги. Если вы не всегда понимаете приведенные в тексте доказательства, имеет смысл прочитать этот раздел. В.1. Доказательство от противного Многие нуждающиеся в доказательстве утверждения имеют вид «если р, то д» (иногда пишут также «р => q»), где р и q - два суждения. Мы всегда начинаем с допущения, чтор истинно, иначе мы решали бы другую задачу Идея доказательства от противного состоит в том, чтобы допустить противоположное тому, что утверждается в исходном высказывании (то есть предположить, что q не истинно), а затем показать, что такое предположение приводит к логическому противоречию - в частности, к выводу о том, что суждение р должно быть ложно, что, как мы знаем, не так. Это заставляет нас заключить, что суждение q все-таки должно быть истинно, к чему мы с самого начала и стремились. Рассмотрим пример. Предположим, что требуется доказать истинность следующего утверждения для всех целых п: Если п2 нечетно, то п нечетно. Здесь «п2 нечетно» - наше суждение р, а «п нечетно» - суждение q. Предположим противное: пусть п не является нечетным, то есть п четно. Что значит, что целое число п четное? Это значит, что его можно записать в виде двойки, умноженной на другое целое число т: п = 2т. Что получится, если возвести п в квадрат? п2 = 2 - 2 - т2. Введем новую переменную х, положив х = 2т2. Тогда можно сделать подстановку: п2 = 2 - 2/tz2 = 2х. в
Стандартные приемы доказательства ♦ 237 Как видим, п2 можно записать как 2, умноженное на целое число. Но это определение четного числа, а мы предположили, что п2 нечетно. Однако же п2 не может быть одновременно четным и нечетным - мы пришли к логическому противоречию. Поэтому исходное предположение о том, что п четно, было неверным; п обязано быть нечетным, и, значит, мы доказали то, что требовалось. В.2. Доказательство по индукции Иногда требуется доказать утверждение, включающее бесконечное множество случаев. Очевидно, перебрать их все невозможно, но можно воспользоваться математической индукцией. Чтобы доказать некое утверждение по индукции, нужно сделать две вещи: 1) доказать, что это утверждение истинно для первого элемента множества. Это называется базой индукции] 2) доказать, что если утверждение истинно для произвольного элемента множества {предположение индукции), то оно истинно и для следующего элемента. Это называется шагом индукции. Пусть, например, нужно доказать, что для любого целого положительного числа п: 1 + 2 + 3 + ... + я = — -. 2 База: Верно ли это равенство при п = 1? Иначе говоря, верно ли что 1=i-(i+i)? 2 Простейшие арифметические действия показывают, что это так. Шаг индукции: Предположим, что утверждение верно при п = k. Будет ли оно тогда верно и для k + 1? Тот факт, что утверждение верно при п = k (то есть наше предположение индукции), означает следующее: , . k(k + l) 1 + 2 + 3 + ... + « = — -. 2 Прибавим к обеим частям k + 1: 1 + 2 + 3 + ... + £ + (£ + 1)=^ + 1) + (£ + 1) _k(k + l) 2(6 + 1) 2 + 2 _(k + \)(k + 2) 2 = (k + !)((& + !) + !) 2
238 ♦ Приложение В А это и есть —^ 1 при п = k + 1. Итак, мы доказали, что если формула верна для k, то она верна и для k + 1. Поскольку продемонстрирована истинность базы и шага индукции, то исходное утверждение доказано. В.З. Принцип Дирихле Принцип Дирихле (иногда его называют принципом «клеток и кроликов») очень прост: есть имеется п клеток и более п кроликов, то по крайней мере в одной клетке должно сидеть больше одного кролика. В жизни этот принцип встречается сплошь и рядом. Так, если имеется 367 людей, то хотя бы двое из них родились в один день. Но эта идея оказывается полезной и в математических доказательствах. Часто, когда требуется доказать, что два объекта должны быть одинаковы, принцип Дирихле приходит на помощь. Рассмотрим пример: Доказать, что любое множество 10 положительных целых чисел, меньших 100, обязательно содержит два подмножества с одинаковой суммой. Подумаем, сколько всего возможно сумм. Сумма любых 10 положительных чисел, меньших 100, не может быть меньше 10 или больше 990, то есть она попадает в интервал [10, 990]. Этот интервал содержит 990 - 10 = 980 значений, это и есть максимальное возможное количество сумм1. Далее посчитаем, сколько существует подмножеств этого множества 10 целых чисел. Каждое подмножество можно представить двоичным числом, в котором i-й бит равен 1, если i-e целое число входит в подмножество, и 0 в противном случае. Всего в множестве 10 элементов, и каждому элементу сопоставлен один бит, то есть всего существует 210 = 1024 подмножества. Поскольку есть только 980 сумм и 1024 возможных подмножества, то, согласно принципу Дирихле, хотя бы у двух подмножеств будет одинаковая сумма. На самом деле в задаче говорится о множестве 10 целых чисел, а в множестве не может быть повторяющихся элементов. Поэтому реальное количество возможных сумм меньше 980, но результат от этого не меняется.
Приложение ФШШФФФшшФФФшшФФФШШФФФШФФФФФШФФттшФФшшФш%ФФтФФШш Язык C++ для программистов на других языках Как правило, в этой книге используется подмножество C++, которое должно быть понятно большинству программистов, пишущих на таких языках, как С или Java. Однако кое-где все же встречаются важные особенности и идиомы C++, которые нам необходимы. Они и перечислены в этом приложении. Если специально не оговорено, то используются только средства C++, описанные в стандарте языка 1998 года. Хорошее краткое описание стандарта С++11 см. в книге «A Tour of C++» Бьярна Страуструпа. Полное справочное руководство имеется в книге Страуструпа «Язык программирования C++». С.1. Шаблонные функции Поддержка парадигмы обобщенного программирования в C++ основана, в частности, на шаблонных функциях. Допустим, что имеется такая функция: int my_function(int x) { int у; ... делает что-то сложное ... return у; } Теперь мы хотим выполнить те же самые вычисления, но принимать и возвращать число с плавающей точкой двойной точности. C++ позволяет перегрузить имя функции, то есть написать новую функцию с таким же именем, но умеющую работать с другими типами: double my_function(double x) { double у; ... делает что-то сложное ... return у; } С
240 ♦ Приложение С Но если ничего, кроме типа, не изменилось, то писать отдельную функцию - бессмысленная трата времени. Решить эту проблему помогают шаблоны. С их помощью можно написать одну функцию, которая будет работать с любым типом, удовлетворяющим синтаксическим и семантическим требованиям программы, а именно: template <typename T> Т my_function(T x) { Т у; ... делает что-то сложное ... return у; } Теперь у нас имеется функция, принимающая и возвращающая данные типа Т, где Т зависит от того, как функция вызывается. С одной стороны, если написать int x(l); int у = my_function(x) ; то при вызове my_function () вместо Т будет подставлен тип int. С другой стороны, если написать double x(1.0); double у = my_function (х); то my_function () будет вызвана с типом double в качестве Т. Подобные подстановки производятся на этапе компиляции, так что использование шаблонов не влечет снижения производительности. С.2. Концепции Концепции - существенная часть обобщенного программирования, мы сравнительно подробно говорили о них в разделе 10.3. Написанное ниже следует рассматривать как краткий справочник. В идеале мы хотели бы как-то сообщить программисту о требованиях к аргументу шаблона. Например, хорошо бы иметь возможность написать template <Number N> понимая под этим, что любой тип, с которым вызывается эта функция, обязан представлять число. То есть код готов работать с объектами типа int, double или uint64_t, но не со строками. Ограничение вида «Number» - пример концепции. К сожалению, в настоящее время C++ не поддерживает концепции на уровне языка, то есть компилятор не может гарантировать выполнение требований к типу. Несмотря на это ограничение, мы в примерах кода делаем вид, что концепции в языке присутствуют. Реализовать это можно, просто определив концепции как синонимы typename: #define Number typename
Язык C++ для программистов на других языках ♦ 241 Это означает, что с точки зрения компилятора запись template <Number N> в точности эквивалентна template <typename N> но программист-человек поймет, какое ограничение мы накладываем. С.З. Синтаксис объявлений и типизированные константы C++ предлагает несколько способов объявления и инициализации переменных. Хотя традиционный синтаксис С int х = у; вполне допустим, в C++ такое предложение все же принято записывать следующим образом: int x(y); Подобный способ лучше согласуется с синтаксисом конструирования произвольных объектов в C++. (Примечание: в современных версиях C++ поддерживается еще один способ инициализации: int x{y); Однако он еще недостаточно широко распространен, поэтому мы его в примерах не используем.) При использовании числовых констант нужно очень внимательно следить за типами. Например, в традиционной программе на С может встретиться такая строка: if (something) return 0; Это небрежность. Какого типа 0 возвращает эта функция? По умолчанию int, но что, если программа должна возвращать целое специального типа, например 16-разрядное без знака, или типа, заданного в шаблоне? Вместо того чтобы полагаться на неявное преобразование типа, лучше явно указать, что мы хотим вернуть: if (something) return uintl6_t(0); или, если речь идет об аргументе шаблона: if (something) return T(0); где Т - тип, которым параметризован шаблон. С.4. Объекты-функиии Часто требуется, чтобы функция могла выполнять какую-то инициализацию и запоминать состояние. В C++ это обычно делается с помощью объектов-функций,
242 ♦ Приложение С или функторов..Объектом-функцией называется объект, который инкапсулирует одну (безымянную) функцию. Рассмотрим простой пример - объект-функцию для конвертации валют: struct converter { double exchange__rate; converter(double ex) : exchange_rate(ex) {} double operator()(double amt) { return amt * exchange_rate; } }; Обратите внимание на синтаксическую конструкцию operator () для объявления безымянной функции, принадлежащей объекту. Чтобы воспользоваться этой функцией, мы сначала сконструируем экземпляр класса converter (при этом инициализируется обменный курс). В данном случае мы хотим конвертировать евро в доллары США, поэтому назовем наш экземпляр eurtousd. Теперь можно с помощью экземпляра вызвать функцию: int main () { converter eur_to_usd(l.3043); double euros; do { std::cout « "Введите суммы в евро: "; std::cin >> euros; std::cout « euros « " евро равно " « eur_to_usd(euros) « I! долларам " « std::endl; } while (euros > 0.0); } Преимущество объектов-функций состоит в том, что их можно передавать функциям в качестве аргументов. (В C++ не разрешается передавать функции напрямую, можно передать только указатель на функцию, а это влечет за собой дополнительные накладные расходы на косвенный вызов функции.) Кроме того, в объектах-функциях можно запоминать информацию. С.5. Предусловия, постусловия и утверждения Получив допустимые аргументы, функция выполняет какие-то вычисления. По-другому это можно выразить, сказав, что если удовлетворяются некоторые предусловия, то будут удовлетворяться и некоторые постусловия. Иногда мы записываем предусловия и постусловия прямо в коде в виде комментариев, например:
Язык C++ для программистов на других языках ♦ 243 // precondition: у != 0.0 double my_ratio(double x, double у) { return x / у; } // postcondition: возвращенное значение равно х/у Однако библиотека предоставляет механизм утверждений assert для проверки условий. Можно было бы написать и так: double my_ratio(double x, double у) { assert(у != 0.0); return x / у; } Если выражение, переданное assert, истинно, то ничего не происходит. Но если его вычисление дает false, то выполнение программы прекращается, и печатается сообщение об ошибке. В производственном коде утверждения обычно отключают во избежание накладных расходов и снижения производительности. СБ. Алгоритмы и структуры данных STL Вместе с языком C++ поставляется библиотека стандартных программных компонентов Standard Template Library (STL). Она включает структуры данных, алгоритмы и прочие полезные программистам средства. Все компоненты STL находятся в пространстве имен std; мы используем префикс std:: явно, если хотим сослаться на тот или иной компонент из программы. STL - обобщенная библиотека, то есть каждый компонент можно использовать с любым типом. В случае структур данных типы задаются в виде аргументов шаблона при объявлении объекта. Например, в предложении std::vector<int> v; объявляется вектор целых чисел, а в предложении std::vector<bool> v; вектор булевых величин. В этой книге используются следующие компоненты STL: Объекты-функции для выполнения арифметических операций и сравнений (о том, что такое объект-функция, см. раздел С.4): О std: :plus - вычисляет сумму двух аргументов своего оператора; О std::multiplies - вычисляет произведение двух аргументов своего оператора; О std:: negate - вычисляет величину, противоположную аргументу своего оператора; О std:: less - возвращает true, если первый аргумент оператора меньше второго, и false в противном случае; О std:: less_equal - возвращает true, если первый аргумент оператора меньше или равен второму, и false в противном случае.
244 ♦> Приложение С Структуры данных: О std: :pair - структура для хранения двух произвольных объектов; обычно используется для возврата двух значений из функции; О std: -.vector - контейнер для хранения последовательности элементов одного типа, поддерживающий произвольный доступ за постоянное время. Алгоритмы: О std::fill - заполняет диапазон, заданный первыми двумя аргументами, значением, заданным третьим аргументом; О std:: swap - обменивает содержимое своих аргументов; О std: :partition_point - возвращает итератор, указывающий на первый элемент в уже разбитом на части диапазоне (заданном первыми двумя аргумента), для которого заданный в третьем аргументе предикат не равен true. См. обсуждение в разделе 10.8. Прочие средства: О std:: advance - смещает позицию итератора (первый аргумент) вперед на заданное расстояние (второй аргумента). Подробное описание этих и других компонентов STL см. в части IV книги Стра- уструпа «Язык программирования C++». С.7. Итераторы и диапазоны Итераторы - важная часть обобщенного программирования, их подробному обсуждению посвящен раздел 10.4. Написанное ниже следует рассматривать как краткий справочник. Итераторы - это абстракция указателей; итератор обозначает позицию в последовательности. В примерах мы используем итераторы четырех видов, каждому из которых соответствует свой тег. О Итераторы ввода поддерживают обход в одном направлении, как в однопроходных алгоритмах. Каноническая модель итератора ввода - позиция в потоке ввода. Тег: std::input_iterator_tag О Однонаправленные итераторы поддерживают обход только в одном направлении, но при необходимости обход можно повторить - как в многопроходных алгоритмах. Каноническая модель однонаправленного итератора - позиция в односвязном списке. Тег: std::forward_iterator_tag О Двунаправленные итераторы поддерживают обход в двух направлениях, который при необходимости можно повторить (то есть их также можно использовать в многопроходных алгоритмах). Каноническая модель двунаправленного итератора - позиция в двусвязном списке. Тег: std::bidirectional_iterator_tag О Итераторы с произвольным доступом поддерживают алгоритмы с произвольным доступом, то есть допускают доступ к любому элементу за постоянное время. Каноническая модель - позиция в массиве. Тег: std::random_access_iterator_tag
Язык C++ для программистов на других языках ♦ 245 Теги итераторов - это специальные типы, которые можно использовать в сигнатурах функций, чтобы гарантированно выбирался тот вариант перегруженной функции, который соответствуют заданному итератору; пример см. в главе 11. * * * Функции из библиотеки STL часто принимают два итератора, обозначающие начала и конец диапазона данных. По соглашению, итератор конца данных (обычно он называется last) указывает на позицию, следующую сразу за последним элементом. У итераторов имеются также специальные атрибуты, называемые характеристиками. Мы используем такие характеристики итераторов: О value_type: тип объектов, на которые указывает итератор; О difference_type: целочисленный тип, достаточно широкий для представления количества операций инкремента, необходимого для перехода от одного итератора к другому; О iterator_category: описанный выше тег итератора. Синтаксис доступа к характеристикам итератора для итератора конкретного типа х имеет вид: std::iterator_traits<x>::value_type Дополнительные сведения об итераторах см. в главе 10. С.8. Использование using для псевдонимов типов и функций типов в С++11 В C++11, текущем стандарте C++, есть возможность использовать ключевое слово using для определения псевдонимов типов и других конструкций. Обычно это делается для сокращенной записи длинных и сложных типов. Например: using myptr = long_complicated_class_name*; После этого программист может писать myptr в любом месте программы, где раньше следовало писать long_complicated_class_name*. Пользователи С и предыдущих версий C++, вероятно, знакомы со старым механизмом определения псевдонимов типов с помощью ключевого слова typedef, но механизм на основе using более гибкий. Так, использование using позволяет записать шаблонизированные функции типа для характеристик итераторов. Если написать template <InputIterator I> using IteratorCategory = typename std::iterator_traits<I>::iterator_category; то всякий раз, как нам понадобится узнать категорию итератора, мы можем сказать IteratorCategory<I> вместо std: -.iterator traits<I>: :iterator_category
246 ♦ Приложение С С.9. Списки инициализаторов в С++11 В С и C++ массив инициализируется путем заключения списка значений в фигурные скобки: char my_array[5] = {'a', !e!, fi!, fo!, fu!}; В C++11 этот синтаксис распространен и на другие структуры, помимо массивов, так что теперь допустимы и такие конструкции: std::vector<char> = {'a1, 'е1, Ч1, 'о1, 'и'}/ std::pair<int, double> = {42, 3.1415}; СЛО. Лямбда-фуншии в С++11 В C++И включена поддержка лямбда-функций. Это анонимные функции, которые нужны только один раз, часто в качестве аргументов другой функции. Предположим, что нам для какой-то цели понадобилось взять функцию, которая вычисляет куб своего аргумента, и передать ее другой функции. Традиционно мы решили бы эту задачу с помощью объекта-функции: отдельно объявили бы класс, создали экземпляр этого класса и передали его в качестве параметра: struct cuber { cuber() {}; // конструктор int operator()(int x) { return x * x * x; } }/ int main() { cuber cube/ int a = some_other_function(cube); } Но если функция возведения в куб нужна нам только единожды, то работы получается непропорционально много. Зачем создавать объект-функцию или присваивать функции имя, если мы ей больше никогда не воспользуемся? Вместо этого мы можем написать по месту лямбда-функцию и передать все выражение в качестве аргумента: int main() { int a = some other function ([ = ] (int x) { return x * x * x; })/
Язык C++ для программистов на других языках ♦ 247 Синтаксис лямбда-функций аналогичен синтаксису любой другой функции, только место имени занимает выражение [=], а тип возвращаемого значения обычно задавать не надо - компилятор выведет его самостоятельно. СИ. Замечание о ключевом слове inline В C++ наличие ключевого слова inline перед функцией служит сообщением компилятору о том, что программист хотел бы, чтобы тело функции было подставлено в код в том месте, где она вызывается, избегнув тем самым накладных расходов на вызов обычной функции. На практике многие рассмотренные в этой книге функции выиграли бы от объявления встраиваемыми (inline). Встраивать имеет смысл только относительно небольшие фрагменты кода, иначе размер вызывающей программы может вырасти настолько, что сведется к нулю выигрыш от кэширования кода, возможны и другие проблемы с производительностью. Компиляторы знают об этом и в таких случаях игнорируют запрос на встраивание. В то же время компиляторы становятся настолько «умными», что сами встраивают код, когда это имеет смысл. А это означает, что ключевое слово inline скоро станет неактуальным, поэтому мы не использовали его в примерах.
Библиография Adler, Mortimer J. (Ed.). (1991). Great Books of the Western World, Vol. 10: Euclid, Archimedes, Nicomachus. Chicago: Encyclopaedia Brittanica. Aristotle. (1938). Aiistotle: Categories, On Interpretation, Prior Analytics, Vol. 325. Translated by H. P. Cooke and Hugh Tredennick. Cambridge, MA: Loeb Classical Library. Aristotle. (1984). The Complete Works of Aristotle: The Revised Oxford Translation. Edited by Jonathan Barnes. Princeton, NJ: Princeton University Press. Backus, John. (1978). «Can Programming Be Liberated from the Von Neumann Style?: A Functional Style and Its Algebra of Programs.» Communications of the ACM 21(8), 613-641. Ball, W. W. Rouse, and H. S. M. Coxeter1. ([1922] 2010). Mathematical Recreations and Essays (10th ed.). Reprint, New York: Dover Publications. Original edition published 1892. Bonola, Roberto. ([1955] 2010). Non-Euclidean Geometry: A Critical and Historical Study of Its Development Translated by H. S. Carslaw. Reprint, New York: Dover Publications. Originally published as La Geometria non-Euclidea, 1912. Burnside, William. ([1911] 2004). Theory of Groups of Einite Order (2nd ed.). Reprint, Mineola, NY: Dover Publications. Byrne, Oliver. (2010). The First Six Books of the Elements of Euclid, Taschen. Facsimile of 1847 edition. Chrystal, George. ([1964] 1999). Algebra: An Elementary Text-Book (7th ed.). Reprint, Providence, RI: American Mathematical Society. Original edition published 1886. Cohen, Morris R., and I. E. Drabkin. (1948). A Source Book in Greek Science. Cambridge, MA: Harvard University Press. Corry, Leo. (2004). Modern Algebra and the Rise of Mathematical Structures (2nd revised ed.). Basel, Switzerland: Birkhauser. Dean, Jeffrey, and Sanjay Ghemawat. (2008). «MapReduce: Simplified Data Processing on Large Clusters». Communications of the ACM 51 (\.), 107-113. Dedekind, Richard. (1996). Theory of Algebraic Integers. Translated by John Stillwell. Cambridge, UK: Cambridge University Press. Originally published as Uberdie theorie derganzen algebraicschen Zahlen, 1877. De Millo, Richard A., Richard J. Lipton, and Alan J. Perlis. (1979). «Social Processes and Proofs of Theorems and Programs». Communications of the ACM 22(5), 271-280. Dirichlet, P. G. L. (1999)2. Lectures on Number Theory. Supplements by R. Dedekind. Translated by John Stillwell. Providence, RI: American Mathematical Society. Originally published as Vorlesungen iiber Zahlentheorie, 1863. 1 Болл У, Коксетер Г. Математические эссе и развлечения. Мир, 1986. 2 Дирихле П. Г. Л. Лекции по теории чисел. ЛКИ, 2007.
Библиография ♦ 249 Euclid1. (1956). Euclid: The Thirteen Books of the Elements. Translated by Thomas L. Heath. (2nd ed.). New York: Dover Publications. Euler, Leonhard2. (1988). Introduction to Analysis of the Infinite, Vol. 1 and 2. Translated by John D. Blanton. New York: Springer. Originally published as Introductio in analysin infinitorum, 1748. Euler, Leonhard (2000)3. Foundations of Differential Calculus. Translated by John D. Blanton. New York: Springer. Originally published as Institutiones Calculi Differentiate, 1755. Fibonacci, Leonardo Pisano, and L. E. Sigler (Trans.). (1987). The Book of Squares: An Annotated Translation into Modern English. Boston: Academic Press. Originally published in Latin as Liber Quadratomm, 1225. Floyd, Robert W., and Donald E. Knuth. (1990). «Addition Machines». SIAMJournal on Computing 19(2), 329-340. Fowler, David H. (1987). The Mathematics of Plato's Academy: A New Reconstmction. Oxford, UK: Clarendon Press. Gauss, Carl Friedrich4. (1965). Disquisitiones Arithmeticae. Translated by Arthur A. Clarke, S.J. New Haven, CT: Yale University Press. Original Latin edition, 1801. Granville, Andrew. (2005). «It Is Easy to Determine whether a Given Integer Is Prime». Bulletin of the American Mathematical Society 42(1), 3-38. Gries, David, and Gary Levin. (1980). «Computing Fibonacci Numbers (and Similarly Defined Functions) in Log Time». Infoimation Processing Letters 11(2), 68-69. Grimm, Richard E. (1973). «The Autobiography of Leonardo Pisano». Fibonacci Quarterly 11(1), 99-104. Hartshorne, Robin. (2000). Geometry: Euclid and Beyond. New York: Springer. Heath, Thomas. ([1921] 1981). A History of Greek Mathematics. Reprint, New York: Dover Publications. Hilbert, David5. ([1971] 1999). Foundations of Geometry (10th ed.). Translated by Leo Unger and revised by Paul Bernays. Chicago: Open Court. Originally published as Grundlagen der Geometrie, 1899. Iverson, Kenneth E. (1962). «A Programming Language». In Proceedings of the May 1-3, 1962, Springjoint Computer Conference, AIEE-IRE '62, pp. 345-351. ACM. Iverson, Kenneth E. (1980). «Notation As a Tool of Thought». Communications of the ACM35(1-2), 2-31. Kahn, David. (1996). The Codebreakers: The Comprehensive Histoiy of Secret Communication from Ancient Times to the Internet (Revised ed.). New York: Scribner. 1 Начала Евклида / перевод с греч. и комм. Д. Д. Мордухай-Болтовского при ред. участии И. Н. Веселовского и М. Я. Выгодского. М; Л.: ГТТИ, 1949-1951. 2 Эйлер Л. Введение в анализ бесконечных. ГИФМЛ, 1961. 3 Эйлер Л. Дифференциальное исчисление. Геодезиздат, 1949. 4 Гаусс К. Ф. Труды по теории чисел. Изд-во АН СССР, 1959. 5 Гильберт Д. Основания геометрии. Гостехиздат, 1948.
250 ♦ Библиография Kapur, D., D. R. Musser, and A. A. Stepanov. (1981a). «Operators and Algebraic Structures». In Proceedings of the 1981 Conference on Functional Programming Languages and Computer Architecture, FPCA '81, New York, NY, pp. 59-64. ACM. Kapur, D., D. R. Musser, and A. A. Stepanov. (1981b). «Tecton: A Language for Manipulating Generic Objects». In Program Specification, Proceedings of a Workshop, pp. 402-414. Springer-Verlag. Katz, Jonathan, and Yehuda Lindell. (2008). Introduction to Modern Cryptography, Boca Raton, FL: CRC Press. Katz, Victor J. (2009). A History of Mathematics: An Introduction (3rd ed.). Boston: Addison-Wesley Keisler, H.Jerome. (1989). «Fundamentals of Model Theory». In J. Barwise (Ed.), Handbook of Mathematical Logic, North Holland. Kennedy, Hubert. (2002). Twelve Articles on Giuseppe Peano, San Francisco: Peremptory Publications. Knuth, Donald E. (2007)1. The Art of Computer Programming, Vol. 2: Seminumerical Algorithms. Boston: Addison-Wesley. Landau, Edmund. ([1966] 2001)2. Foundations of Analysis (3rd ed.). Translated by F. Steinhardt. Reprint, Providence, RI: Chelsea. McClenon, R. B. (1919). «Leonardo of Pisa and His Liber Quadratorum». The American Mathematical Monthly 26(1), 1-8. Musser, David R., and Alexander A. Stepanov (1988). «Generic Programming». In Proceedings of the International Symposium ISSAC88 on Symbolic and Algebraic Computation, pp. 13-25. Springer-Verlag. Peano, Giuseppe. (1960). Formulario Mathematico, Edizioni Cremonense. Original edition published 1908. Plato. (1997). Plato: Complete Works, Edited by J. M. Cooper and D. S. Hutchinson. Indianapolis, IN: Hackett Publishing. Robins, Gay, and Charles Shute. (1987). The RhindMathematical Papyrus: An Ancient Egyptian Text, London: British Museum Publications. Ross, David. (2004). Aristotle (6th ed.). London: Routledge. Russo, Lucio. (2004). The Forgotten Revolution: How Science Was Born in 300 ВС and Why It Had to Be Reborn, Translated by Silvio Levy. New York: Springer. Originally published as La rivoluzione dimenticata, 1996. Sarton, George. (1934). «Simon Stevin of Bruges». Isis21{2), 241-303. Sigler, Laurence. (1987). Fibonacci's Liber Abaci: A Translation into Modern English of Leonardo Pisano's Book of Calculation, New York: Springer. Original Latin edition, 1202. Stein, Josef. (1967). «Computational Problems Associated with Racah Algebra». Journal of Computational Physics 1(3), 397-405. 1 Кнут Д. Искусство программирования. Т. 2: Получисленные алгоритмы. М.: Вильяме, 2011. 2 Ландау Э. Основы анализа. Изд-во иностранной литературы, 1947.
Библиография ♦ 251 Stepanov, Alexander, and Meng Lee. (1995). The Standard Template Libraiy. Hewlett- Packard Laboratories, Technical Publications Department. Stepanov, Alexander, and Paul Mcjones. (2009). Elements of Programming. Boston: Addison-Wesley Professional. Stillwell, John. (1994). Elements of Algebra. New York: Springer. Stillwell, John. (2002). Elements of Number Theory. New York: Springer. Stillwell, John. (2010). Mathematics and Its History. New York: Springer. Stroustrup, Bjarne. (2013a). The C+ + Programming Language (4th ed.). Boston: Addison-Wesley Professional. Stroustrup, Bjarne. (2013b). Л Tour of C++. Boston: Addison-Wesley Professional. van der Waerden, B. L. (1983). Geometry and Algebra in Ancient Civilizations. Berlin: Springer-Verlag. van der Waerden, B. L. (1988)1. Science Awakening: Egyptian, Babylonian, and Greek Mathematics. Translated by Arnold Dresden. Dordrecht, Netherlands: Kluwer Academic Publishers. Weil, Andre. (2007). Number Theory: An Approach through History from Hammurapi to Legendre. Cambridge, MA: Birkhauser Boston. 1 Ван дер Варден Б. И. Пробуждающаяся наука. ГИФМЛ, 1959.
Предметный указатель Символы а. См. Аликвотная сумма с (сумма делителей), формула, 37 ф. См. Эйлера функция * (оператор), математическое соглашение, 106 + (оператор), математическое соглашение, 106 А advance, 172 AKS, тест простоты, 221 APL, 114 A Tour of C++, 239 extended_gcd, 212 F fermat_test, 219 findjf, 173 find_if_n, 173 Formulario Mathematico, 154 FP, 114 G gcd,136 get_temporary_buffer, 197 H half, 108 В bsearch, 175 C++, 15, 239 C++11.57, 170, 177, 239, 245 Cogitata Physico Mathematica, 63 Common Lisp, 106, 114, 173 I identity_element, 112, 219 inverse_operation, 113 is_prime, 218 iterator_category, характеристика итератора, 169 difference_type, характеристика итератора, 169 Disme: The Art of Tenths, 118 distance, 169 divides, 218 Elements of Programming, 15, 104, 166, 167, 189 largest_doubling, 55 Latine sine Flexione, 155 Liber Abaci, 53 Liber Quadratorum, 54 lower_bound, 177 M mark_sieve, 30 miller_rabin_test, 220 modulo_multiply, 218
Предметный указатель ♦ 253 multiplicative_inverse, 111, 224 multiplicative_inverse_fermat, 219 О odd, 108 P Q quotient_remainder, 57 R reciprocal, 113 reference, характеристика итератора, 169 Regular, концепция, 165 remainder, 55 remainder_fibonacci, 58 reverse, 192 reverse_copy, 196 reverse_n, 194 reverse_n_adaptive, 197 reverse_n_with_buffer, 196 reverse_recursive, 194 RSA, алгоритм, 217, 222 s Scheme, 106 Semiregular, концепция, 166 sift, 34 smallest_divisor, 218 stein_gcd, 200 STL (Standard Template Library) алгоритмы, 177, 196, 197 и некатегоричные теории, 97 контейнеры, 172 применение обобщенного программирования, 13, 168 соглашения, 31 swap, 180 swap_ranges, 183 V value_type, характеристика итератора, 169 А Абелева группа, 81, 100, 138 Абстракция Аристотель, 80, 160, 162, 178 в математике, 79 и программирование, 14, 16, 226 Аверроэс. См. Ибн Рушд Автоморфизм, 96 Агравал Маниндра, 221 Адлеман Лен, 217 Адрес, 163 Айверсон Кен, 114 Академия (Платона), 45, 160 Аксиоматический метод, 147 Аксиомы Гильберта, 152 Евклида, 148 определение, 148 Пеано, 154 Алгебраические структуры, 80 Алгебраические целые числа, 127 Алгоритмы адаптирующиеся к объему памяти, 197 partition_point, 176 partition_point_n, 175 pointer, характеристика итератора, 169 polynomial_value, 121 power_accumulate_semigroup, 111 power_monoid, 112 power_semigroup, 112, 113
254 ♦ Предметный указатель в Древнем Египте, 17 на месте, 196 область определения, 136 обобщение, 102, 109, 117, 136 определение, 17 первые упоминания, 17 практическая производительность, 192 с полиномиально-логарифмической пространственной сложностью, 196 Александр Великий, 46, 161 Александрийская библиотека, 46 Александрия, 46 Аликвотная сумма, 37 Аналитическая механика, 92 Апология, 46 Ариабхатия, 52 Аристоксен, 25 Аристотель, 25, 160 Аристофан, 46 Арифметика, 120 Арифметика по модулю, 69, 78 малая теорема Ферма, 79 теорема Вильсона, 78 Арифметические исследования, 124 Архимед аксиома, 49 место в истории, 51 о добыче математических знаний, 159 Асимметричные ключи, 216 Ассоциативная бинарная операция в группах, 80 в моноидах, 83 в полугруппах, 84 Ассоциативность сложения, 18, 104 наглядное доказательство, 142 определение, 157 Ассоциативность умножения наглядное доказательство, 143 Атрибуты типа, 165 Афины, 45 Ахмес, 17, 57 Б Бартельс Мартин, 150 Баше де Мезирьяк, 65, 213 жизнеописание, 205 Безу теорема, 204 Бернулли Иоганн, 67 Блетчли-парк, 216 Больцано-Коши теорема, 174 Больяи Фаркаш, 150 Больяи Янош, 150 Булево полукольцо, 134 Бэкус Джон, 114 В Ван дер Варден Бартель, 129 Введение в анализ бесконечных, 67 Введение в арифметику, 19, 26 Веблен Освальд, 96 Вейлерт Андре, 204 Вектор, контейнер, 106 Векторное пространство, 138, 139 Вес Хэмминга, 20 Вещественные числа, 119, 234 Взаимно обратные числа, 70 Взаимно-однозначное соответствие, 86 Взаимно простые числа, 37, 74, 76, 224 Вильсона теорема, 72, 78 Вильсон Джон, 71 Возведение в степень, алгоритм, 109, 226 вычисление линейной рекуррентной последовательности, 116 вычисление чисел Фибоначчи, 116 использование в алгоритмах на графах, 134
Предметный указатель ♦ 255 использование в криптологии, 219, 223 Г Галуа Эварист, открытие групп, 80 Гаусс Карл Фридрих, 37, 69,124, 150,217 жизнеописание, 124 Гауссовы целые числа, 126, 204 Гёдель Курт, 153 Геттингенский университет Давид Гильберт, 152 Карл Гаусс, 124 описание, 123 Эмми Нётер, 128 Гильберта проблемы, 153 Гильберта программа, 153 Гильберт Давид, 128, 151, 208 жизнеописание, 152 Гильбертовы пространства, 153 Гиперболическая геометрия, 149 Главный идеал, 207 Горнера схема, 120 Грассман Герман, 154 Гриса-Миллса алгоритм, 186 Грис Дэвид, 186 Группы абелевы,81, 100, 138 бинарная операция, 81 группа Клейна, 99 нейтральный элемент, 81 операция обращения, 81 определение, 80 открытие, 80 порядок элемента, 88 примеры, 81 сводное описание, 100, 138 симметричная, 180 теоремы о, 86 циклические, 89, 101 Грэнвилл Эндрю, 222 д Двоичный поиск, 174 лемма о, 176 Двусвязные списки, 168 Дедекинд Ричард, 127, 154 Декартовы координаты, 120, 125 Декарт Рене, 63, 120 Деление полиномов, 122 Делители нуля, 131, 139 Десятичные дроби, 118 Джефферсон Томас, 47, 119 Диапазоны замкнутые, 171 обмен, 182 ограниченные, 171, 185 определение, 171 открытые, 171 полуоткрытые, 171 счетные, 171, 184 точка разбиения, 175 Диофант, 65, 205 Дирихле Петер Густав Лёжен, 127, 142 Дирихле принцип, 238 Диспетчеризация по категории, 170, 173, 178, 193 Дифференциальное исчисление, 67 Диффи Уитфилд, 217 Доказательство наглядное, 141 неконструктивное, 208 определение, 144 от противного, 40, 236 по индукции, 237 принцип Дирихле, 88, 238 Дружественные числа, 62
25Б ♦ Предметный указатель Е Евклид. См. также: аксиоматический метод, 147 алгоритм наибольшей общей меры, 48 жизнеописание, 47 несоизмеримые величины, 47 о теории чисел, 28 Евклида алгоритм, 48 Евклидова геометрия альтернативы, 148 и гиперболическая геометрия, 149 и неевклидова геометрия, 150 пятый постулат, 148 Евклидовы кольца, 136 Египетское деление, 57 Египетское умножение, 18 обобщение на возведение в степень, 109 требования, 102 Единицы кольца, 131 3 Замкнутые диапазоны, 171 Занимательная математика, 205 Значение, определение, 163 И Ибн Рушд, 162 Идеал. См. также Кольца лемма об идеалах в евклидовых кольцах, 206 лемма об идеале линейных комбинаций, 111, 206 область главных идеалов, 207 определение, 205 порождающий элемент, 207 Изменяемые объекты, 163 Изоморфизм моделей, 96 Индийские математики, 52 Индукции аксиома, 28, 154, 156 Индукция, доказательство по, 237 Инман Бобби Рэй, 217 Интегральное исчисление, 67 Интерлингва, 155 Интерфейсы, проектирование, 195 Интуиционистская философия математики, 208 Иррациональные числа, 43 Искусство программирования, 18 История алгебры, 118 Итераторы ввода, 167 в массивах, 168 вывода, 168 вычисление расстояния между, 169 двунаправленные, 168 мегментированные, 168 общие сведения, 166 однонаправленные, 168 определение, 166 понятие следующего, 166 разыименование, 166 связанные, 168 с ^произвольным доступом, 168 тип разности, 169 К Капур Дипак, 114 Кармайкла числа, 220 Катальди Пьетро, 63 Категории итераторов ввода, 167 вывода, 168 двунаправленные, 168 однонаправленные, 168 с произвольным доступом, 168 Категоричные теории и STL, 97
Предметный указатель ♦ 257 определение, 96 примеры, 97 Каял Нирадж, 221 Квадривиум, 26 Кватернионы, 137 Китайские математики, 52 Клейна группа, 99 Клейн Феликс, 99, 128 Клини Стивен, 106 Ключи, криптографические, 216 Кнут Дональд Э., 18, 58 Ковалевская Софья, 128 Код, определение, 215 Кокс Клиффорд, 217 Колосс, вычислительная машина, 216 Кольцо водное описание, 139 делители нуля, 131 единицы, 131 и НОД, 205 область целостности, 131 определение, 129 унитарное, 130 Коммутативная алгебра, кольца, 130 Коммутативность сложения, 141, 157 Коммутативность степеней, полугруппы, 85 Коммутативность умножения, наглядное доказательство, 142 Комплексные числа, 125 Конечно аксиоматизируемая теория, 94 Конструктивисты, 208 Контрапозиция, 235 Концепции Regular, 165 Semiregular, 166 атрибуты типа, 165 выбор, 227 и общая алгебра, 128 обзор, 163, 240 определение, 163 примеры, 106 соглашение об именовании, 165 требования к типам, 31, 164 функции типа, 165 Кратчайший путь, нахождение, 134 Криптоанализ, 215 Криптография, 212, 215 Криптология RSA, алгоритм, 217, 222 асимметричные ключи, 216 Блетчли-парк, 216 ключи, 216 код, определение, 215 Колосс, вычислительная машина, 216 криптоанализ, 215 криптография, 215 криптосистемы, 216 криптосистемы с открытым ключом, 216 машина Лоренца, 216 односторонняя функция с лазейкой, 217 открытый текст, 216 симметричные ключи, 216 шифртекст, 216 шифр Цезаря, 215 Энигма, шифровальная машина, 216 Криптосистемы, 216 Криптосистемы с открытым ключом, 216 Кристал Джордж, 40 Кэли теорема, 180 Л Лагранжа теорема, 90, 93 Лагранж Жозеф Луи, 92, 174 Лаплас Пьер Симон, 67
258 ♦> Предметный указатель Лекции по теории чисел (Vorlesungen iiber Zahlentheorie), 127 ЛемерД. Г, 174 Леонардо Пизанский понятие нуля, 53 последовательность чисел Фибоначчи, 58 Линейная алгебра произведение матриц, 132 произведение матрицы на вектор, 132 скалярное произведение, 132 Линейная рекуррентная последовательность, 116 Линейная рекуррентная функция, 116 Линейный поиск, 173 Линкольн Авраам, 47 Лицей, 161 Лобачевский Николай, 149 Лоренца машина, 216 Лю Хуэй, 52 Лямбда-выражения, 177, 246 М Магма, 85 Макджонс Пол, 15 Математика в девяти книгах, 52 Менон, 46 мера отрезка, 39 Мерсенна простые числа, 63 Мерсенн Марин, 63 Метафизика, 161 Миллера-Рабина тест, 220 Миллс Харлан, 186 Модели изоморфизм, 96 определение, 95 Модуль, определение, 137, 139 Мокли Джон, 174 Моноид, 83 аддитивный, 84, 101 мультипликативный, 84 примеры, 84 сводное описание, 100, 138 Музейон, 46 Мультипликативные функции, 37 Мюссер Давид М., 114 н Натуральные числа, 133, 154, 155, 158, 234 Начала (Евклида), 14, 28, 46, 147 предложение [IX, 36], 36 предложение [VII, 30], 67 предложение [VII, 32], 28 предложение [X, 2], 47 предложение [X, 3], 48 предложение [X, 117], 43 Недостижимые числа, 156 Неевклидова геометрия, 149 Независимая теория, 94 Неизменяемые объекты, 163 Нейтральный элемент, 101 в группе, 81 в кольце, 130 в моноиде, 84 Некатегоричная теория, 99 Некоммутативная алгебра, кольца, 130 Неконструктивные доказательства, 208 Необратимость, лемма о, 74 Неограниченные объекты, 163 Непротиворечивая теория, 95, 96 Несоизмеримые величины, 47 Нётер Эмми, 128 жизнеописание, 128 Никомах из Герасы, 19, 26 Никомахова этика, 161
Предметный указатель ♦ 259 НОД (наибольший общий делитель) алгоритм Евклида, 47 алгоритм Штайна, 199 алгоритмы циклической перестановки, 213 арифметика рациональных чисел, 213 вычисление, 59 доказательство правильности, 60 и кольцевые структуры, 205 исторические вехи, 202 описание, 59 полиномов, 122 применения, 212 расширенный алгоритм Евклида, 208, 223 символическое интегрирование, 213 Нуль в египетской системе счисления, 18 история, 52 появление в Европе, 53 О Облака, 46 Область главных идеалов, 207 Область определения алгоритма, 136 Область определения функции, 104 Область целостности, 131, 139 Обобщенное программирование в C++, 239, 243 и математика, 79 история, 114, 123, 128, 162 концепции, 163 общие сведения, 13, 16 сущность, 117, 226 Обратимость взаимно обратные числа, 70 и арифметика по модулю, 69 лемма о, 208 правило обратимости, 70 правило самообратимости, 71 следующего, аксиома, 156 Обратимые элементы. См. Единицы Обращения операция, 109, 111 в группе, 81 Общая алгебра векторное пространство, 138, 139 главный идеал, 207 группа, 80, 86, 100, 138 евклидовы кольца, 136, 139 зарождение, 80, 128 идеал, 205 и программирование, 13, 128, 226 кольцо, 129, 139 модуль, 137, 139 моноид, 83, 100, 138 поле, 137, 139 полугруппа, 84, 100, 138 полукольцо, 133, 139 Общая мера отрезков, 39 Общие понятия, аксиоматический метод Евклида, 147 Объектные типы, определение, 163 Объекты изменяемые, 163 неизменяемые, 163 неограниченные, 163 определение, 163 отдаленные части, 163 Объекты-функции, 113, 241, 243 Объявления синтаксиса, 241 Ограниченные диапазоны, 171, 185 Односторонняя функция с лазейкой, 217 Октонионы, 137 Органон, 162 Остаток, 49, 54, 57, 136 алгоритм Флойда-Кнута, 58 в арифметике по модулю, 70
2Б0 ♦ Предметный указатель перестановка остатков, 68 при делении гауссовых целых чисел, 126 при делении полиномов, 122 Отдаленные части объекта, 163 Открытый текст, 216 П Палиндромические простые числа, 34 Пеано аксиомы, 154 Пеано Джузеппе, 153 жизнеописание, 154 Пеано кривая, 155 Переписывание кода, 23 Перестановка остатков, лемма о, 68 Перестановки, 179 Пир, 46 Письма к немецкой принцессе, 67 Пифагор, 25 жизнеописание, 25 Пифагора теорема, 47 Пифагорейская программа, 38 Пифагоровы тройки, 52 Платон, 160 жизнеописание, 45 Платоновские вопросы, 27 Платоновы тела, 45 Плейфэра аксиома, 148 Плутарх, 27 Подгруппа определение, 88 порождающий элемент, 89 тривиальная, 89 циклической группы, 90 Поиск двоичный, 174 линейный, 173 Полезного возврата закон, 58,183, 194 Полиномиально-логарифмическая пространственная сложность, 196 Полиномы аналогия с числами, 123 вычисление НОД, 122 деление с остатком, 122 история, 120 степень, определение, 122 схема Горнера, 120 Политика, 161 Полная теория, 94 Полного упорядочения принцип, 40 Полноты закон, 184 Полугруппа аддитивная, 84, 101 коммутативность степеней, 85 мультипликативная, 84 определение, 84 примеры, 84 сводное описание, 100, 138 Полукольцо булево, 134 кратчайший путь, 134 описание, 132 сводное описание, 139 слабое, 133 транзитивное замыкание, 134 трассировка социальных сетей, 134 тропическое, 135 умножение матриц, 132 Поля определение, 137, 139 простые, 137, 139 расширение, 137 характеристика, 137 Порождающий элемент, 207 Порождающий элемент подгруппы, 89 Порядок элемента группы, 88 Постулаты Евклида, 147 Постусловия, 242 Принцип включения-исключения, 77 Проверка простоты, 217 Пппгтпянгттзрттняэт гппжттпгть 1 Qfi
Предметный указатель ♦ 261 Простые числа бесконечное количество, 28 в Древней Греции, 28 Мерсенна, 63 определение, 28 проверка простоты, 217 решето Эратосфена, 29 Ферма, 62 Прямоугольные числа, 26 Псевдонимы, 245 Птолемей, 149 Пуанкаре Жюль Анри, 225 жизнеописание, 208 Пятый постулат евклидовой геометрии, 148 Р Разделения типов закон, 183 Разложение на простые множители, 37, 64, 124, 127 Разности степеней формула, 36 Разыименование итераторов, 166 Райвест Рон, 217 Рассел Бертран, 155 Расширенный алгоритм Евклида, 208, 223 Рациональные числа, 137, 234 Региус Худальрикус, 62 Регулярные типы, 105 Регулярные функции, 166 Редукции, алгоритм, 114 Реевский Мариан, 216 Рекурсивный остаток, лемма о, 50 Решето Эратосфена, 29 реализация, 30 Ринда математический папирус, 17 С Саккери Джованни Джироламо, 149 Саксена Нитин, 221 Самообратимости правило, 71 Свидетели, проверка простоты, 219 Семантические требования к обобщенным алгоритмам, 104 Символическое интегрирование, применение НОД, 213 Симметричная группа, 180 Синтаксические требования к обобщенным алгоритмам, 103 Скалярное произведение, 132 Следующий, 154 Сложение ассоциативность, 18, 142, 157 коммутативность, 141, 157 цепочка, 20 Смежные классы, 90 Снижение стоимости операций, 33 Собственный делитель, 38 Совершенные числа в Древней Греции, 35 интерес математиков к, 62 определение, 35 Современная алгебра, 129 Сократ, 45 Сократический метод, 46 Составные числа определение, 28 отличение от простых, 217 Софисты, 45 Социальные сети, трассировка связей, 134 Стевин Симон, 118, 174 Степанов Александр, 15, 114 Степень полинома, 122 Страуструп Бьярн, 239 Существование нуля, аксиома, 156 Счетные диапазоны, 171, 184 Т Теорема о промежуточном значении, 174
2Б2 ♦ Предметный указатель Теорема о числе присваиваний, 182 Теорема Фалеса, 145 Теория чисел, 14 Теории категоричные, 96 конечная аксиоматизируемость, 94 независимость, 94 некатегоричные, 99 непротиворечивость, 95, 96 определение, 94 полнота, 94 унивалентность, 96 установление истинности, 151 характеристики, 94 Теория чисел, 45 Liber Quadratorum, 54 арифметика по модулю, 69 в XVII и XVIII веках, 62, 74 в Древней Греции, 25 Гаусс, 124 иНОД, 127 малая теорема Ферма, 66, 93 проверка простоты, 217 простые числа, 28 решето Эратосфена, 29 совершенные числа, 35 теорема Безу, 204 теорема Вильсона, 71, 78 теорема Эйлера, 74, 94 фигурные числа, 26 Тип значений, 163 Тип разности, итераторы, 169 Точка разбиения, 175 Транзитивное замыкание, вычисление, 134 Транспозиции, 179, 180 лемма о, 181 Трансфинитные ординальные числа, 156 Требования к алгоритму, 102 Треугольные числа, 26 Тривиальные подгруппы, 89 Тривиальные циклы, 182, 189 Трихотомии закон, 40 Тускуланские беседы, 51 Тьюринг Алан, 153, 216 У Уайлс Эндрю, 65 Уайтхед Альфред Норт, 46 Умножение алгоритм русского крестьянина, 18 египетское, 18 определение, 18, 157 Уоринг Эдвард, 71 Утверждения, 243 Уточнения интерфейса закон, 195 Ф Фалес Милетский, 25, 144 Федон, 46 Ферма Великая теорема, 65 Ферма малая теорема доказательство, 72 доказательство с помощью теоремы Лагранжа, 93 лемма о необратимости, 74 обращение, 73 проверка простоты, 219 формулировка, 66 формулировка в терминах арифметики по модулю, 79 Ферма простые числа, 62 Ферма Пьер де, 62, 63 доказательства, 64, 65 жизнеописание, 65 Ферма тест, 219 Фибоначчи последовательность чисел, 58
Предметный указатель ♦ 2БЗ Фибоначчи числа, вычисление, 114 Фигурные числа, 25, 26 гномоны, 27 квадратные, 27 прямоугольные, 26 треугольные, 26 Флауэрс Томми, 216 Флойд Роберт, 58 Формалистическая философия математики, 151 Функторы. См. Объекты-функции Функции типа, 165 Функция умножения с аккумулированием, 21 X Хайям Омар, 149 Характеристики итераторов, 169 Хвостовая рекурсия, 21 Хеллман Мартин, 217 ц ч Частное, 56, 136, 183 при делении полиномов, 122 Числовая ось, 119 Ш Шаблонные функции, 239 Шамир Ади, 217 Шифр, определение, 215 Шифртекст, 216 Штайна алгоритм, 199 Штайн Джозеф, 199 э Эйлера функция, 76 Эйлер Леонард, 79 жизнеописание, 67 и Лагранж, 92 простые числа, 66 совершенные числа, 38, 62 теорема Эйлера, 74 функция Эйлера, 76, 223 Эквивалентность, 105 Элемент данных, 163 Энигма, шифровальная машина, 216 Эратосфен, 29 Я Язык программирования C++, 239, 244 Цезаря шифр, 215 Циклическая перестановка, 185 применение НОД в алгоритмах, 213 Циклические группы, 89, 101 порождающий элемент, 89 Циклы, в перестановках, 181, 188 Цицерон, 51
% / t h. Александр Степанов »' ^ / ' ,. > Отм»те «т ки к • • • енн• п • г* • • в• н •