Главная страница
Навигация по странице:

  • 5.1. Введение

  • 5.2. Выбор хэшGфункции

  • 5.3. Разрешение коллизий

  • 5.4. Анализ хэширования

  • Алгоритмы и структуры данныхНовая версия для Оберона cdмосква, 2010Никлаус ВиртПеревод с английского под редакцией


    Скачать 2.67 Mb.
    НазваниеАлгоритмы и структуры данныхНовая версия для Оберона cdмосква, 2010Никлаус ВиртПеревод с английского под редакцией
    Анкор11221
    Дата18.05.2023
    Размер2.67 Mb.
    Формат файлаpdf
    Имя файлаAlgoritmy_i_struktury_dannyh.pdf
    ТипКнига
    #1142198
    страница21 из 22
    1   ...   14   15   16   17   18   19   20   21   22

    Глава 5
    Хэширование
    5.1. Введение .......................... 256 5.2. Выбор хэш<функции ......... 257 5.3. Разрешение коллизий ...... 257 5.4. Анализ хэширования ........ 261
    Упражнения ............................. 263
    Литература .............................. 263

    Хэширование
    256
    5.1. Введение
    В главе 4 подробно обсуждалась следующая основная проблема: если задан набор элементов, характеризующихся ключом (который определяет отношение поряд%
    ка), то как организовать этот набор, чтобы извлечение элемента с заданным клю%
    чом требовало наименьших усилий? Ясно, что в конечном счете доступ к каждому элементу в памяти компьютера осуществляется указанием его адреса в памяти.
    Поэтому вышеуказанная проблема по сути сводится к нахождению подходящего отображения
    H
    ключей (
    K
    ) в адреса (
    A
    ):
    H: K
    → A
    В главе 4 это отображение реализовывалось с помощью различных алгоритмов поиска в списках и деревьях на основе разных способов организации данных.
    Здесь мы опишем еще один подход, простой по сути и во многих случаях очень эффективный. Затем мы обсудим и некоторые его недостатки.
    В этом методе данные организуются с помощью массива. Поэтому
    H
    является отображением, преобразующим ключи в индексы массива, откуда и происходит название преобразование ключей, нередко используемое для этого метода. Заме%
    тим, что здесь нам не понадобятся процедуры динамического размещения; массив является одной из фундаментальных, статических структур. Метод преобра%
    зования ключей часто используют в тех задачах, где с примерно равным успехом можно применить и деревья.
    Фундаментальная трудность при использовании преобразования ключей заключается в том, что множество возможных значений ключей гораздо больше,
    чем множество доступных адресов в памяти (индексов массива). К примеру,
    возьмем имена длиной до 16 букв в качестве ключей, идентифицирующих отдель%
    ных людей во множестве из тысячи человек. Здесь есть
    26 16
    возможных значений ключей, которые нужно отобразить на
    10 3
    возможных индексов. Очевидно, что функция
    H
    отображает несколько значений аргументов в одно значение индекса.
    Если задан ключ k
    , то первый шаг операции поиска состоит в вычислении соот%
    ветствующего индекса h = H(k)
    , а второй – очевидно, обязательный – шаг состоит в проверке того, действительно ли элемент с ключом k
    соответствует элементу массива (таблицы)
    T
    с индексом h
    , то есть выполняется ли равенство
    T[H(k)].key = k
    Мы сразу сталкиваемся с двумя вопросами:
    1. Какую функцию
    H
    надо взять?
    2. Что делать, если
    H не смогла вычислить адрес искомого элемента?
    Ответ на второй вопрос состоит в том, чтобы использовать метод, который даст альтернативную позицию, скажем индекс h'
    , и если там по%прежнему нет иско%
    мого элемента, то третий индекс h"
    , и т. д. (Такие попытки обозначаются ниже как
    пробы (probe) – прим. перев.) Ситуацию, когда в вычисленной позиции находится элемент, отличный от искомого, называют коллизией; задача порождения альтер%
    нативных индексов называется разрешением коллизий. Далее мы обсудим выбор функции преобразования ключей и методы разрешения коллизий.

    257
    5.2. Выбор хэшGфункции
    Хорошая функция преобразования ключей должна обеспечивать как можно бо%
    лее равномерное распределение ключей по всему диапазону значений индекса.
    Других ограничений на распределение нет, но на самом деле желательно, чтобы оно казалось совершенно случайным. Это свойство дало методу несколько нена%
    учное название хэширование (hashing от англ. «превращать в фарш» и «мешани%
    на» – прим. перев.).
    H называется хэшфункцией. Очевидно, эта функция должна допускать эффективное вычисление, то есть состоять из очень небольшого числа основных арифметических операций.
    Предположим, что имеется функция преобразования
    ORD(k)
    , которая вычис%
    ляет порядковый номер ключа k
    во множестве всех возможных ключей. Кроме того, предположим, что индекс массива i
    принимает значения в диапазоне целых чисел
    0 .. N–1
    , где
    N
    – размер массива. Тогда есть очевидный вариант:
    H(k) = ORD(k) MOD N
    Такой выбор обеспечивает равномерное распределение ключей по диапазону индексов и поэтому является основой большинства хэш%функций. Это выраже%
    ние очень быстро вычисляется, если
    N
    есть степень 2, но именно этого случая сле%
    дует избегать, если ключи являются последовательностями букв. Предположе%
    ние, что все ключи равно вероятны, в этом случае неверно, и на самом деле слова,
    отличающиеся лишь немногими буквами, будут с большой вероятностью отобра%
    жаться на одно и то же значение индекса, так что получится весьма неоднородное распределение. Поэтому особенно рекомендуется в качестве значения
    N
    выбирать простое число [5.2]. Как следствие придется использовать полную операцию де%
    ления, которую нельзя заменить простым отбрасыванием двоичных цифр, но это не является проблемой на большинстве современных компьютеров, имеющих встроенную инструкцию деления.
    Часто используют хэш%функции, состоящие в применении логических опера%
    ций, таких как исключающее «или», к некоторым частям ключа, представленного как последовательность двоичных цифр. На некоторых компьютерах эти опера%
    ции могут выполняться быстрее, чем деление, но иногда они приводят к удиви%
    тельно неоднородному распределению ключей по диапазону индексов. Поэтому мы воздержимся от дальнейшего обсуждения таких методов.
    5.3. Разрешение коллизий
    Если оказывается, что элемент таблицы, соответствующий данному ключу, не яв%
    ляется искомым элементом, то имеет место коллизия, то есть у двух элементов ключи отображаются на одно значение индекса. Тогда нужна вторая проба с неко%
    торым значением индекса, полученным из данного ключа детерминированным способом. Есть несколько способов порождения вторичных индексов. Очевидный способ – связать все элементы с одинаковым первичным индексом
    H(k)
    в связный список. Это называют прямым связыванием (direct chaining). Элементы этого списка могут находиться в первичной таблице или вне ее; во втором случае об%
    Разрешение коллизий

    Хэширование
    258
    ласть памяти, где они размещаются, называется областью переполнения (overflow area). Недостатки этого метода – необходимость поддерживать вторичные спис%
    ки, а также что каждый элемент таблицы должен содержать указатель (или ин%
    декс) на список конфликтующих элементов.
    Альтернативный способ разрешения коллизий состоит в том, чтобы вообще отказаться от списков и просто перебирать другие элементы в той же таблице,
    пока не будет найден искомый элемент либо пустая позиция, что означает отсут%
    ствие указанного ключа в таблице. Такой метод называется открытой адресацией
    (open addressing [5.3]). Естественно, последовательность индексов во вторичных попытках должна быть всегда одной и той же для заданного ключа. Тогда алго%
    ритм поиска в таблице может быть кратко описан следующим образом:
    h := H(k); i := 0;
    REPEAT
    IF T[h].key = k THEN
       
    ELSIF T[h].key = free THEN
          
    ELSE (* *)
    i := i+1; h := H(k) + G(i)
    END
    UNTIL
          (    )
    В литературе предлагались разные функции для разрешения коллизий. Обзор темы, сделанный Моррисом в 1968 г. [4.8], вызвал значительную активность в этой области. Простейший метод – проверить соседнюю позицию (считая таблицу циклической), пока не будет найден либо элемент с указанным ключом,
    либо пустая позиция. Таким образом,
    G(i) = i
    ; в этом случае индексы h
    i
    , исполь%
    зуемые для поиска, даются выражениями h
    0
    = H(k)
    h i
    = (h i–1
    + i) MOD N,i = 1 ... N–1
    Этот способ называется методом линейных проб (linear probing). Его недоста%
    ток – тенденция элементов к скучиванию вблизи первичных ключей (то есть клю%
    чей, не испытавших коллизии при вставке). Конечно, в идеале функция
    G
    должна тоже распределять ключи равномерно по множеству свободных позиций. Однако на практике это довольно сложно обеспечить, и здесь предпочитают компромисс%
    ные методы, которые не требуют сложных вычислений, но все же работают лучше,
    чем линейная функция. Один из них состоит в использовании квадратичной фун%
    кции, так что индексы для последовательных проб задаются формулами h
    0
    = H(k)
    h i
    = (h
    0
    + i
    2
    ) MOD N, i > 0
    Заметим, что при вычислении очередного индекса можно обойтись без возве%
    дения в квадрат, если воспользоваться следующими рекуррентными соотношени%
    ями для h
    i
    = i
    2
    и d
    i
    = 2i + 1
    :
    h i+1
    = h i
    + d i
    d i+1
    = d i
    + 2, i > 0
    причем h
    0
    = 0
    и d
    0
    = 1
    . Этот способ называется методом квадратичных проб
    (quadratic probing), и он, в общем, обходит упомянутую проблему скучивания,

    259
    практически не требуя дополнительных вычислений. Незначительный недоста%
    ток здесь в том, что при последовательных пробах проверяются не все элементы таблицы, то есть при вставке можно не обнаружить свободной позиции, хотя в таблице они еще есть. На самом деле в методе квадратичных проб проверяется по крайней мере половина таблицы, если ее размер
    N
    является простым числом.
    Это утверждение можно доказать следующим образом. Тот факт, что i
    %я и j
    %я про%
    бы попадают в один элемент таблицы, выражается уравнением i
    2
    MOD N = j
    2
    MOD N
    (i
    2
    – j
    2
    )
    ≡ 0 (modulo N)
    Применяя формулу для разности квадратов, получаем
    (i + j)(i – j)
    ≡ 0 (modulo N)
    и так как i

    j
    , то заключаем, что хотя бы одно из чисел i
    или j
    должно быть не меньше
    N/2
    , чтобы получить i+j = c*N
    с целым c
    . На практике этот недостаток не важен, так как необходимость выполнять
    N/2
    вторичных проб при разрешении коллизий случается крайне редко, и только если таблица уже почти полна.
    В качестве применения описанной техники перепишем процедуру порожде%
    ния перекрестных ссылок из раздела 4.4.3. Главные отличия – в процедуре search и в замене указательного типа
    Node глобальной хэш%таблицей слов
    T
    . Хэш%функ%
    ция
    H
    вычисляется как остаток от деления на размер таблицы; для разрешения коллизий применяются квардатичные пробы. Подчеркнем, что для хорошей производительности важно, чтобы размер таблицы был простым числом.
    Хотя метод хэширования весьма эффективен в этом случае, – даже более эф%
    фективен, чем методы, использующие деревья, – у него есть и недостаток. Про%
    смотрев текст и собрав слова, мы, вероятно, захотим создать из них алфавитный список. Это несложно, если данные организованы в виде дерева, потому что прин%
    цип упорядоченности – основа этого способа организации. Однако простота теря%
    ется, если используется хэширование. Здесь и проявляется смысл слова «хэширо%
    вание». Для печати таблицы придется не только выполнить сортировку (которая здесь не показана), но оказывается даже предпочтительным отслеживать вставляе%
    мые ключи, явным образом связывая их в список. Поэтому высокая производитель%
    ность метода хэширования при поиске частично компенсируется дополнительны%
    ми операциями, необходимыми для завершения полной задачи порождения упорядоченного указателя перекрестных ссылок.
    CONST N = 997; (* ,     *)
    (*ADruS53_CrossRef*)
    WordLen = 32; (*      *)
    Noc = 16; (* .       *)
    TYPE
    Word = ARRAY WordLen OF CHAR;
    Table = POINTER TO ARRAY N OF
    RECORD key: Word; n: INTEGER;
    lno: ARRAY Noc OF INTEGER
    END;
    VAR line: INTEGER;
    Разрешение коллизий

    Хэширование
    260
    PROCEDURE search (T: Table; VAR a: Word);
    VAR i, d: INTEGER; h: LONGINT; found: BOOLEAN;
    (*   #       line*)
    BEGIN
    (*   v–   h  a*)
    i := 0; h := 0;
    WHILE a[i] > 0X DO h := (256*h + ORD(a[i])) MOD N; INC(i) END;
    d := 1; found := FALSE;
    REPEAT
    IF T[h].key = a THEN (*   *)
    found := TRUE; T[h].lno[T[h].n] := line;
    IF T[h].n < Noc THEN INC(T[h].n) END
    ELSIF T[h].key[0] = " " THEN (*      *)
    found := TRUE; COPY(a, T[h].key); T[h].lno[0] := line; T[h].n := 1
    ELSE (* *) h := h+d; d := d+2;
    IF h >= N THEN h := h–N END;
    IF d = N THEN Texts.WriteString(W,"      "); HALT(88)
    END
    END
    UNTIL found
    END search;
    PROCEDURE Tabulate (T: Table);
    VAR i, k: INTEGER;
    (*     #   ˆ    W*)
    BEGIN
    FOR k := 0 TO N–1 DO
    IF T[k].key[0] # " " THEN
    Texts.WriteString(W, T[k].key); Texts.Write(W, TAB);
    FOR i := 0 TO T[k].n –1 DO Texts.WriteInt(W, T[k].lno[i], 4) END;
    Texts.WriteLn(W)
    END
    END
    END Tabulate;
    PROCEDURE CrossRef (VAR R: Texts.Reader);
    VAR i: INTEGER; ch: CHAR; w: Word;
    H: Table;
    BEGIN
    NEW(H); (*       v– *)
    FOR i := 0 TO N–1 DO H[i].key[0] := " " END;
    line := 0;
    Texts.WriteInt(W, 0, 6); Texts.Write(W, TAB); Texts.Read(R, ch);
    WHILE

    R.eot DO
    IF ch = 0DX THEN (*  *) Texts.WriteLn(W);
    INC(line); Texts.WriteInt(W, line, 6); Texts.Write(W, 9X); Texts.Read(R, ch)
    ELSIF ("A" <= ch) & (ch <= "Z") OR ("a" <= ch) & (ch <= "z") THEN
    i := 0;
    REPEAT
    IF i < WordLen–1 THEN w[i] := ch; INC(i) END;
    Texts.Write(W, ch); Texts.Read(R, ch)
    UNTIL (i = WordLen–1) OR (("A" <= ch) & (ch <= "Z")) &

    261
    (("a" <= ch) & (ch <= "z")) & (("0" <= ch) & (ch <= "9"));
    w[i] := 0X; (*      *)
    search(H, w)
    ELSE Texts.Write(W, ch); Texts.Read(R, ch)
    END;
    Texts.WriteLn(W); Texts.WriteLn(W); Tabulate(H)
    END
    END CrossRef
    5.4. Анализ хэширования
    Производительность вставки и поиска в методе хэширования для худшего случая,
    очевидно, ужасная. Ведь нельзя исключать, что аргумент поиска таков, что все пробы пройдут в точности по занятым позициям, ни разу не попав в нужные (или свободные). Нужно иметь большое доверие законам теории вероятности, чтобы применять технику хэширования. Здесь нужна уверенность в том, что в среднем число проб мало. Приводимые ниже вероятностные аргументы показывают, что это число не просто мало, а очень мало.
    Снова предположим, что все возможные значения ключей равновероятны и что хэш%функция
    H
    распределяет их равномерно по диапазону индексов таблицы.
    Еще предположим, что некоторый ключ вставляется в таблицу размера
    N
    , уже со%
    держащую k
    элементов. Тогда вероятность попадания в свободную позицию с первого раза равна
    (N–k)/N
    . Этой же величине равна вероятность p
    1
    того, что будет достаточно одного сравнения. Вероятность того, что понадобится в точно%
    сти еще одна проба, равна вероятности коллизии на первой попытке, умноженной на вероятность попасть в свободную позицию на второй. В общем случае получа%
    ем вероятность p
    i вставки, требующей в точности i
    проб:
    p
    1
    = (N–k)/N
    p
    2
    = (k/N)
    × (N–k)/(N–1)
    p
    3
    = (k/N)
    × (k–1)/(N–1) × (N–k)/(N–2)
    ………
    p i
    = (k/N)
    × (k–1)/(N–1) × (k–2)/(N–2) × … × (N–k)/(N–(i–1))
    Поэтому среднее число
    E
    проб, необходимых для вставки k+1
    %го ключа, равно
    E
    k+1
    = S
    S
    S
    S
    Si: 1
    ≤ i ≤ k+1 : i × p i
    = 1
    × (N–k)/N + 2 × (k/N) × (N–k)/(N–1) + ...
    + (k+1) * (k/N)
    × (k–1)/(N–1) × (k–2)/(N–2) × … × 1/(N–(k–1))
    = (N+1) / (N–(k–1))
    Поскольку число проб для вставки элемента совпадает с числом проб для его поиска, этот результат можно использовать для вычисления среднего числа
    E
    проб, необходимых для доступа к случайному ключу в таблице. Пусть снова раз%
    мер таблицы обозначен как
    N
    , и пусть m
    – число ключей уже в таблице. Тогда
    E = (S
    S
    S
    S
    Sk: 1
    ≤ k ≤ m : E
    k
    ) / m
    = (N+1)
    × (SSSSSk: 1 ≤ k ≤ m : 1/(N–k+2))/m
    = (N+1)
    × (H
    N+1
    – H
    N–m+1
    )
    Анализ хэширования

    Хэширование
    262
    где
    H
    – гармоническая функция.
    H
    можно аппроксимировать как
    H
    N
    = ln(N) + g
    ,
    где g
    – постоянная Эйлера. Далее, если ввести обозначение a
    для отношения m/
    (N+1)
    , то получаем
    E = (ln(N+1) – ln(N–m+1))/a = ln((N+1)/(N–m+1))/a = –ln(1–a)/a
    Величина a
    примерно равна отношению занятых и сво%
    бодных позиций; это отношение называется коэффициен
    том заполнения (load factor); a = 0
    соответствует пустой таблице, a = N/(N+1)

    1
    – полной. Среднее число
    E
    проб для поиска или вставки случайного ключа дано в табл. 5.1
    как функция коэффициента заполнения.
    Числа получаются удивительные, и они объясняют ис%
    ключительно высокую производительность метода преоб%
    разования ключей. Даже если таблица заполнена на 90%, в среднем нужно только 2,56 пробы, чтобы найти искомый ключ или свободную позицию. Особо подчеркнем, что это число не зависит от абсолютного числа ключей, а только от коэффициента заполнения.
    Приведенный анализ предполагает, что применяемый метод разрешения коллизий равномерно рассеивает ключи по оставшимся пози%
    циям. Методы, используемые на практике, дают несколько худшую производи%
    тельность. Детальный анализ метода линейных проб дает следующий результат для среднего числа проб:
    E = (1 – a/2) / (1 – a)
    Некоторые численные значения
    E(a)
    приведены в табл. 5.2 [5.4].
    Результаты даже для простейшего способа разрешения коллизий настолько хороши, что есть соблазн рассматривать хэширование как панацею на все случаи жизни. Тем более что его производительность превышает даже самые изощрен%
    ные из обсуждавшихся методов с использованием деревьев, по крайней мере с точки зрения числа сравнений, необходимых для поиска и вставки. Но именно поэтому важно явно указать некоторые недостатки хэширования, даже если они очевидны при непредвзятом анализе.
    Разумеется, серьезным недостатком по сравнению с методами с динамическим размещением являются фиксированный размер таблицы и невозможность изме%
    нять его в соответствии с текущей необходимостью.
    Поэтому обязательно нужна достаточно хорошая ап%
    риорная оценка числа обрабатываемых элементов дан%
    ных, если неприемлемы плохое использование памяти или низкая производительность (или переполнение таблицы). Даже если число элементов известно точ%
    но, – что бывает крайне редко, – стремление к хорошей производительности заставляет выбирать таблицу не%
    много большего размера (скажем, на 10%).
    Второй серьезный недостаток методов «рассеянно%
    го хранения» становится очевидным, если ключи нуж%
    Таблица 5.1.
    Таблица 5.1.
    Таблица 5.1.
    Таблица 5.1.
    Таблица 5.1. Среднее число проб
    E как функция коэффици:
    ента заполнения a
    a
    E
    0.1 1.05 0.25 1.15 0.5 1.39 0.75 1.85 0.9 2.56 0.95 3.15 0.99 4.66
    Таблица 5.2.
    Таблица 5.2.
    Таблица 5.2.
    Таблица 5.2.
    Таблица 5.2. Среднее число проб для метода линейных проб a
    E
    0.1 1.06 0.25 1.17 0.5 1.50 0.75 2.50 0.9 5.50 0.95 10.50

    263
    но не только вставлять и искать, но и удалять. Удаление элементов в хэш%табли%
    це – чрезвычайно громоздкая операция, если только не использовать прямое свя%
    зывание в отдельной области переполнения. Поэтому разумно заключить, что древесные способы организации по%прежнему привлекательны и даже предпоч%
    тительны, если объем данных плохо предсказуем, сильно меняется и даже может уменьшаться.
    1   ...   14   15   16   17   18   19   20   21   22


    написать администратору сайта