Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Методические указания / Специальные структуры данных

.pdf
Скачиваний:
1
Добавлен:
29.06.2024
Размер:
280.2 Кб
Скачать

1891

Федеральное агентство по образованию

Государственное образовательное учреждение высшего профессионального образования

«Липецкий государственный технический университет»

Кафедра автоматизированных систем управления

О.А. Назаркин, М.Г. Журавлева

СПЕЦИАЛЬНЫЕ СТРУКТУРЫ ДАННЫХ

МЕТОДИЧЕСКИЕ УКАЗАНИЯ

для студентов к индивидуальному домашнему заданию по курсу

«Структуры и алгоритмы компьютерной обработки данных»

Липецк 2009

УДК 681.3.01 (07)

Н 191

Назаркин, О.А. Специальные структуры данных: методические указания к ин-

дивидуальному домашнему заданию по курсу «Структуры и алгоритмы компь-

ютерной обработки данных» / О.А. Назаркин, М. Г. Журавлева. – Липецк:

ЛГТУ, 2009. – 22 с.

Предназначены для студентов направлений подготовки бакалавров 230100.62, 200500.62 и инженеров 230101.65; 230102.65; 230104.65; 230105.65; 220501.65; 230401.65; 010503.65.

Рассмотрены принципы построения и алгоритмы обработки связных блоковых,

индексированных и индексированных блоковых списков.

Табл. 1. Рис. 10. Библиогр.: 2 назв.

Рецензент В. А. Алексеев.

2

2

1.Теоретические положения

1.1.Линейные связные блоковые списки

Связные линейные структуры могут быть узловыми и блоковыми. Наибо-

лее широко известны узловые списки – в них каждый элемент содержит ин-

формацию об отношениях порядка, т.е. указатели на предыдущий и следующий узлы (см. рис. 1).

...

0

 

0

 

Рис. 1. Узловой связный список

Узлы списков могут быть размещены в памяти любого типа, в том числе в статической и внешней, т.е. не оперативной памяти. Так, типичным односвяз-

ным списком является структура данных FAT одноименной файловой системы,

размещаемой на внешних носителях.

В блоковых структурах элементы объединяются в группы - блоки. Явная информация "предыдущий/следующий" определяется для блока, а не для каж-

дого элемента. Элементы внутри блока содержат неявную связь, т.е. образуют массив (см. рис. 2).

0

 

 

 

 

 

 

...

 

 

 

 

 

 

 

...

...

 

 

 

 

 

...

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

0

Рис. 2. Блоковый связный список

Появление структуры данных, изображенной на рис. 2, обусловлено, в

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

ки/удаления в произвольную позицию последовательности. Векторы же ориен-

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

лировать произвольный доступ, любая его реализация будет малоэффективной.

В то же время вставка/удаление в вектор тем менее эффективна, чем ближе к

3

3

началу находится позиция вставки: "хвост" приходится сдвигать на одну пози-

цию. В контейнерах профессионального уровня, например, файловых системах,

структурах данных компиляторов и интерпретаторов, часто используются связ-

ные блоковые списки. Рассмотрим организацию двусвязного блокового списка с заданным постоянным размером блока М:

const int M = 16;

...

struct Block { int cnt;

int cells[M]; Block *previous; Block *next;

};

Здесь приведена структура на языке Си для представления блока размером 16

ячеек (cells), каждая из которых хранит целое число. Интерфейс блокового спи-

ска мало чем отличается от интерфейса линейного списка любого другого вида.

Таким образом, блочная структура становится скрытой особенностью реализа-

ции. С некоторыми упрощениями определим интерфейс так:

1.Вставить элемент в голову списка (InsertToHead).

2.Удалить элемент из головы списка (DeleteFromHead).

3.Вставить элемент в хвост списка (InsertToTail).

4.Удалить элемент из хвоста списка (DeleteFromTail).

5.Вставить элемент в произвольную позицию i (Insert(i)).

6.Удалить элемент из произвольной позиции i (Delete(i)).

7.Получить значение элемента в голове (GetHeadElement).

8.Получить значение элемента в хвосте (GetTailElement).

9.Получить значение элемента в произвольной позиции i (GetElement(i)).

10.Установить значение элемента в голове (SetHeadElement).

11.Установить значение элемента в хвосте (SetTailElement).

4

4

12.Установить значение элемента в произвольной позиции (SetElement(i)).

13.Очистить список (Clear).

С узловыми списками применяют итераторы (iterator) – абстракции, сход-

ные с указателями на узлы. В зависимости от степени связности, итератор по-

зволяет переходить к следующему и/или предыдущему узлу, изменяя свое зна-

чение на новый адрес. Часто используются термины инкремент/декремент ите-

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

ратора отличается от семантики для внутренних элементов. Иными словами,

переход к предыдущему/следующему для итераторов первого, некоторого внутреннего и последнего элементов должен осуществляться по разным прави-

лам. Такое разнообразие было бы чрезвычайно неудобным в работе. Поэтому в приведенном выше интерфейсе нет функций вида "получить адрес элемента", в

отличие от широко использующегося в узловых списках соответствующего на-

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

сте и произвольной позиции, а операции чтения/изменения производить через эти указатели, которые не являются итераторами, так как не обеспечивают на-

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

альна проблема повышения производительности. Представим ситуацию, что пользователь контейнера реализует просмотр элементов с изменениями, при необходимости, т.е. выполняет нечто подобное:

for (int i = 0; i < n; ++i) {

a = GetElement(i); //прочитать ячейку

...

// использовать а, возможно, изменить значение

5

5

if (некоторое условие) SetElement(i, a);

}

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

ляться к близким в адресном пространстве ячейкам, выше вероятности обраще-

ния к разнесенным данным. Иными словами, если в указанном цикле мы обра-

щаемся к одному и тому же элементу на чтение/запись (логическое расстояние

0), то могли бы с высокой вероятностью использовать предыдущий/следующий элементы (логическое расстояние 1). И наоборот, значительно реже может по-

надобиться доступ "через один" (логическое расстояние 2) и т.д. элементы. Реа-

лизация функций GetElement/SetElement будет, в общем случае, просматривать в блоковом списке блок за блоком, начиная, например, от головы и наращивая счетчик пройденных элементов каждый раз на значение счетчика занятых эле-

ментов в текущем блоке, пока не остановится на том блоке, в котором находит-

ся запрашиваемая позиция. Это весьма длительный итеративный процесс, не-

смотря на то, что по сравнению с узловыми списками здесь происходит ускоре-

ние за счет "скачков" через блоки. Эффективная реализация функций доступа к произвольному элементу должна вести нечто вроде кэш-таблицы, содержащей два поля – номер позиции, адрес блока, в котором находится элемент, индекс элемента в массиве. При каждом обращении к некоторому элементу i сначала просматривается кэш-таблица. Если в поле "номер позиции" найдено значение i, то сопутствующая информация на этой строке используется для быстрого доступа; если же кэш не содержит позицию i, происходит общая процедура скачкообразной итерации по блокам. Когда элемент будет найден, индекс эле-

мента в массиве и адрес блока, в котором остановился поиск, следует занести в кэш.

6

6

Размер кэш-таблицы должен выбираться относительно небольшим. Сле-

дует постоянно держать в кэше записи для головы и хвоста. Так как поиск в та-

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

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

раниченного размера.

Вставка элемента в блок реализуется по следующему правилу:

если блок содержит свободные ячейки (т.е. cnt < M), то вставить элемент (воз-

можно, со сдвигом содержимого блока на одну позицию); увеличить счетчик;

иначе // т.е. блок заполнен до конца

создать новый блок и вставить его как узел в двусвязном списке, настроив указатели previous и next там, где необходимо. Осуществить сдвиг в ста-

ром блоке, хвостовой элемент поместить в нулевой позиции нового бло-

ка, добавляемый элемент - в освободившуюся позицию, счетчику нового блока присвоить единицу;

конец_если

Если реализован кэш, следует обновить соответствующие позиции и ад-

реса. Графический пример вставки элемента в блоковый список приведен на рис. 3. Несмотря на то, что в блоке D (рис. 3) оставалось свободное место и мож-

но было не создавать новый блок C', а сдвинуть блок D на одну позицию вправо и разместить там "вытесняемую" из блока С букву m, был добавлен новый блок.

Очевидно, что допустимы обе стратегии (рис. 4). Назовем первый вариант встав-

ки (рис. 3) стратегией 1, а второй (рис. 4) – стратегией 2.

7

7

а

М = 4

A

 

cnt = 4

 

 

0

а b c d

 

0

1

2

3

B

cnt = 4

 

 

e

f

g h

4

5

6

7

C

cnt = 4

 

j

k

l m

8

9 10 11

D

cnt = 1

 

n

0

12

 

б

 

 

A

 

 

B

 

 

C

С'

D

 

 

cnt = 4

 

cnt = 4

cnt = 4

cnt = 1

cnt = 1

 

 

 

 

 

 

 

 

 

 

cnt = 1

 

 

0

а b c d

e f g h

 

j k l

m

n

0

 

0

1

2 3

4

5

6 7

8

9 10 11

12

13

 

 

 

 

 

 

 

 

i

 

новый блок

 

 

 

 

 

 

 

 

 

 

 

 

 

Рис. 3. Пример вставки элемента: а – исходный блоковый список;

б – блоковый список после вставки в позицию 8 буквы "i"

A

 

cnt = 4

 

 

0

а b c d

 

0

1

2

3

B

cnt = 4

 

 

e

f

g h

4

5

6

7

 

C

 

 

cnt = 4

 

 

 

j

k

l

8

9 10 11

i

 

 

 

D

cnt = 2

 

m n

0

12 13

 

Рис. 4. Вставка элемента без создания нового блока

В случае использования стратегии 2 правило вставки таково:

если блок содержит свободные ячейки (т.е. cnt < M), то вставить элемент (воз-

можно, со сдвигом содержимого блока на одну позицию); увеличить счетчик;

иначе // т.е. блок заполнен до конца

если следующий блок содержит свободные ячейки, то сдвинуть содер-

жимое следующего блока на одну позицию вправо, поместить в ну-

левую позицию последний элемент текущего блока; увеличить счетчик следующего блока; сдвинуть содержимое текущего блока на одну позицию вправо, начиная с позиции вставки, разместить новый элемент;

иначе // следующий блок тоже заполнен до конца

создать новый блок и вставить его как узел в двусвязном списке,

настроив указатели previous и next там, где необходимо. Осущест-

8

8

вить сдвиг в старом блоке, хвостовой элемент поместить в нулевой позиции нового блока, добавляемый элемент - в освободившуюся позицию, счетчику нового блока присвоить единицу;

конец_если

конец_если

В зависимости от того, насколько интенсивно происходит вставка эле-

ментов, можно выбирать одну из указанных стратегий. Если вставки происхо-

дят часто, лучше добавлять новые блоки, невзирая на наличие свободного мес-

та в соседних. Если список сильно фрагментирован, предпочтительно исполь-

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

При удалении существует только одна стратегия. Общий принцип реали-

зации удаления из блока таков:

если в текущем блоке остаются элементы (т.е. cnt > 1), то сдвинуть содержимое блока на одну позицию влево, начиная с последней заполненной ячейки,

до позиции удаляемого элемента; уменьшить счетчик на единицу

иначе // удаляется последний элемент

исключить опустевший блок полностью, удалив узел в двусвязном спи-

ске, настроив указатели previous и next, где необходимо

конец_если

Графический пример удаления элементов приведен на рис. 5.

Преимущества блоковых списков перед узловыми:

1. Ускорение доступа к произвольной позиции в списке.

Для доступа к нужной позиции итерации совершаются по блокам, кото-

рых меньше, чем элементов списка, в то время как в узловых списках итерации осуществляются по узлам, которых ровно столько же, сколько элементов.

2. Снижение накладных расходов на служебные описатели динамической памяти.

9

9

а

М = 4

A

 

cnt = 4

 

 

0

а b c d

 

0

1

2

3

B

cnt = 3

 

e

f

g

4

5

6

б

A

 

cnt = 4

0

а b c d

 

0

1

2 3

B

cnt = 2

 

f

g

0

4

5

 

C

cnt = 1

 

h

0

7

 

cnt =1

h

уничтоженный блок

Рис. 5. Удаление элементов: а – исходный блоковый список; б – блоковый спи-

сок после удаления элементов из позиций 4 и 7

Каждый участок динамической памяти, занятый или свободный, содер-

жит примерно следующие дополнительные атрибуты: признак занятости, раз-

мер, указатель на следующий участок в цепочке. Известно, что описатель уча-

стка динамической памяти занимает от восьми до шестнадцати и более байт.

Объявление типичного узла занимает 12 байт: struct Node {

Node *previous; //как правило, указатель занимает 4 байта

Node *next;

int data; // размер данных целого типа, как правило, 4 байта

};

Возможно, что структура будет выровнена на параграф, т.е. границу 16

байт. Когда такой элементарный узел нужно будет разместить в куче, описате-

ли фрагмента потребуют еще от восьми до шестнадцати байт. Видно, что по-

лезная информация узла (поле int data) "тонет" среди обилия сопутствующих служебных полей.

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

10

10