Содержание

Работа с таблицами

1.Тип таблицы
2.Поиск в неупорядоченной таблице
3.Двоичный поиск
4.Представление таблицы в виде бинарного упорядоченного дерева
5.2-3-деревья
6.AVL-деревья
7. Хеш-адресация
8. Методы сортировки


1. Тип таблицы

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

Определим тип таблицы и саму таблицу:

const M = 7;                     {Макс. размер таблицы}
type TRec = record               {Тип записей таблицы}
            Tag:  Char;          {Тэг записи}
            Key:  KeyType;       {Ключ записи}
            Info: InfoType;      {Значение записи}
            end;
     TTab = array[1..M] of TRec; {Тип таблицы}

var  Tab:   TTab;                {Таблица}
     Cnt:   Integer;             {Фактический размер таблицы}

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

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

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

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

2. Поиск в неупорядоченной таблице

procedure Search(Arg: KeyType; var Index, ErrCode: Integer);
var i: Integer;
begin
for i:=1 to Cnt do
   if Tab[i].Key = Arg then
      begin Index:=i; ErrCode:=0; Exit end;
ErrCode:=1; {Запись не найдена}
end; {Search}

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

procedure Search(Arg: KeyType; var Index, ErrCode: Integer);
var i: Integer;
begin
Tab[Cnt+1].Key:=Arg;   { Установка барьера }
i:=1;
while Tab[i].Key <> Arg do Inc(i);
if i = Cnt + 1 then begin ErrCode:=1; Exit end;  {Запись не найдена}
Index:=i; ErrCode:=0;
end; {Search}

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

3. Двоичный поиск

Может быть использован в случае упорядоченной таблицы. Идея двоичного поиска состоит в последовательном делении области поиска пополам. Уменьшение области поиска по геометрической прогрессии приводит к резкому сокращению времени поиска. Предположим, что записи в таблице упорядочены по возрастанию ключа и [Up..Down] - множество индексов записей таблицы, среди которых находится искомая (возможно). Определим середину отрезка Up..Down - i:=(Up + Down) div 2. Теперь, если Tab[i].Key < Arg, то это в силу упорядоченности таблицы означает, что индекс искомой записи может находиться только на отрезке i+1..Down. Если же Tab[i].Key > Arg, то - на отрезке Up..i-1. Мы видим что проверка всего двух условий приводит к сокращению области поиска сразу в два раза! На следующем шаге полученная область уменьшится ещё в два раза, и т.д.

procedure Search(Arg: KeyType; var Index, ErrCode: Integer);
var i: Integer;
begin
Up:=1; Down:=Cnt;
repeat
   i:=(Up + Down) div 2;
   if Tab[i].Key <= Arg then Up:=i+1;
   if Tab[i].Key >= Arg then Down:=i-1;
until Up > Down;
if Up = Down + 2 then
   begin Index:=i; ErrCode:=0; Exit end;
ErrCode:=1;  {Запись не найдена}
end; {Search}

Сравним данный метод с предыдущим. По объёму кода несколько больше процедура двоичного поиска. Оценим производительность каждого метода. Время поиска методом перебора, который применяется для поиска в неупорядоченной таблице, может быть оценено по формуле Tп = Kп*Cnt/2. Для двоичного поиска - Tд = Kд*log2(Cnt). Здесь Kп и Kд - это коэффициенты, пропорциональные времени выполнения операторов тела цикла соответствующих методов. Грубая оценка даёт Kд ~= 5*Kп. Если предположить, что Kп=1, то для таблицы, содержащей 1000 записей, получим Tп = 500, а Tд = 50 некоторых условных единиц времени. Вывод очевиден. Однако, следует заметить, что поддержание таблицы в упорядоченном состоянии тоже требует затрат времени и окончательный выбор метода следует делать с учетом всех факторов. На практике двоичный поиск применяют тогда, когда таблица изменяется редко или не изменяется совсем.

4. Представление таблицы в виде упорядоченного бинарного дерева

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

type TRec = record               {Тип записей таблицы}
            Tag:   Char;         {Тэг записи}
            Key:   KeyType;      {Ключ записи}
            Info:  InfoType;     {Значение записи}
            LLink: Integer;      {Указатель на левое п/д}
            RLink: Integer;      {Указатель на правое п/д}
            end;

Алгоритм поиска в упорядоченном бинарном дереве:

procedure Search(Arg: KeyType; var Index, ErrCode: Integer);
var p: Integer;  {Указатель текущего элемента}
begin
p:=Head;
while p<>0 do
    if Arg<Tab[p].Key then p:=Tab[p].LLink
    else
        if Arg>Tab[p].Key then p:=Tab[p].RLink
        else begin Index:=p; ErrCode:=0; Exit end;
ErrCode:=1;   {Запись не найдена}
end; {Search}

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

5. 2-3-деревья

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

      (44)                   (44)
 (40)     (50)          (40)     (48,50)
30  40   44  50        30  40   44  48  50

2-3-дерево, как структура данных языка Turbo Prolog, может быть определена следующим образом:

domains
tree=nil;l(int);n2(tree,int,tree);
%                   T1   M2  T2           M2=min(T2)
            n3(tree,int,tree,int,tree)
%               T1   M2  T2   M3  T3      M2=min(T2), M3=min(T3)

Предикае l/1 предназначен для представления информационных элементов, а предикаты n2/3 и n3/5 - узловых.

Определим предикат search для поиска элемента в 2-3-дереве.

search(X,l(X)).
search(X,n2(T1,M2,_)) :- X<M2,!,search(X,T1).
search(X,n2(_,_,T2)) :- search(X,T2).
search(X,n3(T1,M2,_,_,_)) :- X<M2,!,search(X,T1).
search(X,n3(_,_,T2,M3,_)) :- X<M3,!,search(X,T2).
search(X,n3(_,_,_,_,T3)) :- search(X,T3).

При включении нового элемента в дерево возможны два способа его роста:

Эта идея реализована в программе Tree23.pro, которая содержит процедуру определения предиката add23 для включения нового элемента в 2-3-дерево. Следует иметь в виду, что для выполнения этой программы в среде Turbo Prolog правила для предиката insert необходимо перегруппировать с учётом их арности.

6. AVL-деревья

Адельсон, Вельский и Лэндис предложили способ включения элемента в бинарное дерево, который поддерживает его сбалансированность. Пусть в дерево (L) A (R) включается новый элемент x>A. В результате будет получено дерево (L/H1) A ((R1/H2) B (R2/H3)), где через / указана глубина соответствующих деревьев. Возможны три варианта соотношения глубин поддеревьев:
1) H3 >= H2 и H3 >= H1:   ((L) A (R1)) B (R2);
2) H1>= H2 и H1 >= H3:    (L) A ((R1) B (R2));
3) H2 > H1 и H2 > H3:       ((L) A (R11)) C ((R12) B (R2)), где (R11) C (R12) = R1.
Аналогичная процедура выполняется на всех уровнях включения элемента в дерево.

7. Хеш-адресация

Этот метод известен также как метод расстановки ключей или распаковки адреса.
Будем считать двоичное представление ключа натуральным индексом в таблице. Поиск элемента с заданным значением ключа в этом случае сводится к прямому доступу. Но таблица при этом должна быть огромных размеров и будет крайне разреженной. Практическое значение получил некоторый средний вариант с использованием хеш-функции i = h(key), которая отображает пространство ключей на множество индексов 0..m-1, где m - размер таблицы. Трудно ожидать, что такое отображение будет взаимооднозначным, но чем более беспорядочным оно будет, тем лучше. Идея метода состоит в использовании хеширования ( i = h(key) ) для получения индекса элемента при его включении в таблицу и рехеширования для исключения конфликтов ( h(k1) = h(k2) при k1 <> k2). Предположительно можно подобрать достаточно простую хеш-функцию, что удельный вес конфликтных ситуаций будет не велик, а это позволяет надеяться на высокую эффективность данного метода работы с таблицей.

Простейший метод рехеширования состоит в поиске первой свободной ячейки в таблице в порядке увеличения индексов, начиная с i = h(key), если ячейка с индексом i оказалась уже занятой. При достижении границы таблицы поиск такой ячейки можно продолжить в её начале. Такое рехеширование называется линейным.

Приведём программу, которая иллюстрирует эту технику.

{ Hash.pas - Хеш-адресация }
program Hash;
type TRec = record Busy: Boolean; Key: String[8]; Info: Integer end;
const m = 7;
var Key:   string[8];
    Index: Integer;
    Tab:   array[0..m-1] of TRec;
    Cnt:   Integer; {Порядковый номер включаемого значения}

function h(Key: string):Integer;
var i, s: Integer; sum, b: Longint;
begin
sum := 0;
for i := 1 to Length(Key) do
   begin
   b := byte(Key[i]);
   s := ((i - 1) mod 7) * 4;
   b := b shl s;
   sum := sum xor b;
   end;
h := abs(sum) mod m;
end; {h}

procedure Init;
var i: Integer;
begin
for i := 0 to m - 1 do with Tab[i] do
   begin Busy := False; Key := ''; Info := 0 end;
end; {Init}

function Insert(Key: string; Info: Integer): Boolean;
var i: Integer;
begin
i := h(Key);
while Tab[i].Busy do
   if Tab[i].Key = Key then
      begin Insert := False; Exit end
   else
      i := (i + 1) mod m;
Insert := True;
Tab[i].Busy := True;
Tab[i].Key  := Key;
Tab[i].Info := Info;
end; {Insert}

function Search(Key: string; var Index: Integer): Boolean;
var i: Integer;
begin
i:=h(Key);
while Tab[i].Busy do
   if Tab[i].Key = Key then
      begin Index := i; Search := True; Exit end
   else
      i := (i + 1) mod m;
Search := False;
end; {Search}

procedure PrintTab;
var i: Integer;
begin
WriteLn;
for i := 0 to m - 1 do
   with Tab[i] do WriteLn(i:1, Ord(Busy):3, Key:10, Info:3);
end; {PrintTab}

begin
WriteLn('=== Inserting ===');
Cnt := 1;  
repeat
   Write('Enter any Key please for Insert: '); ReadLn(Key);
   if Key = '' then Break;
   Index := h(Key);
   WriteLn('h(', Key, ')=', Index);
   if Insert(Key, Cnt * 10 + Index) then Cnt := Cnt + 1
                                    else WriteLn('Duble name');
until False;

PrintTab;

WriteLn('=== Searching ===');
repeat
   Write('Enter any Key please for Search: '); ReadLn(Key);
   if Key = '' then Break;
   if Search(Key, Index) then WriteLn('Info = ', Tab[Index].Info)
                         else WriteLn('Not found');
until False;
end.

Пример диалога с программой Hash:

=== Inserting ===
Enter any Key please for Insert: DOG
h(DOG)=0
Enter any Key please for Insert: GUN
h(GUN)=1
Enter any Key please for Insert: MAN
h(MAN)=4
Enter any Key please for Insert: FOX
h(FOX)=4
Enter any Key please for Insert: BEAR
h(BEAR)=5
Enter any Key please for Insert: WOLF
h(WOLF)=6
Enter any Key please for Insert:

0  1       DOG 10
1  1       GUN 21
2  1      WOLF 66
3  0            0
4  1       MAN 34
5  1       FOX 44
6  1      BEAR 55
=== Searching ===
Enter any Key please for Search: GUN
Info = 21
Enter any Key please for Search:

Процедуры Insert и Search в программе Hash будут работать правильно, если в таблице остаётся хотя бы одна свободная ячейка.

Из других многочисленных методов рехеширования отметим организацию таблицы указателей на упорядоченные линейные списки элементов, для которых значения h(key) одинаковы.

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

Заполнение таблицы (%) Среднее количество сравнений при поиске
10 1.06
50 1.5
90 5.5

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

Метод хеширования в настоящее время один из самых популярных методов работы с таблицами.

8. Методы сортировки

1. Сортировка простыми вставкам

var R:   TRec;
    i,j: Integer;
. . .
for i:=2 to Cnt do
   begin
   R:=Tab[i]; j:=i-1;
   while (j > 0) and (R.Key < Tab[j].Key) do
      begin Tab[j+1]:=Tab[j]; Dec(j) end;
   Tab[j+1]:=R;
   end;

Идея метода простых вставок состоит в следующем. Предположим что начальная часть таблицы 1..i-1 уже отсортирована. В частности она может состоять всего из одной записи, первой. Запись Tab[i] сохраняется в R. Далее определяется местоположение записи R в части таблицы 1..i-1. Для этого R.Key последовательно сравнивается с ключами записей таблицы, начиная с j = i - 1 в сторону уменьшения индекса j, пока не будет нарушено условие R.Key < Tab[j].Key. Попутно записи, для которых условие R.Key < Tab[j].Key выполняется, сдвигаются на одну позицию в сторону конца таблицы, освобождая тем самым место для включения записи R. В результате выполнения этого процесса размер отсортированной части таблицы увеличивается на 1. Остаётся продолжить эти действия для оставшихся записей и таблица будет отсортирована.

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

2. Сортировка Шелла

В алгоритме сортировки простыми вставками обратное продвижение выполняется с шагом равным 1. Если его выполнять с шагом d, то сортировка будет частичной - в заданном порядке будут находиться элементы таблицы, которые расположенные друг от друга на расстоянии d и более. Идея Шелла состоит в применении многократной сортировки простыми вставками с уменьшающимся значением d. Рекомендуется следующая последовательность шагов (в порядке противоположном применению): d[1] = 1; d[k+1] = 3*d[k] + 1, k>1. Начальный шаг d[km] следует выбирать из условия d[km + 2] >= m. Например, при m = 1000 d[7] = 1093 > 1000 и поэтому надо выполнить пять проходов с значениями d равными 121, 40, 13, 4 и 1. Сортировка Шелла даёт хорошие результаты для m в диапазоне 30..1000.

3. Быстрая сортировка Хоара

procedure QSort(L,H: Integer);
var Key: KeyType; {Разделяющий ключ}
    W:   TRec;
    i,j: Integer;
begin
Key:=Tab[(L+H) div 2].Key; i:=L; j:=H;
repeat
    while Tab[i].Key < Key do i:=i+1;
    while Tab[i].Key > Key do j:=j-1;
    if i<=j then
        begin
        W:=Tab[i]; Tab[i]:=Tab[j]; Tab[j]:=W;
        i:=i+1; j:=j-1
        end;
until i>j;
if L<j then QSort(L,j);
if i<H then QSort(i,H);
end; {QSort}

Сортировка рекурсивная. Главный вызов: QSort(1,m) . Кнут советует при H - L < 30 переходить на метод простых вставок. Эффективность метода существенно зависит от удачного выбора разделяющего ключа. Рекомендуется определять разделяющий ключ как средний по значению из трёх ключей таблицы с псевдослучайными индексами. Эта сортировка хорошо себя зарекомендовала для m > 500.

4. Индексные таблицы

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

var IndTab: array[1..M] of
            record Key: KeyType; Index: Integer end;
...
for i:=1 to M do
    begin
    IndTab[i].Key:=Tab[i].Key;
    IndTab[i].Index:=i
    end;
{Сортировка IndTab по ключу Key};
{Обращение к значению k-го элемента в отсортированной таблице}
d := Tab[IndTab[k].Index].Info;

5. Внешняя сортировка

Сортировка называется внешней, если сортируемая таблица находится на ВЗУ. Для сортировки таблиц на ВЗУ прямого доступа можно использовать рассмотренные выше методы. Учёт специфики размещения данных на ВЗУ заключается, как правило, в их блокировании (объединение в блоки или страницы) с целью сокращения общего количества обращений к ВЗУ. Для ВЗУ последовательного доступа требуются особые методы. Рассмотрим метод сортировки разбиением-слиянием, который был популярен во времена использования ВЗУ на магнитных лентах.

Предположим, что записи файла F разбиты на блоки по n записей и записи в блоках упорядочены. Выполним операцию поблочного разбиения записей файла F на два файла F1 и F2 по принципу "на первый-второй рассчитайсь". Затем выполним операцию поблочного слияния файлов F1 и F2 в один (это может быть файл F). При слиянии двух упорядоченных блоков будет получен один упорядоченный блок, длина которого удваивается. Общее количество блоков в файле F при этом уменьшается вдвое. Каждая из операций слияния и разбиения выполняется за один проход файлов. Через некоторое количество шагов разбиения-слияния будет получен один упорядоченный блок. На первом шаге сортировки полагают n = 1. Составим абстрактную программу сортировки.

n:=1;
repeat
    { Поблочное разбиение F на F1 и F2 };
    { Поблочное слияние F1 и F2 в F.
       Подсчёт количества блоков k. };
    n:=2*n;
until k=1;

Copyright г Барков Валерий Андреевич, 2000