
- •Конспект лекций по курсу
- •Абстрактные структуры данных
- •Определение
- •Базовые структуры данных
- •Очереди и стеки
- •Деревья
- •Внутренние структуры данных
- •Отображение абстрактных структур данных на внутренние
- •Строка-вектор
- •1. Функция сцепления двух строк
- •2. Функция поэлементного сравнения двух строк
- •3. Функция разбиения строки.
- •4. Функция нахождения подстроки в строке
- •Строка-список
- •1. Сцепление двух строк
- •2. Поэлементное сравнение двух строк
- •3. Разбиение строки на части
- •4. Функция нахождения подстроки в строке
- •Стек-вектор
- •Стек-список
- •Очереди
- •Очередь-вектор
- •Очередь-список
- •Деревья
- •Классификация таблиц
- •Способ работы с таблицей
- •Способ доступа к таблице
- •Просматриваемые таблицы
- •Статическая просматриваемая таблица-вектор
- •Динамическая просматриваемая таблица-вектор
- •Просматриваемая таблица-список
- •Упорядоченные таблицы
- •Упорядоченная таблица-вектор
- •Динамическая упорядоченная таблица – вектор
- •Упорядоченная таблица – двоичное дерево
- •Перемешанные таблицы
- •Открытое перемешивание
- •Перемешивание сцеплением
Динамическая упорядоченная таблица – вектор
Для динамических упорядоченных таблиц включение новых или удаление существующих элементов не должно вызывать нарушение структуры таблицы, поэтому при выполнении указанных операций требуется реорганизация таблицы.
Удаление элемента из упорядоченной таблицы реализуется достаточно просто: в результате поиска элемента получаем его индекс в таблице, после чего нижняя половина таблицы перемещается на одну позицию вверх:
const int M = 20 /* максимальный размер таблицы */
struct Item{
int key;
Type info;
};
Item bintable[M];
int n; /* текущий размер таблицы */
int binsearch(int k); /* Поиск элемента в таблице */
int del(int k)
{
int i;
if((i = binsearch(k)) < 0)
return -1; /* элемента в таблице нет */
--n; /* новый текущий размер таблицы */
delInfo(bintable[i].info);
for(; i < n; i++)
bintable[i] = bintable[i + 1];
return 0;
}
При включении нового элемента в таблицу удобно использовать алгоритм сортировки вставками, схема которого приведена на рис. II–43. При этом если в таблице не могут находиться элементы с одинаковыми значениями ключей, сначала следует выполнить поиск элемента и, в случае неуспешного поиска, вставить в таблицу новый элемент.
Рис. II–43
При использовании сортировки вставками сравнение ключей элементов осуществляется от конца таблицы к ее началу. Пока очередной элемент таблицы имеет ключ, превышающий ключ включаемого элемента, этот элемент таблицы копируется в следующую за ним позицию таблицы. Как только ключ очередного элемента таблицы окажется меньше ключа нового элемента, новый элемент включается после найденного. Если вся таблица будет просмотрена, новый элемент будет включен в первую позицию таблицы.
Текст функции для включения нового элемента в упорядоченную таблицу (сортировки вставками) приводится ниже.
const int M = 20 /* максимальный размер таблицы */
struct Item{
int key;
Type info;
};
Item bintable[M];
int n; /* текущий размер таблицы */
int inssort(int k, Type in)
{
int i;
for(i = n-1; i >= 0 && bintable[i].key > k; i--)
bintable[i + 1] = bintable[i];
bintable[i + 1].key = k;
bintable[i + 1].info = dupl(in);
return ++n;
}
int insert(int k, Type in)
{
if(binsearch(k) >= 0)
return -1; /* в таблице есть элемент с указанным ключом */
if(n == M)
return -2; /*в таблице нет свободной позиции для нового элемента*/
return inssort(k, in);
}
Все рассмотренные функции приведены также в файле tab2vec.cpp.
Упорядоченная таблица – двоичное дерево
Упорядоченную таблицу можно представить двоичным деревом, если принять, что элементы таблицы размещаются в вершинах дерева и с каждой вершиной дерева (как с элементом таблицы) связывается некоторое значение ключа. Обозначим значения ключей через ki.
Введем следующие обозначения:
Пусть k*определяет ключ элемента, находящегося в корне дерева, Л = {ki} – множество ключей, размещенных в вершинах левого поддерева, и П = {ki} – множество ключей, размещенных в вершинах правого поддерева.
Тогда для таблицы, представленной двоичным деревом, устанавливаются следующие правила размещения элементов:
Для любого ki
Л ki< k*;
Для любого ki
П ki> k*.
Указанные соотношения применяются к любому поддереву внутри двоичного дерева. Пример упорядоченной таблицы – двоичного дерева приведен на рис. II–44.
Рис. II–44
Для того чтобы получить действительно упорядоченный по возрастанию ключей список значений элементов, нужно использовать обратный обход дерева (ЛКП); в результате его применения к дереву, представленному на рис. II–45, получим:
25, 30, 33, 35, 40, 45, 50, 55, 80.
Для того чтобы найти в таблице – дереве элемент по значению его ключа, используются сформулированные выше правила, в соответствии с которыми, если искомый ключ меньше ключа в вершине дерева, следует продолжить просмотр левого поддерева; если больше – правого. Поиск в поддереве выполняется так же, как и в дереве (в силу рекурсивности определения двоичного дерева). Алгоритм поиска элемента в двоичном дереве приведен на рис. II–45.
Рис. II–45
Здесь root->kопределяет ключ элемента,root->left– указатель на левое поддерево,root->roght– указатель на правое поддерево дерева, корень которого задается указателемroot.
Текст функции приводится ниже.
struct Node{
int key;
Type info;
Node *left, *right;
};
Node *search(Node *root, int k)
{
while(root){
if(root->key == k)
return root;
root = k < root->key ? root->left : root->right;
}
return NULL;
}
В силу рекурсивного определения дерева функцию поиска элемента можно также реализовать как рекурсивную; текст рекурсивной функции приводится ниже. Очевидно, что такой вариант не выигрывает по сравнению с предыдущим.
Node *recsearch(Node *root, int k)
{
if(!root)
return NULL; /* элемент с указанным ключом отсутствует */
return (root->key == k) ? root : (k < root->key) ? recsearch(root->left, k) : recsearch(root->right, k);
}
Если таблица – двоичное дерево определена как динамическая, для такой таблицы достаточно просто реализовать операцию включения в таблицу нового элемента: продвигаясь по соответствующим (в зависимости от значения ключа нового элемента) поддеревьям, находим вершину, имеющую в требуемом поле указателя на поддерево значение NULL, и к этому элементу подсоединяем новый элемент.
Операция удаления элемента существенно более сложна, так как в случае, если удаляется элемент, находящийся в промежуточной или корневой вершине дерева, требуется принятие специального решения. Одним из возможных решений является реорганизация дерева, в результате которого вершины более низкого уровня перемещаются наверх; в этом случае операция удаления элемента требует много времени. Другим решением может быть использование специального флага удаленного элемента (как в просматриваемых таблицах), но при этом очень неэффективно используется память. Конкретные алгоритмы удаления элемента здесь не рассматриваются.
Схема алгоритма включения нового элемента в упорядоченную таблицу – двоичное дерево приведена на рис. II–46.
Рис. II–46
Функцию включения нового элемента можно сократить, если:
использовать тип данных "указатель на указатель на ...";
использовать рекурсию.
Рассмотрим оба способа реализации функции.
В варианте (a) использование типа "указатель на указатель на элемент дерева" позволяет объединить идентичные операции по включению нового элемента в левое или правое поддерево. Текст функции приводится ниже. Функция возвращает NULL, если элемент не может быть включен в таблицу (элемент с таким ключом в таблице уже есть или нет памяти для построения нового элемента дерева), и указатель на новый элемент дерева, если новый элемент включен в таблицу.
struct Node{
int key;
Type info;
Node *left, *right;
};
Node *insert(Node **proot, int k, Type in)
{
Node **ptr = proot;
Node *cur;
/* поиск позиции в таблице – дереве для включения нового элемента */
while(*ptr){
if((*ptr)->key == k)
return NULL; /* элемент с таким ключом в таблице есть */
ptr = k < (*ptr)->key ? &(*ptr)->left : &(*ptr)->right;
}
/* создание нового элемента таблицы – дерева */
cur = new Node;
if(!cur)
return NULL; /* нет свободной памяти */
cur->key = k;
cur->info = dupl(in);
cur->left = cur->right = NULL;
/* нашли позицию; включаем новый элемент */
*ptr = cur;
return cur;
}
Вариант (b), в силу рекурсивности дерева, можно рассматривать следующим образом: Если дерево пусто, новый элемент становится его корнем. Если дерево не пусто, в зависимости от соотношения ключей выбирается левое или правое поддерево, которое является деревом; для него вновь вызывается функция включения.
Для рекурсивной функции создание элемента дерева должно быть выполнено вне функции, поэтому удобно использовать две функции: основная функция включения нового элемента в таблицу (insert()), которая создает новый элемент и вызывает функцию включения элемента в дерево, и рекурсивная функция включения в дерево (instree()). Тексты соответствующих функций приведены ниже.
struct Node{
int key;
Type info;
Node *left, *right;
};
/* рекурсивная функция включения нового элемента в двоичное дерево.
* Результат –NULL, если элемент с таким ключом в таблице уже есть, или
* указатель на включенный элемент.
* Так как функция включает новый элемент в корень дерева – т.е. должно
* измениться значение указателя на корень дерева, в функцию нужно
* передать указатель на указатель на корень дерева –Node **.
*/
Node *instree (Node **proot, Node *newnode)
{
Node **ptr = proot;
if(!*proot){
/* дерево пусто */
*proot = newnode;
return newnode;
}
/* выбор нужного поддерева */
if((*proot)->key == newnode->key)
return NULL; /* элемент с таким ключом в таблице – дереве есть */
ptr = (newnode->key < (*proot)->key) ?
&(*proot)->left : &(*proot)->right;
/* рекурсивный вызов функции включения нового элемента в выбранное
* поддерево */
return instree(ptr, newnode);
}
/* Функция включения нового элемента в таблицу. Результат –NULL, если
* элемент не может быть включен в таблицу, и указатель на новый элемент
* в противном случае
*/
Node *insert(Node **proot, int k, Type in)
{
Node *cur, *ptr;
/* создание нового элемента таблицы – дерева */
Node *cur = new Node;
if(!cur)
return NULL; /* нет свободной памяти */
cur->key = k;
cur->info = dupl(in);
cur->left = cur->right = NULL;
/* включение нового элемента в дерево */
if(!(ptr = instree(proot, cur))){
delInfo(cur->info);
delete cur;
}
return ptr;
}
Тексты всех рассмотренных функций приведены также в файле tab2bin.cpp.
Таблицы прямого доступа
Часто операции включения и поиска информации в таблице основываются на использовании не самого ключа, а некоторой информации, зависящей от ключа – так называемого производного ключа. При этом определяется некотораяфункция расстановкиI(k), которая для заданного ключаkпозволяет получить более простой и более эффективный производный ключ, используемый для обращения к элементам таблицы. Обычно производный ключ определяет размещение искомого элемента в таблице. Таблицы, доступ к которым осуществляется с помощью такого производного ключа, называют ещетаблицами с вычисляемыми входами. Такие таблицы отображаются в памяти вектором, а функция расстановки в качестве производного ключа возвращает индекс соответствующего элемента вектора.
Пусть таблица содержит M записей со
значениями ключей k1, k2, ...,kM
(все значения ключей разные) и
отображается в вектор A из N элементов
A[0], A[2], ...,
A[N-1], причем M
N.
Если определена функция расстановки
I(k), такая, что для любогоki,i= 1, 2, ..., M I(ki)
имеет целое значение от 0 до N–1, причем
I(ki)
I(kj) для любых ki
kj, то элемент таблицы с ключомkвзаимно однозначно отображается в
элемент вектора A[I(k)].
Такие таблицы и являются таблицами
прямого доступа.
Таким образом, функция расстановки обеспечивает вычисление для каждого элемента таблицы индекса соответствующего элемента вектора A. Доступ к элементу таблицы по ключу kосуществляется в этом случае непосредственно путем вычисления значения I(k). Максимальная и средняя длина поиска в таких таблицах минимальны и равны:T=D= 1.
Таблицы прямого доступа обладают целым рядом важных свойств, существенно влияющих на их использование:
Так как в таких таблицах ключи элементов однозначно отображаются в элементы вектора, нет необходимости хранить в них значения ключей;
В силу сложности подбора функции расстановки часто таблицы прямого доступа имеют не очень высокую степень заполненности таблицы
, которую можно определить следующим образом:
,
где M– общее количество элементов, размещенных в таблице (размер таблицы),
N– размер вектора, на который отображается таблица.
Обычно
< 1 и даже (достаточно часто) могут иметь
случаи, когда
<< 1.
Подбор функции расстановки, обеспечивающей однозначность преобразования ключа элемента таблицы в адрес (индекс) его хранения, в общем случае является очень сложной задачей. На практике ее можно решить только для статических таблиц с заранее известным набором значений ключа. В противном случае при появлении нового ключа k* может возникнуть недопустимая для таких таблиц ситуация, при которой I(k*) = I(kj) для уже имеющегося в таблице элемента с ключом kj.
Рассмотрим пример. Пусть в таблице должны размещаться элементы с ключами (всего 14 элементов):
12, 48, 3, 5, 7, 63, 15, 202, 103, 188, 30, 43, 6, 18
В качестве одного из вариантов функции расстановки можно взять I(k) = k – 1; тогда в векторе должен быть элемент с индексом 201 (чтобы отобразить в вектор элемент с максимальным значением ключа 202). Из этого следует, что таблица-вектор должна содержать 202 элемента, из которых будут использованы только 14, т.е. степень заполненности таблицы
Если в качестве функции расстановки взять I(k) = k%50, тогда таблица должна иметь 50 элементов, и степень заполненности таблицы
Однако если в таблицу потребуется добавить элемент с ключом 62, для этого ключа функция расстановки даст значение 12, совпадающее со значением производного ключа для первого элемента (с ключом 12), и потребуется менять функцию расстановки.
В качестве иллюстрации сложности подбора
функции расстановки приведу выдержку
из монографии Д.Кнута "Искусство
программирования для ЭВМ, т. 3. Сортировка
и поиск": " ... Существует 4131
1050различных отображений множества из 31
элемента в множество из 41 элемента, и
только 41
40
...
11
=
1043из них дают различные значения для
каждого аргумента; таким образом, лишь
одна из каждых 10 миллионов функций
оказывается подходящей." (стр. 601).
Реализация функций работы с таблицами прямого доступа не вызывает никаких затруднений; главная проблема здесь – подбор функции расстановки.