sp [Index (1 , 5)] = 4 sp [Index (1 , 2)] = 0
« Структуры данных и алгоритмы. Теория. Динамические структуры данных | Структуры данных и алгоритмы. Теория. Деревья. Основные алгоритмы над деревьями » |
Структуры данных и алгоритмы. Теория. Общие правила и рекомендации к разработке собственных контейнерных структур данных
Необходимым для любого программиста является знание и умение использовать разнообразные классы-коллекции - структуры данных, которые служат для хранения и управления другими объектами согласно определенной дисциплине обслуживания и представления. Практически каждая коммерческая библиотека для популярных языков программирования имеет собственный набор коллекций. Часто различные коллекции являются уже частью стандарта языка. Предполагается, что читатель имеет понятия о коллекциях из раздела STL стандартной библиотеки C++. В данном разделе рассматриваются индексируемые коллекции, итераторы коллекций, вводится понятие курсоров. Рассматриваются основные принципы создания модифицируемых в процессе просмотра коллекций.При создании коллекций разработчики стремятся к тому, чтобы стандартизировать средства доступа и модификации различных коллекций. Часто с целью этого выполняется перегрузка набора операторов, в качестве примера далее приводится пример интерфейса класса множества (SET).
template <class T> class Set {
protected:
// множество полей которые служат для хранения данных коллекции
// множество вспомогательных методов для реализации логики работы
// коллекции
public:
// множество методов и операций, которые и составляют интерфейс данного класса
Set ();
Set (const Set<T> & ans);
// создание множества на основании массива из N элементов
// находяющихся внутри массива “Elements”
Set (T * Elements, int n);
// оператор объединения множеств
Set <T> operator + (const Set <T> & ans) const;
// оператор разности множеств
Set <T> operator – (const Set <T> & ans) const;
// оператор пересечения двух множеств
Set <T> operator * (const Set <T> & ans) const;
// проверка принадлежности одного множества другому
bool operator > (const Set <T> & ans);
// проверка принадлежности одного множества другому
bool operator < (const Set <T> & ans);
// проверка принадлежности одного множества другому
bool operator >= (const Set <T> & ans);
// проверка принадлежности одного множества другому
bool operator <= (const Set <T> & ans);
// проверка равенства двух множеств
bool operator == (const Set <T> & ans);
// проверка неравенства двух множеств
bool operator != (const Set <T> & ans);
// оператор получения мощности – размерности множества
operator int ();
friend Set <T> operator + (const T & ans , const Set<T> ansto);
};
Для доступа к коллекциям часто используют перегруженную версию оператора “[]”. В простейшем случае его перегрузка выполняется, для того чтобы выполнять проверку доступа к элементу массива по индексу (в C++, с целью повышения производительности решений, подобная проверка не выполняется, и если вы обратитесь к несуществующему элементу, то сыграете в рулетку – как вариант ваша программа будет аварийно завершена, или вы извлекаете из памяти номер телефона вашей тетушки и прочую несуществующую информацию).
Пример кода класса, в котором выполняется перегрузка оператора “[]” приводится ниже.
#include <stdio.h>
template <class T>
class SafeArray {
protected:
T * data;
int count;
public:
class EonInvalidIndex {
// некоторый набор полей
// а также множество методов класса
// и все это для того чтобы предоставить
// информацию о том что у нас “поломалось”
};
SafeArray (int size): count (size) , data (new T[size]) {}
~SafeArray (){delete data;}
T & operator [] (int idx){
if (idx < 0 || idx >= count)
throw EonInvalidIndex () ;
return data [idx];
}
};
// и пример использования
void main (void){
SafeArray<char> sa (10);
sa [0] = 'a';
try{
sa [10] = 'b';
}
catch (SafeArray<char>::EonInvalidIndex err){
printf ("we have some problems\n");
}
printf ("error was fixed");
}
SafeArray <int> si(10);
si[0] = 12; // левостороннее выражение
int x = si [1]; // правостороннее выражение
#include <stdio.h>
#include <string>
using namespace std;
const MAX_HASH_SIZE = 100;
class Hash {
protected:
string keys [MAX_HASH_SIZE];
string values [MAX_HASH_SIZE];
int hash_size;
public:
Hash ();
Hash (const Hash & ans);
Hash & operator = (const Hash & ans);
string & operator [] (const string & idx);
};
void main (void){
Hash h;
h ["apple"] = "яблоко";
h ["orange"] = "апельсинка";
}
С использованием оператора “[]” можно выполнить имитацию работы с многомерным массивом. К сожалению, вполне логичный, казалось бы, пример кода:
int & operator [] (int i , int j , int k);
struct Index {
int I , j , k;
};
// ...
int & operator [] (const Index & idx);
// ...
// и наконец пример использования
arr [Index (1 , 3 , 7)] = 23;
Умные указатели
Концепция умных указателей заключается в том, что вместо того, что бы пользоваться объектами некоторого класса, указателями на эти объекты или ссылками, определяется новый тип, который выступает в роли промежуточного слоя между самим объектом и использующим его кодом.
Основные направления применения умных указателей:
- Разименование значения NULL;
- Отладка и трассировка;
- Кэширование.
Однако перед тем как я более подробно рассмотрю эти применения, давайте для затравки попробуем решить одну из проблем всегда стоящих перед даже опытными программистами. Когда мы создаем некоторую переменную, то память для нее может быть выделена либо в стеке, либо в “куче”. Чтобы выделить место для стековой переменной нужно ее просто объявить в некотором блоке и по завершению этого блока выделенный фрагмент памяти будет автоматически освобожден. Итак, когда мы создаем объект, который размещается в памяти стека, то его деструктор неявно вызывается при выходе объекта из области видимости:
{
MyClass a;
a.someFunction ();
}// именно тут происходит удаление объекта “a” и освобождение памяти.
{
MyClass *a = new MyClass;
a->someFunction ();
delete a;// освобождение памяти необходимо выполнять явно.
}
int * createArray (int delta){
int arr [10];
for (int I=0; I < 10; I++)
arr [i] = delta + I;
}
// ...
int *myarr = createArray (5);
myarr [0] *= 2;
Однако рекомендация: «создавайте ваши объекты только в “куче”», также не слишком хороша. Прежде всего идет потеря в скорости работы: после некотрого промежутка времени работы вашей программы память кучи будет уже достаточно фрагментирована, чтобы задача выделения блока стала уже не тривиальной. Компьютер должен потратить часть ресурсов чтобы решить откуда взять блок нужного размера. Однако самым главным недостаком будет необходимость явного контроля за освобождением памяти. Если мы забудем освободить память, то постепенно через несколько часов или дней работы нашей программы могут появиться совершенно неожиданные ошибки, когда стопроцентно надежные фрагменты кода уже будут отказываться работать, или же вы переусердствуете и попытаетесь освободить память два раза. В такой ситуации те программисты, которые пишут на языках с “автоматической сборкой мусора” (в таких языках оператора delete или его аналога просто нет, а среда исполнения сама освобождает некоторый фрагмент памяти, когда на объект находящийся в нем нет больше активных ссылок, раз нет ссылок, то нельзя к нему обратиться и следовательно объект можно уничтожить без боязни) находятся в более выигрышной позиции. Итак, автоматическое удаление это плюс стековых объектов, поэтому существует прием создания “оберток”. Создается класс обертки содержащий поле типа указателя не целевой объект. Для удобства работы выполняется перегрузка оператора селектора “->”. Далее приводится пример кода, в котором к ранее созданному в главе посвященной стеку классу “MySuperStack” добавлется обертка.
template <class T>
class Wrapper {
T * ptr;
public:
Wrapper (T * w) : ptr (w) {printf ("Wrapper was created");};
~Wrapper (){delete ptr;};
T * operator -> () {return ptr;}
};
void main (void){
Wrapper < MySuperStack <int> > wr ( new MySuperStack<int>(10 , 10));
wr->push (123);// именно благодаря перегруженному оператору “->”
wr->pop ();// мы получаем возможность доступа к методам стека
}// именно здесь по завершению области действия wr вызывается его деструктор
и освобождается память выделеннная и под “MySuperStack”
Подведем итог: для того чтобы создавать объекты динамически и автоматизировать процесс вызова деструктора каждый раз, когда объект выходит из области видимости, то рекомендуется создать обертку целевого класса. Обертка создается в памяти стека функции, а, следовательно, будет удалена автоматически, в деструкторе обертки идет явное удаление динамического объекта с помощью “delete ptr;”. Для удобства использования перегружают оператор-селектор “->”.
Рассмотрим теперь прием с разыменованием значения NULL. Общая схема использования умного указателя выглядит так:
template <class T> class SmartPointer {
protected:
T * data;
public:
SmartPointer (T * t): data (t){}
~ SmartPointer () {delete data;}
operator T * (){
return data;
}
};
T * operator -> (){
if (! data){
perror (“error, dereferencing NULL, created new element”);
return (data = new T);
}
return data;
}
struct InvalidItemException {
// некоторый набор полей
};
T * operator -> (){
if (! data){
throw InvalidItemException ();
}
return data;
}
Ведение статистики использования объекта. Это имеет большое значение при решении задач профилирования кода. Если вы в операторах “->” и “T *” выполните хотя бы подсчет количества обращений, то можно уже искать узкие места программы “bottle necks”, т.е. те фрагменты кода, которые вызыватся наиболее часто и занимают больше всего времени. Для оптимизации по скорости работы программы в дальнейшем можно “bottle neck” функции переписать.
Задание: разработайте такую версию умного указателя, в котором будет выполняться подсчет всех операций использования целевого объекта, а также по завершению работы программы в специальный файл журнала будет записываться эта величина.
Кэширование: в основе данной концепции лежит тот факт, что существуют объекты занимающие настолько большой объем памяти что одновременное нахождение хотя бы десятка из них в физической оперативной памяти не желательно. При этом, что очень важно, вероятность одновременного использования всех их крайне мала. Подобный прием, в ходе которого малоиспользуемые объекты сохраняются, например, на жесткий диск используется в современных операционных системах (виртуальная память, файлы подкачки, swap-файлы, страничный обмен). И только тогда, когда идет непосредственно обращение к объекту, которого нет в оперативной памяти его умный указатель выполняет его подзагрузку, так что для клиента создается иллюзия, что объект всегда был доступен.
Задание: необходимо разработать умный указатель, выполняющий вышеописанную дисциплину. В качество целевого объекта используйте объект BigArray (это будет уже знакомый нам класс динамического массива из предыдущих заданий, однако в потомок должен реализовать методы SaveToDisk, LoadFromDisk для сохранения и записи в файл-хранилище).
Блокировка совместного использования: умные указатели также служат для решения задачи запрета совместного использования неразделяемого ресурса несколькими потоками в многозадачном приложении. Общепринятой является механизм синхронизации: идея данного приема в том, что перед началом использования некоторого ресурса значение специального флага, назовем его “IsBusy” устанавливается в состояние “Заблокировано”, а после завершения работы с объектом наш флаг сбрасывается в состояние “Свободен”. Идея запрета доступа к объекту не через умный указатель и перегрузка оператора селектора позволяет создать единый способ обращения к функциональности класса только через посредника, который этот процесс и контролирует.
В библиотеке STL присутствует класс шаблона auto_ptr (аналогичный ранее рассмотренному Wrapper), существует достаточное количество коммерческих библиотек, в которых развиваются идеи умных указателей, однако, каждый программист должен попытаться создать их для себя сам, так как это способствует лучшему пониманию механизмов их работы.
Также следует отметить с некоторым заделом на будущее, что если вам придется писать код приложений с использованием технологий COM, то такие непонятные пока для вас вещи как “Iunknown”, “QueryInterface”, “ClassFactory” и т.д. произрастают из идеологии умных указателей.
Курсоры
Чтобы понять концепцию курсоров и того, как их использовать при работе с коллекциями, необходимо рассмотреть конкретный пример. Классическим примером являются разреженные массивы. Разраженный массив относится к числу основных структур данных. Он представляет собой матрицу, у которой большинство ячеек в любой момент времени остаются пустыми. Какими бы большими и быстрыми компьютерами мы не обладали, но для хранения матрицы размером 1000*1000*1000 вам потребуется слишком много ресурсов и хотелось бы мне посмотреть на вас когда вы будете убеждать заказчика, что ваша программа не может работать пока он не докупит еще пару гигабайт памяти и все лишь потому, что вы не удосужились поработать над оптимизацией. Когда вы на C++ или ином языке высокого уровня декларируете массив, то память выделяется для него всего сразу. При этом 99% захваченных вами ресурсов не будут использоваться и, увы, не будут доступны также и для других приложений. Все методы работы с разреженными массива имеют общую черту – память выделяется для него не сразу, а именно по мере необходимости. В качестве простейшего пример использования разреженных массивов можно привести приложения вроде ms excel или иной электронной таблицы, ведь вы не думаете, что программа хранит информацию обо всех ее ячейках (даже не заполненных), ведь в excel количество ячеек порядка 256 * 65536 = 16 777 216. если вы будете работать в сфере математического или физического моделирования, экономики, то вам придется использовать разреженные массивы.
В качестве небольшого исследования я далее привожу пример кода реализации разреженного массива навеянный решением, предложенным в книге Г.Шилдт, “Теория и практика C++” (с небольшими модификациями, разумеется). Попробуйте в нем разобраться и ответить на следующий вопрос: когда создаются новые элементы и хорош ли такой подход, а если нет то почему:
#include <stdio.h>
/* структура служащая для адресации элементов в массиве,
количество полей: i, j – количество размерностей нашего разреженного массива,
в данном случае у нас обычная прямоугольная двумерная матрица.
*/
struct Index {
protected:
int i , j;
public:
Index (int ii , int jj) : i(ii) , j (jj) {}
Index () : i(-1) , j (-1) {}
bool operator == (const Index & idx) {
return i == idx.i && j == idx.j;
}
};
struct Item {
Index index;
int Data;
Item * next;
public:
Item (int DData , Index idx) : index (idx) , Data (DData), next (NULL){}
void SetIndex (const Index &idx) { index = idx;}
Index GetIndex (void) {return index;}
Item () : Data (-1), next (NULL) {}
int GetData () {return Data;}
int & GetRefData () {return Data;}
void SetData (int DData) {Data = DData;}
};
class SparseArray {
protected:
Item * first;
public:
SparseArray (void) : first (NULL) {}
~SparseArray (){
while (first){
Item * tmp = first;
first = first->next;
delete tmp;
}
};
int & operator [] (const Index & idx ){
Item * tmp = first;
while (tmp){
if (tmp->GetIndex() == idx) break;
tmp = tmp->next;
}
if (tmp) return tmp->GetRefData ();
Item * nelt = new Item();
nelt->next = first;
first = nelt;
first->SetIndex (idx);
first->SetData (0);
return first->GetRefData ();
}
}; //end of class
// и пример использования
void main (void){
SparseArray sp ;
sp [Index (1 , 1)] = 2;
sp [Index (1 , 3)] = 3;
sp [Index (1 , 5)] = 4;
printf ("sp [Index (1 , 5)] = %d\n" , sp [Index (1 , 5)]);
printf ("sp [Index (1 , 2)] = %d\n" , sp [Index (1 , 2)]);
}
А с использованием курсоров решение подобной задачи становится гораздо легче.
Нам нужно чтобы оператор “[]” возвращал нечто обладающее двумя свойствами:
- это должно обладать способностью преобразования к типу элемента хранимого в разреженном массиве;
- оно может быть использовано в левой части операции присвоения для изменения соответствующей ячейки.
#include <stdio.h>
class DataType{
// данный класс представляет собой имитацию
// класса хранящего некоторый набор данных
};
struct Index {
protected:
int i , j;
public:
Index (int ii , int jj) : i(ii) , j (jj) {}
Index () : i(-1) , j (-1) {}
bool operator == (const Index & idx) {
return i == idx.i && j == idx.j;
}
};
struct Item {
Index index;
DataType * Data;
Item * next;
public:
Item (DataType * DData , Index idx) : index (idx) , Data (DData), next (NULL){}
void SetIndex (const Index &idx) { index = idx;}
Index GetIndex (void) { return index;}
Item () : Data (NULL), next (NULL) {}
DataType * GetData () { return Data;}
DataType * & GetRefData () { return Data;}
void SetData (DataType * DData) {Data = DData;}
};
class SparseArray2 {
protected:
Item * first;
friend class SparseCursor;
public:
SparseArray2 () : first (NULL) {}
SparseCursor operator [] (Index idx);
};
class SparseCursor {
/*
класс курсора – обратите внимание что он находится в дружеских отношениях
с классом разреженного массива равно как и наоборот, дело в том, что подобные классы
тесно связаны с внутренним устройством друг друга
*/
friend class SparseArray2;
SparseArray2 & refParentContainer;
Index index;
Item * item;
public:
SparseCursor (Index idx , Item * it , SparseArray2 & parentContainer)
: refParentContainer (parentContainer) , item (it) , index (idx){}
SparseCursor & operator = (DataType * DData){
if (item){
item->SetData (DData);
return *this;
}
Item * nelt = new Item;
nelt->index = index;
nelt->SetData (DData);
nelt->next = refParentContainer.first;
refParentContainer.first = nelt;
return *this;
}
};
SparseCursor SparseArray2::operator [] (Index idx){
Item * tmp = first;
while (tmp){
if (tmp->index == idx) return SparseCursor (idx , tmp , *this);
tmp = tmp->next;
}
return SparseCursor (idx , NULL , *this);
}
// и пример использования
void main (void){
SparseArray2 spa2 ;
// мы добавляем новый элемент в наш резреженный массив
spa2 [Index(12 , 23)] = new DataType;
// обращаемся к уже существующему и меняем его информационную часть
(spa2 [Index(12 , 23)] = new DataType) = new DataType;
}
DataType * operator -> (){
if (item) return item->GetData();
throw EInvalidItemException ();
}
operator DataType * (){
return item?item->GetData ():NULL;
}
struct EInvalidItemException{
// данный класс хранит набор информации о том
// почему произошло исключение из-за
// обращения к несуществующему элементу
};
void main (void){
SparseArray2 spa2 ;
// мы добавляем новый элемент в наш резреженный массив
spa2 [Index(12 , 23)] = new DataType;
// обращаемся к уже существующему и меняем его информационную часть
(spa2 [Index(12 , 23)] = new DataType) = new DataType;
printf ("is null? %c" , spa2 [Index(12 , 24)]?'N':'Y');
}
is null? Y.
Итераторы
Мы создаем коллекцию и можем работать, обращаться к каждому из элементов коллекции. Что же давайте, сделаем перебор всех элементов созданного нами разреженного массива. Если вы хотите воспользоваться циклом наподобие:
for (int i=0; I < 10000; i++)
for (int j=0; j < 10000; j++)
printf ("elt at position (%d, %d) = %d" , i , j , array[index (I , j)]);
Типичные операции, выполняемые над списками, массивами и иными контейнерами (т.е. классами которые служат для хранения и обслуживания согласно определенной дисциплине FIFO, LIFO или какой-то иной дисциплине, множества элементов) – это операции просмотра этих элементов по порядку и выполнения некоторой операции, например поиска максимального элемента или изменения значений согласно некоторой зависимости (далее приводится пример прохода по всем элементам списка и обычного массива):
|
|
Итератором называют абстрактное представление процесса просмотра коллекции элементов по порядку. Итератор включает последовательность S, текущую позицию в данной последовательности, а также способ перехода к следующей позиции и превращения ее в текущую. В простейшем виде всякий итератор должен поддерживать два следующих метода:
- hasNext () – проверка того есть ли еще не пройденные элементы в итераторе;
- next () – перемещения к следующему элементу с возвращением текущего.
Важно: итератор содержит обобщенную схему для доступа ко всем элементам контейнера, не зависящую от особенностей его реализации.
Считается, что если некоторый АТД поддерживает концепцию итератора, то он содержит метод “elements” – или иной который создает и подготавливает для работы итератор. Можно отдельно выделять итераторы, которые обладают способностью перемещения в обоих направлениях, методы для модификации элементов контейнера, обладают способностью выбирать направление перемещения от последнего к первому или наоборот, итераторы, которые не позволяет модификации элементов, по которым выполняется проход. Особый интерес представляют итераторы которые выполняют замыкание: суть данного процесса в том что при создании экземпляра итератора мы становимся очень чуствительными к попытками изменить наш контейнер во время его прохода. При создании многопоточных приложений особенно часто бывает ситуация, когда два параллельно работают два программных потока: один из которых просматривает контейнер, а второй в этот же момент времени пытается добавить или удалить элемент. В итого получается что первый поток по нескольку раз просмотрел один и тот же элемент, или пропустил что-то. Решением может быть создание внутренней копии коллекции (принадлежащей конкретному итератору) соответствующей ей на момент вызова метода “elements”, так даже если другой поток пытался модифицировать контейнер, то первый поток не заметит этого и будет работать с первоначальным состоянием контейнера.
Возвращаясь к нашему примеру с разреженным массивом. Класс итератора для него можно реализовать согласно следующему примеру:
class SparseArray2;
class Iterator {
protected:
Item * cur;
SparseArray2 & refSparse;
public:
Iterator (SparseArray2 & loc_refSparse);
void rewind (void);
bool hasNext (void);
DataType * next (void);
};
class SparseArray2 {
protected:
Item * first;
friend class SparseCursor;
friend class Iterator;
public:
SparseArray2 () : first (NULL) {}
SparseCursor operator [] (Index idx);
Iterator elements (void){
return Iterator (*this);
}
};
Iterator::Iterator (SparseArray2 & loc_refSparse) :
refSparse (loc_refSparse) ,
cur (loc_refSparse.first)
{}
void Iterator::rewind (void){
cur = refSparse.first;
}
DataType * Iterator::next (void){
DataType * tmp = cur->GetData ();
cur = cur->next;
return tmp;
}
bool Iterator::hasNext (void) {
return cur != NULL;
}
Iterator I = spa2.elements ();
while (I.hasNext() ){
DataType * info = I.next ();
printf ("Data %x\n" , info);
}
Задание
1) Давайте рассмотрим снова наш пример хеша, необходимо добавить к нему поддержку просмотра элементов с помощью итераторов.
Необходимо создать два итератора:
- первый итератор должен служить для просмотра списка ключей хеша;
- второй итератор должен перебирать пары значений (ключ, значение).
2) В детской игре «Горячая картошка» группа из N детей садится в круг, после чего участники по кругу передают друг другу предмет, называемый “картошкой” (Допустим, по часовой стрелке). Дети передают картошку до тех пор, пока ведущий не зазвонит в колокольчик, после чего ребенок, у которого в руках осталась картошка, выбывает из игры, а остальные дети замыкают круг. Данный процесс повторяется до тех пор, пока не останется один ребенок, который объявляется победителем. Используя АТД список необходимо реализовать эффективную реализацию данной игры. Пусть ведущий звонит в колокольчик после того как картошку передали M раз.
3) Развейте дальше пример хеша и добавьте к нему еще один метод “const_elements”, который должен возвращать ссылку на итератор с внутренней копией коллекции.
4) Напишите псевдокод реализации алгоритма пузырьковой сортировки, использующей только два стека и максимально пять дополнительных переменных для сортировки набора чисел изначально хранящихся в одном из стеков. Конечным результатом будет являться один из стеков, в котором элементы расположены таким образом, чтобы последовательность операций извлечения элементов (pop) выстроила элементы в нужном порядке.
« Структуры данных и алгоритмы. Теория. Динамические структуры данных | Структуры данных и алгоритмы. Теория. Деревья. Основные алгоритмы над деревьями » |