Министерство образования республики Беларусь Белорусский национальный технический университет А. В. Романов Структуры и алгоритмы обработки данных Учебное пособие по дисциплине «Структуры и алгоритмы обработки данных» для специальностей «Программное обеспечение информационных технологий» и «Информационные системы и технологии» Минск 2010 2 ББК 32.973.2 УДК 681.3.06(075) Романов Александр Васильевич Структуры и алгоритмы обработки данных: Учеб. пособие. – Мн: БНТУ, 2010. – 151 с.: ил. В пособии рассматриваются вопросы организации структур данных, применяемых программистами для реализации современных алгоритмов обработки данных. Рекомендуется проводить анализ эффективности алгоритмов на основе оценки времени их выполнения для худшего и среднего случаев. Некоторые алгоритмы иллюстрируются программными кодами для системы разработки приложений Delphi 3 СОДЕРЖАНИЕ 1 ВВЕДЕНИЕ .............................................................................................................. 6 1.1 Данные ................................................................................................................... 6 1.2 Логическая и физическая структуры данных .................................................... 7 1.3 Классификация структур данных ....................................................................... 9 1.4 Основные операции над структурами данных .................................................. 10 1.5 Алгоритм и примеры задач, решаемых с помощью алгоритмов ..................... 12 2 АДРЕСАЦИЯ И РАСПРЕДЕЛЕНИЕ ПАМЯТИ .................................................. 14 2.1 Адресные пространства и модели оперативной памяти ................................... 14 2.2 Формирование физического адреса в реальном режиме ................................. 15 2.3 Особенности адресации в защищенном режиме ............................................... 16 2.4 Кэширование ......................................................................................................... 17 3 АНАЛИЗ АЛГОРИТМОВ ...................................................................................... 19 3.1 Быстродействие – основной показатель эффективности алгоритма ............... 19 3.2 Подсчет числа простейших операций ................................................................ 21 3.3 О-нотация .............................................................................................................. 22 3.4 Лучший, средний и худший случаи .................................................................... 23 3.5 Алгоритмы и платформы ..................................................................................... 24 3.5.1 Виртуализация памяти ...................................................................................... 24 3.5.2 Использование кэша .......................................................................................... 25 3.5.3 Выравнивание данных ...................................................................................... 26 3.6 Рандомизированные алгоритмы .......................................................................... 26 4 ЗАПИСИ ................................................................................................................... 27 4.1 Общая характеристика записей и способы описания в Delphi ........................ 27 4.2 Многоуровневые записи ...................................................................................... 29 4.3 Выравнивание и упакованные записи ................................................................ 29 4.4 Записи с вариантной частью ................................................................................ 30 5 МАССИВЫ .............................................................................................................. 33 5.1 Общая характеристика массивов ........................................................................ 33 5.2 Статические (стандартные) массивы .................................................................. 34 5.2.1 Вектор ................................................................................................................. 34 5.2.2 Многомерные статические массивы ................................................................ 35 5.2.3 Свойства статических массивов ....................................................................... 37 5.3 Открытые массивы ............................................................................................... 37 5.4 Динамические массивы ........................................................................................ 38 5.4.1 Динамические векторы ..................................................................................... 38 5.4.2 Многомерные динамические массивы ............................................................ 40 5.5 Массивы типа Variant ........................................................................................... 42 5.6 Вставка и удаление в массиве ............................................................................. 43 6 СВЯЗНЫЕ СПИСКИ И АЛГОРИТМЫ ИХ ОБРАБОТКИ ................................. 46 6.1 Списки и их разновидности ................................................................................. 46 6.2 Односвязный список ............................................................................................ 47 6.2.1 Общая характеристика односвязного списка ................................................. 47 6.2.2 Структура элемента односвязного списка ...................................................... 49 6.2.3 Формирование односвязного списка ............................................................... 49 6.2.4 Просмотр односвязного списка ........................................................................ 50 6.2.5 Вставка элемента в односвязный список ........................................................ 51 6.2.6 Удаление элемента из односвязного списка ................................................... 53 6.3 Линейный двухсвязный список ........................................................................... 54 4 6.3.1 Структура элемента двухсвязного списка ...................................................... 55 6.3.2 Реализация операций в линейном двухсвязном списке ................................. 55 6.4 Плексы ................................................................................................................... 58 6.4.1 Нелинейный двухсвязный список .................................................................... 58 6.4.2 Нелинейный трехсвязный список .................................................................... 59 6.4.3 Определение плекса и его общие признаки .................................................... 60 6.5 Иерархическая списковая структура. Сетевая структура ................................. 60 6.6 Достоинства и недостатки связных списков ...................................................... 63 6.7 Функциональные и свободные списки ............................................................... 63 6.7.1 Общая характеристика функционального и свободного списка .................. 63 6.7.2 Методы формирования свободного списка .................................................... 65 7 СТЕКИ, ОЧЕРЕДИ, ДЕКИ И ОПЕРАЦИИ В НИХ ............................................. 66 7.1 Общая характеристика ......................................................................................... 66 7.2 Стек ........................................................................................................................ 66 7.3 Очередь .................................................................................................................. 68 7.4 Дек .......................................................................................................................... 70 8 ДИНАМИЧЕСКИЕ МНОЖЕСТВА И СЛОВАРИ ............................................... 72 8.1 Общая характеристика ......................................................................................... 72 8.2 Таблица – логическое представление словаря ................................................... 73 8.3 Операции в динамических множествах ............................................................. 74 9 ДЕРЕВЬЯ .................................................................................................................. 76 9.1 Общая характеристика и некоторые определения ............................................ 76 9.2 Представление дерева в памяти .......................................................................... 78 9.2.1 Естественное представление дерева ................................................................ 78 9.2.2 Представление дерева с помощью вектора ..................................................... 80 9.2.3 Представление дерева с использованием списков сыновей .......................... 82 9.3 Методы обхода деревьев ...................................................................................... 82 9.4 Бинарное дерево ................................................................................................... 84 9.4.1 Структура бинарного дерева ............................................................................ 84 9.4.2 Формирование бинарного дерева .................................................................... 85 9.4.3 Обход бинарного дерева ................................................................................... 86 9.4.4 Преобразование любого дерева к бинарному дереву .................................... 88 9.5 Включение и исключение в дереве ..................................................................... 89 9.5.1 Включение в дереве ........................................................................................... 90 9.5.2 Исключение в дереве ......................................................................................... 90 10 ПОИСК ................................................................................................................... 91 10.1 Поиск: определение и общая терминология .................................................... 91 10.2 Линейный (последовательный) поиск .............................................................. 93 10.3 Последовательный поиск в упорядоченной таблице ...................................... 94 10.4 Последовательный поиск при накоплении запросов ...................................... 95 10.5 Индексно-последовательный поиск ................................................................. 96 10.6 Бинарный поиск .................................................................................................. 98 10.7 Поиск, использующий бинарное дерево .......................................................... 100 11 СОРТИРОВКА ....................................................................................................... 102 11.1 Общие сведения и некоторые определения ..................................................... 102 11.2 Внутренняя сортировка ...................................................................................... 103 11.2.1 Сортировка прямыми включениями .............................................................. 104 11.2.2 Сортировка бинарными включениями .......................................................... 106 11.2.3 Сортировка прямым выбором ........................................................................ 106 11.2.4 Сортировка прямым обменом ........................................................................ 108 11.2.5 Сортировка методом Шелла ........................................................................... 111 5 11.2.6 Сортировка с помощью бинарного дерева .................................................... 113 11.2.7 Пирамидальная сортировка ............................................................................ 115 11.2.8 Быстрая сортировка разделением .................................................................. 119 11.3 Внешняя сортировка .......................................................................................... 121 11.3.1 Сортировка прямым слиянием ....................................................................... 122 11.3.2 Сортировка естественным слиянием ............................................................. 125 11.3.3 Сортировка многопутевым слиянием ............................................................ 126 11.3.4 Многофазная сортировка ................................................................................ 127 12 ХЕШИРОВАНИЕ И ХЕШ-ТАБЛИЦЫ ............................................................... 129 12.1 Общие сведения и определения ........................................................................ 129 12.2 Коллизии при хешировании .............................................................................. 132 12.3 Разрешение коллизий при хешировании .......................................................... 132 12.3.1 Разрешение коллизий методом открытой адресации .................................. 132 12.3.2 Разрешение коллизий методом цепочек ........................................................ 137 12.4 Функции хеширования ....................................................................................... 139 12.4.1 Хеш-функции для целочисленных ключей ................................................... 139 12.4.2 Хеш-функции для строковых ключей ........................................................... 141 13 ПОИСКОВЫЕ ДЕРЕВЬЯ ..................................................................................... 143 13.1 Бинарное дерево поиска ..................................................................................... 143 13.1.1 Структура бинарного дерева поиска и алгоритм поиска ............................. 143 13.1.2 Вставка элемента в бинарное дерево поиска ................................................ 144 13.1.3 Удаление из бинарного дерева поиска .......................................................... 146 13.2 Красно-черные деревья ...................................................................................... 147 13.2.1 Определение красно-черного дерева, структура его элементов ................. 147 13.2.2 Повороты .......................................................................................................... 149 ЛИТЕРАТУРА ............................................................................................................. 151 6 ВВЕДЕНИЕ 1.1 Данные Данные – непременный атрибут любой программы. Когда употребляют термин «программа», подразумевают не только последовательность операторов некоторого языка программирования, но и набор различных информационных объектов (константы, переменные, массивы и т. д.), над которыми выполняются действия, описанные операторами программы. Такие объекты называют данными. В операторе If a > b[i] Then a := 0; описаны действия, в которых «участвуют» переменные a, i, элемент массива b[i] и константа 0. Эти информационные объекты и являются данными. Данные и команды хранятся в памяти компьютера в двоичном коде. Этот код занимает часть памяти, называемую ячейкой или слотом памяти. Местоположение каждой ячейки определяется ее адресом. В оперативной памяти минимальная двоичная ячейка, имеющая собственный уникальный адрес, – это последовательность восьми битов, т. е. байт. Обычно ячейка, предназначенная для хранения индивидуального данного, состоит из нескольких последовательных байтов, т. е. из нескольких адресуемых ячеек. При компиляции программы программа-компилятор выполняет, в частности, следующие действия: 1) каждому данному выделяется своя ячейка памяти, 2) каждый оператор, записанный на языке высокого уровня, представляется последовательностью машинных команд (или одной командой), которые содержат адреса тех данных, с которыми эти команды оперируют (именно поэтому данные часто называют операндами). Каждое данное относится к одному из конечного множества типов, допустимых для конкретной версии языка программирования. Программистам хорошо известны данные таких типов как Byte (байтовый), Integer (целый), Rеаl(вещественный), Boolean (логический), Char (символьный). Значение любого данного этих типов логически неразделимо, поэтому такие данные называются неструктурированными или примитивными данными. Неструктурированные данные служат для построения структур данных: записей, массивов, деревьев, файлов и т. д. Биты в байте нумеруются от 0 до 7, при этом бит с номером 0 является самым младшим битом. При схематическом изображении ячейки, состоящей из одного или нескольких байтов, самый младший бит принято располагать на правом конце ячейки, и старшин- 7 ство битов увеличивается при приближении к левому концу. На рисунке 1.1 показано схематичное изображение байта, содержащего значение 5 в прямом двоичном коде. Рисунок 1.1 – Нумерация битов в байте Два байта со смежными адресами образуют слово (word) разрядностью 16 бит, два смежных слова образуют двойное слово (double word). Учетверенное слово (quad word), образуемое последовательностью из восьми байт, имеет разрядность 64 бита. Биты ячеек, состоящих более чем из одного байта, нумеруются от 0, до общего количества битов минус 1, например, самый старший бит учетверенного слова имеет номер 63 Если ячейка состоит более чем из двух байтов, то адресом всей ячейки является адрес ее младшего байта. При графическом изображении ячейки, состоящей из нескольких байтов, старшинство байтов (и соответственно значения их адресов) увеличиваются в направлении справа налево, т. е. самый правый байт, является самым младшим байтом ячейки, как это показано на рисунке 1.2 на примере двойного слова Рисунок 1.2 – Пример представления двойного слова 1.2 Логическая и физическая структуры данных Структурой данных (data structure) называют совокупность (множество) данных и отношений между ними. Один и тот же набор данных можно представить по-разному. Пусть имеется пять данных типа Char: А, B, С , D и Е. Из этой совокупности значений можно сформировать как линейную последовательность элементов, так и дерево или сеть, как показано на рисунке 1.3. 8 Как видно из рисунка 1.3, способ построения структур данных определяется характером связей между элементами. Все связи одного элемента данных с другими образуют элемент отношений. Пару, содержащую элемент данных и ассоциированный с ним элемент отношений, называют элементом структуры данных. Заметим, что элементом структуры данных может быть другая структура данных, например, элементом сети может быть запись. а А B C D б E в E А А B B C D D C E Рисунок 1.3 – Представление множества из пяти элементов разными структурами: а – линейная последовательность, б – дерево, в сеть Графическое представление структур данных, подобное представлению на рисунке 1.3, называется графом. Графовое представление часто используют на практике для показа логической структуры. Вершины графа соответствуют элементам данных, а ребра – отношениям между этими элементами. Используя термин «структура данных», следует различать понятия логической и физической структур. Логическая структура это абстрактная схема расположения данных, которую представляет себе пользователь или программист. Физическая структура – это способ (или схема) конкретного размещения данных в памяти вычислительной машины. Физическую структуру иногда называют структурой хранения. В общем случае логическая и физическая структуры одних и тех же данных не совпадают. Например, последовательность записей, имеющая логическую структуру, изображенную на рисунке 1.4, представляется программисту как непрерывная последовательность строк одинакового размера. Однако при хранении на диске в виде файла эти записи могут располагаться не «вплотную» друг к другу. Коды логически смежных записей могут размещаться в далеко отстоящих друг от друга физических областях диска, при этом между такими записями будут размещены другие файлы (или части других файлов). Такая «разбросанность» раз- 9 ных частей (фрагментов) одного и того же файла по разным физическим участкам диска называется фрагментацией. Запись 1 Запись 2 ... Запись N Рисунок 1.4 – Логическая структура таблицы Или другой пример. Логическая структура двумерного массива чисел это прямоугольная двумерная фигура элементов или матрица, в которой каждый элемент однозначно идентифицируется парой индексов строки и столбца, на пересечении которых он находится. Физической же структурой двумерного массива является линейная последовательность ячеек оперативной памяти компьютера, каждая из которых однозначно определяется своим единственным адресом. На рисунке 1.5 показаны логическая и физическая структуры двумерного массива типа Word, состоящего их трех строк и двух столбцов. Одна и та же логическая структура может по-разному храниться в памяти разных ЭВМ (различная конфигурация памяти) или для разных компиляторов. a x [0, 0] x [1, 0] x [2, 0] x [0, 1] x [1, 1] x [2, 1] б Ячейка ОЗУ x [0, 0] x [0, 1] x [1, 0] x [1, 1] x [2, 0] x [2, 1] Адрес (смещение) $0A56 $0A58 $0A5А $0A5С $0A5Е $0A60 Рисунок 1.5 – Логическая (а) и физическая (б) структуры матрицы типа Word 1.3 Классификация структур данных С практической точки зрения структуру данных рассматривают как способ хранения и организации данных, облегчающий доступ к этим данным и их модификацию. Ни одна структура данных не является универсальной и не может подходить для всех целей, поэтому важно знать преимущества и ограничения, присущие некоторым из них. Прежде чем приступить к рассмотрению конкретных структур данных, необходимо определить некоторые их основные черты, наличие или отсутствие которых позволяет отнести ту или иную структуру к определенному классу. 10 Структуры данных, которые физически создаются, хранятся и обрабатываются в основной (оперативной) памяти ЭВМ, называются оперативными структурами. Представление структуры хранения во внешней памяти называют файловой структурой. Файл – это поименованная совокупность данных, расположенных на внешнем носителе, например, на диске. Элементами файловой структуры служат, в общем случае, записи. В процессе «зарождения» запись проходит фазу существования в оперативной памяти. Важным признаком структуры является ее изменчивость, под которой понимают способность структуры изменять в процессе ее обработки количество элементов или характер связей между элементами. В определении изменчивости не учитываются возможность изменения значений внутреннего содержания самих элементов. По изменчивости структуры подразделяют на статические, полустатические и динамические. В зависимости от отсутствия или наличия явно заданных связей между отдельными элементами данных следует различать несвязные структуры (векторы, массивы, записи) и связные, или связные, структуры данных (связные списки). По характеру упорядоченности элементов структуры различают линейно- упорядоченные (или линейные) и нелинейные структуры данных. К линейным структурам, в частности, относятся такие структуры как векторы, линейные списки. Примеры нелинейных структур дают многосвязные списки (плексы), древовидные и сетевые структуры. В зависимости от характера взаимного расположения элементов данных в памяти структуры разделяют на структуры с последовательным распределением элементов (векторы, записи, стеки), т. е. последовательные структуры, и структуры с произвольным связанным распределением элементов (многосвязные линейные и нелинейные структуры, циклически связанные списки, деревья, файлы). 1.4 Основные операции над структурами данных К основным операциям над структурами данных относятся следующие операции: формирование; просмотр; вставка (включение); добавление (дополнение); извлечение; удаление (исключение); сдвиг; изменение содержимого элемента; 11 сортировка. Большинство из перечисленных операций связано с корректировкой (updating) структуры данных. Под корректировкой структуры данных понимают алгоритм, применение которого позволяет изменить содержимое отдельных элементов структуры, либо сами структуры (количество элементов, характер отношений между элементами). Рассмотрим эти операции. Формирование это создание в памяти компьютера структуры данных, соответствующей ее логическому представлению. При этом различают фазы: 1) первоначальное формирование (generation), когда создаются ячейки памяти для элементов структуры и отношений, и значения данных размещаются в порядке их поступления извне в этих ячейках, и 2) перегруппирования (regrouping), например, создания древовидной структуры из записей, хранящихся в некоторой таблице; на этапе перегруппирования может быть применена сортировка. Просмотр (scan, pass) последовательное выполнение над элементами структуры одной и той же операции, например, сравнение их содержимого с некоторым заданным значением. Просмотр может выполняться с целью контроля содержимого элементов или для подсчета их числа. Вставка (insertion) это ввод нового данного в структуру данных. При вставке указываются элементы, между которыми в логической структуре расположится новый элемент; эти элементы определяют точку вставки. Хотя на расположение точки вставки могут быть наложены ограничения (например, включение в очередь возможно только со стороны «хвоста»), обычно, употребляя термин «вставка», подразумевают возможность включения нового элемента в любое место исходной структуры. При вставке в массивах выполняется сдвиг некоторого количества элементов, чтобы освободить место для вставляемого элемента. Вставка в динамические структуры такого сдвига не требует: просто изменяются адреса связей без физического перемещения данных в памяти. Под добавлением (store) понимают вставку нового элемента в логический конец корректируемой структуры. В сущности, формирование структуры это последовательность добавлений элементов данных. Извлечение (extraction) выполняется с целью передачи содержимого элемента структуры для дальнейшего использования, например, для печати этого содержимого. Удаление (deletion) это исключение некоторого элемента из структуры данных. При удалении в массивах удаляемый элемент либо помечают как удаленный (такое удале- 12 ние без физического уничтожения называется логическим удалением logical deletion), либо осуществляют сдвиг некоторого количества элементов, при котором в ячейку с адресом удаляемого элемента заносится значение соседнего сдвигаемого элемента. В динамических структурах просто изменяются адреса связей без физического перемещения данных, а ячейка, в которой размещался удаляемый элемент, включается в список свободных ячеек, доступных для вставки. Сдвиг (shift) это перемещение некоторых элементов данных в одном из направлений: либо от логического начала структуры к ее логическому концу, либо наоборот. При сдвиге сохраняется порядок следования сдвигаемых элементов. Под изменением содержимого (data modification) элемента данных обычно понимают присваивание этому элементу нового значения. Сортировка (sorting) это распределение элементов некоторого множества с целью их расположения в соответствии с некоторыми правилами. Разновидностью сортировки является упорядочение данных по возрастанию или убыванию значений некоторого признака или ключа сортировки. Часто сортировка выполняется как переупорядочение (reordering) ранее упорядоченной последовательности по другому признаку (полю). Сортировка массивов предполагает, что перестановки, приводящие элементы в порядок, должны выполняться «на том же месте». Сортировка динамической структуры предполагает изменение адресов связей. На практике перечисленные выше операции часто применяются в различных комбинациях. Например, просмотр часто имеет целью поиск некоторого элемента или нескольких элементов структуры, а поиск может завершаться удалением найденного элемента или вставкой нового элемента на место найденного или на новое место. 1.5 Алгоритм и примеры задач, решаемых с помощью алгоритмов Алгоритм (algorithm) – это любая корректно определенная вычислительная процедура, на вход которой подается некоторая величина или набор величин, и результатом которой является выходная величина или набор значений. Таким образом, алгоритм представляет собой последовательность вычислительных шагов, преобразующих входные величины в выходные. Алгоритм можно рассматривать как инструмент, предназначенный для решения корректно поставленной вычислительной задачи (computational problem). В постановке задачи в общих чертах задаются отношения между входом и выходом. В алгоритме описывается 13 корректная вычислительная процедура, с помощью которой удается добиться указанных отношений. Например, в информатике основополагающей операцией является сортировка (во многих приложениях она используется в качестве промежуточного шага). Задача сортировки в неубывающем порядке формально определяется следующим образом: Вход: последовательность из N чисел ( a1 , a2 , ..., a N ). Выход: перестановка входной последовательности для получения из ее элементов новой последовательности ( a1 , a2 , ..., a N ) такой, что для ее членов выполняется соотношение a1 a 2 ... a N . Любой конкретный набор значений входной последовательности a1 , a2 , ..., a N называется экземпляром (instance) задачи сортировки. В общем случае, экземпляр задачи состоит из входных данных, необходимых для решения задачи и удовлетворяющих всем ограничениям, наложенным при постановке задачи. Говорят, что алгоритм корректен, если для каждого корректно заданного набора входных данных результатом его работы является корректный набор выходных данных. Если алгоритм некорректный, то для некоторых корректных входных наборов он может вообще не завершить свою работу или выдать ответ, отличный от ожидаемого. Алгоритм может быть задан на естественном языке, в виде схемы, в виде компьютерной программы или даже реализован в аппаратном обеспечении. Единственное требование – его спецификация должна предоставлять точное описание процедуры, которую требуется выполнить. Практическое применение алгоритмов чрезвычайно широко. Приведем два примера. В любой точке мира пользователь с помощью сети Internet может быстро получать доступ к информации и извлекать ее в больших объемах. Управление этой информацией, выполняемое для обеспечения доступа, осуществляются с помощью сложных алгоритмов. В число задач, которые нужно решить, входит определение оптимальных маршрутов, по которым перемещаются данные, и быстрый поиск страниц, на которых находится нужная информация. Электронная коммерция позволяет заключать сделки и предоставлять товары и услуги с помощью электронных технических средств. При этом важно защищать такую информацию, как номера кредитных карт, пароли и банковские счета. В набор базовых технологий в этой области входят криптография и цифровые подписи, основанные на численных алгоритмах и теории чисел. 14 2 АДРЕСАЦИЯ И РАСПРЕДЕЛЕНИЕ ПАМЯТИ 2.1 Адресные пространства и модели оперативной памяти Синтаксическая конструкция, с помощью которой обеспечивается доступ к определенному объекту (переменной, полю записи, массиву или его элементу, файлу), называется неявным адресом (implied address). Примеры неявных адресов дают следующие конструкции: Point^.Name, arrA[4], intValue, D:\DelProj\Table.xls. Используя неявный адрес, программист или пользователь предполагает, что адресуемый объект хранится в некотором участке памяти. Доступ к этому участку со стороны программы осуществляется по логическому адресу (logical address), который идентифицирует объект в виртуальной памяти. Логический адрес преобразуются в реальный физический адрес (physical address) ячейки оперативной памяти или порта. Под адресным пространством компьютера понимается множество адресов памяти. Адресное пространство, понимаемое как множество адресов, доступных программе, еще называют виртуальным адресным пространством, а элемент этого пространства – виртуальным адресом. Определение «виртуальный», относящееся к техническим средствам или данным, указывает на то, что некоторый элемент представляется прикладному программисту существующим, тогда как фактически в представляемом виде он отсутствует. Например, при запуске приложения современная 32-разрядная операционная система, работая в так называемом защищенном режиме, представляет ему для кода и данных блок памяти размером 4 Гбайт. Понятно, что далеко не каждый пользователь может выделить 4 Гбайт ОЗУ под одно приложение. Фактически предоставляется пространство логических адресов, по которым, теоретически может храниться до 4 Гбайт информации. Это и есть виртуальное пространство. Компьютер незаметно для программиста компенсирует недостаток физической памяти, используя специальный страничный механизм, организующий обмен блоков данных или программ между оперативной и внешней памятью. Реальное адресное пространство – это множество адресов, существующих в памяти ЭВМ физически (реально). Элемент реального адресного пространства – реальный адрес. Реальное адресное пространство является лишь частью виртуального. Логическим адресным пространством называется множество адресов команд и данных, используемых в конкретной программе, потому иногда его называют адресным пространством программы. Логическое адресное пространство может быть частью реального адресного пространства или выходить за его пределы. Микропроцессор аппаратно поддерживает две модели оперативной памяти: 15 сегментированная модель, в которой программе выделяются непрерывные обла- сти памяти, называемые сегментами, а сама программа может обращаться только к информации, которая находится в этих сегментах; страничная модель, которую можно рассматривать как надстройку над сегменти- рованной моделью. Основное применение этой модели связано с организацией виртуальной памяти, что позволяет операционной системе использовать для работы программ пространство памяти большее, чем объем физической памяти. Для микропроцессоров Pentium размер доступной виртуальной памяти может достигать 4 Тбайт (терабайт – 240 байт). Особенности использования и реализации этих моделей зависят от режима работы микропроцессора: режим реальных адресов, или просто реальный режим, защищенный режим. 2.2 Формирование физического адреса в реальном режиме Для формирования адреса в реальном режиме используется два компонента – сегмент адреса и смещение адреса, а сам адрес принято записывать парой этих значений, разделенных двоеточием: segment : offset, например, $0051:06А5 (здесь символ $ обозначает, что следующая за ним последовательность символов, кроме двоеточия, является шестнадцатеричным кодом). Значение «segment» хранится в специальном 16-битном сегментном регистре микропроцессора, а смещение «offset» — помещается в один из 16-битных регистров общего назначения (РОН). Физический адрес формируется на аппаратном уровне следующим образом: 1) значение 16-битового сегмента адреса сдвигается влево на 4 двоичных разряда (бита) с заполнением разрядов справа нулями; в результате получается 20-битовое значение; 2) к полученному 20-битовому значению прибавляется смещение, а значение суммы передается на шину адреса в качестве физического адреса. Получаемое значение физического адреса является 20-разрядным. Следовательно, с его помощью можно адресовать (т. е. получить доступ) 1 Мбайт (220 байт) оперативной памяти. Здесь можно отметить несоответствие размеров шины адреса современных микропроцессоров (32 или 36 бит) и 20-битного значения физического адреса реального режима. Пока микропроцессор находится в реальном режиме, старшие 12 (или 16) линий шины адреса попросту недоступны. 16 Как известно, сдвиг двоичного числа на 4 битов влево (или, что то же самое, сдвиг шестнадцатеричного кода на одну шестнадцатеричную позицию влево) эквивалентен умножению этого числа на 24 = 16. Следовательно, физический адрес А определяется формулой А = 16 segment + offset. Величину 16segment часто называют базой сегмента или просто базой, а схему формирования адреса обозначают термином «база-смещение». Эта схема иллюстрируется рисунком 2.1. Рисунок 2.1 – Формирование физического адреса в реальном режиме 2.3 Особенности адресации в защищенном режиме Защищенный режим работы позволяет использовать все возможности, предоставляемые современным микропроцессором. Все современные многозадачные операционные системы работают только в этом режиме. Реальный режим поддерживает выполнение всего одной программы. Для этого достаточно простых механизмов распределения оперативной памяти и нет потребности в организации защиты программы от влияния других программ. Все, что нужно знать программе, это адреса, по которым располагаются сегменты кода, данных и стека. Если возникает потребность в размещении в программно-аппаратной среде нескольких независимых программ, то автоматически встает вопрос об их защите от взаимного влияния. И процессор переходит в защищенный режим работы. В отличие от реального режима, в защищенном режиме программа уже не может запросто обратиться по любому физическому адресу. В защищенном режиме используется виртуализация памяти (страничная модель). Каждой загруженной программе (задаче, про- 17 цессу) операционная система выделяет 4 Гбайт виртуальной памяти, которая состоит из сегментов различного назначения и с разными правами доступа. Сегмент может иметь почти произвольный размер до 4 Гбайт, в отличие от сегмента реального режима, который не превышает 64 Кбайт. Из одних сегментов можно только читать данные, в другие возможна и запись. Ключевым объектом защищенного режима является специальная структура – дескриптор сегмента, содержащий краткое описание непрерывной области памяти, которая может являться сегментом кода, данных или стека. Все дескрипторы программ, выполняемых в текущий момент, собираются в одну из трех дескрипторных таблиц. Для программного кода выделяются специальные сегменты, команды могут выбираться и исполняться только из них. Процессору «безразлично» содержимое ячейки памяти, которой передается управление, он всегда пытается трактовать ее как код команды. Если ошибочно управление передалось на сегмент данных, то сработает защита и ошибочный процесс будет завершен. Виртуальная логическая память, адресуемая программой в пределах выделенных ей сегментов, разбивается на страницы. В системах Win32 с процессорами Pentium размер одной страницы составляет 4 Кбайт, следовательно, Win32 разбивает блок памяти 4 Гбайт на страницы по 4 Кбайт. При этом в каждой странице содержится небольшой объем служебной информации, в частности, данные о том, занята страница или нет. В служебную информацию страницы входит ссылка на таблицу перевода страниц. Эта таблица связывает отдельную виртуальную страницу программы с реальной страницей, доступной в ОЗУ. Таким образом операционная система выполняет перевод виртуального адреса в реальный (физический) адрес ОЗУ. Если активировано несколько программ, то в физической оперативной памяти в каждый момент времени присутствует только часть виртуальных страниц. Остальные страницы хранятся на диске, откуда операционная система может «подкачать» их в физическую память, предварительно выгрузив на диск часть не используемых в данный момент страниц. Обращение процессора к ячейке виртуальной памяти, присутствующей в физической памяти, происходит обычным образом. Если затребованная область памяти в данный момент отсутствует в физической памяти, то операционная система организует замену страниц, называемую свопингом (swapping). 2.4 Кэширование Под термином кэш (cache запас, тайник) понимают некоторый участок памяти, используемый в качестве дополнительного источника памяти для оптимизации процессов 18 обмена. Хорошо известен такой способ кэширования дисковой памяти, как организация виртуального диска. С точки зрения прикладной программы эта область выглядит как обычный, хотя и очень быстрый, диск. А на самом деле – это область оперативной (т. е. энергозависимой) памяти, которая выделяется для временного хранения файлов, что существенно повышает эффективность работы компьютера при интенсивном файловом обмене. Виртуальную память можно считать кэшированием оперативной памяти за счет дисковой памяти. Виртуализация памяти позволяет компенсировать наличие такого недостатка оперативной памяти, как ее сравнительно небольшой объем. Все современные компьютеры используют кэш процессора. Обычно это блок высокоскоростной памяти, представляющей собой буфер между процессором и его регистрами и основной памятью. Когда процессору необходимо считать некоторые данные из ОЗУ, кэш проверяет, есть ли эти данные в его памяти, и если требуемых данных нет, считывает их. 19 3 АНАЛИЗ АЛГОРИТМОВ 3.1 Быстродействие – основной показатель эффективности алгоритма Анализ алгоритмов заключается в том, чтобы предсказать требуемые для его выполнения ресурсы. Иногда оценивается потребность в таких ресурсах, как объем памяти, пропускная способность сети или необходимое аппаратное обеспечение, однако чаще всего определяется время вычислений. Если бы компьютеры были неограниченно быстрыми, подошел бы любой корректный алгоритм решения задачи. Сегодня есть весьма производительные компьютеры, но их быстродействие не может быть бесконечно большим. Память тоже дешевеет, но она не может быть бесплатной. Следовательно, время вычислений – это такой же ограниченный ресурс, как и объем необходимой памяти. Разумному распределению этих ресурсов способствует применение алгоритмов, эффективных с точки зрения расходов памяти и времени. При этом решающим фактором выбора алгоритма является время его выполнения. Время работы алгоритма зависит от аппаратного обеспечения (процессора, тактовой частоты, размера памяти, объема дискового пространства и т.п.) и программного обеспечения (операционной системы, среды программирования, компилятора), с помощью которых осуществляется реализация, компиляция и выполнение алгоритма. Например, при всех равных условиях время выполнения алгоритма для определенного количества исходных данных будет меньше при использовании более мощного компьютера или при записи алгоритма на машинном коде по сравнению с его исполнением машиной, которая выполняет интерпретацию текста алгоритма. Однако решающим фактором, влияющим на быстродействие, считается размер входных данных алгоритма. Очевидно, время выполнения алгоритма возрастает при увеличении размера исходных данных (input size). Например, время, затрачиваемое на сортировку числового массива, увеличивается при увеличении количества сортируемых чисел. Для сравнительного анализа нескольких алгоритмов, решающих одну и ту же задачу, целесообразно применить следующую методику: 1) задать для эксперимента один из сравниваемых алгоритмов; 2) для заданного алгоритма провести ряд экспериментов, в которых используется различное количество исходных данных N ( N N1 , N 2 , ..., N M ); 3) далее полученные результаты наглядно представляются в виде графика, на котором каждый m-й случай (m = 1, 2, …, M) выполнения алгоритма обозначается с помощью 20 одиночной точки. У этой точки координата по оси абсцисс равна размеру исходных данных N m , а координата по оси ординат – времени выполнения алгоритма t m ; 4) задать следующий проверяемый алгоритм, и если он не является последним в наборе алгоритмов, то перейти к п. 2, если набор сравниваемых алгоритмов исчерпан, то завершить анализ. Результатом применения такой методики является диаграмма с графиками, число которых равно количеству исследуемых алгоритмов. На рисунке 3.1 показан случай, когда сравниваются два алгоритма. Время t … Алгоритм 1 … Алгоритм Алгорит 2 N1 N2 N3 … NM Рисунок 3.1 – Результаты исследования времени выполнения двух алгоритмов Результаты, представленные на рисунке 3.1, показывают, что график времени выполнения алгоритма 2 располагается ниже графика для алгоритма 1. Следовательно, алгоритм 2 является более эффективным, нежели алгоритм 1, для заданных наборов исходных данных. Чтобы сделать более определенные выводы на основе экспериментальных исследований, необходимо использовать не одиночные, а многочисленные корректные экземпляры исходных данных для достаточно большого числа экспериментов. Это позволит определить некоторые статистические характеристики в отношении времени выполнения алгоритмов. Экспериментальные исследования очень полезны, однако при их проведении существуют три основных ограничения: эксперименты могут проводиться с использованием ограниченного числа набо- ров исходных данных; 21 для сравнения эффективности двух алгоритмов необходимо, чтобы экспери- менты проводились на одинаковом аппаратном и программном обеспечении; для экспериментального изучения необходимо провести реализацию и выпол- нение алгоритма. Чтобы провести анализ алгоритма без экспериментов, можно использовать аналитический подход, который заключается в подсчете простейших операций, выполняемых при работе алгоритма. 3.2 Подсчет числа простейших операций В соответствии с методом подсчета простейших операций алгоритм представляется в виде программы на развитом языке программирования (например, Pascal). Определяется множество простейших операций высокого уровня, которые не зависят от языка программирования. К таким операциям относятся: 1) присваивание переменной значения, 2) вызов метода (процедуры или функции), 3) выполнение арифметической операции, 4) сравнение двух значений, 5) индексация массива, 6) переход по ссылке на объект, 7) возвращение из метода. Данный метод основан на неявном предположении, что время выполнения простейших операций приблизительно одинаково. Таким образом, К простейших операций, выполняемых внутри алгоритма, пропорционально действительному времени выполнения данного алгоритма. Рассмотрим процесс подсчета простейших операций на примере алгоритма определения максимального значения в одномерном массиве X, состоящего из N элементов. На рисунке 3.2 приводится код данного алгоритма на языке Pascal. begin currentMax:= X[0]; for i:= 1 to N-1 do if currentMax < X[i] then currentMax:= X[i]; end; Рисунок 3.2 – Pascal-код алгоритма определения максимального значения в массиве 22 Приведем рассуждения, формулируемые в процессе анализа: на этапе инициализации переменной currentMax и присваивания ее значения X[0] выполняются две простейшие операции (индексация массива и присваивание значения); таким образом, счетчик операций равен 2; в начале цикла for счетчик i получает значение 1, что соответствует одной простейшей операции присваивания; перед выполнением тела цикла проверяется условие i< N (операция сравнения); такое сравнение выполняется N раз, поэтому счетчик простейших операций увеличивается еще на N единиц; тело цикла выполняется для значений i, равных 1, 2, …, N-1. При каждой ите- рации X[i] сравнивается с currentMax (две простейших операции – индексирование и сравнение), значение X[currentMax], возможно, присваивается переменной currentMax (две операции – сложение и присваивание), а счетчик i увеличивается на 1 (две операции – сложение и присваивание). Следовательно, при каждой итерации цикла выполняется 4 или 6 простейших операций, в зависимости от выполнения одного из условий X[i] currentMax или X[i]> currentMax. Таким образом, при выполнении тела цикла в счетчик операций добавляется 4(N–1) или 6(N–1); при возвращении значения переменной currentMax однократно выполняется одна простейшая операция. Итак, число простейших операций К(N), выполняемых алгоритмом, минимально равно 2 1 N 4( N 1) 1 5N , а максимально 2 1 N 6( N 1) 1 7 N 2 . Число выполняемых простейших операций равно минимально (К(N) = 5N) в том случае, если X[0] является максимальным элементом массива, т. е. переменной currentMax не присваивается нового значения. Число выполняемых операций максимально равно 7N–2 в том случае, если элементы массива упорядочены по возрастанию, и переменной currentMax присваивается значение при каждой итерации цикла. 3.3 О-нотация Для выражения характеристик быстродействия в вычислительной технике используется короткая схема – О-нотация (big-Oh notation). В этой нотации используется специ- 23 альная математическая функция от N, т. е. количества исходных данных, которому пропорционально быстродействие алгоритма. Утверждается, что алгоритм принадлежит к классу О(f(N)), где f(N) – некоторая функция от N. Приведенное обозначение читается как «О большое от f(N)» или, менее строго, «пропорционально f(N)» Например, исследования показали, что алгоритм А принадлежит к классу О(N), а алгоритм В – к классу О(log(N)). Поскольку для положительных чисел log(N) < N, можно сделать вывод о том, что алгоритм В всегда эффективнее алгоритма А. О-нотация подчиняется простым арифметическим правилам. Во-первых, умножение математической функции внутри скобок в О-нотации на константу не оказывает никакого влияния на О-нотацию. Например, О(3f(N)) и О(24f(N)) эквивалентно О(f(N)), поскольку константы 3 или 24 можно без последствий вынести за скобки как коэффициент пропорциональности, который игнорируется. Во-вторых, О-нотация демонстрирует асимптотическое поведение, проявляющееся в том, что для больших значений N О-нотация определяет тот класс алгоритма, к которому принадлежит его доминантная часть. Пусть некоторый алгоритм принадлежит к классу О(N2 + N). Если величина N достаточно велика, то влияние члена «+N» поглощается членом «N2». Другими словами при больших значениях N алгоритм О(N2 + N) эквивалентен алгоритму О(N2). То же можно сказать и для более высоких степеней N. Предположим, что есть алгоритм, который выполняет три различных задачи. Первая задача выполняется алгоритмом класса О(N), вторая – алгоритмом класса О(N2), третья – алгоритмом класса О(log(N)). Каково будет быстродействие алгоритма в целом. Ответ будет О(N2), поскольку к этому классу принадлежит доминантная часть алгоритма. Таким образом, значения О большого характеризуют алгоритм для больших значений N, а для маленьких значений N О-нотация не имеет смысла. 3.4 Лучший, средний и худший случаи О-нотация описывает так называемый средний случай скорости выполнения алгоритма. Алгоритм пузырьковой сортировки завершает работу сразу, как только обнаруживается, что все элементы входного массива поступили на вход в виде последовательности, уже отсортированной в нужном порядке. Такая ситуация называется лучшим случаем. Время сортировки в этом случае будет минимальным. Алгоритм поиска стремится обнаружить в некоторой таблице определенную запись по первичному ключу. Если искомая запись в таблице отсутствует, то алгоритму прихо- 24 дится просматривать все элементы таблицы от начала до конца. Такой случай связан с максимальными затратами времени поиска, поэтому он называется худшим случаем. Лучшие случаи, как правило, не интересны, поскольку программисты всегда судят о быстродействии алгоритма, когда он выполняется при наихудших условиях. Поэтому при выборе алгоритма следует учитывать значения в О-нотации для среднего и худшего случаев. 3.5 Алгоритмы и платформы В обсуждении быстродействия алгоритмов до сих пор не затрагивались вопросы, касающиеся операционной системы и оборудования компьютера, на котором выполняется программная реализация алгоритма. О-нотация справедлива только для некоторой виртуальной машины, в которой нет узких мест в операционной системы и оборудовании. На практике приложения и алгоритмы выполняются на реальных физических компьютерах, поэтому при анализе алгоритмов следует учитывать и данный фактор. 3.5.1 Виртуализация памяти В подразделе 2.3 показано, что виртуальная память разбивается на страницы. Если физическая страница ОЗУ занята виртуальной страницей приложения, то приложение обращается к этой странице по реальным адресам кода или данных. Если страница, к которой пытается обратиться приложение, находится на диске, а свободного места в ОЗУ нет, то возникает ошибка отсутствия страницы (page fault). При этом процессор освобождает физическую страницу, записывает ее на диск, а на освободившееся место в ОЗУ передает с диска запрашиваемую страницу. Эти процессы в 32-разрядной операционной системе происходят постоянно. Физические страницы записываются на диск и считываются с диска. В большинстве случаев пользователь ничего не замечает, за исключением ситуации, которая называется пробуксовкой. Пробуксовка может негативно сказаться на приложении, основанном на эффективном алгоритме, превращая высокоскоростную программу в медленную. Допустим, некоторое приложение создает большие массивы крупных блоков памяти, выделяя память из кучи. Такое выделение приведет к тому, что будут заниматься новые страницы, а старые будут записываться на диск. Затем приложение считывает эти большие блоки последовательно, начиная с начала массива и в направлении его конца. При этом никаких проблем не возникает. Если же приложение считывает блоки в произвольном порядке, например, сначала из блока 56, затем из блоков 123, 12, 234 и т. д., то частота ошибок отсутствия страницы 25 увеличивается. Индикатор работы диска будет гореть почти постоянно, а скорость работы приложения заметно упадет. Это и есть пробуксовка – непрерывный обмен страницами между диском и ОЗУ, вызванный запросами приложения страниц в произвольном порядке. Свести вероятность пробуксовки к минимуму позволяет метод, называемый повышением уровня локальности ссылок. Этот метод предполагает, что элементы одной и той же структуры данных должны находиться в виртуальной памяти как можно ближе друг к другу. Например, массив записей имеет очень высокий уровень локальности ссылок, так как элемент с индексом i находится в памяти с элементом с индексом i+1. Связанные динамические списки обладают низким уровнем локальности ссылок, поскольку их элементы размещаются в памяти произвольно. Программист не может управлять конкретным расположением блоков памяти. При многочисленных вставках и удалениях элементов в динамических связных структурах эти элементы могут располагаться на разных страницах. В этих случаях можно рекомендовать использовать не одиночные, а «блочные» элементы, которые на самом деле являются последовательными блоками одиночных элементов и занимают непрерывные участки памяти. Говоря «один элемент располагается рядом с другим элементом», мы используем понятие локальности в смысле расстояния. С другой стороны это понятие можно трактовать по отношению ко времени. Воплощением локальности ссылок во времени является кэш-память. 3.5.2 Использование кэша Кэш память работает по принципу «если элемент недавно использовался, он скоро будет использоваться снова» или «элемент Х всегда используется с элементом Y». Кэшпамять представляет собой небольшой блок памяти для некоторого процесса, содержащий элементы, которые использовались недавно. При каждом использовании элемента он копируется в кэш-память. Если кэш заполнен, то применяется алгоритм удаления наиболее давно использованных элементов, который элемент, давно не использовавшийся, замещается элементом, который использовался недавно. Таким образом, кэш-память содержит несколько близких в пространственном смысле элементов, которые близки и в смысле времени их использования. Обычно кэш-память применяется для элементов, которые хранятся на медленных устройствах. Классический пример – дисковый кэш. 26 3.5.3 Выравнивание данных Современные процессоры устроены таким образом, что они считывают данные отдельными кусками по 32 бита (4 байта). Это означает, что адреса памяти, передаваемые от процессора в его кэш-память, всегда делятся на 4 без остатка, т. е. два младших бита адреса являются нулевыми. В 64-разрядных процессорах адресация является 64-битной и выравнивание производится по границе 8 байт. Если внутреннее представление данного неструктурированного типа переходит в памяти границу 4 байт, например, процессору придется выдавать две команды на считывание кэша: первая команда для считывания части данного, расположенной в первой четырехбайтной ячейке, вторая команда для считывания второй части. Затем процессору потребуется соединить две части данного и отбросить ненужные биты. Это замедляет процесс вычислений. Современные системы программирования автоматически выполняют выравнивание. Например, 32-разрядные версии компилятора Delphi автоматически выравнивают не только глобальные и локальные примитивные данные, а также и поля типа record (если этим типом описывается неупакованная запись). 3.6 Рандомизированные алгоритмы Термин «рандомизированный алгоритм» (randomized algorithm) употребляется в отношении алгоритма, который генерирует или использует случайные числа. В состав ядра операционной системы Linux входит модуль, который анализирует интервалы между нажатиями клавиш пользователем, а затем использует полученные данные для вычисления рандомизирующего коэффициента. В структурах данных, называемых списками с пропусками, используется алгоритм организации отсортированных связных списков с помощью случайных чисел, что существенно увеличивает скорость выполнения операции вставки нового элемента. В некоторых цифровых системах связи используется перестройка частоты передатчика и приемника для обеспечения конфиденциальности связи между пользователями. При этом частотные диапазоны, по которым передаются сигналы, перестраиваются соответствии набором случайных чисел, называемых псевдошумовыми кодами. Случайные числа применяются в системах компьютерного моделирования различных физических объектов, что позволяет избавиться от дорогостоящего натурного экспериментирования с этими объектами. Приведенные примеры показывают, что рандомизированные алгоритмы являются весьма полезными для решения практических задач. 27 4 ЗАПИСИ 4.1 Общая характеристика записей и способы описания в Delphi Запись (record) это структура данных, представляющая собой конечное множество элементов, называемых полями записи или просто полями. Поля записи в общем случае имеют различные типы. Обычно данные типа «запись» используются в качестве элементов структур, называемых таблицами. Запись, хранящаяся в оперативной памяти, относится к классу оперативных последовательных структур, поскольку а) в течение всего времени существования запись занимает сплошной участок памяти, в котором хотя могут находиться «пустоты», обусловленные выравниванием, однако между слотами её полей недопустимо существование слотов других данных; б) поля в физической памяти располагаются в той последовательности, в которой они перечисляются при объявлении типа Record; в) адресом всей записи в целом является адрес слота ее начального поля. Правилами языка Object Pascal не запрещается описывать переменную-запись непосредственно в ее объявлении, используя следующий формат: Var <имя переменной>: Record <список имен полей 1>: <тип 1>; <список имен полей m>: <тип m>; End; Однако с точки зрения хорошего стиля описание переменной типа «запись» следует начинать с явного объявления ее типа, которое (объявление типа) выглядит следующим образом: Type <имя типа> = Record <список имен полей 1>: <тип 1>; <список имен полей m>: <тип m>; End; Var <имя переменной> : <имя типа>; Пример объявления типа «запись» и переменной-записи, содержащей сведения о студенте, приводится ниже: 28 Type TStud = Record Fam, Name, Par: String[35]; Year: 1950..2000; Sex : (Male, Female); Group: String[7] End; Var StudFITR, StudMSF: TStud; Доступ к любому элементу записи осуществляется с помощью имени, называемого селектором поля записи. Селектор состоит из имени переменной типа Record, и после точки записывается имя поля, например, StudFITR.Fam, где StudFITR имя переменной, Fam имя поля. Логическую структуру записи часто изображают в виде прямоугольника, разделенного горизонтальными и вертикальными линиями на более мелкие прямоугольники, соответствующие отдельным полям. При этом размеры внутренних прямоугольников никак не сопоставляются с физическими размерами полей в байтах. Рядом с прямоугольниками указываются идентификаторы соответствующих полей, а внутри их значения, называемые метками. Пример логической структуры записи типа TStud приводится на рисунке 4.1. Рисунок 4.1 – Логическая структура записи типа TStud Обычно любой физической структуре ставится в соответствие дескриптор (description описание, приметы) или заголовок, который содержит общие сведения о физической структуре. Дескриптор является записью, в которой количество, размеры и содержимое полей зависят от той структуры, которой поставлен в соответствие дескриптор. Например, дескриптор записи может содержать: код структуры (Record), имя записи, число входящих в нее полей, имена, типы и длины полей, 29 адреса (указатели) слотов полей. 4.2 Многоуровневые записи В записи некоторые поля сами могут представлять собой записи. Например, дополним объявление типа TStud объявлением следующего типа: TStudent = Record SelfStud: TStud; Faculty: (FITR, MSF, ATF, MTF); Military: Boolean; End; где поля Faculty и Military предназначены для хранения соответственно названия факультета, на котором обучается студент, и признака «служил в армии / не служил». Пусть к тому же переменная Student имеет тип TStudent. Тогда в селекторах некоторых полей может появиться третья компонента, например, Student.SelfStud.Fam:= ’Иванов’; Student.SelfStud.Group:= ’107312’; Student.Military:= True; Записи, примером которых является запись Student, называются двухуровневыми записями. В общем случае, возможно любое количество уровней: трехуровневые записи, четырехуровневые и т. д. 4.3 Выравнивание и упакованные записи Современные процессоры устроены таким образом, что они считывают данные отдельными порциями по 4 байта. Кроме того, эти порции всегда выравниваются по границе двойного слова. Это означает, что если данное «переходит» границу 4 байта, то процессору придется выдать две команды на считывание памяти: первая команда на считывание первой части данного, а вторая на считывание второй части. Затем процессор соединит две части и отбросит ненужные биты. Все глобальные и локальные примитивные данные выравниваются должным образом. Данное типа Record компилятор Delphi выравнивает автоматически. Для этого он добавляет незначащие (пустые) байты. Пусть, например, объявлен следующий тип: TRec = Record ValByte: Byte; ValLongWord: LongWord; End; 30 и переменная Rec этого типа. На первый взгляд применение функции sizeof к типу TRec (или к переменной Rec) даст размер 5 байт. Однако верным ответом будет 8 байт. Дело в том, что компилятор вставит между полями ValByte и ValLongWord три дополнительных байта, чтобы выровнять поле ValLongWord по 4-хбайтовой границе. Адреса записи Rec и ее первого поля ValByte будут совпадать, а адрес поля ValLongWord будет больше адреса ValByte на 4. Если тип объявить следующим образом: TRec = packed Record ValByte: Byte; ValLongWord: LongWord; End; то функция SizeOf(TRec) даст результат 5. Но в этом случае доступ к полю ValLongWord потребует больше времени, чем в предыдущем примере, поскольку граница 4 байт будет «пересекать» слот этого поля. Следовательно, на практике желательно пользоваться следующей рекомендацией: если используется ключевое слово packed, то сначала целесообразно объявить 4-байтные поля, или поля, размер которых кратен 4, а потом уже все остальные. Выравнивание данных помогает выполнять диспетчер распределения памяти Delphi. Он выравнивает не только 4-байтные значения по границе 4 байт, но 8-байтные по границе 8 байт. Это имеет большое значение для вещественных переменных: операции с числами с плавающей точкой выполняются быстрее, если переменные выровнены по границе 8 байт. 4.4 Записи с вариантной частью Иногда в записях в зависимости от ситуации могут присутствовать различные наборы полей или поля могут иметь различные типы. Было бы нерационально отводить место под все возможные поля, если все они одновременно не используются. В этих случаях можно вводить в запись вариантную часть, в которой перечисляются варианты полей и условия их использования. Вариантная часть оформляется как оператор множественного выбора Case, который размещается в описании типа записи после объявления обязательных полей (т. е. полей, присутствующих при любых условиях). Вариантная часть начинается ключевым словом Case. Тег (tag ярлык, признак) любой допустимый идентификатор. Его указание не обязательно. Если тег указывается, то в запись включается соответствующее ему поле, по значению которого можно узнать, какой именно вариант полей используется. 31 При отсутствии тега не записывается и двоеточие после него. Приведем общую форму Вариантная часть описания записи с вариантной частью. Type <имя типа> = Record <список имен обязательных полей>: <список имен обязательных полей>: Case <тег> : <порядковый тип> Of <список значений 1>: (<вариант <список значений n>: (<вариант End; <тип 1>; <тип m>; 1>); n>); После тега или сразу после слова Case, если тег отсутствует, указывается порядковый тип. В случае, когда тег указан, этот тип является типом поля тега. Если тега нет, то это просто тип значений, которые будут перечисляться ниже. В части <список значений> указывается одно или несколько значений заданного порядкового типа (типа тега). А <вариант>, указываемый в круглых скобках, это разделенный символами «;» список объявлений вариантных полей, не входящих в списки обязательных полей. Каждое такое объявление имеет вид: <список имен полей> : <тип>; Указываемые типы полей не могут быть длинными строками, динамическими массивами, интерфейсами, Variant или структурами, содержащими эти запрещенные типы. Но тип поля может быть указателем на подобные типы. Объявленные варианты полей занимают в памяти один и тот же участок. Компилятор отводит слот для всей записи, ориентируясь на самый большой по размеру вариант. Все обязательные поля, поле тега и все поля всех вариантов доступны программе в любой момент времени, независимо от значения тега. Программа может в любой момент занести в запись значение любого поля любого варианта. Но если после этого заносится значение поля другого варианта, оно может изменить значение предыдущего варианта. Для контроля таких изменений предназначен тег. Например, пусть имеются следующие объявления: Type TRecVar = Record Flag: Boolean; longField: LongInt; Case logVal: Boolean Of True: (X1, Y1: Real; intField: Integer); False:(A1: Real; ch1, ch2: Char); End; 32 Var RecVar: TRecVar; Адрес всей переменной RecVar совпадает с адресом ее поля Flag, допустим, этот адрес равен $41. Несмотря на то, что поле Flag занимает один байт, адрес поля longField будет на 4 больше за счет выравнивания, т. е. он будет равен $45. Поля Х1 и А1 будут занимать один и тот же слот размером 8 байт. Поэтому если программа присвоит некоторое значение полю Х1, то это же значение будет наблюдаться в поле А1. Вывод: чтобы избежать ошибок, связанных с интерпретацией содержимого различных полей, необходимо внимательно контролировать значение тега. 33 5 МАССИВЫ 5.1 Общая характеристика массивов Массив (array) это структура данных. В Delphi имеется много типов массивов, и каждый такой тип имеет свои специфические особенности. Общим признаком массивов всех типов является возможность прямого доступа к их элементам со стороны программы. Эта возможность обеспечивается нумерацией элементов с помощью числового индекса, который обычно имеет целый тип. Delphi допускает использование в качестве индекса величины как диапазонного, так и перечислимого типов. Для логического определения массива ему необходимо присвоить имя, указать пару граничных значений индекса (или несколько пар граничных значений индексов), а также указать тип элементов. Все элементы массива имеют одинаковый тип, этот тип называется базовым типом массива. Пара граничных значений индекса задает диапазон изменения значений этого индекса. Если в объявлении массива определен один диапазон для индекса, то для доступа к любому элементу массива следует указать один единственный индекс, например, Vect[2], где Vect имя массива, объявленное в секции переменных. Такие массивы называются векторами или одномерными массивами. В случае, когда для доступа к некоторому элементу необходимо указать два или более индексов, то такой массив называют многомерным массивом (двумерный, трехмерный и т. д.). Двумерный массив часто называют матрицей, доступ к ее элементу обеспечивается записью двух индексов, например, Matr[k,j]. Количество индексов, которые необходимо указать при использовании отдельного элемента многомерного массива называется размерностью этого массива. В общем случае границы диапазона индекса могут быть любыми целыми значениями, в том числе и отрицательными; важно чтобы значение левой границы не превышало значения правой границы. На этот счет имеется рекомендация: если нет веских причин делать по-другому, то индексацию следует начинать с нуля (т. е. установить минимальное значение индекса 0). Эта рекомендация обоснована следующими аргументами: 1) для основных типов массивов, в которых элементы располагаются в памяти непрерывно, в случае индексации от нуля проще вычисляется адрес любого элемента массива; 2) в описаниях некоторых массивов (динамические и открытые массивы) не допускается указывать граничные значения индекса или индексов; в таких массивах индексация элементов начинается с нуля; 34 3) поскольку в языках Си, Си++ и Java индексация всех массивов начинается с нуля, а операционные системы Windows и Linux реализованы на Си или Си++, при вызове API-функций считается, что индекс первого элемента массива равен 0; 4) в Delphi-библиотеках VCL (библиотека визуальных компонентов) и CLX (кроссплатформенная библиотека компонентов) предполагается, что начальный элемент массива имеет индекс 0. 5.2 Статические (стандартные) массивы 5.2.1 Вектор Объявление вектора или одномерного массива имеет следующий общий вид (здесь и в дальнейшем используем рекомендацию, в соответствии с которой структурированный пользовательский тип объявляется отдельно от объявления переменной этого типа): Type <имя типа> = Array [<ограниченный тип>] Of <тип элементов>; Var <имя массива> : <имя типа>; Например: Type TarrVect = Array [-2..10] Of Integer; TarrStudent = Array [0..100] Of TStudent; TStroka = Array [0..10] Of Char; Var A, B: TarrVect; arrStudent: TarrStudent; S: TStroka; Здесь объявлены два одинаковых массива A и B, все элементы которого имеют тип Integer, и один массив arrStudent, с базовым типом TarrStudent, объявленным в подразделе 5.2. Количество элементов в каждом массиве A и B равно 13, массив arrStudent содержит 101 элемент. Массив символов S это фактически строка, с ним можно обращаться как со строкой. В рассмотренных примерах индексы были заданы ограниченным целым типом. Так чаще всего и бывает, но это не обязательно. Для индексации массива могут использоваться любые ограниченные или перечислимые типы. Можно определить массив arrColor следующим образом: Type Color = (Red, Green, Blue); Var arrColor: Array [Color] Of Real; Теперь можно обратиться, например, ко второму элементу arrColor[Green]. Не запрещается задавать индекс символами. вектора arrColor как 35 Физическая структура вектора представляется в машинной памяти последовательностью одинаковых по длине участков памяти (слотов), каждый из которых предназначен для хранения одного элемента вектора. Обычно элементы вектора располагаются в памяти в порядке возрастания адресов соответствующих им слотов. Дескриптор вектора может содержать такие поля, как имя вектора, адрес в памяти его начального элемента (т. е. первого слота), нижняя и верхняя границы индекса, тип элемента и размер слота. Физическая структура вектора A, объявленного выше, показана на рисунке 5.1. Рисунок 5.1 Пример физической структуры вектора с базовым типом Integer При доступе к любому элементу имя вектора и текущий индекс элемента преобразуются в физический адрес слота, в котором располагается этот элемент. Как видно из рисунка 5.1, логическая и физическая структуры вектора совпадают, и то и другое линейные последовательности элементов. Если в логической структуре при переходе от одного элемента к другому последовательному элементу индекс изменяется на единицу, то при переходе между последовательными элементами в физической структуре, адрес изменяется на величину, равную размеру слота базового типа. 5.2.2 Многомерные статические массивы Как было отмечено выше, вектор это одномерный массив. Во всех языках программирования можно объявлять и многомерные массивы, т. е. массивы, элементами которых являются массивы. Например, двумерный массив можно объявить следующим образом: Type TarrMatr = Array [0..5] Of Array [0..3] Of Word; Var X: TarrMatr; 36 То же самое можно объявить более компактно: Type TarrMatr = Array [0..5, 0..3] Of Word; Var X: TarrMatr; Доступ к значениям элементов многомерного массива обеспечивается с помощью имени переменной типа «массив» и индексов, перечисляемых через запятую, например, X[3, 0]. Количество указываемых индексов должно быть равно размерности массива: для доступа к элементу М-мерного массива необходимо указать М индексов. Все элементы массива имеют один и тот же тип. В отличие от вектора для массива общего вида преобразование логической структуры в физическую имеет более сложный вид. Это преобразование выполняется путем линеаризации, в ходе которой М-мерная логическая структура массива преобразуется в одномерную физическую структуру, представляющую собой линейно упорядоченную последовательность слотов. Такое преобразование реализуется с помощью подходящей функции упорядочения, аргументом которой является упорядоченный набор индексов элемента, а значением адрес соответствующего слота. Например, если последовательность однотипных элементов трактуется как двумерный массив, и нижние границы индексов i и j равны 0, то адрес (i, j)-элемента матрицы вычисляется с помощью следующей функции упорядочения: Адрес(i, j) = база + Nrow SizeOf(Element) i + j, где база адрес начального элемента массива, Nrow количество элементов в строке, SizeOf(Element) размер слота элемента в байтах, Хотя физическая структура М-мерного массива при линейном упорядочении его элементов в памяти совпадает с физической структурой вектора, дескриптор массива отличается от дескриптора вектора. В частности, в полях дескриптора массива может содержаться следующая информация: поле типа структуры; поле имени массива, например, Matr; поле, содержащее размерность массива; адрес массива в памяти (база); поля, содержащие пары граничных значений индексов, число этих полей равно размерности массива; поля, содержащие специальные индексные множители (их число равно размерно- сти массива), необходимые для использования в функции упорядочения; 37 поле базового типа массива; поле, содержащее размер слота для элемента. Для массивов в Delphi определена операция присваивания. Если два массива A и B типа TarrVect определены, с помощью нотаций, приведенных в п. 5.2.1, то в результате выполнения оператора A:= B; значения элементов массива В скопируются в элементы массива А. Но если объявить массивы как Var A: Array [-2..10] Of Integer; B: Array [-2..10] Of Integer; то при попытке присваивания A:= B компилятор Delphi выдаст сообщение об ошибке несовместимости типов. Дело в том, что компилятор считает, что переменные имеют один и тот же тип только в случае, если они объявлены в одном и том же списке или они явно определены через некоторый поименованный тип. Это еще один аргумент в пользу использования имен типов в описаниях структурированных переменных. 5.2.3 Свойства статических массивов Статические массивы характеризуются следующими свойствами: постоянство структуры в течение всего времени ее существования, смежность элементов и непрерывность области памяти, отводимой сразу для всех элементов структуры. простота и постоянство отношений между элементами структуры, позволяющие исключить информацию об этих отношениях из области памяти, выделенной для элементов структуры, и хранить ее в компактной форме в дескрипторах. 5.3 Открытые массивы В списке параметров, помещаемом в заголовках функций и процедур, допускается указывать массивы. Если размер воспринимаемого массива фиксирован, то в списке параметров тип такого параметра задается только с помощью идентификатора типа. Подпрограммы Object Pascal могут воспринимать также массивы, размер которых неизвестен. В этом случае в заголовке подпрограммы после записи имени параметра-массива указывается слово array, а затем базовый тип, а описание индексов не приводится. Например, Procedure InverArray(D: Array Of Real; Var T: Array Of Real); Такие массивы (как D и T) называются открытыми массивами. 38 При таком определении массивов D и T тот массив, который передается в процедуру InverArray первым в качестве фактического параметра, будет копироваться в стек, и с этой копией массивом D будет работать процедура. Второй открытый массив (массив Т) определен как Var. Этот массив передается «по ссылке», т. е. он не копируется в стек, и процедура будет работать с тем массивом, который был передан в качестве фактического параметра при вызове InverArray. Массив, переданный как открытый, воспринимается в теле процедуры или функции как массив с целыми индексами, начинающимися с нуля. При этом не имеет значения, как был объявлен диапазон для индекса в массиве, переданном в процедуру в виде фактического параметра. 5.4 Динамические массивы 5.4.1 Динамические векторы Динамические массивы относятся к классу динамических структур. Физическое существование динамического массива реализуется в специальной области памяти, называемой куча (heap). Эта область предназначена для динамического распределения. Динамические массивы отличаются от стандартных статических массивов тем, что в них не объявляется заранее число элементов. Поэтому динамические массивы удобно использовать в приложениях, где объем обрабатываемых массивов заранее неизвестен и определяется в процессе выполнения в зависимости от действий пользователя или объема обрабатываемой информации. Объявление типа одномерного динамического массива, называемого также динамическим вектором, содержит только имя типа, ключевое слово array и идентификатор типа. Переменная типа «динамический вектор» объявляется обычным образом. Например, динамический вектор dinArr может быть объявлен следующим образом: Type TdinVector = Array Of Real; Var dinArr: TdinVector; Если объявленный в секции Var статический массив размещается компилятором в памяти, то объявление динамического массива не приводит к отведению памяти для него. В памяти лишь резервируется 4-байтовая ячейка, в которую будет занесен адрес динамически создаваемого участка памяти. Сам участок памяти для динамического массива создается при выполнении процедуры SetLength. 39 Для создания динамического вектора в функцию SetLength в качестве аргументов передаются имя массива и целая неотрицательная величина, значение которой равно количеству элементов создаваемого массива. Например, результатом вызова SetLength(dinArr, 10); будет выделение для вектора dinArr участка памяти под 10 элементов типа Real и задает всем элементам нулевые значения. Индексы динамического массива всегда целые числа, начинающиеся с нуля. Поэтому в приведенном примере вектор состоит из элементов от dinArr[0] до dinArr[9]. Повторное применение SetLength к уже существующему массиву изменяет его размер. Если новое значение размера больше предыдущего, то все предыдущие значения элементов сохраняются, и в конце массива добавляются новые нулевые элементы базового типа. Если же новый размер меньше предыдущего, то массив усекается «справа» и в нем остаются значения начальных элементов. Усечение можно проводить функцией Copy, присваивая ее результат самому массиву, например, вызов dinArr:= Copy(dinArr, 0, 3); усекает динамический вектор dinArr, оставляя неизменными первые три его элемента. Сама переменная динамического массива (в нашем примере это переменная dinArr) является указателем на начало массива. Если место под массив еще не выделено, значение этой переменной равно Nil. Операция сравнения А = В двух динамических массивов даст True только в том случае, если А и В указывают на один и тот же массив. Но это не совсем обычный указатель. Его нельзя, например, разыменовать операцией ^ , нельзя передать в процедуры New и Dispose. Удалить из памяти динамический массив можно одним из следующих способов: присвоить ему значение Nil, например, dinArr:= Nil; использовать функцию Finalize: Finalize(dinArr); установить функцией SetLength нулевую длину, например, SetLength(dinArr, 0); Динамические массивы могут передаваться в подпрограммы в качестве фактических параметров вместо формальных параметров, объявленных как открытые массивы. Как указывалось выше, физическое существование динамического массива реализуется в специальной области памяти, называемой куча (heap). Эта область предназначена для динамического распределения. Поскольку одномерный динамический массив, как и статический, является последовательной структурой (т. е. участок памяти для массива 40 непрерывен и слоты его элементов смежны), при изменении размеров массива можно наблюдать перемещение участка на новое место в куче. Например, пусть в программе записана следующая последовательность вызовов: SetLength(D, 3); SetLength(С, 4); SetLength(D, 6); где D и С динамические векторы с одинаковым базовым типом. Тогда в результате выполнения второго вызова SetLength(С, 4) участок памяти для С будет создан сразу после участка вектора D, созданного при первом вызове SetLength(D, 3). Третий вызов должен увеличить участок для размещения нового вектора D. Поскольку новый участок должен превосходить старый вдвое, то «старых» ячеек ему будет недостаточно. Поэтому третий вызов приведет к появлению нового участка, содержащего смежные слоты элементов вектора D, и этот участок будет расположен после участка отведенного ранее для вектора С. 5.4.2 Многомерные динамические массивы Многомерный динамический массив размерности М определяется как динамический вектор динамических массивов размерности М1, динамический массив размерности М1 является вектором динамических массивов размерности М2 и т. д. Например, нотации Type TdinMatr = Array Of Array Of Integer; Var arrMatr: TdinMatr; описывают двумерный динамический массив (динамическую матрицу). Один из способов отвести память под такой массив использовать процедуру SetLength. Если требуется зафиксировать диапазоны изменения всех индексов, то в функцию SetLength следует передавать столько размеров, сколько раз повторяется слово Array в объявлении типа создаваемого многомерного динамического массива. Например, оператор SetLength(arrMatr, 3, 5); отводит память под прямоугольную матрицу arrMatr, состоящую из трех строк и пяти столбцов. Индексы существующего динамического массива отсчитываются от нуля. Можно изменять диапазоны некоторых индексов в зависимости от значений других индексов. Например, можно создать матрицу, у которой в зависимости от номера строки варьируется длина строки. В этом случае сначала процедурой SetLength фиксируется 41 диапазон первого индекса. Если диапазон изменения первого индекса нужно установить [0, 2], то следует записать вызов SetLength в виде: SetLength(arrMatr, 3); При выполнении этого вызова в памяти отводится место для трех переменных arrMatr[0], arrMatr[1] и arrMatr[2]. Затем можно задать размер, например, второго из этих массивов, т. е. фактически количество элементов второй строки динамического двумерного массива (строки с индексом 1). Следующий оператор устанавливает этому размеру значение 5: SetLength(arrMatr[1], 5); Программный фрагмент, приведенный ниже, уменьшает размер строки с ростом ее индекса: Var arrMatr: TdinMatr; N, i, j, m: Integer; Begin N:= 3; m:= 1; SetLength(arrMatr, N); For i:= 0 To N-1 Do Begin SetLength(arrMatr[i], N-i); For j:= 0 To N-i-1 Do Begin arrMatr[i, j]:= m; Inc(m); End; End; End; В результате выполнения этого фрагмента формируется логическая структура, показанная на рисунке 5.2 Рисунок 5.2 Логическая структура динамической матрицы arrMatr, содержащей строки различных размеров Физическая структура массива приводится на рисунке 5.3. Как показывает этот рисунок, для всего массива в сегменте статических данных для массива выделяется 4- 42 хбайтовая ячейка, в которую помещается указатель (адрес) на вектор (arrMatr[0], arrMatr[1], arrMatr[2]), элементы которого располагаются в куче, занимая последовательные 4-хбайтовые ячейки. В этих ячейках содержатся ссылки на слоты соответствующих строк, а сами слоты размещаются в куче в непредсказуемом «порядке». Элементы каждой строки располагаются в слотах строки последовательно друг за другом. В ячейки элементов заносятся из значения. Рисунок 5.3 Физическая структура динамической матрицы arrMatr, содержащей строки различных размеров 5.5 Массивы типа Variant Переменной типа Variant, как указывалось в п. 3.3.10 , нельзя присвоить значение обычного статического массива. Это можно сделать только с помощью специальных функций VarArrayCreate и VarArrayOf. Функция VarArrayCreate определена следующим образом: Function VarArrayCreate(Const Bounds: Array Of Integer; VarType: Integer); Здесь параметр Bounds является массивом, содержащим четное количество целых чисел, каждая пара которых определяет диапазон индекса, соответствующего паре. Параметр VarType определяет тип элементов массива. Он может принимать такие значения, как, например, varInteger, varDouble или varString соответствие таких значений и определяемых ими типов приводится в пособиях по Object Pascal. Например, для 43 создания вектора V1 из девяти элементов целого типа и матрицы V2 типа Double (размерности 46), можно воспользоваться следующими операторами: Var V1, V2: Variant; . . . V1:= VarArrayCreate([2, 10], varInteger); V2:= VarArrayCreate([0,3, 0,5], varDouble); Пример создания и использования массива типа Variant дают следующие строки: Var A: Variant; . . . A:= VarArrayCreate([0, 2], varVariant); A[0]:= 12e-6; A[1]:= 20; A[2]:= 'Количество = ' + IntToStr(A[1]); ShowMessage(A[1]); ShowMessage(A[2]); Label1.Caption:= A[2]; Функция VarArrayOf возвращает одномерный массив элементов, задаваемый параметром Values, и имеет следующий интерфейс: Function VarArrayOf(Const Values: Array Of Variant): Variant; Например, можно «превратить» элемент A[2] из вышеприведенного примера в вектор с помощью операторов: A[2]:= VarArrayOf([-1.5, ’Параметр = ’, 20]); ShowMessage(A[2][1] + IntToStr(A[2][2])); 5.6 Вставка и удаление в массиве Основным достоинством массива является прямой доступ к его элементам: для использования элемента нужно указать только имя массива и индекс (или индексы), которые преобразуются в физический адрес элемента. Скорость доступа не зависит от положения элемента в структуре. Недостаток связан с операциями вставки и удаления элементов. Допустим, одномерный массив arrTable используется для хранения в своих элементах полезной информации, причем содержат информацию только начальные элементы, число которых равно Count. Эти «занятые» элементы называются активными, активные элементы имеют индексы в диапазоне [0, LastIndex]. Очевидно, LastIndex = Count - 1. Если, например, в массив нужно вставить новую информацию в элемент с индексом ind, то все элементы с индексами, начиная с ind и до конца активной части, потребуется переместить на одну позицию, чтобы освободить место под вставляемый элемент NewElement. Эта операция может быть описана следующим фрагментом: 44 For i:= LastIndex DownTo ind Do arrTable [i+1]:= arrTable [i]; arrTable [ind]:= NewElement; Inc(Count); Inc(LastIndex); Сдвиг части элементов означает их физическое перемещение в памяти. Логическая схема операции вставки показана на рисунке 5.4 Рисунок 5.4 Вставка в вектор нового элемента: а до вставки, б после вставки Объем памяти, который будет затронут при вставке нового элемента, зависит от значения n и количества сдвигаемых элементов. Время, требуемое на выполнение вставки, зависит от числа активных элементов в массиве: чем больше это количество, тем больше (в среднем) потребуется времени на сдвиг. Тот же ход рассуждений справедлив и для операции удаления активного элемента из вектора. В случае удаления элемента с индексом ind, все элементы, начиная с элемента arrTable[ind+1], должны быть перенесены на одну позицию к началу вектора, чтобы «закрыть» образовавшуюся от удаления элемента «дыру». Логическая схема операции удаления приводится на рисунке 5.5. Программный фрагмент удаления может иметь вид: For i:= ind + 1 To LastIndex Do arrTable [i-1]:= arrTable [i]; Dec(Count); Dec(LastIndex); Таким образом, можно сделать вывод: операции вставки и удаления в массиве выполняются медленнее при увеличении количества элементов. Другой важный недостаток массива связан с фиксацией его размеров. Если необходимо вставить новый элемент в статический массив, который полностью заполнен активными элементами, то произойдет ошибка времени выполнения. Решением проблемы является использование динамического массива, однако приходится все время контролировать 45 текущее число элементов и применять процедуру SetLength при вставке в полностью заполненный массив. Рисунок 5.5 Удаление элемента в векторе: а до удаления, б после удаления 46 6 СВЯЗНЫЕ СПИСКИ И АЛГОРИТМЫ ИХ ОБРАБОТКИ 6.1 Списки и их разновидности В повседневной жизни под термином «список» (list) подразумевают некий перечень объектов реального мира, например, «список студентов группы» или «список комплектующих деталей на сладе». При этом указанный перечень может содержать не только наименование объектов, но и их дополнительные атрибуты. В нашем примере к таким атрибутам можно отнести пол студента, размер стипендии и т. д. С точки зрения организации данных под списком понимают структуру данных, представляющую собой логически связанную последовательность элементов. Элементом списка обычно является запись. Список называется линейным (linear list), если входящие в него элементы e1 , e2 ,…,en линейно упорядочены. Упорядоченность элементов линейного списка может задаваться неявно путем последовательного расположения его элементов как в логической структуре, так и в памяти ЭВМ. Такой список называют последовательным (sequential list). Примером последовательного списка является вектор, в котором элементы используются для хранения информации списка. С другой стороны, упорядоченность может задаваться с помощью специальных указателей (ссылок), располагаемых в элементах и дающих возможность для каждого элемента определить его непосредственных предшественника и последователя. Такой список называется связным (linked) или цепным (chained) списком. Связные списки относятся к классу динамических структур. Количество элементов в них заранее не фиксируется, оно зависит от потребностей решаемой в текущий момент задачи. Поскольку элемент связного списка может быть создан и уничтожен в любой момент времени, существование связного списка реализуется в куче. Элемент связного списка называется обычно узлом (node) Каждый узел связного списка состоит из двух различных по назначению видов полей: содержательные (информационные) поля и поля структурных (или логических) указателей. В содержательных полях хранятся данные, ради которых и создавался список. Некоторые содержательные поля могут хранить указатели на данные, по каким либо причинам не вместившиеся в содержательные поля; такие указатели называются дополнительными или вторичными (secondary pointer). Поля логических или структурных указателей (logical pointer) хранят адреса других элементов списка. Пользуясь логическим указателем (адресом), можно получить доступ от элемента, содержащего этот указатель, к тому элементу списка, на который этот указатель направлен. 47 Определение связного списка отличается от определения массива, в котором следующий элемент находится в памяти рядом с предыдущим. В связном списке элементы могут быть разбросаны по разным местам памяти, а их порядок определяется ссылками. В связном списке каждый узел является отдельным элементом. И, как правило, распределение памяти под каждый узел выполняется отдельно. При необходимости добавления в список нового элемента под него распределяется слот памяти, а затем на этот слот устанавливается структурная ссылка от элемента списка. При удалении узла нужно всего лишь удалить ссылки, направленные на него от других элементов и освободить память, занимаемую его слотом. 6.2 Односвязный список 6.2.1 Общая характеристика односвязного списка Наиболее простой способ объединить или связать некоторое множество элементов это «вытянуть их в линию», то есть организовать односвязный список (singly linked list, one-linked list). В односвязном списке каждый элемент (узел) состоит из информационных полей и поля для размещения единственного структурного указателя. Поле логического указателя хранит адрес в памяти следующего элемента списка. Пользуясь указателем, можно получить доступ к элементу списка, а от него к следующему элементу и т. д., пока не будет достигнут последний элемент. Поле указателя последнего элемента списка должно содержать признак пустого указателя (Nil), свидетельствующий о конце списка. На рисунке 6.1 показаны примеры изображения логической структуры односвязного списка. Рисунок 6.1 Два варианта представления логической структуры односвязного списка Односвязный список всегда является линейным, поэтому его называют часто линейным односвязным списком (linear singly linked list). Свойство линейности односвязного 48 списка определяется линейностью логической упорядоченности его элементов: для каждого элемента (кроме первого и последнего) имеется единственный предыдущий и единственный последующий элементы. Организованный таким образом список называют еще однонаправленным (one-wаy list). Для доступа к желаемому элементу списка в общем случае необходимо просматривать список с его начала, даже если указатель текущего элемента расположен близко от искомого элемента, но после него. В кольцевом односвязном списке (ring, circular, cyclic one-linked list), логическая структура которого представлена на рисунке 6.2, очередной просмотр можно начинать с текущего элемента, поскольку элементы списка «связаны» в кольцо. Для этого в поле логического указателя последнего элемента помещается вместо «пустого» указателя указатель начала списка. Рисунок 6.2 Два варианта представления структуры кольцевого односвязного списка Дескриптор односвязного списка может быть реализован в виде отдельной записи и может содержать такую информацию о списке, как код структуры, имя списка, указатель (адрес) начала списка (First) этот указатель называется указателем списка (list pointer), указатель на текущий элемент (Current), текущее количество элементов в списке, описатель (дескриптор) элемента. 49 Указатель, указывающий на некоторый узел списка, называется курсором этого узла: First это курсор первого элемента списка, Current курсор текущего элемента. В одном из содержательных полей каждого элемента иногда размещают так называемый указатель возврата (backward pointer), ссылающийся на дескриптор. Физическая структура односвязного списка характеризуется физической несмежностью элементов, причем в памяти в любой текущий момент времени между элементами одного списка могут находиться элементы другой динамической структуры. 6.2.2 Структура элемента односвязного списка Перед началом описания операций с односвязным списком рассмотрим структуру его элемента. Структура элемента (узла) может быть определена с помощью следующих нотаций: Type PNode = ^TNode; TNode = Record Key: Integer; Dat: <идентификатор типа данных>; Next: PNode; End; Указатель типа PNode представляет собой указатель на базовый тип TNode. Базовый тип TNode определяет структуру узла односвязного списка: поле Key является полем ключа, идентифицирующего каждый элемент; значения этого поля будем называть метками они помогут нам отличать элементы друг от друга при описании операций в списке; поле Dat это фактически набор полей, описывающих полезные данные; для хранения структурных ссылок предназначено поле Next. Характерной особенностью типа TNode является наличие поля Next, которое должно содержать указатель на данное типа TNode. Записи некоторого типа, содержащие в своих полях указатели на записи такого же типа, называются самоадресующимися записями. А типы самоадресующихся записей называют иногда рекурсивными типами. Для иллюстрирования операций в односвязном списке нам потребуется описание следующих переменных, которые будут использоваться в качестве внешних курсоров: Var First, Current, G: PNode; 6.2.3 Формирование односвязного списка Если указатель списка First равен Nil, то списка еще нет (он пуст). Значит это 50 начальное значение списка. Для его инициализации следует записать: First:= Nil; Следующий программный код формирует список из N узлов: Procedure CreateOneWayList(N: Integer); Var i: Integer; Begin i:= N; While i > 0 Do Begin New(Current); Current^.Next:= First; First:= Current; With Current^ Do Begin Key:= i; <заполнение полей Current^.Dat> End; i:= i-1; End; End; Под управлением процедуры CreateOneWayList список формируется в направлении «от конца к началу», т. е. последний элемент списка будет создан первым. В поля Key заносятся номера узлов. Логическая структура списка, сформированного при N=4, приводится на рисунке 6.3. Рисунок 6.3 Структура односвязного списка, сформированного процедурой CreateOneWayList при N=4 6.2.4 Просмотр односвязного списка Операция просмотра списка, называемая также прохождением, заключается в переходах курсора Current от узла к узлу по структурным указателям, расположенным в поле Next всех узлов. Просмотр начинается от начала списка (от элемента First^) и производится до достижения узла, у которого в поле Next записано значение Nil. 51 Программный фрагмент просмотра имеет следующий вид: Current:= First; While Current <> Nil Do Begin With Current^ Do Begin <действия с полями элемента Current^> Current:= Current^.Next; End; End; 6.2.5 Вставка элемента в односвязный список Для реализации операций вставки и удаления необходимо запрограммировать алгоритмы, предусматривающие несложные манипуляции с указателями. Рассмотрим включение в список после текущего элемента (элемента, заданного указателем Current), когда этот текущий элемент не является ни первым и ни последним элементом списка. На рисунке 6.4 изображена схема, иллюстрирующая первую разновидность операции включения узла с меткой 20. Для организации операции включения требуется дополнительный указатель G (типа PNode). Фрагмент программной реализации этой операции включения может выглядеть так: New(G); G^.Key:= 20; G^.Next:= Current^.Next; Current^.Next:= G; После выполнения операторов первой строки этого фрагмента а) в памяти образуется ячейка, размер которой определяется типом TNode, б) указатель G получает направление («начинает указывать») на созданную ячейку, в) в поле Key этой ячейки заносится значение 20. Результат этих действий показан на рисунке 6.4 б. Новая ячейка идентифицируется как G^. При выполнении присваивания во второй строке в поле Next элемента G^ записывается тот же адрес, который хранится в указателе Next текущего элемента. Иначе говоря, указатель G^.Next начинает указывать на то же место в памяти, что и указатель Current^.Next, т. е. на узел с меткой 3. Это показано на рисунке 6.4 в. Рассмотрение операции вставки можно было бы закончить, приведением логической схемы, показанной на рисунке 6.4 г. Однако эту схему можно преобразовать к более привычному виду односвязного списка, который показан на рисунке 6.5. 52 Рисунок 6.4 Этапы операции включения узла в односвязный список Рисунок 6.5 Результат вставки узла с меткой 20 53 Фрагмент, выполняющий вставку в начало списка, может иметь следующий вид: New(G); G^.Next:= First; First:= G; а для добавления элемента (вставки в конец списка), когда текущим является последний элемент списка, можно использовать следующий код: New(G); G^.Next:= Current^.Next; Current^.Next:= G; 6.2.6 // т.е. G^.Next = Nil Удаление элемента из односвязного списка Для удаления простейшим вариантом является удаление элемента, находящегося после текущего узла. В этом случае необходимо установить, чтобы указатель Next текущего узла, указывавший до удаления на удаляемый элемент, стал указывать на элемент, расположенный после удаляемого. Если стартовая структура такая же, как изображенная на рисунке 6.4 а, то для удаления элемента с меткой 3 можно использовать следующий программный код: G:= Current^.Next;; Current^.Next:= G^.Next; Dispose(G); Процесс и результат выполнения операторов этого фрагмента, показан на рисунке 6.6 Рисунок 6.6 Процесс удаления узла в односвязном списке: а – «переброска» структурного указателя текущего узла, к узлу с меткой 4, расположенному после удаляемого узла; б – уничтожение элемента с меткой 3 Код операции удаления первого элемента списка может выглядеть следующим образом: Current:= First^.Next;; Dispose(First); First:= Current; 54 а с помощью фрагмента, приведенного ниже, удаляется последний элемент односвязного списка, если именно этот последний элемент адресуется курсором Current: G:= First; While G^.Next <> Current Do G:= G^.Next; G^.Next:= Nil; Dispose(Current); Current:= G; Здесь в While-цикле курсор G перемещается к предпоследнему элементу. Затем в поле структурного указателя элемента G^ записывается значение Nil, в результате чего этот элемент становится последним в списке. Удаляемый элемент Current^ уничтожается, и текущим становится новый последний элемент. 6.3 Линейный двухсвязный список Односвязный список обеспечивает возможность продвижения лишь в одном направлении от начала к концу при просмотре списка. Линейный двусвязный список (linear double-linked list) дает возможность двигаться в одном из двух направлений. Он отличается от односвязного тем, что каждый его элемент содержит два логических указателя, один из которых, прямой указатель (direct pointer), адресует, как и в односвязном списке, следующий справа элемент, а другой, обратный указатель (backward роinter), адресует предыдущий элемент списка. Логическая структура линейного двусвязного списка (ЛДС) представлена на рисунке 6.7. Начало и конец такого списка логически эквивалентны, так как доступ к любому элементу может быть осуществлен с любого конца. Поэтому вместо терминов «начало» и «конец» списка используются термины «левый конец» и «правый конец». Рисунок 6.7 Два варианта изображения логической структуры линейного двухсвязного списка 55 В кольцевом двухсвязном списке прямой указатель самого правого узла ссылается на самый левый элемент, и обратный указатель самого левого узла адресует самый правый в логической структуре узел. Логическая структура кольцевого двухсвязного списка представлена на рисунке 6.8 Рисунок 6.8 Кольцевой двухсвязный список 6.3.1 Структура элемента двухсвязного списка Структура узла двухсвязного списка, в общем случае, и, в частности, линейного двухсвязного списка, может задаваться следующими описаниями: Type PNodeDList = ^TNodeDList; TNodeDList = Record Key: Integer; Dat: <идентификатор типа данных>; Dir, Back: PNodeDlist; End; Тип TNodeDList это самоадресующийся тип. Он предусматривает наличие в элементе двухсвязного списка двух структурных указателей: поле Dir предназначено для размещения указателя на следующий правый элемент, поле Back на следующий элемент, расположенный в логической структуре слева. Поле Key введено с целью облегчения дальнейших пояснений. Для иллюстрации операций в ЛДС нам потребуется описание некоторых переменных-курсоров: Var Left, Right, Current, G: PNodeDList; 6.3.2 Реализация операций в линейном двухсвязном списке Пока список пуст, курсоры его концов Left и Right не должны никуда указывать. Следовательно, способ инициализировать двухсвязный список заключается в присваивании этим указателям значения Nil: 56 Left:= Nil; Right:= Nil; Текст программного кода формирования линейного двухсвязного списка из N элементов может быть записан следующим образом: Var i: Integer; Begin i:= N; While i > 0 Do Begin New(Current); Current^.Dir := Left; Current^.Back:= Nil; IF i = N Then Right:= Current Else Current^.Dir^.Back:= Current; Left:= Current; Current^.Key:= i; <заполнение полей Current^.Dat> i:= i-1; End; {While} End; Под управлением этого кода список дополняется элементами в направлении справа налево, т. е. первый созданный элемент окажется концевым правым элементом, а последний расположится на левом конце. Курсор текущего элемента будет ссылаться на левый концевой элемент. Программа просмотра ЛДС аналогична программе просмотра односвязного списка. Необходимо только указать от какого конца, левого или правого, следует начинать продвижение по узлам. Например, если требуется выполнить просмотр списка слева направо, то можно использовать следующий фрагмент: Current:= Left; While Current <> Nil Do Begin With Current^ Do Begin <действия с полями элемента Current^> End; Current:= Current^.Dir; End; Для прохождения узлов справа налево в приведенном фрагменте нужно заменить идентификаторы Left на Right и Dir на Back. Рассмотрим наиболее общий случай вставки, когда элемент, рядом с которым в логической структуре вставляется новый элемент, не является кольцевым элементом. Коды, реализующие дополнение линейного двухсвязного списка со стороны одного из концевых узлов, во многом напоминают вставку в начало односвязного списка. Итак, фрагмент программы вставки справа от текущего элемента, расположенного в средней части ЛДС, может содержать следующую последовательность операторов: 57 New(G); G^.Key:= 20; G^.Dir:= Current^.Dir; Current^.Dir:= G; G^.Back:= Current; G^.Dir^.Back:= G; На рисунке 6.9 а показана логическая схема, изображающая ЛДС из трех элементов и элемент G^, созданный в результате выполнения первой строки рассматриваемого фрагмента. А на рисунке 6.9 б показана логическая структура, сформированная в после завершения вставки. Рисунок 6.9 Операция вставки в ЛДС Для иллюстрации операции удаления приведем фрагмент кода, при выполнении которого удаляется текущий элемент с перемещением его указателя на один элемент направо. Этот фрагмент может иметь вид: G:= Current; Current:= Current^.Dir; G^.Back^.Dir:= Current; Current^.Back:= G^.Back; Dispose(G); 58 6.4 Плексы 6.4.1 Нелинейный двухсвязный список Односвязный список всегда линейный. Двусвязный же список может быть и нелинейным, о чем свидетельствует следующий пример. Представим себе, что двухсвязный список формируется из данных об абитуриентах ВУЗа. Причем первый указатель связывает элементы списка в порядке подачи абитуриентами документов в приемную комиссию, а второй – устанавливает упорядоченность элементов, соответствующую алфавитному порядку фамилий абитуриентов. Тогда, если к текущему моменту времени заявления о поступлении в ВУЗ подали шестеро абитуриентов, двусвязная структура, состоящая из данных об абитуриентах, может принять вид, изображенный на рисунке 6.10. Рисунок 6.10 Логическая структура нелинейного двухсвязного списка Ссылки List1 и List2 являются указателями двух отдельных односвязных списков, в каждый из которых одновременно входят все шесть элементов. Поля Р1 и Р2 всех элементов служат для хранения структурных ссылок, с помощью которых элементы связываются, формируя тем самым каждый из двух списков. На рисунке 6.10 показано лишь одно из содержательных полей узлов, а именно, поле фамилии абитуриента (объекта предметной области). Из рисунка видно, что в списке, сформированном с помощью ссылки Р1, установлен следующий порядок следования узлов: Фокин Лавров Бойко Яшина Власова Жеглов В цепном списке, указателем которого является List2, этот список сформирован с помощью структурной ссылки Р2 элементы располагаются в следующем порядке: Бойко Власова Жеглов Лавров Фокин Яшина 59 Представление логической структуры этого же двухсвязного списка может быть преобразовано к другому, эквивалентному, виду, который показан на рисунке 6.11. Рисунки 6.10 и 6.11 отображают структуру одного и того же нелинейного двухсвязного списка. Сопоставление рисунков 6.10 и 6.11 показывает, что оба цепных списка двухсвязной структуры, рассматриваемые по отдельности, являются линейными. Вся структура в целом, с учетом обеих связок, не линейна Рисунок 6.11 Эквивалентная логическая структура нелинейного двухсвязного списка, изображенного на рисунке 6.10 6.4.2 Нелинейный трехсвязный список Несложно представить трехсвязный список, содержащий информацию об абитуриентах, в котором две структурных ссылки объединяют узлы так же, как на рисунках 6.10 и 6.11, а третья ссылка Р3 связывает те элементы (в алфавитном порядке), которые относятся к абитуриентам, обладающим правами преимущественного поступления в ВУЗ. Логическая структура такого списка показана на рисунке 6.12. На этом рисунке штриховыми линиями показаны структурные ссылки, объединяющие элементы с помощью поля Р3. Назовем этот цепной список Р3-списком. В цепном списке, организованном с помощью структурного указателя Р3, очевидно, будет содержать не все элементы. В нашем примере (см. рисунок 6.12) в Р3-списке содержится три элемента, которые упорядочены ссылкой Р3 следующим образом: Бойко Фокин Яшина Указателем Р3-списка является внешний указатель List3. Этот пример показывает, что необязательно, чтобы каждый элемент общей списковой структуры непременно входил во все цепные списки одновременно. 60 Рисунок 6.12 Логическая структура нелинейного трехсвязного списка 6.4.3 Определение плекса и его общие признаки В общем случае возможно создание многосвязного списка, каждый элемент которого может содержать К полей (К = 2, 3, 4 …) структурных указателей. Как показывают логические структуры нелинейных связных списков, изображенные на рисунках 6.10 6.12, многосвязный список как бы «прошит» в разных направлениях многими указателями. Поэтому такие списки называют прошитыми списками или плексами (plexus сплетение, переплетение). Сформулируем общие признаки плексов: 1) все элементы такой структуры содержат одинаковое количество полей структурных указателей, число которых К степень связности является важной характеристикой структуры; 2) не обязательно, чтобы каждый элемент общей структуры входил во все К цепных списков одновременно; 3) каждый отдельный список, организованный с помощью одной и той же ссылки, поле для которой имеется во всех элементах, является односвязным цепным, а значит и линейным списком, если не принимать во внимание другие связи; 4) на каждый элемент может ссылаться произвольное число других элементов структуры, и от любого элемента к другим элементам может быть направлено произвольное число указателей, но в обоих случаях число таких указателей-ссылок не превышает К; 5) вся структура в целом не линейна, поскольку, если учитывать все связки, для каждого элемента не может быть определен единственный элемент-предшественник и единственный элемент-последователь. 6.5 Иерархическая списковая структура. Сетевая структура Многосвязный нелинейный список может быть организован как иерархическая списковая структура (hierarchical structure), логическую организацию которой проиллюстри- 61 руем следующим примером. Пусть на факультете высшего учебного заведения имеется 5 групп. Кроме того, каждая группа состоит из множества студентов, причем группы различаются количеством студентов. Для представления информации о группах и студентах можно предложить следующую информационную модель: формируется список (назовем его Main-списком), элементы которого содержат информацию о группах: номер группы, число студентов, староста и т. д., каждый элемент Main-списка «дает начало» дополнительному списку (Sub- списку), состоящему из элементов, каждый из которых содержит информацию об отдельном студенте группы. Очевидно, эта модель предполагает существование такого количества Sub-списков, которое равно числу групп. Каждый Sub-список как бы подчиняется Main-списку. Для представления описанной модели в памяти можно использовать следующую структуру. Пусть указатель Faculty адресует начало главного цепного списка, каждый элемент которого, кроме информации о группах факультета, содержит два указателя Main и Sub. Указатель Main связывает элементы главного списка, а указатель Sub каждого элемента главного списка ссылается на начало другого списка, который содержит информацию о студентах соответствующей группы. Такая структура показана на рисунке 6.13. Рисунок 6.13 Двухуровневый иерархический связный список Как видно из рисунка 6.13, каждый список, адресуемый указателем Sub как бы «подчиняется» элементу главного списка; такой список, «ответвляющийся» от главного 62 списка, называется подсписком (sublist). Очевидно, в каждом подсписке может располагаться произвольное число элементов, в общем случае не равное для всех подсписков. Список, организованный указателем-связкой Main, образует верхний уровень иерархии, а списки, на начало которых указывают указатели Sub нижний уровень. Такой список называется двухуровневым связным списком. Нетрудно представить себе увеличение количества уровней, причем как со стороны верхнего уровня, так и со стороны нижнего уровня. Например, для информационной системы ВУЗа целесообразно создать список, объединяющий информацию всех факультетов; тогда «над» Main-списком появится новый список, который становится главным, а вся структура в целом будет иметь три уровня. Если в дополнение к этому пользователя интересует информация о членах семьи каждого студента, то каждый элемент нашего Sub-списка должен стать началом подсписка, содержащего в своих элементах данные о родственниках соответствующего студента. Тогда структура станет четырехуровневой. В общем случае структура, элементы которой размещены на двух или более уровнях иерархии, называется многоуровневой структурой (multilevel structure). К классу таких структур относятся, в частности, многоуровневая запись или дерево. Нетрудно представить себе такую иерархическую структуру, в которой на верхнем уровне «расположен» многосвязный нелинейный список, а элементами подсписков являются деревья или векторы. Поскольку общая структура, образованная таким образом, будет получена путем композиции разных структур, она называется комбинированной структурой (combined structure). Дальнейшим обобщением многосвязной списковой структуры является сеть (network) или сетевая структура. Она характеризуется следующими свойствами: различные элементы сети могут иметь различные форматы, т.е. состоять из разно- го количества полей; каждый элемент сетевой структуры содержит произвольное количество структур- ных ссылок на другие элементы; на любой элемент может ссылаться произвольное число других элементов; каждая связка в сети может иметь не только направление, но и вес. Логически сеть эквивалентна взвешенному ориентированному графу общего вида. Поэтому названия «сетевая структура» или «сеть» обычно заменяют термином «графовая структура», и вместо названий «элемент» и «структурная ссылка» используют названия «узел» и «ребро». 63 6.6 Достоинства и недостатки связных списков Перечислим преимущества связных списков. 1) Важным преимуществом связных список является независимость быстродействия операций включения и удаления от количества элементов. В подразделе 6.5, где обсуждаются особенности операций вставки и удаления в массиве, показано, что эти операции требуют выполнения сдвига, т. е. физического перемещения в памяти группы элементов. Аналогичные операции в связных списках вызывают простое изменение значений адресов, хранящихся в самих элементах. 2) На основе концепции связности элементов можно создавать бесконечное множество различных структур данных, что позволяет формировать в памяти ЭВМ разнообразные информационные модели для различных предметных областей. 3) Благодаря использованию динамических связных списков экономится оперативная память ЭВМ. В связном списке в любой текущий момент времени существует ровно столько элементов, сколько требуется для решаемой задачи. 4) Динамические связные структуры создаются, хранятся и обрабатываются в самой большой по объему области оперативной памяти куче (heap). Это дает возможность с помощью таких структур решать серьезные задачи, требующие много памяти. Основным недостатком связных списков является то, что затраты времени на получение доступа к их элементам зависят от количества элементов. Для получения доступа к некоторому i-ому элементу необходимо выполнить переходы по ссылкам вплоть до искомого элемента. Чем больше элементов в списке, тем больше переходов придется совершить. Второй недостаток связан с произвольным физическим размещением элементов списка в куче. Поскольку в общем случае вставки и удаления элементов выполняются в непредсказуемом порядке, узлы списка будут разбросаны по различным страницам виртуальной памяти. Это влияет на эффективность выполнения операций. Наконец, в элементах связных списков приходится размещать структурные ссылки, число которых зависит от степени связности К. Для К-связной структуры затраты памяти на ссылки составляют К*SizeOf(Poiner) байт на каждый элемент. 6.7 Функциональные и свободные списки 6.7.1 Общая характеристика функционального и свободного списка Важнейшие операции над связными списками включение и исключение элементов. Список, уже сформированный к текущему моменту времени и хранящий в содержатель- 64 ных полях своих элементов полезную информацию, называют функциональным списком. Свободным списком называют такой дополнительный список элементов (или один элемент), который служит источником памяти при формировании функциональных списков. Каждый элемент свободного списка имеет такой же формат полей, как и элемент функционального списка, но содержимое информационной его части не определено. Свободный список формируется из элементов, исключаемых из функционального списка. Операцию исключения можно изобразить с помощью рисунка 6.14, на котором показана операция исключения третьего узла. Если на удаляемом элементе не сохранить указатель (как на рисунке 6.14 6), то его слот перестанет быть адресуемым, то есть в куче появится неадресуемое пространство «дыра». С целью экономии памяти исключенный элемент следует включить в список, сформированный из других исключенных элементов одинакового формата, то есть в свободный список. В дальнейшем прежде чем добавить в функциональный список новый элемент, следует проверить соответствующий свободный список, и, если в свободном списке есть элементы, то взять элемент из него, а не создавать в памяти новый элемент. а б в Рисунок 6.14 Исключение в связном списке В Delphi распределением и освобождением памяти занимается очень сложный диспетчер кучи. Диспетчер кучи содержит код, который позволяет манипулировать кусками памяти. Диспетчер кучи Delphi использует свободный список освобожденных блоков памяти различного размера. Некоторые массивы в Delphi используют цепочку удаленных элементов, которая является ни чем иным, как другим названием свободного списка. Многие базы данных поддерживают скрытый свободный список удаленных записей, которые можно использовать повторно. 65 6.7.2 Методы формирования свободного списка Каждый элемент многосвязной структуры может входить в несколько односвязных списков. Исключение элемента из одного такого списка еще не означает, что его можно поместить в свободный список элементов. Проблема возврата элементов многосвязной списковой структуры в свободный список может реализоваться разными методами. Метод счетчиков сводится к тому, что после операции исключения элемента из того или иного списка делается проверка, остался ли этот элемент хотя бы в каком-либо одном функциональном списке, и в случае отрицательного ответа элемент присоединяется к свободному списку (т. е. удаляется из связного функционального списка). Метод предполагает наличие в каждом элементе многосвязной структуры специального поля счетчика. Содержимое этого поля увеличивается на единицу при установлении каждой новой ссылки, направленной к элементу, и уменьшается на единицу при исчезновении такой ссылки. Как только счетчик станет равен нулю, элемент помещается в свободный список. В методе сборки мусора (garbage collection) не требуется при разрыве каждой связки делать проверку элемента и возвращать освободившийся элемент в свободный список. Освободившийся элемент как бы «повисает» до тех пор, пока не будет исчерпан свободный список. И только после этого запускается процедура «сборки мусора», которая отыскивает все освободившиеся к тому времени элементы и включает их в свободный список. Для реализации метода сборки мусора в каждом элементе многосвязной структуры резервируется минимально возможное поле памяти для маркера, который используется на этапе поиска освободившегося элемента. С этой целью процедура маркирует все доступные элементы многосвязной структуры, т. е. такие элементы, к которым направлен хотя бы один указатель. Затем просматривается вся область памяти, отведенная для элементов, и в свободный список включаются все элементы, в которых отсутствует маркер. Диспетчер кучи Delphi создает список свободных областей (free space list) по мере выполнения процедур «уничтожения» динамических переменных, таких, например, как Dispose. 66 7 СТЕКИ, ОЧЕРЕДИ, ДЕКИ И ОПЕРАЦИИ В НИХ 7.1 Общая характеристика Рассматриваемые в данном разделе структуры данных характеризуются ограниченным доступом к своим элементам для операций включения и исключения. До относительно недавнего времени такие структуры находили применение в основном при выполнении операций, организуемых на аппаратном уровне. При таком подходе элементы структуры располагаются последовательно, занимая непрерывный участок памяти. Примеры последовательной структуры с ограниченным доступом дают стеки оперативной памяти и буферные области, используемые в операциях обмена с внешними устройствами. Со временем выяснилось, что структуры с ограниченным доступом весьма удобны для процедур, не требующих последовательного размещения элементов (проверка скобочной структуры различных выражений и последовательностей операторов, операции по преобразованию строк и т. п.). Были разработаны и нашли применение разнообразные основанные на связных структурах классы, в которых методы ограничивают доступ по вставке и удалению. Ниже приводится «классическое» описание, основанное на следующих постулатах: 1) структура существует и изменяется в пределах ограниченной области заранее отводимой памяти, в общем случае элементами занята только часть ячеек, отведенных для структуры; 2) элементы структуры в каждый текущий момент времени занимают сплошной участок памяти, т. е. структура является последовательной структурой; 3) для операций включения и исключения доступны только концевые элементы структуры, иными словами, внутренние части участка памяти, содержащего «полезные» элементы е1, е2, …, еn, для этих операций недоступны. С точки зрения организации такая структура обладает, с одной стороны, таким признаком статической структуры, как последовательное расположение элементов, и, с другой стороны, способностью изменять количество своих элементов свойство динамических структур. 7.2 Стек Стек (stack) это такой последовательный список е1, е2, …, еn с переменной длиной n, включение в который нового элемента и исключение из него выполняется только с одной стороны. Говорят, что стек функционирует по принципу LIFO (Last In First Out, т. е. 67 «последним пришел первым вышел»). Примером стековой организации является винтовочный магазин: патрон, вставленный в него последним, при стрельбе «выйдет» первым. Логическая структура стека представлена на рисунке 7.1. Элементы стека могут иметь как одинаковые, так и различные размеры. Последний добавленный в стек элемент еn, называется вершиной стека (top of stack). Важной составляющей структуры стека является указатель, направленный к вершине, этот указатель называется указателем стека (stack pointer). Элемент, непосредственно примыкающий к нижней границе, называется дном стека (bottom of stack). Рисунок 7.1 – Логическая структура стека Для хранения стека в памяти отводится сплошная область памяти, называемая сегментом стека. Граничные адреса этой области памяти являются параметрами физической структуры стека. Физическая структура стека, показанная на рисунке 7.2, обычно дополняется дескриптором. Важнейшие операции в стеке – включение и исключение. Применительно к стеку операцию включения обычно называют заталкиванием (push) элемента, а операцию исключения выталкиванием (pop). Процедура заталкивания в стек нового элемента организуется как последовательность следующих действий: указатель стека сначала перемещается «вверх» (по рисунку 7.1) к слоту включаемого элемента, а затем по новому значению указателя помещается содержимое включаемого элемента. При выталкивании из стека сначала извлекается содержимое элемента еn, а затем указатель стека перемещается «вниз» на длину слота исключаемого элемента. Таким образом указатель стека обслуживает динамику изменения стека. 68 Рисунок 7.2 – Физическая структура стека Если в процессе заполнения отведенной области указатель стека, перемещаясь к верхней границе, выходит за эту границу, то происходит переполнение стека (stack overflow). Попытка выполнить исключение элемента из пустого стека приводит к ошибке опустошения (underflow). Переполнение и опустошение стека рассматривается как исключительные ситуации, требующие (либо от пользователя, либо от логики аппаратуры) выполнения некоторых действий по их ликвидации. Сегмент стека важная область памяти, которую, наряду с сегментами кода и данных, создает компилятор, реализуя программу в виде исполняемого приложения. При работе в реальном режиме сегмент стека ограничивается 64 Кбайт, тогда как в защищенном режиме этот сегмент может занимать до 4 Гбайт. Сегмент стека можно увеличить или уменьшить до определенного размера, но он есть всегда. Типичным применением стека является запись в него параметров, передаваемых в подпрограмму при ее вызове, и освобождение памяти стека от этих параметров после завершения работы подпрограммы. 7.3 Очередь Очередь (queue) – это такой последовательный список с переменной длиной, в который включение элементов происходит с одной стороны, а исключение – с другой стороны. Она функционирует по принципу FIFO (First In First Out, т.е. «первым пришел первым вышел»). Та сторона, с которой осуществляется добавление элементов, называется 69 хвостом (tail) или концом очереди, другая сторона, из которой выполняется удаление, – головой (head). Для индикации хвоста и головы организуется два указателя: указатель хвоста (tail pointer) и указатель головы (head pointer). Логическая структура очереди, которую принято изображать в горизонтальной «ориентации», показана на рисунке 7.3. Обратите внимание, что указатель хвоста ссылается на свободную ячейку, следующую за элементом еn, включенным в очередь последним. Рисунок 7.3 – Логическая структура очереди Для очереди выделяется конечная последовательность ячеек, из которых в каждый текущий момент времени элементами очереди занята лишь часть последовательных ячеек. Каждый элемент очереди обычно представляет запись с одинаковой для всех элементов организацией. Основные операции в очереди включение и исключение элемента. При включении новый элемент заносится в ячейку, адресуемую указателем хвоста, после чего указатель хвоста «перемещается» к следующему свободному элементу. При исключении из очереди извлекается элемент е1, адресуемый указателем головы, и этот указатель «передвигается» к элементу е2, который становится головным элементом. Признаком пустой очереди является равенство указателей хвоста и головы. В процессе выполнения неоднократных включений и исключений наблюдается перемещение всей очереди к границе хвоста, причем это происходит независимо от того, исключаются элементы или не исключаются. В отличие от стека, скорость перемещения к той границе, откуда включаются элементы, не зависит от интенсивности операций исключения. Состояние переполнения возникает при выходе указателя хвоста за пределы отведенного участка памяти. Этот недостаток устранен в кольцевой очереди, логическая структура которой показана на рисунке 7.4. 70 В кольцевой очереди соблюдается дисциплина FIFO. В отличие от обычной очереди включение в кольцевую очередь организовано следующим образом: сразу после выхода указателя хвоста за пределы его границы он переводится на слот, расположенный «вплотную» к границе, расположенной со стороны головы и, если этот слот пуст, то в него включается новый элемент. На рисунке 7.4 показана ситуация, когда перемещение указателя хвоста к начальному слоту происходит при включении элемента е4, а затем элемента е5. Очевидно, в кольцевой очереди возможно любое соотношение между указателями головы и хвоста. Рисунок 7.4 – Логическая структура кольцевой очереди Очереди находят интенсивное применение в вычислительной технике, например, при буферизации данных в устройствах. Буфер представляет собой набор внутренних ячеек памяти с определенными правилами доступа как со стороны устройства, так и со стороны компьютерного «центра» (программы, исполняемой процессором). Для устройств с потоковой передачей данных (принтеры, сканеры) буфер ставится между «центром» и устройством, с одной стороны он наполняется, с противоположной опорожняется. Тем самым реализуется принцип обычной очереди. Опорожняющая сторона может извлекать данные из буфера, лишь когда наполняющая сторона их туда «положит». Логика буфера следит за степенью заполнения буфера и сообщает процессору о критических ситуациях, препятствуя переполнению или опустошению. По принципу кольцевой очереди функционируют, например, буферы некоторых устройств с блочным обменом данными (диски, адаптеры локальных сетей). Наиболее известным примером кольцевой очереди является структура, называемая буфером клавиатуры (буфер BIOS для записи кодов нажимаемых клавиш). 7.4 Дек Особым типом последовательного списка с переменной длиной является дек (deque). Другие названия дека двухсторонняя очередь, очередь с двумя концами (double ended 71 queue). Дек – это такой последовательный список, в котором как включение, так и исключение могут выполняться с любого из двух концов. Частные случаи дека – дек с ограниченным входом (когда исключение возможно из обоих концов, а включение только с одного) и дек с ограниченным выходом. Во втором случае включение выполняется с обоих концов, а для исключения доступен только один концевой элемент. Структура дека аналогична структуре обычной очереди. Однако применительно к деку вместо хвоста и головы говорят о правом и левом концах. 72 8 ДИНАМИЧЕСКИЕ МНОЖЕСТВА И СЛОВАРИ 8.1 Общая характеристика В разделе 1 (см. подраздел 1.2) структура данных определяется как множество данных и отношений между ними. Множество – это фундаментальное понятие как в математике, так и в теории вычислительных машин. Тогда как математические множества остаются неизменными, множества которые обрабатываются в ходе выполнения алгоритмов, могут с течением времени разрастаться, уменьшаться, изменять отношения между элементами или подвергаться другим изменениям. Такие множества называются динамическими (dynamic) множествами. Динамические множества представляются динамическими структурами данных. Некоторые алгоритмы, предназначенные для обработки множеств, требуют выполнения операций нескольких различных видов. Например, во многих алгоритмах используется набор операций, который ограничивается возможностью вставлять элементы в множество, удалять их, а также проверять, принадлежит ли множеству тот или иной элемент. Для выполнения последней (третьей) из названных операций во множестве организуется поиск. Динамическое множество, поддерживающее перечисленные операции, называется словарем (dictionary). Первоочередное назначение словаря заключается в хранении элементов таким образом, чтобы их можно было легко и быстро обнаружить с помощью ключей. Мотивом для поиска является еще и то, что обычно каждый элемент в словаре помимо поискового ключа хранит дополнительную информацию, однако единственным способом ее получить является ключ. Например, словарь может хранить банковские счета. Каждый счет является объектом, который идентифицируется номером счета и хранит множество дополнительных сведений, включающих текущий баланс, имя и адрес владельца, историю вкладов и расходов. Приложение, которое будет работать со счетом, должно предоставлять его номер в качестве поискового ключа для получения информации, соответствующей счету из словаря. Компьютерный словарь подобен своему бумажному аналогу в том смысле, что оба используются для поиска информации. Однако компьютерный словарь более динамичен, так как поддерживает операции вставки, удаления и изменения содержимого элементов. Следовательно, программный тип данных, определяющий словарь, должен иметь методы вставки, удаления и поиска элементов по ключам. 73 Таким образом, словарь – это динамическое множество элементов, которые идентифицируются значениями ключей. Кроме ключа, каждый элемент словаря содержит сопутствующие данные (satellite data), которые находятся в других его полях, но не используются реализацией множества. Формально словарь D определяется как контейнер пар «ключ-элемент» (key, e), где key – ключ, а е – элемент. 8.2 Таблица – логическое представление словаря Логически словарь представляется двумерной структурой, которая называется таблицей. Таблица – это множество записей (строк) одинакового формата. В дальнейшем будем считать, что все записи таблицы – одноуровневые. Каждая запись, входящая в таблицу является элементом таблицы. В реляционной алгебре элемент таблицы называется кортежем. Логической структурой таблицы является двумерная фигура, изображаемая в виде последовательности расположенных друг под другом строк одинаковой длины, соответствующих записям таблицы. Одно и то же поле всех записей таблицы образует ее графу или столбец. Пример таблицы приводится на рисунке 8.1. Рисунок 8.1 Структура таблицы Имя столбца (имя одного и того же поля всех записей) называется атрибутом, а индивидуальные значения, появляющиеся в отдельных записях, – значениями атрибута. В памяти таблица может быть организована по-разному: это может быть вектор записей, плекс, дерево и т. д. В любом случае структура таблицы представляется пользователю, оперирующему с таблицами, в таком виде, который показан на рисунке 8.1. С каждой записью ассоциируется некоторый ключ (key), который используется для того, чтобы отличить одну запись от другой. Первичный ключ (master, unique key) определяется как такой атрибут, или набор атрибутов, который может быть использован для од- 74 нозначной идентификации конкретной записи. Говорят, что первичный ключ является уникальным, т. е. никакие две записи в таблице не имеют одинакового значения первичного ключа. Первичный ключ не должен иметь дополнительных атрибутов. Это значит, что если произвольный атрибут исключить из первичного ключа, то оставшихся атрибутов будет недостаточно для однозначной идентификации отдельных элементов таблицы. Не следует путать первичный ключ с главным ключом (major key), по которому записи при сортировке упорядочиваются в первую очередь. Поскольку любое поле записи может служить в качестве ключа в каком-либо конкретном приложении, то ключи не всегда должны быть уникальными. Например, если в некоторой таблице с фамилиями и адресами название города используется как ключ для некоторого поиска, то он, возможно, не будет уникальным, так как в таблице могут быть две записи с названием одного и того же города. Такой ключ называется вторичным ключом (secondary, nonunique key). Соответствие между записью и ключом может быть простым или сложным. В простейшем случае ключ представляется некоторым полем (или набором полей) внутри записи, располагающимся, возможно, с некоторым конкретным сдвигом от начала записи. Такой ключ называется внутренним ключом или встроенным ключом (built-in key). В других случаях ключом является относительная позиция записи внутри таблицы, или имеется некоторая отдельная таблица ключей, которая содержит указатели на записи. Такие ключи называются внешними ключами. В реляционной базе данных каждая таблица хранится в отдельном файле. Поэтому таблицу в литературе часто называют файлом. Структура такого файла довольно проста, поскольку все записи имеют одинаковый формат. 8.3 Операции в динамических множествах Операции динамического множества можно разбить на две категории: запросы (queries) и модифицирующие операции (modifying operation). Запросы просто возвращают информацию о множестве, а модифицирующие операции изменяют множество. В каждом конкретном приложении требуется реализация только некоторых из операций. Практически операции реализуются в виде методов класса множества. Примеры некоторых запросов приводятся ниже: 1) size возвращает количество элементов в D, 2) isEmpty выдает признак пустого множества, если в D нет элементов, 3) findElement(х) возвращает элемент с ключом, равным х, 4) findAllElements(х) возвращает все элементы с ключами, равными х, 75 5) minimum возвращает указатель на элемент в D с наименьшим ключом, 6) maximum возвращает указатель на элемент в D с наименьшим ключом. Перечислим некоторые методы для модифицирующих операций: 1) clear удаляет все элементы из множества, 2) insertItem(p) пополняет заданное множество одним элементом, на который указывает р (обычно предполагается, что выполнена предварительная инициализация полей вставляемого элемента), 3) removeItem(х) удаляет из заданного множества элемент с ключом, равным х, 4) sortingElements сортирует множество D. 76 9 ДЕРЕВЬЯ 9.1 Общая характеристика и некоторые определения Любая связная структура может быть представлена ориентированным графом, в любой узел которого может входить любое количество связок, исходящих из других узлов. Такие структуры называются в общем случае сетевыми (графовыми) структурами или сетями. Древовидные структуры или просто деревья являются частными случаями сетевых. Элементы структуры в соответствии с терминологией теории графов называют узлами (node). Деревом (tree) называется сетевая структура данных, которая характеризуется следующими свойствами: 1) существует единственный элемент (узел), на который не ссылается никакой другой элемент и который называется корнем (root); 2) начиная с корня и следуя по определенной цепочке структурных указателей, задающих непосредственных предшественников и последователей, можно осуществить доступ к любому узлу дерева; 3) на каждый элемент дерева, кроме корня, имеется единственный структурный указатель от другого элемента. Определенная таким образом структура называется корневым деревом (sink tree). На рисунке 9.1 представлен древовидный граф, отражающий отношения между элементами, информационные поля которых содержат символы-метки А, В, ..., Н. Как видно из этого рисунка, деревья принято рисовать перевернутыми (т. е. «растущими сверху вниз»), при этом корень оказывается самым верхним узлом. Рисунок 9.1 – Логическая структура дерева 77 Линию связи между парой узлов дерева называют ветвью (branch), а сами узлы вершинами (vertex). Те узлы, которые не ссылаются ни на какие другие узлы дерева, называются листьями (leaf) или концевыми (end vertex), висячими (dangling vertex) вершинами. Узел, не являющийся листом или корнем, называется узлом ветвления или промежуточным (внутренним) узлом. Если в дереве вершина Х ссылается на вершину Y, то Х называют родителем (parent node) вершины Y, а Y сыном или дочерней вершиной (child node) вершины Х. Если же вершина Х ссылается на узлы Y1, Y2, ... , Yn, то последние называются сыновьями вершины Х. Все вершины, имеющие общего родителя, называются братьями. Очевидно, любая ветвь дерева устанавливает между двумя вершинами отношение «родитель сын». В дереве обычно задается порядок старшинства братьев: самый левый на графе из братьев считается старшим братом (старшим сыном общего родителя); порядок старшинства убывает слева направо. Как правило, старшинство братьев задается с помощью переменной, называемой индексом старшинства, например, Y1 - самый старший из братьев (индекс равен 1), Yn самый младший брат. Если в дереве задан порядок старшинства братьев, то такое дерево называется упорядоченным. Если порядок старшинства игнорируется, то дерево называется неупорядоченным. На рисунке 9.2 изображены различные упорядоченные деревья. Рисунок 9.2 – Два различных упорядоченных дерева Число сыновей у родителя Х называется степенью исхода (или просто степенью) вершины Х. Максимальная степень всех вершин называется степенью дерева. Дерево называется m-арным, если его степень равна m. Если степень исхода каждой вершины одного и того же дерева равна либо m, либо нулю, то такое m-арное дерево называется полным; в противном случае дерево является неполным m-арным деревом. Изображенное на рисунке 9.1 дерево имеет степень 3; оно является неполным троичным (3-арным) деревом. Дерево степени 2 называется бинарным или двоичным деревом. Все вершины, которые встречаются на пути от корня до вершины Х, называются предками вершины Х. С другой стороны, все вершины, для которых вершина Y является 78 предком, называются потомками вершины Y. Родитель вершины Х называется непосредственным предком для Х; вершина-сын называется непосредственным потомком. Таким образом, дерево можно определить как корень со всеми его потомками. Путем от вершины Х к вершине Y называется последовательность вершин, которые встречаются при перемещении по ветвям от Х к Y. Количество ветвей, которые нужно пройти по пути от вершины Х к вершине Y называется длиной этого пути. Длина пути от корня до некоторого узла Х называется уровнем яруса или, просто, уровнем узла Х. (Уровень узла дерева иногда называют глубиной этого узла). Уровень корня равен нулю, уровень узла Е на рисунке 9.1 равен 2. Сумма длин путей для всех элементов называется длиной внутреннего пути. Высотой узла Х называется длина самого длинного пути от Х до какого- либо листа. Например, высота вершины В на рисунке 9.1 равна 2, а высота вершины D равна 1. Высота дерева совпадает с высотой его корня. Деревья, использующие указатели для связи вершин, являются ориентированными. Структура дерева такова, что каждая его внутренняя вершина является корнем древовидной структуры, называемой поддеревом (subtree) исходного дерева. Действительно, если в некотором дереве убрать его корень и ветви, соединяющие его с вершинами первого уровня, то получим набор поддеревьев, корни которого это вершины первого яруса исходного дерева. Множество несвязанных между собой деревьев называется лесом (forest). Если развернуть логическую структуру односвязного списка (см. рисунок 6.1) на 90 градусов так, чтобы начальный элемент оказался наверху, то получим дерево, в котором каждая вершина имеет не более одного поддерева. Такое дерево (оно имеет степень 1) называется вырожденным деревом. 9.2 Представление дерева в памяти 9.2.1 Естественное представление дерева Дерево может быть представлено в машинной памяти в форме многосвязного списка, в котором каждый логический указатель соответствует ребру древовидного графа. При таком представлении каждому узлу дерева ставится в соответствие элемент многосвязного списка, причем в каждом элементе резервируются следующие поля: поля, содержащие полезные данные; поле степени исхода; 79 поля логических указателей; их общее число в одном узле равно степени дере- ва, но в каждый момент времени количество непустых логических указателей равно степени исхода соответствующей вершины дерева. На рисунке 9.3 показана логическая структура спискового представления дерева, изображенного на рисунке 9.1. Такое представление называют естественным представлением дерева. Все элементы дерева при этом имеют один и тот же тип. Тип элемента дерева, назовем этот тип Tvertex, называется базовым типом для дерева. Рисунок 9.3 Естественное представление дерева Теперь можно сформулировать рекурсивное определение дерева: дерево типа Tvertex это а) либо пустое дерево, б) либо некоторая вершина типа Tvertex, называемая корнем, с конечным числом связанных с ней поддеревьев, имеющих базовый тип Tvertex. При естественном списковом представлении дерева обязательно должен быть организован указатель (курсор) на корневую вершину, обеспечивающий доступ к дереву. На рисунке 9.3 курсором корня является указатель Root. Иногда в элементе списка, соответствующем вершине дерева, размещают указатель, направленный к родительскому узлу, это упрощает реализацию некоторых алгоритмов обработки деревьев. Базовый тип (Tvertex) и курсор корня (Root) для дерева степени 3 можно описать нотациями языка Pascal, представленными ниже. Очевидно, при таком объявлении типа Tvertex количество полей для размещения структурных указателей фиксируется описанием типа Pchild. Если имеются основания предполагать, что при выполнении опера- 80 ций в дереве степень дерева потребуется увеличить, то это нужно предусмотреть в описании типа Pchild. Type Pvertex = ^Tvertex; Pchild = Array [0..2] Of Pvertex; Tvertex = Record Dat: <поля данных>; CountChild: Integer; arrPchild: Pchild; End; Var Root: Pvertex; 9.2.2 Представление дерева с помощью вектора Возможно, самым простым представлением дерева будет одномерный массив (вектор), в котором каждый элемент соответствует отдельной вершине и содержит два поля: поле данных и поле курсора родителя. Поле курсора некоторого элемента будет содержать индекс того элемента, который является родителем данного элемента. Если предположить, что поле Dat предназначено для хранения меток вершин дерева, то векторное представление дерева, показанного на рисунке 9.1, может быть представлено рисунком 9.4. Рисунок 9.4 Представление дерева с помощью вектора, в котором каждая вершина содержит курсор на вершину-родитель в виде его индекса Поскольку элемент-корень не имеет родительского узла, то его курсор равен -1, т. е. он не указывает ни на какую другую вершину дерева. Такое представление использует то свойство деревьев, что каждая вершина, отличная от корня, имеет только одного родителя. Используя это представление можно легко организовать переходы от вершины к ее родителю, от него к его родителю и т. д. С другой стороны, использование встроенных курсоров на родителей делает крайне сложными алгоритмы переходов от родителей к сыновьям. Такой массив для дерева может быть объявлен следующим образом: 81 Type Tvertex = Record Dat: <поля данных>; РarentCursor: Integer; End; Ttree = Array [0..maxVertex] Of Tvertex; Var Tree: Ttree; Если необходимо организовать переходы от родителей к сыновьям, то в дополнение к имеющимся полям (поле курсора на родителя можно оставить) следует добавить поля курсоров на сыновей, число которых соответствует максимально возможной степени дерева. В этом случае дерево можно описать следующим образом: Type TchildCursors = Array[0..maxChild] Of Integer; Tvertex = Record Dat: <поля данных>; РarentCursor: Integer; ChildCursors: TchildCursors; End; Ttree = Array [0..maxVertex] Of Tvertex; Var Tree: Ttree; В этом случае логическая структура дерева из рассматриваемого примера, может быть представлена рисунком 9.5. Рисунок 9.5 Представление дерева с помощью вектора, в котором каждая вершина содержит курсоры как на родительские вершины, так и на вершины-сыновья Очевидно, старшинство сыновей, на которые указывают курсоры, возрастает с увеличением индекса поля-массива ChildCursors. Несуществующим (пустым) ссылкам соответствует значение курсора -1. Легко заметить, что повышение гибкости такого представления по сравнению с предыдущим вариантом векторной организации связано с дополнительными затратами памяти, вызванными необходимостью хранения пустых ссылок. 82 9.2.3 Представление дерева с использованием списков сыновей Важный и полезный способ представления деревьев состоит в формировании для каждого узла списка его сыновей. Эти списки можно представить векторным методом, но так как число сыновей у разных вершин может быть разное, обычно для этих целей применяются связные списки. На рисунке 9.6 показано, каким способом представить дерево, изображенное на рисунке 9.1. Здесь имеет место индексированный вектор заголовков, в котором размещаются метки, играющие роль полезных данных. Каждый элемент такого вектора, называемый заголовком (header) содержит указатель, являющийся указателем связного списка. Элементы каждого списка являются сыновьями узла, соответствующего элементу заголовка. Например, узлы E и F сыновья узла B. Рисунок 9.6 Представление дерева со списком сыновей 9.3 Методы обхода деревьев Систематический последовательный просмотр вершин дерева называется его обходом (walk) или прохождением (traversal). При обходе дерева каждая вершина обрабатывается один раз некоторым единым для всех вершин образом. Обход может быть, например, использован для контроля информации, хранящейся в элементах структуры, или для извлечения содержимого некоторого поля всех элементов с целью использования в другой процедуре. При формулировании алгоритма обхода следует определить некоторый порядок, ориентируясь на который можно говорить об обработке следующего элемента дерева. Из 83 структуры дерева, которая изображена на рисунке 9.7, вытекает три способа упорядочения: 1) сверху вниз (нисходящий): R, T1, T2, …, Tn (здесь R обозначает корень, T1, T2, …, Tn поддеревья); 2) слева направо (смешанный): T1, R , T2, …, Tn; 3) снизу вверх (восходящий): T1, T2, …, Tn , R. Рисунок 9.7 Укрупненная структура дерева Ниже приводятся рекурсивные формулировки методов обхода, различающиеся способом упорядочения дерева. 1) При нисходящем (прямом) обходе сначала посещается и обрабатывается корень R, затем выполняется нисходящий обход самого левого поддерева T1, далее нисходящий обход поддерева T2, расположенного правее и т. д. Последним обходится нисходящим методом самое правое поддерево Tn. В соответствии с таким определением вершины дерева, изображенного на рисунке 9.1, при нисходящем обходе будут обрабатываться в следующем порядке: A, B, E, H, F, C, D,G. 2) Смешанный обход выполняется следующим образом: смешанный обход самого левого поддерева T1, обработка корня R, смешанный обход поддерева T2, расположенного правее T1, и т. д. В конце смешанным методом обходится самое правое поддерево Tn. Последовательность обработки вершин того же самого дерева следующая: H, E, B, F, A, C, G, D. 3) Во время выполнения восходящего или обратного обхода обходятся восходящим методом все поддеревья в последовательности их расположения слева направо (T1, T2, …, Tn ), последним обрабатывается корень R. При этом последовательность поступления на обработку вершин того же дерева на рисунке 9.1 будет следующая: H, E, F, B, C, G, D, A. Существует еще один четвертый метод обхода, который называется обходом по уровням, он наиболее прост для визуального представления, но наиболее сложен для программной реализации. Этот алгоритм предполагает обработку каждого из узлов, начиная с 84 корневого, и просмотр вершин сверху вниз, уровень за уровнем. На каждом уровне вершины обрабатываются слева направо. При обходе этим методом дерева на рисунке 10.1 вершины будут обрабатываться в следующем порядке: A, B, C, D, E, F, G, H. Если во всех вышеприведенных рекурсивных определениях методов обхода поменять местами слово «левый» на «правый» и наоборот, то получим определение трех других методов обхода, называемых соответственно обратным нисходящим, обратным смешанным и обратным восходящим методами. 9.4 Бинарное дерево 9.4.1 Структура бинарного дерева Упорядоченные деревья второй степени (m-арные деревья при m=2) называют двоичными или бинарными деревьями. Упорядоченное бинарное дерево можно определить как множество вершин, которое либо пусто, либо состоит из корня с двумя отдельными двоичными деревьями, которые называют левым и правым поддеревьями этого корня. Деревья степени больше двух называют сильно ветвящимися деревьями (multiway trees). При представлении бинарного дерева в виде связного списка каждый элемент может содержать поля данных и два структурных указателя: указатель (Left) левого сына и указатель (Right) правого сына. Такое представление называется естественным представлением бинарного дерева. Тип бинарного дерева (базовый тип дерева) может быть объявлен следующим образом: Type Pvertex = ^Tvertex; Tvertex = Record Dat: <поля данных>; Left, Right: Pvertex; End; Var CurrentVertex, Root: Pvertex; CountVertex: Integer; где Dat, как и ранее, совокупность информационных полей; Left и Right поля структурных указателей. Поля данных мы будем использовать для размещения меток вершин. Переменные-указатели CurrentVertex и Root и необходимы для идентификации соответственно некоторой текущей вершины и корня. CountVertex количество вершин в дереве. На рисунке 9.8 изображена структура естественного представления бинарного дерева (пустые поля указателей содержат пустые ссылки). 85 Рисунок 9.8 Естественное представление бинарного дерева Формирование бинарного дерева 9.4.2 Предположим, что нужно построить такое бинарное дерево, которое при заданном количестве вершин N имела бы минимальную высоту. Очевидно, минимальная высота дерева достигается, если на всех уровнях, кроме последнего, поместить максимально возможное число вершин. Этого можно добиться, размещая «приходящие» в дерево вершины поровну слева и справа от той вершины, которая будет для них родителем. В результате при каждом увеличении количества вершин мы будем получать деревья, показанные на рисунке 9.9. Рисунок 9.9 Сбалансированные бинарные деревья для различных N Дерево, имеющее структуру, показанную на рисунке 9.9, называется сбалансированным деревом (balanced tree, AVL-tree). В таком дереве число потомков в левом и правом поддереве любой вершины отличается не более, чем на 1. Алгоритм формирования сбалансированного бинарного дерева допускает следующую рекурсивную формулировку: 86 взять одну вершину в качестве корня; построить тем же способом левое поддерево с Nleft = N Div 2 вершинами; построить тем же способом правое поддерево с Nright = N Nleft 1 вер- шинами. Вышеприведенному алгоритму соответствует рекурсивная функция BinaryTreeCreate, приведенная ниже: Function BinaryTreeCreate(N: Integer): Pvertex; Var Nleft, Nright: Integer; Begin If N = 0 Then Tree:= Nil Else Begin Nleft:= N Div 2; Nright:= N - Nleft - 1; New(CurrentVertex); BinaryTreeCreate:= CurrentVertex; With CurrentVertex^ Do Begin <занесение данных в поля Dat> Left:= BinaryTreeCreate(Nleft); Right:= BinaryTreeCreate(Nright); End; End; End; Для использования функции BinaryTreeCreate нужно передать ей значение числа вершин и выполнить вызов в следующей форме: Root:= BinaryTreeCreate(CountVertex); 9.4.3 Обход бинарного дерева Методы обхода дерева любой степени, рассматриваемые в подразделе 10.3, переформулируем в отношении бинарных деревьев. 1) Нисходящий обход: обработка корня, нисходящий обход левого поддерева, нисходящий обход правого поддерева. Вершины дерева, изображенного на рисунке 9.8, поступали бы на обработку при обходе нисходящим методом в следующем порядке: a, b, d, h, i, e, c, f, g, j. 2) Смешанный обход: смешанный обход левого поддерева, обработка корня, смешанный обход правого поддерева. 87 Например, при обходе дерева на рисунке 9.8 смешанным методом вершины обрабатываются в следующей последовательности: h, d, i, b, e, a, f, c, j, g. 3) Восходящий обход: восходящий обход левого поддерева, восходящий обход правого поддерева, обработка корня. Порядок обработки вершин того же дерева при восходящем обходе выглядит так: h, i, d, e, b, f, j, g, c, a. Для методов обхода в применении к бинарным деревьям часто применяют специфичные названия: нисходящий обход называют обходом pre-order, смешанный обход обходом in-order и восходящий обходом post-order. Обход post-order чаще всего применяется для уничтожения всех вершин в бинарном дереве, когда процесс уничтожения можно было бы описать следующим образом: «чтобы уничтожить все вершины бинарного дерева, необходимо уничтожить левое поддерево корня, затем правое поддерево, а затем и сам корень». Все три метода легко представить рекурсивными процедурами. Прежде чем это сделать, необходимо определить процедуру, активируемую на этапе «обработка». Такая процедура должна выполнять некоторые действия над вершиной, к которой получен доступ на текущем шаге просмотра. А доступ к элементу связной структуры легче всего обеспечить через указатель, который назовем pNode. Текст такой процедуры может выглядеть, например, следующим образом: Procedure ProcessingNode(pNode: Pvertex); Var S: String; Begin S:= <преобразование информационных полей элемента рNode^ в строку>; Form1.Memo1.Lines.Append(S); End; Теперь можно привести тексты трех подпрограмм обхода, которые реализуют приведенные выше рекурсивные алгоритмы: Procedure PreOrder(pRoot: Pvertex); Begin If (aRoot <> Nil) Then Begin ProcessingNode(pRoot); PreOrder(pRoot^.Left); PreOrder(pRoot^.Right); End; End; 88 Procedure InOrder(pRoot: Pvertex); Begin If (aRoot <> Nil) Then Begin InOrder(pRoot^.Left); ProcessingNode(pRoot); InOrder(pRoot^.Right); End; End; Procedure PostOrder(pRoot: Pvertex); Begin If (aRoot <> Nil) Then Begin PostOrder(pRoot^.Left); PostOrder(pRoot^.Right); ProcessingNode(pRoot); End; End; Для активации любой из этих трех процедур, следует воспользоваться вызовом в следующем виде: <имя процедуры>(Root); где Root указатель корня, например: PostOrder(Root). 9.4.4 Преобразование любого дерева к бинарному дереву Любое m-арное дерево (т. е. дерево степени m) может быть преобразовано в эквивалентное ему бинарное дерево, которое проще исходного дерева с точки зрения представления в памяти и обработки. Графически такое преобразование сводится к следующим действиям: 1) сначала в каждом узле исходного дерева вычеркиваем все ветви, кроме самой левой ветви, которая соответствует ссылке на старшего сына; 2) в получившемся графе соединяем те узлы одного уровня, которые являются братьями в исходном дереве. На рисунке 9.10 приведен пример такого преобразования, причем после него из некоторых элементов, исходят две ссылки: горизонтальная соединяет данный элемент с его младшим (в исходном дереве) братом, а вертикальная с его старшим сыном. Если на рисунке 9.10 повернуть все ссылки на 45° по часовой стрелке, то получим структуру, очень похожую на двоичное дерево. Однако считать ее таковым было бы ошибкой, поскольку функционально горизонтальные и вертикальные ссылки на рисунке 9.10 б имеют совершенно разный смысл. Правильнее было бы использовать следующую интерпретацию: после выполнения указанных преобразований из сыновей каждого родителя образуется ли- 89 нейный список, причем на старшего сына указывает ссылка от его родителя, а сам старший сын находится в голове списка своих братьев. Рисунок 9.10 Преобразование 3-арного дерева к бинарному Пользуясь аналогичным алгоритмом можно представить в виде двоичного дерева и лес. На рисунке 9.11 показаны этапы преобразования леса из двух деревьев в бинарное дерево. Переход от m-арного дерева (или леса) к представлению в виде двоичного дерева при естественном связном хранении сокращает объем занимаемой памяти, поскольку каждый элемент m-арного дерева должен иметь m полей для логических указателей, тогда как элемент двоичного дерева имеет только два таких поля. С другой стороны, при таком преобразовании нужно помнить о функциональном назначении левой и правой ссылок и учитывать это при обработке дерева. Рисунок 9.11 Преобразование леса к бинарному дереву 9.5 Включение и исключение в дереве Рассмотрим некоторые особенности операций включения и исключения в любом m-арном дереве. 90 9.5.1 Включение в дереве Включаемым в дерево объектом является некоторое (другое) дерево, которое затем станет поддеревом. При выполнении операции включения должны быть заданы: 1) включаемое поддерево (т. е. в памяти должна существовать соответствующая древовидная структура), 2) та вершина исходного дерева, к которой «подвешивается» в качестве ветви включаемое дерево и 3) индекс, назначаемый поддереву и определяющий старшинство его корня среди сыновей вершины подключения. В результате операции включения поддерева Y в вершину Х исходного дерева степень исхода вершины Х увеличивается на единицу и у этой вершины появляется еще один сын. При этом в общем случае потребуется заново перенумеровать вершины-сыновья узла X. 9.5.2 Исключение в дереве Исключаемым объектом, применительно к произвольному дереву, является некоторое поддерево. В операции исключения следует указать вершину исходного дерева, играющую роль корня исключаемого поддерева, и номер (или индекс) исключаемого поддерева, поскольку одна вершина в зависимости от ее степени исхода может играть роль корня для разных поддеревьев. Пусть Х некоторая вершина произвольного дерева, а вершины, Y1 ..., Yn ее сыновья. Тогда исключение i-ого поддерева вершины Х означает уменьшение степени исхода Х на единицу и удаление из исходного дерева ветви Х Yi , и поддерева, корнем которого служит вершина Yi. В результате такого удаления вершина Х может стать листом. 91 10 ПОИСК 10.1 Поиск: определение и общая терминология Любая таблица (словарь) содержит информацию, которая может быть полезна при решении каких-либо информационных задач. Часто для решения задачи требуется не вся содержащаяся в таблице информация, а лишь несколько записей или даже одна запись. И для того чтобы получить доступ к требуемой записи, необходимо ее найти, т. е. выполнить поиск в таблице. Дадим определение. Поиск (searching, retrieval) это алгоритм, который воспринимает некоторый аргумент ArgSearch и пытается локализовать (найти, определить) запись, ключ которой равен ArgSearch. Значение ArgSearch называется аргументом поиска. Алгоритм поиска может возвратить всю найденную запись или чаще всего может возвратить некоторый указатель на эту запись. Поиск, по завершении которого найдена запись с ключом, равным аргументу поиска, называется успешным или результативным. Успешный поиск часто завершается извлечением. Однако возможно, что поиск некоторого конкретного аргумента в таблице является неудачным (безрезультатным), т. е. в данной таблице не существует записи с этим аргументом в качестве ключа. В этом случае такой алгоритм может возвратить некоторую специальную «пустую запись» или некоторый пустой указатель. Однако чаще такое условие приводит к появлению некоторой ошибки или к установке во флаге некоторого конкретного значения, которое указывает, что искомая запись отсутствует. Если поиск закончился неудачно, то часто бывает желательно добавить некоторую новую запись с аргументом ArgSearch в качестве ключа. Алгоритм, который выполняет эту функцию, называется алгоритмом поиска и вставки. В некоторых случаях бывает желательно вставить некоторую запись с первичным ключом Key в некоторую таблицу без первоначального поиска другой записи с этим же самым ключом. Такая ситуация могла бы возникнуть, если бы уже было определено, что такой записи нет в файле. В таких случаях необходимо различать ситуации, относящиеся к поиску, к вставке или к поиску со вставкой. Как указывалось ранее, таблица может быть физически организована по-разному. Это может быть массив записей, связанный список, дерево или даже граф. Поскольку различные методы поиска могут соответствовать различным организациям таблиц, то таблица часто строится, исходя из соображений конкретного метода поиска. Такая таблица может полностью располагаться в оперативной памяти, или во вспомогательной памяти, или 92 там и там. Ясно, что для разных организаций таблиц необходимы различные методы поиска. Методы поиска, при которых вся таблица постоянно находится в оперативной памяти, называются методами внутреннего поиска, а те методы, для которых большая часть таблицы хранится во вспомогательной памяти (такой, как диск или лента), называются методами внешнего поиска. Мы будем рассматривать только внутренний поиск. Ниже рассматриваются алгоритмы (методы) операций в таблицах. Некоторые алгоритмы поясняются программными текстами, написанными на алгоритмическом языке Pascal. Поэтому необходимо определить некоторые переменные и структуру таблицы. В языке Pascal имеется тип, подходящий для описания типа элементов таблицы, – это тип Record (запись), следовательно, тип элемента таблицы может быть задан, например, как Type TElement = Record Key: Word; {описания других полей} end; (10.1) где Key это поле-ключ, в котором размещается значение ключа, идентифицирующее элемент, «другие поля» поля для размещения тех полезных данных, которые должны содержаться в записи. Излагаемые ниже методы оперируют с отдельными записями таблицы, используя единственно возможный способ доступа к записям – доступ по ключу. Значит ключ «с точки зрения» этих методов единственная существенная компонента, и нет необходимости как-то описывать остальные поля. Выбор в качестве типа ключа целого типа (Word) достаточно произволен; точно так же можно использовать и любой тип, на котором задано отношение порядка. Тип таблицы определим как TTable = Array [0.. HighIndex] Of TElement; (10.2а) Тогда сама таблица может быть определена как Var a: TTable; (10.2б) иначе говоря, таблица представляется как последовательность элементов а[0], a[1],…, a[HighIndex], каждый из которых является записью типа TElement. Представление таблицы в виде массива необязательно; для нас важен порядок следования элементов, который задается с помощью индекса. 93 Зададим некоторые переменные: N, HighIndex: Integer; где N количество активных элементов, HighIndex максимальное значение индекса активного элемента. Индексы вектора а начинаются с нуля, поэтому HighIndex = N - 1. 10.2 Линейный (последовательный) поиск Простейшей формой поиска является линейный или последовательный поиск (serial search). Этот поиск применяется к таблице, которая организована или как массив, или как связанный список. Если нет никакой дополнительной информации о разыскиваемых данных, то очевидный подход простой последовательный просмотр таблицы с увеличением шаг за шагом той его части, где желаемого элемента не обнаружено. Такой метод называется линейным поиском. Его алгоритм заключается в следующем: 1) index = -1; 2) i:= 0; 3) если a[i].Кey = ArgSearch, то index:= i и переход к п. 6; 4) если a[i].key <> ArgSearch, то i:= i+1 ; 5) если i <= HighIndex, то переход к п. 3; 6) завершение поиска. Переменная index (порядковый номер, индекс в векторе а) играет роль указателя на найденную запись. Если после завершения поиска index = -1, то поиск неудачен, если index -1, то поиск результативен, причем искомая запись есть a[index]. В случае, когда не осуществляется ни вставок, ни удалений, так что поиск осуществляется по всей таблице с постоянным размером N, то число сравнений зависит от того, где в таблице располагается запись с ключом, равным аргументу поиска. Если данная запись является первой в таблице, то выполняется только одно сравнение. Если эта запись является последней в таблице, то необходимо N сравнений. Если равновероятно, что аргумент попадает в любую заданную позицию таблицы, то успешный поиск (в среднем) будет выполняться за (N+1)/2 сравнений, а неуспешный поиск потребует N сравнений. В любом случае число сравнений пропорционально величине N. Очевидно, приведенный выше алгоритм подразумевает, что поиск ведется по первичному ключу. Если ключ, по которому ведется поиск, является вторичным ключом и заранее неизвестно, сколько записей имеет одинаковое значение ключа, то для нахожде- 94 ния всех записей с ключом, равным аргументу поиска, необходимо просматривать всю таблицу. На практике часто бывает, что некоторые аргументы задаются для алгоритма поиска чаще других. Например, в файлах информационной системы ВУЗа более вероятно, что обращения будут к записям о студенте старшего курса, подготавливающем документы в аспирантуру, или о первокурснике, для которого подновляется информация об окончании школы, чем о среднем второкурснике или студенте третьего курса. Аналогичным образом более вероятно, что из файлов бюро по учету автомобилей или налогового управления записи будут извлекаться чаще о нарушителях закона и людях, уклоняющихся от налогов, чем о гражданах, уважающих законы. Тогда, если часто используемые записи поместить в начало таблицы, среднее число сравнений сильно уменьшится, поскольку поиск записей, к которым наиболее часто осуществляется доступ, занимает наименьшее время. 10.3 Последовательный поиск в упорядоченной таблице Если таблица упорядочена по уменьшающемуся или увеличивающемуся значению ключа записей, то эффективность линейного поиска существенно увеличивается. Допустим, что ключи в таблице упорядочены по возрастанию их значений, т.е. для первичных ключей выполняется условие: a[0].Кey < a[1].Кey < ... < a[HighIndex].Кey, (10.3) а для вторичных ключей условие упорядоченности выглядит как a[0].Кey a[1].Кey ... a[HighIndex].Кey, (104) Тогда последовательный просмотр ключей таблицы следует завершать при выполнении условия ArgSearch a[i].Кey для первичных ключей, (10.5) ArgSearch < a[i].Кey для вторичных ключей, (10.6) где i индекс первого встретившегося при последовательном просмотре ключа, для которого выполняется одно из приведенных условий. В случае, когда поиск ведется по первичному ключу, выполнение строгого равенства в (10.5), т. е. когда ArgSearch = a[i].Кey, означает, что поиск завершается с результативным итогом, index = i. Если же выполняется условие ArgSearch < a[i].Кey, то поиск завершается, он неудачен (поскольку a[i+1].Кey, a[i+2].Кey, …, a[HighIndex].Кey заведомо больше ArgSearch), index = -1. При поиске по вторичному ключу выполнение условия (10.6) приводит к останову 95 алгоритма. Поскольку, согласно (10.4), при этом возможны совпадения аргумента поиска с несколькими значениями ключа (допустим число таких значений равно m), то записи a[i-m], a[i-m+1], …, a[i-1] являются искомыми записями и поиск – результативен. Если же совпадений до выполнения неравенства (10.6) не произошло, то завершившийся поиск неудачен. В любом случае значения a[i+1].Кey, a[i+2].Кey, …, a[HighIndex].Кey не проверяются на совпадение с аргументом ArgSearch, поскольку они заведомо больше ArgSearch . Таким образом, последовательный поиск в упорядоченной по ключу таблице значительно эффективнее последовательного поиска в неупорядоченной таблице, поскольку при отсутствии в таблице значения (или значений) ключа, совпадающего с аргументом поиска, не следует просматривать все таблицу до конца (если, строго говоря, аргумент не превышает максимального значения ключа). 10.4 Последовательный поиск при накоплении запросов Предположим, что можно собрать некоторое большое число запросов на поиск до того, как они будут обработаны. Например, во многих приложениях ответ на запрос информации может быть отсрочен до следующего дня. В таких случаях все запросы за некоторый день могут быть собраны вместе, и реальный поиск может быть осуществлен в течение ночи, когда новые запросы не поступают. Если и таблица, и список запросов упорядочены, то может быть выполнен последовательный поиск при одновременном продвижении и по таблице, и по списку запросов, начиная поиск каждого следующего элемента списка запросов в том месте, где окончился предыдущий поиск. Таким образом, нет необходимости выполнять поиск по всей таблице для каждого запроса на поиск. Рассмотрим этот процесс подробнее. Пусть для значений ключа элементов таблицы выполняется одно из условий (10.3) или (10.4). Список запросов представляет собой набор аргументов поиска Arg[0], Arg[1], …, Arg[L]. Допустим, что массив запросов упорядочен таким же образом, как и таблица, т.е. Arg[0] Arg[1] ... Arg[L], (10.7) Тогда поиск при накоплении запросов целесообразно организовать следующим образом. Вначале в процедуру последовательного поиска передается в качестве аргумента значение Arg[0], и выполняется линейный поиск, начиная с записи a[0]. Допустим, что поиск для аргумента Arg[0] завершился на элементе a[i]. Следующим удовлетворяется запрос на поиск с аргументом Arg[1]. Поскольку, согласно (11.7), этот новый аргумент не 96 меньше, чем предыдущий, не имеет смысла начинать поиск для него с элемента a[0], т. е. с начала таблицы. Последовательный поиск для аргумента Arg[1] начинается с элемента a[i] и, допустим, завершается на элементе a[j]. Новый поиск для следующего аргумента Arg[2] начинается с элемента a[j] и т. д. Из-за простоты и эффективности последовательной обработки упорядоченной таблицы может иметь смысл отсортировать таблицу прежде, чем осуществлять в ней поиск. Это особенно справедливо для ситуации, описанной в предыдущем абзаце, где мы имели дело с некоторым «главным» файлом и с большим файлом «транзакций», состоящим из запросов. 10.5 Индексно-последовательный поиск Для увеличения эффективности поиска в отсортированном файле существует другой метод, но он приводит к увеличению требуемой памяти. Этот метод называется индекснопоследовательным методом поиска. В дополнение к отсортированной основной таблице (файлу) организуется некоторая вспомогательная таблица, называемая индексом. Каждый элемент индекса состоит из ключа kindex и указателя на запись в файле, соответствующую этому ключу pindex. Элементы в индексе, так же как и элементы в файле, должны быть отсортированы по этому ключу. Если индекс имеет размер, составляющий одну шестую от размера файла, то каждая шестая запись в файле представлена первоначально в индексе. Это показано на рисунке 10.1. Действительным преимуществом индексно-последовательного метода является то, что элементы в таблице могут быть проверены последовательно, если доступ должен быть осуществлен ко всем записям в файле, а для доступа к некоторому конкретному элементу время поиска сильно сокращено. Последовательный поиск выполняется по меньшему индексу, а не по большой таблице. Когда найден правильный индекс, второй последовательный поиск выполняется по небольшой части записей самой таблицы. Индекс применяется для отсортированной таблицы, представленной и в виде связанного списка, и в виде массива. Использование связанного списка приводит к несколько большим накладным расходам по памяти для указателей, хотя вставки и удаления могут быть выполнены проще. Может также использоваться некоторая смешанная реализация, при которой все записи между двумя соседними элементами индекса хранятся в небольшой отдельной таблице, которая также содержит указатель на следующую такую же таблицу. 97 Рисунок 10.1 – Индексно-последовательная таблица Если таблица является такой большой, что даже использование индекса не дает достаточной эффективности, то может быть использован индекс второго уровня (вторичный индекс). Индекс второго уровня действует как индекс для первичного индекса, который указывает на элементы в основной таблице. Это показано на рисунке 10.2. Причины для организации вторичного индекса две: либо индекс большой, чтобы уменьшить последовательный поиск по таблице, либо индекс маленький, так что соседние ключи в индексе находятся далеко друг от друга в таблице. Вставка элемента в индексно-последовательную таблицу является довольно трудной, поскольку между двумя уже существующими элементами таблицы может не быть свободного места, что приводит к необходимости сдвигать большое число элементов таблицы. Однако если некоторый недалеко расположенный элемент в таблице был отмечен флагом как удаленный, то необходимо сдвинуть только несколько элементов и поверх удаленного элемента может быть записана новая информация. Это в свою очередь может привести к необходимости изменения индекса, если элемент, на который указывал некоторый элемент индекса, был сдвинут. В общем случае, когда инициализируется некоторая таблица, по всей таблице расставляются пустые записи, чтобы оставить место для вставок. Другой метод состоит в том, чтобы в некотором другом месте иметь некую область пере- 98 полнения, а вставляемые записи связывать между собой. Однако для этого потребуется дополнительное поле указателя в каждой записи первоначальной таблицы. Возможное решение этой проблемы состоит в том, чтобы иметь только один указатель после каждой группы записей, вставлять новую запись в ее соответствующее место, а все записи после вставленной записи сдвигать вперед на одну позицию. Если последняя запись данной группы сдвигается, то она помещается в область переполнения, на которую указывает один указатель в группе. Рисунок 10.2 – Использование вторичного индекса Удаления из индексно-последовательной таблицы могут быть сделаны наиболее простым способом при помощи отметки удаленных записей флагом. При последовательном поиске по таблице удаленные записи игнорируются. Отметим, что если некоторый элемент удален, то, даже если его ключ находится в индексе, ничего не надо делать с индексом, а надо отметить флагом данный элемент первоначальной таблицы. 10.6 Бинарный поиск Наиболее эффективным методом поиска в упорядоченном массиве без использования вспомогательных индексов или таблиц является двоичный или бинарный поиск (binary search). Очевидно, что других способов убыстрения поиска не существует, если, конечно, 99 нет еще какой-либо информации о данных, среди которых идет поиск. Другие названия бинарного поиска поиск делением пополам, дихотомический поиск (dichotomizing search). Рассмотрим алгоритм бинарного поиска в предположении, что поиск выполняется по первичному ключу. Основная идея выбрать случайно некоторый элемент, предположим а[m], и сравнить его значение ключа с аргументом поиска ArgSearch. Если а[m].Кey равен ArgSearch, то поиск заканчивается, если он (ключ а[m].Кey ) меньше ArgSearch, то мы заключаем, что все элементы с индексами, меньшими или равными m, можно исключить из дальнейшего поиска. Если же он больше ArgSearch, то исключаются индексы больше и равные m. Это соображение приводит нас к алгоритму, программный текст которого приведен ниже. Здесь две индексные переменные L и R отмечают соответственно левый и правый конец секции массива а, где еще может быть обнаружен требуемый элемент. L:= 0; R:= HighIndex; index:= -1; While (L<=R) Do Begin m:= <любое значение между L и R>; If а[m].Кey = ArgSearch Then Begin index:= m; Break End ElseIf a[m].Кey < ArgSearch Then L:= m+1 Else R:= m-1 End; Выбор m произволен в том смысле, что корректность алгоритма от него не зависит. Однако на его эффективность выбор влияет. Ясно, что наша задача исключить на каждом шаге из дальнейшего поиска, каким бы ни был результат сравнения, как можно больше элементов. Оптимальным решением будет выбор срединного элемента (т. е. среднего элемента не по значению, а по положению), для чего в вышеприведенном фрагменте следует записать m:= (L+R) div 2; При этом в любом случае будет исключаться половина массива. В результате максимальное число сравнений равно значению log2N, округленному до ближайшего целого. Приведенный алгоритм существенно выигрывает по сравнению с последовательным поиском, ведь там ожидаемое среднее число сравнений равно N/2. 100 Бинарный поиск может быть использован вместе с индексно-последовательной организацией таблицы, упоминавшейся ранее. Вместо поиска по индексу последовательно может быть использован бинарный поиск. Бинарный поиск может быть также использован при поиске в основной таблице, когда идентифицированы две граничные записи. Однако размер этого сегмента таблицы, вероятно, будет настолько малым, что бинарный поиск не более эффективен, чем последовательный поиск. Алгоритм бинарного поиска может быть использован только в том случае, если таблица хранится в виде упорядоченного массива. Это происходит потому, что данный алгоритм использует тот факт, что индексами элементов массива являются последовательные целые числа. По этой причине бинарный поиск практически бесполезен в ситуациях, где имеется много добавлений, требующих дополнительного упорядочения элементов, или логических удалений. 10.7 Поиск, использующий бинарное дерево Бинарные деревья находят широкое применение для хранения таблицы с тем, чтобы сделать сортировку этой таблицы более эффективной. В этом методе все левосторонние потомки некоторой вершины с ключом Кey имеют ключи, которые меньше ключа Кey, а все правосторонние потомки имеют ключи, которые больше или равны ключу Кey. Смешанный обход такого бинарного дерева дает последовательность, упорядоченную по возрастающему значению ключа. Такое дерево может также быть использовано для бинарного поиска, поэтому оно называется деревом бинарного поиска (binary search tree). Допустим, имеется следующая последовательность ключей: 30, 47, 85, 95, 115, 130, 138, 159, 166, 184, 206, 212, 219, 224, 237, 258, 296, 307, 314 Дерево бинарного поиска, соответствующего этой последовательности, показано на рисунке 10.3. Алгоритм поиска в дереве бинарного поиска использует упорядоченность дерева. Поиск элемента выполняется следующим образом. Поиск начинается с корневой вершины, и эта вершина становится текущей. Затем аргумент поиска сравнивается с ключом текущей вершины. Если они равны, то требуемый элемент найден. В противном случае, если аргумент поиска меньше ключа текущей вершины, то выполняется переход к левой дочерней вершине, которая становится текущей. Если аргумент поиска больше ключа текущей вершины, то необходимо перейти к правому сыну текущей вершины, который станет текущей вершиной. В любом случае после перехода к новой вершине происходит этап сравнения ключа с аргументом поиска. Со временем либо определяется нужная вершина, 101 либо достигается лист дерева, что свидетельствует об отсутствии искомого элемента в дереве. Рисунок 10.3 Бинарное дерево поиска Бинарный поиск, рассмотренный ранее, фактически использует отсортированный массив как некоторое неявное дерево бинарного поиска. Срединный элемент этого массива можно представить как корень такого дерева, нижнюю половину массива (все те элементы, которые меньше, чем срединный элемент) можно рассматривать как левое поддерево, а верхнюю половину (все те элементы, которые больше, чем срединный элемент) как правое поддерево. Упорядоченный массив может быть получен из дерева бинарного поиска при помощи смешанного обхода этого дерева и вставки каждого элемента последовательно в некоторый массив по мере того, как он встречается в дереве. С другой стороны, для некоторого заданного отсортированного массива имеется много соответствующих ему деревьев бинарного поиска. Предпочтительными являются сбалансированные бинарные деревья, рассмотренные в п. 9.4.2, для которых среднее время, затрачиваемое на поиск, пропорционально log2N. 102 11 СОРТИРОВКА 11.1 Общие сведения и некоторые определения Термин «сортировка» (sorting) определяется как «распределение, отбор по сортам или деление на категории, сорта, разряды». В общем случае сортировку следует понимать как процесс перегруппировки заданного множества объектов в некотором определенном порядке. Цель сортировки – облегчить последующий поиск элементов в таком упорядоченном (отсортированном) множестве. Это почти универсальная, фундаментальная деятельность. Мы встречаемся с отсортированными объектами в телефонных справочниках, в библиотеках, в словарях, на складах – почти везде, где нужно искать хранимые объекты. Даже малышей учат держать свои вещи «в порядке», и они уже сталкиваются с некоторыми видами сортировок задолго до того, как познакомятся с азами арифметики. С точки зрения обработки данных сортировка (упорядочение) таблиц позволяет существенно ускорить процесс поиска данных, о чем свидетельствует материал раздела 10. Таким образом, разговор о сортировке вполне уместен и важен, если речь идет об обработке данных. Сортировка – это идеальный «объект» для демонстрации огромного разнообразия алгоритмов, которые изобретены для решения одной и той же задачи. Поэтому это еще и идеальный «объект», демонстрирующий необходимость анализа производительности алгоритмов. К тому же на примерах сортировок можно показать как путем усложнения алгоритма, хотя под рукой и есть уже очевидные методы, можно добиться значительного выигрыша в эффективности. Выбор алгоритма зависит от структуры обрабатываемых данных – это почти закон, но в случае сортировки такая зависимость столь глубока, что соответствующие методы были разбиты на два класса – сортировку массивов и сортировку последовательностей. Часто их называют внутренней и внешней сортировкой, поскольку массивы хранятся в быстрой оперативной, внутренней памяти ЭВМ, допускающей произвольный (прямой) доступ к элементам, а последовательности (файлы) размещаются в более медленной, но более емкой внешней памяти на устройствах, основанных на механических перемещениях (дисках, лентах). Строго говоря, при внутренней сортировке все данные хранятся в оперативной памяти, а при внешней сортировке не все записи там помещаются. При внутренней сортировке имеются более гибкие возможности для построения структур данных и доступа к ним; внешняя же сортировка показывает, как поступать в условиях сильно ограниченного доступа. Введем некоторые понятия и обозначения. Если у нас есть элементы 103 a[0], a[1], a[2], …, a[HighIndex], то сортировка есть перестановка этих элементов в последовательность a[k0], a[k1], …, a[kHighIndex] = a[0], a[1], …, a[HighIndex], такую, что при некоторой упорядочивающей функции f выполняются отношения f(a[0]) f(a[1]) … f(a[HighIndex]). где символом « » обозначено отношение «предшествования», задающее некоторое правило упорядочения. Обычно упорядочивающая функция не вычисляется по какому-либо правилу, а хранится как явная компонента, ассоциированная с каждым элементом сортируемой таблицы. Эта компонента является полем ключа или, просто, ключом элемента данных. Как и ранее, в разделе 11, ограничимся описанием элемента таблицы и самой таблицы a в форме (10.1) и (10.2). Если в результате сортировки элементы таблицы располагаются в соответствии с соотношением (10.3) или (10.4), то говорят, что таблица упорядочена по возрастанию (значений ключа Кey). Если после сортировки элементы располагаются так, что значения ключа уменьшаются при увеличении индекса, то выполнено упорядочение по убыванию. В дальнейшем везде будем считать, что сортировки производят упорядочение элементов по возрастанию ключа. Метод сортировки называется устойчивым, если в процессе сортировки относительное расположение элементов с равными ключами не изменяется. Устойчивость сортировки часто бывает желательной, если речь идет об элементах, уже упорядоченных (отсортированных) по некоторым вторичным ключам, не влияющим на первичный (основной) ключ. 11.2 Внутренняя сортировка Внутреннюю сортировку иногда называют сортировкой массивов, поскольку сортируемая во внутренней памяти таблица, как правило, организуется как вектор записей. Основное требование к методам сортировки массивов экономное использование памяти. Это означает, что переупорядочение элементов нужно выполнять на том же месте, где и расположен массив, и что методы, которые пересылают элементы из одного массива в другой, не представляют интереса. Такие методы называются сортировками «на том же месте» (in sity). 104 Существует несколько несложных и очевидных способов сортировки, называемых прямыми (простыми) сортировками. Эти простые методы стоит рассмотреть прежде, чем перейти к более быстрым алгоритмам, по следующим трем причинам: простые методы особенно хорошо подходят для разъяснения свойств большин- ства принципов сортировки; программы, основанные на этих методах, легки для понимания и коротки; хотя сложные методы требуют меньше числа операций, эти операции более сложны; поэтому при достаточно малых числах сортируемых элементов простые методы работают быстрее, но их не следует использовать при большом числе сортируемых элементов. Более сложные по сравнению с прямыми сортировками методы (Шелла, пирамидальная сортировка и т. д.) называются усовершенствованными сортировками; они применяются для упорядочения больших (по количеству элементов) таблиц. Методы, сортирующие элементы «на том же месте», можно разбить на три основных класса в зависимости от лежащего в их основе приема: 1) сортировка включениями (вставками), 2) сортировка выбором (выделением), 3) сортировка обменом. 11.2.1 Сортировка прямыми включениями Этот метод обычно используют игроки в карты. Элементы (карты) условно разделяют на готовую a[0], ..., a[i-1] и входную (неупорядоченную) последовательности a[i], ..., a[HighIndex]. Готовая последовательность уже упорядочена, т. е. для нее выполняется соотношение: a[0].Кey a[1].Кey ... a[i-1].Кey На каждом шаге, начиная с i=1 и увеличивая i на единицу, берется i-й элемент входной последовательности и передается в готовую последовательность, вставляя на подходящее место. В общем случае этапы метода, заключающиеся в выполнении одинаковых действий, называются проходами. Проход метода прямого включения состоит в локализации включаемого элемента и перемещении его в готовую последовательность на нужное место. В общем виде алгоритм сортировки прямыми включениями выглядит следующим образом: 105 For i:=1 To HighIndex Do Begin x:=a[i]; вставить x на подходящее место в a[0]. . . a[i-1]; end; Процесс сортировки прямыми включениями показан на примере восьми случайно взятых чисел: На i-ом проходе i-ый ключ сравнивается с (i-1)-ым ключом, и если a[i-1].Кey > a[i].Кey, то элементы a[i-1] и a[i] обмениваются местами. Затем «новый» ключ a[i-1].Кey сравнивается с предыдущим ключом a[i-2].Кey и т. д. Таким образом, на каждом проходе часть элементов готовой последовательности сдвигается на одну позицию вправо, освобождая место для включаемого элемента. Этот процесс называется «просеиванием» элемента a[i]. Алгоритм сортировки прямыми включениями может иметь следующую реализацию: Рrocedure DirInsSort; Var i, j: Integer; x: TElement; Begin For i:=1 To HighIndex Do Begin For j:= i-1 Downto 0 Do If a[j+1].Кey < a[j].Кey Then Begin x:= a[j+1]; a[j+1]:= a[j]; a[j]:= x; End Else break; End; End; 106 11.2.2 Сортировка бинарными включениями Алгоритм сортировки простыми включениями можно легко улучшить, пользуясь тем, что готовая последовательность a[0], ..., a[i-1], в которую нужно включить новый элемент, уже упорядочена. Поэтому место включения можно найти быстрее, применив бинарный поиск, который определяет срединный элемент готовой последовательности и продолжает деление пополам, пока не будет найдено место включения. Модифицированный алгоритм сортировки называется сортировкой бинарными включениями, он показан в следующей программе: Рrocedure BinInsSort; Var i, j, L, R, m: Integer; x: TElement; Begin For i:=2 To N Do Begin x:= a[i-1]; L:= 1; R:= i-1; While L <= R Do Begin m:= (L + R) Div 2; If x.Кey < a[m-1].Кey Then R:= m - 1 Else L:= m + 1; End; For j:= i-1 Downto L Do a[j]:= a[j-1]; a[L-1]:= x; End End; Сортировка включениями оказывается не очень подходящим методом для компьютеров: включение элемента с последующим сдвигом всего ряда элементов на одну позицию не экономна. Лучших результатов можно ожидать от метода, при котором пересылки элементов выполняются только для отдельных элементов и на большие расстояния. Эта мысль приводит к сортировке выбором. 11.2.3 Сортировка прямым выбором Этот метод основан на следующем алгоритме: выбираем (выделяем) элемент с наименьшим (среди всех N элементов) ключом, допустим это элемент a[k]: a[k].Key = min(a[0].Key, a[1].Key, …, a[HighIndex].Key) элемент a[k] меняется местами с первым элементом, т. е. с элементом a[0]. Затем выбираем элемент с наименьшим ключом среди всех элементов, кроме элемента a[0]; меняем его местами с элементом a[1] и т. д. 107 Эти операции затем повторяются с оставшимися N2 элементами, затем с N3 элементами, пока не останется только один элемент наибольший. Метод, основанный на принципе пошагового выбора, продемонстрирован на тех же восьми ключах: Обобщенно алгоритм прямого выбора можно представить следующим образом: For i:=0 To HighIndex Do Begin присвоить k индекс наименьшего элемента из a[i], … a[HighIndex]; поменять местами a[i] и a[k]; end; Этот метод, называемый сортировкой прямым (простым) выбором, в некотором смысле противоположен сортировке простыми включениями; при сортировке простыми включениями на каждом шаге рассматривается только один очередной элемент входной последовательности и все элементы готового массива для нахождения места для включения; при сортировке простым выбором рассматриваются все элементы входного массива для нахождения элемента с наименьшим ключом, и этот один очередной элемент отправляется в готовую последовательность. Обычно алгоритм сортировки простым выбором предпочтительней алгоритма сортировки простыми включениями, хотя в случае, когда ключи заранее упорядочены или почти упорядочены, сортировка простыми включениями все же работает несколько быстрее. Весь алгоритм сортировки простым выбором реализован в следующей программе DirSelSort: 108 Рrocedure DirSelSort; Var i, j, k: Integer; x: TElement; Begin For i:=0 To HighIndex-1 Do Begin x:=a[i]; k:= i; For j:=i+1 To HighIndex Do If x.Key > a[j].Key Then Begin k:=j; x:=a[j]; End; a[k]:=a[i]; a[i]:=x; End End; 11.2.4 Сортировка прямым обменом Классификация методов сортировки не всегда четко определена. Методы прямого включения и прямого выбора используют в процессе сортировки обмен ключей. Однако теперь мы рассмотрим метод, в котором обмен двух элементов является основной характеристикой процесса. Приведенный ниже алгоритм сортировки простым обменом основан на принципе сравнения и обмена пары соседних элементов до тех пор, пока не будут рассортированы все элементы. Если мы будем рассматривать массив, расположенный вертикально, а не горизонтально, и представим себе элементы пузырьками в резервуаре с водой, обладающими «весами», соответствующими их ключам, то каждый проход по массиву приводит к «всплыванию» пузырька на соответствующий его весу уровень. Этот метод широко известен как сортировка методом пузырька или пузырьковой сортировкой. Проход метода начинается с конца сортируемого массива. Ключ последнего элемента a[HighIndex] сравнивается с ключом предпоследнего элемента a[HighIndex-1]. Если a[HighIndex].Key < a[HighIndex-1].Key, то сравниваемые элементы меняются местами друг с другом. Затем предпоследний элемент сравнивается с предыдущим, и если нужно, то происходит обмен и т. д. Следующий проход выполняется аналогичным образом. Сортировка завершается тогда, когда во время очередного прохода не произошло ни одного обмена. 109 Алгоритм пузырьковой сортировки иллюстрируется результатами выполнения проходов на тех же, что и ранее, ключах: Простейшей реализацией пузырьковой сортировки является подпрограмма BubbleSort, показанная ниже: Рrocedure BubbleSort; Var i, j: Integer; x: TElement; flag: boolean; Вegin For i:= 1 To HighIndex Do Begin // Начало прохода flag:= True; For j:= HighIndex DownTo i Do If a[j-1].Key > a[j].Key Then Begin flag:= False; x:=a[j-1]; a[j-1]:=a[j]; a[j]:=x end; // Если обменов не было, то закончить If flag Then Break; End; End; В подпрограмме BubbleSort используется булева переменная flag, которая устанавливается в True в начале каждого прохода. Если при выполнении очередного прохода не было выполнено ни одного обмена, это означает, что массив a упорядочен. В этом случае переменная flag не изменяет своего значения и происходит выход из подпрограммы BubbleSort. Внимательный читатель заметит здесь странную асимметрию: самый «легкий пузырек», расположенный в «тяжелом» конце неупорядоченного массива всплывет на нужное место за один проход, а «тяжелый пузырек», неправильно расположенный в «легком» 110 конце будет опускаться на правильное место только на один шаг на каждом проходе. Например, массив 12, 18, 42, 44, 55, 67, 94, 06 будет рассортирован при помощи метода пузырька за один проход, а сортировка массива 94, 06, 12, 18, 42, 44, 55, 67 требует семи проходов, пока ключ 94 не окажется в конце массива. Эта неестественная асимметрия подсказывает следующее улучшение: менять направление следующих один за другим проходов. Полученный в результате алгоритм называют шейкер-сортировкой. Его работа показана на следующем примере: Из рисунка видно, что в результате первого прохода наверх переместился самый «легкий» ключ 06. А самый «тяжелый элемент», имеющий ключ 94, переместился на свое место уже на втором проходе, чего не наблюдалось при рассмотрении пузырьковой сортировки. Анализ показывает, что сортировка обменом и ее небольшие улучшения хуже, чем сортировка включениями и выбором. Сортировка методом пузырька вряд ли имеет какието преимущества, кроме своего легко запоминающегося названия. Алгоритм шейкерсортировки выгодно использовать в тех случаях, когда известно, что элементы уже почти упорядочены редкий случай на практике. Можно показать, что среднее расстояние, на которое должен переместиться каждый из N элементов во время сортировки, это N/3 мест. Это число дает ключ к поиску усовершенствованных, т. е. более эффективных, методов сортировки. Все простые методы в принципе перемещают каждый элемент на одну позицию на каждом элементарном шаге. Поэтому они требуют порядка N2 таких шагов. Любое улучшение должно основываться на принципе пересылки элементов за один шаг на большое расстояние. Текст подпрограммы шейкерной сортировки приводится ниже. 111 Procedure ShakerSort; Var j, k ,L ,R: Integer; x: TElement; flag: boolean; Begin L:=1; R:= HighIndex; k:= R; Repeat flag:= true; For j:=R DownTo L Do Begin If a[j-1].Key > a[j].Key Then Begin x:=a[j-1]; a[j-1]:=a[j]; a[j]:=x; k:=j; flag:= false; end; End; If flag Then Break; L:=k+1; flag:= True; For j:=L To R Do If a[j-1].Key > a[j].Key Then Begin x:=a[j-1]; a[j-1]:=a[j]; a[j]:=x; k:=j; flag:= false; end; If flag Then Break; R:=k-1; Until L > R End; 11.2.5 Сортировка методом Шелла Некоторое усовершенствование сортировки простыми включениями было предложено Д. Л. Шеллом в 1959 году. Этот метод показан на следующем примере. Сортировка с убывающими расстояниями: Расстоянием h между двумя элементами a[j] и a[i] называется положительная разность их индексов. Например, расстояние между элементами a[2] и a[6] равно 4 (h = 62). На первом проходе отдельно группируются и сортируются (внутри группы) методом простого включения все элементы, отстоящие друг от друга на четыре позиции. Этот процесс называется 4-сортировкой. В нашем примере из восьми элементов каждая группа на 112 первом проходе содержит ровно два элемента. После этого элементы вновь объединяются в группы с элементами, расстояние между которыми равно 2, и сортируются заново. Этот процесс называется 2-сортировкой. Наконец на третьем проходе все элементы сортируются обычной сортировкой включением (1-сортировка). Заметим, что группы, в которых последовательные элементы отстоят на одинаковые расстояния, никуда не передаются они остаются «на том же месте». На каждом шаге в сортировке участвует либо сравнительно мало элементов, либо они уже довольно хорошо упорядочены и требуют относительно мало перестановок. Очевидно, что этот метод дает упорядоченный массив, и что каждый проход будет использовать результаты предыдущего прохода, поскольку каждая i-сортировка объединяет две группы, рассортированные предыдущей (2i)-сортировкой. Также ясно, что приемлема любая последовательность приращений, лишь бы последнее было равно 1, так как в худшем случае вся работа будет выполняться на последнем проходе. Однако менее очевидно, что метод убывающего приращения дает даже лучшие результаты, когда приращения не являются степенями двойки. Все t приращений обозначим через h1, h2, ..., ht с условиями ht = 1, hi+1 < hi, i=1, …, t1. Каждая h-сортировка программируется как сортировка простыми включениями. Программа сортировки методом Шелла может иметь следующий вид: Procedure ShellSort; Var i, j, k, t, m: integer; x: TElement; h: Array [1..20] Of integer; Begin t:= Round(Ln(N)/Ln(3))-1; If t = 0 Then t:= 1; h[t]:= 1; For m:= t-1 Downto 1 Do h[m]:= 3*h[m+1] + 1; For m:= 1 To t Do Begin k:= h[m]; For i:= k+1 To N Do Begin j:= i - k; While a[j+k-1].Key < a[j-1].Key Do Begin x:= a[j+k-1]; a[j+k-1]:= a[j-1]; a[j-1]:= x; j:= j - k; If j < 1 Then Break; End {While} End; {For i} End; {For m} End; 113 В литературе рекомендуется такая последовательность приращений (записанная в обратном порядке): 1, 4, 13, 40, 121,..., где hi-1 = 3hi+1, ht = 1 и t = log3(N1). Именно такие параметры задаются в подпрограмме ShellSort. Приемлемой считается также последовательность 1, 3, 7, 15, 31,..., где hi-1 = 2hi+1, и t = log2(N1). Анализ показывает, что в последнем случае затраты, которые требуются для сортировки N элементов с помощью алгоритма сортировки Шелла, пропорциональны N 1,2. 11.2.6 Сортировка с помощью бинарного дерева Метод сортировки простым выбором основан на повторном выборе наименьшего ключа среди N элементов, затем среди N1 элементов и т. д. Понятно, что поиск наименьшего ключа из N элементов требует N1 сравнений, а поиск его среди N1 элементов еще N2 сравнений. Улучшить сортировку выбором можно в том случае, если получать от каждого прохода больше информации, чем просто указание на один, наименьший элемент. Например, с помощью N/2 сравнений можно определить наименьший ключ из каждой пары. При помощи следующих N/4 сравнений можно выбрать наименьший из каждой пары таких наименьших ключей и т. д. Наконец, при помощи всего N1 сравнений мы можем построить дерево выбора, как показано на рисунке 11.1, и определить корень, как наименьший ключ. Рисунок 11.1 Построение дерева при последовательном выборе из двух ключей На втором шаге выполняется спуск по пути, отмеченном наименьшим ключом, с последовательной заменой его на «дыру» (или ключ-бесконечность). По достижении (при 114 спуске) элемента-листа, соответствующего наименьшему ключу, этот элемент исключается из дерева и помещается в начало некоторого списка. Результат выполнения этого процесса показан на рисунке 11.2. Рисунок 11.2 Спуск по дереву с образованием дыр и формирование упорядоченного списка из исключаемых элементов Затем происходит заполнение элементов-«дыр» в направлении снизу вверх. При этом очередной заполняемый пустой элемент («дыра») получает значение ключа, наименьшего среди значений сыновей этого элемента. В результате получается дерево, изображенное на рисунке 11.3. Рисунок 11.3 Заполнение «дыр» Элемент, который оказывается в корне дерева, вновь имеет наименьший ключ среди оставшихся элементов и может быть исключен из дерева и включен в «готовую» последовательность. После N таких шагов дерево становится пустым (т. е. состоит из «дыр»), и процесс сортировки завершается. При сортировке с помощью дерева задача хранения информации стала сложнее и поэтому увеличилась сложность отдельных шагов; в конечном счете, для хранения возросшего объема информации нужно строить некую древовидную структуру. Также жела- 115 тельно избавиться от необходимости в «дырах», которые в конце заполняют дерево и приводят к большому числу ненужных сравнений. Кроме того, нужно найти способ представить дерево из N элементов в N единицах памяти вместо 2N-1 единиц. Это можно осуществить с помощью метода, который его изобретатель Дж. Уильямс назвал пирамидальной сортировкой. 11.2.7 Пирамидальная сортировка Пирамида определяется как некоторая последовательность ключей K[L], …, K[R], такая, что K[i] K[2i] K[i] K[2i+1], (11.1) для всякого i = L, …, R/2. Если имеется массив K[1], K[2], …, K[R], который индексируется от 1, то этот массив можно представить в виде двоичного дерева. Пример такого представления при R=10 показан на рисунке 11.4. Рисунок 11.4 Массив ключей, представленный в виде двоичного дерева Дерево, изображенное на рисунке 11.4, представляет собой пирамиду, поскольку для каждого i = 1, 2, …, R/2 выполняется условие (11.1). Очевидно, последовательность элементов с индексами i = R/2+1, R/2+2, …, R (листьев двоичного дерева), является пирамидой, поскольку для этих индексов в пирамиде нет сыновей. Способ построения пирамиды «на том же месте» был предложен Р. Флойдом. В нем используется процедура просеивания (sift), которую рассмотрим на следующем примере. Предположим, что дана пирамида с элементами K[3], K[4], …, K[10] нужно добавить новый элемент K[2] для того, чтобы сформировать расширенную пирамиду K[2], 116 K[3], K[4], …, K[10]. Возьмем, например, исходную пирамиду K[3], …, K[10], показанную на рисунке 11.5, и расширим эту пирамиду «влево», добавив элемент K[2]=44. Рисунок 11.5 Пирамида, в которую добавляется ключ K[2]=44 Добавляемый ключ K[2] просеивается в пирамиду: его значение сравнивается с ключами узлов-сыновей, т. е. со значениями 15 и 28. Если бы оба ключа-сына были больше, чем просеиваемый ключ, то последний остался бы на месте, и просеивание было бы завершено. В нашем случае оба ключа-сына меньше, чем 44, следовательно, вставляемый ключ меняется местами с наименьшим ключом в этой паре, т. е. с ключом 15. Ключ 44 записывается в элемент K[4], а ключ 15 в элемент K[2]. Просеивание продолжается, поскольку ключи-сыновья нового элемента K[4] оказываются меньше его происходит обмен ключей 44 и 18. В результате получаем новую пирамиду, показанную на рисунке 11.6. В нашем примере получалось так, что оба ключа-сына просеиваемого элемента оказывались меньше его. Это не обязательно: для инициализации обмена достаточно того, чтобы оказался меньше хотя бы один дочерний ключ, с которым и производится обмен. Просеивание элемента завершается при выполнении любого из двух условий: либо у него не оказывается потомков в пирамиде, либо значение его ключа не превышает значений ключей обоих сыновей. Рисунок 11.6 Просеивание ключа 44 в пирамиду 117 Алгоритм просеивания в пирамиду допускает рекурсивную формулировку: 1) просеивание элемента с индексом temp, 2) при выполнении условий остановки выход, 3) определение индекса q элемента, с которым выполняется обмен, 4) обмен элементов с индексами temp и q, 5) temp:= q, 6) перейти к п. 1. Этот алгоритм в применении к нашему массиву а реализован в подпрограмме Sift, которая выполняет просеивания в пирамиду с максимальным индексом R: Procedure Sift(temp, R: Integer); Var q: integer; x: TElement; Begin q:=2*temp; If q > R Then Exit; If q < R Then If a[q-1].Key > a[q].Key Then q:= q + 1; If a[temp-1].Key <= a[q-1].Key Then Exit; x:= a[temp-1]; a[temp-1]:= a[q-1]; a[q-1]:= x; temp:= q; Shift(temp, R); End; Процедура Shift учитывает индексацию вектора а от нуля. Теперь рассмотрим процесс создания пирамиды из массива a[0], a[1], …, a[HighIndex]. Напомним, что элементы этого массива индексируются от 0, а пирамида от 1 и, кроме того, N = HighIndex+1. Ясно, что элементы a[N/2], a[N/2+1], …, a[HighIndex] уже образуют пирамиду, поскольку не существует двух индексов i (i= N/2+1, N/2+2,…) и j, таких, что, j=2i (или j=2i+1). Эти элементы составляют последовательность, которую можно рассматривать как листья соответствующего двоичного дерева. Теперь пирамида расширяется влево: на каждом шаге добавляется новый элемент и при помощи просеивания помещается на соответствующее место. Этот процесс иллюстрируется следующим примером. Процесс построения пирамиды (жирным шрифтом отмечены ключи, образующие пирамиду на текущем шаге ее построения): 118 Следовательно, процесс построения пирамиды из N элементов «на том же месте» можно описать следующим образом: R:= N; For i:= N Div 2 Downto 1 Do Sift(i, R); Для того, чтобы получить не только частичную, но и полную упорядоченность элементов нужно проделать N сдвигающих шагов, причем после каждого шага на вершину дерева выталкивается очередной (наименьший элемент). Возникает вопрос, где хранить «всплывающие» верхние элементы? Существует такой выход: каждый раз брать последнюю компоненту пирамиды (скажем, это будет х), прятать верхний элемент на место х, а х посылать в начало пирамиды в качестве элемента a[0] и просеивать его в нужное место. В следующей таблице приводятся необходимые в этом случае шаги: Пример преобразования пирамиды в упорядоченную последовательность Этот процесс описывается с помощью процедуры Sift следующим образом: For R:= HighIndex Downto 1 Do Begin x:=a[0]; a[0]:= a[R]; a[R]:= x; Sift(1, R); End; Из примера сортировки видно, что на самом деле в результате мы получаем последовательность в обратном порядке. Но это легко можно исправить, изменив направление отношения порядка в процедуре Sift (в третьем и четвертом операторах If текста про- 119 цедуры Sift, приведенного выше). В результате получаем следующую процедуру PyramidSort, учитывающую специфику индексации вектора a: Procedure PyramidSort; Var R, i,: integer; x: TElement; Begin R:= N; For i:= N Div 2 Downto 1 Do Sift(i, R); For R:= HighIndex Downto 1 Do Begin x:=a[0]; a[0]:= a[R]; a[R]:= x; Sift(1, R); End; С первого взгляда неочевидно, что этот метод сортировки дает хорошие результаты. Ведь элементы с большими ключами вначале просеиваются влево, прежде чем, наконец, окажутся справа. Действительно, эта процедура не рекомендуется для небольшого числа элементов, как, скажем, в нашем примере. Однако для больших значений N пирамидальная сортировка оказывается очень эффективной, и чем больше N, тем эффективнее. Пирамидальная сортировка требует Nlog2N шагов даже в худшем случае. Такие отличные характеристики для худшего случая одно из самых выгодных качеств пирамидальной сортировки. Но в принципе для пирамидальной сортировки, видимо, больше всего подходят случаи, когда элементы более или менее рассортированы в обратном порядке, т. е. для нее характерно неестественное поведение. Очевидно, что при обратном порядке фаза построения пирамиды не требует никаких пересылок. 11.2.8 Быстрая сортировка разделением Этот метод основан на принципе обмена. К. Хоар, создатель метода, окрестил его быстрой сортировкой (QuickSort). Быстрая сортировка основана на том факте, что для достижения наибольшей эффективности желательно производить обмены элементов на больших расстояниях. Реализуется она на основе следующего алгоритма: выбирается любой произвольный элемент массива, называемый медианой далее массив просматривается слева направо до тех пор, пока не будет найден элемент c ключом, большим ключа медианы, допустим это элемент a[i]. Затем массив просматривается справа налево, пока не будет найден элемент a[j], ключ которого меньше ключа медианы. Найденные элементы a[i] и a[j] меняются местами. Затем продолжается процесс «просмотра с обменом», пока два просмотра не 120 встретятся где-то в середине массива. В результате массив разделится на две части: левую с ключами меньшими медианы; и правую с большими ключами. Обычно в качестве медианы выбирается срединный элемент. Если, например, выбрать средний ключ, равный 42, из массива ключей 44, 55, 12, 42, 94, 06, 18, 67, то для того, чтобы разделить массив, потребуются два обмена: Конечные значения индексов i=5 и j=3. Ключи a[0], ..., a[i-2] меньше или равны ключу x=42, ключи a[j], ..., a[HighIndex] больше или равны x. Следовательно, мы получили два подмассива a[k].Кey x.Кey для k=0, ..., i2, a[k].Кey x.Кey для k=j, ..., HighIndex. Наша цель не только разделить исходный массив элементов на большие и меньшие ключи, но также рассортировать его. Однако от разделения до сортировки один шаг: разделив массив нужно сделать то же самое с обеими частями, затем с частями этих частей и т. д., пока каждая часть не будет содержать только один элемент. Этот метод представлен следующей подпрограммой Partition , учитывающей особенности индексации сортируемого массива а: Procedure Partition(L, R: Integer); Var i, j: integer; w, x: TElement; Begin If L=R Then Exit; i:= L; j:= R; x:= a[(L+R) div 2 - 1]; Repeat While a[i-1].Key < x.Key Do i:= i+1; While a[j-1].Key > x.Key Do j:= j-1; If i <= j Then Begin w:= a[i-1]; a[i-1]:= a[j-1]; a[j-1]:= w; i:= i+1; j:= j-1; End; Until i > j; If L < j Then Partition(L, j); If i < R Then Partition(i, R); End; Для запуска процесса сортировки нужно выполнить процедуру QuickSort, которая имеет простую структуру: 121 Procedure QuickSort; Begin Partition(1, N); End; Обменная сортировка разделением самый эффективный из известных методов внутренней сортировки. Это связано с небольшим числом обменов, выполняемых на большие расстояния. Однако быстрая сортировка все же имеет свои «подводные камни». Прежде всего, при небольших значениях N ее эффективность невелика, как и у всех усовершенствованных методов. 11.3 Внешняя сортировка В случае если сортируемые данные не помещаются в оперативной памяти, а расположены на внешнем запоминающем устройстве, то методы их обработки называют «внешними методами», например, «внешний поиск», «внешняя сортировка». Исторически получилось так, что до повсеместного использования файлов прямого доступа сортируемые таблицы большого размера размещали на магнитных лентах, которые допускали только последовательный доступ. При последовательном доступе для перехода от любого текущего элемента, например х, к элементу y, расположенному перед х, приходится просматривать всю исходную таблицу с начала. Пример оперативной структуры с последовательным доступом – линейный односвязный список. В общем случае структура с последовательным доступом характеризуется тем, что в каждый момент имеется непо- средственный доступ к одному и только одному элементу. Это строгое ограничение по сравнению с возможностями, которые дает массив (массив – оперативная структура с прямым доступом), и поэтому здесь приходится применять другие методы сортировки. Основной метод это сортировка слиянием. Слияние означает объединение двух (или более) упорядоченных последовательностей в одну упорядоченную последова- тельность при помощи циклического выбора элементов, доступных в данный момент. Слияние намного более простая операция, чем сортировка; она используется в качестве вспомогательной в более сложном процессе последовательной сортировки. Несмотря на то, что сортировка слиянием может быть применена для обработки оперативных структур с прямым доступом, все же методы такой сортировки разрабатывались специально для магнитных лент. Поэтому последовательная сортировка обычно называется внешней сортировкой. 122 11.3.1 Сортировка прямым слиянием Один из методов сортировки слиянием называется прямым (простым) слиянием и состоит в следующем: 1) исходная последовательность а разбивается на две половины b и с; 2) последовательности b и c сливаются при помощи объединения отдельных элементов в упорядоченные пары; 3) полученной после слияния последовательности присваивается имя а, и по- вторяются шаги 1 и 2; на этот раз упорядоченные пары сливаются в упорядоченные четверки; 4) предыдущие шаги повторяются: четверки сливаются в восьмерки, и весь процесс продолжается до тех пор, пока не будет упорядочена вся последовательность, при этом длины сливаемых последовательностей каждый раз удваиваются. В качестве примера рассмотрим последовательность a: 44, 55, 12, 42, 94, 18, 06, 67 На первом шаге разбиение дает последовательности b: 44, 55, 12, 42 c: 94, 18, 06, 67 Разбиение предполагает, что длина всей последовательности известна и равна N. Тогда начальные N/2 элементов на ленте a последовательно переписываются на ленту b, а элементы второй половины на ленту c. После этого лента a очищается. Затем выполняется фаза слияния. Поскольку в текущий момент доступен только один элемент на ленте, то слияние выполняется так: извлекаются начальные элементы с лент b и c (в примере это элементы 44 и 94). Эти элементы сравниваются друг с другом, и меньший элемент (44 из b) записывается на ленту a. Затем выполняется переход к следующему элементу (к 55 на b) на той ленте, откуда он был извлечен элемент, посланный на ленту a. Теперь второй извлеченный элемент может быть записан на ленту a вторым по порядку с гарантией того, что он больше ранее записанного. Поскольку извлечение сопровождается переходом на одну позицию, то на следующем шаге начальными становятся те элементы, которые раньше были вторыми на лентах b и c. Эти элементы (55 и 18) сливаются на a, образуя упорядоченную пару. Слияние отдельных компонент (которые являются упорядоченными последовательностями длины 1) в упорядоченные пары дает a: 44, 94, | 18, 55, | 06, 12, | 42, 67 . 123 После завершения фазы слияния ленты b и c очищаются. Новое разбиение пополам и слияние упорядоченных пар дают a: 06, 12, 44, 94, | 18, 42, 55, 67. При втором слиянии учитывается то обстоятельство, что последовательные пары элементов упорядочены. Третье разбиение и слияние приводят, наконец, к нужному результату: a: 06, 12, 18, 42, 44, 55, 67, 94 Операция, которая однократно обрабатывает все множество данных, называется фазой, а наименьший подпроцесс, который, повторяясь, образует процесс сортировки, называется проходом или этапом. В приведенном выше примере сортировка производится за три прохода, каждый проход состоит из фазы разбиения и фазы слияния. Для выполнения сортировки требуются три ленты, поэтому процесс называется трехленточным слиянием. Собственно говоря, фазы разбиения не относятся к сортировке, поскольку они никак не переставляют элементы; в каком-то смысле они непродуктивны, хотя и составляют половину всех операций переписи. Их можно удалить, объединив фазы разбиения и слияния. Вместо того чтобы сливать элементы в одну последовательность, результат слияния сразу распределяют на две ленты, которые на следующем проходе будут входными. В отличие от двухфазного слияния этот метод называют однофазным или сбалансированным слиянием. Он имеет явные преимущества, так как требует вдвое меньше операций переписи, но это достигается ценой использования четвертой ленты. Разберем программу слияния подробно; предположим, что данные расположены в виде массива, который, однако, можно рассматривать только строго последовательно. Вместо двух файлов можно легко использовать один массив, если рассматривать его как последовательность с двумя концами. Вместо того чтобы сливать элементы из двух исходных файлов, мы можем брать их с двух концов массива. Направление пересылки сливаемых элементов меняется (переключается) после каждой упорядоченной пары на первом проходе, после каждой упорядоченной четверки на втором проходе и т. д.; таким образом равномерно заполняются две выходные последовательности, пред- ставленные двумя концами одного массива (выходного). После каждого прохода два массива меняются ролями: входной становится выходным и наоборот. Программу можно еще больше упростить, объединив два концептуально различных массива в один двойной длины. Итак, данные будут представлены следующим образом: 124 a: Аrray[1..2*N] Оf TElement; Пусть индексы i и j указывают два исходных элемента, тогда как k и l обозначают два места пересылки. Исходные данные это элементы а[1], ..., а[N]. Очевидно, что нужна булевская переменная up для указания направления пересылки данных. Условие up=true будет означать, что на текущем проходе компоненты a[1], …, a[N] будут пересылаться «направо» в переменные a[N+1], …, a[2N]; если up=false, то a[N+1], …, a[2N] должны переписываться «налево» в a[1], …, a[N]. Значение up строго чередуется между двумя последовательными проходами. И наконец, вводится переменная р для обозначения длины сливаемых последовательностей (р-наборов). Ее начальное значение равно 1, и оно удваивается перед каждым очередным проходом. Для простоты мы будем считать, что N (число элементов в таблице) всегда степень двойки. Итак, программа простого слияния выглядит следующим образом: Procedure Mergesort; Var i, j, Jc, l, t: Word; uр: Вооlеап; p, h, m, q, r : Word ; {а имеет индексы 1..2*N} Begin up:= true; p:= 1; Repeat h:= 1; m:= N; If up Then Begin i:= 1; j:= N; k:= N+1; l:= 2*N End Else Begin k:= 1; l:= N; i:= N+1; j:= 2*N End; Repeat {слияние серий из i и j в k} If m>=p Then q:= p Else q:= m; m:= m-q; If m>=p Then r:= p Else r:= m; m:= m-r; While (q<>0) And (r<>0) Do Begin {слияние} If a[i].Key < a[j].Key Then Begin a[k]:= a[i]; k:= k+h; i:= i+1; q:= q-1 End Else Begin a[k]:= a[j]; k:= k+h; j:=j-1; r:=r-1 End End; {копирование остатка серии из j} While r<>0 Do Begin a[k]:= a[j]; k:= k+h; j:= j-1; r:= r -1 End; {копирование остатка серии из i} While q<>0 Do Begin a[k]:= a[i]; k:= k+h; i:= i+1; q:= q-l End; h:= -h; t:= k; k:= l; l:=t Until m=0; up:= -up; p:=2*p; Until p>=n If -up Then For i:=1 To N Do a[i]:=a[i+N]; End; {Mergesort} 125 Алгоритм сортировки прямым слиянием выдерживает сравнение даже с усовершенствованными методами внутренней сортировки. Но затраты на управление индексами довольно высоки, кроме того, существенным недостатком является использование памяти размером 2N элементов. Поэтому сортировка слиянием редко применяется при работе с массивами, т. е. данными, расположенными в оперативной памяти. 11.3.2 Сортировка естественным слиянием В случае простого слияния мы ничего не выигрываем, если данные уже частично рассортированы. На k-м проходе длина всех сливаемых последовательностей меньше или равна 2k. При этом не учитывается то, что более длинные последовательности могут быть уже упорядочены и, следовательно, их можно сливать. Фактически можно было бы сразу сливать какие-либо упорядоченные подпоследовательности длиной m и k в одну последовательность из m+k элементов. Метод сортировки, при котором каждый раз сливаются две самые длинные возможные подпоследовательности, называется естественным слиянием. Упорядоченную подпоследовательность часто называют цепочкой. Но, поскольку слово «цепочка» чаще используется для обозначения односвязного списка, мы будем использовать слово серия, когда речь идет об упорядоченной подпоследовательности. Сортировка естественным слиянием сливает последовательности не фиксированной длины, а серии. Серии имеют то свойство, что при слиянии двух последовательностей, каждая из которых содержит n серий, возникает одна последовательность, содержащая ровно n/2 серий. Таким образом, на каждом проходе общее число серий уменьшается вдвое, и число необходимых пересылок элементов в худшем случае равно Nlog2N, а в обычном случае даже меньше. Ожидаемое число сравнений, однако, намного больше, так как кроме сравнений, необходимых для упорядочения элементов, требуются еще сравнения соседних элементов каждого файла для определения концов серии. Пусть исходная последовательность элементов задана в виде файла а, который в конце работы должен содержать результат сортировки. Используются две вспомогательные ленты b и с. Каждый проход состоит из фазы распределения, которая распределяет серии поровну из а в b и с, и фазы слияния, которая сливает серии из b и с в а. В качестве примера ниже показан файл а в исходном состоянии (строка 1) и после каждого прохода (строки 2 4). В естественном слиянии участвуют 20 чисел. Заметим, что требуются только три прохода. Сортировка заканчивается, как только число серий в а 126 будет равно 1. (Предполагается, что в исходном файле имеется хотя бы одна непустая серия.) 17, 31, | 05, 59, | 13, 05, 17, 31, 59, | 11, 05, 11, 13, 17, 23, 02, 03, 05, 07, 11, 41, 43, 67, | 11, 23, 29, 47, | 03, 07, 71, | 02, 19, 57, | 37, 61 13, 23, 29, 41, 43, 47, 67, | 02, 03, 07, 19, 57, 71, | 37, 61 29, 31, 41, 43, 47, 59, 67, | 02, 03, 07, 19, 37, 57, 61, 71 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 57, 59, 61, 67, 71 Схематично процесс сортировки естественным слиянием можно отобразить рисунком 11.7. Рисунок 11.7 Фазы сортировки естественным слиянием При просмотре последовательности, выполняемом на фазе разделения (распределения) определяется конец очередной серии. Элемент, расположенный на конце предыдущей серии должен быть больше первого элемента следующей серии, т. е. нужно сравнивать ключи двух последовательных элементов. Однако природа последовательности такова, что непосредственно доступен только один-единственный элемент. Поэтому невозможно избежать «заглядывания вперед», т. е. необходимо считывать в некий «буфер» первый еще не считанный элемент последовательности. Процесс сравнения и выбора ключей при слиянии заканчивается, как только исчерпана одна из двух серий. После этого оставшаяся неисчерпанной серия должна быть просто передана в результирующую серию, т.е. скопирована в ее «хвост». 11.3.3 Сортировка многопутевым слиянием Затраты на любую последовательную сортировку пропорциональны числу требуемых проходов, так как по определению при каждом из проходов копируются все данные. Один из способов сократить это число – распределять серии в более чем две последовательности (ленты). Допустим, исходная последовательность хранится на ленте а. Кроме того, используются r дополнительных лент: b1, b2 , …, br. Тогда r-путевое слияние выполняется сле- 127 дующим образом: первая серия на ленте а распределяется на ленту b1, вторая на ленту b2 и т. д., r-ая серия на ленту br; затем (r+1)-ая серия на ленту b1, (r+2)-ая на ленту b2 и так до тех пор, пока не распределится последняя из серий ленты а. Затем начальные серии всех дополнительных лент, сливаются на ленту а в одну общую серию, вторые серии дополнительных лент сливаются во вторую серию на ленту а и т. д. Подобный процесс может быть представлен схемой, показанной на рисунке 11.8. Рисунок 11.8 Схема r-путевого слияния Таким образом, естественное слияния является 2-х путевым слиянием. Слияние p серий поровну распределенных в r последовательностей (лент) даст в результате p/r серий. Второй проход уменьшит это число до р/r2, третий – до р/r3 и т. д., после k проходов останется r/pk серий. Поэтому общее число проходов, необходимых для сортировки N элементов с помощью r-путевого слияния, равно k=logrN. Поскольку в каждом проходе выполняется N операций копирования, то в самом плохом случае понадобится NlogrN таких операций. На практике многопутевое слияние реализуется как сбалансированное многопутевое слияние с одной единственной фазой, которое предполагает, что в каждом проходе участвует равное количество входных и выходных файлов (лент), в которые по очереди распределяются последовательные серии. Такой алгоритм использует r лент (r четное число), поэтому он базируется на (r/2)-путевом слиянии. Фазы разделения и слияния как бы объединяются в одну фазу, в ходе которой серии из r/2 лент, называемых входными, не сливаются в одну общую последовательность, а тут же разделяются на другие r/2 лент (они называются выходными), после чего входные и выходные последовательности меняются местами. 11.3.4 Многофазная сортировка В основе усовершенствований, реализованных в многофазной сортировке (Polyphase Sort), лежит отказ от жесткого понятия прохода и переход к более изощренному использо- 128 ванию последовательностей, называемых лентами. Этот метод был изобретен Р. Гилстэдом. Продемонстрируем многофазную сортировку на примере с тремя последовательностями (лентами) f1, f2 и f3. В каждый момент сливаются элементы из двух лентисточников и записываются в третью ленту. Как только одна из входных последовательностей исчерпывается, она сразу же становится выходной для операции слияния из оставшейся, неисчерпанной входной последовательности и предыдущей выходной. Поскольку известно, что p серий на каждом из входов трансформируются в p серий на выходе, то нужно только держать список из числа серий в каждой последовательности (а не определять их действительные ключи). Будем считать, что в начале две входные последовательности f1 и f2 содержат соответственно 13 и 8 серий. Следовательно, на первом проходе 8 серий из f1 и f2 сливаются в f3, на втором – 5 серий из f1 и f3 и сливаются в f2 и т. д. В конце концов, в f1 оказывается отсортированная последовательность. Этот процесс показан на рисунке 11.9. Рисунок 11.9 Многофазная сортировка слиянием 21 серии на трех лентах Многофазная сортировка более эффективна, чем сбалансированная многопутевая, поскольку она имеет дело с (r-1)-путевым слиянием, а не с (r/2)-путевым слиянием, если она начинается с r последовательностей (лент). Ведь число необходимых проходов приблизительно равно logrN , где N – число сортируемых элементов, а r – степень операции слияния, – это и определяет значительное преимущество многофазной сортировки. 129 12 ХЕШИРОВАНИЕ И ХЕШ-ТАБЛИЦЫ 12.1 Общие сведения и определения В разделе 10 были рассмотрены алгоритмы поиска элемента в таблице. Эти алгоритмы предполагают, что прежде, чем найти требуемую запись, необходимо организовать просмотр и сравнение с аргументом поиска некоторого количества ключей. Организация таблицы (последовательная, индексно-последовательная, в виде бинарного дерева и т. д.) и порядок, в котором вставляются ключи, определяют то число ключей, которое должно быть проверено до получения нужного ключа. Очевидно, что эффективными методами поиска являются те методы, которые минимизируют число этих проверок. Было показано, что наиболее эффективным методом поиска является бинарный поиск, требующий в наихудшем случае выполнения числа сравнений, приблизительно равного log2N. Однако бинарный поиск не может быть применен для таблиц с произвольным расположением элементов: элементы должны быть расположены в отсортированном порядке. В идеале мы бы хотели иметь такую организацию таблицы, при которой не было бы ненужных сравнений. Посмотрим, возможна ли такая организация. Если каждый ключ должен быть извлечен за один доступ, то положение записи внутри такой таблицы может зависеть только от данного ключа. Оно не может зависеть от расположения других ключей, как это имеет место в дереве. Наиболее эффективным способом организации такой таблицы является одномерный массив, т. е. доступ к каждой записи обеспечивается с помощью ее уникального целочисленного индекса, который определяет позицию элемента в общей таблице. Если ключи записей являются целыми числами, то сами ключи могут использоваться как индексы в массиве. Рассмотрим пример такой системы. Предположим, что некоторая фирма-производитель имеет таблицу с перечнем производимых изделий, состоящую из 1000 наименований изделий, причем каждое изделие имеет уникальный номенклатурный номер из трех цифр. Тогда обычным способом хранения этой таблицы является описание некоторого массива: a[0], a[1], a[2], …, a[HighIndex]. Допустим для определенности, что массив a описывается нотациями (11.1) и (11.2). Общее число элементов по-прежнему обозначаем N, тогда HighIndex = N - 1. В нашем случае можно задать N = 1000, и если номера изделий, составляющие содержимое поля Key, являются ключами, то целесообразно определить, что эти номера используются как индексы в данном массиве. Тогда запись с ключом, например, 67 при вставке размещается в элементе a[67]. В такой таблице легко организовать поиск: эле- 130 мент, соответствующий аргументу поиска ArgSearch, это элемент a[ArgSearch]. Мы видим, что при такой организации таблицы при поиске не нужно производить ни одного сравнения. Та же самая структура может использоваться для организации файла производимых изделий, даже если на складах фирмы накопилось до, например, 200 наименований изделий (при условии что ключи по-прежнему состоят из трех цифр). Хотя многие ячейки в массиве a тогда бы соответствовали несуществующим ключам, эти потери компенсируются преимуществом прямого доступа к записи с информацией о каждом из существующих изделий. К сожалению, такая система не всегда имеет практический смысл. Например, предположим, что фирма имеет некоторую таблицу производимых изделий, состоящую из более 1000 пунктов, и ключ каждой записи является номером изделия из семи цифр. Для применения прямой индексации с использованием полного семизначного ключа потребовался бы массив из 10000000 (10 млн.) элементов. Ясно, что это привело бы к потере неприемлемо большого пространства памяти, поскольку совершенно невероятно, что какаялибо фирма может иметь больше чем несколько тысяч наименований изделий. Поэтому необходим некоторый метод преобразования ключа в какое-либо целое число внутри ограниченного диапазона. В идеале в одно и то же число не должны преобразовываться два различных ключа. К сожалению, такого идеального метода не существует. Попытаемся разработать методы, которые приближаются к идеальным, и определить, какие действия надо предпринять, когда идеальный случай не достигается. Рассмотрим опять пример с таблицей наименований изделий фирмы, в которой каждая запись задается ключом из семизначного номера изделия. Предположим, что фирма имеет не более 1000 наименований изделий и что для каждого изделия используется только одна запись. Тогда для хранения всего файла будет достаточно массива из 1000 элементов. Этот массив индексируется целым числом в диапазоне от 0 до 999 включительно. В качестве индекса записи об изделии в этом массиве используются три последние цифры номера изделия. Это показано на рисунке 12.1. Отметим, что два ключа, которые близки друг к другу как числа (такие как 4618396 и 4618996), могут располагаться дальше друг от друга в этой таблице, чем два ключа, которые значительно различаются как числа (такие как 0000991 и 9846995). Это происходит из-за того, что для определения позиции записи используются только три последние цифры ключа. Функция, которая трансформирует ключ в некоторый индекс в таблице, называется функцией хеширования (hash function). Другие названия функции хеширования: хеш- 131 функция, функция перемешивания. Если h является некоторой хеш-функцией, а Кey некоторый ключ, то h(Кey) называется значением хеш-функции от ключа Кey и является индексом, по которому должна быть помещена запись с ключом Кey. Преобразование ключа элемента в значение индекса называется хешированием (hashing). Массив, используемый для хранения элементов, в котором индексы определяются с помощью хеширования ключей, называется хеш-таблицей (hash table). Рисунок 12.1 Размещение записей в таблице, когда позиция записи определяется по трем последним цифрам ключа Если мы обозначим остаток от деления X на Y как X Mod Y, то хеш-функция для вышеприведенного примера есть h(Кey) = Кey Mod 1000. (12.1) Значения, которые выдает функция h, должны покрывать все множество индексов в таблице. Например, функция Кey Mod 1000 может дать любое целое число в диапазоне от 0 до 999 в зависимости от значения Кey. Как мы вскоре увидим, хорошей идеей является таблица, размер которой немного больше, чем число вставляемых записей. Это иллюстрируется на рисунке 12.1, где несколько позиций таблицы не используются. 132 12.2 Коллизии при хешировании При получении таблицы с помощью преобразования ключей имеет место один недостаток. Предположим, что существуют два различных ключа k1 и k2 (k1 k2) такие, что h(k1) = h(k2). Когда запись с ключом k1 вводится в таблицу, она вставляется в позицию с индексом h(k1). Но когда хешируется ключ k2, получаемая позиция является той же позицией, в которой хранится запись с ключом k1. Ясно, что две записи не могут занимать одну и ту же позицию. Такая ситуация называется коллизией (collision) при хешировании или столкновением. Иногда коллизию называют конфликтом. В примере с изделиями на рисунке 12.1 коллизия при хешировании произойдет, если в таблицу будет добавлена, например, запись с ключом 0596993. Далее мы будем исследовать возможности, как найти решение в такой ситуации. Следует отметить, что хорошей хеш-функцией является такая функция, которая минимизирует коллизии и распределяет записи равномерно по всей таблице. Поэтому и желательно иметь массив с размером больше, чем число реальных записей. Чем больше диапазон хеш-функции, тем менее вероятно, что два ключа дадут одинаковое значение хеш-функции. Конечно, при этом возникает компромисс между временем и пространством. Наличие пустых мест в массиве неэффективно с точки зрения использования пространства, но при этом уменьшается необходимость разрешения коллизий при хешировании, что, следовательно, является более эффективным в смысле временных затрат. Алгоритм, который позволяет распределять в таблице записи, конкурирующие с другими записями в одну ячейку хеш-таблицы, называется методом разрешения коллизий (collision resolution). 12.3 Разрешение коллизий при хешировании Посмотрим, что произойдет, если мы попытаемся ввести в таблицу на рисунке 12.1 некоторую новую запись с ключом 0596993. Используя хеш-функцию Кey Mod 1000, мы найдем, что h(0596993) = 993, и что запись для этого ключа должна находиться в позиции 993 в массиве. Однако позиция 993 уже занята, поскольку там находится запись с ключом 0047993. Следовательно, запись с ключом 0596993 должна быть вставлена в таблицу в другом месте. 12.3.1 Разрешение коллизий методом открытой адресации Самым простым методом разрешения коллизий при хешировании является помещение данной записи в следующую свободную позицию в массиве. Например, на рисун- 133 ке 12.1 запись с ключом 0596993 помещается в ячейку 994, которая пока свободна, поскольку 993 уже занята. Когда эта запись будет вставлена, другая запись, которая хешируется в позицию 993 (с таким ключом, как 8764993) или в позицию 994 (с таким ключом, как 2194994), вставляется в следующую свободную позицию, индекс которой в данном случае равна 995. Этот метод называется линейным зондированием или линейным опробованием (linear probing); он является частным случаем некоторого общего метода разрешения коллизий при хешировании, который называется повторным хешированием или схемой с открытой адресацией (open-addressing schemes). В общем случае функция повторного хеширования rh воспринимает один индекс в массиве и выдает другой индекс. Если ячейка массива с индексом i = h(Кey) уже занята некоторой записью с другим ключом, то функция rh применяется к значению i для того, чтобы найти другую ячейку, куда может быть помещена эта запись. Если ячейка с индексом j = rh(i) = rh(h(key)) также занята, то хеширование выполняется еще (12.2) раз и проверяется ячейка rh(rh(h(key))). Этот процесс продолжается до тех пор, пока не будет найдена пустая ячейка. В нашем примере хеш-функция есть h(key)= Кey Mod 1000, а функция повторного хеширования rh(i)= (i+1) Mod 1000, т. е. повторное хеширование какого-либо индекса есть следующая последовательная позиция в данном массиве, за исключением того случая, что повторное хеширование 999 дает 0. Поиск в такой таблице выполняется по аналогичному алгоритму: 1) аргумент поиска ArgSearch хешируется в индекс i = h(ArgSearch); 2) проверяется i-ая позиция таблицы: если ArgSearch совпадает с ключом i-ой записи, то поиск результативен (искомая запись имеет индекс i), поиск завершается, если совпадения не произошло, то переход к п. 3, или i-ая позиция пуста, поиск безрезультатен, поиск завершен; 3) выполняется повторное хеширование, т. е. проверяется позиция с индексом j = rh(i) = rh(h(ArgSearch)), и результат проверки анализируется так же, как в п. 2. 134 Рассмотрим данный алгоритм более подробно, чтобы понять, можно ли определить свойства некоторой «хорошей» функции повторного хеширования. Сфокусируем наше внимание на количестве повторных хеширований, поскольку это количество определяет эффективность поиска. Выход из цикла повторных хеширований может быть в одном из двух случаев. Или переменная i принимает такое значение, что ключ с индексом rh(i) равен Кey (и в этом случае найдена запись), или переменная i принимает такое значение, что элемент с индексом rh(i) пуст; в этом случае найдена пустая позиция, и запись может быть вставлена. Может случиться, однако, что данный цикл будет выполняться бесконечно. Для этого существуют две возможные причины. Во-первых, таблица может быть полной, так что вставить какие-либо новые записи невозможно. Эта ситуация может быть обнаружена при помощи счетчика числа записей в таблице. Когда этот счетчик равен размеру таблицы, не надо проверять дополнительные позиции. Возможно, однако, что данный алгоритм приведет к бесконечному зацикливанию, даже если имеются некоторые пустые позиции (или даже много таких позиций). Предположим, например, что в качестве функции повторного хеширования используется функция rh(i) = (i+2) Mod 1000. Тогда любой ключ, который хешируется в нечетное целое число, повторно хешируется в следующие за ним нечетные целые числа, а любой ключ, который хешируется в четное число, повторно хешируется в следующие за ним четные целые числа. Рассмотрим ситуацию, при которой все нечетные позиции в таблице заняты, а все четные свободны. Несмотря на тот факт, что половина позиций в массиве свободна, невозможно вставить новую запись, чей ключ хешируется в нечетное число. Конечно, маловероятно, что заняты все нечетные позиции, а ни одна из четных позиций не занята. Но если использовать функцию повторного хеширования rh(i) = (i+200) Mod 1000, то каждый ключ может быть помещен только в одну из пяти позиций (поскольку х Mod 1000 = (х+1000) Mod 1000. При этом вполне возможно, что эти пять позиций будут заняты, а большая часть таблицы будет пустой. Свойство хорошей функции повторного хеширования состоит в том, что для любого индекса i последовательно выполняемые повторные хеширования rh(i), rh(rh(i)), ... располагаются на максимально возможное число целых чисел от 0 до N1 (где N является числом элементов в таблице), причем в идеале на все эти числа. Функция повторного хеширования rh(i)= (i+1) Mod N обладает этим свойством. И дей- 135 ствительно, любая функция rh(i)= (i+с) Mod N, где c некоторая константа такая, что значения с и N являются взаимно простыми числами (т. е. они одновременно не могут делиться нацело ни на какое число, кроме 1), выдает последовательные значения, которые распространяются на всю таблицу. Имеется другая мера пригодности функции хеширования. Рассмотрим функцию повторного хеширования rh(i)= (i+1) Mod N. Предполагаем, что функция хеширования h(Key) выдает индексы, которые равномерно распределены в интервале от 0 до N-1, т.е. вероятность того, что функция h(Key) будет равна какому-либо конкретному числу в этом диапазоне, одинакова для всех чисел. Тогда первоначально, когда весь массив пуст, равновероятно, что некоторая произвольная запись будет помещена в любую заданную пустую позицию в массиве. Однако, когда записи будут вставлены и будет разрешено несколько коллизий при хешировании, это уже не будет справедливо. После большого количества вставок и разрешений коллизий с помощью повторного хеширования может возникнуть следующая ситуация: при вставке записи, у которой ключ хешируется в индекс i, обнаруживается, что i-ая позиция занята записью, ключ которой хешируется в другой индекс. Явление, при котором два ключа, которые хешируются в разные значения, конкурируют друг с другом при повторных хешированиях, называется скучиванием или скоплекнием. При скучивании в таблице образуются локальные участки, в которых располагаются конкурирующие друг с другом записи, тогда как другие части таблицы оказываются незанятыми. Эти участки называются кластерами. Одним способом ослабления эффекта скучивания является использование двойного хеширования, которое состоит в использовании двух хеш-функций h1(key) и h2(key). Функция h1, которая называется первичной хеш-функцией, используется первой при определении той позиции, в которую должна быть помещена запись. Если эта позиция занята, то последовательно используется функция повторного хеширования rh(i) = (i+h2(key)) Mod N до тех пор, пока не будет найдена пустая позиция. Записи с ключами key1 и key2 не будут соревноваться за одну и ту же позицию, если h2(key1) не равно h2(key2). Это справедливо, несмотря на ту возможность, что h1(key1) может в действительности равняться h1(key2). Функция повторного хеширования зависит не только от индекса, который надо повторно хешировать, но также от первоначального ключа. Отметим, что значение h2(key) не нужно еще раз вычислять при каждом повторном хешировании: его 136 необходимо вычислить только один раз для каждого ключа, который надо повторно хешировать. Следовательно, в оптимальном случае функции h1 и h2 должны быть выбраны так, чтобы они выполняли хеширование и повторное хеширование равномерно в интервале от 0 до N1, а также минимизировали скучивание. Такие функции не всегда просто найти. Кроме линейного опробывания существует несколько других схем с открытой адресацией. Рассмотрим две из них. При использовании алгоритма квадратичного опробывания (quadratic probing) поиск точки вставки элемента осуществляется путем проверки не следующей по порядку ячейки, а ячеек, которые расположены все дальше от исходной. Если первое хеширование ключа оказывается безрезультатным и ячейка занята, то проверяется следующая ячейка (элемент массива). В случае неудачи проверяется ячейка, которая расположена через четыре ячейки. Если и эта проверка неудачна, то проверяется ячейка расположенная через девять индексов и т. д., причем последующие повторные опробывания выполняются для ячеек, расположенных через 16, 25, 36 и так далее ячеек. Этот алгоритм позволяет предотвратить образование кластеров, однако он может приводить и к ряду нежелательных проблем. Во-первых, если для многих ключей хеширование генерирует один и тот же индекс, то все их последовательности повторных опробываний будут выполняться вдоль одного и того же пути. В результате образуется кластер, но такой, который кажется распределенным по хеш-таблице. Во-вторых, квадратичное опробывание не гарантирует посещения всех ячеек. Максимум в чем можно быть уверенным, если размер таблицы равен простому числу, это в том, что квадратичное опробывание обеспечит посещение, по меньшей мере, половины всех ячеек хеш-таблицы. Следующая схема с открытой адресацией псевдослучайное опробывание (pseudorandom probing). Этот алгоритм требует наличия программного датчика случайных чисел, начальное значение которого можно устанавливать в любой момент. Алгоритм устанавливает следующую последовательность действий. Выполняется первоначальное хеширование ключа, и полученное хеш-значение, передается в датчик псевдослучайных чисел в качестве его начального значения. Генерируется первое вещественное случайное число в диапазоне [0, 1], и оно умножается на размер таблицы (N) для получения целочисленного значения в диапазоне от 0 до N-1. Это значение и будет индексом проверяемого элемента. Если ячейка занята, то генерируется следующее случайное число, умножается на размер таблицы, и ячейка с полученным значением индекса проверяется. Этот процесс 137 выполняется до тех пор, пока не будет найдена пустая ячейка. Поскольку для одного и того же начального значения генератор будет генерировать одни и те же числа в одной и той же последовательности, для одного и того же хеш-значения всегда будет создаваться одна и та же последовательность опробывания. 12.3.2 Разрешение коллизий методом цепочек Имеется несколько причин, почему повторное хеширование может быть неадекватным методом для обработки коллизий при хешировании. Во-первых, оно предполагает фиксированный размер таблицы. Если число записей превысит этот размер, то невозможно выполнять вставки без выделения таблицы большего размера и повторного вычисления значений хеширования для ключей всех записей, находящихся уже в таблице, используя новую хеш-функцию. Более того, из такой таблицы трудно удалить запись. Например, предположим, что в позиции рosInd находится запись Rec1. При добавлении некоторой записи Rec2, чей ключ k2 хешируется в рosInd, эта запись должна быть вставлена в первую свободную позицию rh(рosInd), rh(rh(рosInd)), .... Предположим, что Rec1 затем удаляется, так что ячейка с индексом рosInd становится свободной. Поиск записи Rec2 начинается с позиции h(k2), что равно рosInd. Но поскольку эта позиция уже свободна, процесс поиска может ошибочно сделать вывод, что записи Rec2 нет в таблице. Одно возможное решение этой проблемы состоит в маркировании удаленной записи как «удаленная» (логическое удаление), а не «свободная», и продолжении поиска, когда встречается такая «удаленная» позиция. Но это реально, если только выполняется небольшое число удалений. В противном случае при неудачном поиске придется организовать поиск по всей таблице, потому что большинство позиций будет отмечено как «удаленные», а не «свободные». Другой метод разрешения коллизий при хешировании называется методом цепочек или методом, использующим связывание (chaining). Он представляет собой организацию связанного списка (цепочки) из всех записей, чьи ключи хешируются в одно и то же значение. Предположим, что хеш-функция выдает значения в диапазоне от 0 до N-1. Тогда описывается некоторый массив arrHeader, имеющий размер N и состоящий из узлов заголовков. Элемент arrHeader[i] указывает на список всех записей, чьи ключи хешируются в i. Вставка c помощью хеширования осуществляет доступ к заголовку списка arrHeader[k], где k = h(Key). Затем производится вставка элемента с ключом Key в k- 138 ый список. На рисунке 12.2 показан метод цепочек. Предполагается, что имеется массив заголовков из 10 элементов и что хеш-функция равна Key Mod 10. Предполагается также, что включение очередного элемента производится в конец списка. На рисунке представлен пример поступления ключей в таком порядке: 75, 66, 42, 192, 91, 40, 49, 87, 67, 16, 417, 130, 372, 227 Рисунок 12.2 Разрешение коллизий методом цепочек Удаление узла из таблицы, которая построена по методу цепочек, заключается просто в исключении узла из связанного списка. Удаленный узел никак не влияет на эффективность алгоритма поиска. Алгоритм будет работать так, как если бы этот узел никогда не вставлялся в таблицу. Отметим, что эти списки могут быть динамически переупорядочены для получения большей эффективности поиска. Поиск выполняется очень просто: сначала аргумент поиска ArgSearch хешируется в некоторый индекс, допустим в индекс k, а затем в k-ом списке осуществляется поиск ключа, равного значению ArgSearch. Основным недостатком метода цепочек является то, что для узлов указателей требуется дополнительное пространство памяти. Однако в алгоритмах, которые используют метод цепочек, первоначальный массив меньше, чем в алгоритмах, которые используют повторное хеширование. Это происходит из-за того, что при методе цепочек ситуация не становится критичной, если весь массив становится заполненным. Всегда имеется возможность выделить дополнительные узлы и добавить их к различным спискам. Конечно, если эти списки станут очень длинными, то теряет смысл вся идея хеширования. 139 12.4 Функции хеширования Обратимся к вопросу о том, как выбрать хорошую хеш-функцию. Ясно, что эта функция должна создавать как можно меньше коллизий при хешировании, т. е. она должна равномерно распределять ключи на имеющиеся индексы в массиве. Конечно, нельзя определить, будет ли некоторая конкретная хеш-функция распределять ключи правильно, если эти ключи заранее не известны. Однако, хотя до выбора хеш-функции редко известны сами ключи, некоторые свойства этих ключей, которые влияют на их распределение, обычно известны. Понятно, что для различных типов ключей должны быть использованы различные функции хеширования. Функция хеширования, предназначенная для целочисленного ключа, будет отличаться от хеш-функции, применяемой к строковому ключу. В идеале функция хеширования должна создавать значения индексов, которые внешне никак не связаны с ключами. Иначе говоря, очень похожие ключи должны приводить к созданию различных хеш-значений. 12.4.1 Хеш-функции для целочисленных ключей 1) Наиболее известная хеш-функция (которую мы использовали в примерах этого раздела) использует метод деления, при котором некоторый целый ключ делится на размер таблицы и остаток от деления берется в качестве значения хеш-функции. Эта хешфункция имеет вид h(Кey) = Кey Mod N. Предположим, однако, что N равно 1000 и что все ключи оканчиваются на три одинаковые цифры (например, последние три цифры номера изделия могут обозначать номер фабрики и программа пишется для этой фабрики). Тогда остаток от деления на 1000 для всех ключей будет одним и тем же, так что для всех записей, кроме первой, будет происходить коллизия при хешировании. Ясно, что при таком наборе ключей должна использоваться другая хеш-функция. Было найдено, что наилучшие результаты для метода деления, как и для большинства других методов, получаются тогда, когда размер таблицы N является простым числом (т. е. N не делится ни на какое положительное целое число, кроме 1 и N). 2) Значение хеш-функции, использующей метод умножения, формируется следующим образом. Допустим, что число позиций в таблице, обозначаемое как N, равно целой степени числа 2, т. е. N =2n, где n – целое. Представим хешируемый ключ Кey в виде 140 двоичного числа, умножим его на некоторое заранее выбранное значение и выделим в произведении Кey дробную часть. Обозначим эту дробную часть как {Кey}. В методе умножения в качестве значения хеш-функции принимается значение, представленное n старшими разрядами двоичного представления {Кey}. Иными словами, h(Кey) = ]N{Кey}[ , где ]х[ наибольшее целое, не превосходящее х. Заметим, что в методе умножения, во-первых, в качестве значения рекомендуется использовать иррациональное число, которое близко к длине машинного слова; хорошие результаты дает значение =(Sqrt(5)1)/2, где Sqrt функция вычисления квадратного корня; во-вторых, требование, заключающееся в том, чтобы N была целой степенью двойки, необязательно. 3) В методе преобразования системы счисления ключ представляется в некоторой р-ичной системе счисления: Кey = d0 + d1p + d2p2 + … Выбирается основание q новой системы счисления такое, что q < p. Пусть S – некоторое целое число. Тогда для метода преобразования системы счисления значение хешфункции вычисляется в новой системе счисления со «старыми» коэффициентами: h(Кey) = d0 + d1q + d2q2 + … + dS-1qS-1 . Очевидно, S ограничивает порядок значения хеш-функции в q-ичной системе счисления. Трудоемкость этого метода больше, чем у предыдущих методов, поскольку для вычисления значения h(Кey) нужно S операций умножения и сложения. 4) Для хеш-функции, использующей метод деления многочленов, рассматривается значение ключа, выраженное в двоичной системе счисления, которое записывается так: Кey = b0 + b12 + b222 + … + bm-12m-1, и ключ представляется в виде многочлена Кey(t) = b0 + b1t + b2t2 + … + bm-1tm-1, причем сохраняются те же коэффициенты. Пусть заранее заданы коэффициенты вспомогательного многочлена C(t) = c0 + c1t + c2t2 + … + cm-1tm-1, В качестве значения хеш-функции используется остаток от деления Кey(t) на C(t), рассматриваемый в двоичной системе счисления. Если в качестве C(t) выбрать 141 простой неприводимый многочлен, то при key1 key2 обязательно выполнится условие h(key1) h(key2), т. е. эта хеш-функция обладает сильным свойством рассеивания скученности. 5) При использовании метода, известного как метод середины квадрата, ключ умножается сам на себя и в качестве индекса используется несколько средних цифр этого квадрата. Если данный квадрат рассматривается как десятичное число, то размер таблицы должен быть некоторой степенью 10, а если он рассматривается как двоичное число, то размер таблицы должен быть степенью 2. Причиной возведения числа в квадрат до извлечения средних цифр является то, что все цифры первоначального числа дают свой вклад в значение средних цифр квадрата. 6) При методе свертки ключ разбивается на несколько сегментов, над которыми выполняется операция сложения или нетождественности для формирования хешфункции. Например, предположим, что внутреннее представление некоторого ключа в виде последовательности разрядов имеет вид 010111001010110 и для индекса отводится пять двоичных разрядов. Над тремя последовательностями разрядов 01011, 10010 и 10110 выполняется операция нетождественности: 01011 11001 10010 10110 11001 01111 результат применения операции нетождественности, что дает 01111 или двоичное представление числа 15. (Операция нетождественности двух разрядов дает 1, если значения этих двух разрядов различны, и 0, если значения их равны.) Имеется много других хеш-функций, каждая со своими преимуществами и недостатками в зависимости от набора хешируемых ключей. При выборе хеш-функции важна эффективность ее вычисления, так как поиск некоторого объекта за одну попытку не будет эффективнее, если на эту попытку затрачивается больше времени, чем на несколько попыток при альтернативном методе. 12.4.2 Хеш-функции для строковых ключей Если ключи не являются числами, то они должны быть преобразованы в целые числа перед применением описанных выше хеш-функций. Для этого имеется несколько способов. Например, для строки символов в качестве двоичного числа может интерпретироваться внутреннее двоичное представление кода каждого символа. Недостатком этого яв- 142 ляется то, что для большинства ЭВМ двоичные представления всех букв или цифр очень похожи друг на друга. В методе слияния для создания некоторого целого числа используется порядковый номер в ANSI-последовательности каждой буквы. Так, заглавная буква русского алфавита ’И’ представляется цифрами 200, а малая буква ’в’ представляется цифрами 226. Ключ «Иван» после слияния всех номеров букв представляется целым числом 200226224237. Когда существует некоторое целое представление строки символов, то для сведения его к приемлемому размеру может быть использован метод свертки или середины квадрата. Метод весовых коэффициентов использует значение позиции каждого символа во избежание коллизий при использовании анаграмм в качестве ключей (анаграмма это слово, полученное из другого слова перестановкой его букв). Этот метод реализуется подпрограммой SimpleHash, которая имеет следующий вид: Function SimpleHash(Const aKey: String; N: Integer): Integer; Var i: Integer; Hash: LongInt; Begin Hash:= 0; For i:= 1 To Length(aKey) Do Hash:= ((Hash*17) + Ord(aKey[i])) Mod N; Result:= Hash; If Result < 0 Then Inc(Result, N); End; Эта подпрограмма воспринимает два параметра: первый из них хешируемая строка, второй число ячеек в таблице. Алгоритм по циклу изменяет переменную Hash, умножая ее текущее значение на простое число 17 и прибавляя порядковый номер i-ого символа; завершается изменение делением по модулю на размер таблицы. Заключительный If требуется потому, что промежуточное значение переменной может быть отрицательным, а программа, вызывающая эту функцию, ожидает получить результат, значение которого находится в диапазоне от 0 до N-1. 143 13 ПОИСКОВЫЕ ДЕРЕВЬЯ 13.1 Бинарное дерево поиска 13.1.1 Структура бинарного дерева поиска и алгоритм поиска В подразделе 10.7 бинарное дерево поиска представлено как структура, используемая в одном из возможных методов поиска. Перейдем к более детальному рассмотрению подобных деревьев. Как следует из названия, бинарное дерево поиска (binary search tree) в первую очередь является бинарным деревом, как показано на рисунке 10.3. Такое дерево может быть представлено связанной структурой данных (естественное представление дерева, см. п. 9.4.1), в которой каждая вершина является записью. В дополнение к полю (или полям) ключа key и сопутствующих данных, каждая вершина содержит поля left, right и parent, которые являются указателями на левую и правую дочерние вершины и на родительскую вершину соответственно. Если дочерняя или родительская вершины отсутствуют, то соответствующее поле содержит значение Nil. Единственная вершина, у которой указатель parent равен Nil, – это корень дерева. Ключи в бинарном дереве хранятся в упорядоченном виде: для каждой текущей вершины ключ левой дочерней вершины меньше или равен ключу самой текущей вершины, а этот ключ, в свою очередь, меньше или равен ключу правой дочерней вершины. Свойство бинарного дерева поиска позволяет вывести все ключи, находящиеся в дереве, в отсортированном порядке с помощью смешанного обхода (обхода in-order), излагаемого в п. 9.4.3. Алгоритм поиска использует упорядоченность вершин дерева. Поиск выполняется следующим образом. Поиск начинается с корневой вершины, которая становится текущей. Затем аргумент поиска сравнивается с ключом текущей вершины. Если они равны, то поиск завершается результативно – текущая вершина является искомой вершиной. В противном случае, если аргумент поиска меньше ключа текущей вершины, то текущим становится левая дочерняя вершина. Если он больше, то текущей становится правая дочерняя вершина. Снова выполняется шаг сравнения. Со временем будет либо найден искомый элемент, либо встретится очередной указатель на дочерний элемент, который равен Nil, что свидетельствует об окончании поиска, который безуспешен. Необходимо отметить одну особенность приведенного алгоритма в случае поиска по вторичному ключу. Если в дереве имеется несколько элементов с равными ключами, то не существует никаких гарантий, что будет найдена конкретная вершина с соответствующим 144 ключом. Следовательно, дублирование ключей в дереве поиска не допускается. На практике различение ключей обеспечивается использованием, кроме главного ключа, дополнительного младшего ключа (или ключей). В результате определение дерева бинарного поиска будет формулироваться следующим образом: это дерево, в котором ключ левой дочерней вершины строго меньше ключа данной вершины, который, в свою очередь, строго меньше ключа правой дочерней вершины. Алгоритм поиска в дереве бинарного поиска имитирует бинарный поиск в массиве. В каждой текущей вершине принимается решение об игнорировании вершин в одном из дочерних поддеревьев. Если дерево поиска сбалансировано (см. п. 9.4.2), алгоритм поиска является операцией типа О(log(N)), где N – число вершин в дереве. Под сбалансированным деревом понимается дерево, в котором длина пути от корня до любого листа приблизительно одинакова, для разных листьев она может отличаться не более, чем на единицу. Сбалансированное дерево имеет минимальное количество уровней, необходимое для заданного количества вершин. На рисунке 13.1 показаны два бинарных дерева поиска. Дерево на рисунке 13.1б менее эффективно для поиска, поскольку его высота равна 4, в отличие от дерева на рисунке 13.1а, высота которого равна 2. Рисунок 13.1 – Бинарные деревья поиска 13.1.2 Вставка элемента в бинарное дерево поиска Вставить новую вершину в бинарное дерево поиска довольно просто. С помощью алгоритма поиска, изложенного ранее, отыскивается вершина с одной или двумя нулевыми ссылками left или (и) right. Если ключ вставляемого элемента меньше ключа вер- 145 шины с нулевой ссылкой left, то эта ссылка направляется к новой вершине, если больше, то к новой вершине направляется ссылка right, если она была нулевой. В результате вставленный элемент оказывается листом (его обе ссылки left и right являются нулевыми). На рисунке 13.2 показан результат вставки в дерево бинарного поиска вершины с ключом 13. Рисунок 13.2 – Вставка в дерево бинарного поиска элемента с ключом 13. Светлые вершины располагаются на пути от корня к позиции вставки; пунктиром указана связь, добавляемая при вставке новой вершины Алгоритм вставки сопряжен с одной проблемой. Хотя алгоритм гарантирует создание допустимого дерева бинарного поиска, после выполнения алгоритма дерево может быть неоптимальным или неэффективным. Пусть в пустое дерево бинарного поиска вставляются элементы с ключами 2, 5, 7, 8. С ключом 2 все просто – он становится ключом корня. Вершины с остальными ключами добавляются в качестве правых дочерних вершин для предшествующих ключей. Результат представляет длинное вытянутое вырожденное дерево, показанное на рисунке 13.3. Рисунок 13.3 – Вырожденное дерево бинарного поиска 146 Если бы можно было гарантировать случайный порядок вставки ключей и вершин, или если бы общее количество вершин было очень небольшим, описанный алгоритм вставки оказался бы приемлемым. Однако в общем случае подобную гарантию нельзя дать, поэтому необходимо использовать более сложный метод вставки, частью которого является стремление сбалансировать дерево бинарного поиска. Этот метод балансировки используется в красно-черных деревьях. 13.1.3 Удаление из бинарного дерева поиска Процедура удаления заданной вершины z из бинарного дерева поиска рассматривает три возможные ситуации. Если у удаляемой вершины нет дочерних вершин, т. е. удаляемая вершина – лист, то у его родителя указатель на z получает значение Nil. Если у удаляемой вершины z только одна дочерняя вершина, то с помощью «переброски» указателя от родителя вершины z к ее дочерней вершине, как это показано на рисунке 13.4. Рисунок 13.4 – Удаление из дерева бинарного поиска элемента с ключом 18, который имеет только одну дочернюю вершину. Двойная стрелка указывает на результат выполнения удаления Если у удаляемой вершины z имеется две дочерних вершины, то определяется следующая за ним вершина s, у которой нет левого сына. Затем все данные, кроме структурных ссылок, из вершины s копируются в поля удаляемого элемента, а вершина s удаляется путем создания новой связи между ее родителем и сыном. Эти преобразования иллюстрируются рисунком 13.5. 147 Рисунок 13.5 – Удаление из дерева бинарного поиска элемента с ключом 5, который имеет две дочерних вершины. Вершина 6 удаляется, ее данные копируются в слот вершины 5. В литературных источниках показано, что бинарные деревья поиска высоты h обеспечивают выполнение базовых операций за время О(h). К базовым операциям в данном случае относятся операции поиска, вставки, удаления, определения максимального и минимального ключа и некоторые другие операции Таким образом, операции выполняются тем быстрее, чем меньше высота дерева. Однако в наихудшем случае, когда при плохом расположении ключей вставляемых вершин дерево приближается к вырождению, эффективность бинарного дерева поиска ничуть не лучше, чем эффективность, которую обеспечивает линейный связный список. 13.2 Красно-черные деревья 13.2.1 Определение красно-черного дерева, структура его элементов Красно-черные деревья представляет собой одну из множества сбалансированных схем деревьев поиска, которые гарантируют время выполнения операций над динамическим множеством О(log(N)) даже в наихудшем случае. В 1978 году Гюиба и Седжвик предложили концепцию красно-черного дерева. Красно-черные деревья (RB-деревья) – это структуры данных, используемые для реализации карт преобразования данных в библиотеке стандартных шаблонов Си++. Красночерный алгоритм предоставляет быстрый и эффективный метод балансировки дерева бинарного поиска, требующий для каждой вершины не слишком много дополнительного объема памяти для хранения информации, необходимой для балансировки. 148 Красно-черное дерево представляет собой бинарное дерево поиска с одним дополнительным полем цвета каждой вершины. Цвет вершины может быть либо красным, либо черным. В соответствии с ограничениями, накладываемыми на вершины дерева, красночерные деревья являются приближенно сбалансированными. Каждая вершина дерева содержит поля color, left, right, parent и информационные поля, среди которых выделим поле ключа Key. Если у некоторой вершины не существует дочерней вершины или родителя, то соответствующие указатели left, right или parent принимают значения Nil. Эти значения Nil рассматриваются как указатели на внешние вершины (естественно несуществующие, фиктивные). Внешние вершины, следовательно, являются листьями. При этом все обычные вершины, содержащие поле ключа, определяются как внутренние вершины. Бинарное дерево поиска является красно-черным деревом, если оно удовлетворяет следующим красно-черным свойствам: 1) каждая вершина является красной или черной; 2) корень дерева является черным; 3) каждая внешняя вершина является черной; 4) если вершина – красная, то обе ее дочерние вершины – черные; 5) для каждой вершины все пути от нее до листьев, являющихся потомками данной вершины, содержит одно и то же количество черных вершин. На рисунке 13.6 показан пример красно-черного дерева. Рисунок 13.6 – Пример красно-черного дерева 149 Количество черных вершин на пути от вершины z (не считая саму вершину) к листу называется черной высотой вершины (black-height) и обозначается bh(z). На рисунке 13.6 возле некоторых вершин указана их черная высота. В соответствии со свойством 5 красно-черных деревьев, черная высота вершины – точно определяемое значение. Черной высотой дерева считается черная высота ее корня. Для красно-черного дерева справедливо следующее утверждение: красно-черное дерево с N внутренними вершинами имеет высоту не более чем 2lg(N + 1). Непосредственным следствием данного утверждения является то, что базовые операции на динамическими множествами при использовании красно-черных деревьев выполняются за время О(log(N))., поскольку, как показано в подразделе 13.1, время выполнения этих операций высотой h составляет О(h), а любое красно-черное дерево с N вершинами является деревом поиска высотой 2lg(N + 1). 13.2.2 Повороты Операции вставки и удаления, будучи применены к красно-черному дереву с N вершинами изменяют дерево. В результате выполнения этих операций могут нарушаться красно-черные свойства, перечисленные ранее. Для восстановления этих свойств необходимо изменить цвета некоторых вершин, а также структуру его указателей. Изменения в структуре указателей выполняются при помощи поворотов (rotations), которые представляют собой локальные операции в дереве поиска, сохраняющие свойство бинарного дерева поиска. На рисунке 13.7 показаны два типа поворотов – левый и правый (здесь , и – произвольные поддеревья). При выполнении левого поворота в вершине х предполагается, что ее правая дочерняя вершина y не является листом. Левый поворот выполняется «вокруг» связи между х и y, делая y новым корнем поддерева, левой дочерней вершиной которого становится х, а бывший левый сын вершины y – правым сыном х. Рисунок 13.7 – Операции поворота в бинарном дереве 150 При повороте изменяются только указатели, все остальные поля сохраняют свое значение. 151 ЛИТЕРАТУРА 1. Бакнелл Д.М. Фундаментальные алгоритмы и структуры данных в Delphi. СПб: ООО «ДиаСофтЮП», 2003. 506 с. 2. Вирт Н. Алгоритмы и структуры данных. СПб: Невский диалект, 2001. – 352 с. 3. Гудрич М. Т. Структуры данных и алгоритмы в Java. / М. Т. Гудрич, Р. Тамассия. – Мн.: Новое знание, 2003. – 671 с. 4. Кормен Т. Х., Лейзерсон Ч. И., Ривест Р. Л., Штайн К. Алгоритмы: построение и анализ. – М.: Издательский дом «Вильямс», 2009. – 1296 с. 5. Круз Р. Л. Структуры данных и проектирование программ. – М.: БИНОМ. Лаборатория знаний, 2008. – 765 с. 6. Седжвик Р. Фундаментальные алгоритмы на С. Части 14: Анализ / Структуры данных / Сортировка / Поиск. К.: Издательство «ДиаСофт», 2003. 672 с. 7. Седжвик Р. Фундаментальные алгоритмы на С. Часть 5: Алгоритмы на графах. К.: Издательство «ДиаСофт», 2003. 496 с. 8. Архангельский А.Я. Программирование в Delphi 6. М.: ЗАО «Издательсво БИНОМ», 2002. 1120 с. 9. Гетц К., Гилберт М. Программирование на Visual Basic 6 и VBA. Руководство разработчика. К.: Издательская группа BHV, 2001. 912 с. 10. Гук М. Аппаратные средства IBM PC. Энциклопедия. СПб: Питер, 2003. 928 с. 11. Кнут Д. Э. Искусство программирования, том 1. Основные алгоритмы. М.: Издательский дом «Вильямс», 2002. 720 с. 12. Кнут Д. Э. Искусство программирования, том 3. Сортировка и поиск. М.: Издательский дом «Вильямс», 2001. 832 с. 13. Либерти Д. Освой самостоятельно С++ за 21 день. М.: Издательский дом «Вильямс», 2002. 832 с. 14. Лэнгсам Й., Огенстайн М., Тененбаум А. Структура данных для персональных ЭВМ. – М.: Мир, 1989. – 475 с. 15. Системное программное обеспечение / А.В. Гордеев, А.Ю. Молчанов СПб: Питер, 2003. 736 с. 16. Структуры и организация данных в компьютере. Учебное пособие / Лакин В.И., Романов А.В. – Мн.: НПООО «Пион», 2001 – 160 с.