8.7. Эффективность хранения и поиска данных

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

    Вернемся к тому, с чего начали: к сортировке и поиску (4.6). Все известные нам структуры данных обеспечивают линейную трудоемкость на неупорядоченных данных и логарифмическую – на упорядоченных. При этом сами хранимые значения никак не влияют на «внешний вид» структуры данных – массив остается массивом, а список – списком. Попробуем сделать наоборот, чтобы структура данных «участвовала» в размещении хранимых в ней значений, чтобы затем их проще было бы найти.

    Распределение по разрядам ключа

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

    рис. 87-1. Дерево с распределением по символам строки-ключа.
    рис. 87-1. Дерево с распределением по символам строки-ключа.

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

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

    CPP
    //-------------------------------------------------87-01.cpp
    
    // Дерево с распределением по разрядам ключа
    
    struct node{
    
                char *data;        // Текущее слово
    
                int cnt;              // Счетчик повторений
    
                node *pr[256];    // Вершины - потомки по разрядам ключа
    
    void create(){                                          // Создание вершины
    
                for (int i=0;i<256;i++) pr[i]=NULL;
    
                cnt=0; data=NULL;
    
                }                                               // Рекурсивное включение нового слова
    
    void insert(unsigned char *wd, int lv){        // lv – номер символа
    
                if (wd[lv]==0 || wd[lv]==' ' || wd[lv]=='\t'){
    
                            cnt++;                           // Конец слова – если первое, поместить
    
                            if (cnt==1) data=strdup((char*)wd);
    
                            }
    
                else{     unsigned k=wd[lv];         // Очередной символ – селектор потомка
    
                            if (pr[k]==NULL) {           // Потомка нет - создать
    
                                        pr[k]=new node();
    
                                        pr[k]->create(); }
    
                            pr[k]->insert(wd,lv+1);  // Рекурсивный вызов для потомка и
    
                            }}                                  // следующего символа

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

    CPP
    //-------------------------------------------------87-01.cpp
    
    void scan(int lv){                         // Обход поддерева
    
                if (cnt!=0) { char c2[80];              // Есть слово – вывести его
    
                            CharToOem(data,c2);
    
                            printf("n=%d:%s\n",cnt,c2); }
    
                for (int i=0;i<256;i++)                  // Обход непустых потомков
    
                            if (pr[i]!=NULL) pr[i]->scan(lv+1); }
    
    int count(){                                             // Подсчет количества слов в поддереве
    
                int i,n=(data!=NULL);                  // Есть слово в этом узле – n=1
    
                for (i=0; i<256;i++)                     // Суммирование счетчиков от потомков
    
                            if (pr[i]!=NULL) n+=pr[i]->count();
    
                return n;}
    
    void find(char wd[], int lv){                        // Поиск группы слов по общему началу
    
                if (wd[lv]==0) {                            // Общее начало закончилось
    
                            printf("[%d]\n",count());   // Обход поддерева, в котором оказались
    
                            scan(lv); }
    
                else {    unsigned char c1=wd[lv];// Иначе – рекурсивный переход к потомку
    
                            if (pr[c1]!=NULL) pr[c1]->find(wd,lv+1);
    
                            else puts("No words");    // по очередному символу - селектору
    
                            }
    
                }}

    Замечание: поскольку все типы данных можно так или иначе привести в двоичное представление, то можно предложить для размещения любых данных дерево с двумя потомками, в котором каждая вершина «отвечает» за очередной двоичный разряд ключа, а левый и правый потомки используются для размещения значений, содержащих в очередном разряде 0 или 1 соответственно.

    Хеширование

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

    ![рис. 87-2. Хеширование – размещение вычислением адреса.]./assets/087-02.png)

    Возникает вопрос: а если все так просто, то почему хеширование не стало «единственно правильным методом» организации данных? Не вдаваясь в подробности, перечислим основные особенности и «подводные камни» этого метода:

    • для преобразования значения в адрес используется функция (в обычном, математическом смысле этого слова, преобразующая размещаемое значение в адрес – индекс в таблице). Она должна обеспечивать псевдо-случайное, хаотическое разбрасывание значений по ячейкам. Для этого используются всяческие способы «перемешивания» разрядов размещаемых данных (отсюда hash – кромсать, перемешивать);

    • при размещении данных возможны столкновения (коллизии), когда два разных значения получают от функции один и тот же адрес размещения;

    • коллизии разрешаются либо за счет размещения «лишних» значений в соседних свободных ячейках, либо путем создания из них линейных цепочек, например, списков;

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

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

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

    Для создания простейшей хеш-таблицы воспользуемся динамическим массивом указателей на заголовки списков. В качестве хранимых значений будем использовать целые числа с количеством разрядов (цифр) KSIZE и диапазоном 10KSIZE. Поскольку индексом в таблице является значение хеш-функции, то размерность таблицы также определим как кратную степени 10, sz=10POW . Для «перемешивания» размещаемых значений найдем первое простое число, превышающее sz, остаток от деления на него будем использовать для адресации в таблице.

    CPP
    //--------------------------------------------------------------87-02.cpp
    
    struct cell{                                 // Ячейка хеш-таблицы - элемент списка
    
                int ckey;                        // Значение ключа
    
                cell *next; };                   // Ссылка в списке
    
    struct HASH{                             // Структура со встроенными функциями
    
                cell **hash;                    // Хеш-таблица - массив указателей на списки
    
                int POW,sz;                  // Размерность таблицы sz=10^POW
    
                int KSIZE,w;                  // Размерность ключа (кол-во цифр) w=10^KSIZE
    
                int s1;                           // Простое число, следующее за sz
    
    void reset(){                               // Разрушение хеш-таблицы
    
                cell *p,*q;
    
                for (int i=0;i<sz;i++)        // Разрушение списков
    
                            for (p=hash[i];p!=NULL;){
    
                                        q=p; p=p->next; delete q; }
    
                delete []hash; }              // Разрушение ДМУ
    
    void init(int p0, int ksz0){             // Создание хеш-таблицы
    
                srand(time(NULL));
    
                POW=p0; sz=pow(10,POW);
    
                KSIZE=ksz0; w=pow(10,KSIZE);
    
                for(s1=sz+1;1;s1++){      // Простое число, следующее за sz
    
                            for (int i=2;i<s1 && s1%i!=0;i++);
    
                            if (i==s1) break; }
    
                hash=new cell*[sz];        // Создание ДМУ на списки
    
                for (int i=0;i<sz;i++) hash[i]=NULL;
    
                }
    
    int hash_fun(int key)                   // Хэш-функция – остаток от деления на s1
    
    { return (key%s1) %sz; }            
    
    void insert(int val){                      // Вставка в хеш-таблицу
    
                int v=hash_fun(val);         // Вызов хеш-функции
    
                cell *q;
    
                for (q=hash[v];q!=NULL;q=q->next)
    
                            if (q->ckey==val) break;  // Поиск в списке этого значения
    
                if (q==NULL){                             // Значения в таблице нет
    
                            q=new cell;                    // Вставка в начало списка
    
                            q->ckey=val;
    
                            q->next=hash[v];
    
                            hash[v]=q;}
    
                }
    
    void load_rand(double proc){                    // Добавление случайных значений
    
                for (int i=0;i<sz*proc;i++)            // по заданному проценту заполнения
    
                            insert(rand()%w);            // таблицы
    
                }};

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

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

    Иерархические структуры данных

    «Лучшее - враг хорошего». Пословица.

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

    • список, элемент которого содержит массив указателей.
    CPP
    struct elem{                   // Элемент односвязного списка
    
    elem *next;
    
    void *pp[20]; };               // Массив указателей на элементы данных
    
    // Подсчет количества элементов в структуре данных
    
    int         count(elem *p) {
    
    elem *q; int cnt;             // Цикл по списку
    
    for (cnt=0, q=p; q!=NULL; q=q->next) {
    
                int i;                  // Цикл по массиву указателей
    
                for (i=0; q->pp[i]!=NULL; i++)
    
                            cnt++;
    
                } return cnt; }
    • массив, каждый элемент которого является заголовком списка
    CPP
    struct list {
    
    list *next;
    
    void *data; };
    
    int         count(list *p[]) {
    
    int k,cnt;                       // Цикл по массиву заголовков списков
    
    for (cnt=0, k=0; p[k]!=NULL; k++) {
    
                list *q;               // Цикл по списку
    
                for (q=p[k]; q!=NULL; q=q->next)
    
                            cnt++;
    
                } return cnt; }
    • двухуровневый массив указателей
    CPP
    
    void **p[20];                   // массив указателей на массивы указателей
    
    int         count(void **p[]) {
    
    int k,cnt;                       // Цикл по массиву верхнего уровня
    
    for (cnt=0, k=0; p[k]!=NULL; k++) {
    
                int i;                  // Цикл по массиву нижнего уровня
    
                for (i=0; p[k][i]!=NULL; i++)
    
                                        cnt++;
    
                } return cnt; }
    

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

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

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

    Сбалансированность структур данных. Необходимой платой за перечисленные достоинства является поддержка необходимой сбалансированности – размерности структур данных нижнего уровня должны быть примерно одинаковы. Алгоритмы, выполняющие эту процедуру при каждой операции включения и исключения, могут быть достаточно громоздкими. Альтернатива – периодическое «утрясание» всей структуры данных (например, путем переписывания всех ее элементов в аналогичную новую структуру) при значительном нарушении сбалансированности.

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

    CPP
    
    //------------------------------------------------------87-03.cpp
    
    //----- Двухуровневый массив указателей на целые
    
     #define N 4
    
     int **p[20]={NULL};                    // Исходное состояние структура данных пуста
    
     //--- Вспомогательные функции для нижнего уровня
    
     int size(int *p[])                         // Количество элементов в массиве указателей
    
     { for (int i=0; p[i]!=NULL; i++); return i; }
    
     //--- Включение в массив указателей нижнего уровня по номеру
    
     int F3(int *p[], int *q, int n) {
    
     int i,m=size(p);
    
     for (i=m; i>=n; i--) p[i+1] = p[i];
    
     p[n] = q;
    
     return m+1==N; }                      // Результат - проверка на переполнение

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

    ![рис. 87-3. Двухуровневый массив указателей]./assets/087-03.png)

    CPP
    //------------------------------------------------------87-03.cpp
    
    //----- Обход структуры данных со сквозной нумерацией
    
     void show(int **p[])
    
     { int i,j,k;
    
     for (i=0,k=0; p[i] != NULL; i++)
    
          for (j =0; p[i][j] != NULL; j++,k++)
    
                   printf("A[%d(%d,%d)]=%d\n",k,i,j,*p[i][j]);
    
     }

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

    CPP
    //------------------------------------------------------87-03.cpp
    //------ Включение по логическому номеру
    
    void Insert_Num(int **p[],int *q, int n) { 
         int i,j,l,sz;
    
          if (p[0]==NULL){      // Отдельно для пустой структуры данных
    
          p[0]=new int*[N+1];
    
          p[0][0]=q; p[0][1]=NULL; return;
    
          }                     // Поиск места включения
    
          for (i =0; p[i] != NULL; i++,n-=sz) {
    
          sz=size(p[i]);        // Количество указателей в массиве
    
          if (n<=sz) break;     // Номер попадает в текущий массив
    
          }
    
     if (p[i]==NULL)            // Не найден включить последним
    
          { i--; n=size(p[i]); }
    
     if (F3(p[i],q,n))                           // Вызов функции включения для нижнего уровня
    
          {                     // Переполнение создание нового массива
    
          for (int ii=0; p[ii] != NULL; ii++);
    
          for(int h=ii;h>i;h--) // Раздвижка в массиве указателей
    
          p[h+1]=p[h];          // верхнего уровня
    
          p[i+1]=new int*[N+1]; // Создание массива нижнего уровня
    
          for(j=0;j<N/2;j++)    // Перенос указателей
    
          p[i+1][j]=p[i][j+N/2];
    
          p[i][N/2]=NULL;
    
          p[i+1][N/2]=NULL;
    
          }}