Глава 11 представление множеств хеширование


Содержание

Класс для представления множества целых чисел на основе хеширования со связанными цепочками

25.12.2020, 16:03

Определить класс Set на основе множества целых чисел
Определить класс Set на основе множества целых чисел, n = размер. Создать методы для определения.

Реализация хеширования с цепочками переполнения
Надо писать курсач. Хеширование с цепочками переполнения на Delphi. Ничего не знаю. Нашёл я вот.

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

ООП — Класс для представления комплексных чисел
Составить описание класса для представления комплекстных чисел с возможностью задания вещественной.

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

Глава 11. Сжатие данных.

Глава 11. Сжатие данных.

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

Рассмотрим двойственность природы данных: с одной стороны, содержимое информации, а с другой — ее физическое представление. В 1950 году Клод Шеннон (Claude Shannon) заложил основы теории информации, в том числе идею о том, что данные могут быть представлены определенным минимальным количеством битов. Эта величина получила название энтропии данных (термин был заимствован из термодинамики). Шеннон установил также, что обычно количество бит в физическом представлении данных превышает значение, определяемое их энтропией.

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

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

Diplom Consult.ru

7.1. Исходные понятия

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

Задача поиска элемента по ключу , в конечном счёте, сводится к определению адреса , по которому этот элемент хранится в массиве существенно меньшего размера.

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

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

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

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

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

7.2. Методы хеширования

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

7.2.1. Хеширование методом деления

В методе деления хеш-функция задаётся как вычет ключа по модулю некоторого числа (то есть как остаток от деления нацело на ):

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

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

Максимально возможное число коллизий на единицу больше целой части дроби :

Например, если , то

и семь чисел имеют одинаковый остаток по модулю .

7.2.2. Хеширование методом умножения

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

то есть — целая часть произведения на дробную часть числа . Например, если , то , , , .

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

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

7.2.3. Универсальное хеширование

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

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

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

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

где — остаток целочисленного деления на . Количество таких функций .

Убедимся, что множество универсально.

Из теории чисел известно ([19], с. 41), что если пробегает полную систему вычетов по модулю , то соответствующие значения , в силу взаимной простоты и , также пробегают полную систему вычетов. Значения параметров и линейной функции однозначно определяются её значениями на двух различных вычетах из полной системы. Действительно, если , то , и это сравнение первой степени имеет решением единственный класс вычетов , поскольку и взаимно просты. Итак, определено однозначно. Поскольку , то по свойствам сравнений , то есть по модулю свободный член также определён однозначно.

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

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

Для ключа в множестве имеется не более чем ˥ значений , сравнимых с по модулю и отличных от . (Например, при среди чисел имеется значений, сравнимых с по модулю чисел: ; исключая , получаем ˥ значений). Применяя к выражению ˥ неравенство (14) (см. гл. 1), получаем, что количество подходящих для коллизии ключей не превосходит числа

Поскольку общее количество ключей в , отличных от , есть , то

Множества. Операции над множествами.
Отображение множеств. Мощность множества

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

Вузовский курс вышмата традиционно зиждется на трёх китах:

– математическом анализе (пределы, производные и т.д.)

– и, наконец, сезон 2015/16 учебного года открывается уроками Алгебра для чайников, Элементы математической логики, на которых мы разберём основы раздела, а также познакомимся с базовыми математическими понятиями и распространёнными обозначениями. Надо сказать, что в других статьях я не злоупотребляю «закорючками» , однако то лишь стиль, и, конечно же, их нужно узнавать в любом состоянии =). Вновь прибывшим читателям сообщаю, что мои уроки ориентированы на практику, и нижеследующий материал будет представлен именно в этом ключе. За более полной и академичной информацией, пожалуйста, обращайтесь к учебной литературе. Поехали:

Множество. Примеры множеств

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

В широком смысле, множество – это совокупность объектов (элементов), которые понимаются как единое целое (по тем или иным признакам, критериям или обстоятельствам). Причём, это не только материальные объекты, но и буквы, цифры, теоремы, мысли, эмоции и т.д.

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

– множество букв русского алфавита;
– множество натуральных чисел;

ну что же, пришла пора немного познакомиться:
– множество студентов в 1-м ряду

… я рад видеть ваши серьёзные и сосредоточенные лица =)

Множества и являются конечными (состоящими из конечного числа элементов), а множество – это пример бесконечного множества. Кроме того, в теории и на практике рассматривается так называемое пустое множество:

– множество, в котором нет ни одного элемента.

Пример вам хорошо известен – множество на экзамене частенько бывает пусто =)

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

– буква «бэ» принадлежит множеству букв русского алфавита;
– буква «бета» не принадлежит множеству букв русского алфавита;
– число 5 принадлежит множеству натуральных чисел;
– а вот число 5,5 – уже нет;
– Вольдемар не сидит в первом ряду (и тем более, не принадлежит множеству или =)).

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

– элемент принадлежит множеству .

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

– множество всех натуральных чисел, меньших ста.

Запомните: длинная вертикальная палка выражает словесный оборот «которые», «таких, что». Довольно часто вместо неё используется двоеточие: – давайте прочитаем запись более формально: «множество элементов , принадлежащих множеству натуральных чисел, таких, что ». Молодцы!

Данное множество можно записать и прямым перечислением:

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

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

Следует отметить, что элементы множества не обязаны быть «однородными» или логически взаимосвязанными. Возьмите большой пакет и начните наобум складывать в него различные предметы. В этом нет никакой закономерности, но, тем не менее, речь идёт о множестве предметов. Образно говоря, множество – это и есть обособленный «пакет», в котором «волею судьбы» оказалась некоторая совокупность объектов.

Подмножества

Практически всё понятно из самого названия: множество является подмножеством множества , если каждый элемент множества принадлежит множеству . Иными словами, множество содержится во множестве :

Значок называют значком включения.

Вернёмся к примеру, в котором – это множество букв русского алфавита. Обозначим через – множество его гласных букв. Тогда:

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

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

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

Множество студентов другого ВУЗа следует изобразить кругом, который не пересекает внешний круг; множество студентов страны – кругом, который содержит в себе оба этих круга, и т.д.

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

Числовые множества

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

Иногда к множеству натуральных чисел относят ноль.

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

, рационализаторы и лентяи записывают его элементы со значками «плюс минус»:))

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

Название множества тоже «говорящее»: целые числа – это значит, никаких дробей.

И, коль скоро, целые, то сразу же вспомним важные признаки их делимости на 2, 3, 4, 5 и 10, которые будут требоваться в практических вычислениях чуть ли не каждый день:

Целое число делится на 2 без остатка, если оно заканчивается на 0, 2, 4, 6 или 8 (т.е. любой чётной цифрой). Например, числа:
400, -1502, -24, 66996, 818 – делятся на 2 без остатка.

И давайте тут же разберём «родственный» признак: целое число делится на 4, если число, составленное из двух его последних цифр (в порядке их следования) делится на 4.

400 – делится на 4 (т.к. 00 (ноль) делится на 4);
-1502 – не делится на 4 (т.к. 02 (двойка) не делится на 4);
-24, понятно, делится на 4;
66996 – делится на 4 (т.к. 96 делится на 4);
818 – не делится на 4 (т.к. 18 не делится на 4).

Самостоятельно проведите несложное обоснование данного факта.

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

Проверим, делится ли на 3 число 27901. Для этого просуммируем его цифры:
2 + 7 + 9 + 0 + 1 = 19 – не делится на 3
Вывод: 27901 не делится на 3.

Просуммируем цифры числа -825432:
8 + 2 + 5 + 4 + 3 + 2 = 24 – делится на 3
Вывод: число -825432 делится на 3

Целое число делится на 5, если оно заканчивается пятёркой либо нулём:
775, -2390 – делятся на 5

Целое число делится на 10, если оно заканчивается на ноль:
798400 – делится на 10 (и, очевидно, на 100). Ну и, наверное, все помнят – для того, чтобы разделить на 10, нужно просто убрать один ноль: 79840

Также существуют признаки делимости на 6, 8, 9, 11 и т.д., но практического толку от них практически никакого =)

Следует отметить, что перечисленные признаки (казалось бы, такие простые) строго доказываются в теории чисел. Этот раздел алгебры вообще достаточно интересен, однако его теоремы… прямо современная китайская казнь =) А Вольдемару за последней партой и того хватило…, но ничего страшного, скоро мы займёмся живительными физическими упражнениями =)

Следующим числовым множеством идёт множество рациональных чисел:
– то есть, любое рациональное число представимо в виде дроби с целым числителем и натуральным знаменателем.

Очевидно, что множество целых чисел является подмножеством множества рациональных чисел:

И в самом деле – ведь любое целое число можно представить в виде рациональной дроби , например: и т.д. Таким образом, целое число можно совершенно законно назвать и рациональным числом.

Характерным «опознавательным» признаком рационального числа является то обстоятельство, что при делении числителя на знаменатель получается либо
– целое число,

либо
конечная десятичная дробь,

либо
– бесконечная периодическая десятичная дробь (повтор может начаться не сразу).

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

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

Согласитесь, что иметь дело с дробью значительно удобнее, чем с десятичным числом 0,375 (не говоря уже о бесконечных дробях).

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

О знаменитых константах «пи» и «е» информации предостаточно, поэтому на них я не останавливаюсь.

Объединение рациональных и иррациональных чисел образует множество действительных (вещественных) чисел:

– значок объединения множеств.

Геометрическая интерпретация множества вам хорошо знакома – это числовая прямая:

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

Числовую прямую также обозначают бесконечным интервалом , а запись или эквивалентная ей запись символизирует тот факт, что принадлежит множеству действительных чисел (или попросту «икс» – действительное число).

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

Множество иррациональных чисел – это тоже подмножество действительных чисел:

При этом подмножества и не пересекаются – то есть ни одно иррациональное число невозможно представить в виде рациональной дроби.

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

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

Действия над множествами. Диаграммы Венна

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

1) Пересечение множеств характеризуется логической связкой И и обозначается значком

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

Так, например, для множеств :

Если у множеств нет одинаковых элементов, то их пересечение пусто. Такой пример нам только что встретился при рассмотрении числовых множеств:

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

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

2) Объединение множеств характеризуется логической связкой ИЛИ и обозначается значком

Объединением множеств и называется множество , каждый элемент которого принадлежит множеству или множеству :

Запишем объединение множеств :
– грубо говоря, тут нужно перечислить все элементы множеств и , причём одинаковые элементы (в данном случае единица на пересечении множеств) следует указать один раз.

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

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

Операция объединения применима и для бОльшего количества множеств, например, если , то:

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

3) Разностью множеств и называют множество , каждый элемент которого принадлежит множеству и не принадлежит множеству :

Разность читаются следующим образом: «а без бэ». И рассуждать можно точно так же: рассмотрим множества . Чтобы записать разность , нужно из множества «выбросить» все элементы, которые есть во множестве :

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

Зеркально: разностью множеств и называют множество , каждый элемент которого принадлежит множеству и не принадлежит множеству :

Для тех же множеств
– из множества «выброшено» то, что есть во множестве .

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

Кроме того, иногда рассматривают симметрическую разность , которая объединяет оба «полумесяца»:
– иными словами, это «всё, кроме пересечения множеств».

4) Декартовым (прямым) произведением множеств и называется множество всех упорядоченных пар , в которых элемент , а элемент

Запишем декартово произведение множеств :
– перечисление пар удобно осуществлять по следующему алгоритму: «сначала к 1-му элементу множества последовательно присоединяем каждый элемент множества , затем ко 2-му элементу множества присоединяем каждый элемент множества , затем к 3-му элементу множества присоединяем каждый элемент множества »:

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

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

А теперь гвоздь программы: декартово произведение – это есть не что иное, как множество точек нашей родной декартовой системы координат .

Задание для самостоятельного закрепления материала:

Выполнить операции , если:

Множество удобно расписать перечислением его элементов.

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

Напоминаю, что квадратная скобка означает включение числа в промежуток, а круглая – его невключение, то есть «минус единица» принадлежит множеству , а «тройка» не принадлежит множеству . Постарайтесь разобраться, что представляет собой декартово произведение данных множеств. Если возникнут затруднения, выполните чертёж ;)

Краткое решение задачи в конце урока.

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

Отображение множества во множество – это правило, по которому каждому элементу множества ставится в соответствие элемент (или элементы) множества . В том случае если в соответствие ставится единственный элемент, то данное правило называется однозначно определённой функцией или просто функцией.

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

Ну а сейчас я снова побеспокою множество студентов 1-го ряда и предложу им 6 тем для рефератов (множество ):

Установленное (добровольно или принудительно =)) правило ставит в соответствие каждому студенту множества единственную тему реферата множества .

…а вы, наверное, и представить себе не могли, что сыграете роль аргумента функции =) =)

Элементы множества образуют область определения функции (обозначается через ), а элементы множества – область значений функции (обозначается через ).

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

Однако не следует думать, что всякое отображение биективно. Если на 1-й ряд (к множеству ) добавить 7-го студента, то взаимно-однозначное соответствие пропадёт – либо один из студентов останется без темы (отображения не будет вообще), либо какая-то тема достанется сразу двум студентам. Обратная ситуация: если к множеству добавить седьмую тему, то взаимнооднозначность отображения тоже будет утрачена – одна из тем останется невостребованной.

Уважаемые студенты на 1-м ряду, не расстраивайтесь – остальные 20 человек после пар пойдут прибирать территорию университета от осенней листвы. Завхоз выдаст двадцать голиков, после чего будет установлено взаимно-однозначное соответствие между основной частью группы и мётлами…, а Вольдемар ещё и в магазин сбегать успеет =)

Теперь разберёмся со «школьной» функцией одной переменной. Пожалуйста, загляните на страницу Функции и графики (отроется на соседней вкладке), и в Примере 1 найдите график линейной функции .

Задумаемся, что это такое? Это правило , которое каждому элементу области определения (в данном случае это все значения «икс») ставит в соответствие единственное значение . С теоретико-множественной точки зрения, здесь происходит отображение множества действительных чисел во множество действительных чисел:

Первое множество мы по-обывательски называем «иксами» (независимая переменная или аргумент), а второе – «игреками» (зависимая переменная или функция ).

Далее взглянем на старую знакомую параболу . Здесь правило каждому значению «икс» ставит в соответствие его квадрат, и имеет место отображение:

Итак, что же такое функция одной переменной? Функция одной переменной – это правило , которое каждому значению независимой переменной из области определения ставит в соответствие одно и только одно значение .

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

! На всякий случай ликвидирую возможное недопонимание: моя постоянная оговорка об области определения не случайна! Функция может быть определена далеко не при всех «икс», и, кроме того, может быть взаимно-однозначной и в этом случае. Типичный пример:

А вот у квадратичной функции не наблюдается ничего подобного, во-первых:
– то есть, различные значения «икс» отобразились в одно и то же значение «игрек»; и во-вторых: если кто-то вычислил значение функции и сообщил нам, что , то не понятно – этот «игрек» получен при или при ? Что и говорить, взаимной однозначностью здесь даже не пахнет.

Задание 2: просмотреть графики основных элементарных функций и выписать на листок биективные функции. Список для сверки в конце этого урока.

Мощность множества

Интуиция подсказывает, что термин характеризует размер множества, а именно количество его элементов. И интуиция нас не обманывает!

Мощность пустого множества равна нулю.

Мощность множества равна шести.

Мощность множества букв русского алфавита равна тридцати трём.

И вообще – мощность любого конечного множества равно количеству элементов данного множества.

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

Само собой, множества можно сравнивать по мощности и их равенство в этом смысле называется равномощностью. Равномощность определяется следующим образом:

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

Множество студентов равномощно множеству тем рефератов, множество букв русского алфавита равномощно любому множеству из 33 элементов и т.д. Заметьте, что именно любому множеству из 33 элементов – в данном случае имеет значение лишь их количество. Буквы русского алфавита можно сопоставить не только с множеством номеров
1, 2, 3, …, 32, 33, но и вообще со стадом в 33 коровы.

Гораздо более интересно обстоят дела с бесконечными множествами. Бесконечности тоже бывают разными! . зелёными и красными Самые «маленькие» бесконечные множества – это счётные множества. Если совсем просто, элементы такого множества можно пронумеровать. Эталонный пример – это множество натуральных чисел . Да – оно бесконечно, однако у каждого его элемента в ПРИНЦИПЕ есть номер.

Примеров очень много. В частности, счётным является множество всех чётных натуральных чисел . Как это доказать? Нужно установить его взаимно-однозначное соответствие с множеством натуральных чисел или попросту пронумеровывать элементы:

Взаимно-однозначное соответствие установлено, следовательно, множества равномощны и множество счётно. Парадоксально, но с точки зрения мощности – чётных натуральных чисел столько же, сколько и натуральных!

Множество целых чисел тоже счётно. Его элементы можно занумеровать, например, так:

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

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

Поскольку между множеством и числовой прямой существует взаимно-однозначное соответствие (см. выше), то множество точек числовой прямой тоже несчётно. И более того, что на километровом, что на миллиметровом отрезке – точек столько же! Классический пример:

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

Данный парадокс, видимо, связан с загадкой бесконечности… но мы сейчас не будем забивать голову проблемами мироздания, ибо на очереди основы математической логики, а не философия =)

Спасибо за внимание и успехов вам в учёбе!

Задание 1

2)
– это множество нечётных натуральных чисел:

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

Задание 2 Взаимно-однозначные функции на иллюстрациях урока Функции и графики:

Хеширование (стр. 1 из 4)

Министерство Образования РФ

Воронежский государственный университет

Факультет Компьютерных наук

Кафедра программирования и информационных технологий

по курсу «Технологии программирования» по теме

Выполнил: студент 3 его курса

Проверил: доцент каф. ПиИТ

Хлебостроев Виктор Григорьевич

Метод деления_ 4

Метод умножения (мультипликативный) 5

Динамическое хеширование 5

Расширяемое хеширование (extendible hashing) 7

Функции, сохраняющие порядок ключей (Order preserving hash functions) 8

Минимальное идеальное хеширование 8

Разрешение коллизий_ 10

Метод цепочек_ 10

Открытая адресация_ 10

Линейная адресация 11

Квадратичная и произвольная адресация 11

Адресация с двойным хешированием_ 11

Удаление элементов хеш-таблицы_ 12

Применение хеширования_ 13

Хеширование паролей_ 13

Приложение (демонстрационная программа) 15

Список литературы: 16

С хешированием мы сталкиваемся едва ли не на каждом шагу: при работе с браузером (список Web-ссылок), текстовым редактором и переводчиком (словарь), языками скриптов (Perl, Python, PHP и др.), компилятором (таблица символов). По словам Брайана Кернигана, это «одно из величайших изобретений информатики». Заглядывая в адресную книгу, энциклопедию, алфавитный указатель, мы даже не задумываемся, что упорядочение по алфавиту является не чем иным, как хешированием.

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

Термин «хеширование» (hashing) в печатных работах по программированию появился сравнительно недавно (1967 г., [1]), хотя сам механизм был известен и ранее. Глагол «hash» в английском языке означает «рубить, крошить». Для русского языка академиком А.П. Ершовым [2] был предложен достаточно удачный эквивалент — «расстановка», созвучный с родственными понятиями комбинаторики, такими как «подстановка» и «перестановка». Однако он не прижился.

Как отмечает Дональд Кнут [3], идея хеширования впервые была высказана Г.П. Ланом при создании внутреннего меморандума IBM в январе 1953 г. с предложением использовать для разрешения коллизий хеш-адресов метод цепочек. Примерно в это же время другой сотрудник IBM – Жини Амдал – высказала идею использования открытую линейную адресацию. В открытой печати хеширование впервые было описано Арнольдом Думи (1956), указавшим, что в качестве хеш-адреса удобно использовать остаток от деления на простое число. А. Думи описывал метод цепочек для разрешения коллизий, но не говорил об открытой адресации. Подход к хешированию, отличный от метода цепочек, был предложен А.П. Ершовым (1957, [2]), который разработал и описал метод линейной открытой адресации. Среди других исследований можно отметить работу Петерсона (1957, [4]). В ней реализовывался класс методов с открытой адресацией при работе с большими файлами. Петерсон определил открытую адресацию в общем случае, проанализировал характеристики равномерного хеширования, глубоко изучил статистику использования линейной адресации на различных задачах. В 1963 г. Вернер Букхольц [6] опубликовал наиболее основательное исследование хеш-функций.


К концу шестидесятых годов прошлого века линейная адресация была единственным типом схемы открытой адресации, описанной в литературе, хотя несколькими исследователями независимо была разработана другая схема, основанная на неоднократном случайном применении независимых хеш-функции ([3], стр. 585). В течение нескольких последующих лет хеширование стало широко использоваться, хотя не было опубликовано никаких новых работ. Затем Роберт Моррис [5] обширный обзор по хешированию и ввел термин «рассеянная память» (scatter storage). Эта работа привела к созданию открытой адресации с двойным хешированием.

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

Илон Маск рекомендует:  Что такое код mkdir

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

Коллизия – это ситуация, когда h(K1) = h(K2), в то время как K1 ≠ K2. В этом случае, очевидно, необходимо найти новое место для хранения данных. Очевидно, что количество коллизий необходимо минимизировать. Методикам разрешения коллизий будет посвящен отдельный раздел ниже.

Хорошая хеш-функция должна удовлетворять двум требованиям:

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

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

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

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

Метод деления весьма прост – используется остаток от деления на M:

Надо тщательно выбирать эту константу. Если взять ее равной 100, а ключом будет случить год рождения, то распределение будет очень неравномерным для ряда задач (идентификация игроков юношеской бейсбольной лиги, например). Более того, при четной константе значение функции будет четным при четном K и нечетным — при нечетном, что приведет к нежелательному результату. Еще хуже обстоят дела, если M – это степень счисления компьютера, поскольку при этом результат будет зависеть только от нескольких цифр ключа справа. Точно также можно показать, что M не должно быть кратно трем, поскольку при буквенных ключах два из них, отличающиеся только перестановкой букв, могут давать числовые значения с разностью, кратной трем (см. [3], стр. 552). Приведенные рассуждения приводят к мысли, что лучше использовать простое число. В большинстве случаев подобный выбор вполне удовлетворителен.

Другой пример – ключ, являющийся символьной строкой С++. Хеш-функция отображает эту строку в целое число посредством суммирования первого и последнего символов и последующего вычисления остатка от деления на 101 (размер таблицы). Эта хеш-функция приводит к коллизии при одинаковых первом и последнем символах строки. Например, строки «start» и «slant» будут отображаться в индекс 29. Так же ведет себя хеш-функция, суммирующая все символы строки. Строки «bad» и «dab» преобразуются в один и тот же индекс. Лучшие результаты дает хеш-функция, производящая перемешивание битов в символах.

На практике, метод деления – самый распространенный [7].

Для мультипликативного хеширования используется следующая формула:

h(K) = [M * ((C * K) mod 1)]

Здесь производится умножение ключа на некую константу С, лежащую в интервале [0..1]. После этого берется дробная часть этого выражения и умножается на некоторую константу M, выбранную таким образом, чтобы результат не вышел за границы хеш-таблицы. Оператор [ ] возвращает наибольшее целое, которое меньше аргумента.

Если константа С выбрана верно, то можно добиться очень хороших результатов, однако, этот выбор сложно сделать. Дональд Кнут (см. [3], стр. 553) отмечает, что умножение может иногда выполняться быстрее деления.

Мультипликативный метод хорошо использует то, что реальные файлы неслучайны. Например, часто множества ключей представляют собой арифметические прогрессии, когда в файле содержатся ключи . Например, рассмотрим имена типа . Мультипликативный метод преобразует арифметическую прогрессию в приближенно арифметическую прогрессию h(K), h(K + d), h(K + 2d),… различных хеш-значений, уменьшая число коллизий по сравнению со случайной ситуацией. Впрочем, справедливости ради надо заметить, что метод деления обладает тем же свойством.

Множество

Определение множества

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

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

Величиной называется все что может быть измерено и выражено числом.

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

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

Если элемент x принадлежит множеству X, то записывают xХ ( — принадлежит).
Если множество А является частью множества В, то записывают А ⊂ В ( — содержится).

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

Например, перечислением заданы следующие множества:

  • А= <1,2,3,5,7>— множество чисел
  • Х=1,x2. xn> — множество некоторых элементов x1,x2. xn
  • N= <1,2. n>— множество натуральных чисел
  • Z= <0,±1,±2. ±n>— множество целых чисел

Множество (-∞;+∞) называется числовой прямой, а любое число — точкой этой прямой. Пусть a — произвольная точка числовой прямой иδ — положительное число. Интервал (a-δ; a+δ) называется δ-окрестностью точки а.

Множество Х ограничено сверху (снизу), если существует такое число c, что для любого x ∈ X выполняется неравенство x≤с (x≥c). Число с в этом случае называется верхней(нижней) гранью множества Х. Множество, ограниченное и сверху и снизу, называется ограниченным. Наименьшая (наибольшая) из верхних (нижних) граней множества называется точной верхней (нижней) гранью этого множества.

Основные числовые множества

Множество рациональных чисел.

Кроме целых чисел имеются ещё и дроби. Дробь — это выражение вида , где p — целое число, q — натуральное. Десятичные дроби также можно записать в виде . Например: 0,25 = 25/100 = 1/4. Целые числа также можно записать в виде . Например, в виде дроби со знаменателем «один»: 2 = 2/1.

Таким образом любое рациональное число можно записать десятичной дробью — конечно или бесконечной периодической.

Множество всех вещественных чисел.

Иррациональные числа — это бесконечные непериодические дроби. К ним относятся:

  • число — отношение длины окружности к её диаметру;
  • число — названное в честь Эйлера и др.;

Вместе два множества (рациональных и иррациональных чисел) — образуют множество действительных (или вещественных) чисел.

Если множество не содержит ни одного элемента, то оно называется пустым множеством и записывается Ø.

Элементы логической символики

N <1,2,3. n>Множество всех натуральных чисел
Z <0, ±1, ±2, ±3. >Множество целых чисел. Множество целых чисел включает в себя множество натуральных.
Q
«следует», «выполняется»
равносильность утверждения
: «такой, что»

Запись ∀x: |x| 2 2 Квантор

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

Квантором называется логический символ, который характеризует следующие за ним элементы в количественном отношении.

  • ∀- квантор общности, используется вместо слов «для всех», «для любого».
  • ∃- квантор существования, используется вместо слов «существует», «имеется». Используется также сочетание символов ∃!, которое читается как существует единственный.

Операции над множествами

Два множества А и В равны (А=В), если они состоят из одних и тех же элементов.
Например, если А=<1,2,3,4>, B= <3,1,4,2>то А=В.

Объединением (суммой) множеств А и В называется множество А ∪ В, элементы которого принадлежат хотя бы одному из этих множеств.
Например, если А=<1,2,4>, B=<3,4,5,6>, то А ∪ B =

Пересечением (произведением) множеств А и В называется множество А ∩ В, элементы которого принадлежат как множеству А, так и множеству В.
Например, если А=<1,2,4>, B=<3,4,5,2>, то А ∩ В =

Разностью множеств А и В называется множество АВ, элементы которого принадлежат множесву А, но не принадлежат множеству В.
Например, если А=<1,2,3,4>, B=<3,4,5>, то АВ =

Симметричной разностью множеств А и В называется множество А Δ В, являющееся объединением разностей множеств АВ и ВА, то есть А Δ В = (АВ) ∪ (ВА).
Например, если А=<1,2,3,4>, B=<3,4,5,6>, то А Δ В = <1,2>∪ <5,6>=

Свойства операций над множествами

A ∪ B = B ∪ A
A ∩ B = B ∩ A

(A ∪ B) ∪ C = A ∪ (B ∪ C)
(A ∩ B) ∩ C = A ∩ (B ∩ C)

Счетные и несчетные множества

Для того, чтобы сравнить два каких-либо множества А и В, между их элементами устанавливают соответствие.

Если это соответствие взаимооднозначное, то множества называются эквивалентными или равномощными, А В или В А.

Множество точек катета ВС и гипотенузы АС треугольника АВС являются равномощными.

Python алгоритмы

Блог про алгоритмы и все что с ними связано. Основной инструмент реализации — Python.

четверг, 16 июня 2011 г.

Представление множеств

И хотя в нашем любимом Python’е уже есть такой тип данных, как множества(set), я все таки считаю, Что изобрести велосипед заново, в данном случае будет полезно. Хотя бы чтобы просто получше понять, а как оно там вообще работает?!
Для понимания идеологии советую прочитать «Свойство замыкания на примере list»
*-Нравится статья? Кликни по рекламе! :)
Множество есть просто набор различных объектов. Чтобы дать ему более точное определение, можно использовать метод абстракции данных. А именно, мы определяем «множество», указывая операции, которые можно производить над множествами. Это операции union-set (объединение), intersection-set (пересечение), element_of_set (проверка на принадлежность) и adjoin-set (добавление элемента). Adjoin_set принимает как аргументы объект и множество, и возвращает множество, которое содержит все элементы исходного множества плюс добавленный элемент. Intersection_set вычисляет пересечение двух множеств, то есть множество, которое содержит только те элементы, которые присутствуют в обоих аргументах.

Можно представить множество как список, в котором ни один элемент не содержится более одного раза. Пустое множество представляется пустым списком.
Итак, первая функция — проверка на наличие элемента в множестве:
Используя эту процедуру, мы можем написать adjoin_set. Если объект, который требуется добавить, уже принадлежит множеству, мы просто возвращаем исходное множество. В противном случае мы добавим объект к списку.представляющему множество:
Для intersection_set можно использовать рекурсивную стратегию. Если мы знаем, как получить пересечение set2 и cdr от set1, нам нужно только понять, надо ли добавить к нему car от set1. Это зависит от того, принадлежит ли (car set1) еще и set2. Получается такая процедура:
Один из вопросов, которые должны нас заботить при разработке реализации — эффективность. Рассмотрим число шагов, которые требуют наши операции над множествами. Поскольку все они используют element_of_set, скорость этой операции оказывает большое влияние на скорость реализации в целом. Теперь заметим, что для того, чтобы проверить, является ли объект элементом множества, процедуре element_of_set может потребоваться просмотреть весь список. (В худшем случае оказывается, что объекта в списке нет.) Следовательно, если в множестве n элементов, element_of_set может затратить до n шагов. Таким образом, число требуемых шагов растет как O(n). Число шагов, требуемых adjoin_set, которая эту операцию использует, также
растет как O(n). Для intersection_set, которая проделывает element_of_set для каждого элемента set1, число требуемых шагов растет как произведение размеров исходных множеств, или O(n 2 ) для двух множеств размера n.

Множества как упорядоченные списки
Один из способов ускорить операции над множествами состоит в том, чтобы изменить
представление таким образом, чтобы элементы множества перечислялись в порядке возрастания. Для этого нам потребуется способ сравнения объектов, так, чтобы можно было
сказать, какой из них больше. Например, символы мы могли бы сравнивать лексикографически, или же мы могли бы найти какой-нибудь способ ставить каждому объекту в соответствие некоторое уникальное число и затем сравнивать объекты путем сравнения соответствующих чисел. Чтобы упростить обсуждение, мы рассмотрим только случай, когда элементами множества являются числа, так что мы сможем сравнивать элементы при помощи > и 2 ) шагов, поскольку мы производили полный поиск в set2 для каждого элемента set1. Однако при упорядоченном представлении мы можем воспользоваться более разумным методом. Начнем со сравнения
первых элементов двух множеств, x1 и x2. Если x1 равно x2, мы получаем один элемент пересечения, а остальные элементы пересечения мы можем получить, пересекая
оставшиеся элементы списков-множеств. Допустим, однако, что x1 меньше, чем x2. Поскольку x2 — наименьший элемент set2, мы можем немедленно заключить, что x1
больше нигде в set2 не может встретиться и, следовательно, не принадлежит пересечению. Следовательно пересечение двух множеств равно пересечению set2 с cdr от
set1. Подобным образом, если x2 меньше, чем x1, то пересечение множеств получается
путем пересечения set1 с cdr от set2. Вот процедура:
Чтобы оценить число шагов, необходимое для этого процесса, заметим, что на каждом шагу мы сводим задачу нахождения пересечения к вычислению пересечения меньших множеств — убирая первый элемент либо из set1, либо из set2, либо из обоих. Таким образом, число требуемых шагов не больше суммы размеров set1 и set2, а не их произведения, как при неупорядоченном представлении. Это рост O(n), а не O(n 2 ) — заметное ускорение, даже для множеств небольшого размера.

Множества как бинарные деревья
Можно добиться еще лучших результатов, чем при представлении в виде упорядоченных списков, если расположить элементы множества в виде дерева. Каждая вершина дерева содержит один элемент множества, называемый «входом» этой вершины, и указатели (возможно, пустые) на две другие вершины. «Левый» указатель указывает на элементы, меньшие, чем тот, который содержится в вершине, а «правый» на элементы, большие, чем тот, который содержится в вершине. На рисунке ниже показано несколько вариантов представления множества <1, 3, 5, 7, 9, 11>в виде дерева. Одно и то же множество может быть представлено в виде дерева несколькими различными способами. Единственное, чего мы требуем от правильного представления — это чтобы все элементы левого поддерева были меньше, чем вход вершины, а элементы правого поддеревабольше.

Преимущество древовидного представления следующее. Предположим, мы хотим
проверить, содержится ли в множестве число x. Начнем с того, что сравним x со входом
начальной вершины. Если x меньше его, то мы уже знаем, что достаточно просмотреть
только левое поддерево; если x больше, достаточно просмотреть правое поддерево. Если
дерево «сбалансировано», то каждое из поддеревьев будет по размеру примерно вполовину меньше. Таким образом, за один шаг мы свели задачу поиска в дереве размера n к задаче поиска в дереве размера n/2. Поскольку размер дерева уменьшается вдвое на каждом шаге, следует ожидать, что число шагов, требуемых для поиска в дереве размера n, растет как O(log n). Для больших множеств это будет заметным ускорением по сравнению с предыдущими реализациями.

Деревья мы можем представлять при помощи списков. Каждая вершина будет списком из трех элементов: вход вершины, левое поддерево и правое поддерево. None список на месте левого или правого поддерева будет означать, что в этом месте никакое поддерево не присоединяется.
Пример l=(7, (3,(1, None, None),(5, None, None)), (9,(None, None, None),(11, None, None)))
Можно написать процедуру element_of_set с использованием вышеописанной стратегии:
Где функция car возвращает текущий элемент, cdr — левую ветвь, а cddr — правую.
Добавление элемента к множеству реализуется похожим образом и также требует O(log n) шагов. Чтобы добавить объект x, мы сравниваем его с входом вершины и определяем, должны ли мы добавить x к левой или правой ветви, а добавив x к соответствующей ветви, мы соединяем результат с изначальным входом и второй ветвью.
Если x равен входу, мы просто возвращаем вершину. Если нам требуется добавить x к пустому дереву, мы порождаем дерево, которое содержит x на входе и пустые левое и правое поддеревья. Вот процедура:
Утверждение, что поиск в дереве можно осуществить за логарифмическое число шагов, основывается на предположении, что дерево «сбалансировано», то есть что левое и правое его поддеревья содержат приблизительно одинаковое число элементов, так что каждое поддерево содержит приблизительно половину элементов своего родителя. Но как нам добиться того, чтобы те деревья, которые мы строим, были сбалансированы?

Даже если мы начинаем со сбалансированного дерева, добавление элементов при помощи adjoin_set может дать несбалансированный результат. Поскольку позиция нового добавляемого элемента зависит от того, как этот элемент соотносится с объектами, уже содержащимися в
множестве, мы имеем право ожидать, что если мы будем добавлять элементы «случайным образом», в среднем дерево будет получаться сбалансированным. Однако такой гарантии у нас нет. Например, если мы начнем с пустого множества и будем добавлять по очереди числа от 1 до 7, то получится весьма несбалансированное дерево, показанное на рисунке 2.17. В этом дереве все левые поддеревья пусты, так что нет никакого преимущества по сравнению с
простым упорядоченным списком. Одним из способов решения этой проблемы было бы
определение операции, которая переводит произвольное дерево в сбалансированное с теми же элементами. Тогда мы сможем проводить преобразование через каждые несколько операций adjoin_set, чтобы поддерживать множество в сбалансированном виде. Есть и другие способы решения этой задачи. Большая часть из них связана с разработкой новых структур данных, для которых и поиск, и вставка могут производиться за O(log n) шагов.

Множества и поиск информации
Мы рассмотрели способы представления множеств при помощи списков и увидели, как выбор представления для объектов данных может сильно влиять на производительность программ, использующих эти данные. Еще одной причиной нашего внимания к множествам было то, что описанные здесь методы снова и снова возникают в приложениях, связанных с поиском данных.
Рассмотрим базу данных, содержащую большое количество записей, например, сведения о кадрах какой-нибудь компании или о транзакциях в торговой системе. Как правило, системы управления данными много времени проводят, занимаясь поиском и модификацией данных в записях; следовательно, им нужны эффективные методы доступа к записям. Для этого часть каждой записи выделяется как идентифицирующий ключ (key). Ключом может служить что угодно, что однозначно определяет запись. В случае записей о кадрах это может быть номер карточки сотрудника. Для торговой системы это может быть номер транзакции. Каков бы ни был ключ, когда мы определяем запись в виде структуры данных, нам нужно указать процедуру выборки ключа, котораявозвращает ключ, связанный с данной записью.
Конечно, существуют лучшие способы представить большие множества, чем в виде неупорядоченных списков. Системы доступа к информации, в которых необходим «произвольный доступ» к записям, как правило, реализуются с помощью методов, основанных
на деревьях, вроде вышеописанной системы с бинарными деревьями. При разработке таких систем методология абстракции данных оказывается весьма полезной. Проектировщик может создать исходную реализацию с помощью простого, прямолинейного представления вроде неупорядоченных списков. Для окончательной версии это не подходит, но такой вариант можно использовать как «поспешную и небрежную» реализацию базы данных, на которой тестируется остальная часть системы. Позже представление данных можно изменить и сделать более изощренным. Если доступ к базе данных происходит в терминах абстрактных селекторов и конструкторов, такое изменение представления данных не потребует никаких модификаций в остальной системе.

Что такое хеш-таблицы и как они работают

Хеш-таблица (hash table) — это специальная структура данных для хранения пар ключей и их значений. По сути это ассоциативный массив, в котором ключ представлен в виде хеш-функции.

Пожалуй, главное свойство hash-таблиц — все три операции вставка, поиск и удаление в среднем выполняются за время O(1), среднее время поиска по ней также равно O(1) и O(n) в худшем случае.

Простое представление хеш-таблиц

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

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

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

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

Учтите, что хеш-функция должна иметь следующие свойства:

  • Всегда возвращать один и тот же адрес для одного и того же ключа;
  • Не обязательно возвращает разные адреса для разных ключей;
  • Использует все адресное пространство с одинаковой вероятностью;
  • Быстро вычислять адрес.

Борьба с коллизиями (они же столкновения)

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

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

Метод цепочек

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

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

Если выбран метод цепочек, то вставка нового элемента происходит за O(1), а время поиска зависит от длины списка и в худшем случае равно O(n). Если количество ключей n , а распределяем по m -ячейкам, то соотношение n/m будет коэффициентом заполнения.

В C++ метод цепочек реализуется так:

# Проверка ячейки и создание списка

Открытая индексация (или закрытое хеширование)

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

Самая простая в реализации последовательность проб — линейное пробирование (или линейное исследование). Здесь все просто — в случае коллизии, следующие ячейки проверяются линейно, пока не будет найдена пустая ячейка.

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

Метод линейного пробирования для открытой индексации на C++:

# Проверка ячеек и вставка значения

Самое главное

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

2 примера денормализации для оптимизации базы данных

Простые и быстрые варианты переноса ключей Redis на другой сервер.

Разделение базы данных на несколько независимых баз

Типы и способы применения репликации на примере MySQL

Как решать типичные задачи с помощью NoSQL

Основные понятия о шардинге и репликации

Как строятся по-настоящему большие системы на основе MySQL

Поиск по большому количеству текста

Как делать перераспределение данных между серверами

Разделение таблиц данных на разные узлы

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

Худшие практики при работе над растущими проектами

Введение в кэширование данных на примере Memcache

Примеры использования Lua в Nginx для решения стандартных задач

Повышение скорости работы запросов с MySQL Handlersocket

Что такое индексы в Mysql и как их использовать для оптимизации запросов

Примеры использования колоночной базы данных Vertica

Как построить мини CDN на основе распределенного Nginx кеша

Работа приложения с несколькими бэкендами при помощи Nginx

Правила и практика масштабирования Твиттера

Архитектурные принципы высоконагруженных приложений

Как и зачем используются очередей сообщений

Что значит высокая нагрузка (highload) и что при этом делать?

3 аспекта эффективного мониторинга для Web приложений

indbooks

Читать онлайн книгу

Глава 7. Хеширование и хеш-таблицы

В главе 4 были рассмотрены алгоритмы поиска элемента в массиве (например, TList) или в связном списке. Наиболее быстрым из рассмотренных методов был бинарный поиск, для выполнения которого требовался отсортированный контейнер. Бинарный поиск представляет собой алгоритм класса O(log(n)). Так, чтобы установить наличие или отсутствие заданного элемента в списке из 1000 элементов, требуется выполнить приблизительно 10 сравнений (поскольку 2(^10^) = 1024). Возможен ли еще более эффективный подход?

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

Однако если бы элемент можно было связать с уникальным индексом, его можно было бы найти посредством однонаправленного действия: просто извлекая элемент, расположенный в позиции MyList[ItemIndex]. Это пример поиска с использованием индексирования по ключу, когда ключ элемента преобразуется в индекс, и элемент извлекается из массива с помощью этого индекса. Такой подход кардинально отличается от бинарного поиска, при котором, по существу, ключ элемента используется для перемещения по структуре с применением метода, в основе которого лежит сравнение.

Преобразование ключа элемента в значение индекса называется хешированием (hashing) и оно выполняется с помощью функции хеширования (hash function). Массив, используемый для хранения элементов, с которым используется значение индекса, называют хеш-таблицей (hash table).

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

Хеш-таблица — прекрасный пример достижения компромисса между быстродействием и занимаемым объемом памяти. Если бы ключи элементов были уникальными значениями типа word, нужно было бы всего лишь создать 65536 элементов, и при этом можно было бы гарантировать нахождение элемента с конкретным ключом в результате выполнения одной операции. Однако если нужно хранить, скажем, не более 100 элементов, подобный подход оказывается чрезмерно расточительным. Да, возможно, этот метод работает достаточно быстро, но 99.85% области памяти массива пребывает пустой. Впадая в другую крайность, можно было бы выделить только необходимый объем памяти, выделяя массив требуемого размера, храня элементы в отсортированном порядке и используя бинарный поиск. Согласен, этот метод работает медленнее, но зато отсутствует бесполезно расходуемая память. Хеширование и хеш-таблицы позволяют выбрать золотую середину между этими двумя диаметрально противоположными подходами. Хеш-таблицы будут занимать больше места, причем некоторые элементы окажутся пустыми, тем не менее, использование функции хеширования позволяет найти элемент в результате очень небольшого числа обращений — обычно одного при тщательном выполнении хеширования.

Время от времени, с хеш-таблицами придется выполнять следующие операции:

* вставлять элементы в хеш-таблицу;

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

* удалять элементы из хеш-таблицы.

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

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

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

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

Функции хеширования

Алгоритм, который необходимо рассмотреть в первую очередь, — функция хеширования. Это подпрограмма, которая будет принимать ключ элемента и магическим образом преобразовывать его в значение индекса. Очевидно, что если в хеш-таблице предусмотрено место для n элементов, то функция хеширования должна создавать значения индексов, лежащие в диапазоне от 0 до n -1 (как обычно, мы будем предполагать, что значения индексов начинаются с 0).

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

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

Простейший случай — использование целочисленный ключей, когда элемент уникально идентифицируется целочисленным значением. Простейшей функцией хеширования, которую можно было бы использовать в этом случае, является операция деления по модулю. Если хеш-таблица содержит n элементов, хеш-значение ключа k вычисляется путем вычисления k по модулю n (если результат этой операции оказывается отрицательным, нужно просто добавить к нему n). Например, если n равно 16, то ключу 6 будет соответствовать индекс 6, ключу 44 — индекс 12 и т.д. В случае равномерного распределения значений ключей эта функция вполне подходила бы для работы, но в общем случае множество значений ключей не столь равномерно распределенное, и поэтому в качестве размера хеш-таблицы необходимо использовать простое число.

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

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

Так как же преобразовать строку в целочисленное значение? Один из возможных способов предполагает использование длины строкового ключа. Преимущество применения этого метода состоит в простоте и высокой скорости выполнения. Однако его недостатком является генерирование множества конфликтов. На практике таких конфликтов возникает слишком много. Например, предположим, что нужно создать хеш-таблицу, которая должна содержать названия альбомов коллекции компакт-дисков. В частности, в принадлежащей автору коллекции компакт-дисков, насчитывающей несколько сот наименований, названия подавляющего большинства альбомов содержат от 2 до 20 символов. Использование длины названия альбома привело бы к возникновению множества конфликтов: альбом Bilingual в исполнении Pet Shop Boys конфликтовал бы с Technique в исполнении New Order и с Mind Bomb в исполнении The The. Таким образом, подобная функция хеширования совершенно неприемлема.

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

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

Простая функция хеширования для строк

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

Листинг 7.1. Простая функция хеширования строковых ключей

function TDSimpleHash( const aKey : string;

aTableSize : integer): integer;

for i := 1 to length (aKey) do

Hash := ((Hash * 17) + ord(aKey[i])) mod aTableSize;

if (Result 0) then

Hash := (Hash xor (G shr 24)) xor G;

Result := Hash mod aTableSize;

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

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

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

Но предположим, что у нас имеется известный список 100 строковых ключей. Существует ли какая-либо функция хеширования, которая будет генерировать уникальное хеш-значение для каждого из этих известных ключей, чтобы можно было разработать хеш-функцию, содержащую ровно 100 элементов? Функции хеширования такого типа называют совершенными. Безусловно, теоретически это возможно. Существует очень много таких функций (по существу, это равнозначно определению перестановок исходных ключей). Но как найти одну из таких функций? К сожалению, ответ на данный вопрос выходит за рамки этой книги. Даже Кнут (Knuth) [13] обходит эту тему. На практике совершенные функции хеширования представляют лишь теоретический интерес. Как только возникает потребность в другом ключе, совершенная функция хеширования разрушается и нам приходится разрабатывать следующую. Значительно удобнее считать, что никаких совершенных функций хеширования не существует, и иметь дело с неизбежными конфликтами, которые будут периодически возникать.

Разрешение конфликтов посредством линейного зондирования

Если количество элементов, которые, скорее всего, должна содержать хеш-таблица, известно, можно выделить место для хеш-таблицы, содержащей это количество элементов и небольшое число свободных ячеек «на всякий случай». Было разработано несколько алгоритмов, которые позволяют хранить элементы в таблице, используя пустые ячейки таблицы для хранения элементов, которые конфликтуют с уже имеющимися. Этот класс алгоритмов называют схемами с открытой адресацией (open-addressing schemes). Простейшая схема с открытой адресацией — это линейное зондирование (linear probing).

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

Для начала вставим в пустую хеш-таблицу фамилию «Smith» (т.е. вставим элемент, ключом которого является «Smith»). Выполним хеширование ключа Smith с помощью функции хеширования и получим значение индекса, равное 42. Установим значение 42-го элемента хеш-таблицы равным Smith. Теперь записи хеш-таблицы вблизи этого элемента выглядят следующим образом:

Элемент 42: Smith

Это было достаточно просто. Теперь вставим фамилию «Jones». Необходимо выполнить те же действия, что и в предыдущем случае: следует вычислить хеш-значение ключа Jones, а затем вставить значение Jones по результирующему индексу. К сожалению, используемая функция хеширования имеет неизвестное происхождение и для фамилии Jones генерирует хеш-значение, которое также равно 42. Если теперь обратиться к хеш-таблице, выясняется, что имеет место конфликт: ячейка 42 уже занята фамилией Smith. Что же делать? Используя линейное зондирование, мы проверяем следующую ячейку, чтобы выяснить, пуста ли она. Если да, то мы устанавливаем значение 43-го элемента хеш-таблицы равным Jones. (Если бы 43-я ячейка оказалась занятой, пришлось бы проверить следующую ячейку и т.д., возвращаясь к началу хеш-таблицы по достижении ее конца. Со временем мы нашли бы пустую ячейку либо вернулись бы к исходному состоянию, выяснив, что таблица заполнена.) Действие по проверке ячейки в хеш-таблице называется зондированием (probing), отсюда и название самого алгоритма — линейное зондирование.

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

Элемент 42: Smith

Элемент 43: Jones

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

А как насчет поиска элемента, который отсутствует в таблице? Выполним поиск элемента «Brown». Реализуем хеширование, в результате чего будет получено значение индекса, равное 43. При обращении к 43-му элементу выясняется, что он соответствует элементу Jones. При переходе к следующему, 44-му, элементу выясняется, что он пуст. Теперь можно сделать вывод, что элемент Brown в хеш-таблице отсутствует.

Преимущества и недостатки линейного зондирования

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

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

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

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

После вставки второго элемента возможны три ситуации: два элемента образуют кластер, два элемента разделены одной пустой ячейкой или два элемента разделены более чем одной пустой ячейкой. Вероятности этих трех ситуаций соответственно равны 3/n, 2/n и (n — 5)/n.

Вставим третий элемент. В первом случае это может привести к увеличению размера кластера с вероятность 4/n. Во втором случае это может привести к образованию кластера с вероятностью 5/n. В третьем случае это может привести к образованию кластера с вероятностью 6/n. Продолжая такие логические рассуждения, мы приходим к выводу, что вероятность образования кластера после вставки трех элементов равна 6/n — 8/n(^2^), что приблизительно в два раза больше предыдущего значения вероятности. Можно было бы продолжить вычисление вероятностей для все большего количества элементов, но это лишено особого смысла. Вместо этого обратите внимание, что при вставке элемента и при наличии кластера из двух элементов вероятность увеличения этого кластера равна 4/n. При наличии кластера с тремя элементами вероятность его увеличения возрастает до 5/n и т.д.

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

Кластеры влияют на среднее количество зондирований, требуемых как для обнаружения существующего элемента (попадания), так и для выяснения того, что элемент в хеш-таблице отсутствует (промаха). Кнут показал, что среднее количество зондирований для обнаружения попадания приблизительно равно 1/2(1 + 1/(1 -x)), где x — количество элементов в хеш-таблице, деленное на размер хеш-таблицы (эту величину называют коэффициентом загрузки (load factor)), а среднее количество зондирований для обнаружения промаха приблизительно равно 1/2(1 + 1/(1 -x)(^2^)) [13]. Несмотря на простоту этих выражений, математические выкладки, приводящие к их получению, весьма сложны.

Используя приведенные формулы, можно показать, что если хеш-таблица заполнена примерно наполовину, для обнаружения попадания требуется в среднем приблизительно 1.5 зондирования, а для обнаружения промаха — 2.5 зондирования. Если же таблица заполнена на 90%, для обнаружения попадания требуется в среднем 5.5 зондирований, а для обнаружения промаха — 55.5 зондирований. Как видите, при использовании хеш-таблицы, в которой в качестве схемы разрешения конфликтов применяется линейное зондирование, таблица должна быть заполнена не более чем на две трети, чтобы эффективность оставалась приемлемой. Если это удастся, мы снизим влияние, которое кластеризация оказывает на эффективность хеш-таблицы.

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

Удаление элементов из хеш-таблицы с линейным зондированием

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

Предположим, что функция хеширования для ключей Smith, Jones и Brown создает следующие хеш-значения: 42, 42 и 43. Их добавление в хеш-таблицу в указанном порядке приводит к возникновению ситуации, показанной ниже:

Элемент 42: Smith

Элемент 43: Jones

Элемент 44: Brown

Иначе говоря, элемент Smith вставляется непосредственно в ячейку 42, элемент Jones вступает в конфликт с элементом Smith и попадает в ячейку 43, а элемент Brown вступает в конфликт с элементом Jones и попадает в ячейку 44.

Удалим элемент Jones, используя предложенный алгоритм удаления. В результате возникнет следующая ситуация:

Элемент 42: Smith

Элемент 44: Brown

Теперь возникает проблема: попытайтесь найти элемент Brown. Ему соответствует индекс 43. Однако при просмотре ячейки 43 она оказывается пустой и, в соответствии с применяемым алгоритмом поиска, это означает, что элемент Brown в хеш-таблице отсутствует. Разумеется, это неверно.

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

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

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

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

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

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

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

Класс хеш-таблиц с линейным зондированием

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

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

TtdHashFunc = function ( const aKey : string;

aTableSize : integer): integer;

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

Листинг 7.3. Хеш-таблица линейного зондирования TtdHashTableLinear

procedure htlAlterTableSize(aNewTableSize : integer);

procedure htlError(aErrorCode : integer;

const aMethodName : TtdNameString);

function htlIndexOf( const aKey : string; var aSlot : pointer): integer;

constructor Create(aTableSize : integer;

destructor Destroy; override;

procedure Delete(const aKey : string);

function Find(const aKey : string; var aItem : pointer): boolean;

procedure Insert(const aKey : string; aItem : pointer);

property Count : integer read FCount;

property Name : TtdNameString read FName write FName;

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

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

THashSlot = packed record


Ячейка представляет собой запись с тремя полями: ключом, собственно элементом и состоянием ячейки (независимо от того, используется оно или нет). В Delphi1 ключ — это указатель строки, в то время как в последующих версиях он является длинной строкой (которая, естественно, представляет собой замаскированный указатель).

Конструктор Create выделяет экземпляр списка записей, а деструктор Destroy освобождает его.

Листинг 7.4. Конструктор и деструктор класса TtdHashTableLinear

constructor TtdHashTableLinear.Create( aTableSize : integer;

if not Assigned(aHashFunc) then

FTable.Name := ClassName + 1 : hash table1;

if (FTable <> nil) then begin

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

Рассмотрим вставку нового элемента. Метод Insert принимает ключ элемента и сам элемент и добавляет их в хеш-таблицу.

Листинг 7.5. Вставка элемента в хеш-таблицу с линейным зондированием

procedure TtdHashTableLinear.Insert(const aKey : string; aItem : pointer);

if (htlIndexOf (aKey, Slot) <> -1) then

if (Slot = nil) then

with PHashSlot (Slot)^ do

if ((FCount * 3) > (FTable.Count * 2)) then

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

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

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

Листинг 7.6. Удаление элемента из хеш-таблицы с линейным зондированием

procedure TtdHashTableLinear.Delete(const aKey : string);

Inx := htlIndexOf(aKey, ItemSlot);

with PHashSlot (ItemSlot)^ do

if Assigned(FDispose) then

if (Inx = FTable.Count) then

while Slot^.hsInUse do

if (Inx = FTable.Count) then

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

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

Метод Clear очень похож на метод Delete. Он используется для удаления всех элементов из хеш-таблицы.

Листинг 7.7. Опустошение хеш-таблицы с линейным зондированием

for Inx := 0 to pred(FTable.Count) do

with PHashSlot (FTable [Inx])^ do

if hsInUse then begin

if Assigned(FDispose) then

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

Поиск элемента по его ключу выполняется методом Find уверен, что после ознакомления с методами Insert и Delete читатели догадываются, что это — всего лишь вызовы пресловутого метода htlIndexOf.

Листинг 7.8. Поиск элемента в хеш-таблице по ключу

function TtdHashTableLinear.Find(const aKey : string; var aItem : pointer): boolean;

if (htlIndexOf (aKey, Slot)o-1) then begin

Как видите, все достаточно просто.

Методы, которые выполняют увеличение хеш-таблицы, используют еще один, метод — htlAlterTableSize. Код обоих методов выглядит следующим образом.

Листинг 7.9. Изменение размера хеш-таблицы с линейным зондированием

procedure TtdHashTableLinear.htlAlterTableSize(aNewTableSize : integer);

for Inx := 0 to pred(OldTable.Count) do

with PHashSlot (OldTable [ Inx])^ do

if (hsState = hssInUse) then begin

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

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

Теперь пора разобраться с последним фрагментом головоломки: рассмотреть «закулисный» метод htlIndexOf — примитив, используемый методами Insert, Delete и Find.

Листинг 7.10. Примитив поиска ключа в хеш-таблице

function TtdHashTableLinear.htlIndexOf(const aKey : string; var aSlot : pointer): integer;

Inx := FHashFunc(aKey, FTable.Count);

with CurSlot^ do

if not hsInUse then begin

if (hsKey^ = aKey) then begin

if (hsKey = aKey) then begin

if (Inx = FTable.Count) then

if (Inx = First Inx) then begin

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

Метод определяет указатель на начальную ячейку. Мы просматриваем ячейку и выполняем различные операции, зависящие от состояния ячейки. Первый случай — когда ячейка пуста. Достижение этой точки означает, что ключ не был найден, поэтому метод возвращает указатель именно на эту ячейку. Естественно, в этом случае возвращаемое значение функции равно -1, что означает «ключ не найден».

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

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

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

Полный вариант кода класса TtdHashTableLinear можно найти на Web-сайте издательства, в разделе материалов. После выгрузки материалов отыщите среди них файл TDHshLnP.pas.

Другие схемы открытой адресации

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

Квадратичное зондирование

Первая из таких схем — квадратичное зондирование (quadratic probing). При использовании этого алгоритма контроль и предотвращение создания кластеров осуществляется путем проверки не следующей по порядку ячейки, а ячеек, которые расположены все дальше от исходной. Если первое зондирование оказывается безрезультатным, мы проверяем следующую ячейку. В случае неудачи этой попытки мы проверяем ячейку, которая расположена через четыре ячейки. Если и эта попытка неудачна, мы проверяем ячейку, расположенную через девять ячеек — и т.д., причем последующие зондирования выполняются для ячеек, расположенных через 16, 25, 36 и так далее ячеек. Этот алгоритм позволяет предотвратить образование кластеров, которые могут появляться в результате применения линейного зондирования, однако он может приводить и к ряду нежелательных проблем. Во-первых, если для многих ключей хеширование генерирует один и тот же индекс, все их последовательности зондирования должны будут выполняться вдоль одного и того же пути. В результате они образуют кластер, но такой, который кажется распределенным по хеш-таблице. Однако вторая проблема значительно серьезнее: квадратичное зондирование не гарантирует посещение всех ячеек. Максимум в чем можно быть уверенным, если размер таблицы равен простому числу, это в том, что квадратичное зондирование обеспечит посещение, по меньшей мере, половины ячеек хеш-таблицы. Таким образом, образом, можно говорить о выполнении задачи-минимум, но не задачи-максимум.

В этом легко убедиться. Начнем квадратичное зондирование с 0-й ячейки хеш-таблицы, содержащей 11 ячеек, и посмотрим, какие ячейки будут посещены при этом. Последовательность посещений выглядит следующим образом: 0, 1, 5, 3, 8, после чего зондирование снова начинается с ячейки 0. Мы никогда не посещаем ячейки 2, 4, 7, 9. По-моему, одной этой проблемы достаточно, чтобы в любом случае избегать применения квадратичного зондирования, хотя ее можно было бы избегнуть, не позволяя хеш-таблице заполняться более чем на половину.

Псевдослучайное зондирование

Следующая возможность — применение псевдослучайного зондирования (pseudorandom probing). Этот алгоритм требует использования генератора случайных чисел, который можно сбрасывать в определенный момент. Применительно к рассматриваемому алгоритму, из числа рассмотренных в 6 главе генераторов наиболее подошел бы минимальный стандартный генератор случайных чисел, поскольку его состояние однозначно определяется одним характеристическим значением — начальным числом. Алгоритм определяет следующую последовательность действий. Выполните хеширование ключа для получения хеш-значения, но не выполняйте деление по модулю на размер таблицы. Установите начальное значение генератора равным этому хеш-значению. Сгенерируйте первое случайное число с плавающей точкой (в диапазоне от 0 до 1) и умножьте его на размер таблицы для получения целочисленного значения в диапазоне от 0 до размера таблицы минус 1. Эта точка будет точкой первого зондирования. Если ячейка занята, сгенерируйте следующее случайное число, умножьте его на размер таблицы и снова выполните зондирование. Продолжайте выполнять упомянутые действия до тех пор, пока не найдете свободную ячейку. Поскольку при одном и том же заданном начальном значении генератор случайных чисел будет генерировать одни и те же случайные числа в одной и той же последовательности, для одного и того же хеш-значения всегда будет создаваться одна и та же последовательность зондирования.

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

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

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

В заключение рассмотрим двойное хеширование (double hashing). На практике эта схема оказывается наиболее удачной из всех альтернативных схем с открытой адресацией. Итак, выполним хеширование ключа элемента в значение индекса. Назовем его h(_1_). Выполним зондирование этой ячейки. Если она занята, выполним хеширование ключа путем применения совершенно иного и независимого алгоритма хеширования для получения другого значения индекса. Назовем его h(_2_). Выполним зондирование ячейки h(_1_) + h(_2_). Если она занята, выполним зондирование ячейки h(_1_) + 2h(_2_), затем h(_1_) + 3h(_2_) и так далее (понятно, что все вычисления выполняются с делением по модулю на размер таблицы). Обоснование этого алгоритма следующее: если первая функция хеширования для двух ключей генерирует один и тот же индекс, очень маловероятно, что вторая функция хеширования сгенерирует для них то же самое значение. Таким образом, два ключа, которые первоначально хешируются в одну и ту же ячейку, затем не будут соответствовать одной и той же последовательности зондирования. В результате мы можем ликвидировать «неизбежную» кластеризацию, сопряженную с линейным зондированием. Если размер таблицы равен простому числу, последовательность зондирования обеспечит посещение всех ячеек, прежде чем начнется сначала, что позволит избежать проблем, связных с квадратичным и псевдослучайным зондированием. Единственная реальная проблема, возникающая при использовании двойного хеширования, — если не принимать во внимание необходимость вычисления дополнительного хеш-значения — состоит в том, что вторая функция хеширования по понятным причинам никогда не должна возвращать значение, равное 0. На практике эту проблему легко решить, выполняя деление по модулю на размер таблицы минус 1 (в результате мы получим значение в диапазоне от 0 до TableSize-2), а затем добавляя к результату единицу.

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

Разрешение конфликтов посредством связывания

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

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

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

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

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

Преимущества и недостатки связывания

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

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

Стоит отметить еще ряд обстоятельств. При использовании алгоритма разрешения конфликтов линейного зондирования мы сознательно старались минимизировать количество выполняемых зондирований, расширяя хеш-таблицу, когда ее коэффициент загрузки начинал превышать две третьих. Как следует из результатов анализа, в этой ситуации для успешного поиска должно в среднем требоваться два зондирования, а для безрезультатного — пять. Подумайте, что означает зондирование. По существу, это сравнение ключей. Весь смысл применения хеш-таблицы заключался в уменьшении количества сравнений ключей до одного или двух. В противном случае вполне можно было бы выполнить бинарный поиск в отсортированном массиве строк. Что ж, при использовании связывания для разрешения конфликтов каждый раз, когда мы спускаемся по связному списку, пытаясь найти конкретный ключ, для этого мы используем сравнение. Если прибегнуть к терминологии метода с открытой адресацией, то каждое сравнение можно сравнить с «зондированием». Так сколько же зондирований в среднем требуется для успешного поиска при использовании связывания? Для алгоритма связывания коэффициент загрузки по-прежнему вычисляется как число элементов, деленное на число ячеек (хотя на этот раз оно может иметь значение больше 1.0), и его можно представить средней длиной связных списков, присоединенных к ячейкам хеш-таблицы. Если коэффициент загрузки равен F, то среднее число зондирований для успешного поиска составит F/2. Для безрезультатного поиска среднее число зондирований равно F. (Эти результаты справедливы для несортированных связных списков. Если бы связные списки были отсортированы, значения были бы меньше — исходя из теории, оба значения нужно разделить на log(_2_)(F))

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

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

Класс связных хеш-таблиц

Теперь пора рассмотреть какой-нибудь код. Общедоступный интерфейс к классу TtdHashTableChained в общих чертах не отличается от такового для класса TtdHashTableLinear. Различия между двумя этими классами проявляются в разделах private и protected.

Листинг 7.11. Класс TtdHashTableChained

procedure htcSetMaxLoadFactor(aMLF : integer);

procedure htcAllocHeads(aTable : TList);

procedure htcAlterTableSize(aNewTableSize : integer);

procedure htcError(aErrorCode : integer;

const aMethodName : TtdNameString);

function htcFindPrim(const aKey : string;

var aInx : integer; var aParent : pointer): boolean;

procedure htcFreeHeads(aTable : TList);

constructor Create(aTableSize : integer;

aHashFunc : TtdHashFunc; aDispose : TtdDisposeProc);

destructor Destroy; override;

procedure Delete(const aKey : string);

function Find(const aKey : string; var aItem : pointer): boolean;

procedure Insert(const aKey : string; aItem : pointer);

property Count : integer read FCount;

property MaxLoadFactor : integer

read FMaxLoadFactor write htcSetMaxLoadFactor;

property Name : TtdNameString read FName write FName;

property ChainUsage : TtdHashChainUsage

read FChainUsage write FChainUsage;

Мы объявили небольшой перечислимый тип TtdHashChainUsage для указания того, выполняется ли вставка элементов в начало или в конец связного списка. Класс содержит свойство ChainUsage, которое указывает, какой метод следует использовать.

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

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

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

Если внимательно присмотреться к коду, то мы увидим, что в нем используется хорошо известный нам класс TtdNodeManager (как именно — будет показано вскоре). Конструктор Create, как и TList, будет выделять один экземпляр этого класса. Деструктор Destroy будет освобождать оба эти экземпляра.

Листинг 7.12. Конструктор и деструктор класса TtdHashTableChained

constructor TtdHashTableChained.Create(aTableSize : integer;

if not Assigned(aHashFunc) then

if (FTable <> nil) then begin

Созданный нами диспетчер узлов предназначен для работы с узлами THashItem. Он определяет структуру записей этого типа. Эта структура во многом аналогична структуре записей класса TtdHashLinear, за исключением того, что требуется связное поле и не требуется поле «используется» (все элементы в связном списке «используются» по определению;

удаленные из хеш-таблицы элементы в связном списке отсутствуют).

THashedItem = packed record

Конструктор вызывает метод htcAllocHeads для создания первоначально пустой хеш-таблицы. Вот что должно при этом происходить. Каждая ячейка в хеш-таблице будет содержать указатель на односвязный список (поскольку каждая ячейка содержит только указатель, для хранения хеш-таблицы можно воспользоваться TList). Для упрощения вставки и удаления элементов мы выделяем заглавные узлы для каждого возможного связного списка, как было описано в главе 3. Естественно, деструктор должен освобождать эти заглавные узлы — данная операция выполняется при помощи метода htcFreeHeads.

Листинг 7.13. Выделение и освобождение заглавных узлов связных списков

procedure TtdHashTableChained.htcAllocHeads(aTable : TList);

for Inx := 0 to pred(aTable.Count) do

procedure TtdHashTableChained.htcFreeHeads(aTable : TList);

for Inx := 0 to pred(aTable.Count) do

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

Листинг 7.14. Вставка нового элемента в хеш-таблицу со связыванием

procedure TtdHashTableChained.Insert(const aKey : string; aItem : pointer );

if htcFindPrim(aKey, Inx, Parent) then

NewNode^.hi Item := aItem;

if (FCount > (FMaxLoadFactor * FTable.Count)) then

Прежде всего, мы вызываем подпрограмму htcFindPrim. Она выполняет такую же операцию, как и htllIndexOf, при использовании линейного зондирования: предпринимает попытку найти ключ и возвращает индекс ячейки, в которой он был найден. Однако этот метод создан с учетом применения связных списков. Если поиск ключа выполняется успешно, метод возвращает значение «истина», а также индекс ячейки хеш-таблицы и указатель на родительский узел элемента в связном списке. Почему родительский? Что ж, если вспомнить сказанное в главе 3, основные операции с односвязным списком предполагают вставку и удаление узла за данным узлом. Следовательно, целесообразнее, чтобы метод htcFindPrim возвращал родительский узел узла, в который выполняется вставка.

Если ключ не найден, метод htcFindPrlm возвращает значение «ложь» и индекс ячейки, в которую нужно вставить элемент, и родительский узел, за которым его можно успешно вставить.

Итак, вернемся к методу Insert. ЕстестЁенно, если ключ был найден, метод генерирует ошибку. В противном случае мы выделяем новый узел, устанавливаем элемент и ключ, а затем вставляем элемент непосредственно за данным узлом.

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

Как легко догадаться, метод Delete работает аналогично.

Листинг 7.15. Удаление элемента из хеш-таблицы со связыванием

procedure TtdHashTableChained.Delete(const aKey : string);

if not htcFindPrim(aKey, Inx, Parent) then

if Assigned(FDispose) then

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

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

Листинг 7.16. Очистка хеш-таблицы TtdHashTableChained

Temp, Walker : PHashedItem;

for Inx := 0 to pred(FTable.Count) do

while (Walker <> nil) do

if Assigned(FDispose) then

Метод Find прост, поскольку основная часть работы выполняется вездесущим методом htcFindPrim.

Листинг 7.17. Поиск элемента в хеш-таблице со связыванием

function TtdHashTableChained.Find(const aKey : string; var aItem : pointer): boolean;

if htcFindPrim(aKey, Inx, Parent) then begin

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

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

Листинг 7.18. Увеличение хеш-таблицы со связыванием

procedure TtdHashTableChained.htcAlterTableSize(aNewTableSize : integer);

Walker, Temp : PHashedItem;

for Inx := 0 to pred(OldTable.Count) do

while (Walker <> nil) do

for Inx := 0 to pred(01dTable.Count) do

while (Walker <> nil) do

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

В заключение рассмотрим основной метод, используемый многими методами класса хеш-таблицы — htcFindPrim (листинг 7.19).

Листинг 7.19. Примитив для поиска элемента в хеш-таблице со связыванием

function TtdHashTableChained.htcFindPrim( const aKey : string;

var aInx : integer;

var aParent : pointer): boolean;

Head, Walker, Parent : PHashedItem;

Inx := FHashFunc(aKey, FTable.Count);

while (Walker <> nil) do

if (Walker^.hiKey^ = aKey) then begin

if (Walker^.hiKey = aKey) then begin

if (ChainUsage = hcuFirst) and (Parent = Head) then begin

if ChainUsage = hcuLast then

aParent := Parent else

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

Если ключ не был найден, мы возвращаем узел в конце списка или заглавный узел — это определяется свойством ChainUsage. Если его значение установлено равным hcuLast, мы возвращаем последний узел, если оно установлено равным hcuFirst — заглавный узел. Таким образом, если вызывающим методом был метод Insert, можно быть уверенным, что новый элемент будет вставлен в требуемое место. Метод возвращает также индекс ячейки.

Если ключ был найден и значением свойства ChainUsage является hcuFirst, необходимо воспользоваться методологией «перемещения в начало» и переместить найденный элемент в первую позицию связного списка. Конечно, в случае использования односвязного списка эта операция проста и эффективна. И, наконец, мы возвращаем родительский узел и индекс ячейки.

Полный исходный код класса TtdHashTableChained можно найти на Web-сайте издательства, в разделе материалов. После выгрузки материалов отыщите среди них файл TDHshChn.pas.

Разрешение конфликтов посредством группирования

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

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

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

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

Вторая возможность заключается в использовании групп переполнения. В этом случае хеш-таблица содержит дополнительную группу, которая не используется при обычном применении хеш-таблицы. Эту группу называют группой переполнения (overflow bucket). Если при вставке элемента в группе места под него не оказывается, мы ищем пустой элемент в группе переполнения и вставляем элемент туда. Таким образом, группа переполнения содержит элементы переполнения всех обычных групп. Если сама группа переполнения заполняется, мы просто выделяем еще одну группу и продолжаем выполнять описанные операции. Поиск элемента в этой структуре данных предполагает просмотр каждого элемента в группе, в которую был хеширован ключ, и, если она заполнена, — просмотр каждого элемента в каждой группе переполнения, пока не будет найден пустой элемент. Удаление элемента из такой хеш-таблицы настолько не эффективно, что может оказаться вообще невозможным. Единственный целесообразный метод удаления — пометка элементов как удаленных. В противном случае, при необходимости удалить элемент из правильно заполненной группы придется повторно вставить каждый элемент, который присутствует в группах переполнения.

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

Хеш-таблицы на диске

Контроллеры для таких устройств постоянного хранения данных, как жесткие и гибкие диски, дисководы Iomega Zip и ленточные накопители разработаны для поблочного считывания и записи данных. Обычно размер этих блоков равен какой-то степени двойки, например, 512, 1024 или 4096 байт. Поскольку контроллер должен выполнить считывание всего блока даже в том случае, когда требуется всего несколько байт, имеет смысл попытаться извлечь выгоду из подобного поведения.

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

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

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

Прежде всего, создадим файл данных, состоящий из множества записей одинакового размера, каждая из которых описывает отдельный элемент. Естественно, для этого мы будем использовать класс TtdRecordFile, описанный в главе 2.

Файл индексов — это, по сути дела, второй файл базы данных хеш-информации. Как и в предыдущем случае, нам не нужно считывать в память весь файл индексов. Например, если бы каждый ключ содержал 10 цифр, а связанный с каждым ключом номер записи имел бы длину, равную 4 байтам, для хранения одного ключа требовалось бы 15 байт (исходя из предположения, что ключ содержит либо ноль в качестве символа-ограничителя, либо байт-префикс, определяющий его длину). Если бы хеш-таблица содержала 100 000 элементов, то для хранения ее индексов в памяти потребовалось бы минимум 1 500 000 байт. Разумеется, мы еще и выделяем дополнительную память под хранение строк ключей хеш-таблицы в куче, что приведет к еще большим накладным расходам (например, в 32-разрядной системе каждая строка кучи содержит три дополнительных символа типа longint). Значительно целесообразнее было бы считывать фрагменты индекса, когда в них возникает необходимость.

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

Расширяемое хеширование

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

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

Теперь функция хеширования будет возвращать значение типа longint. Если вернуться к первоначальной хеш-функции PJW, можно убедиться, что она вычисляла 32-разрядное хеш-значение (фактически, 28-разрядное значение, поскольку значения четырех старших разрядов всегда устанавливались равными 0), а затем выполнялось деление по модулю этого значения на размер таблицы. При использовании расширяемого хеширования заключительное деление по модулю не выполняется. Вместо этого мы используем все хеш-значение полностью.

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

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

Начнем вставлять в таблицу хеш-значения вместе с номерами их записей. При наличии только одной группы их можно вставить только в одно место, поэтому после 10 вставок группа заполняется. Разобьем заполненную группу на две группы одинаковых размеров и повторим вставку всех элементов исходной группы в две новые группы. Причем все элементы, которые завершаются нулевым разрядом, поместим в одну группу, а завершающиеся единичным разрядом — в другую. Эти две группы имеют так называемую разрядную глубину (bit-depth), равную одному разряду. Теперь при каждой вставке пары хеш-значение/номер записи она будет помещаться в первую или во вторую группу, в зависимости от последнего разряда хеш-значения.

Со временем мы заполним еще одну группу. Предположим, что это группа, в которую мы вставляли все хеш-значения, завершающиеся 0. Снова разобьем группу на две отдельные группы. На этот раз все элементы, хеш-значения которых заканчиваются двумя нулевыми разрядами, т.е. 00, будут помещаться в первую группу, а завершающиеся разрядами 10 — во вторую группу. Обе группы имеют разрядную глубину, равную 2. Поэтому для определения места вставки необходимо проверять два младших разряда хеш-значения. Теперь у нас имеются три группы: в первую вставляются элементы, завершающиеся разрядами 00, во вторую -разрядами 10, а в третью — просто 1.

Предположим, что мы продолжаем вставку и заполняем группу 10. Мы снова разбиваем заполненную группу на две и повторяем вставку ее элементов в две новые группы. На этот раз две новые группы будут принимать элементы, завершающиеся разрядами 010 и 110. Таким образом, теперь у нас имеются четыре группы: одна с разрядной глубиной, равной 1, в которую выполняется вставка хеш-значений, завершающихся 1, одна с разрядной глубиной равной 2, содержащая хеш-значения, которые завершаются разрядами 00, и две группы с разрядной глубиной, равной 3, которые предназначены для хеш-значений, завершающихся разрядами 010 и 110.

Почему-то есть уверенность, что читатели уже получили представление о работе расширяемого хеширования, — все остальное не представляет сложности.

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

В рассмотренном нами примере максимальная разрядная глубина группы была равна 3, поэтому разрядная глубина каталога также равна этому значению. Три разряда позволяют образовать восемь комбинаций разрядов: 000, 001, 010, 011, 100, 101, 110 и 111. Все комбинации, которые завершаются 1 (т.е. вторая, четвертая, шестая и восьмая), указывают на одну и ту же группу, принимающую элементы, хеш-значения которых завершаются 1. Аналогично, записи каталога для значений 000 и 100 указывают на одну и ту же группу, в которую помещаются элементы с хеш-значениями, завершающимися разрядами 00.

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

Для достижения этого следует инвертировать последние разряды хеш-значения при вычислении индексной записи каталога. Так, например, если хеш-значение завершается разрядами 001, при поиске мы обратимся не к записи 001 каталога, а к записи 100 (4, которая соответствует инвертированному значению 001). В результате использование каталога значительно упрощается. В нашем примере хеш-значения, которые завершаются разрядами 00, помещаются в запись каталога 000 (0) или 001 (1). Хеш-значения, которые завершаются разрядами 010, помещаются в запись каталога 010 (2). Хеш-значения, которые завершаются разрядами 011, помещаются в запись каталога 011 (3). И, наконец, хеш-значения, которые завершаются разрядом 1, помещаются в записи 100, 101, 110 или 111 (4, 5, 6, 7).

Вернемся немного назад, и вставим элементы в пустую хеш-таблицу, как это было сделано ранее. Выполняемые при этом действия показаны на рис. 7.1. Мы начинаем с каталога только с одной записью с индексом 0 (а). Принято считать, что в подобной ситуации разрядная глубина равна 0. Мы заполняем единственную группу (назовем ее А) и теперь ее нужно разбить. Вначале мы увеличиваем разрядную глубину каталога до 1. Иначе говоря, теперь он будет содержать две записи (b). В результате будут созданы две группы, на первую из которых указывает запись 0 (исходная запись А), а на вторую — запись 1, В (с). Все элементы, хеш-значения которых завершаются разрядом 0, помещаются в группу А, а остальные — в группу В. Снова заполним группу A. Теперь разрядную глубину каталога необходимо увеличить с 1 до 2, чтобы получить четыре группы, доступных для вставки. Перед разделением заполненной группы записи каталога 00 и 01 будут указывать на исходную группу А, а записи 10 и 11 — на группу В (d). Группа А разбивается на группу, которая принимает хеш-значения с окончанием 00 (снова А), и группу, которая принимает хеш-значения с окончанием 10, С. На группу А будет указывать запись 00 каталога, а на группу С — запись 01 (e). И, наконец, группа С (на которую указывает запись 01 каталога) заполняется. Нужно снова увеличить разрядную глубину каталога, на этот раз до трех разрядов.

Рисунок 7.1.Вставка в расширяемую хеш-таблицу

Теперь записи 000 и 001 указывают на запись А, записи 010 и 011- на группу С, а 100, 101, 110 и 111 — на группу В (f). Мы создаем новую группу D и повторяем вставку всех элементов группы С в группы С и D, причем первая группа, которой соответствует запись каталога 010 (2), принимает хеш-значения с окончанием 010, а вторая, которой соответствует запись каталога 011 (3), — хеш-значения с окончанием 110 (g).

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

Извлечение и реализация каталога — следующая по сложности задача. Код интерфейса для ее выполнения приведен в листинге 7.20.

Листинг 7.20. Интерфейс класса TtdHashDirectory

Python алгоритмы

Блог про алгоритмы и все что с ними связано. Основной инструмент реализации — Python.

четверг, 16 июня 2011 г.

Представление множеств

И хотя в нашем любимом Python’е уже есть такой тип данных, как множества(set), я все таки считаю, Что изобрести велосипед заново, в данном случае будет полезно. Хотя бы чтобы просто получше понять, а как оно там вообще работает?!
Для понимания идеологии советую прочитать «Свойство замыкания на примере list»
*-Нравится статья? Кликни по рекламе! :)
Множество есть просто набор различных объектов. Чтобы дать ему более точное определение, можно использовать метод абстракции данных. А именно, мы определяем «множество», указывая операции, которые можно производить над множествами. Это операции union-set (объединение), intersection-set (пересечение), element_of_set (проверка на принадлежность) и adjoin-set (добавление элемента). Adjoin_set принимает как аргументы объект и множество, и возвращает множество, которое содержит все элементы исходного множества плюс добавленный элемент. Intersection_set вычисляет пересечение двух множеств, то есть множество, которое содержит только те элементы, которые присутствуют в обоих аргументах.

Можно представить множество как список, в котором ни один элемент не содержится более одного раза. Пустое множество представляется пустым списком.
Итак, первая функция — проверка на наличие элемента в множестве:
Используя эту процедуру, мы можем написать adjoin_set. Если объект, который требуется добавить, уже принадлежит множеству, мы просто возвращаем исходное множество. В противном случае мы добавим объект к списку.представляющему множество:
Для intersection_set можно использовать рекурсивную стратегию. Если мы знаем, как получить пересечение set2 и cdr от set1, нам нужно только понять, надо ли добавить к нему car от set1. Это зависит от того, принадлежит ли (car set1) еще и set2. Получается такая процедура:
Один из вопросов, которые должны нас заботить при разработке реализации — эффективность. Рассмотрим число шагов, которые требуют наши операции над множествами. Поскольку все они используют element_of_set, скорость этой операции оказывает большое влияние на скорость реализации в целом. Теперь заметим, что для того, чтобы проверить, является ли объект элементом множества, процедуре element_of_set может потребоваться просмотреть весь список. (В худшем случае оказывается, что объекта в списке нет.) Следовательно, если в множестве n элементов, element_of_set может затратить до n шагов. Таким образом, число требуемых шагов растет как O(n). Число шагов, требуемых adjoin_set, которая эту операцию использует, также
растет как O(n). Для intersection_set, которая проделывает element_of_set для каждого элемента set1, число требуемых шагов растет как произведение размеров исходных множеств, или O(n 2 ) для двух множеств размера n.

Множества как упорядоченные списки
Один из способов ускорить операции над множествами состоит в том, чтобы изменить
представление таким образом, чтобы элементы множества перечислялись в порядке возрастания. Для этого нам потребуется способ сравнения объектов, так, чтобы можно было
сказать, какой из них больше. Например, символы мы могли бы сравнивать лексикографически, или же мы могли бы найти какой-нибудь способ ставить каждому объекту в соответствие некоторое уникальное число и затем сравнивать объекты путем сравнения соответствующих чисел. Чтобы упростить обсуждение, мы рассмотрим только случай, когда элементами множества являются числа, так что мы сможем сравнивать элементы при помощи > и 2 ) шагов, поскольку мы производили полный поиск в set2 для каждого элемента set1. Однако при упорядоченном представлении мы можем воспользоваться более разумным методом. Начнем со сравнения
первых элементов двух множеств, x1 и x2. Если x1 равно x2, мы получаем один элемент пересечения, а остальные элементы пересечения мы можем получить, пересекая
оставшиеся элементы списков-множеств. Допустим, однако, что x1 меньше, чем x2. Поскольку x2 — наименьший элемент set2, мы можем немедленно заключить, что x1
больше нигде в set2 не может встретиться и, следовательно, не принадлежит пересечению. Следовательно пересечение двух множеств равно пересечению set2 с cdr от
set1. Подобным образом, если x2 меньше, чем x1, то пересечение множеств получается
путем пересечения set1 с cdr от set2. Вот процедура:
Чтобы оценить число шагов, необходимое для этого процесса, заметим, что на каждом шагу мы сводим задачу нахождения пересечения к вычислению пересечения меньших множеств — убирая первый элемент либо из set1, либо из set2, либо из обоих. Таким образом, число требуемых шагов не больше суммы размеров set1 и set2, а не их произведения, как при неупорядоченном представлении. Это рост O(n), а не O(n 2 ) — заметное ускорение, даже для множеств небольшого размера.

Множества как бинарные деревья
Можно добиться еще лучших результатов, чем при представлении в виде упорядоченных списков, если расположить элементы множества в виде дерева. Каждая вершина дерева содержит один элемент множества, называемый «входом» этой вершины, и указатели (возможно, пустые) на две другие вершины. «Левый» указатель указывает на элементы, меньшие, чем тот, который содержится в вершине, а «правый» на элементы, большие, чем тот, который содержится в вершине. На рисунке ниже показано несколько вариантов представления множества <1, 3, 5, 7, 9, 11>в виде дерева. Одно и то же множество может быть представлено в виде дерева несколькими различными способами. Единственное, чего мы требуем от правильного представления — это чтобы все элементы левого поддерева были меньше, чем вход вершины, а элементы правого поддеревабольше.

Преимущество древовидного представления следующее. Предположим, мы хотим
проверить, содержится ли в множестве число x. Начнем с того, что сравним x со входом
начальной вершины. Если x меньше его, то мы уже знаем, что достаточно просмотреть
только левое поддерево; если x больше, достаточно просмотреть правое поддерево. Если
дерево «сбалансировано», то каждое из поддеревьев будет по размеру примерно вполовину меньше. Таким образом, за один шаг мы свели задачу поиска в дереве размера n к задаче поиска в дереве размера n/2. Поскольку размер дерева уменьшается вдвое на каждом шаге, следует ожидать, что число шагов, требуемых для поиска в дереве размера n, растет как O(log n). Для больших множеств это будет заметным ускорением по сравнению с предыдущими реализациями.

Деревья мы можем представлять при помощи списков. Каждая вершина будет списком из трех элементов: вход вершины, левое поддерево и правое поддерево. None список на месте левого или правого поддерева будет означать, что в этом месте никакое поддерево не присоединяется.
Пример l=(7, (3,(1, None, None),(5, None, None)), (9,(None, None, None),(11, None, None)))
Можно написать процедуру element_of_set с использованием вышеописанной стратегии:
Где функция car возвращает текущий элемент, cdr — левую ветвь, а cddr — правую.
Добавление элемента к множеству реализуется похожим образом и также требует O(log n) шагов. Чтобы добавить объект x, мы сравниваем его с входом вершины и определяем, должны ли мы добавить x к левой или правой ветви, а добавив x к соответствующей ветви, мы соединяем результат с изначальным входом и второй ветвью.
Если x равен входу, мы просто возвращаем вершину. Если нам требуется добавить x к пустому дереву, мы порождаем дерево, которое содержит x на входе и пустые левое и правое поддеревья. Вот процедура:
Утверждение, что поиск в дереве можно осуществить за логарифмическое число шагов, основывается на предположении, что дерево «сбалансировано», то есть что левое и правое его поддеревья содержат приблизительно одинаковое число элементов, так что каждое поддерево содержит приблизительно половину элементов своего родителя. Но как нам добиться того, чтобы те деревья, которые мы строим, были сбалансированы?

Даже если мы начинаем со сбалансированного дерева, добавление элементов при помощи adjoin_set может дать несбалансированный результат. Поскольку позиция нового добавляемого элемента зависит от того, как этот элемент соотносится с объектами, уже содержащимися в
множестве, мы имеем право ожидать, что если мы будем добавлять элементы «случайным образом», в среднем дерево будет получаться сбалансированным. Однако такой гарантии у нас нет. Например, если мы начнем с пустого множества и будем добавлять по очереди числа от 1 до 7, то получится весьма несбалансированное дерево, показанное на рисунке 2.17. В этом дереве все левые поддеревья пусты, так что нет никакого преимущества по сравнению с
простым упорядоченным списком. Одним из способов решения этой проблемы было бы
определение операции, которая переводит произвольное дерево в сбалансированное с теми же элементами. Тогда мы сможем проводить преобразование через каждые несколько операций adjoin_set, чтобы поддерживать множество в сбалансированном виде. Есть и другие способы решения этой задачи. Большая часть из них связана с разработкой новых структур данных, для которых и поиск, и вставка могут производиться за O(log n) шагов.

Множества и поиск информации
Мы рассмотрели способы представления множеств при помощи списков и увидели, как выбор представления для объектов данных может сильно влиять на производительность программ, использующих эти данные. Еще одной причиной нашего внимания к множествам было то, что описанные здесь методы снова и снова возникают в приложениях, связанных с поиском данных.
Рассмотрим базу данных, содержащую большое количество записей, например, сведения о кадрах какой-нибудь компании или о транзакциях в торговой системе. Как правило, системы управления данными много времени проводят, занимаясь поиском и модификацией данных в записях; следовательно, им нужны эффективные методы доступа к записям. Для этого часть каждой записи выделяется как идентифицирующий ключ (key). Ключом может служить что угодно, что однозначно определяет запись. В случае записей о кадрах это может быть номер карточки сотрудника. Для торговой системы это может быть номер транзакции. Каков бы ни был ключ, когда мы определяем запись в виде структуры данных, нам нужно указать процедуру выборки ключа, котораявозвращает ключ, связанный с данной записью.
Конечно, существуют лучшие способы представить большие множества, чем в виде неупорядоченных списков. Системы доступа к информации, в которых необходим «произвольный доступ» к записям, как правило, реализуются с помощью методов, основанных
на деревьях, вроде вышеописанной системы с бинарными деревьями. При разработке таких систем методология абстракции данных оказывается весьма полезной. Проектировщик может создать исходную реализацию с помощью простого, прямолинейного представления вроде неупорядоченных списков. Для окончательной версии это не подходит, но такой вариант можно использовать как «поспешную и небрежную» реализацию базы данных, на которой тестируется остальная часть системы. Позже представление данных можно изменить и сделать более изощренным. Если доступ к базе данных происходит в терминах абстрактных селекторов и конструкторов, такое изменение представления данных не потребует никаких модификаций в остальной системе.

Илон Маск рекомендует:  Что такое код pdf_translate
Понравилась статья? Поделиться с друзьями:
Кодинг, CSS и SQL