Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
СТА (лекции+лабы) / Структуры и алгоритмы обработки данных.docx
Скачиваний:
100
Добавлен:
16.03.2016
Размер:
1.9 Mб
Скачать

Атд “Очередь”

АТД “Очередь” (queue) - это набор данных, организованный таким образом, что вставка нового элемента производится только с конечной ячейки, а удаление - только с начальной. Очередь работает по принципу FIFO (First In - First Out), что соответствует общепринятому понятию очереди в жизни, например, возле кассы в супермаркете.

Также как и стек, очередь может быть ограниченного и неограниченного размера в зависимости от потребностей решаемой задачи.

Очередь предполагает наличие следующих абстрактных операций:

  • CLEAR( queue ) - делает очередь пустой;

  • IS_EMPTY( queue ) : bool - определяет является ли очередь пустой;

  • IS_FULL( queue ) : bool - определяет является ли очередь полностью заполненной (что имеет смысл только для очередей ограниченного размера);

  • PUSH ( queue , value ) - помещает новое значение в конец очереди;

  • POP ( queue )  - удаляет значение с начала очереди;

  • FRONT ( queue ) : value - возвращает значение в начале очереди.

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

integer_queue.hpp

#ifndef _INTEGER_QUEUE_HPP_

#define _INTEGER_QUEUE_HPP_

struct IntegerQueue;

IntegerQueue * IntegerQueueCreate ();

void IntegerQueueDestroy ( IntegerQueue * _pQueue );

void IntegerQueueClear ( IntegerQueue & _queue );

bool IntegerQueueIsEmpty ( const IntegerQueue & _queue );

bool IntegerQueueIsFull ( const IntegerQueue & _queue );

void IntegerQueuePush ( IntegerQueue & _queue, int _value );

void IntegerQueuePop ( IntegerQueue & _queue );

int IntegerQueueFront ( const IntegerQueue & _queue );

#endif // _INTEGER_QUEUE_HPP_

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

integer_queue_list_impl.cpp

#include "integer_queue.hpp"

#include "integer_list.hpp"

#include <cassert>

struct IntegerQueue

{

   // Реализуем очередь через связный список

   IntegerList m_List;

};

IntegerQueue * IntegerQueueCreate ()

{

   // Создаем объект-очередь динамической памяти,    // т.к. только здесь известен настоящий тип

   IntegerQueue * pNewQueue = new IntegerQueue;

   // Инициализируем внутренний объект-список

   IntegerListInit( pNewQueue->m_List );

   // Возвращаем указатель на реальную очередь,    // он будет использоваться в клиентском коде как описатель

   return pNewQueue;

}

void IntegerQueueDestroy ( IntegerQueue * _pQueue )

{

   // Уничтожаем внутренний объект-список

   IntegerListDestroy( _pQueue->m_List );

   // Уничтожаем объект-очередь, т.к. только мы знаем его настоящий тип

   delete _pQueue;

}

void IntegerQueueClear ( IntegerQueue & _queue )

{

   // Очистка списка равносильна его уничтожению

   IntegerListDestroy( _queue.m_List );

}

bool IntegerQueueIsEmpty ( const IntegerQueue & _queue )

{

   // Очередь пуста, когда пуст внутренний список

   return IntegerListIsEmpty( _queue.m_List );

}

bool IntegerQueueIsFull ( const IntegerQueue & _queue )

{

   // Такая очередь в теории никогда не переполнится

   return false;

}

void IntegerQueuePush ( IntegerQueue & _queue, int _value )

{

   // Помещение элемента в очередь = добавление элемента в конец списка

   IntegerListPushBack( _queue.m_List, _value );

}

void IntegerQueuePop ( IntegerQueue & _queue )

{

   // Удаление элемента из очереди = удаление элемента с начала списка

   assert( ! IntegerQueueIsEmpty( _queue ) );

   IntegerListPopFront( _queue.m_List );

}

int IntegerQueueFront ( const IntegerQueue & _queue )

{

   // Начало очереди в начале списка

   assert( ! IntegerQueueIsEmpty( _queue ) );

   return _queue.m_List.m_pFirst->m_value;

}

Используя реализованную функциональность, решим следующую задачу: пользователь вводит последовательность целых чисел, а программа, начиная с 3-го числа, дублирует ввод с отставанием на 2 элемента. Т.е., при вводе последовательности “1 2 3 4 5” программа должна вывести поэлементно последовательность “1 2 3”, начиная с момента ввода числа “3”.

Ниже приведен исходный код данной программы:

#include "integer_queue.hpp"

#include <iostream>

int main ()

{

   IntegerQueue * pQueue = IntegerQueueCreate();

   int delayCounter = 2;

   while ( std::cin )

   {

       std::cout << "Input a number: ";

       int temp;

       std::cin >> temp;

       if ( std::cin )

       {

           IntegerQueuePush( * pQueue, temp );

         

           if ( delayCounter > 0 )

             -- delayCounter;

           else

           {

                std::cout << "Queued: " << IntegerQueueFront( * pQueue ) << std::endl;

                IntegerQueuePop( * pQueue );

           }

       }

       else

           break;

   }

   IntegerQueueDestroy( pQueue );

}

Если задача предполагает ограничение размера очереди, удачной структурой является циклический массив.  Примем условно, что элементом массива, следующим за последним, является начальный элемент. Предполагается хранение индексов для помещения в условный конец очереди (m_BackIndex) и для изъятия из условного начала очереди (m_FrontIndex). При помещении нового элемента индекс конца увеличивается с учетом возможного закицливания. Аналогично, при изъятии элемента, увеличивается индекс начала очереди. В результате работы индексы могут как бы “перехлестнуться”.

Количество хранимых элементов можно определить по разнице индексов, при этом следует учесть возможное “перехлестывание”. Однако, чтобы отличить пустую очередь от очереди, заполненной на 100%, следует зарезервировать дополнительную ячейку. Т.е., когда требуется очередь, скажем, из 5 элементов, следует выделить блок из 6 ячеек. В результате, когда очередь будет полна, должна быть сводобна ровно 1 ячейка.

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

После добавления элемента в очередь индекс конца будет двигаться, а начала - стоять на месте:

Элементы можно будет добавлять до достижения максимально разрешенного числа хранимых ячеек, что соответствует размеру выделенного блока минус 1:

Если затем начать изымать элементы из очереди, двигаться вправо будет индекс начала, а индекс конца останется на месте:

     

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

       

Ситуация может вернуться в обратную сторону, если вновь изъять несколько элементов:

       

За счет подмены индексов при выходе за границу массива, достигается иллюзия циклической структуры. Ниже представлена реализация данного подхода:

integer_queue_cyclic_array_impl.cpp

#include "integer_queue.hpp"

#include <cassert>

struct IntegerQueue

{

   int * m_pData;

   int m_Size;

   int m_FrontIndex;

   int m_BackIndex;

};

IntegerQueue * IntegerQueueCreate ( int _fixedSize )

{

   assert( _fixedSize > 0 );

   

   IntegerQueue * pNewQueue = new IntegerQueue;

   

   pNewQueue->m_Size       = _fixedSize + 1;

   pNewQueue->m_pData      = new int[ pNewQueue->m_Size ];

   IntegerQueueClear( * pNewQueue );

   return pNewQueue;

}

void IntegerQueueDestroy ( IntegerQueue * _pQueue )

{

   delete[] _pQueue->m_pData;

   delete _pQueue;

}

void IntegerQueueClear ( IntegerQueue & _queue )

{

   _queue.m_FrontIndex = _queue.m_BackIndex = 0;

}

int IntegerQueueSize ( const IntegerQueue & _queue )

{

    // |-|-|-|-|-|-|        |-|-|-|-|-|-|  

    //   F     B                B     F

    return ( _queue.m_FrontIndex <= _queue.m_BackIndex ) ?

             _queue.m_BackIndex - _queue.m_FrontIndex :

              _queue.m_BackIndex + _queue.m_Size - _queue.m_FrontIndex;

}

bool IntegerQueueIsEmpty ( const IntegerQueue & _queue )

{

   return IntegerQueueSize( _queue ) == 0;

}

bool IntegerQueueIsFull ( const IntegerQueue & _queue )

{

   return IntegerQueueSize( _queue ) == ( _queue.m_Size - 1 );

}

int IntegerQueueNextIndex ( const IntegerQueue & _queue, int _index )

{

   int index  = _index + 1;

   if ( index == _queue.m_Size )

      index = 0;

   return index;

}

void IntegerQueuePush ( IntegerQueue & _queue, int _value )

{

   assert( ! IntegerQueueIsFull( _queue ) );

   _queue.m_pData[ _queue.m_BackIndex ] = _value;

   _queue.m_BackIndex = IntegerQueueNextIndex( _queue, _queue.m_BackIndex );

}

void IntegerQueuePop ( IntegerQueue & _queue )

{

   assert( ! IntegerQueueIsEmpty( _queue ) );

   _queue.m_FrontIndex = IntegerQueueNextIndex( _queue, _queue.m_FrontIndex );

}

int IntegerQueueFront ( const IntegerQueue & _queue )

{

   assert( ! IntegerQueueIsEmpty( _queue ) );

   return _queue.m_pData[ _queue.m_FrontIndex ];

}

Выводы

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

СТА: Лекция №4 - Отображения и множества

Версия 2.01, 09 декабря 2014г.

(С) 2012-2014, Зайченко Сергей Александрович, к.т.н, ХНУРЭ, доцент кафедры АПВТ

Отображения и множества

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

  1. Отображения (MAP), или словари (DICTIONARY), реже - таблицы символов (SYMBOL TABLE).

  2. Множества (SET).

Отображения представляют собой хранилища элементов-значений (values), идентифицируемых по данным-ключам (keys). Целью поиска является отыскание элементов-значений по заданному ключу. Такой способ организации данных для поиска также называют ассоциативной памятью.

Отображения напоминают обычные унарные функции (аргумент-ключ, возврат - хранимый элемент), однако функции, такие как SQUARE(X) = X * X , реализуются при помощи вычисляющих инструкций, а отображения предполагают хранение готовых данных-результатов в памяти и поиск в хранилище по аргументу-ключу вместо непосредственного вычисления результата.

Среди основных операций АТД отображение:

  • CLEAR( m ) - полная очистка отображения;

  • IS_EMPTY( m ) : bool - определяет является ли отображение пустым;

  • INSERT ( m, key, value ) - вставка в отображение значения value с ключом key;

  • HAS_KEY ( m, key ): bool - тестирование наличия в отображении элемента с ключом key

  • FIND( m, key ): value - поиск элемента по ключу key;

  • REMOVE_KEY( m, key ) - удаление элемента с ключом key.

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

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

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

  • CLEAR( s ) - очищает множество;

  • INSERT( s, key ) - вставка ключа key;

  • HAS_KEY( s, key ): bool - тестирования наличия ключа key внутри множества;

  • REMOVE_KEY( s, key ) - удаление ключа лун из множества;

  • UNION ( s1, s2 ): s - объединение с другим множеством;

  • INTERSECTION( s1, s2 ) : s - пересечение с другим множеством;

  • DIFFERENCE( s1, s2 ) : s - разница с другим множеством;

  • SYMMETRIC_DIFFERENCE( s1, s2 ) : s - объединение разницы s1 и s2 с разницей s2 и s1.

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

Интерфейс отображений

Предположим, необходимо создать отображение, в котором и ключ, и значение являются целыми числами. Ниже представлен заголовочный файл с интерфейсом такого АТД:

#ifndef _INTEGER_MAP_HPP_

#define _INTEGER_MAP_HPP_

// Форвардное объявление структуры-отображения

struct IntegerMap;

// Создание нового объекта-отображения

IntegerMap * IntegerMapCreate ();

// Уничтожение объекта-отображения

void IntegerMapDestroy ( IntegerMap * _pMap );

// Очистка отображения

void IntegerMapClear ( IntegerMap & _map );

// Проверка отображения на пустоту

bool IntegerMapIsEmpty ( const IntegerMap & _map );

// Проверка отображения на содержание указанного ключа

bool IntegerMapHasKey ( const IntegerMap & _map, int _key );

// Поиск значения по указанному ключу в отображении

int IntegerMapFind ( const IntegerMap & _map, int _key );

// Вставка пары ключ-значение в отображение

void IntegerMapInsertKey ( IntegerMap & _map, int _key, int _value );

// Удаление элемента с указанным ключом из отображения

void IntegerMapRemoveKey ( IntegerMap & _map, int _key );

#endif //  _INTEGER_MAP_HPP_

Можно представить простую тестовую программу, использующую отображения данного типа:

#include "integer_map.hpp"

#include <cassert>

int main ()

{

   // Создаем объект-отображение

   IntegerMap * pMap = IntegerMapCreate();

   // Вставляем в отображение 3 пары ключ-значение

   IntegerMapInsertKey( * pMap, 10, 100 );

   IntegerMapInsertKey( * pMap, 20, 200 );

   IntegerMapInsertKey( * pMap, 30, 300 );

   // Проверяем корректность поиска в отображении

   assert( IntegerMapFind( * pMap, 20 ) == 200 );

   // Удаляем элемент по одному из ключей

   IntegerMapRemoveKey( * pMap, 30 );

   // Убеждаемся, что такого элемента больше нет в отображении

   assert( ! IntegerMapHasKey( * pMap, 30 ) );

   // Уничтожаем объект-отображение

   IntegerMapDestroy( pMap );

}

Реализация отображений при помощи векторов

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

struct IntegerPairVector

{

   int m_nUsed;

   int m_nAllocated;

   struct Cell

   {

       int m_key;

       int m_value;

   };

   Cell * m_pData;

};

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

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

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

Ниже представлена реализация отображения, основанная на предложенной идее:

#include "integer_map.hpp"

#include "integer_vector.hpp"

#include <cassert>

// Структура для отображения из двух отдельных векторов для ключей и значений

struct IntegerMap

{

   IntegerVector m_keys;

   IntegerVector m_values;

};

// Создание объекта-отображения

IntegerMap * IntegerMapCreate ()

{

   // Создаем объект-отображение в динамической памяти

   IntegerMap * pMap = new IntegerMap;

   // Инициализируем оба внутренних вектора

   IntegerVectorInit( pMap->m_keys );

   IntegerVectorInit( pMap->m_values );

   // Возвращаем объект во внешний код

   return pMap;

}

// Уничтожение объекта-отображения

void IntegerMapDestroy ( IntegerMap * _pMap )

{

   // Освобождаем ресурсы внутренних векторов

   IntegerVectorDestroy( _pMap->m_keys );

   IntegerVectorDestroy( _pMap->m_values );

   // Удаляем сам объект-отображение

   delete _pMap;

}

// Очистка отображения

void IntegerMapClear ( IntegerMap & _map )

{

   // Очищаем оба внутренних вектора

   IntegerVectorClear( _map.m_keys );

   IntegerVectorClear( _map.m_values );

}

// Проверка отображения на пустоту

bool IntegerMapIsEmpty ( const IntegerMap & _map )

{

   // Отображение пусто, если пусты внутренние векторы.

   // Поскольку векторы модифицируются всегда одновременно,

   // достаточно проверить один из векторов на пустоту, например, вектор ключей

   return IntegerVectorIsEmpty( _map.m_keys );

}

// Вспомогательная функция для поиска позиции ключа в отображении

int IntegerMapFindKeyPosition ( const IntegerMap & _map, int _key )

{

   // Ищем позицию ключа в векторе ключей путем полного перебора

   for ( int i = 0; i < _map.m_keys.m_nUsed; i++ )

       if ( _map.m_keys.m_pData[ i ] == _key )

           // Позиция найдена

           return i;

   // Позиция не найдена

   return -1;

}

// Проверка отображения на содержание указанного ключа

bool IntegerMapHasKey ( const IntegerMap & _map, int _key )

{

   // Ключ есть в отображении, если поиск его позиции дает корректный ответ

   return IntegerMapFindKeyPosition( _map, _key ) != -1;

}

// Поиск значения по указанному ключу в отображении

int IntegerMapFind ( const IntegerMap & _map, int _key )

{

   // Ищем позицию ключа в векторе ключей

   int position = IntegerMapFindKeyPosition( _map, _key );

   // Ключ должен существовать!

   assert( position != -1 );

   // Используем найденную позицию для извлечения значения из вектора значений

   return _map.m_values.m_pData[ position ];

}

// Вставка пары ключ-значение в отображение

void IntegerMapInsertKey ( IntegerMap & _map, int _key, int _value )

{

   // Ищем позицию ключа в векторе ключей. Возможно такой ключ уже существует

   int position = IntegerMapFindKeyPosition( _map, _key );

   // Если такого ключа нет, добавляем ключ и значение в соответствующие вектора

   if ( position == - 1 )

   {

       IntegerVectorPushBack( _map.m_keys, _key );

       IntegerVectorPushBack( _map.m_values, _value );

   }

   else

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

      _map.m_values.m_pData[ position ] = _value;

}

// Удаление элемента с указанным ключом из отображения

void IntegerMapRemoveKey ( IntegerMap & _map, int _key )

{

   // Ищем позицию ключа в векторе ключей

   int position = IntegerMapFindKeyPosition( _map, _key );

   // Ключ должен существовать!

   assert( position != -1 );

   // Одновременно удаляем ключ и значение из векторов в найденной позиции

   IntegerVectorDeleteAt( _map.m_keys, position );

   IntegerVectorDeleteAt( _map.m_values, position );

}

Простейшая реализация отображений при помощи массивов

Если ключи являются целыми числами от 0 до небольшого N, при этом корректными являются все ключи в диапазоне, можно реализовать отображения при помощи простейших массивов. При этом, ключи будут использоваться как индексы массива, а элементы будут храниться в ячейках. Если для какого-то из ключей значение не определено, то требуется хранить в соответствующей ячейке некоторое значение, означающее  “неопределенность” (например, нулевой указатель), разумеется, если такое возможно для рассматриваемого типа хранимых элементов.

Допустим, имеется набор данных об избирательных округах. В Украине насчитывается 225 избирательных округов, каждый из которых имеет уникальный номер. Может потребоваться найти информацию о конкретном округе, зная его номер. В качестве ключей здесь выступают номера округов, а в качестве значений - описывающие структуры. Такой случай подходит под реализацию при помощи массивов, поскольку номера могут быть естественно использованы как индексы массивов. Кроме того, будут задействованы все номера в интервале от 1 до 225 включительно.

Пусть имеется структура, описывающая информацию о конкретном округе:

struct VotingDistrict

{

   short m_districtNumber;

   int m_votersCount;

   char * m_locationDescription;

};

А информация обо всех округах хранится в виде массива указателей на объекты для каждого округа:

const int TOTAL_DISTRICTS_COUNT = 255;

VotingDistrict * g_districts[ TOTAL_DISTRICTS_COUNT ] ;

Чтобы заполнить данные о конкретном округе, следует обратиться к соответствующей ячейке массива по номеру округа (со смещением, т.к. массивы индексируются начиная с 0, а округа нумеруются с единицы):

void addDataAboutDistrict (

       short _districtNumber

   ,   int _votersCount

   ,   const char * _locationDescription

)

{

   assert( _districtNumber > 0 && _districtNumber <= TOTAL_DISTRICTS_COUNT );

   int districtIndex = _districtNumber - 1 ;    assert( ! g_districts[ districtIndex ] );

   VotingDistrict * pDistrict = new VotingDistrict;

   pDistrict->m_districtNumber = _districtNumber;

   pDistrict->m_votersCount    = _votersCount;

   size_t descriptionLength = strlen( _locationDescription );

   pDistrict->m_locationDescription = new char[ _descriptionLength + 1 );

   strcpy( pDistrict->m_locationDescription, _locationDescription );

   g_districts[ districtIndex ] = pDistrict;

}

Теперь, чтобы получить информацию об интересующем округе достаточно обратиться к массиву, использовав номер округа как индекс:

            

short districtNumber = 168;

VotingDistrict * pDistrict = g_Districts[ districtNumber - 1 ];

Если информации об округе еще не введено, массив будет содержать нулевой указатель. Если необходимо удалить информацию об округе, достаточно удалить объект в соответствующей ячейке массива и обнулить данную ячейку.

В такой структуре в качестве ключей иногда можно использовать перечисляемые типы, реализуемые в языке C как целые числа. Такие типы удобны для представления фиксированного набора значений, естественного для предметной области. Значения литералов-перечислений можно не указывать, по умолчанию, компилятор назначает значения 0, 1, 2, …, что также подходит для индексирования массивов. Например, по ключу-континенту, реализованному в виде перечисления:

     enum Continents

     {

             Europe            // 0

         ,   Asia              // 1

         ,   Africa            // 2

         ,   NorthAmerica      // 3

         ,   SouthAmerica      // 4

         ,   Australia         // 5

         ,   ContinentsTotal   // 6 - обозначает количество литералов в перечислении

     };

могут храниться данные о текущей численности населения:

     int g_populationByContinent[ ContinentsTotal ];

Соответственно, если появляются новые данные переписи населения Южной Америки, их можно внести в программу следующим образом:

     g_populationByContinent[ SouthAmerica ] = 400103516;

Получить суммарную численность населения Австралии и Африки можно так:

 int x = g_populationByContinent[ Australia ] + g_populationByContinent[ Africa ];

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

Может существовать ряд факторов, при которых отображение нельзя реализовать в виде подобного массива:

  • в качестве ключей выступают типы, отличные от целых чисел, например, строки ;

  • в области значений не существует значения “не определено”;

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

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

Интерфейс множеств

Ниже представлен заголовочный файл с интерфейсом объекта-множества целых чисел:

#ifndef _INTEGER_SET_HPP_

#define _INTEGER_SET_HPP_

// Форвардное объявление структуры-множества

struct IntegerSet;

// Создание объекта-множества

IntegerSet * IntegerSetCreate ();

// Уничтожение объекта-множества

void IntegerSetDestroy ( IntegerSet * _pSet );

// Очистка множества

void IntegerSetClear ( IntegerSet & _set );

// Проверка множества на пустоту

bool IntegerSetIsEmpty ( const IntegerSet & _set );

// Возврат количества элементов в множестве

int IntegerSetSize ( const IntegerSet & _set );

// Проверка множества на наличие указанного ключа

bool IntegerSetHasKey ( const IntegerSet & _set, int _key );

// Добавление указанного ключа в множество

void IntegerSetInsertKey ( IntegerSet & _set, int _key );

// Удаление указанного ключа из множества

void IntegerSetRemoveKey ( IntegerSet & _set, int _key );

// Объединение двух множеств - результат в третьем множестве

void IntegerSetUnite ( const IntegerSet & _set1,

                      const IntegerSet & _set2,

                      IntegerSet & _targetSet );

// Пересечение двух множеств - результат в третьем множестве

void IntegerSetIntersect ( const IntegerSet & _set1,

                          const IntegerSet & _set2,

                          IntegerSet & _targetSet );

// Разница между двумя множествами - результат в третьем множестве

void IntegerSetDifference ( const IntegerSet & _set1,

                           const IntegerSet & _set2,

                           IntegerSet & _targetSet );

// Симметрическая разница между двумя множествами - результат в третьем множестве

void IntegerSetSymmetricDifference ( const IntegerSet & _set1,

                                    const IntegerSet & _set2,

                                    IntegerSet & _targetSet );

#endif //  _INTEGER_SET_HPP_

Простая тестовая программа на основе таких множеств представлена ниже:

#include "integer_set.hpp"

#include <cassert>

int main ()

{

   // Создаем первое множество и помещаем в него ключи 10 и 20

   IntegerSet * pSet1 = IntegerSetCreate();

   IntegerSetInsertKey( * pSet1, 10 );

   IntegerSetInsertKey( * pSet1, 20 );

   // Создаем второе множество и помещаем в него ключи 20 и 30

   IntegerSet * pSet2 = IntegerSetCreate();

   IntegerSetInsertKey( * pSet2, 20 );

   IntegerSetInsertKey( * pSet2, 30 );

   // Создаем пересечение множеств - ожидаем наличия 1 элемента 20

   IntegerSet * pSetI = IntegerSetCreate();

   IntegerSetIntersect( * pSet1, * pSet2, * pSetI );

   assert( IntegerSetSize( * pSetI ) == 1 );

   assert( IntegerSetHasKey( * pSetI, 20 ) );

   // Создаем объединение множеств - ожидаем наличия 3 элементов - 10 20 30

   IntegerSet * pSetU = IntegerSetCreate();

   IntegerSetUnite( * pSet1, * pSet2, * pSetU );

   assert( IntegerSetSize( * pSetU ) == 3 );

   assert( IntegerSetHasKey( * pSetU, 10 ) );

   assert( IntegerSetHasKey( * pSetU, 20 ) );

   assert( IntegerSetHasKey( * pSetU, 30 ) );

   // Создаем разность множеств - ожидаем наличия 1 элементов - 10

   IntegerSet * pSetD = IntegerSetCreate();

   IntegerSetDifference( * pSet1, * pSet2, * pSetD );

   assert( IntegerSetSize( * pSetD ) == 1 );

   // Уничтожаем все созданные множества

   IntegerSetDestroy( pSet1 );

   IntegerSetDestroy( pSet2 );

   IntegerSetDestroy( pSetI );

   IntegerSetDestroy( pSetU );

   IntegerSetDestroy( pSetD );

}

Реализация множеств при помощи последовательностей

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

В основе реализации множества, приведенной ниже, лежит введенная ранее реализация односвязных списков:

#include "integer_set.hpp"

#include "integer_list.hpp"

#include <cassert>

// Структура-множество

struct IntegerSet

{

   // Внутренний список с данными

   IntegerList m_data;

};

// Создание объекта-множества

IntegerSet * IntegerSetCreate ()

{

   // Создаем объект-множество в динамической памяти

   IntegerSet * pSet = new IntegerSet;

   // Инициализируем внутренний список

   IntegerListInit( pSet->m_data );

   // Возвращаем объект во внешний код

   return pSet;

}

// Уничтожение объекта-множества

void IntegerSetDestroy ( IntegerSet * _pSet )

{

   // Освобождаем ресурсы внутреннего списка

   IntegerListDestroy( _pSet->m_data );

   // Уничтожаем сам объект-множество

   delete _pSet;

}

// Очистка множества

void IntegerSetClear ( IntegerSet & _set )

{

   // Очищаем внутренний список

   IntegerListClear( _set.m_data );

}

// Проверка множества на пустоту

bool IntegerSetIsEmpty ( const IntegerSet & _set )

{

   // Множество пусто, когда пуст внутренний список

   return IntegerListIsEmpty( _set.m_data );

}

// Возврат количества элементов в множестве

int IntegerSetSize ( const IntegerSet & _set )

{

   // Равно размеру внутреннего списка

   return IntegerListSize( _set.m_data );

}

// Определение принадлежности указанного ключа множеству

bool IntegerSetHasKey ( const IntegerSet & _set, int _key )

{

   // Осуществляем поиск ключа с начала списка методом полного перебора

   IntegerList::Node * pNode = _set.m_data.m_pFirst;

   while ( pNode )

   {

       if ( pNode->m_value == _key )

           // Ключ найден

           return true;

       pNode = pNode->m_pNext;

   }

   // Ключ не найден

   return false;

}

// Вставка ключа во множество

void IntegerSetInsertKey ( IntegerSet & _set, int _key )

{

   // Если ключа еще нет во внутреннем списке, его следует вставить в конец списка

   if ( ! IntegerSetHasKey( _set, _key ) )

       IntegerListPushBack( _set.m_data, _key );

}

// Удаление указанного ключа из множества

void IntegerSetRemoveKey ( IntegerSet & _set, int _key )

{

   // Ищем существующий узел с таким ключом

   IntegerList::Node * pNode = _set.m_data.m_pFirst;

   while ( pNode )

   {

       if ( pNode->m_value == _key )

       {

           // Удаляем найденный узел и завершаем процедуру

           IntegerListDeleteNode( _set.m_data, pNode );

           return;

       }

       pNode = pNode->m_pNext;

   }

   // Ошибка - ключа не существует в данном множестве!

   assert( !"Key is unavailble!" );

}

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

void IntegerSetInsertAllKeys ( const IntegerSet & _sourceSet, IntegerSet & _targetSet )

{

   // Перебираем все элементы исходного множества

   IntegerList::Node * pNode = _sourceSet.m_data.m_pFirst;

   while ( pNode )

   {

       // Вставляем очередной ключ в целевое множество и движемся далее

       IntegerSetInsertKey( _targetSet, pNode->m_value );

       pNode = pNode->m_pNext;

   }

}

// Объединение двух множеств - результат в третьем множестве

void IntegerSetUnite ( const IntegerSet & _set1,

                      const IntegerSet & _set2,

                      IntegerSet & _targetSet )

{

   // Очищаем целевое множество

   IntegerSetClear( _targetSet );

   // Копируем все элементы первого множества

   IntegerSetInsertAllKeys( _set1, _targetSet );

   // Копируем все элементы второго множества.

   // Функция вставки ключа гарантирует,

   // что повторяющиеся значения будут игнорироваться

   IntegerSetInsertAllKeys( _set2, _targetSet );

}

// Пересечение двух множеств - результат в третьем множестве

void IntegerSetIntersect ( const IntegerSet & _set1,

                          const IntegerSet & _set2,

                          IntegerSet & _targetSet )

{

   // Очищаем целевое множество

   IntegerSetClear( _targetSet );

   // Перебираем все элементы первого множества

   IntegerList::Node * pNode = _set1.m_data.m_pFirst;

   while ( pNode )

   {

       // Если выбранный элемент первого множества существует во втором,

       // то вставляем его в целевое множество

       if ( IntegerSetHasKey( _set2, pNode->m_value ) )

           IntegerSetInsertKey( _targetSet, pNode->m_value );

       pNode = pNode->m_pNext;

   }

}

// Разность двух множеств - результат в третьем множестве

void IntegerSetDifference ( const IntegerSet & _set1,

                           const IntegerSet & _set2,

                           IntegerSet & _targetSet )

{

   // Очищаем целевое множество

   IntegerSetClear( _targetSet );

   // Перебираем все элементы первого множества

   IntegerList::Node * pNode = _set1.m_data.m_pFirst;

   while ( pNode )

   {

       // Если выбранный элемент первого множества НЕ существует во втором,

       // то вставляем его в целевое множество

       if ( ! IntegerSetHasKey( _set2, pNode->m_value ) )

           IntegerSetInsertKey( _targetSet, pNode->m_value );

       pNode = pNode->m_pNext;

   }

}

// Симметрическая разность двух множеств - результат в третьем множестве

void IntegerSetSymmetricDifference ( const IntegerSet & _set1,

                                    const IntegerSet & _set2,

                                    IntegerSet & _targetSet )

{

   // Сохраняем разницу между первым и вторым во временном множестве

   IntegerSet * pTemp1 = IntegerSetCreate();

   IntegerSetDifference( _set1, _set2, * pTemp1 );

   // Сохраняем разницу между вторым и первым в другом временном множестве

   IntegerSet * pTemp2 = IntegerSetCreate();

   IntegerSetDifference( _set2, _set1, * pTemp2 );

   // Объединяем результирующие множества

   IntegerSetUnion( * pTemp1, * pTemp2, _targetSet );

   // Освобождаем ресурсы временных множеств

   IntegerSetDestroy( pTemp1 );

   IntegerSetDestroy( pTemp2 );

}

Множества на основе отсортированных последовательностей

Изложенную выше реализацию теоретико-множественных операций (объединение. пересечение, разность) нельзя назвать блестящей с точки зрения производительности. Для каждого ключа одного множества происходит поиск путем полного перебора элементов в другом множестве. Если предположить, что количество элементов в первом и втором множестве приблизительно равны между собой и имеют значение N, то реализация таких операций потребует в худшем случае N2 сравнений ключей. Если множества содержат большое количество элементов, время выполнения операций основанных на полном переборе, может стать неприемлемо большим.

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

void IntegerSetInsertKey ( IntegerSet & _set, int _key )

{

   // Если список пока пуст, вставить новый ключ в список

   if ( IntegerListIsEmpty( _set.m_data ) )

       IntegerListPushBack( _set.m_data, _key );

   else

   {

       // Если новый ключ меньше первого узла, вставить новый ключ в начало списка

       IntegerList::Node * pNode = _set.m_data.m_pFirst;

       if ( _key < pNode->m_value )

       {

           IntegerListPushFront( _set.m_data, _key );

           return;

       }

       // Ищем узел, после которого нужно вставить новый ключ, не нарушив порядок

       while ( pNode )

       {

           // Игнорируем ключи, которые уже есть в множестве

           if ( pNode->m_value == _key )

               return;

           // Если текущий узел последний в списке,            // новый элемент вставляется в конец списка

           else if ( ! pNode->m_pNext  )

           {

               IntegerListPushBack( _set.m_data, _key );

               return;

           }

           // Если ключ в следующем узле больше нового ключа,

           // нужно вставить новый ключ между текущим и следующим

           else if ( pNode->m_pNext->m_value > _key )

           {

               IntegerListInsertAfter( _set.m_data, pNode, _key );

               return;

           }

           // Движемся далее

           else

               pNode = pNode->m_pNext;

       }

       // Если ошибок нет, мы никогда не дойдем до этой точки

       assert( ! "We should never get here" );

   }

}

Сформировав таким образом упорядоченный во возрастанию список ключей, можно улучшить производительность поиска при переборе узлов. В частности, если искомый ключ меньше ключа в очередном обрабатываемом узле списка, то продолжать поиск не имеет смысла, т.к. в упорядоченном списке такого ключа уже точно не будет. Предположим, имеется множество из значений 10, 20, 30, 40, 50. Необходимо установить входит ли число 27 в состав множества. Значение 27 больше значений 10 и 20, однако меньше 30. Поскольку поиск доходит до большего значения 30, а значение 27 не найдено, дальнейший поиск не имеет смысла:

Улучшим процедуру поиска с учетом изложенного соображения:

bool IntegerSetHasKey ( const IntegerSet & _set, int _key )

{

   IntegerList::Node * pNode = _set.m_data.m_pFirst;

   while ( pNode )

   {

       if ( pNode->m_value == _key )

           return true;

       else if ( pNode->m_value > _key )

           return false;

       pNode = pNode->m_pNext;

   }

   return false;

}

Аналогичным образом можно усовершенствовать операцию удаления ключа:

void IntegerSetDeleteKey ( IntegerSet & _set, int _key )

{

   IntegerList::Node * pNode = _set.m_data.m_pFirst;

   while ( pNode )

   {

       if ( pNode->m_value == _key )

       {

           IntegerListDeleteNode( _set.m_data, pNode );

           return;

       }

       else if ( pNode->m_value > _key )

           break;

       pNode = pNode->m_pNext;

   }

   assert( !"Key is unavailble!" );

}

Но наибольший эффект от упорядоченности можно получить именно при реализации теоретико-множественных операций. Например, алгоритм пересечения может быть реализован одновременным продвижением по внутренним спискам двух множеств со сравнением ключей в текущих узлах. Если находятся узлы с равными ключами, то такой ключ следует вставить в результирующее множество, в противном случае в зависимости от соотношения ключей следует перейти к следующему узлу в одном из списков. Предположим имеется два множества - с ключами { 10, 20 } и { 20, 30 } соответственно:

Начинаем процедуру сравнения узлов в двух списках с первых позиций. 10 < 20, соответственно, необходимо выбрать следующий узел в первом множестве:

Сравнивая следующие элементы обнаруживаем, что в двух списках имеется значение 20. Заносим его в результирующее множество. Далее, передвигаемся к следующим узлам в обоих списках:

После этого действия первый список заканчивается, а значит других пересечений, кроме элемента {20} у данных множеств быть не может. Этим процедура пересечения и завершается.

Более точно этот алгоритм можно представить в виде следующего программного кода:

void IntegerSetIntersect ( const IntegerSet & _set1,

                          const IntegerSet & _set2,

                          IntegerSet & _targetSet )

{

   // Очищаем целевое множество

   IntegerSetClear( _targetSet );

   // Начинаем анализ с первых позиций в обоих списках одновременно

   IntegerList::Node * pNode1 = _set1.m_data.m_pFirst;

   IntegerList::Node * pNode2 = _set2.m_data.m_pFirst;

   // Пересечение возможно, пока остаются узлы для рассмотрения в обоих списках

   while ( pNode1 && pNode2 )

   {

       // Если ключи в двух списках равны, этот ключ пересекается,

       // и заносится в результирующее множество

       if ( pNode1->m_value == pNode2->m_value )

           IntegerListPushBack( _targetSet.m_data, pNode1->m_value );

       else if ( pNode1->m_value < pNode2->m_value )

           pNode1 = pNode1->m_pNext;

       else

           pNode2 = pNode2->m_pNext;

   }

}

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

Аналогично можно представить реализации объединения и разности множеств:

void IntegerSetUnite ( const IntegerSet & _set1,

                      const IntegerSet & _set2,

                      IntegerSet & _targetSet )

{

   // Очищаем целевое множество

   IntegerSetClear( _targetSet );

   // Начинаем анализ с первых позиций в обоих списках одновременно

   IntegerList::Node * pNode1 = _set1.m_data.m_pFirst;

   IntegerList::Node * pNode2 = _set2.m_data.m_pFirst;

   // Объединение возможно, пока остается хотя бы один узел в двух множествах

   while ( pNode1 || pNode2 )

   {

       // Имеются узлы для рассмотрения в обоих множествах

       if ( pNode1 && pNode2 )

       {

           // Если ключ в узле первого списка меньше ключа в узле второго списка,

           // следует вставить ключ из первого множества

           if ( pNode1->m_value < pNode2->m_value )

           {

               IntegerListPushBack( _targetSet.m_data, pNode1->m_value );

               pNode1 = pNode1->m_pNext;

           }

           // Если ключ в узле второго списка меньше ключа в узле первого списка,

           // следует вставить ключ из второго множества

           else if ( pNode1->m_value > pNode2->m_value )

           {

               IntegerListPushBack( _targetSet.m_data, pNode2->m_value );

               pNode2 = pNode2->m_pNext;

           }

           // Иначе - ключи в двух списках одинаковые. Следует вставить только 1 копию

           else

           {

               IntegerListPushBack( _targetSet.m_data, pNode1->m_value );

               pNode1 = pNode1->m_pNext;

               pNode2 = pNode2->m_pNext;

           }

       }

       // Если элементов во втором множестве не осталось, копируем первое до конца

       else if ( pNode1 )

       {

           IntegerListPushBack( _targetSet.m_data, pNode1->m_value );

           pNode1 = pNode1->m_pNext;

       }

       // Если же не осталось элементов в первом множестве, копируем второе до конца

       else

       {

           IntegerListPushBack( _targetSet.m_data, pNode2->m_value );

           pNode2 = pNode2->m_pNext;

       }

   }

}

void IntegerSetDifference ( const IntegerSet & _set1,

                           const IntegerSet & _set2,

                           IntegerSet & _targetSet )

{

   // Очищаем целевое множество

   IntegerSetClear( _targetSet );

   // Начинаем анализ с первых позиций в обоих списках одновременно

   IntegerList::Node * pNode1 = _set1.m_data.m_pFirst;

   IntegerList::Node * pNode2 = _set1.m_data.m_pFirst;

   // Разность возможна, пока остается хотя бы один узел в первом множестве

   while ( pNode1 )

   {

       // Если еще есть элементы во втором множестве, необходимо сравнивать ключи

       if ( pNode2 )

       {

           // Нельзя вставлять ключи, которые есть в обоих множествах

           if ( pNode1->m_value == pNode2->m_value )

           {

               pNode1 = pNode1->m_pNext;

               pNode2 = pNode2->m_pNext;

           }

           // Если найден ключ в первом множестве, меньший ключу из второго,

           // это означает его уникальность, следует внести ключ в результаты

           else if ( pNode1->m_value < pNode2->m_value )

           {

               IntegerListPushBack( _targetSet.m_data, pNode1->m_value );

               pNode1 = pNode1->m_pNext;

           }

           // В противном случае нужно перейти к следующему элементу во втором м-ве

           else

               pNode2 = pNode2->m_pNext;

       }

       else

       {

           // Если второе множество закончилось, копируем элементы первого до конца

           IntegerListPushBack( _targetSet.m_data, pNode1->m_value );

           pNode1 = pNode1->m_pNext;

       }

   }

}

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

Реализация множеств при помощи характеристических векторов

Предположим, в академической группе числится не более 32 студентов и их состав не меняется в течение семестра (вполне допустимое для типичного случая ограничение). Смоделировать множество студентов, сдавших тот или иной зачет, можно более оптимальным образом, воспользовавшись простой структурой информации. Для хранения факта сдачи конкретным студентом зачета достаточно 1 бита - сдал или не сдал. Представляется возможным компактно хранить данную информацию внутри переменной целого типа, воспользовавшись побитовыми операциями. Такую структуру данных принято называть характеристическим вектором. При этом, каждому студенту будет соответствовать собственный бит в числе:

   unsigned int g_studentPassData;

Допустим студент под номером X в списке сдал зачет, запишем данную информацию в множество:

   // битовая маска, содержащая 1 в бите X-1, остальные биты 0

   g_studentPassData |= 1 << ( X - 1 );  

Если требуется узнать, сдал ли зачет студент под номером Y, также применяем битовые маски

   if ( g_studentPassData & ( 1 << ( Y - 1 ) ) )

       ….

Предположим, выявлен плагиат, и преподаватель аннулировал сдачу зачета студентом Z:

   g_studentPassData &= ~( 1 << ( Z - 1 ) );

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

   g_studentPassData = 0;

Представляется возможным реализовать подобную идею для множеств, содержащих и более 32 элементов. Во-первых, существуют 64-битные типы (например, __int64 в Visual C++ или long long в GCC). Во-вторых, можно получить множество и с большим максимальным количеством элементов, если реализовать собственный тип для больших чисел.

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

Пусть имеется информация о сдаче группой двух разных зачетов:

   unsigned int g_studentPassData1;

   unsigned int g_studentPassData2;

Множество студентов, получивших оба зачета (INTERSECT), можно получить так:

  unsigned int g_studentsWith2Passes = g_studentPassData1 & g_studentPassData2;

Множество студентов, сдавших хотя бы один зачет (UNION), можно получить так:

  unsigned int g_studentsWithAtLeast1Pass = g_studentPassData1 | g_studentPassData2;

Множество студентов, сдавших первый зачет, но не сдавших второй зачет (DIFFERENCE):

  unsigned int g_studentsWithPass1ButWithoutPass2 =

      g_studentPassData1 & ~( g_studentPassData2 );

Множество студентов, сдавших только один зачет - первый или второй (SYMMETRIC DIFFERENСE):

  unsigned int g_studentsWithExactly1Pass = g_studentPassData1 ^ g_studentPassData2;

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

Выводы

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

СТА: Лекция №5 - Вычислительная сложность алгоритмов

Версия 2.01, 15 ноября 2014г.

(С) 2012-2014, Зайченко Сергей Александрович, к.т.н, ХНУРЭ, доцент кафедры АПВТ

Понятие вычислительной сложности

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

Существует несколько факторов, влияющих на время, необходимое для выполнение программы:

  1. Объем входных данных.

  2. Уровень оптимизации кода компилятором.

  3. Производительность компьютерной системы.

  4. Вычислительная сложность алгоритма

Фактор №1 мало зависит от технических свойств системы, а определяется внешними требованиями. Если обрабатываемых данных поступает мало, практически любой алгоритм на любом компьютере успешно справится с поставленной задачей. Если же данных много - задача существенно осложняется.

Фактор №2 имеет медленную тенденцию к улучшению, средства автоматизации постепенно совершенствуются с годами. Переход на новую версию компилятора либо на совсем другой компилятор, реализующий более удачную стратегию низкоуровневой оптимизации программного кода, может улучшить производительность программ в 1.5-5 раз.

Проблемы с фактором №3 могут быть решены приобретением более дорогого и совершенного компьютера. Аппаратные технологии постоянно развиваются. За счет перехода на более производительный процессор, более быструю оперативную память, другую архитектуру кэш-памяти и других подобных аппаратных улучшений, можно повысить производительность элементарных вычислительных инструкций в 10-100 раз. Следует отметить, что в конкретный момент времени всегда имеется конечный предел производительности компьютеров, доступных на рынке, кроме того, наиболее мощные компьютеры будут иметь самую высокую стоимость.

Тщательный выбор алгоритма, структур данных и, соответственно, решение проблемы с фактором №4, способны ускорить время выполнения программ в тысячи и миллионы раз. Программа, основанная на эффективном алгоритме, при большом объеме входных данных будет производительнее даже на устаревшем компьютере, будучи скомпилированной простейшим компилятором, по сравнению с неэффективным алгоритмом, запускаемым при идеальном состоянии остальных факторов.

Учитывая степень влияния свойств алгоритмов на итоговое быстродействие программ, очевидна необходимость в понимании характеристик их быстродействия. Простейшим способом достижения такого понимания является измерение. Одним из базовых путей измерения времени выполнения программ (или их фрагментом) является использование функции clock() из стандартной библиотеки языка C. Данная функция возвращает количество некоторых условных единиц процессорного времени, прошедших момента запуска процесса. Чаще всего в качестве такой единицы используются миллисекунды. Абсолютное значение, возвращаемое этой функцией, говорит мало о чем, для измерения времени выполнения нужно использовать разницу между двумя значениями. Для перевода измеренного интервала в секунды, разницу между двумя значениями, возвращенными функцией clock, следует поделить на константу CLOCKS_PER_SEC:

#include <ctime>

#include <iostream>

int main ()

{

   // Фиксируем начальный момент времени

   clock_t clock1 = clock();

   // Некоторый программный код, решающий интересующую задачу

   // …

   // Фиксируем конечный момент времени

   clock_t clock2 = clock();

   // Пересчитываем зафиксированный временной интервал в секундах

   double timeInSec = ( ( double ) ( clock2 - clock1 ) / ( double ) CLOCKS_PER_SEC );

   std::cout << “Time: “ << timeInSec << “ sec” << std::endl;

}

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

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

Аналитический подход должен исключать из рассмотрения факторы, не связанные с сутью реализуемых алгоритмов (свойства компьютерной системы и особенности работы компилятора). Соответственно рассуждение строится на предположении, что все элементарные выражения и инструкции выполняются за некоторое условное одинаковое единичное время. Целью анализа свойств алгоритма является оценка ВЫЧИСЛИТЕЛЬНОЙ СЛОЖНОСТИ - зависимости между количеством элементарных инструкций, выполняющихся алгоритмом на некотором идеализированном компьютере, от объема поданных входных данных.

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

void bubbleSort ( int* _pData, int _N )

{

   for ( int i = 0; i < _N - 1; i++ )           // (1)

       for ( int j = _N - 1; j > i; j-- )       // (2)

           if ( _pData[ j - 1 ] > _pData[ j ] ) // (3)

           {

               int temp = _pData[ j - 1 ];      // (4)

               _pData[ j - 1 ] = _pData[ j ];   // (5)

               _pData[ j ] = temp;              // (6)

           }

}

Время выполнения алгоритма зависит от количества выполняемых элементарных инструкций, что можно оценить аналитически. Внешний цикл (1-6) выполняется ровно N - 1 раз. Внутренний цикл (2-6) выполняется N - 1 раз на первой итерации внешнего цикла, затем N - 2 на второй итерации, и количество его запусков уменьшается на единицу с каждой новой итерацией. При анализе условия (3-6) в худшем случае, когда массив изначально отсортирован в обратном порядке, результат проверки (3) будет справедливым каждый раз, что запустит последовательность элементарных инструкций (4-6). Таким образом, общее время выполнения пузырьковой сортировки определяется следующей зависимостью:

T(N)=( N-1 )T3-6+(N-2)T3-6+(N-3)T3-6+... +T3-6

Раскрыв скобки и упростив данный ряд, получим:

T(N)=T3-6(( N-1 )+(N-2)+(N-3) + ... +1)=T3-6 (N-1)1+(N-1)2=T3-6 0,5( N2-N )

Учитывая, что условие (3-6) состоит только из элементарных инструкций, время выполнения которых можно считать условной единицей, преобразуем соотношение в следующее:

T(N)=с (N2-N)

где с - некоторый константный множитель.

В лучшем случае, когда исходный массив уже отсортирован в нужном порядке, характер зависимости принципиально не изменится. В такой удачной ситуации блок (4-6) не будет выполнен ни разу, и время будет определяться количеством сравнений при выполнении условия (3). Это заметно уменьшит значение константного множителя с, но не повлияет на форму зависимости.

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

int find ( const IntegerVector & _v, int _value )

{

   int p = 0;                             // 1

   while ( p < _v.m_nUsed )               // 2

   {                                      // 3

       if ( _v.m_pData[ p ] == _value )   // 4

           return p;                      // 5

       ++p;                               // 6

   }

   return -1;                             // 7

}

Строки (1) и (7) выполняются не более 1 раза, соответственно их можно исключить из рассмотрения. Худшим случаем для данного алгоритма является отсутствие искомого значения в последовательности. Цикл (2-6) в худшем случае выполнится N раз, где N - длина последовательности. Тело цикла состоит из элементарных инструкций, при чем условие (4) в худшем случае никогда не выполнится.  Лучший случай - это обнаружение искомого значения при первом же сравнении. В среднем функция будет выполнять N/2 итераций основного цикла.

T(N)=с N

Соответственно, вычислительная сложность данной функции характеризуется как линейная, что вытекает из количества итераций основного цикла. В связи с этим фактом, описанный алгоритм часто называют алгоритмом ЛИНЕЙНОГО ПОИСКА (linear search),

Асимптотическая оценка вычислительной сложности

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

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

limnT(N)=limn(с (N2-N))=с N2

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

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

T(N)cf(N)

При изучении свойств и сравнении алгоритмов можно отбросить константный множитель, поскольку при достаточно больших N именно порядок роста функции f(N) является определяющим фактором. Это легко объяснить на примере. Предположим имеется 2 альтернативных алгоритма, при этом первый имеет квадратичный порядок роста, а второй - описывается линейной функцией. Также допустим, что реализация первого алгоритма близка к оптимальной, а программа выполняется на современном компьютере. В то же время, реализация второго алгоритма далека от блестящей и выполняется на устаревшем компьютере. Такой дисбаланс внешних условий можно смоделировать при помощи разницы в константных множителях (пусть, c1=2, а c2=50).  При небольших N, например, при 5 данных, время выполнения первого алгоритма будет меньшим времени второго:

T1(N=5)= c1N2=225=50

T2(N=5)= c2N=505=250

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

T1(N=20)= c1N2=2400=800

T2(N=20)= c2N=5020=1000

T1(N=50)= c1N2=22500=5000

T2(N=50)= c2N=5050=2500

T1(N=100)= c1N2=210000=20000

T2(N=100)= c2N=50100=5000

Каковы бы ни были значения константных множителей, когда вычислительная сложность одного алгоритма лучше вычислительной сложности другого, всегда существует некоторый переломный объем входных данных N0, начиная с которого именно порядок роста времени выполнения алгоритма становится доминирующим фактором.

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

-оценка представляет собой сравнение с бесконечным множеством функций с одинаковым порядком роста g(N), отличающихся на константный множитель. Функция f(N) принадлежит множеству (g(N)), если имеются такие константы c1 и c2, которые при достаточно больших N сверху и снизу ограничивают функцию, описывающую скорость анализируемого алгоритма. Таким образом, выполняется следующее соотношение:

c1g(N)cf(N)c2g(N)

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

Среди типично встречающихся асимптотических оценок вычислительной сложности алгоритмов распространенными являются следующие функции:

  • линейная O(N)  (время пропорционально увеличению данных);

  • квадратичная O(N2);

  • полиномиальная сложность O(NM), в частности, кубическая O(N3);

  • экспоненциальная сложность O(2N);

  • факториальная сложность O(N!);

  • логаримфическая сложность O(log(N)) (подразумевают логарифм по основанию 2);

  • линейно-логарифмическая сложность O(N * log(N)) ;

  • константная вычислительная сложность O(1) (время не зависит от количества данных).

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

O(1)O(logN)O(N)O(NlogN)O(N2)O(NM)O(2N)O(N!)

Вычислительную сложность разрабатываемых алгоритмов следует максимально ограничивать, насколько это является возможным. Соотношение между данными оценками можно ощутить, представив количество выполненных инструкций на конкретных примерах, скажем при N=5, 10, 25, 100 и 500 (положим, что константные коэффициенты одинаковы для упрощения понимания). При таком объеме данных получим следующие показатели:

Оценка

N=5

N=10

N=25

N=100

N=500

O(1)

1

1

1

1

1

O(logN)

3

4

5

7

9

O(N)

5

10

25

100

500

O(NlogN)

15

40

125

700

4500

O(N2)

25

100

625

10 000

250 000

O(N3)

125

1 000

15 625

1 000 000

125 000 000

O(2N)

32

1 024

33 554 432

очень много

очень много

O(N!)

120

3 628 800

очень много

очень много

очень много

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

Если программа относится к научно-исследовательским, предельно допустимой сложностью является полиномиальная, в частности, кубическая O(N3). Алгоритмы с более высокой вычислительной сложностью имеют применение только для малых значений N, в противном случае задачи не имеют компьютерного решения с достижимыми вычислительными затратами.

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

Поверхностная оценка вычислительной сложности

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

Ниже приведено несколько подсказок для поверхностной оценки вычислительной сложности “”на глаз” без строгого аналитического подхода.

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

  1. Вычислительная сложность последовательности инструкций равна максимальной сложности входящих в нее инструкций.

T1,2,..k(N)=max(T1(N),T2(N),...,Tk(N)), k2

  1. Если нет никаких специальных сведений о вероятности срабатывания условных переходов, то все возможные условные переходы, в том числе неявные (опущенные ветки else, default), следует считать равновероятными. Сложность каждого блока инструкций оценивается отдельно, а затем выбирается максимальная из них, что и становится результатом оценки для условной конструкции в целом.

  1. Если инструкция находится в теле цикла, количество итераций которого зависит от N, то вычислительная сложность инструкции умножается на линейную функцию.

  1. Вычислительная сложность двух циклов, зависящих от N, вложенных друг в друга, скорее всего описывается квадратичной функцией. Соответственно, вложенность из 3 уровней соответствует кубической сложности.

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

Многие алгоритмы являются рекурсивными, т.е. вызывают сами себя с другими аргументами. При этом, для выхода из рекурсии всегда должно существовать некоторое “условие выхода” - значения аргументов, при которых рекурсия разрывается и выполняется некоторое элементарное действие. Простейшим примером может послужить функция вычисления факториала:

int factorial ( int _x )

{

   if ( _x < 1 )

       return 0;

   else if ( _x == 1 )

       return 1;

   else

       return _x * factorial( _x - 1 );

}

Первые два разветвления основного условия являются элементарными инструкциями, их вычислительная сложность оценивается как O(1). Что же касается последней ветки, сложность описывается, так называемым, рекуррентным соотношением:

T(N)=T(N-1) +1, N>1

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

T(N)=T(N-1) +1= (T(N-2) +1)+1=((T(N-3)+1)+1)+1=N

T(N)=O(N)

Бинарный поиск

Еще одним из известных рекурсивных алгоритмов является БИНАРНЫЙ ПОИСК. Если организовать хранение данных таким образом, что пары ключ-значение хранятся в виде массива, а также изначально отсортированы по возрастанию ключей, представляется возможным существенно повысить быстродействие процедуры поиска элемента множества или отображения по сравнению с простейшим линейными поиском. Безусловно, сортировка данных занимает ощутимое время. Однако программист может столкнуться с ситуацией, когда необходимо обрабатывать уже отсортированные кем-то данные, получаемые, например, из внешнего источника - дискового файла или базы данных - в уже упорядоченном виде.

Пусть имеется заранее упорядоченное по алфавиту множество названий государств, входящих в Шенгенскую зону:

  const char * const g_SchengenCountries [] = {

          “Austria”

      ,   “Belgium”

      ,   “Czech Republic”

      ,   “Denmark”

      ,   “Estonia”

      ,   “Finland”

      ,   “France”

      ,   “Germany”

      ,   “Greece”

      ,   “Hungary”

      ,   “Iceland”

      ,   “Italy”

      ,   “Latvia”

      ,   “Liechtenstein”

      ,   “Lithuania”

      ,   “Luxembourg”

      ,   “Malta”

      ,   “Netherlands”

      ,   “Norway”

      ,   “Poland”

      ,   “Portugal”

      ,   “Slovakia”

      ,   “Slovenia”

      ,   “Spain”

      ,   “Sweden”

      ,   “Switzerland”

  };

Требуется написать функцию, определяющую входит ли интересующая страна в Шенгенскую зону. На каждом шаге выбираем ключ в середине интервала и сравниваем его с искомым ключом. Если ключи равны, необходимое значение найдено. Если искомый ключ меньше, далее анализируем первую половину данных. Если больше - вторую.

    bool isSchengenCountry ( const char* _country )

  {

      int minIndex = 0;

      int maxIndex = sizeof( g_SchengenCountries ) / sizeof( const char * );

      

      while ( minIndex < maxIndex )

      {

          int midIndex = ( minIndex + maxIndex ) >> 1;

          int cmpResult = strcmp( _country, g_SchengenCountries[ midIndex ] );

          if ( ! cmpResult )

              return true;

          

          else if ( cmpResult < 0 )

              maxIndex = midIndex;

          else

              minIndex = midIndex + 1;

      }

      return false;

  }

Предположим, происходит вызов данной функции с аргументом “Finland”. Пошаговое выполнение алгоритма бинарного поиска выглядит следующим образом:

  1. На первом шаге интервал поиска составляет 26 стран от индекса 0 до индекса 25 включительно. Первое сравнение происходит со страной с индексом 13, а именно с “Liechtenstein”. По алфавиту искомая страна “Finland” идет раньше, чем “Liechtenstein”, соответственно поиск продолжается в первой половине массива.

  2. На втором шаге интервал поиска составляет 13 стран от индекса 0 до индекса 12 включительно. Следующее сравнение происходит со страной с индексом 6, т.е. “France”. Искомая страна “Finland” также раньше по алфавиту, и поиск переходит в первую четверть.

  3. На третьем шаге интервал поиска составляет 6 стран от индекса 0 до индекса 5 включительно. Значение, которое берется для сравнения - “Denmark” с индексом 3. На этот раз поиск будет продолжен во второй половине текущего интервала.

  4. На четвертом шаге интервал поиска сужается до 2 стран от индекса 4 до индекса 5 включительно. Сравнение происходит со строкой “Finland” по индексу 5, таким образом образуется итоговый утвердительный ответ.

Вычислительная сложность данной функции оценивается как O(log N), поскольку на каждом шаге алгоритма отсекается половина неправильных результатов. Более строго это можно вывести исходя из рекуррентного соотношения:

T(N)=T(N/2)+1

Соотношение имеет указанную форму, поскольку на каждом шаге алгоритма решается аналогичная задача на вдвое меньшем объеме данных. При этом на каждом шаге дополнительно выполняется некоторая элементарная вычислительная работа, в частности, сравнение искомого ключа и ключа в середине массива, а затем выбор следующего интервала в зависимости от результата сравнения. Раскрыть данное рекуррентное соотношение с целью получения оценки вычислительной сложности можно при помощи подстановки N=2M, где M - некоторое целое число, степенью двойки которого является N. Несмотря на то, что такая подстановка несправедлива в общем случае, т.к. N не обязан являться степенью двойки в каждой задаче, такое предположение можно заложить, представив, что исходный набор данных дополняется до ближайшей степени двойки сверху ненужными значениями, например, нулями. Осуществим задуманную подстановку и начнем раскрытие рекурсии поэтапно:

T(2M)=T(2M/2)+1=T(2M-1)+1=T(2M-2)+1+1=T(2M-3)+1+1+1=...=M

T(2M)=MT(N)=log2(N)

Из логарифмической вычислительной сложности вытекает, что в худшем случае поиск интересующей страны в составе стран Шенгенской зоны потребует не более 5 сравнений строк. Худшим случаем для данного алгоритма является поиск значения, отсутствующего в наборе. 5 сравнений - это значительно лучше, чем 26 сравнений в худшем случае при линейном поиске.

Сравнение простых алгоритмов сортировки

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

Схожей вычислительной сложностью обладают еще два распространенных алгоритма - сортировка вставками (insertion sort) и сортировка выбором (selection sort):

void insertionSort ( int* _pData, const int _N )

{

   for ( int i = 1; i < _N; i++ )

   {

       int j = i;

       while ( j && ( _pData[ j ] < _pData[ j - 1 ] ) )

       {

           int temp = _pData[ j - 1 ];           

           _pData[ j - 1 ] = _pData[ j ];

           _pData[ j ] = temp;

           --j;

       }

   }

}

void selectionSort ( int* _pData, const int _N )

{

   for ( int i = 0; i < _N - 1; i++ )

   {

       int lowIndex = i;

       for ( int j = i + 1; j < _N; j++ )

           if ( _pData[ j ] < _pData[ lowIndex ] )

               lowIndex = j;

       int temp = _pData[ lowIndex ];

       _pData[ lowIndex ] = _pData[ i ];

       _pData[ i ] = temp;

   }

}

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

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

Когда же на вход будет подан массив, отсортированный в обратном порядке, алгоритм сортировки вставками сделает по N22перестановок и сравнений, а алгоритм сортировки выбором сохранит свои свойства, осуществив N22сравнений и N-1 перестановку на любом входном наборе данных.

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

Ниже приведено сравнение замеров производительности трех рассмотренных алгоритмов сортировки для различных N в 3-х вариантах - случайные данные, отсортированные данные и данные, отсортированные в противоположном порядке. Разумеется, при выполнении на другом компьютере абсолютные значения времени могут существенно отличаться, однако пропорции между значениями должны сохраниться.

Время сортировки случайного набора данных (средний случай):

Количество данных

Пузырьковая

Вставками

Выбором

100 000

57.500s

33.781s

23.156

50 000

14.360s

8.406s

5.796s

20 000

2.296s

1.359s

0.921s

10 000

0.578s

0.343s

0.234s

5 000

0.141s

0.093s

0.062s

Время сортировки уже отсортированных данных (лучший случай):

Количество данных

Пузырьковая

Вставками

Выбором

100 000

23.156s

< 0.001s

23.140s

50 000

5.796s

< 0.001s

5.782s

20 000

0.922s

< 0.001s

0.938s

10 000

0.218s

< 0.001s

0.234s

5 000

0.063s

< 0.001s

0.063s

Время сортировки данных, отсортированных в обратном порядке (худший случай):

Количество данных

Пузырьковая

Вставками

Выбором

100 000

61.781

67.563s

24.157s

50 000

15.454s

16.906s

6.047s

20 000

2.469s

2.703s

0.953s

10 000

0.625s

0.672 s

0.235s

5 000

0.156s

0.157s

0.062s

Из полученных данных измерений можно сделать интересные выводы:

  • Метод сортировки выбором в среднем является наиболее эффективным, кроме того его время выполнения стабильно на любом наборе поданных входных данных. Стабильное время выполнения - это важная характеристика алгоритма, когда о характере подаваемых данных заранее ничего не известно.

  • Метод сортировки вставками существенно выигрывает у двух других при подаче уже отсортированного массива (фактически, время выполнения настолько малое, что его не удается замерить), однако заметно проигрывает сортировке выбором в худшем случае.

Сортировка слиянием

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

Разобьем данный массив на две равные половины:

А затем каждую из половин разобьем еще надвое:

И т.д., пока не дойдет до массивов, состоящих не более чем из двух элементов:

Отсортировать такие массивы из двух элементов не составляет никакого труда - достаточно сравнить 2 числа и переставить их местами, если это необходимо:

Далее следует объединить (“слить”) соседние массивы таким образом, чтобы получить один вдвое больший отсортированный массив вместо двух. Поскольку две объединяемые части к этому моменту уже отсортированы, слияние осуществляется выбором наименьшего из значений в двух массивах при последовательном проходе:

Слияние следует повторить на следующем уровне:

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

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

TSORT(N)=2TSORT(N/2)+TMERGE

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

TSORT(N)=2TSORT(N/2)+N

Аналогично процессу раскрытия, предложенному при разъяснении алгоритма бинарного поиска, предположим, что существует некоторое число M, такое что 2M=N. Тогда:

TSORT(2M)=2TSORT(2M-1)+2M

Разделив левую и правую часть уравнения на 2M, получим:

TSORT(2M)2M=2TSORT(2M-1)2M+1=TSORT(2M-1)2M-1+1=TSORT(2M-2)2M-2+2=...

Поэтапно раскрывая рекурсию шаг за шагом, получим следующее соотношение:

TSORT(2M)2M=MTSORT(2M)=2MM

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

TSORT(N)=Nlog2(N)

Если количество входных данных невелико, скажем, N<10, простейшие алгоритмы сортировки будут выполняться за меньшее время, чем алгоритм слияния, однако при возрастании N выигрыш слияния станет очевиден. Этот факт можно использовать для комбинирования алгоритмов. Если в результате разбиения исходного массива на одном из уровней получен массив с длиной до 10 элементов, можно применить другой алгоритм сортировки, например, сортировку выбором. Это позволит повысить производительность сортировки слиянием в целом.

Ниже представлена программная реализация данного подхода:

// Вспомогательная функция слияния двух отсортированных массивов в один

void mergeSorted ( const int * _pFirst,  int _firstSize,

                  const int * _pSecond, int _secondSize,

                  int * _pTarget )

{

   // Поддерживаем 3 текущих индекса:

   //  - индекс в целевом массиве (targetIndex)

   //  - индекс в первом массиве-операнде (firstIndex)

   //  - индекс во втором массиве-операнде (secondIndex)

   // Проходим по массивам-операндам и формируем целевой массив

   int firstIndex = 0, secondIndex = 0, targetIndex = 0;

   while ( firstIndex < _firstSize && secondIndex < _secondSize )

   {

       // Если число из первого массива не больше числа из второго - оно идет первым

       if ( _pFirst[ firstIndex ] <= _pSecond[ secondIndex ] )

       {

           _pTarget[ targetIndex ] = _pFirst[ firstIndex ];

           ++ firstIndex;

       }

       // Иначе берется число из второго массива

       else

       {

           _pTarget[ targetIndex ] = _pSecond[ secondIndex ];

           ++ secondIndex;

       }

       ++ targetIndex;

   }

   // Возможно второй массив был короче. Дописываем оставшиеся элементы 1-го в конец

   if ( firstIndex < _firstSize )

       memcpy( _pTarget + targetIndex,                _pFirst + firstIndex,                sizeof( int ) * ( _firstSize - firstIndex )

       );

   // Аналогично для случая, если первый массив был короче.

   else if ( secondIndex < _secondSize )

       memcpy( _pTarget + targetIndex,                _pSecond + secondIndex,                sizeof( int ) * ( _secondSize - secondIndex )

       );

}

// Основная функция сортировки слиянием

void mergeSort ( int * _pData, int _N )

{

   // Для малых N используем сортировку выбором

   if ( _N < 10 )

       selectionSort( _pData, _N );

   else

   {

       // Вычислияем размеры половин (могут отличаться на 1 при нечетном N)

       int halfSize = _N / 2;

       int otherHalfSize = _N - halfSize;

       // Формируем первую половину в дополнительном массиве N/2

       int * pFirstHalf = new int[ halfSize ];

       memcpy( pFirstHalf, _pData, sizeof( int ) * halfSize );

       // Формируем вторую половину в дополнительном массиве N/2

       int * pSecondHalf = new int[ otherHalfSize ];

       memcpy( pSecondHalf, _pData + halfSize, sizeof( int ) * otherHalfSize );

       // Сортируем половины рекурсивно

       mergeSort( pFirstHalf,  halfSize );

       mergeSort( pSecondHalf, otherHalfSize );

       // Осуществляем слияние отсортированных половин в единый массив

       mergeSorted( pFirstHalf, halfSize, pSecondHalf, otherHalfSize, _pData );

       // Освобождаем временные массивы

       delete[] pFirstHalf;

       delete[] pSecondHalf;

   }

}

Ниже приведены результаты измерения производительности сортировки данной реализации, подтверждающие существенное превосходство над простейшими методами:

Размер массива

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

Отсортированные данные

Данные в обратном порядке

500 000

0,235s

0,166s

0,172s

200 000

0,104s

0,073s

0,074s

100 000

0,050s

0,036s

0,036s

50 000

0,025s

0,017s

0,017s

20 000

0,012s

0,008s

0,008s

10 000

0,006s

0,004s

0,004s

Если отключить использование сортировки выбором для нижних уровней разбиения, а пользоваться идеей базового алгоритма, очевидно падение производительности, что подтверждает изначально выдвинутую гипотезу для малых N:

Размер массива

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

Отсортированные данные

Данные в обратном порядке

500 000

0,520s

0,452s

0,458s

200 000

0,247s

0,221s

0,217s

100 000

0,121s

0,111s

0,108s

50 000

0,064s

0,055s

0,053s

20 000

0,026s

0,019s

0,020s

10 000

0,014s

0,011s

0,010s

Недостатком алгоритма сортировки слиянием является дополнительный расход памяти для хранения временных массивов-половин. Преимуществом - стабильное время выполнения на любом наборе входных данных.

Быстрая сортировка

Еще одним алгоритмом, в среднем выполняющимся с вычислительной сложностью O(Nlog2(N)), является быстрая сортировка. По сравнению с сортировкой слиянием, преимуществом быстрой сортировки является отсутствие необходимости в значительном объеме дополнительной памяти для хранения временных массивов. Быстрая сортировка модифицирует исходный массив непосредственно (in-place).

Суть алгоритма сводится к следующему:

  1. Тем или иным образом выбирается некоторый опорный элемент (pivot) в исходном массиве. Например, в качестве опорного элемента может быть случайная позиция, либо серединная позиция.

  2. Все элементы, меньшие опорного, располагаются левее, а все элементы большие опорного - правее.

  3. Массив условно разбивается на 2 части по опорному элементу, которые сортируются рекурсивно тем же алгоритмом.

  4. Как и в сортировке слиянием, по достижению некоторого граничного количества элементов, например N < 10, вместо дальнейшей рекурсии имеет смысл использовать простейший алгоритм сортировки, такой как сортировка выбором.

Продемонстрируем работу данного алгоритма в графической форме, используя тот же самый массив, что и в примере сортировки слиянием. Для упрощения восприятия, не будем применять сортировку выбором при N < 10, а доведем разбиение до минимального количества элементов. В качестве опорного элемента в данном случае будем брать серединную позицию. Итак, для исходного массива из 16 элементов серединной позицией является 8 со значением 12.

Необходимо переупорядочить элементы массива таким образом, чтобы слева от выбранного опорного значения 12 находились все значения, меньшие 12, а правее - большие. Начнем двигаться одновременно слева и справа:

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

Переставим эти элементы местами:

Продолжим аналогичную процедуру. Следующим ближайшим значением слева, большим или равным опорному, является 17. А ближайшим большим или равным 12, является 5:

Поменяем эти значения местами:

Продолжим процедуру аналогичным образом, пока индексы не пересекутся между собой:

Переставим местами очередную пару:

Продолжим перестановки, и, наконец, достигнем ситуации, в которой индексы пересекутся:

В результате всех приведенных шагов, получены 2 части массива - элементы с индексами от 0 до 4 меньше опорного значения 12, а элементы с индексами от 5 до 15 - больше либо равны 12. Далее процесс следует продолжить рекурсивно на этих частях (при этом массивы нужно разделить лишь логически, копирования не потребуется):

Обработаем аналогично первую часть массива. Серединным элементом является 12:

Потребуются следующие перестановки:

Далее индексы пересекутся, и придется обрабатывать части 11, 5, 8, 0 и 12 отдельно друг от друга. С частью, состоящей из одного элемента 12, делать ничего не нужно. Далее приведено пошаговое выполнение над первой частью из 4 элементов с серединой 8:

Далее массив уже является отсортированным. Итак, возвращаемся на предыдущий уровень, и получим отсортированную часть массива:

Проведем аналогичные действия с оставшейся частью массива:

Далее от массива отколется элемент 14, и сортировка продолжится с серединным элементом 41:

Далее, разделяем массив на две части для рекурсивной обработки:

С правой частью задача сводится к элементарной:

 

В левой части потребуется большее число шагов:

Значение 17 “откалывается”, аналогичная ситуация произойдет со следующим значением 19:

Далее серединным значением является 28:

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

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

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

TSORT(N)=TPARTITION+2TSORT(N/2)

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

TSORT(N)=N+2TSORT(N/2)

Выше уже было доказано, что такое соотношение раскрывается как линейно-логарифмическое:

TSORT(N)=Nlog2(N)

Как это было наглядно продемонстрировано в примере, предположение о разбиении массива на 2 равные половины является чересчур оптимистичным. В худшем случае, от массива на каждом очередном шаге может “откалываться” лишь одно число, что существенно видоизменит рекуррентное соотношение:

TSORT(N)=N+1+TSORT(N-1)

Раскрывая рекурсию, получим следующий ряд:

TSORT(N)=(N+1)+(N-1+1)+TSORT(N-2)=2N+1+TSORT(N-2)=... =

=(N-1)N+1+1

Из чего, делаем вывод, что в самом худшем случае, вычислительная сложность быстрой сортировки стремится к квадратичной:

TSORT(N)=N2

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

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

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

Ниже представлена программная реализация описанного алгоритма, при чем при выборе опорного элемента используется случайная позиция от 0 до N - 1:

void quickSort ( int * _pData, int _N )

{

   // На малом количестве элементов используем алгоритм сортировки выбором

   if ( _N < 10 )

   {

       selectionSort( _pData, _N );

       return;

   }

   int leftIndex = -1, rightIndex = _N;

   // Случайным образом выбираем опорный элемент

   int pivotIndex = rand() % _N;

   int pivot = _pData[ pivotIndex ];

   // Цикл перераспределения данных вокруг опорного элемента

   while ( true )

   {

       // Двигаем индекс слева до позиции, равной или превышающей опорный элемент

       while ( leftIndex < _N && _pData[ ++ leftIndex ] < pivot );

       // Двигаем индекс справа в обратном направлении до меньшего опорному значения

       while ( rightIndex >= 0 && pivot < _pData[ --rightIndex ] )

           if ( rightIndex == leftIndex ) // Прерываемся при пересечении индексов

               break;

       // Перераспределение завершено

       if ( leftIndex >= rightIndex )

           break;

       // Меняем местами элементы, стоящие не на своих позициях

       int temp = _pData[ leftIndex ];

       _pData[ leftIndex ] = _pData[ rightIndex ];

       _pData[ rightIndex ] = temp;

   }

   // Особый случай - вытеснение опорного элемента в крайнее положение

   if ( leftIndex == 0 )

       leftIndex = 1;

   else if ( leftIndex == _N )

       leftIndex = _N - 1;

   // Рекурсивно сортируем две части массива отдельно

   quickSort( _pData, leftIndex );

   quickSort( _pData + leftIndex, _N - leftIndex );

}

Несмотря на возможную деградацию алгоритма быстрой сортировки до квадратичной вычислительной сложности, именно его используют на практике наиболее часто. В частности, средства сортировки в стандартной библиотеке C (функция qsort) и С++ (алгоритм std::sort) основаны именно на алгоритме быстрой сортировки. В среднем, быстрая сортировка опережает сортировку слиянием, т.к. не использует копирование данных во временные массивы. Ниже представлены эквивалентные замеры производительности, подтверждающие данный вывод:

Размер массива

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

Отсортированные данные

Данные в обратном порядке

500 000

0,165s

0,087s

0,098s

200 000

0,063s

0,026s

0,030s

100 000

0,031s

0,012s

0,014s

50 000

0,015s

0,006s

0,007s

20 000

0,006s

0,002s

0,002s

10 000

0,002s

0,001s

0,001s

Сравнение же быстрой сортировки с простейшими методами с квадратичной сложностью для достаточно больших N окончательно убеждает в важности выбора правильного алгоритма. Так для N=100000 элементов, разница между этими двумя алгоритмами для случайного набора данных составляет 23.156 / 0,031 ~= 747 раз!  

Сложность базовых операций структур данных

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

Операции с векторами характеризуются следующей вычислительной сложностью:

Количество элементов

O(1)

Доступ к i-ой ячейке

O(1)

Вставка в конец

O(1)*

Удаление с конца

O(1)

Вставка в произвольную позицию

O(N)*

Удаление из произвольной позиции

O(N)

Вставка в конец вектора оценивается как амортизированная константная вычислительная сложность. Предсказать ее строго аналитически является нетривиальной задачей. Проблема состоит в том, что вектор может в некоторый момент времени достигнуть размера выделенного блока целиком, вызвав процедуру удвоения с переносом данных в новый блок. До момента роста вычислительная сложность не зависит от размера и является константной O(1). При больших N рост вектора происходит довольно редко, однако в этот редкий момент сложность равна O(N).

Понятие амортизированной сложности можно легко представить на жизненном примере. Предположим, 1 пачка бумаги из 500 листов стоит 40 грн, и бумага продается только целыми пачками. Какова стоимость одного листа бумаги? Наивная логика состоит в делении стоимости пачки на количество листов:

40500=0,08 грн.

Рассуждая формально, в ситуации, когда бумага закончилась, первый лист бумаги будет стоить 40 грн., поскольку купить меньше целой пачки не представляется возможным. После покупки пачки остальные 499 листов “достанутся” бесплатно. Хотя этот вывод вызывает улыбку у многих людей, это соответствует истине. Конечно, если использовать все 500 листов из купленной пачки, то их средняя стоимость составит 8 коп., исходя из приведенной выше формулы.

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

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

Операции со связными списками ведут себя более предсказуемо:

Количество элементов

O(N)

Доступ к i-ой ячейке

O(N)

Вставка в произвольную позицию

O(1)

Удаление из произвольной позиции

O(1)

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

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

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

Выводы

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

СТА: Лекция №6 - Хэш-таблицы

Версия 2.0, 17 сентября 2013г.

(С) 2012-2013, Зайченко Сергей Александрович, к.т.н, ХНУРЭ, доцент кафедры АПВТ

Мотивация

Практическое применение алгоритмов поиска с линейной вычислительной сложностью весьма ограничено. Фактически, линейный поиск пригоден лишь для обработки очень малых объемов данных, поскольку в худшем случае для N хранимых элементов выполняется N сравнений ключей. Если число элементов не превышает 10, использование для поиска таких простых структур данных, как массив или связный список, может быть оправдано минимумом служебных вычислений, необходимых для функционирования, а также простотой реализации.

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

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

Как будет показано далее, реализация отображений и множеств на основе хэш-таблиц значительно сложнее реализации на основе массивов или связных списков. Также, хэш-таблицы требуют существенно большего объема памяти для функционирования, а в процессе поиска выполняют большее число служебных действий для обеспечения своей работы. Если речь идет о малом количестве элементов, применение такой структуры данных может быть нерациональным как с точки зрения памяти, так и времени. Однако, для больших отображений и множеств все усилия полностью окупаются значительно лучшей эффективностью - в среднем, время поиска ключа в хэш-таблице вообще не зависит от количества хранимых ключей (O(1) - константная вычислительная сложность). При грамотной реализации, производительность поиска в хэш-таблице на большом количестве ключей (тысячи, десятки тысяч и более) будет предпочтительнее бинарного поиска, при этом не потребуется предварительного упорядочивания.

Константной вычислительной сложностью O(1) также обладает случай реализации отображений и множеств, в котором ключи ставятся в прямое соответствие с индексами массива. Однако, в общем случае, пространство всех возможных ключей может быть слишком большим, например, весь возможный диапазон числа типа int. Чтобы выделить для каждого возможного ключа по ячейке потребуется очень большой объем памяти. Даже если предположить, что среда выполнения имеет доступ к огромным объемам оперативной памяти, большая часть ячеек в таком массиве бы не использовалась, и память расходовалась бы напрасно.

Устройство хэш-таблицы

Предположим, имеется большое множество теоретически возможных ключей, состоящее из B элементов. Также, предположим имеется массив из N ячеек, при этом N несравнимо меньше чем B. Для организации хэш-таблицы необходимо подобрать некоторую функцию h(x) называемую ХЭШ-ФУНКЦИЕЙ, принимающую любой из ключей xB, и возвращающую целое неотрицательное число h[0; N-1], называемое ХЭШ-КОДОМ. Не имеет определяющего значения конкретная форма и сложность функции h(x), при условии, что она обладает следующими свойствами:

  1. Функция h(x) не имеет состояния и всегда возвращает для одинаковых ключей одинаковый результат, сколько бы раз функция не вызывалась:

x1=x2h(x1)=h(x2).

  1. Область значений функции h(x) распределяется приблизительно равномерно для всех возможных ключей. Т.е. для каждого хэш-кода h[0; N-1]существует приблизительно одинаковое количество ключей x[0; B-1], хэш-код которых равен h.

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

Например, пусть ключи x являются целыми числами, которые могут принимать любые значения в допустимом диапазоне типа int (от -231 до 231-1), т.е. B=232. Очевидно, массив такого размера создавать непрактично даже на очень мощном компьютере, поскольку это бы потребовало нескольких гигабайт оперативной памяти (B возможных значений ключа * размер хранимого значения). Простейшей хэш-функцией для такого типа ключа является остаток от деления:

h(x)=x % N

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

Возьмем конкретный набор ключей x1..xMи соответствующих им значений y1..yM, где M - конечное число, сопоставимое с размером массива N, и несравнимо меньшее количества всех возможных ключей B. Для каждого ключа вычислим значение хэш-функции и получим набор хэш-кодов h1..hM.  Предположим, M<N, и все полученные хэш-коды являются уникальными между собой. Тогда представляется возможным свободно поместить все пары ключ-значение (x,y) в массив A, называемый ХЭШ-ТАБЛИЦЕЙ, при этом пары ключ-значение попадут в ячейки, индекс которых соответствует хэш-коду ключа. Т.е. по индексу h(xi) в массиве A, соответствующему хэш-коду конкретного ключа xi будет находится ячейка с парой ключ-значение (xi,yi):

A[h(xi)]=(xi,yi).

Пусть N = 10, M = 5, и имеются следующие наборы ключей и соответствующих им значений:

x

y

25

315

37

210

13

150

20

242

18

167

Используя простейшую хэш-функцию для целых чисел - остаток от деления на N - получим для всех ключей следующие хэш-коды:

x

h(x)=x%N (N=10)

25

5

37

7

13

3

20

0

18

8

Разместим все пары (x,y) в соответствующие ячейки массива и получим хэш-таблицу:

Теперь, для поиска в таблице значения по некоторому ключу xk необходимо:

  1. Вычислить значение хэш-кода для ключа: hk=h(xk)=xk%N.

  2. Если ячейка по индексуhkзанята, сравнить хранящийся в ней ключ с искомым xk, и если они равны, вернуть значение yk.

  3. Если же ячейка по индексу hk не занята, или хранимый в этой ячейке ключ не равен xk, из этого следует, что искомый ключ в таблице не содержится.

Аналогичным образом должно реализовываться удаления пары ключ-значение по ключу xk:

  1. Вычислить значение хэш-кода для ключа: hk=h(xk)=xk%N.

  2. Если ячейка по индексуhkзанята, сравнить хранящийся в ней ключ с искомым xk, и если они равны, освободить ячейку.

  3. Если же ячейка по индексу hk не занята, или хранимый в этой ячейке ключ не равен xk, из этого следует, что искомый ключ в таблице не содержится, и был инициирован некорректный запрос на удаление незарегистрированного ключа.

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

Коллизии

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

  • число пар ключ-значение превышает число ячеек в массиве ( M >= N );

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

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

Ситуация, когда для двух различных ключей xiи xkв результате вычисления хэш-функции получены одинаковые  хэш-коды, называется КОЛЛИЗИЕЙ.

Например, пусть необходимо расширить рассмотренное выше отображение новой парой (x,y):

x

y

35

271

Вычислим значение хэш-кода от нового ключа:

x

h(x)=x%N (N=10)

35

5

К сожалению, ячейка с индексом 5 уже занята парой с другим значением ключа, и такая ситуация представляет собой коллизию.

Существует несколько подходов к разрешению данной проблемы, в частности:

  • открытое хэширование

  • закрытое хэширование

  • двойное хэширование

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

Частично, проблему частых коллизий может решить увеличение размера массива для хранения данных. Типично, если умножить число N на 2, т.е. использовать вдвое большую таблицу, вероятность возникновения коллизий также уменьшится вдвое, поскольку новая хэш-функция h1(x)=x % 2Nс высокой вероятностью даст более равномерное распределение множества ключей.

Как правило, хэш-таблица эффективна, если она заполнена не более чем на половину. Если число хранимых пар ключ-значение заранее неизвестно и динамически увеличивается, вероятность коллизий будет постепенно повышаться по мере вставки новых пар. В таком случае, когда динамически формируемая хэш-таблица становится заполненной более чем на половину, имеет смысл автоматически увеличить размер массива с данными, и выполнить процедуру ПОВТОРНОГО ХЭШИРОВАНИЯ, т.е. повторно вставить все уже имеющиеся в старой таблице пары (x,y) в новый массив большего размера, используя новую хэш-функцию h1(x).

Разрешение коллизий - открытое хэширование

В методе открытого хэширования проблему коллизий решают путем создания связных списков в каждой из ячеек хэш-таблицы. В ячейках таблицы, вместо непосредственного хранения пар ключ-значение, помещаются указатели на начало связных списков, элементы-узлы которых и хранят интересующие пары ключ-значение. Если появляется новая пара ключ-значение, хэш-код ключа которой совпадает с уже существующим ключом, то эту пару помещают в начало списка, а все ранее помещенные пары становятся следующими узлами. Соответственно, ячейки, которые пока не заняты, хранят нулевые указатели на пока несуществующие узлы. Сами связные списки часто называют СЕГМЕНТАМИ хэш-таблицы.

Рассмотренный выше пример c применением открытого хэширования будет графически представлен следующим образом:

В приведенном примере, ключи 25 и 35 дают одинаковые хэш-коды и направляют поиск к ячейке с индексом 5. Они объединяются в связный список. Для поиска конкретного ключа необходимо, получив хэш-код, последовательно пройти от узла к узлу с начала списка соответствующей ячейки, и сравнивать ключи в данных узлах с интересующим. Если, дойдя до конца списка, искомый ключ не будет обнаружен в таблице, это означает его отсутствие. Удалить пару можно отцеплением нужного узла из списка соответствующего сегмента.

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

Разрешение коллизий - закрытое хэширование

В методе закрытого хэширования, новый ключ, вызывающий коллизию с ранее вставленным ключем, помещают в ближайшую соседнюю свободную ячейку. Такой подход также называют хэш-таблицами с ЛИНЕЙНЫМ ОПРОБИРОВАНИЕМ, или хэш-таблицами с ЛИНЕЙНЫМ РАЗРЕШЕНИЕМ КОЛЛИЗИЙ.

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

В том же самом примере, новый ключ 35, изначально указывает на ячейку с индексом 5, которая уже занята ключом 25. Ближайшая ячейка справа - с индексом 6 - свободна, и конфликтующий новый ключ будет помещен в нее:

Соответственно, для поиска в таблице значения по некоторому ключу xk необходимо:

  1. Вычислить значение хэш-кода для ключа: hk=h(xk)=xk%N.

  2. Если ячейка по индексуhkзанята:

    1. Сравнить хранящийся в ней ключ с xk, и если они равны, вернуть значение yk.

    2. Если ключи не равны, двигаться к следующей ячейке и повторить процедуру 2.

    3. Если достигнут конец массива, перейти к его началу и продолжить процедуру 2.

  1. Если ячейка по индексу hk не занята, искомый ключ в таблице не содержится.

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

При закрытом хэшировании особую осторожность следует соблюдать при удалении пар ключ-значение из таблицы. Предположим, отображение более не нуждается в паре с ключом 25. Если такой ключ просто удалить, то значение для ключа 35 никогда не будет найдено. Алгоритм поиска, вычислив хэш-код, попадет в ячейку 5, которая окажется не занятой, и поиск прекратится. Т.е., удаление ключа нарушает работу поиска в хэш-таблице для пар ключ-значение, ранее образовывавших коллизию с удаленным  ключом.

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

После удаления ключа 25, ячейка 5 должна быть помечена специальным маркером (графически показано желтым цветом заливки). Далее, при поиске ключа 35, изначально попав по ключу в ячейку 5, алгоритм обнаружит, что там ранее находился некоторый удаленный ключ. Из этого будет сделан вывод, что нужно продолжать поиск ключа в следующих ячейках. Сделав шаг к ячейке 6, искомый ключ 35 будет обнаружен.

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

x

y

45

182

x

h(x)=x%N (N=10)

45

5

Двойное хэширование

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

x

y

16

109

Вычислим хэш-код от нового ключа:

x

h(x)=x%N (N=10)

16

6

Ячейка с индексом 6 уже занята ключом 35 в результате предыдущего разрешения коллизии, несмотря на то, что его хэш-код не равен 6. Соответственно, следуя логике алгоритма, новую пару ключ-значение следует разместить в ближайшей ячейке 9:

Из-за образовавшегося кластера идущих подряд ячеек 5-8, новая пара размещена в ячейке 9, более значительно отстоящей от изначальной 6, на которую указывает хэш-код. Хотя все процедуры хэш-таблицы, такие как поиск, удаление, будут функционировать корректно, это приведет к заметной потере производительности из-за большого количества операций перебора ключей.

Метод двойного хэширования расширяет базовый подход закрытого хэширования и направлен на борьбу с образованием таких кластеров, замедляющих работу таблицы. Суть метода состоит в выборе дополнительной хэш-функции g(x). Для определения позиции размещения ключа xk следует:

  1. Вычислить начальный хэш-код h(xk) от основной хэш-функции.

  2. Если соответствующая хэш-коду ячейка еще не занята, разместить ключ в эту ячейку (никаких отличий от базового алгоритма в этом пункте).

  3. Если ячейка уже занята:

    1. Вычислить еще один хэш-код g(xk)от дополнительной хэш-функции.

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

Вторая хэш-функция должна обладать следующими свойствами:

  1. Никогда не возвращать значение 0 (иначе алгоритм зациклится).

  2. Не должна зависеть от основной хэш-функции (иначе не будет эффекта).

Такой подход существенно уменьшает вероятность формирования кластеров и уменьшает количество коллизий.  

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

g(x)=x % 7+1

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

x

h(x)=x%N (N=10)

g(x)= x%7+1

(h(x)+g(x))%N

37

7

3

0

13

3

2

5

20

0

7

6

18

8

5

3

35

5

1

6

45

5

4

9

16

6

3

9

Следуя логике описанного алгоритма двойного хэширования, разместим узлы в таблице по одному:

При вставке следующего ключа 45 происходит коллизия, поскольку ячейка 5 уже занята. В этот момент начинает использоваться вторая хэш-функция. Первая дает значение 5, вторая - 4, в сумме 9. Разместим новый элемент в свободную ячейку 9:

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

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

x

y

29

155

x

h(x)=x%N (N=10)

g(x)= x%7+1

(h(x)+g(x))%N

29

9

2

1

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

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

Хэш-функции

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

Предпочтительным является низкоуровневое преобразование байтов памяти, содержащих двоичное представления ключа. Такое универсальное преобразование будет более равномерно распределять все возможные ключи по области значений, а также будет непредсказуемым для человека.

Например, для текстовых строк подойдет следующая функция:

unsigned int HashCodeForString ( const char* _key )

{

    unsigned int hashCode = 0;

    for ( ; *_key; ++_key)

    {

       char c = *_key;

       hashCode = ( hashCode << 5 ) + c;

    }

    return hashCode;

}

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

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

unsigned int HashCodeForStringIgnoreCase ( const char* _key )

{

    unsigned int hashCode = 0;

    for ( ; *_key; ++_key)

    {

       char c = *_key;

    if ( c >= ‘A’ && c <= ‘Z’ )

        c = c - ‘A’ + ‘a’;

       hashCode = ( hashCode << 5 ) + c;

    }

    return hashCode;

}

Для массивов двоичных данных (абсолютно любой объект в памяти можно интерпретировать как последовательность байт в памяти), можно предложить различные функции с использованием операций сдвига, исключающего ИЛИ и других операций с битами, обеспечивающих равновероятное перемешивание всех бит ключей. Например:

unsigned int hashCodeForBinarySequence ( unsigned char * bytes, size_t length )

{

    unsigned int result = 0;

    for ( size_t i = 0; i < length; i++ )

        result = result ^ ( ( bytes[i] >> i ) + ( result << 6 ) +  ( result >> 2 ) );

    return result;

}

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

В криптографии, где также применяются хэш-функции, существуют популярные алгоритмы для хэширования двоичных данных, обладающие различными свойствами. Например - семейства SHA (Secure Hash Algorithm) и MD (Message Digest Algorithm), имеющие широкое распространение. Они предназначены, в первую очередь, для преобразования больших двоичных файлов в короткие блоки-подписи (160 и 128 бит соответственно), которые можно использовать, например, для сверки подлинности содержимого файла с образцом. В частности, функция MD5 широко используется в программировании для проверки паролей учетных записей пользователей.  Такие алгоритмы также можно использовать для организации работы хэш-таблиц, однако такой подход нельзя назвать рациональным для общего случая - криптографические хэш-функции намного более сложны и вычисляются существенно дольше, по сравнению с рассмотренными более простыми аналогами.

Интерфейс хэш таблицы

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

hash_table.hpp

#ifndef _HASH_TABLE_HPP_

#define _HASH_TABLE_HPP_

// Форвардное объявление структуры

struct HashTable;

// Функция создания новой хэш-таблицы

HashTable* HashTableCreate ( int _initialSize = 100 );

// Функция уничтожения хэш-таблицы

void HashTableDestroy ( HashTable* _pHT );

// Функция очистки хэш-таблицы

void HashTableClear ( HashTable & _ht );

// Функция получения количества хранимых пар ключ-значение

int HashTableNumElements ( const HashTable & _ht );

// Функция вставки новой пары ключ-значение в хэш-таблицу

void HashTableInsert ( HashTable & _ht, int _key, int _value );

// Функция получения значения из хэш-таблицы по ключу

int HashTableGet ( const HashTable & _ht, int _key );

// Функция удаления пары ключ-значение из хэш-таблицы по ключу

void HashTableRemoveKey ( HashTable & _ht, int _key );

#endif //  _HASH_TABLE_HPP_

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

test.cpp

#include "hash_table.hpp"

#include <cassert>

int main ()

{

   HashTable * pHT = HashTableCreate( 10 );

   HashTableInsert( * pHT, 10, 0 );

   HashTableInsert( * pHT, 30, 2 );

   HashTableInsert( * pHT, 20, 1 );

   assert( HashTableNumElements( * pHT ) == 3 );

   assert( HashTableGet( * pHT, 20 ) == 1 );

   assert( HashTableGet( * pHT, 10 ) == 0 );

   assert( HashTableGet( * pHT, 30 ) == 2 );

   HashTableRemoveKey( * pHT, 10 );

   assert( HashTableGet( * pHT, 10 ) == -1 );

   assert( HashTableGet( * pHT, 20 ) == 1 );

   assert( HashTableGet( * pHT, 30 ) == 2 );

   assert( HashTableNumElements( * pHT ) == 2 );

   HashTableRemoveKey( * pHT, 30 );

   assert( HashTableGet( * pHT, 20 ) == 1 );

   assert( HashTableGet( * pHT, 30 ) == -1 );

   assert( HashTableNumElements( * pHT ) == 1 );

   HashTableRemoveKey( * pHT, 20 );

   assert( HashTableGet( * pHT, 20 ) == -1 );

   assert( HashTableNumElements( * pHT ) == 0 );

   HashTableDestroy( pHT );

}

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

Пример реализации хэш-таблицы с открытым хэшированием

hash_table_open_impl.cpp

#include "hash_table.hpp"

#include <cassert>

#include <cstring>

struct HashTable

{

   struct Element

   {

       int m_key;

       int m_value;

       Element* m_pNext;

   };

   int m_tableSize;

   int m_numElements;

   Element** m_pData;

};

// Функция создания новой хэш-таблицы

HashTable* HashTableCreate ( int _initialSize )

{

   HashTable * ht = new HashTable;

   ht->m_tableSize     = _initialSize;

   ht->m_numElements   = 0;

   ht->m_pData         = new HashTable::Element*[ ht->m_tableSize ];

   memset( ht->m_pData, 0, sizeof ( HashTable::Element* ) * ht->m_tableSize );

   return ht;

}

// Функция уничтожения хэш-таблицы

void HashTableDestroy ( HashTable* _pHT )

{

   HashTableClear( * _pHT );

   delete[] _pHT->m_pData;

   delete _pHT;

}

// Функция очистки хэш-таблицы

void HashTableClear ( HashTable & _ht )

{

   _ht.m_numElements   = 0;

   for ( int i = 0; i < _ht.m_tableSize; i++ )

   {

       HashTable::Element* element = _ht.m_pData[ i ];

       while ( element )

       {

           HashTable::Element* temp = element->m_pNext;

           delete element;

           element = temp;

       }

       _ht.m_pData[ i ] = nullptr;

   }

}

// Функция получения количества хранимых пар ключ-значение

int HashTableNumElements ( const HashTable & _ht )

{

   return _ht.m_numElements;

}

// Функция вычисления хэш-кода для ключа - для чисел может быть такой тривиальной.

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

unsigned int HashCode ( int _key )

{

   unsigned int hashCode = _key;

   return hashCode;

}

// Функция создания нового элемента хэш-таблицы

HashTable::Element* HashTableMakeElement ( int _key, int _value, HashTable::Element* _next )

{

   HashTable::Element* newElement = new HashTable::Element;

   newElement->m_key   = _key;

   newElement->m_value = _value;

   newElement->m_pNext = _next;

   return newElement;

}

// Функция вставки новой пары ключ-значение в хэш-таблицу

void HashTableInsert ( HashTable & _ht, int _key, int _value )

{

   unsigned int hashCode = HashCode( _key );

   int bucketNr = hashCode % _ht.m_tableSize;

   HashTable::Element* element = _ht.m_pData[ bucketNr ];

   if ( !element )

   {

       _ht.m_pData[ bucketNr ] = HashTableMakeElement( _key, _value, nullptr );

       _ht.m_numElements++;

   }

   else

   {

       HashTable::Element* prevElement = nullptr;

       while ( element )

       {

           if ( element->m_key == _key )

           {

               element->m_value = _value;

               return;

           }

           else if ( element->m_key > _key )

           {

               HashTable::Element* newElement = HashTableMakeElement( _key, _value, element );

               if ( prevElement )

                   prevElement->m_pNext = newElement;

               else

                   _ht.m_pData[ bucketNr ] = newElement;

               _ht.m_numElements++;

               return;

           }

           else if ( !element->m_pNext )

           {

               _ht.m_numElements++;

               element->m_pNext = HashTableMakeElement( _key, _value, nullptr );

               return;

           }

           prevElement = element;

           element     = element->m_pNext;

       }

   }

}

// Функция получения значения из хэш-таблицы по ключу

int HashTableGet ( const HashTable & _ht, int _key )

{

   unsigned int hashCode = HashCode( _key );

   int bucketNr = hashCode % _ht.m_tableSize;

   const HashTable::Element* element = _ht.m_pData[ bucketNr ];

   while ( element )

   {

       if ( element->m_key == _key )

           return element->m_value;

       else if ( element->m_key > _key )

           break;

       element = element->m_pNext;

   }

   return -1;

}

// Функция удаления пары ключ-значение из хэш-таблицы по ключу

void HashTableRemoveKey ( HashTable & _ht, int _key )

{

   unsigned int hashCode = HashCode( _key );

   int bucketNr = hashCode % _ht.m_tableSize;

   HashTable::Element* element     = _ht.m_pData[ bucketNr ];

   HashTable::Element* prevElement = nullptr;

   while ( element )

   {

       if ( element->m_key == _key )

       {

           if ( prevElement )

               prevElement->m_pNext = element->m_pNext;

           else

               _ht.m_pData[ bucketNr ] = element->m_pNext;

           

           delete element;

           _ht.m_numElements--;

           return;

       }

       else if ( element->m_key > _key )

           break;

       

       prevElement = element;

       element = element->m_pNext;

   }

}

Пример реализации хэш-таблицы с закрытым хэшированием

hash_table_closed_impl.cpp

#include "hash_table.hpp"

#include <cassert>

struct HashTable

{

   struct Element

   {

       int m_key;

       int m_value;

       enum { NOT_OCCUPIED, OCCUPIED, DELETED } m_status;

   };

   int m_tableSize;

   int m_numOccupied;

   Element* m_pData;

};

// Функция создания новой хэш-таблицы

HashTable* HashTableCreate ( int _initialSize )

{

   HashTable * pNewHT = new HashTable;

       

   pNewHT->m_tableSize = _initialSize;

   pNewHT->m_pData     = new HashTable::Element[ pNewHT->m_tableSize ];

   HashTableClear( * pNewHT );

   return pNewHT;

}

// Функция уничтожения хэш-таблицы

void HashTableDestroy ( HashTable* _pHT )

{

   delete[] _pHT->m_pData;

   delete _pHT;

}

// Функция очистки хэш-таблицы

void HashTableClear ( HashTable & _ht )

{

   _ht.m_numOccupied = 0;

   for ( int i = 0; i < _ht.m_tableSize; i++ )

       _ht.m_pData[ i ].m_status = HashTable::Element::NOT_OCCUPIED;

}

// Функция получения количества хранимых пар ключ-значение

int HashTableNumElements ( const HashTable & _ht )

{

   return _ht.m_numOccupied;

}

// Функция вычисления хэш-кода для ключа - для чисел может быть такой тривиальной.

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

unsigned int HashCode ( int _key )

{

   unsigned int hashCode = _key;

   return hashCode;

}

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

bool HashTableTryInsertElement ( HashTable & _ht, int _bucketNr, int _key, int _value )

{

   if ( _ht.m_pData[ _bucketNr ].m_status != HashTable::Element::OCCUPIED )

   {

       _ht.m_pData[ _bucketNr ].m_status = HashTable::Element::OCCUPIED;

       _ht.m_pData[ _bucketNr ].m_key    = _key;

       _ht.m_pData[ _bucketNr ].m_value  = _value;

       _ht.m_numOccupied++;

       return true;

   }

   else if ( _ht.m_pData[ _bucketNr ].m_key == _key )

   {

       _ht.m_pData[ _bucketNr ].m_value = _value;

       return true;

   }

   else

       return false;

}

// Функция удвоения размера хэш-таблицы с повторным хэшированием

void HashTableDoubleSize ( HashTable & _ht )

{

   int oldSize = _ht.m_tableSize;

   _ht.m_tableSize <<= 1;

   HashTable::Element* oldData = _ht.m_pData;

   _ht.m_pData = new HashTable::Element[ _ht.m_tableSize ];

   for ( int i = 0; i < _ht.m_tableSize; i++ )

       _ht.m_pData[ i ].m_status = HashTable::Element::NOT_OCCUPIED;

   _ht.m_numOccupied = 0;

   for ( int i = 0; i < oldSize; i++ )

       if (  oldData[ i ].m_status == HashTable::Element::OCCUPIED  )

           HashTableInsert( _ht, oldData[ i ].m_key, oldData[ i ].m_value );

   delete[] oldData;

}

// Функция вставки новой пары ключ-значение в хэш-таблицу

void HashTableInsert ( HashTable & _ht, int _key, int _value )

{

   if ( ( _ht.m_numOccupied << 1 ) >= _ht.m_tableSize )

       HashTableDoubleSize( _ht );

   unsigned int hashCode = HashCode( _key );

   int bucketNr = hashCode % _ht.m_tableSize;

   for ( int i = bucketNr; i < _ht.m_tableSize; i++ )

       if ( HashTableTryInsertElement( _ht, i, _key, _value ) )

           return;

   for ( int i = 0; i < bucketNr; i++ )

       if ( HashTableTryInsertElement( _ht, i, _key, _value ) )

           return;

}

// Функция получения значения из хэш-таблицы по ключу

int HashTableGet ( const HashTable & _ht, int _key )

{

   unsigned int hashCode = HashCode( _key );

   int bucketNr = hashCode % _ht.m_tableSize;

   for ( int i = bucketNr; i < _ht.m_tableSize; i++ )

       if ( _ht.m_pData[ i ].m_status == HashTable::Element::NOT_OCCUPIED )

           break;

    else if ( _ht.m_pData[ i ].m_status == HashTable::Element::OCCUPIED &&

              _ht.m_pData[ i ].m_key == _key )

           return _ht.m_pData[ i ].m_value;

   for ( int i = 0; i < bucketNr; i++ )

       if ( _ht.m_pData[ i ].m_status == HashTable::Element::NOT_OCCUPIED )

           break;

       else if ( _ht.m_pData[ i ].m_status == HashTable::Element::OCCUPIED &&

                 _ht.m_pData[ i ].m_key == _key )

           return _ht.m_pData[ i ].m_value;

   return -1;

}

// Функция удаления пары ключ-значение из хэш-таблицы по ключу

void HashTableRemoveKey ( HashTable & _ht, int _key )

{

   unsigned int hashCode = HashCode( _key );

   int bucketNr = hashCode % _ht.m_tableSize;

   for ( int i = bucketNr; i < _ht.m_tableSize; i++ )

       if ( _ht.m_pData[ i ].m_status == HashTable::Element::OCCUPIED &&

            _ht.m_pData[ i ].m_key == _key )

       {

           _ht.m_pData[ i ].m_status = HashTable::Element::DELETED;

           --_ht.m_numOccupied;

           return;

       }

   for ( int i = 0; i < bucketNr; i++ )

       if ( _ht.m_pData[ i ].m_status == HashTable::Element::OCCUPIED &&

            _ht.m_pData[ i ].m_key == _key )

       {

           _ht.m_pData[ i ].m_status = HashTable::Element::DELETED;

           --_ht.m_numOccupied;

           return;

       }

   assert( !"key not found" );

}

Пример реализации хэш-таблицы с двойным хэшированием

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

// Вторая функция вычисления хэш-кода для ключа

unsigned int HashCode2 ( int _key )

{

   unsigned int hashCode = _key % 7 + 1;

   return hashCode;

}

void HashTableInsert ( HashTable* _ht, const char* _key, int _value )

{

   if ( ( _ht->numOccupied << 1 ) >= _ht->hashTableSize )

      HashTableDoubleSize( _ht );

   unsigned int hashCode1 = HashCode( _key );

   int bucketNr = hashCode1 % _ht->hashTableSize;

   if ( HashTableTryInsertElement( _ht, bucketNr, _key, _value ) )

      return;

   unsigned int hashCode2 = HashCode2( _key );

   do

   {

      hashCode1 += hashCode2;

      bucketNr  = hashCode1 % _ht->hashTableSize;

   } while ( !HashTableTryInsertElement( _ht, bucketNr, _key, _value ) );

}  

Поиск и удаление реализуются по аналогии.