- •Введение
- •Основные понятия и определения
- •1.1. Типы данных
- •1.1.1. Понятие типа данных
- •1.1.2. Внутреннее представление базовых типов в оперативной памяти
- •1.1.3. Внутреннее представление структурированных типов данных
- •1.1.4. Статическое и динамическое выделение памяти
- •1.2. Абстрактные типы данных (атд)
- •1.2.1. Понятие атд
- •1.2.2. Спецификация и реализация атд
- •1.3. Структуры данных
- •1.3.1. Понятие структуры данных
- •1.3.2. Структуры хранения — непрерывная и ссылочная
- •1.3.3. Классификация структур данных
- •1.4. Понятие алгоритма
- •1.5. Введение в анализ алгоритмов
- •1.5.1. Вычислительные модели
- •1.5.2. Показатели эффективности алгоритма
- •1.5.3. Постановка задачи анализа алгоритмов
- •1.5.4. Время работы алгоритма
- •Время выполнения в худшем и среднем случае
- •1.5.5. Асимптотические оценки сложности алгоритмов
- •Точная асимптотическая оценка θ
- •Верхняя асимптотическая оценка о
- •Нижняя асимптотическая оценка ω
- •Наиболее часто встречающиеся асимптотические оценки
- •1.6. Анализ рекурсивных алгоритмов
- •1.6.1. Рекурсия и итерация
- •1.6.2. Пример анализа рекурсивного алгоритма
- •1.7. Первые примеры
- •1.7.1. Введение в «длинную» арифметику
- •1.7.2. Примеры рекурсивных алгоритмов
- •1.7.3. Поразрядные операции. Реализация атд «Множество»
- •2. Линейные структуры данных
- •2.1. Атд "Стек", "Очередь", "Дек"
- •2.1.1. Функциональная спецификация стека
- •2.1.2. Функциональная спецификация очереди
- •2.1.3. Деки
- •2.1.4. Общие замечания по реализации атд
- •2.2. Реализация стеков
- •2.2.1. Непрерывная реализация стека с помощью массива
- •2.2.2. Ссылочная реализация стека в динамической памяти
- •2.2.3. Примеры программ с использованием стеков
- •2.3. Реализация очередей
- •2.3.2. Непрерывная реализация очереди с помощью массива
- •2.3.2. Ссылочная реализация очереди в динамической памяти
- •2.3.3. Ссылочная реализация очереди с помощью циклического списка
- •2.3.4. Очереди с приоритетами
- •2.3.5. Пример программы с использованием очереди
- •2.4. Списки как абстрактные типы данных
- •2.4.1. Модель списка с выделенным текущим элементом
- •Операции над списками
- •2.4.2. Однонаправленный список (список л1)
- •2.4.3. Двунаправленный список (список л2)
- •2.4.4. Циклический (кольцевой) список
- •2.5. Реализация списков с выделенным текущим элементом
- •2.5.1. Однонаправленные списки Ссылочная реализация в динамической памяти на основе указателей
- •2.5.2. Двусвязные списки
- •2.5.3. Кольцевые списки
- •2.5.4. Примеры программ, использующих списки Очередь с приоритетами на основе линейного списка
- •2.6. Рекурсивная обработка линейных списков
- •2.6.1. Модель списка при рекурсивном подходе
- •2.6.2. Реализация линейного списка при рекурсивном подходе
- •3. Иерархические структуры данных
- •3.1. Иерархические списки
- •3.1.1 Иерархические списки как атд
- •3.1.2. Реализация иерархических списков
- •3.2. Деревья и леса
- •3.2.1. Определения
- •3.2. Способы представления деревьев
- •3.2.3. Терминология деревьев
- •3.2.4. Упорядоченные деревья и леса. Связь с иерархическими списками
- •3.3. Бинарные деревья
- •3.3.1. Определение. Представления бинарных деревьев
- •3.3.2. Математические свойства и специальные виды бинарных деревьев
- •Вырожденные бинарные деревья
- •Полные бинарные деревья
- •Бинарные деревья минимальной высоты с произвольным числом узлов
- •Почти полные бинарные деревья
- •Идеально сбалансированные бинарные деревья
- •Расширенные бинарные деревья
- •3.4. Деревья как атд
- •Атд «Дерево» и «Лес»
- •Атд «Бинарное дерево»
- •3.5. Соответствие между упорядоченным лесом, бинарным деревом и иерархическим списком
- •3.5.1. Каноническое соответствие между бинарным деревом и упорядоченным лесом
- •3.5.2. Взаимосвязь бинарных деревьев и иерархических списков
- •3.6. Ссылочная реализация бинарных деревьев
- •3.6.1. Ссылочная реализация бинарного дерева на основе указателей
- •3.6.2. Ссылочная реализация на основе массива
- •3.6.3. Пример — построение дерева турнира
- •3.7. Обходы бинарных деревьев и леса
- •3.7.1. Понятие обхода. Виды обходов
- •3.7.2. Пример обходов — дерево-формула
- •3.7.3. Рекурсивные функции обхода бинарных деревьев
- •3.7.3. Нерекурсивные функции обхода бинарных деревьев
- •Прямой порядок обхода (клп)
- •Центрированный порядок обхода (лкп)
- •Обратный порядок обхода (лпк)
- •Обход в ширину
- •3.7.4. Обходы леса
- •3.7.5. Прошитые деревья
- •3.8. Применение деревьев для кодирования информации — деревья Хаффмана
- •3.8.2. Задача сжатия информации. Коды Хаффмана
- •4. Сортировка и родственные задачи
- •4.1. Общие сведения
- •4.1.1. Постановка задачи
- •4.1.2. Характеристики и классификация алгоритмов сортировки
- •4.2. Простые методы сортировки
- •4.2.1. Сортировка выбором
- •4.2.2. Сортировка алгоритмом пузырька
- •4.2.3.Сортировка простыми вставками.
- •4.3. Быстрые способы сортировки, основанные на сравнении
- •4.3.1. Пирамидальная сортировка. Очереди с приоритетами на основе пирамиды
- •Первая фаза сортировки пирамидой
- •Вторая фаза сортировки пирамидой
- •Анализ алгоритма сортировки пирамидой
- •Реализация очереди с приоритетами на базе пирамиды
- •4.3.2. Сортировка слиянием
- •Анализ алгоритма сортировки слиянием
- •4.3.3. Быстрая сортировка Хоара
- •Анализ алгоритма быстрой сортировки
- •4.3.4. Сортировка Шелла
- •4.3.5. Нижняя оценка для алгоритмов сортировки, основанных на сравнениях
- •4.4. Сортировка за линейное время
- •4.4.1. Сортировка подсчетом
- •4.4.2. Распределяющая сортировка от младшего разряда к старшему
- •4.4.3. Распределяющая сортировка от старшего разряда к младшему
- •5. Структуры и алгоритмы для поиска данных
- •5.1. Общие сведения
- •5.1.1. Постановка задачи поиска
- •5.1.2. Структуры для поддержки поиска
- •5.1.3. Соглашения по программному интерфейсу
- •5.2. Последовательный (линейный) поиск
- •5.3. Бинарный поиск в упорядоченном массиве
- •5.4. Бинарные деревья поиска
- •5.4.1. Анализ алгоритмов поиска, вставки и удаления Поиск
- •Вставка
- •Удаление
- •5.4.3. Реализация бинарного дерева поиска
- •5.5. Сбалансированные деревья
- •Определение и свойства авл-деревьев
- •Вращения
- •Алгоритмы вставки и удаления
- •Реализация рекурсивного алгоритма вставки в авл-дерево
- •5.5.2. Сильноветвящиеся деревья
- •Бинарные представления сильноветвящихся деревьев
- •5.5.3. Рандомизированные деревья поиска
- •5.6. Структуры данных, основанные на хеш-таблицах
- •5.6.2. Выбор хеш-функций и оценка их эффективности
- •Модульное хеширование (метод деления)
- •Мультипликативный метод
- •Метод середины квадрата
- •5.6.2. Метод цепочек
- •5.6.3. Хеширование с открытой адресацией
- •5.6.4. Пример решения задачи поиска с использованием хеш-таблицы
2.5. Реализация списков с выделенным текущим элементом
Сначала сравним два возможных способа реализации — непрерывную и ссылочную. Когда речь шла о стеке или очереди, то не вставал вопрос оценки времени выполнения алгоритма, поскольку основные операции вставки и извлечения элементов на концах списка при любом способе реализации выполняются за константное время. В таких случаях точная асимптотическая оценка времени выполнения составляет Θ (1).
Однако при непрерывной реализации списков с произвольным включением и исключением элементов на основе массивов вставка и удаление элементов требуют перемещения в памяти большого количества элементов (такие операции называются массовыми). Их время выполнения в худшем случае оценивается как линейное — Θ (n), где n— количество элементов.
Можно сделать вывод, что для частных случаев (стек, очередь, дек) применение непрерывной реализации на основе массива вполне приемлемо, но при частых вставках и удалениях в произвольных позициях непрерывная реализация неэффективна.
Далее коснемся только ссылочной реализации списков. Заметим, что она может быть выполнена как на основе на основе связных структур в динамической памяти, так и на основе массива.
2.5.1. Однонаправленные списки Ссылочная реализация в динамической памяти на основе указателей
Этот способ уже использовался для реализации стека и очереди, которые можно рассматривать как частные случаи однонаправленного списка. В основе реализации во всех случаях лежит универсальная структура элемента однонаправленного списка:
struct item // структура одного элемента
{ type_of_data data; // тип был определен с помощью typedef
item *next; // указатель на следующий элемент списка
};
Далее требуется определить, какие дополнительные указатели необходимы для эффективной реализации операций над списками (вспомним, что при реализации стека использовался один, а при реализации очереди — два дополнительных указателя). Рассмотрим операции вставки и удаления элементов в произвольной позиции.
На рис.2.10. схематически показан процесс вставки нового элемента между первым и вторым элементами списка.
Рис.2.10. Вставка нового элемента в произвольную позицию списка.
Здесь текущим элементом является второй. Из рисунка понятно, что если бы новый элемент вставлялся после текущего (т. е. если бы текущим был первый), то достаточно только указателя на этот текущий элемент, т. к. адрес следующего легко получим из текущего. Однако общепринятым вариантом вставки является вставка элемента перед текущим, такой способ был принят и в рассмотренной ранее неформальной спецификации списка. Адреса предыдущего элемента в текущем нет. Следовательно, необходим дополнительный указатель на предыдущий элемент.
Аналогичные случаи можно рассмотреть и для удаления элемента. На рис. 2.11. показаны все три случая удаления. При этом перечеркивание изображений элементов обозначает удаление их из памяти.
Рис. 2.11. Удаление элементов однонаправленного списка
Ситуация такая же, как и в случае вставки. Для удаления элемента, следующего за текущим, не потребовалось бы никаких дополнительных уазателей, но для удаления элемента в текущей позиции обязательно нужно иметь дополнительный указатель на предшествующий элемент.
Разумеется, для того, чтобы реализовать операцию перехода к первому элементу, необходимо иметь еще один указатель указатель на первый элемент (начало) списка.
Отсюда вывод: для того, чтобы реализовать АТД «Однонаправленный список с выделенным текущим элементом», необходимо, кроме самого списка, иметь три дополнительных указателя:
на начало списка списка — назовем этот указатель head;
на текущий элемент — cur;
на элемент, предшествующий текущему — predcur.
Данные три указателя называют формуляром списка, поскольку их значения полностью определяют текущее состояние списка.
Таким образом, можно определить однонаправленный список в виде, например, представленной ниже структуры list_l1:
struct list_l1 //структура списка
{ item *head, *cur, *predcur; //формуляр списка
// базовые функции
list_l1() {head=cur=predcur=NULL;}//конструктор
bool eolist() {return cur==NULL;}//проверка на конец списка
bool isnull() { return head==NULL; } // проверка на пустоту
type_of_data getdata(); //получить текущий элемент
void first(); //встать в начало
void next(); //перейти к следующему элементу
void ins(type_of_data x);//вставка перед текущим элементом
void del(); // удаление текущего, текущим станет предыдущий
void makenull(); // очистка списка
};
В качестве пояснения к реализации базовых функций выделим особые состояния списка и соответствующие этим состояниям значения указателей:
head=predcur=cur=NULL — список пуст, в этом состоянии нельзя удалять элементы, первый добавленный элемент будет одновременно и началом, и концом списка;
head ≠ NULL; cur=head; predcur=NULL — «начало списка»; в этом состоянии новый элемент вставляется перед первым;
head ≠ NULL; predcur ≠ NULL; cur=NULL; — «конец списка», в этом состоянии новый элемент вставляется после последнего, а удалить текущий элемент нельзя.
Два последних состояния определены для непустого списка
(head ≠ NULL).
При реализации функций учитываются все возможные особые состояния, которые могут случиться при выполнении соответствующей операции. Например, переход к следующему элементу может быть реализован так:
void list_l1::next()
{ if (isnull()) {cerr << "Список пустой"; exit(1);}
if (eolist()) {cerr << "Достигнут конец списка"; exit(2);}
predcur=cur; cur=cur->next;
}
Наличие дополнительного указателя predcur позволяет реализовать операцию вставки довольно просто, при этом даже не требуется отдельно рассматривать случай вставки в конец списка, поскольку он входит в общий случай вставки не в начало списка.
void list_l1::ins(type_of_data x)
{ item *temp=new item;
temp->data=x; temp->next=cur;
if (predcur) //вставка не в начало списка
predcur->next=temp;
else head=temp; //вставка в начало
cur=temp;
}
Удаление требует несколько большего количества проверок, поскольку необходимо обработать аварийные ситуации.
void list_l1::del()
{ if (isnull()) {cerr << "Список пустой"; exit(1); }
if (eolist()) {cerr << "Достигнут конец списка"; exit(2);}
if (predcur) //удаляется не первый элемент списка
{ predcur->next=cur->next; delete cur; cur=predcur->next;
}
else // удаляется первый элемент
{ head=head->next; delete cur; cur=head;
}
}
Ссылочная реализация на основе массива
При реализации списка в динамической памяти выделение памяти под каждый элемент выполняется автоматически (в С++ это делается с помощью операции new). Можно сделать процесс выделения и освобождения памяти для элементов списка полностью управляемым программой (допустим, в целях повышения быстродействия). В этом случае необходимо воспользоваться обычным одномерным массивом (вектором), но использовать его несколько необычным образом, отказавшись от размещения соседних элементов списка в соседних ячейках памяти. В этом случае в каждом элементе должна храниться ссылка на следующий элемент (обычно ссылка— это индекс следующего элемента, при реализации на С++ ссылкой может быть указатель).
Тогда каждый элемент списка будет представлять собой запись (в С++ — структуру), а память, отводимая для хранения списка, представляется одномерным массивом записей. Поскольку память под массив выделяется заранее, то максимальная длина списка ограничена размером массива. В этом случае говорят об ограниченном Л1–списке и при реализации обязательно отслеживают аварийную ситуацию, связанную с переполнением области памяти.
На рис.2.12 приведен пример размещения списка, состоящего из четырех элементов, имеющих значения a, b, c, d в массиве, максимальный размер которого составляет 8 элементов (нумерация с единицы). Очевидно, в этом списке уже выполнялись операции удаления и вставки, поскольку элементы расположены не по порядку. Но это не важно, т. к. индекс следующего элемента явно хранится в каждом элементе. Последний элемент имеет значение поля связи, соответствующее пустой ссылке. В примере используется несуществующее значение индекса -1.
Для работы со списком используем формуляр списка, включающий три дополнительных указателя, как и при реализации в динамической памяти (head, cur, predcur). Для примера текущим выбран третий элемент списка со значением c.
Рис. 2.12. Ссылочная реализация однонаправленного списка на основе массива
Элементы массива, которые в данный момент являются свободными, также расположены хаотически. Для удобства работы их также удобно связать в список свободных ячеек. При удалении элемента из списка тот будет добавляться в конец списка свободных ячеек, а при вставке нового элемента проще всего взять элемент с этого же конца списка свободных ячеек. Получается, что этот список представляет собой стек, а для работы с ним достаточно иметь всего один дополнительный указатель на вершину (мы назвали его top_free).
Для того, чтобы идея стала совсем понятной, на рис. 2.13 массив записей из рис.2.12 изображен в виде двух списков, представляющих собой занятую и свободную части массива.
Рис. 2.13. Список занятых элементов массива и список (стек) свободных ячеек.
В рассмотренном примере в качестве ссылок на соседние элементы используются индексы массива, поэтому для реализации потребуется структура, похожая, но не идентичная уже знакомой структуре элемента однонаправленного списка:
struct item // структура для одного элемента списка
{ type_of_data data; //тип данных определен с помощью typedef
int next; //индекс следующего элемента (целое число)
};
Можно использовать указатель на следующий элемент списка вместо его индекса, реализуя ту же самую идею двух списков внутри массива. В этом случае структура элемента будет точно такой же, как и при реализации в динамической памяти, а сама реализация — более эффективной (ее полезно проделать самостоятельно).
Структура однонаправленного списка на основе массива может иметь следующий вид:
const int maxlength=100;
struct list_l1 // структура списка, включающая и функции
{ item elements[maxlength]; //массив элементов
int head, cur, predcur; //голова, текущий, предыдущий
int top_free; //индекс вершины списка (стека) пустых ячеек
list_l1(); //конструктор
//далее следуют прототипы базовых функций .....
};
Обратим внимание на то, что в данном случае необходимо реализовать все действия со стеком свободных ячеек внутри массива, поскольку ранее рассмотренные реализации стека не подойдут.
Реализация конструктора может иметь следующий вид:
list_l1::list_l1()
{ head=-1; cur=-1; predcur=-1;// начальные значения индексов
// первоначально весь массив - стек пустых ячеек
elements[maxlength-1].next=-1;//последний элемент-дно стека
for (int i=maxlength-2; i>=0; i--)
elements[i].next=i+1;
top_free=0; //элемент с нулевым индексом - вершина стека
}
Функции вставки и удаления по своей логике аналогичны соответствующим функциям при реализации в динамической памяти, однако усложнены действиями со стеком свободных ячеек:
void list_l1::ins(type_of_data x)
{ if (top_free==-1) {cerr << "Список переполнен"; exit(3);}
// берем первую пустую ячейку с вершины стека top_free
int k=top_free; top_free=elements[top_free].next;
elements[k].data=x; elements[k].next=cur;
if (predcur!=-1) // элемент вставляется не в начало списка
elements[predcur].next=k;
else head=k; // вставка в начало
cur=k;
}
void list_l1::del()
{ if (isnull()) {cerr << "Список пустой"; exit(1);}
if (eolist()) {cerr << "Достигнут конец списка"; exit(2);}
if (predcur!=-1) // если удаляется не начало списка
{ elements[predcur].next=elements[cur].next;
// добавляем на вершину стека освободившуюся ячейку
elements[cur].next=top_free; top_free=cur;
cur=elements[predcur].next;
}
else // удаляется первый элемент
{ head=elements[head].next;
elements[cur].next=top_free; top_free=cur;
cur=head;
}
}
В данной реализации рассматривается только один список, хотя сам принцип можно расширить на несколько списков, которые организуются на основе одного массива и имеют общий список свободных ячеек. Собственно, и стандартные средства автоматического выделения и освобождения памяти, встроенные в языки программирования, используют похожие принципы.
