Пособие часть1

ФЕДЕРАЛЬНОЕ АГЕНТСТВО ПО ОБРАЗОВАНИЮ

ВОЛОГОДСКИЙ ГОСУДАРСТВЕННЫЙ ТЕХНИЧЕСКИЙ УНИВЕРСИТЕТ

С.Ю. Ржеуцкая, И.А. Андрианов


СТРУКТУРЫ ДАННЫХ И
АЛГОРИТМЫ ОБРАБОТКИ. Часть 1






Утверждено редакционно- издательским советом университета в качестве учебного пособия


Вологда 2005
УДК 681.3.06
ББК Р23

Рецензенты:
Сипин А.С., к.ф-м.н, доцент кафедры Прикладной математики ВГПУ
Кузнецов Р.Н., директор учебного центра "Мезон"

Ржеуцкая С.Ю., Андрианов И.А.
Структуры и алгоритмы обработки данных. Часть 1: Учеб. пособие. - Вологда: ВоГТУ, 2005. – 232 с.

ISBN 5-87851-152-5

Учебное пособие предназначено для студентов специальности 230105 – программное обеспечение вычислительной техники и автоматизированных систем, но может быть полезно студентам всех специальностей, связанных с компьютерными технологиями. Первая часть содержит определение основных понятий, анализ и реализацию линейных и иерархических структур данных и алгоритмов сортировки и поиска.

УДК 681.3.06
ББК Р23
©Вологодский государственный технический университет, 2005
ISBN 5-87851-152-5 ©Ржеуцкая С.Ю.,
Андрианов И.А.
Введение
Пособие предназначено для студентов специальности 230105  «Программное обеспечение вычислительной техники и автоматизированных систем», но может использоваться студентами других специальностей, связанных с компьютерными технологиями, в качестве дополнительного материала.
Исследование структур данных и алгоритмов их обработки  это основа, на которой держатся все остальные компьютерные науки. Грамотное решение задач в любой области программирования невозможно без обширных и прочных знаний типовых структур данных и алгоритмов.
Несмотря на динамичное развитие различных направлений информационных технологий, в этой области положение довольно стабильно. Конечно, новые задачи часто требуют нестандартных решений, поэтому набор востребованных структур данных и алгоритмов постоянно пополняется. Тем не менее, основные способы организации данных и алгоритмы их обработки хорошо проработаны, имеется несколько фундаментальных учебников и монографий по этому вопросу, например [7,9,2,13].
При изложении авторы в основном ориентируются на известные, много раз переиздававшиеся фундаментальные работы. Мы старались изложить лаконично и систематизированно материал, почерпнутый из большого количества источников, дополнить его собственными примерами и сведениями о современном состоянии некоторых проблем. Иногда в различных источниках встречается несогласованность терминологии. В этом случае употребляются наиболее распространенные термины, а остальные все-таки упоминаются со ссылкой на авторов.
Все примеры в пособии написаны на языке С++, при этом авторы старались сделать тексты программ понятными, поэтому использовали такие конструкции языка, которые не затрудняют чтение исходных текстов. Мы стремились показать различные подходы к программированию алгоритмов обработки данных, поэтому там, где это уместно, использовался объектно-ориентированный подход и шаблоны. Сделано и небольшое введение в функциональное программирование в разделах, посвященных рекурсивной обработке линейных и иерархических списков.
Пособие содержит десять глав. В первой из них определяются основные понятия и дается первое введение в анализ алгоритмов. Вторая и третья главы содержат довольно подробное описание линейных и иерархических структур данных с примерами реализации. Три следующих главы посвящены практически важным задачам сортировки и поиска данных, причем в шестой главе рассматриваются способы сортировки и поиска данных во внешней памяти. Затем разбирается обширная группа методов и алгоритмов для решения задач дискретной оптимизации под общим названием «исчерпывающий поиск». Восьмая глава посвящена классическим алгоритмам на графах. В следующей главе разбираются алгоритмы поиска в текстовых последовательностях, которые в последнее время приобретают повышенную актуальность в связи с массовым распространением поисковых систем. Наконец, последняя глава содержит введение в NP-полные задачи и способы их приближенного решения.
Представленный материал полностью охватывает программу курса «Структуры данных и алгоритмы обработки» и может использоваться в качестве учебника по данной дисциплине.
В качестве дополнения к пособию следует использовать методические указания по структурам и алгоритмам обработки данных, в которых содержатся практические задания.
Основные понятия и определения
Первая глава является вводной и посвящена определению основных понятий.
Разумеется, способы организации данных и алгоритмы их обработки нельзя рассматривать отдельно друг от друга. Однако в рамках данной главы полезно сначала сосредоточиться на терминологии, которая относится к определению данных, а затем разобрать наиболее фундаментальные понятия, касающиеся алгоритмов. Здесь уместно привести высказывание Фредерика П.Брукса. «Покажите мне ваши блок-схемы и спрячьте таблицы, и я ничего не пойму. Покажите мне таблицы, и блок-схемы мне не понадобятся  все будет очевидно и так.» Таблицы (дословный перевод английского tables)  это структуры данных, а блок-схемы  способ записи алгоритмов. Иными словами, выбор структур данных определяет используемые алгоритмы. Возможно, это чересчур упрощенный подход, но важность грамотного выбора способов организации данных нельзя оспорить[14].
Существует несколько фундаментальных понятий, которые относятся к определению данных. Это типы данных, абстрактные типы данных и структуры данных. Разберем данные понятия по порядку.
1.1. Типы данных
1.1.1. Понятие типа данных
Понятие типа данных хорошо проработано в начальном курсе программирования, поэтому кратко остановимся только на тех моментах (и терминологии), которые важны для дальнейшего изложения.
Любая переменная, константа, а также значение выражения или функции относятся к определенному типу данных.
Тип данных определяет множество допустимых значений данных и множество операций над этими значениями.
Все типы данных, используемые в языках высокого уровня, можно разделить на две большие группы:
скалярные (простые);
структурированные (составные, конструируемые).
Скалярные типы, в свою очередь, делятся на несколько групп:
базовые (встроенные в язык), т. е. типы, предопределенные в языке программирования;
производные от базовых (например, перечисляемый тип или диапазон в языке Pascal);
ссылочные типы (указатели), обеспечивающие возможность работы с множеством абстрактных адресов памяти.
Структурированные (конструируемые) типы имеют в своей основе скалярные типы данных и используются для определения совокупностей данных. В языке программирования предопределены средства спецификации таких типов и некоторый набор операций, дающих возможность доступа к отдельным элементам составных значений.
Большинство языков программирования представляет стандартный набор структурированных типов:
массивы (в том числе строки  массивы символов);
записи (в языке С они называются структурами, аналоги записей с вариантами  объединения);
в некоторых языках, например в Pascal, есть множества.
Важно отметить, что в языке высокого уровня внутреннее представление типа данных скрыто от программиста, а доступ к значениям типа осуществляется через предопределенный набор операций, реализация которых также скрыта. Такой принцип называется инкапсуляцией, и это обозначает, что внутреннее представление данных как бы заключено в непроницаемую капсулу, а доступным является лишь внешнее представление типа, содержащееся в описании языка.
Внутреннее представление данных, размер выделяемой памяти и реализация операций предоставляются на усмотрение разработчиков компиляторов. Единственное, что предоставляют практически все языки программирования,  операция sizeof(тип) для определения размера области памяти, отводимой под переменную заданного типа.
Все же полное незнание внутренего представления стандартных типов в языках высокого уровня может привести к многочисленным ошибкам при реализации алгоритмов. Поэтому очень кратко остановимся на этом вопросе.
1.1.2. Внутреннее представление базовых типов в оперативной памяти
В состав базовых типов данных любого языка программирования включаются такие типы, операции над значениями которых эффективно поддерживаются архитектурой компьютера.
В современных компьютерах имеется две основных формы представления данных:
с фиксированной точкой (в русском переводе иногда употребляют термин "с фиксированной запятой", т. к. речь идет о разделителе, отделяющем целую часть числа от дробной части);
с плавающей точкой (запятой).
Первый способ употребляется для хранения целых чисел, символов и логических значений. По этой причине в языках C/C++ все перечисленные типы данных относятся к целочисленным. Второй способ используется для хранения вещественных чисел либо целых чисел, имеющих очень широкий диапазон значений. Примерно такой набор базовых типов имеется во всех языках.
В представлении с фиксированной точкой целое число хранится в двоичной системе счисления, занимая столько байт памяти, сколько определено в конкретном компиляторе для его типа.
Например:
целое число 19 в двоичной форме с фиксированной точкой будет записано как 10011 (разложение по степеням двойки: 19=1Ч24+0Ч23+0Ч22+1Ч21+1Ч20). Один крайний бит отводится под знак числа (для положительного числа 0, для отрицательного 1), остальные незанятые биты заполняются нулями.
Представление с плавающей точкой в различных компьютерных архитектурах может быть реализовано по-разному, но в любом случае число рассматривается как произведение двух сомножителей, один из которых представляет собой число 2 в целой степени. Степень двойки определяет порядок числа, а первый из сомножителей определяет значащие цифры числа и называется мантиссой.
Например: вещественное число 15,375 в двоичной форме с плавающей запятой можно представить как 1,111011Ч211, где 1,111011  мантисса, 11  порядок (запись в двоичной форме).
В ячейке памяти, отводимой под число с плавающей точкой, находится два значения  мантисса и порядок.
Рассмотрим следствия, которые вытекают из рассмотренных способов хранения данных.
Целочисленные типы в языке программирования  это интервал целых чисел. Операции над целыми числами определены лишь тогда, когда исходные данные и результат лежат в этом интервале. Иначе возникает ситуация, называемая переполнением. При целочисленном переполнении, как правило, не возникает ошибка выполнения программы, однако результат становится неверным. За исключением переполнения все операции над аргументами с фиксированной точкой выполняются точно (без погрешности).
Все операции над данными с фиксированной точкой выполняются быстрее, чем над данными с плавающей точкой. В последнем случае значительное время тратится на операции выравнивания порядков (для возможности выполнения операции операнды должны иметь один и тот же порядок). В современных архитектурах компьютеров благодаря мощной аппаратной поддержке операций с плавающей точкой они выполняются уже не намного медленней, чем операции с фиксированной точкой.
Вещественные числа, как и целые, представляются конечным множеством значений (хотя количество различных значений вещественных чисел так велико, что может показаться бесконечным).
Каждое вещественное число будет иметь приблизительно одинаковое количество значащих цифр в его представлении, т. к. под мантиссу отводится фиксированное количество байтов. Как следствие этого, ошибка для очень больших чисел будет больше по абсолютной величине.
Вещественные числа всегда представлены с некоторой погрешностью. Все операции над вещественными числами также выполняются с погрешностью. Поэтому нельзя сравнивать на равенство/неравенство вещественные числа обычным способом. Вместо этого можно сравнить модуль разности этих чисел с какой-нибудь достаточно малой величиной.
Следует быть осторожным при выполнении операций между числами, порядки которых сильно отличаются, например, между очень большими и очень маленькими числами. Возможны ситуации, когда мантисса не сможет обеспечить требуемую точность. Например, при сложении чисел 10000000 и 0.00000001 типа float языка C разрядности мантиссы не хватит, чтобы представить число 10000000.00000001, и результат останется равным 10000000.
Замечание
Для уменьшения влияния ошибки округления при выполнении арифметических операций с вещественными числами необходимо иметь в виду следующее. Если складывается много чисел, то их необходимо разбить на группы чисел, близких по абсолютному значению, произвести суммирование в группах, начиная с меньшего числа, после чего полученные суммы сложить, опять-таки начиная с меньшей. Таким образом, при работе с вещественными числами при перестановке мест слагаемых сумма может измениться, и иногда значительно. Похожие рекомендации можно дать и для других арифметических операций.
1.1.3. Внутреннее представление структурированных типов данных
Переменные структурированных типов, как правило, занимают непрерывную область памяти, т. е. соседние элементы занимают соседние ячейки памяти. Это обеспечивает возможность быстрого вычисления адреса элемента по его индексу (совокупности индексов в многомерных массивах или имени поля в записях). Такой способ доступа к элементам называется прямым доступом. Множества не составляют исключения, например, в языке Pascal множества представлены в виде битового массива.
Для обеспечения прямого доступа к элементам данных структурированных типов обычно создается дополнительная структура, называемая дескриптором, которая хранит адрес начала, размер одного элемента и, возможно, некоторые другие данные, необходимые для быстрого вычисления адреса конкретного элемента данных.
Например, для одномерного массива, нумерация элементов которого начинается с нуля, адрес элемента ai определяется так:
ai=a0+i*sizeof(ТипЭлементовМассива),
где a0  адрес первого байта памяти, занимаемой массивом.
1.1.4. Статическое и динамическое выделение памяти
Современные языки программирования предоставляют различные способы выделения памяти для данных, обрабатываемых программой. Возможны следующие варианты.
Память под переменную выделяется до начала выполнения программы в соответствии с описанием типа и остается неизменной до конца программы. Это внешние (глобальные) переменные и статические переменные. Термин «статические» пришел из языка С и обозначает переменные, имеющие ограниченную область видимости, но существующие от начала до конца работы программы.
Память выделяется при входе в определенный блок программы также в соответствии с описанием типа и освобождается при выходе из блока (автоматические переменные);
Память выделяется (захватывается) по запросу из программы и освобождается также по запросу. При этом размер памяти может определяться типом переменной или явно указываться в запросе. Такие переменные называются динамическими.
Первые два способа называют статическим выделением памяти (не путать со статическими переменными в смысле языка С). Во многих случаях статическое выделение памяти ведет к ее неэффективному использованию, особенно для структурированных типов данных, т. к. не вся выделенная область памяти реально заполняется данными и обрабатывается. Поэтому во многих языках есть удобные средства динамического формирования переменных. Возможности создания и использования динамических переменных тесно связаны с механизмами указателей, поскольку динамическая переменная не имеет статически заданного имени, и доступ к такой переменной возможен только через указатель.
Отметим важную особенность  при любом способе формирования память для переменной структурированного типа выделяется сразу одной непрерывной областью. Например, нельзя сначала выделить память под половину массива, а затем под оставшуюся половину, даже если пользоваться динамически формируемыми массивами. Только так можно обеспечить прямой доступ к отдельным элементам.
1.2. Абстрактные типы данных (АТД)
1.2.1. Понятие АТД
Набор стандартных типов во всех языках программирования ограничен. При этом операции, которые можно связать с каждым стандартным типом, жестко фиксированы в самом языке и не могут быть изменены программистом так, чтобы они стали частью этого типа. Заметим, что производные типы данных позволяют ограничить множество значений базового типа, но не переопределить операции. Структурированные типы данных также не являются исключением, поскольку набор стандартных средств для доступа к отдельным элементам составного значения жестко фиксирован.
Однако при решении самых разнообразных задач возникает потребность в новых типах данных, связанных со своими операциями, которые не определены для стандартных типов. В других случаях появляется необходимость изменить реализацию встроенных операций, что, увы, невозможно. Идя навстречу пожеланиям прикладных программистав, разработчики компиляторов могли бы расширять набор встроенных типов в новых версиях, но есть стандарт языка, который довольно консервативен. Кроме того, нельзя расширять набор встроенных типов до бесконечности.
Выход из этой ситуации состоит в предоставлении программисту возможности конструировать свои собственные типы, связывая с ними любой набор операций. В связи с этим в программировании появилось понятие абстрактного типа данных (АТД).
Этот термин появился в 1974 г. в статье Б.Лисков и С.Зиллеса [22], посвященной принципам разрабатывавшегося ими языка программирования CLU. Термин быстро распространился среди программистов и теоретиков программирования, хотя на самом деле абстрактные типы не более абстрактны, чем стандартные типы (точнее, стандартные типы так же абстрактны, как и АТД). Иногда используется термин «типы, определяемые пользователем», но и он не совсем точно отражает суть понятия, поскольку все типы, кроме встроенных, так или иначе определяются пользователем.
Обсудим смысл этого понятия. АТД, как и обычный тип данных, определяет множество значений и множество операций, применимых к этим значениям. Однако, в отличие от стандартных типов, реализация операций для абстрактного типа не встроена в язык программирования, а является частью определения АТД. При этом прикладные программы (они называются клиентами АТД) используют абстрактные типы так же, как стандартные типы, т.е. только через набор операций, определенных для этого АТД, и не имеют никакого доступа к его реализации.
Важно отметить, что таким образом можно расширять до бесконечности набор стандартных типов, добавляя именно те типы, которые нужны для решения тех или иных конкретных задач.
Подводя итог сказанному, можно считать АТД основными строительными конструкциями при разработке алгоритмов. В [12] АТД трактуются как исполнители. Имея заданный набор исполнителей с четко определенным набором операций, можно существенно сократить время разработки алгоритмов, при этом повысить надежность разработанного программного обеспечения.
АТД является полным, если он обеспечивает достаточно операций для того, чтобы все требующиеся пользователю действия с переменными этого типа (назовем их объектами) могли быть проделаны с приемлемой эффективностью. Полнота типа зависит от контекста использования. Если тип предполагается использовать в ограниченном контексте (например, одна программа), то должно быть обеспечено достаточно операций для этого контекста.
Если тип предназначен для общего использования, желательно иметь полный набор операций [13], который должен включать 3 класса различных операций.
 Примитивные конструкторы. Эти операции создают объекты соответствующего типа, не используя никаких объектов в качестве аргумента. Конструктор предшествует другим операциям.
Конструкторы и(или) модификаторы. Эти операции используют в качестве аргументов объекты соответствующего им типа и создают другие объекты такого же типа либо модифицируют заданные объекты. Это группа операций, которая используется для выполнения основной работы с объектами.
Селекторы и(или) индикаторы. Эти операции используют в качестве аргументов объекты соответствующего им типа и возвращают результат другого типа (например, логического). Они используются для получения информации об объектах или получения отдельных элементов составного значения, но никак не изменяют объекты.
Например, для простых целочисленных типов операции сложения и вычитания являются конструкторами, а операции инкремента и декремента  модификаторами, операции сравнения при этом являются индикаторами.
Задача определения необходимого набора операций АТД является очень ответственной. К счастью, имеется довольно обширный, хорошо проработанный набор АТД широкого назначения, которые считаются стандартными. Они хорошо освещены в литературе, поэтому их назначение и смысл операций понятны. Во многих языках имеются библиотеки, содержащие набор стандартных АТД универсального назначения, например, библиотека STL в C++, библиотека VCL в Delphi и т.д..
В следующих разделах стандартные АТД будут рассмотрены достаточно подробно. Отметим, что сложные АТД могут строиться на основе более простых, используя их так же, как и стандартные типы данных.
1.2.2. Спецификация и реализация АТД
Исходя из сказанного, понятно, что определение абстрактного типа данных должно состоять из двух четко отделенных друг от друга частей:
внешнее описание АТД, доступное клиентам (такое описание называют функциональной спецификацией или просто спецификацией);
реализация операций на конкретном языке программирования (или просто реализация), скрытая от клиентов.
Подчеркнем два основных преимущества отделения спецификации от реализации.
При разработке алгоритма использование АТД позволяет абстрагироваться от деталей реализации, сосредоточив максимум внимания на самом алгоритме. При этом достаточно иметь только спецификацию АТД, а его реализацию отложить до того момента, когда станут ясными все детали (язык и среда программирования, доступные ресурсы и т. д.).
Можно изменять внутреннюю реализацию АТД, не изменяя внешней спецификации, при этом все программы-клиенты останутся работоспособными. Таким образом, можно бесконечно совершенствовать отдельные детали реализации АТД, при этом автоматически будут улучшаться характеристики уже работающего программного обеспечения. Если учесть, что многие АТД используются большим количеством программ-клиентов, то понятно, что это очень перспективный способ разработки программного обеспечения.
Большинство языков программирования содержат средства для выполнения и спецификации, и реализации АТД, позволяя при этом четко отделить друг от друга спецификацию и реализацию, а также скрыть детали разработки. Например, в С++ для определения АТД можно использовать такие типы как класс (class) и структура (struct).
Однако есть и более абстрактные способы описания спецификации АТД, не привязанные к конкретному языку программирования. Вспомним, что на ранних этапах разработки программы вопрос выбора языка программирования может быть еще не решен. Более того, имея спецификацию на абстрактном формальном языке, можно затем реализовать один и тот же АТД на различных языках.
Один из наиболее распространенных способов описания основан на алгебраическом подходе [17]. Идея состоит в том, что определяется список операций данного АТД, при этом для каждой операции задается множество входных данных (аргументы) и множество выходных значений (результаты). Таким образом, множество значений определяется через множество операций. Для уточнения смысла каждой операции и для определения связей между различными операциями дополнительно задается совокупность формальных правил в виде алгебраических формул (их принято называть аксиомами). Семантику каждой операции можно задать дополнительно, используя так называемые тройки Хоара {Предусловие Операция Постусловие}, где предусловие и постусловие  логические выражения. Такую тройку можно рассматривать как формулу, при этом если предусловие истинно, то после выполнения операции гарантируется истинность постусловия. Более подробную информацию по формальному описанию АТД можно найти в [2].
В следующих главах алгебраический подход будет использоваться для определения наиболее важных абстрактных типов данных, что позволит отделить эти определения от синтаксиса конкретного языка программирования.
Рассмотрим теперь способы реализации АТД. Традиционный способ реализации предполагает выбор подходящих стандартных типов данных и реализацию каждой операции в виде отдельной подпрограммы. При этом большинство языков программирования позволяют отделить спецификацию от реализации. Например, в языке Pascal такое разделение можно обеспечить, используя модули, в языке С для определения спецификации можно использовать файлы заголовков. Важно обеспечить полное соответствие спецификации и реализации. Такой подход к реализации АТД называется структурным.
Современные языки программирования позволяют реализовать другой подход, получивший название объектно-ориентированного, предоставляя в распоряжение разработчика специальный тип данных  класс (в языке С++ объектно-ориентированный подход может быть реализован и с использованием структур). Классы идеально подходят для реализации АТД, поскольку соединяют данные (поля) и функции их обработки (методы). Различие между структурным и объектно-ориентированным подходом к реализации АТД показано на рис.1.5.
13 SHAPE \* MERGEFORMAT 1415Рис.1.1. Различные подходы к реализации АТД
В действительности объектно-ориентированный подход является развитием структурного подхода, поэтому не следует противопоставлять их друг другу. В примерах данного пособия будет использоваться объектно-ориентированный подход там, где это уместно. Во многих случаях будет использован структурный подход, чтобы привлечь максимум внимания к структурам данных и алгоритмам их обработки.
1.3. Структуры данных
1.3.1. Понятие структуры данных
Наиболее общим и фундаментальным понятием в определении данных является понятие структуры данных.
Определим структуру данных как совокупность элементов данных, между которыми установлены определенные отношения (связи) [14].
Это определение имеет столь общий характер, что может использоваться в различных случаях, имея несколько разный смысл. Рассмотренные выше конструируемые типы данных обычно относят к элементарным структурам данных. Возможно, по этой причине конструируемые типы данных часто называют структурированными, а запись в языке С называется структурой. В дальнейшем не будем путать структуры в смысле языка С со структурами данных в широком понимании.
Кроме структурированных типов данных имеется еще ряд структур данных, которые относят к элементарным или базовым, хотя в большинство языков программирования не встроена их явная поддержка. Эти структуры будут подробно рассмотрены в следующих разделах, а затем будут использоваться как элементарные кирпичики при конструировании более сложных структур данных или описании алгоритмов.
Структуры данных принято рассматривать на двух уровнях  логическом и физическом. На логическом (абстрактном или внешнем) уровне рассматриваются наиболее существнные признаки структуры, которые не зависят от способа внутреннего представления данных в памяти. Возможность анализа структуры данных на логическом уровне обеспечивает концепция АТД, рассмотренная выше.
На физическом (внутреннем) уровне рассматривается конкретный способ представления структуры в оперативной памяти. При этом принимается во внимание как способ хранения самих элементов данных, так и способ представления отношений между данными. В этом случае иногда используются такие термины как структура хранения или внутренняя структура.
Внутреннее представление структур данных оказывает очень сильное влияние на реализацию алгоритмов, поэтому рассмотрим этот вопрос подробнее.
1.3.2. Структуры хранения   непрерывная и ссылочная
Несмотря на широкое разнообразие структур данных, имеется всего два альтернативных принципа их внутренней организации:
непрерывная организация  размещение всех элементов структуры по порядку в одной непрерывной области памяти, в результате чего соседние элементы занимают соседние ячейки памяти (такой способ иногда называют векторной организацией, чтобы как-то отделить это понятие от понятия массива, хотя по сути это одно и то же);
ссылочная организация  отдельные элементы могут располагаться в памяти как угодно (теоретически), но каждый элемент хранит, кроме своего значения, ссылки на связанные с ним элементы (такие структуры часто называют связными или цепными).
Массивы (векторы) предоставляют очень простой и эффективный способ агрегации ячеек памяти, который не требует явного хранения связей между элементами данных. Тем не менее, отношения соседства подразумеваются исходя из способа размещения элементов.
В связных структурах связи хранятся явно в виде ссылок. Например, каждый элемент связной структуры может содержать ссылку на следующий (предыдущий) элемент или две ссылки на два соседних с ним элемента. Из этого следует, что каждый элемент такой структуры состоит из двух различных по значению частей: содержательной (информационной) и указующей (рис. 1.2). В содержательной части хранятся данные, для обработки которых и существует данная структура. В указующую часть помещаются ссылки на связанные элементы.

Рис. 1.2. Элемент связной структуры
Связные структуры могут быть реализованы двумя различными способами, в зависимости от способа реализации ссылок:
память под каждый элемент захватывается динамически, при этом ссылки на связанные элементы реализуются с помощью указателей;
память под всю структуру выделяется статически и организована в виде вектора (массива достаточных размеров), но элементы структуры расположены в массиве не по порядку, а как получится. При этом каждый элемент, кроме своего значения, хранит один или несколько индексов связанных с ним элементов.
Таким образом, ссылками могут быть либо указатели, либо индексы. Второй способ является единственным в языках программирования, где нет указателей, но может применяться и как альтернатива указателям там, где доступны оба варианта. В следующих разделах при рассмотрении вопросов реализации будут анализироваться все возможные варианты.
Сравним различные структуры хранения.
Организация данных в виде связных структур на основе указателей обеспечивает более эффективное использование памяти по сравнению с массивами в случае, когда даже ориентировочно заранее не известно количество данных. При использовании массива в такой ситуации приходится резервировать память с большим избытком. С другой стороны, связная структура требует дополнительной памяти для хранения указателей, которая иногда может превышать размер полезной информации;
Для вставки и удаления элементов в заданной позиции связной структуры не требуется передвигать элементы, как при использовании массивов, достаточно только поменять значения указующих полей соседних элементов. Это преимущество любой связной структуры, независимо от того, как она реализована.
Основным недостатком связных структур является отсутствие прямого доступа к элементам по индексу. Сам принцип организации связных структур порождает последовательный способ доступа к их элементам (продвижение по цепочке, начиная с самого крайнего элемента, до тех пор, пока не будет достигнут нужный элемент данных).
Сравнение показывает, что ни одна из структур хранения не является идеальной, поэтому довольно часто используются структуры данных, в которых комбинируются различные структуры хранения. Например, в известной библиотеке шаблонов STL (Standard Template Library) для языка С++ некоторые типы реализованы как связные списки, элементами которых являются массивы (допустим, тип vector). Не менее часто используются массивы, элементами которых являются списки. Например, такой способ используется в одном из вариантов реализации хеш-таблиц, которые будут рассмотрены в главе 5.
1.3.3. Классификация структур данных
Попробуем систематизировать полученные знания. Приведем несколько способов классификации структур данных, выделив существенные признаки.
Если рассматривать структуры данных на логическом уровне, не принимая во внимание их реализацию, то основным критерием для классификации является характер связей между элементами. Выделим три основных группы:
линейные;
иерархические;
многосвязные.
Иерархические и многосвязные структуры иначе называют нелинейными.
Если элементы структуры связаны линейными отношениями соседства, то такая структура является линейной (рис.1.3,а). Линейная структура обычно называется линейным списком или просто списком (связным списком, цепным списком).
Подробные сведения о линейных структурах данных содержатся в главе 2.

13 EMBED Visio.Drawing.6 1415
Рис.1.3. а). Линейная структура; б). Иерархическая структура; в) Многосвязная структура
Если элемент связной структуры содержит несколько следующих за ним элементов, но только один предыдущий элемент, то такое отношение является отношением иерархии, а структура называется иерархической (рис. 1.3, б). В этом случае вводятся понятия родительского и дочерних элементов (узлов). Иерархические структуры данных рассматриваются подробно в главе 3.
Возможны и более сложные отношения – многосвязные структуры, когда каждый элемент данных связан с произвольным количеством других элементов. Реализация таких отношений обычно сводится к комбинации массивов и связных структур.
Важным критерием классификации является способ доступа к данным:
прямой доступ по индексу;
последовательный доступ по ссылкам;
смешанные способы доступа.
На уровне рализации важную роль играет способ формирования структуры данных:
статический
динамический;
смешанный.
Например, массив  линейная структура с прямым доступом к данным, статически или динамически формируемая, линейный список на основе указателей  динамически формируемая линейная связная структура с последовательным доступом.

Перейдем теперь к обработке данных. Наиболее фундаментальным понятием в этой области является алгоритм.
1.4. Понятие алгоритма
Несмотря на все усилия исследователей, на сегодняшний день отсутствует единое исчерпывающе строгое определение алгоритма, поэтому приведем одно из неформальных определений.
Алгоритм  конечная последовательность инструкций, каждая из которых имеет чёткий смысл и может быть выполнена с конечными затратами ресурсов за конечное время. Составление алгоритма для решения задачи называется ее алгоритмизацией.
Имеется несколько синонимов для слова «алгоритм»  способ, метод, рецепт. Эти понятия очень близки по смыслу, но все-таки не идентичны понятию алгоритма. Уточним приведенное выше неформальное определение перечнем требований к алгоритму (часть требований вытекает из определения):
однозначность (определенность) каждая инструкция алгоритма должна быть понятна исполнителю алгоритма и иметь однозначное толкование. Здесь очень важно отметить, что любой алгоритм предназначен для конкретного исполнителя, поэтому должен содержать только инструкции; которые этим исполнителем могут быть выполнены.
конечность  решение задачи должно быть получено за конечное число шагов для любых входных данных. На практике это ограничение ставится еще жестче  алгоритм должен выполняться за ограниченное число шагов, которые могут быть выполнены за разумное время.
детерминированность  для одних и тех же входных данных алгоритм должен каждый раз выдавать один и тот же результат. (Замечание: если в алгоритме используются датчики случайных чисел, то, казалось бы, результаты будут получаться разные. Но на самом деле, эти числа можно тоже рассматривать как входные параметры);
корректность (правильность)  алгоритм должен давать правильное решение задачи при различных входных данных;
массовость (универсальность)  алгоритм обычно предназначен для решения не одной задачи, а множества задач, относящихся к некоторому классу (например, алгоритмы сортировки и поиска).
1.5. Введение в анализ алгоритмов
При использовании алгоритмов для решения различных практических задач возникает очень важная проблема выбора алгоритма, наиболее подходящего для решения конкретной задачи. В связи с этим необходимо иметь стройную систему сравнительных оценок алгоритмов. Рассмотрим основные понятия из теории анализа алгоритмов, которые потребуются при дальнейшем изложении.
1.5.1. Вычислительные модели
Алгоритмы создаются для конкретного исполнителя, и при разработке алгоритма разработчик ориентируется на его возможности – доступные ресурсы, операции и стоимости их использования. Все эти факторы учитываются в так называемой вычислительной модели [10].
В теоретических исследованиях, когда основной интерес представляет доказательство конечности алгоритма и его правильности, рассматривают вычислительные модели, удобные для анализа (но не для программирования!), с которыми легче работать математикам. Одна из наиболее известных таких моделей - машина Тьюринга.
При разработке и анализе "практических" алгоритмов чаще всего в качестве вычислительной модели рассматривается так называемая машина с произвольным доступом, которая, по существу, является моделью современного компьютера. В такой модели имеется оперативная память и один процессор, который последовательно выполняет инструкции программы в оперативной памяти. При этом задан набор инструкций, поддерживаемых процессором – арифметические операции, пересылка данных, управление выполнением программы. Каждая инструкция выполняется за константное время.
В модели с внешней памятью, кроме быстрой памяти ограниченного объёма, имеется также внешняя память, обращения к которой производятся значительно медленнее, а обмен данными идёт блоками фиксированного размера (страницами).
В некоторых задачах требуется модель многопроцессорной машины с возможностью распараллеливания вычислений и др.
Обычно можно преобразовать алгоритм, рассчитанный на одну вычислительную модель, в алгоритм для другой модели, решающий ту же саму задачу (правда, вследствие преобразований исходный алгоритм может измениться до такой степени, что превратится по существу в уже другой алгоритм).
1.5.2. Показатели эффективности алгоритма
Под эффективностью алгоритма понимается рациональное использование ресурсов заданной вычислительной модели [10].
Выделим основные показатели эффективности. Практически для любой вычислительной модели важнейшими показателями эффективности работы алгоритма являются время работы (трудоемкость) и размер используемой памяти.
Для модели с внешней памятью важным показателем является количество операций обмена между оперативной и внешней памятью, поэтому при разработке алгоритмов обработки данных, размещенных во внешней памяти, этот показатель учитывается в первую очередь.
Иногда приходится рассматривать и другие показатели – например, объём сетевого трафика между клиентом и сервером и т.д.
Часто для решения одной и той же задачи могут быть использованы различные алгоритмы. Выбор между ними выполняется в основном по критерию их эффективности. При этом актуальным является вопрос оценки эффективности алгоритма. Конечно, затраты ресурсов можно определить экспериментально, если алгоритм уже реализован. Но реализовывать все альтернативные алгоритмы экономически невыгодно. Кроме того, как правило, мы не в состоянии проверить работу алгоритма на всех возможных вариантах входных данных. По этой причине еще на этапе проектирования выполняется анализ предлагаемых алгоритмов.
1.5.3. Постановка задачи анализа алгоритмов
Основной задачей анализа алгоритма является получение зависимости того или иного показателя эффективности от размера входных данных (размера входа, размера задачи). Сразу возникает вопрос – как измерять размер входа? Это зависит от конкретной задачи. В одних случаях размером разумно считать число элементов на входе (например, поиск элемента в массиве или его сортировка). Иногда размер входа измеряется не одним числом, а несколькими (например, число вершин и число рёбер в графе). В некоторых случаях более естественно считать размером входа общее число бит, необходимое для представления всех входных данных. Последний способ рассматривается как основной в теории вычислений.
Зависимость времени выполнения алгоритма от размера задачи называется временнуй сложностью алгоритма, а зависимость необходимого размера памяти от размера задачи  пространственной (емкостной) сложностью алгоритма.
1.5.4. Время работы алгоритма
Пусть n – размер входа (для задач, где размер входа задаётся несколькими числами, рассуждения будут аналогичны). Определим более точно, что понимать под временем работы алгоритма. Поскольку алгоритм – это ещё не программа для конкретной вычислительной машины, время его работы нельзя измерять, например, в секундах. Обычно под временем работы алгоритма понимают число элементарных операций, которые он выполняет. При этом, если алгоритм записан на каком-то псевдокоде или языке высокого уровня, предполагается, что выполнение одной строки требует не более чем фиксированного числа операций (если, конечно, это не словесное описание каких-то сложных действий).
Примечание
Действуя более формально, мы могли бы записать алгоритм с помощью инструкций предполагаемой вычислительной модели, назначить каждой из них некую стоимость и вывести выражение для стоимости алгоритма. Однако, это достаточно трудоёмко и в большинстве случаев не требуется.
Время выполнения в худшем и среднем случае
Существуют алгоритмы, время работы которых зависит только от размера входных данных, но не зависит от самих данных (например, поиск суммы элементов заданного массива). Для таких алгоритмов можно вывести аналитическую зависимость времени выполнения от размера задачи T(n).
Однако большинство алгоритмов содержит ветвления, поэтому время их выполнения зависит не только от количества входных данных, но и от самих значений этих данных. Для сравнения таких алгоритмов обычно определяют время их выполнения для наихудшего или для среднего случая (время выполнения в наилучшем случае обычно представляет меньший интерес). В связи с этим говорят о времени выполнения алгоритма в наихудшем случае (т.е. максимальное время выполнения по возможным входным данным) и о времени выполнения в среднем. Время выполнения в среднем можно определить по-разному:
среднее время работы алгоритма по всем возможных вариантам входных данных;
ожидаемое время его работы по всем возможным вариантам входных данных с учетом вероятности их появления
Недостатки есть и у того, и у другого способов:
Первый способ не учитывает, что в реальных задачах данные часто распределены неравномерно.
При втором способе получается, что мы анализируем алгоритм не в общем виде, а применительно к некой предполагаемой области, для которой можно определить вероятности появления различных входных данных.
Чаще всего при анализе времени работы алгоритма ограничиваются наихудшим случаем. Причины этого в следующем:
Время выполнения в наихудшем случае обычно найти гораздо проще, чем в среднем.
Зная верхнюю границу, мы можем быть уверены, что алгоритм не будет работать дольше ни на каких входных данных.
Для многих алгоритмов плохие случаи (или близкие к ним) могут происходить очень часто.
Зачастую «средний случай» почти так же плох, как и наихудший. Например, сортировка вставками или любой другой квадратичный алгоритм сортировки.
Тем не менее, время выполнения в среднем также иногда анализируют – например, для тех алгоритмов, где оно существенно отличается от наиухудшего, и при этом вероятность появления «плохих» входных данных достаточно мала (алгоритм быстрой сортировки Хоара и др.)
Для примера найдём время выполнения алгоритма сортировки массива методом пузырька для худшего случая. Сортировка методом пузырька выполняется следующим образом. Двигаясь от конца массива к началу, мы на каждом шаге сравниваем очередные два соседних элемента. Если первый элемент больше второго, то меняем их местами. Таким образом, после первого прохода по массиву самый маленький элемент поднимется на самый верх массива и займёт нулевую позицию. Второй цикл сортировки выполняется для оставшейся части массива (без первого элемента), в результате следующий по величине элемент окажется в первой позиции массива, и т.д.
Отметим около каждой строки её стоимость (число операций) и число раз, которое эта строка выполняется.

void bubble(int *a, int n)
{ int i,j,temp;
1 for( i=0; i
2 for (j=n-1; j>i;j--)
3 if (a[j-1]>a[j])

4 { temp=a[j-1];

5 a[j-1]=a[j];

6 a[j]=temp;
}
}
стоимость


c1

c2

c3

c4

c5

c6

Число раз


N

13 EMBED Equation.3 1415
13 EMBED Equation.3 1415
13 EMBED Equation.3 1415
13 EMBED Equation.3 1415
13 EMBED Equation.3 1415


В худшем случае массив изначально упорядочен по убыванию, и условие в строке 3 всегда истинно, в результате чего строки 4-6 всегда выполняются.
Общее время работы получается следующим:
T(n)=c1n+c213 EMBED Equation.3 1415+(c3+c4+c5+c6)13 EMBED Equation.3 1415=
=c1n+c213 EMBED Equation.3 1415+(c3+c4+c5+c6)13 EMBED Equation.3 1415= (с2+c3+c4+c5+c6)n2/2+(c1+(c2-c3-c4-c5-c6)/2)n-c2
Как видим, даже для сравнительно несложного алгоритма расчет получается весьма трудоемким.
1.5.5. Асимптотические оценки сложности алгоритмов
В большинстве случаев нет необходимости находить точное число действий, выполняемых алгоритмом. Интерес представляет общий вид зависимости времени работы алгоритма от размера входных данных, стремящегося в пределе к бесконечности – т.е. асимптотическая временная сложность (аналогично можно рассматривать асимптотическую пространственную сложность). Так, для рассмотренного выше примера сортировки методом пузырька T(n) имеет вид an2+bn+c. Однако, можно огрубить данную зависимость ещё сильней и сказать, что T(n) имеет порядок n2. Слагаемые низшего порядка не учитываются, поскольку при больших входных данных они играют незначительную роль. Мультипликативную константу при старшем члене также обычно опускают. Причины этого в следующем:
Мультипликативная константа может зависеть от разных факторов, не связанных непосредственно с алгоритмом – например, от мастерства программиста, качества компилятора и других факторов.
Для подавляющего большинства известных алгоритмов эта константа находится в разумных пределах. Это означает, что алгоритмы, которые более эффективны асимптотически, оказываются более эффективными и при тех сравнительно небольших размерах входных данных, для которых они используются на практике.
Для примера рассмотрим два алгоритма сортировки. Пусть первый алгоритм выполняет сортировку массива из n чисел за 2·n2 операций, второй – за 100·n·log2n операций. Хотя при совсем маленьких значениях n первый алгоритм работает быстрее, с увеличением n второй алгоритм становится значительно более эффективным. Так, при n=10000 второй алгоритм работает в 15 раз быстрее первого, при n=100000 – в 120 раз, а при n=1000000 – более чем в 1000 раз.
Примечание
Асимптотические оценки всё-таки достаточно грубо характеризуют алгоритм, и в ряде случаев при анализе алгоритма (особенно при сравнении алгоритмов одного порядка сложности) стремятся получить и более точные оценки, например, сколько раз выполнится та или иная специфическая операция. Так, для алгоритмов сортировки часто рассматриваются такие характеристики, как число сравнений элементов и число замен.
Рассмотрим теперь данные понятия более строго, используя асимптотические обозначения, принятые в математике.
Точная асимптотическая оценка
·
Запись f(n)=
·(g(n)) (читается как “тэта от g от n”), где g(n) - некоторая функция, означает следующее: найдутся такие константы c1,c2>0 и такое число n0, что c1g(n)13 EMBED Equation.3 1415f(n)13 EMBED Equation.3 1415c2g(n) для всех n13 EMBED Equation.3 1415n0. Функция g(n) в этом случае является асимптотически точной оценкой для f(n). На рис. 1.4а. показана иллюстрация данного определения.

Рис .1.4. Иллюстрации к определениям f(n)=
·(g(n)), f(n)=O(g(n), f(n)=
·(g(n)) (рисунок взят из [10]).
Здесь и далее предполагается, что функции f и g неотрицательны по крайней мере для достаточно больших n.
Пример 1. Покажем, что (n+1)2=
·(n2). Нам нужно найти такие константы c1 и c2, что для всех достаточно больших n будут выполняться неравенства: c1n213 EMBED Equation.3 1415(n+1)2, (n+1)213 EMBED Equation.3 1415c2n2.
Возьмём c1=1, тогда первое неравенство, очевидно, верно для всех n13 EMBED Equation.3 14151. Возьмём c2=4. Тогда имеем:
(n+1)213 EMBED Equation.3 14154n2 13 EMBED Equation.3 1415(n+1)2-4n213 EMBED Equation.3 14150 13 EMBED Equation.3 1415(n+1-2n)(n+1+2n)13 EMBED Equation.3 14150 13 EMBED Equation.3 1415(1-n)(3n+1)13 EMBED Equation.3 14150.
Полученное неравенство также выполняется для всех n13 EMBED Equation.3 14151. Таким образом, действительно (n+1)2=
·(n2).
Пример 2. Покажем, что 3n13 EMBED Equation.3 1415
·(2n). Для этого убедимся, что неравенство 3n13 EMBED Equation.3 1415c22n не может выполняться для всех достаточно больших n ни для какого фиксированного c2. Имеем:
3n13 EMBED Equation.3 1415c22n 13 EMBED Equation.3 1415(3/2)n13 EMBED Equation.3 1415c2.
Какая бы большая ни была константа c2, выражение (3/2)n при достаточно больших n всё равно превысит её. Отсюда следует, что 3n13 EMBED Equation.3 1415
·(2n).
Используя аналогичные рассуждения, легко показать, что точная асимптотическая оценка времени выполнения рассмотренного выше алгоритма сортировки пузырьком T(n)=
· (n2) в наихудшем случае.
Верхняя асимптотическая оценка О
К сожалению, далеко не всегда для алгоритма легко получить точную асимптотическую оценку, и приходится удовлетворяться оценками менее точными. Говорят, что f(n)=O(g(n)) (читается “О большое от g от n”), если найдётся такая константа c>0 и такое число n0, что f(n)13 EMBED Equation.3 1415cg(n) для всех n13 EMBED Equation.3 1415n0 (рис. 1.4,б). Функция g(n) представляет собой верхнюю асимптотическую оценку функции f(n) (где f(n) - например, время работы или другая характеристика алгоритма).
Зная оценку времени выполнения алгоритма T(n)=O(g(n)), мы можем сказать, что число операций алгоритма не превышает g(n), умноженному на некоторую константу. Однако, мы не знаем, действительно ли алгоритм будет выполнять столько операций – возможно, на самом деле их значительно меньше. Например, для алгоритма сортировки пузырьком, рассмотренного выше, можно сказать, что T(n)=O(n4). Однако, оценка T(n)=O(n3) характеризует алгоритм более точно, а T(n)=O(n2) - ещё точней (она совпадает с точной асимптотической оценкой). При анализе алгоритма нужно стремиться получить верхнюю оценку как можно ниже, тогда она будет иметь практическое значение.
Нижняя асимптотическая оценка
·
Аналогично определяется нижняя асимптотическая оценка. Говорят, что f(n)=
·(g(n)), если найдётся такая константа c>0 и такое число n0, что f(n)13 EMBED Equation.3 1415cg(n)) для всех n13 EMBED Equation.3 1415n0 (рис. 1.4в). Зная, что временя выполнения алгоритма T(n)=
·(g(n)), мы можем сказать, что число операций алгоритма не меньше, чем g(n), умноженное на некоторую константу. Однако, данная характеристика не показывает, насколько действительно больше операций выполняет алгоритм в действительности. При анализе алгоритма нужно стремиться получить как можно более высокую нижнюю оценку.
Примечание
Иногда нижнюю границу определяют несколько по-другому [3]: говорят, что f(n)=
·(g(n)), если существует такая константа c, что f(n)13 EMBED Equation.3 1415cg(n)) для бесконечно большого количества значений n. Например, алгоритм, который выполняет n операций для чётных значений n и n2 для нечётных, с этой точки зрения будет иметь максимальную нижнюю границу
·(n2), тогда как для ранее данного определения – только
·(n). Однако, для большинства практических алгоритмов оценки совпадают.
Точная асимптотическая оценка времени работы алгоритма находится где-то между его нижней и верхней оценками. В частности, несложно показать, что если T(n)=O(g(n)) и T(n)=
·(g(n)), то T(n)=
·(g(n)).
Исторически так сложилось, что верхняя асимптотическая оценка О была введена намного раньше оценок
· и
· (учебник Бахмана по теории простых чисел, 1892 год). Две последних асимптотических оценки были введены Дональдом Кнутом. Возможно, в связи с этим в литературе по программированию чаще используется оценка О, даже в тех случаях, когда она совпадает с точной асимптотической оценкой
·. В дальнейшем изложении мы будем использовать все асимптотические оценки, отдавая предпочтение точной оценке
·, если это не связано с чрезмерно большими вычислительными затратами.
Наиболее часто встречающиеся асимптотические оценки
В следующей таблице приведены некоторые наиболее часто встречающиеся асимптотические оценки сложности. Для каждой из них также приводится примерное время работы соответствующей программы на некоторых входных данных, а также его изменение при увеличении размера входных данных в 10 раз (будем считать, что 10 миллионов простых операций выполняются примерно за 1 секунду).
Таблица1.1
Типичные временные оценки сложности
Оценка
Пояснение и примеры типичных задач
Время работы при n=10
Время работы при n=100


·(1)
Время выполнения алгоритма не зависит от объёма входных данных
100 нс
100 нс


·(log n)
Логарифмическое время. Например, двоичный поиск, поиск в сбалансированном дереве и др.
332 нс
664 нс


·(n)
Линейное время. Например, каждый элемент массива обрабатывается постоянное число раз.
1 мкс
10 мкс


·(n
·log n)
Обычно характерно для программ, использующих стратегию ”разделяй и властвуй”, например, алгоритмы быстрой сортировки или сортировка слиянием
3,32 мкс
66,44 мкс


·(nk)
Полиномиальная сложность. Большое число разнообразных алгоритмов.
10 мкс
(для k=2)
1 с
(для k=2)


·(kn)
Экспоненциальная сложность. Характерна для переборных алгоритмов.
102 мкс
(для k=2)
4,2·1015 лет
(для k=2)


·(n!)
Факториальная сложность. Также может встретиться в переборных алгоритмах.
0,363 с
1,08·10146 лет


Отметим, что в логарифмической асимптотической оценке основание логарифма принято не указывать, однако в тех случаях, когда при сравнении двух алгоритмов окажется, что оценки времени их выполнения отличаются только основанием логарифма, предпочтение явно следует отдать алгоритму, у которого в оценке основание логарифма больше. На практике очень часто встречается основание логарифма 2.
1.6. Анализ рекурсивных алгоритмов
1.6.1. Рекурсия и итерация
Рекурсия  это такой способ организации вычислительного процесса, при котором подпрограмма в ходе выполнения обращается сама к себе.
Рекурсия широко применяется в математике. В качестве примера дадим рекурсивное определение суммы первых n натуральных чисел. Сумма первых n натуральных чисел равна сумме первых (n – 1) натуральных чисел плюс n, а сумма первого числа равна 1. Или: Sn = Sn-1 + n; S1 = 1.
Напишем функцию, которая вычисляет сумму, пользуясь данным определением:
int sum(int n)
{ if (n==1) sum=1; else sum=sum(n-1)+n;
}
Обратим внимание на следующие обстоятельства.
Рекурсивная функция содержит всегда, по крайней мере, одну терминальную ветвь и условие окончания (if (n==1) sum=1).
При выполнении рекурсивной ветви (else sum=sum(n-1)+n) процесс выполнения функции приостанавливается, но его переменные не удаляются из стека. Происходит новый вызов функции, переменные которой также помещаются в стек и т.д. Так образуется последовательность прерванных процессов, из которых выполняется всегда последний, а по окончании его работы продолжает выполняться предыдущий процесс. Целиком весь процесс считается выполненным, когда стек опустеет, или, другими словами, все прерванные процессы выполнятся.
Большинство алгоритмов можно реализовать двумя способами: итерацией (т. е. с помощью цикла) и рекурсией. Так, приведенный пример с суммой легко реализуется при помощи цикла, причем это решение более эффективное, т. к. не требует дополнительных расходов стековой памяти. Вообще, если задача имеет очевидное нерекурсивное решение, то следует избрать именно его.
1.6.2. Пример анализа рекурсивного алгоритма
В качестве более интересного примера применения рекурсии рассмотрим следующую задачу. Требуется возвести число a в степень b (a и b – натуральные). Если решать данную задачу «в лоб», то нам потребуется выполнить b умножений: ab=a·a·a·a (b раз). Однако,
13 EMBED Equation.3 1415
Например, можно вычислять ab, используя следующие соображения:13 EMBED Equation.3 1415- итого всего 4 операции умножения, а не 7.
Несложно написать рекурсивную функцию, выполняющую вычисления по данной формуле:
unsigned int pow(unsigned int a, unsigned int b)
{ if (b==1) return a; //терминальная ветвь
else
if (b % 2 == 0)
{ unsigned int p = pow(a,b/2);
return p*p;
}
else return pow(a,b-1)*a;
}
Оценим время выполнения данного алгоритма в худшем случае.
При выполнении функции pow число b, передаваемое при рекурсивном вызове, либо делится на 2 (если оно чётное), либо уменьшается на 1 (если оно нечётное) и тем самым становится чётным. Отсюда можно сделать вывод, что после двух последовательных рекурсивных вызовов число b уменьшится не менее чем в два раза. Рекурсия остановится, когда b станет равным 1. Таким образом, если k-общее число вызовов, то получается следующее соотношение:
13 EMBED Equation.3 1415,
откуда k=2log2b. Поскольку другие операции внутри рекурсивной функции выполняются за константное время, то, пренебрегая мультипликативной константой 2 и основанием логарифма, получим точную асимптотическую оценку T(b)=
· (logb), где b  степень (целое число), в которую возводится целое число a.
Отметим, что наименьшее число операций алгоритм будет выполнять, если b является степенью двойки. В этом случае при каждом рекурсивном вызове аргумент будет уменьшаться в два раза. Тогда общее число вызовов k найдётся так:
13 EMBED Equation.3 1415
Отсюда заключаем, что асимптотическая оценка для наилучшего случая совпадает с оценкой для наихудшего случая, причем это точная асимптотическая оценка, т. к. она получена на основе точно рассчитанного выражения для оценки времени выполнения алгоритма путем отбрасывания мультикативной константы.
Несложно показать, что аналогичная оценка выполняется и для памяти: M(b)=
·(logb) – при каждом рекурсивном вызове в стек помещается некоторое постоянное число байт – локальные переменные и параметры функции.
1.7. Первые примеры
В качестве первых примеров подобраны небольшие задачи-этюды, решение которых не требует никаких специальных знаний, но позволяет освоить или закрепить некоторые полезные приемы программирования, которые могут найти применение и при решении серьезных задач. Они сгруппированы по темам.
1.7.1. Введение в «длинную» арифметику
Иногда возникает необходимость выполнять вычисления над очень большими числами, которые не входят ни в один из стандартных числовых типов (вспомним, что тип  это множество допустимых значений и набор допустимых операций). В таком случае приходится придумывать какой-либо способ хранения таких больших чисел. Разумеется, весь набор арифметических операций в этом случае приходится реализовывать самостоятельно, так и возник термин «длинная» арифметика.
Наиболее простой способ хранения большого целого числа, который чаще всего и применяется,  использование целочисленного одномерного массива, каждый элемент которого хранит одну десятичную цифру числа. В этом случае реализация основных арифметических операций сводится к задаче обработки массивов и требует только хорошей техники программирования и аккуратности.
Для примера возьмем небольшую задачу:
Получить наименьшее натуральное число, обладающее следующими свойствами:
Оканчивается на 5.
При умножении его на 5 получается то же самое число, что и при переносе цифры 5 с последнего места на первое.
Решение задачи очевидно нужно выполнить умножение на 5 так, как оно обычно выполняется на бумаге столбиком, при этом цифра за цифрой будет получено все число, наычиная с младших разрядов. Процесс умножения останавливается, как только очередная цифра числа окажется равной единице при отсутствии переноса из предыдущего разряда  при умножении ее на пять как раз и получим пять.
#include
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·Примеры рекурсивных алгоритмов
Пример 1. Последовательность чисел Фибоначчи определяется так:a(0)= 1, a(1) = 1, a(k) = a(k-1) + a(k-2) при k >= 2. Первые члены этой последовательности: 1 1 2 3 5 8 13 21 и т. д. Дано n, вычислить a(n).
Задача имеет очевидное нерекурсивное решение, имеющее сложность C*n. При больших n вычисления могут занять много времени, поэтому приведем быстрый рекурсивный алгоритм решения этой задачи. Пара соседних чисел Фибоначчи получается из предыдущей умножением на матрицу
|1 1|
|1 0|
(проверьте сами), так что задача сводится к возведению матрицы в степень n. Это можно сделать за C*log n действий при помощи примерно такого же рекурсивного алгоритма, что и для возведения числа в степень (см. разд. 1.6.2).
#in
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
{ memcpy(tmp,t,4*sizeof(int)); power(t,n-1); mult(tmp,t,tmp2);
memcpy(t,tmp2,4*sizeof(int));
}
}
int main(void)
{ Matr t = {1,1,1,0};
power(t,10); cout << t[0][0] << endl;
return 0;
}
Многие задачи, требующие перебора различных вариантов, имеют компактное рекурсивное решение. Приведем пример одной из таких задач.
Пример 2. Перечислить все представления положительного целого числа n в виде суммы последовательности невозрастающих целых положительных слагаемых. Например, для числа 3 таких представлений будет всего два:
2+1 и 1+1+1.
#include
int a[1000];
void summa(int n, int max, int k)
{ if (n for (int i=max; i>0; i--)
{ a[k]=i;
if (i==n)
{ for (int i=0; i cout< }
else summa(n-i,i,k+1);
}
}
void main()
{ int n;
cout<<"Введите число N: "; cin>>n;
summa(n,n,0);
}
1.7.3. Поразрядные операции. Реализация АТД «Множество»
Последний пример имеет несколько большие размеры и демонстрирует один из вариантов реализации абстрактного типа «Множество» (считаем, что основной набор операций, выполняемый над множествами, известен.). Имеются различные способы реализации данного абстрактного типа. Приведем реализацию множества из ограниченного количества элементов на основе битового массива (аналог типа set в Паскале). Как и в Паскале, ограничим множество 256 элементами (например, множество символов). Следовательно, битовый массив должен иметь размер 256 бит или 32 байта, поэтому будем использовать массив из 32 элементов размером 1 байт (используем тип unsigned char). Значение каждого бита равно 1, если соответствующий символ присутствует в множестве, и 0 – в противном случае.
Большинство языков программирования содержит набор поразрядных операций, т. е. операций, которые выполняются над каждым битом операндов по отдельности. Так, в языке С++ имеются операции поразрядного логического И (&), ИЛИ (|), НЕ (~) и поразрядные сдвиги содержимого ячейки памяти вправо (>>) или влево (<<) на один разряд. Эти операции выполняются очень быстро, поэтому желательно использовать их везде, где представляется такая возможность.
Для доступа к конкретному элементу множества необходимо сначала вычислить номер элемента массива и номер бита, соответствующего заданному символу. После этого мы можем производить поразрядные операции над элементом.
Допустим, нам надо установить в 1 третий бит (биты нумеруются справа налево, начиная с нуля), оставив все остальные биты в элементе неизменными. Для этого выполним над данным элементом поразрядную операцию ИЛИ с числом 8 (в двоичном представлении 00001000). Для установки этого же бита в 0 необходимо произвести поразрядную операцию И с двоичным числом 11110111, которое соответствует инверсии числа 8 (~8). Для того, чтобы посмотреть значение третьего бита, выполняем операцию И с числом 8.
Для доступа к другим битам вместо числа 8 используются другие степени двойки. Удобно записать эти значения в специальный массив, в приведенном листинге это массив mask.
#include
typedef unsigned char CHAR; // этот тип включает значения от 0 до 255
const CHAR mask[8]={1,2,4,8,16,32,64,128};
struct set
{ CHAR data[32]; // массив из 32 байт или 256 бит
set();
void add(CHAR C); // добавляем символ C в множество
void addstr(CHAR *s); // добавляем символы строки s в множество
void del(CHAR C); // удаляляем символ C из множества
void delstr(CHAR *s); // удаляем символы строки s из множества
bool in(CHAR C); // возвращает true, если символ С есть во множестве
};
// реализация функций
set::set()
{ for (int i=0; i<32; i++) data[i]=0; // делаем множество пустым
}
void set::add(CHAR C)
{ CHAR n=C/8, k=C%8;
// устанавливаем в 1 k-й бит n-го элемента
data[n] = data[n] | mask[k];
}
void set::addstr(CHAR *s)
{ CHAR n,k;
for (int i=0; i}
void set::del(CHAR C)
{ CHAR n=C/8, k=C%8;
// устанавливаем в 0 k-й бит n-го элемента
data[n] = data[n] & (~mask[k]);
}
void set::delstr(CHAR *s)
{ CHAR n,k;
for (int i=0; i}
bool set::in(CHAR C)
{ CHAR n=C/8, k=C%8;
return (data[n] & mask[k]) > 0;
}
// внешние функции для работы с множествами
set join(set s1,set s2) // объединяем множества s1 и s2
{ set s;
for (int i=0; i<32; i++) s.data[i] = s1.data[i] | s2.data[i];
return s;
}
set intersect(set s1,set s2) // пересекаем множества s1 и s2
{ set s;
for (int i=0; i<32; i++) s.data[i] = s1.data[i] & s2.data[i];
return s;
}
set dif(set s1,set s2) // вычитаем s2 из s1
{ set s;
for (int i=0; i<32; i++) s.data[i] = s1.data[i] & (~s2.data[i]);
return s;
}
void print(set s) // выводим символы множества s на экран
{ CHAR c;
for(c=0; c<255; c++) if (s.in(c)) cout< c=255; if (s.in(c)) cout<}
void main()
{ set s1,s2;
s1.addstr("abc"); s2.addstr("def"); s2.add('g');
s1=join(s1,s2); print(s1);
s2.delstr("ghij"); print(s2);
set s3=intersect(s1,s2); print(s3);
s1=dif(s1,s3); print(s1);
}

2. Линейные структуры данных
К линейным структурам данных относятся различные виды линейных списков.
Линейный список представляет собой такой способ организации совокупности элементов одного типа, при котором, каждый элемент, кроме первого, имеет одного предшественника (предыдущий элемент) и каждый элемент, кроме последнего, имеет одного преемника (следующий элемент). Если последний элемент имеет в качестве преемника первый элемент, а первый в качестве предшественника  последний, то такой список называют кольцевым (циклическим).
Специальными видами линейных списков с ограниченным набором операций являются стеки, очереди, деки. С них удобно начать изложение, поскольку набор базовых операций в этих структурах существенно ограничен по сравнению с произвольными списками, а реализация этих операций не должна вызвать затруднений. Между тем эти структуры используются при решении различных задач, в частности, многие алгоритмы, которые будут рассматриваться в следующих разделах пособия, будут опираться именно на применение стеков или очередей как элементарных структур данных.
Далее будет рассмотрено два основных подхода к обработке линейных списков  итеративная обработка списка с выделенным текущим элементом и рекурсивная обработка списка. При изложении первого подхода отдельно рассмотрим однонаправленные и двунаправленные списки (Л1-списки и Л2-списки [12]), а также особенности кольцевых списков.
Изложение материала строится так. Сначала каждая структура рассматривается на логическом уровне как абстрактный тип данных. Затем рассматриваются различные способы реализации с небольшими фрагментами программного кода на С++. В заключение приводятся примеры программ, использующих данные структуры.
2.1. АТД "Стек", "Очередь", "Дек"
2.1.1. Функциональная спецификация стека
Стек (stack) это специальный тип списка, в котором все вставки и удаления элементов выполняются только на одном конце, называемом вершиной (top). Такой метод доступа обозначается аббревиатурой LIFO (last-in-first-out последним пришел первым вышел).
Основные операции со стеками имеют сложившиеся названия, которыми мы, естественно, воспользуемся. Выделим пять операций (базовых функций), считая, что любые действия со стеками можно выполнить, используя эти операции (и только их).
Создание пустого стека  операция Create;
Проверка стека на наличие в нем хотя бы одного элемента  предикат (логическая функция) IsNull.
Получение элемента из вершины непустого стека  операция GetTop;
Удаление элемента с вершины непустого стека (элемент выталкивается из стека)   операция Pop;
Вставка значения х в вершину стека (элемент заталкивается в стек)  операция Push.
Операция Сreate представляет собой примитивный конструктор, операция IsNull  индикатор, GetTop  селектор, операции Push и Pop  модификаторы.
Хотя семантика этих операций интуитивно понятна из их неформального описания, перед тем, как начать реализацию, рассмотрим формальную спецификацию каждой операции, применив алгебраический подход [2].
Спецификацию каждой операции представим в виде:
операция: множество входных данных ( множество выходных данных
Множество входных или выходных данных задается с помощью указания их типа, при этом, если операция имеет несколько аргументов или результатов, при задании спецификации будет использоваться символ (, обозначающий декартово произведение множеств.
Пусть стек состоит из элементов типа
·. Введем новый тип данных  Stack of
· ( Stack ( 
· ). Непустой стек обозначим как Non_null_stack.
Базовый набор операций определяется так:
Create: ( Stack ( 
· ) ;
IsNull:  Stack ( 
· ) ( Boolean ;
GetTop:   Non_null_stack ( 
· ) ( 
· ;
Pop:  Non_null_stack ( 
· ) ( Stack ( 
· ) ;
Push:  
· ( Stack ( 
· ) ( Stack ( 
· )
Дополним данное определение набором аксиом, которые справедливы для вышеуказанных операций. Пусть p  значение типа
·; s stack ( 
· ):
A1. IsNull ( Create ) = true ;
A2. IsNull ( Push ( p , s ) ) = false ;
A3. GetTop ( Push ( p , s ) ) = p ;
A4. Pop ( Push ( p , s ) ) = s ;
A5. Push ( GetTop ( s ) , Pop ( s ) ) = s.
Часто при определении стека вместо операции Pop используют операцию, совмещающую результат действия GetTop и Pop. Обозначим такую операцию Pop2. Тогда
Pop2:  Non_null_stack ( 
· ) ( 
· ( Stack ( 
· ).
2.1.2. Функциональная спецификация очереди
Очередь (queue) - это другой специальный тип списка, где элементы вставляются в конец списка, называемый хвостом (tail), а удаляются из начала списка, называемого головой (head). Очереди также называют "списками типа FIFO" (аббревиатура FIFO расшифровывается как first-in-first-out: первым вошел  первым вышел). Операции, выполняемые с очередями, аналогичны операциям со стеками.
К сожалению, для обозначения операций нет стандартных устоявшихся названий. Воспользуемся обозначениями, которые предлагаются в книгах Ахо и Седжвика.
Создание пустой очереди   операция create
Проверка очереди на наличие хотя бы одного элемента  предикат (логическая функция) isnull.
Вставка значения p в конец (хвост) очереди  операция enqueue;
Удаление элемента из начала (головы) непустой очереди с получением его значения  операция dequeue. Эту операцию можно рассматривать и как две различные операции: head  получение первого элемента из головы очереди и собственно dequeue  удаление элемента.
Формальная спецификация операций для очереди из элементов типа
· ( Queue of
· ( Queue ( 
· ) ) [2] выполняется аналогично спецификации стека.
1. Create: ( Null_queue ( 
· ) ;
2. IsNull: Queue ( 
· ) ( Boolean ;
3. EnQueue: Queue ( 
· ) (
· ( Queue ( 
· );
4. GetHead: Non_null_queue ( 
· ) ( 
·;
5. DeQueue: Non_null_queue ( 
· ) ( Queue ( 
· ).
Приведем набор аксиом, справедливых для данных операций. Пусть p  значение типа
·; q queue ( 
· ):
A1. IsNull ( Create ) = true ;
A2. IsNull ( EnQueue ( q , p ) ) = false ;
A3. GetHead ( EnQueue ( q , p ) ) =
если IsNull ( q ) то p иначе Head ( q );
A4. DeQueue ( EnQueue ( q , p ) ) = 
если IsNull(q) то Create иначе EnQueue(DeQueue(q),p).
2.1.3. Деки
Иногда используют списки, в которых вставка и удаление элементов выполняются с обоих концов. Такой список называется деком. (сокращение от английского double ended queue-очередь с двумя концами).
Дек представляет собой универсальную структуру данных, которая может функционировать и как стек, и как очередь. В качестве частных случаев различают деки с ограниченным вводом, в которых на одном конце не разрешена вставка, и деки с ограниченным выводом, где не разрешено удаление на одном из концов.
Формальную спецификацию дека можно выполнить аналогично стеку или очереди.
2.1.4. Общие замечания по реализации АТД
Прежде чем перейти к реализации конкретных АТД, сделаем несколько общих замечаний.
Реализация АТД, в отличие от его формальной спецификации, существенно зависит от выбранного языка программирования и подхода к реализации. Язык С++ предоставляет программисту различные механизмы реализации АТД:
отдельно определить стуктуру данных и базовые функции  структурный подход;
определить АТД в виде структуры (struct) или класса (class), при этом базовые функции включить в состав данной структуры или класса как методы  объектно-ориентированный подход.
Объектно-ориентированный подход является более предпочтительным. В данной главе будем использовать структуры (struct), включающие операции (базовые функции) как методы. При таком подходе операция создания пустого стека, очереди или дека (в спецификации Create) обычно реализуется как конструктор  специальный метод, который автоматически выполняется при создании переменной и имеет имя, совпадающее с именем структуры.
Примечаение
Заметим, что при реализации линейных структур на основе массивов память под массив можно захватить динамически в конструкторе, при этом появится возможность управлять максимальными размерами (в языке С++ разрешены конструкторы с параметрами). Оставим это для самостоятельной реализации.
В формальной спецификации АТД предполагается, что его элементы могут иметь любой тип (но обязательно один и тот же). В языке С++ имеется два способа сделать описание структуры независимым от типа элементов оператор typedef и шаблоны (template).
Шаблоны  это кардинальное средство сделать код независимым от типа данных, поскольку на основе одного шаблона можно определять сколько угодно конкретных классов или структур, отличающихся только типами элементов. Однако исходный код на С++ при использовании шаблонов содержит дополнительные конструкции, которые затрудняют его восприятие и отвлекают от алгоритма.
С этой точки зрения оператор typedef представляет компромиссный вариант, поскольку он все-таки предполагает присутствие конкретного типа элементов, но только в самом операторе typedef. Для изменения типа достаточно поменять только этот оператор typedef (можно вообще поместить этот оператор в отдельный файл, который подключать с помощью директивы #include).
В примерах будут продемонстрированы оба подхода, но для лучшей читаемости кода в основном будет использоваться оператор typedef. Заметим, что в С++ имеется специальная библиотека STL (Standart Template Library), содержащая шаблонные классы для наиболее востребованных структур данных, поэтому большинство примеров реализации АТД, приведенных далее, следует рассматривать всего лишь как демонстрацию рассматриваемых подходов и алгоритмов.
При реализации АТД важную проблему представляет обработка аварийных ситуаций, которая может выполняться по-разному. В примерах реализован самый простой способ  сообщение об ошибке посылается в стандартный выходной поток cerr и выполнение программы прекращается. Можно предложить и другие варианты, например, использование исключительных ситуаций (в С++ конструкция try catch), самое главное  не допустить неправильной работы функций в подобных крайних случаях.
2.2. Реализация стеков
2.2.1. Непрерывная реализация стека с помощью массива
Если максимально возможное количество элементов в стеке реально ограничено каким-либо значением, можно предложить самый простой способ реализации  массив. В этом случае для стека выделяется непрерывная область памяти ограниченных размеров. Фактическое количество элементов в стеке в произвольный момент времени может быть намного меньше заданного граничного значения, но ни в коем случае не может быть больше. Поэтому при добавлении элементов в стек обязательно следует предусмотреть обработку аварийной ситуации, связанной с его переполнением.
Рис. 2.1. поясняет работу со стеком на основе массива из maxlength элементов. Идея проста  достаточно завести дополнительный указатель top на вершину стека (или индекс элемента, который в данный момент является вершиной). Заметим, что в реализации на языке С при работе с массивами удобно использовать указатели, при этом имя массива, на основе которого реализован стек, является указателем на самый первый элемент стека («дно» стека). Нулевое значение указателя top соответствует пустому стеку. В случае использования индексов, а не указателей, признаком пустого стека может быть любое предопределенное значение, например отрицательное число.
13 EMBED Visio.Drawing.6 1415
Рис.2.1. Представление стека с помощью массива
Алгоритмы для реализации операций удаления и вставки элементов просты и сводятся к изменению значения указателя top на единицу. При этом, конечно, нельзя забыть об обработке аварийных ситуаций, связанных с добавлением элемента в переполненный стек или попыткой извлечения элемента из пустого стека.
При определении структуры stack вначале зададимся максимальным размером массива и определим тип данных для элементов type_of_data, что позволит сделать определение самой структуры вполне универсальным.
const int maxlength=100; // максимальный размер (любой)
typedef char type_of_data; // тип элементов(любой)
struct stack
{ type_of_data data[maxlength]; // массив под стек
type_of_data *top; // указатель на вершину стека
// базовые функции для работы со стеком
stack() {top=NULL;}// конструктор, создание пустого стека
void push (type_of_data x); // поместить значение x в стек
type_of_data gettop();// получить элемент с вершины стека
void pop(); // извлечь элемент с вершины стека
bool isnull() {return top==NULL;}//проверить, пуст ли стек
// дополнительная функция
void makenull(){stack();} // сделать стек пустым
};
Здесь конструктор stack() используется в качестве базовой функции create для создания пустого стека. Дополнительная функция makenull() обычно включается в состав методов для работы со стеком, но она не является базовой, т. к. может быть реализована через другие функции. В данном случае она легко реализуется явным вызовом конструктора stack().
Приведем реализацию функций push() и pop(). Напомним, что знак :: обозначает принадлежность метода структуре stack().
void stack::push (type_of_data x)
{ if (isnull()) { top=data; *top=x;} //стек был пуст
else
if (top-data else
{ cerr << "Стек полон\n"; exit(2); // аварийная ситуация
}
}
void stack::pop ()
{ //проверим, не пуст ли стек и обработаем аварийную ситуацию
if (isnull()) {cerr << "Стек пуст \n"; exit(1);}
if (top==data) makenull(); else top--; // удалили элемент
}
Реализация функции gettop элементарна, однако ее вызов в случае пустого стека приводит к той же аварийной ситуации, что и в функции pop.
Обычно определение структуры и реализацию методов располагают в двух разных файлах, например, stack.h и stack.cpp, которые затем можно подключить к любому проекту на С++. Для использования стека достаточно объявить переменную типа stack, а затем обращаться к методам стека, например:
stack s;
s.push(’a’); // поместили один символ на вершину стека
При использовании других типов данных для элементов стека требуется изменить оператор typedef в определении стека.
Приведенный выше способ реализации имеет один существенный недостаток, который, возможно, не сразу бросается в глаза. Дело в том, что все элементы структуры struct являются общедоступными, если для них явно не указана область видимости. А это означает, что программа-клиент, использующая реализованный выше стек, может напрямую обратиться к элементам массива data в обход базовых функций стека.
Чтобы исключить принципиальную возможность подобных действий со стороны программ-клиентов, необходимо явно определить область видимости private для массива data и указателя top. Заметим, что если в приведенном выше определении стека заменить тип struct на class, то программы-клиенты вообще не смогут использовать ни один из методов, поскольку элементы класса по умолчанию имеют область видимости private (в этом принципиальная разница типов class и struct). Поэтому при использовании класса явное определение public для методов обязательно.
Оставим эти усовершенствования для самостоятельных упражнений.
2.2.2. Ссылочная реализация стека в динамической памяти
Более гибким и экономичным способом с точки зрения затрат памяти является ссылочная реализация стека. В этом случае память под каждый новый элемент, помещаемый на вершину стека, выделяется динамически, поэтому теоретически размер стека не ограничен (на практике, конечно, существуют разумные ограничения). При удалении элемента занимаемая им память немедленно освобождается, таким образом, вся структрура занимает ровно столько ячеек памяти, сколько ей требуется в данный момент. Но не нужно забывать, что каждый элемент стека требует больше памяти, чем при реализации с помощью массива, так как в каждом элементе нужно хранить еще и указатель на предыдущий элемент.
Рисунок 2.2. поясняет идею cсылочной реализации стека. Отдельный указатель содержит адрес самого последнего элемента, который будет вершиной стека ( top).
13 EMBED Visio.Drawing.6 1415
Рис.2.2. Ссылочная реализация стека с помощью списка в динамической памяти
Определим структуру элемента стека, продемонстрировав применение шаблонов для указания произвольного типа содержательной части:
template // шаблонный тип элементов стека
struct item //структура каждого элемента стека
{ T data; // данные
item *prev; // указатель на предыдущий элемент
};
Как видно из рисунка 2.2, для реализации базовых функций стека требуется только один указатель на вершину (top). В остальном определение структуры выполняется аналогично определению предыдущей реализации на основе массива, небольшие отличия в синтаксисе связаны с использованием шаблонов.
template struct stack //структура стека
{ item *top;
// базовые функции для работы со стеком
stack() {top=NULL;} //конструктор, создание пустого стека
void push (T x); // поместить значение x в стек
void pop();// извлечь элемент с вершины стека
T gettop();// получить вершину стека
bool isnull() {return top==NULL;} // проверка на пустоту
void makenull(); // дополнительная функция очистки стека
};
При ссылочной организации стека добавление и удаление элементов требует дополнительных операций по выделению и освобождению памяти.
Для вставки необходимо:
захватить память под новый элемент, заполнить данными содержательную часть;
указующей части присвоить значение указателя на вершину;
присвоить указателю на вершину адрес нового элемента (он теперь будет вершиной).
template void stack::push(T x)
{ item *p=new item;
p->data=x; p->prev=top; // добавили элемент
top=p; // изменили указатель на вершину top
}
При извлечении элемента необходимо:
изменить указатель на вершину стека (теперь вершиной будет предпослений элемент);
освободить память, которую занимал последний элемент;
template void stack::pop ()
{ if (isnull()) {cerr<< "Стек пуст \n"; exit(1);};
item *p=top; top=top->prev; // изменили указатель top
delete p; // удалили элемент
}
Возвратить последний элемент из буфера не представляет труда:
template T stack::gettop()
{ if (isnull()) {cerr<< "Стек пуст \n"; exit(1);};
return top->data;
}
Дополнительная функция очистки стека реализуется через базовые:
template void stack::makenull()
{ while (!isnull()) pop(); // удаляем элементы по очереди
}
При использовании данной реализации описание конкретного стека с элементами, например, типа char, будет иметь вид
stack имя;
В прикладных программах-клиентах, использующих стеки, можно пользоваться любой реализацией, при этом будем иметь одинаковую функциональность. В дальнейшем будем считать, что шаблон стека находится в файлах stack.h (интерфейс) и stack.cpp (реализация), который можно подключать к различным программам, использующим стек как элементарную структуру данных.
2.2.3. Примеры программ с использованием стеков
Стеки широко используются при разработке трансляторов, например, при анализе и вычислении арифметических выражений. Эта задача является предметом отдельного курса, но в качестве введения в проблему приведем компактные примеры анализа и преобразования скобочного выражения.
Пример 1.
Приведенная программа проверяет правильность расстановки скобок в заданной строке текста, длина которой в принципе не ограничена. При этом просматриваются все символы скобок: круглых, квадратных и фигурных. Учитывается, что различные скобки могут быть вложены одна в другую. Список из открывающихся скобок строится по принципу стека. Это значит, что та скобка, которая была помещена в стек последней, будет извлечена из него первой, и легко проверить, соответствует ли закрывающая скобка своей открывающей. Все остальные символы программа игнорирует.
Для проверки соответствия скобок используются две вспомогательные строки с открывающимися и закрывающимися скобками. Стандартная функция strchr() возвращает указатель на найденный символ, поэтому, чтобы найти номер данного символа, приходится вычитать из этого указателя указатель на начало строки. Все остальное просто.
Листинг 2.1 Проверка правильности расстановки всех видов скобок в строке
#include
#include "stack.h"
#include "stack.cpp"
void main()
{ char s[80];
cout<<"Введите строку, содержащую скобки "; cin.getline(s,80);
stack st;
char *kind1="([{", *kind2=")]}";
for (int i=0; i { if(strchr(kind1,s[i])) st.push(s[i]);
if(strchr(kind2,s[i]))
if((st.isnull())||(strchr(kind1,st.pop())-kind1!=strchr(kind2,s[i])-kind2))
{ cout<<"Ошибка!";cin.get(); return;
}
}
if (!st.isnull()) cout<<"Ошибка!";
else cout<<"Ошибок нет";
st.makenull(); cin.get();
}
Пример 2.
Усложним задачу. Пусть имеется скобочное выражение, в котором присутствуют только круглые скобки. Требуется скобки самого глубокого уровня вложенности оставить без изменения, скобки следующего уровня заменить на квадратные, а все остальные скобки заменить на фигурные.
Листинг 2.2 Преобразование скобок
#include
#include "stack.h"
#include "stack.cpp"
int main()
{ char s[80]; stack st;
cout<<"Введите строку, содержащую только круглые скобки "; cin.getline(s,80);
int l; //Уровень вложеннности скобок в данный момент
for (int i=0; i { if (s[i]=='(') {st.push(i); l=1;}
if (s[i]==')')
if (st.isnull()) {cout<<"Ошибка!!!"; cin.get(); return 1;}
else {
int p=st.pop();
if (l==2) {s[p]='['; s[i]=']';}
if (l>2) {s[p]='{'; s[i]='}';}
l++;
}
}
if (!st.isnull()) cout<<"Ошибка!!!";
else cout<<"Получили:\n"< st.makenull(); cin.get();
}
2.3. Реализация очередей
2.3.2. Непрерывная реализация очереди с помощью массива
Реализация очереди при помощи массива немного сложнее, чем реализация стека. Вспомним, что вставка и удаление элементов выполняются с разных концов. Легко сделать вставку в конец массива (хвост очереди), если еще есть свободные ячейки памяти, но сложнее выполнить удаление первого элемента массива (это голова очереди). Поэтому способ реализации, при котором голова очереди совпадает с первым элементом массива, используют только в тех задачах, где элементы добавляются в очередь постепенно, а опустошение очереди происходит сразу целиком. В этом случае достаточно иметь только один дополнительный указатель  на хвост.
В том случае, когда необходимо эффективно реализовать и вставку, и удаление элементов, обычно используют два дополнительных. указателя  на начало очереди (голову) и конец очереди (хвост). Назовем указатели head и tail.В качестве таких указателей могут выступать как индексы элементов массива, так и непосредственно указатели на элементы. При реализации очереди на С++ обычно используются непосредствено указатели на голову и на хвост. Тогда вставка и удаление элементов очереди реализуется с помощью изменения значений этих указателей (рис.2.3). По аналогии со стеком, в пустой очереди эти указатели будут иметь значение NULL (причем одновременно оба, только один из них никогда не может быть нулевым).
13 EMBED Visio.Drawing.6 141513 EMBED Visio.Drawing.6 1415
Рис.2.3. Представление очереди при помощи массива и двух указателей (в начальный момент и спустя некоторое время)
При вставках и удалениях элементов очередь как бы передвигается по массиву, постепенно приближаясь к его границе При этом в начале массива появляется свободное пространство за счет освободившихся при удалении элементов. Как только хвост очереди достигнет верхней границы массива, следующие элементы будут добавляться уже в свободные ячейки в начале массива (рис.2.4). Такую реализацию очереди называют кольцевой или циклической.

13 EMBED Visio.Drawing.6 1415
Рис.2.4. Еще одно состояние очереди (хвост оказался ниже головы)
Определение очереди при реализации с помощью массива и двух указателей может иметь, например, следующий вид (считаем, что тип элементов type_of_data и максимальный размер массива maxlength уже определены):
struct queue
{ type_of_data data[maxlength]; // массив данных очереди
type_of_data *head,*tail; // указатели на голову и хвост
// базовые функции для работы с очередью
queue(){head=tail=NULL;}//конструктор - пустая очередь
void enqueue(type_of_data x); //добавление элемента в хвост
type_of_data gethead(); //получение элемента из головы
void dequeue();// удаление (извлечение) элемента из головы
bool isnull() { return head==NULL; } // проверка на пустоту
void makenull(){queue();} // доп. функция очистки очереди
};
Рассмотрим подробнее реализацию методов добавления и удаления элементов.
При добавлении необходимо обеспечить циклическое перемещение указателя tail по массиву data и своевременно обнаружить попытку переполнения очереди. В полностью заполненной очереди хвост находится непосредственно перед головой (это справедливо и для случая, когда первый элемент очереди совпадает с первым элементом массива, поскольку перемещение по массиву выполняется циклически). Попытка добавить еще один элемент в очередь приведет к тому, что значения указателей на хвост и голову сравняются, что будет служить признаком переполнения очереди (аварийная ситуация). Однако если перед добавлением элемента очередь была пуста, то равенство указателей на хвост и голову означает всего-навсего, что очередь состоит из одного элемента.
void queue::enqueue (type_of_data x) // вставка элемента
{ if (isnull()) // добавляем самый первый элемент
{tail=head=data;} //имя массива-указатель на первый элемент
else // очередь не пуста
{ tail++; //теперь проверим выход хвоста за границы массива
if (tail==data+maxlength)
tail=data; // поместили хвост в начало массива
if (head==tail) {cerr << "Очередь переполнена"; exit(2);}
}
*tail=x;// если все в порядке, поместили x в хвост очерели
}
Теперь будем извлекать элементы из непустой очереди. Двигаясь по масиву, голова наконец дойдет до хвоста (остался один последний элемент). Этот случай нужно отследить особо, т. к. в результате должна получиться пустая очередь.
void queue::dequeue () //извлечение элемента
{ if (isnull()) { cerr << "Очередь пуста"; exit(1); }
if (head==tail) // в очереди только один элемент
{head=tail=NULL;} // установили признак пустой очереди
else//поднимаем голову и проверяем выход за границы массива
{ head++; if (head==data+maxlength) head=data; }
}
2.3.2. Ссылочная реализация очереди в динамической памяти
Ссылочная реализация очереди принципиально не отличается от ссылочной реализации стека, однако необходимость выполнять вставку и удаление на разных концах несколько усложняют детали. Во-первых, нужно хранить два дополнительных указателя  на хвост и голову очереди (обозначим их head и tail, как и в случае непрерывной реализации на массиве). Во-вторых, каждый элемент содержит указатель на следующий добавленный в очередь элемент, а не на предыдущий, как в стеке. Самый последний элемент очереди еще не имеет следующего элемента, поэтому содержит пустой указатель. Особенности ссылочной реализации очереди показаны на рисунке 2.5.
13 EMBED Visio.Drawing.6 1415
Рис.2.5. Представление очереди при помощи связного списка
Структура элемента очереди совпадает со структурой элементов стека с точностью до обозначений:
struct item //структура каждого элемента очереди
{ type_of_data data; // данные
item *next; // указатель на следующий элемент
};
Полное определение структуры queue приводить не будем, поскольку оно аналогично приведенной выше непрерывной реализации очереди (отсутствует только массив data). Можно самостоятельно определить структуру (или класс) очереди на основе шаблонов.
Интерес представляют операции добавления и удаления элементов, поскольку они выполняются немного иначе, чем аналогичные операции со стеком.
При добавлении элемента в хвост очереди достаточно (рис. 2.6):
захватить под него память, заполнить информационную часть данными, указателю присвоить пустое значение;
заполнить указующую часть элемента, бывшего последним (хвостом) в очереди  он теперь будет предпоследним и должен указывать на новый элемент;
присвоить указателю на хвост указатель на новый элемент  он теперь будет хвостом очереди.
13 EMBED Visio.Drawing.6 1415
Рис.2.6. Добавление элемента в очередь
В соответствии с данным алгоритмом функция добавления имеет вид:
void queue::enqueue (type_of_data x)
{ item *i=new item; i->data=x; i->next=NULL; //элемент создан
if (isnull())//добавление первого элемента в пустую очередь
{ head=tail=i;
}
else // добавление в непустую очередь
{ tail->next=i; //последний элемент стал предпоследним
tail=i; // обновили указатель на хвост
}
}
Удаление из головы очереди также выполняется просто  достаточно изменить указатель на голову и освободить память, которую занимал элемент, воспользовавшись буферной переменной (рис.2.7).
13 EMBED Visio.Drawing.6 1415
Рис.2.7.Удаление (извлечение) элемента из очереди
void queue::dequeue ()
{ if (isnull()) { cerr << "Очередь пуста"; exit(1); }
item *i=head->next;//запомнили указатель на второй элемент
delete head; head=i;//удалили голову и изменили указатель
}
Полную очистку всей очереди можно реализовать, например, так:
void queue::makenull()
{ while (!isnull()) dequeue(); // удаляем элементы по порядку
}
2.3.3. Ссылочная реализация очереди с помощью циклического списка
Использование циклического связного списка позволяет сэкономить на одном дополнительном указателе, и хранить только указатель на хвост очереди, поскольку указатель на голову уже содержится в указующей части хвоста очереди (рис.2.8).
13 EMBED Visio.Drawing.6 1415
Рис.2.8. Реализация очереди на основе циклического списка
В целом реализация мало отличается от реализации с помощью двух указателей, необходимо только аккуратно отследить момент, когда очередь становится пустой и присвоить указателю на хвост нулевое значение. Если в очереди только один элемент, то указатель на хвост указывает на него, а он указывает сам на себя.
2.3.4. Очереди с приоритетами
В реальных задачах часто возникает необходимость организации очередей, принцип работы которых отличен от FIFO. Порядок выборки элементов в них определяется приоритетами элементов. Приоритет  это некоторое числовое значение, связанное с элементами очереди, в простейшем случае, это может быть само значение элемента. При выборке элемента из очереди каждый раз выбирается элемент с наибольшим приоритетом.
Различают два способа реализации очередей с приоритетами.
1. Очереди с приоритетным включением. В этом случае очередь всё время поддерживается упорядоченной, т.е. каждый новый элемент помещается в то место очереди, которое определяется его приоритетом.
2. Очереди с приоритетным исключением. Новые элементы помещаются в конец очереди, при исключении элемента отыскивается элемент с максимальным приоритетом.
Реализация очередей с приоритетами на базе отсортированных массивов или списков используется при небольшом количестве элементов такой очереди. При небольшом количестве приоритетов может использоваться так называемый корзинный способ. Наиболее удобной формой реализации больших очередей с приоритетами является реализация с помощью частично упорядоченных деревьев.
2.3.5. Пример программы с использованием очереди
Очереди также часто, как и стеки, используются в качестве элементарных структур данных при реализации более сложных структур и алгоритмов. В следующих разделах будет достаточно примеров использования очередей, один из примеров приведем ниже (подробнее с алгоритмом решения аналогичных задач можно познакомиться в главе 8, посвященной алгоритмам на графах).
Пример. Лабиринт представляет собой клетчатое поле NxM клеток, некоторые из них пустые, другие закрашены - через них проходить нельзя. За один шаг можно перейти из текущей клетки в одну из свободных соседних. Требуется найти длину кратчайший путь из левого верхнего угла в правый нижний. Лабиринт считывается из текстового файла input.txt, где в первой строке записаны числа N и M (через пробел), а в каждой из последующих N строк находятся M символов информации о клетках лабиринта. При этом символ пробела обозначает пустую клетку, символ 'x' - закрашенную. Результат работы программы выводится на экран.
Идея решения состоит в следующем («метод волны»). Для начала определим тип элементов очереди как структуру из трёх полей  координаты клетки и число шагов до неё от левого верхнего угла:
struct cell
{ int x,y,l;
};
typedef cell type_of_data;
После чтения информации о лабиринте создаем очередь и помещаем в нее первый элемент с координатами левого верхнего угла и числом шагов 0.
На каждом шаге извлекаем очередной элемент, смотрим, в какие соседние клетки можно переместиться из текущей, и помещаем в очередь элементы, соответствующие этим клеткам. При этом для них делаем количество шагов на единицу больше, чем для извлечённого элемента. После этого необходимо пометить текущую клетку как закрашенную, чтобы не возвращаться в нее в дальнейшем, и перейти к извлечению нового элемента из очереди.
В конце концов либо окажется, что очередь пуста (это означает, что все возможные пути пройдены, и все свободные клетки помечены), либо очередной из извлеченных элементов окажется с координатами правого нижнего угла. В последнем случае количество шагов в структуре и будет решением задачи, т.е. длиной кратчайшего пути. Это объясняется тем, что элементы в очередь помещаются по возрастанию количества шагов.

#include
#include "queue.h"
main() {
ifstream fi("input.txt");
int N,M;
fi>>N>>M; fi.get(); //Считывание размеров лабиринта
char s[100]; int a[10][10], i, j;
for (i=0; i fi.getline(s,100);
for (j=0; j if (s[j]=='.') a[i][j]=0;
else a[i][j]=1;
}
}
queue q; // Очередь абсцисс, ординат и длин путей
cell c;
// Заносим в очередь первую клетку:
c.x=0; c.y=0; c.l=0; q.enqueue(c);
int len;
while (!q.isnull())
{c=q.gethead(); q.dequeue();
i=c.y; j=c.x; len=c.l;
if ((i==N-1)&&(j==M-1)) break; // Путь найден
// Проверяем соседние с (i,j) клетки:
c.l++; //увеличиваем путь на 1
if((i>0)&&(!a[i-1][j])){c.y--; q.enqueue(c); c.y++;}
if((i if((j>0)&&(!a[i][j-1])){c.x--; q.enqueue(c); c.x++;}
if((j a[i][j]=1;
}
if((i==N-1)&&(j==M-1))cout<<"Длина кратч. пути"< else {
q.makenull();
cout<<"Путь в лабиринте не найден.";
}
cin.get(); return 0;
}

Стеки и очереди являются частными случаями более универсальной структуры данных линейных списков.
2.4. Списки как абстрактные типы данных
Для определения функциональной спецификации формализуем понятие линейного списка. Имеется два подхода к обработке списков  итеративный и рекурсивный, которые основаны на различных формальных моделях списка и предполагают различный набор базовых операций. Начнем с итеративного подхода как более распространенного и, возможно, более простого для восприятия.
2.4.1. Модель списка с выделенным текущим элементом
Будем считать, что состояние списка задается не только перечислением набора элементов, но и дополнительно указанием одного из них в качестве текущего элемента. Относительно этого элемента будет определяться семантика базовых операций. Заданием текущего элемента список разделяется на две части: от начала до текущего элемента (пройденная или прочитанная часть) и от текущего элемента (включая его) до конца списка (рис.2.9). К элементам пройденной части можно получить доступ, только начиная просмотр списка с начала. В непройденной части доступны все элементы поочередно, начиная с текущего элемента.

13 EMBED Visio.Drawing.6 1415
Рис.2.9. Модель списка с текущим элементом
Операции над списками
Набор операций над списками существенно расширен по сравнению со стеками или очередями. В первую очередь это касается операций вставки и удаления  их теперь можно выполнять не только на концах, но в любой позиции списка, в зависимости от положения текущего элемента.
Операция вставки неформально определяется так  вставить элемент перед текущим, при этом текущим должен стать новый элемент. Особый случай вставки  новый элемент добавляется в конец списка.
При удалении текущего элемента следующий становится текущим. Нельзя удалять из пустого списка.
Одна из часто выполняемых операций над списками  получение значения текущего элемента. Можно выделить отдельную операцию изменения значения текущего элемента. Хотя ее можно представить как последовательность из двух операций (удаления с последующей вставкой), это будет неудобно в реализации.
Для любых списков требуются дополнительные операции создания пустого списка и проверки списка на наличие в нем хотя бы одного элемента.
Поскольку все базовые операции определяются относительно текущего элемента, обязательно должна быть обеспечена возможность изменения текущего элемента, иными словами, механизм передвижения по списку. Различные виды списков отличаются друг от друга именно по возможности передвижения по списку. Определим соответствющие абстрактные типы данных.
2.4.2. Однонаправленный список (список Л1)
В однонаправленном списке двигаться от элемента к элементу можно только в одном направлении. Для многих приложений этого достаточно, допустим для выполнения таких действий над списками как вычисление суммы элементов, вычисление максимального элемента, изменение значений элементов и т. д.
Для передвижения по однонаправленному списку достаточно определить такие операции:
сделать текущим первый элемент (встать в начало списка);
сделать текущим следующий элемент (продвинуться вперед на один элемент):
проверить, не достигнут ли конец списка при переходе от текущего элемента к следующему, что может случиться, если текущим был последний элемент.
2.4.3. Двунаправленный список (список Л2)
В списках Л2 базовые операции также определяются относительно текущего элемента (см. рис. 2.9), поэтому основной набор операций совпадает со списками Л1. Дополнительная возможность состоит в обходе списка не только в прямом, но и обратном направлении. Такая возможность требуется, например, при реализации просмотра или редактирования текстовых файлов с использованием списков и многих других алгоритмах.
Для того, чтобы сделать возможным движение по списку в двух направлениях, необходимо добавить к тем операциям, которые определены для однонаправленного списка, еще три дополнительных операции:
сделать текущим последний элемент (встать в конец списка);
сделать текущим предыдущий элемент (продвинуться назад на один элемент):
проверить состояние начала списка при переходе к предыдущему элементу, что может случиться, если текущим был первый элемент.
Формальную спецификацию списков Л1 и Л2 можно выполнить самостоятельно, аналогично представленной выше спецификации для стеков и очередей. В следующем разделе, посвященном реализации списков, формальная спецификация будет выполнена средствами языка программирования.
2.4.4. Циклический (кольцевой) список
В обычных линейных списках последний элемент завершает список (не имеет следующего за ним элемента), а первый означает начало списка (не имеет предыдущего). Существует особый вид списков, называемых циклическими, в которых для последнего элемента следующим является первый, а для первого предыдущим является пооследний. Фактически связи элементов образуют кольцо, поэтому иначе такие списки называются кольцевыми. Такие списки используются, например, при реализации некоторых элементов пользовательского интерфейса, допустим, меню (продвигаясь вперед от последнего пункта мы снова попадаем в первый).
Циклические списки имеют некоторые преимущества перед линейными, поскольку в самом списке содержатся указатели на все элементы без исключения. Мы уже пользовались этим преимуществом при реализации очереди с помощью циклического списка, при этом сэкономили на одном дополнительном указателе.
В циклических списках всегда можно попасть в любую позицию, двигаясь от любого элемента только в одном направлении. Для циклических списков легче, чем для линейных, выполняются некоторые операции, например, слияние двух списков, при этом достаточно иметь только по одному дополнительному указателю на произвольный элемент каждого списка.
Вообще в кольцевых списках понятие первого и последнего элемента весьма условно, но обычно все же выделяют особый легкораспознаваемый элемент как начало списка для удобства реализации. Такой узел называют заголовком списка [Кнут].
Если говорить о формальной спецификации операций над кольцевыми списками, то набор базовых операций соответствует набору для линейных списков Л1 или Л2 (за исключением проверки выхода за границы списка  в кольцевом списке движение выполняется по кругу). Однако семантика основных операций немного отличается от линейных списков, например, для операций вставки и удаления отсутствует необходимость рассматривать отдельные случаи, связанные с началом и концом списка.
2.5. Реализация списков с выделенным текущим элементом
Сначала сравним два возможных способа реализации  непрерывную и ссылочную. Когда речь шла о стеке или очереди, то не вставал вопрос оценки времени выполнения алгоритма, поскольку основные операции вставки и извлечения элементов на концах списка при любом способе реализации выполняются за константное время. В таких случаях точная асимптотическая оценка времени выполнения составляет
· (1).
Однако при непрерывной реализации списков с произвольным включением и исключением элементов на основе массивов вставка и удаление элементов требуют перемещения в памяти большого количества элементов (такие операции называются массовыми). Их время выполнения в худшем случае оценивается как линейное 
· (n), где n количество элементов.
Можно сделать вывод, что для частных случаев (стек, очередь, дек) применение непрерывной реализации на основе массива вполне приемлемо, но при частых вставках и удалениях в произвольных позициях непрерывная реализация неэффективна.
Далее коснемся только ссылочной реализации списков. Заметим, что она может быть выполнена как на основе на основе связных структур в динамической памяти, так и на основе массива.
2.5.1. Однонаправленные списки
Ссылочная реализация в динамической памяти на основе указателей
Этот способ уже использовался для реализации стека и очереди, которые можно рассматривать как частные случаи однонаправленного списка. В основе реализации во всех случаях лежит универсальная структура элемента однонаправленного списка:
struct item // структура одного элемента
{ type_of_data data; // тип был определен с помощью typedef
item *next; // указатель на следующий элемент списка
};
Далее требуется определить, какие дополнительные указатели необходимы для эффективной реализации операций над списками (вспомним, что при реализации стека использовался один, а при реализации очереди  два дополнительных указателя). Рассмотрим операции вставки и удаления элементов в произвольной позиции.
На рис.2.10. схематически показан процесс вставки нового элемента между первым и вторым элементами списка.
13 EMBED Visio.Drawing.5 1415
Рис.2.10. Вставка нового элемента в произвольную позицию списка.
Здесь текущим элементом является второй. Из рисунка понятно, что если бы новый элемент вставлялся после текущего (т. е. если бы текущим был первый), то достаточно только указателя на этот текущий элемент, т. к. адрес следующего легко получим из текущего. Однако общепринятым вариантом вставки является вставка элемента перед текущим, такой способ был принят и в рассмотренной ранее неформальной спецификации списка. Адреса предыдущего элемента в текущем нет. Следовательно, необходим дополнительный указатель на предыдущий элемент.
Аналогичные случаи можно рассмотреть и для удаления элемента. На рис. 2.11. показаны все три случая удаления. При этом перечеркивание изображений элементов обозначает удаление их из памяти.

13 EMBED Visio.Drawing.5 1415
Рис. 2.11. Удаление элементов однонаправленного списка
Ситуация такая же, как и в случае вставки. Для удаления элемента, следующего за текущим, не потребовалось бы никаких дополнительных уазателей, но для удаления элемента в текущей позиции обязательно нужно иметь дополнительный указатель на предшествующий элемент.
Разумеется, для того, чтобы реализовать операцию перехода к первому элементу, необходимо иметь еще один указатель указатель на первый элемент (начало) списка.
Отсюда вывод: для того, чтобы реализовать АТД «Однонаправленный список с выделенным текущим элементом», необходимо, кроме самого списка, иметь три дополнительных указателя:
на начало списка списка  назовем этот указатель head;
на текущий элемент  cur;
на элемент, предшествующий текущему  predcur.
Данные три указателя называют формуляром списка, поскольку их значения полностью определяют текущее состояние списка.
Таким образом, можно определить однонаправленный список в виде, например, представленной ниже структуры list_l1:
struct list_l1 //структура списка
{ item *head, *cur, *predcur; //формуляр списка
// базовые функции
list_l1() {head=cur=predcur=NULL;}//конструктор
bool eolist() {return cur==NULL;}//проверка на конец списка
bool isnull() { return head==NULL; } // проверка на пустоту
type_of_data getdata(); //получить текущий элемент
void first(); //встать в начало
void next(); //перейти к следующему элементу
void ins(type_of_data x);//вставка перед текущим элементом
void del(); // удаление текущего, текущим станет предыдущий
void makenull(); // очистка списка
};
В качестве пояснения к реализации базовых функций выделим особые состояния списка и соответствующие этим состояниям значения указателей:
head=predcur=cur=NULL  список пуст, в этом состоянии нельзя удалять элементы, первый добавленный элемент будет одновременно и началом, и концом списка;
head
· NULL; cur=head; predcur=NULL  «начало списка»; в этом состоянии новый элемент вставляется перед первым;
head
· NULL; predcur
· NULL; cur=NULL;   «конец списка», в этом состоянии новый элемент вставляется после последнего, а удалить текущий элемент нельзя.
Два последних состояния определены для непустого списка
(head
· NULL).
При реализации функций учитываются все возможные особые состояния, которые могут случиться при выполнении соответствующей операции. Например, переход к следующему элементу может быть реализован так:
void list_l1::next()
{ if (isnull()) {cerr << "Список пустой"; exit(1);}
if (eolist()) {cerr << "Достигнут конец списка"; exit(2);}
predcur=cur; cur=cur->next;
}
Наличие дополнительного указателя predcur позволяет реализовать операцию вставки довольно просто, при этом даже не требуется отдельно рассматривать случай вставки в конец списка, поскольку он входит в общий случай вставки не в начало списка.
void list_l1::ins(type_of_data x)
{ item *temp=new item;
temp->data=x; temp->next=cur;
if (predcur) //вставка не в начало списка
predcur->next=temp;
else head=temp; //вставка в начало
cur=temp;
}
Удаление требует несколько большего количества проверок, поскольку необходимо обработать аварийные ситуации.
void list_l1::del()
{ if (isnull()) {cerr << "Список пустой"; exit(1); }
if (eolist()) {cerr << "Достигнут конец списка"; exit(2);}
if (predcur) //удаляется не первый элемент списка
{ predcur->next=cur->next; delete cur; cur=predcur->next;
}
else // удаляется первый элемент
{ head=head->next; delete cur; cur=head;
}
}
Ссылочная реализация на основе массива
При реализации списка в динамической памяти выделение памяти под каждый элемент выполняется автоматически (в С++ это делается с помощью операции new). Можно сделать процесс выделения и освобождения памяти для элементов списка полностью управляемым программой (допустим, в целях повышения быстродействия). В этом случае необходимо воспользоваться обычным одномерным массивом (вектором), но использовать его несколько необычным образом, отказавшись от размещения соседних элементов списка в соседних ячейках памяти. В этом случае в каждом элементе должна храниться ссылка на следующий элемент (обычно ссылка это индекс следующего элемента, при реализации на С++ ссылкой может быть указатель).
Тогда каждый элемент списка будет представлять собой запись (в С++  структуру), а память, отводимая для хранения списка, представляется одномерным массивом записей. Поскольку память под массив выделяется заранее, то максимальная длина списка ограничена размером массива. В этом случае говорят об ограниченном Л1–списке и при реализации обязательно отслеживают аварийную ситуацию, связанную с переполнением области памяти.
На рис.2.12 приведен пример размещения списка, состоящего из четырех элементов, имеющих значения a, b, c, d в массиве, максимальный размер которого составляет 8 элементов (нумерация с единицы). Очевидно, в этом списке уже выполнялись операции удаления и вставки, поскольку элементы расположены не по порядку. Но это не важно, т. к. индекс следующего элемента явно хранится в каждом элементе. Последний элемент имеет значение поля связи, соответствующее пустой ссылке. В примере используется несуществующее значение индекса -1.
Для работы со списком используем формуляр списка, включающий три дополнительных указателя, как и при реализации в динамической памяти (head, cur, predcur). Для примера текущим выбран третий элемент списка со значением c.
13 EMBED Visio.Drawing.6 1415
Рис. 2.12. Ссылочная реализация однонаправленного списка на основе массива
Элементы массива, которые в данный момент являются свободными, также расположены хаотически. Для удобства работы их также удобно связать в список свободных ячеек. При удалении элемента из списка тот будет добавляться в конец списка свободных ячеек, а при вставке нового элемента проще всего взять элемент с этого же конца списка свободных ячеек. Получается, что этот список представляет собой стек, а для работы с ним достаточно иметь всего один дополнительный указатель на вершину (мы назвали его top_free).
Для того, чтобы идея стала совсем понятной, на рис. 2.13 массив записей из рис.2.12 изображен в виде двух списков, представляющих собой занятую и свободную части массива.
13 EMBED Visio.Drawing.6 1415
Рис. 2.13. Список занятых элементов массива и список (стек) свободных ячеек.
В рассмотренном примере в качестве ссылок на соседние элементы используются индексы массива, поэтому для реализации потребуется структура, похожая, но не идентичная уже знакомой структуре элемента однонаправленного списка:
struct item // структура для одного элемента списка
{ type_of_data data; //тип данных определен с помощью typedef
int next; //индекс следующего элемента (целое число)
};
Можно использовать указатель на следующий элемент списка вместо его индекса, реализуя ту же самую идею двух списков внутри массива. В этом случае структура элемента будет точно такой же, как и при реализации в динамической памяти, а сама реализация  более эффективной (ее полезно проделать самостоятельно).
Структура однонаправленного списка на основе массива может иметь следующий вид:
const int maxlength=100;
struct list_l1 // структура списка, включающая и функции
{ item elements[maxlength]; //массив элементов
int head, cur, predcur; //голова, текущий, предыдущий
int top_free; //индекс вершины списка (стека) пустых ячеек
list_l1(); //конструктор
//далее следуют прототипы базовых функций .....
};
Обратим внимание на то, что в данном случае необходимо реализовать все действия со стеком свободных ячеек внутри массива, поскольку ранее рассмотренные реализации стека не подойдут.
Реализация конструктора может иметь следующий вид:
list_l1::list_l1()
{ head=-1; cur=-1; predcur=-1;// начальные значения индексов
// первоначально весь массив - стек пустых ячеек
elements[maxlength-1].next=-1;//последний элемент-дно стека
for (int i=maxlength-2; i>=0; i--)
elements[i].next=i+1;
top_free=0; //элемент с нулевым индексом - вершина стека
}
Функции вставки и удаления по своей логике аналогичны соответствующим функциям при реализации в динамической памяти, однако усложнены действиями со стеком свободных ячеек:
void list_l1::ins(type_of_data x)
{ if (top_free==-1) {cerr << "Список переполнен"; exit(3);}
// берем первую пустую ячейку с вершины стека top_free
int k=top_free; top_free=elements[top_free].next;
elements[k].data=x; elements[k].next=cur;
if (predcur!=-1) // элемент вставляется не в начало списка
elements[predcur].next=k;
else head=k; // вставка в начало
cur=k;
}
void list_l1::del()
{ if (isnull()) {cerr << "Список пустой"; exit(1);}
if (eolist()) {cerr << "Достигнут конец списка"; exit(2);}
if (predcur!=-1) // если удаляется не начало списка
{ elements[predcur].next=elements[cur].next;
// добавляем на вершину стека освободившуюся ячейку
elements[cur].next=top_free; top_free=cur;
cur=elements[predcur].next;
}
else // удаляется первый элемент
{ head=elements[head].next;
elements[cur].next=top_free; top_free=cur;
cur=head;
}
}
В данной реализации рассматривается только один список, хотя сам принцип можно расширить на несколько списков, которые организуются на основе одного массива и имеют общий список свободных ячеек. Собственно, и стандартные средства автоматического выделения и освобождения памяти, встроенные в языки программирования, используют похожие принципы.
2.5.2. Двусвязные списки
Схематическое изображение двусвязного списка на основе указателей показано на рис.2.
13 EMBED Visio.Drawing.5 1415Рис.2.14. Двусвязный список
При работе с двусвязным списком уже не нужен дополнительный указатель на элемент, предшествующий текущему, т. к. все необходимые указатели есть в каждом элементе, а значит, для выполнения вставок и удалений достаточно только указателя на текущий элемент. Однако для реализации все-таки опять потребуется три дополнительных указателя  на первый, последний и текущий элементы (например, first, last, cur). Они являются формуляром списка и их значения полностью определяют состояние списка.
Структуры, описывающие двунаправленный спиоск, могут иметь, например, такой вид:
struct item //один элемент списка
{type_of_data data;
item *next, *prev;
};
struct list_l2 // структура списка
{ item *first, *last; *cur;//указатели на первый, последний и текущий элементы.
//прототипы функций
...
};
Также, как и для списков Л1, можно выделить три особых состояния:
head=last=cur=NULL  список пуст, в этом состоянии нельзя удалять элементы, первый добавленный элемент будет одновременно и началом и концом списка;
head
· NULL; last
· NULL;cur=head  «начало списка»; в этом состоянии новый элемент вставляется перед первым;
head
· NULL; last
· NULL; cur=NULL;   «конец списка», в этом состоянии новый элемент вставляется после последнего, а удалить текущий элемент нельзя.
Обратим внимание, что в двунаправленном списке размер каждого элемента списка больше, чем в однонаправленном, поскольку указующая часть содержит не один, а два указателя. Фактически каждый указатель хранится два раза, поскольку каждый элемент, кроме первого и последнего, имеют и предыдущий, и следующий. Интересно отметить, что в случае ссылочной реализации с использованием массива можно хранить не два индекса (next и prev), а одно значение, равное разности next-prev. Нетрудно убедиться, что имея такие разности и зная индексы первого и последнего элемента списка, можно без труда восстановить цепочки индексов как при движении в прямом, так и в обратном направлении. В этом случае мы даже не увеличиваем расход памяти под элементы списка по сравнению с Л1, правда немного проигрываем в быстродействии при обходе списков за счет необходимости вычисления индексов.
В целом реализация функций для работы с двунаправленными списками выполняется аналогично однонаправленным спискам, поэтому это можно проделать самостоятельно.
2.5.3. Кольцевые списки
В кольцевом списке вообще нет пустых указателей, поскольку крайние элементы указывают друг на друга. Кольцевые списки могут быть как односвязными, так и двусвязными (на рис. 2.15. кольцевой список является односвязным).
13 EMBED Visio.Drawing.5 1415Рис.2.15. Кольцевой односвязный список
Реализация операций с кольцевыми списками отличается от линейных списков только деталями, связанными с крайними элементами.
2.5.4. Примеры программ, использующих списки
Очередь с приоритетами на основе линейного списка
Очередь с приоритетами на основе линейного списка  простой, но далеко не самый эффективный способ реализации. В главе 4 будет рассмотрен другой вариант на основе специальной древовидной структуры, которая называется пирамидой. Данный пример можно рассматривать как реализацию более сложного АТД (очереди с приоритетами) на основе более простого (списка Л1).
Реализуем очередь с приоритетным включением элементов (структура sorted_list).
#include "list.h"
#include
struct sorted_list
{ list_l1 list;
type_of_data getdata() {list.first();
return list.getdata();}
void ins(type_of_data x)
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·небольшой пример использования sorted_list
{ int n,k,i; sorted_list l;
cout<<"к-во элементов "; cin>>n; cin.get();
for(int i=n; i>0; i--) {cout< cout< for(int i=0; i { cout< }
cout<}

2.6. Рекурсивная обработка линейных списков
2.6.1. Модель списка при рекурсивном подходе
Рассмотренный ранее подход к организации линейных списков ориентирован на итеративную (циклическую) обработку, при которой на каждом шаге выделяется текущий элемент списка и все действия выполняются относительно этого элемента.
Рассмотрим теперь другой подход к организации и обработке списков, основанный на систематическом применении рекурсии и не предполагающий явного выделения текущего элемента [2]. На рис.2.16 изображен линейный список, разделенный на две неравные части: "голову" (первый элемент списка) и "хвост" (все остальное).
13 SHAPE \* MERGEFORMAT 1415
Рис.2.16. Модель линейного списка при рекурсивном подходе к его обработке
Используя такой подход, дадим рекурсивное определение линейного списка. Линейный список   представляет собой либо пустой список (не содержащий ни одного элемента), либо упорядоченную пару "голова – хвост", в которой голова есть элемент базового типа
·, а хвост, в свою очередь, есть линейный список ( возможно пустой ).
Одной из распространенных форм представления определенных таким образом списков является так называемая скобочная запись, применяемая, например, в языке функционального программирования Lisp. При этом для представления упорядоченной пары "голова – хвост" используется точка как разделитель, поэтому ее часто называют точечной парой. Пустой список обозначается символом Nil.
Например, скобочная запись списка из элементов a, b, c, d типа
· имеет вид ( a . ( b . ( c . ( d . Nil ) ) ) ) или в сокращенной записи ( a b c d ). Переход к сокращенной записи производится с помощью отбрасывания конструкции  . Nil и удаления точки с парой скобок везде, где они встречаются. Пробелы в сокращенной записи используются для обеспечения однозначности прочтения конструкции, количество их выбирается произвольно.
Такая модель линейного списка предполагает принципиально новый набор базовых операций, отличный от набора базовых операций для модели списка с выделенным текущим элементом.
Выделим базовые операции для рекурсивной обработки списков:
функция формирования пустого списка  назовем ее Nil;
предикат IsNull (список пуст),
функция Head возвращает значение первого элемента (головы списка);
функция Tail возвращает хвост непустогос списка, т.е. список, получаемый из исходного списка после удаления из него головного элемента.
функция Cons (Construct) строит новый список из переданных ей в качестве аргументов головы и хвоста.
Здесь функция IsNull является индикатором, Head и Tail  селекторы, Cons  конструктор.
Данный набор функций является базовым в языках функционального программирования. Например, в языке Lisp функция Head имеет имя CAR, а функция Tail называется CDR (обозначение Cons такое же как и в языке Lisp). Дело в том, что автор языка LISP Джон Маккарти (США) реализовал первую LISP-систему на машине IBM 605 и использовал регистры c названиями CAR и CDR для хранения головы и хвоста списка.
Обратим внимание, что функции Head и Tail могут быть определены только для непустых списков, хотя функция Tail может возвратить и пустой список («хвост»). Функция Cons, в свою очередь, формирует только непустой список. Заметим, что список, разбитый с помощью функций Head и Tail на голову и хвост, можно восстановить с помощью функции Cons.
Определенная проблема возникает с пустым списком, который нельзя использовать как параметр функций Head и Tail, но можно использовать в качестве «хвоста» в функции Cons, которая в этом случае сформирует список из одного единственного элемента. Поэтому в любой реализации придется аккуратно отслеживать ситуацию, когда список является пустым, и уметь формировать пустой список.
Запишем формальную функциональную спецификацию списка. Обозначим список элементов типа
· как List(
· ), непустой список  Non_null_list( 
· ), пустой список обозначим Null_list (
·).
0. Nil : ( Null_list(
·);
1. IsNull : List( 
· )( Boolean;
2. Head : Non_null_list( 
· ) (
·;
3. Tail : Non_null_list( 
· ) ( List( 
· );
4. Cons :
· ( List( 
· ) ( Non_null_list( 
· );
Выстроим систему аксиом для данных базовых операций. Пусть x имеет тип
·, y  список элементов типа
· List(
· ), z  непустой список Non_null_list( 
· ).
A1. IsNull( Nil ) = true;
A2. IsNull( Cons( x , y ) ) = false;
A3. Head( Cons( x , y ) ) = x;
A4. Tail( Cons( x , y ) ) = y;
A5. Cons( Head( z ) , Tail( z ) ) = z.
Пустой список рассматривается здесь как значение типа List( 
· ), возвращаемое функцией без параметров Nil.
Все остальные операции над линейными списками выполняются при помощи соответствующей суперпозиции рекурсивных вызовов данных базовых функций.
Так, доступ к произвольному элементу списка осуществляется с помощью функций Head и Tail.
Например, если список y = (a b c d),
то Head(y) = a, Head(Tail(y)) = b,
а
Head(Tail(Tail(Tail(y)))) = d.
Понятно, что такой способ доступа к элементам сильно отличается от рассмотренного ранее передвижения по списку при помощи перемещения указателя текущего элемента. Например, при рекурсивном подходе невозможно явно выделить однонаправленные и двунаправленные списки, однако можно организовать передвижение по списку в любом направлении, используя разную последовательность рекурсивных вызовов.
Сформировать любой непустой список можно только одним способом  используя функцию Cons. Например, сформируем список из одного и трех элементов:
( a ) = ( a . Nil ) = Cons( a , Nil );
( a b c ) = ( a. ( b. ( c . Nil ) ) ) = Cons( a , Cons ( b , Cons ( c , Nil ) ) ).
Отметим, что построение каждой точечной пары в скобочной записи списка требует однократного применения конструктора Cons. При этом можно очень легко добавлять элементы в «голову» списка однократным вызовом Cons, а добавление в другие позиции требует «разборки» списка при помощи селекторов и последующей сборки при помощи конструктора. При определенном навыке использования рекурсивных вызовов функций можно легко «разбирать» и «собирать» списки, добавляя, удаляя и переставляя элементы
2.6.2. Реализация линейного списка при рекурсивном подходе
Приведенный выше рекурсивный подход к обработке линейных списков реализуется компактно и наглядно. Для формирования списка будем использовать динамическую память. Описание структуры линейного списка сводится к описанию точечной пары (структуры из двух полей):
struct list
{ type_of_data list_h; //голова, тип был определен в typedef
list *list_t; //указатель на хвост списка типа list
};

Базовые функции получают в качестве параметра указатель на список, что удобно для выполнения рекурсивных вызовов.
Представим реализацию базовых функций:
bool isnull(list *l) // проверка на пустоту
{ return (l==NULL);
}
type_of_data head(list *l) //получение значения головы списка
{ if (isnull(l)) { cerr<<"список пуст"; exit(1); }
return l->list_h;
}
list *tail(list *l) // получение указателя на хвост списка
{ if (isnull(l)) { cerr<<"!tail(NULL)"; exit(2); }
return l->list_t;
}
list *cons(type_of_data l_head,list *l_tail)//создание списка
{ list *temp=new list;
temp->list_h=l_head; temp->list_t=l_tail;
return temp;
}
В дополнение к основным функциям добавим еще рекурсивную функцию очистки списка, которая освобождает память, которую занимали элементы списка, и присваивает значение NULL указателю на список. Это значение является признаком пустого списка.
list* makenull(list *l) // очистка списка
{ if (isnull(tail(l))) {delete l; return NULL;}
else return makenull(tail(l));
}
В качестве примеров применения базовых функций приведем еще несколько полезных функций для работы со списками.
list *concat(list *l1, list *l2) //присоединение l2 к l1
{ if (isnull(l1)) return l2;
return cons(head(l1),concat(tail(l1),l2));
}
type_of_data sum(list *l) // сумма (для числовых данных)
{ if (isnull(l)) return 0;
return head(l)+sum(tail(l));
}
list *append(type_of_data x, list *l) //добавление элемента x
{ return concat(l,cons(x,NULL));
}
list *reverse(list *l) //список в обратном порядке
{ if (isnull(l)) return NULL;
return concat(reverse(tail(l)),cons(head(l),NULL));
}
void print(list *l) // вывод элементов по порядку
{ if (isnull(l)) { cout< cout< }
Небольшая демонстрационная программа показывает применение приведенных функций при работе со списком, элементами которого являются целые числа (при определении списка использовался оператор typedef int type_of_data).
main()
{ list *l=cons(1,cons(2,cons(3,cons(4,NULL))));
print(l); //создали и вывели список(1 2 3 4)
l=concat(l,cons(5,cons(6,NULL))); //добавили список (5 6)
print(l);
l=append(7,l); print(l);//добавили еще элемент (7)
l=reverse(l); // изменили порядок элементов на обратный
print(l);
cout<<"Sum="< l=empty(l); if (isnull(l)) cout<<"Список пуст";
cin.get(); return 0;
}

В заключение сравним итерационный и рекурсивный подходы к обработке линейных списков. По наглядности и компактности кода, конечно, выиграет рекурсивный подход (хотя для разработки нужны определенные навыки применения рекурсии). Однако более эффективным как по времени исполнения, так и по расходу памяти все-таки является традиционное итерационное решение. Это связано с дополнительными затратами стековой памяти и времени для организации рекурсивных вызовов, причем, чем больше размер списка, тем больше глубина рекурсии, следовательно, выше накладные расходы, связанные с организацией рекурсии.
Будем считать приведенную здесь рекурсивную реализацию линейного списка хорошим упражнением в программировании рекурсивных функций и подготовкой к изучению иерархических структур, при реализации которых рекурсия более уместна.
3. Иерархические структуры данных
В жизни отношения иерархии (иначе, ветвления) встречаются не менее часто, чем линейные отношения соседства. Допустим, в университете несколько факультетов, на каждом факультете несколько специальностей, для каждой специальности имеется несколько студенческих групп, в каждой из них учится не один студент. При этом каждый студент учится только в одной группе, каждая группа принадлежит только одной специальности и т. д. Следовательно, каждого студента можно отыскать, двигаясь от самого верхнего уровня, только одним единственным путем.
Другие примеры  оглавление книги, структура каталогов любого диска и т. д..
Структуры данных, между элементами которых существуют отношения иерархии (ветвления), называются иерархическими. К наиболее распространенным иерархическим структурам относятся:
иерархические списки;
деревья и леса;
бинарные деревья.
Между этими структурами имеется много общего. Однако они имеют различное происхождение и использование, поэтому рассмотрим данные структуры по порядку, начиная с иерархических списков.
3.1. Иерархические списки
3.1.1 Иерархические списки как АТД
В предыдущей главе обсуждался рекурсивный подход к определению и обработке линейных списков. В продолжение темы расширим данный подход, введя понятие «иерархический список». Иерархические списки имеют и другие названия. Так, в классической работе Кнута [8] они называются Списками (с заглавной буквы) и рассматриваются как одна из наиболее универсальных структур данных.
Основная область использования иерархических списков  функциональное программирование, где они называются S-выражениями (Symbolic Expression).
Чтобы быстрее понять суть дела, сначала дадим неформальное определение понятия «иерархический список». Представим себе, что содержательная часть любого элемента линейного связного списка может содержать или данные (значения базового типа, которые в этом случае называются атомами), или указатель (ссылку) на другой список. Элементы этого вложенного списка также могут содержать указатели на другие списки. Так образуется иерархическая структура. Пример такой структуры изображен на рис. 3.1

13 EMBED Visio.Drawing.6 1415
Рис.3.1. Пример иерархического списка
Здесь a,b,c,d,e  значения базового типа. Первый и последний элементы списка содержат атомы a и e (значения базового типа), второй элемент  указатель на список. Первый элемент этого вложенного списка  указатель на пустой список (это допустимо в иерархических списках), второй содержит указатель на линейный список, содержащий два атома b и c. Последний элемент вложенного списка содержит атом d.
Обратим внимание, что графическое изображение иерархического списка  двухмерное, в отличие от линейного, где все элементы располагаются на одной линии.
Используя аналогию с разделом 2.6, данный иерархический список можно компактно представить в виде скобочного выражения (a.(().(b.c).d).e). Здесь конструкция () обозначает пустой список.
Перейдем к формальному рекурсивному определению иерархического списка ( S-выражения) [2].
S-выражение представляет собой или элемент базового типа, называемый в этом случае атомом ( атомарным S–выражением ), или линейный список из S–выражений (возможно пустой).
Непустой линейный список из S–выражений представим как точечную пару "голова"–"хвост" по аналогии с разделом 2.6, с той разницей, что голова является S-выражением, а хвост линейный список S-выражений (в непустом линейном списке голова  всегда атом, хвост  линейный список атомов).
Таким образом, линейные списки можно рассматривать как частный случай иерархических списков, а рекурсивный подход к обработке линейных списков можно расширить и на случай иерархических списков, изменив и дополнив функциональную спецификацию. Для пустого списка по-прежнему используем обозначение nil.
В таблице 3.1 показаны примеры иерархических списков, представляющие списки в полной и сокращенной скобочной записи. Переход к сокращенной записи произведен по правилам, изложенным в 2.6.
Таблица 3.1.
Примеры иерархических списков
Полная запись
Сокращенная запись
Комментарий

a
Nil
( a . ( b . ( c . nil ) ) )

( a.((b.(c.nil)).(d.(e. nil))))
a
(  )
( a.b.c )

( a.( b.c ).d.e )
Атомарное S-выражение
Пустое S-выражение
Частный случай  линейный список
S-выражение иерархической структуры


Зададим функциональную спецификацию иерархических списков, воспользовавшись базовыми функциями (операциями) в тех же обозначениях, как было принято при определении линейных списков в разделе 2.6. По сравнению с линейными списками ситуация усложняется необходимостью отличать атомарные S-выражения от неатомарных. Введем следующие обозначения. Пусть
S_expr(
·)  произвольное S-выражение (атомарное или неатомарное), где
·   базовый тип данных;
Atomic (
·)   атомарное S-выражение, согласно определению, представляет собой произвольное значение типа
·, при этом является частью типа S_expr(
·);
List(S_expr(
·))  линейный список (возможно, и пустой) из S-выражений (т. е. неатомарное S-выражение);
Non_null_list(S_expr(
·))   непустое неатомарное S-выражение;
Null_list(S_expr(
·))   пустое S-выражение (не является атомом).
Базовые функции IsNull, Head, Tail, Cons применительно к иерархическим спискам имеют тот же смысл, что и для линейных списков (проверить на пустоту, разобрать список, выделив голову и хвост, и собрать список из головы и хвоста). Для проверки S-выражения на атомарность вводится предикат IsAtom.
Две дополнительных функции (назовем их MakeAtom и GetAtom) придется добавить к функциональной спецификации в связи с необходимостью приведения базового типа данных
· к типу атомарного S-выражения Atomic (
·) и выполнения обратного преобразования. Данные функции носят вспомогательеый характер и потребуются при реализации АТД на языке высокого уровня.
Таким образом, формальная функциональная спецификация АТД «Иерархический список (S-выражение)» будет иметь вид:
0) nil : ( Null_list( S_expr(
·));
1) IsNull: List(S_expr(
·)) ( Boolean;
2) Head : Non_null_list(S_expr(
·)) ( S_expr(
·);
3) Tail : Non_null_list(S_expr(
·)) ( List(S_expr(
·));
4) Cons : S_expr(
·)(List(S_expr(
·)) (
Non_null_list(S_expr(
·));
5) IsAtom : S_expr(
·) ( Boolean;
6) MakeAtom:
· ( Atomic (
·);
7) GetAtom: Atomic (
·) (
·.
Для уточнения смысла каждой функции зададим систему правил (аксиом) А1–А7, справедливых для всех t типа
·, всех u типа List(S_expr(
·)), всех v типа Non_null_list(S_expr(
·)), всех w типа S_expr(
·):
A1) IsNull ( nil ) = true;
A2) IsNull ( Cons ( w, u ) ) = false;
A3) Head ( Cons ( w, u ) ) = w;
A4) Tail ( Cons ( w, u ) ) = u;
A5) Cons ( Head( v ), Tail( v ) ) = v;
A6) IsAtom ( MakeAtom(t)) = true;
A7) IsAtom ( u ) = false;
A8) GetAtom ( MakeAtom( t ) ) = t.
Как и в случае линейных списков, для создания пустого списка в функциональной спецификации введена функция без параметров nil. Обратим внимание, что пустой список не является атомом, но результат функции MakeAtom всегда является атомом.
Функции Head и Tail неприменимы к атомарным S-выражениям, однако являются единственным средством доступа к неатомарным S-выражениям. Так, например, если u = ( a ( b c ) d e ), то
Head ( Tail ( u ) ) = ( b c );
Head ( Tail ( Head ( Tail ( u ) ) ) ) = c.
Здесь  a, b, c, d, e  представляют собой атомарные S-выражения. Для получения данных базового типа, например, значения c, следует применить функцию GetAtom(c).
Формирование списков выполняется только с помощью функции Cons. Например:
( a (b c) d e ) = ( a .( ( b .( c . nil ) ) . ( d . ( e .nil ) ) ) )=
Cons( a, Cons( Cons(b,Cons(c, nil)), Cons(d,Cons(e,nil))));
(a (( ) (b c) d) e) =
(a .((nil . (( b . (c . nil)) . (d . nil))) . (e . nil))) =
Cons(a, Cons(Cons(nil, Cons( Cons(b, Cons(c,nil)), Cons(d,nil))), Cons(e,nil))).
Для того, чтобы перейти от значений базового типа к атомарным S-выражениям, к аргументам функции Cons следует применить функцию MakeAtom. Нетрудно заметить, что построение каждой точечной пары в скобочной записи списка требует, как и в случае линейного списка, однократного применения конструктора Cons.
Таким образом, любые действия с иерархическими списками будут состоять в последовательности рекурсивных вызовов базовых функций, причем, в отличие от линейных списков, рекурсивный подход здесь является основным.
3.1.2. Реализация иерархических списков
В разделе 2.6. приводилась рекурсивная реализация линейных списков. Для иерархических списков можно выполнить аналогичную реализацию, но для этого потребуется несколько усложнить внутреннюю структуру данных. Поскольку каждый элемент может быть или значением базового типа, или указателем на список, наилучшим решением для языка С++ является использование типа union (объединение), которое позволяет использовать одну и ту же область памяти для хранения данных разных типов (в нашем случае базовый тип и тип указатель). Для того, чтобы отличать атомы от указателей на список, для каждого элемента вводим дополнительное поле логического типа, в котором будем хранить признак, является ли данное поле атомом (без него не обойтись).
Таким образом, каждый элемент будет являться структурой (struct), состоящей из двух частей  признака атомарности (тип bool) и непосредственно определения элемента (тип union).
struct list
{ bool atomic; // признак атомомарности
union // определение списка (атом или пара «голова-хвост»)
{ type_of_data atm;
struct head_tail
{ list *list_h, *list_t;
} pair;
};
~list()//деструктор, введен для освобождения памяти
{ if (!atomic)
{ delete pair.list_h;
delete pair.list_t;
}
}
};
Выделение памяти под элемент списка будет выполняться в функции makeatom.
Заметим, что в языке Pascal для реализации иерархического списка удобно использовать записи с вариантами (case внутри определения record - аналог union).
Реализация базовых функций выполняется аналогично рекурсивной реализации линейных списков с той разницей, что при обработке аварийных ситуаций приходится выполнять проверки не только на пустоту, но и на атомарность S-выражения.
bool isnull(list *l) //возвращает true, если список пустой
{ return (l==NULL);
}
bool isatom(list *l) //возвращает true, если список атомарный
{ if (isnull(l)) return false;
return l->atomic;
}
list *head(list *l) // возвращает указатель на голову
{ if (isnull(l)) { cerr<<"пустое S-выражение"; exit(1); }
if (isatom(l)) { cerr<<"атомарное S-выражение"; exit(2); }
return l->pair.list_h;
}
list *tail(list *l) // возвращает указатель на хвост
{ if (isnull(l)) { cerr<<"пустое S-выражение"; exit(3); }
if (isatom(l)) { cerr<<"атомарное S-выражение "; exit(4); }
return l->pair.list_t;
}
list *makeatom(type_of_data x) //создает атомарный список
{ list *temp=new list;
temp->atomic=true; temp->atm=x;
return temp;
}
type_of_data getatom(list *l) //возвращает значение атома
{ if (!isatom(l)) {cerr<<"неатомарное S-выражение"; exit(5);}
return l->atm;
}
list *cons(list *l_head, list *l_tail) //создает список
{ if (isatom(l_tail)) { cerr<<"хвост - атом";exit(6);}
list *temp=new list; temp->atomic=false;
temp->pair.list_h=l_head; temp->pair.list_t=l_tail;
return temp;
}
В качестве примера применения базовых функций приведем дополнительные полезные функции для работы с иерархическими списками  соединение двух списков и вывод в сокращенной скобочной записи.
list *concat(list *l1, list *l2) // присоединение l2 к l1
{ if (isnull(l1)) return l2;
if (isatom(l1)) return cons(l1,l2);
return cons(head(l1),concat(tail(l1),l2));
}
void print(list *l) // вывод списка l
{ if (isnull(l)) {cout << endl; return;}
if (isatom(l)) cout< else
{ if (isatom(head(l))) print(head(l));
else
{ cout<<"( "; print(head(l)); cout<<") ";
}
print(tail(l));
}
}
Небольшая демонстрационная программа показывает работу с иерархическим списком, состоящим из целых чисел (при определении списка использовался оператор typedef int type_of_data).
main()
{ // для примера создаем список ( 1 ( 2 3 ) 4 5 ):
list *l=cons(makeatom(1), // голова
cons(cons(makeatom(2),cons(makeatom(3),NULL)),//хвост
cons(makeatom(4),cons(makeatom(5),NULL))));
print(l);
// соединяем исходный список со списком ( ( 6 7 ) ):
l=concat(l,cons(cons(makeatom(6),cons(makeatom(7),NULL)),NULL));
// образуется список ( 1 ( 2 3 ) 4 5 ( 6 7 ) )
print(l); return 0;
}
3.2. Деревья и леса
Другой подход к представлению иерархических структур представляют собой деревья и леса. Это достаточно распространенные структуры, которые используются как базовые большим количеством алгоритмов обработки данных, и в связи с этим заслуживают самого пристального внимания.
В данной главе сосредоточим максимум внимания на общих свойствах деревьев и лесов как структур данных, которые будут использоваться при изучении следующих разделов. Для этого сначала рассмотрим их как абстрактные математические объекты, а затем перейдем к формальной функциональной спецификации и различным формам реализации.
Особый вид деревьев  бинарные будут рассмотрены отдельно.
3.2.1. Определения
Когда речь идет об иерархических структурах, то под термином «дерево» обычно понимают дерево с корнем (корневое дерево). Однако заметим, что корневое дерево  это частный случай более общего определения дерева, называемого иначе свободным деревом. Свободные деревья, в свою очередь, являются частным случаем графов и определяются в терминах теории графов. Они будут рассмотрены позже. В данной главе будем рассматривать только корневые деревья, называя их просто деревьями.
Формально дерево можно рекурсивно определить следующим образом [8].
Дерево (tree)  конечное множество T одного или более узлов (nodes) со следующими свойствами:
Существует один выделенный узел, называемый корнем (root) этого дерева T. Дерево может состоять и из одного корня.
Остальные узлы (если они есть) распределены среди k непересекающихся множеств T1, Т2, ..., Tk, и каждое их этих множеств, в свою очередь, является деревом. Деревья T1, Т2, ..., Tk называются поддеревьями (subtrees) этого корня.
Из этого определения следует, что каждый узел дерева является корнем некоторого другого дерева (поддерева).
Совокупность нескольких непересекающихся деревьев называется лесом (forest иногда переводится как бор). Например, все потомки одного узла дерева образуют лес. Лес всегда можно преобразовать в дерево, добавив один единственный корневой элемент и связав его с корнями всех деревьев, из которых состоит лес. Поэтому лес и дерево  это два неразрывно сязанных понятия. Для того, чтобы подчеркнуть общность этих понятий, лес из n деревьев иногда называют деревом с n-кратным корнем.
Обратим внимание, что дерево всегда имеет хотя бы один узел (корень), в то время как лес может быть и пустым, т. е. не содержащим ни одного дерева.
3.2. Способы представления деревьев
Существует множество способов представления деревьев, одни из них используют двухмерные рисунки для наглядного отображения отношений иерархии, в других эти отношения удается отобразить и при помощи одномерного представления. Остановимся на наиболее часто используемых способах.
13 EMBED Visio.Drawing.6 1415
Рис.3.2. Графическое изображение дерева
Традиционно деревья изображают графически, располагая корень вверху (т. е. дерево растет вниз), как показано на рис. 3.2. Очевидно, такое представление связано с тем, что человеку привычнее рисовать и читать рисунок сверху вниз. Узлы дерева обычно изображают с помощью окружностей, соединяя каждый узел с его сыновьями линиями (связями). Связи обычно изображают без стрелки на конце.
Другим представлением может быть так называемый уступчатый список. На рис.3.3,а,б так представлено дерево из рис.3.2.
a
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
· а
b
·
·
·
·
·
·
·
·
·
·
·
·
·
· b
i
·
·
·
·
·
·
·
·
·
· i
j
·
·
·
·
·
·
·
·
·
· j
c
·
·
·
·
·
·
·
·
·
·
·
·
·
· c
h
·
·
·
·
·
·
·
·
·
· h
d
·
·
·
·
·
·
·
·
·
·
·
·
·
· d
e
·
·
·
·
·
·
·
·
·
· e
f
·
·
·
·
·
·
·
·
·
· f
k
·
·
·
·
·
·
· k
g
·
·
·
·
·
·
·
·
·
· g
а ) б) 
Рис.3.3. Представление дерева: а – в виде уступчатого списка; б – в виде “упрощенного”уступчатого списка
Здесь двухмерность рисунка поддерживается посредством отступов.
Более компактными являются одномерные способы изображения деревьев. Например, от списка с отступами можно легко перейти к десятичной системе обозначений Дьюи, которая используется в библиографии. Например, для нашего дерева она будет выглядеть так:
1.a, 1.1.b, 1.1.1.i, 1.1.2.j, 1.2.c, 1.2.1.h,
1.3.d, 1.3.1.e, 1.3.2.f, 1.3.2.1.k, 1.3.3.g
Другой вид одномерного представления дерева - это так называемая скобочная запись, в которой отношения иерархии представляются с помощью вложенности скобок. Один из возможных вариантов скобочной записи [8] для дерева (рис.3.2) выглядит так:
( a (b (i)(j) )(c (h) ) (d (e) (f(k) )(g) ) )
Можно немного сократить количество скобок:
a ( b ( i, j ), c ( h ), d ( e, f ( k ), g ))
Такой способ называется левым скобочным представлением дерева, поскольку корень каждого поддерева расположен слева от скобки, открывающей список его поддеревьев. Возможны и другие способы перечисления порядка узлов, например, правое скобочное представление, в котором корень расположен справа. Заметим, что различные формы скобочного представления связаны с понятием обхода дерева, который будет подробно рассмотрен ниже.
В заключение упомянем еще один компактный способ представления деревьев, который основан на очень важном свойстве иерархической структуры. На рис. 3.2 хорошо видно, что дерево разветвляется от корня к листьям, т. е. каждый узел (кроме корня) имеет только один связанный с ним родительский (вышестоящий) узел. Из этого следует, что возможно такое простое представление дерева, в котором для каждого узла указана одна единственная ссылка на его родителя (для корня  пустая ссылка).
Например, представим дерево из рис. 3.2. в виде таблицы, содержащей для каждого узла обозначение его родителя. Для экономии места расположим таблицу горизонтально, хотя логичнее представить дерево в виде таблицы из двух столбцов «узел»-«его родитель».
Таблица 3.2.
Предствление дерева из рис. 3.2 с помощью ссылок на родителей
Узел
a
b
c
d
i
j
h
e
f
g
k

Родитель
nil
a
a
a
b
b
c
d
d
d
f


Подобное представление фактически не используется для наглядного представления деревьев, т. к. сильно проигрывает в этом плане всем расмотренным выше способам представления. В большинстве алгоритмов, использующих деревья, такая структура также не приведет к эффективной реализации, поскольку чаще необходимо движение по дереву от корня к листьям, чем наоборот. Однако есть область, где такой способ представления иерархической информации является основным  это реляционные (табличные) базы данных. В связи с этим обратим на него внимание.
Еще один способ представления деревьев с помощью указания левого сына и правого брата каждого узла также не обладает достаточной наглядностью, но очень удобен для эффективной реализации. Этот способ будет подробно рассмотрен ниже (см. разд. 3.4).
3.2.3. Терминология деревьев
В предыдущих разделах уже были введены основные термины, касающиеся деревьев. Учитывая важность темы, рассмотрим этот вопрос подробно.
Стандартная терминология деревьев происходит от генеалогических деревьев. Каждый узел (элемент дерева) является родителем (parent) корней его поддеревьев, а сами корни называются братьями, а также детьми (child) или сыновьями своего родителя. Узлы, которые или являются детьми данного узла, или являются детьми его детей, называются потомками данного узла. И наоборот, родитель узла, а также родители родителей называются предками узла. Корень является общим предком всех узлов дерева.
Узел, не имеющий детей, называется листом или внешним узлом (leaf, external node). Остальные узлы являются внутренними (internal). Количество детей (непосредственных потомков) узла называется его степенью. Листья имеют степень ноль. Если максимальная степень узлов дерева больше двух, то такие деревья называют сильно ветвящимися.
Путем (path) из узла n1 в узел nk называется последовательность узлов n1. n2, ..., nk, где для всех i ( 113EMBED Equation.31415 i< k) узел ni, является родителем узла ni+1. Длиной пути называется число, на единицу меньшее числа узлов, составляющих этот путь (или равное количеству линий связи). Например, на рис. 3.2 путем из узла d в узел k будет являться последовательность d f k. Длина такого пути равна двум (две связи).
Основное свойство корневых деревьев  путь от корня до любого узла всегда существует и является единственным. Это свойство вытекает из того факта, что в иерархических структурах каждый узел всегда имеет только одного родителя (одну линию связи с родительским узлом), а значит, и единственный путь до корня дерева.
Высотой (height) узла дерева называется длина самого длинного пути из этого узла до какого-либо листа. Высота дерева совпадает с высотой корня.
Уровень узла (level) определяется как длина пути от корня до этого узла. Корень имеет уровень ноль. Все братья имеют один и тот же уровень, который на единицу больше уровня их отца. Например, на рис. 3.2 братья a, b и c имеют уровень 1, а сыновья узла с (братья e,f и g) имеют уровень 2. Высота дерева на рис.3.2 равна трем.
3.2.4. Упорядоченные деревья и леса. Связь с иерархическими списками
Упорядоченное (ordered) дерево  дерево, у которого относительный порядок сыновей (поддеревьев) имеет значение. Обычно сыновья упорядочиваются слева направо. Поэтому два дерева на рисунке 3.4. различны, так как порядок сыновей узла а различен.
13 EMBED Visio.Drawing.6 1415
Рис. 3.4. Два различных упорядоченных дерева
Если порядок сыновей игнорируется, то такое дерево называется неупорядоченным. В большинстве алгоритмов используются упорядоченные деревья, очевидно, это объясняется тем, что расположение данных в памяти компьютера всегда имеет определенный порядок.
Если дерево является упорядоченным, то и каждый лес, являющийся частью этого дерева, также является упорядоченным. Упорядоченный лес можно рассматривать как линейный список деревьев, в котором можно четко выделить первое дерево (оно расположено слева от всех своих братьев), второе и т. д. Аналогично списку, в частном случае упорядоченный лес может не содержать ни одного дерева (быть пустым) или содержать одно единственное дерево.
Такое представление упорядоченного леса позволяет рассматривать упорядоченные леса (и деревья) как одну из разновидностей иерархических списков (вспомним определение иерархического списка). Однако иерархические списки считаются более универсальной структурой данных [8]:
несколько иерархических списков может пересекаться (т. е. иметь одинаковые подсписки);
иерархические списки могут быть рекурсивными (т. е. содержать ссылки на самих себя).
Такие возможности не противоречат определению иерархического списка, но не допустимы для деревьев исходя из их определения. Другими словами, для каждого дерева существует эквивалентная структура иерархического списка, однако есть иерархические списки, которые не соответствуют никакой древовидной структуре.
Примечание
Строго говоря, иерархические списки, которые пересекаются или содержат ссылку на самих себя, уже нельзя отнести к иерархическим структурам данных, правильнее назвать их многосвязными структурами. В фундаментальной работе [8] они рассматриваются именно как многосвязные структуры и называются Списками (с заглавной буквы). В реальных задачах такие нетривиальные случаи встречаются редко, поэтому термин «иерархические списки»  достаточно устоявшееся название подобных структур данных.
Далее рассмотрим еще один вид деревьев  бинарные.
3.3. Бинарные деревья
3.3.1. Определение. Представления бинарных деревьев
Бинарное (двоичное) дерево  особый вид дерева, в котором каждый узел имеет не более двух поддеревьев, причем в случае одного поддерева следует различать левое и правое поддерево. При изображении бинарных деревьев левого и правого сына различают по наклону соединительной линии (влево или вправо). На рис.3.4 показаны два различных бинарных дерева. Интересно отметить, что если рассматривать данные структуры как обычные упорядоченные деревья, то они являются полностью идентичными (в упорядоченном дереве единственный сын всегда первый, т. е. левый потомок). Это говорит о том, что бинарные деревья не являются частным случаем упорядоченого дерева, а представляют собой особый вид деревьев.
13 EMBED Visio.Drawing.6 1415
Рис.3.5. Два различных бинарных дерева
Приведем формальное рекурсивное определение бинарного дерева [8].
Бинарное дерево  конечное множество узлов, которое является пустым или состоит из корня и двух непересекающихся бинарных деревьев, которые называются левым и правым поддеревьями данного корня.
Обратим внимание на то, что бинарное дерево может быть пустым, в отличие от обычного дерева, которое всегда содержит хотя бы один узел (однако лес может быть пустым).
Бинарное дерево может быть представлено и в форме скобочного выражения. Аналогично обычному корневому дереву, для бинарного дерева также возможен различный порядок перечисления узлов в скобочном представлении. Например, левое скобочное представление непустого бинарного дерева рекурсивно определяется так:
(<корень> (<левое поддерево> <правое поддерево>))
Иногда при записи левое и правое поддерево разделяют запятыми, но чаще пробелом.
Левое или правое поддерево или оба вместе (для листьев) могут быть пустыми, при этом для пустых деревьев часто используется специальное обозначение (. Чтобы сократить запись, в ней разрешается опустить правое поддерево, если оно пустое, а для листьев опустить оба пустых поддерева (но нельзя опускать пустое левое поддерево, иначе по такой записи нельзя будет правильно восстановить изображение бинарного дерева!). Так, деревьям, изображенным на рис.3.5, соответствуют различные левые скобочные записи в сокращенной форме:
( a ( b (c (( e) d ) ) )
( a ( ( b ( c ( e ) d ) )
Бинарные деревья, у которых все узлы, кроме листьев, имеют сторого по два сына, называются строго бинарными. Деревья, изображенные на рис. 3.5, не являются строго бинарными.
3.3.2. Математические свойства и специальные виды бинарных деревьев
Бинарные деревья, как абстрактные математические объекты, обладают рядом интересных свойств, которые потребуются при анализе различных алгоритмов, как использующих деревья в качестве структур данных (сортировка, сжатие, поиск данных), так и не использующих деревья в явном виде, например, алгоритмы типа «разделяй и властвуй». Многие алгоритмы используют специальные виды бинарных деревьев, которые обладают дополнительными полезными свойствами. Рассмотрим этот вопрос подробнее.
На любом уровне k бинарное дерево может содержать от 1 до 2k узлов (k=0,1,..h, где h  высота дерева). На рис. 3.6,а дерево содержит 8 узлов при высоте 3, в то время как дерево на рис 3.6,б содержит 5 узлов при высоте 4.
13 EMBED Visio.Drawing.6 1415
Рис.3.6. Бинарные деревья различной плотности
Число узлов, приходящееся на уровень, характеризует плотность дерева и определяет его высоту, которая является важным показателем при анализе алгоритмов. Рассмотрим крайние случаи.
Вырожденные бинарные деревья
Дерево, содержащее на каждом уровне только один узел, называется вырожденным (degenerate) деревом (рис. 3.6,б). Вырожденное дерево можно считать аналогом линейного связного списка, поэтому при заданном количестве узлов n высота вырожденного дерева является максимально возможной:
h=n-1
В большинстве алгоритмов, использующих бинарные деревья, вырожденное дерево  наихудший случай при оценке производительности.
Наоборот, деревья с большой плотностью очень важны в качестве структур данных, так как они содержат пропорционально больше элементов вблизи корня, т.е. с более короткими путями от корня.
Полные бинарные деревья
Наивысшей степенью плотности обладают полные бинарные деревья, которые имеют 2k узов на каждом уровне k (рис. 3.7).
13 EMBED Visio.Drawing.11 1415
Рис.3.7. Полное бинарное дерево высоты два
Рассмотрим некоторые свойства полных бинарных деревьев.
На первых k-1 уровнях количество узлов составляет
1 + 2 + 4 + ... + 2k-1 = 2k-1
На уровне k количество узлов 2k, т. е. ровно на один больше.
Из этого следует, что в полном бинарном дереве количество внутренних узлов на единицу меньше количества листьев, а общее число его узлов и высота связаны соотношением n=2h+1-1.
Следовательно, высота полного бинарного дерева определяется формулами:
h= log 2 (n+1)-1, где n  количество узлов полного бинарного дерева
или
h=log 2 L, где L  количество листьев (поскольку L=2h )
Приведенные формулы соответствуют минимально возможному значению высоты бинарного дерева.
Бинарные деревья минимальной высоты с произвольным числом узлов
В общем случае, когда количество узлов дерева имеет произвольное значение, для получения бинарного дерева минимальной высоты необходимо заполнять все уровни дерева, кроме последнего, максимально возможным количеством узлов.
Тогда 2h
·n
·2h+1-1 , следовательно, h= (log 2 (n+1)-1(
Здесь операция (x( обозначает ближайшее целое, большее или равное x, например, (2,1( =3; (2,9( =3.
Почти полные бинарные деревья
Если узлы на последнем уровне располагать по порядку, начиная слева, то полученное таким образом бинарное дерево называют почти полным. На рис. 3.8 изображено почти полное бинарное дерево.
13 EMBED Visio.Drawing.11 1415
Рис.3.8. Почти полное бинарное дерево
Полные и почти полные бинарные деревья обладают еще одним интересным свойством  если их узлы нумеровать, начиная с единицы, сверху вниз и слева направо, то левому сыну всегда будет соответствовать код, в два раза больше кода его родителя, а правому сыну  код, на единицу больший, чем код код левого сына (рис.3.8). Номер корня всегда равен 1, его левый потомок получает номер 2, правый - номер 3. Левый потомок узла 2 получит номер 4, а правый - 5, левый потомок узла 3 получит номер 6, правый - 7 и т.д.
13 EMBED Visio.Drawing.6 1415
Рис.3.8. Нумерация узлов полного или почти полного бинарного дерева
По такой схеме можно нумеровать и узлы бинарных деревьев, которые не являются почти полными, поскольку в этом случае гарантируется уникальность каждого номера, если в процессе работы к дереву добавляются новые листья. Используя такой способ нумерации, можно реализовать древовидную структуру на основе массива. Заметим, что в С/С++ нумерация элементов массива начинается с нуля, поэтому схема нумерации будет немного отличаться от приведенной на рисунке 3.8,б.
Идеально сбалансированные бинарные деревья
Рассмотрим еще один вариант заполнения последнего уровня бинарного дерева минимальной высоты, при котором формирующееся дерево всегда будет идеально сбалансированным. Идеально сбалансированным называется такое бинарное дерево, каждый узел которого обладает следующим свойством  количество узлов в его правом и левом поддереве различается не более чем на единицу. Возможная последовательность добавления узлов к бинарному дереву, при которой оно всегда будет идеально сбалансированным, приведена на рис. 3.9.
13 EMBED Visio.Drawing.11 1415
Рис.3.9. Последовательность построения идеально сбалансированного бинарного дерева
Полные бинарные деревья являются идеально сбалансированными, более того, для полного бинарного дерева количество узлов в левом и правом поддереве каждого узла всегда одинаково.
Расширенные бинарные деревья
В заключение рассмотрим полезные свойства строго бинарных деревьев (в таких деревьях каждый внутренний узел содержит ровно двух сыновей). Ранее было доказано, что количество листьев в полном бинарном дереве ровно на единицу больше количества внутренних узлов. Можно показать, что данное утверждение справедливо для любого строго бинарного дерева, не обязательно полного. Действительно, пусть количество листьев равно L, а количество внутренних узлов  S. Тогда число ветвей, исходящих из всех внутренних узлов, равно 2S (дерево строго бинарное). Общее количество ветвей дерева на единицу меньше числа узлов и составляет L+S-1 (в каждый узел дерева, кроме корня, входит ровно одна ветвь). Следовательно, 2S=L+S-1, отсюда L=S+1, а общее количество узлов строго бинарного дерева n=2L-1.
Этими полезными свойствами строго бинарных деревьев можно воспользоваться и для анализа обычных бинарных деревьев. Для этого бинарное дерево дополняют фиктивными внешними узлами так, чтобы каждая ветвь дерева заканчивалась таким фиктивным узлом. При таком дополнении любое бинарное дерево превращается в строго бинарное и называется расширенным бинарным деревом. Пример изображения расширенного бинарного дерева изображен на рис. 3.10, при этом фиктивные листья показаны прямоугольниками.
13 EMBED Visio.Drawing.11 1415
Рис. 3.10. Расширенное бинарное дерево
3.4. Деревья как АТД
Аналогично другим структурам данных, таким как линейные и иерархические списки, можно ввести понятия АТД «Дерево», «Лес» и «Бинарное дерево», представив формальную функциональную спецификацию. Однако в данном случае положение осложняется двумя обстоятельствами.
Во-первых, имеется огромное количество алгоритмов, использующих деревья, причем очень часто это деревья специального вида, подобные тем, которые были рассмотрены в предыдущем разделе, а их функции зависят от задачи, для решения которой они предназначены. Во-вторых, для обработки деревьев активно используется как рекурсивный, так и итерационный подход, которые предполагают различные наборы операций (базовых функций). В связи с этим в литературе можно встретить различные варианты функциональной спецификации деревьев [2, 3], а некоторые авторы вообще отказываются от введения АТД при анализе древовидных структур.
Тем не менее, обсуждать вопросы реализации деревьев, не имея никакой функциональной спецификации, затруднительно, поэтому введем соответствующие АТД, выделив минимальный универсальный набор операций. При этом воспользуемся рекурсивным подходом, несколько модифицировав набор операций, который вводился ранее для иерархических списков.
АТД «Дерево» и «Лес»
Рассмотрим функциональную спецификацию структуры данных дерева c узлами типа
·
·: Tree (
· ). При этом лес деревьев Forest ( Tree (
· ) ) определим как L_list ( Tree (
· ) ) через уже известную структуру линейного списка L_list с базовыми функциями Cons, Head, Tail, Null (см.1.6). Базовые операции с деревом задаются набором функций:
1) Root : Tree (
·;
2) Listing : Tree ( Forest;
3) ConsTree :
·
· ( Forest ( Tree
и аксиомами, справедливыми для любого u типа
·; любого f типа Forest ( Tree (
· ) ); любого t типа Tree (
· ) ):
А1) Root ( ConsTree ( u , f ) ) = u;
А2) Listing ( ConsTree ( u , f ) ) = f;
А3) ConsTree ( Root ( t ) , Listing ( t ) ) = t.
Здесь функции Root и Listing - селекторы: Root выделяет корень дерева, а Listing выделяет лес поддеревьев корня данного дерева. Конструктор ConsTree порождает дерево из заданных корня и леса поддеревьев.
АТД «Бинарное дерево»
Далее введем АТД BinTree  сокращенно BT( 
· ), где
·   тип данных, которые хранятся в узлах бинарного дерева . Считаем, что значение типа BT есть либо ( (пустое бинарное дерево), либо значение типа NonNullBT( 
· ). Тогда базовые операции типа BT ( 
· ) задаются набором функций:
0) ( : ( BT(
·);
1) Root: NonNullBT(
·) (
·;
2) Left: NonNullBT (
· )( BT(
·);
3) Right: NonNullBT ( BT(
·);
4) ConsBT:
·
· ( BT(
·) ( BT(
·) ( NonNullBT(
·);
5) IsNull: BT(
·) ( Boolean;
и набором аксиом, справедливых для всех  u типа
·, b типа NonNullBT (
· ), b1, b2 типа BT (
· ) ):
A1) IsNull ( ( ) = true;
A1') IsNull ( b ) = false;
A2) IsNull ( ConsBT ( u , b1 , b2 ) ) = false;
A3) Root ( ConsBT ( u , b1 , b2 ) ) = u;
A4) Left ( ConsBT ( u , b1 , b2 ) ) = b1;
A5) Right ( ConsBT ( u , b1 , b2 ) ) = b2;
A6) ConsBT (Root ( b ) , Left ( b ) , Right ( b )) = b.
Здесь функции Root, Left и Right ( селекторы: Root выделяет значение корня бинарного дерева, а Left и Right ( его левое и правое поддеревья соответственно. Конструктор ConsBT порождает бинарное дерево из заданных узла и двух бинарных деревьев  его сыновей. Предикат IsNull ( индикатор, различающий пустое и непустое бинарные деревья.
Используя рекурсивные вызовы представленных базовых функций, можно реализовать любые действия с бинарными деревьями. Например, дерево, которое изображено на рис. 3.10,а, в следующем разделе будет использоваться как пример для реализации. Если обозначить его t, то можно записать следующее полное левое скобочное представление (для тренировки изобразите дерево, не заглядывая в рисунок):
t=a(b(d (( () e(g(( () ()) с( ( f( ( ()))
Его можно сформировать, используя функцию consbt, следующим образом:
t=consbt(a, consbt(b, consbt(d,(,(),consbt(e,consbt(g,(,(),()), consbt(c,(,consbt(f,(,()))
Однако, для того, чтобы добавить или удалить узел (а это типовые операции в большинстве применений деревьев), придется сначала «разобрать» дерево с помощью селекторов, а затем «собрать» с помощью конструктора. Например, для того, чтобы удалить узел g из уже сформированного дерева t, можно использовать следующую последовательность операций
t=consbt(root(t), consbt(root(left(t)), left(left(t), consbt(root(right(left(t))), (,()), right(t))
При реализации данного выражения на языке С++ (или Pascal) придется решать серьезные проблемы с корректным выделением и освобождением памяти, поэтому такие операции как добавление, удаление узлов, а также поиск и ряд других обычно реализуют как самостоятельные функции, используя для этого рекурсию или итерацию.
Примем данную спецификацию за основу, которую можно дополнить (изменить) в конкретных случаях.
3.5. Соответствие между упорядоченным лесом, бинарным деревом и иерархическим списком
Как видно из предыдущего изложения, несмотря на видимые различия, иерархические структуры имеют много общего. Рассмотрим этот вопрос подробнее, проанализировав соответствие и возможности преобразования между данными структурами.
3.5.1. Каноническое соответствие между бинарным деревом и упорядоченным лесом
Справедливо следующее утверждение:
существует однозначное соответствие между бинарным деревом и упорядоченным лесом.
Другими словами, для каждого упорядоченного леса можно построить эквивалентное ему бинарное дерево, а, выполнив обратные преобразования для бинарного дерева, можно снова получить тот же самый упорядоченный лес.
Будем считать, что упорядоченный лес задан своим графическим представлением, например, на рис.3.9,а изображен упорядоченный лес из двух деревьев. Для перехода от упорядоченного леса к соответствующему ему бинарному дереву воспользуемся следующим алгоритмом:
разорвем все связи между узлами, оставив для каждого только крайнюю левую связь  от узла к его левому сыну, если он есть;
проведем правую связь от каждого узла к его правому брату, если он есть.
Таким образом, каждый узел имеет теперь не более двух связей  правую и левую, причем любая из них может отсутствовать.. Такое представление леса называется «левый сын»-«правый брат» [3] и ему соответствует бинарное дерево, изображенное на рис.3.9, б.
13 EMBED Visio.Drawing.6 1415
а)упорядоченный лес из двух деревьев б)соответствующее бинарное дерево
13 EMBED Visio.Drawing.6 1415
в) привычное изображение бинарного дерева
Рис.3.9. Соответствие между упорядоченным лесом и бинарным деревом.
Немного развернув рисунок, получим привычное изображение бинарного дерева (рис.3.9,в)
Полученное бинарное дерево также можно снова превратить в лес, выполнив обратные преобразования для каждого узла, т. е. развернув рисунок в обратном направлении и превратив правого сына каждого узла в правого брата этого узла путем изменения связей.
Поскольку алгоритмы и прямого, и обратного перехода включают действия, которые можно выполнить только единственным образом, можно говорить об однозначном соответствии между упорядоченным лесом и эквивалентным ему бинарным деревом. Такое соответствие иначе называется естественным или каноническим [8,10].
Аналогично упорядоченному лесу, для каждого упорядоченного дерева можно построить эквивалентное ему бинарное дерево, и при необходимости выполнить обратные преобразования. В полученном бинарном дереве будет отсутствовать правое поддерево, поскольку у корня дерева нет братьев. Например, если из бинарного дерева на рисунке 3.9,в удалить правое поддерево, то получим бинарное представление первого дерева леса из рисунка 3.9,а.
3.5.2. Взаимосвязь бинарных деревьев и иерархических списков
Каноническое соответствие между упорядоченным лесом и бинарным деревом можно обосновать более формально, используя введенную ранее функциональную спецификацию иерархических списков и деревьев. Пусть лес F задан как список деревьев Ti(i=1,2,...,m):
F = (Т1,Т2,...,Тm).
Считая F особым видом иерархического списка, представим его в виде пары «голова-хвост». Тогда голова Head(F) ( первое дерево Т1  леса F, а хвост Tail(F)  лес остальных деревьев (Т2 ... Тm). Если в дереве T1=Head(F) выделить корень и лес поддеревьев, то исходный лес F представляется как совокупность трех частей:
корень первого дерева упорядоченного леса Root(Head(F)),
лес поддеревьев этого первого дерева Listing(Head(F)),
оставшаяся часть исходного леса без первого дерева Tail(F).
Из этих трех частей рекурсивно порождается бинарное дерево B(F), представляющее лес F:
B(F) = если IsNull(F) то (
иначе ConsBT(Root(Head(F)),
B(Listing(Head(F))),
B(Tail(F)))
Согласно этому определению, у бинарного дерева B(F) корнем является корень первого дерева Т1 в лесу F, левым поддеревом является бинарное дерево, представляющее лес поддеревьев первого дерева Т1, а правым поддеревом является бинарное дерево, представляющее лес остальных (кроме первого) деревьев исходного леса F.
Ранее та же структура бинарного дерева была получена с помощью представления «левый сын»  «правый брат».
Приведенные рассуждения еще раз подтверждают общность иерархических списков, деревьев и бинарных деревьев и приводят к выводу  любое упорядоченное дерево (лес) можно реализовать либо в виде иерархического списка (S-выражения), либо бинарного дерева.
При использовании императивных языков (таких как С++ или Pascal) более эффективной является реализация бинарных деревьев. Отметим, что многие задачи (сортировка, поиск, сжатие данных и т. п.) сами по себе предполагают использование бинарных деревьев. В связи с этим внимательно отнесемся к реализации бинарных деревьев.
3.6. Ссылочная реализация бинарных деревьев
Наиболее понятным и естественным вариантом для бинарных деревьев является ссылочная реализация, однако может быть выполнена и непрерывная реализация, учитывая возможность нумерации узлов бинарных деревьев, рассмотренную в 3.3.2. Поскольку непрерывная реализация используется в одном из способов сортировки, она будет рассмотрена в главе 4.
Далее рассмотрим ссылочную реализацию как более универсальную. Учитывая тот факт, что в бинарном дереве каждый узел содержит не более двух сыновей, причем левого и правого сына следует различать, в указующей части каждого узла хранятся ровно две ссылки  на левое и правое поддерево. При этом бинарное дерево полностью определено указателем на его корень.


13 EMBED Visio.Drawing.6 1415
Рис.3.10. Пример бинарного дерева (a) и его ссылочного представления (б)
На рис.3.10 представлен пример ссылочной реализации бинарного дерева. Обратим внимание, что структурно каждый элемент бинарного дерева напоминает элемент двунаправленного списка, который тоже содержит ровно две ссылки. Однако смысл данных ссылок разный, что приводит к различным алгоритмам обработки. Заметим также, что представление бинарного дерева содержит большое количество пустых ссылок, гораздо больше, чем линейные списки. Каждый лист содержит две пустые ссылки, а если дерево не является строго бинарным, то и внутренние узлы могут содержать пустую левую или правую ссылку. К этому обстоятельству мы еще вернемся.
Проанализируем два варианта ссылочной реализации  на основе указателей и на основе массива (вектора).
3.6.1. Ссылочная реализация бинарного дерева на основе указателей
Реализация на основе указателей является простой и естественной, поэтому она наиболее распространена. Каждый узел описывается структурой, которая содержит, кроме данных, указатели на левого и правого сына.
struct node //структура одного узла BT
{type_of_data data; // данные, тип был определен в typedef
node *left_bt,*right_bt;//указатели на левого и правого сына
};
//указатель на узел BT (корень дерева или поддерева)
typedef node* bt;
При таком подходе каждая из базовых функций реализуется буквально «в лоб» в соответствии с приведенной выше спецификацией. Пустому дереву, как обычно, соответствует пустой указатель NULL, все функции-селекторы обязательно проверяют дерево на пустоту.
void error() //небольшая вспомогательная функция
{cerr<<"дерево пусто!!!"; exit(1);}
//базовые функции обработки бинарного дерева
type_of_data root(bt t) //получить значение корня
{ if (t) return t->data; else error();
}
bt left(bt t) //перейти к левому поддереву
{if (t) return t->left_bt; else error();
}
bt right(bt t) //перейти к правому поддереву
{ if (t) return t->right_bt; else error();
}
bt consbt(type_of_data r, bt l_tree, bt r_tree)
//формирование дерева из корня и двух поддеревьев
{ bt t=new node; t->data=r;
t->left_bt=l_tree; t->right_bt=r_tree;
return t;
}
bool isnull(bt t) {return t==NULL;}// проверка на пустоту
Используя базовые функции, реализуем дополнительную функцию  вывод левого скобочного представления.
void out(bt t) // вывод дерева t
{ if (!isnull(t))
{cout << root(t)<<'('; out(left(t));out(right(t));cout<<')';}
}
Например, построим и выведем дерево из рисунка 3.10,а (тип элементов определим с помощью typedef char type_of_data;)
bt t=consbt('a', consbt('b', consbt('d',NULL,NULL),
consbt('e',consbt('g',NULL,NULL), NULL)),
consbt('c',NULL,consbt('f',NULL,NULL)));
out(t);
Дерево формируется «снизу вверх», начиная с листьев, поскольку это самый глубокий уровень вложенности функции consbt. Каждый родитель формируется после того, как сформированы оба его сына. Последним будет сформирован корень.
Для дерева из рисунка 3.10,а узлы будут сформированы в следующей последовательности  f c g e d b a (это легко проверить, добавив в тело функции consbt вывод значения корня).
3.6.2. Ссылочная реализация на основе массива
В разделе 2.5 рассматривался способ ссылочной реализации линейного списка на основе массива (вектора). Вспомним, что в этом случае элементы могут располагаться в массиве не по порядку, но каждый элемент содержит одну или две ссылки на своих соседей. Ссылки обычно представляют собой индексы элементов массива, следовательно, если применять такой принцип к бинарному дереву, то каждый узел можно представить как структуру (запись), в которой хранятся данные и два индекса  ссылка на левого и правого сына (пустой ссылке обычно соответстствует несуществующий индекс, например, 0 или -1).
Все узлы дерева размещаются в одном массиве, размеры которого подбираются таким образом, чтобы в нем поместились все узлы и осталось достаточно свободных элементов для добавления новых узлов. Все же размеры массива всегда ограничены, поэтому количество узлов дерева также должно быть ограничено, а при реализации функций обязательна проверка исключительной ситуации, связанной с переполнением массива.
Описание такой структуры может выглядеть, например, так:
struct node // структура для одного узла дерева
{ type_of_data data; // данные
int left, right; // индекс левого и правого сына
};
const int maxlength=10000; //максимальный размер массива
node bintree[maxlength]; //массив для бинарного дерева
int topfree; //ссылка на начало свободной области (индекс)

Построить дерево по такому принципу несколько сложнее, чем при использовании указателей, поскольку программа должна сама корректно выделять память под новые узлы дерева и освобождать ее при удалении узлов. Но в этом случае процесс выделения памяти становится полностью управляемым, и в каких-то случаях такая реализация может оказаться предпочтительней.
Принципы выделения памяти под узлы бинарного дерева такие же, как и для элементов линейного списка. В процессе работы узлы дерева, скорее всего, будут расположены не в соседних элементах массива (если уже выполнялись удаления узлов), соответственно, и свободные элементы расположены хаотически. В таком случае область свободных элементов внутри массива целесообразно представить в виде связного списка. В каждом элементе массива есть целых два поля для хранения ссылок, но для целей выделения и осовобождения памяти достаточно и одного из них (однонаправленный список). Допустим, можно использовать поле left для хранения ссылки на следующую свободную ячейку, а выделение и освобождение памяти организовать по принципу стека, как это делалось при ссылочной реализации списков на массиве (т.е. тот элемент массива, который только что освободился при удалении узла, будет выделен под новый добавляемый узел, поскольку именно он окажется на верхушке стека).
Поясним процесс формирования бинарного дерева на простом примере. Пусть необходимо сформировать то же самое дерево, изображенное на рис.3.10,а, используя массив из 10 элементов.
В начальный момент весь массив представляет собой пустой стек. Дерево формируется «снизу вверх», начиная с листьев. Последним будет сформирован корень, он окажется на границе занятой и свободной части массива, которые непрерывны только до первого удаления какого-либо узла.
Если обозначить пустую ссылку как -1, то содержимое массива будет таким, как изображено в табл. 3.3, а ссылка topfree имеет значение 7, если нумерация элементов массива начинается с нуля.
Таблица 3.3.
Реализация дерева из рисунка 3.10,а с помощью массива
Индекс в массиве
Значение data
Левый сын left
Правый сын right
Примечание

0
f
-1
-1
Лист f

1
c
-1
0
Узел c

2
g
-1
-1
Лист g

3
e
-1
2
Узел e

4
d
-1
-1
Лист d

5
b
4
3
Узел b

6
a
5
1
Корень дерева a

7

8

Начало списка свободных элементов

8

9

Свободный элемент

9

-1

Последний свободный элемент


Реализацию приведенных в спецификации базовых функций можно выполнить самостоятельно по аналогии с разделом 2.5. При этом интересно реализовать такие дополнительные функции как вставка и удаление узлов.
3.6.3. Пример  построение дерева турнира
В качестве первого примера построения бинарного дерева представлена функция для построения так называемого «дерева турнира» (функция называется contest). Данная функция является рекурсивной и использует базовые функции для работы с бинарным деревом.
Сначала поясним, что представляет собой дерево турнира [Сэнджвик]. Это бинарное дерево, в котором вся полезная информация содержится в листьях, а каждый внутренний узел  это копия наибольшего из его сыновей. Следовательно, корень такого дерева копия наибольшего из значений листьев. Поэтому дерево турнира может быть использовано, например, для определения максимального значения в последовательности (при этом используется термин «разыграть турнир», очевидно, по аналогии с турниром по теннису, где после каждой игры проигравший выбывает). К сожалению, такой способ определения максимального элемента не эффективнее, чем обычный способ с использованием цикла. Это понятно  для того, чтобы найти наибольший из n элементов, нужно убедиться, что все остальные (n-1) элементов его меньше, для чего требуется выполнить (n-1) сравнений.
Один из способов сортировки данных, называемый турнирной сортировкой, также основан на явном построении дерева турнира. Турнирная сортировка несколько проигрывает по эффективности самым быстрым методам сортировки (см. следующую главу). Тем не менее, построение дерева турнира  хорошая тренировка в реализации бинарных деревьев.
Листинг 3.1. Построение дерева турнира
#include
#include “bintree.h” // файл с определением АТД bintree (bt)
// рекурсивная функция построения дерева турнира
// low и high – индексы начала и конца части массива,
// для которой разыгрывается турнир
bt contest(int *c, int low, int high)
{ bt l,r; int m;
if (low==high)// терминальная ветвь – остался один элемент
return consbt(c[low],NULL,NULL);// листья-элементы массива
m=(low+high)/2; // находим середину части массива
// затем разыгрываем турнир для левой и правой половин
l=contest(c,low,m); r=contest(c,m+1,high);
// внутренние узлы - копия наибольшего из двух детей
if (root(l)>root(r))return consbt(root(l),l,r);
else return consbt(root(r),l,r);
}
// Например, разыграем турнир для массива из 10 элементов
int main()
{ int c[10]={0,1,2,3,4,5,6,7,8,9};
out(contest(c,0,9)); return 0;
}
На рис. 3.11 изображено дерево турнира для массива из 10 элементов, который используется в программе в качестве примера. Программа выводит дерево в левом скобочном представлении, но нетрудно написать функциюдля вывода дерева в виде двухмерного рисунка.
13 EMBED Visio.Drawing.6 1415
Рис.3.11. Дерево турнира, построенное программой из листинга 3.10.
Обратим внимание, что для вывода дерева на экран в том или ином виде необходимо пройти все его узлы в определенном порядке. Здесь мы вплотную приблизились к понятию обхода дерева. Рассмотрим этот вопрос подробнее.
3.7. Обходы бинарных деревьев и леса
3.7.1. Понятие обхода. Виды обходов
Многие алгоритмы работы с бинарными деревьями основаны на последовательной обработке узлов дерева. Если для линейного списка последовательность обработки очевидна (в однонаправленных списках только в прямом, в двунаправленных  в прямом и обратном направлении), то в бинарном дереве имеется гораздо больше возможностей из-зи наличия ветвления. В связи с этим вводится понятие обхода дерева. При обходе дерева каждый узел посещается только один раз, при этом узлы выстраиваются в определённую линейную последовательность узлов, т.е. можно говорить о предыдущем и следующем узле.
Понятие обхода вводится для любых деревьев, однако удобнее начать с обхода бинарных деревьев.
Наиболее известны и практически важны 3 способа прохождения, которые отличаются порядком и направлением обхода бинарного дерева. К сожалению, в литературе встречается довольно много различных названий для данных обходов, что порождает некоторую путаницу. В таблице 3.4 приведены основные названия (верхняя строка) и алгоритмы рекурсивного прохождения узлов дерева для каждого способа (нижняя строка).
 Таблица 3.4.
Рекурсивное прохождение бинарного дерева.
Прямой порядок, сверху вниз (в глубину), нисходящий, preorder (префиксный)
Центрированный, симметричный, слева направо, поперечный, inorder (инфиксный)
Концевой порядок,
обратный,
снизу вверх,
восходящий,
postorder(постфиксный)

Алгоритм КЛП
(корень-левое-правое),
Попасть в корень
Пройти левое поддерево
Пройти правое поддерево
Алгоритм ЛКП
(левое-корень-правое)
Пройти левое поддерево
Попасть в корень
Пройти правое поддерево
Алгоритм ЛПК (левое-правое-корень)
Пройти левое поддерево
Пройти правое поддерево
Попасть в корень

Прямой порядок прохождения означает обход в направлении сверху-вниз, когда после посещения очередного разветвления продолжается прохождение вглубь дерева, пока не пройдены все потомки достигнутого узла. По этой причине прямой порядок прохождения часто называют нисходящим, или прохождением в глубину. Прямой порядок используется в представлении дерева в форме вложенных скобок (левое скобочное представление), в виде уступчатого списка или десятичной классификации Дьюи. В генеалогических терминах прямой порядок прохождения дерева отражает династический порядок престолонаследования, когда титул передается старшему потомку.
При центрированном (симметричном) порядке дерево проходится слева направо. Такой порядок используется, например, при обходе бинарного дерева поиска, порождая упорядоченную последовательность значений. Подробнее об этом будет рассказано в главах, посвященных сортировке и поиску.
Если применяется концевой порядок прохождения, то получается обход дерева снизу-вверх, когда в момент посещения любого узла все его потомки уже пройдены, а корень дерева проходится последним. Из-за этой особенности обхода, концевой порядок называют восходящим, или обратным относительно прямого.
Иногда используется еще один способ обхода ( в горизонтальном порядке (в ширину). При таком способе узлы бинарного дерева проходятся слева направо, уровень за уровнем, от корня вниз (поколение за поколением от старших к младшим).
Таблица 3.5.
Прохождение узлов дерева при различных порядках обхода
13 EMBED Visio.Drawing.6 1415
Порядок обхода
Очередность обработки узлов


1. Прямой (КЛП)
a b d e g c f


2.Центрированный (ЛКП)
d b g e a c f


3.Обратный (ЛПК)
d g e b f c a


4. В ширину
a b c d e f g

Например, построенное ранее бинарное дерево, изображеннное на рис. 3.10,а (для удобства мы его перерисуем снова) можно обойти различными способами так, как показано в табл.3.5
3.7.2. Пример обходов  дерево-формула
Арифметические и логические выражения могут быть представлены при помощи дерева, которое получило название дерева-формулы. Возьмём в качестве примера выражение (2+4)*7-3/5. Тогда получим следующее бинарное дерево, изображенное на рис. 3.13.
13 EMBED Visio.Drawing.11 1415
Рис.3.13. Пример дерева-формулы
Листья дерева-формулы  всегда операнды (переменные или значения), а все внутренние узлы соответствуют операциям.
Строго говоря, дерево-формулу правильнее отнести к упорядоченным, а не бинарным, деревьям из-за наличия унарных операций, т. к. в этом случае правое и левое поддерево не различаются. Но если в выражении присутствуют только бинарные операции, как в приведенном примере, то соответствующее дерево-формула представляет собой строго бинарное дерево.
Каждое поддерево дерева-формулы соответствует некоторой части исходного выражения, причем операцию, которая находится в корне этого дерева, можно выполнить только после вычисления значений поддеревьев. Например, для дерева, которое изображено на рис.3.12, операция умножения (корень левого поддерева) может быть выполнена только после того, как будет выполнена операция сложения (2+4). Операция, которая находится в корне дерева выражения, должна быть выполнена последней. В данном примере сначала вычисляется (2+4)*7, затем 3/5, а последней выполняется операция вычитания. Обратим внимание, что такой порядок вычисления соответствует обратному (ЛПК) порядку обхода дерева.
Дерево-формула  классический пример для иллюстрации различных способов обхода деревьев. Одна из распространенных терминологий для названий обходов  префиксный (PreOrder, прямой порядок), постфиксный (PostOrder, обратный порядок) и инфиксный (InOrder, центрированный порядок). Эти названия связаны с различными формами представления дерева-формулы. Применяя различные методы обхода к дереву на рис.3.13, получим разные способы записи выражения:
- * + 2 4 7 / 3 5 префиксная форма
2 4 + 7 * 3 5 / - постфиксная форма
2 + 4 * 7 – 3 / 5 инфиксная форма
Как видим, во всех трех случаях порядок следования операндов один и тот же, разница только в порядке операций. Объясняется это просто  во всех трех основных порядках обхода левое поддерево обходится раньше правого, следовательно, листья дерева всегда располагаются в порядке слева направо.
Проанализируем три формы записи выражения, полученные при различных обходах дерева-формулы. Центрированный порядок полностью соответствует обычной записи арифметического выражения, если из нее убрать скобки. По такой записи невозможно правильно вычислить значение выражения, не зная, как были расставлены скобки. Зато два других способа можно использовать для вычисления значения выражения непосредственно, т. к. в этом случае порядок вычислений определяется однозначно.
Из приведенного примера видно, что при префиксной форме знак операции непосредственно предшествует своим операндам, при постфиксной форме он располагается сразу после операндов. В префиксной форме порядок выполнения операций следует читать справа налево (знак последней операции  крайний слева символ), в постфиксной  слева направо. Эти две формы иначе называются бесскобочными формами записи выражения.
Наиболее удобной в реализации является постфиксная форма, которая получила название обратной польской записи (ОПЗ), поскольку ее использование для представления выражений впервые предложил польский математик Я.Лукашевич. Она часто используется как промежуточная форма представления выражений по следующим причинам:
вычисление выражения по ОПЗ можно выполнить итеративно за один проход;
существует простой итеративный алгоритм, предложенный Дейкстрой, для перехода от арифметического выражения, записанного в обычной форме со скобками, к постфиксной форме. С ним можно познакомиться, например, в[10].
Далее рассмотрим реализацию различных способов обхода бинарных деревьев, при этом проанализируем как рекурсивный, так и нерекурсивный способы.
3.7.3. Рекурсивные функции обхода бинарных деревьев
Первые три порядка обхода, которые приводятся в таблице 3.5, легко реализуются рекурсивным вызовом функции обхода для левого и правого поддеревьев в том порядке, которого требует алгоритм. Требуется только пояснить шаг «посетить корень». Поскольку обходы деревьев могут потребоваться для любой обработки значений, находящихся в узлах, то будем считать, что этот шаг обозначает именно ту обработку, которая нужна для конкретной задачи.
Предположим, что вся обработка узла t выполняется в функции visit(t). Здесь t  указатель на узел типа bt, который был определен ранее. Все функции должны получить в качестве параметра указатель на корень дерева.
void forward(bt t)//прямой порядок обхода (КЛП)
{ if(t) { visit(t); forward(left(t)); forward(right(t));}
}
void reverse(bt t)// обратный порядок обхода (ЛПК)
{ if(t) {reverse (left(t));reverse(right(t)); visit(t);}
}
void central(bt t)// центрированный порядок обхода (ЛКП)
{ if (t) {central(left(t)); visit(t); central(right(t));}
}
Заметим, что обход в ширину не имеет простой рекурсивной реализации. Ниже мы приведем нерекурсивный алгоритм обхода в ширину с использованием вспомогательной очереди.
3.7.3. Нерекурсивные функции обхода бинарных деревьев
Учитывая важность эффективной реализации обходов бинарных деревьев, рассмотрим нерекурсивные способы обходов, использующие цикл обхода по дереву. Вспомним, что рекурсивные функции используют внутренний (системный) стек программы, в который при каждом рекурсивном вызове помещается параметр (в данном случае указатель на узел) и адрес возврата. Следовательно, для реализации нерекурсивных алгоритмов логично использовать вспомогательный (внешний) стек, которая будет использоваться для тех же целей, что и системный стек программы в рекурсивных способах.
Введем вспомогательный стек s. При использовании языков высокого уровня мы не сможем в точности воспроизвести в нем содержимое системного стека, да это и не нужно. В литературе [8,10,14] приводятся различные алгоритмы нерекурсивного обхода, в которых вспомогательный стек используется только для хранения указателей на узлы дерева. Различные способы обхода различаются порядком, в котором эти указатели заталкиваются в стек и извлекаются из него.
Предположим, что уже имеется определение шаблона стека и типа данных bt (бинарное дерево). Напомним базовые функции стека:
push, pop – добавление элемента в стек и извлечение;
isnull – проверка на пустоту.
Прямой порядок обхода (КЛП)
Наиболее прост в реализации алгоритм прямого обхода, который приводится в [14]. Напомним, что любая функция обхода получает в качестве параметра корень дерева. Поместим его в стек. Затем на каждом шаге итерации сначала извлекаем элемент с верхушки стека. При прямом обходе корень обрабатывается первым, после чего указатель на корень уже не нужен, а в стек помещается сначала указатель на правое поддерево, а затем на левое (пустые указатели в стек не помещаются). На следующем шаге итерации из стека извлекается и обрабатывается именно корень левого поддерева (он на вершине), после чего в стек заталкиваются указатели на его правое и левое поддеревья. Цикл повторяется до тех пор, пока стек не опустеет (это произойдет после извлечения крайнего правого листа). Функция прямого обхода может иметь вид:
void forwardstack(bt t)//нерекурсивный обход в прямом порядке
{ stack s; // шаблон стека должен быть реализован!
s.push(t); bt p;// поместили корень в стек
while (!s.isnull())
{ p=s.pop(); // извлекли узел
visit(p); //любая обработка узла
if(right(p)) s.push(right(p)); //добавили в стек правого
if(left(p)) s.push(left(p)); //затем левого сына
}
}
Проанализируем выполнение данной функции приментельно к дереву из семи узлов, которое уже использовалось в качестве примера. В таблице 3.6 представлены последовательность извлекаемых элементов и содержимое стека в начале каждого шага (всего семь шагов).
Таблица 3.6
Содержимое стека при прямом порядке обхода
13 EMBED Visio.Drawing.6 1415
№ итерации
Извлекаемый узел
Содержимое стека


1
a
A


2
b
Bc


3
d
dec


4
e
ec


5
g
gc


6
c
c


7
f
f

Центрированный порядок обхода (ЛКП)
Алгоритм центрированного обхода несколько сложнее, поскольку в этом случае корень каждого поддерева нельзя обрабатывать сразу, поэтому придется поместить его в стек и двигаться дальше по левой ветви, помещая в стек все узлы до первого листа [7]. Нерекурсивная функция ЛКП обхода может выглядеть так:
void centralstack(bt t) // в центрированном порядке (ЛКП)
{ stack s; bt p=t;
do // помещаем в стек узел и переходим к левому сыну
{ if (p) { s.push(p); p=left(p);}
else // дошли до левого листа, будем извлекать узлы
{if (!s.isnull())p=s.pop(); else return;
visit(p); // любая обработка узла p
p=right(p); //правое поддерево проходим после корня
}
}
while (true);
В табл. 3.7 представлено содержимое стека перед каждым извлечением очередного элемента.
Таблица 3.7
Содержимое стека при центрированном порядке обхода
13 EMBED Visio.Drawing.6 1415
№ итерации
Извлекаемый узел
Содержимое стека


4
D
dba


5
b
ba


8
G
gea


9
E
ea


10
A
a


12
C
c


14
F
f

В данном алгоритме обход дерева из семи узлов выполняется за 14 итераций, поскольку на каждой итерации в стек заносится или из него извлекается один узел (итерации, на которых в стек заносится узел, в таблице не показаны).
Обратный порядок обхода (ЛПК)
Алгоритм обратного обхода на основе вспомогательного стека s реализовать сложнее, чем два других способа. Однако в этом случае можно применить небольшую хитрость, вспомнив, что слово «обратный» означает противоположный порядок по отношению к прямому. Поэтому можно использовать приведенный выше алгоритм прямого обхода, модифицировав его следующим образом.
Вместо обработки узла (корня поддерева) будем помещать его в еще один, дополнительный стек (назовем его стеком вывода sout). Тогда на заключительном этапе алгоритма элементы дополнительного стека будут обработаны в порядке, обратном тому, в котором они поступили в стек.
Поскольку обратный порядок означает ЛПК, а не ПЛК (правое-левое-корень  такой порядок действительно обрабатывал бы узлы в противоположном по отношение к прямому порядке), то внесем еще одно изменение  сначала будем помещать в стек указатель на левого сына, а затем на правого.
Функция обратного обхода может иметь вид:
void reversestack(bt t) // обход в обратном порядке (ЛПК)
// используем дополнительный стек sout
{ stack s,sout;
s.push(t); bt p;
while (!s.isnull())
{p=s.pop();
sout.push(p);//вместо обработки узла помещаем его в стек
if(left(p)) s.push(left(p));
if(right(p)) s.push(right(p));
}
while (!sout.isnull())// извлекаем узлы из стека
{p=sout.pop(); visit(p);}//любая обработка
}
Обход в ширину
Интересно, что алгоритм прямого обхода можно применить и для реализации обхода в ширину, также выполнив две модификации.
Вместо стека используем очередь, которая как раз подходит для этих целей, поскольку при продвижении от корня к листьям в нее будут попадать все более удаленные от корня элементы. При этом, чем раньше узел попал в очередь, тем раньше он будет обработан.
Вторая модификация такая же, как и для обратного обхода  сначала помещаем в очередь левого сына, а после него правого. Это тоже понятно, поскольку узлы обходятся слева направо.
Предполагаем, что используется шаблон структуры queue, реализация которой рассмотрена предыдущей главе.
Напомним основные операции:
enqueue,dequeue – помещение элемента в очередь и удаление из нее;
isnull – проверка на пустоту.
void widthstack(bt t) // обход в ширину
{ queue q; // шаблон queue должен быть реализован!
q.enqueue(t); bt p;
while (!q.isnull())
{ p=q.dequeue();
cout << root(p)<<" ";
if(left(p)) q.enqueue (left(p));
if(right(p)) q.enqueue (right(p));
}
}
Все приведенные в данном разделе функции обхода в конце работы оставляют вспомогательные структуры пустыми, т. е. не требуют никакой дополнительной «уборки» памяти.
3.7.4. Обходы леса
Следуя каноническому соответствию между бинарными деревьями и лесами, можно на основе известных обходов бинарного дерева получить три соответствующие порядка прохождения леса (дерева). Вспомним, что любой упорядоченный лес можно представить в виде трех частей:
корень первого дерева
лес поддеревьев первого дерева
оставшиеся деревья.
На основе этого представления сформулируем правила обхода.
Прямой порядок: 
а) посетить корень первого дерева;
б) пройти поддеревья первого дерева (в прямом порядке);
в) пройти оставшиеся деревья (в прямом порядке).
Центрированный порядок: 
а) пройти поддеревья первого дерева (в центрированном порядке);
б) посетить корень первого дерева;
в) пройти оставшиеся деревья (в центрированном порядке).
Обратный (концевой) порядок: 
а) пройти поддеревья первого дерева (в концевом порядке);
б) пройти оставшиеся деревья (в концевом порядке);
б) посетить корень первого дерева.
При необходимости применить какой-либо из этих обходов к лесу (дереву) можно сначала построить бинарное дерево, представляющее этот лес, а затем применить соответствующий обход бинарного дерева. Следует заметить, что такой способ не подойдет для обхода в ширину, поскольку в этом случае порядок следования узлов для исходного дерева и эквивалентного бинарного будет отличаться.
В следующем разделе мы покажем, что на уровне реализации возможно внести некоторое усовершенствование в структуру бинарного дерева, которое позволит выполнять нерекурсивный обход без использования вспомогательного стека.
3.7.5. Прошитые деревья
Проанализировав различные способы обхода деревьев, можно сделать вывод, что любой способ требует дополнительных расходов памяти либо во внутреннем стеке программы, либо в виде внешнего стека или очереди. При этом в структуре самого дерева имеется много пустых ссылок (в бинарном дереве их примерно столько же, сколько и непустых). Все это приводит к неэкономному использованию памяти.
Хорошим способом сократить расходы памяти при обходе являются так называемые прошитые деревья (используем термин из [8]). Идея их очень проста  вместо пустых ссылок хранить обратные ссылки на узлы-предки, тогда в процессе обхода всю (или почти всю) необходимую информацию для перемещения между узлами можно будет получать из самих узлов. Правда при этом в каждом узле придется иметь дополнительное поле-признак, который позволит отличить указателей на сыновей от указателей на родителей. В принципе для такого признака достаточно иметь всего лишь один бит, и иногда такой бит даже находится в структуре самого дерева. Например, если известно, что в узлах хранятся только положительные значения, можно задействовать для этих целей знаковый разряд.
Алгоритмы обхода при применении прошитых бинарных деревьев несколько усложняются, поскольку необходимо предусмотреть проверку признака, при этом время обхода незначительно увеличивается. Однако в тех случаях, когда важнее экономия памяти, прошитые деревья могут оказаться очень полезными.
Имеются различные способы реализации прошитых деревьев. Наиболее понятным и простым в реализации представляется следующий случай.
Пусть задано упорядоченное дерево произвольного вида (не обязательно бинарное). Если построить эквивалентное ему бинарное дерево, используя представление «левый сын-правый брат», то в полученном дереве у самого правого (младшего) брата гарантированно будет пустой правая ссылка (у него нет правых братьев). Эту ссылку можно использовать как ссылку на отца (обратную ссылку).
Данная идея поясняется с помощью рис.3.11. На рис.3.11,а изображено упорядоченное дерево произвольного вида. Путем изменения связей это дерево преобразуется к бинарному (рис.3.11, б). В полученном бинарном дереве узлы a,d,g и h не имеют правых сыновей, поэтому они могут использоваться для хранения обратных ссылок (они показаны пунктиром). Мы умышленно не стали разворачивать рисунок так, чтобы бинарное дерево приобрело привычный вид, чтобы хорошо была заметна аналогия с циклическими линейными списками, которые используют ту же самую идею, что и прошитые деревья.
13 EMBED Visio.Drawing.6 1415
Рис.3.12. Прошитое дерево
Проанализируем порядок выполнения прямого обхода (КЛП). Для данного дерева последовательность посещения узлов будет иметь вид
a b c e f g d h
На рис.3.12,б хорошо видно, что хранимых в дереве обратных ссылок вполне достаточно, чтобы выполнить прямой обход, не используя никакой дополнительной памяти. Структура для представления узлов дерева может иметь, например, такой вид
template
struct node
{ T data; //данные, которые содержатся в узле
node *son, *brother;// указатели на левого сына и правого брата
bool youngest; // признак самого младшего брата
}
Алгоритм прямого обхода прост [11] и понятен из рис. 3.12, б.
Если у узла имеется сын, то переходим к сыну.
Если нет сына, но имеется брат, то переходим к брату.
Если нет ни сына, ни брата, то находим брата у ближайшего предка, у которого он есть.
Если таких предков нет, то обход закончен.
3.8. Применение деревьев для кодирования информации  деревья Хаффмана
Деревья находят применение в различных алгоритмах обработки данных, поэтому набор задач, которые можно выбрать в качестве примеров, весьма обширен. Учитывая, что такие важные направления применения как сортировка и поиск данных рассматриваются в следующих главах, в данном разделе рассмотрим два других очень распространенных применения. Первое из них  анализ выражений, записанных в виде формулы, второе относится к задаче сжатия информации.

3.8.2. Задача сжатия информации. Коды Хаффмана
Предположим, мы работаем с сообщениями, которые составляются из некоторого набора символов. Известна вероятность появления каждого символа в сообщении. Мы хотим закодировать каждый символ некоторой последовательностью нулей и единиц (возможно, разной длины), чтобы записывать сообщения в двоичном коде.
Например:
Символ
Код

a
1

b
011

c
00

d
010

Сообщение bacd в таком коде запишется как 011100010.
Наоборот, закодированное сообщение 000111 декодируется в cba.
Можно сформулировать два условия, которым должен удовлетворять код:
1). По любому закодированному сообщению можно однозначно восстановить исходное.
2). Средняя длина кода должна быть минимальной.
1-е условие можно реализовать по-разному. Например, будем последовательно брать из начала закодированного сообщения коды символов (префиксы). Если по ним можно однозначно восстановить все символы исходного сообщения, то такое свойство кода называется префиксным. Именно такие коды нас и интересуют.
Для примера рассмотрим несколько кодов:
Символ
Вероятность
Код1
Код2
Код3

a
0.55
00
1
1

b
0.15
01
011
011

c
0.2
10
00
10

d
0.1
11
010
101

Ясно, что 1-й код обладает префиксным свойством (все коды символов имеют одинаковую длину и все они различны).
Легко проверить, что 2-й код тоже обладает префиксным свойством. Проверить можно, например, так. Максимальная длина кода=3. Возьмём все комбинации первых трёх символов сообщения и убедимся, что для каждой из них подходит только 1 код (или не подходит ни одного):
Последовательность
Код
Символ

000
00
c

001
00
c

010
010
d

011
011
b

100
1
a

101
1
a

110
1
a

111
1
a

Извлекаем этот код и снова приходим к этой же самой задаче и т.д.
3-й код не обладает префиксным свойством. Например, последовательность 1011 можно декодировать и как da, и как ab.
Код с префиксным свойством, для которого средняя длина закодированного сообщения минимальна, называется кодом Хаффмана. Вместо средней длины закодированного сообщения удобнее рассматривать среднюю длину кода отдельного символа. Она находится как сумма произведений длин кодов символов на их вероятности. Например, для второго кода из примера средняя длина символа = 1*0,55+3*0,15+2*0,2+3*0,1 = 1,7.
Очевидно, что средняя длина символа для первого кода равна 2, значит второй код является более оптимальным.
Чтобы сократить среднюю длину символа до минимума, рассмотрим способ построения оптимального кода (алгоритм Хаффмана). В нём используется лес, в котором листья деревьев помечены кодируемыми символами, а сумма их вероятностей составляет вес дерева.
Вначале каждому символу соответствует дерево, состоящее из одного узла. На каждом шаге выбирается два дерева с минимальным весом и объединяются в одно путём создания нового корня. При этом дерево с наименьшим весом становиться левым сыном, а другое дерево – правым сыном нового узла. Так продолжается до тех пор, пока не останется только одно дерево.
В этом дереве путь от корня к любому листу представляет код соответствующего символа (при движении влево пишем 0, вправо – 1).
Пример:
13 EMBED Visio.Drawing.6 1415
Рис.3.14. Деревья Хаффмана
Алгоритм Хаффмана можно применять, например, для сжатия файлов. Сжимая файл по алгоритму Хаффмана, первое, что мы должны сделать - прочитать файл полностью и подсчитать сколько раз встречается каждый символ. После этого строим декодирующее дерево, кодируем файл и сохраняем данные и таблицу соответствия символов и кодов Хаффмана.
Пример. Требуется в заданной строке получить вероятности появления каждого символа и получить для нее оптимальный код Хаффмана.
Для начала опишем структуры данных, которые нам потребуются для построения кодов и для их использования. Приведенный ниже фрагмент кода является началом нашей программы.

#include
#include
struct tree //структура дерева
{float probability;//частота встречи символов дерева
int root; //указатель на корень (индекс в массиве)
};
tree *letters[256], //массив букв
forest[256]; //массив деревьев (лес)
struct node //структура узла дерева
{ int left, right, parent; //указатели на сыновей и на родителя
};
node nodes[1000]; //массив узлов
int lasttree=0, //количество деревьев в лесу
lastnode=0; //общее количество узлов
char codes[256][10];//массив кодов символов (для кодирования)
char leafs[256]; //массив листьев (для декодирования)

В данной программе производится построение деревьев с помощью массива узлов nodes. Каждый его элемент представляет собой три указателя – на левого и правого сына, а также на родителя. Сами указатели имеют целочисленный тип и представляют собой индексы соответствующих элементов массива nodes. Указатель на родителя parent нужен для того, чтобы по известному символу можно было восстановить полный путь от корня до соответствующего листа. Это потребуется при построении таблицы кодов (массива codes). Количество элементов nodes взято 1000, на самом деле их должно быть гораздо меньше.
Структура дерева tree содержит лишь указатель на корень и вероятность появления его символов (вес дерева).
Массив letters служит для быстрого подсчета вероятностей появления символов в строке. В качестве индекса можно указывать сам символ, поскольку язык С++ автоматически преобразует его в целое число. При этом, если символ не встречается в строке, то соответствующий ему элемент массива letters будет представлять собой пустой указатель. Массив leafs, наоборот, служит для восстановления символа по указателю на его лист (который берется в качестве индекса). Сами указатели не могут превышать 255 поскольку все листья располагаются в начале массива nodes, а количество различных символов не может превышать 256.
Массив forest, в отличие от letters, будет хранить не только листья, но и сами деревья. При завершении алгоритма Хаффмана в нем должно остаться только одно дерево (forest[0]) с весом 1.
Следующий фрагмент программы представляет собой функцию, производящую подсчет вероятностей появления символов в строке, а также начальное или полное заполнение приведенных выше массивов.
void initialize(char *s) //заполнение массивов letters, leafs и forest
{ int i,j;
for (i=0; i<256; i++) letters[i]=NULL;
for (i=0; i if(letters[s[i]])
letters[s[i]]->probability+=1.0/strlen(s);
else //создаем новый узел (лист будущего дерева)
{letters[s[i]]=new tree;
letters[s[i]]->probability=1.0/strlen(s);
letters[s[i]]->root=lastnode;
nodes[lastnode].left=nodes[lastnode].right=-1;
nodes[lastnode].parent=-1; //пустые указатели
leafs[lastnode]=s[i]; //запоминаем символ листа
lastnode++;
}
for (i=0; i<256; i++) //заполняем forest листами
if (letters[i]) {
forest[lasttree]=*letters[i]; lasttree++;
}
}
Далее идет основная функция, выполняющая построение общего дерева и таблицы кодов Хаффмана. Последняя служит только для убыстрения процесса кодирования. Для ее построения необходимо для каждого листа восстановить полный путь до него от корня дерева. При этом, чтобы сохранить прямой порядок следования цифр, каждая новая из них вставляется в начало строки, а не в ее конец.
void build(char *s)//построение дерева и таблицы кодов Хаффмана
{int i,j;
while (lasttree>1)
{ int first=0, second=1, //указатели на самые "легкие" деревья
root1, root2; //указатели на их корни
if ((lasttree>0)&&(forest[first].probability >
forest[second].probability)){ first=1; second=0;}
for (i=0; i if ((i!=first)&&(i!=second))
if(forest[i].probability<=forest[first].probability)
{ second=first; first=i; }
else
if(forest[i].probability second=i;
root1=forest[first].root;
root2=forest[second].root;
//создаем новый узел:
nodes[lastnode].left=root1;
nodes[lastnode].right=root2;
nodes[lastnode].parent=-1;
//объединяем деревья с корнями root1 и root2 в новое дерево:
nodes[root1].parent=nodes[root2].parent=lastnode;
forest[lasttree].probability=
forest[first].probability+forest[second].probability;
forest[lasttree].root=lastnode; lastnode++;
//удаляем самые "легкие" деревья:
forest[first]=forest[lasttree];
forest[second]=forest[lasttree-1];
lasttree--; //сокращаем лес на одно дерево
}
char tmp[9]; //временная строка
for (i=0;i<256;i++)//построение таблицы кодов Хаффмана
if (letters[i])
{ strcpy(codes[i],""); j=letters[i]->root;
while (nodes[j].parent!=-1)
{ strcpy(tmp,codes[i]);
if (nodes[nodes[j].parent].left==j) strcpy(codes[i],"0");
else strcpy(codes[i],"1");
strcat(codes[i],tmp);
j=nodes[j].parent;
}
cout< }
}
Таким образом, при последовательном выполнении функций initialize и build заполняются все основные структуры данных. По ним легко можно закодировать строку и восстановить ее обратно. Приведем для этого две функции.
void encode(char *s, char *out)//кодирование в строку out
{ int i,j; strcpy(out,"");
for (i=0; i strcat(out,codes[s[i]]);
}
void decode(char *out, char *s)//декодирование в строку s
{ int i=0,j=forest[0].root; //j - указатель на корень дурева Хаффмана
strcpy(s,"");
while (i {if((out[i]=='0')&&(nodes[j].left>-1))
j=nodes[j].left;
if((out[i]=='1')&&(nodes[j].left>-1))
j=nodes[j].right;
if ((nodes[j].left==-1)&&(nodes[j].right==-1))
{ strcat(s," "); s[strlen(s)-1]=leafs[j];
j=forest[0].root;
}
i++;
}
}
В завершение приведем небольшую демонстрационную программу, показывающую порядок работы с приведенными структурами данных и функциями.
main()
{ cout<<"Введите строку текста:\n";
char s[100];
cin.getline(s,100);
initialize(s); build(s);
char out[800];
encode(s,out);
cout<<"Закодированная строка: "< decode(out,s);
cout<<"Раскодированная строка: "< cin.get();
return 0;
}

Собрав вместе приведенные фрагменты, получим готовую программу, которую можно использовать для кодирования и декодирования любых строк текста
4. Сортировка и родственные задачи
4.1. Общие сведения
4.1.1. Постановка задачи
Линейные и иерархические структуры данных находят применение при решении разнообразных прикладных задач, имеющих практическое значение. Выше уже было рассмотрено применение деревьев для решения задачи кодирования информации. Далее рассмотрим такие тесно связанные между собой задачи, как сортировка и поиск данных, которые занимают большую часть компьютерного времени в современных информационных системах. Хотя сортировка носит вспомогательный характер по отношению к другим задачам обработки данных, именно с нее удобно начать изучение материала, поскольку алгоритмы сортировки часто являются основой для других алгоритмов. Да они и сами по себе интересны.
Пусть имеется некоторая последовательность элементов произвольного типа (массив или связный список). Сортировкой называется расположение элементов этой последовательности согласно определённому линейному отношению порядка. Наиболее привычным является отношение "(", в этом случае говорят о сортировке по возрастанию (более строго   по неубыванию). Отношения сравнения определены для большинства стандартных типов. При определении своего собственного типа пользователь может определить для него и операции сравнения и, таким образом, получает возможность отсортировать данные этого типа.
Сортировка имеет очень много применений, перечислим лишь некоторые из них:
для выполнения быстрого поиска данных в отсортированной последовательности;
для группировки элементов по некоторому признаку (например, если отсортировать список товаров по стране изготовления, то можно быстро подсчитать количество товаров, которые поставляет каждая из стран, их среднюю цену и т.д.)
для эффективного поиска общих элементов двух или более последовательностей и др.
Обычно предполагается, что элементы сортируемой последовательности представляют собой записи, а упорядочение осуществляется по значениям одного из полей. Это поле называется ключом (key), а остальные поля называются связанными данными (satellite data). Такой подход к сортировке является обычным в базах данных, где данные естественно представлены в виде записей (например, данные о студентах или преподавателях).
4.1.2. Характеристики и классификация алгоритмов сортировки
Сортировка называется устойчивой, если после её выполнения записи с одинаковыми ключами располагаются друг относительно друга в том же порядке, что и до сортировки.
Например, рассмотрим следующую последовательность записей:
<6,'E'>, <2,'B'>, <1,'A'>, <3,'C'>, <1,'D'>.
Пусть ключами будут первые элементы записей. Устойчивый алгоритм сортировки на выходе даст нам последовательность
<1,'A'>, <1,'D'>, <2,'B'>, <3,'C'>, <6,'E'>,
в которой относительный порядок записей <1,'A'> и <1,'D'> остался без изменения. Для неустойчивого алгоритма сортировки также допустим и другой результат:
<1,'D'>, <1,'A'>, <2,'B'>, <3,'C'>, <6,'E'>,
где эти элементы поменялись местами.
Если требуется отсортировать последовательность по составному ключу (т.е. состоящему из нескольких полей), то можно для этого, используя устойчивый алгоритм, последовательно выполнить сортировку по составляющим ключа, взятым в обратном порядке.
Например, чтобы отсортировать последовательность записей вида <имя, фамилия> по фамилии, а записи с одинаковыми фамилиями - ещё и по имени, можно сначала выполнить устойчивую сортировку по имени, а затем - по фамилии:
Исходная последовательность
Последовательность после сортировки по имени
Последовательность после сортировки по фамилии

Васильев Сергей
Петров Иван
Васильев Иван
Петров Иван
Васильев Иван
Васильев Сергей
Васильев Иван
Васильев Сергей
Петров Иван


Важной характеристикой алгоритма сортировки является объём дополнительной памяти, которую он использует при своей работе. Для сортировок, которые использует не более чем константное количество дополнительной памяти, иногда используют термин “in-place”.
Некоторые авторы рассматривают такую характеристику алгоритмов сортировки, как естественность поведения, показывающую, зависит ли существенно число операций алгоритма от степени неупорядоченности исходной последовательности. Считается, что алгоритм ведёт себя более естественно, если почти отсортированную последовательность он "досортировывает" быстрее, чем произвольную.
Выделяют внутреннюю и внешнюю сортировки. При внутренней сортировке все сортируемые данные помещаются в оперативную память компьютера. Внешняя сортировка используется, когда объём данных слишком большой и они не помещается целиком в оперативную память (время сортировки в этом случае существенно зависит от числа операций обмена с внешней памятью, и алгоритмы строятся с учетом этого). Внешняя сортировка подробно рассматривается в главе 6.
Имеется очень много различных алгоритмов сортировки, они используют различные идеи. Приведем примерную классификацию методов сортировки (рис. 4.1).

13 EMBED Visio.Drawing.6 1415
Рис.4.1. Классификация методов сортировки
В данной главе рассмотрим наиболее распространенные способы, охватив практически все направления данной классификации.
Предметом данной главы является анализ алгоритмов сортировки, которые фактически не зависят от типа сортируемых данных, поэтому с целью упрощения и повышения наглядности программного кода будем считать, что сортируется массив целых чисел. На практике это довольно распространенный случай. Можно представить, что целые числа  ключи записей, и при желании любую из приводимых функций легко переработать так, чтобы она сортировала массив записей по значениям одного из полей.
Также приведенные в данной главе алгоритмы с небольшими изменениями могут быть использованы для связных списков.
Начнем с самых простых методов.
4.2. Простые методы сортировки
Существует ряд методов сортировки, которые чрезвычайно просты в понимании и реализации, однако имеют более высокий порядок сложности, чем более совершенные алгоритмы. Хотя эти способы неприменимы для сортировки больших объёмов данных, для данных небольшого объёма они могут быть вполне приемлемы
4.2.1. Сортировка выбором
Одним из простейших алгоритмов сортировки является алгоритм сортировки выбором. Его суть в следующем. Пусть первые (i-1) элементов массива уже содержат правильные значения. Тогда на следующем шаге найдём в оставшейся части массива минимальный элемент и поменяем его местами с i-м – в результате правильные значения будут содержать уже i первых элементов массива.



i




3
4
7
8
5
6

Рис 4.2 Иллюстрация работы сортировки выбором.
Минимальный элемент 5 меняем местами с текущим.
Приведем текст функции, выполняющей сортировку выбором:
void sort(int a[], int n)
{ for(int i=0; i {//определяем индекс мин.элемента и ставим его на i-е место
int indMin=i;
for(int j=i+1; j if (a[j] int tmp = a[i]; a[i] = a[indMin]; a[indMin] = tmp;
}
}
Как видим, алгоритм выполняет O(n2) операций независимо от исходных данных, даже если массив изначально был отсортирован или почти отсортирован. Из-за этого недостатка он достаточно редко применяется даже для сортировки небольшого числа элементов.
4.2.2. Сортировка алгоритмом пузырька
Алгоритм пузырьковой сортировки уже рассматривался в разделе 1._. Хотя данный алгоритм также не отличается эффективностью, но у него есть большой плюс – он допускает различные улучшения.
Так, простейший вариант алгоритма, рассмотренный ранее, выполняет O(n2) операций независимо от входных данных. Очевидный способ улучшить алгоритм – запоминать, производился ли на очередном проходе хотя бы один обмен. Если ни один обмен не производился, значит, данные уже отсортированы, и алгоритм может закончить работу.
Чтобы ещё более улучшить алгоритм, можно запоминать не только сам факт обмена, но и индекс места, где произошёл последний обмен. Если в нём участвовали элементы ak и ak+1, то, значит, часть массива от 1 до k уже отсортирована и дальше изменяться не будет. (Действительно, раз не было ни одного обмена в этой части массива, значит, она отсортирована. Кроме того, в оставшейся части массива нет элементов, меньших ak, поскольку ak был минимальным из них).
Ещё одно улучшение вытекает из следующего наблюдения. Представим следующую ситуацию. Пусть имеется почти отсортированный массив, за исключением того, что в конце его стоит маленький элемент. За один проход этот элемент поднимется в начало массива, после чего на следующем проходе обменов выполнено не будет, и алгоритм остановится. Если же, наоборот, в начале почти отсортированного массива стоит большой элемент, то на каждом проходе он будет сдвигаться вправо всего лишь на одну позицию. Отсюда вытекает следующее улучшение – менять направление следующих один за другим проходов. Полученный алгоритм иногда называют шейкер-сортировкой. Приведем пример реализации алгоритма со всеми этими модификациями:
void sheiksort(int a[], int n)
{
int l=0; //индекс элемента, левее которого уже всё готово
int r=n-1; //индекс элемента, правее которого уже всё готово
int i,t,j;
while (l { //двигаемся слева направо
j = -1;
for(i=l; i if (a[i]>a[i+1])
{
t = a[i+1]; a[i+1] = a[i]; a[i] = t;
j=i; //запоминаем позицию обмена
}
if (j==-1) return; //ни одного обмена - завершение
r=j; //всё, что правее, уже отсортировано и не изменится
//теперь двигаемся справа налево
j=-1;
for(i=r; i>l; i--)
if (a[i-1]>a[i])
{
t = a[i-1]; a[i-1] = a[i]; a[i] = t;
j = i; //запоминаем позицию обмена
}
if(j==-1) return;//ни одного обмена - сортировка завершена
l=j;//всё, что левее, уже отсортировано и не изменится
}
}

l
i(


R



1
2
3
2
4
4
7
9

Рис. 4.3. Иллюстрация к алгоритму шейкер-сортировки (показан проход слева направо, уже готовые части выделены серым)
К сожалению, сложность данного алгоритма в худшем случае (можно показать, что и в среднем тоже) осталась O(n2), но, по крайней мере, поведение алгоритма стало естественным – почти отсортированный массив будет «досортирован» за линейное время.
4.2.3.Сортировка простыми вставками.
Пусть первые (i-1) элементов последовательности уже отсортированы. На очередном шаге возьмём элемент, стоящий на i-м месте, и поместим его в нужное место отсортированной части последовательности. Будем делать так до тех пор, пока вся последовательность не окажется отсортированной.
Один из способов вставки элемента будет выглядеть так. Запомним i-й элемент в переменной t. Будем двигаться от (i-1)-го элемента к началу массива, сдвигая элементы на 1 вправо, пока они больше t. Наконец, поместим элемент t на освободившееся место. Пример функции, выполняющей сортировку простыми вставками:
void inssort(int a[], int n)
{ int i,j;
for (i=1; i {int t = a[i]; //очередной элемент будет поставлен на место
for (j=i-1; (j>=0) && (a[j]>t); j--)
a[j+1]=a[j];
a[j+1]=t;
}
}



i



3
5
7
4
8
2



3
4
5
7
8
2

Рис 4.4 Иллюстрация работы сортировки простыми вставками.
Как несложно заметить, в худшем случае (и в среднем также) алгоритму потребуется выполнить O(n2) операций. Как и алгоритм пузырька, данный алгоритм также обладает естественностью поведения.
4.3. Быстрые способы сортировки, основанные на сравнении
Сначала рассмотрим сортировки, основанные на использовании бинарных деревьев. Учитывая, что высота бинарного дерева в лучшем случае составляет log2n, можно рассчитывать на сокращение числа сравнений при сортировке. Реализовать эту общую идею можно различными способами, используя специальные виды бинарных деревьев, которые кратко рассматривались в предыдущей главе (разделы 3.3.2 и 3.6.3). Наибольшее распространение среди древовидных сортировок получили:
турнирная, основанная на построении и многократной перестройке дерева турнира (разд. 3.6.3);
сортировка упорядоченным бинарным деревом (деревом поиска), которая основана на представлении входной последовательности в виде бинарного дерева поиска с последующим ЛКП обходом данного дерева (разд. 3.3.2);
сортировка пирамидой или пирамидальная сортировка (разд. 3.3.2).
Наиболее эффективной из древовидных сортировок является пирамидальная, поэтому она будет рассмотрена подробно. Бинарные деревья поиска будут подробно рассмотрены в следующей главе.
4.3.1. Пирамидальная сортировка. Очереди с приоритетами на основе пирамиды
В алгоритме пирамидальной сортировки используется специальная структура данных пирамида (иногда её также называют кучей, англ. heap). Пирамидой называется полное или почти полное бинарное дерево, для которого выполняется основное свойство пирамиды: ни один элемент в пирамиде не может быть больше своего родителя. Напомним, что у почти полного бинарного дерева все уровни, за исключением последнего, заполнены полностью, а последний уровень может быть заполнен частично, но обязательно по порядку слева направо.
Рис. 4.6 Пирамида
Очевидно, что на вершине пирамиды находится наибольший её элемент.
Для представления пирамиды в памяти удобно использовать массив, при этом пирамида хранится в массиве следующим образом. Сыновья элемента с индексом i будут иметь индексы i*2+1 и i*2+2, а его родитель - индекс (i-1)/2 (напомним, что в языке C++ массивы начинаются с нуля, а при делении двух целых чисел дробная часть отбрасывается). Так, для вышеприведённого рисунка пирамида будет храниться в массиве следующим образом:


0
1
2
3
4
5
6
7
8
9

12
8
6
7
8
3
4
4
1
6

Рис. 4.7. Хранение пирамиды в массиве
Например, для элемента с индексом 3 сыновьями будут элементы с индексами 3*2+1=7 и 3*2+2=8, а родителем - элемент с индексом (3-1)/2=1.
Первая фаза сортировки пирамидой
Сортировка пирамидой включает в себя две фазы. На первой фазе мы преобразуем исходную последовательность в пирамиду. Как правило, пирамида строится прямо в исходном массиве, и дополнительная память не требуется.
Итак, пусть задан массив a из n элементов. Заметим, что элементы с индексами i(n/2 заведомо не превосходят своих детей, поскольку таковых не имеют (индексы i
·2+1 и i
·2+2 выходят за границы массива). Чтобы это свойство пирамиды выполнялось и для других элементов, поступим следующим образом. Будем двигаться по массиву справа налево, начиная с индекса n/2-1. Встав на очередной элемент a[i], выберем максимального из его сыновей a[i
·2+1] и a[i
·2+2] (не забывая при этом, что у элемента может быть только один сын или не быть их вовсе). Если максимальный из сыновей не превосходит родителя, то всё в порядке, иначе поменяем их местами и выполним аналогичную проверку для нашего элемента уже на его новом месте. Возможно, этот шаг придётся выполнить несколько раз. Пример показан на рис. 4.6.




0
I=1
2
3
4
5
6
7

3
4
7
8
5
6
5
5






0
I=1
2
3
4
5
6
7

3
8
7
4
5
6
5
5






0
I=1
2
3
4
5
6
7

3
8
7
5
5
6
5
4



Рис 4.8. Элемент 4 "проваливается" в пирамиду.
Приведём пример функции, выполняющей “погружение” очередного элемента вглубь пирамиды. Параметрами функции будут исходный массив a, его длина n и индекс элемента i.
void downheap(int a[], int n, int i)
{
while (i=n/2 детей нет, и основное свойство
{ //пирамиды выполняется тривиальным образом
//определяем максимального из сыновей
int i_max = i*2+1;
if (i_max+1 if (a[i_max+1]>a[i_max])
i_max=i_max+1;
//проверяем, выполняется ли основное свойство пирамиды
if (a[i]>=a[i_max]) break;
//меняем местами элемент и его сына и корректируем i
int tmp = a[i]; a[i] = a[i_max]; a[i_max] = tmp;
i = i_max;
}
}

Теперь чтобы преобразовать исходный массив в пирамиду, осталось только вызвать функцию downheap для всех элементов от n/2-1 до 0:

for(int i=n/2-1; i>=0; i--) downheap(a,n,i);
Вторая фаза сортировки пирамидой
На второй фазе полученная пирамида преобразуется в отсортированный массив. Это делается следующим образом. Из основного свойства пирамиды следует, что максимальный элемент находится на её вершине, то есть это элемент a[0]. Поменяем местами элемент a[0] c последним элементом массива – a[n-1]. В результате a[0] встанет на своё конечное место (действительно, раз он наибольший, то после сортировки должен стоять в самом конце).
Чтобы этот элемент больше не рассматривать, уменьшим на 1 длину массива (исходную длину мы предварительно запомним).
Наконец, вызвав функцию downheap() для элемента a[0], мы снова получим пирамиду, только в ней будет на один элемент меньше.
Выполняя в цикле вышеперечисленные действия, мы будем получать отсортированную последовательность, начиная с конца массива. После (n-1) итераций, мы, очевидно, получим полностью отсортированную последовательность. Приведём пример реализации второй фазы алгоритма:
for(int i=n-1; i>0; i--)
{ // a[0] - сейчас максимальный элемент среди a[0]..a[i]
// поставим его на своё место
int tmp = a[i]; a[i] = a[0]; a[0] = tmp;
// восстановим пирамидальность оставшейся части массива
downheap(a,i,0);
}
Анализ алгоритма сортировки пирамидой
Оценим время работы функции downheap(). На каждой итерации её цикла элемент опускается в пирамиде на один уровень, поэтому общее число шагов пропорционально высоте h пирамиды, от корня которой мы начали спускаться.
В разделе 3.3.2. была выведена формула для высоты полного бинарного дерева:
h=log2(k+1)-1, где k – количество узлов дерева.
Но, поскольку в пирамиде последний уровень заполнен не полностью, эту величину нужно округлить в большую сторону:
h=(log2(k+1)-1(
Таким образом, верхняя оценка составляет O(log k)
Несложно получить и нижнюю оценку. В худшем случае на вершине пирамиды оказывается элемент, который меньше всех остальных, и его приходится опускать до самого низу. Отсюда сразу получаем оценку
·(log n).
Таким образом, точная асимптотическая оценка “погружения” элемента в пирамиду составляет
·(log n).
Теперь выполним анализ собственно сортировки. На первой фазе функция downheap() вызывается порядка n раз, при этом каждый раз число элементов в пирамиде (( n, поэтому можно считать, что временная сложность алгоритма составляет O(n(logn).
Примечание. Мы не учли, что почти во всех вызовах downheap() число элементов в пирамиде значительно меньше n. Если более аккуратно провести анализ первой фазы, можно показать, что построение пирамиды требует всего
·(n) операций.
На второй фазе алгоритма выполняется n-1 вызов функции downheap(), при этом каждый вызов работает с пирамидой порядка n. Таким образом, сложность второй фазы и всего алгоритма 
·(n(log n).
Один из недостатков алгоритма сортировки пирамидой состоит в том, что он не является устойчивым. Преимущества – алгоритм не использует дополнительной памяти, работает одинаково хорошо для среднего и худшего случая.
Реализация очереди с приоритетами на базе пирамиды
Очередь с приоритетами также часто реализуют с помощью пирамиды. При этом извлечение элемента с максимальным приоритетом из очереди осуществляется аналогично тому, как это делалось во 2-й фазе алгоритма пирамидальной сортировки.
Вставка элемента в очередь происходит похожим образом – помещаем элемент в конец пирамиды (увеличив её размер на единицу) и обеспечиваем выполнение основного свойства пирамиды для этого элемента. Основное отличие состоит в том, что при этом мы сравнивать элемент с его родителем (а не сыном) и при необходимости поднимать, а не опускать. Пример вставки элемента показан на рис.4.9.
Иногда требуется, чтобы элементы с одинаковым приоритетом выходили из очереди в том же порядке, в каком они и пришли. Для этого можно использовать разные способы, например, расширить приоритеты элементов очереди до двух частей – собственно приоритета и некоего порядкового номера, который будет увеличиваться на единицу для каждого нового элемента, помещаемого в очередь.
0
1
2
3
4
5
6
7

9
7
5
6
2
4
1
3



0
1
2
3
4
5
6
7
8

9
7
5
6
2
4
1
3
8



0
1
2
3
4
5
6
7
8

9
7
5
8
2
4
1
3
6



0
1
2
3
4
5
6
7
8

9
8
5
7
2
4
1
3
6

Рис 4.9. Вставка элемента в очередь с приоритетами на базе пирамиды
4.3.2. Сортировка слиянием
Прежде чем описывать данный способ сортировки, посмотрим, как можно эффективно слить две уже отсортированных последовательности в одну общую, также отсортированную. Для этого можно поступить следующим образом. Сравним первые элементы двух последовательностей, меньший из них извлечём и поместим на выход. Так будем продолжать до тех пор, пока одна из последовательностей не опустеет. После этого остаётся только добавить остаток второй последовательности в конец результирующей.
Например, пусть заданы последовательности <2,5,8> и <3, 4>. Сначала сравним элементы 2 и 3, меньший из них 2 – он станет первым элементом результата, а у нас остаётся <5,8> и <3,4>. Теперь 3 меньше чем 5, 3 уходит в результат, у нас остаётся <5,8> и <4>. На следующем шаге 4 уходит, и вторая последовательность становится пустой. Для завершения слияния остаток первой последовательности уходит в результат. Более наглядно этот процесс показан на рисунке.
В случае, когда последовательности заданы в массивах, для текущего первого элемента последовательности можно просто использовать отдельную переменную – индекс. приведём пример реализации (a и b – исходные массивы, n и m – их длины, r – массив, куда помещается результат):
void merge(int a[], int n, int b[], int m, int r[])
{ int i=0, j=0, k=0;
while (i r[k++] = (a[i]<=b[j]) ? a[i++] : b[j++];
//добавим остаток первого массива
for(; i r[k++] = a[i];
//добавим остаток второго массива
for(; j r[k++] = b[j];
}
Теперь перейдём к алгоритму сортировки слиянием. Рекурсивная реализация данного алгоритма очень легка и проста для понимания. Чтобы отсортировать массив, мы разбиваем его на две примерно равные части, рекурсивно сортируем каждую из них, после чего сливаем отсортированные части и получаем результирующий отсортированный массив.
Большим недостатком данного алгоритма является необходимость выделения памяти под вспомогательный массив, куда помещаются результаты слияния двух половинок исходного массива.
Пример реализации:
void _mergeSort(int a[], int n, int r[])
{ бif (n<2) return;
//разбиваем на две части, рекурсивно сортируем левую и правую часть
int p = n/2;
_mergeSort(a,p,r);
_mergeSort(a+p,n-p,r);
//сливаем отсортированные части во вспомогательный массив b
merge(a,p,a+p,n-p,r);
//копируем отсортированные данные из b обратно в a
memcpy(a,r,n * sizeof(int) );
}
void mergeSort(int a[], int n)
{
int *r = new int[n];
_mergeSort(a,n,r);
delete [] r;
}
Здесь основная работа выполняется в функции _mergeSort, рекурсивно вызывающей саму себя, тогда как в функции-оболочке mergeSort выделяется и освобождается память под вспомогательный массив.
Нерекурсивный вариант данной сортировки будет рассмотрен в главе, посвященной внешней сортировке.
Анализ алгоритма сортировки слиянием
Оценим время работы функции _mergeSort(). Оно складывается из времени выполнения двух рекурсивных вызовов, слияния и копирования данных из одного массива в другой. Слияние и копирование выполняется за
·(n), таким образом, имеем рекуррентное соотношение:
13 EMBED Equation.3 1415
Как показано, например, в [9], решением такого рекуррентного соотношения будет T(n)=
·(n(logn).
Время работы алгоритма оказывается таким же, как и у быстрой или пирамидальной сортировки. Однако, в связи с необходимостью использования дополнительного массива такого же размера, как исходный, данный алгоритм редко используется для сортировки в оперативной памяти, но является одним из основных алгоритмов внешней сортировки.
4.3.3. Быстрая сортировка Хоара
Быстрая сортировка, как и сортировка слиянием, также является реализацией принципа “разделяй и властвуй”. Элементы сортируемого массива переставляются так, чтобы массив условно разделился на две части – левую и правую, причём никакой элемент из левой части не должен превосходить никакого элемента из правой.

5
3
8
5
7
1
4
6
2



2
3
4
1
7
5
8
6
5



Рис. 4.10. Основная идея алгоритма быстрой сортировки
После этого процедура сортировки вызывается рекурсивно для левой и правой части, в результате чего массив будет отсортирован.
Для получения такого разбиения можно действовать следующим образом. Пусть дан массив a[p..r]. Элемент x=a[p] выбирается в качестве граничного (опорного). Нам нужно добиться, чтобы для некоторого q выполнялось следующие условия:
элементы a[p..q] не больше x
элементы a[q+1..r] не меньше x
p(qСтрогое неравенство в последнем условии говорит о том, что после разбиения обе получившиеся части должны быть не пустыми, иначе алгоритм зациклится.
Разбиение производится следующим образом. Двигаясь от конца массива к началу, найдём элемент ( x. Затем, двигаясь от начала к концу, найдём элемент ( x. Поменяем эти элементы местами. Будем продолжать действовать таким образом, пока не встретимся где-то внутри массива. Пример выполнения разбиения:

Массив и положения индексов
Комментарии

4 7 4 2 3 5
I( (j
Идём навстречу друг другу:
сначала - справа нелево, останавливаемся при a[j](x,
затем – слева направо, останавливаемся при a[i](x

4 7 4 2 3 5
I j
Меняем местами a[i] и a[j]

3 7 4 2 4 5
I( (j
Снова движемся навстречу друг другу

3 7 4 2 4 5
i j
Меняем местами a[i] и a[j]

3 2 4 7 4 5
i((j
Снова движемся навстречу друг другу

3 2 4 7 4 5
i
j
Поскольку i(j, разбиение завершено


После разбиения массива рекурсивно выполнятся сортировка получившихся частей a[p..q] и a[q+1..r].
Приведём пример реализации алгоритма. Здесь a – исходный массив, l и r – диапазон в нём, который нужно отсортировать.

void qsort(int a[], int l, int r)
{ if (l>=r) return;
int i=l-1, j=r+1, x = a[l];
for(;;)
{ do {j--;} while (a[j]>x);
do {i++;} while (a[i] if (i { int tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}
else
{ qsort(a,l,j);
qsort(a,j+1,r);
return;
}
}
}
Анализ алгоритма быстрой сортировки
Наихудший случай получается, когда при каждом разбиении получаются наиболее неравные части – один элемент и оставшиеся. При выполнении рекурсивных вызовов получается последовательность разбиений из n, n-1, n-2 и т.д. элементов. Поскольку одно разбиение выполняется за линейное время, то вся сортировка выполняется за
·(n2).
Среднее время работы алгоритма составляет O(n(logn). Поскольку точный анализ несколько трудоёмок, мы его не приводим, найти его можно, например, в [9]. Однако, данную оценку можно пояснить следующими нестрогими соображениями. Предположим, при каждом разбиении исходная последовательность длины n делится на две части длиной (1/K)(n и, соответственно, ((1-K)/K)(n, где K – некоторая константа. В результате получается следующее рекуррентное соотношение:
T(n) = T( (1/K)(n ) + T( ((1-K)/K)(n ) + O(n)
Решением данного соотношения будет T(n)=O(n(logn) (см. [9]). При этом данная оценка справедлива, какой бы маленькой не была константа K, то есть как бы сильно не отличались размеры частей разбиений.
Следует сказать, что худший или близкий к нему случай для алгоритма быстрой сортировки очень маловероятен, и на практике данный алгоритм является, пожалуй, самым быстрым алгоритмом сортировки, основанным на сравнениях элементов. При этом он не требует дополнительной памяти за исключением значений, помещаемых в стек при рекурсивных вызовах.
На самом деле худший случай маловероятен, если мы сортируем действительно случайные последовательности. Реальные же данные зачастую такими не являются – они могут быть уже отсортированы, идти в арифметической или геометрической прогрессии и т.п. Наконец, можно даже предположить, что некий злоумышленник (например, автор олимпиадной задачи по программированию) может специально расположить входные данные так, чтобы произошел как раз худший случай и сортировка «вылетела» с переполнением стека или работала за квадратичное время.
Чтобы обезопаситься от этого, используется рандомизированный вариант данного алгоритма: например, перемешиваются случайным образом все элементы в массиве перед выполнением сортировки либо опорный элемент выбирается не каждый раз одинаково, а случайным образом.
4.3.4. Сортировка Шелла
Алгоритм сортировки Шелла представляет собой усовершенствование метода простых вставок, но обеспечивает существенно более высокую скорость работы. Алгоритм работает следующим образом. Вначале выполняется сортировка простыми вставками подпоследовательностей элементов, отстоящих друг от друга на некоторое расстояние hs. Далее аналогичная операция выполняется для hs-1, hs-2, h1. При этом h1 должен быть равен 1 - это гарантирует корректность сортировки, поскольку на последнем проходе она превращается в обычную сортировку вставками.
Рассмотрим пример. Пусть приращения выглядят следующим образом: h1=1, h2=5, исходная последовательность:
<3,6,2,8,4,2,9,1,5,7,11,0,10,14,8,12>. На первом проходе мы выполняем сортировку подпоследовательностей, элементы которых расположены с шагом 5: <3,2,11,12>, <6,9,0>, <2,1,10>, <8,5,14> и <4,7,8>. В результате исходная последовательность преобразуется к следующей:
<2,0,1,5,4,3,6,2,8,7,11,9,10,14,8,12>.
На втором проходе h1=1, т.е. мы выполняем сортировку вставками всей оставшейся последовательности и получаем окончательный результат:
<0,1,2,2,3,4,5,6,7,8,8,9,10,11,12,14>
На первый взгляд может показаться, что алгоритм Шелла должен работает ещё медленнее, чем обычная сортировка вставками, так как последний проход алгоритма как раз к ней и сводится. На самом деле это не так. Высокую эффективность алгоритма неформально можно пояснить следующим образом. На начальных проходах сортируемые группы сравнительно невелики, но в результате этих проходов значительная часть элементов оказывается вблизи нужных позиций. При росте же длины подпоследовательностей для их сортировки требуется уже сравнительно небольшое число перестановок, так как они тому времени становятся довольно хорошо упорядочены.
При анализе данного алгоритма возникают сложные математические задачи, многие из которых ещё не решены. В частности, неизвестно, какая последовательность приращений даёт наилучшие результаты. Кнут показал, что для большинства случаев хорошим выбором является последовательность h1=1, h2=3h1+1, , hs=3hs-1+1. Величина s - наименьшее число, такое что hs+2(N, где N – число элементов массива.
Например, для N=1000 нужно взять следующую последовательность приращений (в обратном порядке): 1, 4, 13, 40, 121.
При таком выборе среднее время сортировки составит O(n1.25), время сортировки в наихудшем случае – O(n1.5).
Седжвиком была предложена следующая последовательность приращений:

13 EMBED Equation.3 1415
здесь s – наименьшее число, такое что 3(hs+1(N.

При таком выборе время сортировки в худшем случае составляет O(n4/3), в среднем – O(n7/6) [Кнут, том 3].
Пример сортировки Шелла с использованием последовательности Седжвика:
void shellsort(int a[], int n)
{ //заранее просчитанная последовательность приращений Седжвика
const unsigned long h[] =
{1,5,19,41,109,209,505,929,2161,3905,8929,16001,36289,64769,146305,260609,
587521,1045505,2354689,4188161,9427969,16764929,37730305,67084289,150958081,
268386305,603906049,1073643521,2415771649,4294770689};
//определяем начальное приращение
int s;
for(s=0; h[s+1] //цикл по приращениям
for(; s>=0; s--)
{ //выполняем сортировку вставками с шагом h[s].
//Внешний цикл проходит по всем элементам, внутренний цикл
//вставляет элемент на соответствующее место в его группе
int step = h[s];
for(int i=step; i { int t = a[i];
int j;
for(j=i-step; (j>=0) && (a[j]>t); j=j-step)
a[j+step] = a[j];
a[j+step]=t;
}
}
}
Хотя асимптотически время работы данного алгоритма и превышает n(logn, но для реальных входных данных (помещающихся в оперативную память современных машин) он вполне способен конкурировать с описанными выше быстрыми способами сортировки.
4.3.5. Нижняя оценка для алгоритмов сортировки, основанных на сравнениях
Все алгоритмы, рассмотренные в предыдущих разделах, относятся к классу сортировок, основанных на сравнениях. Говорят, что алгоритм сортировки основан на сравнениях, если он никак не использует внутреннюю структуру сортируемых элементов, а лишь сравнивает их и после некоторого числа сравнений выдаёт ответ (указывающий порядок элементов) [10].
Модель любого алгоритма, основанного на сравнениях, можно задать с помощью разрешающего дерева. Пример для сортировки вставками массива из трёх элементов представлен на следующем рис.4.11. Поскольку число перестановок из трёх элементов равно 3!=6, у разрешающего дерева 6 листьев.



Рис. 4.11. Разрешающее дерево для алгоритма обработки вставками последовательности из трёх элементов [9].
Пусть мы сортируем n элементов a1,,an. Каждая внутренняя вершина разрешающего дерева соответствует операции сравнения и снабжена пометкой ai:aj, указывающей, какие элементы надо сравнивать. В каждом листе находится перестановка исходной последовательности, соответствующей ответу. Пути от корня до листьев соответствуют возможным последовательностям сравнений, выполняемых во время работы алгоритма.
Очевидно, что в результате работы алгоритма сортировки ответом может быть любая перестановка исходной последовательности, поэтому каждая из n! перестановок должна появиться хотя бы на одном листе.
Найдём нижнюю оценку для худшего случая. Число сравнений в худшем случае равно высоте разрешающего дерева – максимальной длине пути от корня до листа. Поскольку среди листьев представлены все перестановки n элементов, то их число не меньше n!. Поскольку двоичное дерево высоты h имеет не более 2h листьев, то n!(2h. Логарифмируя это неравенство по основанию 2 и пользуясь неравенством n!>(n/e)n, вытекающим из формулы Стирлинга, имеем:
h ( n(log n - n(log e =
·(n(log n)
Таким образом, нижняя граница любого алгоритма сортировки, основанного на сравнениях, составляет
·(n(log n).
Отсюда, в частности, следует, что алгоритмы сортировки слиянием и пирамидой являются асимптотически оптимальными.
4.4. Сортировка за линейное время
Как мы посмотрели в предыдущем параграфе, сортировки, основанные на сравнениях, не могут работать быстрее, чем за n(logn. Однако, если разрешить использование других операций – извлечение разрядов сортируемых элементов и использование их в качестве индексов, то можно добиться линейного времени работы.
4.4.1. Сортировка подсчетом
Алгоритм сортировки подсчетом (counting sort) применим, если сортируемые значения представляют собой целые положительные числа в известном диапазоне (не превосходящие заранее известного k).
В простейшем варианте алгоритм выглядит следующим образом. Создаётся вспомогательный массив с, размер которого совпадает с диапазоном возможных значений исходных чисел. Для каждого элемента x мы подсчитываем, сколько раз он встречается в исходной последовательности, используя в качестве счётчика элемент c[x]. Наконец, проходя по массиву c слева направо, выводим каждое число столько раз, сколько оно встречается. Пример реализации (все исходные элементы лежат в диапазоне [0,65535]):

void countSort(unsigned int a[], int n)
{
unsigned int c[65536];
memset(c,0,sizeof(c));
int i,j,k=0;
//подсчитываем, сколько раз встречается каждое число
for(i=0; i
//формируем ответ
for(i=0; i<=65535; i++)
for(j=0; j a[k++] = i;
}
У описанного способа реализации имеется большой недостаток – его нельзя использовать для сортировки не самих числовых значений, а записей, содержащих эти значения в качестве ключей. Для того, чтобы это было возможным, логика работы алгоритма несколько изменяется. Первый шаг остаётся прежним – для каждого элемента мы подсчитываем в массиве c, сколько раз он встречается в исходной последовательности.
На следующем шаге мы проходим по массиву c и формируем в нём сумму с накоплением – т.е. после этого элемент c[x] будет содержать сумму всех элементов c, стоящих левее него. Смысл этого в том, что элемент c[x] будет содержать количество элементов, которые в результирующей последовательности стоят левее него.
На последнем шаге нам потребуется ещё один вспомогательный массив b для формирования результата. Проходя по исходному массиву, для каждого его элемента a[i] мы сразу определяем индекс в массиве b, где он должен оказаться: он равен c[a[i]]-1. При этом, поместив туда элемент, необходимо вычесть единицу из c[a[i]], чтобы следующий элемент с таким же значением поместился на нужное место.
При выполнении последнего шага есть одна особенность – для того, чтобы сортировка была устойчивой, исходный массив просматривается справа налево.
Пример реализации:
void countSort(unsigned int a[], int n)
{ unsigned int c[65536];
memset(c,0,sizeof(c));
int i,j;
//подсчитываем, сколько раз встречается каждое число
for(i=0; i //считаем сумму с накоплением
for(i=1; i<=65535; i++) c[i] += c[i-1];
//формируем ответ
unsigned int *b = new unsigned int[n];
for(i=n-1; i>=0; i--)
b[ --c[a[i]] ] = a[i];
memcpy(a,b,n*sizeof(unsigned int));
delete [] b;
}
Поскольку диапазон исходных значений ограничен константой, то как легко видно, время работы данного алгоритма составляет
·(n).
4.4.2. Распределяющая сортировка от младшего разряда к старшему
Вышеописанным методом можно выполнять сортировку чисел лишь из небольшого диапазона (так как массив c должен иметь приемлемую длину). Алгоритм распределяющей сортировки берёт за основу алгоритм сортировки подсчётом и позволяет выполнять сортировку чисел (и некоторых других типов) произвольной разрядности. Сначала выполняется сортировка по младшему разряду (в качестве разрядов обычно выступают байты или двухбайтные слова), затем – по следующему и т.д. до старшего. При этом используется тот факт, что сортировка подсчётом является устойчивой, в результате чего данные будут корректно отсортированы (см. начало главы).
Рассмотрим реализацию алгоритма. Сортировка подсчётом немного усложняется – добавляется код для выделения значения заданного разряда-байта, при этом используются битовые операции сдвига влево <<, сдвига вправо >> и побитового “И” &.

//сортировка подсчетом по байту с номером bytenum
void countsort(unsigned int a[], int n, int bytenum)
{ int c[256]; int i;
int shift = bytenum * 8; //смещение этого байта в битах
int mask = 0xFF << shift; //битовая маска для выделения разряда
//подсчитываем количества
memset(c,0,sizeof(c));
for (i=0; i> shift ]++;
//подсчитываем сумму с накоплением
for (i=1; i<256; i++) c[i]+=c[i-1];
//заполняем результирующий массив b
unsigned int *b = new unsigned int[n];
memset(b,0,sizeof(int)*n);
for (i=n-1; i>=0; i--)
{ b[--c[(a[i]&mask)>>shift]]=a[i];
}
memcpy(a,b,n*sizeof(int));
delete[] b;
}
//Собственно сортировка будет заключаться в вызове
// данной функции для всех разрядов:
void radsort(int a[], int n)
{ for(int i=0; i countsort(a, n, i);
}
Если k – число разрядов в сортируемых числах, то сложность алгоритма составляет
·(kn). Обычно k ограничено константой, и в этом случае время работы составляет
·(n). Таким образом, данный алгоритм является наиболее быстрым из вышерассмотренных (хотя разница становится заметной лишь при больших объёмах данных).
Недостатком алгоритма является зависимость от типа сортируемых данных: необходимо, чтобы данные допускали возможность разбития на разряды и их количество было ограничено. Примерами таких типов данных являются числа и короткие строки.
Ещё один недостаток алгоритма – использование вспомогательного массива такого же размера, как и исходный.
4.4.3. Распределяющая сортировка от старшего разряда к младшему
В случае сортировки строк переменной длины удобно выполнять распределяющую сортировку от старшего разряда к младшему. Выполнив сортировку по текущему разряду, мы разбиваем элементы на группы с одинаковым значением этого разряда. Для каждой из групп сортировка выполняется рекурсивно для следующего разряда:
void ssort(char* a[], int n, int k)
{ static char **b = new char* [n];
static int c[256]; int i,j;
//подсчет количеств каждого значения i-го символа
memset(c,0,sizeof(c));
for(i=0; i //сумма с накоплением
for(i=1; i<256; i++) c[i]+=c[i-1];
//сортировка по i-му разряду
for(i=n-1; i>=0; i--)
b[--c[(unsigned char)a[i][k]]] = a[i];
memcpy(a,b,sizeof(char*)*n);
//для каждой группы с одинаковым значением i-го разряда
//рекурсивно выполняем сортировку по (i+1)-му разряду
i=0;
while((i while(i { j = i;
while((j if (j>i+1) ssort(a+i, j-i, k+1);
i=j;
}
}
В худшем случае время выполнения данного алгоритма прямо пропорционально суммарной длине сортируемых строк и является асимптотически оптимальным.
Однако, в среднем время выполнения значительно меньше, так как сортировка группы завершается, как только она начинает состоять из одной строки. Если считать, что длина префикса, которой каждая строка уникально отличается от другой, ограничена некоторой небольшой константой (что недалеко от истины), то время работы составит O(n), где n – число строк.
5. Структуры и алгоритмы для поиска данных
5.1. Общие сведения
5.1.1. Постановка задачи поиска
Слово «поиск» имеет очень широкое толкование, поэтому, прежде чем переходить к материалу данной главы, поясним суть задачи.
Пусть имеется совокупность данных, состоящая из отдельных элементов, каждый из которых представляет собой запись из нескольких полей. Одно из полей выделим в качестве ключа для поиска, остальные поля образуют связанную с ключом информацию (точно так же, как в задаче сортировки  key и satellite data). При этом в одной записи вместе с ключом может храниться либо реальная информация, либо ссылка на другую запись, где хранится информация. Последняя ситуация является стандартной в базах данных, где поиск обычно ведется по нескольким различным ключам и по каждому из них создается своя структура для поиска (а реальная информация хранится в другом месте).
Задача поиска формулируется так   найти запись (или несколько записей), значение ключа которых совпадает с искомым значением. При этом становится доступной связанная с ключом информация. Обычно получение этой информации и является целью поиска. Иногда поиск может преследовать и более скромную цель  определить наличие или отсутствие записи с искомым значением ключа в совокупности данных. Например, если имеются данные о книгах в библиотеке, то, используя шифр книги в качестве ключа для поиска, можно получить как полную информацию о книге (автор, название, год издания и т.д.), так и просто выяснить, есть ли такая книга в наличии.
Ситуация, когда искомое значение не обнаружено, называется промахом, в противном случае говорят об успешном поиске (попадании). При анализе алгоритмов поиска обычно случаи промаха и попадания рассматриваются отдельно.
В данном примере шифр книги является уникальным, поэтому может быть найдена единственная запись или не найдено ничего. В принципе, допускаются и повторяющиеся значения ключей, например, если в примере о книгах в качестве ключа поиска взять автора или издательство. В этом случае возможны два варианта действий: 
найти первую попавшуюся запись;
найти все записи.
Принципиальной разницы между реализацией поиска при уникальных и повторяющихся значениях ключа нет, но программный код для случая повторяющихся ключей получается более громоздким и менее прозрачным для понимания. Поэтому в дальнейшем предполагается, что ключ или является уникальным, или при поиске достаточно найти одну (любую) запись с заданным ключом. Справедливости ради следует признать, что поиск всех записей с заданным значением ключа на практике очень востребован. Для этого случая алгоритмы, предлагаемые в данной главе, необходимо доработать.
Перечислим ряд дополнительных соображений, которые также входят в постановку задачи.
Данные, в которых ведется поиск, как правило, постоянно находятся на диске. Однако будем считать, что размер совокупности данных позволяет разместить ее целиком в оперативной памяти. В данной главе ограничимся только поиском в оперативной памяти. Методы внешнего поиска будут подробно рассмотрены в следующей главе.
Будем считать, что выбор структуры данных в оперативной памяти производится исключительно из соображений эффективного поиска, при этом допустимы некоторые дополнительные расходы памяти, которые окупятся ускорением поиска.
Данные, в которых ведется поиск, могут время от времени подвергаться изменениям (частота модификации данных зависит от конкретного приложения, но случаи статических данных на практике встречаются крайне редко). При этом все изменения, вносимые в данные, должны немедленно становиться доступными для поиска. Применительно к нашей постановке задачи это означает, что, кроме эффективного поиска, необходимо обеспечить возможность добавления и удаления элементов за приемлемое время, как правило, без коренной перестройки всей структуры. Изменение значения ключа записи обычно заменяют удалением записи с последующим добавлением новой записи.
На основе вышеизложенного можно сделать вывод, что основной проблемой при решении задачи поиска является выбор (разработка) подходящей структуры данных, которая поддерживала бы эффективное выполнение трех основных операций: поиск, вставка и удаление элементов. Такие структуры называют структурами для поддержки поиска.
Можно сформулировать этот вывод и так  задача поиска сводится к реализации абстрактного типа данных, который поддерживает эти три базовых операции. В литературе встречаются различные названия такого АТД, например, таблица [7, 13] или словарь [3]. Название и формальная функциональная спецификация в данном случае не имеют принципиального значения, поскольку постановка задачи понятна, а формальные соглашения по реализации будут приведены ниже.
5.1.2. Структуры для поддержки поиска
Структуры данных, поддерживающие поиск, хорошо проработаны и освещены в многочисленной литературе [7, 9, 3, 13]. Можно выделить три группы таких структур:
линейные (массивы и списки, возможно, предварительно отсортированные);
специальные виды деревьев, предназначенные для поддержки поиска;
хеш-таблицы (перемешанные таблицы).
Каждая группа включает довольно большое количество конкретных структур, которые, однако, объединены общими принципами и, как правило, обеспечивают одинаковые асимптотические оценки сложности алгоритмов. Наиболее распространенные структуры и алгоритмы для каждой из трех групп будут рассмотрены в данной главе. Изложение материала будет вестись в таком порядке  сначала линейные, затем древовидные структуры, в заключение  хеш-таблицы.
Как обычно, основные алгоритмы доведены до работоспособных программных модулей. Для того, чтобы внести единообразие в реализацию различных структур данных, примем некоторые соглашения по программному интерфейсу, которые соответствуют принятой постановке задачи.
5.1.3. Соглашения по программному интерфейсу
Будем считать, что запись (структура каждого элемента item) состоит из двух полей key (ключ) и data (связанные данные), тип которых задается с помощью оператора typedef. В примерах для определенности предполагается, что ключ является положительным целым числом, а связанные данные имеют тип char. Разумеется, можно было бы определить шаблон элемента, чтобы иметь возможность быстро конструировать любые типы для конкретных случаев поиска, однако каждый такой случай, скорее всего, потребует внесения каких-то изменений и в алгоритмы. Поэтому программный код, который будет реализован ниже, следует рассматривать как основу для реальных приложений. Приведем определение структуры:
typedef int T_key; //тип ключа, может быть любым
typedef char T_data;//тип связанных данных, любой
struct item //структура элемента
{ T_key key; //ключ
T_data data; //связанные данные
};
Функции, которые будут реализованы при анализе различных структур для поддержки поиска, в качестве первого параметра принимают ссылку на структуру, в которой будет выполнен поиск. Эта ссылка для конкретных структур может иметь различный тип. Все остальные параметры и возвращаемые значения будут одинаковыми для всех случаев, так нам удобнее выполнять реализацию и сравнивать различные способы.
// функция поиска возвращает найденный элемент типа item
// параметр – искомое значение k типа T_key
item seach(<ссылка на структуру>, T_key k);
// функции вставки и удаления возвращают логическое значение,
// успешно или нет выполнены эти операции
bool insert(<ссылка на структуру>, item x);//x-вставляемый элемент
bool remove(<ссылка на структуру>, T_key k); // k-значение,
// которое нужно найти и удалить

Возможны и несколько иные варианты определения базовых функций, например, функции insert и remove могут иметь тип void, а неудачное выполнение операций обрабатываться как аварийная ситуация.
Функция поиска seach в данном определении возвращает целиком найденную запись (всегда единственную), если поиск успешен. Для случая промаха предусмотрим специальную константу nullitem («пустышка»). Если предположить, что ключами являются положительные целые числа (на практике такой случай является очень распространенным), то константу nullitem можно определить, например, так:
const item nullitem={-1};
После того, как соглашения по интерфейсу сделаны, перейдем к конкретным реализациям структур для поддержки поиска.
5.2. Последовательный (линейный) поиск
Самая простая реализация поиска  использование обычного неупорядоченного массива или связного списка для хранения данных. Однако при небольших размерах совокупности данных (до нескольких тысяч записей) этот метод обеспечивает приемлемую скорость поиска, поэтому находит применение (в базах данных такой случай называют полным просмотром таблицы).
Алгоритм линейного поиска прост  необходимо просматривать все элементы по порядку (в прямом или обратном направлении) до тех пор, пока не будет найден искомый элемент или массив (список) не будет исчерпан. Данный алгоритм реализуется при помощи цикла, на каждом шаге которого выполняются два сравнения. Если массив уже исчерпан, а искомый элемент так и не найден, это является признаком промаха при поиске. Функция линейного поиска может иметь, например, такой вид:
// линейный поиск в массиве a из n элементов типа item
// item – структура, состоящая из ключа и связанных данных
item seach(item a[], int n, T_key k)
{ int i=0; // в цикле - два сравнения
while (a[i].key!=k&&i if(i==n) return nullitem;
else return a[i];
}
Можно сократить число сравнений в теле цикла, если использовать немного усовершенствованный алгоритм линейного поиска, который называется поиском с барьером (порогом, заграждающим элементом). Идея проста  искомый элемент записывается в конец массива (для этого должна быть зарезервирована лишняя служебная ячейка), тогда в цикле можно убрать проверку на исчерпание массива, поскольку элемент гарантированно будет найден. После цикла осталось только проверить, является ли найденный элемент последним (этот случай соответствует промаху).
// линейный поиск с барьером
item seach_bar(item a[], int n, T_key k)
{ int i=0;
a[n+1].key=k; // в цикле - одно сравнение
while (a[i].key!=k) i++;
if(i==n+1) return nullitem;
else return a[i];
}
Заметим, что функция поиска в массиве seach могла бы возвращать индекс найденного элемента, а в случае промаха  несуществующий индекс. Для этого требуется совсем незначительная ее модификация.

В любом случае, при успешном поиске требуется в среднем n/2 повторений цикла, при неуспешном  n (или n+1) повторений. Следовательно, алгоритм имеет линейную сложность
· (n).
Алгоритм вставки имеет константную сложность, т.к. новый элемент всегда вставляется в конец массива или списка:
// вставка элемента в неупорядоченный массив
bool insert(item a[], int &n, item x)
{ if (n>=maxsize) return false; // массив исчерпан
else {n++; a[n]=x; return true;}
}
Алгоритм удаления имеет линейную сложность
·(n), поскольку в этом случае надо сначала найти тот элемент, который удаляется. Заметим, что сам процесс удаления не требует перемещения элементов, поскольку достаточно поставить последний элемент массива на место удаляемого и уменьшить размер массива на единицу.
// удаление элемента
bool remove(item a[], int &n, T_key k)
{int i=0;
while (a[i].key!=k&&i if(i==n) return false; // элемент не найден
else {a[i]=a[n-1]; n--; return true;}
}
Линейный (последовательный) поиск можно выполнять и в предварительно упорядоченном массиве, при этом несколько сокращается количество повторений цикла для обнаружения промаха, поскольку можно гарантированно выходить из цикла, как только встретится первое значение ключа, большее искомого значения (дальше искать бесполезно). Однако для поиска в упорядоченных массивах имеется более эффективный алгоритм, который позволяет кардинально сократить время поиска.
5.3. Бинарный поиск в упорядоченном массиве
Он состоит в последовательном разделении упорядоченного массива пополам, при этом на каждом шаге можно определить, в какой из двух половин находится искомый элемент, сравнив искомый элемент и элемент, который находится в точке деления. При этом локализуется сначала половина массива, затем уже четверть массива и т. д. Процесс продолжается до тех пор, пока в массиве не останется один последний элемент. Если он совпадает с искомым значением, то поиск успешен, в противном случае имеем промах.
Данный алгоритм можно реализовать как итеративно, так и рекурсивно. На практике чаще используется более эффективный итеративный вариант, но рекурсивное решение является хорошей иллюстрацией идеи бинарного поиска.
// рекурсивный вариант функции,
// начальный вызов: seach_bin_r(a, 0, n-1, k);
item seach_bin_r(item a[], int l, int r, T_key k)
{ int m=(l+r)/2;
if (a[m].key==k) return a[m];//искомый элемент на границе
if (l==r) return nullitem;//поиск закончен промахом
if (k else return seach_bin_r(a,m+1,r,k);
}
// нерекурсивный вариант функции бинарного поиска
item seach_bin(item a[], int n, T_key k)
{ int l=0, r=n-1;
while (l<=r)
{ int m=(l+r)/2;
if (k>a[m].key) l=m+1;
else if (k else return a[m];
}
return nullitem;
}
Максимальное количество делений массива пополам составляет ближайшее целое, большее log2n , следовательно, асимптотическая оценка сложности поиска O(logn). Например, если в массиве 1000 элементов, то за 10 делений пополам мы уменьшим массив до одного элемента. Поиск может закончиться и раньше, если искомый элемент окажется граничным на каком-то промежуточном шаге.
Попробуем оценить, во что обходится поддержание массива в отсортированном виде. Ведь новый элемент уже нельзя вставить в конец массива, а удаляемый элемент нельзя заменить последним. Действительно, порядок временной сложности вставки становится O(n), время удаления также увеличивается, но асимптотическая оценка остается по-прежнему O(n) (сначала движемся по массиву вперед, чтобы найти нужный элемент, после этого продолжаем движение, перемещая каждый элемент влево).
На практике вопрос о применении данного алгоритма обычно решают путем сравнения количества операций поиска и операций обновления. Если данные обновляются редко, и размер массива велик, то бинарный поиск существенно ускорит производительность всего приложения в целом.
5.4. Бинарные деревья поиска
Проанализировав в целом реализацию поддержки поиска на основе линейной структуры, можно сделать вывод, что любой вариант включает функции с линейной временной сложностью алгоритма. При организации поиска на больших совокупностях данных стоит подумать об отказе от линейной структуры в пользу иерархической. Для этого есть весомые основания  в древовидной структуре, в отличие от линейной, путь от корня к любым данным не превышает высоты дерева, следовательно, имеется реальная возможность вообще избавиться от медленных операций с линейной сложностью.
Бинарное дерево поиска уже упоминалось в предыдущих главах. Напомним, что это упорядоченное бинарное дерево, для каждого узла которого выполняется условие все левые потомки имеют ключи, меньшие, чем ключ узла, а правые  большие (возможно равные).
5.4.1. Анализ алгоритмов поиска, вставки и удаления
Поиск
На рис. 5.1 приведены четыре различных бинарных дерева одинаковой высоты, каждое из которых обладает свойством упорядоченности, поэтому принципиально может использоваться в качестве дерева поиска. При изображении деревьев здесь и далее в узлах будем показывать только значения ключей (целые положительные числа), этого вполне достаточно для того, чтобы понять суть дела.

При достаточной плотности бинарного дерева поиска (рис.5.1,а) оно является очень удобной структурой быстрого поиска. Само название этого дерева, очевидно, связано с тем, что поиск нужного элемента можно выполнить кратчайшим путем. Двигаясь от корня к листьям и поворачивая при этом то вправо, то влево, мы в конце концов или найдем нужное значение ключа (попадание) или дойдем до пустой ссылки (промах). Путь, который был пройден до обнаружения попадания или промаха, назовем путем поиска.
Например, в дереве на рис.5.1,а значение 18 можно найти за 4 сравнения, при этом путь поиска пройдет через узлы с ключами 44 12 42 18 (последнее значение является искомым). Значение 55 будет найдено за 2 сравнения, а 44 (корень) будет обнаружено при первом же сравнении. При поиске значения 100 обнаружим промах за 3 сравнения, а при поиске числа 50  промах за 2 сравнения.
При восьми узлах дерева это не так плохо, однако можно было бы получить и лучший результат (максимум 4 сравнения при 15 узлах), если бы бинарное дерево поиска оказалось полным (см. разд. 3.4). Действительно, высота полного бинарного дерева
h =log2(n+1)-1,
т.е. в лучшем случае имеем логарифмическую сложность поиска, как для бинарного поиска в отсортированном массиве.
Для дерева на рис.5.1, б получаем результат похуже  для 6 узлов максимум 4 сравнения. И, наконец, на рис.5.1,в и 5.1,г представлены два самых худших варианта  вырожденные деревья, которые, по сути, ничем не отличаются от линейных списков, т. е. дают линейную сложность поиска.
Для того, чтобы избежать подобных крайне нежелательных ситуаций, на практике обычно используют так называемые сбалансированные деревья, высота которых специально поддерживается на своем нижнем уровне или близком к нему. Понятно, что сбалансированность дерева должна поддерживаться во время вставок и удалений элементов. Этому вопросу посвящен отдельный раздел, а сначала рассмотрим самые простые алгоритмы вставки и удаления, которые не гарантируют сбалансированной структуры дерева. Для нас они интересны тем, что с их помощью можно легко понять принципы работы с бинарными деревьями поиска, а затем использоваться их как основу алгоритмов для сбалансированных деревьев.
Для того, чтобы каждый раз отдельно не оговаривать лучший и худший случаи, будем оценивать сложность алгоритмов в зависимости от высоты дерева, а не от количества его узлов. Так, сложность алгоритма поиска можно оценить как
O(h), где h  высота бинарного дерева,
т. е. временная сложность поиска линейно зависит от высоты бинарного дерева поиска.
Вставка
Наиболее простым для реализации случаем вставки в бинарное дерево поиска является вставка каждого нового элемента в качестве листа дерева. В [13, 10] рассматривается алгоритм вставки нового элемента в корень, который также не гарантирует сбалансированности дерева, но приводит к тому, что последние добавленные элементы располагаются вблизи корня, следовательно, будут найдены быстрее (в некоторых задачах это важно). Вставка в корень будет рассмотрена немного позже, в данном разделе рассмотрим алгоритм вставки нового элемента в качестве листа.
Алгоритм вставки листа мало отличается от алгоритма поиска, поскольку сама вставка в уже найденную позицию сводится всего лишь к формированию нового элемента и присвоению значения соответствующей ссылке родителя. Поиск позиции для вставки  представляет собой передвижение по пути поиска до обнаружения пустой ссылки.
Например, для того, чтобы вставить в дерево на рис.5.1,а новый узел с ключом 50, сначала перемещаемся по пути поиска. При этом обнаружим пустую ссылку на левого сына у узла с ключом 55. Именно в эту позицию и будет вставлен новый элемент (рис.5.2,а).

13 EMBED Visio.Drawing.6 1415
Рис.5.2. Добавление узлов в бинарное дерево поиска
Несколько слов о повторяющихся значениях ключей. Для приложений бинарных деревьев поиска это достаточно редкая ситуация, но принципиально она допустима. Попробуем добавить к дереву на рис.5.2,а еще один элемент с ключом 44. Но такой ключ уже есть у корня. Не обращая внимания на это совпадение, движемся дальше по правой ветви, следуя определению бинарного дерева поиска. Новое значение добавится в качестве левого сына только что добавленного листа со значением 50, оказавшись довольно далеко от корня. Можно сказать и точнее повторяющееся значение будет крайним левым сыном в правом поддереве своего дубликата. Это наблюдение нам еще пригодится при реализации удаления.
Конечно, алгоритм поиска, который работает до первого совпадения, новый лист вообще никогда не найдет, поэтому для повторяющихся ключей этот алгоритм должен быть доработан.
А главный вывод, который можно сделать по алгоритму вставки  его временная сложность имеет тот же порядок, что и поиск. Временная сложность вставки, как и поиска, линейно зависит от высоты дерева.
Удаление
Удаление узлов выполняется несколько сложнее, чем поиск и вставка, поскольку новый узел можно всегда вставлять в качестве листа, но удалять приходится не только листья, но и внутренние узлы. Рассмотрим 3 основных ситуации.
1. Удаляется лист. Это самый простой случай, т. к. достаточно лишь обнулить соответствующую ссылку у родителя и, конечно, освободить память (это действие обязательно во всех случаях).
2. Удаляется внутренний узел, но имеющий только одно поддерево (левое или правое). Этот случай также особых проблем не представляет, т. к. единственное поддерево удаляемого узла подсоединяется к его родителю, и дерево не теряет своей упорядоченности.
3. Последний случай является самым сложным. У удаляемого внутреннего узла есть оба сына, например, из дерева на рис.5.3,а нужно удалить корень.
13 EMBED Visio.Drawing.6 1415

Рис.5.3. Удаления корня из бинарного дерева поиска (два варианта)
Ни один из сыновей удаляемого корня не сможет занять его место, не нарушив упорядоченности дерева. Но все же есть два варианта решения этой задачи, изображенные на рис.5.3,б и 5.3,в. В первом случае это самый последний правый сын из левого поддерева, во втором, наоборот, самый последний левый сын, но из правого поддерева. Оба решения вполне логичны, на самом деле, это два самых близких значения к ключу корня (одно немного меньше, другое немного больше, а при наличии повторяющихся значений вторым способом будет найден дубликат). При реализации для определенности можно следовать любому из двух вариантов.
Несмотря на разветвленную логику алгоритма удаления, количество перемещений по дереву по-прежнему не превышает его высоту. Это значит, что мы опять имеем линейную зависимость от высоты дерева.
Итак, последний вывод  бинарное дерево поиска с хорошей степенью плотности позволяет полностью избежать медленных операций с линейной сложностью от количества узлов дерева, при этом все операции имеют линейную сложность от высоты.
Теперь можно перейти к реализации функций для работы с бинарным деревом поиска.
5.4.3. Реализация бинарного дерева поиска
В подавляющем большинстве приложений используется ссылочная реализация дерева на основе указателей. Структура узла бинарного дерева в этом случае содержит, кроме ключа и связанных с ним данных, указатели на правого и левого сына. Поэтому введем новую структуру узла дерева node, которая содержит в качестве данных уже используемую ранее структуру item и два указателя.
Определим тип bst (binary seach tree  бинарное дерево поиска),
typedef node* bst;
который будет использоваться при определении первого параметра всех функций.
В листинге 5.3 содержится рекурсивный и нерекурсивный варианты реализации функций поиска и вставки и нерекурсивный вариант функции удаления (рекурсивный можно найти в [14]). Для полноты картины приведена функция ЛКП-обхода, которая выводит элементы дерева в порядке возрастания ключей, и функция, которая строит бинарное дерево поиска, заполненное случайными значениями, вызывая при этом функцию вставки.
Листинг 5.3. Реализация бинарного дерева поиска
#include
#include
typedef int T_key; //тип ключа, может быть любым
typedef char T_data;//тип связанных данных, любой
struct item //структура элемента
{ T_key key; //ключ
T_data data; //связанные данные
};
const item nullitem={-1};//пустой элемент возвращается при промахе поиска
struct node // узел дерева
{item data; // данные
node *left, *right; // указатели на детей
node(item x) // конструктор для заполнения узла при создании
{data=x;left=right=NULL;}
};
typedef node* bst; //bst - binary seach tree
// ниже приводится реализация функций
// нерекурсивная функция поиска
item seach(bst root, T_key k)
{if (!root) return nullitem;
bst p=root;
while (p)
{ if (k==p->data.key) return p->data;
if (kdata.key) p=p->left;
else p=p->right;
}
return nullitem;
}
// рекурсивный вариант функции поиска
item seach_rec(bst root, T_key k)
{ if(!root) return nullitem; // дерево пусто - промах
if (k==root->data.key) return root->data; // поиск успешен
if (kdata.key) return seach_rec(root->left, k);
else return seach_rec(root->right, k);
}
// нерекурсивная функция вставки
bool insert(bst &root, item x)
{ if (!root) // дерево еще не заполнено
{ root=new node(x); if(root)return true; else return false;
}
bst p=root,parent; // parent родитель p
while (p) // находим место для вставки
{ parent=p;
if (x.keydata.key) p=p->left;
else p=p->right;
}
p= new node(x); // формируем новый элемент
//вставляем его как левого или правого сына
if (x.keydata.key) parent->left=p;
else parent->right=p;
if (p) return true; else return false;
}
// рекурсивная функция вставки
bool insert_rec(bst &root, item x)
{ if (!root)// дерево пустое - терминальная ветвь
{ root=new node(x);if (root) return true; else return false;
}
if (x.keydata.key) return insert_rec(root->left,x);
else return insert_rec(root->right,x);
}
// функция удаления узла
bool remove(bst &root, T_key k)
{ if(!root) return false; // дерево пусто
bst p=root,parent=NULL;
// поиск удаляемого узла p и его родителя
while (p&&k!=p->data.key)
{ parent=p;
if (kdata.key) p=p->left;
else p=p->right;
}
if (!p) return false; // обработали промах
// удаляем лист
if (!p->left&&!p->right)
if(p==root) root=NULL; //может, в дереве всего один узел
else if (parent->left==p) parent->left=NULL;
else parent->right=NULL;
// удаляем узел, у которого только один сын
if (p->left&&!p->right||!p->left&&p->right)
{ bst q; // запомним указатель на сына
if (p->left) q=p->left; else q=p->right;
if(p==root) root=q; // у корня нет родителя
else // подсоединяем сына к дедушке, удаляя родителя
if (parent->left==p) parent->left=q;
else parent->right=q;
}
if (p->left&&p->right)// есть оба сына
{ //спускаемся в левое поддерево
bst t=p->left,parent=p; //parent-родитель t
while (t->right) {parent=t;t=t->right;}
//нашли крайнего правого сына t и его родителя parent
p->data=t->data; //заменили данные у удаляемого узла
// теперь удаляем крайнего правого сына t
if (!t->left) //он лист
parent->right=NULL;
else // у него есть левое поддерево
parent->right=t->left;
p=t; // теперь можно освобождать память для t
}
delete p; return true; //освободили память
}
// формирование бинарного дерева поиска из n случайных элементов
void randtree(bst &root, int n)
{ item x;
for (int i=0;i { x.key=rand()%1000; // случайные числа
x.data=rand()%26+65; // случайные заглавные латинские буквы
insert(root,x); //или insert_rec(root,x);
}
}
// вывод дерева в порядке возрастания ключей
void out(bst root)
{if (!root) return;
out(root->left);
cout<data.key<<" "<data.data<<"; ";
out(root->right);
}
5.5. Сбалансированные деревья
Теперь можно перейти к обсуждению вопроса, как поддерживать бинарное дерево поиска в таком состоянии, которое полностью исключает наличие в нем длинных путей для поиска. Безусловно, самым идеальным вариантом для поиска является полное бинарное дерево, в котором каждый узел идеально сбалансирован, т. е. имеет одинаковое число потомков в правом и левом поддереве. При произвольном количестве узлов наиболее оптимальным для поиска является требование, чтобы все листья располагались в двух последних уровнях дерева. К сожалению, поддержка дерева в таком состоянии при выполнении каждой вставки или удаления требует значительных усилий. Если таблица изменяется достаточно интенсивно, то существенное замедление вставок и удалений сведет на нет весь выигрыш в скорости поиска.
Однако есть несколько способов поддержки дерева в хорошем (хоть и не идеальном) состоянии за приемлемое время вставки и удаления. Деревья, которые постоянно поддерживаются в состоянии, близком к наилучшему, с приемлемыми временными характеристиками вставки и удаления, получили название сбалансированных. Мы рассмотрим несколько видов таких деревьев. Наиболее изученными деревьями, которые поддерживают хорошее сбалансированное состояние, являются АВЛ-деревья.
5.5.1. АВЛ-деревья
Определение и свойства АВЛ-деревьев
АВЛ-дерево названо в честь изобретателей этого метода Г.М. Адельсона-Вельского и E.М. Ландиса, которые дали ему следующее определение. Дерево называется АВЛ-деревом, если для любого его узла высоты левого и правого поддеревьев отличаются не более чем на 1. АВЛ-дерево называют сбалансированным по высоте.
Согласно определению, сбалансированность АВЛ-дерева должна проверяться для каждого его узла. С этой целью вводится дополнительная характеристика каждого узла, которая называется показателем сбалансированности узла или балансом узла. Этот показатель представляет собой разность между высотой правого и левого поддерева узла (сами высоты поддеревьев не важны, так как только разность является мерой сбалансированности). Для сбалансированного дерева этот показатель может принимать всего три значения (рис.5.4):
0 высоты поддеревьев равны;
-1  левое поддерево немного перевешивает;
1  правое поддерево немного перевешивает.
13 EMBED Visio.Drawing.6 1415
Рис.5.4. Значения показателя сбалансированности
Здесь треугольниками разной высоты обозначены поддеревья узла (такое обозначение будет использоваться и в других рисунках).
На рис. 5.5 изображен пример АВЛ-дерева, из которого видно, что оно не соответствует идеальному состоянию сбалансированности, поскольку листья располагаются в трех нижних уровнях. Тем не менее, для всех узлов балансы находятся в допустимых пределах (балансы изображены сверху от каждого узла). Обратим внимание, что у всех узлов, которые имеют только одного сына, этот сын является листом, а все листья имеют нулевые балансы.
13 EMBED Visio.Drawing.6 1415
Рис.5.5. Пример АВЛ-дерева с расставленными балансами узлов
Авторами АВЛ-дерева доказано, что при наличии n узлов высота дерева находится в интервале от log2(n+1)-1, что соответствует полному бинарному дереву, до 1,44log2(n+2)-1,33 для наихудшего случая (доказательство приводится в [8, 21]). Иными словами, оно гарантированно обеспечивает время поиска, не превышающее наилучший случай более чем на 45%.
Замечательной особенностью АВЛ-деревьев является то, что это почти идеальное время поиска достигается незначительным усложнением алгоритмов вставки и удаления, которые по-прежнему могут быть выполнены с логарифмической временной сложностью.
Для восстановления состояния сбалансированности АВЛ-дерева при вставках и удалениях необходимо проверить балансы всех его узлов, и в случае недопустимых значений выполнить специальные операции, которые получили название вращений (другие названия  повороты, ротации, англ. rotations). Рассмотрим подробнее, что представляют собой вращения.
Вращения
Вращениями называются такие изменения структуры дерева, которые не меняют содержащуюся в нем информацию и не нарушают упорядоченности, но приводят к более сбалансированной структуре. Иными словами, после выполнения вращения последовательность, полученная при помощи ЛКП обхода дерева, не должна измениться.
Для балансировки АВЛ-дерева используют четыре вида вращений: левое и правое малые вращения затрагивают два узла вместе с их поддеревьями, левое и правое большое вращения затрагивают три узла с их поддеревьями. Большие вращения представляют собой комбинацию двух малых, поэтому их иногда называют комбинированными или двукратными (а малые  однократными).
Начнем с малых вращений. Сначала рассмотрим примеры. На рис.5.6 показано дерево всего с двумя узлами (узел 7 является правым сыном узла 5). Ясно, что поворот рисунка не нарушил упорядоченности дерева, но изменил его структуру. Теперь узел 5 стал левым сыном узла 7. Такое преобразование называется малым правым вращением (понятия левое и правое в данном случае весьма условны, мы пользуемся терминологией из [Вирт, Шень]). Если на рис.5.6 изменить направление стрелки, то будем иметь левое малое вращение.
13 EMBED Visio.Drawing.6 1415
Рис.5.6. Простой пример малого правого вращения
Однако в данном случае никакой необходимости во вращении не было, так как дерево из двух узлов всегда сбалансировано. Поэтому усложним пример  нарушен баланс в узле 5 (рис.5.7). Сбалансированность можно восстановить при помощи малого правого вращения для узлов 5 и 7, как на предыдущем рисунке, при этом получим идеально сбалансированное дерево. Из данного примера понятно, что вращение не сводится к простому повороту рисунка, поскольку левое поддерево узла 7 стало теперь правым поддеревом узла 5.
13 EMBED Visio.Drawing.6 1415
Рис.5.7. Восстановление сбалансированности при помощи малого правого вращения
Сформулируем правило малого правого вращения. Пусть вершина a имеет правого сына b . Обозначим через P левое поддерево вершины a, через Q и R  левое и правое поддеревья вершины b (рис.5.8). Упорядоченность дерева требует выполнения условия P
13 EMBED Visio.Drawing.6 1415
Рис.5.8. Общее правило для малого правого вращения
Кроме малых вращений в АВЛ-дереве используются еще большие вращения, которые затрагивают три узла дерева с их поддеревьями. Сначала простой пример  дерево из трех узлов (рис.5.9), у которого нарушенный баланс узла 4, восстанавливается с помощью большого правого вращения.
13 EMBED Visio.Drawing.6 1415
Рис.5.9. Простой пример большого правого вращения
Из этого примера видно, что фактически большое вращение это два малых (левое и правое), выполненных одно за другим. Теперь более сложный пример (рис.5.10), из которого видно, как перемещаются поддеревья. Обратим внимание, что опять полностью восстановили сбалансированность.
13 EMBED Visio.Drawing.6 1415
Рис.5.10. Более сложный пример большого правого вращения
Общее правило показано на рис.5.11. Аналогично определяется большое левое вращение  достаточно поменять направление стрелки на рис.5.11.
13 EMBED Visio.Drawing.6 1415
Рис.5.11. Общее правило большого правого вращения
Доказано, что рассмотренных выше четырех вариантов вращения достаточно для того, чтобы при выполнении вставок и удалений элементов все время поддерживать дерево в сбалансированном состоянии.
Например, рассмотрим процесс построения АВЛ-дерева из последовательности элементов 4 5 7 2 1 3 6 (данный пример приводится в [4] и хорош тем, что позволяет продемонстрировать все четыре вида вращений). Если использовать обычный алгоритм вставки каждого узла в качестве листа бинарного дерева поиска, то получим дерево, изображенное на рис. 5.12.
13 EMBED Visio.Drawing.6 1415
Рис.5.12. Бинарное дерево поиска, построенное при помощи обычного алгоритма вставки
Данное дерево не сбалансировано в узлах 2 и 5, его высота равна трем. Попробуем построить из этой же последовательности сбалансированное дерево высоты 2, используя вращения. Процесс построения дерева изображен на рис.5.13, пунктирная связь проведена к тому узлу, который добавляется на каждом этапе.
13 EMBED Visio.Drawing.6 1415
а) добавление одного элемента к корню не может нарушить упорядоченности





б) теперь корень(4) не сбалансирован выполнили малое правое вращение (узлы 4 и 5)
13 EMBED Visio.Drawing.6 1415
в)сбалансированность не нарушилась 13 EMBED Visio.Drawing.6 1415
г) выполнили малое левое вращение (узлы 4 и 2)
13 EMBED Visio.Drawing.6 1415
д) выполнили большое левое вращение (узлы 5, 2 и 4)
13 EMBED Visio.Drawing.6 1415
е) выполнили большое правое вращение (узлы 5,7 и 6)
Рис.5.13. Последовательность построения АВЛ-дерева из значений 4 5 7 2 1 3 6.
Теперь дерево полностью сбалансировано, а высота его равна двум. Интересно, что в данном случае удалось получить полное бинарное дерево (все листья в одном нижнем уровне), поскольку количество узлов равно 7. При произвольных данных результат был бы несколько хуже.
Теперь обсудим в общих чертах алгоритмы вставки и удаления элементов. Алгоритм поиска в АВЛ-дереве полностью совпадает с алгоритмом для обычного бинарного дерева, обеспечивая при сбалансированной структуре логарифмическую сложность от числа узлов в любом случае.
Алгоритмы вставки и удаления
Для обеспечения сбалансированности АВЛ-дерева придется существенно усложнить функции добавления и удаления элементов. Сейчас наша задача состоит в том, чтобы проанализировать усложненные алгоритмы вставки и удаления и убедиться, что их несколько увеличившаяся временная сложность все же не превысила логарифмическую.
Очевидно, что баланс каждого узла удобно хранить в самом узле вместе с другими данными, чтобы можно было легко получать и корректировать его значение при выполнении любых действий (вставок, удалений и вращений). В принципе, можно было бы завести и отдельный массив для хранения балансов, но большого смысла в этом нет.
Общий алгоритм вставок и удалений состоит из двух шагов.
Выполнить вставку или удаление элемента в соответствии с алгоритмом для обычного бинарного дерева. При включении нового узла как листа определить для него нулевой показатель сбалансированности.
Пройти обратно до корня по тому же самому пути поиска, по которому только что пришли, при этом проверить и пересчитать баланс каждого узла и выполнить нужное вращение, если этот показатель принял недопустимое значение.
Как видим, при вставке и удалении придется два раза выполнить перемещения по пути поиска (сначала вперед, потом обратно), но при этом мы не потеряли логарифмической сложности от количества узлов, поскольку путь поиска не превышает высоты АВЛ-дерева, которая все время поддерживается на своем минимальном уровне,
Таким образом, в АВЛ-дереве операции поиска, вставки и удаления имеют логарифмическую сложность от количества узлов дерева независимо от исходных данных.
Как известно, при вставке и удалении элементов бинарного дерева поиска можно применять рекурсивный и нерекурсивный алгоритмы. Оба варианта можно использовать и при реализации АВЛ-дерева, но, пожалуй, легче модифицировать рекурсивные алгоритмы, поскольку пройденный путь поиска все равно нужно запоминать, чтобы именно по нему вернуться обратно, выполняя балансировку. В нерекурсивном варианте придется использовать дополнительный стек, а в рекурсивном варианте этот путь уже запоминается в системном стеке программы, так что останется только им воспользоваться.
В качестве примера приведем реализацию рекурсивного алгоритма вставки в АВЛ-дерево.
Реализация рекурсивного алгоритма вставки в АВЛ-дерево
Дополним уже имеющуюся структуру узла бинарного дерева поиска еще одним полем (назовем его balance), которое будет служить для хранения баланса узла. Вообще-то для хранения баланса узла достаточно всего двух бит, но в реализации на С++ определим для него тип short.
Для реализации алгоритма вставки нам потребуются вспомогательные функции. Это четыре функции , соответствующие большим и малым правым и левым вращениями, и еще одна функция c именем rotate, в которой будет приниматься решение, какое именно из вращений нужно выполнить.
Алгоритм функции rotate основан на анализе балансов. Если значение этого показателя для узла положительно, значит, перевешивает правое поддерево и нужно выполнять правое вращение, и наоборот, при отрицательном значении нужно выполнять левое вращение. Решение о том, большое или малое вращение нужно выполнить, можно принять на основе рисунков 5.8 и 5.11.
Функции вращения потребуют большой аккуратности, но в основном они сводятся к изменению нескольких указателей. Следует иметь в виду, что после преобразований нужно изменить значения балансов для тех узлов, которые участвовали во вращении.
При наличии данных вспомогательных функций сама функция вставки элемента будет реализовывать общую логику рекурсивной вставки в бинарное дерево поиска. При этом после каждого рекурсивного вызова функции вставки в левое или правое поддерево узла будем корректировать баланс этого узла. Если вызывалась функция вставки для правого поддерева, изменение высоты этого поддерева нужно прибавить к показателю сбалансированности узла, а если для левого вычесть. Если при этом получим значения 2 или -2 , вызываем функцию вращения.
Для реализации этого алгоритма добавим еще один параметр в функцию вставки  изменение высоты поддерева (в листинге это параметр d). При добавлении узла этот параметр будет принимать значение 1 (узел всегда добавляется в пустое поддерево), а при вращениях, наоборот, высота поддерева уменьшается на 1 (т.е. d=-1).
Реализация рекурсивного алгоритма вставки в АВЛ-дерево приводится в листинге 5.4. Кроме функции вставки и всех необходимых вспомогательных функций, приводится функция построения АВЛ-дерева из заданной последовательности и функция нисходящего обхода, которая строит левое скобочное представление полученного дерева (этого вполне достаточно, чтобы убедиться в том, что построенное дерево является сбалансированным).
Листинг 5. Вставка в АВЛ-дерево с восстановлением сбалансированности
#include
#include
typedef int T_key; //тип ключа, может быть любым
typedef char T_data;//тип связанных данных, любой
struct item //структура элемента
{ T_key key; //ключ
T_data data; //связанные данные
};
struct node // структура узла дерева
{item data; //данные типа item
node *left, *right; // указатели на детей
short balance; // показатель сбалансированности
node(item x) // конструктор, вызывается при создании узла
{data=x;left=right=NULL;balance=0;}
};
typedef node* avlbst; //avlbst - avl binary seach tree
//Малое правое вращение:
void SmallRightRotate(avlbst root)
{ avlbst x,y; item t;
x=root; y=x->right;
t=x->data; x->data=y->data; y->data=t;
x->right=y->right;
y->right=y->left; y->left=x->left;
x->left=y;
x->balance= y->balance=0; //изменяем balance для x и y
}
//Большое правое вращение:
void LargeRightRotate(avlbst root)
{ avlbst x,y,z; item t;
x=root; y=x->right; z=y->left;
t=x->data;x->data=z->data; z->data=t;
y->left=z->right;
z->right=z->left; z->left=x->left;
x->left=z;
x->balance=0;
if (z->balance==0) y->balance=z->balance=0;
else
if (z->balance==-1) { y->balance=0; z->balance=1;}
else {z->balance=0; y->balance=-1;}
}
//Малое левое вращение (аналогично малому правому):
void SmallLeftRotate(avlbst root)
{ avlbst x,y; item t;
x=root; y=x->left;
t=x->data; x->data=y->data; y->data=t;
x->left=y->left;
y->left=y->right; y->right=x->right;
x->right=y;
x->balance=y->balance=0;
}
//Большое левое вращение (аналогично большому правому):
void LargeLeftRotate(avlbst root)
{ avlbst x,y,z; item t;
x=root; y=x->left; z=y->right;
t=x->data;x->data=z->data; z->data=t;
y->right=z->left;
z->left=z->right; z->right=x->right;
x->right=z;
x->balance=0;
if (z->balance==0)
y->balance=z->balance=0;
else
if (z->balance==-1)
{ y->balance=0; z->balance=1;}
else
{ z->balance=0; y->balance=-1;}
}
// функция определяет, какое именно вращение нужно
void rotate(avlbst root)
{ if (root->balance==2) //Правое вращение
if (root->right->balance<0) LargeRightRotate(root);
else SmallRightRotate(root);
else //Левое вращение
if (root->left->balance>0) LargeLeftRotate(root);
else SmallLeftRotate(root);
}
bool insertavl_rec(avlbst &root, item x, int &d)
{ //параметр d - изменение высоты текущего поддерева
if (!root) // дерево пусто – терминальная ветвь
{ root=new node(x); d=1;//создали узел-высота увеличилась на 1
if(root)return true; else return false;
}
// рекурсивная ветвь
if (x.keydata.key) // нужно двигаться влево
{ insertavl_rec(root->left,x,d); // вставка в левое поддерево
root->balance=root->balance-d; //корректируем balance у отца
if (abs(root->balance)==2) //если отец (root) разбалансирован
{ rotate(root); d--;}// вызываем нужное вращение
} // оно уменьшило высоту дерева
else // нужно двигаться вправо
{ insertavl_rec(root->right,x,d); // вставка в правое поддерево
root->balance=root->balance+d; //пришли справа, d прибавляется
if (abs(root->balance)==2) // при разбалансировке
{ rotate(root); d--;} // выполняем вращение
}
}
// функция-тест строит дерево из примера (рис.5.13)
void randtree(avlbst &root)
{ item x; int a[7]={4,5,7,2,1,3,6}; int d=0;
for (int i=0;i<7; i++)
{ x.key=a[i];
x.data=rand()%26+65; // случайные заглавные латинские буквы
insertavl_rec(root,x,d);
}
}
// вывод дерева в КЛП порядке (для проверки сбалансированности)
void out(avlbst root)
{if (!root) return;
cout<data.key<<' '<data.data<<"; ";
cout<<'('; out(root->left); cout<<")(";
out(root->right);cout<<')';
}
5.5.2. Сильноветвящиеся деревья
Еще одна группа сбалансированных деревьев поиска, которая использует другой способ поддержки сбалансированности,  сильноветвящиеся деревья. Напомним, что узлы сильноветвящихся деревьев могут иметь более двух сыновей, т. е. их нельзя отнести к бинарным деревьям. Однако принцип упорядоченности для сильноветвящихся деревьев аналогичен принципу, по которому строятся бинарные деревья поиска. А для того, чтобы обеспечить ветвление более чем по двум направлениям, узлам разрешено иметь более одного ключа.
Допустим, узел дерева имеет два ключа  10 и 20. Такой узел может иметь максимум три сына  первый (крайний левый) должен иметь ключи, меньшие 10, второй  от 10 до 20 (10 входит в этот диапазон), третий (крайний правый)  более или равно 20.
На практике используются различные виды сильноветвящихся деревьев. В качестве структур для поиска в оперативной памяти применяются 2-3 деревья (их узлы имеют двух или трех сыновей) и 2-3-4 деревья (узлы могут иметь еще и четырех сыновей). Для поиска во внешней памяти наиболее подходят B-деревья, которые могут ветвиться еще сильней (B  balansed). B-деревья в настоящее время являются основными структурами для поиска в базах данных.
Различают две разновидности сильноветвящихся деревьев поиска. В первом случае все данные, в которых ведется поиск, располагаются только в листьях, а внутренние узлы содержат ключи-разделители, которые задают диапазоны ключей в поддеревьях и служат для выбора правильного направления движения по пути поиска. Во втором случае данные распределены по всем узлам.
В качестве примера рассмотрим структуру 2-3 дерева, в котором вся информация размещается в листьях [3]. Будем считать этот материал введением в сильноветвящиеся деревья поиска.
2-3 деревья
Пример 2-3 дерева изображен на рис. 5.14. Поскольку внутренние узлы могут содержать два ключа, на рисунке они изображены в виде прямоугольников, а листья, содержащие только один ключ, изображены, как обычно, в виде окружностей. Свойства 2-3 деревьев можно определить так.
Каждый лист содержит ключ и связанные с ним данные (на рисунке показаны только ключи). В графическом представлении 2-3 дерева ключи в листьях упорядочены слева направо.
Каждый внутренний узел содержит один или два ключа. Узел, содержащий один ключ, имеет двух сыновей, узел, содержащий два ключа, имеет трех сыновей.
Первым ключом внутреннего узла является ключ наименьшего из потомков второго сына (второй сын всегда есть, а его наименьший потомок может использоваться в качестве разделителя между первым и вторым поддеревом). Вторым ключом может являться ключ наименьшего потомка третьего сына, если третий сын есть. Тогда его наименьший потомок задает границу между вторым и третьим поддеревом.
Все листья располагаются на одном уровне. Исходя из этого условия, решается вопрос, сколько сыновей должен иметь каждый из внутренних узлов  два или три.
Добавим, что пустое дерево и дерево с одним корнем также являются 2-3 деревьями.
13 EMBED Visio.Drawing.11 1415
Рис.5.14. Пример 2-3 дерева
2-3-дерево с k уровнями может иметь от 2k-1 до 3k-1 листьев. Если в дереве n элементов, то дерево будет иметь от 1+log3n до 1+log2n уровней. Таким образом, длины всех путей имеют порядок O(log2n).
Рассмотрим выполнение основных операций над 2-3 деревьями.
Поиск элемента в 2-3- дереве
Поиск выполняется аналогично поиску в бинарном дереве с той разницей, что для тех узлов, которые имеют два ключа, разветвление возможно по трем направлениям  к первому (левому), второму или третьему сыну, в зависимости от значения ключа. Например, для того, чтобы найти элемент с ключом 16 на рис.5.14, переходим от корня к его третьему сыну, а из этого узла переходим к первому сыну (он уже является листом и его ключ равен 16, поэтому поиск закончен успешно). При попытке найти ключ 18, будем двигаться по тому же самому пути, но, дойдя до листа, не обнаружим там значение 18, и поиск закончится промахом.
Вставка элемента в 2-3-дерево
Как обычно, сначала находим место, куда необходимо вставить новый элемент, двигаясь по пути поиска, как описано выше. Пусть мы уже спустились на уровень, непосредственно предшествующий листьям, и стоим в узле node. Далее возможны два варианта действий.
Если узел node имеет два сына, то делаем новый элемент третьим, помещая его в правильном порядке среди других сыновей, и изменяем значения ключей в родителе node. Например, на рис.5.15 показано добавление элемента с ключом 18 , к дереву ни рис.5.14. Больше никаких преобразований структуры не требуется, поскольку сбалансированность дерева не нарушена. Это простой случай.
13 EMBED Visio.Drawing.11 1415
Рис. 5.15 Вставка элемента 18 в 2-3 дерево.
Второй случай сложнее. Если узел node уже имеет трёх сыновей, то новый добавляемый элемент становится четвертым сыном. Но четырех сыновей ни у одного узла 2-3 дерева быть не может. Поэтому выполняется операция, называемая расщеплением. Узел node раcщепляется на два узла node и node'. Два наименьших элемента из четырёх становятся сыновьями узла node, два наибольших – сыновьями узла node'. Теперь нужно вставить узел node' среди сыновей узла p – родителя узла node. Здесь ситуация аналогичная. Если p имеет два сына, то node' становится третьим и помещается непосредственно справа от node. Если узел p уже имеет трёх сыновей, то он расщепляется на да узла p и p', узлу p приписываются два наименьших сына, узлу p' – оставшиеся. Затем вставляем узел p' среди сыновей родителя узла p и т.д.
На рис.5.16 показан первый этап вставки элемента 10 в 1-2 дерево из рис.5.15, на котором произошло расщепление узла с ключами 8 и 12 на два узла. В полученном дереве корень имеет четырех сыновей, следовательно, потребуется расщеплять корень.
13 EMBED Visio.Drawing.11 1415

Рис.5.16. Вставка элемента 10 в 2-3 дерево  первый этап
В этой ситуации создаётся новый корень, чьими сыновьями будут два узла, полученные в результате разбиения старого корня. При этом число уровней дерева увеличивается на 1. Обратим внимание, что это единственная ситуация, когда высота дерева увеличивается, но сбалансированность не нарушается все пути от корня до любого листа по-прежнему имеют одинаковую длину (рис.5.17).
13 EMBED Visio.Drawing.11 1415

Рис.5.17. Вставка элемента 10 в 2-3 дерево  2 этап (расщепление корня.)
Но в данной ситуации можно было бы обойтись без расщепления корня, если выполнить еще одну операцию над 2-3 деревьями, которая называется переливанием. Так, из рис.5.16 можно догадаться, что от одного из сыновей корня (это второй сын с ключом 8) можно избавиться, передав его первого сына левому брату, а второго сына правому брату. Это возможно, поскольку у каждого брата только по два сына. Результат показан на рис.5.18.


13 SHAPE \* MERGEFORMAT 1415

Рис.5.18. Вставка элемента 10 в 2-3 дерево  2 этап (переливание узлов)
Однако при попытке вставить любое новое значение в дерево на рис. 5.18 все равно придется выполнять расщепление корня, и высота дерева увеличится на 1.
Удаление элемента из 2-3-дерева.
Если у родителя три листа, то удаление проблем не представляет. Например, из дерева на рис.5.18 можно удалить любой лист без какого-либо дополнительного преобразования структуры.
Рассмотрим случай, когда у узла два листа. Если узел является корнем, то единственный сын становится новым корнем дерева. Пусть node не является корнем. p – его родитель.
Если p имеет другого сына, расположенного слева или справа от node и имеющего трёх сыновей, то один из них становится сыном узла node. Это уже известная операция переливания.
Если сын p имеет только двух сыновей, то единственный сын узла node присоединяется к этим сыновьям, а узел node удаляется. Такая операция является обратной расщеплению и называется склеиванием. Если после этого родитель p будет иметь только одного сына, то повторяется вышеописанный процесс с заменой node на p.
Например, удалим элемент 10 из рис.5.17.

13 SHAPE \* MERGEFORMAT 1415
Рис.5.19. Удаление элемента 10 из 2-3 дерева из рис.5.17
В данном случае была выполнена операция переливания.
Теперь удалим из полученного дерева узел 7. Выполнив операции переливания и склеивания, получим дерево, изображенное на рис.5.20.
13 SHAPE \* MERGEFORMAT 1415
Рис.5.20. Удаление элемента 7 из 2-3 дерева из рис.5.19
Реализация вышеописанных операций не сложна, но трудоемка, поэтому здесь не приводится.
Бинарные представления сильноветвящихся деревьев
Любое упорядоченное дерево можно представить в виде эквивалентного ему бинарного дерева, поэтому известны бинарные представления как для 2-3 дерева, так и для 2-3-4 дерева. Бинарное представление 2-3-4 деревьев получило название красно-черных деревьев. Наиболее полное описание красно-черных деревьев содержится в [10].
5.5.3. Рандомизированные деревья поиска
Рассмотренные выше сбалансированные деревья гарантированно обеспечивают логарифмическую сложность выполнения основных операций. Существует довольно простая реализация бинарного дерева поиска, которая не гарантирует полное исключение длинных путей, но делает вероятность их появления ничтожно малой. Это так называемые рандомизированные или случайные деревья поиска.
Доказано [8, 10], что если при построении дерева исходные данные будут поступать в случайном порядке (т. е. равновероятны все n! перестановок исходных данных), то средняя высота такого дерева будет пропорциональна log n. Считаем, что все ключи уникальны.
На практике далеко не во всех случаях имеется возможность при построении дерева подавать исходные данные на вход алгоритма в случайном порядке. Но есть возможность встроить случайность в сам алгоритм построения дерева. Для этого в реализации обычных бинарных деревьев поиска необходимо изменить алгоритм вставки, отказавшись от самого простого способа вставки нового узла в качестве листа. Теперь новый элемент вставляется как корень одного из поддеревьев, причем вставка в любое поддерево равновероятна и управляет этим процессом датчик случайных чисел.
Таким образом, основой для построения рандомизированного дерева поиска является алгоритм вставки нового элемента в корень дерева, о котором уже упоминалось. Понятно, что мы не можем сделать добавляемый элемент новым корнем, просто подвесив к нему старое дерево в качестве левого или правого поддерева. Легко проверить, что в этом случае упорядоченность дерева нарушится и восстановить ее будет не очень-то просто. Поэтому поступают по-другому сначала вставляют новый элемент в качестве листа, а потом с помощью уже известных нам вращений (малых  левого и правого) последовательно продвигают его к корню. Наиболее просто реализуется рекурсивный алгоритм вставки в корень, который отличается от обычного алгоритма вставки в качестве листа только тем, что после каждого рекурсивного вызова вставки в левое или правое поддерево вызывается соответствующая функция вращения.
Вставка нового узла в рандомизированное дерево поиска выполняется так. Начиная с корня дерева, движемся по пути поиска, как при вставке в обычное бинарное дерево поиска. При посещении каждого узла датчик случайных чисел формирует очередное случайное число. Считаем, что вероятность вставки нового элемента в корень поддерева равна 1/(n+1), где n  число узлов этого поддерева. Диапазон изменения случайного числа подбирается так, чтобы обеспечить именно такую вероятность вставки в данное поддерево. Если случайное число примет определенное значение, движение по пути поиска прерывается, после чего вызывается функция вставки в корень.
Конечно, данный алгоритм не гарантирует, что после каждой вставки дерево будет сбалансированным, поскольку балансы узлов не проверяются (вместо балансов узлов в каждом узле хранится количество его потомков, чтобы правильно определить вероятность вставки в этот узел). Однако при достаточно большом количестве узлов рандомизированное дерево немногим уступает рассмотренным выше сбалансированным деревьям.
Сложность алгоритма вставки в рандомизированное дерево поиска по-прежнему логарифмическая, поскольку выполняется все то же передвижение по пути поиска сначала в прямом, а затем в обратном направлении.
#include
#include
#include
typedef int T_key; //тип ключа, может быть любым
typedef char T_data;//тип связанных данных, любой
struct item //структура элемента массива
{ T_key key; //ключ
T_data data; //связанные данные
};
struct node
{item data;
int n;
node *left, *right;
node(item x)
{data=x;left=right=NULL;}
};
typedef node* bst; //bst - binary seach tree
//малое правое вращение
void RightRotate(bst &root)
{ bst x;
x=root->left; root->left=x->right; x->right=root; root=x;
}
//малое левое вращение
void LeftRotate(bst &root)
{ bst x;
x=root->right; root->right=x->left; x->left=root; root=x;
}
// вставка в корень
void insert_root(bst &root, item x)
{ if (!root)// дерео пустое- терминальная ветвь
{ root=new node(x); root->n=1;
return;
}
if (x.keydata.key)
{ insert_root(root->left,x);
RightRotate(root); }
else
{ insert_root(root->right,x);
LeftRotate(root); }
}
// рекурсивная функция вставки
void insert_rec(bst &root, item x)
{ if (!root)// дерео пустое- терминальная ветвь
{ root=new node(x); root->n=1;
return;
}
if (rand()n+1))
{ insert_root(root,x);
return;
}
if (x.keydata.key) insert_rec(root->left,x);
else insert_rec(root->right,x);
root->n++;
return;
}
// формирование дерева из n случайных элементов
void randtree(bst &root, int n)
{ item x;
for (int i=0;i { x.key=rand()%1000;
x.data=rand()%26+65;
insert_rec(root,x);
}
}
5.6. Структуры данных, основанные на хеш-таблицах
Использование сбалансированных деревьев обеспечивает логарифмическую сложность при выполнении операций поиска, вставки и удаления элементов. Это неплохо, но нельзя ли добиться еще лучших результатов? Вспомним, что самый быстрый способ поиска данных  прямой доступ к элементам массива по их индексу, который всегда выполняется за константное (не зависящее от размеров массива данных) время. Возникает вопрос, можно ли выполнить поиск по ключу с константной сложностью?
В некоторых частных случаях такая задача легко решается. Например, если ключи поиска представляют собой неотрицательные целые значения в ограниченном диапазоне, то можно использовать ключ в качестве индекса элементов массива, в котором ведется поиск. Такие структуры называются таблицами прямого доступа [14]. Допустим, известно, что все ключи имеют целочисленные значения, не выходящие за пределы диапазона [0, 9999]. Тогда использование массива размером 10000 элементов обеспечит минимально возможное время выполнения всех основных операций: поиск, вставка и удаление элементов реализуются как прямой доступ по индексу к элементам этого массива.
Например, при продаже билетов в кино или на концерт схему зрительного зала можно представить в виде таблицы прямого доступа, в данном конкретном случае, двумерного массива, количество элементов которого равно размеру зрительного зала. Каждый элемент определяется двумя индексами (ряд и место) и может принимать одно из двух значений  «занято» или «свободно». Тогда продажа и возврат билетов выполняются как прямой доступ по индексам к элементам данного двумерного массива, которые одновременно являются и ключами поиска. Таблица хорошо поддается визуализации, поэтому с поиском свободных мест проблем не возникает. При хорошей наполняемости зрительного зала дополнительные расходы памяти на хранение незанятых мест будут минимальны.
Такие статические структуры на практике встречаются редко. Во многих реальных применениях диапазон возможных значений ключей достаточно широк и таблицы прямого доступа получаются очень разреженными, занимая при этом неоправданно большую область памяти. Таким образом, платой за высокую производительность является неэффективное использование памяти. В силу этого обстоятельства таблицы прямого доступа в чистом виде применяются редко.
Однако сама идея использования ключа в качестве индекса элемента массива заслуживает самого пристального внимания, поскольку на ее основе может возникнуть другая  преобразование значения ключа в индекс элемента массива с использованием какой-либо последовательности арифметических операций, возвращающей результат в виде целого числа в заданном ограниченном диапазоне. В этом случае расход памяти становится управляемым и может быть достигнут разумный компромисс между скоростью выполнения основных операций и размером используемой памяти.
Эта идея воплощена в одном распространенном методе реализации структур для поддержки поиска, который получил название хеширования (hashing). Математическая функция h(K), которая преобразует значений ключей K в индексы элементов массива, называется хеш-функцией. Сами индексы иначе называются хеш-адресами и находятся в диапазоне от 0 до M-1, где M  некоторое положительное целое число. Массив размером M, в котором ведется поиск, называется хеш-таблицей и обычно представляет собой массив записей (ключи и связанная информация или указатель на нее). В частном случае элементами хеш-таблицы могут быть просто значения ключей (числа или строки текста).
Например, пусть входная последовательность ключей имеет вид: 3 25 7 48 71. Если для организации быстрого поиска использовать таблицу прямого доступа, то она должна содержать не менее 71 элемента (значение наибольшего ключа), и из этих элементов заполненными окажутся только 5. Будем использовать простейшую хеш-функцию, применив к ключам операцию вычисления остатка от деления на размер хеш-таблицы (обозначим эту операцию K mod M). Поскольку входных данных немного, выберем M=7. Тогда все хеш-адреса будут находиться в диапазоне от 0 до 6, а хеш-таблица будет почти заполнена (см. табл. 5.). Заметим, что положение ключей в хеш-таблице не зависит от порядка их следования во входной последовательности
Таблица 5. Хеш-таблица для последовательности 3 25 7 48 71 при применении хеш-функции k mod 7
Хеш-адрес
0
1
2
3
4
5
6

Значение
7
71
пусто
3
25
пусто
48


Термин «хеширование» в литературе по программированию появился в 1967 году, хотя сам механизм был известен и ранее. Сама идея хеширования впервые была высказана Г.П. Ланом при создании внутреннего меморандума IBM в январе 1953 г. (т. е. хеширование возникло еще до появления языков высокого уровня). Глагол «hash» в английском языке означает «рубить, крошить, перемешивать», поэтому термин «хеш-таблица» в русском языке можно заменить термином «перемешанная таблица», который довольно точно соответствует сути дела. Академиком А.П. Ершовым был предложен удачный эквивалент термина «хеширование»  «расстановка» (эквивалент хеш-функции  функция расстановки). Однако русскоязычные термины используется реже, чем оригинальные английские.
Реализация алгоритмов поиска, основанных на хеш-таблицах, почти всегда представляет собой нетривиальную задачу. Для приведенного выше первого примера хеш-таблицы данные специально были подобраны таким образом, чтобы не заострять внимания на проблемах. Предположим, что нужно вставить в хеш-таблицу 5. еще одно значение ключа, на этот раз равное 8. Подсчитываем значение хеш-функции: 8 mod 7=1. Однако ячейка с хеш-адресом 1 уже занята ключом 71 и для ключа 8 требуется найти новое место. Такая ситуация иначе называется конфликтом или коллизией. В таблицах прямого доступа коллизий не может быть в принципе, если все ключи уникальны, при применении метода хеширования коллизии являются обычным явлением, такова плата за экономию памяти.
Причина возникновения коллизий имеет глубокие математические корни и состоит в том, что почти невозможно подобрать такую идеальную хеш-функцию, которая преобразует каждое значение ключа в уникальное значение хеш-адреса, соответствующее только этому ключу, и при этом обеспечит высокую степень заполнения хеш-таблицы. В [9] приводится так называемый «парадокс дней рождения», который состоит в том, что в компании из 23 человек вероятность совпадения хотя бы двух дней рождения больше, чем вероятность несовпадения (а в году 365 дней). Аналогично в большинстве реальных задач при вычислении хеш-функции вероятность совпадения хеш-адресов очень велика.
Сказанное вовсе не означает, что не нужно заниматься подбором хеш-функции для каждой конкретной задачи. Чем меньше коллизий, тем выше производительность. Поэтому различные способы построения хеш-функций будут внимательно проанализированы ниже. Однако наряду с подбором подходящей хеш-функции необходимо решить еще одну задачу  разрешение коллизий при преобразовании ключа в хеш-адрес, иначе говоря, подбор нового хеш-адреса взамен занятого. Для этого существуют различные способы, основные из которых также будут рассмотрены.
При удачном решении обеих перечисленных задач метод хеширования обеспечивает в среднем константное время выполнения основных операций (поиск, вчтавка и удаление элементов). Это рекордно короткое время, которое не может обеспечить ни один другой известный способ поиска. Правда, при хешировании нельзя полностью исключить наихудший случай, даже если вероятность его ничтожно мала. В самом худшем случае все ключи будут преобразованы в одно и то же значение хеш-адреса, тогда при любом способе разрешения коллизий время поиска будет как при самом медленном, последовательном поиске. В этом состоит отличие метода хеширования от более надежного способа поиска с помощью сбалансированных деревьев, которые гарантируют логарифмическую сложность поиска.
Задача и состоит в том, чтобы сделать вероятность худшего случая достаточно малой. Как мы уже выяснили, эта задача состоит из двух частей  удачный выбор хеш-функции и наиболее подходящего способа разрешения коллизий.
Сначала рассмотрим наиболее часто используемые хеш-функции.
5.6.2. Выбор хеш-функций и оценка их эффективности
Вопросы выбора хеш-функции очень подробно рассмотрены в [9,14]. Авторы отмечают, что хорошая хеш-функция должна удовлетворять двум требованиям:
ее вычисление должно выполняться очень быстро;
она должна минимизировать число коллизий. Желательно, чтобы хеш-функция не только сокращала число коллизий, но и не допускала скучивания ключей в отдельных частях таблицы, иными словами, как можно более равномерно распределяла ключи по всей хеш-таблице.
Теоретически невозможно определить одну идеальную хеш-функцию, подходящую для любых входных данных, поскольку выбор хеш-функции зависит как от типа входных данных (т. е. от диапазона их значений), так и от характера распределения данных внутри диапазона. Часто на практике можно построить несколько различных хеш-функций и экспериментально проверить, которая из них окажется более эффективной. Для этого нужно знать наиболее часто используемые способы построения хеш-функций.
Один из наиболее распространенных случаев представляют целочисленные значения ключей. Реально это могут быть не только целые числа, но любые значения, которые можно представить в виде двоичного числа, занимающего фиксированное число байт памяти (вспомним, что хеширование появилось раньше, чем языки высокого уровня). Рассмотрим основные методы построения хеш-функций для этого случая.
Модульное хеширование (метод деления)
В этом случае используется уже рассмотренная выше модульная хеш-функция, основанная на вычислении остатка от деления ключа K на размер хеш-таблицы M: h(K)= K mod M.
Надо тщательно выбирать константу M, ориентируясь на характер распределения данных. В [9, 14] показано, что выбор в качестве размера таблицы степени числа 2 (M=2n) оправдан только в том случае, если входные данные достаточно равномерно распределены внутри своего диапазона значений. Тогда вычисление хеш-адеса сведется всего-навсего к тому, чтобы взять n младших бит ключа. Аналогичная ситуация и со степенью числа 10, когда хеш адрес определяется несколькими младшими десятичными цифрами ключа. В большинстве случаев реальные данные носят неслучайный характер и лучший результат получается, когда хеш-адрес определяется полным значением ключа, а не его частью.
Показано [9], что в большинстве применений наилучший результат получается, если в качестве размера таблицы M взять простое число. Есть хорошо известные простые числа. Например, числа, равные 2t-1, являются простыми при t=2,3,5,7,13,19,31 (и ни при каких других t<31). Это простые числа Мерсенне. В общем случае подбор простого числа, наиболее близкого к нужному значению, является отдельной задачей.
Мультипликативный метод
Последовательность выполняемых операций при мультикативном методе описывается несколько сложнее, чем при модульном хешировнии, однако реализуются эти операции обычно эффективно и во многих случаях обеспечивают хорошее распределение хеш-адресов. Значение ключа K умножается на константу C, которая находится в интервале [0,1], затем от произведения K*C берётся дробная часть, умножается на размер таблицы М и усекается до целого значения..В данном методе M берется равной степени двойки (M=2n), поэтому умножение на M сводится к простому сдвигу вправо на n двоичных разрядов. Единственная медленная операция  умножение на константу C, однако во многих случаях умножение реализуется эффективней, чем деление на простое число при модульном методе. В качестве константы C Кнут рекомендует золотое сечение С=(sqrt(5) - 1)/2 = 0.6180339887499.
Метод середины квадрата
Значение ключа K возводится в квадрат и из полученного квадрата извлекается нескольких средних цифр. Так, при размере хеш-таблицы M=100 достаточно двух цифр (00-99), при M=1000 – трёх и т.д. Эксперименты показали, что такой способ хорошо работает, когда в ключах нет большого количества нолей слева или справа [Кнут].
Метод середины квадрата можно обобщить для случая, когда M уже не обязательно степень числа 10. Предположим, что ключи – целые числа из интервала [0, N]. Найдём такое целое число C, что MC2 примерно равно N2. Тогда используется функция h(K)=[K2/C] mod M, где [] означает целую часть.
Например, если ключи находятся в интервале от 0 до 1000 (N=1000), размер хеш-таблицы M=8, то можно выбрать C=354 (8*3542
·10002). Тогда, например, h(456)=[4562/354] mod 8 = 587 mod 8 = 3.
Хеш-функции для строк переменной длины
Очень часто ключами являются строки текста, содержащие довольно большое количество символов, часто размер строк бывает переменным. Для применения хеш-функции к строке ее символы обычно группируются в блоки с фиксированным количеством символов, например, 1, 2 или 4 символа. Каждый такой блок можно рассматривать, как двоичное число, к которому можно применить все перечисленные выше методы. Самую простую хеш-функцию можно построить на основе простого преобразования кода первого символа, нечто похожее применяется в словарях, телефонных справочниках (печатных, а не электронных), в которых все данные группируются по первой букве слова. Однако при организации электронного поиска такая функция приведет к огромному количеству коллизий.
Гораздо более равномерное распределение можно получить, включив в процесс вычисления все символы или значительное количество первых символов. Обычно коды этих символов суммируются, после чего к ним может быть применено модульное или мультипликативное хеширование. Неплохие результаты ддя текстов дает в этом случае и метод середины квадрата.
Рассмотрим для примера одну из самых простых хеш-функций для строки S (M  размер хеш-таблицы):
int h(char *S, int M)
{ int i, sum=0;
for (i=1; i<= 10; i++) sum = sum + S[i];
return sum%M;
}
В данной функции суммируются коды первых 10 символов и берётся остаток от деления этой суммы на размер хеш-таблицы. Хотелось бы знать, насколько разными получаются значения хеш-адресов при использовании этой функции.
Возьмем 100 следующих строк: “A0”, “A1”, “A2”,,”A99”. Легко проверить, что при обработке этих 100 строк будет получено всего 28 различных хеш-адресов из 100 возможных. Это означает, что на таких специфических входных данных рассмотренная функция работает не очень эффективно, хотя для случайно взятых текстов она вполне приемлема. В [14] можно найти еще несколько вариантов более сложных хеш-функций для строк. Ниже будет разбираться пример реализации хеш-таблицы, в котором используется хеш-функция, взятая из [16], которая, по мнению авторов, гораздо более равномерно распределяет данные из реальных текстовых файлов по хеш-таблице, чем способ простого суммирования кодов символов.
Рассмотрим теперь основные способы разрешения коллизий при хешировании. Сначала рассмотрим случай, когда размер исходных данных можно предсказать заранее (хотя бы приблизительно), а значит, можно говорить о предварительном выделении памяти.
5.6.2. Метод цепочек
Наиболее простой и естественный способ разрешения коллизий состоит в том, что все элементы с одинаковыми хеш-адресами объединяются в связный список (хеш-цепочку). Тогда хеш-таблица представляет собой массив из N указателей на хеш-цепочки. Такая структура показана на рис. 521.
13 EMBED PBrush 1415
Рис.5.21. Хеш –таблица при использовании метода цепочек
В этом случае в наихудшем случае время поиска пропорционально размеру хеш-цепочки (списка), поскольку нужную хеш-цепочку можно найти с константной сложностью, а для того, чтобы найти элемент в цепочке, придется двигаться по ней последовательно. Операции вставки и удаления элементов выполняются за константное время как в обычном связном списке, обычно их совмещают с операцией поиска, чтобы лишний раз не вычислять хеш-функцию.
Если исходное множество данных состоит из K элементов, а размер массива указателей N, то средняя длина списков будет K/N элементов. Если можно оценить среднее значение K, то можно выбрать N так, чтобы в каждом списке было всего несколько элементов. Тогда время выполнения всех операций будет малой постоянной величиной. Если размер исходного множества непредсказуем или динамично изменяется, данный метод применять не рекомендуется.
Реализация хеш-таблицы с использованием метода цепочек довольно проста. Сами цепочки обычно реализуются в виде однонаправленных связных списков, для хранения указателей на цепочки используется обычный массив. Следуя принятым в данной главе соглашениям, можно записать следующие определения структур данных:
typedef int T_key; //тип ключа, может быть любым
typedef char T_data;//тип связанных данных, любой
struct item //структура элемента данных – ключ и связанные данные
{ T_key key; //ключ
T_data data; //связанные данные
};
struct h_item //структура элемента хеш-цепочки
{ item data; // элемент данных
h_item *next; //ссылка на следующий элемент
}
h_item *h_table[размер массива указателей] //массив указателей на хеш-цепочки
Пример реализации хеш-таблицы для решения конкретной задачи будет приведен ниже.
5.6.3. Хеширование с открытой адресацией
Если в памяти имеется непрерывная область достаточных размеров, то в этом случае можно вообще отказаться от ссылок при реализации хеш-таблицы. Такой способ реализация хеш-таблицы называется хешированием с открытой адресацией [9, 14]. В [3] такая хеш-таблица называется закрытой, очевидно, имеется в виду, что она закрыта для расширения. Этот метод накладывает еще более жесткие ограничения на размер входных данных, чем метод цепочек, но для случая статических входных данных он вполне годится.
Коллизии в этом случае разрешаются следующим образом. В случае, если вычисленный по ключу хеш-адрес оказывается занятым, каким-либо способом находится другая незанятая позиция, куда и помещается новый элемент. Если все позиции заняты, то элемент вставить нельзя (место кончилось). Этот процесс поиска подходящей позиции называется исследованием хеш-таблицы [14], а количество позиций, просмотренных до того, как найдена подходящая позиция, называют количеством проб.
Наиболее простым способом разрешения коллизий является линейное зондирование. При линейном зондировании hi(x)=(h(x)+i) mod N. Предположим, N=8, ключи a,b,c,d имеют хеш-значения h(a)=3, h(b)=0, h(c)=4, h(d)=3. Например, если мы хотим вставить элемент d, а сегмент 3 уже занят, то мы проверим 4-й сегмент, если и он занят, то 5-й, 6-й, 7-й, 0-й, 1-й, 2-й.
Пусть сначала вся хеш-таблица пуста. Поместим в неё последовательно элементы a, b, c, d. Элемент a попадёт в 3-й сегмент, b – в 0-й, c – в 4-й. При вставке элемента d оказывается, что 3-й элемент уже занят. Проверяем 4-й элемент, но он тоже занят. Пятый элемент свободен – туда и помещаем d.
0
B

1
пусто

2
пусто

3
A

4
C

5
D

6
пусто

7
пусто

Посмотрим, как выполняется поиск элемента x. Будем сначала считать, что элементы из хеш-таблицы никогда не удаляются. Тогда при поиске элемента x необходимо просмотреть всю последовательность, начиная с вычисленного хеш-адреса, пока не будет найден x, не встретится пустая позиция, или не будут просмотрены все позиции последовательности. Легко объяснить, почему при достижении пустого элемента поиск можно прекратить – ведь при вставке элемент вставляется в первый пустой сегмент, следовательно, далее элемент находиться не может.
Но если элементы из хеш-таблицы всё-таки удаляются, то при достижении пустого элемента мы уже не можем прекратить поиск, так как возможно, что искомый элемент находится в одной из следующих позиций последовательности. Для повышения скорости поиска иногда используется следующий приём – при удалении элемента его позиция помечается специальным образом, так чтобы ее можно было отличить от изначально пустой позиции. При выполнении вставки такие позиции рассматриваются как свободные.
Вернёмся к вышеприведённому примеру. Пусть нам нужно проверить, содержится ли в множестве элемент e, где h(e)=4. Проверяем сегменты 4,5 и 6. Сегмент 6 пустой, следовательно, элемента e в множестве нет.
Предположим теперь, что мы удалили элемент c и проверяем, содержится ли в множестве элемент d:
0
B

1
пусто

2
пусто

3
A

4
удален

5
D

6
пусто

7
пусто

Мы проверяем элемент 3, затем переходим к элементу 4. Он помечен как удаленный, поэтому не останавливаемся в нём и переходим к элементу 5, где и находим D.
Рассмотренное нами линейное зондирование – далеко не самый лучший способ разрешения коллизий. Как только несколько последовательных элементов будут заполнены (образуя группу), любой новый элемент при попытке вставки в эти позиции будет вставлен в конец группы, увеличивая её длину. Отсюда следует, что при таком расположении элементов увеличивается время выполнения операций поиска, вставки, удаления элемента.
Имеются методы организации хеширования с открытой адресацией, обеспечивающие в среднем меньшее количество проб, с ними можно познакомиться, например, в [10, 14].
5.6.4. Пример решения задачи поиска с использованием хеш-таблицы
Для примера рассмотрим следующую задачу. Необходимо реализовать быстрый поиск слов в текстовом файле. Для упрощения реализации будем считать, что достаточно определить, встречается ли заданное слово в файле и если встречается, то сколько раз. Разумеется, можно расширить такую постановку задачи и определять номера позиций (строк, страниц), в которых встречается заданное слово, при этом поиск выполнять по коллекции текстовых файлов, можно искать сочетания нескольких слов и т.д. Для демонстрации идеи использования хеш-таблицы такие детали несущественны. Важно другое  в нашей задаче разрешено потратить какое-то начальное время перед поиском на предварительное построение любой подходящей структуры в оперативной памяти (индекса), если поиск в индексе будет выполняться существенно быстрее, чем в исходном файле.
В данном случае весьма подходящей структурой индекса может оказаться не только дерево поиска, хотя этот вариант также может рассматриваться. Однако при удачном выборе хеш-функции индекс на основе хеш-таблицы может обеспечить наименьшее время поиска в среднем на обычных текстовых файлах, содержащих, например, текст на естественном языке или исходные тексты программ на каком-либо языке программирования.
В примере для вычисления хеш-значения каждого слова был использован алгоритм, подробно разобранный в [16]. Его суть в том, что вместо простого вычисления суммы кодов символов слова используется следующая рекуррентная формула:
hi+1=k*hi+si , где i=0,1, ,n-1 (n – длина слова). При этом h0=0.
Здесь si   очередной символ слова, k специально подобранный множитель, его рекомендуемые значения 31 или 37 (мы взяли 31). За счет умножения частичной суммы hi на этот множитель обеспечивается более равномерное распределение слов в хеш-таблице, чем при использовании многих других хеш-функций [16].
Для реализации хеш-таблицы был выбран метод цепочек, в качестве которых используются обычные однонаправленные связанные списки. Элементы хеш-таблицы в качестве информационной части содержат значение слова (ключ) и количество повторений этого слова в файле (связанные данные). Размер массива указателей (N=2013) выбирался, исходя из того, что общее количество различных слов в реальных текстах вряд ли превысит 10 000, выбранная хеш-функция обеспечивает хорошее равномерное их перемешивание, поэтому цепочки не должны быть слишком длинными.
#include
#include
#include

#define N 2013 //размер массива указателей – простое число

//небольшая служебная функция для извлечения слов из файла
int getword(ifstream *, char *, char *,int *);
// описание всех необходимых структур данных
struct item // элемент данных
{ char word[40]; // слово
int count; //число повторений в файле
};
struct h_item // элемент хеш-цепочки
{ item data;
h_item *next;
};
h_item *a[N]; // массив указателей на цепочки

unsigned int hashnumber(char *s)// вычисление хеш-значения
{ const int k=31;
unsigned int h=0; char *a;

for (a = (unsigned char *) s; *a!= '\0'; a++)
{ h = k*h+*a;
h = h % N;
}
return h;
}

bool insert(char *w)//вставка нового значения в хеш-таблицу
{ unsigned int n=hashnumber(w);
h_item *i=a[n];
while (i)
{ if (strcmp(i->data.word,w)==0)
{ i->data.count++;
break;//нашли такое слово
}
i=i->next;
}
if (i==NULL)//слова нет, добавляем новый элемент
{i=new h_item;
strcpy(i->data.word,w);
i->data.count=1;
i->next=a[n];
a[n]=i;
return false;
}
return true;
}
//загрузка слов текстового файла в хеш-таблицу
bool create_hash(char *name)
{ ifstream f(name);
if (f.bad()) {return false;}
char s[100]="", w[40]; int first=1;
for (int n=0; n while (!getword(&f,s,w,&first)) insert(w);
return true;
}
//поиск слова в хеш-таблице, возвращает количество слов
//если слово не найдено, возвращает нуль
int seach(char *w)
{ unsigned int n=hashnumber(w);
h_item *i=a[n];
while (i)
{ if (strcmp(i->data.word,w)==0)
return i->data.count;
i=i->next;
}
return 0;
}
main()
{ // небольшая демонстрационная программа
char name[50]; // вначале строится хеш-таблица
cout<<"Vvedite imya faila "; cin.getline(name,50);
if (!create_hash(name))
{cout<< "not found"; cin.get();return 1;
}
cout<<"хеш-таблица построена"; cin.get();
char w[40];//выполняется поиск слов до ввода пустой строки
for(;;)
{cout<<"7";cin.getline(w,40); if (strlen(w)==0) break;
int i=seach(w);
if (i==0) cout<<"слово не найдено"< else cout<<"слово встречается" < }
cin.get(); return 0;
}

int getword(ifstream *f,char *s, char *w, int *first)
{//служебная функция для извлечения слов из файла
char *w1=NULL;
do
{ if (*first)
{ w1=strtok(s," .,;()!?");
*first=0;
}
else w1=strtok(NULL," .,;()!?");
if (w1==NULL)
if (f->getline(s,80)) *first=1;
else return 1;
}
while (w1==NULL); strcpy(w,w1);
return 0;
}

В дополнение к рассмотренным методам хеширования в следующем разделе будет рассмотрен метод расширяемого хеширования применительно к файлам данных, расположенным во внешней памяти.
13 TOC \o "1-3" \h \z \u 1413 LINK \l "_Toc170643919" 14Введение 13 PAGEREF _Toc170643919 \h 1431515
13 LINK \l "_Toc170643920" 141. Основные понятия и определения 13 PAGEREF _Toc170643920 \h 1451515
13 LINK \l "_Toc170643921" 141.1. Типы данных 13 PAGEREF _Toc170643921 \h 1451515
13 LINK \l "_Toc170643922" 141.1.1. Понятие типа данных 13 PAGEREF _Toc170643922 \h 1451515
13 LINK \l "_Toc170643923" 141.2.2. Внутреннее представление базовых типов в оперативной памяти 13 PAGEREF _Toc170643923 \h 1471515
13 LINK \l "_Toc170643924" 141.2.2. Внутреннее представление структурированных типов данных 13 PAGEREF _Toc170643924 \h 1491515
13 LINK \l "_Toc170643925" 141.2.3. Статическое и динамическое выделение памяти 13 PAGEREF _Toc170643925 \h 14101515
13 LINK \l "_Toc170643926" 141.2. Абстрактные типы данных (АТД) 13 PAGEREF _Toc170643926 \h 14111515
13 LINK \l "_Toc170643927" 141.2.1. Понятие АТД 13 PAGEREF _Toc170643927 \h 14111515
13 LINK \l "_Toc170643928" 141.2.2. Спецификация и реализация АТД 13 PAGEREF _Toc170643928 \h 14141515
13 LINK \l "_Toc170643929" 141.3. Структуры данных 13 PAGEREF _Toc170643929 \h 14161515
13 LINK \l "_Toc170643930" 141.3.1. Понятие структуры данных 13 PAGEREF _Toc170643930 \h 14161515
13 LINK \l "_Toc170643931" 141.3.2. Структуры хранения   непрерывная и ссылочная 13 PAGEREF _Toc170643931 \h 14171515
13 LINK \l "_Toc170643932" 141.4.3. Классификация структур данных 13 PAGEREF _Toc170643932 \h 14201515
13 LINK \l "_Toc170643933" 141.4. Алгоритмы 13 PAGEREF _Toc170643933 \h 14Ошибка! Закладка не определена.1515
13 LINK \l "_Toc170643934" 141.4.1. Понятие алгоритма 13 PAGEREF _Toc170643934 \h 14211515
13 LINK \l "_Toc170643935" 141.4.2. Способы записи алгоритмов. 13 PAGEREF _Toc170643935 \h 14Ошибка! Закладка не определена.1515
13 LINK \l "_Toc170643936" 141.4.3. Введение в анализ алгоритмов 13 PAGEREF _Toc170643936 \h 14221515
13 LINK \l "_Toc170643937" 141.4.3. Введение в рекурсию 13 PAGEREF _Toc170643937 \h 14331515
13 LINK \l "_Toc170643938" 141.5. Первые примеры 13 PAGEREF _Toc170643938 \h 14361515
13 LINK \l "_Toc170643939" 141.5.1. Введение в «длинную» арифметику 13 PAGEREF _Toc170643939 \h 14361515
13 LINK \l "_Toc170643940" 141.5.2. Рекурсия 13 PAGEREF _Toc170643940 \h 14371515
13 LINK \l "_Toc170643941" 141.5.3. Поразрядные операции. Реализация АТД «Множество» 13 PAGEREF _Toc170643941 \h 14401515
13 LINK \l "_Toc170643942" 142. Линейные структуры данных 13 PAGEREF _Toc170643942 \h 14441515
13 LINK \l "_Toc170643943" 142.1. АТД "Стек", "Очередь", "Дек" 13 PAGEREF _Toc170643943 \h 14451515
13 LINK \l "_Toc170643945" 142.2. Реализация стеков 13 PAGEREF _Toc170643945 \h 14501515
13 LINK \l "_Toc170643946" 142.2.1. Непрерывная реализация стека с помощью массива 13 PAGEREF _Toc170643946 \h 14501515
13 LINK \l "_Toc170643947" 142.2.2. Ссылочная реализация стека в динамической памяти 13 PAGEREF _Toc170643947 \h 14531515
13 LINK \l "_Toc170643948" 142.2.3. Примеры программ с использованием стеков 13 PAGEREF _Toc170643948 \h 14561515
13 LINK \l "_Toc170643949" 142.3. Реализация очередей 13 PAGEREF _Toc170643949 \h 14581515
13 LINK \l "_Toc170643950" 142.3.2. Непрерывная реализация очереди с помощью массива 13 PAGEREF _Toc170643950 \h 14581515
13 LINK \l "_Toc170643951" 142.3.2. Ссылочная реализация очереди в динамической памяти 13 PAGEREF _Toc170643951 \h 14621515
13 LINK \l "_Toc170643952" 142.3.3. Ссылочная реализация очереди с помощью циклического списка 13 PAGEREF _Toc170643952 \h 14641515
13 LINK \l "_Toc170643953" 142.3.4. Очереди с приоритетами 13 PAGEREF _Toc170643953 \h 14651515
13 LINK \l "_Toc170643954" 142.3.5. Пример программы с использованием очереди 13 PAGEREF _Toc170643954 \h 14661515
13 LINK \l "_Toc170643955" 142.4. Списки как абстрактные типы данных 13 PAGEREF _Toc170643955 \h 14681515
13 LINK \l "_Toc170643956" 142.4.1. Модель списка с выделенным текущим элементом 13 PAGEREF _Toc170643956 \h 14681515
13 LINK \l "_Toc170643957" 142.4.2. Однонаправленный список (список Л1) 13 PAGEREF _Toc170643957 \h 14701515
13 LINK \l "_Toc170643958" 142.4.3. Двунаправленный список (список Л2) 13 PAGEREF _Toc170643958 \h 14701515
13 LINK \l "_Toc170643959" 142.4.4. Циклический (кольцевой) список 13 PAGEREF _Toc170643959 \h 14711515
13 LINK \l "_Toc170643960" 142.5. Реализация списков с выделенным текущим элементом 13 PAGEREF _Toc170643960 \h 14721515
13 LINK \l "_Toc170643961" 142.5.1. Однонаправленные списки 13 PAGEREF _Toc170643961 \h 14731515
13 LINK \l "_Toc170643962" 142.5.2. Двусвязные списки 13 PAGEREF _Toc170643962 \h 14811515
13 LINK \l "_Toc170643963" 142.5.3. Кольцевые списки 13 PAGEREF _Toc170643963 \h 14831515
13 LINK \l "_Toc170643964" 142.5.4. Примеры программ, использующих списки 13 PAGEREF _Toc170643964 \h 14831515
13 LINK \l "_Toc170643965" 142.6. Рекурсивная обработка линейных списков 13 PAGEREF _Toc170643965 \h 14851515
13 LINK \l "_Toc170643966" 142.6.1. Модель списка при рекурсивном подходе 13 PAGEREF _Toc170643966 \h 14851515
13 LINK \l "_Toc170643967" 142.6.2. Реализация линейного списка при рекурсивном подходе 13 PAGEREF _Toc170643967 \h 14881515
13 LINK \l "_Toc170643968" 143. Иерархические структуры данных 13 PAGEREF _Toc170643968 \h 14921515
13 LINK \l "_Toc170643969" 143.1. Иерархические списки 13 PAGEREF _Toc170643969 \h 14921515
13 LINK \l "_Toc170643970" 143.1.1 Иерархические списки как АТД 13 PAGEREF _Toc170643970 \h 14921515
13 LINK \l "_Toc170643971" 143.1.2. Реализация иерархических списков 13 PAGEREF _Toc170643971 \h 14971515
13 LINK \l "_Toc170643972" 143.2. Деревья и леса 13 PAGEREF _Toc170643972 \h 141001515
13 LINK \l "_Toc170643973" 143.2.1. Определения 13 PAGEREF _Toc170643973 \h 141011515
13 LINK \l "_Toc170643974" 143.2. Способы представления деревьев 13 PAGEREF _Toc170643974 \h 141021515
13 LINK \l "_Toc170643975" 143.2.3. Терминология деревьев 13 PAGEREF _Toc170643975 \h 141051515
13 LINK \l "_Toc170643976" 143.2.4. Упорядоченные деревья и леса. Связь с иерархическими списками 13 PAGEREF _Toc170643976 \h 141061515
13 LINK \l "_Toc170643977" 143.3. Бинарные деревья 13 PAGEREF _Toc170643977 \h 141071515
13 LINK \l "_Toc170643978" 143.3.1. Определение. Представления бинарных деревьев 13 PAGEREF _Toc170643978 \h 141071515
13 LINK \l "_Toc170643979" 143.3.2. Математические свойства бинарных деревьев 13 PAGEREF _Toc170643979 \h 141091515
13 LINK \l "_Toc170643980" 143.4. Соответствие между упорядоченным лесом и бинарным деревом 13 PAGEREF _Toc170643980 \h 141121515
13 LINK \l "_Toc170643981" 143.5. Бинарные деревья как АТД 13 PAGEREF _Toc170643981 \h 14Ошибка! Закладка не определена.1515
13 LINK \l "_Toc170643982" 143.6. Ссылочная реализация бинарных деревьев 13 PAGEREF _Toc170643982 \h 141181515
13 LINK \l "_Toc170643983" 143.6.1. Ссылочная реализация бинарного дерева на основе указателей 13 PAGEREF _Toc170643983 \h 141201515
13 LINK \l "_Toc170643984" 143.6.2. Ссылочная реализация на основе массива 13 PAGEREF _Toc170643984 \h 141211515
13 LINK \l "_Toc170643985" 143.6.3. Пример  построение дерева турнира 13 PAGEREF _Toc170643985 \h 141241515
13 LINK \l "_Toc170643986" 143.7. Обходы бинарных деревьев и леса 13 PAGEREF _Toc170643986 \h 141261515
13 LINK \l "_Toc170643987" 143.7.1. Понятие обхода. Виды обходов 13 PAGEREF _Toc170643987 \h 141261515
13 LINK \l "_Toc170643988" 143.7.2. Рекурсивные функции обхода бинарных деревьев 13 PAGEREF _Toc170643988 \h 141281515
13 LINK \l "_Toc170643989" 143.7.3. Нерекурсивные функции обхода бинарных деревьев 13 PAGEREF _Toc170643989 \h 141311515
13 LINK \l "_Toc170643990" 143.7.4. Обходы леса 13 PAGEREF _Toc170643990 \h 141361515
13 LINK \l "_Toc170643991" 143.7.5. Прошитые деревья 13 PAGEREF _Toc170643991 \h 141371515
13 LINK \l "_Toc170643992" 143.8. Применения деревьев 13 PAGEREF _Toc170643992 \h 141391515
13 LINK \l "_Toc170643993" 143.8.1. Дерево-формула 13 PAGEREF _Toc170643993 \h 14Ошибка! Закладка не определена.1515
13 LINK \l "_Toc170643994" 143.8.2. Задача сжатия информации. Коды Хаффмана 13 PAGEREF _Toc170643994 \h 141401515
13 LINK \l "_Toc170643995" 144. Сортировка и родственные задачи 13 PAGEREF _Toc170643995 \h 141491515
13 LINK \l "_Toc170643996" 144.1. Общие сведения 13 PAGEREF _Toc170643996 \h 141491515
13 LINK \l "_Toc170643997" 144.1.1. Постановка задачи 13 PAGEREF _Toc170643997 \h 141491515
13 LINK \l "_Toc170643998" 144.1.2. Характеристики и классификация алгоритмов сортировки 13 PAGEREF _Toc170643998 \h 141501515
13 LINK \l "_Toc170643999" 144.2. Простые методы сортировки 13 PAGEREF _Toc170643999 \h 141531515
13 LINK \l "_Toc170644000" 144.2.1. Сортировка выбором 13 PAGEREF _Toc170644000 \h 141531515
13 LINK \l "_Toc170644001" 144.2.2. Сортировка алгоритмом пузырька 13 PAGEREF _Toc170644001 \h 141541515
13 LINK \l "_Toc170644002" 144.2.3.Сортировка простыми вставками. 13 PAGEREF _Toc170644002 \h 141561515
13 LINK \l "_Toc170644003" 144.3. Быстрые способы сортировки, основанные на сравнении 13 PAGEREF _Toc170644003 \h 141571515
13 LINK \l "_Toc170644004" 144.3.1. Сортировка упорядоченным бинарным деревом 13 PAGEREF _Toc170644004 \h 14Ошибка! Закладка не определена.1515
13 LINK \l "_Toc170644005" 14Анализ алгоритма сортировки бинарным деревом поиска 13 PAGEREF _Toc170644005 \h 14Ошибка! Закладка не определена.1515
13 LINK \l "_Toc170644006" 144.3.2. Пирамидальная сортировка 13 PAGEREF _Toc170644006 \h 141581515
13 LINK \l "_Toc170644007" 14Первая фаза сортировки пирамидой 13 PAGEREF _Toc170644007 \h 141591515
13 LINK \l "_Toc170644008" 14Вторая фаза сортировки пирамидой 13 PAGEREF _Toc170644008 \h 141611515
13 LINK \l "_Toc170644009" 14Анализ алгоритма сортировки пирамидой 13 PAGEREF _Toc170644009 \h 141621515
13 LINK \l "_Toc170644010" 14Реализация очереди с приоритетами на базе пирамиды 13 PAGEREF _Toc170644010 \h 141631515
13 LINK \l "_Toc170644011" 144.3.2. Сортировка слиянием 13 PAGEREF _Toc170644011 \h 141641515
13 LINK \l "_Toc170644012" 14Анализ алгоритма сортировки слиянием 13 PAGEREF _Toc170644012 \h 141661515
13 LINK \l "_Toc170644013" 144.3.3. Быстрая сортировка Хоара 13 PAGEREF _Toc170644013 \h 141661515
13 LINK \l "_Toc170644014" 14Анализ алгоритма быстрой сортировки 13 PAGEREF _Toc170644014 \h 141691515
13 LINK \l "_Toc170644015" 144.3.4. Сортировка Шелла 13 PAGEREF _Toc170644015 \h 141701515
13 LINK \l "_Toc170644016" 144.3.5. Нижняя оценка для алгоритмов сортировки, основанных на сравнениях 13 PAGEREF _Toc170644016 \h 141731515
13 LINK \l "_Toc170644017" 144.4. Сортировка за линейное время 13 PAGEREF _Toc170644017 \h 141741515
13 LINK \l "_Toc170644018" 144.4.1. Сортировка подсчетом 13 PAGEREF _Toc170644018 \h 141751515
13 LINK \l "_Toc170644019" 144.4.2. Распределяющая сортировка от младшего разряда к старшему 13 PAGEREF _Toc170644019 \h 141771515
13 LINK \l "_Toc170644020" 144.4.3. Распределяющая сортировка от старшего разряда к младшему 13 PAGEREF _Toc170644020 \h 141781515
13 LINK \l "_Toc170644021" 145. Структуры и алгоритмы для поиска данных 13 PAGEREF _Toc170644021 \h 141801515
13 LINK \l "_Toc170644022" 145.1. Общие сведения 13 PAGEREF _Toc170644022 \h 141801515
13 LINK \l "_Toc170644023" 145.1.1. Постановка задачи поиска 13 PAGEREF _Toc170644023 \h 141801515
13 LINK \l "_Toc170644024" 145.1.2. Структуры для поддержки поиска 13 PAGEREF _Toc170644024 \h 141821515
13 LINK \l "_Toc170644025" 145.1.3. Соглашения по программному интерфейсу 13 PAGEREF _Toc170644025 \h 141831515
13 LINK \l "_Toc170644026" 145.2. Последовательный (линейный) поиск 13 PAGEREF _Toc170644026 \h 141841515
13 LINK \l "_Toc170644027" 145.3. Бинарный поиск в упорядоченном массиве 13 PAGEREF _Toc170644027 \h 141861515
13 LINK \l "_Toc170644028" 145.4. Бинарные деревья поиска 13 PAGEREF _Toc170644028 \h 141881515
13 LINK \l "_Toc170644029" 145.4.1. Анализ алгоритмов поиска, вставки и удаления 13 PAGEREF _Toc170644029 \h 141891515
13 LINK \l "_Toc170644030" 145.4.3. Реализация бинарного дерева поиска 13 PAGEREF _Toc170644030 \h 141931515
13 LINK \l "_Toc170644031" 145.5. Сбалансированные деревья 13 PAGEREF _Toc170644031 \h 141981515
13 LINK \l "_Toc170644032" 145.5.1. АВЛ-деревья 13 PAGEREF _Toc170644032 \h 141991515
13 LINK \l "_Toc170644033" 145.5.3. Рандомизированные деревья поиска 13 PAGEREF _Toc170644033 \h 142181515
13 LINK \l "_Toc170644034" 145.6. Структуры данных, основанные на хеш-таблицах 13 PAGEREF _Toc170644034 \h 142221515
13 LINK \l "_Toc170644035" 145.6.2. Выбор хеш-функций и оценка их эффективности 13 PAGEREF _Toc170644035 \h 142261515
13 LINK \l "_Toc170644036" 14Модульное хеширование (метод деления) 13 PAGEREF _Toc170644036 \h 142261515
13 LINK \l "_Toc170644037" 14Мультипликативный метод 13 PAGEREF _Toc170644037 \h 142271515
13 LINK \l "_Toc170644038" 14Метод середины квадрата 13 PAGEREF _Toc170644038 \h 142271515
13 LINK \l "_Toc170644039" 14Хеш-функции для строк переменной длины 13 PAGEREF _Toc170644039 \h 142281515
13 LINK \l "_Toc170644040" 145.6.2. Метод цепочек 13 PAGEREF _Toc170644040 \h 142291515
13 LINK \l "_Toc170644041" 145.6.3. Хеширование с открытой адресацией 13 PAGEREF _Toc170644041 \h 142311515
13 LINK \l "_Toc170644042" 145.6.4. Пример решения задачи поиска с использованием хеш-таблицы 13 PAGEREF _Toc170644042 \h 142331515
15
13PAGE 15


13PAGE 1411015



Проектирование

АТД

АТД

Описание данных

Набор подпрограмм

Класс

Реализация

Структурный подход

Объектно-ориентированный подход

Содержательная часть
(данные)

Указующая часть
(связи)

“Хвост” списка

“Голова” списка

6

5

4

7

8

3

5

5

6

5

8

7

4

3



1



4










4

3

8

7

6

8

12



5

5

3

8

7

5

5

6

5

4

(































































































8

16

5

7

10

12

18

19

16

18

8

10

12

19

2

5

7

7

16

5

-

2

5

8

-

7

8

5

-

2

5

8

-

7

8

7

-

12

-

7

16

5

-

2

5

8

-

7

8

16

-

12

16

19

-

18

19

18

-

5

8

2

7

-

5

8

18

-

16

-

12

16

19

-

18

19

12

-

5

8

2

5

8

12

18

16

-

12

16

19

-

18

19




Приложенные файлы

  • doc 26762077
    Размер файла: 7 MB Загрузок: 2

Добавить комментарий