Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Курс Лекций.doc
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
982.53 Кб
Скачать

Void WalkTree(Node *p) {

     if (P == NIL) return;

     WalkTree(P-Left);

     /* Здесь исследуем P-Data */

     WalkTree(P-Right);

 }

 WalkTree(Root);

Чтобы получить отсортированный список узлов разделенного списка, достаточно пройтись по ссылкам нулевого уровня. Вот так:

Node *P = List.Hdr-Forward[0];

while (P != NIL) {

    /* Здесь исследуем P-Data */

    P = P-Forward[0];

}

Память. Минимизация памяти, которая уходит на "накладные расходы", особенно важна, когда нужно хранить много маленьких узлов.

Для хеш-таблиц требуется только один указатель на узел. Кроме того, требуется память под саму таблицу.

Для красно-черных деревьев в каждом узле нужно хранить ссылку на левого и правого потомка, а также ссылку на предка. Кроме того, где-то нужно хранить и цвет узла! Хотя на цвет достаточен только один бит, из-за выравнивания структур, требуемого для эффективности доступа, как правило, будет потрачено больше места. Таким образом, каждый узел красно-черного дерева требует памяти, которой хватило бы на 3-4 указателя.

Для разделенных списков в каждом узле имеется ссылка нулевого уровня. Вероятность иметь ссылку уровня 1 равна 1/2. Вероятность иметь ссылку уровня 2 равна 1/4. В общем, количество ссылок на узел равно

n = 1 + 1/2 + 1/4 + ... = 2.

Время. Алгоритм должен быть эффективным. Это особенно важно, когда ожидаются большие объемы данных. В таблице 3.2 сравниваются времена поиска для каждого алгоритма. Обратите внимание на то, что наихудшие случаи для хеш-таблиц и разделенных списков чрезвычайно маловероятны. Экспериментальные данные описаны ниже.

Простота. Если алгоритм короток и прост, при его реализации и/или использовании ошибки будут допущены с меньшей вероятностью. Кроме того, это облегчает проблемы сопровождения программ. Количества операторов, исполняемых в каждом алгоритме, также содержатся в таблице 3.2.

Таблица 3.2. Сравнение алгоритмов ведения словарей

метод

операторы

среднее время

время в худшем случае

хеш-таблицы

26

O(1)

O(n)

несбалансированные деревья

41

O(lg n)

O(n)

красно-черные деревья

120

O(lg n)

O(lg n)

разделенные списки

55

O(lg n)

O(n)

В таблице 3-3приводится среднее время вставки, поиска и удаления 65,536 (216) случайных элементов. В этом эксперименте размер хеш-таблицы равнялся 10009, для слоёных списков допускалось до 16 слоев. Хотя некоторое различие времен для этих четырех методов и наблюдается, результаты достаточно близки, так что для выбора алгоритма нужны какие-то другие соображения.

Таблица 3.3. Среднее время (мсек) для 65536 случайных элементов

метод

вставка

поиск

удаление

хеш-таблицы

18

8

10

несбалансированные деревья

37

17

26

красно-черные деревья

40

16

37

разделенные списки

48

31

35

В таблице 3.4 приведены средние времена поиска для двух случаев: случайных данных, и упорядоченных, значения которых поступали в возрастающем порядке. Упорядоченные данные являются наихудшим случаем для несбалансированных деревьев, поскольку дерево вырождается в обычный односвязный список. Приведены времена для "одиночного" поиска. Если бы нам понадобилось найти все 65536 элементов, то красно-черному дереву понадобилось бы .6 секунд, а несбалансированному - около 1 часа.

Таблица 3.4. Среднее время поиска (мсек.)

случайные  данные

Кол-во элементов

хеш-таблицы

несбаланс. деревья

красно-черные деревья

слоёные списки

16

4

3

2

5

256

3

4

4

9

4,096

3

7

6

12

65,536

8

17

16

31

упорядоченные данные

16

3

4

2

4

256

3

47

4

7

4,096

3

1,033

6

11

65,536

7

55,019

9

15

 

Внешняя сортировка

В предыдущих алгоритмах предполагалось, что все имеющиеся данные помещаются в памяти. Однако, довольно часто данные слишком велики и для работы с ними нужны адекватные методы. В данном разделе исследуются методы сортировки (внешняя сортировка) и реализации словарей (Б-деревья) для такого случая.

Проще всего отсортировать файл так: загрузить его, отсортировать его в памяти, затем записать результат. Если файл настолько велик, что загрузить его в оперативную память не удается, приходится применять разнообразные методы внешней сортировки. Мы рассмотрим здесь внешнюю сортировку, использующую выбор с замещением для получения начальных отрезков, за которым следует многофазное слияние для слияния отрезков в один отсортированный файл. Очень рекомендую книжку Кнута [1998] [Knuth] - неисчерпаемый источник дополнительной информации.  

Теория

Для определенности я буду считать, что данные располагаются на одной или более бобин магнитной ленты. На рис. 4-1 иллюстрируется трехпутевое многофазное слияние. В начале, на фазе А, все денные находятся на лентах Т1 и Т2. Предполагается, что начало каждой ленты - внизу картинки. Имеется два упорядоченных отрезка на Т1: 4-8 и 6-7. На ленте Т2 - один отрезок 5-9. На фазе B мы сливаем первый отрезок с ленты Т1 (4-8) с первым отрезком Т2 (5-9) и получаем более длинный отрезок на ленте Т3 (4-5-8-9). На фазе С мы просто переименовываем ленты, так чтобы можно было повторить слияние. На фазе D мы повторяем слияние, получив результат на ленте Т3.

Фаза

T1

T2

T3

A

7 6 8 4

9 5

B

7 6

9 8 5 4

C

9 8 5 4

7 6

D

9 8 7 6 5 4

Рис. 4-1. Сортировка слиянием

В приведенном тексте опущены некоторые интересные детали. Например, как были созданы начальные возрастающие отрезки? Кроме того, вы обратили внимание: они слиты так, что не потребовалось создавать дополнительные отрезки? Перед тем, как я объясню, каким образом были созданы начальные отрезки, позвольте мне слегка отвлечься.

В 1202 Леонардо Фибоначчи в книге Liber Abbaci (Книга об абаке) рассмотрел следующую задачу: Сколько пар кроликов можно получить за год, если в начале была лишь одна пара? Предположим, что каждая пара кроликов производит потомство каждый месяц, становится способной к воспроизводству также за один месяц, а также, что кролики не мрут. Спустя один месяц у нас будет 2 пары кроликов, спустя 2 месяца - 3 пары. Спустя еще месяц исходная пара и пара, рожденная в 1-й месяц, дадут каждая по паре, так что всего у нас будет 5 пар. Ряд, в котором каждый член является суммой двух предыдущих членов, называется последовательностью Фибоначчи:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ... .

Любопытно, что ряды Фибоначчи встречаются в самых разных ситуациях - от изучения расположения цветов на растении до оценки эффективности алгоритма Евклида. Неудивительно, что издается журнал Fibonacci Quarterly (Ежеквартальный Фибоначчи). И, как вы уже, конечно, догадались, ряд Фибоначчи имеет прямое отношение к порождению начальных отрезков при внешней сортировке.   Вспомним, что сначала у нас был один отрезок на ленте Т2 и два - на ленте Т1. Обратите внимание - числа {1,2} являются двумя последовательными членами ряда Фибоначчи. После первого слияния у нас появился один отрезок на Т1 и один на Т2. Заметим, что числа  {1,1} - тоже два последовательных члена ряда Фибоначчи, только на шаг раньше. Мы, таким образом, готовы предсказать, что если бы у нас было 13 отрезков на Т2 и 21 на Т1 {13,21}, то после одного прохода у нас было бы 8 отрезков на Т1 и 13 на Т3 {8,13}. Последовательные проходы привели бы нас к отрезкам {5,8}, {3,5}, {2,3}, {1,1} и {0,1}, т.е. всего понадобилось бы 7 проходов. Этот порядок идеален, при нем требуется минимальное число проходов. Если данные действительно находятся на ленте, минимизация числа проходов очень важна, поскольку ленты может понадобиться снимать и устанавливать после каждого прохода. В случае, когда имеется более двух лент, применяются числа Фибоначчи высшего порядка.

Сначала все данные располагаются на одной ленте. Лента читается и отрезки распределяются по другим лентам, имеющимся в системе. после того, как созданы начальные отрезки, они сливаются, как описано выше. Один из методов, который можно использовать для создания начальных отрезков, состоит в чтении порции записей в память, их сортировке и записи результата на ленту. Выбор с замещением позволяет нам получать более длинные отрезки. Этот алгоритм работает с буфером, располагающимся в оперативной памяти. Сначала мы заполняем буфер. Затем повторяем следующие шаги до тех пор, пока не будут исчерпаны входные данные:

Выбрать запись с наименьшим ключом, т.е. с ключом, значение которого значению ключа последней прочитанной записи.

Если все "старые" ключи меньше последнего ключа, то мы достигли конца отрезка. Выбираем запись с наименьшим ключом в качестве первого элемента следующего отрезка.

Записываем выбранную запись.

Заменяем выбранную и записанную запись на новую из входного файла.

На рис. 4-2 выбор с замещением иллюстрируются для совсем маленького файла. Начало файла - справа. Чтобы упростить пример, считается, что в буфер помещается всего лишь 2 записи. Конечно, в реальных задачах в буфер помещаются тысячи записей. Мы загружаем буфер на шаге В и записываем в выходной файл запись с наименьшим номером = 6. На шаге D ею оказалась запись с ключом 7. Теперь мы заменяем ее на новую запись из входного файла - с ключом 4. Процесс продолжается до шага F,  где оказывается, что последний записанный ключ равен 8 и все ключи меньше 8. В этот момент мы заканчиваем формирование текущего отрезка и начинаем формирование следующего.

Шаг

Вход

Буфер

Выход

A

5-3-4-8-6-7

B

5-3-4-8

6-7

C

5-3-4

8-7

6

D

5-3

8-4

7-6

E

5

3-4

8-7-6

F

5-4

3 | 8-7-6

G

5

4-3 | 8-7-6

H

5-4-3 | 8-7-6

Рис. 4-2. Выбор с замещением

Обратите внимание мы храним записи в буфере до тех пор, пока не наступит время записать их в выходной файл. Если вход случаен, средняя длина отрезков равна примерно удвоенной длине буфера. Однако, если данные хоть как-то упорядочены, отрезки могут быть очень длинными. Вот почему этот метод, вообще говоря, эффективен более промежуточных, частичных сортировок.

Прочитав из входного файла очередную запись, мы ищем наименьший ключ, который = последнего считанного. При этом мы, конечно, можем просто сканировать записи в буфере. Однако, если таких записей тысячи, время поиска может оказаться недопустимо большим. Если на этом этапе использовать двоичные деревья, нам понадобится всего лишь lg n сравнений.

Реализация

В кодах внешней сортировки на ANSI-C функция makeRuns вызывает readRec для чтения очередной записи. В функции readRec используется выбор с замещением (с двоичными деревьями) для получения нужной записи, а makeRuns распределяет записи согласно ряду Фибоначчи. Если количество отрезков оказывается вне последовательности Фибоначчи, в начало каждого файла добавляются пустые отрезки. Затем вызывается функция mergeSort, которая производит многофазное слияние отрезков.

Б-деревья

Словари для очень больших файлов располагаются, как правило, во вторичной памяти, такой, как диск. Словарь представляет собой индекс исходного файла и содержит ключи и адреса записей в нем. Для реализации словаря мы могли бы использовать красно-черные деревья, заменив указатели на смещения от начала индексного файла, и использовать обычный произвольный доступ для узлов дерева. Однако, каждый переход по дереву означает обращение к диску и, значит, обходится достаточно дорого. Напомню, что операция доступа к диску означает посекторный обмен; типичный размер сектора - 256 байтов. Мы можем уравнять размер узла с размером сектора и сгруппировать вместе несколько ключей в каждом узле, чтобы уменьшить количество операций обмена. В этом, собственно, и состоит идея Б-деревьев. Они хорошо описаны в книжках Кнута [1998] [Knuth] и Кормена [1990] [Cormen]. О Б+-деревьях почитайте в книжке Ахо [1983] [Aho].

Теория

На рис. 4-3 представлено Б-дерево с 3 ключами на узел. Ключи в внутреннем узле окружены указателями или смещениями записей, отсылающими к ключам, которые либо все больше, либо все меньше окруженного ключа. Например, все ключи, меньшие 22, адресуются левой ссылкой, все большие - правой. Для простоты здесь не показаны адреса записей, связанные с каждым ключом.

INCLUDEPICTURE "s_fig43.gif" \* MERGEFORMAT \d Рис. 4-3. Б-дерево

В этом двухуровневом дереве мы можем добраться до любого ключа за три доступа к диску. Если бы мы сгруппировали по 100 ключей на узел, то за три доступа к диску мы могли бы найти любой ключ из 1000000. Чтобы сохранить это свойство, нам нужно сохранять сбалансированность дерево во время вставок и удалений. Во время вставки мы исследуем потомка, чтобы определить, можно ли добавить в него узел. Если нет, в дерево добавляется еще один брат, а ключи потомка перераспределяются так, чтобы появилось место для нового узла. Когда мы спускаемся, чтобы вставить ключ, и узел оказывается заполненным, мы рассыпаем корень, у которого появляются новые потомки, так что глубина дерева увеличивается. Аналогичные действия предпринимаются при удалении - здесь может потребоваться объединить потомков. Этот метод изменения глубины дерева позволяет сохранить сбалансированность дерева.

Таблица 4-1. Реализация Б-деревьев

Б-дерево

Б*-дерево

Б+-дерево

Б++-дерево

данные хранятся в

любом узле

любом узле

только в листьях

только в листьях

при вставке - расщепление

1 x 1 –>2 x 1/2

2 x 1 –>3 x 2/3

1 x 1 –>2 x 1/2

3 x 1 –>4 x 3/4

при удалении - слияние

2 x 1/2 –>1 x 1

3 x 2/3 –>2 x 1

2 x 1/2 –>1 x 1

3 x 1/2 –>2 x 3/4

В таблице 4-1 приведены несколько вариантов Б-деревьев. В стандартных Б-деревьях ключи и данные хранятся как во внутренних узлах, так и в листьях (концевых узлах). Если при спуске по дереву во время вставки встречен заполненный узел, его содержимое перераспределяется между братьями. Если братья тоже полны, создается новый узел и половина ключей потомка пересылается в него. Во время удаления наполовину заполненные потомки являются первыми кандидатами на добавление ключей из прилежащих узлов. Если сами прилежащие узлы полны лишь наполовину, они объединяются так, чтобы получился полный узел. Б*-деревья устроены аналогично, единственное отличие - узлы заполняются на 2/3. Это приводит к лучшему использованию места, занимаемого деревом, и чуть лучшей производительности.

INCLUDEPICTURE "s_fig44.gif" \* MERGEFORMAT \d Рис. 4-4. Б+-дерево

На рис. 4-4 представлено Б+-дерево. Все ключи хранятся в листьях, там же хранится и информационная часть узла. Во внутренних узлах хранятся копии ключей - они помогают искать нужный лист. У указателей смысл немножко не такой, как при работе с обычными Б-деревьями. Левый указатель ведет к ключам, которые меньше заданного значения, правый - ключам, которые больше или равны (GE). Например, к ключам, меньшим 22, ведет левый указатель, а к ключам от 23 и выше ведет правый. Обратите внимание на то, что ключ 22 повторяется в листе, где хранятся соответствующие ему данные. Во время вставки и удаления необходимо аккуратно работать с родительскими узлами. Когда модифицируются первый ключ в листе, дерево проходится от листа к корню. Последний из GE-указателей, найденный при спуске по дереву, и является тем, который потребуется модифицировать, чтобы отразить новое значение ключа. Поскльку все ключи повторяются в листьях, мы можем связать их для последовательного доступа.

Последний метод, Б++-деревья, мое изобретение. Оно устроено аналогично Б+-деревьям, отличается лишь стратегия расщепления/объединения. Предположим, что в каждом узле могут храниться k ключей, а в корне их может быть 3k. Перед тем, как во время вставки мы спустимся к потому, мы проверяем пуст ли он. Если это так, ключи, находящиеся в потомке и лвух смежных к нему узлах, объединяются и перераспределяются. Если два смежных узла также заполнены, то добавляется узел. Таким образом, мы получаем уже четыре узла, каждый из которых полон на 3/4. Перед тем, как во время удаления спуститься к потомку, мы проверяем, не полон ли он на 1/2. Если это так, ключи потомка и двух смежных узлов объединяются и перераспределяются. Если два смежных узла сами полны наполовину, они сливаются в два узла, каждый из которых полон на 3/4. Мы, таким образом, оказываемся посредине между заполненностью на 1/2 и полной заполненностью, что позволяет нам ожидать равного числа вставок и удалений.

Напомним, что в корневом узле хранятся 3k ключей. Если во время вставки окажется, что корень полон, мы распределяем ключи по четырем новым узлам, каждый из которых полон на 3/4. Это увеличивает высоту дерева. Во время удаления мы исследуем потомков. Если имеется только три потомка и они полны наполовину, переносим их содержимое в корень, в результате чего высота дерева уменьшается.

Другой способ описать работу с деревом - сказать, что мы собираем три узла, а затем рассыпаем их. При вставке, когда нам нужен дополнительный узел, мы рассыпаем на четыре узла. При удалении, когда узел нужно удалить, мы рассыпаем на два узла. Симметрия операций позволяет использовать при реализации вставки и удаления одни и те же собирающие/рассыпающие функции.

Реализация

Среди сопровождающих эти заметки кодах имеется реализация Б++-деревьев на ANCI-C. В разделе, зависящем от реализации, необходимо определить bAdrType и eAdrType, которые используются для смещений - в файле Б-дерева и в файле данных соответственно. Вам понадобится также написать функцию, которая используется в алгоритмах Б++-дерева для сравнения ключей. Имеются функции, для вставки/удаления ключей, поиска ключей и для последовательного доступа к ключам. Функция main в конце файла - простой пример вставки.

В приведенных кодах допустимы множественные ссылки на одни и те же данные. Реализация использует дескриптор (handle), возвращаемый после открытия индекса. При последующем доступе работа ведется с этим дескриптором. Разрешены повторяющиеся ключи. В пределах одного индекса все ключи должны быть одной длины. Для поиска узлов применяется бираный поиск. Гибкая схема буферизации позволяет удерживать узлы в памяти до тех пор, пока памяти хватает. Если ожидается доступ к чему-нибудь упорядоченному, следует увеличить значение bufCt - это уменьшает число обращений к диску.

Исчерпывающий поиск

Поиск с возвращением

Опишем теперь общий метод, позволяющий решить практически любую задачу теории графов и значительно сократить число шагов в алгоритмах типа полного (исчерпывающего) перебора всех возможностей. Чтобы применить этот метод, искомое решение должно иметь вид последовательности x1, ..., xn. Основная идея метода состоит в том, чтобы строить наше решение последовательно, начиная с пустой последовательности (длины 0). Вообще, имея данное частичное решение x1, ..., xi, мы стараемся найти такое его допустимое расширение xi+1; которое либо можно расширить далее, либо оно уже является решением. Если такого расширения не существует, то мы возвращаемся к нашему частичному решению x1, ..., xi-1 и продолжаем наш процесс на предыдущем шаге ветвления, отыскивая новое, еще не использованное допустимое значение х'i. Отсюда название “алгоритм с возвратом” (англ. backtracking).

Точнее говоря, мы предполагаем, что для каждого k > 0 существует некоторое множество Ak, из которого мы будем выбирать кандидатов для k-й координаты частичного решения. Очевидно, что множества Ak должны быть определены для каждого частичного решения x1, ..., xk. И если Ak  , то расширение возможно.

Этот алгоритм можно записать в виде следующей схемы:

begin k := 1;

while k > 0 do

if существует еще неиспользованный элемент y Ak then begin

х [k] := y; (* элемент y использован *)

if (x[1],...,x[k]) является решением then write(x[1],...,x[k]);

k := k + 1

end

else (* возврат на более короткое частичное решение; все элементы множества Ak вновь становятся неиспользованными *)

k := k - 1

end.

Покажем также вариант рекурсивного алгоритма поиска с возвратом:

procedure BACKTRACK(k);

(*генерирование всех решений, являющихся расширением последовательности x[1],...,x[k-1], массив x -, глобальный *)

begin

for у  (Ak \ x) do begin

x[k] := у;

if x[1], ..., x[k] есть решение then

write (x [1], ..., x [k]);

BACKTRACK(k + 1)

end

end.

Метод ветвей и границ

Хорошо известный вариант поиска с возвращением, называе­мый методом ветвей и границ, на самом деле является лишь специ­альным типом поиска с ограничениями. Ограничения основывают­ся на предположении, что каждое решение связано с определенной стоимостью и что нужно найти оптимальное решение (решение с наименьшей стоимостью).

Для применения метода ветвей и границ

  • стоимость должна быть четко определена для частичных решений;

  • для всех частичных решений (a1, a2,, ak-1) и для всех расширений (a1, a2,, ak-1, ak) мы должны иметь